diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index c3912fbdf07de..0000000000000 --- a/.coveralls.yml +++ /dev/null @@ -1,2 +0,0 @@ -service_name: travis-ci -repo_token: bFwjIppLtIFrDeKZf9tynR2ONLGPgdDg6 diff --git a/.dockerignore b/.dockerignore index e757521322df0..840fcde68d78b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,12 @@ .venv* -localstack/node_modules/ -localstack/infra/ -localstack/dashboard/web/node_modules/ + +.filesystem +**/.filesystem + +# ignore files generated in CI build +tests/aws/**/node_modules +tests/aws/**/.terraform +**/__pycache__ +target/ +htmlcov/ +.coverage diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000000..d34a760dc9669 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# format the codebase with black and isort +8c006f122803dd06811343782b34f067f0622baf +# reorganize utility package +a7602821ac8666c9ab3a253457eb99570908ac8e diff --git a/.github/CLA.md b/.github/CLA.md new file mode 100644 index 0000000000000..fb829b3434191 --- /dev/null +++ b/.github/CLA.md @@ -0,0 +1,25 @@ +# Contributor License Agreement (CLA) + +This license is for your protection as a Contributor as well as the protection of the maintainers of the LocalStack software; it does not change your rights to use your own Contributions for any other purpose. In the following, the maintainers of LocalStack are referred to as "LocalStack". + +You accept and agree to the following terms and conditions for Your present and future Contributions submitted to "LocalStack". Except for the license granted herein to LocalSack and recipients of software distributed by "LocalStack", You reserve all right, title, and interest in and to Your Contributions. + +1. Definitions. + + "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with "LocalStack". For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. + + "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to "LocalStack" for inclusion in, or documentation of, any of the products owned or managed by "LocalStack" (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to "LocalStack" or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, "LocalStack" for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You grant to "LocalStack" and to recipients of software distributed by "LocalStack" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. + +3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You grant to "LocalStack" and to recipients of software distributed by "LocalStack" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. + +4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to "LocalStack", or that your employer has executed a separate Contributor License Agreement with "LocalStack". + +5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. + +6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +7. Should You wish to submit work that is not Your original creation, You may submit it to "LocalStack" separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". + +8. You agree to notify "LocalStack" of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000000..53f9b8b738a7c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +open_collective: localstack diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index a31faa0b7739e..0000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,2 +0,0 @@ - \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000000000..16e4bab6fbb37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,86 @@ +name: 🐞 Bug +description: File a bug/issue +title: "bug: " +labels: ["type: bug", "status: triage needed"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug! If you need real-time help, join us in the [community Slack](https://localstack-community.slack.com). +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: false +- type: dropdown + attributes: + label: How are you starting LocalStack? + options: + - With a docker-compose file + - With a `docker run` command + - With the `localstack` script + - Custom (please describe below) + validations: + required: true +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + value: | + #### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`) + + docker run localstack/localstack + + #### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands) + + awslocal s3 mb s3://mybucket + + validations: + required: true +- type: textarea + attributes: + label: Environment + description: | + examples: + - **OS**: Ubuntu 20.04 + - **LocalStack**: + You can find this information in the logs when starting localstack + + LocalStack version: 3.4.1.dev + LocalStack Docker image sha: sha256:f02ab8ef73f66b0ab26bb3d24a165e1066a714355f79a42bf8aa1a336d5722e7 + LocalStack build date: 2024-05-14 + LocalStack build git hash: ecd7dc879 + + value: | + - OS: + - LocalStack: + LocalStack version: + LocalStack Docker image sha: + LocalStack build date: + LocalStack build git hash: + render: markdown + validations: + required: false +- type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..47868d7b130ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: πŸ“– LocalStack Documentation + url: https://localstack.cloud/docs/getting-started/overview/ + about: The LocalStack documentation may answer your questions! + - name: πŸ’¬ LocalStack Community Support (Slack) + url: https://localstack.cloud/slack + about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000000000..747700eb3bca9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,36 @@ +name: ✨ Feature request +description: Request a new feature +title: "feature request: <title>" +labels: ["type: feature", "status: triage needed"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to improve LocalStack! +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the feature you are requesting. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Feature description + description: Please describe the feature you would like LocalStack to have + validations: + required: true +- type: textarea + attributes: + label: πŸ§‘β€πŸ’» Implementation + description: If you are a developer and have an idea how to implement this feature, please sketch it out here. + validations: + required: false +- type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 367c67bd7f151..ee3b8eecf5459 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1 +1,24 @@ -**Please refer to the contribution guidelines in the README when submitting PRs.** +<!-- Please refer to the contribution guidelines before raising a PR: https://github.com/localstack/localstack/blob/master/docs/CONTRIBUTING.md --> + +<!-- Why am I raising this PR? Add context such as related issues, PRs, or documentation. --> +## Motivation + + +<!-- What changes does this PR make? How does LocalStack behave differently now? --> +## Changes + +<!-- Optional section: How to test these changes? --> +<!-- +## Testing + +--> + +<!-- Optional section: What's left to do before it can be merged? --> +<!-- +## TODO + +What's left to do: + +- [ ] ... +- [ ] ... +--> diff --git a/.github/actions/build-image/action.yml b/.github/actions/build-image/action.yml new file mode 100644 index 0000000000000..eeb8832cb4494 --- /dev/null +++ b/.github/actions/build-image/action.yml @@ -0,0 +1,63 @@ +name: 'Build Image' +description: 'Composite action which combines all steps necessary to build the LocalStack Community image.' +inputs: + dockerhubPullUsername: + description: 'Username to log in to DockerHub to mitigate rate limiting issues with DockerHub.' + required: false + dockerhubPullToken: + description: 'API token to log in to DockerHub to mitigate rate limiting issues with DockerHub.' + required: false + disableCaching: + description: 'Disable Caching' + required: false +outputs: + image-artifact-name: + description: "Name of the artifact containing the built docker image" + value: ${{ steps.image-artifact-name.outputs.image-artifact-name }} +runs: + using: "composite" + # This GH Action requires localstack repo in 'localstack' dir + full git history (fetch-depth: 0) + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: 'localstack/.python-version' + + - name: Install docker helper dependencies + shell: bash + run: pip install --upgrade setuptools setuptools_scm + + - name: Login to Docker Hub + # login to DockerHub to avoid rate limiting issues on custom runners + uses: docker/login-action@v3 + if: ${{ inputs.dockerHubPullUsername != '' && inputs.dockerHubPullToken != '' }} + with: + username: ${{ inputs.dockerhubPullUsername }} + password: ${{ inputs.dockerhubPullToken }} + + - name: Build Docker Image + id: build-image + shell: bash + env: + DOCKER_BUILD_FLAGS: "--load ${{ inputs.disableCaching == 'true' && '--no-cache' || '' }}" + PLATFORM: ${{ (runner.arch == 'X64' && 'amd64') || (runner.arch == 'ARM64' && 'arm64') || '' }} + DOCKERFILE: ../Dockerfile + DOCKER_BUILD_CONTEXT: .. + IMAGE_NAME: "localstack/localstack" + working-directory: localstack/localstack-core + run: | + ../bin/docker-helper.sh build + ../bin/docker-helper.sh save + + - name: Store Docker Image as Artifact + uses: actions/upload-artifact@v4 + with: + name: localstack-docker-image-${{ (runner.arch == 'X64' && 'amd64') || (runner.arch == 'ARM64' && 'arm64') || '' }} + # the path is defined by the "save" command of the docker-helper, which sets a GitHub output "IMAGE_FILENAME" + path: localstack/localstack-core/${{ steps.build-image.outputs.IMAGE_FILENAME || steps.build-test-image.outputs.IMAGE_FILENAME}} + retention-days: 1 + + - name: Set image artifact name as output + id: image-artifact-name + shell: bash + run: echo "image-artifact-name=localstack-docker-image-${{ (runner.arch == 'X64' && 'amd64') || (runner.arch == 'ARM64' && 'arm64') || '' }}" >> $GITHUB_OUTPUT diff --git a/.github/actions/load-localstack-docker-from-artifacts/action.yml b/.github/actions/load-localstack-docker-from-artifacts/action.yml new file mode 100644 index 0000000000000..cb22c52682734 --- /dev/null +++ b/.github/actions/load-localstack-docker-from-artifacts/action.yml @@ -0,0 +1,31 @@ +name: 'Load Localstack Docker image' +description: 'Composite action that loads a LocalStack Docker image from a tar archive stored in GitHub Workflow Artifacts into the local Docker image cache' +inputs: + platform: + required: false + description: Target architecture for running the Docker image + default: "amd64" +runs: + using: "composite" + steps: + - name: Download Docker Image + uses: actions/download-artifact@v4 + with: + name: localstack-docker-image-${{ inputs.platform }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: '.python-version' + cache: 'pip' + cache-dependency-path: 'requirements-typehint.txt' + + - name: Install docker helper dependencies + shell: bash + run: pip install --upgrade setuptools setuptools_scm + + - name: Load Docker Image + shell: bash + env: + PLATFORM: ${{ inputs.platform }} + run: bin/docker-helper.sh load diff --git a/.github/actions/setup-tests-env/action.yml b/.github/actions/setup-tests-env/action.yml new file mode 100644 index 0000000000000..95cd7fe359787 --- /dev/null +++ b/.github/actions/setup-tests-env/action.yml @@ -0,0 +1,22 @@ +name: 'Setup Test Environment' +description: 'Composite action which combines all steps necessary to setup the runner for test execution' +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: '.python-version' + cache: 'pip' + cache-dependency-path: 'requirements-typehint.txt' + + - name: Install Community Dependencies + shell: bash + run: make install-dev-types + + - name: Setup environment + shell: bash + run: | + make install + mkdir -p target/reports + mkdir -p target/coverage diff --git a/.github/bot_templates/ASF_UPGRADE_PR.md b/.github/bot_templates/ASF_UPGRADE_PR.md new file mode 100644 index 0000000000000..567edf963fdd4 --- /dev/null +++ b/.github/bot_templates/ASF_UPGRADE_PR.md @@ -0,0 +1,22 @@ +# πŸš€ ASF Update Report πŸš€ +This PR has been automatically generated to update the generated API stubs for our ASF services. +It uses the latest code-generator from the _master_ branch ([scaffold.py](https://github.com/localstack/localstack/blob/master/localstack/aws/scaffold.py)) and the latest _published_ version of [botocore](https://github.com/boto/botocore) to re-generate all API stubs which are already present in the `localstack.aws.api` module of the _master_ branch. + +## πŸ”„ Updated Services +This PR updates the following services: +{{ SERVICES }} + +## πŸ‘·πŸ½ Handle this PR +The following options describe how to interact with this PR / the auto-update: + +βœ”οΈ **Accept Changes** +If the changes are satisfying, just squash-merge the PR and delete the source branch. + +🚫 **Ignore Changes** +If you want to ignore the changes in this PR, just close the PR and *do not delete* the source branch. The PR will not be opened and a new PR will not be created for as long as the generated code does not change (or the branch is deleted). As soon as there are new changes, a new PR will be created. + +✏️ **Adapt Changes** +*Don't do this.* The APIs are auto-generated. If you decide that the APIs should look different, you have to change the code-generation. + +⏸️ **Pause Updates** +Remove the cron-schedule trigger of the GitHub Action workflow which creates these PRs. The action can then still be triggered manually, but it will not be executed automatically. diff --git a/.github/bot_templates/MARKER_REPORT_ISSUE.md.j2 b/.github/bot_templates/MARKER_REPORT_ISSUE.md.j2 new file mode 100644 index 0000000000000..75aaab56924b9 --- /dev/null +++ b/.github/bot_templates/MARKER_REPORT_ISSUE.md.j2 @@ -0,0 +1,36 @@ +--- +title: AWS Marker Report +--- +# AWS Marker Report + +- Repository: {{ data.meta.repo_url }} +- Reference Commit: `{{ data.meta.commit_sha }}` +- Timestamp: `{{ data.meta.timestamp }}` + +This is an autogenerated report on our pytest marker usage with a special focus on our AWS compatibility markers, i.e. the ones prefixed with `aws_`. + +## Overview + +```text +{% for name, count in data.aggregated.items() -%} +{{ name }} : {{ count }} +{% endfor -%} +``` + +Both `aws_unknown` and `aws_needs_fixing` should be reduced to `0` over time. +If you have some spare capacity please take one of these tests, try it against AWS and see if it works. Replace `aws_unknown` with the correct marker. +To avoid the case where two people are concurrently working on one test case, please tick the box to "claim" a case when you want to work on it. + +_Note_: The individual assignments here are based on the entries in the [CODEOWNERS]({{ data.meta.repo_url }}/blob/{{ data.meta.commit_sha }}/CODEOWNERS) file. + +## unknown ({{ data.aggregated['aws_unknown'] }}) + +{% for item in data.owners_aws_unknown -%} +- [ ] `{{ item.pytest_node_id }}` {{ " ".join(item.owners) }} +{% endfor %} + +## needs_fixing ({{ data.aggregated['aws_needs_fixing'] }}) + +{% for item in data.owners_aws_needs_fixing -%} +- [ ] `{{ item.pytest_node_id }}` {{ " ".join(item.owners) }} +{% endfor %} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..3fd7b9f6a75e2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 +updates: + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + ignore: + - dependency-name: "python" + update-types: ["version-update:semver-major", "version-update:semver-minor"] + - dependency-name: "eclipse-temurin" + update-types: ["version-update:semver-major"] + labels: + - "area: dependencies" + - "semver: patch" + groups: + docker-base-images: + patterns: + - "*" + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: "weekly" + labels: + - "area: dependencies" + - "semver: patch" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000000000..37ac2b7325bc0 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,15 @@ +# configuration for automatically generated GitHub release notes +changelog: + exclude: + labels: + - "area: dependencies" + categories: + - title: Breaking Changes πŸ›  + labels: + - "semver: major" + - title: Exciting New Features πŸŽ‰ + labels: + - "semver: minor" + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/asf-updates.yml b/.github/workflows/asf-updates.yml new file mode 100644 index 0000000000000..69bf11a17e754 --- /dev/null +++ b/.github/workflows/asf-updates.yml @@ -0,0 +1,119 @@ +name: Update ASF APIs +on: + schedule: + - cron: 0 5 * * MON + workflow_dispatch: + +jobs: + update-asf: + name: Update ASF APIs + runs-on: ubuntu-latest + steps: + - name: Checkout Open Source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up system wide dependencies + run: | + sudo apt-get update + sudo apt-get install jq + + - name: Set up Python 3.11 + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install release helper dependencies + run: pip install --upgrade setuptools setuptools_scm + + - name: Cache LocalStack community dependencies (venv) + uses: actions/cache@v4 + with: + path: .venv + key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-venv-${{ hashFiles('requirements-dev.txt') }} + + - name: Install dependencies + run: make install-dev + + - name: Update botocore (specs) + run: | + source .venv/bin/activate + python3 -m pip install --upgrade botocore + + - name: Update ASF APIs + run: | + source .venv/bin/activate + python3 -m localstack.aws.scaffold upgrade + + - name: Format code + run: | + source .venv/bin/activate + # explicitly perform an unsafe fix to remove unused imports in the generated ASF APIs + ruff check --select F401 --unsafe-fixes --fix . --config "lint.preview = true" + make format-modified + + - name: Check for changes + id: check-for-changes + run: | + # Check if there are changed files and store the result in target/diff-check.log + # Check against the PR branch if it exists, otherwise against the master + # Store the result in target/diff-check.log and store the diff count in the GitHub Action output "diff-count" + mkdir -p target + (git diff --name-only origin/asf-auto-updates localstack-core/localstack/aws/api/ 2>/dev/null || git diff --name-only origin/master localstack-core/localstack/aws/api/ 2>/dev/null) | tee target/diff-check.log + echo "diff-count=$(cat target/diff-check.log | wc -l)" >> $GITHUB_OUTPUT + + # Store a (multiline-sanitized) list of changed services (compared to the master) in the GitHub Action output "changed-services" + echo "changed-services<<EOF" >> $GITHUB_OUTPUT + echo "$(git diff --name-only origin/master localstack-core/localstack/aws/api/ | sed 's#localstack-core/localstack/aws/api/#- #g' | sed 's#/__init__.py##g' | sed 's/_/-/g')" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Update botocore and transitive pins + # only update the pin if we have updates in the ASF code + if: ${{ success() && steps.check-for-changes.outputs.diff-count != '0' && steps.check-for-changes.outputs.diff-count != '' }} + run: | + source .venv/bin/activate + # determine botocore version in venv + BOTOCORE_VERSION=$(python -c "import botocore; print(botocore.__version__)"); + echo "Pinning botocore, boto3, and boto3-stubs to version $BOTOCORE_VERSION" + bin/release-helper.sh set-dep-ver botocore "==$BOTOCORE_VERSION" + bin/release-helper.sh set-dep-ver boto3 "==$BOTOCORE_VERSION" + + # upgrade the requirements files only for the botocore package + pip install pip-tools + pip-compile --strip-extras --upgrade-package "botocore==$BOTOCORE_VERSION" --upgrade-package "boto3==$BOTOCORE_VERSION" --extra base-runtime -o requirements-base-runtime.txt pyproject.toml + pip-compile --strip-extras --upgrade-package "botocore==$BOTOCORE_VERSION" --upgrade-package "boto3==$BOTOCORE_VERSION" --upgrade-package "awscli" --extra runtime -o requirements-runtime.txt pyproject.toml + pip-compile --strip-extras --upgrade-package "botocore==$BOTOCORE_VERSION" --upgrade-package "boto3==$BOTOCORE_VERSION" --upgrade-package "awscli" --extra test -o requirements-test.txt pyproject.toml + pip-compile --strip-extras --upgrade-package "botocore==$BOTOCORE_VERSION" --upgrade-package "boto3==$BOTOCORE_VERSION" --upgrade-package "awscli" --extra dev -o requirements-dev.txt pyproject.toml + pip-compile --strip-extras --upgrade-package "botocore==$BOTOCORE_VERSION" --upgrade-package "boto3==$BOTOCORE_VERSION" --upgrade-package "awscli" --extra typehint -o requirements-typehint.txt pyproject.toml + + - name: Read PR markdown template + if: ${{ success() && steps.check-for-changes.outputs.diff-count != '0' && steps.check-for-changes.outputs.diff-count != '' }} + id: template + uses: juliangruber/read-file-action@v1 + with: + path: .github/bot_templates/ASF_UPGRADE_PR.md + + - name: Add changed services to template + if: ${{ success() && steps.check-for-changes.outputs.diff-count != '0' && steps.check-for-changes.outputs.diff-count != '' }} + id: markdown + uses: mad9000/actions-find-and-replace-string@5 + with: + source: ${{ steps.template.outputs.content }} + find: '{{ SERVICES }}' + replace: ${{ steps.check-for-changes.outputs.changed-services }} + + - name: Create PR + uses: peter-evans/create-pull-request@v7 + if: ${{ success() && steps.check-for-changes.outputs.diff-count != '0' && steps.check-for-changes.outputs.diff-count != '' }} + with: + title: "Update ASF APIs" + body: "${{ steps.markdown.outputs.value }}" + branch: "asf-auto-updates" + author: "LocalStack Bot <localstack-bot@users.noreply.github.com>" + committer: "LocalStack Bot <localstack-bot@users.noreply.github.com>" + commit-message: "update generated ASF APIs to latest version" + labels: "area: asf, area: dependencies, semver: patch" + token: ${{ secrets.PRO_ACCESS_TOKEN }} + reviewers: silv-io,alexrashed diff --git a/.github/workflows/aws-main.yml b/.github/workflows/aws-main.yml new file mode 100644 index 0000000000000..e3daf1a6a5fc8 --- /dev/null +++ b/.github/workflows/aws-main.yml @@ -0,0 +1,303 @@ +name: AWS / Build, Test, Push + +on: + schedule: + - cron: 0 2 * * MON-FRI + push: + paths: + - '**' + - '!.github/**' + - '.github/actions/**' + - '.github/workflows/aws-main.yml' + - '.github/workflows/aws-tests.yml' + - '!CODEOWNERS' + - '!README.md' + - '!.gitignore' + - '!.git-blame-ignore-revs' + - '!docs/**' + branches: + - master + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + pull_request: + paths: + - '**' + - '!.github/**' + - '.github/actions/**' + - '.github/workflows/aws-main.yml' + - '.github/workflows/aws-tests.yml' + - '!CODEOWNERS' + - '!README.md' + - '!.gitignore' + - '!.git-blame-ignore-revs' + - '!docs/**' + workflow_dispatch: + inputs: + onlyAcceptanceTests: + description: 'Only run acceptance tests' + required: false + type: boolean + default: false + forceARMTests: + description: 'Run the ARM tests' + required: false + type: boolean + default: false + enableTestSelection: + description: 'Enable Test Selection' + required: false + type: boolean + default: false + disableCaching: + description: 'Disable Caching' + required: false + type: boolean + default: false + PYTEST_LOGLEVEL: + type: choice + description: Loglevel for PyTest + options: + - DEBUG + - INFO + - WARNING + - ERROR + - CRITICAL + default: WARNING + +env: + # Docker Image name and default tag used by docker-helper.sh + IMAGE_NAME: "localstack/localstack" + DEFAULT_TAG: "latest" + PLATFORM_NAME_AMD64: "amd64" + PLATFORM_NAME_ARM64: "arm64" + + +jobs: + test: + name: "Run integration tests" + uses: ./.github/workflows/aws-tests.yml + with: + # onlyAcceptance test is either explicitly set, or it's a push event. + # otherwise it's false (schedule event, workflow_dispatch event without setting it to true) + onlyAcceptanceTests: ${{ inputs.onlyAcceptanceTests == true || github.event_name == 'push' }} + # default "disableCaching" to `false` if it's a push or schedule event + disableCaching: ${{ inputs.disableCaching == true }} + # default "disableTestSelection" to `true` if it's a push or schedule event + disableTestSelection: ${{ (inputs.enableTestSelection != '' && inputs.enableTestSelection) || github.event_name == 'push' }} + PYTEST_LOGLEVEL: ${{ inputs.PYTEST_LOGLEVEL }} + forceARMTests: ${{ inputs.forceARMTests == true }} + secrets: + DOCKERHUB_PULL_USERNAME: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + DOCKERHUB_PULL_TOKEN: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + TINYBIRD_CI_TOKEN: ${{ secrets.TINYBIRD_CI_TOKEN }} + + report: + name: "Publish coverage and parity metrics" + runs-on: ubuntu-latest + needs: + - test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: '.python-version' + cache: 'pip' + cache-dependency-path: 'requirements-dev.txt' + + - name: Install Community Dependencies + shell: bash + run: make install-dev + + - name: Load all test results + uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: target/coverage/ + merge-multiple: true + + - name: Combine coverage results from acceptance tests + run: | + source .venv/bin/activate + mkdir target/coverage/acceptance + cp target/coverage/.coverage.acceptance* target/coverage/acceptance + cd target/coverage/acceptance + coverage combine + mv .coverage ../../../.coverage.acceptance + + - name: Combine all coverage results + run: | + source .venv/bin/activate + cd target/coverage + ls -la + coverage combine + mv .coverage ../../ + + - name: Report coverage statistics + env: + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + run: | + source .venv/bin/activate + coverage report || true + coverage html || true + coveralls || true + + - name: Create Coverage Diff (Code Coverage) + # pycobertura diff will return with exit code 0-3 -> we currently expect 2 (2: the changes worsened the overall coverage), + # but we still want cirecleci to continue with the tasks, so we return 0. + # From the docs: + # Upon exit, the diff command may return various exit codes: + # 0: all changes are covered, no new uncovered statements have been introduced + # 1: some exception occurred (likely due to inappropriate usage or a bug in pycobertura) + # 2: the changes worsened the overall coverage + # 3: the changes introduced uncovered statements but the overall coverage is still better than before + run: | + source .venv/bin/activate + pip install pycobertura + coverage xml --data-file=.coverage -o all.coverage.report.xml --include="localstack-core/localstack/services/*/**" --omit="*/**/__init__.py" + coverage xml --data-file=.coverage.acceptance -o acceptance.coverage.report.xml --include="localstack-core/localstack/services/*/**" --omit="*/**/__init__.py" + pycobertura show --format html acceptance.coverage.report.xml -o coverage-acceptance.html + bash -c "pycobertura diff --format html all.coverage.report.xml acceptance.coverage.report.xml -o coverage-diff.html; if [[ \$? -eq 1 ]] ; then exit 1 ; else exit 0 ; fi" + + - name: Create Metric Coverage Diff (API Coverage) + env: + COVERAGE_DIR_ALL: "parity_metrics" + COVERAGE_DIR_ACCEPTANCE: "acceptance_parity_metrics" + OUTPUT_DIR: "api-coverage" + run: | + source .venv/bin/activate + mkdir $OUTPUT_DIR + python -m scripts.metrics_coverage.diff_metrics_coverage + + - name: Archive coverage and parity metrics + uses: actions/upload-artifact@v4 + with: + name: coverage-and-parity-metrics + path: | + .coverage + api-coverage/ + coverage-acceptance.html + coverage-diff.html + parity_metrics/ + acceptance_parity_metrics/ + scripts/implementation_coverage_aggregated.csv + scripts/implementation_coverage_full.csv + retention-days: 7 + + push: + name: "Push images" + runs-on: ubuntu-latest + # push image on master, target branch not set, and the dependent steps were either successful or skipped + if: ( github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') ) && !failure() && !cancelled() && github.repository == 'localstack/localstack' + needs: + # all tests need to be successful for the image to be pushed + - test + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Load Localstack ${{ env.PLATFORM_NAME_AMD64 }} Docker Image + uses: ./.github/actions/load-localstack-docker-from-artifacts + with: + platform: ${{ env.PLATFORM_NAME_AMD64 }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + with: + registry-type: public + + - name: Push ${{ env.PLATFORM_NAME_AMD64 }} Docker Image + env: + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + PLATFORM: ${{ env.PLATFORM_NAME_AMD64 }} + run: | + # Push to Docker Hub + ./bin/docker-helper.sh push + # Push to Amazon Public ECR + TARGET_IMAGE_NAME="public.ecr.aws/localstack/localstack" ./bin/docker-helper.sh push + + - name: Load Localstack ${{ env.PLATFORM_NAME_ARM64 }} Docker Image + uses: ./.github/actions/load-localstack-docker-from-artifacts + with: + platform: ${{ env.PLATFORM_NAME_ARM64 }} + + - name: Push ${{ env.PLATFORM_NAME_ARM64 }} Docker Image + env: + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + PLATFORM: ${{ env.PLATFORM_NAME_ARM64 }} + run: | + # Push to Docker Hub + ./bin/docker-helper.sh push + # Push to Amazon Public ECR + TARGET_IMAGE_NAME="public.ecr.aws/localstack/localstack" ./bin/docker-helper.sh push + + - name: Push Multi-Arch Manifest + env: + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + run: | + # Push to Docker Hub + ./bin/docker-helper.sh push-manifests + # Push to Amazon Public ECR + IMAGE_NAME="public.ecr.aws/localstack/localstack" ./bin/docker-helper.sh push-manifests + + - name: Publish dev release + env: + TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + run: | + if git describe --exact-match --tags >/dev/null 2>&1; then + echo "not publishing a dev release as this is a tagged commit" + else + make install-runtime publish || echo "dev release failed (maybe it is already published)" + fi + + push-to-tinybird: + name: Push Workflow Status to Tinybird + if: always() && ( github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') ) && github.repository == 'localstack/localstack' + runs-on: ubuntu-latest + needs: + - test + steps: + - name: Push to Tinybird + uses: localstack/tinybird-workflow-push@v3 + with: + # differentiate between "acceptance only" and "proper / full" runs + workflow_id: ${{ (inputs.onlyAcceptanceTests == true || github.event_name == 'push') && 'tests_acceptance' || 'tests_full' }} + tinybird_token: ${{ secrets.TINYBIRD_CI_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tinybird_datasource: "ci_workflows" + # determine the output only for the jobs that are direct dependencies of this job (to avoid issues with workflow_call embeddings) + outcome: ${{ ((contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure') || 'success' }} + + cleanup: + name: "Cleanup" + runs-on: ubuntu-latest + # only remove the image artifacts if the build was successful + # (this allows a re-build of failed jobs until for the time of the retention period) + if: always() && !failure() && !cancelled() + needs: push + steps: + - uses: geekyeggo/delete-artifact@v5 + with: + # delete the docker images shared within the jobs (storage on GitHub is expensive) + name: | + localstack-docker-image-* + lambda-common-* + failOnError: false + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/aws-tests-mamr.yml b/.github/workflows/aws-tests-mamr.yml new file mode 100644 index 0000000000000..63872a81ec488 --- /dev/null +++ b/.github/workflows/aws-tests-mamr.yml @@ -0,0 +1,83 @@ +name: AWS / MA/MR tests + +on: + schedule: + - cron: 0 1 * * MON-FRI + pull_request: + paths: + - '.github/workflows/aws-mamr.yml' + - '.github/workflows/aws-tests.yml' + - '.github/actions/**' + workflow_dispatch: + inputs: + disableCaching: + description: 'Disable Caching' + required: false + type: boolean + default: false + PYTEST_LOGLEVEL: + type: choice + description: Loglevel for PyTest + options: + - DEBUG + - INFO + - WARNING + - ERROR + - CRITICAL + default: WARNING + +env: + IMAGE_NAME: "localstack/localstack" + + + +jobs: + generate-random-creds: + name: "Generate random AWS credentials" + runs-on: ubuntu-latest + outputs: + region: ${{ steps.generate-aws-values.outputs.region }} + account_id: ${{ steps.generate-aws-values.outputs.account_id }} + steps: + - name: Generate values + id: generate-aws-values + run: | + # Generate a random 12-digit number for TEST_AWS_ACCOUNT_ID + ACCOUNT_ID=$(shuf -i 100000000000-999999999999 -n 1) + echo "account_id=$ACCOUNT_ID" >> $GITHUB_OUTPUT + # Set TEST_AWS_REGION_NAME to a random AWS region other than us-east-1 + REGIONS=("us-east-2" "us-west-1" "us-west-2" "ap-southeast-2" "ap-northeast-1" "eu-central-1" "eu-west-1") + REGION=${REGIONS[RANDOM % ${#REGIONS[@]}]} + echo "region=$REGION" >> $GITHUB_OUTPUT + + test-ma-mr: + name: "Run integration tests" + needs: generate-random-creds + uses: ./.github/workflows/aws-tests.yml + with: + disableCaching: ${{ inputs.disableCaching == true }} + PYTEST_LOGLEVEL: ${{ inputs.PYTEST_LOGLEVEL }} + testAWSRegion: ${{ needs.generate-random-creds.outputs.region }} + testAWSAccountId: ${{ needs.generate-random-creds.outputs.account_id }} + testAWSAccessKeyId: ${{ needs.generate-random-creds.outputs.account_id }} + secrets: + DOCKERHUB_PULL_USERNAME: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + DOCKERHUB_PULL_TOKEN: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + TINYBIRD_CI_TOKEN: ${{ secrets.TINYBIRD_CI_TOKEN }} + + push-to-tinybird: + name: Push Workflow Status to Tinybird + if: always() && ( github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') ) && github.repository == 'localstack/localstack' + runs-on: ubuntu-latest + needs: + - test-ma-mr + steps: + - name: Push to Tinybird + uses: localstack/tinybird-workflow-push@v3 + with: + workflow_id: ${{ 'tests_mamr' }} + tinybird_token: ${{ secrets.TINYBIRD_CI_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tinybird_datasource: "ci_workflows" + # determine the output only for the jobs that are direct dependencies of this job (to avoid issues with workflow_call embeddings) + outcome: ${{ ((contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure') || 'success' }} diff --git a/.github/workflows/aws-tests-s3-image.yml b/.github/workflows/aws-tests-s3-image.yml new file mode 100644 index 0000000000000..f57d89930f1c2 --- /dev/null +++ b/.github/workflows/aws-tests-s3-image.yml @@ -0,0 +1,281 @@ +name: AWS / S3 Image Integration Tests + +on: + push: + paths: + - .github/workflows/tests-s3-image.yml + - localstack-core/localstack/aws/*.py + - localstack-core/localstack/aws/handlers/*Β¨ + - localstack-core/localstack/aws/protocol/** + - localstack-core/localstack/aws/serving/** + - localstack-core/localstack/aws/api/s3/** + - localstack-core/localstack/http/** + - localstack-core/localstack/runtime/** + - localstack-core/localstack/services/s3/** + - localstack-core/localstack/*.py + - tests/aws/services/s3/** + - Dockerfile.s3 + - requirements-*.txt + - setup.cfg + - Makefile + branches: + - master + pull_request: + paths: + - .github/workflows/tests-s3-image.yml + - localstack-core/localstack/aws/*.py + - localstack-core/localstack/aws/handlers/*Β¨ + - localstack-core/localstack/aws/protocol/** + - localstack-core/localstack/aws/serving/** + - localstack-core/localstack/aws/api/s3/** + - localstack-core/localstack/http/** + - localstack-core/localstack/runtime/** + - localstack-core/localstack/services/s3/** + - localstack-core/localstack/*.py + - tests/aws/services/s3/** + - Dockerfile.s3 + - requirements-*.txt + - setup.cfg + - Makefile + workflow_dispatch: + inputs: + publishDockerImage: + description: 'Publish S3-only images on Dockerhub' + required: false + type: boolean + default: false + PYTEST_LOGLEVEL: + type: choice + description: Loglevel for PyTest + options: + - DEBUG + - INFO + - WARNING + - ERROR + - CRITICAL + default: WARNING + +# Only one pull-request triggered run should be executed at a time +# (head_ref is only set for PR events, otherwise fallback to run_id which differs for every run). +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + # Configure PyTest log level + PYTEST_LOGLEVEL: "${{ inputs.PYTEST_LOGLEVEL || 'WARNING' }}" + # Set non-job-specific environment variables for pytest-tinybird + TINYBIRD_URL: https://api.tinybird.co + TINYBIRD_DATASOURCE: community_tests_s3_image + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_CI_TOKEN }} + CI_COMMIT_BRANCH: ${{ github.head_ref || github.ref_name }} + CI_COMMIT_SHA: ${{ github.sha }} + CI_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + # report to tinybird if executed on master + TINYBIRD_PYTEST_ARGS: "${{ github.repository == 'localstack/localstack' && github.ref == 'refs/heads/master' && '--report-to-tinybird ' || '' }}" + + +jobs: + build-test-s3: + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: buildjet-2vcpu-ubuntu-2204-arm + name: "Build and Test S3 image" + env: + PLATFORM: ${{ matrix.arch }} + IMAGE_NAME: "localstack/localstack" + DEFAULT_TAG: "s3-latest" + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + # If this is triggered by a pull_request, make sure the PR head repo name is the same as the target repo name + # (i.e. do not execute job for workflows coming from forks) + if: >- + ( + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + ) + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install docker build dependencies + run: pip install --upgrade setuptools setuptools_scm + + - name: Build S3 Docker Image + env: + DOCKERFILE: "./Dockerfile.s3" + run: ./bin/docker-helper.sh build + + - name: Run S3 Image tests + timeout-minutes: 10 + env: + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}-o junit_family=legacy --junitxml=target/pytest-junit-s3-image-${{ matrix.arch }}.xml" + TEST_PATH: "tests/aws/services/s3" + DEBUG: 1 + run: | + mkdir target + make docker-run-tests-s3-only + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-s3-image-${{ matrix.arch }} + path: target/pytest-junit-s3-image-${{ matrix.arch }}.xml + retention-days: 30 + + - name: Save the S3 image + id: save-image + run: ./bin/docker-helper.sh save + + - name: Store Docker image as artifact + uses: actions/upload-artifact@v4 + with: + name: localstack-s3-image-${{ matrix.arch }} + path: ${{ steps.save-image.outputs.IMAGE_FILENAME }} + retention-days: 1 + + publish-test-results: + name: "Publish S3 Image Test Results" + needs: build-test-s3 + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + issues: read + # If this is triggered by a pull_request, make sure the PR head repo name is the same as the target repo name + # (i.e. do not execute job for workflows coming from forks) + if: >- + (success() || failure()) && ( + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + ) + steps: + - name: Download AMD64 Results + uses: actions/download-artifact@v4 + with: + name: test-results-s3-image-amd64 + + - name: Download ARM64 Results + uses: actions/download-artifact@v4 + with: + name: test-results-s3-image-arm64 + + - name: Publish S3 Image Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: pytest-junit-*.xml + check_name: "S3 Image Test Results (AMD64 / ARM64)" + action_fail_on_inconclusive: true + + push-s3-image: + name: "Push S3 images and manifest" + runs-on: ubuntu-latest + needs: + - build-test-s3 + if: inputs.publishDockerImage + env: + IMAGE_NAME: "localstack/localstack" + DEFAULT_TAG: "s3-latest" + steps: + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install docker build dependencies + run: pip install --upgrade setuptools setuptools_scm + + - name: Download AMD64 image + uses: actions/download-artifact@v4 + with: + name: localstack-s3-image-amd64 + + - name: Download ARM64 image + uses: actions/download-artifact@v4 + with: + name: localstack-s3-image-arm64 + + - name: Load AMD64 image + env: + PLATFORM: amd64 + run: ./bin/docker-helper.sh load + + - name: Push AMD64 image + env: + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + PLATFORM: amd64 + run: ./bin/docker-helper.sh push + + - name: Load ARM64 image + env: + PLATFORM: arm64 + run: ./bin/docker-helper.sh load + + - name: Push ARM64 image + env: + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + PLATFORM: arm64 + run: ./bin/docker-helper.sh push + + - name: Create and push manifest + env: + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} + run: ./bin/docker-helper.sh push-manifests + + cleanup: + name: "Clean up" + runs-on: ubuntu-latest + if: success() + needs: push-s3-image + steps: + - uses: geekyeggo/delete-artifact@v5 + with: + name: localstack-s3-image-* + failOnError: false + + push-to-tinybird: + if: always() && github.ref == 'refs/heads/master' && github.repository == 'localstack/localstack' + runs-on: ubuntu-latest + needs: push-s3-image + steps: + - name: Push to Tinybird + uses: localstack/tinybird-workflow-push@v3 + with: + workflow_id: "tests_s3_image" + tinybird_token: ${{ secrets.TINYBIRD_CI_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tinybird_datasource: "ci_workflows" diff --git a/.github/workflows/aws-tests.yml b/.github/workflows/aws-tests.yml new file mode 100644 index 0000000000000..9951389d18a3c --- /dev/null +++ b/.github/workflows/aws-tests.yml @@ -0,0 +1,955 @@ +name: AWS / Integration Tests + +on: + workflow_dispatch: + inputs: + disableCaching: + description: 'Disable Caching' + required: false + type: boolean + default: false + PYTEST_LOGLEVEL: + type: choice + description: Loglevel for PyTest + options: + - DEBUG + - INFO + - WARNING + - ERROR + - CRITICAL + default: WARNING + disableTestSelection: + description: 'Disable Test Selection' + required: false + type: boolean + default: false + randomize-aws-credentials: + description: 'Randomize AWS credentials' + default: false + required: false + type: boolean + onlyAcceptanceTests: + description: 'Run only acceptance tests' + default: false + required: false + type: boolean + forceARMTests: + description: 'Run the ARM64 tests' + default: false + required: false + type: boolean + testAWSRegion: + description: 'AWS test region' + required: false + type: string + default: 'us-east-1' + testAWSAccountId: + description: 'AWS test account ID' + required: false + type: string + default: '000000000000' + testAWSAccessKeyId: + description: 'AWS test access key ID' + required: false + type: string + default: 'test' + workflow_call: + inputs: + disableCaching: + description: 'Disable Caching' + required: false + type: boolean + default: false + PYTEST_LOGLEVEL: + type: string + required: false + description: Loglevel for PyTest + default: WARNING + disableTestSelection: + description: 'Disable Test Selection' + required: false + type: boolean + default: false + randomize-aws-credentials: + description: "Randomize AWS credentials" + default: false + required: false + type: boolean + onlyAcceptanceTests: + description: "Run only acceptance tests" + default: false + required: false + type: boolean + forceARMTests: + description: 'Run the ARM64 tests' + default: false + required: false + type: boolean + testAWSRegion: + description: 'AWS test region' + required: false + type: string + default: 'us-east-1' + testAWSAccountId: + description: 'AWS test account ID' + required: false + type: string + default: '000000000000' + testAWSAccessKeyId: + description: 'AWS test access key ID' + required: false + type: string + default: 'test' + secrets: + DOCKERHUB_PULL_USERNAME: + description: 'A DockerHub username - Used to avoid rate limiting issues.' + required: true + DOCKERHUB_PULL_TOKEN: + description: 'A DockerHub token - Used to avoid rate limiting issues.' + required: true + TINYBIRD_CI_TOKEN: + description: 'Token for accessing our tinybird ci analytics workspace.' + required: true + +env: + PYTEST_LOGLEVEL: ${{ inputs.PYTEST_LOGLEVEL || 'WARNING' }} + IMAGE_NAME: "localstack/localstack" + TESTSELECTION_PYTEST_ARGS: "${{ !inputs.disableTestSelection && '--path-filter=dist/testselection/test-selection.txt ' || '' }}" + TEST_AWS_REGION_NAME: ${{ inputs.testAWSRegion }} + TEST_AWS_ACCOUNT_ID: ${{ inputs.testAWSAccountId }} + TEST_AWS_ACCESS_KEY_ID: ${{ inputs.testAWSAccessKeyId }} + # Set non-job-specific environment variables for pytest-tinybird + TINYBIRD_URL: https://api.tinybird.co + TINYBIRD_DATASOURCE: raw_tests + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_CI_TOKEN }} + TINYBIRD_TIMEOUT: 5 + CI_REPOSITORY_NAME: localstack/localstack + # differentiate between "acceptance", "mamr" and "full" runs + CI_WORKFLOW_NAME: ${{ inputs.onlyAcceptanceTests && 'tests_acceptance' + || inputs.testAWSAccountId != '000000000000' && 'tests_mamr' + || 'tests_full' }} + CI_COMMIT_BRANCH: ${{ github.head_ref || github.ref_name }} + CI_COMMIT_SHA: ${{ github.sha }} + CI_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + # report to tinybird if executed on master + TINYBIRD_PYTEST_ARGS: "${{ github.repository == 'localstack/localstack' && ( github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') ) && '--report-to-tinybird ' || '' }}" + DOCKER_PULL_SECRET_AVAILABLE: ${{ secrets.DOCKERHUB_PULL_USERNAME != '' && secrets.DOCKERHUB_PULL_TOKEN != '' && 'true' || 'false' }} + + + +jobs: + build: + name: "Build Docker Image (${{ contains(matrix.runner, 'arm') && 'ARM64' || 'AMD64' }})" + needs: + - test-preflight + strategy: + matrix: + runner: + - ubuntu-latest + - ubuntu-24.04-arm + exclude: + # skip the ARM integration tests in forks, and also if not on master/upgrade-dependencies and forceARMTests is not set to true + # TODO ARM runners are not yet available for private repositories; skip them for potential private forks + - runner: ${{ ((github.repository != 'localstack/localstack') || (github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/v') && github.ref != 'upgrade-dependencies' && inputs.forceARMTests == false)) && 'ubuntu-24.04-arm' || ''}} + fail-fast: false + runs-on: ${{ matrix.runner }} + steps: + - name: Determine Runner Architecture + shell: bash + run: echo "PLATFORM=${{ (runner.arch == 'X64' && 'amd64') || (runner.arch == 'ARM64' && 'arm64') || '' }}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v4 + with: + path: localstack + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Build Image + uses: localstack/localstack/.github/actions/build-image@master + with: + disableCaching: ${{ inputs.disableCaching == true && 'true' || 'false' }} + dockerhubPullUsername: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + dockerhubPullToken: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + + - name: Restore Lambda common runtime packages + id: cached-lambda-common-restore + if: inputs.disableCaching != true + uses: actions/cache/restore@v4 + with: + path: localstack/tests/aws/services/lambda_/functions/common + key: common-it-${{ runner.os }}-${{ runner.arch }}-lambda-common-${{ hashFiles('localstack/tests/aws/services/lambda_/functions/common/**/src/*', 'localstack/tests/aws/services/lambda_/functions/common/**/Makefile') }} + + - name: Prebuild lambda common packages + run: ./localstack/scripts/build_common_test_functions.sh `pwd`/localstack/tests/aws/services/lambda_/functions/common + + - name: Save Lambda common runtime packages + if: inputs.disableCaching != true + uses: actions/cache/save@v4 + with: + path: localstack/tests/aws/services/lambda_/functions/common + key: ${{ steps.cached-lambda-common-restore.outputs.cache-primary-key }} + + - name: Archive Lambda common packages + uses: actions/upload-artifact@v4 + with: + name: lambda-common-${{ env.PLATFORM }} + path: | + localstack/tests/aws/services/lambda_/functions/common + retention-days: 1 + + + test-preflight: + name: "Preflight & Unit-Tests" + runs-on: ubuntu-latest + outputs: + cloudwatch-v1: ${{ steps.changes.outputs.cloudwatch-v1 }} + dynamodb-v2: ${{ steps.changes.outputs.dynamodb-v2 }} + events-v1: ${{ steps.changes.outputs.events-v1 }} + cloudformation-v2: ${{ steps.changes.outputs.cloudformation-v2 }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Prepare Local Test Environment + uses: ./.github/actions/setup-tests-env + + - name: Linting + run: make lint + + - name: Check AWS compatibility markers + run: make check-aws-markers + + - name: Determine Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + run: | + source .venv/bin/activate + if [ -z "${{ github.event.pull_request.base.sha }}" ]; then + echo "Do test selection based on branch name" + else + echo "Do test selection based on Pull Request event" + SCRIPT_OPTS="--base-commit-sha ${{ github.event.pull_request.base.sha }} --head-commit-sha ${{ github.event.pull_request.head.sha }}" + fi + source .venv/bin/activate + python -m localstack.testing.testselection.scripts.generate_test_selection $(pwd) dist/testselection/test-selection.txt $SCRIPT_OPTS || (mkdir -p dist/testselection && echo "SENTINEL_ALL_TESTS" >> dist/testselection/test-selection.txt) + echo "Test selection:" + cat dist/testselection/test-selection.txt + + - name: Archive Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + uses: actions/upload-artifact@v4 + with: + name: test-selection + path: | + dist/testselection/test-selection.txt + retention-days: 1 + + # This step determines which services were affected by changes of the modified files + # The output from this step is later used in combination with the test-selection file + # + # The test-selection file specifies which tests to run for each service, + # while this step allows skipping entire jobs when no relevant services have changed + - name: Determine services affected by change + uses: dorny/paths-filter@v3.0.2 + id: changes + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: | + cloudwatch-v1: + - 'tests/aws/services/cloudwatch/**' + dynamodb-v2: + - 'tests/aws/services/dynamodb/**' + - 'tests/aws/services/dynamodbstreams/**' + - 'tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py' + events-v1: + - 'tests/aws/services/events/**' + cloudformation-v2: + - 'tests/aws/services/cloudformation/v2/**' + + - name: Run Unit Tests + timeout-minutes: 8 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEBUG: 1 + TEST_PATH: "tests/unit" + JUNIT_REPORTS_FILE: "pytest-junit-unit.xml" + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }} -o junit_suite_name=unit-tests" + COVERAGE_FILE: ".coverage.unit" + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }}-unit + CI_JOB_ID: ${{ github.job }}-unit + run: make test-coverage + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-preflight + include-hidden-files: true + path: | + pytest-junit-unit.xml + .coverage.unit + retention-days: 30 + + publish-preflight-test-results: + name: Publish Preflight- & Unit-Test Results + needs: test-preflight + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + issues: read + # execute on success or failure, but not if the workflow is cancelled or any of the dependencies has been skipped + if: always() && !cancelled() && !contains(needs.*.result, 'skipped') + steps: + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + pattern: test-results-preflight + + - name: Publish Preflight- & Unit-Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: success() || failure() + with: + files: | + test-results-preflight/*.xml + check_name: "Test Results ${{ inputs.testAWSAccountId != '000000000000' && '(MA/MR) ' || ''}}- Preflight, Unit" + test_file_prefix: "-/opt/code/localstack/" + action_fail_on_inconclusive: true + + + test-integration: + name: "Integration Tests (${{ contains(matrix.runner, 'arm') && 'ARM64' || 'AMD64' }} - ${{ matrix.group }})" + if: ${{ !inputs.onlyAcceptanceTests }} + needs: + - build + - test-preflight + strategy: + matrix: + group: [ 1, 2, 3, 4 ] + runner: + - ubuntu-latest + - ubuntu-24.04-arm + exclude: + # skip the ARM integration tests in forks, and also if not on master/upgrade-dependencies and forceARMTests is not set to true + # TODO ARM runners are not yet available for private repositories; skip them for potential private forks + - runner: ${{ ((github.repository != 'localstack/localstack') || (github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/v') && github.ref != 'upgrade-dependencies' && inputs.forceARMTests == false)) && 'ubuntu-24.04-arm' || ''}} + fail-fast: false + runs-on: ${{ matrix.runner }} + env: + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }}-${{ contains(matrix.runner, 'arm') && 'arm' || 'amd' }} + CI_JOB_ID: ${{ github.job }}-${{ contains(matrix.runner, 'arm') && 'arm' || 'amd' }} + steps: + - name: Determine Runner Architecture + shell: bash + run: echo "PLATFORM=${{ (runner.arch == 'X64' && 'amd64') || (runner.arch == 'ARM64' && 'arm64') || '' }}" >> $GITHUB_ENV + + - name: Login to Docker Hub + # login to DockerHub to avoid rate limiting issues on custom runners + if: github.repository_owner == 'localstack' && env.DOCKER_PULL_SECRET_AVAILABLE == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + password: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + + - name: Set environment + if: ${{ inputs.testEnvironmentVariables != ''}} + shell: bash + run: | + echo "${{ inputs.testEnvironmentVariables }}" | sed "s/;/\n/" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v4 + with: + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Download Lambda Common packages + uses: actions/download-artifact@v4 + with: + name: lambda-common-${{ env.PLATFORM }} + path: | + tests/aws/services/lambda_/functions/common + + - name: Load Localstack Docker Image + uses: ./.github/actions/load-localstack-docker-from-artifacts + with: + platform: "${{ env.PLATFORM }}" + + - name: Download Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + uses: actions/download-artifact@v4 + with: + name: test-selection + path: dist/testselection/ + + - name: Run Integration Tests + timeout-minutes: 120 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }} --splits 4 --group ${{ matrix.group }} --store-durations --clean-durations --ignore=tests/unit/ --ignore=tests/bootstrap" + COVERAGE_FILE: "target/.coverage.integration-${{ env.PLATFORM }}-${{ matrix.group }}" + JUNIT_REPORTS_FILE: "target/pytest-junit-integration-${{ env.PLATFORM }}-${{ matrix.group }}.xml" + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + # increase Docker SDK timeout to avoid timeouts on BuildJet runners - https://github.com/docker/docker-py/issues/2266 + DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS: 300 + run: make docker-run-tests + + # Test durations are fetched and merged automatically by a separate workflow. + # Files must have unique names to prevent overwrites when multiple artifacts are downloaded + - name: Rename test durations file + run: | + mv .test_durations .test_durations-${{ env.PLATFORM }}-${{ matrix.group }} + + - name: Archive Test Durations + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: pytest-split-durations-${{ env.PLATFORM }}-${{ matrix.group }} + path: .test_durations-${{ env.PLATFORM }}-${{ matrix.group }} + include-hidden-files: true + retention-days: 5 + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-integration-${{ env.PLATFORM }}-${{ matrix.group }} + include-hidden-files: true + path: | + target/pytest-junit-integration-${{ env.PLATFORM }}-${{ matrix.group }}.xml + target/.coverage.integration-${{ env.PLATFORM }}-${{ matrix.group }} + retention-days: 30 + + - name: Archive Parity Metric Results + if: success() + uses: actions/upload-artifact@v4 + with: + name: parity-metric-raw-${{ env.PLATFORM }}-${{ matrix.group }} + path: target/metric_reports + retention-days: 30 + + test-bootstrap: + name: Test Bootstrap + if: ${{ !inputs.onlyAcceptanceTests }} + runs-on: ubuntu-latest + needs: + - test-preflight + - build + timeout-minutes: 60 + env: + PLATFORM: 'amd64' + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Prepare Local Test Environment + uses: ./.github/actions/setup-tests-env + + - name: Load Localstack Docker Image + uses: ./.github/actions/load-localstack-docker-from-artifacts + with: + platform: "${{ env.PLATFORM }}" + + - name: Run Bootstrap Tests + timeout-minutes: 30 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TEST_PATH: "tests/bootstrap" + COVERAGE_FILE: ".coverage.bootstrap" + JUNIT_REPORTS_FILE: "pytest-junit-bootstrap.xml" + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }} -o junit_suite_name=bootstrap-tests" + run: make test-coverage + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-bootstrap + include-hidden-files: true + path: | + pytest-junit-bootstrap.xml + .coverage.bootstrap + retention-days: 30 + + publish-test-results: + name: Publish Test Results + strategy: + matrix: + arch: + - amd64 + - arm64 + exclude: + # skip the ARM integration tests in forks, and also if not on master/upgrade-dependencies and forceARMTests is not set to true + # TODO ARM runners are not yet available for private repositories; skip them for potential private forks + - arch: ${{ ((github.repository != 'localstack/localstack') || (github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/v') && github.ref != 'upgrade-dependencies' && inputs.forceARMTests == false)) && 'arm64' || ''}} + needs: + - test-integration + - test-bootstrap + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + issues: read + # execute on success or failure, but not if the workflow is cancelled or any of the dependencies has been skipped + if: always() && !cancelled() && !contains(needs.*.result, 'skipped') + steps: + - name: Download Bootstrap Artifacts + uses: actions/download-artifact@v4 + if: ${{ matrix.arch == 'amd64' }} + with: + pattern: test-results-bootstrap + + - name: Download Integration Artifacts + uses: actions/download-artifact@v4 + with: + pattern: test-results-integration-${{ matrix.arch }}-* + + - name: Publish Bootstrap and Integration Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: success() || failure() + with: + files: | + **/pytest-junit-*.xml + check_name: "Test Results (${{ matrix.arch }}${{ inputs.testAWSAccountId != '000000000000' && ', MA/MR' || ''}}) - Integration${{ matrix.arch == 'amd64' && ', Bootstrap' || ''}}" + test_file_prefix: "-/opt/code/localstack/" + action_fail_on_inconclusive: true + + test-acceptance: + name: "Acceptance Tests (${{ contains(matrix.runner, 'arm') && 'ARM64' || 'AMD64' }})" + needs: + - build + strategy: + matrix: + runner: + - ubuntu-latest + - ubuntu-24.04-arm + exclude: + # skip the ARM integration tests in forks, and also if not on master/upgrade-dependencies and forceARMTests is not set to true + # TODO ARM runners are not yet available for private repositories; skip them for potential private forks + - runner: ${{ ((github.repository != 'localstack/localstack') || (github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/v') && github.ref != 'upgrade-dependencies' && inputs.forceARMTests == false)) && 'ubuntu-24.04-arm' || ''}} + fail-fast: false + runs-on: ${{ matrix.runner }} + env: + # Acceptance tests are executed for all test cases, without any test selection + TESTSELECTION_PYTEST_ARGS: "" + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }}-${{ contains(matrix.runner, 'arm') && 'arm' || 'amd' }} + CI_JOB_ID: ${{ github.job }}-${{ contains(matrix.runner, 'arm') && 'arm' || 'amd' }} + steps: + - name: Determine Runner Architecture + shell: bash + run: echo "PLATFORM=${{ (runner.arch == 'X64' && 'amd64') || (runner.arch == 'ARM64' && 'arm64') || '' }}" >> $GITHUB_ENV + + - name: Login to Docker Hub + # login to DockerHub to avoid rate limiting issues on custom runners + if: github.repository_owner == 'localstack' && env.DOCKER_PULL_SECRET_AVAILABLE == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + password: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + + - name: Set environment + if: ${{ inputs.testEnvironmentVariables != ''}} + shell: bash + run: | + echo "${{ inputs.testEnvironmentVariables }}" | sed "s/;/\n/" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v4 + with: + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Load Localstack Docker Image + uses: ./.github/actions/load-localstack-docker-from-artifacts + with: + platform: "${{ env.PLATFORM }}" + + - name: Run Acceptance Tests + timeout-minutes: 120 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEBUG: 1 + LOCALSTACK_INTERNAL_TEST_COLLECT_METRIC: 1 + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }} --reruns 3 -m acceptance_test -o junit_suite_name='acceptance_test'" + COVERAGE_FILE: "target/.coverage.acceptance-${{ env.PLATFORM }}" + JUNIT_REPORTS_FILE: "target/pytest-junit-acceptance-${{ env.PLATFORM }}.xml" + TEST_PATH: "tests/aws/" + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + run: make docker-run-tests + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-acceptance-${{ env.PLATFORM }} + include-hidden-files: true + path: | + target/pytest-junit-acceptance-${{ env.PLATFORM }}.xml + target/.coverage.acceptance-${{ env.PLATFORM }} + retention-days: 30 + + publish-acceptance-test-results: + name: Publish Acceptance Test Results + strategy: + matrix: + arch: + - amd64 + - arm64 + exclude: + # skip the ARM integration tests in forks, and also if not on master/upgrade-dependencies and forceARMTests is not set to true + # TODO ARM runners are not yet available for private repositories; skip them for potential private forks + - arch: ${{ ((github.repository != 'localstack/localstack') || (github.ref != 'refs/heads/master' && !startsWith(github.ref, 'refs/tags/v') && github.ref != 'upgrade-dependencies' && inputs.forceARMTests == false)) && 'arm64' || ''}} + needs: + - test-acceptance + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + issues: read + # execute on success or failure, but not if the workflow is cancelled or any of the dependencies has been skipped + if: always() && !cancelled() && !contains(needs.*.result, 'skipped') + steps: + - name: Download Acceptance Artifacts + uses: actions/download-artifact@v4 + with: + pattern: test-results-acceptance-${{ matrix.arch }} + + - name: Publish Acceptance Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: success() || failure() + with: + files: | + **/pytest-junit-*.xml + check_name: "Test Results (${{ matrix.arch }}${{ inputs.testAWSAccountId != '000000000000' && ', MA/MR' || ''}}) - Acceptance" + test_file_prefix: "-/opt/code/localstack/" + action_fail_on_inconclusive: true + + test-cloudwatch-v1: + name: Test CloudWatch V1 + if: ${{ !inputs.onlyAcceptanceTests && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') || needs.test-preflight.outputs.cloudwatch-v1 == 'true') }} + runs-on: ubuntu-latest + needs: + - test-preflight + - build + timeout-minutes: 60 + env: + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare Local Test Environment + uses: ./.github/actions/setup-tests-env + + - name: Download Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + uses: actions/download-artifact@v4 + with: + name: test-selection + path: dist/testselection/ + + - name: Run Cloudwatch v1 Provider Tests + timeout-minutes: 30 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEBUG: 1 + COVERAGE_FILE: ".coverage.cloudwatch_v1" + TEST_PATH: "tests/aws/services/cloudwatch/" + JUNIT_REPORTS_FILE: "pytest-junit-cloudwatch-v1.xml" + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }} --reruns 3 -o junit_suite_name=cloudwatch_v1" + PROVIDER_OVERRIDE_CLOUDWATCH: "v1" + run: make test-coverage + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-cloudwatch-v1 + include-hidden-files: true + path: | + pytest-junit-cloudwatch-v1.xml + .coverage.cloudwatch_v1 + retention-days: 30 + + test-ddb-v2: + name: Test DynamoDB(Streams) v2 + if: ${{ !inputs.onlyAcceptanceTests && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') || needs.test-preflight.outputs.dynamodb-v2 == 'true') }} + runs-on: ubuntu-latest + needs: + - test-preflight + - build + timeout-minutes: 60 + env: + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare Local Test Environment + uses: ./.github/actions/setup-tests-env + + - name: Download Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + uses: actions/download-artifact@v4 + with: + name: test-selection + path: dist/testselection/ + + - name: Run DynamoDB(Streams) v2 Provider Tests + timeout-minutes: 30 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERAGE_FILE: ".coverage.dynamodb_v2" + TEST_PATH: "tests/aws/services/dynamodb/ tests/aws/services/dynamodbstreams/ tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py" + JUNIT_REPORTS_FILE: "pytest-junit-dynamodb-v2.xml" + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }} --reruns 3 -o junit_suite_name=dynamodb_v2" + PROVIDER_OVERRIDE_DYNAMODB: "v2" + run: make test-coverage + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-dynamodb-v2 + include-hidden-files: true + path: | + pytest-junit-dynamodb-v2.xml + .coverage.dynamodb_v2 + retention-days: 30 + + test-events-v1: + name: Test EventBridge v1 + if: ${{ !inputs.onlyAcceptanceTests && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') || needs.test-preflight.outputs.events-v1 == 'true') }} + runs-on: ubuntu-latest + needs: + - test-preflight + - build + timeout-minutes: 60 + env: + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare Local Test Environment + uses: ./.github/actions/setup-tests-env + + - name: Download Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + uses: actions/download-artifact@v4 + with: + name: test-selection + path: dist/testselection/ + + - name: Run EventBridge v1 Provider Tests + timeout-minutes: 30 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEBUG: 1 + COVERAGE_FILE: ".coverage.events_v1" + TEST_PATH: "tests/aws/services/events/" + JUNIT_REPORTS_FILE: "pytest-junit-events-v1.xml" + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }} --reruns 3 -o junit_suite_name=events_v1" + PROVIDER_OVERRIDE_EVENTS: "v1" + run: make test-coverage + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-events-v1 + path: | + pytest-junit-events-v1.xml + .coverage.events_v1 + retention-days: 30 + + test-cfn-v2-engine: + name: Test CloudFormation Engine v2 + if: ${{ !inputs.onlyAcceptanceTests && ( github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') || needs.test-preflight.outputs.cloudformation-v2 == 'true' )}} + runs-on: ubuntu-latest + needs: + - test-preflight + - build + timeout-minutes: 60 + env: + COVERAGE_FILE: ".coverage.cloudformation_v2" + JUNIT_REPORTS_FILE: "pytest-junit-cloudformation-v2.xml" + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare Local Test Environment + uses: ./.github/actions/setup-tests-env + + - name: Download Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + uses: actions/download-artifact@v4 + with: + name: test-selection + path: dist/testselection/ + + - name: Run CloudFormation Engine v2 Tests + timeout-minutes: 30 + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TEST_PATH: "tests/aws/services/cloudformation/v2" + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }} --reruns 3 -o junit_suite_name='cloudformation_v2'" + PROVIDER_OVERRIDE_CLOUDFORMATION: "engine-v2" + run: make test-coverage + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-cloudformation-v2 + include-hidden-files: true + path: | + ${{ env.COVERAGE_FILE }} + ${{ env.JUNIT_REPORTS_FILE }} + retention-days: 30 + + publish-alternative-provider-test-results: + name: Publish Alternative Provider Test Results + needs: + - test-cfn-v2-engine + - test-events-v1 + - test-ddb-v2 + - test-cloudwatch-v1 + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + issues: read + # execute on success or failure, but not if the workflow is cancelled or any of the dependencies has been skipped + if: always() && !cancelled() && !contains(needs.*.result, 'skipped') + steps: + - name: Download Cloudformation v2 Artifacts + uses: actions/download-artifact@v4 + with: + pattern: test-results-cloudformation-v2 + + - name: Download EventBridge v1 Artifacts + uses: actions/download-artifact@v4 + with: + pattern: test-results-events-v1 + + - name: Download DynamoDB v2 Artifacts + uses: actions/download-artifact@v4 + with: + pattern: test-results-dynamodb-v2 + + - name: Download CloudWatch v1 Artifacts + uses: actions/download-artifact@v4 + with: + pattern: test-results-cloudwatch-v1 + + - name: Publish Bootstrap and Integration Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: success() || failure() + with: + files: | + **/pytest-junit-*.xml + check_name: "Test Results ${{ inputs.testAWSAccountId != '000000000000' && '(MA/MR) ' || ''}}- Alternative Providers" + test_file_prefix: "-/opt/code/localstack/" + action_fail_on_inconclusive: true + + capture-not-implemented: + name: "Capture Not Implemented" + if: ${{ !inputs.onlyAcceptanceTests && ( github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') ) }} + runs-on: ubuntu-latest + needs: build + env: + PLATFORM: 'amd64' + steps: + - name: Login to Docker Hub + # login to DockerHub to avoid rate limiting issues on custom runners + if: github.repository_owner == 'localstack' && env.DOCKER_PULL_SECRET_AVAILABLE == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_PULL_USERNAME }} + password: ${{ secrets.DOCKERHUB_PULL_TOKEN }} + + - name: Checkout + uses: actions/checkout@v4 + with: + # setuptools_scm requires the git history (at least until the last tag) to determine the version + fetch-depth: 0 + + - name: Load Localstack Docker Image + uses: ./.github/actions/load-localstack-docker-from-artifacts + with: + platform: "${{ env.PLATFORM }}" + + - name: Install Community Dependencies + run: make install-dev + + - name: Start LocalStack + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DISABLE_EVENTS: "1" + DEBUG: 1 + IMAGE_NAME: "localstack/localstack:latest" + run: | + source .venv/bin/activate + localstack start -d + localstack wait -t 120 || (localstack logs && false) + + - name: Run capture-not-implemented + run: | + source .venv/bin/activate + cd scripts + mkdir ../results + python -m capture_notimplemented_responses ../results/ + + - name: Print the logs + run: | + source .venv/bin/activate + localstack logs + + - name: Stop localstack + run: | + source .venv/bin/activate + localstack stop + + - name: Archive Capture-Not-Implemented Results + uses: actions/upload-artifact@v4 + with: + name: capture-notimplemented + path: results/ + retention-days: 30 diff --git a/.github/workflows/create_artifact_with_features_files.yml b/.github/workflows/create_artifact_with_features_files.yml new file mode 100644 index 0000000000000..30e87074a19c0 --- /dev/null +++ b/.github/workflows/create_artifact_with_features_files.yml @@ -0,0 +1,14 @@ +name: AWS / Archive feature files + +on: + schedule: + - cron: 0 9 * * TUE + workflow_dispatch: + +jobs: + validate-features-files: + name: Create artifact with features files + uses: localstack/meta/.github/workflows/create-artifact-with-features-files.yml@main + with: + artifact_name: 'features-files' + aws_services_path: 'localstack-core/localstack/services' diff --git a/.github/workflows/dockerhub-description.yml b/.github/workflows/dockerhub-description.yml new file mode 100644 index 0000000000000..68f30c7ef9c4a --- /dev/null +++ b/.github/workflows/dockerhub-description.yml @@ -0,0 +1,25 @@ +name: Update Docker Hub Description + +on: + push: + branches: + - master + paths: + - DOCKER.md + - .github/workflows/dockerhub-description.yml + +jobs: + dockerHubDescription: + name: Sync DockerHub Description + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Docker Hub Description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + repository: localstack/localstack + short-description: ${{ github.event.repository.description }} + readme-filepath: DOCKER.md diff --git a/.github/workflows/marker-report.yml b/.github/workflows/marker-report.yml new file mode 100644 index 0000000000000..6992be9827954 --- /dev/null +++ b/.github/workflows/marker-report.yml @@ -0,0 +1,120 @@ +name: Generate pytest marker report / Open marker report GH issue +on: + workflow_dispatch: + inputs: + dryRun: + description: 'Execute a Dry-Run? A Dry-Run will not create any issues and only print the issue content in the logs instead' + required: false + type: boolean + default: false + updateExistingIssue: + description: 'Select the empty string "" to open duplicate issues, "true" to update duplicate issues and "false" to skip duplicate issues' + required: false + type: choice + default: '' + options: + - '' + - 'false' + - 'true' + createIssue: + description: 'Open marker report github issue' + required: false + type: boolean + default: false + + push: + paths: + - "tests/**" + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + marker-report: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Cache LocalStack community dependencies (venv) + uses: actions/cache@v4 + with: + path: .venv + key: ${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-venv-${{ hashFiles('requirements-dev.txt') }} + + - name: Install dependencies + run: make install-dev + + - name: Collect marker report + if: ${{ !inputs.createIssue }} + env: + PYTEST_ADDOPTS: "-p no:localstack.testing.pytest.fixtures -p no:localstack_snapshot.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -p no:localstack.testing.pytest.stepfunctions.fixtures -p no:localstack.testing.pytest.cloudformation.fixtures -s --co --disable-warnings --marker-report --marker-report-tinybird-upload" + MARKER_REPORT_PROJECT_NAME: localstack + MARKER_REPORT_TINYBIRD_TOKEN: ${{ secrets.MARKER_REPORT_TINYBIRD_TOKEN }} + MARKER_REPORT_COMMIT_SHA: ${{ github.sha }} + run: | + . ./.venv/bin/activate + python -m pytest tests/aws/ + + # makes use of the marker report plugin localstack.testing.pytest.marker_report + - name: Generate marker report + env: + PYTEST_ADDOPTS: "-p no:localstack.testing.pytest.fixtures -p no:localstack_snapshot.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:tests.fixtures -p no:localstack.testing.pytest.stepfunctions.fixtures -p no:localstack.testing.pytest.cloudformation.fixtures -p no: -s --co --disable-warnings --marker-report --marker-report-path './target'" + MARKER_REPORT_PROJECT_NAME: localstack + MARKER_REPORT_COMMIT_SHA: ${{ github.sha }} + run: | + . ./.venv/bin/activate + pip install codeowners + python -m pytest tests/aws/ + mv ./target/marker-report*.json ./target/marker-report.json + + - name: Enrich and render marker report + if: ${{ inputs.createIssue }} + env: + MARKER_REPORT_PATH: ./target/marker-report.json + CODEOWNERS_PATH: ./CODEOWNERS + TEMPLATE_PATH: ./.github/bot_templates/MARKER_REPORT_ISSUE.md.j2 + OUTPUT_PATH: ./target/MARKER_REPORT_ISSUE.md + GITHUB_REPO: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + run: | + . ./.venv/bin/activate + pip install codeowners + python scripts/render_marker_report.py + + - name: Print generated markdown + if: ${{ inputs.createIssue }} + run: | + cat ./target/MARKER_REPORT_ISSUE.md + + - name: Upload generated markdown + if: ${{ inputs.createIssue }} + uses: actions/upload-artifact@v4 + with: + path: ./target/MARKER_REPORT_ISSUE.md + + - name: Create GH issue from template + if: inputs.dryRun != true && inputs.createIssue == true + uses: JasonEtco/create-an-issue@v2 + env: + GITHUB_TOKEN: ${{ secrets.PRO_ACCESS_TOKEN }} + with: + # `update_existing` actually has 3 possible values: + # 1. not set => will always open duplicates + # 2. false => will not update and will not open duplicates (NOOP if title conflict detected) + # 3. true => will update an existing one if conflict detected + update_existing: ${{ inputs.updateExistingIssue || '' }} +# search_existing: open + filename: ./target/MARKER_REPORT_ISSUE.md diff --git a/.github/workflows/pr-cla.yml b/.github/workflows/pr-cla.yml new file mode 100644 index 0000000000000..b81b0786207e5 --- /dev/null +++ b/.github/workflows/pr-cla.yml @@ -0,0 +1,30 @@ +name: "CLA Assistant" + +on: + issue_comment: + types: + - "created" + pull_request_target: + types: + - "opened" + - "closed" + - "synchronize" + +jobs: + cla-assistant: + runs-on: "ubuntu-latest" + steps: + - name: "CLA Assistant" + if: "(github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'" + uses: "cla-assistant/github-action@v2.6.1" + env: + GITHUB_TOKEN: "${{ secrets.PRO_ACCESS_TOKEN }}" + PERSONAL_ACCESS_TOKEN: "${{ secrets.PRO_ACCESS_TOKEN }}" + with: + remote-organization-name: "localstack" + remote-repository-name: "localstack" + path-to-signatures: "etc/cla-signatures/signatures.json" + path-to-document: "https://github.com/localstack/localstack/blob/master/.github/CLA.md" + branch: "cla-signatures" + allowlist: "localstack-bot,*[bot]" + lock-pullrequest-aftermerge: false diff --git a/.github/workflows/pr-enforce-no-major-master.yml b/.github/workflows/pr-enforce-no-major-master.yml new file mode 100644 index 0000000000000..d2efcf8f93191 --- /dev/null +++ b/.github/workflows/pr-enforce-no-major-master.yml @@ -0,0 +1,17 @@ +name: Enforce no major on master + +on: + pull_request_target: + types: [labeled, unlabeled, opened, edited, synchronize] + # only enforce for PRs targeting the master branch + branches: + - master + +jobs: + enforce-no-major: + permissions: + issues: write + pull-requests: write + uses: localstack/meta/.github/workflows/pr-enforce-no-major.yml@main + secrets: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/pr-enforce-no-major-minor-master.yml b/.github/workflows/pr-enforce-no-major-minor-master.yml new file mode 100644 index 0000000000000..60fbd79b4108e --- /dev/null +++ b/.github/workflows/pr-enforce-no-major-minor-master.yml @@ -0,0 +1,17 @@ +name: Enforce no major or minor on master + +on: + pull_request_target: + types: [labeled, unlabeled, opened, edited, synchronize] + # only enforce for PRs targeting the master branch + branches: + - master + +jobs: + enforce-no-major-minor: + permissions: + issues: write + pull-requests: write + uses: localstack/meta/.github/workflows/pr-enforce-no-major-minor.yml@main + secrets: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/pr-enforce-pr-labels.yml b/.github/workflows/pr-enforce-pr-labels.yml new file mode 100644 index 0000000000000..b316c45845360 --- /dev/null +++ b/.github/workflows/pr-enforce-pr-labels.yml @@ -0,0 +1,11 @@ +name: Enforce PR Labels + +on: + pull_request_target: + types: [labeled, unlabeled, opened, edited, synchronize] + +jobs: + enforce-semver-labels: + uses: localstack/meta/.github/workflows/pr-enforce-semver-labels.yml@main + secrets: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/pr-validate-features-files.yml b/.github/workflows/pr-validate-features-files.yml new file mode 100644 index 0000000000000..d62d2b5ffaa77 --- /dev/null +++ b/.github/workflows/pr-validate-features-files.yml @@ -0,0 +1,14 @@ +name: Validate AWS features files + +on: + pull_request: + paths: + - localstack-core/localstack/services/** + branches: + - master + +jobs: + validate-features-files: + uses: localstack/meta/.github/workflows/pr-validate-features-files.yml@main + with: + aws_services_path: 'localstack-core/localstack/services' diff --git a/.github/workflows/pr-welcome-first-time-contributors.yml b/.github/workflows/pr-welcome-first-time-contributors.yml new file mode 100644 index 0000000000000..c01b376ececde --- /dev/null +++ b/.github/workflows/pr-welcome-first-time-contributors.yml @@ -0,0 +1,81 @@ +name: Welcome First Time Contributors ✨ + +on: + pull_request_target: + types: + - opened + issues: + types: + - opened + +jobs: + welcome: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} + script: | + const issueMessage = `Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/slack/) for real-time help and drop a message to [LocalStack Support](https://docs.localstack.cloud/getting-started/help-and-support/) if you are a licensed user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md).`; + const prMessage = `Welcome to LocalStack! Thanks for raising your first Pull Request and landing in your contributions. Our team will reach out with any reviews or feedbacks that we have shortly. We recommend joining our [Slack Community](https://localstack.cloud/slack/) and share your PR on the **#community** channel to share your contributions with us. Please make sure you are following our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [Code of Conduct](https://github.com/localstack/.github/blob/main/CODE_OF_CONDUCT.md).`; + + if (!issueMessage && !prMessage) { + throw new Error('Action should have either issueMessage or prMessage set'); + } + + const isIssue = !!context.payload.issue; + let isFirstContribution; + if (isIssue) { + const query = `query($owner:String!, $name:String!, $contributor:String!) { + repository(owner:$owner, name:$name){ + issues(first: 1, filterBy: {createdBy:$contributor}){ + totalCount + } + } + }`; + + const variables = { + owner: context.repo.owner, + name: context.repo.repo, + contributor: context.payload.sender.login + }; + + const { repository: { issues: { totalCount } } } = await github.graphql(query, variables); + isFirstContribution = totalCount === 1; + } else { + const query = `query($qstr: String!) { + search(query: $qstr, type: ISSUE, first: 1) { + issueCount + } + }`; + const variables = { + "qstr": `repo:${context.repo.owner}/${context.repo.repo} type:pr author:${context.payload.sender.login}`, + }; + const { search: { issueCount } } = await github.graphql(query, variables); + isFirstContribution = issueCount === 1; + } + + if (!isFirstContribution) { + return; + } + + const message = isIssue ? issueMessage : prMessage; + if (isIssue) { + const issueNumber = context.payload.issue.number; + await github.rest.issues.createComment({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: issueNumber, + body: message + }); + } + else { + const pullNumber = context.payload.pull_request.number; + await github.rest.pulls.createReview({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + pull_number: pullNumber, + body: message, + event: 'COMMENT' + }); + } diff --git a/.github/workflows/rebase-release-prs.yml b/.github/workflows/rebase-release-prs.yml new file mode 100644 index 0000000000000..44f9ba9397c06 --- /dev/null +++ b/.github/workflows/rebase-release-prs.yml @@ -0,0 +1,35 @@ +name: Rebase Release PRs +on: + workflow_dispatch: + schedule: + - cron: 0 5 * * MON-FRI +jobs: + find-release-branches: + runs-on: ubuntu-latest + name: "find release branches" + steps: + - name: find release branches + id: find-release-branches + uses: actions/github-script@v7 + with: + script: | + // find all refs in the repo starting with "release/" + const refs = await github.rest.git.listMatchingRefs({owner: "localstack", repo: "localstack", ref: "heads/release/"}) + // extract the ref name of every ref entry in the data field of the response + // remove the "refs/heads/" prefix and add the organization prefix for the rebase action + // f.e. ["localstack:release/v1.3", "localstack:release/v2"] + return refs.data.map(ref => "localstack:" + ref.ref.substring(11)) + outputs: + matrix: ${{ steps.find-release-branches.outputs.result }} + rebase: + runs-on: ubuntu-latest + needs: "find-release-branches" + strategy: + matrix: + head: ${{ fromJson(needs.find-release-branches.outputs.matrix) }} + steps: + - uses: peter-evans/rebase@v3 + with: + token: ${{ secrets.PRO_ACCESS_TOKEN }} + head: ${{ matrix.head }} + base: master diff --git a/.github/workflows/rebase-release-targeting-prs.yml b/.github/workflows/rebase-release-targeting-prs.yml new file mode 100644 index 0000000000000..d92480c9a5ef6 --- /dev/null +++ b/.github/workflows/rebase-release-targeting-prs.yml @@ -0,0 +1,21 @@ +name: Rebase PRs targeting Release Branches +on: + push: + branches: + - 'release/*' +jobs: + rebase: + runs-on: ubuntu-latest + steps: + - name: Determine base ref + id: determine-base-ref + uses: actions/github-script@v7 + with: + result-encoding: string + script: | + // remove the ref prefx "refs/heads/" + return context.payload.ref.substr(11) + - uses: peter-evans/rebase@v3 + with: + token: ${{ secrets.PRO_ACCESS_TOKEN }} + base: ${{steps.determine-base-ref.outputs.result}} diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml new file mode 100644 index 0000000000000..b55bca386e155 --- /dev/null +++ b/.github/workflows/stale-bot.yml @@ -0,0 +1,12 @@ +name: Triage Stale Issues + +on: + schedule: + - cron: "0 * * * *" + workflow_dispatch: + +jobs: + sync-with-project: + uses: localstack/meta/.github/workflows/stale-bot.yml@main + secrets: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 0000000000000..f9835bf171c39 --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,15 @@ +name: Sync Labels + +on: + schedule: + # once a day at midnight + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + sync-labels: + uses: localstack/meta/.github/workflows/sync-labels.yml@main + with: + categories: status,aws,semver + secrets: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/sync-project.yml b/.github/workflows/sync-project.yml new file mode 100644 index 0000000000000..2bc927284129c --- /dev/null +++ b/.github/workflows/sync-project.yml @@ -0,0 +1,14 @@ +name: Sync Project Cards + +on: + issues: + types: + - labeled + - unlabeled + - opened + +jobs: + sync-with-project: + uses: localstack/meta/.github/workflows/sync-project.yml@main + secrets: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/tests-bin.yml b/.github/workflows/tests-bin.yml new file mode 100644 index 0000000000000..4da8063a78600 --- /dev/null +++ b/.github/workflows/tests-bin.yml @@ -0,0 +1,58 @@ +name: Helper Script Tests +on: + workflow_dispatch: + pull_request: + paths: + - 'bin/docker-helper.sh' + - '.github/workflows/tests-bin.yml' + - 'tests/bin/*.bats' + push: + paths: + - 'bin/docker-helper.sh' + - '.github/workflows/tests-bin.yml' + - 'tests/bin/*.bats' + branches: + - master + - release/* + +jobs: + script-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup BATS + run: | + git clone https://github.com/bats-core/bats-core.git "$HOME"/bats-core + cd "$HOME"/bats-core + sudo ./install.sh /usr/local + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install helper script dependencies + run: pip install --upgrade setuptools setuptools_scm + + - name: Run bats tests + run: | + bats --report-formatter junit -r tests/bin/ --output . + mv report.xml tests-junit-base.xml + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-tests-bin + path: tests-junit-*.xml + retention-days: 30 + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: success() || failure() + with: + files: tests-junit-*.xml + check_name: "Helper Script Tests" + action_fail_on_inconclusive: true diff --git a/.github/workflows/tests-cli.yml b/.github/workflows/tests-cli.yml new file mode 100644 index 0000000000000..68eca09252eaf --- /dev/null +++ b/.github/workflows/tests-cli.yml @@ -0,0 +1,118 @@ +name: CLI Tests +on: + workflow_dispatch: + inputs: + PYTEST_LOGLEVEL: + type: choice + description: Loglevel for PyTest + options: + - DEBUG + - INFO + - WARNING + - ERROR + - CRITICAL + default: WARNING + pull_request: + paths: + - '**' + - '!.github/**' + - '.github/workflows/tests-cli.yml' + - '!docs/**' + - '!scripts/**' + - '!.dockerignore' + - '!.git-blame-ignore-revs' + - '!CODE_OF_CONDUCT.md' + - '!CODEOWNERS' + - '!CONTRIBUTING.md' + - '!docker-compose.yml' + - '!docker-compose-pro.yml' + - '!Dockerfile*' + - '!LICENSE.txt' + - '!README.md' + push: + paths: + - '**' + - '!.github/**' + - '.github/workflows/tests-cli.yml' + - '!docs/**' + - '!scripts/**' + - '!.dockerignore' + - '!.git-blame-ignore-revs' + - '!CODE_OF_CONDUCT.md' + - '!CODEOWNERS' + - '!CONTRIBUTING.md' + - '!docker-compose.yml' + - '!docker-compose-pro.yml' + - '!Dockerfile*' + - '!LICENSE.txt' + - '!README.md' + branches: + - master + - release/* + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + # Configure PyTest log level + PYTEST_LOGLEVEL: "${{ inputs.PYTEST_LOGLEVEL || 'WARNING' }}" + # Set non-job-specific environment variables for pytest-tinybird + TINYBIRD_URL: https://api.tinybird.co + TINYBIRD_DATASOURCE: community_tests_cli + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_CI_TOKEN }} + CI_COMMIT_BRANCH: ${{ github.head_ref || github.ref_name }} + CI_COMMIT_SHA: ${{ github.sha }} + CI_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + # report to tinybird if executed on master + TINYBIRD_PYTEST_ARGS: "${{ github.repository == 'localstack/localstack' && github.ref == 'refs/heads/master' && '--report-to-tinybird ' || '' }}" + +permissions: + contents: read # checkout the repository + +jobs: + cli-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] + timeout-minutes: 10 + env: + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }}-${{ matrix.python-version }} + CI_JOB_ID: ${{ github.job }}-${{ matrix.python-version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install CLI test dependencies + run: | + make venv + source .venv/bin/activate + pip install -e . + pip install pytest pytest-tinybird + - name: Run CLI tests + env: + PYTEST_ADDOPTS: "${{ env.TINYBIRD_PYTEST_ARGS }}-p no:localstack.testing.pytest.fixtures -p no:localstack_snapshot.pytest.snapshot -p no:localstack.testing.pytest.filters -p no:localstack.testing.pytest.fixture_conflicts -p no:localstack.testing.pytest.validation_tracking -p no:localstack.testing.pytest.path_filter -p no:tests.fixtures -p no:localstack.testing.pytest.stepfunctions.fixtures -p no:localstack.testing.pytest.cloudformation.fixtures -s" + TEST_PATH: "tests/cli/" + run: make test + + push-to-tinybird: + if: always() && github.ref == 'refs/heads/master' && github.repository == 'localstack/localstack' + runs-on: ubuntu-latest + needs: cli-tests + permissions: + actions: read + steps: + - name: Push to Tinybird + uses: localstack/tinybird-workflow-push@v3 + with: + workflow_id: "tests_cli" + tinybird_token: ${{ secrets.TINYBIRD_CI_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tinybird_datasource: "ci_workflows" diff --git a/.github/workflows/tests-podman.yml b/.github/workflows/tests-podman.yml new file mode 100644 index 0000000000000..664de59630750 --- /dev/null +++ b/.github/workflows/tests-podman.yml @@ -0,0 +1,90 @@ +name: Podman Docker Client Tests + +on: + workflow_dispatch: + inputs: + PYTEST_LOGLEVEL: + type: choice + description: Loglevel for PyTest + options: + - DEBUG + - INFO + - WARNING + - ERROR + - CRITICAL + default: WARNING + +env: + # Configure PyTest log level + PYTEST_LOGLEVEL: "${{ inputs.PYTEST_LOGLEVEL || 'WARNING' }}" + # Set non-job-specific environment variables for pytest-tinybird + TINYBIRD_URL: https://api.tinybird.co + TINYBIRD_DATASOURCE: community_tests_podman + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_CI_TOKEN }} + CI_COMMIT_BRANCH: ${{ github.head_ref || github.ref_name }} + CI_COMMIT_SHA: ${{ github.sha }} + CI_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + # report to tinybird if executed on master + TINYBIRD_PYTEST_ARGS: "${{ github.repository == 'localstack/localstack' && github.ref == 'refs/heads/master' && '--report-to-tinybird ' || '' }}" + +jobs: + podman-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install podman and test dependencies + run: | + make install-test & + + # install podman + sudo apt update + sudo apt install -y podman + podman ps + podman system info + + # disable Docker, to ensure the tests are running against Podman only + docker ps + sudo mv /var/run/docker.sock /var/run/docker.sock.bk + docker ps && exit 1 + dockerCmd=$(which docker) + sudo mv $dockerCmd $dockerCmd".bk" + + # wait for async installation process to finish + wait + + - name: Run Podman Docker client tests + env: + DOCKER_CMD: "podman" + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}" + TEST_PATH: "tests/integration/docker_utils" + DEBUG: "1" + run: | + # determine path of podman socket + podmanSocket=$(podman system info --format json | jq -r '.host.remoteSocket.path') + echo "Running tests against local podman socket $podmanSocket" + DOCKER_HOST=$podmanSocket make test + + push-to-tinybird: + if: always() && github.ref == 'refs/heads/master' && github.repository == 'localstack/localstack' + runs-on: ubuntu-latest + needs: podman-tests + steps: + - name: Push to Tinybird + uses: localstack/tinybird-workflow-push@v3 + with: + workflow_id: "tests_podman" + tinybird_token: ${{ secrets.TINYBIRD_CI_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tinybird_datasource: "ci_workflows" diff --git a/.github/workflows/tests-pro-integration.yml b/.github/workflows/tests-pro-integration.yml new file mode 100644 index 0000000000000..22fe1a82b138e --- /dev/null +++ b/.github/workflows/tests-pro-integration.yml @@ -0,0 +1,412 @@ +name: Community Integration Tests against Pro +on: + workflow_call: + inputs: + disableCaching: + description: 'Disable Caching' + required: false + type: boolean + default: false + disableTestSelection: + description: 'Disable Test Selection' + required: false + type: boolean + default: false + targetRef: + description: 'LocalStack Pro Ref' + required: false + type: string + PYTEST_LOGLEVEL: + type: string + description: Loglevel for PyTest + default: WARNING + workflow_dispatch: + inputs: + disableCaching: + description: 'Disable Caching' + required: false + type: boolean + default: false + disableTestSelection: + description: 'Disable Test Selection' + required: false + type: boolean + default: false + targetRef: + description: 'LocalStack Pro Ref' + required: false + type: string + PYTEST_LOGLEVEL: + type: choice + description: Loglevel for PyTest + options: + - DEBUG + - INFO + - WARNING + - ERROR + - CRITICAL + default: WARNING + pull_request: + paths: + - '**' + - '!.github/**' + - '.github/workflows/tests-pro-integration.yml' + - '!docs/**' + - '!scripts/**' + - './scripts/build_common_test_functions.sh' + - '!.dockerignore' + - '!.git-blame-ignore-revs' + - '!CODE_OF_CONDUCT.md' + - '!CODEOWNERS' + - '!CONTRIBUTING.md' + - '!docker-compose.yml' + - '!docker-compose-pro.yml' + - '!Dockerfile*' + - '!LICENSE.txt' + - '!README.md' + schedule: + - cron: '15 4 * * *' # run once a day at 4:15 AM UTC + push: + paths: + - '**' + - '!.github/**' + - '.github/workflows/tests-pro-integration.yml' + - '!docs/**' + - '!scripts/**' + - './scripts/build_common_test_functions.sh' + - '!.dockerignore' + - '!.git-blame-ignore-revs' + - '!CODE_OF_CONDUCT.md' + - '!CODEOWNERS' + - '!CONTRIBUTING.md' + - '!docker-compose.yml' + - '!docker-compose-pro.yml' + - '!Dockerfile*' + - '!LICENSE.txt' + - '!README.md' + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + # Configure PyTest log level + PYTEST_LOGLEVEL: "${{ inputs.PYTEST_LOGLEVEL || 'WARNING' }}" + # Set non-job-specific environment variables for pytest-tinybird + TINYBIRD_URL: https://api.tinybird.co + TINYBIRD_DATASOURCE: community_tests_pro_integration + TINYBIRD_TOKEN: ${{ secrets.TINYBIRD_CI_TOKEN }} + CI_COMMIT_BRANCH: ${{ github.head_ref || github.ref_name }} + CI_COMMIT_SHA: ${{ github.sha }} + CI_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + # report to tinybird if executed on master on community AND pro (targetRef not set) + TINYBIRD_PYTEST_ARGS: "${{ github.repository == 'localstack/localstack' && github.ref == 'refs/heads/master' && inputs.targetRef == '' && '--report-to-tinybird ' || '' }}" + # enable test selection if not running on master and test selection is not explicitly disabled + TESTSELECTION_PYTEST_ARGS: "${{ !inputs.disableTestSelection && '--path-filter=../../localstack/target/testselection/test-selection.txt ' || '' }}" + +jobs: + test-pro: + name: "Community Integration Tests against Pro" + # If this is triggered by a pull_request, make sure the PR head repo name is the same as the target repo name + # (i.e. do not execute job for workflows coming from forks) + if: >- + ( + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + ) + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + matrix: + group: [ 1, 2 ] + fail-fast: false + env: + # Set job-specific environment variables for pytest-tinybird + CI_JOB_NAME: ${{ github.job }} + CI_JOB_ID: ${{ github.job }} + steps: + - name: Free Disk Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + # don't perform all optimizations to decrease action execution time + large-packages: false + docker-images: false + + - name: Checkout Community + uses: actions/checkout@v4 + with: + path: localstack + fetch-depth: 0 # we need the additional commits to figure out the merge base for test selection + + - name: "Determine Companion Ref" + id: determine-companion-ref + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} + result-encoding: string + script: | + if (context.payload.inputs && context.payload.inputs.targetRef) { + console.log("Using manually set target reference: ", context.payload.inputs.targetRef) + return context.payload.inputs.targetRef + } + + const DEFAULT_REF = "refs/heads/master" + + async function isCompanionRefExisting(refName) { + try { + // strip the leading "refs/" for the API call + const apiRef = refName.substr(5) + console.log("Checking if companion repo has ref: ", apiRef) + await github.rest.git.getRef({owner: "localstack", repo: "localstack-ext", ref: apiRef}) + return true + } catch (error) { + if (error.status == 404) { + return false + } else { + // another (unexpected) error occurred, raise the error + throw new Error(`Fetching companion refs failed: ${error}`) + } + } + } + + let ref = context.ref + let baseRef = null + if (context.payload.pull_request) { + // pull requests have their own refs (f.e. 'refs/pull/1/merge') + // use the PR head ref instead + ref = `refs/heads/${context.payload.pull_request.head.ref}` + baseRef = `refs/heads/${context.payload.pull_request.base.ref}` + } + + if (ref == DEFAULT_REF) { + console.log("Current ref is default ref. Using the same for ext repo: ", DEFAULT_REF) + return DEFAULT_REF + } + + if (await isCompanionRefExisting(ref)) { + console.log("Using companion ref in ext repo: ", ref) + return ref + } else if (baseRef && baseRef != DEFAULT_REF && (await isCompanionRefExisting(baseRef))) { + console.log("Using PR base companion ref in ext repo: ", baseRef) + return baseRef + } + + // the companion repo does not have a companion ref, use the default + console.log("Ext repo does not have a companion ref. Using default: ", DEFAULT_REF) + return DEFAULT_REF + + - name: Checkout Pro + uses: actions/checkout@v4 + with: + repository: localstack/localstack-ext + ref: ${{steps.determine-companion-ref.outputs.result}} + token: ${{ secrets.PRO_ACCESS_TOKEN }} + path: localstack-ext + + - name: Set up Python 3.11 + id: setup-python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up Node 18.x + uses: actions/setup-node@v4 + with: + node-version: 18.x + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 0.13.7 + + - name: Install OS packages + run: | + sudo apt-get update + sudo apt-get install -y --allow-downgrades libsnappy-dev jq libvirt-dev + + - name: Cache Ext Dependencies (venv) + if: inputs.disableCaching != true + uses: actions/cache@v4 + with: + path: | + localstack-ext/.venv + # include the matrix group (to re-use the venv) + key: community-it-${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-venv-${{ hashFiles('localstack-ext/localstack-pro-core/requirements-test.txt') }}-${{steps.determine-companion-ref.outputs.result}} + restore-keys: | + community-it-${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-venv-${{ hashFiles('localstack-ext/localstack-pro-core/requirements-test.txt') }}-refs/heads/master + + - name: Cache Ext Dependencies (libs) + if: inputs.disableCaching != true + uses: actions/cache@v4 + with: + path: | + localstack/localstack-core/.filesystem/var/lib/localstack + # include the matrix group (to re-use the var-libs used in the specific test group) + key: community-it-${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-libs-${{ hashFiles('**/packages.py', '**/packages/*') }}-${{steps.determine-companion-ref.outputs.result}}-group-${{ matrix.group }} + restore-keys: | + community-it-${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-libs-${{ hashFiles('**/packages.py', '**/packages/*') }}-refs/heads/master-group-${{ matrix.group }} + + - name: Restore Lambda common runtime packages + id: cached-lambda-common-restore + if: inputs.disableCaching != true + uses: actions/cache/restore@v4 + with: + path: | + localstack/tests/aws/services/lambda_/functions/common + key: community-it-${{ runner.os }}-${{ runner.arch }}-lambda-common-${{ hashFiles('localstack/tests/aws/services/lambda_/functions/common/**/src/*', 'localstack/tests/aws/services/lambda_/functions/common/**/Makefile') }} + + - name: Prebuild lambda common packages + working-directory: localstack + run: ./scripts/build_common_test_functions.sh `pwd`/tests/aws/services/lambda_/functions/common + + - name: Save Lambda common runtime packages + if: inputs.disableCaching != true + uses: actions/cache/save@v4 + with: + path: | + localstack/tests/aws/services/lambda_/functions/common + key: ${{ steps.cached-lambda-common-restore.outputs.cache-primary-key }} + + - name: Install Python Dependencies for Pro + working-directory: localstack-ext + run: make install-ci + + - name: Link Community into Pro venv + working-directory: localstack-ext + run: | + source .venv/bin/activate + pip install -e ../localstack[runtime,test] + + - name: Create Community Entrypoints + working-directory: localstack + # Entrypoints need to be generated _after_ the community edition has been linked into the venv + run: | + VENV_DIR="../localstack-ext/.venv" make entrypoints + ../localstack-ext/.venv/bin/python -m plux show + + - name: Create Pro Entrypoints + working-directory: localstack-ext + # Entrypoints need to be generated _after_ the community edition has been linked into the venv + run: | + make entrypoints + cd localstack-pro-core + ../.venv/bin/python -m plux show + + - name: Test Pro Startup + env: + DEBUG: 1 + DNS_ADDRESS: 0 + LOCALSTACK_AUTH_TOKEN: "test" + working-directory: localstack-ext + run: | + source .venv/bin/activate + bin/test_localstack_pro.sh + + - name: Determine Test Selection + if: ${{ env.TESTSELECTION_PYTEST_ARGS }} + working-directory: localstack + run: | + if [ -z "${{ github.event.pull_request.base.sha }}" ]; then + echo "Do test selection based on Push event" + else + echo "Do test selection based on Pull Request event" + SCRIPT_OPTS="--base-commit-sha ${{ github.event.pull_request.base.sha }} --head-commit-sha ${{ github.event.pull_request.head.sha }}" + fi + . ../localstack-ext/.venv/bin/activate + python -m localstack.testing.testselection.scripts.generate_test_selection $(pwd) target/testselection/test-selection.txt $SCRIPT_OPTS || (mkdir -p target/testselection && echo "SENTINEL_ALL_TESTS" >> target/testselection/test-selection.txt) + echo "Resulting Test Selection file:" + cat target/testselection/test-selection.txt + + - name: Run Community Integration Tests + env: + # add the GitHub API token to avoid rate limit issues + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEBUG: 1 + DISABLE_BOTO_RETRIES: 1 + DNS_ADDRESS: 0 + LAMBDA_EXECUTOR: "local" + LOCALSTACK_AUTH_TOKEN: "test" + AWS_SECRET_ACCESS_KEY: "test" + AWS_ACCESS_KEY_ID: "test" + AWS_DEFAULT_REGION: "us-east-1" + JUNIT_REPORTS_FILE: "pytest-junit-community-${{ matrix.group }}.xml" + TEST_PATH: "../../localstack/tests/aws/" # TODO: run tests in tests/integration + PYTEST_ARGS: "${{ env.TINYBIRD_PYTEST_ARGS }}${{ env.TESTSELECTION_PYTEST_ARGS }}--splits ${{ strategy.job-total }} --group ${{ matrix.group }} --durations-path ../../localstack/.test_durations --store-durations" + working-directory: localstack-ext + run: | + # Remove the host tmp folder (might contain remnant files with different permissions) + sudo rm -rf ../localstack/localstack-core/.filesystem/var/lib/localstack/tmp + make test + + - name: Archive Test Durations + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: pytest-split-durations-community-${{ matrix.group }} + path: | + localstack/.test_durations + retention-days: 5 + + - name: Archive Test Results + uses: actions/upload-artifact@v4 + if: success() || failure() + with: + name: test-results-community-${{ matrix.group }} + path: | + localstack-ext/localstack-pro-core/pytest-junit-community-${{ matrix.group }}.xml + retention-days: 30 + + publish-pro-test-results: + name: "Publish Community Tests against Pro Results" + needs: test-pro + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + issues: read + # If this is triggered by a pull_request, make sure the PR head repo name is the same as the target repo name + # (i.e. do not execute job for workflows coming from forks) + if: >- + (success() || failure()) && ( + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + ) + steps: + - name: Download Artifacts 1 + uses: actions/download-artifact@v4 + with: + name: test-results-community-1 + + - name: Download Artifacts 2 + uses: actions/download-artifact@v4 + with: + name: test-results-community-2 + + - name: Publish Community Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: "pytest-junit-community-*.xml" + check_name: "LocalStack Community integration with Pro" + action_fail_on_inconclusive: true + + push-to-tinybird: + if: always() && github.ref == 'refs/heads/master' && github.repository == 'localstack/localstack' + runs-on: ubuntu-latest + needs: publish-pro-test-results + steps: + - name: Push to Tinybird + uses: localstack/tinybird-workflow-push@v3 + with: + workflow_id: "tests_pro_integration" + tinybird_token: ${{ secrets.TINYBIRD_CI_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tinybird_datasource: "ci_workflows" diff --git a/.github/workflows/update-openapi-spec.yml b/.github/workflows/update-openapi-spec.yml new file mode 100644 index 0000000000000..07d28dee8eccf --- /dev/null +++ b/.github/workflows/update-openapi-spec.yml @@ -0,0 +1,25 @@ +name: Update OpenAPI Spec + +on: + push: + branches: + - master + paths: + - '**/*openapi.yaml' + - '**/*openapi.yml' + workflow_dispatch: + +jobs: + update-openapi-spec: + runs-on: ubuntu-latest + + steps: + # This step dispatches a workflow in the OpenAPI repo, updating the spec and opening a PR. + - name: Dispatch update spec workflow + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.PRO_ACCESS_TOKEN }} + repository: localstack/openapi + event-type: openapi-update + # A git reference is needed when we want to dispatch the worflow from another branch. + client-payload: '{"ref": "${{ github.ref }}", "repo": "${{ github.repository }}"}' diff --git a/.github/workflows/update-test-durations.yml b/.github/workflows/update-test-durations.yml new file mode 100644 index 0000000000000..12c33df527337 --- /dev/null +++ b/.github/workflows/update-test-durations.yml @@ -0,0 +1,75 @@ +name: Update test durations + +on: + schedule: + - cron: 0 4 * 1-12 MON + workflow_dispatch: + inputs: + publishMethod: + description: 'Select how to publish the workflow result' + type: choice + options: + - UPLOAD_ARTIFACT + - CREATE_PR + default: UPLOAD_ARTIFACT + +env: + # Take test durations only for this platform + PLATFORM: "amd64" + +jobs: + report: + name: "Download, merge and create PR with test durations" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: localstack + + - name: Latest run-id from community repository + run: | + latest_workflow_id=$(curl -s https://api.github.com/repos/localstack/localstack/actions/workflows \ + | jq '.workflows[] | select(.path==".github/workflows/aws-main.yml").id') + latest_run_id=$(curl -s \ + "https://api.github.com/repos/localstack/localstack/actions/workflows/${latest_workflow_id}/runs?branch=master&status=success&per_page=30" \ + | jq '[.workflow_runs[] | select(.event == "schedule")][0].id') + echo "Latest run: https://github.com/localstack/localstack/actions/runs/${latest_run_id}" + echo "AWS_MAIN_LATEST_SCHEDULED_RUN_ID=${latest_run_id}" >> $GITHUB_ENV + + - name: Load test durations + uses: actions/download-artifact@v4 + with: + pattern: pytest-split-durations-${{ env.PLATFORM }}-* + path: artifacts-test-durations + merge-multiple: true + run-id: ${{ env.AWS_MAIN_LATEST_SCHEDULED_RUN_ID }} + github-token: ${{ secrets.GITHUB_TOKEN }} # PAT with access to artifacts from GH Actions + + - name: Merge test durations files + shell: bash + run: | + jq -s 'add | to_entries | sort_by(.key) | from_entries' artifacts-test-durations/.test_durations-${{ env.PLATFORM }}* > localstack/.test_durations || echo "::warning::Test durations were not merged" + + - name: Upload artifact with merged test durations + uses: actions/upload-artifact@v4 + if: ${{ success() && inputs.publishMethod == 'UPLOAD_ARTIFACT' }} + with: + name: merged-test-durations + path: localstack/.test_durations + include-hidden-files: true + if-no-files-found: error + + - name: Create PR + uses: peter-evans/create-pull-request@v7 + if: ${{ success() && inputs.publishMethod != 'UPLOAD_ARTIFACT' }} + with: + title: "[Testing] Update test durations" + body: "This PR includes an updated `.test_durations` file, generated based on latest test durations from master" + branch: "test-durations-auto-updates" + author: "LocalStack Bot <localstack-bot@users.noreply.github.com>" + committer: "LocalStack Bot <localstack-bot@users.noreply.github.com>" + commit-message: "CI: update .test_durations to latest version" + path: localstack + add-paths: .test_durations + labels: "semver: patch, area: testing, area: ci" + token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/upgrade-python-dependencies.yml b/.github/workflows/upgrade-python-dependencies.yml new file mode 100644 index 0000000000000..83b26043bd8c7 --- /dev/null +++ b/.github/workflows/upgrade-python-dependencies.yml @@ -0,0 +1,13 @@ +name: Upgrade Pinned Python Dependencies + +on: + schedule: + - cron: 0 5 * * TUE + workflow_dispatch: + + +jobs: + upgrade-dependencies: + uses: localstack/meta/.github/workflows/upgrade-python-dependencies.yml@main + secrets: + github-token: ${{ secrets.PRO_ACCESS_TOKEN }} diff --git a/.github/workflows/validate-codeowners.yml b/.github/workflows/validate-codeowners.yml new file mode 100644 index 0000000000000..2f0b19cfd7ca0 --- /dev/null +++ b/.github/workflows/validate-codeowners.yml @@ -0,0 +1,22 @@ +name: LocalStack - Validate Codeowners + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + validate-codeowners: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Validate codeowners + uses: mszostok/codeowners-validator@v0.7.4 + with: + checks: "files,duppatterns,syntax" + experimental_checks: "avoid-shadowing" diff --git a/.gitignore b/.gitignore index de7b98efa00cd..eebbe6e846c4f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,24 +2,77 @@ *.pyo *.class *.log +*.iml +.coverage.* +htmlcov +*.orig +*.sln + +# ignore .vs files that store temproray cache of visual studio workspace settings +.vs + +.cache +.filesystem /infra/ +localstack/infra/ + /node_modules/ package-lock.json /nosetests.xml + +.env +venv /.venv* +/.coverage .settings/ .project .classpath -/.coverage -node_modules/ -localstack/infra/ .DS_Store -/build/ -/dist/ *.egg-info/ .eggs/ -/target/ -*.sw* +.vagrant/ ~* *~ -/.idea + +node_modules/ +/build/ +/dist/ +/target/ + +.idea +.vscode + +**/obj/** +**/lib/** + +!bin/docker-entrypoint.sh + +requirements.copy.txt +.serverless/ + +**/.terraform/* +*.tfstate +*.tfstate.* +*tfplan +*.terraform.lock.hcl + +venv +api_states + +/aws/lambdas/golang/handler.zip +/tests/aws/lambdas/golang/handler.zip +tmp/ + +volume/ + +# ANTLR4 ID plugin. +gen/ + +# hypothesis pytest plugin +.hypothesis + +# RAW snapshots +*.raw.snapshot.json + +# setuptools_scm version.py +*/*/version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000..75487ebeb308d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.12.3 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + # Run the formatter. + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.16.1 + hooks: + - id: mypy + entry: bash -c 'cd localstack-core && mypy --install-types --non-interactive' + additional_dependencies: ['botocore-stubs', 'rolo'] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/localstack/pre-commit-hooks + rev: v1.2.1 + hooks: + - id: check-pinned-deps-for-needed-upgrade + + - repo: https://github.com/python-openapi/openapi-spec-validator + rev: 0.8.0b1 + hooks: + - id: openapi-spec-validator + files: .*openapi.*\.(json|yaml|yml) + exclude: ^(tests/|.github/workflows/) diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000000..2c0733315e415 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/.test_durations b/.test_durations new file mode 100644 index 0000000000000..fc4927a66ecd1 --- /dev/null +++ b/.test_durations @@ -0,0 +1,5259 @@ +{ + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_lambda_dynamodb": 1.8591925400000093, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_opensearch_crud": 3.4542978110000035, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_search_books": 63.59329259399999, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_setup": 89.16224498400001, + "tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.py::TestKinesisFirehoseScenario::test_kinesis_firehose_s3": 0.002819474000034461, + "tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py::TestLambdaDestinationScenario::test_destination_sns": 5.6126325460001, + "tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py::TestLambdaDestinationScenario::test_infra": 12.26068547899996, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_prefill_dynamodb_table": 25.062954546999947, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input0-SUCCEEDED]": 4.753118397000037, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input1-SUCCEEDED]": 2.7944347069999935, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input2-FAILED]": 0.9179088109999611, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input3-FAILED]": 0.6808095409999737, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input4-FAILED]": 0.5240530809999768, + "tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py::TestMythicalMisfitsScenario::test_deployed_infra_state": 0.0027725980000354866, + "tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py::TestMythicalMisfitsScenario::test_populate_data": 0.0017560499999831336, + "tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py::TestMythicalMisfitsScenario::test_user_clicks_are_stored": 0.001746832000037557, + "tests/aws/scenario/note_taking/test_note_taking.py::TestNoteTakingScenario::test_notes_rest_api": 4.639125250000006, + "tests/aws/scenario/note_taking/test_note_taking.py::TestNoteTakingScenario::test_validate_infra_setup": 32.74242746199997, + "tests/aws/services/acm/test_acm.py::TestACM::test_boto_wait_for_certificate_validation": 1.118890973999953, + "tests/aws/services/acm/test_acm.py::TestACM::test_certificate_for_subdomain_wildcard": 2.217093190000014, + "tests/aws/services/acm/test_acm.py::TestACM::test_create_certificate_for_multiple_alternative_domains": 11.206475674999979, + "tests/aws/services/acm/test_acm.py::TestACM::test_domain_validation": 0.2747168080000506, + "tests/aws/services/acm/test_acm.py::TestACM::test_import_certificate": 1.0384068200000343, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiAuthorizer::test_authorizer_crud_no_api": 0.03355816800001321, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_doc_parts_crud_no_api": 0.03499321999998983, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_documentation_part_lifecycle": 0.07383347799998319, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_import_documentation_parts": 0.13196213499998066, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_create_documentation_part_operations": 0.04432463400002007, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_delete_documentation_part": 0.05508120899997948, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_get_documentation_part": 0.04705650800002559, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_get_documentation_parts": 0.016791373999978987, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_update_documentation_part": 0.058419485000001714, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_method_lifecycle": 0.07490121500001123, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_method_request_parameters": 0.050075023999966106, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_put_method_model": 0.28907013999997844, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_put_method_validation": 0.07500743499997498, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_update_method": 0.08137474700004077, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_update_method_validation": 0.15210924999996678, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_model_lifecycle": 0.07780886799997688, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_model_validation": 0.10623816899999383, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_update_model": 0.07650709399996458, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_create_request_validator_invalid_api_id": 0.01676081699997667, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_delete_request_validator": 0.04610745300004737, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_get_request_validator": 0.04737211000002617, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_get_request_validators": 0.01598174600007951, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_update_request_validator_operations": 0.0658933759999627, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_request_validator_lifecycle": 0.0967978199999493, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_validators_crud_no_api": 0.034875632999956, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_proxy_resource": 0.8957564810000349, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_proxy_resource_validation": 0.07905507799995348, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_resource_parent_invalid": 0.03482252899999594, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_delete_resource": 0.07523433899996235, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_resource_lifecycle": 0.12031327400001146, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_update_resource_behaviour": 0.1741109189999861, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_binary_media_types": 0.027287844999989375, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_optional_params": 0.07789825200001133, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_tags": 0.04767004299998234, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_get_api_case_insensitive": 0.0019003470000029665, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_list_and_delete_apis": 0.09343369599997686, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_behaviour": 0.05412815500000079, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_compression": 0.09286251099996434, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_invalid_api_id": 0.015286289000016495, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_operation_add_remove": 0.0521871969999097, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_crud": 0.11371110400000362, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_put": 0.10616874300006884, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_validation": 0.11559211499996991, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_update_gateway_response": 0.13145873799999208, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_integration": 0.04566580500005557, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_responsetemplates": 0.0019817740000576123, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_statuscode": 0.04377819199987698, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_api": 0.024984995000011168, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_method": 0.04507710900003303, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_resource": 0.04138816200003248, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_status_code": 0.05497632100008332, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_lifecycle_integration_response": 0.10275084199997764, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_lifecycle_method_response": 0.09687917200000129, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_request_parameter_bool_type": 0.0017891400000280555, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_response_validation": 0.08119039999991173, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_wrong_type": 0.045587436000005255, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_lack_response_parameters_and_models": 0.08356579499996997, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response": 0.07124489599999606, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response_negative_tests": 0.09211698100000376, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response_wrong_operations": 0.09276022000005923, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_wrong_param_names": 0.09258312299999716, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayTestInvoke::test_invoke_test_method": 0.1999600709999072, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_account": 0.04572928500004991, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_authorizer_crud": 0.0019280499999467793, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_handle_domain_name": 0.24527348200001597, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_http_integration_with_path_request_parameter": 0.0021185520000130964, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_asynchronous_invocation": 1.3223967149999112, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_integration_aws_type": 7.77135099599991, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_proxy_integration[/lambda/foo1]": 0.0017183639999984734, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_proxy_integration[/lambda/{test_param1}]": 0.0016879060000292156, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_proxy_integration_any_method": 0.0016394659999150463, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_proxy_integration_any_method_with_path_param": 0.001756387000000359, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_proxy_integration_with_is_base_64_encoded": 0.0016327339999406831, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_mock_integration": 0.06332164799999873, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_mock_integration_response_params": 0.0017545439999935297, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigateway_with_custom_authorization_method": 15.379194112999983, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigw_stage_variables[dev]": 1.6465004900000508, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigw_stage_variables[local]": 1.6253507880000484, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigw_test_invoke_method_api": 2.1734129350000444, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_base_path_mapping": 0.18929234099999803, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_base_path_mapping_root": 0.16708001899996816, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_create_rest_api_with_custom_id[host_based_url]": 0.06823920899995528, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_create_rest_api_with_custom_id[localstack_path_based_url]": 0.06749188899999581, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_create_rest_api_with_custom_id[path_based_url]": 0.06842219500003921, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_delete_rest_api_with_invalid_id": 0.012952146000031917, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://allowed-False-UrlType.HOST_BASED]": 0.07642984999995406, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://allowed-False-UrlType.LS_PATH_BASED]": 0.07887058799997249, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://allowed-False-UrlType.PATH_BASED]": 0.07558965200007606, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://allowed-True-UrlType.HOST_BASED]": 0.09626078599995935, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://allowed-True-UrlType.LS_PATH_BASED]": 0.07413806400006706, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://allowed-True-UrlType.PATH_BASED]": 0.07322217200004388, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://denied-False-UrlType.HOST_BASED]": 0.07702566299997216, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://denied-False-UrlType.LS_PATH_BASED]": 0.07619589600005838, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://denied-False-UrlType.PATH_BASED]": 0.0759787090000259, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://denied-True-UrlType.HOST_BASED]": 0.07350749300002235, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://denied-True-UrlType.LS_PATH_BASED]": 0.07567922399999816, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_invoke_endpoint_cors_headers[http://denied-True-UrlType.PATH_BASED]": 0.07501279400003114, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_multiple_api_keys_validate": 0.5071212729999957, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_put_integration_dynamodb_proxy_validation_with_request_template": 0.0017567589999885058, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_put_integration_dynamodb_proxy_validation_without_request_template": 0.0019377499999677639, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_response_headers_invocation_with_apigw": 1.8057933929999876, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_update_rest_api_deployment": 0.07676386299999649, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_api_gateway_http_integrations[custom]": 0.0018434160000424527, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_api_gateway_http_integrations[proxy]": 0.0017542780000212588, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.HOST_BASED-GET]": 0.1139157070000465, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.HOST_BASED-POST]": 0.10478480900002296, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.PATH_BASED-GET]": 0.10333242800004427, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.PATH_BASED-POST]": 0.1045801750000237, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.HOST_BASED-GET]": 0.10142848099997082, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.HOST_BASED-POST]": 0.09949420200001668, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.PATH_BASED-GET]": 0.09949811899997485, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.PATH_BASED-POST]": 0.09679538299997148, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.HOST_BASED-GET]": 0.09742141499998525, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.HOST_BASED-POST]": 0.09756221799995046, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.PATH_BASED-GET]": 0.09938227000003508, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.PATH_BASED-POST]": 0.10375858100002233, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestTagging::test_tag_api": 0.06936384800008, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_apigw_call_api_with_aws_endpoint_url": 0.01400064099999554, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[UrlType.HOST_BASED-ANY]": 3.399788921000038, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[UrlType.HOST_BASED-GET]": 3.378014028999985, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[path_based_url-ANY]": 3.3944754569999986, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[path_based_url-GET]": 9.588368939999953, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestCanaryDeployments::test_invoking_canary_deployment": 0.1304895579999652, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment": 0.1331955169999901, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_by_stage_update": 0.13333336500005544, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_validation": 0.10110085700006266, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_with_stage": 0.10907110799996644, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_update_stages": 0.14577936400002045, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_update_stage_canary_deployment_validation": 0.16637644599990153, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_update_stage_with_copy_ops": 0.13467170900003111, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator": 2.4615997920000154, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator_with_ref_models": 0.17534835300000395, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator_with_ref_one_ofmodels": 0.18558459099995162, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_body_formatting": 3.4996529319999468, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_path_template_formatting": 0.628897190000032, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_integration_request_parameters_mapping": 0.11012785400004077, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_invocation_trace_id": 2.3368035429999736, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_api_not_existing": 0.032551157999989755, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_proxy_routing_with_hardcoded_resource_sibling": 0.2121103460000313, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_routing_not_found": 0.1219497260000253, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_routing_with_custom_api_id": 0.13833483900003785, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_routing_with_hardcoded_resource_sibling_order": 0.20469306899997264, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_delete_deployments[False]": 0.40519716100004644, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_delete_deployments[True]": 0.45230475300002126, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_update_deployments": 0.33244588300004807, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDocumentations::test_documentation_parts_and_versions": 0.11479638700006944, + "tests/aws/services/apigateway/test_apigateway_common.py::TestStages::test_create_update_stages": 0.3202888729999813, + "tests/aws/services/apigateway/test_apigateway_common.py::TestStages::test_update_stage_remove_wildcard": 0.31526051399993094, + "tests/aws/services/apigateway/test_apigateway_common.py::TestUsagePlans::test_api_key_required_for_methods": 0.1953746879999585, + "tests/aws/services/apigateway/test_apigateway_common.py::TestUsagePlans::test_usage_plan_crud": 0.19260923499996352, + "tests/aws/services/apigateway/test_apigateway_custom_ids.py::test_apigateway_custom_ids": 0.09079105299991852, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_error_aws_proxy_not_supported": 0.14670340999998643, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[PutItem]": 0.44720512300000337, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[Query]": 0.4590416809999738, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[Scan]": 0.3601540660000069, + "tests/aws/services/apigateway/test_apigateway_eventbridge.py::test_apigateway_to_eventbridge": 0.2087996730000441, + "tests/aws/services/apigateway/test_apigateway_extended.py::TestApigatewayApiKeysCrud::test_get_api_keys": 0.16784295099989777, + "tests/aws/services/apigateway/test_apigateway_extended.py::TestApigatewayApiKeysCrud::test_get_usage_plan_api_keys": 0.16944145500002605, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_create_domain_names": 0.07329601600008573, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_oas30_openapi[TEST_IMPORT_PETSTORE_SWAGGER]": 0.4037520669999708, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_oas30_openapi[TEST_IMPORT_PETS]": 0.3130234049999103, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_swagger_openapi[TEST_IMPORT_PETSTORE_SWAGGER]": 0.40965416099999175, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_swagger_openapi[TEST_IMPORT_PETS]": 0.3155040400000644, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_get_domain_name": 0.07397052900000745, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_get_domain_names": 0.0719271370000456, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_invoke_status_code_passthrough[HTTP]": 1.8164248749999956, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_invoke_status_code_passthrough[HTTP_PROXY]": 1.7498827080000297, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_method[HTTP]": 2.0298281169999655, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_method[HTTP_PROXY]": 2.024674245999961, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_with_lambda[HTTP]": 2.9520059260000266, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_with_lambda[HTTP_PROXY]": 2.202295377999974, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_proxy_integration_request_data_mappings": 1.981778596999959, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_and_validate_rest_api[openapi.spec.tf.json]": 0.3748473929999818, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_and_validate_rest_api[swagger-mock-cors.json]": 0.443353590000072, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api": 0.06481902100000525, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[ignore]": 0.8797029519999455, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[prepend]": 0.9219029829999954, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[split]": 0.8851523529999668, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[ignore]": 0.6085407979999786, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[prepend]": 0.630305169000053, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[split]": 0.6079022139999779, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_swagger_api": 0.7859107119999749, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_circular_models": 0.28531187400000135, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_circular_models_and_request_validation": 0.3994953889999806, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_cognito_auth_identity_source": 0.3857450619999554, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_global_api_key_authorizer": 0.28112125799998466, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_http_method_integration": 0.28810908600001994, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_integer_http_status_code": 0.17882546300000968, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_stage_variables": 1.6661230509999996, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_put_rest_api_mode_binary_media_types[merge]": 0.34298273899997866, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_put_rest_api_mode_binary_media_types[overwrite]": 0.3410583930000257, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_aws[AWS]": 2.4024414019999085, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_aws[AWS_PROXY]": 2.408681938999962, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_http[HTTP]": 0.8181987309998249, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_http[HTTP_PROXY]": 0.8030356849999407, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_create_execute_api_vpc_endpoint": 6.454628359000026, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_http_integration_status_code_selection": 0.12344339599997056, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_path_param": 0.09995677200004138, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_request_overrides_in_response_template": 0.12366609900004732, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_response_override_in_request_template[False]": 0.08879398500005209, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_response_override_in_request_template[True]": 0.09053800800006684, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_vtl_map_assignation": 0.09530660299992633, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_response_with_response_template": 1.2166933430000881, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_responses": 0.17058324700002458, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_validation": 0.21587373899990325, + "tests/aws/services/apigateway/test_apigateway_kinesis.py::test_apigateway_to_kinesis[PutRecord]": 1.135655194999913, + "tests/aws/services/apigateway/test_apigateway_kinesis.py::test_apigateway_to_kinesis[PutRecords]": 1.0992614450000247, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_aws_proxy_binary_response": 3.7942881140000964, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_aws_proxy_response_payload_format_validation": 4.063599011000065, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration": 1.6757779069998833, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration_response_with_mapping_templates": 1.8655714450000005, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration_with_request_template": 1.8079630610000095, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration": 4.062134031000028, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration_non_post_method": 1.2897300920000134, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration_request_data_mapping": 2.8216764090000197, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_response_format": 2.0445097860001624, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_rust_proxy_integration": 1.7827746500000785, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_selection_patterns": 1.9364196969999057, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_put_integration_aws_proxy_uri": 1.3109805709999591, + "tests/aws/services/apigateway/test_apigateway_lambda_cfn.py::TestApigatewayLambdaIntegration::test_scenario_validate_infra": 7.638353240999891, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request[CONVERT_TO_TEXT]": 0.6176476680000178, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request[None]": 0.5394738730000199, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request_convert_to_binary": 0.4803462309999986, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request_convert_to_binary_with_request_template": 0.3061233189999939, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_binary": 0.5531388009999318, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_binary_with_request_template": 0.32468523000000005, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_text": 0.577642001000072, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_no_content_handling": 0.5665712100001201, + "tests/aws/services/apigateway/test_apigateway_s3.py::test_apigateway_s3_any": 0.4161948939998865, + "tests/aws/services/apigateway/test_apigateway_s3.py::test_apigateway_s3_method_mapping": 0.459655709000117, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_amz_json_protocol": 0.9801772509999864, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration": 1.1851523539999107, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration_with_message_attribute[MessageAttribute]": 0.25785856799996054, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration_with_message_attribute[MessageAttributes]": 0.2524745690000145, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_request_and_response_xml_templates_integration": 0.34808442500013825, + "tests/aws/services/apigateway/test_apigateway_ssm.py::test_get_parameter_query_protocol": 0.0019834490000221194, + "tests/aws/services/apigateway/test_apigateway_ssm.py::test_ssm_aws_integration": 0.21950245400012136, + "tests/aws/services/apigateway/test_apigateway_stepfunctions.py::TestApigatewayStepfunctions::test_apigateway_with_step_function_integration[DeleteStateMachine]": 2.312177140999893, + "tests/aws/services/apigateway/test_apigateway_stepfunctions.py::TestApigatewayStepfunctions::test_apigateway_with_step_function_integration[StartExecution]": 1.4536808509999446, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_api_exceptions": 0.0022912079999741763, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_create_exceptions": 0.0016714309999770194, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_create_invalid_desiredstate": 0.0017175680000036664, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_double_create_with_client_token": 0.002082115000007434, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_lifecycle": 0.002318820999903437, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_list_resources": 0.0020213000000239845, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_list_resources_with_resource_model": 0.0018823979999069707, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_update": 0.0017229379999434968, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_edge_cases[FAIL]": 0.0017355400000269583, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_edge_cases[SUCCESS]": 0.0017351310001458842, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_request": 0.0017461219999859168, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_get_request_status": 0.0018566989999726502, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_invalid_request_token_exc": 0.002325904000144874, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_list_request_status": 0.0018138389999649007, + "tests/aws/services/cloudformation/api/test_changesets.py::TestUpdates::test_deleting_resource": 0.0017233779999514809, + "tests/aws/services/cloudformation/api/test_changesets.py::TestUpdates::test_simple_update_single_resource": 4.203915298999959, + "tests/aws/services/cloudformation/api/test_changesets.py::TestUpdates::test_simple_update_two_resources": 0.001805542999932186, + "tests/aws/services/cloudformation/api/test_changesets.py::test_autoexpand_capability_requirement": 0.08932291500013889, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_and_then_remove_non_supported_resource_change_set": 27.22724400800007, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_and_then_remove_supported_resource_change_set": 17.806701735000047, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_and_then_update_refreshes_template_metadata": 2.155039932999898, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_create_existing": 0.001633058999914283, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_invalid_params": 0.015966558999934932, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_missing_stackname": 0.004827821000048971, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_update_nonexisting": 0.016184872000053474, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_update_without_parameters": 0.0017474940000283823, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_with_ssm_parameter": 1.1573612189998812, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_without_parameters": 0.09023810299993329, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_changeset_with_stack_id": 0.24376118700001825, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_delete_create": 2.1556554349999715, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_while_in_review": 0.0018482550000271658, + "tests/aws/services/cloudformation/api/test_changesets.py::test_delete_change_set_exception": 0.021744644000023072, + "tests/aws/services/cloudformation/api/test_changesets.py::test_deleted_changeset": 0.05105870799991408, + "tests/aws/services/cloudformation/api/test_changesets.py::test_describe_change_set_nonexisting": 0.013830210000037368, + "tests/aws/services/cloudformation/api/test_changesets.py::test_describe_change_set_with_similarly_named_stacks": 0.04928400800008603, + "tests/aws/services/cloudformation/api/test_changesets.py::test_empty_changeset": 1.328948627999921, + "tests/aws/services/cloudformation/api/test_changesets.py::test_execute_change_set": 0.0017321649999075817, + "tests/aws/services/cloudformation/api/test_changesets.py::test_multiple_create_changeset": 0.3614531260000149, + "tests/aws/services/cloudformation/api/test_changesets.py::test_name_conflicts": 1.9197973079998292, + "tests/aws/services/cloudformation/api/test_drift_detection.py::test_drift_detection_on_lambda": 0.001801456000066537, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[HOOK-LocalStack::Testing::TestHook-hooks/localstack-testing-testhook.zip]": 0.001718579000112186, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[MODULE-LocalStack::Testing::TestModule::MODULE-modules/localstack-testing-testmodule-module.zip]": 0.0018584750000627537, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[RESOURCE-LocalStack::Testing::TestResource-resourcetypes/localstack-testing-testresource.zip]": 0.0017523839999284974, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_not_complete": 0.0017677729999832081, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_type_configuration": 0.001807909000035579, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_versioning": 0.0017189720000487796, + "tests/aws/services/cloudformation/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[FAIL]": 0.0017422749999695952, + "tests/aws/services/cloudformation/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[WARN]": 0.0017155049999928451, + "tests/aws/services/cloudformation/api/test_extensions_modules.py::TestExtensionsModules::test_module_usage": 0.001733549000050516, + "tests/aws/services/cloudformation/api/test_extensions_resourcetypes.py::TestExtensionsResourceTypes::test_deploy_resource_type": 0.0017273859999704655, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_deletion_of_failed_nested_stack": 6.262886840999954, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_lifecycle_nested_stack": 0.0023648509999247835, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_nested_output_in_params": 12.656267582000055, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_nested_stack": 6.22095801699993, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_nested_stack_output_refs": 6.23890205400005, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_nested_stacks_conditions": 6.232598200999973, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_nested_with_nested_stack": 12.365712428999927, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_nested_getatt_ref[TopicArn]": 2.1101331890000665, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_nested_getatt_ref[TopicName]": 2.1052438460000076, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_reference_unsupported_resource": 2.1164659569999458, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_sub_resolving": 2.120728740000004, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_create_stack_with_policy": 0.0016880599998785328, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_different_action_attribute": 0.001961254999969242, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_different_principal_attribute": 0.0018179660000896547, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_empty_policy": 0.001745798999991166, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_not_json_policy": 0.0016605590000153825, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_policy_during_update": 0.0017011349999620506, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_policy_lifecycle": 0.0019151379999584606, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_deletion[resource0]": 0.00182810600006178, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_deletion[resource1]": 0.0017859260000250288, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_modifying_with_policy_specifying_resource_id": 0.001772800000026109, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_replacement": 0.0018073649999905683, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_resource_deletion": 0.0017013760000281763, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_stack_update": 0.0017273060000206897, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_update[AWS::S3::Bucket]": 0.0017663989999618934, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_update[AWS::SNS::Topic]": 0.0017604880000590128, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_empty_policy_with_url": 0.0017843129999164375, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_invalid_policy_with_url": 0.0017649059999484962, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_both_policy_and_url": 0.001774002999923141, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_update_operation": 0.0016868789999762157, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_url": 0.0017028290000098423, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_update_with_empty_policy": 0.001797386999896844, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_update_with_overlapping_policies[False]": 0.001658975000054852, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_update_with_overlapping_policies[True]": 0.0017804059999662059, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_update_with_policy": 0.0017856859999483277, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_create_stack_with_custom_id": 1.0596463009999297, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_creation[False-0]": 0.0017657970000755085, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_creation[True-1]": 0.001804891000006137, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[False-2]": 0.0016743649999853005, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[True-1]": 0.001749214999904325, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[json]": 2.1074563150001495, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[yaml]": 2.108339416000149, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[json]": 1.0547990000000027, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[yaml]": 1.0567504480000025, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_list_events_after_deployment": 2.1817230719999543, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_list_stack_resources_for_removed_resource": 18.45106128100008, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_description_special_chars": 2.2991229010000325, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_lifecycle": 4.368161413000053, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_name_creation": 0.08920185199997377, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_update_resources": 4.441264477000004, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_update_stack_actual_update": 4.179479352000044, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange": 2.090991225000039, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange_transformation": 2.256792214999905, + "tests/aws/services/cloudformation/api/test_stacks.py::test_blocked_stack_deletion": 0.0019525179999391185, + "tests/aws/services/cloudformation/api/test_stacks.py::test_describe_stack_events_errors": 0.023749629999883837, + "tests/aws/services/cloudformation/api/test_stacks.py::test_events_resource_types": 2.1503585100000464, + "tests/aws/services/cloudformation/api/test_stacks.py::test_linting_error_during_creation": 0.001829676999932417, + "tests/aws/services/cloudformation/api/test_stacks.py::test_list_parameter_type": 2.1265436880000834, + "tests/aws/services/cloudformation/api/test_stacks.py::test_name_conflicts": 2.402198083999906, + "tests/aws/services/cloudformation/api/test_stacks.py::test_no_echo_parameter": 3.9161584590000302, + "tests/aws/services/cloudformation/api/test_stacks.py::test_notifications": 0.0018007629998919583, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[A-B-C]": 2.3849829010000576, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[A-C-B]": 2.386323845999982, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[B-A-C]": 2.389029623000056, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[B-C-A]": 2.3938188020000553, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[C-A-B]": 2.4041329739999355, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[C-B-A]": 2.389554589000113, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_resource_not_found": 2.116385367000021, + "tests/aws/services/cloudformation/api/test_stacks.py::test_update_termination_protection": 2.132597996999948, + "tests/aws/services/cloudformation/api/test_stacks.py::test_updating_an_updated_stack_sets_status": 6.42036151800005, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_host]": 1.140919112000006, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_invalid]": 0.09525446200007082, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_path]": 1.1429320170001347, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[s3_url]": 0.09972416499999781, + "tests/aws/services/cloudformation/api/test_templates.py::test_get_template_summary": 2.2563617829998748, + "tests/aws/services/cloudformation/api/test_templates.py::test_validate_invalid_json_template_should_fail": 0.09040959099991142, + "tests/aws/services/cloudformation/api/test_templates.py::test_validate_template": 0.09211332400002448, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach": 1.2868793879999885, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach_multiple_resources": 1.386565983999958, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach_use_case": 1.5463610600000948, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_length": 1.269616398999915, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_to_json_string": 1.2850613649999332, + "tests/aws/services/cloudformation/api/test_transformers.py::test_duplicate_resources": 2.3573717790000046, + "tests/aws/services/cloudformation/api/test_transformers.py::test_transformer_individual_resource_level": 2.2449173310001242, + "tests/aws/services/cloudformation/api/test_transformers.py::test_transformer_property_level": 2.2853386879999107, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_basic_update": 3.1261854889999086, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_diff_after_update": 3.1500545210000155, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_no_parameters_update": 3.1239089100000683, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_no_template_error": 0.0016695120000349561, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_set_notification_arn_with_update": 0.0016462579999370064, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_tags": 0.0016588820000151827, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_using_template_url": 3.2000377109999363, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_capabilities[capability0]": 0.0017795789999581757, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_capabilities[capability1]": 0.0018383289999519548, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_invalid_rollback_configuration_errors": 0.0016748120000329436, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_previous_parameter_value": 3.1228443820000393, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_previous_template": 0.0019446889999699124, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_resource_types": 0.0017927249998592742, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_role_without_permissions": 0.0017733480000288182, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_rollback_configuration": 0.001759071000037693, + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[missing-def]": 0.0016907060000903584, + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[multiple-nones]": 0.0016583559998935016, + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[none-value]": 0.0018622899999627407, + "tests/aws/services/cloudformation/api/test_validations.py::test_missing_resources_block": 0.0016586369999913586, + "tests/aws/services/cloudformation/api/test_validations.py::test_resources_blocks[invalid-key]": 0.0017820590001065284, + "tests/aws/services/cloudformation/api/test_validations.py::test_resources_blocks[missing-type]": 0.0016485470000588975, + "tests/aws/services/cloudformation/engine/test_attributes.py::TestResourceAttributes::test_dependency_on_attribute_with_dot_notation": 2.117958759999965, + "tests/aws/services/cloudformation/engine/test_attributes.py::TestResourceAttributes::test_invalid_getatt_fails": 0.0017751659999021285, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_condition_on_outputs": 2.1252235939999764, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_att_to_conditional_resources[create]": 2.13701943500007, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_att_to_conditional_resources[no-create]": 2.1287500720001162, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_in_conditional[dev-us-west-2]": 2.1021212040000137, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_in_conditional[production-us-east-1]": 2.10239591200002, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_with_select": 2.1070476199998893, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependency_in_non_evaluated_if_branch[None-FallbackParamValue]": 2.1347859960001188, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependency_in_non_evaluated_if_branch[false-DefaultParamValue]": 2.138201448000018, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependency_in_non_evaluated_if_branch[true-FallbackParamValue]": 2.1320724930000097, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref": 0.0019821429999637985, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref_intrinsic_fn_condition": 0.0016866759999629721, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref_with_macro": 0.0016739630000301986, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-bucket-policy]": 0.0017256490000363556, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-nobucket-nopolicy]": 0.0017262409999148076, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-bucket-nopolicy]": 0.0017245770001181882, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-nobucket-nopolicy]": 0.0017897810001841208, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_output_reference_to_skipped_resource": 0.0016727309999851059, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_simple_condition_evaluation_deploys_resource": 2.1094780890000493, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_simple_condition_evaluation_doesnt_deploy_resource": 0.08724021700004414, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_simple_intrinsic_fn_condition_evaluation[nope]": 2.096514058000025, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_simple_intrinsic_fn_condition_evaluation[yep]": 2.097483908000072, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_sub_in_conditions": 2.1233398790000138, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_update_conditions": 4.233549260000132, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_async_mapping_error_first_level": 2.074252192000017, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_async_mapping_error_second_level": 2.0730904560000454, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_aws_refs_in_mappings": 2.0966487100000677, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_maximum_nesting_depth": 0.0017004610000412868, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_minimum_nesting_depth": 0.0016706059999478384, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-deploy]": 2.1139441360001, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-not-deploy]": 2.094758117999959, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_invalid_refs": 0.0017483839999385964, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_nonexisting_key": 0.0018001660000663833, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_simple_mapping_working": 2.109327890999907, + "tests/aws/services/cloudformation/engine/test_references.py::TestDependsOn::test_depends_on_with_missing_reference": 0.0018667840000716751, + "tests/aws/services/cloudformation/engine/test_references.py::TestFnSub::test_fn_sub_cases": 2.12412515099993, + "tests/aws/services/cloudformation/engine/test_references.py::TestFnSub::test_non_string_parameter_in_sub": 2.1114215530000138, + "tests/aws/services/cloudformation/engine/test_references.py::test_resolve_transitive_placeholders_in_strings": 2.1255051870000443, + "tests/aws/services/cloudformation/engine/test_references.py::test_useful_error_when_invalid_ref": 0.01766548300008708, + "tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.py::TestBasicCRD::test_black_box": 2.5566669820000243, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_instance_with_key_pair": 2.4513768500002016, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_prefix_list": 7.205947429000048, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_security_group_with_tags": 2.109163421000062, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_vpc_endpoint": 2.5199879269999883, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_autogenerated_values": 2.1006752270000106, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_black_box": 2.138026726000021, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_getatt": 2.1383570699999837, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestUpdates::test_update_without_replacement": 0.0018247059999794146, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py::TestAttributeAccess::test_getatt[Arn]": 0.0017127450000771205, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py::TestAttributeAccess::test_getatt[Id]": 0.0017090480000661046, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py::TestAttributeAccess::test_getatt[Path]": 0.0016925959999980478, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py::TestAttributeAccess::test_getatt[PermissionsBoundary]": 0.0016998700000385725, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py::TestAttributeAccess::test_getatt[UserName]": 0.0016497860001436493, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.py::TestParity::test_create_with_full_properties": 2.2857147590000295, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_cfn_handle_iam_role_resource_no_role_name": 2.1389533749999146, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_delete_role_detaches_role_policy": 4.223758951999912, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_user_access_key": 4.20742734800001, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_username_defaultname": 2.1817282070001056, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_managed_policy_with_empty_resource": 2.47778097399987, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_policy_attachments": 2.3026068830000668, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_server_certificate": 2.245159842000021, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_update_inline_policy": 4.28998763200002, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_updating_stack_with_iam_role": 12.262457225000048, + "tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py::TestAttributeAccess::test_getattr[Arn]": 0.001770010000086586, + "tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py::TestAttributeAccess::test_getattr[DomainArn]": 0.001718093000022236, + "tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py::TestAttributeAccess::test_getattr[DomainEndpoint]": 0.0018502519999401557, + "tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py::TestAttributeAccess::test_getattr[DomainName]": 0.00203874699991502, + "tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py::TestAttributeAccess::test_getattr[EngineVersion]": 0.001752617999954964, + "tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py::TestAttributeAccess::test_getattr[Id]": 0.0018496100000220395, + "tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.py::test_schedule_and_group": 2.498942882000051, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestBasicCRD::test_black_box": 0.0020350910000388467, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestUpdates::test_update_without_replacement": 0.0017724959999441126, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[AllowedPattern]": 0.001951621999864983, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[DataType]": 0.0019140310000693717, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[Description]": 0.0017333719998759989, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[Id]": 0.0017605429999321132, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[Name]": 0.0017548219999525827, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[Policies]": 0.0017184529999667575, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[Tier]": 0.0017294639999363426, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[Type]": 0.001711288999899807, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py::TestAttributeAccess::test_getattr[Value]": 0.0017169110000168075, + "tests/aws/services/cloudformation/resources/test_acm.py::test_cfn_acm_certificate": 2.103202859000021, + "tests/aws/services/cloudformation/resources/test_apigateway.py::TestServerlessApigwLambda::test_serverless_like_deployment_with_update": 11.481987719000017, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_account": 2.1575564920001398, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_api_gateway_with_policy_as_dict": 2.116327663999982, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_apigateway_aws_integration": 2.3330158210000036, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_apigateway_rest_api": 2.3321271110000907, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_apigateway_swagger_import": 2.3414432530000795, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_from_s3_swagger": 2.6908957749999445, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_integration": 2.2344279829999323, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_models": 3.316799441999933, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_with_apigateway_resources": 2.3481310099999746, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_rest_api_serverless_ref_resolving": 9.962780654000085, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_update_apigateway_stage": 4.554352121999955, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_update_usage_plan": 4.492471101000092, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_url_output": 2.19083423699999, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[10]": 8.634859523000046, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[11]": 8.674219913000115, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[12]": 8.682995712999968, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap_redeploy": 5.5978490960000045, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkSampleApp::test_cdk_sample": 2.437001627999962, + "tests/aws/services/cloudformation/resources/test_cloudformation.py::test_create_macro": 3.201351855000212, + "tests/aws/services/cloudformation/resources/test_cloudformation.py::test_waitcondition": 2.2023980000000165, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_alarm_creation": 2.1142722259996845, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_alarm_ext_statistic": 2.1270276670002204, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_composite_alarm_creation": 2.4173014210000474, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_billing_mode_as_conditional[PAY_PER_REQUEST]": 2.4921620569998595, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_billing_mode_as_conditional[PROVISIONED]": 2.4762469000002056, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_default_name_for_table": 2.483544122000012, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_deploy_stack_with_dynamodb_table": 2.232402340000135, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_global_table": 2.492649267999923, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_global_table_with_ttl_and_sse": 2.1494175759999052, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_globalindex_read_write_provisioned_throughput_dynamodb_table": 2.1877572200003215, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_table_with_ttl_and_sse": 2.142040158000327, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_ttl_cdk": 1.270172974000161, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_cfn_update_ec2_instance_type": 0.0018225399996936176, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_cfn_with_multiple_route_table_associations": 2.4730414709999877, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_cfn_with_multiple_route_tables": 2.2050810190003176, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_dhcp_options": 2.325468927000202, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_ec2_security_group_id_with_vpc": 2.158153948000063, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_internet_gateway_ref_and_attr": 2.300158639000074, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_keypair_create_import": 2.2343917840000813, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_simple_route_table_creation": 2.241844635999996, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_simple_route_table_creation_without_vpc": 2.2321249990002343, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_transit_gateway_attachment": 2.7933389080001234, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_vpc_creates_default_sg": 2.474828806000005, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_vpc_with_route_table": 2.3085319510000772, + "tests/aws/services/cloudformation/resources/test_elasticsearch.py::test_cfn_handle_elasticsearch_domain": 4.214922818000105, + "tests/aws/services/cloudformation/resources/test_events.py::test_cfn_event_api_destination_resource": 16.395980405000046, + "tests/aws/services/cloudformation/resources/test_events.py::test_cfn_event_bus_resource": 2.1544752039999366, + "tests/aws/services/cloudformation/resources/test_events.py::test_event_rule_creation_without_target": 2.117284753000149, + "tests/aws/services/cloudformation/resources/test_events.py::test_event_rule_to_logs": 2.232324323999819, + "tests/aws/services/cloudformation/resources/test_events.py::test_eventbus_policies": 13.332169992000217, + "tests/aws/services/cloudformation/resources/test_events.py::test_eventbus_policy_statement": 2.1195276010003, + "tests/aws/services/cloudformation/resources/test_events.py::test_rule_pattern_transformation": 2.134911890000012, + "tests/aws/services/cloudformation/resources/test_events.py::test_rule_properties": 2.1462256440001966, + "tests/aws/services/cloudformation/resources/test_firehose.py::test_firehose_stack_with_kinesis_as_source": 29.547423629999912, + "tests/aws/services/cloudformation/resources/test_integration.py::test_events_sqs_sns_lambda": 14.78055541499998, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_cfn_handle_kinesis_firehose_resources": 11.387066733999973, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_default_parameters_kinesis": 11.324862966000182, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_describe_template": 0.13614104099997348, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_dynamodb_stream_response_with_cf": 11.357302712999854, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_kinesis_stream_consumer_creations": 17.301159116000008, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_stream_creation": 11.35855876100004, + "tests/aws/services/cloudformation/resources/test_kms.py::test_cfn_with_kms_resources": 2.135656648000122, + "tests/aws/services/cloudformation/resources/test_kms.py::test_deploy_stack_with_kms": 2.123311767999894, + "tests/aws/services/cloudformation/resources/test_kms.py::test_kms_key_disabled": 2.1212718999997833, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaDestinations::test_generic_destination_routing[sqs-sqs]": 15.61256068800003, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_dynamodb_source": 10.724501675000056, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_kinesis_source": 21.34937487000002, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_permissions": 7.884791771999744, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_sqs_source": 8.15349554799991, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_lambda_dynamodb_event_filter": 9.456715711000015, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_cfn_function_url": 7.571186258000125, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_event_invoke_config": 6.289769599999772, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_alias": 12.531488443999933, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_dead_letter_config_async_invocation": 11.066651045000071, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_run": 6.602450584000053, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_run_with_empty_string_replacement_deny_list": 6.182805175000112, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_run_with_non_empty_string_replacement_deny_list": 6.178905628999928, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_code_signing_config": 2.2026724590000413, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_function_tags": 6.557388057000026, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_layer_crud": 6.2773250780001035, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_logging_config": 6.2155377429999135, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version": 6.799997513000108, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": 12.581515278000097, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_vpc": 0.0020820070001263957, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter": 11.466839559999926, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter_update": 12.712890368000217, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_multiple_lambda_permissions_for_singlefn": 6.218357078000054, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_python_lambda_code_deployed_via_s3": 6.700574433000156, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_update_lambda_function": 8.295991722999815, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_update_lambda_function_name": 12.322580002999985, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_update_lambda_permissions": 8.302687592999746, + "tests/aws/services/cloudformation/resources/test_logs.py::test_cfn_handle_log_group_resource": 2.40735709899991, + "tests/aws/services/cloudformation/resources/test_logs.py::test_logstream": 2.120762069999955, + "tests/aws/services/cloudformation/resources/test_opensearch.py::test_domain": 0.0018734879997737153, + "tests/aws/services/cloudformation/resources/test_opensearch.py::test_domain_with_alternative_types": 17.31709127499994, + "tests/aws/services/cloudformation/resources/test_redshift.py::test_redshift_cluster": 2.1340201469997737, + "tests/aws/services/cloudformation/resources/test_resource_groups.py::test_group_defaults": 2.2786965110001347, + "tests/aws/services/cloudformation/resources/test_route53.py::test_create_health_check": 2.2646535770002174, + "tests/aws/services/cloudformation/resources/test_route53.py::test_create_record_set_via_id": 2.1899505000001227, + "tests/aws/services/cloudformation/resources/test_route53.py::test_create_record_set_via_name": 2.18740697200019, + "tests/aws/services/cloudformation/resources/test_route53.py::test_create_record_set_without_resource_record": 2.1770236969996404, + "tests/aws/services/cloudformation/resources/test_s3.py::test_bucket_autoname": 2.1310846019998735, + "tests/aws/services/cloudformation/resources/test_s3.py::test_bucket_versioning": 2.1164292809999097, + "tests/aws/services/cloudformation/resources/test_s3.py::test_bucketpolicy": 10.215162469000006, + "tests/aws/services/cloudformation/resources/test_s3.py::test_cfn_handle_s3_notification_configuration": 2.172483757000009, + "tests/aws/services/cloudformation/resources/test_s3.py::test_cors_configuration": 2.5275519839997287, + "tests/aws/services/cloudformation/resources/test_s3.py::test_object_lock_configuration": 2.5219108829999186, + "tests/aws/services/cloudformation/resources/test_s3.py::test_website_configuration": 2.4870599389998915, + "tests/aws/services/cloudformation/resources/test_sam.py::test_cfn_handle_serverless_api_resource": 6.629851936999785, + "tests/aws/services/cloudformation/resources/test_sam.py::test_sam_policies": 6.32645338400016, + "tests/aws/services/cloudformation/resources/test_sam.py::test_sam_sqs_event": 13.490114923999954, + "tests/aws/services/cloudformation/resources/test_sam.py::test_sam_template": 6.634891635000258, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cdk_deployment_generates_secret_value_if_no_value_is_provided": 1.270135848999871, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_handle_secretsmanager_secret": 2.2852553329998955, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secret_policy[default]": 2.1230692959998123, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secret_policy[true]": 2.1228311260001647, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secretsmanager_gen_secret": 2.273966974999894, + "tests/aws/services/cloudformation/resources/test_sns.py::test_deploy_stack_with_sns_topic": 2.14185912500011, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_subscription": 2.1272246389999054, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_subscription_region": 2.1660405849997915, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": 2.3463721789996725, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_fifo_without_suffix_fails": 2.093717362000234, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_policy_resets_to_default": 1.3770199089997277, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_update_attributes": 4.47499999799993, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_update_name": 4.524773090000053, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_with_attributes": 1.2405141169997478, + "tests/aws/services/cloudformation/resources/test_sns.py::test_update_subscription": 4.247274863000257, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_cfn_handle_sqs_resource": 2.144439621000174, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_sqs_fifo_queue_generates_valid_name": 2.1098485859999982, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_sqs_non_fifo_queue_generates_valid_name": 2.1151642349998383, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_sqs_queue_policy": 2.1237477040001522, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_update_queue_no_change": 4.2113350760000685, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_update_sqs_queuepolicy": 4.210529220000126, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_deploy_patch_baseline": 2.2699770309998257, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_maintenance_window": 2.176962357000093, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_parameter_defaults": 2.306172483999717, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_update_ssm_parameter_tag": 4.202411019999772, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_update_ssm_parameters": 4.183204357999557, + "tests/aws/services/cloudformation/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": 1.143669811000109, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_apigateway_invoke": 9.550658830999964, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_apigateway_invoke_localhost": 9.59580293199997, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_apigateway_invoke_localhost_with_path": 15.700124639999785, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_apigateway_invoke_with_path": 15.636654505000024, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_cfn_statemachine_default_s3_location": 4.789177542999823, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_cfn_statemachine_with_dependencies": 2.1900348299998313, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_nested_statemachine_with_sync2": 15.495529817999795, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_retry_and_catch": 0.0027130080000006274, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_statemachine_create_with_logging_configuration": 2.6778726249999636, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_statemachine_definitionsubstitution": 7.314838959000099, + "tests/aws/services/cloudformation/test_cloudformation_ui.py::TestCloudFormationUi::test_get_cloudformation_ui": 0.07332294999991973, + "tests/aws/services/cloudformation/test_cloudtrail_trace.py::test_cloudtrail_trace_example": 0.0017383449999215372, + "tests/aws/services/cloudformation/test_template_engine.py::TestImportValues::test_cfn_with_exports": 2.1333055009999953, + "tests/aws/services/cloudformation/test_template_engine.py::TestImportValues::test_import_values_across_stacks": 4.213759987000003, + "tests/aws/services/cloudformation/test_template_engine.py::TestImports::test_stack_imports": 4.220892208000123, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-0-0-False]": 0.08964925200007201, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-0-1-False]": 0.08422666199999185, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-1-0-False]": 0.08488471499981642, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-1-1-True]": 2.1260705879999477, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-0-0-False]": 0.08623441800023102, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-0-1-True]": 2.1192471740000656, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-1-0-True]": 2.1387521929998456, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-1-1-True]": 2.140584247000106, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_base64_sub_and_getatt_functions": 2.1050183369998194, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_cfn_template_with_short_form_fn_sub": 2.102920144999871, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_cidr_function": 0.0019710050003141077, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_find_map_function": 2.106992183000102, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-northeast-1]": 2.1157430159998967, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-southeast-2]": 2.118299900999773, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-central-1]": 2.1192461379998804, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-west-1]": 2.115412787999958, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-1]": 2.102858225000091, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-2]": 2.117316669999809, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-1]": 2.116469378000147, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-2]": 2.113965917000087, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_join_no_value_construct": 2.1053546840000763, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_split_length_and_join_functions": 2.1763561250002113, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_sub_not_ready": 2.1185990649998985, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_sub_number_type": 2.1050137680001626, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_to_json_functions": 0.0020020740003019455, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_attribute_uses_macro": 5.71837533799976, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_capabilities_requirements": 5.290424990000247, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_error_pass_macro_as_reference": 0.02768524099997194, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[raise_error.py]": 3.6952866749998066, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_invalid_template.py]": 3.7014039419998426, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_with_message.py]": 3.671679276000077, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_without_message.py]": 3.6547270949999984, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_functions_and_references_during_transformation": 4.69161074800013, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_global_scope": 5.095732750999787, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_macro_deployment": 3.231526179999946, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_pyplate_param_type_list": 8.741712994999943, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_scope_order_and_parameters": 0.002084426000010353, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.json]": 5.746132134999925, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.yml]": 5.749774039000158, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_to_validate_template_limit_for_macro": 3.761602764000145, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_validate_lambda_internals": 5.246526786000004, + "tests/aws/services/cloudformation/test_template_engine.py::TestPreviousValues::test_parameter_usepreviousvalue_behavior": 0.0019251359999543638, + "tests/aws/services/cloudformation/test_template_engine.py::TestPseudoParameters::test_stack_id": 2.104047305999984, + "tests/aws/services/cloudformation/test_template_engine.py::TestSecretsManagerParameters::test_resolve_secretsmanager[resolve_secretsmanager.yaml]": 2.1310968800000865, + "tests/aws/services/cloudformation/test_template_engine.py::TestSecretsManagerParameters::test_resolve_secretsmanager[resolve_secretsmanager_full.yaml]": 2.11986049799998, + "tests/aws/services/cloudformation/test_template_engine.py::TestSecretsManagerParameters::test_resolve_secretsmanager[resolve_secretsmanager_partial.yaml]": 2.1146658840000327, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_create_change_set_with_ssm_parameter_list": 2.1632730360001915, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_create_stack_with_ssm_parameters": 2.167986142000018, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_resolve_ssm": 2.127064709000024, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_resolve_ssm_secure": 2.12845302300002, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_resolve_ssm_with_version": 2.1652705839996997, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_ssm_nested_with_nested_stack": 7.458595521000007, + "tests/aws/services/cloudformation/test_template_engine.py::TestStackEvents::test_invalid_stack_deploy": 2.3791878570000335, + "tests/aws/services/cloudformation/test_template_engine.py::TestTypes::test_implicit_type_conversion": 2.161635395000303, + "tests/aws/services/cloudformation/test_unsupported.py::test_unsupported": 2.1033945589999803, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_deleting_resource": 0.0017235239999990881, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_simple_update_single_resource": 0.0019867199998770957, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_simple_update_two_resources": 0.001741058000106932, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_autoexpand_capability_requirement": 0.0017797410000639502, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_and_then_remove_non_supported_resource_change_set": 0.0016536529999484628, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_and_then_remove_supported_resource_change_set": 0.00181669099993087, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_and_then_update_refreshes_template_metadata": 0.0016712470001039037, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_create_existing": 0.0016903119999369665, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_invalid_params": 0.001792554999838103, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_missing_stackname": 0.0022553450000941666, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_update_nonexisting": 0.0016758350000145583, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_update_without_parameters": 0.001668050000034782, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_with_ssm_parameter": 0.0016511690000697854, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_without_parameters": 0.0017023749999225402, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_changeset_with_stack_id": 0.0018163299998832372, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_delete_create": 0.0016352380000626, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_while_in_review": 0.0017116320000241103, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_delete_change_set_exception": 0.001698206999890317, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_deleted_changeset": 0.0018160699999043572, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_describe_change_set_nonexisting": 0.001783457999863458, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_describe_change_set_with_similarly_named_stacks": 0.0016695619999609335, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_empty_changeset": 0.0018521370000144088, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_execute_change_set": 0.0018994260001363727, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_multiple_create_changeset": 0.001713917000188303, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_name_conflicts": 0.0016986770001494733, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.py::test_drift_detection_on_lambda": 0.0017151990000456863, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[HOOK-LocalStack::Testing::TestHook-hooks/localstack-testing-testhook.zip]": 0.0017232350000995211, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[MODULE-LocalStack::Testing::TestModule::MODULE-modules/localstack-testing-testmodule-module.zip]": 0.001835976999700506, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[RESOURCE-LocalStack::Testing::TestResource-resourcetypes/localstack-testing-testresource.zip]": 0.0017163709999294952, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_not_complete": 0.0016968439999800466, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_type_configuration": 0.0016727689996969275, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_versioning": 0.0019036030000734172, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[FAIL]": 0.0019336509999448026, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[WARN]": 0.0016879680001693487, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.py::TestExtensionsModules::test_module_usage": 0.001720890000115105, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.py::TestExtensionsResourceTypes::test_deploy_resource_type": 0.0016984680000859953, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_deletion_of_failed_nested_stack": 0.0016937380000854318, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_lifecycle_nested_stack": 0.0017744109998147906, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_nested_output_in_params": 0.0017189360000884335, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_nested_stack": 0.0018285020000803343, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_nested_stack_output_refs": 0.0016813849999834929, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_nested_stacks_conditions": 0.0016877870000371331, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_nested_with_nested_stack": 0.0018146469999464898, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_nested_getatt_ref[TopicArn]": 0.001734095000074376, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_nested_getatt_ref[TopicName]": 0.0017131850001987914, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_reference_unsupported_resource": 0.0016889600001377403, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_sub_resolving": 0.0017052300001978438, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_create_stack_with_policy": 0.001709838000124364, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_different_action_attribute": 0.0018338520001179859, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_different_principal_attribute": 0.001799057999960496, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_empty_policy": 0.001690101000122013, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_not_json_policy": 0.0018431500000133383, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_policy_during_update": 0.0017103700001825928, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_policy_lifecycle": 0.001713484999982029, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_deletion[resource0]": 0.0017426999997951498, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_deletion[resource1]": 0.001716681999823777, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_modifying_with_policy_specifying_resource_id": 0.0016971759998796188, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_replacement": 0.0016691230002834345, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_resource_deletion": 0.0016989689997899404, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_stack_update": 0.0017215709999618412, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_update[AWS::S3::Bucket]": 0.0017201279997607344, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_update[AWS::SNS::Topic]": 0.0016897609998522967, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_empty_policy_with_url": 0.0016901819999475265, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_invalid_policy_with_url": 0.0018265100002281542, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_both_policy_and_url": 0.0017044900000655616, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_update_operation": 0.0017103499999393534, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_url": 0.0017869250000330794, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_update_with_empty_policy": 0.0016639630000554462, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_update_with_overlapping_policies[False]": 0.0016498869999850285, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_update_with_overlapping_policies[True]": 0.0016779990000941325, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_update_with_policy": 0.0016693529998974554, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_create_stack_with_custom_id": 0.001750455999854239, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_creation[False-0]": 0.0017780569999104046, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_creation[True-1]": 0.0016620989999864832, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[False-2]": 0.001804886999707378, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[True-1]": 0.0017882259999169037, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[json]": 0.0017871649999960937, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[yaml]": 0.0017563960000188672, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[json]": 0.0026928499999030464, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[yaml]": 0.0016842299999098032, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_list_events_after_deployment": 0.0018253969999477704, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_list_stack_resources_for_removed_resource": 0.001634677999845735, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_description_special_chars": 0.003515056999958688, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_lifecycle": 0.0018813419999332837, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_name_creation": 0.0016461790003177157, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_update_resources": 0.0016776480001681193, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_update_stack_actual_update": 0.0018086339998717449, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange": 0.0016744120002840646, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange_transformation": 0.0016165629999704834, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_blocked_stack_deletion": 0.001795380000203295, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_describe_stack_events_errors": 0.0018149069999253697, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_events_resource_types": 0.001670483999987482, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_linting_error_during_creation": 0.001801600999897346, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_list_parameter_type": 0.0016658370002460288, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_name_conflicts": 0.0017978459998175822, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_no_echo_parameter": 0.0016944299998158385, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_notifications": 0.001774180999973396, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[A-B-C]": 0.0016742519999297656, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[A-C-B]": 0.001706192999790801, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[B-A-C]": 0.0016916250001486333, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[B-C-A]": 0.0016806840001208911, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[C-A-B]": 0.0017207400001097994, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[C-B-A]": 0.001758431000098426, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_resource_not_found": 0.0017059420001714898, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_update_termination_protection": 0.0017720159999043972, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_updating_an_updated_stack_sets_status": 0.0019514640000579675, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_host]": 0.001728274000242891, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_invalid]": 0.0016871759999048663, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_path]": 0.0017169630000353209, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[s3_url]": 0.0016617779999705817, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_get_template_summary": 0.0017513170000711398, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_validate_invalid_json_template_should_fail": 0.0016949410000961507, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_validate_template": 0.0016985380000278383, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_duplicate_resources": 0.0017502350001450395, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_transformer_individual_resource_level": 0.0017053300000497984, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_transformer_property_level": 0.001998632000095313, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_basic_update": 0.001691623999931835, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_diff_after_update": 0.0016937190000589908, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_no_parameters_update": 0.001678520000041317, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_no_template_error": 0.0016723400001410482, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_set_notification_arn_with_update": 0.0016622790001292742, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_tags": 0.0016795820001789252, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_using_template_url": 0.0016387240002586623, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_capabilities[capability0]": 0.001814044999946418, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_capabilities[capability1]": 0.0018373789998804568, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_invalid_rollback_configuration_errors": 0.0018282920000274316, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_previous_parameter_value": 0.0017151189999822236, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_previous_template": 0.0018118509999567323, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_resource_types": 0.001732491999746344, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_role_without_permissions": 0.0018157390002215834, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_rollback_configuration": 0.0017220919999090256, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[missing-def]": 0.0018446529998072947, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[multiple-nones]": 0.0017364999998790154, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[none-value]": 0.0016850419999627775, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_missing_resources_block": 0.0016888499999367923, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_resources_blocks[invalid-key]": 0.0018640090002008947, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_resources_blocks[missing-type]": 0.0016791509999620757, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py::TestResourceAttributes::test_dependency_on_attribute_with_dot_notation": 0.0018324199998005497, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py::TestResourceAttributes::test_invalid_getatt_fails": 0.0018111399999725109, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_condition_on_outputs": 0.00179946900016148, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_att_to_conditional_resources[create]": 0.0016814849998354475, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_att_to_conditional_resources[no-create]": 0.0018406650001452363, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_in_conditional[dev-us-west-2]": 0.0016677700000400364, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_in_conditional[production-us-east-1]": 0.0016551660000914126, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_conditional_with_select": 0.0016477029998895887, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_dependency_in_non_evaluated_if_branch[None-FallbackParamValue]": 0.0016784000001734967, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_dependency_in_non_evaluated_if_branch[false-DefaultParamValue]": 0.001681885999687438, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref": 0.0018248149999635643, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref_intrinsic_fn_condition": 0.0016250690000561008, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref_with_macro": 0.0019515550000051007, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-bucket-policy]": 0.0017046889997800463, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-nobucket-nopolicy]": 0.001822922000201288, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-bucket-nopolicy]": 0.0018188850001479295, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-nobucket-nopolicy]": 0.0018030539999926987, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_output_reference_to_skipped_resource": 0.0017044180001448694, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_simple_condition_evaluation_deploys_resource": 0.0018210190000900184, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_simple_condition_evaluation_doesnt_deploy_resource": 0.0018338129998483055, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_simple_intrinsic_fn_condition_evaluation[nope]": 0.0017868929999167449, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_simple_intrinsic_fn_condition_evaluation[yep]": 0.0016552360000332556, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_sub_in_conditions": 0.0017058920000181388, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_update_conditions": 0.0017761039998731576, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_async_mapping_error_first_level": 0.0017006709999805025, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_async_mapping_error_second_level": 0.0017273630001000129, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_aws_refs_in_mappings": 0.0018210890000318614, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_maximum_nesting_depth": 0.0018563349999567436, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_minimum_nesting_depth": 0.0018329399999856832, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-deploy]": 0.0019993739999790705, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-not-deploy]": 0.0018118810000942176, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_invalid_refs": 0.0018055489999824204, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_nonexisting_key": 0.0016977770001176395, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_simple_mapping_working": 0.0017975740001929807, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestDependsOn::test_depends_on_with_missing_reference": 0.0018111100000623992, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestFnSub::test_fn_sub_cases": 0.0017092380001031415, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestFnSub::test_non_string_parameter_in_sub": 0.0018495399999665096, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::test_resolve_transitive_placeholders_in_strings": 0.0017825160000484175, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::test_useful_error_when_invalid_ref": 0.0018190049997883762, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_acm.py::test_cfn_acm_certificate": 0.0018359570001393877, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::TestServerlessApigwLambda::test_serverless_like_deployment_with_update": 0.0016857139999046922, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_account": 0.0016862449999734963, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_api_gateway_with_policy_as_dict": 0.001695611999821267, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_apigateway_aws_integration": 0.0017970940000395785, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_apigateway_rest_api": 0.0016967550000117626, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_apigateway_swagger_import": 0.0016738110000460438, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_from_s3_swagger": 0.001699358999985634, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_integration": 0.0018615239998780453, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_models": 0.001790731000028245, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_with_apigateway_resources": 0.0017010620001656207, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_rest_api_serverless_ref_resolving": 0.0016958630001226993, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_update_apigateway_stage": 0.0016983880000225327, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_update_usage_plan": 0.0016916960000799008, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_url_output": 0.0017248869999093586, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[10]": 0.001978144000077009, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[11]": 0.0016880480002328113, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[12]": 0.0016510780001226522, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap_redeploy": 0.0016527709999536455, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py::TestCdkSampleApp::test_cdk_sample": 0.0016752940000515082, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py::test_create_macro": 0.0016905629997836513, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py::test_waitcondition": 0.0017012840000916185, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_alarm_creation": 0.0016896509998787224, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_alarm_ext_statistic": 0.0017029069999807689, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_composite_alarm_creation": 0.0017780390001007618, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_billing_mode_as_conditional[PAY_PER_REQUEST]": 0.0016942810000273312, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_billing_mode_as_conditional[PROVISIONED]": 0.0016387860000577348, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_default_name_for_table": 0.0016486050001276453, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_deploy_stack_with_dynamodb_table": 0.0016618889999335806, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_global_table": 0.0016626609999548236, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_global_table_with_ttl_and_sse": 0.0018572460000996216, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_globalindex_read_write_provisioned_throughput_dynamodb_table": 0.0016321319999406114, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_table_with_ttl_and_sse": 0.0017833370000062132, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_ttl_cdk": 0.0016485029998420941, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_cfn_update_ec2_instance_type": 0.0017888570000650361, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_cfn_with_multiple_route_table_associations": 0.0016953520000697608, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_cfn_with_multiple_route_tables": 0.0016884590002064215, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_dhcp_options": 0.0016788609998457105, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_ec2_security_group_id_with_vpc": 0.0017795799999476003, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_internet_gateway_ref_and_attr": 0.001700270999890563, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_keypair_create_import": 0.0023714049998488917, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_simple_route_table_creation": 0.001690673999974024, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_simple_route_table_creation_without_vpc": 0.0017877069999485684, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_transit_gateway_attachment": 0.0016975059998003417, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_vpc_creates_default_sg": 0.001676576999898316, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_vpc_with_route_table": 0.0017451260000598268, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.py::test_cfn_handle_elasticsearch_domain": 0.0016977569998744002, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_cfn_event_api_destination_resource": 0.0016754549999404844, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_cfn_event_bus_resource": 0.001675765000300089, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_event_rule_creation_without_target": 0.0016924359997574356, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_event_rule_to_logs": 0.0016655960000662162, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_eventbus_policies": 0.0016750639997553662, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_eventbus_policy_statement": 0.0016676499999448424, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_rule_pattern_transformation": 0.0018699899999319314, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_rule_properties": 0.0017137759998604452, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.py::test_firehose_stack_with_kinesis_as_source": 0.0016666980002355558, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.py::test_events_sqs_sns_lambda": 0.00329278900017016, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_cfn_handle_kinesis_firehose_resources": 0.001773138000316976, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_default_parameters_kinesis": 0.0017935170001237566, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_describe_template": 0.0016922160000376607, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_dynamodb_stream_response_with_cf": 0.0017784589997518196, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_kinesis_stream_consumer_creations": 0.0021793629998683173, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_stream_creation": 0.001796732999764572, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py::test_cfn_with_kms_resources": 0.0017107199998918077, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py::test_deploy_stack_with_kms": 0.0016710360000615765, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py::test_kms_key_disabled": 0.0018011309998655634, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaDestinations::test_generic_destination_routing[sqs-sqs]": 0.0018442919999870355, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_dynamodb_source": 0.0018948469999031659, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_kinesis_source": 0.0016773479999301344, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_permissions": 0.0017946689999916998, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_sqs_source": 0.0018134039999040397, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_lambda_dynamodb_event_filter": 0.0016799620000256255, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_cfn_function_url": 0.0017666270002791862, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_event_invoke_config": 0.0016828980001264426, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_alias": 0.0016984070000489737, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_cfn_dead_letter_config_async_invocation": 0.001676395999993474, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_cfn_run": 0.001647542999990037, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_cfn_run_with_empty_string_replacement_deny_list": 0.0016486940000959294, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_cfn_run_with_non_empty_string_replacement_deny_list": 0.0018721149999691988, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_code_signing_config": 0.001695931999847744, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_function_tags": 0.0017808220000006258, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_layer_crud": 0.0016838910000842588, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_logging_config": 0.0018428199998652417, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_version": 0.0016892000001007546, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": 0.0016805029999886756, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_vpc": 0.0017119930000717432, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter": 0.001684631000216541, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter_update": 0.0017208500000833737, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_multiple_lambda_permissions_for_singlefn": 0.0017974540000977868, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_python_lambda_code_deployed_via_s3": 0.0016693420000137849, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_update_lambda_function": 0.001705941999944116, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_update_lambda_function_name": 0.0017860420000488375, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_update_lambda_permissions": 0.0016682309999396239, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.py::test_cfn_handle_log_group_resource": 0.0018377200001395977, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.py::test_logstream": 0.0016848320001372485, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py::test_domain": 0.0017543020001085097, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py::test_domain_with_alternative_types": 0.0016806340001949138, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.py::test_redshift_cluster": 0.0016836899999361776, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.py::test_group_defaults": 0.0016862449999734963, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py::test_create_health_check": 0.0018637789999047527, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py::test_create_record_set_via_id": 0.001683809999803998, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py::test_create_record_set_via_name": 0.0016877260000001115, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py::test_create_record_set_without_resource_record": 0.0018404049999389827, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_bucket_autoname": 0.0017966519999390584, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_bucket_versioning": 0.0016780289997768705, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_bucketpolicy": 0.0018148569999993924, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_cfn_handle_s3_notification_configuration": 0.0018787060000704514, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_cors_configuration": 0.0016968150000593596, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_object_lock_configuration": 0.0016885390000425105, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_website_configuration": 0.0017139660001248558, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_cfn_handle_serverless_api_resource": 0.001695763000043371, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_sam_policies": 0.0016953919998741185, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_sam_sqs_event": 0.0016917850000481849, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_sam_template": 0.0017366990000482474, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cdk_deployment_generates_secret_value_if_no_value_is_provided": 0.001769240000157879, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_handle_secretsmanager_secret": 0.0018164089999572752, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secret_policy[default]": 0.0018601209999360435, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secret_policy[true]": 0.0017155399998500798, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secretsmanager_gen_secret": 0.0016824570000153471, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_deploy_stack_with_sns_topic": 0.0016731300001993077, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_subscription": 0.0018112089999249292, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": 0.0016994900001918722, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_topic_fifo_without_suffix_fails": 0.0017242360002001078, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_topic_with_attributes": 0.0021096929997383995, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_update_subscription": 0.0017188760000408365, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_cfn_handle_sqs_resource": 0.001718196000183525, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_sqs_fifo_queue_generates_valid_name": 0.0017217520000940567, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_sqs_non_fifo_queue_generates_valid_name": 0.0017578199999661592, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_sqs_queue_policy": 0.0017452659999435127, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_update_queue_no_change": 0.0017346249999263819, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_update_sqs_queuepolicy": 0.0017400060000909434, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_deploy_patch_baseline": 0.0018159779999677994, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_maintenance_window": 0.001825676999942516, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_parameter_defaults": 0.0016709559999981138, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_update_ssm_parameter_tag": 0.0017308280000634113, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_update_ssm_parameters": 0.002274052000075244, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": 0.0018148370002109004, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_apigateway_invoke": 9.55670019199988, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_apigateway_invoke_localhost": 9.584605539999984, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_apigateway_invoke_localhost_with_path": 15.692652052000085, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_apigateway_invoke_with_path": 15.646685982000008, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_cfn_statemachine_default_s3_location": 4.851693664999857, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_cfn_statemachine_with_dependencies": 2.1767626269995617, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_nested_statemachine_with_sync2": 0.0021158739998554665, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_retry_and_catch": 0.002625191000106497, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_statemachine_create_with_logging_configuration": 2.679367164000041, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_statemachine_definitionsubstitution": 7.326691123999581, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestImportValues::test_cfn_with_exports": 0.0018481789998077147, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestImportValues::test_import_values_across_stacks": 0.0017253270000310295, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestImports::test_stack_imports": 0.0017042289998698834, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-0-0-False]": 0.001812481999877491, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-0-1-False]": 0.0018454229998496885, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-1-0-False]": 0.001703666000139492, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::And-1-1-True]": 0.0018242640001062682, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-0-0-False]": 0.001811089000057109, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-0-1-True]": 0.0018392220001715032, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-1-0-True]": 0.0016692019999027252, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_and_or_functions[Fn::Or-1-1-True]": 0.001964799999996103, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_base64_sub_and_getatt_functions": 0.0017246960001102707, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_cfn_template_with_short_form_fn_sub": 0.001845413999944867, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_cidr_function": 0.0016858829999364389, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_find_map_function": 0.001707173999875522, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-northeast-1]": 0.0017478900001606235, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-southeast-2]": 0.0016470099999423837, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-central-1]": 0.0016771180000887398, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-west-1]": 0.0018514749999667401, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-1]": 0.001696795000043494, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-2]": 0.001673230000278636, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-1]": 0.0016384739999466547, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-2]": 0.0017933750000338478, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_join_no_value_construct": 0.0017050199999175675, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_split_length_and_join_functions": 0.0017103499999393534, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_sub_not_ready": 0.0016859340000792145, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_sub_number_type": 0.0016717969997444015, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_to_json_functions": 0.0017297660001531767, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_attribute_uses_macro": 0.0017378120001012576, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_capabilities_requirements": 0.001692386000058832, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_error_pass_macro_as_reference": 0.0017056810002031852, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[raise_error.py]": 0.0016890389999844047, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_invalid_template.py]": 0.001696092999964094, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_with_message.py]": 0.0017201979997025774, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_without_message.py]": 0.0017487510001501505, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_functions_and_references_during_transformation": 0.001725908000025811, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_global_scope": 0.0017288839999309857, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_macro_deployment": 0.001692316000116989, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_pyplate_param_type_list": 0.0018219290000160981, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_scope_order_and_parameters": 0.001714959000082672, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.json]": 0.0017161719997602631, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.yml]": 0.0016997699997318705, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_to_validate_template_limit_for_macro": 0.001674000999855707, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_validate_lambda_internals": 0.0016987270000754506, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestPreviousValues::test_parameter_usepreviousvalue_behavior": 0.0018217800002275908, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestPseudoParameters::test_stack_id": 0.0017287039997881948, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSecretsManagerParameters::test_resolve_secretsmanager[resolve_secretsmanager.yaml]": 0.0017080749998967804, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSecretsManagerParameters::test_resolve_secretsmanager[resolve_secretsmanager_full.yaml]": 0.0016871469997568056, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSecretsManagerParameters::test_resolve_secretsmanager[resolve_secretsmanager_partial.yaml]": 0.0018325589999221847, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_create_change_set_with_ssm_parameter_list": 0.001732661999994889, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_create_stack_with_ssm_parameters": 0.0018173420000948681, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_resolve_ssm": 0.0017745410000316042, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_resolve_ssm_secure": 0.0016943190000802133, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_resolve_ssm_with_version": 0.0018198770001163211, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_ssm_nested_with_nested_stack": 0.0016926470000271365, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestStackEvents::test_invalid_stack_deploy": 0.0017125839999607706, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestTypes::test_implicit_type_conversion": 0.0019701090000125987, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_add_new_negative_condition_to_existent_resource": 0.0016892300000108662, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_add_new_positive_condition_to_existent_resource": 0.0016801029996713623, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_update_adds_resource": 0.0017243259999304428, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_update_removes_resource": 0.0017388329999903362, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_multiple_dependencies_addition": 0.0016767370000252413, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_multiple_dependencies_deletion": 0.0019475070000680716, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_update_depended_resource": 0.00167573499993523, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_update_depended_resource_list": 0.001654263999853356, + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_add_to_static_property": 0.0017023049999806972, + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_change_input_string": 0.001713946999871041, + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_remove_from_static_property": 0.00170007099995928, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change": 0.0017363389999900392, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change_in_get_attr_chain": 0.0016983069999696454, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change_with_dependent_addition": 0.0019328380001297774, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_immutable_property_update_causes_resource_replacement": 0.0018102590001944918, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_resource_addition": 0.0017719749998832413, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_resource_deletion": 0.0018616449999626639, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_indirect_update_refence_argument": 0.001899123999692165, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_refence_argument": 0.0016359300000203802, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_argument": 0.0018022530000507686, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_arguments_empty": 0.0020442969998839544, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_delimiter": 0.0018097470001521287, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_delimiter_empty": 0.0018465579996700399, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_add_to_static_property": 0.002085636000174418, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_get_att_reference": 0.0018310569998902793, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selected_element_type_ref": 0.0024419459998625825, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selection_index_only": 0.0017792999999528547, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selection_list": 0.0016353779999462859, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_remove_from_static_property": 0.0017669059998297598, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_add_to_static_property": 0.0017667370000253868, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_change_delimiter": 0.0016881470000953414, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_change_source_string_only": 0.0016976259998955356, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_remove_from_static_property": 0.0016824959998302802, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_with_get_att": 0.0016775280000729254, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_with_ref_as_string_source": 0.0016920450000270648, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter": 0.0018032859998129425, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter_literal": 0.0018512150002152339, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter_ref": 0.001958166000122219, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_string_pseudo": 0.002630271000043649, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_delete_parameter_literal": 0.0018072519997076597, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_delete_string_pseudo": 0.0017117819998020423, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_parameter_literal": 0.001813523000009809, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_parameter_type": 0.0018322190001072158, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_string_pseudo": 0.0016858030001003499, + "tests/aws/services/cloudformation/v2/test_change_set_global_macros.py::TestChangeSetGlobalMacros::test_base_global_macro": 0.001711601999886625, + "tests/aws/services/cloudformation/v2/test_change_set_global_macros.py::TestChangeSetGlobalMacros::test_update_after_macro_for_before_version_is_deleted": 0.001663472000245747, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_addition_with_resource": 0.0018693700001222169, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_deletion_with_resource_remap": 0.0017495929998858628, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_addition_with_resource": 0.0018855289999919478, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_deletion_with_resource_remap": 0.002151330999822676, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_update": 0.001835615000118196, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_leaf_update": 0.001983103000156916, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_default_value": 0.0018663130001641548, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_default_value_with_dynamic_overrides": 0.0016807940000944654, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_with_added_default_value": 0.0017692399999305053, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_with_removed_default_value": 0.00170671199998651, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change": 0.0017727369997828646, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change_in_ref_chain": 0.0019083720001162874, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change_with_dependent_addition": 0.0017145480001090618, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_immutable_property_update_causes_resource_replacement": 0.0017811529999107734, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_resource_addition": 0.0018243150000216701, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_supported_pseudo_parameter": 0.0018056389997127553, + "tests/aws/services/cloudformation/v2/test_change_set_values.py::TestChangeSetValues::test_property_empy_list": 0.0017073240001082013, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_dynamic]": 0.0016461689997413487, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_parameter_for_condition_create_resource]": 0.0018166610000207584, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property]": 0.0016723999999612715, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property_not_create_only]": 0.0016454180001801433, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_mapping_scenarios[update_string_referencing_resource]": 0.0017560850001245853, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_conditions": 0.0016841009999097878, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_direct_update": 0.0017815749999954278, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_dynamic_update": 0.001802894000093147, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_execute_with_ref": 0.001684521000015593, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_mappings_with_parameter_lookup": 0.0016738509998504014, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_mappings_with_static_fields": 0.00165469600005963, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_parameter_changes": 0.0018175010000049951, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_unrelated_changes_requires_replacement": 0.0016636110001400084, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_unrelated_changes_update_propagation": 0.0016616679997696338, + "tests/aws/services/cloudformation/v2/test_change_sets.py::test_single_resource_static_update": 0.001830304999884902, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_alarm_lambda_target": 1.6588327070001014, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_anomaly_detector_lifecycle": 0.0017814530001487583, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_aws_sqs_metrics_created": 2.4053226489997996, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_breaching_alarm_actions": 5.327894351999703, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_create_metric_stream": 0.0017906409998431627, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_dashboard_lifecycle": 0.13635574800014183, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_default_ordering": 0.1217799759999707, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_delete_alarm": 0.08614855199994054, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_describe_alarms_converts_date_format_correctly": 0.07565382800021325, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_describe_minimal_metric_alarm": 0.0789208129999679, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_enable_disable_alarm_actions": 10.267964980000215, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data": 2.0696550639997895, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data0]": 0.0017495630002031248, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data1]": 0.0017082350000237057, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data2]": 0.0016820660000576027, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_for_multiple_metrics": 1.054883954999923, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_pagination": 2.193059437999864, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Average]": 0.03721415499990144, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Maximum]": 0.03557060400021328, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Minimum]": 0.03759001799994621, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[SampleCount]": 0.034644050999986575, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Sum]": 0.03631544700010636, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_with_different_units": 0.027011082999933933, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_with_dimensions": 0.0456323319999683, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_with_zero_and_labels": 0.041791563000060705, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_statistics": 0.1820999319998009, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_with_no_results": 0.04700870300007409, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_with_null_dimensions": 0.035240652999846134, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_handle_different_units": 0.03014209200023288, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_insight_rule": 0.0016768159998719057, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_invalid_amount_of_datapoints": 0.53788765399986, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_invalid_dashboard_name": 0.01694008100002975, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs0]": 0.04137902499996926, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs1]": 0.03300057700016623, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs2]": 0.03621734200009996, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs3]": 0.03134011600013764, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs4]": 0.032011985999815806, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs5]": 0.03184679200012397, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs6]": 0.03489718600008018, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_list_metrics_pagination": 5.487225811999679, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_list_metrics_uniqueness": 2.0642293529999733, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_list_metrics_with_filters": 4.089517793999903, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_metric_widget": 0.001764189999903465, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_multiple_dimensions": 2.113000705999866, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_multiple_dimensions_statistics": 0.05675538199966468, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_parallel_put_metric_data_list_metrics": 0.26865911299978507, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_composite_alarm_describe_alarms": 0.08756290399992395, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_alarm": 10.626126554999928, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_alarm_escape_character": 0.07427573499990103, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_data_gzip": 0.025508685999966474, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_data_validation": 0.042883724000148504, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_data_values_list": 0.03484400100023777, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_uses_utc": 0.031368503999829045, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_raw_metric_data": 0.02475959900016278, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_set_alarm": 2.3241947650001293, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_set_alarm_invalid_input": 0.08598116500002106, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_store_tags": 0.130997010000101, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_trigger_composite_alarm": 4.622326079999766, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestCloudWatchLambdaMetrics::test_lambda_invoke_error": 2.565200761999904, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestCloudWatchLambdaMetrics::test_lambda_invoke_successful": 2.505402973000173, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestSQSMetrics::test_alarm_number_of_messages_sent": 61.344707223999876, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestSqsApproximateMetrics::test_sqs_approximate_metrics": 4.301799806999952, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_binary": 0.11349163599999201, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_items": 0.14627707300002157, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_items_streaming": 1.2025184160000322, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_not_existing_table": 0.3768779879999897, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_not_matching_schema": 0.156668475999993, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_binary_data_with_stream": 2.41027739499998, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_continuous_backup_update": 0.6152465350000398, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_create_duplicate_table": 0.12191865000005464, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_data_encoding_consistency": 2.0314438209999253, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_delete_table": 0.11897333099994967, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_batch_execute_statement": 0.15081189100004622, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_create_table_with_class": 0.19210136700002067, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_create_table_with_partial_sse_specification": 0.6410952319999978, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_create_table_with_sse_specification": 0.11019953400000304, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_execute_statement_empy_parameter": 0.12211886099998992, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_execute_transaction": 0.2074975699999868, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_get_batch_items": 0.14252459000005047, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_idempotent_writing": 0.1841898709999441, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_partiql_missing": 0.1308254310000052, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_pay_per_request": 0.05984891499997502, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_stream_records_with_update_item": 0.0036306599999988975, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_stream_shard_iterator": 0.9187986900000169, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_stream_stream_view_type": 1.4272737469999583, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_streams_describe_with_exclusive_start_shard_id": 0.8492215069999247, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_streams_shard_iterator_format": 3.151018262999969, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_update_table_without_sse_specification_change": 0.1575417570000468, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_with_kinesis_stream": 1.5270516769999745, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_empty_and_binary_values": 0.09601939199995968, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_global_tables": 0.10391515699996035, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_global_tables_version_2019": 0.48177860900005953, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_gsi_with_billing_mode[PAY_PER_REQUEST]": 0.6263888520000478, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_gsi_with_billing_mode[PROVISIONED]": 1.0917168589999733, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_invalid_query_index": 0.08313355399997135, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_large_data_download": 2.196408341999984, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_list_tags_of_resource": 0.09998308000001543, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_more_than_20_global_secondary_indexes": 0.3060519480000039, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_multiple_update_expressions": 0.193885236999904, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_non_ascii_chars": 3.675071284000012, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_nosql_workbench_localhost_region": 0.1730570020000073, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_query_on_deleted_resource": 0.6398077360000798, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_return_values_in_put_item": 0.1343439750000357, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_return_values_on_conditions_check_failure": 0.3338704630000393, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_stream_destination_records": 12.188755568999966, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_streams_on_global_tables": 1.3810885899999903, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_time_to_live": 0.2813494790000277, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_time_to_live_deletion": 0.5211896499999398, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_get_items": 0.11801381499992658, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_write_items_streaming": 1.6436349730000188, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_write_items_streaming_for_different_tables": 1.516481970999962, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_binary_data": 0.10573595300002125, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_canceled": 0.13781535000003942, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_items": 0.11073084000003064, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_valid_local_secondary_index": 0.13313932499994507, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_valid_query_index": 0.11534692899999754, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_enable_kinesis_streaming_destination": 0.0018199290000211477, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_non_existent_stream": 0.033307666000041536, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_stream_spec_and_region_replacement": 2.3782015960000535, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_table_v2_stream": 3.436000407999927, + "tests/aws/services/ec2/test_ec2.py::TestEc2FlowLogs::test_ec2_flow_logs_s3": 0.8893998539999757, + "tests/aws/services/ec2/test_ec2.py::TestEc2FlowLogs::test_ec2_flow_logs_s3_validation": 0.26893967099999827, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_route_table_association": 1.6482777530000021, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_security_group_with_custom_id[False-id_manager]": 0.0872756140000206, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_security_group_with_custom_id[False-tag]": 0.06628052000002072, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_security_group_with_custom_id[True-id_manager]": 0.05318838499999856, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_security_group_with_custom_id[True-tag]": 0.350141973999996, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_subnet_with_custom_id": 0.07275645400005715, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_subnet_with_custom_id_and_vpc_id": 0.08844031700004962, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_subnet_with_tags": 0.15842780500003073, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_vpc_endpoint": 0.2315456210000093, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_vpc_with_custom_id": 0.14685428699999648, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_describe_vpc_endpoints_with_filter": 0.6905055550000156, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_describe_vpn_gateways_filter_by_vpc": 0.4941545809999752, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_get_security_groups_for_vpc": 0.617765808999934, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_modify_launch_template[id]": 0.11494995800006791, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_modify_launch_template[name]": 0.0912102619999473, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_reserved_instance_api": 0.04226078100009545, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_vcp_peering_difference_regions": 1.635318161999919, + "tests/aws/services/ec2/test_ec2.py::test_create_specific_vpc_id": 0.02708632300004865, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filter_with_zone_ids": 0.5261020180000173, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filter_with_zone_names": 0.3469415580000259, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filters": 0.47863561100007246, + "tests/aws/services/ec2/test_ec2.py::test_pickle_ec2_backend": 2.3284769100000062, + "tests/aws/services/ec2/test_ec2.py::test_raise_create_volume_without_size": 0.025804088999962005, + "tests/aws/services/ec2/test_ec2.py::test_raise_duplicate_launch_template_name": 0.04926594399995565, + "tests/aws/services/ec2/test_ec2.py::test_raise_invalid_launch_template_name": 0.019498324999972283, + "tests/aws/services/ec2/test_ec2.py::test_raise_modify_to_invalid_default_version": 0.05252567400003727, + "tests/aws/services/ec2/test_ec2.py::test_raise_when_launch_template_data_missing": 0.016165004999947996, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_create_domain": 0.003018074000010529, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_create_existing_domain_causes_exception": 0.003031398999951307, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_describe_domains": 0.002428226999995786, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_domain_version": 0.0030371299999956136, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_get_compatible_version_for_domain": 0.0019651680000265515, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_get_compatible_versions": 0.0021843499999363303, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_list_versions": 0.002715414999954646, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_path_endpoint_strategy": 0.0030334530000573068, + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_update_domain_config": 0.0026147569999466214, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth0]": 0.23469952200008493, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth1]": 0.15394945100001678, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth2]": 0.1223246079999285, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_create_api_destination_invalid_parameters": 0.02115666700001384, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_create_api_destination_name_validation": 0.05575944999998228, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[api-key]": 0.0540015199999857, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[basic]": 0.05361437499999511, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[oauth]": 0.05495488499991552, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection": 0.06798480000003337, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_invalid_parameters": 0.017069528000035916, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_name_validation": 0.014286934999915957, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params0]": 0.07059437300006266, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params1]": 0.056218457999932525, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params2]": 0.06160210199999483, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_delete_connection": 0.13109994299992422, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_list_connections": 0.05958683800008657, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_update_connection": 0.0980421550000301, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_duplicate[custom]": 0.08734138699998084, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_duplicate[default]": 0.05860850100009429, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_unknown_event_bus": 0.014170994999972208, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_list_describe_update_delete_archive[custom]": 0.11181014900000719, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_list_describe_update_delete_archive[default]": 0.09229064799995967, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_delete_archive_error_unknown_archive": 0.013437099000100261, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_describe_archive_error_unknown_archive": 0.013217936999978974, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_error_unknown_source_arn": 0.013384460000054332, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_state_enabled[custom]": 0.1051071169999318, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_state_enabled[default]": 0.07749451299997645, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[False-custom]": 0.5411336529999744, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[False-default]": 0.515105186000028, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[True-custom]": 0.6220621250000136, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[True-default]": 0.7255060229999231, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_name_prefix[custom]": 0.09801773099997035, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_name_prefix[default]": 0.07185793400003604, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_source_arn[custom]": 0.09465817900002094, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_source_arn[default]": 0.05763519100003123, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_update_archive_error_unknown_archive": 0.001707845999987967, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_describe_replay_error_unknown_replay": 0.013978424000015366, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replay_with_limit": 0.20878605499996183, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replays_with_event_source_arn": 0.09801699600001257, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replays_with_prefix": 0.15152677599996878, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_list_describe_canceled_replay[custom]": 0.001698627999985547, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_list_describe_canceled_replay[default]": 0.0017006219999871064, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_duplicate_different_archive": 0.11871430000002192, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_duplicate_name_same_archive": 0.06989189299997633, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_invalid_end_time[0]": 0.06381381500000316, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_invalid_end_time[10]": 0.06427945100000443, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_archive": 0.013826757000003909, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_event_bus": 0.09415415000000849, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::tests_concurrency_error_too_many_active_replays": 0.0017979939999577255, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[False-regions0]": 0.04175286000003098, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[False-regions1]": 0.11111188900002844, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[True-regions0]": 0.04467408600004319, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[True-regions1]": 0.12745141699997475, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_multiple_event_buses_same_name": 0.041425725000010516, + "tests/aws/services/events/test_events.py::TestEventBus::test_delete_default_event_bus": 0.01302329199995711, + "tests/aws/services/events/test_events.py::TestEventBus::test_describe_delete_not_existing_event_bus": 0.024849921999987146, + "tests/aws/services/events/test_events.py::TestEventBus::test_list_event_buses_with_limit": 0.2282282749999922, + "tests/aws/services/events/test_events.py::TestEventBus::test_list_event_buses_with_prefix": 0.07724721299990733, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[domain]": 0.2837156449999725, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[path]": 0.28376381499998615, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[standard]": 0.42943199400008325, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_nonexistent_event_bus": 0.1535605530000339, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_to_default_eventbus_for_custom_eventbus": 1.031943348000027, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission[custom]": 0.29073789900007796, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission[default]": 0.09472468299998127, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission_non_existing_event_bus": 0.01379484000000275, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission[custom]": 0.0834056490000421, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission[default]": 0.06415180200002624, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[False-custom]": 0.04245576499994286, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[False-default]": 0.02076899000002186, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[True-custom]": 0.04953463399994007, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[True-default]": 0.027508268000019598, + "tests/aws/services/events/test_events.py::TestEventPattern::test_put_events_pattern_nested": 10.228749643999947, + "tests/aws/services/events/test_events.py::TestEventPattern::test_put_events_pattern_with_values_in_array": 5.283841778999999, + "tests/aws/services/events/test_events.py::TestEventRule::test_delete_rule_with_targets": 0.09435497599997689, + "tests/aws/services/events/test_events.py::TestEventRule::test_describe_nonexistent_rule": 0.01850615400002198, + "tests/aws/services/events/test_events.py::TestEventRule::test_disable_re_enable_rule[custom]": 0.11928338600000643, + "tests/aws/services/events/test_events.py::TestEventRule::test_disable_re_enable_rule[default]": 0.07781191099996931, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target[custom]": 0.20858407599996553, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target[default]": 0.1563032209999733, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_no_matches[custom]": 0.13232287200003157, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_no_matches[default]": 0.0914042509999149, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_with_limit[custom]": 0.28537931100004243, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_with_limit[default]": 0.26561828599994897, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_with_limit": 0.258920729999943, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_pattern_to_single_matching_rules_single_target": 7.348347513999954, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_multiple_matching_rules_different_targets": 0.5252856949999796, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_multiple_matching_rules_single_target": 18.615692163999995, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_single_matching_rules_single_target": 10.426828766000028, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_list_with_prefix_describe_delete_rule[custom]": 0.08185192199999847, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_list_with_prefix_describe_delete_rule[default]": 0.055609611000022596, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_multiple_rules_with_same_name": 0.07952248800000916, + "tests/aws/services/events/test_events.py::TestEventRule::test_update_rule_with_targets": 0.10432465199994567, + "tests/aws/services/events/test_events.py::TestEventTarget::test_add_exceed_fife_targets_per_rule": 0.09373364599997558, + "tests/aws/services/events/test_events.py::TestEventTarget::test_list_target_by_rule_limit": 0.14459214499999007, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_list_remove_target[custom]": 0.10854775699993979, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_list_remove_target[default]": 0.07789943799997445, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_arn_across_different_rules": 0.10777059900004815, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_arn_single_rule": 0.07667117599999074, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_id_across_different_rules": 0.1110918530000049, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_id_single_rule": 0.07648567600000433, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_target_id_validation": 0.09121507799994788, + "tests/aws/services/events/test_events.py::TestEvents::test_create_connection_validations": 0.01308466200003977, + "tests/aws/services/events/test_events.py::TestEvents::test_events_written_to_disk_are_timestamp_prefixed_for_chronological_ordering": 0.0017903000000387692, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[ARRAY]": 0.013749877000009292, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[MALFORMED_JSON]": 0.013566947999947843, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[SERIALIZED_STRING]": 0.013508372999979201, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[STRING]": 0.013660237000067355, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_with_too_big_detail": 0.017984473000069556, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_without_detail": 0.013129133000006732, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_without_detail_type": 0.013097560000005615, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_exceed_limit_ten_entries[custom]": 0.0436409479999611, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_exceed_limit_ten_entries[default]": 0.015689125000051263, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_response_entries_order": 0.2882998000000043, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_time": 0.977946060000022, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_target_delivery_failure": 1.1554350709998857, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_time_field": 0.18852878599994938, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_without_source": 0.013379289999988941, + "tests/aws/services/events/test_events_cross_account_region.py::TestEventsCrossAccountRegion::test_put_events[custom-account]": 0.15055006500000445, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-account]": 0.4320094120000135, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-region]": 0.42844577800002526, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-region_account]": 0.43192022499999894, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-account]": 0.46716208999993114, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-region]": 0.4716454199999589, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-region_account]": 0.4605463089999944, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path": 0.1830151029999456, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_max_level_depth": 0.18371442299996943, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_multiple_targets": 0.2892783020000138, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_nested[event_detail0]": 0.18150700799992592, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_nested[event_detail1]": 0.18669072900001993, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\"<listmulti> multiple list items\"]": 0.23574862099997063, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\"<listsingle> single list item <listmulti> multiple list items <systemstring> system account id <payload> payload <userId> user id\"]": 0.2505186330000697, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\"<listsingle> single list item\"]": 0.22521228300001894, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\"Payload of <payload> with path users-service/users/<userId> and <userId>\"]": 0.2210204590000444, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"id\" : \"<userId>\"}]": 0.22071483799999214, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"id\" : <userId>}]": 0.223227755000039, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"nested\": {\"level1\": {\"level2\": {\"level3\": \"users-service/users/<userId>\"} } }, \"bod\": \"<userId>\"}]": 0.22565098699993769, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/<userId>\", \"bod\": <payload>}]": 0.2196051179999472, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/<userId>\", \"bod\": [<userId>, \"hardcoded\"]}]": 0.22595661100001507, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/<userId>\", \"id\": <userId>, \"body\": <payload>}]": 0.2242483599999332, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"multi_replacement\": \"users/<userId>/second/<userId>\"}]": 0.22825741599996263, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"singlelistitem\": <listsingle>}]": 0.2635568649999982, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement_not_valid[{\"not_valid\": \"users-service/users/<payload>\", \"bod\": <payload>}]": 5.147318427000016, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement_not_valid[{\"payload\": \"<payload>\"}]": 5.147621232000006, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement_not_valid[{\"singlelistitem\": \"<listsingle>\"}]": 5.1489003419999335, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_predefined_variables[\"Message containing all pre defined variables <aws.events.rule-arn> <aws.events.rule-name> <aws.events.event.ingestion-time>\"]": 0.21727245199991785, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_predefined_variables[{\"originalEvent\": <aws.events.event>, \"originalEventJson\": <aws.events.event.json>}]": 0.2275967389999778, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_json": 0.39502966300000253, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string[\"Event of <detail-type> type, at time <timestamp>, info extracted from detail <command>\"]": 0.3987984340000139, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string[\"{[/Check with special starting characters for event of <detail-type> type\"]": 0.3936877289999643, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_missing_keys": 0.10821629500003382, + "tests/aws/services/events/test_events_inputs.py::test_put_event_input_path_and_input_transformer": 0.10013962100003937, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_array_event_payload": 0.013773596000078214, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays]": 0.014040058999967187, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_NEG]": 0.015562696999950276, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_empty_EXC]": 0.08843525900005034, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_empty_null_NEG]": 0.013602265000031366, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[boolean]": 0.014405872999986968, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[boolean_NEG]": 0.013858427999991818, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_many_rules]": 0.015654740000059064, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_multi_match]": 0.013726020999968114, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_multi_match_NEG]": 0.014024318999986463, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_or]": 0.013989125999955832, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_or_NEG]": 0.013855427000009968, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase]": 0.013965616999939812, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_EXC]": 0.087636539000016, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_NEG]": 0.013942755000016405, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list]": 0.014905666000004203, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list_EXC]": 0.0870418189999782, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list_NEG]": 0.013550779000013335, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number]": 0.013796421999984432, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_NEG]": 0.013867847000028632, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_list]": 0.013652535999995052, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_list_NEG]": 0.01993404000006649, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_zero]": 0.01369203000001562, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string]": 0.013876404000029652, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_NEG]": 0.014050567999959185, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_list]": 0.01392395199997054, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_list_NEG]": 0.017232824000018354, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_null]": 0.013848819999964235, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix]": 0.014241361000017605, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_NEG]": 0.01461464099998011, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_empty_EXC]": 0.0911445909999884, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_ignorecase_EXC]": 0.08758095500002128, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_int_EXC]": 0.09594218699999146, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list]": 0.01404647199996134, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list_NEG]": 0.013891630000046007, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list_type_EXC]": 0.08727440600000591, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix]": 0.0137232250000352, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_NEG]": 0.014114688999995906, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_empty_EXC]": 0.09220430799996393, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_ignorecase_EXC]": 0.08718205799993939, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_int_EXC]": 0.08683477899995751, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list]": 0.013841734999971322, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list_NEG]": 0.01361837700005708, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list_type_EXC]": 0.08767270199996346, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard]": 0.01391706799995518, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_NEG]": 0.014058657999953539, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_empty]": 0.014069723999909911, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list]": 0.013921857000013915, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list_NEG]": 0.014829747000021598, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list_type_EXC]": 0.08730382099997769, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_type_EXC]": 0.08700658000009298, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists]": 0.013848348999999871, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_NEG]": 0.013587809999989986, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_false]": 0.013813382999956048, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_false_NEG]": 0.013968915000020843, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase]": 0.013946584000052553, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_EXC]": 0.0989963449999891, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_NEG]": 0.013983473000052982, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_empty]": 0.014182284999947115, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_empty_NEG]": 0.01472548299994969, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_list_EXC]": 0.08705670699998791, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address]": 0.01376603400001386, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_EXC]": 0.08761551899999631, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_NEG]": 0.014279410999961328, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_bad_ip_EXC]": 0.08630129899995609, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_bad_mask_EXC]": 0.08683350100000098, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_type_EXC]": 0.08684269600001926, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6]": 0.014033853000000818, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6_NEG]": 0.0140171250000094, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6_bad_ip_EXC]": 0.0869462569999655, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_EXC]": 0.08727682899996125, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_and]": 0.013542794999978014, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_and_NEG]": 0.013737861999970846, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_number_EXC]": 0.08614337300002717, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_operatorcasing_EXC]": 0.09880673900005377, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_syntax_EXC]": 0.08737821999994821, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix]": 0.013585403000035967, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_NEG]": 0.013656999999966501, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_empty]": 0.01393199099999265, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_ignorecase]": 0.013522798999986207, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_int_EXC]": 0.08657317699993428, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_list_EXC]": 0.08644767200007664, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix]": 0.01367644599997675, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_NEG]": 0.014039999000033276, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_empty]": 0.013767712000003485, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_ignorecase]": 0.014643884999998136, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_ignorecase_NEG]": 0.013586600999985876, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_int_EXC]": 0.08728891299995212, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_list_EXC]": 0.08597028900004489, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_complex_EXC]": 0.0877361409999935, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_empty_NEG]": 0.014157860000068467, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_int_EXC]": 0.08731635800000959, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_list_EXC]": 0.086449765999987, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_nonrepeating]": 0.01423243000004959, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_nonrepeating_NEG]": 0.014291718000038145, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating]": 0.014023174999977073, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating_NEG]": 0.014559693000023799, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating_star_EXC]": 0.08697795699993094, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_simplified]": 0.01385032199999614, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_event]": 0.013866277999966314, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_event_NEG]": 0.01401473199996417, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_pattern]": 0.017195278999963648, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_pattern_NEG]": 0.013633105999986128, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dynamodb]": 0.013941133999935573, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_dynamodb]": 0.013991518000011638, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_dynamodb_NEG]": 0.013824928000019554, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_list_empty_NEG]": 0.0173190119999731, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[int_nolist_EXC]": 0.08675671000008833, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[key_case_sensitive_NEG]": 0.01405114100003857, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[list_within_dict]": 0.014002227999981187, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[minimal]": 0.014479203000007601, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[nested_json_NEG]": 0.0179087590000222, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[null_value]": 0.013696995000032075, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[null_value_NEG]": 0.013822751000020617, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[number_comparison_float]": 0.014144864999991569, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-int-float]": 0.013695142000017313, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-null_NEG]": 0.013890828000000965, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-string_NEG]": 0.013516184999957659, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[operator_case_sensitive_EXC]": 0.8294943249999278, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[operator_multiple_list]": 0.014124957999968046, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-anything-but]": 0.0139261749999946, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-exists-parent]": 0.014139785999930155, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-exists]": 0.013657061000003523, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-numeric-anything-but]": 0.013953606999962176, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-numeric-anything-but_NEG]": 0.013552523999976529, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[prefix]": 0.01898723199997221, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[sample1]": 0.017245091000006596, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string]": 0.01347310500000276, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string_empty]": 0.01394448000007742, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string_nolist_EXC]": 0.08822467699991421, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern_source": 0.023055875000011383, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern_with_escape_characters": 0.012032885999985865, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern_with_multi_key": 0.012080663999995522, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_with_large_and_complex_payload": 0.025368117000084567, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_event_payload": 0.013811659999987569, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[[\"not\", \"a\", \"dict\", \"but valid json\"]]": 0.0857283979999579, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[this is valid json but not a dict]": 0.08595454499999278, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[{\"not\": closed mark\"]": 0.0876374609999857, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[{'bad': 'quotation'}]": 0.08669788200000994, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_plain_string_payload": 0.013962031000005481, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_event_with_content_base_rule_in_pattern": 0.19360697100000834, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_anything_but": 5.307954657999915, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_exists_false": 5.238481857000011, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_exists_true": 5.234440705999987, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::test_schedule_cron_target_sqs": 0.001737500999979602, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(0 1 * * * *)]": 0.013733963999925436, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(0 dummy ? * MON-FRI *)]": 0.013225159999933567, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(7 20 * * NOT *)]": 0.013126385999953527, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(71 8 1 * ? *)]": 0.01372266300006686, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(INVALID)]": 0.013064934999988509, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(* * ? * SAT#3 *)]": 0.038826570999958676, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 10 * * ? *)]": 0.03709637500003282, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 12 * * ? *)]": 0.03669567899987669, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 18 ? * MON-FRI *)]": 0.03676752400008354, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 2 ? * SAT *)]": 0.03594952800006013, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 2 ? * SAT#3 *)]": 0.036397962000023654, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 8 1 * ? *)]": 0.03570408699999916, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/10 * ? * MON-FRI *)]": 0.03540154099994197, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/15 * * * ? *)]": 0.035606159000053594, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/30 0-2 ? * MON-FRI *)]": 0.035662940000065646, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/30 20-23 ? * MON-FRI *)]": 0.03531066499999724, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/5 5 ? JAN 1-5 2022)]": 0.03637777100004769, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/5 8-17 ? * MON-FRI *)]": 0.03596931400011272, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(15 10 ? * 6L 2002-2005)]": 0.03603244700002506, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(15 12 * * ? *)]": 0.03643399500003852, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(5,35 14 * * ? *)]": 0.036786365000011756, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_scheduled_rule_does_not_trigger_on_put_events": 3.094000660000006, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[ rate(10 minutes)]": 0.011253780999993523, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate( 10 minutes )]": 0.011276268000017353, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate()]": 0.011101514000017687, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(-10 minutes)]": 0.01081757099996139, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(0 minutes)]": 0.011134967000032248, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(1 days)]": 0.011167419000003065, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(1 hours)]": 0.01106499999997368, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(1 minutes)]": 0.01116553499997508, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 MINUTES)]": 0.011139827999954832, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 day)]": 0.010950137000008908, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 hour)]": 0.011457675000031031, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 minute)]": 0.010989634999930331, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 minutess)]": 0.01123357700004135, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 seconds)]": 0.01199954899993827, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 years)]": 0.011193364000007477, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10)]": 0.011089900999991187, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(foo minutes)]": 0.011157720000028348, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_schedule_rate": 0.03725044399999433, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_scheduled_rule_logs": 0.0019174680001015076, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_put_rule_with_schedule_custom_event_bus": 0.04064513899999156, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_schedule_rate_custom_input_target_sqs": 60.11011747900005, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_schedule_rate_target_sqs": 0.0017124849999845537, + "tests/aws/services/events/test_events_tags.py::TestEventBusTags::test_create_event_bus_with_tags": 0.041447564999998576, + "tests/aws/services/events/test_events_tags.py::TestEventBusTags::test_list_tags_for_deleted_event_bus": 0.0341245990000516, + "tests/aws/services/events/test_events_tags.py::TestRuleTags::test_list_tags_for_deleted_rule": 0.06179310899983648, + "tests/aws/services/events/test_events_tags.py::TestRuleTags::test_put_rule_with_tags": 0.061931929000024866, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[event_bus-event_bus_custom]": 0.06673670500015305, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[event_bus-event_bus_default]": 0.01950081899997258, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[rule-event_bus_custom]": 0.08961624400001256, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[rule-event_bus_default]": 0.06215435599983721, + "tests/aws/services/events/test_events_tags.py::tests_tag_list_untag_not_existing_resource[not_existing_event_bus]": 0.02803000400001565, + "tests/aws/services/events/test_events_tags.py::tests_tag_list_untag_not_existing_resource[not_existing_rule]": 0.029277464999950098, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus-event_bus_custom]": 0.06772289600007753, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus-event_bus_default]": 0.04664786500006812, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule-event_bus_custom]": 0.08818240199991578, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule-event_bus_default]": 0.06167665899988606, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetApiDestination::test_put_events_to_target_api_destinations[auth0]": 0.11412957299990012, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetApiDestination::test_put_events_to_target_api_destinations[auth1]": 0.1063869629998635, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetApiDestination::test_put_events_to_target_api_destinations[auth2]": 0.11224377400003505, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetApiGateway::test_put_events_with_target_api_gateway": 8.156932009999991, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetCloudWatchLogs::test_put_events_with_target_cloudwatch_logs": 0.2092246670000577, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination0]": 0.28902916300012294, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination1]": 0.31755686799999694, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination2]": 0.2868856390000474, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetFirehose::test_put_events_with_target_firehose": 0.99379687499993, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetKinesis::test_put_events_with_target_kinesis": 0.8892552610001303, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda": 4.247750239000084, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda_list_entries_partial_match": 4.267272430000048, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda_list_entry": 4.2584446840000965, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[domain]": 0.21977460400000837, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[path]": 0.2163728280000896, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[standard]": 0.5083796410000332, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSqs::test_put_events_with_target_sqs": 0.1774397429999226, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSqs::test_put_events_with_target_sqs_event_detail_match": 5.211128032999909, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetStepFunctions::test_put_events_with_target_statefunction_machine": 2.9524454170000354, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_api_gateway": 5.693505470000105, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_events[bus_combination0]": 4.401832877000061, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_events[bus_combination1]": 4.376761090999935, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_events[bus_combination2]": 4.359639313999992, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_lambda": 4.246128535000025, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_elasticsearch_s3_backup": 0.0018654810000953148, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_kinesis_as_source": 31.22429723999994, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_kinesis_as_source_multiple_delivery_streams": 40.96366342400006, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_opensearch_s3_backup[domain]": 0.0018198449999999866, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_opensearch_s3_backup[path]": 0.001755044999981692, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_opensearch_s3_backup[port]": 0.0018549310000253172, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_s3_as_destination_with_file_extension": 1.1729861930000425, + "tests/aws/services/firehose/test_firehose.py::test_kinesis_firehose_http[False]": 0.07258426900000359, + "tests/aws/services/firehose/test_firehose.py::test_kinesis_firehose_http[True]": 1.5689024840000911, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_create_role_with_malformed_assume_role_policy_document": 0.018865736999941873, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_create_user_add_permission_boundary_afterwards": 0.10586921500009794, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_create_user_with_permission_boundary": 0.089678197000012, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_get_user_without_username_as_role": 0.11553348100017047, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_get_user_without_username_as_root": 0.038981957999908445, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_get_user_without_username_as_user": 0.1460948719998214, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_role_with_path_lifecycle": 0.1168416680000064, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_attach_detach_role_policy": 0.08421882499999356, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_attach_iam_role_to_new_iam_user": 0.09604501699993762, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_create_describe_role": 0.14556146800009628, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_create_role_with_assume_role_policy": 0.13123533000009502, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_create_user_with_tags": 0.031119363000016165, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_delete_non_existent_policy_returns_no_such_entity": 0.015018507000036152, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_instance_profile_tags": 0.14267651400007253, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_list_roles_with_permission_boundary": 0.1661541449999504, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_recreate_iam_role": 0.05844579100005376, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_role_attach_policy": 0.3997728690000031, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_service_linked_role_name_should_match_aws[ecs.amazonaws.com-AWSServiceRoleForECS]": 0.0018847480000658834, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_service_linked_role_name_should_match_aws[eks.amazonaws.com-AWSServiceRoleForAmazonEKS]": 0.0017429409999749623, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[group]": 0.18756371200004196, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[role]": 0.21019440700001724, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[user]": 0.22487879000004796, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_update_assume_role_policy": 0.1117942150002591, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_user_attach_policy": 0.41098029999989194, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_group_policy_encoding": 0.05440843300004872, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_role_policy_encoding": 0.17527150600005825, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_user_policy_encoding": 0.08587161700006618, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_already_exists": 0.03270288199996685, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_deletion": 3.880975657000022, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[accountdiscovery.ssm.amazonaws.com]": 0.27654325400010293, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[acm.amazonaws.com]": 0.2750345010000501, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[appmesh.amazonaws.com]": 0.2793880969999236, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[autoscaling-plans.amazonaws.com]": 0.2732665129999532, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[autoscaling.amazonaws.com]": 0.2751391469998907, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[backup.amazonaws.com]": 0.28093682899998385, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[batch.amazonaws.com]": 0.2987161080000078, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cassandra.application-autoscaling.amazonaws.com]": 0.28010696200010443, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cks.kms.amazonaws.com]": 0.2769673779999948, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cloudtrail.amazonaws.com]": 0.27491705499994623, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[codestar-notifications.amazonaws.com]": 0.2738159899998891, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[config.amazonaws.com]": 0.27364827000008063, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[connect.amazonaws.com]": 0.2736134460001267, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[dms-fleet-advisor.amazonaws.com]": 0.2746804170000132, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[dms.amazonaws.com]": 0.2725408940000307, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[docdb-elastic.amazonaws.com]": 0.2739201319999438, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ec2-instance-connect.amazonaws.com]": 0.2709721389999231, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ec2.application-autoscaling.amazonaws.com]": 0.2755708099999765, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ecr.amazonaws.com]": 0.2750042569997504, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ecs.amazonaws.com]": 0.2728810629999998, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-connector.amazonaws.com]": 0.2751310169999215, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-fargate.amazonaws.com]": 0.27249375600001713, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-nodegroup.amazonaws.com]": 0.2719201500000281, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks.amazonaws.com]": 0.2704133899999306, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticache.amazonaws.com]": 0.2763020240001879, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticbeanstalk.amazonaws.com]": 0.27332827500015355, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticfilesystem.amazonaws.com]": 0.2715916519999837, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticloadbalancing.amazonaws.com]": 0.27636849600014557, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[email.cognito-idp.amazonaws.com]": 0.27807051199988564, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[emr-containers.amazonaws.com]": 0.27575367400015693, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[emrwal.amazonaws.com]": 0.28013338399989607, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[fis.amazonaws.com]": 0.27435615099989263, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[grafana.amazonaws.com]": 0.2769268970000667, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[imagebuilder.amazonaws.com]": 0.2770457899998746, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[iotmanagedintegrations.amazonaws.com]": 0.346458245000008, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[kafka.amazonaws.com]": 0.27361881100000573, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[kafkaconnect.amazonaws.com]": 0.2760644240000829, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lakeformation.amazonaws.com]": 0.27113695299988194, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lex.amazonaws.com]": 0.3455643260000443, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lexv2.amazonaws.com]": 1.1703846189999467, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lightsail.amazonaws.com]": 0.29297139200002675, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[m2.amazonaws.com]": 0.28311628300002667, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[memorydb.amazonaws.com]": 0.27104241299991827, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[mq.amazonaws.com]": 0.2802386009999509, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[mrk.kms.amazonaws.com]": 0.27355616400006966, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[notifications.amazonaws.com]": 0.2750217759999032, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[observability.aoss.amazonaws.com]": 0.27250392300015847, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opensearchservice.amazonaws.com]": 0.26988900999992893, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ops.apigateway.amazonaws.com]": 0.2692581550001023, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ops.emr-serverless.amazonaws.com]": 0.2729070909999791, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opsdatasync.ssm.amazonaws.com]": 0.2735837970000148, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opsinsights.ssm.amazonaws.com]": 0.2741830200000095, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[pullthroughcache.ecr.amazonaws.com]": 0.2724824179999814, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ram.amazonaws.com]": 0.27586270899985266, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[rds.amazonaws.com]": 0.28175316900001235, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[redshift.amazonaws.com]": 0.2739707079999789, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[replication.cassandra.amazonaws.com]": 0.2758735529999967, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[replication.ecr.amazonaws.com]": 0.273337993000041, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[repository.sync.codeconnections.amazonaws.com]": 0.2732975970000098, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[resource-explorer-2.amazonaws.com]": 0.2727198950000229, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[rolesanywhere.amazonaws.com]": 0.2752373880000505, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[s3-outposts.amazonaws.com]": 0.2774595779999345, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ses.amazonaws.com]": 0.27736040199988565, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[shield.amazonaws.com]": 0.2791164980000076, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm-incidents.amazonaws.com]": 0.27360992199987777, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm-quicksetup.amazonaws.com]": 0.2746261009999671, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm.amazonaws.com]": 0.27520300399999087, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[sso.amazonaws.com]": 0.2750662770000645, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[vpcorigin.cloudfront.amazonaws.com]": 0.27600891700001284, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[waf.amazonaws.com]": 0.27462486800004626, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[wafv2.amazonaws.com]": 0.2747683989999814, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[autoscaling.amazonaws.com]": 0.12866534100010085, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[connect.amazonaws.com]": 0.12731474600002457, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[lexv2.amazonaws.com]": 0.12675599700003204, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[accountdiscovery.ssm.amazonaws.com]": 0.015008775000069363, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[acm.amazonaws.com]": 0.015101142999924377, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[appmesh.amazonaws.com]": 0.014972994000117978, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[autoscaling-plans.amazonaws.com]": 0.015302595999969526, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[backup.amazonaws.com]": 0.015539133999936894, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[batch.amazonaws.com]": 0.014786135999884209, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cassandra.application-autoscaling.amazonaws.com]": 0.015324632999977439, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cks.kms.amazonaws.com]": 0.014890811000100257, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cloudtrail.amazonaws.com]": 0.01638002400000005, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[codestar-notifications.amazonaws.com]": 0.014907828999980666, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[config.amazonaws.com]": 0.01490401200010183, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[dms-fleet-advisor.amazonaws.com]": 0.015192269000067427, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[dms.amazonaws.com]": 0.015451729000119485, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[docdb-elastic.amazonaws.com]": 0.01544304000003649, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ec2-instance-connect.amazonaws.com]": 0.01489394199995786, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ec2.application-autoscaling.amazonaws.com]": 0.015489530999957424, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ecr.amazonaws.com]": 0.015028806000032091, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ecs.amazonaws.com]": 0.015088601999991624, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-connector.amazonaws.com]": 0.015484601999901315, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-fargate.amazonaws.com]": 0.015096322999966105, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-nodegroup.amazonaws.com]": 0.01493789900007414, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks.amazonaws.com]": 0.015514628999881097, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticache.amazonaws.com]": 0.014896337999971365, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticbeanstalk.amazonaws.com]": 0.015030989999900157, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticfilesystem.amazonaws.com]": 0.015137280999965697, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticloadbalancing.amazonaws.com]": 0.014849800999968465, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[email.cognito-idp.amazonaws.com]": 0.015457290999961515, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[emr-containers.amazonaws.com]": 0.015155442999912339, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[emrwal.amazonaws.com]": 0.017907458000081533, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[fis.amazonaws.com]": 0.015226617000166698, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[grafana.amazonaws.com]": 0.014904071999922053, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[imagebuilder.amazonaws.com]": 0.0152319679999664, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[iotmanagedintegrations.amazonaws.com]": 0.015132921000031274, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[kafka.amazonaws.com]": 0.014931924000165964, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[kafkaconnect.amazonaws.com]": 0.015321054000082768, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lakeformation.amazonaws.com]": 0.015461557999969955, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lex.amazonaws.com]": 0.015088816000002225, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lightsail.amazonaws.com]": 0.015012570000067171, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[m2.amazonaws.com]": 0.015426644000058332, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[memorydb.amazonaws.com]": 0.015414660000033109, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[mq.amazonaws.com]": 0.015021111999999448, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[mrk.kms.amazonaws.com]": 0.01491172699991239, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[notifications.amazonaws.com]": 0.01601394400006484, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[observability.aoss.amazonaws.com]": 0.01501275899988741, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opensearchservice.amazonaws.com]": 0.015112174000023515, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ops.apigateway.amazonaws.com]": 0.015522201999942808, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ops.emr-serverless.amazonaws.com]": 0.016088323999838394, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opsdatasync.ssm.amazonaws.com]": 0.015216238000107296, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opsinsights.ssm.amazonaws.com]": 0.015034387000014249, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[pullthroughcache.ecr.amazonaws.com]": 0.015051701999823308, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ram.amazonaws.com]": 0.015461076999940815, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[rds.amazonaws.com]": 0.015285187999893424, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[redshift.amazonaws.com]": 0.015173035999964668, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[replication.cassandra.amazonaws.com]": 0.015621908999946754, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[replication.ecr.amazonaws.com]": 0.015025550999894222, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[repository.sync.codeconnections.amazonaws.com]": 0.015146869999966839, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[resource-explorer-2.amazonaws.com]": 0.015084589000025517, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[rolesanywhere.amazonaws.com]": 0.014947003000088444, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[s3-outposts.amazonaws.com]": 0.01496205600005851, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ses.amazonaws.com]": 0.015130417000136731, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[shield.amazonaws.com]": 0.015220353999893632, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm-incidents.amazonaws.com]": 0.014909291000094527, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm-quicksetup.amazonaws.com]": 0.015137168000023848, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm.amazonaws.com]": 0.015164626999990105, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[sso.amazonaws.com]": 0.015343555999947966, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[vpcorigin.cloudfront.amazonaws.com]": 0.015081605999966996, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[waf.amazonaws.com]": 0.01497851100009484, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[wafv2.amazonaws.com]": 0.0161602949999633, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_create_service_specific_credential_invalid_service": 0.07391881000000922, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_create_service_specific_credential_invalid_user": 0.024205239000025358, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_delete_user_after_service_credential_created": 0.07588603800002147, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_id_match_user_mismatch": 0.09093537699993703, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_invalid_update_parameters": 0.0752078730000676, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_list_service_specific_credential_different_service": 0.07629627200003597, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_service_specific_credential_lifecycle[cassandra.amazonaws.com]": 0.10273542599998109, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_service_specific_credential_lifecycle[codecommit.amazonaws.com]": 0.10563920300000973, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_match_id_mismatch[satisfiesregexbutstillinvalid]": 0.09140832399998544, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_match_id_mismatch[totally-wrong-credential-id-with-hyphens]": 0.09034530899987203, + "tests/aws/services/iam/test_iam.py::TestRoles::test_role_with_tags": 0.07079992199999197, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_add_tags_to_stream": 0.6656089489999886, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_cbor_blob_handling": 0.6499804300000278, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_create_stream_without_shard_count": 0.6625004589999435, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_create_stream_without_stream_name_raises": 0.037835629999904086, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_get_records": 0.720968950999918, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_get_records_empty_stream": 0.6532199759999457, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_get_records_next_shard_iterator": 0.6557734530001653, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_get_records_shard_iterator_with_surrounding_quotes": 0.6549024720001171, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_record_lifecycle_data_integrity": 0.8495777449999196, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_stream_consumers": 1.311129015000006, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard": 4.504598001999966, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_cbor_at_timestamp": 4.348904934000075, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_timeout": 6.299241492000078, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_at_timestamp": 4.492809118999958, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_at_timestamp_cbor": 0.6369343710000521, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_sequence_number_as_iterator": 4.524160890999951, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisJavaSDK::test_subscribe_to_shard_with_java_sdk_v2_lambda": 9.55920469800003, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_add_tags_to_stream": 0.6744998809999743, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_cbor_blob_handling": 0.6526024050000387, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_create_stream_without_shard_count": 0.6476333750000549, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_create_stream_without_stream_name_raises": 0.04097701499983941, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_get_records": 0.7072758749998229, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_get_records_empty_stream": 0.6546011099998168, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_get_records_next_shard_iterator": 0.6554258190001292, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_get_records_shard_iterator_with_surrounding_quotes": 0.658289616999923, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_record_lifecycle_data_integrity": 0.8496299580000368, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_stream_consumers": 1.267917539999985, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_subscribe_to_shard": 4.468063014999984, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_subscribe_to_shard_cbor_at_timestamp": 4.339432351000028, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_subscribe_to_shard_timeout": 6.311445835000086, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_subscribe_to_shard_with_at_timestamp": 4.5011585800000375, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_subscribe_to_shard_with_at_timestamp_cbor": 0.6400576260000435, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisMockScala::test_subscribe_to_shard_with_sequence_number_as_iterator": 4.486823338999898, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisPythonClient::test_run_kcl": 26.93776612499994, + "tests/aws/services/kms/test_kms.py::TestKMS::test_all_types_of_key_id_can_be_used_for_encryption": 0.06494192699983614, + "tests/aws/services/kms/test_kms.py::TestKMS::test_cant_delete_deleted_key": 0.03335391999996773, + "tests/aws/services/kms/test_kms.py::TestKMS::test_cant_use_disabled_or_deleted_keys": 0.052931303000150365, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_alias": 0.11019844700001613, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_custom_key_asymmetric": 0.037706835999870236, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_grant_with_invalid_key": 0.023597825000024386, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_grant_with_same_name_two_keys": 0.05924451999987923, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_grant_with_valid_key": 0.04095820799989269, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key": 0.11579136999989714, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_custom_id": 0.02934977799986882, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_custom_key_material_hmac": 0.03689107500008504, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_custom_key_material_symmetric_decrypt": 0.029594279999855644, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[lowercase_prefix]": 0.09164164300000266, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[too_long_key]": 0.08905004500013547, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[uppercase_prefix]": 0.08814092000000073, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_tag_and_untag": 0.11232046399993578, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_too_many_tags_raises_error": 0.08897360599996773, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_list_delete_alias": 0.058487191000040184, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_multi_region_key": 0.16881458399996063, + "tests/aws/services/kms/test_kms.py::TestKMS::test_derive_shared_secret": 0.19611615799999527, + "tests/aws/services/kms/test_kms.py::TestKMS::test_describe_and_list_sign_key": 0.03476213400006145, + "tests/aws/services/kms/test_kms.py::TestKMS::test_disable_and_enable_key": 0.053302724999980455, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_decrypt[RSA_2048-RSAES_OAEP_SHA_256]": 0.05132915799993043, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_decrypt[SYMMETRIC_DEFAULT-SYMMETRIC_DEFAULT]": 0.03342328899987024, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_decrypt_encryption_context": 0.18521451800006616, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_2048-RSAES_OAEP_SHA_1]": 0.13456630199993924, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_2048-RSAES_OAEP_SHA_256]": 0.13794123399986802, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_3072-RSAES_OAEP_SHA_1]": 0.3471852719999333, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_3072-RSAES_OAEP_SHA_256]": 0.22891654900001868, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_4096-RSAES_OAEP_SHA_1]": 0.7416380699997944, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_4096-RSAES_OAEP_SHA_256]": 1.4525493990000768, + "tests/aws/services/kms/test_kms.py::TestKMS::test_error_messaging_for_invalid_keys": 0.22137786799999049, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_224-HMAC_SHA_224]": 0.12181289099999049, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_256-HMAC_SHA_256]": 0.12165450500003772, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_384-HMAC_SHA_384]": 0.12276580199988985, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_512-HMAC_SHA_512]": 0.12456876399994599, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[1024]": 0.0851899689999982, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[12]": 0.08676812200008044, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[1]": 0.08481757099991682, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[44]": 0.08455359399999907, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[91]": 0.08433361199990941, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[0]": 0.08769573900008254, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[1025]": 0.08952539500000967, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[None]": 0.09406993900006455, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_does_not_exist": 0.1160767130000977, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_in_different_region": 0.13495136299980004, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_invalid_uuid": 0.10313127000006261, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_parameters_for_import": 0.7752716300000202, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_public_key": 0.057767248999880394, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_put_list_key_policies": 0.04831409700000222, + "tests/aws/services/kms/test_kms.py::TestKMS::test_hmac_create_key": 0.11943663200008814, + "tests/aws/services/kms/test_kms.py::TestKMS::test_hmac_create_key_invalid_operations": 0.10336854399997719, + "tests/aws/services/kms/test_kms.py::TestKMS::test_import_key_asymmetric": 0.21531683999978668, + "tests/aws/services/kms/test_kms.py::TestKMS::test_import_key_symmetric": 0.2921221430000287, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_generate_mac[HMAC_224-HMAC_SHA_256]": 0.10146143299994037, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_generate_mac[HMAC_256-INVALID]": 0.10149931599994488, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_key_usage": 0.7619058449998874, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-HMAC_SHA_256-some different important message]": 0.18493324300004588, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-HMAC_SHA_512-some important message]": 0.18357465799999773, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-INVALID-some important message]": 0.1809678710001208, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_enable_rotation_status[180]": 0.10894483899994611, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_enable_rotation_status[90]": 0.10686972500002412, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotation_status": 0.05503462999990916, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotations_encryption_decryption": 0.12556557400000656, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotations_limits": 0.22562786900004994, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_with_long_tag_value_raises_error": 0.10730461800005742, + "tests/aws/services/kms/test_kms.py::TestKMS::test_list_aliases_of_key": 0.06176765700001852, + "tests/aws/services/kms/test_kms.py::TestKMS::test_list_grants_with_invalid_key": 0.013170928000022286, + "tests/aws/services/kms/test_kms.py::TestKMS::test_list_keys": 0.026431174999970608, + "tests/aws/services/kms/test_kms.py::TestKMS::test_list_retirable_grants": 0.0683796620000976, + "tests/aws/services/kms/test_kms.py::TestKMS::test_non_multi_region_keys_should_not_have_multi_region_properties": 0.1670322990000841, + "tests/aws/services/kms/test_kms.py::TestKMS::test_plaintext_size_for_encrypt": 0.10000337899998613, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt[RSA_2048-RSAES_OAEP_SHA_256]": 0.18053645399993457, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt[SYMMETRIC_DEFAULT-SYMMETRIC_DEFAULT]": 0.13641436000011709, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt_incorrect_source_key": 0.1203014320000193, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt_invalid_destination_key": 0.04753469199999927, + "tests/aws/services/kms/test_kms.py::TestKMS::test_replicate_key": 0.5240556229999811, + "tests/aws/services/kms/test_kms.py::TestKMS::test_retire_grant_with_grant_id_and_key_id": 0.05587179700000888, + "tests/aws/services/kms/test_kms.py::TestKMS::test_retire_grant_with_grant_token": 0.05690569399996548, + "tests/aws/services/kms/test_kms.py::TestKMS::test_revoke_grant": 0.05681567900001028, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_modifies_key_material": 0.11486728599993512, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_is_disabled": 0.7929894569997487, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_that_does_not_exist": 0.08718738500010659, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_with_imported_key_material": 0.100093988000026, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_non_symmetric_key": 1.4577738630000567, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_disabled": 0.11549465200005216, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_enabled": 0.13673397199988813, + "tests/aws/services/kms/test_kms.py::TestKMS::test_schedule_and_cancel_key_deletion": 0.04728603499995643, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_NIST_P256-ECDSA_SHA_256]": 0.2991813239998464, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_NIST_P384-ECDSA_SHA_384]": 0.30465004900020176, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_SECG_P256K1-ECDSA_SHA_256]": 0.30739025399998354, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_256]": 0.7255258870000034, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_384]": 0.6955293329999677, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_512]": 0.8146632570001202, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_4096-RSASSA_PKCS1_V1_5_SHA_256]": 3.2752407160000985, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_4096-RSASSA_PKCS1_V1_5_SHA_512]": 4.6014803660000325, + "tests/aws/services/kms/test_kms.py::TestKMS::test_symmetric_encrypt_offline_decrypt_online[RSA_2048-RSAES_OAEP_SHA_1]": 0.1169449619999341, + "tests/aws/services/kms/test_kms.py::TestKMS::test_symmetric_encrypt_offline_decrypt_online[RSA_2048-RSAES_OAEP_SHA_256]": 0.12183301399988977, + "tests/aws/services/kms/test_kms.py::TestKMS::test_symmetric_encrypt_offline_decrypt_online[RSA_3072-RSAES_OAEP_SHA_1]": 0.3825505659998498, + "tests/aws/services/kms/test_kms.py::TestKMS::test_symmetric_encrypt_offline_decrypt_online[RSA_3072-RSAES_OAEP_SHA_256]": 0.4281850120000854, + "tests/aws/services/kms/test_kms.py::TestKMS::test_symmetric_encrypt_offline_decrypt_online[RSA_4096-RSAES_OAEP_SHA_1]": 0.6988828559999547, + "tests/aws/services/kms/test_kms.py::TestKMS::test_symmetric_encrypt_offline_decrypt_online[RSA_4096-RSAES_OAEP_SHA_256]": 0.9752125920001617, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_existing_key_and_untag": 0.12554772500004674, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_existing_key_with_invalid_tag_key": 0.1053680989998611, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_key_with_duplicate_tag_keys_raises_error": 0.10309362800001054, + "tests/aws/services/kms/test_kms.py::TestKMS::test_untag_key_partially": 0.11488069100005305, + "tests/aws/services/kms/test_kms.py::TestKMS::test_update_alias": 0.06715193499996985, + "tests/aws/services/kms/test_kms.py::TestKMS::test_update_and_add_tags_on_tagged_key": 0.11512721300005069, + "tests/aws/services/kms/test_kms.py::TestKMS::test_update_key_description": 0.04216177000000698, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_NIST_P256-ECDSA_SHA_256]": 0.040377650999857906, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_NIST_P384-ECDSA_SHA_384]": 0.04202404000000115, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_SECG_P256K1-ECDSA_SHA_256]": 0.043791787000031945, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_256]": 0.21464519300002394, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_384]": 0.16541899200001353, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_512]": 0.168733700999951, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_4096-RSASSA_PKCS1_V1_5_SHA_256]": 1.1178364539999848, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_4096-RSASSA_PKCS1_V1_5_SHA_512]": 1.2230685570000333, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key": 0.1816854940000212, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_pair": 0.1395070600000281, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_pair_without_plaintext": 0.1570776459999479, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_without_plaintext": 0.18148438599996553, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key": 0.035309089000065796, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair": 0.12095041800000672, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_dry_run": 0.02949523700010559, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_without_plaintext": 0.051217230000133895, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_without_plaintext_dry_run": 0.07268918600004781, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_without_plaintext": 0.03035679300000993, + "tests/aws/services/kms/test_kms.py::TestKMSMultiAccounts::test_cross_accounts_access": 1.729220373999965, + "tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.py::test_adding_tags": 17.631117195999877, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_deletion_event_source_mapping_with_dynamodb": 6.106967671999996, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_disabled_dynamodb_event_source_mapping": 12.290464992000011, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_duplicate_event_source_mappings": 5.569817903000171, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_filter_type]": 12.759028485000044, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_multiple_filters]": 0.01949063600000045, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_or_filter]": 12.80061878999993, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[date_time_conversion]": 12.787256531999901, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[exists_false_filter]": 12.787223159000064, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[exists_filter_type]": 12.861079945000029, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[insert_same_entry_twice]": 12.76723725699992, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[numeric_filter]": 12.801853258999927, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[prefix_filter]": 12.793306839999786, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping": 14.773536923000165, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_on_failure_destination_config": 12.326346782999963, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_s3_on_failure_destination": 11.467876665000063, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_sns_on_failure_destination_config": 11.326636291, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_invalid_event_filter[[{\"eventName\": [\"INSERT\"=123}]]": 4.541035339000018, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_invalid_event_filter[single-string]": 4.551128230000131, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[empty_string_item_identifier_failure]": 15.787028628999906, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[invalid_key_foo_failure]": 14.864239499999712, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[invalid_key_foo_null_value_failure]": 14.833716632999995, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[item_identifier_not_present_failure]": 14.811849359000007, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[null_item_identifier_failure]": 14.793406796, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[unhandled_exception_in_function]": 14.832705266999938, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failures": 15.152531349000128, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_batch_item_failure_success]": 9.792657134000137, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_dict_success]": 9.727105500000107, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_list_success]": 9.721277063999878, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[null_batch_item_failure_success]": 9.741211215000021, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[null_success]": 9.731033525000157, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_esm_with_not_existing_dynamodb_stream": 1.847918232999973, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisEventFiltering::test_kinesis_event_filtering_json_pattern": 9.221229530000073, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_create_kinesis_event_source_mapping": 12.124542015000088, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_create_kinesis_event_source_mapping_multiple_lambdas_single_kinesis_event_stream": 19.4569824780001, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_disable_kinesis_event_source_mapping": 29.237951503999966, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_duplicate_event_source_mappings": 3.4308385409999573, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_esm_with_not_existing_kinesis_stream": 1.4248643280000124, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_empty_provided": 9.21406395799977, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_async_invocation": 20.183046332999993, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_on_failure_destination_config": 9.177713167999855, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_s3_on_failure_destination": 9.234696785999859, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_sns_on_failure_destination_config": 9.218450892999954, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_trim_horizon": 26.2689719949999, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded[expire-before-ingestion]": 14.291592448000074, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded[expire-while-retrying]": 9.304034706000039, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded_discard_records": 19.332092689999854, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[empty_string_item_identifier_failure]": 12.151112499999954, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[invalid_key_foo_failure]": 12.158886597999981, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[invalid_key_foo_null_value_failure]": 12.194354185000293, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[item_identifier_not_present_failure]": 12.153801438999835, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[null_item_identifier_failure]": 12.175606449999805, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[unhandled_exception_in_function]": 12.163257663999957, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failures": 12.278017024000064, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_batch_item_failure_success]": 7.11028017700005, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_dict_success]": 7.115026274999764, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_list_success]": 7.125336644999834, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_string_success]": 7.098304268999982, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[null_batch_item_failure_success]": 7.12129396399996, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[null_success]": 7.106951506000087, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_duplicate_event_source_mappings": 2.598862421999911, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_event_source_mapping_default_batch_size": 3.436823104000041, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[and]": 6.455917790000058, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[exists]": 6.452980779999734, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-bigger]": 6.4280974519999745, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-range]": 6.427309272000002, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-smaller]": 6.4399540580002395, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[or]": 6.430508362000182, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[plain-string-filter]": 0.002295086000003721, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[plain-string-matching]": 0.002510781999944811, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[prefix]": 6.407622073999846, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[single]": 6.4176224929999535, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[valid-json-filter]": 6.447590876000049, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping": 6.360414357999844, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[10000]": 9.588201448999826, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[1000]": 9.54244215000017, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[100]": 4.591935854999974, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[15]": 9.590653606999922, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size_override[10000]": 0.02089085499983412, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size_override[1000]": 8.740668241000094, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size_override[100]": 6.6316958139998405, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size_override[20]": 6.422534341999835, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batching_reserved_concurrency": 8.680707425999799, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batching_window_size_override": 26.836252384999852, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_update": 12.663267043999895, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[None]": 1.2579787570000462, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[invalid_filter2]": 1.2263874390000638, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[invalid_filter3]": 1.2247902080000586, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[simple string]": 1.2218056500000785, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_esm_with_not_existing_sqs_queue": 1.1958207269999548, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_failing_lambda_retries_after_visibility_timeout": 18.671009881999908, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_fifo_message_group_parallelism": 63.49843166200003, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_message_body_and_attributes_passed_correctly": 4.152928347999932, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_redrive_policy_with_failing_lambda": 16.86877668599982, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures": 0.003110908000053314, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_empty_json_batch_succeeds": 9.602823443999796, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_invalid_result_json_batch_fails": 16.96466153900019, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_on_lambda_error": 10.372471217000111, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_sqs_queue_as_lambda_dead_letter_queue": 6.252290095000035, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaAliases::test_alias_routingconfig": 3.1730388249998214, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaAliases::test_lambda_alias_moving": 3.389555078000285, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_assume_role[1]": 1.6808617039998808, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_assume_role[2]": 1.723265531999914, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_function_state": 1.2316579450000518, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_different_iam_keys_environment": 3.7469249169998875, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_large_response": 1.6334867709999799, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_too_large_response": 1.8499307699999008, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_too_large_response_but_with_custom_limit": 1.5865839640000559, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_large_payloads": 1.8303828909999993, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_ignore_architecture": 1.5328259970001454, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_cache_local[nodejs]": 7.684209659000089, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_cache_local[python]": 1.6480342039999414, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_host_prefix_api_operation": 9.84963017999985, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_init_environment": 3.676184160000048, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_no_timeout": 3.6246289089999664, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_timed_out_environment_reuse": 0.0028428949999579345, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_with_timeout": 3.594745670999828, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_mixed_architecture": 0.0027579460002016276, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_introspection_arm": 0.0028347669999675418, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_introspection_x86": 1.8280395420001696, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_ulimits": 1.6121395759998904, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaCleanup::test_delete_lambda_during_sync_invoke": 0.0017551639994053403, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaCleanup::test_recreate_function": 3.3897109480003564, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_block": 12.510622922000039, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_crud": 1.2318670630006636, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_update": 1.3869260860005852, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_provisioned_concurrency_moves_with_alias": 0.002844558000560937, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_provisioned_concurrency_scheduling": 8.510932137999589, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_provisioned_concurrency": 2.8972144579997803, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_provisioned_concurrency_on_alias": 2.936844003999795, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_concurrency": 14.937752569000168, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_concurrency_async_queue": 3.919685405000564, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_provisioned_overlap": 12.257354295999903, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_handler_error": 1.5851359799999045, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_handler_exit": 0.0024207700000715704, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_invoke_payload_encoding_error[body-n\\x87r\\x9e\\xe9\\xb5\\xd7I\\xee\\x9bmt]": 1.3601066559999708, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_invoke_payload_encoding_error[message-\\x99\\xeb,j\\x07\\xa1zYh]": 1.3560118329992292, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_error": 7.680137858999842, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_exit": 0.0017815039998367865, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_exit_segfault": 0.001689740999836431, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_startup_error": 2.078147134000119, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_startup_timeout": 42.21676927300041, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_wrapper_not_found": 0.0022428079998917383, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_dry_run[nodejs16.x]": 0.0025751830000899645, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_dry_run[python3.10]": 0.0023063769999680517, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event[nodejs16.x]": 2.2744897260001835, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event[python3.10]": 2.279039306000186, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event_error": 0.0023121399999581627, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[nodejs-Event]": 2.2860843379999096, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[nodejs-RequestResponse]": 8.68022176699992, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[python-Event]": 2.280357951999804, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[python-RequestResponse]": 2.5905306349995953, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_request_response[nodejs16.x]": 1.5968785639997805, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_request_response[python3.10]": 1.5871303079998142, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_logs[nodejs16.x]": 15.741419938000035, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_logs[python3.10]": 7.77611514299997, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_qualifier": 1.8154841760001545, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invoke_exceptions": 0.11245390100043551, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_lambda_with_context": 0.0033356610001646914, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_upload_lambda_from_s3": 2.1800418549998994, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_delete_function": 1.1517036850004843, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_alias": 1.1828112369998962, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_concurrency": 1.1366513269999814, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_invocation": 1.521739911000168, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_tags": 1.167680384999585, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_function": 1.1475878969995392, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_function_configuration": 1.134314247000475, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_lambda_layer": 0.10747023400017497, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_list_versions_by_function": 1.136862062000091, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_publish_version": 1.181476793999991, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaPermissions::test_lambda_permission_url_invocation": 0.002786019000268425, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_update_function_url_config": 1.4784457299999758, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_http_fixture_default": 2.094357168999977, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_http_fixture_trim_x_headers": 1.966418984999791, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[BUFFERED]": 1.9120143480001843, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[None]": 1.930916618999845, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[RESPONSE_STREAM]": 0.012038217000053919, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_form_payload": 1.9208320339998863, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_headers_and_status": 1.5827251089999663, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invalid_invoke_mode": 1.473137238999925, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[boolean]": 1.8244553830002133, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[dict]": 1.8263942199998837, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[float]": 1.816632462999678, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[http-response-json]": 1.8255985230002807, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[http-response]": 1.8341550269999516, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[integer]": 1.8118135639999764, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[list-mixed]": 1.8275532070001645, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[string]": 1.8134462679997796, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_custom_id": 1.547590350000064, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_custom_id_aliased": 1.5429372549999698, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_exception": 1.8369036990002314, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_non_existing_url": 1.057647807999956, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_persists_after_alias_delete": 3.8815891470003407, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_async_invoke_queue_upon_function_update": 93.75921184800018, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_function_update_during_invoke": 0.0036731340001097124, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_lambda_handler_update": 2.2249111759997504, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_lambda_versions_with_code_changes": 5.514880239000377, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_async_invoke_with_retry": 11.26577465299988, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_format": 0.025870402999771613, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_invoke": 3.659220598000047, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_invoke_url": 3.6154512880002585, + "tests/aws/services/lambda_/test_lambda_api.py::TestCodeSigningConfig::test_code_signing_not_found_excs": 1.3248440430000414, + "tests/aws/services/lambda_/test_lambda_api.py::TestCodeSigningConfig::test_function_code_signing_config": 1.2859016789999487, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings": 0.0910165049999705, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings_total_code_size": 1.4427104820000523, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings_total_code_size_config_update": 7.346102621000057, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_alias_lifecycle": 1.531063056999983, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_alias_naming": 1.702489392000075, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_non_existent_alias_deletion": 1.1994329019999554, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_non_existent_alias_update": 1.2087753720000478, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_notfound_and_invalid_routingconfigs": 2.425216807999959, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventInvokeConfig::test_lambda_eventinvokeconfig_exceptions": 2.8103612960000532, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventInvokeConfig::test_lambda_eventinvokeconfig_lifecycle": 1.3523335519999478, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_filter_criteria_validation": 3.543260241999974, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_self_managed": 0.001998851000053037, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_validation": 3.4403314090000094, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_validation_kinesis": 1.9260186640000256, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_exceptions": 0.15615853399998514, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_lifecycle": 8.076686644999995, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_lifecycle_delete_function": 6.068899886999986, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_function_name_variations": 16.055669911000052, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_create_lambda_exceptions": 0.17116884100002494, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_delete_on_nonexisting_version": 1.2380888339999956, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_arns": 2.592323059999984, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_lifecycle": 17.07096592800002, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-create_function]": 0.10607619399999635, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-delete_function]": 0.09170757399996887, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-get_function]": 0.6635176260000435, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-invoke]": 0.09174749099994983, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-create_function]": 0.10467427399998996, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-delete_function]": 0.09164685200002509, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-get_function]": 0.09082329699995739, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-invoke]": 0.09355379199993763, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-create_function]": 0.1052476010000305, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-delete_function]": 0.09066651700001671, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-get_function]": 0.09013190400003168, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-invoke]": 0.09163795700001742, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-create_function]": 0.10284151299998712, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-delete_function]": 0.09030901200003427, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-get_function]": 0.09189935099999502, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-invoke]": 0.008838929000006601, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-create_function]": 0.1057286190000184, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-delete_function]": 0.09438250299996298, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-get_function]": 0.09094487799998774, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-invoke]": 0.09209692999999675, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-create_function]": 0.008108448000001545, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-delete_function]": 0.08941914600003997, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-get_function]": 0.09045368400001053, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-invoke]": 0.008877950999988116, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-create_function]": 0.10341382999999382, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-delete_function]": 0.09283747900002481, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-get_function]": 0.09005106600002932, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-invoke]": 0.08967896000001474, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-create_function]": 0.10639932400002294, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-delete_function]": 0.09407763999999474, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-get_function]": 0.09480182799995873, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-invoke]": 0.09320125499999676, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-create_function]": 0.10550488100000166, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-delete_function]": 0.09174484299998653, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-get_function]": 0.09240018400001304, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-invoke]": 0.09217551599999751, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-create_function]": 0.10418250800000806, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-delete_function]": 0.09240945199999828, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-get_function]": 0.09086244200000237, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-invoke]": 0.09106459999998151, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-create_function]": 0.10340884800001504, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-delete_function]": 0.09056178299999829, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-get_function]": 0.09090549199999032, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-invoke]": 0.08798102899999094, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-create_function]": 0.10416694799999959, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-delete_function]": 0.009126463000058038, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-get_function]": 0.09042750399993338, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-invoke]": 0.0899811300000124, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-create_function]": 0.1079632160000017, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-delete_function]": 0.093636575000005, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-get_function]": 0.09056257600002482, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-invoke]": 0.09602341400000114, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-create_function]": 0.10478527300000451, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-delete_function]": 0.08995763800001555, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-get_function]": 0.09097450099997673, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-invoke]": 0.09144750100000465, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-create_function]": 0.10607784899997341, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-delete_function]": 0.09822603599997137, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-get_function]": 0.0929182080000146, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-invoke]": 0.10925572600001487, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-create_function]": 0.10475273199998014, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-delete_function]": 0.09132657499995389, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-get_function]": 0.09114438800000357, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-invoke]": 0.09240598499997077, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-create_function]": 0.1050961019999761, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-delete_function]": 0.08926702299999079, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-get_function]": 0.0904325039999776, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-invoke]": 0.09163165300000742, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-create_function]": 0.10561663799998655, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-delete_function]": 0.09154435199999966, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-get_function]": 0.09274387399997863, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-invoke]": 0.09260056800002303, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[delete_function]": 1.2143496360000086, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function]": 1.2299110940000162, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_code_signing_config]": 1.2165081950000172, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_concurrency]": 1.2192828569999676, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_configuration]": 1.2357642310000188, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_event_invoke_config]": 1.2334706489999974, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_url_config]": 1.2263347210000006, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[invoke]": 1.2182909210000332, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_invoke": 0.09083194000001527, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_vpc_config_security_group": 0.0017375329999822497, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_vpc_config_subnet": 0.4179838289999509, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_code_location_s3": 1.5160923459999935, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_code_location_zipfile": 1.4127229810000017, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_concurrent_code_updates": 2.3035886030000086, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_concurrent_config_updates": 2.2844414750000226, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_list_functions": 2.5023929350000174, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[delete_function]": 0.09268410699999663, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function]": 0.09218001800002185, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_code_signing_config]": 0.09373805000001312, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_concurrency]": 0.09258512100004168, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_configuration]": 0.0919927639999969, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_event_invoke_config]": 0.09304169899999692, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_url_config]": 0.0947174580000194, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function]": 1.7942339789999835, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function_configuration]": 1.2113523499999985, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function_event_invoke_config]": 1.2166737310000144, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[delete_function]": 0.10309209899997995, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[get_function]": 0.10230458300000578, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[get_function_configuration]": 0.10183342499999526, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_redundant_updates": 1.3633443020000016, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_update_lambda_exceptions": 1.2209352170000045, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_vpc_config": 2.1232066660000157, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_and_image_config_crud": 0.8664933720000079, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_crud": 5.75704882200003, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_versions": 1.9504911709999817, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_zip_file_to_image": 1.5499271729999577, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_compatibilities[runtimes0]": 0.13422012399996675, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_compatibilities[runtimes1]": 0.1315451780000103, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_deterministic_version": 0.06222224999999071, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_exceptions": 0.30969639399995685, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_function_exceptions": 17.504394522999974, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_function_quota_exception": 16.393886264999935, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_lifecycle": 1.4697190080000269, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_policy_exceptions": 0.23918489999999792, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_policy_lifecycle": 0.17744573599998148, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_s3_content": 0.21223691000000144, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_add_lambda_permission_aws": 1.2281434420000323, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_add_lambda_permission_fields": 1.2896680900000206, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_create_multiple_lambda_permissions": 1.224903295000047, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_lambda_permission_fn_versioning": 1.3838460799999552, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_permission_exceptions": 1.3612876249999886, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_remove_multi_permissions": 1.2765855080000392, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_lambda_provisioned_lifecycle": 2.4525274289999857, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_provisioned_concurrency_exceptions": 1.385112499999991, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_provisioned_concurrency_limits": 1.2698044900000127, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_allow": 1.233173767999972, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_default_terminate": 1.2069979709999927, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_invalid_value": 1.2065661640000087, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency": 1.8892534199999886, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency_exceptions": 1.2358771770000772, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency_limits": 1.220562405999999, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_basic": 13.708655550999993, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_permissions": 1.2643269049999617, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_version_and_alias": 1.3708985120000534, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_lambda_envvars_near_limit_succeeds": 1.2934690420000265, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_environment_fails_multiple_keys": 16.211619877999965, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_environment_variables_fails": 16.215384775999894, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_lambda": 12.77011238099999, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_request_create_lambda": 1.996707391999962, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_unzipped_lambda": 4.724087802000042, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_zipped_create_lambda": 1.621360535000008, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_exceptions": 0.10609610999995311, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[dotnet8]": 4.301395871999944, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java11]": 3.3049281660000247, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java17]": 3.2946198550000076, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java21]": 3.2985338720000072, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[python3.12]": 1.2522969709999643, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[python3.13]": 7.347160301000088, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[dotnet8]": 1.2289751259999662, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java11]": 1.2535956019999617, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java17]": 1.239825854000003, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java21]": 1.232484688999989, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[python3.12]": 1.2206994539999982, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[python3.13]": 1.219443704000014, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_create_tag_on_esm_create": 1.5140729230000147, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_create_tag_on_fn_create": 1.228791855000054, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_exceptions[event_source_mapping]": 0.12381458199996587, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_exceptions[lambda_function]": 0.12499760600002219, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle[event_source_mapping]": 1.422096483999951, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle[lambda_function]": 1.298512072000051, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_nonexisting_resource": 1.2993060119999882, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_exceptions": 1.3051099389999763, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_lifecycle": 1.3788510119999842, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_limits": 1.3886170369999604, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_versions": 1.265109343000006, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_create_url_config_custom_id_tag": 1.1355670440000267, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_create_url_config_custom_id_tag_alias": 3.4029506680000736, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_create_url_config_custom_id_tag_invalid_id": 1.1297659330000442, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_deletion_without_qualifier": 1.37914562200001, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_exceptions": 7.611816517999955, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_lifecycle": 1.3384153280000533, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_list_paging": 1.3850888060001125, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_version_on_create": 1.2833536359999869, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_with_update": 1.4115198009999688, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_with_wrong_sha256": 1.2545855300000142, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_version_lifecycle": 2.484034909999991, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_advanced_logging_configuration_format_switch": 1.6071665009999379, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_advanced_logging_configuration": 1.278103082999678, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config0]": 33.94296191899997, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config1]": 1.4748748920000025, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config2]": 1.4453500559999668, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config3]": 2.492105589999994, + "tests/aws/services/lambda_/test_lambda_api.py::TestPartialARNMatching::test_cross_region_arn_function_access": 1.139468200000465, + "tests/aws/services/lambda_/test_lambda_api.py::TestPartialARNMatching::test_update_function_configuration_full_arn": 1.2428232330003084, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_disabled": 15.204814091999651, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[dotnetcore3.1]": 0.10922257999982321, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[go1.x]": 0.10509972799991374, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[java8]": 0.10707773800004361, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[nodejs12.x]": 0.10794945199995709, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[nodejs14.x]": 0.10670257100036906, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[provided]": 0.10294355799987898, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[python3.7]": 1.1508106580004096, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[ruby2.7]": 0.10469957299983434, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[dotnet6]": 1.9592016710000735, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[dotnet8]": 1.899118367999904, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java11]": 4.946052971999961, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java17]": 4.363307842999916, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java21]": 4.176003104000074, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java8.al2]": 5.874369872999864, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs16.x]": 1.7829422719999002, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs18.x]": 1.7394704750000756, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs20.x]": 1.72124726800007, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs22.x]": 1.682612797000047, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.10]": 1.7387179920000335, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.11]": 1.7491155709998338, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.12]": 1.8116745430000947, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.13]": 1.7402287500000284, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.8]": 1.7426490690000946, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.9]": 1.7346543499999143, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.2]": 2.4150127039999916, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.3]": 2.302569482000081, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.4]": 2.09285273099988, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[dotnet6]": 3.556518664999885, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[dotnet8]": 2.569433885999956, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java11]": 2.4770941459998994, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java17]": 2.4379554160000225, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java21]": 2.4606446770000048, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java8.al2]": 4.498446123000008, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs16.x]": 9.58646904300008, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs18.x]": 2.5105010989999528, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs20.x]": 2.415828357000123, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs22.x]": 7.45901530000009, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[provided.al2023]": 3.261089681000044, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[provided.al2]": 5.124950828999999, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.10]": 7.628996817000029, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.11]": 2.5231310189999476, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.12]": 3.3646108000000368, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.13]": 2.605426027999897, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.8]": 2.7307419060000484, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.9]": 7.652198057999954, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.2]": 8.558754177999958, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.3]": 9.610121607999986, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.4]": 9.623663167000018, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[dotnet6]": 3.718319668000049, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[dotnet8]": 3.7301175530001274, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java11]": 4.8510681330000125, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java17]": 3.66357911099999, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java21]": 3.691383351000127, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java8.al2]": 3.9038316710000345, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs16.x]": 3.555306981000058, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs18.x]": 3.5876227229999813, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs20.x]": 3.5694588220000014, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs22.x]": 3.548932771000068, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[provided.al2023]": 4.690739939999958, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[provided.al2]": 3.608797967999976, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.10]": 3.526540546000092, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.11]": 3.5657367549998753, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.12]": 4.58616891500003, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.13]": 3.5678040840001586, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.8]": 3.542580509000004, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.9]": 3.563971250999998, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.2]": 3.6153081290000273, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.3]": 3.6907235470000614, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.4]": 3.646961569000041, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[dotnet6]": 1.812769954999908, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[dotnet8]": 1.8198864690000391, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java11]": 1.9406517560000793, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java17]": 1.833452467999905, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java21]": 1.8572878669999682, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java8.al2]": 2.104230029000064, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs16.x]": 1.7113875879999796, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs18.x]": 1.7553012099998568, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs20.x]": 1.6992024609999135, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs22.x]": 1.7060004229999777, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.10]": 1.701958970000078, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.11]": 1.7037847970000257, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.12]": 1.6940829820000545, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.13]": 1.6966000300000132, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.8]": 2.7384168849999924, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.9]": 1.6776964240001462, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.2]": 1.7668514010000536, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.3]": 1.7738371529999313, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.4]": 1.769729454999947, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[dotnet6]": 1.8527065499998798, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[dotnet8]": 1.839407309000194, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java11]": 1.9961731849998614, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java17]": 1.8712743390000242, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java21]": 1.8808367500000713, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java8.al2]": 2.1239435859999958, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs16.x]": 1.7239446369999314, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs18.x]": 1.7487895199999457, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs20.x]": 1.7156638459998703, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs22.x]": 1.7452296549998891, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[provided.al2023]": 1.7391481500001191, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[provided.al2]": 1.7503012290000015, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.10]": 1.7146287699997629, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.11]": 1.7497371279998788, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.12]": 1.7230264360000547, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.13]": 1.723328993000223, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.8]": 1.7193251679999548, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.9]": 1.7220050959998616, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.2]": 1.7799142559999837, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.3]": 1.7659715199999937, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.4]": 1.7802337749999424, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDLQ::test_dead_letter_queue": 20.243131066999922, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationEventbridge::test_invoke_lambda_eventbridge": 16.10188266399996, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_assess_lambda_destination_invocation[payload0]": 1.8613699219999944, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_assess_lambda_destination_invocation[payload1]": 1.8772197720001031, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_lambda_destination_default_retries": 21.398218818000032, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_maxeventage": 63.85512571300001, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_retries": 22.50119047499993, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestDockerFlags::test_additional_docker_flags": 1.5492804409999508, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestDockerFlags::test_lambda_docker_networks": 4.936304337000024, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestHotReloading::test_hot_reloading[nodejs20.x]": 3.41296803299997, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestHotReloading::test_hot_reloading[python3.12]": 4.372210234000022, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestHotReloading::test_hot_reloading_environment_placeholder": 0.45191760300008355, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestHotReloading::test_hot_reloading_error_path_not_absolute": 0.02646276100006162, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestHotReloading::test_hot_reloading_publish_version": 1.113749372000143, + "tests/aws/services/lambda_/test_lambda_developer_tools.py::TestLambdaDNS::test_lambda_localhost_localstack_cloud_connectivity": 1.5720982120000144, + "tests/aws/services/lambda_/test_lambda_integration_xray.py::test_traceid_outside_handler[Active]": 2.5744458500000746, + "tests/aws/services/lambda_/test_lambda_integration_xray.py::test_traceid_outside_handler[PassThrough]": 2.5764993139999888, + "tests/aws/services/lambda_/test_lambda_integration_xray.py::test_xray_trace_propagation": 1.5462961090000817, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestCloudwatchLogs::test_multi_line_prints": 3.624001548999786, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_manual_endpoint_injection[provided.al2023]": 1.8818634790000033, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_manual_endpoint_injection[provided.al2]": 1.8787955039997541, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_uncaught_exception_invoke[provided.al2023]": 1.9628375559998403, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_uncaught_exception_invoke[provided.al2]": 1.9695461410001371, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom-INTERFACE]": 3.0325628439998127, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequest-INTERFACE]": 3.027488008000091, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequestCustom-CUSTOM]": 3.0723002060000226, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_lambda_subscribe_sns_topic": 8.85726094599977, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_runtime_with_lib": 5.623548458999949, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java11]": 2.67506008700002, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java17]": 2.574775374000069, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java21]": 2.7595085299999482, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java8.al2]": 2.800040777999925, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java11]": 1.7221893089999867, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java17]": 1.698258729000031, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java21]": 1.7587888389999762, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java8.al2]": 1.7417926940000825, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs16.x]": 4.712040349999938, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs18.x]": 4.694500220999998, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs20.x]": 4.676033183999948, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs22.x]": 4.686106505999987, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.10]": 1.6478566229995977, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.11]": 1.7794072119997963, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.12]": 1.6553480690001834, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.13]": 1.6518568010001218, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.8]": 1.6778594499999144, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.9]": 1.6703607810002268, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.10]": 1.5447454499997093, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.11]": 1.5190538190001917, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.12]": 1.5483294129999194, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.13]": 1.5491482299999007, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.8]": 1.579595849999805, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.9]": 1.5444160470001407, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_create_and_delete_log_group": 0.2093402220000371, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_create_and_delete_log_stream": 0.49619339500031856, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_delivery_logs_for_sns": 1.0934741949999989, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_filter_log_events_response_header": 0.05597701700003199, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_list_tags_log_group": 0.22845650900012515, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_metric_filters": 0.0019960860001901892, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_put_events_multi_bytes_msg": 0.058269946999871536, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_put_subscription_filter_firehose": 0.5033557619999556, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_put_subscription_filter_kinesis": 2.390128225000126, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_put_subscription_filter_lambda": 2.9595373019999442, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_resource_does_not_exist": 0.043008572999951866, + "tests/aws/services/opensearch/test_opensearch.py::TestCustomBackendManager::test_custom_backend": 0.14299478299994917, + "tests/aws/services/opensearch/test_opensearch.py::TestCustomBackendManager::test_custom_backend_with_custom_endpoint": 0.16543726200006859, + "tests/aws/services/opensearch/test_opensearch.py::TestEdgeProxiedOpensearchCluster::test_custom_endpoint": 10.964950407999822, + "tests/aws/services/opensearch/test_opensearch.py::TestEdgeProxiedOpensearchCluster::test_custom_endpoint_disabled": 10.431276466999861, + "tests/aws/services/opensearch/test_opensearch.py::TestEdgeProxiedOpensearchCluster::test_route_through_edge": 10.351477474000149, + "tests/aws/services/opensearch/test_opensearch.py::TestMultiClusterManager::test_multi_cluster": 17.41935173500019, + "tests/aws/services/opensearch/test_opensearch.py::TestMultiplexingClusterManager::test_multiplexing_cluster": 10.712634626000181, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_cloudformation_deployment": 12.245869679999942, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_create_domain_with_invalid_custom_endpoint": 0.020952545000227474, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_create_domain_with_invalid_name": 0.027585891999933665, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_create_existing_domain_causes_exception": 10.95183583700009, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_create_indices": 12.142682875999753, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_describe_domains": 10.50056709599994, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_domain_lifecycle": 13.691449409000143, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_domain_version": 10.52693980700019, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_endpoint_strategy_path": 10.461875981000276, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_endpoint_strategy_port": 9.890676918000054, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_exception_header_field": 0.012844930000255772, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_get_compatible_version_for_domain": 9.417730744999972, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_get_compatible_versions": 0.02411261999986891, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_get_document": 10.956192454000075, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_gzip_responses": 11.11908126000003, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_list_versions": 0.10235233499997776, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_search": 11.141928919999827, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_security_plugin": 16.037830759999906, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_sql_plugin": 15.726944203999892, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_update_domain_config": 10.524298520999764, + "tests/aws/services/opensearch/test_opensearch.py::TestSingletonClusterManager::test_endpoint_strategy_port_singleton_cluster": 9.791047596999988, + "tests/aws/services/redshift/test_redshift.py::TestRedshift::test_cluster_security_groups": 0.03551398500007963, + "tests/aws/services/redshift/test_redshift.py::TestRedshift::test_create_clusters": 0.16633352300027582, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_cloudformation_query": 0.0016760129999511264, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_create_group": 0.42543509899996934, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_groups_different_region": 0.0017822209999849292, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_groups_tag_query": 0.0018122259998563095, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_type_filters": 0.0018035399998552748, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_search_resources": 0.0016781360000095447, + "tests/aws/services/resourcegroupstaggingapi/test_rgsa.py::TestRGSAIntegrations::test_get_resources": 0.5062781630001609, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_associate_vpc_with_hosted_zone": 0.47818312600020363, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_hosted_zone": 0.6217465839999932, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_hosted_zone_in_non_existent_vpc": 0.18971460699981435, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_private_hosted_zone": 1.7543981000001168, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_crud_health_check": 0.15641552700003558, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_reusable_delegation_sets": 0.15347252900005515, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_associate_and_disassociate_resolver_rule": 0.5017922620002082, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_endpoint[INBOUND-5]": 0.3507684099997732, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_endpoint[OUTBOUND-10]": 0.2979596430000129, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_query_log_config": 0.3172143340000275, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_rule": 0.40107656600002883, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_rule_with_invalid_direction": 0.3049212399998851, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_endpoint": 0.09031297299998187, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_query_log_config": 0.16013734800003476, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_rule": 0.09132384299982732, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_resolver_endpoint": 0.3063655109999672, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_disassociate_non_existent_association": 0.09003192300019691, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_domain_lists": 0.19216798200000085, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules": 0.3582587490000151, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules_for_empty_rule_group": 0.10574194199989506, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules_for_missing_rule_group": 0.1586205730002348, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_multipe_create_resolver_rule": 0.4272995470000751, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_multiple_create_resolver_endpoint_with_same_req_id": 0.3024692070000583, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_route53resolver_bad_create_endpoint_security_groups": 0.20258284600004117, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_update_resolver_endpoint": 0.32076443400001153, + "tests/aws/services/s3/test_s3.py::TestS3::test_access_bucket_different_region": 0.00197315500008699, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_availability": 0.03372516799981895, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_does_not_exist": 0.4623202800000854, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_exists": 0.25396079799998006, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_name_with_dots": 0.5805706840001221, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_operation_between_regions": 0.47893485800000235, + "tests/aws/services/s3/test_s3.py::TestS3::test_complete_multipart_parts_order": 0.49265821099993445, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_in_place_with_bucket_encryption": 0.1425520850000339, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_kms": 0.6988784260001921, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_special_character": 0.663166062999835, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_special_character_plus_for_space": 0.09724246000018866, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_head_bucket": 0.6611636489999455, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_via_host_name": 0.040557594000119934, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_with_existing_name": 0.44061686200029726, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_no_such_bucket": 0.01961162600014177, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy": 0.10069347600028777, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy_expected_bucket_owner": 0.1094853189999867, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_with_content": 0.7423675210000056, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_keys_in_versioned_bucket": 0.5462430949999089, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys": 0.086179201999812, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys_in_non_existing_bucket": 0.024269766999850617, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys_quiet": 0.08039799000016501, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_object_tagging": 0.11380159099985576, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_objects_encoding": 0.12062863000005564, + "tests/aws/services/s3/test_s3.py::TestS3::test_different_location_constraint": 0.6148220570000831, + "tests/aws/services/s3/test_s3.py::TestS3::test_download_fileobj_multiple_range_requests": 1.1260778729997583, + "tests/aws/services/s3/test_s3.py::TestS3::test_empty_bucket_fixture": 0.16037406600003123, + "tests/aws/services/s3/test_s3.py::TestS3::test_etag_on_get_object_call": 0.47629589899975144, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_notification_configuration_no_such_bucket": 0.019710392999968462, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy": 0.12410200700014684, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000000000020]": 0.06696469300004537, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000]": 0.0676245930001187, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[aa000000000$]": 0.06933318099959251, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[abcd]": 0.06929101999980958, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_versioning_order": 0.5435158000000229, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_after_deleted_in_versioned_bucket": 0.12014189200021974, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes": 0.3204719489999661, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes_versioned": 0.5527548090001346, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes_with_space": 0.0997401950000949, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_content_length_with_virtual_host[False]": 0.10040963499977806, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_content_length_with_virtual_host[True]": 0.10121348900042904, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_no_such_bucket": 0.0215344539999478, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part": 0.24432591999993747, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part_checksum[COMPOSITE]": 0.1283157430002575, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part_checksum[FULL_OBJECT]": 0.13171113299972603, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_with_anon_credentials": 0.5088541049999549, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_range_object_headers": 0.09785267100005512, + "tests/aws/services/s3/test_s3.py::TestS3::test_head_object_fields": 0.10052647200018328, + "tests/aws/services/s3/test_s3.py::TestS3::test_invalid_range_error": 0.09207504699998026, + "tests/aws/services/s3/test_s3.py::TestS3::test_metadata_header_character_decoding": 0.45979596900019715, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_and_list_parts": 0.18372949900003732, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_complete_multipart_too_small": 0.105998050999915, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_complete_multipart_wrong_part": 0.09781825100003516, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_copy_object_etag": 0.13706869999987248, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_no_such_upload": 0.08683782000002793, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_overwrite_key": 0.12511915000004592, + "tests/aws/services/s3/test_s3.py::TestS3::test_object_with_slashes_in_key[False]": 0.18616027999996732, + "tests/aws/services/s3/test_s3.py::TestS3::test_object_with_slashes_in_key[True]": 0.19385757999998532, + "tests/aws/services/s3/test_s3.py::TestS3::test_precondition_failed_error": 0.10293714499994167, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_content_language_disposition": 0.9427751069999886, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_hash_prefix": 0.4541095059998952, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_utf8_key": 0.46126005400014947, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_inventory_config_order": 0.16159274699975867, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy": 0.092179490000035, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_expected_bucket_owner": 1.3950950420000936, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000000000020]": 0.0684062149998681, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000]": 0.06977988200014806, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[aa000000000$]": 0.06607288000009248, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[abcd]": 0.06742357799998899, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_single_character_trailing_slash": 0.1532584480000878, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[a/%F0%9F%98%80/]": 0.4744005269999434, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[file%2Fname]": 0.4674334710000494, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test key//]": 0.4632082770001489, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test key/]": 0.4743285330000617, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%123/]": 0.46959542699983103, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%123]": 0.4782150339999589, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%percent]": 0.4759737120002683, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test@key/]": 0.47201639499985504, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_acl_on_delete_marker": 0.5632814609998604, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_chunked_checksum": 0.10131459999979597, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_chunked_content_encoding": 0.10397253600012846, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_chunked_newlines": 0.09010460799981956, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_chunked_newlines_no_sig": 0.08548940700006824, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_chunked_newlines_no_sig_empty_body": 0.08991244899993944, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_chunked_newlines_with_trailing_checksum": 0.10859601499987548, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[DEEP_ARCHIVE-False]": 0.10350070200024675, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[GLACIER-False]": 0.10688397300009456, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[GLACIER_IR-True]": 0.10397368400026608, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[INTELLIGENT_TIERING-True]": 0.10497393600007854, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[ONEZONE_IA-True]": 0.10440879899988431, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[REDUCED_REDUNDANCY-True]": 0.10434775400017315, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[STANDARD-True]": 0.10438086200019825, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[STANDARD_IA-True]": 0.10487764499998775, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class_outposts": 0.08708290399999896, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_tagging_empty_list": 0.12534269499997208, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_with_md5_and_chunk_signature": 0.0875544689997696, + "tests/aws/services/s3/test_s3.py::TestS3::test_putobject_with_multiple_keys": 0.46337015699987205, + "tests/aws/services/s3/test_s3.py::TestS3::test_range_header_body_length": 0.11301083399985146, + "tests/aws/services/s3/test_s3.py::TestS3::test_range_key_not_exists": 0.06778927400000612, + "tests/aws/services/s3/test_s3.py::TestS3::test_region_header_exists_outside_us_east_1": 0.5587312520001433, + "tests/aws/services/s3/test_s3.py::TestS3::test_response_structure": 0.16935184000021763, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_analytics_configurations": 0.2299701679996815, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_objects": 0.5159335739999733, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_objects_using_requests_with_acl": 0.0019586980001804477, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_public_objects_using_requests": 0.4941589100001238, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_bucket_acl": 1.3570944759999293, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_bucket_acl_exceptions": 0.26492087900032857, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_content_type_and_metadata": 0.5177701119998801, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_metadata_directive_copy": 0.48290225899995676, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_metadata_replace": 0.48337952499991843, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place": 0.5470693109998592, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_metadata_directive": 0.5677437270003338, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_storage_class": 0.4938991559999977, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_suspended_only": 0.6019478410003103, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_versioned": 0.6336351559998548, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_website_redirect_location": 0.4808714890000374, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_with_encryption": 0.7918998130001, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_preconditions": 3.5386904110000614, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_storage_class": 0.5078367099999923, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC32C]": 0.4979822300001615, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC32]": 0.4912876190001043, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC64NVME]": 0.4952810469999349, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[SHA1]": 0.49826639499997327, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[SHA256]": 0.49278280899989113, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC32C]": 0.5029639230001521, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC32]": 0.5056044279997423, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC64NVME]": 0.5136505590000979, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[SHA1]": 0.5004996220004614, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[SHA256]": 0.5039495170003647, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_wrong_format": 0.4283393179998711, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[COPY]": 0.5040150139998332, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[None]": 0.5067537710001488, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[REPLACE]": 0.5023609609997948, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[COPY]": 0.5988136040000427, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[None]": 0.603453060999982, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[REPLACE]": 0.599321836999934, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_delete_object_with_version_id": 0.5217531939999844, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_delete_objects_trailing_slash": 0.07734378200007086, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_download_object_with_lambda": 4.256885804999683, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_header_overrides": 0.13046132199997373, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_headers": 0.16092970299996523, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_preconditions[get_object]": 3.559920361000195, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_preconditions[head_object]": 3.5600981710001633, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_hostname_with_subdomain": 0.02023860899976171, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_intelligent_tier_config": 0.16747719499994673, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_invalid_content_md5": 24.373805396999842, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_inventory_report_crud": 0.17200360399988313, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_lambda_integration": 11.63584078000008, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_multipart_upload_acls": 0.20544137900014903, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_multipart_upload_sse": 0.20587831300008474, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_acl": 0.18171623100010947, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_acl_exceptions": 0.2409344909997344, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_expiry": 3.5639310170001863, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_inventory_report_exceptions": 0.1615676229998826, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_more_than_1000_items": 14.565878652000265, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_object_versioned": 0.6609418120001465, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_raw_request_routing": 0.11487258400006795, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_request_payer": 0.08942348599998695, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_request_payer_exceptions": 0.08705117699992115, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_bucket_key_default": 0.23803369300026134, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_default_kms_key": 0.0019469159999516705, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_validate_kms_key": 0.2839329689998067, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_validate_kms_key_state": 0.3018501680001009, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_timestamp_precision": 0.11186059200008458, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_upload_download_gzip": 0.09927624499982812, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_uppercase_bucket_name": 0.3946347980001974, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_uppercase_key_names": 0.10620404300016162, + "tests/aws/services/s3/test_s3.py::TestS3::test_set_external_hostname": 0.1404979160001858, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_big_file": 0.614674966000166, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_file_multipart": 0.4839288320001742, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_file_with_xml_preamble": 0.4592400470000939, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_part_chunked_cancelled_valid_etag": 0.11369754499992268, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_part_chunked_newlines_valid_etag": 0.10058801399986805, + "tests/aws/services/s3/test_s3.py::TestS3::test_url_encoded_key[False]": 0.1419779989998915, + "tests/aws/services/s3/test_s3.py::TestS3::test_url_encoded_key[True]": 0.14505885999983548, + "tests/aws/services/s3/test_s3.py::TestS3::test_virtual_host_proxy_does_not_decode_gzip": 0.08702040799994393, + "tests/aws/services/s3/test_s3.py::TestS3::test_virtual_host_proxying_headers": 0.09950163400003476, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_date": 0.08062298699996973, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_object_expiry": 0.12360423899986017, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_object_expiry_versioned": 0.16846179700019093, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_multiple_rules": 0.13078046699979495, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_object_size_rules": 0.12982043600004545, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_tag_rules": 0.20158644499997536, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_delete_bucket_lifecycle_configuration": 0.11799245800011704, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_delete_lifecycle_configuration_on_bucket_deletion": 0.12202781700011656, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_lifecycle_expired_object_delete_marker": 0.11648194200029138, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_object_expiry_after_bucket_lifecycle_configuration": 0.13417481299961764, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_put_bucket_lifecycle_conf_exc": 0.13694396000005327, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_s3_transition_default_minimum_object_size": 0.1299478010000712, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging": 0.161322557999938, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_accept_wrong_grants": 0.14077334499984318, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_cross_locations": 0.17989501100009875, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_wrong_target": 0.12823111799980325, + "tests/aws/services/s3/test_s3.py::TestS3BucketReplication::test_replication_config": 1.8359033250001175, + "tests/aws/services/s3/test_s3.py::TestS3BucketReplication::test_replication_config_without_filter": 0.6505051759997968, + "tests/aws/services/s3/test_s3.py::TestS3DeepArchive::test_s3_get_deep_archive_object_restore": 0.5508051580000028, + "tests/aws/services/s3/test_s3.py::TestS3DeepArchive::test_storage_class_deep_archive": 0.17338402100017447, + "tests/aws/services/s3/test_s3.py::TestS3MultiAccounts::test_cross_account_access": 0.1323644389999572, + "tests/aws/services/s3/test_s3.py::TestS3MultiAccounts::test_cross_account_copy_object": 0.0968086959996981, + "tests/aws/services/s3/test_s3.py::TestS3MultiAccounts::test_shared_bucket_namespace": 0.0724576600000546, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[CRC32C]": 0.5026149279997298, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[CRC32]": 0.5010776960000385, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[SHA1]": 0.5283802360002028, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[SHA256]": 0.5499513750000915, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_default": 0.23165879600014705, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC32C]": 0.5652639659997476, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC32]": 0.58090115899995, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC64NVME]": 0.61629050800002, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object_default": 0.14199672399968222, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC32C]": 0.09737393699970198, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC32]": 0.09684394199985036, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC64NVME]": 0.08106761500016546, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-SHA1]": 0.09791653399997813, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-SHA256]": 0.10076278599990474, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC32C]": 0.07366583499992885, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC32]": 0.07458639599963135, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC64NVME]": 0.07208600299986756, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-SHA1]": 0.07353632400008792, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-SHA256]": 0.071970599999986, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC32C]": 0.07240395500002705, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC32]": 0.07303740699944683, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC64NVME]": 0.07220480699993459, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[SHA1]": 0.07851158700009364, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[SHA256]": 0.0767022900001848, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_parts_checksum_exceptions_composite": 12.80642985999998, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_parts_checksum_exceptions_full_object": 33.148257720000174, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_size_validation": 0.13118641400001252, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC32C]": 6.620719754999982, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC32]": 6.388613734000046, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC64NVME]": 6.345888356999922, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[SHA1]": 4.007398880999972, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[SHA256]": 6.687101071000143, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_copy_checksum[COMPOSITE]": 0.18046534199970665, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_copy_checksum[FULL_OBJECT]": 0.17284081900015735, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_delete_locked_object": 0.13317263899989484, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_get_object_legal_hold": 0.14256791200023144, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_object_legal_hold_exc": 0.17897700100002112, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_object_with_legal_hold": 0.1158486830001948, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_s3_copy_object_legal_hold": 0.5244989570001053, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_s3_legal_hold_lock_versioned": 0.5520742510002492, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_bucket_config_default_retention": 0.14835029800019583, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_object_lock_delete_markers": 0.13188643000012235, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_object_lock_extend_duration": 0.135053811999569, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_copy_object_retention_lock": 0.5054117750000842, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_lock_mode_validation": 0.107428581999784, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention": 6.175300314999959, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention_compliance_mode": 6.1472041960003025, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention_exc": 0.26930742400031704, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_default_checksum": 0.10709031700002924, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_casing[s3]": 0.10529939399998511, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_casing[s3v4]": 0.10577803699993638, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_conditions_validation_eq": 0.34373631999983445, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_conditions_validation_starts_with": 0.2979261619998397, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_validation_size": 0.24154480300012438, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_file_as_string": 0.3515007290000085, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_files": 0.13909012799967968, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_metadata": 0.11692746099970464, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_storage_class": 0.36012375300015265, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[invalid]": 0.18654262899985952, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[list]": 0.17272819900017566, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[notxml]": 0.18366617699985, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[single]": 0.17299679200004903, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_wrong_content_type": 0.15278917399996317, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_expires": 3.153930395999623, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_malformed_policy[s3]": 0.16289918699976624, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_malformed_policy[s3v4]": 0.16386224200027755, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_fields[s3]": 0.1760144830000172, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_fields[s3v4]": 0.17413464399987788, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_signature[s3]": 0.1622722900001463, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_signature[s3v4]": 0.16275654499986558, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_presigned_post_with_different_user_credentials": 0.1964797800001179, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_s3_presigned_post_success_action_redirect": 0.09812805800015667, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_s3_presigned_post_success_action_status_201_response": 0.08644123299973216, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_delete_has_empty_content_length_header": 0.10167214500006594, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_get_object_ignores_request_body": 0.09191854100004093, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_get_request_expires_ignored_if_validation_disabled": 3.1161971999997604, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_head_has_correct_content_length_header": 0.08780549300013263, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_pre_signed_url_forward_slash_bucket": 0.10604770899999494, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_pre_signed_url_if_match": 0.105679173000226, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_pre_signed_url_if_none_match": 0.10367928900018342, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presign_check_signature_validation_for_port_permutation": 0.10720932100025493, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presign_with_additional_query_params": 0.1145536000001357, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_double_encoded_credentials": 0.1775440209999033, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3-False]": 0.22498785400011911, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3-True]": 0.2261991319999197, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3v4-False]": 0.23872102300015285, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3v4-True]": 0.25754940899992107, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3-False]": 2.184290624000141, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3-True]": 2.191994462000139, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3v4-False]": 2.1791915280000467, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3v4-True]": 2.182097841000086, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3-False]": 0.13042441499987945, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3-True]": 0.15132369799994194, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3v4-False]": 0.12425882600018667, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3v4-True]": 0.12749365900026532, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_v4_signed_headers_in_qs": 1.983413371000097, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_v4_x_amz_in_qs": 8.48407282900007, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_with_different_user_credentials": 0.21027165200007403, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_with_session_token": 0.15356245299994953, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object": 0.4607080420003058, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3-False]": 0.09722037299980002, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3-True]": 0.17112357400014844, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3v4-False]": 0.0954565699998966, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3v4-True]": 0.1720241810000971, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3[False]": 0.5738141779997932, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3[True]": 0.6047878110000511, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3v4[False]": 0.5804183309999189, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3v4[True]": 0.5753122500000245, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_copy_md5": 0.11649680199980139, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_case_sensitive_headers": 0.09057117399993331, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_content_type_same_as_upload_and_range": 0.11288940099984757, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_default_content_type": 0.08887177299948235, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_header_overrides[s3]": 0.10305149099986011, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_header_overrides[s3v4]": 0.10333485599994674, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_ignored_special_headers": 0.13232632900007957, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presign_url_encoding[s3]": 0.10285235300011664, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presign_url_encoding[s3v4]": 0.10090957199963668, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presigned_url_expired[s3]": 3.201685520000183, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presigned_url_expired[s3v4]": 3.204823587999954, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_missing_sig_param[s3]": 0.183133386000236, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_missing_sig_param[s3v4]": 0.1955025620000015, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_same_header_and_qs_parameter": 0.19760925999980827, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_with_different_headers[s3]": 1.3245765589999792, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_with_different_headers[s3v4]": 0.22717458900001475, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC32C]": 9.109814508999989, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC32]": 10.052975495999817, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC64NVME]": 12.02609666599983, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[SHA1]": 8.692350379999652, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[SHA256]": 4.503924855999912, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_no_algorithm": 0.11403651099976742, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_no_automatic_sdk_calculation": 0.2547866529998828, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_with_content_encoding": 0.1162493109998195, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC32C]": 0.12463894999996228, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC32]": 0.12991229500016743, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC64NVME]": 0.1231433659997947, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[None]": 0.12419337000005726, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[SHA1]": 0.12668078900014734, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[SHA256]": 0.1262730730002204, + "tests/aws/services/s3/test_s3.py::TestS3Routing::test_access_favicon_via_aws_endpoints[s3.amazonaws.com-False]": 0.10006109999994806, + "tests/aws/services/s3/test_s3.py::TestS3Routing::test_access_favicon_via_aws_endpoints[s3.amazonaws.com-True]": 0.09790990500005137, + "tests/aws/services/s3/test_s3.py::TestS3Routing::test_access_favicon_via_aws_endpoints[s3.us-west-2.amazonaws.com-False]": 0.10607122200008234, + "tests/aws/services/s3/test_s3.py::TestS3Routing::test_access_favicon_via_aws_endpoints[s3.us-west-2.amazonaws.com-True]": 0.10388245300009658, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_copy_object_with_sse_c": 0.23267155000007733, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_multipart_upload_sse_c": 0.4733700520000639, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_multipart_upload_sse_c_validation": 0.1975247729997136, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_object_retrieval_sse_c": 0.2584269160001895, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_default_checksum_with_sse_c": 0.1894062630001372, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_lifecycle_with_sse_c": 0.188436652000064, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_validation_sse_c": 0.22591462299965315, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_sse_c_with_versioning": 0.23512968300019566, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_crud_website_configuration": 0.11060472899998786, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_object_website_redirect_location": 0.276459290000048, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_routing_rules_conditions": 0.5619045869998445, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_routing_rules_empty_replace_prefix": 0.44247989000018606, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_routing_rules_order": 0.2546975669999938, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_routing_rules_redirects": 0.15884029899984853, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_s3_static_website_hosting": 0.5631324440000753, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_s3_static_website_index": 0.14533859099992696, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_validate_website_configuration": 0.20917430299982698, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_404": 0.23761776600008488, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_http_methods": 0.14725610900018182, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_index_lookup": 0.2945937140004844, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_no_such_website": 0.13676470700011123, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_redirect_all": 0.32232925099992826, + "tests/aws/services/s3/test_s3.py::TestS3TerraformRawRequests::test_terraform_request_sequence": 0.05900464700016528, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketAccelerateConfiguration::test_bucket_acceleration_configuration_crud": 0.10540322399992874, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketAccelerateConfiguration::test_bucket_acceleration_configuration_exc": 0.13851688300019305, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketCRUD::test_delete_bucket_with_objects": 0.4535801210001864, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketCRUD::test_delete_versioned_bucket_with_objects": 0.4932700060001025, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_kms": 0.2388688280000224, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_kms_aws_managed_key": 0.2852026420000584, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_s3": 0.10983556899986979, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_default_bucket_encryption": 0.09529618700003084, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_default_bucket_encryption_exc": 0.49727485400012483, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_bucket_tagging_crud": 0.1460439669999687, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_bucket_tagging_exc": 0.09237591300006898, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_crud": 0.16730051600006846, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_exc": 0.22144096100009847, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_versioned": 0.2186370959998385, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tags_delete_or_overwrite_object": 0.14671714800033442, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_put_object_with_tags": 0.21395852599971477, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_tagging_validation": 0.18930809700032114, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketOwnershipControls::test_bucket_ownership_controls_exc": 0.12224592200004736, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketOwnershipControls::test_crud_bucket_ownership_controls": 0.17803875799995694, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketPolicy::test_bucket_policy_crud": 0.1270301729998664, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketPolicy::test_bucket_policy_exc": 0.10410168999987945, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketVersioning::test_bucket_versioning_crud": 0.16540369900008045, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketVersioning::test_object_version_id_format": 0.09930176700004267, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_all_non_express": 0.09405574400011574, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_modified_non_express": 0.09369080900023619, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_non_express": 0.09177899899987096, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_size_non_express": 0.09439786999973876, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_delete_metrics_configuration": 0.09033451999994213, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_delete_metrics_configuration_twice": 0.08557629100005215, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_get_bucket_metrics_configuration": 0.08174477600005048, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_get_bucket_metrics_configuration_not_exist": 0.0731129029998101, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_list_bucket_metrics_configurations": 0.08643900700008089, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_list_bucket_metrics_configurations_paginated": 0.8569166489999134, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_overwrite_bucket_metrics_configuration": 0.16004871900008766, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_put_bucket_metrics_configuration": 0.15436997200004043, + "tests/aws/services/s3/test_s3_api.py::TestS3Multipart::test_upload_part_copy_no_copy_source_range": 0.1962781349998295, + "tests/aws/services/s3/test_s3_api.py::TestS3Multipart::test_upload_part_copy_range": 0.34236564600018937, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object": 0.09988595000004352, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object_on_suspended_bucket": 0.6108128949999809, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object_versioned": 0.5940755259998696, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_objects": 0.09846562500024447, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_objects_versioned": 0.5083191290000286, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_get_object_range": 0.32879147899984673, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_get_object_with_version_unversioned_bucket": 0.4745985639999617, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_list_object_versions_order_unversioned": 0.533842204000166, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_put_object_on_suspended_bucket": 0.6439261220002663, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_delete_object_with_no_locking": 0.11706702700007554, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_disable_versioning_on_locked_bucket": 0.07679679600005329, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_get_object_lock_configuration_exc": 0.07985410700007378, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_get_put_object_lock_configuration": 0.1086205780002274, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_put_object_lock_configuration_exc": 0.12956235400019978, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_put_object_lock_configuration_on_existing_bucket": 0.1253470589999779, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_etag": 0.15538120600012917, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_delete": 0.15204420999998547, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_put": 0.16793554499986385, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_put_identical": 0.1593157020001854, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_none_match_with_delete": 0.1609870260003845, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_none_match_with_put": 0.1158939949998512, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match": 0.13742292599999928, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_and_if_none_match_validation": 0.07533402000012757, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_validation": 0.09496577899994918, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_versioned_bucket": 0.18487065999988772, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match": 0.11959712800012312, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match_validation": 0.09374148400024751, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match_versioned_bucket": 0.16477816199994777, + "tests/aws/services/s3/test_s3_api.py::TestS3PublicAccessBlock::test_crud_public_access_block": 0.11627519800003938, + "tests/aws/services/s3/test_s3_concurrency.py::TestParallelBucketCreation::test_parallel_bucket_creation": 0.4474063399998158, + "tests/aws/services/s3/test_s3_concurrency.py::TestParallelBucketCreation::test_parallel_object_creation_and_listing": 0.3960190000000239, + "tests/aws/services/s3/test_s3_concurrency.py::TestParallelBucketCreation::test_parallel_object_creation_and_read": 1.670120024000198, + "tests/aws/services/s3/test_s3_concurrency.py::TestParallelBucketCreation::test_parallel_object_read_range": 2.711662180999838, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_expose_headers": 0.2759559010000885, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_get_no_config": 0.11957721000021593, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_options_no_config": 0.21138417699989986, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_options_non_existent_bucket": 0.17481359900034477, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_options_non_existent_bucket_ls_allowed": 0.08358171299983042, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_list_buckets": 0.09279736300027253, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_headers": 0.8091445500001555, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_methods": 0.7545037549998597, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_origins": 0.6713996000003135, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_no_config_localstack_allowed": 0.12117535599963958, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_options_fails_partial_origin": 0.46337537699992026, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_options_match_partial_origin": 0.17344863099992835, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_delete_cors": 0.20390555400013, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_get_cors": 0.1831076309999844, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors": 0.17309904399985498, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_default_values": 0.5044573299999229, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_empty_origin": 0.16918629799988594, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_invalid_rules": 0.17182613899967691, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_s3_cors_disabled": 0.10894259599990619, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_by_bucket_region": 0.5934922499998265, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_by_prefix_with_case_sensitivity": 0.5263748169998053, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_when_continuation_token_is_empty": 0.4874251250000725, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_with_continuation_token": 0.5459260639997865, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_with_max_buckets": 0.51605313999994, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multipart_uploads_marker_common_prefixes": 0.5179120589998547, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multiparts_next_marker": 0.6594019139997727, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multiparts_with_prefix_and_delimiter": 0.5231705650001004, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_s3_list_multiparts_timestamp_precision": 0.08256486099980975, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_object_versions_pagination_common_prefixes": 0.6021719129996654, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_markers": 0.7060049270000945, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix": 0.6096113930000229, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix_only_and_pagination": 0.629096724999954, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix_only_and_pagination_many_versions": 1.2326595360000283, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_s3_list_object_versions_timestamp_precision": 0.11446008900020388, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_marker_common_prefixes": 0.5709854959998211, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_next_marker": 0.5387953989998095, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[%2F]": 0.48179683899979864, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[/]": 0.4681397829999696, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[]": 0.4925782140001047, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_s3_list_objects_empty_marker": 0.44353926600001614, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_s3_list_objects_timestamp_precision[ListObjectsV2]": 0.09622208499990847, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_s3_list_objects_timestamp_precision[ListObjects]": 0.09562790399991172, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_continuation_common_prefixes": 0.5468975119999868, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_continuation_start_after": 0.6675455349998174, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_with_prefix": 0.5389973770002143, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_with_prefix_and_delimiter": 0.52619140600018, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_empty_part_number_marker": 0.12096679100022811, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_pagination": 0.15753977300005317, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_via_object_attrs_pagination": 0.27373330099999293, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_s3_list_parts_timestamp_precision": 0.09555631299963352, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put": 1.873343327000157, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put_in_different_region": 1.868576179999991, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put_versioned": 6.58268254599966, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_put_acl": 1.294172462999768, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_restore_object": 1.1957186710001224, + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_create_object_by_presigned_request_via_dynamodb": 6.120672615999865, + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_create_object_put_via_dynamodb": 2.961846759000082, + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_invalid_lambda_arn": 0.4548882860001413, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_bucket_not_exist": 0.3890254170000844, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_bucket_notifications_with_filter": 1.6482997969997086, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_invalid_topic_arn": 0.26342117700005474, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_object_created_put": 1.7558761759999015, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_bucket_notification_with_invalid_filter_rules": 0.28131020299997544, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_delete_objects": 0.8454481849998956, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_filter_rules_case_insensitive": 0.1065665880000779, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_invalid_sqs_arn": 0.42254666399981033, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_key_encoding": 0.6476338730001316, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_multiple_invalid_sqs_arns": 0.6256722480000008, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_notifications_with_filter": 0.7662331340000037, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_and_object_removed": 0.9403812260002269, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_complete_multipart_upload": 0.6938460090000262, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_copy": 0.7065687189999608, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put": 0.7356424399999923, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put_versioned": 1.1276264819998687, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put_with_presigned_url_upload": 0.9520425279999927, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_put_acl": 0.8580521609999323, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_tagging_delete_event": 0.6910409119998349, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_tagging_put_event": 0.6938666420001027, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_restore_object": 0.8563706419997743, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_xray_header": 1.6470353830002296, + "tests/aws/services/s3control/test_s3control.py::TestLegacyS3Control::test_lifecycle_public_access_block": 0.29192462700029864, + "tests/aws/services/s3control/test_s3control.py::TestLegacyS3Control::test_public_access_block_validations": 0.03225669899984496, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_already_exists": 0.0016606019999017008, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_bucket_not_exists": 0.0017097729996748967, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_lifecycle": 0.0017145429999345652, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_name_validation": 0.0018094780000410537, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_pagination": 0.0016869920000317506, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_public_access_block_configuration": 0.0016833139998198021, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlPublicAccessBlock::test_crud_public_access_block": 0.0017165360000035434, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlPublicAccessBlock::test_empty_public_access_block": 0.0017025199999807228, + "tests/aws/services/scheduler/test_scheduler.py::test_list_schedules": 0.06595184399975551, + "tests/aws/services/scheduler/test_scheduler.py::test_tag_resource": 0.03569380599992655, + "tests/aws/services/scheduler/test_scheduler.py::test_untag_resource": 0.031017261999977563, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[ rate(10 minutes)]": 0.01506955700006074, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[at(2021-12-31)]": 0.014849436000076821, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[at(2021-12-31T23:59:59Z)]": 0.014749059999985548, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron()]": 0.015635273000043526, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(0 1 * * * *)]": 0.017838727000025756, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(0 dummy ? * MON-FRI *)]": 0.016321272999903158, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(7 20 * * NOT *)]": 0.01576132900004268, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(71 8 1 * ? *)]": 0.015490920000047481, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(INVALID)]": 0.018163261000154307, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate( 10 minutes )]": 0.015455353999868748, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate()]": 0.015951258000086455, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(-10 minutes)]": 0.016002262000029077, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 minutess)]": 0.01627392799991867, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 seconds)]": 0.015326893000064956, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 years)]": 0.014951536000125998, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10)]": 0.016240656000036324, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(foo minutes)]": 0.016535918000045058, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_valid_schedule_expression": 0.11654861100009839, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_call_lists_secrets_multiple_times": 0.05780057299989494, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_call_lists_secrets_multiple_times_snapshots": 0.001749435999954585, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_can_recreate_delete_secret": 0.05750505899982272, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2]": 0.0879677850000462, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2c3-]": 0.08986523200019292, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name]": 0.08724958100015101, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[s-c64bdc03]": 0.11293259700028102, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_multi_secrets": 0.10444908099998429, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_multi_secrets_snapshot": 0.0018658340000001772, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_secret_version_from_empty_secret": 0.043871468999896024, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_secret_with_custom_id": 0.025167964000047505, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_delete_non_existent_secret_returns_as_if_secret_exists": 0.022006639999744948, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_deprecated_secret_version": 0.9338546039998619, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_deprecated_secret_version_stage": 0.19720595700005106, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_exp_raised_on_creation_of_secret_scheduled_for_deletion": 0.04384953499993571, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_first_rotate_secret_with_missing_lambda_arn": 0.0387822600000618, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_force_delete_deleted_secret": 0.060503461000052994, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_random_exclude_characters_and_symbols": 0.01641634299994621, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_secret_value": 0.07993451700008336, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_secret_value_errors": 0.045177548000083334, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_http_put_secret_value_custom_client_request_token_new_version_stages": 0.06019618800019089, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_http_put_secret_value_duplicate_req": 0.04847852500006411, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_http_put_secret_value_null_client_request_token_new_version_stages": 0.05797301100005825, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_http_put_secret_value_with_duplicate_client_request_token": 0.049712256000020716, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_http_put_secret_value_with_non_provided_client_request_token": 0.050505537999924854, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv *?!]Name\\\\-]": 0.10239482799988764, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv Name]": 0.09064332899993133, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv*Name? ]": 0.09293515400031538, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[Inv Name]": 0.09800008599995635, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_last_accessed_date": 0.06454952400031289, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_last_updated_date": 0.08975562500017986, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_list_secrets_filtering": 0.19309195499999987, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[CreateSecret]": 0.02916261200016379, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[PutSecretValue]": 0.02612051000005522, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[RotateSecret]": 0.02564147599991884, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[UpdateSecret]": 0.02649162099987734, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_non_versioning_version_stages_no_replacement": 1.5417170400000941, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_non_versioning_version_stages_replacement": 0.2265479120001146, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_put_secret_value_with_new_custom_client_request_token": 0.052243447000137166, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_put_secret_value_with_version_stages": 0.108671897000022, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_resource_policy": 0.05148330600013651, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_invalid_lambda_arn": 0.2262543760000426, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_multiple_times_with_lambda_success": 2.9134931899998264, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_with_lambda_success[None]": 2.3650102779997724, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_with_lambda_success[True]": 2.383824018000041, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_exists": 0.05125931100019443, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_exists_snapshots": 0.06292859500035775, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_not_found": 0.026290863000212994, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_restore": 0.04855026999985057, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_tags": 0.1297430100000838, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_version_not_found": 0.04458845600015593, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_description": 0.10627695200014387, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending": 0.2360657359997731, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle": 0.2846910329997172, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_1": 0.28796212799989007, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_2": 0.322898695000049, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_3": 0.2773040709998895, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_previous": 0.21628996899971753, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_return_type": 0.0500496419999763, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_with_non_provided_client_request_token": 0.04900064100002055, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManagerMultiAccounts::test_cross_account_access": 0.14401837500008696, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManagerMultiAccounts::test_cross_account_access_non_default_key": 0.11852805099988473, + "tests/aws/services/ses/test_ses.py::TestSES::test_cannot_create_event_for_no_topic": 0.04366634999996677, + "tests/aws/services/ses/test_ses.py::TestSES::test_clone_receipt_rule_set": 0.8827203099999679, + "tests/aws/services/ses/test_ses.py::TestSES::test_creating_event_destination_without_configuration_set": 0.06723909900028957, + "tests/aws/services/ses/test_ses.py::TestSES::test_delete_template": 0.06744242499962638, + "tests/aws/services/ses/test_ses.py::TestSES::test_deleting_non_existent_configuration_set": 0.0166297680000298, + "tests/aws/services/ses/test_ses.py::TestSES::test_deleting_non_existent_configuration_set_event_destination": 0.03544346399985443, + "tests/aws/services/ses/test_ses.py::TestSES::test_get_identity_verification_attributes_for_domain": 0.014465330000348331, + "tests/aws/services/ses/test_ses.py::TestSES::test_get_identity_verification_attributes_for_email": 0.029242627000030552, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[-]": 0.01664231699987795, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[-test]": 0.017959887999950297, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test-]": 0.017698113000051308, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test-test_invalid_value:123]": 0.01887810400012313, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name:123-test]": 0.017543815999943035, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name:123-test_invalid_value:123]": 0.01738436100004037, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name_len]": 0.017171424999787632, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_value_len]": 0.018015015999935713, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_priority_name_value]": 0.01655714500020622, + "tests/aws/services/ses/test_ses.py::TestSES::test_list_templates": 0.16027558500013583, + "tests/aws/services/ses/test_ses.py::TestSES::test_sending_to_deleted_topic": 0.47302001500020197, + "tests/aws/services/ses/test_ses.py::TestSES::test_sent_message_counter": 0.13389550400006556, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_email": 1.577122723999537, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_raw_email": 1.5435215759998755, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_templated_email": 1.6819929349996983, + "tests/aws/services/ses/test_ses.py::TestSES::test_special_tags_send_email[ses:feedback-id-a-this-marketing-campaign]": 0.019557791999659457, + "tests/aws/services/ses/test_ses.py::TestSES::test_special_tags_send_email[ses:feedback-id-b-that-campaign]": 0.02005948799978796, + "tests/aws/services/ses/test_ses.py::TestSES::test_trying_to_delete_event_destination_from_non_existent_configuration_set": 0.09985097499975382, + "tests/aws/services/ses/test_ses.py::TestSESRetrospection::test_send_email_can_retrospect": 1.5249821879997398, + "tests/aws/services/ses/test_ses.py::TestSESRetrospection::test_send_templated_email_can_retrospect": 0.07850659400014592, + "tests/aws/services/sns/test_sns.py::TestSNSCertEndpoint::test_cert_endpoint_host[]": 0.2094221119998565, + "tests/aws/services/sns/test_sns.py::TestSNSCertEndpoint::test_cert_endpoint_host[sns.us-east-1.amazonaws.com]": 0.15048183800013248, + "tests/aws/services/sns/test_sns.py::TestSNSMultiAccounts::test_cross_account_access": 0.13347943999974632, + "tests/aws/services/sns/test_sns.py::TestSNSMultiAccounts::test_cross_account_publish_to_sqs": 0.5912939269999242, + "tests/aws/services/sns/test_sns.py::TestSNSMultiRegions::test_cross_region_access": 0.10083079599985467, + "tests/aws/services/sns/test_sns.py::TestSNSMultiRegions::test_cross_region_delivery_sqs": 0.20867633800003205, + "tests/aws/services/sns/test_sns.py::TestSNSPlatformEndpoint::test_create_platform_endpoint_check_idempotency": 0.002061959000002389, + "tests/aws/services/sns/test_sns.py::TestSNSPlatformEndpoint::test_publish_disabled_endpoint": 0.13475104599979204, + "tests/aws/services/sns/test_sns.py::TestSNSPlatformEndpoint::test_publish_to_gcm": 0.001910187000021324, + "tests/aws/services/sns/test_sns.py::TestSNSPlatformEndpoint::test_publish_to_platform_endpoint_is_dispatched": 0.17146861200012609, + "tests/aws/services/sns/test_sns.py::TestSNSPlatformEndpoint::test_subscribe_platform_endpoint": 0.1879886719998467, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_empty_sns_message": 0.10173204599982455, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_message_structure_json_exc": 0.06150560100013536, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_batch_too_long_message": 0.08085113000015554, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_by_path_parameters": 0.1422315580000486, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_message_before_subscribe_topic": 0.15103066699998635, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_message_by_target_arn": 0.21622816099988995, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_non_existent_target": 0.036729945000161024, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_too_long_message": 0.08286049400021511, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_with_empty_subject": 0.04519835700011754, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_wrong_arn_format": 0.037021448999894346, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_topic_publish_another_region": 0.06448831600005178, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_unknown_topic_publish": 0.04628687700005685, + "tests/aws/services/sns/test_sns.py::TestSNSPublishDelivery::test_delivery_lambda": 2.174588754000297, + "tests/aws/services/sns/test_sns.py::TestSNSRetrospectionEndpoints::test_publish_sms_can_retrospect": 0.2707566549997864, + "tests/aws/services/sns/test_sns.py::TestSNSRetrospectionEndpoints::test_publish_to_platform_endpoint_can_retrospect": 0.21777770699986831, + "tests/aws/services/sns/test_sns.py::TestSNSRetrospectionEndpoints::test_subscription_tokens_can_retrospect": 1.1070162589996926, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_publish_sms": 0.01965063799980271, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_publish_sms_endpoint": 0.1932339239999692, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_publish_wrong_phone_format": 0.06734938300019166, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_subscribe_sms_endpoint": 0.05685347299981913, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_create_subscriptions_with_attributes": 0.09498173899987705, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_list_subscriptions": 0.3607071060000635, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_list_subscriptions_by_topic_pagination": 1.6172531190002246, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_not_found_error_on_set_subscription_attributes": 0.3112330290002774, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_sns_confirm_subscription_wrong_token": 0.1305780960001357, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_idempotency": 0.127111806999892, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_with_invalid_protocol": 0.03617736700016394, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_with_invalid_topic": 0.0675346940001873, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_from_non_existing_subscription": 0.09394025200003853, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_idempotency": 0.11907734399983383, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_wrong_arn_format": 0.0503471410002021, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_validate_set_sub_attributes": 0.2790145450003365, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionFirehose::test_publish_to_firehose_with_s3": 1.3150663779999832, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_dlq_external_http_endpoint[False]": 2.6836687929999243, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_dlq_external_http_endpoint[True]": 2.6859250099998917, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_http_subscription_response": 0.08937643899980685, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_multiple_subscriptions_http_endpoint": 1.7281893479996597, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_redrive_policy_http_subscription": 0.6651373640002021, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[False]": 2.135803071000055, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[True]": 1.640622330000042, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_content_type[False]": 1.621023389000129, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_content_type[True]": 1.6229521409998142, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_lambda_url_sig_validation": 2.0731141179999213, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_publish_lambda_verify_signature[1]": 4.238022110000202, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_publish_lambda_verify_signature[2]": 4.25448828399999, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_python_lambda_subscribe_sns_topic": 4.211322753000104, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_redrive_policy_lambda_subscription": 1.3338678729999174, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_sns_topic_as_lambda_dead_letter_queue": 2.3732942519998232, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSES::test_email_sender": 2.1266051649997735, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSES::test_topic_email_subscription_confirmation": 0.06319854999992458, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_attribute_raw_subscribe": 0.16274477099977958, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_empty_or_wrong_message_attributes": 0.3991618609998113, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_attributes_not_missing": 0.23755770699972345, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_attributes_prefixes": 0.1974293079997551, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_structure_json_to_sqs": 0.2291508650000651, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_exceptions": 0.0729826280000907, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_messages_from_sns_to_sqs": 0.7068346380001458, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_messages_without_topic": 0.036589964000086184, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_from_sns": 0.24746136899989324, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_from_sns_with_xray_propagation": 0.15770290100022066, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_verify_signature[1]": 0.16735678399982135, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_verify_signature[2]": 0.16339168099989365, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_unicode_chars": 0.1518844510001145, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_redrive_policy_sqs_queue_subscription[False]": 0.20318071899964707, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_redrive_policy_sqs_queue_subscription[True]": 0.2230318360000183, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_sqs_topic_subscription_confirmation": 0.08207524600015859, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscribe_sqs_queue": 0.19571102300005805, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscribe_to_sqs_with_queue_url": 0.05330634599977202, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscription_after_failure_to_deliver": 1.5452918719997797, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_fifo_topic_to_regular_sqs[False]": 0.2888521459999538, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_fifo_topic_to_regular_sqs[True]": 0.29185546200005774, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs[False]": 1.1999326170000586, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs[True]": 1.1974212950001402, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs_ordering": 2.7802646520001417, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_batch_messages_from_fifo_topic_to_fifo_queue[False]": 3.6392769450001197, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_batch_messages_from_fifo_topic_to_fifo_queue[True]": 3.6750010330001714, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_fifo_messages_to_dlq[False]": 1.6147210609999547, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_fifo_messages_to_dlq[True]": 1.6015147880002587, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_deduplication_on_topic_level": 1.695315785000048, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_to_sqs_queue_no_content_dedup[False]": 0.30163222300006964, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_to_sqs_queue_no_content_dedup[True]": 0.30990992199986067, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_with_target_arn": 0.035956387999931394, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_validations_for_fifo": 0.24760621199970956, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_duplicate_topic_check_idempotency": 0.09449936500004696, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_duplicate_topic_with_more_tags": 0.03747917400005463, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_after_delete_with_new_tags": 0.058019091000005574, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_test_arn": 0.3237592730001779, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_with_attributes": 0.2850619740002003, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_delete_topic_idempotency": 0.058431228999779705, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_tags": 0.09438304900004368, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_topic_delivery_policy_crud": 0.0018642310001268925, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_exists_filter_policy": 0.3746523679999427, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_exists_filter_policy_attributes_array": 4.352528548999999, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_filter_policy": 5.342556068999784, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_empty_array_payload": 0.19072873899972365, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_for_batch": 3.40477781699974, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_ip_address_condition": 0.3762319180000304, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_large_complex_payload": 0.21614578200001233, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body[False]": 5.362140765999811, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body[True]": 5.361197647000154, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_array_attributes": 0.6364353639999081, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_array_of_object_attributes": 0.36628857299979245, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_dot_attribute": 6.976484288999927, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_or_attribute": 0.843804930000033, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_policy_complexity": 0.06214441499992063, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_policy_complexity_with_or": 0.06746930900021653, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy": 0.13294175999999425, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_exists_operator": 0.1219205439997495, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_nested_anything_but_operator": 0.17863118100012798, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_numeric_operator": 0.23636517900013132, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_string_operators": 0.24219511700016483, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_set_subscription_filter_policy_scope": 0.13158637100013948, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_sub_filter_policy_nested_property": 0.11965933099986614, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_sub_filter_policy_nested_property_constraints": 0.19429368800001612, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_cross_account_access[domain]": 0.10752379699988523, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_cross_account_access[path]": 0.10544912199975442, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_cross_account_access[standard]": 0.10423173499998484, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_cross_account_get_queue_url[domain]": 0.03475557199999457, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_cross_account_get_queue_url[path]": 0.0348200830001133, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_cross_account_get_queue_url[standard]": 0.03623972199989112, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_delete_queue_multi_account[sqs]": 0.10454385399975763, + "tests/aws/services/sqs/test_sqs.py::TestSQSMultiAccounts::test_delete_queue_multi_account[sqs_query]": 0.10239039499992941, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_approximate_number_of_messages_delayed[sqs]": 3.145401195000204, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_approximate_number_of_messages_delayed[sqs_query]": 3.148666141999911, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_aws_trace_header_propagation[sqs]": 0.13738820699995813, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_aws_trace_header_propagation[sqs_query]": 0.13174159899995175, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_batch_send_with_invalid_char_should_succeed[sqs]": 0.10681960999954754, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_batch_send_with_invalid_char_should_succeed[sqs_query]": 0.2592347089998839, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_after_visibility_timeout_expiration[sqs]": 2.1067708160001075, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_after_visibility_timeout_expiration[sqs_query]": 2.111746906999997, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_batch_with_too_large_batch[sqs]": 0.6510865280001781, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_batch_with_too_large_batch[sqs_query]": 0.6724273390000235, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_not_permanent[sqs]": 0.11603891000004296, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_not_permanent[sqs_query]": 0.11010402200008684, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_visibility_on_deleted_message_raises_invalid_parameter_value[sqs]": 0.09707912399994711, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_visibility_on_deleted_message_raises_invalid_parameter_value[sqs_query]": 0.1039994969999043, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_send_to_fifo_queue[sqs]": 0.07050939199962158, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_send_to_fifo_queue[sqs_query]": 0.07079123699986667, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_update_queue_attributes[sqs]": 0.09038001099997928, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_update_queue_attributes[sqs_query]": 0.09358487700023943, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_fifo_queue_with_different_attributes_raises_error[sqs]": 0.1482310450003297, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_fifo_queue_with_different_attributes_raises_error[sqs_query]": 0.14826946399989538, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_fifo_queue_with_same_attributes_is_idempotent": 0.043409686000359216, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_after_internal_attributes_changes_works[sqs]": 0.09654605099990476, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_after_internal_attributes_changes_works[sqs_query]": 0.09302374099979716, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_after_modified_attributes[sqs]": 0.001718439000114813, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_after_modified_attributes[sqs_query]": 0.0017113269998390024, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_after_send[sqs]": 0.1279264779998357, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_after_send[sqs_query]": 0.12824502899979962, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_and_get_attributes[sqs]": 0.03473943800008783, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_and_get_attributes[sqs_query]": 0.03750203100003091, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted[sqs]": 0.040735833000098864, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted[sqs_query]": 0.040174810000053185, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted_cache[sqs]": 1.5617854769998303, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted_cache[sqs_query]": 1.5655761660002554, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted_can_be_disabled[sqs]": 0.04883473900008539, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted_can_be_disabled[sqs_query]": 0.051089651999973285, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_default_arguments_works_with_modified_attributes[sqs]": 0.0018045790000087436, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_default_arguments_works_with_modified_attributes[sqs_query]": 0.0017138610000984045, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_default_attributes_is_idempotent": 0.04293179500018596, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_different_attributes_raises_exception[sqs]": 0.2143847980000828, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_different_attributes_raises_exception[sqs_query]": 0.21307093099994745, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_same_attributes_is_idempotent": 0.044327179999982036, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_tags[sqs]": 0.034677759000260266, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_tags[sqs_query]": 0.033690141000079166, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_without_attributes_is_idempotent": 0.042326238999748966, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_standard_queue_with_fifo_attribute_raises_error[sqs]": 0.08396395599993411, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_standard_queue_with_fifo_attribute_raises_error[sqs_query]": 0.08376116699992053, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_chain[sqs]": 0.0018328619999010698, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_chain[sqs_query]": 0.001697619999958988, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_config": 0.04248411000003216, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_execution_lambda_mapping_preserves_id[sqs]": 0.0034503839999615593, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_execution_lambda_mapping_preserves_id[sqs_query]": 0.0017856139998002618, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_list_sources[sqs]": 0.06519370700016225, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_list_sources[sqs_query]": 0.06442652600003385, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_max_receive_count[sqs]": 0.14152619700007563, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_max_receive_count[sqs_query]": 0.145868844000006, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_message_attributes": 0.7880914509999002, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_with_fifo_and_content_based_deduplication[sqs]": 0.18546046000005845, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_with_fifo_and_content_based_deduplication[sqs_query]": 0.19117494100009935, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_deduplication_interval[sqs]": 0.0018380019998858188, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_deduplication_interval[sqs_query]": 0.0017066459997749917, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_after_visibility_timeout[sqs]": 1.137682100999882, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_after_visibility_timeout[sqs_query]": 1.138916465999955, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_from_lambda[sqs]": 0.0018055800001093303, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_from_lambda[sqs_query]": 0.0017697320001843764, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-]": 0.19547669299981862, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-invalid:id]": 0.07452020500022627, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-testLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongId]": 0.07201551799994377, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-]": 0.07547444499982703, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-invalid:id]": 0.07336394300000393, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-testLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongId]": 0.07479634299988902, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_with_too_large_batch[sqs]": 0.6734129700000722, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_with_too_large_batch[sqs_query]": 0.6641241560000708, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_deletes_with_change_visibility_timeout[sqs]": 0.1479326650000985, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_deletes_with_change_visibility_timeout[sqs_query]": 0.15015552200020466, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_with_deleted_receipt_handle[sqs]": 0.11765419299968016, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_with_deleted_receipt_handle[sqs_query]": 0.1204620309999882, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_with_illegal_receipt_handle[sqs]": 0.033526314999789975, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_with_illegal_receipt_handle[sqs_query]": 0.03353892500012989, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_disallow_queue_name_with_slashes": 0.0017790219997095846, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_extend_message_visibility_timeout_set_in_queue[sqs]": 7.113487185000167, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_extend_message_visibility_timeout_set_in_queue[sqs_query]": 6.999521292000281, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_external_endpoint[sqs]": 0.1458080309998877, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_external_endpoint[sqs_query]": 0.06851366699993378, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_external_host_via_header_complete_message_lifecycle": 0.09760431699987748, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_external_hostname_via_host_header": 0.03677318900008686, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_approx_number_of_messages[sqs]": 0.2616445420001128, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_approx_number_of_messages[sqs_query]": 0.2665408689999822, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_high_throughput_after_creation[sqs]": 0.34677693000003273, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_high_throughput_after_creation[sqs_query]": 0.3620400509996671, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_regular_throughput_after_creation[sqs]": 0.24807855600010953, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_regular_throughput_after_creation[sqs_query]": 0.25108450399966387, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_content_based_message_deduplication_arrives_once[sqs]": 1.1119839509999565, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_content_based_message_deduplication_arrives_once[sqs_query]": 1.137555243999941, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs-False]": 1.159753192999915, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs-True]": 1.1587123429999338, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs_query-False]": 1.1658219659998394, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs_query-True]": 1.1722177530000408, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs-False]": 1.1406274129999474, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs-True]": 1.1481800210001438, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs_query-False]": 1.1473605260000568, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs_query-True]": 1.150387224000042, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_after_visibility_timeout[sqs]": 1.187417882000318, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_after_visibility_timeout[sqs_query]": 1.1913687579999532, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_message_with_expired_receipt_handle[sqs]": 0.0019062679998569365, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_message_with_expired_receipt_handle[sqs_query]": 0.0017382050000378513, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_empty_message_groups_added_back_to_queue[sqs]": 0.18099170999994385, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_empty_message_groups_added_back_to_queue[sqs_query]": 0.18955985000025066, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_high_throughput_ordering[sqs]": 0.1724835659999826, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_high_throughput_ordering[sqs_query]": 0.17187849300012203, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_attributes[sqs]": 0.1668164180000531, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_attributes[sqs_query]": 0.16858229400008895, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility": 2.1271436969998376, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_change_message_visibility[sqs]": 2.1331166939999093, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_change_message_visibility[sqs_query]": 2.1352081219999945, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_delete[sqs]": 0.29302098199991633, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_delete[sqs_query]": 0.3093348029999561, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_partial_delete[sqs]": 0.28830223499994645, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_partial_delete[sqs_query]": 0.284946778000176, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_terminate_visibility_timeout[sqs]": 0.14624972299975525, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_group_visibility_after_terminate_visibility_timeout[sqs_query]": 0.15135191199988185, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_messages_in_order_after_timeout[sqs]": 2.118956553000089, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_messages_in_order_after_timeout[sqs_query]": 2.120084540000107, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_requires_suffix": 0.017153455999959988, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_on_queue_works[sqs]": 4.108532061999995, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_on_queue_works[sqs_query]": 4.1291995909998604, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_seconds_fails[sqs]": 0.1641569599999002, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_seconds_fails[sqs_query]": 0.1655647800000679, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_multiple_messages_multiple_single_receives[sqs]": 0.26543260299990834, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_multiple_messages_multiple_single_receives[sqs_query]": 0.2654057050001484, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_receive_message_group_id_ordering[sqs]": 0.15305341599992062, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_receive_message_group_id_ordering[sqs_query]": 0.15108570500001406, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_receive_message_visibility_timeout_shared_in_group[sqs]": 2.1739506179999353, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_receive_message_visibility_timeout_shared_in_group[sqs_query]": 2.1985084300001745, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_receive_message_with_zero_visibility_timeout[sqs]": 0.19059476500001438, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_receive_message_with_zero_visibility_timeout[sqs_query]": 0.20182209400013562, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_sequence_number_increases[sqs]": 0.10873189800008731, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_sequence_number_increases[sqs_query]": 0.11343428300006053, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_set_content_based_deduplication_strategy[sqs]": 0.09141701400017155, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_set_content_based_deduplication_strategy[sqs_query]": 0.09346205100018778, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_list_queues_with_query_auth": 0.023389039999983652, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_queue_url_contains_localstack_host[sqs]": 0.03441413899986401, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_queue_url_contains_localstack_host[sqs_query]": 0.043016021999847, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_queue_url_multi_region[domain]": 0.05448491900006047, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_queue_url_multi_region[path]": 0.05592134499988788, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_queue_url_multi_region[standard]": 0.059369954999738184, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_specific_queue_attribute_response[sqs]": 0.06397124000000076, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_get_specific_queue_attribute_response[sqs_query]": 0.0660786719997759, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_inflight_message_requeue": 4.604776447999939, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_batch_id[sqs]": 0.15665863899994292, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_batch_id[sqs_query]": 0.15387543699989692, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_dead_letter_arn_rejected_before_lookup": 0.001818336000042109, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_receipt_handle_should_return_error_message[sqs]": 0.033555155999920316, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_receipt_handle_should_return_error_message[sqs_query]": 0.03342973099984192, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_string_attributes_cause_invalid_parameter_value_error[sqs]": 0.03570585400007076, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_string_attributes_cause_invalid_parameter_value_error[sqs_query]": 0.037233435999951325, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queue_tags[sqs]": 0.038085110000110944, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queue_tags[sqs_query]": 0.043317520999835324, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues": 0.11012235600014719, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues_multi_region_with_endpoint_strategy_domain": 0.06901149400005124, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues_multi_region_with_endpoint_strategy_standard": 0.06885107600032825, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues_multi_region_without_endpoint_strategy": 0.07646024099994975, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues_pagination": 0.29593796399990424, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_marker_serialization_json_protocol[\"{\\\\\"foo\\\\\": \\\\\"ba\\\\rr\\\\\"}\"]": 0.0884703809997518, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_marker_serialization_json_protocol[{\"foo\": \"ba\\rr\", \"foo2\": \"ba"r"\"}]": 0.08731576400009544, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_deduplication_id_too_long": 0.17177202699986083, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_group_id_too_long": 0.17450663900012842, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_retention": 3.1045608689996698, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_retention_fifo": 3.083965822999744, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_retention_with_inflight": 5.620424720999836, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_system_attribute_names_with_attribute_names[sqs]": 0.12747570100009398, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_system_attribute_names_with_attribute_names[sqs_query]": 0.13217381699996622, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_with_attributes_should_be_enqueued[sqs]": 0.06487595299995519, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_with_attributes_should_be_enqueued[sqs_query]": 0.06970577799984312, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_with_carriage_return[sqs]": 0.07318649699982416, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_with_carriage_return[sqs_query]": 0.07302193299960891, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_non_existent_queue": 0.17747946299982686, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_fifo_requires_deduplicationid_group_id[sqs]": 0.2643771289999677, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_fifo_requires_deduplicationid_group_id[sqs_query]": 0.25984335999987707, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_queue_via_queue_name[sqs]": 0.055575712999825555, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_queue_via_queue_name[sqs_query]": 0.05585215499991136, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_publish_get_delete_message[sqs]": 0.10435367500008397, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_publish_get_delete_message[sqs_query]": 0.10580533899997135, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_publish_get_delete_message_batch[sqs]": 0.18128249099959248, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_publish_get_delete_message_batch[sqs_query]": 0.2748848099997758, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue[sqs]": 1.2515650919999644, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue[sqs_query]": 1.2575099419998423, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue_clears_fifo_deduplication_cache[sqs]": 0.10844683400023314, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue_clears_fifo_deduplication_cache[sqs_query]": 0.10840762400016501, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue_deletes_delayed_messages[sqs]": 3.1661334680002255, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue_deletes_delayed_messages[sqs_query]": 3.156630189000225, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue_deletes_inflight_messages[sqs]": 4.25626735600008, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_purge_queue_deletes_inflight_messages[sqs_query]": 4.294738597000105, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_queue_list_nonexistent_tags[sqs]": 0.034184860000095796, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_queue_list_nonexistent_tags[sqs_query]": 0.03318413700003475, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_after_visibility_timeout[sqs]": 1.6992580140001792, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_after_visibility_timeout[sqs_query]": 1.9988293859998976, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_empty_queue[sqs]": 1.0976392610002677, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_empty_queue[sqs_query]": 1.1001510930000222, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attribute_names_filters[sqs]": 0.2485836040000322, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attribute_names_filters[sqs_query]": 0.25222833499969965, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attributes_timestamp_types[sqs]": 0.07162777800022013, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attributes_timestamp_types[sqs_query]": 0.07297605300027499, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_attribute_names_filters[sqs]": 0.3463207379995765, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_attribute_names_filters[sqs_query]": 0.31689583499996843, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_system_attribute_names_filters[sqs]": 0.16922910099992805, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_system_attribute_names_filters[sqs_query]": 0.16967031099989072, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_wait_time_seconds_and_max_number_of_messages_does_not_block[sqs]": 0.10661419899997782, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_wait_time_seconds_and_max_number_of_messages_does_not_block[sqs_query]": 0.10664911000026223, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_with_visibility_timeout_updates_timeout[sqs]": 0.1053867180000907, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_with_visibility_timeout_updates_timeout[sqs_query]": 0.11304337899991879, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_terminate_visibility_timeout[sqs]": 0.11087818900023194, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_terminate_visibility_timeout[sqs_query]": 0.10616224300042632, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_redrive_policy_attribute_validity[sqs]": 0.001733337000132451, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_redrive_policy_attribute_validity[sqs_query]": 0.001793809999981022, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_remove_message_with_old_receipt_handle[sqs]": 2.0897462479997557, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_remove_message_with_old_receipt_handle[sqs_query]": 2.0868110979999983, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_message_size": 0.2547429329999886, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_deduplication_id_for_fifo_queue[sqs]": 0.1463161740000487, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_deduplication_id_for_fifo_queue[sqs_query]": 0.15387652100002924, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_message_group_id_for_fifo_queue[sqs]": 0.16675059699991834, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_message_group_id_for_fifo_queue[sqs_query]": 0.16321063999998842, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_receive_multiple[sqs]": 0.11992886500024724, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_receive_multiple[sqs_query]": 0.11729322700034572, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_delay_and_wait_time[sqs]": 2.0280393080001886, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_delay_and_wait_time[sqs_query]": 1.9986103029998503, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_empty_message[sqs]": 0.14649228600001152, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_empty_message[sqs_query]": 0.15205280400027732, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch[sqs]": 0.12333786599992891, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch[sqs_query]": 0.1281049060000896, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_empty_list[sqs]": 0.03400034599985702, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_empty_list[sqs_query]": 0.033624128000155906, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents[sqs]": 0.15147296499981167, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents[sqs_query]": 0.1587568139996165, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents_with_updated_maximum_message_size[sqs]": 0.12213781499985998, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents_with_updated_maximum_message_size[sqs_query]": 0.12322557599986794, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_to_standard_queue_with_empty_message_group_id": 0.09241603600003145, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_attributes[sqs]": 0.06860787000005075, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_attributes[sqs_query]": 0.0703884360000302, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_binary_attributes[sqs]": 0.10976956899980905, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_binary_attributes[sqs_query]": 0.11230360000013206, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_delay_0_works_for_fifo[sqs]": 0.0708486459998312, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_delay_0_works_for_fifo[sqs_query]": 0.06905346600001394, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_empty_string_attribute[sqs]": 0.15133838899987495, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_empty_string_attribute[sqs_query]": 0.1494742600000336, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_fifo_parameters[sqs]": 0.0019508220002535381, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_fifo_parameters[sqs_query]": 0.0018372909999015974, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_payload_characters[sqs]": 0.03470251799990365, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_payload_characters[sqs_query]": 0.033766475999982504, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_string_attributes[sqs]": 0.14802395499987142, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_string_attributes[sqs_query]": 0.15189857499990467, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_updated_maximum_message_size[sqs]": 0.18062490399961462, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_updated_maximum_message_size[sqs_query]": 0.18720810200011329, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_oversized_message[sqs]": 0.15475551500026086, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_oversized_message[sqs_query]": 0.156282358999988, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_max_number_of_messages[sqs]": 0.17268306799996935, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_max_number_of_messages[sqs_query]": 0.17473566800026674, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message[sqs]": 0.06981231999998272, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message[sqs_query]": 0.07361489100003382, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message_encoded_content[sqs]": 0.07361996599979648, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message_encoded_content[sqs_query]": 0.07084435399997346, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message_multiple_queues": 0.09980903499990745, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_wait_time_seconds[sqs]": 0.24809545799985244, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_wait_time_seconds[sqs_query]": 0.24808775599967703, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sent_message_retains_attributes_after_receive[sqs]": 0.08631835699998192, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sent_message_retains_attributes_after_receive[sqs_query]": 0.08829990099980023, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sequence_number[sqs]": 0.09819445800007998, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sequence_number[sqs_query]": 0.09715929800017875, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_queue_policy[sqs]": 0.06786696999984088, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_queue_policy[sqs_query]": 0.07095895299971744, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_redrive_policy[sqs]": 0.07369445700010147, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_redrive_policy[sqs_query]": 0.07962261000011495, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_queue_policy[sqs]": 0.04832844500015199, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_queue_policy[sqs_query]": 0.05207483599997431, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_fifo[sqs]": 0.25009891800027617, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_fifo[sqs_query]": 0.2538656209999317, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_standard[sqs]": 0.22862591600005544, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_standard[sqs_query]": 0.2296396100000493, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_message_group_scope_no_throughput_setting[sqs]": 0.16849181600014163, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_message_group_scope_no_throughput_setting[sqs_query]": 0.17888240299976133, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_same_dedup_id_different_message_groups[sqs]": 0.16688097800010837, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_same_dedup_id_different_message_groups[sqs_query]": 0.169827818000158, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_permission_lifecycle[sqs]": 0.25291564400004063, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_permission_lifecycle[sqs_query]": 0.2611969100000806, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_kms_and_sqs_are_mutually_exclusive[sqs]": 0.0019182309999905556, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_kms_and_sqs_are_mutually_exclusive[sqs_query]": 0.0019104269999843382, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_queue_attributes[sqs]": 0.11183937199962202, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_queue_attributes[sqs_query]": 0.13503917200000615, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_standard_queue_cannot_have_fifo_suffix": 0.015540052000005744, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_successive_purge_calls_fail[sqs]": 0.15980262700009007, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_successive_purge_calls_fail[sqs_query]": 0.1541566400001102, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_system_attributes_have_no_effect_on_attr_md5[sqs]": 0.0988305590001346, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_system_attributes_have_no_effect_on_attr_md5[sqs_query]": 0.1031639009997889, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_queue_overwrites_existing_tag[sqs]": 0.048020590000305674, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_queue_overwrites_existing_tag[sqs_query]": 0.05069036099985169, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_untag_queue[sqs]": 0.11389594299976125, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_untag_queue[sqs_query]": 0.11447156900021582, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tags_case_sensitive[sqs]": 0.039492491000373775, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tags_case_sensitive[sqs_query]": 0.041711072999987664, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_terminate_visibility_timeout_after_receive[sqs]": 0.13545690500018281, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_terminate_visibility_timeout_after_receive[sqs_query]": 0.1473616860000675, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_too_many_entries_in_batch_request[sqs]": 0.15009467799995946, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_too_many_entries_in_batch_request[sqs_query]": 0.15596685299988167, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_untag_queue_ignores_non_existing_tag[sqs]": 0.04993029800016302, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_untag_queue_ignores_non_existing_tag[sqs_query]": 0.04960832400001891, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_wait_time_seconds_queue_attribute_waits_correctly[sqs]": 1.068008306000138, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_wait_time_seconds_queue_attribute_waits_correctly[sqs_query]": 1.0694903500000237, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_wait_time_seconds_waits_correctly[sqs]": 1.0693614430001617, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_wait_time_seconds_waits_correctly[sqs_query]": 1.0790416099998765, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_endpoint_strategy_with_multi_region[domain]": 0.13308852899990598, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_endpoint_strategy_with_multi_region[off]": 0.12985905699997602, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_endpoint_strategy_with_multi_region[path]": 0.13166133400000035, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_endpoint_strategy_with_multi_region[standard]": 0.19578174399998716, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_create_queue_fails": 0.03523274000008314, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_delete_queue[domain]": 0.05263482599980307, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_delete_queue[path]": 0.05283828500000709, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_delete_queue[standard]": 0.0552116430001206, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_list_queues_fails": 0.03660737000018344, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_list_queues_fails_json_format": 0.0020953309999640624, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_on_deleted_queue_fails[sqs]": 0.05693651600040539, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_on_deleted_queue_fails[sqs_query]": 0.06019795600013822, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_all": 0.057296069000130956, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_json_format": 0.0018420989999867743, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_of_fifo_queue": 0.04729289399983827, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_with_invalid_arg_returns_error": 0.04465686999992613, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_with_query_args": 0.04524931899982221, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_works_without_authparams[domain]": 0.047666362000200024, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_works_without_authparams[path]": 0.04622982400019282, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_attributes_works_without_authparams[standard]": 0.04553283000018382, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_url_work_for_different_queue[domain]": 0.06004502400014644, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_url_work_for_different_queue[path]": 0.061547522999944704, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_url_work_for_different_queue[standard]": 0.05873433200008549, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_url_works_for_same_queue[domain]": 0.04622646499979055, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_url_works_for_same_queue[path]": 0.044323269999949844, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_queue_url_works_for_same_queue[standard]": 0.044290297000088685, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_send_and_receive_messages": 0.13151893600047515, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_without_query_json_format_returns_returns_xml": 0.03652240099995652, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_get_without_query_returns_unknown_operation": 0.033648229000164065, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_invalid_action_raises_exception": 0.03826458099979391, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_overwrite_queue_url_in_params": 0.06420296300029804, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_queue_url_format_path_strategy": 0.02895888900002319, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_send_message_via_queue_url_with_json_protocol": 1.0978493459999754, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_valid_action_with_missing_parameter_raises_exception": 0.03638321799962796, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_fifo_list_messages_as_botocore_endpoint_url[json-domain]": 0.112489504999985, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_fifo_list_messages_as_botocore_endpoint_url[json-path]": 0.10932953699989412, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_fifo_list_messages_as_botocore_endpoint_url[json-standard]": 0.11194339099984063, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_fifo_list_messages_as_botocore_endpoint_url[query-domain]": 0.11523653100016418, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_fifo_list_messages_as_botocore_endpoint_url[query-path]": 0.11383334700008163, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_fifo_list_messages_as_botocore_endpoint_url[query-standard]": 0.11134208800035594, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_botocore_endpoint_url[json-domain]": 0.08522040000002562, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_botocore_endpoint_url[json-path]": 0.08396175000007133, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_botocore_endpoint_url[json-standard]": 0.08988810800019564, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_botocore_endpoint_url[query-domain]": 0.08566115100006755, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_botocore_endpoint_url[query-path]": 0.08728481999992255, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_botocore_endpoint_url[query-standard]": 0.0954757540000628, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_json[domain]": 0.08271128700016561, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_json[path]": 0.08218180499989103, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_as_json[standard]": 0.08071549300029801, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_has_no_side_effects[domain]": 0.11263048900013928, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_has_no_side_effects[path]": 0.10979123000015534, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_has_no_side_effects[standard]": 0.1132800049999787, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_delayed_messages[domain]": 0.11487801200019021, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_delayed_messages[path]": 0.12267478399985521, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_delayed_messages[standard]": 0.1169061169998713, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_action_raises_error[json-domain]": 0.03133796900010566, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_action_raises_error[json-path]": 0.033945033999998486, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_action_raises_error[json-standard]": 0.034815323999964676, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_action_raises_error[query-domain]": 0.03424945799974921, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_action_raises_error[query-path]": 0.033452457000066715, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_action_raises_error[query-standard]": 0.03485122499978388, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_queue_url[domain]": 0.02132417899997563, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_queue_url[path]": 0.02129169600016212, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invalid_queue_url[standard]": 0.023901496000007683, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invisible_messages[domain]": 0.13510917500002506, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invisible_messages[path]": 0.13671734900026422, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_invisible_messages[standard]": 0.13242709000019204, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_non_existent_queue[domain]": 0.027754940000249917, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_non_existent_queue[path]": 0.026966301999891584, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_non_existent_queue[standard]": 0.028718152000010377, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_queue_url_in_path[domain]": 0.09328148699978556, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_queue_url_in_path[path]": 0.08878544600020177, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_with_queue_url_in_path[standard]": 0.09320026200020948, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_without_queue_url[domain]": 0.022032154999806153, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_without_queue_url[path]": 0.021176046999698883, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsDeveloperEndpoints::test_list_messages_without_queue_url[standard]": 0.02238916999999674, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsOverrideHeaders::test_receive_message_override_max_number_of_messages": 0.5866712409999764, + "tests/aws/services/sqs/test_sqs_backdoor.py::TestSqsOverrideHeaders::test_receive_message_override_message_wait_time_seconds": 25.30734893299973, + "tests/aws/services/sqs/test_sqs_move_task.py::test_basic_move_task_workflow": 1.8504090120004548, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_source_arn_in_task_handle": 0.05581147700058864, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_handle": 0.055586209000466624, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_id_in_task_handle": 0.0789477330004047, + "tests/aws/services/sqs/test_sqs_move_task.py::test_destination_needs_to_exist": 0.11856383100030143, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_cancel": 1.9038878649998878, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_delete_destination_queue_while_running": 1.9773679940003603, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_with_throughput_limit": 3.4274532429994906, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_workflow_with_default_destination": 1.827939088000221, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_workflow_with_multiple_sources_as_default_destination": 3.890405383000598, + "tests/aws/services/sqs/test_sqs_move_task.py::test_source_needs_redrive_policy": 0.10197077599968907, + "tests/aws/services/sqs/test_sqs_move_task.py::test_start_multiple_move_tasks": 0.7627350459997615, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_describe_parameters": 0.01810817599971415, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_inexistent_maintenance_window": 0.023817740000595222, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_inexistent_secret": 0.03787198200006969, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_parameter_by_arn": 0.08231778299978032, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_parameters_and_secrets": 0.14842445400017823, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_parameters_by_path_and_filter_by_labels": 0.08196294599974863, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_secret_parameter": 0.07568512800071403, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_hierarchical_parameter[/<param>//b//c]": 0.07566867600007754, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_hierarchical_parameter[<param>/b/c]": 0.07476626700008637, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_parameters_with_path": 0.17018202700000984, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_put_parameters": 0.0893688510004722, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_trigger_event_on_systems_manager_change[domain]": 0.15690301000040563, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_trigger_event_on_systems_manager_change[path]": 0.1290321239998775, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_trigger_event_on_systems_manager_change[standard]": 0.1642492669998319, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task": 2.242031850999865, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_failure": 2.020435523999822, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_no_worker_name": 1.9649174989995117, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_on_deleted": 0.4500415950005845, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_start_timeout": 5.732111526000153, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_with_heartbeat": 6.045177750000221, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EMPTY]": 2.329653178999706, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EMPTY_GLOBAL_QL_JSONATA]": 2.37017564900043, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EXPRESSION]": 6.779571311999916, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_LITERALS]": 2.4363465440001164, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_assign_in_choice[CONDITION_FALSE]": 2.1746429400000125, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_assign_in_choice[CONDITION_TRUE]": 0.9968447280002692, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_CONSTANT_LITERALS]": 1.155137544000354, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_EMPTY]": 0.7360776300001817, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_PATHS]": 1.011504891000186, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_SCOPE_MAP]": 1.0812960429998384, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_VAR]": 1.3519847150000714, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_parallel_cases[BASE_SCOPE_PARALLEL]": 1.1427289280004516, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_INTRINSIC_FUNCTION]": 1.9891838349999489, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_PARAMETERS]": 1.0018021460000455, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_RESULT]": 0.9999479560001419, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_catch_state": 2.4012238420004905, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_choice_state[CORRECT]": 1.0208247360001224, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_choice_state[INCORRECT]": 1.0028377329999785, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_wait_state": 0.7542664139996305, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_CHOICE]": 1.0591938850002407, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_FAIL]": 0.9515942590005579, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_INPUTPATH]": 0.9882390630000373, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_INTRINSIC_FUNCTION]": 1.2406991290004044, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE]": 1.9965731669999514, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_OUTPUTPATH]": 1.007706987000347, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_PARAMETERS]": 0.9767649740001616, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_WAIT]": 0.9948958230002063, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION]": 1.3146945670005152, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_ITEMS_PATH]": 1.305960007000067, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_ITEM_SELECTOR]": 1.30932739400032, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH]": 1.020460675999857, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH]": 1.1418136660004166, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state_max_items_path[MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH]": 1.1738197749996289, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state_max_items_path[MAP_STATE_REFERENCE_IN_MAX_PER_BATCH_PATH]": 0.0020755529999405553, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_state_assign_evaluation_order[BASE_EVALUATION_ORDER_PASS_STATE]": 0.0019113699995614297, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ARGUMENTS]": 0.0019522949996826355, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ARGUMENTS_FIELD]": 0.001715875000172673, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ASSIGN]": 1.2490582929999619, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT]": 1.2790427709996948, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT_FIELD]": 1.2577873539999018, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT_MULTIPLE_STATES]": 1.2923457249999046, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_variables_in_lambda_task[BASE_ASSIGN_FROM_LAMBDA_TASK_RESULT]": 2.7275797179995607, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_decl_version_1_0": 0.6958222570001453, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_event_bridge_events_base": 2.6480675540001357, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_event_bridge_events_failure": 0.001965859999472741, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_execution_dateformat": 0.41480535900018367, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_access[$.items[0]]": 0.7389285600006588, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_access[$.items[10]]": 0.7164162270005363, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[*]]": 0.6628290879998531, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:5].itemValue]": 0.7336257259999002, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:5]]": 0.7090061819994844, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:]]": 0.7024853229995642, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[:1]]": 0.6933978339998248, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[*].itemValue]": 0.7153138220000983, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[*]]": 0.7159685199994783, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[1:].itemValue]": 0.6784299910004847, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[1:]]": 0.7141097290000289, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[:1].itemValue]": 0.6934069059998365, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[:1]]": 0.7097749800004749, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$[*]]": 0.71615936399985, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_query_context_object_values": 1.635277502000008, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail": 2.0985364250000202, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_empty": 0.6383006179994481, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_intrinsic": 0.7259708199999295, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_path": 0.705225165999309, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_regex_json_path": 0.0020772070001839893, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_regex_json_path_base": 0.6974344999994173, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result": 0.6891566399999647, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result_jsonpaths": 0.6880147540000507, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result_null_input_output_paths": 0.7843766139999389, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[-1.5]": 0.7031375800002024, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[-1]": 0.7188891609998791, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[0]": 0.6999193100000412, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[1.5]": 0.6973953849997088, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[1]": 1.5692182150005465, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_timestamp_too_far_in_future_boundary[24855]": 0.002017295000314334, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_timestamp_too_far_in_future_boundary[24856]": 0.001901509999697737, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[.000000Z]": 0.7089863589999368, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[.000000]": 0.6924165779996656, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[.00Z]": 0.5085419379997802, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[Z]": 0.7197762620003232, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[]": 0.7080046660003063, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_multiple_executions_and_heartbeat_notifications": 0.0021513960000447696, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_multiple_heartbeat_notifications": 0.003356468999754725, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sns_publish_wait_for_task_token": 2.679377270000259, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_tok_no_error_field[SQS_PARALLEL_WAIT_FOR_TASK_TOKEN]": 0.010154371000226092, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_tok_no_error_field[SQS_WAIT_FOR_TASK_TOKEN_CATCH]": 1.9305591559991626, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_token": 2.5050398080006744, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_tok_with_heartbeat": 7.73147349699957, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token": 2.6198129390004397, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_call_chain": 4.455658238999604, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_no_token_parameter": 5.7943492770000375, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_timeout": 5.824181580999721, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync": 1.3633710930002962, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync2": 1.1816195270007483, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync_delegate_failure": 1.1522702939996634, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync_delegate_timeout": 7.597652157000539, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sync_with_task_token": 3.1107491289999416, + "tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py::TestBooleanEquals::test_boolean_equals": 14.514289044999714, + "tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py::TestBooleanEquals::test_boolean_equals_path": 16.099977430999843, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_boolean": 16.30216810899998, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_null": 15.001139835000004, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_numeric": 14.001467439999999, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_present": 13.966535318000012, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_string": 13.39825145499998, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_timestamp": 0.00360081199997353, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_equals": 21.387754504999975, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_equals_path": 21.37731186800002, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than": 2.5167962280000324, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_equals": 2.4989763789999984, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_equals_path": 2.4573440810000307, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_path": 2.568412529999989, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than": 2.2600690750000467, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_equals": 2.4290509850000603, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_equals_path": 3.074075902000004, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_path": 2.516358124999954, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_equals": 6.226543449000019, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_equals_path": 1.4046097709999685, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than": 1.7633436250000045, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_equals": 1.3591214109999896, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_equals_path": 1.4134702739999625, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_path": 1.7416662459999657, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than": 1.415889107000055, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_equals": 1.4140059269999483, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_equals_path": 1.4245928910000316, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_path": 1.392402632000028, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_equals": 7.271110790999899, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_equals_path": 1.4025970529999654, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than": 1.3869821399999864, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_equals": 1.4126027149999345, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_equals_path": 0.6629442820000122, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_path": 0.6542495320000512, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than": 1.4236030490000076, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_equals": 1.4605613190000213, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_equals_path": 0.6199327100000005, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_path": 0.6922918869999535, + "tests/aws/services/stepfunctions/v2/comments/test_comments.py::TestComments::test_comment_in_parameters": 0.44134421400002566, + "tests/aws/services/stepfunctions/v2/comments/test_comments.py::TestComments::test_comments_as_per_docs": 22.157156340000085, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_error_cause_path": 0.9382617709999863, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_input_path[$$.Execution.Input]": 0.9514327339999227, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_input_path[$$]": 0.7324065549999546, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_output_path[$$.Execution.Input]": 0.7468561869999348, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_output_path[$$]": 0.9226442729999462, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_result_selector": 2.399989683000001, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_variable": 0.9819469849999223, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_lambda_task": 2.4867980180000586, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_service_lambda_invoke": 2.5020032009999795, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_service_lambda_invoke_retry": 5.885236023999994, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_INTRINSIC]": 1.61057839099999, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_JSONATA]": 5.173212783000054, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH]": 1.6032758730000296, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH_CONTEXT]": 1.6520411930000023, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_VARIABLE]": 1.603894294999975, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_invalid_credentials_field[EMPTY_CREDENTIALS]": 0.8242704360000062, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_invalid_credentials_field[INVALID_CREDENTIALS_FIELD]": 0.8054714610000246, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_dynamodb_invalid_param": 0.0019231250000188993, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_dynamodb_put_item_no_such_table": 3.322759299999973, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_invalid_secret_name": 0.7822934949999762, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_no_such_bucket": 0.7145450499999697, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_s3_no_such_key": 0.7783031249999226, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_service_task_lambada_catch_state_all_data_limit_exceeded_on_large_utf8_response": 2.3597563090000335, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_service_task_lambada_data_limit_exceeded_on_large_utf8_response": 2.3607912890000193, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_start_large_input": 4.796172262000084, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_task_lambda_catch_state_all_data_limit_exceeded_on_large_utf8_response": 2.2836558480000235, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_task_lambda_data_limit_exceeded_on_large_utf8_response": 2.3897195730000362, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_no_such_function": 2.4308436389999883, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_no_such_function_catch": 2.4252569010000116, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_custom_exception": 2.292089906000001, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_exception": 2.460956431999989, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_exception_catch": 2.5426769500000432, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_invalid_param": 0.7670283300000165, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_put_item_invalid_table_name": 0.8333409889999643, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_put_item_no_such_table": 0.7583451810000383, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_invoke_timeout": 6.816047728000001, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_no_such_function": 1.840961856999968, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_no_such_function_catch": 1.8704461300000048, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_custom_exception": 2.4264822150000214, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception": 2.2047895949999656, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch": 2.4215069370000037, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[$.Payload]": 2.3673283059999903, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[$.no.such.path]": 2.3282058679999977, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[None]": 3.1659377110000264, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.py::TestTaskServiceSfn::test_start_execution_no_such_arn": 1.037894940000001, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_empty_body": 0.0018017769999687516, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_no_such_queue": 1.377715324999997, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_no_such_queue_no_catch": 1.0765385149999815, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_sqs_failure_in_wait_for_task_tok": 2.733356070999946, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[ITEMS]": 1.3465718539999898, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[ITEMS_DOUBLE_QUOTES]": 1.107220018000021, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[MAX_CONCURRENCY]": 1.1005247840000152, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[TOLERATED_FAILURE_COUNT]": 1.1027006970000457, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[TOLERATED_FAILURE_PERCENTAGE]": 1.0990770319999683, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[ITEMS]": 2.2318607270000257, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[MAX_CONCURRENCY]": 2.2284459820000393, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[TOLERATED_FAILURE_COUNT]": 2.1952385499999423, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[TOLERATED_FAILURE_PERCENTAGE]": 2.2095875189999674, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task[HEARTBEAT_SECONDS]": 2.4980992810000657, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task[TIMEOUT_SECONDS]": 0.0019382850000511098, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task_from_input[HEARTBEAT_SECONDS]": 3.283979819000024, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task_from_input[TIMEOUT_SECONDS]": 0.0017817220000324596, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_base[BASE_PASS_RESULT]": 1.3144713360000537, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_base[BASE_RAISE_FAILURE]": 1.2751362060000133, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_catch": 2.98343294, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_query_runtime_memory": 2.3308225460000926, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_retry": 10.162601367999969, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_base[BASE_PASS_RESULT]": 0.6018137350000075, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_base[BASE_RAISE_FAILURE]": 0.5176827190001063, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_catch": 2.235349595999992, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_query_runtime_memory": 1.392398294999964, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_retry": 9.518081786999971, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_0": 0.47417495700000245, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_2": 3.6662597139999207, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_contains": 3.207171326999969, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_get_item": 0.6790916459999607, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_length": 0.6848478709998744, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_partition": 8.240546040000027, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_range": 1.6391085660000044, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_unique": 0.675856643999964, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py::TestArrayJSONata::test_array_partition": 6.077333998000086, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py::TestArrayJSONata::test_array_range": 1.999990698999909, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py::TestEncodeDecode::test_base_64_decode": 0.9667441670000017, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py::TestEncodeDecode::test_base_64_encode": 0.9847756110000319, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_context_json_path": 0.7224820760000057, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_escape_sequence": 0.4457683000000543, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_format_1": 2.5116429599999037, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_format_2": 2.9935324090000677, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_nested_calls_1": 0.6803017839999939, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_nested_calls_2": 0.6942053579998628, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.py::TestHashCalculations::test_hash": 1.9456632200000286, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_merge": 0.6850081620001447, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_merge_escaped_argument": 0.6971334689999367, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_to_string": 2.8634183789998815, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_string_to_json": 3.4719998020000276, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.py::TestJsonManipulationJSONata::test_parse": 2.087498473999858, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_add": 6.807506750000016, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_random": 1.3580643889998782, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_random_seeded": 0.7297351829998888, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.py::TestMathOperationsJSONata::test_math_random_seeded": 0.0021580640000138374, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py::TestStringOperations::test_string_split": 2.4791937839999036, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py::TestStringOperations::test_string_split_context_object": 0.6677804850000939, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.py::TestUniqueIdGeneration::test_uuid": 1.4820307870000988, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[pass_result.json5_ALL_False]": 0.998255337000046, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[pass_result.json5_ALL_True]": 1.0063819260000173, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[raise_failure.json5_ALL_False]": 1.0014054359999136, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[raise_failure.json5_ALL_True]": 1.0379777800001193, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[wait_seconds_path.json5_ALL_False]": 1.010286930999996, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[wait_seconds_path.json5_ALL_True]": 1.0057918129999734, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_deleted_log_group": 0.9970833500000253, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_log_group_with_multiple_runs": 1.6317852160000257, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_ERROR_False]": 0.7197714320000159, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_ERROR_True]": 0.7217546900000116, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_FATAL_False]": 0.7253661020000663, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_FATAL_True]": 0.7198694840000144, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_OFF_False]": 0.7109153319998995, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_OFF_True]": 0.7155022570000256, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_ERROR_False]": 1.0010752700000012, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_ERROR_True]": 1.0119501069999615, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_FATAL_False]": 0.7954570580000109, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_FATAL_True]": 0.8113759809999692, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_OFF_False]": 0.7552789170000551, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_OFF_True]": 0.7013259110000263, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_ERROR_False]": 1.0107652009999128, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_ERROR_True]": 1.0084796310001138, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_FATAL_False]": 1.0135865869999634, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_FATAL_True]": 0.9770494079999708, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_OFF_False]": 1.7912125009999045, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_OFF_True]": 0.9438085760000376, + "tests/aws/services/stepfunctions/v2/mocking/test_aws_scenarios.py::TestBaseScenarios::test_lambda_sqs_integration_happy_path": 0.42989456499992684, + "tests/aws/services/stepfunctions/v2/mocking/test_aws_scenarios.py::TestBaseScenarios::test_lambda_sqs_integration_hybrid_path": 0.3669824530001051, + "tests/aws/services/stepfunctions/v2/mocking/test_aws_scenarios.py::TestBaseScenarios::test_lambda_sqs_integration_retry_path": 7.22663837999994, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sfn_start_execution_sync[SFN_SYNC2]": 1.7220257889999857, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sfn_start_execution_sync[SFN_SYNC]": 1.7232101889999285, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sqs_wait_for_task_token": 1.5624631519999639, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sqs_wait_for_task_token_task_failure": 1.7049762190000592, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_dynamodb_put_get_item": 0.9977716460000465, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_events_put_events": 0.9562400290001278, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke": 0.9150270070000488, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke_retries": 3.340779224999892, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke": 0.949390562000076, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke_sync_execution": 0.8293090280001252, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_map_state_lambda": 2.359212247999949, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_lambda": 1.2038061430000653, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_sns_publish_base": 0.9425786960000551, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_sqs_send_message": 0.9691656630000125, + "tests/aws/services/stepfunctions/v2/mocking/test_mock_config_file.py::TestMockConfigFile::test_is_mock_config_flag_detected_set": 0.004709639999987303, + "tests/aws/services/stepfunctions/v2/mocking/test_mock_config_file.py::TestMockConfigFile::test_is_mock_config_flag_detected_unset": 0.006456018999983826, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_DIRECT_EXPR]": 0.9549841680001236, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_EMPTY]": 0.889614396999832, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_EXPR]": 1.0169817800000374, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_LITERALS]": 1.0376705190000166, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_lambda[BASE_LAMBDA]": 2.6001813139999967, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[BOOL]": 0.8827991209999482, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[FLOAT]": 0.688300326999979, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[INT]": 0.6892659250000861, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[JSONATA_EXPR]": 0.895088018000024, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[LIST_EMPY]": 0.7206845710001062, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[LIST_RICH]": 0.9035954920000222, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[NULL]": 0.6960282400000324, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[STR_LIT]": 0.6778374269999858, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_task_lambda[BASE_TASK_LAMBDA]": 2.3199001380000936, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_output_in_choice[CONDITION_FALSE]": 0.6950512359999266, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_output_in_choice[CONDITION_TRUE]": 0.9404303080000318, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_base_query_language_field[JSONATA]": 0.45010343000012654, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_base_query_language_field[JSON_PATH]": 0.44744589000004, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_jsonata_query_language_field_downgrade_exception": 0.001987330000019938, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_query_language_field_override[JSONATA_OVERRIDE]": 0.4402096359999632, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_query_language_field_override[JSONATA_OVERRIDE_DEFAULT]": 0.6387934280000991, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_LEGACY_RESOURCE_JSONATA_TO_JSONPATH]": 2.222615226999892, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_LEGACY_RESOURCE_JSONPATH_TO_JSONATA]": 2.222495937999952, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_SDK_RESOURCE_JSONATA_TO_JSONPATH]": 2.220916958999851, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_SDK_RESOURCE_JSONPATH_TO_JSONATA]": 2.2386456769999086, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_output_to_state[JSONATA_OUTPUT_TO_JSONPATH]": 0.8416305189999775, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_output_to_state[JSONPATH_OUTPUT_TO_JSONATA]": 1.7210791940000263, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_task_dataflow_to_state": 2.300100521000104, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_variable_sampling[JSONATA_ASSIGN_JSONPATH_REF]": 0.8524179450000702, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_variable_sampling[JSONPATH_ASSIGN_JSONATA_REF]": 0.838684760000092, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_catch_empty": 2.0757423480000625, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_catch_states_runtime": 2.3876029600000948, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_aws_docs_scenario[CHOICE_STATE_AWS_SCENARIO]": 0.779847683999833, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_aws_docs_scenario[CHOICE_STATE_AWS_SCENARIO_JSONATA]": 0.7469914060002338, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_condition_constant_jsonata": 0.49586971799999446, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE]": 0.7197179480001523, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE_JSONATA]": 0.7026216879999083, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE_LITERAL_JSONATA]": 0.7172579140000153, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_negative[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS]": 0.715119679000054, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_negative[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": 0.6774791009999035, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_positive[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS]": 0.9027840589999414, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_positive[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": 0.7634367919999931, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONATA_COMPARISON_ASSIGN]": 0.7148710230000006, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONATA_COMPARISON_OUTPUT]": 0.7190550219999068, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONPATH]": 0.7124750469999981, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_STRING_LITERALS]": 0.7668642950000049, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_fail_cause_jsonata": 0.6619611830000167, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_fail_error_jsonata": 0.6612164130000338, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_illegal_escapes[ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION]": 0.0017835149999427813, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_illegal_escapes[ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION_2]": 0.0017719539999916378, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_ERRORPATH]": 0.7003735649999498, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_STRING_EXPR_CONTEXTPATH]": 0.6571601889999101, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_STRING_EXPR_JSONPATH]": 0.6971713200000522, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_CAUSEPATH]": 0.6899200109999128, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_HEARTBEATSECONDSPATH]": 0.0015948340000022654, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_INPUTPATH]": 0.6830984910000097, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_OUTPUTPATH]": 0.6692485119999674, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_TIMEOUTSECONDSPATH]": 0.0017567569999528132, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_empty_retry": 2.13356346199987, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke_with_retry_base": 9.549384801999963, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke_with_retry_extended_input": 9.639404133000085, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke_with_retry_extended_input": 9.948011956000073, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_batching_base_json_max_per_batch_jsonata": 0.001854380999930072, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_csv_headers_decl": 0.8279420279999385, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_csv_headers_first_line": 0.8216388949999782, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json": 0.8230287430000089, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_max_items": 0.7898285349999696, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_max_items_jsonata": 1.757611799000074, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[INVALID_ITEMS_PATH]": 1.1107659000001604, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_ITEM_READER]": 1.1251319250000051, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_PREVIOUS]": 1.1458473320000166, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_list_objects_v2": 0.7980860540000094, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_first_row_extra_fields": 0.8099386909999566, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_decl_duplicate_headers": 0.7922998169999573, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_decl_extra_fields": 0.8055924210000285, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_first_row_typed_headers": 0.7770112780000318, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[0]": 0.7849523349999572, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[100000000]": 0.7992133989999957, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[2]": 0.8102652139999691, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[-1]": 0.7922323219999043, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[0]": 0.7997625250000056, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[1.5]": 0.021506852000015897, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[100000000]": 0.8075668440000072, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[100000001]": 1.0245488549999209, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[2]": 0.8080124699999942, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_json_no_json_list_object": 0.803940085000022, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state": 0.819581085999971, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_break_condition": 0.8460003739999138, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_break_condition_legacy": 0.8408662329999288, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch": 0.7535390860000462, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch_empty_fail": 0.7672907280000345, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch_legacy": 0.7760022920000438, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_item_selector": 0.7913092329999927, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_item_selector_parameters": 1.0733225429999038, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_items_path_from_previous": 0.8346928060001346, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_parameters": 0.7989063950000173, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_reentrant": 1.6442169060001106, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_reentrant_lambda": 2.9174723020000783, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_inline_item_selector": 0.7845633699998871, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_inline_parameters": 0.852207351000061, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector[MAP_STATE_ITEM_SELECTOR]": 1.9092864090000603, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector[MAP_STATE_ITEM_SELECTOR_JSONATA]": 0.753142304999983, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector_parameters": 1.0286497059997828, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector_singleton": 1.3126578070000505, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[empty]": 0.6987842519998821, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[mixed]": 1.6113327610000852, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[singleton]": 0.6821663010001657, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[boolean]": 0.7487476300000253, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[function]": 0.0019548070000610096, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[null]": 0.7645098100000496, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[number]": 0.7629414209999368, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[object]": 0.7535066549999101, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[string]": 0.7449569469999915, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[boolean]": 0.771803008999882, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[null]": 0.7750460279999061, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[number]": 0.788661425999976, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[object]": 0.7726528880000387, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[string]": 1.5009154410000747, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[empty]": 0.686478971000156, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[mixed]": 0.7287054009999565, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[singleton]": 0.7008249870001464, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[boolean]": 0.9667607129999851, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[null]": 0.925189299000067, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[number]": 0.9430383090000305, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[object]": 0.9621818390000954, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[string]": 0.930252745000189, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[boolean]": 0.7733566610000935, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[null]": 0.769822764999958, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[number]": 0.776680987000077, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[object]": 0.7789946899999904, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[string]": 0.7788582509999742, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_label": 0.6886462960000017, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy": 0.805254517000094, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed": 0.7968573199998445, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed_item_selector": 0.790882341999918, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed_parameters": 0.8340644240000756, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline": 1.652162575000034, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline_item_selector": 0.8323863050001137, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline_parameters": 0.8565904749999618, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_reentrant": 1.6678457490000937, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested": 0.8785099399999581, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested_config_distributed": 0.8581223020001971, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested_config_distributed_no_max_max_concurrency": 10.860483625000029, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_no_processor_config": 0.7567306510001117, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_parameters_legacy": 1.980591358999959, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_parameters_singleton_legacy": 1.308044371999813, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_result_writer": 1.012002290000055, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry": 4.52254801100014, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry_legacy": 3.717991963999907, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry_multiple_retriers": 7.698763803999896, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[-1]": 0.7049893669999392, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[0]": 0.7109394060000795, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[1]": 0.709907009999938, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[NoNumber]": 0.7165135210001381, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[tolerated_failure_count_value0]": 0.6835508369998706, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[-1.1]": 0.7095147379998252, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[-1]": 0.6826312800001233, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[0]": 0.7294024690000924, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[1.1]": 0.691483135999988, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[100.1]": 0.7112506999999368, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[100]": 0.7034129740000026, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[1]": 0.7425394960000631, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[NoNumber]": 0.7140869689999363, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[tolerated_failure_percentage_value0]": 0.7125602840001193, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_values[count_literal]": 0.700274484999909, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_values[percentage_literal]": 0.7035602409998774, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[0]": 0.6857358580000437, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[1]": 0.6892366759999504, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[NoNumber]": 0.6632936199998767, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[max_concurrency_value0]": 0.7168398289999232, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path_negative": 0.7763080400000035, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state[PARALLEL_STATE]": 0.8052484840000034, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state[PARALLEL_STATE_PARAMETERS]": 0.7583163770000283, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_catch": 0.7078042470000128, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_fail": 0.5324258179999788, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_nested": 0.9977190710000059, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_order": 0.8165190849999817, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_retry": 3.621605466000119, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features": 4.240869398999962, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features_jitter_none": 4.441330146000041, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features_max_attempts_zero": 2.335554794000018, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_seconds_jsonata": 0.4613009570000486, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp[NANOSECONDS]": 0.4512630569998919, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp[SECONDS]": 1.35777501400014, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_DATE]": 0.40580544500016913, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_ISO]": 0.41160655400017276, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_TIME]": 0.4061650510000163, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[JSONATA]": 0.41421622999985175, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[NO_T]": 0.4227252950000775, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[NO_Z]": 0.401756176000049, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_DATE]": 0.0017534609999074746, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_ISO]": 0.0017542309998361816, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_TIME]": 0.0017845879999640601, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NANOSECONDS]": 0.6537537839999459, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NO_T]": 0.001763689000085833, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NO_Z]": 0.001747098000009828, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[SECONDS]": 0.6605004030000146, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_DATE]": 0.6935427880000589, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_ISO]": 0.64727527499997, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_TIME]": 0.7373158099999273, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NANOSECONDS]": 0.6940160560000095, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NO_T]": 0.6849085180000429, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NO_Z]": 0.48893706999990627, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[SECONDS]": 0.6999829699998372, + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_path_based_on_data": 6.397225202999948, + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_step_functions_calling_api_gateway": 11.397811037999986, + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_wait_for_callback": 19.590867753999873, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_base": 2.9955853489999527, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_error": 2.7195412360000546, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[HelloWorld]": 3.03086947099996, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[None]": 3.0400981070000626, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[]": 2.9885273759998654, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[request_body3]": 3.0524723719998974, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[custom_header1]": 4.085523456000033, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[custom_header2]": 3.048816011000099, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[singleStringHeader]": 0.0030492549998371032, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_query_parameters": 3.4116910290000533, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_delete_item": 0.9864020889999665, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_get_item": 1.1284898199999134, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_update_get_item": 1.3404560799999672, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_list_secrets": 0.9294702930000085, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[binary]": 1.0854392920000464, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[bytearray]": 1.0974341249999497, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[empty_binary]": 1.1380771200000481, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[empty_str]": 1.159153058999891, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[str]": 1.066789176000043, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[bool]": 1.14153302800014, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[dict]": 1.1551140980000127, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[list]": 2.3945919840000442, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[num]": 1.188813573999937, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[str]": 1.1513815199999726, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_send_task_outcome_with_no_such_token[state_machine_template0]": 0.9334489699999722, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_send_task_outcome_with_no_such_token[state_machine_template1]": 0.9372429140000804, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_start_execution": 0.9985067530000151, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_start_execution_implicit_json_serialisation": 1.0079264870001907, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_DELETE_ITEM]": 1.2789913940000588, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_GET_ITEM]": 1.2289150139999947, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_QUERY]": 1.2570946210000784, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_UPDATE_GET_ITEM]": 1.6176261929999782, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_invalid_integration": 0.577723138000124, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task": 0.0019267240000999664, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_raise_failure": 0.0017757210000581836, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_sync": 0.0018563520000043354, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_sync_raise_failure": 0.0018338500000254498, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_base": 2.028847136999957, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_malformed_detail": 0.9016878250000673, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_mixed_malformed_detail": 0.9900200229999427, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_no_source": 31.008931994999898, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_bytes_payload": 2.063031224999918, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0.0]": 2.1149553510000487, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0_0]": 2.0556827930000736, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0_1]": 2.0568516579998004, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[HelloWorld]": 2.0247143689999803, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[True]": 2.0435847239999703, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[json_value5]": 2.0474390850000646, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[json_value6]": 2.0937964239999474, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_pipe": 3.671816361000083, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_string_payload": 2.0251886030000605, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_lambda_task_filter_parameters_input": 2.12621185699993, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke": 3.5816846650000116, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_bytes_payload": 2.50494870600005, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0.0]": 2.5228463550000697, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0_0]": 2.509149897000043, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0_1]": 2.5284124640000982, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[HelloWorld]": 2.48911896900006, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[True]": 2.492393434000178, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[json_value5]": 2.515985006000051, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[json_value6]": 2.5625799570000254, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_unsupported_param": 2.5292429790000597, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_list_functions": 0.002096830000027694, + "tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py::TestTaskServiceSfn::test_start_execution": 1.0109025780000138, + "tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py::TestTaskServiceSfn::test_start_execution_input_json": 1.040150487999881, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_fifo_message_attribute[input_params0-True]": 1.2359824780003237, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_fifo_message_attribute[input_params1-False]": 0.9524391729999024, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[1]": 0.8906811589997687, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[HelloWorld]": 0.9345146069999828, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[None]": 0.9149203189999753, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[True]": 0.9078963760000534, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[]": 0.9619701499998428, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[message1]": 0.9312344529998882, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base_error_topic_arn": 0.9428704630001903, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[\"HelloWorld\"]": 2.234957452999879, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[HelloWorld]": 1.1247440929998902, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[message_value3]": 1.0742752799997106, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[{}]": 1.072550260999833, + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message": 1.1171851199997036, + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message_attributes": 1.1468574259997695, + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message_unsupported_parameters": 1.0712639749999653, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_catch_error_variable_sampling[TASK_CATCH_ERROR_VARIABLE_SAMPLING]": 2.284389454999882, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_catch_error_variable_sampling[TASK_CATCH_ERROR_VARIABLE_SAMPLING_TO_JSONPATH]": 3.495701100999895, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_OUTPUT]": 0.0027904560001843493, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_OUTPUT_WITH_RETRY]": 0.001899331999993592, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_VARIABLE_SAMPLING]": 0.001907587000005151, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_OUTPUT]": 0.0017096770000080141, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_OUTPUT_WITH_RETRY]": 0.001751124000065829, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_VARIABLE_SAMPLING]": 0.0018400999999812484, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_output[TASK_CATCH_ERROR_OUTPUT]": 2.2794532020000133, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_output[TASK_CATCH_ERROR_OUTPUT_TO_JSONPATH]": 2.2775014440001087, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_with_retry[TASK_CATCH_ERROR_OUTPUT_WITH_RETRY]": 3.56064378200017, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_with_retry[TASK_CATCH_ERROR_OUTPUT_WITH_RETRY_TO_JSONPATH]": 3.52841641100008, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_create_describe[dump]": 1.4815840229998685, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_create_describe[dumps]": 1.4895683499998995, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_string_create_describe[dump]": 1.4801428460000352, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_string_create_describe[dumps]": 1.4839042299995526, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_delete_invalid_sm": 0.5587203119998776, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_delete_valid_sm": 1.5607782820000011, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_duplicate_definition_format_sm": 0.4438962619999529, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_duplicate_sm_name": 0.4404979649998495, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_exact_duplicate_sm": 0.5331606180002382, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_definition": 0.5041620080000939, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_definition_and_role": 0.6225898540001253, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_role_arn": 0.6128592519999074, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_update_none": 0.4590780179999001, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_same_parameters": 0.5608002579997446, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_delete_nonexistent_sm": 0.4229455600000165, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution": 0.7340801870000178, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_arn_containing_punctuation": 1.895308127999897, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_invalid_arn": 0.4211252790000799, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_no_such_state_machine": 0.7278816259999985, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_invalid_arn_sm": 0.4249557079999704, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_nonexistent_sm": 0.429683298999862, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_sm_arn_containing_punctuation": 0.43426738900006967, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_state_machine_for_execution": 0.7131116110001585, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_invalid_arn": 0.41550556699985464, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_no_such_execution": 0.4718798310002512, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_reversed": 0.5399221570000918, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_invalid_start_execution_arn": 0.47758628700012196, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_invalid_start_execution_input": 0.7723481109999284, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_execution_invalid_arn": 0.41682884799979547, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_execution_no_such_state_machine": 0.43787986999996065, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_executions_pagination": 1.7233896389998336, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_executions_versions_pagination": 2.845546381000304, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_sms": 0.5413942569998653, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_sms_pagination": 0.8840717989999121, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_execution": 0.5934464520000802, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_execution_idempotent": 1.1258412639999733, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_sync_execution": 0.4536565440000686, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_state_machine_status_filter": 0.8166807700001755, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_stop_execution": 0.5280075769999257, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[\\x00activity]": 0.3389150379998682, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity name]": 0.35876297999993767, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\"name]": 0.3370844840001155, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity#name]": 0.33648348799988526, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity$name]": 0.33924911100007193, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity%name]": 0.3443260410003859, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity&name]": 0.3410857369997302, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity*name]": 0.3452924919997713, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity,name]": 0.3380723699997361, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity/name]": 0.3416569350001737, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity:name]": 0.3437918379997882, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity;name]": 0.34107409399985045, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity<name]": 0.3338634629997159, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity>name]": 0.3359390230000372, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity?name]": 0.33733432199983326, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity[name]": 0.3390754890001517, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\\\name]": 0.3429689030001555, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\x1f]": 0.3413359619999028, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\x7f]": 0.3384880399996746, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity]name]": 0.3383667810001043, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity^name]": 0.34289657499994064, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity`name]": 0.33841489299993555, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity{name]": 0.33395739799971125, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity|name]": 0.3404211739998573, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity}name]": 0.3358119669999269, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity~name]": 0.3358754739997494, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[ACTIVITY_NAME_ABC]": 0.4114520599998741, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[Activity1]": 0.4063136790000499, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]": 0.4237922399997842, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity-name.1]": 0.41539400100009516, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity-name_123]": 0.4070558680000431, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity.name.v2]": 0.42165062499998385, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity.name]": 0.41060388599998987, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activityName.with.dots]": 0.41991113200015207, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity_123.name]": 0.4107109789997594, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_describe_activity_invalid_arn": 0.42191968400015867, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_describe_deleted_activity": 0.36266206400000556, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_get_activity_task_deleted": 0.35691779799981305, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_get_activity_task_invalid_arn": 0.43205298299994865, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_list_activities": 0.3894902080000975, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_create_alias_single_router_config": 0.6911751879999883, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_delete_list": 0.8351259619998928, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_invoke_describe_list": 0.8496771829998124, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_update_describe": 0.7559092070000588, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_no_such_alias_arn": 0.7458462149998013, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_revision_with_alias": 0.694284596999978, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_version_with_alias": 0.7267470739998316, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_invalid_name": 0.7000303140000597, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_invalid_router_configs": 0.7310111619999589, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_not_idempotent": 0.7152012529998046, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_with_state_machine_arn": 0.683119433000229, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_idempotent_create_alias": 1.9530064230000335, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_invalid_next_token": 0.707369227000072, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results[0]": 0.7910726399995838, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results[1]": 0.8064809599998171, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_update_no_such_alias_arn": 0.7063752999999906, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_create_describe_delete": 0.7647490140002446, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_activity_task": 0.914557552999895, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_callbacks[SYNC]": 0.8911468290000357, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_callbacks[WAIT_FOR_TASK_TOKEN]": 0.9154476649998742, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_start_async_describe_history_execution": 1.3852232379999805, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_start_sync_execution": 0.7955915319998894, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_deleted_log_group": 0.5098920640000415, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_incomplete_logging_configuration[logging_configuration0]": 0.4485659439997107, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_incomplete_logging_configuration[logging_configuration1]": 0.4430130040000222, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration0]": 0.398403684999721, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration1]": 0.399038998999913, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration2]": 0.39976912799988895, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ALL-False]": 0.4701010420001239, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ALL-True]": 0.4864735240000755, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ERROR-False]": 0.5354216949999682, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ERROR-True]": 0.47868891100006294, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[FATAL-False]": 0.4806218470000658, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[FATAL-True]": 0.4887028920004468, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[OFF-False]": 0.47605741400002444, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[OFF-True]": 0.475419378999959, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_multiple_destinations": 0.4521561159999692, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_update_logging_configuration": 1.7943260749998444, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_list_map_runs_and_describe_map_run": 0.8486460459998852, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_empty_fail": 0.34684389499989265, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[ ]": 0.3413571050000428, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\"]": 0.32088011800010463, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[#]": 0.3250530410002739, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[$]": 0.3239804429999822, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[%]": 0.32518490999996175, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[&]": 0.3222675139998046, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[*]": 0.3296964239996214, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[,]": 0.3299801690000095, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[:]": 0.33626083500007553, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[;]": 0.3235918559998936, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[<]": 0.32847937999986243, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[>]": 0.3265827579998586, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[?]": 0.3279778310002257, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[[]": 0.3410170460001609, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\\\]": 0.3271184880002238, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\n]": 0.3321672040001431, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\r]": 0.32894806400008747, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\t]": 0.3282310399999915, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x00]": 0.3258154899999681, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x01]": 0.32531574000017827, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x02]": 0.33082467900021584, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x03]": 0.3281101350000881, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x04]": 0.32742342700021254, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x05]": 0.3269184780001524, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x06]": 0.32453608800005895, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x07]": 0.32597672699989744, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x08]": 0.32968034300006366, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0b]": 0.3271136609998848, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0c]": 0.3240375730001688, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0e]": 0.32885056100008114, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0f]": 0.32835943200007023, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x10]": 0.33190535800008547, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x11]": 0.3371905930000594, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x12]": 0.3544236179998279, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x13]": 0.33132125699989956, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x14]": 0.3320923490000496, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x15]": 0.3246423040002355, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x16]": 0.3275199650001923, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x17]": 0.3394696319999184, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x18]": 0.3265617240001575, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x19]": 0.32626942999991115, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1a]": 0.32973674100003336, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1b]": 0.33027967300017735, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1c]": 0.33031535500003883, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1d]": 0.33244451900009153, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1e]": 0.33309187400004703, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1f]": 0.3303883969999788, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x7f]": 0.333045213999867, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x80]": 0.32697609199999533, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x81]": 0.33379805799995665, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x82]": 0.3498903610002344, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x83]": 0.3351786089997404, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x84]": 0.3263143230001333, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x85]": 0.3327412659998572, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x86]": 0.3334438929998669, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x87]": 1.5312017169999308, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x88]": 0.3210110069999246, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x89]": 0.32561847800002397, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8a]": 0.32332312199991975, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8b]": 0.3246128939999835, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8c]": 0.33803340899999057, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8d]": 0.37181417599958877, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8e]": 0.32271031599975686, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8f]": 0.3221501860002718, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x90]": 0.3285164240000995, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x91]": 0.33644636199983324, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x92]": 0.3234084169998823, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x93]": 0.3290148810001483, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x94]": 0.3297526320000088, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x95]": 0.3221491619999597, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x96]": 0.3257365539998318, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x97]": 0.32633882499999345, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x98]": 0.32342142800007423, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x99]": 0.322541339000054, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9a]": 0.32604189300013786, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9b]": 0.32799611600012213, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9c]": 0.32561064200035617, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9d]": 0.32712014499998077, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9e]": 0.3242601060001107, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9f]": 0.32478709300016817, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[]]": 0.3549811279997357, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[^]": 0.3344453119998434, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[`]": 0.32844328199985284, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[{]": 0.3286596970001483, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[|]": 0.33020460500029003, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[}]": 0.3274862549999398, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[~]": 0.32512895699983346, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_too_long_fail": 0.34042027100008454, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_create_state_machine": 0.35719300799996745, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[None]": 0.3433994580000217, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list1]": 0.3460811429997648, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list2]": 0.34608605299990813, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list3]": 0.34997660699991684, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list0]": 0.3686806250000245, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list1]": 0.3594021689998499, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list2]": 0.36508042099990234, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list3]": 0.3598952189997817, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list4]": 0.36457554299977346, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine_version": 0.37595672199995533, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys0]": 0.4050373049999507, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys1]": 0.36674198800028535, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys2]": 0.3694483499998569, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys3]": 0.37154390799992143, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[EMPTY_DICT]": 0.34352941099996315, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[EMPTY_STRING]": 0.3399476570000388, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[NOT_A_DEF]": 0.34436999500007914, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[ILLEGAL_WFTT]": 0.35396685399996386, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[INVALID_BASE_NO_STARTAT]": 0.34037100799992004, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[VALID_BASE_PASS]": 0.33357084399972337, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_standard[INVALID_BASE_NO_STARTAT]": 0.340517797000075, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_standard[VALID_BASE_PASS]": 0.3426629060002142, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_INTRINSIC_FUNCTION]": 2.066036771999734, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_PARAMETERS]": 0.9011115660000542, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_RESULT]": 0.8421264399999018, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_EVALUATION_ORDER_PASS_STATE]": 0.9268872759996611, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_CHOICE]": 0.9766749249997702, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_FAIL]": 0.8671612899997854, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_INPUTPATH]": 0.8809260700002142, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_INTRINSIC_FUNCTION]": 2.4498498919999747, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE]": 1.5962050880000334, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_OUTPUTPATH]": 0.9305147039999611, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_PARAMETERS]": 0.9043679999997494, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_WAIT]": 0.9090189990001818, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION]": 1.2055034330001035, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_ITEMS_PATH]": 1.2588669660001415, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_ITEM_SELECTOR]": 1.1418684259999736, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH]": 0.8962754719998429, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH]": 0.9674165119997724, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH]": 0.9213006160000532, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_jsonata_template[CHOICE_CONDITION_CONSTANT_JSONATA]": 0.5330749569998261, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_jsonata_template[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": 0.5529085300001952, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_express_with_publish": 0.40781699199988, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_publish_describe_no_version_description": 0.45628577799993764, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_publish_describe_with_version_description": 0.4541499859999476, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_with_publish": 0.4267770520002614, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_with_version_description_no_publish": 0.403634534000048, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_describe_state_machine_for_execution_of_version": 0.5088450189998639, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_describe_state_machine_for_execution_of_version_with_revision": 0.5200427799998124, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_empty_revision_with_publish_and_no_publish_on_creation": 0.4476839070000551, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_empty_revision_with_publish_and_publish_on_creation": 0.4601777590000893, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_idempotent_publish": 0.45513325900014934, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_list_delete_version": 0.48298622600032104, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_list_state_machine_versions_pagination": 0.8877154889999019, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version": 0.5511433350000061, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version_invalid_arn": 0.4149735830001191, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version_no_such_machine": 0.4331624040000861, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_start_version_execution": 0.8321363099998962, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_update_state_machine": 0.4953909719999956, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_version_ids_between_deletions": 0.4729205829996772, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_CHOICE_STATE]": 0.9758676430003561, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_FAIL_STATE]": 0.8161885699998948, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_PASS_STATE]": 0.8274042749999353, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_RESULT_PASS_STATE]": 0.8595386260001305, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_SUCCEED_STATE]": 0.8081798659998185, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[IO_PASS_STATE]": 0.9085055300001841, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[IO_RESULT_PASS_STATE]": 0.920266830999708, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_CHOICE_STATE]": 0.6679897489998439, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_FAIL_STATE]": 0.4884387440001774, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_PASS_STATE]": 0.5210119239998221, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_RESULT_PASS_STATE]": 0.5144390389998534, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_SUCCEED_STATE]": 1.6637656010000228, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[IO_PASS_STATE]": 0.6044787109999561, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[IO_RESULT_PASS_STATE]": 0.5974511680001342, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_CHOICE_STATE]": 1.0205980109999473, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_FAIL_STATE]": 0.8107255429999896, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_PASS_STATE]": 0.8068977529997028, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_RESULT_PASS_STATE]": 0.8136454610000783, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_SUCCEED_STATE]": 0.8235590780000166, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[IO_PASS_STATE]": 0.9077844600001299, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[IO_RESULT_PASS_STATE]": 0.9122443769997517, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[DEBUG]": 2.40678171400009, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[INFO]": 2.39312528299979, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[TRACE]": 2.401771804999953, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[DEBUG]": 2.379632098999764, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[INFO]": 2.378426283999943, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[TRACE]": 2.3799031010000817, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::TestStateMachine::test_create_choice_state_machine": 3.988727494000159, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::TestStateMachine::test_create_run_map_state_machine": 1.160958612000286, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::TestStateMachine::test_create_run_state_machine": 1.5510584529995413, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::TestStateMachine::test_create_state_machines_in_parallel": 1.8738574419999168, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::TestStateMachine::test_events_state_machine": 0.0018517629998768825, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::TestStateMachine::test_intrinsic_functions": 1.235324023000203, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::TestStateMachine::test_try_catch_state_machine": 10.150347214000021, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::test_aws_sdk_task": 1.2106949270000769, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::test_default_logging_configuration": 0.06757723100008661, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::test_multiregion_nested[statemachine_definition0-eu-central-1]": 0.0017627960000936582, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::test_multiregion_nested[statemachine_definition0-eu-west-1]": 0.0016403459997036407, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::test_multiregion_nested[statemachine_definition0-us-east-1]": 0.0019984649998150417, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::test_multiregion_nested[statemachine_definition0-us-east-2]": 0.0016599130001395679, + "tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py::test_run_aws_sdk_secrets_manager": 3.3417508019999786, + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_no_timeout": 5.994449399000132, + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_path_timeout": 6.198517898999853, + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_timeout": 6.109951656000021, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_lambda": 6.894155072000103, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_service_lambda": 6.852017531000001, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_service_lambda_with_path": 7.0650439430000915, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_global_timeout": 5.5959590180000305, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_service_lambda_map_timeout": 0.0039377110001623805, + "tests/aws/services/sts/test_sts.py::TestSTSAssumeRoleTagging::test_assume_role_tag_validation": 0.16559362299994973, + "tests/aws/services/sts/test_sts.py::TestSTSAssumeRoleTagging::test_iam_role_chaining_override_transitive_tags": 0.22754587200006426, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_non_existent_role": 0.0186742299999878, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_role": 0.24685753800031307, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_role_with_saml": 0.05329433599990807, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_role_with_web_identity": 0.04907957099999294, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_expiration_date_format": 0.0207340029999159, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_caller_identity_role_access_key[False]": 0.09128128200018182, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_caller_identity_role_access_key[True]": 0.09230722000006608, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_caller_identity_root": 0.016829451999910816, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_caller_identity_user_access_key[False]": 0.06986664199985171, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_caller_identity_user_access_key[True]": 0.21712195300005988, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_federation_token": 0.13912097199977325, + "tests/aws/services/support/test_support.py::TestConfigService::test_support_case_lifecycle": 0.06849215300007927, + "tests/aws/services/swf/test_swf.py::TestSwf::test_run_workflow": 0.18720082400022875, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_failing_deletion": 0.1371174730002167, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_failing_start_transcription_job": 0.4754534879998573, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_get_transcription_job": 0.4818586520000281, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_list_transcription_jobs": 4.422854875999974, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_error_invalid_length": 33.24437079199993, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_error_speaker_labels": 0.0017114890001721506, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_happy_path": 3.1467155769998953, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_speaker_diarization": 0.0018758070000330918, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[None-None]": 2.382481632000008, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-2-None]": 5.0111458260000745, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-3-test-output]": 4.981071361000204, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-4-test-output.json]": 3.0274327150000317, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-5-test-files/test-output.json]": 2.969176392999998, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-6-test-files/test-output]": 4.951191982999944, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job_same_name": 2.285088588000008, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-gb.amr-hello my name is]": 2.1622178519999125, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-gb.flac-hello my name is]": 2.161767103999864, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-gb.mp3-hello my name is]": 4.665412912999955, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-gb.mp4-hello my name is]": 2.166329664000159, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-gb.ogg-hello my name is]": 2.6754738390000057, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-gb.webm-hello my name is]": 2.161084437999989, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-us_video.mkv-one of the most vital]": 2.1618564929999593, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_supported_media_formats[../../files/en-us_video.mp4-one of the most vital]": 2.1905418379999446, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_unsupported_media_format_failure": 3.1827230619999227, + "tests/aws/test_error_injection.py::TestErrorInjection::test_dynamodb_error_injection": 25.733884051000132, + "tests/aws/test_error_injection.py::TestErrorInjection::test_dynamodb_read_error_injection": 25.721735740999748, + "tests/aws/test_error_injection.py::TestErrorInjection::test_dynamodb_write_error_injection": 51.349487702999795, + "tests/aws/test_error_injection.py::TestErrorInjection::test_kinesis_error_injection": 2.0667036649999773, + "tests/aws/test_integration.py::TestIntegration::test_firehose_extended_s3": 0.19402966199982075, + "tests/aws/test_integration.py::TestIntegration::test_firehose_kinesis_to_s3": 39.55232836599998, + "tests/aws/test_integration.py::TestIntegration::test_firehose_s3": 0.3504830370000036, + "tests/aws/test_integration.py::TestIntegration::test_lambda_streams_batch_and_transactions": 41.55390902299996, + "tests/aws/test_integration.py::TestIntegration::test_scheduled_lambda": 6.204941354000084, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_put_item_to_dynamodb[python3.10]": 1.8779431529999329, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_put_item_to_dynamodb[python3.11]": 1.861379849000059, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_put_item_to_dynamodb[python3.12]": 1.8903420469998764, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_put_item_to_dynamodb[python3.13]": 1.8803573299999243, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_put_item_to_dynamodb[python3.8]": 1.9424699109999892, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_put_item_to_dynamodb[python3.9]": 1.8734911540000212, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_send_message_to_sqs[python3.10]": 7.829105158000175, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_send_message_to_sqs[python3.11]": 7.77104674799989, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_send_message_to_sqs[python3.12]": 1.7672955929999716, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_send_message_to_sqs[python3.13]": 15.851045397000235, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_send_message_to_sqs[python3.8]": 15.813660433999985, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_send_message_to_sqs[python3.9]": 1.809717424999917, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_start_stepfunctions_execution[python3.10]": 3.9275238970001283, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_start_stepfunctions_execution[python3.11]": 3.931963920999806, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_start_stepfunctions_execution[python3.12]": 3.9199773269999696, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_start_stepfunctions_execution[python3.13]": 3.907474767999929, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_start_stepfunctions_execution[python3.8]": 3.9402414050000516, + "tests/aws/test_integration.py::TestLambdaOutgoingSdkCalls::test_lambda_start_stepfunctions_execution[python3.9]": 3.9124237250000533, + "tests/aws/test_integration.py::test_kinesis_lambda_forward_chain": 0.003315785000040705, + "tests/aws/test_moto.py::test_call_include_response_metadata": 0.007413605999772699, + "tests/aws/test_moto.py::test_call_multi_region_backends": 0.017886380999925677, + "tests/aws/test_moto.py::test_call_non_implemented_operation": 0.04351320999990094, + "tests/aws/test_moto.py::test_call_s3_with_streaming_trait[IO[bytes]]": 0.025206423000099676, + "tests/aws/test_moto.py::test_call_s3_with_streaming_trait[bytes]": 0.021419778999870687, + "tests/aws/test_moto.py::test_call_s3_with_streaming_trait[str]": 0.05104993500026467, + "tests/aws/test_moto.py::test_call_sqs_invalid_call_raises_http_exception": 0.008053254000060406, + "tests/aws/test_moto.py::test_call_with_es_creates_state_correctly": 0.06393011299996942, + "tests/aws/test_moto.py::test_call_with_modified_request": 0.01102967799988619, + "tests/aws/test_moto.py::test_call_with_sns_with_full_uri": 0.005301119000023391, + "tests/aws/test_moto.py::test_call_with_sqs_creates_state_correctly": 3.5696953580002173, + "tests/aws/test_moto.py::test_call_with_sqs_invalid_call_raises_exception": 0.007393988000103491, + "tests/aws/test_moto.py::test_call_with_sqs_modifies_state_in_moto_backend": 0.009535218999872086, + "tests/aws/test_moto.py::test_call_with_sqs_returns_service_response": 0.006871298999840292, + "tests/aws/test_moto.py::test_moto_fallback_dispatcher": 0.011215466999829005, + "tests/aws/test_moto.py::test_moto_fallback_dispatcher_error_handling": 0.03453630500007421, + "tests/aws/test_moto.py::test_request_with_response_header_location_fields": 0.10320277400001032, + "tests/aws/test_multi_accounts.py::TestMultiAccounts::test_account_id_namespacing_for_localstack_backends": 0.1588861079999333, + "tests/aws/test_multi_accounts.py::TestMultiAccounts::test_account_id_namespacing_for_moto_backends": 1.686497204000034, + "tests/aws/test_multi_accounts.py::TestMultiAccounts::test_multi_accounts_dynamodb": 0.27847857299980205, + "tests/aws/test_multi_accounts.py::TestMultiAccounts::test_multi_accounts_kinesis": 1.4830951399999321, + "tests/aws/test_multiregion.py::TestMultiRegion::test_multi_region_api_gateway": 0.4300387180001053, + "tests/aws/test_multiregion.py::TestMultiRegion::test_multi_region_sns": 0.07013101300003655, + "tests/aws/test_network_configuration.py::TestLambda::test_function_url": 1.159327340000118, + "tests/aws/test_network_configuration.py::TestLambda::test_http_api_for_function_url": 0.0019428480002261495, + "tests/aws/test_network_configuration.py::TestOpenSearch::test_default_strategy": 10.25090428700014, + "tests/aws/test_network_configuration.py::TestOpenSearch::test_path_strategy": 10.68757030200004, + "tests/aws/test_network_configuration.py::TestOpenSearch::test_port_strategy": 10.467708148999918, + "tests/aws/test_network_configuration.py::TestS3::test_201_response": 0.09010279499989338, + "tests/aws/test_network_configuration.py::TestS3::test_multipart_upload": 0.12009285099998124, + "tests/aws/test_network_configuration.py::TestS3::test_non_us_east_1_location": 0.07768254500001603, + "tests/aws/test_network_configuration.py::TestSQS::test_domain_based_strategies[domain]": 0.021290008000050875, + "tests/aws/test_network_configuration.py::TestSQS::test_domain_based_strategies[standard]": 0.024970439999833616, + "tests/aws/test_network_configuration.py::TestSQS::test_off_strategy_with_external_port": 0.020647534000090673, + "tests/aws/test_network_configuration.py::TestSQS::test_off_strategy_without_external_port": 0.02268692699976782, + "tests/aws/test_network_configuration.py::TestSQS::test_path_strategy": 0.02072189399996205, + "tests/aws/test_notifications.py::TestNotifications::test_sns_to_sqs": 0.15411809499983065, + "tests/aws/test_notifications.py::TestNotifications::test_sqs_queue_names": 0.021744305999845892, + "tests/aws/test_serverless.py::TestServerless::test_apigateway_deployed": 0.03336402199988697, + "tests/aws/test_serverless.py::TestServerless::test_dynamodb_stream_handler_deployed": 0.0394248370000696, + "tests/aws/test_serverless.py::TestServerless::test_event_rules_deployed": 99.53009074600004, + "tests/aws/test_serverless.py::TestServerless::test_kinesis_stream_handler_deployed": 0.0018530179997924279, + "tests/aws/test_serverless.py::TestServerless::test_lambda_with_configs_deployed": 0.01954193899996426, + "tests/aws/test_serverless.py::TestServerless::test_queue_handler_deployed": 0.0352729079997971, + "tests/aws/test_serverless.py::TestServerless::test_s3_bucket_deployed": 22.648420071000146, + "tests/aws/test_terraform.py::TestTerraform::test_acm": 0.0018343319998166407, + "tests/aws/test_terraform.py::TestTerraform::test_apigateway": 0.0018153869998513983, + "tests/aws/test_terraform.py::TestTerraform::test_apigateway_escaped_policy": 0.0017592210001566855, + "tests/aws/test_terraform.py::TestTerraform::test_bucket_exists": 0.004861798000092676, + "tests/aws/test_terraform.py::TestTerraform::test_dynamodb": 0.0017788090001431556, + "tests/aws/test_terraform.py::TestTerraform::test_event_source_mapping": 0.0018187639998359373, + "tests/aws/test_terraform.py::TestTerraform::test_lambda": 0.001744885000107388, + "tests/aws/test_terraform.py::TestTerraform::test_route53": 0.0017445150001549337, + "tests/aws/test_terraform.py::TestTerraform::test_security_groups": 0.0017493430000286025, + "tests/aws/test_terraform.py::TestTerraform::test_sqs": 0.0018370569998751307, + "tests/aws/test_validate.py::TestMissingParameter::test_elasticache": 0.0017946890002349392, + "tests/aws/test_validate.py::TestMissingParameter::test_opensearch": 0.0017385730000114563, + "tests/aws/test_validate.py::TestMissingParameter::test_sns": 0.0017343559998153069, + "tests/aws/test_validate.py::TestMissingParameter::test_sqs_create_queue": 0.0018129719999251392, + "tests/aws/test_validate.py::TestMissingParameter::test_sqs_send_message": 0.0018621550000261777, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_container_starts_non_root": 0.0017813040001328773, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_custom_docker_flags": 0.0017338650000056077, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_logs": 0.0017396559999269812, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_pulling_image_message": 0.0017686600001525221, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_restart": 0.001760785999977088, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_start_already_running": 0.0018234939998365007, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_start_cli_within_container": 0.0018741469998531102, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_start_wait_stop": 0.0018491500002255634, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_status_services": 0.00177234599982512, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_volume_dir_mounted_correctly": 0.0017771249997622363, + "tests/cli/test_cli.py::TestCliContainerLifecycle::test_wait_timeout_raises_exception": 0.001886471000034362, + "tests/cli/test_cli.py::TestDNSServer::test_dns_port_not_published_by_default": 0.0018739780000487372, + "tests/cli/test_cli.py::TestDNSServer::test_dns_port_published_with_flag": 0.0018557219998456276, + "tests/cli/test_cli.py::TestHooks::test_prepare_host_hook_called_with_correct_dirs": 0.5663967779998984, + "tests/cli/test_cli.py::TestImports::test_import_venv": 0.006796589999794378, + "tests/integration/aws/test_app.py::TestExceptionHandlers::test_404_unfortunately_detected_as_s3_request": 0.0371446389999619, + "tests/integration/aws/test_app.py::TestExceptionHandlers::test_internal_failure_handler_http_errors": 0.02024471599975186, + "tests/integration/aws/test_app.py::TestExceptionHandlers::test_router_handler_get_http_errors": 0.0018143350000627834, + "tests/integration/aws/test_app.py::TestExceptionHandlers::test_router_handler_get_unexpected_errors": 0.001855641000020114, + "tests/integration/aws/test_app.py::TestExceptionHandlers::test_router_handler_patch_http_errors": 0.11661765700000615, + "tests/integration/aws/test_app.py::TestHTTP2Support::test_http2_http": 0.11512412299975949, + "tests/integration/aws/test_app.py::TestHTTP2Support::test_http2_https": 0.10054100100001051, + "tests/integration/aws/test_app.py::TestHTTP2Support::test_http2_https_localhost": 0.06373853900004178, + "tests/integration/aws/test_app.py::TestHttps::test_default_cert_works": 0.06979465700010223, + "tests/integration/aws/test_app.py::TestWebSocketIntegration::test_return_response": 0.0029861989999062644, + "tests/integration/aws/test_app.py::TestWebSocketIntegration::test_ssl_websockets": 0.0032003690000692586, + "tests/integration/aws/test_app.py::TestWebSocketIntegration::test_websocket_reject_through_edge_router": 0.003164523000123154, + "tests/integration/aws/test_app.py::TestWebSocketIntegration::test_websockets_served_through_edge_router": 0.00314615699994647, + "tests/integration/aws/test_app.py::TestWerkzeugIntegration::test_chunked_request_streaming": 0.11618684200016105, + "tests/integration/aws/test_app.py::TestWerkzeugIntegration::test_chunked_response_streaming": 0.12722986900007527, + "tests/integration/aws/test_app.py::TestWerkzeugIntegration::test_raw_header_handling": 0.10808157400037999, + "tests/integration/aws/test_app.py::TestWerkzeugIntegration::test_response_close_handlers_called_with_router": 0.11196197499998561, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[CmdDockerClient-False-False]": 0.0018279910000273958, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[CmdDockerClient-False-True]": 0.0017667959998561855, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[CmdDockerClient-True-False]": 0.0017781480000849115, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[CmdDockerClient-True-True]": 0.0018504639999719075, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[SdkDockerClient-False-False]": 2.993281944000273, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[SdkDockerClient-False-True]": 3.0005114819998653, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[SdkDockerClient-True-False]": 2.9977195240001038, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_build_image[SdkDockerClient-True-True]": 2.8067074260000027, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_container_lifecycle_commands[CmdDockerClient]": 0.0018186329998570727, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_container_lifecycle_commands[SdkDockerClient]": 21.109316934000162, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_directory_content_into_container[CmdDockerClient]": 0.0017999489998601348, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_directory_content_into_container[SdkDockerClient]": 0.2808834279999246, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_directory_into_container[CmdDockerClient]": 0.002006374999837135, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_directory_into_container[SdkDockerClient]": 0.20336770599988085, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_directory_structure_into_container[CmdDockerClient]": 0.0038232349997997517, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_directory_structure_into_container[SdkDockerClient]": 0.24341681299983975, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_container[CmdDockerClient]": 0.0019576330000745656, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_container[SdkDockerClient]": 0.2369550779999372, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_container_into_directory[CmdDockerClient]": 0.0019394000000829692, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_container_into_directory[SdkDockerClient]": 0.23823619599988888, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_container_to_different_file[CmdDockerClient]": 0.0019820589998289506, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_container_to_different_file[SdkDockerClient]": 0.24137264699993466, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_non_existent_container[CmdDockerClient]": 0.001921847000176058, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_from_non_existent_container[SdkDockerClient]": 0.008290619999570481, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_container[CmdDockerClient]": 0.004033467999761342, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_container[SdkDockerClient]": 0.1899938500000644, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_container_with_existing_target[CmdDockerClient]": 0.0019309049998810224, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_container_with_existing_target[SdkDockerClient]": 0.3339840560001903, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_container_without_target_filename[CmdDockerClient]": 0.0018105870001363655, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_container_without_target_filename[SdkDockerClient]": 0.1952319469999111, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_non_existent_container[CmdDockerClient]": 0.0018592400001580245, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_copy_into_non_existent_container[SdkDockerClient]": 0.007935213999871849, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_non_existing_image[CmdDockerClient]": 0.001781242999868482, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_non_existing_image[SdkDockerClient]": 0.2979444059999423, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_remove_removes_container[CmdDockerClient]": 0.0018179020000843593, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_remove_removes_container[SdkDockerClient]": 1.1872944670001289, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_with_init[CmdDockerClient]": 0.001816017999772157, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_with_init[SdkDockerClient]": 0.026060707000169714, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_with_max_env_vars[CmdDockerClient]": 0.0018290529999376304, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_container_with_max_env_vars[SdkDockerClient]": 0.21483664199990926, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_file_in_container[CmdDockerClient]": 0.0019134599999688362, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_file_in_container[SdkDockerClient]": 0.193792146000078, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_file[CmdDockerClient-False]": 0.0018575269998564181, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_file[CmdDockerClient-True]": 0.0018645200000264595, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_file[SdkDockerClient-False]": 0.18440558999964196, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_file[SdkDockerClient-True]": 0.19530575099997805, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_stdout[CmdDockerClient-False]": 0.0019827009998607537, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_stdout[CmdDockerClient-True]": 0.0018338320001021202, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_stdout[SdkDockerClient-False]": 0.19494387199983976, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_start_container_with_stdin_to_stdout[SdkDockerClient-True]": 0.2020680020002601, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_exposed_ports[CmdDockerClient]": 0.0018320290002975526, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_exposed_ports[SdkDockerClient]": 0.004421719999982088, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_host_network[CmdDockerClient]": 0.0018368769999597134, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_host_network[SdkDockerClient]": 0.02752769599965177, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_port_mapping[CmdDockerClient]": 0.0017802099998789345, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_port_mapping[SdkDockerClient]": 0.022833673000150156, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_volume[CmdDockerClient]": 0.001705982999965272, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_create_with_volume[SdkDockerClient]": 0.0017637290000038774, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_docker_image_names[CmdDockerClient]": 0.0018106179998085281, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_docker_image_names[SdkDockerClient]": 0.9137878090000413, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_docker_not_available[CmdDockerClient]": 0.006371895000029326, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_docker_not_available[SdkDockerClient]": 0.005901615000311722, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_error_in_container[CmdDockerClient]": 0.0017871430000013788, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_error_in_container[SdkDockerClient]": 0.2440898830000151, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container[CmdDockerClient]": 0.0017250789999252447, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container[SdkDockerClient]": 0.2333486850002373, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_not_running_raises_exception[CmdDockerClient]": 0.0018875219998335524, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_not_running_raises_exception[SdkDockerClient]": 0.03366921000019829, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_env[CmdDockerClient]": 0.0017591609998817148, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_env[SdkDockerClient]": 0.23776943600000777, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_env_deletion[CmdDockerClient]": 0.0018234020001273166, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_env_deletion[SdkDockerClient]": 0.26536420999991606, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_stdin[CmdDockerClient]": 0.0018476180000561726, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_stdin[SdkDockerClient]": 0.24206064300005892, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_stdin_stdout_stderr[CmdDockerClient]": 0.0019263750000391155, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_stdin_stdout_stderr[SdkDockerClient]": 0.23012958599997546, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_workdir[CmdDockerClient]": 0.0018907979999767122, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_exec_in_container_with_workdir[SdkDockerClient]": 0.23264229999995223, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_command[CmdDockerClient]": 0.0018340210001497326, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_command[SdkDockerClient]": 0.005919421999806218, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_command_non_existing_image[CmdDockerClient]": 0.0018231610001748777, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_command_non_existing_image[SdkDockerClient]": 0.28212738200022613, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_command_not_pulled_image[CmdDockerClient]": 0.003832000000102198, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_command_not_pulled_image[SdkDockerClient]": 0.7521369390001382, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_entrypoint[CmdDockerClient]": 0.0018195349998677557, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_entrypoint[SdkDockerClient]": 0.007529335000072024, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_entrypoint_non_existing_image[CmdDockerClient]": 0.0018649600001481303, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_entrypoint_non_existing_image[SdkDockerClient]": 0.280608644999802, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_entrypoint_not_pulled_image[CmdDockerClient]": 0.0018378389997906197, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_entrypoint_not_pulled_image[SdkDockerClient]": 0.7297077759999411, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_id[CmdDockerClient]": 0.0019359729999450792, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_id[SdkDockerClient]": 0.18600953500003925, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_id_not_existing[CmdDockerClient]": 0.0017877249999855849, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_id_not_existing[SdkDockerClient]": 0.007020174999979645, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip[CmdDockerClient]": 0.0019323960000292573, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip[SdkDockerClient]": 0.19265641600009076, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_host_network[CmdDockerClient]": 0.0018036359999769047, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_host_network[SdkDockerClient]": 0.03866516400012188, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_network[CmdDockerClient]": 0.0018083239999668876, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_network[SdkDockerClient]": 0.427574906000018, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_network_non_existent_network[CmdDockerClient]": 0.001843040000039764, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_network_non_existent_network[SdkDockerClient]": 0.1874245050000809, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_network_wrong_network[CmdDockerClient]": 0.001788898000086192, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_for_network_wrong_network[SdkDockerClient]": 0.3614849369996591, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_non_existing_container[CmdDockerClient]": 0.0020182479997856717, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_ip_non_existing_container[SdkDockerClient]": 0.005898363999676803, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_name[CmdDockerClient]": 0.0019386280000617262, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_name[SdkDockerClient]": 0.19567936599992208, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_name_not_existing[CmdDockerClient]": 0.0019162370001595264, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_container_name_not_existing[SdkDockerClient]": 0.007451228999798332, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_logs[CmdDockerClient]": 0.0018103279999195365, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_logs[SdkDockerClient]": 0.17593369199994413, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_logs_non_existent_container[CmdDockerClient]": 0.0017914319998908468, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_logs_non_existent_container[SdkDockerClient]": 0.007077435999917725, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_network[CmdDockerClient]": 0.0017911910001657816, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_network[SdkDockerClient]": 0.026405472000305963, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_network_multiple_networks[CmdDockerClient]": 0.0017855300000064744, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_network_multiple_networks[SdkDockerClient]": 0.4062381970002207, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_network_non_existing_container[CmdDockerClient]": 0.0017760840000846656, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_network_non_existing_container[SdkDockerClient]": 0.006417986000087694, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_system_id[CmdDockerClient]": 0.0017904310000176338, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_system_id[SdkDockerClient]": 0.020978276000050755, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_system_info[CmdDockerClient]": 0.003590529000348397, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_get_system_info[SdkDockerClient]": 0.024861886000053346, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_container[CmdDockerClient]": 0.0018019020001247554, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_container[SdkDockerClient]": 0.18822473599993828, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_container_volumes[CmdDockerClient]": 0.001833671999975195, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_container_volumes[SdkDockerClient]": 0.00172626899984607, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_container_volumes_with_no_volumes[CmdDockerClient]": 0.0017924940002558287, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_container_volumes_with_no_volumes[SdkDockerClient]": 0.19090764500015212, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_image[CmdDockerClient]": 0.0018557020000571356, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_image[SdkDockerClient]": 0.028438552000125128, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_network[CmdDockerClient]": 0.0018745380000382283, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_network[SdkDockerClient]": 0.15242643400006273, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_network_non_existent_network[CmdDockerClient]": 0.0018388420000974293, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_inspect_network_non_existent_network[SdkDockerClient]": 0.007792616999950042, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_is_container_running[CmdDockerClient]": 0.0018087949999880948, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_is_container_running[SdkDockerClient]": 22.395865744000048, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers[CmdDockerClient]": 0.0017964720000236412, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers[SdkDockerClient]": 0.07842941399985648, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_filter[CmdDockerClient]": 0.0018239639998682833, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_filter[SdkDockerClient]": 0.07836071599990646, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_filter_illegal_filter[CmdDockerClient]": 0.0018112799998561968, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_filter_illegal_filter[SdkDockerClient]": 0.006042651999905502, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_filter_non_existing[CmdDockerClient]": 0.0018221700001959107, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_filter_non_existing[SdkDockerClient]": 0.006365897000023324, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_with_podman_image_ref_format[CmdDockerClient]": 0.0018216590001429722, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_list_containers_with_podman_image_ref_format[SdkDockerClient]": 0.22259903300005135, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pause_non_existing_container[CmdDockerClient]": 0.0018007190001299023, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pause_non_existing_container[SdkDockerClient]": 0.005689947999599099, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image[CmdDockerClient]": 0.001766414999792687, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image[SdkDockerClient]": 0.7465849210000215, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image_with_hash[CmdDockerClient]": 0.0018374680003034882, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image_with_hash[SdkDockerClient]": 0.5688395139998192, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image_with_log_handler[CmdDockerClient]": 0.0018443320000187668, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image_with_log_handler[SdkDockerClient]": 0.7086373600002389, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image_with_tag[CmdDockerClient]": 0.0018484390000139683, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_docker_image_with_tag[SdkDockerClient]": 0.7328827269998328, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_non_existent_docker_image[CmdDockerClient]": 0.0018078129999139492, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_pull_non_existent_docker_image[SdkDockerClient]": 0.27862614700006816, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_push_access_denied[CmdDockerClient]": 0.001804085999765448, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_push_access_denied[SdkDockerClient]": 1.05901116799987, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_push_invalid_registry[CmdDockerClient]": 0.0018175109998992411, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_push_invalid_registry[SdkDockerClient]": 0.015406587999905241, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_push_non_existent_docker_image[CmdDockerClient]": 0.0017761550000159332, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_push_non_existent_docker_image[SdkDockerClient]": 0.008014397000124518, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_remove_non_existing_container[CmdDockerClient]": 0.0017947779999758495, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_remove_non_existing_container[SdkDockerClient]": 0.005579230999956053, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_restart_non_existing_container[CmdDockerClient]": 0.0017820040000060544, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_restart_non_existing_container[SdkDockerClient]": 0.005961905000276602, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container[CmdDockerClient]": 0.0018130330001895345, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container[SdkDockerClient]": 0.1702978579999126, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_automatic_pull[CmdDockerClient]": 0.0018242139999529172, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_automatic_pull[SdkDockerClient]": 0.9444557199997234, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_error[CmdDockerClient]": 0.0018474679998234933, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_error[SdkDockerClient]": 0.12634902299987516, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_non_existent_image[CmdDockerClient]": 0.0019429160001891432, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_non_existent_image[SdkDockerClient]": 0.30333460499991816, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_with_init[CmdDockerClient]": 0.003885360999902332, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_with_init[SdkDockerClient]": 0.18452799499982575, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_with_stdin[CmdDockerClient]": 0.0018384400002560142, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_container_with_stdin[SdkDockerClient]": 0.1911943339998743, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_detached_with_logs[CmdDockerClient]": 0.0019333079999341862, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_run_detached_with_logs[SdkDockerClient]": 0.18416577200014217, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_running_container_names[CmdDockerClient]": 0.001781302999916079, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_running_container_names[SdkDockerClient]": 10.973466285000313, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_set_container_entrypoint[CmdDockerClient-echo]": 0.0018253760001698538, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_set_container_entrypoint[CmdDockerClient-entrypoint1]": 0.0017549839999446704, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_set_container_entrypoint[SdkDockerClient-echo]": 0.18603482000025906, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_set_container_entrypoint[SdkDockerClient-entrypoint1]": 0.17842079499996544, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_start_non_existing_container[CmdDockerClient]": 0.001754272999960449, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_start_non_existing_container[SdkDockerClient]": 0.0055246189999706985, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_stop_non_existing_container[CmdDockerClient]": 0.0018115000002580928, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_stop_non_existing_container[SdkDockerClient]": 0.006359410999948523, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_stream_logs[CmdDockerClient]": 0.0019261240001924307, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_stream_logs[SdkDockerClient]": 0.1739173290002327, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_stream_logs_non_existent_container[CmdDockerClient]": 0.0018314980000013747, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_stream_logs_non_existent_container[SdkDockerClient]": 0.005794296999965809, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_tag_image[CmdDockerClient]": 0.0017898400001286063, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_tag_image[SdkDockerClient]": 0.14789147300007244, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_tag_non_existing_image[CmdDockerClient]": 0.0019260750000285043, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_tag_non_existing_image[SdkDockerClient]": 0.00637922800024171, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_unpause_non_existing_container[CmdDockerClient]": 0.0018047670000669314, + "tests/integration/docker_utils/test_docker.py::TestDockerClient::test_unpause_non_existing_container[SdkDockerClient]": 0.005626045999861162, + "tests/integration/docker_utils/test_docker.py::TestDockerImages::test_commit_creates_image_from_running_container[CmdDockerClient]": 0.0034125350000522303, + "tests/integration/docker_utils/test_docker.py::TestDockerImages::test_commit_creates_image_from_running_container[SdkDockerClient]": 0.7523694960002558, + "tests/integration/docker_utils/test_docker.py::TestDockerImages::test_commit_image_raises_for_nonexistent_container[CmdDockerClient]": 0.0019969660002061573, + "tests/integration/docker_utils/test_docker.py::TestDockerImages::test_commit_image_raises_for_nonexistent_container[SdkDockerClient]": 0.0060299699998722645, + "tests/integration/docker_utils/test_docker.py::TestDockerImages::test_remove_image_raises_for_nonexistent_image[CmdDockerClient]": 0.0018857469999602472, + "tests/integration/docker_utils/test_docker.py::TestDockerImages::test_remove_image_raises_for_nonexistent_image[SdkDockerClient]": 0.0063817870000093535, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_create_container_with_labels[CmdDockerClient]": 0.0034511750000092434, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_create_container_with_labels[SdkDockerClient]": 0.04260960600004182, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_get_container_stats[CmdDockerClient]": 0.0019208830001389288, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_get_container_stats[SdkDockerClient]": 1.2036195279999902, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_list_containers_with_labels[CmdDockerClient]": 0.001868655999942348, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_list_containers_with_labels[SdkDockerClient]": 0.19184098899972923, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_run_container_with_labels[CmdDockerClient]": 0.0018886829998336907, + "tests/integration/docker_utils/test_docker.py::TestDockerLabels::test_run_container_with_labels[SdkDockerClient]": 0.1940702940000847, + "tests/integration/docker_utils/test_docker.py::TestDockerLogging::test_docker_logging_fluentbit[CmdDockerClient]": 0.001814155000147366, + "tests/integration/docker_utils/test_docker.py::TestDockerLogging::test_docker_logging_fluentbit[SdkDockerClient]": 3.2792525320001005, + "tests/integration/docker_utils/test_docker.py::TestDockerLogging::test_docker_logging_none_disables_logs[CmdDockerClient]": 0.003254308000123274, + "tests/integration/docker_utils/test_docker.py::TestDockerLogging::test_docker_logging_none_disables_logs[SdkDockerClient]": 0.20709205499997552, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_network[CmdDockerClient]": 0.0069121800001994416, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_network[SdkDockerClient]": 0.4432827999999063, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_network_with_alias_and_disconnect[CmdDockerClient]": 0.0020904499999687687, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_network_with_alias_and_disconnect[SdkDockerClient]": 0.7677727949997006, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_network_with_link_local_address[CmdDockerClient]": 0.0023048719999678724, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_network_with_link_local_address[SdkDockerClient]": 0.19581444599998576, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_nonexistent_network[CmdDockerClient]": 0.0020353570002953347, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_container_to_nonexistent_network[SdkDockerClient]": 0.19118021299982502, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_nonexistent_container_to_network[CmdDockerClient]": 0.001973692000092342, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_connect_nonexistent_container_to_network[SdkDockerClient]": 0.14664760900018337, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_disconnect_container_from_nonexistent_network[CmdDockerClient]": 0.001984722999850419, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_disconnect_container_from_nonexistent_network[SdkDockerClient]": 0.1865279260000534, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_disconnect_nonexistent_container_from_network[CmdDockerClient]": 0.0019371050000245305, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_disconnect_nonexistent_container_from_network[SdkDockerClient]": 0.16360530699989795, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_docker_sdk_no_retries": 0.027791447999788943, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_docker_sdk_retries_after_init": 1.0891511770000761, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_docker_sdk_retries_on_init": 1.070130115000211, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_docker_sdk_timeout_seconds": 0.01904424200029098, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_get_container_ip_with_network[CmdDockerClient]": 0.0019399090001570585, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_get_container_ip_with_network[SdkDockerClient]": 0.3685316299997794, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_network_lifecycle[CmdDockerClient]": 0.003377438000143229, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_network_lifecycle[SdkDockerClient]": 0.17139327299992146, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_set_container_workdir[CmdDockerClient]": 0.0020075449999694683, + "tests/integration/docker_utils/test_docker.py::TestDockerNetworking::test_set_container_workdir[SdkDockerClient]": 0.18690938999975515, + "tests/integration/docker_utils/test_docker.py::TestDockerPermissions::test_container_with_cap_add[CmdDockerClient]": 0.003387957000086317, + "tests/integration/docker_utils/test_docker.py::TestDockerPermissions::test_container_with_cap_add[SdkDockerClient]": 0.4063843779999843, + "tests/integration/docker_utils/test_docker.py::TestDockerPermissions::test_container_with_cap_drop[CmdDockerClient]": 0.0019780800000717136, + "tests/integration/docker_utils/test_docker.py::TestDockerPermissions::test_container_with_cap_drop[SdkDockerClient]": 0.36656208399972456, + "tests/integration/docker_utils/test_docker.py::TestDockerPermissions::test_container_with_sec_opt[CmdDockerClient]": 0.001833459999943443, + "tests/integration/docker_utils/test_docker.py::TestDockerPermissions::test_container_with_sec_opt[SdkDockerClient]": 0.02720381499989344, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_container_port_can_be_bound[CmdDockerClient-None]": 0.0019707470000867033, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_container_port_can_be_bound[CmdDockerClient-tcp]": 0.001967380999985835, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_container_port_can_be_bound[CmdDockerClient-udp]": 0.001932635000002847, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_container_port_can_be_bound[SdkDockerClient-None]": 1.4595667970002069, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_container_port_can_be_bound[SdkDockerClient-tcp]": 1.4548421970000618, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_container_port_can_be_bound[SdkDockerClient-udp]": 1.479573094999978, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_reserve_container_port[CmdDockerClient-None]": 0.003389441000081206, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_reserve_container_port[CmdDockerClient-tcp]": 0.0019720289999440865, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_reserve_container_port[CmdDockerClient-udp]": 0.0019828790000246954, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_reserve_container_port[SdkDockerClient-None]": 2.6437320379998255, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_reserve_container_port[SdkDockerClient-tcp]": 2.5562603310002032, + "tests/integration/docker_utils/test_docker.py::TestDockerPorts::test_reserve_container_port[SdkDockerClient-udp]": 2.7824807570002577, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments[CmdDockerClient]": 0.0035280400002193346, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments[SdkDockerClient]": 0.3674299270001029, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_add_dns[CmdDockerClient-False]": 0.0018578050000996882, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_add_dns[CmdDockerClient-True]": 0.0018486980000034237, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_add_dns[SdkDockerClient-False]": 0.11840362899988577, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_add_dns[SdkDockerClient-True]": 0.11947073299984368, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_add_host[CmdDockerClient]": 0.0018177810002271144, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_add_host[SdkDockerClient]": 0.1848618220001299, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_env_files[CmdDockerClient]": 0.001893242000051032, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_env_files[SdkDockerClient]": 0.7353357959998448, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_random_port[CmdDockerClient]": 0.0018639669999629405, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_additional_arguments_random_port[SdkDockerClient]": 0.24491471800024556, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_ulimit[CmdDockerClient]": 0.0018848059999072575, + "tests/integration/docker_utils/test_docker.py::TestRunWithAdditionalArgs::test_run_with_ulimit[SdkDockerClient]": 0.18334250900011284, + "tests/integration/services/test_internal.py::TestHealthResource::test_get": 0.017968850999977803, + "tests/integration/services/test_internal.py::TestHealthResource::test_head": 0.018190129999993587, + "tests/integration/services/test_internal.py::TestInfoEndpoint::test_get": 0.04994243600003756, + "tests/integration/services/test_internal.py::TestInitScriptsResource::test_query_individual_stage_completed[boot-True]": 0.017859725000107574, + "tests/integration/services/test_internal.py::TestInitScriptsResource::test_query_individual_stage_completed[ready-True]": 0.01841755100008413, + "tests/integration/services/test_internal.py::TestInitScriptsResource::test_query_individual_stage_completed[shutdown-False]": 0.018065029999888793, + "tests/integration/services/test_internal.py::TestInitScriptsResource::test_query_individual_stage_completed[start-True]": 0.02321548800000528, + "tests/integration/services/test_internal.py::TestInitScriptsResource::test_query_nonexisting_stage": 0.018814966000036293, + "tests/integration/services/test_internal.py::TestInitScriptsResource::test_stages_have_completed": 1.5346031930000663, + "tests/integration/test_config_endpoint.py::test_config_endpoint": 0.06227641799955563, + "tests/integration/test_config_service.py::TestConfigService::test_put_configuration_recorder": 0.21785928999975113, + "tests/integration/test_config_service.py::TestConfigService::test_put_delivery_channel": 0.21353211399991778, + "tests/integration/test_forwarder.py::test_forwarding_fallback_dispatcher": 0.006584207000059905, + "tests/integration/test_forwarder.py::test_forwarding_fallback_dispatcher_avoid_fallback": 0.004360786000006556, + "tests/integration/test_security.py::TestCSRF::test_CSRF": 0.10043611099968075, + "tests/integration/test_security.py::TestCSRF::test_additional_allowed_origins": 0.01657879400022466, + "tests/integration/test_security.py::TestCSRF::test_cors_apigw_not_applied": 0.04249741100011306, + "tests/integration/test_security.py::TestCSRF::test_cors_s3_override": 0.08404852300009225, + "tests/integration/test_security.py::TestCSRF::test_default_cors_headers": 0.014409634999992704, + "tests/integration/test_security.py::TestCSRF::test_disable_cors_checks": 0.01787133399989216, + "tests/integration/test_security.py::TestCSRF::test_disable_cors_headers": 0.0210201509999024, + "tests/integration/test_security.py::TestCSRF::test_internal_route_cors_headers[/_localstack/health]": 0.0112286150001637, + "tests/integration/test_security.py::TestCSRF::test_no_cors_without_origin_header": 0.010233193999738432, + "tests/integration/test_stores.py::test_nonstandard_regions": 0.16057162700030858, + "tests/integration/utils/test_diagnose.py::test_diagnose_resource": 0.2482701450001059 +} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a736491c449cd..0000000000000 --- a/.travis.yml +++ /dev/null @@ -1,58 +0,0 @@ -sudo: required - -language: python - -services: - - docker - -python: - - "3.6" - -branches: - only: - - master - -before_install: - - "sudo apt-get purge openjdk-6*" - - "sudo apt-get purge openjdk-7*" - - "sudo apt-get purge oracle-java7-*" - - "sudo apt-get purge python2.7" - - "nvm install 6; nvm alias default node" - - "sudo useradd localstack -s /bin/bash" - -addons: - apt: - packages: - - oracle-java8-installer - - oracle-java8-set-default - -env: - global: - - JAVA_HOME=/usr/lib/jvm/java-8-oracle - -install: - - make reinstall-p3 - - make init - -script: - - set -e # fail fast - # run tests using Python 3 - - DEBUG=1 LAMBDA_EXECUTOR=docker TEST_ERROR_INJECTION=1 make test - - LAMBDA_EXECUTOR=local USE_SSL=1 make test - # run tests using Python 2 - # Note: we're not using multiple versions in the top-level "python" configuration, - # but instead reinstall using 2.x here, as that allows us to use some cached libs etc. - - "make reinstall-p2 > /dev/null" - - make init - - DEBUG=1 LAMBDA_EXECUTOR=docker-reuse USE_SSL=1 make test - # run Java tests - - make test-java-if-changed - # build Docker image, and push it (if on master branch) - - make docker-build - - make docker-push-master - -after_success: - - make coveralls - -notifications: - email: false diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000000..808c3f75aec7c --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,251 @@ +###################### +### CODE OWNERS ### +###################### + +# CODEOWNERS +/CODEOWNERS @thrau @dominikschubert @alexrashed + +# README / Docs +/docs/ @thrau @HarshCasper +/README.md @HarshCasper +/CODE_OF_CONDUCT.md @HarshCasper +/LICENSE.txt @HarshCasper @alexrashed + +# Docker +/bin/docker-entrypoint.sh @thrau @alexrashed +/.dockerignore @alexrashed +/Dockerfile* @alexrashed @silv-io + +# Git, Pipelines, GitHub config +/.github @alexrashed @dfangl @dominikschubert @silv-io @k-a-il +/.test_durations @alexrashed @silv-io @k-a-il +/.git-blame-ignore-revs @alexrashed @thrau +/bin/release-dev.sh @thrau @alexrashed +/bin/release-helper.sh @thrau @alexrashed + +# ASF +/localstack-core/localstack/aws/ @thrau +/tests/unit/aws/ @thrau +# ASF parsers and serializers +/localstack-core/localstack/aws/protocol @alexrashed +# left empty (without owner) because the generated APIs belong to the specific service owners +# you can overwrite this for single services afterwards +/localstack-core/localstack/aws/api/ + +# CLI +/localstack-core/localstack/cli/ @thrau @alexrashed +/tests/unit/cli/ @thrau @alexrashed +/tests/cli/ @thrau @alexrashed + +# Plugins +/localstack-core/localstack/plugins.py @thrau +/localstack-core/localstack/config.py @thrau +/tests/unit/services/test_internal.py @thrau + +# Extensions +/localstack-core/localstack/extensions/ @thrau + +# Container utils +/localstack-core/localstack/utils/container_utils/ @dfangl @dominikschubert +/localstack-core/localstack/utils/docker_utils.py @dfangl @dominikschubert +/tests/unit/test_docker_utils.py @dfangl @dominikschubert +/tests/unit/test_dockerclient.py @dfangl @dominikschubert + +# Package Installers +/localstack-core/localstack/packages/ @alexrashed +/localstack-core/localstack/services/kinesis/packages.py @alexrashed + +# DNS server +/localstack-core/localstack/dns @simonrw @dfangl + +# HTTP framework +/localstack-core/localstack/http/ @thrau +/tests/unit/http_/ @thrau + +# Runtime +/localstack-core/localstack/runtime/ @thrau + +# Logging +/localstack-core/localstack/logging/ @dfangl @alexrashed @dominikschubert + +# Stores +/localstack-core/localstack/services/stores.py @viren-nadkarni +/tests/unit/test_stores.py @viren-nadkarni + +# Analytics client +/localstack-core/localstack/utils/analytics/ @thrau +/tests/unit/utils/analytics/ @thrau + +# Snapshot testing +/localstack-core/localstack/testing/snapshots/ @dominikschubert @steffyP +/localstack-core/localstack/testing/pytest/ @dominikschubert + +# Scenario testing +/localstack-core/localstack/testing/scenario/ @dominikschubert @steffyP + +# Bootstrap tests +/tests/bootstrap @simonrw +/localstack-core/localstack/testing/pytest/container.py @dominikschubert @simonrw + +# Test Selection +/localstack-core/localstack/testing/testselection @dominikschubert @alexrashed @silv-io + +###################### +### SERVICE OWNERS ### +###################### +# DO NOT modify anything below! +# Everything below is _autogenerated_ and any manual changes will be overwritten. + + +# acm +/localstack-core/localstack/aws/api/acm/ @alexrashed +/localstack-core/localstack/services/acm/ @alexrashed +/tests/aws/services/acm/ @alexrashed + +# apigateway +/localstack-core/localstack/aws/api/apigateway/ @bentsku @cloutierMat +/localstack-core/localstack/services/apigateway/ @bentsku @cloutierMat +/tests/aws/services/apigateway/ @bentsku @cloutierMat +/tests/unit/services/apigateway/ @bentsku @cloutierMat + +# cloudcontrol +/localstack-core/localstack/aws/api/cloudcontrol/ @simonrw +/tests/aws/services/cloudcontrol/ @simonrw + +# cloudformation +/localstack-core/localstack/aws/api/cloudformation/ @dominikschubert @pinzon @simonrw +/localstack-core/localstack/services/cloudformation/ @dominikschubert @pinzon @simonrw +/tests/aws/services/cloudformation/ @dominikschubert @pinzon @simonrw +/tests/unit/services/cloudformation/ @dominikschubert @pinzon @simonrw + +# cloudwatch +/localstack-core/localstack/aws/api/cloudwatch/ @pinzon @steffyP +/localstack-core/localstack/services/cloudwatch/ @pinzon @steffyP +/tests/aws/services/cloudwatch/ @pinzon @steffyP +/tests/unit/services/cloudwatch/ @pinzon @steffyP + +# dynamodb +/localstack-core/localstack/aws/api/dynamodb/ @viren-nadkarni @giograno +/localstack-core/localstack/services/dynamodb/ @viren-nadkarni @giograno +/tests/aws/services/dynamodb/ @viren-nadkarni @giograno +/tests/unit/services/dynamodb/ @viren-nadkarni @giograno + +# ec2 +/localstack-core/localstack/aws/api/ec2/ @viren-nadkarni @macnev2013 +/localstack-core/localstack/services/ec2/ @viren-nadkarni @macnev2013 +/tests/aws/services/ec2/ @viren-nadkarni @macnev2013 + +# ecr +/localstack-core/localstack/services/ecr/ @dfangl + +# es +/localstack-core/localstack/aws/api/es/ @alexrashed @silv-io +/localstack-core/localstack/services/es/ @alexrashed @silv-io +/tests/aws/services/es/ @alexrashed @silv-io + +# events +/localstack-core/localstack/aws/api/events/ @maxhoheiser @bentsku +/localstack-core/localstack/services/events/ @maxhoheiser @bentsku +/tests/aws/services/events/ @maxhoheiser @bentsku +/tests/unit/services/events/ @maxhoheiser @bentsku + +# firehose +/localstack-core/localstack/aws/api/firehose/ @pinzon +/localstack-core/localstack/services/firehose/ @pinzon +/tests/aws/services/firehose/ @pinzon + +# iam +/localstack-core/localstack/aws/api/iam/ @dfangl @pinzon +/localstack-core/localstack/services/iam/ @dfangl @pinzon +/tests/aws/services/iam/ @dfangl @pinzon + +# kms +/localstack-core/localstack/aws/api/kms/ @sannya-singal +/localstack-core/localstack/services/kms/ @sannya-singal +/tests/aws/services/kms/ @sannya-singal +/tests/unit/services/kms/ @sannya-singal + +# lambda +/localstack-core/localstack/aws/api/lambda_/ @joe4dev @dominikschubert @dfangl @gregfurman +/localstack-core/localstack/services/lambda_/ @joe4dev @dominikschubert @dfangl @gregfurman +/tests/aws/services/lambda_/ @joe4dev @dominikschubert @dfangl @gregfurman +/tests/unit/services/lambda_/ @joe4dev @dominikschubert @dfangl @gregfurman + +# logs +/localstack-core/localstack/aws/api/logs/ @pinzon @steffyP +/localstack-core/localstack/services/logs/ @pinzon @steffyP +/tests/aws/services/logs/ @pinzon @steffyP +/tests/unit/services/logs/ @pinzon @steffyP + +# opensearch +/localstack-core/localstack/aws/api/opensearch/ @alexrashed @silv-io +/localstack-core/localstack/services/opensearch/ @alexrashed @silv-io +/tests/aws/services/opensearch/ @alexrashed @silv-io +/tests/unit/services/opensearch/ @alexrashed @silv-io + +# pipes +/localstack-core/localstack/aws/api/pipes/ @tiurin @gregfurman @joe4dev + +# route53 +/localstack-core/localstack/aws/api/route53/ @giograno +/localstack-core/localstack/services/route53/ @giograno +/tests/aws/services/route53/ @giograno + +# route53resolver +/localstack-core/localstack/aws/api/route53resolver/ @macnev2013 @sannya-singal +/localstack-core/localstack/services/route53resolver/ @macnev2013 @sannya-singal +/tests/aws/services/route53resolver/ @macnev2013 @sannya-singal + +# s3 +/localstack-core/localstack/aws/api/s3/ @bentsku @k-a-il +/localstack-core/localstack/services/s3/ @bentsku @k-a-il +/tests/aws/services/s3/ @bentsku @k-a-il +/tests/unit/services/s3/ @bentsku @k-a-il + +# s3control +/localstack-core/localstack/aws/api/s3control/ @bentsku +/localstack-core/localstack/services/s3control/ @bentsku +/tests/aws/services/s3control/ @bentsku + +# secretsmanager +/localstack-core/localstack/aws/api/secretsmanager/ @dominikschubert @macnev2013 @MEPalma +/localstack-core/localstack/services/secretsmanager/ @dominikschubert @macnev2013 @MEPalma +/tests/aws/services/secretsmanager/ @dominikschubert @macnev2013 @MEPalma + +# ses +/localstack-core/localstack/aws/api/ses/ @viren-nadkarni +/localstack-core/localstack/services/ses/ @viren-nadkarni +/tests/aws/services/ses/ @viren-nadkarni + +# sns +/localstack-core/localstack/aws/api/sns/ @bentsku @baermat +/localstack-core/localstack/services/sns/ @bentsku @baermat +/tests/aws/services/sns/ @bentsku @baermat +/tests/unit/services/sns/ @bentsku @baermat + +# sqs +/localstack-core/localstack/aws/api/sqs/ @thrau @baermat @gregfurman +/localstack-core/localstack/services/sqs/ @thrau @baermat @gregfurman +/tests/aws/services/sqs/ @thrau @baermat @gregfurman +/tests/unit/services/sqs/ @thrau @baermat @gregfurman + +# ssm +/localstack-core/localstack/aws/api/ssm/ @dominikschubert +/localstack-core/localstack/services/ssm/ @dominikschubert +/tests/aws/services/ssm/ @dominikschubert + +# stepfunctions +/localstack-core/localstack/aws/api/stepfunctions/ @MEPalma @joe4dev @gregfurman +/localstack-core/localstack/services/stepfunctions/ @MEPalma @joe4dev @gregfurman +/tests/aws/services/stepfunctions/ @MEPalma @joe4dev @gregfurman +/tests/unit/services/stepfunctions/ @MEPalma @joe4dev @gregfurman + +# sts +/localstack-core/localstack/aws/api/sts/ @pinzon @dfangl +/localstack-core/localstack/services/sts/ @pinzon @dfangl +/tests/aws/services/sts/ @pinzon @dfangl + +# transcribe +/localstack-core/localstack/aws/api/transcribe/ @sannya-singal +/localstack-core/localstack/services/transcribe/ @sannya-singal +/tests/aws/services/transcribe/ @sannya-singal diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000..c1e032e055dda --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +info@localstack.cloud. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000000000..9d102b1a0e942 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,144 @@ +<p align="center"> + <img src="https://raw.githubusercontent.com/localstack/localstack/master/docs/localstack-readme-banner.svg" alt="LocalStack - A fully functional local cloud stack"> +</p> + +<p align="center"> + <a href="https://github.com/localstack/localstack/actions/workflows/aws-main.yml?query=branch%3Amaster"><img alt="GitHub Actions" src="https://github.com/localstack/localstack/actions/workflows/aws-main.yml/badge.svg?branch=master"></a> + <a href="https://coveralls.io/github/localstack/localstack?branch=master"><img alt="Coverage Status" src="https://coveralls.io/repos/github/localstack/localstack/badge.svg?branch=master"></a> + <a href="https://pypi.org/project/localstack/"><img alt="PyPI Version" src="https://img.shields.io/pypi/v/localstack?color=blue"></a> + <a href="https://hub.docker.com/r/localstack/localstack"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/localstack/localstack"></a> + <a href="https://pypi.org/project/localstack"><img alt="PyPi downloads" src="https://static.pepy.tech/badge/localstack"></a> + <a href="#backers"><img alt="Backers on Open Collective" src="https://opencollective.com/localstack/backers/badge.svg"></a> + <a href="#sponsors"><img alt="Sponsors on Open Collective" src="https://opencollective.com/localstack/sponsors/badge.svg"></a> + <a href="https://img.shields.io/pypi/l/localstack.svg"><img alt="PyPI License" src="https://img.shields.io/pypi/l/localstack.svg"></a> + <a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a> + <a href="https://github.com/astral-sh/ruff"><img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json"></a> + <a href="https://twitter.com/localstack"><img alt="Twitter" src="https://img.shields.io/twitter/url/http/shields.io.svg?style=social"></a> +</p> + +# What is LocalStack? + +[LocalStack](https://localstack.cloud) is a cloud service emulator that runs in a single container on your laptop or in your CI environment. With LocalStack, you can run your AWS applications or Lambdas entirely on your local machine without connecting to a remote cloud provider! Whether you are testing complex CDK applications or Terraform configurations, or just beginning to learn about AWS services, LocalStack helps speed up and simplify your testing and development workflow. + +LocalStack supports a growing number of AWS services, like AWS Lambda, S3, Dynamodb, Kinesis, SQS, SNS, and many more! You can find a comprehensive list of supported APIs on our [β˜‘οΈ Feature Coverage](https://docs.localstack.cloud/user-guide/aws/feature-coverage/) page. + +LocalStack also provides additional features to make your life as a cloud developer easier! Check out LocalStack's [User Guides](https://docs.localstack.cloud/user-guide/) for more information. + +## Usage + +Please make sure that you have a working [Docker environment](https://docs.docker.com/get-docker/) on your machine before moving on. You can check if Docker is correctly configured on your machine by executing `docker info` in your terminal. If it does not report an error (but shows information on your Docker system), you’re good to go. + +### Docker CLI + +You can directly start the LocalStack container using the Docker CLI. This method requires more manual steps and configuration, but it gives you more control over the container settings. + +You can start the Docker container simply by executing the following docker run command: + +```console +$ docker run --rm -it -p 4566:4566 -p 4510-4559:4510-4559 localstack/localstack +``` + +Create an s3 bucket with LocalStack's [`awslocal`](https://docs.localstack.cloud/user-guide/integrations/aws-cli/#localstack-aws-cli-awslocal) CLI: + +``` +$ awslocal s3api create-bucket --bucket sample-bucket +$ awslocal s3api list-buckets +``` + +**Notes** + +- This command reuses the image if it’s already on your machine, i.e. it will **not** pull the latest image automatically from Docker Hub. + +- This command does not bind all ports that are potentially used by LocalStack, nor does it mount any volumes. When using Docker to manually start LocalStack, you will have to configure the container on your own (see [`docker-compose.yml`](https://github.com/localstack/localstack/blob/master/docker-compose.yml) and [Configuration](https://docs.localstack.cloud/references/configuration/)). This could be seen as the β€œexpert mode” of starting LocalStack. If you are looking for a simpler method of starting LocalStack, please use the [LocalStack CLI](https://docs.localstack.cloud/getting-started/installation/#localstack-cli). + +### Docker Compose + +You can start LocalStack with [Docker Compose](https://docs.docker.com/compose/) by configuring a `docker-compose.yml file`. Currently, docker-compose version 1.9.0+ is supported. + +``` +version: "3.8" + +services: + localstack: + container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}" + image: localstack/localstack + ports: + - "127.0.0.1:4566:4566" # LocalStack Gateway + - "127.0.0.1:4510-4559:4510-4559" # external services port range + environment: + # LocalStack configuration: https://docs.localstack.cloud/references/configuration/ + - DEBUG=${DEBUG:-0} + volumes: + - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack" + - "/var/run/docker.sock:/var/run/docker.sock" +``` + +Start the container by running the following command: + +```console +$ docker-compose up +``` + +Create a queue using SQS with LocalStack's [`awslocal`](https://docs.localstack.cloud/user-guide/integrations/aws-cli/#localstack-aws-cli-awslocal) CLI: + +``` +$ awslocal sqs create-queue --queue-name test-queue +$ awslocal sqs list-queues +``` + +**Notes** + +- This command pulls the current nightly build from the `master` branch (if you don’t have the image locally) and **not** the latest supported version. If you want to use a specific version, set the appropriate localstack image tag at `services.localstack.image` in the `docker-compose.yml` file (for example `localstack/localstack:<version>`). + +- This command reuses the image if it’s already on your machine, i.e. it will **not** pull the latest image automatically from Docker Hub. + +- Mounting the Docker socket `/var/run/docker.sock` as a volume is required for the Lambda service. Check out the [Lambda providers](https://docs.localstack.cloud/user-guide/aws/lambda/) documentation for more information. + +Please note that there are a few pitfalls when configuring your stack manually via docker-compose (e.g., required container name, Docker network, volume mounts, and environment variables). We recommend using the LocalStack CLI to validate your configuration, which will print warning messages in case it detects any potential misconfigurations: + +```console +$ localstack config validate +``` + +## Base Image Tags + +We do push a set of different image tags for the LocalStack Docker images. When using LocalStack, you can decide which tag you want to use.These tags have different semantics and will be updated on different occasions: + +- `latest` (default) + - This is our default tag. + It refers to the latest commit which has been fully tested using our extensive integration test suite. + - This also entails changes that are part of major releases, which means that this tag can contain breaking changes. + - This tag should be used if you want to stay up-to-date with the latest changes. +- `stable` + - This tag refers to the latest tagged release. + It will be updated with every release of LocalStack. + - This also entails major releases, which means that this tag can contain breaking changes. + - This tag should be used if you want to stay up-to-date with releases, but don't necessarily need the latest and greatest changes right away. +- `<major>` (e.g. `3`) + - These tags can be used to refer to the latest release of a specific major release. + It will be updated with every minor and patch release within this major release. + - This tag should be used if you want to avoid any potential breaking changes. +- `<major>.<minor>` (e.g. `3.0`) + - These tags can be used to refer to the latest release of a specific minor release. + It will be updated with every patch release within this minor release. + - This tag can be used if you want to avoid any bigger changes, like new features, but still want to update to the latest bugfix release. +- `<major>.<minor>.<patch>` (e.g. `3.0.2`) + - These tags can be used if you want to use a very specific release. + It will not be updated. + - This tag can be used if you really want to avoid any changes to the image (not even minimal bug fixes). + +## Where to get help + +Get in touch with the LocalStack Team to report 🐞 [issues](https://github.com/localstack/localstack/issues/new/choose),upvote πŸ‘ [feature requests](https://github.com/localstack/localstack/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+),πŸ™‹πŸ½ ask [support questions](https://docs.localstack.cloud/getting-started/help-and-support/),or πŸ—£οΈ discuss local cloud development: + +- [LocalStack Slack Community](https://localstack.cloud/contact/) +- [LocalStack GitHub Issue tracker](https://github.com/localstack/localstack/issues) +- [Getting Started - FAQ](https://docs.localstack.cloud/getting-started/faq/) + +## License + +Copyright (c) 2017-2024 LocalStack maintainers and contributors. + +Copyright (c) 2016 Atlassian and others. + +This version of LocalStack is released under the Apache License, Version 2.0 (see [LICENSE](https://github.com/localstack/localstack/blob/master/LICENSE.txt)). By downloading and using this software you agree to the [End-User License Agreement (EULA)](https://github.com/localstack/localstack/blob/master/doc/end_user_license_agreement). diff --git a/Dockerfile b/Dockerfile index 8bc4ace0d239d..38174aff318ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,63 +1,193 @@ -FROM localstack/java-maven-node-python - -MAINTAINER Waldemar Hummer (waldemar.hummer@gmail.com) -LABEL authors="Waldemar Hummer (waldemar.hummer@gmail.com), Gianluca Bortoli (giallogiallo93@gmail.com)" - -# add files required to run "make install" -ADD Makefile requirements.txt ./ -RUN mkdir -p localstack/utils/kinesis/ && mkdir -p localstack/services/ && \ - touch localstack/__init__.py localstack/utils/__init__.py localstack/services/__init__.py localstack/utils/kinesis/__init__.py -ADD localstack/constants.py localstack/config.py localstack/ -ADD localstack/services/install.py localstack/services/ -ADD localstack/utils/common.py localstack/utils/ -ADD localstack/utils/kinesis/ localstack/utils/kinesis/ -ADD localstack/ext/ localstack/ext/ - -# install dependencies -RUN make install - -# add files required to run "make init" -ADD localstack/package.json localstack/package.json -ADD localstack/services/__init__.py localstack/services/install.py localstack/services/ - -# initialize installation (downloads remaining dependencies) -RUN make init - -# add rest of the code -ADD localstack/ localstack/ -ADD bin/localstack bin/localstack - -# (re-)install web dashboard dependencies (already installed in base image) -RUN make install-web - -# fix some permissions and create local user -RUN mkdir -p /.npm && \ - mkdir -p localstack/infra/elasticsearch/data && \ - mkdir -p localstack/infra/elasticsearch/logs && \ - chmod 777 . && \ - chmod 755 /root && \ - chmod -R 777 /.npm && \ - chmod -R 777 localstack/infra/elasticsearch/config && \ - chmod -R 777 localstack/infra/elasticsearch/data && \ - chmod -R 777 localstack/infra/elasticsearch/logs && \ +# +# base: Stage which installs necessary runtime dependencies (OS packages, etc.) +# +FROM python:3.11.13-slim-bookworm@sha256:139020233cc412efe4c8135b0efe1c7569dc8b28ddd88bddb109b764f8977e30 AS base +ARG TARGETARCH + +# Install runtime OS package dependencies +RUN --mount=type=cache,target=/var/cache/apt \ + apt-get update && \ + # Install dependencies to add additional repos + apt-get install -y --no-install-recommends \ + # Runtime packages (groff-base is necessary for AWS CLI help) + ca-certificates curl gnupg git make openssl tar pixz zip unzip groff-base iputils-ping nss-passwords procps iproute2 xz-utils libatomic1 binutils && \ + # patch for CVE-2024-45490, CVE-2024-45491, CVE-2024-45492 + apt-get install --only-upgrade libexpat1 + +# FIXME Node 18 actually shouldn't be necessary in Community, but we assume its presence in lots of tests +# Install nodejs package from the dist release server. Note: we're installing from dist binaries, and not via +# `apt-get`, to avoid installing `python3.9` into the image (which otherwise comes as a dependency of nodejs). +# See https://github.com/nodejs/docker-node/blob/main/18/bullseye/Dockerfile +RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \ + && case "${dpkgArch##*-}" in \ + amd64) ARCH='x64';; \ + arm64) ARCH='arm64';; \ + *) echo "unsupported architecture"; exit 1 ;; \ + esac \ + # gpg keys listed at https://github.com/nodejs/node#release-keys + && set -ex \ + && for key in \ + C0D6248439F1D5604AAFFB4021D900FFDB233756 \ + DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 \ + CC68F5A3106FF448322E48ED27F5E38D5B0A215F \ + 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \ + 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 \ + C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \ + 108F52B48DB57BB0CC439B2997B01419BD92F80A \ + A363A499291CBBC940DD62E41F10027AF002F8B0 \ + ; do \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; \ + done \ + && curl -LO https://nodejs.org/dist/latest-v18.x/SHASUMS256.txt \ + && LATEST_VERSION_FILENAME=$(cat SHASUMS256.txt | grep -o "node-v.*-linux-$ARCH" | sort | uniq) \ + && rm SHASUMS256.txt \ + && curl -fsSLO --compressed "https://nodejs.org/dist/latest-v18.x/$LATEST_VERSION_FILENAME.tar.xz" \ + && curl -fsSLO --compressed "https://nodejs.org/dist/latest-v18.x/SHASUMS256.txt.asc" \ + && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && grep " $LATEST_VERSION_FILENAME.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ + && tar -xJf "$LATEST_VERSION_FILENAME.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ + && rm "$LATEST_VERSION_FILENAME.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ + && ln -s /usr/local/bin/node /usr/local/bin/nodejs \ + # upgrade npm to the latest version + && npm upgrade -g npm \ + # smoke tests + && node --version \ + && npm --version \ + && test ! $(which python3.9) + +SHELL [ "/bin/bash", "-c" ] +ENV LANG=C.UTF-8 + +# set workdir +RUN mkdir -p /opt/code/localstack +RUN mkdir /opt/code/localstack/localstack-core +WORKDIR /opt/code/localstack/ + +# create localstack user and filesystem hierarchy, perform some permission fixes +RUN chmod 777 . && \ + useradd -ms /bin/bash localstack && \ + mkdir -p /var/lib/localstack && \ + chmod -R 777 /var/lib/localstack && \ + mkdir -p /usr/lib/localstack && \ + mkdir /tmp/localstack && \ chmod -R 777 /tmp/localstack && \ - chown -R `id -un`:`id -gn` . && \ - adduser -D localstack && \ - ln -s `pwd` /tmp/localstack_install_dir + touch /tmp/localstack/.marker && \ + mkdir -p /.npm && \ + chmod 755 /root && \ + chmod -R 777 /.npm -# expose default environment (required for aws-cli to work) -ENV MAVEN_CONFIG=/opt/code/localstack \ - USER=localstack +# install the entrypoint script +ADD bin/docker-entrypoint.sh /usr/local/bin/ +# add the shipped hosts file to prevent performance degredation in windows container mode on windows +# (where hosts file is not mounted) See https://github.com/localstack/localstack/issues/5178 +ADD bin/hosts /etc/hosts -# expose service & web dashboard ports -EXPOSE 4567-4583 8080 +# expose default environment +# Set edge bind host so localstack can be reached by other containers +# set library path and default LocalStack hostname +ENV USER=localstack +ENV PYTHONUNBUFFERED=1 -# install supervisor daemon & copy config file -ADD bin/supervisord.conf /etc/supervisord.conf +# Install the latest version of awslocal globally +RUN --mount=type=cache,target=/root/.cache \ + pip3 install --upgrade awscli awscli-local requests -# define command at startup -ENTRYPOINT ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] -# run tests (to verify the build before pushing the image) -ADD tests/ tests/ -RUN make test +# +# builder: Stage which installs the dependencies of LocalStack Community +# +FROM base AS builder +ARG TARGETARCH + +# Install build dependencies to base +RUN --mount=type=cache,target=/var/cache/apt \ + apt-get update && \ + # Install dependencies to add additional repos + # g++ is a workaround to fix the JPype1 compile error on ARM Linux "gcc: fatal error: cannot execute β€˜cc1plus’" + apt-get install -y gcc g++ + +# upgrade python build tools +RUN --mount=type=cache,target=/root/.cache \ + (python -m venv .venv && . .venv/bin/activate && pip3 install --upgrade pip wheel setuptools) + +# add files necessary to install runtime dependencies +ADD Makefile pyproject.toml requirements-runtime.txt ./ +# add the localstack start scripts (necessary for the installation of the runtime dependencies, i.e. `pip install -e .`) +ADD bin/localstack bin/localstack.bat bin/localstack-supervisor bin/ + +# Install dependencies for running the LocalStack runtime +RUN --mount=type=cache,target=/root/.cache\ + . .venv/bin/activate && pip3 install -r requirements-runtime.txt + + +# +# final stage: Builds upon base stage and copies resources from builder stages +# +FROM base +COPY --from=builder /opt/code/localstack/.venv /opt/code/localstack/.venv +# The build version is set in the docker-helper.sh script to be the output of setuptools_scm +ARG LOCALSTACK_BUILD_VERSION + +# add project files necessary to install all dependencies +ADD Makefile pyproject.toml ./ +# add the localstack start scripts (necessary for the installation of the runtime dependencies, i.e. `pip install -e .`) +ADD bin/localstack bin/localstack.bat bin/localstack-supervisor bin/ + +# add the code as late as possible +ADD localstack-core/ /opt/code/localstack/localstack-core + +# Install LocalStack Community and generate the version file while doing so +RUN --mount=type=cache,target=/root/.cache \ + . .venv/bin/activate && \ + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LOCALSTACK_CORE=${LOCALSTACK_BUILD_VERSION} \ + pip install -e .[runtime] + +# Generate the plugin entrypoints +RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LOCALSTACK_CORE=${LOCALSTACK_BUILD_VERSION} \ + make entrypoints + +# Generate service catalog cache in static libs dir +RUN . .venv/bin/activate && python3 -m localstack.aws.spec + +# Install packages which should be shipped by default +RUN --mount=type=cache,target=/root/.cache \ + --mount=type=cache,target=/var/lib/localstack/cache \ + source .venv/bin/activate && \ + python -m localstack.cli.lpm install \ + lambda-runtime \ + jpype-jsonata \ + dynamodb-local && \ + chown -R localstack:localstack /usr/lib/localstack && \ + chmod -R 777 /usr/lib/localstack + +# link the python package installer virtual environments into the localstack venv +RUN echo /var/lib/localstack/lib/python-packages/lib/python3.11/site-packages > localstack-var-python-packages-venv.pth && \ + mv localstack-var-python-packages-venv.pth .venv/lib/python*/site-packages/ +RUN echo /usr/lib/localstack/python-packages/lib/python3.11/site-packages > localstack-static-python-packages-venv.pth && \ + mv localstack-static-python-packages-venv.pth .venv/lib/python*/site-packages/ + +# expose edge service, external service ports, and debugpy +EXPOSE 4566 4510-4559 5678 + +HEALTHCHECK --interval=10s --start-period=15s --retries=5 --timeout=10s CMD /opt/code/localstack/.venv/bin/localstack status services --format=json + +# default volume directory +VOLUME /var/lib/localstack + +# mark the image version +RUN touch /usr/lib/localstack/.community-version + +LABEL authors="LocalStack Contributors" +LABEL maintainer="LocalStack Team (info@localstack.cloud)" +LABEL description="LocalStack Docker image" + +# Add the build date and git hash at last (changes everytime) +ARG LOCALSTACK_BUILD_DATE +ARG LOCALSTACK_BUILD_GIT_HASH +ENV LOCALSTACK_BUILD_DATE=${LOCALSTACK_BUILD_DATE} +ENV LOCALSTACK_BUILD_GIT_HASH=${LOCALSTACK_BUILD_GIT_HASH} +ENV LOCALSTACK_BUILD_VERSION=${LOCALSTACK_BUILD_VERSION} + +# define command at startup +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/Dockerfile.s3 b/Dockerfile.s3 new file mode 100644 index 0000000000000..59c4ef1cc0706 --- /dev/null +++ b/Dockerfile.s3 @@ -0,0 +1,133 @@ +# base: Stage which installs necessary runtime dependencies (OS packages, filesystem...) +FROM python:3.11.13-slim-bookworm@sha256:139020233cc412efe4c8135b0efe1c7569dc8b28ddd88bddb109b764f8977e30 AS base +ARG TARGETARCH + +# set workdir +RUN mkdir -p /opt/code/localstack +RUN mkdir /opt/code/localstack/localstack-core +WORKDIR /opt/code/localstack/ + +# Install runtime OS package dependencies +RUN --mount=type=cache,target=/var/cache/apt \ + apt-get update && \ + # Install dependencies to add additional repos + apt-get install -y --no-install-recommends \ + # Runtime packages (groff-base is necessary for AWS CLI help) + ca-certificates curl make openssl +# TODO: add this if we need the DNS server: iputils-ping iproute2 + +SHELL [ "/bin/bash", "-c" ] + +# create localstack user and filesystem hierarchy, perform some permission fixes +RUN chmod 777 . && \ + useradd -ms /bin/bash localstack && \ + mkdir -p /var/lib/localstack && \ + chmod -R 777 /var/lib/localstack && \ + mkdir -p /usr/lib/localstack && \ + mkdir /tmp/localstack && \ + chmod -R 777 /tmp/localstack && \ + touch /tmp/localstack/.marker + +# install the entrypoint script +ADD bin/docker-entrypoint.sh /usr/local/bin/ +# add the shipped hosts file to prevent performance degredation in windows container mode on windows +# (where hosts file is not mounted) See https://github.com/localstack/localstack/issues/5178 +ADD bin/hosts /etc/hosts + +# expose default environment +# Set edge bind host so localstack can be reached by other containers +# set library path and default LocalStack hostname +ENV USER=localstack +ENV PYTHONUNBUFFERED=1 + + +# builder: Stage which installs the dependencies of LocalStack Community +FROM base AS builder +ARG TARGETARCH + +# Install build dependencies to base +RUN --mount=type=cache,target=/var/cache/apt \ + apt-get update && \ + # Install dependencies to add additional repos + apt-get install -y gcc + +# upgrade python build tools +RUN --mount=type=cache,target=/root/.cache \ + (python3 -m venv .venv && . .venv/bin/activate && pip3 install --upgrade pip wheel setuptools setuptools_scm build) + +# add files necessary to install all dependencies +ADD Makefile pyproject.toml requirements-base-runtime.txt ./ +# add the localstack start scripts (necessary for the installation of the runtime dependencies, i.e. `pip install -e .`) +ADD bin/localstack bin/localstack.bat bin/localstack-supervisor bin/ + +# Install dependencies for running the LocalStack base runtime (for S3) +RUN --mount=type=cache,target=/root/.cache \ + . .venv/bin/activate && pip3 install -r requirements-base-runtime.txt + +# delete the botocore specs for other services (>80mb) +# TODO: well now it's compressed and it's much lighter: 20mb maybe not worth it +RUN find .venv/lib/python3.11/site-packages/botocore/data/ -mindepth 1 -maxdepth 1 -type d -not -name s3 -exec rm -rf '{}' \; + + +# final stage: Builds upon base stage and copies resources from builder stages +FROM base +COPY --from=builder /opt/code/localstack/.venv /opt/code/localstack/.venv +# The build version is set in the docker-helper.sh script to be the output of setuptools_scm +ARG LOCALSTACK_BUILD_VERSION + +# add project files necessary to install all dependencies +ADD Makefile pyproject.toml requirements-base-runtime.txt ./ +# add the localstack start scripts (necessary for the installation of the runtime dependencies, i.e. `pip install -e .`) +ADD bin/localstack bin/localstack.bat bin/localstack-supervisor bin/ + +# add the code as late as possible +ADD localstack-core/ /opt/code/localstack/localstack-core + +# Install LocalStack Community and generate the version file while doing so +RUN --mount=type=cache,target=/root/.cache \ + . .venv/bin/activate && \ + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LOCALSTACK_CORE=${LOCALSTACK_BUILD_VERSION} \ + pip install -e .[base-runtime] + +# Generate the plugin entrypoints +RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_LOCALSTACK_CORE=${LOCALSTACK_BUILD_VERSION} \ + make entrypoints + +# Generate service catalog cache in static libs dir +RUN . .venv/bin/activate && python3 -m localstack.aws.spec + +# link the python package installer virtual environments into the localstack venv +RUN echo /var/lib/localstack/lib/python-packages/lib/python3.11/site-packages > localstack-var-python-packages-venv.pth && \ + mv localstack-var-python-packages-venv.pth .venv/lib/python*/site-packages/ +RUN echo /usr/lib/localstack/python-packages/lib/python3.11/site-packages > localstack-static-python-packages-venv.pth && \ + mv localstack-static-python-packages-venv.pth .venv/lib/python*/site-packages/ + +# expose edge service and debugpy +EXPOSE 4566 5678 + +HEALTHCHECK --interval=10s --start-period=15s --retries=5 --timeout=10s CMD /opt/code/localstack/.venv/bin/localstack status services --format=json + +# default volume directory +VOLUME /var/lib/localstack + +# mark the image version +RUN touch /usr/lib/localstack/.s3-version + +LABEL authors="LocalStack Contributors" +LABEL maintainer="LocalStack Team (info@localstack.cloud)" +LABEL description="LocalStack S3 Docker image" + +# Add the build date and git hash at last (changes everytime) +ARG LOCALSTACK_BUILD_DATE +ARG LOCALSTACK_BUILD_GIT_HASH +ENV LOCALSTACK_BUILD_DATE=${LOCALSTACK_BUILD_DATE} +ENV LOCALSTACK_BUILD_GIT_HASH=${LOCALSTACK_BUILD_GIT_HASH} +ENV LOCALSTACK_BUILD_VERSION=${LOCALSTACK_BUILD_VERSION} +ENV EAGER_SERVICE_LOADING=1 +ENV SERVICES=s3 +ENV GATEWAY_SERVER=twisted +# TODO: do we need DNS for the S3 image? +ENV DNS_ADDRESS=false + +# define command at startup +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/LICENSE.txt b/LICENSE.txt index 735de706571ab..cdb01382c05f9 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2017 LocalStack contributors +Copyright (c) 2017+ LocalStack contributors Copyright (c) 2016 Atlassian Pty Ltd Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/MANIFEST.in b/MANIFEST.in index b433042ee66b3..07442c11a993f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,10 @@ -include requirements.txt +exclude .github/** +exclude .circleci/** +exclude docs/** +exclude tests/** +exclude .test_durations +exclude .gitignore +exclude .pre-commit-config.yaml +exclude .python-version include Makefile -recursive-include localstack/ext *.java -recursive-include localstack/ext pom.xml -recursive-include localstack/utils/kinesis *.java -recursive-include localstack/utils/kinesis *.py -recursive-include localstack/dashboard/web * -prune localstack/dashboard/web/node_modules \ No newline at end of file +include LICENSE.txt diff --git a/Makefile b/Makefile index af56a8f6dab4c..4f926170e9272 100644 --- a/Makefile +++ b/Makefile @@ -1,122 +1,157 @@ IMAGE_NAME ?= localstack/localstack -IMAGE_NAME_BASE ?= localstack/java-maven-node-python -IMAGE_TAG ?= $(shell cat localstack/constants.py | grep '^VERSION =' | sed "s/VERSION = ['\"]\(.*\)['\"].*/\1/") +DEFAULT_TAG ?= latest +VENV_BIN ?= python3 -m venv VENV_DIR ?= .venv -VENV_RUN = . $(VENV_DIR)/bin/activate -PIP_CMD ?= pip - -usage: ## Show this help - @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' - -install: ## Install dependencies in virtualenv - (test `which virtualenv` || $(PIP_CMD) install --user virtualenv) && \ - (test -e $(VENV_DIR) || virtualenv $(VENV_OPTS) $(VENV_DIR)) && \ - ($(VENV_RUN) && $(PIP_CMD) install --upgrade pip) && \ - (test ! -e requirements.txt || ($(VENV_RUN); $(PIP_CMD) install six==1.10.0 ; $(PIP_CMD) -q install -r requirements.txt) && \ - $(VENV_RUN); PYTHONPATH=. exec python localstack/services/install.py testlibs) - -install-web: ## Install npm dependencies for dashboard Web UI - (cd localstack/dashboard/web && (test ! -e package.json || npm install --silent > /dev/null)) - -publish: ## Publish the library to the central PyPi repository - # build and upload archive - ($(VENV_RUN) && ./setup.py sdist upload) - -publish-maven: ## Publish artifacts to Maven Central - (cd localstack/ext/java/; mvn -Pfatjar clean javadoc:jar source:jar package deploy) - -coveralls: ## Publish coveralls metrics - ($(VENV_RUN); coveralls) - -init: ## Initialize the infrastructure, make sure all libs are downloaded - $(VENV_RUN); PYTHONPATH=. exec python localstack/services/install.py libs - -infra: ## Manually start the local infrastructure for testing - ($(VENV_RUN); exec bin/localstack start) - -docker-build: ## Build Docker image - docker build -t $(IMAGE_NAME) . - # remove topmost layer ("make test") from image - LAST_BUT_ONE_LAYER=`docker history -q $(IMAGE_NAME) | head -n 2 | tail -n 1`; \ - docker tag $$LAST_BUT_ONE_LAYER $(IMAGE_NAME); \ - docker tag $$LAST_BUT_ONE_LAYER $(IMAGE_NAME):$(IMAGE_TAG) - -docker-build-base: - docker build -t $(IMAGE_NAME_BASE) -f bin/Dockerfile.base . - docker tag $(IMAGE_NAME_BASE) $(IMAGE_NAME_BASE):$(IMAGE_TAG) - which docker-squash || $(PIP_CMD) install docker-squash - docker-squash -t $(IMAGE_NAME_BASE):$(IMAGE_TAG) $(IMAGE_NAME_BASE):$(IMAGE_TAG) - docker tag $(IMAGE_NAME_BASE):$(IMAGE_TAG) $(IMAGE_NAME_BASE):latest - -docker-push: ## Push Docker image to registry - docker push $(IMAGE_NAME):$(IMAGE_TAG) - -docker-push-master:## Push Docker image to registry IF we are currently on the master branch - (CURRENT_BRANCH=`(git rev-parse --abbrev-ref HEAD | grep '^master$$' || ((git branch -a | grep 'HEAD detached at [0-9a-zA-Z]*)') && git branch -a)) | grep '^[* ]*master$$' | sed 's/[* ]//g' || true`; \ - test "$$CURRENT_BRANCH" != 'master' && echo "Not on master branch.") || \ - ((test "$$DOCKER_USERNAME" = '' || test "$$DOCKER_PASSWORD" = '' ) && \ - echo "Skipping docker push as no credentials are provided.") || \ - (REMOTE_ORIGIN="`git remote -v | grep '/localstack' | grep origin | grep push | awk '{print $$2}'`"; \ - test "$$REMOTE_ORIGIN" != 'https://github.com/localstack/localstack.git' && \ - echo "This is a fork and not the main repo.") || \ - ( \ - which $(PIP_CMD) || (wget https://bootstrap.pypa.io/get-pip.py && python get-pip.py); \ - which docker-squash || $(PIP_CMD) install docker-squash; \ - docker info | grep Username || docker login -u $$DOCKER_USERNAME -p $$DOCKER_PASSWORD; \ - BASE_IMAGE_ID=`docker history -q $(IMAGE_NAME):$(IMAGE_TAG) | tail -n 1`; \ - docker-squash -t $(IMAGE_NAME):$(IMAGE_TAG) -f $$BASE_IMAGE_ID $(IMAGE_NAME):$(IMAGE_TAG) && \ - docker tag $(IMAGE_NAME):$(IMAGE_TAG) $(IMAGE_NAME):latest; \ - ((! (git diff HEAD~1 localstack/constants.py | grep '^+VERSION =') && echo "Only pushing tag 'latest' as version has not changed.") || \ - docker push $(IMAGE_NAME):$(IMAGE_TAG)) && \ - docker push $(IMAGE_NAME):latest \ - ) - -docker-run: ## Run Docker image locally - ($(VENV_RUN); bin/localstack start --docker) - -docker-mount-run: - ENTRYPOINT="-v `pwd`/localstack/constants.py:/opt/code/localstack/localstack/constants.py -v `pwd`/localstack/config.py:/opt/code/localstack/localstack/config.py -v `pwd`/localstack/plugins.py:/opt/code/localstack/localstack/plugins.py -v `pwd`/localstack/utils:/opt/code/localstack/localstack/utils -v `pwd`/localstack/services:/opt/code/localstack/localstack/services -v `pwd`/tests:/opt/code/localstack/tests" make docker-run - -web: ## Start web application (dashboard) - ($(VENV_RUN); bin/localstack web) - -test: ## Run automated tests - make lint && \ - ($(VENV_RUN); DEBUG=$(DEBUG) PYTHONPATH=`pwd` nosetests --with-coverage --logging-level=WARNING --nocapture --no-skip --exe --cover-erase --cover-tests --cover-inclusive --cover-package=localstack --with-xunit --exclude='$(VENV_DIR).*' --ignore-files='lambda_python3.py' .) - -test-java: ## Run tests for Java/JUnit compatibility - cd localstack/ext/java; mvn -q test && USE_SSL=1 mvn -q test - -test-java-if-changed: - @(! (git log -n 1 --no-merges --raw | grep localstack/ext/java/)) || make test-java - -test-java-docker: - ENTRYPOINT="--entrypoint=" CMD="make test-java" make docker-run - -test-docker: ## Run automated tests in Docker - ENTRYPOINT="--entrypoint=" CMD="make test" make docker-run - -test-docker-mount: - ENTRYPOINT="--entrypoint= -v `pwd`/localstack/constants.py:/opt/code/localstack/localstack/constants.py -v `pwd`/localstack/utils:/opt/code/localstack/localstack/utils -v `pwd`/localstack/services:/opt/code/localstack/localstack/services -v `pwd`/tests:/opt/code/localstack/tests" CMD="make test" make docker-run - -reinstall-p2: ## Re-initialize the virtualenv with Python 2.x - rm -rf $(VENV_DIR) - PIP_CMD=pip2 VENV_OPTS="-p `which python2`" make install - -reinstall-p3: ## Re-initialize the virtualenv with Python 3.x - rm -rf $(VENV_DIR) - PIP_CMD=pip3 VENV_OPTS="-p `which python3`" make install +PIP_CMD ?= pip3 +TEST_PATH ?= . +TEST_EXEC ?= python -m +PYTEST_LOGLEVEL ?= warning + +uname_m := $(shell uname -m) +ifeq ($(uname_m),x86_64) +platform = amd64 +else +platform = arm64 +endif + +ifeq ($(OS), Windows_NT) + VENV_ACTIVATE = $(VENV_DIR)/Scripts/activate +else + VENV_ACTIVATE = $(VENV_DIR)/bin/activate +endif + +VENV_RUN = . $(VENV_ACTIVATE) + +usage: ## Show this help + @grep -Fh "##" $(MAKEFILE_LIST) | grep -Fv fgrep | sed -e 's/:.*##\s*/##/g' | awk -F'##' '{ printf "%-25s %s\n", $$1, $$2 }' + +$(VENV_ACTIVATE): pyproject.toml + test -d $(VENV_DIR) || $(VENV_BIN) $(VENV_DIR) + $(VENV_RUN); $(PIP_CMD) install --upgrade pip setuptools wheel plux + touch $(VENV_ACTIVATE) + +venv: $(VENV_ACTIVATE) ## Create a new (empty) virtual environment + +freeze: ## Run pip freeze -l in the virtual environment + @$(VENV_RUN); pip freeze -l + +upgrade-pinned-dependencies: venv + $(VENV_RUN); $(PIP_CMD) install --upgrade pip-tools pre-commit + $(VENV_RUN); pip-compile --strip-extras --upgrade -o requirements-basic.txt pyproject.toml + $(VENV_RUN); pip-compile --strip-extras --upgrade --extra runtime -o requirements-runtime.txt pyproject.toml + $(VENV_RUN); pip-compile --strip-extras --upgrade --extra test -o requirements-test.txt pyproject.toml + $(VENV_RUN); pip-compile --strip-extras --upgrade --extra dev -o requirements-dev.txt pyproject.toml + $(VENV_RUN); pip-compile --strip-extras --upgrade --extra typehint -o requirements-typehint.txt pyproject.toml + $(VENV_RUN); pip-compile --strip-extras --upgrade --extra base-runtime -o requirements-base-runtime.txt pyproject.toml + $(VENV_RUN); pre-commit autoupdate + +install-basic: venv ## Install basic dependencies for CLI usage into venv + $(VENV_RUN); $(PIP_CMD) install -r requirements-basic.txt + $(VENV_RUN); $(PIP_CMD) install $(PIP_OPTS) -e . + +install-runtime: venv ## Install dependencies for the localstack runtime into venv + $(VENV_RUN); $(PIP_CMD) install -r requirements-runtime.txt + $(VENV_RUN); $(PIP_CMD) install $(PIP_OPTS) -e ".[runtime]" + +install-test: venv ## Install requirements to run tests into venv + $(VENV_RUN); $(PIP_CMD) install -r requirements-test.txt + $(VENV_RUN); $(PIP_CMD) install $(PIP_OPTS) -e ".[test]" + +install-dev: venv ## Install developer requirements into venv + $(VENV_RUN); $(PIP_CMD) install -r requirements-dev.txt + $(VENV_RUN); $(PIP_CMD) install $(PIP_OPTS) -e ".[dev]" + +install-dev-types: venv ## Install developer requirements incl. type hints into venv + $(VENV_RUN); $(PIP_CMD) install -r requirements-typehint.txt + $(VENV_RUN); $(PIP_CMD) install $(PIP_OPTS) -e ".[typehint]" + +install-s3: venv ## Install dependencies for the localstack runtime for s3-only into venv + $(VENV_RUN); $(PIP_CMD) install -r requirements-base-runtime.txt + $(VENV_RUN); $(PIP_CMD) install $(PIP_OPTS) -e ".[base-runtime]" + +install: install-dev entrypoints ## Install full dependencies into venv + +entrypoints: ## Run plux to build entry points + $(VENV_RUN); python3 -c "from setuptools import setup; setup()" plugins egg_info + @# make sure that the entrypoints were correctly created and are non-empty + @test -s localstack-core/localstack_core.egg-info/entry_points.txt || (echo "Entrypoints were not correctly created! Aborting!" && exit 1) + +dist: entrypoints ## Build source and built (wheel) distributions of the current version + $(VENV_RUN); pip install --upgrade twine; python -m build + +publish: clean-dist dist ## Publish the library to the central PyPi repository + # make sure the dist archive contains a non-empty entry_points.txt file before uploading + tar --wildcards --to-stdout -xf dist/localstack?core*.tar.gz "localstack?core*/localstack-core/localstack_core.egg-info/entry_points.txt" | grep . > /dev/null 2>&1 || (echo "Refusing upload, localstack-core dist does not contain entrypoints." && exit 1) + $(VENV_RUN); twine upload dist/* + +coveralls: ## Publish coveralls metrics + $(VENV_RUN); coveralls + +start: ## Manually start the local infrastructure for testing + ($(VENV_RUN); exec bin/localstack start --host) + +docker-run-tests: ## Initializes the test environment and runs the tests in a docker container + docker run -e LOCALSTACK_INTERNAL_TEST_COLLECT_METRIC=1 --entrypoint= -v `pwd`/.git:/opt/code/localstack/.git -v `pwd`/requirements-test.txt:/opt/code/localstack/requirements-test.txt -v `pwd`/.test_durations:/opt/code/localstack/.test_durations -v `pwd`/tests/:/opt/code/localstack/tests/ -v `pwd`/dist/:/opt/code/localstack/dist/ -v `pwd`/target/:/opt/code/localstack/target/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/localstack:/var/lib/localstack \ + $(IMAGE_NAME):$(DEFAULT_TAG) \ + bash -c "make install-test && DEBUG=$(DEBUG) PYTEST_LOGLEVEL=$(PYTEST_LOGLEVEL) PYTEST_ARGS='$(PYTEST_ARGS)' COVERAGE_FILE='$(COVERAGE_FILE)' JUNIT_REPORTS_FILE=$(JUNIT_REPORTS_FILE) TEST_PATH='$(TEST_PATH)' LAMBDA_IGNORE_ARCHITECTURE=1 LAMBDA_INIT_POST_INVOKE_WAIT_MS=50 TINYBIRD_PYTEST_ARGS='$(TINYBIRD_PYTEST_ARGS)' TINYBIRD_DATASOURCE='$(TINYBIRD_DATASOURCE)' TINYBIRD_TOKEN='$(TINYBIRD_TOKEN)' TINYBIRD_URL='$(TINYBIRD_URL)' CI_REPOSITORY_NAME='$(CI_REPOSITORY_NAME)' CI_WORKFLOW_NAME='$(CI_WORKFLOW_NAME)' CI_COMMIT_BRANCH='$(CI_COMMIT_BRANCH)' CI_COMMIT_SHA='$(CI_COMMIT_SHA)' CI_JOB_URL='$(CI_JOB_URL)' CI_JOB_NAME='$(CI_JOB_NAME)' CI_JOB_ID='$(CI_JOB_ID)' CI='$(CI)' TEST_AWS_REGION_NAME='${TEST_AWS_REGION_NAME}' TEST_AWS_ACCESS_KEY_ID='${TEST_AWS_ACCESS_KEY_ID}' TEST_AWS_ACCOUNT_ID='${TEST_AWS_ACCOUNT_ID}' make test-coverage" + +docker-run-tests-s3-only: ## Initializes the test environment and runs the tests in a docker container for the S3 only image + # TODO: We need node as it's a dependency of the InfraProvisioner at import time, remove when we do not need it anymore + # g++ is a workaround to fix the JPype1 compile error on ARM Linux "gcc: fatal error: cannot execute β€˜cc1plus’" because the test dependencies include the runtime dependencies. + docker run -e LOCALSTACK_INTERNAL_TEST_COLLECT_METRIC=1 --entrypoint= -v `pwd`/.git:/opt/code/localstack/.git -v `pwd`/requirements-test.txt:/opt/code/localstack/requirements-test.txt -v `pwd`/tests/:/opt/code/localstack/tests/ -v `pwd`/target/:/opt/code/localstack/target/ -v /var/run/docker.sock:/var/run/docker.sock -v /tmp/localstack:/var/lib/localstack \ + $(IMAGE_NAME):$(DEFAULT_TAG) \ + bash -c "apt-get update && apt-get install -y g++ git && make install-test && apt-get install -y --no-install-recommends gnupg && mkdir -p /etc/apt/keyrings && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && echo \"deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main\" > /etc/apt/sources.list.d/nodesource.list && apt-get update && apt-get install -y --no-install-recommends nodejs && DEBUG=$(DEBUG) PYTEST_LOGLEVEL=$(PYTEST_LOGLEVEL) PYTEST_ARGS='$(PYTEST_ARGS)' TEST_PATH='$(TEST_PATH)' TINYBIRD_PYTEST_ARGS='$(TINYBIRD_PYTEST_ARGS)' TINYBIRD_DATASOURCE='$(TINYBIRD_DATASOURCE)' TINYBIRD_TOKEN='$(TINYBIRD_TOKEN)' TINYBIRD_URL='$(TINYBIRD_URL)' CI_COMMIT_BRANCH='$(CI_COMMIT_BRANCH)' CI_COMMIT_SHA='$(CI_COMMIT_SHA)' CI_JOB_URL='$(CI_JOB_URL)' CI_JOB_NAME='$(CI_JOB_NAME)' CI_JOB_ID='$(CI_JOB_ID)' CI='$(CI)' make test" + + +docker-cp-coverage: + @echo 'Extracting .coverage file from Docker image'; \ + id=$$(docker create localstack/localstack); \ + docker cp $$id:/opt/code/localstack/.coverage .coverage; \ + docker rm -v $$id + +test: ## Run automated tests + ($(VENV_RUN); $(TEST_EXEC) pytest --durations=10 --log-cli-level=$(PYTEST_LOGLEVEL) --junitxml=$(JUNIT_REPORTS_FILE) $(PYTEST_ARGS) $(TEST_PATH)) + +test-coverage: LOCALSTACK_INTERNAL_TEST_COLLECT_METRIC = 1 +test-coverage: TEST_EXEC = python -m coverage run $(COVERAGE_ARGS) -m +test-coverage: test ## Run automated tests and create coverage report + +lint: ## Run code linter to check code style, check if formatter would make changes and check if dependency pins need to be updated + @[ -f localstack-core/localstack/__init__.py ] && echo "localstack-core/localstack/__init__.py will break packaging." && exit 1 || : + ($(VENV_RUN); python -m ruff check --output-format=full . && python -m ruff format --check --diff .) + $(VENV_RUN); pre-commit run check-pinned-deps-for-needed-upgrade --files pyproject.toml # run pre-commit hook manually here to ensure that this check runs in CI as well + $(VENV_RUN); openapi-spec-validator localstack-core/localstack/openapi.yaml + $(VENV_RUN); cd localstack-core && mypy --install-types --non-interactive + +lint-modified: ## Run code linter to check code style, check if formatter would make changes on modified files, and check if dependency pins need to be updated because of modified files + ($(VENV_RUN); python -m ruff check --output-format=full `git diff --diff-filter=d --name-only HEAD | grep '\.py$$' | xargs` && python -m ruff format --check `git diff --diff-filter=d --name-only HEAD | grep '\.py$$' | xargs`) + $(VENV_RUN); pre-commit run check-pinned-deps-for-needed-upgrade --files $(git diff master --name-only) # run pre-commit hook manually here to ensure that this check runs in CI as well + +check-aws-markers: ## Lightweight check to ensure all AWS tests have proper compatibility markers set + ($(VENV_RUN); python -m pytest --co tests/aws/) -lint: ## Run code linter to check code style - ($(VENV_RUN); flake8 --inline-quotes=single --show-source --max-line-length=120 --ignore=E128 --exclude=node_modules,$(VENV_DIR)*,dist .) +format: ## Run ruff to format the whole codebase + ($(VENV_RUN); python -m ruff format .; python -m ruff check --output-format=full --fix .) + +format-modified: ## Run ruff to format only modified code + ($(VENV_RUN); python -m ruff format `git diff --diff-filter=d --name-only HEAD | grep '\.py$$' | xargs`; python -m ruff check --output-format=full --fix `git diff --diff-filter=d --name-only HEAD | grep '\.py$$' | xargs`) + +init-precommit: ## install te pre-commit hook into your local git repository + ($(VENV_RUN); pre-commit install) + +docker-build: + IMAGE_NAME=$(IMAGE_NAME) PLATFORM=$(platform) ./bin/docker-helper.sh build -clean: ## Clean up (npm dependencies, downloaded infrastructure code, compiled Java classes) - rm -rf localstack/dashboard/web/node_modules/ - rm -rf localstack/infra/amazon-kinesis-client - rm -rf localstack/infra/elasticsearch - rm -rf localstack/infra/dynamodb - rm -rf localstack/node_modules/ +clean: ## Clean up (npm dependencies, downloaded infrastructure code, compiled Java classes) + rm -rf .filesystem + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info + rm -rf localstack-core/*.egg-info rm -rf $(VENV_DIR) - rm -f localstack/utils/kinesis/java/com/atlassian/*.class -.PHONY: usage compile clean install web install-web infra test +clean-dist: ## Clean up python distribution directories + rm -rf dist/ build/ + rm -rf localstack-core/*.egg-info + +.PHONY: usage freeze install-basic install-runtime install-test install-dev install entrypoints dist publish coveralls start docker-run-tests docker-cp-coverage test test-coverage lint lint-modified format format-modified init-precommit clean clean-dist upgrade-pinned-dependencies diff --git a/README.md b/README.md index 87cd896fea4f7..4bc4c4ff512fb 100644 --- a/README.md +++ b/README.md @@ -1,422 +1,202 @@ -[![Build Status](https://travis-ci.org/localstack/localstack.png)](https://travis-ci.org/localstack/localstack) [![Backers on Open Collective](https://opencollective.com/localstack/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/localstack/sponsors/badge.svg)](#sponsors) [![Coverage Status](https://coveralls.io/repos/github/atlassian/localstack/badge.svg?branch=master)](https://coveralls.io/github/atlassian/localstack?branch=master) -[![Gitter](https://img.shields.io/gitter/room/localstack/Platform.svg)](https://gitter.im/localstack/Platform) -[![PyPI Version](https://badge.fury.io/py/localstack.svg)](https://badge.fury.io/py/localstack) -[![PyPI License](https://img.shields.io/pypi/l/localstack.svg)](https://img.shields.io/pypi/l/localstack.svg) -[![Code Climate](https://codeclimate.com/github/atlassian/localstack/badges/gpa.svg)](https://codeclimate.com/github/atlassian/localstack) -[![Twitter](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/_localstack) - -# LocalStack - A fully functional local AWS cloud stack - -![LocalStack](https://github.com/localstack/localstack/raw/master/localstack/dashboard/web/img/localstack.png) - -*LocalStack* provides an easy-to-use test/mocking framework for developing Cloud applications. - -Currently, the focus is primarily on supporting the AWS cloud stack. - -# Announcements - -* **2017-08-27**: **We need your support!** LocalStack is growing fast, we now have thousands of developers using the platform on a regular basis. Last month we have recorded a staggering 100k test runs, with 25k+ DynamoDB tables, 20k+ SQS queues, 15k+ Kinesis streams, 13k+ S3 buckets, and 10k+ Lambda functions created locally - for 0$ costs (more details to be published soon). Bug and feature requests are pouring in, and we now need some support from _you_ to keep the open source version actively maintained. Please check out [Open Collective](https://opencollective.com/localstack) and become a [backer](https://github.com/localstack/localstack#backers) or [supporter](https://github.com/localstack/localstack#backers) of the project today! Thanks everybody for contributing. β™₯ -* **2017-07-20**: Please note: Starting with version `0.7.0`, the Docker image will be pushed -and kept up to date under the **new name** `localstack/localstack`. (This means that you may -have to update your CI configurations.) Please refer to the updated -**[End-User License Agreement (EULA)](doc/end_user_license_agreement)** for the new versions. -The old Docker image (`atlassianlabs/localstack`) is still available but will not be maintained -any longer. +<p align="center"> +:zap: We are thrilled to announce the release of <a href="https://blog.localstack.cloud/localstack-for-aws-release-v-4-6-0/">LocalStack 4.6</a> :zap: +</p> + +<p align="center"> + <img src="https://raw.githubusercontent.com/localstack/localstack/master/docs/localstack-readme-banner.svg" alt="LocalStack - A fully functional local cloud stack"> +</p> + +<p align="center"> + <a href="https://github.com/localstack/localstack/actions/workflows/aws-main.yml?query=branch%3Amaster"><img alt="GitHub Actions" src="https://github.com/localstack/localstack/actions/workflows/aws-main.yml/badge.svg?branch=master"></a> + <a href="https://coveralls.io/github/localstack/localstack?branch=master"><img alt="Coverage Status" src="https://coveralls.io/repos/github/localstack/localstack/badge.svg?branch=master"></a> + <a href="https://pypi.org/project/localstack/"><img alt="PyPI Version" src="https://img.shields.io/pypi/v/localstack?color=blue"></a> + <a href="https://hub.docker.com/r/localstack/localstack"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/localstack/localstack"></a> + <a href="https://pypi.org/project/localstack"><img alt="PyPi downloads" src="https://static.pepy.tech/badge/localstack"></a> + <a href="#backers"><img alt="Backers on Open Collective" src="https://opencollective.com/localstack/backers/badge.svg"></a> + <a href="#sponsors"><img alt="Sponsors on Open Collective" src="https://opencollective.com/localstack/sponsors/badge.svg"></a> + <a href="https://img.shields.io/pypi/l/localstack.svg"><img alt="PyPI License" src="https://img.shields.io/pypi/l/localstack.svg"></a> + <a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a> + <a href="https://github.com/astral-sh/ruff"><img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json"></a> + <a href="https://bsky.app/profile/localstack.cloud"><img alt="Bluesky" src="https://img.shields.io/badge/bluesky-Follow-blue?logo=bluesky"></a> +</p> + +<p align="center"> + LocalStack is a cloud software development framework to develop and test your AWS applications locally. +</p> + +<p align="center"> + <a href="#overview">Overview</a> β€’ + <a href="#install">Install</a> β€’ + <a href="#quickstart">Quickstart</a> β€’ + <a href="#running">Run</a> β€’ + <a href="#usage">Usage</a> β€’ + <a href="#releases">Releases</a> β€’ + <a href="#contributing">Contributing</a> + <br/> + <a href="https://docs.localstack.cloud" target="_blank">πŸ“– Docs</a> β€’ + <a href="https://app.localstack.cloud" target="_blank">πŸ’» Pro version</a> β€’ + <a href="https://docs.localstack.cloud/references/coverage/" target="_blank">β˜‘οΈ LocalStack coverage</a> +</p> + +--- # Overview -*LocalStack* spins up the following core Cloud APIs on your local machine: - -* **API Gateway** at http://localhost:4567 -* **Kinesis** at http://localhost:4568 -* **DynamoDB** at http://localhost:4569 -* **DynamoDB Streams** at http://localhost:4570 -* **Elasticsearch** at http://localhost:4571 -* **S3** at http://localhost:4572 -* **Firehose** at http://localhost:4573 -* **Lambda** at http://localhost:4574 -* **SNS** at http://localhost:4575 -* **SQS** at http://localhost:4576 -* **Redshift** at http://localhost:4577 -* **ES (Elasticsearch Service)** at http://localhost:4578 -* **SES** at http://localhost:4579 -* **Route53** at http://localhost:4580 -* **CloudFormation** at http://localhost:4581 -* **CloudWatch** at http://localhost:4582 -* **SSM** at http://localhost:4583 - - -Additionally, *LocalStack* provides a powerful set of tools to interact with the cloud services, including -a fully featured KCL Kinesis client with Python binding, simple setup/teardown integration for nosetests, as -well as an Environment abstraction that allows to easily switch between local and remote Cloud execution. - -## Why *LocalStack*? - -*LocalStack* builds on existing best-of-breed mocking/testing tools, most notably -[kinesalite](https://github.com/mhart/kinesalite)/[dynalite](https://github.com/mhart/dynalite) -and [moto](https://github.com/spulec/moto). While these tools are *awesome* (!), they lack functionality -for certain use cases. *LocalStack* combines the tools, makes them interoperable, and adds important -missing functionality on top of them: - -* **Error injection:** *LocalStack* allows to inject errors frequently occurring in real Cloud environments, - for instance `ProvisionedThroughputExceededException` which is thrown by Kinesis or DynamoDB if the amount of - read/write throughput is exceeded. -* **Actual HTTP REST services**: All services in *LocalStack* allow actual HTTP connections on a TCP port. In contrast, - moto uses boto client proxies that are injected into all methods annotated with `@mock_sqs`. These client proxies - do not perform an actual REST call, but rather call a local mock service method that lives in the same process as - the test code. -* **Language agnostic**: Although *LocalStack* is written in Python, it works well with arbitrary programming - languages and environments, due to the fact that we are using the actual REST APIs via HTTP. -* **Isolated processes**: All services in *LocalStack* run in separate processes. The overhead of additional - processes is negligible, and the entire stack can easily be executed on any developer machine and CI server. - In moto, components are often hard-wired in RAM (e.g., when forwarding a message on an SNS topic to an SQS queue, - the queue endpoint is looked up in a local hash map). In contrast, *LocalStack* services live in isolation - (separate processes available via HTTP), which fosters true decoupling and more closely resembles the real - cloud environment. -* **Pluggable services**: All services in *LocalStack* are easily pluggable (and replaceable), due to the fact that - we are using isolated processes for each service. This allows us to keep the framework up-to-date and select - best-of-breed mocks for each individual service. - - -## Requirements - -* `make` -* `python` (both Python 2.x and 3.x supported) -* `pip` (python package manager) -* `npm` (node.js package manager) -* `java`/`javac` (Java 8 runtime environment and compiler) -* `mvn` (Maven, the build system for Java) - -## Installing - -The easiest way to install *LocalStack* is via `pip`: +[LocalStack](https://localstack.cloud) is a cloud service emulator that runs in a single container on your laptop or in your CI environment. With LocalStack, you can run your AWS applications or Lambdas entirely on your local machine without connecting to a remote cloud provider! Whether you are testing complex CDK applications or Terraform configurations, or just beginning to learn about AWS services, LocalStack helps speed up and simplify your testing and development workflow. -``` -pip install localstack -``` +LocalStack supports a growing number of AWS services, like AWS Lambda, S3, DynamoDB, Kinesis, SQS, SNS, and many more! The [Pro version of LocalStack](https://localstack.cloud/pricing) supports additional APIs and advanced features. You can find a comprehensive list of supported APIs on our [β˜‘οΈ Feature Coverage](https://docs.localstack.cloud/user-guide/aws/feature-coverage/) page. -Once installed, run the infrastructure using the following command: -``` -localstack start -``` +LocalStack also provides additional features to make your life as a cloud developer easier! Check out LocalStack's [User Guides](https://docs.localstack.cloud/user-guide/) for more information. -**Note**: Please do **not** use `sudo` or the `root` user - *LocalStack* -should be installed and started entirely under a local non-root user. +## Install -## Running in Docker +The quickest way to get started with LocalStack is by using the LocalStack CLI. It enables you to start and manage the LocalStack Docker container directly through your command line. Ensure that your machine has a functional [`docker` environment](https://docs.docker.com/get-docker/) installed before proceeding. -You can also spin up *LocalStack* in Docker: +### Brew (macOS or Linux with Homebrew) -``` -localstack start --docker -``` +Install the LocalStack CLI through our [official LocalStack Brew Tap](https://github.com/localstack/homebrew-tap): -(Note that on MacOS you may have to run `TMPDIR=/private$TMPDIR localstack start --docker` if -`$TMPDIR` contains a symbolic link that cannot be mounted by Docker.) - -Or using docker-compose (you need to clone the repository first): - -``` -docker-compose up +```bash +brew install localstack/tap/localstack-cli ``` -(Note that on MacOS you may have to run `TMPDIR=/private$TMPDIR docker-compose up` if -`$TMPDIR` contains a symbolic link that cannot be mounted by Docker.) - -## Configurations - -You can pass the following environment variables to LocalStack: - -* `SERVICES`: Comma-separated list of service names and (optional) ports they should run on. - If no port is specified, a default port is used. Service names basically correspond to the - [service names of the AWS CLI](http://docs.aws.amazon.com/cli/latest/reference/#available-services) - (`kinesis`, `lambda`, `sqs`, etc), although LocalStack only supports a subset of them. - Example value: `kinesis,lambda:4569,sqs:4570` to start Kinesis on the default port, - Lambda on port 4569, and SQS on port 4570. -* `DEFAULT_REGION`: AWS region to use when talking to the API (defaults to `us-east-1`). -* `HOSTNAME`: Name of the host to expose the services internally (defaults to `localhost`). - Use this to customize the framework-internal communication, e.g., if services are - started in different containers using docker-compose. -* `HOSTNAME_EXTERNAL`: Name of the host to expose the services externally (defaults to `localhost`). - This host is used, e.g., when returning queue URLs from the SQS service to the client. -* `USE_SSL`: Whether to use `https://...` URLs with SSL encryption (defaults to `false`). -* `KINESIS_ERROR_PROBABILITY`: Decimal value between 0.0 (default) and 1.0 to randomly - inject `ProvisionedThroughputExceededException` errors into Kinesis API responses. -* `DYNAMODB_ERROR_PROBABILITY`: Decimal value between 0.0 (default) and 1.0 to randomly - inject `ProvisionedThroughputExceededException` errors into DynamoDB API responses. -* `LAMBDA_EXECUTOR`: Method to use for executing Lambda functions. Possible values are: - - `local`: run Lambda functions in a temporary directory on the local machine - - `docker`: run each function invocation in a separate Docker container - - `docker-reuse`: create one Docker container per function and reuse it across invocations - - For `docker` and `docker-reuse`, if *LocalStack* itself is started inside Docker, then - the `docker` command needs to be available inside the container (usually requires to run the - container in privileged mode). Default is `docker`, fallback to `local` if Docker is not available. -* `LAMBDA_REMOTE_DOCKER` determines whether Lambda code is copied or mounted into containers. - Possible values are: - - `true` (default): your Lambda function definitions will be passed to the container by - copying the zip file (potentially slower). It allows for remote execution, where the host - and the client are not on the same machine. - - `false`: your Lambda function definitions will be passed to the container by mounting a - volume (potentially faster). This requires to have the Docker client and the Docker - host on the same machine. -* `DATA_DIR`: Local directory for saving persistent data (currently only supported for these services: - Kinesis, DynamoDB, Elasticsearch). Set it to `/tmp/localstack/data` to enable persistence - (`/tmp/localstack` is mounted into the Docker container), leave blank to disable - persistence (default). -* `PORT_WEB_UI`: Port for the Web user interface (dashboard). Default is `8080`. -* `<SERVICE>_BACKEND`: Custom endpoint URL to use for a specific service, where `<SERVICE>` is the uppercase - service name (currently works for: `APIGATEWAY`, `CLOUDFORMATION`, `DYNAMODB`, `ELASTICSEARCH`, - `KINESIS`, `S3`, `SNS`, `SQS`). This allows to easily integrate third-party services into LocalStack. - -Additionally, the following *read-only* environment variables are available: - -* `LOCALSTACK_HOSTNAME`: Name of the host where LocalStack services are available. - This is needed in order to access the services from within your Lambda functions - (e.g., to store an item to DynamoDB or S3 from Lambda). - The variable `LOCALSTACK_HOSTNAME` is available for both, local Lambda execution - (`LAMBDA_EXECUTOR=local`) and execution inside separate Docker containers (`LAMBDA_EXECUTOR=docker`). - -## Accessing the infrastructure via CLI or code - -You can point your `aws` CLI to use the local infrastructure, for example: - -``` -aws --endpoint-url=http://localhost:4568 kinesis list-streams -{ - "StreamNames": [] -} -``` - -**NEW**: Check out [awslocal](https://github.com/localstack/awscli-local), a thin CLI wrapper that runs commands directly against *LocalStack* (no need to -specify `--endpoint-url` anymore). Install it via `pip install awscli-local`, and then use it as follows: - -``` -awslocal kinesis list-streams -{ - "StreamNames": [] -} -``` - -**UPDATE**: Use the environment variable `$LOCALSTACK_HOSTNAME` to determine the target host -inside your Lambda function. See [Configurations](#Configurations) section for more details. - -### Client Libraries - -* Python: https://github.com/localstack/localstack-python-client - * alternatively, you can also use `boto3` and use the `endpoint_url` parameter when creating a connection -* (more coming soon...) +### Binary download (macOS, Linux, Windows) -## Integration with nosetests +If Brew is not installed on your machine, you can download the pre-built LocalStack CLI binary directly: -If you want to use *LocalStack* in your integration tests (e.g., nosetests), simply fire up the -infrastructure in your test setup method and then clean up everything in your teardown method: +- Visit [localstack/localstack-cli](https://github.com/localstack/localstack-cli/releases/latest) and download the latest release for your platform. +- Extract the downloaded archive to a directory included in your `PATH` variable: + - For macOS/Linux, use the command: `sudo tar xvzf ~/Downloads/localstack-cli-*-darwin-*-onefile.tar.gz -C /usr/local/bin` -``` -from localstack.services import infra - -def setup(): - infra.start_infra(async=True) +### PyPI (macOS, Linux, Windows) -def teardown(): - infra.stop_infra() +LocalStack is developed using Python. To install the LocalStack CLI using `pip`, run the following command: -def my_app_test(): - # here goes your test logic +```bash +python3 -m pip install localstack ``` -See the example test file `tests/test_integration.py` for more details. +The `localstack-cli` installation enables you to run the Docker image containing the LocalStack runtime. To interact with the local AWS services, you need to install the `awslocal` CLI separately. For installation guidelines, refer to the [`awslocal` documentation](https://docs.localstack.cloud/user-guide/integrations/aws-cli/#localstack-aws-cli-awslocal). -## Integration with Java/JUnit +> **Important**: Do not use `sudo` or run as `root` user. LocalStack must be installed and started entirely under a local non-root user. If you have problems with permissions in macOS High Sierra, install with `pip install --user localstack` -In order to use *LocalStack* with Java, the project ships with a simple JUnit runner. Take a look -at the example JUnit test in `ext/java`. When you run the test, all dependencies are automatically -downloaded and installed to a temporary directory in your system. - -``` -... -import cloud.localstack.LocalstackTestRunner; -import cloud.localstack.TestUtils; +## Quickstart -@RunWith(LocalstackTestRunner.class) -public class MyCloudAppTest { +Start LocalStack inside a Docker container by running: - @Test - public void testLocalS3API() { - AmazonS3 s3 = TestUtils.getClientS3() - List<Bucket> buckets = s3.listBuckets(); - ... - } +```bash + % localstack start -d -} -``` + __ _______ __ __ + / / ____ _________ _/ / ___// /_____ ______/ /__ + / / / __ \/ ___/ __ `/ /\__ \/ __/ __ `/ ___/ //_/ + / /___/ /_/ / /__/ /_/ / /___/ / /_/ /_/ / /__/ ,< + /_____/\____/\___/\__,_/_//____/\__/\__,_/\___/_/|_| -The *LocalStack* JUnit test runner is published as an artifact in Maven Central. -Simply add the following dependency to your `pom.xml` file: +- LocalStack CLI: 4.6.0 +- Profile: default +- App: https://app.localstack.cloud -``` -<dependency> - <groupId>cloud.localstack</groupId> - <artifactId>localstack-utils</artifactId> - <version>0.1.4</version> -</dependency> +[17:00:15] starting LocalStack in Docker mode 🐳 localstack.py:512 + preparing environment bootstrap.py:1322 + configuring container bootstrap.py:1330 + starting container bootstrap.py:1340 +[17:00:16] detaching bootstrap.py:1344 ``` -### Troubleshooting +You can query the status of respective services on LocalStack by running: -* If you're using AWS Java libraries with Kinesis, please, refer to [CBOR protocol issues with the Java SDK guide](https://github.com/mhart/kinesalite#cbor-protocol-issues-with-the-java-sdk) how to disable CBOR protocol which is not supported by kinesalite. - -* Accessing local S3 from Java: To avoid domain name resolution issues, you need to enable **path style access** on your client: -``` -s3.setS3ClientOptions(S3ClientOptions.builder().setPathStyleAccess(true).build()); -// There is also an option to do this if you're using any of the client builder classes: -AmazonS3ClientBuilder builder = AmazonS3ClientBuilder.standard(); -builder.withPathStyleAccessEnabled(true); +```bash +% localstack status services +┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ +┃ Service ┃ Status ┃ +┑━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ +β”‚ acm β”‚ βœ” available β”‚ +β”‚ apigateway β”‚ βœ” available β”‚ +β”‚ cloudformation β”‚ βœ” available β”‚ +β”‚ cloudwatch β”‚ βœ” available β”‚ +β”‚ config β”‚ βœ” available β”‚ +β”‚ dynamodb β”‚ βœ” available β”‚ ... ``` -* Mounting the temp. directory: Note that on MacOS you may have to run `TMPDIR=/private$TMPDIR docker-compose up` if -`$TMPDIR` contains a symbolic link that cannot be mounted by Docker. -(See details here: https://bitbucket.org/atlassian/localstack/issues/40/getting-mounts-failed-on-docker-compose-up) - -* If you run into file permission issues on `pip install` under Mac OS (e.g., `Permission denied: '/Library/Python/2.7/site-packages/six.py'`), then you may have to re-install `pip` via Homebrew (see [this discussion thread](https://github.com/localstack/localstack/issues/260#issuecomment-334458631)). - -* If you are deploying within OpenShift, please be aware: the pod must run as `root`, and the user must have capabilities added to the running pod, in order to allow Elasticsearch to be run as the non-root `localstack` user. - -* The environment variable `no_proxy` is rewritten by *LocalStack*. -(Internal requests will go straight via localhost, bypassing any proxy configuration). - -## Developing - -If you pull the repo in order to extend/modify LocalStack, run this command to install -all the dependencies: +To use SQS, a fully managed distributed message queuing service, on LocalStack, run: -``` -make install +```shell +% awslocal sqs create-queue --queue-name sample-queue +{ + "QueueUrl": "http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/sample-queue" +} ``` -This will install the required pip dependencies in a local Python virtualenv directory -`.venv` (your global python packages will remain untouched), as well as some node modules -in `./localstack/node_modules/`. Depending on your system, some pip/npm modules may require -additional native libs installed. +Learn more about [LocalStack AWS services](https://docs.localstack.cloud/references/coverage/) and using them with LocalStack's `awslocal` CLI. -The Makefile contains a target to conveniently run the local infrastructure for development: +## Running -``` -make infra -``` +You can run LocalStack through the following options: -Check out the -[developer guide](https://github.com/localstack/localstack/tree/master/doc/developer_guides) which -contains a few instructions on how to get started with developing (and debugging) features for -LocalStack. +- [LocalStack CLI](https://docs.localstack.cloud/getting-started/installation/#localstack-cli) +- [Docker](https://docs.localstack.cloud/getting-started/installation/#docker) +- [Docker Compose](https://docs.localstack.cloud/getting-started/installation/#docker-compose) +- [Helm](https://docs.localstack.cloud/getting-started/installation/#helm) -## Testing +## Usage -The project contains a set of unit and integration tests that can be kicked off via a make -target: +To start using LocalStack, check out our [documentation](https://docs.localstack.cloud). -``` -make test -``` +- [LocalStack Configuration](https://docs.localstack.cloud/references/configuration/) +- [LocalStack in CI](https://docs.localstack.cloud/user-guide/ci/) +- [LocalStack Integrations](https://docs.localstack.cloud/user-guide/integrations/) +- [LocalStack Tools](https://docs.localstack.cloud/user-guide/tools/) +- [Understanding LocalStack](https://docs.localstack.cloud/references/) +- [Frequently Asked Questions](https://docs.localstack.cloud/getting-started/faq/) -## Web Dashboard +To use LocalStack with a graphical user interface, you can use the following UI clients: -The projects also comes with a simple Web dashboard that allows to view the deployed AWS -components and the relationship between them. +* [LocalStack Web Application](https://app.localstack.cloud) +* [LocalStack Desktop](https://docs.localstack.cloud/user-guide/tools/localstack-desktop/) +* [LocalStack Docker Extension](https://docs.localstack.cloud/user-guide/tools/localstack-docker-extension/) -``` -localstack web -``` +## Releases -## Change Log - -* v0.8.3: Fix DDB stream events for UPDATE operations; fix DDB streams sequence numbers; fix transfer-encoding for DDB; fix requests with missing content-length header; support non-ascii content in DynamoDB items; map external port for SQS queue URLs; default to LAMBDA_REMOTE_DOCKER=true if running in Docker; S3 lifecycle support; reduce Docker image size -* v0.8.2: Fix S3 bucket notification configuration; CORS headers for API Gateway; fix >128k S3 multipart uploads; return valid ShardIDs in DynamoDB Streams; fix hardcoded "ddblocal" DynamoDB TableARN; import default service ports from localstack-client; fix S3 bucket policy response; Execute lambdas asynchronously if the source is a topic -* v0.8.1: Improvements in Lambda API: publish-version, list-version, function aliases; use single map with Lambda function details; workaround for SQS .fifo queues; add test for S3 upload; initial support for SSM; fix regex to replace SQS queue URL hostnames; update linter (single quotes); use `docker.for.mac.localhost` to connect to LocalStack from Docker on Mac; fix b64 encoding for Java Lambdas; fix path of moto_server command -* v0.8.0: Fix request data in `GenericProxyHandler`; add `$PORT_WEB_UI` and `$HOSTNAME_EXTERNAL` configs; API Gateway path parameters; enable flake8 linting; add config for service backend URLs; use ElasticMQ instead of moto for SQS; expose `$LOCALSTACK_HOSTNAME`; custom environment variable support for Lambda; improve error logging and installation for Java/JUnit; add support for S3 REST Object POST -* v0.7.5: Fix issue with incomplete parallel downloads; bypass http_proxy for internal requests; use native Python code to unzip archives; download KCL client libs only for testing and not on pip install -* v0.7.4: Refactor CLI and enable plugins; support unicode names for S3; fix SQS names containing a dot character; execute Java Lambda functions in Docker containers; fix DynamoDB error handling; update docs -* v0.7.3: Extract proxy listeners into (sub-)classes; put java libs into a single "fat" jar; fix issue with non-daemonized threads; refactor code to start flask services -* v0.7.2: Fix DATA_DIR config when running in Docker; fix Maven dependencies; return 'ConsumedCapacity' from DynamoDB get-item; use Queue ARN instead of URL for S3 bucket notifications -* v0.7.1: Fix S3 API to GET bucket notifications; release Java artifacts to Maven Central; fix S3 file access from Spark; create DDB stream on UpdateTable; remove AUI dependency, optimize size of Docker image -* v0.7.0: Support for Kinesis in CloudFormation; extend and integrate Java tests in CI; publish Docker image under new name; update READMEs and license agreements -* v0.6.2: Major refactoring of installation process, lazy loading of dependencies -* v0.6.1: Add CORS headers; platform compatibility fixes (remove shell commands and sh module); add CloudFormation validate-template; fix Lambda execution in Docker; basic domain handling in ES API; API Gateway authorizers -* v0.6.0: Load services as plugins; fix service default ports; fix SQS->SNS and MD5 of message attributes; fix Host header for S3 -* v0.5.5: Enable SSL encryption for all service endpoints (`USE_SSL` config); create Docker base image; fix issue with DATA_DIR -* v0.5.4: Remove hardcoded /tmp/ for Windows-compat.; update CLI and docs; fix S3/SNS notifications; disable Elasticsearch compression -* v0.5.3: Add CloudFormation support for serverless / API Gateway deployments; fix installation via pypi; minor fix for Java (passing of environment variables) -* v0.5.0: Extend DynamoDB Streams API; fix keep-alive connection for S3; fix deadlock in nested Lambda executions; add integration SNS->Lambda; CloudFormation serverless example; replace dynalite with DynamoDBLocal; support Lambda execution in remote Docker container; fix CloudWatch metrics for Lambda invocation errors -* v0.4.3: Initial support for CloudWatch metrics (for Lambda functions); HTTP forwards for API Gateway; fix S3 message body signatures; download Lambda archive from S3 bucket; fix/extend ES tests -* v0.4.2: Initial support for Java Lambda functions; CloudFormation deployments; API Gateway tests -* v0.4.1: Python 3 compatibility; data persistence; add seq. numbers in Kinesis events; limit Elasticsearch memory -* v0.4.0: Execute Lambda functions in Docker containers; CORS headers for S3 -* v0.3.11: Add Route53, SES, CloudFormation; DynamoDB fault injection; UI tweaks; refactor config -* v0.3.10: Add initial support for S3 bucket notifications; fix subprocess32 installation -* v0.3.9: Make services/ports configurable via $SERVICES; add tests for Firehose+S3 -* v0.3.8: Fix Elasticsearch via local bind and proxy; refactoring; improve error logging -* v0.3.5: Fix lambda handler name; fix host name for S3 API; install web libs on pip install -* v0.3.4: Fix file permissions in build; fix and add UI to Docker image; add stub of ES API -* v0.3.3: Add version tags to Docker images -* v0.3.2: Add support for Redshift API; code refactoring -* v0.3.1: Add Dockerfile and push image to Docker Hub -* v0.3.0: Add simple integration for JUnit; improve process signal handling -* v0.2.11: Refactored the AWS assume role function -* v0.2.10: Added AWS assume role functionality. -* v0.2.9: Kinesis error response formatting -* v0.2.7: Throw Kinesis errors randomly -* v0.2.6: Decouple SNS/SQS: intercept SNS calls and forward to subscribed SQS queues -* v0.2.5: Return error response from Kinesis if flag is set -* v0.2.4: Allow Lambdas to use __file__ (import from file instead of exec'ing) -* v0.2.3: Improve Kinesis/KCL auto-checkpointing (leases in DDB) -* v0.2.0: Speed up installation time by lazy loading libraries -* v0.1.19: Pass shard_id in records sent from KCL process -* v0.1.16: Minor restructuring and refactoring (create separate kinesis_util.py) -* v0.1.14: Fix AWS tokens when creating Elasticsearch client -* v0.1.11: Add startup/initialization notification for KCL process -* v0.1.10: Bump version of amazon_kclpy to 1.4.1 -* v0.1.9: Add initial support for SQS/SNS -* v0.1.8: Fix installation of JARs in amazon_kclpy if localstack is installed transitively -* v0.1.7: Bump version of amazon_kclpy to 1.4.0 -* v0.1.6: Add travis-ci and coveralls configuration -* v0.1.5: Refactor Elasticsearch utils; fix bug in method to delete all ES indexes -* v0.1.4: Enhance logging; extend java KCL credentials provider (support STS assumed roles) -* v0.1.2: Add configurable KCL log output -* v0.1.0: Initial release +Please refer to [GitHub releases](https://github.com/localstack/localstack/releases) to see the complete list of changes for each release. For extended release notes, please refer to the [changelog](https://docs.localstack.cloud/references/changelog/). ## Contributing -We welcome feedback, bug reports, and pull requests! +If you are interested in contributing to LocalStack: -For pull requests, please stick to the following guidelines: +- Start by reading our [contributing guide](docs/CONTRIBUTING.md). +- Check out our [development environment setup guide](docs/development-environment-setup/README.md). +- Navigate our codebase and [open issues](https://github.com/localstack/localstack/issues). -* Add tests for any new features and bug fixes. Ideally, each PR should increase the test coverage. -* Follow the existing code style (e.g., indents). A PEP8 code linting target is included in the Makefile. -* Put a reasonable amount of comments into the code. -* Separate unrelated changes into multiple pull requests. -* 1 commit per PR: Please squash/rebase multiple commits into one single commit (to keep the history clean). +We are thankful for all the contributions and feedback we receive. -Please note that by contributing any code or documentation to this repository (by -raising pull requests, or otherwise) you explicitly agree to -the [**Contributor License Agreement**](doc/contributor_license_agreement). +## Get in touch -## Contributors +Get in touch with the LocalStack Team to +report 🐞 [issues](https://github.com/localstack/localstack/issues/new/choose), +upvote πŸ‘ [feature requests](https://github.com/localstack/localstack/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+), +πŸ™‹πŸ½ ask [support questions](https://docs.localstack.cloud/getting-started/help-and-support/), +or πŸ—£οΈ discuss local cloud development: -This project exists thanks to all the people who contribute. -<a href="graphs/contributors"><img src="https://opencollective.com/localstack/contributors.svg?width=890" /></a> +- [LocalStack Slack Community](https://localstack.cloud/contact/) +- [LocalStack GitHub Issue tracker](https://github.com/localstack/localstack/issues) +### Contributors -## Backers +We are thankful to all the people who have contributed to this project. -Thank you to all our backers! πŸ™ [[Become a backer](https://opencollective.com/localstack#backer)] +<a href="https://github.com/localstack/localstack/graphs/contributors"><img src="https://opencollective.com/localstack/contributors.svg?width=890" /></a> -<a href="https://opencollective.com/localstack#backers" target="_blank"><img src="https://opencollective.com/localstack/backers.svg?width=890"></a> +### Backers +We are also grateful to all our backers who have donated to the project. You can become a backer on [Open Collective](https://opencollective.com/localstack#backer). -## Sponsors +<a href="https://opencollective.com/localstack#backers" target="_blank"><img src="https://opencollective.com/localstack/backers.svg?width=890"></a> -Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/localstack#sponsor)] +### Sponsors + +You can also support this project by becoming a sponsor on [Open Collective](https://opencollective.com/localstack#sponsor). Your logo will show up here along with a link to your website. <a href="https://opencollective.com/localstack/sponsor/0/website" target="_blank"><img src="https://opencollective.com/localstack/sponsor/0/avatar.svg"></a> <a href="https://opencollective.com/localstack/sponsor/1/website" target="_blank"><img src="https://opencollective.com/localstack/sponsor/1/avatar.svg"></a> @@ -429,38 +209,10 @@ Support this project by becoming a sponsor. Your logo will show up here with a l <a href="https://opencollective.com/localstack/sponsor/8/website" target="_blank"><img src="https://opencollective.com/localstack/sponsor/8/avatar.svg"></a> <a href="https://opencollective.com/localstack/sponsor/9/website" target="_blank"><img src="https://opencollective.com/localstack/sponsor/9/avatar.svg"></a> - - ## License -Copyright (c) 2017 *LocalStack* maintainers and contributors. +Copyright (c) 2017-2025 LocalStack maintainers and contributors. Copyright (c) 2016 Atlassian and others. -This version of *LocalStack* is released under the Apache License, Version 2.0 (see LICENSE.txt). -By downloading and using this software you agree to the -[End-User License Agreement (EULA)](doc/end_user_license_agreement). - -We build on a number of third-party software tools, with the following licenses: - -Third-Party software | License -----------------------------|----------------------- -**Python/pip modules:** | -airspeed | BSD License -amazon_kclpy | Amazon Software License -boto3 | Apache License 2.0 -coverage | Apache License 2.0 -docopt | MIT License -elasticsearch | Apache License 2.0 -flask | BSD License -flask_swagger | MIT License -jsonpath-rw | Apache License 2.0 -moto | Apache License 2.0 -nose | GNU LGPL -pep8 | Expat license -requests | Apache License 2.0 -subprocess32 | PSF License -**Node.js/npm modules:** | -kinesalite | MIT License -**Other tools:** | -Elasticsearch | Apache License 2.0 +This version of LocalStack is released under the Apache License, Version 2.0 (see [LICENSE](LICENSE.txt)). By downloading and using this software you agree to the [End-User License Agreement (EULA)](docs/end_user_license_agreement). diff --git a/bin/Dockerfile.base b/bin/Dockerfile.base deleted file mode 100644 index c50c0b84d1ac7..0000000000000 --- a/bin/Dockerfile.base +++ /dev/null @@ -1,90 +0,0 @@ -FROM node:alpine - -MAINTAINER Waldemar Hummer (waldemar.hummer@gmail.com) -LABEL authors="Waldemar Hummer (waldemar.hummer@gmail.com)" - -# install some common libs -RUN apk add --no-cache autoconf automake build-base ca-certificates curl git \ - libffi-dev libtool linux-headers make openssl openssl-dev python python-dev \ - py-pip supervisor tar xz zip && \ - update-ca-certificates - -# install Docker (CLI only) -RUN docker_version=17.05.0-ce; \ - curl -fsSLO https://get.docker.com/builds/Linux/x86_64/docker-$docker_version.tgz \ - && tar xzvf docker-$docker_version.tgz \ - && mv docker/docker /usr/local/bin \ - && rm -r docker docker-$docker_version.tgz - -# Install Java - taken from official repo: -# https://github.com/docker-library/openjdk/blob/master/8-jdk/alpine/Dockerfile) -ENV LANG C.UTF-8 -RUN { \ - echo '#!/bin/sh'; echo 'set -e'; echo; \ - echo 'dirname "$(dirname "$(readlink -f "$(which javac || which java)")")"'; \ - } > /usr/local/bin/docker-java-home \ - && chmod +x /usr/local/bin/docker-java-home -ENV JAVA_HOME /usr/lib/jvm/java-1.8-openjdk -ENV PATH $PATH:/usr/lib/jvm/java-1.8-openjdk/jre/bin:/usr/lib/jvm/java-1.8-openjdk/bin -ENV JAVA_VERSION 8u131 -ENV JAVA_ALPINE_VERSION 8.131.11-r2 -RUN set -x && apk add --no-cache openjdk8="$JAVA_ALPINE_VERSION" \ - && [ "$JAVA_HOME" = "$(docker-java-home)" ] - -# Install Maven - taken from official repo: -# https://github.com/carlossg/docker-maven/blob/master/jdk-8/Dockerfile) -ARG MAVEN_VERSION=3.5.2 -ARG USER_HOME_DIR="/root" -ARG SHA=707b1f6e390a65bde4af4cdaf2a24d45fc19a6ded00fff02e91626e3e42ceaff -ARG BASE_URL=https://apache.osuosl.org/maven/maven-3/${MAVEN_VERSION}/binaries -RUN mkdir -p /usr/share/maven /usr/share/maven/ref \ - && curl -fsSL -o /tmp/apache-maven.tar.gz ${BASE_URL}/apache-maven-$MAVEN_VERSION-bin.tar.gz \ - && echo "${SHA} /tmp/apache-maven.tar.gz" | sha256sum -c - \ - && tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \ - && rm -f /tmp/apache-maven.tar.gz \ - && ln -s /usr/share/maven/bin/mvn /usr/bin/mvn -ENV MAVEN_HOME /usr/share/maven -ENV MAVEN_CONFIG "$USER_HOME_DIR/.m2" -ADD https://raw.githubusercontent.com/carlossg/docker-maven/master/jdk-8/settings-docker.xml /usr/share/maven/ref/ - -# set workdir -RUN mkdir -p /opt/code/localstack -WORKDIR /opt/code/localstack/ - -# init environment and cache some dependencies -ADD requirements.txt . -RUN mkdir -p /opt/code/localstack/localstack/infra && \ - wget -O /tmp/localstack.es.zip \ - https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.3.0.zip && \ - wget -O /tmp/elasticmq-server.jar \ - https://s3-eu-west-1.amazonaws.com/softwaremill-public/elasticmq-server-0.13.8.jar && \ - (cd localstack/infra/ && unzip -q /tmp/localstack.es.zip && \ - mv elasticsearch* elasticsearch && rm /tmp/localstack.es.zip) && \ - mkdir -p /opt/code/localstack/localstack/infra/dynamodb && \ - wget -O /tmp/localstack.ddb.zip \ - https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip && \ - (cd localstack/infra/dynamodb && unzip -q /tmp/localstack.ddb.zip && rm /tmp/localstack.ddb.zip) && \ - (pip install --upgrade pip) && \ - (test `which virtualenv` || \ - pip install virtualenv || \ - sudo pip install virtualenv) && \ - (virtualenv .testvenv && \ - source .testvenv/bin/activate && \ - pip install six==1.10.0 && \ - pip install --quiet -r requirements.txt && \ - rm -rf .testvenv) - -# add files required to run "make install-web" -ADD Makefile . -ADD localstack/dashboard/web/package.json localstack/dashboard/web/package.json - -# install web dashboard dependencies -RUN make install-web - -# install npm dependencies -ADD localstack/package.json localstack/package.json -RUN cd localstack && npm install - -# clean up (layers are later squashed into a single one) -RUN rm -rf /root/.npm; \ - apk del --purge autoconf automake build-base linux-headers openssl-dev python-dev diff --git a/bin/docker-entrypoint.sh b/bin/docker-entrypoint.sh new file mode 100755 index 0000000000000..1c4a297fd1fa1 --- /dev/null +++ b/bin/docker-entrypoint.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -eo pipefail +shopt -s nullglob + +# When trying to activate pro features in the community version, raise a warning +if [[ -n $LOCALSTACK_API_KEY || -n $LOCALSTACK_AUTH_TOKEN ]]; then + echo "WARNING" + echo "============================================================================" + echo " It seems you are trying to use the LocalStack Pro version without using " + echo " the dedicated Pro image." + echo " LocalStack will only start with community services enabled." + echo " To fix this warning, use localstack/localstack-pro instead." + echo "" + echo " See: https://github.com/localstack/localstack/issues/7882" + echo "============================================================================" + echo "" +fi + +# Strip `LOCALSTACK_` prefix in environment variables name; except LOCALSTACK_HOST and LOCALSTACK_HOSTNAME (deprecated) +source <( + env | + grep -v -e '^LOCALSTACK_HOSTNAME' | + grep -v -e '^LOCALSTACK_HOST' | + grep -v -e '^LOCALSTACK_[[:digit:]]' | # See issue #1387 + sed -ne 's/^LOCALSTACK_\([^=]\+\)=.*/export \1=${LOCALSTACK_\1}/p' +) + +LOG_DIR=/var/lib/localstack/logs +test -d ${LOG_DIR} || mkdir -p ${LOG_DIR} + +# activate the virtual environment +source /opt/code/localstack/.venv/bin/activate + +# run runtime init hooks BOOT stage before starting localstack +test -d /etc/localstack/init/boot.d && python3 -m localstack.runtime.init BOOT + +# run the localstack supervisor. it's important to run with `exec` and don't use pipes so signals are handled correctly +exec localstack-supervisor diff --git a/bin/docker-helper.sh b/bin/docker-helper.sh new file mode 100755 index 0000000000000..2b21a0f1ce4ce --- /dev/null +++ b/bin/docker-helper.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash + +set -eo pipefail +# set -x +shopt -s nullglob + +# global defaults +DOCKERFILE=${DOCKERFILE-"Dockerfile"} +DEFAULT_TAG=${DEFAULT_TAG-"latest"} +DOCKER_BUILD_CONTEXT=${DOCKER_BUILD_CONTEXT-"."} + +function usage() { + echo "A set of commands that facilitate building and pushing versioned Docker images" + echo "" + echo "USAGE" + echo " docker-helper <command> [options]" + echo "" + echo "Commands:" + echo " build" + echo " Build a platform-specific Docker image for the project in the working directory" + echo "" + echo " save" + echo " Save the Docker image to disk (to transfer it to other runners / machines)" + echo "" + echo " load" + echo " Load a previously saved Docker image from disk" + echo "" + echo " push" + echo " Push a platform-specific the Docker image for the project" + echo "" + echo " push-manifests" + echo " Create and push the multi-arch Docker manifests for already pushed platform-specific images" + echo "" + echo " help" + echo " Show this message" +} + + + +############# +## Helpers ## +############# + +function _fail { + # send error message to stderr + printf '%s\n' "$1" >&2 + # exit with error code, $2 or by default 1 + exit "${2-1}" +} + +function _get_current_version() { + # check if setuptools_scm is installed, if not prompt to install. python3 is expected to be present + if ! python3 -m pip -qqq show setuptools_scm > /dev/null ; then + echo "ERROR: setuptools_scm is not installed. Run 'pip install --upgrade setuptools setuptools_scm'" >&2 + exit 1 + fi + python3 -m setuptools_scm +} + +function _is_release_commit() { + [[ $(_get_current_version) =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +function _get_current_branch() { + git branch --show-current +} + +function _enforce_image_name() { + if [ -z "$IMAGE_NAME" ]; then _fail "Mandatory parameter IMAGE_NAME missing."; fi +} + +function _enforce_main_branch() { + MAIN_BRANCH=${MAIN_BRANCH-"master"} + CURRENT_BRANCH=$(_get_current_branch) + echo "Current git branch: '$CURRENT_BRANCH'" + test "$CURRENT_BRANCH" == "$MAIN_BRANCH" || _fail "Current branch ($CURRENT_BRANCH) is not $MAIN_BRANCH." +} + +function _enforce_no_fork() { + REMOTE_ORIGIN=$(git remote -v | grep 'localstack/' | grep origin | grep push | awk '{print $2}') + if [[ "$REMOTE_ORIGIN" != 'https://github.com/localstack/'* ]] && [[ "$REMOTE_ORIGIN" != 'git@github.com:localstack/'* ]]; then + _fail "This is a fork and not the main repo." + fi +} + +function _enforce_docker_credentials() { + if [ -z "$DOCKER_USERNAME" ] || [ -z "$DOCKER_PASSWORD" ]; then _fail "Mandatory Docker credentials are missing."; fi +} + +function _enforce_platform() { + if [ -z "$PLATFORM" ]; then _fail "Mandatory parameter PLATFORM is missing."; fi +} + +function _set_version_defaults() { + # determine major/minor/patch versions + if [ -z "$IMAGE_TAG" ]; then + IMAGE_TAG=$(_get_current_version) + fi + if [ -z "$MAJOR_VERSION" ]; then MAJOR_VERSION=$(echo ${IMAGE_TAG} | cut -d '.' -f1); fi + if [ -z "$MINOR_VERSION" ]; then MINOR_VERSION=$(echo ${IMAGE_TAG} | cut -d '.' -f2); fi + if [ -z "$PATCH_VERSION" ]; then PATCH_VERSION=$(echo ${IMAGE_TAG} | cut -d '.' -f3); fi +} + + + +############## +## Commands ## +############## + +function cmd-build() { + # start build of a platform-specific image (this target will get called for multiple archs like AMD64/ARM64) + _enforce_image_name + _set_version_defaults + + if [ ! -f "pyproject.toml" ]; then + echo "No pyproject.toml found, setuptools_scm will not be able to retrieve configuration." + fi + if [ -z "$DOCKERFILE" ]; then DOCKERFILE=Dockerfile; fi + # by default we load the result to the docker daemon + if [ "$DOCKER_BUILD_FLAGS" = "" ]; then DOCKER_BUILD_FLAGS="--load"; fi + + # --add-host: Fix for Centos host OS + # --build-arg BUILDKIT_INLINE_CACHE=1: Instruct buildkit to inline the caching information into the image + # --cache-from: Use the inlined caching information when building the image + DOCKER_BUILDKIT=1 docker buildx build --pull --progress=plain \ + --cache-from "$IMAGE_NAME" --build-arg BUILDKIT_INLINE_CACHE=1 \ + --build-arg LOCALSTACK_PRE_RELEASE=$(_is_release_commit && echo "0" || echo "1") \ + --build-arg LOCALSTACK_BUILD_GIT_HASH=$(git rev-parse --short HEAD) \ + --build-arg=LOCALSTACK_BUILD_DATE=$(date -u +"%Y-%m-%d") \ + --build-arg=LOCALSTACK_BUILD_VERSION=$IMAGE_TAG \ + --add-host="localhost.localdomain:127.0.0.1" \ + -t "$IMAGE_NAME:$DEFAULT_TAG" $DOCKER_BUILD_FLAGS $DOCKER_BUILD_CONTEXT -f $DOCKERFILE +} + +function cmd-save() { + _enforce_image_name + + if [ -z "$IMAGE_FILENAME" ]; then + _enforce_platform + IMAGE_FILENAME="localstack-docker-image-$PLATFORM.tar.gz"; + fi + + docker save $IMAGE_NAME:$DEFAULT_TAG | gzip > $IMAGE_FILENAME + # set the filename as github output if it's available + if [ -n "$GITHUB_OUTPUT" ]; then + echo "IMAGE_FILENAME=$IMAGE_FILENAME" >> "$GITHUB_OUTPUT" + fi +} + +function cmd-load() { + if [ -z "$IMAGE_FILENAME" ]; then + _enforce_platform + IMAGE_FILENAME="localstack-docker-image-$PLATFORM.tar.gz"; + fi + + docker load -i $IMAGE_FILENAME +} + +function cmd-push() { + _enforce_image_name + _enforce_main_branch + _enforce_no_fork + _enforce_docker_credentials + _enforce_platform + _set_version_defaults + + if [ -z "$TARGET_IMAGE_NAME" ]; then TARGET_IMAGE_NAME=$IMAGE_NAME; fi + + # login to DockerHub + docker login -u "$DOCKER_USERNAME" -p "$DOCKER_PASSWORD" + + # create the platform specific default tag + docker tag $IMAGE_NAME:$DEFAULT_TAG $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM + + # push default tag + docker push $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM + + function _push_versioned_tags() { + # create explicitly set image tag (via $IMAGE_TAG) + docker tag $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM $TARGET_IMAGE_NAME:$IMAGE_TAG-$PLATFORM + + # always create "latest" tag on version push + docker tag $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM $TARGET_IMAGE_NAME:latest-$PLATFORM + + # create "stable" tag + docker tag $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM $TARGET_IMAGE_NAME:stable-$PLATFORM + + # create <major> tag (f.e. 4) + docker tag $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM $TARGET_IMAGE_NAME:$MAJOR_VERSION-$PLATFORM + + # create <major>.<minor> (f.e. 4.0) + docker tag $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM $TARGET_IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION-$PLATFORM + + # create <major>.<minor>.<patch> (f.e. 4.0.0) + docker tag $TARGET_IMAGE_NAME:$DEFAULT_TAG-$PLATFORM $TARGET_IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION.$PATCH_VERSION-$PLATFORM + + # push all the created tags + docker push $TARGET_IMAGE_NAME:stable-$PLATFORM + docker push $TARGET_IMAGE_NAME:latest-$PLATFORM + docker push $TARGET_IMAGE_NAME:$IMAGE_TAG-$PLATFORM + docker push $TARGET_IMAGE_NAME:$MAJOR_VERSION-$PLATFORM + docker push $TARGET_IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION-$PLATFORM + docker push $TARGET_IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION.$PATCH_VERSION-$PLATFORM + } + + if _is_release_commit; then + echo "Pushing version tags, we're building the commit of a version tag." + _push_versioned_tags + else + echo "Not pushing any other tags, we're not building a version-tagged commit." + fi +} + +function cmd-push-manifests() { + _enforce_image_name + _enforce_main_branch + _enforce_no_fork + _enforce_docker_credentials + _set_version_defaults + + # login to DockerHub + docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD + + # create the multiarch manifest + docker manifest create $IMAGE_NAME:$DEFAULT_TAG --amend $IMAGE_NAME:$DEFAULT_TAG-amd64 --amend $IMAGE_NAME:$DEFAULT_TAG-arm64 + + # push default tag + docker manifest push $IMAGE_NAME:$DEFAULT_TAG + + function _push_versioned_tags() { + # create explicitly set image tag (via $IMAGE_TAG) + docker manifest create $IMAGE_NAME:$IMAGE_TAG \ + --amend $IMAGE_NAME:$IMAGE_TAG-amd64 \ + --amend $IMAGE_NAME:$IMAGE_TAG-arm64 + + # always create "latest" tag on version push + docker manifest create $IMAGE_NAME:latest \ + --amend $IMAGE_NAME:latest-amd64 \ + --amend $IMAGE_NAME:latest-arm64 + + # create "stable" tag + docker manifest create $IMAGE_NAME:stable \ + --amend $IMAGE_NAME:stable-amd64 \ + --amend $IMAGE_NAME:stable-arm64 + + # create <major> tag (f.e. 4) + docker manifest create $IMAGE_NAME:$MAJOR_VERSION \ + --amend $IMAGE_NAME:$MAJOR_VERSION-amd64 \ + --amend $IMAGE_NAME:$MAJOR_VERSION-arm64 + + # create <major>.<minor> (f.e. 4.0) + docker manifest create $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION \ + --amend $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION-amd64 \ + --amend $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION-arm64 + + # create <major>.<minor>.<patch> (f.e. 4.0.0) + docker manifest create $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION.$PATCH_VERSION \ + --amend $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION.$PATCH_VERSION-amd64 \ + --amend $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION.$PATCH_VERSION-arm64 + + # push all the created tags + docker manifest push $IMAGE_NAME:$IMAGE_TAG + docker manifest push $IMAGE_NAME:stable + docker manifest push $IMAGE_NAME:latest + docker manifest push $IMAGE_NAME:$MAJOR_VERSION + docker manifest push $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION + docker manifest push $IMAGE_NAME:$MAJOR_VERSION.$MINOR_VERSION.$PATCH_VERSION + } + + if _is_release_commit; then + echo "Pushing version tags, we're building the commit of a version tag." + _push_versioned_tags + else + echo "Not pushing any other tags, we're not building a version-tagged commit." + fi +} + + + +############## +## Commands ## +############## + +function main() { + [[ $# -lt 1 ]] && { usage; exit 1; } + + command_name=$1 + shift + + # invoke command + case $command_name in + "build") cmd-build "$@" ;; + "save") cmd-save "$@" ;; + "load") cmd-load "$@" ;; + "push") cmd-push "$@" ;; + "push-manifests") cmd-push-manifests "$@" ;; + "help") usage && exit 0 ;; + *) usage && exit 1 ;; + esac +} + +main "$@" diff --git a/bin/hosts b/bin/hosts new file mode 100644 index 0000000000000..f81950b217904 --- /dev/null +++ b/bin/hosts @@ -0,0 +1,3 @@ +# normally overwritten by docker, this hosts file should only be readable in the container in windows container mode +127.0.0.1 localhost localhost.localdomain +::1 localhost localhost.localdomain diff --git a/bin/localstack b/bin/localstack index ed2b2d3a13d5f..5cd199565d8bf 100755 --- a/bin/localstack +++ b/bin/localstack @@ -1,127 +1,23 @@ -#!/usr/bin/env python - -""" -Command line interface (CLI) for LocalStack. - -Usage: - localstack [options] <command> [ <args> ... ] - localstack (-v | --version) - localstack (-h | --help) - -Commands:%s - -Options: - -d --debug Show verbose debug output - -h --help Show this screen - -v --version Show version -%s -""" +#!/usr/bin/env python3 +import glob import os import sys -import json - -PARENT_FOLDER = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) -if os.path.isdir(os.path.join(PARENT_FOLDER, '.venv')): - sys.path.insert(0, PARENT_FOLDER) - -from docopt import docopt -from localstack import config, constants -from localstack.services import infra - - -def cmd_infra(argv, args): - """ -Usage: - localstack infra <subcommand> [options] - -Commands: - infra start Start the local infrastructure - -Options: - --docker Run the infrastructure in a Docker container - """ - if argv[0] == 'start': - argv = ['infra', 'start'] + argv[1:] - args['<command>'] = 'infra' - args['<args>'] = ['start'] + args['<args>'] - args.update(docopt(cmd_infra.__doc__.strip(), argv=argv)) - if args['<subcommand>'] == 'start': - print('Starting local dev environment. CTRL-C to quit.') - if args['--docker']: - infra.start_infra_in_docker() - else: - infra.start_infra() - - -def cmd_web(argv, args): - """ -Usage: - localstack web <subcommand> [options] - -Commands: - web start Start the Web dashboard - -Options: - --port=<> Network port for running the Web server (default: 8080) - """ - if len(argv) <= 1 or argv[1] != 'start': - argv = ['web', 'start'] + argv[1:] - args['<args>'] = ['start'] + args['<args>'] - args.update(docopt(cmd_web.__doc__.strip(), argv=argv)) - if args['<subcommand>'] == 'start': - import localstack.dashboard.api - port = args['--port'] or config.PORT_WEB_UI - localstack.dashboard.api.serve(port) - - -if __name__ == '__main__': - - # set basic CLI commands - config.CLI_COMMANDS['infra'] = { - 'description': 'Commands to manage the infrastructure', - 'function': cmd_infra - } - config.CLI_COMMANDS['start'] = { - 'description': 'Shorthand to start the infrastructure', - 'function': cmd_infra - } - config.CLI_COMMANDS['web'] = { - 'description': 'Commands to manage the Web dashboard', - 'function': cmd_web - } - # load CLI plugins - infra.load_plugins(scope=infra.PLUGIN_SCOPE_COMMANDS) +PARENT_FOLDER = os.path.realpath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")) +venv_dir = os.path.join(PARENT_FOLDER, ".venv") +insert_pos = min(len(sys.path), 2) +if os.path.isdir(venv_dir): + for path in glob.glob(os.path.join(venv_dir, "lib/python*/site-packages")): + sys.path.insert(insert_pos, path) + sys.path.insert(insert_pos, PARENT_FOLDER) - # create final usage string - additional_params = [] - additional_commands = '' - for cmd in sorted(config.CLI_COMMANDS.keys()): - cmd_details = config.CLI_COMMANDS[cmd] - additional_commands += '\n %s%s%s' % (cmd, (20 - len(cmd)) * ' ', cmd_details['description']) - for param in cmd_details.get('parameters', []): - additional_params.append(param) - additional_params = '\n'.join(additional_params) - doc_string = __doc__ % (additional_commands, additional_params) - args = docopt(doc_string, options_first=True) +def main(): + from localstack.cli import main - if args['--version']: - print(constants.VERSION) - sys.exit(0) + main.main() - if args['--debug']: - os.environ['DEBUG'] = '1' - # invoke subcommand - argv = [args['<command>']] + args['<args>'] - subcommand = config.CLI_COMMANDS.get(args['<command>']) - if subcommand: - try: - subcommand['function'](argv, args) - except Exception as e: - print('ERROR: %s' % e) - else: - print('ERROR: Invalid command "%s"' % args['<command>']) - sys.exit(1) +if __name__ == "__main__": + main() diff --git a/bin/localstack-supervisor b/bin/localstack-supervisor new file mode 100755 index 0000000000000..f0943b4447781 --- /dev/null +++ b/bin/localstack-supervisor @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Supervisor script for managing localstack processes, acting like a mini init system tailored to +localstack. This can be used on the host or in the docker-entrypoint.sh. + +The supervisor behaves as follows: +* SIGUSR1 to supervisor will terminate the localstack instance and then start a new process +* SIGTERM to supervisor will terminate the localstack instance and then return +* if the localstack instance exits, then the supervisor exits with the same exit code. + +The methods ``waitpid_reap_other_children`` and ``stop_child_process`` were adapted from baseimage-docker +licensed under MIT: https://github.com/phusion/baseimage-docker/blob/rel-0.9.16/image/bin/my_init""" + +import errno +import os +import signal +import subprocess +import sys +import threading +from typing import Optional + +DEBUG = os.getenv("DEBUG", "").strip().lower() in ["1", "true"] + +# configurable process shutdown timeout, to allow for longer shutdown procedures +DEFAULT_SHUTDOWN_TIMEOUT = int(os.getenv("SHUTDOWN_TIMEOUT", "").strip() or 5) + + +class AlarmException(Exception): + """Special exception raise if SIGALRM is received.""" + + pass + + +def get_localstack_command() -> list[str]: + """ + Allow modification of the command to start LocalStack + :return: Command to start LocalStack + """ + import shlex + + command = os.environ.get("LOCALSTACK_SUPERVISOR_COMMAND") + if not command: + return [sys.executable, "-m", "localstack.runtime.main"] + return shlex.split(command) + + +def log(message: str): + """Prints the given message to stdout with a logging prefix.""" + if not DEBUG: + return + print(f"LocalStack supervisor: {message}") + + +_terminated_child_processes = {} + + +def waitpid_reap_other_children(pid: int) -> Optional[int]: + """ + Waits for the child process with the given PID, while at the same time reaping any other child + processes that have exited (e.g. adopted child processes that have terminated). + + :param pid: the pid of the process + :returns: the status of the process + """ + global _terminated_child_processes + + status = _terminated_child_processes.get(pid) + if status: + # A previous call to waitpid_reap_other_children(), + # with an argument not equal to the current argument, + # already waited for this process. Return the status + # that was obtained back then. + del _terminated_child_processes[pid] + return status + + done = False + status = None + while not done: + try: + this_pid, status = os.waitpid(-1, 0) + + if this_pid == pid: + done = True + else: + # Save status for later. + _terminated_child_processes[this_pid] = status + except OSError as e: + if e.errno == errno.ECHILD or e.errno == errno.ESRCH: + return None + else: + raise + return status + + +def stop_child_process(name: str, pid: int, sig: int = signal.SIGTERM, timeout: int | None = None): + """ + Sends a signal to the given process and then waits for all child processes to avoid zombie processes. + + :param name: readable process name to log + :param pid: the pid to terminate + :param sig: the signal to send to the process + :param timeout: the wait timeout + :return: + """ + log(f"Shutting down {name} (PID {pid})...") + try: + os.kill(pid, sig) + except OSError: + pass + timeout = timeout or DEFAULT_SHUTDOWN_TIMEOUT + signal.alarm(timeout) + try: + waitpid_reap_other_children(pid) + except OSError: + pass + except AlarmException: + log(f"{name} (PID {pid}) did not shut down in time. Forcing it to exit.") + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + try: + waitpid_reap_other_children(pid) + except OSError: + pass + finally: + signal.alarm(0) + + +def main(): + # the localstack process + process: Optional[subprocess.Popen] = None + + # signal handlers set these events which further determine which actions should be taken in the main loop + should_restart = threading.Event() + + # signal handlers + + def _raise_alarm_exception(signum, frame): + raise AlarmException() + + def _terminate_localstack(signum, frame): + if not process: + return + stop_child_process("localstack", process.pid, signal.SIGTERM) + + def _restart_localstack(signum, frame): + # this handler terminates localstack but leaves the supervisor in a state to restart it + if not process: + return + should_restart.set() + stop_child_process("localstack", process.pid, signal.SIGTERM) + + signal.signal(signal.SIGALRM, _raise_alarm_exception) + signal.signal(signal.SIGTERM, _terminate_localstack) + # TODO investigate: when we tried to forward SIGINT to LS, for some reason SIGINT was raised twice in LS + # yet setting this to a no-op also worked. since we couldn't really figure out what was going on, we just + # translate SIGINT to SIGTERM for the localstack process. + signal.signal(signal.SIGINT, _terminate_localstack) + signal.signal(signal.SIGUSR1, _restart_localstack) + + # sets the supervisor PID so localstack can signal to it more easily + os.environ["SUPERVISOR_PID"] = str(os.getpid()) + + exit_code = 0 + try: + log("starting") + while True: + # clear force event indicators + should_restart.clear() + + # start a new localstack process + process = subprocess.Popen( + get_localstack_command(), + stdout=sys.stdout, + stderr=subprocess.STDOUT, + ) + log(f"localstack process (PID {process.pid}) starting") + + # wait for the localstack process to return + exit_code = process.wait() + log(f"localstack process (PID {process.pid}) returned with exit code {exit_code}") + + # make sure that, if the localstack process terminates on its own accord, that we still reap all + # child processes + waitpid_reap_other_children(process.pid) + + if should_restart.is_set(): + continue + else: + break + finally: + log("exiting") + + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/bin/localstack.bat b/bin/localstack.bat new file mode 100644 index 0000000000000..05370a69c42ef --- /dev/null +++ b/bin/localstack.bat @@ -0,0 +1 @@ +python "%~dp0\localstack" %* diff --git a/bin/mvn_release.sh b/bin/mvn_release.sh deleted file mode 100755 index c748b99c277cf..0000000000000 --- a/bin/mvn_release.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -# config - -VERSION=1.0-SNAPSHOT -ARTIFACT=localstack-utils -GROUP=com.atlassian -LOCAL_REPO=target/m2_repo - -# package artifacts via Maven - -( -cd localstack/ext/java -mvn clean package - -rm -rf $LOCAL_REPO/com/atlassian - -mvn org.apache.maven.plugins:maven-install-plugin:2.5.2:install-file -Dfile=target/original-$ARTIFACT-$VERSION.jar \ - -DgroupId=$GROUP -DartifactId=$ARTIFACT -Dversion=$VERSION -Dpackaging=jar -DlocalRepositoryPath=$LOCAL_REPO \ - -DcreateChecksum=true -) - -# copy artifacts to ./release folder - -mkdir -p release -cp -r localstack/ext/java/$LOCAL_REPO/* release/ -for p in release/com/atlassian/$ARTIFACT/ release/com/atlassian/$ARTIFACT/$VERSION; do - ( - cd $p - mv maven-metadata-local.xml maven-metadata.xml - mv maven-metadata-local.xml.md5 maven-metadata.xml.md5 - mv maven-metadata-local.xml.sha1 maven-metadata.xml.sha1 - ) -done diff --git a/bin/release-dev.sh b/bin/release-dev.sh new file mode 100755 index 0000000000000..60c8a9c808936 --- /dev/null +++ b/bin/release-dev.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -e + +# use UTC timestamp as version +timestamp=$(date -u +%Y%m%d%H%M%S) +sed -i -r "s/^([0-9]+\.[0-9]+\.[0-9]+\.dev).*/\1${timestamp}/" VERSION + +echo "release $(cat VERSION)? (press CTRL+C to abort)" +read +make publish diff --git a/bin/release-helper.sh b/bin/release-helper.sh new file mode 100755 index 0000000000000..b0ca988df4c73 --- /dev/null +++ b/bin/release-helper.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash + +# set -x +set -euo pipefail +shopt -s nullglob +shopt -s globstar + +DEPENDENCY_FILE=${DEPENDENCY_FILE:-pyproject.toml} + +function usage() { + echo "A set of commands that facilitate release automation" + echo "" + echo "USAGE" + echo " release-helper <command> [options]" + echo "" + echo "Commands:" + echo " github-outputs <patch|minor|major>" + echo " print version number outputs for github actions" + echo "" + echo " explain-steps <patch|minor|major>" + echo " print a list of steps that should be executed for the release type" + echo "" + echo " get-ver" + echo " prints the current version number in the version file" + echo "" + echo " set-dep-ver <dep> <range>" + echo " set the dependency version in the dependency file" + echo " example: set-dep-ver 'localstack-ext' '==0.15.0'" + echo "" + echo " pip-download-retry <dep> <version>" + echo " blocks until the given version of the given dependency becomes downloadable by pip" + echo " example: pip-download-retry 'localstack-ext' '0.15.0'" + echo "" + echo " git-commit-release <version>" + echo " creates a tag and release commit for the given version" + echo "" + echo " git-commit-increment" + echo " creates a commit for the next development iteration" + echo "" + echo " help" + echo " show this message" +} + +function get_current_version() { + # check if setuptools_scm is installed, if not prompt to install. python3 is expected to be present + if ! python3 -m pip -qqq show setuptools_scm > /dev/null ; then + echo "ERROR: setuptools_scm is not installed. Run 'pip install --upgrade setuptools setuptools_scm'" >&2 + exit 1 + fi + python3 -m setuptools_scm +} + +function remove_ver_suffix() { + awk -F. '{ print $1 "." $2 "." $3 }' +} + +function add_dev_suffix() { + awk -F. '{ print $1 "." $2 "." $3 ".dev" }' +} + +function increment_patch() { + awk -F. '{ print $1 "." $2 "." $3 + 1 }' +} + +function increment_minor() { + awk -F. '{ print $1 "." $2 + 1 "." 0 }' +} + +function increment_major() { + awk -F. '{ print $1 + 1 "." 0 "." 0 }' +} + +function verify_valid_version() { + read ver + echo $ver | egrep "^([0-9]+)\.([0-9]+)(\.[0-9]+)?" > /dev/null || { echo "invalid version string '$ver'"; exit 1; } +} + +function release_env_compute() { + case $1 in + "patch") + RELEASE_VER=$(get_current_version | remove_ver_suffix) + ;; + "minor") + RELEASE_VER=$(get_current_version | increment_minor) + ;; + "major") + RELEASE_VER=$(get_current_version | increment_major) + ;; + *) + echo "unknown release type '$1'" + exit 1 + ;; + esac + + export CURRENT_VER=$(get_current_version) + export RELEASE_VER=${RELEASE_VER} + export DEVELOP_VER=$(echo ${RELEASE_VER} | increment_patch | add_dev_suffix) + # uses only the minor version. for 1.0.1 -> patch the boundary would be 1.1 + export BOUNDARY_VER=$(echo ${DEVELOP_VER} | increment_minor | cut -d'.' -f-2) + + release_env_validate || { echo "invalid release environment"; exit 1; } +} + +function release_env_validate() { + echo ${CURRENT_VER} | verify_valid_version + echo ${RELEASE_VER} | verify_valid_version + echo ${DEVELOP_VER} | verify_valid_version + echo ${BOUNDARY_VER} | verify_valid_version +} + +function explain_release_steps() { + echo "- perform release" + echo " - set synced dependencies to ==${RELEASE_VER}" + echo " - git commit -a -m 'Release version ${RELEASE_VER}'" + echo " - git tag -a 'v${RELEASE_VER}' -m 'Release version ${RELEASE_VER}'" + echo " - make publish" + echo " - git push && git push --tags" + echo "- prepare development iteration" + echo " - set synced dependencies to >=${DEVELOP_VER},<${BOUNDARY_VER}" + echo " - git commit -a -m 'Prepare next development iteration'" + echo " - git push" +} + +function print_github_outputs() { + echo "current=${CURRENT_VER}" >> $GITHUB_OUTPUT + echo "release=${RELEASE_VER}" >> $GITHUB_OUTPUT + echo "develop=${DEVELOP_VER}" >> $GITHUB_OUTPUT + echo "boundary=${BOUNDARY_VER}" >> $GITHUB_OUTPUT +} + +# commands + +function cmd-get-ver() { + [[ $# -eq 0 ]] || { usage; exit 1; } + get_current_version +} + +function cmd-set-dep-ver() { + [[ $# -eq 2 ]] || { usage; exit 1; } + + dep=$1 + ver=$2 + + egrep -h "^(\s*\"?)(${dep})(\[[a-zA-Z0-9,\-]+\])?(>=|==|<=)([^\"]*)(\")?(,)?$" ${DEPENDENCY_FILE} || { echo "dependency ${dep} not found in ${DEPENDENCY_FILE}"; return 1; } + sed -i -r "s/^(\s*\"?)(${dep})(\[[a-zA-Z0-9,\-]+\])?(>=|==|<=)([^\"]*)(\")?(,)?$/\1\2\3${ver}\6\7/g" ${DEPENDENCY_FILE} +} + +function cmd-github-outputs() { + release_env_compute $1 + print_github_outputs +} + +function cmd-explain-steps() { + release_env_compute $1 + explain_release_steps +} + +function cmd-pip-download-retry() { + [[ $# -eq 2 ]] || { usage; exit 1; } + + dep=$1 + ver=$2 + + export pip_download_tmpdir="$(mktemp -d)" + trap 'rm -rf -- "$pip_download_tmpdir"' EXIT + + while ! python3 -m pip download -d ${pip_download_tmpdir} --no-deps --pre "${dep}==${ver}" &> /dev/null; do + sleep 5 + done +} + +function cmd-git-commit-release() { + [[ $# -eq 1 ]] || { usage; exit 1; } + + echo $1 || verify_valid_version + + git add "${DEPENDENCY_FILE}" + # allow empty commit here as the community version might not have any changes, but we still need a commit for a tag + git commit --allow-empty -m "release version ${1}" + git tag -a "v${1}" -m "Release version ${1}" +} + +function cmd-git-commit-increment() { + git add "${DEPENDENCY_FILE}" + git commit --allow-empty -m "prepare next development iteration" +} + +function main() { + [[ $# -lt 1 ]] && { usage; exit 1; } + + command_name=$1 + shift + + # invoke command + case $command_name in + "get-ver") cmd-get-ver "$@" ;; + "set-dep-ver") cmd-set-dep-ver "$@" ;; + "github-outputs") cmd-github-outputs "$@" ;; + "explain-steps") cmd-explain-steps "$@" ;; + "pip-download-retry") cmd-pip-download-retry "$@" ;; + "git-commit-release") cmd-git-commit-release "$@" ;; + "git-commit-increment") cmd-git-commit-increment "$@" ;; + "help") usage && exit 0 ;; + *) usage && exit 1 ;; + esac +} + +main "$@" diff --git a/bin/supervisord.conf b/bin/supervisord.conf deleted file mode 100644 index c0ab99ee8cc8a..0000000000000 --- a/bin/supervisord.conf +++ /dev/null @@ -1,21 +0,0 @@ -[supervisord] -nodaemon=true -logfile=/tmp/supervisord.log - -[program:infra] -command=make infra -autostart=true -autorestart=true -stdout_logfile=/dev/fd/1 -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/fd/2 -stderr_logfile_maxbytes=0 - -[program:dashboard] -command=make web -autostart=true -autorestart=true -stdout_logfile=/dev/fd/1 -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/fd/2 -stderr_logfile_maxbytes=0 diff --git a/doc/contributor_license_agreement/README.md b/doc/contributor_license_agreement/README.md deleted file mode 100644 index 392a0e16a3ad7..0000000000000 --- a/doc/contributor_license_agreement/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Contributor License Agreement (CLA) - -This license is for your protection as a Contributor as well as the protection of the maintainers of the LocalStack software; it does not change your rights to use your own Contributions for any other purpose. In the following, the maintainers of LocalStack are referred to as "LocalStack". - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to "LocalStack". Except for the license granted herein to LocalSack and recipients of software distributed by "LocalStack", You reserve all right, title, and interest in and to Your Contributions. - -1. Definitions. - - "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with "LocalStack". For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. - - "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to "LocalStack" for inclusion in, or documentation of, any of the products owned or managed by "LocalStack" (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to "LocalStack" or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, "LocalStack" for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You grant to "LocalStack" and to recipients of software distributed by "LocalStack" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You grant to "LocalStack" and to recipients of software distributed by "LocalStack" a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. - -4. You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to "LocalStack", or that your employer has executed a separate Contributor License Agreement with "LocalStack". - -5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. - -6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -7. Should You wish to submit work that is not Your original creation, You may submit it to "LocalStack" separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". - -8. You agree to notify "LocalStack" of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. \ No newline at end of file diff --git a/doc/developer_guides/README.md b/doc/developer_guides/README.md deleted file mode 100644 index 6f887a30ca51b..0000000000000 --- a/doc/developer_guides/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Developer Guide - -This document contains a few essential instructions for developing new features and bug fixes for *LocalStack*. - -## General Application Architecture - -t.b.a. - -## Proxy Interceptors - -For the basic "CRUD" functionality of most services we're using a mock implementation (e.g., based on `moto`) in the background, and *LocalStack* adds a bunch of integrations on top of these services. We start up an HTTP proxy which intercepts all invocations and forwards requests to the backend. This allows us to add extended functionality without having to change the backend service. - -The figure below illustrates the proxy mechanism and ports for the API Gateway service. (The default ports can be found in https://github.com/localstack/localstack/blob/master/localstack/constants.py ) - -``` - -------- ------------- ------------- -| Client | -> | Proxy | -> | Backend | -| | | (port 4567) | | (port 4566) | - -------- ------------- ------------- -``` - -The proxy follows a simple protocol by implementing 2 methods: `forward_request` which is called *before* a request is forwarded to the backend, and `return_response` which is called *after* a response has been received from the backend: https://github.com/localstack/localstack/blob/master/localstack/services/generic_proxy.py - -The proxy implementation for API Gateway can be found here: https://github.com/localstack/localstack/blob/master/localstack/services/apigateway/apigateway_listener.py#L81 - -## Patching/Releasing a Third-Party Libraries - -To enable a fast release cycle of *LocalStack*, we're using forked versions of various third-party libraries. For example, we have a forked version of `moto` which is published as a separate `moto-ext` pip package: https://github.com/whummer/moto/tree/localstack-fixes . If you decide to extend `moto`, you can either raise a PR against that repo, or against the main repo `spulec/moto` (then we need to take care of cross-merging and releasing new versions). diff --git a/doc/roadmap/README.md b/doc/roadmap/README.md deleted file mode 100644 index e1a07cdcbf4f8..0000000000000 --- a/doc/roadmap/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# LocalStack Roadmap - -**UPDATE: This document is outdated. We are working on an updated roadmap which will be -published here and on the LocalStack website: [http://localstack.cloud](http://localstack.cloud)** - -## Main Streams of Work - -The following list is an attempt to summarize the main streams of work in *LocalStack* (in no particular order): - -1. Support for Different Platforms - - Although running *LocalStack* in Docker is a good common denominator for cross-platform compatibility, there are always - subtle differences between environments (e.g., mounting of volumes into the container, running Docker containers from - within a container, etc). We need to make sure that *LocalStack* is truly platform independent and can be used - (although MacOS/Linux will remain the main target platforms). - -2. API Feature Parity - - The AWS APIs/services are constantly evolving and sometimes have very complex and detailed semantics. Users may rely - on the exact semantics of an API, hence we should strive for the maxiumum possible parity of the mocks with the real - services. - -3. Upstream Issues - - We receive a significant amount of bug requests (and feature requests) from the community that are actually related - to our upstream dependencies. In particular, a majority of our API mocks are based on - [moto](https://github.com/spulec/moto), hence users will often raise a request in *LocalStack* if they discover an - issue in one of the API mocks provided by moto. Some of the issues should be fixed upstream, in some cases it is more - advisable to add a fix directly into *LocalStack*. This has to be decided on a case-by-case basis, but ideally we - would want to define guidelines around that. - -4. Integrations - - Integrations between the APIs/services are essential to support testing of any non-trivial cloud application. We - currently have some of the basic integrations set up (e.g., Kinesis to Lambda, S3 bucket notifications to SQS/SNS, - DynamoDB Streams to Kinesis), but we have a long way to go to provide the "full" set of integrations. - -5. Extensibility / Developer Documentation - - In order to attract more contributions from the community, we need to clearly document how developers can extend - and develop new features for *LocalStack*. - -6. Support for Different Languages / SDKs - - Developers are using LocalStack in very different ways, using different languages and SDKs (Python, Java,\ - Node.js, Go), different testing frameworks (e.g., nosetests, JUnit), etc. We need to ensure a smooth developer - experience for the mainstream . This is related to "Support for Different Platforms". - -7. Extended Test Features - - Applications in a real cloud environments are often exposed to various exceptional situations and intermittent - runtime problems, such as DNS issues, API throttling errors, etc. By adding support for injecting faults into Kinesis - (`KINESIS_ERROR_PROBABILITY`) and DynamoDB (`DYNAMODB_ERROR_PROBABILITY`) we have only just scratched the surface of - what can and should be done regarding systematic resilience testing. - -8. Test Coverage - - The *LocalStack* codebase currently only has a limited set of tests (mainly integration tests), and overall test - coverage should be improved. To achieve a certain level of quality assurance and API parity, we could employ - automated Pact/schema/contract based testing. - -9. Use Cases and Demos - - Make the framework available to a wider audience and showcase the usefulness based on real-world scenarios. Give demos - and talks at conferences/summits, publish blog posts with success stories, customer use cases, etc. - -## Priorities and Timelines - -t.b.a. diff --git a/docker-compose-pro.yml b/docker-compose-pro.yml new file mode 100644 index 0000000000000..98061c285824a --- /dev/null +++ b/docker-compose-pro.yml @@ -0,0 +1,17 @@ +services: + localstack: + container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}" + image: localstack/localstack-pro # required for Pro + ports: + - "127.0.0.1:4566:4566" # LocalStack Gateway + - "127.0.0.1:4510-4559:4510-4559" # external services port range + - "127.0.0.1:443:443" # LocalStack HTTPS Gateway (Pro) + environment: + # Activate LocalStack Pro: https://docs.localstack.cloud/getting-started/auth-token/ + - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:?} # required for Pro + # LocalStack configuration: https://docs.localstack.cloud/references/configuration/ + - DEBUG=${DEBUG:-0} + - PERSISTENCE=${PERSISTENCE:-0} + volumes: + - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack" + - "/var/run/docker.sock:/var/run/docker.sock" diff --git a/docker-compose.yml b/docker-compose.yml index 5083f3fb6a28a..6d70da64e2e06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,13 @@ -version: '2.1' - services: localstack: + container_name: "${LOCALSTACK_DOCKER_NAME:-localstack-main}" image: localstack/localstack ports: - - "4567-4583:4567-4583" - - "8080:8080" + - "127.0.0.1:4566:4566" # LocalStack Gateway + - "127.0.0.1:4510-4559:4510-4559" # external services port range environment: - - SERVICES=${SERVICES- } - - DEBUG=${DEBUG- } - - DATA_DIR=${DATA_DIR- } - - PORT_WEB_UI=${PORT_WEB_UI- } - - LAMBDA_EXECUTOR=${LAMBDA_EXECUTOR- } - - KINESIS_ERROR_PROBABILITY=${KINESIS_ERROR_PROBABILITY- } - - DOCKER_HOST=unix:///var/run/docker.sock + # LocalStack configuration: https://docs.localstack.cloud/references/configuration/ + - DEBUG=${DEBUG:-0} volumes: - - "${TMPDIR:-/tmp/localstack}:/tmp/localstack" + - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack" - "/var/run/docker.sock:/var/run/docker.sock" diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000000000..35af4e8333b5d --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +We welcome contributions to LocalStack! Please refer to the following sections to better understand how LocalStack works internally, how to set up local development environments, and how to contribute to the codebase. + +## Sections + +- [Contribution Guidelines](#contribution-guidelines) +- [Development Environment Setup](development-environment-setup/README.md) +- [LocalStack Concepts](localstack-concepts/README.md) +- [Testing](testing/README.md) + +## Contribution Guidelines + +We welcome feedback, bug reports, and pull requests! + +For pull requests (PRs), please stick to the following guidelines: + +* Before submitting a PR, verify that [an issue](https://github.com/localstack/localstack/issues) exists that describes the bug fix or feature you want to contribute. If there's no issue yet, please [create one](https://github.com/localstack/localstack/issues/new/choose). +* Fork localstack on your GitHub user account, make code changes there, and then create a PR against main localstack repository. +* Add tests for any new features or bug fixes. Ideally, each PR increases the test coverage. Please read our [integration testing](testing/integration-tests/README.md) and [parity testing](testing/parity-testing/README.md) guides on how to write tests for AWS services. +* Follow the existing code style. Run `make format` and `make lint` before checking in your code. + * Refer to [Development Environment Setup](development-environment-setup/README.md) if your local testing environment is not yet properly set up. +* Document newly introduced methods and classes with pydoc, and add inline comments to code that is not self-documenting. +* Separate unrelated changes into multiple PRs. +* When creating a PR, classify the size of your change with setting a semver label: + * `semver: patch`: Small, non-breaking changes. + * `semver: minor`: Bigger, non-breaking changes (like features or bigger refactorings). + * `semver: major`: Breaking changes (no matter how big). + +Please note that by contributing any code or documentation to this repository (by raising PRs, or otherwise) you explicitly agree to the [**Contributor License Agreement**](../.github/CLA.md). diff --git a/docs/development-environment-setup/README.md b/docs/development-environment-setup/README.md new file mode 100644 index 0000000000000..81284d433a263 --- /dev/null +++ b/docs/development-environment-setup/README.md @@ -0,0 +1,114 @@ +# Development Environment Setup + +Before you get started with contributing to LocalStack, make sure you’ve familiarized yourself with LocalStack from the perspective of a user. +You can follow our [getting started guide](https://docs.localstack.cloud/get-started/). +Once LocalStack runs in your Docker environment and you’ve played around with the LocalStack and `awslocal` CLI, you can move forward to set up your developer environment. + +## Development requirements + +You will need the following tools for the local development of LocalStack. + +* [Python](https://www.python.org/downloads/) and `pip` + * We recommend to use a Python version management tool like [`pyenv`](https://github.com/pyenv/pyenv/). + This way you will always use the correct Python version as defined in `.python-version`. +* [Node.js & npm](https://nodejs.org/en/download/) +* [Docker](https://docs.docker.com/desktop/) + +We recommend you to individually install the above tools using your favorite package manager. +For example, on macOS, you can use [Homebrew](https://brew.sh/) to install the above tools. + +### Setting up the Development Environment + +To make contributions to LocalStack, you need to be able to run LocalStack in host mode from your IDE, and be able to attach a debugger to the running LocalStack instance. +We have a basic tutorial to cover how you can do that. + +The basic steps include: + +1. Fork the localstack repository on GitHub [https://github.com/localstack/localstack/](https://github.com/localstack/localstack/) +2. Clone the forked localstack repository `git clone git@github.com:<GITHUB_USERNAME>/localstack.git` +3. Ensure you have `python`, `pip`, `node`, and `npm` installed. +> [!NOTE] +> You might also need `java` for some emulated services. +4. Install the Python dependencies using `make install`. +> [!NOTE] +> This will install the required pip dependencies in a local Python 3 `venv` directory called `.venv` (your global Python packages will remain untouched). +> Depending on your system, some `pip` modules may require additional native libs installed. + +> [!NOTE] +> Consider running `make install-dev-types` to enable type hinting for efficient [integration tests](../testing/integration-tests/README.md) development. +5. Start localstack in host mode using `make start` + +<div align="left"> + <a href="https://youtu.be/XHLBy6VKuCM"> + <img src="https://img.youtube.com/vi/XHLBy6VKuCM/0.jpg" style="width:100%;"> + </a> +</div> + +### Building the Docker image for Development + +We generally recommend using this command to build the `localstack/localstack` Docker image locally (works on Linux/macOS): + +```bash +IMAGE_NAME="localstack/localstack" ./bin/docker-helper.sh build +``` + +### Additional Dependencies for running LocalStack in Host Mode + +In host mode, additional dependencies (e.g., Java) are required for developing certain AWS-emulated services (e.g., DynamoDB). +The required dependencies vary depending on the service, [Configuration](https://docs.localstack.cloud/references/configuration/), operating system, and system architecture (i.e., x86 vs ARM). +Refer to our official [Dockerfile](https://github.com/localstack/localstack/blob/master/Dockerfile) and our [package installer LPM](Concepts/index.md#packages-and-installers) for more details. + +#### Root Permissions + +LocalStack runs its own [DNS server](https://docs.localstack.cloud/user-guide/tools/dns-server/) which listens for requests on port 53. This requires root permission. When LocalStack starts in host mode it runs the DNS server as sudo, so a prompt is triggered asking for the sudo password. This is annoying during local development, so to disable this functionality, use `DNS_ADDRESS=0`. + +> [!NOTE] +> We don't recommend disabling the DNS server in general (e.g. in Docker) because the [DNS server](https://docs.localstack.cloud/user-guide/tools/dns-server/) enables seamless connectivity to LocalStack from different environments via the domain name `localhost.localstack.cloud`. + + +#### Python Dependencies + +* [JPype1](https://pypi.org/project/JPype1/) might require `g++` to fix a compile error on ARM Linux `gcc: fatal error: cannot execute β€˜cc1plus’` + * Used in EventBridge, EventBridge Pipes, and Lambda Event Source Mapping for a Java-based event ruler via the opt-in configuration `EVENT_RULE_ENGINE=java` + * Introduced in [#10615](https://github.com/localstack/localstack/pull/10615) + +#### Test Dependencies + +* Node.js is required for running LocalStack tests because the test fixture for CDK-based tests needs Node.js + +#### DynamoDB + +* [OpenJDK](https://openjdk.org/install/) + +#### Kinesis + +* [NodeJS & npm](https://nodejs.org/en/download/) + +#### Lambda + +* macOS users need to configure `LAMBDA_DEV_PORT_EXPOSE=1` such that the host can reach Lambda containers via IPv4 in bridge mode (see [#7367](https://github.com/localstack/localstack/pull/7367)). + +### Changing our fork of moto + +1. Fork our moto repository on GitHub [https://github.com/localstack/moto](https://github.com/localstack/moto) +2. Clone the forked moto repository `git clone git@github.com:<GITHUB_USERNAME>/moto.git` (using the `localstack` branch) +3. Within the localstack repository, install moto in **editable** mode: + +```sh +# Assuming the following directory structure: +#. +#β”œβ”€β”€ localstack +#└── moto + +cd localstack +source .venv/bin/activate + +pip install -e ../moto +``` + +### Tips + +* If `virtualenv` chooses system python installations before your pyenv installations, manually initialize `virtualenv` before running `make install`: `virtualenv -p ~/.pyenv/shims/python3.10 .venv` . +* Terraform needs version <0.14 to work currently. Use [`tfenv`](https://github.com/tfutils/tfenv) to manage Terraform versions comfortable. Quick start: `tfenv install 0.13.7 && tfenv use 0.13.7` +* Set env variable `LS_LOG='trace'` to print every `http` request sent to localstack and their responses. It is useful for debugging certain issues. +* Catch linter or format errors early by installing Git pre-commit hooks via `pre-commit install`. [pre-commit](https://pre-commit.com/) installation: `pip install pre-commit` or `brew install pre-commit`. diff --git a/doc/end_user_license_agreement/README.md b/docs/end_user_license_agreement/README.md similarity index 100% rename from doc/end_user_license_agreement/README.md rename to docs/end_user_license_agreement/README.md diff --git a/docs/localstack-concepts/README.md b/docs/localstack-concepts/README.md new file mode 100644 index 0000000000000..53f15bcc2d632 --- /dev/null +++ b/docs/localstack-concepts/README.md @@ -0,0 +1,248 @@ +# LocalStack Concepts + +When you first start working on LocalStack, you will most likely start working on AWS providers, either fixing bugs or adding features. In that case, you probably care mostly about [Services](#service), and, depending on the service and how it interacts with the [Gateway](#gateway), also **custom request handlers** and edge **routes**. + +If you are adding new service providers, then you’ll want to know how [Plugins](#plugins) work, and how to expose a service provider as a [service plugin](#service-plugin). This guide will give you a comprehensive overview about various core architectural concepts of LocalStack. + +## AWS Server Framework (ASF) + +AWS is essentially a Remote Procedure Call (RPC) system, and ASF is our server-side implementation of that system. The principal components of which are: + +- Service specifications +- Stub generation +- Remote objects (service implementations) +- Marshalling +- Skeleton + +### Service specifications + +AWS developed a specification language, [Smithy](https://awslabs.github.io/smithy/), which they use internally to define their APIs in a declarative way. They use these specs to generate client SDKs and client documentation. All these specifications are available, among other repositories, in the [botocore repository](https://github.com/boto/botocore/tree/develop/botocore/data). Botocore are the internals of the AWS Python SDK, which allows ASF to interpret and operate on the service specifications. Take a look at an example, [the `Invoke` operation of the `lambda` API](https://github.com/boto/botocore/blob/474e7a23d0fd178790579638cec9123d7e92d10b/botocore/data/lambda/2015-03-31/service-2.json#L564-L573): + +```bash + "Invoke":{ + "name":"Invoke", + "http":{ + "method":"POST", + "requestUri":"/2015-03-31/functions/{FunctionName}/invocations" + }, + "input":{"shape":"InvocationRequest"}, + "output":{"shape":"InvocationResponse"}, + "errors":[ + {"shape":"ServiceException"}, + ... +``` + +### Scaffold - Generating AWS API stubs + +We use these specifications to generate server-side API stubs using our scaffold script. The stubs comprise Python representations of _Shapes_ (type definitions), and an `<Service>Api` class that contains all the operations as function definitions. Notice the `@handler` decorator, which binds the function to the particular AWS operation. This is how we know where to dispatch the request to. + +<img src="asf-code-generation.png" width="800px" alt="Generating AWS API stubs via ASF" /> + +You can try it using this command in the LocalStack repository: + +```bash +python -m localstack.aws.scaffold generate <service> --save [--doc] +``` + +### Service providers + +A service provider is an implementation of an AWS service API. Service providers are the remote object in the RPC terminology. You will find the modern ASF provider implementations in `localstack/services/<service>/provider.py`. + +### Marshalling + +A server-side protocol implementation requires a marshaller (a parser for incoming requests, and a serializer for outgoing responses). + +- Our [protocol parser](https://github.com/localstack/localstack/blob/master/localstack-core/localstack/aws/protocol/parser.py) translates AWS HTTP requests into objects that can be used to call the respective function of the service provider. +- Our [protocol serializer](https://github.com/localstack/localstack/blob/master/localstack-core/localstack/aws/protocol/serializer.py) translates response objects coming from service provider functions into HTTP responses. + +## Service + +Most services are AWS providers, i.e, implementations of AWS APIs. But don’t necessarily have to be. + +### Provider + +Here’s the anatomy of an AWS service implementation. It implements the API stub generated by the scaffold. + +<img src="service-implementation.png" width="800px" alt="Anatomy of an AWS service implementation" /> + +### Stores + +All data processed by the providers are retained by in-memory structures called Stores. Think of them as an in-memory database for the providers to store state. Stores are written in a declarative manner similar to how one would write SQLAlchemy models. + +Stores support namespacing based on AWS Account ID and Regions, which allows emulation of multi-tenant setups and data isolation between regions, respectively. + +LocalStack has a feature called persistence, where the states of all providers are restored when the LocalStack instance is restarted. This is achieved by pickling and unpickling the provider stores. + +### `call_moto` + +Many LocalStack service providers use [`moto`](https://github.com/spulec/moto) as a backend. Moto is an open-source library that provides mocking for Python tests that use Boto, the Python AWS SDK. We re-use a lot of moto’s internal functionality, which provides mostly CRUD and some basic emulation for AWS services. We often extend services in Moto with additional functionality. Moto plays such a fundamental role for many LocalStack services, that we have introduced our own tooling around it, specifically to make requests directly to moto. + +To add functionality on top of `moto`, you can use `call_moto(context: RequestContext)` to forward the given request to `moto`. When used in a service provider `@handler` method, it will dispatch the request to the correct `moto` implementation of the operation, if it exists, and return the parsed AWS response. + +The `MotoFallbackDispatcher` generalizes the behavior for an entire API. You can wrap any provider with it, and it will forward any request that returns a `NotImplementedError` to moto instead and hope for the best. + +Sometimes we also use `moto` code directly, for example importing and accessing `moto` backend dicts (state storage for services). + +## `@patch` + +[The patch utility](https://github.com/localstack/localstack/blob/master/localstack-core/localstack/utils/patch.py) enables easy [monkey patching](https://en.wikipedia.org/wiki/Monkey_patch) of external functionality. We often use this to modify internal moto functionality. Sometimes it is easier to patch internals than to wrap the entire API method with the custom functionality. + +### Server + +[Server](<https://github.com/localstack/localstack/blob/master/localstack-core/localstack/utils/serving.py>) is an abstract class that provides a basis for serving other backends that run in a separate process. For example, our Kinesis implementation uses [kinesis-mock](https://github.com/etspaceman/kinesis-mock/) as a backend that implements the Kinesis AWS API and also emulates its behavior. + +The provider [starts the kinesis-mock binary in a `Server`](https://github.com/localstack/localstack/blob/2e1e8b4e3e98965a7e99cd58ccdeaa6350a2a414/localstack/services/kinesis/kinesis_mock_server.py), and then forwards all incoming requests to it using `forward_request`. This is a similar construct to `call_moto`, only generalized to arbitrary HTTP AWS backends. + +A server is reachable through some URL (not necessarily HTTP), and the abstract class implements the lifecycle of the process (start, stop, is_started, is_running, etc). To create a new server, you only need to overwrite either `do_run`, or `do_start_thread`, with custom logic to start the binary. + +There are some existing useful utilities and specializations of `Server` which can be found across the codebase. For example, `DockerContainerServer` spins up a Docker container on a specific port, and `ProxiedDockerContainerServer` adds an additional TCP/HTTP proxy server (running inside the LocalStack container) that tunnels requests to the container. + +### External service ports + +Some services create additional user-facing resources. For example, the RDS service starts a PostgreSQL server, and the ElastiCache service starts a Redis server, that users then directly connect to. + +These resources are not hidden behind the service API, and need to be exposed through an available network port. This is what the [external service port range](https://docs.localstack.cloud/references/external-ports/) is for. We expose this port range by default in the docker-compose template, or via the CLI. + +### Service plugin + +A service provider has to be exposed as a service plugin for our code loading framework to pick it up. + +## Gateway + +The Gateway is a simple interface: `process(Request, Response)`. It receives an HTTP request and a response that it should populate. To that end, the Gateway uses a `HandlerChain` to process the request. + +An adapter exposes the gateway as something that can be served by a web server. By default, we use Hypercorn, an ASGI web server, and expose the Gateway as an ASGI app through our WSGI/ASGI bridge. + +The gateway creates a `RequestContext` object for each request, which is passed through the handler chain. + +All components of our HTTP framework build heavily on the Werkzeug HTTP server library [Werkzeug](https://github.com/pallets/werkzeug/), which makes our app WSGI compatible. + +<img src="gateway-overview.png" width="800px" alt="LocalStack Gateway overview" /> + +### Handler Chain + +The handler chain implements a variant of the [chain-of-responsibility pattern](https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern), not unlike [the javax.servlet API](https://docs.oracle.com/javaee/7/api/javax/servlet/package-summary.html). The handler chain knows about three different handlers: Request Handlers, Response Handlers, and Exception Handlers. Request and response handlers have the same interface, they only differ in how they are invoked by the handler chain. + +A handler chain can be _running_, _stopped_ or _terminated_. If a request handler stops the chain using `chain.stop()`, the chain stops invoking the remaining request handlers, and jumps straight to the response handlers. If the chain is _terminated_, then even response handlers are skipped. + +If an exception occurs during the execution of a request handler, no other request handlers are executed, and instead the chain calls the exception handlers, and then all response handlers. Exceptions during response handlers are logged, but they do not interrupt the handler chain flow. + +### LocalStack AWS Gateway + +Here is a figure of the handler chain underlying the `LocalstackAwsGateway`, which every HTTP request to `:4566` goes through. + +Some handlers are designed to be extended dynamically at runtime by other services. For example, a service can add HTTP routes to the edge router, which can then process the request differently. OpenSearch, for example, uses this to register HTTP routes to [cluster endpoints](https://docs.localstack.cloud/user-guide/aws/opensearch/#interact-with-the-cluster), that are proxied through `:4566` to the cluster backend. + +<img src="localstack-handler-chain.png" width="800px" alt="LocalStack Handler chain" /> + +## Plugins + +Plugins provided by [https://github.com/localstack/plux](https://github.com/localstack/plux) are how we load: + +- Service providers +- Hooks +- Extensions + +Key points to understand are that plugins use [Python entry points, which are part of the PyPA specification](https://packaging.python.org/en/latest/specifications/entry-points/). Entry points are discovered from the code during a build step rather than defined manually (this is the main differentiator of Plux to other code loading tools). In LocalStack, the `make entrypoints` make target does that, which is also part of `make install`. + +When you add new hooks or service providers, or any other plugin, make sure to run `make entrypoints`. + +When writing plugins, it is important to understand that any code that sits in the same module as the plugin, will be imported when the plugin is _resolved_. That is, _before_ it is loaded. Resolving a plugin simply means discovering the entry points and loading the code the underlying entry point points to. This is why many times you will see imports deferred to the actual loading of the plugin. + +## Config + +The LocalStack configuration is currently simply a set of well-known environment variables that we parse into python values in `localstack/config.py`. When LocalStack is started via the CLI, we also need to pass those environment variables to the container, which is why we keep [a list of the environment variables we consider to be LocalStack configuration](https://github.com/localstack/localstack/blob/7e3045dcdca255e01c0fbd5dbf0228e500e8f42e/localstack/config.py#L693-L700). + +## Hooks + +Hooks are functions exposed as plugins that are collected and executed at specific points during the LocalStack lifecycle. This can be both in the runtime (executed in the container) and the CLI (executed on the host). + +### **Host/CLI hooks** + +These hooks are relevant only to invocations of the CLI. If you use, for example, a docker-compose file to start LocalStack, these are not used. + +- `@hooks.prepare_host` Hooks to prepare the host that's starting LocalStack. Executed on the host when invoking the CLI. +- `@hooks.onfigure_localstack_container` Hooks to configure the LocalStack container before it starts. Executed on the host when invoking the CLI. This hook receives the `LocalstackContainer` object, which can be used to instrument the `docker run` command that starts LocalStack. + +### **Runtime hooks** + +- `@hooks.on_infra_start` Executed when LocalStack runtime components (previously known as _infrastructure_) are started. +- `@hooks.on_infra_ready` Executed when LocalStack is ready to server HTTP requests. + +## Runtime + +The components necessary to run the LocalStack server application are collectively referred to as the _runtime_. This includes the Gateway, scheduled worker threads, etc. The runtime is distinct from the CLI, which runs on the host. Currently, there is no clear separation between the two, you will notice this, for example, in the configuration, where some config variables are used for both the CLI and the runtime. Similarly, there is code used by both. Separating the two is an ongoing process. + + +## Packages and installers + +Whenever we rely on certain third party software, we install it using our package installation framework, which consists of packages and installers. + +A package defines a specific kind of software we need for certain services, for example [dynamodb-local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html). +It also encapsulates general information like name, available versions, etc., and manages the access to the actual installer that is used. + +The installer manages all installation-related information: the destination, the actual installation routine, etc. +There are various types of installers available as base classes that try to minimize the required effort to install software, depending on what we need to install (executables, jar files, GitHub assets,...). +So before you start reinventing the wheel, please check if there is a suitable class to extend. + +Packages and installers can usually be found in `packages.py` in the `localstack.services.<service>` module of the service that requires the dependency. +Dependencies that are required by multiple services are located in `localstack.packages`. + +Additionally, there is the _LocalStack Package Manager (LPM)_. +`lpm` is a module located in `localstack.cli` that provides a [Click](https://click.palletsprojects.com/)-powered CLI interface to trigger installations. +It uses the [Plugin mechanism](#plugins) to discover packages. +_LPM_ can be used directly as a module, and if called without a specific command it prints an extensive description of its available commands: + +```python +source .venv/bin/activate +python -m localstack.cli.lpm +``` + +### Versions +As dependencies exist in different versions, we need to reflect this in our process. +Every version of a package needs to be explicitly supported by an installer implementation. +The package needs to manage the different installers for the different versions. +Each installer for a specific version should only have one instance (due to lock handling). +Resources that do not use versions (e.g. because there is only a link to the newest one) generally use `latest` as version name. + +### Installation targets +To keep things nice and clean, packages are installed in two locations, `static_libs` and `var_libs`. + +`static_libs` is used for packages installed at build time. +When building the docker container, the packages are installed to a folder which will not be overwritten by a host-mounted volume. +The `static_libs` directory should not be modified at container runtime, as it will be reset when the container is recreated. +This is the default target if a package is installed in the aforementioned way via `python -m localstack.cli.lpm install`. + +`var_libs` is the main and default location used for packages installed at runtime. +When starting the docker container, a host-volume is mounted at `var_libs`. +The content of the directory will persist across multiple containers. + +### Installation life-cycle +The installer base class provides two methods to manage potentially necessary side tasks for the installation: `_prepare_installation` and `_post_process`. +These methods simply `pass` by default and need to be overwritten should they be needed. + +### Package discovery +For LPM to be able to discover a package, we expose it via the package plugin mechanism. +This is usually done by writing a function in `plugins.py` that loads a package instance by using the `@package` decorator. + +### `lpm` commands +The available `lpm` commands are: + +- `python -m localstack.cli.lpm list` +- `python -m localstack.cli.lpm install [OPTIONS] PACKAGE...` + +For help with the specific commands, use `python -m localstack.cli.lpm <command> --help`. + +## Utilities + +The codebase contains a wealth of utility functions for various common tasks like handling strings, JSON/XML, threads/processes, collections, date/time conversions, and much more. + +The utilities are grouped into multiple util modules inside the [localstack.utils](<https://github.com/localstack/localstack/tree/master/localstack-core/localstack/utils>) package. Some of the most commonly used utils modules include: + +- `.files` - file handling utilities (e.g., `load_file`, `save_file`, or `mkdir`) +- `.json` - handle JSON content (e.g., `json_safe`, or `canonical_json`) +- `.net` - network ports (e.g., `wait_for_port_open`, or `is_ip_address`) +- `.run` - run external commands (e.g., `run`, or `ShellCommandThread`) +- `.strings` - string/bytes manipulation (e.g., `to_str`, `to_bytes`, or `short_uid`) +- `.sync` - concurrency synchronization (e.g., `poll_condition`, or `retry`) +- `.threads` - manage threads and processes (e.g., `FuncThread`) diff --git a/docs/localstack-concepts/asf-code-generation.png b/docs/localstack-concepts/asf-code-generation.png new file mode 100644 index 0000000000000..c93e4301fd2af Binary files /dev/null and b/docs/localstack-concepts/asf-code-generation.png differ diff --git a/docs/localstack-concepts/gateway-overview.png b/docs/localstack-concepts/gateway-overview.png new file mode 100644 index 0000000000000..4cb50fa64a24a Binary files /dev/null and b/docs/localstack-concepts/gateway-overview.png differ diff --git a/docs/localstack-concepts/localstack-handler-chain.png b/docs/localstack-concepts/localstack-handler-chain.png new file mode 100644 index 0000000000000..ea79a7dfacffa Binary files /dev/null and b/docs/localstack-concepts/localstack-handler-chain.png differ diff --git a/docs/localstack-concepts/service-implementation.png b/docs/localstack-concepts/service-implementation.png new file mode 100644 index 0000000000000..93f8840255e6d Binary files /dev/null and b/docs/localstack-concepts/service-implementation.png differ diff --git a/docs/localstack-readme-banner.svg b/docs/localstack-readme-banner.svg new file mode 100644 index 0000000000000..1645ad03cf8ce --- /dev/null +++ b/docs/localstack-readme-banner.svg @@ -0,0 +1,1389 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + sodipodi:docname="localstack-readme-banner-paths.svg" + inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)" + id="svg8" + version="1.1" + viewBox="0 0 206.42434 77.680802" + height="77.680801mm" + width="206.42435mm"> + <defs + id="defs2"> + <linearGradient + inkscape:collect="always" + id="linearGradient326"> + <stop + style="stop-color:#7860ef;stop-opacity:1" + offset="0" + id="stop322" /> + <stop + style="stop-color:#6ff5e5;stop-opacity:1" + offset="1" + id="stop324" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient4919"> + <stop + style="stop-color:#ffff00;stop-opacity:1;" + offset="0" + id="stop4915" /> + <stop + style="stop-color:#ffff00;stop-opacity:0;" + offset="1" + id="stop4917" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient4903"> + <stop + style="stop-color:#ff0000;stop-opacity:1;" + offset="0" + id="stop4899" /> + <stop + style="stop-color:#ff0000;stop-opacity:0;" + offset="1" + id="stop4901" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient269"> + <stop + style="stop-color:#ff0000;stop-opacity:1" + offset="0" + id="stop265" /> + <stop + style="stop-color:#000000;stop-opacity:1" + offset="1" + id="stop267" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient741"> + <stop + style="stop-color:#654fec;stop-opacity:1" + offset="0" + id="stop737" /> + <stop + style="stop-color:#6ea3fc;stop-opacity:1" + offset="1" + id="stop739" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient610"> + <stop + style="stop-color:#64a7e2;stop-opacity:1" + offset="0" + id="stop606" /> + <stop + style="stop-color:#6ef3e5;stop-opacity:1" + offset="1" + id="stop608" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + id="linearGradient27"> + <stop + style="stop-color:#12123e;stop-opacity:1" + offset="0" + id="stop23" /> + <stop + style="stop-color:#302863;stop-opacity:1" + offset="1" + id="stop25" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient27" + id="linearGradient29" + x1="3.6227629" + y1="127.55271" + x2="110.88775" + y2="72.639816" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1.7771047,0,0,1.3635178,-31.387789,-22.851957)" /> + <linearGradient + gradientUnits="userSpaceOnUse" + y2="74.968002" + x2="13.9516" + y1="12.0444" + x1="77.503502" + id="paint0_linear_20_3" + gradientTransform="matrix(0.23849482,0,0,0.23849482,10.599256,79.814353)"> + <stop + id="stop68" + stop-color="#4D29B4" /> + <stop + id="stop70" + stop-color="#836FFF" + offset="1" /> + </linearGradient> + <linearGradient + gradientUnits="userSpaceOnUse" + y2="-6.5488701" + x2="93.996399" + y1="36.657001" + x1="50.0107" + id="paint1_linear_20_3" + gradientTransform="matrix(0.23849482,0,0,0.23849482,10.599256,79.814353)"> + <stop + id="stop73" + stop-color="#70FFE5" /> + <stop + id="stop75" + stop-color="#6295E1" + offset="1" /> + </linearGradient> + <linearGradient + gradientUnits="userSpaceOnUse" + y2="58.6544" + x2="32.470501" + y1="19.3759" + x1="73.8377" + id="paint2_linear_20_3" + gradientTransform="matrix(0.23849482,0,0,0.23849482,10.599256,79.814353)"> + <stop + id="stop78" + stop-color="#654FEC" + offset="0.00747511" /> + <stop + id="stop80" + stop-color="#71B2FF" + offset="1" /> + </linearGradient> + <clipPath + clipPathUnits="userSpaceOnUse" + id="clipPath398"> + <rect + style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.26458332" + id="rect400" + width="122.84225" + height="15.308036" + x="17.749252" + y="33.436646" /> + </clipPath> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient745" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient747" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient749" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient751" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient753" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient755" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient757" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient759" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient761" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient763" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient765" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient767" + gradientUnits="userSpaceOnUse" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient782" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient784" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient786" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient788" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient790" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient792" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient794" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient796" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient798" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient800" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient802" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient804" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-33.032012,70.968232)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient806" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient808" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient810" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient812" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient814" + gradientUnits="userSpaceOnUse" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-32.872576,69.91595)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient269" + id="linearGradient271" + x1="103.93748" + y1="43.62616" + x2="103.13513" + y2="46.620552" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(31.923131,0.00292326)" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient269" + id="linearGradient4857" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1,0,0,1,242.63786,0.00292326)" + x1="103.93748" + y1="43.62616" + x2="103.13513" + y2="46.620552" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4903" + id="linearGradient4907" + x1="137.99057" + y1="45.766937" + x2="137.99057" + y2="47.825726" + gradientUnits="userSpaceOnUse" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4919" + id="linearGradient4923" + x1="138.0071" + y1="45.800011" + x2="138.0071" + y2="49.38842" + gradientUnits="userSpaceOnUse" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4919" + id="linearGradient4938" + gradientUnits="userSpaceOnUse" + x1="138.0071" + y1="45.800011" + x2="138.0071" + y2="49.38842" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient4903" + id="linearGradient4940" + gradientUnits="userSpaceOnUse" + x1="137.99057" + y1="45.766937" + x2="137.99057" + y2="47.825726" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient175" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-113.49349,69.91595)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient177" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-113.49349,69.91595)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient179" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient181" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient183" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-113.49349,69.91595)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient185" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1.3635178,0,0,1.3635178,-113.49349,69.91595)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient187" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient189" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient225" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1.3635178,0,0,1.3635178,198.31137,69.916213)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient227" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1.3635178,0,0,1.3635178,198.31137,69.916213)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient229" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1.3635178,0,0,1.3635178,198.4708,70.968496)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient231" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1.3635178,0,0,1.3635178,198.31137,69.916213)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient233" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1.3635178,0,0,1.3635178,198.31137,69.916213)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient235" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(3.7795276,0,0,3.7795276,-84.226643,-9.6231538)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient610" + id="linearGradient245" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1.3635178,0,0,1.3635178,278.93228,69.916213)" + x1="61.573586" + y1="59.517006" + x2="61.573586" + y2="50.830746" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient741" + id="linearGradient247" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-1.3635178,0,0,1.3635178,278.93228,69.916213)" + x1="49.499252" + y1="70.683777" + x2="49.499252" + y2="51.579502" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient326" + id="linearGradient328" + x1="49.388699" + y1="108.59602" + x2="119.56761" + y2="108.59602" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1.3635178,0,0,0.6681508,-25.984911,35.03607)" /> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="2.8284271" + inkscape:cx="349.50174" + inkscape:cy="131.92138" + inkscape:document-units="mm" + inkscape:current-layer="layer1" + showgrid="false" + inkscape:lockguides="true" + showguides="true" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:window-width="2560" + inkscape:window-height="1419" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="1" + inkscape:guide-bbox="true" /> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(31.730368,-73.387642)"> + <rect + style="fill:url(#linearGradient29);fill-opacity:1;stroke:none;stroke-width:0.51463312" + id="rect10" + width="193.76692" + height="77.680801" + x="-25.034384" + y="73.387642" + ry="6.6325674" + rx="6.6325674" /> + <g + style="fill:#ffffff" + id="g125" + transform="matrix(0.35298555,0,0,0.35298555,3.0939882,84.010553)"> + <path + id="path42" + d="m 115.18,46.5465 h 11.49 V 52.092 H 108.185 V 17.22 h 6.995 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path44" + d="m 143.406,52.5417 c -2.664,0 -5.063,-0.5829 -7.194,-1.7486 -2.132,-1.1991 -3.814,-2.881 -5.046,-5.046 -1.199,-2.1649 -1.799,-4.6629 -1.799,-7.4939 0,-2.8311 0.617,-5.3291 1.849,-7.494 1.266,-2.165 2.981,-3.8303 5.146,-4.996 2.165,-1.1991 4.579,-1.7986 7.244,-1.7986 2.664,0 5.079,0.5995 7.244,1.7986 2.165,1.1657 3.864,2.831 5.096,4.996 1.266,2.1649 1.898,4.6629 1.898,7.494 0,2.831 -0.649,5.329 -1.948,7.4939 -1.266,2.165 -2.998,3.8469 -5.196,5.046 -2.165,1.1657 -4.596,1.7486 -7.294,1.7486 z m 0,-6.0951 c 1.266,0 2.448,-0.2998 3.547,-0.8993 1.133,-0.6328 2.032,-1.5654 2.698,-2.7978 0.666,-1.2323 0.999,-2.7311 0.999,-4.4963 0,-2.6313 -0.699,-4.6463 -2.098,-6.0452 -1.366,-1.4322 -3.048,-2.1483 -5.046,-2.1483 -1.998,0 -3.68,0.7161 -5.046,2.1483 -1.332,1.3989 -1.998,3.4139 -1.998,6.0452 0,2.6312 0.649,4.6629 1.948,6.0951 1.332,1.3988 2.998,2.0983 4.996,2.0983 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path46" + d="m 161.177,38.2532 c 0,-2.8644 0.582,-5.3624 1.748,-7.494 1.166,-2.165 2.781,-3.8303 4.846,-4.996 2.065,-1.1991 4.43,-1.7986 7.095,-1.7986 3.43,0 6.261,0.866 8.493,2.5979 2.265,1.6987 3.78,4.0968 4.546,7.1943 h -7.544 c -0.4,-1.1991 -1.082,-2.1317 -2.048,-2.7978 -0.933,-0.6994 -2.099,-1.0491 -3.497,-1.0491 -1.999,0 -3.581,0.7327 -4.747,2.1982 -1.165,1.4322 -1.748,3.4805 -1.748,6.1451 0,2.6312 0.583,4.6795 1.748,6.145 1.166,1.4322 2.748,2.1483 4.747,2.1483 2.831,0 4.679,-1.2657 5.545,-3.797 h 7.544 c -0.766,2.9976 -2.281,5.379 -4.546,7.1443 -2.265,1.7652 -5.096,2.6479 -8.493,2.6479 -2.665,0 -5.03,-0.5829 -7.095,-1.7486 -2.065,-1.1991 -3.68,-2.8644 -4.846,-4.996 -1.166,-2.1649 -1.748,-4.6796 -1.748,-7.5439 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path48" + d="m 191.231,38.1532 c 0,-2.7977 0.549,-5.2791 1.648,-7.444 1.133,-2.1649 2.648,-3.8302 4.547,-4.996 1.931,-1.1657 4.08,-1.7486 6.444,-1.7486 2.065,0 3.864,0.4164 5.396,1.249 1.565,0.8327 2.814,1.8818 3.747,3.1475 v -3.9468 h 7.044 V 52.092 h -7.044 v -4.0467 c -0.899,1.2989 -2.148,2.3814 -3.747,3.2474 -1.565,0.8326 -3.381,1.249 -5.446,1.249 -2.331,0 -4.463,-0.5995 -6.394,-1.7986 -1.899,-1.199 -3.414,-2.881 -4.547,-5.0459 -1.099,-2.1983 -1.648,-4.7129 -1.648,-7.544 z m 21.782,0.1 c 0,-1.6987 -0.333,-3.1475 -0.999,-4.3466 -0.666,-1.2323 -1.566,-2.1649 -2.698,-2.7977 -1.132,-0.6661 -2.348,-0.9992 -3.647,-0.9992 -1.299,0 -2.498,0.3164 -3.597,0.9492 -1.099,0.6329 -1.999,1.5654 -2.698,2.7978 -0.666,1.199 -0.999,2.6312 -0.999,4.2965 0,1.6654 0.333,3.1308 0.999,4.3965 0.699,1.2323 1.599,2.1816 2.698,2.8477 1.132,0.6661 2.331,0.9992 3.597,0.9992 1.299,0 2.515,-0.3164 3.647,-0.9492 1.132,-0.6662 2.032,-1.5987 2.698,-2.7978 0.666,-1.2323 0.999,-2.6978 0.999,-4.3964 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path50" + d="M 233.883,15.1217 V 52.092 h -6.994 V 15.1217 Z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path52" + d="m 252.834,52.4417 c -2.432,0 -4.63,-0.4163 -6.595,-1.2489 -1.932,-0.8327 -3.464,-2.0318 -4.596,-3.5972 -1.133,-1.5654 -1.715,-3.4139 -1.749,-5.5455 h 7.494 c 0.1,1.4322 0.6,2.5646 1.499,3.3973 0.933,0.8326 2.198,1.249 3.797,1.249 1.632,0 2.914,-0.3831 3.847,-1.1491 0.932,-0.7994 1.399,-1.8319 1.399,-3.0975 0,-1.0325 -0.317,-1.8818 -0.95,-2.548 -0.632,-0.6661 -1.432,-1.1824 -2.398,-1.5487 -0.932,-0.3997 -2.231,-0.8327 -3.896,-1.299 -2.265,-0.6661 -4.114,-1.3156 -5.546,-1.9484 -1.399,-0.6662 -2.615,-1.6487 -3.647,-2.9477 -0.999,-1.3322 -1.499,-3.0975 -1.499,-5.2957 0,-2.065 0.516,-3.8636 1.549,-5.3957 1.032,-1.5321 2.481,-2.6978 4.346,-3.4972 1.866,-0.8326 3.997,-1.2489 6.395,-1.2489 3.597,0 6.512,0.8826 8.743,2.6478 2.265,1.732 3.514,4.1633 3.747,7.2942 h -7.694 c -0.066,-1.1991 -0.582,-2.1816 -1.548,-2.9477 -0.933,-0.7993 -2.182,-1.199 -3.747,-1.199 -1.366,0 -2.465,0.3497 -3.298,1.0491 -0.799,0.6995 -1.199,1.7153 -1.199,3.0476 0,0.9326 0.3,1.7153 0.9,2.3481 0.632,0.5995 1.398,1.0991 2.298,1.4988 0.932,0.3664 2.231,0.7994 3.897,1.299 2.264,0.6661 4.113,1.3322 5.545,1.9984 1.432,0.6661 2.665,1.6653 3.697,2.9976 1.033,1.3322 1.549,3.0808 1.549,5.2457 0,1.8652 -0.483,3.5971 -1.449,5.1959 -0.966,1.5987 -2.381,2.881 -4.247,3.8469 -1.865,0.9326 -4.08,1.3988 -6.644,1.3988 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path54" + d="m 279.381,30.1597 v 13.3892 c 0,0.9326 0.217,1.6154 0.65,2.0483 0.466,0.3997 1.232,0.5996 2.298,0.5996 h 3.247 v 5.8952 h -4.396 c -5.895,0 -8.843,-2.8643 -8.843,-8.5931 V 30.1597 h -3.297 v -5.7454 h 3.297 v -6.8445 h 7.044 v 6.8445 h 6.195 v 5.7454 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path56" + d="m 288.808,38.1532 c 0,-2.7977 0.55,-5.2791 1.649,-7.444 1.133,-2.1649 2.648,-3.8302 4.546,-4.996 1.932,-1.1657 4.08,-1.7486 6.445,-1.7486 2.065,0 3.864,0.4164 5.396,1.249 1.565,0.8327 2.814,1.8818 3.747,3.1475 v -3.9468 h 7.044 V 52.092 h -7.044 v -4.0467 c -0.899,1.2989 -2.148,2.3814 -3.747,3.2474 -1.565,0.8326 -3.381,1.249 -5.446,1.249 -2.331,0 -4.463,-0.5995 -6.395,-1.7986 -1.898,-1.199 -3.413,-2.881 -4.546,-5.0459 -1.099,-2.1983 -1.649,-4.7129 -1.649,-7.544 z m 21.783,0.1 c 0,-1.6987 -0.333,-3.1475 -0.999,-4.3466 -0.666,-1.2323 -1.566,-2.1649 -2.698,-2.7977 -1.133,-0.6661 -2.348,-0.9992 -3.647,-0.9992 -1.299,0 -2.498,0.3164 -3.597,0.9492 -1.099,0.6329 -1.999,1.5654 -2.698,2.7978 -0.666,1.199 -0.999,2.6312 -0.999,4.2965 0,1.6654 0.333,3.1308 0.999,4.3965 0.699,1.2323 1.599,2.1816 2.698,2.8477 1.132,0.6661 2.331,0.9992 3.597,0.9992 1.299,0 2.514,-0.3164 3.647,-0.9492 1.132,-0.6662 2.032,-1.5987 2.698,-2.7978 0.666,-1.2323 0.999,-2.6978 0.999,-4.3964 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path58" + d="m 322.668,38.2532 c 0,-2.8644 0.583,-5.3624 1.749,-7.494 1.165,-2.165 2.781,-3.8303 4.846,-4.996 2.065,-1.1991 4.429,-1.7986 7.094,-1.7986 3.431,0 6.262,0.866 8.493,2.5979 2.265,1.6987 3.78,4.0968 4.546,7.1943 h -7.543 c -0.4,-1.1991 -1.083,-2.1317 -2.049,-2.7978 -0.932,-0.6994 -2.098,-1.0491 -3.497,-1.0491 -1.998,0 -3.58,0.7327 -4.746,2.1982 -1.166,1.4322 -1.749,3.4805 -1.749,6.1451 0,2.6312 0.583,4.6795 1.749,6.145 1.166,1.4322 2.748,2.1483 4.746,2.1483 2.831,0 4.68,-1.2657 5.546,-3.797 h 7.543 c -0.766,2.9976 -2.281,5.379 -4.546,7.1443 -2.265,1.7652 -5.096,2.6479 -8.493,2.6479 -2.665,0 -5.029,-0.5829 -7.094,-1.7486 -2.065,-1.1991 -3.681,-2.8644 -4.846,-4.996 -1.166,-2.1649 -1.749,-4.6796 -1.749,-7.5439 z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + <path + id="path60" + d="M 370.907,52.092 361.515,40.3015 V 52.092 H 354.52 V 15.1217 h 6.995 v 21.0331 l 9.292,-11.7405 H 379.9 L 367.71,38.3031 380,52.092 Z" + inkscape:connector-curvature="0" + style="fill:#ffffff" /> + </g> + <g + id="g174" + transform="matrix(1.831508,0,0,1.831508,-28.624013,-56.920346)"> + <path + id="path62" + d="m 28.796005,94.570671 c 0.239044,0 0.358744,0.289008 0.189723,0.45803 l -5.813908,5.813459 c -0.169021,0.16902 -0.458053,0.0493 -0.458053,-0.1897 v -5.813482 c 0,-0.148177 0.12013,-0.268307 0.268331,-0.268307 z M 10.868116,89.09266 c -0.239043,0 -0.358757,-0.289008 -0.189726,-0.458005 l 5.813906,-5.813479 c 0.169021,-0.169021 0.458029,-0.04932 0.458029,0.189699 v 5.813478 c 0,0.148177 -0.120129,0.268307 -0.268306,0.268307 z" + inkscape:connector-curvature="0" + style="clip-rule:evenodd;fill:url(#paint0_linear_20_3);fill-rule:evenodd;stroke-width:0.23849481" /> + <path + id="path64" + d="m 23.524101,88.138514 h 7.788168 c 0.148177,0 0.268306,-0.120106 0.268306,-0.268283 v -7.7876 c 0,-0.148176 -0.120129,-0.268297 -0.268306,-0.268297 l -7.788168,-10e-7 c -0.148177,0 -0.268306,0.120121 -0.268306,0.268296 v 7.787602 c 0,0.148177 0.120129,0.268283 0.268306,0.268283 z" + inkscape:connector-curvature="0" + style="fill:url(#paint1_linear_20_3);stroke-width:0.23849481" /> + <path + id="path66" + d="M 29.292909,93.22921 H 18.560237 c -0.148177,0 -0.268307,-0.12013 -0.268307,-0.268307 V 82.229018 c 0,-0.14817 0.12013,-0.26829 0.268307,-0.26829 h 3.085646 c 0.148177,0 0.268307,0.12012 0.268307,0.26829 v 6.975735 c 0,0.148176 0.120129,0.268306 0.26833,0.268306 h 7.110389 c 0.148177,0 0.268307,0.120106 0.268307,0.268283 v 3.219561 c 0,0.148177 -0.12013,0.268307 -0.268307,0.268307 z" + inkscape:connector-curvature="0" + style="fill:url(#paint2_linear_20_3);stroke-width:0.23849481" /> + </g> + <g + aria-label="The leading platform for local +cloud development" + style="font-style:normal;font-weight:normal;font-size:7.0002079px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.17500518" + id="text141"> + <path + d="m 42.870778,118.15465 v -4.64814 h -1.589047 v -0.50402 h 3.738111 v 0.50402 h -1.589048 v 4.64814 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path187" /> + <path + d="m 45.663861,118.15465 v -5.34116 h 0.553016 v 2.01606 q 0.259008,-0.18901 0.616019,-0.32901 0.36401,-0.14701 0.756022,-0.14701 0.392012,0 0.644019,0.16801 0.259008,0.168 0.378011,0.45501 0.126004,0.28701 0.126004,0.63702 v 2.54108 h -0.553016 v -2.48508 q 0,-0.238 -0.098,-0.42001 -0.091,-0.182 -0.266008,-0.28701 -0.175005,-0.105 -0.420013,-0.105 -0.231007,0 -0.427012,0.049 -0.196006,0.042 -0.378012,0.12601 -0.182005,0.077 -0.378011,0.189 v 2.93309 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path189" /> + <path + d="m 51.208021,118.19665 q -0.588017,0 -0.931027,-0.17501 -0.33601,-0.175 -0.483015,-0.59501 -0.147004,-0.42002 -0.147004,-1.14804 0,-0.74202 0.147004,-1.15503 0.154005,-0.42001 0.497015,-0.58802 0.34301,-0.168 0.924027,-0.168 0.497015,0 0.805024,0.112 0.31501,0.112 0.462014,0.38501 0.154005,0.26601 0.154005,0.74202 0,0.32901 -0.133004,0.53202 -0.126004,0.196 -0.371011,0.28701 -0.238007,0.084 -0.567017,0.084 h -1.365041 q 0.007,0.45501 0.098,0.73502 0.091,0.27301 0.32901,0.39901 0.245007,0.11901 0.714021,0.11901 h 1.204036 v 0.33601 q -0.32201,0.042 -0.630019,0.07 -0.308009,0.028 -0.707021,0.028 z m -1.01503,-2.05106 h 1.33004 q 0.315009,0 0.462013,-0.11201 0.154005,-0.119 0.154005,-0.42001 0,-0.32201 -0.098,-0.49701 -0.091,-0.18201 -0.294009,-0.25201 -0.196006,-0.077 -0.532016,-0.077 -0.392011,0 -0.616018,0.119 -0.224007,0.112 -0.315009,0.40601 -0.091,0.29401 -0.091,0.83303 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path192" /> + <path + d="m 56.437155,118.16165 q -0.32201,0 -0.539016,-0.11201 -0.217007,-0.119 -0.32901,-0.37801 -0.112003,-0.266 -0.112003,-0.71402 v -4.14412 h 0.553016 v 4.07412 q 0,0.33601 0.063,0.51102 0.063,0.168 0.182005,0.238 0.119004,0.063 0.280009,0.084 l 0.378011,0.042 v 0.39901 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path194" /> + <path + d="m 58.94322,118.19665 q -0.588017,0 -0.931028,-0.17501 -0.336009,-0.175 -0.483014,-0.59501 -0.147004,-0.42002 -0.147004,-1.14804 0,-0.74202 0.147004,-1.15503 0.154005,-0.42001 0.497015,-0.58802 0.34301,-0.168 0.924027,-0.168 0.497015,0 0.805024,0.112 0.31501,0.112 0.462014,0.38501 0.154005,0.26601 0.154005,0.74202 0,0.32901 -0.133004,0.53202 -0.126004,0.196 -0.371011,0.28701 -0.238007,0.084 -0.567017,0.084 H 57.93519 q 0.007,0.45501 0.098,0.73502 0.091,0.27301 0.32901,0.39901 0.245007,0.11901 0.714021,0.11901 h 1.204036 v 0.33601 q -0.32201,0.042 -0.630019,0.07 -0.308009,0.028 -0.707021,0.028 z m -1.01503,-2.05106 h 1.330039 q 0.31501,0 0.462014,-0.11201 0.154005,-0.119 0.154005,-0.42001 0,-0.32201 -0.098,-0.49701 -0.091,-0.18201 -0.294009,-0.25201 -0.196006,-0.077 -0.532016,-0.077 -0.392011,0 -0.616018,0.119 -0.224007,0.112 -0.315009,0.40601 -0.091,0.29401 -0.091,0.83303 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path196" /> + <path + d="m 62.030301,118.19665 q -0.413012,0 -0.67902,-0.24501 -0.266008,-0.24501 -0.266008,-0.69302 v -0.26601 q 0,-0.44101 0.287009,-0.71402 0.294008,-0.27301 0.882026,-0.27301 h 1.267037 v -0.46201 q 0,-0.23101 -0.084,-0.39201 -0.077,-0.16801 -0.287009,-0.25901 -0.203006,-0.091 -0.595017,-0.091 h -1.162035 v -0.32901 q 0.245007,-0.042 0.567017,-0.077 0.32901,-0.035 0.770023,-0.035 0.455013,-0.007 0.756022,0.112 0.301009,0.119 0.441013,0.37801 0.147005,0.25201 0.147005,0.66502 v 2.63908 h -0.427013 l -0.105003,-0.42701 q -0.028,0.028 -0.175005,0.098 -0.140004,0.07 -0.364011,0.161 -0.217006,0.084 -0.476014,0.14701 -0.252008,0.063 -0.497015,0.063 z m 0.231007,-0.41301 q 0.126004,0.007 0.287008,-0.028 0.161005,-0.035 0.32901,-0.077 0.168005,-0.049 0.308009,-0.098 0.147005,-0.049 0.238007,-0.084 0.091,-0.035 0.098,-0.035 v -1.17604 l -1.141034,0.049 q -0.399011,0.014 -0.574017,0.19601 -0.168005,0.182 -0.168005,0.46901 v 0.16801 q 0,0.22401 0.091,0.36401 0.098,0.133 0.238007,0.189 0.147005,0.056 0.294009,0.063 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path198" /> + <path + d="m 66.328417,118.19665 q -0.462014,0 -0.763023,-0.19601 -0.294008,-0.203 -0.441013,-0.61602 -0.140004,-0.42001 -0.140004,-1.07103 0,-0.69302 0.140004,-1.12003 0.140004,-0.43401 0.448014,-0.63002 0.308009,-0.196 0.826024,-0.196 0.315009,0 0.616018,0.07 0.301009,0.07 0.532016,0.154 v -1.77805 h 0.553017 v 5.34116 h -0.448014 l -0.105003,-0.37801 q -0.105003,0.091 -0.315009,0.189 -0.203006,0.098 -0.448013,0.16801 -0.238007,0.063 -0.455014,0.063 z m 0.161005,-0.45501 q 0.322009,0 0.581017,-0.098 0.266008,-0.105 0.476014,-0.217 v -2.44308 q -0.252007,-0.077 -0.483014,-0.126 -0.231007,-0.056 -0.518015,-0.056 -0.357011,0 -0.581018,0.133 -0.224006,0.13301 -0.32901,0.46202 -0.098,0.32201 -0.098,0.89602 0,0.51802 0.091,0.84003 0.098,0.32201 0.308009,0.46901 0.210007,0.14001 0.553017,0.14001 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path200" /> + <path + d="m 69.226494,113.75152 q -0.112003,0 -0.112003,-0.11201 v -0.54601 q 0,-0.11201 0.112003,-0.11201 h 0.392011 q 0.056,0 0.077,0.035 0.028,0.028 0.028,0.077 v 0.54601 q 0,0.11201 -0.105004,0.11201 z m -0.084,4.40313 v -3.73811 h 0.553017 v 3.73811 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path202" /> + <path + d="m 70.759532,118.15465 v -3.73811 h 0.434013 l 0.119003,0.41301 q 0.266008,-0.18901 0.623019,-0.32901 0.357011,-0.14701 0.742022,-0.14701 0.413012,0 0.66502,0.17501 0.252007,0.17501 0.371011,0.46201 0.119003,0.28701 0.119003,0.62302 v 2.54108 h -0.553016 v -2.47808 q 0,-0.238 -0.098,-0.42001 -0.091,-0.189 -0.266008,-0.29401 -0.168005,-0.105 -0.420012,-0.105 -0.231007,0 -0.434013,0.049 -0.196006,0.042 -0.378012,0.12601 -0.175005,0.077 -0.371011,0.189 v 2.93309 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path204" /> + <path + d="m 75.792679,119.8697 q -0.364011,0 -0.64402,-0.105 -0.273008,-0.098 -0.434012,-0.30801 -0.154005,-0.20301 -0.154005,-0.51102 v -0.168 q 0,-0.32201 0.175005,-0.53902 0.182006,-0.21701 0.455014,-0.32201 0.273008,-0.112 0.560016,-0.112 l 0.140005,0.224 q -0.210007,0 -0.392012,0.07 -0.182006,0.063 -0.301009,0.21 -0.112003,0.14701 -0.112003,0.37801 v 0.15401 q 0,0.33601 0.224006,0.47601 0.224007,0.14701 0.609018,0.14701 h 0.868026 q 0.420013,0 0.65802,-0.16101 0.245007,-0.161 0.245007,-0.50401 v -0.14001 q 0,-0.196 -0.077,-0.33601 -0.077,-0.14 -0.259008,-0.217 -0.175005,-0.077 -0.497015,-0.077 h -1.127033 q -0.434013,0 -0.67902,-0.182 -0.238008,-0.18901 -0.238008,-0.48302 0,-0.28 0.161005,-0.44101 0.168005,-0.161 0.469014,-0.22401 -0.259007,-0.105 -0.406012,-0.28 -0.147004,-0.18201 -0.203006,-0.38502 -0.056,-0.21 -0.056,-0.42001 0,-0.43401 0.175006,-0.70002 0.175005,-0.26601 0.497014,-0.38501 0.32901,-0.119 0.770023,-0.119 h 1.99506 v 0.31501 l -0.840025,0.07 q 0.091,0.063 0.203006,0.168 0.112003,0.098 0.189005,0.28701 0.084,0.18201 0.084,0.50402 0,0.28701 -0.126004,0.54601 -0.119004,0.25901 -0.406012,0.42702 -0.287009,0.161 -0.791024,0.161 h -0.595017 q -0.294009,0 -0.469014,0.091 -0.168005,0.084 -0.168005,0.30801 0,0.16801 0.126004,0.23801 0.126003,0.07 0.266007,0.07 h 1.239037 q 0.434013,0 0.714021,0.112 0.287009,0.11201 0.427013,0.34301 0.147005,0.22401 0.147005,0.57402 v 0.15401 q 0,0.51101 -0.350011,0.79802 -0.34301,0.29401 -1.02903,0.29401 z m 0.259007,-3.4091 h 0.420013 q 0.35701,0 0.539016,-0.10501 0.189005,-0.112 0.259007,-0.287 0.07,-0.17501 0.07,-0.37802 0,-0.315 -0.105004,-0.49701 -0.098,-0.18901 -0.280008,-0.26601 -0.182005,-0.084 -0.420012,-0.084 h -0.399012 q -0.399012,0 -0.630019,0.182 -0.224007,0.17501 -0.224007,0.61602 0,0.43402 0.203006,0.63002 0.210007,0.18901 0.567017,0.18901 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path206" /> + <path + d="m 80.797805,119.8137 v -5.39716 h 0.448013 l 0.105003,0.38501 q 0.126004,-0.091 0.32201,-0.18901 0.203006,-0.105 0.434013,-0.175 0.238007,-0.07 0.469014,-0.07 0.36401,0 0.616018,0.161 0.259008,0.154 0.420012,0.42001 0.161005,0.26601 0.231007,0.60902 0.07,0.34301 0.07,0.72102 0,0.63002 -0.147004,1.06403 -0.140004,0.42702 -0.434013,0.64402 -0.294009,0.21701 -0.756022,0.21701 -0.31501,0 -0.637019,-0.098 -0.31501,-0.10501 -0.588018,-0.25901 v 1.96706 z m 1.624048,-2.07906 q 0.266008,0 0.476014,-0.13301 0.210006,-0.133 0.33601,-0.44801 0.126004,-0.32201 0.126004,-0.87503 0,-0.52501 -0.133004,-0.84002 -0.133004,-0.32201 -0.34301,-0.46202 -0.210006,-0.147 -0.462014,-0.147 -0.322009,0 -0.595018,0.105 -0.266008,0.098 -0.476014,0.21001 v 2.28907 q 0.245008,0.133 0.518016,0.217 0.280008,0.084 0.553016,0.084 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path208" /> + <path + d="m 85.823946,118.16165 q -0.32201,0 -0.539016,-0.11201 -0.217006,-0.119 -0.32901,-0.37801 -0.112003,-0.266 -0.112003,-0.71402 v -4.14412 h 0.553016 v 4.07412 q 0,0.33601 0.063,0.51102 0.063,0.168 0.182006,0.238 0.119003,0.063 0.280008,0.084 l 0.378011,0.042 v 0.39901 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path210" /> + <path + d="m 87.629991,118.19665 q -0.413013,0 -0.679021,-0.24501 -0.266007,-0.24501 -0.266007,-0.69302 v -0.26601 q 0,-0.44101 0.287008,-0.71402 0.294009,-0.27301 0.882026,-0.27301 h 1.267038 v -0.46201 q 0,-0.23101 -0.084,-0.39201 -0.077,-0.16801 -0.287008,-0.25901 -0.203006,-0.091 -0.595018,-0.091 h -1.162034 v -0.32901 q 0.245007,-0.042 0.567016,-0.077 0.32901,-0.035 0.770023,-0.035 0.455014,-0.007 0.756023,0.112 0.301009,0.119 0.441013,0.37801 0.147004,0.25201 0.147004,0.66502 v 2.63908 h -0.427012 l -0.105004,-0.42701 q -0.028,0.028 -0.175005,0.098 -0.140004,0.07 -0.364011,0.161 -0.217006,0.084 -0.476014,0.14701 -0.252007,0.063 -0.497014,0.063 z m 0.231006,-0.41301 q 0.126004,0.007 0.287009,-0.028 0.161005,-0.035 0.32901,-0.077 0.168005,-0.049 0.308009,-0.098 0.147004,-0.049 0.238007,-0.084 0.091,-0.035 0.098,-0.035 v -1.17604 l -1.141034,0.049 q -0.399012,0.014 -0.574017,0.19601 -0.168005,0.182 -0.168005,0.46901 v 0.16801 q 0,0.22401 0.091,0.36401 0.098,0.133 0.238007,0.189 0.147004,0.056 0.294008,0.063 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path213" /> + <path + d="m 91.956107,118.15465 q -0.36401,0 -0.602018,-0.11901 -0.238007,-0.119 -0.35001,-0.38501 -0.112003,-0.26601 -0.105003,-0.70702 l 0.021,-2.07206 h -0.595018 v -0.35701 l 0.602018,-0.098 0.091,-1.04303 h 0.427012 v 1.04303 h 1.078032 v 0.45501 h -1.078032 v 2.07206 q 0,0.24501 0.049,0.39201 0.056,0.14701 0.140004,0.22401 0.091,0.077 0.189005,0.112 0.098,0.028 0.189006,0.035 l 0.455014,0.042 v 0.40601 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path216" /> + <path + d="m 93.489146,118.15465 v -3.2831 h -0.574018 v -0.35701 l 0.574018,-0.098 v -0.42702 q 0,-0.39901 0.098,-0.66502 0.098,-0.273 0.32901,-0.41301 0.231007,-0.14 0.644019,-0.14 0.245008,0 0.413013,0.028 0.175005,0.021 0.308009,0.042 v 0.39201 H 94.65818 q -0.252007,0 -0.385011,0.084 -0.133004,0.077 -0.182006,0.24501 -0.049,0.16101 -0.049,0.39901 v 0.45502 h 1.162034 v 0.45501 h -1.162034 v 3.2831 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path218" /> + <path + d="m 97.346245,118.19665 q -0.406012,0 -0.714021,-0.084 -0.301009,-0.084 -0.504015,-0.29401 -0.203006,-0.21001 -0.301009,-0.58102 -0.098,-0.37801 -0.098,-0.95903 0,-0.58802 0.098,-0.95903 0.098,-0.37801 0.301009,-0.58101 0.203006,-0.21001 0.504015,-0.28701 0.308009,-0.084 0.714021,-0.084 0.406012,0 0.707021,0.084 0.308009,0.084 0.511015,0.29401 0.203006,0.203 0.301009,0.58101 0.105003,0.37101 0.105003,0.95203 0,0.58802 -0.105003,0.96603 -0.098,0.37101 -0.301009,0.58102 -0.196006,0.203 -0.504015,0.28701 -0.308009,0.084 -0.714021,0.084 z m 0,-0.45501 q 0.259008,0 0.455013,-0.049 0.203006,-0.049 0.33601,-0.196 0.133004,-0.14701 0.203006,-0.44101 0.07,-0.29401 0.07,-0.77703 0,-0.49001 -0.07,-0.77702 -0.07,-0.28701 -0.203006,-0.43401 -0.133004,-0.14701 -0.33601,-0.19601 -0.196005,-0.049 -0.455013,-0.049 -0.266008,0 -0.462014,0.049 -0.196006,0.049 -0.32901,0.19601 -0.133004,0.147 -0.203006,0.43401 -0.07,0.28701 -0.07,0.77702 0,0.48302 0.07,0.77703 0.07,0.294 0.203006,0.44101 0.133004,0.147 0.32901,0.196 0.196006,0.049 0.462014,0.049 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path220" /> + <path + d="m 99.90132,118.15465 v -3.73811 h 0.42001 l 0.13301,0.65102 q 0.252,-0.30101 0.56001,-0.49702 0.31501,-0.196 0.72102,-0.196 0.098,0 0.18201,0.007 0.091,0.007 0.16801,0.028 v 0.56701 q -0.091,-0.014 -0.19601,-0.021 -0.105,-0.014 -0.21001,-0.014 -0.259,0 -0.46201,0.063 -0.20301,0.063 -0.38501,0.18901 -0.18201,0.126 -0.37801,0.30801 v 2.65308 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path222" /> + <path + d="m 102.7854,118.15465 v -3.73811 h 0.43401 l 0.119,0.41301 q 0.28001,-0.19601 0.62302,-0.33601 0.35001,-0.14001 0.77702,-0.14001 0.35001,0 0.59502,0.16101 0.25201,0.154 0.37801,0.39201 0.17501,-0.126 0.42001,-0.25201 0.25201,-0.126 0.53202,-0.21 0.28701,-0.091 0.55302,-0.091 0.40601,0 0.66502,0.17501 0.259,0.16801 0.37801,0.46201 0.126,0.28701 0.126,0.64402 v 2.52008 h -0.55301 v -2.47808 q 0,-0.238 -0.098,-0.42001 -0.091,-0.189 -0.26601,-0.29401 -0.175,-0.105 -0.42701,-0.105 -0.31501,0 -0.64402,0.119 -0.32901,0.11901 -0.60902,0.28701 0.035,0.10501 0.049,0.21001 0.021,0.098 0.021,0.21 v 2.47108 h -0.55302 v -2.47808 q 0,-0.238 -0.098,-0.42001 -0.091,-0.189 -0.26601,-0.29401 -0.16801,-0.105 -0.42001,-0.105 -0.23101,0 -0.43402,0.049 -0.196,0.042 -0.37801,0.12601 -0.175,0.077 -0.37101,0.189 v 2.93309 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path224" /> + <path + d="m 111.51464,118.15465 v -3.2831 h -0.57401 v -0.35701 l 0.57401,-0.098 v -0.42702 q 0,-0.39901 0.098,-0.66502 0.098,-0.273 0.32901,-0.41301 0.231,-0.14 0.64401,-0.14 0.24501,0 0.41302,0.028 0.175,0.021 0.30801,0.042 v 0.39201 h -0.62302 q -0.25201,0 -0.38501,0.084 -0.13301,0.077 -0.18201,0.24501 -0.049,0.16101 -0.049,0.39901 v 0.45502 h 1.16203 v 0.45501 h -1.16203 v 3.2831 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path226" /> + <path + d="m 115.37175,118.19665 q -0.40602,0 -0.71403,-0.084 -0.301,-0.084 -0.50401,-0.29401 -0.20301,-0.21001 -0.30101,-0.58102 -0.098,-0.37801 -0.098,-0.95903 0,-0.58802 0.098,-0.95903 0.098,-0.37801 0.30101,-0.58101 0.20301,-0.21001 0.50401,-0.28701 0.30801,-0.084 0.71403,-0.084 0.40601,0 0.70702,0.084 0.30801,0.084 0.51101,0.29401 0.20301,0.203 0.30101,0.58101 0.105,0.37101 0.105,0.95203 0,0.58802 -0.105,0.96603 -0.098,0.37101 -0.30101,0.58102 -0.196,0.203 -0.50401,0.28701 -0.30801,0.084 -0.71402,0.084 z m 0,-0.45501 q 0.259,0 0.45501,-0.049 0.20301,-0.049 0.33601,-0.196 0.133,-0.14701 0.20301,-0.44101 0.07,-0.29401 0.07,-0.77703 0,-0.49001 -0.07,-0.77702 -0.07,-0.28701 -0.20301,-0.43401 -0.133,-0.14701 -0.33601,-0.19601 -0.19601,-0.049 -0.45501,-0.049 -0.26601,0 -0.46202,0.049 -0.196,0.049 -0.32901,0.19601 -0.133,0.147 -0.203,0.43401 -0.07,0.28701 -0.07,0.77702 0,0.48302 0.07,0.77703 0.07,0.294 0.203,0.44101 0.13301,0.147 0.32901,0.196 0.19601,0.049 0.46202,0.049 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path228" /> + <path + d="m 117.92682,118.15465 v -3.73811 h 0.42001 l 0.133,0.65102 q 0.25201,-0.30101 0.56002,-0.49702 0.31501,-0.196 0.72102,-0.196 0.098,0 0.18201,0.007 0.091,0.007 0.168,0.028 v 0.56701 q -0.091,-0.014 -0.196,-0.021 -0.10501,-0.014 -0.21001,-0.014 -0.25901,0 -0.46201,0.063 -0.20301,0.063 -0.38501,0.18901 -0.18201,0.126 -0.37802,0.30801 v 2.65308 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path230" /> + <path + d="m 123.68097,118.16165 q -0.32201,0 -0.53901,-0.11201 -0.21701,-0.119 -0.32901,-0.37801 -0.11201,-0.266 -0.11201,-0.71402 v -4.14412 h 0.55302 v 4.07412 q 0,0.33601 0.063,0.51102 0.063,0.168 0.18201,0.238 0.119,0.063 0.28,0.084 l 0.37802,0.042 v 0.39901 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path232" /> + <path + d="m 126.24304,118.19665 q -0.40601,0 -0.71402,-0.084 -0.30101,-0.084 -0.50402,-0.29401 -0.203,-0.21001 -0.30101,-0.58102 -0.098,-0.37801 -0.098,-0.95903 0,-0.58802 0.098,-0.95903 0.098,-0.37801 0.30101,-0.58101 0.20301,-0.21001 0.50402,-0.28701 0.30801,-0.084 0.71402,-0.084 0.40601,0 0.70702,0.084 0.30801,0.084 0.51101,0.29401 0.20301,0.203 0.30101,0.58101 0.10501,0.37101 0.10501,0.95203 0,0.58802 -0.10501,0.96603 -0.098,0.37101 -0.30101,0.58102 -0.196,0.203 -0.50401,0.28701 -0.30801,0.084 -0.71402,0.084 z m 0,-0.45501 q 0.25901,0 0.45501,-0.049 0.20301,-0.049 0.33601,-0.196 0.13301,-0.14701 0.20301,-0.44101 0.07,-0.29401 0.07,-0.77703 0,-0.49001 -0.07,-0.77702 -0.07,-0.28701 -0.20301,-0.43401 -0.133,-0.14701 -0.33601,-0.19601 -0.196,-0.049 -0.45501,-0.049 -0.26601,0 -0.46202,0.049 -0.196,0.049 -0.32901,0.19601 -0.133,0.147 -0.203,0.43401 -0.07,0.28701 -0.07,0.77702 0,0.48302 0.07,0.77703 0.07,0.294 0.203,0.44101 0.13301,0.147 0.32901,0.196 0.19601,0.049 0.46202,0.049 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path234" /> + <path + d="m 130.15615,118.19665 q -0.31501,0 -0.58802,-0.084 -0.27301,-0.084 -0.47601,-0.29401 -0.20301,-0.21001 -0.31501,-0.58102 -0.112,-0.37101 -0.112,-0.94503 0,-0.58101 0.105,-0.95203 0.105,-0.37801 0.30101,-0.58801 0.196,-0.21001 0.47601,-0.29401 0.28001,-0.084 0.62302,-0.084 0.28001,0 0.60902,0.028 0.32901,0.021 0.63002,0.07 v 0.32901 h -1.09203 q -0.35702,0 -0.60202,0.119 -0.24501,0.11901 -0.37101,0.44802 -0.12601,0.322 -0.12601,0.93802 0,0.59502 0.13301,0.91003 0.133,0.31501 0.37801,0.43401 0.245,0.11201 0.59502,0.11201 h 1.14103 v 0.32201 q -0.16101,0.028 -0.37801,0.056 -0.21001,0.028 -0.44802,0.042 -0.238,0.014 -0.48301,0.014 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path236" /> + <path + d="m 132.95623,118.19665 q -0.41301,0 -0.67902,-0.24501 -0.26601,-0.24501 -0.26601,-0.69302 v -0.26601 q 0,-0.44101 0.28701,-0.71402 0.29401,-0.27301 0.88203,-0.27301 h 1.26704 v -0.46201 q 0,-0.23101 -0.084,-0.39201 -0.077,-0.16801 -0.28701,-0.25901 -0.203,-0.091 -0.59501,-0.091 h -1.16204 v -0.32901 q 0.24501,-0.042 0.56702,-0.077 0.32901,-0.035 0.77002,-0.035 0.45502,-0.007 0.75602,0.112 0.30101,0.119 0.44102,0.37801 0.147,0.25201 0.147,0.66502 v 2.63908 h -0.42701 l -0.105,-0.42701 q -0.028,0.028 -0.17501,0.098 -0.14,0.07 -0.36401,0.161 -0.21701,0.084 -0.47601,0.14701 -0.25201,0.063 -0.49702,0.063 z m 0.23101,-0.41301 q 0.126,0.007 0.28701,-0.028 0.161,-0.035 0.32901,-0.077 0.168,-0.049 0.30801,-0.098 0.147,-0.049 0.238,-0.084 0.091,-0.035 0.098,-0.035 v -1.17604 l -1.14104,0.049 q -0.39901,0.014 -0.57402,0.19601 -0.168,0.182 -0.168,0.46901 v 0.16801 q 0,0.22401 0.091,0.36401 0.098,0.133 0.23801,0.189 0.147,0.056 0.29401,0.063 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path238" /> + <path + d="m 137.02334,118.16165 q -0.32201,0 -0.53902,-0.11201 -0.217,-0.119 -0.32901,-0.37801 -0.112,-0.266 -0.112,-0.71402 v -4.14412 h 0.55302 v 4.07412 q 0,0.33601 0.063,0.51102 0.063,0.168 0.182,0.238 0.11901,0.063 0.28001,0.084 l 0.37801,0.042 v 0.39901 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path240" /> + <path + d="m 43.059783,126.98762 q -0.315009,0 -0.588017,-0.084 -0.273008,-0.084 -0.476014,-0.294 -0.203006,-0.21001 -0.315009,-0.58102 -0.112004,-0.37101 -0.112004,-0.94503 0,-0.58102 0.105003,-0.95203 0.105003,-0.37801 0.301009,-0.58802 0.196006,-0.21 0.476014,-0.294 0.280009,-0.084 0.623019,-0.084 0.280008,0 0.609018,0.028 0.32901,0.021 0.630019,0.07 v 0.32901 h -1.092033 q -0.35701,0 -0.602018,0.119 -0.245007,0.119 -0.371011,0.44801 -0.126003,0.32201 -0.126003,0.93803 0,0.59502 0.133004,0.91003 0.133004,0.31501 0.378011,0.43401 0.245007,0.112 0.595017,0.112 h 1.141034 v 0.32201 q -0.161004,0.028 -0.378011,0.056 -0.210006,0.028 -0.448013,0.042 -0.238007,0.014 -0.483015,0.014 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path242" /> + <path + d="m 46.111872,126.95262 q -0.322009,0 -0.539016,-0.11201 -0.217006,-0.119 -0.329009,-0.37801 -0.112004,-0.26601 -0.112004,-0.71402 v -4.14412 h 0.553017 v 4.07412 q 0,0.33601 0.063,0.51101 0.063,0.16801 0.182005,0.23801 0.119004,0.063 0.280008,0.084 l 0.378012,0.042 v 0.39902 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path244" /> + <path + d="m 48.67394,126.98762 q -0.406012,0 -0.714022,-0.084 -0.301008,-0.084 -0.504014,-0.294 -0.203006,-0.21001 -0.301009,-0.58102 -0.098,-0.37801 -0.098,-0.95903 0,-0.58802 0.098,-0.95903 0.098,-0.37801 0.301009,-0.58102 0.203006,-0.21 0.504014,-0.287 0.30801,-0.084 0.714022,-0.084 0.406012,0 0.707021,0.084 0.308009,0.084 0.511015,0.294 0.203006,0.20301 0.301009,0.58102 0.105003,0.37101 0.105003,0.95203 0,0.58802 -0.105003,0.96603 -0.098,0.37101 -0.301009,0.58102 -0.196006,0.203 -0.504015,0.287 -0.308009,0.084 -0.714021,0.084 z m 0,-0.45502 q 0.259007,0 0.455013,-0.049 0.203006,-0.049 0.33601,-0.196 0.133004,-0.14701 0.203006,-0.44102 0.07,-0.29401 0.07,-0.77702 0,-0.49001 -0.07,-0.77702 -0.07,-0.28701 -0.203006,-0.43402 -0.133004,-0.147 -0.33601,-0.196 -0.196006,-0.049 -0.455013,-0.049 -0.266008,0 -0.462014,0.049 -0.196006,0.049 -0.32901,0.196 -0.133004,0.14701 -0.203006,0.43402 -0.07,0.28701 -0.07,0.77702 0,0.48301 0.07,0.77702 0.07,0.29401 0.203006,0.44102 0.133004,0.147 0.32901,0.196 0.196006,0.049 0.462014,0.049 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path246" /> + <path + d="m 52.342046,126.98762 q -0.539016,0 -0.833024,-0.28001 -0.287009,-0.28001 -0.287009,-0.86803 v -2.63208 h 0.553017 v 2.49208 q 0,0.46901 0.210006,0.63702 0.217006,0.161 0.602018,0.161 0.32901,0 0.602018,-0.091 0.273008,-0.098 0.560016,-0.26601 v -2.93309 h 0.553017 v 3.73812 h -0.434013 l -0.119004,-0.41302 q -0.287008,0.18201 -0.651019,0.32201 -0.357011,0.13301 -0.756023,0.13301 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path248" /> + <path + d="m 56.556167,126.98762 q -0.462014,0 -0.763023,-0.19601 -0.294009,-0.203 -0.441013,-0.61602 -0.140004,-0.42001 -0.140004,-1.07103 0,-0.69302 0.140004,-1.12003 0.140004,-0.43401 0.448013,-0.63002 0.30801,-0.19601 0.826025,-0.19601 0.315009,0 0.616018,0.07 0.301009,0.07 0.532016,0.154 v -1.77805 h 0.553016 v 5.34116 h -0.448013 l -0.105003,-0.37802 q -0.105003,0.091 -0.315009,0.18901 -0.203006,0.098 -0.448014,0.16801 -0.238007,0.063 -0.455013,0.063 z m 0.161005,-0.45502 q 0.322009,0 0.581017,-0.098 0.266008,-0.105 0.476014,-0.21701 v -2.44307 q -0.252007,-0.077 -0.483014,-0.126 -0.231007,-0.056 -0.518016,-0.056 -0.35701,0 -0.581017,0.133 -0.224007,0.133 -0.32901,0.46201 -0.098,0.32201 -0.098,0.89603 0,0.51802 0.091,0.84003 0.098,0.32201 0.308009,0.46901 0.210006,0.14 0.553017,0.14 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path250" /> + <path + d="m 62.471325,126.98762 q -0.462014,0 -0.763023,-0.19601 -0.294008,-0.203 -0.441013,-0.61602 -0.140004,-0.42001 -0.140004,-1.07103 0,-0.69302 0.140004,-1.12003 0.140005,-0.43401 0.448014,-0.63002 0.308009,-0.19601 0.826024,-0.19601 0.31501,0 0.616019,0.07 0.301008,0.07 0.532015,0.154 v -1.77805 h 0.553017 v 5.34116 h -0.448014 l -0.105003,-0.37802 q -0.105003,0.091 -0.315009,0.18901 -0.203006,0.098 -0.448013,0.16801 -0.238007,0.063 -0.455014,0.063 z m 0.161005,-0.45502 q 0.32201,0 0.581017,-0.098 0.266008,-0.105 0.476014,-0.21701 v -2.44307 q -0.252007,-0.077 -0.483014,-0.126 -0.231007,-0.056 -0.518015,-0.056 -0.357011,0 -0.581018,0.133 -0.224006,0.133 -0.329009,0.46201 -0.098,0.32201 -0.098,0.89603 0,0.51802 0.091,0.84003 0.098,0.32201 0.30801,0.46901 0.210006,0.14 0.553016,0.14 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path252" /> + <path + d="m 66.713442,126.98762 q -0.588018,0 -0.931028,-0.17501 -0.33601,-0.175 -0.483014,-0.59502 -0.147005,-0.42001 -0.147005,-1.14803 0,-0.74202 0.147005,-1.15503 0.154004,-0.42002 0.497015,-0.58802 0.34301,-0.16801 0.924027,-0.16801 0.497015,0 0.805024,0.11201 0.315009,0.112 0.462014,0.38501 0.154004,0.26601 0.154004,0.74202 0,0.32901 -0.133004,0.53202 -0.126003,0.196 -0.371011,0.287 -0.238007,0.084 -0.567017,0.084 h -1.36504 q 0.007,0.45501 0.098,0.73502 0.091,0.27301 0.32901,0.39901 0.245007,0.119 0.714021,0.119 h 1.204036 v 0.33601 q -0.32201,0.042 -0.630019,0.07 -0.308009,0.028 -0.707021,0.028 z m -1.01503,-2.05106 h 1.330039 q 0.31501,0 0.462014,-0.11201 0.154004,-0.119 0.154004,-0.42001 0,-0.32201 -0.098,-0.49701 -0.091,-0.18201 -0.294009,-0.25201 -0.196006,-0.077 -0.532016,-0.077 -0.392012,0 -0.616018,0.119 -0.224007,0.112 -0.31501,0.40601 -0.091,0.29401 -0.091,0.83303 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path254" /> + <path + d="m 70.010528,126.94562 -1.407042,-3.73812 h 0.567017 l 1.162034,3.2201 1.197036,-3.2201 h 0.560016 l -1.449043,3.73812 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path256" /> + <path + d="m 74.042639,126.98762 q -0.588018,0 -0.931028,-0.17501 -0.33601,-0.175 -0.483014,-0.59502 -0.147005,-0.42001 -0.147005,-1.14803 0,-0.74202 0.147005,-1.15503 0.154004,-0.42002 0.497015,-0.58802 0.34301,-0.16801 0.924027,-0.16801 0.497015,0 0.805024,0.11201 0.315009,0.112 0.462014,0.38501 0.154004,0.26601 0.154004,0.74202 0,0.32901 -0.133004,0.53202 -0.126004,0.196 -0.371011,0.287 -0.238007,0.084 -0.567017,0.084 h -1.36504 q 0.007,0.45501 0.098,0.73502 0.091,0.27301 0.32901,0.39901 0.245007,0.119 0.714021,0.119 h 1.204035 v 0.33601 q -0.322009,0.042 -0.630018,0.07 -0.308009,0.028 -0.707021,0.028 z m -1.01503,-2.05106 h 1.330039 q 0.315009,0 0.462014,-0.11201 0.154004,-0.119 0.154004,-0.42001 0,-0.32201 -0.098,-0.49701 -0.091,-0.18201 -0.294009,-0.25201 -0.196006,-0.077 -0.532016,-0.077 -0.392012,0 -0.616018,0.119 -0.224007,0.112 -0.31501,0.40601 -0.091,0.29401 -0.091,0.83303 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path258" /> + <path + d="m 77.381727,126.95262 q -0.322009,0 -0.539016,-0.11201 -0.217006,-0.119 -0.32901,-0.37801 -0.112003,-0.26601 -0.112003,-0.71402 v -4.14412 h 0.553017 v 4.07412 q 0,0.33601 0.063,0.51101 0.063,0.16801 0.182006,0.23801 0.119003,0.063 0.280008,0.084 l 0.378011,0.042 v 0.39902 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path260" /> + <path + d="m 79.943794,126.98762 q -0.406012,0 -0.714021,-0.084 -0.301009,-0.084 -0.504015,-0.294 -0.203006,-0.21001 -0.301009,-0.58102 -0.098,-0.37801 -0.098,-0.95903 0,-0.58802 0.098,-0.95903 0.098,-0.37801 0.301009,-0.58102 0.203006,-0.21 0.504015,-0.287 0.308009,-0.084 0.714021,-0.084 0.406012,0 0.707021,0.084 0.308009,0.084 0.511015,0.294 0.203006,0.20301 0.301009,0.58102 0.105003,0.37101 0.105003,0.95203 0,0.58802 -0.105003,0.96603 -0.098,0.37101 -0.301009,0.58102 -0.196005,0.203 -0.504015,0.287 -0.308009,0.084 -0.714021,0.084 z m 0,-0.45502 q 0.259008,0 0.455014,-0.049 0.203006,-0.049 0.33601,-0.196 0.133004,-0.14701 0.203006,-0.44102 0.07,-0.29401 0.07,-0.77702 0,-0.49001 -0.07,-0.77702 -0.07,-0.28701 -0.203006,-0.43402 -0.133004,-0.147 -0.33601,-0.196 -0.196006,-0.049 -0.455014,-0.049 -0.266008,0 -0.462013,0.049 -0.196006,0.049 -0.32901,0.196 -0.133004,0.14701 -0.203006,0.43402 -0.07,0.28701 -0.07,0.77702 0,0.48301 0.07,0.77702 0.07,0.29401 0.203006,0.44102 0.133004,0.147 0.32901,0.196 0.196005,0.049 0.462013,0.049 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path262" /> + <path + d="m 82.49887,128.60467 v -5.39717 h 0.448013 l 0.105003,0.38502 q 0.126004,-0.091 0.32201,-0.18901 0.203006,-0.105 0.434013,-0.175 0.238007,-0.07 0.469014,-0.07 0.364011,0 0.616018,0.16101 0.259008,0.154 0.420012,0.42001 0.161005,0.26601 0.231007,0.60902 0.07,0.34301 0.07,0.72102 0,0.63002 -0.147004,1.06403 -0.140004,0.42701 -0.434013,0.64402 -0.294009,0.21701 -0.756022,0.21701 -0.31501,0 -0.637019,-0.098 -0.31501,-0.105 -0.588018,-0.259 v 1.96706 z m 1.624048,-2.07907 q 0.266008,0 0.476014,-0.133 0.210007,-0.133 0.33601,-0.44801 0.126004,-0.32201 0.126004,-0.87503 0,-0.52502 -0.133004,-0.84002 -0.133004,-0.32201 -0.34301,-0.46202 -0.210006,-0.147 -0.462014,-0.147 -0.322009,0 -0.595018,0.105 -0.266007,0.098 -0.476014,0.21001 v 2.28906 q 0.245008,0.13301 0.518016,0.21701 0.280008,0.084 0.553016,0.084 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path264" /> + <path + d="m 86.544982,126.94562 v -3.73812 h 0.434013 l 0.119003,0.41302 q 0.280009,-0.19601 0.623019,-0.33601 0.35001,-0.14001 0.777023,-0.14001 0.35001,0 0.595018,0.16101 0.252007,0.154 0.378011,0.39201 0.175005,-0.126 0.420012,-0.25201 0.252008,-0.126 0.532016,-0.21 0.287009,-0.091 0.553017,-0.091 0.406012,0 0.665019,0.17501 0.259008,0.168 0.378012,0.46201 0.126003,0.28701 0.126003,0.64402 v 2.52008 h -0.553016 v -2.47808 q 0,-0.238 -0.098,-0.42001 -0.091,-0.18901 -0.266008,-0.29401 -0.175005,-0.105 -0.427013,-0.105 -0.315009,0 -0.644019,0.119 -0.32901,0.11901 -0.609018,0.28701 0.035,0.105 0.049,0.21001 0.021,0.098 0.021,0.21 v 2.47108 h -0.553016 v -2.47808 q 0,-0.238 -0.098,-0.42001 -0.091,-0.18901 -0.266008,-0.29401 -0.168005,-0.105 -0.420012,-0.105 -0.231007,0 -0.434013,0.049 -0.196006,0.042 -0.378012,0.126 -0.175005,0.077 -0.371011,0.18901 v 2.93309 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path266" /> + <path + d="m 94.609221,126.98762 q -0.588018,0 -0.931028,-0.17501 -0.33601,-0.175 -0.483014,-0.59502 -0.147005,-0.42001 -0.147005,-1.14803 0,-0.74202 0.147005,-1.15503 0.154004,-0.42002 0.497014,-0.58802 0.34301,-0.16801 0.924028,-0.16801 0.497014,0 0.805024,0.11201 0.315009,0.112 0.462013,0.38501 0.154005,0.26601 0.154005,0.74202 0,0.32901 -0.133004,0.53202 -0.126004,0.196 -0.371011,0.287 -0.238007,0.084 -0.567017,0.084 h -1.36504 q 0.007,0.45501 0.098,0.73502 0.091,0.27301 0.32901,0.39901 0.245008,0.119 0.714021,0.119 h 1.204036 v 0.33601 q -0.322009,0.042 -0.630018,0.07 -0.30801,0.028 -0.707021,0.028 z m -1.015031,-2.05106 h 1.33004 q 0.315009,0 0.462014,-0.11201 0.154004,-0.119 0.154004,-0.42001 0,-0.32201 -0.098,-0.49701 -0.091,-0.18201 -0.294008,-0.25201 -0.196006,-0.077 -0.532016,-0.077 -0.392012,0 -0.616019,0.119 -0.224006,0.112 -0.315009,0.40601 -0.091,0.29401 -0.091,0.83303 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path268" /> + <path + d="m 96.96828,126.94562 v -3.73812 h 0.434013 l 0.119003,0.41302 q 0.266008,-0.18901 0.623019,-0.32901 0.35701,-0.14701 0.742022,-0.14701 0.413012,0 0.66502,0.17501 0.252007,0.175 0.371011,0.46201 0.119002,0.28701 0.119002,0.62302 v 2.54108 h -0.553015 v -2.47808 q 0,-0.238 -0.098,-0.42001 -0.091,-0.18901 -0.266008,-0.29401 -0.168005,-0.105 -0.420013,-0.105 -0.231006,0 -0.434012,0.049 -0.196006,0.042 -0.378012,0.126 -0.175005,0.077 -0.371011,0.18901 v 2.93309 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path270" /> + <path + d="m 102.32344,126.94562 q -0.36401,0 -0.60202,-0.11901 -0.23801,-0.119 -0.35001,-0.38501 -0.11201,-0.26601 -0.10501,-0.70702 l 0.021,-2.07206 h -0.59502 v -0.35701 l 0.60202,-0.098 0.091,-1.04303 h 0.42701 v 1.04303 h 1.07803 v 0.45502 h -1.07803 v 2.07206 q 0,0.24501 0.049,0.39201 0.056,0.14701 0.14001,0.22401 0.091,0.077 0.189,0.112 0.098,0.028 0.18901,0.035 l 0.45501,0.042 v 0.40602 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:Exo;-inkscape-font-specification:Exo;fill:#ffffff;stroke-width:0.17500518" + id="path272" /> + </g> + <path + id="circle338" + d="m 138.72855,142.72411 a 5.8937802,5.8937802 0 0 0 -5.89341,5.89341 5.8937802,5.8937802 0 0 0 0.54115,2.45066 h 10.70595 a 5.8937802,5.8937802 0 0 0 0.54044,-2.45066 5.8937802,5.8937802 0 0 0 -5.89413,-5.89341 z" + style="fill:url(#linearGradient782);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="path315" + d="m 110.97368,136.36774 c 0,0 -0.27039,7.63384 -3.62033,10.56505 -3.34996,2.9312 -5.5086,-0.38675 -7.40905,-2.06171 -1.900446,-1.67499 -3.446282,-0.96604 -3.446282,-0.96604 v 7.16314 h 28.951322 v -7.16314 c 0,0 -1.54583,-0.70895 -3.44629,0.96604 -1.90044,1.67496 -4.05911,4.99291 -7.40904,2.06171 -3.34996,-2.93121 -3.62033,-10.56505 -3.62033,-10.56505 z" + style="fill:url(#linearGradient784);fill-opacity:1;stroke:none;stroke-width:0.36076409px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + inkscape:connector-curvature="0" /> + <path + id="circle293" + d="m 83.518261,142.72411 a 5.8937802,5.8937802 0 0 0 -5.894125,5.89341 5.8937802,5.8937802 0 0 0 0.541854,2.45066 h 10.705956 a 5.8937802,5.8937802 0 0 0 0.539735,-2.45066 5.8937802,5.8937802 0 0 0 -5.89342,-5.89341 z" + style="fill:url(#linearGradient786);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="circle285" + d="m 10.046401,142.48243 a 5.8937802,5.8937802 0 0 0 -5.8941239,5.89412 5.8937802,5.8937802 0 0 0 0.6517697,2.69163 H 15.283116 a 5.8937802,5.8937802 0 0 0 0.656705,-2.69163 5.8937802,5.8937802 0 0 0 -5.89342,-5.89412 z" + style="fill:url(#linearGradient788);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="circle259" + d="m 33.045111,145.63911 a 5.8937802,5.8937802 0 0 0 -5.870871,5.42907 h 11.743856 a 5.8937802,5.8937802 0 0 0 -5.872985,-5.42907 z" + style="fill:url(#linearGradient790);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="circle246" + d="m 25.847445,144.54625 a 5.8937802,5.8937802 0 0 0 -5.894125,5.89342 5.8937802,5.8937802 0 0 0 0.04087,0.62851 h 11.711444 a 5.8937802,5.8937802 0 0 0 0.03523,-0.62851 5.8937802,5.8937802 0 0 0 -5.893419,-5.89342 z" + style="fill:url(#linearGradient792);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="circle214" + d="m 19.030272,140.61941 a 8.9546806,8.9546806 0 0 0 -8.954278,8.95428 8.9546806,8.9546806 0 0 0 0.126126,1.49449 h 17.647141 a 8.9546806,8.9546806 0 0 0 0.135998,-1.49449 8.9546806,8.9546806 0 0 0 -8.954983,-8.95428 z" + style="fill:url(#linearGradient794);fill-opacity:1;stroke:none;stroke-width:0.28492162" + inkscape:connector-curvature="0" /> + <path + id="circle287" + d="m 89.973261,140.62716 a 8.2405272,8.2405272 0 0 0 -8.240498,8.2405 8.2405272,8.2405272 0 0 0 0.29946,2.20052 h 15.873619 a 8.2405272,8.2405272 0 0 0 0.307921,-2.20052 8.2405272,8.2405272 0 0 0 -8.240502,-8.2405 z" + style="fill:url(#linearGradient798);fill-opacity:1;stroke:none;stroke-width:0.26219857" + inkscape:connector-curvature="0" /> + <path + id="circle303" + d="m 131.71903,140.62716 a 8.2405272,8.2405272 0 0 0 -8.2405,8.2405 8.2405272,8.2405272 0 0 0 0.29945,2.20052 h 15.87292 a 8.2405272,8.2405272 0 0 0 0.30862,-2.20052 8.2405272,8.2405272 0 0 0 -8.24049,-8.2405 z" + style="fill:url(#linearGradient800);fill-opacity:1;stroke:none;stroke-width:0.26219857" + inkscape:connector-curvature="0" /> + <path + id="circle330" + d="m 146.18482,142.16252 a 5.8937802,5.8937802 0 0 0 -4.96826,5.81732 5.8937802,5.8937802 0 0 0 0.87865,3.08834 h 4.08961 z" + style="fill:url(#linearGradient802);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="circle277" + d="m 1.01865,144.86361 c -1.33084524,8e-5 -2.622488,0.45058 -3.6647146,1.27817 v 4.9264 h 9.391844 c 0.1083315,-0.44621 0.1641335,0.14872 0.1662905,-0.31044 1.896e-4,-3.25505 -2.6383811,-5.89393 -5.8934199,-5.89413 z" + style="fill:url(#linearGradient804);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccc" /> + <path + id="circle261" + d="m 70.950001,144.69704 a 7.2466456,7.2466456 0 0 0 -7.187095,6.37114 h 14.371377 a 7.2466456,7.2466456 0 0 0 -7.184282,-6.37114 z" + style="fill:url(#linearGradient806);fill-opacity:1;stroke:none;stroke-width:0.23057507" + inkscape:connector-curvature="0" /> + <path + id="circle322" + d="m 142.73713,144.91054 a 5.8937802,5.8937802 0 0 0 -5.89343,5.89341 5.8937802,5.8937802 0 0 0 0.01,0.26423 h 9.33195 v -5.03731 a 5.8937802,5.8937802 0 0 0 -3.44769,-1.12033 z" + style="fill:url(#linearGradient814);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="circle332" + d="m 146.18482,144.16928 a 5.5734916,5.5734916 0 0 0 -4.64766,5.4953 5.5734916,5.5734916 0 0 0 0.1832,1.4036 h 4.46446 z" + style="fill:url(#linearGradient745);fill-opacity:1;stroke:none;stroke-width:0.17733836" + inkscape:connector-curvature="0" /> + <path + id="circle279" + d="m 1.1780861,145.81666 a 5.5734916,5.5734916 0 0 0 -3.6647146,1.38317 v 3.86835 h 9.2269647 a 5.5734916,5.5734916 0 0 0 -5.5622501,-5.25152 z" + style="fill:url(#linearGradient747);fill-opacity:1;stroke:none;stroke-width:0.17733836" + inkscape:connector-curvature="0" /> + <path + id="circle271" + d="m 8.9084441,146.7827 a 5.5734916,5.5734916 0 0 0 -5.4163946,4.28548 H 14.324837 A 5.5734916,5.5734916 0 0 0 8.9084441,146.7827 Z" + style="fill:url(#linearGradient749);fill-opacity:1;stroke:none;stroke-width:0.17733836" + inkscape:connector-curvature="0" /> + <path + id="circle263" + d="m 70.950001,147.16179 a 6.8528376,6.8528376 0 0 0 -6.178085,3.90639 h 12.358285 a 6.8528376,6.8528376 0 0 0 -6.1802,-3.90639 z" + style="fill:url(#linearGradient751);fill-opacity:1;stroke:none;stroke-width:0.2180448" + inkscape:connector-curvature="0" /> + <g + id="g400" + transform="matrix(1.3635178,0,0,1.3635178,-32.872577,69.915953)"> + <path + style="fill:url(#linearGradient753);fill-opacity:1;stroke:none;stroke-width:0.65909088" + d="m 59.822266,199.2168 a 20.714287,20.714287 0 0 0 -20.175782,16.10547 h 40.34375 A 20.714287,20.714287 0 0 0 59.822266,199.2168 Z" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + id="path212" + inkscape:connector-curvature="0" /> + </g> + <g + id="g397" + transform="matrix(1.3635178,0,0,1.3635178,-32.872577,69.915953)"> + <path + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + style="fill:url(#linearGradient796);fill-opacity:1;stroke:none;stroke-width:0.72678679" + d="m 124.46484,184.95117 a 22.841872,22.841872 0 0 0 -22.84179,22.8418 22.841872,22.841872 0 0 0 1.30273,7.5293 h 43.07617 a 22.841872,22.841872 0 0 0 1.30469,-7.5293 22.841872,22.841872 0 0 0 -22.8418,-22.8418 z" + id="circle220" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient755);fill-opacity:1;stroke:none;stroke-width:0.60652709" + d="m 124.62891,196.7832 a 19.062281,19.062281 0 0 0 -19.03711,18.53907 h 38.08008 A 19.062281,19.062281 0 0 0 124.62891,196.7832 Z" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + id="circle222" + inkscape:connector-curvature="0" /> + </g> + <g + id="g389" + transform="matrix(1.3635178,0,0,1.3635178,-32.872577,69.915953)"> + <path + style="fill:url(#linearGradient808);fill-opacity:1;stroke:none;stroke-width:0.63912976" + d="m 174.26367,198.16602 a 20.086937,20.086937 0 0 0 -19.8457,17.15625 h 39.69922 a 20.086937,20.086937 0 0 0 -19.85352,-17.15625 z" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + id="circle226" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient757);fill-opacity:1;stroke:none;stroke-width:0.60439718" + d="m 174.26367,205 a 18.995343,18.995343 0 0 0 -16.8789,10.32227 h 33.77539 A 18.995343,18.995343 0 0 0 174.26367,205 Z" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + id="circle228" + inkscape:connector-curvature="0" /> + </g> + <path + id="circle289" + d="m 90.032449,144.89573 a 6.8769865,6.8769865 0 0 0 -6.839721,6.17245 h 13.673101 a 6.8769865,6.8769865 0 0 0 -6.83338,-6.17245 z" + style="fill:url(#linearGradient761);fill-opacity:1;stroke:none;stroke-width:0.21881318" + inkscape:connector-curvature="0" /> + <path + id="circle324" + d="m 142.73713,146.91517 a 5.5734916,5.5734916 0 0 0 -5.38188,4.15301 h 8.82957 v -2.95093 a 5.5734916,5.5734916 0 0 0 -3.44769,-1.20208 z" + style="fill:url(#linearGradient765);fill-opacity:1;stroke:none;stroke-width:0.17733836" + inkscape:connector-curvature="0" /> + <path + id="circle305" + d="m 131.77821,144.89573 a 6.8769865,6.8769865 0 0 0 -6.83972,6.17245 h 13.6731 a 6.8769865,6.8769865 0 0 0 -6.83338,-6.17245 z" + style="fill:url(#linearGradient767);fill-opacity:1;stroke:none;stroke-width:0.21881318" + inkscape:connector-curvature="0" /> + <path + id="circle295" + d="m 77.231665,145.73071 a 5.8937802,5.8937802 0 0 0 -5.865938,5.33747 h 11.727651 a 5.8937802,5.8937802 0 0 0 -5.861713,-5.33747 z" + style="fill:url(#linearGradient812);fill-opacity:1;stroke:none;stroke-width:0.18752934" + inkscape:connector-curvature="0" /> + <path + id="circle297" + d="m 77.231665,147.73534 a 5.5734916,5.5734916 0 0 0 -5.100021,3.33284 h 10.195813 a 5.5734916,5.5734916 0 0 0 -5.095792,-3.33284 z" + style="fill:url(#linearGradient763);fill-opacity:1;stroke:none;stroke-width:0.17733836" + inkscape:connector-curvature="0" /> + <g + id="g393" + transform="matrix(1.3635178,0,0,1.3635178,-32.872577,69.915953)"> + <path + style="fill:url(#linearGradient810);fill-opacity:1;stroke:none;stroke-width:0.51981157" + d="m 157.83594,204.31445 a 16.336937,16.336937 0 0 0 -15.42383,11.00782 h 30.84766 a 16.336937,16.336937 0 0 0 -15.42383,-11.00782 z" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + id="circle236" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient759);fill-opacity:1;stroke:none;stroke-width:0.49156323" + d="m 157.83594,209.87305 a 15.449131,15.449131 0 0 0 -11.77344,5.44922 h 23.52734 a 15.449131,15.449131 0 0 0 -11.7539,-5.44922 z" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + id="circle238" + inkscape:connector-curvature="0" /> + </g> + <g + id="g4974" + transform="matrix(1.3635178,0,0,1.3635178,-77.291655,69.915953)"> + <g + transform="translate(-0.08769782)" + id="g4950"> + <g + id="g4928" + transform="translate(0.13001108)"> + <path + style="fill:url(#linearGradient4923);fill-opacity:1;stroke:none;stroke-width:0.05;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 137.2861,45.807795 0.11383,-0.09355 0.19408,0.01167 0.0776,0.105269 0.22742,1.834053 -0.14925,0.02988 -0.0417,0.918068 -0.11471,0.07299 -0.10345,-0.868328 -0.0244,0.742551 -0.1562,-0.102421 0.0157,-0.921492 -0.051,0.322096 -0.0991,-0.309866 z" + id="path4883" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccccc" /> + <path + sodipodi:nodetypes="cccccccccc" + inkscape:connector-curvature="0" + id="rect4878" + d="m 137.2861,45.807795 0.11383,-0.09355 0.19408,0.01167 0.0776,0.105269 0.0314,1.419601 -0.0786,0.415051 -0.10641,-0.187817 -0.12115,0.205165 -0.13998,-0.339098 z" + style="fill:url(#linearGradient4907);fill-opacity:1;stroke:none;stroke-width:0.05;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + <path + sodipodi:nodetypes="ccccc" + inkscape:connector-curvature="0" + id="rect4873" + d="m 137.39047,45.324585 h 0.17983 l 0.0413,0.42476 h -0.26252 z" + style="fill:#4d4d4d;fill-opacity:1;stroke:#333333;stroke-width:0.05;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + </g> + <g + id="g4936" + transform="translate(1.0011427)"> + <path + sodipodi:nodetypes="ccccccccccccccc" + inkscape:connector-curvature="0" + id="path4930" + d="m 137.2861,45.807795 0.11383,-0.09355 0.19408,0.01167 0.0776,0.105269 0.22742,1.834053 -0.14925,0.02988 -0.0417,0.918068 -0.11471,0.07299 -0.10345,-0.868328 -0.0244,0.742551 -0.1562,-0.102421 0.0157,-0.921492 -0.051,0.322096 -0.0991,-0.309866 z" + style="fill:url(#linearGradient4938);fill-opacity:1;stroke:none;stroke-width:0.05;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + <path + style="fill:url(#linearGradient4940);fill-opacity:1;stroke:none;stroke-width:0.05;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 137.2861,45.807795 0.11383,-0.09355 0.19408,0.01167 0.0776,0.105269 0.0314,1.419601 -0.0786,0.415051 -0.10641,-0.187817 -0.12115,0.205165 -0.13998,-0.339098 z" + id="path4932" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccccccc" /> + <path + style="fill:#4d4d4d;fill-opacity:1;stroke:#333333;stroke-width:0.05;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 137.39047,45.324585 h 0.17983 l 0.0413,0.42476 h -0.26252 z" + id="path4934" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + </g> + </g> + <g + transform="translate(0.73435907)" + id="g4871"> + <path + sodipodi:nodetypes="cccccccc" + inkscape:connector-curvature="0" + id="path4855" + d="m 139.1134,46.621488 c 0,0 0.31833,-1.070737 0.15296,-1.984375 -0.16536,-0.91364 -0.70693,-0.971518 -0.70693,-0.971518 0,0 -0.0538,0.161232 -0.0703,0.289388 -0.0165,0.128159 -0.13763,0.366639 -0.13763,0.366639 0,0 0.38087,0.04977 0.50967,0.530465 0.12881,0.480708 0.25219,1.769401 0.25219,1.769401 z" + style="fill:url(#linearGradient4857);fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" /> + <path + style="fill:#f2f2f2;stroke:none;stroke-width:0.26863509px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 137.28585,38.286213 c 0,0 -1.39518,1.302983 -1.39518,3.800285 0,2.497299 0.68028,3.307807 0.68028,3.307807 h 0.7149 0.71436 c 0,0 0.68028,-0.810508 0.68028,-3.307807 0,-2.497302 -1.39464,-3.800285 -1.39464,-3.800285 z" + id="path121" + inkscape:connector-curvature="0" /> + <path + id="rect140" + d="m 136.3302,39.791913 c -0.0403,0.0981 -0.0793,0.200504 -0.11627,0.307994 h 2.14354 c -0.037,-0.107493 -0.076,-0.209892 -0.11627,-0.307994 z" + style="fill:#b50000;fill-opacity:1;stroke:none;stroke-width:0.44239438" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient271);fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 135.44759,46.621488 c 0,0 -0.31833,-1.070737 -0.15296,-1.984375 0.16536,-0.91364 0.70693,-0.971518 0.70693,-0.971518 0,0 0.0538,0.161232 0.0703,0.289388 0.0165,0.128159 0.13763,0.366639 0.13763,0.366639 0,0 -0.38087,0.04977 -0.50967,0.530465 -0.12881,0.480708 -0.25219,1.769401 -0.25219,1.769401 z" + id="path261" + inkscape:connector-curvature="0" + sodipodi:nodetypes="cccccccc" /> + <path + inkscape:connector-curvature="0" + style="fill:#cccccc;fill-opacity:1;stroke:none;stroke-width:0.99999994px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 430.59766,141.93555 c -0.70706,2.05435 -1.22266,4.56094 -1.22266,7.50781 0,9.43861 2.57227,12.50195 2.57227,12.50195 h 2.70117 2.70117 c 0,0 0.72354,-0.90907 1.4043,-3.13867 -9.88566,-5.70749 -8.15625,-16.87109 -8.15625,-16.87109 z" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + id="path281" /> + <path + id="rect136" + d="m 136.19946,44.563716 c 0.0357,0.116988 0.0698,0.217839 0.1049,0.307475 h 1.96216 c 0.0351,-0.08965 0.0697,-0.190458 0.10542,-0.307475 z" + style="fill:#b50000;fill-opacity:1;stroke:none;stroke-width:0.44239438" + inkscape:connector-curvature="0" /> + <path + id="rect138" + d="m 135.94056,43.176204 c 0.0101,0.107264 0.0215,0.209746 0.0341,0.307991 h 2.62154 c 0.0126,-0.09826 0.0245,-0.200713 0.0346,-0.307991 z" + style="fill:#b50000;fill-opacity:1;stroke:none;stroke-width:0.44239438" + inkscape:connector-curvature="0" /> + <path + style="fill:#cccccc;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 137.2857,38.286064 c 0,0 -0.5471,0.516906 -0.9555,1.505849 h 0.9679 c -0.13584,-0.50698 -0.16932,-1.010858 -0.012,-1.505832 z" + id="path286" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <g + transform="matrix(1.3389652,0,0,1.3389652,-46.487579,-14.067036)" + id="g4853"> + <circle + r="0.18758447" + cy="40.903839" + cx="137.25014" + id="path294" + style="fill:#ffffff;fill-opacity:1;stroke:#999999;stroke-width:0.15745319;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + <circle + style="fill:#ffffff;fill-opacity:1;stroke:#999999;stroke-width:0.15745319;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + id="circle4806" + cx="137.25014" + cy="41.581787" + r="0.18758447" /> + <circle + r="0.18758447" + cy="42.268513" + cx="137.25014" + id="circle4848" + style="fill:#ffffff;fill-opacity:1;stroke:#999999;stroke-width:0.15745319;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> + </g> + </g> + </g> + <path + style="fill:url(#linearGradient175);fill-opacity:1;stroke:none;stroke-width:0.23057507" + d="m -9.6709099,144.69704 a 7.2466456,7.2466456 0 0 0 -7.1870951,6.37114 h 14.3713765 a 7.2466456,7.2466456 0 0 0 -7.1842814,-6.37114 z" + id="path155" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient177);fill-opacity:1;stroke:none;stroke-width:0.2180448" + d="m -9.6709099,147.16179 a 6.8528376,6.8528376 0 0 0 -6.1780841,3.90639 h 12.3582832 a 6.8528376,6.8528376 0 0 0 -6.1801991,-3.90639 z" + id="path157" + inkscape:connector-curvature="0" /> + <g + id="g163" + transform="matrix(1.3635178,0,0,1.3635178,-113.49349,69.915953)"> + <path + id="path159" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + d="m 174.26367,198.16602 a 20.086937,20.086937 0 0 0 -19.8457,17.15625 h 39.69922 a 20.086937,20.086937 0 0 0 -19.85352,-17.15625 z" + style="fill:url(#linearGradient179);fill-opacity:1;stroke:none;stroke-width:0.63912976" + inkscape:connector-curvature="0" /> + <path + id="path161" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + d="m 174.26367,205 a 18.995343,18.995343 0 0 0 -16.8789,10.32227 h 33.77539 A 18.995343,18.995343 0 0 0 174.26367,205 Z" + style="fill:url(#linearGradient181);fill-opacity:1;stroke:none;stroke-width:0.60439718" + inkscape:connector-curvature="0" /> + </g> + <path + style="fill:url(#linearGradient183);fill-opacity:1;stroke:none;stroke-width:0.18752934" + d="m -3.3892459,145.73071 a 5.8937802,5.8937802 0 0 0 -5.8659369,5.33747 H 2.4724668 a 5.8937802,5.8937802 0 0 0 -5.8617127,-5.33747 z" + id="path165" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient185);fill-opacity:1;stroke:none;stroke-width:0.17733836" + d="m -3.3892459,147.73534 a 5.5734916,5.5734916 0 0 0 -5.1000203,3.33284 H 1.7065475 a 5.5734916,5.5734916 0 0 0 -5.0957934,-3.33284 z" + id="path167" + inkscape:connector-curvature="0" /> + <g + id="g173" + transform="matrix(1.3635178,0,0,1.3635178,-113.49349,69.915953)"> + <path + id="path169" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + d="m 157.83594,204.31445 a 16.336937,16.336937 0 0 0 -15.42383,11.00782 h 30.84766 a 16.336937,16.336937 0 0 0 -15.42383,-11.00782 z" + style="fill:url(#linearGradient187);fill-opacity:1;stroke:none;stroke-width:0.51981157" + inkscape:connector-curvature="0" /> + <path + id="path171" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + d="m 157.83594,209.87305 a 15.449131,15.449131 0 0 0 -11.77344,5.44922 h 23.52734 a 15.449131,15.449131 0 0 0 -11.7539,-5.44922 z" + style="fill:url(#linearGradient189);fill-opacity:1;stroke:none;stroke-width:0.49156323" + inkscape:connector-curvature="0" /> + </g> + <path + style="fill:url(#linearGradient225);fill-opacity:1;stroke:none;stroke-width:0.18752934" + d="m 155.3924,142.48269 a 5.8937802,5.8937802 0 0 1 5.89412,5.89412 5.8937802,5.8937802 0 0 1 -0.65178,2.69163 h -10.47907 a 5.8937802,5.8937802 0 0 1 -0.65669,-2.69163 5.8937802,5.8937802 0 0 1 5.89342,-5.89412 z" + id="path191" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient227);fill-opacity:1;stroke:none;stroke-width:0.28492162" + d="m 146.40852,140.61967 a 8.9546806,8.9546806 0 0 1 8.95428,8.95429 8.9546806,8.9546806 0 0 1 -0.12613,1.49448 h -17.64714 a 8.9546806,8.9546806 0 0 1 -0.13594,-1.49448 8.9546806,8.9546806 0 0 1 8.95499,-8.95429 z" + id="path193" + inkscape:connector-curvature="0" /> + <path + sodipodi:nodetypes="cccccc" + inkscape:connector-curvature="0" + style="fill:url(#linearGradient229);fill-opacity:1;stroke:none;stroke-width:0.18752934" + d="m 164.42014,144.86388 c 1.33085,8e-5 2.6225,0.45057 3.66471,1.27817 v 4.92639 h -9.39184 c -0.1084,-0.44621 -0.16412,0.14872 -0.16628,-0.31044 -1.9e-4,-3.25505 2.63838,-5.89393 5.89341,-5.89412 z" + id="path195" /> + <path + style="fill:url(#linearGradient231);fill-opacity:1;stroke:none;stroke-width:0.17733836" + d="m 164.26071,145.81694 a 5.5734916,5.5734916 0 0 1 3.66471,1.38316 v 3.86834 h -9.22697 a 5.5734916,5.5734916 0 0 1 5.56226,-5.2515 z" + id="path197" + inkscape:connector-curvature="0" /> + <path + style="fill:url(#linearGradient233);fill-opacity:1;stroke:none;stroke-width:0.17733836" + d="m 156.53035,146.78296 a 5.5734916,5.5734916 0 0 1 5.4164,4.28548 h -10.8328 a 5.5734916,5.5734916 0 0 1 5.4164,-4.28548 z" + id="path199" + inkscape:connector-curvature="0" /> + <g + id="g203" + transform="matrix(-1.3635178,0,0,1.3635178,198.31137,69.916216)"> + <path + id="path201" + transform="matrix(0.26458333,0,0,0.26458333,22.284966,2.5461261)" + d="m 59.822266,199.2168 a 20.714287,20.714287 0 0 0 -20.175782,16.10547 h 40.34375 A 20.714287,20.714287 0 0 0 59.822266,199.2168 Z" + style="fill:url(#linearGradient235);fill-opacity:1;stroke:none;stroke-width:0.65909088" + inkscape:connector-curvature="0" /> + </g> + <path + inkscape:connector-curvature="0" + id="path215" + d="m 168.82804,145.73097 a 5.8937802,5.8937802 0 0 1 5.86594,5.33747 h -11.72765 a 5.8937802,5.8937802 0 0 1 5.86171,-5.33747 z" + style="fill:url(#linearGradient245);fill-opacity:1;stroke:none;stroke-width:0.18752934" /> + <path + inkscape:connector-curvature="0" + id="path217" + d="m 168.82804,147.73561 a 5.5734916,5.5734916 0 0 1 5.10002,3.33283 h -10.19581 a 5.5734916,5.5734916 0 0 1 5.09579,-3.33283 z" + style="fill:url(#linearGradient247);fill-opacity:1;stroke:none;stroke-width:0.17733836" /> + <rect + style="fill:url(#linearGradient328);fill-opacity:1;stroke:none;stroke-width:0.14486974;stroke-opacity:1" + id="rect320" + width="95.690208" + height="0.61278474" + x="41.35746" + y="107.5946" /> + </g> +</svg> diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 0000000000000..ca9a027bf9088 --- /dev/null +++ b/docs/testing/README.md @@ -0,0 +1,427 @@ +# Testing in LocalStack + +- [Test Types](test-types/README.md) +- [Integration Tests](integration-tests/README.md) +- [Parity Testing](parity-testing/README.md) +- [Multi-account and Multi-region Testing](multi-account-region-testing/README.md) +- [Terraform Tests](terraform-tests/README.md) + +## Rules for stable tests + +Through experience, we encountered some guiding principles and rules when it comes to testing LocalStack. +These aim to ensure a stable pipeline, keeping flakes minimal and reducing maintenance effort. +Any newly added test and feature should keep these in mind! + +| **ID** | **Rule** | +|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [R01](#r01) | Inform code owners and/or test authors about flaky tests by creating a PR skipping them (reason: flaky), so that they can be fixed ASAP. | +| [R02](#r02) | Do not assume external dependencies are indefinitely available on the same location. They can move and we need to adapt in the future for it. | +| [R03](#r03) | Where possible, tests should be in control of the resources they use and re-create them if removed. | +| [R04](#r04) | If on-demand creation is not possible, opt for a fail-fast approach and make retrieval failures clearly visible for further investigation. | +| [R05](#r05) | Add mechanisms to avoid access failures caused by rate limiting. | +| [R06](#r06) | Do not wait a set amount of time but instead opt for a reactive approach using notification systems or polling for asynchronous (long-lasting) operations | +| [R07](#r07) | For tests with multiple steps, handle waits separately and start each wait in the correct state. | +| [R08](#r08) | Ensure features interacting with account numbers work with arbitrary account numbers and multiple accounts simultaneously. | +| [R09](#r09) | Make sure that your tests are idempotent and could theoretically run in parallel, by using randomized IDs and not re-using IDs across tests. | +| [R10](#r10) | Ensure deterministic responses for anything that reaches an assertion or a snapshot match. | +| [R11](#r11) | Be vigilant about changes happening to dependencies that can affect stability of your added features and tests. | +| [R12](#r12) | Ensure all dependencies are available and functional on both AMD64 and ARM64 architectures. If a dependency is exclusive to one architecture, mark the corresponding test accordingly. | +| [R13](#r13) | After the test run, make sure that the created resources are cleaned up properly. | +| [R14](#r14) | Utilize fixture scopes for ensuring created resources exist as long as they should. | + + +### R01 + +Inform code owners and/or test authors about flaky tests by creating a PR skipping them (reason: flaky), so that they can be fixed ASAP. +This way, the flaky tests are not blocking the pipeline and can be fixed in a separate PR. +We also set the test author and/or service owner as reviewer to ensure that the test is fixed in a timely manner. + +#### Anti-pattern + +1. Noticing a flake +2. Ignoring it + +#### Best practice + +1. Noticing a flake +2. Creating a new PR skipping the test and marking it as flaky + +```python + +@pytest.mark.skip(reason="flaky") +def test_xyz(): + pass +``` + +3. Setting test author and/or service owner as reviewer + +### R02 + +Do not assume external dependencies (AWS resources, files, packages, images, licenses) are indefinitely available on the same location. +They can move, and we need to adapt in the future for it. +This can be done by checking the status code of the response and reacting accordingly. +Ideally, the test should be able to guide anyone to how to find the new location. + +#### Anti-pattern + +```python +response = requests.get("http://resource.com/my-resource.tar.gz") +use_resource(response.content) +``` + +#### Best practice + +```python +response = requests.get("http://resource.com/my-resource.tar.gz") +if response.status_code == 404: + further_steps() # e.g. clear error message, potential documentation on where to find a new location, etc. +use_resource(response.content) +``` + +### R03 + +Where possible, tests should be in control of the resources they use and re-create them if removed (e.g., S3 buckets, roles). + +#### Anti-pattern + +```python +bucket = s3_client.get_bucket("test-bucket") +use_bucket(bucket) +``` + +#### Best practice + +```python +buckets = s3_client.list_buckets() +if "test-bucket" not in buckets: + s3_client.create_bucket("on-demand-bucket") + +bucket = s3_client.get_bucket("on-demand-bucket") +use_bucket(bucket) +``` + +### R04 + +If on-demand creation is not possible, opt for a fail-fast approach and make retrieval failures clearly visible for further investigation. +We should not proceed with the test if the resource is not available. +This could lead to long-lasting loops with long log files and unclear error messages. + +#### Anti-pattern + +```python +bucket = s3_client.get_bucket("test-bucket") +use_bucket(bucket) +``` + +#### Best practice + +```python +buckets = s3_client.list_buckets() +if "test-bucket" not in buckets: + pytest.fail("Expected test-bucket to exist - it doesn't") +``` + +### R05 + +Add mechanisms to avoid access failures caused by rate limiting. +This can be done by adding exponential backoff or caching mechanisms. +In some cases, rate limits can be avoided by using an authenticated request. + +#### Anti-pattern + +```python +while True: + response = requests.get("http://resource.com") + if response.status_code == 429: # Too many requests + pass # immediately try again + else: + use(response) +``` + +#### Best practice + +```python +cache = TTLCache(ttl=60) + + +@cached(cache) +def get_resource(url, token, retries=10): + retry = 0 + while retry < retries: + response = authenticated_request(url, token) + if response.status_code == 429: + time.sleep(2 ** retry) # Exponential backoff + else: + return response + + +resource = get_resource("http://resource.com", "abdfabdf") +use(resource) +``` + +### R06 + +Do not wait a set amount of time but instead opt for a reactive approach using notification systems or polling for asynchronous (long-lasting) operations. +Waiting a set amount of time can lead to long test runs and flaky tests, as the time needed for the operation can vary. + +#### Anti-pattern + +```python +create_resource() +time.sleep(300) +use_resource() +``` + +#### Best practice + +```python +create_resource() +poll_condition(resource_exists, timeout=60) +use_resource() +``` + +### R07 + +For tests with multiple steps, handle waits separately and start each wait in the correct state. +This way, the test can be more reactive and not wait for a set amount of time. + +#### Anti-pattern + +```python +create_resource() +deploy_resource() +use_resource() +``` + +or + +```python +create_resource() +deploy_resource() +poll_condition(resource_deployed, timeout=60) +use_resource() +``` + +#### Best practice + +```python +create_resource() +poll_condition(resource_exists, timeout=20) +deploy_resource() +poll_condition(resource_deployed, timeout=60) +use_resource() +``` + +### R08 + +Ensure features interacting with account numbers work with arbitrary account numbers and multiple accounts simultaneously. +See [here](multi-account-region-testing/README.md) for further documentation for multi account/region testing. + +#### Anti-pattern + +1. Add new feature +2. Use it with fixed account number +3. Works -> done + +#### Best practice + +1. Add new feature +2. Use it with fixed account number +3. Works +4. Try with randomized account numbers (as in [documentation](multi-account-region-testing/README.md) +5. Works -> done + +### R09 + +Make sure that your tests are idempotent and could theoretically run in parallel, by using randomized IDs and not re-using IDs across tests. +This also means that tests should not depend on each other and should be able to run in any order. + +#### Anti-pattern + +```python +def test_something(): + key = "test-bucket" + create_bucket(key) + +def test_something_else(): + key = "test-bucket" + create_bucket(key) +``` + +#### Best practice + +```python +def test_something(): + key = f"test-bucket-{short_uid()}" + create_bucket(key) + +def test_something_else(): + key = f"test-bucket-{short_uid()}" + create_bucket(key) +``` + +### R10 + +Ensure deterministic responses for anything that reaches an assertion or a snapshot match. +This is especially important when you have randomized IDs in your tests as per [R09](#r09). +You can achieve this by using proper transformations. +See [here](parity-testing/README.md) for further documentation on parity testing and how to use transformers. + +#### Anti-pattern + +```python +snapshot = {"key": "key-asdfasdf"} # representing snapshot as a dict for presentation purposes + + +def test_something(snapshot): + key = f"key-{short_uid()}" + snapshot.match(snapshot, {"key": key}) +``` + +#### Best practice + +```python +snapshot = {"key": "<key:1>"} # representing snapshot as a dict for presentation purposes + + +def test_something(snapshot): + snapshot.add_transformer(...) # add appropriate transformers + key = f"key-{short_uid()}" + snapshot.match(snapshot, {"key": key}) +``` + +### R11 + +Be vigilant about changes happening to dependencies (Python dependencies and other) that can affect stability of your added features and tests. + +#### Anti-pattern + +1. Add dependency +2. Forget about it +3. Dependency adds instability +4. Disregard + +#### Best practice + +1. Add dependency +2. Check weekly python upgrade PR for upgrades to the dependency +3. Keep track of relevant changes from the changelog + +### R12 + +Ensure all dependencies are available and functional on both AMD64 and ARM64 architectures. +If a dependency is exclusive to one architecture, mark the corresponding test accordingly. +However, if possible, try to use multi-platform resources. + +#### Anti-pattern + +```python +def test_docker(): + docker.run(image="amd64-only-image") +``` + +#### Best practice + +```python +def test_docker(): + docker.run(image="multi-platform-image") +``` + +if above not possible, then: + +```python +@markers.only_on_amd64 +def test_docker(): + docker.run(image="amd64-only-image") +``` + +### R13 + +After the test run, make sure that the created resources are cleaned up properly. +This can easily be achieved by using fixtures with a yield statement. +This way, the resources are cleaned up after the test run, even if the test fails. +Furthermore, you could use factory fixtures to create resources on demand and then clean them up together. + +#### Anti-pattern + +```python +def test_something(): + key = f"test-{short_uid()}" + s3_client.create_bucket(key) + use_bucket(key) + # bucket still exists after test run +``` + +#### Best practice + +```python +@pytest.fixture +def bucket(): + key = f"test-{short_uid()}" + s3_client.create_bucket(key) + yield key + s3_client.delete_bucket(key) + +def test_something(bucket): + use_bucket(bucket) + # bucket is deleted after test run +``` + +### R14 + +Utilize fixture scopes for ensuring created resources exist as long as they should. +For example, if a resource should exist for the duration of the test run, use the `session` scope. +If a resource should exist for the duration of the test, use the `function` scope. + +#### Anti-pattern + +```python +@pytest.fixture(scope="function") # function scope is default +def database_server(): + server = start_database_server() + yield server + stop_database_server() + +@pytest.fixture(scope="function") # function scope is default +def database_connection(database_server): + conn = connect_to_database(database_server) + yield conn + conn.close() + +def test_insert_data(database_connection): + insert_data(database_connection) + # The database server is started and stopped for each test function, + # leading to increased overhead and potential performance issues. + +def test_query_data(database_connection): + query_data(database_connection) + # Similar issue here, the server is started and stopped for each test. +``` + +#### Best practice + +```python +@pytest.fixture(scope="session") +def database_server(): + server = start_database_server() + yield server + stop_database_server() + +@pytest.fixture(scope="function") # function scope is default +def database_connection(database_server): + conn = connect_to_database(database_server) + yield conn + conn.close() + +def test_insert_data(database_connection): + insert_data(database_connection) + +def test_query_data(database_connection): + query_data(database_connection) +``` + +## Test markers + +For tests, we offer additional markers which can be found +in: [localstack/testing/pytest/marking.py](../../localstack-core/localstack/testing/pytest/marking.py) + diff --git a/docs/testing/integration-tests/README.md b/docs/testing/integration-tests/README.md new file mode 100644 index 0000000000000..99e2f40795d58 --- /dev/null +++ b/docs/testing/integration-tests/README.md @@ -0,0 +1,182 @@ +# Integration tests + +LocalStack has an extensive set of [integration tests](https://github.com/localstack/localstack/tree/master/tests/integration). This document describes how to run and write integration tests. + +## Writing integration tests + +The following guiding principles apply to writing integration tests in addition to the [general rules](../README.md): + +- Tests should pass when running against AWS: + - Don't make assumptions about the time it takes to create resources. If you do asserts after creating resources, use `poll_condition`, `retry` or one of the waiters included in the boto3 library to wait for the resource to be created. + - Make sure your tests always clean up AWS resources, even if your test fails! Prefer existing factory fixtures (like `sqs_create_queue`). Introduce try/finally blocks if necessary. +- Tests should be runnable concurrently: + - Protect your tests against side effects. Example: never assert on global state that could be modified by a concurrently running test (like `assert len(sqs.list_queues()) == 1`; may not hold!). + - Make sure your tests are side-effect free. Avoid creating top-level resources with constant names. Prefer using generated unique names (like `short_uid`). +- Tests should not be clever. It should be plain to see what they are doing by looking at the test. This means avoiding creating functions, loops, or abstractions, even for repeated behavior (like groups of asserts) and instead preferring a bit of code duplication: +- Group tests logically using classes. +- Avoid injecting more than 2-3 fixtures in a test (unless you are testing complex integrations where your tests requires several different clients). +- Create factory fixtures only for top-level resources (like Queues, Topics, Lambdas, Tables). +- Avoid sleeps! Use `poll_condition`, `retry`, or `threading.Event` internally to control concurrent flows. + +We use [pytest](https://docs.pytest.org) for our testing framework. +Older tests were written using the unittest framework, but its use is discouraged. + +If your test matches the pattern `tests/integration/**/test_*.py` or `tests/aws/**/test_*.py` it will be picked up by the integration test suite. +Any test targeting one or more AWS services should go into `tests/aws/**` in the corresponding service package. +Every test in `tests/aws/**/test_*.py` must be marked by exactly one pytest marker, e.g. `@markers.aws.validated`. + +### Functional-style tests + +You can write functional style tests by defining a function with the prefix `test_` with basic asserts: + +```python +def test_something(): + assert True is not False +``` + +### Class-style tests + +Or you can write class-style tests by grouping tests that logically belong together in a class: + +```python +class TestMyThing: + def test_something(self): + assert True is not False +``` + +### Fixtures + +We use the pytest fixture concept, and provide several fixtures you can use when writing AWS tests. For example, to inject a boto client factory for all services, you can specify the `aws_client` fixture in your test method and access a client from it: + +```python +class TestMyThing: + def test_something(self, aws_client): + assert len(aws_client.sqs.list_queues()["QueueUrls"]) == 0 +``` + +We also provide fixtures for certain disposable resources, like buckets: + +```bash +def test_something_on_a_bucket(s3_bucket): + s3_bucket + # s3_bucket is a boto s3 bucket object that is created before + # the test runs, and removed after it returns. +``` + +Another pattern we use is the [factory as fixture](https://docs.pytest.org/en/6.2.x/fixture.html#factories-as-fixtures) pattern. + +```bash +def test_something_on_multiple_buckets(s3_create_bucket): + bucket1 = s3_create_bucket() + bucket2 = s3_create_bucket() + # both buckets will be deleted after the test returns +``` + +You can find the list of available fixtures in the [fixtures.py](https://github.com/localstack/localstack/blob/master/localstack-core/localstack/testing/pytest/fixtures.py) file. + + +## Running the test suite + +To run the tests you can use the make target and set the `TEST_PATH` variable. + +```bash +TEST_PATH="tests/integration" make test +``` + +or run it manually within the virtual environment: + +```bash +python -m pytest --log-cli-level=INFO tests/integration +``` + +### Running individual tests + +You can further specify the file and test class you want to run in the test path: + +```bash +TEST_PATH="tests/integration/docker_utils/test_docker.py::TestDockerClient" make test +``` + +### Test against a running LocalStack instance + +When you run the integration tests, LocalStack is automatically started (via the pytest conftest mechanism in [tests/integration/conftest.py](https://github.com/localstack/localstack/blob/master/tests/integration/conftest.py)). +You can disable this behavior by setting the environment variable `TEST_SKIP_LOCALSTACK_START=1`. + +### Test against Amazon Web Services + +Ideally every integration is tested against real AWS. To run the integration tests, we prefer you to use an AWS sandbox account, so that you don't accidentally run tests against your production account. + +#### Creating an AWS sandbox account + +1. Login with your credentials into your AWS Sandbox Account with `AWSAdministratorAccess`. +2. Type in **IAM** in the top bar and navigate to the **IAM** service +3. Navigate to `Users` and create a new user (**Add Users**) + 1. Add the username as `localstack-testing`. + 2. Keep the **Provide user access to the AWS Management Console - optional** box unchecked. +4. Attach existing policies directly. +5. Check **AdministratorAccess** and click **Next** before **Next/Create User** until done. +6. Go to the newly created user under `IAM/Users`, go to the `Security Credentials` tab, and click on **Create access key** within the `Access Keys` section. +7. Pick the **Local code** option and check the **I understand the above recommendation and want to proceed to create an access key** box. +8. Click on **Create access key** and copy the Access Key ID and the Secret access key immediately. +9. Run `aws configure --profile ls-sandbox` and enter the Access Key ID, and the Secret access key when prompted. +10. Verify that the profile is set up correctly by running: `aws sts get-caller-identity --profile ls-sandbox`. + +Here is how `~/.aws/credentials` should look like: + +```bash +[ls-sandbox] +aws_access_key_id = <your-key-id> +aws_secret_access_key = <your-secret-key> +``` + +The `~/.aws/config` file should look like: + +```bash +[ls-sandbox] +region=eu-central-1 +# .... you can add additional configuration options for AWS clients here +``` + +#### Running integration tests against AWS + +- Set the environment variable: `TEST_TARGET=AWS_CLOUD`. +- Use the client `fixtures` and other fixtures for resource creation instead of methods from `aws_stack.py` + - While using the environment variable `TEST_TARGET=AWS_CLOUD`, the boto client will be automatically configured to target AWS instead of LocalStack. +- Configure your AWS profile/credentials: + - When running the test, set the environment variable `AWS_PROFILE` to the profile name you chose in the previous step. Example: `AWS_PROFILE=ls-sandbox` +- Ensure that all resources are cleaned up even when the test fails and even when other fixture cleanup operations fail! +- Testing against AWS might require additional roles and policies. + +Here is how a useful environment configuration for testing against AWS could look like: + +```bash +DEBUG=1; # enables debug logging +TEST_DISABLE_RETRIES_AND_TIMEOUTS=1; +TEST_TARGET=AWS_CLOUD; +AWS_DEFAULT_REGION=us-east-1; +AWS_PROFILE=ls-sandbox +``` + +Once you're confident your test is reliably working against AWS you can add the pytest marker `@markers.aws.validated`. + +#### Create a snapshot test + +Once you verified that your test is running against AWS, you can record snapshots for the test run. A snapshot records the response from AWS and can be later on used to compare the response of LocalStack. + +Snapshot tests helps to increase the parity with AWS and to raise the confidence in the service implementations. Therefore, snapshot tests are preferred over normal integrations tests. + +Please check our subsequent guide on [Parity Testing](../parity-testing/README.md) for a detailed explanation on how to write AWS validated snapshot tests. + +#### Force the start of a local instance + +When running test with `TEST_TARGET=AWS_CLOUD`, by default, no localstack instance will be created. This can be bypassed by also setting `TEST_FORCE_LOCALSTACK_START=1`. + +Note that the `aws_client` fixture will keep pointing at the aws instance and you will need to create your own client factory using the `aws_client_factory`. + +```python +local_client = aws_client_factory( + endpoint_url=f"http://{localstack_host()}", + aws_access_key_id="test", + aws_secret_access_key="test", +) +``` diff --git a/docs/testing/multi-account-region-testing/README.md b/docs/testing/multi-account-region-testing/README.md new file mode 100644 index 0000000000000..323643cbc8a97 --- /dev/null +++ b/docs/testing/multi-account-region-testing/README.md @@ -0,0 +1,55 @@ +# Multi-account and Multi-region Testing + +LocalStack has multi-account and multi-region support. This document contains some tips to make sure that your contributions are compatible with this functionality. + +## Overview + +For cross-account inter-service access, specify a role with which permissions the source service makes a request to the target service to access another service's resource. +This role should be in the source account. +When writing an AWS validated test case, you need to properly configure IAM roles. + +For example: +The test case [`test_apigateway_with_step_function_integration`](https://github.com/localstack/localstack/blob/628b96b44a4fc63d880a4c1238a4f15f5803a3f2/tests/aws/services/apigateway/test_apigateway_basic.py#L999) specifies a [role](https://github.com/localstack/localstack/blob/628b96b44a4fc63d880a4c1238a4f15f5803a3f2/tests/aws/services/apigateway/test_apigateway_basic.py#L1029-L1034) which has permissions to access the target step function account. +```python +role_arn = create_iam_role_with_policy( + RoleName=f"sfn_role-{short_uid()}", + PolicyName=f"sfn-role-policy-{short_uid()}", + RoleDefinition=STEPFUNCTIONS_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_LAMBDA_POLICY, +) +``` + +For cross-account inter-service access, you can create the client using `connect_to.with_assumed_role(...)`. +For example: +```python +connect_to.with_assumed_role( + role_arn="role-arn", + service_principal=ServicePrincial.service_name, + region_name=region_name, +).lambda_ +``` + +When there is no role specified, you should use the source arn conceptually if cross-account is allowed. +This can be seen in a case where `account_id` was [added](https://github.com/localstack/localstack/blob/ae31f63bb6d8254edc0c85a66e3c36cd0c7dc7b0/localstack/utils/aws/message_forwarding.py#L42) to [send events to the target](https://github.com/localstack/localstack/blob/ae31f63bb6d8254edc0c85a66e3c36cd0c7dc7b0/localstack/utils/aws/message_forwarding.py#L31) service like SQS, SNS, Lambda, etc. + +Always refer to the official AWS documentation and investigate how the the services communicate with each other. +For example, here are the [AWS Firehose docs](https://docs.aws.amazon.com/firehose/latest/dev/controlling-access.html#cross-account-delivery-s3) explaining Firehose and S3 integration. + + +## Test changes in CI with random credentials + +We regularly run the test suite on GitHub Actions to verify compatibility with multi-account and multi-region features. + +A [scheduled GitHub Actions workflow](https://github.com/localstack/localstack/actions/workflows/aws-tests-mamr.yml) runs on working days at 01:00 UTC, executing the tests with randomized account IDs and regions. +If you have the necessary permissions, you can also manually trigger the [workflow](https://github.com/localstack/localstack/actions/workflows/aws-tests-mamr.yml) directly from GitHub. + +## Test changes locally with random credentials + +To test changes locally for multi-account and multi-region compatibility, set the environment config values as follows: + +- `TEST_AWS_ACCOUNT_ID` (Any value except `000000000000`) +- `TEST_AWS_ACCESS_KEY_ID` (Any value except `000000000000`) +- `TEST_AWS_REGION` (Any value except `us-east-1`) + +Note that within all tests you must use `account_id`, `secondary_account_id`, `region_name`, `secondary_region_name` fixtures. +Importing and using `localstack.constants.TEST_` values is not advised. diff --git a/docs/testing/parity-testing/README.md b/docs/testing/parity-testing/README.md new file mode 100644 index 0000000000000..9127dc5794b45 --- /dev/null +++ b/docs/testing/parity-testing/README.md @@ -0,0 +1,259 @@ +from conftest import aws_client + +# Parity Testing + +Parity tests (also called snapshot tests) are a special form of integration tests that should verify and improve the correctness of LocalStack compared to AWS. + +Initially, the integration test is executed against AWS and collects responses of interest. Those responses are called "snapshots" and will be used later on to compare the results from AWS with the ones from LocalStack. +Those responses aka "snapshots" are stored in a **snapshot.json** file. + +Once the snapshot is recorded, the test can be executed against LocalStack. During this β€œnormal” test execution, the test runs against LocalStack and compares the LocalStack responses with the recorded content. + +In theory, every integration test can be converted to a parity conform snapshot test. + +This guide assumes you are already familiar with writing [integration tests](../integration-tests/README.md) for LocalStack in general. + +## How to write Parity tests + +In a nutshell, the necessary steps include: + +1. Make sure that the test works against AWS. + * Check out our [Integration Test Guide](../integration-tests/README.md#running-integration-tests-against-aws) for tips on how run integration tests against AWS. +2. Add the `snapshot` fixture to your test and identify which responses you want to collect and compare against LocalStack. + * Use `snapshot.match(”identifier”, result)` to mark the result of interest. It will be recorded and stored in a file with the name `<testfile-name>.snapshot.json` + * The **identifier** can be freely selected, but ideally it gives a hint on what is recorded - so typically the name of the function. The **result** is expected to be a `dict`. + * Run the test against AWS: use the parameter `--snapshot-update` (or the environment variable `SNAPSHOT_UPDATE=1`) and set the environment variable as `TEST_TARGET=AWS_CLOUD`. + * Check the recorded result in `<testfile-name>.snapshot.json` and consider [using transformers](#using-transformers) to make the result comparable. +3. Run the test against LocalStack. + * Hint: Ensure that the `AWS_CLOUD` is not set as a test target and that the parameter `--snapshot-update` is removed. + * If you used the environment variable make sure to delete it or reset the value, e.g. `SNAPSHOT_UPDATE=0` + +Here is an example of a parity test: + +```python +def test_invocation(self, aws_client, snapshot): + # add transformers to make the results comparable + snapshot.add_transformer(snapshot.transform.lambda_api()) + + result = aws_client.lambda_.invoke( + .... + ) + # records the 'result' using the identifier 'invoke' + snapshot.match("invoke", result) +``` + + +## The Snapshot + +When an integration test is executed against AWS with the `snapshot-update` flag, the response will automatically be updated in the snapshot-file. + +**The file is automatically created if it doesn't exist yet.** The naming pattern is `<filename>.snapshot.json` where `<filename>` is the name of the file where the test is located. +One file can contain several snapshot recordings, e.g. the result from several tests. + +The snapshot file is a json-file, and each json-object on the root-level represents one test. +E.g., imagine the test file name is `test_lambda_api.py` (example is outlined in ['Reference Replacement'](#reference-replacement)), with the class `TestLambda`. + +When running the test `test_basic_invoke` it will create a json-object `test_lambda_api.py::TestLambda::test_basic_invoke`. + +Each recorded snapshot contains: + * `recorded-date` the timestamp when this test was last updated + * `recorded-content` contains all `identifiers` as keys, with the `response` as values, from the tests `snapshot.match(identifier, response)` definitions + +Note that all json-strings of a response will automatically be parsed to json. This makes the comparison, transformation, and exclusion of certain keys easier (string vs json-object). + +**Snapshot files should never be modified manually.** If one or more snapshots need to be updated, simply execute the test against AWS, and [use transformers](#using-transformers) to make the recorded responses comparable. + +## Using Transformers + +In order to make results comparable, some parts response might need to be adapted before storing the record as a snapshot. +For example, AWS responses could contain special IDs, usernames, timestamps, etc. + +Transformers should bring AWS response in a comparable form by replacing any request-specific parameters. Replacements require thoughtful handling so that important information is not lost in translation. + +The `snapshot` fixture uses some basic transformations by default, including: + +- Trimming MetaData (we only keep the `HTTPStatusCode` and `content-type` if set). +- Replacing all UUIDs (that match a regex) with [reference-replacement](#reference-replacement). +- Replacing everything that matches the ISO8601 pattern with β€œdate”. +- Replacing any value with datatype `datetime` with β€œdatetime”. +- Replace all values where the key contains β€œtimestamp” with β€œtimestamp”. +- Regex replacement of the `account-id`. +- Regex replacement of the location. + +## API Transformer + +APIs for one service often require similar transformations. Therefore, we introduced some utilities that collect common transformations grouped by service. + +Ideally, the service-transformation already includes every transformation that is required. +The [TransformerUtility](https://github.com/localstack/localstack/blob/master/localstack-core/localstack/testing/snapshots/transformer_utility.py) already provides some collections of transformers for specific service APIs. + +For example, to add common transformers for lambda, you can use: `snapshot.add_transformer(snapshot.transform.lambda_api()`. + +## Transformer Types + +The Parity testing framework currently includes some basic transformer types: + +- `KeyValueBasedTransformer` replaces a value directly, or by reference; based on key-value evaluation. +- `JsonPathTransformer` replaces the JSON path value directly, or by reference. [jsonpath-ng](https://pypi.org/project/jsonpath-ng/) is used for the JSON path evaluation. +- `RegexTransformer` replaces the regex pattern globally. Please be aware that this will be applied on the json-string. The JSON will be transformed into a string, and the replacement happens globally - use it with care. + +Hint: There are also some simplified transformers in [TransformerUtility](https://github.com/localstack/localstack/blob/master/localstack-core/localstack/testing/snapshots/transformer_utility.py). + +### Examples + +A transformer, that replaces the key `logGroupName` only if the value matches the value `log_group_name`: + +```python +snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: v if k == "logGroupName" and v == log_group_name else None, + replacement="log-group", + ) + ) +``` + +If you only want to check for the key name, a simplified transformer could look like this: + +```python +snapshot.add_transformer(snapshot.transform.key_value("logGroupName")) +``` + +## Reference Replacement + +Parameters can be replaced by reference. In contrast to the β€œdirect” replacement, the value to be replaced will be **registered, and replaced later on as regex pattern**. It has the advantage of keeping information, when the same reference is used in several recordings in one test. + +Consider the following example: + +```python +def test_basic_invoke( + self, aws_client, create_lambda, snapshot + ): + + # custom transformers + snapshot.add_transformer(snapshot.transform.lambda_api()) + + # predefined names for functions + fn_name = f"ls-fn-{short_uid()}" + fn_name_2 = f"ls-fn-{short_uid()}" + + # create function 1 + response = create_lambda(FunctionName=fn_name, ... ) + snapshot.match("lambda_create_fn", response) + + # create function 2 + response = create_lambda(FunctionName=fn_name_2, ... ) + snapshot.match("lambda_create_fn_2", response) + + # get function 1 + get_fn_result = aws_client.lambda_.get_function(FunctionName=fn_name) + snapshot.match("lambda_get_fn", get_fn_result) + + # get function 2 + get_fn_result_2 = aws_client.lambda_.get_function(FunctionName=fn_name_2) + snapshot.match("lambda_get_fn_2", get_fn_result_2) +``` + +The information that the function-name of the first recording (`lambda_create_fn`) is the same as in the record for `lambda_get_fn` is important. + +Using reference replacement, this information is preserved in the `snapshot.json`. The reference replacement automatically adds an ascending number, to ensure that different values can be differentiated. + +```json +{ + "test_lambda_api.py::TestLambda::test_basic_invoke": { + "recorded-date": ..., + "recorded-content": { + "lambda_create_fn": { + ... + "FunctionName": "<function-name:1>", + "FunctionArn": "arn:aws:lambda:<region>:111111111111:function:<function-name:1>", + "Runtime": "python3.9", + "Role": "arn:aws:iam::111111111111:role/<resource:1>", + ... + }, + "lambda_create_fn_2": { + ... + "FunctionName": "<function-name:2>", + "FunctionArn": "arn:aws:lambda:<region>:111111111111:function:<function-name:2>", + "Runtime": "python3.9", + "Role": "arn:aws:iam::111111111111:role/<resource:1>", + ... + }, + "lambda_get_fn": { + ... + "Configuration": { + "FunctionName": "<function-name:1>", + "FunctionArn": "arn:aws:lambda:<region>:111111111111:function:<function-name:1>", + "Runtime": "python3.9", + "Role": "arn:aws:iam::111111111111:role/<resource:1>", + ... + }, + "lambda_get_fn_2": { + ... + "Configuration": { + "FunctionName": "<function-name:2>", + "FunctionArn": "arn:aws:lambda:<region>:111111111111:function:<function-name:2>", + "Role": "arn:aws:iam::111111111111:role/<resource:1>", + .... + }, + }, + + } + } +} +``` + +## Tips and Tricks for Transformers + +Getting the transformations right can be a tricky task and we appreciate the time you spend on writing parity snapshot tests for LocalStack! We are aware that it might be challenging to implement transformers that work for AWS and LocalStack responses. + +In general, we are interested in transformers that work for AWS. Therefore, we recommend also running the tests and testing the transformers against AWS itself. + +Meaning, after you have executed the test with the `snapshot-update` flag and recorded the snapshot, you can run the test without the update flag against the `AWS_CLOUD` test target. If the test passes, we can be quite certain that the transformers work in general. Any deviations with LocalStack might be due to missing parity. + +You do not have to fix any deviations right away, even though we would appreciate this very much! It is also possible to exclude the snapshot verification of single test cases, or specific json-pathes of the snapshot. + +### Skipping verification of snapshot test + +Snapshot verification is enabled by default. If for some reason you want to skip any snapshot verification, you can set the parameter `--snapshot-skip-all`. + +If you want to skip verification for or a single test case, you can set the pytest marker `skip_snapshot_verify`. If you set the marker without a parameter, the verification will be skipped entirely for this test case. + +Additionally, you can exclude certain paths from the verification only. +Simply include a list of json-paths. Those paths will then be excluded from the comparison: + +```python +@pytest.mark.skip_snapshot_verify( + paths=["$..LogResult", "$..Payload.context.memory_limit_in_mb"] + ) + def test_something_that_does_not_work_completly_yet(self, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.lambda_api()) + result = aws_client.lambda_.... + snapshot.match("invoke-result", result) +``` + +> [!NOTE] +> Generally, [transformers](#using-transformers) should be used wherever possible to make responses comparable. +> If specific paths are skipped from the verification, it means LocalStack does not have parity yet. + +### Debugging the Transformers + +Sometimes different transformers might interfere, especially regex transformers and reference transformations can be tricky We added debug logs so that each replacement step should be visible in the output to help locate any unexpected behavior. You can enable the debug logs by setting the env `DEBUG_SNAPSHOT=1`. + +```bash +localstack.testing.snapshots.transformer: Registering regex pattern '000000000000' in snapshot with '111111111111' +localstack.testing.snapshots.transformer: Registering regex pattern 'us-east-1' in snapshot with '<region>'localstack.testing.snapshots.transformer: Replacing JsonPath '$.json_encoded_delivery..Body.Signature' in snapshot with '<signature>' +localstack.testing.snapshots.transformer: Registering reference replacement for value: '1ad533b5-ac54-4354-a273-3ea885f0d59d' -> '<uuid:1>' +localstack.testing.snapshots.transformer: Replacing JsonPath '$.json_encoded_delivery..MD5OfBody' in snapshot with '<md5-hash>' +localstack.testing.snapshots.transformer: Replacing regex '000000000000' with '111111111111' +localstack.testing.snapshots.transformer: Replacing regex 'us-east-1' with '<region>' +localstack.testing.snapshots.transformer: Replacing '1ad533b5-ac54-4354-a273-3ea885f0d59d' in snapshot with '<uuid:1>' +``` + +### Test duration recording + +When a test runs successfully against AWS, its last validation date and duration are recorded in a corresponding ***.validation.json** file. +The validation date is recorded precisely, while test durations can vary between runs. +For example, test setup time may differ depending on whether a test runs in isolation or as part of a class test suite with class-level fixtures. +The recorded durations should be treated as approximate indicators of test execution time rather than precise measurements. +The goal of duration recording is to give _an idea_ about execution times. +If no duration is present in the validation file, it means the test has not been re-validated against AWS since duration recording was implemented. diff --git a/docs/testing/terraform-tests/README.md b/docs/testing/terraform-tests/README.md new file mode 100644 index 0000000000000..1a79f76ac51c0 --- /dev/null +++ b/docs/testing/terraform-tests/README.md @@ -0,0 +1,3 @@ +# Terraform test suite + +We regularly run the test suite of the Terraform AWS provider against LocalStack to test the compatibility of LocalStack to Terraform. To achieve that, we have a dedicated [GitHub action](https://github.com/localstack/localstack-terraform-test/blob/main/.github/workflows/main.yml) on [LocalStack](https://github.com/localstack/localstack), which executes the allow listed set of tests of [hashicorp/terraform-provider-aws](https://github.com/hashicorp/terraform-provider-aws/). diff --git a/docs/testing/test-types/README.md b/docs/testing/test-types/README.md new file mode 100644 index 0000000000000..2cf9a8ca9a168 --- /dev/null +++ b/docs/testing/test-types/README.md @@ -0,0 +1,65 @@ +# Test Types + +In the LocalStack codebase we differentiate between the following test types: + +- Unit tests +- Acceptance Tests +- Integration Tests + +Depending on the workflow and its trigger not all of those tests are executed at once. +For ordinary pushes to `master` we only want to execute the Unit and Acceptance tests. +On a regular schedule, however, we want to execute all tests to have as big of a coverage of our logic as possible. +This differentiation also educates what we expect from the different types of tests. + +## Unit tests + +As the name suggests, these tests are performed on smaller units of logic to check if they're sound and perform the operations they claim to. +This small unit can most often be a kind of helper function inside of a larger procedure. +These tests should be able to complete their execution very quickly, so they never contain any interaction with some kind of infrastructure. +If you need some kind of waiting mechanism in your unit test, it is most likely that you are not writing a unit test. + +A good example for a unit test is `tests.unit.testing.testselection.test_matching.test_service_dependency_resolving_with_dependencies`. +It tests whether an algorithm implemented inside of a bigger implementation performs as it is expected of it. + +## Acceptance tests + +We use acceptance tests to gain a quick understanding of whether the recently pushed commit to `master` fulfils minimally viable quality criteria. +This means that these tests do not aim at maximum coverage but instead should test that the most important functionality works. +This in general is the entire serving infrastructure and the main features of the most used services. + +As these tests are executed very often we need them to be as stable, fast and relevant as possible. +We ensure this by the following criteria: + +- It shows some kind of real-world usage. This is usually a scenario or architectural pattern with multiple services. + - When composing these scenarios, the services should not overlap too much with already existing acceptance tests. We want to avoid redundancy where possible. At the same time we want to have our primary services and typical use-cases being covered. + - Existing samples (from [our samples organization](https://github.com/localstack-samples)) might serve as a starting point for constructing such a scenario. + However, keep in mind that we want to use many interacting resources in these tests, so the samples might need further expansion. +- It perfectly conforms to all the testing rules laid out [here](../README.md) +- It does not contain long wait times (e.g., for resources to spin up). + The acceptance tests need to be fast. + Whether they are fast enough is evaluated on a case-by-case basis (e.g., depending on the amount of confidence they provide) +- It is fully parallelizable. + If certain acceptance tests need to run together (e.g., in a scenario), they need to be added to the same test class. +- The test needs to be perfectly stable and only fail because of real issues with the implementation under test. + - Should an acceptance test turn flaky, it will be skipped until it is fixed ([as we already state in our testing rules](../README.md)). +- It needs to be validated against the targeted cloud provider if it is purely testing parity with that cloud provider. + - See [the documentation on parity tests for further information](../parity-testing/README.md) + - This effectively means that the test should not carry the markers `aws.unknown` or `needs_fixing`. + +Note, that some criteria is still not concrete and will evolve over time. +For cases where it is unclear if a test fulfils a criterium, reviewers will need to decide whether it fits the general goals laid out here. +With growing maturity, however, criteria will become more concrete (and strict). + +The first acceptance test that we added to our suite, and which serves as an example is `tests.aws.scenario.bookstore.test_bookstore.TestBookstoreApplication`. +It implements an entire application involving multiple services and tests their interaction with each other. + +## Integration tests + +These tests are quite similar to the acceptance tests, but are less restrictive. +Any acceptance test can be demoted to an integration test should it not satisfy the needs of the acceptance test suite anymore. +However, this does not mean that integration tests do not have any quality requirements in their own right. +Flaky integration tests can (and will) still be skipped until their flake is resolved. +Also, they still should all conform to the testing rules. + +An example for a good integration test, that could not be an acceptance test is `tests.aws.services.s3.test_s3.TestS3.test_object_with_slashes_in_key`. +It tests a concrete feature of the S3 implementation while not being part of a scenario with other services. diff --git a/localstack/__init__.py b/localstack-core/localstack/aws/__init__.py similarity index 100% rename from localstack/__init__.py rename to localstack-core/localstack/aws/__init__.py diff --git a/localstack-core/localstack/aws/accounts.py b/localstack-core/localstack/aws/accounts.py new file mode 100644 index 0000000000000..0308daf468209 --- /dev/null +++ b/localstack-core/localstack/aws/accounts.py @@ -0,0 +1,81 @@ +"""Functionality related to AWS Accounts""" + +import base64 +import binascii +import logging +import re + +from localstack import config +from localstack.constants import DEFAULT_AWS_ACCOUNT_ID + +LOG = logging.getLogger(__name__) + +# Account id offset for id extraction +# generated from int.from_bytes(base64.b32decode(b"QAAAAAAA"), byteorder="big") (user id 000000000000) +ACCOUNT_OFFSET = 549755813888 + +# Basically the base32 alphabet, for better access as constant here +AWS_ACCESS_KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + +def extract_account_id_from_access_key_id(access_key_id: str) -> str: + """ + Extract account id from access key id + + Example: + "ASIAQAAAAAAAGMKEM7X5" => "000000000000" + "AKIARZPUZDIKGB2VALC4" => "123456789012" + :param access_key_id: Access key id. Must start with either ASIA or AKIA and has at least 20 characters + :return: Account ID (as string), 12 digits + """ + account_id_part = access_key_id[4:12] + # decode account id part + try: + account_id_part_int = int.from_bytes(base64.b32decode(account_id_part), byteorder="big") + except binascii.Error: + LOG.warning( + "Invalid Access Key Id format. Falling back to default id: %s", DEFAULT_AWS_ACCOUNT_ID + ) + return DEFAULT_AWS_ACCOUNT_ID + + account_id = 2 * (account_id_part_int - ACCOUNT_OFFSET) + try: + if AWS_ACCESS_KEY_ALPHABET.index(access_key_id[12]) >= 16: + account_id += 1 + except ValueError: + LOG.warning( + "Char at index 12 not from base32 alphabet. Falling back to default id: %s", + DEFAULT_AWS_ACCOUNT_ID, + ) + return DEFAULT_AWS_ACCOUNT_ID + if account_id < 0 or account_id > 999999999999: + LOG.warning( + "Extracted account id not between 000000000000 and 999999999999. Falling back to default id: %s", + DEFAULT_AWS_ACCOUNT_ID, + ) + return DEFAULT_AWS_ACCOUNT_ID + return f"{account_id:012}" + + +def get_account_id_from_access_key_id(access_key_id: str) -> str: + """Return the Account ID associated the Access Key ID.""" + + # If AWS_ACCESS_KEY_ID has a 12-digit integer value, use it as the account ID + if re.match(r"\d{12}", access_key_id): + return access_key_id + + elif len(access_key_id) >= 20: + if not config.PARITY_AWS_ACCESS_KEY_ID: + # If AWS_ACCESS_KEY_ID has production AWS credentials, ignore them + if access_key_id.startswith("ASIA") or access_key_id.startswith("AKIA"): + LOG.debug( + "Ignoring production AWS credentials provided to LocalStack. Falling back to default account ID." + ) + + elif access_key_id.startswith("LSIA") or access_key_id.startswith("LKIA"): + return extract_account_id_from_access_key_id(access_key_id) + else: + if access_key_id.startswith("ASIA") or access_key_id.startswith("AKIA"): + return extract_account_id_from_access_key_id(access_key_id) + + return DEFAULT_AWS_ACCOUNT_ID diff --git a/localstack-core/localstack/aws/api/__init__.py b/localstack-core/localstack/aws/api/__init__.py new file mode 100644 index 0000000000000..ab4e6ce81b7f5 --- /dev/null +++ b/localstack-core/localstack/aws/api/__init__.py @@ -0,0 +1,17 @@ +from .core import ( + CommonServiceException, + RequestContext, + ServiceException, + ServiceRequest, + ServiceResponse, + handler, +) + +__all__ = [ + "RequestContext", + "ServiceException", + "CommonServiceException", + "ServiceRequest", + "ServiceResponse", + "handler", +] diff --git a/localstack-core/localstack/aws/api/acm/__init__.py b/localstack-core/localstack/aws/api/acm/__init__.py new file mode 100644 index 0000000000000..b62d3c2508d96 --- /dev/null +++ b/localstack-core/localstack/aws/api/acm/__init__.py @@ -0,0 +1,685 @@ +from datetime import datetime +from enum import StrEnum +from typing import List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Arn = str +AvailabilityErrorMessage = str +CertificateBody = str +CertificateChain = str +DomainNameString = str +IdempotencyToken = str +MaxItems = int +NextToken = str +NullableBoolean = bool +PcaArn = str +PositiveInteger = int +PrivateKey = str +ServiceErrorMessage = str +String = str +TagKey = str +TagValue = str +ValidationExceptionMessage = str + + +class CertificateExport(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class CertificateManagedBy(StrEnum): + CLOUDFRONT = "CLOUDFRONT" + + +class CertificateStatus(StrEnum): + PENDING_VALIDATION = "PENDING_VALIDATION" + ISSUED = "ISSUED" + INACTIVE = "INACTIVE" + EXPIRED = "EXPIRED" + VALIDATION_TIMED_OUT = "VALIDATION_TIMED_OUT" + REVOKED = "REVOKED" + FAILED = "FAILED" + + +class CertificateTransparencyLoggingPreference(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class CertificateType(StrEnum): + IMPORTED = "IMPORTED" + AMAZON_ISSUED = "AMAZON_ISSUED" + PRIVATE = "PRIVATE" + + +class DomainStatus(StrEnum): + PENDING_VALIDATION = "PENDING_VALIDATION" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + + +class ExtendedKeyUsageName(StrEnum): + TLS_WEB_SERVER_AUTHENTICATION = "TLS_WEB_SERVER_AUTHENTICATION" + TLS_WEB_CLIENT_AUTHENTICATION = "TLS_WEB_CLIENT_AUTHENTICATION" + CODE_SIGNING = "CODE_SIGNING" + EMAIL_PROTECTION = "EMAIL_PROTECTION" + TIME_STAMPING = "TIME_STAMPING" + OCSP_SIGNING = "OCSP_SIGNING" + IPSEC_END_SYSTEM = "IPSEC_END_SYSTEM" + IPSEC_TUNNEL = "IPSEC_TUNNEL" + IPSEC_USER = "IPSEC_USER" + ANY = "ANY" + NONE = "NONE" + CUSTOM = "CUSTOM" + + +class FailureReason(StrEnum): + NO_AVAILABLE_CONTACTS = "NO_AVAILABLE_CONTACTS" + ADDITIONAL_VERIFICATION_REQUIRED = "ADDITIONAL_VERIFICATION_REQUIRED" + DOMAIN_NOT_ALLOWED = "DOMAIN_NOT_ALLOWED" + INVALID_PUBLIC_DOMAIN = "INVALID_PUBLIC_DOMAIN" + DOMAIN_VALIDATION_DENIED = "DOMAIN_VALIDATION_DENIED" + CAA_ERROR = "CAA_ERROR" + PCA_LIMIT_EXCEEDED = "PCA_LIMIT_EXCEEDED" + PCA_INVALID_ARN = "PCA_INVALID_ARN" + PCA_INVALID_STATE = "PCA_INVALID_STATE" + PCA_REQUEST_FAILED = "PCA_REQUEST_FAILED" + PCA_NAME_CONSTRAINTS_VALIDATION = "PCA_NAME_CONSTRAINTS_VALIDATION" + PCA_RESOURCE_NOT_FOUND = "PCA_RESOURCE_NOT_FOUND" + PCA_INVALID_ARGS = "PCA_INVALID_ARGS" + PCA_INVALID_DURATION = "PCA_INVALID_DURATION" + PCA_ACCESS_DENIED = "PCA_ACCESS_DENIED" + SLR_NOT_FOUND = "SLR_NOT_FOUND" + OTHER = "OTHER" + + +class KeyAlgorithm(StrEnum): + RSA_1024 = "RSA_1024" + RSA_2048 = "RSA_2048" + RSA_3072 = "RSA_3072" + RSA_4096 = "RSA_4096" + EC_prime256v1 = "EC_prime256v1" + EC_secp384r1 = "EC_secp384r1" + EC_secp521r1 = "EC_secp521r1" + + +class KeyUsageName(StrEnum): + DIGITAL_SIGNATURE = "DIGITAL_SIGNATURE" + NON_REPUDIATION = "NON_REPUDIATION" + KEY_ENCIPHERMENT = "KEY_ENCIPHERMENT" + DATA_ENCIPHERMENT = "DATA_ENCIPHERMENT" + KEY_AGREEMENT = "KEY_AGREEMENT" + CERTIFICATE_SIGNING = "CERTIFICATE_SIGNING" + CRL_SIGNING = "CRL_SIGNING" + ENCIPHER_ONLY = "ENCIPHER_ONLY" + DECIPHER_ONLY = "DECIPHER_ONLY" + ANY = "ANY" + CUSTOM = "CUSTOM" + + +class RecordType(StrEnum): + CNAME = "CNAME" + + +class RenewalEligibility(StrEnum): + ELIGIBLE = "ELIGIBLE" + INELIGIBLE = "INELIGIBLE" + + +class RenewalStatus(StrEnum): + PENDING_AUTO_RENEWAL = "PENDING_AUTO_RENEWAL" + PENDING_VALIDATION = "PENDING_VALIDATION" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + + +class RevocationReason(StrEnum): + UNSPECIFIED = "UNSPECIFIED" + KEY_COMPROMISE = "KEY_COMPROMISE" + CA_COMPROMISE = "CA_COMPROMISE" + AFFILIATION_CHANGED = "AFFILIATION_CHANGED" + SUPERCEDED = "SUPERCEDED" + SUPERSEDED = "SUPERSEDED" + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION" + CERTIFICATE_HOLD = "CERTIFICATE_HOLD" + REMOVE_FROM_CRL = "REMOVE_FROM_CRL" + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN" + A_A_COMPROMISE = "A_A_COMPROMISE" + + +class SortBy(StrEnum): + CREATED_AT = "CREATED_AT" + + +class SortOrder(StrEnum): + ASCENDING = "ASCENDING" + DESCENDING = "DESCENDING" + + +class ValidationMethod(StrEnum): + EMAIL = "EMAIL" + DNS = "DNS" + HTTP = "HTTP" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidArgsException(ServiceException): + code: str = "InvalidArgsException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidArnException(ServiceException): + code: str = "InvalidArnException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDomainValidationOptionsException(ServiceException): + code: str = "InvalidDomainValidationOptionsException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidParameterException(ServiceException): + code: str = "InvalidParameterException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidStateException(ServiceException): + code: str = "InvalidStateException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTagException(ServiceException): + code: str = "InvalidTagException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class RequestInProgressException(ServiceException): + code: str = "RequestInProgressException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class TagPolicyException(ServiceException): + code: str = "TagPolicyException" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTagsException(ServiceException): + code: str = "TooManyTagsException" + sender_fault: bool = False + status_code: int = 400 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: Optional[TagValue] + + +TagList = List[Tag] + + +class AddTagsToCertificateRequest(ServiceRequest): + CertificateArn: Arn + Tags: TagList + + +CertificateBodyBlob = bytes +CertificateChainBlob = bytes + + +class CertificateOptions(TypedDict, total=False): + CertificateTransparencyLoggingPreference: Optional[CertificateTransparencyLoggingPreference] + Export: Optional[CertificateExport] + + +class ExtendedKeyUsage(TypedDict, total=False): + Name: Optional[ExtendedKeyUsageName] + OID: Optional[String] + + +ExtendedKeyUsageList = List[ExtendedKeyUsage] + + +class KeyUsage(TypedDict, total=False): + Name: Optional[KeyUsageName] + + +KeyUsageList = List[KeyUsage] +TStamp = datetime + + +class HttpRedirect(TypedDict, total=False): + RedirectFrom: Optional[String] + RedirectTo: Optional[String] + + +class ResourceRecord(TypedDict, total=False): + Name: String + Type: RecordType + Value: String + + +ValidationEmailList = List[String] + + +class DomainValidation(TypedDict, total=False): + DomainName: DomainNameString + ValidationEmails: Optional[ValidationEmailList] + ValidationDomain: Optional[DomainNameString] + ValidationStatus: Optional[DomainStatus] + ResourceRecord: Optional[ResourceRecord] + HttpRedirect: Optional[HttpRedirect] + ValidationMethod: Optional[ValidationMethod] + + +DomainValidationList = List[DomainValidation] + + +class RenewalSummary(TypedDict, total=False): + RenewalStatus: RenewalStatus + DomainValidationOptions: DomainValidationList + RenewalStatusReason: Optional[FailureReason] + UpdatedAt: TStamp + + +InUseList = List[String] +DomainList = List[DomainNameString] + + +class CertificateDetail(TypedDict, total=False): + CertificateArn: Optional[Arn] + DomainName: Optional[DomainNameString] + SubjectAlternativeNames: Optional[DomainList] + ManagedBy: Optional[CertificateManagedBy] + DomainValidationOptions: Optional[DomainValidationList] + Serial: Optional[String] + Subject: Optional[String] + Issuer: Optional[String] + CreatedAt: Optional[TStamp] + IssuedAt: Optional[TStamp] + ImportedAt: Optional[TStamp] + Status: Optional[CertificateStatus] + RevokedAt: Optional[TStamp] + RevocationReason: Optional[RevocationReason] + NotBefore: Optional[TStamp] + NotAfter: Optional[TStamp] + KeyAlgorithm: Optional[KeyAlgorithm] + SignatureAlgorithm: Optional[String] + InUseBy: Optional[InUseList] + FailureReason: Optional[FailureReason] + Type: Optional[CertificateType] + RenewalSummary: Optional[RenewalSummary] + KeyUsages: Optional[KeyUsageList] + ExtendedKeyUsages: Optional[ExtendedKeyUsageList] + CertificateAuthorityArn: Optional[Arn] + RenewalEligibility: Optional[RenewalEligibility] + Options: Optional[CertificateOptions] + + +CertificateStatuses = List[CertificateStatus] +ExtendedKeyUsageNames = List[ExtendedKeyUsageName] +KeyUsageNames = List[KeyUsageName] + + +class CertificateSummary(TypedDict, total=False): + CertificateArn: Optional[Arn] + DomainName: Optional[DomainNameString] + SubjectAlternativeNameSummaries: Optional[DomainList] + HasAdditionalSubjectAlternativeNames: Optional[NullableBoolean] + Status: Optional[CertificateStatus] + Type: Optional[CertificateType] + KeyAlgorithm: Optional[KeyAlgorithm] + KeyUsages: Optional[KeyUsageNames] + ExtendedKeyUsages: Optional[ExtendedKeyUsageNames] + ExportOption: Optional[CertificateExport] + InUse: Optional[NullableBoolean] + Exported: Optional[NullableBoolean] + RenewalEligibility: Optional[RenewalEligibility] + NotBefore: Optional[TStamp] + NotAfter: Optional[TStamp] + CreatedAt: Optional[TStamp] + IssuedAt: Optional[TStamp] + ImportedAt: Optional[TStamp] + RevokedAt: Optional[TStamp] + ManagedBy: Optional[CertificateManagedBy] + + +CertificateSummaryList = List[CertificateSummary] + + +class DeleteCertificateRequest(ServiceRequest): + CertificateArn: Arn + + +class DescribeCertificateRequest(ServiceRequest): + CertificateArn: Arn + + +class DescribeCertificateResponse(TypedDict, total=False): + Certificate: Optional[CertificateDetail] + + +class DomainValidationOption(TypedDict, total=False): + DomainName: DomainNameString + ValidationDomain: DomainNameString + + +DomainValidationOptionList = List[DomainValidationOption] + + +class ExpiryEventsConfiguration(TypedDict, total=False): + DaysBeforeExpiry: Optional[PositiveInteger] + + +PassphraseBlob = bytes + + +class ExportCertificateRequest(ServiceRequest): + CertificateArn: Arn + Passphrase: PassphraseBlob + + +class ExportCertificateResponse(TypedDict, total=False): + Certificate: Optional[CertificateBody] + CertificateChain: Optional[CertificateChain] + PrivateKey: Optional[PrivateKey] + + +ExtendedKeyUsageFilterList = List[ExtendedKeyUsageName] +KeyAlgorithmList = List[KeyAlgorithm] +KeyUsageFilterList = List[KeyUsageName] + + +class Filters(TypedDict, total=False): + extendedKeyUsage: Optional[ExtendedKeyUsageFilterList] + keyUsage: Optional[KeyUsageFilterList] + keyTypes: Optional[KeyAlgorithmList] + exportOption: Optional[CertificateExport] + managedBy: Optional[CertificateManagedBy] + + +class GetAccountConfigurationResponse(TypedDict, total=False): + ExpiryEvents: Optional[ExpiryEventsConfiguration] + + +class GetCertificateRequest(ServiceRequest): + CertificateArn: Arn + + +class GetCertificateResponse(TypedDict, total=False): + Certificate: Optional[CertificateBody] + CertificateChain: Optional[CertificateChain] + + +PrivateKeyBlob = bytes + + +class ImportCertificateRequest(ServiceRequest): + CertificateArn: Optional[Arn] + Certificate: CertificateBodyBlob + PrivateKey: PrivateKeyBlob + CertificateChain: Optional[CertificateChainBlob] + Tags: Optional[TagList] + + +class ImportCertificateResponse(TypedDict, total=False): + CertificateArn: Optional[Arn] + + +class ListCertificatesRequest(ServiceRequest): + CertificateStatuses: Optional[CertificateStatuses] + Includes: Optional[Filters] + NextToken: Optional[NextToken] + MaxItems: Optional[MaxItems] + SortBy: Optional[SortBy] + SortOrder: Optional[SortOrder] + + +class ListCertificatesResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + CertificateSummaryList: Optional[CertificateSummaryList] + + +class ListTagsForCertificateRequest(ServiceRequest): + CertificateArn: Arn + + +class ListTagsForCertificateResponse(TypedDict, total=False): + Tags: Optional[TagList] + + +class PutAccountConfigurationRequest(ServiceRequest): + ExpiryEvents: Optional[ExpiryEventsConfiguration] + IdempotencyToken: IdempotencyToken + + +class RemoveTagsFromCertificateRequest(ServiceRequest): + CertificateArn: Arn + Tags: TagList + + +class RenewCertificateRequest(ServiceRequest): + CertificateArn: Arn + + +class RequestCertificateRequest(ServiceRequest): + DomainName: DomainNameString + ValidationMethod: Optional[ValidationMethod] + SubjectAlternativeNames: Optional[DomainList] + IdempotencyToken: Optional[IdempotencyToken] + DomainValidationOptions: Optional[DomainValidationOptionList] + Options: Optional[CertificateOptions] + CertificateAuthorityArn: Optional[PcaArn] + Tags: Optional[TagList] + KeyAlgorithm: Optional[KeyAlgorithm] + ManagedBy: Optional[CertificateManagedBy] + + +class RequestCertificateResponse(TypedDict, total=False): + CertificateArn: Optional[Arn] + + +class ResendValidationEmailRequest(ServiceRequest): + CertificateArn: Arn + Domain: DomainNameString + ValidationDomain: DomainNameString + + +class RevokeCertificateRequest(ServiceRequest): + CertificateArn: Arn + RevocationReason: RevocationReason + + +class RevokeCertificateResponse(TypedDict, total=False): + CertificateArn: Optional[Arn] + + +class UpdateCertificateOptionsRequest(ServiceRequest): + CertificateArn: Arn + Options: CertificateOptions + + +class AcmApi: + service = "acm" + version = "2015-12-08" + + @handler("AddTagsToCertificate") + def add_tags_to_certificate( + self, context: RequestContext, certificate_arn: Arn, tags: TagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteCertificate") + def delete_certificate(self, context: RequestContext, certificate_arn: Arn, **kwargs) -> None: + raise NotImplementedError + + @handler("DescribeCertificate") + def describe_certificate( + self, context: RequestContext, certificate_arn: Arn, **kwargs + ) -> DescribeCertificateResponse: + raise NotImplementedError + + @handler("ExportCertificate") + def export_certificate( + self, context: RequestContext, certificate_arn: Arn, passphrase: PassphraseBlob, **kwargs + ) -> ExportCertificateResponse: + raise NotImplementedError + + @handler("GetAccountConfiguration") + def get_account_configuration( + self, context: RequestContext, **kwargs + ) -> GetAccountConfigurationResponse: + raise NotImplementedError + + @handler("GetCertificate") + def get_certificate( + self, context: RequestContext, certificate_arn: Arn, **kwargs + ) -> GetCertificateResponse: + raise NotImplementedError + + @handler("ImportCertificate") + def import_certificate( + self, + context: RequestContext, + certificate: CertificateBodyBlob, + private_key: PrivateKeyBlob, + certificate_arn: Arn | None = None, + certificate_chain: CertificateChainBlob | None = None, + tags: TagList | None = None, + **kwargs, + ) -> ImportCertificateResponse: + raise NotImplementedError + + @handler("ListCertificates") + def list_certificates( + self, + context: RequestContext, + certificate_statuses: CertificateStatuses | None = None, + includes: Filters | None = None, + next_token: NextToken | None = None, + max_items: MaxItems | None = None, + sort_by: SortBy | None = None, + sort_order: SortOrder | None = None, + **kwargs, + ) -> ListCertificatesResponse: + raise NotImplementedError + + @handler("ListTagsForCertificate") + def list_tags_for_certificate( + self, context: RequestContext, certificate_arn: Arn, **kwargs + ) -> ListTagsForCertificateResponse: + raise NotImplementedError + + @handler("PutAccountConfiguration") + def put_account_configuration( + self, + context: RequestContext, + idempotency_token: IdempotencyToken, + expiry_events: ExpiryEventsConfiguration | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RemoveTagsFromCertificate") + def remove_tags_from_certificate( + self, context: RequestContext, certificate_arn: Arn, tags: TagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("RenewCertificate") + def renew_certificate(self, context: RequestContext, certificate_arn: Arn, **kwargs) -> None: + raise NotImplementedError + + @handler("RequestCertificate") + def request_certificate( + self, + context: RequestContext, + domain_name: DomainNameString, + validation_method: ValidationMethod | None = None, + subject_alternative_names: DomainList | None = None, + idempotency_token: IdempotencyToken | None = None, + domain_validation_options: DomainValidationOptionList | None = None, + options: CertificateOptions | None = None, + certificate_authority_arn: PcaArn | None = None, + tags: TagList | None = None, + key_algorithm: KeyAlgorithm | None = None, + managed_by: CertificateManagedBy | None = None, + **kwargs, + ) -> RequestCertificateResponse: + raise NotImplementedError + + @handler("ResendValidationEmail") + def resend_validation_email( + self, + context: RequestContext, + certificate_arn: Arn, + domain: DomainNameString, + validation_domain: DomainNameString, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RevokeCertificate") + def revoke_certificate( + self, + context: RequestContext, + certificate_arn: Arn, + revocation_reason: RevocationReason, + **kwargs, + ) -> RevokeCertificateResponse: + raise NotImplementedError + + @handler("UpdateCertificateOptions") + def update_certificate_options( + self, context: RequestContext, certificate_arn: Arn, options: CertificateOptions, **kwargs + ) -> None: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/apigateway/__init__.py b/localstack-core/localstack/aws/api/apigateway/__init__.py new file mode 100644 index 0000000000000..0010dd6b5b24a --- /dev/null +++ b/localstack-core/localstack/aws/api/apigateway/__init__.py @@ -0,0 +1,2938 @@ +from datetime import datetime +from enum import StrEnum +from typing import IO, Dict, Iterable, List, Optional, TypedDict, Union + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Boolean = bool +DocumentationPartLocationStatusCode = str +Double = float +Integer = int +NullableBoolean = bool +NullableInteger = int +ProviderARN = str +StatusCode = str +String = str + + +class AccessAssociationSourceType(StrEnum): + VPCE = "VPCE" + + +class ApiKeySourceType(StrEnum): + HEADER = "HEADER" + AUTHORIZER = "AUTHORIZER" + + +class ApiKeysFormat(StrEnum): + csv = "csv" + + +class AuthorizerType(StrEnum): + TOKEN = "TOKEN" + REQUEST = "REQUEST" + COGNITO_USER_POOLS = "COGNITO_USER_POOLS" + + +class CacheClusterSize(StrEnum): + i_0_5 = "0.5" + i_1_6 = "1.6" + i_6_1 = "6.1" + i_13_5 = "13.5" + i_28_4 = "28.4" + i_58_2 = "58.2" + i_118 = "118" + i_237 = "237" + + +class CacheClusterStatus(StrEnum): + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + AVAILABLE = "AVAILABLE" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + NOT_AVAILABLE = "NOT_AVAILABLE" + FLUSH_IN_PROGRESS = "FLUSH_IN_PROGRESS" + + +class ConnectionType(StrEnum): + INTERNET = "INTERNET" + VPC_LINK = "VPC_LINK" + + +class ContentHandlingStrategy(StrEnum): + CONVERT_TO_BINARY = "CONVERT_TO_BINARY" + CONVERT_TO_TEXT = "CONVERT_TO_TEXT" + + +class DocumentationPartType(StrEnum): + API = "API" + AUTHORIZER = "AUTHORIZER" + MODEL = "MODEL" + RESOURCE = "RESOURCE" + METHOD = "METHOD" + PATH_PARAMETER = "PATH_PARAMETER" + QUERY_PARAMETER = "QUERY_PARAMETER" + REQUEST_HEADER = "REQUEST_HEADER" + REQUEST_BODY = "REQUEST_BODY" + RESPONSE = "RESPONSE" + RESPONSE_HEADER = "RESPONSE_HEADER" + RESPONSE_BODY = "RESPONSE_BODY" + + +class DomainNameStatus(StrEnum): + AVAILABLE = "AVAILABLE" + UPDATING = "UPDATING" + PENDING = "PENDING" + PENDING_CERTIFICATE_REIMPORT = "PENDING_CERTIFICATE_REIMPORT" + PENDING_OWNERSHIP_VERIFICATION = "PENDING_OWNERSHIP_VERIFICATION" + + +class EndpointType(StrEnum): + REGIONAL = "REGIONAL" + EDGE = "EDGE" + PRIVATE = "PRIVATE" + + +class GatewayResponseType(StrEnum): + DEFAULT_4XX = "DEFAULT_4XX" + DEFAULT_5XX = "DEFAULT_5XX" + RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND" + UNAUTHORIZED = "UNAUTHORIZED" + INVALID_API_KEY = "INVALID_API_KEY" + ACCESS_DENIED = "ACCESS_DENIED" + AUTHORIZER_FAILURE = "AUTHORIZER_FAILURE" + AUTHORIZER_CONFIGURATION_ERROR = "AUTHORIZER_CONFIGURATION_ERROR" + INVALID_SIGNATURE = "INVALID_SIGNATURE" + EXPIRED_TOKEN = "EXPIRED_TOKEN" + MISSING_AUTHENTICATION_TOKEN = "MISSING_AUTHENTICATION_TOKEN" + INTEGRATION_FAILURE = "INTEGRATION_FAILURE" + INTEGRATION_TIMEOUT = "INTEGRATION_TIMEOUT" + API_CONFIGURATION_ERROR = "API_CONFIGURATION_ERROR" + UNSUPPORTED_MEDIA_TYPE = "UNSUPPORTED_MEDIA_TYPE" + BAD_REQUEST_PARAMETERS = "BAD_REQUEST_PARAMETERS" + BAD_REQUEST_BODY = "BAD_REQUEST_BODY" + REQUEST_TOO_LARGE = "REQUEST_TOO_LARGE" + THROTTLED = "THROTTLED" + QUOTA_EXCEEDED = "QUOTA_EXCEEDED" + WAF_FILTERED = "WAF_FILTERED" + + +class IntegrationType(StrEnum): + HTTP = "HTTP" + AWS = "AWS" + MOCK = "MOCK" + HTTP_PROXY = "HTTP_PROXY" + AWS_PROXY = "AWS_PROXY" + + +class IpAddressType(StrEnum): + ipv4 = "ipv4" + dualstack = "dualstack" + + +class LocationStatusType(StrEnum): + DOCUMENTED = "DOCUMENTED" + UNDOCUMENTED = "UNDOCUMENTED" + + +class Op(StrEnum): + add = "add" + remove = "remove" + replace = "replace" + move = "move" + copy = "copy" + test = "test" + + +class PutMode(StrEnum): + merge = "merge" + overwrite = "overwrite" + + +class QuotaPeriodType(StrEnum): + DAY = "DAY" + WEEK = "WEEK" + MONTH = "MONTH" + + +class ResourceOwner(StrEnum): + SELF = "SELF" + OTHER_ACCOUNTS = "OTHER_ACCOUNTS" + + +class RoutingMode(StrEnum): + BASE_PATH_MAPPING_ONLY = "BASE_PATH_MAPPING_ONLY" + ROUTING_RULE_ONLY = "ROUTING_RULE_ONLY" + ROUTING_RULE_THEN_BASE_PATH_MAPPING = "ROUTING_RULE_THEN_BASE_PATH_MAPPING" + + +class SecurityPolicy(StrEnum): + TLS_1_0 = "TLS_1_0" + TLS_1_2 = "TLS_1_2" + + +class UnauthorizedCacheControlHeaderStrategy(StrEnum): + FAIL_WITH_403 = "FAIL_WITH_403" + SUCCEED_WITH_RESPONSE_HEADER = "SUCCEED_WITH_RESPONSE_HEADER" + SUCCEED_WITHOUT_RESPONSE_HEADER = "SUCCEED_WITHOUT_RESPONSE_HEADER" + + +class VpcLinkStatus(StrEnum): + AVAILABLE = "AVAILABLE" + PENDING = "PENDING" + DELETING = "DELETING" + FAILED = "FAILED" + + +class BadRequestException(ServiceException): + code: str = "BadRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 409 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 429 + retryAfterSeconds: Optional[String] + + +class NotFoundException(ServiceException): + code: str = "NotFoundException" + sender_fault: bool = False + status_code: int = 404 + + +class ServiceUnavailableException(ServiceException): + code: str = "ServiceUnavailableException" + sender_fault: bool = False + status_code: int = 503 + retryAfterSeconds: Optional[String] + + +class TooManyRequestsException(ServiceException): + code: str = "TooManyRequestsException" + sender_fault: bool = False + status_code: int = 429 + retryAfterSeconds: Optional[String] + + +class UnauthorizedException(ServiceException): + code: str = "UnauthorizedException" + sender_fault: bool = False + status_code: int = 401 + + +class AccessLogSettings(TypedDict, total=False): + format: Optional[String] + destinationArn: Optional[String] + + +ListOfString = List[String] + + +class ThrottleSettings(TypedDict, total=False): + burstLimit: Optional[Integer] + rateLimit: Optional[Double] + + +class Account(TypedDict, total=False): + cloudwatchRoleArn: Optional[String] + throttleSettings: Optional[ThrottleSettings] + features: Optional[ListOfString] + apiKeyVersion: Optional[String] + + +MapOfStringToString = Dict[String, String] +Timestamp = datetime + + +class ApiKey(TypedDict, total=False): + id: Optional[String] + value: Optional[String] + name: Optional[String] + customerId: Optional[String] + description: Optional[String] + enabled: Optional[Boolean] + createdDate: Optional[Timestamp] + lastUpdatedDate: Optional[Timestamp] + stageKeys: Optional[ListOfString] + tags: Optional[MapOfStringToString] + + +class ApiKeyIds(TypedDict, total=False): + ids: Optional[ListOfString] + warnings: Optional[ListOfString] + + +ListOfApiKey = List[ApiKey] + + +class ApiKeys(TypedDict, total=False): + warnings: Optional[ListOfString] + position: Optional[String] + items: Optional[ListOfApiKey] + + +MapOfApiStageThrottleSettings = Dict[String, ThrottleSettings] + + +class ApiStage(TypedDict, total=False): + apiId: Optional[String] + stage: Optional[String] + throttle: Optional[MapOfApiStageThrottleSettings] + + +ListOfARNs = List[ProviderARN] +Authorizer = TypedDict( + "Authorizer", + { + "id": Optional[String], + "name": Optional[String], + "type": Optional[AuthorizerType], + "providerARNs": Optional[ListOfARNs], + "authType": Optional[String], + "authorizerUri": Optional[String], + "authorizerCredentials": Optional[String], + "identitySource": Optional[String], + "identityValidationExpression": Optional[String], + "authorizerResultTtlInSeconds": Optional[NullableInteger], + }, + total=False, +) +ListOfAuthorizer = List[Authorizer] + + +class Authorizers(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfAuthorizer] + + +class BasePathMapping(TypedDict, total=False): + basePath: Optional[String] + restApiId: Optional[String] + stage: Optional[String] + + +ListOfBasePathMapping = List[BasePathMapping] + + +class BasePathMappings(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfBasePathMapping] + + +Blob = bytes + + +class CanarySettings(TypedDict, total=False): + percentTraffic: Optional[Double] + deploymentId: Optional[String] + stageVariableOverrides: Optional[MapOfStringToString] + useStageCache: Optional[Boolean] + + +class ClientCertificate(TypedDict, total=False): + clientCertificateId: Optional[String] + description: Optional[String] + pemEncodedCertificate: Optional[String] + createdDate: Optional[Timestamp] + expirationDate: Optional[Timestamp] + tags: Optional[MapOfStringToString] + + +ListOfClientCertificate = List[ClientCertificate] + + +class ClientCertificates(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfClientCertificate] + + +class StageKey(TypedDict, total=False): + restApiId: Optional[String] + stageName: Optional[String] + + +ListOfStageKeys = List[StageKey] + + +class CreateApiKeyRequest(ServiceRequest): + name: Optional[String] + description: Optional[String] + enabled: Optional[Boolean] + generateDistinctId: Optional[Boolean] + value: Optional[String] + stageKeys: Optional[ListOfStageKeys] + customerId: Optional[String] + tags: Optional[MapOfStringToString] + + +CreateAuthorizerRequest = TypedDict( + "CreateAuthorizerRequest", + { + "restApiId": String, + "name": String, + "type": AuthorizerType, + "providerARNs": Optional[ListOfARNs], + "authType": Optional[String], + "authorizerUri": Optional[String], + "authorizerCredentials": Optional[String], + "identitySource": Optional[String], + "identityValidationExpression": Optional[String], + "authorizerResultTtlInSeconds": Optional[NullableInteger], + }, + total=False, +) + + +class CreateBasePathMappingRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + basePath: Optional[String] + restApiId: String + stage: Optional[String] + + +class DeploymentCanarySettings(TypedDict, total=False): + percentTraffic: Optional[Double] + stageVariableOverrides: Optional[MapOfStringToString] + useStageCache: Optional[Boolean] + + +class CreateDeploymentRequest(ServiceRequest): + restApiId: String + stageName: Optional[String] + stageDescription: Optional[String] + description: Optional[String] + cacheClusterEnabled: Optional[NullableBoolean] + cacheClusterSize: Optional[CacheClusterSize] + variables: Optional[MapOfStringToString] + canarySettings: Optional[DeploymentCanarySettings] + tracingEnabled: Optional[NullableBoolean] + + +DocumentationPartLocation = TypedDict( + "DocumentationPartLocation", + { + "type": DocumentationPartType, + "path": Optional[String], + "method": Optional[String], + "statusCode": Optional[DocumentationPartLocationStatusCode], + "name": Optional[String], + }, + total=False, +) + + +class CreateDocumentationPartRequest(ServiceRequest): + restApiId: String + location: DocumentationPartLocation + properties: String + + +class CreateDocumentationVersionRequest(ServiceRequest): + restApiId: String + documentationVersion: String + stageName: Optional[String] + description: Optional[String] + + +class CreateDomainNameAccessAssociationRequest(ServiceRequest): + domainNameArn: String + accessAssociationSourceType: AccessAssociationSourceType + accessAssociationSource: String + tags: Optional[MapOfStringToString] + + +class MutualTlsAuthenticationInput(TypedDict, total=False): + truststoreUri: Optional[String] + truststoreVersion: Optional[String] + + +ListOfEndpointType = List[EndpointType] + + +class EndpointConfiguration(TypedDict, total=False): + types: Optional[ListOfEndpointType] + ipAddressType: Optional[IpAddressType] + vpcEndpointIds: Optional[ListOfString] + + +class CreateDomainNameRequest(ServiceRequest): + domainName: String + certificateName: Optional[String] + certificateBody: Optional[String] + certificatePrivateKey: Optional[String] + certificateChain: Optional[String] + certificateArn: Optional[String] + regionalCertificateName: Optional[String] + regionalCertificateArn: Optional[String] + endpointConfiguration: Optional[EndpointConfiguration] + tags: Optional[MapOfStringToString] + securityPolicy: Optional[SecurityPolicy] + mutualTlsAuthentication: Optional[MutualTlsAuthenticationInput] + ownershipVerificationCertificateArn: Optional[String] + policy: Optional[String] + routingMode: Optional[RoutingMode] + + +class CreateModelRequest(ServiceRequest): + restApiId: String + name: String + description: Optional[String] + schema: Optional[String] + contentType: String + + +class CreateRequestValidatorRequest(ServiceRequest): + restApiId: String + name: Optional[String] + validateRequestBody: Optional[Boolean] + validateRequestParameters: Optional[Boolean] + + +class CreateResourceRequest(ServiceRequest): + restApiId: String + parentId: String + pathPart: String + + +class CreateRestApiRequest(ServiceRequest): + name: String + description: Optional[String] + version: Optional[String] + cloneFrom: Optional[String] + binaryMediaTypes: Optional[ListOfString] + minimumCompressionSize: Optional[NullableInteger] + apiKeySource: Optional[ApiKeySourceType] + endpointConfiguration: Optional[EndpointConfiguration] + policy: Optional[String] + tags: Optional[MapOfStringToString] + disableExecuteApiEndpoint: Optional[Boolean] + + +class CreateStageRequest(ServiceRequest): + restApiId: String + stageName: String + deploymentId: String + description: Optional[String] + cacheClusterEnabled: Optional[Boolean] + cacheClusterSize: Optional[CacheClusterSize] + variables: Optional[MapOfStringToString] + documentationVersion: Optional[String] + canarySettings: Optional[CanarySettings] + tracingEnabled: Optional[Boolean] + tags: Optional[MapOfStringToString] + + +class CreateUsagePlanKeyRequest(ServiceRequest): + usagePlanId: String + keyId: String + keyType: String + + +class QuotaSettings(TypedDict, total=False): + limit: Optional[Integer] + offset: Optional[Integer] + period: Optional[QuotaPeriodType] + + +ListOfApiStage = List[ApiStage] + + +class CreateUsagePlanRequest(ServiceRequest): + name: String + description: Optional[String] + apiStages: Optional[ListOfApiStage] + throttle: Optional[ThrottleSettings] + quota: Optional[QuotaSettings] + tags: Optional[MapOfStringToString] + + +class CreateVpcLinkRequest(ServiceRequest): + name: String + description: Optional[String] + targetArns: ListOfString + tags: Optional[MapOfStringToString] + + +class DeleteApiKeyRequest(ServiceRequest): + apiKey: String + + +class DeleteAuthorizerRequest(ServiceRequest): + restApiId: String + authorizerId: String + + +class DeleteBasePathMappingRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + basePath: String + + +class DeleteClientCertificateRequest(ServiceRequest): + clientCertificateId: String + + +class DeleteDeploymentRequest(ServiceRequest): + restApiId: String + deploymentId: String + + +class DeleteDocumentationPartRequest(ServiceRequest): + restApiId: String + documentationPartId: String + + +class DeleteDocumentationVersionRequest(ServiceRequest): + restApiId: String + documentationVersion: String + + +class DeleteDomainNameAccessAssociationRequest(ServiceRequest): + domainNameAccessAssociationArn: String + + +class DeleteDomainNameRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + + +class DeleteGatewayResponseRequest(ServiceRequest): + restApiId: String + responseType: GatewayResponseType + + +class DeleteIntegrationRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + + +class DeleteIntegrationResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + + +class DeleteMethodRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + + +class DeleteMethodResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + + +class DeleteModelRequest(ServiceRequest): + restApiId: String + modelName: String + + +class DeleteRequestValidatorRequest(ServiceRequest): + restApiId: String + requestValidatorId: String + + +class DeleteResourceRequest(ServiceRequest): + restApiId: String + resourceId: String + + +class DeleteRestApiRequest(ServiceRequest): + restApiId: String + + +class DeleteStageRequest(ServiceRequest): + restApiId: String + stageName: String + + +class DeleteUsagePlanKeyRequest(ServiceRequest): + usagePlanId: String + keyId: String + + +class DeleteUsagePlanRequest(ServiceRequest): + usagePlanId: String + + +class DeleteVpcLinkRequest(ServiceRequest): + vpcLinkId: String + + +class MethodSnapshot(TypedDict, total=False): + authorizationType: Optional[String] + apiKeyRequired: Optional[Boolean] + + +MapOfMethodSnapshot = Dict[String, MethodSnapshot] +PathToMapOfMethodSnapshot = Dict[String, MapOfMethodSnapshot] + + +class Deployment(TypedDict, total=False): + id: Optional[String] + description: Optional[String] + createdDate: Optional[Timestamp] + apiSummary: Optional[PathToMapOfMethodSnapshot] + + +ListOfDeployment = List[Deployment] + + +class Deployments(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfDeployment] + + +class DocumentationPart(TypedDict, total=False): + id: Optional[String] + location: Optional[DocumentationPartLocation] + properties: Optional[String] + + +class DocumentationPartIds(TypedDict, total=False): + ids: Optional[ListOfString] + warnings: Optional[ListOfString] + + +ListOfDocumentationPart = List[DocumentationPart] + + +class DocumentationParts(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfDocumentationPart] + + +class DocumentationVersion(TypedDict, total=False): + version: Optional[String] + createdDate: Optional[Timestamp] + description: Optional[String] + + +ListOfDocumentationVersion = List[DocumentationVersion] + + +class DocumentationVersions(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfDocumentationVersion] + + +class MutualTlsAuthentication(TypedDict, total=False): + truststoreUri: Optional[String] + truststoreVersion: Optional[String] + truststoreWarnings: Optional[ListOfString] + + +class DomainName(TypedDict, total=False): + domainName: Optional[String] + domainNameId: Optional[String] + domainNameArn: Optional[String] + certificateName: Optional[String] + certificateArn: Optional[String] + certificateUploadDate: Optional[Timestamp] + regionalDomainName: Optional[String] + regionalHostedZoneId: Optional[String] + regionalCertificateName: Optional[String] + regionalCertificateArn: Optional[String] + distributionDomainName: Optional[String] + distributionHostedZoneId: Optional[String] + endpointConfiguration: Optional[EndpointConfiguration] + domainNameStatus: Optional[DomainNameStatus] + domainNameStatusMessage: Optional[String] + securityPolicy: Optional[SecurityPolicy] + tags: Optional[MapOfStringToString] + mutualTlsAuthentication: Optional[MutualTlsAuthentication] + ownershipVerificationCertificateArn: Optional[String] + managementPolicy: Optional[String] + policy: Optional[String] + routingMode: Optional[RoutingMode] + + +class DomainNameAccessAssociation(TypedDict, total=False): + domainNameAccessAssociationArn: Optional[String] + domainNameArn: Optional[String] + accessAssociationSourceType: Optional[AccessAssociationSourceType] + accessAssociationSource: Optional[String] + tags: Optional[MapOfStringToString] + + +ListOfDomainNameAccessAssociation = List[DomainNameAccessAssociation] + + +class DomainNameAccessAssociations(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfDomainNameAccessAssociation] + + +ListOfDomainName = List[DomainName] + + +class DomainNames(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfDomainName] + + +class ExportResponse(TypedDict, total=False): + body: Optional[Union[Blob, IO[Blob], Iterable[Blob]]] + contentType: Optional[String] + contentDisposition: Optional[String] + + +class FlushStageAuthorizersCacheRequest(ServiceRequest): + restApiId: String + stageName: String + + +class FlushStageCacheRequest(ServiceRequest): + restApiId: String + stageName: String + + +class GatewayResponse(TypedDict, total=False): + responseType: Optional[GatewayResponseType] + statusCode: Optional[StatusCode] + responseParameters: Optional[MapOfStringToString] + responseTemplates: Optional[MapOfStringToString] + defaultResponse: Optional[Boolean] + + +ListOfGatewayResponse = List[GatewayResponse] + + +class GatewayResponses(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfGatewayResponse] + + +class GenerateClientCertificateRequest(ServiceRequest): + description: Optional[String] + tags: Optional[MapOfStringToString] + + +class GetAccountRequest(ServiceRequest): + pass + + +class GetApiKeyRequest(ServiceRequest): + apiKey: String + includeValue: Optional[NullableBoolean] + + +class GetApiKeysRequest(ServiceRequest): + position: Optional[String] + limit: Optional[NullableInteger] + nameQuery: Optional[String] + customerId: Optional[String] + includeValues: Optional[NullableBoolean] + + +class GetAuthorizerRequest(ServiceRequest): + restApiId: String + authorizerId: String + + +class GetAuthorizersRequest(ServiceRequest): + restApiId: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetBasePathMappingRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + basePath: String + + +class GetBasePathMappingsRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetClientCertificateRequest(ServiceRequest): + clientCertificateId: String + + +class GetClientCertificatesRequest(ServiceRequest): + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetDeploymentRequest(ServiceRequest): + restApiId: String + deploymentId: String + embed: Optional[ListOfString] + + +class GetDeploymentsRequest(ServiceRequest): + restApiId: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetDocumentationPartRequest(ServiceRequest): + restApiId: String + documentationPartId: String + + +GetDocumentationPartsRequest = TypedDict( + "GetDocumentationPartsRequest", + { + "restApiId": String, + "type": Optional[DocumentationPartType], + "nameQuery": Optional[String], + "path": Optional[String], + "position": Optional[String], + "limit": Optional[NullableInteger], + "locationStatus": Optional[LocationStatusType], + }, + total=False, +) + + +class GetDocumentationVersionRequest(ServiceRequest): + restApiId: String + documentationVersion: String + + +class GetDocumentationVersionsRequest(ServiceRequest): + restApiId: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetDomainNameAccessAssociationsRequest(ServiceRequest): + position: Optional[String] + limit: Optional[NullableInteger] + resourceOwner: Optional[ResourceOwner] + + +class GetDomainNameRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + + +class GetDomainNamesRequest(ServiceRequest): + position: Optional[String] + limit: Optional[NullableInteger] + resourceOwner: Optional[ResourceOwner] + + +class GetExportRequest(ServiceRequest): + restApiId: String + stageName: String + exportType: String + parameters: Optional[MapOfStringToString] + accepts: Optional[String] + + +class GetGatewayResponseRequest(ServiceRequest): + restApiId: String + responseType: GatewayResponseType + + +class GetGatewayResponsesRequest(ServiceRequest): + restApiId: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetIntegrationRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + + +class GetIntegrationResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + + +class GetMethodRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + + +class GetMethodResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + + +class GetModelRequest(ServiceRequest): + restApiId: String + modelName: String + flatten: Optional[Boolean] + + +class GetModelTemplateRequest(ServiceRequest): + restApiId: String + modelName: String + + +class GetModelsRequest(ServiceRequest): + restApiId: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetRequestValidatorRequest(ServiceRequest): + restApiId: String + requestValidatorId: String + + +class GetRequestValidatorsRequest(ServiceRequest): + restApiId: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetResourceRequest(ServiceRequest): + restApiId: String + resourceId: String + embed: Optional[ListOfString] + + +class GetResourcesRequest(ServiceRequest): + restApiId: String + position: Optional[String] + limit: Optional[NullableInteger] + embed: Optional[ListOfString] + + +class GetRestApiRequest(ServiceRequest): + restApiId: String + + +class GetRestApisRequest(ServiceRequest): + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetSdkRequest(ServiceRequest): + restApiId: String + stageName: String + sdkType: String + parameters: Optional[MapOfStringToString] + + +class GetSdkTypeRequest(ServiceRequest): + id: String + + +class GetSdkTypesRequest(ServiceRequest): + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetStageRequest(ServiceRequest): + restApiId: String + stageName: String + + +class GetStagesRequest(ServiceRequest): + restApiId: String + deploymentId: Optional[String] + + +class GetTagsRequest(ServiceRequest): + resourceArn: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetUsagePlanKeyRequest(ServiceRequest): + usagePlanId: String + keyId: String + + +class GetUsagePlanKeysRequest(ServiceRequest): + usagePlanId: String + position: Optional[String] + limit: Optional[NullableInteger] + nameQuery: Optional[String] + + +class GetUsagePlanRequest(ServiceRequest): + usagePlanId: String + + +class GetUsagePlansRequest(ServiceRequest): + position: Optional[String] + keyId: Optional[String] + limit: Optional[NullableInteger] + + +class GetUsageRequest(ServiceRequest): + usagePlanId: String + keyId: Optional[String] + startDate: String + endDate: String + position: Optional[String] + limit: Optional[NullableInteger] + + +class GetVpcLinkRequest(ServiceRequest): + vpcLinkId: String + + +class GetVpcLinksRequest(ServiceRequest): + position: Optional[String] + limit: Optional[NullableInteger] + + +class ImportApiKeysRequest(ServiceRequest): + body: IO[Blob] + format: ApiKeysFormat + failOnWarnings: Optional[Boolean] + + +class ImportDocumentationPartsRequest(ServiceRequest): + body: IO[Blob] + restApiId: String + mode: Optional[PutMode] + failOnWarnings: Optional[Boolean] + + +class ImportRestApiRequest(ServiceRequest): + body: IO[Blob] + failOnWarnings: Optional[Boolean] + parameters: Optional[MapOfStringToString] + + +class TlsConfig(TypedDict, total=False): + insecureSkipVerification: Optional[Boolean] + + +class IntegrationResponse(TypedDict, total=False): + statusCode: Optional[StatusCode] + selectionPattern: Optional[String] + responseParameters: Optional[MapOfStringToString] + responseTemplates: Optional[MapOfStringToString] + contentHandling: Optional[ContentHandlingStrategy] + + +MapOfIntegrationResponse = Dict[String, IntegrationResponse] +Integration = TypedDict( + "Integration", + { + "type": Optional[IntegrationType], + "httpMethod": Optional[String], + "uri": Optional[String], + "connectionType": Optional[ConnectionType], + "connectionId": Optional[String], + "credentials": Optional[String], + "requestParameters": Optional[MapOfStringToString], + "requestTemplates": Optional[MapOfStringToString], + "passthroughBehavior": Optional[String], + "contentHandling": Optional[ContentHandlingStrategy], + "timeoutInMillis": Optional[Integer], + "cacheNamespace": Optional[String], + "cacheKeyParameters": Optional[ListOfString], + "integrationResponses": Optional[MapOfIntegrationResponse], + "tlsConfig": Optional[TlsConfig], + }, + total=False, +) +Long = int +ListOfLong = List[Long] + + +class Model(TypedDict, total=False): + id: Optional[String] + name: Optional[String] + description: Optional[String] + schema: Optional[String] + contentType: Optional[String] + + +ListOfModel = List[Model] +PatchOperation = TypedDict( + "PatchOperation", + { + "op": Optional[Op], + "path": Optional[String], + "value": Optional[String], + "from": Optional[String], + }, + total=False, +) +ListOfPatchOperation = List[PatchOperation] + + +class RequestValidator(TypedDict, total=False): + id: Optional[String] + name: Optional[String] + validateRequestBody: Optional[Boolean] + validateRequestParameters: Optional[Boolean] + + +ListOfRequestValidator = List[RequestValidator] +MapOfStringToBoolean = Dict[String, NullableBoolean] + + +class MethodResponse(TypedDict, total=False): + statusCode: Optional[StatusCode] + responseParameters: Optional[MapOfStringToBoolean] + responseModels: Optional[MapOfStringToString] + + +MapOfMethodResponse = Dict[String, MethodResponse] + + +class Method(TypedDict, total=False): + httpMethod: Optional[String] + authorizationType: Optional[String] + authorizerId: Optional[String] + apiKeyRequired: Optional[NullableBoolean] + requestValidatorId: Optional[String] + operationName: Optional[String] + requestParameters: Optional[MapOfStringToBoolean] + requestModels: Optional[MapOfStringToString] + methodResponses: Optional[MapOfMethodResponse] + methodIntegration: Optional[Integration] + authorizationScopes: Optional[ListOfString] + + +MapOfMethod = Dict[String, Method] + + +class Resource(TypedDict, total=False): + id: Optional[String] + parentId: Optional[String] + pathPart: Optional[String] + path: Optional[String] + resourceMethods: Optional[MapOfMethod] + + +ListOfResource = List[Resource] + + +class RestApi(TypedDict, total=False): + id: Optional[String] + name: Optional[String] + description: Optional[String] + createdDate: Optional[Timestamp] + version: Optional[String] + warnings: Optional[ListOfString] + binaryMediaTypes: Optional[ListOfString] + minimumCompressionSize: Optional[NullableInteger] + apiKeySource: Optional[ApiKeySourceType] + endpointConfiguration: Optional[EndpointConfiguration] + policy: Optional[String] + tags: Optional[MapOfStringToString] + disableExecuteApiEndpoint: Optional[Boolean] + rootResourceId: Optional[String] + + +ListOfRestApi = List[RestApi] + + +class SdkConfigurationProperty(TypedDict, total=False): + name: Optional[String] + friendlyName: Optional[String] + description: Optional[String] + required: Optional[Boolean] + defaultValue: Optional[String] + + +ListOfSdkConfigurationProperty = List[SdkConfigurationProperty] + + +class SdkType(TypedDict, total=False): + id: Optional[String] + friendlyName: Optional[String] + description: Optional[String] + configurationProperties: Optional[ListOfSdkConfigurationProperty] + + +ListOfSdkType = List[SdkType] + + +class MethodSetting(TypedDict, total=False): + metricsEnabled: Optional[Boolean] + loggingLevel: Optional[String] + dataTraceEnabled: Optional[Boolean] + throttlingBurstLimit: Optional[Integer] + throttlingRateLimit: Optional[Double] + cachingEnabled: Optional[Boolean] + cacheTtlInSeconds: Optional[Integer] + cacheDataEncrypted: Optional[Boolean] + requireAuthorizationForCacheControl: Optional[Boolean] + unauthorizedCacheControlHeaderStrategy: Optional[UnauthorizedCacheControlHeaderStrategy] + + +MapOfMethodSettings = Dict[String, MethodSetting] + + +class Stage(TypedDict, total=False): + deploymentId: Optional[String] + clientCertificateId: Optional[String] + stageName: Optional[String] + description: Optional[String] + cacheClusterEnabled: Optional[Boolean] + cacheClusterSize: Optional[CacheClusterSize] + cacheClusterStatus: Optional[CacheClusterStatus] + methodSettings: Optional[MapOfMethodSettings] + variables: Optional[MapOfStringToString] + documentationVersion: Optional[String] + accessLogSettings: Optional[AccessLogSettings] + canarySettings: Optional[CanarySettings] + tracingEnabled: Optional[Boolean] + webAclArn: Optional[String] + tags: Optional[MapOfStringToString] + createdDate: Optional[Timestamp] + lastUpdatedDate: Optional[Timestamp] + + +ListOfStage = List[Stage] +ListOfUsage = List[ListOfLong] + + +class UsagePlan(TypedDict, total=False): + id: Optional[String] + name: Optional[String] + description: Optional[String] + apiStages: Optional[ListOfApiStage] + throttle: Optional[ThrottleSettings] + quota: Optional[QuotaSettings] + productCode: Optional[String] + tags: Optional[MapOfStringToString] + + +ListOfUsagePlan = List[UsagePlan] +UsagePlanKey = TypedDict( + "UsagePlanKey", + { + "id": Optional[String], + "type": Optional[String], + "value": Optional[String], + "name": Optional[String], + }, + total=False, +) +ListOfUsagePlanKey = List[UsagePlanKey] + + +class VpcLink(TypedDict, total=False): + id: Optional[String] + name: Optional[String] + description: Optional[String] + targetArns: Optional[ListOfString] + status: Optional[VpcLinkStatus] + statusMessage: Optional[String] + tags: Optional[MapOfStringToString] + + +ListOfVpcLink = List[VpcLink] +MapOfKeyUsages = Dict[String, ListOfUsage] +MapOfStringToList = Dict[String, ListOfString] + + +class Models(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfModel] + + +class PutGatewayResponseRequest(ServiceRequest): + restApiId: String + responseType: GatewayResponseType + statusCode: Optional[StatusCode] + responseParameters: Optional[MapOfStringToString] + responseTemplates: Optional[MapOfStringToString] + + +PutIntegrationRequest = TypedDict( + "PutIntegrationRequest", + { + "restApiId": String, + "resourceId": String, + "httpMethod": String, + "type": IntegrationType, + "integrationHttpMethod": Optional[String], + "uri": Optional[String], + "connectionType": Optional[ConnectionType], + "connectionId": Optional[String], + "credentials": Optional[String], + "requestParameters": Optional[MapOfStringToString], + "requestTemplates": Optional[MapOfStringToString], + "passthroughBehavior": Optional[String], + "cacheNamespace": Optional[String], + "cacheKeyParameters": Optional[ListOfString], + "contentHandling": Optional[ContentHandlingStrategy], + "timeoutInMillis": Optional[NullableInteger], + "tlsConfig": Optional[TlsConfig], + }, + total=False, +) + + +class PutIntegrationResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + selectionPattern: Optional[String] + responseParameters: Optional[MapOfStringToString] + responseTemplates: Optional[MapOfStringToString] + contentHandling: Optional[ContentHandlingStrategy] + + +class PutMethodRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + authorizationType: String + authorizerId: Optional[String] + apiKeyRequired: Optional[Boolean] + operationName: Optional[String] + requestParameters: Optional[MapOfStringToBoolean] + requestModels: Optional[MapOfStringToString] + requestValidatorId: Optional[String] + authorizationScopes: Optional[ListOfString] + + +class PutMethodResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + responseParameters: Optional[MapOfStringToBoolean] + responseModels: Optional[MapOfStringToString] + + +class PutRestApiRequest(ServiceRequest): + body: IO[Blob] + restApiId: String + mode: Optional[PutMode] + failOnWarnings: Optional[Boolean] + parameters: Optional[MapOfStringToString] + + +class RejectDomainNameAccessAssociationRequest(ServiceRequest): + domainNameAccessAssociationArn: String + domainNameArn: String + + +class RequestValidators(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfRequestValidator] + + +class Resources(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfResource] + + +class RestApis(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfRestApi] + + +class SdkResponse(TypedDict, total=False): + body: Optional[Union[Blob, IO[Blob], Iterable[Blob]]] + contentType: Optional[String] + contentDisposition: Optional[String] + + +class SdkTypes(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfSdkType] + + +class Stages(TypedDict, total=False): + item: Optional[ListOfStage] + + +class TagResourceRequest(ServiceRequest): + resourceArn: String + tags: MapOfStringToString + + +class Tags(TypedDict, total=False): + tags: Optional[MapOfStringToString] + + +class Template(TypedDict, total=False): + value: Optional[String] + + +class TestInvokeAuthorizerRequest(ServiceRequest): + restApiId: String + authorizerId: String + headers: Optional[MapOfStringToString] + multiValueHeaders: Optional[MapOfStringToList] + pathWithQueryString: Optional[String] + body: Optional[String] + stageVariables: Optional[MapOfStringToString] + additionalContext: Optional[MapOfStringToString] + + +class TestInvokeAuthorizerResponse(TypedDict, total=False): + clientStatus: Optional[Integer] + log: Optional[String] + latency: Optional[Long] + principalId: Optional[String] + policy: Optional[String] + authorization: Optional[MapOfStringToList] + claims: Optional[MapOfStringToString] + + +class TestInvokeMethodRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + pathWithQueryString: Optional[String] + body: Optional[String] + headers: Optional[MapOfStringToString] + multiValueHeaders: Optional[MapOfStringToList] + clientCertificateId: Optional[String] + stageVariables: Optional[MapOfStringToString] + + +class TestInvokeMethodResponse(TypedDict, total=False): + status: Optional[Integer] + body: Optional[String] + headers: Optional[MapOfStringToString] + multiValueHeaders: Optional[MapOfStringToList] + log: Optional[String] + latency: Optional[Long] + + +class UntagResourceRequest(ServiceRequest): + resourceArn: String + tagKeys: ListOfString + + +class UpdateAccountRequest(ServiceRequest): + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateApiKeyRequest(ServiceRequest): + apiKey: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateAuthorizerRequest(ServiceRequest): + restApiId: String + authorizerId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateBasePathMappingRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + basePath: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateClientCertificateRequest(ServiceRequest): + clientCertificateId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateDeploymentRequest(ServiceRequest): + restApiId: String + deploymentId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateDocumentationPartRequest(ServiceRequest): + restApiId: String + documentationPartId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateDocumentationVersionRequest(ServiceRequest): + restApiId: String + documentationVersion: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateDomainNameRequest(ServiceRequest): + domainName: String + domainNameId: Optional[String] + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateGatewayResponseRequest(ServiceRequest): + restApiId: String + responseType: GatewayResponseType + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateIntegrationRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateIntegrationResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateMethodRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateMethodResponseRequest(ServiceRequest): + restApiId: String + resourceId: String + httpMethod: String + statusCode: StatusCode + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateModelRequest(ServiceRequest): + restApiId: String + modelName: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateRequestValidatorRequest(ServiceRequest): + restApiId: String + requestValidatorId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateResourceRequest(ServiceRequest): + restApiId: String + resourceId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateRestApiRequest(ServiceRequest): + restApiId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateStageRequest(ServiceRequest): + restApiId: String + stageName: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateUsagePlanRequest(ServiceRequest): + usagePlanId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateUsageRequest(ServiceRequest): + usagePlanId: String + keyId: String + patchOperations: Optional[ListOfPatchOperation] + + +class UpdateVpcLinkRequest(ServiceRequest): + vpcLinkId: String + patchOperations: Optional[ListOfPatchOperation] + + +class Usage(TypedDict, total=False): + usagePlanId: Optional[String] + startDate: Optional[String] + endDate: Optional[String] + position: Optional[String] + items: Optional[MapOfKeyUsages] + + +class UsagePlanKeys(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfUsagePlanKey] + + +class UsagePlans(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfUsagePlan] + + +class VpcLinks(TypedDict, total=False): + position: Optional[String] + items: Optional[ListOfVpcLink] + + +class ApigatewayApi: + service = "apigateway" + version = "2015-07-09" + + @handler("CreateApiKey") + def create_api_key( + self, + context: RequestContext, + name: String | None = None, + description: String | None = None, + enabled: Boolean | None = None, + generate_distinct_id: Boolean | None = None, + value: String | None = None, + stage_keys: ListOfStageKeys | None = None, + customer_id: String | None = None, + tags: MapOfStringToString | None = None, + **kwargs, + ) -> ApiKey: + raise NotImplementedError + + @handler("CreateAuthorizer", expand=False) + def create_authorizer( + self, context: RequestContext, request: CreateAuthorizerRequest, **kwargs + ) -> Authorizer: + raise NotImplementedError + + @handler("CreateBasePathMapping") + def create_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + rest_api_id: String, + domain_name_id: String | None = None, + base_path: String | None = None, + stage: String | None = None, + **kwargs, + ) -> BasePathMapping: + raise NotImplementedError + + @handler("CreateDeployment") + def create_deployment( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String | None = None, + stage_description: String | None = None, + description: String | None = None, + cache_cluster_enabled: NullableBoolean | None = None, + cache_cluster_size: CacheClusterSize | None = None, + variables: MapOfStringToString | None = None, + canary_settings: DeploymentCanarySettings | None = None, + tracing_enabled: NullableBoolean | None = None, + **kwargs, + ) -> Deployment: + raise NotImplementedError + + @handler("CreateDocumentationPart") + def create_documentation_part( + self, + context: RequestContext, + rest_api_id: String, + location: DocumentationPartLocation, + properties: String, + **kwargs, + ) -> DocumentationPart: + raise NotImplementedError + + @handler("CreateDocumentationVersion") + def create_documentation_version( + self, + context: RequestContext, + rest_api_id: String, + documentation_version: String, + stage_name: String | None = None, + description: String | None = None, + **kwargs, + ) -> DocumentationVersion: + raise NotImplementedError + + @handler("CreateDomainName") + def create_domain_name( + self, + context: RequestContext, + domain_name: String, + certificate_name: String | None = None, + certificate_body: String | None = None, + certificate_private_key: String | None = None, + certificate_chain: String | None = None, + certificate_arn: String | None = None, + regional_certificate_name: String | None = None, + regional_certificate_arn: String | None = None, + endpoint_configuration: EndpointConfiguration | None = None, + tags: MapOfStringToString | None = None, + security_policy: SecurityPolicy | None = None, + mutual_tls_authentication: MutualTlsAuthenticationInput | None = None, + ownership_verification_certificate_arn: String | None = None, + policy: String | None = None, + routing_mode: RoutingMode | None = None, + **kwargs, + ) -> DomainName: + raise NotImplementedError + + @handler("CreateDomainNameAccessAssociation") + def create_domain_name_access_association( + self, + context: RequestContext, + domain_name_arn: String, + access_association_source_type: AccessAssociationSourceType, + access_association_source: String, + tags: MapOfStringToString | None = None, + **kwargs, + ) -> DomainNameAccessAssociation: + raise NotImplementedError + + @handler("CreateModel") + def create_model( + self, + context: RequestContext, + rest_api_id: String, + name: String, + content_type: String, + description: String | None = None, + schema: String | None = None, + **kwargs, + ) -> Model: + raise NotImplementedError + + @handler("CreateRequestValidator") + def create_request_validator( + self, + context: RequestContext, + rest_api_id: String, + name: String | None = None, + validate_request_body: Boolean | None = None, + validate_request_parameters: Boolean | None = None, + **kwargs, + ) -> RequestValidator: + raise NotImplementedError + + @handler("CreateResource") + def create_resource( + self, + context: RequestContext, + rest_api_id: String, + parent_id: String, + path_part: String, + **kwargs, + ) -> Resource: + raise NotImplementedError + + @handler("CreateRestApi") + def create_rest_api( + self, + context: RequestContext, + name: String, + description: String | None = None, + version: String | None = None, + clone_from: String | None = None, + binary_media_types: ListOfString | None = None, + minimum_compression_size: NullableInteger | None = None, + api_key_source: ApiKeySourceType | None = None, + endpoint_configuration: EndpointConfiguration | None = None, + policy: String | None = None, + tags: MapOfStringToString | None = None, + disable_execute_api_endpoint: Boolean | None = None, + **kwargs, + ) -> RestApi: + raise NotImplementedError + + @handler("CreateStage") + def create_stage( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String, + deployment_id: String, + description: String | None = None, + cache_cluster_enabled: Boolean | None = None, + cache_cluster_size: CacheClusterSize | None = None, + variables: MapOfStringToString | None = None, + documentation_version: String | None = None, + canary_settings: CanarySettings | None = None, + tracing_enabled: Boolean | None = None, + tags: MapOfStringToString | None = None, + **kwargs, + ) -> Stage: + raise NotImplementedError + + @handler("CreateUsagePlan") + def create_usage_plan( + self, + context: RequestContext, + name: String, + description: String | None = None, + api_stages: ListOfApiStage | None = None, + throttle: ThrottleSettings | None = None, + quota: QuotaSettings | None = None, + tags: MapOfStringToString | None = None, + **kwargs, + ) -> UsagePlan: + raise NotImplementedError + + @handler("CreateUsagePlanKey") + def create_usage_plan_key( + self, + context: RequestContext, + usage_plan_id: String, + key_id: String, + key_type: String, + **kwargs, + ) -> UsagePlanKey: + raise NotImplementedError + + @handler("CreateVpcLink") + def create_vpc_link( + self, + context: RequestContext, + name: String, + target_arns: ListOfString, + description: String | None = None, + tags: MapOfStringToString | None = None, + **kwargs, + ) -> VpcLink: + raise NotImplementedError + + @handler("DeleteApiKey") + def delete_api_key(self, context: RequestContext, api_key: String, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteAuthorizer") + def delete_authorizer( + self, context: RequestContext, rest_api_id: String, authorizer_id: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteBasePathMapping") + def delete_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + base_path: String, + domain_name_id: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteClientCertificate") + def delete_client_certificate( + self, context: RequestContext, client_certificate_id: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDeployment") + def delete_deployment( + self, context: RequestContext, rest_api_id: String, deployment_id: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDocumentationPart") + def delete_documentation_part( + self, context: RequestContext, rest_api_id: String, documentation_part_id: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDocumentationVersion") + def delete_documentation_version( + self, context: RequestContext, rest_api_id: String, documentation_version: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDomainName") + def delete_domain_name( + self, + context: RequestContext, + domain_name: String, + domain_name_id: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteDomainNameAccessAssociation") + def delete_domain_name_access_association( + self, context: RequestContext, domain_name_access_association_arn: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteGatewayResponse") + def delete_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteIntegration") + def delete_integration( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteIntegrationResponse") + def delete_integration_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteMethod") + def delete_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteMethodResponse") + def delete_method_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteModel") + def delete_model( + self, context: RequestContext, rest_api_id: String, model_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteRequestValidator") + def delete_request_validator( + self, context: RequestContext, rest_api_id: String, request_validator_id: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteResource") + def delete_resource( + self, context: RequestContext, rest_api_id: String, resource_id: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteRestApi") + def delete_rest_api(self, context: RequestContext, rest_api_id: String, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteStage") + def delete_stage( + self, context: RequestContext, rest_api_id: String, stage_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteUsagePlan") + def delete_usage_plan(self, context: RequestContext, usage_plan_id: String, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteUsagePlanKey") + def delete_usage_plan_key( + self, context: RequestContext, usage_plan_id: String, key_id: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteVpcLink") + def delete_vpc_link(self, context: RequestContext, vpc_link_id: String, **kwargs) -> None: + raise NotImplementedError + + @handler("FlushStageAuthorizersCache") + def flush_stage_authorizers_cache( + self, context: RequestContext, rest_api_id: String, stage_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("FlushStageCache") + def flush_stage_cache( + self, context: RequestContext, rest_api_id: String, stage_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("GenerateClientCertificate") + def generate_client_certificate( + self, + context: RequestContext, + description: String | None = None, + tags: MapOfStringToString | None = None, + **kwargs, + ) -> ClientCertificate: + raise NotImplementedError + + @handler("GetAccount") + def get_account(self, context: RequestContext, **kwargs) -> Account: + raise NotImplementedError + + @handler("GetApiKey") + def get_api_key( + self, + context: RequestContext, + api_key: String, + include_value: NullableBoolean | None = None, + **kwargs, + ) -> ApiKey: + raise NotImplementedError + + @handler("GetApiKeys") + def get_api_keys( + self, + context: RequestContext, + position: String | None = None, + limit: NullableInteger | None = None, + name_query: String | None = None, + customer_id: String | None = None, + include_values: NullableBoolean | None = None, + **kwargs, + ) -> ApiKeys: + raise NotImplementedError + + @handler("GetAuthorizer") + def get_authorizer( + self, context: RequestContext, rest_api_id: String, authorizer_id: String, **kwargs + ) -> Authorizer: + raise NotImplementedError + + @handler("GetAuthorizers") + def get_authorizers( + self, + context: RequestContext, + rest_api_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> Authorizers: + raise NotImplementedError + + @handler("GetBasePathMapping") + def get_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + base_path: String, + domain_name_id: String | None = None, + **kwargs, + ) -> BasePathMapping: + raise NotImplementedError + + @handler("GetBasePathMappings") + def get_base_path_mappings( + self, + context: RequestContext, + domain_name: String, + domain_name_id: String | None = None, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> BasePathMappings: + raise NotImplementedError + + @handler("GetClientCertificate") + def get_client_certificate( + self, context: RequestContext, client_certificate_id: String, **kwargs + ) -> ClientCertificate: + raise NotImplementedError + + @handler("GetClientCertificates") + def get_client_certificates( + self, + context: RequestContext, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> ClientCertificates: + raise NotImplementedError + + @handler("GetDeployment") + def get_deployment( + self, + context: RequestContext, + rest_api_id: String, + deployment_id: String, + embed: ListOfString | None = None, + **kwargs, + ) -> Deployment: + raise NotImplementedError + + @handler("GetDeployments") + def get_deployments( + self, + context: RequestContext, + rest_api_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> Deployments: + raise NotImplementedError + + @handler("GetDocumentationPart") + def get_documentation_part( + self, context: RequestContext, rest_api_id: String, documentation_part_id: String, **kwargs + ) -> DocumentationPart: + raise NotImplementedError + + @handler("GetDocumentationParts", expand=False) + def get_documentation_parts( + self, context: RequestContext, request: GetDocumentationPartsRequest, **kwargs + ) -> DocumentationParts: + raise NotImplementedError + + @handler("GetDocumentationVersion") + def get_documentation_version( + self, context: RequestContext, rest_api_id: String, documentation_version: String, **kwargs + ) -> DocumentationVersion: + raise NotImplementedError + + @handler("GetDocumentationVersions") + def get_documentation_versions( + self, + context: RequestContext, + rest_api_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> DocumentationVersions: + raise NotImplementedError + + @handler("GetDomainName") + def get_domain_name( + self, + context: RequestContext, + domain_name: String, + domain_name_id: String | None = None, + **kwargs, + ) -> DomainName: + raise NotImplementedError + + @handler("GetDomainNameAccessAssociations") + def get_domain_name_access_associations( + self, + context: RequestContext, + position: String | None = None, + limit: NullableInteger | None = None, + resource_owner: ResourceOwner | None = None, + **kwargs, + ) -> DomainNameAccessAssociations: + raise NotImplementedError + + @handler("GetDomainNames") + def get_domain_names( + self, + context: RequestContext, + position: String | None = None, + limit: NullableInteger | None = None, + resource_owner: ResourceOwner | None = None, + **kwargs, + ) -> DomainNames: + raise NotImplementedError + + @handler("GetExport") + def get_export( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String, + export_type: String, + parameters: MapOfStringToString | None = None, + accepts: String | None = None, + **kwargs, + ) -> ExportResponse: + raise NotImplementedError + + @handler("GetGatewayResponse") + def get_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + **kwargs, + ) -> GatewayResponse: + raise NotImplementedError + + @handler("GetGatewayResponses") + def get_gateway_responses( + self, + context: RequestContext, + rest_api_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> GatewayResponses: + raise NotImplementedError + + @handler("GetIntegration") + def get_integration( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> Integration: + raise NotImplementedError + + @handler("GetIntegrationResponse") + def get_integration_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + **kwargs, + ) -> IntegrationResponse: + raise NotImplementedError + + @handler("GetMethod") + def get_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> Method: + raise NotImplementedError + + @handler("GetMethodResponse") + def get_method_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + **kwargs, + ) -> MethodResponse: + raise NotImplementedError + + @handler("GetModel") + def get_model( + self, + context: RequestContext, + rest_api_id: String, + model_name: String, + flatten: Boolean | None = None, + **kwargs, + ) -> Model: + raise NotImplementedError + + @handler("GetModelTemplate") + def get_model_template( + self, context: RequestContext, rest_api_id: String, model_name: String, **kwargs + ) -> Template: + raise NotImplementedError + + @handler("GetModels") + def get_models( + self, + context: RequestContext, + rest_api_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> Models: + raise NotImplementedError + + @handler("GetRequestValidator") + def get_request_validator( + self, context: RequestContext, rest_api_id: String, request_validator_id: String, **kwargs + ) -> RequestValidator: + raise NotImplementedError + + @handler("GetRequestValidators") + def get_request_validators( + self, + context: RequestContext, + rest_api_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> RequestValidators: + raise NotImplementedError + + @handler("GetResource") + def get_resource( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + embed: ListOfString | None = None, + **kwargs, + ) -> Resource: + raise NotImplementedError + + @handler("GetResources") + def get_resources( + self, + context: RequestContext, + rest_api_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + embed: ListOfString | None = None, + **kwargs, + ) -> Resources: + raise NotImplementedError + + @handler("GetRestApi") + def get_rest_api(self, context: RequestContext, rest_api_id: String, **kwargs) -> RestApi: + raise NotImplementedError + + @handler("GetRestApis") + def get_rest_apis( + self, + context: RequestContext, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> RestApis: + raise NotImplementedError + + @handler("GetSdk") + def get_sdk( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String, + sdk_type: String, + parameters: MapOfStringToString | None = None, + **kwargs, + ) -> SdkResponse: + raise NotImplementedError + + @handler("GetSdkType") + def get_sdk_type(self, context: RequestContext, id: String, **kwargs) -> SdkType: + raise NotImplementedError + + @handler("GetSdkTypes") + def get_sdk_types( + self, + context: RequestContext, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> SdkTypes: + raise NotImplementedError + + @handler("GetStage") + def get_stage( + self, context: RequestContext, rest_api_id: String, stage_name: String, **kwargs + ) -> Stage: + raise NotImplementedError + + @handler("GetStages") + def get_stages( + self, + context: RequestContext, + rest_api_id: String, + deployment_id: String | None = None, + **kwargs, + ) -> Stages: + raise NotImplementedError + + @handler("GetTags") + def get_tags( + self, + context: RequestContext, + resource_arn: String, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> Tags: + raise NotImplementedError + + @handler("GetUsage") + def get_usage( + self, + context: RequestContext, + usage_plan_id: String, + start_date: String, + end_date: String, + key_id: String | None = None, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> Usage: + raise NotImplementedError + + @handler("GetUsagePlan") + def get_usage_plan(self, context: RequestContext, usage_plan_id: String, **kwargs) -> UsagePlan: + raise NotImplementedError + + @handler("GetUsagePlanKey") + def get_usage_plan_key( + self, context: RequestContext, usage_plan_id: String, key_id: String, **kwargs + ) -> UsagePlanKey: + raise NotImplementedError + + @handler("GetUsagePlanKeys") + def get_usage_plan_keys( + self, + context: RequestContext, + usage_plan_id: String, + position: String | None = None, + limit: NullableInteger | None = None, + name_query: String | None = None, + **kwargs, + ) -> UsagePlanKeys: + raise NotImplementedError + + @handler("GetUsagePlans") + def get_usage_plans( + self, + context: RequestContext, + position: String | None = None, + key_id: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> UsagePlans: + raise NotImplementedError + + @handler("GetVpcLink") + def get_vpc_link(self, context: RequestContext, vpc_link_id: String, **kwargs) -> VpcLink: + raise NotImplementedError + + @handler("GetVpcLinks") + def get_vpc_links( + self, + context: RequestContext, + position: String | None = None, + limit: NullableInteger | None = None, + **kwargs, + ) -> VpcLinks: + raise NotImplementedError + + @handler("ImportApiKeys") + def import_api_keys( + self, + context: RequestContext, + body: IO[Blob], + format: ApiKeysFormat, + fail_on_warnings: Boolean | None = None, + **kwargs, + ) -> ApiKeyIds: + raise NotImplementedError + + @handler("ImportDocumentationParts") + def import_documentation_parts( + self, + context: RequestContext, + rest_api_id: String, + body: IO[Blob], + mode: PutMode | None = None, + fail_on_warnings: Boolean | None = None, + **kwargs, + ) -> DocumentationPartIds: + raise NotImplementedError + + @handler("ImportRestApi") + def import_rest_api( + self, + context: RequestContext, + body: IO[Blob], + fail_on_warnings: Boolean | None = None, + parameters: MapOfStringToString | None = None, + **kwargs, + ) -> RestApi: + raise NotImplementedError + + @handler("PutGatewayResponse") + def put_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + status_code: StatusCode | None = None, + response_parameters: MapOfStringToString | None = None, + response_templates: MapOfStringToString | None = None, + **kwargs, + ) -> GatewayResponse: + raise NotImplementedError + + @handler("PutIntegration", expand=False) + def put_integration( + self, context: RequestContext, request: PutIntegrationRequest, **kwargs + ) -> Integration: + raise NotImplementedError + + @handler("PutIntegrationResponse") + def put_integration_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + selection_pattern: String | None = None, + response_parameters: MapOfStringToString | None = None, + response_templates: MapOfStringToString | None = None, + content_handling: ContentHandlingStrategy | None = None, + **kwargs, + ) -> IntegrationResponse: + raise NotImplementedError + + @handler("PutMethod") + def put_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + authorization_type: String, + authorizer_id: String | None = None, + api_key_required: Boolean | None = None, + operation_name: String | None = None, + request_parameters: MapOfStringToBoolean | None = None, + request_models: MapOfStringToString | None = None, + request_validator_id: String | None = None, + authorization_scopes: ListOfString | None = None, + **kwargs, + ) -> Method: + raise NotImplementedError + + @handler("PutMethodResponse") + def put_method_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + response_parameters: MapOfStringToBoolean | None = None, + response_models: MapOfStringToString | None = None, + **kwargs, + ) -> MethodResponse: + raise NotImplementedError + + @handler("PutRestApi") + def put_rest_api( + self, + context: RequestContext, + rest_api_id: String, + body: IO[Blob], + mode: PutMode | None = None, + fail_on_warnings: Boolean | None = None, + parameters: MapOfStringToString | None = None, + **kwargs, + ) -> RestApi: + raise NotImplementedError + + @handler("RejectDomainNameAccessAssociation") + def reject_domain_name_access_association( + self, + context: RequestContext, + domain_name_access_association_arn: String, + domain_name_arn: String, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: String, tags: MapOfStringToString, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TestInvokeAuthorizer") + def test_invoke_authorizer( + self, + context: RequestContext, + rest_api_id: String, + authorizer_id: String, + headers: MapOfStringToString | None = None, + multi_value_headers: MapOfStringToList | None = None, + path_with_query_string: String | None = None, + body: String | None = None, + stage_variables: MapOfStringToString | None = None, + additional_context: MapOfStringToString | None = None, + **kwargs, + ) -> TestInvokeAuthorizerResponse: + raise NotImplementedError + + @handler("TestInvokeMethod") + def test_invoke_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + path_with_query_string: String | None = None, + body: String | None = None, + headers: MapOfStringToString | None = None, + multi_value_headers: MapOfStringToList | None = None, + client_certificate_id: String | None = None, + stage_variables: MapOfStringToString | None = None, + **kwargs, + ) -> TestInvokeMethodResponse: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: String, tag_keys: ListOfString, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdateAccount") + def update_account( + self, + context: RequestContext, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Account: + raise NotImplementedError + + @handler("UpdateApiKey") + def update_api_key( + self, + context: RequestContext, + api_key: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> ApiKey: + raise NotImplementedError + + @handler("UpdateAuthorizer") + def update_authorizer( + self, + context: RequestContext, + rest_api_id: String, + authorizer_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Authorizer: + raise NotImplementedError + + @handler("UpdateBasePathMapping") + def update_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + base_path: String, + domain_name_id: String | None = None, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> BasePathMapping: + raise NotImplementedError + + @handler("UpdateClientCertificate") + def update_client_certificate( + self, + context: RequestContext, + client_certificate_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> ClientCertificate: + raise NotImplementedError + + @handler("UpdateDeployment") + def update_deployment( + self, + context: RequestContext, + rest_api_id: String, + deployment_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Deployment: + raise NotImplementedError + + @handler("UpdateDocumentationPart") + def update_documentation_part( + self, + context: RequestContext, + rest_api_id: String, + documentation_part_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> DocumentationPart: + raise NotImplementedError + + @handler("UpdateDocumentationVersion") + def update_documentation_version( + self, + context: RequestContext, + rest_api_id: String, + documentation_version: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> DocumentationVersion: + raise NotImplementedError + + @handler("UpdateDomainName") + def update_domain_name( + self, + context: RequestContext, + domain_name: String, + domain_name_id: String | None = None, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> DomainName: + raise NotImplementedError + + @handler("UpdateGatewayResponse") + def update_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> GatewayResponse: + raise NotImplementedError + + @handler("UpdateIntegration") + def update_integration( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Integration: + raise NotImplementedError + + @handler("UpdateIntegrationResponse") + def update_integration_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> IntegrationResponse: + raise NotImplementedError + + @handler("UpdateMethod") + def update_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Method: + raise NotImplementedError + + @handler("UpdateMethodResponse") + def update_method_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> MethodResponse: + raise NotImplementedError + + @handler("UpdateModel") + def update_model( + self, + context: RequestContext, + rest_api_id: String, + model_name: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Model: + raise NotImplementedError + + @handler("UpdateRequestValidator") + def update_request_validator( + self, + context: RequestContext, + rest_api_id: String, + request_validator_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> RequestValidator: + raise NotImplementedError + + @handler("UpdateResource") + def update_resource( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Resource: + raise NotImplementedError + + @handler("UpdateRestApi") + def update_rest_api( + self, + context: RequestContext, + rest_api_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> RestApi: + raise NotImplementedError + + @handler("UpdateStage") + def update_stage( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Stage: + raise NotImplementedError + + @handler("UpdateUsage") + def update_usage( + self, + context: RequestContext, + usage_plan_id: String, + key_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> Usage: + raise NotImplementedError + + @handler("UpdateUsagePlan") + def update_usage_plan( + self, + context: RequestContext, + usage_plan_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> UsagePlan: + raise NotImplementedError + + @handler("UpdateVpcLink") + def update_vpc_link( + self, + context: RequestContext, + vpc_link_id: String, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> VpcLink: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/cloudcontrol/__init__.py b/localstack-core/localstack/aws/api/cloudcontrol/__init__.py new file mode 100644 index 0000000000000..7420a35c50e8c --- /dev/null +++ b/localstack-core/localstack/aws/api/cloudcontrol/__init__.py @@ -0,0 +1,420 @@ +from datetime import datetime +from enum import StrEnum +from typing import List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ClientToken = str +ErrorMessage = str +HandlerNextToken = str +HookFailureMode = str +HookInvocationPoint = str +HookStatus = str +HookTypeArn = str +Identifier = str +MaxResults = int +NextToken = str +PatchDocument = str +Properties = str +RequestToken = str +RoleArn = str +StatusMessage = str +TypeName = str +TypeVersionId = str + + +class HandlerErrorCode(StrEnum): + NotUpdatable = "NotUpdatable" + InvalidRequest = "InvalidRequest" + AccessDenied = "AccessDenied" + UnauthorizedTaggingOperation = "UnauthorizedTaggingOperation" + InvalidCredentials = "InvalidCredentials" + AlreadyExists = "AlreadyExists" + NotFound = "NotFound" + ResourceConflict = "ResourceConflict" + Throttling = "Throttling" + ServiceLimitExceeded = "ServiceLimitExceeded" + NotStabilized = "NotStabilized" + GeneralServiceException = "GeneralServiceException" + ServiceInternalError = "ServiceInternalError" + ServiceTimeout = "ServiceTimeout" + NetworkFailure = "NetworkFailure" + InternalFailure = "InternalFailure" + + +class Operation(StrEnum): + CREATE = "CREATE" + DELETE = "DELETE" + UPDATE = "UPDATE" + + +class OperationStatus(StrEnum): + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + CANCEL_IN_PROGRESS = "CANCEL_IN_PROGRESS" + CANCEL_COMPLETE = "CANCEL_COMPLETE" + + +class AlreadyExistsException(ServiceException): + code: str = "AlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class ClientTokenConflictException(ServiceException): + code: str = "ClientTokenConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class ConcurrentModificationException(ServiceException): + code: str = "ConcurrentModificationException" + sender_fault: bool = False + status_code: int = 400 + + +class ConcurrentOperationException(ServiceException): + code: str = "ConcurrentOperationException" + sender_fault: bool = False + status_code: int = 400 + + +class GeneralServiceException(ServiceException): + code: str = "GeneralServiceException" + sender_fault: bool = False + status_code: int = 400 + + +class HandlerFailureException(ServiceException): + code: str = "HandlerFailureException" + sender_fault: bool = False + status_code: int = 400 + + +class HandlerInternalFailureException(ServiceException): + code: str = "HandlerInternalFailureException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidCredentialsException(ServiceException): + code: str = "InvalidCredentialsException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRequestException(ServiceException): + code: str = "InvalidRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class NetworkFailureException(ServiceException): + code: str = "NetworkFailureException" + sender_fault: bool = False + status_code: int = 400 + + +class NotStabilizedException(ServiceException): + code: str = "NotStabilizedException" + sender_fault: bool = False + status_code: int = 400 + + +class NotUpdatableException(ServiceException): + code: str = "NotUpdatableException" + sender_fault: bool = False + status_code: int = 400 + + +class PrivateTypeException(ServiceException): + code: str = "PrivateTypeException" + sender_fault: bool = False + status_code: int = 400 + + +class RequestTokenNotFoundException(ServiceException): + code: str = "RequestTokenNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceConflictException(ServiceException): + code: str = "ResourceConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ServiceInternalErrorException(ServiceException): + code: str = "ServiceInternalErrorException" + sender_fault: bool = False + status_code: int = 400 + + +class ServiceLimitExceededException(ServiceException): + code: str = "ServiceLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class TypeNotFoundException(ServiceException): + code: str = "TypeNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedActionException(ServiceException): + code: str = "UnsupportedActionException" + sender_fault: bool = False + status_code: int = 400 + + +class CancelResourceRequestInput(ServiceRequest): + RequestToken: RequestToken + + +Timestamp = datetime + + +class ProgressEvent(TypedDict, total=False): + TypeName: Optional[TypeName] + Identifier: Optional[Identifier] + RequestToken: Optional[RequestToken] + HooksRequestToken: Optional[RequestToken] + Operation: Optional[Operation] + OperationStatus: Optional[OperationStatus] + EventTime: Optional[Timestamp] + ResourceModel: Optional[Properties] + StatusMessage: Optional[StatusMessage] + ErrorCode: Optional[HandlerErrorCode] + RetryAfter: Optional[Timestamp] + + +class CancelResourceRequestOutput(TypedDict, total=False): + ProgressEvent: Optional[ProgressEvent] + + +class CreateResourceInput(ServiceRequest): + TypeName: TypeName + TypeVersionId: Optional[TypeVersionId] + RoleArn: Optional[RoleArn] + ClientToken: Optional[ClientToken] + DesiredState: Properties + + +class CreateResourceOutput(TypedDict, total=False): + ProgressEvent: Optional[ProgressEvent] + + +class DeleteResourceInput(ServiceRequest): + TypeName: TypeName + TypeVersionId: Optional[TypeVersionId] + RoleArn: Optional[RoleArn] + ClientToken: Optional[ClientToken] + Identifier: Identifier + + +class DeleteResourceOutput(TypedDict, total=False): + ProgressEvent: Optional[ProgressEvent] + + +class GetResourceInput(ServiceRequest): + TypeName: TypeName + TypeVersionId: Optional[TypeVersionId] + RoleArn: Optional[RoleArn] + Identifier: Identifier + + +class ResourceDescription(TypedDict, total=False): + Identifier: Optional[Identifier] + Properties: Optional[Properties] + + +class GetResourceOutput(TypedDict, total=False): + TypeName: Optional[TypeName] + ResourceDescription: Optional[ResourceDescription] + + +class GetResourceRequestStatusInput(ServiceRequest): + RequestToken: RequestToken + + +class HookProgressEvent(TypedDict, total=False): + HookTypeName: Optional[TypeName] + HookTypeVersionId: Optional[TypeVersionId] + HookTypeArn: Optional[HookTypeArn] + InvocationPoint: Optional[HookInvocationPoint] + HookStatus: Optional[HookStatus] + HookEventTime: Optional[Timestamp] + HookStatusMessage: Optional[StatusMessage] + FailureMode: Optional[HookFailureMode] + + +HooksProgressEvent = List[HookProgressEvent] + + +class GetResourceRequestStatusOutput(TypedDict, total=False): + ProgressEvent: Optional[ProgressEvent] + HooksProgressEvent: Optional[HooksProgressEvent] + + +OperationStatuses = List[OperationStatus] +Operations = List[Operation] + + +class ResourceRequestStatusFilter(TypedDict, total=False): + Operations: Optional[Operations] + OperationStatuses: Optional[OperationStatuses] + + +class ListResourceRequestsInput(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + ResourceRequestStatusFilter: Optional[ResourceRequestStatusFilter] + + +ResourceRequestStatusSummaries = List[ProgressEvent] + + +class ListResourceRequestsOutput(TypedDict, total=False): + ResourceRequestStatusSummaries: Optional[ResourceRequestStatusSummaries] + NextToken: Optional[NextToken] + + +class ListResourcesInput(ServiceRequest): + TypeName: TypeName + TypeVersionId: Optional[TypeVersionId] + RoleArn: Optional[RoleArn] + NextToken: Optional[HandlerNextToken] + MaxResults: Optional[MaxResults] + ResourceModel: Optional[Properties] + + +ResourceDescriptions = List[ResourceDescription] + + +class ListResourcesOutput(TypedDict, total=False): + TypeName: Optional[TypeName] + ResourceDescriptions: Optional[ResourceDescriptions] + NextToken: Optional[HandlerNextToken] + + +class UpdateResourceInput(ServiceRequest): + TypeName: TypeName + TypeVersionId: Optional[TypeVersionId] + RoleArn: Optional[RoleArn] + ClientToken: Optional[ClientToken] + Identifier: Identifier + PatchDocument: PatchDocument + + +class UpdateResourceOutput(TypedDict, total=False): + ProgressEvent: Optional[ProgressEvent] + + +class CloudcontrolApi: + service = "cloudcontrol" + version = "2021-09-30" + + @handler("CancelResourceRequest") + def cancel_resource_request( + self, context: RequestContext, request_token: RequestToken, **kwargs + ) -> CancelResourceRequestOutput: + raise NotImplementedError + + @handler("CreateResource") + def create_resource( + self, + context: RequestContext, + type_name: TypeName, + desired_state: Properties, + type_version_id: TypeVersionId | None = None, + role_arn: RoleArn | None = None, + client_token: ClientToken | None = None, + **kwargs, + ) -> CreateResourceOutput: + raise NotImplementedError + + @handler("DeleteResource") + def delete_resource( + self, + context: RequestContext, + type_name: TypeName, + identifier: Identifier, + type_version_id: TypeVersionId | None = None, + role_arn: RoleArn | None = None, + client_token: ClientToken | None = None, + **kwargs, + ) -> DeleteResourceOutput: + raise NotImplementedError + + @handler("GetResource") + def get_resource( + self, + context: RequestContext, + type_name: TypeName, + identifier: Identifier, + type_version_id: TypeVersionId | None = None, + role_arn: RoleArn | None = None, + **kwargs, + ) -> GetResourceOutput: + raise NotImplementedError + + @handler("GetResourceRequestStatus") + def get_resource_request_status( + self, context: RequestContext, request_token: RequestToken, **kwargs + ) -> GetResourceRequestStatusOutput: + raise NotImplementedError + + @handler("ListResourceRequests") + def list_resource_requests( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + resource_request_status_filter: ResourceRequestStatusFilter | None = None, + **kwargs, + ) -> ListResourceRequestsOutput: + raise NotImplementedError + + @handler("ListResources") + def list_resources( + self, + context: RequestContext, + type_name: TypeName, + type_version_id: TypeVersionId | None = None, + role_arn: RoleArn | None = None, + next_token: HandlerNextToken | None = None, + max_results: MaxResults | None = None, + resource_model: Properties | None = None, + **kwargs, + ) -> ListResourcesOutput: + raise NotImplementedError + + @handler("UpdateResource") + def update_resource( + self, + context: RequestContext, + type_name: TypeName, + identifier: Identifier, + patch_document: PatchDocument, + type_version_id: TypeVersionId | None = None, + role_arn: RoleArn | None = None, + client_token: ClientToken | None = None, + **kwargs, + ) -> UpdateResourceOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/cloudformation/__init__.py b/localstack-core/localstack/aws/api/cloudformation/__init__.py new file mode 100644 index 0000000000000..7306ed543e1c0 --- /dev/null +++ b/localstack-core/localstack/aws/api/cloudformation/__init__.py @@ -0,0 +1,3867 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AcceptTermsAndConditions = bool +Account = str +AccountGateStatusReason = str +AccountsUrl = str +AfterContext = str +AfterValue = str +AllowedValue = str +Arn = str +AutoDeploymentNullable = bool +AutoUpdate = bool +BeforeContext = str +BeforeValue = str +BoxedInteger = int +BoxedMaxResults = int +CapabilitiesReason = str +CausingEntity = str +ChangeSetId = str +ChangeSetName = str +ChangeSetNameOrId = str +ChangeSetStatusReason = str +ClientRequestToken = str +ClientToken = str +ConfigurationSchema = str +ConnectionArn = str +Description = str +DetectionReason = str +DisableRollback = bool +DriftedStackInstancesCount = int +EnableStackCreation = bool +EnableTerminationProtection = bool +ErrorCode = str +ErrorMessage = str +EventId = str +ExecutionRoleName = str +ExecutionStatusReason = str +ExportName = str +ExportValue = str +FailedStackInstancesCount = int +FailureToleranceCount = int +FailureTolerancePercentage = int +GeneratedTemplateId = str +GeneratedTemplateName = str +HookInvocationCount = int +HookResultId = str +HookStatusReason = str +HookTargetTypeName = str +HookType = str +HookTypeConfigurationVersionId = str +HookTypeName = str +HookTypeVersionId = str +ImportExistingResources = bool +InProgressStackInstancesCount = int +InSyncStackInstancesCount = int +IncludeNestedStacks = bool +IncludePropertyValues = bool +IsActivated = bool +IsDefaultConfiguration = bool +IsDefaultVersion = bool +JazzResourceIdentifierPropertyKey = str +JazzResourceIdentifierPropertyValue = str +Key = str +LimitName = str +LimitValue = int +LogGroupName = str +LogicalIdHierarchy = str +LogicalResourceId = str +ManagedByStack = bool +ManagedExecutionNullable = bool +MaxConcurrentCount = int +MaxConcurrentPercentage = int +MaxResults = int +Metadata = str +MonitoringTimeInMinutes = int +NextToken = str +NoEcho = bool +NotificationARN = str +NumberOfResources = int +OperationResultFilterValues = str +OptionalSecureUrl = str +OrganizationalUnitId = str +OutputKey = str +OutputValue = str +ParameterKey = str +ParameterType = str +ParameterValue = str +PercentageCompleted = float +PhysicalResourceId = str +PrivateTypeArn = str +Properties = str +PropertyDescription = str +PropertyName = str +PropertyPath = str +PropertyValue = str +PublicVersionNumber = str +PublisherId = str +PublisherName = str +PublisherProfile = str +Reason = str +RefreshAllResources = bool +Region = str +RegistrationToken = str +RequestToken = str +RequiredProperty = bool +ResourceIdentifier = str +ResourceIdentifierPropertyKey = str +ResourceIdentifierPropertyValue = str +ResourceModel = str +ResourceProperties = str +ResourcePropertyPath = str +ResourceScanId = str +ResourceScanStatusReason = str +ResourceScannerMaxResults = int +ResourceSignalUniqueId = str +ResourceStatusReason = str +ResourceToSkip = str +ResourceType = str +ResourceTypeFilter = str +ResourceTypePrefix = str +ResourcesFailed = int +ResourcesPending = int +ResourcesProcessing = int +ResourcesRead = int +ResourcesScanned = int +ResourcesSucceeded = int +RetainExceptOnCreate = bool +RetainStacks = bool +RetainStacksNullable = bool +RetainStacksOnAccountRemovalNullable = bool +RoleARN = str +RoleArn = str +S3Bucket = str +S3Url = str +StackDriftDetectionId = str +StackDriftDetectionStatusReason = str +StackId = str +StackIdsUrl = str +StackInstanceFilterValues = str +StackName = str +StackNameOrId = str +StackPolicyBody = str +StackPolicyDuringUpdateBody = str +StackPolicyDuringUpdateURL = str +StackPolicyURL = str +StackRefactorId = str +StackRefactorResourceIdentifier = str +StackRefactorStatusReason = str +StackResourceDriftStatusReason = str +StackSetARN = str +StackSetId = str +StackSetName = str +StackSetNameOrId = str +StackSetOperationStatusReason = str +StackStatusReason = str +StatusMessage = str +SupportedMajorVersion = int +TagKey = str +TagValue = str +TemplateBody = str +TemplateDescription = str +TemplateStatusReason = str +TemplateURL = str +ThirdPartyTypeArn = str +TimeoutMinutes = int +TotalStackInstancesCount = int +TotalWarnings = int +TransformName = str +TreatUnrecognizedResourceTypesAsWarnings = bool +Type = str +TypeArn = str +TypeConfiguration = str +TypeConfigurationAlias = str +TypeConfigurationArn = str +TypeHierarchy = str +TypeName = str +TypeNamePrefix = str +TypeSchema = str +TypeTestsStatusDescription = str +TypeVersionId = str +Url = str +UsePreviousTemplate = bool +UsePreviousValue = bool +Value = str +Version = str + + +class AccountFilterType(StrEnum): + NONE = "NONE" + INTERSECTION = "INTERSECTION" + DIFFERENCE = "DIFFERENCE" + UNION = "UNION" + + +class AccountGateStatus(StrEnum): + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + SKIPPED = "SKIPPED" + + +class AttributeChangeType(StrEnum): + Add = "Add" + Remove = "Remove" + Modify = "Modify" + + +class CallAs(StrEnum): + SELF = "SELF" + DELEGATED_ADMIN = "DELEGATED_ADMIN" + + +class Capability(StrEnum): + CAPABILITY_IAM = "CAPABILITY_IAM" + CAPABILITY_NAMED_IAM = "CAPABILITY_NAMED_IAM" + CAPABILITY_AUTO_EXPAND = "CAPABILITY_AUTO_EXPAND" + + +class Category(StrEnum): + REGISTERED = "REGISTERED" + ACTIVATED = "ACTIVATED" + THIRD_PARTY = "THIRD_PARTY" + AWS_TYPES = "AWS_TYPES" + + +class ChangeAction(StrEnum): + Add = "Add" + Modify = "Modify" + Remove = "Remove" + Import = "Import" + Dynamic = "Dynamic" + + +class ChangeSetHooksStatus(StrEnum): + PLANNING = "PLANNING" + PLANNED = "PLANNED" + UNAVAILABLE = "UNAVAILABLE" + + +class ChangeSetStatus(StrEnum): + CREATE_PENDING = "CREATE_PENDING" + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_COMPLETE = "CREATE_COMPLETE" + DELETE_PENDING = "DELETE_PENDING" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + DELETE_COMPLETE = "DELETE_COMPLETE" + DELETE_FAILED = "DELETE_FAILED" + FAILED = "FAILED" + + +class ChangeSetType(StrEnum): + CREATE = "CREATE" + UPDATE = "UPDATE" + IMPORT = "IMPORT" + + +class ChangeSource(StrEnum): + ResourceReference = "ResourceReference" + ParameterReference = "ParameterReference" + ResourceAttribute = "ResourceAttribute" + DirectModification = "DirectModification" + Automatic = "Automatic" + + +class ChangeType(StrEnum): + Resource = "Resource" + + +class ConcurrencyMode(StrEnum): + STRICT_FAILURE_TOLERANCE = "STRICT_FAILURE_TOLERANCE" + SOFT_FAILURE_TOLERANCE = "SOFT_FAILURE_TOLERANCE" + + +class DeletionMode(StrEnum): + STANDARD = "STANDARD" + FORCE_DELETE_STACK = "FORCE_DELETE_STACK" + + +class DeprecatedStatus(StrEnum): + LIVE = "LIVE" + DEPRECATED = "DEPRECATED" + + +class DetailedStatus(StrEnum): + CONFIGURATION_COMPLETE = "CONFIGURATION_COMPLETE" + VALIDATION_FAILED = "VALIDATION_FAILED" + + +class DifferenceType(StrEnum): + ADD = "ADD" + REMOVE = "REMOVE" + NOT_EQUAL = "NOT_EQUAL" + + +class EvaluationType(StrEnum): + Static = "Static" + Dynamic = "Dynamic" + + +class ExecutionStatus(StrEnum): + UNAVAILABLE = "UNAVAILABLE" + AVAILABLE = "AVAILABLE" + EXECUTE_IN_PROGRESS = "EXECUTE_IN_PROGRESS" + EXECUTE_COMPLETE = "EXECUTE_COMPLETE" + EXECUTE_FAILED = "EXECUTE_FAILED" + OBSOLETE = "OBSOLETE" + + +class GeneratedTemplateDeletionPolicy(StrEnum): + DELETE = "DELETE" + RETAIN = "RETAIN" + + +class GeneratedTemplateResourceStatus(StrEnum): + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + COMPLETE = "COMPLETE" + + +class GeneratedTemplateStatus(StrEnum): + CREATE_PENDING = "CREATE_PENDING" + UPDATE_PENDING = "UPDATE_PENDING" + DELETE_PENDING = "DELETE_PENDING" + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + FAILED = "FAILED" + COMPLETE = "COMPLETE" + + +class GeneratedTemplateUpdateReplacePolicy(StrEnum): + DELETE = "DELETE" + RETAIN = "RETAIN" + + +class HandlerErrorCode(StrEnum): + NotUpdatable = "NotUpdatable" + InvalidRequest = "InvalidRequest" + AccessDenied = "AccessDenied" + InvalidCredentials = "InvalidCredentials" + AlreadyExists = "AlreadyExists" + NotFound = "NotFound" + ResourceConflict = "ResourceConflict" + Throttling = "Throttling" + ServiceLimitExceeded = "ServiceLimitExceeded" + NotStabilized = "NotStabilized" + GeneralServiceException = "GeneralServiceException" + ServiceInternalError = "ServiceInternalError" + NetworkFailure = "NetworkFailure" + InternalFailure = "InternalFailure" + InvalidTypeConfiguration = "InvalidTypeConfiguration" + HandlerInternalFailure = "HandlerInternalFailure" + NonCompliant = "NonCompliant" + Unknown = "Unknown" + UnsupportedTarget = "UnsupportedTarget" + + +class HookFailureMode(StrEnum): + FAIL = "FAIL" + WARN = "WARN" + + +class HookInvocationPoint(StrEnum): + PRE_PROVISION = "PRE_PROVISION" + + +class HookStatus(StrEnum): + HOOK_IN_PROGRESS = "HOOK_IN_PROGRESS" + HOOK_COMPLETE_SUCCEEDED = "HOOK_COMPLETE_SUCCEEDED" + HOOK_COMPLETE_FAILED = "HOOK_COMPLETE_FAILED" + HOOK_FAILED = "HOOK_FAILED" + + +class HookTargetType(StrEnum): + RESOURCE = "RESOURCE" + + +class IdentityProvider(StrEnum): + AWS_Marketplace = "AWS_Marketplace" + GitHub = "GitHub" + Bitbucket = "Bitbucket" + + +class ListHookResultsTargetType(StrEnum): + CHANGE_SET = "CHANGE_SET" + STACK = "STACK" + RESOURCE = "RESOURCE" + CLOUD_CONTROL = "CLOUD_CONTROL" + + +class OnFailure(StrEnum): + DO_NOTHING = "DO_NOTHING" + ROLLBACK = "ROLLBACK" + DELETE = "DELETE" + + +class OnStackFailure(StrEnum): + DO_NOTHING = "DO_NOTHING" + ROLLBACK = "ROLLBACK" + DELETE = "DELETE" + + +class OperationResultFilterName(StrEnum): + OPERATION_RESULT_STATUS = "OPERATION_RESULT_STATUS" + + +class OperationStatus(StrEnum): + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + + +class OrganizationStatus(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + DISABLED_PERMANENTLY = "DISABLED_PERMANENTLY" + + +class PermissionModels(StrEnum): + SERVICE_MANAGED = "SERVICE_MANAGED" + SELF_MANAGED = "SELF_MANAGED" + + +class PolicyAction(StrEnum): + Delete = "Delete" + Retain = "Retain" + Snapshot = "Snapshot" + ReplaceAndDelete = "ReplaceAndDelete" + ReplaceAndRetain = "ReplaceAndRetain" + ReplaceAndSnapshot = "ReplaceAndSnapshot" + + +class ProvisioningType(StrEnum): + NON_PROVISIONABLE = "NON_PROVISIONABLE" + IMMUTABLE = "IMMUTABLE" + FULLY_MUTABLE = "FULLY_MUTABLE" + + +class PublisherStatus(StrEnum): + VERIFIED = "VERIFIED" + UNVERIFIED = "UNVERIFIED" + + +class RegionConcurrencyType(StrEnum): + SEQUENTIAL = "SEQUENTIAL" + PARALLEL = "PARALLEL" + + +class RegistrationStatus(StrEnum): + COMPLETE = "COMPLETE" + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + + +class RegistryType(StrEnum): + RESOURCE = "RESOURCE" + MODULE = "MODULE" + HOOK = "HOOK" + + +class Replacement(StrEnum): + True_ = "True" + False_ = "False" + Conditional = "Conditional" + + +class RequiresRecreation(StrEnum): + Never = "Never" + Conditionally = "Conditionally" + Always = "Always" + + +class ResourceAttribute(StrEnum): + Properties = "Properties" + Metadata = "Metadata" + CreationPolicy = "CreationPolicy" + UpdatePolicy = "UpdatePolicy" + DeletionPolicy = "DeletionPolicy" + UpdateReplacePolicy = "UpdateReplacePolicy" + Tags = "Tags" + + +class ResourceScanStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + COMPLETE = "COMPLETE" + EXPIRED = "EXPIRED" + + +class ResourceSignalStatus(StrEnum): + SUCCESS = "SUCCESS" + FAILURE = "FAILURE" + + +class ResourceStatus(StrEnum): + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_FAILED = "CREATE_FAILED" + CREATE_COMPLETE = "CREATE_COMPLETE" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + DELETE_FAILED = "DELETE_FAILED" + DELETE_COMPLETE = "DELETE_COMPLETE" + DELETE_SKIPPED = "DELETE_SKIPPED" + UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS" + UPDATE_FAILED = "UPDATE_FAILED" + UPDATE_COMPLETE = "UPDATE_COMPLETE" + IMPORT_FAILED = "IMPORT_FAILED" + IMPORT_COMPLETE = "IMPORT_COMPLETE" + IMPORT_IN_PROGRESS = "IMPORT_IN_PROGRESS" + IMPORT_ROLLBACK_IN_PROGRESS = "IMPORT_ROLLBACK_IN_PROGRESS" + IMPORT_ROLLBACK_FAILED = "IMPORT_ROLLBACK_FAILED" + IMPORT_ROLLBACK_COMPLETE = "IMPORT_ROLLBACK_COMPLETE" + EXPORT_FAILED = "EXPORT_FAILED" + EXPORT_COMPLETE = "EXPORT_COMPLETE" + EXPORT_IN_PROGRESS = "EXPORT_IN_PROGRESS" + EXPORT_ROLLBACK_IN_PROGRESS = "EXPORT_ROLLBACK_IN_PROGRESS" + EXPORT_ROLLBACK_FAILED = "EXPORT_ROLLBACK_FAILED" + EXPORT_ROLLBACK_COMPLETE = "EXPORT_ROLLBACK_COMPLETE" + UPDATE_ROLLBACK_IN_PROGRESS = "UPDATE_ROLLBACK_IN_PROGRESS" + UPDATE_ROLLBACK_COMPLETE = "UPDATE_ROLLBACK_COMPLETE" + UPDATE_ROLLBACK_FAILED = "UPDATE_ROLLBACK_FAILED" + ROLLBACK_IN_PROGRESS = "ROLLBACK_IN_PROGRESS" + ROLLBACK_COMPLETE = "ROLLBACK_COMPLETE" + ROLLBACK_FAILED = "ROLLBACK_FAILED" + + +class ScanType(StrEnum): + FULL = "FULL" + PARTIAL = "PARTIAL" + + +class StackDriftDetectionStatus(StrEnum): + DETECTION_IN_PROGRESS = "DETECTION_IN_PROGRESS" + DETECTION_FAILED = "DETECTION_FAILED" + DETECTION_COMPLETE = "DETECTION_COMPLETE" + + +class StackDriftStatus(StrEnum): + DRIFTED = "DRIFTED" + IN_SYNC = "IN_SYNC" + UNKNOWN = "UNKNOWN" + NOT_CHECKED = "NOT_CHECKED" + + +class StackInstanceDetailedStatus(StrEnum): + PENDING = "PENDING" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + INOPERABLE = "INOPERABLE" + SKIPPED_SUSPENDED_ACCOUNT = "SKIPPED_SUSPENDED_ACCOUNT" + FAILED_IMPORT = "FAILED_IMPORT" + + +class StackInstanceFilterName(StrEnum): + DETAILED_STATUS = "DETAILED_STATUS" + LAST_OPERATION_ID = "LAST_OPERATION_ID" + DRIFT_STATUS = "DRIFT_STATUS" + + +class StackInstanceStatus(StrEnum): + CURRENT = "CURRENT" + OUTDATED = "OUTDATED" + INOPERABLE = "INOPERABLE" + + +class StackRefactorActionEntity(StrEnum): + RESOURCE = "RESOURCE" + STACK = "STACK" + + +class StackRefactorActionType(StrEnum): + MOVE = "MOVE" + CREATE = "CREATE" + + +class StackRefactorDetection(StrEnum): + AUTO = "AUTO" + MANUAL = "MANUAL" + + +class StackRefactorExecutionStatus(StrEnum): + UNAVAILABLE = "UNAVAILABLE" + AVAILABLE = "AVAILABLE" + OBSOLETE = "OBSOLETE" + EXECUTE_IN_PROGRESS = "EXECUTE_IN_PROGRESS" + EXECUTE_COMPLETE = "EXECUTE_COMPLETE" + EXECUTE_FAILED = "EXECUTE_FAILED" + ROLLBACK_IN_PROGRESS = "ROLLBACK_IN_PROGRESS" + ROLLBACK_COMPLETE = "ROLLBACK_COMPLETE" + ROLLBACK_FAILED = "ROLLBACK_FAILED" + + +class StackRefactorStatus(StrEnum): + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_COMPLETE = "CREATE_COMPLETE" + CREATE_FAILED = "CREATE_FAILED" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + DELETE_COMPLETE = "DELETE_COMPLETE" + DELETE_FAILED = "DELETE_FAILED" + + +class StackResourceDriftStatus(StrEnum): + IN_SYNC = "IN_SYNC" + MODIFIED = "MODIFIED" + DELETED = "DELETED" + NOT_CHECKED = "NOT_CHECKED" + UNKNOWN = "UNKNOWN" + + +class StackSetDriftDetectionStatus(StrEnum): + COMPLETED = "COMPLETED" + FAILED = "FAILED" + PARTIAL_SUCCESS = "PARTIAL_SUCCESS" + IN_PROGRESS = "IN_PROGRESS" + STOPPED = "STOPPED" + + +class StackSetDriftStatus(StrEnum): + DRIFTED = "DRIFTED" + IN_SYNC = "IN_SYNC" + NOT_CHECKED = "NOT_CHECKED" + + +class StackSetOperationAction(StrEnum): + CREATE = "CREATE" + UPDATE = "UPDATE" + DELETE = "DELETE" + DETECT_DRIFT = "DETECT_DRIFT" + + +class StackSetOperationResultStatus(StrEnum): + PENDING = "PENDING" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + + +class StackSetOperationStatus(StrEnum): + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + STOPPING = "STOPPING" + STOPPED = "STOPPED" + QUEUED = "QUEUED" + + +class StackSetStatus(StrEnum): + ACTIVE = "ACTIVE" + DELETED = "DELETED" + + +class StackStatus(StrEnum): + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_FAILED = "CREATE_FAILED" + CREATE_COMPLETE = "CREATE_COMPLETE" + ROLLBACK_IN_PROGRESS = "ROLLBACK_IN_PROGRESS" + ROLLBACK_FAILED = "ROLLBACK_FAILED" + ROLLBACK_COMPLETE = "ROLLBACK_COMPLETE" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + DELETE_FAILED = "DELETE_FAILED" + DELETE_COMPLETE = "DELETE_COMPLETE" + UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS" + UPDATE_COMPLETE_CLEANUP_IN_PROGRESS = "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS" + UPDATE_COMPLETE = "UPDATE_COMPLETE" + UPDATE_FAILED = "UPDATE_FAILED" + UPDATE_ROLLBACK_IN_PROGRESS = "UPDATE_ROLLBACK_IN_PROGRESS" + UPDATE_ROLLBACK_FAILED = "UPDATE_ROLLBACK_FAILED" + UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS = "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS" + UPDATE_ROLLBACK_COMPLETE = "UPDATE_ROLLBACK_COMPLETE" + REVIEW_IN_PROGRESS = "REVIEW_IN_PROGRESS" + IMPORT_IN_PROGRESS = "IMPORT_IN_PROGRESS" + IMPORT_COMPLETE = "IMPORT_COMPLETE" + IMPORT_ROLLBACK_IN_PROGRESS = "IMPORT_ROLLBACK_IN_PROGRESS" + IMPORT_ROLLBACK_FAILED = "IMPORT_ROLLBACK_FAILED" + IMPORT_ROLLBACK_COMPLETE = "IMPORT_ROLLBACK_COMPLETE" + + +class TemplateFormat(StrEnum): + JSON = "JSON" + YAML = "YAML" + + +class TemplateStage(StrEnum): + Original = "Original" + Processed = "Processed" + + +class ThirdPartyType(StrEnum): + RESOURCE = "RESOURCE" + MODULE = "MODULE" + HOOK = "HOOK" + + +class TypeTestsStatus(StrEnum): + PASSED = "PASSED" + FAILED = "FAILED" + IN_PROGRESS = "IN_PROGRESS" + NOT_TESTED = "NOT_TESTED" + + +class VersionBump(StrEnum): + MAJOR = "MAJOR" + MINOR = "MINOR" + + +class Visibility(StrEnum): + PUBLIC = "PUBLIC" + PRIVATE = "PRIVATE" + + +class WarningType(StrEnum): + MUTUALLY_EXCLUSIVE_PROPERTIES = "MUTUALLY_EXCLUSIVE_PROPERTIES" + UNSUPPORTED_PROPERTIES = "UNSUPPORTED_PROPERTIES" + MUTUALLY_EXCLUSIVE_TYPES = "MUTUALLY_EXCLUSIVE_TYPES" + EXCLUDED_PROPERTIES = "EXCLUDED_PROPERTIES" + + +class AlreadyExistsException(ServiceException): + code: str = "AlreadyExistsException" + sender_fault: bool = True + status_code: int = 400 + + +class CFNRegistryException(ServiceException): + code: str = "CFNRegistryException" + sender_fault: bool = True + status_code: int = 400 + + +class ChangeSetNotFoundException(ServiceException): + code: str = "ChangeSetNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ConcurrentResourcesLimitExceededException(ServiceException): + code: str = "ConcurrentResourcesLimitExceeded" + sender_fault: bool = True + status_code: int = 429 + + +class CreatedButModifiedException(ServiceException): + code: str = "CreatedButModifiedException" + sender_fault: bool = True + status_code: int = 409 + + +class GeneratedTemplateNotFoundException(ServiceException): + code: str = "GeneratedTemplateNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class HookResultNotFoundException(ServiceException): + code: str = "HookResultNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class InsufficientCapabilitiesException(ServiceException): + code: str = "InsufficientCapabilitiesException" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidChangeSetStatusException(ServiceException): + code: str = "InvalidChangeSetStatus" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidOperationException(ServiceException): + code: str = "InvalidOperationException" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidStateTransitionException(ServiceException): + code: str = "InvalidStateTransition" + sender_fault: bool = True + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = True + status_code: int = 400 + + +class NameAlreadyExistsException(ServiceException): + code: str = "NameAlreadyExistsException" + sender_fault: bool = True + status_code: int = 409 + + +class OperationIdAlreadyExistsException(ServiceException): + code: str = "OperationIdAlreadyExistsException" + sender_fault: bool = True + status_code: int = 409 + + +class OperationInProgressException(ServiceException): + code: str = "OperationInProgressException" + sender_fault: bool = True + status_code: int = 409 + + +class OperationNotFoundException(ServiceException): + code: str = "OperationNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class OperationStatusCheckFailedException(ServiceException): + code: str = "ConditionalCheckFailed" + sender_fault: bool = True + status_code: int = 400 + + +class ResourceScanInProgressException(ServiceException): + code: str = "ResourceScanInProgress" + sender_fault: bool = True + status_code: int = 400 + + +class ResourceScanLimitExceededException(ServiceException): + code: str = "ResourceScanLimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ResourceScanNotFoundException(ServiceException): + code: str = "ResourceScanNotFound" + sender_fault: bool = True + status_code: int = 400 + + +class StackInstanceNotFoundException(ServiceException): + code: str = "StackInstanceNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class StackNotFoundException(ServiceException): + code: str = "StackNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class StackRefactorNotFoundException(ServiceException): + code: str = "StackRefactorNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class StackSetNotEmptyException(ServiceException): + code: str = "StackSetNotEmptyException" + sender_fault: bool = True + status_code: int = 409 + + +class StackSetNotFoundException(ServiceException): + code: str = "StackSetNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class StaleRequestException(ServiceException): + code: str = "StaleRequestException" + sender_fault: bool = True + status_code: int = 409 + + +class TokenAlreadyExistsException(ServiceException): + code: str = "TokenAlreadyExistsException" + sender_fault: bool = True + status_code: int = 400 + + +class TypeConfigurationNotFoundException(ServiceException): + code: str = "TypeConfigurationNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class TypeNotFoundException(ServiceException): + code: str = "TypeNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class AccountGateResult(TypedDict, total=False): + Status: Optional[AccountGateStatus] + StatusReason: Optional[AccountGateStatusReason] + + +class AccountLimit(TypedDict, total=False): + Name: Optional[LimitName] + Value: Optional[LimitValue] + + +AccountLimitList = List[AccountLimit] +AccountList = List[Account] + + +class ActivateOrganizationsAccessInput(ServiceRequest): + pass + + +class ActivateOrganizationsAccessOutput(TypedDict, total=False): + pass + + +MajorVersion = int + + +class LoggingConfig(TypedDict, total=False): + LogRoleArn: RoleArn + LogGroupName: LogGroupName + + +class ActivateTypeInput(ServiceRequest): + Type: Optional[ThirdPartyType] + PublicTypeArn: Optional[ThirdPartyTypeArn] + PublisherId: Optional[PublisherId] + TypeName: Optional[TypeName] + TypeNameAlias: Optional[TypeName] + AutoUpdate: Optional[AutoUpdate] + LoggingConfig: Optional[LoggingConfig] + ExecutionRoleArn: Optional[RoleArn] + VersionBump: Optional[VersionBump] + MajorVersion: Optional[MajorVersion] + + +class ActivateTypeOutput(TypedDict, total=False): + Arn: Optional[PrivateTypeArn] + + +AllowedValues = List[AllowedValue] + + +class AutoDeployment(TypedDict, total=False): + Enabled: Optional[AutoDeploymentNullable] + RetainStacksOnAccountRemoval: Optional[RetainStacksOnAccountRemovalNullable] + + +class TypeConfigurationIdentifier(TypedDict, total=False): + TypeArn: Optional[TypeArn] + TypeConfigurationAlias: Optional[TypeConfigurationAlias] + TypeConfigurationArn: Optional[TypeConfigurationArn] + Type: Optional[ThirdPartyType] + TypeName: Optional[TypeName] + + +class BatchDescribeTypeConfigurationsError(TypedDict, total=False): + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + TypeConfigurationIdentifier: Optional[TypeConfigurationIdentifier] + + +BatchDescribeTypeConfigurationsErrors = List[BatchDescribeTypeConfigurationsError] +TypeConfigurationIdentifiers = List[TypeConfigurationIdentifier] + + +class BatchDescribeTypeConfigurationsInput(ServiceRequest): + TypeConfigurationIdentifiers: TypeConfigurationIdentifiers + + +Timestamp = datetime + + +class TypeConfigurationDetails(TypedDict, total=False): + Arn: Optional[TypeConfigurationArn] + Alias: Optional[TypeConfigurationAlias] + Configuration: Optional[TypeConfiguration] + LastUpdated: Optional[Timestamp] + TypeArn: Optional[TypeArn] + TypeName: Optional[TypeName] + IsDefaultConfiguration: Optional[IsDefaultConfiguration] + + +TypeConfigurationDetailsList = List[TypeConfigurationDetails] +UnprocessedTypeConfigurations = List[TypeConfigurationIdentifier] + + +class BatchDescribeTypeConfigurationsOutput(TypedDict, total=False): + Errors: Optional[BatchDescribeTypeConfigurationsErrors] + UnprocessedTypeConfigurations: Optional[UnprocessedTypeConfigurations] + TypeConfigurations: Optional[TypeConfigurationDetailsList] + + +class CancelUpdateStackInput(ServiceRequest): + StackName: StackName + ClientRequestToken: Optional[ClientRequestToken] + + +Capabilities = List[Capability] + + +class ModuleInfo(TypedDict, total=False): + TypeHierarchy: Optional[TypeHierarchy] + LogicalIdHierarchy: Optional[LogicalIdHierarchy] + + +class ResourceTargetDefinition(TypedDict, total=False): + Attribute: Optional[ResourceAttribute] + Name: Optional[PropertyName] + RequiresRecreation: Optional[RequiresRecreation] + Path: Optional[ResourcePropertyPath] + BeforeValue: Optional[BeforeValue] + AfterValue: Optional[AfterValue] + AttributeChangeType: Optional[AttributeChangeType] + + +class ResourceChangeDetail(TypedDict, total=False): + Target: Optional[ResourceTargetDefinition] + Evaluation: Optional[EvaluationType] + ChangeSource: Optional[ChangeSource] + CausingEntity: Optional[CausingEntity] + + +ResourceChangeDetails = List[ResourceChangeDetail] +Scope = List[ResourceAttribute] + + +class ResourceChange(TypedDict, total=False): + PolicyAction: Optional[PolicyAction] + Action: Optional[ChangeAction] + LogicalResourceId: Optional[LogicalResourceId] + PhysicalResourceId: Optional[PhysicalResourceId] + ResourceType: Optional[ResourceType] + Replacement: Optional[Replacement] + Scope: Optional[Scope] + Details: Optional[ResourceChangeDetails] + ChangeSetId: Optional[ChangeSetId] + ModuleInfo: Optional[ModuleInfo] + BeforeContext: Optional[BeforeContext] + AfterContext: Optional[AfterContext] + + +class Change(TypedDict, total=False): + Type: Optional[ChangeType] + HookInvocationCount: Optional[HookInvocationCount] + ResourceChange: Optional[ResourceChange] + + +class ChangeSetHookResourceTargetDetails(TypedDict, total=False): + LogicalResourceId: Optional[LogicalResourceId] + ResourceType: Optional[HookTargetTypeName] + ResourceAction: Optional[ChangeAction] + + +class ChangeSetHookTargetDetails(TypedDict, total=False): + TargetType: Optional[HookTargetType] + ResourceTargetDetails: Optional[ChangeSetHookResourceTargetDetails] + + +class ChangeSetHook(TypedDict, total=False): + InvocationPoint: Optional[HookInvocationPoint] + FailureMode: Optional[HookFailureMode] + TypeName: Optional[HookTypeName] + TypeVersionId: Optional[HookTypeVersionId] + TypeConfigurationVersionId: Optional[HookTypeConfigurationVersionId] + TargetDetails: Optional[ChangeSetHookTargetDetails] + + +ChangeSetHooks = List[ChangeSetHook] +CreationTime = datetime + + +class ChangeSetSummary(TypedDict, total=False): + StackId: Optional[StackId] + StackName: Optional[StackName] + ChangeSetId: Optional[ChangeSetId] + ChangeSetName: Optional[ChangeSetName] + ExecutionStatus: Optional[ExecutionStatus] + Status: Optional[ChangeSetStatus] + StatusReason: Optional[ChangeSetStatusReason] + CreationTime: Optional[CreationTime] + Description: Optional[Description] + IncludeNestedStacks: Optional[IncludeNestedStacks] + ParentChangeSetId: Optional[ChangeSetId] + RootChangeSetId: Optional[ChangeSetId] + ImportExistingResources: Optional[ImportExistingResources] + + +ChangeSetSummaries = List[ChangeSetSummary] +Changes = List[Change] +ResourcesToSkip = List[ResourceToSkip] + + +class ContinueUpdateRollbackInput(ServiceRequest): + StackName: StackNameOrId + RoleARN: Optional[RoleARN] + ResourcesToSkip: Optional[ResourcesToSkip] + ClientRequestToken: Optional[ClientRequestToken] + + +class ContinueUpdateRollbackOutput(TypedDict, total=False): + pass + + +ResourceIdentifierProperties = Dict[ResourceIdentifierPropertyKey, ResourceIdentifierPropertyValue] + + +class ResourceToImport(TypedDict, total=False): + ResourceType: ResourceType + LogicalResourceId: LogicalResourceId + ResourceIdentifier: ResourceIdentifierProperties + + +ResourcesToImport = List[ResourceToImport] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +Tags = List[Tag] +NotificationARNs = List[NotificationARN] + + +class RollbackTrigger(TypedDict, total=False): + Arn: Arn + Type: Type + + +RollbackTriggers = List[RollbackTrigger] + + +class RollbackConfiguration(TypedDict, total=False): + RollbackTriggers: Optional[RollbackTriggers] + MonitoringTimeInMinutes: Optional[MonitoringTimeInMinutes] + + +ResourceTypes = List[ResourceType] + + +class Parameter(TypedDict, total=False): + ParameterKey: Optional[ParameterKey] + ParameterValue: Optional[ParameterValue] + UsePreviousValue: Optional[UsePreviousValue] + ResolvedValue: Optional[ParameterValue] + + +Parameters = List[Parameter] + + +class CreateChangeSetInput(ServiceRequest): + StackName: StackNameOrId + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + UsePreviousTemplate: Optional[UsePreviousTemplate] + Parameters: Optional[Parameters] + Capabilities: Optional[Capabilities] + ResourceTypes: Optional[ResourceTypes] + RoleARN: Optional[RoleARN] + RollbackConfiguration: Optional[RollbackConfiguration] + NotificationARNs: Optional[NotificationARNs] + Tags: Optional[Tags] + ChangeSetName: ChangeSetName + ClientToken: Optional[ClientToken] + Description: Optional[Description] + ChangeSetType: Optional[ChangeSetType] + ResourcesToImport: Optional[ResourcesToImport] + IncludeNestedStacks: Optional[IncludeNestedStacks] + OnStackFailure: Optional[OnStackFailure] + ImportExistingResources: Optional[ImportExistingResources] + + +class CreateChangeSetOutput(TypedDict, total=False): + Id: Optional[ChangeSetId] + StackId: Optional[StackId] + + +class TemplateConfiguration(TypedDict, total=False): + DeletionPolicy: Optional[GeneratedTemplateDeletionPolicy] + UpdateReplacePolicy: Optional[GeneratedTemplateUpdateReplacePolicy] + + +class ResourceDefinition(TypedDict, total=False): + ResourceType: ResourceType + LogicalResourceId: Optional[LogicalResourceId] + ResourceIdentifier: ResourceIdentifierProperties + + +ResourceDefinitions = List[ResourceDefinition] + + +class CreateGeneratedTemplateInput(ServiceRequest): + Resources: Optional[ResourceDefinitions] + GeneratedTemplateName: GeneratedTemplateName + StackName: Optional[StackName] + TemplateConfiguration: Optional[TemplateConfiguration] + + +class CreateGeneratedTemplateOutput(TypedDict, total=False): + GeneratedTemplateId: Optional[GeneratedTemplateId] + + +class CreateStackInput(ServiceRequest): + StackName: StackName + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + Parameters: Optional[Parameters] + DisableRollback: Optional[DisableRollback] + RollbackConfiguration: Optional[RollbackConfiguration] + TimeoutInMinutes: Optional[TimeoutMinutes] + NotificationARNs: Optional[NotificationARNs] + Capabilities: Optional[Capabilities] + ResourceTypes: Optional[ResourceTypes] + RoleARN: Optional[RoleARN] + OnFailure: Optional[OnFailure] + StackPolicyBody: Optional[StackPolicyBody] + StackPolicyURL: Optional[StackPolicyURL] + Tags: Optional[Tags] + ClientRequestToken: Optional[ClientRequestToken] + EnableTerminationProtection: Optional[EnableTerminationProtection] + RetainExceptOnCreate: Optional[RetainExceptOnCreate] + + +RegionList = List[Region] + + +class StackSetOperationPreferences(TypedDict, total=False): + RegionConcurrencyType: Optional[RegionConcurrencyType] + RegionOrder: Optional[RegionList] + FailureToleranceCount: Optional[FailureToleranceCount] + FailureTolerancePercentage: Optional[FailureTolerancePercentage] + MaxConcurrentCount: Optional[MaxConcurrentCount] + MaxConcurrentPercentage: Optional[MaxConcurrentPercentage] + ConcurrencyMode: Optional[ConcurrencyMode] + + +OrganizationalUnitIdList = List[OrganizationalUnitId] + + +class DeploymentTargets(TypedDict, total=False): + Accounts: Optional[AccountList] + AccountsUrl: Optional[AccountsUrl] + OrganizationalUnitIds: Optional[OrganizationalUnitIdList] + AccountFilterType: Optional[AccountFilterType] + + +class CreateStackInstancesInput(ServiceRequest): + StackSetName: StackSetName + Accounts: Optional[AccountList] + DeploymentTargets: Optional[DeploymentTargets] + Regions: RegionList + ParameterOverrides: Optional[Parameters] + OperationPreferences: Optional[StackSetOperationPreferences] + OperationId: Optional[ClientRequestToken] + CallAs: Optional[CallAs] + + +class CreateStackInstancesOutput(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + + +class CreateStackOutput(TypedDict, total=False): + StackId: Optional[StackId] + + +class StackDefinition(TypedDict, total=False): + StackName: Optional[StackName] + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + + +StackDefinitions = List[StackDefinition] + + +class ResourceLocation(TypedDict, total=False): + StackName: StackName + LogicalResourceId: LogicalResourceId + + +class ResourceMapping(TypedDict, total=False): + Source: ResourceLocation + Destination: ResourceLocation + + +ResourceMappings = List[ResourceMapping] + + +class CreateStackRefactorInput(ServiceRequest): + Description: Optional[Description] + EnableStackCreation: Optional[EnableStackCreation] + ResourceMappings: Optional[ResourceMappings] + StackDefinitions: StackDefinitions + + +class CreateStackRefactorOutput(TypedDict, total=False): + StackRefactorId: StackRefactorId + + +class ManagedExecution(TypedDict, total=False): + Active: Optional[ManagedExecutionNullable] + + +class CreateStackSetInput(ServiceRequest): + StackSetName: StackSetName + Description: Optional[Description] + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + StackId: Optional[StackId] + Parameters: Optional[Parameters] + Capabilities: Optional[Capabilities] + Tags: Optional[Tags] + AdministrationRoleARN: Optional[RoleARN] + ExecutionRoleName: Optional[ExecutionRoleName] + PermissionModel: Optional[PermissionModels] + AutoDeployment: Optional[AutoDeployment] + CallAs: Optional[CallAs] + ClientRequestToken: Optional[ClientRequestToken] + ManagedExecution: Optional[ManagedExecution] + + +class CreateStackSetOutput(TypedDict, total=False): + StackSetId: Optional[StackSetId] + + +class DeactivateOrganizationsAccessInput(ServiceRequest): + pass + + +class DeactivateOrganizationsAccessOutput(TypedDict, total=False): + pass + + +class DeactivateTypeInput(ServiceRequest): + TypeName: Optional[TypeName] + Type: Optional[ThirdPartyType] + Arn: Optional[PrivateTypeArn] + + +class DeactivateTypeOutput(TypedDict, total=False): + pass + + +class DeleteChangeSetInput(ServiceRequest): + ChangeSetName: ChangeSetNameOrId + StackName: Optional[StackNameOrId] + + +class DeleteChangeSetOutput(TypedDict, total=False): + pass + + +class DeleteGeneratedTemplateInput(ServiceRequest): + GeneratedTemplateName: GeneratedTemplateName + + +RetainResources = List[LogicalResourceId] + + +class DeleteStackInput(ServiceRequest): + StackName: StackName + RetainResources: Optional[RetainResources] + RoleARN: Optional[RoleARN] + ClientRequestToken: Optional[ClientRequestToken] + DeletionMode: Optional[DeletionMode] + + +class DeleteStackInstancesInput(ServiceRequest): + StackSetName: StackSetName + Accounts: Optional[AccountList] + DeploymentTargets: Optional[DeploymentTargets] + Regions: RegionList + OperationPreferences: Optional[StackSetOperationPreferences] + RetainStacks: RetainStacks + OperationId: Optional[ClientRequestToken] + CallAs: Optional[CallAs] + + +class DeleteStackInstancesOutput(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + + +class DeleteStackSetInput(ServiceRequest): + StackSetName: StackSetName + CallAs: Optional[CallAs] + + +class DeleteStackSetOutput(TypedDict, total=False): + pass + + +DeletionTime = datetime + + +class DeregisterTypeInput(ServiceRequest): + Arn: Optional[PrivateTypeArn] + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + VersionId: Optional[TypeVersionId] + + +class DeregisterTypeOutput(TypedDict, total=False): + pass + + +class DescribeAccountLimitsInput(ServiceRequest): + NextToken: Optional[NextToken] + + +class DescribeAccountLimitsOutput(TypedDict, total=False): + AccountLimits: Optional[AccountLimitList] + NextToken: Optional[NextToken] + + +class DescribeChangeSetHooksInput(ServiceRequest): + ChangeSetName: ChangeSetNameOrId + StackName: Optional[StackNameOrId] + NextToken: Optional[NextToken] + LogicalResourceId: Optional[LogicalResourceId] + + +class DescribeChangeSetHooksOutput(TypedDict, total=False): + ChangeSetId: Optional[ChangeSetId] + ChangeSetName: Optional[ChangeSetName] + Hooks: Optional[ChangeSetHooks] + Status: Optional[ChangeSetHooksStatus] + NextToken: Optional[NextToken] + StackId: Optional[StackId] + StackName: Optional[StackName] + + +class DescribeChangeSetInput(ServiceRequest): + ChangeSetName: ChangeSetNameOrId + StackName: Optional[StackNameOrId] + NextToken: Optional[NextToken] + IncludePropertyValues: Optional[IncludePropertyValues] + + +class DescribeChangeSetOutput(TypedDict, total=False): + ChangeSetName: Optional[ChangeSetName] + ChangeSetId: Optional[ChangeSetId] + StackId: Optional[StackId] + StackName: Optional[StackName] + Description: Optional[Description] + Parameters: Optional[Parameters] + CreationTime: Optional[CreationTime] + ExecutionStatus: Optional[ExecutionStatus] + Status: Optional[ChangeSetStatus] + StatusReason: Optional[ChangeSetStatusReason] + NotificationARNs: Optional[NotificationARNs] + RollbackConfiguration: Optional[RollbackConfiguration] + Capabilities: Optional[Capabilities] + Tags: Optional[Tags] + Changes: Optional[Changes] + NextToken: Optional[NextToken] + IncludeNestedStacks: Optional[IncludeNestedStacks] + ParentChangeSetId: Optional[ChangeSetId] + RootChangeSetId: Optional[ChangeSetId] + OnStackFailure: Optional[OnStackFailure] + ImportExistingResources: Optional[ImportExistingResources] + + +class DescribeGeneratedTemplateInput(ServiceRequest): + GeneratedTemplateName: GeneratedTemplateName + + +class TemplateProgress(TypedDict, total=False): + ResourcesSucceeded: Optional[ResourcesSucceeded] + ResourcesFailed: Optional[ResourcesFailed] + ResourcesProcessing: Optional[ResourcesProcessing] + ResourcesPending: Optional[ResourcesPending] + + +LastUpdatedTime = datetime + + +class WarningProperty(TypedDict, total=False): + PropertyPath: Optional[PropertyPath] + Required: Optional[RequiredProperty] + Description: Optional[PropertyDescription] + + +WarningProperties = List[WarningProperty] + + +class WarningDetail(TypedDict, total=False): + Type: Optional[WarningType] + Properties: Optional[WarningProperties] + + +WarningDetails = List[WarningDetail] + + +class ResourceDetail(TypedDict, total=False): + ResourceType: Optional[ResourceType] + LogicalResourceId: Optional[LogicalResourceId] + ResourceIdentifier: Optional[ResourceIdentifierProperties] + ResourceStatus: Optional[GeneratedTemplateResourceStatus] + ResourceStatusReason: Optional[ResourceStatusReason] + Warnings: Optional[WarningDetails] + + +ResourceDetails = List[ResourceDetail] + + +class DescribeGeneratedTemplateOutput(TypedDict, total=False): + GeneratedTemplateId: Optional[GeneratedTemplateId] + GeneratedTemplateName: Optional[GeneratedTemplateName] + Resources: Optional[ResourceDetails] + Status: Optional[GeneratedTemplateStatus] + StatusReason: Optional[TemplateStatusReason] + CreationTime: Optional[CreationTime] + LastUpdatedTime: Optional[LastUpdatedTime] + Progress: Optional[TemplateProgress] + StackId: Optional[StackId] + TemplateConfiguration: Optional[TemplateConfiguration] + TotalWarnings: Optional[TotalWarnings] + + +class DescribeOrganizationsAccessInput(ServiceRequest): + CallAs: Optional[CallAs] + + +class DescribeOrganizationsAccessOutput(TypedDict, total=False): + Status: Optional[OrganizationStatus] + + +class DescribePublisherInput(ServiceRequest): + PublisherId: Optional[PublisherId] + + +class DescribePublisherOutput(TypedDict, total=False): + PublisherId: Optional[PublisherId] + PublisherStatus: Optional[PublisherStatus] + IdentityProvider: Optional[IdentityProvider] + PublisherProfile: Optional[PublisherProfile] + + +class DescribeResourceScanInput(ServiceRequest): + ResourceScanId: ResourceScanId + + +ResourceTypeFilters = List[ResourceTypeFilter] + + +class ScanFilter(TypedDict, total=False): + Types: Optional[ResourceTypeFilters] + + +ScanFilters = List[ScanFilter] + + +class DescribeResourceScanOutput(TypedDict, total=False): + ResourceScanId: Optional[ResourceScanId] + Status: Optional[ResourceScanStatus] + StatusReason: Optional[ResourceScanStatusReason] + StartTime: Optional[Timestamp] + EndTime: Optional[Timestamp] + PercentageCompleted: Optional[PercentageCompleted] + ResourceTypes: Optional[ResourceTypes] + ResourcesScanned: Optional[ResourcesScanned] + ResourcesRead: Optional[ResourcesRead] + ScanFilters: Optional[ScanFilters] + + +class DescribeStackDriftDetectionStatusInput(ServiceRequest): + StackDriftDetectionId: StackDriftDetectionId + + +class DescribeStackDriftDetectionStatusOutput(TypedDict, total=False): + StackId: StackId + StackDriftDetectionId: StackDriftDetectionId + StackDriftStatus: Optional[StackDriftStatus] + DetectionStatus: StackDriftDetectionStatus + DetectionStatusReason: Optional[StackDriftDetectionStatusReason] + DriftedStackResourceCount: Optional[BoxedInteger] + Timestamp: Timestamp + + +class DescribeStackEventsInput(ServiceRequest): + StackName: Optional[StackName] + NextToken: Optional[NextToken] + + +class StackEvent(TypedDict, total=False): + StackId: StackId + EventId: EventId + StackName: StackName + LogicalResourceId: Optional[LogicalResourceId] + PhysicalResourceId: Optional[PhysicalResourceId] + ResourceType: Optional[ResourceType] + Timestamp: Timestamp + ResourceStatus: Optional[ResourceStatus] + ResourceStatusReason: Optional[ResourceStatusReason] + ResourceProperties: Optional[ResourceProperties] + ClientRequestToken: Optional[ClientRequestToken] + HookType: Optional[HookType] + HookStatus: Optional[HookStatus] + HookStatusReason: Optional[HookStatusReason] + HookInvocationPoint: Optional[HookInvocationPoint] + HookFailureMode: Optional[HookFailureMode] + DetailedStatus: Optional[DetailedStatus] + + +StackEvents = List[StackEvent] + + +class DescribeStackEventsOutput(TypedDict, total=False): + StackEvents: Optional[StackEvents] + NextToken: Optional[NextToken] + + +class DescribeStackInstanceInput(ServiceRequest): + StackSetName: StackSetName + StackInstanceAccount: Account + StackInstanceRegion: Region + CallAs: Optional[CallAs] + + +class StackInstanceComprehensiveStatus(TypedDict, total=False): + DetailedStatus: Optional[StackInstanceDetailedStatus] + + +class StackInstance(TypedDict, total=False): + StackSetId: Optional[StackSetId] + Region: Optional[Region] + Account: Optional[Account] + StackId: Optional[StackId] + ParameterOverrides: Optional[Parameters] + Status: Optional[StackInstanceStatus] + StackInstanceStatus: Optional[StackInstanceComprehensiveStatus] + StatusReason: Optional[Reason] + OrganizationalUnitId: Optional[OrganizationalUnitId] + DriftStatus: Optional[StackDriftStatus] + LastDriftCheckTimestamp: Optional[Timestamp] + LastOperationId: Optional[ClientRequestToken] + + +class DescribeStackInstanceOutput(TypedDict, total=False): + StackInstance: Optional[StackInstance] + + +class DescribeStackRefactorInput(ServiceRequest): + StackRefactorId: StackRefactorId + + +StackIds = List[StackId] + + +class DescribeStackRefactorOutput(TypedDict, total=False): + Description: Optional[Description] + StackRefactorId: Optional[StackRefactorId] + StackIds: Optional[StackIds] + ExecutionStatus: Optional[StackRefactorExecutionStatus] + ExecutionStatusReason: Optional[ExecutionStatusReason] + Status: Optional[StackRefactorStatus] + StatusReason: Optional[StackRefactorStatusReason] + + +StackResourceDriftStatusFilters = List[StackResourceDriftStatus] + + +class DescribeStackResourceDriftsInput(ServiceRequest): + StackName: StackNameOrId + StackResourceDriftStatusFilters: Optional[StackResourceDriftStatusFilters] + NextToken: Optional[NextToken] + MaxResults: Optional[BoxedMaxResults] + + +class PropertyDifference(TypedDict, total=False): + PropertyPath: PropertyPath + ExpectedValue: PropertyValue + ActualValue: PropertyValue + DifferenceType: DifferenceType + + +PropertyDifferences = List[PropertyDifference] + + +class PhysicalResourceIdContextKeyValuePair(TypedDict, total=False): + Key: Key + Value: Value + + +PhysicalResourceIdContext = List[PhysicalResourceIdContextKeyValuePair] + + +class StackResourceDrift(TypedDict, total=False): + StackId: StackId + LogicalResourceId: LogicalResourceId + PhysicalResourceId: Optional[PhysicalResourceId] + PhysicalResourceIdContext: Optional[PhysicalResourceIdContext] + ResourceType: ResourceType + ExpectedProperties: Optional[Properties] + ActualProperties: Optional[Properties] + PropertyDifferences: Optional[PropertyDifferences] + StackResourceDriftStatus: StackResourceDriftStatus + Timestamp: Timestamp + ModuleInfo: Optional[ModuleInfo] + DriftStatusReason: Optional[StackResourceDriftStatusReason] + + +StackResourceDrifts = List[StackResourceDrift] + + +class DescribeStackResourceDriftsOutput(TypedDict, total=False): + StackResourceDrifts: StackResourceDrifts + NextToken: Optional[NextToken] + + +class DescribeStackResourceInput(ServiceRequest): + StackName: StackName + LogicalResourceId: LogicalResourceId + + +class StackResourceDriftInformation(TypedDict, total=False): + StackResourceDriftStatus: StackResourceDriftStatus + LastCheckTimestamp: Optional[Timestamp] + + +class StackResourceDetail(TypedDict, total=False): + StackName: Optional[StackName] + StackId: Optional[StackId] + LogicalResourceId: LogicalResourceId + PhysicalResourceId: Optional[PhysicalResourceId] + ResourceType: ResourceType + LastUpdatedTimestamp: Timestamp + ResourceStatus: ResourceStatus + ResourceStatusReason: Optional[ResourceStatusReason] + Description: Optional[Description] + Metadata: Optional[Metadata] + DriftInformation: Optional[StackResourceDriftInformation] + ModuleInfo: Optional[ModuleInfo] + + +class DescribeStackResourceOutput(TypedDict, total=False): + StackResourceDetail: Optional[StackResourceDetail] + + +class DescribeStackResourcesInput(ServiceRequest): + StackName: Optional[StackName] + LogicalResourceId: Optional[LogicalResourceId] + PhysicalResourceId: Optional[PhysicalResourceId] + + +class StackResource(TypedDict, total=False): + StackName: Optional[StackName] + StackId: Optional[StackId] + LogicalResourceId: LogicalResourceId + PhysicalResourceId: Optional[PhysicalResourceId] + ResourceType: ResourceType + Timestamp: Timestamp + ResourceStatus: ResourceStatus + ResourceStatusReason: Optional[ResourceStatusReason] + Description: Optional[Description] + DriftInformation: Optional[StackResourceDriftInformation] + ModuleInfo: Optional[ModuleInfo] + + +StackResources = List[StackResource] + + +class DescribeStackResourcesOutput(TypedDict, total=False): + StackResources: Optional[StackResources] + + +class DescribeStackSetInput(ServiceRequest): + StackSetName: StackSetName + CallAs: Optional[CallAs] + + +class DescribeStackSetOperationInput(ServiceRequest): + StackSetName: StackSetName + OperationId: ClientRequestToken + CallAs: Optional[CallAs] + + +class StackSetOperationStatusDetails(TypedDict, total=False): + FailedStackInstancesCount: Optional[FailedStackInstancesCount] + + +class StackSetDriftDetectionDetails(TypedDict, total=False): + DriftStatus: Optional[StackSetDriftStatus] + DriftDetectionStatus: Optional[StackSetDriftDetectionStatus] + LastDriftCheckTimestamp: Optional[Timestamp] + TotalStackInstancesCount: Optional[TotalStackInstancesCount] + DriftedStackInstancesCount: Optional[DriftedStackInstancesCount] + InSyncStackInstancesCount: Optional[InSyncStackInstancesCount] + InProgressStackInstancesCount: Optional[InProgressStackInstancesCount] + FailedStackInstancesCount: Optional[FailedStackInstancesCount] + + +class StackSetOperation(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + StackSetId: Optional[StackSetId] + Action: Optional[StackSetOperationAction] + Status: Optional[StackSetOperationStatus] + OperationPreferences: Optional[StackSetOperationPreferences] + RetainStacks: Optional[RetainStacksNullable] + AdministrationRoleARN: Optional[RoleARN] + ExecutionRoleName: Optional[ExecutionRoleName] + CreationTimestamp: Optional[Timestamp] + EndTimestamp: Optional[Timestamp] + DeploymentTargets: Optional[DeploymentTargets] + StackSetDriftDetectionDetails: Optional[StackSetDriftDetectionDetails] + StatusReason: Optional[StackSetOperationStatusReason] + StatusDetails: Optional[StackSetOperationStatusDetails] + + +class DescribeStackSetOperationOutput(TypedDict, total=False): + StackSetOperation: Optional[StackSetOperation] + + +class StackSet(TypedDict, total=False): + StackSetName: Optional[StackSetName] + StackSetId: Optional[StackSetId] + Description: Optional[Description] + Status: Optional[StackSetStatus] + TemplateBody: Optional[TemplateBody] + Parameters: Optional[Parameters] + Capabilities: Optional[Capabilities] + Tags: Optional[Tags] + StackSetARN: Optional[StackSetARN] + AdministrationRoleARN: Optional[RoleARN] + ExecutionRoleName: Optional[ExecutionRoleName] + StackSetDriftDetectionDetails: Optional[StackSetDriftDetectionDetails] + AutoDeployment: Optional[AutoDeployment] + PermissionModel: Optional[PermissionModels] + OrganizationalUnitIds: Optional[OrganizationalUnitIdList] + ManagedExecution: Optional[ManagedExecution] + Regions: Optional[RegionList] + + +class DescribeStackSetOutput(TypedDict, total=False): + StackSet: Optional[StackSet] + + +class DescribeStacksInput(ServiceRequest): + StackName: Optional[StackName] + NextToken: Optional[NextToken] + + +class StackDriftInformation(TypedDict, total=False): + StackDriftStatus: StackDriftStatus + LastCheckTimestamp: Optional[Timestamp] + + +class Output(TypedDict, total=False): + OutputKey: Optional[OutputKey] + OutputValue: Optional[OutputValue] + Description: Optional[Description] + ExportName: Optional[ExportName] + + +Outputs = List[Output] + + +class Stack(TypedDict, total=False): + StackId: Optional[StackId] + StackName: StackName + ChangeSetId: Optional[ChangeSetId] + Description: Optional[Description] + Parameters: Optional[Parameters] + CreationTime: CreationTime + DeletionTime: Optional[DeletionTime] + LastUpdatedTime: Optional[LastUpdatedTime] + RollbackConfiguration: Optional[RollbackConfiguration] + StackStatus: StackStatus + StackStatusReason: Optional[StackStatusReason] + DisableRollback: Optional[DisableRollback] + NotificationARNs: Optional[NotificationARNs] + TimeoutInMinutes: Optional[TimeoutMinutes] + Capabilities: Optional[Capabilities] + Outputs: Optional[Outputs] + RoleARN: Optional[RoleARN] + Tags: Optional[Tags] + EnableTerminationProtection: Optional[EnableTerminationProtection] + ParentId: Optional[StackId] + RootId: Optional[StackId] + DriftInformation: Optional[StackDriftInformation] + RetainExceptOnCreate: Optional[RetainExceptOnCreate] + DeletionMode: Optional[DeletionMode] + DetailedStatus: Optional[DetailedStatus] + + +Stacks = List[Stack] + + +class DescribeStacksOutput(TypedDict, total=False): + Stacks: Optional[Stacks] + NextToken: Optional[NextToken] + + +class DescribeTypeInput(ServiceRequest): + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + Arn: Optional[TypeArn] + VersionId: Optional[TypeVersionId] + PublisherId: Optional[PublisherId] + PublicVersionNumber: Optional[PublicVersionNumber] + + +SupportedMajorVersions = List[SupportedMajorVersion] + + +class RequiredActivatedType(TypedDict, total=False): + TypeNameAlias: Optional[TypeName] + OriginalTypeName: Optional[TypeName] + PublisherId: Optional[PublisherId] + SupportedMajorVersions: Optional[SupportedMajorVersions] + + +RequiredActivatedTypes = List[RequiredActivatedType] + + +class DescribeTypeOutput(TypedDict, total=False): + Arn: Optional[TypeArn] + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + DefaultVersionId: Optional[TypeVersionId] + IsDefaultVersion: Optional[IsDefaultVersion] + TypeTestsStatus: Optional[TypeTestsStatus] + TypeTestsStatusDescription: Optional[TypeTestsStatusDescription] + Description: Optional[Description] + Schema: Optional[TypeSchema] + ProvisioningType: Optional[ProvisioningType] + DeprecatedStatus: Optional[DeprecatedStatus] + LoggingConfig: Optional[LoggingConfig] + RequiredActivatedTypes: Optional[RequiredActivatedTypes] + ExecutionRoleArn: Optional[RoleArn] + Visibility: Optional[Visibility] + SourceUrl: Optional[OptionalSecureUrl] + DocumentationUrl: Optional[OptionalSecureUrl] + LastUpdated: Optional[Timestamp] + TimeCreated: Optional[Timestamp] + ConfigurationSchema: Optional[ConfigurationSchema] + PublisherId: Optional[PublisherId] + OriginalTypeName: Optional[TypeName] + OriginalTypeArn: Optional[TypeArn] + PublicVersionNumber: Optional[PublicVersionNumber] + LatestPublicVersion: Optional[PublicVersionNumber] + IsActivated: Optional[IsActivated] + AutoUpdate: Optional[AutoUpdate] + + +class DescribeTypeRegistrationInput(ServiceRequest): + RegistrationToken: RegistrationToken + + +class DescribeTypeRegistrationOutput(TypedDict, total=False): + ProgressStatus: Optional[RegistrationStatus] + Description: Optional[Description] + TypeArn: Optional[TypeArn] + TypeVersionArn: Optional[TypeArn] + + +LogicalResourceIds = List[LogicalResourceId] + + +class DetectStackDriftInput(ServiceRequest): + StackName: StackNameOrId + LogicalResourceIds: Optional[LogicalResourceIds] + + +class DetectStackDriftOutput(TypedDict, total=False): + StackDriftDetectionId: StackDriftDetectionId + + +class DetectStackResourceDriftInput(ServiceRequest): + StackName: StackNameOrId + LogicalResourceId: LogicalResourceId + + +class DetectStackResourceDriftOutput(TypedDict, total=False): + StackResourceDrift: StackResourceDrift + + +class DetectStackSetDriftInput(ServiceRequest): + StackSetName: StackSetNameOrId + OperationPreferences: Optional[StackSetOperationPreferences] + OperationId: Optional[ClientRequestToken] + CallAs: Optional[CallAs] + + +class DetectStackSetDriftOutput(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + + +class EstimateTemplateCostInput(ServiceRequest): + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + Parameters: Optional[Parameters] + + +class EstimateTemplateCostOutput(TypedDict, total=False): + Url: Optional[Url] + + +class ExecuteChangeSetInput(ServiceRequest): + ChangeSetName: ChangeSetNameOrId + StackName: Optional[StackNameOrId] + ClientRequestToken: Optional[ClientRequestToken] + DisableRollback: Optional[DisableRollback] + RetainExceptOnCreate: Optional[RetainExceptOnCreate] + + +class ExecuteChangeSetOutput(TypedDict, total=False): + pass + + +class ExecuteStackRefactorInput(ServiceRequest): + StackRefactorId: StackRefactorId + + +class Export(TypedDict, total=False): + ExportingStackId: Optional[StackId] + Name: Optional[ExportName] + Value: Optional[ExportValue] + + +Exports = List[Export] + + +class GetGeneratedTemplateInput(ServiceRequest): + Format: Optional[TemplateFormat] + GeneratedTemplateName: GeneratedTemplateName + + +class GetGeneratedTemplateOutput(TypedDict, total=False): + Status: Optional[GeneratedTemplateStatus] + TemplateBody: Optional[TemplateBody] + + +class GetStackPolicyInput(ServiceRequest): + StackName: StackName + + +class GetStackPolicyOutput(TypedDict, total=False): + StackPolicyBody: Optional[StackPolicyBody] + + +class GetTemplateInput(ServiceRequest): + StackName: Optional[StackName] + ChangeSetName: Optional[ChangeSetNameOrId] + TemplateStage: Optional[TemplateStage] + + +StageList = List[TemplateStage] + + +class GetTemplateOutput(TypedDict, total=False): + TemplateBody: Optional[TemplateBody] + StagesAvailable: Optional[StageList] + + +class TemplateSummaryConfig(TypedDict, total=False): + TreatUnrecognizedResourceTypesAsWarnings: Optional[TreatUnrecognizedResourceTypesAsWarnings] + + +class GetTemplateSummaryInput(ServiceRequest): + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + StackName: Optional[StackNameOrId] + StackSetName: Optional[StackSetNameOrId] + CallAs: Optional[CallAs] + TemplateSummaryConfig: Optional[TemplateSummaryConfig] + + +class Warnings(TypedDict, total=False): + UnrecognizedResourceTypes: Optional[ResourceTypes] + + +ResourceIdentifiers = List[ResourceIdentifierPropertyKey] + + +class ResourceIdentifierSummary(TypedDict, total=False): + ResourceType: Optional[ResourceType] + LogicalResourceIds: Optional[LogicalResourceIds] + ResourceIdentifiers: Optional[ResourceIdentifiers] + + +ResourceIdentifierSummaries = List[ResourceIdentifierSummary] +TransformsList = List[TransformName] + + +class ParameterConstraints(TypedDict, total=False): + AllowedValues: Optional[AllowedValues] + + +class ParameterDeclaration(TypedDict, total=False): + ParameterKey: Optional[ParameterKey] + DefaultValue: Optional[ParameterValue] + ParameterType: Optional[ParameterType] + NoEcho: Optional[NoEcho] + Description: Optional[Description] + ParameterConstraints: Optional[ParameterConstraints] + + +ParameterDeclarations = List[ParameterDeclaration] + + +class GetTemplateSummaryOutput(TypedDict, total=False): + Parameters: Optional[ParameterDeclarations] + Description: Optional[Description] + Capabilities: Optional[Capabilities] + CapabilitiesReason: Optional[CapabilitiesReason] + ResourceTypes: Optional[ResourceTypes] + Version: Optional[Version] + Metadata: Optional[Metadata] + DeclaredTransforms: Optional[TransformsList] + ResourceIdentifierSummaries: Optional[ResourceIdentifierSummaries] + Warnings: Optional[Warnings] + + +class HookResultSummary(TypedDict, total=False): + InvocationPoint: Optional[HookInvocationPoint] + FailureMode: Optional[HookFailureMode] + TypeName: Optional[HookTypeName] + TypeVersionId: Optional[HookTypeVersionId] + TypeConfigurationVersionId: Optional[HookTypeConfigurationVersionId] + Status: Optional[HookStatus] + HookStatusReason: Optional[HookStatusReason] + + +HookResultSummaries = List[HookResultSummary] +StackIdList = List[StackId] + + +class ImportStacksToStackSetInput(ServiceRequest): + StackSetName: StackSetNameOrId + StackIds: Optional[StackIdList] + StackIdsUrl: Optional[StackIdsUrl] + OrganizationalUnitIds: Optional[OrganizationalUnitIdList] + OperationPreferences: Optional[StackSetOperationPreferences] + OperationId: Optional[ClientRequestToken] + CallAs: Optional[CallAs] + + +class ImportStacksToStackSetOutput(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + + +Imports = List[StackName] +JazzLogicalResourceIds = List[LogicalResourceId] +JazzResourceIdentifierProperties = Dict[ + JazzResourceIdentifierPropertyKey, JazzResourceIdentifierPropertyValue +] + + +class ListChangeSetsInput(ServiceRequest): + StackName: StackNameOrId + NextToken: Optional[NextToken] + + +class ListChangeSetsOutput(TypedDict, total=False): + Summaries: Optional[ChangeSetSummaries] + NextToken: Optional[NextToken] + + +class ListExportsInput(ServiceRequest): + NextToken: Optional[NextToken] + + +class ListExportsOutput(TypedDict, total=False): + Exports: Optional[Exports] + NextToken: Optional[NextToken] + + +class ListGeneratedTemplatesInput(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class TemplateSummary(TypedDict, total=False): + GeneratedTemplateId: Optional[GeneratedTemplateId] + GeneratedTemplateName: Optional[GeneratedTemplateName] + Status: Optional[GeneratedTemplateStatus] + StatusReason: Optional[TemplateStatusReason] + CreationTime: Optional[CreationTime] + LastUpdatedTime: Optional[LastUpdatedTime] + NumberOfResources: Optional[NumberOfResources] + + +TemplateSummaries = List[TemplateSummary] + + +class ListGeneratedTemplatesOutput(TypedDict, total=False): + Summaries: Optional[TemplateSummaries] + NextToken: Optional[NextToken] + + +class ListHookResultsInput(ServiceRequest): + TargetType: ListHookResultsTargetType + TargetId: HookResultId + NextToken: Optional[NextToken] + + +class ListHookResultsOutput(TypedDict, total=False): + TargetType: Optional[ListHookResultsTargetType] + TargetId: Optional[HookResultId] + HookResults: Optional[HookResultSummaries] + NextToken: Optional[NextToken] + + +class ListImportsInput(ServiceRequest): + ExportName: ExportName + NextToken: Optional[NextToken] + + +class ListImportsOutput(TypedDict, total=False): + Imports: Optional[Imports] + NextToken: Optional[NextToken] + + +class ScannedResourceIdentifier(TypedDict, total=False): + ResourceType: ResourceType + ResourceIdentifier: JazzResourceIdentifierProperties + + +ScannedResourceIdentifiers = List[ScannedResourceIdentifier] + + +class ListResourceScanRelatedResourcesInput(ServiceRequest): + ResourceScanId: ResourceScanId + Resources: ScannedResourceIdentifiers + NextToken: Optional[NextToken] + MaxResults: Optional[BoxedMaxResults] + + +class ScannedResource(TypedDict, total=False): + ResourceType: Optional[ResourceType] + ResourceIdentifier: Optional[JazzResourceIdentifierProperties] + ManagedByStack: Optional[ManagedByStack] + + +RelatedResources = List[ScannedResource] + + +class ListResourceScanRelatedResourcesOutput(TypedDict, total=False): + RelatedResources: Optional[RelatedResources] + NextToken: Optional[NextToken] + + +class ListResourceScanResourcesInput(ServiceRequest): + ResourceScanId: ResourceScanId + ResourceIdentifier: Optional[ResourceIdentifier] + ResourceTypePrefix: Optional[ResourceTypePrefix] + TagKey: Optional[TagKey] + TagValue: Optional[TagValue] + NextToken: Optional[NextToken] + MaxResults: Optional[ResourceScannerMaxResults] + + +ScannedResources = List[ScannedResource] + + +class ListResourceScanResourcesOutput(TypedDict, total=False): + Resources: Optional[ScannedResources] + NextToken: Optional[NextToken] + + +class ListResourceScansInput(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[ResourceScannerMaxResults] + ScanTypeFilter: Optional[ScanType] + + +class ResourceScanSummary(TypedDict, total=False): + ResourceScanId: Optional[ResourceScanId] + Status: Optional[ResourceScanStatus] + StatusReason: Optional[ResourceScanStatusReason] + StartTime: Optional[Timestamp] + EndTime: Optional[Timestamp] + PercentageCompleted: Optional[PercentageCompleted] + ScanType: Optional[ScanType] + + +ResourceScanSummaries = List[ResourceScanSummary] + + +class ListResourceScansOutput(TypedDict, total=False): + ResourceScanSummaries: Optional[ResourceScanSummaries] + NextToken: Optional[NextToken] + + +class ListStackInstanceResourceDriftsInput(ServiceRequest): + StackSetName: StackSetNameOrId + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + StackInstanceResourceDriftStatuses: Optional[StackResourceDriftStatusFilters] + StackInstanceAccount: Account + StackInstanceRegion: Region + OperationId: ClientRequestToken + CallAs: Optional[CallAs] + + +class StackInstanceResourceDriftsSummary(TypedDict, total=False): + StackId: StackId + LogicalResourceId: LogicalResourceId + PhysicalResourceId: Optional[PhysicalResourceId] + PhysicalResourceIdContext: Optional[PhysicalResourceIdContext] + ResourceType: ResourceType + PropertyDifferences: Optional[PropertyDifferences] + StackResourceDriftStatus: StackResourceDriftStatus + Timestamp: Timestamp + + +StackInstanceResourceDriftsSummaries = List[StackInstanceResourceDriftsSummary] + + +class ListStackInstanceResourceDriftsOutput(TypedDict, total=False): + Summaries: Optional[StackInstanceResourceDriftsSummaries] + NextToken: Optional[NextToken] + + +class StackInstanceFilter(TypedDict, total=False): + Name: Optional[StackInstanceFilterName] + Values: Optional[StackInstanceFilterValues] + + +StackInstanceFilters = List[StackInstanceFilter] + + +class ListStackInstancesInput(ServiceRequest): + StackSetName: StackSetName + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + Filters: Optional[StackInstanceFilters] + StackInstanceAccount: Optional[Account] + StackInstanceRegion: Optional[Region] + CallAs: Optional[CallAs] + + +class StackInstanceSummary(TypedDict, total=False): + StackSetId: Optional[StackSetId] + Region: Optional[Region] + Account: Optional[Account] + StackId: Optional[StackId] + Status: Optional[StackInstanceStatus] + StatusReason: Optional[Reason] + StackInstanceStatus: Optional[StackInstanceComprehensiveStatus] + OrganizationalUnitId: Optional[OrganizationalUnitId] + DriftStatus: Optional[StackDriftStatus] + LastDriftCheckTimestamp: Optional[Timestamp] + LastOperationId: Optional[ClientRequestToken] + + +StackInstanceSummaries = List[StackInstanceSummary] + + +class ListStackInstancesOutput(TypedDict, total=False): + Summaries: Optional[StackInstanceSummaries] + NextToken: Optional[NextToken] + + +class ListStackRefactorActionsInput(ServiceRequest): + StackRefactorId: StackRefactorId + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +StackRefactorUntagResources = List[TagKey] +StackRefactorTagResources = List[Tag] + + +class StackRefactorAction(TypedDict, total=False): + Action: Optional[StackRefactorActionType] + Entity: Optional[StackRefactorActionEntity] + PhysicalResourceId: Optional[PhysicalResourceId] + ResourceIdentifier: Optional[StackRefactorResourceIdentifier] + Description: Optional[Description] + Detection: Optional[StackRefactorDetection] + DetectionReason: Optional[DetectionReason] + TagResources: Optional[StackRefactorTagResources] + UntagResources: Optional[StackRefactorUntagResources] + ResourceMapping: Optional[ResourceMapping] + + +StackRefactorActions = List[StackRefactorAction] + + +class ListStackRefactorActionsOutput(TypedDict, total=False): + StackRefactorActions: StackRefactorActions + NextToken: Optional[NextToken] + + +StackRefactorExecutionStatusFilter = List[StackRefactorExecutionStatus] + + +class ListStackRefactorsInput(ServiceRequest): + ExecutionStatusFilter: Optional[StackRefactorExecutionStatusFilter] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class StackRefactorSummary(TypedDict, total=False): + StackRefactorId: Optional[StackRefactorId] + Description: Optional[Description] + ExecutionStatus: Optional[StackRefactorExecutionStatus] + ExecutionStatusReason: Optional[ExecutionStatusReason] + Status: Optional[StackRefactorStatus] + StatusReason: Optional[StackRefactorStatusReason] + + +StackRefactorSummaries = List[StackRefactorSummary] + + +class ListStackRefactorsOutput(TypedDict, total=False): + StackRefactorSummaries: StackRefactorSummaries + NextToken: Optional[NextToken] + + +class ListStackResourcesInput(ServiceRequest): + StackName: StackName + NextToken: Optional[NextToken] + + +class StackResourceDriftInformationSummary(TypedDict, total=False): + StackResourceDriftStatus: StackResourceDriftStatus + LastCheckTimestamp: Optional[Timestamp] + + +class StackResourceSummary(TypedDict, total=False): + LogicalResourceId: LogicalResourceId + PhysicalResourceId: Optional[PhysicalResourceId] + ResourceType: ResourceType + LastUpdatedTimestamp: Timestamp + ResourceStatus: ResourceStatus + ResourceStatusReason: Optional[ResourceStatusReason] + DriftInformation: Optional[StackResourceDriftInformationSummary] + ModuleInfo: Optional[ModuleInfo] + + +StackResourceSummaries = List[StackResourceSummary] + + +class ListStackResourcesOutput(TypedDict, total=False): + StackResourceSummaries: Optional[StackResourceSummaries] + NextToken: Optional[NextToken] + + +class ListStackSetAutoDeploymentTargetsInput(ServiceRequest): + StackSetName: StackSetNameOrId + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + CallAs: Optional[CallAs] + + +class StackSetAutoDeploymentTargetSummary(TypedDict, total=False): + OrganizationalUnitId: Optional[OrganizationalUnitId] + Regions: Optional[RegionList] + + +StackSetAutoDeploymentTargetSummaries = List[StackSetAutoDeploymentTargetSummary] + + +class ListStackSetAutoDeploymentTargetsOutput(TypedDict, total=False): + Summaries: Optional[StackSetAutoDeploymentTargetSummaries] + NextToken: Optional[NextToken] + + +class OperationResultFilter(TypedDict, total=False): + Name: Optional[OperationResultFilterName] + Values: Optional[OperationResultFilterValues] + + +OperationResultFilters = List[OperationResultFilter] + + +class ListStackSetOperationResultsInput(ServiceRequest): + StackSetName: StackSetName + OperationId: ClientRequestToken + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + CallAs: Optional[CallAs] + Filters: Optional[OperationResultFilters] + + +class StackSetOperationResultSummary(TypedDict, total=False): + Account: Optional[Account] + Region: Optional[Region] + Status: Optional[StackSetOperationResultStatus] + StatusReason: Optional[Reason] + AccountGateResult: Optional[AccountGateResult] + OrganizationalUnitId: Optional[OrganizationalUnitId] + + +StackSetOperationResultSummaries = List[StackSetOperationResultSummary] + + +class ListStackSetOperationResultsOutput(TypedDict, total=False): + Summaries: Optional[StackSetOperationResultSummaries] + NextToken: Optional[NextToken] + + +class ListStackSetOperationsInput(ServiceRequest): + StackSetName: StackSetName + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + CallAs: Optional[CallAs] + + +class StackSetOperationSummary(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + Action: Optional[StackSetOperationAction] + Status: Optional[StackSetOperationStatus] + CreationTimestamp: Optional[Timestamp] + EndTimestamp: Optional[Timestamp] + StatusReason: Optional[StackSetOperationStatusReason] + StatusDetails: Optional[StackSetOperationStatusDetails] + OperationPreferences: Optional[StackSetOperationPreferences] + + +StackSetOperationSummaries = List[StackSetOperationSummary] + + +class ListStackSetOperationsOutput(TypedDict, total=False): + Summaries: Optional[StackSetOperationSummaries] + NextToken: Optional[NextToken] + + +class ListStackSetsInput(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + Status: Optional[StackSetStatus] + CallAs: Optional[CallAs] + + +class StackSetSummary(TypedDict, total=False): + StackSetName: Optional[StackSetName] + StackSetId: Optional[StackSetId] + Description: Optional[Description] + Status: Optional[StackSetStatus] + AutoDeployment: Optional[AutoDeployment] + PermissionModel: Optional[PermissionModels] + DriftStatus: Optional[StackDriftStatus] + LastDriftCheckTimestamp: Optional[Timestamp] + ManagedExecution: Optional[ManagedExecution] + + +StackSetSummaries = List[StackSetSummary] + + +class ListStackSetsOutput(TypedDict, total=False): + Summaries: Optional[StackSetSummaries] + NextToken: Optional[NextToken] + + +StackStatusFilter = List[StackStatus] + + +class ListStacksInput(ServiceRequest): + NextToken: Optional[NextToken] + StackStatusFilter: Optional[StackStatusFilter] + + +class StackDriftInformationSummary(TypedDict, total=False): + StackDriftStatus: StackDriftStatus + LastCheckTimestamp: Optional[Timestamp] + + +class StackSummary(TypedDict, total=False): + StackId: Optional[StackId] + StackName: StackName + TemplateDescription: Optional[TemplateDescription] + CreationTime: CreationTime + LastUpdatedTime: Optional[LastUpdatedTime] + DeletionTime: Optional[DeletionTime] + StackStatus: StackStatus + StackStatusReason: Optional[StackStatusReason] + ParentId: Optional[StackId] + RootId: Optional[StackId] + DriftInformation: Optional[StackDriftInformationSummary] + + +StackSummaries = List[StackSummary] + + +class ListStacksOutput(TypedDict, total=False): + StackSummaries: Optional[StackSummaries] + NextToken: Optional[NextToken] + + +class ListTypeRegistrationsInput(ServiceRequest): + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + TypeArn: Optional[TypeArn] + RegistrationStatusFilter: Optional[RegistrationStatus] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +RegistrationTokenList = List[RegistrationToken] + + +class ListTypeRegistrationsOutput(TypedDict, total=False): + RegistrationTokenList: Optional[RegistrationTokenList] + NextToken: Optional[NextToken] + + +class ListTypeVersionsInput(ServiceRequest): + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + Arn: Optional[TypeArn] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + DeprecatedStatus: Optional[DeprecatedStatus] + PublisherId: Optional[PublisherId] + + +class TypeVersionSummary(TypedDict, total=False): + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + VersionId: Optional[TypeVersionId] + IsDefaultVersion: Optional[IsDefaultVersion] + Arn: Optional[TypeArn] + TimeCreated: Optional[Timestamp] + Description: Optional[Description] + PublicVersionNumber: Optional[PublicVersionNumber] + + +TypeVersionSummaries = List[TypeVersionSummary] + + +class ListTypeVersionsOutput(TypedDict, total=False): + TypeVersionSummaries: Optional[TypeVersionSummaries] + NextToken: Optional[NextToken] + + +class TypeFilters(TypedDict, total=False): + Category: Optional[Category] + PublisherId: Optional[PublisherId] + TypeNamePrefix: Optional[TypeNamePrefix] + + +class ListTypesInput(ServiceRequest): + Visibility: Optional[Visibility] + ProvisioningType: Optional[ProvisioningType] + DeprecatedStatus: Optional[DeprecatedStatus] + Type: Optional[RegistryType] + Filters: Optional[TypeFilters] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class TypeSummary(TypedDict, total=False): + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + DefaultVersionId: Optional[TypeVersionId] + TypeArn: Optional[TypeArn] + LastUpdated: Optional[Timestamp] + Description: Optional[Description] + PublisherId: Optional[PublisherId] + OriginalTypeName: Optional[TypeName] + PublicVersionNumber: Optional[PublicVersionNumber] + LatestPublicVersion: Optional[PublicVersionNumber] + PublisherIdentity: Optional[IdentityProvider] + PublisherName: Optional[PublisherName] + IsActivated: Optional[IsActivated] + + +TypeSummaries = List[TypeSummary] + + +class ListTypesOutput(TypedDict, total=False): + TypeSummaries: Optional[TypeSummaries] + NextToken: Optional[NextToken] + + +class PublishTypeInput(ServiceRequest): + Type: Optional[ThirdPartyType] + Arn: Optional[PrivateTypeArn] + TypeName: Optional[TypeName] + PublicVersionNumber: Optional[PublicVersionNumber] + + +class PublishTypeOutput(TypedDict, total=False): + PublicTypeArn: Optional[TypeArn] + + +class RecordHandlerProgressInput(ServiceRequest): + BearerToken: ClientToken + OperationStatus: OperationStatus + CurrentOperationStatus: Optional[OperationStatus] + StatusMessage: Optional[StatusMessage] + ErrorCode: Optional[HandlerErrorCode] + ResourceModel: Optional[ResourceModel] + ClientRequestToken: Optional[ClientRequestToken] + + +class RecordHandlerProgressOutput(TypedDict, total=False): + pass + + +class RegisterPublisherInput(ServiceRequest): + AcceptTermsAndConditions: Optional[AcceptTermsAndConditions] + ConnectionArn: Optional[ConnectionArn] + + +class RegisterPublisherOutput(TypedDict, total=False): + PublisherId: Optional[PublisherId] + + +class RegisterTypeInput(ServiceRequest): + Type: Optional[RegistryType] + TypeName: TypeName + SchemaHandlerPackage: S3Url + LoggingConfig: Optional[LoggingConfig] + ExecutionRoleArn: Optional[RoleArn] + ClientRequestToken: Optional[RequestToken] + + +class RegisterTypeOutput(TypedDict, total=False): + RegistrationToken: Optional[RegistrationToken] + + +class RollbackStackInput(ServiceRequest): + StackName: StackNameOrId + RoleARN: Optional[RoleARN] + ClientRequestToken: Optional[ClientRequestToken] + RetainExceptOnCreate: Optional[RetainExceptOnCreate] + + +class RollbackStackOutput(TypedDict, total=False): + StackId: Optional[StackId] + + +class SetStackPolicyInput(ServiceRequest): + StackName: StackName + StackPolicyBody: Optional[StackPolicyBody] + StackPolicyURL: Optional[StackPolicyURL] + + +class SetTypeConfigurationInput(ServiceRequest): + TypeArn: Optional[TypeArn] + Configuration: TypeConfiguration + ConfigurationAlias: Optional[TypeConfigurationAlias] + TypeName: Optional[TypeName] + Type: Optional[ThirdPartyType] + + +class SetTypeConfigurationOutput(TypedDict, total=False): + ConfigurationArn: Optional[TypeConfigurationArn] + + +class SetTypeDefaultVersionInput(ServiceRequest): + Arn: Optional[PrivateTypeArn] + Type: Optional[RegistryType] + TypeName: Optional[TypeName] + VersionId: Optional[TypeVersionId] + + +class SetTypeDefaultVersionOutput(TypedDict, total=False): + pass + + +class SignalResourceInput(ServiceRequest): + StackName: StackNameOrId + LogicalResourceId: LogicalResourceId + UniqueId: ResourceSignalUniqueId + Status: ResourceSignalStatus + + +class StartResourceScanInput(ServiceRequest): + ClientRequestToken: Optional[ClientRequestToken] + ScanFilters: Optional[ScanFilters] + + +class StartResourceScanOutput(TypedDict, total=False): + ResourceScanId: Optional[ResourceScanId] + + +class StopStackSetOperationInput(ServiceRequest): + StackSetName: StackSetName + OperationId: ClientRequestToken + CallAs: Optional[CallAs] + + +class StopStackSetOperationOutput(TypedDict, total=False): + pass + + +class TemplateParameter(TypedDict, total=False): + ParameterKey: Optional[ParameterKey] + DefaultValue: Optional[ParameterValue] + NoEcho: Optional[NoEcho] + Description: Optional[Description] + + +TemplateParameters = List[TemplateParameter] + + +class TestTypeInput(ServiceRequest): + Arn: Optional[TypeArn] + Type: Optional[ThirdPartyType] + TypeName: Optional[TypeName] + VersionId: Optional[TypeVersionId] + LogDeliveryBucket: Optional[S3Bucket] + + +class TestTypeOutput(TypedDict, total=False): + TypeVersionArn: Optional[TypeArn] + + +class UpdateGeneratedTemplateInput(ServiceRequest): + GeneratedTemplateName: GeneratedTemplateName + NewGeneratedTemplateName: Optional[GeneratedTemplateName] + AddResources: Optional[ResourceDefinitions] + RemoveResources: Optional[JazzLogicalResourceIds] + RefreshAllResources: Optional[RefreshAllResources] + TemplateConfiguration: Optional[TemplateConfiguration] + + +class UpdateGeneratedTemplateOutput(TypedDict, total=False): + GeneratedTemplateId: Optional[GeneratedTemplateId] + + +class UpdateStackInput(ServiceRequest): + StackName: StackName + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + UsePreviousTemplate: Optional[UsePreviousTemplate] + StackPolicyDuringUpdateBody: Optional[StackPolicyDuringUpdateBody] + StackPolicyDuringUpdateURL: Optional[StackPolicyDuringUpdateURL] + Parameters: Optional[Parameters] + Capabilities: Optional[Capabilities] + ResourceTypes: Optional[ResourceTypes] + RoleARN: Optional[RoleARN] + RollbackConfiguration: Optional[RollbackConfiguration] + StackPolicyBody: Optional[StackPolicyBody] + StackPolicyURL: Optional[StackPolicyURL] + NotificationARNs: Optional[NotificationARNs] + Tags: Optional[Tags] + DisableRollback: Optional[DisableRollback] + ClientRequestToken: Optional[ClientRequestToken] + RetainExceptOnCreate: Optional[RetainExceptOnCreate] + + +class UpdateStackInstancesInput(ServiceRequest): + StackSetName: StackSetNameOrId + Accounts: Optional[AccountList] + DeploymentTargets: Optional[DeploymentTargets] + Regions: RegionList + ParameterOverrides: Optional[Parameters] + OperationPreferences: Optional[StackSetOperationPreferences] + OperationId: Optional[ClientRequestToken] + CallAs: Optional[CallAs] + + +class UpdateStackInstancesOutput(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + + +class UpdateStackOutput(TypedDict, total=False): + StackId: Optional[StackId] + + +class UpdateStackSetInput(ServiceRequest): + StackSetName: StackSetName + Description: Optional[Description] + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + UsePreviousTemplate: Optional[UsePreviousTemplate] + Parameters: Optional[Parameters] + Capabilities: Optional[Capabilities] + Tags: Optional[Tags] + OperationPreferences: Optional[StackSetOperationPreferences] + AdministrationRoleARN: Optional[RoleARN] + ExecutionRoleName: Optional[ExecutionRoleName] + DeploymentTargets: Optional[DeploymentTargets] + PermissionModel: Optional[PermissionModels] + AutoDeployment: Optional[AutoDeployment] + OperationId: Optional[ClientRequestToken] + Accounts: Optional[AccountList] + Regions: Optional[RegionList] + CallAs: Optional[CallAs] + ManagedExecution: Optional[ManagedExecution] + + +class UpdateStackSetOutput(TypedDict, total=False): + OperationId: Optional[ClientRequestToken] + + +class UpdateTerminationProtectionInput(ServiceRequest): + EnableTerminationProtection: EnableTerminationProtection + StackName: StackNameOrId + + +class UpdateTerminationProtectionOutput(TypedDict, total=False): + StackId: Optional[StackId] + + +class ValidateTemplateInput(ServiceRequest): + TemplateBody: Optional[TemplateBody] + TemplateURL: Optional[TemplateURL] + + +class ValidateTemplateOutput(TypedDict, total=False): + Parameters: Optional[TemplateParameters] + Description: Optional[Description] + Capabilities: Optional[Capabilities] + CapabilitiesReason: Optional[CapabilitiesReason] + DeclaredTransforms: Optional[TransformsList] + + +class CloudformationApi: + service = "cloudformation" + version = "2010-05-15" + + @handler("ActivateOrganizationsAccess") + def activate_organizations_access( + self, context: RequestContext, **kwargs + ) -> ActivateOrganizationsAccessOutput: + raise NotImplementedError + + @handler("ActivateType", expand=False) + def activate_type( + self, context: RequestContext, request: ActivateTypeInput, **kwargs + ) -> ActivateTypeOutput: + raise NotImplementedError + + @handler("BatchDescribeTypeConfigurations") + def batch_describe_type_configurations( + self, + context: RequestContext, + type_configuration_identifiers: TypeConfigurationIdentifiers, + **kwargs, + ) -> BatchDescribeTypeConfigurationsOutput: + raise NotImplementedError + + @handler("CancelUpdateStack") + def cancel_update_stack( + self, + context: RequestContext, + stack_name: StackName, + client_request_token: ClientRequestToken | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ContinueUpdateRollback") + def continue_update_rollback( + self, + context: RequestContext, + stack_name: StackNameOrId, + role_arn: RoleARN | None = None, + resources_to_skip: ResourcesToSkip | None = None, + client_request_token: ClientRequestToken | None = None, + **kwargs, + ) -> ContinueUpdateRollbackOutput: + raise NotImplementedError + + @handler("CreateChangeSet") + def create_change_set( + self, + context: RequestContext, + stack_name: StackNameOrId, + change_set_name: ChangeSetName, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + use_previous_template: UsePreviousTemplate | None = None, + parameters: Parameters | None = None, + capabilities: Capabilities | None = None, + resource_types: ResourceTypes | None = None, + role_arn: RoleARN | None = None, + rollback_configuration: RollbackConfiguration | None = None, + notification_arns: NotificationARNs | None = None, + tags: Tags | None = None, + client_token: ClientToken | None = None, + description: Description | None = None, + change_set_type: ChangeSetType | None = None, + resources_to_import: ResourcesToImport | None = None, + include_nested_stacks: IncludeNestedStacks | None = None, + on_stack_failure: OnStackFailure | None = None, + import_existing_resources: ImportExistingResources | None = None, + **kwargs, + ) -> CreateChangeSetOutput: + raise NotImplementedError + + @handler("CreateGeneratedTemplate") + def create_generated_template( + self, + context: RequestContext, + generated_template_name: GeneratedTemplateName, + resources: ResourceDefinitions | None = None, + stack_name: StackName | None = None, + template_configuration: TemplateConfiguration | None = None, + **kwargs, + ) -> CreateGeneratedTemplateOutput: + raise NotImplementedError + + @handler("CreateStack") + def create_stack( + self, + context: RequestContext, + stack_name: StackName, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + parameters: Parameters | None = None, + disable_rollback: DisableRollback | None = None, + rollback_configuration: RollbackConfiguration | None = None, + timeout_in_minutes: TimeoutMinutes | None = None, + notification_arns: NotificationARNs | None = None, + capabilities: Capabilities | None = None, + resource_types: ResourceTypes | None = None, + role_arn: RoleARN | None = None, + on_failure: OnFailure | None = None, + stack_policy_body: StackPolicyBody | None = None, + stack_policy_url: StackPolicyURL | None = None, + tags: Tags | None = None, + client_request_token: ClientRequestToken | None = None, + enable_termination_protection: EnableTerminationProtection | None = None, + retain_except_on_create: RetainExceptOnCreate | None = None, + **kwargs, + ) -> CreateStackOutput: + raise NotImplementedError + + @handler("CreateStackInstances") + def create_stack_instances( + self, + context: RequestContext, + stack_set_name: StackSetName, + regions: RegionList, + accounts: AccountList | None = None, + deployment_targets: DeploymentTargets | None = None, + parameter_overrides: Parameters | None = None, + operation_preferences: StackSetOperationPreferences | None = None, + operation_id: ClientRequestToken | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> CreateStackInstancesOutput: + raise NotImplementedError + + @handler("CreateStackRefactor") + def create_stack_refactor( + self, + context: RequestContext, + stack_definitions: StackDefinitions, + description: Description | None = None, + enable_stack_creation: EnableStackCreation | None = None, + resource_mappings: ResourceMappings | None = None, + **kwargs, + ) -> CreateStackRefactorOutput: + raise NotImplementedError + + @handler("CreateStackSet") + def create_stack_set( + self, + context: RequestContext, + stack_set_name: StackSetName, + description: Description | None = None, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + stack_id: StackId | None = None, + parameters: Parameters | None = None, + capabilities: Capabilities | None = None, + tags: Tags | None = None, + administration_role_arn: RoleARN | None = None, + execution_role_name: ExecutionRoleName | None = None, + permission_model: PermissionModels | None = None, + auto_deployment: AutoDeployment | None = None, + call_as: CallAs | None = None, + client_request_token: ClientRequestToken | None = None, + managed_execution: ManagedExecution | None = None, + **kwargs, + ) -> CreateStackSetOutput: + raise NotImplementedError + + @handler("DeactivateOrganizationsAccess") + def deactivate_organizations_access( + self, context: RequestContext, **kwargs + ) -> DeactivateOrganizationsAccessOutput: + raise NotImplementedError + + @handler("DeactivateType", expand=False) + def deactivate_type( + self, context: RequestContext, request: DeactivateTypeInput, **kwargs + ) -> DeactivateTypeOutput: + raise NotImplementedError + + @handler("DeleteChangeSet") + def delete_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId | None = None, + **kwargs, + ) -> DeleteChangeSetOutput: + raise NotImplementedError + + @handler("DeleteGeneratedTemplate") + def delete_generated_template( + self, context: RequestContext, generated_template_name: GeneratedTemplateName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteStack") + def delete_stack( + self, + context: RequestContext, + stack_name: StackName, + retain_resources: RetainResources | None = None, + role_arn: RoleARN | None = None, + client_request_token: ClientRequestToken | None = None, + deletion_mode: DeletionMode | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteStackInstances") + def delete_stack_instances( + self, + context: RequestContext, + stack_set_name: StackSetName, + regions: RegionList, + retain_stacks: RetainStacks, + accounts: AccountList | None = None, + deployment_targets: DeploymentTargets | None = None, + operation_preferences: StackSetOperationPreferences | None = None, + operation_id: ClientRequestToken | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> DeleteStackInstancesOutput: + raise NotImplementedError + + @handler("DeleteStackSet") + def delete_stack_set( + self, + context: RequestContext, + stack_set_name: StackSetName, + call_as: CallAs | None = None, + **kwargs, + ) -> DeleteStackSetOutput: + raise NotImplementedError + + @handler("DeregisterType", expand=False) + def deregister_type( + self, context: RequestContext, request: DeregisterTypeInput, **kwargs + ) -> DeregisterTypeOutput: + raise NotImplementedError + + @handler("DescribeAccountLimits") + def describe_account_limits( + self, context: RequestContext, next_token: NextToken | None = None, **kwargs + ) -> DescribeAccountLimitsOutput: + raise NotImplementedError + + @handler("DescribeChangeSet") + def describe_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId | None = None, + next_token: NextToken | None = None, + include_property_values: IncludePropertyValues | None = None, + **kwargs, + ) -> DescribeChangeSetOutput: + raise NotImplementedError + + @handler("DescribeChangeSetHooks") + def describe_change_set_hooks( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId | None = None, + next_token: NextToken | None = None, + logical_resource_id: LogicalResourceId | None = None, + **kwargs, + ) -> DescribeChangeSetHooksOutput: + raise NotImplementedError + + @handler("DescribeGeneratedTemplate") + def describe_generated_template( + self, context: RequestContext, generated_template_name: GeneratedTemplateName, **kwargs + ) -> DescribeGeneratedTemplateOutput: + raise NotImplementedError + + @handler("DescribeOrganizationsAccess") + def describe_organizations_access( + self, context: RequestContext, call_as: CallAs | None = None, **kwargs + ) -> DescribeOrganizationsAccessOutput: + raise NotImplementedError + + @handler("DescribePublisher") + def describe_publisher( + self, context: RequestContext, publisher_id: PublisherId | None = None, **kwargs + ) -> DescribePublisherOutput: + raise NotImplementedError + + @handler("DescribeResourceScan") + def describe_resource_scan( + self, context: RequestContext, resource_scan_id: ResourceScanId, **kwargs + ) -> DescribeResourceScanOutput: + raise NotImplementedError + + @handler("DescribeStackDriftDetectionStatus") + def describe_stack_drift_detection_status( + self, context: RequestContext, stack_drift_detection_id: StackDriftDetectionId, **kwargs + ) -> DescribeStackDriftDetectionStatusOutput: + raise NotImplementedError + + @handler("DescribeStackEvents") + def describe_stack_events( + self, + context: RequestContext, + stack_name: StackName | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeStackEventsOutput: + raise NotImplementedError + + @handler("DescribeStackInstance") + def describe_stack_instance( + self, + context: RequestContext, + stack_set_name: StackSetName, + stack_instance_account: Account, + stack_instance_region: Region, + call_as: CallAs | None = None, + **kwargs, + ) -> DescribeStackInstanceOutput: + raise NotImplementedError + + @handler("DescribeStackRefactor") + def describe_stack_refactor( + self, context: RequestContext, stack_refactor_id: StackRefactorId, **kwargs + ) -> DescribeStackRefactorOutput: + raise NotImplementedError + + @handler("DescribeStackResource") + def describe_stack_resource( + self, + context: RequestContext, + stack_name: StackName, + logical_resource_id: LogicalResourceId, + **kwargs, + ) -> DescribeStackResourceOutput: + raise NotImplementedError + + @handler("DescribeStackResourceDrifts") + def describe_stack_resource_drifts( + self, + context: RequestContext, + stack_name: StackNameOrId, + stack_resource_drift_status_filters: StackResourceDriftStatusFilters | None = None, + next_token: NextToken | None = None, + max_results: BoxedMaxResults | None = None, + **kwargs, + ) -> DescribeStackResourceDriftsOutput: + raise NotImplementedError + + @handler("DescribeStackResources") + def describe_stack_resources( + self, + context: RequestContext, + stack_name: StackName | None = None, + logical_resource_id: LogicalResourceId | None = None, + physical_resource_id: PhysicalResourceId | None = None, + **kwargs, + ) -> DescribeStackResourcesOutput: + raise NotImplementedError + + @handler("DescribeStackSet") + def describe_stack_set( + self, + context: RequestContext, + stack_set_name: StackSetName, + call_as: CallAs | None = None, + **kwargs, + ) -> DescribeStackSetOutput: + raise NotImplementedError + + @handler("DescribeStackSetOperation") + def describe_stack_set_operation( + self, + context: RequestContext, + stack_set_name: StackSetName, + operation_id: ClientRequestToken, + call_as: CallAs | None = None, + **kwargs, + ) -> DescribeStackSetOperationOutput: + raise NotImplementedError + + @handler("DescribeStacks") + def describe_stacks( + self, + context: RequestContext, + stack_name: StackName | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeStacksOutput: + raise NotImplementedError + + @handler("DescribeType", expand=False) + def describe_type( + self, context: RequestContext, request: DescribeTypeInput, **kwargs + ) -> DescribeTypeOutput: + raise NotImplementedError + + @handler("DescribeTypeRegistration") + def describe_type_registration( + self, context: RequestContext, registration_token: RegistrationToken, **kwargs + ) -> DescribeTypeRegistrationOutput: + raise NotImplementedError + + @handler("DetectStackDrift") + def detect_stack_drift( + self, + context: RequestContext, + stack_name: StackNameOrId, + logical_resource_ids: LogicalResourceIds | None = None, + **kwargs, + ) -> DetectStackDriftOutput: + raise NotImplementedError + + @handler("DetectStackResourceDrift") + def detect_stack_resource_drift( + self, + context: RequestContext, + stack_name: StackNameOrId, + logical_resource_id: LogicalResourceId, + **kwargs, + ) -> DetectStackResourceDriftOutput: + raise NotImplementedError + + @handler("DetectStackSetDrift") + def detect_stack_set_drift( + self, + context: RequestContext, + stack_set_name: StackSetNameOrId, + operation_preferences: StackSetOperationPreferences | None = None, + operation_id: ClientRequestToken | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> DetectStackSetDriftOutput: + raise NotImplementedError + + @handler("EstimateTemplateCost") + def estimate_template_cost( + self, + context: RequestContext, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + parameters: Parameters | None = None, + **kwargs, + ) -> EstimateTemplateCostOutput: + raise NotImplementedError + + @handler("ExecuteChangeSet") + def execute_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId | None = None, + client_request_token: ClientRequestToken | None = None, + disable_rollback: DisableRollback | None = None, + retain_except_on_create: RetainExceptOnCreate | None = None, + **kwargs, + ) -> ExecuteChangeSetOutput: + raise NotImplementedError + + @handler("ExecuteStackRefactor") + def execute_stack_refactor( + self, context: RequestContext, stack_refactor_id: StackRefactorId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("GetGeneratedTemplate") + def get_generated_template( + self, + context: RequestContext, + generated_template_name: GeneratedTemplateName, + format: TemplateFormat | None = None, + **kwargs, + ) -> GetGeneratedTemplateOutput: + raise NotImplementedError + + @handler("GetStackPolicy") + def get_stack_policy( + self, context: RequestContext, stack_name: StackName, **kwargs + ) -> GetStackPolicyOutput: + raise NotImplementedError + + @handler("GetTemplate") + def get_template( + self, + context: RequestContext, + stack_name: StackName | None = None, + change_set_name: ChangeSetNameOrId | None = None, + template_stage: TemplateStage | None = None, + **kwargs, + ) -> GetTemplateOutput: + raise NotImplementedError + + @handler("GetTemplateSummary") + def get_template_summary( + self, + context: RequestContext, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + stack_name: StackNameOrId | None = None, + stack_set_name: StackSetNameOrId | None = None, + call_as: CallAs | None = None, + template_summary_config: TemplateSummaryConfig | None = None, + **kwargs, + ) -> GetTemplateSummaryOutput: + raise NotImplementedError + + @handler("ImportStacksToStackSet") + def import_stacks_to_stack_set( + self, + context: RequestContext, + stack_set_name: StackSetNameOrId, + stack_ids: StackIdList | None = None, + stack_ids_url: StackIdsUrl | None = None, + organizational_unit_ids: OrganizationalUnitIdList | None = None, + operation_preferences: StackSetOperationPreferences | None = None, + operation_id: ClientRequestToken | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> ImportStacksToStackSetOutput: + raise NotImplementedError + + @handler("ListChangeSets") + def list_change_sets( + self, + context: RequestContext, + stack_name: StackNameOrId, + next_token: NextToken | None = None, + **kwargs, + ) -> ListChangeSetsOutput: + raise NotImplementedError + + @handler("ListExports") + def list_exports( + self, context: RequestContext, next_token: NextToken | None = None, **kwargs + ) -> ListExportsOutput: + raise NotImplementedError + + @handler("ListGeneratedTemplates") + def list_generated_templates( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListGeneratedTemplatesOutput: + raise NotImplementedError + + @handler("ListHookResults") + def list_hook_results( + self, + context: RequestContext, + target_type: ListHookResultsTargetType, + target_id: HookResultId, + next_token: NextToken | None = None, + **kwargs, + ) -> ListHookResultsOutput: + raise NotImplementedError + + @handler("ListImports") + def list_imports( + self, + context: RequestContext, + export_name: ExportName, + next_token: NextToken | None = None, + **kwargs, + ) -> ListImportsOutput: + raise NotImplementedError + + @handler("ListResourceScanRelatedResources") + def list_resource_scan_related_resources( + self, + context: RequestContext, + resource_scan_id: ResourceScanId, + resources: ScannedResourceIdentifiers, + next_token: NextToken | None = None, + max_results: BoxedMaxResults | None = None, + **kwargs, + ) -> ListResourceScanRelatedResourcesOutput: + raise NotImplementedError + + @handler("ListResourceScanResources") + def list_resource_scan_resources( + self, + context: RequestContext, + resource_scan_id: ResourceScanId, + resource_identifier: ResourceIdentifier | None = None, + resource_type_prefix: ResourceTypePrefix | None = None, + tag_key: TagKey | None = None, + tag_value: TagValue | None = None, + next_token: NextToken | None = None, + max_results: ResourceScannerMaxResults | None = None, + **kwargs, + ) -> ListResourceScanResourcesOutput: + raise NotImplementedError + + @handler("ListResourceScans") + def list_resource_scans( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: ResourceScannerMaxResults | None = None, + scan_type_filter: ScanType | None = None, + **kwargs, + ) -> ListResourceScansOutput: + raise NotImplementedError + + @handler("ListStackInstanceResourceDrifts") + def list_stack_instance_resource_drifts( + self, + context: RequestContext, + stack_set_name: StackSetNameOrId, + stack_instance_account: Account, + stack_instance_region: Region, + operation_id: ClientRequestToken, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + stack_instance_resource_drift_statuses: StackResourceDriftStatusFilters | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> ListStackInstanceResourceDriftsOutput: + raise NotImplementedError + + @handler("ListStackInstances") + def list_stack_instances( + self, + context: RequestContext, + stack_set_name: StackSetName, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + filters: StackInstanceFilters | None = None, + stack_instance_account: Account | None = None, + stack_instance_region: Region | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> ListStackInstancesOutput: + raise NotImplementedError + + @handler("ListStackRefactorActions") + def list_stack_refactor_actions( + self, + context: RequestContext, + stack_refactor_id: StackRefactorId, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListStackRefactorActionsOutput: + raise NotImplementedError + + @handler("ListStackRefactors") + def list_stack_refactors( + self, + context: RequestContext, + execution_status_filter: StackRefactorExecutionStatusFilter | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListStackRefactorsOutput: + raise NotImplementedError + + @handler("ListStackResources") + def list_stack_resources( + self, + context: RequestContext, + stack_name: StackName, + next_token: NextToken | None = None, + **kwargs, + ) -> ListStackResourcesOutput: + raise NotImplementedError + + @handler("ListStackSetAutoDeploymentTargets") + def list_stack_set_auto_deployment_targets( + self, + context: RequestContext, + stack_set_name: StackSetNameOrId, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> ListStackSetAutoDeploymentTargetsOutput: + raise NotImplementedError + + @handler("ListStackSetOperationResults") + def list_stack_set_operation_results( + self, + context: RequestContext, + stack_set_name: StackSetName, + operation_id: ClientRequestToken, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + call_as: CallAs | None = None, + filters: OperationResultFilters | None = None, + **kwargs, + ) -> ListStackSetOperationResultsOutput: + raise NotImplementedError + + @handler("ListStackSetOperations") + def list_stack_set_operations( + self, + context: RequestContext, + stack_set_name: StackSetName, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> ListStackSetOperationsOutput: + raise NotImplementedError + + @handler("ListStackSets") + def list_stack_sets( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + status: StackSetStatus | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> ListStackSetsOutput: + raise NotImplementedError + + @handler("ListStacks") + def list_stacks( + self, + context: RequestContext, + next_token: NextToken | None = None, + stack_status_filter: StackStatusFilter | None = None, + **kwargs, + ) -> ListStacksOutput: + raise NotImplementedError + + @handler("ListTypeRegistrations", expand=False) + def list_type_registrations( + self, context: RequestContext, request: ListTypeRegistrationsInput, **kwargs + ) -> ListTypeRegistrationsOutput: + raise NotImplementedError + + @handler("ListTypeVersions", expand=False) + def list_type_versions( + self, context: RequestContext, request: ListTypeVersionsInput, **kwargs + ) -> ListTypeVersionsOutput: + raise NotImplementedError + + @handler("ListTypes", expand=False) + def list_types( + self, context: RequestContext, request: ListTypesInput, **kwargs + ) -> ListTypesOutput: + raise NotImplementedError + + @handler("PublishType", expand=False) + def publish_type( + self, context: RequestContext, request: PublishTypeInput, **kwargs + ) -> PublishTypeOutput: + raise NotImplementedError + + @handler("RecordHandlerProgress") + def record_handler_progress( + self, + context: RequestContext, + bearer_token: ClientToken, + operation_status: OperationStatus, + current_operation_status: OperationStatus | None = None, + status_message: StatusMessage | None = None, + error_code: HandlerErrorCode | None = None, + resource_model: ResourceModel | None = None, + client_request_token: ClientRequestToken | None = None, + **kwargs, + ) -> RecordHandlerProgressOutput: + raise NotImplementedError + + @handler("RegisterPublisher") + def register_publisher( + self, + context: RequestContext, + accept_terms_and_conditions: AcceptTermsAndConditions | None = None, + connection_arn: ConnectionArn | None = None, + **kwargs, + ) -> RegisterPublisherOutput: + raise NotImplementedError + + @handler("RegisterType", expand=False) + def register_type( + self, context: RequestContext, request: RegisterTypeInput, **kwargs + ) -> RegisterTypeOutput: + raise NotImplementedError + + @handler("RollbackStack") + def rollback_stack( + self, + context: RequestContext, + stack_name: StackNameOrId, + role_arn: RoleARN | None = None, + client_request_token: ClientRequestToken | None = None, + retain_except_on_create: RetainExceptOnCreate | None = None, + **kwargs, + ) -> RollbackStackOutput: + raise NotImplementedError + + @handler("SetStackPolicy") + def set_stack_policy( + self, + context: RequestContext, + stack_name: StackName, + stack_policy_body: StackPolicyBody | None = None, + stack_policy_url: StackPolicyURL | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SetTypeConfiguration", expand=False) + def set_type_configuration( + self, context: RequestContext, request: SetTypeConfigurationInput, **kwargs + ) -> SetTypeConfigurationOutput: + raise NotImplementedError + + @handler("SetTypeDefaultVersion", expand=False) + def set_type_default_version( + self, context: RequestContext, request: SetTypeDefaultVersionInput, **kwargs + ) -> SetTypeDefaultVersionOutput: + raise NotImplementedError + + @handler("SignalResource") + def signal_resource( + self, + context: RequestContext, + stack_name: StackNameOrId, + logical_resource_id: LogicalResourceId, + unique_id: ResourceSignalUniqueId, + status: ResourceSignalStatus, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("StartResourceScan") + def start_resource_scan( + self, + context: RequestContext, + client_request_token: ClientRequestToken | None = None, + scan_filters: ScanFilters | None = None, + **kwargs, + ) -> StartResourceScanOutput: + raise NotImplementedError + + @handler("StopStackSetOperation") + def stop_stack_set_operation( + self, + context: RequestContext, + stack_set_name: StackSetName, + operation_id: ClientRequestToken, + call_as: CallAs | None = None, + **kwargs, + ) -> StopStackSetOperationOutput: + raise NotImplementedError + + @handler("TestType", expand=False) + def test_type( + self, context: RequestContext, request: TestTypeInput, **kwargs + ) -> TestTypeOutput: + raise NotImplementedError + + @handler("UpdateGeneratedTemplate") + def update_generated_template( + self, + context: RequestContext, + generated_template_name: GeneratedTemplateName, + new_generated_template_name: GeneratedTemplateName | None = None, + add_resources: ResourceDefinitions | None = None, + remove_resources: JazzLogicalResourceIds | None = None, + refresh_all_resources: RefreshAllResources | None = None, + template_configuration: TemplateConfiguration | None = None, + **kwargs, + ) -> UpdateGeneratedTemplateOutput: + raise NotImplementedError + + @handler("UpdateStack") + def update_stack( + self, + context: RequestContext, + stack_name: StackName, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + use_previous_template: UsePreviousTemplate | None = None, + stack_policy_during_update_body: StackPolicyDuringUpdateBody | None = None, + stack_policy_during_update_url: StackPolicyDuringUpdateURL | None = None, + parameters: Parameters | None = None, + capabilities: Capabilities | None = None, + resource_types: ResourceTypes | None = None, + role_arn: RoleARN | None = None, + rollback_configuration: RollbackConfiguration | None = None, + stack_policy_body: StackPolicyBody | None = None, + stack_policy_url: StackPolicyURL | None = None, + notification_arns: NotificationARNs | None = None, + tags: Tags | None = None, + disable_rollback: DisableRollback | None = None, + client_request_token: ClientRequestToken | None = None, + retain_except_on_create: RetainExceptOnCreate | None = None, + **kwargs, + ) -> UpdateStackOutput: + raise NotImplementedError + + @handler("UpdateStackInstances") + def update_stack_instances( + self, + context: RequestContext, + stack_set_name: StackSetNameOrId, + regions: RegionList, + accounts: AccountList | None = None, + deployment_targets: DeploymentTargets | None = None, + parameter_overrides: Parameters | None = None, + operation_preferences: StackSetOperationPreferences | None = None, + operation_id: ClientRequestToken | None = None, + call_as: CallAs | None = None, + **kwargs, + ) -> UpdateStackInstancesOutput: + raise NotImplementedError + + @handler("UpdateStackSet") + def update_stack_set( + self, + context: RequestContext, + stack_set_name: StackSetName, + description: Description | None = None, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + use_previous_template: UsePreviousTemplate | None = None, + parameters: Parameters | None = None, + capabilities: Capabilities | None = None, + tags: Tags | None = None, + operation_preferences: StackSetOperationPreferences | None = None, + administration_role_arn: RoleARN | None = None, + execution_role_name: ExecutionRoleName | None = None, + deployment_targets: DeploymentTargets | None = None, + permission_model: PermissionModels | None = None, + auto_deployment: AutoDeployment | None = None, + operation_id: ClientRequestToken | None = None, + accounts: AccountList | None = None, + regions: RegionList | None = None, + call_as: CallAs | None = None, + managed_execution: ManagedExecution | None = None, + **kwargs, + ) -> UpdateStackSetOutput: + raise NotImplementedError + + @handler("UpdateTerminationProtection") + def update_termination_protection( + self, + context: RequestContext, + enable_termination_protection: EnableTerminationProtection, + stack_name: StackNameOrId, + **kwargs, + ) -> UpdateTerminationProtectionOutput: + raise NotImplementedError + + @handler("ValidateTemplate") + def validate_template( + self, + context: RequestContext, + template_body: TemplateBody | None = None, + template_url: TemplateURL | None = None, + **kwargs, + ) -> ValidateTemplateOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/cloudwatch/__init__.py b/localstack-core/localstack/aws/api/cloudwatch/__init__.py new file mode 100644 index 0000000000000..e05e85a069dee --- /dev/null +++ b/localstack-core/localstack/aws/api/cloudwatch/__init__.py @@ -0,0 +1,1560 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AccountId = str +ActionPrefix = str +ActionsEnabled = bool +ActionsSuppressedReason = str +AlarmArn = str +AlarmDescription = str +AlarmName = str +AlarmNamePrefix = str +AlarmRule = str +AmazonResourceName = str +AnomalyDetectorMetricStat = str +AnomalyDetectorMetricTimezone = str +AwsQueryErrorMessage = str +DashboardArn = str +DashboardBody = str +DashboardErrorMessage = str +DashboardName = str +DashboardNamePrefix = str +DataPath = str +DatapointValue = float +DatapointsToAlarm = int +DimensionName = str +DimensionValue = str +EntityAttributesMapKeyString = str +EntityAttributesMapValueString = str +EntityKeyAttributesMapKeyString = str +EntityKeyAttributesMapValueString = str +ErrorMessage = str +EvaluateLowSampleCountPercentile = str +EvaluationPeriods = int +ExceptionType = str +ExtendedStatistic = str +FailureCode = str +FailureDescription = str +FailureResource = str +FaultDescription = str +GetMetricDataLabelTimezone = str +GetMetricDataMaxDatapoints = int +HistoryData = str +HistorySummary = str +IncludeLinkedAccounts = bool +IncludeLinkedAccountsMetrics = bool +InsightRuleAggregationStatistic = str +InsightRuleContributorKey = str +InsightRuleContributorKeyLabel = str +InsightRuleDefinition = str +InsightRuleIsManaged = bool +InsightRuleMaxResults = int +InsightRuleMetricName = str +InsightRuleName = str +InsightRuleOnTransformedLogs = bool +InsightRuleOrderBy = str +InsightRuleSchema = str +InsightRuleState = str +InsightRuleUnboundDouble = float +InsightRuleUnboundInteger = int +ListMetricStreamsMaxResults = int +MaxRecords = int +MaxReturnedResultsCount = int +Message = str +MessageDataCode = str +MessageDataValue = str +MetricExpression = str +MetricId = str +MetricLabel = str +MetricName = str +MetricStreamName = str +MetricStreamState = str +MetricStreamStatistic = str +MetricWidget = str +Namespace = str +NextToken = str +OutputFormat = str +Period = int +PeriodicSpikes = bool +ResourceId = str +ResourceName = str +ResourceType = str +ReturnData = bool +Stat = str +StateReason = str +StateReasonData = str +StorageResolution = int +StrictEntityValidation = bool +SuppressorPeriod = int +TagKey = str +TagValue = str +TemplateName = str +Threshold = float +TreatMissingData = str + + +class ActionsSuppressedBy(StrEnum): + WaitPeriod = "WaitPeriod" + ExtensionPeriod = "ExtensionPeriod" + Alarm = "Alarm" + + +class AlarmType(StrEnum): + CompositeAlarm = "CompositeAlarm" + MetricAlarm = "MetricAlarm" + + +class AnomalyDetectorStateValue(StrEnum): + PENDING_TRAINING = "PENDING_TRAINING" + TRAINED_INSUFFICIENT_DATA = "TRAINED_INSUFFICIENT_DATA" + TRAINED = "TRAINED" + + +class AnomalyDetectorType(StrEnum): + SINGLE_METRIC = "SINGLE_METRIC" + METRIC_MATH = "METRIC_MATH" + + +class ComparisonOperator(StrEnum): + GreaterThanOrEqualToThreshold = "GreaterThanOrEqualToThreshold" + GreaterThanThreshold = "GreaterThanThreshold" + LessThanThreshold = "LessThanThreshold" + LessThanOrEqualToThreshold = "LessThanOrEqualToThreshold" + LessThanLowerOrGreaterThanUpperThreshold = "LessThanLowerOrGreaterThanUpperThreshold" + LessThanLowerThreshold = "LessThanLowerThreshold" + GreaterThanUpperThreshold = "GreaterThanUpperThreshold" + + +class EvaluationState(StrEnum): + PARTIAL_DATA = "PARTIAL_DATA" + + +class HistoryItemType(StrEnum): + ConfigurationUpdate = "ConfigurationUpdate" + StateUpdate = "StateUpdate" + Action = "Action" + + +class MetricStreamOutputFormat(StrEnum): + json = "json" + opentelemetry0_7 = "opentelemetry0.7" + opentelemetry1_0 = "opentelemetry1.0" + + +class RecentlyActive(StrEnum): + PT3H = "PT3H" + + +class ScanBy(StrEnum): + TimestampDescending = "TimestampDescending" + TimestampAscending = "TimestampAscending" + + +class StandardUnit(StrEnum): + Seconds = "Seconds" + Microseconds = "Microseconds" + Milliseconds = "Milliseconds" + Bytes = "Bytes" + Kilobytes = "Kilobytes" + Megabytes = "Megabytes" + Gigabytes = "Gigabytes" + Terabytes = "Terabytes" + Bits = "Bits" + Kilobits = "Kilobits" + Megabits = "Megabits" + Gigabits = "Gigabits" + Terabits = "Terabits" + Percent = "Percent" + Count = "Count" + Bytes_Second = "Bytes/Second" + Kilobytes_Second = "Kilobytes/Second" + Megabytes_Second = "Megabytes/Second" + Gigabytes_Second = "Gigabytes/Second" + Terabytes_Second = "Terabytes/Second" + Bits_Second = "Bits/Second" + Kilobits_Second = "Kilobits/Second" + Megabits_Second = "Megabits/Second" + Gigabits_Second = "Gigabits/Second" + Terabits_Second = "Terabits/Second" + Count_Second = "Count/Second" + None_ = "None" + + +class StateValue(StrEnum): + OK = "OK" + ALARM = "ALARM" + INSUFFICIENT_DATA = "INSUFFICIENT_DATA" + + +class Statistic(StrEnum): + SampleCount = "SampleCount" + Average = "Average" + Sum = "Sum" + Minimum = "Minimum" + Maximum = "Maximum" + + +class StatusCode(StrEnum): + Complete = "Complete" + InternalError = "InternalError" + PartialData = "PartialData" + Forbidden = "Forbidden" + + +class ConcurrentModificationException(ServiceException): + code: str = "ConcurrentModificationException" + sender_fault: bool = True + status_code: int = 429 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class DashboardValidationMessage(TypedDict, total=False): + DataPath: Optional[DataPath] + Message: Optional[Message] + + +DashboardValidationMessages = List[DashboardValidationMessage] + + +class DashboardInvalidInputError(ServiceException): + code: str = "InvalidParameterInput" + sender_fault: bool = True + status_code: int = 400 + dashboardValidationMessages: Optional[DashboardValidationMessages] + + +class DashboardNotFoundError(ServiceException): + code: str = "ResourceNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class InternalServiceFault(ServiceException): + code: str = "InternalServiceError" + sender_fault: bool = False + status_code: int = 500 + + +class InvalidFormatFault(ServiceException): + code: str = "InvalidFormat" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidNextToken(ServiceException): + code: str = "InvalidNextToken" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidParameterCombinationException(ServiceException): + code: str = "InvalidParameterCombination" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidParameterValueException(ServiceException): + code: str = "InvalidParameterValue" + sender_fault: bool = True + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = True + status_code: int = 400 + + +class LimitExceededFault(ServiceException): + code: str = "LimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class MissingRequiredParameterException(ServiceException): + code: str = "MissingParameter" + sender_fault: bool = True + status_code: int = 400 + + +class ResourceNotFound(ServiceException): + code: str = "ResourceNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = True + status_code: int = 404 + ResourceType: Optional[ResourceType] + ResourceId: Optional[ResourceId] + + +Timestamp = datetime + + +class AlarmHistoryItem(TypedDict, total=False): + AlarmName: Optional[AlarmName] + AlarmType: Optional[AlarmType] + Timestamp: Optional[Timestamp] + HistoryItemType: Optional[HistoryItemType] + HistorySummary: Optional[HistorySummary] + HistoryData: Optional[HistoryData] + + +AlarmHistoryItems = List[AlarmHistoryItem] +AlarmNames = List[AlarmName] +AlarmTypes = List[AlarmType] + + +class Dimension(TypedDict, total=False): + Name: DimensionName + Value: DimensionValue + + +Dimensions = List[Dimension] + + +class Metric(TypedDict, total=False): + Namespace: Optional[Namespace] + MetricName: Optional[MetricName] + Dimensions: Optional[Dimensions] + + +class MetricStat(TypedDict, total=False): + Metric: Metric + Period: Period + Stat: Stat + Unit: Optional[StandardUnit] + + +class MetricDataQuery(TypedDict, total=False): + Id: MetricId + MetricStat: Optional[MetricStat] + Expression: Optional[MetricExpression] + Label: Optional[MetricLabel] + ReturnData: Optional[ReturnData] + Period: Optional[Period] + AccountId: Optional[AccountId] + + +MetricDataQueries = List[MetricDataQuery] + + +class MetricMathAnomalyDetector(TypedDict, total=False): + MetricDataQueries: Optional[MetricDataQueries] + + +class SingleMetricAnomalyDetector(TypedDict, total=False): + AccountId: Optional[AccountId] + Namespace: Optional[Namespace] + MetricName: Optional[MetricName] + Dimensions: Optional[Dimensions] + Stat: Optional[AnomalyDetectorMetricStat] + + +class MetricCharacteristics(TypedDict, total=False): + PeriodicSpikes: Optional[PeriodicSpikes] + + +class Range(TypedDict, total=False): + StartTime: Timestamp + EndTime: Timestamp + + +AnomalyDetectorExcludedTimeRanges = List[Range] + + +class AnomalyDetectorConfiguration(TypedDict, total=False): + ExcludedTimeRanges: Optional[AnomalyDetectorExcludedTimeRanges] + MetricTimezone: Optional[AnomalyDetectorMetricTimezone] + + +class AnomalyDetector(TypedDict, total=False): + Namespace: Optional[Namespace] + MetricName: Optional[MetricName] + Dimensions: Optional[Dimensions] + Stat: Optional[AnomalyDetectorMetricStat] + Configuration: Optional[AnomalyDetectorConfiguration] + StateValue: Optional[AnomalyDetectorStateValue] + MetricCharacteristics: Optional[MetricCharacteristics] + SingleMetricAnomalyDetector: Optional[SingleMetricAnomalyDetector] + MetricMathAnomalyDetector: Optional[MetricMathAnomalyDetector] + + +AnomalyDetectorTypes = List[AnomalyDetectorType] +AnomalyDetectors = List[AnomalyDetector] + + +class PartialFailure(TypedDict, total=False): + FailureResource: Optional[FailureResource] + ExceptionType: Optional[ExceptionType] + FailureCode: Optional[FailureCode] + FailureDescription: Optional[FailureDescription] + + +BatchFailures = List[PartialFailure] +ResourceList = List[ResourceName] + + +class CompositeAlarm(TypedDict, total=False): + ActionsEnabled: Optional[ActionsEnabled] + AlarmActions: Optional[ResourceList] + AlarmArn: Optional[AlarmArn] + AlarmConfigurationUpdatedTimestamp: Optional[Timestamp] + AlarmDescription: Optional[AlarmDescription] + AlarmName: Optional[AlarmName] + AlarmRule: Optional[AlarmRule] + InsufficientDataActions: Optional[ResourceList] + OKActions: Optional[ResourceList] + StateReason: Optional[StateReason] + StateReasonData: Optional[StateReasonData] + StateUpdatedTimestamp: Optional[Timestamp] + StateValue: Optional[StateValue] + StateTransitionedTimestamp: Optional[Timestamp] + ActionsSuppressedBy: Optional[ActionsSuppressedBy] + ActionsSuppressedReason: Optional[ActionsSuppressedReason] + ActionsSuppressor: Optional[AlarmArn] + ActionsSuppressorWaitPeriod: Optional[SuppressorPeriod] + ActionsSuppressorExtensionPeriod: Optional[SuppressorPeriod] + + +CompositeAlarms = List[CompositeAlarm] +Counts = List[DatapointValue] +Size = int +LastModified = datetime + + +class DashboardEntry(TypedDict, total=False): + DashboardName: Optional[DashboardName] + DashboardArn: Optional[DashboardArn] + LastModified: Optional[LastModified] + Size: Optional[Size] + + +DashboardEntries = List[DashboardEntry] +DashboardNames = List[DashboardName] +DatapointValueMap = Dict[ExtendedStatistic, DatapointValue] + + +class Datapoint(TypedDict, total=False): + Timestamp: Optional[Timestamp] + SampleCount: Optional[DatapointValue] + Average: Optional[DatapointValue] + Sum: Optional[DatapointValue] + Minimum: Optional[DatapointValue] + Maximum: Optional[DatapointValue] + Unit: Optional[StandardUnit] + ExtendedStatistics: Optional[DatapointValueMap] + + +DatapointValues = List[DatapointValue] +Datapoints = List[Datapoint] + + +class DeleteAlarmsInput(ServiceRequest): + AlarmNames: AlarmNames + + +class DeleteAnomalyDetectorInput(ServiceRequest): + Namespace: Optional[Namespace] + MetricName: Optional[MetricName] + Dimensions: Optional[Dimensions] + Stat: Optional[AnomalyDetectorMetricStat] + SingleMetricAnomalyDetector: Optional[SingleMetricAnomalyDetector] + MetricMathAnomalyDetector: Optional[MetricMathAnomalyDetector] + + +class DeleteAnomalyDetectorOutput(TypedDict, total=False): + pass + + +class DeleteDashboardsInput(ServiceRequest): + DashboardNames: DashboardNames + + +class DeleteDashboardsOutput(TypedDict, total=False): + pass + + +InsightRuleNames = List[InsightRuleName] + + +class DeleteInsightRulesInput(ServiceRequest): + RuleNames: InsightRuleNames + + +class DeleteInsightRulesOutput(TypedDict, total=False): + Failures: Optional[BatchFailures] + + +class DeleteMetricStreamInput(ServiceRequest): + Name: MetricStreamName + + +class DeleteMetricStreamOutput(TypedDict, total=False): + pass + + +class DescribeAlarmHistoryInput(ServiceRequest): + AlarmName: Optional[AlarmName] + AlarmTypes: Optional[AlarmTypes] + HistoryItemType: Optional[HistoryItemType] + StartDate: Optional[Timestamp] + EndDate: Optional[Timestamp] + MaxRecords: Optional[MaxRecords] + NextToken: Optional[NextToken] + ScanBy: Optional[ScanBy] + + +class DescribeAlarmHistoryOutput(TypedDict, total=False): + AlarmHistoryItems: Optional[AlarmHistoryItems] + NextToken: Optional[NextToken] + + +class DescribeAlarmsForMetricInput(ServiceRequest): + MetricName: MetricName + Namespace: Namespace + Statistic: Optional[Statistic] + ExtendedStatistic: Optional[ExtendedStatistic] + Dimensions: Optional[Dimensions] + Period: Optional[Period] + Unit: Optional[StandardUnit] + + +class MetricAlarm(TypedDict, total=False): + AlarmName: Optional[AlarmName] + AlarmArn: Optional[AlarmArn] + AlarmDescription: Optional[AlarmDescription] + AlarmConfigurationUpdatedTimestamp: Optional[Timestamp] + ActionsEnabled: Optional[ActionsEnabled] + OKActions: Optional[ResourceList] + AlarmActions: Optional[ResourceList] + InsufficientDataActions: Optional[ResourceList] + StateValue: Optional[StateValue] + StateReason: Optional[StateReason] + StateReasonData: Optional[StateReasonData] + StateUpdatedTimestamp: Optional[Timestamp] + MetricName: Optional[MetricName] + Namespace: Optional[Namespace] + Statistic: Optional[Statistic] + ExtendedStatistic: Optional[ExtendedStatistic] + Dimensions: Optional[Dimensions] + Period: Optional[Period] + Unit: Optional[StandardUnit] + EvaluationPeriods: Optional[EvaluationPeriods] + DatapointsToAlarm: Optional[DatapointsToAlarm] + Threshold: Optional[Threshold] + ComparisonOperator: Optional[ComparisonOperator] + TreatMissingData: Optional[TreatMissingData] + EvaluateLowSampleCountPercentile: Optional[EvaluateLowSampleCountPercentile] + Metrics: Optional[MetricDataQueries] + ThresholdMetricId: Optional[MetricId] + EvaluationState: Optional[EvaluationState] + StateTransitionedTimestamp: Optional[Timestamp] + + +MetricAlarms = List[MetricAlarm] + + +class DescribeAlarmsForMetricOutput(TypedDict, total=False): + MetricAlarms: Optional[MetricAlarms] + + +class DescribeAlarmsInput(ServiceRequest): + AlarmNames: Optional[AlarmNames] + AlarmNamePrefix: Optional[AlarmNamePrefix] + AlarmTypes: Optional[AlarmTypes] + ChildrenOfAlarmName: Optional[AlarmName] + ParentsOfAlarmName: Optional[AlarmName] + StateValue: Optional[StateValue] + ActionPrefix: Optional[ActionPrefix] + MaxRecords: Optional[MaxRecords] + NextToken: Optional[NextToken] + + +class DescribeAlarmsOutput(TypedDict, total=False): + CompositeAlarms: Optional[CompositeAlarms] + MetricAlarms: Optional[MetricAlarms] + NextToken: Optional[NextToken] + + +class DescribeAnomalyDetectorsInput(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxReturnedResultsCount] + Namespace: Optional[Namespace] + MetricName: Optional[MetricName] + Dimensions: Optional[Dimensions] + AnomalyDetectorTypes: Optional[AnomalyDetectorTypes] + + +class DescribeAnomalyDetectorsOutput(TypedDict, total=False): + AnomalyDetectors: Optional[AnomalyDetectors] + NextToken: Optional[NextToken] + + +class DescribeInsightRulesInput(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[InsightRuleMaxResults] + + +class InsightRule(TypedDict, total=False): + Name: InsightRuleName + State: InsightRuleState + Schema: InsightRuleSchema + Definition: InsightRuleDefinition + ManagedRule: Optional[InsightRuleIsManaged] + ApplyOnTransformedLogs: Optional[InsightRuleOnTransformedLogs] + + +InsightRules = List[InsightRule] + + +class DescribeInsightRulesOutput(TypedDict, total=False): + NextToken: Optional[NextToken] + InsightRules: Optional[InsightRules] + + +class DimensionFilter(TypedDict, total=False): + Name: DimensionName + Value: Optional[DimensionValue] + + +DimensionFilters = List[DimensionFilter] + + +class DisableAlarmActionsInput(ServiceRequest): + AlarmNames: AlarmNames + + +class DisableInsightRulesInput(ServiceRequest): + RuleNames: InsightRuleNames + + +class DisableInsightRulesOutput(TypedDict, total=False): + Failures: Optional[BatchFailures] + + +class EnableAlarmActionsInput(ServiceRequest): + AlarmNames: AlarmNames + + +class EnableInsightRulesInput(ServiceRequest): + RuleNames: InsightRuleNames + + +class EnableInsightRulesOutput(TypedDict, total=False): + Failures: Optional[BatchFailures] + + +EntityAttributesMap = Dict[EntityAttributesMapKeyString, EntityAttributesMapValueString] +EntityKeyAttributesMap = Dict[EntityKeyAttributesMapKeyString, EntityKeyAttributesMapValueString] + + +class Entity(TypedDict, total=False): + KeyAttributes: Optional[EntityKeyAttributesMap] + Attributes: Optional[EntityAttributesMap] + + +Values = List[DatapointValue] + + +class StatisticSet(TypedDict, total=False): + SampleCount: DatapointValue + Sum: DatapointValue + Minimum: DatapointValue + Maximum: DatapointValue + + +class MetricDatum(TypedDict, total=False): + MetricName: MetricName + Dimensions: Optional[Dimensions] + Timestamp: Optional[Timestamp] + Value: Optional[DatapointValue] + StatisticValues: Optional[StatisticSet] + Values: Optional[Values] + Counts: Optional[Counts] + Unit: Optional[StandardUnit] + StorageResolution: Optional[StorageResolution] + + +MetricData = List[MetricDatum] + + +class EntityMetricData(TypedDict, total=False): + Entity: Optional[Entity] + MetricData: Optional[MetricData] + + +EntityMetricDataList = List[EntityMetricData] +ExtendedStatistics = List[ExtendedStatistic] + + +class GetDashboardInput(ServiceRequest): + DashboardName: DashboardName + + +class GetDashboardOutput(TypedDict, total=False): + DashboardArn: Optional[DashboardArn] + DashboardBody: Optional[DashboardBody] + DashboardName: Optional[DashboardName] + + +InsightRuleMetricList = List[InsightRuleMetricName] + + +class GetInsightRuleReportInput(ServiceRequest): + RuleName: InsightRuleName + StartTime: Timestamp + EndTime: Timestamp + Period: Period + MaxContributorCount: Optional[InsightRuleUnboundInteger] + Metrics: Optional[InsightRuleMetricList] + OrderBy: Optional[InsightRuleOrderBy] + + +class InsightRuleMetricDatapoint(TypedDict, total=False): + Timestamp: Timestamp + UniqueContributors: Optional[InsightRuleUnboundDouble] + MaxContributorValue: Optional[InsightRuleUnboundDouble] + SampleCount: Optional[InsightRuleUnboundDouble] + Average: Optional[InsightRuleUnboundDouble] + Sum: Optional[InsightRuleUnboundDouble] + Minimum: Optional[InsightRuleUnboundDouble] + Maximum: Optional[InsightRuleUnboundDouble] + + +InsightRuleMetricDatapoints = List[InsightRuleMetricDatapoint] + + +class InsightRuleContributorDatapoint(TypedDict, total=False): + Timestamp: Timestamp + ApproximateValue: InsightRuleUnboundDouble + + +InsightRuleContributorDatapoints = List[InsightRuleContributorDatapoint] +InsightRuleContributorKeys = List[InsightRuleContributorKey] + + +class InsightRuleContributor(TypedDict, total=False): + Keys: InsightRuleContributorKeys + ApproximateAggregateValue: InsightRuleUnboundDouble + Datapoints: InsightRuleContributorDatapoints + + +InsightRuleContributors = List[InsightRuleContributor] +InsightRuleUnboundLong = int +InsightRuleContributorKeyLabels = List[InsightRuleContributorKeyLabel] + + +class GetInsightRuleReportOutput(TypedDict, total=False): + KeyLabels: Optional[InsightRuleContributorKeyLabels] + AggregationStatistic: Optional[InsightRuleAggregationStatistic] + AggregateValue: Optional[InsightRuleUnboundDouble] + ApproximateUniqueCount: Optional[InsightRuleUnboundLong] + Contributors: Optional[InsightRuleContributors] + MetricDatapoints: Optional[InsightRuleMetricDatapoints] + + +class LabelOptions(TypedDict, total=False): + Timezone: Optional[GetMetricDataLabelTimezone] + + +class GetMetricDataInput(ServiceRequest): + MetricDataQueries: MetricDataQueries + StartTime: Timestamp + EndTime: Timestamp + NextToken: Optional[NextToken] + ScanBy: Optional[ScanBy] + MaxDatapoints: Optional[GetMetricDataMaxDatapoints] + LabelOptions: Optional[LabelOptions] + + +class MessageData(TypedDict, total=False): + Code: Optional[MessageDataCode] + Value: Optional[MessageDataValue] + + +MetricDataResultMessages = List[MessageData] +Timestamps = List[Timestamp] + + +class MetricDataResult(TypedDict, total=False): + Id: Optional[MetricId] + Label: Optional[MetricLabel] + Timestamps: Optional[Timestamps] + Values: Optional[DatapointValues] + StatusCode: Optional[StatusCode] + Messages: Optional[MetricDataResultMessages] + + +MetricDataResults = List[MetricDataResult] + + +class GetMetricDataOutput(TypedDict, total=False): + MetricDataResults: Optional[MetricDataResults] + NextToken: Optional[NextToken] + Messages: Optional[MetricDataResultMessages] + + +Statistics = List[Statistic] + + +class GetMetricStatisticsInput(ServiceRequest): + Namespace: Namespace + MetricName: MetricName + Dimensions: Optional[Dimensions] + StartTime: Timestamp + EndTime: Timestamp + Period: Period + Statistics: Optional[Statistics] + ExtendedStatistics: Optional[ExtendedStatistics] + Unit: Optional[StandardUnit] + + +class GetMetricStatisticsOutput(TypedDict, total=False): + Label: Optional[MetricLabel] + Datapoints: Optional[Datapoints] + + +class GetMetricStreamInput(ServiceRequest): + Name: MetricStreamName + + +MetricStreamStatisticsAdditionalStatistics = List[MetricStreamStatistic] + + +class MetricStreamStatisticsMetric(TypedDict, total=False): + Namespace: Namespace + MetricName: MetricName + + +MetricStreamStatisticsIncludeMetrics = List[MetricStreamStatisticsMetric] + + +class MetricStreamStatisticsConfiguration(TypedDict, total=False): + IncludeMetrics: MetricStreamStatisticsIncludeMetrics + AdditionalStatistics: MetricStreamStatisticsAdditionalStatistics + + +MetricStreamStatisticsConfigurations = List[MetricStreamStatisticsConfiguration] +MetricStreamFilterMetricNames = List[MetricName] + + +class MetricStreamFilter(TypedDict, total=False): + Namespace: Optional[Namespace] + MetricNames: Optional[MetricStreamFilterMetricNames] + + +MetricStreamFilters = List[MetricStreamFilter] + + +class GetMetricStreamOutput(TypedDict, total=False): + Arn: Optional[AmazonResourceName] + Name: Optional[MetricStreamName] + IncludeFilters: Optional[MetricStreamFilters] + ExcludeFilters: Optional[MetricStreamFilters] + FirehoseArn: Optional[AmazonResourceName] + RoleArn: Optional[AmazonResourceName] + State: Optional[MetricStreamState] + CreationDate: Optional[Timestamp] + LastUpdateDate: Optional[Timestamp] + OutputFormat: Optional[MetricStreamOutputFormat] + StatisticsConfigurations: Optional[MetricStreamStatisticsConfigurations] + IncludeLinkedAccountsMetrics: Optional[IncludeLinkedAccountsMetrics] + + +class GetMetricWidgetImageInput(ServiceRequest): + MetricWidget: MetricWidget + OutputFormat: Optional[OutputFormat] + + +MetricWidgetImage = bytes + + +class GetMetricWidgetImageOutput(TypedDict, total=False): + MetricWidgetImage: Optional[MetricWidgetImage] + + +class ListDashboardsInput(ServiceRequest): + DashboardNamePrefix: Optional[DashboardNamePrefix] + NextToken: Optional[NextToken] + + +class ListDashboardsOutput(TypedDict, total=False): + DashboardEntries: Optional[DashboardEntries] + NextToken: Optional[NextToken] + + +class ListManagedInsightRulesInput(ServiceRequest): + ResourceARN: AmazonResourceName + NextToken: Optional[NextToken] + MaxResults: Optional[InsightRuleMaxResults] + + +class ManagedRuleState(TypedDict, total=False): + RuleName: InsightRuleName + State: InsightRuleState + + +class ManagedRuleDescription(TypedDict, total=False): + TemplateName: Optional[TemplateName] + ResourceARN: Optional[AmazonResourceName] + RuleState: Optional[ManagedRuleState] + + +ManagedRuleDescriptions = List[ManagedRuleDescription] + + +class ListManagedInsightRulesOutput(TypedDict, total=False): + ManagedRules: Optional[ManagedRuleDescriptions] + NextToken: Optional[NextToken] + + +class ListMetricStreamsInput(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[ListMetricStreamsMaxResults] + + +class MetricStreamEntry(TypedDict, total=False): + Arn: Optional[AmazonResourceName] + CreationDate: Optional[Timestamp] + LastUpdateDate: Optional[Timestamp] + Name: Optional[MetricStreamName] + FirehoseArn: Optional[AmazonResourceName] + State: Optional[MetricStreamState] + OutputFormat: Optional[MetricStreamOutputFormat] + + +MetricStreamEntries = List[MetricStreamEntry] + + +class ListMetricStreamsOutput(TypedDict, total=False): + NextToken: Optional[NextToken] + Entries: Optional[MetricStreamEntries] + + +class ListMetricsInput(ServiceRequest): + Namespace: Optional[Namespace] + MetricName: Optional[MetricName] + Dimensions: Optional[DimensionFilters] + NextToken: Optional[NextToken] + RecentlyActive: Optional[RecentlyActive] + IncludeLinkedAccounts: Optional[IncludeLinkedAccounts] + OwningAccount: Optional[AccountId] + + +OwningAccounts = List[AccountId] +Metrics = List[Metric] + + +class ListMetricsOutput(TypedDict, total=False): + Metrics: Optional[Metrics] + NextToken: Optional[NextToken] + OwningAccounts: Optional[OwningAccounts] + + +class ListTagsForResourceInput(ServiceRequest): + ResourceARN: AmazonResourceName + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class ListTagsForResourceOutput(TypedDict, total=False): + Tags: Optional[TagList] + + +class ManagedRule(TypedDict, total=False): + TemplateName: TemplateName + ResourceARN: AmazonResourceName + Tags: Optional[TagList] + + +ManagedRules = List[ManagedRule] +MetricStreamNames = List[MetricStreamName] + + +class PutAnomalyDetectorInput(ServiceRequest): + Namespace: Optional[Namespace] + MetricName: Optional[MetricName] + Dimensions: Optional[Dimensions] + Stat: Optional[AnomalyDetectorMetricStat] + Configuration: Optional[AnomalyDetectorConfiguration] + MetricCharacteristics: Optional[MetricCharacteristics] + SingleMetricAnomalyDetector: Optional[SingleMetricAnomalyDetector] + MetricMathAnomalyDetector: Optional[MetricMathAnomalyDetector] + + +class PutAnomalyDetectorOutput(TypedDict, total=False): + pass + + +class PutCompositeAlarmInput(ServiceRequest): + ActionsEnabled: Optional[ActionsEnabled] + AlarmActions: Optional[ResourceList] + AlarmDescription: Optional[AlarmDescription] + AlarmName: AlarmName + AlarmRule: AlarmRule + InsufficientDataActions: Optional[ResourceList] + OKActions: Optional[ResourceList] + Tags: Optional[TagList] + ActionsSuppressor: Optional[AlarmArn] + ActionsSuppressorWaitPeriod: Optional[SuppressorPeriod] + ActionsSuppressorExtensionPeriod: Optional[SuppressorPeriod] + + +class PutDashboardInput(ServiceRequest): + DashboardName: DashboardName + DashboardBody: DashboardBody + + +class PutDashboardOutput(TypedDict, total=False): + DashboardValidationMessages: Optional[DashboardValidationMessages] + + +class PutInsightRuleInput(ServiceRequest): + RuleName: InsightRuleName + RuleState: Optional[InsightRuleState] + RuleDefinition: InsightRuleDefinition + Tags: Optional[TagList] + ApplyOnTransformedLogs: Optional[InsightRuleOnTransformedLogs] + + +class PutInsightRuleOutput(TypedDict, total=False): + pass + + +class PutManagedInsightRulesInput(ServiceRequest): + ManagedRules: ManagedRules + + +class PutManagedInsightRulesOutput(TypedDict, total=False): + Failures: Optional[BatchFailures] + + +class PutMetricAlarmInput(ServiceRequest): + AlarmName: AlarmName + AlarmDescription: Optional[AlarmDescription] + ActionsEnabled: Optional[ActionsEnabled] + OKActions: Optional[ResourceList] + AlarmActions: Optional[ResourceList] + InsufficientDataActions: Optional[ResourceList] + MetricName: Optional[MetricName] + Namespace: Optional[Namespace] + Statistic: Optional[Statistic] + ExtendedStatistic: Optional[ExtendedStatistic] + Dimensions: Optional[Dimensions] + Period: Optional[Period] + Unit: Optional[StandardUnit] + EvaluationPeriods: EvaluationPeriods + DatapointsToAlarm: Optional[DatapointsToAlarm] + Threshold: Optional[Threshold] + ComparisonOperator: ComparisonOperator + TreatMissingData: Optional[TreatMissingData] + EvaluateLowSampleCountPercentile: Optional[EvaluateLowSampleCountPercentile] + Metrics: Optional[MetricDataQueries] + Tags: Optional[TagList] + ThresholdMetricId: Optional[MetricId] + + +class PutMetricDataInput(ServiceRequest): + Namespace: Namespace + MetricData: Optional[MetricData] + EntityMetricData: Optional[EntityMetricDataList] + StrictEntityValidation: Optional[StrictEntityValidation] + + +class PutMetricStreamInput(ServiceRequest): + Name: MetricStreamName + IncludeFilters: Optional[MetricStreamFilters] + ExcludeFilters: Optional[MetricStreamFilters] + FirehoseArn: AmazonResourceName + RoleArn: AmazonResourceName + OutputFormat: MetricStreamOutputFormat + Tags: Optional[TagList] + StatisticsConfigurations: Optional[MetricStreamStatisticsConfigurations] + IncludeLinkedAccountsMetrics: Optional[IncludeLinkedAccountsMetrics] + + +class PutMetricStreamOutput(TypedDict, total=False): + Arn: Optional[AmazonResourceName] + + +class SetAlarmStateInput(ServiceRequest): + AlarmName: AlarmName + StateValue: StateValue + StateReason: StateReason + StateReasonData: Optional[StateReasonData] + + +class StartMetricStreamsInput(ServiceRequest): + Names: MetricStreamNames + + +class StartMetricStreamsOutput(TypedDict, total=False): + pass + + +class StopMetricStreamsInput(ServiceRequest): + Names: MetricStreamNames + + +class StopMetricStreamsOutput(TypedDict, total=False): + pass + + +TagKeyList = List[TagKey] + + +class TagResourceInput(ServiceRequest): + ResourceARN: AmazonResourceName + Tags: TagList + + +class TagResourceOutput(TypedDict, total=False): + pass + + +class UntagResourceInput(ServiceRequest): + ResourceARN: AmazonResourceName + TagKeys: TagKeyList + + +class UntagResourceOutput(TypedDict, total=False): + pass + + +class CloudwatchApi: + service = "cloudwatch" + version = "2010-08-01" + + @handler("DeleteAlarms") + def delete_alarms(self, context: RequestContext, alarm_names: AlarmNames, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteAnomalyDetector") + def delete_anomaly_detector( + self, + context: RequestContext, + namespace: Namespace | None = None, + metric_name: MetricName | None = None, + dimensions: Dimensions | None = None, + stat: AnomalyDetectorMetricStat | None = None, + single_metric_anomaly_detector: SingleMetricAnomalyDetector | None = None, + metric_math_anomaly_detector: MetricMathAnomalyDetector | None = None, + **kwargs, + ) -> DeleteAnomalyDetectorOutput: + raise NotImplementedError + + @handler("DeleteDashboards") + def delete_dashboards( + self, context: RequestContext, dashboard_names: DashboardNames, **kwargs + ) -> DeleteDashboardsOutput: + raise NotImplementedError + + @handler("DeleteInsightRules") + def delete_insight_rules( + self, context: RequestContext, rule_names: InsightRuleNames, **kwargs + ) -> DeleteInsightRulesOutput: + raise NotImplementedError + + @handler("DeleteMetricStream") + def delete_metric_stream( + self, context: RequestContext, name: MetricStreamName, **kwargs + ) -> DeleteMetricStreamOutput: + raise NotImplementedError + + @handler("DescribeAlarmHistory") + def describe_alarm_history( + self, + context: RequestContext, + alarm_name: AlarmName | None = None, + alarm_types: AlarmTypes | None = None, + history_item_type: HistoryItemType | None = None, + start_date: Timestamp | None = None, + end_date: Timestamp | None = None, + max_records: MaxRecords | None = None, + next_token: NextToken | None = None, + scan_by: ScanBy | None = None, + **kwargs, + ) -> DescribeAlarmHistoryOutput: + raise NotImplementedError + + @handler("DescribeAlarms") + def describe_alarms( + self, + context: RequestContext, + alarm_names: AlarmNames | None = None, + alarm_name_prefix: AlarmNamePrefix | None = None, + alarm_types: AlarmTypes | None = None, + children_of_alarm_name: AlarmName | None = None, + parents_of_alarm_name: AlarmName | None = None, + state_value: StateValue | None = None, + action_prefix: ActionPrefix | None = None, + max_records: MaxRecords | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAlarmsOutput: + raise NotImplementedError + + @handler("DescribeAlarmsForMetric") + def describe_alarms_for_metric( + self, + context: RequestContext, + metric_name: MetricName, + namespace: Namespace, + statistic: Statistic | None = None, + extended_statistic: ExtendedStatistic | None = None, + dimensions: Dimensions | None = None, + period: Period | None = None, + unit: StandardUnit | None = None, + **kwargs, + ) -> DescribeAlarmsForMetricOutput: + raise NotImplementedError + + @handler("DescribeAnomalyDetectors") + def describe_anomaly_detectors( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxReturnedResultsCount | None = None, + namespace: Namespace | None = None, + metric_name: MetricName | None = None, + dimensions: Dimensions | None = None, + anomaly_detector_types: AnomalyDetectorTypes | None = None, + **kwargs, + ) -> DescribeAnomalyDetectorsOutput: + raise NotImplementedError + + @handler("DescribeInsightRules") + def describe_insight_rules( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: InsightRuleMaxResults | None = None, + **kwargs, + ) -> DescribeInsightRulesOutput: + raise NotImplementedError + + @handler("DisableAlarmActions") + def disable_alarm_actions( + self, context: RequestContext, alarm_names: AlarmNames, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DisableInsightRules") + def disable_insight_rules( + self, context: RequestContext, rule_names: InsightRuleNames, **kwargs + ) -> DisableInsightRulesOutput: + raise NotImplementedError + + @handler("EnableAlarmActions") + def enable_alarm_actions( + self, context: RequestContext, alarm_names: AlarmNames, **kwargs + ) -> None: + raise NotImplementedError + + @handler("EnableInsightRules") + def enable_insight_rules( + self, context: RequestContext, rule_names: InsightRuleNames, **kwargs + ) -> EnableInsightRulesOutput: + raise NotImplementedError + + @handler("GetDashboard") + def get_dashboard( + self, context: RequestContext, dashboard_name: DashboardName, **kwargs + ) -> GetDashboardOutput: + raise NotImplementedError + + @handler("GetInsightRuleReport") + def get_insight_rule_report( + self, + context: RequestContext, + rule_name: InsightRuleName, + start_time: Timestamp, + end_time: Timestamp, + period: Period, + max_contributor_count: InsightRuleUnboundInteger | None = None, + metrics: InsightRuleMetricList | None = None, + order_by: InsightRuleOrderBy | None = None, + **kwargs, + ) -> GetInsightRuleReportOutput: + raise NotImplementedError + + @handler("GetMetricData") + def get_metric_data( + self, + context: RequestContext, + metric_data_queries: MetricDataQueries, + start_time: Timestamp, + end_time: Timestamp, + next_token: NextToken | None = None, + scan_by: ScanBy | None = None, + max_datapoints: GetMetricDataMaxDatapoints | None = None, + label_options: LabelOptions | None = None, + **kwargs, + ) -> GetMetricDataOutput: + raise NotImplementedError + + @handler("GetMetricStatistics") + def get_metric_statistics( + self, + context: RequestContext, + namespace: Namespace, + metric_name: MetricName, + start_time: Timestamp, + end_time: Timestamp, + period: Period, + dimensions: Dimensions | None = None, + statistics: Statistics | None = None, + extended_statistics: ExtendedStatistics | None = None, + unit: StandardUnit | None = None, + **kwargs, + ) -> GetMetricStatisticsOutput: + raise NotImplementedError + + @handler("GetMetricStream") + def get_metric_stream( + self, context: RequestContext, name: MetricStreamName, **kwargs + ) -> GetMetricStreamOutput: + raise NotImplementedError + + @handler("GetMetricWidgetImage") + def get_metric_widget_image( + self, + context: RequestContext, + metric_widget: MetricWidget, + output_format: OutputFormat | None = None, + **kwargs, + ) -> GetMetricWidgetImageOutput: + raise NotImplementedError + + @handler("ListDashboards") + def list_dashboards( + self, + context: RequestContext, + dashboard_name_prefix: DashboardNamePrefix | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListDashboardsOutput: + raise NotImplementedError + + @handler("ListManagedInsightRules") + def list_managed_insight_rules( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + next_token: NextToken | None = None, + max_results: InsightRuleMaxResults | None = None, + **kwargs, + ) -> ListManagedInsightRulesOutput: + raise NotImplementedError + + @handler("ListMetricStreams") + def list_metric_streams( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: ListMetricStreamsMaxResults | None = None, + **kwargs, + ) -> ListMetricStreamsOutput: + raise NotImplementedError + + @handler("ListMetrics") + def list_metrics( + self, + context: RequestContext, + namespace: Namespace | None = None, + metric_name: MetricName | None = None, + dimensions: DimensionFilters | None = None, + next_token: NextToken | None = None, + recently_active: RecentlyActive | None = None, + include_linked_accounts: IncludeLinkedAccounts | None = None, + owning_account: AccountId | None = None, + **kwargs, + ) -> ListMetricsOutput: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs + ) -> ListTagsForResourceOutput: + raise NotImplementedError + + @handler("PutAnomalyDetector") + def put_anomaly_detector( + self, + context: RequestContext, + namespace: Namespace | None = None, + metric_name: MetricName | None = None, + dimensions: Dimensions | None = None, + stat: AnomalyDetectorMetricStat | None = None, + configuration: AnomalyDetectorConfiguration | None = None, + metric_characteristics: MetricCharacteristics | None = None, + single_metric_anomaly_detector: SingleMetricAnomalyDetector | None = None, + metric_math_anomaly_detector: MetricMathAnomalyDetector | None = None, + **kwargs, + ) -> PutAnomalyDetectorOutput: + raise NotImplementedError + + @handler("PutCompositeAlarm") + def put_composite_alarm( + self, + context: RequestContext, + alarm_name: AlarmName, + alarm_rule: AlarmRule, + actions_enabled: ActionsEnabled | None = None, + alarm_actions: ResourceList | None = None, + alarm_description: AlarmDescription | None = None, + insufficient_data_actions: ResourceList | None = None, + ok_actions: ResourceList | None = None, + tags: TagList | None = None, + actions_suppressor: AlarmArn | None = None, + actions_suppressor_wait_period: SuppressorPeriod | None = None, + actions_suppressor_extension_period: SuppressorPeriod | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutDashboard") + def put_dashboard( + self, + context: RequestContext, + dashboard_name: DashboardName, + dashboard_body: DashboardBody, + **kwargs, + ) -> PutDashboardOutput: + raise NotImplementedError + + @handler("PutInsightRule") + def put_insight_rule( + self, + context: RequestContext, + rule_name: InsightRuleName, + rule_definition: InsightRuleDefinition, + rule_state: InsightRuleState | None = None, + tags: TagList | None = None, + apply_on_transformed_logs: InsightRuleOnTransformedLogs | None = None, + **kwargs, + ) -> PutInsightRuleOutput: + raise NotImplementedError + + @handler("PutManagedInsightRules") + def put_managed_insight_rules( + self, context: RequestContext, managed_rules: ManagedRules, **kwargs + ) -> PutManagedInsightRulesOutput: + raise NotImplementedError + + @handler("PutMetricAlarm") + def put_metric_alarm( + self, + context: RequestContext, + alarm_name: AlarmName, + evaluation_periods: EvaluationPeriods, + comparison_operator: ComparisonOperator, + alarm_description: AlarmDescription | None = None, + actions_enabled: ActionsEnabled | None = None, + ok_actions: ResourceList | None = None, + alarm_actions: ResourceList | None = None, + insufficient_data_actions: ResourceList | None = None, + metric_name: MetricName | None = None, + namespace: Namespace | None = None, + statistic: Statistic | None = None, + extended_statistic: ExtendedStatistic | None = None, + dimensions: Dimensions | None = None, + period: Period | None = None, + unit: StandardUnit | None = None, + datapoints_to_alarm: DatapointsToAlarm | None = None, + threshold: Threshold | None = None, + treat_missing_data: TreatMissingData | None = None, + evaluate_low_sample_count_percentile: EvaluateLowSampleCountPercentile | None = None, + metrics: MetricDataQueries | None = None, + tags: TagList | None = None, + threshold_metric_id: MetricId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutMetricData") + def put_metric_data( + self, + context: RequestContext, + namespace: Namespace, + metric_data: MetricData | None = None, + entity_metric_data: EntityMetricDataList | None = None, + strict_entity_validation: StrictEntityValidation | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutMetricStream") + def put_metric_stream( + self, + context: RequestContext, + name: MetricStreamName, + firehose_arn: AmazonResourceName, + role_arn: AmazonResourceName, + output_format: MetricStreamOutputFormat, + include_filters: MetricStreamFilters | None = None, + exclude_filters: MetricStreamFilters | None = None, + tags: TagList | None = None, + statistics_configurations: MetricStreamStatisticsConfigurations | None = None, + include_linked_accounts_metrics: IncludeLinkedAccountsMetrics | None = None, + **kwargs, + ) -> PutMetricStreamOutput: + raise NotImplementedError + + @handler("SetAlarmState") + def set_alarm_state( + self, + context: RequestContext, + alarm_name: AlarmName, + state_value: StateValue, + state_reason: StateReason, + state_reason_data: StateReasonData | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("StartMetricStreams") + def start_metric_streams( + self, context: RequestContext, names: MetricStreamNames, **kwargs + ) -> StartMetricStreamsOutput: + raise NotImplementedError + + @handler("StopMetricStreams") + def stop_metric_streams( + self, context: RequestContext, names: MetricStreamNames, **kwargs + ) -> StopMetricStreamsOutput: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: TagList, **kwargs + ) -> TagResourceOutput: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagResourceOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/config/__init__.py b/localstack-core/localstack/aws/api/config/__init__.py new file mode 100644 index 0000000000000..13e5026cd3aba --- /dev/null +++ b/localstack-core/localstack/aws/api/config/__init__.py @@ -0,0 +1,4113 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ARN = str +AccountId = str +AllSupported = bool +AmazonResourceName = str +Annotation = str +AutoRemediationAttempts = int +AvailabilityZone = str +AwsRegion = str +BaseResourceId = str +Boolean = bool +ChannelName = str +ClientToken = str +ComplianceScore = str +ConfigRuleName = str +Configuration = str +ConfigurationAggregatorArn = str +ConfigurationAggregatorName = str +ConfigurationItemMD5Hash = str +ConfigurationRecorderFilterValue = str +ConfigurationStateId = str +ConformancePackArn = str +ConformancePackId = str +ConformancePackName = str +ConformancePackStatusReason = str +CosmosPageLimit = int +DeliveryS3Bucket = str +DeliveryS3KeyPrefix = str +DescribeConformancePackComplianceLimit = int +DescribePendingAggregationRequestsLimit = int +Description = str +EmptiableStringWithCharLimit256 = str +ErrorMessage = str +EvaluationContextIdentifier = str +EvaluationTimeout = int +Expression = str +FieldName = str +GetConformancePackComplianceDetailsLimit = int +GroupByAPILimit = int +IncludeGlobalResourceTypes = bool +Integer = int +Limit = int +ListResourceEvaluationsPageItemLimit = int +MaxResults = int +Name = str +NextToken = str +OrganizationConfigRuleName = str +OrganizationConformancePackName = str +PageSizeLimit = int +ParameterName = str +ParameterValue = str +Percentage = int +PolicyRuntime = str +PolicyText = str +QueryArn = str +QueryDescription = str +QueryExpression = str +QueryId = str +QueryName = str +RecorderName = str +RelatedEvent = str +RelationshipName = str +ResourceConfiguration = str +ResourceEvaluationId = str +ResourceId = str +ResourceName = str +ResourceTypeString = str +ResourceTypeValue = str +RetentionConfigurationName = str +RetentionPeriodInDays = int +RuleLimit = int +SSMDocumentName = str +SSMDocumentVersion = str +SchemaVersionId = str +ServicePrincipal = str +ServicePrincipalValue = str +StackArn = str +String = str +StringWithCharLimit1024 = str +StringWithCharLimit128 = str +StringWithCharLimit2048 = str +StringWithCharLimit256 = str +StringWithCharLimit256Min0 = str +StringWithCharLimit64 = str +StringWithCharLimit768 = str +SupplementaryConfigurationName = str +SupplementaryConfigurationValue = str +TagKey = str +TagValue = str +TemplateBody = str +TemplateS3Uri = str +Value = str +Version = str + + +class AggregateConformancePackComplianceSummaryGroupKey(StrEnum): + ACCOUNT_ID = "ACCOUNT_ID" + AWS_REGION = "AWS_REGION" + + +class AggregatedSourceStatusType(StrEnum): + FAILED = "FAILED" + SUCCEEDED = "SUCCEEDED" + OUTDATED = "OUTDATED" + + +class AggregatedSourceType(StrEnum): + ACCOUNT = "ACCOUNT" + ORGANIZATION = "ORGANIZATION" + + +class AggregatorFilterType(StrEnum): + INCLUDE = "INCLUDE" + + +class ChronologicalOrder(StrEnum): + Reverse = "Reverse" + Forward = "Forward" + + +class ComplianceType(StrEnum): + COMPLIANT = "COMPLIANT" + NON_COMPLIANT = "NON_COMPLIANT" + NOT_APPLICABLE = "NOT_APPLICABLE" + INSUFFICIENT_DATA = "INSUFFICIENT_DATA" + + +class ConfigRuleComplianceSummaryGroupKey(StrEnum): + ACCOUNT_ID = "ACCOUNT_ID" + AWS_REGION = "AWS_REGION" + + +class ConfigRuleState(StrEnum): + ACTIVE = "ACTIVE" + DELETING = "DELETING" + DELETING_RESULTS = "DELETING_RESULTS" + EVALUATING = "EVALUATING" + + +class ConfigurationItemStatus(StrEnum): + OK = "OK" + ResourceDiscovered = "ResourceDiscovered" + ResourceNotRecorded = "ResourceNotRecorded" + ResourceDeleted = "ResourceDeleted" + ResourceDeletedNotRecorded = "ResourceDeletedNotRecorded" + + +class ConfigurationRecorderFilterName(StrEnum): + recordingScope = "recordingScope" + + +class ConformancePackComplianceType(StrEnum): + COMPLIANT = "COMPLIANT" + NON_COMPLIANT = "NON_COMPLIANT" + INSUFFICIENT_DATA = "INSUFFICIENT_DATA" + + +class ConformancePackState(StrEnum): + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_COMPLETE = "CREATE_COMPLETE" + CREATE_FAILED = "CREATE_FAILED" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + DELETE_FAILED = "DELETE_FAILED" + + +class DeliveryStatus(StrEnum): + Success = "Success" + Failure = "Failure" + Not_Applicable = "Not_Applicable" + + +class EvaluationMode(StrEnum): + DETECTIVE = "DETECTIVE" + PROACTIVE = "PROACTIVE" + + +class EventSource(StrEnum): + aws_config = "aws.config" + + +class MaximumExecutionFrequency(StrEnum): + One_Hour = "One_Hour" + Three_Hours = "Three_Hours" + Six_Hours = "Six_Hours" + Twelve_Hours = "Twelve_Hours" + TwentyFour_Hours = "TwentyFour_Hours" + + +class MemberAccountRuleStatus(StrEnum): + CREATE_SUCCESSFUL = "CREATE_SUCCESSFUL" + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_FAILED = "CREATE_FAILED" + DELETE_SUCCESSFUL = "DELETE_SUCCESSFUL" + DELETE_FAILED = "DELETE_FAILED" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + UPDATE_SUCCESSFUL = "UPDATE_SUCCESSFUL" + UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS" + UPDATE_FAILED = "UPDATE_FAILED" + + +class MessageType(StrEnum): + ConfigurationItemChangeNotification = "ConfigurationItemChangeNotification" + ConfigurationSnapshotDeliveryCompleted = "ConfigurationSnapshotDeliveryCompleted" + ScheduledNotification = "ScheduledNotification" + OversizedConfigurationItemChangeNotification = "OversizedConfigurationItemChangeNotification" + + +class OrganizationConfigRuleTriggerType(StrEnum): + ConfigurationItemChangeNotification = "ConfigurationItemChangeNotification" + OversizedConfigurationItemChangeNotification = "OversizedConfigurationItemChangeNotification" + ScheduledNotification = "ScheduledNotification" + + +class OrganizationConfigRuleTriggerTypeNoSN(StrEnum): + ConfigurationItemChangeNotification = "ConfigurationItemChangeNotification" + OversizedConfigurationItemChangeNotification = "OversizedConfigurationItemChangeNotification" + + +class OrganizationResourceDetailedStatus(StrEnum): + CREATE_SUCCESSFUL = "CREATE_SUCCESSFUL" + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_FAILED = "CREATE_FAILED" + DELETE_SUCCESSFUL = "DELETE_SUCCESSFUL" + DELETE_FAILED = "DELETE_FAILED" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + UPDATE_SUCCESSFUL = "UPDATE_SUCCESSFUL" + UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS" + UPDATE_FAILED = "UPDATE_FAILED" + + +class OrganizationResourceStatus(StrEnum): + CREATE_SUCCESSFUL = "CREATE_SUCCESSFUL" + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_FAILED = "CREATE_FAILED" + DELETE_SUCCESSFUL = "DELETE_SUCCESSFUL" + DELETE_FAILED = "DELETE_FAILED" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + UPDATE_SUCCESSFUL = "UPDATE_SUCCESSFUL" + UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS" + UPDATE_FAILED = "UPDATE_FAILED" + + +class OrganizationRuleStatus(StrEnum): + CREATE_SUCCESSFUL = "CREATE_SUCCESSFUL" + CREATE_IN_PROGRESS = "CREATE_IN_PROGRESS" + CREATE_FAILED = "CREATE_FAILED" + DELETE_SUCCESSFUL = "DELETE_SUCCESSFUL" + DELETE_FAILED = "DELETE_FAILED" + DELETE_IN_PROGRESS = "DELETE_IN_PROGRESS" + UPDATE_SUCCESSFUL = "UPDATE_SUCCESSFUL" + UPDATE_IN_PROGRESS = "UPDATE_IN_PROGRESS" + UPDATE_FAILED = "UPDATE_FAILED" + + +class Owner(StrEnum): + CUSTOM_LAMBDA = "CUSTOM_LAMBDA" + AWS = "AWS" + CUSTOM_POLICY = "CUSTOM_POLICY" + + +class RecorderStatus(StrEnum): + Pending = "Pending" + Success = "Success" + Failure = "Failure" + NotApplicable = "NotApplicable" + + +class RecordingFrequency(StrEnum): + CONTINUOUS = "CONTINUOUS" + DAILY = "DAILY" + + +class RecordingScope(StrEnum): + INTERNAL = "INTERNAL" + PAID = "PAID" + + +class RecordingStrategyType(StrEnum): + ALL_SUPPORTED_RESOURCE_TYPES = "ALL_SUPPORTED_RESOURCE_TYPES" + INCLUSION_BY_RESOURCE_TYPES = "INCLUSION_BY_RESOURCE_TYPES" + EXCLUSION_BY_RESOURCE_TYPES = "EXCLUSION_BY_RESOURCE_TYPES" + + +class RemediationExecutionState(StrEnum): + QUEUED = "QUEUED" + IN_PROGRESS = "IN_PROGRESS" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + + +class RemediationExecutionStepState(StrEnum): + SUCCEEDED = "SUCCEEDED" + PENDING = "PENDING" + FAILED = "FAILED" + + +class RemediationTargetType(StrEnum): + SSM_DOCUMENT = "SSM_DOCUMENT" + + +class ResourceConfigurationSchemaType(StrEnum): + CFN_RESOURCE_SCHEMA = "CFN_RESOURCE_SCHEMA" + + +class ResourceCountGroupKey(StrEnum): + RESOURCE_TYPE = "RESOURCE_TYPE" + ACCOUNT_ID = "ACCOUNT_ID" + AWS_REGION = "AWS_REGION" + + +class ResourceEvaluationStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + SUCCEEDED = "SUCCEEDED" + + +class ResourceType(StrEnum): + AWS_EC2_CustomerGateway = "AWS::EC2::CustomerGateway" + AWS_EC2_EIP = "AWS::EC2::EIP" + AWS_EC2_Host = "AWS::EC2::Host" + AWS_EC2_Instance = "AWS::EC2::Instance" + AWS_EC2_InternetGateway = "AWS::EC2::InternetGateway" + AWS_EC2_NetworkAcl = "AWS::EC2::NetworkAcl" + AWS_EC2_NetworkInterface = "AWS::EC2::NetworkInterface" + AWS_EC2_RouteTable = "AWS::EC2::RouteTable" + AWS_EC2_SecurityGroup = "AWS::EC2::SecurityGroup" + AWS_EC2_Subnet = "AWS::EC2::Subnet" + AWS_CloudTrail_Trail = "AWS::CloudTrail::Trail" + AWS_EC2_Volume = "AWS::EC2::Volume" + AWS_EC2_VPC = "AWS::EC2::VPC" + AWS_EC2_VPNConnection = "AWS::EC2::VPNConnection" + AWS_EC2_VPNGateway = "AWS::EC2::VPNGateway" + AWS_EC2_RegisteredHAInstance = "AWS::EC2::RegisteredHAInstance" + AWS_EC2_NatGateway = "AWS::EC2::NatGateway" + AWS_EC2_EgressOnlyInternetGateway = "AWS::EC2::EgressOnlyInternetGateway" + AWS_EC2_VPCEndpoint = "AWS::EC2::VPCEndpoint" + AWS_EC2_VPCEndpointService = "AWS::EC2::VPCEndpointService" + AWS_EC2_FlowLog = "AWS::EC2::FlowLog" + AWS_EC2_VPCPeeringConnection = "AWS::EC2::VPCPeeringConnection" + AWS_Elasticsearch_Domain = "AWS::Elasticsearch::Domain" + AWS_IAM_Group = "AWS::IAM::Group" + AWS_IAM_Policy = "AWS::IAM::Policy" + AWS_IAM_Role = "AWS::IAM::Role" + AWS_IAM_User = "AWS::IAM::User" + AWS_ElasticLoadBalancingV2_LoadBalancer = "AWS::ElasticLoadBalancingV2::LoadBalancer" + AWS_ACM_Certificate = "AWS::ACM::Certificate" + AWS_RDS_DBInstance = "AWS::RDS::DBInstance" + AWS_RDS_DBSubnetGroup = "AWS::RDS::DBSubnetGroup" + AWS_RDS_DBSecurityGroup = "AWS::RDS::DBSecurityGroup" + AWS_RDS_DBSnapshot = "AWS::RDS::DBSnapshot" + AWS_RDS_DBCluster = "AWS::RDS::DBCluster" + AWS_RDS_DBClusterSnapshot = "AWS::RDS::DBClusterSnapshot" + AWS_RDS_EventSubscription = "AWS::RDS::EventSubscription" + AWS_S3_Bucket = "AWS::S3::Bucket" + AWS_S3_AccountPublicAccessBlock = "AWS::S3::AccountPublicAccessBlock" + AWS_Redshift_Cluster = "AWS::Redshift::Cluster" + AWS_Redshift_ClusterSnapshot = "AWS::Redshift::ClusterSnapshot" + AWS_Redshift_ClusterParameterGroup = "AWS::Redshift::ClusterParameterGroup" + AWS_Redshift_ClusterSecurityGroup = "AWS::Redshift::ClusterSecurityGroup" + AWS_Redshift_ClusterSubnetGroup = "AWS::Redshift::ClusterSubnetGroup" + AWS_Redshift_EventSubscription = "AWS::Redshift::EventSubscription" + AWS_SSM_ManagedInstanceInventory = "AWS::SSM::ManagedInstanceInventory" + AWS_CloudWatch_Alarm = "AWS::CloudWatch::Alarm" + AWS_CloudFormation_Stack = "AWS::CloudFormation::Stack" + AWS_ElasticLoadBalancing_LoadBalancer = "AWS::ElasticLoadBalancing::LoadBalancer" + AWS_AutoScaling_AutoScalingGroup = "AWS::AutoScaling::AutoScalingGroup" + AWS_AutoScaling_LaunchConfiguration = "AWS::AutoScaling::LaunchConfiguration" + AWS_AutoScaling_ScalingPolicy = "AWS::AutoScaling::ScalingPolicy" + AWS_AutoScaling_ScheduledAction = "AWS::AutoScaling::ScheduledAction" + AWS_DynamoDB_Table = "AWS::DynamoDB::Table" + AWS_CodeBuild_Project = "AWS::CodeBuild::Project" + AWS_WAF_RateBasedRule = "AWS::WAF::RateBasedRule" + AWS_WAF_Rule = "AWS::WAF::Rule" + AWS_WAF_RuleGroup = "AWS::WAF::RuleGroup" + AWS_WAF_WebACL = "AWS::WAF::WebACL" + AWS_WAFRegional_RateBasedRule = "AWS::WAFRegional::RateBasedRule" + AWS_WAFRegional_Rule = "AWS::WAFRegional::Rule" + AWS_WAFRegional_RuleGroup = "AWS::WAFRegional::RuleGroup" + AWS_WAFRegional_WebACL = "AWS::WAFRegional::WebACL" + AWS_CloudFront_Distribution = "AWS::CloudFront::Distribution" + AWS_CloudFront_StreamingDistribution = "AWS::CloudFront::StreamingDistribution" + AWS_Lambda_Function = "AWS::Lambda::Function" + AWS_NetworkFirewall_Firewall = "AWS::NetworkFirewall::Firewall" + AWS_NetworkFirewall_FirewallPolicy = "AWS::NetworkFirewall::FirewallPolicy" + AWS_NetworkFirewall_RuleGroup = "AWS::NetworkFirewall::RuleGroup" + AWS_ElasticBeanstalk_Application = "AWS::ElasticBeanstalk::Application" + AWS_ElasticBeanstalk_ApplicationVersion = "AWS::ElasticBeanstalk::ApplicationVersion" + AWS_ElasticBeanstalk_Environment = "AWS::ElasticBeanstalk::Environment" + AWS_WAFv2_WebACL = "AWS::WAFv2::WebACL" + AWS_WAFv2_RuleGroup = "AWS::WAFv2::RuleGroup" + AWS_WAFv2_IPSet = "AWS::WAFv2::IPSet" + AWS_WAFv2_RegexPatternSet = "AWS::WAFv2::RegexPatternSet" + AWS_WAFv2_ManagedRuleSet = "AWS::WAFv2::ManagedRuleSet" + AWS_XRay_EncryptionConfig = "AWS::XRay::EncryptionConfig" + AWS_SSM_AssociationCompliance = "AWS::SSM::AssociationCompliance" + AWS_SSM_PatchCompliance = "AWS::SSM::PatchCompliance" + AWS_Shield_Protection = "AWS::Shield::Protection" + AWS_ShieldRegional_Protection = "AWS::ShieldRegional::Protection" + AWS_Config_ConformancePackCompliance = "AWS::Config::ConformancePackCompliance" + AWS_Config_ResourceCompliance = "AWS::Config::ResourceCompliance" + AWS_ApiGateway_Stage = "AWS::ApiGateway::Stage" + AWS_ApiGateway_RestApi = "AWS::ApiGateway::RestApi" + AWS_ApiGatewayV2_Stage = "AWS::ApiGatewayV2::Stage" + AWS_ApiGatewayV2_Api = "AWS::ApiGatewayV2::Api" + AWS_CodePipeline_Pipeline = "AWS::CodePipeline::Pipeline" + AWS_ServiceCatalog_CloudFormationProvisionedProduct = ( + "AWS::ServiceCatalog::CloudFormationProvisionedProduct" + ) + AWS_ServiceCatalog_CloudFormationProduct = "AWS::ServiceCatalog::CloudFormationProduct" + AWS_ServiceCatalog_Portfolio = "AWS::ServiceCatalog::Portfolio" + AWS_SQS_Queue = "AWS::SQS::Queue" + AWS_KMS_Key = "AWS::KMS::Key" + AWS_QLDB_Ledger = "AWS::QLDB::Ledger" + AWS_SecretsManager_Secret = "AWS::SecretsManager::Secret" + AWS_SNS_Topic = "AWS::SNS::Topic" + AWS_SSM_FileData = "AWS::SSM::FileData" + AWS_Backup_BackupPlan = "AWS::Backup::BackupPlan" + AWS_Backup_BackupSelection = "AWS::Backup::BackupSelection" + AWS_Backup_BackupVault = "AWS::Backup::BackupVault" + AWS_Backup_RecoveryPoint = "AWS::Backup::RecoveryPoint" + AWS_ECR_Repository = "AWS::ECR::Repository" + AWS_ECS_Cluster = "AWS::ECS::Cluster" + AWS_ECS_Service = "AWS::ECS::Service" + AWS_ECS_TaskDefinition = "AWS::ECS::TaskDefinition" + AWS_EFS_AccessPoint = "AWS::EFS::AccessPoint" + AWS_EFS_FileSystem = "AWS::EFS::FileSystem" + AWS_EKS_Cluster = "AWS::EKS::Cluster" + AWS_OpenSearch_Domain = "AWS::OpenSearch::Domain" + AWS_EC2_TransitGateway = "AWS::EC2::TransitGateway" + AWS_Kinesis_Stream = "AWS::Kinesis::Stream" + AWS_Kinesis_StreamConsumer = "AWS::Kinesis::StreamConsumer" + AWS_CodeDeploy_Application = "AWS::CodeDeploy::Application" + AWS_CodeDeploy_DeploymentConfig = "AWS::CodeDeploy::DeploymentConfig" + AWS_CodeDeploy_DeploymentGroup = "AWS::CodeDeploy::DeploymentGroup" + AWS_EC2_LaunchTemplate = "AWS::EC2::LaunchTemplate" + AWS_ECR_PublicRepository = "AWS::ECR::PublicRepository" + AWS_GuardDuty_Detector = "AWS::GuardDuty::Detector" + AWS_EMR_SecurityConfiguration = "AWS::EMR::SecurityConfiguration" + AWS_SageMaker_CodeRepository = "AWS::SageMaker::CodeRepository" + AWS_Route53Resolver_ResolverEndpoint = "AWS::Route53Resolver::ResolverEndpoint" + AWS_Route53Resolver_ResolverRule = "AWS::Route53Resolver::ResolverRule" + AWS_Route53Resolver_ResolverRuleAssociation = "AWS::Route53Resolver::ResolverRuleAssociation" + AWS_DMS_ReplicationSubnetGroup = "AWS::DMS::ReplicationSubnetGroup" + AWS_DMS_EventSubscription = "AWS::DMS::EventSubscription" + AWS_MSK_Cluster = "AWS::MSK::Cluster" + AWS_StepFunctions_Activity = "AWS::StepFunctions::Activity" + AWS_WorkSpaces_Workspace = "AWS::WorkSpaces::Workspace" + AWS_WorkSpaces_ConnectionAlias = "AWS::WorkSpaces::ConnectionAlias" + AWS_SageMaker_Model = "AWS::SageMaker::Model" + AWS_ElasticLoadBalancingV2_Listener = "AWS::ElasticLoadBalancingV2::Listener" + AWS_StepFunctions_StateMachine = "AWS::StepFunctions::StateMachine" + AWS_Batch_JobQueue = "AWS::Batch::JobQueue" + AWS_Batch_ComputeEnvironment = "AWS::Batch::ComputeEnvironment" + AWS_AccessAnalyzer_Analyzer = "AWS::AccessAnalyzer::Analyzer" + AWS_Athena_WorkGroup = "AWS::Athena::WorkGroup" + AWS_Athena_DataCatalog = "AWS::Athena::DataCatalog" + AWS_Detective_Graph = "AWS::Detective::Graph" + AWS_GlobalAccelerator_Accelerator = "AWS::GlobalAccelerator::Accelerator" + AWS_GlobalAccelerator_EndpointGroup = "AWS::GlobalAccelerator::EndpointGroup" + AWS_GlobalAccelerator_Listener = "AWS::GlobalAccelerator::Listener" + AWS_EC2_TransitGatewayAttachment = "AWS::EC2::TransitGatewayAttachment" + AWS_EC2_TransitGatewayRouteTable = "AWS::EC2::TransitGatewayRouteTable" + AWS_DMS_Certificate = "AWS::DMS::Certificate" + AWS_AppConfig_Application = "AWS::AppConfig::Application" + AWS_AppSync_GraphQLApi = "AWS::AppSync::GraphQLApi" + AWS_DataSync_LocationSMB = "AWS::DataSync::LocationSMB" + AWS_DataSync_LocationFSxLustre = "AWS::DataSync::LocationFSxLustre" + AWS_DataSync_LocationS3 = "AWS::DataSync::LocationS3" + AWS_DataSync_LocationEFS = "AWS::DataSync::LocationEFS" + AWS_DataSync_Task = "AWS::DataSync::Task" + AWS_DataSync_LocationNFS = "AWS::DataSync::LocationNFS" + AWS_EC2_NetworkInsightsAccessScopeAnalysis = "AWS::EC2::NetworkInsightsAccessScopeAnalysis" + AWS_EKS_FargateProfile = "AWS::EKS::FargateProfile" + AWS_Glue_Job = "AWS::Glue::Job" + AWS_GuardDuty_ThreatIntelSet = "AWS::GuardDuty::ThreatIntelSet" + AWS_GuardDuty_IPSet = "AWS::GuardDuty::IPSet" + AWS_SageMaker_Workteam = "AWS::SageMaker::Workteam" + AWS_SageMaker_NotebookInstanceLifecycleConfig = ( + "AWS::SageMaker::NotebookInstanceLifecycleConfig" + ) + AWS_ServiceDiscovery_Service = "AWS::ServiceDiscovery::Service" + AWS_ServiceDiscovery_PublicDnsNamespace = "AWS::ServiceDiscovery::PublicDnsNamespace" + AWS_SES_ContactList = "AWS::SES::ContactList" + AWS_SES_ConfigurationSet = "AWS::SES::ConfigurationSet" + AWS_Route53_HostedZone = "AWS::Route53::HostedZone" + AWS_IoTEvents_Input = "AWS::IoTEvents::Input" + AWS_IoTEvents_DetectorModel = "AWS::IoTEvents::DetectorModel" + AWS_IoTEvents_AlarmModel = "AWS::IoTEvents::AlarmModel" + AWS_ServiceDiscovery_HttpNamespace = "AWS::ServiceDiscovery::HttpNamespace" + AWS_Events_EventBus = "AWS::Events::EventBus" + AWS_ImageBuilder_ContainerRecipe = "AWS::ImageBuilder::ContainerRecipe" + AWS_ImageBuilder_DistributionConfiguration = "AWS::ImageBuilder::DistributionConfiguration" + AWS_ImageBuilder_InfrastructureConfiguration = "AWS::ImageBuilder::InfrastructureConfiguration" + AWS_DataSync_LocationObjectStorage = "AWS::DataSync::LocationObjectStorage" + AWS_DataSync_LocationHDFS = "AWS::DataSync::LocationHDFS" + AWS_Glue_Classifier = "AWS::Glue::Classifier" + AWS_Route53RecoveryReadiness_Cell = "AWS::Route53RecoveryReadiness::Cell" + AWS_Route53RecoveryReadiness_ReadinessCheck = "AWS::Route53RecoveryReadiness::ReadinessCheck" + AWS_ECR_RegistryPolicy = "AWS::ECR::RegistryPolicy" + AWS_Backup_ReportPlan = "AWS::Backup::ReportPlan" + AWS_Lightsail_Certificate = "AWS::Lightsail::Certificate" + AWS_RUM_AppMonitor = "AWS::RUM::AppMonitor" + AWS_Events_Endpoint = "AWS::Events::Endpoint" + AWS_SES_ReceiptRuleSet = "AWS::SES::ReceiptRuleSet" + AWS_Events_Archive = "AWS::Events::Archive" + AWS_Events_ApiDestination = "AWS::Events::ApiDestination" + AWS_Lightsail_Disk = "AWS::Lightsail::Disk" + AWS_FIS_ExperimentTemplate = "AWS::FIS::ExperimentTemplate" + AWS_DataSync_LocationFSxWindows = "AWS::DataSync::LocationFSxWindows" + AWS_SES_ReceiptFilter = "AWS::SES::ReceiptFilter" + AWS_GuardDuty_Filter = "AWS::GuardDuty::Filter" + AWS_SES_Template = "AWS::SES::Template" + AWS_AmazonMQ_Broker = "AWS::AmazonMQ::Broker" + AWS_AppConfig_Environment = "AWS::AppConfig::Environment" + AWS_AppConfig_ConfigurationProfile = "AWS::AppConfig::ConfigurationProfile" + AWS_Cloud9_EnvironmentEC2 = "AWS::Cloud9::EnvironmentEC2" + AWS_EventSchemas_Registry = "AWS::EventSchemas::Registry" + AWS_EventSchemas_RegistryPolicy = "AWS::EventSchemas::RegistryPolicy" + AWS_EventSchemas_Discoverer = "AWS::EventSchemas::Discoverer" + AWS_FraudDetector_Label = "AWS::FraudDetector::Label" + AWS_FraudDetector_EntityType = "AWS::FraudDetector::EntityType" + AWS_FraudDetector_Variable = "AWS::FraudDetector::Variable" + AWS_FraudDetector_Outcome = "AWS::FraudDetector::Outcome" + AWS_IoT_Authorizer = "AWS::IoT::Authorizer" + AWS_IoT_SecurityProfile = "AWS::IoT::SecurityProfile" + AWS_IoT_RoleAlias = "AWS::IoT::RoleAlias" + AWS_IoT_Dimension = "AWS::IoT::Dimension" + AWS_IoTAnalytics_Datastore = "AWS::IoTAnalytics::Datastore" + AWS_Lightsail_Bucket = "AWS::Lightsail::Bucket" + AWS_Lightsail_StaticIp = "AWS::Lightsail::StaticIp" + AWS_MediaPackage_PackagingGroup = "AWS::MediaPackage::PackagingGroup" + AWS_Route53RecoveryReadiness_RecoveryGroup = "AWS::Route53RecoveryReadiness::RecoveryGroup" + AWS_ResilienceHub_ResiliencyPolicy = "AWS::ResilienceHub::ResiliencyPolicy" + AWS_Transfer_Workflow = "AWS::Transfer::Workflow" + AWS_EKS_IdentityProviderConfig = "AWS::EKS::IdentityProviderConfig" + AWS_EKS_Addon = "AWS::EKS::Addon" + AWS_Glue_MLTransform = "AWS::Glue::MLTransform" + AWS_IoT_Policy = "AWS::IoT::Policy" + AWS_IoT_MitigationAction = "AWS::IoT::MitigationAction" + AWS_IoTTwinMaker_Workspace = "AWS::IoTTwinMaker::Workspace" + AWS_IoTTwinMaker_Entity = "AWS::IoTTwinMaker::Entity" + AWS_IoTAnalytics_Dataset = "AWS::IoTAnalytics::Dataset" + AWS_IoTAnalytics_Pipeline = "AWS::IoTAnalytics::Pipeline" + AWS_IoTAnalytics_Channel = "AWS::IoTAnalytics::Channel" + AWS_IoTSiteWise_Dashboard = "AWS::IoTSiteWise::Dashboard" + AWS_IoTSiteWise_Project = "AWS::IoTSiteWise::Project" + AWS_IoTSiteWise_Portal = "AWS::IoTSiteWise::Portal" + AWS_IoTSiteWise_AssetModel = "AWS::IoTSiteWise::AssetModel" + AWS_IVS_Channel = "AWS::IVS::Channel" + AWS_IVS_RecordingConfiguration = "AWS::IVS::RecordingConfiguration" + AWS_IVS_PlaybackKeyPair = "AWS::IVS::PlaybackKeyPair" + AWS_KinesisAnalyticsV2_Application = "AWS::KinesisAnalyticsV2::Application" + AWS_RDS_GlobalCluster = "AWS::RDS::GlobalCluster" + AWS_S3_MultiRegionAccessPoint = "AWS::S3::MultiRegionAccessPoint" + AWS_DeviceFarm_TestGridProject = "AWS::DeviceFarm::TestGridProject" + AWS_Budgets_BudgetsAction = "AWS::Budgets::BudgetsAction" + AWS_Lex_Bot = "AWS::Lex::Bot" + AWS_CodeGuruReviewer_RepositoryAssociation = "AWS::CodeGuruReviewer::RepositoryAssociation" + AWS_IoT_CustomMetric = "AWS::IoT::CustomMetric" + AWS_Route53Resolver_FirewallDomainList = "AWS::Route53Resolver::FirewallDomainList" + AWS_RoboMaker_RobotApplicationVersion = "AWS::RoboMaker::RobotApplicationVersion" + AWS_EC2_TrafficMirrorSession = "AWS::EC2::TrafficMirrorSession" + AWS_IoTSiteWise_Gateway = "AWS::IoTSiteWise::Gateway" + AWS_Lex_BotAlias = "AWS::Lex::BotAlias" + AWS_LookoutMetrics_Alert = "AWS::LookoutMetrics::Alert" + AWS_IoT_AccountAuditConfiguration = "AWS::IoT::AccountAuditConfiguration" + AWS_EC2_TrafficMirrorTarget = "AWS::EC2::TrafficMirrorTarget" + AWS_S3_StorageLens = "AWS::S3::StorageLens" + AWS_IoT_ScheduledAudit = "AWS::IoT::ScheduledAudit" + AWS_Events_Connection = "AWS::Events::Connection" + AWS_EventSchemas_Schema = "AWS::EventSchemas::Schema" + AWS_MediaPackage_PackagingConfiguration = "AWS::MediaPackage::PackagingConfiguration" + AWS_KinesisVideo_SignalingChannel = "AWS::KinesisVideo::SignalingChannel" + AWS_AppStream_DirectoryConfig = "AWS::AppStream::DirectoryConfig" + AWS_LookoutVision_Project = "AWS::LookoutVision::Project" + AWS_Route53RecoveryControl_Cluster = "AWS::Route53RecoveryControl::Cluster" + AWS_Route53RecoveryControl_SafetyRule = "AWS::Route53RecoveryControl::SafetyRule" + AWS_Route53RecoveryControl_ControlPanel = "AWS::Route53RecoveryControl::ControlPanel" + AWS_Route53RecoveryControl_RoutingControl = "AWS::Route53RecoveryControl::RoutingControl" + AWS_Route53RecoveryReadiness_ResourceSet = "AWS::Route53RecoveryReadiness::ResourceSet" + AWS_RoboMaker_SimulationApplication = "AWS::RoboMaker::SimulationApplication" + AWS_RoboMaker_RobotApplication = "AWS::RoboMaker::RobotApplication" + AWS_HealthLake_FHIRDatastore = "AWS::HealthLake::FHIRDatastore" + AWS_Pinpoint_Segment = "AWS::Pinpoint::Segment" + AWS_Pinpoint_ApplicationSettings = "AWS::Pinpoint::ApplicationSettings" + AWS_Events_Rule = "AWS::Events::Rule" + AWS_EC2_DHCPOptions = "AWS::EC2::DHCPOptions" + AWS_EC2_NetworkInsightsPath = "AWS::EC2::NetworkInsightsPath" + AWS_EC2_TrafficMirrorFilter = "AWS::EC2::TrafficMirrorFilter" + AWS_EC2_IPAM = "AWS::EC2::IPAM" + AWS_IoTTwinMaker_Scene = "AWS::IoTTwinMaker::Scene" + AWS_NetworkManager_TransitGatewayRegistration = ( + "AWS::NetworkManager::TransitGatewayRegistration" + ) + AWS_CustomerProfiles_Domain = "AWS::CustomerProfiles::Domain" + AWS_AutoScaling_WarmPool = "AWS::AutoScaling::WarmPool" + AWS_Connect_PhoneNumber = "AWS::Connect::PhoneNumber" + AWS_AppConfig_DeploymentStrategy = "AWS::AppConfig::DeploymentStrategy" + AWS_AppFlow_Flow = "AWS::AppFlow::Flow" + AWS_AuditManager_Assessment = "AWS::AuditManager::Assessment" + AWS_CloudWatch_MetricStream = "AWS::CloudWatch::MetricStream" + AWS_DeviceFarm_InstanceProfile = "AWS::DeviceFarm::InstanceProfile" + AWS_DeviceFarm_Project = "AWS::DeviceFarm::Project" + AWS_EC2_EC2Fleet = "AWS::EC2::EC2Fleet" + AWS_EC2_SubnetRouteTableAssociation = "AWS::EC2::SubnetRouteTableAssociation" + AWS_ECR_PullThroughCacheRule = "AWS::ECR::PullThroughCacheRule" + AWS_GroundStation_Config = "AWS::GroundStation::Config" + AWS_ImageBuilder_ImagePipeline = "AWS::ImageBuilder::ImagePipeline" + AWS_IoT_FleetMetric = "AWS::IoT::FleetMetric" + AWS_IoTWireless_ServiceProfile = "AWS::IoTWireless::ServiceProfile" + AWS_NetworkManager_Device = "AWS::NetworkManager::Device" + AWS_NetworkManager_GlobalNetwork = "AWS::NetworkManager::GlobalNetwork" + AWS_NetworkManager_Link = "AWS::NetworkManager::Link" + AWS_NetworkManager_Site = "AWS::NetworkManager::Site" + AWS_Panorama_Package = "AWS::Panorama::Package" + AWS_Pinpoint_App = "AWS::Pinpoint::App" + AWS_Redshift_ScheduledAction = "AWS::Redshift::ScheduledAction" + AWS_Route53Resolver_FirewallRuleGroupAssociation = ( + "AWS::Route53Resolver::FirewallRuleGroupAssociation" + ) + AWS_SageMaker_AppImageConfig = "AWS::SageMaker::AppImageConfig" + AWS_SageMaker_Image = "AWS::SageMaker::Image" + AWS_ECS_TaskSet = "AWS::ECS::TaskSet" + AWS_Cassandra_Keyspace = "AWS::Cassandra::Keyspace" + AWS_Signer_SigningProfile = "AWS::Signer::SigningProfile" + AWS_Amplify_App = "AWS::Amplify::App" + AWS_AppMesh_VirtualNode = "AWS::AppMesh::VirtualNode" + AWS_AppMesh_VirtualService = "AWS::AppMesh::VirtualService" + AWS_AppRunner_VpcConnector = "AWS::AppRunner::VpcConnector" + AWS_AppStream_Application = "AWS::AppStream::Application" + AWS_CodeArtifact_Repository = "AWS::CodeArtifact::Repository" + AWS_EC2_PrefixList = "AWS::EC2::PrefixList" + AWS_EC2_SpotFleet = "AWS::EC2::SpotFleet" + AWS_Evidently_Project = "AWS::Evidently::Project" + AWS_Forecast_Dataset = "AWS::Forecast::Dataset" + AWS_IAM_SAMLProvider = "AWS::IAM::SAMLProvider" + AWS_IAM_ServerCertificate = "AWS::IAM::ServerCertificate" + AWS_Pinpoint_Campaign = "AWS::Pinpoint::Campaign" + AWS_Pinpoint_InAppTemplate = "AWS::Pinpoint::InAppTemplate" + AWS_SageMaker_Domain = "AWS::SageMaker::Domain" + AWS_Transfer_Agreement = "AWS::Transfer::Agreement" + AWS_Transfer_Connector = "AWS::Transfer::Connector" + AWS_KinesisFirehose_DeliveryStream = "AWS::KinesisFirehose::DeliveryStream" + AWS_Amplify_Branch = "AWS::Amplify::Branch" + AWS_AppIntegrations_EventIntegration = "AWS::AppIntegrations::EventIntegration" + AWS_AppMesh_Route = "AWS::AppMesh::Route" + AWS_Athena_PreparedStatement = "AWS::Athena::PreparedStatement" + AWS_EC2_IPAMScope = "AWS::EC2::IPAMScope" + AWS_Evidently_Launch = "AWS::Evidently::Launch" + AWS_Forecast_DatasetGroup = "AWS::Forecast::DatasetGroup" + AWS_GreengrassV2_ComponentVersion = "AWS::GreengrassV2::ComponentVersion" + AWS_GroundStation_MissionProfile = "AWS::GroundStation::MissionProfile" + AWS_MediaConnect_FlowEntitlement = "AWS::MediaConnect::FlowEntitlement" + AWS_MediaConnect_FlowVpcInterface = "AWS::MediaConnect::FlowVpcInterface" + AWS_MediaTailor_PlaybackConfiguration = "AWS::MediaTailor::PlaybackConfiguration" + AWS_MSK_Configuration = "AWS::MSK::Configuration" + AWS_Personalize_Dataset = "AWS::Personalize::Dataset" + AWS_Personalize_Schema = "AWS::Personalize::Schema" + AWS_Personalize_Solution = "AWS::Personalize::Solution" + AWS_Pinpoint_EmailTemplate = "AWS::Pinpoint::EmailTemplate" + AWS_Pinpoint_EventStream = "AWS::Pinpoint::EventStream" + AWS_ResilienceHub_App = "AWS::ResilienceHub::App" + AWS_ACMPCA_CertificateAuthority = "AWS::ACMPCA::CertificateAuthority" + AWS_AppConfig_HostedConfigurationVersion = "AWS::AppConfig::HostedConfigurationVersion" + AWS_AppMesh_VirtualGateway = "AWS::AppMesh::VirtualGateway" + AWS_AppMesh_VirtualRouter = "AWS::AppMesh::VirtualRouter" + AWS_AppRunner_Service = "AWS::AppRunner::Service" + AWS_CustomerProfiles_ObjectType = "AWS::CustomerProfiles::ObjectType" + AWS_DMS_Endpoint = "AWS::DMS::Endpoint" + AWS_EC2_CapacityReservation = "AWS::EC2::CapacityReservation" + AWS_EC2_ClientVpnEndpoint = "AWS::EC2::ClientVpnEndpoint" + AWS_Kendra_Index = "AWS::Kendra::Index" + AWS_KinesisVideo_Stream = "AWS::KinesisVideo::Stream" + AWS_Logs_Destination = "AWS::Logs::Destination" + AWS_Pinpoint_EmailChannel = "AWS::Pinpoint::EmailChannel" + AWS_S3_AccessPoint = "AWS::S3::AccessPoint" + AWS_NetworkManager_CustomerGatewayAssociation = ( + "AWS::NetworkManager::CustomerGatewayAssociation" + ) + AWS_NetworkManager_LinkAssociation = "AWS::NetworkManager::LinkAssociation" + AWS_IoTWireless_MulticastGroup = "AWS::IoTWireless::MulticastGroup" + AWS_Personalize_DatasetGroup = "AWS::Personalize::DatasetGroup" + AWS_IoTTwinMaker_ComponentType = "AWS::IoTTwinMaker::ComponentType" + AWS_CodeBuild_ReportGroup = "AWS::CodeBuild::ReportGroup" + AWS_SageMaker_FeatureGroup = "AWS::SageMaker::FeatureGroup" + AWS_MSK_BatchScramSecret = "AWS::MSK::BatchScramSecret" + AWS_AppStream_Stack = "AWS::AppStream::Stack" + AWS_IoT_JobTemplate = "AWS::IoT::JobTemplate" + AWS_IoTWireless_FuotaTask = "AWS::IoTWireless::FuotaTask" + AWS_IoT_ProvisioningTemplate = "AWS::IoT::ProvisioningTemplate" + AWS_InspectorV2_Filter = "AWS::InspectorV2::Filter" + AWS_Route53Resolver_ResolverQueryLoggingConfigAssociation = ( + "AWS::Route53Resolver::ResolverQueryLoggingConfigAssociation" + ) + AWS_ServiceDiscovery_Instance = "AWS::ServiceDiscovery::Instance" + AWS_Transfer_Certificate = "AWS::Transfer::Certificate" + AWS_MediaConnect_FlowSource = "AWS::MediaConnect::FlowSource" + AWS_APS_RuleGroupsNamespace = "AWS::APS::RuleGroupsNamespace" + AWS_CodeGuruProfiler_ProfilingGroup = "AWS::CodeGuruProfiler::ProfilingGroup" + AWS_Route53Resolver_ResolverQueryLoggingConfig = ( + "AWS::Route53Resolver::ResolverQueryLoggingConfig" + ) + AWS_Batch_SchedulingPolicy = "AWS::Batch::SchedulingPolicy" + AWS_ACMPCA_CertificateAuthorityActivation = "AWS::ACMPCA::CertificateAuthorityActivation" + AWS_AppMesh_GatewayRoute = "AWS::AppMesh::GatewayRoute" + AWS_AppMesh_Mesh = "AWS::AppMesh::Mesh" + AWS_Connect_Instance = "AWS::Connect::Instance" + AWS_Connect_QuickConnect = "AWS::Connect::QuickConnect" + AWS_EC2_CarrierGateway = "AWS::EC2::CarrierGateway" + AWS_EC2_IPAMPool = "AWS::EC2::IPAMPool" + AWS_EC2_TransitGatewayConnect = "AWS::EC2::TransitGatewayConnect" + AWS_EC2_TransitGatewayMulticastDomain = "AWS::EC2::TransitGatewayMulticastDomain" + AWS_ECS_CapacityProvider = "AWS::ECS::CapacityProvider" + AWS_IAM_InstanceProfile = "AWS::IAM::InstanceProfile" + AWS_IoT_CACertificate = "AWS::IoT::CACertificate" + AWS_IoTTwinMaker_SyncJob = "AWS::IoTTwinMaker::SyncJob" + AWS_KafkaConnect_Connector = "AWS::KafkaConnect::Connector" + AWS_Lambda_CodeSigningConfig = "AWS::Lambda::CodeSigningConfig" + AWS_NetworkManager_ConnectPeer = "AWS::NetworkManager::ConnectPeer" + AWS_ResourceExplorer2_Index = "AWS::ResourceExplorer2::Index" + AWS_AppStream_Fleet = "AWS::AppStream::Fleet" + AWS_Cognito_UserPool = "AWS::Cognito::UserPool" + AWS_Cognito_UserPoolClient = "AWS::Cognito::UserPoolClient" + AWS_Cognito_UserPoolGroup = "AWS::Cognito::UserPoolGroup" + AWS_EC2_NetworkInsightsAccessScope = "AWS::EC2::NetworkInsightsAccessScope" + AWS_EC2_NetworkInsightsAnalysis = "AWS::EC2::NetworkInsightsAnalysis" + AWS_Grafana_Workspace = "AWS::Grafana::Workspace" + AWS_GroundStation_DataflowEndpointGroup = "AWS::GroundStation::DataflowEndpointGroup" + AWS_ImageBuilder_ImageRecipe = "AWS::ImageBuilder::ImageRecipe" + AWS_KMS_Alias = "AWS::KMS::Alias" + AWS_M2_Environment = "AWS::M2::Environment" + AWS_QuickSight_DataSource = "AWS::QuickSight::DataSource" + AWS_QuickSight_Template = "AWS::QuickSight::Template" + AWS_QuickSight_Theme = "AWS::QuickSight::Theme" + AWS_RDS_OptionGroup = "AWS::RDS::OptionGroup" + AWS_Redshift_EndpointAccess = "AWS::Redshift::EndpointAccess" + AWS_Route53Resolver_FirewallRuleGroup = "AWS::Route53Resolver::FirewallRuleGroup" + AWS_SSM_Document = "AWS::SSM::Document" + AWS_AppConfig_ExtensionAssociation = "AWS::AppConfig::ExtensionAssociation" + AWS_AppIntegrations_Application = "AWS::AppIntegrations::Application" + AWS_AppSync_ApiCache = "AWS::AppSync::ApiCache" + AWS_Bedrock_Guardrail = "AWS::Bedrock::Guardrail" + AWS_Bedrock_KnowledgeBase = "AWS::Bedrock::KnowledgeBase" + AWS_Cognito_IdentityPool = "AWS::Cognito::IdentityPool" + AWS_Connect_Rule = "AWS::Connect::Rule" + AWS_Connect_User = "AWS::Connect::User" + AWS_EC2_ClientVpnTargetNetworkAssociation = "AWS::EC2::ClientVpnTargetNetworkAssociation" + AWS_EC2_EIPAssociation = "AWS::EC2::EIPAssociation" + AWS_EC2_IPAMResourceDiscovery = "AWS::EC2::IPAMResourceDiscovery" + AWS_EC2_IPAMResourceDiscoveryAssociation = "AWS::EC2::IPAMResourceDiscoveryAssociation" + AWS_EC2_InstanceConnectEndpoint = "AWS::EC2::InstanceConnectEndpoint" + AWS_EC2_SnapshotBlockPublicAccess = "AWS::EC2::SnapshotBlockPublicAccess" + AWS_EC2_VPCBlockPublicAccessExclusion = "AWS::EC2::VPCBlockPublicAccessExclusion" + AWS_EC2_VPCBlockPublicAccessOptions = "AWS::EC2::VPCBlockPublicAccessOptions" + AWS_EC2_VPCEndpointConnectionNotification = "AWS::EC2::VPCEndpointConnectionNotification" + AWS_EC2_VPNConnectionRoute = "AWS::EC2::VPNConnectionRoute" + AWS_Evidently_Segment = "AWS::Evidently::Segment" + AWS_IAM_OIDCProvider = "AWS::IAM::OIDCProvider" + AWS_InspectorV2_Activation = "AWS::InspectorV2::Activation" + AWS_MSK_ClusterPolicy = "AWS::MSK::ClusterPolicy" + AWS_MSK_VpcConnection = "AWS::MSK::VpcConnection" + AWS_MediaConnect_Gateway = "AWS::MediaConnect::Gateway" + AWS_MemoryDB_SubnetGroup = "AWS::MemoryDB::SubnetGroup" + AWS_OpenSearchServerless_Collection = "AWS::OpenSearchServerless::Collection" + AWS_OpenSearchServerless_VpcEndpoint = "AWS::OpenSearchServerless::VpcEndpoint" + AWS_Redshift_EndpointAuthorization = "AWS::Redshift::EndpointAuthorization" + AWS_Route53Profiles_Profile = "AWS::Route53Profiles::Profile" + AWS_S3_StorageLensGroup = "AWS::S3::StorageLensGroup" + AWS_S3Express_BucketPolicy = "AWS::S3Express::BucketPolicy" + AWS_S3Express_DirectoryBucket = "AWS::S3Express::DirectoryBucket" + AWS_SageMaker_InferenceExperiment = "AWS::SageMaker::InferenceExperiment" + AWS_SecurityHub_Standard = "AWS::SecurityHub::Standard" + AWS_Transfer_Profile = "AWS::Transfer::Profile" + + +class ResourceValueType(StrEnum): + RESOURCE_ID = "RESOURCE_ID" + + +class SortBy(StrEnum): + SCORE = "SCORE" + + +class SortOrder(StrEnum): + ASCENDING = "ASCENDING" + DESCENDING = "DESCENDING" + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class ConformancePackTemplateValidationException(ServiceException): + code: str = "ConformancePackTemplateValidationException" + sender_fault: bool = False + status_code: int = 400 + + +class IdempotentParameterMismatch(ServiceException): + code: str = "IdempotentParameterMismatch" + sender_fault: bool = False + status_code: int = 400 + + +class InsufficientDeliveryPolicyException(ServiceException): + code: str = "InsufficientDeliveryPolicyException" + sender_fault: bool = False + status_code: int = 400 + + +class InsufficientPermissionsException(ServiceException): + code: str = "InsufficientPermissionsException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidConfigurationRecorderNameException(ServiceException): + code: str = "InvalidConfigurationRecorderNameException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDeliveryChannelNameException(ServiceException): + code: str = "InvalidDeliveryChannelNameException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidExpressionException(ServiceException): + code: str = "InvalidExpressionException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidLimitException(ServiceException): + code: str = "InvalidLimitException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidNextTokenException(ServiceException): + code: str = "InvalidNextTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidParameterValueException(ServiceException): + code: str = "InvalidParameterValueException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRecordingGroupException(ServiceException): + code: str = "InvalidRecordingGroupException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidResultTokenException(ServiceException): + code: str = "InvalidResultTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRoleException(ServiceException): + code: str = "InvalidRoleException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidS3KeyPrefixException(ServiceException): + code: str = "InvalidS3KeyPrefixException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidS3KmsKeyArnException(ServiceException): + code: str = "InvalidS3KmsKeyArnException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidSNSTopicARNException(ServiceException): + code: str = "InvalidSNSTopicARNException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTimeRangeException(ServiceException): + code: str = "InvalidTimeRangeException" + sender_fault: bool = False + status_code: int = 400 + + +class LastDeliveryChannelDeleteFailedException(ServiceException): + code: str = "LastDeliveryChannelDeleteFailedException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxActiveResourcesExceededException(ServiceException): + code: str = "MaxActiveResourcesExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxNumberOfConfigRulesExceededException(ServiceException): + code: str = "MaxNumberOfConfigRulesExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxNumberOfConfigurationRecordersExceededException(ServiceException): + code: str = "MaxNumberOfConfigurationRecordersExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxNumberOfConformancePacksExceededException(ServiceException): + code: str = "MaxNumberOfConformancePacksExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxNumberOfDeliveryChannelsExceededException(ServiceException): + code: str = "MaxNumberOfDeliveryChannelsExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxNumberOfOrganizationConfigRulesExceededException(ServiceException): + code: str = "MaxNumberOfOrganizationConfigRulesExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxNumberOfOrganizationConformancePacksExceededException(ServiceException): + code: str = "MaxNumberOfOrganizationConformancePacksExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxNumberOfRetentionConfigurationsExceededException(ServiceException): + code: str = "MaxNumberOfRetentionConfigurationsExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class NoAvailableConfigurationRecorderException(ServiceException): + code: str = "NoAvailableConfigurationRecorderException" + sender_fault: bool = False + status_code: int = 400 + + +class NoAvailableDeliveryChannelException(ServiceException): + code: str = "NoAvailableDeliveryChannelException" + sender_fault: bool = False + status_code: int = 400 + + +class NoAvailableOrganizationException(ServiceException): + code: str = "NoAvailableOrganizationException" + sender_fault: bool = False + status_code: int = 400 + + +class NoRunningConfigurationRecorderException(ServiceException): + code: str = "NoRunningConfigurationRecorderException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchBucketException(ServiceException): + code: str = "NoSuchBucketException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchConfigRuleException(ServiceException): + code: str = "NoSuchConfigRuleException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchConfigRuleInConformancePackException(ServiceException): + code: str = "NoSuchConfigRuleInConformancePackException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchConfigurationAggregatorException(ServiceException): + code: str = "NoSuchConfigurationAggregatorException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchConfigurationRecorderException(ServiceException): + code: str = "NoSuchConfigurationRecorderException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchConformancePackException(ServiceException): + code: str = "NoSuchConformancePackException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchDeliveryChannelException(ServiceException): + code: str = "NoSuchDeliveryChannelException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchOrganizationConfigRuleException(ServiceException): + code: str = "NoSuchOrganizationConfigRuleException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchOrganizationConformancePackException(ServiceException): + code: str = "NoSuchOrganizationConformancePackException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchRemediationConfigurationException(ServiceException): + code: str = "NoSuchRemediationConfigurationException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchRemediationExceptionException(ServiceException): + code: str = "NoSuchRemediationExceptionException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchRetentionConfigurationException(ServiceException): + code: str = "NoSuchRetentionConfigurationException" + sender_fault: bool = False + status_code: int = 400 + + +class OrganizationAccessDeniedException(ServiceException): + code: str = "OrganizationAccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class OrganizationAllFeaturesNotEnabledException(ServiceException): + code: str = "OrganizationAllFeaturesNotEnabledException" + sender_fault: bool = False + status_code: int = 400 + + +class OrganizationConformancePackTemplateValidationException(ServiceException): + code: str = "OrganizationConformancePackTemplateValidationException" + sender_fault: bool = False + status_code: int = 400 + + +class OversizedConfigurationItemException(ServiceException): + code: str = "OversizedConfigurationItemException" + sender_fault: bool = False + status_code: int = 400 + + +class RemediationInProgressException(ServiceException): + code: str = "RemediationInProgressException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceConcurrentModificationException(ServiceException): + code: str = "ResourceConcurrentModificationException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotDiscoveredException(ServiceException): + code: str = "ResourceNotDiscoveredException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTagsException(ServiceException): + code: str = "TooManyTagsException" + sender_fault: bool = False + status_code: int = 400 + + +class UnmodifiableEntityException(ServiceException): + code: str = "UnmodifiableEntityException" + sender_fault: bool = False + status_code: int = 400 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + + +AggregatorRegionList = List[String] +AccountAggregationSourceAccountList = List[AccountId] + + +class AccountAggregationSource(TypedDict, total=False): + AccountIds: AccountAggregationSourceAccountList + AllAwsRegions: Optional[Boolean] + AwsRegions: Optional[AggregatorRegionList] + + +AccountAggregationSourceList = List[AccountAggregationSource] + + +class ComplianceContributorCount(TypedDict, total=False): + CappedCount: Optional[Integer] + CapExceeded: Optional[Boolean] + + +class Compliance(TypedDict, total=False): + ComplianceType: Optional[ComplianceType] + ComplianceContributorCount: Optional[ComplianceContributorCount] + + +class AggregateComplianceByConfigRule(TypedDict, total=False): + ConfigRuleName: Optional[ConfigRuleName] + Compliance: Optional[Compliance] + AccountId: Optional[AccountId] + AwsRegion: Optional[AwsRegion] + + +AggregateComplianceByConfigRuleList = List[AggregateComplianceByConfigRule] + + +class AggregateConformancePackCompliance(TypedDict, total=False): + ComplianceType: Optional[ConformancePackComplianceType] + CompliantRuleCount: Optional[Integer] + NonCompliantRuleCount: Optional[Integer] + TotalRuleCount: Optional[Integer] + + +class AggregateComplianceByConformancePack(TypedDict, total=False): + ConformancePackName: Optional[ConformancePackName] + Compliance: Optional[AggregateConformancePackCompliance] + AccountId: Optional[AccountId] + AwsRegion: Optional[AwsRegion] + + +AggregateComplianceByConformancePackList = List[AggregateComplianceByConformancePack] +Date = datetime + + +class ComplianceSummary(TypedDict, total=False): + CompliantResourceCount: Optional[ComplianceContributorCount] + NonCompliantResourceCount: Optional[ComplianceContributorCount] + ComplianceSummaryTimestamp: Optional[Date] + + +class AggregateComplianceCount(TypedDict, total=False): + GroupName: Optional[StringWithCharLimit256] + ComplianceSummary: Optional[ComplianceSummary] + + +AggregateComplianceCountList = List[AggregateComplianceCount] + + +class AggregateConformancePackComplianceCount(TypedDict, total=False): + CompliantConformancePackCount: Optional[Integer] + NonCompliantConformancePackCount: Optional[Integer] + + +class AggregateConformancePackComplianceFilters(TypedDict, total=False): + ConformancePackName: Optional[ConformancePackName] + ComplianceType: Optional[ConformancePackComplianceType] + AccountId: Optional[AccountId] + AwsRegion: Optional[AwsRegion] + + +class AggregateConformancePackComplianceSummary(TypedDict, total=False): + ComplianceSummary: Optional[AggregateConformancePackComplianceCount] + GroupName: Optional[StringWithCharLimit256] + + +class AggregateConformancePackComplianceSummaryFilters(TypedDict, total=False): + AccountId: Optional[AccountId] + AwsRegion: Optional[AwsRegion] + + +AggregateConformancePackComplianceSummaryList = List[AggregateConformancePackComplianceSummary] + + +class EvaluationResultQualifier(TypedDict, total=False): + ConfigRuleName: Optional[ConfigRuleName] + ResourceType: Optional[StringWithCharLimit256] + ResourceId: Optional[BaseResourceId] + EvaluationMode: Optional[EvaluationMode] + + +class EvaluationResultIdentifier(TypedDict, total=False): + EvaluationResultQualifier: Optional[EvaluationResultQualifier] + OrderingTimestamp: Optional[Date] + ResourceEvaluationId: Optional[ResourceEvaluationId] + + +class AggregateEvaluationResult(TypedDict, total=False): + EvaluationResultIdentifier: Optional[EvaluationResultIdentifier] + ComplianceType: Optional[ComplianceType] + ResultRecordedTime: Optional[Date] + ConfigRuleInvokedTime: Optional[Date] + Annotation: Optional[StringWithCharLimit256] + AccountId: Optional[AccountId] + AwsRegion: Optional[AwsRegion] + + +AggregateEvaluationResultList = List[AggregateEvaluationResult] + + +class AggregateResourceIdentifier(TypedDict, total=False): + SourceAccountId: AccountId + SourceRegion: AwsRegion + ResourceId: ResourceId + ResourceType: ResourceType + ResourceName: Optional[ResourceName] + + +class AggregatedSourceStatus(TypedDict, total=False): + SourceId: Optional[String] + SourceType: Optional[AggregatedSourceType] + AwsRegion: Optional[AwsRegion] + LastUpdateStatus: Optional[AggregatedSourceStatusType] + LastUpdateTime: Optional[Date] + LastErrorCode: Optional[String] + LastErrorMessage: Optional[String] + + +AggregatedSourceStatusList = List[AggregatedSourceStatus] +AggregatedSourceStatusTypeList = List[AggregatedSourceStatusType] + + +class AggregationAuthorization(TypedDict, total=False): + AggregationAuthorizationArn: Optional[String] + AuthorizedAccountId: Optional[AccountId] + AuthorizedAwsRegion: Optional[AwsRegion] + CreationTime: Optional[Date] + + +AggregationAuthorizationList = List[AggregationAuthorization] +ResourceTypeValueList = List[ResourceTypeValue] + + +class AggregatorFilterResourceType(TypedDict, total=False): + Type: Optional[AggregatorFilterType] + Value: Optional[ResourceTypeValueList] + + +ServicePrincipalValueList = List[ServicePrincipalValue] + + +class AggregatorFilterServicePrincipal(TypedDict, total=False): + Type: Optional[AggregatorFilterType] + Value: Optional[ServicePrincipalValueList] + + +class AggregatorFilters(TypedDict, total=False): + ResourceType: Optional[AggregatorFilterResourceType] + ServicePrincipal: Optional[AggregatorFilterServicePrincipal] + + +ResourceTypeList = List[ResourceType] + + +class AssociateResourceTypesRequest(ServiceRequest): + ConfigurationRecorderArn: AmazonResourceName + ResourceTypes: ResourceTypeList + + +RecordingModeResourceTypesList = List[ResourceType] + + +class RecordingModeOverride(TypedDict, total=False): + description: Optional[Description] + resourceTypes: RecordingModeResourceTypesList + recordingFrequency: RecordingFrequency + + +RecordingModeOverrides = List[RecordingModeOverride] + + +class RecordingMode(TypedDict, total=False): + recordingFrequency: RecordingFrequency + recordingModeOverrides: Optional[RecordingModeOverrides] + + +class RecordingStrategy(TypedDict, total=False): + useOnly: Optional[RecordingStrategyType] + + +class ExclusionByResourceTypes(TypedDict, total=False): + resourceTypes: Optional[ResourceTypeList] + + +class RecordingGroup(TypedDict, total=False): + allSupported: Optional[AllSupported] + includeGlobalResourceTypes: Optional[IncludeGlobalResourceTypes] + resourceTypes: Optional[ResourceTypeList] + exclusionByResourceTypes: Optional[ExclusionByResourceTypes] + recordingStrategy: Optional[RecordingStrategy] + + +class ConfigurationRecorder(TypedDict, total=False): + arn: Optional[AmazonResourceName] + name: Optional[RecorderName] + roleARN: Optional[String] + recordingGroup: Optional[RecordingGroup] + recordingMode: Optional[RecordingMode] + recordingScope: Optional[RecordingScope] + servicePrincipal: Optional[ServicePrincipal] + + +class AssociateResourceTypesResponse(TypedDict, total=False): + ConfigurationRecorder: ConfigurationRecorder + + +AutoRemediationAttemptSeconds = int +ConfigurationItemDeliveryTime = datetime +SupplementaryConfiguration = Dict[SupplementaryConfigurationName, SupplementaryConfigurationValue] +ResourceCreationTime = datetime +ConfigurationItemCaptureTime = datetime + + +class BaseConfigurationItem(TypedDict, total=False): + version: Optional[Version] + accountId: Optional[AccountId] + configurationItemCaptureTime: Optional[ConfigurationItemCaptureTime] + configurationItemStatus: Optional[ConfigurationItemStatus] + configurationStateId: Optional[ConfigurationStateId] + arn: Optional[ARN] + resourceType: Optional[ResourceType] + resourceId: Optional[ResourceId] + resourceName: Optional[ResourceName] + awsRegion: Optional[AwsRegion] + availabilityZone: Optional[AvailabilityZone] + resourceCreationTime: Optional[ResourceCreationTime] + configuration: Optional[Configuration] + supplementaryConfiguration: Optional[SupplementaryConfiguration] + recordingFrequency: Optional[RecordingFrequency] + configurationItemDeliveryTime: Optional[ConfigurationItemDeliveryTime] + + +BaseConfigurationItems = List[BaseConfigurationItem] +ResourceIdentifiersList = List[AggregateResourceIdentifier] + + +class BatchGetAggregateResourceConfigRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + ResourceIdentifiers: ResourceIdentifiersList + + +UnprocessedResourceIdentifierList = List[AggregateResourceIdentifier] + + +class BatchGetAggregateResourceConfigResponse(TypedDict, total=False): + BaseConfigurationItems: Optional[BaseConfigurationItems] + UnprocessedResourceIdentifiers: Optional[UnprocessedResourceIdentifierList] + + +class ResourceKey(TypedDict, total=False): + resourceType: ResourceType + resourceId: ResourceId + + +ResourceKeys = List[ResourceKey] + + +class BatchGetResourceConfigRequest(ServiceRequest): + resourceKeys: ResourceKeys + + +class BatchGetResourceConfigResponse(TypedDict, total=False): + baseConfigurationItems: Optional[BaseConfigurationItems] + unprocessedResourceKeys: Optional[ResourceKeys] + + +class ComplianceByConfigRule(TypedDict, total=False): + ConfigRuleName: Optional[StringWithCharLimit64] + Compliance: Optional[Compliance] + + +ComplianceByConfigRules = List[ComplianceByConfigRule] + + +class ComplianceByResource(TypedDict, total=False): + ResourceType: Optional[StringWithCharLimit256] + ResourceId: Optional[BaseResourceId] + Compliance: Optional[Compliance] + + +ComplianceByResources = List[ComplianceByResource] +ComplianceResourceTypes = List[StringWithCharLimit256] + + +class ComplianceSummaryByResourceType(TypedDict, total=False): + ResourceType: Optional[StringWithCharLimit256] + ComplianceSummary: Optional[ComplianceSummary] + + +ComplianceSummariesByResourceType = List[ComplianceSummaryByResourceType] +ComplianceTypes = List[ComplianceType] + + +class ConfigExportDeliveryInfo(TypedDict, total=False): + lastStatus: Optional[DeliveryStatus] + lastErrorCode: Optional[String] + lastErrorMessage: Optional[String] + lastAttemptTime: Optional[Date] + lastSuccessfulTime: Optional[Date] + nextDeliveryTime: Optional[Date] + + +class EvaluationModeConfiguration(TypedDict, total=False): + Mode: Optional[EvaluationMode] + + +EvaluationModes = List[EvaluationModeConfiguration] + + +class CustomPolicyDetails(TypedDict, total=False): + PolicyRuntime: PolicyRuntime + PolicyText: PolicyText + EnableDebugLogDelivery: Optional[Boolean] + + +class SourceDetail(TypedDict, total=False): + EventSource: Optional[EventSource] + MessageType: Optional[MessageType] + MaximumExecutionFrequency: Optional[MaximumExecutionFrequency] + + +SourceDetails = List[SourceDetail] + + +class Source(TypedDict, total=False): + Owner: Owner + SourceIdentifier: Optional[StringWithCharLimit256] + SourceDetails: Optional[SourceDetails] + CustomPolicyDetails: Optional[CustomPolicyDetails] + + +class Scope(TypedDict, total=False): + ComplianceResourceTypes: Optional[ComplianceResourceTypes] + TagKey: Optional[StringWithCharLimit128] + TagValue: Optional[StringWithCharLimit256] + ComplianceResourceId: Optional[BaseResourceId] + + +class ConfigRule(TypedDict, total=False): + ConfigRuleName: Optional[ConfigRuleName] + ConfigRuleArn: Optional[StringWithCharLimit256] + ConfigRuleId: Optional[StringWithCharLimit64] + Description: Optional[EmptiableStringWithCharLimit256] + Scope: Optional[Scope] + Source: Source + InputParameters: Optional[StringWithCharLimit1024] + MaximumExecutionFrequency: Optional[MaximumExecutionFrequency] + ConfigRuleState: Optional[ConfigRuleState] + CreatedBy: Optional[StringWithCharLimit256] + EvaluationModes: Optional[EvaluationModes] + + +class ConfigRuleComplianceFilters(TypedDict, total=False): + ConfigRuleName: Optional[ConfigRuleName] + ComplianceType: Optional[ComplianceType] + AccountId: Optional[AccountId] + AwsRegion: Optional[AwsRegion] + + +class ConfigRuleComplianceSummaryFilters(TypedDict, total=False): + AccountId: Optional[AccountId] + AwsRegion: Optional[AwsRegion] + + +class ConfigRuleEvaluationStatus(TypedDict, total=False): + ConfigRuleName: Optional[ConfigRuleName] + ConfigRuleArn: Optional[String] + ConfigRuleId: Optional[String] + LastSuccessfulInvocationTime: Optional[Date] + LastFailedInvocationTime: Optional[Date] + LastSuccessfulEvaluationTime: Optional[Date] + LastFailedEvaluationTime: Optional[Date] + FirstActivatedTime: Optional[Date] + LastDeactivatedTime: Optional[Date] + LastErrorCode: Optional[String] + LastErrorMessage: Optional[String] + FirstEvaluationStarted: Optional[Boolean] + LastDebugLogDeliveryStatus: Optional[String] + LastDebugLogDeliveryStatusReason: Optional[String] + LastDebugLogDeliveryTime: Optional[Date] + + +ConfigRuleEvaluationStatusList = List[ConfigRuleEvaluationStatus] +ConfigRuleNames = List[ConfigRuleName] +ConfigRules = List[ConfigRule] + + +class ConfigSnapshotDeliveryProperties(TypedDict, total=False): + deliveryFrequency: Optional[MaximumExecutionFrequency] + + +class ConfigStreamDeliveryInfo(TypedDict, total=False): + lastStatus: Optional[DeliveryStatus] + lastErrorCode: Optional[String] + lastErrorMessage: Optional[String] + lastStatusChangeTime: Optional[Date] + + +class OrganizationAggregationSource(TypedDict, total=False): + RoleArn: String + AwsRegions: Optional[AggregatorRegionList] + AllAwsRegions: Optional[Boolean] + + +class ConfigurationAggregator(TypedDict, total=False): + ConfigurationAggregatorName: Optional[ConfigurationAggregatorName] + ConfigurationAggregatorArn: Optional[ConfigurationAggregatorArn] + AccountAggregationSources: Optional[AccountAggregationSourceList] + OrganizationAggregationSource: Optional[OrganizationAggregationSource] + CreationTime: Optional[Date] + LastUpdatedTime: Optional[Date] + CreatedBy: Optional[StringWithCharLimit256] + AggregatorFilters: Optional[AggregatorFilters] + + +ConfigurationAggregatorList = List[ConfigurationAggregator] +ConfigurationAggregatorNameList = List[ConfigurationAggregatorName] + + +class Relationship(TypedDict, total=False): + resourceType: Optional[ResourceType] + resourceId: Optional[ResourceId] + resourceName: Optional[ResourceName] + relationshipName: Optional[RelationshipName] + + +RelationshipList = List[Relationship] +RelatedEventList = List[RelatedEvent] +Tags = Dict[Name, Value] + + +class ConfigurationItem(TypedDict, total=False): + version: Optional[Version] + accountId: Optional[AccountId] + configurationItemCaptureTime: Optional[ConfigurationItemCaptureTime] + configurationItemStatus: Optional[ConfigurationItemStatus] + configurationStateId: Optional[ConfigurationStateId] + configurationItemMD5Hash: Optional[ConfigurationItemMD5Hash] + arn: Optional[ARN] + resourceType: Optional[ResourceType] + resourceId: Optional[ResourceId] + resourceName: Optional[ResourceName] + awsRegion: Optional[AwsRegion] + availabilityZone: Optional[AvailabilityZone] + resourceCreationTime: Optional[ResourceCreationTime] + tags: Optional[Tags] + relatedEvents: Optional[RelatedEventList] + relationships: Optional[RelationshipList] + configuration: Optional[Configuration] + supplementaryConfiguration: Optional[SupplementaryConfiguration] + recordingFrequency: Optional[RecordingFrequency] + configurationItemDeliveryTime: Optional[ConfigurationItemDeliveryTime] + + +ConfigurationItemList = List[ConfigurationItem] +ConfigurationRecorderFilterValues = List[ConfigurationRecorderFilterValue] + + +class ConfigurationRecorderFilter(TypedDict, total=False): + filterName: Optional[ConfigurationRecorderFilterName] + filterValue: Optional[ConfigurationRecorderFilterValues] + + +ConfigurationRecorderFilterList = List[ConfigurationRecorderFilter] +ConfigurationRecorderList = List[ConfigurationRecorder] +ConfigurationRecorderNameList = List[RecorderName] + + +class ConfigurationRecorderStatus(TypedDict, total=False): + arn: Optional[AmazonResourceName] + name: Optional[String] + lastStartTime: Optional[Date] + lastStopTime: Optional[Date] + recording: Optional[Boolean] + lastStatus: Optional[RecorderStatus] + lastErrorCode: Optional[String] + lastErrorMessage: Optional[String] + lastStatusChangeTime: Optional[Date] + servicePrincipal: Optional[ServicePrincipal] + + +ConfigurationRecorderStatusList = List[ConfigurationRecorderStatus] + + +class ConfigurationRecorderSummary(TypedDict, total=False): + arn: AmazonResourceName + name: RecorderName + servicePrincipal: Optional[ServicePrincipal] + recordingScope: RecordingScope + + +ConfigurationRecorderSummaries = List[ConfigurationRecorderSummary] +ConformancePackConfigRuleNames = List[StringWithCharLimit64] + + +class ConformancePackComplianceFilters(TypedDict, total=False): + ConfigRuleNames: Optional[ConformancePackConfigRuleNames] + ComplianceType: Optional[ConformancePackComplianceType] + + +ConformancePackComplianceResourceIds = List[StringWithCharLimit256] +LastUpdatedTime = datetime + + +class ConformancePackComplianceScore(TypedDict, total=False): + Score: Optional[ComplianceScore] + ConformancePackName: Optional[ConformancePackName] + LastUpdatedTime: Optional[LastUpdatedTime] + + +ConformancePackComplianceScores = List[ConformancePackComplianceScore] +ConformancePackNameFilter = List[ConformancePackName] + + +class ConformancePackComplianceScoresFilters(TypedDict, total=False): + ConformancePackNames: ConformancePackNameFilter + + +class ConformancePackComplianceSummary(TypedDict, total=False): + ConformancePackName: ConformancePackName + ConformancePackComplianceStatus: ConformancePackComplianceType + + +ConformancePackComplianceSummaryList = List[ConformancePackComplianceSummary] + + +class TemplateSSMDocumentDetails(TypedDict, total=False): + DocumentName: SSMDocumentName + DocumentVersion: Optional[SSMDocumentVersion] + + +class ConformancePackInputParameter(TypedDict, total=False): + ParameterName: ParameterName + ParameterValue: ParameterValue + + +ConformancePackInputParameters = List[ConformancePackInputParameter] + + +class ConformancePackDetail(TypedDict, total=False): + ConformancePackName: ConformancePackName + ConformancePackArn: ConformancePackArn + ConformancePackId: ConformancePackId + DeliveryS3Bucket: Optional[DeliveryS3Bucket] + DeliveryS3KeyPrefix: Optional[DeliveryS3KeyPrefix] + ConformancePackInputParameters: Optional[ConformancePackInputParameters] + LastUpdateRequestedTime: Optional[Date] + CreatedBy: Optional[StringWithCharLimit256] + TemplateSSMDocumentDetails: Optional[TemplateSSMDocumentDetails] + + +ConformancePackDetailList = List[ConformancePackDetail] + + +class ConformancePackEvaluationFilters(TypedDict, total=False): + ConfigRuleNames: Optional[ConformancePackConfigRuleNames] + ComplianceType: Optional[ConformancePackComplianceType] + ResourceType: Optional[StringWithCharLimit256] + ResourceIds: Optional[ConformancePackComplianceResourceIds] + + +class ConformancePackEvaluationResult(TypedDict, total=False): + ComplianceType: ConformancePackComplianceType + EvaluationResultIdentifier: EvaluationResultIdentifier + ConfigRuleInvokedTime: Date + ResultRecordedTime: Date + Annotation: Optional[Annotation] + + +ConformancePackNamesList = List[ConformancePackName] +ConformancePackNamesToSummarizeList = List[ConformancePackName] +ControlsList = List[StringWithCharLimit128] + + +class ConformancePackRuleCompliance(TypedDict, total=False): + ConfigRuleName: Optional[ConfigRuleName] + ComplianceType: Optional[ConformancePackComplianceType] + Controls: Optional[ControlsList] + + +ConformancePackRuleComplianceList = List[ConformancePackRuleCompliance] +ConformancePackRuleEvaluationResultsList = List[ConformancePackEvaluationResult] + + +class ConformancePackStatusDetail(TypedDict, total=False): + ConformancePackName: ConformancePackName + ConformancePackId: ConformancePackId + ConformancePackArn: ConformancePackArn + ConformancePackState: ConformancePackState + StackArn: StackArn + ConformancePackStatusReason: Optional[ConformancePackStatusReason] + LastUpdateRequestedTime: Date + LastUpdateCompletedTime: Optional[Date] + + +ConformancePackStatusDetailsList = List[ConformancePackStatusDetail] +DebugLogDeliveryAccounts = List[AccountId] + + +class DeleteAggregationAuthorizationRequest(ServiceRequest): + AuthorizedAccountId: AccountId + AuthorizedAwsRegion: AwsRegion + + +class DeleteConfigRuleRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + + +class DeleteConfigurationAggregatorRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + + +class DeleteConfigurationRecorderRequest(ServiceRequest): + ConfigurationRecorderName: RecorderName + + +class DeleteConformancePackRequest(ServiceRequest): + ConformancePackName: ConformancePackName + + +class DeleteDeliveryChannelRequest(ServiceRequest): + DeliveryChannelName: ChannelName + + +class DeleteEvaluationResultsRequest(ServiceRequest): + ConfigRuleName: StringWithCharLimit64 + + +class DeleteEvaluationResultsResponse(TypedDict, total=False): + pass + + +class DeleteOrganizationConfigRuleRequest(ServiceRequest): + OrganizationConfigRuleName: OrganizationConfigRuleName + + +class DeleteOrganizationConformancePackRequest(ServiceRequest): + OrganizationConformancePackName: OrganizationConformancePackName + + +class DeletePendingAggregationRequestRequest(ServiceRequest): + RequesterAccountId: AccountId + RequesterAwsRegion: AwsRegion + + +class DeleteRemediationConfigurationRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + ResourceType: Optional[String] + + +class DeleteRemediationConfigurationResponse(TypedDict, total=False): + pass + + +class RemediationExceptionResourceKey(TypedDict, total=False): + ResourceType: Optional[StringWithCharLimit256] + ResourceId: Optional[StringWithCharLimit1024] + + +RemediationExceptionResourceKeys = List[RemediationExceptionResourceKey] + + +class DeleteRemediationExceptionsRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + ResourceKeys: RemediationExceptionResourceKeys + + +class FailedDeleteRemediationExceptionsBatch(TypedDict, total=False): + FailureMessage: Optional[String] + FailedItems: Optional[RemediationExceptionResourceKeys] + + +FailedDeleteRemediationExceptionsBatches = List[FailedDeleteRemediationExceptionsBatch] + + +class DeleteRemediationExceptionsResponse(TypedDict, total=False): + FailedBatches: Optional[FailedDeleteRemediationExceptionsBatches] + + +class DeleteResourceConfigRequest(ServiceRequest): + ResourceType: ResourceTypeString + ResourceId: ResourceId + + +class DeleteRetentionConfigurationRequest(ServiceRequest): + RetentionConfigurationName: RetentionConfigurationName + + +class DeleteServiceLinkedConfigurationRecorderRequest(ServiceRequest): + ServicePrincipal: ServicePrincipal + + +class DeleteServiceLinkedConfigurationRecorderResponse(TypedDict, total=False): + Arn: AmazonResourceName + Name: RecorderName + + +class DeleteStoredQueryRequest(ServiceRequest): + QueryName: QueryName + + +class DeleteStoredQueryResponse(TypedDict, total=False): + pass + + +class DeliverConfigSnapshotRequest(ServiceRequest): + deliveryChannelName: ChannelName + + +class DeliverConfigSnapshotResponse(TypedDict, total=False): + configSnapshotId: Optional[String] + + +class DeliveryChannel(TypedDict, total=False): + name: Optional[ChannelName] + s3BucketName: Optional[String] + s3KeyPrefix: Optional[String] + s3KmsKeyArn: Optional[String] + snsTopicARN: Optional[String] + configSnapshotDeliveryProperties: Optional[ConfigSnapshotDeliveryProperties] + + +DeliveryChannelList = List[DeliveryChannel] +DeliveryChannelNameList = List[ChannelName] + + +class DeliveryChannelStatus(TypedDict, total=False): + name: Optional[String] + configSnapshotDeliveryInfo: Optional[ConfigExportDeliveryInfo] + configHistoryDeliveryInfo: Optional[ConfigExportDeliveryInfo] + configStreamDeliveryInfo: Optional[ConfigStreamDeliveryInfo] + + +DeliveryChannelStatusList = List[DeliveryChannelStatus] + + +class DescribeAggregateComplianceByConfigRulesRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + Filters: Optional[ConfigRuleComplianceFilters] + Limit: Optional[GroupByAPILimit] + NextToken: Optional[NextToken] + + +class DescribeAggregateComplianceByConfigRulesResponse(TypedDict, total=False): + AggregateComplianceByConfigRules: Optional[AggregateComplianceByConfigRuleList] + NextToken: Optional[NextToken] + + +class DescribeAggregateComplianceByConformancePacksRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + Filters: Optional[AggregateConformancePackComplianceFilters] + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class DescribeAggregateComplianceByConformancePacksResponse(TypedDict, total=False): + AggregateComplianceByConformancePacks: Optional[AggregateComplianceByConformancePackList] + NextToken: Optional[NextToken] + + +class DescribeAggregationAuthorizationsRequest(ServiceRequest): + Limit: Optional[Limit] + NextToken: Optional[String] + + +class DescribeAggregationAuthorizationsResponse(TypedDict, total=False): + AggregationAuthorizations: Optional[AggregationAuthorizationList] + NextToken: Optional[String] + + +class DescribeComplianceByConfigRuleRequest(ServiceRequest): + ConfigRuleNames: Optional[ConfigRuleNames] + ComplianceTypes: Optional[ComplianceTypes] + NextToken: Optional[String] + + +class DescribeComplianceByConfigRuleResponse(TypedDict, total=False): + ComplianceByConfigRules: Optional[ComplianceByConfigRules] + NextToken: Optional[String] + + +class DescribeComplianceByResourceRequest(ServiceRequest): + ResourceType: Optional[StringWithCharLimit256] + ResourceId: Optional[BaseResourceId] + ComplianceTypes: Optional[ComplianceTypes] + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class DescribeComplianceByResourceResponse(TypedDict, total=False): + ComplianceByResources: Optional[ComplianceByResources] + NextToken: Optional[NextToken] + + +class DescribeConfigRuleEvaluationStatusRequest(ServiceRequest): + ConfigRuleNames: Optional[ConfigRuleNames] + NextToken: Optional[String] + Limit: Optional[RuleLimit] + + +class DescribeConfigRuleEvaluationStatusResponse(TypedDict, total=False): + ConfigRulesEvaluationStatus: Optional[ConfigRuleEvaluationStatusList] + NextToken: Optional[String] + + +class DescribeConfigRulesFilters(TypedDict, total=False): + EvaluationMode: Optional[EvaluationMode] + + +class DescribeConfigRulesRequest(ServiceRequest): + ConfigRuleNames: Optional[ConfigRuleNames] + NextToken: Optional[String] + Filters: Optional[DescribeConfigRulesFilters] + + +class DescribeConfigRulesResponse(TypedDict, total=False): + ConfigRules: Optional[ConfigRules] + NextToken: Optional[String] + + +class DescribeConfigurationAggregatorSourcesStatusRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + UpdateStatus: Optional[AggregatedSourceStatusTypeList] + NextToken: Optional[String] + Limit: Optional[Limit] + + +class DescribeConfigurationAggregatorSourcesStatusResponse(TypedDict, total=False): + AggregatedSourceStatusList: Optional[AggregatedSourceStatusList] + NextToken: Optional[String] + + +class DescribeConfigurationAggregatorsRequest(ServiceRequest): + ConfigurationAggregatorNames: Optional[ConfigurationAggregatorNameList] + NextToken: Optional[String] + Limit: Optional[Limit] + + +class DescribeConfigurationAggregatorsResponse(TypedDict, total=False): + ConfigurationAggregators: Optional[ConfigurationAggregatorList] + NextToken: Optional[String] + + +class DescribeConfigurationRecorderStatusRequest(ServiceRequest): + ConfigurationRecorderNames: Optional[ConfigurationRecorderNameList] + ServicePrincipal: Optional[ServicePrincipal] + Arn: Optional[AmazonResourceName] + + +class DescribeConfigurationRecorderStatusResponse(TypedDict, total=False): + ConfigurationRecordersStatus: Optional[ConfigurationRecorderStatusList] + + +class DescribeConfigurationRecordersRequest(ServiceRequest): + ConfigurationRecorderNames: Optional[ConfigurationRecorderNameList] + ServicePrincipal: Optional[ServicePrincipal] + Arn: Optional[AmazonResourceName] + + +class DescribeConfigurationRecordersResponse(TypedDict, total=False): + ConfigurationRecorders: Optional[ConfigurationRecorderList] + + +class DescribeConformancePackComplianceRequest(ServiceRequest): + ConformancePackName: ConformancePackName + Filters: Optional[ConformancePackComplianceFilters] + Limit: Optional[DescribeConformancePackComplianceLimit] + NextToken: Optional[NextToken] + + +class DescribeConformancePackComplianceResponse(TypedDict, total=False): + ConformancePackName: ConformancePackName + ConformancePackRuleComplianceList: ConformancePackRuleComplianceList + NextToken: Optional[NextToken] + + +class DescribeConformancePackStatusRequest(ServiceRequest): + ConformancePackNames: Optional[ConformancePackNamesList] + Limit: Optional[PageSizeLimit] + NextToken: Optional[NextToken] + + +class DescribeConformancePackStatusResponse(TypedDict, total=False): + ConformancePackStatusDetails: Optional[ConformancePackStatusDetailsList] + NextToken: Optional[NextToken] + + +class DescribeConformancePacksRequest(ServiceRequest): + ConformancePackNames: Optional[ConformancePackNamesList] + Limit: Optional[PageSizeLimit] + NextToken: Optional[NextToken] + + +class DescribeConformancePacksResponse(TypedDict, total=False): + ConformancePackDetails: Optional[ConformancePackDetailList] + NextToken: Optional[NextToken] + + +class DescribeDeliveryChannelStatusRequest(ServiceRequest): + DeliveryChannelNames: Optional[DeliveryChannelNameList] + + +class DescribeDeliveryChannelStatusResponse(TypedDict, total=False): + DeliveryChannelsStatus: Optional[DeliveryChannelStatusList] + + +class DescribeDeliveryChannelsRequest(ServiceRequest): + DeliveryChannelNames: Optional[DeliveryChannelNameList] + + +class DescribeDeliveryChannelsResponse(TypedDict, total=False): + DeliveryChannels: Optional[DeliveryChannelList] + + +OrganizationConfigRuleNames = List[StringWithCharLimit64] + + +class DescribeOrganizationConfigRuleStatusesRequest(ServiceRequest): + OrganizationConfigRuleNames: Optional[OrganizationConfigRuleNames] + Limit: Optional[CosmosPageLimit] + NextToken: Optional[String] + + +class OrganizationConfigRuleStatus(TypedDict, total=False): + OrganizationConfigRuleName: OrganizationConfigRuleName + OrganizationRuleStatus: OrganizationRuleStatus + ErrorCode: Optional[String] + ErrorMessage: Optional[String] + LastUpdateTime: Optional[Date] + + +OrganizationConfigRuleStatuses = List[OrganizationConfigRuleStatus] + + +class DescribeOrganizationConfigRuleStatusesResponse(TypedDict, total=False): + OrganizationConfigRuleStatuses: Optional[OrganizationConfigRuleStatuses] + NextToken: Optional[String] + + +class DescribeOrganizationConfigRulesRequest(ServiceRequest): + OrganizationConfigRuleNames: Optional[OrganizationConfigRuleNames] + Limit: Optional[CosmosPageLimit] + NextToken: Optional[String] + + +ResourceTypesScope = List[StringWithCharLimit256] +OrganizationConfigRuleTriggerTypeNoSNs = List[OrganizationConfigRuleTriggerTypeNoSN] + + +class OrganizationCustomPolicyRuleMetadataNoPolicy(TypedDict, total=False): + Description: Optional[StringWithCharLimit256Min0] + OrganizationConfigRuleTriggerTypes: Optional[OrganizationConfigRuleTriggerTypeNoSNs] + InputParameters: Optional[StringWithCharLimit2048] + MaximumExecutionFrequency: Optional[MaximumExecutionFrequency] + ResourceTypesScope: Optional[ResourceTypesScope] + ResourceIdScope: Optional[StringWithCharLimit768] + TagKeyScope: Optional[StringWithCharLimit128] + TagValueScope: Optional[StringWithCharLimit256] + PolicyRuntime: Optional[PolicyRuntime] + DebugLogDeliveryAccounts: Optional[DebugLogDeliveryAccounts] + + +ExcludedAccounts = List[AccountId] +OrganizationConfigRuleTriggerTypes = List[OrganizationConfigRuleTriggerType] + + +class OrganizationCustomRuleMetadata(TypedDict, total=False): + Description: Optional[StringWithCharLimit256Min0] + LambdaFunctionArn: StringWithCharLimit256 + OrganizationConfigRuleTriggerTypes: OrganizationConfigRuleTriggerTypes + InputParameters: Optional[StringWithCharLimit2048] + MaximumExecutionFrequency: Optional[MaximumExecutionFrequency] + ResourceTypesScope: Optional[ResourceTypesScope] + ResourceIdScope: Optional[StringWithCharLimit768] + TagKeyScope: Optional[StringWithCharLimit128] + TagValueScope: Optional[StringWithCharLimit256] + + +class OrganizationManagedRuleMetadata(TypedDict, total=False): + Description: Optional[StringWithCharLimit256Min0] + RuleIdentifier: StringWithCharLimit256 + InputParameters: Optional[StringWithCharLimit2048] + MaximumExecutionFrequency: Optional[MaximumExecutionFrequency] + ResourceTypesScope: Optional[ResourceTypesScope] + ResourceIdScope: Optional[StringWithCharLimit768] + TagKeyScope: Optional[StringWithCharLimit128] + TagValueScope: Optional[StringWithCharLimit256] + + +class OrganizationConfigRule(TypedDict, total=False): + OrganizationConfigRuleName: OrganizationConfigRuleName + OrganizationConfigRuleArn: StringWithCharLimit256 + OrganizationManagedRuleMetadata: Optional[OrganizationManagedRuleMetadata] + OrganizationCustomRuleMetadata: Optional[OrganizationCustomRuleMetadata] + ExcludedAccounts: Optional[ExcludedAccounts] + LastUpdateTime: Optional[Date] + OrganizationCustomPolicyRuleMetadata: Optional[OrganizationCustomPolicyRuleMetadataNoPolicy] + + +OrganizationConfigRules = List[OrganizationConfigRule] + + +class DescribeOrganizationConfigRulesResponse(TypedDict, total=False): + OrganizationConfigRules: Optional[OrganizationConfigRules] + NextToken: Optional[String] + + +OrganizationConformancePackNames = List[OrganizationConformancePackName] + + +class DescribeOrganizationConformancePackStatusesRequest(ServiceRequest): + OrganizationConformancePackNames: Optional[OrganizationConformancePackNames] + Limit: Optional[CosmosPageLimit] + NextToken: Optional[String] + + +class OrganizationConformancePackStatus(TypedDict, total=False): + OrganizationConformancePackName: OrganizationConformancePackName + Status: OrganizationResourceStatus + ErrorCode: Optional[String] + ErrorMessage: Optional[String] + LastUpdateTime: Optional[Date] + + +OrganizationConformancePackStatuses = List[OrganizationConformancePackStatus] + + +class DescribeOrganizationConformancePackStatusesResponse(TypedDict, total=False): + OrganizationConformancePackStatuses: Optional[OrganizationConformancePackStatuses] + NextToken: Optional[String] + + +class DescribeOrganizationConformancePacksRequest(ServiceRequest): + OrganizationConformancePackNames: Optional[OrganizationConformancePackNames] + Limit: Optional[CosmosPageLimit] + NextToken: Optional[String] + + +class OrganizationConformancePack(TypedDict, total=False): + OrganizationConformancePackName: OrganizationConformancePackName + OrganizationConformancePackArn: StringWithCharLimit256 + DeliveryS3Bucket: Optional[DeliveryS3Bucket] + DeliveryS3KeyPrefix: Optional[DeliveryS3KeyPrefix] + ConformancePackInputParameters: Optional[ConformancePackInputParameters] + ExcludedAccounts: Optional[ExcludedAccounts] + LastUpdateTime: Date + + +OrganizationConformancePacks = List[OrganizationConformancePack] + + +class DescribeOrganizationConformancePacksResponse(TypedDict, total=False): + OrganizationConformancePacks: Optional[OrganizationConformancePacks] + NextToken: Optional[String] + + +class DescribePendingAggregationRequestsRequest(ServiceRequest): + Limit: Optional[DescribePendingAggregationRequestsLimit] + NextToken: Optional[String] + + +class PendingAggregationRequest(TypedDict, total=False): + RequesterAccountId: Optional[AccountId] + RequesterAwsRegion: Optional[AwsRegion] + + +PendingAggregationRequestList = List[PendingAggregationRequest] + + +class DescribePendingAggregationRequestsResponse(TypedDict, total=False): + PendingAggregationRequests: Optional[PendingAggregationRequestList] + NextToken: Optional[String] + + +class DescribeRemediationConfigurationsRequest(ServiceRequest): + ConfigRuleNames: ConfigRuleNames + + +class SsmControls(TypedDict, total=False): + ConcurrentExecutionRatePercentage: Optional[Percentage] + ErrorPercentage: Optional[Percentage] + + +class ExecutionControls(TypedDict, total=False): + SsmControls: Optional[SsmControls] + + +StaticParameterValues = List[StringWithCharLimit256] + + +class StaticValue(TypedDict, total=False): + Values: StaticParameterValues + + +class ResourceValue(TypedDict, total=False): + Value: ResourceValueType + + +class RemediationParameterValue(TypedDict, total=False): + ResourceValue: Optional[ResourceValue] + StaticValue: Optional[StaticValue] + + +RemediationParameters = Dict[StringWithCharLimit256, RemediationParameterValue] + + +class RemediationConfiguration(TypedDict, total=False): + ConfigRuleName: ConfigRuleName + TargetType: RemediationTargetType + TargetId: StringWithCharLimit256 + TargetVersion: Optional[String] + Parameters: Optional[RemediationParameters] + ResourceType: Optional[String] + Automatic: Optional[Boolean] + ExecutionControls: Optional[ExecutionControls] + MaximumAutomaticAttempts: Optional[AutoRemediationAttempts] + RetryAttemptSeconds: Optional[AutoRemediationAttemptSeconds] + Arn: Optional[StringWithCharLimit1024] + CreatedByService: Optional[StringWithCharLimit1024] + + +RemediationConfigurations = List[RemediationConfiguration] + + +class DescribeRemediationConfigurationsResponse(TypedDict, total=False): + RemediationConfigurations: Optional[RemediationConfigurations] + + +class DescribeRemediationExceptionsRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + ResourceKeys: Optional[RemediationExceptionResourceKeys] + Limit: Optional[Limit] + NextToken: Optional[String] + + +class RemediationException(TypedDict, total=False): + ConfigRuleName: ConfigRuleName + ResourceType: StringWithCharLimit256 + ResourceId: StringWithCharLimit1024 + Message: Optional[StringWithCharLimit1024] + ExpirationTime: Optional[Date] + + +RemediationExceptions = List[RemediationException] + + +class DescribeRemediationExceptionsResponse(TypedDict, total=False): + RemediationExceptions: Optional[RemediationExceptions] + NextToken: Optional[String] + + +class DescribeRemediationExecutionStatusRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + ResourceKeys: Optional[ResourceKeys] + Limit: Optional[Limit] + NextToken: Optional[String] + + +class RemediationExecutionStep(TypedDict, total=False): + Name: Optional[String] + State: Optional[RemediationExecutionStepState] + ErrorMessage: Optional[String] + StartTime: Optional[Date] + StopTime: Optional[Date] + + +RemediationExecutionSteps = List[RemediationExecutionStep] + + +class RemediationExecutionStatus(TypedDict, total=False): + ResourceKey: Optional[ResourceKey] + State: Optional[RemediationExecutionState] + StepDetails: Optional[RemediationExecutionSteps] + InvocationTime: Optional[Date] + LastUpdatedTime: Optional[Date] + + +RemediationExecutionStatuses = List[RemediationExecutionStatus] + + +class DescribeRemediationExecutionStatusResponse(TypedDict, total=False): + RemediationExecutionStatuses: Optional[RemediationExecutionStatuses] + NextToken: Optional[String] + + +RetentionConfigurationNameList = List[RetentionConfigurationName] + + +class DescribeRetentionConfigurationsRequest(ServiceRequest): + RetentionConfigurationNames: Optional[RetentionConfigurationNameList] + NextToken: Optional[NextToken] + + +class RetentionConfiguration(TypedDict, total=False): + Name: RetentionConfigurationName + RetentionPeriodInDays: RetentionPeriodInDays + + +RetentionConfigurationList = List[RetentionConfiguration] + + +class DescribeRetentionConfigurationsResponse(TypedDict, total=False): + RetentionConfigurations: Optional[RetentionConfigurationList] + NextToken: Optional[NextToken] + + +class DisassociateResourceTypesRequest(ServiceRequest): + ConfigurationRecorderArn: AmazonResourceName + ResourceTypes: ResourceTypeList + + +class DisassociateResourceTypesResponse(TypedDict, total=False): + ConfigurationRecorder: ConfigurationRecorder + + +DiscoveredResourceIdentifierList = List[AggregateResourceIdentifier] +EarlierTime = datetime +OrderingTimestamp = datetime + + +class Evaluation(TypedDict, total=False): + ComplianceResourceType: StringWithCharLimit256 + ComplianceResourceId: BaseResourceId + ComplianceType: ComplianceType + Annotation: Optional[StringWithCharLimit256] + OrderingTimestamp: OrderingTimestamp + + +class EvaluationContext(TypedDict, total=False): + EvaluationContextIdentifier: Optional[EvaluationContextIdentifier] + + +class EvaluationResult(TypedDict, total=False): + EvaluationResultIdentifier: Optional[EvaluationResultIdentifier] + ComplianceType: Optional[ComplianceType] + ResultRecordedTime: Optional[Date] + ConfigRuleInvokedTime: Optional[Date] + Annotation: Optional[StringWithCharLimit256] + ResultToken: Optional[String] + + +EvaluationResults = List[EvaluationResult] + + +class EvaluationStatus(TypedDict, total=False): + Status: ResourceEvaluationStatus + FailureReason: Optional[StringWithCharLimit1024] + + +Evaluations = List[Evaluation] + + +class ExternalEvaluation(TypedDict, total=False): + ComplianceResourceType: StringWithCharLimit256 + ComplianceResourceId: BaseResourceId + ComplianceType: ComplianceType + Annotation: Optional[StringWithCharLimit256] + OrderingTimestamp: OrderingTimestamp + + +class FailedRemediationBatch(TypedDict, total=False): + FailureMessage: Optional[String] + FailedItems: Optional[RemediationConfigurations] + + +FailedRemediationBatches = List[FailedRemediationBatch] + + +class FailedRemediationExceptionBatch(TypedDict, total=False): + FailureMessage: Optional[String] + FailedItems: Optional[RemediationExceptions] + + +FailedRemediationExceptionBatches = List[FailedRemediationExceptionBatch] + + +class FieldInfo(TypedDict, total=False): + Name: Optional[FieldName] + + +FieldInfoList = List[FieldInfo] + + +class GetAggregateComplianceDetailsByConfigRuleRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + ConfigRuleName: ConfigRuleName + AccountId: AccountId + AwsRegion: AwsRegion + ComplianceType: Optional[ComplianceType] + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class GetAggregateComplianceDetailsByConfigRuleResponse(TypedDict, total=False): + AggregateEvaluationResults: Optional[AggregateEvaluationResultList] + NextToken: Optional[NextToken] + + +class GetAggregateConfigRuleComplianceSummaryRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + Filters: Optional[ConfigRuleComplianceSummaryFilters] + GroupByKey: Optional[ConfigRuleComplianceSummaryGroupKey] + Limit: Optional[GroupByAPILimit] + NextToken: Optional[NextToken] + + +class GetAggregateConfigRuleComplianceSummaryResponse(TypedDict, total=False): + GroupByKey: Optional[StringWithCharLimit256] + AggregateComplianceCounts: Optional[AggregateComplianceCountList] + NextToken: Optional[NextToken] + + +class GetAggregateConformancePackComplianceSummaryRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + Filters: Optional[AggregateConformancePackComplianceSummaryFilters] + GroupByKey: Optional[AggregateConformancePackComplianceSummaryGroupKey] + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class GetAggregateConformancePackComplianceSummaryResponse(TypedDict, total=False): + AggregateConformancePackComplianceSummaries: Optional[ + AggregateConformancePackComplianceSummaryList + ] + GroupByKey: Optional[StringWithCharLimit256] + NextToken: Optional[NextToken] + + +class ResourceCountFilters(TypedDict, total=False): + ResourceType: Optional[ResourceType] + AccountId: Optional[AccountId] + Region: Optional[AwsRegion] + + +class GetAggregateDiscoveredResourceCountsRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + Filters: Optional[ResourceCountFilters] + GroupByKey: Optional[ResourceCountGroupKey] + Limit: Optional[GroupByAPILimit] + NextToken: Optional[NextToken] + + +Long = int + + +class GroupedResourceCount(TypedDict, total=False): + GroupName: StringWithCharLimit256 + ResourceCount: Long + + +GroupedResourceCountList = List[GroupedResourceCount] + + +class GetAggregateDiscoveredResourceCountsResponse(TypedDict, total=False): + TotalDiscoveredResources: Long + GroupByKey: Optional[StringWithCharLimit256] + GroupedResourceCounts: Optional[GroupedResourceCountList] + NextToken: Optional[NextToken] + + +class GetAggregateResourceConfigRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + ResourceIdentifier: AggregateResourceIdentifier + + +class GetAggregateResourceConfigResponse(TypedDict, total=False): + ConfigurationItem: Optional[ConfigurationItem] + + +class GetComplianceDetailsByConfigRuleRequest(ServiceRequest): + ConfigRuleName: StringWithCharLimit64 + ComplianceTypes: Optional[ComplianceTypes] + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class GetComplianceDetailsByConfigRuleResponse(TypedDict, total=False): + EvaluationResults: Optional[EvaluationResults] + NextToken: Optional[NextToken] + + +class GetComplianceDetailsByResourceRequest(ServiceRequest): + ResourceType: Optional[StringWithCharLimit256] + ResourceId: Optional[BaseResourceId] + ComplianceTypes: Optional[ComplianceTypes] + NextToken: Optional[String] + ResourceEvaluationId: Optional[ResourceEvaluationId] + + +class GetComplianceDetailsByResourceResponse(TypedDict, total=False): + EvaluationResults: Optional[EvaluationResults] + NextToken: Optional[String] + + +class GetComplianceSummaryByConfigRuleResponse(TypedDict, total=False): + ComplianceSummary: Optional[ComplianceSummary] + + +ResourceTypes = List[StringWithCharLimit256] + + +class GetComplianceSummaryByResourceTypeRequest(ServiceRequest): + ResourceTypes: Optional[ResourceTypes] + + +class GetComplianceSummaryByResourceTypeResponse(TypedDict, total=False): + ComplianceSummariesByResourceType: Optional[ComplianceSummariesByResourceType] + + +class GetConformancePackComplianceDetailsRequest(ServiceRequest): + ConformancePackName: ConformancePackName + Filters: Optional[ConformancePackEvaluationFilters] + Limit: Optional[GetConformancePackComplianceDetailsLimit] + NextToken: Optional[NextToken] + + +class GetConformancePackComplianceDetailsResponse(TypedDict, total=False): + ConformancePackName: ConformancePackName + ConformancePackRuleEvaluationResults: Optional[ConformancePackRuleEvaluationResultsList] + NextToken: Optional[NextToken] + + +class GetConformancePackComplianceSummaryRequest(ServiceRequest): + ConformancePackNames: ConformancePackNamesToSummarizeList + Limit: Optional[PageSizeLimit] + NextToken: Optional[NextToken] + + +class GetConformancePackComplianceSummaryResponse(TypedDict, total=False): + ConformancePackComplianceSummaryList: Optional[ConformancePackComplianceSummaryList] + NextToken: Optional[NextToken] + + +class GetCustomRulePolicyRequest(ServiceRequest): + ConfigRuleName: Optional[ConfigRuleName] + + +class GetCustomRulePolicyResponse(TypedDict, total=False): + PolicyText: Optional[PolicyText] + + +class GetDiscoveredResourceCountsRequest(ServiceRequest): + resourceTypes: Optional[ResourceTypes] + limit: Optional[Limit] + nextToken: Optional[NextToken] + + +class ResourceCount(TypedDict, total=False): + resourceType: Optional[ResourceType] + count: Optional[Long] + + +ResourceCounts = List[ResourceCount] + + +class GetDiscoveredResourceCountsResponse(TypedDict, total=False): + totalDiscoveredResources: Optional[Long] + resourceCounts: Optional[ResourceCounts] + nextToken: Optional[NextToken] + + +class StatusDetailFilters(TypedDict, total=False): + AccountId: Optional[AccountId] + MemberAccountRuleStatus: Optional[MemberAccountRuleStatus] + + +class GetOrganizationConfigRuleDetailedStatusRequest(ServiceRequest): + OrganizationConfigRuleName: OrganizationConfigRuleName + Filters: Optional[StatusDetailFilters] + Limit: Optional[CosmosPageLimit] + NextToken: Optional[String] + + +class MemberAccountStatus(TypedDict, total=False): + AccountId: AccountId + ConfigRuleName: StringWithCharLimit64 + MemberAccountRuleStatus: MemberAccountRuleStatus + ErrorCode: Optional[String] + ErrorMessage: Optional[String] + LastUpdateTime: Optional[Date] + + +OrganizationConfigRuleDetailedStatus = List[MemberAccountStatus] + + +class GetOrganizationConfigRuleDetailedStatusResponse(TypedDict, total=False): + OrganizationConfigRuleDetailedStatus: Optional[OrganizationConfigRuleDetailedStatus] + NextToken: Optional[String] + + +class OrganizationResourceDetailedStatusFilters(TypedDict, total=False): + AccountId: Optional[AccountId] + Status: Optional[OrganizationResourceDetailedStatus] + + +class GetOrganizationConformancePackDetailedStatusRequest(ServiceRequest): + OrganizationConformancePackName: OrganizationConformancePackName + Filters: Optional[OrganizationResourceDetailedStatusFilters] + Limit: Optional[CosmosPageLimit] + NextToken: Optional[String] + + +class OrganizationConformancePackDetailedStatus(TypedDict, total=False): + AccountId: AccountId + ConformancePackName: StringWithCharLimit256 + Status: OrganizationResourceDetailedStatus + ErrorCode: Optional[String] + ErrorMessage: Optional[String] + LastUpdateTime: Optional[Date] + + +OrganizationConformancePackDetailedStatuses = List[OrganizationConformancePackDetailedStatus] + + +class GetOrganizationConformancePackDetailedStatusResponse(TypedDict, total=False): + OrganizationConformancePackDetailedStatuses: Optional[ + OrganizationConformancePackDetailedStatuses + ] + NextToken: Optional[String] + + +class GetOrganizationCustomRulePolicyRequest(ServiceRequest): + OrganizationConfigRuleName: OrganizationConfigRuleName + + +class GetOrganizationCustomRulePolicyResponse(TypedDict, total=False): + PolicyText: Optional[PolicyText] + + +LaterTime = datetime + + +class GetResourceConfigHistoryRequest(ServiceRequest): + resourceType: ResourceType + resourceId: ResourceId + laterTime: Optional[LaterTime] + earlierTime: Optional[EarlierTime] + chronologicalOrder: Optional[ChronologicalOrder] + limit: Optional[Limit] + nextToken: Optional[NextToken] + + +class GetResourceConfigHistoryResponse(TypedDict, total=False): + configurationItems: Optional[ConfigurationItemList] + nextToken: Optional[NextToken] + + +class GetResourceEvaluationSummaryRequest(ServiceRequest): + ResourceEvaluationId: ResourceEvaluationId + + +class ResourceDetails(TypedDict, total=False): + ResourceId: BaseResourceId + ResourceType: StringWithCharLimit256 + ResourceConfiguration: ResourceConfiguration + ResourceConfigurationSchemaType: Optional[ResourceConfigurationSchemaType] + + +class GetResourceEvaluationSummaryResponse(TypedDict, total=False): + ResourceEvaluationId: Optional[ResourceEvaluationId] + EvaluationMode: Optional[EvaluationMode] + EvaluationStatus: Optional[EvaluationStatus] + EvaluationStartTimestamp: Optional[Date] + Compliance: Optional[ComplianceType] + EvaluationContext: Optional[EvaluationContext] + ResourceDetails: Optional[ResourceDetails] + + +class GetStoredQueryRequest(ServiceRequest): + QueryName: QueryName + + +class StoredQuery(TypedDict, total=False): + QueryId: Optional[QueryId] + QueryArn: Optional[QueryArn] + QueryName: QueryName + Description: Optional[QueryDescription] + Expression: Optional[QueryExpression] + + +class GetStoredQueryResponse(TypedDict, total=False): + StoredQuery: Optional[StoredQuery] + + +class ResourceFilters(TypedDict, total=False): + AccountId: Optional[AccountId] + ResourceId: Optional[ResourceId] + ResourceName: Optional[ResourceName] + Region: Optional[AwsRegion] + + +class ListAggregateDiscoveredResourcesRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + ResourceType: ResourceType + Filters: Optional[ResourceFilters] + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class ListAggregateDiscoveredResourcesResponse(TypedDict, total=False): + ResourceIdentifiers: Optional[DiscoveredResourceIdentifierList] + NextToken: Optional[NextToken] + + +class ListConfigurationRecordersRequest(ServiceRequest): + Filters: Optional[ConfigurationRecorderFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListConfigurationRecordersResponse(TypedDict, total=False): + ConfigurationRecorderSummaries: ConfigurationRecorderSummaries + NextToken: Optional[NextToken] + + +class ListConformancePackComplianceScoresRequest(ServiceRequest): + Filters: Optional[ConformancePackComplianceScoresFilters] + SortOrder: Optional[SortOrder] + SortBy: Optional[SortBy] + Limit: Optional[PageSizeLimit] + NextToken: Optional[NextToken] + + +class ListConformancePackComplianceScoresResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + ConformancePackComplianceScores: ConformancePackComplianceScores + + +ResourceIdList = List[ResourceId] + + +class ListDiscoveredResourcesRequest(ServiceRequest): + resourceType: ResourceType + resourceIds: Optional[ResourceIdList] + resourceName: Optional[ResourceName] + limit: Optional[Limit] + includeDeletedResources: Optional[Boolean] + nextToken: Optional[NextToken] + + +ResourceDeletionTime = datetime + + +class ResourceIdentifier(TypedDict, total=False): + resourceType: Optional[ResourceType] + resourceId: Optional[ResourceId] + resourceName: Optional[ResourceName] + resourceDeletionTime: Optional[ResourceDeletionTime] + + +ResourceIdentifierList = List[ResourceIdentifier] + + +class ListDiscoveredResourcesResponse(TypedDict, total=False): + resourceIdentifiers: Optional[ResourceIdentifierList] + nextToken: Optional[NextToken] + + +class TimeWindow(TypedDict, total=False): + StartTime: Optional[Date] + EndTime: Optional[Date] + + +class ResourceEvaluationFilters(TypedDict, total=False): + EvaluationMode: Optional[EvaluationMode] + TimeWindow: Optional[TimeWindow] + EvaluationContextIdentifier: Optional[EvaluationContextIdentifier] + + +class ListResourceEvaluationsRequest(ServiceRequest): + Filters: Optional[ResourceEvaluationFilters] + Limit: Optional[ListResourceEvaluationsPageItemLimit] + NextToken: Optional[String] + + +class ResourceEvaluation(TypedDict, total=False): + ResourceEvaluationId: Optional[ResourceEvaluationId] + EvaluationMode: Optional[EvaluationMode] + EvaluationStartTimestamp: Optional[Date] + + +ResourceEvaluations = List[ResourceEvaluation] + + +class ListResourceEvaluationsResponse(TypedDict, total=False): + ResourceEvaluations: Optional[ResourceEvaluations] + NextToken: Optional[String] + + +class ListStoredQueriesRequest(ServiceRequest): + NextToken: Optional[String] + MaxResults: Optional[Limit] + + +class StoredQueryMetadata(TypedDict, total=False): + QueryId: QueryId + QueryArn: QueryArn + QueryName: QueryName + Description: Optional[QueryDescription] + + +StoredQueryMetadataList = List[StoredQueryMetadata] + + +class ListStoredQueriesResponse(TypedDict, total=False): + StoredQueryMetadata: Optional[StoredQueryMetadataList] + NextToken: Optional[String] + + +class ListTagsForResourceRequest(ServiceRequest): + ResourceArn: AmazonResourceName + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class Tag(TypedDict, total=False): + Key: Optional[TagKey] + Value: Optional[TagValue] + + +TagList = List[Tag] + + +class ListTagsForResourceResponse(TypedDict, total=False): + Tags: Optional[TagList] + NextToken: Optional[NextToken] + + +class OrganizationCustomPolicyRuleMetadata(TypedDict, total=False): + Description: Optional[StringWithCharLimit256Min0] + OrganizationConfigRuleTriggerTypes: Optional[OrganizationConfigRuleTriggerTypeNoSNs] + InputParameters: Optional[StringWithCharLimit2048] + MaximumExecutionFrequency: Optional[MaximumExecutionFrequency] + ResourceTypesScope: Optional[ResourceTypesScope] + ResourceIdScope: Optional[StringWithCharLimit768] + TagKeyScope: Optional[StringWithCharLimit128] + TagValueScope: Optional[StringWithCharLimit256] + PolicyRuntime: PolicyRuntime + PolicyText: PolicyText + DebugLogDeliveryAccounts: Optional[DebugLogDeliveryAccounts] + + +TagsList = List[Tag] + + +class PutAggregationAuthorizationRequest(ServiceRequest): + AuthorizedAccountId: AccountId + AuthorizedAwsRegion: AwsRegion + Tags: Optional[TagsList] + + +class PutAggregationAuthorizationResponse(TypedDict, total=False): + AggregationAuthorization: Optional[AggregationAuthorization] + + +class PutConfigRuleRequest(ServiceRequest): + ConfigRule: ConfigRule + Tags: Optional[TagsList] + + +class PutConfigurationAggregatorRequest(ServiceRequest): + ConfigurationAggregatorName: ConfigurationAggregatorName + AccountAggregationSources: Optional[AccountAggregationSourceList] + OrganizationAggregationSource: Optional[OrganizationAggregationSource] + Tags: Optional[TagsList] + AggregatorFilters: Optional[AggregatorFilters] + + +class PutConfigurationAggregatorResponse(TypedDict, total=False): + ConfigurationAggregator: Optional[ConfigurationAggregator] + + +class PutConfigurationRecorderRequest(ServiceRequest): + ConfigurationRecorder: ConfigurationRecorder + Tags: Optional[TagsList] + + +class PutConformancePackRequest(ServiceRequest): + ConformancePackName: ConformancePackName + TemplateS3Uri: Optional[TemplateS3Uri] + TemplateBody: Optional[TemplateBody] + DeliveryS3Bucket: Optional[DeliveryS3Bucket] + DeliveryS3KeyPrefix: Optional[DeliveryS3KeyPrefix] + ConformancePackInputParameters: Optional[ConformancePackInputParameters] + TemplateSSMDocumentDetails: Optional[TemplateSSMDocumentDetails] + + +class PutConformancePackResponse(TypedDict, total=False): + ConformancePackArn: Optional[ConformancePackArn] + + +class PutDeliveryChannelRequest(ServiceRequest): + DeliveryChannel: DeliveryChannel + + +class PutEvaluationsRequest(ServiceRequest): + Evaluations: Optional[Evaluations] + ResultToken: String + TestMode: Optional[Boolean] + + +class PutEvaluationsResponse(TypedDict, total=False): + FailedEvaluations: Optional[Evaluations] + + +class PutExternalEvaluationRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + ExternalEvaluation: ExternalEvaluation + + +class PutExternalEvaluationResponse(TypedDict, total=False): + pass + + +class PutOrganizationConfigRuleRequest(ServiceRequest): + OrganizationConfigRuleName: OrganizationConfigRuleName + OrganizationManagedRuleMetadata: Optional[OrganizationManagedRuleMetadata] + OrganizationCustomRuleMetadata: Optional[OrganizationCustomRuleMetadata] + ExcludedAccounts: Optional[ExcludedAccounts] + OrganizationCustomPolicyRuleMetadata: Optional[OrganizationCustomPolicyRuleMetadata] + + +class PutOrganizationConfigRuleResponse(TypedDict, total=False): + OrganizationConfigRuleArn: Optional[StringWithCharLimit256] + + +class PutOrganizationConformancePackRequest(ServiceRequest): + OrganizationConformancePackName: OrganizationConformancePackName + TemplateS3Uri: Optional[TemplateS3Uri] + TemplateBody: Optional[TemplateBody] + DeliveryS3Bucket: Optional[DeliveryS3Bucket] + DeliveryS3KeyPrefix: Optional[DeliveryS3KeyPrefix] + ConformancePackInputParameters: Optional[ConformancePackInputParameters] + ExcludedAccounts: Optional[ExcludedAccounts] + + +class PutOrganizationConformancePackResponse(TypedDict, total=False): + OrganizationConformancePackArn: Optional[StringWithCharLimit256] + + +class PutRemediationConfigurationsRequest(ServiceRequest): + RemediationConfigurations: RemediationConfigurations + + +class PutRemediationConfigurationsResponse(TypedDict, total=False): + FailedBatches: Optional[FailedRemediationBatches] + + +class PutRemediationExceptionsRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + ResourceKeys: RemediationExceptionResourceKeys + Message: Optional[StringWithCharLimit1024] + ExpirationTime: Optional[Date] + + +class PutRemediationExceptionsResponse(TypedDict, total=False): + FailedBatches: Optional[FailedRemediationExceptionBatches] + + +class PutResourceConfigRequest(ServiceRequest): + ResourceType: ResourceTypeString + SchemaVersionId: SchemaVersionId + ResourceId: ResourceId + ResourceName: Optional[ResourceName] + Configuration: Configuration + Tags: Optional[Tags] + + +class PutRetentionConfigurationRequest(ServiceRequest): + RetentionPeriodInDays: RetentionPeriodInDays + + +class PutRetentionConfigurationResponse(TypedDict, total=False): + RetentionConfiguration: Optional[RetentionConfiguration] + + +class PutServiceLinkedConfigurationRecorderRequest(ServiceRequest): + ServicePrincipal: ServicePrincipal + Tags: Optional[TagsList] + + +class PutServiceLinkedConfigurationRecorderResponse(TypedDict, total=False): + Arn: Optional[AmazonResourceName] + Name: Optional[RecorderName] + + +class PutStoredQueryRequest(ServiceRequest): + StoredQuery: StoredQuery + Tags: Optional[TagsList] + + +class PutStoredQueryResponse(TypedDict, total=False): + QueryArn: Optional[QueryArn] + + +class QueryInfo(TypedDict, total=False): + SelectFields: Optional[FieldInfoList] + + +ReevaluateConfigRuleNames = List[ConfigRuleName] +Results = List[String] + + +class SelectAggregateResourceConfigRequest(ServiceRequest): + Expression: Expression + ConfigurationAggregatorName: ConfigurationAggregatorName + Limit: Optional[Limit] + MaxResults: Optional[Limit] + NextToken: Optional[NextToken] + + +class SelectAggregateResourceConfigResponse(TypedDict, total=False): + Results: Optional[Results] + QueryInfo: Optional[QueryInfo] + NextToken: Optional[NextToken] + + +class SelectResourceConfigRequest(ServiceRequest): + Expression: Expression + Limit: Optional[Limit] + NextToken: Optional[NextToken] + + +class SelectResourceConfigResponse(TypedDict, total=False): + Results: Optional[Results] + QueryInfo: Optional[QueryInfo] + NextToken: Optional[NextToken] + + +class StartConfigRulesEvaluationRequest(ServiceRequest): + ConfigRuleNames: Optional[ReevaluateConfigRuleNames] + + +class StartConfigRulesEvaluationResponse(TypedDict, total=False): + pass + + +class StartConfigurationRecorderRequest(ServiceRequest): + ConfigurationRecorderName: RecorderName + + +class StartRemediationExecutionRequest(ServiceRequest): + ConfigRuleName: ConfigRuleName + ResourceKeys: ResourceKeys + + +class StartRemediationExecutionResponse(TypedDict, total=False): + FailureMessage: Optional[String] + FailedItems: Optional[ResourceKeys] + + +class StartResourceEvaluationRequest(ServiceRequest): + ResourceDetails: ResourceDetails + EvaluationContext: Optional[EvaluationContext] + EvaluationMode: EvaluationMode + EvaluationTimeout: Optional[EvaluationTimeout] + ClientToken: Optional[ClientToken] + + +class StartResourceEvaluationResponse(TypedDict, total=False): + ResourceEvaluationId: Optional[ResourceEvaluationId] + + +class StopConfigurationRecorderRequest(ServiceRequest): + ConfigurationRecorderName: RecorderName + + +TagKeyList = List[TagKey] + + +class TagResourceRequest(ServiceRequest): + ResourceArn: AmazonResourceName + Tags: TagList + + +class UntagResourceRequest(ServiceRequest): + ResourceArn: AmazonResourceName + TagKeys: TagKeyList + + +class ConfigApi: + service = "config" + version = "2014-11-12" + + @handler("AssociateResourceTypes") + def associate_resource_types( + self, + context: RequestContext, + configuration_recorder_arn: AmazonResourceName, + resource_types: ResourceTypeList, + **kwargs, + ) -> AssociateResourceTypesResponse: + raise NotImplementedError + + @handler("BatchGetAggregateResourceConfig") + def batch_get_aggregate_resource_config( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + resource_identifiers: ResourceIdentifiersList, + **kwargs, + ) -> BatchGetAggregateResourceConfigResponse: + raise NotImplementedError + + @handler("BatchGetResourceConfig") + def batch_get_resource_config( + self, context: RequestContext, resource_keys: ResourceKeys, **kwargs + ) -> BatchGetResourceConfigResponse: + raise NotImplementedError + + @handler("DeleteAggregationAuthorization") + def delete_aggregation_authorization( + self, + context: RequestContext, + authorized_account_id: AccountId, + authorized_aws_region: AwsRegion, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteConfigRule") + def delete_config_rule( + self, context: RequestContext, config_rule_name: ConfigRuleName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteConfigurationAggregator") + def delete_configuration_aggregator( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteConfigurationRecorder") + def delete_configuration_recorder( + self, context: RequestContext, configuration_recorder_name: RecorderName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteConformancePack") + def delete_conformance_pack( + self, context: RequestContext, conformance_pack_name: ConformancePackName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDeliveryChannel") + def delete_delivery_channel( + self, context: RequestContext, delivery_channel_name: ChannelName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteEvaluationResults") + def delete_evaluation_results( + self, context: RequestContext, config_rule_name: StringWithCharLimit64, **kwargs + ) -> DeleteEvaluationResultsResponse: + raise NotImplementedError + + @handler("DeleteOrganizationConfigRule") + def delete_organization_config_rule( + self, + context: RequestContext, + organization_config_rule_name: OrganizationConfigRuleName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteOrganizationConformancePack") + def delete_organization_conformance_pack( + self, + context: RequestContext, + organization_conformance_pack_name: OrganizationConformancePackName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeletePendingAggregationRequest") + def delete_pending_aggregation_request( + self, + context: RequestContext, + requester_account_id: AccountId, + requester_aws_region: AwsRegion, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteRemediationConfiguration") + def delete_remediation_configuration( + self, + context: RequestContext, + config_rule_name: ConfigRuleName, + resource_type: String | None = None, + **kwargs, + ) -> DeleteRemediationConfigurationResponse: + raise NotImplementedError + + @handler("DeleteRemediationExceptions") + def delete_remediation_exceptions( + self, + context: RequestContext, + config_rule_name: ConfigRuleName, + resource_keys: RemediationExceptionResourceKeys, + **kwargs, + ) -> DeleteRemediationExceptionsResponse: + raise NotImplementedError + + @handler("DeleteResourceConfig") + def delete_resource_config( + self, + context: RequestContext, + resource_type: ResourceTypeString, + resource_id: ResourceId, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteRetentionConfiguration") + def delete_retention_configuration( + self, + context: RequestContext, + retention_configuration_name: RetentionConfigurationName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteServiceLinkedConfigurationRecorder") + def delete_service_linked_configuration_recorder( + self, context: RequestContext, service_principal: ServicePrincipal, **kwargs + ) -> DeleteServiceLinkedConfigurationRecorderResponse: + raise NotImplementedError + + @handler("DeleteStoredQuery") + def delete_stored_query( + self, context: RequestContext, query_name: QueryName, **kwargs + ) -> DeleteStoredQueryResponse: + raise NotImplementedError + + @handler("DeliverConfigSnapshot") + def deliver_config_snapshot( + self, context: RequestContext, delivery_channel_name: ChannelName, **kwargs + ) -> DeliverConfigSnapshotResponse: + raise NotImplementedError + + @handler("DescribeAggregateComplianceByConfigRules") + def describe_aggregate_compliance_by_config_rules( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + filters: ConfigRuleComplianceFilters | None = None, + limit: GroupByAPILimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAggregateComplianceByConfigRulesResponse: + raise NotImplementedError + + @handler("DescribeAggregateComplianceByConformancePacks") + def describe_aggregate_compliance_by_conformance_packs( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + filters: AggregateConformancePackComplianceFilters | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAggregateComplianceByConformancePacksResponse: + raise NotImplementedError + + @handler("DescribeAggregationAuthorizations") + def describe_aggregation_authorizations( + self, + context: RequestContext, + limit: Limit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeAggregationAuthorizationsResponse: + raise NotImplementedError + + @handler("DescribeComplianceByConfigRule") + def describe_compliance_by_config_rule( + self, + context: RequestContext, + config_rule_names: ConfigRuleNames | None = None, + compliance_types: ComplianceTypes | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeComplianceByConfigRuleResponse: + raise NotImplementedError + + @handler("DescribeComplianceByResource") + def describe_compliance_by_resource( + self, + context: RequestContext, + resource_type: StringWithCharLimit256 | None = None, + resource_id: BaseResourceId | None = None, + compliance_types: ComplianceTypes | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeComplianceByResourceResponse: + raise NotImplementedError + + @handler("DescribeConfigRuleEvaluationStatus") + def describe_config_rule_evaluation_status( + self, + context: RequestContext, + config_rule_names: ConfigRuleNames | None = None, + next_token: String | None = None, + limit: RuleLimit | None = None, + **kwargs, + ) -> DescribeConfigRuleEvaluationStatusResponse: + raise NotImplementedError + + @handler("DescribeConfigRules") + def describe_config_rules( + self, + context: RequestContext, + config_rule_names: ConfigRuleNames | None = None, + next_token: String | None = None, + filters: DescribeConfigRulesFilters | None = None, + **kwargs, + ) -> DescribeConfigRulesResponse: + raise NotImplementedError + + @handler("DescribeConfigurationAggregatorSourcesStatus") + def describe_configuration_aggregator_sources_status( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + update_status: AggregatedSourceStatusTypeList | None = None, + next_token: String | None = None, + limit: Limit | None = None, + **kwargs, + ) -> DescribeConfigurationAggregatorSourcesStatusResponse: + raise NotImplementedError + + @handler("DescribeConfigurationAggregators") + def describe_configuration_aggregators( + self, + context: RequestContext, + configuration_aggregator_names: ConfigurationAggregatorNameList | None = None, + next_token: String | None = None, + limit: Limit | None = None, + **kwargs, + ) -> DescribeConfigurationAggregatorsResponse: + raise NotImplementedError + + @handler("DescribeConfigurationRecorderStatus") + def describe_configuration_recorder_status( + self, + context: RequestContext, + configuration_recorder_names: ConfigurationRecorderNameList | None = None, + service_principal: ServicePrincipal | None = None, + arn: AmazonResourceName | None = None, + **kwargs, + ) -> DescribeConfigurationRecorderStatusResponse: + raise NotImplementedError + + @handler("DescribeConfigurationRecorders") + def describe_configuration_recorders( + self, + context: RequestContext, + configuration_recorder_names: ConfigurationRecorderNameList | None = None, + service_principal: ServicePrincipal | None = None, + arn: AmazonResourceName | None = None, + **kwargs, + ) -> DescribeConfigurationRecordersResponse: + raise NotImplementedError + + @handler("DescribeConformancePackCompliance") + def describe_conformance_pack_compliance( + self, + context: RequestContext, + conformance_pack_name: ConformancePackName, + filters: ConformancePackComplianceFilters | None = None, + limit: DescribeConformancePackComplianceLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeConformancePackComplianceResponse: + raise NotImplementedError + + @handler("DescribeConformancePackStatus") + def describe_conformance_pack_status( + self, + context: RequestContext, + conformance_pack_names: ConformancePackNamesList | None = None, + limit: PageSizeLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeConformancePackStatusResponse: + raise NotImplementedError + + @handler("DescribeConformancePacks") + def describe_conformance_packs( + self, + context: RequestContext, + conformance_pack_names: ConformancePackNamesList | None = None, + limit: PageSizeLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeConformancePacksResponse: + raise NotImplementedError + + @handler("DescribeDeliveryChannelStatus") + def describe_delivery_channel_status( + self, + context: RequestContext, + delivery_channel_names: DeliveryChannelNameList | None = None, + **kwargs, + ) -> DescribeDeliveryChannelStatusResponse: + raise NotImplementedError + + @handler("DescribeDeliveryChannels") + def describe_delivery_channels( + self, + context: RequestContext, + delivery_channel_names: DeliveryChannelNameList | None = None, + **kwargs, + ) -> DescribeDeliveryChannelsResponse: + raise NotImplementedError + + @handler("DescribeOrganizationConfigRuleStatuses") + def describe_organization_config_rule_statuses( + self, + context: RequestContext, + organization_config_rule_names: OrganizationConfigRuleNames | None = None, + limit: CosmosPageLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeOrganizationConfigRuleStatusesResponse: + raise NotImplementedError + + @handler("DescribeOrganizationConfigRules") + def describe_organization_config_rules( + self, + context: RequestContext, + organization_config_rule_names: OrganizationConfigRuleNames | None = None, + limit: CosmosPageLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeOrganizationConfigRulesResponse: + raise NotImplementedError + + @handler("DescribeOrganizationConformancePackStatuses") + def describe_organization_conformance_pack_statuses( + self, + context: RequestContext, + organization_conformance_pack_names: OrganizationConformancePackNames | None = None, + limit: CosmosPageLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeOrganizationConformancePackStatusesResponse: + raise NotImplementedError + + @handler("DescribeOrganizationConformancePacks") + def describe_organization_conformance_packs( + self, + context: RequestContext, + organization_conformance_pack_names: OrganizationConformancePackNames | None = None, + limit: CosmosPageLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeOrganizationConformancePacksResponse: + raise NotImplementedError + + @handler("DescribePendingAggregationRequests") + def describe_pending_aggregation_requests( + self, + context: RequestContext, + limit: DescribePendingAggregationRequestsLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribePendingAggregationRequestsResponse: + raise NotImplementedError + + @handler("DescribeRemediationConfigurations") + def describe_remediation_configurations( + self, context: RequestContext, config_rule_names: ConfigRuleNames, **kwargs + ) -> DescribeRemediationConfigurationsResponse: + raise NotImplementedError + + @handler("DescribeRemediationExceptions") + def describe_remediation_exceptions( + self, + context: RequestContext, + config_rule_name: ConfigRuleName, + resource_keys: RemediationExceptionResourceKeys | None = None, + limit: Limit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeRemediationExceptionsResponse: + raise NotImplementedError + + @handler("DescribeRemediationExecutionStatus") + def describe_remediation_execution_status( + self, + context: RequestContext, + config_rule_name: ConfigRuleName, + resource_keys: ResourceKeys | None = None, + limit: Limit | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeRemediationExecutionStatusResponse: + raise NotImplementedError + + @handler("DescribeRetentionConfigurations") + def describe_retention_configurations( + self, + context: RequestContext, + retention_configuration_names: RetentionConfigurationNameList | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeRetentionConfigurationsResponse: + raise NotImplementedError + + @handler("DisassociateResourceTypes") + def disassociate_resource_types( + self, + context: RequestContext, + configuration_recorder_arn: AmazonResourceName, + resource_types: ResourceTypeList, + **kwargs, + ) -> DisassociateResourceTypesResponse: + raise NotImplementedError + + @handler("GetAggregateComplianceDetailsByConfigRule") + def get_aggregate_compliance_details_by_config_rule( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + config_rule_name: ConfigRuleName, + account_id: AccountId, + aws_region: AwsRegion, + compliance_type: ComplianceType | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetAggregateComplianceDetailsByConfigRuleResponse: + raise NotImplementedError + + @handler("GetAggregateConfigRuleComplianceSummary") + def get_aggregate_config_rule_compliance_summary( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + filters: ConfigRuleComplianceSummaryFilters | None = None, + group_by_key: ConfigRuleComplianceSummaryGroupKey | None = None, + limit: GroupByAPILimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetAggregateConfigRuleComplianceSummaryResponse: + raise NotImplementedError + + @handler("GetAggregateConformancePackComplianceSummary") + def get_aggregate_conformance_pack_compliance_summary( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + filters: AggregateConformancePackComplianceSummaryFilters | None = None, + group_by_key: AggregateConformancePackComplianceSummaryGroupKey | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetAggregateConformancePackComplianceSummaryResponse: + raise NotImplementedError + + @handler("GetAggregateDiscoveredResourceCounts") + def get_aggregate_discovered_resource_counts( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + filters: ResourceCountFilters | None = None, + group_by_key: ResourceCountGroupKey | None = None, + limit: GroupByAPILimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetAggregateDiscoveredResourceCountsResponse: + raise NotImplementedError + + @handler("GetAggregateResourceConfig") + def get_aggregate_resource_config( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + resource_identifier: AggregateResourceIdentifier, + **kwargs, + ) -> GetAggregateResourceConfigResponse: + raise NotImplementedError + + @handler("GetComplianceDetailsByConfigRule") + def get_compliance_details_by_config_rule( + self, + context: RequestContext, + config_rule_name: StringWithCharLimit64, + compliance_types: ComplianceTypes | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetComplianceDetailsByConfigRuleResponse: + raise NotImplementedError + + @handler("GetComplianceDetailsByResource") + def get_compliance_details_by_resource( + self, + context: RequestContext, + resource_type: StringWithCharLimit256 | None = None, + resource_id: BaseResourceId | None = None, + compliance_types: ComplianceTypes | None = None, + next_token: String | None = None, + resource_evaluation_id: ResourceEvaluationId | None = None, + **kwargs, + ) -> GetComplianceDetailsByResourceResponse: + raise NotImplementedError + + @handler("GetComplianceSummaryByConfigRule") + def get_compliance_summary_by_config_rule( + self, context: RequestContext, **kwargs + ) -> GetComplianceSummaryByConfigRuleResponse: + raise NotImplementedError + + @handler("GetComplianceSummaryByResourceType") + def get_compliance_summary_by_resource_type( + self, context: RequestContext, resource_types: ResourceTypes | None = None, **kwargs + ) -> GetComplianceSummaryByResourceTypeResponse: + raise NotImplementedError + + @handler("GetConformancePackComplianceDetails") + def get_conformance_pack_compliance_details( + self, + context: RequestContext, + conformance_pack_name: ConformancePackName, + filters: ConformancePackEvaluationFilters | None = None, + limit: GetConformancePackComplianceDetailsLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetConformancePackComplianceDetailsResponse: + raise NotImplementedError + + @handler("GetConformancePackComplianceSummary") + def get_conformance_pack_compliance_summary( + self, + context: RequestContext, + conformance_pack_names: ConformancePackNamesToSummarizeList, + limit: PageSizeLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetConformancePackComplianceSummaryResponse: + raise NotImplementedError + + @handler("GetCustomRulePolicy") + def get_custom_rule_policy( + self, context: RequestContext, config_rule_name: ConfigRuleName | None = None, **kwargs + ) -> GetCustomRulePolicyResponse: + raise NotImplementedError + + @handler("GetDiscoveredResourceCounts") + def get_discovered_resource_counts( + self, + context: RequestContext, + resource_types: ResourceTypes | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetDiscoveredResourceCountsResponse: + raise NotImplementedError + + @handler("GetOrganizationConfigRuleDetailedStatus") + def get_organization_config_rule_detailed_status( + self, + context: RequestContext, + organization_config_rule_name: OrganizationConfigRuleName, + filters: StatusDetailFilters | None = None, + limit: CosmosPageLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> GetOrganizationConfigRuleDetailedStatusResponse: + raise NotImplementedError + + @handler("GetOrganizationConformancePackDetailedStatus") + def get_organization_conformance_pack_detailed_status( + self, + context: RequestContext, + organization_conformance_pack_name: OrganizationConformancePackName, + filters: OrganizationResourceDetailedStatusFilters | None = None, + limit: CosmosPageLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> GetOrganizationConformancePackDetailedStatusResponse: + raise NotImplementedError + + @handler("GetOrganizationCustomRulePolicy") + def get_organization_custom_rule_policy( + self, + context: RequestContext, + organization_config_rule_name: OrganizationConfigRuleName, + **kwargs, + ) -> GetOrganizationCustomRulePolicyResponse: + raise NotImplementedError + + @handler("GetResourceConfigHistory") + def get_resource_config_history( + self, + context: RequestContext, + resource_type: ResourceType, + resource_id: ResourceId, + later_time: LaterTime | None = None, + earlier_time: EarlierTime | None = None, + chronological_order: ChronologicalOrder | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetResourceConfigHistoryResponse: + raise NotImplementedError + + @handler("GetResourceEvaluationSummary") + def get_resource_evaluation_summary( + self, context: RequestContext, resource_evaluation_id: ResourceEvaluationId, **kwargs + ) -> GetResourceEvaluationSummaryResponse: + raise NotImplementedError + + @handler("GetStoredQuery") + def get_stored_query( + self, context: RequestContext, query_name: QueryName, **kwargs + ) -> GetStoredQueryResponse: + raise NotImplementedError + + @handler("ListAggregateDiscoveredResources") + def list_aggregate_discovered_resources( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + resource_type: ResourceType, + filters: ResourceFilters | None = None, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListAggregateDiscoveredResourcesResponse: + raise NotImplementedError + + @handler("ListConfigurationRecorders") + def list_configuration_recorders( + self, + context: RequestContext, + filters: ConfigurationRecorderFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListConfigurationRecordersResponse: + raise NotImplementedError + + @handler("ListConformancePackComplianceScores") + def list_conformance_pack_compliance_scores( + self, + context: RequestContext, + filters: ConformancePackComplianceScoresFilters | None = None, + sort_order: SortOrder | None = None, + sort_by: SortBy | None = None, + limit: PageSizeLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListConformancePackComplianceScoresResponse: + raise NotImplementedError + + @handler("ListDiscoveredResources") + def list_discovered_resources( + self, + context: RequestContext, + resource_type: ResourceType, + resource_ids: ResourceIdList | None = None, + resource_name: ResourceName | None = None, + limit: Limit | None = None, + include_deleted_resources: Boolean | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListDiscoveredResourcesResponse: + raise NotImplementedError + + @handler("ListResourceEvaluations") + def list_resource_evaluations( + self, + context: RequestContext, + filters: ResourceEvaluationFilters | None = None, + limit: ListResourceEvaluationsPageItemLimit | None = None, + next_token: String | None = None, + **kwargs, + ) -> ListResourceEvaluationsResponse: + raise NotImplementedError + + @handler("ListStoredQueries") + def list_stored_queries( + self, + context: RequestContext, + next_token: String | None = None, + max_results: Limit | None = None, + **kwargs, + ) -> ListStoredQueriesResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("PutAggregationAuthorization") + def put_aggregation_authorization( + self, + context: RequestContext, + authorized_account_id: AccountId, + authorized_aws_region: AwsRegion, + tags: TagsList | None = None, + **kwargs, + ) -> PutAggregationAuthorizationResponse: + raise NotImplementedError + + @handler("PutConfigRule") + def put_config_rule( + self, + context: RequestContext, + config_rule: ConfigRule, + tags: TagsList | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutConfigurationAggregator") + def put_configuration_aggregator( + self, + context: RequestContext, + configuration_aggregator_name: ConfigurationAggregatorName, + account_aggregation_sources: AccountAggregationSourceList | None = None, + organization_aggregation_source: OrganizationAggregationSource | None = None, + tags: TagsList | None = None, + aggregator_filters: AggregatorFilters | None = None, + **kwargs, + ) -> PutConfigurationAggregatorResponse: + raise NotImplementedError + + @handler("PutConfigurationRecorder") + def put_configuration_recorder( + self, + context: RequestContext, + configuration_recorder: ConfigurationRecorder, + tags: TagsList | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutConformancePack") + def put_conformance_pack( + self, + context: RequestContext, + conformance_pack_name: ConformancePackName, + template_s3_uri: TemplateS3Uri | None = None, + template_body: TemplateBody | None = None, + delivery_s3_bucket: DeliveryS3Bucket | None = None, + delivery_s3_key_prefix: DeliveryS3KeyPrefix | None = None, + conformance_pack_input_parameters: ConformancePackInputParameters | None = None, + template_ssm_document_details: TemplateSSMDocumentDetails | None = None, + **kwargs, + ) -> PutConformancePackResponse: + raise NotImplementedError + + @handler("PutDeliveryChannel") + def put_delivery_channel( + self, context: RequestContext, delivery_channel: DeliveryChannel, **kwargs + ) -> None: + raise NotImplementedError + + @handler("PutEvaluations") + def put_evaluations( + self, + context: RequestContext, + result_token: String, + evaluations: Evaluations | None = None, + test_mode: Boolean | None = None, + **kwargs, + ) -> PutEvaluationsResponse: + raise NotImplementedError + + @handler("PutExternalEvaluation") + def put_external_evaluation( + self, + context: RequestContext, + config_rule_name: ConfigRuleName, + external_evaluation: ExternalEvaluation, + **kwargs, + ) -> PutExternalEvaluationResponse: + raise NotImplementedError + + @handler("PutOrganizationConfigRule") + def put_organization_config_rule( + self, + context: RequestContext, + organization_config_rule_name: OrganizationConfigRuleName, + organization_managed_rule_metadata: OrganizationManagedRuleMetadata | None = None, + organization_custom_rule_metadata: OrganizationCustomRuleMetadata | None = None, + excluded_accounts: ExcludedAccounts | None = None, + organization_custom_policy_rule_metadata: OrganizationCustomPolicyRuleMetadata + | None = None, + **kwargs, + ) -> PutOrganizationConfigRuleResponse: + raise NotImplementedError + + @handler("PutOrganizationConformancePack") + def put_organization_conformance_pack( + self, + context: RequestContext, + organization_conformance_pack_name: OrganizationConformancePackName, + template_s3_uri: TemplateS3Uri | None = None, + template_body: TemplateBody | None = None, + delivery_s3_bucket: DeliveryS3Bucket | None = None, + delivery_s3_key_prefix: DeliveryS3KeyPrefix | None = None, + conformance_pack_input_parameters: ConformancePackInputParameters | None = None, + excluded_accounts: ExcludedAccounts | None = None, + **kwargs, + ) -> PutOrganizationConformancePackResponse: + raise NotImplementedError + + @handler("PutRemediationConfigurations") + def put_remediation_configurations( + self, + context: RequestContext, + remediation_configurations: RemediationConfigurations, + **kwargs, + ) -> PutRemediationConfigurationsResponse: + raise NotImplementedError + + @handler("PutRemediationExceptions") + def put_remediation_exceptions( + self, + context: RequestContext, + config_rule_name: ConfigRuleName, + resource_keys: RemediationExceptionResourceKeys, + message: StringWithCharLimit1024 | None = None, + expiration_time: Date | None = None, + **kwargs, + ) -> PutRemediationExceptionsResponse: + raise NotImplementedError + + @handler("PutResourceConfig") + def put_resource_config( + self, + context: RequestContext, + resource_type: ResourceTypeString, + schema_version_id: SchemaVersionId, + resource_id: ResourceId, + configuration: Configuration, + resource_name: ResourceName | None = None, + tags: Tags | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutRetentionConfiguration") + def put_retention_configuration( + self, context: RequestContext, retention_period_in_days: RetentionPeriodInDays, **kwargs + ) -> PutRetentionConfigurationResponse: + raise NotImplementedError + + @handler("PutServiceLinkedConfigurationRecorder") + def put_service_linked_configuration_recorder( + self, + context: RequestContext, + service_principal: ServicePrincipal, + tags: TagsList | None = None, + **kwargs, + ) -> PutServiceLinkedConfigurationRecorderResponse: + raise NotImplementedError + + @handler("PutStoredQuery") + def put_stored_query( + self, + context: RequestContext, + stored_query: StoredQuery, + tags: TagsList | None = None, + **kwargs, + ) -> PutStoredQueryResponse: + raise NotImplementedError + + @handler("SelectAggregateResourceConfig") + def select_aggregate_resource_config( + self, + context: RequestContext, + expression: Expression, + configuration_aggregator_name: ConfigurationAggregatorName, + limit: Limit | None = None, + max_results: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> SelectAggregateResourceConfigResponse: + raise NotImplementedError + + @handler("SelectResourceConfig") + def select_resource_config( + self, + context: RequestContext, + expression: Expression, + limit: Limit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> SelectResourceConfigResponse: + raise NotImplementedError + + @handler("StartConfigRulesEvaluation") + def start_config_rules_evaluation( + self, + context: RequestContext, + config_rule_names: ReevaluateConfigRuleNames | None = None, + **kwargs, + ) -> StartConfigRulesEvaluationResponse: + raise NotImplementedError + + @handler("StartConfigurationRecorder") + def start_configuration_recorder( + self, context: RequestContext, configuration_recorder_name: RecorderName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("StartRemediationExecution") + def start_remediation_execution( + self, + context: RequestContext, + config_rule_name: ConfigRuleName, + resource_keys: ResourceKeys, + **kwargs, + ) -> StartRemediationExecutionResponse: + raise NotImplementedError + + @handler("StartResourceEvaluation") + def start_resource_evaluation( + self, + context: RequestContext, + resource_details: ResourceDetails, + evaluation_mode: EvaluationMode, + evaluation_context: EvaluationContext | None = None, + evaluation_timeout: EvaluationTimeout | None = None, + client_token: ClientToken | None = None, + **kwargs, + ) -> StartResourceEvaluationResponse: + raise NotImplementedError + + @handler("StopConfigurationRecorder") + def stop_configuration_recorder( + self, context: RequestContext, configuration_recorder_name: RecorderName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: TagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> None: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/core.py b/localstack-core/localstack/aws/api/core.py new file mode 100644 index 0000000000000..dbe32d7973284 --- /dev/null +++ b/localstack-core/localstack/aws/api/core.py @@ -0,0 +1,186 @@ +import functools +from typing import ( + Any, + Callable, + NamedTuple, + ParamSpec, + Protocol, + Type, + TypedDict, + TypeVar, +) + +from botocore.model import OperationModel, ServiceModel +from rolo.gateway import RequestContext as RoloRequestContext + +from localstack.aws.connect import InternalRequestParameters +from localstack.http import Request, Response +from localstack.utils.strings import long_uid + + +class ServiceRequest(TypedDict): + pass + + +P = ParamSpec("P") +T = TypeVar("T") + + +ServiceResponse = Any + + +class ServiceException(Exception): + """ + An exception that indicates that a service error occurred. + These exceptions, when raised during the execution of a service function, will be serialized and sent to the client. + Do not use this exception directly (use the generated subclasses or CommonsServiceException instead). + """ + + code: str + status_code: int + sender_fault: bool + message: str + + def __init__(self, *args: Any, **kwargs: Any): + super(ServiceException, self).__init__(*args) + + if len(args) >= 1: + self.message = args[0] + else: + self.message = "" + for key, value in kwargs.items(): + setattr(self, key, value) + + +class CommonServiceException(ServiceException): + """ + An exception which can be raised within a service during its execution, even if it is not specified (i.e. it's not + generated based on the service specification). + In the AWS API references, this kind of errors are usually referred to as "Common Errors", f.e.: + https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/CommonErrors.html + """ + + def __init__(self, code: str, message: str, status_code: int = 400, sender_fault: bool = False): + super(CommonServiceException, self).__init__(message) + self.code = code + self.status_code = status_code + self.sender_fault = sender_fault + + +Operation = Type[ServiceRequest] + + +class ServiceOperation(NamedTuple): + service: str + operation: str + + +class RequestContext(RoloRequestContext): + """ + A RequestContext object holds the information of an HTTP request that is processed by the LocalStack Gateway. The + context holds information about the request, such as which AWS service the request is made to, which operation is + being invoked, and other metadata such as the account or the region. The context is continuously populated as the + request moves through the handler chain. Once the HTTP request has been parsed, the context also holds the parsed + request parameters of the AWS API call. The handler chain may also add the AWS response from the backend to the + context, so it can be used for logging or modification before going to the serializer. + """ + + request: Request + """The underlying incoming HTTP request.""" + service: ServiceModel | None + """The botocore ServiceModel of the service the request is made to.""" + operation: OperationModel | None + """The botocore OperationModel of the AWS operation being invoked.""" + region: str + """The region the request is made to.""" + partition: str + """The partition the request is made to.""" + account_id: str + """The account the request is made from.""" + request_id: str | None + """The autogenerated AWS request ID identifying the original request""" + service_request: ServiceRequest | None + """The AWS operation parameters.""" + service_response: ServiceResponse | None + """The response from the AWS emulator backend.""" + service_exception: ServiceException | None + """The exception the AWS emulator backend may have raised.""" + internal_request_params: InternalRequestParameters | None + """Data sent by client-side LocalStack during internal calls.""" + trace_context: dict[str, Any] + """Tracing metadata such as X-Ray trace headers""" + + def __init__(self, request: Request): + super().__init__(request) + self.service = None + self.operation = None + self.region = None # type: ignore[assignment] # type=str, because we know it will always be set downstream + self.partition = "aws" # Sensible default - will be overwritten by region-handler + self.account_id = None # type: ignore[assignment] # type=str, because we know it will always be set downstream + self.request_id = long_uid() + self.service_request = None + self.service_response = None + self.service_exception = None + self.trace_context = {} + self.internal_request_params = None + + @property + def is_internal_call(self) -> bool: + """ + Whether this request is an internal cross-service call. + """ + return self.internal_request_params is not None + + @property + def service_operation(self) -> ServiceOperation | None: + """ + If both the service model and the operation model are set, this returns a tuple of the service name and + operation name. + + :return: a tuple like ("s3", "PutObject") or ("lambda", "CreateFunction") + """ + if not self.service or not self.operation: + return None + return ServiceOperation(self.service.service_name, self.operation.name) + + def __repr__(self) -> str: + return f"<RequestContext {self.service=}, {self.operation=}, {self.region=}, {self.account_id=}, {self.request=}>" + + +class ServiceRequestHandler(Protocol): + """ + A protocol to describe a Request--Response handler that processes an AWS API call with the already parsed request. + """ + + def __call__( + self, context: RequestContext, request: ServiceRequest + ) -> ServiceResponse | Response | None: + """ + Handle the given request. + + :param context: the request context + :param request: the request parameters, e.g., ``{"Bucket": "my-bucket-name"}`` for an s3 create bucket operation + :return: either an already serialized HTTP Response object, or a service response dictionary. + """ + raise NotImplementedError + + +def handler( + operation: str | None = None, context: bool = True, expand: bool = True +) -> Callable[[Callable[P, T]], Callable[P, T]]: + """ + Decorator that indicates that the given function is a handler + """ + + def wrapper(fn: Callable[P, T]) -> Callable[P, T]: + @functools.wraps(fn) + def operation_marker(*args: P.args, **kwargs: P.kwargs) -> T: + return fn(*args, **kwargs) + + operation_marker.operation = operation # type: ignore[attr-defined] + operation_marker.expand_parameters = expand # type: ignore[attr-defined] + operation_marker.pass_context = context # type: ignore[attr-defined] + + return operation_marker + + return wrapper diff --git a/localstack-core/localstack/aws/api/dynamodb/__init__.py b/localstack-core/localstack/aws/api/dynamodb/__init__.py new file mode 100644 index 0000000000000..abb79fbbad4b9 --- /dev/null +++ b/localstack-core/localstack/aws/api/dynamodb/__init__.py @@ -0,0 +1,2965 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ArchivalReason = str +AttributeName = str +AutoScalingPolicyName = str +AutoScalingRoleArn = str +Backfilling = bool +BackupArn = str +BackupName = str +BackupsInputLimit = int +BooleanAttributeValue = bool +BooleanObject = bool +ClientRequestToken = str +ClientToken = str +CloudWatchLogGroupArn = str +Code = str +ConditionExpression = str +ConfirmRemoveSelfResourceAccess = bool +ConsistentRead = bool +ConsumedCapacityUnits = float +ContributorInsightsRule = str +CsvDelimiter = str +CsvHeader = str +DeletionProtectionEnabled = bool +DoubleObject = float +ErrorMessage = str +ExceptionDescription = str +ExceptionName = str +ExportArn = str +ExportManifest = str +ExportNextToken = str +ExpressionAttributeNameVariable = str +ExpressionAttributeValueVariable = str +FailureCode = str +FailureMessage = str +GlobalTableArnString = str +ImportArn = str +ImportNextToken = str +IndexName = str +Integer = int +IntegerObject = int +ItemCollectionSizeEstimateBound = float +KMSMasterKeyArn = str +KMSMasterKeyId = str +KeyExpression = str +KeySchemaAttributeName = str +ListContributorInsightsLimit = int +ListExportsMaxLimit = int +ListImportsMaxLimit = int +ListTablesInputLimit = int +NextTokenString = str +NonKeyAttributeName = str +NullAttributeValue = bool +NumberAttributeValue = str +PartiQLNextToken = str +PartiQLStatement = str +PolicyRevisionId = str +PositiveIntegerObject = int +ProjectionExpression = str +RecoveryPeriodInDays = int +RegionName = str +ReplicaStatusDescription = str +ReplicaStatusPercentProgress = str +ResourceArnString = str +ResourcePolicy = str +RestoreInProgress = bool +S3Bucket = str +S3BucketOwner = str +S3Prefix = str +S3SseKmsKeyId = str +SSEEnabled = bool +ScanSegment = int +ScanTotalSegments = int +StreamArn = str +StreamEnabled = bool +String = str +StringAttributeValue = str +TableArn = str +TableId = str +TableName = str +TagKeyString = str +TagValueString = str +TimeToLiveAttributeName = str +TimeToLiveEnabled = bool +UpdateExpression = str + + +class ApproximateCreationDateTimePrecision(StrEnum): + MILLISECOND = "MILLISECOND" + MICROSECOND = "MICROSECOND" + + +class AttributeAction(StrEnum): + ADD = "ADD" + PUT = "PUT" + DELETE = "DELETE" + + +class BackupStatus(StrEnum): + CREATING = "CREATING" + DELETED = "DELETED" + AVAILABLE = "AVAILABLE" + + +class BackupType(StrEnum): + USER = "USER" + SYSTEM = "SYSTEM" + AWS_BACKUP = "AWS_BACKUP" + + +class BackupTypeFilter(StrEnum): + USER = "USER" + SYSTEM = "SYSTEM" + AWS_BACKUP = "AWS_BACKUP" + ALL = "ALL" + + +class BatchStatementErrorCodeEnum(StrEnum): + ConditionalCheckFailed = "ConditionalCheckFailed" + ItemCollectionSizeLimitExceeded = "ItemCollectionSizeLimitExceeded" + RequestLimitExceeded = "RequestLimitExceeded" + ValidationError = "ValidationError" + ProvisionedThroughputExceeded = "ProvisionedThroughputExceeded" + TransactionConflict = "TransactionConflict" + ThrottlingError = "ThrottlingError" + InternalServerError = "InternalServerError" + ResourceNotFound = "ResourceNotFound" + AccessDenied = "AccessDenied" + DuplicateItem = "DuplicateItem" + + +class BillingMode(StrEnum): + PROVISIONED = "PROVISIONED" + PAY_PER_REQUEST = "PAY_PER_REQUEST" + + +class ComparisonOperator(StrEnum): + EQ = "EQ" + NE = "NE" + IN = "IN" + LE = "LE" + LT = "LT" + GE = "GE" + GT = "GT" + BETWEEN = "BETWEEN" + NOT_NULL = "NOT_NULL" + NULL = "NULL" + CONTAINS = "CONTAINS" + NOT_CONTAINS = "NOT_CONTAINS" + BEGINS_WITH = "BEGINS_WITH" + + +class ConditionalOperator(StrEnum): + AND = "AND" + OR = "OR" + + +class ContinuousBackupsStatus(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class ContributorInsightsAction(StrEnum): + ENABLE = "ENABLE" + DISABLE = "DISABLE" + + +class ContributorInsightsStatus(StrEnum): + ENABLING = "ENABLING" + ENABLED = "ENABLED" + DISABLING = "DISABLING" + DISABLED = "DISABLED" + FAILED = "FAILED" + + +class DestinationStatus(StrEnum): + ENABLING = "ENABLING" + ACTIVE = "ACTIVE" + DISABLING = "DISABLING" + DISABLED = "DISABLED" + ENABLE_FAILED = "ENABLE_FAILED" + UPDATING = "UPDATING" + + +class ExportFormat(StrEnum): + DYNAMODB_JSON = "DYNAMODB_JSON" + ION = "ION" + + +class ExportStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +class ExportType(StrEnum): + FULL_EXPORT = "FULL_EXPORT" + INCREMENTAL_EXPORT = "INCREMENTAL_EXPORT" + + +class ExportViewType(StrEnum): + NEW_IMAGE = "NEW_IMAGE" + NEW_AND_OLD_IMAGES = "NEW_AND_OLD_IMAGES" + + +class GlobalTableStatus(StrEnum): + CREATING = "CREATING" + ACTIVE = "ACTIVE" + DELETING = "DELETING" + UPDATING = "UPDATING" + + +class ImportStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + CANCELLING = "CANCELLING" + CANCELLED = "CANCELLED" + FAILED = "FAILED" + + +class IndexStatus(StrEnum): + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + ACTIVE = "ACTIVE" + + +class InputCompressionType(StrEnum): + GZIP = "GZIP" + ZSTD = "ZSTD" + NONE = "NONE" + + +class InputFormat(StrEnum): + DYNAMODB_JSON = "DYNAMODB_JSON" + ION = "ION" + CSV = "CSV" + + +class KeyType(StrEnum): + HASH = "HASH" + RANGE = "RANGE" + + +class MultiRegionConsistency(StrEnum): + EVENTUAL = "EVENTUAL" + STRONG = "STRONG" + + +class PointInTimeRecoveryStatus(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class ProjectionType(StrEnum): + ALL = "ALL" + KEYS_ONLY = "KEYS_ONLY" + INCLUDE = "INCLUDE" + + +class ReplicaStatus(StrEnum): + CREATING = "CREATING" + CREATION_FAILED = "CREATION_FAILED" + UPDATING = "UPDATING" + DELETING = "DELETING" + ACTIVE = "ACTIVE" + REGION_DISABLED = "REGION_DISABLED" + INACCESSIBLE_ENCRYPTION_CREDENTIALS = "INACCESSIBLE_ENCRYPTION_CREDENTIALS" + ARCHIVING = "ARCHIVING" + ARCHIVED = "ARCHIVED" + REPLICATION_NOT_AUTHORIZED = "REPLICATION_NOT_AUTHORIZED" + + +class ReturnConsumedCapacity(StrEnum): + INDEXES = "INDEXES" + TOTAL = "TOTAL" + NONE = "NONE" + + +class ReturnItemCollectionMetrics(StrEnum): + SIZE = "SIZE" + NONE = "NONE" + + +class ReturnValue(StrEnum): + NONE = "NONE" + ALL_OLD = "ALL_OLD" + UPDATED_OLD = "UPDATED_OLD" + ALL_NEW = "ALL_NEW" + UPDATED_NEW = "UPDATED_NEW" + + +class ReturnValuesOnConditionCheckFailure(StrEnum): + ALL_OLD = "ALL_OLD" + NONE = "NONE" + + +class S3SseAlgorithm(StrEnum): + AES256 = "AES256" + KMS = "KMS" + + +class SSEStatus(StrEnum): + ENABLING = "ENABLING" + ENABLED = "ENABLED" + DISABLING = "DISABLING" + DISABLED = "DISABLED" + UPDATING = "UPDATING" + + +class SSEType(StrEnum): + AES256 = "AES256" + KMS = "KMS" + + +class ScalarAttributeType(StrEnum): + S = "S" + N = "N" + B = "B" + + +class Select(StrEnum): + ALL_ATTRIBUTES = "ALL_ATTRIBUTES" + ALL_PROJECTED_ATTRIBUTES = "ALL_PROJECTED_ATTRIBUTES" + SPECIFIC_ATTRIBUTES = "SPECIFIC_ATTRIBUTES" + COUNT = "COUNT" + + +class StreamViewType(StrEnum): + NEW_IMAGE = "NEW_IMAGE" + OLD_IMAGE = "OLD_IMAGE" + NEW_AND_OLD_IMAGES = "NEW_AND_OLD_IMAGES" + KEYS_ONLY = "KEYS_ONLY" + + +class TableClass(StrEnum): + STANDARD = "STANDARD" + STANDARD_INFREQUENT_ACCESS = "STANDARD_INFREQUENT_ACCESS" + + +class TableStatus(StrEnum): + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + ACTIVE = "ACTIVE" + INACCESSIBLE_ENCRYPTION_CREDENTIALS = "INACCESSIBLE_ENCRYPTION_CREDENTIALS" + ARCHIVING = "ARCHIVING" + ARCHIVED = "ARCHIVED" + REPLICATION_NOT_AUTHORIZED = "REPLICATION_NOT_AUTHORIZED" + + +class TimeToLiveStatus(StrEnum): + ENABLING = "ENABLING" + DISABLING = "DISABLING" + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class WitnessStatus(StrEnum): + CREATING = "CREATING" + DELETING = "DELETING" + ACTIVE = "ACTIVE" + + +class BackupInUseException(ServiceException): + code: str = "BackupInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class BackupNotFoundException(ServiceException): + code: str = "BackupNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class AttributeValue(TypedDict, total=False): + S: Optional["StringAttributeValue"] + N: Optional["NumberAttributeValue"] + B: Optional["BinaryAttributeValue"] + SS: Optional["StringSetAttributeValue"] + NS: Optional["NumberSetAttributeValue"] + BS: Optional["BinarySetAttributeValue"] + M: Optional["MapAttributeValue"] + L: Optional["ListAttributeValue"] + NULL: Optional["NullAttributeValue"] + BOOL: Optional["BooleanAttributeValue"] + + +ListAttributeValue = List[AttributeValue] +MapAttributeValue = Dict[AttributeName, AttributeValue] +BinaryAttributeValue = bytes +BinarySetAttributeValue = List[BinaryAttributeValue] +NumberSetAttributeValue = List[NumberAttributeValue] +StringSetAttributeValue = List[StringAttributeValue] +AttributeMap = Dict[AttributeName, AttributeValue] + + +class ConditionalCheckFailedException(ServiceException): + code: str = "ConditionalCheckFailedException" + sender_fault: bool = False + status_code: int = 400 + Item: Optional[AttributeMap] + + +class ContinuousBackupsUnavailableException(ServiceException): + code: str = "ContinuousBackupsUnavailableException" + sender_fault: bool = False + status_code: int = 400 + + +class DuplicateItemException(ServiceException): + code: str = "DuplicateItemException" + sender_fault: bool = False + status_code: int = 400 + + +class ExportConflictException(ServiceException): + code: str = "ExportConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class ExportNotFoundException(ServiceException): + code: str = "ExportNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class GlobalTableAlreadyExistsException(ServiceException): + code: str = "GlobalTableAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class GlobalTableNotFoundException(ServiceException): + code: str = "GlobalTableNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class IdempotentParameterMismatchException(ServiceException): + code: str = "IdempotentParameterMismatchException" + sender_fault: bool = False + status_code: int = 400 + + +class ImportConflictException(ServiceException): + code: str = "ImportConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class ImportNotFoundException(ServiceException): + code: str = "ImportNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class IndexNotFoundException(ServiceException): + code: str = "IndexNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServerError(ServiceException): + code: str = "InternalServerError" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidExportTimeException(ServiceException): + code: str = "InvalidExportTimeException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRestoreTimeException(ServiceException): + code: str = "InvalidRestoreTimeException" + sender_fault: bool = False + status_code: int = 400 + + +class ItemCollectionSizeLimitExceededException(ServiceException): + code: str = "ItemCollectionSizeLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class PointInTimeRecoveryUnavailableException(ServiceException): + code: str = "PointInTimeRecoveryUnavailableException" + sender_fault: bool = False + status_code: int = 400 + + +class PolicyNotFoundException(ServiceException): + code: str = "PolicyNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ProvisionedThroughputExceededException(ServiceException): + code: str = "ProvisionedThroughputExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ReplicaAlreadyExistsException(ServiceException): + code: str = "ReplicaAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class ReplicaNotFoundException(ServiceException): + code: str = "ReplicaNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ReplicatedWriteConflictException(ServiceException): + code: str = "ReplicatedWriteConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class RequestLimitExceeded(ServiceException): + code: str = "RequestLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class TableAlreadyExistsException(ServiceException): + code: str = "TableAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class TableInUseException(ServiceException): + code: str = "TableInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class TableNotFoundException(ServiceException): + code: str = "TableNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class CancellationReason(TypedDict, total=False): + Item: Optional[AttributeMap] + Code: Optional[Code] + Message: Optional[ErrorMessage] + + +CancellationReasonList = List[CancellationReason] + + +class TransactionCanceledException(ServiceException): + code: str = "TransactionCanceledException" + sender_fault: bool = False + status_code: int = 400 + CancellationReasons: Optional[CancellationReasonList] + + +class TransactionConflictException(ServiceException): + code: str = "TransactionConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class TransactionInProgressException(ServiceException): + code: str = "TransactionInProgressException" + sender_fault: bool = False + status_code: int = 400 + + +Date = datetime + + +class ArchivalSummary(TypedDict, total=False): + ArchivalDateTime: Optional[Date] + ArchivalReason: Optional[ArchivalReason] + ArchivalBackupArn: Optional[BackupArn] + + +class AttributeDefinition(TypedDict, total=False): + AttributeName: KeySchemaAttributeName + AttributeType: ScalarAttributeType + + +AttributeDefinitions = List[AttributeDefinition] +AttributeNameList = List[AttributeName] + + +class AttributeValueUpdate(TypedDict, total=False): + Value: Optional[AttributeValue] + Action: Optional[AttributeAction] + + +AttributeUpdates = Dict[AttributeName, AttributeValueUpdate] +AttributeValueList = List[AttributeValue] + + +class AutoScalingTargetTrackingScalingPolicyConfigurationDescription(TypedDict, total=False): + DisableScaleIn: Optional[BooleanObject] + ScaleInCooldown: Optional[IntegerObject] + ScaleOutCooldown: Optional[IntegerObject] + TargetValue: DoubleObject + + +class AutoScalingPolicyDescription(TypedDict, total=False): + PolicyName: Optional[AutoScalingPolicyName] + TargetTrackingScalingPolicyConfiguration: Optional[ + AutoScalingTargetTrackingScalingPolicyConfigurationDescription + ] + + +AutoScalingPolicyDescriptionList = List[AutoScalingPolicyDescription] + + +class AutoScalingTargetTrackingScalingPolicyConfigurationUpdate(TypedDict, total=False): + DisableScaleIn: Optional[BooleanObject] + ScaleInCooldown: Optional[IntegerObject] + ScaleOutCooldown: Optional[IntegerObject] + TargetValue: DoubleObject + + +class AutoScalingPolicyUpdate(TypedDict, total=False): + PolicyName: Optional[AutoScalingPolicyName] + TargetTrackingScalingPolicyConfiguration: ( + AutoScalingTargetTrackingScalingPolicyConfigurationUpdate + ) + + +PositiveLongObject = int + + +class AutoScalingSettingsDescription(TypedDict, total=False): + MinimumUnits: Optional[PositiveLongObject] + MaximumUnits: Optional[PositiveLongObject] + AutoScalingDisabled: Optional[BooleanObject] + AutoScalingRoleArn: Optional[String] + ScalingPolicies: Optional[AutoScalingPolicyDescriptionList] + + +class AutoScalingSettingsUpdate(TypedDict, total=False): + MinimumUnits: Optional[PositiveLongObject] + MaximumUnits: Optional[PositiveLongObject] + AutoScalingDisabled: Optional[BooleanObject] + AutoScalingRoleArn: Optional[AutoScalingRoleArn] + ScalingPolicyUpdate: Optional[AutoScalingPolicyUpdate] + + +BackupCreationDateTime = datetime + + +class SSEDescription(TypedDict, total=False): + Status: Optional[SSEStatus] + SSEType: Optional[SSEType] + KMSMasterKeyArn: Optional[KMSMasterKeyArn] + InaccessibleEncryptionDateTime: Optional[Date] + + +class TimeToLiveDescription(TypedDict, total=False): + TimeToLiveStatus: Optional[TimeToLiveStatus] + AttributeName: Optional[TimeToLiveAttributeName] + + +class StreamSpecification(TypedDict, total=False): + StreamEnabled: StreamEnabled + StreamViewType: Optional[StreamViewType] + + +LongObject = int + + +class OnDemandThroughput(TypedDict, total=False): + MaxReadRequestUnits: Optional[LongObject] + MaxWriteRequestUnits: Optional[LongObject] + + +class ProvisionedThroughput(TypedDict, total=False): + ReadCapacityUnits: PositiveLongObject + WriteCapacityUnits: PositiveLongObject + + +NonKeyAttributeNameList = List[NonKeyAttributeName] + + +class Projection(TypedDict, total=False): + ProjectionType: Optional[ProjectionType] + NonKeyAttributes: Optional[NonKeyAttributeNameList] + + +class KeySchemaElement(TypedDict, total=False): + AttributeName: KeySchemaAttributeName + KeyType: KeyType + + +KeySchema = List[KeySchemaElement] + + +class GlobalSecondaryIndexInfo(TypedDict, total=False): + IndexName: Optional[IndexName] + KeySchema: Optional[KeySchema] + Projection: Optional[Projection] + ProvisionedThroughput: Optional[ProvisionedThroughput] + OnDemandThroughput: Optional[OnDemandThroughput] + + +GlobalSecondaryIndexes = List[GlobalSecondaryIndexInfo] + + +class LocalSecondaryIndexInfo(TypedDict, total=False): + IndexName: Optional[IndexName] + KeySchema: Optional[KeySchema] + Projection: Optional[Projection] + + +LocalSecondaryIndexes = List[LocalSecondaryIndexInfo] + + +class SourceTableFeatureDetails(TypedDict, total=False): + LocalSecondaryIndexes: Optional[LocalSecondaryIndexes] + GlobalSecondaryIndexes: Optional[GlobalSecondaryIndexes] + StreamDescription: Optional[StreamSpecification] + TimeToLiveDescription: Optional[TimeToLiveDescription] + SSEDescription: Optional[SSEDescription] + + +ItemCount = int +TableCreationDateTime = datetime + + +class SourceTableDetails(TypedDict, total=False): + TableName: TableName + TableId: TableId + TableArn: Optional[TableArn] + TableSizeBytes: Optional[LongObject] + KeySchema: KeySchema + TableCreationDateTime: TableCreationDateTime + ProvisionedThroughput: ProvisionedThroughput + OnDemandThroughput: Optional[OnDemandThroughput] + ItemCount: Optional[ItemCount] + BillingMode: Optional[BillingMode] + + +BackupSizeBytes = int + + +class BackupDetails(TypedDict, total=False): + BackupArn: BackupArn + BackupName: BackupName + BackupSizeBytes: Optional[BackupSizeBytes] + BackupStatus: BackupStatus + BackupType: BackupType + BackupCreationDateTime: BackupCreationDateTime + BackupExpiryDateTime: Optional[Date] + + +class BackupDescription(TypedDict, total=False): + BackupDetails: Optional[BackupDetails] + SourceTableDetails: Optional[SourceTableDetails] + SourceTableFeatureDetails: Optional[SourceTableFeatureDetails] + + +class BackupSummary(TypedDict, total=False): + TableName: Optional[TableName] + TableId: Optional[TableId] + TableArn: Optional[TableArn] + BackupArn: Optional[BackupArn] + BackupName: Optional[BackupName] + BackupCreationDateTime: Optional[BackupCreationDateTime] + BackupExpiryDateTime: Optional[Date] + BackupStatus: Optional[BackupStatus] + BackupType: Optional[BackupType] + BackupSizeBytes: Optional[BackupSizeBytes] + + +BackupSummaries = List[BackupSummary] +PreparedStatementParameters = List[AttributeValue] + + +class BatchStatementRequest(TypedDict, total=False): + Statement: PartiQLStatement + Parameters: Optional[PreparedStatementParameters] + ConsistentRead: Optional[ConsistentRead] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +PartiQLBatchRequest = List[BatchStatementRequest] + + +class BatchExecuteStatementInput(ServiceRequest): + Statements: PartiQLBatchRequest + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + + +class Capacity(TypedDict, total=False): + ReadCapacityUnits: Optional[ConsumedCapacityUnits] + WriteCapacityUnits: Optional[ConsumedCapacityUnits] + CapacityUnits: Optional[ConsumedCapacityUnits] + + +SecondaryIndexesCapacityMap = Dict[IndexName, Capacity] + + +class ConsumedCapacity(TypedDict, total=False): + TableName: Optional[TableArn] + CapacityUnits: Optional[ConsumedCapacityUnits] + ReadCapacityUnits: Optional[ConsumedCapacityUnits] + WriteCapacityUnits: Optional[ConsumedCapacityUnits] + Table: Optional[Capacity] + LocalSecondaryIndexes: Optional[SecondaryIndexesCapacityMap] + GlobalSecondaryIndexes: Optional[SecondaryIndexesCapacityMap] + + +ConsumedCapacityMultiple = List[ConsumedCapacity] + + +class BatchStatementError(TypedDict, total=False): + Code: Optional[BatchStatementErrorCodeEnum] + Message: Optional[String] + Item: Optional[AttributeMap] + + +class BatchStatementResponse(TypedDict, total=False): + Error: Optional[BatchStatementError] + TableName: Optional[TableName] + Item: Optional[AttributeMap] + + +PartiQLBatchResponse = List[BatchStatementResponse] + + +class BatchExecuteStatementOutput(TypedDict, total=False): + Responses: Optional[PartiQLBatchResponse] + ConsumedCapacity: Optional[ConsumedCapacityMultiple] + + +ExpressionAttributeNameMap = Dict[ExpressionAttributeNameVariable, AttributeName] +Key = Dict[AttributeName, AttributeValue] +KeyList = List[Key] + + +class KeysAndAttributes(TypedDict, total=False): + Keys: KeyList + AttributesToGet: Optional[AttributeNameList] + ConsistentRead: Optional[ConsistentRead] + ProjectionExpression: Optional[ProjectionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + + +BatchGetRequestMap = Dict[TableArn, KeysAndAttributes] + + +class BatchGetItemInput(ServiceRequest): + RequestItems: BatchGetRequestMap + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + + +ItemList = List[AttributeMap] +BatchGetResponseMap = Dict[TableArn, ItemList] + + +class BatchGetItemOutput(TypedDict, total=False): + Responses: Optional[BatchGetResponseMap] + UnprocessedKeys: Optional[BatchGetRequestMap] + ConsumedCapacity: Optional[ConsumedCapacityMultiple] + + +class DeleteRequest(TypedDict, total=False): + Key: Key + + +PutItemInputAttributeMap = Dict[AttributeName, AttributeValue] + + +class PutRequest(TypedDict, total=False): + Item: PutItemInputAttributeMap + + +class WriteRequest(TypedDict, total=False): + PutRequest: Optional[PutRequest] + DeleteRequest: Optional[DeleteRequest] + + +WriteRequests = List[WriteRequest] +BatchWriteItemRequestMap = Dict[TableArn, WriteRequests] + + +class BatchWriteItemInput(ServiceRequest): + RequestItems: BatchWriteItemRequestMap + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + ReturnItemCollectionMetrics: Optional[ReturnItemCollectionMetrics] + + +ItemCollectionSizeEstimateRange = List[ItemCollectionSizeEstimateBound] +ItemCollectionKeyAttributeMap = Dict[AttributeName, AttributeValue] + + +class ItemCollectionMetrics(TypedDict, total=False): + ItemCollectionKey: Optional[ItemCollectionKeyAttributeMap] + SizeEstimateRangeGB: Optional[ItemCollectionSizeEstimateRange] + + +ItemCollectionMetricsMultiple = List[ItemCollectionMetrics] +ItemCollectionMetricsPerTable = Dict[TableArn, ItemCollectionMetricsMultiple] + + +class BatchWriteItemOutput(TypedDict, total=False): + UnprocessedItems: Optional[BatchWriteItemRequestMap] + ItemCollectionMetrics: Optional[ItemCollectionMetricsPerTable] + ConsumedCapacity: Optional[ConsumedCapacityMultiple] + + +BilledSizeBytes = int + + +class BillingModeSummary(TypedDict, total=False): + BillingMode: Optional[BillingMode] + LastUpdateToPayPerRequestDateTime: Optional[Date] + + +class Condition(TypedDict, total=False): + AttributeValueList: Optional[AttributeValueList] + ComparisonOperator: ComparisonOperator + + +ExpressionAttributeValueMap = Dict[ExpressionAttributeValueVariable, AttributeValue] + + +class ConditionCheck(TypedDict, total=False): + Key: Key + TableName: TableArn + ConditionExpression: ConditionExpression + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class PointInTimeRecoveryDescription(TypedDict, total=False): + PointInTimeRecoveryStatus: Optional[PointInTimeRecoveryStatus] + RecoveryPeriodInDays: Optional[RecoveryPeriodInDays] + EarliestRestorableDateTime: Optional[Date] + LatestRestorableDateTime: Optional[Date] + + +class ContinuousBackupsDescription(TypedDict, total=False): + ContinuousBackupsStatus: ContinuousBackupsStatus + PointInTimeRecoveryDescription: Optional[PointInTimeRecoveryDescription] + + +ContributorInsightsRuleList = List[ContributorInsightsRule] + + +class ContributorInsightsSummary(TypedDict, total=False): + TableName: Optional[TableName] + IndexName: Optional[IndexName] + ContributorInsightsStatus: Optional[ContributorInsightsStatus] + + +ContributorInsightsSummaries = List[ContributorInsightsSummary] + + +class CreateBackupInput(ServiceRequest): + TableName: TableArn + BackupName: BackupName + + +class CreateBackupOutput(TypedDict, total=False): + BackupDetails: Optional[BackupDetails] + + +class WarmThroughput(TypedDict, total=False): + ReadUnitsPerSecond: Optional[LongObject] + WriteUnitsPerSecond: Optional[LongObject] + + +class CreateGlobalSecondaryIndexAction(TypedDict, total=False): + IndexName: IndexName + KeySchema: KeySchema + Projection: Projection + ProvisionedThroughput: Optional[ProvisionedThroughput] + OnDemandThroughput: Optional[OnDemandThroughput] + WarmThroughput: Optional[WarmThroughput] + + +class Replica(TypedDict, total=False): + RegionName: Optional[RegionName] + + +ReplicaList = List[Replica] + + +class CreateGlobalTableInput(ServiceRequest): + GlobalTableName: TableName + ReplicationGroup: ReplicaList + + +class TableClassSummary(TypedDict, total=False): + TableClass: Optional[TableClass] + LastUpdateDateTime: Optional[Date] + + +class GlobalSecondaryIndexWarmThroughputDescription(TypedDict, total=False): + ReadUnitsPerSecond: Optional[PositiveLongObject] + WriteUnitsPerSecond: Optional[PositiveLongObject] + Status: Optional[IndexStatus] + + +class OnDemandThroughputOverride(TypedDict, total=False): + MaxReadRequestUnits: Optional[LongObject] + + +class ProvisionedThroughputOverride(TypedDict, total=False): + ReadCapacityUnits: Optional[PositiveLongObject] + + +class ReplicaGlobalSecondaryIndexDescription(TypedDict, total=False): + IndexName: Optional[IndexName] + ProvisionedThroughputOverride: Optional[ProvisionedThroughputOverride] + OnDemandThroughputOverride: Optional[OnDemandThroughputOverride] + WarmThroughput: Optional[GlobalSecondaryIndexWarmThroughputDescription] + + +ReplicaGlobalSecondaryIndexDescriptionList = List[ReplicaGlobalSecondaryIndexDescription] + + +class TableWarmThroughputDescription(TypedDict, total=False): + ReadUnitsPerSecond: Optional[PositiveLongObject] + WriteUnitsPerSecond: Optional[PositiveLongObject] + Status: Optional[TableStatus] + + +class ReplicaDescription(TypedDict, total=False): + RegionName: Optional[RegionName] + ReplicaStatus: Optional[ReplicaStatus] + ReplicaStatusDescription: Optional[ReplicaStatusDescription] + ReplicaStatusPercentProgress: Optional[ReplicaStatusPercentProgress] + KMSMasterKeyId: Optional[KMSMasterKeyId] + ProvisionedThroughputOverride: Optional[ProvisionedThroughputOverride] + OnDemandThroughputOverride: Optional[OnDemandThroughputOverride] + WarmThroughput: Optional[TableWarmThroughputDescription] + GlobalSecondaryIndexes: Optional[ReplicaGlobalSecondaryIndexDescriptionList] + ReplicaInaccessibleDateTime: Optional[Date] + ReplicaTableClassSummary: Optional[TableClassSummary] + + +ReplicaDescriptionList = List[ReplicaDescription] + + +class GlobalTableDescription(TypedDict, total=False): + ReplicationGroup: Optional[ReplicaDescriptionList] + GlobalTableArn: Optional[GlobalTableArnString] + CreationDateTime: Optional[Date] + GlobalTableStatus: Optional[GlobalTableStatus] + GlobalTableName: Optional[TableName] + + +class CreateGlobalTableOutput(TypedDict, total=False): + GlobalTableDescription: Optional[GlobalTableDescription] + + +class CreateGlobalTableWitnessGroupMemberAction(TypedDict, total=False): + RegionName: RegionName + + +class CreateReplicaAction(TypedDict, total=False): + RegionName: RegionName + + +class ReplicaGlobalSecondaryIndex(TypedDict, total=False): + IndexName: IndexName + ProvisionedThroughputOverride: Optional[ProvisionedThroughputOverride] + OnDemandThroughputOverride: Optional[OnDemandThroughputOverride] + + +ReplicaGlobalSecondaryIndexList = List[ReplicaGlobalSecondaryIndex] + + +class CreateReplicationGroupMemberAction(TypedDict, total=False): + RegionName: RegionName + KMSMasterKeyId: Optional[KMSMasterKeyId] + ProvisionedThroughputOverride: Optional[ProvisionedThroughputOverride] + OnDemandThroughputOverride: Optional[OnDemandThroughputOverride] + GlobalSecondaryIndexes: Optional[ReplicaGlobalSecondaryIndexList] + TableClassOverride: Optional[TableClass] + + +class Tag(TypedDict, total=False): + Key: TagKeyString + Value: TagValueString + + +TagList = List[Tag] + + +class SSESpecification(TypedDict, total=False): + Enabled: Optional[SSEEnabled] + SSEType: Optional[SSEType] + KMSMasterKeyId: Optional[KMSMasterKeyId] + + +class GlobalSecondaryIndex(TypedDict, total=False): + IndexName: IndexName + KeySchema: KeySchema + Projection: Projection + ProvisionedThroughput: Optional[ProvisionedThroughput] + OnDemandThroughput: Optional[OnDemandThroughput] + WarmThroughput: Optional[WarmThroughput] + + +GlobalSecondaryIndexList = List[GlobalSecondaryIndex] + + +class LocalSecondaryIndex(TypedDict, total=False): + IndexName: IndexName + KeySchema: KeySchema + Projection: Projection + + +LocalSecondaryIndexList = List[LocalSecondaryIndex] + + +class CreateTableInput(ServiceRequest): + AttributeDefinitions: AttributeDefinitions + TableName: TableArn + KeySchema: KeySchema + LocalSecondaryIndexes: Optional[LocalSecondaryIndexList] + GlobalSecondaryIndexes: Optional[GlobalSecondaryIndexList] + BillingMode: Optional[BillingMode] + ProvisionedThroughput: Optional[ProvisionedThroughput] + StreamSpecification: Optional[StreamSpecification] + SSESpecification: Optional[SSESpecification] + Tags: Optional[TagList] + TableClass: Optional[TableClass] + DeletionProtectionEnabled: Optional[DeletionProtectionEnabled] + WarmThroughput: Optional[WarmThroughput] + ResourcePolicy: Optional[ResourcePolicy] + OnDemandThroughput: Optional[OnDemandThroughput] + + +class RestoreSummary(TypedDict, total=False): + SourceBackupArn: Optional[BackupArn] + SourceTableArn: Optional[TableArn] + RestoreDateTime: Date + RestoreInProgress: RestoreInProgress + + +class GlobalTableWitnessDescription(TypedDict, total=False): + RegionName: Optional[RegionName] + WitnessStatus: Optional[WitnessStatus] + + +GlobalTableWitnessDescriptionList = List[GlobalTableWitnessDescription] +NonNegativeLongObject = int + + +class ProvisionedThroughputDescription(TypedDict, total=False): + LastIncreaseDateTime: Optional[Date] + LastDecreaseDateTime: Optional[Date] + NumberOfDecreasesToday: Optional[PositiveLongObject] + ReadCapacityUnits: Optional[NonNegativeLongObject] + WriteCapacityUnits: Optional[NonNegativeLongObject] + + +class GlobalSecondaryIndexDescription(TypedDict, total=False): + IndexName: Optional[IndexName] + KeySchema: Optional[KeySchema] + Projection: Optional[Projection] + IndexStatus: Optional[IndexStatus] + Backfilling: Optional[Backfilling] + ProvisionedThroughput: Optional[ProvisionedThroughputDescription] + IndexSizeBytes: Optional[LongObject] + ItemCount: Optional[LongObject] + IndexArn: Optional[String] + OnDemandThroughput: Optional[OnDemandThroughput] + WarmThroughput: Optional[GlobalSecondaryIndexWarmThroughputDescription] + + +GlobalSecondaryIndexDescriptionList = List[GlobalSecondaryIndexDescription] + + +class LocalSecondaryIndexDescription(TypedDict, total=False): + IndexName: Optional[IndexName] + KeySchema: Optional[KeySchema] + Projection: Optional[Projection] + IndexSizeBytes: Optional[LongObject] + ItemCount: Optional[LongObject] + IndexArn: Optional[String] + + +LocalSecondaryIndexDescriptionList = List[LocalSecondaryIndexDescription] + + +class TableDescription(TypedDict, total=False): + AttributeDefinitions: Optional[AttributeDefinitions] + TableName: Optional[TableName] + KeySchema: Optional[KeySchema] + TableStatus: Optional[TableStatus] + CreationDateTime: Optional[Date] + ProvisionedThroughput: Optional[ProvisionedThroughputDescription] + TableSizeBytes: Optional[LongObject] + ItemCount: Optional[LongObject] + TableArn: Optional[String] + TableId: Optional[TableId] + BillingModeSummary: Optional[BillingModeSummary] + LocalSecondaryIndexes: Optional[LocalSecondaryIndexDescriptionList] + GlobalSecondaryIndexes: Optional[GlobalSecondaryIndexDescriptionList] + StreamSpecification: Optional[StreamSpecification] + LatestStreamLabel: Optional[String] + LatestStreamArn: Optional[StreamArn] + GlobalTableVersion: Optional[String] + Replicas: Optional[ReplicaDescriptionList] + GlobalTableWitnesses: Optional[GlobalTableWitnessDescriptionList] + RestoreSummary: Optional[RestoreSummary] + SSEDescription: Optional[SSEDescription] + ArchivalSummary: Optional[ArchivalSummary] + TableClassSummary: Optional[TableClassSummary] + DeletionProtectionEnabled: Optional[DeletionProtectionEnabled] + OnDemandThroughput: Optional[OnDemandThroughput] + WarmThroughput: Optional[TableWarmThroughputDescription] + MultiRegionConsistency: Optional[MultiRegionConsistency] + + +class CreateTableOutput(TypedDict, total=False): + TableDescription: Optional[TableDescription] + + +CsvHeaderList = List[CsvHeader] + + +class CsvOptions(TypedDict, total=False): + Delimiter: Optional[CsvDelimiter] + HeaderList: Optional[CsvHeaderList] + + +class Delete(TypedDict, total=False): + Key: Key + TableName: TableArn + ConditionExpression: Optional[ConditionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class DeleteBackupInput(ServiceRequest): + BackupArn: BackupArn + + +class DeleteBackupOutput(TypedDict, total=False): + BackupDescription: Optional[BackupDescription] + + +class DeleteGlobalSecondaryIndexAction(TypedDict, total=False): + IndexName: IndexName + + +class DeleteGlobalTableWitnessGroupMemberAction(TypedDict, total=False): + RegionName: RegionName + + +class ExpectedAttributeValue(TypedDict, total=False): + Value: Optional[AttributeValue] + Exists: Optional[BooleanObject] + ComparisonOperator: Optional[ComparisonOperator] + AttributeValueList: Optional[AttributeValueList] + + +ExpectedAttributeMap = Dict[AttributeName, ExpectedAttributeValue] + + +class DeleteItemInput(ServiceRequest): + TableName: TableArn + Key: Key + Expected: Optional[ExpectedAttributeMap] + ConditionalOperator: Optional[ConditionalOperator] + ReturnValues: Optional[ReturnValue] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + ReturnItemCollectionMetrics: Optional[ReturnItemCollectionMetrics] + ConditionExpression: Optional[ConditionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class DeleteItemOutput(TypedDict, total=False): + Attributes: Optional[AttributeMap] + ConsumedCapacity: Optional[ConsumedCapacity] + ItemCollectionMetrics: Optional[ItemCollectionMetrics] + + +class DeleteReplicaAction(TypedDict, total=False): + RegionName: RegionName + + +class DeleteReplicationGroupMemberAction(TypedDict, total=False): + RegionName: RegionName + + +class DeleteResourcePolicyInput(ServiceRequest): + ResourceArn: ResourceArnString + ExpectedRevisionId: Optional[PolicyRevisionId] + + +class DeleteResourcePolicyOutput(TypedDict, total=False): + RevisionId: Optional[PolicyRevisionId] + + +class DeleteTableInput(ServiceRequest): + TableName: TableArn + + +class DeleteTableOutput(TypedDict, total=False): + TableDescription: Optional[TableDescription] + + +class DescribeBackupInput(ServiceRequest): + BackupArn: BackupArn + + +class DescribeBackupOutput(TypedDict, total=False): + BackupDescription: Optional[BackupDescription] + + +class DescribeContinuousBackupsInput(ServiceRequest): + TableName: TableArn + + +class DescribeContinuousBackupsOutput(TypedDict, total=False): + ContinuousBackupsDescription: Optional[ContinuousBackupsDescription] + + +class DescribeContributorInsightsInput(ServiceRequest): + TableName: TableArn + IndexName: Optional[IndexName] + + +class FailureException(TypedDict, total=False): + ExceptionName: Optional[ExceptionName] + ExceptionDescription: Optional[ExceptionDescription] + + +LastUpdateDateTime = datetime + + +class DescribeContributorInsightsOutput(TypedDict, total=False): + TableName: Optional[TableName] + IndexName: Optional[IndexName] + ContributorInsightsRuleList: Optional[ContributorInsightsRuleList] + ContributorInsightsStatus: Optional[ContributorInsightsStatus] + LastUpdateDateTime: Optional[LastUpdateDateTime] + FailureException: Optional[FailureException] + + +class DescribeEndpointsRequest(ServiceRequest): + pass + + +Long = int + + +class Endpoint(TypedDict, total=False): + Address: String + CachePeriodInMinutes: Long + + +Endpoints = List[Endpoint] + + +class DescribeEndpointsResponse(TypedDict, total=False): + Endpoints: Endpoints + + +class DescribeExportInput(ServiceRequest): + ExportArn: ExportArn + + +ExportToTime = datetime +ExportFromTime = datetime + + +class IncrementalExportSpecification(TypedDict, total=False): + ExportFromTime: Optional[ExportFromTime] + ExportToTime: Optional[ExportToTime] + ExportViewType: Optional[ExportViewType] + + +ExportTime = datetime +ExportEndTime = datetime +ExportStartTime = datetime + + +class ExportDescription(TypedDict, total=False): + ExportArn: Optional[ExportArn] + ExportStatus: Optional[ExportStatus] + StartTime: Optional[ExportStartTime] + EndTime: Optional[ExportEndTime] + ExportManifest: Optional[ExportManifest] + TableArn: Optional[TableArn] + TableId: Optional[TableId] + ExportTime: Optional[ExportTime] + ClientToken: Optional[ClientToken] + S3Bucket: Optional[S3Bucket] + S3BucketOwner: Optional[S3BucketOwner] + S3Prefix: Optional[S3Prefix] + S3SseAlgorithm: Optional[S3SseAlgorithm] + S3SseKmsKeyId: Optional[S3SseKmsKeyId] + FailureCode: Optional[FailureCode] + FailureMessage: Optional[FailureMessage] + ExportFormat: Optional[ExportFormat] + BilledSizeBytes: Optional[BilledSizeBytes] + ItemCount: Optional[ItemCount] + ExportType: Optional[ExportType] + IncrementalExportSpecification: Optional[IncrementalExportSpecification] + + +class DescribeExportOutput(TypedDict, total=False): + ExportDescription: Optional[ExportDescription] + + +class DescribeGlobalTableInput(ServiceRequest): + GlobalTableName: TableName + + +class DescribeGlobalTableOutput(TypedDict, total=False): + GlobalTableDescription: Optional[GlobalTableDescription] + + +class DescribeGlobalTableSettingsInput(ServiceRequest): + GlobalTableName: TableName + + +class ReplicaGlobalSecondaryIndexSettingsDescription(TypedDict, total=False): + IndexName: IndexName + IndexStatus: Optional[IndexStatus] + ProvisionedReadCapacityUnits: Optional[PositiveLongObject] + ProvisionedReadCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + ProvisionedWriteCapacityUnits: Optional[PositiveLongObject] + ProvisionedWriteCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + + +ReplicaGlobalSecondaryIndexSettingsDescriptionList = List[ + ReplicaGlobalSecondaryIndexSettingsDescription +] + + +class ReplicaSettingsDescription(TypedDict, total=False): + RegionName: RegionName + ReplicaStatus: Optional[ReplicaStatus] + ReplicaBillingModeSummary: Optional[BillingModeSummary] + ReplicaProvisionedReadCapacityUnits: Optional[NonNegativeLongObject] + ReplicaProvisionedReadCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + ReplicaProvisionedWriteCapacityUnits: Optional[NonNegativeLongObject] + ReplicaProvisionedWriteCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + ReplicaGlobalSecondaryIndexSettings: Optional[ + ReplicaGlobalSecondaryIndexSettingsDescriptionList + ] + ReplicaTableClassSummary: Optional[TableClassSummary] + + +ReplicaSettingsDescriptionList = List[ReplicaSettingsDescription] + + +class DescribeGlobalTableSettingsOutput(TypedDict, total=False): + GlobalTableName: Optional[TableName] + ReplicaSettings: Optional[ReplicaSettingsDescriptionList] + + +class DescribeImportInput(ServiceRequest): + ImportArn: ImportArn + + +ImportedItemCount = int +ProcessedItemCount = int +ImportEndTime = datetime +ImportStartTime = datetime + + +class TableCreationParameters(TypedDict, total=False): + TableName: TableName + AttributeDefinitions: AttributeDefinitions + KeySchema: KeySchema + BillingMode: Optional[BillingMode] + ProvisionedThroughput: Optional[ProvisionedThroughput] + OnDemandThroughput: Optional[OnDemandThroughput] + SSESpecification: Optional[SSESpecification] + GlobalSecondaryIndexes: Optional[GlobalSecondaryIndexList] + + +class InputFormatOptions(TypedDict, total=False): + Csv: Optional[CsvOptions] + + +ErrorCount = int + + +class S3BucketSource(TypedDict, total=False): + S3BucketOwner: Optional[S3BucketOwner] + S3Bucket: S3Bucket + S3KeyPrefix: Optional[S3Prefix] + + +class ImportTableDescription(TypedDict, total=False): + ImportArn: Optional[ImportArn] + ImportStatus: Optional[ImportStatus] + TableArn: Optional[TableArn] + TableId: Optional[TableId] + ClientToken: Optional[ClientToken] + S3BucketSource: Optional[S3BucketSource] + ErrorCount: Optional[ErrorCount] + CloudWatchLogGroupArn: Optional[CloudWatchLogGroupArn] + InputFormat: Optional[InputFormat] + InputFormatOptions: Optional[InputFormatOptions] + InputCompressionType: Optional[InputCompressionType] + TableCreationParameters: Optional[TableCreationParameters] + StartTime: Optional[ImportStartTime] + EndTime: Optional[ImportEndTime] + ProcessedSizeBytes: Optional[LongObject] + ProcessedItemCount: Optional[ProcessedItemCount] + ImportedItemCount: Optional[ImportedItemCount] + FailureCode: Optional[FailureCode] + FailureMessage: Optional[FailureMessage] + + +class DescribeImportOutput(TypedDict, total=False): + ImportTableDescription: ImportTableDescription + + +class DescribeKinesisStreamingDestinationInput(ServiceRequest): + TableName: TableArn + + +class KinesisDataStreamDestination(TypedDict, total=False): + StreamArn: Optional[StreamArn] + DestinationStatus: Optional[DestinationStatus] + DestinationStatusDescription: Optional[String] + ApproximateCreationDateTimePrecision: Optional[ApproximateCreationDateTimePrecision] + + +KinesisDataStreamDestinations = List[KinesisDataStreamDestination] + + +class DescribeKinesisStreamingDestinationOutput(TypedDict, total=False): + TableName: Optional[TableName] + KinesisDataStreamDestinations: Optional[KinesisDataStreamDestinations] + + +class DescribeLimitsInput(ServiceRequest): + pass + + +class DescribeLimitsOutput(TypedDict, total=False): + AccountMaxReadCapacityUnits: Optional[PositiveLongObject] + AccountMaxWriteCapacityUnits: Optional[PositiveLongObject] + TableMaxReadCapacityUnits: Optional[PositiveLongObject] + TableMaxWriteCapacityUnits: Optional[PositiveLongObject] + + +class DescribeTableInput(ServiceRequest): + TableName: TableArn + + +class DescribeTableOutput(TypedDict, total=False): + Table: Optional[TableDescription] + + +class DescribeTableReplicaAutoScalingInput(ServiceRequest): + TableName: TableArn + + +class ReplicaGlobalSecondaryIndexAutoScalingDescription(TypedDict, total=False): + IndexName: Optional[IndexName] + IndexStatus: Optional[IndexStatus] + ProvisionedReadCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + ProvisionedWriteCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + + +ReplicaGlobalSecondaryIndexAutoScalingDescriptionList = List[ + ReplicaGlobalSecondaryIndexAutoScalingDescription +] + + +class ReplicaAutoScalingDescription(TypedDict, total=False): + RegionName: Optional[RegionName] + GlobalSecondaryIndexes: Optional[ReplicaGlobalSecondaryIndexAutoScalingDescriptionList] + ReplicaProvisionedReadCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + ReplicaProvisionedWriteCapacityAutoScalingSettings: Optional[AutoScalingSettingsDescription] + ReplicaStatus: Optional[ReplicaStatus] + + +ReplicaAutoScalingDescriptionList = List[ReplicaAutoScalingDescription] + + +class TableAutoScalingDescription(TypedDict, total=False): + TableName: Optional[TableName] + TableStatus: Optional[TableStatus] + Replicas: Optional[ReplicaAutoScalingDescriptionList] + + +class DescribeTableReplicaAutoScalingOutput(TypedDict, total=False): + TableAutoScalingDescription: Optional[TableAutoScalingDescription] + + +class DescribeTimeToLiveInput(ServiceRequest): + TableName: TableArn + + +class DescribeTimeToLiveOutput(TypedDict, total=False): + TimeToLiveDescription: Optional[TimeToLiveDescription] + + +class EnableKinesisStreamingConfiguration(TypedDict, total=False): + ApproximateCreationDateTimePrecision: Optional[ApproximateCreationDateTimePrecision] + + +class ExecuteStatementInput(ServiceRequest): + Statement: PartiQLStatement + Parameters: Optional[PreparedStatementParameters] + ConsistentRead: Optional[ConsistentRead] + NextToken: Optional[PartiQLNextToken] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + Limit: Optional[PositiveIntegerObject] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class ExecuteStatementOutput(TypedDict, total=False): + Items: Optional[ItemList] + NextToken: Optional[PartiQLNextToken] + ConsumedCapacity: Optional[ConsumedCapacity] + LastEvaluatedKey: Optional[Key] + + +class ParameterizedStatement(TypedDict, total=False): + Statement: PartiQLStatement + Parameters: Optional[PreparedStatementParameters] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +ParameterizedStatements = List[ParameterizedStatement] + + +class ExecuteTransactionInput(ServiceRequest): + TransactStatements: ParameterizedStatements + ClientRequestToken: Optional[ClientRequestToken] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + + +class ItemResponse(TypedDict, total=False): + Item: Optional[AttributeMap] + + +ItemResponseList = List[ItemResponse] + + +class ExecuteTransactionOutput(TypedDict, total=False): + Responses: Optional[ItemResponseList] + ConsumedCapacity: Optional[ConsumedCapacityMultiple] + + +class ExportSummary(TypedDict, total=False): + ExportArn: Optional[ExportArn] + ExportStatus: Optional[ExportStatus] + ExportType: Optional[ExportType] + + +ExportSummaries = List[ExportSummary] + + +class ExportTableToPointInTimeInput(ServiceRequest): + TableArn: TableArn + ExportTime: Optional[ExportTime] + ClientToken: Optional[ClientToken] + S3Bucket: S3Bucket + S3BucketOwner: Optional[S3BucketOwner] + S3Prefix: Optional[S3Prefix] + S3SseAlgorithm: Optional[S3SseAlgorithm] + S3SseKmsKeyId: Optional[S3SseKmsKeyId] + ExportFormat: Optional[ExportFormat] + ExportType: Optional[ExportType] + IncrementalExportSpecification: Optional[IncrementalExportSpecification] + + +class ExportTableToPointInTimeOutput(TypedDict, total=False): + ExportDescription: Optional[ExportDescription] + + +FilterConditionMap = Dict[AttributeName, Condition] + + +class Get(TypedDict, total=False): + Key: Key + TableName: TableArn + ProjectionExpression: Optional[ProjectionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + + +class GetItemInput(ServiceRequest): + TableName: TableArn + Key: Key + AttributesToGet: Optional[AttributeNameList] + ConsistentRead: Optional[ConsistentRead] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + ProjectionExpression: Optional[ProjectionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + + +class GetItemOutput(TypedDict, total=False): + Item: Optional[AttributeMap] + ConsumedCapacity: Optional[ConsumedCapacity] + + +class GetResourcePolicyInput(ServiceRequest): + ResourceArn: ResourceArnString + + +class GetResourcePolicyOutput(TypedDict, total=False): + Policy: Optional[ResourcePolicy] + RevisionId: Optional[PolicyRevisionId] + + +class GlobalSecondaryIndexAutoScalingUpdate(TypedDict, total=False): + IndexName: Optional[IndexName] + ProvisionedWriteCapacityAutoScalingUpdate: Optional[AutoScalingSettingsUpdate] + + +GlobalSecondaryIndexAutoScalingUpdateList = List[GlobalSecondaryIndexAutoScalingUpdate] + + +class UpdateGlobalSecondaryIndexAction(TypedDict, total=False): + IndexName: IndexName + ProvisionedThroughput: Optional[ProvisionedThroughput] + OnDemandThroughput: Optional[OnDemandThroughput] + WarmThroughput: Optional[WarmThroughput] + + +class GlobalSecondaryIndexUpdate(TypedDict, total=False): + Update: Optional[UpdateGlobalSecondaryIndexAction] + Create: Optional[CreateGlobalSecondaryIndexAction] + Delete: Optional[DeleteGlobalSecondaryIndexAction] + + +GlobalSecondaryIndexUpdateList = List[GlobalSecondaryIndexUpdate] + + +class GlobalTable(TypedDict, total=False): + GlobalTableName: Optional[TableName] + ReplicationGroup: Optional[ReplicaList] + + +class GlobalTableGlobalSecondaryIndexSettingsUpdate(TypedDict, total=False): + IndexName: IndexName + ProvisionedWriteCapacityUnits: Optional[PositiveLongObject] + ProvisionedWriteCapacityAutoScalingSettingsUpdate: Optional[AutoScalingSettingsUpdate] + + +GlobalTableGlobalSecondaryIndexSettingsUpdateList = List[ + GlobalTableGlobalSecondaryIndexSettingsUpdate +] +GlobalTableList = List[GlobalTable] + + +class GlobalTableWitnessGroupUpdate(TypedDict, total=False): + Create: Optional[CreateGlobalTableWitnessGroupMemberAction] + Delete: Optional[DeleteGlobalTableWitnessGroupMemberAction] + + +GlobalTableWitnessGroupUpdateList = List[GlobalTableWitnessGroupUpdate] + + +class ImportSummary(TypedDict, total=False): + ImportArn: Optional[ImportArn] + ImportStatus: Optional[ImportStatus] + TableArn: Optional[TableArn] + S3BucketSource: Optional[S3BucketSource] + CloudWatchLogGroupArn: Optional[CloudWatchLogGroupArn] + InputFormat: Optional[InputFormat] + StartTime: Optional[ImportStartTime] + EndTime: Optional[ImportEndTime] + + +ImportSummaryList = List[ImportSummary] + + +class ImportTableInput(ServiceRequest): + ClientToken: Optional[ClientToken] + S3BucketSource: S3BucketSource + InputFormat: InputFormat + InputFormatOptions: Optional[InputFormatOptions] + InputCompressionType: Optional[InputCompressionType] + TableCreationParameters: TableCreationParameters + + +class ImportTableOutput(TypedDict, total=False): + ImportTableDescription: ImportTableDescription + + +KeyConditions = Dict[AttributeName, Condition] + + +class KinesisStreamingDestinationInput(ServiceRequest): + TableName: TableArn + StreamArn: StreamArn + EnableKinesisStreamingConfiguration: Optional[EnableKinesisStreamingConfiguration] + + +class KinesisStreamingDestinationOutput(TypedDict, total=False): + TableName: Optional[TableName] + StreamArn: Optional[StreamArn] + DestinationStatus: Optional[DestinationStatus] + EnableKinesisStreamingConfiguration: Optional[EnableKinesisStreamingConfiguration] + + +TimeRangeUpperBound = datetime +TimeRangeLowerBound = datetime + + +class ListBackupsInput(ServiceRequest): + TableName: Optional[TableArn] + Limit: Optional[BackupsInputLimit] + TimeRangeLowerBound: Optional[TimeRangeLowerBound] + TimeRangeUpperBound: Optional[TimeRangeUpperBound] + ExclusiveStartBackupArn: Optional[BackupArn] + BackupType: Optional[BackupTypeFilter] + + +class ListBackupsOutput(TypedDict, total=False): + BackupSummaries: Optional[BackupSummaries] + LastEvaluatedBackupArn: Optional[BackupArn] + + +class ListContributorInsightsInput(ServiceRequest): + TableName: Optional[TableArn] + NextToken: Optional[NextTokenString] + MaxResults: Optional[ListContributorInsightsLimit] + + +class ListContributorInsightsOutput(TypedDict, total=False): + ContributorInsightsSummaries: Optional[ContributorInsightsSummaries] + NextToken: Optional[NextTokenString] + + +class ListExportsInput(ServiceRequest): + TableArn: Optional[TableArn] + MaxResults: Optional[ListExportsMaxLimit] + NextToken: Optional[ExportNextToken] + + +class ListExportsOutput(TypedDict, total=False): + ExportSummaries: Optional[ExportSummaries] + NextToken: Optional[ExportNextToken] + + +class ListGlobalTablesInput(ServiceRequest): + ExclusiveStartGlobalTableName: Optional[TableName] + Limit: Optional[PositiveIntegerObject] + RegionName: Optional[RegionName] + + +class ListGlobalTablesOutput(TypedDict, total=False): + GlobalTables: Optional[GlobalTableList] + LastEvaluatedGlobalTableName: Optional[TableName] + + +class ListImportsInput(ServiceRequest): + TableArn: Optional[TableArn] + PageSize: Optional[ListImportsMaxLimit] + NextToken: Optional[ImportNextToken] + + +class ListImportsOutput(TypedDict, total=False): + ImportSummaryList: Optional[ImportSummaryList] + NextToken: Optional[ImportNextToken] + + +class ListTablesInput(ServiceRequest): + ExclusiveStartTableName: Optional[TableName] + Limit: Optional[ListTablesInputLimit] + + +TableNameList = List[TableName] + + +class ListTablesOutput(TypedDict, total=False): + TableNames: Optional[TableNameList] + LastEvaluatedTableName: Optional[TableName] + + +class ListTagsOfResourceInput(ServiceRequest): + ResourceArn: ResourceArnString + NextToken: Optional[NextTokenString] + + +class ListTagsOfResourceOutput(TypedDict, total=False): + Tags: Optional[TagList] + NextToken: Optional[NextTokenString] + + +class PointInTimeRecoverySpecification(TypedDict, total=False): + PointInTimeRecoveryEnabled: BooleanObject + RecoveryPeriodInDays: Optional[RecoveryPeriodInDays] + + +class Put(TypedDict, total=False): + Item: PutItemInputAttributeMap + TableName: TableArn + ConditionExpression: Optional[ConditionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class PutItemInput(ServiceRequest): + TableName: TableArn + Item: PutItemInputAttributeMap + Expected: Optional[ExpectedAttributeMap] + ReturnValues: Optional[ReturnValue] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + ReturnItemCollectionMetrics: Optional[ReturnItemCollectionMetrics] + ConditionalOperator: Optional[ConditionalOperator] + ConditionExpression: Optional[ConditionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class PutItemOutput(TypedDict, total=False): + Attributes: Optional[AttributeMap] + ConsumedCapacity: Optional[ConsumedCapacity] + ItemCollectionMetrics: Optional[ItemCollectionMetrics] + + +class PutResourcePolicyInput(ServiceRequest): + ResourceArn: ResourceArnString + Policy: ResourcePolicy + ExpectedRevisionId: Optional[PolicyRevisionId] + ConfirmRemoveSelfResourceAccess: Optional[ConfirmRemoveSelfResourceAccess] + + +class PutResourcePolicyOutput(TypedDict, total=False): + RevisionId: Optional[PolicyRevisionId] + + +class QueryInput(ServiceRequest): + TableName: TableArn + IndexName: Optional[IndexName] + Select: Optional[Select] + AttributesToGet: Optional[AttributeNameList] + Limit: Optional[PositiveIntegerObject] + ConsistentRead: Optional[ConsistentRead] + KeyConditions: Optional[KeyConditions] + QueryFilter: Optional[FilterConditionMap] + ConditionalOperator: Optional[ConditionalOperator] + ScanIndexForward: Optional[BooleanObject] + ExclusiveStartKey: Optional[Key] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + ProjectionExpression: Optional[ProjectionExpression] + FilterExpression: Optional[ConditionExpression] + KeyConditionExpression: Optional[KeyExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + + +class QueryOutput(TypedDict, total=False): + Items: Optional[ItemList] + Count: Optional[Integer] + ScannedCount: Optional[Integer] + LastEvaluatedKey: Optional[Key] + ConsumedCapacity: Optional[ConsumedCapacity] + + +class ReplicaGlobalSecondaryIndexAutoScalingUpdate(TypedDict, total=False): + IndexName: Optional[IndexName] + ProvisionedReadCapacityAutoScalingUpdate: Optional[AutoScalingSettingsUpdate] + + +ReplicaGlobalSecondaryIndexAutoScalingUpdateList = List[ + ReplicaGlobalSecondaryIndexAutoScalingUpdate +] + + +class ReplicaAutoScalingUpdate(TypedDict, total=False): + RegionName: RegionName + ReplicaGlobalSecondaryIndexUpdates: Optional[ReplicaGlobalSecondaryIndexAutoScalingUpdateList] + ReplicaProvisionedReadCapacityAutoScalingUpdate: Optional[AutoScalingSettingsUpdate] + + +ReplicaAutoScalingUpdateList = List[ReplicaAutoScalingUpdate] + + +class ReplicaGlobalSecondaryIndexSettingsUpdate(TypedDict, total=False): + IndexName: IndexName + ProvisionedReadCapacityUnits: Optional[PositiveLongObject] + ProvisionedReadCapacityAutoScalingSettingsUpdate: Optional[AutoScalingSettingsUpdate] + + +ReplicaGlobalSecondaryIndexSettingsUpdateList = List[ReplicaGlobalSecondaryIndexSettingsUpdate] + + +class ReplicaSettingsUpdate(TypedDict, total=False): + RegionName: RegionName + ReplicaProvisionedReadCapacityUnits: Optional[PositiveLongObject] + ReplicaProvisionedReadCapacityAutoScalingSettingsUpdate: Optional[AutoScalingSettingsUpdate] + ReplicaGlobalSecondaryIndexSettingsUpdate: Optional[ + ReplicaGlobalSecondaryIndexSettingsUpdateList + ] + ReplicaTableClass: Optional[TableClass] + + +ReplicaSettingsUpdateList = List[ReplicaSettingsUpdate] + + +class ReplicaUpdate(TypedDict, total=False): + Create: Optional[CreateReplicaAction] + Delete: Optional[DeleteReplicaAction] + + +ReplicaUpdateList = List[ReplicaUpdate] + + +class UpdateReplicationGroupMemberAction(TypedDict, total=False): + RegionName: RegionName + KMSMasterKeyId: Optional[KMSMasterKeyId] + ProvisionedThroughputOverride: Optional[ProvisionedThroughputOverride] + OnDemandThroughputOverride: Optional[OnDemandThroughputOverride] + GlobalSecondaryIndexes: Optional[ReplicaGlobalSecondaryIndexList] + TableClassOverride: Optional[TableClass] + + +class ReplicationGroupUpdate(TypedDict, total=False): + Create: Optional[CreateReplicationGroupMemberAction] + Update: Optional[UpdateReplicationGroupMemberAction] + Delete: Optional[DeleteReplicationGroupMemberAction] + + +ReplicationGroupUpdateList = List[ReplicationGroupUpdate] + + +class RestoreTableFromBackupInput(ServiceRequest): + TargetTableName: TableName + BackupArn: BackupArn + BillingModeOverride: Optional[BillingMode] + GlobalSecondaryIndexOverride: Optional[GlobalSecondaryIndexList] + LocalSecondaryIndexOverride: Optional[LocalSecondaryIndexList] + ProvisionedThroughputOverride: Optional[ProvisionedThroughput] + OnDemandThroughputOverride: Optional[OnDemandThroughput] + SSESpecificationOverride: Optional[SSESpecification] + + +class RestoreTableFromBackupOutput(TypedDict, total=False): + TableDescription: Optional[TableDescription] + + +class RestoreTableToPointInTimeInput(ServiceRequest): + SourceTableArn: Optional[TableArn] + SourceTableName: Optional[TableName] + TargetTableName: TableName + UseLatestRestorableTime: Optional[BooleanObject] + RestoreDateTime: Optional[Date] + BillingModeOverride: Optional[BillingMode] + GlobalSecondaryIndexOverride: Optional[GlobalSecondaryIndexList] + LocalSecondaryIndexOverride: Optional[LocalSecondaryIndexList] + ProvisionedThroughputOverride: Optional[ProvisionedThroughput] + OnDemandThroughputOverride: Optional[OnDemandThroughput] + SSESpecificationOverride: Optional[SSESpecification] + + +class RestoreTableToPointInTimeOutput(TypedDict, total=False): + TableDescription: Optional[TableDescription] + + +class ScanInput(ServiceRequest): + TableName: TableArn + IndexName: Optional[IndexName] + AttributesToGet: Optional[AttributeNameList] + Limit: Optional[PositiveIntegerObject] + Select: Optional[Select] + ScanFilter: Optional[FilterConditionMap] + ConditionalOperator: Optional[ConditionalOperator] + ExclusiveStartKey: Optional[Key] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + TotalSegments: Optional[ScanTotalSegments] + Segment: Optional[ScanSegment] + ProjectionExpression: Optional[ProjectionExpression] + FilterExpression: Optional[ConditionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ConsistentRead: Optional[ConsistentRead] + + +class ScanOutput(TypedDict, total=False): + Items: Optional[ItemList] + Count: Optional[Integer] + ScannedCount: Optional[Integer] + LastEvaluatedKey: Optional[Key] + ConsumedCapacity: Optional[ConsumedCapacity] + + +TagKeyList = List[TagKeyString] + + +class TagResourceInput(ServiceRequest): + ResourceArn: ResourceArnString + Tags: TagList + + +class TimeToLiveSpecification(TypedDict, total=False): + Enabled: TimeToLiveEnabled + AttributeName: TimeToLiveAttributeName + + +class TransactGetItem(TypedDict, total=False): + Get: Get + + +TransactGetItemList = List[TransactGetItem] + + +class TransactGetItemsInput(ServiceRequest): + TransactItems: TransactGetItemList + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + + +class TransactGetItemsOutput(TypedDict, total=False): + ConsumedCapacity: Optional[ConsumedCapacityMultiple] + Responses: Optional[ItemResponseList] + + +class Update(TypedDict, total=False): + Key: Key + UpdateExpression: UpdateExpression + TableName: TableArn + ConditionExpression: Optional[ConditionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class TransactWriteItem(TypedDict, total=False): + ConditionCheck: Optional[ConditionCheck] + Put: Optional[Put] + Delete: Optional[Delete] + Update: Optional[Update] + + +TransactWriteItemList = List[TransactWriteItem] + + +class TransactWriteItemsInput(ServiceRequest): + TransactItems: TransactWriteItemList + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + ReturnItemCollectionMetrics: Optional[ReturnItemCollectionMetrics] + ClientRequestToken: Optional[ClientRequestToken] + + +class TransactWriteItemsOutput(TypedDict, total=False): + ConsumedCapacity: Optional[ConsumedCapacityMultiple] + ItemCollectionMetrics: Optional[ItemCollectionMetricsPerTable] + + +class UntagResourceInput(ServiceRequest): + ResourceArn: ResourceArnString + TagKeys: TagKeyList + + +class UpdateContinuousBackupsInput(ServiceRequest): + TableName: TableArn + PointInTimeRecoverySpecification: PointInTimeRecoverySpecification + + +class UpdateContinuousBackupsOutput(TypedDict, total=False): + ContinuousBackupsDescription: Optional[ContinuousBackupsDescription] + + +class UpdateContributorInsightsInput(ServiceRequest): + TableName: TableArn + IndexName: Optional[IndexName] + ContributorInsightsAction: ContributorInsightsAction + + +class UpdateContributorInsightsOutput(TypedDict, total=False): + TableName: Optional[TableName] + IndexName: Optional[IndexName] + ContributorInsightsStatus: Optional[ContributorInsightsStatus] + + +class UpdateGlobalTableInput(ServiceRequest): + GlobalTableName: TableName + ReplicaUpdates: ReplicaUpdateList + + +class UpdateGlobalTableOutput(TypedDict, total=False): + GlobalTableDescription: Optional[GlobalTableDescription] + + +class UpdateGlobalTableSettingsInput(ServiceRequest): + GlobalTableName: TableName + GlobalTableBillingMode: Optional[BillingMode] + GlobalTableProvisionedWriteCapacityUnits: Optional[PositiveLongObject] + GlobalTableProvisionedWriteCapacityAutoScalingSettingsUpdate: Optional[ + AutoScalingSettingsUpdate + ] + GlobalTableGlobalSecondaryIndexSettingsUpdate: Optional[ + GlobalTableGlobalSecondaryIndexSettingsUpdateList + ] + ReplicaSettingsUpdate: Optional[ReplicaSettingsUpdateList] + + +class UpdateGlobalTableSettingsOutput(TypedDict, total=False): + GlobalTableName: Optional[TableName] + ReplicaSettings: Optional[ReplicaSettingsDescriptionList] + + +class UpdateItemInput(ServiceRequest): + TableName: TableArn + Key: Key + AttributeUpdates: Optional[AttributeUpdates] + Expected: Optional[ExpectedAttributeMap] + ConditionalOperator: Optional[ConditionalOperator] + ReturnValues: Optional[ReturnValue] + ReturnConsumedCapacity: Optional[ReturnConsumedCapacity] + ReturnItemCollectionMetrics: Optional[ReturnItemCollectionMetrics] + UpdateExpression: Optional[UpdateExpression] + ConditionExpression: Optional[ConditionExpression] + ExpressionAttributeNames: Optional[ExpressionAttributeNameMap] + ExpressionAttributeValues: Optional[ExpressionAttributeValueMap] + ReturnValuesOnConditionCheckFailure: Optional[ReturnValuesOnConditionCheckFailure] + + +class UpdateItemOutput(TypedDict, total=False): + Attributes: Optional[AttributeMap] + ConsumedCapacity: Optional[ConsumedCapacity] + ItemCollectionMetrics: Optional[ItemCollectionMetrics] + + +class UpdateKinesisStreamingConfiguration(TypedDict, total=False): + ApproximateCreationDateTimePrecision: Optional[ApproximateCreationDateTimePrecision] + + +class UpdateKinesisStreamingDestinationInput(ServiceRequest): + TableName: TableArn + StreamArn: StreamArn + UpdateKinesisStreamingConfiguration: Optional[UpdateKinesisStreamingConfiguration] + + +class UpdateKinesisStreamingDestinationOutput(TypedDict, total=False): + TableName: Optional[TableName] + StreamArn: Optional[StreamArn] + DestinationStatus: Optional[DestinationStatus] + UpdateKinesisStreamingConfiguration: Optional[UpdateKinesisStreamingConfiguration] + + +class UpdateTableInput(ServiceRequest): + AttributeDefinitions: Optional[AttributeDefinitions] + TableName: TableArn + BillingMode: Optional[BillingMode] + ProvisionedThroughput: Optional[ProvisionedThroughput] + GlobalSecondaryIndexUpdates: Optional[GlobalSecondaryIndexUpdateList] + StreamSpecification: Optional[StreamSpecification] + SSESpecification: Optional[SSESpecification] + ReplicaUpdates: Optional[ReplicationGroupUpdateList] + TableClass: Optional[TableClass] + DeletionProtectionEnabled: Optional[DeletionProtectionEnabled] + MultiRegionConsistency: Optional[MultiRegionConsistency] + GlobalTableWitnessUpdates: Optional[GlobalTableWitnessGroupUpdateList] + OnDemandThroughput: Optional[OnDemandThroughput] + WarmThroughput: Optional[WarmThroughput] + + +class UpdateTableOutput(TypedDict, total=False): + TableDescription: Optional[TableDescription] + + +class UpdateTableReplicaAutoScalingInput(ServiceRequest): + GlobalSecondaryIndexUpdates: Optional[GlobalSecondaryIndexAutoScalingUpdateList] + TableName: TableArn + ProvisionedWriteCapacityAutoScalingUpdate: Optional[AutoScalingSettingsUpdate] + ReplicaUpdates: Optional[ReplicaAutoScalingUpdateList] + + +class UpdateTableReplicaAutoScalingOutput(TypedDict, total=False): + TableAutoScalingDescription: Optional[TableAutoScalingDescription] + + +class UpdateTimeToLiveInput(ServiceRequest): + TableName: TableArn + TimeToLiveSpecification: TimeToLiveSpecification + + +class UpdateTimeToLiveOutput(TypedDict, total=False): + TimeToLiveSpecification: Optional[TimeToLiveSpecification] + + +class DynamodbApi: + service = "dynamodb" + version = "2012-08-10" + + @handler("BatchExecuteStatement") + def batch_execute_statement( + self, + context: RequestContext, + statements: PartiQLBatchRequest, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + **kwargs, + ) -> BatchExecuteStatementOutput: + raise NotImplementedError + + @handler("BatchGetItem") + def batch_get_item( + self, + context: RequestContext, + request_items: BatchGetRequestMap, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + **kwargs, + ) -> BatchGetItemOutput: + raise NotImplementedError + + @handler("BatchWriteItem") + def batch_write_item( + self, + context: RequestContext, + request_items: BatchWriteItemRequestMap, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + return_item_collection_metrics: ReturnItemCollectionMetrics | None = None, + **kwargs, + ) -> BatchWriteItemOutput: + raise NotImplementedError + + @handler("CreateBackup") + def create_backup( + self, context: RequestContext, table_name: TableArn, backup_name: BackupName, **kwargs + ) -> CreateBackupOutput: + raise NotImplementedError + + @handler("CreateGlobalTable") + def create_global_table( + self, + context: RequestContext, + global_table_name: TableName, + replication_group: ReplicaList, + **kwargs, + ) -> CreateGlobalTableOutput: + raise NotImplementedError + + @handler("CreateTable") + def create_table( + self, + context: RequestContext, + attribute_definitions: AttributeDefinitions, + table_name: TableArn, + key_schema: KeySchema, + local_secondary_indexes: LocalSecondaryIndexList | None = None, + global_secondary_indexes: GlobalSecondaryIndexList | None = None, + billing_mode: BillingMode | None = None, + provisioned_throughput: ProvisionedThroughput | None = None, + stream_specification: StreamSpecification | None = None, + sse_specification: SSESpecification | None = None, + tags: TagList | None = None, + table_class: TableClass | None = None, + deletion_protection_enabled: DeletionProtectionEnabled | None = None, + warm_throughput: WarmThroughput | None = None, + resource_policy: ResourcePolicy | None = None, + on_demand_throughput: OnDemandThroughput | None = None, + **kwargs, + ) -> CreateTableOutput: + raise NotImplementedError + + @handler("DeleteBackup") + def delete_backup( + self, context: RequestContext, backup_arn: BackupArn, **kwargs + ) -> DeleteBackupOutput: + raise NotImplementedError + + @handler("DeleteItem") + def delete_item( + self, + context: RequestContext, + table_name: TableArn, + key: Key, + expected: ExpectedAttributeMap | None = None, + conditional_operator: ConditionalOperator | None = None, + return_values: ReturnValue | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + return_item_collection_metrics: ReturnItemCollectionMetrics | None = None, + condition_expression: ConditionExpression | None = None, + expression_attribute_names: ExpressionAttributeNameMap | None = None, + expression_attribute_values: ExpressionAttributeValueMap | None = None, + return_values_on_condition_check_failure: ReturnValuesOnConditionCheckFailure | None = None, + **kwargs, + ) -> DeleteItemOutput: + raise NotImplementedError + + @handler("DeleteResourcePolicy") + def delete_resource_policy( + self, + context: RequestContext, + resource_arn: ResourceArnString, + expected_revision_id: PolicyRevisionId | None = None, + **kwargs, + ) -> DeleteResourcePolicyOutput: + raise NotImplementedError + + @handler("DeleteTable") + def delete_table( + self, context: RequestContext, table_name: TableArn, **kwargs + ) -> DeleteTableOutput: + raise NotImplementedError + + @handler("DescribeBackup") + def describe_backup( + self, context: RequestContext, backup_arn: BackupArn, **kwargs + ) -> DescribeBackupOutput: + raise NotImplementedError + + @handler("DescribeContinuousBackups") + def describe_continuous_backups( + self, context: RequestContext, table_name: TableArn, **kwargs + ) -> DescribeContinuousBackupsOutput: + raise NotImplementedError + + @handler("DescribeContributorInsights") + def describe_contributor_insights( + self, + context: RequestContext, + table_name: TableArn, + index_name: IndexName | None = None, + **kwargs, + ) -> DescribeContributorInsightsOutput: + raise NotImplementedError + + @handler("DescribeEndpoints") + def describe_endpoints(self, context: RequestContext, **kwargs) -> DescribeEndpointsResponse: + raise NotImplementedError + + @handler("DescribeExport") + def describe_export( + self, context: RequestContext, export_arn: ExportArn, **kwargs + ) -> DescribeExportOutput: + raise NotImplementedError + + @handler("DescribeGlobalTable") + def describe_global_table( + self, context: RequestContext, global_table_name: TableName, **kwargs + ) -> DescribeGlobalTableOutput: + raise NotImplementedError + + @handler("DescribeGlobalTableSettings") + def describe_global_table_settings( + self, context: RequestContext, global_table_name: TableName, **kwargs + ) -> DescribeGlobalTableSettingsOutput: + raise NotImplementedError + + @handler("DescribeImport") + def describe_import( + self, context: RequestContext, import_arn: ImportArn, **kwargs + ) -> DescribeImportOutput: + raise NotImplementedError + + @handler("DescribeKinesisStreamingDestination") + def describe_kinesis_streaming_destination( + self, context: RequestContext, table_name: TableArn, **kwargs + ) -> DescribeKinesisStreamingDestinationOutput: + raise NotImplementedError + + @handler("DescribeLimits") + def describe_limits(self, context: RequestContext, **kwargs) -> DescribeLimitsOutput: + raise NotImplementedError + + @handler("DescribeTable") + def describe_table( + self, context: RequestContext, table_name: TableArn, **kwargs + ) -> DescribeTableOutput: + raise NotImplementedError + + @handler("DescribeTableReplicaAutoScaling") + def describe_table_replica_auto_scaling( + self, context: RequestContext, table_name: TableArn, **kwargs + ) -> DescribeTableReplicaAutoScalingOutput: + raise NotImplementedError + + @handler("DescribeTimeToLive") + def describe_time_to_live( + self, context: RequestContext, table_name: TableArn, **kwargs + ) -> DescribeTimeToLiveOutput: + raise NotImplementedError + + @handler("DisableKinesisStreamingDestination") + def disable_kinesis_streaming_destination( + self, + context: RequestContext, + table_name: TableArn, + stream_arn: StreamArn, + enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration | None = None, + **kwargs, + ) -> KinesisStreamingDestinationOutput: + raise NotImplementedError + + @handler("EnableKinesisStreamingDestination") + def enable_kinesis_streaming_destination( + self, + context: RequestContext, + table_name: TableArn, + stream_arn: StreamArn, + enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration | None = None, + **kwargs, + ) -> KinesisStreamingDestinationOutput: + raise NotImplementedError + + @handler("ExecuteStatement") + def execute_statement( + self, + context: RequestContext, + statement: PartiQLStatement, + parameters: PreparedStatementParameters | None = None, + consistent_read: ConsistentRead | None = None, + next_token: PartiQLNextToken | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + limit: PositiveIntegerObject | None = None, + return_values_on_condition_check_failure: ReturnValuesOnConditionCheckFailure | None = None, + **kwargs, + ) -> ExecuteStatementOutput: + raise NotImplementedError + + @handler("ExecuteTransaction") + def execute_transaction( + self, + context: RequestContext, + transact_statements: ParameterizedStatements, + client_request_token: ClientRequestToken | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + **kwargs, + ) -> ExecuteTransactionOutput: + raise NotImplementedError + + @handler("ExportTableToPointInTime") + def export_table_to_point_in_time( + self, + context: RequestContext, + table_arn: TableArn, + s3_bucket: S3Bucket, + export_time: ExportTime | None = None, + client_token: ClientToken | None = None, + s3_bucket_owner: S3BucketOwner | None = None, + s3_prefix: S3Prefix | None = None, + s3_sse_algorithm: S3SseAlgorithm | None = None, + s3_sse_kms_key_id: S3SseKmsKeyId | None = None, + export_format: ExportFormat | None = None, + export_type: ExportType | None = None, + incremental_export_specification: IncrementalExportSpecification | None = None, + **kwargs, + ) -> ExportTableToPointInTimeOutput: + raise NotImplementedError + + @handler("GetItem") + def get_item( + self, + context: RequestContext, + table_name: TableArn, + key: Key, + attributes_to_get: AttributeNameList | None = None, + consistent_read: ConsistentRead | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + projection_expression: ProjectionExpression | None = None, + expression_attribute_names: ExpressionAttributeNameMap | None = None, + **kwargs, + ) -> GetItemOutput: + raise NotImplementedError + + @handler("GetResourcePolicy") + def get_resource_policy( + self, context: RequestContext, resource_arn: ResourceArnString, **kwargs + ) -> GetResourcePolicyOutput: + raise NotImplementedError + + @handler("ImportTable") + def import_table( + self, + context: RequestContext, + s3_bucket_source: S3BucketSource, + input_format: InputFormat, + table_creation_parameters: TableCreationParameters, + client_token: ClientToken | None = None, + input_format_options: InputFormatOptions | None = None, + input_compression_type: InputCompressionType | None = None, + **kwargs, + ) -> ImportTableOutput: + raise NotImplementedError + + @handler("ListBackups") + def list_backups( + self, + context: RequestContext, + table_name: TableArn | None = None, + limit: BackupsInputLimit | None = None, + time_range_lower_bound: TimeRangeLowerBound | None = None, + time_range_upper_bound: TimeRangeUpperBound | None = None, + exclusive_start_backup_arn: BackupArn | None = None, + backup_type: BackupTypeFilter | None = None, + **kwargs, + ) -> ListBackupsOutput: + raise NotImplementedError + + @handler("ListContributorInsights") + def list_contributor_insights( + self, + context: RequestContext, + table_name: TableArn | None = None, + next_token: NextTokenString | None = None, + max_results: ListContributorInsightsLimit | None = None, + **kwargs, + ) -> ListContributorInsightsOutput: + raise NotImplementedError + + @handler("ListExports") + def list_exports( + self, + context: RequestContext, + table_arn: TableArn | None = None, + max_results: ListExportsMaxLimit | None = None, + next_token: ExportNextToken | None = None, + **kwargs, + ) -> ListExportsOutput: + raise NotImplementedError + + @handler("ListGlobalTables") + def list_global_tables( + self, + context: RequestContext, + exclusive_start_global_table_name: TableName | None = None, + limit: PositiveIntegerObject | None = None, + region_name: RegionName | None = None, + **kwargs, + ) -> ListGlobalTablesOutput: + raise NotImplementedError + + @handler("ListImports") + def list_imports( + self, + context: RequestContext, + table_arn: TableArn | None = None, + page_size: ListImportsMaxLimit | None = None, + next_token: ImportNextToken | None = None, + **kwargs, + ) -> ListImportsOutput: + raise NotImplementedError + + @handler("ListTables") + def list_tables( + self, + context: RequestContext, + exclusive_start_table_name: TableName | None = None, + limit: ListTablesInputLimit | None = None, + **kwargs, + ) -> ListTablesOutput: + raise NotImplementedError + + @handler("ListTagsOfResource") + def list_tags_of_resource( + self, + context: RequestContext, + resource_arn: ResourceArnString, + next_token: NextTokenString | None = None, + **kwargs, + ) -> ListTagsOfResourceOutput: + raise NotImplementedError + + @handler("PutItem") + def put_item( + self, + context: RequestContext, + table_name: TableArn, + item: PutItemInputAttributeMap, + expected: ExpectedAttributeMap | None = None, + return_values: ReturnValue | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + return_item_collection_metrics: ReturnItemCollectionMetrics | None = None, + conditional_operator: ConditionalOperator | None = None, + condition_expression: ConditionExpression | None = None, + expression_attribute_names: ExpressionAttributeNameMap | None = None, + expression_attribute_values: ExpressionAttributeValueMap | None = None, + return_values_on_condition_check_failure: ReturnValuesOnConditionCheckFailure | None = None, + **kwargs, + ) -> PutItemOutput: + raise NotImplementedError + + @handler("PutResourcePolicy") + def put_resource_policy( + self, + context: RequestContext, + resource_arn: ResourceArnString, + policy: ResourcePolicy, + expected_revision_id: PolicyRevisionId | None = None, + confirm_remove_self_resource_access: ConfirmRemoveSelfResourceAccess | None = None, + **kwargs, + ) -> PutResourcePolicyOutput: + raise NotImplementedError + + @handler("Query") + def query( + self, + context: RequestContext, + table_name: TableArn, + index_name: IndexName | None = None, + select: Select | None = None, + attributes_to_get: AttributeNameList | None = None, + limit: PositiveIntegerObject | None = None, + consistent_read: ConsistentRead | None = None, + key_conditions: KeyConditions | None = None, + query_filter: FilterConditionMap | None = None, + conditional_operator: ConditionalOperator | None = None, + scan_index_forward: BooleanObject | None = None, + exclusive_start_key: Key | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + projection_expression: ProjectionExpression | None = None, + filter_expression: ConditionExpression | None = None, + key_condition_expression: KeyExpression | None = None, + expression_attribute_names: ExpressionAttributeNameMap | None = None, + expression_attribute_values: ExpressionAttributeValueMap | None = None, + **kwargs, + ) -> QueryOutput: + raise NotImplementedError + + @handler("RestoreTableFromBackup") + def restore_table_from_backup( + self, + context: RequestContext, + target_table_name: TableName, + backup_arn: BackupArn, + billing_mode_override: BillingMode | None = None, + global_secondary_index_override: GlobalSecondaryIndexList | None = None, + local_secondary_index_override: LocalSecondaryIndexList | None = None, + provisioned_throughput_override: ProvisionedThroughput | None = None, + on_demand_throughput_override: OnDemandThroughput | None = None, + sse_specification_override: SSESpecification | None = None, + **kwargs, + ) -> RestoreTableFromBackupOutput: + raise NotImplementedError + + @handler("RestoreTableToPointInTime") + def restore_table_to_point_in_time( + self, + context: RequestContext, + target_table_name: TableName, + source_table_arn: TableArn | None = None, + source_table_name: TableName | None = None, + use_latest_restorable_time: BooleanObject | None = None, + restore_date_time: Date | None = None, + billing_mode_override: BillingMode | None = None, + global_secondary_index_override: GlobalSecondaryIndexList | None = None, + local_secondary_index_override: LocalSecondaryIndexList | None = None, + provisioned_throughput_override: ProvisionedThroughput | None = None, + on_demand_throughput_override: OnDemandThroughput | None = None, + sse_specification_override: SSESpecification | None = None, + **kwargs, + ) -> RestoreTableToPointInTimeOutput: + raise NotImplementedError + + @handler("Scan") + def scan( + self, + context: RequestContext, + table_name: TableArn, + index_name: IndexName | None = None, + attributes_to_get: AttributeNameList | None = None, + limit: PositiveIntegerObject | None = None, + select: Select | None = None, + scan_filter: FilterConditionMap | None = None, + conditional_operator: ConditionalOperator | None = None, + exclusive_start_key: Key | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + total_segments: ScanTotalSegments | None = None, + segment: ScanSegment | None = None, + projection_expression: ProjectionExpression | None = None, + filter_expression: ConditionExpression | None = None, + expression_attribute_names: ExpressionAttributeNameMap | None = None, + expression_attribute_values: ExpressionAttributeValueMap | None = None, + consistent_read: ConsistentRead | None = None, + **kwargs, + ) -> ScanOutput: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: ResourceArnString, tags: TagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TransactGetItems") + def transact_get_items( + self, + context: RequestContext, + transact_items: TransactGetItemList, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + **kwargs, + ) -> TransactGetItemsOutput: + raise NotImplementedError + + @handler("TransactWriteItems") + def transact_write_items( + self, + context: RequestContext, + transact_items: TransactWriteItemList, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + return_item_collection_metrics: ReturnItemCollectionMetrics | None = None, + client_request_token: ClientRequestToken | None = None, + **kwargs, + ) -> TransactWriteItemsOutput: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, + context: RequestContext, + resource_arn: ResourceArnString, + tag_keys: TagKeyList, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateContinuousBackups") + def update_continuous_backups( + self, + context: RequestContext, + table_name: TableArn, + point_in_time_recovery_specification: PointInTimeRecoverySpecification, + **kwargs, + ) -> UpdateContinuousBackupsOutput: + raise NotImplementedError + + @handler("UpdateContributorInsights") + def update_contributor_insights( + self, + context: RequestContext, + table_name: TableArn, + contributor_insights_action: ContributorInsightsAction, + index_name: IndexName | None = None, + **kwargs, + ) -> UpdateContributorInsightsOutput: + raise NotImplementedError + + @handler("UpdateGlobalTable") + def update_global_table( + self, + context: RequestContext, + global_table_name: TableName, + replica_updates: ReplicaUpdateList, + **kwargs, + ) -> UpdateGlobalTableOutput: + raise NotImplementedError + + @handler("UpdateGlobalTableSettings") + def update_global_table_settings( + self, + context: RequestContext, + global_table_name: TableName, + global_table_billing_mode: BillingMode | None = None, + global_table_provisioned_write_capacity_units: PositiveLongObject | None = None, + global_table_provisioned_write_capacity_auto_scaling_settings_update: AutoScalingSettingsUpdate + | None = None, + global_table_global_secondary_index_settings_update: GlobalTableGlobalSecondaryIndexSettingsUpdateList + | None = None, + replica_settings_update: ReplicaSettingsUpdateList | None = None, + **kwargs, + ) -> UpdateGlobalTableSettingsOutput: + raise NotImplementedError + + @handler("UpdateItem") + def update_item( + self, + context: RequestContext, + table_name: TableArn, + key: Key, + attribute_updates: AttributeUpdates | None = None, + expected: ExpectedAttributeMap | None = None, + conditional_operator: ConditionalOperator | None = None, + return_values: ReturnValue | None = None, + return_consumed_capacity: ReturnConsumedCapacity | None = None, + return_item_collection_metrics: ReturnItemCollectionMetrics | None = None, + update_expression: UpdateExpression | None = None, + condition_expression: ConditionExpression | None = None, + expression_attribute_names: ExpressionAttributeNameMap | None = None, + expression_attribute_values: ExpressionAttributeValueMap | None = None, + return_values_on_condition_check_failure: ReturnValuesOnConditionCheckFailure | None = None, + **kwargs, + ) -> UpdateItemOutput: + raise NotImplementedError + + @handler("UpdateKinesisStreamingDestination") + def update_kinesis_streaming_destination( + self, + context: RequestContext, + table_name: TableArn, + stream_arn: StreamArn, + update_kinesis_streaming_configuration: UpdateKinesisStreamingConfiguration | None = None, + **kwargs, + ) -> UpdateKinesisStreamingDestinationOutput: + raise NotImplementedError + + @handler("UpdateTable") + def update_table( + self, + context: RequestContext, + table_name: TableArn, + attribute_definitions: AttributeDefinitions | None = None, + billing_mode: BillingMode | None = None, + provisioned_throughput: ProvisionedThroughput | None = None, + global_secondary_index_updates: GlobalSecondaryIndexUpdateList | None = None, + stream_specification: StreamSpecification | None = None, + sse_specification: SSESpecification | None = None, + replica_updates: ReplicationGroupUpdateList | None = None, + table_class: TableClass | None = None, + deletion_protection_enabled: DeletionProtectionEnabled | None = None, + multi_region_consistency: MultiRegionConsistency | None = None, + global_table_witness_updates: GlobalTableWitnessGroupUpdateList | None = None, + on_demand_throughput: OnDemandThroughput | None = None, + warm_throughput: WarmThroughput | None = None, + **kwargs, + ) -> UpdateTableOutput: + raise NotImplementedError + + @handler("UpdateTableReplicaAutoScaling") + def update_table_replica_auto_scaling( + self, + context: RequestContext, + table_name: TableArn, + global_secondary_index_updates: GlobalSecondaryIndexAutoScalingUpdateList | None = None, + provisioned_write_capacity_auto_scaling_update: AutoScalingSettingsUpdate | None = None, + replica_updates: ReplicaAutoScalingUpdateList | None = None, + **kwargs, + ) -> UpdateTableReplicaAutoScalingOutput: + raise NotImplementedError + + @handler("UpdateTimeToLive") + def update_time_to_live( + self, + context: RequestContext, + table_name: TableArn, + time_to_live_specification: TimeToLiveSpecification, + **kwargs, + ) -> UpdateTimeToLiveOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/dynamodbstreams/__init__.py b/localstack-core/localstack/aws/api/dynamodbstreams/__init__.py new file mode 100644 index 0000000000000..a9ecabeff5864 --- /dev/null +++ b/localstack-core/localstack/aws/api/dynamodbstreams/__init__.py @@ -0,0 +1,270 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AttributeName = str +BooleanAttributeValue = bool +ErrorMessage = str +KeySchemaAttributeName = str +NullAttributeValue = bool +NumberAttributeValue = str +PositiveIntegerObject = int +SequenceNumber = str +ShardId = str +ShardIterator = str +StreamArn = str +String = str +StringAttributeValue = str +TableName = str + + +class KeyType(StrEnum): + HASH = "HASH" + RANGE = "RANGE" + + +class OperationType(StrEnum): + INSERT = "INSERT" + MODIFY = "MODIFY" + REMOVE = "REMOVE" + + +class ShardIteratorType(StrEnum): + TRIM_HORIZON = "TRIM_HORIZON" + LATEST = "LATEST" + AT_SEQUENCE_NUMBER = "AT_SEQUENCE_NUMBER" + AFTER_SEQUENCE_NUMBER = "AFTER_SEQUENCE_NUMBER" + + +class StreamStatus(StrEnum): + ENABLING = "ENABLING" + ENABLED = "ENABLED" + DISABLING = "DISABLING" + DISABLED = "DISABLED" + + +class StreamViewType(StrEnum): + NEW_IMAGE = "NEW_IMAGE" + OLD_IMAGE = "OLD_IMAGE" + NEW_AND_OLD_IMAGES = "NEW_AND_OLD_IMAGES" + KEYS_ONLY = "KEYS_ONLY" + + +class ExpiredIteratorException(ServiceException): + code: str = "ExpiredIteratorException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServerError(ServiceException): + code: str = "InternalServerError" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class TrimmedDataAccessException(ServiceException): + code: str = "TrimmedDataAccessException" + sender_fault: bool = False + status_code: int = 400 + + +class AttributeValue(TypedDict, total=False): + S: Optional["StringAttributeValue"] + N: Optional["NumberAttributeValue"] + B: Optional["BinaryAttributeValue"] + SS: Optional["StringSetAttributeValue"] + NS: Optional["NumberSetAttributeValue"] + BS: Optional["BinarySetAttributeValue"] + M: Optional["MapAttributeValue"] + L: Optional["ListAttributeValue"] + NULL: Optional["NullAttributeValue"] + BOOL: Optional["BooleanAttributeValue"] + + +ListAttributeValue = List[AttributeValue] +MapAttributeValue = Dict[AttributeName, AttributeValue] +BinaryAttributeValue = bytes +BinarySetAttributeValue = List[BinaryAttributeValue] +NumberSetAttributeValue = List[NumberAttributeValue] +StringSetAttributeValue = List[StringAttributeValue] +AttributeMap = Dict[AttributeName, AttributeValue] +Date = datetime + + +class DescribeStreamInput(ServiceRequest): + StreamArn: StreamArn + Limit: Optional[PositiveIntegerObject] + ExclusiveStartShardId: Optional[ShardId] + + +class SequenceNumberRange(TypedDict, total=False): + StartingSequenceNumber: Optional[SequenceNumber] + EndingSequenceNumber: Optional[SequenceNumber] + + +class Shard(TypedDict, total=False): + ShardId: Optional[ShardId] + SequenceNumberRange: Optional[SequenceNumberRange] + ParentShardId: Optional[ShardId] + + +ShardDescriptionList = List[Shard] + + +class KeySchemaElement(TypedDict, total=False): + AttributeName: KeySchemaAttributeName + KeyType: KeyType + + +KeySchema = List[KeySchemaElement] + + +class StreamDescription(TypedDict, total=False): + StreamArn: Optional[StreamArn] + StreamLabel: Optional[String] + StreamStatus: Optional[StreamStatus] + StreamViewType: Optional[StreamViewType] + CreationRequestDateTime: Optional[Date] + TableName: Optional[TableName] + KeySchema: Optional[KeySchema] + Shards: Optional[ShardDescriptionList] + LastEvaluatedShardId: Optional[ShardId] + + +class DescribeStreamOutput(TypedDict, total=False): + StreamDescription: Optional[StreamDescription] + + +class GetRecordsInput(ServiceRequest): + ShardIterator: ShardIterator + Limit: Optional[PositiveIntegerObject] + + +class Identity(TypedDict, total=False): + PrincipalId: Optional[String] + Type: Optional[String] + + +PositiveLongObject = int + + +class StreamRecord(TypedDict, total=False): + ApproximateCreationDateTime: Optional[Date] + Keys: Optional[AttributeMap] + NewImage: Optional[AttributeMap] + OldImage: Optional[AttributeMap] + SequenceNumber: Optional[SequenceNumber] + SizeBytes: Optional[PositiveLongObject] + StreamViewType: Optional[StreamViewType] + + +class Record(TypedDict, total=False): + eventID: Optional[String] + eventName: Optional[OperationType] + eventVersion: Optional[String] + eventSource: Optional[String] + awsRegion: Optional[String] + dynamodb: Optional[StreamRecord] + userIdentity: Optional[Identity] + + +RecordList = List[Record] + + +class GetRecordsOutput(TypedDict, total=False): + Records: Optional[RecordList] + NextShardIterator: Optional[ShardIterator] + + +class GetShardIteratorInput(ServiceRequest): + StreamArn: StreamArn + ShardId: ShardId + ShardIteratorType: ShardIteratorType + SequenceNumber: Optional[SequenceNumber] + + +class GetShardIteratorOutput(TypedDict, total=False): + ShardIterator: Optional[ShardIterator] + + +class ListStreamsInput(ServiceRequest): + TableName: Optional[TableName] + Limit: Optional[PositiveIntegerObject] + ExclusiveStartStreamArn: Optional[StreamArn] + + +class Stream(TypedDict, total=False): + StreamArn: Optional[StreamArn] + TableName: Optional[TableName] + StreamLabel: Optional[String] + + +StreamList = List[Stream] + + +class ListStreamsOutput(TypedDict, total=False): + Streams: Optional[StreamList] + LastEvaluatedStreamArn: Optional[StreamArn] + + +class DynamodbstreamsApi: + service = "dynamodbstreams" + version = "2012-08-10" + + @handler("DescribeStream") + def describe_stream( + self, + context: RequestContext, + stream_arn: StreamArn, + limit: PositiveIntegerObject | None = None, + exclusive_start_shard_id: ShardId | None = None, + **kwargs, + ) -> DescribeStreamOutput: + raise NotImplementedError + + @handler("GetRecords") + def get_records( + self, + context: RequestContext, + shard_iterator: ShardIterator, + limit: PositiveIntegerObject | None = None, + **kwargs, + ) -> GetRecordsOutput: + raise NotImplementedError + + @handler("GetShardIterator") + def get_shard_iterator( + self, + context: RequestContext, + stream_arn: StreamArn, + shard_id: ShardId, + shard_iterator_type: ShardIteratorType, + sequence_number: SequenceNumber | None = None, + **kwargs, + ) -> GetShardIteratorOutput: + raise NotImplementedError + + @handler("ListStreams") + def list_streams( + self, + context: RequestContext, + table_name: TableName | None = None, + limit: PositiveIntegerObject | None = None, + exclusive_start_stream_arn: StreamArn | None = None, + **kwargs, + ) -> ListStreamsOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/ec2/__init__.py b/localstack-core/localstack/aws/api/ec2/__init__.py new file mode 100644 index 0000000000000..faeb55f9d6697 --- /dev/null +++ b/localstack-core/localstack/aws/api/ec2/__init__.py @@ -0,0 +1,29173 @@ +from datetime import datetime +from enum import StrEnum +from typing import List, Optional, TypedDict + +from localstack.aws.api import ( + RequestContext, + ServiceRequest, + handler, +) +from localstack.aws.api import ( + ServiceException as ServiceException, +) + +AccountID = str +AddressMaxResults = int +AllocationId = str +AllowedInstanceType = str +AssetId = str +AutoRecoveryFlag = bool +AvailabilityZoneId = str +AvailabilityZoneName = str +BareMetalFlag = bool +BaselineBandwidthInGbps = float +BaselineBandwidthInMbps = int +BaselineIops = int +BaselineThroughputInMBps = float +Boolean = bool +BoxedDouble = float +BoxedInteger = int +BundleId = str +BurstablePerformanceFlag = bool +CancelCapacityReservationFleetErrorCode = str +CancelCapacityReservationFleetErrorMessage = str +CapacityBlockId = str +CapacityReservationFleetId = str +CapacityReservationId = str +CarrierGatewayId = str +CarrierGatewayMaxResults = int +CertificateArn = str +CertificateId = str +ClientSecretType = str +ClientVpnEndpointId = str +CloudWatchLogGroupArn = str +CoipPoolId = str +CoipPoolMaxResults = int +ComponentAccount = str +ComponentRegion = str +ConnectionNotificationId = str +ConversionTaskId = str +CoolOffPeriodRequestHours = int +CoolOffPeriodResponseHours = int +CopySnapshotRequestPSU = str +CoreCount = int +CoreNetworkArn = str +CpuManufacturerName = str +CurrentGenerationFlag = bool +CustomerGatewayId = str +DITMaxResults = int +DITOMaxResults = int +DeclarativePoliciesMaxResults = int +DeclarativePoliciesReportId = str +DedicatedHostFlag = bool +DedicatedHostId = str +DefaultEnaQueueCountPerInterface = int +DefaultNetworkCardIndex = int +DefaultingDhcpOptionsId = str +DescribeAddressTransfersMaxResults = int +DescribeByoipCidrsMaxResults = int +DescribeCapacityBlockExtensionOfferingsMaxResults = int +DescribeCapacityBlockOfferingsMaxResults = int +DescribeCapacityBlockStatusMaxResults = int +DescribeCapacityBlocksMaxResults = int +DescribeCapacityReservationBillingRequestsRequestMaxResults = int +DescribeCapacityReservationFleetsMaxResults = int +DescribeCapacityReservationsMaxResults = int +DescribeClassicLinkInstancesMaxResults = int +DescribeClientVpnAuthorizationRulesMaxResults = int +DescribeClientVpnConnectionsMaxResults = int +DescribeClientVpnEndpointMaxResults = int +DescribeClientVpnRoutesMaxResults = int +DescribeClientVpnTargetNetworksMaxResults = int +DescribeDhcpOptionsMaxResults = int +DescribeEgressOnlyInternetGatewaysMaxResults = int +DescribeElasticGpusMaxResults = int +DescribeExportImageTasksMaxResults = int +DescribeFastLaunchImagesRequestMaxResults = int +DescribeFastSnapshotRestoresMaxResults = int +DescribeFpgaImagesMaxResults = int +DescribeFutureCapacityMaxResults = int +DescribeHostReservationsMaxResults = int +DescribeIamInstanceProfileAssociationsMaxResults = int +DescribeInstanceCreditSpecificationsMaxResults = int +DescribeInstanceImageMetadataMaxResults = int +DescribeInstanceTopologyMaxResults = int +DescribeInternetGatewaysMaxResults = int +DescribeIpamByoasnMaxResults = int +DescribeLaunchTemplatesMaxResults = int +DescribeLockedSnapshotsMaxResults = int +DescribeMacHostsRequestMaxResults = int +DescribeMacModificationTasksMaxResults = int +DescribeMovingAddressesMaxResults = int +DescribeNatGatewaysMaxResults = int +DescribeNetworkAclsMaxResults = int +DescribeNetworkInterfacePermissionsMaxResults = int +DescribeNetworkInterfacesMaxResults = int +DescribePrincipalIdFormatMaxResults = int +DescribeReplaceRootVolumeTasksMaxResults = int +DescribeRouteTablesMaxResults = int +DescribeScheduledInstanceAvailabilityMaxResults = int +DescribeSecurityGroupRulesMaxResults = int +DescribeSecurityGroupVpcAssociationsMaxResults = int +DescribeSecurityGroupsMaxResults = int +DescribeSnapshotTierStatusMaxResults = int +DescribeSpotFleetInstancesMaxResults = int +DescribeSpotFleetRequestHistoryMaxResults = int +DescribeStaleSecurityGroupsMaxResults = int +DescribeStaleSecurityGroupsNextToken = str +DescribeStoreImageTasksRequestMaxResults = int +DescribeSubnetsMaxResults = int +DescribeTrunkInterfaceAssociationsMaxResults = int +DescribeVerifiedAccessEndpointsMaxResults = int +DescribeVerifiedAccessGroupMaxResults = int +DescribeVerifiedAccessInstanceLoggingConfigurationsMaxResults = int +DescribeVerifiedAccessInstancesMaxResults = int +DescribeVerifiedAccessTrustProvidersMaxResults = int +DescribeVpcBlockPublicAccessExclusionsMaxResults = int +DescribeVpcClassicLinkDnsSupportMaxResults = int +DescribeVpcClassicLinkDnsSupportNextToken = str +DescribeVpcPeeringConnectionsMaxResults = int +DescribeVpcsMaxResults = int +DhcpOptionsId = str +DisassociateSecurityGroupVpcSecurityGroupId = str +DiskCount = int +Double = float +DoubleWithConstraints = float +DrainSeconds = int +EfaSupportedFlag = bool +EgressOnlyInternetGatewayId = str +EipAllocationPublicIp = str +EkPubKeyValue = str +ElasticGpuId = str +ElasticInferenceAcceleratorCount = int +ElasticIpAssociationId = str +EnaSrdSupported = bool +EncryptionInTransitSupported = bool +ExcludedInstanceType = str +ExportImageTaskId = str +ExportTaskId = str +ExportVmTaskId = str +FleetId = str +Float = float +FlowLogResourceId = str +FpgaDeviceCount = int +FpgaDeviceManufacturerName = str +FpgaDeviceMemorySize = int +FpgaDeviceName = str +FpgaImageId = str +FreeTierEligibleFlag = bool +GVCDMaxResults = int +GetCapacityReservationUsageRequestMaxResults = int +GetGroupsForCapacityReservationRequestMaxResults = int +GetIpamPoolAllocationsMaxResults = int +GetManagedPrefixListAssociationsMaxResults = int +GetNetworkInsightsAccessScopeAnalysisFindingsMaxResults = int +GetSecurityGroupsForVpcRequestMaxResults = int +GetSubnetCidrReservationsMaxResults = int +GetVerifiedAccessEndpointTargetsMaxResults = int +GpuDeviceCount = int +GpuDeviceManufacturerName = str +GpuDeviceMemorySize = int +GpuDeviceName = str +HibernationFlag = bool +HostReservationId = str +Hour = int +IamInstanceProfileAssociationId = str +ImageId = str +ImageProvider = str +ImageProviderRequest = str +ImportImageTaskId = str +ImportManifestUrl = str +ImportSnapshotTaskId = str +ImportTaskId = str +InferenceDeviceCount = int +InferenceDeviceManufacturerName = str +InferenceDeviceMemorySize = int +InferenceDeviceName = str +InstanceConnectEndpointId = str +InstanceConnectEndpointMaxResults = int +InstanceEventId = str +InstanceEventWindowCronExpression = str +InstanceEventWindowId = str +InstanceId = str +InstanceIdForResolver = str +InstanceIdWithVolumeResolver = str +InstanceStorageFlag = bool +Integer = int +IntegerWithConstraints = int +InternetGatewayId = str +IpAddress = str +IpamAddressHistoryMaxResults = int +IpamExternalResourceVerificationTokenId = str +IpamId = str +IpamMaxResults = int +IpamNetmaskLength = int +IpamPoolAllocationId = str +IpamPoolCidrId = str +IpamPoolId = str +IpamResourceDiscoveryAssociationId = str +IpamResourceDiscoveryId = str +IpamScopeId = str +Ipv4PoolCoipId = str +Ipv4PoolEc2Id = str +Ipv6Address = str +Ipv6Flag = bool +Ipv6PoolEc2Id = str +Ipv6PoolMaxResults = int +KernelId = str +KeyPairId = str +KeyPairName = str +KeyPairNameWithResolver = str +KmsKeyArn = str +KmsKeyId = str +LaunchTemplateElasticInferenceAcceleratorCount = int +LaunchTemplateId = str +LaunchTemplateName = str +ListImagesInRecycleBinMaxResults = int +ListSnapshotsInRecycleBinMaxResults = int +LoadBalancerArn = str +LocalGatewayId = str +LocalGatewayMaxResults = int +LocalGatewayRouteTableVirtualInterfaceGroupAssociationId = str +LocalGatewayRouteTableVpcAssociationId = str +LocalGatewayRoutetableId = str +LocalGatewayVirtualInterfaceGroupId = str +LocalGatewayVirtualInterfaceId = str +Location = str +MacModificationTaskId = str +MaxIpv4AddrPerInterface = int +MaxIpv6AddrPerInterface = int +MaxNetworkInterfaces = int +MaxResults = int +MaxResultsParam = int +MaximumBandwidthInMbps = int +MaximumEfaInterfaces = int +MaximumEnaQueueCount = int +MaximumEnaQueueCountPerInterface = int +MaximumIops = int +MaximumNetworkCards = int +MaximumThroughputInMBps = float +MediaDeviceCount = int +MediaDeviceManufacturerName = str +MediaDeviceMemorySize = int +MediaDeviceName = str +NatGatewayId = str +NetmaskLength = int +NetworkAclAssociationId = str +NetworkAclId = str +NetworkCardIndex = int +NetworkInsightsAccessScopeAnalysisId = str +NetworkInsightsAccessScopeId = str +NetworkInsightsAnalysisId = str +NetworkInsightsMaxResults = int +NetworkInsightsPathId = str +NetworkInsightsResourceId = str +NetworkInterfaceAttachmentId = str +NetworkInterfaceId = str +NetworkInterfacePermissionId = str +NetworkPerformance = str +NeuronDeviceCoreCount = int +NeuronDeviceCoreVersion = int +NeuronDeviceCount = int +NeuronDeviceMemorySize = int +NeuronDeviceName = str +NextToken = str +NitroTpmSupportedVersionType = str +OdbNetworkArn = str +OfferingId = str +OutpostArn = str +OutpostLagId = str +OutpostLagMaxResults = int +PasswordData = str +PeakBandwidthInGbps = float +PlacementGroupArn = str +PlacementGroupId = str +PlacementGroupName = str +PoolMaxResults = int +Port = int +PrefixListMaxResults = int +PrefixListResourceId = str +Priority = int +PrivateIpAddressCount = int +ProcessorSustainedClockSpeed = float +ProtocolInt = int +PublicIpAddress = str +RamdiskId = str +RdsDbClusterArn = str +RdsDbInstanceArn = str +RdsDbProxyArn = str +ReplaceRootVolumeTaskId = str +ReportInstanceStatusRequestDescription = str +ReservationId = str +ReservedInstancesListingId = str +ReservedInstancesModificationId = str +ReservedInstancesOfferingId = str +ResourceArn = str +ResourceConfigurationArn = str +RestoreSnapshotTierRequestTemporaryRestoreDays = int +ResultRange = int +RetentionPeriodRequestDays = int +RetentionPeriodResponseDays = int +RoleId = str +RouteGatewayId = str +RouteServerEndpointId = str +RouteServerId = str +RouteServerMaxResults = int +RouteServerPeerId = str +RouteTableAssociationId = str +RouteTableId = str +RunInstancesUserData = str +S3StorageUploadPolicy = str +S3StorageUploadPolicySignature = str +ScheduledInstanceId = str +SecurityGroupId = str +SecurityGroupName = str +SecurityGroupRuleId = str +SensitiveMacCredentials = str +SensitiveUrl = str +SensitiveUserData = str +ServiceLinkMaxResults = int +ServiceLinkVirtualInterfaceId = str +ServiceNetworkArn = str +SnapshotCompletionDurationMinutesRequest = int +SnapshotCompletionDurationMinutesResponse = int +SnapshotId = str +SpotFleetRequestId = str +SpotInstanceRequestId = str +SpotPlacementScoresMaxResults = int +SpotPlacementScoresTargetCapacity = int +String = str +StringType = str +SubnetCidrAssociationId = str +SubnetCidrReservationId = str +SubnetId = str +TaggableResourceId = str +ThreadsPerCore = int +TotalMediaMemory = int +TotalNeuronMemory = int +TrafficMirrorFilterId = str +TrafficMirrorFilterRuleIdWithResolver = str +TrafficMirrorSessionId = str +TrafficMirrorTargetId = str +TrafficMirroringMaxResults = int +TransitAssociationGatewayId = str +TransitGatewayAttachmentId = str +TransitGatewayConnectPeerId = str +TransitGatewayId = str +TransitGatewayMaxResults = int +TransitGatewayMulticastDomainId = str +TransitGatewayPolicyTableId = str +TransitGatewayRouteTableAnnouncementId = str +TransitGatewayRouteTableId = str +TrunkInterfaceAssociationId = str +VCpuCount = int +VerifiedAccessEndpointId = str +VerifiedAccessEndpointPortNumber = int +VerifiedAccessGroupId = str +VerifiedAccessInstanceId = str +VerifiedAccessTrustProviderId = str +VersionDescription = str +VolumeId = str +VolumeIdWithResolver = str +VpcBlockPublicAccessExclusionId = str +VpcCidrAssociationId = str +VpcEncryptionControlId = str +VpcEndpointId = str +VpcEndpointServiceId = str +VpcFlowLogId = str +VpcId = str +VpcPeeringConnectionId = str +VpcPeeringConnectionIdWithResolver = str +VpnConnectionDeviceSampleConfiguration = str +VpnConnectionDeviceTypeId = str +VpnConnectionId = str +VpnGatewayId = str +customerGatewayConfiguration = str +maxResults = int +preSharedKey = str +totalFpgaMemory = int +totalGpuMemory = int +totalInferenceMemory = int + + +class AcceleratorManufacturer(StrEnum): + amazon_web_services = "amazon-web-services" + amd = "amd" + nvidia = "nvidia" + xilinx = "xilinx" + habana = "habana" + + +class AcceleratorName(StrEnum): + a100 = "a100" + inferentia = "inferentia" + k520 = "k520" + k80 = "k80" + m60 = "m60" + radeon_pro_v520 = "radeon-pro-v520" + t4 = "t4" + vu9p = "vu9p" + v100 = "v100" + a10g = "a10g" + h100 = "h100" + t4g = "t4g" + + +class AcceleratorType(StrEnum): + gpu = "gpu" + fpga = "fpga" + inference = "inference" + + +class AccountAttributeName(StrEnum): + supported_platforms = "supported-platforms" + default_vpc = "default-vpc" + + +class ActivityStatus(StrEnum): + error = "error" + pending_fulfillment = "pending_fulfillment" + pending_termination = "pending_termination" + fulfilled = "fulfilled" + + +class AddressAttributeName(StrEnum): + domain_name = "domain-name" + + +class AddressFamily(StrEnum): + ipv4 = "ipv4" + ipv6 = "ipv6" + + +class AddressTransferStatus(StrEnum): + pending = "pending" + disabled = "disabled" + accepted = "accepted" + + +class Affinity(StrEnum): + default = "default" + host = "host" + + +class AllocationState(StrEnum): + available = "available" + under_assessment = "under-assessment" + permanent_failure = "permanent-failure" + released = "released" + released_permanent_failure = "released-permanent-failure" + pending = "pending" + + +class AllocationStrategy(StrEnum): + lowestPrice = "lowestPrice" + diversified = "diversified" + capacityOptimized = "capacityOptimized" + capacityOptimizedPrioritized = "capacityOptimizedPrioritized" + priceCapacityOptimized = "priceCapacityOptimized" + + +class AllocationType(StrEnum): + used = "used" + future = "future" + + +class AllowedImagesSettingsDisabledState(StrEnum): + disabled = "disabled" + + +class AllowedImagesSettingsEnabledState(StrEnum): + enabled = "enabled" + audit_mode = "audit-mode" + + +class AllowsMultipleInstanceTypes(StrEnum): + on = "on" + off = "off" + + +class AmdSevSnpSpecification(StrEnum): + enabled = "enabled" + disabled = "disabled" + + +class AnalysisStatus(StrEnum): + running = "running" + succeeded = "succeeded" + failed = "failed" + + +class ApplianceModeSupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class ArchitectureType(StrEnum): + i386 = "i386" + x86_64 = "x86_64" + arm64 = "arm64" + x86_64_mac = "x86_64_mac" + arm64_mac = "arm64_mac" + + +class ArchitectureValues(StrEnum): + i386 = "i386" + x86_64 = "x86_64" + arm64 = "arm64" + x86_64_mac = "x86_64_mac" + arm64_mac = "arm64_mac" + + +class AsnAssociationState(StrEnum): + disassociated = "disassociated" + failed_disassociation = "failed-disassociation" + failed_association = "failed-association" + pending_disassociation = "pending-disassociation" + pending_association = "pending-association" + associated = "associated" + + +class AsnState(StrEnum): + deprovisioned = "deprovisioned" + failed_deprovision = "failed-deprovision" + failed_provision = "failed-provision" + pending_deprovision = "pending-deprovision" + pending_provision = "pending-provision" + provisioned = "provisioned" + + +class AssociatedNetworkType(StrEnum): + vpc = "vpc" + + +class AssociationStatusCode(StrEnum): + associating = "associating" + associated = "associated" + association_failed = "association-failed" + disassociating = "disassociating" + disassociated = "disassociated" + + +class AttachmentStatus(StrEnum): + attaching = "attaching" + attached = "attached" + detaching = "detaching" + detached = "detached" + + +class AutoAcceptSharedAssociationsValue(StrEnum): + enable = "enable" + disable = "disable" + + +class AutoAcceptSharedAttachmentsValue(StrEnum): + enable = "enable" + disable = "disable" + + +class AutoPlacement(StrEnum): + on = "on" + off = "off" + + +class AvailabilityZoneOptInStatus(StrEnum): + opt_in_not_required = "opt-in-not-required" + opted_in = "opted-in" + not_opted_in = "not-opted-in" + + +class AvailabilityZoneState(StrEnum): + available = "available" + information = "information" + impaired = "impaired" + unavailable = "unavailable" + constrained = "constrained" + + +class BandwidthWeightingType(StrEnum): + default = "default" + vpc_1 = "vpc-1" + ebs_1 = "ebs-1" + + +class BareMetal(StrEnum): + included = "included" + required = "required" + excluded = "excluded" + + +class BatchState(StrEnum): + submitted = "submitted" + active = "active" + cancelled = "cancelled" + failed = "failed" + cancelled_running = "cancelled_running" + cancelled_terminating = "cancelled_terminating" + modifying = "modifying" + + +class BgpStatus(StrEnum): + up = "up" + down = "down" + + +class BlockPublicAccessMode(StrEnum): + off = "off" + block_bidirectional = "block-bidirectional" + block_ingress = "block-ingress" + + +class BootModeType(StrEnum): + legacy_bios = "legacy-bios" + uefi = "uefi" + + +class BootModeValues(StrEnum): + legacy_bios = "legacy-bios" + uefi = "uefi" + uefi_preferred = "uefi-preferred" + + +class BundleTaskState(StrEnum): + pending = "pending" + waiting_for_shutdown = "waiting-for-shutdown" + bundling = "bundling" + storing = "storing" + cancelling = "cancelling" + complete = "complete" + failed = "failed" + + +class BurstablePerformance(StrEnum): + included = "included" + required = "required" + excluded = "excluded" + + +class ByoipCidrState(StrEnum): + advertised = "advertised" + deprovisioned = "deprovisioned" + failed_deprovision = "failed-deprovision" + failed_provision = "failed-provision" + pending_deprovision = "pending-deprovision" + pending_provision = "pending-provision" + provisioned = "provisioned" + provisioned_not_publicly_advertisable = "provisioned-not-publicly-advertisable" + + +class CallerRole(StrEnum): + odcr_owner = "odcr-owner" + unused_reservation_billing_owner = "unused-reservation-billing-owner" + + +class CancelBatchErrorCode(StrEnum): + fleetRequestIdDoesNotExist = "fleetRequestIdDoesNotExist" + fleetRequestIdMalformed = "fleetRequestIdMalformed" + fleetRequestNotInCancellableState = "fleetRequestNotInCancellableState" + unexpectedError = "unexpectedError" + + +class CancelSpotInstanceRequestState(StrEnum): + active = "active" + open = "open" + closed = "closed" + cancelled = "cancelled" + completed = "completed" + + +class CapacityBlockExtensionStatus(StrEnum): + payment_pending = "payment-pending" + payment_failed = "payment-failed" + payment_succeeded = "payment-succeeded" + + +class CapacityBlockInterconnectStatus(StrEnum): + ok = "ok" + impaired = "impaired" + insufficient_data = "insufficient-data" + + +class CapacityBlockResourceState(StrEnum): + active = "active" + expired = "expired" + unavailable = "unavailable" + cancelled = "cancelled" + failed = "failed" + scheduled = "scheduled" + payment_pending = "payment-pending" + payment_failed = "payment-failed" + + +class CapacityReservationBillingRequestStatus(StrEnum): + pending = "pending" + accepted = "accepted" + rejected = "rejected" + cancelled = "cancelled" + revoked = "revoked" + expired = "expired" + + +class CapacityReservationDeliveryPreference(StrEnum): + fixed = "fixed" + incremental = "incremental" + + +class CapacityReservationFleetState(StrEnum): + submitted = "submitted" + modifying = "modifying" + active = "active" + partially_fulfilled = "partially_fulfilled" + expiring = "expiring" + expired = "expired" + cancelling = "cancelling" + cancelled = "cancelled" + failed = "failed" + + +class CapacityReservationInstancePlatform(StrEnum): + Linux_UNIX = "Linux/UNIX" + Red_Hat_Enterprise_Linux = "Red Hat Enterprise Linux" + SUSE_Linux = "SUSE Linux" + Windows = "Windows" + Windows_with_SQL_Server = "Windows with SQL Server" + Windows_with_SQL_Server_Enterprise = "Windows with SQL Server Enterprise" + Windows_with_SQL_Server_Standard = "Windows with SQL Server Standard" + Windows_with_SQL_Server_Web = "Windows with SQL Server Web" + Linux_with_SQL_Server_Standard = "Linux with SQL Server Standard" + Linux_with_SQL_Server_Web = "Linux with SQL Server Web" + Linux_with_SQL_Server_Enterprise = "Linux with SQL Server Enterprise" + RHEL_with_SQL_Server_Standard = "RHEL with SQL Server Standard" + RHEL_with_SQL_Server_Enterprise = "RHEL with SQL Server Enterprise" + RHEL_with_SQL_Server_Web = "RHEL with SQL Server Web" + RHEL_with_HA = "RHEL with HA" + RHEL_with_HA_and_SQL_Server_Standard = "RHEL with HA and SQL Server Standard" + RHEL_with_HA_and_SQL_Server_Enterprise = "RHEL with HA and SQL Server Enterprise" + Ubuntu_Pro = "Ubuntu Pro" + + +class CapacityReservationPreference(StrEnum): + capacity_reservations_only = "capacity-reservations-only" + open = "open" + none = "none" + + +class CapacityReservationState(StrEnum): + active = "active" + expired = "expired" + cancelled = "cancelled" + pending = "pending" + failed = "failed" + scheduled = "scheduled" + payment_pending = "payment-pending" + payment_failed = "payment-failed" + assessing = "assessing" + delayed = "delayed" + unsupported = "unsupported" + unavailable = "unavailable" + + +class CapacityReservationTenancy(StrEnum): + default = "default" + dedicated = "dedicated" + + +class CapacityReservationType(StrEnum): + default = "default" + capacity_block = "capacity-block" + + +class CarrierGatewayState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class ClientCertificateRevocationListStatusCode(StrEnum): + pending = "pending" + active = "active" + + +class ClientVpnAuthenticationType(StrEnum): + certificate_authentication = "certificate-authentication" + directory_service_authentication = "directory-service-authentication" + federated_authentication = "federated-authentication" + + +class ClientVpnAuthorizationRuleStatusCode(StrEnum): + authorizing = "authorizing" + active = "active" + failed = "failed" + revoking = "revoking" + + +class ClientVpnConnectionStatusCode(StrEnum): + active = "active" + failed_to_terminate = "failed-to-terminate" + terminating = "terminating" + terminated = "terminated" + + +class ClientVpnEndpointAttributeStatusCode(StrEnum): + applying = "applying" + applied = "applied" + + +class ClientVpnEndpointStatusCode(StrEnum): + pending_associate = "pending-associate" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class ClientVpnRouteStatusCode(StrEnum): + creating = "creating" + active = "active" + failed = "failed" + deleting = "deleting" + + +class ConnectionNotificationState(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ConnectionNotificationType(StrEnum): + Topic = "Topic" + + +class ConnectivityType(StrEnum): + private = "private" + public = "public" + + +class ContainerFormat(StrEnum): + ova = "ova" + + +class ConversionTaskState(StrEnum): + active = "active" + cancelling = "cancelling" + cancelled = "cancelled" + completed = "completed" + + +class CopyTagsFromSource(StrEnum): + volume = "volume" + + +class CpuManufacturer(StrEnum): + intel = "intel" + amd = "amd" + amazon_web_services = "amazon-web-services" + apple = "apple" + + +class CurrencyCodeValues(StrEnum): + USD = "USD" + + +class DatafeedSubscriptionState(StrEnum): + Active = "Active" + Inactive = "Inactive" + + +class DefaultInstanceMetadataEndpointState(StrEnum): + disabled = "disabled" + enabled = "enabled" + no_preference = "no-preference" + + +class DefaultInstanceMetadataTagsState(StrEnum): + disabled = "disabled" + enabled = "enabled" + no_preference = "no-preference" + + +class DefaultRouteTableAssociationValue(StrEnum): + enable = "enable" + disable = "disable" + + +class DefaultRouteTablePropagationValue(StrEnum): + enable = "enable" + disable = "disable" + + +class DefaultTargetCapacityType(StrEnum): + spot = "spot" + on_demand = "on-demand" + capacity_block = "capacity-block" + + +class DeleteFleetErrorCode(StrEnum): + fleetIdDoesNotExist = "fleetIdDoesNotExist" + fleetIdMalformed = "fleetIdMalformed" + fleetNotInDeletableState = "fleetNotInDeletableState" + unexpectedError = "unexpectedError" + + +class DeleteQueuedReservedInstancesErrorCode(StrEnum): + reserved_instances_id_invalid = "reserved-instances-id-invalid" + reserved_instances_not_in_queued_state = "reserved-instances-not-in-queued-state" + unexpected_error = "unexpected-error" + + +class DestinationFileFormat(StrEnum): + plain_text = "plain-text" + parquet = "parquet" + + +class DeviceTrustProviderType(StrEnum): + jamf = "jamf" + crowdstrike = "crowdstrike" + jumpcloud = "jumpcloud" + + +class DeviceType(StrEnum): + ebs = "ebs" + instance_store = "instance-store" + + +class DiskImageFormat(StrEnum): + VMDK = "VMDK" + RAW = "RAW" + VHD = "VHD" + + +class DiskType(StrEnum): + hdd = "hdd" + ssd = "ssd" + + +class DnsNameState(StrEnum): + pendingVerification = "pendingVerification" + verified = "verified" + failed = "failed" + + +class DnsRecordIpType(StrEnum): + ipv4 = "ipv4" + dualstack = "dualstack" + ipv6 = "ipv6" + service_defined = "service-defined" + + +class DnsSupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class DomainType(StrEnum): + vpc = "vpc" + standard = "standard" + + +class DynamicRoutingValue(StrEnum): + enable = "enable" + disable = "disable" + + +class EbsEncryptionSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + + +class EbsNvmeSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + required = "required" + + +class EbsOptimizedSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + default = "default" + + +class Ec2InstanceConnectEndpointState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + delete_failed = "delete-failed" + + +class EkPubKeyFormat(StrEnum): + der = "der" + tpmt = "tpmt" + + +class EkPubKeyType(StrEnum): + rsa_2048 = "rsa-2048" + ecc_sec_p384 = "ecc-sec-p384" + + +class ElasticGpuState(StrEnum): + ATTACHED = "ATTACHED" + + +class ElasticGpuStatus(StrEnum): + OK = "OK" + IMPAIRED = "IMPAIRED" + + +class EnaSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + required = "required" + + +class EndDateType(StrEnum): + unlimited = "unlimited" + limited = "limited" + + +class EphemeralNvmeSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + required = "required" + + +class EventCode(StrEnum): + instance_reboot = "instance-reboot" + system_reboot = "system-reboot" + system_maintenance = "system-maintenance" + instance_retirement = "instance-retirement" + instance_stop = "instance-stop" + + +class EventType(StrEnum): + instanceChange = "instanceChange" + fleetRequestChange = "fleetRequestChange" + error = "error" + information = "information" + + +class ExcessCapacityTerminationPolicy(StrEnum): + noTermination = "noTermination" + default = "default" + + +class ExportEnvironment(StrEnum): + citrix = "citrix" + vmware = "vmware" + microsoft = "microsoft" + + +class ExportTaskState(StrEnum): + active = "active" + cancelling = "cancelling" + cancelled = "cancelled" + completed = "completed" + + +class FastLaunchResourceType(StrEnum): + snapshot = "snapshot" + + +class FastLaunchStateCode(StrEnum): + enabling = "enabling" + enabling_failed = "enabling-failed" + enabled = "enabled" + enabled_failed = "enabled-failed" + disabling = "disabling" + disabling_failed = "disabling-failed" + + +class FastSnapshotRestoreStateCode(StrEnum): + enabling = "enabling" + optimizing = "optimizing" + enabled = "enabled" + disabling = "disabling" + disabled = "disabled" + + +class FindingsFound(StrEnum): + true = "true" + false = "false" + unknown = "unknown" + + +class FleetActivityStatus(StrEnum): + error = "error" + pending_fulfillment = "pending_fulfillment" + pending_termination = "pending_termination" + fulfilled = "fulfilled" + + +class FleetCapacityReservationTenancy(StrEnum): + default = "default" + + +class FleetCapacityReservationUsageStrategy(StrEnum): + use_capacity_reservations_first = "use-capacity-reservations-first" + + +class FleetEventType(StrEnum): + instance_change = "instance-change" + fleet_change = "fleet-change" + service_error = "service-error" + + +class FleetExcessCapacityTerminationPolicy(StrEnum): + no_termination = "no-termination" + termination = "termination" + + +class FleetInstanceMatchCriteria(StrEnum): + open = "open" + + +class FleetOnDemandAllocationStrategy(StrEnum): + lowest_price = "lowest-price" + prioritized = "prioritized" + + +class FleetReplacementStrategy(StrEnum): + launch = "launch" + launch_before_terminate = "launch-before-terminate" + + +class FleetStateCode(StrEnum): + submitted = "submitted" + active = "active" + deleted = "deleted" + failed = "failed" + deleted_running = "deleted_running" + deleted_terminating = "deleted_terminating" + modifying = "modifying" + + +class FleetType(StrEnum): + request = "request" + maintain = "maintain" + instant = "instant" + + +class FlexibleEnaQueuesSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + + +class FlowLogsResourceType(StrEnum): + VPC = "VPC" + Subnet = "Subnet" + NetworkInterface = "NetworkInterface" + TransitGateway = "TransitGateway" + TransitGatewayAttachment = "TransitGatewayAttachment" + + +class FpgaImageAttributeName(StrEnum): + description = "description" + name = "name" + loadPermission = "loadPermission" + productCodes = "productCodes" + + +class FpgaImageStateCode(StrEnum): + pending = "pending" + failed = "failed" + available = "available" + unavailable = "unavailable" + + +class GatewayAssociationState(StrEnum): + associated = "associated" + not_associated = "not-associated" + associating = "associating" + disassociating = "disassociating" + + +class GatewayType(StrEnum): + ipsec_1 = "ipsec.1" + + +class HostMaintenance(StrEnum): + on = "on" + off = "off" + + +class HostRecovery(StrEnum): + on = "on" + off = "off" + + +class HostTenancy(StrEnum): + default = "default" + dedicated = "dedicated" + host = "host" + + +class HostnameType(StrEnum): + ip_name = "ip-name" + resource_name = "resource-name" + + +class HttpTokensState(StrEnum): + optional = "optional" + required = "required" + + +class HypervisorType(StrEnum): + ovm = "ovm" + xen = "xen" + + +class IamInstanceProfileAssociationState(StrEnum): + associating = "associating" + associated = "associated" + disassociating = "disassociating" + disassociated = "disassociated" + + +class Igmpv2SupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class ImageAttributeName(StrEnum): + description = "description" + kernel = "kernel" + ramdisk = "ramdisk" + launchPermission = "launchPermission" + productCodes = "productCodes" + blockDeviceMapping = "blockDeviceMapping" + sriovNetSupport = "sriovNetSupport" + bootMode = "bootMode" + tpmSupport = "tpmSupport" + uefiData = "uefiData" + lastLaunchedTime = "lastLaunchedTime" + imdsSupport = "imdsSupport" + deregistrationProtection = "deregistrationProtection" + + +class ImageBlockPublicAccessDisabledState(StrEnum): + unblocked = "unblocked" + + +class ImageBlockPublicAccessEnabledState(StrEnum): + block_new_sharing = "block-new-sharing" + + +class ImageState(StrEnum): + pending = "pending" + available = "available" + invalid = "invalid" + deregistered = "deregistered" + transient = "transient" + failed = "failed" + error = "error" + disabled = "disabled" + + +class ImageTypeValues(StrEnum): + machine = "machine" + kernel = "kernel" + ramdisk = "ramdisk" + + +class ImdsSupportValues(StrEnum): + v2_0 = "v2.0" + + +class InstanceAttributeName(StrEnum): + instanceType = "instanceType" + kernel = "kernel" + ramdisk = "ramdisk" + userData = "userData" + disableApiTermination = "disableApiTermination" + instanceInitiatedShutdownBehavior = "instanceInitiatedShutdownBehavior" + rootDeviceName = "rootDeviceName" + blockDeviceMapping = "blockDeviceMapping" + productCodes = "productCodes" + sourceDestCheck = "sourceDestCheck" + groupSet = "groupSet" + ebsOptimized = "ebsOptimized" + sriovNetSupport = "sriovNetSupport" + enaSupport = "enaSupport" + enclaveOptions = "enclaveOptions" + disableApiStop = "disableApiStop" + + +class InstanceAutoRecoveryState(StrEnum): + disabled = "disabled" + default = "default" + + +class InstanceBandwidthWeighting(StrEnum): + default = "default" + vpc_1 = "vpc-1" + ebs_1 = "ebs-1" + + +class InstanceBootModeValues(StrEnum): + legacy_bios = "legacy-bios" + uefi = "uefi" + + +class InstanceEventWindowState(StrEnum): + creating = "creating" + deleting = "deleting" + active = "active" + deleted = "deleted" + + +class InstanceGeneration(StrEnum): + current = "current" + previous = "previous" + + +class InstanceHealthStatus(StrEnum): + healthy = "healthy" + unhealthy = "unhealthy" + + +class InstanceInterruptionBehavior(StrEnum): + hibernate = "hibernate" + stop = "stop" + terminate = "terminate" + + +class InstanceLifecycle(StrEnum): + spot = "spot" + on_demand = "on-demand" + + +class InstanceLifecycleType(StrEnum): + spot = "spot" + scheduled = "scheduled" + capacity_block = "capacity-block" + + +class InstanceMatchCriteria(StrEnum): + open = "open" + targeted = "targeted" + + +class InstanceMetadataEndpointState(StrEnum): + disabled = "disabled" + enabled = "enabled" + + +class InstanceMetadataOptionsState(StrEnum): + pending = "pending" + applied = "applied" + + +class InstanceMetadataProtocolState(StrEnum): + disabled = "disabled" + enabled = "enabled" + + +class InstanceMetadataTagsState(StrEnum): + disabled = "disabled" + enabled = "enabled" + + +class InstanceRebootMigrationState(StrEnum): + disabled = "disabled" + default = "default" + + +class InstanceStateName(StrEnum): + pending = "pending" + running = "running" + shutting_down = "shutting-down" + terminated = "terminated" + stopping = "stopping" + stopped = "stopped" + + +class InstanceStorageEncryptionSupport(StrEnum): + unsupported = "unsupported" + required = "required" + + +class InstanceType(StrEnum): + a1_medium = "a1.medium" + a1_large = "a1.large" + a1_xlarge = "a1.xlarge" + a1_2xlarge = "a1.2xlarge" + a1_4xlarge = "a1.4xlarge" + a1_metal = "a1.metal" + c1_medium = "c1.medium" + c1_xlarge = "c1.xlarge" + c3_large = "c3.large" + c3_xlarge = "c3.xlarge" + c3_2xlarge = "c3.2xlarge" + c3_4xlarge = "c3.4xlarge" + c3_8xlarge = "c3.8xlarge" + c4_large = "c4.large" + c4_xlarge = "c4.xlarge" + c4_2xlarge = "c4.2xlarge" + c4_4xlarge = "c4.4xlarge" + c4_8xlarge = "c4.8xlarge" + c5_large = "c5.large" + c5_xlarge = "c5.xlarge" + c5_2xlarge = "c5.2xlarge" + c5_4xlarge = "c5.4xlarge" + c5_9xlarge = "c5.9xlarge" + c5_12xlarge = "c5.12xlarge" + c5_18xlarge = "c5.18xlarge" + c5_24xlarge = "c5.24xlarge" + c5_metal = "c5.metal" + c5a_large = "c5a.large" + c5a_xlarge = "c5a.xlarge" + c5a_2xlarge = "c5a.2xlarge" + c5a_4xlarge = "c5a.4xlarge" + c5a_8xlarge = "c5a.8xlarge" + c5a_12xlarge = "c5a.12xlarge" + c5a_16xlarge = "c5a.16xlarge" + c5a_24xlarge = "c5a.24xlarge" + c5ad_large = "c5ad.large" + c5ad_xlarge = "c5ad.xlarge" + c5ad_2xlarge = "c5ad.2xlarge" + c5ad_4xlarge = "c5ad.4xlarge" + c5ad_8xlarge = "c5ad.8xlarge" + c5ad_12xlarge = "c5ad.12xlarge" + c5ad_16xlarge = "c5ad.16xlarge" + c5ad_24xlarge = "c5ad.24xlarge" + c5d_large = "c5d.large" + c5d_xlarge = "c5d.xlarge" + c5d_2xlarge = "c5d.2xlarge" + c5d_4xlarge = "c5d.4xlarge" + c5d_9xlarge = "c5d.9xlarge" + c5d_12xlarge = "c5d.12xlarge" + c5d_18xlarge = "c5d.18xlarge" + c5d_24xlarge = "c5d.24xlarge" + c5d_metal = "c5d.metal" + c5n_large = "c5n.large" + c5n_xlarge = "c5n.xlarge" + c5n_2xlarge = "c5n.2xlarge" + c5n_4xlarge = "c5n.4xlarge" + c5n_9xlarge = "c5n.9xlarge" + c5n_18xlarge = "c5n.18xlarge" + c5n_metal = "c5n.metal" + c6g_medium = "c6g.medium" + c6g_large = "c6g.large" + c6g_xlarge = "c6g.xlarge" + c6g_2xlarge = "c6g.2xlarge" + c6g_4xlarge = "c6g.4xlarge" + c6g_8xlarge = "c6g.8xlarge" + c6g_12xlarge = "c6g.12xlarge" + c6g_16xlarge = "c6g.16xlarge" + c6g_metal = "c6g.metal" + c6gd_medium = "c6gd.medium" + c6gd_large = "c6gd.large" + c6gd_xlarge = "c6gd.xlarge" + c6gd_2xlarge = "c6gd.2xlarge" + c6gd_4xlarge = "c6gd.4xlarge" + c6gd_8xlarge = "c6gd.8xlarge" + c6gd_12xlarge = "c6gd.12xlarge" + c6gd_16xlarge = "c6gd.16xlarge" + c6gd_metal = "c6gd.metal" + c6gn_medium = "c6gn.medium" + c6gn_large = "c6gn.large" + c6gn_xlarge = "c6gn.xlarge" + c6gn_2xlarge = "c6gn.2xlarge" + c6gn_4xlarge = "c6gn.4xlarge" + c6gn_8xlarge = "c6gn.8xlarge" + c6gn_12xlarge = "c6gn.12xlarge" + c6gn_16xlarge = "c6gn.16xlarge" + c6i_large = "c6i.large" + c6i_xlarge = "c6i.xlarge" + c6i_2xlarge = "c6i.2xlarge" + c6i_4xlarge = "c6i.4xlarge" + c6i_8xlarge = "c6i.8xlarge" + c6i_12xlarge = "c6i.12xlarge" + c6i_16xlarge = "c6i.16xlarge" + c6i_24xlarge = "c6i.24xlarge" + c6i_32xlarge = "c6i.32xlarge" + c6i_metal = "c6i.metal" + cc1_4xlarge = "cc1.4xlarge" + cc2_8xlarge = "cc2.8xlarge" + cg1_4xlarge = "cg1.4xlarge" + cr1_8xlarge = "cr1.8xlarge" + d2_xlarge = "d2.xlarge" + d2_2xlarge = "d2.2xlarge" + d2_4xlarge = "d2.4xlarge" + d2_8xlarge = "d2.8xlarge" + d3_xlarge = "d3.xlarge" + d3_2xlarge = "d3.2xlarge" + d3_4xlarge = "d3.4xlarge" + d3_8xlarge = "d3.8xlarge" + d3en_xlarge = "d3en.xlarge" + d3en_2xlarge = "d3en.2xlarge" + d3en_4xlarge = "d3en.4xlarge" + d3en_6xlarge = "d3en.6xlarge" + d3en_8xlarge = "d3en.8xlarge" + d3en_12xlarge = "d3en.12xlarge" + dl1_24xlarge = "dl1.24xlarge" + f1_2xlarge = "f1.2xlarge" + f1_4xlarge = "f1.4xlarge" + f1_16xlarge = "f1.16xlarge" + g2_2xlarge = "g2.2xlarge" + g2_8xlarge = "g2.8xlarge" + g3_4xlarge = "g3.4xlarge" + g3_8xlarge = "g3.8xlarge" + g3_16xlarge = "g3.16xlarge" + g3s_xlarge = "g3s.xlarge" + g4ad_xlarge = "g4ad.xlarge" + g4ad_2xlarge = "g4ad.2xlarge" + g4ad_4xlarge = "g4ad.4xlarge" + g4ad_8xlarge = "g4ad.8xlarge" + g4ad_16xlarge = "g4ad.16xlarge" + g4dn_xlarge = "g4dn.xlarge" + g4dn_2xlarge = "g4dn.2xlarge" + g4dn_4xlarge = "g4dn.4xlarge" + g4dn_8xlarge = "g4dn.8xlarge" + g4dn_12xlarge = "g4dn.12xlarge" + g4dn_16xlarge = "g4dn.16xlarge" + g4dn_metal = "g4dn.metal" + g5_xlarge = "g5.xlarge" + g5_2xlarge = "g5.2xlarge" + g5_4xlarge = "g5.4xlarge" + g5_8xlarge = "g5.8xlarge" + g5_12xlarge = "g5.12xlarge" + g5_16xlarge = "g5.16xlarge" + g5_24xlarge = "g5.24xlarge" + g5_48xlarge = "g5.48xlarge" + g5g_xlarge = "g5g.xlarge" + g5g_2xlarge = "g5g.2xlarge" + g5g_4xlarge = "g5g.4xlarge" + g5g_8xlarge = "g5g.8xlarge" + g5g_16xlarge = "g5g.16xlarge" + g5g_metal = "g5g.metal" + hi1_4xlarge = "hi1.4xlarge" + hpc6a_48xlarge = "hpc6a.48xlarge" + hs1_8xlarge = "hs1.8xlarge" + h1_2xlarge = "h1.2xlarge" + h1_4xlarge = "h1.4xlarge" + h1_8xlarge = "h1.8xlarge" + h1_16xlarge = "h1.16xlarge" + i2_xlarge = "i2.xlarge" + i2_2xlarge = "i2.2xlarge" + i2_4xlarge = "i2.4xlarge" + i2_8xlarge = "i2.8xlarge" + i3_large = "i3.large" + i3_xlarge = "i3.xlarge" + i3_2xlarge = "i3.2xlarge" + i3_4xlarge = "i3.4xlarge" + i3_8xlarge = "i3.8xlarge" + i3_16xlarge = "i3.16xlarge" + i3_metal = "i3.metal" + i3en_large = "i3en.large" + i3en_xlarge = "i3en.xlarge" + i3en_2xlarge = "i3en.2xlarge" + i3en_3xlarge = "i3en.3xlarge" + i3en_6xlarge = "i3en.6xlarge" + i3en_12xlarge = "i3en.12xlarge" + i3en_24xlarge = "i3en.24xlarge" + i3en_metal = "i3en.metal" + im4gn_large = "im4gn.large" + im4gn_xlarge = "im4gn.xlarge" + im4gn_2xlarge = "im4gn.2xlarge" + im4gn_4xlarge = "im4gn.4xlarge" + im4gn_8xlarge = "im4gn.8xlarge" + im4gn_16xlarge = "im4gn.16xlarge" + inf1_xlarge = "inf1.xlarge" + inf1_2xlarge = "inf1.2xlarge" + inf1_6xlarge = "inf1.6xlarge" + inf1_24xlarge = "inf1.24xlarge" + is4gen_medium = "is4gen.medium" + is4gen_large = "is4gen.large" + is4gen_xlarge = "is4gen.xlarge" + is4gen_2xlarge = "is4gen.2xlarge" + is4gen_4xlarge = "is4gen.4xlarge" + is4gen_8xlarge = "is4gen.8xlarge" + m1_small = "m1.small" + m1_medium = "m1.medium" + m1_large = "m1.large" + m1_xlarge = "m1.xlarge" + m2_xlarge = "m2.xlarge" + m2_2xlarge = "m2.2xlarge" + m2_4xlarge = "m2.4xlarge" + m3_medium = "m3.medium" + m3_large = "m3.large" + m3_xlarge = "m3.xlarge" + m3_2xlarge = "m3.2xlarge" + m4_large = "m4.large" + m4_xlarge = "m4.xlarge" + m4_2xlarge = "m4.2xlarge" + m4_4xlarge = "m4.4xlarge" + m4_10xlarge = "m4.10xlarge" + m4_16xlarge = "m4.16xlarge" + m5_large = "m5.large" + m5_xlarge = "m5.xlarge" + m5_2xlarge = "m5.2xlarge" + m5_4xlarge = "m5.4xlarge" + m5_8xlarge = "m5.8xlarge" + m5_12xlarge = "m5.12xlarge" + m5_16xlarge = "m5.16xlarge" + m5_24xlarge = "m5.24xlarge" + m5_metal = "m5.metal" + m5a_large = "m5a.large" + m5a_xlarge = "m5a.xlarge" + m5a_2xlarge = "m5a.2xlarge" + m5a_4xlarge = "m5a.4xlarge" + m5a_8xlarge = "m5a.8xlarge" + m5a_12xlarge = "m5a.12xlarge" + m5a_16xlarge = "m5a.16xlarge" + m5a_24xlarge = "m5a.24xlarge" + m5ad_large = "m5ad.large" + m5ad_xlarge = "m5ad.xlarge" + m5ad_2xlarge = "m5ad.2xlarge" + m5ad_4xlarge = "m5ad.4xlarge" + m5ad_8xlarge = "m5ad.8xlarge" + m5ad_12xlarge = "m5ad.12xlarge" + m5ad_16xlarge = "m5ad.16xlarge" + m5ad_24xlarge = "m5ad.24xlarge" + m5d_large = "m5d.large" + m5d_xlarge = "m5d.xlarge" + m5d_2xlarge = "m5d.2xlarge" + m5d_4xlarge = "m5d.4xlarge" + m5d_8xlarge = "m5d.8xlarge" + m5d_12xlarge = "m5d.12xlarge" + m5d_16xlarge = "m5d.16xlarge" + m5d_24xlarge = "m5d.24xlarge" + m5d_metal = "m5d.metal" + m5dn_large = "m5dn.large" + m5dn_xlarge = "m5dn.xlarge" + m5dn_2xlarge = "m5dn.2xlarge" + m5dn_4xlarge = "m5dn.4xlarge" + m5dn_8xlarge = "m5dn.8xlarge" + m5dn_12xlarge = "m5dn.12xlarge" + m5dn_16xlarge = "m5dn.16xlarge" + m5dn_24xlarge = "m5dn.24xlarge" + m5dn_metal = "m5dn.metal" + m5n_large = "m5n.large" + m5n_xlarge = "m5n.xlarge" + m5n_2xlarge = "m5n.2xlarge" + m5n_4xlarge = "m5n.4xlarge" + m5n_8xlarge = "m5n.8xlarge" + m5n_12xlarge = "m5n.12xlarge" + m5n_16xlarge = "m5n.16xlarge" + m5n_24xlarge = "m5n.24xlarge" + m5n_metal = "m5n.metal" + m5zn_large = "m5zn.large" + m5zn_xlarge = "m5zn.xlarge" + m5zn_2xlarge = "m5zn.2xlarge" + m5zn_3xlarge = "m5zn.3xlarge" + m5zn_6xlarge = "m5zn.6xlarge" + m5zn_12xlarge = "m5zn.12xlarge" + m5zn_metal = "m5zn.metal" + m6a_large = "m6a.large" + m6a_xlarge = "m6a.xlarge" + m6a_2xlarge = "m6a.2xlarge" + m6a_4xlarge = "m6a.4xlarge" + m6a_8xlarge = "m6a.8xlarge" + m6a_12xlarge = "m6a.12xlarge" + m6a_16xlarge = "m6a.16xlarge" + m6a_24xlarge = "m6a.24xlarge" + m6a_32xlarge = "m6a.32xlarge" + m6a_48xlarge = "m6a.48xlarge" + m6g_metal = "m6g.metal" + m6g_medium = "m6g.medium" + m6g_large = "m6g.large" + m6g_xlarge = "m6g.xlarge" + m6g_2xlarge = "m6g.2xlarge" + m6g_4xlarge = "m6g.4xlarge" + m6g_8xlarge = "m6g.8xlarge" + m6g_12xlarge = "m6g.12xlarge" + m6g_16xlarge = "m6g.16xlarge" + m6gd_metal = "m6gd.metal" + m6gd_medium = "m6gd.medium" + m6gd_large = "m6gd.large" + m6gd_xlarge = "m6gd.xlarge" + m6gd_2xlarge = "m6gd.2xlarge" + m6gd_4xlarge = "m6gd.4xlarge" + m6gd_8xlarge = "m6gd.8xlarge" + m6gd_12xlarge = "m6gd.12xlarge" + m6gd_16xlarge = "m6gd.16xlarge" + m6i_large = "m6i.large" + m6i_xlarge = "m6i.xlarge" + m6i_2xlarge = "m6i.2xlarge" + m6i_4xlarge = "m6i.4xlarge" + m6i_8xlarge = "m6i.8xlarge" + m6i_12xlarge = "m6i.12xlarge" + m6i_16xlarge = "m6i.16xlarge" + m6i_24xlarge = "m6i.24xlarge" + m6i_32xlarge = "m6i.32xlarge" + m6i_metal = "m6i.metal" + mac1_metal = "mac1.metal" + p2_xlarge = "p2.xlarge" + p2_8xlarge = "p2.8xlarge" + p2_16xlarge = "p2.16xlarge" + p3_2xlarge = "p3.2xlarge" + p3_8xlarge = "p3.8xlarge" + p3_16xlarge = "p3.16xlarge" + p3dn_24xlarge = "p3dn.24xlarge" + p4d_24xlarge = "p4d.24xlarge" + r3_large = "r3.large" + r3_xlarge = "r3.xlarge" + r3_2xlarge = "r3.2xlarge" + r3_4xlarge = "r3.4xlarge" + r3_8xlarge = "r3.8xlarge" + r4_large = "r4.large" + r4_xlarge = "r4.xlarge" + r4_2xlarge = "r4.2xlarge" + r4_4xlarge = "r4.4xlarge" + r4_8xlarge = "r4.8xlarge" + r4_16xlarge = "r4.16xlarge" + r5_large = "r5.large" + r5_xlarge = "r5.xlarge" + r5_2xlarge = "r5.2xlarge" + r5_4xlarge = "r5.4xlarge" + r5_8xlarge = "r5.8xlarge" + r5_12xlarge = "r5.12xlarge" + r5_16xlarge = "r5.16xlarge" + r5_24xlarge = "r5.24xlarge" + r5_metal = "r5.metal" + r5a_large = "r5a.large" + r5a_xlarge = "r5a.xlarge" + r5a_2xlarge = "r5a.2xlarge" + r5a_4xlarge = "r5a.4xlarge" + r5a_8xlarge = "r5a.8xlarge" + r5a_12xlarge = "r5a.12xlarge" + r5a_16xlarge = "r5a.16xlarge" + r5a_24xlarge = "r5a.24xlarge" + r5ad_large = "r5ad.large" + r5ad_xlarge = "r5ad.xlarge" + r5ad_2xlarge = "r5ad.2xlarge" + r5ad_4xlarge = "r5ad.4xlarge" + r5ad_8xlarge = "r5ad.8xlarge" + r5ad_12xlarge = "r5ad.12xlarge" + r5ad_16xlarge = "r5ad.16xlarge" + r5ad_24xlarge = "r5ad.24xlarge" + r5b_large = "r5b.large" + r5b_xlarge = "r5b.xlarge" + r5b_2xlarge = "r5b.2xlarge" + r5b_4xlarge = "r5b.4xlarge" + r5b_8xlarge = "r5b.8xlarge" + r5b_12xlarge = "r5b.12xlarge" + r5b_16xlarge = "r5b.16xlarge" + r5b_24xlarge = "r5b.24xlarge" + r5b_metal = "r5b.metal" + r5d_large = "r5d.large" + r5d_xlarge = "r5d.xlarge" + r5d_2xlarge = "r5d.2xlarge" + r5d_4xlarge = "r5d.4xlarge" + r5d_8xlarge = "r5d.8xlarge" + r5d_12xlarge = "r5d.12xlarge" + r5d_16xlarge = "r5d.16xlarge" + r5d_24xlarge = "r5d.24xlarge" + r5d_metal = "r5d.metal" + r5dn_large = "r5dn.large" + r5dn_xlarge = "r5dn.xlarge" + r5dn_2xlarge = "r5dn.2xlarge" + r5dn_4xlarge = "r5dn.4xlarge" + r5dn_8xlarge = "r5dn.8xlarge" + r5dn_12xlarge = "r5dn.12xlarge" + r5dn_16xlarge = "r5dn.16xlarge" + r5dn_24xlarge = "r5dn.24xlarge" + r5dn_metal = "r5dn.metal" + r5n_large = "r5n.large" + r5n_xlarge = "r5n.xlarge" + r5n_2xlarge = "r5n.2xlarge" + r5n_4xlarge = "r5n.4xlarge" + r5n_8xlarge = "r5n.8xlarge" + r5n_12xlarge = "r5n.12xlarge" + r5n_16xlarge = "r5n.16xlarge" + r5n_24xlarge = "r5n.24xlarge" + r5n_metal = "r5n.metal" + r6g_medium = "r6g.medium" + r6g_large = "r6g.large" + r6g_xlarge = "r6g.xlarge" + r6g_2xlarge = "r6g.2xlarge" + r6g_4xlarge = "r6g.4xlarge" + r6g_8xlarge = "r6g.8xlarge" + r6g_12xlarge = "r6g.12xlarge" + r6g_16xlarge = "r6g.16xlarge" + r6g_metal = "r6g.metal" + r6gd_medium = "r6gd.medium" + r6gd_large = "r6gd.large" + r6gd_xlarge = "r6gd.xlarge" + r6gd_2xlarge = "r6gd.2xlarge" + r6gd_4xlarge = "r6gd.4xlarge" + r6gd_8xlarge = "r6gd.8xlarge" + r6gd_12xlarge = "r6gd.12xlarge" + r6gd_16xlarge = "r6gd.16xlarge" + r6gd_metal = "r6gd.metal" + r6i_large = "r6i.large" + r6i_xlarge = "r6i.xlarge" + r6i_2xlarge = "r6i.2xlarge" + r6i_4xlarge = "r6i.4xlarge" + r6i_8xlarge = "r6i.8xlarge" + r6i_12xlarge = "r6i.12xlarge" + r6i_16xlarge = "r6i.16xlarge" + r6i_24xlarge = "r6i.24xlarge" + r6i_32xlarge = "r6i.32xlarge" + r6i_metal = "r6i.metal" + t1_micro = "t1.micro" + t2_nano = "t2.nano" + t2_micro = "t2.micro" + t2_small = "t2.small" + t2_medium = "t2.medium" + t2_large = "t2.large" + t2_xlarge = "t2.xlarge" + t2_2xlarge = "t2.2xlarge" + t3_nano = "t3.nano" + t3_micro = "t3.micro" + t3_small = "t3.small" + t3_medium = "t3.medium" + t3_large = "t3.large" + t3_xlarge = "t3.xlarge" + t3_2xlarge = "t3.2xlarge" + t3a_nano = "t3a.nano" + t3a_micro = "t3a.micro" + t3a_small = "t3a.small" + t3a_medium = "t3a.medium" + t3a_large = "t3a.large" + t3a_xlarge = "t3a.xlarge" + t3a_2xlarge = "t3a.2xlarge" + t4g_nano = "t4g.nano" + t4g_micro = "t4g.micro" + t4g_small = "t4g.small" + t4g_medium = "t4g.medium" + t4g_large = "t4g.large" + t4g_xlarge = "t4g.xlarge" + t4g_2xlarge = "t4g.2xlarge" + u_6tb1_56xlarge = "u-6tb1.56xlarge" + u_6tb1_112xlarge = "u-6tb1.112xlarge" + u_9tb1_112xlarge = "u-9tb1.112xlarge" + u_12tb1_112xlarge = "u-12tb1.112xlarge" + u_6tb1_metal = "u-6tb1.metal" + u_9tb1_metal = "u-9tb1.metal" + u_12tb1_metal = "u-12tb1.metal" + u_18tb1_metal = "u-18tb1.metal" + u_24tb1_metal = "u-24tb1.metal" + vt1_3xlarge = "vt1.3xlarge" + vt1_6xlarge = "vt1.6xlarge" + vt1_24xlarge = "vt1.24xlarge" + x1_16xlarge = "x1.16xlarge" + x1_32xlarge = "x1.32xlarge" + x1e_xlarge = "x1e.xlarge" + x1e_2xlarge = "x1e.2xlarge" + x1e_4xlarge = "x1e.4xlarge" + x1e_8xlarge = "x1e.8xlarge" + x1e_16xlarge = "x1e.16xlarge" + x1e_32xlarge = "x1e.32xlarge" + x2iezn_2xlarge = "x2iezn.2xlarge" + x2iezn_4xlarge = "x2iezn.4xlarge" + x2iezn_6xlarge = "x2iezn.6xlarge" + x2iezn_8xlarge = "x2iezn.8xlarge" + x2iezn_12xlarge = "x2iezn.12xlarge" + x2iezn_metal = "x2iezn.metal" + x2gd_medium = "x2gd.medium" + x2gd_large = "x2gd.large" + x2gd_xlarge = "x2gd.xlarge" + x2gd_2xlarge = "x2gd.2xlarge" + x2gd_4xlarge = "x2gd.4xlarge" + x2gd_8xlarge = "x2gd.8xlarge" + x2gd_12xlarge = "x2gd.12xlarge" + x2gd_16xlarge = "x2gd.16xlarge" + x2gd_metal = "x2gd.metal" + z1d_large = "z1d.large" + z1d_xlarge = "z1d.xlarge" + z1d_2xlarge = "z1d.2xlarge" + z1d_3xlarge = "z1d.3xlarge" + z1d_6xlarge = "z1d.6xlarge" + z1d_12xlarge = "z1d.12xlarge" + z1d_metal = "z1d.metal" + x2idn_16xlarge = "x2idn.16xlarge" + x2idn_24xlarge = "x2idn.24xlarge" + x2idn_32xlarge = "x2idn.32xlarge" + x2iedn_xlarge = "x2iedn.xlarge" + x2iedn_2xlarge = "x2iedn.2xlarge" + x2iedn_4xlarge = "x2iedn.4xlarge" + x2iedn_8xlarge = "x2iedn.8xlarge" + x2iedn_16xlarge = "x2iedn.16xlarge" + x2iedn_24xlarge = "x2iedn.24xlarge" + x2iedn_32xlarge = "x2iedn.32xlarge" + c6a_large = "c6a.large" + c6a_xlarge = "c6a.xlarge" + c6a_2xlarge = "c6a.2xlarge" + c6a_4xlarge = "c6a.4xlarge" + c6a_8xlarge = "c6a.8xlarge" + c6a_12xlarge = "c6a.12xlarge" + c6a_16xlarge = "c6a.16xlarge" + c6a_24xlarge = "c6a.24xlarge" + c6a_32xlarge = "c6a.32xlarge" + c6a_48xlarge = "c6a.48xlarge" + c6a_metal = "c6a.metal" + m6a_metal = "m6a.metal" + i4i_large = "i4i.large" + i4i_xlarge = "i4i.xlarge" + i4i_2xlarge = "i4i.2xlarge" + i4i_4xlarge = "i4i.4xlarge" + i4i_8xlarge = "i4i.8xlarge" + i4i_16xlarge = "i4i.16xlarge" + i4i_32xlarge = "i4i.32xlarge" + i4i_metal = "i4i.metal" + x2idn_metal = "x2idn.metal" + x2iedn_metal = "x2iedn.metal" + c7g_medium = "c7g.medium" + c7g_large = "c7g.large" + c7g_xlarge = "c7g.xlarge" + c7g_2xlarge = "c7g.2xlarge" + c7g_4xlarge = "c7g.4xlarge" + c7g_8xlarge = "c7g.8xlarge" + c7g_12xlarge = "c7g.12xlarge" + c7g_16xlarge = "c7g.16xlarge" + mac2_metal = "mac2.metal" + c6id_large = "c6id.large" + c6id_xlarge = "c6id.xlarge" + c6id_2xlarge = "c6id.2xlarge" + c6id_4xlarge = "c6id.4xlarge" + c6id_8xlarge = "c6id.8xlarge" + c6id_12xlarge = "c6id.12xlarge" + c6id_16xlarge = "c6id.16xlarge" + c6id_24xlarge = "c6id.24xlarge" + c6id_32xlarge = "c6id.32xlarge" + c6id_metal = "c6id.metal" + m6id_large = "m6id.large" + m6id_xlarge = "m6id.xlarge" + m6id_2xlarge = "m6id.2xlarge" + m6id_4xlarge = "m6id.4xlarge" + m6id_8xlarge = "m6id.8xlarge" + m6id_12xlarge = "m6id.12xlarge" + m6id_16xlarge = "m6id.16xlarge" + m6id_24xlarge = "m6id.24xlarge" + m6id_32xlarge = "m6id.32xlarge" + m6id_metal = "m6id.metal" + r6id_large = "r6id.large" + r6id_xlarge = "r6id.xlarge" + r6id_2xlarge = "r6id.2xlarge" + r6id_4xlarge = "r6id.4xlarge" + r6id_8xlarge = "r6id.8xlarge" + r6id_12xlarge = "r6id.12xlarge" + r6id_16xlarge = "r6id.16xlarge" + r6id_24xlarge = "r6id.24xlarge" + r6id_32xlarge = "r6id.32xlarge" + r6id_metal = "r6id.metal" + r6a_large = "r6a.large" + r6a_xlarge = "r6a.xlarge" + r6a_2xlarge = "r6a.2xlarge" + r6a_4xlarge = "r6a.4xlarge" + r6a_8xlarge = "r6a.8xlarge" + r6a_12xlarge = "r6a.12xlarge" + r6a_16xlarge = "r6a.16xlarge" + r6a_24xlarge = "r6a.24xlarge" + r6a_32xlarge = "r6a.32xlarge" + r6a_48xlarge = "r6a.48xlarge" + r6a_metal = "r6a.metal" + p4de_24xlarge = "p4de.24xlarge" + u_3tb1_56xlarge = "u-3tb1.56xlarge" + u_18tb1_112xlarge = "u-18tb1.112xlarge" + u_24tb1_112xlarge = "u-24tb1.112xlarge" + trn1_2xlarge = "trn1.2xlarge" + trn1_32xlarge = "trn1.32xlarge" + hpc6id_32xlarge = "hpc6id.32xlarge" + c6in_large = "c6in.large" + c6in_xlarge = "c6in.xlarge" + c6in_2xlarge = "c6in.2xlarge" + c6in_4xlarge = "c6in.4xlarge" + c6in_8xlarge = "c6in.8xlarge" + c6in_12xlarge = "c6in.12xlarge" + c6in_16xlarge = "c6in.16xlarge" + c6in_24xlarge = "c6in.24xlarge" + c6in_32xlarge = "c6in.32xlarge" + m6in_large = "m6in.large" + m6in_xlarge = "m6in.xlarge" + m6in_2xlarge = "m6in.2xlarge" + m6in_4xlarge = "m6in.4xlarge" + m6in_8xlarge = "m6in.8xlarge" + m6in_12xlarge = "m6in.12xlarge" + m6in_16xlarge = "m6in.16xlarge" + m6in_24xlarge = "m6in.24xlarge" + m6in_32xlarge = "m6in.32xlarge" + m6idn_large = "m6idn.large" + m6idn_xlarge = "m6idn.xlarge" + m6idn_2xlarge = "m6idn.2xlarge" + m6idn_4xlarge = "m6idn.4xlarge" + m6idn_8xlarge = "m6idn.8xlarge" + m6idn_12xlarge = "m6idn.12xlarge" + m6idn_16xlarge = "m6idn.16xlarge" + m6idn_24xlarge = "m6idn.24xlarge" + m6idn_32xlarge = "m6idn.32xlarge" + r6in_large = "r6in.large" + r6in_xlarge = "r6in.xlarge" + r6in_2xlarge = "r6in.2xlarge" + r6in_4xlarge = "r6in.4xlarge" + r6in_8xlarge = "r6in.8xlarge" + r6in_12xlarge = "r6in.12xlarge" + r6in_16xlarge = "r6in.16xlarge" + r6in_24xlarge = "r6in.24xlarge" + r6in_32xlarge = "r6in.32xlarge" + r6idn_large = "r6idn.large" + r6idn_xlarge = "r6idn.xlarge" + r6idn_2xlarge = "r6idn.2xlarge" + r6idn_4xlarge = "r6idn.4xlarge" + r6idn_8xlarge = "r6idn.8xlarge" + r6idn_12xlarge = "r6idn.12xlarge" + r6idn_16xlarge = "r6idn.16xlarge" + r6idn_24xlarge = "r6idn.24xlarge" + r6idn_32xlarge = "r6idn.32xlarge" + c7g_metal = "c7g.metal" + m7g_medium = "m7g.medium" + m7g_large = "m7g.large" + m7g_xlarge = "m7g.xlarge" + m7g_2xlarge = "m7g.2xlarge" + m7g_4xlarge = "m7g.4xlarge" + m7g_8xlarge = "m7g.8xlarge" + m7g_12xlarge = "m7g.12xlarge" + m7g_16xlarge = "m7g.16xlarge" + m7g_metal = "m7g.metal" + r7g_medium = "r7g.medium" + r7g_large = "r7g.large" + r7g_xlarge = "r7g.xlarge" + r7g_2xlarge = "r7g.2xlarge" + r7g_4xlarge = "r7g.4xlarge" + r7g_8xlarge = "r7g.8xlarge" + r7g_12xlarge = "r7g.12xlarge" + r7g_16xlarge = "r7g.16xlarge" + r7g_metal = "r7g.metal" + c6in_metal = "c6in.metal" + m6in_metal = "m6in.metal" + m6idn_metal = "m6idn.metal" + r6in_metal = "r6in.metal" + r6idn_metal = "r6idn.metal" + inf2_xlarge = "inf2.xlarge" + inf2_8xlarge = "inf2.8xlarge" + inf2_24xlarge = "inf2.24xlarge" + inf2_48xlarge = "inf2.48xlarge" + trn1n_32xlarge = "trn1n.32xlarge" + i4g_large = "i4g.large" + i4g_xlarge = "i4g.xlarge" + i4g_2xlarge = "i4g.2xlarge" + i4g_4xlarge = "i4g.4xlarge" + i4g_8xlarge = "i4g.8xlarge" + i4g_16xlarge = "i4g.16xlarge" + hpc7g_4xlarge = "hpc7g.4xlarge" + hpc7g_8xlarge = "hpc7g.8xlarge" + hpc7g_16xlarge = "hpc7g.16xlarge" + c7gn_medium = "c7gn.medium" + c7gn_large = "c7gn.large" + c7gn_xlarge = "c7gn.xlarge" + c7gn_2xlarge = "c7gn.2xlarge" + c7gn_4xlarge = "c7gn.4xlarge" + c7gn_8xlarge = "c7gn.8xlarge" + c7gn_12xlarge = "c7gn.12xlarge" + c7gn_16xlarge = "c7gn.16xlarge" + p5_48xlarge = "p5.48xlarge" + m7i_large = "m7i.large" + m7i_xlarge = "m7i.xlarge" + m7i_2xlarge = "m7i.2xlarge" + m7i_4xlarge = "m7i.4xlarge" + m7i_8xlarge = "m7i.8xlarge" + m7i_12xlarge = "m7i.12xlarge" + m7i_16xlarge = "m7i.16xlarge" + m7i_24xlarge = "m7i.24xlarge" + m7i_48xlarge = "m7i.48xlarge" + m7i_flex_large = "m7i-flex.large" + m7i_flex_xlarge = "m7i-flex.xlarge" + m7i_flex_2xlarge = "m7i-flex.2xlarge" + m7i_flex_4xlarge = "m7i-flex.4xlarge" + m7i_flex_8xlarge = "m7i-flex.8xlarge" + m7a_medium = "m7a.medium" + m7a_large = "m7a.large" + m7a_xlarge = "m7a.xlarge" + m7a_2xlarge = "m7a.2xlarge" + m7a_4xlarge = "m7a.4xlarge" + m7a_8xlarge = "m7a.8xlarge" + m7a_12xlarge = "m7a.12xlarge" + m7a_16xlarge = "m7a.16xlarge" + m7a_24xlarge = "m7a.24xlarge" + m7a_32xlarge = "m7a.32xlarge" + m7a_48xlarge = "m7a.48xlarge" + m7a_metal_48xl = "m7a.metal-48xl" + hpc7a_12xlarge = "hpc7a.12xlarge" + hpc7a_24xlarge = "hpc7a.24xlarge" + hpc7a_48xlarge = "hpc7a.48xlarge" + hpc7a_96xlarge = "hpc7a.96xlarge" + c7gd_medium = "c7gd.medium" + c7gd_large = "c7gd.large" + c7gd_xlarge = "c7gd.xlarge" + c7gd_2xlarge = "c7gd.2xlarge" + c7gd_4xlarge = "c7gd.4xlarge" + c7gd_8xlarge = "c7gd.8xlarge" + c7gd_12xlarge = "c7gd.12xlarge" + c7gd_16xlarge = "c7gd.16xlarge" + m7gd_medium = "m7gd.medium" + m7gd_large = "m7gd.large" + m7gd_xlarge = "m7gd.xlarge" + m7gd_2xlarge = "m7gd.2xlarge" + m7gd_4xlarge = "m7gd.4xlarge" + m7gd_8xlarge = "m7gd.8xlarge" + m7gd_12xlarge = "m7gd.12xlarge" + m7gd_16xlarge = "m7gd.16xlarge" + r7gd_medium = "r7gd.medium" + r7gd_large = "r7gd.large" + r7gd_xlarge = "r7gd.xlarge" + r7gd_2xlarge = "r7gd.2xlarge" + r7gd_4xlarge = "r7gd.4xlarge" + r7gd_8xlarge = "r7gd.8xlarge" + r7gd_12xlarge = "r7gd.12xlarge" + r7gd_16xlarge = "r7gd.16xlarge" + r7a_medium = "r7a.medium" + r7a_large = "r7a.large" + r7a_xlarge = "r7a.xlarge" + r7a_2xlarge = "r7a.2xlarge" + r7a_4xlarge = "r7a.4xlarge" + r7a_8xlarge = "r7a.8xlarge" + r7a_12xlarge = "r7a.12xlarge" + r7a_16xlarge = "r7a.16xlarge" + r7a_24xlarge = "r7a.24xlarge" + r7a_32xlarge = "r7a.32xlarge" + r7a_48xlarge = "r7a.48xlarge" + c7i_large = "c7i.large" + c7i_xlarge = "c7i.xlarge" + c7i_2xlarge = "c7i.2xlarge" + c7i_4xlarge = "c7i.4xlarge" + c7i_8xlarge = "c7i.8xlarge" + c7i_12xlarge = "c7i.12xlarge" + c7i_16xlarge = "c7i.16xlarge" + c7i_24xlarge = "c7i.24xlarge" + c7i_48xlarge = "c7i.48xlarge" + mac2_m2pro_metal = "mac2-m2pro.metal" + r7iz_large = "r7iz.large" + r7iz_xlarge = "r7iz.xlarge" + r7iz_2xlarge = "r7iz.2xlarge" + r7iz_4xlarge = "r7iz.4xlarge" + r7iz_8xlarge = "r7iz.8xlarge" + r7iz_12xlarge = "r7iz.12xlarge" + r7iz_16xlarge = "r7iz.16xlarge" + r7iz_32xlarge = "r7iz.32xlarge" + c7a_medium = "c7a.medium" + c7a_large = "c7a.large" + c7a_xlarge = "c7a.xlarge" + c7a_2xlarge = "c7a.2xlarge" + c7a_4xlarge = "c7a.4xlarge" + c7a_8xlarge = "c7a.8xlarge" + c7a_12xlarge = "c7a.12xlarge" + c7a_16xlarge = "c7a.16xlarge" + c7a_24xlarge = "c7a.24xlarge" + c7a_32xlarge = "c7a.32xlarge" + c7a_48xlarge = "c7a.48xlarge" + c7a_metal_48xl = "c7a.metal-48xl" + r7a_metal_48xl = "r7a.metal-48xl" + r7i_large = "r7i.large" + r7i_xlarge = "r7i.xlarge" + r7i_2xlarge = "r7i.2xlarge" + r7i_4xlarge = "r7i.4xlarge" + r7i_8xlarge = "r7i.8xlarge" + r7i_12xlarge = "r7i.12xlarge" + r7i_16xlarge = "r7i.16xlarge" + r7i_24xlarge = "r7i.24xlarge" + r7i_48xlarge = "r7i.48xlarge" + dl2q_24xlarge = "dl2q.24xlarge" + mac2_m2_metal = "mac2-m2.metal" + i4i_12xlarge = "i4i.12xlarge" + i4i_24xlarge = "i4i.24xlarge" + c7i_metal_24xl = "c7i.metal-24xl" + c7i_metal_48xl = "c7i.metal-48xl" + m7i_metal_24xl = "m7i.metal-24xl" + m7i_metal_48xl = "m7i.metal-48xl" + r7i_metal_24xl = "r7i.metal-24xl" + r7i_metal_48xl = "r7i.metal-48xl" + r7iz_metal_16xl = "r7iz.metal-16xl" + r7iz_metal_32xl = "r7iz.metal-32xl" + c7gd_metal = "c7gd.metal" + m7gd_metal = "m7gd.metal" + r7gd_metal = "r7gd.metal" + g6_xlarge = "g6.xlarge" + g6_2xlarge = "g6.2xlarge" + g6_4xlarge = "g6.4xlarge" + g6_8xlarge = "g6.8xlarge" + g6_12xlarge = "g6.12xlarge" + g6_16xlarge = "g6.16xlarge" + g6_24xlarge = "g6.24xlarge" + g6_48xlarge = "g6.48xlarge" + gr6_4xlarge = "gr6.4xlarge" + gr6_8xlarge = "gr6.8xlarge" + c7i_flex_large = "c7i-flex.large" + c7i_flex_xlarge = "c7i-flex.xlarge" + c7i_flex_2xlarge = "c7i-flex.2xlarge" + c7i_flex_4xlarge = "c7i-flex.4xlarge" + c7i_flex_8xlarge = "c7i-flex.8xlarge" + u7i_12tb_224xlarge = "u7i-12tb.224xlarge" + u7in_16tb_224xlarge = "u7in-16tb.224xlarge" + u7in_24tb_224xlarge = "u7in-24tb.224xlarge" + u7in_32tb_224xlarge = "u7in-32tb.224xlarge" + u7ib_12tb_224xlarge = "u7ib-12tb.224xlarge" + c7gn_metal = "c7gn.metal" + r8g_medium = "r8g.medium" + r8g_large = "r8g.large" + r8g_xlarge = "r8g.xlarge" + r8g_2xlarge = "r8g.2xlarge" + r8g_4xlarge = "r8g.4xlarge" + r8g_8xlarge = "r8g.8xlarge" + r8g_12xlarge = "r8g.12xlarge" + r8g_16xlarge = "r8g.16xlarge" + r8g_24xlarge = "r8g.24xlarge" + r8g_48xlarge = "r8g.48xlarge" + r8g_metal_24xl = "r8g.metal-24xl" + r8g_metal_48xl = "r8g.metal-48xl" + mac2_m1ultra_metal = "mac2-m1ultra.metal" + g6e_xlarge = "g6e.xlarge" + g6e_2xlarge = "g6e.2xlarge" + g6e_4xlarge = "g6e.4xlarge" + g6e_8xlarge = "g6e.8xlarge" + g6e_12xlarge = "g6e.12xlarge" + g6e_16xlarge = "g6e.16xlarge" + g6e_24xlarge = "g6e.24xlarge" + g6e_48xlarge = "g6e.48xlarge" + c8g_medium = "c8g.medium" + c8g_large = "c8g.large" + c8g_xlarge = "c8g.xlarge" + c8g_2xlarge = "c8g.2xlarge" + c8g_4xlarge = "c8g.4xlarge" + c8g_8xlarge = "c8g.8xlarge" + c8g_12xlarge = "c8g.12xlarge" + c8g_16xlarge = "c8g.16xlarge" + c8g_24xlarge = "c8g.24xlarge" + c8g_48xlarge = "c8g.48xlarge" + c8g_metal_24xl = "c8g.metal-24xl" + c8g_metal_48xl = "c8g.metal-48xl" + m8g_medium = "m8g.medium" + m8g_large = "m8g.large" + m8g_xlarge = "m8g.xlarge" + m8g_2xlarge = "m8g.2xlarge" + m8g_4xlarge = "m8g.4xlarge" + m8g_8xlarge = "m8g.8xlarge" + m8g_12xlarge = "m8g.12xlarge" + m8g_16xlarge = "m8g.16xlarge" + m8g_24xlarge = "m8g.24xlarge" + m8g_48xlarge = "m8g.48xlarge" + m8g_metal_24xl = "m8g.metal-24xl" + m8g_metal_48xl = "m8g.metal-48xl" + x8g_medium = "x8g.medium" + x8g_large = "x8g.large" + x8g_xlarge = "x8g.xlarge" + x8g_2xlarge = "x8g.2xlarge" + x8g_4xlarge = "x8g.4xlarge" + x8g_8xlarge = "x8g.8xlarge" + x8g_12xlarge = "x8g.12xlarge" + x8g_16xlarge = "x8g.16xlarge" + x8g_24xlarge = "x8g.24xlarge" + x8g_48xlarge = "x8g.48xlarge" + x8g_metal_24xl = "x8g.metal-24xl" + x8g_metal_48xl = "x8g.metal-48xl" + i7ie_large = "i7ie.large" + i7ie_xlarge = "i7ie.xlarge" + i7ie_2xlarge = "i7ie.2xlarge" + i7ie_3xlarge = "i7ie.3xlarge" + i7ie_6xlarge = "i7ie.6xlarge" + i7ie_12xlarge = "i7ie.12xlarge" + i7ie_18xlarge = "i7ie.18xlarge" + i7ie_24xlarge = "i7ie.24xlarge" + i7ie_48xlarge = "i7ie.48xlarge" + i8g_large = "i8g.large" + i8g_xlarge = "i8g.xlarge" + i8g_2xlarge = "i8g.2xlarge" + i8g_4xlarge = "i8g.4xlarge" + i8g_8xlarge = "i8g.8xlarge" + i8g_12xlarge = "i8g.12xlarge" + i8g_16xlarge = "i8g.16xlarge" + i8g_24xlarge = "i8g.24xlarge" + i8g_metal_24xl = "i8g.metal-24xl" + u7i_6tb_112xlarge = "u7i-6tb.112xlarge" + u7i_8tb_112xlarge = "u7i-8tb.112xlarge" + u7inh_32tb_480xlarge = "u7inh-32tb.480xlarge" + p5e_48xlarge = "p5e.48xlarge" + p5en_48xlarge = "p5en.48xlarge" + f2_12xlarge = "f2.12xlarge" + f2_48xlarge = "f2.48xlarge" + trn2_48xlarge = "trn2.48xlarge" + c7i_flex_12xlarge = "c7i-flex.12xlarge" + c7i_flex_16xlarge = "c7i-flex.16xlarge" + m7i_flex_12xlarge = "m7i-flex.12xlarge" + m7i_flex_16xlarge = "m7i-flex.16xlarge" + i7ie_metal_24xl = "i7ie.metal-24xl" + i7ie_metal_48xl = "i7ie.metal-48xl" + i8g_48xlarge = "i8g.48xlarge" + c8gd_medium = "c8gd.medium" + c8gd_large = "c8gd.large" + c8gd_xlarge = "c8gd.xlarge" + c8gd_2xlarge = "c8gd.2xlarge" + c8gd_4xlarge = "c8gd.4xlarge" + c8gd_8xlarge = "c8gd.8xlarge" + c8gd_12xlarge = "c8gd.12xlarge" + c8gd_16xlarge = "c8gd.16xlarge" + c8gd_24xlarge = "c8gd.24xlarge" + c8gd_48xlarge = "c8gd.48xlarge" + c8gd_metal_24xl = "c8gd.metal-24xl" + c8gd_metal_48xl = "c8gd.metal-48xl" + i7i_large = "i7i.large" + i7i_xlarge = "i7i.xlarge" + i7i_2xlarge = "i7i.2xlarge" + i7i_4xlarge = "i7i.4xlarge" + i7i_8xlarge = "i7i.8xlarge" + i7i_12xlarge = "i7i.12xlarge" + i7i_16xlarge = "i7i.16xlarge" + i7i_24xlarge = "i7i.24xlarge" + i7i_48xlarge = "i7i.48xlarge" + i7i_metal_24xl = "i7i.metal-24xl" + i7i_metal_48xl = "i7i.metal-48xl" + p6_b200_48xlarge = "p6-b200.48xlarge" + m8gd_medium = "m8gd.medium" + m8gd_large = "m8gd.large" + m8gd_xlarge = "m8gd.xlarge" + m8gd_2xlarge = "m8gd.2xlarge" + m8gd_4xlarge = "m8gd.4xlarge" + m8gd_8xlarge = "m8gd.8xlarge" + m8gd_12xlarge = "m8gd.12xlarge" + m8gd_16xlarge = "m8gd.16xlarge" + m8gd_24xlarge = "m8gd.24xlarge" + m8gd_48xlarge = "m8gd.48xlarge" + m8gd_metal_24xl = "m8gd.metal-24xl" + m8gd_metal_48xl = "m8gd.metal-48xl" + r8gd_medium = "r8gd.medium" + r8gd_large = "r8gd.large" + r8gd_xlarge = "r8gd.xlarge" + r8gd_2xlarge = "r8gd.2xlarge" + r8gd_4xlarge = "r8gd.4xlarge" + r8gd_8xlarge = "r8gd.8xlarge" + r8gd_12xlarge = "r8gd.12xlarge" + r8gd_16xlarge = "r8gd.16xlarge" + r8gd_24xlarge = "r8gd.24xlarge" + r8gd_48xlarge = "r8gd.48xlarge" + r8gd_metal_24xl = "r8gd.metal-24xl" + r8gd_metal_48xl = "r8gd.metal-48xl" + + +class InstanceTypeHypervisor(StrEnum): + nitro = "nitro" + xen = "xen" + + +class InterfacePermissionType(StrEnum): + INSTANCE_ATTACH = "INSTANCE-ATTACH" + EIP_ASSOCIATE = "EIP-ASSOCIATE" + + +class InterfaceProtocolType(StrEnum): + VLAN = "VLAN" + GRE = "GRE" + + +class InternetGatewayBlockMode(StrEnum): + off = "off" + block_bidirectional = "block-bidirectional" + block_ingress = "block-ingress" + + +class InternetGatewayExclusionMode(StrEnum): + allow_bidirectional = "allow-bidirectional" + allow_egress = "allow-egress" + + +class IpAddressType(StrEnum): + ipv4 = "ipv4" + dualstack = "dualstack" + ipv6 = "ipv6" + + +class IpSource(StrEnum): + amazon = "amazon" + byoip = "byoip" + none = "none" + + +class IpamAddressHistoryResourceType(StrEnum): + eip = "eip" + vpc = "vpc" + subnet = "subnet" + network_interface = "network-interface" + instance = "instance" + + +class IpamAssociatedResourceDiscoveryStatus(StrEnum): + active = "active" + not_found = "not-found" + + +class IpamComplianceStatus(StrEnum): + compliant = "compliant" + noncompliant = "noncompliant" + unmanaged = "unmanaged" + ignored = "ignored" + + +class IpamDiscoveryFailureCode(StrEnum): + assume_role_failure = "assume-role-failure" + throttling_failure = "throttling-failure" + unauthorized_failure = "unauthorized-failure" + + +class IpamExternalResourceVerificationTokenState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + delete_failed = "delete-failed" + + +class IpamManagementState(StrEnum): + managed = "managed" + unmanaged = "unmanaged" + ignored = "ignored" + + +class IpamMeteredAccount(StrEnum): + ipam_owner = "ipam-owner" + resource_owner = "resource-owner" + + +class IpamNetworkInterfaceAttachmentStatus(StrEnum): + available = "available" + in_use = "in-use" + + +class IpamOverlapStatus(StrEnum): + overlapping = "overlapping" + nonoverlapping = "nonoverlapping" + ignored = "ignored" + + +class IpamPoolAllocationResourceType(StrEnum): + ipam_pool = "ipam-pool" + vpc = "vpc" + ec2_public_ipv4_pool = "ec2-public-ipv4-pool" + custom = "custom" + subnet = "subnet" + eip = "eip" + + +class IpamPoolAwsService(StrEnum): + ec2 = "ec2" + + +class IpamPoolCidrFailureCode(StrEnum): + cidr_not_available = "cidr-not-available" + limit_exceeded = "limit-exceeded" + + +class IpamPoolCidrState(StrEnum): + pending_provision = "pending-provision" + provisioned = "provisioned" + failed_provision = "failed-provision" + pending_deprovision = "pending-deprovision" + deprovisioned = "deprovisioned" + failed_deprovision = "failed-deprovision" + pending_import = "pending-import" + failed_import = "failed-import" + + +class IpamPoolPublicIpSource(StrEnum): + amazon = "amazon" + byoip = "byoip" + + +class IpamPoolSourceResourceType(StrEnum): + vpc = "vpc" + + +class IpamPoolState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + modify_in_progress = "modify-in-progress" + modify_complete = "modify-complete" + modify_failed = "modify-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + delete_failed = "delete-failed" + isolate_in_progress = "isolate-in-progress" + isolate_complete = "isolate-complete" + restore_in_progress = "restore-in-progress" + + +class IpamPublicAddressAssociationStatus(StrEnum): + associated = "associated" + disassociated = "disassociated" + + +class IpamPublicAddressAwsService(StrEnum): + nat_gateway = "nat-gateway" + database_migration_service = "database-migration-service" + redshift = "redshift" + elastic_container_service = "elastic-container-service" + relational_database_service = "relational-database-service" + site_to_site_vpn = "site-to-site-vpn" + load_balancer = "load-balancer" + global_accelerator = "global-accelerator" + other = "other" + + +class IpamPublicAddressType(StrEnum): + service_managed_ip = "service-managed-ip" + service_managed_byoip = "service-managed-byoip" + amazon_owned_eip = "amazon-owned-eip" + amazon_owned_contig = "amazon-owned-contig" + byoip = "byoip" + ec2_public_ip = "ec2-public-ip" + + +class IpamResourceCidrIpSource(StrEnum): + amazon = "amazon" + byoip = "byoip" + none = "none" + + +class IpamResourceDiscoveryAssociationState(StrEnum): + associate_in_progress = "associate-in-progress" + associate_complete = "associate-complete" + associate_failed = "associate-failed" + disassociate_in_progress = "disassociate-in-progress" + disassociate_complete = "disassociate-complete" + disassociate_failed = "disassociate-failed" + isolate_in_progress = "isolate-in-progress" + isolate_complete = "isolate-complete" + restore_in_progress = "restore-in-progress" + + +class IpamResourceDiscoveryState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + modify_in_progress = "modify-in-progress" + modify_complete = "modify-complete" + modify_failed = "modify-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + delete_failed = "delete-failed" + isolate_in_progress = "isolate-in-progress" + isolate_complete = "isolate-complete" + restore_in_progress = "restore-in-progress" + + +class IpamResourceType(StrEnum): + vpc = "vpc" + subnet = "subnet" + eip = "eip" + public_ipv4_pool = "public-ipv4-pool" + ipv6_pool = "ipv6-pool" + eni = "eni" + + +class IpamScopeState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + modify_in_progress = "modify-in-progress" + modify_complete = "modify-complete" + modify_failed = "modify-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + delete_failed = "delete-failed" + isolate_in_progress = "isolate-in-progress" + isolate_complete = "isolate-complete" + restore_in_progress = "restore-in-progress" + + +class IpamScopeType(StrEnum): + public = "public" + private = "private" + + +class IpamState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + modify_in_progress = "modify-in-progress" + modify_complete = "modify-complete" + modify_failed = "modify-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + delete_failed = "delete-failed" + isolate_in_progress = "isolate-in-progress" + isolate_complete = "isolate-complete" + restore_in_progress = "restore-in-progress" + + +class IpamTier(StrEnum): + free = "free" + advanced = "advanced" + + +class Ipv6AddressAttribute(StrEnum): + public = "public" + private = "private" + + +class Ipv6SupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class KeyFormat(StrEnum): + pem = "pem" + ppk = "ppk" + + +class KeyType(StrEnum): + rsa = "rsa" + ed25519 = "ed25519" + + +class LaunchTemplateAutoRecoveryState(StrEnum): + default = "default" + disabled = "disabled" + + +class LaunchTemplateErrorCode(StrEnum): + launchTemplateIdDoesNotExist = "launchTemplateIdDoesNotExist" + launchTemplateIdMalformed = "launchTemplateIdMalformed" + launchTemplateNameDoesNotExist = "launchTemplateNameDoesNotExist" + launchTemplateNameMalformed = "launchTemplateNameMalformed" + launchTemplateVersionDoesNotExist = "launchTemplateVersionDoesNotExist" + unexpectedError = "unexpectedError" + + +class LaunchTemplateHttpTokensState(StrEnum): + optional = "optional" + required = "required" + + +class LaunchTemplateInstanceMetadataEndpointState(StrEnum): + disabled = "disabled" + enabled = "enabled" + + +class LaunchTemplateInstanceMetadataOptionsState(StrEnum): + pending = "pending" + applied = "applied" + + +class LaunchTemplateInstanceMetadataProtocolIpv6(StrEnum): + disabled = "disabled" + enabled = "enabled" + + +class LaunchTemplateInstanceMetadataTagsState(StrEnum): + disabled = "disabled" + enabled = "enabled" + + +class ListingState(StrEnum): + available = "available" + sold = "sold" + cancelled = "cancelled" + pending = "pending" + + +class ListingStatus(StrEnum): + active = "active" + pending = "pending" + cancelled = "cancelled" + closed = "closed" + + +class LocalGatewayRouteState(StrEnum): + pending = "pending" + active = "active" + blackhole = "blackhole" + deleting = "deleting" + deleted = "deleted" + + +class LocalGatewayRouteTableMode(StrEnum): + direct_vpc_routing = "direct-vpc-routing" + coip = "coip" + + +class LocalGatewayRouteType(StrEnum): + static = "static" + propagated = "propagated" + + +class LocalGatewayVirtualInterfaceConfigurationState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class LocalGatewayVirtualInterfaceGroupConfigurationState(StrEnum): + pending = "pending" + incomplete = "incomplete" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class LocalStorage(StrEnum): + included = "included" + required = "required" + excluded = "excluded" + + +class LocalStorageType(StrEnum): + hdd = "hdd" + ssd = "ssd" + + +class LocationType(StrEnum): + region = "region" + availability_zone = "availability-zone" + availability_zone_id = "availability-zone-id" + outpost = "outpost" + + +class LockMode(StrEnum): + compliance = "compliance" + governance = "governance" + + +class LockState(StrEnum): + compliance = "compliance" + governance = "governance" + compliance_cooloff = "compliance-cooloff" + expired = "expired" + + +class LogDestinationType(StrEnum): + cloud_watch_logs = "cloud-watch-logs" + s3 = "s3" + kinesis_data_firehose = "kinesis-data-firehose" + + +class MacModificationTaskState(StrEnum): + successful = "successful" + failed = "failed" + in_progress = "in-progress" + pending = "pending" + + +class MacModificationTaskType(StrEnum): + sip_modification = "sip-modification" + volume_ownership_delegation = "volume-ownership-delegation" + + +class MacSystemIntegrityProtectionSettingStatus(StrEnum): + enabled = "enabled" + disabled = "disabled" + + +class ManagedBy(StrEnum): + account = "account" + declarative_policy = "declarative-policy" + + +class MarketType(StrEnum): + spot = "spot" + capacity_block = "capacity-block" + + +class MembershipType(StrEnum): + static = "static" + igmp = "igmp" + + +class MetadataDefaultHttpTokensState(StrEnum): + optional = "optional" + required = "required" + no_preference = "no-preference" + + +class MetricType(StrEnum): + aggregate_latency = "aggregate-latency" + + +class ModifyAvailabilityZoneOptInStatus(StrEnum): + opted_in = "opted-in" + not_opted_in = "not-opted-in" + + +class MonitoringState(StrEnum): + disabled = "disabled" + disabling = "disabling" + enabled = "enabled" + pending = "pending" + + +class MoveStatus(StrEnum): + movingToVpc = "movingToVpc" + restoringToClassic = "restoringToClassic" + + +class MulticastSupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class NatGatewayAddressStatus(StrEnum): + assigning = "assigning" + unassigning = "unassigning" + associating = "associating" + disassociating = "disassociating" + succeeded = "succeeded" + failed = "failed" + + +class NatGatewayState(StrEnum): + pending = "pending" + failed = "failed" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class NetworkInterfaceAttribute(StrEnum): + description = "description" + groupSet = "groupSet" + sourceDestCheck = "sourceDestCheck" + attachment = "attachment" + associatePublicIpAddress = "associatePublicIpAddress" + + +class NetworkInterfaceCreationType(StrEnum): + efa = "efa" + efa_only = "efa-only" + branch = "branch" + trunk = "trunk" + + +class NetworkInterfacePermissionStateCode(StrEnum): + pending = "pending" + granted = "granted" + revoking = "revoking" + revoked = "revoked" + + +class NetworkInterfaceStatus(StrEnum): + available = "available" + associated = "associated" + attaching = "attaching" + in_use = "in-use" + detaching = "detaching" + + +class NetworkInterfaceType(StrEnum): + interface = "interface" + natGateway = "natGateway" + efa = "efa" + efa_only = "efa-only" + trunk = "trunk" + load_balancer = "load_balancer" + network_load_balancer = "network_load_balancer" + vpc_endpoint = "vpc_endpoint" + branch = "branch" + transit_gateway = "transit_gateway" + lambda_ = "lambda" + quicksight = "quicksight" + global_accelerator_managed = "global_accelerator_managed" + api_gateway_managed = "api_gateway_managed" + gateway_load_balancer = "gateway_load_balancer" + gateway_load_balancer_endpoint = "gateway_load_balancer_endpoint" + iot_rules_managed = "iot_rules_managed" + aws_codestar_connections_managed = "aws_codestar_connections_managed" + + +class NitroEnclavesSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + + +class NitroTpmSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + + +class OfferingClassType(StrEnum): + standard = "standard" + convertible = "convertible" + + +class OfferingTypeValues(StrEnum): + Heavy_Utilization = "Heavy Utilization" + Medium_Utilization = "Medium Utilization" + Light_Utilization = "Light Utilization" + No_Upfront = "No Upfront" + Partial_Upfront = "Partial Upfront" + All_Upfront = "All Upfront" + + +class OnDemandAllocationStrategy(StrEnum): + lowestPrice = "lowestPrice" + prioritized = "prioritized" + + +class OperationType(StrEnum): + add = "add" + remove = "remove" + + +class PartitionLoadFrequency(StrEnum): + none = "none" + daily = "daily" + weekly = "weekly" + monthly = "monthly" + + +class PayerResponsibility(StrEnum): + ServiceOwner = "ServiceOwner" + + +class PaymentOption(StrEnum): + AllUpfront = "AllUpfront" + PartialUpfront = "PartialUpfront" + NoUpfront = "NoUpfront" + + +class PeriodType(StrEnum): + five_minutes = "five-minutes" + fifteen_minutes = "fifteen-minutes" + one_hour = "one-hour" + three_hours = "three-hours" + one_day = "one-day" + one_week = "one-week" + + +class PermissionGroup(StrEnum): + all = "all" + + +class PhcSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + + +class PlacementGroupState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class PlacementGroupStrategy(StrEnum): + cluster = "cluster" + partition = "partition" + spread = "spread" + + +class PlacementStrategy(StrEnum): + cluster = "cluster" + spread = "spread" + partition = "partition" + + +class PlatformValues(StrEnum): + Windows = "Windows" + + +class PrefixListState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + modify_in_progress = "modify-in-progress" + modify_complete = "modify-complete" + modify_failed = "modify-failed" + restore_in_progress = "restore-in-progress" + restore_complete = "restore-complete" + restore_failed = "restore-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + delete_failed = "delete-failed" + + +class PrincipalType(StrEnum): + All = "All" + Service = "Service" + OrganizationUnit = "OrganizationUnit" + Account = "Account" + User = "User" + Role = "Role" + + +class ProductCodeValues(StrEnum): + devpay = "devpay" + marketplace = "marketplace" + + +class Protocol(StrEnum): + tcp = "tcp" + udp = "udp" + + +class ProtocolValue(StrEnum): + gre = "gre" + + +class PublicIpDnsOption(StrEnum): + public_dual_stack_dns_name = "public-dual-stack-dns-name" + public_ipv4_dns_name = "public-ipv4-dns-name" + public_ipv6_dns_name = "public-ipv6-dns-name" + + +class RIProductDescription(StrEnum): + Linux_UNIX = "Linux/UNIX" + Linux_UNIX_Amazon_VPC_ = "Linux/UNIX (Amazon VPC)" + Windows = "Windows" + Windows_Amazon_VPC_ = "Windows (Amazon VPC)" + + +class RebootMigrationSupport(StrEnum): + unsupported = "unsupported" + supported = "supported" + + +class RecurringChargeFrequency(StrEnum): + Hourly = "Hourly" + + +class ReplaceRootVolumeTaskState(StrEnum): + pending = "pending" + in_progress = "in-progress" + failing = "failing" + succeeded = "succeeded" + failed = "failed" + failed_detached = "failed-detached" + + +class ReplacementStrategy(StrEnum): + launch = "launch" + launch_before_terminate = "launch-before-terminate" + + +class ReportInstanceReasonCodes(StrEnum): + instance_stuck_in_state = "instance-stuck-in-state" + unresponsive = "unresponsive" + not_accepting_credentials = "not-accepting-credentials" + password_not_available = "password-not-available" + performance_network = "performance-network" + performance_instance_store = "performance-instance-store" + performance_ebs_volume = "performance-ebs-volume" + performance_other = "performance-other" + other = "other" + + +class ReportState(StrEnum): + running = "running" + cancelled = "cancelled" + complete = "complete" + error = "error" + + +class ReportStatusType(StrEnum): + ok = "ok" + impaired = "impaired" + + +class ReservationState(StrEnum): + payment_pending = "payment-pending" + payment_failed = "payment-failed" + active = "active" + retired = "retired" + + +class ReservedInstanceState(StrEnum): + payment_pending = "payment-pending" + active = "active" + payment_failed = "payment-failed" + retired = "retired" + queued = "queued" + queued_deleted = "queued-deleted" + + +class ResetFpgaImageAttributeName(StrEnum): + loadPermission = "loadPermission" + + +class ResetImageAttributeName(StrEnum): + launchPermission = "launchPermission" + + +class ResourceType(StrEnum): + capacity_reservation = "capacity-reservation" + client_vpn_endpoint = "client-vpn-endpoint" + customer_gateway = "customer-gateway" + carrier_gateway = "carrier-gateway" + coip_pool = "coip-pool" + declarative_policies_report = "declarative-policies-report" + dedicated_host = "dedicated-host" + dhcp_options = "dhcp-options" + egress_only_internet_gateway = "egress-only-internet-gateway" + elastic_ip = "elastic-ip" + elastic_gpu = "elastic-gpu" + export_image_task = "export-image-task" + export_instance_task = "export-instance-task" + fleet = "fleet" + fpga_image = "fpga-image" + host_reservation = "host-reservation" + image = "image" + import_image_task = "import-image-task" + import_snapshot_task = "import-snapshot-task" + instance = "instance" + instance_event_window = "instance-event-window" + internet_gateway = "internet-gateway" + ipam = "ipam" + ipam_pool = "ipam-pool" + ipam_scope = "ipam-scope" + ipv4pool_ec2 = "ipv4pool-ec2" + ipv6pool_ec2 = "ipv6pool-ec2" + key_pair = "key-pair" + launch_template = "launch-template" + local_gateway = "local-gateway" + local_gateway_route_table = "local-gateway-route-table" + local_gateway_virtual_interface = "local-gateway-virtual-interface" + local_gateway_virtual_interface_group = "local-gateway-virtual-interface-group" + local_gateway_route_table_vpc_association = "local-gateway-route-table-vpc-association" + local_gateway_route_table_virtual_interface_group_association = ( + "local-gateway-route-table-virtual-interface-group-association" + ) + natgateway = "natgateway" + network_acl = "network-acl" + network_interface = "network-interface" + network_insights_analysis = "network-insights-analysis" + network_insights_path = "network-insights-path" + network_insights_access_scope = "network-insights-access-scope" + network_insights_access_scope_analysis = "network-insights-access-scope-analysis" + outpost_lag = "outpost-lag" + placement_group = "placement-group" + prefix_list = "prefix-list" + replace_root_volume_task = "replace-root-volume-task" + reserved_instances = "reserved-instances" + route_table = "route-table" + security_group = "security-group" + security_group_rule = "security-group-rule" + service_link_virtual_interface = "service-link-virtual-interface" + snapshot = "snapshot" + spot_fleet_request = "spot-fleet-request" + spot_instances_request = "spot-instances-request" + subnet = "subnet" + subnet_cidr_reservation = "subnet-cidr-reservation" + traffic_mirror_filter = "traffic-mirror-filter" + traffic_mirror_session = "traffic-mirror-session" + traffic_mirror_target = "traffic-mirror-target" + transit_gateway = "transit-gateway" + transit_gateway_attachment = "transit-gateway-attachment" + transit_gateway_connect_peer = "transit-gateway-connect-peer" + transit_gateway_multicast_domain = "transit-gateway-multicast-domain" + transit_gateway_policy_table = "transit-gateway-policy-table" + transit_gateway_route_table = "transit-gateway-route-table" + transit_gateway_route_table_announcement = "transit-gateway-route-table-announcement" + volume = "volume" + vpc = "vpc" + vpc_endpoint = "vpc-endpoint" + vpc_endpoint_connection = "vpc-endpoint-connection" + vpc_endpoint_service = "vpc-endpoint-service" + vpc_endpoint_service_permission = "vpc-endpoint-service-permission" + vpc_peering_connection = "vpc-peering-connection" + vpn_connection = "vpn-connection" + vpn_gateway = "vpn-gateway" + vpc_flow_log = "vpc-flow-log" + capacity_reservation_fleet = "capacity-reservation-fleet" + traffic_mirror_filter_rule = "traffic-mirror-filter-rule" + vpc_endpoint_connection_device_type = "vpc-endpoint-connection-device-type" + verified_access_instance = "verified-access-instance" + verified_access_group = "verified-access-group" + verified_access_endpoint = "verified-access-endpoint" + verified_access_policy = "verified-access-policy" + verified_access_trust_provider = "verified-access-trust-provider" + vpn_connection_device_type = "vpn-connection-device-type" + vpc_block_public_access_exclusion = "vpc-block-public-access-exclusion" + route_server = "route-server" + route_server_endpoint = "route-server-endpoint" + route_server_peer = "route-server-peer" + ipam_resource_discovery = "ipam-resource-discovery" + ipam_resource_discovery_association = "ipam-resource-discovery-association" + instance_connect_endpoint = "instance-connect-endpoint" + verified_access_endpoint_target = "verified-access-endpoint-target" + ipam_external_resource_verification_token = "ipam-external-resource-verification-token" + capacity_block = "capacity-block" + mac_modification_task = "mac-modification-task" + + +class RootDeviceType(StrEnum): + ebs = "ebs" + instance_store = "instance-store" + + +class RouteOrigin(StrEnum): + CreateRouteTable = "CreateRouteTable" + CreateRoute = "CreateRoute" + EnableVgwRoutePropagation = "EnableVgwRoutePropagation" + + +class RouteServerAssociationState(StrEnum): + associating = "associating" + associated = "associated" + disassociating = "disassociating" + + +class RouteServerBfdState(StrEnum): + up = "up" + down = "down" + + +class RouteServerBgpState(StrEnum): + up = "up" + down = "down" + + +class RouteServerEndpointState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + failing = "failing" + failed = "failed" + delete_failed = "delete-failed" + + +class RouteServerPeerLivenessMode(StrEnum): + bfd = "bfd" + bgp_keepalive = "bgp-keepalive" + + +class RouteServerPeerState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + failing = "failing" + failed = "failed" + + +class RouteServerPersistRoutesAction(StrEnum): + enable = "enable" + disable = "disable" + reset = "reset" + + +class RouteServerPersistRoutesState(StrEnum): + enabling = "enabling" + enabled = "enabled" + resetting = "resetting" + disabling = "disabling" + disabled = "disabled" + modifying = "modifying" + + +class RouteServerPropagationState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + + +class RouteServerRouteInstallationStatus(StrEnum): + installed = "installed" + rejected = "rejected" + + +class RouteServerRouteStatus(StrEnum): + in_rib = "in-rib" + in_fib = "in-fib" + + +class RouteServerState(StrEnum): + pending = "pending" + available = "available" + modifying = "modifying" + deleting = "deleting" + deleted = "deleted" + + +class RouteState(StrEnum): + active = "active" + blackhole = "blackhole" + + +class RouteTableAssociationStateCode(StrEnum): + associating = "associating" + associated = "associated" + disassociating = "disassociating" + disassociated = "disassociated" + failed = "failed" + + +class RuleAction(StrEnum): + allow = "allow" + deny = "deny" + + +class SSEType(StrEnum): + sse_ebs = "sse-ebs" + sse_kms = "sse-kms" + none = "none" + + +class SecurityGroupReferencingSupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class SecurityGroupVpcAssociationState(StrEnum): + associating = "associating" + associated = "associated" + association_failed = "association-failed" + disassociating = "disassociating" + disassociated = "disassociated" + disassociation_failed = "disassociation-failed" + + +class SelfServicePortal(StrEnum): + enabled = "enabled" + disabled = "disabled" + + +class ServiceConnectivityType(StrEnum): + ipv4 = "ipv4" + ipv6 = "ipv6" + + +class ServiceLinkVirtualInterfaceConfigurationState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class ServiceManaged(StrEnum): + alb = "alb" + nlb = "nlb" + rnat = "rnat" + + +class ServiceState(StrEnum): + Pending = "Pending" + Available = "Available" + Deleting = "Deleting" + Deleted = "Deleted" + Failed = "Failed" + + +class ServiceType(StrEnum): + Interface = "Interface" + Gateway = "Gateway" + GatewayLoadBalancer = "GatewayLoadBalancer" + + +class ShutdownBehavior(StrEnum): + stop = "stop" + terminate = "terminate" + + +class SnapshotAttributeName(StrEnum): + productCodes = "productCodes" + createVolumePermission = "createVolumePermission" + + +class SnapshotBlockPublicAccessState(StrEnum): + block_all_sharing = "block-all-sharing" + block_new_sharing = "block-new-sharing" + unblocked = "unblocked" + + +class SnapshotLocationEnum(StrEnum): + regional = "regional" + local = "local" + + +class SnapshotReturnCodes(StrEnum): + success = "success" + skipped = "skipped" + missing_permissions = "missing-permissions" + internal_error = "internal-error" + client_error = "client-error" + + +class SnapshotState(StrEnum): + pending = "pending" + completed = "completed" + error = "error" + recoverable = "recoverable" + recovering = "recovering" + + +class SpotAllocationStrategy(StrEnum): + lowest_price = "lowest-price" + diversified = "diversified" + capacity_optimized = "capacity-optimized" + capacity_optimized_prioritized = "capacity-optimized-prioritized" + price_capacity_optimized = "price-capacity-optimized" + + +class SpotInstanceInterruptionBehavior(StrEnum): + hibernate = "hibernate" + stop = "stop" + terminate = "terminate" + + +class SpotInstanceState(StrEnum): + open = "open" + active = "active" + closed = "closed" + cancelled = "cancelled" + failed = "failed" + disabled = "disabled" + + +class SpotInstanceType(StrEnum): + one_time = "one-time" + persistent = "persistent" + + +class SpreadLevel(StrEnum): + host = "host" + rack = "rack" + + +class State(StrEnum): + PendingAcceptance = "PendingAcceptance" + Pending = "Pending" + Available = "Available" + Deleting = "Deleting" + Deleted = "Deleted" + Rejected = "Rejected" + Failed = "Failed" + Expired = "Expired" + Partial = "Partial" + + +class StaticSourcesSupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class StatisticType(StrEnum): + p50 = "p50" + + +class Status(StrEnum): + MoveInProgress = "MoveInProgress" + InVpc = "InVpc" + InClassic = "InClassic" + + +class StatusName(StrEnum): + reachability = "reachability" + + +class StatusType(StrEnum): + passed = "passed" + failed = "failed" + insufficient_data = "insufficient-data" + initializing = "initializing" + + +class StorageTier(StrEnum): + archive = "archive" + standard = "standard" + + +class SubnetCidrBlockStateCode(StrEnum): + associating = "associating" + associated = "associated" + disassociating = "disassociating" + disassociated = "disassociated" + failing = "failing" + failed = "failed" + + +class SubnetCidrReservationType(StrEnum): + prefix = "prefix" + explicit = "explicit" + + +class SubnetState(StrEnum): + pending = "pending" + available = "available" + unavailable = "unavailable" + failed = "failed" + failed_insufficient_capacity = "failed-insufficient-capacity" + + +class SummaryStatus(StrEnum): + ok = "ok" + impaired = "impaired" + insufficient_data = "insufficient-data" + not_applicable = "not-applicable" + initializing = "initializing" + + +class SupportedAdditionalProcessorFeature(StrEnum): + amd_sev_snp = "amd-sev-snp" + + +class TargetCapacityUnitType(StrEnum): + vcpu = "vcpu" + memory_mib = "memory-mib" + units = "units" + + +class TargetStorageTier(StrEnum): + archive = "archive" + + +class TelemetryStatus(StrEnum): + UP = "UP" + DOWN = "DOWN" + + +class Tenancy(StrEnum): + default = "default" + dedicated = "dedicated" + host = "host" + + +class TieringOperationStatus(StrEnum): + archival_in_progress = "archival-in-progress" + archival_completed = "archival-completed" + archival_failed = "archival-failed" + temporary_restore_in_progress = "temporary-restore-in-progress" + temporary_restore_completed = "temporary-restore-completed" + temporary_restore_failed = "temporary-restore-failed" + permanent_restore_in_progress = "permanent-restore-in-progress" + permanent_restore_completed = "permanent-restore-completed" + permanent_restore_failed = "permanent-restore-failed" + + +class TokenState(StrEnum): + valid = "valid" + expired = "expired" + + +class TpmSupportValues(StrEnum): + v2_0 = "v2.0" + + +class TrafficDirection(StrEnum): + ingress = "ingress" + egress = "egress" + + +class TrafficMirrorFilterRuleField(StrEnum): + destination_port_range = "destination-port-range" + source_port_range = "source-port-range" + protocol = "protocol" + description = "description" + + +class TrafficMirrorNetworkService(StrEnum): + amazon_dns = "amazon-dns" + + +class TrafficMirrorRuleAction(StrEnum): + accept = "accept" + reject = "reject" + + +class TrafficMirrorSessionField(StrEnum): + packet_length = "packet-length" + description = "description" + virtual_network_id = "virtual-network-id" + + +class TrafficMirrorTargetType(StrEnum): + network_interface = "network-interface" + network_load_balancer = "network-load-balancer" + gateway_load_balancer_endpoint = "gateway-load-balancer-endpoint" + + +class TrafficType(StrEnum): + ACCEPT = "ACCEPT" + REJECT = "REJECT" + ALL = "ALL" + + +class TransferType(StrEnum): + time_based = "time-based" + standard = "standard" + + +class TransitGatewayAssociationState(StrEnum): + associating = "associating" + associated = "associated" + disassociating = "disassociating" + disassociated = "disassociated" + + +class TransitGatewayAttachmentResourceType(StrEnum): + vpc = "vpc" + vpn = "vpn" + direct_connect_gateway = "direct-connect-gateway" + connect = "connect" + peering = "peering" + tgw_peering = "tgw-peering" + + +class TransitGatewayAttachmentState(StrEnum): + initiating = "initiating" + initiatingRequest = "initiatingRequest" + pendingAcceptance = "pendingAcceptance" + rollingBack = "rollingBack" + pending = "pending" + available = "available" + modifying = "modifying" + deleting = "deleting" + deleted = "deleted" + failed = "failed" + rejected = "rejected" + rejecting = "rejecting" + failing = "failing" + + +class TransitGatewayConnectPeerState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class TransitGatewayMulitcastDomainAssociationState(StrEnum): + pendingAcceptance = "pendingAcceptance" + associating = "associating" + associated = "associated" + disassociating = "disassociating" + disassociated = "disassociated" + rejected = "rejected" + failed = "failed" + + +class TransitGatewayMulticastDomainState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class TransitGatewayPolicyTableState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class TransitGatewayPrefixListReferenceState(StrEnum): + pending = "pending" + available = "available" + modifying = "modifying" + deleting = "deleting" + + +class TransitGatewayPropagationState(StrEnum): + enabling = "enabling" + enabled = "enabled" + disabling = "disabling" + disabled = "disabled" + + +class TransitGatewayRouteState(StrEnum): + pending = "pending" + active = "active" + blackhole = "blackhole" + deleting = "deleting" + deleted = "deleted" + + +class TransitGatewayRouteTableAnnouncementDirection(StrEnum): + outgoing = "outgoing" + incoming = "incoming" + + +class TransitGatewayRouteTableAnnouncementState(StrEnum): + available = "available" + pending = "pending" + failing = "failing" + failed = "failed" + deleting = "deleting" + deleted = "deleted" + + +class TransitGatewayRouteTableState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class TransitGatewayRouteType(StrEnum): + static = "static" + propagated = "propagated" + + +class TransitGatewayState(StrEnum): + pending = "pending" + available = "available" + modifying = "modifying" + deleting = "deleting" + deleted = "deleted" + + +class TransportProtocol(StrEnum): + tcp = "tcp" + udp = "udp" + + +class TrustProviderType(StrEnum): + user = "user" + device = "device" + + +class TunnelInsideIpVersion(StrEnum): + ipv4 = "ipv4" + ipv6 = "ipv6" + + +class UnlimitedSupportedInstanceFamily(StrEnum): + t2 = "t2" + t3 = "t3" + t3a = "t3a" + t4g = "t4g" + + +class UnsuccessfulInstanceCreditSpecificationErrorCode(StrEnum): + InvalidInstanceID_Malformed = "InvalidInstanceID.Malformed" + InvalidInstanceID_NotFound = "InvalidInstanceID.NotFound" + IncorrectInstanceState = "IncorrectInstanceState" + InstanceCreditSpecification_NotSupported = "InstanceCreditSpecification.NotSupported" + + +class UsageClassType(StrEnum): + spot = "spot" + on_demand = "on-demand" + capacity_block = "capacity-block" + + +class UserTrustProviderType(StrEnum): + iam_identity_center = "iam-identity-center" + oidc = "oidc" + + +class VerificationMethod(StrEnum): + remarks_x509 = "remarks-x509" + dns_token = "dns-token" + + +class VerifiedAccessEndpointAttachmentType(StrEnum): + vpc = "vpc" + + +class VerifiedAccessEndpointProtocol(StrEnum): + http = "http" + https = "https" + tcp = "tcp" + + +class VerifiedAccessEndpointStatusCode(StrEnum): + pending = "pending" + active = "active" + updating = "updating" + deleting = "deleting" + deleted = "deleted" + + +class VerifiedAccessEndpointType(StrEnum): + load_balancer = "load-balancer" + network_interface = "network-interface" + rds = "rds" + cidr = "cidr" + + +class VerifiedAccessLogDeliveryStatusCode(StrEnum): + success = "success" + failed = "failed" + + +class VirtualizationType(StrEnum): + hvm = "hvm" + paravirtual = "paravirtual" + + +class VolumeAttachmentState(StrEnum): + attaching = "attaching" + attached = "attached" + detaching = "detaching" + detached = "detached" + busy = "busy" + + +class VolumeAttributeName(StrEnum): + autoEnableIO = "autoEnableIO" + productCodes = "productCodes" + + +class VolumeModificationState(StrEnum): + modifying = "modifying" + optimizing = "optimizing" + completed = "completed" + failed = "failed" + + +class VolumeState(StrEnum): + creating = "creating" + available = "available" + in_use = "in-use" + deleting = "deleting" + deleted = "deleted" + error = "error" + + +class VolumeStatusInfoStatus(StrEnum): + ok = "ok" + impaired = "impaired" + insufficient_data = "insufficient-data" + + +class VolumeStatusName(StrEnum): + io_enabled = "io-enabled" + io_performance = "io-performance" + + +class VolumeType(StrEnum): + standard = "standard" + io1 = "io1" + io2 = "io2" + gp2 = "gp2" + sc1 = "sc1" + st1 = "st1" + gp3 = "gp3" + + +class VpcAttributeName(StrEnum): + enableDnsSupport = "enableDnsSupport" + enableDnsHostnames = "enableDnsHostnames" + enableNetworkAddressUsageMetrics = "enableNetworkAddressUsageMetrics" + + +class VpcBlockPublicAccessExclusionState(StrEnum): + create_in_progress = "create-in-progress" + create_complete = "create-complete" + create_failed = "create-failed" + update_in_progress = "update-in-progress" + update_complete = "update-complete" + update_failed = "update-failed" + delete_in_progress = "delete-in-progress" + delete_complete = "delete-complete" + disable_in_progress = "disable-in-progress" + disable_complete = "disable-complete" + + +class VpcBlockPublicAccessExclusionsAllowed(StrEnum): + allowed = "allowed" + not_allowed = "not-allowed" + + +class VpcBlockPublicAccessState(StrEnum): + default_state = "default-state" + update_in_progress = "update-in-progress" + update_complete = "update-complete" + + +class VpcCidrBlockStateCode(StrEnum): + associating = "associating" + associated = "associated" + disassociating = "disassociating" + disassociated = "disassociated" + failing = "failing" + failed = "failed" + + +class VpcEncryptionControlExclusionState(StrEnum): + enabling = "enabling" + enabled = "enabled" + disabling = "disabling" + disabled = "disabled" + + +class VpcEncryptionControlMode(StrEnum): + monitor = "monitor" + enforce = "enforce" + + +class VpcEncryptionControlState(StrEnum): + enforce_in_progress = "enforce-in-progress" + monitor_in_progress = "monitor-in-progress" + enforce_failed = "enforce-failed" + monitor_failed = "monitor-failed" + deleting = "deleting" + deleted = "deleted" + available = "available" + creating = "creating" + delete_failed = "delete-failed" + + +class VpcEndpointType(StrEnum): + Interface = "Interface" + Gateway = "Gateway" + GatewayLoadBalancer = "GatewayLoadBalancer" + Resource = "Resource" + ServiceNetwork = "ServiceNetwork" + + +class VpcPeeringConnectionStateReasonCode(StrEnum): + initiating_request = "initiating-request" + pending_acceptance = "pending-acceptance" + active = "active" + deleted = "deleted" + rejected = "rejected" + failed = "failed" + expired = "expired" + provisioning = "provisioning" + deleting = "deleting" + + +class VpcState(StrEnum): + pending = "pending" + available = "available" + + +class VpcTenancy(StrEnum): + default = "default" + + +class VpnEcmpSupportValue(StrEnum): + enable = "enable" + disable = "disable" + + +class VpnProtocol(StrEnum): + openvpn = "openvpn" + + +class VpnState(StrEnum): + pending = "pending" + available = "available" + deleting = "deleting" + deleted = "deleted" + + +class VpnStaticRouteSource(StrEnum): + Static = "Static" + + +class VpnTunnelProvisioningStatus(StrEnum): + available = "available" + pending = "pending" + failed = "failed" + + +class WeekDay(StrEnum): + sunday = "sunday" + monday = "monday" + tuesday = "tuesday" + wednesday = "wednesday" + thursday = "thursday" + friday = "friday" + saturday = "saturday" + + +class scope(StrEnum): + Availability_Zone = "Availability Zone" + Region = "Region" + + +class AcceleratorCount(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +class AcceleratorCountRequest(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +AcceleratorManufacturerSet = List[AcceleratorManufacturer] +AcceleratorNameSet = List[AcceleratorName] + + +class AcceleratorTotalMemoryMiB(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +class AcceleratorTotalMemoryMiBRequest(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +AcceleratorTypeSet = List[AcceleratorType] + + +class Tag(TypedDict, total=False): + Key: Optional[String] + Value: Optional[String] + + +TagList = List[Tag] + + +class TagSpecification(TypedDict, total=False): + ResourceType: Optional[ResourceType] + Tags: Optional[TagList] + + +TagSpecificationList = List[TagSpecification] + + +class AcceptAddressTransferRequest(ServiceRequest): + Address: String + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +MillisecondDateTime = datetime + + +class AddressTransfer(TypedDict, total=False): + PublicIp: Optional[String] + AllocationId: Optional[String] + TransferAccountId: Optional[String] + TransferOfferExpirationTimestamp: Optional[MillisecondDateTime] + TransferOfferAcceptedTimestamp: Optional[MillisecondDateTime] + AddressTransferStatus: Optional[AddressTransferStatus] + + +class AcceptAddressTransferResult(TypedDict, total=False): + AddressTransfer: Optional[AddressTransfer] + + +class AcceptCapacityReservationBillingOwnershipRequest(ServiceRequest): + DryRun: Optional[Boolean] + CapacityReservationId: CapacityReservationId + + +class AcceptCapacityReservationBillingOwnershipResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class TargetConfigurationRequest(TypedDict, total=False): + InstanceCount: Optional[Integer] + OfferingId: ReservedInstancesOfferingId + + +TargetConfigurationRequestSet = List[TargetConfigurationRequest] +ReservedInstanceIdSet = List[ReservationId] + + +class AcceptReservedInstancesExchangeQuoteRequest(ServiceRequest): + DryRun: Optional[Boolean] + ReservedInstanceIds: ReservedInstanceIdSet + TargetConfigurations: Optional[TargetConfigurationRequestSet] + + +class AcceptReservedInstancesExchangeQuoteResult(TypedDict, total=False): + ExchangeId: Optional[String] + + +ValueStringList = List[String] + + +class AcceptTransitGatewayMulticastDomainAssociationsRequest(ServiceRequest): + TransitGatewayMulticastDomainId: Optional[TransitGatewayMulticastDomainId] + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + SubnetIds: Optional[ValueStringList] + DryRun: Optional[Boolean] + + +class SubnetAssociation(TypedDict, total=False): + SubnetId: Optional[String] + State: Optional[TransitGatewayMulitcastDomainAssociationState] + + +SubnetAssociationList = List[SubnetAssociation] + + +class TransitGatewayMulticastDomainAssociations(TypedDict, total=False): + TransitGatewayMulticastDomainId: Optional[String] + TransitGatewayAttachmentId: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + ResourceOwnerId: Optional[String] + Subnets: Optional[SubnetAssociationList] + + +class AcceptTransitGatewayMulticastDomainAssociationsResult(TypedDict, total=False): + Associations: Optional[TransitGatewayMulticastDomainAssociations] + + +class AcceptTransitGatewayPeeringAttachmentRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +DateTime = datetime + + +class PeeringAttachmentStatus(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +class TransitGatewayPeeringAttachmentOptions(TypedDict, total=False): + DynamicRouting: Optional[DynamicRoutingValue] + + +class PeeringTgwInfo(TypedDict, total=False): + TransitGatewayId: Optional[String] + CoreNetworkId: Optional[String] + OwnerId: Optional[String] + Region: Optional[String] + + +class TransitGatewayPeeringAttachment(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[String] + AccepterTransitGatewayAttachmentId: Optional[String] + RequesterTgwInfo: Optional[PeeringTgwInfo] + AccepterTgwInfo: Optional[PeeringTgwInfo] + Options: Optional[TransitGatewayPeeringAttachmentOptions] + Status: Optional[PeeringAttachmentStatus] + State: Optional[TransitGatewayAttachmentState] + CreationTime: Optional[DateTime] + Tags: Optional[TagList] + + +class AcceptTransitGatewayPeeringAttachmentResult(TypedDict, total=False): + TransitGatewayPeeringAttachment: Optional[TransitGatewayPeeringAttachment] + + +class AcceptTransitGatewayVpcAttachmentRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class TransitGatewayVpcAttachmentOptions(TypedDict, total=False): + DnsSupport: Optional[DnsSupportValue] + SecurityGroupReferencingSupport: Optional[SecurityGroupReferencingSupportValue] + Ipv6Support: Optional[Ipv6SupportValue] + ApplianceModeSupport: Optional[ApplianceModeSupportValue] + + +class TransitGatewayVpcAttachment(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[String] + TransitGatewayId: Optional[String] + VpcId: Optional[String] + VpcOwnerId: Optional[String] + State: Optional[TransitGatewayAttachmentState] + SubnetIds: Optional[ValueStringList] + CreationTime: Optional[DateTime] + Options: Optional[TransitGatewayVpcAttachmentOptions] + Tags: Optional[TagList] + + +class AcceptTransitGatewayVpcAttachmentResult(TypedDict, total=False): + TransitGatewayVpcAttachment: Optional[TransitGatewayVpcAttachment] + + +VpcEndpointIdList = List[VpcEndpointId] + + +class AcceptVpcEndpointConnectionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: VpcEndpointServiceId + VpcEndpointIds: VpcEndpointIdList + + +class UnsuccessfulItemError(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +class UnsuccessfulItem(TypedDict, total=False): + Error: Optional[UnsuccessfulItemError] + ResourceId: Optional[String] + + +UnsuccessfulItemSet = List[UnsuccessfulItem] + + +class AcceptVpcEndpointConnectionsResult(TypedDict, total=False): + Unsuccessful: Optional[UnsuccessfulItemSet] + + +class AcceptVpcPeeringConnectionRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcPeeringConnectionId: VpcPeeringConnectionIdWithResolver + + +class VpcPeeringConnectionStateReason(TypedDict, total=False): + Code: Optional[VpcPeeringConnectionStateReasonCode] + Message: Optional[String] + + +class VpcPeeringConnectionOptionsDescription(TypedDict, total=False): + AllowDnsResolutionFromRemoteVpc: Optional[Boolean] + AllowEgressFromLocalClassicLinkToRemoteVpc: Optional[Boolean] + AllowEgressFromLocalVpcToRemoteClassicLink: Optional[Boolean] + + +class CidrBlock(TypedDict, total=False): + CidrBlock: Optional[String] + + +CidrBlockSet = List[CidrBlock] + + +class Ipv6CidrBlock(TypedDict, total=False): + Ipv6CidrBlock: Optional[String] + + +Ipv6CidrBlockSet = List[Ipv6CidrBlock] + + +class VpcPeeringConnectionVpcInfo(TypedDict, total=False): + CidrBlock: Optional[String] + Ipv6CidrBlockSet: Optional[Ipv6CidrBlockSet] + CidrBlockSet: Optional[CidrBlockSet] + OwnerId: Optional[String] + PeeringOptions: Optional[VpcPeeringConnectionOptionsDescription] + VpcId: Optional[String] + Region: Optional[String] + + +class VpcPeeringConnection(TypedDict, total=False): + AccepterVpcInfo: Optional[VpcPeeringConnectionVpcInfo] + ExpirationTime: Optional[DateTime] + RequesterVpcInfo: Optional[VpcPeeringConnectionVpcInfo] + Status: Optional[VpcPeeringConnectionStateReason] + Tags: Optional[TagList] + VpcPeeringConnectionId: Optional[String] + + +class AcceptVpcPeeringConnectionResult(TypedDict, total=False): + VpcPeeringConnection: Optional[VpcPeeringConnection] + + +class PortRange(TypedDict, total=False): + From: Optional[Integer] + To: Optional[Integer] + + +PortRangeList = List[PortRange] + + +class FirewallStatefulRule(TypedDict, total=False): + RuleGroupArn: Optional[ResourceArn] + Sources: Optional[ValueStringList] + Destinations: Optional[ValueStringList] + SourcePorts: Optional[PortRangeList] + DestinationPorts: Optional[PortRangeList] + Protocol: Optional[String] + RuleAction: Optional[String] + Direction: Optional[String] + + +ProtocolIntList = List[ProtocolInt] + + +class FirewallStatelessRule(TypedDict, total=False): + RuleGroupArn: Optional[ResourceArn] + Sources: Optional[ValueStringList] + Destinations: Optional[ValueStringList] + SourcePorts: Optional[PortRangeList] + DestinationPorts: Optional[PortRangeList] + Protocols: Optional[ProtocolIntList] + RuleAction: Optional[String] + Priority: Optional[Priority] + + +class AnalysisComponent(TypedDict, total=False): + Id: Optional[String] + Arn: Optional[String] + Name: Optional[String] + + +class TransitGatewayRouteTableRoute(TypedDict, total=False): + DestinationCidr: Optional[String] + State: Optional[String] + RouteOrigin: Optional[String] + PrefixListId: Optional[String] + AttachmentId: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[String] + + +AnalysisComponentList = List[AnalysisComponent] + + +class AnalysisSecurityGroupRule(TypedDict, total=False): + Cidr: Optional[String] + Direction: Optional[String] + SecurityGroupId: Optional[String] + PortRange: Optional[PortRange] + PrefixListId: Optional[String] + Protocol: Optional[String] + + +class AnalysisRouteTableRoute(TypedDict, total=False): + DestinationCidr: Optional[String] + DestinationPrefixListId: Optional[String] + EgressOnlyInternetGatewayId: Optional[String] + GatewayId: Optional[String] + InstanceId: Optional[String] + NatGatewayId: Optional[String] + NetworkInterfaceId: Optional[String] + Origin: Optional[String] + TransitGatewayId: Optional[String] + VpcPeeringConnectionId: Optional[String] + State: Optional[String] + CarrierGatewayId: Optional[String] + CoreNetworkArn: Optional[ResourceArn] + LocalGatewayId: Optional[String] + + +StringList = List[String] + + +class AnalysisLoadBalancerTarget(TypedDict, total=False): + Address: Optional[IpAddress] + AvailabilityZone: Optional[String] + AvailabilityZoneId: Optional[String] + Instance: Optional[AnalysisComponent] + Port: Optional[Port] + + +class AnalysisLoadBalancerListener(TypedDict, total=False): + LoadBalancerPort: Optional[Port] + InstancePort: Optional[Port] + + +IpAddressList = List[IpAddress] + + +class AnalysisAclRule(TypedDict, total=False): + Cidr: Optional[String] + Egress: Optional[Boolean] + PortRange: Optional[PortRange] + Protocol: Optional[String] + RuleAction: Optional[String] + RuleNumber: Optional[Integer] + + +class Explanation(TypedDict, total=False): + Acl: Optional[AnalysisComponent] + AclRule: Optional[AnalysisAclRule] + Address: Optional[IpAddress] + Addresses: Optional[IpAddressList] + AttachedTo: Optional[AnalysisComponent] + AvailabilityZones: Optional[ValueStringList] + AvailabilityZoneIds: Optional[ValueStringList] + Cidrs: Optional[ValueStringList] + Component: Optional[AnalysisComponent] + CustomerGateway: Optional[AnalysisComponent] + Destination: Optional[AnalysisComponent] + DestinationVpc: Optional[AnalysisComponent] + Direction: Optional[String] + ExplanationCode: Optional[String] + IngressRouteTable: Optional[AnalysisComponent] + InternetGateway: Optional[AnalysisComponent] + LoadBalancerArn: Optional[ResourceArn] + ClassicLoadBalancerListener: Optional[AnalysisLoadBalancerListener] + LoadBalancerListenerPort: Optional[Port] + LoadBalancerTarget: Optional[AnalysisLoadBalancerTarget] + LoadBalancerTargetGroup: Optional[AnalysisComponent] + LoadBalancerTargetGroups: Optional[AnalysisComponentList] + LoadBalancerTargetPort: Optional[Port] + ElasticLoadBalancerListener: Optional[AnalysisComponent] + MissingComponent: Optional[String] + NatGateway: Optional[AnalysisComponent] + NetworkInterface: Optional[AnalysisComponent] + PacketField: Optional[String] + VpcPeeringConnection: Optional[AnalysisComponent] + Port: Optional[Port] + PortRanges: Optional[PortRangeList] + PrefixList: Optional[AnalysisComponent] + Protocols: Optional[StringList] + RouteTableRoute: Optional[AnalysisRouteTableRoute] + RouteTable: Optional[AnalysisComponent] + SecurityGroup: Optional[AnalysisComponent] + SecurityGroupRule: Optional[AnalysisSecurityGroupRule] + SecurityGroups: Optional[AnalysisComponentList] + SourceVpc: Optional[AnalysisComponent] + State: Optional[String] + Subnet: Optional[AnalysisComponent] + SubnetRouteTable: Optional[AnalysisComponent] + Vpc: Optional[AnalysisComponent] + VpcEndpoint: Optional[AnalysisComponent] + VpnConnection: Optional[AnalysisComponent] + VpnGateway: Optional[AnalysisComponent] + TransitGateway: Optional[AnalysisComponent] + TransitGatewayRouteTable: Optional[AnalysisComponent] + TransitGatewayRouteTableRoute: Optional[TransitGatewayRouteTableRoute] + TransitGatewayAttachment: Optional[AnalysisComponent] + ComponentAccount: Optional[ComponentAccount] + ComponentRegion: Optional[ComponentRegion] + FirewallStatelessRule: Optional[FirewallStatelessRule] + FirewallStatefulRule: Optional[FirewallStatefulRule] + + +ExplanationList = List[Explanation] + + +class RuleOption(TypedDict, total=False): + Keyword: Optional[String] + Settings: Optional[StringList] + + +RuleOptionList = List[RuleOption] + + +class RuleGroupRuleOptionsPair(TypedDict, total=False): + RuleGroupArn: Optional[ResourceArn] + RuleOptions: Optional[RuleOptionList] + + +RuleGroupRuleOptionsPairList = List[RuleGroupRuleOptionsPair] + + +class RuleGroupTypePair(TypedDict, total=False): + RuleGroupArn: Optional[ResourceArn] + RuleGroupType: Optional[String] + + +RuleGroupTypePairList = List[RuleGroupTypePair] + + +class AdditionalDetail(TypedDict, total=False): + AdditionalDetailType: Optional[String] + Component: Optional[AnalysisComponent] + VpcEndpointService: Optional[AnalysisComponent] + RuleOptions: Optional[RuleOptionList] + RuleGroupTypePairs: Optional[RuleGroupTypePairList] + RuleGroupRuleOptionsPairs: Optional[RuleGroupRuleOptionsPairList] + ServiceName: Optional[String] + LoadBalancers: Optional[AnalysisComponentList] + + +AdditionalDetailList = List[AdditionalDetail] + + +class AnalysisPacketHeader(TypedDict, total=False): + DestinationAddresses: Optional[IpAddressList] + DestinationPortRanges: Optional[PortRangeList] + Protocol: Optional[String] + SourceAddresses: Optional[IpAddressList] + SourcePortRanges: Optional[PortRangeList] + + +class PathComponent(TypedDict, total=False): + SequenceNumber: Optional[Integer] + AclRule: Optional[AnalysisAclRule] + AttachedTo: Optional[AnalysisComponent] + Component: Optional[AnalysisComponent] + DestinationVpc: Optional[AnalysisComponent] + OutboundHeader: Optional[AnalysisPacketHeader] + InboundHeader: Optional[AnalysisPacketHeader] + RouteTableRoute: Optional[AnalysisRouteTableRoute] + SecurityGroupRule: Optional[AnalysisSecurityGroupRule] + SourceVpc: Optional[AnalysisComponent] + Subnet: Optional[AnalysisComponent] + Vpc: Optional[AnalysisComponent] + AdditionalDetails: Optional[AdditionalDetailList] + TransitGateway: Optional[AnalysisComponent] + TransitGatewayRouteTableRoute: Optional[TransitGatewayRouteTableRoute] + Explanations: Optional[ExplanationList] + ElasticLoadBalancerListener: Optional[AnalysisComponent] + FirewallStatelessRule: Optional[FirewallStatelessRule] + FirewallStatefulRule: Optional[FirewallStatefulRule] + ServiceName: Optional[String] + + +PathComponentList = List[PathComponent] + + +class AccessScopeAnalysisFinding(TypedDict, total=False): + NetworkInsightsAccessScopeAnalysisId: Optional[NetworkInsightsAccessScopeAnalysisId] + NetworkInsightsAccessScopeId: Optional[NetworkInsightsAccessScopeId] + FindingId: Optional[String] + FindingComponents: Optional[PathComponentList] + + +AccessScopeAnalysisFindingList = List[AccessScopeAnalysisFinding] + + +class ResourceStatement(TypedDict, total=False): + Resources: Optional[ValueStringList] + ResourceTypes: Optional[ValueStringList] + + +class ThroughResourcesStatement(TypedDict, total=False): + ResourceStatement: Optional[ResourceStatement] + + +ThroughResourcesStatementList = List[ThroughResourcesStatement] +ProtocolList = List[Protocol] + + +class PacketHeaderStatement(TypedDict, total=False): + SourceAddresses: Optional[ValueStringList] + DestinationAddresses: Optional[ValueStringList] + SourcePorts: Optional[ValueStringList] + DestinationPorts: Optional[ValueStringList] + SourcePrefixLists: Optional[ValueStringList] + DestinationPrefixLists: Optional[ValueStringList] + Protocols: Optional[ProtocolList] + + +class PathStatement(TypedDict, total=False): + PacketHeaderStatement: Optional[PacketHeaderStatement] + ResourceStatement: Optional[ResourceStatement] + + +class AccessScopePath(TypedDict, total=False): + Source: Optional[PathStatement] + Destination: Optional[PathStatement] + ThroughResources: Optional[ThroughResourcesStatementList] + + +AccessScopePathList = List[AccessScopePath] + + +class ResourceStatementRequest(TypedDict, total=False): + Resources: Optional[ValueStringList] + ResourceTypes: Optional[ValueStringList] + + +class ThroughResourcesStatementRequest(TypedDict, total=False): + ResourceStatement: Optional[ResourceStatementRequest] + + +ThroughResourcesStatementRequestList = List[ThroughResourcesStatementRequest] + + +class PacketHeaderStatementRequest(TypedDict, total=False): + SourceAddresses: Optional[ValueStringList] + DestinationAddresses: Optional[ValueStringList] + SourcePorts: Optional[ValueStringList] + DestinationPorts: Optional[ValueStringList] + SourcePrefixLists: Optional[ValueStringList] + DestinationPrefixLists: Optional[ValueStringList] + Protocols: Optional[ProtocolList] + + +class PathStatementRequest(TypedDict, total=False): + PacketHeaderStatement: Optional[PacketHeaderStatementRequest] + ResourceStatement: Optional[ResourceStatementRequest] + + +class AccessScopePathRequest(TypedDict, total=False): + Source: Optional[PathStatementRequest] + Destination: Optional[PathStatementRequest] + ThroughResources: Optional[ThroughResourcesStatementRequestList] + + +AccessScopePathListRequest = List[AccessScopePathRequest] + + +class AccountAttributeValue(TypedDict, total=False): + AttributeValue: Optional[String] + + +AccountAttributeValueList = List[AccountAttributeValue] + + +class AccountAttribute(TypedDict, total=False): + AttributeName: Optional[String] + AttributeValues: Optional[AccountAttributeValueList] + + +AccountAttributeList = List[AccountAttribute] +AccountAttributeNameStringList = List[AccountAttributeName] + + +class ActiveInstance(TypedDict, total=False): + InstanceId: Optional[String] + InstanceType: Optional[String] + SpotInstanceRequestId: Optional[String] + InstanceHealth: Optional[InstanceHealthStatus] + + +ActiveInstanceSet = List[ActiveInstance] + + +class ActiveVpnTunnelStatus(TypedDict, total=False): + Phase1EncryptionAlgorithm: Optional[String] + Phase2EncryptionAlgorithm: Optional[String] + Phase1IntegrityAlgorithm: Optional[String] + Phase2IntegrityAlgorithm: Optional[String] + Phase1DHGroup: Optional[Integer] + Phase2DHGroup: Optional[Integer] + IkeVersion: Optional[String] + ProvisioningStatus: Optional[VpnTunnelProvisioningStatus] + ProvisioningStatusReason: Optional[String] + + +class AddIpamOperatingRegion(TypedDict, total=False): + RegionName: Optional[String] + + +AddIpamOperatingRegionSet = List[AddIpamOperatingRegion] + + +class AddIpamOrganizationalUnitExclusion(TypedDict, total=False): + OrganizationsEntityPath: Optional[String] + + +AddIpamOrganizationalUnitExclusionSet = List[AddIpamOrganizationalUnitExclusion] + + +class AddPrefixListEntry(TypedDict, total=False): + Cidr: String + Description: Optional[String] + + +AddPrefixListEntries = List[AddPrefixListEntry] + + +class AddedPrincipal(TypedDict, total=False): + PrincipalType: Optional[PrincipalType] + Principal: Optional[String] + ServicePermissionId: Optional[String] + ServiceId: Optional[String] + + +AddedPrincipalSet = List[AddedPrincipal] + + +class Address(TypedDict, total=False): + AllocationId: Optional[String] + AssociationId: Optional[String] + Domain: Optional[DomainType] + NetworkInterfaceId: Optional[String] + NetworkInterfaceOwnerId: Optional[String] + PrivateIpAddress: Optional[String] + Tags: Optional[TagList] + PublicIpv4Pool: Optional[String] + NetworkBorderGroup: Optional[String] + CustomerOwnedIp: Optional[String] + CustomerOwnedIpv4Pool: Optional[String] + CarrierIp: Optional[String] + SubnetId: Optional[String] + ServiceManaged: Optional[ServiceManaged] + InstanceId: Optional[String] + PublicIp: Optional[String] + + +class PtrUpdateStatus(TypedDict, total=False): + Value: Optional[String] + Status: Optional[String] + Reason: Optional[String] + + +class AddressAttribute(TypedDict, total=False): + PublicIp: Optional[PublicIpAddress] + AllocationId: Optional[AllocationId] + PtrRecord: Optional[String] + PtrRecordUpdate: Optional[PtrUpdateStatus] + + +AddressList = List[Address] +AddressSet = List[AddressAttribute] +AddressTransferList = List[AddressTransfer] + + +class AdvertiseByoipCidrRequest(ServiceRequest): + Cidr: String + Asn: Optional[String] + DryRun: Optional[Boolean] + NetworkBorderGroup: Optional[String] + + +class AsnAssociation(TypedDict, total=False): + Asn: Optional[String] + Cidr: Optional[String] + StatusMessage: Optional[String] + State: Optional[AsnAssociationState] + + +AsnAssociationSet = List[AsnAssociation] + + +class ByoipCidr(TypedDict, total=False): + Cidr: Optional[String] + Description: Optional[String] + AsnAssociations: Optional[AsnAssociationSet] + StatusMessage: Optional[String] + State: Optional[ByoipCidrState] + NetworkBorderGroup: Optional[String] + + +class AdvertiseByoipCidrResult(TypedDict, total=False): + ByoipCidr: Optional[ByoipCidr] + + +class AllocateAddressRequest(ServiceRequest): + Domain: Optional[DomainType] + Address: Optional[PublicIpAddress] + PublicIpv4Pool: Optional[Ipv4PoolEc2Id] + NetworkBorderGroup: Optional[String] + CustomerOwnedIpv4Pool: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + IpamPoolId: Optional[IpamPoolId] + DryRun: Optional[Boolean] + + +class AllocateAddressResult(TypedDict, total=False): + AllocationId: Optional[String] + PublicIpv4Pool: Optional[String] + NetworkBorderGroup: Optional[String] + Domain: Optional[DomainType] + CustomerOwnedIp: Optional[String] + CustomerOwnedIpv4Pool: Optional[String] + CarrierIp: Optional[String] + PublicIp: Optional[String] + + +AssetIdList = List[AssetId] + + +class AllocateHostsRequest(ServiceRequest): + InstanceFamily: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + HostRecovery: Optional[HostRecovery] + OutpostArn: Optional[String] + HostMaintenance: Optional[HostMaintenance] + AssetIds: Optional[AssetIdList] + AvailabilityZoneId: Optional[AvailabilityZoneId] + AutoPlacement: Optional[AutoPlacement] + ClientToken: Optional[String] + InstanceType: Optional[String] + Quantity: Optional[Integer] + AvailabilityZone: Optional[String] + + +ResponseHostIdList = List[String] + + +class AllocateHostsResult(TypedDict, total=False): + HostIds: Optional[ResponseHostIdList] + + +IpamPoolAllocationDisallowedCidrs = List[String] +IpamPoolAllocationAllowedCidrs = List[String] + + +class AllocateIpamPoolCidrRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + Cidr: Optional[String] + NetmaskLength: Optional[Integer] + ClientToken: Optional[String] + Description: Optional[String] + PreviewNextCidr: Optional[Boolean] + AllowedCidrs: Optional[IpamPoolAllocationAllowedCidrs] + DisallowedCidrs: Optional[IpamPoolAllocationDisallowedCidrs] + + +class IpamPoolAllocation(TypedDict, total=False): + Cidr: Optional[String] + IpamPoolAllocationId: Optional[IpamPoolAllocationId] + Description: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[IpamPoolAllocationResourceType] + ResourceRegion: Optional[String] + ResourceOwner: Optional[String] + + +class AllocateIpamPoolCidrResult(TypedDict, total=False): + IpamPoolAllocation: Optional[IpamPoolAllocation] + + +AllocationIdList = List[AllocationId] +AllocationIds = List[AllocationId] +AllowedInstanceTypeSet = List[AllowedInstanceType] + + +class AllowedPrincipal(TypedDict, total=False): + PrincipalType: Optional[PrincipalType] + Principal: Optional[String] + ServicePermissionId: Optional[String] + Tags: Optional[TagList] + ServiceId: Optional[String] + + +AllowedPrincipalSet = List[AllowedPrincipal] + + +class AlternatePathHint(TypedDict, total=False): + ComponentId: Optional[String] + ComponentArn: Optional[String] + + +AlternatePathHintList = List[AlternatePathHint] +ClientVpnSecurityGroupIdSet = List[SecurityGroupId] + + +class ApplySecurityGroupsToClientVpnTargetNetworkRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + VpcId: VpcId + SecurityGroupIds: ClientVpnSecurityGroupIdSet + DryRun: Optional[Boolean] + + +class ApplySecurityGroupsToClientVpnTargetNetworkResult(TypedDict, total=False): + SecurityGroupIds: Optional[ClientVpnSecurityGroupIdSet] + + +ArchitectureTypeList = List[ArchitectureType] +ArchitectureTypeSet = List[ArchitectureType] +ArnList = List[ResourceArn] +AsPath = List[String] + + +class AsnAuthorizationContext(TypedDict, total=False): + Message: String + Signature: String + + +Ipv6AddressList = List[String] +IpPrefixList = List[String] + + +class AssignIpv6AddressesRequest(ServiceRequest): + Ipv6PrefixCount: Optional[Integer] + Ipv6Prefixes: Optional[IpPrefixList] + NetworkInterfaceId: NetworkInterfaceId + Ipv6Addresses: Optional[Ipv6AddressList] + Ipv6AddressCount: Optional[Integer] + + +class AssignIpv6AddressesResult(TypedDict, total=False): + AssignedIpv6Addresses: Optional[Ipv6AddressList] + AssignedIpv6Prefixes: Optional[IpPrefixList] + NetworkInterfaceId: Optional[String] + + +PrivateIpAddressStringList = List[String] + + +class AssignPrivateIpAddressesRequest(ServiceRequest): + Ipv4Prefixes: Optional[IpPrefixList] + Ipv4PrefixCount: Optional[Integer] + NetworkInterfaceId: NetworkInterfaceId + PrivateIpAddresses: Optional[PrivateIpAddressStringList] + SecondaryPrivateIpAddressCount: Optional[Integer] + AllowReassignment: Optional[Boolean] + + +class Ipv4PrefixSpecification(TypedDict, total=False): + Ipv4Prefix: Optional[String] + + +Ipv4PrefixesList = List[Ipv4PrefixSpecification] + + +class AssignedPrivateIpAddress(TypedDict, total=False): + PrivateIpAddress: Optional[String] + + +AssignedPrivateIpAddressList = List[AssignedPrivateIpAddress] + + +class AssignPrivateIpAddressesResult(TypedDict, total=False): + NetworkInterfaceId: Optional[String] + AssignedPrivateIpAddresses: Optional[AssignedPrivateIpAddressList] + AssignedIpv4Prefixes: Optional[Ipv4PrefixesList] + + +IpList = List[String] + + +class AssignPrivateNatGatewayAddressRequest(ServiceRequest): + NatGatewayId: NatGatewayId + PrivateIpAddresses: Optional[IpList] + PrivateIpAddressCount: Optional[PrivateIpAddressCount] + DryRun: Optional[Boolean] + + +class NatGatewayAddress(TypedDict, total=False): + AllocationId: Optional[String] + NetworkInterfaceId: Optional[String] + PrivateIp: Optional[String] + PublicIp: Optional[String] + AssociationId: Optional[String] + IsPrimary: Optional[Boolean] + FailureMessage: Optional[String] + Status: Optional[NatGatewayAddressStatus] + + +NatGatewayAddressList = List[NatGatewayAddress] + + +class AssignPrivateNatGatewayAddressResult(TypedDict, total=False): + NatGatewayId: Optional[NatGatewayId] + NatGatewayAddresses: Optional[NatGatewayAddressList] + + +class AssociateAddressRequest(ServiceRequest): + AllocationId: Optional[AllocationId] + InstanceId: Optional[InstanceId] + PublicIp: Optional[EipAllocationPublicIp] + DryRun: Optional[Boolean] + NetworkInterfaceId: Optional[NetworkInterfaceId] + PrivateIpAddress: Optional[String] + AllowReassociation: Optional[Boolean] + + +class AssociateAddressResult(TypedDict, total=False): + AssociationId: Optional[String] + + +class AssociateCapacityReservationBillingOwnerRequest(ServiceRequest): + DryRun: Optional[Boolean] + CapacityReservationId: CapacityReservationId + UnusedReservationBillingOwnerId: AccountID + + +class AssociateCapacityReservationBillingOwnerResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class AssociateClientVpnTargetNetworkRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + SubnetId: SubnetId + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class AssociationStatus(TypedDict, total=False): + Code: Optional[AssociationStatusCode] + Message: Optional[String] + + +class AssociateClientVpnTargetNetworkResult(TypedDict, total=False): + AssociationId: Optional[String] + Status: Optional[AssociationStatus] + + +class AssociateDhcpOptionsRequest(ServiceRequest): + DhcpOptionsId: DefaultingDhcpOptionsId + VpcId: VpcId + DryRun: Optional[Boolean] + + +class AssociateEnclaveCertificateIamRoleRequest(ServiceRequest): + CertificateArn: CertificateId + RoleArn: RoleId + DryRun: Optional[Boolean] + + +class AssociateEnclaveCertificateIamRoleResult(TypedDict, total=False): + CertificateS3BucketName: Optional[String] + CertificateS3ObjectKey: Optional[String] + EncryptionKmsKeyId: Optional[String] + + +class IamInstanceProfileSpecification(TypedDict, total=False): + Arn: Optional[String] + Name: Optional[String] + + +class AssociateIamInstanceProfileRequest(ServiceRequest): + IamInstanceProfile: IamInstanceProfileSpecification + InstanceId: InstanceId + + +class IamInstanceProfile(TypedDict, total=False): + Arn: Optional[String] + Id: Optional[String] + + +class IamInstanceProfileAssociation(TypedDict, total=False): + AssociationId: Optional[String] + InstanceId: Optional[String] + IamInstanceProfile: Optional[IamInstanceProfile] + State: Optional[IamInstanceProfileAssociationState] + Timestamp: Optional[DateTime] + + +class AssociateIamInstanceProfileResult(TypedDict, total=False): + IamInstanceProfileAssociation: Optional[IamInstanceProfileAssociation] + + +DedicatedHostIdList = List[DedicatedHostId] +InstanceIdList = List[InstanceId] + + +class InstanceEventWindowAssociationRequest(TypedDict, total=False): + InstanceIds: Optional[InstanceIdList] + InstanceTags: Optional[TagList] + DedicatedHostIds: Optional[DedicatedHostIdList] + + +class AssociateInstanceEventWindowRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceEventWindowId: InstanceEventWindowId + AssociationTarget: InstanceEventWindowAssociationRequest + + +class InstanceEventWindowAssociationTarget(TypedDict, total=False): + InstanceIds: Optional[InstanceIdList] + Tags: Optional[TagList] + DedicatedHostIds: Optional[DedicatedHostIdList] + + +class InstanceEventWindowTimeRange(TypedDict, total=False): + StartWeekDay: Optional[WeekDay] + StartHour: Optional[Hour] + EndWeekDay: Optional[WeekDay] + EndHour: Optional[Hour] + + +InstanceEventWindowTimeRangeList = List[InstanceEventWindowTimeRange] + + +class InstanceEventWindow(TypedDict, total=False): + InstanceEventWindowId: Optional[InstanceEventWindowId] + TimeRanges: Optional[InstanceEventWindowTimeRangeList] + Name: Optional[String] + CronExpression: Optional[InstanceEventWindowCronExpression] + AssociationTarget: Optional[InstanceEventWindowAssociationTarget] + State: Optional[InstanceEventWindowState] + Tags: Optional[TagList] + + +class AssociateInstanceEventWindowResult(TypedDict, total=False): + InstanceEventWindow: Optional[InstanceEventWindow] + + +class AssociateIpamByoasnRequest(ServiceRequest): + DryRun: Optional[Boolean] + Asn: String + Cidr: String + + +class AssociateIpamByoasnResult(TypedDict, total=False): + AsnAssociation: Optional[AsnAssociation] + + +class AssociateIpamResourceDiscoveryRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamId: IpamId + IpamResourceDiscoveryId: IpamResourceDiscoveryId + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + + +class IpamResourceDiscoveryAssociation(TypedDict, total=False): + OwnerId: Optional[String] + IpamResourceDiscoveryAssociationId: Optional[IpamResourceDiscoveryAssociationId] + IpamResourceDiscoveryAssociationArn: Optional[String] + IpamResourceDiscoveryId: Optional[IpamResourceDiscoveryId] + IpamId: Optional[IpamId] + IpamArn: Optional[ResourceArn] + IpamRegion: Optional[String] + IsDefault: Optional[Boolean] + ResourceDiscoveryStatus: Optional[IpamAssociatedResourceDiscoveryStatus] + State: Optional[IpamResourceDiscoveryAssociationState] + Tags: Optional[TagList] + + +class AssociateIpamResourceDiscoveryResult(TypedDict, total=False): + IpamResourceDiscoveryAssociation: Optional[IpamResourceDiscoveryAssociation] + + +class AssociateNatGatewayAddressRequest(ServiceRequest): + NatGatewayId: NatGatewayId + AllocationIds: AllocationIdList + PrivateIpAddresses: Optional[IpList] + DryRun: Optional[Boolean] + + +class AssociateNatGatewayAddressResult(TypedDict, total=False): + NatGatewayId: Optional[NatGatewayId] + NatGatewayAddresses: Optional[NatGatewayAddressList] + + +class AssociateRouteServerRequest(ServiceRequest): + RouteServerId: RouteServerId + VpcId: VpcId + DryRun: Optional[Boolean] + + +class RouteServerAssociation(TypedDict, total=False): + RouteServerId: Optional[RouteServerId] + VpcId: Optional[VpcId] + State: Optional[RouteServerAssociationState] + + +class AssociateRouteServerResult(TypedDict, total=False): + RouteServerAssociation: Optional[RouteServerAssociation] + + +class AssociateRouteTableRequest(ServiceRequest): + GatewayId: Optional[RouteGatewayId] + DryRun: Optional[Boolean] + SubnetId: Optional[SubnetId] + RouteTableId: RouteTableId + + +class RouteTableAssociationState(TypedDict, total=False): + State: Optional[RouteTableAssociationStateCode] + StatusMessage: Optional[String] + + +class AssociateRouteTableResult(TypedDict, total=False): + AssociationId: Optional[String] + AssociationState: Optional[RouteTableAssociationState] + + +class AssociateSecurityGroupVpcRequest(ServiceRequest): + GroupId: SecurityGroupId + VpcId: VpcId + DryRun: Optional[Boolean] + + +class AssociateSecurityGroupVpcResult(TypedDict, total=False): + State: Optional[SecurityGroupVpcAssociationState] + + +class AssociateSubnetCidrBlockRequest(ServiceRequest): + Ipv6IpamPoolId: Optional[IpamPoolId] + Ipv6NetmaskLength: Optional[NetmaskLength] + SubnetId: SubnetId + Ipv6CidrBlock: Optional[String] + + +class SubnetCidrBlockState(TypedDict, total=False): + State: Optional[SubnetCidrBlockStateCode] + StatusMessage: Optional[String] + + +class SubnetIpv6CidrBlockAssociation(TypedDict, total=False): + AssociationId: Optional[SubnetCidrAssociationId] + Ipv6CidrBlock: Optional[String] + Ipv6CidrBlockState: Optional[SubnetCidrBlockState] + Ipv6AddressAttribute: Optional[Ipv6AddressAttribute] + IpSource: Optional[IpSource] + + +class AssociateSubnetCidrBlockResult(TypedDict, total=False): + Ipv6CidrBlockAssociation: Optional[SubnetIpv6CidrBlockAssociation] + SubnetId: Optional[String] + + +TransitGatewaySubnetIdList = List[SubnetId] + + +class AssociateTransitGatewayMulticastDomainRequest(ServiceRequest): + TransitGatewayMulticastDomainId: TransitGatewayMulticastDomainId + TransitGatewayAttachmentId: TransitGatewayAttachmentId + SubnetIds: TransitGatewaySubnetIdList + DryRun: Optional[Boolean] + + +class AssociateTransitGatewayMulticastDomainResult(TypedDict, total=False): + Associations: Optional[TransitGatewayMulticastDomainAssociations] + + +class AssociateTransitGatewayPolicyTableRequest(ServiceRequest): + TransitGatewayPolicyTableId: TransitGatewayPolicyTableId + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class TransitGatewayPolicyTableAssociation(TypedDict, total=False): + TransitGatewayPolicyTableId: Optional[TransitGatewayPolicyTableId] + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + State: Optional[TransitGatewayAssociationState] + + +class AssociateTransitGatewayPolicyTableResult(TypedDict, total=False): + Association: Optional[TransitGatewayPolicyTableAssociation] + + +class AssociateTransitGatewayRouteTableRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class TransitGatewayAssociation(TypedDict, total=False): + TransitGatewayRouteTableId: Optional[TransitGatewayRouteTableId] + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + State: Optional[TransitGatewayAssociationState] + + +class AssociateTransitGatewayRouteTableResult(TypedDict, total=False): + Association: Optional[TransitGatewayAssociation] + + +class AssociateTrunkInterfaceRequest(ServiceRequest): + BranchInterfaceId: NetworkInterfaceId + TrunkInterfaceId: NetworkInterfaceId + VlanId: Optional[Integer] + GreKey: Optional[Integer] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class TrunkInterfaceAssociation(TypedDict, total=False): + AssociationId: Optional[TrunkInterfaceAssociationId] + BranchInterfaceId: Optional[String] + TrunkInterfaceId: Optional[String] + InterfaceProtocol: Optional[InterfaceProtocolType] + VlanId: Optional[Integer] + GreKey: Optional[Integer] + Tags: Optional[TagList] + + +class AssociateTrunkInterfaceResult(TypedDict, total=False): + InterfaceAssociation: Optional[TrunkInterfaceAssociation] + ClientToken: Optional[String] + + +class AssociateVpcCidrBlockRequest(ServiceRequest): + CidrBlock: Optional[String] + Ipv6CidrBlockNetworkBorderGroup: Optional[String] + Ipv6Pool: Optional[Ipv6PoolEc2Id] + Ipv6CidrBlock: Optional[String] + Ipv4IpamPoolId: Optional[IpamPoolId] + Ipv4NetmaskLength: Optional[NetmaskLength] + Ipv6IpamPoolId: Optional[IpamPoolId] + Ipv6NetmaskLength: Optional[NetmaskLength] + VpcId: VpcId + AmazonProvidedIpv6CidrBlock: Optional[Boolean] + + +class VpcCidrBlockState(TypedDict, total=False): + State: Optional[VpcCidrBlockStateCode] + StatusMessage: Optional[String] + + +class VpcCidrBlockAssociation(TypedDict, total=False): + AssociationId: Optional[String] + CidrBlock: Optional[String] + CidrBlockState: Optional[VpcCidrBlockState] + + +class VpcIpv6CidrBlockAssociation(TypedDict, total=False): + AssociationId: Optional[String] + Ipv6CidrBlock: Optional[String] + Ipv6CidrBlockState: Optional[VpcCidrBlockState] + NetworkBorderGroup: Optional[String] + Ipv6Pool: Optional[String] + Ipv6AddressAttribute: Optional[Ipv6AddressAttribute] + IpSource: Optional[IpSource] + + +class AssociateVpcCidrBlockResult(TypedDict, total=False): + Ipv6CidrBlockAssociation: Optional[VpcIpv6CidrBlockAssociation] + CidrBlockAssociation: Optional[VpcCidrBlockAssociation] + VpcId: Optional[String] + + +class AssociatedRole(TypedDict, total=False): + AssociatedRoleArn: Optional[ResourceArn] + CertificateS3BucketName: Optional[String] + CertificateS3ObjectKey: Optional[String] + EncryptionKmsKeyId: Optional[String] + + +AssociatedRolesList = List[AssociatedRole] +AssociatedSubnetList = List[SubnetId] + + +class AssociatedTargetNetwork(TypedDict, total=False): + NetworkId: Optional[String] + NetworkType: Optional[AssociatedNetworkType] + + +AssociatedTargetNetworkSet = List[AssociatedTargetNetwork] +AssociationIdList = List[IamInstanceProfileAssociationId] + + +class AthenaIntegration(TypedDict, total=False): + IntegrationResultS3DestinationArn: String + PartitionLoadFrequency: PartitionLoadFrequency + PartitionStartDate: Optional[MillisecondDateTime] + PartitionEndDate: Optional[MillisecondDateTime] + + +AthenaIntegrationsSet = List[AthenaIntegration] +GroupIdStringList = List[SecurityGroupId] + + +class AttachClassicLinkVpcRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + VpcId: VpcId + Groups: GroupIdStringList + + +class AttachClassicLinkVpcResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class AttachInternetGatewayRequest(ServiceRequest): + DryRun: Optional[Boolean] + InternetGatewayId: InternetGatewayId + VpcId: VpcId + + +class EnaSrdUdpSpecification(TypedDict, total=False): + EnaSrdUdpEnabled: Optional[Boolean] + + +class EnaSrdSpecification(TypedDict, total=False): + EnaSrdEnabled: Optional[Boolean] + EnaSrdUdpSpecification: Optional[EnaSrdUdpSpecification] + + +class AttachNetworkInterfaceRequest(ServiceRequest): + NetworkCardIndex: Optional[Integer] + EnaSrdSpecification: Optional[EnaSrdSpecification] + EnaQueueCount: Optional[Integer] + DryRun: Optional[Boolean] + NetworkInterfaceId: NetworkInterfaceId + InstanceId: InstanceId + DeviceIndex: Integer + + +class AttachNetworkInterfaceResult(TypedDict, total=False): + AttachmentId: Optional[String] + NetworkCardIndex: Optional[Integer] + + +class AttachVerifiedAccessTrustProviderRequest(ServiceRequest): + VerifiedAccessInstanceId: VerifiedAccessInstanceId + VerifiedAccessTrustProviderId: VerifiedAccessTrustProviderId + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class VerifiedAccessInstanceCustomSubDomain(TypedDict, total=False): + SubDomain: Optional[String] + Nameservers: Optional[ValueStringList] + + +class VerifiedAccessTrustProviderCondensed(TypedDict, total=False): + VerifiedAccessTrustProviderId: Optional[String] + Description: Optional[String] + TrustProviderType: Optional[TrustProviderType] + UserTrustProviderType: Optional[UserTrustProviderType] + DeviceTrustProviderType: Optional[DeviceTrustProviderType] + + +VerifiedAccessTrustProviderCondensedList = List[VerifiedAccessTrustProviderCondensed] + + +class VerifiedAccessInstance(TypedDict, total=False): + VerifiedAccessInstanceId: Optional[String] + Description: Optional[String] + VerifiedAccessTrustProviders: Optional[VerifiedAccessTrustProviderCondensedList] + CreationTime: Optional[String] + LastUpdatedTime: Optional[String] + Tags: Optional[TagList] + FipsEnabled: Optional[Boolean] + CidrEndpointsCustomSubDomain: Optional[VerifiedAccessInstanceCustomSubDomain] + + +class NativeApplicationOidcOptions(TypedDict, total=False): + PublicSigningKeyEndpoint: Optional[String] + Issuer: Optional[String] + AuthorizationEndpoint: Optional[String] + TokenEndpoint: Optional[String] + UserInfoEndpoint: Optional[String] + ClientId: Optional[String] + Scope: Optional[String] + + +class VerifiedAccessSseSpecificationResponse(TypedDict, total=False): + CustomerManagedKeyEnabled: Optional[Boolean] + KmsKeyArn: Optional[KmsKeyArn] + + +class DeviceOptions(TypedDict, total=False): + TenantId: Optional[String] + PublicSigningKeyUrl: Optional[String] + + +class OidcOptions(TypedDict, total=False): + Issuer: Optional[String] + AuthorizationEndpoint: Optional[String] + TokenEndpoint: Optional[String] + UserInfoEndpoint: Optional[String] + ClientId: Optional[String] + ClientSecret: Optional[ClientSecretType] + Scope: Optional[String] + + +class VerifiedAccessTrustProvider(TypedDict, total=False): + VerifiedAccessTrustProviderId: Optional[String] + Description: Optional[String] + TrustProviderType: Optional[TrustProviderType] + UserTrustProviderType: Optional[UserTrustProviderType] + DeviceTrustProviderType: Optional[DeviceTrustProviderType] + OidcOptions: Optional[OidcOptions] + DeviceOptions: Optional[DeviceOptions] + PolicyReferenceName: Optional[String] + CreationTime: Optional[String] + LastUpdatedTime: Optional[String] + Tags: Optional[TagList] + SseSpecification: Optional[VerifiedAccessSseSpecificationResponse] + NativeApplicationOidcOptions: Optional[NativeApplicationOidcOptions] + + +class AttachVerifiedAccessTrustProviderResult(TypedDict, total=False): + VerifiedAccessTrustProvider: Optional[VerifiedAccessTrustProvider] + VerifiedAccessInstance: Optional[VerifiedAccessInstance] + + +class AttachVolumeRequest(ServiceRequest): + Device: String + InstanceId: InstanceId + VolumeId: VolumeId + DryRun: Optional[Boolean] + + +class AttachVpnGatewayRequest(ServiceRequest): + VpcId: VpcId + VpnGatewayId: VpnGatewayId + DryRun: Optional[Boolean] + + +class VpcAttachment(TypedDict, total=False): + VpcId: Optional[String] + State: Optional[AttachmentStatus] + + +class AttachVpnGatewayResult(TypedDict, total=False): + VpcAttachment: Optional[VpcAttachment] + + +class AttachmentEnaSrdUdpSpecification(TypedDict, total=False): + EnaSrdUdpEnabled: Optional[Boolean] + + +class AttachmentEnaSrdSpecification(TypedDict, total=False): + EnaSrdEnabled: Optional[Boolean] + EnaSrdUdpSpecification: Optional[AttachmentEnaSrdUdpSpecification] + + +class AttributeBooleanValue(TypedDict, total=False): + Value: Optional[Boolean] + + +class RegionalSummary(TypedDict, total=False): + RegionName: Optional[String] + NumberOfMatchedAccounts: Optional[Integer] + NumberOfUnmatchedAccounts: Optional[Integer] + + +RegionalSummaryList = List[RegionalSummary] + + +class AttributeSummary(TypedDict, total=False): + AttributeName: Optional[String] + MostFrequentValue: Optional[String] + NumberOfMatchedAccounts: Optional[Integer] + NumberOfUnmatchedAccounts: Optional[Integer] + RegionalSummaries: Optional[RegionalSummaryList] + + +AttributeSummaryList = List[AttributeSummary] + + +class AttributeValue(TypedDict, total=False): + Value: Optional[String] + + +class ClientVpnAuthorizationRuleStatus(TypedDict, total=False): + Code: Optional[ClientVpnAuthorizationRuleStatusCode] + Message: Optional[String] + + +class AuthorizationRule(TypedDict, total=False): + ClientVpnEndpointId: Optional[String] + Description: Optional[String] + GroupId: Optional[String] + AccessAll: Optional[Boolean] + DestinationCidr: Optional[String] + Status: Optional[ClientVpnAuthorizationRuleStatus] + + +AuthorizationRuleSet = List[AuthorizationRule] + + +class AuthorizeClientVpnIngressRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + TargetNetworkCidr: String + AccessGroupId: Optional[String] + AuthorizeAllGroups: Optional[Boolean] + Description: Optional[String] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class AuthorizeClientVpnIngressResult(TypedDict, total=False): + Status: Optional[ClientVpnAuthorizationRuleStatus] + + +class PrefixListId(TypedDict, total=False): + Description: Optional[String] + PrefixListId: Optional[String] + + +PrefixListIdList = List[PrefixListId] + + +class Ipv6Range(TypedDict, total=False): + Description: Optional[String] + CidrIpv6: Optional[String] + + +Ipv6RangeList = List[Ipv6Range] + + +class IpRange(TypedDict, total=False): + Description: Optional[String] + CidrIp: Optional[String] + + +IpRangeList = List[IpRange] + + +class UserIdGroupPair(TypedDict, total=False): + Description: Optional[String] + UserId: Optional[String] + GroupName: Optional[String] + GroupId: Optional[String] + VpcId: Optional[String] + VpcPeeringConnectionId: Optional[String] + PeeringStatus: Optional[String] + + +UserIdGroupPairList = List[UserIdGroupPair] + + +class IpPermission(TypedDict, total=False): + IpProtocol: Optional[String] + FromPort: Optional[Integer] + ToPort: Optional[Integer] + UserIdGroupPairs: Optional[UserIdGroupPairList] + IpRanges: Optional[IpRangeList] + Ipv6Ranges: Optional[Ipv6RangeList] + PrefixListIds: Optional[PrefixListIdList] + + +IpPermissionList = List[IpPermission] + + +class AuthorizeSecurityGroupEgressRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + GroupId: SecurityGroupId + SourceSecurityGroupName: Optional[String] + SourceSecurityGroupOwnerId: Optional[String] + IpProtocol: Optional[String] + FromPort: Optional[Integer] + ToPort: Optional[Integer] + CidrIp: Optional[String] + IpPermissions: Optional[IpPermissionList] + + +class ReferencedSecurityGroup(TypedDict, total=False): + GroupId: Optional[String] + PeeringStatus: Optional[String] + UserId: Optional[String] + VpcId: Optional[String] + VpcPeeringConnectionId: Optional[String] + + +class SecurityGroupRule(TypedDict, total=False): + SecurityGroupRuleId: Optional[SecurityGroupRuleId] + GroupId: Optional[SecurityGroupId] + GroupOwnerId: Optional[String] + IsEgress: Optional[Boolean] + IpProtocol: Optional[String] + FromPort: Optional[Integer] + ToPort: Optional[Integer] + CidrIpv4: Optional[String] + CidrIpv6: Optional[String] + PrefixListId: Optional[PrefixListResourceId] + ReferencedGroupInfo: Optional[ReferencedSecurityGroup] + Description: Optional[String] + Tags: Optional[TagList] + SecurityGroupRuleArn: Optional[String] + + +SecurityGroupRuleList = List[SecurityGroupRule] + + +class AuthorizeSecurityGroupEgressResult(TypedDict, total=False): + Return: Optional[Boolean] + SecurityGroupRules: Optional[SecurityGroupRuleList] + + +class AuthorizeSecurityGroupIngressRequest(ServiceRequest): + CidrIp: Optional[String] + FromPort: Optional[Integer] + GroupId: Optional[SecurityGroupId] + GroupName: Optional[SecurityGroupName] + IpPermissions: Optional[IpPermissionList] + IpProtocol: Optional[String] + SourceSecurityGroupName: Optional[String] + SourceSecurityGroupOwnerId: Optional[String] + ToPort: Optional[Integer] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class AuthorizeSecurityGroupIngressResult(TypedDict, total=False): + Return: Optional[Boolean] + SecurityGroupRules: Optional[SecurityGroupRuleList] + + +class AvailabilityZoneMessage(TypedDict, total=False): + Message: Optional[String] + + +AvailabilityZoneMessageList = List[AvailabilityZoneMessage] + + +class AvailabilityZone(TypedDict, total=False): + OptInStatus: Optional[AvailabilityZoneOptInStatus] + Messages: Optional[AvailabilityZoneMessageList] + RegionName: Optional[String] + ZoneName: Optional[String] + ZoneId: Optional[String] + GroupName: Optional[String] + NetworkBorderGroup: Optional[String] + ZoneType: Optional[String] + ParentZoneName: Optional[String] + ParentZoneId: Optional[String] + GroupLongName: Optional[String] + State: Optional[AvailabilityZoneState] + + +AvailabilityZoneList = List[AvailabilityZone] +AvailabilityZoneStringList = List[String] + + +class InstanceCapacity(TypedDict, total=False): + AvailableCapacity: Optional[Integer] + InstanceType: Optional[String] + TotalCapacity: Optional[Integer] + + +AvailableInstanceCapacityList = List[InstanceCapacity] + + +class AvailableCapacity(TypedDict, total=False): + AvailableInstanceCapacity: Optional[AvailableInstanceCapacityList] + AvailableVCpus: Optional[Integer] + + +BandwidthWeightingTypeList = List[BandwidthWeightingType] + + +class BaselineEbsBandwidthMbps(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +class BaselineEbsBandwidthMbpsRequest(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +class PerformanceFactorReference(TypedDict, total=False): + InstanceFamily: Optional[String] + + +PerformanceFactorReferenceSet = List[PerformanceFactorReference] + + +class CpuPerformanceFactor(TypedDict, total=False): + References: Optional[PerformanceFactorReferenceSet] + + +class BaselinePerformanceFactors(TypedDict, total=False): + Cpu: Optional[CpuPerformanceFactor] + + +class PerformanceFactorReferenceRequest(TypedDict, total=False): + InstanceFamily: Optional[String] + + +PerformanceFactorReferenceSetRequest = List[PerformanceFactorReferenceRequest] + + +class CpuPerformanceFactorRequest(TypedDict, total=False): + References: Optional[PerformanceFactorReferenceSetRequest] + + +class BaselinePerformanceFactorsRequest(TypedDict, total=False): + Cpu: Optional[CpuPerformanceFactorRequest] + + +BillingProductList = List[String] +Blob = bytes + + +class BlobAttributeValue(TypedDict, total=False): + Value: Optional[Blob] + + +class EbsBlockDevice(TypedDict, total=False): + DeleteOnTermination: Optional[Boolean] + Iops: Optional[Integer] + SnapshotId: Optional[SnapshotId] + VolumeSize: Optional[Integer] + VolumeType: Optional[VolumeType] + KmsKeyId: Optional[String] + Throughput: Optional[Integer] + OutpostArn: Optional[String] + AvailabilityZone: Optional[String] + Encrypted: Optional[Boolean] + VolumeInitializationRate: Optional[Integer] + AvailabilityZoneId: Optional[String] + + +class BlockDeviceMapping(TypedDict, total=False): + Ebs: Optional[EbsBlockDevice] + NoDevice: Optional[String] + DeviceName: Optional[String] + VirtualName: Optional[String] + + +BlockDeviceMappingList = List[BlockDeviceMapping] +BlockDeviceMappingRequestList = List[BlockDeviceMapping] + + +class EbsBlockDeviceResponse(TypedDict, total=False): + Encrypted: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Iops: Optional[Integer] + Throughput: Optional[Integer] + KmsKeyId: Optional[KmsKeyId] + SnapshotId: Optional[SnapshotId] + VolumeSize: Optional[Integer] + VolumeType: Optional[VolumeType] + + +class BlockDeviceMappingResponse(TypedDict, total=False): + DeviceName: Optional[String] + VirtualName: Optional[String] + Ebs: Optional[EbsBlockDeviceResponse] + NoDevice: Optional[String] + + +BlockDeviceMappingResponseList = List[BlockDeviceMappingResponse] + + +class BlockPublicAccessStates(TypedDict, total=False): + InternetGatewayBlockMode: Optional[BlockPublicAccessMode] + + +BootModeTypeList = List[BootModeType] +BoxedLong = int +BundleIdStringList = List[BundleId] + + +class S3Storage(TypedDict, total=False): + AWSAccessKeyId: Optional[String] + Bucket: Optional[String] + Prefix: Optional[String] + UploadPolicy: Optional[Blob] + UploadPolicySignature: Optional[S3StorageUploadPolicySignature] + + +class Storage(TypedDict, total=False): + S3: Optional[S3Storage] + + +class BundleInstanceRequest(ServiceRequest): + InstanceId: InstanceId + Storage: Storage + DryRun: Optional[Boolean] + + +class BundleTaskError(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +class BundleTask(TypedDict, total=False): + InstanceId: Optional[String] + BundleId: Optional[String] + State: Optional[BundleTaskState] + StartTime: Optional[DateTime] + UpdateTime: Optional[DateTime] + Storage: Optional[Storage] + Progress: Optional[String] + BundleTaskError: Optional[BundleTaskError] + + +class BundleInstanceResult(TypedDict, total=False): + BundleTask: Optional[BundleTask] + + +BundleTaskList = List[BundleTask] + + +class Byoasn(TypedDict, total=False): + Asn: Optional[String] + IpamId: Optional[IpamId] + StatusMessage: Optional[String] + State: Optional[AsnState] + + +ByoasnSet = List[Byoasn] +ByoipCidrSet = List[ByoipCidr] + + +class CancelBundleTaskRequest(ServiceRequest): + BundleId: BundleId + DryRun: Optional[Boolean] + + +class CancelBundleTaskResult(TypedDict, total=False): + BundleTask: Optional[BundleTask] + + +class CancelCapacityReservationFleetError(TypedDict, total=False): + Code: Optional[CancelCapacityReservationFleetErrorCode] + Message: Optional[CancelCapacityReservationFleetErrorMessage] + + +CapacityReservationFleetIdSet = List[CapacityReservationFleetId] + + +class CancelCapacityReservationFleetsRequest(ServiceRequest): + DryRun: Optional[Boolean] + CapacityReservationFleetIds: CapacityReservationFleetIdSet + + +class FailedCapacityReservationFleetCancellationResult(TypedDict, total=False): + CapacityReservationFleetId: Optional[CapacityReservationFleetId] + CancelCapacityReservationFleetError: Optional[CancelCapacityReservationFleetError] + + +FailedCapacityReservationFleetCancellationResultSet = List[ + FailedCapacityReservationFleetCancellationResult +] + + +class CapacityReservationFleetCancellationState(TypedDict, total=False): + CurrentFleetState: Optional[CapacityReservationFleetState] + PreviousFleetState: Optional[CapacityReservationFleetState] + CapacityReservationFleetId: Optional[CapacityReservationFleetId] + + +CapacityReservationFleetCancellationStateSet = List[CapacityReservationFleetCancellationState] + + +class CancelCapacityReservationFleetsResult(TypedDict, total=False): + SuccessfulFleetCancellations: Optional[CapacityReservationFleetCancellationStateSet] + FailedFleetCancellations: Optional[FailedCapacityReservationFleetCancellationResultSet] + + +class CancelCapacityReservationRequest(ServiceRequest): + CapacityReservationId: CapacityReservationId + DryRun: Optional[Boolean] + + +class CancelCapacityReservationResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class CancelConversionRequest(ServiceRequest): + DryRun: Optional[Boolean] + ConversionTaskId: ConversionTaskId + ReasonMessage: Optional[String] + + +class CancelDeclarativePoliciesReportRequest(ServiceRequest): + DryRun: Optional[Boolean] + ReportId: DeclarativePoliciesReportId + + +class CancelDeclarativePoliciesReportResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class CancelExportTaskRequest(ServiceRequest): + ExportTaskId: ExportVmTaskId + + +class CancelImageLaunchPermissionRequest(ServiceRequest): + ImageId: ImageId + DryRun: Optional[Boolean] + + +class CancelImageLaunchPermissionResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class CancelImportTaskRequest(ServiceRequest): + CancelReason: Optional[String] + DryRun: Optional[Boolean] + ImportTaskId: Optional[ImportTaskId] + + +class CancelImportTaskResult(TypedDict, total=False): + ImportTaskId: Optional[String] + PreviousState: Optional[String] + State: Optional[String] + + +class CancelReservedInstancesListingRequest(ServiceRequest): + ReservedInstancesListingId: ReservedInstancesListingId + + +Long = int + + +class PriceSchedule(TypedDict, total=False): + Active: Optional[Boolean] + CurrencyCode: Optional[CurrencyCodeValues] + Price: Optional[Double] + Term: Optional[Long] + + +PriceScheduleList = List[PriceSchedule] + + +class InstanceCount(TypedDict, total=False): + InstanceCount: Optional[Integer] + State: Optional[ListingState] + + +InstanceCountList = List[InstanceCount] + + +class ReservedInstancesListing(TypedDict, total=False): + ClientToken: Optional[String] + CreateDate: Optional[DateTime] + InstanceCounts: Optional[InstanceCountList] + PriceSchedules: Optional[PriceScheduleList] + ReservedInstancesId: Optional[String] + ReservedInstancesListingId: Optional[String] + Status: Optional[ListingStatus] + StatusMessage: Optional[String] + Tags: Optional[TagList] + UpdateDate: Optional[DateTime] + + +ReservedInstancesListingList = List[ReservedInstancesListing] + + +class CancelReservedInstancesListingResult(TypedDict, total=False): + ReservedInstancesListings: Optional[ReservedInstancesListingList] + + +class CancelSpotFleetRequestsError(TypedDict, total=False): + Code: Optional[CancelBatchErrorCode] + Message: Optional[String] + + +class CancelSpotFleetRequestsErrorItem(TypedDict, total=False): + Error: Optional[CancelSpotFleetRequestsError] + SpotFleetRequestId: Optional[String] + + +CancelSpotFleetRequestsErrorSet = List[CancelSpotFleetRequestsErrorItem] +SpotFleetRequestIdList = List[SpotFleetRequestId] + + +class CancelSpotFleetRequestsRequest(ServiceRequest): + DryRun: Optional[Boolean] + SpotFleetRequestIds: SpotFleetRequestIdList + TerminateInstances: Boolean + + +class CancelSpotFleetRequestsSuccessItem(TypedDict, total=False): + CurrentSpotFleetRequestState: Optional[BatchState] + PreviousSpotFleetRequestState: Optional[BatchState] + SpotFleetRequestId: Optional[String] + + +CancelSpotFleetRequestsSuccessSet = List[CancelSpotFleetRequestsSuccessItem] + + +class CancelSpotFleetRequestsResponse(TypedDict, total=False): + SuccessfulFleetRequests: Optional[CancelSpotFleetRequestsSuccessSet] + UnsuccessfulFleetRequests: Optional[CancelSpotFleetRequestsErrorSet] + + +SpotInstanceRequestIdList = List[SpotInstanceRequestId] + + +class CancelSpotInstanceRequestsRequest(ServiceRequest): + DryRun: Optional[Boolean] + SpotInstanceRequestIds: SpotInstanceRequestIdList + + +class CancelledSpotInstanceRequest(TypedDict, total=False): + SpotInstanceRequestId: Optional[String] + State: Optional[CancelSpotInstanceRequestState] + + +CancelledSpotInstanceRequestList = List[CancelledSpotInstanceRequest] + + +class CancelSpotInstanceRequestsResult(TypedDict, total=False): + CancelledSpotInstanceRequests: Optional[CancelledSpotInstanceRequestList] + + +class CapacityAllocation(TypedDict, total=False): + AllocationType: Optional[AllocationType] + Count: Optional[Integer] + + +CapacityAllocations = List[CapacityAllocation] +CapacityReservationIdSet = List[CapacityReservationId] + + +class CapacityBlock(TypedDict, total=False): + CapacityBlockId: Optional[CapacityBlockId] + UltraserverType: Optional[String] + AvailabilityZone: Optional[String] + AvailabilityZoneId: Optional[String] + CapacityReservationIds: Optional[CapacityReservationIdSet] + StartDate: Optional[MillisecondDateTime] + EndDate: Optional[MillisecondDateTime] + CreateDate: Optional[MillisecondDateTime] + State: Optional[CapacityBlockResourceState] + Tags: Optional[TagList] + + +class CapacityBlockExtension(TypedDict, total=False): + CapacityReservationId: Optional[CapacityReservationId] + InstanceType: Optional[String] + InstanceCount: Optional[Integer] + AvailabilityZone: Optional[AvailabilityZoneName] + AvailabilityZoneId: Optional[AvailabilityZoneId] + CapacityBlockExtensionOfferingId: Optional[OfferingId] + CapacityBlockExtensionDurationHours: Optional[Integer] + CapacityBlockExtensionStatus: Optional[CapacityBlockExtensionStatus] + CapacityBlockExtensionPurchaseDate: Optional[MillisecondDateTime] + CapacityBlockExtensionStartDate: Optional[MillisecondDateTime] + CapacityBlockExtensionEndDate: Optional[MillisecondDateTime] + UpfrontFee: Optional[String] + CurrencyCode: Optional[String] + + +class CapacityBlockExtensionOffering(TypedDict, total=False): + CapacityBlockExtensionOfferingId: Optional[OfferingId] + InstanceType: Optional[String] + InstanceCount: Optional[Integer] + AvailabilityZone: Optional[AvailabilityZoneName] + AvailabilityZoneId: Optional[AvailabilityZoneId] + StartDate: Optional[MillisecondDateTime] + CapacityBlockExtensionStartDate: Optional[MillisecondDateTime] + CapacityBlockExtensionEndDate: Optional[MillisecondDateTime] + CapacityBlockExtensionDurationHours: Optional[Integer] + UpfrontFee: Optional[String] + CurrencyCode: Optional[String] + Tenancy: Optional[CapacityReservationTenancy] + + +CapacityBlockExtensionOfferingSet = List[CapacityBlockExtensionOffering] +CapacityBlockExtensionSet = List[CapacityBlockExtension] +CapacityBlockIds = List[CapacityBlockId] + + +class CapacityBlockOffering(TypedDict, total=False): + CapacityBlockOfferingId: Optional[OfferingId] + InstanceType: Optional[String] + AvailabilityZone: Optional[String] + InstanceCount: Optional[Integer] + StartDate: Optional[MillisecondDateTime] + EndDate: Optional[MillisecondDateTime] + CapacityBlockDurationHours: Optional[Integer] + UpfrontFee: Optional[String] + CurrencyCode: Optional[String] + Tenancy: Optional[CapacityReservationTenancy] + UltraserverType: Optional[String] + UltraserverCount: Optional[BoxedInteger] + CapacityBlockDurationMinutes: Optional[Integer] + + +CapacityBlockOfferingSet = List[CapacityBlockOffering] +CapacityBlockSet = List[CapacityBlock] + + +class CapacityReservationStatus(TypedDict, total=False): + CapacityReservationId: Optional[CapacityReservationId] + TotalCapacity: Optional[Integer] + TotalAvailableCapacity: Optional[Integer] + TotalUnavailableCapacity: Optional[Integer] + + +CapacityReservationStatusSet = List[CapacityReservationStatus] + + +class CapacityBlockStatus(TypedDict, total=False): + CapacityBlockId: Optional[CapacityBlockId] + InterconnectStatus: Optional[CapacityBlockInterconnectStatus] + TotalCapacity: Optional[Integer] + TotalAvailableCapacity: Optional[Integer] + TotalUnavailableCapacity: Optional[Integer] + CapacityReservationStatuses: Optional[CapacityReservationStatusSet] + + +CapacityBlockStatusSet = List[CapacityBlockStatus] + + +class CapacityReservationCommitmentInfo(TypedDict, total=False): + CommittedInstanceCount: Optional[Integer] + CommitmentEndDate: Optional[MillisecondDateTime] + + +class CapacityReservation(TypedDict, total=False): + CapacityReservationId: Optional[String] + OwnerId: Optional[String] + CapacityReservationArn: Optional[String] + AvailabilityZoneId: Optional[String] + InstanceType: Optional[String] + InstancePlatform: Optional[CapacityReservationInstancePlatform] + AvailabilityZone: Optional[String] + Tenancy: Optional[CapacityReservationTenancy] + TotalInstanceCount: Optional[Integer] + AvailableInstanceCount: Optional[Integer] + EbsOptimized: Optional[Boolean] + EphemeralStorage: Optional[Boolean] + State: Optional[CapacityReservationState] + StartDate: Optional[MillisecondDateTime] + EndDate: Optional[DateTime] + EndDateType: Optional[EndDateType] + InstanceMatchCriteria: Optional[InstanceMatchCriteria] + CreateDate: Optional[DateTime] + Tags: Optional[TagList] + OutpostArn: Optional[OutpostArn] + CapacityReservationFleetId: Optional[String] + PlacementGroupArn: Optional[PlacementGroupArn] + CapacityAllocations: Optional[CapacityAllocations] + ReservationType: Optional[CapacityReservationType] + UnusedReservationBillingOwnerId: Optional[AccountID] + CommitmentInfo: Optional[CapacityReservationCommitmentInfo] + DeliveryPreference: Optional[CapacityReservationDeliveryPreference] + CapacityBlockId: Optional[CapacityBlockId] + + +class CapacityReservationInfo(TypedDict, total=False): + InstanceType: Optional[String] + AvailabilityZone: Optional[AvailabilityZoneName] + Tenancy: Optional[CapacityReservationTenancy] + AvailabilityZoneId: Optional[AvailabilityZoneId] + + +class CapacityReservationBillingRequest(TypedDict, total=False): + CapacityReservationId: Optional[String] + RequestedBy: Optional[String] + UnusedReservationBillingOwnerId: Optional[AccountID] + LastUpdateTime: Optional[MillisecondDateTime] + Status: Optional[CapacityReservationBillingRequestStatus] + StatusMessage: Optional[String] + CapacityReservationInfo: Optional[CapacityReservationInfo] + + +CapacityReservationBillingRequestSet = List[CapacityReservationBillingRequest] +CapacityReservationCommitmentDuration = int + + +class FleetCapacityReservation(TypedDict, total=False): + CapacityReservationId: Optional[CapacityReservationId] + AvailabilityZoneId: Optional[String] + InstanceType: Optional[InstanceType] + InstancePlatform: Optional[CapacityReservationInstancePlatform] + AvailabilityZone: Optional[String] + TotalInstanceCount: Optional[Integer] + FulfilledCapacity: Optional[Double] + EbsOptimized: Optional[Boolean] + CreateDate: Optional[MillisecondDateTime] + Weight: Optional[DoubleWithConstraints] + Priority: Optional[IntegerWithConstraints] + + +FleetCapacityReservationSet = List[FleetCapacityReservation] + + +class CapacityReservationFleet(TypedDict, total=False): + CapacityReservationFleetId: Optional[CapacityReservationFleetId] + CapacityReservationFleetArn: Optional[String] + State: Optional[CapacityReservationFleetState] + TotalTargetCapacity: Optional[Integer] + TotalFulfilledCapacity: Optional[Double] + Tenancy: Optional[FleetCapacityReservationTenancy] + EndDate: Optional[MillisecondDateTime] + CreateTime: Optional[MillisecondDateTime] + InstanceMatchCriteria: Optional[FleetInstanceMatchCriteria] + AllocationStrategy: Optional[String] + InstanceTypeSpecifications: Optional[FleetCapacityReservationSet] + Tags: Optional[TagList] + + +CapacityReservationFleetSet = List[CapacityReservationFleet] + + +class CapacityReservationGroup(TypedDict, total=False): + GroupArn: Optional[String] + OwnerId: Optional[String] + + +CapacityReservationGroupSet = List[CapacityReservationGroup] + + +class CapacityReservationOptions(TypedDict, total=False): + UsageStrategy: Optional[FleetCapacityReservationUsageStrategy] + + +class CapacityReservationOptionsRequest(TypedDict, total=False): + UsageStrategy: Optional[FleetCapacityReservationUsageStrategy] + + +CapacityReservationSet = List[CapacityReservation] + + +class CapacityReservationTarget(TypedDict, total=False): + CapacityReservationId: Optional[CapacityReservationId] + CapacityReservationResourceGroupArn: Optional[String] + + +class CapacityReservationSpecification(TypedDict, total=False): + CapacityReservationPreference: Optional[CapacityReservationPreference] + CapacityReservationTarget: Optional[CapacityReservationTarget] + + +class CapacityReservationTargetResponse(TypedDict, total=False): + CapacityReservationId: Optional[String] + CapacityReservationResourceGroupArn: Optional[String] + + +class CapacityReservationSpecificationResponse(TypedDict, total=False): + CapacityReservationPreference: Optional[CapacityReservationPreference] + CapacityReservationTarget: Optional[CapacityReservationTargetResponse] + + +class CarrierGateway(TypedDict, total=False): + CarrierGatewayId: Optional[CarrierGatewayId] + VpcId: Optional[VpcId] + State: Optional[CarrierGatewayState] + OwnerId: Optional[String] + Tags: Optional[TagList] + + +CarrierGatewayIdSet = List[CarrierGatewayId] +CarrierGatewaySet = List[CarrierGateway] + + +class CertificateAuthentication(TypedDict, total=False): + ClientRootCertificateChain: Optional[String] + + +class CertificateAuthenticationRequest(TypedDict, total=False): + ClientRootCertificateChainArn: Optional[String] + + +class CidrAuthorizationContext(TypedDict, total=False): + Message: String + Signature: String + + +class ClassicLinkDnsSupport(TypedDict, total=False): + ClassicLinkDnsSupported: Optional[Boolean] + VpcId: Optional[String] + + +ClassicLinkDnsSupportList = List[ClassicLinkDnsSupport] + + +class GroupIdentifier(TypedDict, total=False): + GroupId: Optional[String] + GroupName: Optional[String] + + +GroupIdentifierList = List[GroupIdentifier] + + +class ClassicLinkInstance(TypedDict, total=False): + Groups: Optional[GroupIdentifierList] + InstanceId: Optional[String] + Tags: Optional[TagList] + VpcId: Optional[String] + + +ClassicLinkInstanceList = List[ClassicLinkInstance] + + +class ClassicLoadBalancer(TypedDict, total=False): + Name: Optional[String] + + +ClassicLoadBalancers = List[ClassicLoadBalancer] + + +class ClassicLoadBalancersConfig(TypedDict, total=False): + ClassicLoadBalancers: Optional[ClassicLoadBalancers] + + +class ClientCertificateRevocationListStatus(TypedDict, total=False): + Code: Optional[ClientCertificateRevocationListStatusCode] + Message: Optional[String] + + +class ClientConnectOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + LambdaFunctionArn: Optional[String] + + +class ClientVpnEndpointAttributeStatus(TypedDict, total=False): + Code: Optional[ClientVpnEndpointAttributeStatusCode] + Message: Optional[String] + + +class ClientConnectResponseOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + LambdaFunctionArn: Optional[String] + Status: Optional[ClientVpnEndpointAttributeStatus] + + +class ClientData(TypedDict, total=False): + Comment: Optional[String] + UploadEnd: Optional[DateTime] + UploadSize: Optional[Double] + UploadStart: Optional[DateTime] + + +class ClientLoginBannerOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + BannerText: Optional[String] + + +class ClientLoginBannerResponseOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + BannerText: Optional[String] + + +class ClientRouteEnforcementOptions(TypedDict, total=False): + Enforced: Optional[Boolean] + + +class ClientRouteEnforcementResponseOptions(TypedDict, total=False): + Enforced: Optional[Boolean] + + +class FederatedAuthentication(TypedDict, total=False): + SamlProviderArn: Optional[String] + SelfServiceSamlProviderArn: Optional[String] + + +class DirectoryServiceAuthentication(TypedDict, total=False): + DirectoryId: Optional[String] + + +class ClientVpnAuthentication(TypedDict, total=False): + Type: Optional[ClientVpnAuthenticationType] + ActiveDirectory: Optional[DirectoryServiceAuthentication] + MutualAuthentication: Optional[CertificateAuthentication] + FederatedAuthentication: Optional[FederatedAuthentication] + + +ClientVpnAuthenticationList = List[ClientVpnAuthentication] + + +class FederatedAuthenticationRequest(TypedDict, total=False): + SAMLProviderArn: Optional[String] + SelfServiceSAMLProviderArn: Optional[String] + + +class DirectoryServiceAuthenticationRequest(TypedDict, total=False): + DirectoryId: Optional[String] + + +class ClientVpnAuthenticationRequest(TypedDict, total=False): + Type: Optional[ClientVpnAuthenticationType] + ActiveDirectory: Optional[DirectoryServiceAuthenticationRequest] + MutualAuthentication: Optional[CertificateAuthenticationRequest] + FederatedAuthentication: Optional[FederatedAuthenticationRequest] + + +ClientVpnAuthenticationRequestList = List[ClientVpnAuthenticationRequest] + + +class ClientVpnConnectionStatus(TypedDict, total=False): + Code: Optional[ClientVpnConnectionStatusCode] + Message: Optional[String] + + +class ClientVpnConnection(TypedDict, total=False): + ClientVpnEndpointId: Optional[String] + Timestamp: Optional[String] + ConnectionId: Optional[String] + Username: Optional[String] + ConnectionEstablishedTime: Optional[String] + IngressBytes: Optional[String] + EgressBytes: Optional[String] + IngressPackets: Optional[String] + EgressPackets: Optional[String] + ClientIp: Optional[String] + CommonName: Optional[String] + Status: Optional[ClientVpnConnectionStatus] + ConnectionEndTime: Optional[String] + PostureComplianceStatuses: Optional[ValueStringList] + + +ClientVpnConnectionSet = List[ClientVpnConnection] + + +class ConnectionLogResponseOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + CloudwatchLogGroup: Optional[String] + CloudwatchLogStream: Optional[String] + + +class ClientVpnEndpointStatus(TypedDict, total=False): + Code: Optional[ClientVpnEndpointStatusCode] + Message: Optional[String] + + +class ClientVpnEndpoint(TypedDict, total=False): + ClientVpnEndpointId: Optional[String] + Description: Optional[String] + Status: Optional[ClientVpnEndpointStatus] + CreationTime: Optional[String] + DeletionTime: Optional[String] + DnsName: Optional[String] + ClientCidrBlock: Optional[String] + DnsServers: Optional[ValueStringList] + SplitTunnel: Optional[Boolean] + VpnProtocol: Optional[VpnProtocol] + TransportProtocol: Optional[TransportProtocol] + VpnPort: Optional[Integer] + AssociatedTargetNetworks: Optional[AssociatedTargetNetworkSet] + ServerCertificateArn: Optional[String] + AuthenticationOptions: Optional[ClientVpnAuthenticationList] + ConnectionLogOptions: Optional[ConnectionLogResponseOptions] + Tags: Optional[TagList] + SecurityGroupIds: Optional[ClientVpnSecurityGroupIdSet] + VpcId: Optional[VpcId] + SelfServicePortalUrl: Optional[String] + ClientConnectOptions: Optional[ClientConnectResponseOptions] + SessionTimeoutHours: Optional[Integer] + ClientLoginBannerOptions: Optional[ClientLoginBannerResponseOptions] + ClientRouteEnforcementOptions: Optional[ClientRouteEnforcementResponseOptions] + DisconnectOnSessionTimeout: Optional[Boolean] + + +ClientVpnEndpointIdList = List[ClientVpnEndpointId] + + +class ClientVpnRouteStatus(TypedDict, total=False): + Code: Optional[ClientVpnRouteStatusCode] + Message: Optional[String] + + +class ClientVpnRoute(TypedDict, total=False): + ClientVpnEndpointId: Optional[String] + DestinationCidr: Optional[String] + TargetSubnet: Optional[String] + Type: Optional[String] + Origin: Optional[String] + Status: Optional[ClientVpnRouteStatus] + Description: Optional[String] + + +ClientVpnRouteSet = List[ClientVpnRoute] + + +class CloudWatchLogOptions(TypedDict, total=False): + LogEnabled: Optional[Boolean] + LogGroupArn: Optional[String] + LogOutputFormat: Optional[String] + + +class CloudWatchLogOptionsSpecification(TypedDict, total=False): + LogEnabled: Optional[Boolean] + LogGroupArn: Optional[CloudWatchLogGroupArn] + LogOutputFormat: Optional[String] + + +class CoipAddressUsage(TypedDict, total=False): + AllocationId: Optional[String] + AwsAccountId: Optional[String] + AwsService: Optional[String] + CoIp: Optional[String] + + +CoipAddressUsageSet = List[CoipAddressUsage] + + +class CoipCidr(TypedDict, total=False): + Cidr: Optional[String] + CoipPoolId: Optional[Ipv4PoolCoipId] + LocalGatewayRouteTableId: Optional[String] + + +class CoipPool(TypedDict, total=False): + PoolId: Optional[Ipv4PoolCoipId] + PoolCidrs: Optional[ValueStringList] + LocalGatewayRouteTableId: Optional[LocalGatewayRoutetableId] + Tags: Optional[TagList] + PoolArn: Optional[ResourceArn] + + +CoipPoolIdSet = List[Ipv4PoolCoipId] +CoipPoolSet = List[CoipPool] + + +class ConfirmProductInstanceRequest(ServiceRequest): + InstanceId: InstanceId + ProductCode: String + DryRun: Optional[Boolean] + + +class ConfirmProductInstanceResult(TypedDict, total=False): + Return: Optional[Boolean] + OwnerId: Optional[String] + + +class ConnectionLogOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + CloudwatchLogGroup: Optional[String] + CloudwatchLogStream: Optional[String] + + +class ConnectionNotification(TypedDict, total=False): + ConnectionNotificationId: Optional[String] + ServiceId: Optional[String] + VpcEndpointId: Optional[String] + ConnectionNotificationType: Optional[ConnectionNotificationType] + ConnectionNotificationArn: Optional[String] + ConnectionEvents: Optional[ValueStringList] + ConnectionNotificationState: Optional[ConnectionNotificationState] + ServiceRegion: Optional[String] + + +ConnectionNotificationIdsList = List[ConnectionNotificationId] +ConnectionNotificationSet = List[ConnectionNotification] + + +class ConnectionTrackingConfiguration(TypedDict, total=False): + TcpEstablishedTimeout: Optional[Integer] + UdpStreamTimeout: Optional[Integer] + UdpTimeout: Optional[Integer] + + +class ConnectionTrackingSpecification(TypedDict, total=False): + TcpEstablishedTimeout: Optional[Integer] + UdpTimeout: Optional[Integer] + UdpStreamTimeout: Optional[Integer] + + +class ConnectionTrackingSpecificationRequest(TypedDict, total=False): + TcpEstablishedTimeout: Optional[Integer] + UdpStreamTimeout: Optional[Integer] + UdpTimeout: Optional[Integer] + + +class ConnectionTrackingSpecificationResponse(TypedDict, total=False): + TcpEstablishedTimeout: Optional[Integer] + UdpStreamTimeout: Optional[Integer] + UdpTimeout: Optional[Integer] + + +ConversionIdStringList = List[ConversionTaskId] + + +class DiskImageVolumeDescription(TypedDict, total=False): + Id: Optional[String] + Size: Optional[Long] + + +class DiskImageDescription(TypedDict, total=False): + Checksum: Optional[String] + Format: Optional[DiskImageFormat] + ImportManifestUrl: Optional[ImportManifestUrl] + Size: Optional[Long] + + +class ImportVolumeTaskDetails(TypedDict, total=False): + AvailabilityZone: Optional[String] + BytesConverted: Optional[Long] + Description: Optional[String] + Image: Optional[DiskImageDescription] + Volume: Optional[DiskImageVolumeDescription] + + +class ImportInstanceVolumeDetailItem(TypedDict, total=False): + AvailabilityZone: Optional[String] + BytesConverted: Optional[Long] + Description: Optional[String] + Image: Optional[DiskImageDescription] + Status: Optional[String] + StatusMessage: Optional[String] + Volume: Optional[DiskImageVolumeDescription] + + +ImportInstanceVolumeDetailSet = List[ImportInstanceVolumeDetailItem] + + +class ImportInstanceTaskDetails(TypedDict, total=False): + Description: Optional[String] + InstanceId: Optional[String] + Platform: Optional[PlatformValues] + Volumes: Optional[ImportInstanceVolumeDetailSet] + + +class ConversionTask(TypedDict, total=False): + ConversionTaskId: Optional[String] + ExpirationTime: Optional[String] + ImportInstance: Optional[ImportInstanceTaskDetails] + ImportVolume: Optional[ImportVolumeTaskDetails] + State: Optional[ConversionTaskState] + StatusMessage: Optional[String] + Tags: Optional[TagList] + + +class CopyFpgaImageRequest(ServiceRequest): + DryRun: Optional[Boolean] + SourceFpgaImageId: String + Description: Optional[String] + Name: Optional[String] + SourceRegion: String + ClientToken: Optional[String] + + +class CopyFpgaImageResult(TypedDict, total=False): + FpgaImageId: Optional[String] + + +class CopyImageRequest(ServiceRequest): + ClientToken: Optional[String] + Description: Optional[String] + Encrypted: Optional[Boolean] + KmsKeyId: Optional[KmsKeyId] + Name: String + SourceImageId: String + SourceRegion: String + DestinationOutpostArn: Optional[String] + CopyImageTags: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + SnapshotCopyCompletionDurationMinutes: Optional[Long] + DryRun: Optional[Boolean] + + +class CopyImageResult(TypedDict, total=False): + ImageId: Optional[String] + + +class CopySnapshotRequest(ServiceRequest): + Description: Optional[String] + DestinationOutpostArn: Optional[String] + DestinationRegion: Optional[String] + Encrypted: Optional[Boolean] + KmsKeyId: Optional[KmsKeyId] + PresignedUrl: Optional[CopySnapshotRequestPSU] + SourceRegion: String + SourceSnapshotId: String + TagSpecifications: Optional[TagSpecificationList] + CompletionDurationMinutes: Optional[SnapshotCompletionDurationMinutesRequest] + DryRun: Optional[Boolean] + + +class CopySnapshotResult(TypedDict, total=False): + Tags: Optional[TagList] + SnapshotId: Optional[String] + + +CoreCountList = List[CoreCount] +CpuManufacturerSet = List[CpuManufacturer] + + +class CpuOptions(TypedDict, total=False): + CoreCount: Optional[Integer] + ThreadsPerCore: Optional[Integer] + AmdSevSnp: Optional[AmdSevSnpSpecification] + + +class CpuOptionsRequest(TypedDict, total=False): + CoreCount: Optional[Integer] + ThreadsPerCore: Optional[Integer] + AmdSevSnp: Optional[AmdSevSnpSpecification] + + +class CreateCapacityReservationBySplittingRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + SourceCapacityReservationId: CapacityReservationId + InstanceCount: Integer + TagSpecifications: Optional[TagSpecificationList] + + +class CreateCapacityReservationBySplittingResult(TypedDict, total=False): + SourceCapacityReservation: Optional[CapacityReservation] + DestinationCapacityReservation: Optional[CapacityReservation] + InstanceCount: Optional[Integer] + + +class ReservationFleetInstanceSpecification(TypedDict, total=False): + InstanceType: Optional[InstanceType] + InstancePlatform: Optional[CapacityReservationInstancePlatform] + Weight: Optional[DoubleWithConstraints] + AvailabilityZone: Optional[String] + AvailabilityZoneId: Optional[String] + EbsOptimized: Optional[Boolean] + Priority: Optional[IntegerWithConstraints] + + +ReservationFleetInstanceSpecificationList = List[ReservationFleetInstanceSpecification] + + +class CreateCapacityReservationFleetRequest(ServiceRequest): + AllocationStrategy: Optional[String] + ClientToken: Optional[String] + InstanceTypeSpecifications: ReservationFleetInstanceSpecificationList + Tenancy: Optional[FleetCapacityReservationTenancy] + TotalTargetCapacity: Integer + EndDate: Optional[MillisecondDateTime] + InstanceMatchCriteria: Optional[FleetInstanceMatchCriteria] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class CreateCapacityReservationFleetResult(TypedDict, total=False): + CapacityReservationFleetId: Optional[CapacityReservationFleetId] + State: Optional[CapacityReservationFleetState] + TotalTargetCapacity: Optional[Integer] + TotalFulfilledCapacity: Optional[Double] + InstanceMatchCriteria: Optional[FleetInstanceMatchCriteria] + AllocationStrategy: Optional[String] + CreateTime: Optional[MillisecondDateTime] + EndDate: Optional[MillisecondDateTime] + Tenancy: Optional[FleetCapacityReservationTenancy] + FleetCapacityReservations: Optional[FleetCapacityReservationSet] + Tags: Optional[TagList] + + +class CreateCapacityReservationRequest(ServiceRequest): + ClientToken: Optional[String] + InstanceType: String + InstancePlatform: CapacityReservationInstancePlatform + AvailabilityZone: Optional[AvailabilityZoneName] + AvailabilityZoneId: Optional[AvailabilityZoneId] + Tenancy: Optional[CapacityReservationTenancy] + InstanceCount: Integer + EbsOptimized: Optional[Boolean] + EphemeralStorage: Optional[Boolean] + EndDate: Optional[DateTime] + EndDateType: Optional[EndDateType] + InstanceMatchCriteria: Optional[InstanceMatchCriteria] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + OutpostArn: Optional[OutpostArn] + PlacementGroupArn: Optional[PlacementGroupArn] + StartDate: Optional[MillisecondDateTime] + CommitmentDuration: Optional[CapacityReservationCommitmentDuration] + DeliveryPreference: Optional[CapacityReservationDeliveryPreference] + + +class CreateCapacityReservationResult(TypedDict, total=False): + CapacityReservation: Optional[CapacityReservation] + + +class CreateCarrierGatewayRequest(ServiceRequest): + VpcId: VpcId + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + + +class CreateCarrierGatewayResult(TypedDict, total=False): + CarrierGateway: Optional[CarrierGateway] + + +class CreateClientVpnEndpointRequest(ServiceRequest): + ClientCidrBlock: String + ServerCertificateArn: String + AuthenticationOptions: ClientVpnAuthenticationRequestList + ConnectionLogOptions: ConnectionLogOptions + DnsServers: Optional[ValueStringList] + TransportProtocol: Optional[TransportProtocol] + VpnPort: Optional[Integer] + Description: Optional[String] + SplitTunnel: Optional[Boolean] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + SecurityGroupIds: Optional[ClientVpnSecurityGroupIdSet] + VpcId: Optional[VpcId] + SelfServicePortal: Optional[SelfServicePortal] + ClientConnectOptions: Optional[ClientConnectOptions] + SessionTimeoutHours: Optional[Integer] + ClientLoginBannerOptions: Optional[ClientLoginBannerOptions] + ClientRouteEnforcementOptions: Optional[ClientRouteEnforcementOptions] + DisconnectOnSessionTimeout: Optional[Boolean] + + +class CreateClientVpnEndpointResult(TypedDict, total=False): + ClientVpnEndpointId: Optional[String] + Status: Optional[ClientVpnEndpointStatus] + DnsName: Optional[String] + + +class CreateClientVpnRouteRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + DestinationCidrBlock: String + TargetVpcSubnetId: SubnetId + Description: Optional[String] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class CreateClientVpnRouteResult(TypedDict, total=False): + Status: Optional[ClientVpnRouteStatus] + + +class CreateCoipCidrRequest(ServiceRequest): + Cidr: String + CoipPoolId: Ipv4PoolCoipId + DryRun: Optional[Boolean] + + +class CreateCoipCidrResult(TypedDict, total=False): + CoipCidr: Optional[CoipCidr] + + +class CreateCoipPoolRequest(ServiceRequest): + LocalGatewayRouteTableId: LocalGatewayRoutetableId + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class CreateCoipPoolResult(TypedDict, total=False): + CoipPool: Optional[CoipPool] + + +class CreateCustomerGatewayRequest(ServiceRequest): + BgpAsn: Optional[Integer] + PublicIp: Optional[String] + CertificateArn: Optional[String] + Type: GatewayType + TagSpecifications: Optional[TagSpecificationList] + DeviceName: Optional[String] + IpAddress: Optional[String] + BgpAsnExtended: Optional[Long] + DryRun: Optional[Boolean] + + +class CustomerGateway(TypedDict, total=False): + CertificateArn: Optional[String] + DeviceName: Optional[String] + Tags: Optional[TagList] + BgpAsnExtended: Optional[String] + CustomerGatewayId: Optional[String] + State: Optional[String] + Type: Optional[String] + IpAddress: Optional[String] + BgpAsn: Optional[String] + + +class CreateCustomerGatewayResult(TypedDict, total=False): + CustomerGateway: Optional[CustomerGateway] + + +class CreateDefaultSubnetRequest(ServiceRequest): + AvailabilityZone: AvailabilityZoneName + DryRun: Optional[Boolean] + Ipv6Native: Optional[Boolean] + + +class PrivateDnsNameOptionsOnLaunch(TypedDict, total=False): + HostnameType: Optional[HostnameType] + EnableResourceNameDnsARecord: Optional[Boolean] + EnableResourceNameDnsAAAARecord: Optional[Boolean] + + +SubnetIpv6CidrBlockAssociationSet = List[SubnetIpv6CidrBlockAssociation] + + +class Subnet(TypedDict, total=False): + AvailabilityZoneId: Optional[String] + EnableLniAtDeviceIndex: Optional[Integer] + MapCustomerOwnedIpOnLaunch: Optional[Boolean] + CustomerOwnedIpv4Pool: Optional[CoipPoolId] + OwnerId: Optional[String] + AssignIpv6AddressOnCreation: Optional[Boolean] + Ipv6CidrBlockAssociationSet: Optional[SubnetIpv6CidrBlockAssociationSet] + Tags: Optional[TagList] + SubnetArn: Optional[String] + OutpostArn: Optional[String] + EnableDns64: Optional[Boolean] + Ipv6Native: Optional[Boolean] + PrivateDnsNameOptionsOnLaunch: Optional[PrivateDnsNameOptionsOnLaunch] + BlockPublicAccessStates: Optional[BlockPublicAccessStates] + Type: Optional[String] + SubnetId: Optional[String] + State: Optional[SubnetState] + VpcId: Optional[String] + CidrBlock: Optional[String] + AvailableIpAddressCount: Optional[Integer] + AvailabilityZone: Optional[String] + DefaultForAz: Optional[Boolean] + MapPublicIpOnLaunch: Optional[Boolean] + + +class CreateDefaultSubnetResult(TypedDict, total=False): + Subnet: Optional[Subnet] + + +class CreateDefaultVpcRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class VpcEncryptionControlExclusion(TypedDict, total=False): + State: Optional[VpcEncryptionControlExclusionState] + StateMessage: Optional[String] + + +class VpcEncryptionControlExclusions(TypedDict, total=False): + InternetGateway: Optional[VpcEncryptionControlExclusion] + EgressOnlyInternetGateway: Optional[VpcEncryptionControlExclusion] + NatGateway: Optional[VpcEncryptionControlExclusion] + VirtualPrivateGateway: Optional[VpcEncryptionControlExclusion] + VpcPeering: Optional[VpcEncryptionControlExclusion] + + +class VpcEncryptionControl(TypedDict, total=False): + VpcId: Optional[VpcId] + VpcEncryptionControlId: Optional[VpcEncryptionControlId] + Mode: Optional[VpcEncryptionControlMode] + State: Optional[VpcEncryptionControlState] + StateMessage: Optional[String] + ResourceExclusions: Optional[VpcEncryptionControlExclusions] + Tags: Optional[TagList] + + +VpcCidrBlockAssociationSet = List[VpcCidrBlockAssociation] +VpcIpv6CidrBlockAssociationSet = List[VpcIpv6CidrBlockAssociation] + + +class Vpc(TypedDict, total=False): + OwnerId: Optional[String] + InstanceTenancy: Optional[Tenancy] + Ipv6CidrBlockAssociationSet: Optional[VpcIpv6CidrBlockAssociationSet] + CidrBlockAssociationSet: Optional[VpcCidrBlockAssociationSet] + IsDefault: Optional[Boolean] + EncryptionControl: Optional[VpcEncryptionControl] + Tags: Optional[TagList] + BlockPublicAccessStates: Optional[BlockPublicAccessStates] + VpcId: Optional[String] + State: Optional[VpcState] + CidrBlock: Optional[String] + DhcpOptionsId: Optional[String] + + +class CreateDefaultVpcResult(TypedDict, total=False): + Vpc: Optional[Vpc] + + +class CreateDelegateMacVolumeOwnershipTaskRequest(ServiceRequest): + ClientToken: Optional[String] + DryRun: Optional[Boolean] + InstanceId: InstanceId + MacCredentials: SensitiveMacCredentials + TagSpecifications: Optional[TagSpecificationList] + + +class MacSystemIntegrityProtectionConfiguration(TypedDict, total=False): + AppleInternal: Optional[MacSystemIntegrityProtectionSettingStatus] + BaseSystem: Optional[MacSystemIntegrityProtectionSettingStatus] + DebuggingRestrictions: Optional[MacSystemIntegrityProtectionSettingStatus] + DTraceRestrictions: Optional[MacSystemIntegrityProtectionSettingStatus] + FilesystemProtections: Optional[MacSystemIntegrityProtectionSettingStatus] + KextSigning: Optional[MacSystemIntegrityProtectionSettingStatus] + NvramProtections: Optional[MacSystemIntegrityProtectionSettingStatus] + Status: Optional[MacSystemIntegrityProtectionSettingStatus] + + +class MacModificationTask(TypedDict, total=False): + InstanceId: Optional[InstanceId] + MacModificationTaskId: Optional[MacModificationTaskId] + MacSystemIntegrityProtectionConfig: Optional[MacSystemIntegrityProtectionConfiguration] + StartTime: Optional[MillisecondDateTime] + Tags: Optional[TagList] + TaskState: Optional[MacModificationTaskState] + TaskType: Optional[MacModificationTaskType] + + +class CreateDelegateMacVolumeOwnershipTaskResult(TypedDict, total=False): + MacModificationTask: Optional[MacModificationTask] + + +class NewDhcpConfiguration(TypedDict, total=False): + Key: Optional[String] + Values: Optional[ValueStringList] + + +NewDhcpConfigurationList = List[NewDhcpConfiguration] + + +class CreateDhcpOptionsRequest(ServiceRequest): + DhcpConfigurations: NewDhcpConfigurationList + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +DhcpConfigurationValueList = List[AttributeValue] + + +class DhcpConfiguration(TypedDict, total=False): + Key: Optional[String] + Values: Optional[DhcpConfigurationValueList] + + +DhcpConfigurationList = List[DhcpConfiguration] + + +class DhcpOptions(TypedDict, total=False): + OwnerId: Optional[String] + Tags: Optional[TagList] + DhcpOptionsId: Optional[String] + DhcpConfigurations: Optional[DhcpConfigurationList] + + +class CreateDhcpOptionsResult(TypedDict, total=False): + DhcpOptions: Optional[DhcpOptions] + + +class CreateEgressOnlyInternetGatewayRequest(ServiceRequest): + ClientToken: Optional[String] + DryRun: Optional[Boolean] + VpcId: VpcId + TagSpecifications: Optional[TagSpecificationList] + + +class InternetGatewayAttachment(TypedDict, total=False): + State: Optional[AttachmentStatus] + VpcId: Optional[String] + + +InternetGatewayAttachmentList = List[InternetGatewayAttachment] + + +class EgressOnlyInternetGateway(TypedDict, total=False): + Attachments: Optional[InternetGatewayAttachmentList] + EgressOnlyInternetGatewayId: Optional[EgressOnlyInternetGatewayId] + Tags: Optional[TagList] + + +class CreateEgressOnlyInternetGatewayResult(TypedDict, total=False): + ClientToken: Optional[String] + EgressOnlyInternetGateway: Optional[EgressOnlyInternetGateway] + + +class NetworkBandwidthGbps(TypedDict, total=False): + Min: Optional[Double] + Max: Optional[Double] + + +class TotalLocalStorageGB(TypedDict, total=False): + Min: Optional[Double] + Max: Optional[Double] + + +LocalStorageTypeSet = List[LocalStorageType] + + +class NetworkInterfaceCount(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +InstanceGenerationSet = List[InstanceGeneration] +ExcludedInstanceTypeSet = List[ExcludedInstanceType] + + +class MemoryGiBPerVCpu(TypedDict, total=False): + Min: Optional[Double] + Max: Optional[Double] + + +class MemoryMiB(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +class VCpuCountRange(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +class InstanceRequirements(TypedDict, total=False): + VCpuCount: Optional[VCpuCountRange] + MemoryMiB: Optional[MemoryMiB] + CpuManufacturers: Optional[CpuManufacturerSet] + MemoryGiBPerVCpu: Optional[MemoryGiBPerVCpu] + ExcludedInstanceTypes: Optional[ExcludedInstanceTypeSet] + InstanceGenerations: Optional[InstanceGenerationSet] + SpotMaxPricePercentageOverLowestPrice: Optional[Integer] + OnDemandMaxPricePercentageOverLowestPrice: Optional[Integer] + BareMetal: Optional[BareMetal] + BurstablePerformance: Optional[BurstablePerformance] + RequireHibernateSupport: Optional[Boolean] + NetworkInterfaceCount: Optional[NetworkInterfaceCount] + LocalStorage: Optional[LocalStorage] + LocalStorageTypes: Optional[LocalStorageTypeSet] + TotalLocalStorageGB: Optional[TotalLocalStorageGB] + BaselineEbsBandwidthMbps: Optional[BaselineEbsBandwidthMbps] + AcceleratorTypes: Optional[AcceleratorTypeSet] + AcceleratorCount: Optional[AcceleratorCount] + AcceleratorManufacturers: Optional[AcceleratorManufacturerSet] + AcceleratorNames: Optional[AcceleratorNameSet] + AcceleratorTotalMemoryMiB: Optional[AcceleratorTotalMemoryMiB] + NetworkBandwidthGbps: Optional[NetworkBandwidthGbps] + AllowedInstanceTypes: Optional[AllowedInstanceTypeSet] + MaxSpotPriceAsPercentageOfOptimalOnDemandPrice: Optional[Integer] + BaselinePerformanceFactors: Optional[BaselinePerformanceFactors] + + +class PlacementResponse(TypedDict, total=False): + GroupName: Optional[PlacementGroupName] + + +class FleetLaunchTemplateOverrides(TypedDict, total=False): + InstanceType: Optional[InstanceType] + MaxPrice: Optional[String] + SubnetId: Optional[String] + AvailabilityZone: Optional[String] + WeightedCapacity: Optional[Double] + Priority: Optional[Double] + Placement: Optional[PlacementResponse] + InstanceRequirements: Optional[InstanceRequirements] + ImageId: Optional[ImageId] + BlockDeviceMappings: Optional[BlockDeviceMappingResponseList] + + +class FleetLaunchTemplateSpecification(TypedDict, total=False): + LaunchTemplateId: Optional[String] + LaunchTemplateName: Optional[LaunchTemplateName] + Version: Optional[String] + + +class LaunchTemplateAndOverridesResponse(TypedDict, total=False): + LaunchTemplateSpecification: Optional[FleetLaunchTemplateSpecification] + Overrides: Optional[FleetLaunchTemplateOverrides] + + +class CreateFleetError(TypedDict, total=False): + LaunchTemplateAndOverrides: Optional[LaunchTemplateAndOverridesResponse] + Lifecycle: Optional[InstanceLifecycle] + ErrorCode: Optional[String] + ErrorMessage: Optional[String] + + +CreateFleetErrorsSet = List[CreateFleetError] +InstanceIdsSet = List[InstanceId] + + +class CreateFleetInstance(TypedDict, total=False): + LaunchTemplateAndOverrides: Optional[LaunchTemplateAndOverridesResponse] + Lifecycle: Optional[InstanceLifecycle] + InstanceIds: Optional[InstanceIdsSet] + InstanceType: Optional[InstanceType] + Platform: Optional[PlatformValues] + + +CreateFleetInstancesSet = List[CreateFleetInstance] + + +class TargetCapacitySpecificationRequest(TypedDict, total=False): + TotalTargetCapacity: Integer + OnDemandTargetCapacity: Optional[Integer] + SpotTargetCapacity: Optional[Integer] + DefaultTargetCapacityType: Optional[DefaultTargetCapacityType] + TargetCapacityUnitType: Optional[TargetCapacityUnitType] + + +class NetworkBandwidthGbpsRequest(TypedDict, total=False): + Min: Optional[Double] + Max: Optional[Double] + + +class TotalLocalStorageGBRequest(TypedDict, total=False): + Min: Optional[Double] + Max: Optional[Double] + + +class NetworkInterfaceCountRequest(TypedDict, total=False): + Min: Optional[Integer] + Max: Optional[Integer] + + +class MemoryGiBPerVCpuRequest(TypedDict, total=False): + Min: Optional[Double] + Max: Optional[Double] + + +class MemoryMiBRequest(TypedDict, total=False): + Min: Integer + Max: Optional[Integer] + + +class VCpuCountRangeRequest(TypedDict, total=False): + Min: Integer + Max: Optional[Integer] + + +class InstanceRequirementsRequest(TypedDict, total=False): + VCpuCount: VCpuCountRangeRequest + MemoryMiB: MemoryMiBRequest + CpuManufacturers: Optional[CpuManufacturerSet] + MemoryGiBPerVCpu: Optional[MemoryGiBPerVCpuRequest] + ExcludedInstanceTypes: Optional[ExcludedInstanceTypeSet] + InstanceGenerations: Optional[InstanceGenerationSet] + SpotMaxPricePercentageOverLowestPrice: Optional[Integer] + OnDemandMaxPricePercentageOverLowestPrice: Optional[Integer] + BareMetal: Optional[BareMetal] + BurstablePerformance: Optional[BurstablePerformance] + RequireHibernateSupport: Optional[Boolean] + NetworkInterfaceCount: Optional[NetworkInterfaceCountRequest] + LocalStorage: Optional[LocalStorage] + LocalStorageTypes: Optional[LocalStorageTypeSet] + TotalLocalStorageGB: Optional[TotalLocalStorageGBRequest] + BaselineEbsBandwidthMbps: Optional[BaselineEbsBandwidthMbpsRequest] + AcceleratorTypes: Optional[AcceleratorTypeSet] + AcceleratorCount: Optional[AcceleratorCountRequest] + AcceleratorManufacturers: Optional[AcceleratorManufacturerSet] + AcceleratorNames: Optional[AcceleratorNameSet] + AcceleratorTotalMemoryMiB: Optional[AcceleratorTotalMemoryMiBRequest] + NetworkBandwidthGbps: Optional[NetworkBandwidthGbpsRequest] + AllowedInstanceTypes: Optional[AllowedInstanceTypeSet] + MaxSpotPriceAsPercentageOfOptimalOnDemandPrice: Optional[Integer] + BaselinePerformanceFactors: Optional[BaselinePerformanceFactorsRequest] + + +class FleetEbsBlockDeviceRequest(TypedDict, total=False): + Encrypted: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Iops: Optional[Integer] + Throughput: Optional[Integer] + KmsKeyId: Optional[KmsKeyId] + SnapshotId: Optional[SnapshotId] + VolumeSize: Optional[Integer] + VolumeType: Optional[VolumeType] + + +class FleetBlockDeviceMappingRequest(TypedDict, total=False): + DeviceName: Optional[String] + VirtualName: Optional[String] + Ebs: Optional[FleetEbsBlockDeviceRequest] + NoDevice: Optional[String] + + +FleetBlockDeviceMappingRequestList = List[FleetBlockDeviceMappingRequest] + + +class Placement(TypedDict, total=False): + Affinity: Optional[String] + GroupName: Optional[PlacementGroupName] + PartitionNumber: Optional[Integer] + HostId: Optional[String] + Tenancy: Optional[Tenancy] + SpreadDomain: Optional[String] + HostResourceGroupArn: Optional[String] + GroupId: Optional[PlacementGroupId] + AvailabilityZone: Optional[String] + + +class FleetLaunchTemplateOverridesRequest(TypedDict, total=False): + InstanceType: Optional[InstanceType] + MaxPrice: Optional[String] + SubnetId: Optional[SubnetId] + AvailabilityZone: Optional[String] + WeightedCapacity: Optional[Double] + Priority: Optional[Double] + Placement: Optional[Placement] + BlockDeviceMappings: Optional[FleetBlockDeviceMappingRequestList] + InstanceRequirements: Optional[InstanceRequirementsRequest] + ImageId: Optional[String] + + +FleetLaunchTemplateOverridesListRequest = List[FleetLaunchTemplateOverridesRequest] + + +class FleetLaunchTemplateSpecificationRequest(TypedDict, total=False): + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[LaunchTemplateName] + Version: Optional[String] + + +class FleetLaunchTemplateConfigRequest(TypedDict, total=False): + LaunchTemplateSpecification: Optional[FleetLaunchTemplateSpecificationRequest] + Overrides: Optional[FleetLaunchTemplateOverridesListRequest] + + +FleetLaunchTemplateConfigListRequest = List[FleetLaunchTemplateConfigRequest] + + +class OnDemandOptionsRequest(TypedDict, total=False): + AllocationStrategy: Optional[FleetOnDemandAllocationStrategy] + CapacityReservationOptions: Optional[CapacityReservationOptionsRequest] + SingleInstanceType: Optional[Boolean] + SingleAvailabilityZone: Optional[Boolean] + MinTargetCapacity: Optional[Integer] + MaxTotalPrice: Optional[String] + + +class FleetSpotCapacityRebalanceRequest(TypedDict, total=False): + ReplacementStrategy: Optional[FleetReplacementStrategy] + TerminationDelay: Optional[Integer] + + +class FleetSpotMaintenanceStrategiesRequest(TypedDict, total=False): + CapacityRebalance: Optional[FleetSpotCapacityRebalanceRequest] + + +class SpotOptionsRequest(TypedDict, total=False): + AllocationStrategy: Optional[SpotAllocationStrategy] + MaintenanceStrategies: Optional[FleetSpotMaintenanceStrategiesRequest] + InstanceInterruptionBehavior: Optional[SpotInstanceInterruptionBehavior] + InstancePoolsToUseCount: Optional[Integer] + SingleInstanceType: Optional[Boolean] + SingleAvailabilityZone: Optional[Boolean] + MinTargetCapacity: Optional[Integer] + MaxTotalPrice: Optional[String] + + +class CreateFleetRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + SpotOptions: Optional[SpotOptionsRequest] + OnDemandOptions: Optional[OnDemandOptionsRequest] + ExcessCapacityTerminationPolicy: Optional[FleetExcessCapacityTerminationPolicy] + LaunchTemplateConfigs: FleetLaunchTemplateConfigListRequest + TargetCapacitySpecification: TargetCapacitySpecificationRequest + TerminateInstancesWithExpiration: Optional[Boolean] + Type: Optional[FleetType] + ValidFrom: Optional[DateTime] + ValidUntil: Optional[DateTime] + ReplaceUnhealthyInstances: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + Context: Optional[String] + + +class CreateFleetResult(TypedDict, total=False): + FleetId: Optional[FleetId] + Errors: Optional[CreateFleetErrorsSet] + Instances: Optional[CreateFleetInstancesSet] + + +class DestinationOptionsRequest(TypedDict, total=False): + FileFormat: Optional[DestinationFileFormat] + HiveCompatiblePartitions: Optional[Boolean] + PerHourPartition: Optional[Boolean] + + +FlowLogResourceIds = List[FlowLogResourceId] + + +class CreateFlowLogsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + DeliverLogsPermissionArn: Optional[String] + DeliverCrossAccountRole: Optional[String] + LogGroupName: Optional[String] + ResourceIds: FlowLogResourceIds + ResourceType: FlowLogsResourceType + TrafficType: Optional[TrafficType] + LogDestinationType: Optional[LogDestinationType] + LogDestination: Optional[String] + LogFormat: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + MaxAggregationInterval: Optional[Integer] + DestinationOptions: Optional[DestinationOptionsRequest] + + +class CreateFlowLogsResult(TypedDict, total=False): + ClientToken: Optional[String] + FlowLogIds: Optional[ValueStringList] + Unsuccessful: Optional[UnsuccessfulItemSet] + + +class StorageLocation(TypedDict, total=False): + Bucket: Optional[String] + Key: Optional[String] + + +class CreateFpgaImageRequest(ServiceRequest): + DryRun: Optional[Boolean] + InputStorageLocation: StorageLocation + LogsStorageLocation: Optional[StorageLocation] + Description: Optional[String] + Name: Optional[String] + ClientToken: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + + +class CreateFpgaImageResult(TypedDict, total=False): + FpgaImageId: Optional[String] + FpgaImageGlobalId: Optional[String] + + +class CreateImageRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + SnapshotLocation: Optional[SnapshotLocationEnum] + DryRun: Optional[Boolean] + InstanceId: InstanceId + Name: String + Description: Optional[String] + NoReboot: Optional[Boolean] + BlockDeviceMappings: Optional[BlockDeviceMappingRequestList] + + +class CreateImageResult(TypedDict, total=False): + ImageId: Optional[String] + + +SecurityGroupIdStringListRequest = List[SecurityGroupId] + + +class CreateInstanceConnectEndpointRequest(ServiceRequest): + DryRun: Optional[Boolean] + SubnetId: SubnetId + SecurityGroupIds: Optional[SecurityGroupIdStringListRequest] + PreserveClientIp: Optional[Boolean] + ClientToken: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + + +SecurityGroupIdSet = List[SecurityGroupId] +NetworkInterfaceIdSet = List[String] + + +class Ec2InstanceConnectEndpoint(TypedDict, total=False): + OwnerId: Optional[String] + InstanceConnectEndpointId: Optional[InstanceConnectEndpointId] + InstanceConnectEndpointArn: Optional[ResourceArn] + State: Optional[Ec2InstanceConnectEndpointState] + StateMessage: Optional[String] + DnsName: Optional[String] + FipsDnsName: Optional[String] + NetworkInterfaceIds: Optional[NetworkInterfaceIdSet] + VpcId: Optional[VpcId] + AvailabilityZone: Optional[String] + CreatedAt: Optional[MillisecondDateTime] + SubnetId: Optional[SubnetId] + PreserveClientIp: Optional[Boolean] + SecurityGroupIds: Optional[SecurityGroupIdSet] + Tags: Optional[TagList] + + +class CreateInstanceConnectEndpointResult(TypedDict, total=False): + InstanceConnectEndpoint: Optional[Ec2InstanceConnectEndpoint] + ClientToken: Optional[String] + + +class InstanceEventWindowTimeRangeRequest(TypedDict, total=False): + StartWeekDay: Optional[WeekDay] + StartHour: Optional[Hour] + EndWeekDay: Optional[WeekDay] + EndHour: Optional[Hour] + + +InstanceEventWindowTimeRangeRequestSet = List[InstanceEventWindowTimeRangeRequest] + + +class CreateInstanceEventWindowRequest(ServiceRequest): + DryRun: Optional[Boolean] + Name: Optional[String] + TimeRanges: Optional[InstanceEventWindowTimeRangeRequestSet] + CronExpression: Optional[InstanceEventWindowCronExpression] + TagSpecifications: Optional[TagSpecificationList] + + +class CreateInstanceEventWindowResult(TypedDict, total=False): + InstanceEventWindow: Optional[InstanceEventWindow] + + +class ExportToS3TaskSpecification(TypedDict, total=False): + DiskImageFormat: Optional[DiskImageFormat] + ContainerFormat: Optional[ContainerFormat] + S3Bucket: Optional[String] + S3Prefix: Optional[String] + + +class CreateInstanceExportTaskRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + Description: Optional[String] + InstanceId: InstanceId + TargetEnvironment: ExportEnvironment + ExportToS3Task: ExportToS3TaskSpecification + + +class InstanceExportDetails(TypedDict, total=False): + InstanceId: Optional[String] + TargetEnvironment: Optional[ExportEnvironment] + + +class ExportToS3Task(TypedDict, total=False): + ContainerFormat: Optional[ContainerFormat] + DiskImageFormat: Optional[DiskImageFormat] + S3Bucket: Optional[String] + S3Key: Optional[String] + + +class ExportTask(TypedDict, total=False): + Description: Optional[String] + ExportTaskId: Optional[String] + ExportToS3Task: Optional[ExportToS3Task] + InstanceExportDetails: Optional[InstanceExportDetails] + State: Optional[ExportTaskState] + StatusMessage: Optional[String] + Tags: Optional[TagList] + + +class CreateInstanceExportTaskResult(TypedDict, total=False): + ExportTask: Optional[ExportTask] + + +class CreateInternetGatewayRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class InternetGateway(TypedDict, total=False): + Attachments: Optional[InternetGatewayAttachmentList] + InternetGatewayId: Optional[String] + OwnerId: Optional[String] + Tags: Optional[TagList] + + +class CreateInternetGatewayResult(TypedDict, total=False): + InternetGateway: Optional[InternetGateway] + + +class CreateIpamExternalResourceVerificationTokenRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamId: IpamId + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + + +class IpamExternalResourceVerificationToken(TypedDict, total=False): + IpamExternalResourceVerificationTokenId: Optional[IpamExternalResourceVerificationTokenId] + IpamExternalResourceVerificationTokenArn: Optional[ResourceArn] + IpamId: Optional[IpamId] + IpamArn: Optional[ResourceArn] + IpamRegion: Optional[String] + TokenValue: Optional[String] + TokenName: Optional[String] + NotAfter: Optional[MillisecondDateTime] + Status: Optional[TokenState] + Tags: Optional[TagList] + State: Optional[IpamExternalResourceVerificationTokenState] + + +class CreateIpamExternalResourceVerificationTokenResult(TypedDict, total=False): + IpamExternalResourceVerificationToken: Optional[IpamExternalResourceVerificationToken] + + +class IpamPoolSourceResourceRequest(TypedDict, total=False): + ResourceId: Optional[String] + ResourceType: Optional[IpamPoolSourceResourceType] + ResourceRegion: Optional[String] + ResourceOwner: Optional[String] + + +class RequestIpamResourceTag(TypedDict, total=False): + Key: Optional[String] + Value: Optional[String] + + +RequestIpamResourceTagList = List[RequestIpamResourceTag] + + +class CreateIpamPoolRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamScopeId: IpamScopeId + Locale: Optional[String] + SourceIpamPoolId: Optional[IpamPoolId] + Description: Optional[String] + AddressFamily: AddressFamily + AutoImport: Optional[Boolean] + PubliclyAdvertisable: Optional[Boolean] + AllocationMinNetmaskLength: Optional[IpamNetmaskLength] + AllocationMaxNetmaskLength: Optional[IpamNetmaskLength] + AllocationDefaultNetmaskLength: Optional[IpamNetmaskLength] + AllocationResourceTags: Optional[RequestIpamResourceTagList] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + AwsService: Optional[IpamPoolAwsService] + PublicIpSource: Optional[IpamPoolPublicIpSource] + SourceResource: Optional[IpamPoolSourceResourceRequest] + + +class IpamPoolSourceResource(TypedDict, total=False): + ResourceId: Optional[String] + ResourceType: Optional[IpamPoolSourceResourceType] + ResourceRegion: Optional[String] + ResourceOwner: Optional[String] + + +class IpamResourceTag(TypedDict, total=False): + Key: Optional[String] + Value: Optional[String] + + +IpamResourceTagList = List[IpamResourceTag] + + +class IpamPool(TypedDict, total=False): + OwnerId: Optional[String] + IpamPoolId: Optional[IpamPoolId] + SourceIpamPoolId: Optional[IpamPoolId] + IpamPoolArn: Optional[ResourceArn] + IpamScopeArn: Optional[ResourceArn] + IpamScopeType: Optional[IpamScopeType] + IpamArn: Optional[ResourceArn] + IpamRegion: Optional[String] + Locale: Optional[String] + PoolDepth: Optional[Integer] + State: Optional[IpamPoolState] + StateMessage: Optional[String] + Description: Optional[String] + AutoImport: Optional[Boolean] + PubliclyAdvertisable: Optional[Boolean] + AddressFamily: Optional[AddressFamily] + AllocationMinNetmaskLength: Optional[IpamNetmaskLength] + AllocationMaxNetmaskLength: Optional[IpamNetmaskLength] + AllocationDefaultNetmaskLength: Optional[IpamNetmaskLength] + AllocationResourceTags: Optional[IpamResourceTagList] + Tags: Optional[TagList] + AwsService: Optional[IpamPoolAwsService] + PublicIpSource: Optional[IpamPoolPublicIpSource] + SourceResource: Optional[IpamPoolSourceResource] + + +class CreateIpamPoolResult(TypedDict, total=False): + IpamPool: Optional[IpamPool] + + +class CreateIpamRequest(ServiceRequest): + DryRun: Optional[Boolean] + Description: Optional[String] + OperatingRegions: Optional[AddIpamOperatingRegionSet] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + Tier: Optional[IpamTier] + EnablePrivateGua: Optional[Boolean] + MeteredAccount: Optional[IpamMeteredAccount] + + +class CreateIpamResourceDiscoveryRequest(ServiceRequest): + DryRun: Optional[Boolean] + Description: Optional[String] + OperatingRegions: Optional[AddIpamOperatingRegionSet] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + + +class IpamOrganizationalUnitExclusion(TypedDict, total=False): + OrganizationsEntityPath: Optional[String] + + +IpamOrganizationalUnitExclusionSet = List[IpamOrganizationalUnitExclusion] + + +class IpamOperatingRegion(TypedDict, total=False): + RegionName: Optional[String] + + +IpamOperatingRegionSet = List[IpamOperatingRegion] + + +class IpamResourceDiscovery(TypedDict, total=False): + OwnerId: Optional[String] + IpamResourceDiscoveryId: Optional[IpamResourceDiscoveryId] + IpamResourceDiscoveryArn: Optional[String] + IpamResourceDiscoveryRegion: Optional[String] + Description: Optional[String] + OperatingRegions: Optional[IpamOperatingRegionSet] + IsDefault: Optional[Boolean] + State: Optional[IpamResourceDiscoveryState] + Tags: Optional[TagList] + OrganizationalUnitExclusions: Optional[IpamOrganizationalUnitExclusionSet] + + +class CreateIpamResourceDiscoveryResult(TypedDict, total=False): + IpamResourceDiscovery: Optional[IpamResourceDiscovery] + + +class Ipam(TypedDict, total=False): + OwnerId: Optional[String] + IpamId: Optional[IpamId] + IpamArn: Optional[ResourceArn] + IpamRegion: Optional[String] + PublicDefaultScopeId: Optional[IpamScopeId] + PrivateDefaultScopeId: Optional[IpamScopeId] + ScopeCount: Optional[Integer] + Description: Optional[String] + OperatingRegions: Optional[IpamOperatingRegionSet] + State: Optional[IpamState] + Tags: Optional[TagList] + DefaultResourceDiscoveryId: Optional[IpamResourceDiscoveryId] + DefaultResourceDiscoveryAssociationId: Optional[IpamResourceDiscoveryAssociationId] + ResourceDiscoveryAssociationCount: Optional[Integer] + StateMessage: Optional[String] + Tier: Optional[IpamTier] + EnablePrivateGua: Optional[Boolean] + MeteredAccount: Optional[IpamMeteredAccount] + + +class CreateIpamResult(TypedDict, total=False): + Ipam: Optional[Ipam] + + +class CreateIpamScopeRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamId: IpamId + Description: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + + +class IpamScope(TypedDict, total=False): + OwnerId: Optional[String] + IpamScopeId: Optional[IpamScopeId] + IpamScopeArn: Optional[ResourceArn] + IpamArn: Optional[ResourceArn] + IpamRegion: Optional[String] + IpamScopeType: Optional[IpamScopeType] + IsDefault: Optional[Boolean] + Description: Optional[String] + PoolCount: Optional[Integer] + State: Optional[IpamScopeState] + Tags: Optional[TagList] + + +class CreateIpamScopeResult(TypedDict, total=False): + IpamScope: Optional[IpamScope] + + +class CreateKeyPairRequest(ServiceRequest): + KeyName: String + KeyType: Optional[KeyType] + TagSpecifications: Optional[TagSpecificationList] + KeyFormat: Optional[KeyFormat] + DryRun: Optional[Boolean] + + +class OperatorRequest(TypedDict, total=False): + Principal: Optional[String] + + +class LaunchTemplateNetworkPerformanceOptionsRequest(TypedDict, total=False): + BandwidthWeighting: Optional[InstanceBandwidthWeighting] + + +class LaunchTemplateInstanceMaintenanceOptionsRequest(TypedDict, total=False): + AutoRecovery: Optional[LaunchTemplateAutoRecoveryState] + + +class LaunchTemplatePrivateDnsNameOptionsRequest(TypedDict, total=False): + HostnameType: Optional[HostnameType] + EnableResourceNameDnsARecord: Optional[Boolean] + EnableResourceNameDnsAAAARecord: Optional[Boolean] + + +class LaunchTemplateEnclaveOptionsRequest(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class LaunchTemplateInstanceMetadataOptionsRequest(TypedDict, total=False): + HttpTokens: Optional[LaunchTemplateHttpTokensState] + HttpPutResponseHopLimit: Optional[Integer] + HttpEndpoint: Optional[LaunchTemplateInstanceMetadataEndpointState] + HttpProtocolIpv6: Optional[LaunchTemplateInstanceMetadataProtocolIpv6] + InstanceMetadataTags: Optional[LaunchTemplateInstanceMetadataTagsState] + + +class LaunchTemplateHibernationOptionsRequest(TypedDict, total=False): + Configured: Optional[Boolean] + + +class LaunchTemplateLicenseConfigurationRequest(TypedDict, total=False): + LicenseConfigurationArn: Optional[String] + + +LaunchTemplateLicenseSpecificationListRequest = List[LaunchTemplateLicenseConfigurationRequest] + + +class LaunchTemplateCapacityReservationSpecificationRequest(TypedDict, total=False): + CapacityReservationPreference: Optional[CapacityReservationPreference] + CapacityReservationTarget: Optional[CapacityReservationTarget] + + +class LaunchTemplateCpuOptionsRequest(TypedDict, total=False): + CoreCount: Optional[Integer] + ThreadsPerCore: Optional[Integer] + AmdSevSnp: Optional[AmdSevSnpSpecification] + + +class CreditSpecificationRequest(TypedDict, total=False): + CpuCredits: String + + +class LaunchTemplateSpotMarketOptionsRequest(TypedDict, total=False): + MaxPrice: Optional[String] + SpotInstanceType: Optional[SpotInstanceType] + BlockDurationMinutes: Optional[Integer] + ValidUntil: Optional[DateTime] + InstanceInterruptionBehavior: Optional[InstanceInterruptionBehavior] + + +class LaunchTemplateInstanceMarketOptionsRequest(TypedDict, total=False): + MarketType: Optional[MarketType] + SpotOptions: Optional[LaunchTemplateSpotMarketOptionsRequest] + + +SecurityGroupStringList = List[SecurityGroupName] +SecurityGroupIdStringList = List[SecurityGroupId] + + +class LaunchTemplateElasticInferenceAccelerator(TypedDict, total=False): + Type: String + Count: Optional[LaunchTemplateElasticInferenceAcceleratorCount] + + +LaunchTemplateElasticInferenceAcceleratorList = List[LaunchTemplateElasticInferenceAccelerator] + + +class ElasticGpuSpecification(TypedDict, total=False): + Type: String + + +ElasticGpuSpecificationList = List[ElasticGpuSpecification] + + +class LaunchTemplateTagSpecificationRequest(TypedDict, total=False): + ResourceType: Optional[ResourceType] + Tags: Optional[TagList] + + +LaunchTemplateTagSpecificationRequestList = List[LaunchTemplateTagSpecificationRequest] + + +class LaunchTemplatePlacementRequest(TypedDict, total=False): + AvailabilityZone: Optional[String] + Affinity: Optional[String] + GroupName: Optional[PlacementGroupName] + HostId: Optional[DedicatedHostId] + Tenancy: Optional[Tenancy] + SpreadDomain: Optional[String] + HostResourceGroupArn: Optional[String] + PartitionNumber: Optional[Integer] + GroupId: Optional[PlacementGroupId] + + +class LaunchTemplatesMonitoringRequest(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class EnaSrdUdpSpecificationRequest(TypedDict, total=False): + EnaSrdUdpEnabled: Optional[Boolean] + + +class EnaSrdSpecificationRequest(TypedDict, total=False): + EnaSrdEnabled: Optional[Boolean] + EnaSrdUdpSpecification: Optional[EnaSrdUdpSpecificationRequest] + + +class Ipv6PrefixSpecificationRequest(TypedDict, total=False): + Ipv6Prefix: Optional[String] + + +Ipv6PrefixList = List[Ipv6PrefixSpecificationRequest] + + +class Ipv4PrefixSpecificationRequest(TypedDict, total=False): + Ipv4Prefix: Optional[String] + + +Ipv4PrefixList = List[Ipv4PrefixSpecificationRequest] + + +class PrivateIpAddressSpecification(TypedDict, total=False): + Primary: Optional[Boolean] + PrivateIpAddress: Optional[String] + + +PrivateIpAddressSpecificationList = List[PrivateIpAddressSpecification] + + +class InstanceIpv6AddressRequest(TypedDict, total=False): + Ipv6Address: Optional[String] + + +InstanceIpv6AddressListRequest = List[InstanceIpv6AddressRequest] + + +class LaunchTemplateInstanceNetworkInterfaceSpecificationRequest(TypedDict, total=False): + AssociateCarrierIpAddress: Optional[Boolean] + AssociatePublicIpAddress: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Description: Optional[String] + DeviceIndex: Optional[Integer] + Groups: Optional[SecurityGroupIdStringList] + InterfaceType: Optional[String] + Ipv6AddressCount: Optional[Integer] + Ipv6Addresses: Optional[InstanceIpv6AddressListRequest] + NetworkInterfaceId: Optional[NetworkInterfaceId] + PrivateIpAddress: Optional[String] + PrivateIpAddresses: Optional[PrivateIpAddressSpecificationList] + SecondaryPrivateIpAddressCount: Optional[Integer] + SubnetId: Optional[SubnetId] + NetworkCardIndex: Optional[Integer] + Ipv4Prefixes: Optional[Ipv4PrefixList] + Ipv4PrefixCount: Optional[Integer] + Ipv6Prefixes: Optional[Ipv6PrefixList] + Ipv6PrefixCount: Optional[Integer] + PrimaryIpv6: Optional[Boolean] + EnaSrdSpecification: Optional[EnaSrdSpecificationRequest] + ConnectionTrackingSpecification: Optional[ConnectionTrackingSpecificationRequest] + EnaQueueCount: Optional[Integer] + + +LaunchTemplateInstanceNetworkInterfaceSpecificationRequestList = List[ + LaunchTemplateInstanceNetworkInterfaceSpecificationRequest +] + + +class LaunchTemplateEbsBlockDeviceRequest(TypedDict, total=False): + Encrypted: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Iops: Optional[Integer] + KmsKeyId: Optional[KmsKeyId] + SnapshotId: Optional[SnapshotId] + VolumeSize: Optional[Integer] + VolumeType: Optional[VolumeType] + Throughput: Optional[Integer] + VolumeInitializationRate: Optional[Integer] + + +class LaunchTemplateBlockDeviceMappingRequest(TypedDict, total=False): + DeviceName: Optional[String] + VirtualName: Optional[String] + Ebs: Optional[LaunchTemplateEbsBlockDeviceRequest] + NoDevice: Optional[String] + + +LaunchTemplateBlockDeviceMappingRequestList = List[LaunchTemplateBlockDeviceMappingRequest] + + +class LaunchTemplateIamInstanceProfileSpecificationRequest(TypedDict, total=False): + Arn: Optional[String] + Name: Optional[String] + + +class RequestLaunchTemplateData(TypedDict, total=False): + KernelId: Optional[KernelId] + EbsOptimized: Optional[Boolean] + IamInstanceProfile: Optional[LaunchTemplateIamInstanceProfileSpecificationRequest] + BlockDeviceMappings: Optional[LaunchTemplateBlockDeviceMappingRequestList] + NetworkInterfaces: Optional[LaunchTemplateInstanceNetworkInterfaceSpecificationRequestList] + ImageId: Optional[ImageId] + InstanceType: Optional[InstanceType] + KeyName: Optional[KeyPairName] + Monitoring: Optional[LaunchTemplatesMonitoringRequest] + Placement: Optional[LaunchTemplatePlacementRequest] + RamDiskId: Optional[RamdiskId] + DisableApiTermination: Optional[Boolean] + InstanceInitiatedShutdownBehavior: Optional[ShutdownBehavior] + UserData: Optional[SensitiveUserData] + TagSpecifications: Optional[LaunchTemplateTagSpecificationRequestList] + ElasticGpuSpecifications: Optional[ElasticGpuSpecificationList] + ElasticInferenceAccelerators: Optional[LaunchTemplateElasticInferenceAcceleratorList] + SecurityGroupIds: Optional[SecurityGroupIdStringList] + SecurityGroups: Optional[SecurityGroupStringList] + InstanceMarketOptions: Optional[LaunchTemplateInstanceMarketOptionsRequest] + CreditSpecification: Optional[CreditSpecificationRequest] + CpuOptions: Optional[LaunchTemplateCpuOptionsRequest] + CapacityReservationSpecification: Optional[ + LaunchTemplateCapacityReservationSpecificationRequest + ] + LicenseSpecifications: Optional[LaunchTemplateLicenseSpecificationListRequest] + HibernationOptions: Optional[LaunchTemplateHibernationOptionsRequest] + MetadataOptions: Optional[LaunchTemplateInstanceMetadataOptionsRequest] + EnclaveOptions: Optional[LaunchTemplateEnclaveOptionsRequest] + InstanceRequirements: Optional[InstanceRequirementsRequest] + PrivateDnsNameOptions: Optional[LaunchTemplatePrivateDnsNameOptionsRequest] + MaintenanceOptions: Optional[LaunchTemplateInstanceMaintenanceOptionsRequest] + DisableApiStop: Optional[Boolean] + Operator: Optional[OperatorRequest] + NetworkPerformanceOptions: Optional[LaunchTemplateNetworkPerformanceOptionsRequest] + + +class CreateLaunchTemplateRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + LaunchTemplateName: LaunchTemplateName + VersionDescription: Optional[VersionDescription] + LaunchTemplateData: RequestLaunchTemplateData + Operator: Optional[OperatorRequest] + TagSpecifications: Optional[TagSpecificationList] + + +class ValidationError(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +ErrorSet = List[ValidationError] + + +class ValidationWarning(TypedDict, total=False): + Errors: Optional[ErrorSet] + + +class OperatorResponse(TypedDict, total=False): + Managed: Optional[Boolean] + Principal: Optional[String] + + +class LaunchTemplate(TypedDict, total=False): + LaunchTemplateId: Optional[String] + LaunchTemplateName: Optional[LaunchTemplateName] + CreateTime: Optional[DateTime] + CreatedBy: Optional[String] + DefaultVersionNumber: Optional[Long] + LatestVersionNumber: Optional[Long] + Tags: Optional[TagList] + Operator: Optional[OperatorResponse] + + +class CreateLaunchTemplateResult(TypedDict, total=False): + LaunchTemplate: Optional[LaunchTemplate] + Warning: Optional[ValidationWarning] + + +class CreateLaunchTemplateVersionRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[LaunchTemplateName] + SourceVersion: Optional[String] + VersionDescription: Optional[VersionDescription] + LaunchTemplateData: RequestLaunchTemplateData + ResolveAlias: Optional[Boolean] + + +class LaunchTemplateNetworkPerformanceOptions(TypedDict, total=False): + BandwidthWeighting: Optional[InstanceBandwidthWeighting] + + +class LaunchTemplateInstanceMaintenanceOptions(TypedDict, total=False): + AutoRecovery: Optional[LaunchTemplateAutoRecoveryState] + + +class LaunchTemplatePrivateDnsNameOptions(TypedDict, total=False): + HostnameType: Optional[HostnameType] + EnableResourceNameDnsARecord: Optional[Boolean] + EnableResourceNameDnsAAAARecord: Optional[Boolean] + + +class LaunchTemplateEnclaveOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class LaunchTemplateInstanceMetadataOptions(TypedDict, total=False): + State: Optional[LaunchTemplateInstanceMetadataOptionsState] + HttpTokens: Optional[LaunchTemplateHttpTokensState] + HttpPutResponseHopLimit: Optional[Integer] + HttpEndpoint: Optional[LaunchTemplateInstanceMetadataEndpointState] + HttpProtocolIpv6: Optional[LaunchTemplateInstanceMetadataProtocolIpv6] + InstanceMetadataTags: Optional[LaunchTemplateInstanceMetadataTagsState] + + +class LaunchTemplateHibernationOptions(TypedDict, total=False): + Configured: Optional[Boolean] + + +class LaunchTemplateLicenseConfiguration(TypedDict, total=False): + LicenseConfigurationArn: Optional[String] + + +LaunchTemplateLicenseList = List[LaunchTemplateLicenseConfiguration] + + +class LaunchTemplateCapacityReservationSpecificationResponse(TypedDict, total=False): + CapacityReservationPreference: Optional[CapacityReservationPreference] + CapacityReservationTarget: Optional[CapacityReservationTargetResponse] + + +class LaunchTemplateCpuOptions(TypedDict, total=False): + CoreCount: Optional[Integer] + ThreadsPerCore: Optional[Integer] + AmdSevSnp: Optional[AmdSevSnpSpecification] + + +class CreditSpecification(TypedDict, total=False): + CpuCredits: Optional[String] + + +class LaunchTemplateSpotMarketOptions(TypedDict, total=False): + MaxPrice: Optional[String] + SpotInstanceType: Optional[SpotInstanceType] + BlockDurationMinutes: Optional[Integer] + ValidUntil: Optional[DateTime] + InstanceInterruptionBehavior: Optional[InstanceInterruptionBehavior] + + +class LaunchTemplateInstanceMarketOptions(TypedDict, total=False): + MarketType: Optional[MarketType] + SpotOptions: Optional[LaunchTemplateSpotMarketOptions] + + +class LaunchTemplateElasticInferenceAcceleratorResponse(TypedDict, total=False): + Type: Optional[String] + Count: Optional[Integer] + + +LaunchTemplateElasticInferenceAcceleratorResponseList = List[ + LaunchTemplateElasticInferenceAcceleratorResponse +] + + +class ElasticGpuSpecificationResponse(TypedDict, total=False): + Type: Optional[String] + + +ElasticGpuSpecificationResponseList = List[ElasticGpuSpecificationResponse] + + +class LaunchTemplateTagSpecification(TypedDict, total=False): + ResourceType: Optional[ResourceType] + Tags: Optional[TagList] + + +LaunchTemplateTagSpecificationList = List[LaunchTemplateTagSpecification] + + +class LaunchTemplatePlacement(TypedDict, total=False): + AvailabilityZone: Optional[String] + Affinity: Optional[String] + GroupName: Optional[String] + HostId: Optional[String] + Tenancy: Optional[Tenancy] + SpreadDomain: Optional[String] + HostResourceGroupArn: Optional[String] + PartitionNumber: Optional[Integer] + GroupId: Optional[PlacementGroupId] + + +class LaunchTemplatesMonitoring(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class LaunchTemplateEnaSrdUdpSpecification(TypedDict, total=False): + EnaSrdUdpEnabled: Optional[Boolean] + + +class LaunchTemplateEnaSrdSpecification(TypedDict, total=False): + EnaSrdEnabled: Optional[Boolean] + EnaSrdUdpSpecification: Optional[LaunchTemplateEnaSrdUdpSpecification] + + +class Ipv6PrefixSpecificationResponse(TypedDict, total=False): + Ipv6Prefix: Optional[String] + + +Ipv6PrefixListResponse = List[Ipv6PrefixSpecificationResponse] + + +class Ipv4PrefixSpecificationResponse(TypedDict, total=False): + Ipv4Prefix: Optional[String] + + +Ipv4PrefixListResponse = List[Ipv4PrefixSpecificationResponse] + + +class InstanceIpv6Address(TypedDict, total=False): + Ipv6Address: Optional[String] + IsPrimaryIpv6: Optional[Boolean] + + +InstanceIpv6AddressList = List[InstanceIpv6Address] + + +class LaunchTemplateInstanceNetworkInterfaceSpecification(TypedDict, total=False): + AssociateCarrierIpAddress: Optional[Boolean] + AssociatePublicIpAddress: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Description: Optional[String] + DeviceIndex: Optional[Integer] + Groups: Optional[GroupIdStringList] + InterfaceType: Optional[String] + Ipv6AddressCount: Optional[Integer] + Ipv6Addresses: Optional[InstanceIpv6AddressList] + NetworkInterfaceId: Optional[NetworkInterfaceId] + PrivateIpAddress: Optional[String] + PrivateIpAddresses: Optional[PrivateIpAddressSpecificationList] + SecondaryPrivateIpAddressCount: Optional[Integer] + SubnetId: Optional[SubnetId] + NetworkCardIndex: Optional[Integer] + Ipv4Prefixes: Optional[Ipv4PrefixListResponse] + Ipv4PrefixCount: Optional[Integer] + Ipv6Prefixes: Optional[Ipv6PrefixListResponse] + Ipv6PrefixCount: Optional[Integer] + PrimaryIpv6: Optional[Boolean] + EnaSrdSpecification: Optional[LaunchTemplateEnaSrdSpecification] + ConnectionTrackingSpecification: Optional[ConnectionTrackingSpecification] + EnaQueueCount: Optional[Integer] + + +LaunchTemplateInstanceNetworkInterfaceSpecificationList = List[ + LaunchTemplateInstanceNetworkInterfaceSpecification +] + + +class LaunchTemplateEbsBlockDevice(TypedDict, total=False): + Encrypted: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Iops: Optional[Integer] + KmsKeyId: Optional[KmsKeyId] + SnapshotId: Optional[SnapshotId] + VolumeSize: Optional[Integer] + VolumeType: Optional[VolumeType] + Throughput: Optional[Integer] + VolumeInitializationRate: Optional[Integer] + + +class LaunchTemplateBlockDeviceMapping(TypedDict, total=False): + DeviceName: Optional[String] + VirtualName: Optional[String] + Ebs: Optional[LaunchTemplateEbsBlockDevice] + NoDevice: Optional[String] + + +LaunchTemplateBlockDeviceMappingList = List[LaunchTemplateBlockDeviceMapping] + + +class LaunchTemplateIamInstanceProfileSpecification(TypedDict, total=False): + Arn: Optional[String] + Name: Optional[String] + + +class ResponseLaunchTemplateData(TypedDict, total=False): + KernelId: Optional[String] + EbsOptimized: Optional[Boolean] + IamInstanceProfile: Optional[LaunchTemplateIamInstanceProfileSpecification] + BlockDeviceMappings: Optional[LaunchTemplateBlockDeviceMappingList] + NetworkInterfaces: Optional[LaunchTemplateInstanceNetworkInterfaceSpecificationList] + ImageId: Optional[String] + InstanceType: Optional[InstanceType] + KeyName: Optional[String] + Monitoring: Optional[LaunchTemplatesMonitoring] + Placement: Optional[LaunchTemplatePlacement] + RamDiskId: Optional[String] + DisableApiTermination: Optional[Boolean] + InstanceInitiatedShutdownBehavior: Optional[ShutdownBehavior] + UserData: Optional[SensitiveUserData] + TagSpecifications: Optional[LaunchTemplateTagSpecificationList] + ElasticGpuSpecifications: Optional[ElasticGpuSpecificationResponseList] + ElasticInferenceAccelerators: Optional[LaunchTemplateElasticInferenceAcceleratorResponseList] + SecurityGroupIds: Optional[ValueStringList] + SecurityGroups: Optional[ValueStringList] + InstanceMarketOptions: Optional[LaunchTemplateInstanceMarketOptions] + CreditSpecification: Optional[CreditSpecification] + CpuOptions: Optional[LaunchTemplateCpuOptions] + CapacityReservationSpecification: Optional[ + LaunchTemplateCapacityReservationSpecificationResponse + ] + LicenseSpecifications: Optional[LaunchTemplateLicenseList] + HibernationOptions: Optional[LaunchTemplateHibernationOptions] + MetadataOptions: Optional[LaunchTemplateInstanceMetadataOptions] + EnclaveOptions: Optional[LaunchTemplateEnclaveOptions] + InstanceRequirements: Optional[InstanceRequirements] + PrivateDnsNameOptions: Optional[LaunchTemplatePrivateDnsNameOptions] + MaintenanceOptions: Optional[LaunchTemplateInstanceMaintenanceOptions] + DisableApiStop: Optional[Boolean] + Operator: Optional[OperatorResponse] + NetworkPerformanceOptions: Optional[LaunchTemplateNetworkPerformanceOptions] + + +class LaunchTemplateVersion(TypedDict, total=False): + LaunchTemplateId: Optional[String] + LaunchTemplateName: Optional[LaunchTemplateName] + VersionNumber: Optional[Long] + VersionDescription: Optional[VersionDescription] + CreateTime: Optional[DateTime] + CreatedBy: Optional[String] + DefaultVersion: Optional[Boolean] + LaunchTemplateData: Optional[ResponseLaunchTemplateData] + Operator: Optional[OperatorResponse] + + +class CreateLaunchTemplateVersionResult(TypedDict, total=False): + LaunchTemplateVersion: Optional[LaunchTemplateVersion] + Warning: Optional[ValidationWarning] + + +class CreateLocalGatewayRouteRequest(ServiceRequest): + DestinationCidrBlock: Optional[String] + LocalGatewayRouteTableId: LocalGatewayRoutetableId + LocalGatewayVirtualInterfaceGroupId: Optional[LocalGatewayVirtualInterfaceGroupId] + DryRun: Optional[Boolean] + NetworkInterfaceId: Optional[NetworkInterfaceId] + DestinationPrefixListId: Optional[PrefixListResourceId] + + +class LocalGatewayRoute(TypedDict, total=False): + DestinationCidrBlock: Optional[String] + LocalGatewayVirtualInterfaceGroupId: Optional[LocalGatewayVirtualInterfaceGroupId] + Type: Optional[LocalGatewayRouteType] + State: Optional[LocalGatewayRouteState] + LocalGatewayRouteTableId: Optional[LocalGatewayRoutetableId] + LocalGatewayRouteTableArn: Optional[ResourceArn] + OwnerId: Optional[String] + SubnetId: Optional[SubnetId] + CoipPoolId: Optional[CoipPoolId] + NetworkInterfaceId: Optional[NetworkInterfaceId] + DestinationPrefixListId: Optional[PrefixListResourceId] + + +class CreateLocalGatewayRouteResult(TypedDict, total=False): + Route: Optional[LocalGatewayRoute] + + +class CreateLocalGatewayRouteTableRequest(ServiceRequest): + LocalGatewayId: LocalGatewayId + Mode: Optional[LocalGatewayRouteTableMode] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class StateReason(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +class LocalGatewayRouteTable(TypedDict, total=False): + LocalGatewayRouteTableId: Optional[String] + LocalGatewayRouteTableArn: Optional[ResourceArn] + LocalGatewayId: Optional[LocalGatewayId] + OutpostArn: Optional[String] + OwnerId: Optional[String] + State: Optional[String] + Tags: Optional[TagList] + Mode: Optional[LocalGatewayRouteTableMode] + StateReason: Optional[StateReason] + + +class CreateLocalGatewayRouteTableResult(TypedDict, total=False): + LocalGatewayRouteTable: Optional[LocalGatewayRouteTable] + + +class CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociationRequest(ServiceRequest): + LocalGatewayRouteTableId: LocalGatewayRoutetableId + LocalGatewayVirtualInterfaceGroupId: LocalGatewayVirtualInterfaceGroupId + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class LocalGatewayRouteTableVirtualInterfaceGroupAssociation(TypedDict, total=False): + LocalGatewayRouteTableVirtualInterfaceGroupAssociationId: Optional[ + LocalGatewayRouteTableVirtualInterfaceGroupAssociationId + ] + LocalGatewayVirtualInterfaceGroupId: Optional[LocalGatewayVirtualInterfaceGroupId] + LocalGatewayId: Optional[String] + LocalGatewayRouteTableId: Optional[LocalGatewayId] + LocalGatewayRouteTableArn: Optional[ResourceArn] + OwnerId: Optional[String] + State: Optional[String] + Tags: Optional[TagList] + + +class CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociationResult(TypedDict, total=False): + LocalGatewayRouteTableVirtualInterfaceGroupAssociation: Optional[ + LocalGatewayRouteTableVirtualInterfaceGroupAssociation + ] + + +class CreateLocalGatewayRouteTableVpcAssociationRequest(ServiceRequest): + LocalGatewayRouteTableId: LocalGatewayRoutetableId + VpcId: VpcId + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class LocalGatewayRouteTableVpcAssociation(TypedDict, total=False): + LocalGatewayRouteTableVpcAssociationId: Optional[LocalGatewayRouteTableVpcAssociationId] + LocalGatewayRouteTableId: Optional[String] + LocalGatewayRouteTableArn: Optional[ResourceArn] + LocalGatewayId: Optional[String] + VpcId: Optional[String] + OwnerId: Optional[String] + State: Optional[String] + Tags: Optional[TagList] + + +class CreateLocalGatewayRouteTableVpcAssociationResult(TypedDict, total=False): + LocalGatewayRouteTableVpcAssociation: Optional[LocalGatewayRouteTableVpcAssociation] + + +class CreateLocalGatewayVirtualInterfaceGroupRequest(ServiceRequest): + LocalGatewayId: LocalGatewayId + LocalBgpAsn: Optional[Integer] + LocalBgpAsnExtended: Optional[Long] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +LocalGatewayVirtualInterfaceIdSet = List[LocalGatewayVirtualInterfaceId] + + +class LocalGatewayVirtualInterfaceGroup(TypedDict, total=False): + LocalGatewayVirtualInterfaceGroupId: Optional[LocalGatewayVirtualInterfaceGroupId] + LocalGatewayVirtualInterfaceIds: Optional[LocalGatewayVirtualInterfaceIdSet] + LocalGatewayId: Optional[String] + OwnerId: Optional[String] + LocalBgpAsn: Optional[Integer] + LocalBgpAsnExtended: Optional[Long] + LocalGatewayVirtualInterfaceGroupArn: Optional[ResourceArn] + Tags: Optional[TagList] + ConfigurationState: Optional[LocalGatewayVirtualInterfaceGroupConfigurationState] + + +class CreateLocalGatewayVirtualInterfaceGroupResult(TypedDict, total=False): + LocalGatewayVirtualInterfaceGroup: Optional[LocalGatewayVirtualInterfaceGroup] + + +class CreateLocalGatewayVirtualInterfaceRequest(ServiceRequest): + LocalGatewayVirtualInterfaceGroupId: LocalGatewayVirtualInterfaceGroupId + OutpostLagId: OutpostLagId + Vlan: Integer + LocalAddress: String + PeerAddress: String + PeerBgpAsn: Optional[Integer] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + PeerBgpAsnExtended: Optional[Long] + + +class LocalGatewayVirtualInterface(TypedDict, total=False): + LocalGatewayVirtualInterfaceId: Optional[LocalGatewayVirtualInterfaceId] + LocalGatewayId: Optional[String] + LocalGatewayVirtualInterfaceGroupId: Optional[LocalGatewayVirtualInterfaceGroupId] + LocalGatewayVirtualInterfaceArn: Optional[ResourceArn] + OutpostLagId: Optional[String] + Vlan: Optional[Integer] + LocalAddress: Optional[String] + PeerAddress: Optional[String] + LocalBgpAsn: Optional[Integer] + PeerBgpAsn: Optional[Integer] + PeerBgpAsnExtended: Optional[Long] + OwnerId: Optional[String] + Tags: Optional[TagList] + ConfigurationState: Optional[LocalGatewayVirtualInterfaceConfigurationState] + + +class CreateLocalGatewayVirtualInterfaceResult(TypedDict, total=False): + LocalGatewayVirtualInterface: Optional[LocalGatewayVirtualInterface] + + +class MacSystemIntegrityProtectionConfigurationRequest(TypedDict, total=False): + AppleInternal: Optional[MacSystemIntegrityProtectionSettingStatus] + BaseSystem: Optional[MacSystemIntegrityProtectionSettingStatus] + DebuggingRestrictions: Optional[MacSystemIntegrityProtectionSettingStatus] + DTraceRestrictions: Optional[MacSystemIntegrityProtectionSettingStatus] + FilesystemProtections: Optional[MacSystemIntegrityProtectionSettingStatus] + KextSigning: Optional[MacSystemIntegrityProtectionSettingStatus] + NvramProtections: Optional[MacSystemIntegrityProtectionSettingStatus] + + +class CreateMacSystemIntegrityProtectionModificationTaskRequest(ServiceRequest): + ClientToken: Optional[String] + DryRun: Optional[Boolean] + InstanceId: InstanceId + MacCredentials: Optional[SensitiveMacCredentials] + MacSystemIntegrityProtectionConfiguration: Optional[ + MacSystemIntegrityProtectionConfigurationRequest + ] + MacSystemIntegrityProtectionStatus: MacSystemIntegrityProtectionSettingStatus + TagSpecifications: Optional[TagSpecificationList] + + +class CreateMacSystemIntegrityProtectionModificationTaskResult(TypedDict, total=False): + MacModificationTask: Optional[MacModificationTask] + + +class CreateManagedPrefixListRequest(ServiceRequest): + DryRun: Optional[Boolean] + PrefixListName: String + Entries: Optional[AddPrefixListEntries] + MaxEntries: Integer + TagSpecifications: Optional[TagSpecificationList] + AddressFamily: String + ClientToken: Optional[String] + + +class ManagedPrefixList(TypedDict, total=False): + PrefixListId: Optional[PrefixListResourceId] + AddressFamily: Optional[String] + State: Optional[PrefixListState] + StateMessage: Optional[String] + PrefixListArn: Optional[ResourceArn] + PrefixListName: Optional[String] + MaxEntries: Optional[Integer] + Version: Optional[Long] + Tags: Optional[TagList] + OwnerId: Optional[String] + + +class CreateManagedPrefixListResult(TypedDict, total=False): + PrefixList: Optional[ManagedPrefixList] + + +class CreateNatGatewayRequest(ServiceRequest): + AllocationId: Optional[AllocationId] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + SubnetId: SubnetId + TagSpecifications: Optional[TagSpecificationList] + ConnectivityType: Optional[ConnectivityType] + PrivateIpAddress: Optional[String] + SecondaryAllocationIds: Optional[AllocationIdList] + SecondaryPrivateIpAddresses: Optional[IpList] + SecondaryPrivateIpAddressCount: Optional[PrivateIpAddressCount] + + +class ProvisionedBandwidth(TypedDict, total=False): + ProvisionTime: Optional[DateTime] + Provisioned: Optional[String] + RequestTime: Optional[DateTime] + Requested: Optional[String] + Status: Optional[String] + + +class NatGateway(TypedDict, total=False): + CreateTime: Optional[DateTime] + DeleteTime: Optional[DateTime] + FailureCode: Optional[String] + FailureMessage: Optional[String] + NatGatewayAddresses: Optional[NatGatewayAddressList] + NatGatewayId: Optional[String] + ProvisionedBandwidth: Optional[ProvisionedBandwidth] + State: Optional[NatGatewayState] + SubnetId: Optional[String] + VpcId: Optional[String] + Tags: Optional[TagList] + ConnectivityType: Optional[ConnectivityType] + + +class CreateNatGatewayResult(TypedDict, total=False): + ClientToken: Optional[String] + NatGateway: Optional[NatGateway] + + +class IcmpTypeCode(TypedDict, total=False): + Code: Optional[Integer] + Type: Optional[Integer] + + +class CreateNetworkAclEntryRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkAclId: NetworkAclId + RuleNumber: Integer + Protocol: String + RuleAction: RuleAction + Egress: Boolean + CidrBlock: Optional[String] + Ipv6CidrBlock: Optional[String] + IcmpTypeCode: Optional[IcmpTypeCode] + PortRange: Optional[PortRange] + + +class CreateNetworkAclRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + VpcId: VpcId + + +class NetworkAclEntry(TypedDict, total=False): + CidrBlock: Optional[String] + Egress: Optional[Boolean] + IcmpTypeCode: Optional[IcmpTypeCode] + Ipv6CidrBlock: Optional[String] + PortRange: Optional[PortRange] + Protocol: Optional[String] + RuleAction: Optional[RuleAction] + RuleNumber: Optional[Integer] + + +NetworkAclEntryList = List[NetworkAclEntry] + + +class NetworkAclAssociation(TypedDict, total=False): + NetworkAclAssociationId: Optional[String] + NetworkAclId: Optional[String] + SubnetId: Optional[String] + + +NetworkAclAssociationList = List[NetworkAclAssociation] + + +class NetworkAcl(TypedDict, total=False): + Associations: Optional[NetworkAclAssociationList] + Entries: Optional[NetworkAclEntryList] + IsDefault: Optional[Boolean] + NetworkAclId: Optional[String] + Tags: Optional[TagList] + VpcId: Optional[String] + OwnerId: Optional[String] + + +class CreateNetworkAclResult(TypedDict, total=False): + NetworkAcl: Optional[NetworkAcl] + ClientToken: Optional[String] + + +class CreateNetworkInsightsAccessScopeRequest(ServiceRequest): + MatchPaths: Optional[AccessScopePathListRequest] + ExcludePaths: Optional[AccessScopePathListRequest] + ClientToken: String + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class NetworkInsightsAccessScopeContent(TypedDict, total=False): + NetworkInsightsAccessScopeId: Optional[NetworkInsightsAccessScopeId] + MatchPaths: Optional[AccessScopePathList] + ExcludePaths: Optional[AccessScopePathList] + + +class NetworkInsightsAccessScope(TypedDict, total=False): + NetworkInsightsAccessScopeId: Optional[NetworkInsightsAccessScopeId] + NetworkInsightsAccessScopeArn: Optional[ResourceArn] + CreatedDate: Optional[MillisecondDateTime] + UpdatedDate: Optional[MillisecondDateTime] + Tags: Optional[TagList] + + +class CreateNetworkInsightsAccessScopeResult(TypedDict, total=False): + NetworkInsightsAccessScope: Optional[NetworkInsightsAccessScope] + NetworkInsightsAccessScopeContent: Optional[NetworkInsightsAccessScopeContent] + + +class RequestFilterPortRange(TypedDict, total=False): + FromPort: Optional[Port] + ToPort: Optional[Port] + + +class PathRequestFilter(TypedDict, total=False): + SourceAddress: Optional[IpAddress] + SourcePortRange: Optional[RequestFilterPortRange] + DestinationAddress: Optional[IpAddress] + DestinationPortRange: Optional[RequestFilterPortRange] + + +class CreateNetworkInsightsPathRequest(ServiceRequest): + SourceIp: Optional[IpAddress] + DestinationIp: Optional[IpAddress] + Source: NetworkInsightsResourceId + Destination: Optional[NetworkInsightsResourceId] + Protocol: Protocol + DestinationPort: Optional[Port] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + ClientToken: String + FilterAtSource: Optional[PathRequestFilter] + FilterAtDestination: Optional[PathRequestFilter] + + +class FilterPortRange(TypedDict, total=False): + FromPort: Optional[Port] + ToPort: Optional[Port] + + +class PathFilter(TypedDict, total=False): + SourceAddress: Optional[IpAddress] + SourcePortRange: Optional[FilterPortRange] + DestinationAddress: Optional[IpAddress] + DestinationPortRange: Optional[FilterPortRange] + + +class NetworkInsightsPath(TypedDict, total=False): + NetworkInsightsPathId: Optional[NetworkInsightsPathId] + NetworkInsightsPathArn: Optional[ResourceArn] + CreatedDate: Optional[MillisecondDateTime] + Source: Optional[String] + Destination: Optional[String] + SourceArn: Optional[ResourceArn] + DestinationArn: Optional[ResourceArn] + SourceIp: Optional[IpAddress] + DestinationIp: Optional[IpAddress] + Protocol: Optional[Protocol] + DestinationPort: Optional[Integer] + Tags: Optional[TagList] + FilterAtSource: Optional[PathFilter] + FilterAtDestination: Optional[PathFilter] + + +class CreateNetworkInsightsPathResult(TypedDict, total=False): + NetworkInsightsPath: Optional[NetworkInsightsPath] + + +class CreateNetworkInterfacePermissionRequest(ServiceRequest): + NetworkInterfaceId: NetworkInterfaceId + AwsAccountId: Optional[String] + AwsService: Optional[String] + Permission: InterfacePermissionType + DryRun: Optional[Boolean] + + +class NetworkInterfacePermissionState(TypedDict, total=False): + State: Optional[NetworkInterfacePermissionStateCode] + StatusMessage: Optional[String] + + +class NetworkInterfacePermission(TypedDict, total=False): + NetworkInterfacePermissionId: Optional[String] + NetworkInterfaceId: Optional[String] + AwsAccountId: Optional[String] + AwsService: Optional[String] + Permission: Optional[InterfacePermissionType] + PermissionState: Optional[NetworkInterfacePermissionState] + + +class CreateNetworkInterfacePermissionResult(TypedDict, total=False): + InterfacePermission: Optional[NetworkInterfacePermission] + + +class CreateNetworkInterfaceRequest(ServiceRequest): + Ipv4Prefixes: Optional[Ipv4PrefixList] + Ipv4PrefixCount: Optional[Integer] + Ipv6Prefixes: Optional[Ipv6PrefixList] + Ipv6PrefixCount: Optional[Integer] + InterfaceType: Optional[NetworkInterfaceCreationType] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + EnablePrimaryIpv6: Optional[Boolean] + ConnectionTrackingSpecification: Optional[ConnectionTrackingSpecificationRequest] + Operator: Optional[OperatorRequest] + SubnetId: SubnetId + Description: Optional[String] + PrivateIpAddress: Optional[String] + Groups: Optional[SecurityGroupIdStringList] + PrivateIpAddresses: Optional[PrivateIpAddressSpecificationList] + SecondaryPrivateIpAddressCount: Optional[Integer] + Ipv6Addresses: Optional[InstanceIpv6AddressList] + Ipv6AddressCount: Optional[Integer] + DryRun: Optional[Boolean] + + +class Ipv6PrefixSpecification(TypedDict, total=False): + Ipv6Prefix: Optional[String] + + +Ipv6PrefixesList = List[Ipv6PrefixSpecification] + + +class NetworkInterfaceAssociation(TypedDict, total=False): + AllocationId: Optional[String] + AssociationId: Optional[String] + IpOwnerId: Optional[String] + PublicDnsName: Optional[String] + PublicIp: Optional[String] + CustomerOwnedIp: Optional[String] + CarrierIp: Optional[String] + + +class NetworkInterfacePrivateIpAddress(TypedDict, total=False): + Association: Optional[NetworkInterfaceAssociation] + Primary: Optional[Boolean] + PrivateDnsName: Optional[String] + PrivateIpAddress: Optional[String] + + +NetworkInterfacePrivateIpAddressList = List[NetworkInterfacePrivateIpAddress] + + +class PublicIpDnsNameOptions(TypedDict, total=False): + DnsHostnameType: Optional[String] + PublicIpv4DnsName: Optional[String] + PublicIpv6DnsName: Optional[String] + PublicDualStackDnsName: Optional[String] + + +class NetworkInterfaceIpv6Address(TypedDict, total=False): + Ipv6Address: Optional[String] + PublicIpv6DnsName: Optional[String] + IsPrimaryIpv6: Optional[Boolean] + + +NetworkInterfaceIpv6AddressesList = List[NetworkInterfaceIpv6Address] + + +class NetworkInterfaceAttachment(TypedDict, total=False): + AttachTime: Optional[DateTime] + AttachmentId: Optional[String] + DeleteOnTermination: Optional[Boolean] + DeviceIndex: Optional[Integer] + NetworkCardIndex: Optional[Integer] + InstanceId: Optional[String] + InstanceOwnerId: Optional[String] + Status: Optional[AttachmentStatus] + EnaSrdSpecification: Optional[AttachmentEnaSrdSpecification] + EnaQueueCount: Optional[Integer] + + +class NetworkInterface(TypedDict, total=False): + Association: Optional[NetworkInterfaceAssociation] + Attachment: Optional[NetworkInterfaceAttachment] + AvailabilityZone: Optional[String] + ConnectionTrackingConfiguration: Optional[ConnectionTrackingConfiguration] + Description: Optional[String] + Groups: Optional[GroupIdentifierList] + InterfaceType: Optional[NetworkInterfaceType] + Ipv6Addresses: Optional[NetworkInterfaceIpv6AddressesList] + MacAddress: Optional[String] + NetworkInterfaceId: Optional[String] + OutpostArn: Optional[String] + OwnerId: Optional[String] + PrivateDnsName: Optional[String] + PublicDnsName: Optional[String] + PublicIpDnsNameOptions: Optional[PublicIpDnsNameOptions] + PrivateIpAddress: Optional[String] + PrivateIpAddresses: Optional[NetworkInterfacePrivateIpAddressList] + Ipv4Prefixes: Optional[Ipv4PrefixesList] + Ipv6Prefixes: Optional[Ipv6PrefixesList] + RequesterId: Optional[String] + RequesterManaged: Optional[Boolean] + SourceDestCheck: Optional[Boolean] + Status: Optional[NetworkInterfaceStatus] + SubnetId: Optional[String] + TagSet: Optional[TagList] + VpcId: Optional[String] + DenyAllIgwTraffic: Optional[Boolean] + Ipv6Native: Optional[Boolean] + Ipv6Address: Optional[String] + Operator: Optional[OperatorResponse] + AssociatedSubnets: Optional[AssociatedSubnetList] + + +class CreateNetworkInterfaceResult(TypedDict, total=False): + NetworkInterface: Optional[NetworkInterface] + ClientToken: Optional[String] + + +class CreatePlacementGroupRequest(ServiceRequest): + PartitionCount: Optional[Integer] + TagSpecifications: Optional[TagSpecificationList] + SpreadLevel: Optional[SpreadLevel] + DryRun: Optional[Boolean] + GroupName: Optional[String] + Strategy: Optional[PlacementStrategy] + + +class PlacementGroup(TypedDict, total=False): + GroupName: Optional[String] + State: Optional[PlacementGroupState] + Strategy: Optional[PlacementStrategy] + PartitionCount: Optional[Integer] + GroupId: Optional[String] + Tags: Optional[TagList] + GroupArn: Optional[String] + SpreadLevel: Optional[SpreadLevel] + + +class CreatePlacementGroupResult(TypedDict, total=False): + PlacementGroup: Optional[PlacementGroup] + + +class CreatePublicIpv4PoolRequest(ServiceRequest): + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + NetworkBorderGroup: Optional[String] + + +class CreatePublicIpv4PoolResult(TypedDict, total=False): + PoolId: Optional[Ipv4PoolEc2Id] + + +class CreateReplaceRootVolumeTaskRequest(ServiceRequest): + InstanceId: InstanceId + SnapshotId: Optional[SnapshotId] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + ImageId: Optional[ImageId] + DeleteReplacedRootVolume: Optional[Boolean] + VolumeInitializationRate: Optional[Long] + + +class ReplaceRootVolumeTask(TypedDict, total=False): + ReplaceRootVolumeTaskId: Optional[ReplaceRootVolumeTaskId] + InstanceId: Optional[String] + TaskState: Optional[ReplaceRootVolumeTaskState] + StartTime: Optional[String] + CompleteTime: Optional[String] + Tags: Optional[TagList] + ImageId: Optional[ImageId] + SnapshotId: Optional[SnapshotId] + DeleteReplacedRootVolume: Optional[Boolean] + + +class CreateReplaceRootVolumeTaskResult(TypedDict, total=False): + ReplaceRootVolumeTask: Optional[ReplaceRootVolumeTask] + + +class PriceScheduleSpecification(TypedDict, total=False): + Term: Optional[Long] + Price: Optional[Double] + CurrencyCode: Optional[CurrencyCodeValues] + + +PriceScheduleSpecificationList = List[PriceScheduleSpecification] + + +class CreateReservedInstancesListingRequest(ServiceRequest): + ReservedInstancesId: ReservationId + InstanceCount: Integer + PriceSchedules: PriceScheduleSpecificationList + ClientToken: String + + +class CreateReservedInstancesListingResult(TypedDict, total=False): + ReservedInstancesListings: Optional[ReservedInstancesListingList] + + +class CreateRestoreImageTaskRequest(ServiceRequest): + Bucket: String + ObjectKey: String + Name: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class CreateRestoreImageTaskResult(TypedDict, total=False): + ImageId: Optional[String] + + +class CreateRouteRequest(ServiceRequest): + DestinationPrefixListId: Optional[PrefixListResourceId] + VpcEndpointId: Optional[VpcEndpointId] + TransitGatewayId: Optional[TransitGatewayId] + LocalGatewayId: Optional[LocalGatewayId] + CarrierGatewayId: Optional[CarrierGatewayId] + CoreNetworkArn: Optional[CoreNetworkArn] + OdbNetworkArn: Optional[OdbNetworkArn] + DryRun: Optional[Boolean] + RouteTableId: RouteTableId + DestinationCidrBlock: Optional[String] + GatewayId: Optional[RouteGatewayId] + DestinationIpv6CidrBlock: Optional[String] + EgressOnlyInternetGatewayId: Optional[EgressOnlyInternetGatewayId] + InstanceId: Optional[InstanceId] + NetworkInterfaceId: Optional[NetworkInterfaceId] + VpcPeeringConnectionId: Optional[VpcPeeringConnectionId] + NatGatewayId: Optional[NatGatewayId] + + +class CreateRouteResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class CreateRouteServerEndpointRequest(ServiceRequest): + RouteServerId: RouteServerId + SubnetId: SubnetId + ClientToken: Optional[String] + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + + +class RouteServerEndpoint(TypedDict, total=False): + RouteServerId: Optional[RouteServerId] + RouteServerEndpointId: Optional[RouteServerEndpointId] + VpcId: Optional[VpcId] + SubnetId: Optional[SubnetId] + EniId: Optional[NetworkInterfaceId] + EniAddress: Optional[String] + State: Optional[RouteServerEndpointState] + FailureReason: Optional[String] + Tags: Optional[TagList] + + +class CreateRouteServerEndpointResult(TypedDict, total=False): + RouteServerEndpoint: Optional[RouteServerEndpoint] + + +class RouteServerBgpOptionsRequest(TypedDict, total=False): + PeerAsn: Long + PeerLivenessDetection: Optional[RouteServerPeerLivenessMode] + + +class CreateRouteServerPeerRequest(ServiceRequest): + RouteServerEndpointId: RouteServerEndpointId + PeerAddress: String + BgpOptions: RouteServerBgpOptionsRequest + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + + +class RouteServerBfdStatus(TypedDict, total=False): + Status: Optional[RouteServerBfdState] + + +class RouteServerBgpStatus(TypedDict, total=False): + Status: Optional[RouteServerBgpState] + + +class RouteServerBgpOptions(TypedDict, total=False): + PeerAsn: Optional[Long] + PeerLivenessDetection: Optional[RouteServerPeerLivenessMode] + + +class RouteServerPeer(TypedDict, total=False): + RouteServerPeerId: Optional[RouteServerPeerId] + RouteServerEndpointId: Optional[RouteServerEndpointId] + RouteServerId: Optional[RouteServerId] + VpcId: Optional[VpcId] + SubnetId: Optional[SubnetId] + State: Optional[RouteServerPeerState] + FailureReason: Optional[String] + EndpointEniId: Optional[NetworkInterfaceId] + EndpointEniAddress: Optional[String] + PeerAddress: Optional[String] + BgpOptions: Optional[RouteServerBgpOptions] + BgpStatus: Optional[RouteServerBgpStatus] + BfdStatus: Optional[RouteServerBfdStatus] + Tags: Optional[TagList] + + +class CreateRouteServerPeerResult(TypedDict, total=False): + RouteServerPeer: Optional[RouteServerPeer] + + +class CreateRouteServerRequest(ServiceRequest): + AmazonSideAsn: Long + ClientToken: Optional[String] + DryRun: Optional[Boolean] + PersistRoutes: Optional[RouteServerPersistRoutesAction] + PersistRoutesDuration: Optional[BoxedLong] + SnsNotificationsEnabled: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + + +class RouteServer(TypedDict, total=False): + RouteServerId: Optional[RouteServerId] + AmazonSideAsn: Optional[Long] + State: Optional[RouteServerState] + Tags: Optional[TagList] + PersistRoutesState: Optional[RouteServerPersistRoutesState] + PersistRoutesDuration: Optional[BoxedLong] + SnsNotificationsEnabled: Optional[Boolean] + SnsTopicArn: Optional[String] + + +class CreateRouteServerResult(TypedDict, total=False): + RouteServer: Optional[RouteServer] + + +class CreateRouteTableRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + VpcId: VpcId + + +class Route(TypedDict, total=False): + DestinationCidrBlock: Optional[String] + DestinationIpv6CidrBlock: Optional[String] + DestinationPrefixListId: Optional[String] + EgressOnlyInternetGatewayId: Optional[String] + GatewayId: Optional[String] + InstanceId: Optional[String] + InstanceOwnerId: Optional[String] + NatGatewayId: Optional[String] + TransitGatewayId: Optional[String] + LocalGatewayId: Optional[String] + CarrierGatewayId: Optional[CarrierGatewayId] + NetworkInterfaceId: Optional[String] + Origin: Optional[RouteOrigin] + State: Optional[RouteState] + VpcPeeringConnectionId: Optional[String] + CoreNetworkArn: Optional[CoreNetworkArn] + OdbNetworkArn: Optional[OdbNetworkArn] + + +RouteList = List[Route] + + +class PropagatingVgw(TypedDict, total=False): + GatewayId: Optional[String] + + +PropagatingVgwList = List[PropagatingVgw] + + +class RouteTableAssociation(TypedDict, total=False): + Main: Optional[Boolean] + RouteTableAssociationId: Optional[String] + RouteTableId: Optional[String] + SubnetId: Optional[String] + GatewayId: Optional[String] + AssociationState: Optional[RouteTableAssociationState] + + +RouteTableAssociationList = List[RouteTableAssociation] + + +class RouteTable(TypedDict, total=False): + Associations: Optional[RouteTableAssociationList] + PropagatingVgws: Optional[PropagatingVgwList] + RouteTableId: Optional[String] + Routes: Optional[RouteList] + Tags: Optional[TagList] + VpcId: Optional[String] + OwnerId: Optional[String] + + +class CreateRouteTableResult(TypedDict, total=False): + RouteTable: Optional[RouteTable] + ClientToken: Optional[String] + + +class CreateSecurityGroupRequest(ServiceRequest): + Description: String + GroupName: String + VpcId: Optional[VpcId] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class CreateSecurityGroupResult(TypedDict, total=False): + GroupId: Optional[String] + Tags: Optional[TagList] + SecurityGroupArn: Optional[String] + + +class CreateSnapshotRequest(ServiceRequest): + Description: Optional[String] + OutpostArn: Optional[String] + VolumeId: VolumeId + TagSpecifications: Optional[TagSpecificationList] + Location: Optional[SnapshotLocationEnum] + DryRun: Optional[Boolean] + + +VolumeIdStringList = List[VolumeId] + + +class InstanceSpecification(TypedDict, total=False): + InstanceId: InstanceIdWithVolumeResolver + ExcludeBootVolume: Optional[Boolean] + ExcludeDataVolumeIds: Optional[VolumeIdStringList] + + +class CreateSnapshotsRequest(ServiceRequest): + Description: Optional[String] + InstanceSpecification: InstanceSpecification + OutpostArn: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + CopyTagsFromSource: Optional[CopyTagsFromSource] + Location: Optional[SnapshotLocationEnum] + + +class SnapshotInfo(TypedDict, total=False): + Description: Optional[String] + Tags: Optional[TagList] + Encrypted: Optional[Boolean] + VolumeId: Optional[String] + State: Optional[SnapshotState] + VolumeSize: Optional[Integer] + StartTime: Optional[MillisecondDateTime] + Progress: Optional[String] + OwnerId: Optional[String] + SnapshotId: Optional[String] + OutpostArn: Optional[String] + SseType: Optional[SSEType] + AvailabilityZone: Optional[String] + + +SnapshotSet = List[SnapshotInfo] + + +class CreateSnapshotsResult(TypedDict, total=False): + Snapshots: Optional[SnapshotSet] + + +class CreateSpotDatafeedSubscriptionRequest(ServiceRequest): + DryRun: Optional[Boolean] + Bucket: String + Prefix: Optional[String] + + +class SpotInstanceStateFault(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +class SpotDatafeedSubscription(TypedDict, total=False): + Bucket: Optional[String] + Fault: Optional[SpotInstanceStateFault] + OwnerId: Optional[String] + Prefix: Optional[String] + State: Optional[DatafeedSubscriptionState] + + +class CreateSpotDatafeedSubscriptionResult(TypedDict, total=False): + SpotDatafeedSubscription: Optional[SpotDatafeedSubscription] + + +class S3ObjectTag(TypedDict, total=False): + Key: Optional[String] + Value: Optional[String] + + +S3ObjectTagList = List[S3ObjectTag] + + +class CreateStoreImageTaskRequest(ServiceRequest): + ImageId: ImageId + Bucket: String + S3ObjectTags: Optional[S3ObjectTagList] + DryRun: Optional[Boolean] + + +class CreateStoreImageTaskResult(TypedDict, total=False): + ObjectKey: Optional[String] + + +class CreateSubnetCidrReservationRequest(ServiceRequest): + SubnetId: SubnetId + Cidr: String + ReservationType: SubnetCidrReservationType + Description: Optional[String] + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + + +class SubnetCidrReservation(TypedDict, total=False): + SubnetCidrReservationId: Optional[SubnetCidrReservationId] + SubnetId: Optional[SubnetId] + Cidr: Optional[String] + ReservationType: Optional[SubnetCidrReservationType] + OwnerId: Optional[String] + Description: Optional[String] + Tags: Optional[TagList] + + +class CreateSubnetCidrReservationResult(TypedDict, total=False): + SubnetCidrReservation: Optional[SubnetCidrReservation] + + +class CreateSubnetRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + AvailabilityZone: Optional[String] + AvailabilityZoneId: Optional[String] + CidrBlock: Optional[String] + Ipv6CidrBlock: Optional[String] + OutpostArn: Optional[String] + VpcId: VpcId + Ipv6Native: Optional[Boolean] + Ipv4IpamPoolId: Optional[IpamPoolId] + Ipv4NetmaskLength: Optional[NetmaskLength] + Ipv6IpamPoolId: Optional[IpamPoolId] + Ipv6NetmaskLength: Optional[NetmaskLength] + DryRun: Optional[Boolean] + + +class CreateSubnetResult(TypedDict, total=False): + Subnet: Optional[Subnet] + + +ResourceIdList = List[TaggableResourceId] + + +class CreateTagsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Resources: ResourceIdList + Tags: TagList + + +class CreateTrafficMirrorFilterRequest(ServiceRequest): + Description: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + + +TrafficMirrorNetworkServiceList = List[TrafficMirrorNetworkService] + + +class TrafficMirrorPortRange(TypedDict, total=False): + FromPort: Optional[Integer] + ToPort: Optional[Integer] + + +class TrafficMirrorFilterRule(TypedDict, total=False): + TrafficMirrorFilterRuleId: Optional[String] + TrafficMirrorFilterId: Optional[String] + TrafficDirection: Optional[TrafficDirection] + RuleNumber: Optional[Integer] + RuleAction: Optional[TrafficMirrorRuleAction] + Protocol: Optional[Integer] + DestinationPortRange: Optional[TrafficMirrorPortRange] + SourcePortRange: Optional[TrafficMirrorPortRange] + DestinationCidrBlock: Optional[String] + SourceCidrBlock: Optional[String] + Description: Optional[String] + Tags: Optional[TagList] + + +TrafficMirrorFilterRuleList = List[TrafficMirrorFilterRule] + + +class TrafficMirrorFilter(TypedDict, total=False): + TrafficMirrorFilterId: Optional[String] + IngressFilterRules: Optional[TrafficMirrorFilterRuleList] + EgressFilterRules: Optional[TrafficMirrorFilterRuleList] + NetworkServices: Optional[TrafficMirrorNetworkServiceList] + Description: Optional[String] + Tags: Optional[TagList] + + +class CreateTrafficMirrorFilterResult(TypedDict, total=False): + TrafficMirrorFilter: Optional[TrafficMirrorFilter] + ClientToken: Optional[String] + + +class TrafficMirrorPortRangeRequest(TypedDict, total=False): + FromPort: Optional[Integer] + ToPort: Optional[Integer] + + +class CreateTrafficMirrorFilterRuleRequest(ServiceRequest): + TrafficMirrorFilterId: TrafficMirrorFilterId + TrafficDirection: TrafficDirection + RuleNumber: Integer + RuleAction: TrafficMirrorRuleAction + DestinationPortRange: Optional[TrafficMirrorPortRangeRequest] + SourcePortRange: Optional[TrafficMirrorPortRangeRequest] + Protocol: Optional[Integer] + DestinationCidrBlock: String + SourceCidrBlock: String + Description: Optional[String] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + + +class CreateTrafficMirrorFilterRuleResult(TypedDict, total=False): + TrafficMirrorFilterRule: Optional[TrafficMirrorFilterRule] + ClientToken: Optional[String] + + +class CreateTrafficMirrorSessionRequest(ServiceRequest): + NetworkInterfaceId: NetworkInterfaceId + TrafficMirrorTargetId: TrafficMirrorTargetId + TrafficMirrorFilterId: TrafficMirrorFilterId + PacketLength: Optional[Integer] + SessionNumber: Integer + VirtualNetworkId: Optional[Integer] + Description: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + + +class TrafficMirrorSession(TypedDict, total=False): + TrafficMirrorSessionId: Optional[String] + TrafficMirrorTargetId: Optional[String] + TrafficMirrorFilterId: Optional[String] + NetworkInterfaceId: Optional[String] + OwnerId: Optional[String] + PacketLength: Optional[Integer] + SessionNumber: Optional[Integer] + VirtualNetworkId: Optional[Integer] + Description: Optional[String] + Tags: Optional[TagList] + + +class CreateTrafficMirrorSessionResult(TypedDict, total=False): + TrafficMirrorSession: Optional[TrafficMirrorSession] + ClientToken: Optional[String] + + +class CreateTrafficMirrorTargetRequest(ServiceRequest): + NetworkInterfaceId: Optional[NetworkInterfaceId] + NetworkLoadBalancerArn: Optional[String] + Description: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + GatewayLoadBalancerEndpointId: Optional[VpcEndpointId] + + +class TrafficMirrorTarget(TypedDict, total=False): + TrafficMirrorTargetId: Optional[String] + NetworkInterfaceId: Optional[String] + NetworkLoadBalancerArn: Optional[String] + Type: Optional[TrafficMirrorTargetType] + Description: Optional[String] + OwnerId: Optional[String] + Tags: Optional[TagList] + GatewayLoadBalancerEndpointId: Optional[String] + + +class CreateTrafficMirrorTargetResult(TypedDict, total=False): + TrafficMirrorTarget: Optional[TrafficMirrorTarget] + ClientToken: Optional[String] + + +InsideCidrBlocksStringList = List[String] + + +class TransitGatewayConnectRequestBgpOptions(TypedDict, total=False): + PeerAsn: Optional[Long] + + +class CreateTransitGatewayConnectPeerRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + TransitGatewayAddress: Optional[String] + PeerAddress: String + BgpOptions: Optional[TransitGatewayConnectRequestBgpOptions] + InsideCidrBlocks: InsideCidrBlocksStringList + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class TransitGatewayAttachmentBgpConfiguration(TypedDict, total=False): + TransitGatewayAsn: Optional[Long] + PeerAsn: Optional[Long] + TransitGatewayAddress: Optional[String] + PeerAddress: Optional[String] + BgpStatus: Optional[BgpStatus] + + +TransitGatewayAttachmentBgpConfigurationList = List[TransitGatewayAttachmentBgpConfiguration] + + +class TransitGatewayConnectPeerConfiguration(TypedDict, total=False): + TransitGatewayAddress: Optional[String] + PeerAddress: Optional[String] + InsideCidrBlocks: Optional[InsideCidrBlocksStringList] + Protocol: Optional[ProtocolValue] + BgpConfigurations: Optional[TransitGatewayAttachmentBgpConfigurationList] + + +class TransitGatewayConnectPeer(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + TransitGatewayConnectPeerId: Optional[TransitGatewayConnectPeerId] + State: Optional[TransitGatewayConnectPeerState] + CreationTime: Optional[DateTime] + ConnectPeerConfiguration: Optional[TransitGatewayConnectPeerConfiguration] + Tags: Optional[TagList] + + +class CreateTransitGatewayConnectPeerResult(TypedDict, total=False): + TransitGatewayConnectPeer: Optional[TransitGatewayConnectPeer] + + +class CreateTransitGatewayConnectRequestOptions(TypedDict, total=False): + Protocol: ProtocolValue + + +class CreateTransitGatewayConnectRequest(ServiceRequest): + TransportTransitGatewayAttachmentId: TransitGatewayAttachmentId + Options: CreateTransitGatewayConnectRequestOptions + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class TransitGatewayConnectOptions(TypedDict, total=False): + Protocol: Optional[ProtocolValue] + + +class TransitGatewayConnect(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + TransportTransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + TransitGatewayId: Optional[TransitGatewayId] + State: Optional[TransitGatewayAttachmentState] + CreationTime: Optional[DateTime] + Options: Optional[TransitGatewayConnectOptions] + Tags: Optional[TagList] + + +class CreateTransitGatewayConnectResult(TypedDict, total=False): + TransitGatewayConnect: Optional[TransitGatewayConnect] + + +class CreateTransitGatewayMulticastDomainRequestOptions(TypedDict, total=False): + Igmpv2Support: Optional[Igmpv2SupportValue] + StaticSourcesSupport: Optional[StaticSourcesSupportValue] + AutoAcceptSharedAssociations: Optional[AutoAcceptSharedAssociationsValue] + + +class CreateTransitGatewayMulticastDomainRequest(ServiceRequest): + TransitGatewayId: TransitGatewayId + Options: Optional[CreateTransitGatewayMulticastDomainRequestOptions] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class TransitGatewayMulticastDomainOptions(TypedDict, total=False): + Igmpv2Support: Optional[Igmpv2SupportValue] + StaticSourcesSupport: Optional[StaticSourcesSupportValue] + AutoAcceptSharedAssociations: Optional[AutoAcceptSharedAssociationsValue] + + +class TransitGatewayMulticastDomain(TypedDict, total=False): + TransitGatewayMulticastDomainId: Optional[String] + TransitGatewayId: Optional[String] + TransitGatewayMulticastDomainArn: Optional[String] + OwnerId: Optional[String] + Options: Optional[TransitGatewayMulticastDomainOptions] + State: Optional[TransitGatewayMulticastDomainState] + CreationTime: Optional[DateTime] + Tags: Optional[TagList] + + +class CreateTransitGatewayMulticastDomainResult(TypedDict, total=False): + TransitGatewayMulticastDomain: Optional[TransitGatewayMulticastDomain] + + +class CreateTransitGatewayPeeringAttachmentRequestOptions(TypedDict, total=False): + DynamicRouting: Optional[DynamicRoutingValue] + + +class CreateTransitGatewayPeeringAttachmentRequest(ServiceRequest): + TransitGatewayId: TransitGatewayId + PeerTransitGatewayId: TransitAssociationGatewayId + PeerAccountId: String + PeerRegion: String + Options: Optional[CreateTransitGatewayPeeringAttachmentRequestOptions] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class CreateTransitGatewayPeeringAttachmentResult(TypedDict, total=False): + TransitGatewayPeeringAttachment: Optional[TransitGatewayPeeringAttachment] + + +class CreateTransitGatewayPolicyTableRequest(ServiceRequest): + TransitGatewayId: TransitGatewayId + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class TransitGatewayPolicyTable(TypedDict, total=False): + TransitGatewayPolicyTableId: Optional[TransitGatewayPolicyTableId] + TransitGatewayId: Optional[TransitGatewayId] + State: Optional[TransitGatewayPolicyTableState] + CreationTime: Optional[DateTime] + Tags: Optional[TagList] + + +class CreateTransitGatewayPolicyTableResult(TypedDict, total=False): + TransitGatewayPolicyTable: Optional[TransitGatewayPolicyTable] + + +class CreateTransitGatewayPrefixListReferenceRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + PrefixListId: PrefixListResourceId + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + Blackhole: Optional[Boolean] + DryRun: Optional[Boolean] + + +class TransitGatewayPrefixListAttachment(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + ResourceId: Optional[String] + + +class TransitGatewayPrefixListReference(TypedDict, total=False): + TransitGatewayRouteTableId: Optional[TransitGatewayRouteTableId] + PrefixListId: Optional[PrefixListResourceId] + PrefixListOwnerId: Optional[String] + State: Optional[TransitGatewayPrefixListReferenceState] + Blackhole: Optional[Boolean] + TransitGatewayAttachment: Optional[TransitGatewayPrefixListAttachment] + + +class CreateTransitGatewayPrefixListReferenceResult(TypedDict, total=False): + TransitGatewayPrefixListReference: Optional[TransitGatewayPrefixListReference] + + +TransitGatewayCidrBlockStringList = List[String] + + +class TransitGatewayRequestOptions(TypedDict, total=False): + AmazonSideAsn: Optional[Long] + AutoAcceptSharedAttachments: Optional[AutoAcceptSharedAttachmentsValue] + DefaultRouteTableAssociation: Optional[DefaultRouteTableAssociationValue] + DefaultRouteTablePropagation: Optional[DefaultRouteTablePropagationValue] + VpnEcmpSupport: Optional[VpnEcmpSupportValue] + DnsSupport: Optional[DnsSupportValue] + SecurityGroupReferencingSupport: Optional[SecurityGroupReferencingSupportValue] + MulticastSupport: Optional[MulticastSupportValue] + TransitGatewayCidrBlocks: Optional[TransitGatewayCidrBlockStringList] + + +class CreateTransitGatewayRequest(ServiceRequest): + Description: Optional[String] + Options: Optional[TransitGatewayRequestOptions] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class TransitGatewayOptions(TypedDict, total=False): + AmazonSideAsn: Optional[Long] + TransitGatewayCidrBlocks: Optional[ValueStringList] + AutoAcceptSharedAttachments: Optional[AutoAcceptSharedAttachmentsValue] + DefaultRouteTableAssociation: Optional[DefaultRouteTableAssociationValue] + AssociationDefaultRouteTableId: Optional[String] + DefaultRouteTablePropagation: Optional[DefaultRouteTablePropagationValue] + PropagationDefaultRouteTableId: Optional[String] + VpnEcmpSupport: Optional[VpnEcmpSupportValue] + DnsSupport: Optional[DnsSupportValue] + SecurityGroupReferencingSupport: Optional[SecurityGroupReferencingSupportValue] + MulticastSupport: Optional[MulticastSupportValue] + + +class TransitGateway(TypedDict, total=False): + TransitGatewayId: Optional[String] + TransitGatewayArn: Optional[String] + State: Optional[TransitGatewayState] + OwnerId: Optional[String] + Description: Optional[String] + CreationTime: Optional[DateTime] + Options: Optional[TransitGatewayOptions] + Tags: Optional[TagList] + + +class CreateTransitGatewayResult(TypedDict, total=False): + TransitGateway: Optional[TransitGateway] + + +class CreateTransitGatewayRouteRequest(ServiceRequest): + DestinationCidrBlock: String + TransitGatewayRouteTableId: TransitGatewayRouteTableId + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + Blackhole: Optional[Boolean] + DryRun: Optional[Boolean] + + +class TransitGatewayRouteAttachment(TypedDict, total=False): + ResourceId: Optional[String] + TransitGatewayAttachmentId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + + +TransitGatewayRouteAttachmentList = List[TransitGatewayRouteAttachment] + + +class TransitGatewayRoute(TypedDict, total=False): + DestinationCidrBlock: Optional[String] + PrefixListId: Optional[PrefixListResourceId] + TransitGatewayRouteTableAnnouncementId: Optional[TransitGatewayRouteTableAnnouncementId] + TransitGatewayAttachments: Optional[TransitGatewayRouteAttachmentList] + Type: Optional[TransitGatewayRouteType] + State: Optional[TransitGatewayRouteState] + + +class CreateTransitGatewayRouteResult(TypedDict, total=False): + Route: Optional[TransitGatewayRoute] + + +class CreateTransitGatewayRouteTableAnnouncementRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + PeeringAttachmentId: TransitGatewayAttachmentId + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class TransitGatewayRouteTableAnnouncement(TypedDict, total=False): + TransitGatewayRouteTableAnnouncementId: Optional[TransitGatewayRouteTableAnnouncementId] + TransitGatewayId: Optional[TransitGatewayId] + CoreNetworkId: Optional[String] + PeerTransitGatewayId: Optional[TransitGatewayId] + PeerCoreNetworkId: Optional[String] + PeeringAttachmentId: Optional[TransitGatewayAttachmentId] + AnnouncementDirection: Optional[TransitGatewayRouteTableAnnouncementDirection] + TransitGatewayRouteTableId: Optional[TransitGatewayRouteTableId] + State: Optional[TransitGatewayRouteTableAnnouncementState] + CreationTime: Optional[DateTime] + Tags: Optional[TagList] + + +class CreateTransitGatewayRouteTableAnnouncementResult(TypedDict, total=False): + TransitGatewayRouteTableAnnouncement: Optional[TransitGatewayRouteTableAnnouncement] + + +class CreateTransitGatewayRouteTableRequest(ServiceRequest): + TransitGatewayId: TransitGatewayId + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class TransitGatewayRouteTable(TypedDict, total=False): + TransitGatewayRouteTableId: Optional[String] + TransitGatewayId: Optional[String] + State: Optional[TransitGatewayRouteTableState] + DefaultAssociationRouteTable: Optional[Boolean] + DefaultPropagationRouteTable: Optional[Boolean] + CreationTime: Optional[DateTime] + Tags: Optional[TagList] + + +class CreateTransitGatewayRouteTableResult(TypedDict, total=False): + TransitGatewayRouteTable: Optional[TransitGatewayRouteTable] + + +class CreateTransitGatewayVpcAttachmentRequestOptions(TypedDict, total=False): + DnsSupport: Optional[DnsSupportValue] + SecurityGroupReferencingSupport: Optional[SecurityGroupReferencingSupportValue] + Ipv6Support: Optional[Ipv6SupportValue] + ApplianceModeSupport: Optional[ApplianceModeSupportValue] + + +class CreateTransitGatewayVpcAttachmentRequest(ServiceRequest): + TransitGatewayId: TransitGatewayId + VpcId: VpcId + SubnetIds: TransitGatewaySubnetIdList + Options: Optional[CreateTransitGatewayVpcAttachmentRequestOptions] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + + +class CreateTransitGatewayVpcAttachmentResult(TypedDict, total=False): + TransitGatewayVpcAttachment: Optional[TransitGatewayVpcAttachment] + + +class CreateVerifiedAccessEndpointPortRange(TypedDict, total=False): + FromPort: Optional[VerifiedAccessEndpointPortNumber] + ToPort: Optional[VerifiedAccessEndpointPortNumber] + + +CreateVerifiedAccessEndpointPortRangeList = List[CreateVerifiedAccessEndpointPortRange] +CreateVerifiedAccessEndpointSubnetIdList = List[SubnetId] + + +class CreateVerifiedAccessEndpointCidrOptions(TypedDict, total=False): + Protocol: Optional[VerifiedAccessEndpointProtocol] + SubnetIds: Optional[CreateVerifiedAccessEndpointSubnetIdList] + Cidr: Optional[String] + PortRanges: Optional[CreateVerifiedAccessEndpointPortRangeList] + + +class CreateVerifiedAccessEndpointEniOptions(TypedDict, total=False): + NetworkInterfaceId: Optional[NetworkInterfaceId] + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + PortRanges: Optional[CreateVerifiedAccessEndpointPortRangeList] + + +class CreateVerifiedAccessEndpointLoadBalancerOptions(TypedDict, total=False): + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + LoadBalancerArn: Optional[LoadBalancerArn] + SubnetIds: Optional[CreateVerifiedAccessEndpointSubnetIdList] + PortRanges: Optional[CreateVerifiedAccessEndpointPortRangeList] + + +class CreateVerifiedAccessEndpointRdsOptions(TypedDict, total=False): + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + RdsDbInstanceArn: Optional[RdsDbInstanceArn] + RdsDbClusterArn: Optional[RdsDbClusterArn] + RdsDbProxyArn: Optional[RdsDbProxyArn] + RdsEndpoint: Optional[String] + SubnetIds: Optional[CreateVerifiedAccessEndpointSubnetIdList] + + +class VerifiedAccessSseSpecificationRequest(TypedDict, total=False): + CustomerManagedKeyEnabled: Optional[Boolean] + KmsKeyArn: Optional[KmsKeyArn] + + +SecurityGroupIdList = List[SecurityGroupId] + + +class CreateVerifiedAccessEndpointRequest(ServiceRequest): + VerifiedAccessGroupId: VerifiedAccessGroupId + EndpointType: VerifiedAccessEndpointType + AttachmentType: VerifiedAccessEndpointAttachmentType + DomainCertificateArn: Optional[CertificateArn] + ApplicationDomain: Optional[String] + EndpointDomainPrefix: Optional[String] + SecurityGroupIds: Optional[SecurityGroupIdList] + LoadBalancerOptions: Optional[CreateVerifiedAccessEndpointLoadBalancerOptions] + NetworkInterfaceOptions: Optional[CreateVerifiedAccessEndpointEniOptions] + Description: Optional[String] + PolicyDocument: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + SseSpecification: Optional[VerifiedAccessSseSpecificationRequest] + RdsOptions: Optional[CreateVerifiedAccessEndpointRdsOptions] + CidrOptions: Optional[CreateVerifiedAccessEndpointCidrOptions] + + +VerifiedAccessEndpointSubnetIdList = List[SubnetId] + + +class VerifiedAccessEndpointPortRange(TypedDict, total=False): + FromPort: Optional[VerifiedAccessEndpointPortNumber] + ToPort: Optional[VerifiedAccessEndpointPortNumber] + + +VerifiedAccessEndpointPortRangeList = List[VerifiedAccessEndpointPortRange] + + +class VerifiedAccessEndpointCidrOptions(TypedDict, total=False): + Cidr: Optional[String] + PortRanges: Optional[VerifiedAccessEndpointPortRangeList] + Protocol: Optional[VerifiedAccessEndpointProtocol] + SubnetIds: Optional[VerifiedAccessEndpointSubnetIdList] + + +class VerifiedAccessEndpointRdsOptions(TypedDict, total=False): + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + RdsDbInstanceArn: Optional[String] + RdsDbClusterArn: Optional[String] + RdsDbProxyArn: Optional[String] + RdsEndpoint: Optional[String] + SubnetIds: Optional[VerifiedAccessEndpointSubnetIdList] + + +class VerifiedAccessEndpointStatus(TypedDict, total=False): + Code: Optional[VerifiedAccessEndpointStatusCode] + Message: Optional[String] + + +class VerifiedAccessEndpointEniOptions(TypedDict, total=False): + NetworkInterfaceId: Optional[NetworkInterfaceId] + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + PortRanges: Optional[VerifiedAccessEndpointPortRangeList] + + +class VerifiedAccessEndpointLoadBalancerOptions(TypedDict, total=False): + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + LoadBalancerArn: Optional[String] + SubnetIds: Optional[VerifiedAccessEndpointSubnetIdList] + PortRanges: Optional[VerifiedAccessEndpointPortRangeList] + + +class VerifiedAccessEndpoint(TypedDict, total=False): + VerifiedAccessInstanceId: Optional[String] + VerifiedAccessGroupId: Optional[String] + VerifiedAccessEndpointId: Optional[String] + ApplicationDomain: Optional[String] + EndpointType: Optional[VerifiedAccessEndpointType] + AttachmentType: Optional[VerifiedAccessEndpointAttachmentType] + DomainCertificateArn: Optional[String] + EndpointDomain: Optional[String] + DeviceValidationDomain: Optional[String] + SecurityGroupIds: Optional[SecurityGroupIdList] + LoadBalancerOptions: Optional[VerifiedAccessEndpointLoadBalancerOptions] + NetworkInterfaceOptions: Optional[VerifiedAccessEndpointEniOptions] + Status: Optional[VerifiedAccessEndpointStatus] + Description: Optional[String] + CreationTime: Optional[String] + LastUpdatedTime: Optional[String] + DeletionTime: Optional[String] + Tags: Optional[TagList] + SseSpecification: Optional[VerifiedAccessSseSpecificationResponse] + RdsOptions: Optional[VerifiedAccessEndpointRdsOptions] + CidrOptions: Optional[VerifiedAccessEndpointCidrOptions] + + +class CreateVerifiedAccessEndpointResult(TypedDict, total=False): + VerifiedAccessEndpoint: Optional[VerifiedAccessEndpoint] + + +class CreateVerifiedAccessGroupRequest(ServiceRequest): + VerifiedAccessInstanceId: VerifiedAccessInstanceId + Description: Optional[String] + PolicyDocument: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + SseSpecification: Optional[VerifiedAccessSseSpecificationRequest] + + +class VerifiedAccessGroup(TypedDict, total=False): + VerifiedAccessGroupId: Optional[String] + VerifiedAccessInstanceId: Optional[String] + Description: Optional[String] + Owner: Optional[String] + VerifiedAccessGroupArn: Optional[String] + CreationTime: Optional[String] + LastUpdatedTime: Optional[String] + DeletionTime: Optional[String] + Tags: Optional[TagList] + SseSpecification: Optional[VerifiedAccessSseSpecificationResponse] + + +class CreateVerifiedAccessGroupResult(TypedDict, total=False): + VerifiedAccessGroup: Optional[VerifiedAccessGroup] + + +class CreateVerifiedAccessInstanceRequest(ServiceRequest): + Description: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + FIPSEnabled: Optional[Boolean] + CidrEndpointsCustomSubDomain: Optional[String] + + +class CreateVerifiedAccessInstanceResult(TypedDict, total=False): + VerifiedAccessInstance: Optional[VerifiedAccessInstance] + + +class CreateVerifiedAccessNativeApplicationOidcOptions(TypedDict, total=False): + PublicSigningKeyEndpoint: Optional[String] + Issuer: Optional[String] + AuthorizationEndpoint: Optional[String] + TokenEndpoint: Optional[String] + UserInfoEndpoint: Optional[String] + ClientId: Optional[String] + ClientSecret: Optional[ClientSecretType] + Scope: Optional[String] + + +class CreateVerifiedAccessTrustProviderDeviceOptions(TypedDict, total=False): + TenantId: Optional[String] + PublicSigningKeyUrl: Optional[String] + + +class CreateVerifiedAccessTrustProviderOidcOptions(TypedDict, total=False): + Issuer: Optional[String] + AuthorizationEndpoint: Optional[String] + TokenEndpoint: Optional[String] + UserInfoEndpoint: Optional[String] + ClientId: Optional[String] + ClientSecret: Optional[ClientSecretType] + Scope: Optional[String] + + +class CreateVerifiedAccessTrustProviderRequest(ServiceRequest): + TrustProviderType: TrustProviderType + UserTrustProviderType: Optional[UserTrustProviderType] + DeviceTrustProviderType: Optional[DeviceTrustProviderType] + OidcOptions: Optional[CreateVerifiedAccessTrustProviderOidcOptions] + DeviceOptions: Optional[CreateVerifiedAccessTrustProviderDeviceOptions] + PolicyReferenceName: String + Description: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + SseSpecification: Optional[VerifiedAccessSseSpecificationRequest] + NativeApplicationOidcOptions: Optional[CreateVerifiedAccessNativeApplicationOidcOptions] + + +class CreateVerifiedAccessTrustProviderResult(TypedDict, total=False): + VerifiedAccessTrustProvider: Optional[VerifiedAccessTrustProvider] + + +class CreateVolumePermission(TypedDict, total=False): + UserId: Optional[String] + Group: Optional[PermissionGroup] + + +CreateVolumePermissionList = List[CreateVolumePermission] + + +class CreateVolumePermissionModifications(TypedDict, total=False): + Add: Optional[CreateVolumePermissionList] + Remove: Optional[CreateVolumePermissionList] + + +class CreateVolumeRequest(ServiceRequest): + AvailabilityZone: AvailabilityZoneName + Encrypted: Optional[Boolean] + Iops: Optional[Integer] + KmsKeyId: Optional[KmsKeyId] + OutpostArn: Optional[String] + Size: Optional[Integer] + SnapshotId: Optional[SnapshotId] + VolumeType: Optional[VolumeType] + TagSpecifications: Optional[TagSpecificationList] + MultiAttachEnabled: Optional[Boolean] + Throughput: Optional[Integer] + ClientToken: Optional[String] + VolumeInitializationRate: Optional[Integer] + Operator: Optional[OperatorRequest] + DryRun: Optional[Boolean] + + +class CreateVpcBlockPublicAccessExclusionRequest(ServiceRequest): + DryRun: Optional[Boolean] + SubnetId: Optional[SubnetId] + VpcId: Optional[VpcId] + InternetGatewayExclusionMode: InternetGatewayExclusionMode + TagSpecifications: Optional[TagSpecificationList] + + +class VpcBlockPublicAccessExclusion(TypedDict, total=False): + ExclusionId: Optional[VpcBlockPublicAccessExclusionId] + InternetGatewayExclusionMode: Optional[InternetGatewayExclusionMode] + ResourceArn: Optional[ResourceArn] + State: Optional[VpcBlockPublicAccessExclusionState] + Reason: Optional[String] + CreationTimestamp: Optional[MillisecondDateTime] + LastUpdateTimestamp: Optional[MillisecondDateTime] + DeletionTimestamp: Optional[MillisecondDateTime] + Tags: Optional[TagList] + + +class CreateVpcBlockPublicAccessExclusionResult(TypedDict, total=False): + VpcBlockPublicAccessExclusion: Optional[VpcBlockPublicAccessExclusion] + + +class CreateVpcEndpointConnectionNotificationRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: Optional[VpcEndpointServiceId] + VpcEndpointId: Optional[VpcEndpointId] + ConnectionNotificationArn: String + ConnectionEvents: ValueStringList + ClientToken: Optional[String] + + +class CreateVpcEndpointConnectionNotificationResult(TypedDict, total=False): + ConnectionNotification: Optional[ConnectionNotification] + ClientToken: Optional[String] + + +class SubnetConfiguration(TypedDict, total=False): + SubnetId: Optional[SubnetId] + Ipv4: Optional[String] + Ipv6: Optional[String] + + +SubnetConfigurationsList = List[SubnetConfiguration] + + +class DnsOptionsSpecification(TypedDict, total=False): + DnsRecordIpType: Optional[DnsRecordIpType] + PrivateDnsOnlyForInboundResolverEndpoint: Optional[Boolean] + + +VpcEndpointSecurityGroupIdList = List[SecurityGroupId] +VpcEndpointSubnetIdList = List[SubnetId] +VpcEndpointRouteTableIdList = List[RouteTableId] + + +class CreateVpcEndpointRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcEndpointType: Optional[VpcEndpointType] + VpcId: VpcId + ServiceName: Optional[String] + PolicyDocument: Optional[String] + RouteTableIds: Optional[VpcEndpointRouteTableIdList] + SubnetIds: Optional[VpcEndpointSubnetIdList] + SecurityGroupIds: Optional[VpcEndpointSecurityGroupIdList] + IpAddressType: Optional[IpAddressType] + DnsOptions: Optional[DnsOptionsSpecification] + ClientToken: Optional[String] + PrivateDnsEnabled: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + SubnetConfigurations: Optional[SubnetConfigurationsList] + ServiceNetworkArn: Optional[ServiceNetworkArn] + ResourceConfigurationArn: Optional[ResourceConfigurationArn] + ServiceRegion: Optional[String] + + +class SubnetIpPrefixes(TypedDict, total=False): + SubnetId: Optional[String] + IpPrefixes: Optional[ValueStringList] + + +SubnetIpPrefixesList = List[SubnetIpPrefixes] + + +class LastError(TypedDict, total=False): + Message: Optional[String] + Code: Optional[String] + + +class DnsEntry(TypedDict, total=False): + DnsName: Optional[String] + HostedZoneId: Optional[String] + + +DnsEntrySet = List[DnsEntry] + + +class DnsOptions(TypedDict, total=False): + DnsRecordIpType: Optional[DnsRecordIpType] + PrivateDnsOnlyForInboundResolverEndpoint: Optional[Boolean] + + +class SecurityGroupIdentifier(TypedDict, total=False): + GroupId: Optional[String] + GroupName: Optional[String] + + +GroupIdentifierSet = List[SecurityGroupIdentifier] + + +class VpcEndpoint(TypedDict, total=False): + VpcEndpointId: Optional[String] + VpcEndpointType: Optional[VpcEndpointType] + VpcId: Optional[String] + ServiceName: Optional[String] + State: Optional[State] + PolicyDocument: Optional[String] + RouteTableIds: Optional[ValueStringList] + SubnetIds: Optional[ValueStringList] + Groups: Optional[GroupIdentifierSet] + IpAddressType: Optional[IpAddressType] + DnsOptions: Optional[DnsOptions] + PrivateDnsEnabled: Optional[Boolean] + RequesterManaged: Optional[Boolean] + NetworkInterfaceIds: Optional[ValueStringList] + DnsEntries: Optional[DnsEntrySet] + CreationTimestamp: Optional[MillisecondDateTime] + Tags: Optional[TagList] + OwnerId: Optional[String] + LastError: Optional[LastError] + Ipv4Prefixes: Optional[SubnetIpPrefixesList] + Ipv6Prefixes: Optional[SubnetIpPrefixesList] + FailureReason: Optional[String] + ServiceNetworkArn: Optional[ServiceNetworkArn] + ResourceConfigurationArn: Optional[ResourceConfigurationArn] + ServiceRegion: Optional[String] + + +class CreateVpcEndpointResult(TypedDict, total=False): + VpcEndpoint: Optional[VpcEndpoint] + ClientToken: Optional[String] + + +class CreateVpcEndpointServiceConfigurationRequest(ServiceRequest): + DryRun: Optional[Boolean] + AcceptanceRequired: Optional[Boolean] + PrivateDnsName: Optional[String] + NetworkLoadBalancerArns: Optional[ValueStringList] + GatewayLoadBalancerArns: Optional[ValueStringList] + SupportedIpAddressTypes: Optional[ValueStringList] + SupportedRegions: Optional[ValueStringList] + ClientToken: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + + +class SupportedRegionDetail(TypedDict, total=False): + Region: Optional[String] + ServiceState: Optional[String] + + +SupportedRegionSet = List[SupportedRegionDetail] + + +class PrivateDnsNameConfiguration(TypedDict, total=False): + State: Optional[DnsNameState] + Type: Optional[String] + Value: Optional[String] + Name: Optional[String] + + +SupportedIpAddressTypes = List[ServiceConnectivityType] + + +class ServiceTypeDetail(TypedDict, total=False): + ServiceType: Optional[ServiceType] + + +ServiceTypeDetailSet = List[ServiceTypeDetail] + + +class ServiceConfiguration(TypedDict, total=False): + ServiceType: Optional[ServiceTypeDetailSet] + ServiceId: Optional[String] + ServiceName: Optional[String] + ServiceState: Optional[ServiceState] + AvailabilityZones: Optional[ValueStringList] + AcceptanceRequired: Optional[Boolean] + ManagesVpcEndpoints: Optional[Boolean] + NetworkLoadBalancerArns: Optional[ValueStringList] + GatewayLoadBalancerArns: Optional[ValueStringList] + SupportedIpAddressTypes: Optional[SupportedIpAddressTypes] + BaseEndpointDnsNames: Optional[ValueStringList] + PrivateDnsName: Optional[String] + PrivateDnsNameConfiguration: Optional[PrivateDnsNameConfiguration] + PayerResponsibility: Optional[PayerResponsibility] + Tags: Optional[TagList] + SupportedRegions: Optional[SupportedRegionSet] + RemoteAccessEnabled: Optional[Boolean] + + +class CreateVpcEndpointServiceConfigurationResult(TypedDict, total=False): + ServiceConfiguration: Optional[ServiceConfiguration] + ClientToken: Optional[String] + + +class CreateVpcPeeringConnectionRequest(ServiceRequest): + PeerRegion: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + VpcId: VpcId + PeerVpcId: Optional[String] + PeerOwnerId: Optional[String] + + +class CreateVpcPeeringConnectionResult(TypedDict, total=False): + VpcPeeringConnection: Optional[VpcPeeringConnection] + + +class CreateVpcRequest(ServiceRequest): + CidrBlock: Optional[String] + Ipv6Pool: Optional[Ipv6PoolEc2Id] + Ipv6CidrBlock: Optional[String] + Ipv4IpamPoolId: Optional[IpamPoolId] + Ipv4NetmaskLength: Optional[NetmaskLength] + Ipv6IpamPoolId: Optional[IpamPoolId] + Ipv6NetmaskLength: Optional[NetmaskLength] + Ipv6CidrBlockNetworkBorderGroup: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + InstanceTenancy: Optional[Tenancy] + AmazonProvidedIpv6CidrBlock: Optional[Boolean] + + +class CreateVpcResult(TypedDict, total=False): + Vpc: Optional[Vpc] + + +class VpnTunnelLogOptionsSpecification(TypedDict, total=False): + CloudWatchLogOptions: Optional[CloudWatchLogOptionsSpecification] + + +class IKEVersionsRequestListValue(TypedDict, total=False): + Value: Optional[String] + + +IKEVersionsRequestList = List[IKEVersionsRequestListValue] + + +class Phase2DHGroupNumbersRequestListValue(TypedDict, total=False): + Value: Optional[Integer] + + +Phase2DHGroupNumbersRequestList = List[Phase2DHGroupNumbersRequestListValue] + + +class Phase1DHGroupNumbersRequestListValue(TypedDict, total=False): + Value: Optional[Integer] + + +Phase1DHGroupNumbersRequestList = List[Phase1DHGroupNumbersRequestListValue] + + +class Phase2IntegrityAlgorithmsRequestListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase2IntegrityAlgorithmsRequestList = List[Phase2IntegrityAlgorithmsRequestListValue] + + +class Phase1IntegrityAlgorithmsRequestListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase1IntegrityAlgorithmsRequestList = List[Phase1IntegrityAlgorithmsRequestListValue] + + +class Phase2EncryptionAlgorithmsRequestListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase2EncryptionAlgorithmsRequestList = List[Phase2EncryptionAlgorithmsRequestListValue] + + +class Phase1EncryptionAlgorithmsRequestListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase1EncryptionAlgorithmsRequestList = List[Phase1EncryptionAlgorithmsRequestListValue] + + +class VpnTunnelOptionsSpecification(TypedDict, total=False): + TunnelInsideCidr: Optional[String] + TunnelInsideIpv6Cidr: Optional[String] + PreSharedKey: Optional[preSharedKey] + Phase1LifetimeSeconds: Optional[Integer] + Phase2LifetimeSeconds: Optional[Integer] + RekeyMarginTimeSeconds: Optional[Integer] + RekeyFuzzPercentage: Optional[Integer] + ReplayWindowSize: Optional[Integer] + DPDTimeoutSeconds: Optional[Integer] + DPDTimeoutAction: Optional[String] + Phase1EncryptionAlgorithms: Optional[Phase1EncryptionAlgorithmsRequestList] + Phase2EncryptionAlgorithms: Optional[Phase2EncryptionAlgorithmsRequestList] + Phase1IntegrityAlgorithms: Optional[Phase1IntegrityAlgorithmsRequestList] + Phase2IntegrityAlgorithms: Optional[Phase2IntegrityAlgorithmsRequestList] + Phase1DHGroupNumbers: Optional[Phase1DHGroupNumbersRequestList] + Phase2DHGroupNumbers: Optional[Phase2DHGroupNumbersRequestList] + IKEVersions: Optional[IKEVersionsRequestList] + StartupAction: Optional[String] + LogOptions: Optional[VpnTunnelLogOptionsSpecification] + EnableTunnelLifecycleControl: Optional[Boolean] + + +VpnTunnelOptionsSpecificationsList = List[VpnTunnelOptionsSpecification] + + +class VpnConnectionOptionsSpecification(TypedDict, total=False): + EnableAcceleration: Optional[Boolean] + TunnelInsideIpVersion: Optional[TunnelInsideIpVersion] + TunnelOptions: Optional[VpnTunnelOptionsSpecificationsList] + LocalIpv4NetworkCidr: Optional[String] + RemoteIpv4NetworkCidr: Optional[String] + LocalIpv6NetworkCidr: Optional[String] + RemoteIpv6NetworkCidr: Optional[String] + OutsideIpAddressType: Optional[String] + TransportTransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + StaticRoutesOnly: Optional[Boolean] + + +class CreateVpnConnectionRequest(ServiceRequest): + CustomerGatewayId: CustomerGatewayId + Type: String + VpnGatewayId: Optional[VpnGatewayId] + TransitGatewayId: Optional[TransitGatewayId] + TagSpecifications: Optional[TagSpecificationList] + PreSharedKeyStorage: Optional[String] + DryRun: Optional[Boolean] + Options: Optional[VpnConnectionOptionsSpecification] + + +class VgwTelemetry(TypedDict, total=False): + AcceptedRouteCount: Optional[Integer] + LastStatusChange: Optional[DateTime] + OutsideIpAddress: Optional[String] + Status: Optional[TelemetryStatus] + StatusMessage: Optional[String] + CertificateArn: Optional[String] + + +VgwTelemetryList = List[VgwTelemetry] + + +class VpnStaticRoute(TypedDict, total=False): + DestinationCidrBlock: Optional[String] + Source: Optional[VpnStaticRouteSource] + State: Optional[VpnState] + + +VpnStaticRouteList = List[VpnStaticRoute] + + +class VpnTunnelLogOptions(TypedDict, total=False): + CloudWatchLogOptions: Optional[CloudWatchLogOptions] + + +class IKEVersionsListValue(TypedDict, total=False): + Value: Optional[String] + + +IKEVersionsList = List[IKEVersionsListValue] + + +class Phase2DHGroupNumbersListValue(TypedDict, total=False): + Value: Optional[Integer] + + +Phase2DHGroupNumbersList = List[Phase2DHGroupNumbersListValue] + + +class Phase1DHGroupNumbersListValue(TypedDict, total=False): + Value: Optional[Integer] + + +Phase1DHGroupNumbersList = List[Phase1DHGroupNumbersListValue] + + +class Phase2IntegrityAlgorithmsListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase2IntegrityAlgorithmsList = List[Phase2IntegrityAlgorithmsListValue] + + +class Phase1IntegrityAlgorithmsListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase1IntegrityAlgorithmsList = List[Phase1IntegrityAlgorithmsListValue] + + +class Phase2EncryptionAlgorithmsListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase2EncryptionAlgorithmsList = List[Phase2EncryptionAlgorithmsListValue] + + +class Phase1EncryptionAlgorithmsListValue(TypedDict, total=False): + Value: Optional[String] + + +Phase1EncryptionAlgorithmsList = List[Phase1EncryptionAlgorithmsListValue] + + +class TunnelOption(TypedDict, total=False): + OutsideIpAddress: Optional[String] + TunnelInsideCidr: Optional[String] + TunnelInsideIpv6Cidr: Optional[String] + PreSharedKey: Optional[preSharedKey] + Phase1LifetimeSeconds: Optional[Integer] + Phase2LifetimeSeconds: Optional[Integer] + RekeyMarginTimeSeconds: Optional[Integer] + RekeyFuzzPercentage: Optional[Integer] + ReplayWindowSize: Optional[Integer] + DpdTimeoutSeconds: Optional[Integer] + DpdTimeoutAction: Optional[String] + Phase1EncryptionAlgorithms: Optional[Phase1EncryptionAlgorithmsList] + Phase2EncryptionAlgorithms: Optional[Phase2EncryptionAlgorithmsList] + Phase1IntegrityAlgorithms: Optional[Phase1IntegrityAlgorithmsList] + Phase2IntegrityAlgorithms: Optional[Phase2IntegrityAlgorithmsList] + Phase1DHGroupNumbers: Optional[Phase1DHGroupNumbersList] + Phase2DHGroupNumbers: Optional[Phase2DHGroupNumbersList] + IkeVersions: Optional[IKEVersionsList] + StartupAction: Optional[String] + LogOptions: Optional[VpnTunnelLogOptions] + EnableTunnelLifecycleControl: Optional[Boolean] + + +TunnelOptionsList = List[TunnelOption] + + +class VpnConnectionOptions(TypedDict, total=False): + EnableAcceleration: Optional[Boolean] + StaticRoutesOnly: Optional[Boolean] + LocalIpv4NetworkCidr: Optional[String] + RemoteIpv4NetworkCidr: Optional[String] + LocalIpv6NetworkCidr: Optional[String] + RemoteIpv6NetworkCidr: Optional[String] + OutsideIpAddressType: Optional[String] + TransportTransitGatewayAttachmentId: Optional[String] + TunnelInsideIpVersion: Optional[TunnelInsideIpVersion] + TunnelOptions: Optional[TunnelOptionsList] + + +class VpnConnection(TypedDict, total=False): + Category: Optional[String] + TransitGatewayId: Optional[String] + CoreNetworkArn: Optional[String] + CoreNetworkAttachmentArn: Optional[String] + GatewayAssociationState: Optional[GatewayAssociationState] + Options: Optional[VpnConnectionOptions] + Routes: Optional[VpnStaticRouteList] + Tags: Optional[TagList] + VgwTelemetry: Optional[VgwTelemetryList] + PreSharedKeyArn: Optional[String] + VpnConnectionId: Optional[String] + State: Optional[VpnState] + CustomerGatewayConfiguration: Optional[customerGatewayConfiguration] + Type: Optional[GatewayType] + CustomerGatewayId: Optional[String] + VpnGatewayId: Optional[String] + + +class CreateVpnConnectionResult(TypedDict, total=False): + VpnConnection: Optional[VpnConnection] + + +class CreateVpnConnectionRouteRequest(ServiceRequest): + DestinationCidrBlock: String + VpnConnectionId: VpnConnectionId + + +class CreateVpnGatewayRequest(ServiceRequest): + AvailabilityZone: Optional[String] + Type: GatewayType + TagSpecifications: Optional[TagSpecificationList] + AmazonSideAsn: Optional[Long] + DryRun: Optional[Boolean] + + +VpcAttachmentList = List[VpcAttachment] + + +class VpnGateway(TypedDict, total=False): + AmazonSideAsn: Optional[Long] + Tags: Optional[TagList] + VpnGatewayId: Optional[String] + State: Optional[VpnState] + Type: Optional[GatewayType] + AvailabilityZone: Optional[String] + VpcAttachments: Optional[VpcAttachmentList] + + +class CreateVpnGatewayResult(TypedDict, total=False): + VpnGateway: Optional[VpnGateway] + + +CustomerGatewayIdStringList = List[CustomerGatewayId] +CustomerGatewayList = List[CustomerGateway] + + +class DataQuery(TypedDict, total=False): + Id: Optional[String] + Source: Optional[String] + Destination: Optional[String] + Metric: Optional[MetricType] + Statistic: Optional[StatisticType] + Period: Optional[PeriodType] + + +DataQueries = List[DataQuery] + + +class MetricPoint(TypedDict, total=False): + StartDate: Optional[MillisecondDateTime] + EndDate: Optional[MillisecondDateTime] + Value: Optional[Float] + Status: Optional[String] + + +MetricPoints = List[MetricPoint] + + +class DataResponse(TypedDict, total=False): + Id: Optional[String] + Source: Optional[String] + Destination: Optional[String] + Metric: Optional[MetricType] + Statistic: Optional[StatisticType] + Period: Optional[PeriodType] + MetricPoints: Optional[MetricPoints] + + +DataResponses = List[DataResponse] + + +class DeclarativePoliciesReport(TypedDict, total=False): + ReportId: Optional[String] + S3Bucket: Optional[String] + S3Prefix: Optional[String] + TargetId: Optional[String] + StartTime: Optional[MillisecondDateTime] + EndTime: Optional[MillisecondDateTime] + Status: Optional[ReportState] + Tags: Optional[TagList] + + +DeclarativePoliciesReportList = List[DeclarativePoliciesReport] + + +class DeleteCarrierGatewayRequest(ServiceRequest): + CarrierGatewayId: CarrierGatewayId + DryRun: Optional[Boolean] + + +class DeleteCarrierGatewayResult(TypedDict, total=False): + CarrierGateway: Optional[CarrierGateway] + + +class DeleteClientVpnEndpointRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + DryRun: Optional[Boolean] + + +class DeleteClientVpnEndpointResult(TypedDict, total=False): + Status: Optional[ClientVpnEndpointStatus] + + +class DeleteClientVpnRouteRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + TargetVpcSubnetId: Optional[SubnetId] + DestinationCidrBlock: String + DryRun: Optional[Boolean] + + +class DeleteClientVpnRouteResult(TypedDict, total=False): + Status: Optional[ClientVpnRouteStatus] + + +class DeleteCoipCidrRequest(ServiceRequest): + Cidr: String + CoipPoolId: Ipv4PoolCoipId + DryRun: Optional[Boolean] + + +class DeleteCoipCidrResult(TypedDict, total=False): + CoipCidr: Optional[CoipCidr] + + +class DeleteCoipPoolRequest(ServiceRequest): + CoipPoolId: Ipv4PoolCoipId + DryRun: Optional[Boolean] + + +class DeleteCoipPoolResult(TypedDict, total=False): + CoipPool: Optional[CoipPool] + + +class DeleteCustomerGatewayRequest(ServiceRequest): + CustomerGatewayId: CustomerGatewayId + DryRun: Optional[Boolean] + + +class DeleteDhcpOptionsRequest(ServiceRequest): + DhcpOptionsId: DhcpOptionsId + DryRun: Optional[Boolean] + + +class DeleteEgressOnlyInternetGatewayRequest(ServiceRequest): + DryRun: Optional[Boolean] + EgressOnlyInternetGatewayId: EgressOnlyInternetGatewayId + + +class DeleteEgressOnlyInternetGatewayResult(TypedDict, total=False): + ReturnCode: Optional[Boolean] + + +class DeleteFleetError(TypedDict, total=False): + Code: Optional[DeleteFleetErrorCode] + Message: Optional[String] + + +class DeleteFleetErrorItem(TypedDict, total=False): + Error: Optional[DeleteFleetError] + FleetId: Optional[FleetId] + + +DeleteFleetErrorSet = List[DeleteFleetErrorItem] + + +class DeleteFleetSuccessItem(TypedDict, total=False): + CurrentFleetState: Optional[FleetStateCode] + PreviousFleetState: Optional[FleetStateCode] + FleetId: Optional[FleetId] + + +DeleteFleetSuccessSet = List[DeleteFleetSuccessItem] +FleetIdSet = List[FleetId] + + +class DeleteFleetsRequest(ServiceRequest): + DryRun: Optional[Boolean] + FleetIds: FleetIdSet + TerminateInstances: Boolean + + +class DeleteFleetsResult(TypedDict, total=False): + SuccessfulFleetDeletions: Optional[DeleteFleetSuccessSet] + UnsuccessfulFleetDeletions: Optional[DeleteFleetErrorSet] + + +FlowLogIdList = List[VpcFlowLogId] + + +class DeleteFlowLogsRequest(ServiceRequest): + DryRun: Optional[Boolean] + FlowLogIds: FlowLogIdList + + +class DeleteFlowLogsResult(TypedDict, total=False): + Unsuccessful: Optional[UnsuccessfulItemSet] + + +class DeleteFpgaImageRequest(ServiceRequest): + DryRun: Optional[Boolean] + FpgaImageId: FpgaImageId + + +class DeleteFpgaImageResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DeleteInstanceConnectEndpointRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceConnectEndpointId: InstanceConnectEndpointId + + +class DeleteInstanceConnectEndpointResult(TypedDict, total=False): + InstanceConnectEndpoint: Optional[Ec2InstanceConnectEndpoint] + + +class DeleteInstanceEventWindowRequest(ServiceRequest): + DryRun: Optional[Boolean] + ForceDelete: Optional[Boolean] + InstanceEventWindowId: InstanceEventWindowId + + +class InstanceEventWindowStateChange(TypedDict, total=False): + InstanceEventWindowId: Optional[InstanceEventWindowId] + State: Optional[InstanceEventWindowState] + + +class DeleteInstanceEventWindowResult(TypedDict, total=False): + InstanceEventWindowState: Optional[InstanceEventWindowStateChange] + + +class DeleteInternetGatewayRequest(ServiceRequest): + DryRun: Optional[Boolean] + InternetGatewayId: InternetGatewayId + + +class DeleteIpamExternalResourceVerificationTokenRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamExternalResourceVerificationTokenId: IpamExternalResourceVerificationTokenId + + +class DeleteIpamExternalResourceVerificationTokenResult(TypedDict, total=False): + IpamExternalResourceVerificationToken: Optional[IpamExternalResourceVerificationToken] + + +class DeleteIpamPoolRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + Cascade: Optional[Boolean] + + +class DeleteIpamPoolResult(TypedDict, total=False): + IpamPool: Optional[IpamPool] + + +class DeleteIpamRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamId: IpamId + Cascade: Optional[Boolean] + + +class DeleteIpamResourceDiscoveryRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryId: IpamResourceDiscoveryId + + +class DeleteIpamResourceDiscoveryResult(TypedDict, total=False): + IpamResourceDiscovery: Optional[IpamResourceDiscovery] + + +class DeleteIpamResult(TypedDict, total=False): + Ipam: Optional[Ipam] + + +class DeleteIpamScopeRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamScopeId: IpamScopeId + + +class DeleteIpamScopeResult(TypedDict, total=False): + IpamScope: Optional[IpamScope] + + +class DeleteKeyPairRequest(ServiceRequest): + KeyName: Optional[KeyPairNameWithResolver] + KeyPairId: Optional[KeyPairId] + DryRun: Optional[Boolean] + + +class DeleteKeyPairResult(TypedDict, total=False): + Return: Optional[Boolean] + KeyPairId: Optional[String] + + +class DeleteLaunchTemplateRequest(ServiceRequest): + DryRun: Optional[Boolean] + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[LaunchTemplateName] + + +class DeleteLaunchTemplateResult(TypedDict, total=False): + LaunchTemplate: Optional[LaunchTemplate] + + +VersionStringList = List[String] + + +class DeleteLaunchTemplateVersionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[LaunchTemplateName] + Versions: VersionStringList + + +class ResponseError(TypedDict, total=False): + Code: Optional[LaunchTemplateErrorCode] + Message: Optional[String] + + +class DeleteLaunchTemplateVersionsResponseErrorItem(TypedDict, total=False): + LaunchTemplateId: Optional[String] + LaunchTemplateName: Optional[String] + VersionNumber: Optional[Long] + ResponseError: Optional[ResponseError] + + +DeleteLaunchTemplateVersionsResponseErrorSet = List[DeleteLaunchTemplateVersionsResponseErrorItem] + + +class DeleteLaunchTemplateVersionsResponseSuccessItem(TypedDict, total=False): + LaunchTemplateId: Optional[String] + LaunchTemplateName: Optional[String] + VersionNumber: Optional[Long] + + +DeleteLaunchTemplateVersionsResponseSuccessSet = List[ + DeleteLaunchTemplateVersionsResponseSuccessItem +] + + +class DeleteLaunchTemplateVersionsResult(TypedDict, total=False): + SuccessfullyDeletedLaunchTemplateVersions: Optional[ + DeleteLaunchTemplateVersionsResponseSuccessSet + ] + UnsuccessfullyDeletedLaunchTemplateVersions: Optional[ + DeleteLaunchTemplateVersionsResponseErrorSet + ] + + +class DeleteLocalGatewayRouteRequest(ServiceRequest): + DestinationCidrBlock: Optional[String] + LocalGatewayRouteTableId: LocalGatewayRoutetableId + DryRun: Optional[Boolean] + DestinationPrefixListId: Optional[PrefixListResourceId] + + +class DeleteLocalGatewayRouteResult(TypedDict, total=False): + Route: Optional[LocalGatewayRoute] + + +class DeleteLocalGatewayRouteTableRequest(ServiceRequest): + LocalGatewayRouteTableId: LocalGatewayRoutetableId + DryRun: Optional[Boolean] + + +class DeleteLocalGatewayRouteTableResult(TypedDict, total=False): + LocalGatewayRouteTable: Optional[LocalGatewayRouteTable] + + +class DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociationRequest(ServiceRequest): + LocalGatewayRouteTableVirtualInterfaceGroupAssociationId: ( + LocalGatewayRouteTableVirtualInterfaceGroupAssociationId + ) + DryRun: Optional[Boolean] + + +class DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociationResult(TypedDict, total=False): + LocalGatewayRouteTableVirtualInterfaceGroupAssociation: Optional[ + LocalGatewayRouteTableVirtualInterfaceGroupAssociation + ] + + +class DeleteLocalGatewayRouteTableVpcAssociationRequest(ServiceRequest): + LocalGatewayRouteTableVpcAssociationId: LocalGatewayRouteTableVpcAssociationId + DryRun: Optional[Boolean] + + +class DeleteLocalGatewayRouteTableVpcAssociationResult(TypedDict, total=False): + LocalGatewayRouteTableVpcAssociation: Optional[LocalGatewayRouteTableVpcAssociation] + + +class DeleteLocalGatewayVirtualInterfaceGroupRequest(ServiceRequest): + LocalGatewayVirtualInterfaceGroupId: LocalGatewayVirtualInterfaceGroupId + DryRun: Optional[Boolean] + + +class DeleteLocalGatewayVirtualInterfaceGroupResult(TypedDict, total=False): + LocalGatewayVirtualInterfaceGroup: Optional[LocalGatewayVirtualInterfaceGroup] + + +class DeleteLocalGatewayVirtualInterfaceRequest(ServiceRequest): + LocalGatewayVirtualInterfaceId: LocalGatewayVirtualInterfaceId + DryRun: Optional[Boolean] + + +class DeleteLocalGatewayVirtualInterfaceResult(TypedDict, total=False): + LocalGatewayVirtualInterface: Optional[LocalGatewayVirtualInterface] + + +class DeleteManagedPrefixListRequest(ServiceRequest): + DryRun: Optional[Boolean] + PrefixListId: PrefixListResourceId + + +class DeleteManagedPrefixListResult(TypedDict, total=False): + PrefixList: Optional[ManagedPrefixList] + + +class DeleteNatGatewayRequest(ServiceRequest): + DryRun: Optional[Boolean] + NatGatewayId: NatGatewayId + + +class DeleteNatGatewayResult(TypedDict, total=False): + NatGatewayId: Optional[String] + + +class DeleteNetworkAclEntryRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkAclId: NetworkAclId + RuleNumber: Integer + Egress: Boolean + + +class DeleteNetworkAclRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkAclId: NetworkAclId + + +class DeleteNetworkInsightsAccessScopeAnalysisRequest(ServiceRequest): + NetworkInsightsAccessScopeAnalysisId: NetworkInsightsAccessScopeAnalysisId + DryRun: Optional[Boolean] + + +class DeleteNetworkInsightsAccessScopeAnalysisResult(TypedDict, total=False): + NetworkInsightsAccessScopeAnalysisId: Optional[NetworkInsightsAccessScopeAnalysisId] + + +class DeleteNetworkInsightsAccessScopeRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkInsightsAccessScopeId: NetworkInsightsAccessScopeId + + +class DeleteNetworkInsightsAccessScopeResult(TypedDict, total=False): + NetworkInsightsAccessScopeId: Optional[NetworkInsightsAccessScopeId] + + +class DeleteNetworkInsightsAnalysisRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkInsightsAnalysisId: NetworkInsightsAnalysisId + + +class DeleteNetworkInsightsAnalysisResult(TypedDict, total=False): + NetworkInsightsAnalysisId: Optional[NetworkInsightsAnalysisId] + + +class DeleteNetworkInsightsPathRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkInsightsPathId: NetworkInsightsPathId + + +class DeleteNetworkInsightsPathResult(TypedDict, total=False): + NetworkInsightsPathId: Optional[NetworkInsightsPathId] + + +class DeleteNetworkInterfacePermissionRequest(ServiceRequest): + NetworkInterfacePermissionId: NetworkInterfacePermissionId + Force: Optional[Boolean] + DryRun: Optional[Boolean] + + +class DeleteNetworkInterfacePermissionResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DeleteNetworkInterfaceRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkInterfaceId: NetworkInterfaceId + + +class DeletePlacementGroupRequest(ServiceRequest): + DryRun: Optional[Boolean] + GroupName: PlacementGroupName + + +class DeletePublicIpv4PoolRequest(ServiceRequest): + DryRun: Optional[Boolean] + PoolId: Ipv4PoolEc2Id + NetworkBorderGroup: Optional[String] + + +class DeletePublicIpv4PoolResult(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class DeleteQueuedReservedInstancesError(TypedDict, total=False): + Code: Optional[DeleteQueuedReservedInstancesErrorCode] + Message: Optional[String] + + +DeleteQueuedReservedInstancesIdList = List[ReservationId] + + +class DeleteQueuedReservedInstancesRequest(ServiceRequest): + DryRun: Optional[Boolean] + ReservedInstancesIds: DeleteQueuedReservedInstancesIdList + + +class FailedQueuedPurchaseDeletion(TypedDict, total=False): + Error: Optional[DeleteQueuedReservedInstancesError] + ReservedInstancesId: Optional[String] + + +FailedQueuedPurchaseDeletionSet = List[FailedQueuedPurchaseDeletion] + + +class SuccessfulQueuedPurchaseDeletion(TypedDict, total=False): + ReservedInstancesId: Optional[String] + + +SuccessfulQueuedPurchaseDeletionSet = List[SuccessfulQueuedPurchaseDeletion] + + +class DeleteQueuedReservedInstancesResult(TypedDict, total=False): + SuccessfulQueuedPurchaseDeletions: Optional[SuccessfulQueuedPurchaseDeletionSet] + FailedQueuedPurchaseDeletions: Optional[FailedQueuedPurchaseDeletionSet] + + +class DeleteRouteRequest(ServiceRequest): + DestinationPrefixListId: Optional[PrefixListResourceId] + DryRun: Optional[Boolean] + RouteTableId: RouteTableId + DestinationCidrBlock: Optional[String] + DestinationIpv6CidrBlock: Optional[String] + + +class DeleteRouteServerEndpointRequest(ServiceRequest): + RouteServerEndpointId: RouteServerEndpointId + DryRun: Optional[Boolean] + + +class DeleteRouteServerEndpointResult(TypedDict, total=False): + RouteServerEndpoint: Optional[RouteServerEndpoint] + + +class DeleteRouteServerPeerRequest(ServiceRequest): + RouteServerPeerId: RouteServerPeerId + DryRun: Optional[Boolean] + + +class DeleteRouteServerPeerResult(TypedDict, total=False): + RouteServerPeer: Optional[RouteServerPeer] + + +class DeleteRouteServerRequest(ServiceRequest): + RouteServerId: RouteServerId + DryRun: Optional[Boolean] + + +class DeleteRouteServerResult(TypedDict, total=False): + RouteServer: Optional[RouteServer] + + +class DeleteRouteTableRequest(ServiceRequest): + DryRun: Optional[Boolean] + RouteTableId: RouteTableId + + +class DeleteSecurityGroupRequest(ServiceRequest): + GroupId: Optional[SecurityGroupId] + GroupName: Optional[SecurityGroupName] + DryRun: Optional[Boolean] + + +class DeleteSecurityGroupResult(TypedDict, total=False): + Return: Optional[Boolean] + GroupId: Optional[SecurityGroupId] + + +class DeleteSnapshotRequest(ServiceRequest): + SnapshotId: SnapshotId + DryRun: Optional[Boolean] + + +class DeleteSnapshotReturnCode(TypedDict, total=False): + SnapshotId: Optional[SnapshotId] + ReturnCode: Optional[SnapshotReturnCodes] + + +DeleteSnapshotResultSet = List[DeleteSnapshotReturnCode] + + +class DeleteSpotDatafeedSubscriptionRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DeleteSubnetCidrReservationRequest(ServiceRequest): + SubnetCidrReservationId: SubnetCidrReservationId + DryRun: Optional[Boolean] + + +class DeleteSubnetCidrReservationResult(TypedDict, total=False): + DeletedSubnetCidrReservation: Optional[SubnetCidrReservation] + + +class DeleteSubnetRequest(ServiceRequest): + SubnetId: SubnetId + DryRun: Optional[Boolean] + + +class DeleteTagsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Resources: ResourceIdList + Tags: Optional[TagList] + + +class DeleteTrafficMirrorFilterRequest(ServiceRequest): + TrafficMirrorFilterId: TrafficMirrorFilterId + DryRun: Optional[Boolean] + + +class DeleteTrafficMirrorFilterResult(TypedDict, total=False): + TrafficMirrorFilterId: Optional[String] + + +class DeleteTrafficMirrorFilterRuleRequest(ServiceRequest): + TrafficMirrorFilterRuleId: TrafficMirrorFilterRuleIdWithResolver + DryRun: Optional[Boolean] + + +class DeleteTrafficMirrorFilterRuleResult(TypedDict, total=False): + TrafficMirrorFilterRuleId: Optional[String] + + +class DeleteTrafficMirrorSessionRequest(ServiceRequest): + TrafficMirrorSessionId: TrafficMirrorSessionId + DryRun: Optional[Boolean] + + +class DeleteTrafficMirrorSessionResult(TypedDict, total=False): + TrafficMirrorSessionId: Optional[String] + + +class DeleteTrafficMirrorTargetRequest(ServiceRequest): + TrafficMirrorTargetId: TrafficMirrorTargetId + DryRun: Optional[Boolean] + + +class DeleteTrafficMirrorTargetResult(TypedDict, total=False): + TrafficMirrorTargetId: Optional[String] + + +class DeleteTransitGatewayConnectPeerRequest(ServiceRequest): + TransitGatewayConnectPeerId: TransitGatewayConnectPeerId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayConnectPeerResult(TypedDict, total=False): + TransitGatewayConnectPeer: Optional[TransitGatewayConnectPeer] + + +class DeleteTransitGatewayConnectRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayConnectResult(TypedDict, total=False): + TransitGatewayConnect: Optional[TransitGatewayConnect] + + +class DeleteTransitGatewayMulticastDomainRequest(ServiceRequest): + TransitGatewayMulticastDomainId: TransitGatewayMulticastDomainId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayMulticastDomainResult(TypedDict, total=False): + TransitGatewayMulticastDomain: Optional[TransitGatewayMulticastDomain] + + +class DeleteTransitGatewayPeeringAttachmentRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayPeeringAttachmentResult(TypedDict, total=False): + TransitGatewayPeeringAttachment: Optional[TransitGatewayPeeringAttachment] + + +class DeleteTransitGatewayPolicyTableRequest(ServiceRequest): + TransitGatewayPolicyTableId: TransitGatewayPolicyTableId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayPolicyTableResult(TypedDict, total=False): + TransitGatewayPolicyTable: Optional[TransitGatewayPolicyTable] + + +class DeleteTransitGatewayPrefixListReferenceRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + PrefixListId: PrefixListResourceId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayPrefixListReferenceResult(TypedDict, total=False): + TransitGatewayPrefixListReference: Optional[TransitGatewayPrefixListReference] + + +class DeleteTransitGatewayRequest(ServiceRequest): + TransitGatewayId: TransitGatewayId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayResult(TypedDict, total=False): + TransitGateway: Optional[TransitGateway] + + +class DeleteTransitGatewayRouteRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + DestinationCidrBlock: String + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayRouteResult(TypedDict, total=False): + Route: Optional[TransitGatewayRoute] + + +class DeleteTransitGatewayRouteTableAnnouncementRequest(ServiceRequest): + TransitGatewayRouteTableAnnouncementId: TransitGatewayRouteTableAnnouncementId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayRouteTableAnnouncementResult(TypedDict, total=False): + TransitGatewayRouteTableAnnouncement: Optional[TransitGatewayRouteTableAnnouncement] + + +class DeleteTransitGatewayRouteTableRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayRouteTableResult(TypedDict, total=False): + TransitGatewayRouteTable: Optional[TransitGatewayRouteTable] + + +class DeleteTransitGatewayVpcAttachmentRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class DeleteTransitGatewayVpcAttachmentResult(TypedDict, total=False): + TransitGatewayVpcAttachment: Optional[TransitGatewayVpcAttachment] + + +class DeleteVerifiedAccessEndpointRequest(ServiceRequest): + VerifiedAccessEndpointId: VerifiedAccessEndpointId + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class DeleteVerifiedAccessEndpointResult(TypedDict, total=False): + VerifiedAccessEndpoint: Optional[VerifiedAccessEndpoint] + + +class DeleteVerifiedAccessGroupRequest(ServiceRequest): + VerifiedAccessGroupId: VerifiedAccessGroupId + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class DeleteVerifiedAccessGroupResult(TypedDict, total=False): + VerifiedAccessGroup: Optional[VerifiedAccessGroup] + + +class DeleteVerifiedAccessInstanceRequest(ServiceRequest): + VerifiedAccessInstanceId: VerifiedAccessInstanceId + DryRun: Optional[Boolean] + ClientToken: Optional[String] + + +class DeleteVerifiedAccessInstanceResult(TypedDict, total=False): + VerifiedAccessInstance: Optional[VerifiedAccessInstance] + + +class DeleteVerifiedAccessTrustProviderRequest(ServiceRequest): + VerifiedAccessTrustProviderId: VerifiedAccessTrustProviderId + DryRun: Optional[Boolean] + ClientToken: Optional[String] + + +class DeleteVerifiedAccessTrustProviderResult(TypedDict, total=False): + VerifiedAccessTrustProvider: Optional[VerifiedAccessTrustProvider] + + +class DeleteVolumeRequest(ServiceRequest): + VolumeId: VolumeId + DryRun: Optional[Boolean] + + +class DeleteVpcBlockPublicAccessExclusionRequest(ServiceRequest): + DryRun: Optional[Boolean] + ExclusionId: VpcBlockPublicAccessExclusionId + + +class DeleteVpcBlockPublicAccessExclusionResult(TypedDict, total=False): + VpcBlockPublicAccessExclusion: Optional[VpcBlockPublicAccessExclusion] + + +class DeleteVpcEndpointConnectionNotificationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ConnectionNotificationIds: ConnectionNotificationIdsList + + +class DeleteVpcEndpointConnectionNotificationsResult(TypedDict, total=False): + Unsuccessful: Optional[UnsuccessfulItemSet] + + +VpcEndpointServiceIdList = List[VpcEndpointServiceId] + + +class DeleteVpcEndpointServiceConfigurationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceIds: VpcEndpointServiceIdList + + +class DeleteVpcEndpointServiceConfigurationsResult(TypedDict, total=False): + Unsuccessful: Optional[UnsuccessfulItemSet] + + +class DeleteVpcEndpointsRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcEndpointIds: VpcEndpointIdList + + +class DeleteVpcEndpointsResult(TypedDict, total=False): + Unsuccessful: Optional[UnsuccessfulItemSet] + + +class DeleteVpcPeeringConnectionRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcPeeringConnectionId: VpcPeeringConnectionId + + +class DeleteVpcPeeringConnectionResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DeleteVpcRequest(ServiceRequest): + VpcId: VpcId + DryRun: Optional[Boolean] + + +class DeleteVpnConnectionRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + DryRun: Optional[Boolean] + + +class DeleteVpnConnectionRouteRequest(ServiceRequest): + DestinationCidrBlock: String + VpnConnectionId: VpnConnectionId + + +class DeleteVpnGatewayRequest(ServiceRequest): + VpnGatewayId: VpnGatewayId + DryRun: Optional[Boolean] + + +class DeprovisionByoipCidrRequest(ServiceRequest): + Cidr: String + DryRun: Optional[Boolean] + + +class DeprovisionByoipCidrResult(TypedDict, total=False): + ByoipCidr: Optional[ByoipCidr] + + +class DeprovisionIpamByoasnRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamId: IpamId + Asn: String + + +class DeprovisionIpamByoasnResult(TypedDict, total=False): + Byoasn: Optional[Byoasn] + + +class DeprovisionIpamPoolCidrRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + Cidr: Optional[String] + + +class IpamPoolCidrFailureReason(TypedDict, total=False): + Code: Optional[IpamPoolCidrFailureCode] + Message: Optional[String] + + +class IpamPoolCidr(TypedDict, total=False): + Cidr: Optional[String] + State: Optional[IpamPoolCidrState] + FailureReason: Optional[IpamPoolCidrFailureReason] + IpamPoolCidrId: Optional[IpamPoolCidrId] + NetmaskLength: Optional[Integer] + + +class DeprovisionIpamPoolCidrResult(TypedDict, total=False): + IpamPoolCidr: Optional[IpamPoolCidr] + + +class DeprovisionPublicIpv4PoolCidrRequest(ServiceRequest): + DryRun: Optional[Boolean] + PoolId: Ipv4PoolEc2Id + Cidr: String + + +DeprovisionedAddressSet = List[String] + + +class DeprovisionPublicIpv4PoolCidrResult(TypedDict, total=False): + PoolId: Optional[Ipv4PoolEc2Id] + DeprovisionedAddresses: Optional[DeprovisionedAddressSet] + + +class DeregisterImageRequest(ServiceRequest): + ImageId: ImageId + DeleteAssociatedSnapshots: Optional[Boolean] + DryRun: Optional[Boolean] + + +class DeregisterImageResult(TypedDict, total=False): + Return: Optional[Boolean] + DeleteSnapshotResults: Optional[DeleteSnapshotResultSet] + + +InstanceTagKeySet = List[String] + + +class DeregisterInstanceTagAttributeRequest(TypedDict, total=False): + IncludeAllTagsOfInstance: Optional[Boolean] + InstanceTagKeys: Optional[InstanceTagKeySet] + + +class DeregisterInstanceEventNotificationAttributesRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceTagAttribute: DeregisterInstanceTagAttributeRequest + + +class InstanceTagNotificationAttribute(TypedDict, total=False): + InstanceTagKeys: Optional[InstanceTagKeySet] + IncludeAllTagsOfInstance: Optional[Boolean] + + +class DeregisterInstanceEventNotificationAttributesResult(TypedDict, total=False): + InstanceTagAttribute: Optional[InstanceTagNotificationAttribute] + + +TransitGatewayNetworkInterfaceIdList = List[NetworkInterfaceId] + + +class DeregisterTransitGatewayMulticastGroupMembersRequest(ServiceRequest): + TransitGatewayMulticastDomainId: Optional[TransitGatewayMulticastDomainId] + GroupIpAddress: Optional[String] + NetworkInterfaceIds: Optional[TransitGatewayNetworkInterfaceIdList] + DryRun: Optional[Boolean] + + +class TransitGatewayMulticastDeregisteredGroupMembers(TypedDict, total=False): + TransitGatewayMulticastDomainId: Optional[String] + DeregisteredNetworkInterfaceIds: Optional[ValueStringList] + GroupIpAddress: Optional[String] + + +class DeregisterTransitGatewayMulticastGroupMembersResult(TypedDict, total=False): + DeregisteredMulticastGroupMembers: Optional[TransitGatewayMulticastDeregisteredGroupMembers] + + +class DeregisterTransitGatewayMulticastGroupSourcesRequest(ServiceRequest): + TransitGatewayMulticastDomainId: Optional[TransitGatewayMulticastDomainId] + GroupIpAddress: Optional[String] + NetworkInterfaceIds: Optional[TransitGatewayNetworkInterfaceIdList] + DryRun: Optional[Boolean] + + +class TransitGatewayMulticastDeregisteredGroupSources(TypedDict, total=False): + TransitGatewayMulticastDomainId: Optional[String] + DeregisteredNetworkInterfaceIds: Optional[ValueStringList] + GroupIpAddress: Optional[String] + + +class DeregisterTransitGatewayMulticastGroupSourcesResult(TypedDict, total=False): + DeregisteredMulticastGroupSources: Optional[TransitGatewayMulticastDeregisteredGroupSources] + + +class DescribeAccountAttributesRequest(ServiceRequest): + DryRun: Optional[Boolean] + AttributeNames: Optional[AccountAttributeNameStringList] + + +class DescribeAccountAttributesResult(TypedDict, total=False): + AccountAttributes: Optional[AccountAttributeList] + + +class DescribeAddressTransfersRequest(ServiceRequest): + AllocationIds: Optional[AllocationIdList] + NextToken: Optional[String] + MaxResults: Optional[DescribeAddressTransfersMaxResults] + DryRun: Optional[Boolean] + + +class DescribeAddressTransfersResult(TypedDict, total=False): + AddressTransfers: Optional[AddressTransferList] + NextToken: Optional[String] + + +class DescribeAddressesAttributeRequest(ServiceRequest): + AllocationIds: Optional[AllocationIds] + Attribute: Optional[AddressAttributeName] + NextToken: Optional[NextToken] + MaxResults: Optional[AddressMaxResults] + DryRun: Optional[Boolean] + + +class DescribeAddressesAttributeResult(TypedDict, total=False): + Addresses: Optional[AddressSet] + NextToken: Optional[NextToken] + + +class Filter(TypedDict, total=False): + Name: Optional[String] + Values: Optional[ValueStringList] + + +FilterList = List[Filter] +PublicIpStringList = List[String] + + +class DescribeAddressesRequest(ServiceRequest): + PublicIps: Optional[PublicIpStringList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + AllocationIds: Optional[AllocationIdList] + + +class DescribeAddressesResult(TypedDict, total=False): + Addresses: Optional[AddressList] + + +class DescribeAggregateIdFormatRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class IdFormat(TypedDict, total=False): + Deadline: Optional[DateTime] + Resource: Optional[String] + UseLongIds: Optional[Boolean] + + +IdFormatList = List[IdFormat] + + +class DescribeAggregateIdFormatResult(TypedDict, total=False): + UseLongIdsAggregated: Optional[Boolean] + Statuses: Optional[IdFormatList] + + +ZoneIdStringList = List[String] +ZoneNameStringList = List[String] + + +class DescribeAvailabilityZonesRequest(ServiceRequest): + ZoneNames: Optional[ZoneNameStringList] + ZoneIds: Optional[ZoneIdStringList] + AllAvailabilityZones: Optional[Boolean] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class DescribeAvailabilityZonesResult(TypedDict, total=False): + AvailabilityZones: Optional[AvailabilityZoneList] + + +class DescribeAwsNetworkPerformanceMetricSubscriptionsRequest(ServiceRequest): + MaxResults: Optional[MaxResultsParam] + NextToken: Optional[String] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class Subscription(TypedDict, total=False): + Source: Optional[String] + Destination: Optional[String] + Metric: Optional[MetricType] + Statistic: Optional[StatisticType] + Period: Optional[PeriodType] + + +SubscriptionList = List[Subscription] + + +class DescribeAwsNetworkPerformanceMetricSubscriptionsResult(TypedDict, total=False): + NextToken: Optional[String] + Subscriptions: Optional[SubscriptionList] + + +class DescribeBundleTasksRequest(ServiceRequest): + BundleIds: Optional[BundleIdStringList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class DescribeBundleTasksResult(TypedDict, total=False): + BundleTasks: Optional[BundleTaskList] + + +class DescribeByoipCidrsRequest(ServiceRequest): + DryRun: Optional[Boolean] + MaxResults: DescribeByoipCidrsMaxResults + NextToken: Optional[NextToken] + + +class DescribeByoipCidrsResult(TypedDict, total=False): + ByoipCidrs: Optional[ByoipCidrSet] + NextToken: Optional[String] + + +class DescribeCapacityBlockExtensionHistoryRequest(ServiceRequest): + CapacityReservationIds: Optional[CapacityReservationIdSet] + NextToken: Optional[String] + MaxResults: Optional[DescribeFutureCapacityMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class DescribeCapacityBlockExtensionHistoryResult(TypedDict, total=False): + CapacityBlockExtensions: Optional[CapacityBlockExtensionSet] + NextToken: Optional[String] + + +class DescribeCapacityBlockExtensionOfferingsRequest(ServiceRequest): + DryRun: Optional[Boolean] + CapacityBlockExtensionDurationHours: Integer + CapacityReservationId: CapacityReservationId + NextToken: Optional[String] + MaxResults: Optional[DescribeCapacityBlockExtensionOfferingsMaxResults] + + +class DescribeCapacityBlockExtensionOfferingsResult(TypedDict, total=False): + CapacityBlockExtensionOfferings: Optional[CapacityBlockExtensionOfferingSet] + NextToken: Optional[String] + + +class DescribeCapacityBlockOfferingsRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceType: Optional[String] + InstanceCount: Optional[Integer] + StartDateRange: Optional[MillisecondDateTime] + EndDateRange: Optional[MillisecondDateTime] + CapacityDurationHours: Integer + NextToken: Optional[String] + MaxResults: Optional[DescribeCapacityBlockOfferingsMaxResults] + UltraserverType: Optional[String] + UltraserverCount: Optional[Integer] + + +class DescribeCapacityBlockOfferingsResult(TypedDict, total=False): + CapacityBlockOfferings: Optional[CapacityBlockOfferingSet] + NextToken: Optional[String] + + +class DescribeCapacityBlockStatusRequest(ServiceRequest): + CapacityBlockIds: Optional[CapacityBlockIds] + NextToken: Optional[String] + MaxResults: Optional[DescribeCapacityBlockStatusMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class DescribeCapacityBlockStatusResult(TypedDict, total=False): + CapacityBlockStatuses: Optional[CapacityBlockStatusSet] + NextToken: Optional[String] + + +class DescribeCapacityBlocksRequest(ServiceRequest): + CapacityBlockIds: Optional[CapacityBlockIds] + NextToken: Optional[String] + MaxResults: Optional[DescribeCapacityBlocksMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class DescribeCapacityBlocksResult(TypedDict, total=False): + CapacityBlocks: Optional[CapacityBlockSet] + NextToken: Optional[String] + + +class DescribeCapacityReservationBillingRequestsRequest(ServiceRequest): + CapacityReservationIds: Optional[CapacityReservationIdSet] + Role: CallerRole + NextToken: Optional[String] + MaxResults: Optional[DescribeCapacityReservationBillingRequestsRequestMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class DescribeCapacityReservationBillingRequestsResult(TypedDict, total=False): + NextToken: Optional[String] + CapacityReservationBillingRequests: Optional[CapacityReservationBillingRequestSet] + + +class DescribeCapacityReservationFleetsRequest(ServiceRequest): + CapacityReservationFleetIds: Optional[CapacityReservationFleetIdSet] + NextToken: Optional[String] + MaxResults: Optional[DescribeCapacityReservationFleetsMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class DescribeCapacityReservationFleetsResult(TypedDict, total=False): + CapacityReservationFleets: Optional[CapacityReservationFleetSet] + NextToken: Optional[String] + + +class DescribeCapacityReservationsRequest(ServiceRequest): + CapacityReservationIds: Optional[CapacityReservationIdSet] + NextToken: Optional[String] + MaxResults: Optional[DescribeCapacityReservationsMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class DescribeCapacityReservationsResult(TypedDict, total=False): + NextToken: Optional[String] + CapacityReservations: Optional[CapacityReservationSet] + + +class DescribeCarrierGatewaysRequest(ServiceRequest): + CarrierGatewayIds: Optional[CarrierGatewayIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[CarrierGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class DescribeCarrierGatewaysResult(TypedDict, total=False): + CarrierGateways: Optional[CarrierGatewaySet] + NextToken: Optional[String] + + +InstanceIdStringList = List[InstanceId] + + +class DescribeClassicLinkInstancesRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceIds: Optional[InstanceIdStringList] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[DescribeClassicLinkInstancesMaxResults] + + +class DescribeClassicLinkInstancesResult(TypedDict, total=False): + Instances: Optional[ClassicLinkInstanceList] + NextToken: Optional[String] + + +class DescribeClientVpnAuthorizationRulesRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + DryRun: Optional[Boolean] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + MaxResults: Optional[DescribeClientVpnAuthorizationRulesMaxResults] + + +class DescribeClientVpnAuthorizationRulesResult(TypedDict, total=False): + AuthorizationRules: Optional[AuthorizationRuleSet] + NextToken: Optional[NextToken] + + +class DescribeClientVpnConnectionsRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + Filters: Optional[FilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[DescribeClientVpnConnectionsMaxResults] + DryRun: Optional[Boolean] + + +class DescribeClientVpnConnectionsResult(TypedDict, total=False): + Connections: Optional[ClientVpnConnectionSet] + NextToken: Optional[NextToken] + + +class DescribeClientVpnEndpointsRequest(ServiceRequest): + ClientVpnEndpointIds: Optional[ClientVpnEndpointIdList] + MaxResults: Optional[DescribeClientVpnEndpointMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +EndpointSet = List[ClientVpnEndpoint] + + +class DescribeClientVpnEndpointsResult(TypedDict, total=False): + ClientVpnEndpoints: Optional[EndpointSet] + NextToken: Optional[NextToken] + + +class DescribeClientVpnRoutesRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + Filters: Optional[FilterList] + MaxResults: Optional[DescribeClientVpnRoutesMaxResults] + NextToken: Optional[NextToken] + DryRun: Optional[Boolean] + + +class DescribeClientVpnRoutesResult(TypedDict, total=False): + Routes: Optional[ClientVpnRouteSet] + NextToken: Optional[NextToken] + + +class DescribeClientVpnTargetNetworksRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + AssociationIds: Optional[ValueStringList] + MaxResults: Optional[DescribeClientVpnTargetNetworksMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class TargetNetwork(TypedDict, total=False): + AssociationId: Optional[String] + VpcId: Optional[String] + TargetNetworkId: Optional[String] + ClientVpnEndpointId: Optional[String] + Status: Optional[AssociationStatus] + SecurityGroups: Optional[ValueStringList] + + +TargetNetworkSet = List[TargetNetwork] + + +class DescribeClientVpnTargetNetworksResult(TypedDict, total=False): + ClientVpnTargetNetworks: Optional[TargetNetworkSet] + NextToken: Optional[NextToken] + + +class DescribeCoipPoolsRequest(ServiceRequest): + PoolIds: Optional[CoipPoolIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[CoipPoolMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class DescribeCoipPoolsResult(TypedDict, total=False): + CoipPools: Optional[CoipPoolSet] + NextToken: Optional[String] + + +DescribeConversionTaskList = List[ConversionTask] + + +class DescribeConversionTasksRequest(ServiceRequest): + DryRun: Optional[Boolean] + ConversionTaskIds: Optional[ConversionIdStringList] + + +class DescribeConversionTasksResult(TypedDict, total=False): + ConversionTasks: Optional[DescribeConversionTaskList] + + +class DescribeCustomerGatewaysRequest(ServiceRequest): + CustomerGatewayIds: Optional[CustomerGatewayIdStringList] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class DescribeCustomerGatewaysResult(TypedDict, total=False): + CustomerGateways: Optional[CustomerGatewayList] + + +class DescribeDeclarativePoliciesReportsRequest(ServiceRequest): + DryRun: Optional[Boolean] + NextToken: Optional[String] + MaxResults: Optional[DeclarativePoliciesMaxResults] + ReportIds: Optional[ValueStringList] + + +class DescribeDeclarativePoliciesReportsResult(TypedDict, total=False): + NextToken: Optional[String] + Reports: Optional[DeclarativePoliciesReportList] + + +DhcpOptionsIdStringList = List[DhcpOptionsId] + + +class DescribeDhcpOptionsRequest(ServiceRequest): + DhcpOptionsIds: Optional[DhcpOptionsIdStringList] + NextToken: Optional[String] + MaxResults: Optional[DescribeDhcpOptionsMaxResults] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +DhcpOptionsList = List[DhcpOptions] + + +class DescribeDhcpOptionsResult(TypedDict, total=False): + NextToken: Optional[String] + DhcpOptions: Optional[DhcpOptionsList] + + +EgressOnlyInternetGatewayIdList = List[EgressOnlyInternetGatewayId] + + +class DescribeEgressOnlyInternetGatewaysRequest(ServiceRequest): + DryRun: Optional[Boolean] + EgressOnlyInternetGatewayIds: Optional[EgressOnlyInternetGatewayIdList] + MaxResults: Optional[DescribeEgressOnlyInternetGatewaysMaxResults] + NextToken: Optional[String] + Filters: Optional[FilterList] + + +EgressOnlyInternetGatewayList = List[EgressOnlyInternetGateway] + + +class DescribeEgressOnlyInternetGatewaysResult(TypedDict, total=False): + EgressOnlyInternetGateways: Optional[EgressOnlyInternetGatewayList] + NextToken: Optional[String] + + +ElasticGpuIdSet = List[ElasticGpuId] + + +class DescribeElasticGpusRequest(ServiceRequest): + ElasticGpuIds: Optional[ElasticGpuIdSet] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[DescribeElasticGpusMaxResults] + NextToken: Optional[String] + + +class ElasticGpuHealth(TypedDict, total=False): + Status: Optional[ElasticGpuStatus] + + +class ElasticGpus(TypedDict, total=False): + ElasticGpuId: Optional[String] + AvailabilityZone: Optional[String] + ElasticGpuType: Optional[String] + ElasticGpuHealth: Optional[ElasticGpuHealth] + ElasticGpuState: Optional[ElasticGpuState] + InstanceId: Optional[String] + Tags: Optional[TagList] + + +ElasticGpuSet = List[ElasticGpus] + + +class DescribeElasticGpusResult(TypedDict, total=False): + ElasticGpuSet: Optional[ElasticGpuSet] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +ExportImageTaskIdList = List[ExportImageTaskId] + + +class DescribeExportImageTasksRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + ExportImageTaskIds: Optional[ExportImageTaskIdList] + MaxResults: Optional[DescribeExportImageTasksMaxResults] + NextToken: Optional[NextToken] + + +class ExportTaskS3Location(TypedDict, total=False): + S3Bucket: Optional[String] + S3Prefix: Optional[String] + + +class ExportImageTask(TypedDict, total=False): + Description: Optional[String] + ExportImageTaskId: Optional[String] + ImageId: Optional[String] + Progress: Optional[String] + S3ExportLocation: Optional[ExportTaskS3Location] + Status: Optional[String] + StatusMessage: Optional[String] + Tags: Optional[TagList] + + +ExportImageTaskList = List[ExportImageTask] + + +class DescribeExportImageTasksResult(TypedDict, total=False): + ExportImageTasks: Optional[ExportImageTaskList] + NextToken: Optional[NextToken] + + +ExportTaskIdStringList = List[ExportTaskId] + + +class DescribeExportTasksRequest(ServiceRequest): + Filters: Optional[FilterList] + ExportTaskIds: Optional[ExportTaskIdStringList] + + +ExportTaskList = List[ExportTask] + + +class DescribeExportTasksResult(TypedDict, total=False): + ExportTasks: Optional[ExportTaskList] + + +FastLaunchImageIdList = List[ImageId] + + +class DescribeFastLaunchImagesRequest(ServiceRequest): + ImageIds: Optional[FastLaunchImageIdList] + Filters: Optional[FilterList] + MaxResults: Optional[DescribeFastLaunchImagesRequestMaxResults] + NextToken: Optional[NextToken] + DryRun: Optional[Boolean] + + +class FastLaunchLaunchTemplateSpecificationResponse(TypedDict, total=False): + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[String] + Version: Optional[String] + + +class FastLaunchSnapshotConfigurationResponse(TypedDict, total=False): + TargetResourceCount: Optional[Integer] + + +class DescribeFastLaunchImagesSuccessItem(TypedDict, total=False): + ImageId: Optional[ImageId] + ResourceType: Optional[FastLaunchResourceType] + SnapshotConfiguration: Optional[FastLaunchSnapshotConfigurationResponse] + LaunchTemplate: Optional[FastLaunchLaunchTemplateSpecificationResponse] + MaxParallelLaunches: Optional[Integer] + OwnerId: Optional[String] + State: Optional[FastLaunchStateCode] + StateTransitionReason: Optional[String] + StateTransitionTime: Optional[MillisecondDateTime] + + +DescribeFastLaunchImagesSuccessSet = List[DescribeFastLaunchImagesSuccessItem] + + +class DescribeFastLaunchImagesResult(TypedDict, total=False): + FastLaunchImages: Optional[DescribeFastLaunchImagesSuccessSet] + NextToken: Optional[NextToken] + + +class DescribeFastSnapshotRestoreSuccessItem(TypedDict, total=False): + SnapshotId: Optional[String] + AvailabilityZone: Optional[String] + State: Optional[FastSnapshotRestoreStateCode] + StateTransitionReason: Optional[String] + OwnerId: Optional[String] + OwnerAlias: Optional[String] + EnablingTime: Optional[MillisecondDateTime] + OptimizingTime: Optional[MillisecondDateTime] + EnabledTime: Optional[MillisecondDateTime] + DisablingTime: Optional[MillisecondDateTime] + DisabledTime: Optional[MillisecondDateTime] + + +DescribeFastSnapshotRestoreSuccessSet = List[DescribeFastSnapshotRestoreSuccessItem] + + +class DescribeFastSnapshotRestoresRequest(ServiceRequest): + Filters: Optional[FilterList] + MaxResults: Optional[DescribeFastSnapshotRestoresMaxResults] + NextToken: Optional[NextToken] + DryRun: Optional[Boolean] + + +class DescribeFastSnapshotRestoresResult(TypedDict, total=False): + FastSnapshotRestores: Optional[DescribeFastSnapshotRestoreSuccessSet] + NextToken: Optional[NextToken] + + +class DescribeFleetError(TypedDict, total=False): + LaunchTemplateAndOverrides: Optional[LaunchTemplateAndOverridesResponse] + Lifecycle: Optional[InstanceLifecycle] + ErrorCode: Optional[String] + ErrorMessage: Optional[String] + + +class DescribeFleetHistoryRequest(ServiceRequest): + DryRun: Optional[Boolean] + EventType: Optional[FleetEventType] + MaxResults: Optional[Integer] + NextToken: Optional[String] + FleetId: FleetId + StartTime: DateTime + + +class EventInformation(TypedDict, total=False): + EventDescription: Optional[String] + EventSubType: Optional[String] + InstanceId: Optional[String] + + +class HistoryRecordEntry(TypedDict, total=False): + EventInformation: Optional[EventInformation] + EventType: Optional[FleetEventType] + Timestamp: Optional[DateTime] + + +HistoryRecordSet = List[HistoryRecordEntry] + + +class DescribeFleetHistoryResult(TypedDict, total=False): + HistoryRecords: Optional[HistoryRecordSet] + LastEvaluatedTime: Optional[DateTime] + NextToken: Optional[String] + FleetId: Optional[FleetId] + StartTime: Optional[DateTime] + + +class DescribeFleetInstancesRequest(ServiceRequest): + DryRun: Optional[Boolean] + MaxResults: Optional[Integer] + NextToken: Optional[String] + FleetId: FleetId + Filters: Optional[FilterList] + + +class DescribeFleetInstancesResult(TypedDict, total=False): + ActiveInstances: Optional[ActiveInstanceSet] + NextToken: Optional[String] + FleetId: Optional[FleetId] + + +DescribeFleetsErrorSet = List[DescribeFleetError] + + +class DescribeFleetsInstances(TypedDict, total=False): + LaunchTemplateAndOverrides: Optional[LaunchTemplateAndOverridesResponse] + Lifecycle: Optional[InstanceLifecycle] + InstanceIds: Optional[InstanceIdsSet] + InstanceType: Optional[InstanceType] + Platform: Optional[PlatformValues] + + +DescribeFleetsInstancesSet = List[DescribeFleetsInstances] + + +class DescribeFleetsRequest(ServiceRequest): + DryRun: Optional[Boolean] + MaxResults: Optional[Integer] + NextToken: Optional[String] + FleetIds: Optional[FleetIdSet] + Filters: Optional[FilterList] + + +class OnDemandOptions(TypedDict, total=False): + AllocationStrategy: Optional[FleetOnDemandAllocationStrategy] + CapacityReservationOptions: Optional[CapacityReservationOptions] + SingleInstanceType: Optional[Boolean] + SingleAvailabilityZone: Optional[Boolean] + MinTargetCapacity: Optional[Integer] + MaxTotalPrice: Optional[String] + + +class FleetSpotCapacityRebalance(TypedDict, total=False): + ReplacementStrategy: Optional[FleetReplacementStrategy] + TerminationDelay: Optional[Integer] + + +class FleetSpotMaintenanceStrategies(TypedDict, total=False): + CapacityRebalance: Optional[FleetSpotCapacityRebalance] + + +class SpotOptions(TypedDict, total=False): + AllocationStrategy: Optional[SpotAllocationStrategy] + MaintenanceStrategies: Optional[FleetSpotMaintenanceStrategies] + InstanceInterruptionBehavior: Optional[SpotInstanceInterruptionBehavior] + InstancePoolsToUseCount: Optional[Integer] + SingleInstanceType: Optional[Boolean] + SingleAvailabilityZone: Optional[Boolean] + MinTargetCapacity: Optional[Integer] + MaxTotalPrice: Optional[String] + + +class TargetCapacitySpecification(TypedDict, total=False): + TotalTargetCapacity: Optional[Integer] + OnDemandTargetCapacity: Optional[Integer] + SpotTargetCapacity: Optional[Integer] + DefaultTargetCapacityType: Optional[DefaultTargetCapacityType] + TargetCapacityUnitType: Optional[TargetCapacityUnitType] + + +FleetLaunchTemplateOverridesList = List[FleetLaunchTemplateOverrides] + + +class FleetLaunchTemplateConfig(TypedDict, total=False): + LaunchTemplateSpecification: Optional[FleetLaunchTemplateSpecification] + Overrides: Optional[FleetLaunchTemplateOverridesList] + + +FleetLaunchTemplateConfigList = List[FleetLaunchTemplateConfig] + + +class FleetData(TypedDict, total=False): + ActivityStatus: Optional[FleetActivityStatus] + CreateTime: Optional[DateTime] + FleetId: Optional[FleetId] + FleetState: Optional[FleetStateCode] + ClientToken: Optional[String] + ExcessCapacityTerminationPolicy: Optional[FleetExcessCapacityTerminationPolicy] + FulfilledCapacity: Optional[Double] + FulfilledOnDemandCapacity: Optional[Double] + LaunchTemplateConfigs: Optional[FleetLaunchTemplateConfigList] + TargetCapacitySpecification: Optional[TargetCapacitySpecification] + TerminateInstancesWithExpiration: Optional[Boolean] + Type: Optional[FleetType] + ValidFrom: Optional[DateTime] + ValidUntil: Optional[DateTime] + ReplaceUnhealthyInstances: Optional[Boolean] + SpotOptions: Optional[SpotOptions] + OnDemandOptions: Optional[OnDemandOptions] + Tags: Optional[TagList] + Errors: Optional[DescribeFleetsErrorSet] + Instances: Optional[DescribeFleetsInstancesSet] + Context: Optional[String] + + +FleetSet = List[FleetData] + + +class DescribeFleetsResult(TypedDict, total=False): + NextToken: Optional[String] + Fleets: Optional[FleetSet] + + +class DescribeFlowLogsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filter: Optional[FilterList] + FlowLogIds: Optional[FlowLogIdList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class DestinationOptionsResponse(TypedDict, total=False): + FileFormat: Optional[DestinationFileFormat] + HiveCompatiblePartitions: Optional[Boolean] + PerHourPartition: Optional[Boolean] + + +class FlowLog(TypedDict, total=False): + CreationTime: Optional[MillisecondDateTime] + DeliverLogsErrorMessage: Optional[String] + DeliverLogsPermissionArn: Optional[String] + DeliverCrossAccountRole: Optional[String] + DeliverLogsStatus: Optional[String] + FlowLogId: Optional[String] + FlowLogStatus: Optional[String] + LogGroupName: Optional[String] + ResourceId: Optional[String] + TrafficType: Optional[TrafficType] + LogDestinationType: Optional[LogDestinationType] + LogDestination: Optional[String] + LogFormat: Optional[String] + Tags: Optional[TagList] + MaxAggregationInterval: Optional[Integer] + DestinationOptions: Optional[DestinationOptionsResponse] + + +FlowLogSet = List[FlowLog] + + +class DescribeFlowLogsResult(TypedDict, total=False): + FlowLogs: Optional[FlowLogSet] + NextToken: Optional[String] + + +class DescribeFpgaImageAttributeRequest(ServiceRequest): + DryRun: Optional[Boolean] + FpgaImageId: FpgaImageId + Attribute: FpgaImageAttributeName + + +class ProductCode(TypedDict, total=False): + ProductCodeId: Optional[String] + ProductCodeType: Optional[ProductCodeValues] + + +ProductCodeList = List[ProductCode] + + +class LoadPermission(TypedDict, total=False): + UserId: Optional[String] + Group: Optional[PermissionGroup] + + +LoadPermissionList = List[LoadPermission] + + +class FpgaImageAttribute(TypedDict, total=False): + FpgaImageId: Optional[String] + Name: Optional[String] + Description: Optional[String] + LoadPermissions: Optional[LoadPermissionList] + ProductCodes: Optional[ProductCodeList] + + +class DescribeFpgaImageAttributeResult(TypedDict, total=False): + FpgaImageAttribute: Optional[FpgaImageAttribute] + + +OwnerStringList = List[String] +FpgaImageIdList = List[FpgaImageId] + + +class DescribeFpgaImagesRequest(ServiceRequest): + DryRun: Optional[Boolean] + FpgaImageIds: Optional[FpgaImageIdList] + Owners: Optional[OwnerStringList] + Filters: Optional[FilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[DescribeFpgaImagesMaxResults] + + +InstanceTypesList = List[String] + + +class FpgaImageState(TypedDict, total=False): + Code: Optional[FpgaImageStateCode] + Message: Optional[String] + + +class PciId(TypedDict, total=False): + DeviceId: Optional[String] + VendorId: Optional[String] + SubsystemId: Optional[String] + SubsystemVendorId: Optional[String] + + +class FpgaImage(TypedDict, total=False): + FpgaImageId: Optional[String] + FpgaImageGlobalId: Optional[String] + Name: Optional[String] + Description: Optional[String] + ShellVersion: Optional[String] + PciId: Optional[PciId] + State: Optional[FpgaImageState] + CreateTime: Optional[DateTime] + UpdateTime: Optional[DateTime] + OwnerId: Optional[String] + OwnerAlias: Optional[String] + ProductCodes: Optional[ProductCodeList] + Tags: Optional[TagList] + Public: Optional[Boolean] + DataRetentionSupport: Optional[Boolean] + InstanceTypes: Optional[InstanceTypesList] + + +FpgaImageList = List[FpgaImage] + + +class DescribeFpgaImagesResult(TypedDict, total=False): + FpgaImages: Optional[FpgaImageList] + NextToken: Optional[NextToken] + + +class DescribeHostReservationOfferingsRequest(ServiceRequest): + Filter: Optional[FilterList] + MaxDuration: Optional[Integer] + MaxResults: Optional[DescribeHostReservationsMaxResults] + MinDuration: Optional[Integer] + NextToken: Optional[String] + OfferingId: Optional[OfferingId] + + +class HostOffering(TypedDict, total=False): + CurrencyCode: Optional[CurrencyCodeValues] + Duration: Optional[Integer] + HourlyPrice: Optional[String] + InstanceFamily: Optional[String] + OfferingId: Optional[OfferingId] + PaymentOption: Optional[PaymentOption] + UpfrontPrice: Optional[String] + + +HostOfferingSet = List[HostOffering] + + +class DescribeHostReservationOfferingsResult(TypedDict, total=False): + NextToken: Optional[String] + OfferingSet: Optional[HostOfferingSet] + + +HostReservationIdSet = List[HostReservationId] + + +class DescribeHostReservationsRequest(ServiceRequest): + Filter: Optional[FilterList] + HostReservationIdSet: Optional[HostReservationIdSet] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +ResponseHostIdSet = List[String] + + +class HostReservation(TypedDict, total=False): + Count: Optional[Integer] + CurrencyCode: Optional[CurrencyCodeValues] + Duration: Optional[Integer] + End: Optional[DateTime] + HostIdSet: Optional[ResponseHostIdSet] + HostReservationId: Optional[HostReservationId] + HourlyPrice: Optional[String] + InstanceFamily: Optional[String] + OfferingId: Optional[OfferingId] + PaymentOption: Optional[PaymentOption] + Start: Optional[DateTime] + State: Optional[ReservationState] + UpfrontPrice: Optional[String] + Tags: Optional[TagList] + + +HostReservationSet = List[HostReservation] + + +class DescribeHostReservationsResult(TypedDict, total=False): + HostReservationSet: Optional[HostReservationSet] + NextToken: Optional[String] + + +RequestHostIdList = List[DedicatedHostId] + + +class DescribeHostsRequest(ServiceRequest): + HostIds: Optional[RequestHostIdList] + NextToken: Optional[String] + MaxResults: Optional[Integer] + Filter: Optional[FilterList] + + +class HostInstance(TypedDict, total=False): + InstanceId: Optional[String] + InstanceType: Optional[String] + OwnerId: Optional[String] + + +HostInstanceList = List[HostInstance] + + +class HostProperties(TypedDict, total=False): + Cores: Optional[Integer] + InstanceType: Optional[String] + InstanceFamily: Optional[String] + Sockets: Optional[Integer] + TotalVCpus: Optional[Integer] + + +class Host(TypedDict, total=False): + AutoPlacement: Optional[AutoPlacement] + AvailabilityZone: Optional[String] + AvailableCapacity: Optional[AvailableCapacity] + ClientToken: Optional[String] + HostId: Optional[String] + HostProperties: Optional[HostProperties] + HostReservationId: Optional[String] + Instances: Optional[HostInstanceList] + State: Optional[AllocationState] + AllocationTime: Optional[DateTime] + ReleaseTime: Optional[DateTime] + Tags: Optional[TagList] + HostRecovery: Optional[HostRecovery] + AllowsMultipleInstanceTypes: Optional[AllowsMultipleInstanceTypes] + OwnerId: Optional[String] + AvailabilityZoneId: Optional[String] + MemberOfServiceLinkedResourceGroup: Optional[Boolean] + OutpostArn: Optional[String] + HostMaintenance: Optional[HostMaintenance] + AssetId: Optional[AssetId] + + +HostList = List[Host] + + +class DescribeHostsResult(TypedDict, total=False): + Hosts: Optional[HostList] + NextToken: Optional[String] + + +class DescribeIamInstanceProfileAssociationsRequest(ServiceRequest): + AssociationIds: Optional[AssociationIdList] + Filters: Optional[FilterList] + MaxResults: Optional[DescribeIamInstanceProfileAssociationsMaxResults] + NextToken: Optional[NextToken] + + +IamInstanceProfileAssociationSet = List[IamInstanceProfileAssociation] + + +class DescribeIamInstanceProfileAssociationsResult(TypedDict, total=False): + IamInstanceProfileAssociations: Optional[IamInstanceProfileAssociationSet] + NextToken: Optional[NextToken] + + +class DescribeIdFormatRequest(ServiceRequest): + Resource: Optional[String] + + +class DescribeIdFormatResult(TypedDict, total=False): + Statuses: Optional[IdFormatList] + + +class DescribeIdentityIdFormatRequest(ServiceRequest): + Resource: Optional[String] + PrincipalArn: String + + +class DescribeIdentityIdFormatResult(TypedDict, total=False): + Statuses: Optional[IdFormatList] + + +class DescribeImageAttributeRequest(ServiceRequest): + Attribute: ImageAttributeName + ImageId: ImageId + DryRun: Optional[Boolean] + + +ImageIdStringList = List[ImageId] +ExecutableByStringList = List[String] + + +class DescribeImagesRequest(ServiceRequest): + ExecutableUsers: Optional[ExecutableByStringList] + ImageIds: Optional[ImageIdStringList] + Owners: Optional[OwnerStringList] + IncludeDeprecated: Optional[Boolean] + IncludeDisabled: Optional[Boolean] + MaxResults: Optional[Integer] + NextToken: Optional[String] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class Image(TypedDict, total=False): + PlatformDetails: Optional[String] + UsageOperation: Optional[String] + BlockDeviceMappings: Optional[BlockDeviceMappingList] + Description: Optional[String] + EnaSupport: Optional[Boolean] + Hypervisor: Optional[HypervisorType] + ImageOwnerAlias: Optional[String] + Name: Optional[String] + RootDeviceName: Optional[String] + RootDeviceType: Optional[DeviceType] + SriovNetSupport: Optional[String] + StateReason: Optional[StateReason] + Tags: Optional[TagList] + VirtualizationType: Optional[VirtualizationType] + BootMode: Optional[BootModeValues] + TpmSupport: Optional[TpmSupportValues] + DeprecationTime: Optional[String] + ImdsSupport: Optional[ImdsSupportValues] + SourceInstanceId: Optional[String] + DeregistrationProtection: Optional[String] + LastLaunchedTime: Optional[String] + ImageAllowed: Optional[Boolean] + SourceImageId: Optional[String] + SourceImageRegion: Optional[String] + ImageId: Optional[String] + ImageLocation: Optional[String] + State: Optional[ImageState] + OwnerId: Optional[String] + CreationDate: Optional[String] + Public: Optional[Boolean] + ProductCodes: Optional[ProductCodeList] + Architecture: Optional[ArchitectureValues] + ImageType: Optional[ImageTypeValues] + KernelId: Optional[String] + RamdiskId: Optional[String] + Platform: Optional[PlatformValues] + + +ImageList = List[Image] + + +class DescribeImagesResult(TypedDict, total=False): + NextToken: Optional[String] + Images: Optional[ImageList] + + +ImportTaskIdList = List[ImportImageTaskId] + + +class DescribeImportImageTasksRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + ImportTaskIds: Optional[ImportTaskIdList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class ImportImageLicenseConfigurationResponse(TypedDict, total=False): + LicenseConfigurationArn: Optional[String] + + +ImportImageLicenseSpecificationListResponse = List[ImportImageLicenseConfigurationResponse] + + +class UserBucketDetails(TypedDict, total=False): + S3Bucket: Optional[String] + S3Key: Optional[String] + + +class SnapshotDetail(TypedDict, total=False): + Description: Optional[String] + DeviceName: Optional[String] + DiskImageSize: Optional[Double] + Format: Optional[String] + Progress: Optional[String] + SnapshotId: Optional[String] + Status: Optional[String] + StatusMessage: Optional[String] + Url: Optional[SensitiveUrl] + UserBucket: Optional[UserBucketDetails] + + +SnapshotDetailList = List[SnapshotDetail] + + +class ImportImageTask(TypedDict, total=False): + Architecture: Optional[String] + Description: Optional[String] + Encrypted: Optional[Boolean] + Hypervisor: Optional[String] + ImageId: Optional[String] + ImportTaskId: Optional[String] + KmsKeyId: Optional[String] + LicenseType: Optional[String] + Platform: Optional[String] + Progress: Optional[String] + SnapshotDetails: Optional[SnapshotDetailList] + Status: Optional[String] + StatusMessage: Optional[String] + Tags: Optional[TagList] + LicenseSpecifications: Optional[ImportImageLicenseSpecificationListResponse] + UsageOperation: Optional[String] + BootMode: Optional[BootModeValues] + + +ImportImageTaskList = List[ImportImageTask] + + +class DescribeImportImageTasksResult(TypedDict, total=False): + ImportImageTasks: Optional[ImportImageTaskList] + NextToken: Optional[String] + + +ImportSnapshotTaskIdList = List[ImportSnapshotTaskId] + + +class DescribeImportSnapshotTasksRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + ImportTaskIds: Optional[ImportSnapshotTaskIdList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class SnapshotTaskDetail(TypedDict, total=False): + Description: Optional[String] + DiskImageSize: Optional[Double] + Encrypted: Optional[Boolean] + Format: Optional[String] + KmsKeyId: Optional[String] + Progress: Optional[String] + SnapshotId: Optional[String] + Status: Optional[String] + StatusMessage: Optional[String] + Url: Optional[SensitiveUrl] + UserBucket: Optional[UserBucketDetails] + + +class ImportSnapshotTask(TypedDict, total=False): + Description: Optional[String] + ImportTaskId: Optional[String] + SnapshotTaskDetail: Optional[SnapshotTaskDetail] + Tags: Optional[TagList] + + +ImportSnapshotTaskList = List[ImportSnapshotTask] + + +class DescribeImportSnapshotTasksResult(TypedDict, total=False): + ImportSnapshotTasks: Optional[ImportSnapshotTaskList] + NextToken: Optional[String] + + +class DescribeInstanceAttributeRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + Attribute: InstanceAttributeName + + +class DescribeInstanceConnectEndpointsRequest(ServiceRequest): + DryRun: Optional[Boolean] + MaxResults: Optional[InstanceConnectEndpointMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + InstanceConnectEndpointIds: Optional[ValueStringList] + + +InstanceConnectEndpointSet = List[Ec2InstanceConnectEndpoint] + + +class DescribeInstanceConnectEndpointsResult(TypedDict, total=False): + InstanceConnectEndpoints: Optional[InstanceConnectEndpointSet] + NextToken: Optional[NextToken] + + +class DescribeInstanceCreditSpecificationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + InstanceIds: Optional[InstanceIdStringList] + MaxResults: Optional[DescribeInstanceCreditSpecificationsMaxResults] + NextToken: Optional[String] + + +class InstanceCreditSpecification(TypedDict, total=False): + InstanceId: Optional[String] + CpuCredits: Optional[String] + + +InstanceCreditSpecificationList = List[InstanceCreditSpecification] + + +class DescribeInstanceCreditSpecificationsResult(TypedDict, total=False): + InstanceCreditSpecifications: Optional[InstanceCreditSpecificationList] + NextToken: Optional[String] + + +class DescribeInstanceEventNotificationAttributesRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DescribeInstanceEventNotificationAttributesResult(TypedDict, total=False): + InstanceTagAttribute: Optional[InstanceTagNotificationAttribute] + + +InstanceEventWindowIdSet = List[InstanceEventWindowId] + + +class DescribeInstanceEventWindowsRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceEventWindowIds: Optional[InstanceEventWindowIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[ResultRange] + NextToken: Optional[String] + + +InstanceEventWindowSet = List[InstanceEventWindow] + + +class DescribeInstanceEventWindowsResult(TypedDict, total=False): + InstanceEventWindows: Optional[InstanceEventWindowSet] + NextToken: Optional[String] + + +class DescribeInstanceImageMetadataRequest(ServiceRequest): + Filters: Optional[FilterList] + InstanceIds: Optional[InstanceIdStringList] + MaxResults: Optional[DescribeInstanceImageMetadataMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class ImageMetadata(TypedDict, total=False): + ImageId: Optional[ImageId] + Name: Optional[String] + OwnerId: Optional[String] + State: Optional[ImageState] + ImageOwnerAlias: Optional[String] + CreationDate: Optional[String] + DeprecationTime: Optional[String] + ImageAllowed: Optional[Boolean] + IsPublic: Optional[Boolean] + + +class InstanceState(TypedDict, total=False): + Code: Optional[Integer] + Name: Optional[InstanceStateName] + + +class InstanceImageMetadata(TypedDict, total=False): + InstanceId: Optional[InstanceId] + InstanceType: Optional[InstanceType] + LaunchTime: Optional[MillisecondDateTime] + AvailabilityZone: Optional[String] + ZoneId: Optional[String] + State: Optional[InstanceState] + OwnerId: Optional[String] + Tags: Optional[TagList] + ImageMetadata: Optional[ImageMetadata] + Operator: Optional[OperatorResponse] + + +InstanceImageMetadataList = List[InstanceImageMetadata] + + +class DescribeInstanceImageMetadataResult(TypedDict, total=False): + InstanceImageMetadata: Optional[InstanceImageMetadataList] + NextToken: Optional[String] + + +class DescribeInstanceStatusRequest(ServiceRequest): + InstanceIds: Optional[InstanceIdStringList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + IncludeAllInstances: Optional[Boolean] + + +class EbsStatusDetails(TypedDict, total=False): + ImpairedSince: Optional[MillisecondDateTime] + Name: Optional[StatusName] + Status: Optional[StatusType] + + +EbsStatusDetailsList = List[EbsStatusDetails] + + +class EbsStatusSummary(TypedDict, total=False): + Details: Optional[EbsStatusDetailsList] + Status: Optional[SummaryStatus] + + +class InstanceStatusDetails(TypedDict, total=False): + ImpairedSince: Optional[DateTime] + Name: Optional[StatusName] + Status: Optional[StatusType] + + +InstanceStatusDetailsList = List[InstanceStatusDetails] + + +class InstanceStatusSummary(TypedDict, total=False): + Details: Optional[InstanceStatusDetailsList] + Status: Optional[SummaryStatus] + + +class InstanceStatusEvent(TypedDict, total=False): + InstanceEventId: Optional[InstanceEventId] + Code: Optional[EventCode] + Description: Optional[String] + NotAfter: Optional[DateTime] + NotBefore: Optional[DateTime] + NotBeforeDeadline: Optional[DateTime] + + +InstanceStatusEventList = List[InstanceStatusEvent] + + +class InstanceStatus(TypedDict, total=False): + AvailabilityZone: Optional[String] + OutpostArn: Optional[String] + Operator: Optional[OperatorResponse] + Events: Optional[InstanceStatusEventList] + InstanceId: Optional[String] + InstanceState: Optional[InstanceState] + InstanceStatus: Optional[InstanceStatusSummary] + SystemStatus: Optional[InstanceStatusSummary] + AttachedEbsStatus: Optional[EbsStatusSummary] + + +InstanceStatusList = List[InstanceStatus] + + +class DescribeInstanceStatusResult(TypedDict, total=False): + InstanceStatuses: Optional[InstanceStatusList] + NextToken: Optional[String] + + +DescribeInstanceTopologyGroupNameSet = List[PlacementGroupName] +DescribeInstanceTopologyInstanceIdSet = List[InstanceId] + + +class DescribeInstanceTopologyRequest(ServiceRequest): + DryRun: Optional[Boolean] + NextToken: Optional[String] + MaxResults: Optional[DescribeInstanceTopologyMaxResults] + InstanceIds: Optional[DescribeInstanceTopologyInstanceIdSet] + GroupNames: Optional[DescribeInstanceTopologyGroupNameSet] + Filters: Optional[FilterList] + + +NetworkNodesList = List[String] + + +class InstanceTopology(TypedDict, total=False): + InstanceId: Optional[String] + InstanceType: Optional[String] + GroupName: Optional[String] + NetworkNodes: Optional[NetworkNodesList] + AvailabilityZone: Optional[String] + ZoneId: Optional[String] + CapacityBlockId: Optional[String] + + +InstanceSet = List[InstanceTopology] + + +class DescribeInstanceTopologyResult(TypedDict, total=False): + Instances: Optional[InstanceSet] + NextToken: Optional[String] + + +class DescribeInstanceTypeOfferingsRequest(ServiceRequest): + DryRun: Optional[Boolean] + LocationType: Optional[LocationType] + Filters: Optional[FilterList] + MaxResults: Optional[DITOMaxResults] + NextToken: Optional[NextToken] + + +class InstanceTypeOffering(TypedDict, total=False): + InstanceType: Optional[InstanceType] + LocationType: Optional[LocationType] + Location: Optional[Location] + + +InstanceTypeOfferingsList = List[InstanceTypeOffering] + + +class DescribeInstanceTypeOfferingsResult(TypedDict, total=False): + InstanceTypeOfferings: Optional[InstanceTypeOfferingsList] + NextToken: Optional[NextToken] + + +RequestInstanceTypeList = List[InstanceType] + + +class DescribeInstanceTypesRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceTypes: Optional[RequestInstanceTypeList] + Filters: Optional[FilterList] + MaxResults: Optional[DITMaxResults] + NextToken: Optional[NextToken] + + +class NeuronDeviceMemoryInfo(TypedDict, total=False): + SizeInMiB: Optional[NeuronDeviceMemorySize] + + +class NeuronDeviceCoreInfo(TypedDict, total=False): + Count: Optional[NeuronDeviceCoreCount] + Version: Optional[NeuronDeviceCoreVersion] + + +class NeuronDeviceInfo(TypedDict, total=False): + Count: Optional[NeuronDeviceCount] + Name: Optional[NeuronDeviceName] + CoreInfo: Optional[NeuronDeviceCoreInfo] + MemoryInfo: Optional[NeuronDeviceMemoryInfo] + + +NeuronDeviceInfoList = List[NeuronDeviceInfo] + + +class NeuronInfo(TypedDict, total=False): + NeuronDevices: Optional[NeuronDeviceInfoList] + TotalNeuronDeviceMemoryInMiB: Optional[TotalNeuronMemory] + + +class MediaDeviceMemoryInfo(TypedDict, total=False): + SizeInMiB: Optional[MediaDeviceMemorySize] + + +class MediaDeviceInfo(TypedDict, total=False): + Count: Optional[MediaDeviceCount] + Name: Optional[MediaDeviceName] + Manufacturer: Optional[MediaDeviceManufacturerName] + MemoryInfo: Optional[MediaDeviceMemoryInfo] + + +MediaDeviceInfoList = List[MediaDeviceInfo] + + +class MediaAcceleratorInfo(TypedDict, total=False): + Accelerators: Optional[MediaDeviceInfoList] + TotalMediaMemoryInMiB: Optional[TotalMediaMemory] + + +NitroTpmSupportedVersionsList = List[NitroTpmSupportedVersionType] + + +class NitroTpmInfo(TypedDict, total=False): + SupportedVersions: Optional[NitroTpmSupportedVersionsList] + + +class InferenceDeviceMemoryInfo(TypedDict, total=False): + SizeInMiB: Optional[InferenceDeviceMemorySize] + + +class InferenceDeviceInfo(TypedDict, total=False): + Count: Optional[InferenceDeviceCount] + Name: Optional[InferenceDeviceName] + Manufacturer: Optional[InferenceDeviceManufacturerName] + MemoryInfo: Optional[InferenceDeviceMemoryInfo] + + +InferenceDeviceInfoList = List[InferenceDeviceInfo] + + +class InferenceAcceleratorInfo(TypedDict, total=False): + Accelerators: Optional[InferenceDeviceInfoList] + TotalInferenceMemoryInMiB: Optional[totalInferenceMemory] + + +PlacementGroupStrategyList = List[PlacementGroupStrategy] + + +class PlacementGroupInfo(TypedDict, total=False): + SupportedStrategies: Optional[PlacementGroupStrategyList] + + +class FpgaDeviceMemoryInfo(TypedDict, total=False): + SizeInMiB: Optional[FpgaDeviceMemorySize] + + +class FpgaDeviceInfo(TypedDict, total=False): + Name: Optional[FpgaDeviceName] + Manufacturer: Optional[FpgaDeviceManufacturerName] + Count: Optional[FpgaDeviceCount] + MemoryInfo: Optional[FpgaDeviceMemoryInfo] + + +FpgaDeviceInfoList = List[FpgaDeviceInfo] + + +class FpgaInfo(TypedDict, total=False): + Fpgas: Optional[FpgaDeviceInfoList] + TotalFpgaMemoryInMiB: Optional[totalFpgaMemory] + + +class GpuDeviceMemoryInfo(TypedDict, total=False): + SizeInMiB: Optional[GpuDeviceMemorySize] + + +class GpuDeviceInfo(TypedDict, total=False): + Name: Optional[GpuDeviceName] + Manufacturer: Optional[GpuDeviceManufacturerName] + Count: Optional[GpuDeviceCount] + MemoryInfo: Optional[GpuDeviceMemoryInfo] + + +GpuDeviceInfoList = List[GpuDeviceInfo] + + +class GpuInfo(TypedDict, total=False): + Gpus: Optional[GpuDeviceInfoList] + TotalGpuMemoryInMiB: Optional[totalGpuMemory] + + +class EfaInfo(TypedDict, total=False): + MaximumEfaInterfaces: Optional[MaximumEfaInterfaces] + + +class NetworkCardInfo(TypedDict, total=False): + NetworkCardIndex: Optional[NetworkCardIndex] + NetworkPerformance: Optional[NetworkPerformance] + MaximumNetworkInterfaces: Optional[MaxNetworkInterfaces] + BaselineBandwidthInGbps: Optional[BaselineBandwidthInGbps] + PeakBandwidthInGbps: Optional[PeakBandwidthInGbps] + DefaultEnaQueueCountPerInterface: Optional[DefaultEnaQueueCountPerInterface] + MaximumEnaQueueCount: Optional[MaximumEnaQueueCount] + MaximumEnaQueueCountPerInterface: Optional[MaximumEnaQueueCountPerInterface] + + +NetworkCardInfoList = List[NetworkCardInfo] + + +class NetworkInfo(TypedDict, total=False): + NetworkPerformance: Optional[NetworkPerformance] + MaximumNetworkInterfaces: Optional[MaxNetworkInterfaces] + MaximumNetworkCards: Optional[MaximumNetworkCards] + DefaultNetworkCardIndex: Optional[DefaultNetworkCardIndex] + NetworkCards: Optional[NetworkCardInfoList] + Ipv4AddressesPerInterface: Optional[MaxIpv4AddrPerInterface] + Ipv6AddressesPerInterface: Optional[MaxIpv6AddrPerInterface] + Ipv6Supported: Optional[Ipv6Flag] + EnaSupport: Optional[EnaSupport] + EfaSupported: Optional[EfaSupportedFlag] + EfaInfo: Optional[EfaInfo] + EncryptionInTransitSupported: Optional[EncryptionInTransitSupported] + EnaSrdSupported: Optional[EnaSrdSupported] + BandwidthWeightings: Optional[BandwidthWeightingTypeList] + FlexibleEnaQueuesSupport: Optional[FlexibleEnaQueuesSupport] + + +class EbsOptimizedInfo(TypedDict, total=False): + BaselineBandwidthInMbps: Optional[BaselineBandwidthInMbps] + BaselineThroughputInMBps: Optional[BaselineThroughputInMBps] + BaselineIops: Optional[BaselineIops] + MaximumBandwidthInMbps: Optional[MaximumBandwidthInMbps] + MaximumThroughputInMBps: Optional[MaximumThroughputInMBps] + MaximumIops: Optional[MaximumIops] + + +class EbsInfo(TypedDict, total=False): + EbsOptimizedSupport: Optional[EbsOptimizedSupport] + EncryptionSupport: Optional[EbsEncryptionSupport] + EbsOptimizedInfo: Optional[EbsOptimizedInfo] + NvmeSupport: Optional[EbsNvmeSupport] + + +DiskSize = int + + +class DiskInfo(TypedDict, total=False): + SizeInGB: Optional[DiskSize] + Count: Optional[DiskCount] + Type: Optional[DiskType] + + +DiskInfoList = List[DiskInfo] + + +class InstanceStorageInfo(TypedDict, total=False): + TotalSizeInGB: Optional[DiskSize] + Disks: Optional[DiskInfoList] + NvmeSupport: Optional[EphemeralNvmeSupport] + EncryptionSupport: Optional[InstanceStorageEncryptionSupport] + + +MemorySize = int + + +class MemoryInfo(TypedDict, total=False): + SizeInMiB: Optional[MemorySize] + + +ThreadsPerCoreList = List[ThreadsPerCore] + + +class VCpuInfo(TypedDict, total=False): + DefaultVCpus: Optional[VCpuCount] + DefaultCores: Optional[CoreCount] + DefaultThreadsPerCore: Optional[ThreadsPerCore] + ValidCores: Optional[CoreCountList] + ValidThreadsPerCore: Optional[ThreadsPerCoreList] + + +SupportedAdditionalProcessorFeatureList = List[SupportedAdditionalProcessorFeature] + + +class ProcessorInfo(TypedDict, total=False): + SupportedArchitectures: Optional[ArchitectureTypeList] + SustainedClockSpeedInGhz: Optional[ProcessorSustainedClockSpeed] + SupportedFeatures: Optional[SupportedAdditionalProcessorFeatureList] + Manufacturer: Optional[CpuManufacturerName] + + +VirtualizationTypeList = List[VirtualizationType] +RootDeviceTypeList = List[RootDeviceType] +UsageClassTypeList = List[UsageClassType] + + +class InstanceTypeInfo(TypedDict, total=False): + InstanceType: Optional[InstanceType] + CurrentGeneration: Optional[CurrentGenerationFlag] + FreeTierEligible: Optional[FreeTierEligibleFlag] + SupportedUsageClasses: Optional[UsageClassTypeList] + SupportedRootDeviceTypes: Optional[RootDeviceTypeList] + SupportedVirtualizationTypes: Optional[VirtualizationTypeList] + BareMetal: Optional[BareMetalFlag] + Hypervisor: Optional[InstanceTypeHypervisor] + ProcessorInfo: Optional[ProcessorInfo] + VCpuInfo: Optional[VCpuInfo] + MemoryInfo: Optional[MemoryInfo] + InstanceStorageSupported: Optional[InstanceStorageFlag] + InstanceStorageInfo: Optional[InstanceStorageInfo] + EbsInfo: Optional[EbsInfo] + NetworkInfo: Optional[NetworkInfo] + GpuInfo: Optional[GpuInfo] + FpgaInfo: Optional[FpgaInfo] + PlacementGroupInfo: Optional[PlacementGroupInfo] + InferenceAcceleratorInfo: Optional[InferenceAcceleratorInfo] + HibernationSupported: Optional[HibernationFlag] + BurstablePerformanceSupported: Optional[BurstablePerformanceFlag] + DedicatedHostsSupported: Optional[DedicatedHostFlag] + AutoRecoverySupported: Optional[AutoRecoveryFlag] + SupportedBootModes: Optional[BootModeTypeList] + NitroEnclavesSupport: Optional[NitroEnclavesSupport] + NitroTpmSupport: Optional[NitroTpmSupport] + NitroTpmInfo: Optional[NitroTpmInfo] + MediaAcceleratorInfo: Optional[MediaAcceleratorInfo] + NeuronInfo: Optional[NeuronInfo] + PhcSupport: Optional[PhcSupport] + RebootMigrationSupport: Optional[RebootMigrationSupport] + + +InstanceTypeInfoList = List[InstanceTypeInfo] + + +class DescribeInstanceTypesResult(TypedDict, total=False): + InstanceTypes: Optional[InstanceTypeInfoList] + NextToken: Optional[NextToken] + + +class DescribeInstancesRequest(ServiceRequest): + InstanceIds: Optional[InstanceIdStringList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[Integer] + + +class Monitoring(TypedDict, total=False): + State: Optional[MonitoringState] + + +class InstanceNetworkPerformanceOptions(TypedDict, total=False): + BandwidthWeighting: Optional[InstanceBandwidthWeighting] + + +class InstanceMaintenanceOptions(TypedDict, total=False): + AutoRecovery: Optional[InstanceAutoRecoveryState] + RebootMigration: Optional[InstanceRebootMigrationState] + + +class PrivateDnsNameOptionsResponse(TypedDict, total=False): + HostnameType: Optional[HostnameType] + EnableResourceNameDnsARecord: Optional[Boolean] + EnableResourceNameDnsAAAARecord: Optional[Boolean] + + +class EnclaveOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class InstanceMetadataOptionsResponse(TypedDict, total=False): + State: Optional[InstanceMetadataOptionsState] + HttpTokens: Optional[HttpTokensState] + HttpPutResponseHopLimit: Optional[Integer] + HttpEndpoint: Optional[InstanceMetadataEndpointState] + HttpProtocolIpv6: Optional[InstanceMetadataProtocolState] + InstanceMetadataTags: Optional[InstanceMetadataTagsState] + + +class LicenseConfiguration(TypedDict, total=False): + LicenseConfigurationArn: Optional[String] + + +LicenseList = List[LicenseConfiguration] + + +class HibernationOptions(TypedDict, total=False): + Configured: Optional[Boolean] + + +class InstanceIpv6Prefix(TypedDict, total=False): + Ipv6Prefix: Optional[String] + + +InstanceIpv6PrefixList = List[InstanceIpv6Prefix] + + +class InstanceIpv4Prefix(TypedDict, total=False): + Ipv4Prefix: Optional[String] + + +InstanceIpv4PrefixList = List[InstanceIpv4Prefix] + + +class InstanceNetworkInterfaceAssociation(TypedDict, total=False): + CarrierIp: Optional[String] + CustomerOwnedIp: Optional[String] + IpOwnerId: Optional[String] + PublicDnsName: Optional[String] + PublicIp: Optional[String] + + +class InstancePrivateIpAddress(TypedDict, total=False): + Association: Optional[InstanceNetworkInterfaceAssociation] + Primary: Optional[Boolean] + PrivateDnsName: Optional[String] + PrivateIpAddress: Optional[String] + + +InstancePrivateIpAddressList = List[InstancePrivateIpAddress] + + +class InstanceAttachmentEnaSrdUdpSpecification(TypedDict, total=False): + EnaSrdUdpEnabled: Optional[Boolean] + + +class InstanceAttachmentEnaSrdSpecification(TypedDict, total=False): + EnaSrdEnabled: Optional[Boolean] + EnaSrdUdpSpecification: Optional[InstanceAttachmentEnaSrdUdpSpecification] + + +class InstanceNetworkInterfaceAttachment(TypedDict, total=False): + AttachTime: Optional[DateTime] + AttachmentId: Optional[String] + DeleteOnTermination: Optional[Boolean] + DeviceIndex: Optional[Integer] + Status: Optional[AttachmentStatus] + NetworkCardIndex: Optional[Integer] + EnaSrdSpecification: Optional[InstanceAttachmentEnaSrdSpecification] + EnaQueueCount: Optional[Integer] + + +class InstanceNetworkInterface(TypedDict, total=False): + Association: Optional[InstanceNetworkInterfaceAssociation] + Attachment: Optional[InstanceNetworkInterfaceAttachment] + Description: Optional[String] + Groups: Optional[GroupIdentifierList] + Ipv6Addresses: Optional[InstanceIpv6AddressList] + MacAddress: Optional[String] + NetworkInterfaceId: Optional[String] + OwnerId: Optional[String] + PrivateDnsName: Optional[String] + PrivateIpAddress: Optional[String] + PrivateIpAddresses: Optional[InstancePrivateIpAddressList] + SourceDestCheck: Optional[Boolean] + Status: Optional[NetworkInterfaceStatus] + SubnetId: Optional[String] + VpcId: Optional[String] + InterfaceType: Optional[String] + Ipv4Prefixes: Optional[InstanceIpv4PrefixList] + Ipv6Prefixes: Optional[InstanceIpv6PrefixList] + ConnectionTrackingConfiguration: Optional[ConnectionTrackingSpecificationResponse] + Operator: Optional[OperatorResponse] + + +InstanceNetworkInterfaceList = List[InstanceNetworkInterface] + + +class ElasticInferenceAcceleratorAssociation(TypedDict, total=False): + ElasticInferenceAcceleratorArn: Optional[String] + ElasticInferenceAcceleratorAssociationId: Optional[String] + ElasticInferenceAcceleratorAssociationState: Optional[String] + ElasticInferenceAcceleratorAssociationTime: Optional[DateTime] + + +ElasticInferenceAcceleratorAssociationList = List[ElasticInferenceAcceleratorAssociation] + + +class ElasticGpuAssociation(TypedDict, total=False): + ElasticGpuId: Optional[ElasticGpuId] + ElasticGpuAssociationId: Optional[String] + ElasticGpuAssociationState: Optional[String] + ElasticGpuAssociationTime: Optional[String] + + +ElasticGpuAssociationList = List[ElasticGpuAssociation] + + +class EbsInstanceBlockDevice(TypedDict, total=False): + AttachTime: Optional[DateTime] + DeleteOnTermination: Optional[Boolean] + Status: Optional[AttachmentStatus] + VolumeId: Optional[String] + AssociatedResource: Optional[String] + VolumeOwnerId: Optional[String] + Operator: Optional[OperatorResponse] + + +class InstanceBlockDeviceMapping(TypedDict, total=False): + DeviceName: Optional[String] + Ebs: Optional[EbsInstanceBlockDevice] + + +InstanceBlockDeviceMappingList = List[InstanceBlockDeviceMapping] + + +class Instance(TypedDict, total=False): + Architecture: Optional[ArchitectureValues] + BlockDeviceMappings: Optional[InstanceBlockDeviceMappingList] + ClientToken: Optional[String] + EbsOptimized: Optional[Boolean] + EnaSupport: Optional[Boolean] + Hypervisor: Optional[HypervisorType] + IamInstanceProfile: Optional[IamInstanceProfile] + InstanceLifecycle: Optional[InstanceLifecycleType] + ElasticGpuAssociations: Optional[ElasticGpuAssociationList] + ElasticInferenceAcceleratorAssociations: Optional[ElasticInferenceAcceleratorAssociationList] + NetworkInterfaces: Optional[InstanceNetworkInterfaceList] + OutpostArn: Optional[String] + RootDeviceName: Optional[String] + RootDeviceType: Optional[DeviceType] + SecurityGroups: Optional[GroupIdentifierList] + SourceDestCheck: Optional[Boolean] + SpotInstanceRequestId: Optional[String] + SriovNetSupport: Optional[String] + StateReason: Optional[StateReason] + Tags: Optional[TagList] + VirtualizationType: Optional[VirtualizationType] + CpuOptions: Optional[CpuOptions] + CapacityBlockId: Optional[String] + CapacityReservationId: Optional[String] + CapacityReservationSpecification: Optional[CapacityReservationSpecificationResponse] + HibernationOptions: Optional[HibernationOptions] + Licenses: Optional[LicenseList] + MetadataOptions: Optional[InstanceMetadataOptionsResponse] + EnclaveOptions: Optional[EnclaveOptions] + BootMode: Optional[BootModeValues] + PlatformDetails: Optional[String] + UsageOperation: Optional[String] + UsageOperationUpdateTime: Optional[MillisecondDateTime] + PrivateDnsNameOptions: Optional[PrivateDnsNameOptionsResponse] + Ipv6Address: Optional[String] + TpmSupport: Optional[String] + MaintenanceOptions: Optional[InstanceMaintenanceOptions] + CurrentInstanceBootMode: Optional[InstanceBootModeValues] + NetworkPerformanceOptions: Optional[InstanceNetworkPerformanceOptions] + Operator: Optional[OperatorResponse] + InstanceId: Optional[String] + ImageId: Optional[String] + State: Optional[InstanceState] + PrivateDnsName: Optional[String] + PublicDnsName: Optional[String] + StateTransitionReason: Optional[String] + KeyName: Optional[String] + AmiLaunchIndex: Optional[Integer] + ProductCodes: Optional[ProductCodeList] + InstanceType: Optional[InstanceType] + LaunchTime: Optional[DateTime] + Placement: Optional[Placement] + KernelId: Optional[String] + RamdiskId: Optional[String] + Platform: Optional[PlatformValues] + Monitoring: Optional[Monitoring] + SubnetId: Optional[String] + VpcId: Optional[String] + PrivateIpAddress: Optional[String] + PublicIpAddress: Optional[String] + + +InstanceList = List[Instance] + + +class Reservation(TypedDict, total=False): + ReservationId: Optional[String] + OwnerId: Optional[String] + RequesterId: Optional[String] + Groups: Optional[GroupIdentifierList] + Instances: Optional[InstanceList] + + +ReservationList = List[Reservation] + + +class DescribeInstancesResult(TypedDict, total=False): + NextToken: Optional[String] + Reservations: Optional[ReservationList] + + +InternetGatewayIdList = List[InternetGatewayId] + + +class DescribeInternetGatewaysRequest(ServiceRequest): + NextToken: Optional[String] + MaxResults: Optional[DescribeInternetGatewaysMaxResults] + DryRun: Optional[Boolean] + InternetGatewayIds: Optional[InternetGatewayIdList] + Filters: Optional[FilterList] + + +InternetGatewayList = List[InternetGateway] + + +class DescribeInternetGatewaysResult(TypedDict, total=False): + InternetGateways: Optional[InternetGatewayList] + NextToken: Optional[String] + + +class DescribeIpamByoasnRequest(ServiceRequest): + DryRun: Optional[Boolean] + MaxResults: Optional[DescribeIpamByoasnMaxResults] + NextToken: Optional[NextToken] + + +class DescribeIpamByoasnResult(TypedDict, total=False): + Byoasns: Optional[ByoasnSet] + NextToken: Optional[String] + + +class DescribeIpamExternalResourceVerificationTokensRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[IpamMaxResults] + IpamExternalResourceVerificationTokenIds: Optional[ValueStringList] + + +IpamExternalResourceVerificationTokenSet = List[IpamExternalResourceVerificationToken] + + +class DescribeIpamExternalResourceVerificationTokensResult(TypedDict, total=False): + NextToken: Optional[NextToken] + IpamExternalResourceVerificationTokens: Optional[IpamExternalResourceVerificationTokenSet] + + +class DescribeIpamPoolsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[IpamMaxResults] + NextToken: Optional[NextToken] + IpamPoolIds: Optional[ValueStringList] + + +IpamPoolSet = List[IpamPool] + + +class DescribeIpamPoolsResult(TypedDict, total=False): + NextToken: Optional[NextToken] + IpamPools: Optional[IpamPoolSet] + + +class DescribeIpamResourceDiscoveriesRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryIds: Optional[ValueStringList] + NextToken: Optional[NextToken] + MaxResults: Optional[IpamMaxResults] + Filters: Optional[FilterList] + + +IpamResourceDiscoverySet = List[IpamResourceDiscovery] + + +class DescribeIpamResourceDiscoveriesResult(TypedDict, total=False): + IpamResourceDiscoveries: Optional[IpamResourceDiscoverySet] + NextToken: Optional[NextToken] + + +class DescribeIpamResourceDiscoveryAssociationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryAssociationIds: Optional[ValueStringList] + NextToken: Optional[NextToken] + MaxResults: Optional[IpamMaxResults] + Filters: Optional[FilterList] + + +IpamResourceDiscoveryAssociationSet = List[IpamResourceDiscoveryAssociation] + + +class DescribeIpamResourceDiscoveryAssociationsResult(TypedDict, total=False): + IpamResourceDiscoveryAssociations: Optional[IpamResourceDiscoveryAssociationSet] + NextToken: Optional[NextToken] + + +class DescribeIpamScopesRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[IpamMaxResults] + NextToken: Optional[NextToken] + IpamScopeIds: Optional[ValueStringList] + + +IpamScopeSet = List[IpamScope] + + +class DescribeIpamScopesResult(TypedDict, total=False): + NextToken: Optional[NextToken] + IpamScopes: Optional[IpamScopeSet] + + +class DescribeIpamsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[IpamMaxResults] + NextToken: Optional[NextToken] + IpamIds: Optional[ValueStringList] + + +IpamSet = List[Ipam] + + +class DescribeIpamsResult(TypedDict, total=False): + NextToken: Optional[NextToken] + Ipams: Optional[IpamSet] + + +Ipv6PoolIdList = List[Ipv6PoolEc2Id] + + +class DescribeIpv6PoolsRequest(ServiceRequest): + PoolIds: Optional[Ipv6PoolIdList] + NextToken: Optional[NextToken] + MaxResults: Optional[Ipv6PoolMaxResults] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class PoolCidrBlock(TypedDict, total=False): + Cidr: Optional[String] + + +PoolCidrBlocksSet = List[PoolCidrBlock] + + +class Ipv6Pool(TypedDict, total=False): + PoolId: Optional[String] + Description: Optional[String] + PoolCidrBlocks: Optional[PoolCidrBlocksSet] + Tags: Optional[TagList] + + +Ipv6PoolSet = List[Ipv6Pool] + + +class DescribeIpv6PoolsResult(TypedDict, total=False): + Ipv6Pools: Optional[Ipv6PoolSet] + NextToken: Optional[NextToken] + + +KeyPairIdStringList = List[KeyPairId] +KeyNameStringList = List[KeyPairName] + + +class DescribeKeyPairsRequest(ServiceRequest): + KeyNames: Optional[KeyNameStringList] + KeyPairIds: Optional[KeyPairIdStringList] + IncludePublicKey: Optional[Boolean] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class KeyPairInfo(TypedDict, total=False): + KeyPairId: Optional[String] + KeyType: Optional[KeyType] + Tags: Optional[TagList] + PublicKey: Optional[String] + CreateTime: Optional[MillisecondDateTime] + KeyName: Optional[String] + KeyFingerprint: Optional[String] + + +KeyPairList = List[KeyPairInfo] + + +class DescribeKeyPairsResult(TypedDict, total=False): + KeyPairs: Optional[KeyPairList] + + +class DescribeLaunchTemplateVersionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[LaunchTemplateName] + Versions: Optional[VersionStringList] + MinVersion: Optional[String] + MaxVersion: Optional[String] + NextToken: Optional[String] + MaxResults: Optional[Integer] + Filters: Optional[FilterList] + ResolveAlias: Optional[Boolean] + + +LaunchTemplateVersionSet = List[LaunchTemplateVersion] + + +class DescribeLaunchTemplateVersionsResult(TypedDict, total=False): + LaunchTemplateVersions: Optional[LaunchTemplateVersionSet] + NextToken: Optional[String] + + +LaunchTemplateNameStringList = List[LaunchTemplateName] +LaunchTemplateIdStringList = List[LaunchTemplateId] + + +class DescribeLaunchTemplatesRequest(ServiceRequest): + DryRun: Optional[Boolean] + LaunchTemplateIds: Optional[LaunchTemplateIdStringList] + LaunchTemplateNames: Optional[LaunchTemplateNameStringList] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[DescribeLaunchTemplatesMaxResults] + + +LaunchTemplateSet = List[LaunchTemplate] + + +class DescribeLaunchTemplatesResult(TypedDict, total=False): + LaunchTemplates: Optional[LaunchTemplateSet] + NextToken: Optional[String] + + +LocalGatewayRouteTableVirtualInterfaceGroupAssociationIdSet = List[ + LocalGatewayRouteTableVirtualInterfaceGroupAssociationId +] + + +class DescribeLocalGatewayRouteTableVirtualInterfaceGroupAssociationsRequest(ServiceRequest): + LocalGatewayRouteTableVirtualInterfaceGroupAssociationIds: Optional[ + LocalGatewayRouteTableVirtualInterfaceGroupAssociationIdSet + ] + Filters: Optional[FilterList] + MaxResults: Optional[LocalGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +LocalGatewayRouteTableVirtualInterfaceGroupAssociationSet = List[ + LocalGatewayRouteTableVirtualInterfaceGroupAssociation +] + + +class DescribeLocalGatewayRouteTableVirtualInterfaceGroupAssociationsResult(TypedDict, total=False): + LocalGatewayRouteTableVirtualInterfaceGroupAssociations: Optional[ + LocalGatewayRouteTableVirtualInterfaceGroupAssociationSet + ] + NextToken: Optional[String] + + +LocalGatewayRouteTableVpcAssociationIdSet = List[LocalGatewayRouteTableVpcAssociationId] + + +class DescribeLocalGatewayRouteTableVpcAssociationsRequest(ServiceRequest): + LocalGatewayRouteTableVpcAssociationIds: Optional[LocalGatewayRouteTableVpcAssociationIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[LocalGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +LocalGatewayRouteTableVpcAssociationSet = List[LocalGatewayRouteTableVpcAssociation] + + +class DescribeLocalGatewayRouteTableVpcAssociationsResult(TypedDict, total=False): + LocalGatewayRouteTableVpcAssociations: Optional[LocalGatewayRouteTableVpcAssociationSet] + NextToken: Optional[String] + + +LocalGatewayRouteTableIdSet = List[LocalGatewayRoutetableId] + + +class DescribeLocalGatewayRouteTablesRequest(ServiceRequest): + LocalGatewayRouteTableIds: Optional[LocalGatewayRouteTableIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[LocalGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +LocalGatewayRouteTableSet = List[LocalGatewayRouteTable] + + +class DescribeLocalGatewayRouteTablesResult(TypedDict, total=False): + LocalGatewayRouteTables: Optional[LocalGatewayRouteTableSet] + NextToken: Optional[String] + + +LocalGatewayVirtualInterfaceGroupIdSet = List[LocalGatewayVirtualInterfaceGroupId] + + +class DescribeLocalGatewayVirtualInterfaceGroupsRequest(ServiceRequest): + LocalGatewayVirtualInterfaceGroupIds: Optional[LocalGatewayVirtualInterfaceGroupIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[LocalGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +LocalGatewayVirtualInterfaceGroupSet = List[LocalGatewayVirtualInterfaceGroup] + + +class DescribeLocalGatewayVirtualInterfaceGroupsResult(TypedDict, total=False): + LocalGatewayVirtualInterfaceGroups: Optional[LocalGatewayVirtualInterfaceGroupSet] + NextToken: Optional[String] + + +class DescribeLocalGatewayVirtualInterfacesRequest(ServiceRequest): + LocalGatewayVirtualInterfaceIds: Optional[LocalGatewayVirtualInterfaceIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[LocalGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +LocalGatewayVirtualInterfaceSet = List[LocalGatewayVirtualInterface] + + +class DescribeLocalGatewayVirtualInterfacesResult(TypedDict, total=False): + LocalGatewayVirtualInterfaces: Optional[LocalGatewayVirtualInterfaceSet] + NextToken: Optional[String] + + +LocalGatewayIdSet = List[LocalGatewayId] + + +class DescribeLocalGatewaysRequest(ServiceRequest): + LocalGatewayIds: Optional[LocalGatewayIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[LocalGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class LocalGateway(TypedDict, total=False): + LocalGatewayId: Optional[LocalGatewayId] + OutpostArn: Optional[String] + OwnerId: Optional[String] + State: Optional[String] + Tags: Optional[TagList] + + +LocalGatewaySet = List[LocalGateway] + + +class DescribeLocalGatewaysResult(TypedDict, total=False): + LocalGateways: Optional[LocalGatewaySet] + NextToken: Optional[String] + + +SnapshotIdStringList = List[SnapshotId] + + +class DescribeLockedSnapshotsRequest(ServiceRequest): + Filters: Optional[FilterList] + MaxResults: Optional[DescribeLockedSnapshotsMaxResults] + NextToken: Optional[String] + SnapshotIds: Optional[SnapshotIdStringList] + DryRun: Optional[Boolean] + + +class LockedSnapshotsInfo(TypedDict, total=False): + OwnerId: Optional[String] + SnapshotId: Optional[String] + LockState: Optional[LockState] + LockDuration: Optional[RetentionPeriodResponseDays] + CoolOffPeriod: Optional[CoolOffPeriodResponseHours] + CoolOffPeriodExpiresOn: Optional[MillisecondDateTime] + LockCreatedOn: Optional[MillisecondDateTime] + LockDurationStartTime: Optional[MillisecondDateTime] + LockExpiresOn: Optional[MillisecondDateTime] + + +LockedSnapshotsInfoList = List[LockedSnapshotsInfo] + + +class DescribeLockedSnapshotsResult(TypedDict, total=False): + Snapshots: Optional[LockedSnapshotsInfoList] + NextToken: Optional[String] + + +class DescribeMacHostsRequest(ServiceRequest): + Filters: Optional[FilterList] + HostIds: Optional[RequestHostIdList] + MaxResults: Optional[DescribeMacHostsRequestMaxResults] + NextToken: Optional[String] + + +MacOSVersionStringList = List[String] + + +class MacHost(TypedDict, total=False): + HostId: Optional[DedicatedHostId] + MacOSLatestSupportedVersions: Optional[MacOSVersionStringList] + + +MacHostList = List[MacHost] + + +class DescribeMacHostsResult(TypedDict, total=False): + MacHosts: Optional[MacHostList] + NextToken: Optional[String] + + +MacModificationTaskIdList = List[MacModificationTaskId] + + +class DescribeMacModificationTasksRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MacModificationTaskIds: Optional[MacModificationTaskIdList] + MaxResults: Optional[DescribeMacModificationTasksMaxResults] + NextToken: Optional[String] + + +MacModificationTaskList = List[MacModificationTask] + + +class DescribeMacModificationTasksResult(TypedDict, total=False): + MacModificationTasks: Optional[MacModificationTaskList] + NextToken: Optional[String] + + +class DescribeManagedPrefixListsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[PrefixListMaxResults] + NextToken: Optional[NextToken] + PrefixListIds: Optional[ValueStringList] + + +ManagedPrefixListSet = List[ManagedPrefixList] + + +class DescribeManagedPrefixListsResult(TypedDict, total=False): + NextToken: Optional[NextToken] + PrefixLists: Optional[ManagedPrefixListSet] + + +class DescribeMovingAddressesRequest(ServiceRequest): + DryRun: Optional[Boolean] + PublicIps: Optional[ValueStringList] + NextToken: Optional[String] + Filters: Optional[FilterList] + MaxResults: Optional[DescribeMovingAddressesMaxResults] + + +class MovingAddressStatus(TypedDict, total=False): + MoveStatus: Optional[MoveStatus] + PublicIp: Optional[String] + + +MovingAddressStatusSet = List[MovingAddressStatus] + + +class DescribeMovingAddressesResult(TypedDict, total=False): + MovingAddressStatuses: Optional[MovingAddressStatusSet] + NextToken: Optional[String] + + +NatGatewayIdStringList = List[NatGatewayId] + + +class DescribeNatGatewaysRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filter: Optional[FilterList] + MaxResults: Optional[DescribeNatGatewaysMaxResults] + NatGatewayIds: Optional[NatGatewayIdStringList] + NextToken: Optional[String] + + +NatGatewayList = List[NatGateway] + + +class DescribeNatGatewaysResult(TypedDict, total=False): + NatGateways: Optional[NatGatewayList] + NextToken: Optional[String] + + +NetworkAclIdStringList = List[NetworkAclId] + + +class DescribeNetworkAclsRequest(ServiceRequest): + NextToken: Optional[String] + MaxResults: Optional[DescribeNetworkAclsMaxResults] + DryRun: Optional[Boolean] + NetworkAclIds: Optional[NetworkAclIdStringList] + Filters: Optional[FilterList] + + +NetworkAclList = List[NetworkAcl] + + +class DescribeNetworkAclsResult(TypedDict, total=False): + NetworkAcls: Optional[NetworkAclList] + NextToken: Optional[String] + + +NetworkInsightsAccessScopeAnalysisIdList = List[NetworkInsightsAccessScopeAnalysisId] + + +class DescribeNetworkInsightsAccessScopeAnalysesRequest(ServiceRequest): + NetworkInsightsAccessScopeAnalysisIds: Optional[NetworkInsightsAccessScopeAnalysisIdList] + NetworkInsightsAccessScopeId: Optional[NetworkInsightsAccessScopeId] + AnalysisStartTimeBegin: Optional[MillisecondDateTime] + AnalysisStartTimeEnd: Optional[MillisecondDateTime] + Filters: Optional[FilterList] + MaxResults: Optional[NetworkInsightsMaxResults] + DryRun: Optional[Boolean] + NextToken: Optional[NextToken] + + +class NetworkInsightsAccessScopeAnalysis(TypedDict, total=False): + NetworkInsightsAccessScopeAnalysisId: Optional[NetworkInsightsAccessScopeAnalysisId] + NetworkInsightsAccessScopeAnalysisArn: Optional[ResourceArn] + NetworkInsightsAccessScopeId: Optional[NetworkInsightsAccessScopeId] + Status: Optional[AnalysisStatus] + StatusMessage: Optional[String] + WarningMessage: Optional[String] + StartDate: Optional[MillisecondDateTime] + EndDate: Optional[MillisecondDateTime] + FindingsFound: Optional[FindingsFound] + AnalyzedEniCount: Optional[Integer] + Tags: Optional[TagList] + + +NetworkInsightsAccessScopeAnalysisList = List[NetworkInsightsAccessScopeAnalysis] + + +class DescribeNetworkInsightsAccessScopeAnalysesResult(TypedDict, total=False): + NetworkInsightsAccessScopeAnalyses: Optional[NetworkInsightsAccessScopeAnalysisList] + NextToken: Optional[String] + + +NetworkInsightsAccessScopeIdList = List[NetworkInsightsAccessScopeId] + + +class DescribeNetworkInsightsAccessScopesRequest(ServiceRequest): + NetworkInsightsAccessScopeIds: Optional[NetworkInsightsAccessScopeIdList] + Filters: Optional[FilterList] + MaxResults: Optional[NetworkInsightsMaxResults] + DryRun: Optional[Boolean] + NextToken: Optional[NextToken] + + +NetworkInsightsAccessScopeList = List[NetworkInsightsAccessScope] + + +class DescribeNetworkInsightsAccessScopesResult(TypedDict, total=False): + NetworkInsightsAccessScopes: Optional[NetworkInsightsAccessScopeList] + NextToken: Optional[String] + + +NetworkInsightsAnalysisIdList = List[NetworkInsightsAnalysisId] + + +class DescribeNetworkInsightsAnalysesRequest(ServiceRequest): + NetworkInsightsAnalysisIds: Optional[NetworkInsightsAnalysisIdList] + NetworkInsightsPathId: Optional[NetworkInsightsPathId] + AnalysisStartTime: Optional[MillisecondDateTime] + AnalysisEndTime: Optional[MillisecondDateTime] + Filters: Optional[FilterList] + MaxResults: Optional[NetworkInsightsMaxResults] + DryRun: Optional[Boolean] + NextToken: Optional[NextToken] + + +class NetworkInsightsAnalysis(TypedDict, total=False): + NetworkInsightsAnalysisId: Optional[NetworkInsightsAnalysisId] + NetworkInsightsAnalysisArn: Optional[ResourceArn] + NetworkInsightsPathId: Optional[NetworkInsightsPathId] + AdditionalAccounts: Optional[ValueStringList] + FilterInArns: Optional[ArnList] + FilterOutArns: Optional[ArnList] + StartDate: Optional[MillisecondDateTime] + Status: Optional[AnalysisStatus] + StatusMessage: Optional[String] + WarningMessage: Optional[String] + NetworkPathFound: Optional[Boolean] + ForwardPathComponents: Optional[PathComponentList] + ReturnPathComponents: Optional[PathComponentList] + Explanations: Optional[ExplanationList] + AlternatePathHints: Optional[AlternatePathHintList] + SuggestedAccounts: Optional[ValueStringList] + Tags: Optional[TagList] + + +NetworkInsightsAnalysisList = List[NetworkInsightsAnalysis] + + +class DescribeNetworkInsightsAnalysesResult(TypedDict, total=False): + NetworkInsightsAnalyses: Optional[NetworkInsightsAnalysisList] + NextToken: Optional[String] + + +NetworkInsightsPathIdList = List[NetworkInsightsPathId] + + +class DescribeNetworkInsightsPathsRequest(ServiceRequest): + NetworkInsightsPathIds: Optional[NetworkInsightsPathIdList] + Filters: Optional[FilterList] + MaxResults: Optional[NetworkInsightsMaxResults] + DryRun: Optional[Boolean] + NextToken: Optional[NextToken] + + +NetworkInsightsPathList = List[NetworkInsightsPath] + + +class DescribeNetworkInsightsPathsResult(TypedDict, total=False): + NetworkInsightsPaths: Optional[NetworkInsightsPathList] + NextToken: Optional[String] + + +class DescribeNetworkInterfaceAttributeRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkInterfaceId: NetworkInterfaceId + Attribute: Optional[NetworkInterfaceAttribute] + + +class DescribeNetworkInterfaceAttributeResult(TypedDict, total=False): + Attachment: Optional[NetworkInterfaceAttachment] + Description: Optional[AttributeValue] + Groups: Optional[GroupIdentifierList] + NetworkInterfaceId: Optional[String] + SourceDestCheck: Optional[AttributeBooleanValue] + AssociatePublicIpAddress: Optional[Boolean] + + +NetworkInterfacePermissionIdList = List[NetworkInterfacePermissionId] + + +class DescribeNetworkInterfacePermissionsRequest(ServiceRequest): + NetworkInterfacePermissionIds: Optional[NetworkInterfacePermissionIdList] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[DescribeNetworkInterfacePermissionsMaxResults] + + +NetworkInterfacePermissionList = List[NetworkInterfacePermission] + + +class DescribeNetworkInterfacePermissionsResult(TypedDict, total=False): + NetworkInterfacePermissions: Optional[NetworkInterfacePermissionList] + NextToken: Optional[String] + + +NetworkInterfaceIdList = List[NetworkInterfaceId] + + +class DescribeNetworkInterfacesRequest(ServiceRequest): + NextToken: Optional[String] + MaxResults: Optional[DescribeNetworkInterfacesMaxResults] + DryRun: Optional[Boolean] + NetworkInterfaceIds: Optional[NetworkInterfaceIdList] + Filters: Optional[FilterList] + + +NetworkInterfaceList = List[NetworkInterface] + + +class DescribeNetworkInterfacesResult(TypedDict, total=False): + NetworkInterfaces: Optional[NetworkInterfaceList] + NextToken: Optional[String] + + +OutpostLagIdSet = List[OutpostLagId] + + +class DescribeOutpostLagsRequest(ServiceRequest): + OutpostLagIds: Optional[OutpostLagIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[OutpostLagMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +ServiceLinkVirtualInterfaceIdSet = List[ServiceLinkVirtualInterfaceId] + + +class OutpostLag(TypedDict, total=False): + OutpostArn: Optional[String] + OwnerId: Optional[String] + State: Optional[String] + OutpostLagId: Optional[OutpostLagId] + LocalGatewayVirtualInterfaceIds: Optional[LocalGatewayVirtualInterfaceIdSet] + ServiceLinkVirtualInterfaceIds: Optional[ServiceLinkVirtualInterfaceIdSet] + Tags: Optional[TagList] + + +OutpostLagSet = List[OutpostLag] + + +class DescribeOutpostLagsResult(TypedDict, total=False): + OutpostLags: Optional[OutpostLagSet] + NextToken: Optional[String] + + +PlacementGroupStringList = List[PlacementGroupName] +PlacementGroupIdStringList = List[PlacementGroupId] + + +class DescribePlacementGroupsRequest(ServiceRequest): + GroupIds: Optional[PlacementGroupIdStringList] + DryRun: Optional[Boolean] + GroupNames: Optional[PlacementGroupStringList] + Filters: Optional[FilterList] + + +PlacementGroupList = List[PlacementGroup] + + +class DescribePlacementGroupsResult(TypedDict, total=False): + PlacementGroups: Optional[PlacementGroupList] + + +PrefixListResourceIdStringList = List[PrefixListResourceId] + + +class DescribePrefixListsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + PrefixListIds: Optional[PrefixListResourceIdStringList] + + +class PrefixList(TypedDict, total=False): + Cidrs: Optional[ValueStringList] + PrefixListId: Optional[String] + PrefixListName: Optional[String] + + +PrefixListSet = List[PrefixList] + + +class DescribePrefixListsResult(TypedDict, total=False): + NextToken: Optional[String] + PrefixLists: Optional[PrefixListSet] + + +ResourceList = List[String] + + +class DescribePrincipalIdFormatRequest(ServiceRequest): + DryRun: Optional[Boolean] + Resources: Optional[ResourceList] + MaxResults: Optional[DescribePrincipalIdFormatMaxResults] + NextToken: Optional[String] + + +class PrincipalIdFormat(TypedDict, total=False): + Arn: Optional[String] + Statuses: Optional[IdFormatList] + + +PrincipalIdFormatList = List[PrincipalIdFormat] + + +class DescribePrincipalIdFormatResult(TypedDict, total=False): + Principals: Optional[PrincipalIdFormatList] + NextToken: Optional[String] + + +PublicIpv4PoolIdStringList = List[Ipv4PoolEc2Id] + + +class DescribePublicIpv4PoolsRequest(ServiceRequest): + PoolIds: Optional[PublicIpv4PoolIdStringList] + NextToken: Optional[NextToken] + MaxResults: Optional[PoolMaxResults] + Filters: Optional[FilterList] + + +class PublicIpv4PoolRange(TypedDict, total=False): + FirstAddress: Optional[String] + LastAddress: Optional[String] + AddressCount: Optional[Integer] + AvailableAddressCount: Optional[Integer] + + +PublicIpv4PoolRangeSet = List[PublicIpv4PoolRange] + + +class PublicIpv4Pool(TypedDict, total=False): + PoolId: Optional[String] + Description: Optional[String] + PoolAddressRanges: Optional[PublicIpv4PoolRangeSet] + TotalAddressCount: Optional[Integer] + TotalAvailableAddressCount: Optional[Integer] + NetworkBorderGroup: Optional[String] + Tags: Optional[TagList] + + +PublicIpv4PoolSet = List[PublicIpv4Pool] + + +class DescribePublicIpv4PoolsResult(TypedDict, total=False): + PublicIpv4Pools: Optional[PublicIpv4PoolSet] + NextToken: Optional[String] + + +RegionNameStringList = List[String] + + +class DescribeRegionsRequest(ServiceRequest): + RegionNames: Optional[RegionNameStringList] + AllRegions: Optional[Boolean] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class Region(TypedDict, total=False): + OptInStatus: Optional[String] + RegionName: Optional[String] + Endpoint: Optional[String] + + +RegionList = List[Region] + + +class DescribeRegionsResult(TypedDict, total=False): + Regions: Optional[RegionList] + + +ReplaceRootVolumeTaskIds = List[ReplaceRootVolumeTaskId] + + +class DescribeReplaceRootVolumeTasksRequest(ServiceRequest): + ReplaceRootVolumeTaskIds: Optional[ReplaceRootVolumeTaskIds] + Filters: Optional[FilterList] + MaxResults: Optional[DescribeReplaceRootVolumeTasksMaxResults] + NextToken: Optional[NextToken] + DryRun: Optional[Boolean] + + +ReplaceRootVolumeTasks = List[ReplaceRootVolumeTask] + + +class DescribeReplaceRootVolumeTasksResult(TypedDict, total=False): + ReplaceRootVolumeTasks: Optional[ReplaceRootVolumeTasks] + NextToken: Optional[String] + + +class DescribeReservedInstancesListingsRequest(ServiceRequest): + ReservedInstancesId: Optional[ReservationId] + ReservedInstancesListingId: Optional[ReservedInstancesListingId] + Filters: Optional[FilterList] + + +class DescribeReservedInstancesListingsResult(TypedDict, total=False): + ReservedInstancesListings: Optional[ReservedInstancesListingList] + + +ReservedInstancesModificationIdStringList = List[ReservedInstancesModificationId] + + +class DescribeReservedInstancesModificationsRequest(ServiceRequest): + ReservedInstancesModificationIds: Optional[ReservedInstancesModificationIdStringList] + NextToken: Optional[String] + Filters: Optional[FilterList] + + +class ReservedInstancesId(TypedDict, total=False): + ReservedInstancesId: Optional[String] + + +ReservedIntancesIds = List[ReservedInstancesId] + + +class ReservedInstancesConfiguration(TypedDict, total=False): + AvailabilityZone: Optional[String] + InstanceCount: Optional[Integer] + InstanceType: Optional[InstanceType] + Platform: Optional[String] + Scope: Optional[scope] + AvailabilityZoneId: Optional[String] + + +class ReservedInstancesModificationResult(TypedDict, total=False): + ReservedInstancesId: Optional[String] + TargetConfiguration: Optional[ReservedInstancesConfiguration] + + +ReservedInstancesModificationResultList = List[ReservedInstancesModificationResult] + + +class ReservedInstancesModification(TypedDict, total=False): + ClientToken: Optional[String] + CreateDate: Optional[DateTime] + EffectiveDate: Optional[DateTime] + ModificationResults: Optional[ReservedInstancesModificationResultList] + ReservedInstancesIds: Optional[ReservedIntancesIds] + ReservedInstancesModificationId: Optional[String] + Status: Optional[String] + StatusMessage: Optional[String] + UpdateDate: Optional[DateTime] + + +ReservedInstancesModificationList = List[ReservedInstancesModification] + + +class DescribeReservedInstancesModificationsResult(TypedDict, total=False): + NextToken: Optional[String] + ReservedInstancesModifications: Optional[ReservedInstancesModificationList] + + +ReservedInstancesOfferingIdStringList = List[ReservedInstancesOfferingId] + + +class DescribeReservedInstancesOfferingsRequest(ServiceRequest): + AvailabilityZone: Optional[String] + IncludeMarketplace: Optional[Boolean] + InstanceType: Optional[InstanceType] + MaxDuration: Optional[Long] + MaxInstanceCount: Optional[Integer] + MinDuration: Optional[Long] + OfferingClass: Optional[OfferingClassType] + ProductDescription: Optional[RIProductDescription] + ReservedInstancesOfferingIds: Optional[ReservedInstancesOfferingIdStringList] + AvailabilityZoneId: Optional[AvailabilityZoneId] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + InstanceTenancy: Optional[Tenancy] + OfferingType: Optional[OfferingTypeValues] + NextToken: Optional[String] + MaxResults: Optional[Integer] + + +class RecurringCharge(TypedDict, total=False): + Amount: Optional[Double] + Frequency: Optional[RecurringChargeFrequency] + + +RecurringChargesList = List[RecurringCharge] + + +class PricingDetail(TypedDict, total=False): + Count: Optional[Integer] + Price: Optional[Double] + + +PricingDetailsList = List[PricingDetail] + + +class ReservedInstancesOffering(TypedDict, total=False): + CurrencyCode: Optional[CurrencyCodeValues] + InstanceTenancy: Optional[Tenancy] + Marketplace: Optional[Boolean] + OfferingClass: Optional[OfferingClassType] + OfferingType: Optional[OfferingTypeValues] + PricingDetails: Optional[PricingDetailsList] + RecurringCharges: Optional[RecurringChargesList] + Scope: Optional[scope] + AvailabilityZoneId: Optional[AvailabilityZoneId] + ReservedInstancesOfferingId: Optional[String] + InstanceType: Optional[InstanceType] + AvailabilityZone: Optional[String] + Duration: Optional[Long] + UsagePrice: Optional[Float] + FixedPrice: Optional[Float] + ProductDescription: Optional[RIProductDescription] + + +ReservedInstancesOfferingList = List[ReservedInstancesOffering] + + +class DescribeReservedInstancesOfferingsResult(TypedDict, total=False): + NextToken: Optional[String] + ReservedInstancesOfferings: Optional[ReservedInstancesOfferingList] + + +ReservedInstancesIdStringList = List[ReservationId] + + +class DescribeReservedInstancesRequest(ServiceRequest): + OfferingClass: Optional[OfferingClassType] + ReservedInstancesIds: Optional[ReservedInstancesIdStringList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + OfferingType: Optional[OfferingTypeValues] + + +class ReservedInstances(TypedDict, total=False): + CurrencyCode: Optional[CurrencyCodeValues] + InstanceTenancy: Optional[Tenancy] + OfferingClass: Optional[OfferingClassType] + OfferingType: Optional[OfferingTypeValues] + RecurringCharges: Optional[RecurringChargesList] + Scope: Optional[scope] + Tags: Optional[TagList] + AvailabilityZoneId: Optional[String] + ReservedInstancesId: Optional[String] + InstanceType: Optional[InstanceType] + AvailabilityZone: Optional[String] + Start: Optional[DateTime] + End: Optional[DateTime] + Duration: Optional[Long] + UsagePrice: Optional[Float] + FixedPrice: Optional[Float] + InstanceCount: Optional[Integer] + ProductDescription: Optional[RIProductDescription] + State: Optional[ReservedInstanceState] + + +ReservedInstancesList = List[ReservedInstances] + + +class DescribeReservedInstancesResult(TypedDict, total=False): + ReservedInstances: Optional[ReservedInstancesList] + + +RouteServerEndpointIdsList = List[RouteServerEndpointId] + + +class DescribeRouteServerEndpointsRequest(ServiceRequest): + RouteServerEndpointIds: Optional[RouteServerEndpointIdsList] + NextToken: Optional[String] + MaxResults: Optional[RouteServerMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +RouteServerEndpointsList = List[RouteServerEndpoint] + + +class DescribeRouteServerEndpointsResult(TypedDict, total=False): + RouteServerEndpoints: Optional[RouteServerEndpointsList] + NextToken: Optional[String] + + +RouteServerPeerIdsList = List[RouteServerPeerId] + + +class DescribeRouteServerPeersRequest(ServiceRequest): + RouteServerPeerIds: Optional[RouteServerPeerIdsList] + NextToken: Optional[String] + MaxResults: Optional[RouteServerMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +RouteServerPeersList = List[RouteServerPeer] + + +class DescribeRouteServerPeersResult(TypedDict, total=False): + RouteServerPeers: Optional[RouteServerPeersList] + NextToken: Optional[String] + + +RouteServerIdsList = List[RouteServerId] + + +class DescribeRouteServersRequest(ServiceRequest): + RouteServerIds: Optional[RouteServerIdsList] + NextToken: Optional[String] + MaxResults: Optional[RouteServerMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +RouteServersList = List[RouteServer] + + +class DescribeRouteServersResult(TypedDict, total=False): + RouteServers: Optional[RouteServersList] + NextToken: Optional[String] + + +RouteTableIdStringList = List[RouteTableId] + + +class DescribeRouteTablesRequest(ServiceRequest): + NextToken: Optional[String] + MaxResults: Optional[DescribeRouteTablesMaxResults] + DryRun: Optional[Boolean] + RouteTableIds: Optional[RouteTableIdStringList] + Filters: Optional[FilterList] + + +RouteTableList = List[RouteTable] + + +class DescribeRouteTablesResult(TypedDict, total=False): + RouteTables: Optional[RouteTableList] + NextToken: Optional[String] + + +OccurrenceDayRequestSet = List[Integer] + + +class ScheduledInstanceRecurrenceRequest(TypedDict, total=False): + Frequency: Optional[String] + Interval: Optional[Integer] + OccurrenceDays: Optional[OccurrenceDayRequestSet] + OccurrenceRelativeToEnd: Optional[Boolean] + OccurrenceUnit: Optional[String] + + +class SlotDateTimeRangeRequest(TypedDict, total=False): + EarliestTime: DateTime + LatestTime: DateTime + + +class DescribeScheduledInstanceAvailabilityRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + FirstSlotStartTimeRange: SlotDateTimeRangeRequest + MaxResults: Optional[DescribeScheduledInstanceAvailabilityMaxResults] + MaxSlotDurationInHours: Optional[Integer] + MinSlotDurationInHours: Optional[Integer] + NextToken: Optional[String] + Recurrence: ScheduledInstanceRecurrenceRequest + + +OccurrenceDaySet = List[Integer] + + +class ScheduledInstanceRecurrence(TypedDict, total=False): + Frequency: Optional[String] + Interval: Optional[Integer] + OccurrenceDaySet: Optional[OccurrenceDaySet] + OccurrenceRelativeToEnd: Optional[Boolean] + OccurrenceUnit: Optional[String] + + +class ScheduledInstanceAvailability(TypedDict, total=False): + AvailabilityZone: Optional[String] + AvailableInstanceCount: Optional[Integer] + FirstSlotStartTime: Optional[DateTime] + HourlyPrice: Optional[String] + InstanceType: Optional[String] + MaxTermDurationInDays: Optional[Integer] + MinTermDurationInDays: Optional[Integer] + NetworkPlatform: Optional[String] + Platform: Optional[String] + PurchaseToken: Optional[String] + Recurrence: Optional[ScheduledInstanceRecurrence] + SlotDurationInHours: Optional[Integer] + TotalScheduledInstanceHours: Optional[Integer] + + +ScheduledInstanceAvailabilitySet = List[ScheduledInstanceAvailability] + + +class DescribeScheduledInstanceAvailabilityResult(TypedDict, total=False): + NextToken: Optional[String] + ScheduledInstanceAvailabilitySet: Optional[ScheduledInstanceAvailabilitySet] + + +class SlotStartTimeRangeRequest(TypedDict, total=False): + EarliestTime: Optional[DateTime] + LatestTime: Optional[DateTime] + + +ScheduledInstanceIdRequestSet = List[ScheduledInstanceId] + + +class DescribeScheduledInstancesRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + ScheduledInstanceIds: Optional[ScheduledInstanceIdRequestSet] + SlotStartTimeRange: Optional[SlotStartTimeRangeRequest] + + +class ScheduledInstance(TypedDict, total=False): + AvailabilityZone: Optional[String] + CreateDate: Optional[DateTime] + HourlyPrice: Optional[String] + InstanceCount: Optional[Integer] + InstanceType: Optional[String] + NetworkPlatform: Optional[String] + NextSlotStartTime: Optional[DateTime] + Platform: Optional[String] + PreviousSlotEndTime: Optional[DateTime] + Recurrence: Optional[ScheduledInstanceRecurrence] + ScheduledInstanceId: Optional[String] + SlotDurationInHours: Optional[Integer] + TermEndDate: Optional[DateTime] + TermStartDate: Optional[DateTime] + TotalScheduledInstanceHours: Optional[Integer] + + +ScheduledInstanceSet = List[ScheduledInstance] + + +class DescribeScheduledInstancesResult(TypedDict, total=False): + NextToken: Optional[String] + ScheduledInstanceSet: Optional[ScheduledInstanceSet] + + +GroupIds = List[SecurityGroupId] + + +class DescribeSecurityGroupReferencesRequest(ServiceRequest): + DryRun: Optional[Boolean] + GroupId: GroupIds + + +class SecurityGroupReference(TypedDict, total=False): + GroupId: Optional[String] + ReferencingVpcId: Optional[String] + VpcPeeringConnectionId: Optional[String] + TransitGatewayId: Optional[String] + + +SecurityGroupReferences = List[SecurityGroupReference] + + +class DescribeSecurityGroupReferencesResult(TypedDict, total=False): + SecurityGroupReferenceSet: Optional[SecurityGroupReferences] + + +SecurityGroupRuleIdList = List[String] + + +class DescribeSecurityGroupRulesRequest(ServiceRequest): + Filters: Optional[FilterList] + SecurityGroupRuleIds: Optional[SecurityGroupRuleIdList] + DryRun: Optional[Boolean] + NextToken: Optional[String] + MaxResults: Optional[DescribeSecurityGroupRulesMaxResults] + + +class DescribeSecurityGroupRulesResult(TypedDict, total=False): + SecurityGroupRules: Optional[SecurityGroupRuleList] + NextToken: Optional[String] + + +class DescribeSecurityGroupVpcAssociationsRequest(ServiceRequest): + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[DescribeSecurityGroupVpcAssociationsMaxResults] + DryRun: Optional[Boolean] + + +class SecurityGroupVpcAssociation(TypedDict, total=False): + GroupId: Optional[SecurityGroupId] + VpcId: Optional[VpcId] + VpcOwnerId: Optional[String] + State: Optional[SecurityGroupVpcAssociationState] + StateReason: Optional[String] + GroupOwnerId: Optional[String] + + +SecurityGroupVpcAssociationList = List[SecurityGroupVpcAssociation] + + +class DescribeSecurityGroupVpcAssociationsResult(TypedDict, total=False): + SecurityGroupVpcAssociations: Optional[SecurityGroupVpcAssociationList] + NextToken: Optional[String] + + +GroupNameStringList = List[SecurityGroupName] + + +class DescribeSecurityGroupsRequest(ServiceRequest): + GroupIds: Optional[GroupIdStringList] + GroupNames: Optional[GroupNameStringList] + NextToken: Optional[String] + MaxResults: Optional[DescribeSecurityGroupsMaxResults] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class SecurityGroup(TypedDict, total=False): + GroupId: Optional[String] + IpPermissionsEgress: Optional[IpPermissionList] + Tags: Optional[TagList] + VpcId: Optional[String] + SecurityGroupArn: Optional[String] + OwnerId: Optional[String] + GroupName: Optional[String] + Description: Optional[String] + IpPermissions: Optional[IpPermissionList] + + +SecurityGroupList = List[SecurityGroup] + + +class DescribeSecurityGroupsResult(TypedDict, total=False): + NextToken: Optional[String] + SecurityGroups: Optional[SecurityGroupList] + + +class DescribeServiceLinkVirtualInterfacesRequest(ServiceRequest): + ServiceLinkVirtualInterfaceIds: Optional[ServiceLinkVirtualInterfaceIdSet] + Filters: Optional[FilterList] + MaxResults: Optional[ServiceLinkMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class ServiceLinkVirtualInterface(TypedDict, total=False): + ServiceLinkVirtualInterfaceId: Optional[ServiceLinkVirtualInterfaceId] + ServiceLinkVirtualInterfaceArn: Optional[ResourceArn] + OutpostId: Optional[String] + OutpostArn: Optional[String] + OwnerId: Optional[String] + LocalAddress: Optional[String] + PeerAddress: Optional[String] + PeerBgpAsn: Optional[Long] + Vlan: Optional[Integer] + OutpostLagId: Optional[OutpostLagId] + Tags: Optional[TagList] + ConfigurationState: Optional[ServiceLinkVirtualInterfaceConfigurationState] + + +ServiceLinkVirtualInterfaceSet = List[ServiceLinkVirtualInterface] + + +class DescribeServiceLinkVirtualInterfacesResult(TypedDict, total=False): + ServiceLinkVirtualInterfaces: Optional[ServiceLinkVirtualInterfaceSet] + NextToken: Optional[String] + + +class DescribeSnapshotAttributeRequest(ServiceRequest): + Attribute: SnapshotAttributeName + SnapshotId: SnapshotId + DryRun: Optional[Boolean] + + +class DescribeSnapshotAttributeResult(TypedDict, total=False): + ProductCodes: Optional[ProductCodeList] + SnapshotId: Optional[String] + CreateVolumePermissions: Optional[CreateVolumePermissionList] + + +class DescribeSnapshotTierStatusRequest(ServiceRequest): + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + NextToken: Optional[String] + MaxResults: Optional[DescribeSnapshotTierStatusMaxResults] + + +class SnapshotTierStatus(TypedDict, total=False): + SnapshotId: Optional[SnapshotId] + VolumeId: Optional[VolumeId] + Status: Optional[SnapshotState] + OwnerId: Optional[String] + Tags: Optional[TagList] + StorageTier: Optional[StorageTier] + LastTieringStartTime: Optional[MillisecondDateTime] + LastTieringProgress: Optional[Integer] + LastTieringOperationStatus: Optional[TieringOperationStatus] + LastTieringOperationStatusDetail: Optional[String] + ArchivalCompleteTime: Optional[MillisecondDateTime] + RestoreExpiryTime: Optional[MillisecondDateTime] + + +snapshotTierStatusSet = List[SnapshotTierStatus] + + +class DescribeSnapshotTierStatusResult(TypedDict, total=False): + SnapshotTierStatuses: Optional[snapshotTierStatusSet] + NextToken: Optional[String] + + +RestorableByStringList = List[String] + + +class DescribeSnapshotsRequest(ServiceRequest): + MaxResults: Optional[Integer] + NextToken: Optional[String] + OwnerIds: Optional[OwnerStringList] + RestorableByUserIds: Optional[RestorableByStringList] + SnapshotIds: Optional[SnapshotIdStringList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class Snapshot(TypedDict, total=False): + OwnerAlias: Optional[String] + OutpostArn: Optional[String] + Tags: Optional[TagList] + StorageTier: Optional[StorageTier] + RestoreExpiryTime: Optional[MillisecondDateTime] + SseType: Optional[SSEType] + AvailabilityZone: Optional[String] + TransferType: Optional[TransferType] + CompletionDurationMinutes: Optional[SnapshotCompletionDurationMinutesResponse] + CompletionTime: Optional[MillisecondDateTime] + FullSnapshotSizeInBytes: Optional[Long] + SnapshotId: Optional[String] + VolumeId: Optional[String] + State: Optional[SnapshotState] + StateMessage: Optional[String] + StartTime: Optional[DateTime] + Progress: Optional[String] + OwnerId: Optional[String] + Description: Optional[String] + VolumeSize: Optional[Integer] + Encrypted: Optional[Boolean] + KmsKeyId: Optional[String] + DataEncryptionKeyId: Optional[String] + + +SnapshotList = List[Snapshot] + + +class DescribeSnapshotsResult(TypedDict, total=False): + NextToken: Optional[String] + Snapshots: Optional[SnapshotList] + + +class DescribeSpotDatafeedSubscriptionRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DescribeSpotDatafeedSubscriptionResult(TypedDict, total=False): + SpotDatafeedSubscription: Optional[SpotDatafeedSubscription] + + +class DescribeSpotFleetInstancesRequest(ServiceRequest): + DryRun: Optional[Boolean] + SpotFleetRequestId: SpotFleetRequestId + NextToken: Optional[String] + MaxResults: Optional[DescribeSpotFleetInstancesMaxResults] + + +class DescribeSpotFleetInstancesResponse(TypedDict, total=False): + ActiveInstances: Optional[ActiveInstanceSet] + NextToken: Optional[String] + SpotFleetRequestId: Optional[String] + + +class DescribeSpotFleetRequestHistoryRequest(ServiceRequest): + DryRun: Optional[Boolean] + SpotFleetRequestId: SpotFleetRequestId + EventType: Optional[EventType] + StartTime: DateTime + NextToken: Optional[String] + MaxResults: Optional[DescribeSpotFleetRequestHistoryMaxResults] + + +class HistoryRecord(TypedDict, total=False): + EventInformation: Optional[EventInformation] + EventType: Optional[EventType] + Timestamp: Optional[DateTime] + + +HistoryRecords = List[HistoryRecord] + + +class DescribeSpotFleetRequestHistoryResponse(TypedDict, total=False): + HistoryRecords: Optional[HistoryRecords] + LastEvaluatedTime: Optional[DateTime] + NextToken: Optional[String] + SpotFleetRequestId: Optional[String] + StartTime: Optional[DateTime] + + +class DescribeSpotFleetRequestsRequest(ServiceRequest): + DryRun: Optional[Boolean] + SpotFleetRequestIds: Optional[SpotFleetRequestIdList] + NextToken: Optional[String] + MaxResults: Optional[Integer] + + +class TargetGroup(TypedDict, total=False): + Arn: Optional[String] + + +TargetGroups = List[TargetGroup] + + +class TargetGroupsConfig(TypedDict, total=False): + TargetGroups: Optional[TargetGroups] + + +class LoadBalancersConfig(TypedDict, total=False): + ClassicLoadBalancersConfig: Optional[ClassicLoadBalancersConfig] + TargetGroupsConfig: Optional[TargetGroupsConfig] + + +class LaunchTemplateOverrides(TypedDict, total=False): + InstanceType: Optional[InstanceType] + SpotPrice: Optional[String] + SubnetId: Optional[SubnetId] + AvailabilityZone: Optional[String] + WeightedCapacity: Optional[Double] + Priority: Optional[Double] + InstanceRequirements: Optional[InstanceRequirements] + + +LaunchTemplateOverridesList = List[LaunchTemplateOverrides] + + +class LaunchTemplateConfig(TypedDict, total=False): + LaunchTemplateSpecification: Optional[FleetLaunchTemplateSpecification] + Overrides: Optional[LaunchTemplateOverridesList] + + +LaunchTemplateConfigList = List[LaunchTemplateConfig] + + +class SpotFleetTagSpecification(TypedDict, total=False): + ResourceType: Optional[ResourceType] + Tags: Optional[TagList] + + +SpotFleetTagSpecificationList = List[SpotFleetTagSpecification] + + +class SpotPlacement(TypedDict, total=False): + AvailabilityZone: Optional[String] + GroupName: Optional[PlacementGroupName] + Tenancy: Optional[Tenancy] + + +class InstanceNetworkInterfaceSpecification(TypedDict, total=False): + AssociatePublicIpAddress: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Description: Optional[String] + DeviceIndex: Optional[Integer] + Groups: Optional[SecurityGroupIdStringList] + Ipv6AddressCount: Optional[Integer] + Ipv6Addresses: Optional[InstanceIpv6AddressList] + NetworkInterfaceId: Optional[NetworkInterfaceId] + PrivateIpAddress: Optional[String] + PrivateIpAddresses: Optional[PrivateIpAddressSpecificationList] + SecondaryPrivateIpAddressCount: Optional[Integer] + SubnetId: Optional[String] + AssociateCarrierIpAddress: Optional[Boolean] + InterfaceType: Optional[String] + NetworkCardIndex: Optional[Integer] + Ipv4Prefixes: Optional[Ipv4PrefixList] + Ipv4PrefixCount: Optional[Integer] + Ipv6Prefixes: Optional[Ipv6PrefixList] + Ipv6PrefixCount: Optional[Integer] + PrimaryIpv6: Optional[Boolean] + EnaSrdSpecification: Optional[EnaSrdSpecificationRequest] + ConnectionTrackingSpecification: Optional[ConnectionTrackingSpecificationRequest] + EnaQueueCount: Optional[Integer] + + +InstanceNetworkInterfaceSpecificationList = List[InstanceNetworkInterfaceSpecification] + + +class SpotFleetMonitoring(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class SpotFleetLaunchSpecification(TypedDict, total=False): + AddressingType: Optional[String] + BlockDeviceMappings: Optional[BlockDeviceMappingList] + EbsOptimized: Optional[Boolean] + IamInstanceProfile: Optional[IamInstanceProfileSpecification] + ImageId: Optional[ImageId] + InstanceType: Optional[InstanceType] + KernelId: Optional[String] + KeyName: Optional[KeyPairName] + Monitoring: Optional[SpotFleetMonitoring] + NetworkInterfaces: Optional[InstanceNetworkInterfaceSpecificationList] + Placement: Optional[SpotPlacement] + RamdiskId: Optional[String] + SpotPrice: Optional[String] + SubnetId: Optional[SubnetId] + UserData: Optional[SensitiveUserData] + WeightedCapacity: Optional[Double] + TagSpecifications: Optional[SpotFleetTagSpecificationList] + InstanceRequirements: Optional[InstanceRequirements] + SecurityGroups: Optional[GroupIdentifierList] + + +LaunchSpecsList = List[SpotFleetLaunchSpecification] + + +class SpotCapacityRebalance(TypedDict, total=False): + ReplacementStrategy: Optional[ReplacementStrategy] + TerminationDelay: Optional[Integer] + + +class SpotMaintenanceStrategies(TypedDict, total=False): + CapacityRebalance: Optional[SpotCapacityRebalance] + + +class SpotFleetRequestConfigData(TypedDict, total=False): + AllocationStrategy: Optional[AllocationStrategy] + OnDemandAllocationStrategy: Optional[OnDemandAllocationStrategy] + SpotMaintenanceStrategies: Optional[SpotMaintenanceStrategies] + ClientToken: Optional[String] + ExcessCapacityTerminationPolicy: Optional[ExcessCapacityTerminationPolicy] + FulfilledCapacity: Optional[Double] + OnDemandFulfilledCapacity: Optional[Double] + IamFleetRole: String + LaunchSpecifications: Optional[LaunchSpecsList] + LaunchTemplateConfigs: Optional[LaunchTemplateConfigList] + SpotPrice: Optional[String] + TargetCapacity: Integer + OnDemandTargetCapacity: Optional[Integer] + OnDemandMaxTotalPrice: Optional[String] + SpotMaxTotalPrice: Optional[String] + TerminateInstancesWithExpiration: Optional[Boolean] + Type: Optional[FleetType] + ValidFrom: Optional[DateTime] + ValidUntil: Optional[DateTime] + ReplaceUnhealthyInstances: Optional[Boolean] + InstanceInterruptionBehavior: Optional[InstanceInterruptionBehavior] + LoadBalancersConfig: Optional[LoadBalancersConfig] + InstancePoolsToUseCount: Optional[Integer] + Context: Optional[String] + TargetCapacityUnitType: Optional[TargetCapacityUnitType] + TagSpecifications: Optional[TagSpecificationList] + + +class SpotFleetRequestConfig(TypedDict, total=False): + ActivityStatus: Optional[ActivityStatus] + CreateTime: Optional[MillisecondDateTime] + SpotFleetRequestConfig: Optional[SpotFleetRequestConfigData] + SpotFleetRequestId: Optional[String] + SpotFleetRequestState: Optional[BatchState] + Tags: Optional[TagList] + + +SpotFleetRequestConfigSet = List[SpotFleetRequestConfig] + + +class DescribeSpotFleetRequestsResponse(TypedDict, total=False): + NextToken: Optional[String] + SpotFleetRequestConfigs: Optional[SpotFleetRequestConfigSet] + + +class DescribeSpotInstanceRequestsRequest(ServiceRequest): + NextToken: Optional[String] + MaxResults: Optional[Integer] + DryRun: Optional[Boolean] + SpotInstanceRequestIds: Optional[SpotInstanceRequestIdList] + Filters: Optional[FilterList] + + +class SpotInstanceStatus(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + UpdateTime: Optional[DateTime] + + +class RunInstancesMonitoringEnabled(TypedDict, total=False): + Enabled: Boolean + + +class LaunchSpecification(TypedDict, total=False): + UserData: Optional[SensitiveUserData] + AddressingType: Optional[String] + BlockDeviceMappings: Optional[BlockDeviceMappingList] + EbsOptimized: Optional[Boolean] + IamInstanceProfile: Optional[IamInstanceProfileSpecification] + ImageId: Optional[String] + InstanceType: Optional[InstanceType] + KernelId: Optional[String] + KeyName: Optional[String] + NetworkInterfaces: Optional[InstanceNetworkInterfaceSpecificationList] + Placement: Optional[SpotPlacement] + RamdiskId: Optional[String] + SubnetId: Optional[String] + SecurityGroups: Optional[GroupIdentifierList] + Monitoring: Optional[RunInstancesMonitoringEnabled] + + +class SpotInstanceRequest(TypedDict, total=False): + ActualBlockHourlyPrice: Optional[String] + AvailabilityZoneGroup: Optional[String] + BlockDurationMinutes: Optional[Integer] + CreateTime: Optional[DateTime] + Fault: Optional[SpotInstanceStateFault] + InstanceId: Optional[InstanceId] + LaunchGroup: Optional[String] + LaunchSpecification: Optional[LaunchSpecification] + LaunchedAvailabilityZone: Optional[String] + ProductDescription: Optional[RIProductDescription] + SpotInstanceRequestId: Optional[String] + SpotPrice: Optional[String] + State: Optional[SpotInstanceState] + Status: Optional[SpotInstanceStatus] + Tags: Optional[TagList] + Type: Optional[SpotInstanceType] + ValidFrom: Optional[DateTime] + ValidUntil: Optional[DateTime] + InstanceInterruptionBehavior: Optional[InstanceInterruptionBehavior] + + +SpotInstanceRequestList = List[SpotInstanceRequest] + + +class DescribeSpotInstanceRequestsResult(TypedDict, total=False): + SpotInstanceRequests: Optional[SpotInstanceRequestList] + NextToken: Optional[String] + + +ProductDescriptionList = List[String] +InstanceTypeList = List[InstanceType] + + +class DescribeSpotPriceHistoryRequest(ServiceRequest): + DryRun: Optional[Boolean] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + InstanceTypes: Optional[InstanceTypeList] + ProductDescriptions: Optional[ProductDescriptionList] + Filters: Optional[FilterList] + AvailabilityZone: Optional[String] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class SpotPrice(TypedDict, total=False): + AvailabilityZone: Optional[String] + InstanceType: Optional[InstanceType] + ProductDescription: Optional[RIProductDescription] + SpotPrice: Optional[String] + Timestamp: Optional[DateTime] + + +SpotPriceHistoryList = List[SpotPrice] + + +class DescribeSpotPriceHistoryResult(TypedDict, total=False): + NextToken: Optional[String] + SpotPriceHistory: Optional[SpotPriceHistoryList] + + +class DescribeStaleSecurityGroupsRequest(ServiceRequest): + DryRun: Optional[Boolean] + MaxResults: Optional[DescribeStaleSecurityGroupsMaxResults] + NextToken: Optional[DescribeStaleSecurityGroupsNextToken] + VpcId: VpcId + + +UserIdGroupPairSet = List[UserIdGroupPair] +PrefixListIdSet = List[String] +IpRanges = List[String] + + +class StaleIpPermission(TypedDict, total=False): + FromPort: Optional[Integer] + IpProtocol: Optional[String] + IpRanges: Optional[IpRanges] + PrefixListIds: Optional[PrefixListIdSet] + ToPort: Optional[Integer] + UserIdGroupPairs: Optional[UserIdGroupPairSet] + + +StaleIpPermissionSet = List[StaleIpPermission] + + +class StaleSecurityGroup(TypedDict, total=False): + Description: Optional[String] + GroupId: Optional[String] + GroupName: Optional[String] + StaleIpPermissions: Optional[StaleIpPermissionSet] + StaleIpPermissionsEgress: Optional[StaleIpPermissionSet] + VpcId: Optional[String] + + +StaleSecurityGroupSet = List[StaleSecurityGroup] + + +class DescribeStaleSecurityGroupsResult(TypedDict, total=False): + NextToken: Optional[String] + StaleSecurityGroupSet: Optional[StaleSecurityGroupSet] + + +ImageIdList = List[ImageId] + + +class DescribeStoreImageTasksRequest(ServiceRequest): + ImageIds: Optional[ImageIdList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[DescribeStoreImageTasksRequestMaxResults] + + +class StoreImageTaskResult(TypedDict, total=False): + AmiId: Optional[String] + TaskStartTime: Optional[MillisecondDateTime] + Bucket: Optional[String] + S3objectKey: Optional[String] + ProgressPercentage: Optional[Integer] + StoreTaskState: Optional[String] + StoreTaskFailureReason: Optional[String] + + +StoreImageTaskResultSet = List[StoreImageTaskResult] + + +class DescribeStoreImageTasksResult(TypedDict, total=False): + StoreImageTaskResults: Optional[StoreImageTaskResultSet] + NextToken: Optional[String] + + +SubnetIdStringList = List[SubnetId] + + +class DescribeSubnetsRequest(ServiceRequest): + Filters: Optional[FilterList] + SubnetIds: Optional[SubnetIdStringList] + NextToken: Optional[String] + MaxResults: Optional[DescribeSubnetsMaxResults] + DryRun: Optional[Boolean] + + +SubnetList = List[Subnet] + + +class DescribeSubnetsResult(TypedDict, total=False): + NextToken: Optional[String] + Subnets: Optional[SubnetList] + + +class DescribeTagsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class TagDescription(TypedDict, total=False): + Key: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[ResourceType] + Value: Optional[String] + + +TagDescriptionList = List[TagDescription] + + +class DescribeTagsResult(TypedDict, total=False): + NextToken: Optional[String] + Tags: Optional[TagDescriptionList] + + +TrafficMirrorFilterRuleIdList = List[TrafficMirrorFilterRuleIdWithResolver] + + +class DescribeTrafficMirrorFilterRulesRequest(ServiceRequest): + TrafficMirrorFilterRuleIds: Optional[TrafficMirrorFilterRuleIdList] + TrafficMirrorFilterId: Optional[TrafficMirrorFilterId] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[TrafficMirroringMaxResults] + NextToken: Optional[NextToken] + + +TrafficMirrorFilterRuleSet = List[TrafficMirrorFilterRule] + + +class DescribeTrafficMirrorFilterRulesResult(TypedDict, total=False): + TrafficMirrorFilterRules: Optional[TrafficMirrorFilterRuleSet] + NextToken: Optional[String] + + +TrafficMirrorFilterIdList = List[TrafficMirrorFilterId] + + +class DescribeTrafficMirrorFiltersRequest(ServiceRequest): + TrafficMirrorFilterIds: Optional[TrafficMirrorFilterIdList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[TrafficMirroringMaxResults] + NextToken: Optional[NextToken] + + +TrafficMirrorFilterSet = List[TrafficMirrorFilter] + + +class DescribeTrafficMirrorFiltersResult(TypedDict, total=False): + TrafficMirrorFilters: Optional[TrafficMirrorFilterSet] + NextToken: Optional[String] + + +TrafficMirrorSessionIdList = List[TrafficMirrorSessionId] + + +class DescribeTrafficMirrorSessionsRequest(ServiceRequest): + TrafficMirrorSessionIds: Optional[TrafficMirrorSessionIdList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[TrafficMirroringMaxResults] + NextToken: Optional[NextToken] + + +TrafficMirrorSessionSet = List[TrafficMirrorSession] + + +class DescribeTrafficMirrorSessionsResult(TypedDict, total=False): + TrafficMirrorSessions: Optional[TrafficMirrorSessionSet] + NextToken: Optional[String] + + +TrafficMirrorTargetIdList = List[TrafficMirrorTargetId] + + +class DescribeTrafficMirrorTargetsRequest(ServiceRequest): + TrafficMirrorTargetIds: Optional[TrafficMirrorTargetIdList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[TrafficMirroringMaxResults] + NextToken: Optional[NextToken] + + +TrafficMirrorTargetSet = List[TrafficMirrorTarget] + + +class DescribeTrafficMirrorTargetsResult(TypedDict, total=False): + TrafficMirrorTargets: Optional[TrafficMirrorTargetSet] + NextToken: Optional[String] + + +TransitGatewayAttachmentIdStringList = List[TransitGatewayAttachmentId] + + +class DescribeTransitGatewayAttachmentsRequest(ServiceRequest): + TransitGatewayAttachmentIds: Optional[TransitGatewayAttachmentIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class TransitGatewayAttachmentAssociation(TypedDict, total=False): + TransitGatewayRouteTableId: Optional[String] + State: Optional[TransitGatewayAssociationState] + + +class TransitGatewayAttachment(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[String] + TransitGatewayId: Optional[String] + TransitGatewayOwnerId: Optional[String] + ResourceOwnerId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + ResourceId: Optional[String] + State: Optional[TransitGatewayAttachmentState] + Association: Optional[TransitGatewayAttachmentAssociation] + CreationTime: Optional[DateTime] + Tags: Optional[TagList] + + +TransitGatewayAttachmentList = List[TransitGatewayAttachment] + + +class DescribeTransitGatewayAttachmentsResult(TypedDict, total=False): + TransitGatewayAttachments: Optional[TransitGatewayAttachmentList] + NextToken: Optional[String] + + +TransitGatewayConnectPeerIdStringList = List[TransitGatewayConnectPeerId] + + +class DescribeTransitGatewayConnectPeersRequest(ServiceRequest): + TransitGatewayConnectPeerIds: Optional[TransitGatewayConnectPeerIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayConnectPeerList = List[TransitGatewayConnectPeer] + + +class DescribeTransitGatewayConnectPeersResult(TypedDict, total=False): + TransitGatewayConnectPeers: Optional[TransitGatewayConnectPeerList] + NextToken: Optional[String] + + +class DescribeTransitGatewayConnectsRequest(ServiceRequest): + TransitGatewayAttachmentIds: Optional[TransitGatewayAttachmentIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayConnectList = List[TransitGatewayConnect] + + +class DescribeTransitGatewayConnectsResult(TypedDict, total=False): + TransitGatewayConnects: Optional[TransitGatewayConnectList] + NextToken: Optional[String] + + +TransitGatewayMulticastDomainIdStringList = List[TransitGatewayMulticastDomainId] + + +class DescribeTransitGatewayMulticastDomainsRequest(ServiceRequest): + TransitGatewayMulticastDomainIds: Optional[TransitGatewayMulticastDomainIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayMulticastDomainList = List[TransitGatewayMulticastDomain] + + +class DescribeTransitGatewayMulticastDomainsResult(TypedDict, total=False): + TransitGatewayMulticastDomains: Optional[TransitGatewayMulticastDomainList] + NextToken: Optional[String] + + +class DescribeTransitGatewayPeeringAttachmentsRequest(ServiceRequest): + TransitGatewayAttachmentIds: Optional[TransitGatewayAttachmentIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayPeeringAttachmentList = List[TransitGatewayPeeringAttachment] + + +class DescribeTransitGatewayPeeringAttachmentsResult(TypedDict, total=False): + TransitGatewayPeeringAttachments: Optional[TransitGatewayPeeringAttachmentList] + NextToken: Optional[String] + + +TransitGatewayPolicyTableIdStringList = List[TransitGatewayPolicyTableId] + + +class DescribeTransitGatewayPolicyTablesRequest(ServiceRequest): + TransitGatewayPolicyTableIds: Optional[TransitGatewayPolicyTableIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayPolicyTableList = List[TransitGatewayPolicyTable] + + +class DescribeTransitGatewayPolicyTablesResult(TypedDict, total=False): + TransitGatewayPolicyTables: Optional[TransitGatewayPolicyTableList] + NextToken: Optional[String] + + +TransitGatewayRouteTableAnnouncementIdStringList = List[TransitGatewayRouteTableAnnouncementId] + + +class DescribeTransitGatewayRouteTableAnnouncementsRequest(ServiceRequest): + TransitGatewayRouteTableAnnouncementIds: Optional[ + TransitGatewayRouteTableAnnouncementIdStringList + ] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayRouteTableAnnouncementList = List[TransitGatewayRouteTableAnnouncement] + + +class DescribeTransitGatewayRouteTableAnnouncementsResult(TypedDict, total=False): + TransitGatewayRouteTableAnnouncements: Optional[TransitGatewayRouteTableAnnouncementList] + NextToken: Optional[String] + + +TransitGatewayRouteTableIdStringList = List[TransitGatewayRouteTableId] + + +class DescribeTransitGatewayRouteTablesRequest(ServiceRequest): + TransitGatewayRouteTableIds: Optional[TransitGatewayRouteTableIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayRouteTableList = List[TransitGatewayRouteTable] + + +class DescribeTransitGatewayRouteTablesResult(TypedDict, total=False): + TransitGatewayRouteTables: Optional[TransitGatewayRouteTableList] + NextToken: Optional[String] + + +class DescribeTransitGatewayVpcAttachmentsRequest(ServiceRequest): + TransitGatewayAttachmentIds: Optional[TransitGatewayAttachmentIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayVpcAttachmentList = List[TransitGatewayVpcAttachment] + + +class DescribeTransitGatewayVpcAttachmentsResult(TypedDict, total=False): + TransitGatewayVpcAttachments: Optional[TransitGatewayVpcAttachmentList] + NextToken: Optional[String] + + +TransitGatewayIdStringList = List[TransitGatewayId] + + +class DescribeTransitGatewaysRequest(ServiceRequest): + TransitGatewayIds: Optional[TransitGatewayIdStringList] + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayList = List[TransitGateway] + + +class DescribeTransitGatewaysResult(TypedDict, total=False): + TransitGateways: Optional[TransitGatewayList] + NextToken: Optional[String] + + +TrunkInterfaceAssociationIdList = List[TrunkInterfaceAssociationId] + + +class DescribeTrunkInterfaceAssociationsRequest(ServiceRequest): + AssociationIds: Optional[TrunkInterfaceAssociationIdList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[DescribeTrunkInterfaceAssociationsMaxResults] + + +TrunkInterfaceAssociationList = List[TrunkInterfaceAssociation] + + +class DescribeTrunkInterfaceAssociationsResult(TypedDict, total=False): + InterfaceAssociations: Optional[TrunkInterfaceAssociationList] + NextToken: Optional[String] + + +VerifiedAccessEndpointIdList = List[VerifiedAccessEndpointId] + + +class DescribeVerifiedAccessEndpointsRequest(ServiceRequest): + VerifiedAccessEndpointIds: Optional[VerifiedAccessEndpointIdList] + VerifiedAccessInstanceId: Optional[VerifiedAccessInstanceId] + VerifiedAccessGroupId: Optional[VerifiedAccessGroupId] + MaxResults: Optional[DescribeVerifiedAccessEndpointsMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +VerifiedAccessEndpointList = List[VerifiedAccessEndpoint] + + +class DescribeVerifiedAccessEndpointsResult(TypedDict, total=False): + VerifiedAccessEndpoints: Optional[VerifiedAccessEndpointList] + NextToken: Optional[NextToken] + + +VerifiedAccessGroupIdList = List[VerifiedAccessGroupId] + + +class DescribeVerifiedAccessGroupsRequest(ServiceRequest): + VerifiedAccessGroupIds: Optional[VerifiedAccessGroupIdList] + VerifiedAccessInstanceId: Optional[VerifiedAccessInstanceId] + MaxResults: Optional[DescribeVerifiedAccessGroupMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +VerifiedAccessGroupList = List[VerifiedAccessGroup] + + +class DescribeVerifiedAccessGroupsResult(TypedDict, total=False): + VerifiedAccessGroups: Optional[VerifiedAccessGroupList] + NextToken: Optional[NextToken] + + +VerifiedAccessInstanceIdList = List[VerifiedAccessInstanceId] + + +class DescribeVerifiedAccessInstanceLoggingConfigurationsRequest(ServiceRequest): + VerifiedAccessInstanceIds: Optional[VerifiedAccessInstanceIdList] + MaxResults: Optional[DescribeVerifiedAccessInstanceLoggingConfigurationsMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class VerifiedAccessLogDeliveryStatus(TypedDict, total=False): + Code: Optional[VerifiedAccessLogDeliveryStatusCode] + Message: Optional[String] + + +class VerifiedAccessLogKinesisDataFirehoseDestination(TypedDict, total=False): + Enabled: Optional[Boolean] + DeliveryStatus: Optional[VerifiedAccessLogDeliveryStatus] + DeliveryStream: Optional[String] + + +class VerifiedAccessLogCloudWatchLogsDestination(TypedDict, total=False): + Enabled: Optional[Boolean] + DeliveryStatus: Optional[VerifiedAccessLogDeliveryStatus] + LogGroup: Optional[String] + + +class VerifiedAccessLogS3Destination(TypedDict, total=False): + Enabled: Optional[Boolean] + DeliveryStatus: Optional[VerifiedAccessLogDeliveryStatus] + BucketName: Optional[String] + Prefix: Optional[String] + BucketOwner: Optional[String] + + +class VerifiedAccessLogs(TypedDict, total=False): + S3: Optional[VerifiedAccessLogS3Destination] + CloudWatchLogs: Optional[VerifiedAccessLogCloudWatchLogsDestination] + KinesisDataFirehose: Optional[VerifiedAccessLogKinesisDataFirehoseDestination] + LogVersion: Optional[String] + IncludeTrustContext: Optional[Boolean] + + +class VerifiedAccessInstanceLoggingConfiguration(TypedDict, total=False): + VerifiedAccessInstanceId: Optional[String] + AccessLogs: Optional[VerifiedAccessLogs] + + +VerifiedAccessInstanceLoggingConfigurationList = List[VerifiedAccessInstanceLoggingConfiguration] + + +class DescribeVerifiedAccessInstanceLoggingConfigurationsResult(TypedDict, total=False): + LoggingConfigurations: Optional[VerifiedAccessInstanceLoggingConfigurationList] + NextToken: Optional[NextToken] + + +class DescribeVerifiedAccessInstancesRequest(ServiceRequest): + VerifiedAccessInstanceIds: Optional[VerifiedAccessInstanceIdList] + MaxResults: Optional[DescribeVerifiedAccessInstancesMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +VerifiedAccessInstanceList = List[VerifiedAccessInstance] + + +class DescribeVerifiedAccessInstancesResult(TypedDict, total=False): + VerifiedAccessInstances: Optional[VerifiedAccessInstanceList] + NextToken: Optional[NextToken] + + +VerifiedAccessTrustProviderIdList = List[VerifiedAccessTrustProviderId] + + +class DescribeVerifiedAccessTrustProvidersRequest(ServiceRequest): + VerifiedAccessTrustProviderIds: Optional[VerifiedAccessTrustProviderIdList] + MaxResults: Optional[DescribeVerifiedAccessTrustProvidersMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +VerifiedAccessTrustProviderList = List[VerifiedAccessTrustProvider] + + +class DescribeVerifiedAccessTrustProvidersResult(TypedDict, total=False): + VerifiedAccessTrustProviders: Optional[VerifiedAccessTrustProviderList] + NextToken: Optional[NextToken] + + +class DescribeVolumeAttributeRequest(ServiceRequest): + Attribute: VolumeAttributeName + VolumeId: VolumeId + DryRun: Optional[Boolean] + + +class DescribeVolumeAttributeResult(TypedDict, total=False): + AutoEnableIO: Optional[AttributeBooleanValue] + ProductCodes: Optional[ProductCodeList] + VolumeId: Optional[String] + + +class DescribeVolumeStatusRequest(ServiceRequest): + MaxResults: Optional[Integer] + NextToken: Optional[String] + VolumeIds: Optional[VolumeIdStringList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class VolumeStatusAttachmentStatus(TypedDict, total=False): + IoPerformance: Optional[String] + InstanceId: Optional[String] + + +VolumeStatusAttachmentStatusList = List[VolumeStatusAttachmentStatus] + + +class VolumeStatusDetails(TypedDict, total=False): + Name: Optional[VolumeStatusName] + Status: Optional[String] + + +VolumeStatusDetailsList = List[VolumeStatusDetails] + + +class VolumeStatusInfo(TypedDict, total=False): + Details: Optional[VolumeStatusDetailsList] + Status: Optional[VolumeStatusInfoStatus] + + +class VolumeStatusEvent(TypedDict, total=False): + Description: Optional[String] + EventId: Optional[String] + EventType: Optional[String] + NotAfter: Optional[MillisecondDateTime] + NotBefore: Optional[MillisecondDateTime] + InstanceId: Optional[String] + + +VolumeStatusEventsList = List[VolumeStatusEvent] + + +class VolumeStatusAction(TypedDict, total=False): + Code: Optional[String] + Description: Optional[String] + EventId: Optional[String] + EventType: Optional[String] + + +VolumeStatusActionsList = List[VolumeStatusAction] + + +class VolumeStatusItem(TypedDict, total=False): + Actions: Optional[VolumeStatusActionsList] + AvailabilityZone: Optional[String] + OutpostArn: Optional[String] + Events: Optional[VolumeStatusEventsList] + VolumeId: Optional[String] + VolumeStatus: Optional[VolumeStatusInfo] + AttachmentStatuses: Optional[VolumeStatusAttachmentStatusList] + AvailabilityZoneId: Optional[String] + + +VolumeStatusList = List[VolumeStatusItem] + + +class DescribeVolumeStatusResult(TypedDict, total=False): + NextToken: Optional[String] + VolumeStatuses: Optional[VolumeStatusList] + + +class DescribeVolumesModificationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + VolumeIds: Optional[VolumeIdStringList] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[Integer] + + +class VolumeModification(TypedDict, total=False): + VolumeId: Optional[String] + ModificationState: Optional[VolumeModificationState] + StatusMessage: Optional[String] + TargetSize: Optional[Integer] + TargetIops: Optional[Integer] + TargetVolumeType: Optional[VolumeType] + TargetThroughput: Optional[Integer] + TargetMultiAttachEnabled: Optional[Boolean] + OriginalSize: Optional[Integer] + OriginalIops: Optional[Integer] + OriginalVolumeType: Optional[VolumeType] + OriginalThroughput: Optional[Integer] + OriginalMultiAttachEnabled: Optional[Boolean] + Progress: Optional[Long] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + + +VolumeModificationList = List[VolumeModification] + + +class DescribeVolumesModificationsResult(TypedDict, total=False): + NextToken: Optional[String] + VolumesModifications: Optional[VolumeModificationList] + + +class DescribeVolumesRequest(ServiceRequest): + VolumeIds: Optional[VolumeIdStringList] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + NextToken: Optional[String] + MaxResults: Optional[Integer] + + +class VolumeAttachment(TypedDict, total=False): + DeleteOnTermination: Optional[Boolean] + AssociatedResource: Optional[String] + InstanceOwningService: Optional[String] + VolumeId: Optional[String] + InstanceId: Optional[String] + Device: Optional[String] + State: Optional[VolumeAttachmentState] + AttachTime: Optional[DateTime] + + +VolumeAttachmentList = List[VolumeAttachment] + + +class Volume(TypedDict, total=False): + OutpostArn: Optional[String] + Iops: Optional[Integer] + Tags: Optional[TagList] + VolumeType: Optional[VolumeType] + FastRestored: Optional[Boolean] + MultiAttachEnabled: Optional[Boolean] + Throughput: Optional[Integer] + SseType: Optional[SSEType] + Operator: Optional[OperatorResponse] + VolumeInitializationRate: Optional[Integer] + VolumeId: Optional[String] + Size: Optional[Integer] + SnapshotId: Optional[String] + AvailabilityZone: Optional[String] + State: Optional[VolumeState] + CreateTime: Optional[DateTime] + Attachments: Optional[VolumeAttachmentList] + Encrypted: Optional[Boolean] + KmsKeyId: Optional[String] + + +VolumeList = List[Volume] + + +class DescribeVolumesResult(TypedDict, total=False): + NextToken: Optional[String] + Volumes: Optional[VolumeList] + + +class DescribeVpcAttributeRequest(ServiceRequest): + Attribute: VpcAttributeName + VpcId: VpcId + DryRun: Optional[Boolean] + + +class DescribeVpcAttributeResult(TypedDict, total=False): + EnableDnsHostnames: Optional[AttributeBooleanValue] + EnableDnsSupport: Optional[AttributeBooleanValue] + EnableNetworkAddressUsageMetrics: Optional[AttributeBooleanValue] + VpcId: Optional[String] + + +VpcBlockPublicAccessExclusionIdList = List[VpcBlockPublicAccessExclusionId] + + +class DescribeVpcBlockPublicAccessExclusionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + ExclusionIds: Optional[VpcBlockPublicAccessExclusionIdList] + NextToken: Optional[String] + MaxResults: Optional[DescribeVpcBlockPublicAccessExclusionsMaxResults] + + +VpcBlockPublicAccessExclusionList = List[VpcBlockPublicAccessExclusion] + + +class DescribeVpcBlockPublicAccessExclusionsResult(TypedDict, total=False): + VpcBlockPublicAccessExclusions: Optional[VpcBlockPublicAccessExclusionList] + NextToken: Optional[String] + + +class DescribeVpcBlockPublicAccessOptionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class VpcBlockPublicAccessOptions(TypedDict, total=False): + AwsAccountId: Optional[String] + AwsRegion: Optional[String] + State: Optional[VpcBlockPublicAccessState] + InternetGatewayBlockMode: Optional[InternetGatewayBlockMode] + Reason: Optional[String] + LastUpdateTimestamp: Optional[MillisecondDateTime] + ManagedBy: Optional[ManagedBy] + ExclusionsAllowed: Optional[VpcBlockPublicAccessExclusionsAllowed] + + +class DescribeVpcBlockPublicAccessOptionsResult(TypedDict, total=False): + VpcBlockPublicAccessOptions: Optional[VpcBlockPublicAccessOptions] + + +VpcClassicLinkIdList = List[VpcId] + + +class DescribeVpcClassicLinkDnsSupportRequest(ServiceRequest): + VpcIds: Optional[VpcClassicLinkIdList] + MaxResults: Optional[DescribeVpcClassicLinkDnsSupportMaxResults] + NextToken: Optional[DescribeVpcClassicLinkDnsSupportNextToken] + + +class DescribeVpcClassicLinkDnsSupportResult(TypedDict, total=False): + NextToken: Optional[DescribeVpcClassicLinkDnsSupportNextToken] + Vpcs: Optional[ClassicLinkDnsSupportList] + + +class DescribeVpcClassicLinkRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcIds: Optional[VpcClassicLinkIdList] + Filters: Optional[FilterList] + + +class VpcClassicLink(TypedDict, total=False): + ClassicLinkEnabled: Optional[Boolean] + Tags: Optional[TagList] + VpcId: Optional[String] + + +VpcClassicLinkList = List[VpcClassicLink] + + +class DescribeVpcClassicLinkResult(TypedDict, total=False): + Vpcs: Optional[VpcClassicLinkList] + + +class DescribeVpcEndpointAssociationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcEndpointIds: Optional[VpcEndpointIdList] + Filters: Optional[FilterList] + MaxResults: Optional[maxResults] + NextToken: Optional[String] + + +class VpcEndpointAssociation(TypedDict, total=False): + Id: Optional[String] + VpcEndpointId: Optional[VpcEndpointId] + ServiceNetworkArn: Optional[ServiceNetworkArn] + ServiceNetworkName: Optional[String] + AssociatedResourceAccessibility: Optional[String] + FailureReason: Optional[String] + FailureCode: Optional[String] + DnsEntry: Optional[DnsEntry] + PrivateDnsEntry: Optional[DnsEntry] + AssociatedResourceArn: Optional[String] + ResourceConfigurationGroupArn: Optional[String] + Tags: Optional[TagList] + + +VpcEndpointAssociationSet = List[VpcEndpointAssociation] + + +class DescribeVpcEndpointAssociationsResult(TypedDict, total=False): + VpcEndpointAssociations: Optional[VpcEndpointAssociationSet] + NextToken: Optional[String] + + +class DescribeVpcEndpointConnectionNotificationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ConnectionNotificationId: Optional[ConnectionNotificationId] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class DescribeVpcEndpointConnectionNotificationsResult(TypedDict, total=False): + ConnectionNotificationSet: Optional[ConnectionNotificationSet] + NextToken: Optional[String] + + +class DescribeVpcEndpointConnectionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class VpcEndpointConnection(TypedDict, total=False): + ServiceId: Optional[String] + VpcEndpointId: Optional[String] + VpcEndpointOwner: Optional[String] + VpcEndpointState: Optional[State] + CreationTimestamp: Optional[MillisecondDateTime] + DnsEntries: Optional[DnsEntrySet] + NetworkLoadBalancerArns: Optional[ValueStringList] + GatewayLoadBalancerArns: Optional[ValueStringList] + IpAddressType: Optional[IpAddressType] + VpcEndpointConnectionId: Optional[String] + Tags: Optional[TagList] + VpcEndpointRegion: Optional[String] + + +VpcEndpointConnectionSet = List[VpcEndpointConnection] + + +class DescribeVpcEndpointConnectionsResult(TypedDict, total=False): + VpcEndpointConnections: Optional[VpcEndpointConnectionSet] + NextToken: Optional[String] + + +class DescribeVpcEndpointServiceConfigurationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceIds: Optional[VpcEndpointServiceIdList] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +ServiceConfigurationSet = List[ServiceConfiguration] + + +class DescribeVpcEndpointServiceConfigurationsResult(TypedDict, total=False): + ServiceConfigurations: Optional[ServiceConfigurationSet] + NextToken: Optional[String] + + +class DescribeVpcEndpointServicePermissionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: VpcEndpointServiceId + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +class DescribeVpcEndpointServicePermissionsResult(TypedDict, total=False): + AllowedPrincipals: Optional[AllowedPrincipalSet] + NextToken: Optional[String] + + +class DescribeVpcEndpointServicesRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceNames: Optional[ValueStringList] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + ServiceRegions: Optional[ValueStringList] + + +class PrivateDnsDetails(TypedDict, total=False): + PrivateDnsName: Optional[String] + + +PrivateDnsDetailsSet = List[PrivateDnsDetails] + + +class ServiceDetail(TypedDict, total=False): + ServiceName: Optional[String] + ServiceId: Optional[String] + ServiceType: Optional[ServiceTypeDetailSet] + ServiceRegion: Optional[String] + AvailabilityZones: Optional[ValueStringList] + Owner: Optional[String] + BaseEndpointDnsNames: Optional[ValueStringList] + PrivateDnsName: Optional[String] + PrivateDnsNames: Optional[PrivateDnsDetailsSet] + VpcEndpointPolicySupported: Optional[Boolean] + AcceptanceRequired: Optional[Boolean] + ManagesVpcEndpoints: Optional[Boolean] + PayerResponsibility: Optional[PayerResponsibility] + Tags: Optional[TagList] + PrivateDnsNameVerificationState: Optional[DnsNameState] + SupportedIpAddressTypes: Optional[SupportedIpAddressTypes] + + +ServiceDetailSet = List[ServiceDetail] + + +class DescribeVpcEndpointServicesResult(TypedDict, total=False): + ServiceNames: Optional[ValueStringList] + ServiceDetails: Optional[ServiceDetailSet] + NextToken: Optional[String] + + +class DescribeVpcEndpointsRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcEndpointIds: Optional[VpcEndpointIdList] + Filters: Optional[FilterList] + MaxResults: Optional[Integer] + NextToken: Optional[String] + + +VpcEndpointSet = List[VpcEndpoint] + + +class DescribeVpcEndpointsResult(TypedDict, total=False): + VpcEndpoints: Optional[VpcEndpointSet] + NextToken: Optional[String] + + +VpcPeeringConnectionIdList = List[VpcPeeringConnectionId] + + +class DescribeVpcPeeringConnectionsRequest(ServiceRequest): + NextToken: Optional[String] + MaxResults: Optional[DescribeVpcPeeringConnectionsMaxResults] + DryRun: Optional[Boolean] + VpcPeeringConnectionIds: Optional[VpcPeeringConnectionIdList] + Filters: Optional[FilterList] + + +VpcPeeringConnectionList = List[VpcPeeringConnection] + + +class DescribeVpcPeeringConnectionsResult(TypedDict, total=False): + VpcPeeringConnections: Optional[VpcPeeringConnectionList] + NextToken: Optional[String] + + +VpcIdStringList = List[VpcId] + + +class DescribeVpcsRequest(ServiceRequest): + Filters: Optional[FilterList] + VpcIds: Optional[VpcIdStringList] + NextToken: Optional[String] + MaxResults: Optional[DescribeVpcsMaxResults] + DryRun: Optional[Boolean] + + +VpcList = List[Vpc] + + +class DescribeVpcsResult(TypedDict, total=False): + NextToken: Optional[String] + Vpcs: Optional[VpcList] + + +VpnConnectionIdStringList = List[VpnConnectionId] + + +class DescribeVpnConnectionsRequest(ServiceRequest): + Filters: Optional[FilterList] + VpnConnectionIds: Optional[VpnConnectionIdStringList] + DryRun: Optional[Boolean] + + +VpnConnectionList = List[VpnConnection] + + +class DescribeVpnConnectionsResult(TypedDict, total=False): + VpnConnections: Optional[VpnConnectionList] + + +VpnGatewayIdStringList = List[VpnGatewayId] + + +class DescribeVpnGatewaysRequest(ServiceRequest): + Filters: Optional[FilterList] + VpnGatewayIds: Optional[VpnGatewayIdStringList] + DryRun: Optional[Boolean] + + +VpnGatewayList = List[VpnGateway] + + +class DescribeVpnGatewaysResult(TypedDict, total=False): + VpnGateways: Optional[VpnGatewayList] + + +class DetachClassicLinkVpcRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + VpcId: VpcId + + +class DetachClassicLinkVpcResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DetachInternetGatewayRequest(ServiceRequest): + DryRun: Optional[Boolean] + InternetGatewayId: InternetGatewayId + VpcId: VpcId + + +class DetachNetworkInterfaceRequest(ServiceRequest): + DryRun: Optional[Boolean] + AttachmentId: NetworkInterfaceAttachmentId + Force: Optional[Boolean] + + +class DetachVerifiedAccessTrustProviderRequest(ServiceRequest): + VerifiedAccessInstanceId: VerifiedAccessInstanceId + VerifiedAccessTrustProviderId: VerifiedAccessTrustProviderId + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class DetachVerifiedAccessTrustProviderResult(TypedDict, total=False): + VerifiedAccessTrustProvider: Optional[VerifiedAccessTrustProvider] + VerifiedAccessInstance: Optional[VerifiedAccessInstance] + + +class DetachVolumeRequest(ServiceRequest): + Device: Optional[String] + Force: Optional[Boolean] + InstanceId: Optional[InstanceIdForResolver] + VolumeId: VolumeIdWithResolver + DryRun: Optional[Boolean] + + +class DetachVpnGatewayRequest(ServiceRequest): + VpcId: VpcId + VpnGatewayId: VpnGatewayId + DryRun: Optional[Boolean] + + +DeviceTrustProviderTypeList = List[DeviceTrustProviderType] + + +class DisableAddressTransferRequest(ServiceRequest): + AllocationId: AllocationId + DryRun: Optional[Boolean] + + +class DisableAddressTransferResult(TypedDict, total=False): + AddressTransfer: Optional[AddressTransfer] + + +class DisableAllowedImagesSettingsRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DisableAllowedImagesSettingsResult(TypedDict, total=False): + AllowedImagesSettingsState: Optional[AllowedImagesSettingsDisabledState] + + +class DisableAwsNetworkPerformanceMetricSubscriptionRequest(ServiceRequest): + Source: Optional[String] + Destination: Optional[String] + Metric: Optional[MetricType] + Statistic: Optional[StatisticType] + DryRun: Optional[Boolean] + + +class DisableAwsNetworkPerformanceMetricSubscriptionResult(TypedDict, total=False): + Output: Optional[Boolean] + + +class DisableEbsEncryptionByDefaultRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DisableEbsEncryptionByDefaultResult(TypedDict, total=False): + EbsEncryptionByDefault: Optional[Boolean] + + +class DisableFastLaunchRequest(ServiceRequest): + ImageId: ImageId + Force: Optional[Boolean] + DryRun: Optional[Boolean] + + +class DisableFastLaunchResult(TypedDict, total=False): + ImageId: Optional[ImageId] + ResourceType: Optional[FastLaunchResourceType] + SnapshotConfiguration: Optional[FastLaunchSnapshotConfigurationResponse] + LaunchTemplate: Optional[FastLaunchLaunchTemplateSpecificationResponse] + MaxParallelLaunches: Optional[Integer] + OwnerId: Optional[String] + State: Optional[FastLaunchStateCode] + StateTransitionReason: Optional[String] + StateTransitionTime: Optional[MillisecondDateTime] + + +class DisableFastSnapshotRestoreStateError(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +class DisableFastSnapshotRestoreStateErrorItem(TypedDict, total=False): + AvailabilityZone: Optional[String] + Error: Optional[DisableFastSnapshotRestoreStateError] + + +DisableFastSnapshotRestoreStateErrorSet = List[DisableFastSnapshotRestoreStateErrorItem] + + +class DisableFastSnapshotRestoreErrorItem(TypedDict, total=False): + SnapshotId: Optional[String] + FastSnapshotRestoreStateErrors: Optional[DisableFastSnapshotRestoreStateErrorSet] + + +DisableFastSnapshotRestoreErrorSet = List[DisableFastSnapshotRestoreErrorItem] + + +class DisableFastSnapshotRestoreSuccessItem(TypedDict, total=False): + SnapshotId: Optional[String] + AvailabilityZone: Optional[String] + State: Optional[FastSnapshotRestoreStateCode] + StateTransitionReason: Optional[String] + OwnerId: Optional[String] + OwnerAlias: Optional[String] + EnablingTime: Optional[MillisecondDateTime] + OptimizingTime: Optional[MillisecondDateTime] + EnabledTime: Optional[MillisecondDateTime] + DisablingTime: Optional[MillisecondDateTime] + DisabledTime: Optional[MillisecondDateTime] + + +DisableFastSnapshotRestoreSuccessSet = List[DisableFastSnapshotRestoreSuccessItem] + + +class DisableFastSnapshotRestoresRequest(ServiceRequest): + AvailabilityZones: AvailabilityZoneStringList + SourceSnapshotIds: SnapshotIdStringList + DryRun: Optional[Boolean] + + +class DisableFastSnapshotRestoresResult(TypedDict, total=False): + Successful: Optional[DisableFastSnapshotRestoreSuccessSet] + Unsuccessful: Optional[DisableFastSnapshotRestoreErrorSet] + + +class DisableImageBlockPublicAccessRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DisableImageBlockPublicAccessResult(TypedDict, total=False): + ImageBlockPublicAccessState: Optional[ImageBlockPublicAccessDisabledState] + + +class DisableImageDeprecationRequest(ServiceRequest): + ImageId: ImageId + DryRun: Optional[Boolean] + + +class DisableImageDeprecationResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DisableImageDeregistrationProtectionRequest(ServiceRequest): + ImageId: ImageId + DryRun: Optional[Boolean] + + +class DisableImageDeregistrationProtectionResult(TypedDict, total=False): + Return: Optional[String] + + +class DisableImageRequest(ServiceRequest): + ImageId: ImageId + DryRun: Optional[Boolean] + + +class DisableImageResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DisableIpamOrganizationAdminAccountRequest(ServiceRequest): + DryRun: Optional[Boolean] + DelegatedAdminAccountId: String + + +class DisableIpamOrganizationAdminAccountResult(TypedDict, total=False): + Success: Optional[Boolean] + + +class DisableRouteServerPropagationRequest(ServiceRequest): + RouteServerId: RouteServerId + RouteTableId: RouteTableId + DryRun: Optional[Boolean] + + +class RouteServerPropagation(TypedDict, total=False): + RouteServerId: Optional[RouteServerId] + RouteTableId: Optional[RouteTableId] + State: Optional[RouteServerPropagationState] + + +class DisableRouteServerPropagationResult(TypedDict, total=False): + RouteServerPropagation: Optional[RouteServerPropagation] + + +class DisableSerialConsoleAccessRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DisableSerialConsoleAccessResult(TypedDict, total=False): + SerialConsoleAccessEnabled: Optional[Boolean] + + +class DisableSnapshotBlockPublicAccessRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class DisableSnapshotBlockPublicAccessResult(TypedDict, total=False): + State: Optional[SnapshotBlockPublicAccessState] + + +class DisableTransitGatewayRouteTablePropagationRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + DryRun: Optional[Boolean] + TransitGatewayRouteTableAnnouncementId: Optional[TransitGatewayRouteTableAnnouncementId] + + +class TransitGatewayPropagation(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + TransitGatewayRouteTableId: Optional[String] + State: Optional[TransitGatewayPropagationState] + TransitGatewayRouteTableAnnouncementId: Optional[TransitGatewayRouteTableAnnouncementId] + + +class DisableTransitGatewayRouteTablePropagationResult(TypedDict, total=False): + Propagation: Optional[TransitGatewayPropagation] + + +class DisableVgwRoutePropagationRequest(ServiceRequest): + GatewayId: VpnGatewayId + RouteTableId: RouteTableId + DryRun: Optional[Boolean] + + +class DisableVpcClassicLinkDnsSupportRequest(ServiceRequest): + VpcId: Optional[VpcId] + + +class DisableVpcClassicLinkDnsSupportResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DisableVpcClassicLinkRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcId: VpcId + + +class DisableVpcClassicLinkResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DisassociateAddressRequest(ServiceRequest): + AssociationId: Optional[ElasticIpAssociationId] + PublicIp: Optional[EipAllocationPublicIp] + DryRun: Optional[Boolean] + + +class DisassociateCapacityReservationBillingOwnerRequest(ServiceRequest): + DryRun: Optional[Boolean] + CapacityReservationId: CapacityReservationId + UnusedReservationBillingOwnerId: AccountID + + +class DisassociateCapacityReservationBillingOwnerResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DisassociateClientVpnTargetNetworkRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + AssociationId: String + DryRun: Optional[Boolean] + + +class DisassociateClientVpnTargetNetworkResult(TypedDict, total=False): + AssociationId: Optional[String] + Status: Optional[AssociationStatus] + + +class DisassociateEnclaveCertificateIamRoleRequest(ServiceRequest): + CertificateArn: CertificateId + RoleArn: RoleId + DryRun: Optional[Boolean] + + +class DisassociateEnclaveCertificateIamRoleResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class DisassociateIamInstanceProfileRequest(ServiceRequest): + AssociationId: IamInstanceProfileAssociationId + + +class DisassociateIamInstanceProfileResult(TypedDict, total=False): + IamInstanceProfileAssociation: Optional[IamInstanceProfileAssociation] + + +class InstanceEventWindowDisassociationRequest(TypedDict, total=False): + InstanceIds: Optional[InstanceIdList] + InstanceTags: Optional[TagList] + DedicatedHostIds: Optional[DedicatedHostIdList] + + +class DisassociateInstanceEventWindowRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceEventWindowId: InstanceEventWindowId + AssociationTarget: InstanceEventWindowDisassociationRequest + + +class DisassociateInstanceEventWindowResult(TypedDict, total=False): + InstanceEventWindow: Optional[InstanceEventWindow] + + +class DisassociateIpamByoasnRequest(ServiceRequest): + DryRun: Optional[Boolean] + Asn: String + Cidr: String + + +class DisassociateIpamByoasnResult(TypedDict, total=False): + AsnAssociation: Optional[AsnAssociation] + + +class DisassociateIpamResourceDiscoveryRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryAssociationId: IpamResourceDiscoveryAssociationId + + +class DisassociateIpamResourceDiscoveryResult(TypedDict, total=False): + IpamResourceDiscoveryAssociation: Optional[IpamResourceDiscoveryAssociation] + + +EipAssociationIdList = List[ElasticIpAssociationId] + + +class DisassociateNatGatewayAddressRequest(ServiceRequest): + NatGatewayId: NatGatewayId + AssociationIds: EipAssociationIdList + MaxDrainDurationSeconds: Optional[DrainSeconds] + DryRun: Optional[Boolean] + + +class DisassociateNatGatewayAddressResult(TypedDict, total=False): + NatGatewayId: Optional[NatGatewayId] + NatGatewayAddresses: Optional[NatGatewayAddressList] + + +class DisassociateRouteServerRequest(ServiceRequest): + RouteServerId: RouteServerId + VpcId: VpcId + DryRun: Optional[Boolean] + + +class DisassociateRouteServerResult(TypedDict, total=False): + RouteServerAssociation: Optional[RouteServerAssociation] + + +class DisassociateRouteTableRequest(ServiceRequest): + DryRun: Optional[Boolean] + AssociationId: RouteTableAssociationId + + +class DisassociateSecurityGroupVpcRequest(ServiceRequest): + GroupId: DisassociateSecurityGroupVpcSecurityGroupId + VpcId: String + DryRun: Optional[Boolean] + + +class DisassociateSecurityGroupVpcResult(TypedDict, total=False): + State: Optional[SecurityGroupVpcAssociationState] + + +class DisassociateSubnetCidrBlockRequest(ServiceRequest): + AssociationId: SubnetCidrAssociationId + + +class DisassociateSubnetCidrBlockResult(TypedDict, total=False): + Ipv6CidrBlockAssociation: Optional[SubnetIpv6CidrBlockAssociation] + SubnetId: Optional[String] + + +class DisassociateTransitGatewayMulticastDomainRequest(ServiceRequest): + TransitGatewayMulticastDomainId: TransitGatewayMulticastDomainId + TransitGatewayAttachmentId: TransitGatewayAttachmentId + SubnetIds: TransitGatewaySubnetIdList + DryRun: Optional[Boolean] + + +class DisassociateTransitGatewayMulticastDomainResult(TypedDict, total=False): + Associations: Optional[TransitGatewayMulticastDomainAssociations] + + +class DisassociateTransitGatewayPolicyTableRequest(ServiceRequest): + TransitGatewayPolicyTableId: TransitGatewayPolicyTableId + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class DisassociateTransitGatewayPolicyTableResult(TypedDict, total=False): + Association: Optional[TransitGatewayPolicyTableAssociation] + + +class DisassociateTransitGatewayRouteTableRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class DisassociateTransitGatewayRouteTableResult(TypedDict, total=False): + Association: Optional[TransitGatewayAssociation] + + +class DisassociateTrunkInterfaceRequest(ServiceRequest): + AssociationId: TrunkInterfaceAssociationId + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class DisassociateTrunkInterfaceResult(TypedDict, total=False): + Return: Optional[Boolean] + ClientToken: Optional[String] + + +class DisassociateVpcCidrBlockRequest(ServiceRequest): + AssociationId: VpcCidrAssociationId + + +class DisassociateVpcCidrBlockResult(TypedDict, total=False): + Ipv6CidrBlockAssociation: Optional[VpcIpv6CidrBlockAssociation] + CidrBlockAssociation: Optional[VpcCidrBlockAssociation] + VpcId: Optional[String] + + +class VolumeDetail(TypedDict, total=False): + Size: Long + + +class DiskImageDetail(TypedDict, total=False): + Format: DiskImageFormat + Bytes: Long + ImportManifestUrl: ImportManifestUrl + + +class DiskImage(TypedDict, total=False): + Description: Optional[String] + Image: Optional[DiskImageDetail] + Volume: Optional[VolumeDetail] + + +DiskImageList = List[DiskImage] + + +class DnsServersOptionsModifyStructure(TypedDict, total=False): + CustomDnsServers: Optional[ValueStringList] + Enabled: Optional[Boolean] + + +class EbsInstanceBlockDeviceSpecification(TypedDict, total=False): + VolumeId: Optional[VolumeId] + DeleteOnTermination: Optional[Boolean] + + +ElasticGpuSpecifications = List[ElasticGpuSpecification] + + +class ElasticInferenceAccelerator(TypedDict, total=False): + Type: String + Count: Optional[ElasticInferenceAcceleratorCount] + + +ElasticInferenceAccelerators = List[ElasticInferenceAccelerator] + + +class EnableAddressTransferRequest(ServiceRequest): + AllocationId: AllocationId + TransferAccountId: String + DryRun: Optional[Boolean] + + +class EnableAddressTransferResult(TypedDict, total=False): + AddressTransfer: Optional[AddressTransfer] + + +class EnableAllowedImagesSettingsRequest(ServiceRequest): + AllowedImagesSettingsState: AllowedImagesSettingsEnabledState + DryRun: Optional[Boolean] + + +class EnableAllowedImagesSettingsResult(TypedDict, total=False): + AllowedImagesSettingsState: Optional[AllowedImagesSettingsEnabledState] + + +class EnableAwsNetworkPerformanceMetricSubscriptionRequest(ServiceRequest): + Source: Optional[String] + Destination: Optional[String] + Metric: Optional[MetricType] + Statistic: Optional[StatisticType] + DryRun: Optional[Boolean] + + +class EnableAwsNetworkPerformanceMetricSubscriptionResult(TypedDict, total=False): + Output: Optional[Boolean] + + +class EnableEbsEncryptionByDefaultRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class EnableEbsEncryptionByDefaultResult(TypedDict, total=False): + EbsEncryptionByDefault: Optional[Boolean] + + +class FastLaunchLaunchTemplateSpecificationRequest(TypedDict, total=False): + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[String] + Version: String + + +class FastLaunchSnapshotConfigurationRequest(TypedDict, total=False): + TargetResourceCount: Optional[Integer] + + +class EnableFastLaunchRequest(ServiceRequest): + ImageId: ImageId + ResourceType: Optional[String] + SnapshotConfiguration: Optional[FastLaunchSnapshotConfigurationRequest] + LaunchTemplate: Optional[FastLaunchLaunchTemplateSpecificationRequest] + MaxParallelLaunches: Optional[Integer] + DryRun: Optional[Boolean] + + +class EnableFastLaunchResult(TypedDict, total=False): + ImageId: Optional[ImageId] + ResourceType: Optional[FastLaunchResourceType] + SnapshotConfiguration: Optional[FastLaunchSnapshotConfigurationResponse] + LaunchTemplate: Optional[FastLaunchLaunchTemplateSpecificationResponse] + MaxParallelLaunches: Optional[Integer] + OwnerId: Optional[String] + State: Optional[FastLaunchStateCode] + StateTransitionReason: Optional[String] + StateTransitionTime: Optional[MillisecondDateTime] + + +class EnableFastSnapshotRestoreStateError(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +class EnableFastSnapshotRestoreStateErrorItem(TypedDict, total=False): + AvailabilityZone: Optional[String] + Error: Optional[EnableFastSnapshotRestoreStateError] + + +EnableFastSnapshotRestoreStateErrorSet = List[EnableFastSnapshotRestoreStateErrorItem] + + +class EnableFastSnapshotRestoreErrorItem(TypedDict, total=False): + SnapshotId: Optional[String] + FastSnapshotRestoreStateErrors: Optional[EnableFastSnapshotRestoreStateErrorSet] + + +EnableFastSnapshotRestoreErrorSet = List[EnableFastSnapshotRestoreErrorItem] + + +class EnableFastSnapshotRestoreSuccessItem(TypedDict, total=False): + SnapshotId: Optional[String] + AvailabilityZone: Optional[String] + State: Optional[FastSnapshotRestoreStateCode] + StateTransitionReason: Optional[String] + OwnerId: Optional[String] + OwnerAlias: Optional[String] + EnablingTime: Optional[MillisecondDateTime] + OptimizingTime: Optional[MillisecondDateTime] + EnabledTime: Optional[MillisecondDateTime] + DisablingTime: Optional[MillisecondDateTime] + DisabledTime: Optional[MillisecondDateTime] + + +EnableFastSnapshotRestoreSuccessSet = List[EnableFastSnapshotRestoreSuccessItem] + + +class EnableFastSnapshotRestoresRequest(ServiceRequest): + AvailabilityZones: AvailabilityZoneStringList + SourceSnapshotIds: SnapshotIdStringList + DryRun: Optional[Boolean] + + +class EnableFastSnapshotRestoresResult(TypedDict, total=False): + Successful: Optional[EnableFastSnapshotRestoreSuccessSet] + Unsuccessful: Optional[EnableFastSnapshotRestoreErrorSet] + + +class EnableImageBlockPublicAccessRequest(ServiceRequest): + ImageBlockPublicAccessState: ImageBlockPublicAccessEnabledState + DryRun: Optional[Boolean] + + +class EnableImageBlockPublicAccessResult(TypedDict, total=False): + ImageBlockPublicAccessState: Optional[ImageBlockPublicAccessEnabledState] + + +class EnableImageDeprecationRequest(ServiceRequest): + ImageId: ImageId + DeprecateAt: MillisecondDateTime + DryRun: Optional[Boolean] + + +class EnableImageDeprecationResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class EnableImageDeregistrationProtectionRequest(ServiceRequest): + ImageId: ImageId + WithCooldown: Optional[Boolean] + DryRun: Optional[Boolean] + + +class EnableImageDeregistrationProtectionResult(TypedDict, total=False): + Return: Optional[String] + + +class EnableImageRequest(ServiceRequest): + ImageId: ImageId + DryRun: Optional[Boolean] + + +class EnableImageResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class EnableIpamOrganizationAdminAccountRequest(ServiceRequest): + DryRun: Optional[Boolean] + DelegatedAdminAccountId: String + + +class EnableIpamOrganizationAdminAccountResult(TypedDict, total=False): + Success: Optional[Boolean] + + +class EnableReachabilityAnalyzerOrganizationSharingRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class EnableReachabilityAnalyzerOrganizationSharingResult(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class EnableRouteServerPropagationRequest(ServiceRequest): + RouteServerId: RouteServerId + RouteTableId: RouteTableId + DryRun: Optional[Boolean] + + +class EnableRouteServerPropagationResult(TypedDict, total=False): + RouteServerPropagation: Optional[RouteServerPropagation] + + +class EnableSerialConsoleAccessRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class EnableSerialConsoleAccessResult(TypedDict, total=False): + SerialConsoleAccessEnabled: Optional[Boolean] + + +class EnableSnapshotBlockPublicAccessRequest(ServiceRequest): + State: SnapshotBlockPublicAccessState + DryRun: Optional[Boolean] + + +class EnableSnapshotBlockPublicAccessResult(TypedDict, total=False): + State: Optional[SnapshotBlockPublicAccessState] + + +class EnableTransitGatewayRouteTablePropagationRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + DryRun: Optional[Boolean] + TransitGatewayRouteTableAnnouncementId: Optional[TransitGatewayRouteTableAnnouncementId] + + +class EnableTransitGatewayRouteTablePropagationResult(TypedDict, total=False): + Propagation: Optional[TransitGatewayPropagation] + + +class EnableVgwRoutePropagationRequest(ServiceRequest): + GatewayId: VpnGatewayId + RouteTableId: RouteTableId + DryRun: Optional[Boolean] + + +class EnableVolumeIORequest(ServiceRequest): + DryRun: Optional[Boolean] + VolumeId: VolumeId + + +class EnableVpcClassicLinkDnsSupportRequest(ServiceRequest): + VpcId: Optional[VpcId] + + +class EnableVpcClassicLinkDnsSupportResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class EnableVpcClassicLinkRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcId: VpcId + + +class EnableVpcClassicLinkResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class EnclaveOptionsRequest(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class ExportClientVpnClientCertificateRevocationListRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + DryRun: Optional[Boolean] + + +class ExportClientVpnClientCertificateRevocationListResult(TypedDict, total=False): + CertificateRevocationList: Optional[String] + Status: Optional[ClientCertificateRevocationListStatus] + + +class ExportClientVpnClientConfigurationRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + DryRun: Optional[Boolean] + + +class ExportClientVpnClientConfigurationResult(TypedDict, total=False): + ClientConfiguration: Optional[String] + + +class ExportTaskS3LocationRequest(TypedDict, total=False): + S3Bucket: String + S3Prefix: Optional[String] + + +class ExportImageRequest(ServiceRequest): + ClientToken: Optional[String] + Description: Optional[String] + DiskImageFormat: DiskImageFormat + DryRun: Optional[Boolean] + ImageId: ImageId + S3ExportLocation: ExportTaskS3LocationRequest + RoleName: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + + +class ExportImageResult(TypedDict, total=False): + Description: Optional[String] + DiskImageFormat: Optional[DiskImageFormat] + ExportImageTaskId: Optional[String] + ImageId: Optional[String] + RoleName: Optional[String] + Progress: Optional[String] + S3ExportLocation: Optional[ExportTaskS3Location] + Status: Optional[String] + StatusMessage: Optional[String] + Tags: Optional[TagList] + + +class ExportTransitGatewayRoutesRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + Filters: Optional[FilterList] + S3Bucket: String + DryRun: Optional[Boolean] + + +class ExportTransitGatewayRoutesResult(TypedDict, total=False): + S3Location: Optional[String] + + +class ExportVerifiedAccessInstanceClientConfigurationRequest(ServiceRequest): + VerifiedAccessInstanceId: VerifiedAccessInstanceId + DryRun: Optional[Boolean] + + +class VerifiedAccessInstanceOpenVpnClientConfigurationRoute(TypedDict, total=False): + Cidr: Optional[String] + + +VerifiedAccessInstanceOpenVpnClientConfigurationRouteList = List[ + VerifiedAccessInstanceOpenVpnClientConfigurationRoute +] + + +class VerifiedAccessInstanceOpenVpnClientConfiguration(TypedDict, total=False): + Config: Optional[String] + Routes: Optional[VerifiedAccessInstanceOpenVpnClientConfigurationRouteList] + + +VerifiedAccessInstanceOpenVpnClientConfigurationList = List[ + VerifiedAccessInstanceOpenVpnClientConfiguration +] + + +class VerifiedAccessInstanceUserTrustProviderClientConfiguration(TypedDict, total=False): + Type: Optional[UserTrustProviderType] + Scopes: Optional[String] + Issuer: Optional[String] + AuthorizationEndpoint: Optional[String] + PublicSigningKeyEndpoint: Optional[String] + TokenEndpoint: Optional[String] + UserInfoEndpoint: Optional[String] + ClientId: Optional[String] + ClientSecret: Optional[ClientSecretType] + PkceEnabled: Optional[Boolean] + + +class ExportVerifiedAccessInstanceClientConfigurationResult(TypedDict, total=False): + Version: Optional[String] + VerifiedAccessInstanceId: Optional[String] + Region: Optional[String] + DeviceTrustProviders: Optional[DeviceTrustProviderTypeList] + UserTrustProvider: Optional[VerifiedAccessInstanceUserTrustProviderClientConfiguration] + OpenVpnConfigurations: Optional[VerifiedAccessInstanceOpenVpnClientConfigurationList] + + +class GetActiveVpnTunnelStatusRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + VpnTunnelOutsideIpAddress: String + DryRun: Optional[Boolean] + + +class GetActiveVpnTunnelStatusResult(TypedDict, total=False): + ActiveVpnTunnelStatus: Optional[ActiveVpnTunnelStatus] + + +class GetAllowedImagesSettingsRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +ImageProviderList = List[ImageProvider] + + +class ImageCriterion(TypedDict, total=False): + ImageProviders: Optional[ImageProviderList] + + +ImageCriterionList = List[ImageCriterion] + + +class GetAllowedImagesSettingsResult(TypedDict, total=False): + State: Optional[String] + ImageCriteria: Optional[ImageCriterionList] + ManagedBy: Optional[ManagedBy] + + +class GetAssociatedEnclaveCertificateIamRolesRequest(ServiceRequest): + CertificateArn: CertificateId + DryRun: Optional[Boolean] + + +class GetAssociatedEnclaveCertificateIamRolesResult(TypedDict, total=False): + AssociatedRoles: Optional[AssociatedRolesList] + + +class GetAssociatedIpv6PoolCidrsRequest(ServiceRequest): + PoolId: Ipv6PoolEc2Id + NextToken: Optional[NextToken] + MaxResults: Optional[Ipv6PoolMaxResults] + DryRun: Optional[Boolean] + + +class Ipv6CidrAssociation(TypedDict, total=False): + Ipv6Cidr: Optional[String] + AssociatedResource: Optional[String] + + +Ipv6CidrAssociationSet = List[Ipv6CidrAssociation] + + +class GetAssociatedIpv6PoolCidrsResult(TypedDict, total=False): + Ipv6CidrAssociations: Optional[Ipv6CidrAssociationSet] + NextToken: Optional[String] + + +class GetAwsNetworkPerformanceDataRequest(ServiceRequest): + DataQueries: Optional[DataQueries] + StartTime: Optional[MillisecondDateTime] + EndTime: Optional[MillisecondDateTime] + MaxResults: Optional[Integer] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class GetAwsNetworkPerformanceDataResult(TypedDict, total=False): + DataResponses: Optional[DataResponses] + NextToken: Optional[String] + + +class GetCapacityReservationUsageRequest(ServiceRequest): + CapacityReservationId: CapacityReservationId + NextToken: Optional[String] + MaxResults: Optional[GetCapacityReservationUsageRequestMaxResults] + DryRun: Optional[Boolean] + + +class InstanceUsage(TypedDict, total=False): + AccountId: Optional[String] + UsedInstanceCount: Optional[Integer] + + +InstanceUsageSet = List[InstanceUsage] + + +class GetCapacityReservationUsageResult(TypedDict, total=False): + NextToken: Optional[String] + CapacityReservationId: Optional[String] + InstanceType: Optional[String] + TotalInstanceCount: Optional[Integer] + AvailableInstanceCount: Optional[Integer] + State: Optional[CapacityReservationState] + InstanceUsages: Optional[InstanceUsageSet] + + +class GetCoipPoolUsageRequest(ServiceRequest): + PoolId: Ipv4PoolCoipId + Filters: Optional[FilterList] + MaxResults: Optional[CoipPoolMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class GetCoipPoolUsageResult(TypedDict, total=False): + CoipPoolId: Optional[String] + CoipAddressUsages: Optional[CoipAddressUsageSet] + LocalGatewayRouteTableId: Optional[String] + NextToken: Optional[String] + + +class GetConsoleOutputRequest(ServiceRequest): + InstanceId: InstanceId + Latest: Optional[Boolean] + DryRun: Optional[Boolean] + + +class GetConsoleOutputResult(TypedDict, total=False): + InstanceId: Optional[String] + Timestamp: Optional[DateTime] + Output: Optional[String] + + +class GetConsoleScreenshotRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + WakeUp: Optional[Boolean] + + +class GetConsoleScreenshotResult(TypedDict, total=False): + ImageData: Optional[String] + InstanceId: Optional[String] + + +class GetDeclarativePoliciesReportSummaryRequest(ServiceRequest): + DryRun: Optional[Boolean] + ReportId: DeclarativePoliciesReportId + + +class GetDeclarativePoliciesReportSummaryResult(TypedDict, total=False): + ReportId: Optional[String] + S3Bucket: Optional[String] + S3Prefix: Optional[String] + TargetId: Optional[String] + StartTime: Optional[MillisecondDateTime] + EndTime: Optional[MillisecondDateTime] + NumberOfAccounts: Optional[Integer] + NumberOfFailedAccounts: Optional[Integer] + AttributeSummaries: Optional[AttributeSummaryList] + + +class GetDefaultCreditSpecificationRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceFamily: UnlimitedSupportedInstanceFamily + + +class InstanceFamilyCreditSpecification(TypedDict, total=False): + InstanceFamily: Optional[UnlimitedSupportedInstanceFamily] + CpuCredits: Optional[String] + + +class GetDefaultCreditSpecificationResult(TypedDict, total=False): + InstanceFamilyCreditSpecification: Optional[InstanceFamilyCreditSpecification] + + +class GetEbsDefaultKmsKeyIdRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class GetEbsDefaultKmsKeyIdResult(TypedDict, total=False): + KmsKeyId: Optional[String] + + +class GetEbsEncryptionByDefaultRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class GetEbsEncryptionByDefaultResult(TypedDict, total=False): + EbsEncryptionByDefault: Optional[Boolean] + SseType: Optional[SSEType] + + +class IntegrateServices(TypedDict, total=False): + AthenaIntegrations: Optional[AthenaIntegrationsSet] + + +class GetFlowLogsIntegrationTemplateRequest(ServiceRequest): + DryRun: Optional[Boolean] + FlowLogId: VpcFlowLogId + ConfigDeliveryS3DestinationArn: String + IntegrateServices: IntegrateServices + + +class GetFlowLogsIntegrationTemplateResult(TypedDict, total=False): + Result: Optional[String] + + +class GetGroupsForCapacityReservationRequest(ServiceRequest): + CapacityReservationId: CapacityReservationId + NextToken: Optional[String] + MaxResults: Optional[GetGroupsForCapacityReservationRequestMaxResults] + DryRun: Optional[Boolean] + + +class GetGroupsForCapacityReservationResult(TypedDict, total=False): + NextToken: Optional[String] + CapacityReservationGroups: Optional[CapacityReservationGroupSet] + + +RequestHostIdSet = List[DedicatedHostId] + + +class GetHostReservationPurchasePreviewRequest(ServiceRequest): + HostIdSet: RequestHostIdSet + OfferingId: OfferingId + + +class Purchase(TypedDict, total=False): + CurrencyCode: Optional[CurrencyCodeValues] + Duration: Optional[Integer] + HostIdSet: Optional[ResponseHostIdSet] + HostReservationId: Optional[HostReservationId] + HourlyPrice: Optional[String] + InstanceFamily: Optional[String] + PaymentOption: Optional[PaymentOption] + UpfrontPrice: Optional[String] + + +PurchaseSet = List[Purchase] + + +class GetHostReservationPurchasePreviewResult(TypedDict, total=False): + CurrencyCode: Optional[CurrencyCodeValues] + Purchase: Optional[PurchaseSet] + TotalHourlyPrice: Optional[String] + TotalUpfrontPrice: Optional[String] + + +class GetImageBlockPublicAccessStateRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class GetImageBlockPublicAccessStateResult(TypedDict, total=False): + ImageBlockPublicAccessState: Optional[String] + ManagedBy: Optional[ManagedBy] + + +class GetInstanceMetadataDefaultsRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class InstanceMetadataDefaultsResponse(TypedDict, total=False): + HttpTokens: Optional[HttpTokensState] + HttpPutResponseHopLimit: Optional[BoxedInteger] + HttpEndpoint: Optional[InstanceMetadataEndpointState] + InstanceMetadataTags: Optional[InstanceMetadataTagsState] + ManagedBy: Optional[ManagedBy] + ManagedExceptionMessage: Optional[String] + + +class GetInstanceMetadataDefaultsResult(TypedDict, total=False): + AccountLevel: Optional[InstanceMetadataDefaultsResponse] + + +class GetInstanceTpmEkPubRequest(ServiceRequest): + InstanceId: InstanceId + KeyType: EkPubKeyType + KeyFormat: EkPubKeyFormat + DryRun: Optional[Boolean] + + +class GetInstanceTpmEkPubResult(TypedDict, total=False): + InstanceId: Optional[InstanceId] + KeyType: Optional[EkPubKeyType] + KeyFormat: Optional[EkPubKeyFormat] + KeyValue: Optional[EkPubKeyValue] + + +VirtualizationTypeSet = List[VirtualizationType] + + +class GetInstanceTypesFromInstanceRequirementsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ArchitectureTypes: ArchitectureTypeSet + VirtualizationTypes: VirtualizationTypeSet + InstanceRequirements: InstanceRequirementsRequest + MaxResults: Optional[Integer] + NextToken: Optional[String] + Context: Optional[String] + + +class InstanceTypeInfoFromInstanceRequirements(TypedDict, total=False): + InstanceType: Optional[String] + + +InstanceTypeInfoFromInstanceRequirementsSet = List[InstanceTypeInfoFromInstanceRequirements] + + +class GetInstanceTypesFromInstanceRequirementsResult(TypedDict, total=False): + InstanceTypes: Optional[InstanceTypeInfoFromInstanceRequirementsSet] + NextToken: Optional[String] + + +class GetInstanceUefiDataRequest(ServiceRequest): + InstanceId: InstanceId + DryRun: Optional[Boolean] + + +class GetInstanceUefiDataResult(TypedDict, total=False): + InstanceId: Optional[InstanceId] + UefiData: Optional[String] + + +class GetIpamAddressHistoryRequest(ServiceRequest): + DryRun: Optional[Boolean] + Cidr: String + IpamScopeId: IpamScopeId + VpcId: Optional[String] + StartTime: Optional[MillisecondDateTime] + EndTime: Optional[MillisecondDateTime] + MaxResults: Optional[IpamAddressHistoryMaxResults] + NextToken: Optional[NextToken] + + +class IpamAddressHistoryRecord(TypedDict, total=False): + ResourceOwnerId: Optional[String] + ResourceRegion: Optional[String] + ResourceType: Optional[IpamAddressHistoryResourceType] + ResourceId: Optional[String] + ResourceCidr: Optional[String] + ResourceName: Optional[String] + ResourceComplianceStatus: Optional[IpamComplianceStatus] + ResourceOverlapStatus: Optional[IpamOverlapStatus] + VpcId: Optional[String] + SampledStartTime: Optional[MillisecondDateTime] + SampledEndTime: Optional[MillisecondDateTime] + + +IpamAddressHistoryRecordSet = List[IpamAddressHistoryRecord] + + +class GetIpamAddressHistoryResult(TypedDict, total=False): + HistoryRecords: Optional[IpamAddressHistoryRecordSet] + NextToken: Optional[NextToken] + + +class GetIpamDiscoveredAccountsRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryId: IpamResourceDiscoveryId + DiscoveryRegion: String + Filters: Optional[FilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[IpamMaxResults] + + +class IpamDiscoveryFailureReason(TypedDict, total=False): + Code: Optional[IpamDiscoveryFailureCode] + Message: Optional[String] + + +class IpamDiscoveredAccount(TypedDict, total=False): + AccountId: Optional[String] + DiscoveryRegion: Optional[String] + FailureReason: Optional[IpamDiscoveryFailureReason] + LastAttemptedDiscoveryTime: Optional[MillisecondDateTime] + LastSuccessfulDiscoveryTime: Optional[MillisecondDateTime] + OrganizationalUnitId: Optional[String] + + +IpamDiscoveredAccountSet = List[IpamDiscoveredAccount] + + +class GetIpamDiscoveredAccountsResult(TypedDict, total=False): + IpamDiscoveredAccounts: Optional[IpamDiscoveredAccountSet] + NextToken: Optional[NextToken] + + +class GetIpamDiscoveredPublicAddressesRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryId: IpamResourceDiscoveryId + AddressRegion: String + Filters: Optional[FilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[IpamMaxResults] + + +class IpamPublicAddressSecurityGroup(TypedDict, total=False): + GroupName: Optional[String] + GroupId: Optional[String] + + +IpamPublicAddressSecurityGroupList = List[IpamPublicAddressSecurityGroup] + + +class IpamPublicAddressTag(TypedDict, total=False): + Key: Optional[String] + Value: Optional[String] + + +IpamPublicAddressTagList = List[IpamPublicAddressTag] + + +class IpamPublicAddressTags(TypedDict, total=False): + EipTags: Optional[IpamPublicAddressTagList] + + +class IpamDiscoveredPublicAddress(TypedDict, total=False): + IpamResourceDiscoveryId: Optional[IpamResourceDiscoveryId] + AddressRegion: Optional[String] + Address: Optional[String] + AddressOwnerId: Optional[String] + AddressAllocationId: Optional[String] + AssociationStatus: Optional[IpamPublicAddressAssociationStatus] + AddressType: Optional[IpamPublicAddressType] + Service: Optional[IpamPublicAddressAwsService] + ServiceResource: Optional[String] + VpcId: Optional[String] + SubnetId: Optional[String] + PublicIpv4PoolId: Optional[String] + NetworkInterfaceId: Optional[String] + NetworkInterfaceDescription: Optional[String] + InstanceId: Optional[String] + Tags: Optional[IpamPublicAddressTags] + NetworkBorderGroup: Optional[String] + SecurityGroups: Optional[IpamPublicAddressSecurityGroupList] + SampleTime: Optional[MillisecondDateTime] + + +IpamDiscoveredPublicAddressSet = List[IpamDiscoveredPublicAddress] + + +class GetIpamDiscoveredPublicAddressesResult(TypedDict, total=False): + IpamDiscoveredPublicAddresses: Optional[IpamDiscoveredPublicAddressSet] + OldestSampleTime: Optional[MillisecondDateTime] + NextToken: Optional[NextToken] + + +class GetIpamDiscoveredResourceCidrsRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryId: IpamResourceDiscoveryId + ResourceRegion: String + Filters: Optional[FilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[IpamMaxResults] + + +class IpamDiscoveredResourceCidr(TypedDict, total=False): + IpamResourceDiscoveryId: Optional[IpamResourceDiscoveryId] + ResourceRegion: Optional[String] + ResourceId: Optional[String] + ResourceOwnerId: Optional[String] + ResourceCidr: Optional[String] + IpSource: Optional[IpamResourceCidrIpSource] + ResourceType: Optional[IpamResourceType] + ResourceTags: Optional[IpamResourceTagList] + IpUsage: Optional[BoxedDouble] + VpcId: Optional[String] + SubnetId: Optional[String] + NetworkInterfaceAttachmentStatus: Optional[IpamNetworkInterfaceAttachmentStatus] + SampleTime: Optional[MillisecondDateTime] + AvailabilityZoneId: Optional[String] + + +IpamDiscoveredResourceCidrSet = List[IpamDiscoveredResourceCidr] + + +class GetIpamDiscoveredResourceCidrsResult(TypedDict, total=False): + IpamDiscoveredResourceCidrs: Optional[IpamDiscoveredResourceCidrSet] + NextToken: Optional[NextToken] + + +class GetIpamPoolAllocationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + IpamPoolAllocationId: Optional[IpamPoolAllocationId] + Filters: Optional[FilterList] + MaxResults: Optional[GetIpamPoolAllocationsMaxResults] + NextToken: Optional[NextToken] + + +IpamPoolAllocationSet = List[IpamPoolAllocation] + + +class GetIpamPoolAllocationsResult(TypedDict, total=False): + IpamPoolAllocations: Optional[IpamPoolAllocationSet] + NextToken: Optional[NextToken] + + +class GetIpamPoolCidrsRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + Filters: Optional[FilterList] + MaxResults: Optional[IpamMaxResults] + NextToken: Optional[NextToken] + + +IpamPoolCidrSet = List[IpamPoolCidr] + + +class GetIpamPoolCidrsResult(TypedDict, total=False): + IpamPoolCidrs: Optional[IpamPoolCidrSet] + NextToken: Optional[NextToken] + + +class GetIpamResourceCidrsRequest(ServiceRequest): + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + MaxResults: Optional[IpamMaxResults] + NextToken: Optional[NextToken] + IpamScopeId: IpamScopeId + IpamPoolId: Optional[IpamPoolId] + ResourceId: Optional[String] + ResourceType: Optional[IpamResourceType] + ResourceTag: Optional[RequestIpamResourceTag] + ResourceOwner: Optional[String] + + +class IpamResourceCidr(TypedDict, total=False): + IpamId: Optional[IpamId] + IpamScopeId: Optional[IpamScopeId] + IpamPoolId: Optional[IpamPoolId] + ResourceRegion: Optional[String] + ResourceOwnerId: Optional[String] + ResourceId: Optional[String] + ResourceName: Optional[String] + ResourceCidr: Optional[String] + ResourceType: Optional[IpamResourceType] + ResourceTags: Optional[IpamResourceTagList] + IpUsage: Optional[BoxedDouble] + ComplianceStatus: Optional[IpamComplianceStatus] + ManagementState: Optional[IpamManagementState] + OverlapStatus: Optional[IpamOverlapStatus] + VpcId: Optional[String] + AvailabilityZoneId: Optional[String] + + +IpamResourceCidrSet = List[IpamResourceCidr] + + +class GetIpamResourceCidrsResult(TypedDict, total=False): + NextToken: Optional[NextToken] + IpamResourceCidrs: Optional[IpamResourceCidrSet] + + +class GetLaunchTemplateDataRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + + +class GetLaunchTemplateDataResult(TypedDict, total=False): + LaunchTemplateData: Optional[ResponseLaunchTemplateData] + + +class GetManagedPrefixListAssociationsRequest(ServiceRequest): + DryRun: Optional[Boolean] + PrefixListId: PrefixListResourceId + MaxResults: Optional[GetManagedPrefixListAssociationsMaxResults] + NextToken: Optional[NextToken] + + +class PrefixListAssociation(TypedDict, total=False): + ResourceId: Optional[String] + ResourceOwner: Optional[String] + + +PrefixListAssociationSet = List[PrefixListAssociation] + + +class GetManagedPrefixListAssociationsResult(TypedDict, total=False): + PrefixListAssociations: Optional[PrefixListAssociationSet] + NextToken: Optional[String] + + +class GetManagedPrefixListEntriesRequest(ServiceRequest): + DryRun: Optional[Boolean] + PrefixListId: PrefixListResourceId + TargetVersion: Optional[Long] + MaxResults: Optional[PrefixListMaxResults] + NextToken: Optional[NextToken] + + +class PrefixListEntry(TypedDict, total=False): + Cidr: Optional[String] + Description: Optional[String] + + +PrefixListEntrySet = List[PrefixListEntry] + + +class GetManagedPrefixListEntriesResult(TypedDict, total=False): + Entries: Optional[PrefixListEntrySet] + NextToken: Optional[NextToken] + + +class GetNetworkInsightsAccessScopeAnalysisFindingsRequest(ServiceRequest): + NetworkInsightsAccessScopeAnalysisId: NetworkInsightsAccessScopeAnalysisId + MaxResults: Optional[GetNetworkInsightsAccessScopeAnalysisFindingsMaxResults] + NextToken: Optional[NextToken] + DryRun: Optional[Boolean] + + +class GetNetworkInsightsAccessScopeAnalysisFindingsResult(TypedDict, total=False): + NetworkInsightsAccessScopeAnalysisId: Optional[NetworkInsightsAccessScopeAnalysisId] + AnalysisStatus: Optional[AnalysisStatus] + AnalysisFindings: Optional[AccessScopeAnalysisFindingList] + NextToken: Optional[String] + + +class GetNetworkInsightsAccessScopeContentRequest(ServiceRequest): + NetworkInsightsAccessScopeId: NetworkInsightsAccessScopeId + DryRun: Optional[Boolean] + + +class GetNetworkInsightsAccessScopeContentResult(TypedDict, total=False): + NetworkInsightsAccessScopeContent: Optional[NetworkInsightsAccessScopeContent] + + +class GetPasswordDataRequest(ServiceRequest): + InstanceId: InstanceId + DryRun: Optional[Boolean] + + +class GetPasswordDataResult(TypedDict, total=False): + InstanceId: Optional[String] + Timestamp: Optional[DateTime] + PasswordData: Optional[PasswordData] + + +class GetReservedInstancesExchangeQuoteRequest(ServiceRequest): + DryRun: Optional[Boolean] + ReservedInstanceIds: ReservedInstanceIdSet + TargetConfigurations: Optional[TargetConfigurationRequestSet] + + +class TargetConfiguration(TypedDict, total=False): + InstanceCount: Optional[Integer] + OfferingId: Optional[String] + + +class ReservationValue(TypedDict, total=False): + HourlyPrice: Optional[String] + RemainingTotalValue: Optional[String] + RemainingUpfrontValue: Optional[String] + + +class TargetReservationValue(TypedDict, total=False): + ReservationValue: Optional[ReservationValue] + TargetConfiguration: Optional[TargetConfiguration] + + +TargetReservationValueSet = List[TargetReservationValue] + + +class ReservedInstanceReservationValue(TypedDict, total=False): + ReservationValue: Optional[ReservationValue] + ReservedInstanceId: Optional[String] + + +ReservedInstanceReservationValueSet = List[ReservedInstanceReservationValue] + + +class GetReservedInstancesExchangeQuoteResult(TypedDict, total=False): + CurrencyCode: Optional[String] + IsValidExchange: Optional[Boolean] + OutputReservedInstancesWillExpireAt: Optional[DateTime] + PaymentDue: Optional[String] + ReservedInstanceValueRollup: Optional[ReservationValue] + ReservedInstanceValueSet: Optional[ReservedInstanceReservationValueSet] + TargetConfigurationValueRollup: Optional[ReservationValue] + TargetConfigurationValueSet: Optional[TargetReservationValueSet] + ValidationFailureReason: Optional[String] + + +class GetRouteServerAssociationsRequest(ServiceRequest): + RouteServerId: RouteServerId + DryRun: Optional[Boolean] + + +RouteServerAssociationsList = List[RouteServerAssociation] + + +class GetRouteServerAssociationsResult(TypedDict, total=False): + RouteServerAssociations: Optional[RouteServerAssociationsList] + + +class GetRouteServerPropagationsRequest(ServiceRequest): + RouteServerId: RouteServerId + RouteTableId: Optional[RouteTableId] + DryRun: Optional[Boolean] + + +RouteServerPropagationsList = List[RouteServerPropagation] + + +class GetRouteServerPropagationsResult(TypedDict, total=False): + RouteServerPropagations: Optional[RouteServerPropagationsList] + + +class GetRouteServerRoutingDatabaseRequest(ServiceRequest): + RouteServerId: RouteServerId + NextToken: Optional[String] + MaxResults: Optional[RouteServerMaxResults] + DryRun: Optional[Boolean] + Filters: Optional[FilterList] + + +class RouteServerRouteInstallationDetail(TypedDict, total=False): + RouteTableId: Optional[RouteTableId] + RouteInstallationStatus: Optional[RouteServerRouteInstallationStatus] + RouteInstallationStatusReason: Optional[String] + + +RouteServerRouteInstallationDetails = List[RouteServerRouteInstallationDetail] + + +class RouteServerRoute(TypedDict, total=False): + RouteServerEndpointId: Optional[RouteServerEndpointId] + RouteServerPeerId: Optional[RouteServerPeerId] + RouteInstallationDetails: Optional[RouteServerRouteInstallationDetails] + RouteStatus: Optional[RouteServerRouteStatus] + Prefix: Optional[String] + AsPaths: Optional[AsPath] + Med: Optional[Integer] + NextHopIp: Optional[String] + + +RouteServerRouteList = List[RouteServerRoute] + + +class GetRouteServerRoutingDatabaseResult(TypedDict, total=False): + AreRoutesPersisted: Optional[Boolean] + Routes: Optional[RouteServerRouteList] + NextToken: Optional[String] + + +class GetSecurityGroupsForVpcRequest(ServiceRequest): + VpcId: VpcId + NextToken: Optional[String] + MaxResults: Optional[GetSecurityGroupsForVpcRequestMaxResults] + Filters: Optional[FilterList] + DryRun: Optional[Boolean] + + +class SecurityGroupForVpc(TypedDict, total=False): + Description: Optional[String] + GroupName: Optional[String] + OwnerId: Optional[String] + GroupId: Optional[String] + Tags: Optional[TagList] + PrimaryVpcId: Optional[String] + + +SecurityGroupForVpcList = List[SecurityGroupForVpc] + + +class GetSecurityGroupsForVpcResult(TypedDict, total=False): + NextToken: Optional[String] + SecurityGroupForVpcs: Optional[SecurityGroupForVpcList] + + +class GetSerialConsoleAccessStatusRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class GetSerialConsoleAccessStatusResult(TypedDict, total=False): + SerialConsoleAccessEnabled: Optional[Boolean] + ManagedBy: Optional[ManagedBy] + + +class GetSnapshotBlockPublicAccessStateRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class GetSnapshotBlockPublicAccessStateResult(TypedDict, total=False): + State: Optional[SnapshotBlockPublicAccessState] + ManagedBy: Optional[ManagedBy] + + +class InstanceRequirementsWithMetadataRequest(TypedDict, total=False): + ArchitectureTypes: Optional[ArchitectureTypeSet] + VirtualizationTypes: Optional[VirtualizationTypeSet] + InstanceRequirements: Optional[InstanceRequirementsRequest] + + +RegionNames = List[String] +InstanceTypes = List[String] + + +class GetSpotPlacementScoresRequest(ServiceRequest): + InstanceTypes: Optional[InstanceTypes] + TargetCapacity: SpotPlacementScoresTargetCapacity + TargetCapacityUnitType: Optional[TargetCapacityUnitType] + SingleAvailabilityZone: Optional[Boolean] + RegionNames: Optional[RegionNames] + InstanceRequirementsWithMetadata: Optional[InstanceRequirementsWithMetadataRequest] + DryRun: Optional[Boolean] + MaxResults: Optional[SpotPlacementScoresMaxResults] + NextToken: Optional[String] + + +class SpotPlacementScore(TypedDict, total=False): + Region: Optional[String] + AvailabilityZoneId: Optional[String] + Score: Optional[Integer] + + +SpotPlacementScores = List[SpotPlacementScore] + + +class GetSpotPlacementScoresResult(TypedDict, total=False): + SpotPlacementScores: Optional[SpotPlacementScores] + NextToken: Optional[String] + + +class GetSubnetCidrReservationsRequest(ServiceRequest): + Filters: Optional[FilterList] + SubnetId: SubnetId + DryRun: Optional[Boolean] + NextToken: Optional[String] + MaxResults: Optional[GetSubnetCidrReservationsMaxResults] + + +SubnetCidrReservationList = List[SubnetCidrReservation] + + +class GetSubnetCidrReservationsResult(TypedDict, total=False): + SubnetIpv4CidrReservations: Optional[SubnetCidrReservationList] + SubnetIpv6CidrReservations: Optional[SubnetCidrReservationList] + NextToken: Optional[String] + + +class GetTransitGatewayAttachmentPropagationsRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class TransitGatewayAttachmentPropagation(TypedDict, total=False): + TransitGatewayRouteTableId: Optional[String] + State: Optional[TransitGatewayPropagationState] + + +TransitGatewayAttachmentPropagationList = List[TransitGatewayAttachmentPropagation] + + +class GetTransitGatewayAttachmentPropagationsResult(TypedDict, total=False): + TransitGatewayAttachmentPropagations: Optional[TransitGatewayAttachmentPropagationList] + NextToken: Optional[String] + + +class GetTransitGatewayMulticastDomainAssociationsRequest(ServiceRequest): + TransitGatewayMulticastDomainId: TransitGatewayMulticastDomainId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class TransitGatewayMulticastDomainAssociation(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + ResourceOwnerId: Optional[String] + Subnet: Optional[SubnetAssociation] + + +TransitGatewayMulticastDomainAssociationList = List[TransitGatewayMulticastDomainAssociation] + + +class GetTransitGatewayMulticastDomainAssociationsResult(TypedDict, total=False): + MulticastDomainAssociations: Optional[TransitGatewayMulticastDomainAssociationList] + NextToken: Optional[String] + + +class GetTransitGatewayPolicyTableAssociationsRequest(ServiceRequest): + TransitGatewayPolicyTableId: TransitGatewayPolicyTableId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayPolicyTableAssociationList = List[TransitGatewayPolicyTableAssociation] + + +class GetTransitGatewayPolicyTableAssociationsResult(TypedDict, total=False): + Associations: Optional[TransitGatewayPolicyTableAssociationList] + NextToken: Optional[String] + + +class GetTransitGatewayPolicyTableEntriesRequest(ServiceRequest): + TransitGatewayPolicyTableId: TransitGatewayPolicyTableId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class TransitGatewayPolicyRuleMetaData(TypedDict, total=False): + MetaDataKey: Optional[String] + MetaDataValue: Optional[String] + + +class TransitGatewayPolicyRule(TypedDict, total=False): + SourceCidrBlock: Optional[String] + SourcePortRange: Optional[String] + DestinationCidrBlock: Optional[String] + DestinationPortRange: Optional[String] + Protocol: Optional[String] + MetaData: Optional[TransitGatewayPolicyRuleMetaData] + + +class TransitGatewayPolicyTableEntry(TypedDict, total=False): + PolicyRuleNumber: Optional[String] + PolicyRule: Optional[TransitGatewayPolicyRule] + TargetRouteTableId: Optional[TransitGatewayRouteTableId] + + +TransitGatewayPolicyTableEntryList = List[TransitGatewayPolicyTableEntry] + + +class GetTransitGatewayPolicyTableEntriesResult(TypedDict, total=False): + TransitGatewayPolicyTableEntries: Optional[TransitGatewayPolicyTableEntryList] + + +class GetTransitGatewayPrefixListReferencesRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +TransitGatewayPrefixListReferenceSet = List[TransitGatewayPrefixListReference] + + +class GetTransitGatewayPrefixListReferencesResult(TypedDict, total=False): + TransitGatewayPrefixListReferences: Optional[TransitGatewayPrefixListReferenceSet] + NextToken: Optional[String] + + +class GetTransitGatewayRouteTableAssociationsRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class TransitGatewayRouteTableAssociation(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + State: Optional[TransitGatewayAssociationState] + + +TransitGatewayRouteTableAssociationList = List[TransitGatewayRouteTableAssociation] + + +class GetTransitGatewayRouteTableAssociationsResult(TypedDict, total=False): + Associations: Optional[TransitGatewayRouteTableAssociationList] + NextToken: Optional[String] + + +class GetTransitGatewayRouteTablePropagationsRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class TransitGatewayRouteTablePropagation(TypedDict, total=False): + TransitGatewayAttachmentId: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + State: Optional[TransitGatewayPropagationState] + TransitGatewayRouteTableAnnouncementId: Optional[TransitGatewayRouteTableAnnouncementId] + + +TransitGatewayRouteTablePropagationList = List[TransitGatewayRouteTablePropagation] + + +class GetTransitGatewayRouteTablePropagationsResult(TypedDict, total=False): + TransitGatewayRouteTablePropagations: Optional[TransitGatewayRouteTablePropagationList] + NextToken: Optional[String] + + +class GetVerifiedAccessEndpointPolicyRequest(ServiceRequest): + VerifiedAccessEndpointId: VerifiedAccessEndpointId + DryRun: Optional[Boolean] + + +class GetVerifiedAccessEndpointPolicyResult(TypedDict, total=False): + PolicyEnabled: Optional[Boolean] + PolicyDocument: Optional[String] + + +class GetVerifiedAccessEndpointTargetsRequest(ServiceRequest): + VerifiedAccessEndpointId: VerifiedAccessEndpointId + MaxResults: Optional[GetVerifiedAccessEndpointTargetsMaxResults] + NextToken: Optional[NextToken] + DryRun: Optional[Boolean] + + +class VerifiedAccessEndpointTarget(TypedDict, total=False): + VerifiedAccessEndpointId: Optional[VerifiedAccessEndpointId] + VerifiedAccessEndpointTargetIpAddress: Optional[String] + VerifiedAccessEndpointTargetDns: Optional[String] + + +VerifiedAccessEndpointTargetList = List[VerifiedAccessEndpointTarget] + + +class GetVerifiedAccessEndpointTargetsResult(TypedDict, total=False): + VerifiedAccessEndpointTargets: Optional[VerifiedAccessEndpointTargetList] + NextToken: Optional[NextToken] + + +class GetVerifiedAccessGroupPolicyRequest(ServiceRequest): + VerifiedAccessGroupId: VerifiedAccessGroupId + DryRun: Optional[Boolean] + + +class GetVerifiedAccessGroupPolicyResult(TypedDict, total=False): + PolicyEnabled: Optional[Boolean] + PolicyDocument: Optional[String] + + +class GetVpnConnectionDeviceSampleConfigurationRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + VpnConnectionDeviceTypeId: VpnConnectionDeviceTypeId + InternetKeyExchangeVersion: Optional[String] + SampleType: Optional[String] + DryRun: Optional[Boolean] + + +class GetVpnConnectionDeviceSampleConfigurationResult(TypedDict, total=False): + VpnConnectionDeviceSampleConfiguration: Optional[VpnConnectionDeviceSampleConfiguration] + + +class GetVpnConnectionDeviceTypesRequest(ServiceRequest): + MaxResults: Optional[GVCDMaxResults] + NextToken: Optional[NextToken] + DryRun: Optional[Boolean] + + +class VpnConnectionDeviceType(TypedDict, total=False): + VpnConnectionDeviceTypeId: Optional[String] + Vendor: Optional[String] + Platform: Optional[String] + Software: Optional[String] + + +VpnConnectionDeviceTypeList = List[VpnConnectionDeviceType] + + +class GetVpnConnectionDeviceTypesResult(TypedDict, total=False): + VpnConnectionDeviceTypes: Optional[VpnConnectionDeviceTypeList] + NextToken: Optional[NextToken] + + +class GetVpnTunnelReplacementStatusRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + VpnTunnelOutsideIpAddress: String + DryRun: Optional[Boolean] + + +class MaintenanceDetails(TypedDict, total=False): + PendingMaintenance: Optional[String] + MaintenanceAutoAppliedAfter: Optional[MillisecondDateTime] + LastMaintenanceApplied: Optional[MillisecondDateTime] + + +class GetVpnTunnelReplacementStatusResult(TypedDict, total=False): + VpnConnectionId: Optional[VpnConnectionId] + TransitGatewayId: Optional[TransitGatewayId] + CustomerGatewayId: Optional[CustomerGatewayId] + VpnGatewayId: Optional[VpnGatewayId] + VpnTunnelOutsideIpAddress: Optional[String] + MaintenanceDetails: Optional[MaintenanceDetails] + + +class HibernationOptionsRequest(TypedDict, total=False): + Configured: Optional[Boolean] + + +class LaunchPermission(TypedDict, total=False): + OrganizationArn: Optional[String] + OrganizationalUnitArn: Optional[String] + UserId: Optional[String] + Group: Optional[PermissionGroup] + + +LaunchPermissionList = List[LaunchPermission] + + +class ImageAttribute(TypedDict, total=False): + Description: Optional[AttributeValue] + KernelId: Optional[AttributeValue] + RamdiskId: Optional[AttributeValue] + SriovNetSupport: Optional[AttributeValue] + BootMode: Optional[AttributeValue] + TpmSupport: Optional[AttributeValue] + UefiData: Optional[AttributeValue] + LastLaunchedTime: Optional[AttributeValue] + ImdsSupport: Optional[AttributeValue] + DeregistrationProtection: Optional[AttributeValue] + ImageId: Optional[String] + LaunchPermissions: Optional[LaunchPermissionList] + ProductCodes: Optional[ProductCodeList] + BlockDeviceMappings: Optional[BlockDeviceMappingList] + + +ImageProviderRequestList = List[ImageProviderRequest] + + +class ImageCriterionRequest(TypedDict, total=False): + ImageProviders: Optional[ImageProviderRequestList] + + +ImageCriterionRequestList = List[ImageCriterionRequest] + + +class UserBucket(TypedDict, total=False): + S3Bucket: Optional[String] + S3Key: Optional[String] + + +class ImageDiskContainer(TypedDict, total=False): + Description: Optional[String] + DeviceName: Optional[String] + Format: Optional[String] + SnapshotId: Optional[SnapshotId] + Url: Optional[SensitiveUrl] + UserBucket: Optional[UserBucket] + + +ImageDiskContainerList = List[ImageDiskContainer] + + +class ImageRecycleBinInfo(TypedDict, total=False): + ImageId: Optional[String] + Name: Optional[String] + Description: Optional[String] + RecycleBinEnterTime: Optional[MillisecondDateTime] + RecycleBinExitTime: Optional[MillisecondDateTime] + + +ImageRecycleBinInfoList = List[ImageRecycleBinInfo] + + +class ImportClientVpnClientCertificateRevocationListRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + CertificateRevocationList: String + DryRun: Optional[Boolean] + + +class ImportClientVpnClientCertificateRevocationListResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ImportImageLicenseConfigurationRequest(TypedDict, total=False): + LicenseConfigurationArn: Optional[String] + + +ImportImageLicenseSpecificationListRequest = List[ImportImageLicenseConfigurationRequest] + + +class ImportImageRequest(ServiceRequest): + Architecture: Optional[String] + ClientData: Optional[ClientData] + ClientToken: Optional[String] + Description: Optional[String] + DiskContainers: Optional[ImageDiskContainerList] + DryRun: Optional[Boolean] + Encrypted: Optional[Boolean] + Hypervisor: Optional[String] + KmsKeyId: Optional[KmsKeyId] + LicenseType: Optional[String] + Platform: Optional[String] + RoleName: Optional[String] + LicenseSpecifications: Optional[ImportImageLicenseSpecificationListRequest] + TagSpecifications: Optional[TagSpecificationList] + UsageOperation: Optional[String] + BootMode: Optional[BootModeValues] + + +class ImportImageResult(TypedDict, total=False): + Architecture: Optional[String] + Description: Optional[String] + Encrypted: Optional[Boolean] + Hypervisor: Optional[String] + ImageId: Optional[String] + ImportTaskId: Optional[ImportImageTaskId] + KmsKeyId: Optional[KmsKeyId] + LicenseType: Optional[String] + Platform: Optional[String] + Progress: Optional[String] + SnapshotDetails: Optional[SnapshotDetailList] + Status: Optional[String] + StatusMessage: Optional[String] + LicenseSpecifications: Optional[ImportImageLicenseSpecificationListResponse] + Tags: Optional[TagList] + UsageOperation: Optional[String] + + +class UserData(TypedDict, total=False): + Data: Optional[String] + + +class ImportInstanceLaunchSpecification(TypedDict, total=False): + Architecture: Optional[ArchitectureValues] + GroupNames: Optional[SecurityGroupStringList] + GroupIds: Optional[SecurityGroupIdStringList] + AdditionalInfo: Optional[String] + UserData: Optional[UserData] + InstanceType: Optional[InstanceType] + Placement: Optional[Placement] + Monitoring: Optional[Boolean] + SubnetId: Optional[SubnetId] + InstanceInitiatedShutdownBehavior: Optional[ShutdownBehavior] + PrivateIpAddress: Optional[String] + + +class ImportInstanceRequest(ServiceRequest): + DryRun: Optional[Boolean] + Description: Optional[String] + LaunchSpecification: Optional[ImportInstanceLaunchSpecification] + DiskImages: Optional[DiskImageList] + Platform: PlatformValues + + +class ImportInstanceResult(TypedDict, total=False): + ConversionTask: Optional[ConversionTask] + + +class ImportKeyPairRequest(ServiceRequest): + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + KeyName: String + PublicKeyMaterial: Blob + + +class ImportKeyPairResult(TypedDict, total=False): + KeyFingerprint: Optional[String] + KeyName: Optional[String] + KeyPairId: Optional[String] + Tags: Optional[TagList] + + +class SnapshotDiskContainer(TypedDict, total=False): + Description: Optional[String] + Format: Optional[String] + Url: Optional[SensitiveUrl] + UserBucket: Optional[UserBucket] + + +class ImportSnapshotRequest(ServiceRequest): + ClientData: Optional[ClientData] + ClientToken: Optional[String] + Description: Optional[String] + DiskContainer: Optional[SnapshotDiskContainer] + DryRun: Optional[Boolean] + Encrypted: Optional[Boolean] + KmsKeyId: Optional[KmsKeyId] + RoleName: Optional[String] + TagSpecifications: Optional[TagSpecificationList] + + +class ImportSnapshotResult(TypedDict, total=False): + Description: Optional[String] + ImportTaskId: Optional[String] + SnapshotTaskDetail: Optional[SnapshotTaskDetail] + Tags: Optional[TagList] + + +class ImportVolumeRequest(ServiceRequest): + DryRun: Optional[Boolean] + AvailabilityZone: String + Image: DiskImageDetail + Description: Optional[String] + Volume: VolumeDetail + + +class ImportVolumeResult(TypedDict, total=False): + ConversionTask: Optional[ConversionTask] + + +class InstanceAttribute(TypedDict, total=False): + BlockDeviceMappings: Optional[InstanceBlockDeviceMappingList] + DisableApiTermination: Optional[AttributeBooleanValue] + EnaSupport: Optional[AttributeBooleanValue] + EnclaveOptions: Optional[EnclaveOptions] + EbsOptimized: Optional[AttributeBooleanValue] + InstanceId: Optional[String] + InstanceInitiatedShutdownBehavior: Optional[AttributeValue] + InstanceType: Optional[AttributeValue] + KernelId: Optional[AttributeValue] + ProductCodes: Optional[ProductCodeList] + RamdiskId: Optional[AttributeValue] + RootDeviceName: Optional[AttributeValue] + SourceDestCheck: Optional[AttributeBooleanValue] + SriovNetSupport: Optional[AttributeValue] + UserData: Optional[AttributeValue] + DisableApiStop: Optional[AttributeBooleanValue] + Groups: Optional[GroupIdentifierList] + + +class InstanceBlockDeviceMappingSpecification(TypedDict, total=False): + DeviceName: Optional[String] + Ebs: Optional[EbsInstanceBlockDeviceSpecification] + VirtualName: Optional[String] + NoDevice: Optional[String] + + +InstanceBlockDeviceMappingSpecificationList = List[InstanceBlockDeviceMappingSpecification] + + +class InstanceCreditSpecificationRequest(TypedDict, total=False): + InstanceId: InstanceId + CpuCredits: Optional[String] + + +InstanceCreditSpecificationListRequest = List[InstanceCreditSpecificationRequest] +InstanceIdSet = List[InstanceId] + + +class InstanceMaintenanceOptionsRequest(TypedDict, total=False): + AutoRecovery: Optional[InstanceAutoRecoveryState] + + +class SpotMarketOptions(TypedDict, total=False): + MaxPrice: Optional[String] + SpotInstanceType: Optional[SpotInstanceType] + BlockDurationMinutes: Optional[Integer] + ValidUntil: Optional[DateTime] + InstanceInterruptionBehavior: Optional[InstanceInterruptionBehavior] + + +class InstanceMarketOptionsRequest(TypedDict, total=False): + MarketType: Optional[MarketType] + SpotOptions: Optional[SpotMarketOptions] + + +class InstanceMetadataOptionsRequest(TypedDict, total=False): + HttpTokens: Optional[HttpTokensState] + HttpPutResponseHopLimit: Optional[Integer] + HttpEndpoint: Optional[InstanceMetadataEndpointState] + HttpProtocolIpv6: Optional[InstanceMetadataProtocolState] + InstanceMetadataTags: Optional[InstanceMetadataTagsState] + + +class InstanceMonitoring(TypedDict, total=False): + InstanceId: Optional[String] + Monitoring: Optional[Monitoring] + + +InstanceMonitoringList = List[InstanceMonitoring] + + +class InstanceNetworkPerformanceOptionsRequest(TypedDict, total=False): + BandwidthWeighting: Optional[InstanceBandwidthWeighting] + + +class InstanceStateChange(TypedDict, total=False): + InstanceId: Optional[String] + CurrentState: Optional[InstanceState] + PreviousState: Optional[InstanceState] + + +InstanceStateChangeList = List[InstanceStateChange] + + +class IpamCidrAuthorizationContext(TypedDict, total=False): + Message: Optional[String] + Signature: Optional[String] + + +class KeyPair(TypedDict, total=False): + KeyPairId: Optional[String] + Tags: Optional[TagList] + KeyName: Optional[String] + KeyFingerprint: Optional[String] + KeyMaterial: Optional[SensitiveUserData] + + +class LaunchPermissionModifications(TypedDict, total=False): + Add: Optional[LaunchPermissionList] + Remove: Optional[LaunchPermissionList] + + +class LaunchTemplateSpecification(TypedDict, total=False): + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[String] + Version: Optional[String] + + +class LicenseConfigurationRequest(TypedDict, total=False): + LicenseConfigurationArn: Optional[String] + + +LicenseSpecificationListRequest = List[LicenseConfigurationRequest] + + +class ListImagesInRecycleBinRequest(ServiceRequest): + ImageIds: Optional[ImageIdStringList] + NextToken: Optional[String] + MaxResults: Optional[ListImagesInRecycleBinMaxResults] + DryRun: Optional[Boolean] + + +class ListImagesInRecycleBinResult(TypedDict, total=False): + Images: Optional[ImageRecycleBinInfoList] + NextToken: Optional[String] + + +class ListSnapshotsInRecycleBinRequest(ServiceRequest): + MaxResults: Optional[ListSnapshotsInRecycleBinMaxResults] + NextToken: Optional[String] + SnapshotIds: Optional[SnapshotIdStringList] + DryRun: Optional[Boolean] + + +class SnapshotRecycleBinInfo(TypedDict, total=False): + SnapshotId: Optional[String] + RecycleBinEnterTime: Optional[MillisecondDateTime] + RecycleBinExitTime: Optional[MillisecondDateTime] + Description: Optional[String] + VolumeId: Optional[String] + + +SnapshotRecycleBinInfoList = List[SnapshotRecycleBinInfo] + + +class ListSnapshotsInRecycleBinResult(TypedDict, total=False): + Snapshots: Optional[SnapshotRecycleBinInfoList] + NextToken: Optional[String] + + +class LoadPermissionRequest(TypedDict, total=False): + Group: Optional[PermissionGroup] + UserId: Optional[String] + + +LoadPermissionListRequest = List[LoadPermissionRequest] + + +class LoadPermissionModifications(TypedDict, total=False): + Add: Optional[LoadPermissionListRequest] + Remove: Optional[LoadPermissionListRequest] + + +LocalGatewayRouteList = List[LocalGatewayRoute] + + +class LockSnapshotRequest(ServiceRequest): + SnapshotId: SnapshotId + DryRun: Optional[Boolean] + LockMode: LockMode + CoolOffPeriod: Optional[CoolOffPeriodRequestHours] + LockDuration: Optional[RetentionPeriodRequestDays] + ExpirationDate: Optional[MillisecondDateTime] + + +class LockSnapshotResult(TypedDict, total=False): + SnapshotId: Optional[String] + LockState: Optional[LockState] + LockDuration: Optional[RetentionPeriodResponseDays] + CoolOffPeriod: Optional[CoolOffPeriodResponseHours] + CoolOffPeriodExpiresOn: Optional[MillisecondDateTime] + LockCreatedOn: Optional[MillisecondDateTime] + LockExpiresOn: Optional[MillisecondDateTime] + LockDurationStartTime: Optional[MillisecondDateTime] + + +class ModifyAddressAttributeRequest(ServiceRequest): + AllocationId: AllocationId + DomainName: Optional[String] + DryRun: Optional[Boolean] + + +class ModifyAddressAttributeResult(TypedDict, total=False): + Address: Optional[AddressAttribute] + + +class ModifyAvailabilityZoneGroupRequest(ServiceRequest): + GroupName: String + OptInStatus: ModifyAvailabilityZoneOptInStatus + DryRun: Optional[Boolean] + + +class ModifyAvailabilityZoneGroupResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyCapacityReservationFleetRequest(ServiceRequest): + CapacityReservationFleetId: CapacityReservationFleetId + TotalTargetCapacity: Optional[Integer] + EndDate: Optional[MillisecondDateTime] + DryRun: Optional[Boolean] + RemoveEndDate: Optional[Boolean] + + +class ModifyCapacityReservationFleetResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyCapacityReservationRequest(ServiceRequest): + CapacityReservationId: CapacityReservationId + InstanceCount: Optional[Integer] + EndDate: Optional[DateTime] + EndDateType: Optional[EndDateType] + Accept: Optional[Boolean] + DryRun: Optional[Boolean] + AdditionalInfo: Optional[String] + InstanceMatchCriteria: Optional[InstanceMatchCriteria] + + +class ModifyCapacityReservationResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyClientVpnEndpointRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + ServerCertificateArn: Optional[String] + ConnectionLogOptions: Optional[ConnectionLogOptions] + DnsServers: Optional[DnsServersOptionsModifyStructure] + VpnPort: Optional[Integer] + Description: Optional[String] + SplitTunnel: Optional[Boolean] + DryRun: Optional[Boolean] + SecurityGroupIds: Optional[ClientVpnSecurityGroupIdSet] + VpcId: Optional[VpcId] + SelfServicePortal: Optional[SelfServicePortal] + ClientConnectOptions: Optional[ClientConnectOptions] + SessionTimeoutHours: Optional[Integer] + ClientLoginBannerOptions: Optional[ClientLoginBannerOptions] + ClientRouteEnforcementOptions: Optional[ClientRouteEnforcementOptions] + DisconnectOnSessionTimeout: Optional[Boolean] + + +class ModifyClientVpnEndpointResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyDefaultCreditSpecificationRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceFamily: UnlimitedSupportedInstanceFamily + CpuCredits: String + + +class ModifyDefaultCreditSpecificationResult(TypedDict, total=False): + InstanceFamilyCreditSpecification: Optional[InstanceFamilyCreditSpecification] + + +class ModifyEbsDefaultKmsKeyIdRequest(ServiceRequest): + KmsKeyId: KmsKeyId + DryRun: Optional[Boolean] + + +class ModifyEbsDefaultKmsKeyIdResult(TypedDict, total=False): + KmsKeyId: Optional[String] + + +class ModifyFleetRequest(ServiceRequest): + DryRun: Optional[Boolean] + ExcessCapacityTerminationPolicy: Optional[FleetExcessCapacityTerminationPolicy] + LaunchTemplateConfigs: Optional[FleetLaunchTemplateConfigListRequest] + FleetId: FleetId + TargetCapacitySpecification: Optional[TargetCapacitySpecificationRequest] + Context: Optional[String] + + +class ModifyFleetResult(TypedDict, total=False): + Return: Optional[Boolean] + + +ProductCodeStringList = List[String] +UserGroupStringList = List[String] +UserIdStringList = List[String] + + +class ModifyFpgaImageAttributeRequest(ServiceRequest): + DryRun: Optional[Boolean] + FpgaImageId: FpgaImageId + Attribute: Optional[FpgaImageAttributeName] + OperationType: Optional[OperationType] + UserIds: Optional[UserIdStringList] + UserGroups: Optional[UserGroupStringList] + ProductCodes: Optional[ProductCodeStringList] + LoadPermission: Optional[LoadPermissionModifications] + Description: Optional[String] + Name: Optional[String] + + +class ModifyFpgaImageAttributeResult(TypedDict, total=False): + FpgaImageAttribute: Optional[FpgaImageAttribute] + + +class ModifyHostsRequest(ServiceRequest): + HostRecovery: Optional[HostRecovery] + InstanceType: Optional[String] + InstanceFamily: Optional[String] + HostMaintenance: Optional[HostMaintenance] + HostIds: RequestHostIdList + AutoPlacement: Optional[AutoPlacement] + + +UnsuccessfulItemList = List[UnsuccessfulItem] + + +class ModifyHostsResult(TypedDict, total=False): + Successful: Optional[ResponseHostIdList] + Unsuccessful: Optional[UnsuccessfulItemList] + + +class ModifyIdFormatRequest(ServiceRequest): + Resource: String + UseLongIds: Boolean + + +class ModifyIdentityIdFormatRequest(ServiceRequest): + Resource: String + UseLongIds: Boolean + PrincipalArn: String + + +OrganizationalUnitArnStringList = List[String] +OrganizationArnStringList = List[String] + + +class ModifyImageAttributeRequest(ServiceRequest): + Attribute: Optional[String] + Description: Optional[AttributeValue] + ImageId: ImageId + LaunchPermission: Optional[LaunchPermissionModifications] + OperationType: Optional[OperationType] + ProductCodes: Optional[ProductCodeStringList] + UserGroups: Optional[UserGroupStringList] + UserIds: Optional[UserIdStringList] + Value: Optional[String] + OrganizationArns: Optional[OrganizationArnStringList] + OrganizationalUnitArns: Optional[OrganizationalUnitArnStringList] + ImdsSupport: Optional[AttributeValue] + DryRun: Optional[Boolean] + + +class ModifyInstanceAttributeRequest(ServiceRequest): + SourceDestCheck: Optional[AttributeBooleanValue] + DisableApiStop: Optional[AttributeBooleanValue] + DryRun: Optional[Boolean] + InstanceId: InstanceId + Attribute: Optional[InstanceAttributeName] + Value: Optional[String] + BlockDeviceMappings: Optional[InstanceBlockDeviceMappingSpecificationList] + DisableApiTermination: Optional[AttributeBooleanValue] + InstanceType: Optional[AttributeValue] + Kernel: Optional[AttributeValue] + Ramdisk: Optional[AttributeValue] + UserData: Optional[BlobAttributeValue] + InstanceInitiatedShutdownBehavior: Optional[AttributeValue] + Groups: Optional[GroupIdStringList] + EbsOptimized: Optional[AttributeBooleanValue] + SriovNetSupport: Optional[AttributeValue] + EnaSupport: Optional[AttributeBooleanValue] + + +class ModifyInstanceCapacityReservationAttributesRequest(ServiceRequest): + InstanceId: InstanceId + CapacityReservationSpecification: CapacityReservationSpecification + DryRun: Optional[Boolean] + + +class ModifyInstanceCapacityReservationAttributesResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyInstanceCpuOptionsRequest(ServiceRequest): + InstanceId: InstanceId + CoreCount: Integer + ThreadsPerCore: Integer + DryRun: Optional[Boolean] + + +class ModifyInstanceCpuOptionsResult(TypedDict, total=False): + InstanceId: Optional[InstanceId] + CoreCount: Optional[Integer] + ThreadsPerCore: Optional[Integer] + + +class ModifyInstanceCreditSpecificationRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + InstanceCreditSpecifications: InstanceCreditSpecificationListRequest + + +class UnsuccessfulInstanceCreditSpecificationItemError(TypedDict, total=False): + Code: Optional[UnsuccessfulInstanceCreditSpecificationErrorCode] + Message: Optional[String] + + +class UnsuccessfulInstanceCreditSpecificationItem(TypedDict, total=False): + InstanceId: Optional[String] + Error: Optional[UnsuccessfulInstanceCreditSpecificationItemError] + + +UnsuccessfulInstanceCreditSpecificationSet = List[UnsuccessfulInstanceCreditSpecificationItem] + + +class SuccessfulInstanceCreditSpecificationItem(TypedDict, total=False): + InstanceId: Optional[String] + + +SuccessfulInstanceCreditSpecificationSet = List[SuccessfulInstanceCreditSpecificationItem] + + +class ModifyInstanceCreditSpecificationResult(TypedDict, total=False): + SuccessfulInstanceCreditSpecifications: Optional[SuccessfulInstanceCreditSpecificationSet] + UnsuccessfulInstanceCreditSpecifications: Optional[UnsuccessfulInstanceCreditSpecificationSet] + + +class ModifyInstanceEventStartTimeRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + InstanceEventId: String + NotBefore: DateTime + + +class ModifyInstanceEventStartTimeResult(TypedDict, total=False): + Event: Optional[InstanceStatusEvent] + + +class ModifyInstanceEventWindowRequest(ServiceRequest): + DryRun: Optional[Boolean] + Name: Optional[String] + InstanceEventWindowId: InstanceEventWindowId + TimeRanges: Optional[InstanceEventWindowTimeRangeRequestSet] + CronExpression: Optional[InstanceEventWindowCronExpression] + + +class ModifyInstanceEventWindowResult(TypedDict, total=False): + InstanceEventWindow: Optional[InstanceEventWindow] + + +class ModifyInstanceMaintenanceOptionsRequest(ServiceRequest): + InstanceId: InstanceId + AutoRecovery: Optional[InstanceAutoRecoveryState] + RebootMigration: Optional[InstanceRebootMigrationState] + DryRun: Optional[Boolean] + + +class ModifyInstanceMaintenanceOptionsResult(TypedDict, total=False): + InstanceId: Optional[String] + AutoRecovery: Optional[InstanceAutoRecoveryState] + RebootMigration: Optional[InstanceRebootMigrationState] + + +class ModifyInstanceMetadataDefaultsRequest(ServiceRequest): + HttpTokens: Optional[MetadataDefaultHttpTokensState] + HttpPutResponseHopLimit: Optional[BoxedInteger] + HttpEndpoint: Optional[DefaultInstanceMetadataEndpointState] + InstanceMetadataTags: Optional[DefaultInstanceMetadataTagsState] + DryRun: Optional[Boolean] + + +class ModifyInstanceMetadataDefaultsResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyInstanceMetadataOptionsRequest(ServiceRequest): + InstanceId: InstanceId + HttpTokens: Optional[HttpTokensState] + HttpPutResponseHopLimit: Optional[Integer] + HttpEndpoint: Optional[InstanceMetadataEndpointState] + DryRun: Optional[Boolean] + HttpProtocolIpv6: Optional[InstanceMetadataProtocolState] + InstanceMetadataTags: Optional[InstanceMetadataTagsState] + + +class ModifyInstanceMetadataOptionsResult(TypedDict, total=False): + InstanceId: Optional[String] + InstanceMetadataOptions: Optional[InstanceMetadataOptionsResponse] + + +class ModifyInstanceNetworkPerformanceRequest(ServiceRequest): + InstanceId: InstanceId + BandwidthWeighting: InstanceBandwidthWeighting + DryRun: Optional[Boolean] + + +class ModifyInstanceNetworkPerformanceResult(TypedDict, total=False): + InstanceId: Optional[InstanceId] + BandwidthWeighting: Optional[InstanceBandwidthWeighting] + + +class ModifyInstancePlacementRequest(ServiceRequest): + GroupName: Optional[PlacementGroupName] + PartitionNumber: Optional[Integer] + HostResourceGroupArn: Optional[String] + GroupId: Optional[PlacementGroupId] + InstanceId: InstanceId + Tenancy: Optional[HostTenancy] + Affinity: Optional[Affinity] + HostId: Optional[DedicatedHostId] + + +class ModifyInstancePlacementResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyIpamPoolRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + Description: Optional[String] + AutoImport: Optional[Boolean] + AllocationMinNetmaskLength: Optional[IpamNetmaskLength] + AllocationMaxNetmaskLength: Optional[IpamNetmaskLength] + AllocationDefaultNetmaskLength: Optional[IpamNetmaskLength] + ClearAllocationDefaultNetmaskLength: Optional[Boolean] + AddAllocationResourceTags: Optional[RequestIpamResourceTagList] + RemoveAllocationResourceTags: Optional[RequestIpamResourceTagList] + + +class ModifyIpamPoolResult(TypedDict, total=False): + IpamPool: Optional[IpamPool] + + +class RemoveIpamOperatingRegion(TypedDict, total=False): + RegionName: Optional[String] + + +RemoveIpamOperatingRegionSet = List[RemoveIpamOperatingRegion] + + +class ModifyIpamRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamId: IpamId + Description: Optional[String] + AddOperatingRegions: Optional[AddIpamOperatingRegionSet] + RemoveOperatingRegions: Optional[RemoveIpamOperatingRegionSet] + Tier: Optional[IpamTier] + EnablePrivateGua: Optional[Boolean] + MeteredAccount: Optional[IpamMeteredAccount] + + +class ModifyIpamResourceCidrRequest(ServiceRequest): + DryRun: Optional[Boolean] + ResourceId: String + ResourceCidr: String + ResourceRegion: String + CurrentIpamScopeId: IpamScopeId + DestinationIpamScopeId: Optional[IpamScopeId] + Monitored: Boolean + + +class ModifyIpamResourceCidrResult(TypedDict, total=False): + IpamResourceCidr: Optional[IpamResourceCidr] + + +class RemoveIpamOrganizationalUnitExclusion(TypedDict, total=False): + OrganizationsEntityPath: Optional[String] + + +RemoveIpamOrganizationalUnitExclusionSet = List[RemoveIpamOrganizationalUnitExclusion] + + +class ModifyIpamResourceDiscoveryRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamResourceDiscoveryId: IpamResourceDiscoveryId + Description: Optional[String] + AddOperatingRegions: Optional[AddIpamOperatingRegionSet] + RemoveOperatingRegions: Optional[RemoveIpamOperatingRegionSet] + AddOrganizationalUnitExclusions: Optional[AddIpamOrganizationalUnitExclusionSet] + RemoveOrganizationalUnitExclusions: Optional[RemoveIpamOrganizationalUnitExclusionSet] + + +class ModifyIpamResourceDiscoveryResult(TypedDict, total=False): + IpamResourceDiscovery: Optional[IpamResourceDiscovery] + + +class ModifyIpamResult(TypedDict, total=False): + Ipam: Optional[Ipam] + + +class ModifyIpamScopeRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamScopeId: IpamScopeId + Description: Optional[String] + + +class ModifyIpamScopeResult(TypedDict, total=False): + IpamScope: Optional[IpamScope] + + +class ModifyLaunchTemplateRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + LaunchTemplateId: Optional[LaunchTemplateId] + LaunchTemplateName: Optional[LaunchTemplateName] + DefaultVersion: Optional[String] + + +class ModifyLaunchTemplateResult(TypedDict, total=False): + LaunchTemplate: Optional[LaunchTemplate] + + +class ModifyLocalGatewayRouteRequest(ServiceRequest): + DestinationCidrBlock: Optional[String] + LocalGatewayRouteTableId: LocalGatewayRoutetableId + LocalGatewayVirtualInterfaceGroupId: Optional[LocalGatewayVirtualInterfaceGroupId] + NetworkInterfaceId: Optional[NetworkInterfaceId] + DryRun: Optional[Boolean] + DestinationPrefixListId: Optional[PrefixListResourceId] + + +class ModifyLocalGatewayRouteResult(TypedDict, total=False): + Route: Optional[LocalGatewayRoute] + + +class RemovePrefixListEntry(TypedDict, total=False): + Cidr: String + + +RemovePrefixListEntries = List[RemovePrefixListEntry] + + +class ModifyManagedPrefixListRequest(ServiceRequest): + DryRun: Optional[Boolean] + PrefixListId: PrefixListResourceId + CurrentVersion: Optional[Long] + PrefixListName: Optional[String] + AddEntries: Optional[AddPrefixListEntries] + RemoveEntries: Optional[RemovePrefixListEntries] + MaxEntries: Optional[Integer] + + +class ModifyManagedPrefixListResult(TypedDict, total=False): + PrefixList: Optional[ManagedPrefixList] + + +class NetworkInterfaceAttachmentChanges(TypedDict, total=False): + DefaultEnaQueueCount: Optional[Boolean] + EnaQueueCount: Optional[Integer] + AttachmentId: Optional[NetworkInterfaceAttachmentId] + DeleteOnTermination: Optional[Boolean] + + +SubnetIdList = List[SubnetId] + + +class ModifyNetworkInterfaceAttributeRequest(ServiceRequest): + EnaSrdSpecification: Optional[EnaSrdSpecification] + EnablePrimaryIpv6: Optional[Boolean] + ConnectionTrackingSpecification: Optional[ConnectionTrackingSpecificationRequest] + AssociatePublicIpAddress: Optional[Boolean] + AssociatedSubnetIds: Optional[SubnetIdList] + DryRun: Optional[Boolean] + NetworkInterfaceId: NetworkInterfaceId + Description: Optional[AttributeValue] + SourceDestCheck: Optional[AttributeBooleanValue] + Groups: Optional[SecurityGroupIdStringList] + Attachment: Optional[NetworkInterfaceAttachmentChanges] + + +class ModifyPrivateDnsNameOptionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + PrivateDnsHostnameType: Optional[HostnameType] + EnableResourceNameDnsARecord: Optional[Boolean] + EnableResourceNameDnsAAAARecord: Optional[Boolean] + + +class ModifyPrivateDnsNameOptionsResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyPublicIpDnsNameOptionsRequest(ServiceRequest): + NetworkInterfaceId: NetworkInterfaceId + HostnameType: PublicIpDnsOption + DryRun: Optional[Boolean] + + +class ModifyPublicIpDnsNameOptionsResult(TypedDict, total=False): + Successful: Optional[Boolean] + + +ReservedInstancesConfigurationList = List[ReservedInstancesConfiguration] + + +class ModifyReservedInstancesRequest(ServiceRequest): + ReservedInstancesIds: ReservedInstancesIdStringList + ClientToken: Optional[String] + TargetConfigurations: ReservedInstancesConfigurationList + + +class ModifyReservedInstancesResult(TypedDict, total=False): + ReservedInstancesModificationId: Optional[String] + + +class ModifyRouteServerRequest(ServiceRequest): + RouteServerId: RouteServerId + PersistRoutes: Optional[RouteServerPersistRoutesAction] + PersistRoutesDuration: Optional[BoxedLong] + SnsNotificationsEnabled: Optional[Boolean] + DryRun: Optional[Boolean] + + +class ModifyRouteServerResult(TypedDict, total=False): + RouteServer: Optional[RouteServer] + + +class SecurityGroupRuleRequest(TypedDict, total=False): + IpProtocol: Optional[String] + FromPort: Optional[Integer] + ToPort: Optional[Integer] + CidrIpv4: Optional[String] + CidrIpv6: Optional[String] + PrefixListId: Optional[PrefixListResourceId] + ReferencedGroupId: Optional[SecurityGroupId] + Description: Optional[String] + + +class SecurityGroupRuleUpdate(TypedDict, total=False): + SecurityGroupRuleId: SecurityGroupRuleId + SecurityGroupRule: Optional[SecurityGroupRuleRequest] + + +SecurityGroupRuleUpdateList = List[SecurityGroupRuleUpdate] + + +class ModifySecurityGroupRulesRequest(ServiceRequest): + GroupId: SecurityGroupId + SecurityGroupRules: SecurityGroupRuleUpdateList + DryRun: Optional[Boolean] + + +class ModifySecurityGroupRulesResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifySnapshotAttributeRequest(ServiceRequest): + Attribute: Optional[SnapshotAttributeName] + CreateVolumePermission: Optional[CreateVolumePermissionModifications] + GroupNames: Optional[GroupNameStringList] + OperationType: Optional[OperationType] + SnapshotId: SnapshotId + UserIds: Optional[UserIdStringList] + DryRun: Optional[Boolean] + + +class ModifySnapshotTierRequest(ServiceRequest): + SnapshotId: SnapshotId + StorageTier: Optional[TargetStorageTier] + DryRun: Optional[Boolean] + + +class ModifySnapshotTierResult(TypedDict, total=False): + SnapshotId: Optional[String] + TieringStartTime: Optional[MillisecondDateTime] + + +class ModifySpotFleetRequestRequest(ServiceRequest): + LaunchTemplateConfigs: Optional[LaunchTemplateConfigList] + OnDemandTargetCapacity: Optional[Integer] + Context: Optional[String] + SpotFleetRequestId: SpotFleetRequestId + TargetCapacity: Optional[Integer] + ExcessCapacityTerminationPolicy: Optional[ExcessCapacityTerminationPolicy] + + +class ModifySpotFleetRequestResponse(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifySubnetAttributeRequest(ServiceRequest): + AssignIpv6AddressOnCreation: Optional[AttributeBooleanValue] + MapPublicIpOnLaunch: Optional[AttributeBooleanValue] + SubnetId: SubnetId + MapCustomerOwnedIpOnLaunch: Optional[AttributeBooleanValue] + CustomerOwnedIpv4Pool: Optional[CoipPoolId] + EnableDns64: Optional[AttributeBooleanValue] + PrivateDnsHostnameTypeOnLaunch: Optional[HostnameType] + EnableResourceNameDnsARecordOnLaunch: Optional[AttributeBooleanValue] + EnableResourceNameDnsAAAARecordOnLaunch: Optional[AttributeBooleanValue] + EnableLniAtDeviceIndex: Optional[Integer] + DisableLniAtDeviceIndex: Optional[AttributeBooleanValue] + + +class ModifyTrafficMirrorFilterNetworkServicesRequest(ServiceRequest): + TrafficMirrorFilterId: TrafficMirrorFilterId + AddNetworkServices: Optional[TrafficMirrorNetworkServiceList] + RemoveNetworkServices: Optional[TrafficMirrorNetworkServiceList] + DryRun: Optional[Boolean] + + +class ModifyTrafficMirrorFilterNetworkServicesResult(TypedDict, total=False): + TrafficMirrorFilter: Optional[TrafficMirrorFilter] + + +TrafficMirrorFilterRuleFieldList = List[TrafficMirrorFilterRuleField] + + +class ModifyTrafficMirrorFilterRuleRequest(ServiceRequest): + TrafficMirrorFilterRuleId: TrafficMirrorFilterRuleIdWithResolver + TrafficDirection: Optional[TrafficDirection] + RuleNumber: Optional[Integer] + RuleAction: Optional[TrafficMirrorRuleAction] + DestinationPortRange: Optional[TrafficMirrorPortRangeRequest] + SourcePortRange: Optional[TrafficMirrorPortRangeRequest] + Protocol: Optional[Integer] + DestinationCidrBlock: Optional[String] + SourceCidrBlock: Optional[String] + Description: Optional[String] + RemoveFields: Optional[TrafficMirrorFilterRuleFieldList] + DryRun: Optional[Boolean] + + +class ModifyTrafficMirrorFilterRuleResult(TypedDict, total=False): + TrafficMirrorFilterRule: Optional[TrafficMirrorFilterRule] + + +TrafficMirrorSessionFieldList = List[TrafficMirrorSessionField] + + +class ModifyTrafficMirrorSessionRequest(ServiceRequest): + TrafficMirrorSessionId: TrafficMirrorSessionId + TrafficMirrorTargetId: Optional[TrafficMirrorTargetId] + TrafficMirrorFilterId: Optional[TrafficMirrorFilterId] + PacketLength: Optional[Integer] + SessionNumber: Optional[Integer] + VirtualNetworkId: Optional[Integer] + Description: Optional[String] + RemoveFields: Optional[TrafficMirrorSessionFieldList] + DryRun: Optional[Boolean] + + +class ModifyTrafficMirrorSessionResult(TypedDict, total=False): + TrafficMirrorSession: Optional[TrafficMirrorSession] + + +class ModifyTransitGatewayOptions(TypedDict, total=False): + AddTransitGatewayCidrBlocks: Optional[TransitGatewayCidrBlockStringList] + RemoveTransitGatewayCidrBlocks: Optional[TransitGatewayCidrBlockStringList] + VpnEcmpSupport: Optional[VpnEcmpSupportValue] + DnsSupport: Optional[DnsSupportValue] + SecurityGroupReferencingSupport: Optional[SecurityGroupReferencingSupportValue] + AutoAcceptSharedAttachments: Optional[AutoAcceptSharedAttachmentsValue] + DefaultRouteTableAssociation: Optional[DefaultRouteTableAssociationValue] + AssociationDefaultRouteTableId: Optional[TransitGatewayRouteTableId] + DefaultRouteTablePropagation: Optional[DefaultRouteTablePropagationValue] + PropagationDefaultRouteTableId: Optional[TransitGatewayRouteTableId] + AmazonSideAsn: Optional[Long] + + +class ModifyTransitGatewayPrefixListReferenceRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + PrefixListId: PrefixListResourceId + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + Blackhole: Optional[Boolean] + DryRun: Optional[Boolean] + + +class ModifyTransitGatewayPrefixListReferenceResult(TypedDict, total=False): + TransitGatewayPrefixListReference: Optional[TransitGatewayPrefixListReference] + + +class ModifyTransitGatewayRequest(ServiceRequest): + TransitGatewayId: TransitGatewayId + Description: Optional[String] + Options: Optional[ModifyTransitGatewayOptions] + DryRun: Optional[Boolean] + + +class ModifyTransitGatewayResult(TypedDict, total=False): + TransitGateway: Optional[TransitGateway] + + +class ModifyTransitGatewayVpcAttachmentRequestOptions(TypedDict, total=False): + DnsSupport: Optional[DnsSupportValue] + SecurityGroupReferencingSupport: Optional[SecurityGroupReferencingSupportValue] + Ipv6Support: Optional[Ipv6SupportValue] + ApplianceModeSupport: Optional[ApplianceModeSupportValue] + + +class ModifyTransitGatewayVpcAttachmentRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + AddSubnetIds: Optional[TransitGatewaySubnetIdList] + RemoveSubnetIds: Optional[TransitGatewaySubnetIdList] + Options: Optional[ModifyTransitGatewayVpcAttachmentRequestOptions] + DryRun: Optional[Boolean] + + +class ModifyTransitGatewayVpcAttachmentResult(TypedDict, total=False): + TransitGatewayVpcAttachment: Optional[TransitGatewayVpcAttachment] + + +class ModifyVerifiedAccessEndpointPortRange(TypedDict, total=False): + FromPort: Optional[VerifiedAccessEndpointPortNumber] + ToPort: Optional[VerifiedAccessEndpointPortNumber] + + +ModifyVerifiedAccessEndpointPortRangeList = List[ModifyVerifiedAccessEndpointPortRange] + + +class ModifyVerifiedAccessEndpointCidrOptions(TypedDict, total=False): + PortRanges: Optional[ModifyVerifiedAccessEndpointPortRangeList] + + +class ModifyVerifiedAccessEndpointEniOptions(TypedDict, total=False): + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + PortRanges: Optional[ModifyVerifiedAccessEndpointPortRangeList] + + +ModifyVerifiedAccessEndpointSubnetIdList = List[SubnetId] + + +class ModifyVerifiedAccessEndpointLoadBalancerOptions(TypedDict, total=False): + SubnetIds: Optional[ModifyVerifiedAccessEndpointSubnetIdList] + Protocol: Optional[VerifiedAccessEndpointProtocol] + Port: Optional[VerifiedAccessEndpointPortNumber] + PortRanges: Optional[ModifyVerifiedAccessEndpointPortRangeList] + + +class ModifyVerifiedAccessEndpointPolicyRequest(ServiceRequest): + VerifiedAccessEndpointId: VerifiedAccessEndpointId + PolicyEnabled: Optional[Boolean] + PolicyDocument: Optional[String] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + SseSpecification: Optional[VerifiedAccessSseSpecificationRequest] + + +class ModifyVerifiedAccessEndpointPolicyResult(TypedDict, total=False): + PolicyEnabled: Optional[Boolean] + PolicyDocument: Optional[String] + SseSpecification: Optional[VerifiedAccessSseSpecificationResponse] + + +class ModifyVerifiedAccessEndpointRdsOptions(TypedDict, total=False): + SubnetIds: Optional[ModifyVerifiedAccessEndpointSubnetIdList] + Port: Optional[VerifiedAccessEndpointPortNumber] + RdsEndpoint: Optional[String] + + +class ModifyVerifiedAccessEndpointRequest(ServiceRequest): + VerifiedAccessEndpointId: VerifiedAccessEndpointId + VerifiedAccessGroupId: Optional[VerifiedAccessGroupId] + LoadBalancerOptions: Optional[ModifyVerifiedAccessEndpointLoadBalancerOptions] + NetworkInterfaceOptions: Optional[ModifyVerifiedAccessEndpointEniOptions] + Description: Optional[String] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + RdsOptions: Optional[ModifyVerifiedAccessEndpointRdsOptions] + CidrOptions: Optional[ModifyVerifiedAccessEndpointCidrOptions] + + +class ModifyVerifiedAccessEndpointResult(TypedDict, total=False): + VerifiedAccessEndpoint: Optional[VerifiedAccessEndpoint] + + +class ModifyVerifiedAccessGroupPolicyRequest(ServiceRequest): + VerifiedAccessGroupId: VerifiedAccessGroupId + PolicyEnabled: Optional[Boolean] + PolicyDocument: Optional[String] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + SseSpecification: Optional[VerifiedAccessSseSpecificationRequest] + + +class ModifyVerifiedAccessGroupPolicyResult(TypedDict, total=False): + PolicyEnabled: Optional[Boolean] + PolicyDocument: Optional[String] + SseSpecification: Optional[VerifiedAccessSseSpecificationResponse] + + +class ModifyVerifiedAccessGroupRequest(ServiceRequest): + VerifiedAccessGroupId: VerifiedAccessGroupId + VerifiedAccessInstanceId: Optional[VerifiedAccessInstanceId] + Description: Optional[String] + ClientToken: Optional[String] + DryRun: Optional[Boolean] + + +class ModifyVerifiedAccessGroupResult(TypedDict, total=False): + VerifiedAccessGroup: Optional[VerifiedAccessGroup] + + +class VerifiedAccessLogKinesisDataFirehoseDestinationOptions(TypedDict, total=False): + Enabled: Boolean + DeliveryStream: Optional[String] + + +class VerifiedAccessLogCloudWatchLogsDestinationOptions(TypedDict, total=False): + Enabled: Boolean + LogGroup: Optional[String] + + +class VerifiedAccessLogS3DestinationOptions(TypedDict, total=False): + Enabled: Boolean + BucketName: Optional[String] + Prefix: Optional[String] + BucketOwner: Optional[String] + + +class VerifiedAccessLogOptions(TypedDict, total=False): + S3: Optional[VerifiedAccessLogS3DestinationOptions] + CloudWatchLogs: Optional[VerifiedAccessLogCloudWatchLogsDestinationOptions] + KinesisDataFirehose: Optional[VerifiedAccessLogKinesisDataFirehoseDestinationOptions] + LogVersion: Optional[String] + IncludeTrustContext: Optional[Boolean] + + +class ModifyVerifiedAccessInstanceLoggingConfigurationRequest(ServiceRequest): + VerifiedAccessInstanceId: VerifiedAccessInstanceId + AccessLogs: VerifiedAccessLogOptions + DryRun: Optional[Boolean] + ClientToken: Optional[String] + + +class ModifyVerifiedAccessInstanceLoggingConfigurationResult(TypedDict, total=False): + LoggingConfiguration: Optional[VerifiedAccessInstanceLoggingConfiguration] + + +class ModifyVerifiedAccessInstanceRequest(ServiceRequest): + VerifiedAccessInstanceId: VerifiedAccessInstanceId + Description: Optional[String] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + CidrEndpointsCustomSubDomain: Optional[String] + + +class ModifyVerifiedAccessInstanceResult(TypedDict, total=False): + VerifiedAccessInstance: Optional[VerifiedAccessInstance] + + +class ModifyVerifiedAccessNativeApplicationOidcOptions(TypedDict, total=False): + PublicSigningKeyEndpoint: Optional[String] + Issuer: Optional[String] + AuthorizationEndpoint: Optional[String] + TokenEndpoint: Optional[String] + UserInfoEndpoint: Optional[String] + ClientId: Optional[String] + ClientSecret: Optional[ClientSecretType] + Scope: Optional[String] + + +class ModifyVerifiedAccessTrustProviderDeviceOptions(TypedDict, total=False): + PublicSigningKeyUrl: Optional[String] + + +class ModifyVerifiedAccessTrustProviderOidcOptions(TypedDict, total=False): + Issuer: Optional[String] + AuthorizationEndpoint: Optional[String] + TokenEndpoint: Optional[String] + UserInfoEndpoint: Optional[String] + ClientId: Optional[String] + ClientSecret: Optional[ClientSecretType] + Scope: Optional[String] + + +class ModifyVerifiedAccessTrustProviderRequest(ServiceRequest): + VerifiedAccessTrustProviderId: VerifiedAccessTrustProviderId + OidcOptions: Optional[ModifyVerifiedAccessTrustProviderOidcOptions] + DeviceOptions: Optional[ModifyVerifiedAccessTrustProviderDeviceOptions] + Description: Optional[String] + DryRun: Optional[Boolean] + ClientToken: Optional[String] + SseSpecification: Optional[VerifiedAccessSseSpecificationRequest] + NativeApplicationOidcOptions: Optional[ModifyVerifiedAccessNativeApplicationOidcOptions] + + +class ModifyVerifiedAccessTrustProviderResult(TypedDict, total=False): + VerifiedAccessTrustProvider: Optional[VerifiedAccessTrustProvider] + + +class ModifyVolumeAttributeRequest(ServiceRequest): + AutoEnableIO: Optional[AttributeBooleanValue] + VolumeId: VolumeId + DryRun: Optional[Boolean] + + +class ModifyVolumeRequest(ServiceRequest): + DryRun: Optional[Boolean] + VolumeId: VolumeId + Size: Optional[Integer] + VolumeType: Optional[VolumeType] + Iops: Optional[Integer] + Throughput: Optional[Integer] + MultiAttachEnabled: Optional[Boolean] + + +class ModifyVolumeResult(TypedDict, total=False): + VolumeModification: Optional[VolumeModification] + + +class ModifyVpcAttributeRequest(ServiceRequest): + EnableDnsHostnames: Optional[AttributeBooleanValue] + EnableDnsSupport: Optional[AttributeBooleanValue] + VpcId: VpcId + EnableNetworkAddressUsageMetrics: Optional[AttributeBooleanValue] + + +class ModifyVpcBlockPublicAccessExclusionRequest(ServiceRequest): + DryRun: Optional[Boolean] + ExclusionId: VpcBlockPublicAccessExclusionId + InternetGatewayExclusionMode: InternetGatewayExclusionMode + + +class ModifyVpcBlockPublicAccessExclusionResult(TypedDict, total=False): + VpcBlockPublicAccessExclusion: Optional[VpcBlockPublicAccessExclusion] + + +class ModifyVpcBlockPublicAccessOptionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + InternetGatewayBlockMode: InternetGatewayBlockMode + + +class ModifyVpcBlockPublicAccessOptionsResult(TypedDict, total=False): + VpcBlockPublicAccessOptions: Optional[VpcBlockPublicAccessOptions] + + +class ModifyVpcEndpointConnectionNotificationRequest(ServiceRequest): + DryRun: Optional[Boolean] + ConnectionNotificationId: ConnectionNotificationId + ConnectionNotificationArn: Optional[String] + ConnectionEvents: Optional[ValueStringList] + + +class ModifyVpcEndpointConnectionNotificationResult(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class ModifyVpcEndpointRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcEndpointId: VpcEndpointId + ResetPolicy: Optional[Boolean] + PolicyDocument: Optional[String] + AddRouteTableIds: Optional[VpcEndpointRouteTableIdList] + RemoveRouteTableIds: Optional[VpcEndpointRouteTableIdList] + AddSubnetIds: Optional[VpcEndpointSubnetIdList] + RemoveSubnetIds: Optional[VpcEndpointSubnetIdList] + AddSecurityGroupIds: Optional[VpcEndpointSecurityGroupIdList] + RemoveSecurityGroupIds: Optional[VpcEndpointSecurityGroupIdList] + IpAddressType: Optional[IpAddressType] + DnsOptions: Optional[DnsOptionsSpecification] + PrivateDnsEnabled: Optional[Boolean] + SubnetConfigurations: Optional[SubnetConfigurationsList] + + +class ModifyVpcEndpointResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyVpcEndpointServiceConfigurationRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: VpcEndpointServiceId + PrivateDnsName: Optional[String] + RemovePrivateDnsName: Optional[Boolean] + AcceptanceRequired: Optional[Boolean] + AddNetworkLoadBalancerArns: Optional[ValueStringList] + RemoveNetworkLoadBalancerArns: Optional[ValueStringList] + AddGatewayLoadBalancerArns: Optional[ValueStringList] + RemoveGatewayLoadBalancerArns: Optional[ValueStringList] + AddSupportedIpAddressTypes: Optional[ValueStringList] + RemoveSupportedIpAddressTypes: Optional[ValueStringList] + AddSupportedRegions: Optional[ValueStringList] + RemoveSupportedRegions: Optional[ValueStringList] + + +class ModifyVpcEndpointServiceConfigurationResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ModifyVpcEndpointServicePayerResponsibilityRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: VpcEndpointServiceId + PayerResponsibility: PayerResponsibility + + +class ModifyVpcEndpointServicePayerResponsibilityResult(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class ModifyVpcEndpointServicePermissionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: VpcEndpointServiceId + AddAllowedPrincipals: Optional[ValueStringList] + RemoveAllowedPrincipals: Optional[ValueStringList] + + +class ModifyVpcEndpointServicePermissionsResult(TypedDict, total=False): + AddedPrincipals: Optional[AddedPrincipalSet] + ReturnValue: Optional[Boolean] + + +class PeeringConnectionOptionsRequest(TypedDict, total=False): + AllowDnsResolutionFromRemoteVpc: Optional[Boolean] + AllowEgressFromLocalClassicLinkToRemoteVpc: Optional[Boolean] + AllowEgressFromLocalVpcToRemoteClassicLink: Optional[Boolean] + + +class ModifyVpcPeeringConnectionOptionsRequest(ServiceRequest): + AccepterPeeringConnectionOptions: Optional[PeeringConnectionOptionsRequest] + DryRun: Optional[Boolean] + RequesterPeeringConnectionOptions: Optional[PeeringConnectionOptionsRequest] + VpcPeeringConnectionId: VpcPeeringConnectionId + + +class PeeringConnectionOptions(TypedDict, total=False): + AllowDnsResolutionFromRemoteVpc: Optional[Boolean] + AllowEgressFromLocalClassicLinkToRemoteVpc: Optional[Boolean] + AllowEgressFromLocalVpcToRemoteClassicLink: Optional[Boolean] + + +class ModifyVpcPeeringConnectionOptionsResult(TypedDict, total=False): + AccepterPeeringConnectionOptions: Optional[PeeringConnectionOptions] + RequesterPeeringConnectionOptions: Optional[PeeringConnectionOptions] + + +class ModifyVpcTenancyRequest(ServiceRequest): + VpcId: VpcId + InstanceTenancy: VpcTenancy + DryRun: Optional[Boolean] + + +class ModifyVpcTenancyResult(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class ModifyVpnConnectionOptionsRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + LocalIpv4NetworkCidr: Optional[String] + RemoteIpv4NetworkCidr: Optional[String] + LocalIpv6NetworkCidr: Optional[String] + RemoteIpv6NetworkCidr: Optional[String] + DryRun: Optional[Boolean] + + +class ModifyVpnConnectionOptionsResult(TypedDict, total=False): + VpnConnection: Optional[VpnConnection] + + +class ModifyVpnConnectionRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + TransitGatewayId: Optional[TransitGatewayId] + CustomerGatewayId: Optional[CustomerGatewayId] + VpnGatewayId: Optional[VpnGatewayId] + DryRun: Optional[Boolean] + + +class ModifyVpnConnectionResult(TypedDict, total=False): + VpnConnection: Optional[VpnConnection] + + +class ModifyVpnTunnelCertificateRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + VpnTunnelOutsideIpAddress: String + DryRun: Optional[Boolean] + + +class ModifyVpnTunnelCertificateResult(TypedDict, total=False): + VpnConnection: Optional[VpnConnection] + + +class ModifyVpnTunnelOptionsSpecification(TypedDict, total=False): + TunnelInsideCidr: Optional[String] + TunnelInsideIpv6Cidr: Optional[String] + PreSharedKey: Optional[preSharedKey] + Phase1LifetimeSeconds: Optional[Integer] + Phase2LifetimeSeconds: Optional[Integer] + RekeyMarginTimeSeconds: Optional[Integer] + RekeyFuzzPercentage: Optional[Integer] + ReplayWindowSize: Optional[Integer] + DPDTimeoutSeconds: Optional[Integer] + DPDTimeoutAction: Optional[String] + Phase1EncryptionAlgorithms: Optional[Phase1EncryptionAlgorithmsRequestList] + Phase2EncryptionAlgorithms: Optional[Phase2EncryptionAlgorithmsRequestList] + Phase1IntegrityAlgorithms: Optional[Phase1IntegrityAlgorithmsRequestList] + Phase2IntegrityAlgorithms: Optional[Phase2IntegrityAlgorithmsRequestList] + Phase1DHGroupNumbers: Optional[Phase1DHGroupNumbersRequestList] + Phase2DHGroupNumbers: Optional[Phase2DHGroupNumbersRequestList] + IKEVersions: Optional[IKEVersionsRequestList] + StartupAction: Optional[String] + LogOptions: Optional[VpnTunnelLogOptionsSpecification] + EnableTunnelLifecycleControl: Optional[Boolean] + + +class ModifyVpnTunnelOptionsRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + VpnTunnelOutsideIpAddress: String + TunnelOptions: ModifyVpnTunnelOptionsSpecification + DryRun: Optional[Boolean] + SkipTunnelReplacement: Optional[Boolean] + PreSharedKeyStorage: Optional[String] + + +class ModifyVpnTunnelOptionsResult(TypedDict, total=False): + VpnConnection: Optional[VpnConnection] + + +class MonitorInstancesRequest(ServiceRequest): + InstanceIds: InstanceIdStringList + DryRun: Optional[Boolean] + + +class MonitorInstancesResult(TypedDict, total=False): + InstanceMonitorings: Optional[InstanceMonitoringList] + + +class MoveAddressToVpcRequest(ServiceRequest): + DryRun: Optional[Boolean] + PublicIp: String + + +class MoveAddressToVpcResult(TypedDict, total=False): + AllocationId: Optional[String] + Status: Optional[Status] + + +class MoveByoipCidrToIpamRequest(ServiceRequest): + DryRun: Optional[Boolean] + Cidr: String + IpamPoolId: IpamPoolId + IpamPoolOwner: String + + +class MoveByoipCidrToIpamResult(TypedDict, total=False): + ByoipCidr: Optional[ByoipCidr] + + +class MoveCapacityReservationInstancesRequest(ServiceRequest): + DryRun: Optional[Boolean] + ClientToken: Optional[String] + SourceCapacityReservationId: CapacityReservationId + DestinationCapacityReservationId: CapacityReservationId + InstanceCount: Integer + + +class MoveCapacityReservationInstancesResult(TypedDict, total=False): + SourceCapacityReservation: Optional[CapacityReservation] + DestinationCapacityReservation: Optional[CapacityReservation] + InstanceCount: Optional[Integer] + + +class PrivateDnsNameOptionsRequest(TypedDict, total=False): + HostnameType: Optional[HostnameType] + EnableResourceNameDnsARecord: Optional[Boolean] + EnableResourceNameDnsAAAARecord: Optional[Boolean] + + +class ScheduledInstancesPrivateIpAddressConfig(TypedDict, total=False): + Primary: Optional[Boolean] + PrivateIpAddress: Optional[String] + + +PrivateIpAddressConfigSet = List[ScheduledInstancesPrivateIpAddressConfig] + + +class ProvisionByoipCidrRequest(ServiceRequest): + Cidr: String + CidrAuthorizationContext: Optional[CidrAuthorizationContext] + PubliclyAdvertisable: Optional[Boolean] + Description: Optional[String] + DryRun: Optional[Boolean] + PoolTagSpecifications: Optional[TagSpecificationList] + MultiRegion: Optional[Boolean] + NetworkBorderGroup: Optional[String] + + +class ProvisionByoipCidrResult(TypedDict, total=False): + ByoipCidr: Optional[ByoipCidr] + + +class ProvisionIpamByoasnRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamId: IpamId + Asn: String + AsnAuthorizationContext: AsnAuthorizationContext + + +class ProvisionIpamByoasnResult(TypedDict, total=False): + Byoasn: Optional[Byoasn] + + +class ProvisionIpamPoolCidrRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + Cidr: Optional[String] + CidrAuthorizationContext: Optional[IpamCidrAuthorizationContext] + NetmaskLength: Optional[Integer] + ClientToken: Optional[String] + VerificationMethod: Optional[VerificationMethod] + IpamExternalResourceVerificationTokenId: Optional[IpamExternalResourceVerificationTokenId] + + +class ProvisionIpamPoolCidrResult(TypedDict, total=False): + IpamPoolCidr: Optional[IpamPoolCidr] + + +class ProvisionPublicIpv4PoolCidrRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + PoolId: Ipv4PoolEc2Id + NetmaskLength: Integer + NetworkBorderGroup: Optional[String] + + +class ProvisionPublicIpv4PoolCidrResult(TypedDict, total=False): + PoolId: Optional[Ipv4PoolEc2Id] + PoolAddressRange: Optional[PublicIpv4PoolRange] + + +class PurchaseCapacityBlockExtensionRequest(ServiceRequest): + CapacityBlockExtensionOfferingId: OfferingId + CapacityReservationId: CapacityReservationId + DryRun: Optional[Boolean] + + +class PurchaseCapacityBlockExtensionResult(TypedDict, total=False): + CapacityBlockExtensions: Optional[CapacityBlockExtensionSet] + + +class PurchaseCapacityBlockRequest(ServiceRequest): + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + CapacityBlockOfferingId: OfferingId + InstancePlatform: CapacityReservationInstancePlatform + + +class PurchaseCapacityBlockResult(TypedDict, total=False): + CapacityReservation: Optional[CapacityReservation] + CapacityBlocks: Optional[CapacityBlockSet] + + +class PurchaseHostReservationRequest(ServiceRequest): + ClientToken: Optional[String] + CurrencyCode: Optional[CurrencyCodeValues] + HostIdSet: RequestHostIdSet + LimitPrice: Optional[String] + OfferingId: OfferingId + TagSpecifications: Optional[TagSpecificationList] + + +class PurchaseHostReservationResult(TypedDict, total=False): + ClientToken: Optional[String] + CurrencyCode: Optional[CurrencyCodeValues] + Purchase: Optional[PurchaseSet] + TotalHourlyPrice: Optional[String] + TotalUpfrontPrice: Optional[String] + + +class PurchaseRequest(TypedDict, total=False): + InstanceCount: Integer + PurchaseToken: String + + +PurchaseRequestSet = List[PurchaseRequest] + + +class ReservedInstanceLimitPrice(TypedDict, total=False): + Amount: Optional[Double] + CurrencyCode: Optional[CurrencyCodeValues] + + +class PurchaseReservedInstancesOfferingRequest(ServiceRequest): + InstanceCount: Integer + ReservedInstancesOfferingId: ReservedInstancesOfferingId + PurchaseTime: Optional[DateTime] + DryRun: Optional[Boolean] + LimitPrice: Optional[ReservedInstanceLimitPrice] + + +class PurchaseReservedInstancesOfferingResult(TypedDict, total=False): + ReservedInstancesId: Optional[String] + + +class PurchaseScheduledInstancesRequest(ServiceRequest): + ClientToken: Optional[String] + DryRun: Optional[Boolean] + PurchaseRequests: PurchaseRequestSet + + +PurchasedScheduledInstanceSet = List[ScheduledInstance] + + +class PurchaseScheduledInstancesResult(TypedDict, total=False): + ScheduledInstanceSet: Optional[PurchasedScheduledInstanceSet] + + +ReasonCodesList = List[ReportInstanceReasonCodes] + + +class RebootInstancesRequest(ServiceRequest): + InstanceIds: InstanceIdStringList + DryRun: Optional[Boolean] + + +class RegisterImageRequest(ServiceRequest): + ImageLocation: Optional[String] + BillingProducts: Optional[BillingProductList] + BootMode: Optional[BootModeValues] + TpmSupport: Optional[TpmSupportValues] + UefiData: Optional[StringType] + ImdsSupport: Optional[ImdsSupportValues] + TagSpecifications: Optional[TagSpecificationList] + DryRun: Optional[Boolean] + Name: String + Description: Optional[String] + Architecture: Optional[ArchitectureValues] + KernelId: Optional[KernelId] + RamdiskId: Optional[RamdiskId] + RootDeviceName: Optional[String] + BlockDeviceMappings: Optional[BlockDeviceMappingRequestList] + VirtualizationType: Optional[String] + SriovNetSupport: Optional[String] + EnaSupport: Optional[Boolean] + + +class RegisterImageResult(TypedDict, total=False): + ImageId: Optional[String] + + +class RegisterInstanceTagAttributeRequest(TypedDict, total=False): + IncludeAllTagsOfInstance: Optional[Boolean] + InstanceTagKeys: Optional[InstanceTagKeySet] + + +class RegisterInstanceEventNotificationAttributesRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceTagAttribute: RegisterInstanceTagAttributeRequest + + +class RegisterInstanceEventNotificationAttributesResult(TypedDict, total=False): + InstanceTagAttribute: Optional[InstanceTagNotificationAttribute] + + +class RegisterTransitGatewayMulticastGroupMembersRequest(ServiceRequest): + TransitGatewayMulticastDomainId: TransitGatewayMulticastDomainId + GroupIpAddress: Optional[String] + NetworkInterfaceIds: TransitGatewayNetworkInterfaceIdList + DryRun: Optional[Boolean] + + +class TransitGatewayMulticastRegisteredGroupMembers(TypedDict, total=False): + TransitGatewayMulticastDomainId: Optional[String] + RegisteredNetworkInterfaceIds: Optional[ValueStringList] + GroupIpAddress: Optional[String] + + +class RegisterTransitGatewayMulticastGroupMembersResult(TypedDict, total=False): + RegisteredMulticastGroupMembers: Optional[TransitGatewayMulticastRegisteredGroupMembers] + + +class RegisterTransitGatewayMulticastGroupSourcesRequest(ServiceRequest): + TransitGatewayMulticastDomainId: TransitGatewayMulticastDomainId + GroupIpAddress: Optional[String] + NetworkInterfaceIds: TransitGatewayNetworkInterfaceIdList + DryRun: Optional[Boolean] + + +class TransitGatewayMulticastRegisteredGroupSources(TypedDict, total=False): + TransitGatewayMulticastDomainId: Optional[String] + RegisteredNetworkInterfaceIds: Optional[ValueStringList] + GroupIpAddress: Optional[String] + + +class RegisterTransitGatewayMulticastGroupSourcesResult(TypedDict, total=False): + RegisteredMulticastGroupSources: Optional[TransitGatewayMulticastRegisteredGroupSources] + + +class RejectCapacityReservationBillingOwnershipRequest(ServiceRequest): + DryRun: Optional[Boolean] + CapacityReservationId: CapacityReservationId + + +class RejectCapacityReservationBillingOwnershipResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class RejectTransitGatewayMulticastDomainAssociationsRequest(ServiceRequest): + TransitGatewayMulticastDomainId: Optional[TransitGatewayMulticastDomainId] + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + SubnetIds: Optional[ValueStringList] + DryRun: Optional[Boolean] + + +class RejectTransitGatewayMulticastDomainAssociationsResult(TypedDict, total=False): + Associations: Optional[TransitGatewayMulticastDomainAssociations] + + +class RejectTransitGatewayPeeringAttachmentRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class RejectTransitGatewayPeeringAttachmentResult(TypedDict, total=False): + TransitGatewayPeeringAttachment: Optional[TransitGatewayPeeringAttachment] + + +class RejectTransitGatewayVpcAttachmentRequest(ServiceRequest): + TransitGatewayAttachmentId: TransitGatewayAttachmentId + DryRun: Optional[Boolean] + + +class RejectTransitGatewayVpcAttachmentResult(TypedDict, total=False): + TransitGatewayVpcAttachment: Optional[TransitGatewayVpcAttachment] + + +class RejectVpcEndpointConnectionsRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: VpcEndpointServiceId + VpcEndpointIds: VpcEndpointIdList + + +class RejectVpcEndpointConnectionsResult(TypedDict, total=False): + Unsuccessful: Optional[UnsuccessfulItemSet] + + +class RejectVpcPeeringConnectionRequest(ServiceRequest): + DryRun: Optional[Boolean] + VpcPeeringConnectionId: VpcPeeringConnectionId + + +class RejectVpcPeeringConnectionResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ReleaseAddressRequest(ServiceRequest): + AllocationId: Optional[AllocationId] + PublicIp: Optional[String] + NetworkBorderGroup: Optional[String] + DryRun: Optional[Boolean] + + +class ReleaseHostsRequest(ServiceRequest): + HostIds: RequestHostIdList + + +class ReleaseHostsResult(TypedDict, total=False): + Successful: Optional[ResponseHostIdList] + Unsuccessful: Optional[UnsuccessfulItemList] + + +class ReleaseIpamPoolAllocationRequest(ServiceRequest): + DryRun: Optional[Boolean] + IpamPoolId: IpamPoolId + Cidr: String + IpamPoolAllocationId: IpamPoolAllocationId + + +class ReleaseIpamPoolAllocationResult(TypedDict, total=False): + Success: Optional[Boolean] + + +class ReplaceIamInstanceProfileAssociationRequest(ServiceRequest): + IamInstanceProfile: IamInstanceProfileSpecification + AssociationId: IamInstanceProfileAssociationId + + +class ReplaceIamInstanceProfileAssociationResult(TypedDict, total=False): + IamInstanceProfileAssociation: Optional[IamInstanceProfileAssociation] + + +class ReplaceImageCriteriaInAllowedImagesSettingsRequest(ServiceRequest): + ImageCriteria: Optional[ImageCriterionRequestList] + DryRun: Optional[Boolean] + + +class ReplaceImageCriteriaInAllowedImagesSettingsResult(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class ReplaceNetworkAclAssociationRequest(ServiceRequest): + DryRun: Optional[Boolean] + AssociationId: NetworkAclAssociationId + NetworkAclId: NetworkAclId + + +class ReplaceNetworkAclAssociationResult(TypedDict, total=False): + NewAssociationId: Optional[String] + + +class ReplaceNetworkAclEntryRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkAclId: NetworkAclId + RuleNumber: Integer + Protocol: String + RuleAction: RuleAction + Egress: Boolean + CidrBlock: Optional[String] + Ipv6CidrBlock: Optional[String] + IcmpTypeCode: Optional[IcmpTypeCode] + PortRange: Optional[PortRange] + + +class ReplaceRouteRequest(ServiceRequest): + DestinationPrefixListId: Optional[PrefixListResourceId] + VpcEndpointId: Optional[VpcEndpointId] + LocalTarget: Optional[Boolean] + TransitGatewayId: Optional[TransitGatewayId] + LocalGatewayId: Optional[LocalGatewayId] + CarrierGatewayId: Optional[CarrierGatewayId] + CoreNetworkArn: Optional[CoreNetworkArn] + OdbNetworkArn: Optional[OdbNetworkArn] + DryRun: Optional[Boolean] + RouteTableId: RouteTableId + DestinationCidrBlock: Optional[String] + GatewayId: Optional[RouteGatewayId] + DestinationIpv6CidrBlock: Optional[String] + EgressOnlyInternetGatewayId: Optional[EgressOnlyInternetGatewayId] + InstanceId: Optional[InstanceId] + NetworkInterfaceId: Optional[NetworkInterfaceId] + VpcPeeringConnectionId: Optional[VpcPeeringConnectionId] + NatGatewayId: Optional[NatGatewayId] + + +class ReplaceRouteTableAssociationRequest(ServiceRequest): + DryRun: Optional[Boolean] + AssociationId: RouteTableAssociationId + RouteTableId: RouteTableId + + +class ReplaceRouteTableAssociationResult(TypedDict, total=False): + NewAssociationId: Optional[String] + AssociationState: Optional[RouteTableAssociationState] + + +class ReplaceTransitGatewayRouteRequest(ServiceRequest): + DestinationCidrBlock: String + TransitGatewayRouteTableId: TransitGatewayRouteTableId + TransitGatewayAttachmentId: Optional[TransitGatewayAttachmentId] + Blackhole: Optional[Boolean] + DryRun: Optional[Boolean] + + +class ReplaceTransitGatewayRouteResult(TypedDict, total=False): + Route: Optional[TransitGatewayRoute] + + +class ReplaceVpnTunnelRequest(ServiceRequest): + VpnConnectionId: VpnConnectionId + VpnTunnelOutsideIpAddress: String + ApplyPendingMaintenance: Optional[Boolean] + DryRun: Optional[Boolean] + + +class ReplaceVpnTunnelResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ReportInstanceStatusRequest(ServiceRequest): + DryRun: Optional[Boolean] + Instances: InstanceIdStringList + Status: ReportStatusType + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + ReasonCodes: ReasonCodesList + Description: Optional[ReportInstanceStatusRequestDescription] + + +class RequestSpotFleetRequest(ServiceRequest): + DryRun: Optional[Boolean] + SpotFleetRequestConfig: SpotFleetRequestConfigData + + +class RequestSpotFleetResponse(TypedDict, total=False): + SpotFleetRequestId: Optional[String] + + +RequestSpotLaunchSpecificationSecurityGroupList = List[String] +RequestSpotLaunchSpecificationSecurityGroupIdList = List[SecurityGroupId] + + +class RequestSpotLaunchSpecification(TypedDict, total=False): + SecurityGroupIds: Optional[RequestSpotLaunchSpecificationSecurityGroupIdList] + SecurityGroups: Optional[RequestSpotLaunchSpecificationSecurityGroupList] + AddressingType: Optional[String] + BlockDeviceMappings: Optional[BlockDeviceMappingList] + EbsOptimized: Optional[Boolean] + IamInstanceProfile: Optional[IamInstanceProfileSpecification] + ImageId: Optional[ImageId] + InstanceType: Optional[InstanceType] + KernelId: Optional[KernelId] + KeyName: Optional[KeyPairNameWithResolver] + Monitoring: Optional[RunInstancesMonitoringEnabled] + NetworkInterfaces: Optional[InstanceNetworkInterfaceSpecificationList] + Placement: Optional[SpotPlacement] + RamdiskId: Optional[RamdiskId] + SubnetId: Optional[SubnetId] + UserData: Optional[SensitiveUserData] + + +class RequestSpotInstancesRequest(ServiceRequest): + LaunchSpecification: Optional[RequestSpotLaunchSpecification] + TagSpecifications: Optional[TagSpecificationList] + InstanceInterruptionBehavior: Optional[InstanceInterruptionBehavior] + DryRun: Optional[Boolean] + SpotPrice: Optional[String] + ClientToken: Optional[String] + InstanceCount: Optional[Integer] + Type: Optional[SpotInstanceType] + ValidFrom: Optional[DateTime] + ValidUntil: Optional[DateTime] + LaunchGroup: Optional[String] + AvailabilityZoneGroup: Optional[String] + BlockDurationMinutes: Optional[Integer] + + +class RequestSpotInstancesResult(TypedDict, total=False): + SpotInstanceRequests: Optional[SpotInstanceRequestList] + + +class ResetAddressAttributeRequest(ServiceRequest): + AllocationId: AllocationId + Attribute: AddressAttributeName + DryRun: Optional[Boolean] + + +class ResetAddressAttributeResult(TypedDict, total=False): + Address: Optional[AddressAttribute] + + +class ResetEbsDefaultKmsKeyIdRequest(ServiceRequest): + DryRun: Optional[Boolean] + + +class ResetEbsDefaultKmsKeyIdResult(TypedDict, total=False): + KmsKeyId: Optional[String] + + +class ResetFpgaImageAttributeRequest(ServiceRequest): + DryRun: Optional[Boolean] + FpgaImageId: FpgaImageId + Attribute: Optional[ResetFpgaImageAttributeName] + + +class ResetFpgaImageAttributeResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class ResetImageAttributeRequest(ServiceRequest): + Attribute: ResetImageAttributeName + ImageId: ImageId + DryRun: Optional[Boolean] + + +class ResetInstanceAttributeRequest(ServiceRequest): + DryRun: Optional[Boolean] + InstanceId: InstanceId + Attribute: InstanceAttributeName + + +class ResetNetworkInterfaceAttributeRequest(ServiceRequest): + DryRun: Optional[Boolean] + NetworkInterfaceId: NetworkInterfaceId + SourceDestCheck: Optional[String] + + +class ResetSnapshotAttributeRequest(ServiceRequest): + Attribute: SnapshotAttributeName + SnapshotId: SnapshotId + DryRun: Optional[Boolean] + + +class RestoreAddressToClassicRequest(ServiceRequest): + DryRun: Optional[Boolean] + PublicIp: String + + +class RestoreAddressToClassicResult(TypedDict, total=False): + PublicIp: Optional[String] + Status: Optional[Status] + + +class RestoreImageFromRecycleBinRequest(ServiceRequest): + ImageId: ImageId + DryRun: Optional[Boolean] + + +class RestoreImageFromRecycleBinResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class RestoreManagedPrefixListVersionRequest(ServiceRequest): + DryRun: Optional[Boolean] + PrefixListId: PrefixListResourceId + PreviousVersion: Long + CurrentVersion: Long + + +class RestoreManagedPrefixListVersionResult(TypedDict, total=False): + PrefixList: Optional[ManagedPrefixList] + + +class RestoreSnapshotFromRecycleBinRequest(ServiceRequest): + SnapshotId: SnapshotId + DryRun: Optional[Boolean] + + +class RestoreSnapshotFromRecycleBinResult(TypedDict, total=False): + SnapshotId: Optional[String] + OutpostArn: Optional[String] + Description: Optional[String] + Encrypted: Optional[Boolean] + OwnerId: Optional[String] + Progress: Optional[String] + StartTime: Optional[MillisecondDateTime] + State: Optional[SnapshotState] + VolumeId: Optional[String] + VolumeSize: Optional[Integer] + SseType: Optional[SSEType] + + +class RestoreSnapshotTierRequest(ServiceRequest): + SnapshotId: SnapshotId + TemporaryRestoreDays: Optional[RestoreSnapshotTierRequestTemporaryRestoreDays] + PermanentRestore: Optional[Boolean] + DryRun: Optional[Boolean] + + +class RestoreSnapshotTierResult(TypedDict, total=False): + SnapshotId: Optional[String] + RestoreStartTime: Optional[MillisecondDateTime] + RestoreDuration: Optional[Integer] + IsPermanentRestore: Optional[Boolean] + + +class RevokeClientVpnIngressRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + TargetNetworkCidr: String + AccessGroupId: Optional[String] + RevokeAllGroups: Optional[Boolean] + DryRun: Optional[Boolean] + + +class RevokeClientVpnIngressResult(TypedDict, total=False): + Status: Optional[ClientVpnAuthorizationRuleStatus] + + +class RevokeSecurityGroupEgressRequest(ServiceRequest): + SecurityGroupRuleIds: Optional[SecurityGroupRuleIdList] + DryRun: Optional[Boolean] + GroupId: SecurityGroupId + SourceSecurityGroupName: Optional[String] + SourceSecurityGroupOwnerId: Optional[String] + IpProtocol: Optional[String] + FromPort: Optional[Integer] + ToPort: Optional[Integer] + CidrIp: Optional[String] + IpPermissions: Optional[IpPermissionList] + + +class RevokedSecurityGroupRule(TypedDict, total=False): + SecurityGroupRuleId: Optional[SecurityGroupRuleId] + GroupId: Optional[SecurityGroupId] + IsEgress: Optional[Boolean] + IpProtocol: Optional[String] + FromPort: Optional[Integer] + ToPort: Optional[Integer] + CidrIpv4: Optional[String] + CidrIpv6: Optional[String] + PrefixListId: Optional[PrefixListResourceId] + ReferencedGroupId: Optional[SecurityGroupId] + Description: Optional[String] + + +RevokedSecurityGroupRuleList = List[RevokedSecurityGroupRule] + + +class RevokeSecurityGroupEgressResult(TypedDict, total=False): + Return: Optional[Boolean] + UnknownIpPermissions: Optional[IpPermissionList] + RevokedSecurityGroupRules: Optional[RevokedSecurityGroupRuleList] + + +class RevokeSecurityGroupIngressRequest(ServiceRequest): + CidrIp: Optional[String] + FromPort: Optional[Integer] + GroupId: Optional[SecurityGroupId] + GroupName: Optional[SecurityGroupName] + IpPermissions: Optional[IpPermissionList] + IpProtocol: Optional[String] + SourceSecurityGroupName: Optional[String] + SourceSecurityGroupOwnerId: Optional[String] + ToPort: Optional[Integer] + SecurityGroupRuleIds: Optional[SecurityGroupRuleIdList] + DryRun: Optional[Boolean] + + +class RevokeSecurityGroupIngressResult(TypedDict, total=False): + Return: Optional[Boolean] + UnknownIpPermissions: Optional[IpPermissionList] + RevokedSecurityGroupRules: Optional[RevokedSecurityGroupRuleList] + + +class RunInstancesRequest(ServiceRequest): + BlockDeviceMappings: Optional[BlockDeviceMappingRequestList] + ImageId: Optional[ImageId] + InstanceType: Optional[InstanceType] + Ipv6AddressCount: Optional[Integer] + Ipv6Addresses: Optional[InstanceIpv6AddressList] + KernelId: Optional[KernelId] + KeyName: Optional[KeyPairName] + MaxCount: Integer + MinCount: Integer + Monitoring: Optional[RunInstancesMonitoringEnabled] + Placement: Optional[Placement] + RamdiskId: Optional[RamdiskId] + SecurityGroupIds: Optional[SecurityGroupIdStringList] + SecurityGroups: Optional[SecurityGroupStringList] + SubnetId: Optional[SubnetId] + UserData: Optional[RunInstancesUserData] + ElasticGpuSpecification: Optional[ElasticGpuSpecifications] + ElasticInferenceAccelerators: Optional[ElasticInferenceAccelerators] + TagSpecifications: Optional[TagSpecificationList] + LaunchTemplate: Optional[LaunchTemplateSpecification] + InstanceMarketOptions: Optional[InstanceMarketOptionsRequest] + CreditSpecification: Optional[CreditSpecificationRequest] + CpuOptions: Optional[CpuOptionsRequest] + CapacityReservationSpecification: Optional[CapacityReservationSpecification] + HibernationOptions: Optional[HibernationOptionsRequest] + LicenseSpecifications: Optional[LicenseSpecificationListRequest] + MetadataOptions: Optional[InstanceMetadataOptionsRequest] + EnclaveOptions: Optional[EnclaveOptionsRequest] + PrivateDnsNameOptions: Optional[PrivateDnsNameOptionsRequest] + MaintenanceOptions: Optional[InstanceMaintenanceOptionsRequest] + DisableApiStop: Optional[Boolean] + EnablePrimaryIpv6: Optional[Boolean] + NetworkPerformanceOptions: Optional[InstanceNetworkPerformanceOptionsRequest] + Operator: Optional[OperatorRequest] + DryRun: Optional[Boolean] + DisableApiTermination: Optional[Boolean] + InstanceInitiatedShutdownBehavior: Optional[ShutdownBehavior] + PrivateIpAddress: Optional[String] + ClientToken: Optional[String] + AdditionalInfo: Optional[String] + NetworkInterfaces: Optional[InstanceNetworkInterfaceSpecificationList] + IamInstanceProfile: Optional[IamInstanceProfileSpecification] + EbsOptimized: Optional[Boolean] + + +ScheduledInstancesSecurityGroupIdSet = List[SecurityGroupId] + + +class ScheduledInstancesPlacement(TypedDict, total=False): + AvailabilityZone: Optional[String] + GroupName: Optional[PlacementGroupName] + + +class ScheduledInstancesIpv6Address(TypedDict, total=False): + Ipv6Address: Optional[Ipv6Address] + + +ScheduledInstancesIpv6AddressList = List[ScheduledInstancesIpv6Address] + + +class ScheduledInstancesNetworkInterface(TypedDict, total=False): + AssociatePublicIpAddress: Optional[Boolean] + DeleteOnTermination: Optional[Boolean] + Description: Optional[String] + DeviceIndex: Optional[Integer] + Groups: Optional[ScheduledInstancesSecurityGroupIdSet] + Ipv6AddressCount: Optional[Integer] + Ipv6Addresses: Optional[ScheduledInstancesIpv6AddressList] + NetworkInterfaceId: Optional[NetworkInterfaceId] + PrivateIpAddress: Optional[String] + PrivateIpAddressConfigs: Optional[PrivateIpAddressConfigSet] + SecondaryPrivateIpAddressCount: Optional[Integer] + SubnetId: Optional[SubnetId] + + +ScheduledInstancesNetworkInterfaceSet = List[ScheduledInstancesNetworkInterface] + + +class ScheduledInstancesMonitoring(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class ScheduledInstancesIamInstanceProfile(TypedDict, total=False): + Arn: Optional[String] + Name: Optional[String] + + +class ScheduledInstancesEbs(TypedDict, total=False): + DeleteOnTermination: Optional[Boolean] + Encrypted: Optional[Boolean] + Iops: Optional[Integer] + SnapshotId: Optional[SnapshotId] + VolumeSize: Optional[Integer] + VolumeType: Optional[String] + + +class ScheduledInstancesBlockDeviceMapping(TypedDict, total=False): + DeviceName: Optional[String] + Ebs: Optional[ScheduledInstancesEbs] + NoDevice: Optional[String] + VirtualName: Optional[String] + + +ScheduledInstancesBlockDeviceMappingSet = List[ScheduledInstancesBlockDeviceMapping] + + +class ScheduledInstancesLaunchSpecification(TypedDict, total=False): + BlockDeviceMappings: Optional[ScheduledInstancesBlockDeviceMappingSet] + EbsOptimized: Optional[Boolean] + IamInstanceProfile: Optional[ScheduledInstancesIamInstanceProfile] + ImageId: ImageId + InstanceType: Optional[String] + KernelId: Optional[KernelId] + KeyName: Optional[KeyPairName] + Monitoring: Optional[ScheduledInstancesMonitoring] + NetworkInterfaces: Optional[ScheduledInstancesNetworkInterfaceSet] + Placement: Optional[ScheduledInstancesPlacement] + RamdiskId: Optional[RamdiskId] + SecurityGroupIds: Optional[ScheduledInstancesSecurityGroupIdSet] + SubnetId: Optional[SubnetId] + UserData: Optional[String] + + +class RunScheduledInstancesRequest(ServiceRequest): + ClientToken: Optional[String] + DryRun: Optional[Boolean] + InstanceCount: Optional[Integer] + LaunchSpecification: ScheduledInstancesLaunchSpecification + ScheduledInstanceId: ScheduledInstanceId + + +class RunScheduledInstancesResult(TypedDict, total=False): + InstanceIdSet: Optional[InstanceIdSet] + + +class SearchLocalGatewayRoutesRequest(ServiceRequest): + LocalGatewayRouteTableId: LocalGatewayRoutetableId + Filters: Optional[FilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class SearchLocalGatewayRoutesResult(TypedDict, total=False): + Routes: Optional[LocalGatewayRouteList] + NextToken: Optional[String] + + +class SearchTransitGatewayMulticastGroupsRequest(ServiceRequest): + TransitGatewayMulticastDomainId: TransitGatewayMulticastDomainId + Filters: Optional[FilterList] + MaxResults: Optional[TransitGatewayMaxResults] + NextToken: Optional[String] + DryRun: Optional[Boolean] + + +class TransitGatewayMulticastGroup(TypedDict, total=False): + GroupIpAddress: Optional[String] + TransitGatewayAttachmentId: Optional[String] + SubnetId: Optional[String] + ResourceId: Optional[String] + ResourceType: Optional[TransitGatewayAttachmentResourceType] + ResourceOwnerId: Optional[String] + NetworkInterfaceId: Optional[String] + GroupMember: Optional[Boolean] + GroupSource: Optional[Boolean] + MemberType: Optional[MembershipType] + SourceType: Optional[MembershipType] + + +TransitGatewayMulticastGroupList = List[TransitGatewayMulticastGroup] + + +class SearchTransitGatewayMulticastGroupsResult(TypedDict, total=False): + MulticastGroups: Optional[TransitGatewayMulticastGroupList] + NextToken: Optional[String] + + +class SearchTransitGatewayRoutesRequest(ServiceRequest): + TransitGatewayRouteTableId: TransitGatewayRouteTableId + Filters: FilterList + MaxResults: Optional[TransitGatewayMaxResults] + DryRun: Optional[Boolean] + + +TransitGatewayRouteList = List[TransitGatewayRoute] + + +class SearchTransitGatewayRoutesResult(TypedDict, total=False): + Routes: Optional[TransitGatewayRouteList] + AdditionalRoutesAvailable: Optional[Boolean] + + +class SecurityGroupRuleDescription(TypedDict, total=False): + SecurityGroupRuleId: Optional[String] + Description: Optional[String] + + +SecurityGroupRuleDescriptionList = List[SecurityGroupRuleDescription] + + +class SendDiagnosticInterruptRequest(ServiceRequest): + InstanceId: InstanceId + DryRun: Optional[Boolean] + + +class StartDeclarativePoliciesReportRequest(ServiceRequest): + DryRun: Optional[Boolean] + S3Bucket: String + S3Prefix: Optional[String] + TargetId: String + TagSpecifications: Optional[TagSpecificationList] + + +class StartDeclarativePoliciesReportResult(TypedDict, total=False): + ReportId: Optional[String] + + +class StartInstancesRequest(ServiceRequest): + InstanceIds: InstanceIdStringList + AdditionalInfo: Optional[String] + DryRun: Optional[Boolean] + + +class StartInstancesResult(TypedDict, total=False): + StartingInstances: Optional[InstanceStateChangeList] + + +class StartNetworkInsightsAccessScopeAnalysisRequest(ServiceRequest): + NetworkInsightsAccessScopeId: NetworkInsightsAccessScopeId + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: String + + +class StartNetworkInsightsAccessScopeAnalysisResult(TypedDict, total=False): + NetworkInsightsAccessScopeAnalysis: Optional[NetworkInsightsAccessScopeAnalysis] + + +class StartNetworkInsightsAnalysisRequest(ServiceRequest): + NetworkInsightsPathId: NetworkInsightsPathId + AdditionalAccounts: Optional[ValueStringList] + FilterInArns: Optional[ArnList] + FilterOutArns: Optional[ArnList] + DryRun: Optional[Boolean] + TagSpecifications: Optional[TagSpecificationList] + ClientToken: String + + +class StartNetworkInsightsAnalysisResult(TypedDict, total=False): + NetworkInsightsAnalysis: Optional[NetworkInsightsAnalysis] + + +class StartVpcEndpointServicePrivateDnsVerificationRequest(ServiceRequest): + DryRun: Optional[Boolean] + ServiceId: VpcEndpointServiceId + + +class StartVpcEndpointServicePrivateDnsVerificationResult(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class StopInstancesRequest(ServiceRequest): + InstanceIds: InstanceIdStringList + Hibernate: Optional[Boolean] + DryRun: Optional[Boolean] + Force: Optional[Boolean] + + +class StopInstancesResult(TypedDict, total=False): + StoppingInstances: Optional[InstanceStateChangeList] + + +class TerminateClientVpnConnectionsRequest(ServiceRequest): + ClientVpnEndpointId: ClientVpnEndpointId + ConnectionId: Optional[String] + Username: Optional[String] + DryRun: Optional[Boolean] + + +class TerminateConnectionStatus(TypedDict, total=False): + ConnectionId: Optional[String] + PreviousStatus: Optional[ClientVpnConnectionStatus] + CurrentStatus: Optional[ClientVpnConnectionStatus] + + +TerminateConnectionStatusSet = List[TerminateConnectionStatus] + + +class TerminateClientVpnConnectionsResult(TypedDict, total=False): + ClientVpnEndpointId: Optional[String] + Username: Optional[String] + ConnectionStatuses: Optional[TerminateConnectionStatusSet] + + +class TerminateInstancesRequest(ServiceRequest): + InstanceIds: InstanceIdStringList + DryRun: Optional[Boolean] + + +class TerminateInstancesResult(TypedDict, total=False): + TerminatingInstances: Optional[InstanceStateChangeList] + + +class UnassignIpv6AddressesRequest(ServiceRequest): + Ipv6Prefixes: Optional[IpPrefixList] + NetworkInterfaceId: NetworkInterfaceId + Ipv6Addresses: Optional[Ipv6AddressList] + + +class UnassignIpv6AddressesResult(TypedDict, total=False): + NetworkInterfaceId: Optional[String] + UnassignedIpv6Addresses: Optional[Ipv6AddressList] + UnassignedIpv6Prefixes: Optional[IpPrefixList] + + +class UnassignPrivateIpAddressesRequest(ServiceRequest): + Ipv4Prefixes: Optional[IpPrefixList] + NetworkInterfaceId: NetworkInterfaceId + PrivateIpAddresses: Optional[PrivateIpAddressStringList] + + +class UnassignPrivateNatGatewayAddressRequest(ServiceRequest): + NatGatewayId: NatGatewayId + PrivateIpAddresses: IpList + MaxDrainDurationSeconds: Optional[DrainSeconds] + DryRun: Optional[Boolean] + + +class UnassignPrivateNatGatewayAddressResult(TypedDict, total=False): + NatGatewayId: Optional[NatGatewayId] + NatGatewayAddresses: Optional[NatGatewayAddressList] + + +class UnlockSnapshotRequest(ServiceRequest): + SnapshotId: SnapshotId + DryRun: Optional[Boolean] + + +class UnlockSnapshotResult(TypedDict, total=False): + SnapshotId: Optional[String] + + +class UnmonitorInstancesRequest(ServiceRequest): + InstanceIds: InstanceIdStringList + DryRun: Optional[Boolean] + + +class UnmonitorInstancesResult(TypedDict, total=False): + InstanceMonitorings: Optional[InstanceMonitoringList] + + +class UpdateSecurityGroupRuleDescriptionsEgressRequest(ServiceRequest): + DryRun: Optional[Boolean] + GroupId: Optional[SecurityGroupId] + GroupName: Optional[SecurityGroupName] + IpPermissions: Optional[IpPermissionList] + SecurityGroupRuleDescriptions: Optional[SecurityGroupRuleDescriptionList] + + +class UpdateSecurityGroupRuleDescriptionsEgressResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class UpdateSecurityGroupRuleDescriptionsIngressRequest(ServiceRequest): + DryRun: Optional[Boolean] + GroupId: Optional[SecurityGroupId] + GroupName: Optional[SecurityGroupName] + IpPermissions: Optional[IpPermissionList] + SecurityGroupRuleDescriptions: Optional[SecurityGroupRuleDescriptionList] + + +class UpdateSecurityGroupRuleDescriptionsIngressResult(TypedDict, total=False): + Return: Optional[Boolean] + + +class WithdrawByoipCidrRequest(ServiceRequest): + Cidr: String + DryRun: Optional[Boolean] + + +class WithdrawByoipCidrResult(TypedDict, total=False): + ByoipCidr: Optional[ByoipCidr] + + +class Ec2Api: + service = "ec2" + version = "2016-11-15" + + @handler("AcceptAddressTransfer") + def accept_address_transfer( + self, + context: RequestContext, + address: String, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AcceptAddressTransferResult: + raise NotImplementedError + + @handler("AcceptCapacityReservationBillingOwnership") + def accept_capacity_reservation_billing_ownership( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AcceptCapacityReservationBillingOwnershipResult: + raise NotImplementedError + + @handler("AcceptReservedInstancesExchangeQuote") + def accept_reserved_instances_exchange_quote( + self, + context: RequestContext, + reserved_instance_ids: ReservedInstanceIdSet, + dry_run: Boolean | None = None, + target_configurations: TargetConfigurationRequestSet | None = None, + **kwargs, + ) -> AcceptReservedInstancesExchangeQuoteResult: + raise NotImplementedError + + @handler("AcceptTransitGatewayMulticastDomainAssociations") + def accept_transit_gateway_multicast_domain_associations( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId | None = None, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + subnet_ids: ValueStringList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AcceptTransitGatewayMulticastDomainAssociationsResult: + raise NotImplementedError + + @handler("AcceptTransitGatewayPeeringAttachment") + def accept_transit_gateway_peering_attachment( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AcceptTransitGatewayPeeringAttachmentResult: + raise NotImplementedError + + @handler("AcceptTransitGatewayVpcAttachment") + def accept_transit_gateway_vpc_attachment( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AcceptTransitGatewayVpcAttachmentResult: + raise NotImplementedError + + @handler("AcceptVpcEndpointConnections") + def accept_vpc_endpoint_connections( + self, + context: RequestContext, + service_id: VpcEndpointServiceId, + vpc_endpoint_ids: VpcEndpointIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> AcceptVpcEndpointConnectionsResult: + raise NotImplementedError + + @handler("AcceptVpcPeeringConnection") + def accept_vpc_peering_connection( + self, + context: RequestContext, + vpc_peering_connection_id: VpcPeeringConnectionIdWithResolver, + dry_run: Boolean | None = None, + **kwargs, + ) -> AcceptVpcPeeringConnectionResult: + raise NotImplementedError + + @handler("AdvertiseByoipCidr") + def advertise_byoip_cidr( + self, + context: RequestContext, + cidr: String, + asn: String | None = None, + dry_run: Boolean | None = None, + network_border_group: String | None = None, + **kwargs, + ) -> AdvertiseByoipCidrResult: + raise NotImplementedError + + @handler("AllocateAddress") + def allocate_address( + self, + context: RequestContext, + domain: DomainType | None = None, + address: PublicIpAddress | None = None, + public_ipv4_pool: Ipv4PoolEc2Id | None = None, + network_border_group: String | None = None, + customer_owned_ipv4_pool: String | None = None, + tag_specifications: TagSpecificationList | None = None, + ipam_pool_id: IpamPoolId | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AllocateAddressResult: + raise NotImplementedError + + @handler("AllocateHosts") + def allocate_hosts( + self, + context: RequestContext, + instance_family: String | None = None, + tag_specifications: TagSpecificationList | None = None, + host_recovery: HostRecovery | None = None, + outpost_arn: String | None = None, + host_maintenance: HostMaintenance | None = None, + asset_ids: AssetIdList | None = None, + availability_zone_id: AvailabilityZoneId | None = None, + auto_placement: AutoPlacement | None = None, + client_token: String | None = None, + instance_type: String | None = None, + quantity: Integer | None = None, + availability_zone: String | None = None, + **kwargs, + ) -> AllocateHostsResult: + raise NotImplementedError + + @handler("AllocateIpamPoolCidr") + def allocate_ipam_pool_cidr( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + dry_run: Boolean | None = None, + cidr: String | None = None, + netmask_length: Integer | None = None, + client_token: String | None = None, + description: String | None = None, + preview_next_cidr: Boolean | None = None, + allowed_cidrs: IpamPoolAllocationAllowedCidrs | None = None, + disallowed_cidrs: IpamPoolAllocationDisallowedCidrs | None = None, + **kwargs, + ) -> AllocateIpamPoolCidrResult: + raise NotImplementedError + + @handler("ApplySecurityGroupsToClientVpnTargetNetwork") + def apply_security_groups_to_client_vpn_target_network( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + vpc_id: VpcId, + security_group_ids: ClientVpnSecurityGroupIdSet, + dry_run: Boolean | None = None, + **kwargs, + ) -> ApplySecurityGroupsToClientVpnTargetNetworkResult: + raise NotImplementedError + + @handler("AssignIpv6Addresses") + def assign_ipv6_addresses( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + ipv6_prefix_count: Integer | None = None, + ipv6_prefixes: IpPrefixList | None = None, + ipv6_addresses: Ipv6AddressList | None = None, + ipv6_address_count: Integer | None = None, + **kwargs, + ) -> AssignIpv6AddressesResult: + raise NotImplementedError + + @handler("AssignPrivateIpAddresses") + def assign_private_ip_addresses( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + ipv4_prefixes: IpPrefixList | None = None, + ipv4_prefix_count: Integer | None = None, + private_ip_addresses: PrivateIpAddressStringList | None = None, + secondary_private_ip_address_count: Integer | None = None, + allow_reassignment: Boolean | None = None, + **kwargs, + ) -> AssignPrivateIpAddressesResult: + raise NotImplementedError + + @handler("AssignPrivateNatGatewayAddress") + def assign_private_nat_gateway_address( + self, + context: RequestContext, + nat_gateway_id: NatGatewayId, + private_ip_addresses: IpList | None = None, + private_ip_address_count: PrivateIpAddressCount | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssignPrivateNatGatewayAddressResult: + raise NotImplementedError + + @handler("AssociateAddress") + def associate_address( + self, + context: RequestContext, + allocation_id: AllocationId | None = None, + instance_id: InstanceId | None = None, + public_ip: EipAllocationPublicIp | None = None, + dry_run: Boolean | None = None, + network_interface_id: NetworkInterfaceId | None = None, + private_ip_address: String | None = None, + allow_reassociation: Boolean | None = None, + **kwargs, + ) -> AssociateAddressResult: + raise NotImplementedError + + @handler("AssociateCapacityReservationBillingOwner") + def associate_capacity_reservation_billing_owner( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + unused_reservation_billing_owner_id: AccountID, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateCapacityReservationBillingOwnerResult: + raise NotImplementedError + + @handler("AssociateClientVpnTargetNetwork") + def associate_client_vpn_target_network( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + subnet_id: SubnetId, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateClientVpnTargetNetworkResult: + raise NotImplementedError + + @handler("AssociateDhcpOptions") + def associate_dhcp_options( + self, + context: RequestContext, + dhcp_options_id: DefaultingDhcpOptionsId, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("AssociateEnclaveCertificateIamRole") + def associate_enclave_certificate_iam_role( + self, + context: RequestContext, + certificate_arn: CertificateId, + role_arn: RoleId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateEnclaveCertificateIamRoleResult: + raise NotImplementedError + + @handler("AssociateIamInstanceProfile") + def associate_iam_instance_profile( + self, + context: RequestContext, + iam_instance_profile: IamInstanceProfileSpecification, + instance_id: InstanceId, + **kwargs, + ) -> AssociateIamInstanceProfileResult: + raise NotImplementedError + + @handler("AssociateInstanceEventWindow") + def associate_instance_event_window( + self, + context: RequestContext, + instance_event_window_id: InstanceEventWindowId, + association_target: InstanceEventWindowAssociationRequest, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateInstanceEventWindowResult: + raise NotImplementedError + + @handler("AssociateIpamByoasn") + def associate_ipam_byoasn( + self, + context: RequestContext, + asn: String, + cidr: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateIpamByoasnResult: + raise NotImplementedError + + @handler("AssociateIpamResourceDiscovery") + def associate_ipam_resource_discovery( + self, + context: RequestContext, + ipam_id: IpamId, + ipam_resource_discovery_id: IpamResourceDiscoveryId, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + **kwargs, + ) -> AssociateIpamResourceDiscoveryResult: + raise NotImplementedError + + @handler("AssociateNatGatewayAddress") + def associate_nat_gateway_address( + self, + context: RequestContext, + nat_gateway_id: NatGatewayId, + allocation_ids: AllocationIdList, + private_ip_addresses: IpList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateNatGatewayAddressResult: + raise NotImplementedError + + @handler("AssociateRouteServer") + def associate_route_server( + self, + context: RequestContext, + route_server_id: RouteServerId, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateRouteServerResult: + raise NotImplementedError + + @handler("AssociateRouteTable") + def associate_route_table( + self, + context: RequestContext, + route_table_id: RouteTableId, + gateway_id: RouteGatewayId | None = None, + dry_run: Boolean | None = None, + subnet_id: SubnetId | None = None, + **kwargs, + ) -> AssociateRouteTableResult: + raise NotImplementedError + + @handler("AssociateSecurityGroupVpc") + def associate_security_group_vpc( + self, + context: RequestContext, + group_id: SecurityGroupId, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateSecurityGroupVpcResult: + raise NotImplementedError + + @handler("AssociateSubnetCidrBlock") + def associate_subnet_cidr_block( + self, + context: RequestContext, + subnet_id: SubnetId, + ipv6_ipam_pool_id: IpamPoolId | None = None, + ipv6_netmask_length: NetmaskLength | None = None, + ipv6_cidr_block: String | None = None, + **kwargs, + ) -> AssociateSubnetCidrBlockResult: + raise NotImplementedError + + @handler("AssociateTransitGatewayMulticastDomain") + def associate_transit_gateway_multicast_domain( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + subnet_ids: TransitGatewaySubnetIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateTransitGatewayMulticastDomainResult: + raise NotImplementedError + + @handler("AssociateTransitGatewayPolicyTable") + def associate_transit_gateway_policy_table( + self, + context: RequestContext, + transit_gateway_policy_table_id: TransitGatewayPolicyTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateTransitGatewayPolicyTableResult: + raise NotImplementedError + + @handler("AssociateTransitGatewayRouteTable") + def associate_transit_gateway_route_table( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateTransitGatewayRouteTableResult: + raise NotImplementedError + + @handler("AssociateTrunkInterface") + def associate_trunk_interface( + self, + context: RequestContext, + branch_interface_id: NetworkInterfaceId, + trunk_interface_id: NetworkInterfaceId, + vlan_id: Integer | None = None, + gre_key: Integer | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AssociateTrunkInterfaceResult: + raise NotImplementedError + + @handler("AssociateVpcCidrBlock") + def associate_vpc_cidr_block( + self, + context: RequestContext, + vpc_id: VpcId, + cidr_block: String | None = None, + ipv6_cidr_block_network_border_group: String | None = None, + ipv6_pool: Ipv6PoolEc2Id | None = None, + ipv6_cidr_block: String | None = None, + ipv4_ipam_pool_id: IpamPoolId | None = None, + ipv4_netmask_length: NetmaskLength | None = None, + ipv6_ipam_pool_id: IpamPoolId | None = None, + ipv6_netmask_length: NetmaskLength | None = None, + amazon_provided_ipv6_cidr_block: Boolean | None = None, + **kwargs, + ) -> AssociateVpcCidrBlockResult: + raise NotImplementedError + + @handler("AttachClassicLinkVpc") + def attach_classic_link_vpc( + self, + context: RequestContext, + instance_id: InstanceId, + vpc_id: VpcId, + groups: GroupIdStringList, + dry_run: Boolean | None = None, + **kwargs, + ) -> AttachClassicLinkVpcResult: + raise NotImplementedError + + @handler("AttachInternetGateway") + def attach_internet_gateway( + self, + context: RequestContext, + internet_gateway_id: InternetGatewayId, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("AttachNetworkInterface") + def attach_network_interface( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + instance_id: InstanceId, + device_index: Integer, + network_card_index: Integer | None = None, + ena_srd_specification: EnaSrdSpecification | None = None, + ena_queue_count: Integer | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AttachNetworkInterfaceResult: + raise NotImplementedError + + @handler("AttachVerifiedAccessTrustProvider") + def attach_verified_access_trust_provider( + self, + context: RequestContext, + verified_access_instance_id: VerifiedAccessInstanceId, + verified_access_trust_provider_id: VerifiedAccessTrustProviderId, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AttachVerifiedAccessTrustProviderResult: + raise NotImplementedError + + @handler("AttachVolume") + def attach_volume( + self, + context: RequestContext, + device: String, + instance_id: InstanceId, + volume_id: VolumeId, + dry_run: Boolean | None = None, + **kwargs, + ) -> VolumeAttachment: + raise NotImplementedError + + @handler("AttachVpnGateway") + def attach_vpn_gateway( + self, + context: RequestContext, + vpc_id: VpcId, + vpn_gateway_id: VpnGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> AttachVpnGatewayResult: + raise NotImplementedError + + @handler("AuthorizeClientVpnIngress") + def authorize_client_vpn_ingress( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + target_network_cidr: String, + access_group_id: String | None = None, + authorize_all_groups: Boolean | None = None, + description: String | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AuthorizeClientVpnIngressResult: + raise NotImplementedError + + @handler("AuthorizeSecurityGroupEgress") + def authorize_security_group_egress( + self, + context: RequestContext, + group_id: SecurityGroupId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + source_security_group_name: String | None = None, + source_security_group_owner_id: String | None = None, + ip_protocol: String | None = None, + from_port: Integer | None = None, + to_port: Integer | None = None, + cidr_ip: String | None = None, + ip_permissions: IpPermissionList | None = None, + **kwargs, + ) -> AuthorizeSecurityGroupEgressResult: + raise NotImplementedError + + @handler("AuthorizeSecurityGroupIngress") + def authorize_security_group_ingress( + self, + context: RequestContext, + cidr_ip: String | None = None, + from_port: Integer | None = None, + group_id: SecurityGroupId | None = None, + group_name: SecurityGroupName | None = None, + ip_permissions: IpPermissionList | None = None, + ip_protocol: String | None = None, + source_security_group_name: String | None = None, + source_security_group_owner_id: String | None = None, + to_port: Integer | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> AuthorizeSecurityGroupIngressResult: + raise NotImplementedError + + @handler("BundleInstance") + def bundle_instance( + self, + context: RequestContext, + instance_id: InstanceId, + storage: Storage, + dry_run: Boolean | None = None, + **kwargs, + ) -> BundleInstanceResult: + raise NotImplementedError + + @handler("CancelBundleTask") + def cancel_bundle_task( + self, context: RequestContext, bundle_id: BundleId, dry_run: Boolean | None = None, **kwargs + ) -> CancelBundleTaskResult: + raise NotImplementedError + + @handler("CancelCapacityReservation") + def cancel_capacity_reservation( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> CancelCapacityReservationResult: + raise NotImplementedError + + @handler("CancelCapacityReservationFleets") + def cancel_capacity_reservation_fleets( + self, + context: RequestContext, + capacity_reservation_fleet_ids: CapacityReservationFleetIdSet, + dry_run: Boolean | None = None, + **kwargs, + ) -> CancelCapacityReservationFleetsResult: + raise NotImplementedError + + @handler("CancelConversionTask") + def cancel_conversion_task( + self, + context: RequestContext, + conversion_task_id: ConversionTaskId, + dry_run: Boolean | None = None, + reason_message: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CancelDeclarativePoliciesReport") + def cancel_declarative_policies_report( + self, + context: RequestContext, + report_id: DeclarativePoliciesReportId, + dry_run: Boolean | None = None, + **kwargs, + ) -> CancelDeclarativePoliciesReportResult: + raise NotImplementedError + + @handler("CancelExportTask") + def cancel_export_task( + self, context: RequestContext, export_task_id: ExportVmTaskId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("CancelImageLaunchPermission") + def cancel_image_launch_permission( + self, context: RequestContext, image_id: ImageId, dry_run: Boolean | None = None, **kwargs + ) -> CancelImageLaunchPermissionResult: + raise NotImplementedError + + @handler("CancelImportTask") + def cancel_import_task( + self, + context: RequestContext, + cancel_reason: String | None = None, + dry_run: Boolean | None = None, + import_task_id: ImportTaskId | None = None, + **kwargs, + ) -> CancelImportTaskResult: + raise NotImplementedError + + @handler("CancelReservedInstancesListing") + def cancel_reserved_instances_listing( + self, + context: RequestContext, + reserved_instances_listing_id: ReservedInstancesListingId, + **kwargs, + ) -> CancelReservedInstancesListingResult: + raise NotImplementedError + + @handler("CancelSpotFleetRequests") + def cancel_spot_fleet_requests( + self, + context: RequestContext, + spot_fleet_request_ids: SpotFleetRequestIdList, + terminate_instances: Boolean, + dry_run: Boolean | None = None, + **kwargs, + ) -> CancelSpotFleetRequestsResponse: + raise NotImplementedError + + @handler("CancelSpotInstanceRequests") + def cancel_spot_instance_requests( + self, + context: RequestContext, + spot_instance_request_ids: SpotInstanceRequestIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> CancelSpotInstanceRequestsResult: + raise NotImplementedError + + @handler("ConfirmProductInstance") + def confirm_product_instance( + self, + context: RequestContext, + instance_id: InstanceId, + product_code: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> ConfirmProductInstanceResult: + raise NotImplementedError + + @handler("CopyFpgaImage") + def copy_fpga_image( + self, + context: RequestContext, + source_fpga_image_id: String, + source_region: String, + dry_run: Boolean | None = None, + description: String | None = None, + name: String | None = None, + client_token: String | None = None, + **kwargs, + ) -> CopyFpgaImageResult: + raise NotImplementedError + + @handler("CopyImage") + def copy_image( + self, + context: RequestContext, + name: String, + source_image_id: String, + source_region: String, + client_token: String | None = None, + description: String | None = None, + encrypted: Boolean | None = None, + kms_key_id: KmsKeyId | None = None, + destination_outpost_arn: String | None = None, + copy_image_tags: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + snapshot_copy_completion_duration_minutes: Long | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CopyImageResult: + raise NotImplementedError + + @handler("CopySnapshot") + def copy_snapshot( + self, + context: RequestContext, + source_region: String, + source_snapshot_id: String, + description: String | None = None, + destination_outpost_arn: String | None = None, + destination_region: String | None = None, + encrypted: Boolean | None = None, + kms_key_id: KmsKeyId | None = None, + presigned_url: CopySnapshotRequestPSU | None = None, + tag_specifications: TagSpecificationList | None = None, + completion_duration_minutes: SnapshotCompletionDurationMinutesRequest | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CopySnapshotResult: + raise NotImplementedError + + @handler("CreateCapacityReservation") + def create_capacity_reservation( + self, + context: RequestContext, + instance_type: String, + instance_platform: CapacityReservationInstancePlatform, + instance_count: Integer, + client_token: String | None = None, + availability_zone: AvailabilityZoneName | None = None, + availability_zone_id: AvailabilityZoneId | None = None, + tenancy: CapacityReservationTenancy | None = None, + ebs_optimized: Boolean | None = None, + ephemeral_storage: Boolean | None = None, + end_date: DateTime | None = None, + end_date_type: EndDateType | None = None, + instance_match_criteria: InstanceMatchCriteria | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + outpost_arn: OutpostArn | None = None, + placement_group_arn: PlacementGroupArn | None = None, + start_date: MillisecondDateTime | None = None, + commitment_duration: CapacityReservationCommitmentDuration | None = None, + delivery_preference: CapacityReservationDeliveryPreference | None = None, + **kwargs, + ) -> CreateCapacityReservationResult: + raise NotImplementedError + + @handler("CreateCapacityReservationBySplitting") + def create_capacity_reservation_by_splitting( + self, + context: RequestContext, + source_capacity_reservation_id: CapacityReservationId, + instance_count: Integer, + dry_run: Boolean | None = None, + client_token: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateCapacityReservationBySplittingResult: + raise NotImplementedError + + @handler("CreateCapacityReservationFleet") + def create_capacity_reservation_fleet( + self, + context: RequestContext, + instance_type_specifications: ReservationFleetInstanceSpecificationList, + total_target_capacity: Integer, + allocation_strategy: String | None = None, + client_token: String | None = None, + tenancy: FleetCapacityReservationTenancy | None = None, + end_date: MillisecondDateTime | None = None, + instance_match_criteria: FleetInstanceMatchCriteria | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateCapacityReservationFleetResult: + raise NotImplementedError + + @handler("CreateCarrierGateway") + def create_carrier_gateway( + self, + context: RequestContext, + vpc_id: VpcId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateCarrierGatewayResult: + raise NotImplementedError + + @handler("CreateClientVpnEndpoint") + def create_client_vpn_endpoint( + self, + context: RequestContext, + client_cidr_block: String, + server_certificate_arn: String, + authentication_options: ClientVpnAuthenticationRequestList, + connection_log_options: ConnectionLogOptions, + dns_servers: ValueStringList | None = None, + transport_protocol: TransportProtocol | None = None, + vpn_port: Integer | None = None, + description: String | None = None, + split_tunnel: Boolean | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + tag_specifications: TagSpecificationList | None = None, + security_group_ids: ClientVpnSecurityGroupIdSet | None = None, + vpc_id: VpcId | None = None, + self_service_portal: SelfServicePortal | None = None, + client_connect_options: ClientConnectOptions | None = None, + session_timeout_hours: Integer | None = None, + client_login_banner_options: ClientLoginBannerOptions | None = None, + client_route_enforcement_options: ClientRouteEnforcementOptions | None = None, + disconnect_on_session_timeout: Boolean | None = None, + **kwargs, + ) -> CreateClientVpnEndpointResult: + raise NotImplementedError + + @handler("CreateClientVpnRoute") + def create_client_vpn_route( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + destination_cidr_block: String, + target_vpc_subnet_id: SubnetId, + description: String | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateClientVpnRouteResult: + raise NotImplementedError + + @handler("CreateCoipCidr") + def create_coip_cidr( + self, + context: RequestContext, + cidr: String, + coip_pool_id: Ipv4PoolCoipId, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateCoipCidrResult: + raise NotImplementedError + + @handler("CreateCoipPool") + def create_coip_pool( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateCoipPoolResult: + raise NotImplementedError + + @handler("CreateCustomerGateway", expand=False) + def create_customer_gateway( + self, context: RequestContext, request: CreateCustomerGatewayRequest, **kwargs + ) -> CreateCustomerGatewayResult: + raise NotImplementedError + + @handler("CreateDefaultSubnet") + def create_default_subnet( + self, + context: RequestContext, + availability_zone: AvailabilityZoneName, + dry_run: Boolean | None = None, + ipv6_native: Boolean | None = None, + **kwargs, + ) -> CreateDefaultSubnetResult: + raise NotImplementedError + + @handler("CreateDefaultVpc") + def create_default_vpc( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> CreateDefaultVpcResult: + raise NotImplementedError + + @handler("CreateDelegateMacVolumeOwnershipTask") + def create_delegate_mac_volume_ownership_task( + self, + context: RequestContext, + instance_id: InstanceId, + mac_credentials: SensitiveMacCredentials, + client_token: String | None = None, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateDelegateMacVolumeOwnershipTaskResult: + raise NotImplementedError + + @handler("CreateDhcpOptions") + def create_dhcp_options( + self, + context: RequestContext, + dhcp_configurations: NewDhcpConfigurationList, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateDhcpOptionsResult: + raise NotImplementedError + + @handler("CreateEgressOnlyInternetGateway") + def create_egress_only_internet_gateway( + self, + context: RequestContext, + vpc_id: VpcId, + client_token: String | None = None, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateEgressOnlyInternetGatewayResult: + raise NotImplementedError + + @handler("CreateFleet", expand=False) + def create_fleet( + self, context: RequestContext, request: CreateFleetRequest, **kwargs + ) -> CreateFleetResult: + raise NotImplementedError + + @handler("CreateFlowLogs") + def create_flow_logs( + self, + context: RequestContext, + resource_ids: FlowLogResourceIds, + resource_type: FlowLogsResourceType, + dry_run: Boolean | None = None, + client_token: String | None = None, + deliver_logs_permission_arn: String | None = None, + deliver_cross_account_role: String | None = None, + log_group_name: String | None = None, + traffic_type: TrafficType | None = None, + log_destination_type: LogDestinationType | None = None, + log_destination: String | None = None, + log_format: String | None = None, + tag_specifications: TagSpecificationList | None = None, + max_aggregation_interval: Integer | None = None, + destination_options: DestinationOptionsRequest | None = None, + **kwargs, + ) -> CreateFlowLogsResult: + raise NotImplementedError + + @handler("CreateFpgaImage") + def create_fpga_image( + self, + context: RequestContext, + input_storage_location: StorageLocation, + dry_run: Boolean | None = None, + logs_storage_location: StorageLocation | None = None, + description: String | None = None, + name: String | None = None, + client_token: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateFpgaImageResult: + raise NotImplementedError + + @handler("CreateImage") + def create_image( + self, + context: RequestContext, + instance_id: InstanceId, + name: String, + tag_specifications: TagSpecificationList | None = None, + snapshot_location: SnapshotLocationEnum | None = None, + dry_run: Boolean | None = None, + description: String | None = None, + no_reboot: Boolean | None = None, + block_device_mappings: BlockDeviceMappingRequestList | None = None, + **kwargs, + ) -> CreateImageResult: + raise NotImplementedError + + @handler("CreateInstanceConnectEndpoint") + def create_instance_connect_endpoint( + self, + context: RequestContext, + subnet_id: SubnetId, + dry_run: Boolean | None = None, + security_group_ids: SecurityGroupIdStringListRequest | None = None, + preserve_client_ip: Boolean | None = None, + client_token: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateInstanceConnectEndpointResult: + raise NotImplementedError + + @handler("CreateInstanceEventWindow") + def create_instance_event_window( + self, + context: RequestContext, + dry_run: Boolean | None = None, + name: String | None = None, + time_ranges: InstanceEventWindowTimeRangeRequestSet | None = None, + cron_expression: InstanceEventWindowCronExpression | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateInstanceEventWindowResult: + raise NotImplementedError + + @handler("CreateInstanceExportTask") + def create_instance_export_task( + self, + context: RequestContext, + instance_id: InstanceId, + target_environment: ExportEnvironment, + export_to_s3_task: ExportToS3TaskSpecification, + tag_specifications: TagSpecificationList | None = None, + description: String | None = None, + **kwargs, + ) -> CreateInstanceExportTaskResult: + raise NotImplementedError + + @handler("CreateInternetGateway") + def create_internet_gateway( + self, + context: RequestContext, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateInternetGatewayResult: + raise NotImplementedError + + @handler("CreateIpam") + def create_ipam( + self, + context: RequestContext, + dry_run: Boolean | None = None, + description: String | None = None, + operating_regions: AddIpamOperatingRegionSet | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + tier: IpamTier | None = None, + enable_private_gua: Boolean | None = None, + metered_account: IpamMeteredAccount | None = None, + **kwargs, + ) -> CreateIpamResult: + raise NotImplementedError + + @handler("CreateIpamExternalResourceVerificationToken") + def create_ipam_external_resource_verification_token( + self, + context: RequestContext, + ipam_id: IpamId, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateIpamExternalResourceVerificationTokenResult: + raise NotImplementedError + + @handler("CreateIpamPool") + def create_ipam_pool( + self, + context: RequestContext, + ipam_scope_id: IpamScopeId, + address_family: AddressFamily, + dry_run: Boolean | None = None, + locale: String | None = None, + source_ipam_pool_id: IpamPoolId | None = None, + description: String | None = None, + auto_import: Boolean | None = None, + publicly_advertisable: Boolean | None = None, + allocation_min_netmask_length: IpamNetmaskLength | None = None, + allocation_max_netmask_length: IpamNetmaskLength | None = None, + allocation_default_netmask_length: IpamNetmaskLength | None = None, + allocation_resource_tags: RequestIpamResourceTagList | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + aws_service: IpamPoolAwsService | None = None, + public_ip_source: IpamPoolPublicIpSource | None = None, + source_resource: IpamPoolSourceResourceRequest | None = None, + **kwargs, + ) -> CreateIpamPoolResult: + raise NotImplementedError + + @handler("CreateIpamResourceDiscovery") + def create_ipam_resource_discovery( + self, + context: RequestContext, + dry_run: Boolean | None = None, + description: String | None = None, + operating_regions: AddIpamOperatingRegionSet | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateIpamResourceDiscoveryResult: + raise NotImplementedError + + @handler("CreateIpamScope") + def create_ipam_scope( + self, + context: RequestContext, + ipam_id: IpamId, + dry_run: Boolean | None = None, + description: String | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateIpamScopeResult: + raise NotImplementedError + + @handler("CreateKeyPair") + def create_key_pair( + self, + context: RequestContext, + key_name: String, + key_type: KeyType | None = None, + tag_specifications: TagSpecificationList | None = None, + key_format: KeyFormat | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> KeyPair: + raise NotImplementedError + + @handler("CreateLaunchTemplate") + def create_launch_template( + self, + context: RequestContext, + launch_template_name: LaunchTemplateName, + launch_template_data: RequestLaunchTemplateData, + dry_run: Boolean | None = None, + client_token: String | None = None, + version_description: VersionDescription | None = None, + operator: OperatorRequest | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateLaunchTemplateResult: + raise NotImplementedError + + @handler("CreateLaunchTemplateVersion") + def create_launch_template_version( + self, + context: RequestContext, + launch_template_data: RequestLaunchTemplateData, + dry_run: Boolean | None = None, + client_token: String | None = None, + launch_template_id: LaunchTemplateId | None = None, + launch_template_name: LaunchTemplateName | None = None, + source_version: String | None = None, + version_description: VersionDescription | None = None, + resolve_alias: Boolean | None = None, + **kwargs, + ) -> CreateLaunchTemplateVersionResult: + raise NotImplementedError + + @handler("CreateLocalGatewayRoute") + def create_local_gateway_route( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + destination_cidr_block: String | None = None, + local_gateway_virtual_interface_group_id: LocalGatewayVirtualInterfaceGroupId | None = None, + dry_run: Boolean | None = None, + network_interface_id: NetworkInterfaceId | None = None, + destination_prefix_list_id: PrefixListResourceId | None = None, + **kwargs, + ) -> CreateLocalGatewayRouteResult: + raise NotImplementedError + + @handler("CreateLocalGatewayRouteTable") + def create_local_gateway_route_table( + self, + context: RequestContext, + local_gateway_id: LocalGatewayId, + mode: LocalGatewayRouteTableMode | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateLocalGatewayRouteTableResult: + raise NotImplementedError + + @handler("CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociation") + def create_local_gateway_route_table_virtual_interface_group_association( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + local_gateway_virtual_interface_group_id: LocalGatewayVirtualInterfaceGroupId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociationResult: + raise NotImplementedError + + @handler("CreateLocalGatewayRouteTableVpcAssociation") + def create_local_gateway_route_table_vpc_association( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + vpc_id: VpcId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateLocalGatewayRouteTableVpcAssociationResult: + raise NotImplementedError + + @handler("CreateLocalGatewayVirtualInterface") + def create_local_gateway_virtual_interface( + self, + context: RequestContext, + local_gateway_virtual_interface_group_id: LocalGatewayVirtualInterfaceGroupId, + outpost_lag_id: OutpostLagId, + vlan: Integer, + local_address: String, + peer_address: String, + peer_bgp_asn: Integer | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + peer_bgp_asn_extended: Long | None = None, + **kwargs, + ) -> CreateLocalGatewayVirtualInterfaceResult: + raise NotImplementedError + + @handler("CreateLocalGatewayVirtualInterfaceGroup") + def create_local_gateway_virtual_interface_group( + self, + context: RequestContext, + local_gateway_id: LocalGatewayId, + local_bgp_asn: Integer | None = None, + local_bgp_asn_extended: Long | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateLocalGatewayVirtualInterfaceGroupResult: + raise NotImplementedError + + @handler("CreateMacSystemIntegrityProtectionModificationTask") + def create_mac_system_integrity_protection_modification_task( + self, + context: RequestContext, + instance_id: InstanceId, + mac_system_integrity_protection_status: MacSystemIntegrityProtectionSettingStatus, + client_token: String | None = None, + dry_run: Boolean | None = None, + mac_credentials: SensitiveMacCredentials | None = None, + mac_system_integrity_protection_configuration: MacSystemIntegrityProtectionConfigurationRequest + | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateMacSystemIntegrityProtectionModificationTaskResult: + raise NotImplementedError + + @handler("CreateManagedPrefixList") + def create_managed_prefix_list( + self, + context: RequestContext, + prefix_list_name: String, + max_entries: Integer, + address_family: String, + dry_run: Boolean | None = None, + entries: AddPrefixListEntries | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateManagedPrefixListResult: + raise NotImplementedError + + @handler("CreateNatGateway") + def create_nat_gateway( + self, + context: RequestContext, + subnet_id: SubnetId, + allocation_id: AllocationId | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + connectivity_type: ConnectivityType | None = None, + private_ip_address: String | None = None, + secondary_allocation_ids: AllocationIdList | None = None, + secondary_private_ip_addresses: IpList | None = None, + secondary_private_ip_address_count: PrivateIpAddressCount | None = None, + **kwargs, + ) -> CreateNatGatewayResult: + raise NotImplementedError + + @handler("CreateNetworkAcl") + def create_network_acl( + self, + context: RequestContext, + vpc_id: VpcId, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateNetworkAclResult: + raise NotImplementedError + + @handler("CreateNetworkAclEntry") + def create_network_acl_entry( + self, + context: RequestContext, + network_acl_id: NetworkAclId, + rule_number: Integer, + protocol: String, + rule_action: RuleAction, + egress: Boolean, + dry_run: Boolean | None = None, + cidr_block: String | None = None, + ipv6_cidr_block: String | None = None, + icmp_type_code: IcmpTypeCode | None = None, + port_range: PortRange | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateNetworkInsightsAccessScope") + def create_network_insights_access_scope( + self, + context: RequestContext, + client_token: String, + match_paths: AccessScopePathListRequest | None = None, + exclude_paths: AccessScopePathListRequest | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateNetworkInsightsAccessScopeResult: + raise NotImplementedError + + @handler("CreateNetworkInsightsPath") + def create_network_insights_path( + self, + context: RequestContext, + source: NetworkInsightsResourceId, + protocol: Protocol, + client_token: String, + source_ip: IpAddress | None = None, + destination_ip: IpAddress | None = None, + destination: NetworkInsightsResourceId | None = None, + destination_port: Port | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + filter_at_source: PathRequestFilter | None = None, + filter_at_destination: PathRequestFilter | None = None, + **kwargs, + ) -> CreateNetworkInsightsPathResult: + raise NotImplementedError + + @handler("CreateNetworkInterface") + def create_network_interface( + self, + context: RequestContext, + subnet_id: SubnetId, + ipv4_prefixes: Ipv4PrefixList | None = None, + ipv4_prefix_count: Integer | None = None, + ipv6_prefixes: Ipv6PrefixList | None = None, + ipv6_prefix_count: Integer | None = None, + interface_type: NetworkInterfaceCreationType | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + enable_primary_ipv6: Boolean | None = None, + connection_tracking_specification: ConnectionTrackingSpecificationRequest | None = None, + operator: OperatorRequest | None = None, + description: String | None = None, + private_ip_address: String | None = None, + groups: SecurityGroupIdStringList | None = None, + private_ip_addresses: PrivateIpAddressSpecificationList | None = None, + secondary_private_ip_address_count: Integer | None = None, + ipv6_addresses: InstanceIpv6AddressList | None = None, + ipv6_address_count: Integer | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateNetworkInterfaceResult: + raise NotImplementedError + + @handler("CreateNetworkInterfacePermission") + def create_network_interface_permission( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + permission: InterfacePermissionType, + aws_account_id: String | None = None, + aws_service: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateNetworkInterfacePermissionResult: + raise NotImplementedError + + @handler("CreatePlacementGroup") + def create_placement_group( + self, + context: RequestContext, + partition_count: Integer | None = None, + tag_specifications: TagSpecificationList | None = None, + spread_level: SpreadLevel | None = None, + dry_run: Boolean | None = None, + group_name: String | None = None, + strategy: PlacementStrategy | None = None, + **kwargs, + ) -> CreatePlacementGroupResult: + raise NotImplementedError + + @handler("CreatePublicIpv4Pool") + def create_public_ipv4_pool( + self, + context: RequestContext, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + network_border_group: String | None = None, + **kwargs, + ) -> CreatePublicIpv4PoolResult: + raise NotImplementedError + + @handler("CreateReplaceRootVolumeTask") + def create_replace_root_volume_task( + self, + context: RequestContext, + instance_id: InstanceId, + snapshot_id: SnapshotId | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + image_id: ImageId | None = None, + delete_replaced_root_volume: Boolean | None = None, + volume_initialization_rate: Long | None = None, + **kwargs, + ) -> CreateReplaceRootVolumeTaskResult: + raise NotImplementedError + + @handler("CreateReservedInstancesListing") + def create_reserved_instances_listing( + self, + context: RequestContext, + reserved_instances_id: ReservationId, + instance_count: Integer, + price_schedules: PriceScheduleSpecificationList, + client_token: String, + **kwargs, + ) -> CreateReservedInstancesListingResult: + raise NotImplementedError + + @handler("CreateRestoreImageTask") + def create_restore_image_task( + self, + context: RequestContext, + bucket: String, + object_key: String, + name: String | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateRestoreImageTaskResult: + raise NotImplementedError + + @handler("CreateRoute") + def create_route( + self, + context: RequestContext, + route_table_id: RouteTableId, + destination_prefix_list_id: PrefixListResourceId | None = None, + vpc_endpoint_id: VpcEndpointId | None = None, + transit_gateway_id: TransitGatewayId | None = None, + local_gateway_id: LocalGatewayId | None = None, + carrier_gateway_id: CarrierGatewayId | None = None, + core_network_arn: CoreNetworkArn | None = None, + odb_network_arn: OdbNetworkArn | None = None, + dry_run: Boolean | None = None, + destination_cidr_block: String | None = None, + gateway_id: RouteGatewayId | None = None, + destination_ipv6_cidr_block: String | None = None, + egress_only_internet_gateway_id: EgressOnlyInternetGatewayId | None = None, + instance_id: InstanceId | None = None, + network_interface_id: NetworkInterfaceId | None = None, + vpc_peering_connection_id: VpcPeeringConnectionId | None = None, + nat_gateway_id: NatGatewayId | None = None, + **kwargs, + ) -> CreateRouteResult: + raise NotImplementedError + + @handler("CreateRouteServer") + def create_route_server( + self, + context: RequestContext, + amazon_side_asn: Long, + client_token: String | None = None, + dry_run: Boolean | None = None, + persist_routes: RouteServerPersistRoutesAction | None = None, + persist_routes_duration: BoxedLong | None = None, + sns_notifications_enabled: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateRouteServerResult: + raise NotImplementedError + + @handler("CreateRouteServerEndpoint") + def create_route_server_endpoint( + self, + context: RequestContext, + route_server_id: RouteServerId, + subnet_id: SubnetId, + client_token: String | None = None, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateRouteServerEndpointResult: + raise NotImplementedError + + @handler("CreateRouteServerPeer") + def create_route_server_peer( + self, + context: RequestContext, + route_server_endpoint_id: RouteServerEndpointId, + peer_address: String, + bgp_options: RouteServerBgpOptionsRequest, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateRouteServerPeerResult: + raise NotImplementedError + + @handler("CreateRouteTable") + def create_route_table( + self, + context: RequestContext, + vpc_id: VpcId, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateRouteTableResult: + raise NotImplementedError + + @handler("CreateSecurityGroup") + def create_security_group( + self, + context: RequestContext, + description: String, + group_name: String, + vpc_id: VpcId | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateSecurityGroupResult: + raise NotImplementedError + + @handler("CreateSnapshot") + def create_snapshot( + self, + context: RequestContext, + volume_id: VolumeId, + description: String | None = None, + outpost_arn: String | None = None, + tag_specifications: TagSpecificationList | None = None, + location: SnapshotLocationEnum | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> Snapshot: + raise NotImplementedError + + @handler("CreateSnapshots") + def create_snapshots( + self, + context: RequestContext, + instance_specification: InstanceSpecification, + description: String | None = None, + outpost_arn: String | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + copy_tags_from_source: CopyTagsFromSource | None = None, + location: SnapshotLocationEnum | None = None, + **kwargs, + ) -> CreateSnapshotsResult: + raise NotImplementedError + + @handler("CreateSpotDatafeedSubscription") + def create_spot_datafeed_subscription( + self, + context: RequestContext, + bucket: String, + dry_run: Boolean | None = None, + prefix: String | None = None, + **kwargs, + ) -> CreateSpotDatafeedSubscriptionResult: + raise NotImplementedError + + @handler("CreateStoreImageTask") + def create_store_image_task( + self, + context: RequestContext, + image_id: ImageId, + bucket: String, + s3_object_tags: S3ObjectTagList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateStoreImageTaskResult: + raise NotImplementedError + + @handler("CreateSubnet") + def create_subnet( + self, + context: RequestContext, + vpc_id: VpcId, + tag_specifications: TagSpecificationList | None = None, + availability_zone: String | None = None, + availability_zone_id: String | None = None, + cidr_block: String | None = None, + ipv6_cidr_block: String | None = None, + outpost_arn: String | None = None, + ipv6_native: Boolean | None = None, + ipv4_ipam_pool_id: IpamPoolId | None = None, + ipv4_netmask_length: NetmaskLength | None = None, + ipv6_ipam_pool_id: IpamPoolId | None = None, + ipv6_netmask_length: NetmaskLength | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateSubnetResult: + raise NotImplementedError + + @handler("CreateSubnetCidrReservation") + def create_subnet_cidr_reservation( + self, + context: RequestContext, + subnet_id: SubnetId, + cidr: String, + reservation_type: SubnetCidrReservationType, + description: String | None = None, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateSubnetCidrReservationResult: + raise NotImplementedError + + @handler("CreateTags") + def create_tags( + self, + context: RequestContext, + resources: ResourceIdList, + tags: TagList, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateTrafficMirrorFilter") + def create_traffic_mirror_filter( + self, + context: RequestContext, + description: String | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateTrafficMirrorFilterResult: + raise NotImplementedError + + @handler("CreateTrafficMirrorFilterRule") + def create_traffic_mirror_filter_rule( + self, + context: RequestContext, + traffic_mirror_filter_id: TrafficMirrorFilterId, + traffic_direction: TrafficDirection, + rule_number: Integer, + rule_action: TrafficMirrorRuleAction, + destination_cidr_block: String, + source_cidr_block: String, + destination_port_range: TrafficMirrorPortRangeRequest | None = None, + source_port_range: TrafficMirrorPortRangeRequest | None = None, + protocol: Integer | None = None, + description: String | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateTrafficMirrorFilterRuleResult: + raise NotImplementedError + + @handler("CreateTrafficMirrorSession") + def create_traffic_mirror_session( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + traffic_mirror_target_id: TrafficMirrorTargetId, + traffic_mirror_filter_id: TrafficMirrorFilterId, + session_number: Integer, + packet_length: Integer | None = None, + virtual_network_id: Integer | None = None, + description: String | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateTrafficMirrorSessionResult: + raise NotImplementedError + + @handler("CreateTrafficMirrorTarget") + def create_traffic_mirror_target( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId | None = None, + network_load_balancer_arn: String | None = None, + description: String | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + gateway_load_balancer_endpoint_id: VpcEndpointId | None = None, + **kwargs, + ) -> CreateTrafficMirrorTargetResult: + raise NotImplementedError + + @handler("CreateTransitGateway") + def create_transit_gateway( + self, + context: RequestContext, + description: String | None = None, + options: TransitGatewayRequestOptions | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayResult: + raise NotImplementedError + + @handler("CreateTransitGatewayConnect") + def create_transit_gateway_connect( + self, + context: RequestContext, + transport_transit_gateway_attachment_id: TransitGatewayAttachmentId, + options: CreateTransitGatewayConnectRequestOptions, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayConnectResult: + raise NotImplementedError + + @handler("CreateTransitGatewayConnectPeer") + def create_transit_gateway_connect_peer( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + peer_address: String, + inside_cidr_blocks: InsideCidrBlocksStringList, + transit_gateway_address: String | None = None, + bgp_options: TransitGatewayConnectRequestBgpOptions | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayConnectPeerResult: + raise NotImplementedError + + @handler("CreateTransitGatewayMulticastDomain") + def create_transit_gateway_multicast_domain( + self, + context: RequestContext, + transit_gateway_id: TransitGatewayId, + options: CreateTransitGatewayMulticastDomainRequestOptions | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayMulticastDomainResult: + raise NotImplementedError + + @handler("CreateTransitGatewayPeeringAttachment") + def create_transit_gateway_peering_attachment( + self, + context: RequestContext, + transit_gateway_id: TransitGatewayId, + peer_transit_gateway_id: TransitAssociationGatewayId, + peer_account_id: String, + peer_region: String, + options: CreateTransitGatewayPeeringAttachmentRequestOptions | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayPeeringAttachmentResult: + raise NotImplementedError + + @handler("CreateTransitGatewayPolicyTable") + def create_transit_gateway_policy_table( + self, + context: RequestContext, + transit_gateway_id: TransitGatewayId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayPolicyTableResult: + raise NotImplementedError + + @handler("CreateTransitGatewayPrefixListReference") + def create_transit_gateway_prefix_list_reference( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + prefix_list_id: PrefixListResourceId, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + blackhole: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayPrefixListReferenceResult: + raise NotImplementedError + + @handler("CreateTransitGatewayRoute") + def create_transit_gateway_route( + self, + context: RequestContext, + destination_cidr_block: String, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + blackhole: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayRouteResult: + raise NotImplementedError + + @handler("CreateTransitGatewayRouteTable") + def create_transit_gateway_route_table( + self, + context: RequestContext, + transit_gateway_id: TransitGatewayId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayRouteTableResult: + raise NotImplementedError + + @handler("CreateTransitGatewayRouteTableAnnouncement") + def create_transit_gateway_route_table_announcement( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + peering_attachment_id: TransitGatewayAttachmentId, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayRouteTableAnnouncementResult: + raise NotImplementedError + + @handler("CreateTransitGatewayVpcAttachment") + def create_transit_gateway_vpc_attachment( + self, + context: RequestContext, + transit_gateway_id: TransitGatewayId, + vpc_id: VpcId, + subnet_ids: TransitGatewaySubnetIdList, + options: CreateTransitGatewayVpcAttachmentRequestOptions | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> CreateTransitGatewayVpcAttachmentResult: + raise NotImplementedError + + @handler("CreateVerifiedAccessEndpoint") + def create_verified_access_endpoint( + self, + context: RequestContext, + verified_access_group_id: VerifiedAccessGroupId, + endpoint_type: VerifiedAccessEndpointType, + attachment_type: VerifiedAccessEndpointAttachmentType, + domain_certificate_arn: CertificateArn | None = None, + application_domain: String | None = None, + endpoint_domain_prefix: String | None = None, + security_group_ids: SecurityGroupIdList | None = None, + load_balancer_options: CreateVerifiedAccessEndpointLoadBalancerOptions | None = None, + network_interface_options: CreateVerifiedAccessEndpointEniOptions | None = None, + description: String | None = None, + policy_document: String | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + sse_specification: VerifiedAccessSseSpecificationRequest | None = None, + rds_options: CreateVerifiedAccessEndpointRdsOptions | None = None, + cidr_options: CreateVerifiedAccessEndpointCidrOptions | None = None, + **kwargs, + ) -> CreateVerifiedAccessEndpointResult: + raise NotImplementedError + + @handler("CreateVerifiedAccessGroup") + def create_verified_access_group( + self, + context: RequestContext, + verified_access_instance_id: VerifiedAccessInstanceId, + description: String | None = None, + policy_document: String | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + sse_specification: VerifiedAccessSseSpecificationRequest | None = None, + **kwargs, + ) -> CreateVerifiedAccessGroupResult: + raise NotImplementedError + + @handler("CreateVerifiedAccessInstance") + def create_verified_access_instance( + self, + context: RequestContext, + description: String | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + fips_enabled: Boolean | None = None, + cidr_endpoints_custom_sub_domain: String | None = None, + **kwargs, + ) -> CreateVerifiedAccessInstanceResult: + raise NotImplementedError + + @handler("CreateVerifiedAccessTrustProvider") + def create_verified_access_trust_provider( + self, + context: RequestContext, + trust_provider_type: TrustProviderType, + policy_reference_name: String, + user_trust_provider_type: UserTrustProviderType | None = None, + device_trust_provider_type: DeviceTrustProviderType | None = None, + oidc_options: CreateVerifiedAccessTrustProviderOidcOptions | None = None, + device_options: CreateVerifiedAccessTrustProviderDeviceOptions | None = None, + description: String | None = None, + tag_specifications: TagSpecificationList | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + sse_specification: VerifiedAccessSseSpecificationRequest | None = None, + native_application_oidc_options: CreateVerifiedAccessNativeApplicationOidcOptions + | None = None, + **kwargs, + ) -> CreateVerifiedAccessTrustProviderResult: + raise NotImplementedError + + @handler("CreateVolume") + def create_volume( + self, + context: RequestContext, + availability_zone: AvailabilityZoneName, + encrypted: Boolean | None = None, + iops: Integer | None = None, + kms_key_id: KmsKeyId | None = None, + outpost_arn: String | None = None, + size: Integer | None = None, + snapshot_id: SnapshotId | None = None, + volume_type: VolumeType | None = None, + tag_specifications: TagSpecificationList | None = None, + multi_attach_enabled: Boolean | None = None, + throughput: Integer | None = None, + client_token: String | None = None, + volume_initialization_rate: Integer | None = None, + operator: OperatorRequest | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> Volume: + raise NotImplementedError + + @handler("CreateVpc") + def create_vpc( + self, + context: RequestContext, + cidr_block: String | None = None, + ipv6_pool: Ipv6PoolEc2Id | None = None, + ipv6_cidr_block: String | None = None, + ipv4_ipam_pool_id: IpamPoolId | None = None, + ipv4_netmask_length: NetmaskLength | None = None, + ipv6_ipam_pool_id: IpamPoolId | None = None, + ipv6_netmask_length: NetmaskLength | None = None, + ipv6_cidr_block_network_border_group: String | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + instance_tenancy: Tenancy | None = None, + amazon_provided_ipv6_cidr_block: Boolean | None = None, + **kwargs, + ) -> CreateVpcResult: + raise NotImplementedError + + @handler("CreateVpcBlockPublicAccessExclusion") + def create_vpc_block_public_access_exclusion( + self, + context: RequestContext, + internet_gateway_exclusion_mode: InternetGatewayExclusionMode, + dry_run: Boolean | None = None, + subnet_id: SubnetId | None = None, + vpc_id: VpcId | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateVpcBlockPublicAccessExclusionResult: + raise NotImplementedError + + @handler("CreateVpcEndpoint") + def create_vpc_endpoint( + self, + context: RequestContext, + vpc_id: VpcId, + dry_run: Boolean | None = None, + vpc_endpoint_type: VpcEndpointType | None = None, + service_name: String | None = None, + policy_document: String | None = None, + route_table_ids: VpcEndpointRouteTableIdList | None = None, + subnet_ids: VpcEndpointSubnetIdList | None = None, + security_group_ids: VpcEndpointSecurityGroupIdList | None = None, + ip_address_type: IpAddressType | None = None, + dns_options: DnsOptionsSpecification | None = None, + client_token: String | None = None, + private_dns_enabled: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + subnet_configurations: SubnetConfigurationsList | None = None, + service_network_arn: ServiceNetworkArn | None = None, + resource_configuration_arn: ResourceConfigurationArn | None = None, + service_region: String | None = None, + **kwargs, + ) -> CreateVpcEndpointResult: + raise NotImplementedError + + @handler("CreateVpcEndpointConnectionNotification") + def create_vpc_endpoint_connection_notification( + self, + context: RequestContext, + connection_notification_arn: String, + connection_events: ValueStringList, + dry_run: Boolean | None = None, + service_id: VpcEndpointServiceId | None = None, + vpc_endpoint_id: VpcEndpointId | None = None, + client_token: String | None = None, + **kwargs, + ) -> CreateVpcEndpointConnectionNotificationResult: + raise NotImplementedError + + @handler("CreateVpcEndpointServiceConfiguration") + def create_vpc_endpoint_service_configuration( + self, + context: RequestContext, + dry_run: Boolean | None = None, + acceptance_required: Boolean | None = None, + private_dns_name: String | None = None, + network_load_balancer_arns: ValueStringList | None = None, + gateway_load_balancer_arns: ValueStringList | None = None, + supported_ip_address_types: ValueStringList | None = None, + supported_regions: ValueStringList | None = None, + client_token: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> CreateVpcEndpointServiceConfigurationResult: + raise NotImplementedError + + @handler("CreateVpcPeeringConnection") + def create_vpc_peering_connection( + self, + context: RequestContext, + vpc_id: VpcId, + peer_region: String | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + peer_vpc_id: String | None = None, + peer_owner_id: String | None = None, + **kwargs, + ) -> CreateVpcPeeringConnectionResult: + raise NotImplementedError + + @handler("CreateVpnConnection", expand=False) + def create_vpn_connection( + self, context: RequestContext, request: CreateVpnConnectionRequest, **kwargs + ) -> CreateVpnConnectionResult: + raise NotImplementedError + + @handler("CreateVpnConnectionRoute") + def create_vpn_connection_route( + self, + context: RequestContext, + destination_cidr_block: String, + vpn_connection_id: VpnConnectionId, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateVpnGateway", expand=False) + def create_vpn_gateway( + self, context: RequestContext, request: CreateVpnGatewayRequest, **kwargs + ) -> CreateVpnGatewayResult: + raise NotImplementedError + + @handler("DeleteCarrierGateway") + def delete_carrier_gateway( + self, + context: RequestContext, + carrier_gateway_id: CarrierGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteCarrierGatewayResult: + raise NotImplementedError + + @handler("DeleteClientVpnEndpoint") + def delete_client_vpn_endpoint( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteClientVpnEndpointResult: + raise NotImplementedError + + @handler("DeleteClientVpnRoute") + def delete_client_vpn_route( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + destination_cidr_block: String, + target_vpc_subnet_id: SubnetId | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteClientVpnRouteResult: + raise NotImplementedError + + @handler("DeleteCoipCidr") + def delete_coip_cidr( + self, + context: RequestContext, + cidr: String, + coip_pool_id: Ipv4PoolCoipId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteCoipCidrResult: + raise NotImplementedError + + @handler("DeleteCoipPool") + def delete_coip_pool( + self, + context: RequestContext, + coip_pool_id: Ipv4PoolCoipId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteCoipPoolResult: + raise NotImplementedError + + @handler("DeleteCustomerGateway") + def delete_customer_gateway( + self, + context: RequestContext, + customer_gateway_id: CustomerGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteDhcpOptions") + def delete_dhcp_options( + self, + context: RequestContext, + dhcp_options_id: DhcpOptionsId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteEgressOnlyInternetGateway") + def delete_egress_only_internet_gateway( + self, + context: RequestContext, + egress_only_internet_gateway_id: EgressOnlyInternetGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteEgressOnlyInternetGatewayResult: + raise NotImplementedError + + @handler("DeleteFleets") + def delete_fleets( + self, + context: RequestContext, + fleet_ids: FleetIdSet, + terminate_instances: Boolean, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteFleetsResult: + raise NotImplementedError + + @handler("DeleteFlowLogs") + def delete_flow_logs( + self, + context: RequestContext, + flow_log_ids: FlowLogIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteFlowLogsResult: + raise NotImplementedError + + @handler("DeleteFpgaImage") + def delete_fpga_image( + self, + context: RequestContext, + fpga_image_id: FpgaImageId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteFpgaImageResult: + raise NotImplementedError + + @handler("DeleteInstanceConnectEndpoint") + def delete_instance_connect_endpoint( + self, + context: RequestContext, + instance_connect_endpoint_id: InstanceConnectEndpointId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteInstanceConnectEndpointResult: + raise NotImplementedError + + @handler("DeleteInstanceEventWindow") + def delete_instance_event_window( + self, + context: RequestContext, + instance_event_window_id: InstanceEventWindowId, + dry_run: Boolean | None = None, + force_delete: Boolean | None = None, + **kwargs, + ) -> DeleteInstanceEventWindowResult: + raise NotImplementedError + + @handler("DeleteInternetGateway") + def delete_internet_gateway( + self, + context: RequestContext, + internet_gateway_id: InternetGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteIpam") + def delete_ipam( + self, + context: RequestContext, + ipam_id: IpamId, + dry_run: Boolean | None = None, + cascade: Boolean | None = None, + **kwargs, + ) -> DeleteIpamResult: + raise NotImplementedError + + @handler("DeleteIpamExternalResourceVerificationToken") + def delete_ipam_external_resource_verification_token( + self, + context: RequestContext, + ipam_external_resource_verification_token_id: IpamExternalResourceVerificationTokenId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteIpamExternalResourceVerificationTokenResult: + raise NotImplementedError + + @handler("DeleteIpamPool") + def delete_ipam_pool( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + dry_run: Boolean | None = None, + cascade: Boolean | None = None, + **kwargs, + ) -> DeleteIpamPoolResult: + raise NotImplementedError + + @handler("DeleteIpamResourceDiscovery") + def delete_ipam_resource_discovery( + self, + context: RequestContext, + ipam_resource_discovery_id: IpamResourceDiscoveryId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteIpamResourceDiscoveryResult: + raise NotImplementedError + + @handler("DeleteIpamScope") + def delete_ipam_scope( + self, + context: RequestContext, + ipam_scope_id: IpamScopeId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteIpamScopeResult: + raise NotImplementedError + + @handler("DeleteKeyPair") + def delete_key_pair( + self, + context: RequestContext, + key_name: KeyPairNameWithResolver | None = None, + key_pair_id: KeyPairId | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteKeyPairResult: + raise NotImplementedError + + @handler("DeleteLaunchTemplate") + def delete_launch_template( + self, + context: RequestContext, + dry_run: Boolean | None = None, + launch_template_id: LaunchTemplateId | None = None, + launch_template_name: LaunchTemplateName | None = None, + **kwargs, + ) -> DeleteLaunchTemplateResult: + raise NotImplementedError + + @handler("DeleteLaunchTemplateVersions") + def delete_launch_template_versions( + self, + context: RequestContext, + versions: VersionStringList, + dry_run: Boolean | None = None, + launch_template_id: LaunchTemplateId | None = None, + launch_template_name: LaunchTemplateName | None = None, + **kwargs, + ) -> DeleteLaunchTemplateVersionsResult: + raise NotImplementedError + + @handler("DeleteLocalGatewayRoute") + def delete_local_gateway_route( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + destination_cidr_block: String | None = None, + dry_run: Boolean | None = None, + destination_prefix_list_id: PrefixListResourceId | None = None, + **kwargs, + ) -> DeleteLocalGatewayRouteResult: + raise NotImplementedError + + @handler("DeleteLocalGatewayRouteTable") + def delete_local_gateway_route_table( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteLocalGatewayRouteTableResult: + raise NotImplementedError + + @handler("DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociation") + def delete_local_gateway_route_table_virtual_interface_group_association( + self, + context: RequestContext, + local_gateway_route_table_virtual_interface_group_association_id: LocalGatewayRouteTableVirtualInterfaceGroupAssociationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociationResult: + raise NotImplementedError + + @handler("DeleteLocalGatewayRouteTableVpcAssociation") + def delete_local_gateway_route_table_vpc_association( + self, + context: RequestContext, + local_gateway_route_table_vpc_association_id: LocalGatewayRouteTableVpcAssociationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteLocalGatewayRouteTableVpcAssociationResult: + raise NotImplementedError + + @handler("DeleteLocalGatewayVirtualInterface") + def delete_local_gateway_virtual_interface( + self, + context: RequestContext, + local_gateway_virtual_interface_id: LocalGatewayVirtualInterfaceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteLocalGatewayVirtualInterfaceResult: + raise NotImplementedError + + @handler("DeleteLocalGatewayVirtualInterfaceGroup") + def delete_local_gateway_virtual_interface_group( + self, + context: RequestContext, + local_gateway_virtual_interface_group_id: LocalGatewayVirtualInterfaceGroupId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteLocalGatewayVirtualInterfaceGroupResult: + raise NotImplementedError + + @handler("DeleteManagedPrefixList") + def delete_managed_prefix_list( + self, + context: RequestContext, + prefix_list_id: PrefixListResourceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteManagedPrefixListResult: + raise NotImplementedError + + @handler("DeleteNatGateway") + def delete_nat_gateway( + self, + context: RequestContext, + nat_gateway_id: NatGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteNatGatewayResult: + raise NotImplementedError + + @handler("DeleteNetworkAcl") + def delete_network_acl( + self, + context: RequestContext, + network_acl_id: NetworkAclId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteNetworkAclEntry") + def delete_network_acl_entry( + self, + context: RequestContext, + network_acl_id: NetworkAclId, + rule_number: Integer, + egress: Boolean, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteNetworkInsightsAccessScope") + def delete_network_insights_access_scope( + self, + context: RequestContext, + network_insights_access_scope_id: NetworkInsightsAccessScopeId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteNetworkInsightsAccessScopeResult: + raise NotImplementedError + + @handler("DeleteNetworkInsightsAccessScopeAnalysis") + def delete_network_insights_access_scope_analysis( + self, + context: RequestContext, + network_insights_access_scope_analysis_id: NetworkInsightsAccessScopeAnalysisId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteNetworkInsightsAccessScopeAnalysisResult: + raise NotImplementedError + + @handler("DeleteNetworkInsightsAnalysis") + def delete_network_insights_analysis( + self, + context: RequestContext, + network_insights_analysis_id: NetworkInsightsAnalysisId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteNetworkInsightsAnalysisResult: + raise NotImplementedError + + @handler("DeleteNetworkInsightsPath") + def delete_network_insights_path( + self, + context: RequestContext, + network_insights_path_id: NetworkInsightsPathId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteNetworkInsightsPathResult: + raise NotImplementedError + + @handler("DeleteNetworkInterface") + def delete_network_interface( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteNetworkInterfacePermission") + def delete_network_interface_permission( + self, + context: RequestContext, + network_interface_permission_id: NetworkInterfacePermissionId, + force: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteNetworkInterfacePermissionResult: + raise NotImplementedError + + @handler("DeletePlacementGroup") + def delete_placement_group( + self, + context: RequestContext, + group_name: PlacementGroupName, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeletePublicIpv4Pool") + def delete_public_ipv4_pool( + self, + context: RequestContext, + pool_id: Ipv4PoolEc2Id, + dry_run: Boolean | None = None, + network_border_group: String | None = None, + **kwargs, + ) -> DeletePublicIpv4PoolResult: + raise NotImplementedError + + @handler("DeleteQueuedReservedInstances") + def delete_queued_reserved_instances( + self, + context: RequestContext, + reserved_instances_ids: DeleteQueuedReservedInstancesIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteQueuedReservedInstancesResult: + raise NotImplementedError + + @handler("DeleteRoute") + def delete_route( + self, + context: RequestContext, + route_table_id: RouteTableId, + destination_prefix_list_id: PrefixListResourceId | None = None, + dry_run: Boolean | None = None, + destination_cidr_block: String | None = None, + destination_ipv6_cidr_block: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteRouteServer") + def delete_route_server( + self, + context: RequestContext, + route_server_id: RouteServerId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteRouteServerResult: + raise NotImplementedError + + @handler("DeleteRouteServerEndpoint") + def delete_route_server_endpoint( + self, + context: RequestContext, + route_server_endpoint_id: RouteServerEndpointId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteRouteServerEndpointResult: + raise NotImplementedError + + @handler("DeleteRouteServerPeer") + def delete_route_server_peer( + self, + context: RequestContext, + route_server_peer_id: RouteServerPeerId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteRouteServerPeerResult: + raise NotImplementedError + + @handler("DeleteRouteTable") + def delete_route_table( + self, + context: RequestContext, + route_table_id: RouteTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteSecurityGroup") + def delete_security_group( + self, + context: RequestContext, + group_id: SecurityGroupId | None = None, + group_name: SecurityGroupName | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteSecurityGroupResult: + raise NotImplementedError + + @handler("DeleteSnapshot") + def delete_snapshot( + self, + context: RequestContext, + snapshot_id: SnapshotId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteSpotDatafeedSubscription") + def delete_spot_datafeed_subscription( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteSubnet") + def delete_subnet( + self, context: RequestContext, subnet_id: SubnetId, dry_run: Boolean | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteSubnetCidrReservation") + def delete_subnet_cidr_reservation( + self, + context: RequestContext, + subnet_cidr_reservation_id: SubnetCidrReservationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteSubnetCidrReservationResult: + raise NotImplementedError + + @handler("DeleteTags") + def delete_tags( + self, + context: RequestContext, + resources: ResourceIdList, + dry_run: Boolean | None = None, + tags: TagList | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteTrafficMirrorFilter") + def delete_traffic_mirror_filter( + self, + context: RequestContext, + traffic_mirror_filter_id: TrafficMirrorFilterId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTrafficMirrorFilterResult: + raise NotImplementedError + + @handler("DeleteTrafficMirrorFilterRule") + def delete_traffic_mirror_filter_rule( + self, + context: RequestContext, + traffic_mirror_filter_rule_id: TrafficMirrorFilterRuleIdWithResolver, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTrafficMirrorFilterRuleResult: + raise NotImplementedError + + @handler("DeleteTrafficMirrorSession") + def delete_traffic_mirror_session( + self, + context: RequestContext, + traffic_mirror_session_id: TrafficMirrorSessionId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTrafficMirrorSessionResult: + raise NotImplementedError + + @handler("DeleteTrafficMirrorTarget") + def delete_traffic_mirror_target( + self, + context: RequestContext, + traffic_mirror_target_id: TrafficMirrorTargetId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTrafficMirrorTargetResult: + raise NotImplementedError + + @handler("DeleteTransitGateway") + def delete_transit_gateway( + self, + context: RequestContext, + transit_gateway_id: TransitGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayConnect") + def delete_transit_gateway_connect( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayConnectResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayConnectPeer") + def delete_transit_gateway_connect_peer( + self, + context: RequestContext, + transit_gateway_connect_peer_id: TransitGatewayConnectPeerId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayConnectPeerResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayMulticastDomain") + def delete_transit_gateway_multicast_domain( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayMulticastDomainResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayPeeringAttachment") + def delete_transit_gateway_peering_attachment( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayPeeringAttachmentResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayPolicyTable") + def delete_transit_gateway_policy_table( + self, + context: RequestContext, + transit_gateway_policy_table_id: TransitGatewayPolicyTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayPolicyTableResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayPrefixListReference") + def delete_transit_gateway_prefix_list_reference( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + prefix_list_id: PrefixListResourceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayPrefixListReferenceResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayRoute") + def delete_transit_gateway_route( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + destination_cidr_block: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayRouteResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayRouteTable") + def delete_transit_gateway_route_table( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayRouteTableResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayRouteTableAnnouncement") + def delete_transit_gateway_route_table_announcement( + self, + context: RequestContext, + transit_gateway_route_table_announcement_id: TransitGatewayRouteTableAnnouncementId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayRouteTableAnnouncementResult: + raise NotImplementedError + + @handler("DeleteTransitGatewayVpcAttachment") + def delete_transit_gateway_vpc_attachment( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteTransitGatewayVpcAttachmentResult: + raise NotImplementedError + + @handler("DeleteVerifiedAccessEndpoint") + def delete_verified_access_endpoint( + self, + context: RequestContext, + verified_access_endpoint_id: VerifiedAccessEndpointId, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteVerifiedAccessEndpointResult: + raise NotImplementedError + + @handler("DeleteVerifiedAccessGroup") + def delete_verified_access_group( + self, + context: RequestContext, + verified_access_group_id: VerifiedAccessGroupId, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteVerifiedAccessGroupResult: + raise NotImplementedError + + @handler("DeleteVerifiedAccessInstance") + def delete_verified_access_instance( + self, + context: RequestContext, + verified_access_instance_id: VerifiedAccessInstanceId, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> DeleteVerifiedAccessInstanceResult: + raise NotImplementedError + + @handler("DeleteVerifiedAccessTrustProvider") + def delete_verified_access_trust_provider( + self, + context: RequestContext, + verified_access_trust_provider_id: VerifiedAccessTrustProviderId, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> DeleteVerifiedAccessTrustProviderResult: + raise NotImplementedError + + @handler("DeleteVolume") + def delete_volume( + self, context: RequestContext, volume_id: VolumeId, dry_run: Boolean | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteVpc") + def delete_vpc( + self, context: RequestContext, vpc_id: VpcId, dry_run: Boolean | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteVpcBlockPublicAccessExclusion") + def delete_vpc_block_public_access_exclusion( + self, + context: RequestContext, + exclusion_id: VpcBlockPublicAccessExclusionId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteVpcBlockPublicAccessExclusionResult: + raise NotImplementedError + + @handler("DeleteVpcEndpointConnectionNotifications") + def delete_vpc_endpoint_connection_notifications( + self, + context: RequestContext, + connection_notification_ids: ConnectionNotificationIdsList, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteVpcEndpointConnectionNotificationsResult: + raise NotImplementedError + + @handler("DeleteVpcEndpointServiceConfigurations") + def delete_vpc_endpoint_service_configurations( + self, + context: RequestContext, + service_ids: VpcEndpointServiceIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteVpcEndpointServiceConfigurationsResult: + raise NotImplementedError + + @handler("DeleteVpcEndpoints") + def delete_vpc_endpoints( + self, + context: RequestContext, + vpc_endpoint_ids: VpcEndpointIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteVpcEndpointsResult: + raise NotImplementedError + + @handler("DeleteVpcPeeringConnection") + def delete_vpc_peering_connection( + self, + context: RequestContext, + vpc_peering_connection_id: VpcPeeringConnectionId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeleteVpcPeeringConnectionResult: + raise NotImplementedError + + @handler("DeleteVpnConnection") + def delete_vpn_connection( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteVpnConnectionRoute") + def delete_vpn_connection_route( + self, + context: RequestContext, + destination_cidr_block: String, + vpn_connection_id: VpnConnectionId, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteVpnGateway") + def delete_vpn_gateway( + self, + context: RequestContext, + vpn_gateway_id: VpnGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeprovisionByoipCidr") + def deprovision_byoip_cidr( + self, context: RequestContext, cidr: String, dry_run: Boolean | None = None, **kwargs + ) -> DeprovisionByoipCidrResult: + raise NotImplementedError + + @handler("DeprovisionIpamByoasn") + def deprovision_ipam_byoasn( + self, + context: RequestContext, + ipam_id: IpamId, + asn: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeprovisionIpamByoasnResult: + raise NotImplementedError + + @handler("DeprovisionIpamPoolCidr") + def deprovision_ipam_pool_cidr( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + dry_run: Boolean | None = None, + cidr: String | None = None, + **kwargs, + ) -> DeprovisionIpamPoolCidrResult: + raise NotImplementedError + + @handler("DeprovisionPublicIpv4PoolCidr") + def deprovision_public_ipv4_pool_cidr( + self, + context: RequestContext, + pool_id: Ipv4PoolEc2Id, + cidr: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeprovisionPublicIpv4PoolCidrResult: + raise NotImplementedError + + @handler("DeregisterImage") + def deregister_image( + self, + context: RequestContext, + image_id: ImageId, + delete_associated_snapshots: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeregisterImageResult: + raise NotImplementedError + + @handler("DeregisterInstanceEventNotificationAttributes") + def deregister_instance_event_notification_attributes( + self, + context: RequestContext, + instance_tag_attribute: DeregisterInstanceTagAttributeRequest, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeregisterInstanceEventNotificationAttributesResult: + raise NotImplementedError + + @handler("DeregisterTransitGatewayMulticastGroupMembers") + def deregister_transit_gateway_multicast_group_members( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId | None = None, + group_ip_address: String | None = None, + network_interface_ids: TransitGatewayNetworkInterfaceIdList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeregisterTransitGatewayMulticastGroupMembersResult: + raise NotImplementedError + + @handler("DeregisterTransitGatewayMulticastGroupSources") + def deregister_transit_gateway_multicast_group_sources( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId | None = None, + group_ip_address: String | None = None, + network_interface_ids: TransitGatewayNetworkInterfaceIdList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DeregisterTransitGatewayMulticastGroupSourcesResult: + raise NotImplementedError + + @handler("DescribeAccountAttributes") + def describe_account_attributes( + self, + context: RequestContext, + dry_run: Boolean | None = None, + attribute_names: AccountAttributeNameStringList | None = None, + **kwargs, + ) -> DescribeAccountAttributesResult: + raise NotImplementedError + + @handler("DescribeAddressTransfers") + def describe_address_transfers( + self, + context: RequestContext, + allocation_ids: AllocationIdList | None = None, + next_token: String | None = None, + max_results: DescribeAddressTransfersMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeAddressTransfersResult: + raise NotImplementedError + + @handler("DescribeAddresses") + def describe_addresses( + self, + context: RequestContext, + public_ips: PublicIpStringList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + allocation_ids: AllocationIdList | None = None, + **kwargs, + ) -> DescribeAddressesResult: + raise NotImplementedError + + @handler("DescribeAddressesAttribute") + def describe_addresses_attribute( + self, + context: RequestContext, + allocation_ids: AllocationIds | None = None, + attribute: AddressAttributeName | None = None, + next_token: NextToken | None = None, + max_results: AddressMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeAddressesAttributeResult: + raise NotImplementedError + + @handler("DescribeAggregateIdFormat") + def describe_aggregate_id_format( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DescribeAggregateIdFormatResult: + raise NotImplementedError + + @handler("DescribeAvailabilityZones") + def describe_availability_zones( + self, + context: RequestContext, + zone_names: ZoneNameStringList | None = None, + zone_ids: ZoneIdStringList | None = None, + all_availability_zones: Boolean | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeAvailabilityZonesResult: + raise NotImplementedError + + @handler("DescribeAwsNetworkPerformanceMetricSubscriptions") + def describe_aws_network_performance_metric_subscriptions( + self, + context: RequestContext, + max_results: MaxResultsParam | None = None, + next_token: String | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeAwsNetworkPerformanceMetricSubscriptionsResult: + raise NotImplementedError + + @handler("DescribeBundleTasks") + def describe_bundle_tasks( + self, + context: RequestContext, + bundle_ids: BundleIdStringList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeBundleTasksResult: + raise NotImplementedError + + @handler("DescribeByoipCidrs") + def describe_byoip_cidrs( + self, + context: RequestContext, + max_results: DescribeByoipCidrsMaxResults, + dry_run: Boolean | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeByoipCidrsResult: + raise NotImplementedError + + @handler("DescribeCapacityBlockExtensionHistory") + def describe_capacity_block_extension_history( + self, + context: RequestContext, + capacity_reservation_ids: CapacityReservationIdSet | None = None, + next_token: String | None = None, + max_results: DescribeFutureCapacityMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCapacityBlockExtensionHistoryResult: + raise NotImplementedError + + @handler("DescribeCapacityBlockExtensionOfferings") + def describe_capacity_block_extension_offerings( + self, + context: RequestContext, + capacity_block_extension_duration_hours: Integer, + capacity_reservation_id: CapacityReservationId, + dry_run: Boolean | None = None, + next_token: String | None = None, + max_results: DescribeCapacityBlockExtensionOfferingsMaxResults | None = None, + **kwargs, + ) -> DescribeCapacityBlockExtensionOfferingsResult: + raise NotImplementedError + + @handler("DescribeCapacityBlockOfferings") + def describe_capacity_block_offerings( + self, + context: RequestContext, + capacity_duration_hours: Integer, + dry_run: Boolean | None = None, + instance_type: String | None = None, + instance_count: Integer | None = None, + start_date_range: MillisecondDateTime | None = None, + end_date_range: MillisecondDateTime | None = None, + next_token: String | None = None, + max_results: DescribeCapacityBlockOfferingsMaxResults | None = None, + ultraserver_type: String | None = None, + ultraserver_count: Integer | None = None, + **kwargs, + ) -> DescribeCapacityBlockOfferingsResult: + raise NotImplementedError + + @handler("DescribeCapacityBlockStatus") + def describe_capacity_block_status( + self, + context: RequestContext, + capacity_block_ids: CapacityBlockIds | None = None, + next_token: String | None = None, + max_results: DescribeCapacityBlockStatusMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCapacityBlockStatusResult: + raise NotImplementedError + + @handler("DescribeCapacityBlocks") + def describe_capacity_blocks( + self, + context: RequestContext, + capacity_block_ids: CapacityBlockIds | None = None, + next_token: String | None = None, + max_results: DescribeCapacityBlocksMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCapacityBlocksResult: + raise NotImplementedError + + @handler("DescribeCapacityReservationBillingRequests") + def describe_capacity_reservation_billing_requests( + self, + context: RequestContext, + role: CallerRole, + capacity_reservation_ids: CapacityReservationIdSet | None = None, + next_token: String | None = None, + max_results: DescribeCapacityReservationBillingRequestsRequestMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCapacityReservationBillingRequestsResult: + raise NotImplementedError + + @handler("DescribeCapacityReservationFleets") + def describe_capacity_reservation_fleets( + self, + context: RequestContext, + capacity_reservation_fleet_ids: CapacityReservationFleetIdSet | None = None, + next_token: String | None = None, + max_results: DescribeCapacityReservationFleetsMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCapacityReservationFleetsResult: + raise NotImplementedError + + @handler("DescribeCapacityReservations") + def describe_capacity_reservations( + self, + context: RequestContext, + capacity_reservation_ids: CapacityReservationIdSet | None = None, + next_token: String | None = None, + max_results: DescribeCapacityReservationsMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCapacityReservationsResult: + raise NotImplementedError + + @handler("DescribeCarrierGateways") + def describe_carrier_gateways( + self, + context: RequestContext, + carrier_gateway_ids: CarrierGatewayIdSet | None = None, + filters: FilterList | None = None, + max_results: CarrierGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCarrierGatewaysResult: + raise NotImplementedError + + @handler("DescribeClassicLinkInstances") + def describe_classic_link_instances( + self, + context: RequestContext, + dry_run: Boolean | None = None, + instance_ids: InstanceIdStringList | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: DescribeClassicLinkInstancesMaxResults | None = None, + **kwargs, + ) -> DescribeClassicLinkInstancesResult: + raise NotImplementedError + + @handler("DescribeClientVpnAuthorizationRules") + def describe_client_vpn_authorization_rules( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + dry_run: Boolean | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + max_results: DescribeClientVpnAuthorizationRulesMaxResults | None = None, + **kwargs, + ) -> DescribeClientVpnAuthorizationRulesResult: + raise NotImplementedError + + @handler("DescribeClientVpnConnections") + def describe_client_vpn_connections( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + filters: FilterList | None = None, + next_token: NextToken | None = None, + max_results: DescribeClientVpnConnectionsMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeClientVpnConnectionsResult: + raise NotImplementedError + + @handler("DescribeClientVpnEndpoints") + def describe_client_vpn_endpoints( + self, + context: RequestContext, + client_vpn_endpoint_ids: ClientVpnEndpointIdList | None = None, + max_results: DescribeClientVpnEndpointMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeClientVpnEndpointsResult: + raise NotImplementedError + + @handler("DescribeClientVpnRoutes") + def describe_client_vpn_routes( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + filters: FilterList | None = None, + max_results: DescribeClientVpnRoutesMaxResults | None = None, + next_token: NextToken | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeClientVpnRoutesResult: + raise NotImplementedError + + @handler("DescribeClientVpnTargetNetworks") + def describe_client_vpn_target_networks( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + association_ids: ValueStringList | None = None, + max_results: DescribeClientVpnTargetNetworksMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeClientVpnTargetNetworksResult: + raise NotImplementedError + + @handler("DescribeCoipPools") + def describe_coip_pools( + self, + context: RequestContext, + pool_ids: CoipPoolIdSet | None = None, + filters: FilterList | None = None, + max_results: CoipPoolMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCoipPoolsResult: + raise NotImplementedError + + @handler("DescribeConversionTasks") + def describe_conversion_tasks( + self, + context: RequestContext, + dry_run: Boolean | None = None, + conversion_task_ids: ConversionIdStringList | None = None, + **kwargs, + ) -> DescribeConversionTasksResult: + raise NotImplementedError + + @handler("DescribeCustomerGateways") + def describe_customer_gateways( + self, + context: RequestContext, + customer_gateway_ids: CustomerGatewayIdStringList | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeCustomerGatewaysResult: + raise NotImplementedError + + @handler("DescribeDeclarativePoliciesReports") + def describe_declarative_policies_reports( + self, + context: RequestContext, + dry_run: Boolean | None = None, + next_token: String | None = None, + max_results: DeclarativePoliciesMaxResults | None = None, + report_ids: ValueStringList | None = None, + **kwargs, + ) -> DescribeDeclarativePoliciesReportsResult: + raise NotImplementedError + + @handler("DescribeDhcpOptions") + def describe_dhcp_options( + self, + context: RequestContext, + dhcp_options_ids: DhcpOptionsIdStringList | None = None, + next_token: String | None = None, + max_results: DescribeDhcpOptionsMaxResults | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeDhcpOptionsResult: + raise NotImplementedError + + @handler("DescribeEgressOnlyInternetGateways") + def describe_egress_only_internet_gateways( + self, + context: RequestContext, + dry_run: Boolean | None = None, + egress_only_internet_gateway_ids: EgressOnlyInternetGatewayIdList | None = None, + max_results: DescribeEgressOnlyInternetGatewaysMaxResults | None = None, + next_token: String | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeEgressOnlyInternetGatewaysResult: + raise NotImplementedError + + @handler("DescribeElasticGpus") + def describe_elastic_gpus( + self, + context: RequestContext, + elastic_gpu_ids: ElasticGpuIdSet | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: DescribeElasticGpusMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeElasticGpusResult: + raise NotImplementedError + + @handler("DescribeExportImageTasks") + def describe_export_image_tasks( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + export_image_task_ids: ExportImageTaskIdList | None = None, + max_results: DescribeExportImageTasksMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeExportImageTasksResult: + raise NotImplementedError + + @handler("DescribeExportTasks") + def describe_export_tasks( + self, + context: RequestContext, + filters: FilterList | None = None, + export_task_ids: ExportTaskIdStringList | None = None, + **kwargs, + ) -> DescribeExportTasksResult: + raise NotImplementedError + + @handler("DescribeFastLaunchImages") + def describe_fast_launch_images( + self, + context: RequestContext, + image_ids: FastLaunchImageIdList | None = None, + filters: FilterList | None = None, + max_results: DescribeFastLaunchImagesRequestMaxResults | None = None, + next_token: NextToken | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeFastLaunchImagesResult: + raise NotImplementedError + + @handler("DescribeFastSnapshotRestores") + def describe_fast_snapshot_restores( + self, + context: RequestContext, + filters: FilterList | None = None, + max_results: DescribeFastSnapshotRestoresMaxResults | None = None, + next_token: NextToken | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeFastSnapshotRestoresResult: + raise NotImplementedError + + @handler("DescribeFleetHistory") + def describe_fleet_history( + self, + context: RequestContext, + fleet_id: FleetId, + start_time: DateTime, + dry_run: Boolean | None = None, + event_type: FleetEventType | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeFleetHistoryResult: + raise NotImplementedError + + @handler("DescribeFleetInstances") + def describe_fleet_instances( + self, + context: RequestContext, + fleet_id: FleetId, + dry_run: Boolean | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeFleetInstancesResult: + raise NotImplementedError + + @handler("DescribeFleets") + def describe_fleets( + self, + context: RequestContext, + dry_run: Boolean | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + fleet_ids: FleetIdSet | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeFleetsResult: + raise NotImplementedError + + @handler("DescribeFlowLogs") + def describe_flow_logs( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filter: FilterList | None = None, + flow_log_ids: FlowLogIdList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeFlowLogsResult: + raise NotImplementedError + + @handler("DescribeFpgaImageAttribute") + def describe_fpga_image_attribute( + self, + context: RequestContext, + fpga_image_id: FpgaImageId, + attribute: FpgaImageAttributeName, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeFpgaImageAttributeResult: + raise NotImplementedError + + @handler("DescribeFpgaImages") + def describe_fpga_images( + self, + context: RequestContext, + dry_run: Boolean | None = None, + fpga_image_ids: FpgaImageIdList | None = None, + owners: OwnerStringList | None = None, + filters: FilterList | None = None, + next_token: NextToken | None = None, + max_results: DescribeFpgaImagesMaxResults | None = None, + **kwargs, + ) -> DescribeFpgaImagesResult: + raise NotImplementedError + + @handler("DescribeHostReservationOfferings") + def describe_host_reservation_offerings( + self, + context: RequestContext, + filter: FilterList | None = None, + max_duration: Integer | None = None, + max_results: DescribeHostReservationsMaxResults | None = None, + min_duration: Integer | None = None, + next_token: String | None = None, + offering_id: OfferingId | None = None, + **kwargs, + ) -> DescribeHostReservationOfferingsResult: + raise NotImplementedError + + @handler("DescribeHostReservations") + def describe_host_reservations( + self, + context: RequestContext, + filter: FilterList | None = None, + host_reservation_id_set: HostReservationIdSet | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeHostReservationsResult: + raise NotImplementedError + + @handler("DescribeHosts") + def describe_hosts( + self, + context: RequestContext, + host_ids: RequestHostIdList | None = None, + next_token: String | None = None, + max_results: Integer | None = None, + filter: FilterList | None = None, + **kwargs, + ) -> DescribeHostsResult: + raise NotImplementedError + + @handler("DescribeIamInstanceProfileAssociations") + def describe_iam_instance_profile_associations( + self, + context: RequestContext, + association_ids: AssociationIdList | None = None, + filters: FilterList | None = None, + max_results: DescribeIamInstanceProfileAssociationsMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeIamInstanceProfileAssociationsResult: + raise NotImplementedError + + @handler("DescribeIdFormat") + def describe_id_format( + self, context: RequestContext, resource: String | None = None, **kwargs + ) -> DescribeIdFormatResult: + raise NotImplementedError + + @handler("DescribeIdentityIdFormat") + def describe_identity_id_format( + self, + context: RequestContext, + principal_arn: String, + resource: String | None = None, + **kwargs, + ) -> DescribeIdentityIdFormatResult: + raise NotImplementedError + + @handler("DescribeImageAttribute") + def describe_image_attribute( + self, + context: RequestContext, + attribute: ImageAttributeName, + image_id: ImageId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ImageAttribute: + raise NotImplementedError + + @handler("DescribeImages") + def describe_images( + self, + context: RequestContext, + executable_users: ExecutableByStringList | None = None, + image_ids: ImageIdStringList | None = None, + owners: OwnerStringList | None = None, + include_deprecated: Boolean | None = None, + include_disabled: Boolean | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeImagesResult: + raise NotImplementedError + + @handler("DescribeImportImageTasks") + def describe_import_image_tasks( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + import_task_ids: ImportTaskIdList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeImportImageTasksResult: + raise NotImplementedError + + @handler("DescribeImportSnapshotTasks") + def describe_import_snapshot_tasks( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + import_task_ids: ImportSnapshotTaskIdList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeImportSnapshotTasksResult: + raise NotImplementedError + + @handler("DescribeInstanceAttribute") + def describe_instance_attribute( + self, + context: RequestContext, + instance_id: InstanceId, + attribute: InstanceAttributeName, + dry_run: Boolean | None = None, + **kwargs, + ) -> InstanceAttribute: + raise NotImplementedError + + @handler("DescribeInstanceConnectEndpoints") + def describe_instance_connect_endpoints( + self, + context: RequestContext, + dry_run: Boolean | None = None, + max_results: InstanceConnectEndpointMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + instance_connect_endpoint_ids: ValueStringList | None = None, + **kwargs, + ) -> DescribeInstanceConnectEndpointsResult: + raise NotImplementedError + + @handler("DescribeInstanceCreditSpecifications") + def describe_instance_credit_specifications( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + instance_ids: InstanceIdStringList | None = None, + max_results: DescribeInstanceCreditSpecificationsMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeInstanceCreditSpecificationsResult: + raise NotImplementedError + + @handler("DescribeInstanceEventNotificationAttributes") + def describe_instance_event_notification_attributes( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DescribeInstanceEventNotificationAttributesResult: + raise NotImplementedError + + @handler("DescribeInstanceEventWindows") + def describe_instance_event_windows( + self, + context: RequestContext, + dry_run: Boolean | None = None, + instance_event_window_ids: InstanceEventWindowIdSet | None = None, + filters: FilterList | None = None, + max_results: ResultRange | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeInstanceEventWindowsResult: + raise NotImplementedError + + @handler("DescribeInstanceImageMetadata") + def describe_instance_image_metadata( + self, + context: RequestContext, + filters: FilterList | None = None, + instance_ids: InstanceIdStringList | None = None, + max_results: DescribeInstanceImageMetadataMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeInstanceImageMetadataResult: + raise NotImplementedError + + @handler("DescribeInstanceStatus") + def describe_instance_status( + self, + context: RequestContext, + instance_ids: InstanceIdStringList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + include_all_instances: Boolean | None = None, + **kwargs, + ) -> DescribeInstanceStatusResult: + raise NotImplementedError + + @handler("DescribeInstanceTopology") + def describe_instance_topology( + self, + context: RequestContext, + dry_run: Boolean | None = None, + next_token: String | None = None, + max_results: DescribeInstanceTopologyMaxResults | None = None, + instance_ids: DescribeInstanceTopologyInstanceIdSet | None = None, + group_names: DescribeInstanceTopologyGroupNameSet | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeInstanceTopologyResult: + raise NotImplementedError + + @handler("DescribeInstanceTypeOfferings") + def describe_instance_type_offerings( + self, + context: RequestContext, + dry_run: Boolean | None = None, + location_type: LocationType | None = None, + filters: FilterList | None = None, + max_results: DITOMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeInstanceTypeOfferingsResult: + raise NotImplementedError + + @handler("DescribeInstanceTypes") + def describe_instance_types( + self, + context: RequestContext, + dry_run: Boolean | None = None, + instance_types: RequestInstanceTypeList | None = None, + filters: FilterList | None = None, + max_results: DITMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeInstanceTypesResult: + raise NotImplementedError + + @handler("DescribeInstances") + def describe_instances( + self, + context: RequestContext, + instance_ids: InstanceIdStringList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: Integer | None = None, + **kwargs, + ) -> DescribeInstancesResult: + raise NotImplementedError + + @handler("DescribeInternetGateways") + def describe_internet_gateways( + self, + context: RequestContext, + next_token: String | None = None, + max_results: DescribeInternetGatewaysMaxResults | None = None, + dry_run: Boolean | None = None, + internet_gateway_ids: InternetGatewayIdList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeInternetGatewaysResult: + raise NotImplementedError + + @handler("DescribeIpamByoasn") + def describe_ipam_byoasn( + self, + context: RequestContext, + dry_run: Boolean | None = None, + max_results: DescribeIpamByoasnMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeIpamByoasnResult: + raise NotImplementedError + + @handler("DescribeIpamExternalResourceVerificationTokens") + def describe_ipam_external_resource_verification_tokens( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: NextToken | None = None, + max_results: IpamMaxResults | None = None, + ipam_external_resource_verification_token_ids: ValueStringList | None = None, + **kwargs, + ) -> DescribeIpamExternalResourceVerificationTokensResult: + raise NotImplementedError + + @handler("DescribeIpamPools") + def describe_ipam_pools( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: IpamMaxResults | None = None, + next_token: NextToken | None = None, + ipam_pool_ids: ValueStringList | None = None, + **kwargs, + ) -> DescribeIpamPoolsResult: + raise NotImplementedError + + @handler("DescribeIpamResourceDiscoveries") + def describe_ipam_resource_discoveries( + self, + context: RequestContext, + dry_run: Boolean | None = None, + ipam_resource_discovery_ids: ValueStringList | None = None, + next_token: NextToken | None = None, + max_results: IpamMaxResults | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeIpamResourceDiscoveriesResult: + raise NotImplementedError + + @handler("DescribeIpamResourceDiscoveryAssociations") + def describe_ipam_resource_discovery_associations( + self, + context: RequestContext, + dry_run: Boolean | None = None, + ipam_resource_discovery_association_ids: ValueStringList | None = None, + next_token: NextToken | None = None, + max_results: IpamMaxResults | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeIpamResourceDiscoveryAssociationsResult: + raise NotImplementedError + + @handler("DescribeIpamScopes") + def describe_ipam_scopes( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: IpamMaxResults | None = None, + next_token: NextToken | None = None, + ipam_scope_ids: ValueStringList | None = None, + **kwargs, + ) -> DescribeIpamScopesResult: + raise NotImplementedError + + @handler("DescribeIpams") + def describe_ipams( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: IpamMaxResults | None = None, + next_token: NextToken | None = None, + ipam_ids: ValueStringList | None = None, + **kwargs, + ) -> DescribeIpamsResult: + raise NotImplementedError + + @handler("DescribeIpv6Pools") + def describe_ipv6_pools( + self, + context: RequestContext, + pool_ids: Ipv6PoolIdList | None = None, + next_token: NextToken | None = None, + max_results: Ipv6PoolMaxResults | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeIpv6PoolsResult: + raise NotImplementedError + + @handler("DescribeKeyPairs") + def describe_key_pairs( + self, + context: RequestContext, + key_names: KeyNameStringList | None = None, + key_pair_ids: KeyPairIdStringList | None = None, + include_public_key: Boolean | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeKeyPairsResult: + raise NotImplementedError + + @handler("DescribeLaunchTemplateVersions") + def describe_launch_template_versions( + self, + context: RequestContext, + dry_run: Boolean | None = None, + launch_template_id: LaunchTemplateId | None = None, + launch_template_name: LaunchTemplateName | None = None, + versions: VersionStringList | None = None, + min_version: String | None = None, + max_version: String | None = None, + next_token: String | None = None, + max_results: Integer | None = None, + filters: FilterList | None = None, + resolve_alias: Boolean | None = None, + **kwargs, + ) -> DescribeLaunchTemplateVersionsResult: + raise NotImplementedError + + @handler("DescribeLaunchTemplates") + def describe_launch_templates( + self, + context: RequestContext, + dry_run: Boolean | None = None, + launch_template_ids: LaunchTemplateIdStringList | None = None, + launch_template_names: LaunchTemplateNameStringList | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: DescribeLaunchTemplatesMaxResults | None = None, + **kwargs, + ) -> DescribeLaunchTemplatesResult: + raise NotImplementedError + + @handler("DescribeLocalGatewayRouteTableVirtualInterfaceGroupAssociations") + def describe_local_gateway_route_table_virtual_interface_group_associations( + self, + context: RequestContext, + local_gateway_route_table_virtual_interface_group_association_ids: LocalGatewayRouteTableVirtualInterfaceGroupAssociationIdSet + | None = None, + filters: FilterList | None = None, + max_results: LocalGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeLocalGatewayRouteTableVirtualInterfaceGroupAssociationsResult: + raise NotImplementedError + + @handler("DescribeLocalGatewayRouteTableVpcAssociations") + def describe_local_gateway_route_table_vpc_associations( + self, + context: RequestContext, + local_gateway_route_table_vpc_association_ids: LocalGatewayRouteTableVpcAssociationIdSet + | None = None, + filters: FilterList | None = None, + max_results: LocalGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeLocalGatewayRouteTableVpcAssociationsResult: + raise NotImplementedError + + @handler("DescribeLocalGatewayRouteTables") + def describe_local_gateway_route_tables( + self, + context: RequestContext, + local_gateway_route_table_ids: LocalGatewayRouteTableIdSet | None = None, + filters: FilterList | None = None, + max_results: LocalGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeLocalGatewayRouteTablesResult: + raise NotImplementedError + + @handler("DescribeLocalGatewayVirtualInterfaceGroups") + def describe_local_gateway_virtual_interface_groups( + self, + context: RequestContext, + local_gateway_virtual_interface_group_ids: LocalGatewayVirtualInterfaceGroupIdSet + | None = None, + filters: FilterList | None = None, + max_results: LocalGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeLocalGatewayVirtualInterfaceGroupsResult: + raise NotImplementedError + + @handler("DescribeLocalGatewayVirtualInterfaces") + def describe_local_gateway_virtual_interfaces( + self, + context: RequestContext, + local_gateway_virtual_interface_ids: LocalGatewayVirtualInterfaceIdSet | None = None, + filters: FilterList | None = None, + max_results: LocalGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeLocalGatewayVirtualInterfacesResult: + raise NotImplementedError + + @handler("DescribeLocalGateways") + def describe_local_gateways( + self, + context: RequestContext, + local_gateway_ids: LocalGatewayIdSet | None = None, + filters: FilterList | None = None, + max_results: LocalGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeLocalGatewaysResult: + raise NotImplementedError + + @handler("DescribeLockedSnapshots") + def describe_locked_snapshots( + self, + context: RequestContext, + filters: FilterList | None = None, + max_results: DescribeLockedSnapshotsMaxResults | None = None, + next_token: String | None = None, + snapshot_ids: SnapshotIdStringList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeLockedSnapshotsResult: + raise NotImplementedError + + @handler("DescribeMacHosts") + def describe_mac_hosts( + self, + context: RequestContext, + filters: FilterList | None = None, + host_ids: RequestHostIdList | None = None, + max_results: DescribeMacHostsRequestMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeMacHostsResult: + raise NotImplementedError + + @handler("DescribeMacModificationTasks") + def describe_mac_modification_tasks( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + mac_modification_task_ids: MacModificationTaskIdList | None = None, + max_results: DescribeMacModificationTasksMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeMacModificationTasksResult: + raise NotImplementedError + + @handler("DescribeManagedPrefixLists") + def describe_managed_prefix_lists( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: PrefixListMaxResults | None = None, + next_token: NextToken | None = None, + prefix_list_ids: ValueStringList | None = None, + **kwargs, + ) -> DescribeManagedPrefixListsResult: + raise NotImplementedError + + @handler("DescribeMovingAddresses") + def describe_moving_addresses( + self, + context: RequestContext, + dry_run: Boolean | None = None, + public_ips: ValueStringList | None = None, + next_token: String | None = None, + filters: FilterList | None = None, + max_results: DescribeMovingAddressesMaxResults | None = None, + **kwargs, + ) -> DescribeMovingAddressesResult: + raise NotImplementedError + + @handler("DescribeNatGateways") + def describe_nat_gateways( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filter: FilterList | None = None, + max_results: DescribeNatGatewaysMaxResults | None = None, + nat_gateway_ids: NatGatewayIdStringList | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeNatGatewaysResult: + raise NotImplementedError + + @handler("DescribeNetworkAcls") + def describe_network_acls( + self, + context: RequestContext, + next_token: String | None = None, + max_results: DescribeNetworkAclsMaxResults | None = None, + dry_run: Boolean | None = None, + network_acl_ids: NetworkAclIdStringList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeNetworkAclsResult: + raise NotImplementedError + + @handler("DescribeNetworkInsightsAccessScopeAnalyses") + def describe_network_insights_access_scope_analyses( + self, + context: RequestContext, + network_insights_access_scope_analysis_ids: NetworkInsightsAccessScopeAnalysisIdList + | None = None, + network_insights_access_scope_id: NetworkInsightsAccessScopeId | None = None, + analysis_start_time_begin: MillisecondDateTime | None = None, + analysis_start_time_end: MillisecondDateTime | None = None, + filters: FilterList | None = None, + max_results: NetworkInsightsMaxResults | None = None, + dry_run: Boolean | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeNetworkInsightsAccessScopeAnalysesResult: + raise NotImplementedError + + @handler("DescribeNetworkInsightsAccessScopes") + def describe_network_insights_access_scopes( + self, + context: RequestContext, + network_insights_access_scope_ids: NetworkInsightsAccessScopeIdList | None = None, + filters: FilterList | None = None, + max_results: NetworkInsightsMaxResults | None = None, + dry_run: Boolean | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeNetworkInsightsAccessScopesResult: + raise NotImplementedError + + @handler("DescribeNetworkInsightsAnalyses") + def describe_network_insights_analyses( + self, + context: RequestContext, + network_insights_analysis_ids: NetworkInsightsAnalysisIdList | None = None, + network_insights_path_id: NetworkInsightsPathId | None = None, + analysis_start_time: MillisecondDateTime | None = None, + analysis_end_time: MillisecondDateTime | None = None, + filters: FilterList | None = None, + max_results: NetworkInsightsMaxResults | None = None, + dry_run: Boolean | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeNetworkInsightsAnalysesResult: + raise NotImplementedError + + @handler("DescribeNetworkInsightsPaths") + def describe_network_insights_paths( + self, + context: RequestContext, + network_insights_path_ids: NetworkInsightsPathIdList | None = None, + filters: FilterList | None = None, + max_results: NetworkInsightsMaxResults | None = None, + dry_run: Boolean | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeNetworkInsightsPathsResult: + raise NotImplementedError + + @handler("DescribeNetworkInterfaceAttribute") + def describe_network_interface_attribute( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + dry_run: Boolean | None = None, + attribute: NetworkInterfaceAttribute | None = None, + **kwargs, + ) -> DescribeNetworkInterfaceAttributeResult: + raise NotImplementedError + + @handler("DescribeNetworkInterfacePermissions") + def describe_network_interface_permissions( + self, + context: RequestContext, + network_interface_permission_ids: NetworkInterfacePermissionIdList | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: DescribeNetworkInterfacePermissionsMaxResults | None = None, + **kwargs, + ) -> DescribeNetworkInterfacePermissionsResult: + raise NotImplementedError + + @handler("DescribeNetworkInterfaces") + def describe_network_interfaces( + self, + context: RequestContext, + next_token: String | None = None, + max_results: DescribeNetworkInterfacesMaxResults | None = None, + dry_run: Boolean | None = None, + network_interface_ids: NetworkInterfaceIdList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeNetworkInterfacesResult: + raise NotImplementedError + + @handler("DescribeOutpostLags") + def describe_outpost_lags( + self, + context: RequestContext, + outpost_lag_ids: OutpostLagIdSet | None = None, + filters: FilterList | None = None, + max_results: OutpostLagMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeOutpostLagsResult: + raise NotImplementedError + + @handler("DescribePlacementGroups") + def describe_placement_groups( + self, + context: RequestContext, + group_ids: PlacementGroupIdStringList | None = None, + dry_run: Boolean | None = None, + group_names: PlacementGroupStringList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribePlacementGroupsResult: + raise NotImplementedError + + @handler("DescribePrefixLists") + def describe_prefix_lists( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + prefix_list_ids: PrefixListResourceIdStringList | None = None, + **kwargs, + ) -> DescribePrefixListsResult: + raise NotImplementedError + + @handler("DescribePrincipalIdFormat") + def describe_principal_id_format( + self, + context: RequestContext, + dry_run: Boolean | None = None, + resources: ResourceList | None = None, + max_results: DescribePrincipalIdFormatMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribePrincipalIdFormatResult: + raise NotImplementedError + + @handler("DescribePublicIpv4Pools") + def describe_public_ipv4_pools( + self, + context: RequestContext, + pool_ids: PublicIpv4PoolIdStringList | None = None, + next_token: NextToken | None = None, + max_results: PoolMaxResults | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribePublicIpv4PoolsResult: + raise NotImplementedError + + @handler("DescribeRegions") + def describe_regions( + self, + context: RequestContext, + region_names: RegionNameStringList | None = None, + all_regions: Boolean | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeRegionsResult: + raise NotImplementedError + + @handler("DescribeReplaceRootVolumeTasks") + def describe_replace_root_volume_tasks( + self, + context: RequestContext, + replace_root_volume_task_ids: ReplaceRootVolumeTaskIds | None = None, + filters: FilterList | None = None, + max_results: DescribeReplaceRootVolumeTasksMaxResults | None = None, + next_token: NextToken | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeReplaceRootVolumeTasksResult: + raise NotImplementedError + + @handler("DescribeReservedInstances") + def describe_reserved_instances( + self, + context: RequestContext, + offering_class: OfferingClassType | None = None, + reserved_instances_ids: ReservedInstancesIdStringList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + offering_type: OfferingTypeValues | None = None, + **kwargs, + ) -> DescribeReservedInstancesResult: + raise NotImplementedError + + @handler("DescribeReservedInstancesListings") + def describe_reserved_instances_listings( + self, + context: RequestContext, + reserved_instances_id: ReservationId | None = None, + reserved_instances_listing_id: ReservedInstancesListingId | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeReservedInstancesListingsResult: + raise NotImplementedError + + @handler("DescribeReservedInstancesModifications") + def describe_reserved_instances_modifications( + self, + context: RequestContext, + reserved_instances_modification_ids: ReservedInstancesModificationIdStringList + | None = None, + next_token: String | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeReservedInstancesModificationsResult: + raise NotImplementedError + + @handler("DescribeReservedInstancesOfferings") + def describe_reserved_instances_offerings( + self, + context: RequestContext, + availability_zone: String | None = None, + include_marketplace: Boolean | None = None, + instance_type: InstanceType | None = None, + max_duration: Long | None = None, + max_instance_count: Integer | None = None, + min_duration: Long | None = None, + offering_class: OfferingClassType | None = None, + product_description: RIProductDescription | None = None, + reserved_instances_offering_ids: ReservedInstancesOfferingIdStringList | None = None, + availability_zone_id: AvailabilityZoneId | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + instance_tenancy: Tenancy | None = None, + offering_type: OfferingTypeValues | None = None, + next_token: String | None = None, + max_results: Integer | None = None, + **kwargs, + ) -> DescribeReservedInstancesOfferingsResult: + raise NotImplementedError + + @handler("DescribeRouteServerEndpoints") + def describe_route_server_endpoints( + self, + context: RequestContext, + route_server_endpoint_ids: RouteServerEndpointIdsList | None = None, + next_token: String | None = None, + max_results: RouteServerMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeRouteServerEndpointsResult: + raise NotImplementedError + + @handler("DescribeRouteServerPeers") + def describe_route_server_peers( + self, + context: RequestContext, + route_server_peer_ids: RouteServerPeerIdsList | None = None, + next_token: String | None = None, + max_results: RouteServerMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeRouteServerPeersResult: + raise NotImplementedError + + @handler("DescribeRouteServers") + def describe_route_servers( + self, + context: RequestContext, + route_server_ids: RouteServerIdsList | None = None, + next_token: String | None = None, + max_results: RouteServerMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeRouteServersResult: + raise NotImplementedError + + @handler("DescribeRouteTables") + def describe_route_tables( + self, + context: RequestContext, + next_token: String | None = None, + max_results: DescribeRouteTablesMaxResults | None = None, + dry_run: Boolean | None = None, + route_table_ids: RouteTableIdStringList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeRouteTablesResult: + raise NotImplementedError + + @handler("DescribeScheduledInstanceAvailability") + def describe_scheduled_instance_availability( + self, + context: RequestContext, + first_slot_start_time_range: SlotDateTimeRangeRequest, + recurrence: ScheduledInstanceRecurrenceRequest, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: DescribeScheduledInstanceAvailabilityMaxResults | None = None, + max_slot_duration_in_hours: Integer | None = None, + min_slot_duration_in_hours: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeScheduledInstanceAvailabilityResult: + raise NotImplementedError + + @handler("DescribeScheduledInstances") + def describe_scheduled_instances( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + scheduled_instance_ids: ScheduledInstanceIdRequestSet | None = None, + slot_start_time_range: SlotStartTimeRangeRequest | None = None, + **kwargs, + ) -> DescribeScheduledInstancesResult: + raise NotImplementedError + + @handler("DescribeSecurityGroupReferences") + def describe_security_group_references( + self, context: RequestContext, group_id: GroupIds, dry_run: Boolean | None = None, **kwargs + ) -> DescribeSecurityGroupReferencesResult: + raise NotImplementedError + + @handler("DescribeSecurityGroupRules") + def describe_security_group_rules( + self, + context: RequestContext, + filters: FilterList | None = None, + security_group_rule_ids: SecurityGroupRuleIdList | None = None, + dry_run: Boolean | None = None, + next_token: String | None = None, + max_results: DescribeSecurityGroupRulesMaxResults | None = None, + **kwargs, + ) -> DescribeSecurityGroupRulesResult: + raise NotImplementedError + + @handler("DescribeSecurityGroupVpcAssociations") + def describe_security_group_vpc_associations( + self, + context: RequestContext, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: DescribeSecurityGroupVpcAssociationsMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeSecurityGroupVpcAssociationsResult: + raise NotImplementedError + + @handler("DescribeSecurityGroups") + def describe_security_groups( + self, + context: RequestContext, + group_ids: GroupIdStringList | None = None, + group_names: GroupNameStringList | None = None, + next_token: String | None = None, + max_results: DescribeSecurityGroupsMaxResults | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeSecurityGroupsResult: + raise NotImplementedError + + @handler("DescribeServiceLinkVirtualInterfaces") + def describe_service_link_virtual_interfaces( + self, + context: RequestContext, + service_link_virtual_interface_ids: ServiceLinkVirtualInterfaceIdSet | None = None, + filters: FilterList | None = None, + max_results: ServiceLinkMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeServiceLinkVirtualInterfacesResult: + raise NotImplementedError + + @handler("DescribeSnapshotAttribute") + def describe_snapshot_attribute( + self, + context: RequestContext, + attribute: SnapshotAttributeName, + snapshot_id: SnapshotId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeSnapshotAttributeResult: + raise NotImplementedError + + @handler("DescribeSnapshotTierStatus") + def describe_snapshot_tier_status( + self, + context: RequestContext, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + next_token: String | None = None, + max_results: DescribeSnapshotTierStatusMaxResults | None = None, + **kwargs, + ) -> DescribeSnapshotTierStatusResult: + raise NotImplementedError + + @handler("DescribeSnapshots") + def describe_snapshots( + self, + context: RequestContext, + max_results: Integer | None = None, + next_token: String | None = None, + owner_ids: OwnerStringList | None = None, + restorable_by_user_ids: RestorableByStringList | None = None, + snapshot_ids: SnapshotIdStringList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeSnapshotsResult: + raise NotImplementedError + + @handler("DescribeSpotDatafeedSubscription") + def describe_spot_datafeed_subscription( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DescribeSpotDatafeedSubscriptionResult: + raise NotImplementedError + + @handler("DescribeSpotFleetInstances") + def describe_spot_fleet_instances( + self, + context: RequestContext, + spot_fleet_request_id: SpotFleetRequestId, + dry_run: Boolean | None = None, + next_token: String | None = None, + max_results: DescribeSpotFleetInstancesMaxResults | None = None, + **kwargs, + ) -> DescribeSpotFleetInstancesResponse: + raise NotImplementedError + + @handler("DescribeSpotFleetRequestHistory") + def describe_spot_fleet_request_history( + self, + context: RequestContext, + spot_fleet_request_id: SpotFleetRequestId, + start_time: DateTime, + dry_run: Boolean | None = None, + event_type: EventType | None = None, + next_token: String | None = None, + max_results: DescribeSpotFleetRequestHistoryMaxResults | None = None, + **kwargs, + ) -> DescribeSpotFleetRequestHistoryResponse: + raise NotImplementedError + + @handler("DescribeSpotFleetRequests") + def describe_spot_fleet_requests( + self, + context: RequestContext, + dry_run: Boolean | None = None, + spot_fleet_request_ids: SpotFleetRequestIdList | None = None, + next_token: String | None = None, + max_results: Integer | None = None, + **kwargs, + ) -> DescribeSpotFleetRequestsResponse: + raise NotImplementedError + + @handler("DescribeSpotInstanceRequests") + def describe_spot_instance_requests( + self, + context: RequestContext, + next_token: String | None = None, + max_results: Integer | None = None, + dry_run: Boolean | None = None, + spot_instance_request_ids: SpotInstanceRequestIdList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeSpotInstanceRequestsResult: + raise NotImplementedError + + @handler("DescribeSpotPriceHistory") + def describe_spot_price_history( + self, + context: RequestContext, + dry_run: Boolean | None = None, + start_time: DateTime | None = None, + end_time: DateTime | None = None, + instance_types: InstanceTypeList | None = None, + product_descriptions: ProductDescriptionList | None = None, + filters: FilterList | None = None, + availability_zone: String | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeSpotPriceHistoryResult: + raise NotImplementedError + + @handler("DescribeStaleSecurityGroups") + def describe_stale_security_groups( + self, + context: RequestContext, + vpc_id: VpcId, + dry_run: Boolean | None = None, + max_results: DescribeStaleSecurityGroupsMaxResults | None = None, + next_token: DescribeStaleSecurityGroupsNextToken | None = None, + **kwargs, + ) -> DescribeStaleSecurityGroupsResult: + raise NotImplementedError + + @handler("DescribeStoreImageTasks") + def describe_store_image_tasks( + self, + context: RequestContext, + image_ids: ImageIdList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: DescribeStoreImageTasksRequestMaxResults | None = None, + **kwargs, + ) -> DescribeStoreImageTasksResult: + raise NotImplementedError + + @handler("DescribeSubnets") + def describe_subnets( + self, + context: RequestContext, + filters: FilterList | None = None, + subnet_ids: SubnetIdStringList | None = None, + next_token: String | None = None, + max_results: DescribeSubnetsMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeSubnetsResult: + raise NotImplementedError + + @handler("DescribeTags") + def describe_tags( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeTagsResult: + raise NotImplementedError + + @handler("DescribeTrafficMirrorFilterRules") + def describe_traffic_mirror_filter_rules( + self, + context: RequestContext, + traffic_mirror_filter_rule_ids: TrafficMirrorFilterRuleIdList | None = None, + traffic_mirror_filter_id: TrafficMirrorFilterId | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: TrafficMirroringMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeTrafficMirrorFilterRulesResult: + raise NotImplementedError + + @handler("DescribeTrafficMirrorFilters") + def describe_traffic_mirror_filters( + self, + context: RequestContext, + traffic_mirror_filter_ids: TrafficMirrorFilterIdList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: TrafficMirroringMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeTrafficMirrorFiltersResult: + raise NotImplementedError + + @handler("DescribeTrafficMirrorSessions") + def describe_traffic_mirror_sessions( + self, + context: RequestContext, + traffic_mirror_session_ids: TrafficMirrorSessionIdList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: TrafficMirroringMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeTrafficMirrorSessionsResult: + raise NotImplementedError + + @handler("DescribeTrafficMirrorTargets") + def describe_traffic_mirror_targets( + self, + context: RequestContext, + traffic_mirror_target_ids: TrafficMirrorTargetIdList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: TrafficMirroringMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeTrafficMirrorTargetsResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayAttachments") + def describe_transit_gateway_attachments( + self, + context: RequestContext, + transit_gateway_attachment_ids: TransitGatewayAttachmentIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayAttachmentsResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayConnectPeers") + def describe_transit_gateway_connect_peers( + self, + context: RequestContext, + transit_gateway_connect_peer_ids: TransitGatewayConnectPeerIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayConnectPeersResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayConnects") + def describe_transit_gateway_connects( + self, + context: RequestContext, + transit_gateway_attachment_ids: TransitGatewayAttachmentIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayConnectsResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayMulticastDomains") + def describe_transit_gateway_multicast_domains( + self, + context: RequestContext, + transit_gateway_multicast_domain_ids: TransitGatewayMulticastDomainIdStringList + | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayMulticastDomainsResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayPeeringAttachments") + def describe_transit_gateway_peering_attachments( + self, + context: RequestContext, + transit_gateway_attachment_ids: TransitGatewayAttachmentIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayPeeringAttachmentsResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayPolicyTables") + def describe_transit_gateway_policy_tables( + self, + context: RequestContext, + transit_gateway_policy_table_ids: TransitGatewayPolicyTableIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayPolicyTablesResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayRouteTableAnnouncements") + def describe_transit_gateway_route_table_announcements( + self, + context: RequestContext, + transit_gateway_route_table_announcement_ids: TransitGatewayRouteTableAnnouncementIdStringList + | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayRouteTableAnnouncementsResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayRouteTables") + def describe_transit_gateway_route_tables( + self, + context: RequestContext, + transit_gateway_route_table_ids: TransitGatewayRouteTableIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayRouteTablesResult: + raise NotImplementedError + + @handler("DescribeTransitGatewayVpcAttachments") + def describe_transit_gateway_vpc_attachments( + self, + context: RequestContext, + transit_gateway_attachment_ids: TransitGatewayAttachmentIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewayVpcAttachmentsResult: + raise NotImplementedError + + @handler("DescribeTransitGateways") + def describe_transit_gateways( + self, + context: RequestContext, + transit_gateway_ids: TransitGatewayIdStringList | None = None, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeTransitGatewaysResult: + raise NotImplementedError + + @handler("DescribeTrunkInterfaceAssociations") + def describe_trunk_interface_associations( + self, + context: RequestContext, + association_ids: TrunkInterfaceAssociationIdList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: DescribeTrunkInterfaceAssociationsMaxResults | None = None, + **kwargs, + ) -> DescribeTrunkInterfaceAssociationsResult: + raise NotImplementedError + + @handler("DescribeVerifiedAccessEndpoints") + def describe_verified_access_endpoints( + self, + context: RequestContext, + verified_access_endpoint_ids: VerifiedAccessEndpointIdList | None = None, + verified_access_instance_id: VerifiedAccessInstanceId | None = None, + verified_access_group_id: VerifiedAccessGroupId | None = None, + max_results: DescribeVerifiedAccessEndpointsMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVerifiedAccessEndpointsResult: + raise NotImplementedError + + @handler("DescribeVerifiedAccessGroups") + def describe_verified_access_groups( + self, + context: RequestContext, + verified_access_group_ids: VerifiedAccessGroupIdList | None = None, + verified_access_instance_id: VerifiedAccessInstanceId | None = None, + max_results: DescribeVerifiedAccessGroupMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVerifiedAccessGroupsResult: + raise NotImplementedError + + @handler("DescribeVerifiedAccessInstanceLoggingConfigurations") + def describe_verified_access_instance_logging_configurations( + self, + context: RequestContext, + verified_access_instance_ids: VerifiedAccessInstanceIdList | None = None, + max_results: DescribeVerifiedAccessInstanceLoggingConfigurationsMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVerifiedAccessInstanceLoggingConfigurationsResult: + raise NotImplementedError + + @handler("DescribeVerifiedAccessInstances") + def describe_verified_access_instances( + self, + context: RequestContext, + verified_access_instance_ids: VerifiedAccessInstanceIdList | None = None, + max_results: DescribeVerifiedAccessInstancesMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVerifiedAccessInstancesResult: + raise NotImplementedError + + @handler("DescribeVerifiedAccessTrustProviders") + def describe_verified_access_trust_providers( + self, + context: RequestContext, + verified_access_trust_provider_ids: VerifiedAccessTrustProviderIdList | None = None, + max_results: DescribeVerifiedAccessTrustProvidersMaxResults | None = None, + next_token: NextToken | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVerifiedAccessTrustProvidersResult: + raise NotImplementedError + + @handler("DescribeVolumeAttribute") + def describe_volume_attribute( + self, + context: RequestContext, + attribute: VolumeAttributeName, + volume_id: VolumeId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVolumeAttributeResult: + raise NotImplementedError + + @handler("DescribeVolumeStatus") + def describe_volume_status( + self, + context: RequestContext, + max_results: Integer | None = None, + next_token: String | None = None, + volume_ids: VolumeIdStringList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeVolumeStatusResult: + raise NotImplementedError + + @handler("DescribeVolumes") + def describe_volumes( + self, + context: RequestContext, + volume_ids: VolumeIdStringList | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: Integer | None = None, + **kwargs, + ) -> DescribeVolumesResult: + raise NotImplementedError + + @handler("DescribeVolumesModifications") + def describe_volumes_modifications( + self, + context: RequestContext, + dry_run: Boolean | None = None, + volume_ids: VolumeIdStringList | None = None, + filters: FilterList | None = None, + next_token: String | None = None, + max_results: Integer | None = None, + **kwargs, + ) -> DescribeVolumesModificationsResult: + raise NotImplementedError + + @handler("DescribeVpcAttribute") + def describe_vpc_attribute( + self, + context: RequestContext, + attribute: VpcAttributeName, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVpcAttributeResult: + raise NotImplementedError + + @handler("DescribeVpcBlockPublicAccessExclusions") + def describe_vpc_block_public_access_exclusions( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + exclusion_ids: VpcBlockPublicAccessExclusionIdList | None = None, + next_token: String | None = None, + max_results: DescribeVpcBlockPublicAccessExclusionsMaxResults | None = None, + **kwargs, + ) -> DescribeVpcBlockPublicAccessExclusionsResult: + raise NotImplementedError + + @handler("DescribeVpcBlockPublicAccessOptions") + def describe_vpc_block_public_access_options( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DescribeVpcBlockPublicAccessOptionsResult: + raise NotImplementedError + + @handler("DescribeVpcClassicLink") + def describe_vpc_classic_link( + self, + context: RequestContext, + dry_run: Boolean | None = None, + vpc_ids: VpcClassicLinkIdList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeVpcClassicLinkResult: + raise NotImplementedError + + @handler("DescribeVpcClassicLinkDnsSupport") + def describe_vpc_classic_link_dns_support( + self, + context: RequestContext, + vpc_ids: VpcClassicLinkIdList | None = None, + max_results: DescribeVpcClassicLinkDnsSupportMaxResults | None = None, + next_token: DescribeVpcClassicLinkDnsSupportNextToken | None = None, + **kwargs, + ) -> DescribeVpcClassicLinkDnsSupportResult: + raise NotImplementedError + + @handler("DescribeVpcEndpointAssociations") + def describe_vpc_endpoint_associations( + self, + context: RequestContext, + dry_run: Boolean | None = None, + vpc_endpoint_ids: VpcEndpointIdList | None = None, + filters: FilterList | None = None, + max_results: maxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeVpcEndpointAssociationsResult: + raise NotImplementedError + + @handler("DescribeVpcEndpointConnectionNotifications") + def describe_vpc_endpoint_connection_notifications( + self, + context: RequestContext, + dry_run: Boolean | None = None, + connection_notification_id: ConnectionNotificationId | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeVpcEndpointConnectionNotificationsResult: + raise NotImplementedError + + @handler("DescribeVpcEndpointConnections") + def describe_vpc_endpoint_connections( + self, + context: RequestContext, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeVpcEndpointConnectionsResult: + raise NotImplementedError + + @handler("DescribeVpcEndpointServiceConfigurations") + def describe_vpc_endpoint_service_configurations( + self, + context: RequestContext, + dry_run: Boolean | None = None, + service_ids: VpcEndpointServiceIdList | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeVpcEndpointServiceConfigurationsResult: + raise NotImplementedError + + @handler("DescribeVpcEndpointServicePermissions") + def describe_vpc_endpoint_service_permissions( + self, + context: RequestContext, + service_id: VpcEndpointServiceId, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeVpcEndpointServicePermissionsResult: + raise NotImplementedError + + @handler("DescribeVpcEndpointServices") + def describe_vpc_endpoint_services( + self, + context: RequestContext, + dry_run: Boolean | None = None, + service_names: ValueStringList | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + service_regions: ValueStringList | None = None, + **kwargs, + ) -> DescribeVpcEndpointServicesResult: + raise NotImplementedError + + @handler("DescribeVpcEndpoints") + def describe_vpc_endpoints( + self, + context: RequestContext, + dry_run: Boolean | None = None, + vpc_endpoint_ids: VpcEndpointIdList | None = None, + filters: FilterList | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeVpcEndpointsResult: + raise NotImplementedError + + @handler("DescribeVpcPeeringConnections") + def describe_vpc_peering_connections( + self, + context: RequestContext, + next_token: String | None = None, + max_results: DescribeVpcPeeringConnectionsMaxResults | None = None, + dry_run: Boolean | None = None, + vpc_peering_connection_ids: VpcPeeringConnectionIdList | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> DescribeVpcPeeringConnectionsResult: + raise NotImplementedError + + @handler("DescribeVpcs") + def describe_vpcs( + self, + context: RequestContext, + filters: FilterList | None = None, + vpc_ids: VpcIdStringList | None = None, + next_token: String | None = None, + max_results: DescribeVpcsMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVpcsResult: + raise NotImplementedError + + @handler("DescribeVpnConnections") + def describe_vpn_connections( + self, + context: RequestContext, + filters: FilterList | None = None, + vpn_connection_ids: VpnConnectionIdStringList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVpnConnectionsResult: + raise NotImplementedError + + @handler("DescribeVpnGateways") + def describe_vpn_gateways( + self, + context: RequestContext, + filters: FilterList | None = None, + vpn_gateway_ids: VpnGatewayIdStringList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DescribeVpnGatewaysResult: + raise NotImplementedError + + @handler("DetachClassicLinkVpc") + def detach_classic_link_vpc( + self, + context: RequestContext, + instance_id: InstanceId, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DetachClassicLinkVpcResult: + raise NotImplementedError + + @handler("DetachInternetGateway") + def detach_internet_gateway( + self, + context: RequestContext, + internet_gateway_id: InternetGatewayId, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DetachNetworkInterface") + def detach_network_interface( + self, + context: RequestContext, + attachment_id: NetworkInterfaceAttachmentId, + dry_run: Boolean | None = None, + force: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DetachVerifiedAccessTrustProvider") + def detach_verified_access_trust_provider( + self, + context: RequestContext, + verified_access_instance_id: VerifiedAccessInstanceId, + verified_access_trust_provider_id: VerifiedAccessTrustProviderId, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DetachVerifiedAccessTrustProviderResult: + raise NotImplementedError + + @handler("DetachVolume") + def detach_volume( + self, + context: RequestContext, + volume_id: VolumeIdWithResolver, + device: String | None = None, + force: Boolean | None = None, + instance_id: InstanceIdForResolver | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> VolumeAttachment: + raise NotImplementedError + + @handler("DetachVpnGateway") + def detach_vpn_gateway( + self, + context: RequestContext, + vpc_id: VpcId, + vpn_gateway_id: VpnGatewayId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DisableAddressTransfer") + def disable_address_transfer( + self, + context: RequestContext, + allocation_id: AllocationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisableAddressTransferResult: + raise NotImplementedError + + @handler("DisableAllowedImagesSettings") + def disable_allowed_images_settings( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DisableAllowedImagesSettingsResult: + raise NotImplementedError + + @handler("DisableAwsNetworkPerformanceMetricSubscription") + def disable_aws_network_performance_metric_subscription( + self, + context: RequestContext, + source: String | None = None, + destination: String | None = None, + metric: MetricType | None = None, + statistic: StatisticType | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisableAwsNetworkPerformanceMetricSubscriptionResult: + raise NotImplementedError + + @handler("DisableEbsEncryptionByDefault") + def disable_ebs_encryption_by_default( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DisableEbsEncryptionByDefaultResult: + raise NotImplementedError + + @handler("DisableFastLaunch") + def disable_fast_launch( + self, + context: RequestContext, + image_id: ImageId, + force: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisableFastLaunchResult: + raise NotImplementedError + + @handler("DisableFastSnapshotRestores") + def disable_fast_snapshot_restores( + self, + context: RequestContext, + availability_zones: AvailabilityZoneStringList, + source_snapshot_ids: SnapshotIdStringList, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisableFastSnapshotRestoresResult: + raise NotImplementedError + + @handler("DisableImage") + def disable_image( + self, context: RequestContext, image_id: ImageId, dry_run: Boolean | None = None, **kwargs + ) -> DisableImageResult: + raise NotImplementedError + + @handler("DisableImageBlockPublicAccess") + def disable_image_block_public_access( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DisableImageBlockPublicAccessResult: + raise NotImplementedError + + @handler("DisableImageDeprecation") + def disable_image_deprecation( + self, context: RequestContext, image_id: ImageId, dry_run: Boolean | None = None, **kwargs + ) -> DisableImageDeprecationResult: + raise NotImplementedError + + @handler("DisableImageDeregistrationProtection") + def disable_image_deregistration_protection( + self, context: RequestContext, image_id: ImageId, dry_run: Boolean | None = None, **kwargs + ) -> DisableImageDeregistrationProtectionResult: + raise NotImplementedError + + @handler("DisableIpamOrganizationAdminAccount") + def disable_ipam_organization_admin_account( + self, + context: RequestContext, + delegated_admin_account_id: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisableIpamOrganizationAdminAccountResult: + raise NotImplementedError + + @handler("DisableRouteServerPropagation") + def disable_route_server_propagation( + self, + context: RequestContext, + route_server_id: RouteServerId, + route_table_id: RouteTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisableRouteServerPropagationResult: + raise NotImplementedError + + @handler("DisableSerialConsoleAccess") + def disable_serial_console_access( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DisableSerialConsoleAccessResult: + raise NotImplementedError + + @handler("DisableSnapshotBlockPublicAccess") + def disable_snapshot_block_public_access( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> DisableSnapshotBlockPublicAccessResult: + raise NotImplementedError + + @handler("DisableTransitGatewayRouteTablePropagation") + def disable_transit_gateway_route_table_propagation( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + dry_run: Boolean | None = None, + transit_gateway_route_table_announcement_id: TransitGatewayRouteTableAnnouncementId + | None = None, + **kwargs, + ) -> DisableTransitGatewayRouteTablePropagationResult: + raise NotImplementedError + + @handler("DisableVgwRoutePropagation") + def disable_vgw_route_propagation( + self, + context: RequestContext, + gateway_id: VpnGatewayId, + route_table_id: RouteTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DisableVpcClassicLink") + def disable_vpc_classic_link( + self, context: RequestContext, vpc_id: VpcId, dry_run: Boolean | None = None, **kwargs + ) -> DisableVpcClassicLinkResult: + raise NotImplementedError + + @handler("DisableVpcClassicLinkDnsSupport") + def disable_vpc_classic_link_dns_support( + self, context: RequestContext, vpc_id: VpcId | None = None, **kwargs + ) -> DisableVpcClassicLinkDnsSupportResult: + raise NotImplementedError + + @handler("DisassociateAddress") + def disassociate_address( + self, + context: RequestContext, + association_id: ElasticIpAssociationId | None = None, + public_ip: EipAllocationPublicIp | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DisassociateCapacityReservationBillingOwner") + def disassociate_capacity_reservation_billing_owner( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + unused_reservation_billing_owner_id: AccountID, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateCapacityReservationBillingOwnerResult: + raise NotImplementedError + + @handler("DisassociateClientVpnTargetNetwork") + def disassociate_client_vpn_target_network( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + association_id: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateClientVpnTargetNetworkResult: + raise NotImplementedError + + @handler("DisassociateEnclaveCertificateIamRole") + def disassociate_enclave_certificate_iam_role( + self, + context: RequestContext, + certificate_arn: CertificateId, + role_arn: RoleId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateEnclaveCertificateIamRoleResult: + raise NotImplementedError + + @handler("DisassociateIamInstanceProfile") + def disassociate_iam_instance_profile( + self, context: RequestContext, association_id: IamInstanceProfileAssociationId, **kwargs + ) -> DisassociateIamInstanceProfileResult: + raise NotImplementedError + + @handler("DisassociateInstanceEventWindow") + def disassociate_instance_event_window( + self, + context: RequestContext, + instance_event_window_id: InstanceEventWindowId, + association_target: InstanceEventWindowDisassociationRequest, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateInstanceEventWindowResult: + raise NotImplementedError + + @handler("DisassociateIpamByoasn") + def disassociate_ipam_byoasn( + self, + context: RequestContext, + asn: String, + cidr: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateIpamByoasnResult: + raise NotImplementedError + + @handler("DisassociateIpamResourceDiscovery") + def disassociate_ipam_resource_discovery( + self, + context: RequestContext, + ipam_resource_discovery_association_id: IpamResourceDiscoveryAssociationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateIpamResourceDiscoveryResult: + raise NotImplementedError + + @handler("DisassociateNatGatewayAddress") + def disassociate_nat_gateway_address( + self, + context: RequestContext, + nat_gateway_id: NatGatewayId, + association_ids: EipAssociationIdList, + max_drain_duration_seconds: DrainSeconds | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateNatGatewayAddressResult: + raise NotImplementedError + + @handler("DisassociateRouteServer") + def disassociate_route_server( + self, + context: RequestContext, + route_server_id: RouteServerId, + vpc_id: VpcId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateRouteServerResult: + raise NotImplementedError + + @handler("DisassociateRouteTable") + def disassociate_route_table( + self, + context: RequestContext, + association_id: RouteTableAssociationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DisassociateSecurityGroupVpc") + def disassociate_security_group_vpc( + self, + context: RequestContext, + group_id: DisassociateSecurityGroupVpcSecurityGroupId, + vpc_id: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateSecurityGroupVpcResult: + raise NotImplementedError + + @handler("DisassociateSubnetCidrBlock") + def disassociate_subnet_cidr_block( + self, context: RequestContext, association_id: SubnetCidrAssociationId, **kwargs + ) -> DisassociateSubnetCidrBlockResult: + raise NotImplementedError + + @handler("DisassociateTransitGatewayMulticastDomain") + def disassociate_transit_gateway_multicast_domain( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + subnet_ids: TransitGatewaySubnetIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateTransitGatewayMulticastDomainResult: + raise NotImplementedError + + @handler("DisassociateTransitGatewayPolicyTable") + def disassociate_transit_gateway_policy_table( + self, + context: RequestContext, + transit_gateway_policy_table_id: TransitGatewayPolicyTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateTransitGatewayPolicyTableResult: + raise NotImplementedError + + @handler("DisassociateTransitGatewayRouteTable") + def disassociate_transit_gateway_route_table( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateTransitGatewayRouteTableResult: + raise NotImplementedError + + @handler("DisassociateTrunkInterface") + def disassociate_trunk_interface( + self, + context: RequestContext, + association_id: TrunkInterfaceAssociationId, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> DisassociateTrunkInterfaceResult: + raise NotImplementedError + + @handler("DisassociateVpcCidrBlock") + def disassociate_vpc_cidr_block( + self, context: RequestContext, association_id: VpcCidrAssociationId, **kwargs + ) -> DisassociateVpcCidrBlockResult: + raise NotImplementedError + + @handler("EnableAddressTransfer") + def enable_address_transfer( + self, + context: RequestContext, + allocation_id: AllocationId, + transfer_account_id: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableAddressTransferResult: + raise NotImplementedError + + @handler("EnableAllowedImagesSettings") + def enable_allowed_images_settings( + self, + context: RequestContext, + allowed_images_settings_state: AllowedImagesSettingsEnabledState, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableAllowedImagesSettingsResult: + raise NotImplementedError + + @handler("EnableAwsNetworkPerformanceMetricSubscription") + def enable_aws_network_performance_metric_subscription( + self, + context: RequestContext, + source: String | None = None, + destination: String | None = None, + metric: MetricType | None = None, + statistic: StatisticType | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableAwsNetworkPerformanceMetricSubscriptionResult: + raise NotImplementedError + + @handler("EnableEbsEncryptionByDefault") + def enable_ebs_encryption_by_default( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> EnableEbsEncryptionByDefaultResult: + raise NotImplementedError + + @handler("EnableFastLaunch") + def enable_fast_launch( + self, + context: RequestContext, + image_id: ImageId, + resource_type: String | None = None, + snapshot_configuration: FastLaunchSnapshotConfigurationRequest | None = None, + launch_template: FastLaunchLaunchTemplateSpecificationRequest | None = None, + max_parallel_launches: Integer | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableFastLaunchResult: + raise NotImplementedError + + @handler("EnableFastSnapshotRestores") + def enable_fast_snapshot_restores( + self, + context: RequestContext, + availability_zones: AvailabilityZoneStringList, + source_snapshot_ids: SnapshotIdStringList, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableFastSnapshotRestoresResult: + raise NotImplementedError + + @handler("EnableImage") + def enable_image( + self, context: RequestContext, image_id: ImageId, dry_run: Boolean | None = None, **kwargs + ) -> EnableImageResult: + raise NotImplementedError + + @handler("EnableImageBlockPublicAccess") + def enable_image_block_public_access( + self, + context: RequestContext, + image_block_public_access_state: ImageBlockPublicAccessEnabledState, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableImageBlockPublicAccessResult: + raise NotImplementedError + + @handler("EnableImageDeprecation") + def enable_image_deprecation( + self, + context: RequestContext, + image_id: ImageId, + deprecate_at: MillisecondDateTime, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableImageDeprecationResult: + raise NotImplementedError + + @handler("EnableImageDeregistrationProtection") + def enable_image_deregistration_protection( + self, + context: RequestContext, + image_id: ImageId, + with_cooldown: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableImageDeregistrationProtectionResult: + raise NotImplementedError + + @handler("EnableIpamOrganizationAdminAccount") + def enable_ipam_organization_admin_account( + self, + context: RequestContext, + delegated_admin_account_id: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableIpamOrganizationAdminAccountResult: + raise NotImplementedError + + @handler("EnableReachabilityAnalyzerOrganizationSharing") + def enable_reachability_analyzer_organization_sharing( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> EnableReachabilityAnalyzerOrganizationSharingResult: + raise NotImplementedError + + @handler("EnableRouteServerPropagation") + def enable_route_server_propagation( + self, + context: RequestContext, + route_server_id: RouteServerId, + route_table_id: RouteTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableRouteServerPropagationResult: + raise NotImplementedError + + @handler("EnableSerialConsoleAccess") + def enable_serial_console_access( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> EnableSerialConsoleAccessResult: + raise NotImplementedError + + @handler("EnableSnapshotBlockPublicAccess") + def enable_snapshot_block_public_access( + self, + context: RequestContext, + state: SnapshotBlockPublicAccessState, + dry_run: Boolean | None = None, + **kwargs, + ) -> EnableSnapshotBlockPublicAccessResult: + raise NotImplementedError + + @handler("EnableTransitGatewayRouteTablePropagation") + def enable_transit_gateway_route_table_propagation( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + dry_run: Boolean | None = None, + transit_gateway_route_table_announcement_id: TransitGatewayRouteTableAnnouncementId + | None = None, + **kwargs, + ) -> EnableTransitGatewayRouteTablePropagationResult: + raise NotImplementedError + + @handler("EnableVgwRoutePropagation") + def enable_vgw_route_propagation( + self, + context: RequestContext, + gateway_id: VpnGatewayId, + route_table_id: RouteTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("EnableVolumeIO") + def enable_volume_io( + self, context: RequestContext, volume_id: VolumeId, dry_run: Boolean | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("EnableVpcClassicLink") + def enable_vpc_classic_link( + self, context: RequestContext, vpc_id: VpcId, dry_run: Boolean | None = None, **kwargs + ) -> EnableVpcClassicLinkResult: + raise NotImplementedError + + @handler("EnableVpcClassicLinkDnsSupport") + def enable_vpc_classic_link_dns_support( + self, context: RequestContext, vpc_id: VpcId | None = None, **kwargs + ) -> EnableVpcClassicLinkDnsSupportResult: + raise NotImplementedError + + @handler("ExportClientVpnClientCertificateRevocationList") + def export_client_vpn_client_certificate_revocation_list( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ExportClientVpnClientCertificateRevocationListResult: + raise NotImplementedError + + @handler("ExportClientVpnClientConfiguration") + def export_client_vpn_client_configuration( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ExportClientVpnClientConfigurationResult: + raise NotImplementedError + + @handler("ExportImage") + def export_image( + self, + context: RequestContext, + disk_image_format: DiskImageFormat, + image_id: ImageId, + s3_export_location: ExportTaskS3LocationRequest, + client_token: String | None = None, + description: String | None = None, + dry_run: Boolean | None = None, + role_name: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> ExportImageResult: + raise NotImplementedError + + @handler("ExportTransitGatewayRoutes") + def export_transit_gateway_routes( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + s3_bucket: String, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ExportTransitGatewayRoutesResult: + raise NotImplementedError + + @handler("ExportVerifiedAccessInstanceClientConfiguration") + def export_verified_access_instance_client_configuration( + self, + context: RequestContext, + verified_access_instance_id: VerifiedAccessInstanceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ExportVerifiedAccessInstanceClientConfigurationResult: + raise NotImplementedError + + @handler("GetActiveVpnTunnelStatus") + def get_active_vpn_tunnel_status( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + vpn_tunnel_outside_ip_address: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetActiveVpnTunnelStatusResult: + raise NotImplementedError + + @handler("GetAllowedImagesSettings") + def get_allowed_images_settings( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> GetAllowedImagesSettingsResult: + raise NotImplementedError + + @handler("GetAssociatedEnclaveCertificateIamRoles") + def get_associated_enclave_certificate_iam_roles( + self, + context: RequestContext, + certificate_arn: CertificateId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetAssociatedEnclaveCertificateIamRolesResult: + raise NotImplementedError + + @handler("GetAssociatedIpv6PoolCidrs") + def get_associated_ipv6_pool_cidrs( + self, + context: RequestContext, + pool_id: Ipv6PoolEc2Id, + next_token: NextToken | None = None, + max_results: Ipv6PoolMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetAssociatedIpv6PoolCidrsResult: + raise NotImplementedError + + @handler("GetAwsNetworkPerformanceData") + def get_aws_network_performance_data( + self, + context: RequestContext, + data_queries: DataQueries | None = None, + start_time: MillisecondDateTime | None = None, + end_time: MillisecondDateTime | None = None, + max_results: Integer | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetAwsNetworkPerformanceDataResult: + raise NotImplementedError + + @handler("GetCapacityReservationUsage") + def get_capacity_reservation_usage( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + next_token: String | None = None, + max_results: GetCapacityReservationUsageRequestMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetCapacityReservationUsageResult: + raise NotImplementedError + + @handler("GetCoipPoolUsage") + def get_coip_pool_usage( + self, + context: RequestContext, + pool_id: Ipv4PoolCoipId, + filters: FilterList | None = None, + max_results: CoipPoolMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetCoipPoolUsageResult: + raise NotImplementedError + + @handler("GetConsoleOutput") + def get_console_output( + self, + context: RequestContext, + instance_id: InstanceId, + latest: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetConsoleOutputResult: + raise NotImplementedError + + @handler("GetConsoleScreenshot") + def get_console_screenshot( + self, + context: RequestContext, + instance_id: InstanceId, + dry_run: Boolean | None = None, + wake_up: Boolean | None = None, + **kwargs, + ) -> GetConsoleScreenshotResult: + raise NotImplementedError + + @handler("GetDeclarativePoliciesReportSummary") + def get_declarative_policies_report_summary( + self, + context: RequestContext, + report_id: DeclarativePoliciesReportId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetDeclarativePoliciesReportSummaryResult: + raise NotImplementedError + + @handler("GetDefaultCreditSpecification") + def get_default_credit_specification( + self, + context: RequestContext, + instance_family: UnlimitedSupportedInstanceFamily, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetDefaultCreditSpecificationResult: + raise NotImplementedError + + @handler("GetEbsDefaultKmsKeyId") + def get_ebs_default_kms_key_id( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> GetEbsDefaultKmsKeyIdResult: + raise NotImplementedError + + @handler("GetEbsEncryptionByDefault") + def get_ebs_encryption_by_default( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> GetEbsEncryptionByDefaultResult: + raise NotImplementedError + + @handler("GetFlowLogsIntegrationTemplate") + def get_flow_logs_integration_template( + self, + context: RequestContext, + flow_log_id: VpcFlowLogId, + config_delivery_s3_destination_arn: String, + integrate_services: IntegrateServices, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetFlowLogsIntegrationTemplateResult: + raise NotImplementedError + + @handler("GetGroupsForCapacityReservation") + def get_groups_for_capacity_reservation( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + next_token: String | None = None, + max_results: GetGroupsForCapacityReservationRequestMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetGroupsForCapacityReservationResult: + raise NotImplementedError + + @handler("GetHostReservationPurchasePreview") + def get_host_reservation_purchase_preview( + self, + context: RequestContext, + host_id_set: RequestHostIdSet, + offering_id: OfferingId, + **kwargs, + ) -> GetHostReservationPurchasePreviewResult: + raise NotImplementedError + + @handler("GetImageBlockPublicAccessState") + def get_image_block_public_access_state( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> GetImageBlockPublicAccessStateResult: + raise NotImplementedError + + @handler("GetInstanceMetadataDefaults") + def get_instance_metadata_defaults( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> GetInstanceMetadataDefaultsResult: + raise NotImplementedError + + @handler("GetInstanceTpmEkPub") + def get_instance_tpm_ek_pub( + self, + context: RequestContext, + instance_id: InstanceId, + key_type: EkPubKeyType, + key_format: EkPubKeyFormat, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetInstanceTpmEkPubResult: + raise NotImplementedError + + @handler("GetInstanceTypesFromInstanceRequirements", expand=False) + def get_instance_types_from_instance_requirements( + self, + context: RequestContext, + request: GetInstanceTypesFromInstanceRequirementsRequest, + **kwargs, + ) -> GetInstanceTypesFromInstanceRequirementsResult: + raise NotImplementedError + + @handler("GetInstanceUefiData") + def get_instance_uefi_data( + self, + context: RequestContext, + instance_id: InstanceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetInstanceUefiDataResult: + raise NotImplementedError + + @handler("GetIpamAddressHistory") + def get_ipam_address_history( + self, + context: RequestContext, + cidr: String, + ipam_scope_id: IpamScopeId, + dry_run: Boolean | None = None, + vpc_id: String | None = None, + start_time: MillisecondDateTime | None = None, + end_time: MillisecondDateTime | None = None, + max_results: IpamAddressHistoryMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetIpamAddressHistoryResult: + raise NotImplementedError + + @handler("GetIpamDiscoveredAccounts") + def get_ipam_discovered_accounts( + self, + context: RequestContext, + ipam_resource_discovery_id: IpamResourceDiscoveryId, + discovery_region: String, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: NextToken | None = None, + max_results: IpamMaxResults | None = None, + **kwargs, + ) -> GetIpamDiscoveredAccountsResult: + raise NotImplementedError + + @handler("GetIpamDiscoveredPublicAddresses") + def get_ipam_discovered_public_addresses( + self, + context: RequestContext, + ipam_resource_discovery_id: IpamResourceDiscoveryId, + address_region: String, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: NextToken | None = None, + max_results: IpamMaxResults | None = None, + **kwargs, + ) -> GetIpamDiscoveredPublicAddressesResult: + raise NotImplementedError + + @handler("GetIpamDiscoveredResourceCidrs") + def get_ipam_discovered_resource_cidrs( + self, + context: RequestContext, + ipam_resource_discovery_id: IpamResourceDiscoveryId, + resource_region: String, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + next_token: NextToken | None = None, + max_results: IpamMaxResults | None = None, + **kwargs, + ) -> GetIpamDiscoveredResourceCidrsResult: + raise NotImplementedError + + @handler("GetIpamPoolAllocations") + def get_ipam_pool_allocations( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + dry_run: Boolean | None = None, + ipam_pool_allocation_id: IpamPoolAllocationId | None = None, + filters: FilterList | None = None, + max_results: GetIpamPoolAllocationsMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetIpamPoolAllocationsResult: + raise NotImplementedError + + @handler("GetIpamPoolCidrs") + def get_ipam_pool_cidrs( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: IpamMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetIpamPoolCidrsResult: + raise NotImplementedError + + @handler("GetIpamResourceCidrs") + def get_ipam_resource_cidrs( + self, + context: RequestContext, + ipam_scope_id: IpamScopeId, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + max_results: IpamMaxResults | None = None, + next_token: NextToken | None = None, + ipam_pool_id: IpamPoolId | None = None, + resource_id: String | None = None, + resource_type: IpamResourceType | None = None, + resource_tag: RequestIpamResourceTag | None = None, + resource_owner: String | None = None, + **kwargs, + ) -> GetIpamResourceCidrsResult: + raise NotImplementedError + + @handler("GetLaunchTemplateData") + def get_launch_template_data( + self, + context: RequestContext, + instance_id: InstanceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetLaunchTemplateDataResult: + raise NotImplementedError + + @handler("GetManagedPrefixListAssociations") + def get_managed_prefix_list_associations( + self, + context: RequestContext, + prefix_list_id: PrefixListResourceId, + dry_run: Boolean | None = None, + max_results: GetManagedPrefixListAssociationsMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetManagedPrefixListAssociationsResult: + raise NotImplementedError + + @handler("GetManagedPrefixListEntries") + def get_managed_prefix_list_entries( + self, + context: RequestContext, + prefix_list_id: PrefixListResourceId, + dry_run: Boolean | None = None, + target_version: Long | None = None, + max_results: PrefixListMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetManagedPrefixListEntriesResult: + raise NotImplementedError + + @handler("GetNetworkInsightsAccessScopeAnalysisFindings") + def get_network_insights_access_scope_analysis_findings( + self, + context: RequestContext, + network_insights_access_scope_analysis_id: NetworkInsightsAccessScopeAnalysisId, + max_results: GetNetworkInsightsAccessScopeAnalysisFindingsMaxResults | None = None, + next_token: NextToken | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetNetworkInsightsAccessScopeAnalysisFindingsResult: + raise NotImplementedError + + @handler("GetNetworkInsightsAccessScopeContent") + def get_network_insights_access_scope_content( + self, + context: RequestContext, + network_insights_access_scope_id: NetworkInsightsAccessScopeId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetNetworkInsightsAccessScopeContentResult: + raise NotImplementedError + + @handler("GetPasswordData") + def get_password_data( + self, + context: RequestContext, + instance_id: InstanceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetPasswordDataResult: + raise NotImplementedError + + @handler("GetReservedInstancesExchangeQuote") + def get_reserved_instances_exchange_quote( + self, + context: RequestContext, + reserved_instance_ids: ReservedInstanceIdSet, + dry_run: Boolean | None = None, + target_configurations: TargetConfigurationRequestSet | None = None, + **kwargs, + ) -> GetReservedInstancesExchangeQuoteResult: + raise NotImplementedError + + @handler("GetRouteServerAssociations") + def get_route_server_associations( + self, + context: RequestContext, + route_server_id: RouteServerId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetRouteServerAssociationsResult: + raise NotImplementedError + + @handler("GetRouteServerPropagations") + def get_route_server_propagations( + self, + context: RequestContext, + route_server_id: RouteServerId, + route_table_id: RouteTableId | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetRouteServerPropagationsResult: + raise NotImplementedError + + @handler("GetRouteServerRoutingDatabase") + def get_route_server_routing_database( + self, + context: RequestContext, + route_server_id: RouteServerId, + next_token: String | None = None, + max_results: RouteServerMaxResults | None = None, + dry_run: Boolean | None = None, + filters: FilterList | None = None, + **kwargs, + ) -> GetRouteServerRoutingDatabaseResult: + raise NotImplementedError + + @handler("GetSecurityGroupsForVpc") + def get_security_groups_for_vpc( + self, + context: RequestContext, + vpc_id: VpcId, + next_token: String | None = None, + max_results: GetSecurityGroupsForVpcRequestMaxResults | None = None, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetSecurityGroupsForVpcResult: + raise NotImplementedError + + @handler("GetSerialConsoleAccessStatus") + def get_serial_console_access_status( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> GetSerialConsoleAccessStatusResult: + raise NotImplementedError + + @handler("GetSnapshotBlockPublicAccessState") + def get_snapshot_block_public_access_state( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> GetSnapshotBlockPublicAccessStateResult: + raise NotImplementedError + + @handler("GetSpotPlacementScores") + def get_spot_placement_scores( + self, + context: RequestContext, + target_capacity: SpotPlacementScoresTargetCapacity, + instance_types: InstanceTypes | None = None, + target_capacity_unit_type: TargetCapacityUnitType | None = None, + single_availability_zone: Boolean | None = None, + region_names: RegionNames | None = None, + instance_requirements_with_metadata: InstanceRequirementsWithMetadataRequest | None = None, + dry_run: Boolean | None = None, + max_results: SpotPlacementScoresMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> GetSpotPlacementScoresResult: + raise NotImplementedError + + @handler("GetSubnetCidrReservations") + def get_subnet_cidr_reservations( + self, + context: RequestContext, + subnet_id: SubnetId, + filters: FilterList | None = None, + dry_run: Boolean | None = None, + next_token: String | None = None, + max_results: GetSubnetCidrReservationsMaxResults | None = None, + **kwargs, + ) -> GetSubnetCidrReservationsResult: + raise NotImplementedError + + @handler("GetTransitGatewayAttachmentPropagations") + def get_transit_gateway_attachment_propagations( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetTransitGatewayAttachmentPropagationsResult: + raise NotImplementedError + + @handler("GetTransitGatewayMulticastDomainAssociations") + def get_transit_gateway_multicast_domain_associations( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetTransitGatewayMulticastDomainAssociationsResult: + raise NotImplementedError + + @handler("GetTransitGatewayPolicyTableAssociations") + def get_transit_gateway_policy_table_associations( + self, + context: RequestContext, + transit_gateway_policy_table_id: TransitGatewayPolicyTableId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetTransitGatewayPolicyTableAssociationsResult: + raise NotImplementedError + + @handler("GetTransitGatewayPolicyTableEntries") + def get_transit_gateway_policy_table_entries( + self, + context: RequestContext, + transit_gateway_policy_table_id: TransitGatewayPolicyTableId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetTransitGatewayPolicyTableEntriesResult: + raise NotImplementedError + + @handler("GetTransitGatewayPrefixListReferences") + def get_transit_gateway_prefix_list_references( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetTransitGatewayPrefixListReferencesResult: + raise NotImplementedError + + @handler("GetTransitGatewayRouteTableAssociations") + def get_transit_gateway_route_table_associations( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetTransitGatewayRouteTableAssociationsResult: + raise NotImplementedError + + @handler("GetTransitGatewayRouteTablePropagations") + def get_transit_gateway_route_table_propagations( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetTransitGatewayRouteTablePropagationsResult: + raise NotImplementedError + + @handler("GetVerifiedAccessEndpointPolicy") + def get_verified_access_endpoint_policy( + self, + context: RequestContext, + verified_access_endpoint_id: VerifiedAccessEndpointId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetVerifiedAccessEndpointPolicyResult: + raise NotImplementedError + + @handler("GetVerifiedAccessEndpointTargets") + def get_verified_access_endpoint_targets( + self, + context: RequestContext, + verified_access_endpoint_id: VerifiedAccessEndpointId, + max_results: GetVerifiedAccessEndpointTargetsMaxResults | None = None, + next_token: NextToken | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetVerifiedAccessEndpointTargetsResult: + raise NotImplementedError + + @handler("GetVerifiedAccessGroupPolicy") + def get_verified_access_group_policy( + self, + context: RequestContext, + verified_access_group_id: VerifiedAccessGroupId, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetVerifiedAccessGroupPolicyResult: + raise NotImplementedError + + @handler("GetVpnConnectionDeviceSampleConfiguration") + def get_vpn_connection_device_sample_configuration( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + vpn_connection_device_type_id: VpnConnectionDeviceTypeId, + internet_key_exchange_version: String | None = None, + sample_type: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetVpnConnectionDeviceSampleConfigurationResult: + raise NotImplementedError + + @handler("GetVpnConnectionDeviceTypes") + def get_vpn_connection_device_types( + self, + context: RequestContext, + max_results: GVCDMaxResults | None = None, + next_token: NextToken | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetVpnConnectionDeviceTypesResult: + raise NotImplementedError + + @handler("GetVpnTunnelReplacementStatus") + def get_vpn_tunnel_replacement_status( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + vpn_tunnel_outside_ip_address: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> GetVpnTunnelReplacementStatusResult: + raise NotImplementedError + + @handler("ImportClientVpnClientCertificateRevocationList") + def import_client_vpn_client_certificate_revocation_list( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + certificate_revocation_list: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> ImportClientVpnClientCertificateRevocationListResult: + raise NotImplementedError + + @handler("ImportImage") + def import_image( + self, + context: RequestContext, + architecture: String | None = None, + client_data: ClientData | None = None, + client_token: String | None = None, + description: String | None = None, + disk_containers: ImageDiskContainerList | None = None, + dry_run: Boolean | None = None, + encrypted: Boolean | None = None, + hypervisor: String | None = None, + kms_key_id: KmsKeyId | None = None, + license_type: String | None = None, + platform: String | None = None, + role_name: String | None = None, + license_specifications: ImportImageLicenseSpecificationListRequest | None = None, + tag_specifications: TagSpecificationList | None = None, + usage_operation: String | None = None, + boot_mode: BootModeValues | None = None, + **kwargs, + ) -> ImportImageResult: + raise NotImplementedError + + @handler("ImportInstance") + def import_instance( + self, + context: RequestContext, + platform: PlatformValues, + dry_run: Boolean | None = None, + description: String | None = None, + launch_specification: ImportInstanceLaunchSpecification | None = None, + disk_images: DiskImageList | None = None, + **kwargs, + ) -> ImportInstanceResult: + raise NotImplementedError + + @handler("ImportKeyPair") + def import_key_pair( + self, + context: RequestContext, + key_name: String, + public_key_material: Blob, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ImportKeyPairResult: + raise NotImplementedError + + @handler("ImportSnapshot") + def import_snapshot( + self, + context: RequestContext, + client_data: ClientData | None = None, + client_token: String | None = None, + description: String | None = None, + disk_container: SnapshotDiskContainer | None = None, + dry_run: Boolean | None = None, + encrypted: Boolean | None = None, + kms_key_id: KmsKeyId | None = None, + role_name: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> ImportSnapshotResult: + raise NotImplementedError + + @handler("ImportVolume") + def import_volume( + self, + context: RequestContext, + availability_zone: String, + image: DiskImageDetail, + volume: VolumeDetail, + dry_run: Boolean | None = None, + description: String | None = None, + **kwargs, + ) -> ImportVolumeResult: + raise NotImplementedError + + @handler("ListImagesInRecycleBin") + def list_images_in_recycle_bin( + self, + context: RequestContext, + image_ids: ImageIdStringList | None = None, + next_token: String | None = None, + max_results: ListImagesInRecycleBinMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ListImagesInRecycleBinResult: + raise NotImplementedError + + @handler("ListSnapshotsInRecycleBin") + def list_snapshots_in_recycle_bin( + self, + context: RequestContext, + max_results: ListSnapshotsInRecycleBinMaxResults | None = None, + next_token: String | None = None, + snapshot_ids: SnapshotIdStringList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ListSnapshotsInRecycleBinResult: + raise NotImplementedError + + @handler("LockSnapshot") + def lock_snapshot( + self, + context: RequestContext, + snapshot_id: SnapshotId, + lock_mode: LockMode, + dry_run: Boolean | None = None, + cool_off_period: CoolOffPeriodRequestHours | None = None, + lock_duration: RetentionPeriodRequestDays | None = None, + expiration_date: MillisecondDateTime | None = None, + **kwargs, + ) -> LockSnapshotResult: + raise NotImplementedError + + @handler("ModifyAddressAttribute") + def modify_address_attribute( + self, + context: RequestContext, + allocation_id: AllocationId, + domain_name: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyAddressAttributeResult: + raise NotImplementedError + + @handler("ModifyAvailabilityZoneGroup") + def modify_availability_zone_group( + self, + context: RequestContext, + group_name: String, + opt_in_status: ModifyAvailabilityZoneOptInStatus, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyAvailabilityZoneGroupResult: + raise NotImplementedError + + @handler("ModifyCapacityReservation") + def modify_capacity_reservation( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + instance_count: Integer | None = None, + end_date: DateTime | None = None, + end_date_type: EndDateType | None = None, + accept: Boolean | None = None, + dry_run: Boolean | None = None, + additional_info: String | None = None, + instance_match_criteria: InstanceMatchCriteria | None = None, + **kwargs, + ) -> ModifyCapacityReservationResult: + raise NotImplementedError + + @handler("ModifyCapacityReservationFleet") + def modify_capacity_reservation_fleet( + self, + context: RequestContext, + capacity_reservation_fleet_id: CapacityReservationFleetId, + total_target_capacity: Integer | None = None, + end_date: MillisecondDateTime | None = None, + dry_run: Boolean | None = None, + remove_end_date: Boolean | None = None, + **kwargs, + ) -> ModifyCapacityReservationFleetResult: + raise NotImplementedError + + @handler("ModifyClientVpnEndpoint") + def modify_client_vpn_endpoint( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + server_certificate_arn: String | None = None, + connection_log_options: ConnectionLogOptions | None = None, + dns_servers: DnsServersOptionsModifyStructure | None = None, + vpn_port: Integer | None = None, + description: String | None = None, + split_tunnel: Boolean | None = None, + dry_run: Boolean | None = None, + security_group_ids: ClientVpnSecurityGroupIdSet | None = None, + vpc_id: VpcId | None = None, + self_service_portal: SelfServicePortal | None = None, + client_connect_options: ClientConnectOptions | None = None, + session_timeout_hours: Integer | None = None, + client_login_banner_options: ClientLoginBannerOptions | None = None, + client_route_enforcement_options: ClientRouteEnforcementOptions | None = None, + disconnect_on_session_timeout: Boolean | None = None, + **kwargs, + ) -> ModifyClientVpnEndpointResult: + raise NotImplementedError + + @handler("ModifyDefaultCreditSpecification") + def modify_default_credit_specification( + self, + context: RequestContext, + instance_family: UnlimitedSupportedInstanceFamily, + cpu_credits: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyDefaultCreditSpecificationResult: + raise NotImplementedError + + @handler("ModifyEbsDefaultKmsKeyId") + def modify_ebs_default_kms_key_id( + self, + context: RequestContext, + kms_key_id: KmsKeyId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyEbsDefaultKmsKeyIdResult: + raise NotImplementedError + + @handler("ModifyFleet", expand=False) + def modify_fleet( + self, context: RequestContext, request: ModifyFleetRequest, **kwargs + ) -> ModifyFleetResult: + raise NotImplementedError + + @handler("ModifyFpgaImageAttribute") + def modify_fpga_image_attribute( + self, + context: RequestContext, + fpga_image_id: FpgaImageId, + dry_run: Boolean | None = None, + attribute: FpgaImageAttributeName | None = None, + operation_type: OperationType | None = None, + user_ids: UserIdStringList | None = None, + user_groups: UserGroupStringList | None = None, + product_codes: ProductCodeStringList | None = None, + load_permission: LoadPermissionModifications | None = None, + description: String | None = None, + name: String | None = None, + **kwargs, + ) -> ModifyFpgaImageAttributeResult: + raise NotImplementedError + + @handler("ModifyHosts") + def modify_hosts( + self, + context: RequestContext, + host_ids: RequestHostIdList, + host_recovery: HostRecovery | None = None, + instance_type: String | None = None, + instance_family: String | None = None, + host_maintenance: HostMaintenance | None = None, + auto_placement: AutoPlacement | None = None, + **kwargs, + ) -> ModifyHostsResult: + raise NotImplementedError + + @handler("ModifyIdFormat") + def modify_id_format( + self, context: RequestContext, resource: String, use_long_ids: Boolean, **kwargs + ) -> None: + raise NotImplementedError + + @handler("ModifyIdentityIdFormat") + def modify_identity_id_format( + self, + context: RequestContext, + resource: String, + use_long_ids: Boolean, + principal_arn: String, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyImageAttribute") + def modify_image_attribute( + self, + context: RequestContext, + image_id: ImageId, + attribute: String | None = None, + description: AttributeValue | None = None, + launch_permission: LaunchPermissionModifications | None = None, + operation_type: OperationType | None = None, + product_codes: ProductCodeStringList | None = None, + user_groups: UserGroupStringList | None = None, + user_ids: UserIdStringList | None = None, + value: String | None = None, + organization_arns: OrganizationArnStringList | None = None, + organizational_unit_arns: OrganizationalUnitArnStringList | None = None, + imds_support: AttributeValue | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyInstanceAttribute") + def modify_instance_attribute( + self, + context: RequestContext, + instance_id: InstanceId, + source_dest_check: AttributeBooleanValue | None = None, + disable_api_stop: AttributeBooleanValue | None = None, + dry_run: Boolean | None = None, + attribute: InstanceAttributeName | None = None, + value: String | None = None, + block_device_mappings: InstanceBlockDeviceMappingSpecificationList | None = None, + disable_api_termination: AttributeBooleanValue | None = None, + instance_type: AttributeValue | None = None, + kernel: AttributeValue | None = None, + ramdisk: AttributeValue | None = None, + user_data: BlobAttributeValue | None = None, + instance_initiated_shutdown_behavior: AttributeValue | None = None, + groups: GroupIdStringList | None = None, + ebs_optimized: AttributeBooleanValue | None = None, + sriov_net_support: AttributeValue | None = None, + ena_support: AttributeBooleanValue | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyInstanceCapacityReservationAttributes") + def modify_instance_capacity_reservation_attributes( + self, + context: RequestContext, + instance_id: InstanceId, + capacity_reservation_specification: CapacityReservationSpecification, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyInstanceCapacityReservationAttributesResult: + raise NotImplementedError + + @handler("ModifyInstanceCpuOptions") + def modify_instance_cpu_options( + self, + context: RequestContext, + instance_id: InstanceId, + core_count: Integer, + threads_per_core: Integer, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyInstanceCpuOptionsResult: + raise NotImplementedError + + @handler("ModifyInstanceCreditSpecification") + def modify_instance_credit_specification( + self, + context: RequestContext, + instance_credit_specifications: InstanceCreditSpecificationListRequest, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> ModifyInstanceCreditSpecificationResult: + raise NotImplementedError + + @handler("ModifyInstanceEventStartTime") + def modify_instance_event_start_time( + self, + context: RequestContext, + instance_id: InstanceId, + instance_event_id: String, + not_before: DateTime, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyInstanceEventStartTimeResult: + raise NotImplementedError + + @handler("ModifyInstanceEventWindow") + def modify_instance_event_window( + self, + context: RequestContext, + instance_event_window_id: InstanceEventWindowId, + dry_run: Boolean | None = None, + name: String | None = None, + time_ranges: InstanceEventWindowTimeRangeRequestSet | None = None, + cron_expression: InstanceEventWindowCronExpression | None = None, + **kwargs, + ) -> ModifyInstanceEventWindowResult: + raise NotImplementedError + + @handler("ModifyInstanceMaintenanceOptions") + def modify_instance_maintenance_options( + self, + context: RequestContext, + instance_id: InstanceId, + auto_recovery: InstanceAutoRecoveryState | None = None, + reboot_migration: InstanceRebootMigrationState | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyInstanceMaintenanceOptionsResult: + raise NotImplementedError + + @handler("ModifyInstanceMetadataDefaults") + def modify_instance_metadata_defaults( + self, + context: RequestContext, + http_tokens: MetadataDefaultHttpTokensState | None = None, + http_put_response_hop_limit: BoxedInteger | None = None, + http_endpoint: DefaultInstanceMetadataEndpointState | None = None, + instance_metadata_tags: DefaultInstanceMetadataTagsState | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyInstanceMetadataDefaultsResult: + raise NotImplementedError + + @handler("ModifyInstanceMetadataOptions") + def modify_instance_metadata_options( + self, + context: RequestContext, + instance_id: InstanceId, + http_tokens: HttpTokensState | None = None, + http_put_response_hop_limit: Integer | None = None, + http_endpoint: InstanceMetadataEndpointState | None = None, + dry_run: Boolean | None = None, + http_protocol_ipv6: InstanceMetadataProtocolState | None = None, + instance_metadata_tags: InstanceMetadataTagsState | None = None, + **kwargs, + ) -> ModifyInstanceMetadataOptionsResult: + raise NotImplementedError + + @handler("ModifyInstanceNetworkPerformanceOptions") + def modify_instance_network_performance_options( + self, + context: RequestContext, + instance_id: InstanceId, + bandwidth_weighting: InstanceBandwidthWeighting, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyInstanceNetworkPerformanceResult: + raise NotImplementedError + + @handler("ModifyInstancePlacement") + def modify_instance_placement( + self, + context: RequestContext, + instance_id: InstanceId, + group_name: PlacementGroupName | None = None, + partition_number: Integer | None = None, + host_resource_group_arn: String | None = None, + group_id: PlacementGroupId | None = None, + tenancy: HostTenancy | None = None, + affinity: Affinity | None = None, + host_id: DedicatedHostId | None = None, + **kwargs, + ) -> ModifyInstancePlacementResult: + raise NotImplementedError + + @handler("ModifyIpam") + def modify_ipam( + self, + context: RequestContext, + ipam_id: IpamId, + dry_run: Boolean | None = None, + description: String | None = None, + add_operating_regions: AddIpamOperatingRegionSet | None = None, + remove_operating_regions: RemoveIpamOperatingRegionSet | None = None, + tier: IpamTier | None = None, + enable_private_gua: Boolean | None = None, + metered_account: IpamMeteredAccount | None = None, + **kwargs, + ) -> ModifyIpamResult: + raise NotImplementedError + + @handler("ModifyIpamPool") + def modify_ipam_pool( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + dry_run: Boolean | None = None, + description: String | None = None, + auto_import: Boolean | None = None, + allocation_min_netmask_length: IpamNetmaskLength | None = None, + allocation_max_netmask_length: IpamNetmaskLength | None = None, + allocation_default_netmask_length: IpamNetmaskLength | None = None, + clear_allocation_default_netmask_length: Boolean | None = None, + add_allocation_resource_tags: RequestIpamResourceTagList | None = None, + remove_allocation_resource_tags: RequestIpamResourceTagList | None = None, + **kwargs, + ) -> ModifyIpamPoolResult: + raise NotImplementedError + + @handler("ModifyIpamResourceCidr") + def modify_ipam_resource_cidr( + self, + context: RequestContext, + resource_id: String, + resource_cidr: String, + resource_region: String, + current_ipam_scope_id: IpamScopeId, + monitored: Boolean, + dry_run: Boolean | None = None, + destination_ipam_scope_id: IpamScopeId | None = None, + **kwargs, + ) -> ModifyIpamResourceCidrResult: + raise NotImplementedError + + @handler("ModifyIpamResourceDiscovery") + def modify_ipam_resource_discovery( + self, + context: RequestContext, + ipam_resource_discovery_id: IpamResourceDiscoveryId, + dry_run: Boolean | None = None, + description: String | None = None, + add_operating_regions: AddIpamOperatingRegionSet | None = None, + remove_operating_regions: RemoveIpamOperatingRegionSet | None = None, + add_organizational_unit_exclusions: AddIpamOrganizationalUnitExclusionSet | None = None, + remove_organizational_unit_exclusions: RemoveIpamOrganizationalUnitExclusionSet + | None = None, + **kwargs, + ) -> ModifyIpamResourceDiscoveryResult: + raise NotImplementedError + + @handler("ModifyIpamScope") + def modify_ipam_scope( + self, + context: RequestContext, + ipam_scope_id: IpamScopeId, + dry_run: Boolean | None = None, + description: String | None = None, + **kwargs, + ) -> ModifyIpamScopeResult: + raise NotImplementedError + + @handler("ModifyLaunchTemplate") + def modify_launch_template( + self, + context: RequestContext, + dry_run: Boolean | None = None, + client_token: String | None = None, + launch_template_id: LaunchTemplateId | None = None, + launch_template_name: LaunchTemplateName | None = None, + default_version: String | None = None, + **kwargs, + ) -> ModifyLaunchTemplateResult: + raise NotImplementedError + + @handler("ModifyLocalGatewayRoute") + def modify_local_gateway_route( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + destination_cidr_block: String | None = None, + local_gateway_virtual_interface_group_id: LocalGatewayVirtualInterfaceGroupId | None = None, + network_interface_id: NetworkInterfaceId | None = None, + dry_run: Boolean | None = None, + destination_prefix_list_id: PrefixListResourceId | None = None, + **kwargs, + ) -> ModifyLocalGatewayRouteResult: + raise NotImplementedError + + @handler("ModifyManagedPrefixList") + def modify_managed_prefix_list( + self, + context: RequestContext, + prefix_list_id: PrefixListResourceId, + dry_run: Boolean | None = None, + current_version: Long | None = None, + prefix_list_name: String | None = None, + add_entries: AddPrefixListEntries | None = None, + remove_entries: RemovePrefixListEntries | None = None, + max_entries: Integer | None = None, + **kwargs, + ) -> ModifyManagedPrefixListResult: + raise NotImplementedError + + @handler("ModifyNetworkInterfaceAttribute") + def modify_network_interface_attribute( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + ena_srd_specification: EnaSrdSpecification | None = None, + enable_primary_ipv6: Boolean | None = None, + connection_tracking_specification: ConnectionTrackingSpecificationRequest | None = None, + associate_public_ip_address: Boolean | None = None, + associated_subnet_ids: SubnetIdList | None = None, + dry_run: Boolean | None = None, + description: AttributeValue | None = None, + source_dest_check: AttributeBooleanValue | None = None, + groups: SecurityGroupIdStringList | None = None, + attachment: NetworkInterfaceAttachmentChanges | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyPrivateDnsNameOptions") + def modify_private_dns_name_options( + self, + context: RequestContext, + instance_id: InstanceId, + dry_run: Boolean | None = None, + private_dns_hostname_type: HostnameType | None = None, + enable_resource_name_dns_a_record: Boolean | None = None, + enable_resource_name_dns_aaaa_record: Boolean | None = None, + **kwargs, + ) -> ModifyPrivateDnsNameOptionsResult: + raise NotImplementedError + + @handler("ModifyPublicIpDnsNameOptions") + def modify_public_ip_dns_name_options( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + hostname_type: PublicIpDnsOption, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyPublicIpDnsNameOptionsResult: + raise NotImplementedError + + @handler("ModifyReservedInstances") + def modify_reserved_instances( + self, + context: RequestContext, + reserved_instances_ids: ReservedInstancesIdStringList, + target_configurations: ReservedInstancesConfigurationList, + client_token: String | None = None, + **kwargs, + ) -> ModifyReservedInstancesResult: + raise NotImplementedError + + @handler("ModifyRouteServer") + def modify_route_server( + self, + context: RequestContext, + route_server_id: RouteServerId, + persist_routes: RouteServerPersistRoutesAction | None = None, + persist_routes_duration: BoxedLong | None = None, + sns_notifications_enabled: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyRouteServerResult: + raise NotImplementedError + + @handler("ModifySecurityGroupRules") + def modify_security_group_rules( + self, + context: RequestContext, + group_id: SecurityGroupId, + security_group_rules: SecurityGroupRuleUpdateList, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifySecurityGroupRulesResult: + raise NotImplementedError + + @handler("ModifySnapshotAttribute") + def modify_snapshot_attribute( + self, + context: RequestContext, + snapshot_id: SnapshotId, + attribute: SnapshotAttributeName | None = None, + create_volume_permission: CreateVolumePermissionModifications | None = None, + group_names: GroupNameStringList | None = None, + operation_type: OperationType | None = None, + user_ids: UserIdStringList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifySnapshotTier") + def modify_snapshot_tier( + self, + context: RequestContext, + snapshot_id: SnapshotId, + storage_tier: TargetStorageTier | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifySnapshotTierResult: + raise NotImplementedError + + @handler("ModifySpotFleetRequest", expand=False) + def modify_spot_fleet_request( + self, context: RequestContext, request: ModifySpotFleetRequestRequest, **kwargs + ) -> ModifySpotFleetRequestResponse: + raise NotImplementedError + + @handler("ModifySubnetAttribute") + def modify_subnet_attribute( + self, + context: RequestContext, + subnet_id: SubnetId, + assign_ipv6_address_on_creation: AttributeBooleanValue | None = None, + map_public_ip_on_launch: AttributeBooleanValue | None = None, + map_customer_owned_ip_on_launch: AttributeBooleanValue | None = None, + customer_owned_ipv4_pool: CoipPoolId | None = None, + enable_dns64: AttributeBooleanValue | None = None, + private_dns_hostname_type_on_launch: HostnameType | None = None, + enable_resource_name_dns_a_record_on_launch: AttributeBooleanValue | None = None, + enable_resource_name_dns_aaaa_record_on_launch: AttributeBooleanValue | None = None, + enable_lni_at_device_index: Integer | None = None, + disable_lni_at_device_index: AttributeBooleanValue | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyTrafficMirrorFilterNetworkServices") + def modify_traffic_mirror_filter_network_services( + self, + context: RequestContext, + traffic_mirror_filter_id: TrafficMirrorFilterId, + add_network_services: TrafficMirrorNetworkServiceList | None = None, + remove_network_services: TrafficMirrorNetworkServiceList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyTrafficMirrorFilterNetworkServicesResult: + raise NotImplementedError + + @handler("ModifyTrafficMirrorFilterRule") + def modify_traffic_mirror_filter_rule( + self, + context: RequestContext, + traffic_mirror_filter_rule_id: TrafficMirrorFilterRuleIdWithResolver, + traffic_direction: TrafficDirection | None = None, + rule_number: Integer | None = None, + rule_action: TrafficMirrorRuleAction | None = None, + destination_port_range: TrafficMirrorPortRangeRequest | None = None, + source_port_range: TrafficMirrorPortRangeRequest | None = None, + protocol: Integer | None = None, + destination_cidr_block: String | None = None, + source_cidr_block: String | None = None, + description: String | None = None, + remove_fields: TrafficMirrorFilterRuleFieldList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyTrafficMirrorFilterRuleResult: + raise NotImplementedError + + @handler("ModifyTrafficMirrorSession") + def modify_traffic_mirror_session( + self, + context: RequestContext, + traffic_mirror_session_id: TrafficMirrorSessionId, + traffic_mirror_target_id: TrafficMirrorTargetId | None = None, + traffic_mirror_filter_id: TrafficMirrorFilterId | None = None, + packet_length: Integer | None = None, + session_number: Integer | None = None, + virtual_network_id: Integer | None = None, + description: String | None = None, + remove_fields: TrafficMirrorSessionFieldList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyTrafficMirrorSessionResult: + raise NotImplementedError + + @handler("ModifyTransitGateway") + def modify_transit_gateway( + self, + context: RequestContext, + transit_gateway_id: TransitGatewayId, + description: String | None = None, + options: ModifyTransitGatewayOptions | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyTransitGatewayResult: + raise NotImplementedError + + @handler("ModifyTransitGatewayPrefixListReference") + def modify_transit_gateway_prefix_list_reference( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + prefix_list_id: PrefixListResourceId, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + blackhole: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyTransitGatewayPrefixListReferenceResult: + raise NotImplementedError + + @handler("ModifyTransitGatewayVpcAttachment") + def modify_transit_gateway_vpc_attachment( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + add_subnet_ids: TransitGatewaySubnetIdList | None = None, + remove_subnet_ids: TransitGatewaySubnetIdList | None = None, + options: ModifyTransitGatewayVpcAttachmentRequestOptions | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyTransitGatewayVpcAttachmentResult: + raise NotImplementedError + + @handler("ModifyVerifiedAccessEndpoint") + def modify_verified_access_endpoint( + self, + context: RequestContext, + verified_access_endpoint_id: VerifiedAccessEndpointId, + verified_access_group_id: VerifiedAccessGroupId | None = None, + load_balancer_options: ModifyVerifiedAccessEndpointLoadBalancerOptions | None = None, + network_interface_options: ModifyVerifiedAccessEndpointEniOptions | None = None, + description: String | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + rds_options: ModifyVerifiedAccessEndpointRdsOptions | None = None, + cidr_options: ModifyVerifiedAccessEndpointCidrOptions | None = None, + **kwargs, + ) -> ModifyVerifiedAccessEndpointResult: + raise NotImplementedError + + @handler("ModifyVerifiedAccessEndpointPolicy") + def modify_verified_access_endpoint_policy( + self, + context: RequestContext, + verified_access_endpoint_id: VerifiedAccessEndpointId, + policy_enabled: Boolean | None = None, + policy_document: String | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + sse_specification: VerifiedAccessSseSpecificationRequest | None = None, + **kwargs, + ) -> ModifyVerifiedAccessEndpointPolicyResult: + raise NotImplementedError + + @handler("ModifyVerifiedAccessGroup") + def modify_verified_access_group( + self, + context: RequestContext, + verified_access_group_id: VerifiedAccessGroupId, + verified_access_instance_id: VerifiedAccessInstanceId | None = None, + description: String | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVerifiedAccessGroupResult: + raise NotImplementedError + + @handler("ModifyVerifiedAccessGroupPolicy") + def modify_verified_access_group_policy( + self, + context: RequestContext, + verified_access_group_id: VerifiedAccessGroupId, + policy_enabled: Boolean | None = None, + policy_document: String | None = None, + client_token: String | None = None, + dry_run: Boolean | None = None, + sse_specification: VerifiedAccessSseSpecificationRequest | None = None, + **kwargs, + ) -> ModifyVerifiedAccessGroupPolicyResult: + raise NotImplementedError + + @handler("ModifyVerifiedAccessInstance") + def modify_verified_access_instance( + self, + context: RequestContext, + verified_access_instance_id: VerifiedAccessInstanceId, + description: String | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + cidr_endpoints_custom_sub_domain: String | None = None, + **kwargs, + ) -> ModifyVerifiedAccessInstanceResult: + raise NotImplementedError + + @handler("ModifyVerifiedAccessInstanceLoggingConfiguration") + def modify_verified_access_instance_logging_configuration( + self, + context: RequestContext, + verified_access_instance_id: VerifiedAccessInstanceId, + access_logs: VerifiedAccessLogOptions, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> ModifyVerifiedAccessInstanceLoggingConfigurationResult: + raise NotImplementedError + + @handler("ModifyVerifiedAccessTrustProvider") + def modify_verified_access_trust_provider( + self, + context: RequestContext, + verified_access_trust_provider_id: VerifiedAccessTrustProviderId, + oidc_options: ModifyVerifiedAccessTrustProviderOidcOptions | None = None, + device_options: ModifyVerifiedAccessTrustProviderDeviceOptions | None = None, + description: String | None = None, + dry_run: Boolean | None = None, + client_token: String | None = None, + sse_specification: VerifiedAccessSseSpecificationRequest | None = None, + native_application_oidc_options: ModifyVerifiedAccessNativeApplicationOidcOptions + | None = None, + **kwargs, + ) -> ModifyVerifiedAccessTrustProviderResult: + raise NotImplementedError + + @handler("ModifyVolume") + def modify_volume( + self, + context: RequestContext, + volume_id: VolumeId, + dry_run: Boolean | None = None, + size: Integer | None = None, + volume_type: VolumeType | None = None, + iops: Integer | None = None, + throughput: Integer | None = None, + multi_attach_enabled: Boolean | None = None, + **kwargs, + ) -> ModifyVolumeResult: + raise NotImplementedError + + @handler("ModifyVolumeAttribute") + def modify_volume_attribute( + self, + context: RequestContext, + volume_id: VolumeId, + auto_enable_io: AttributeBooleanValue | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyVpcAttribute") + def modify_vpc_attribute( + self, + context: RequestContext, + vpc_id: VpcId, + enable_dns_hostnames: AttributeBooleanValue | None = None, + enable_dns_support: AttributeBooleanValue | None = None, + enable_network_address_usage_metrics: AttributeBooleanValue | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyVpcBlockPublicAccessExclusion") + def modify_vpc_block_public_access_exclusion( + self, + context: RequestContext, + exclusion_id: VpcBlockPublicAccessExclusionId, + internet_gateway_exclusion_mode: InternetGatewayExclusionMode, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVpcBlockPublicAccessExclusionResult: + raise NotImplementedError + + @handler("ModifyVpcBlockPublicAccessOptions") + def modify_vpc_block_public_access_options( + self, + context: RequestContext, + internet_gateway_block_mode: InternetGatewayBlockMode, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVpcBlockPublicAccessOptionsResult: + raise NotImplementedError + + @handler("ModifyVpcEndpoint") + def modify_vpc_endpoint( + self, + context: RequestContext, + vpc_endpoint_id: VpcEndpointId, + dry_run: Boolean | None = None, + reset_policy: Boolean | None = None, + policy_document: String | None = None, + add_route_table_ids: VpcEndpointRouteTableIdList | None = None, + remove_route_table_ids: VpcEndpointRouteTableIdList | None = None, + add_subnet_ids: VpcEndpointSubnetIdList | None = None, + remove_subnet_ids: VpcEndpointSubnetIdList | None = None, + add_security_group_ids: VpcEndpointSecurityGroupIdList | None = None, + remove_security_group_ids: VpcEndpointSecurityGroupIdList | None = None, + ip_address_type: IpAddressType | None = None, + dns_options: DnsOptionsSpecification | None = None, + private_dns_enabled: Boolean | None = None, + subnet_configurations: SubnetConfigurationsList | None = None, + **kwargs, + ) -> ModifyVpcEndpointResult: + raise NotImplementedError + + @handler("ModifyVpcEndpointConnectionNotification") + def modify_vpc_endpoint_connection_notification( + self, + context: RequestContext, + connection_notification_id: ConnectionNotificationId, + dry_run: Boolean | None = None, + connection_notification_arn: String | None = None, + connection_events: ValueStringList | None = None, + **kwargs, + ) -> ModifyVpcEndpointConnectionNotificationResult: + raise NotImplementedError + + @handler("ModifyVpcEndpointServiceConfiguration") + def modify_vpc_endpoint_service_configuration( + self, + context: RequestContext, + service_id: VpcEndpointServiceId, + dry_run: Boolean | None = None, + private_dns_name: String | None = None, + remove_private_dns_name: Boolean | None = None, + acceptance_required: Boolean | None = None, + add_network_load_balancer_arns: ValueStringList | None = None, + remove_network_load_balancer_arns: ValueStringList | None = None, + add_gateway_load_balancer_arns: ValueStringList | None = None, + remove_gateway_load_balancer_arns: ValueStringList | None = None, + add_supported_ip_address_types: ValueStringList | None = None, + remove_supported_ip_address_types: ValueStringList | None = None, + add_supported_regions: ValueStringList | None = None, + remove_supported_regions: ValueStringList | None = None, + **kwargs, + ) -> ModifyVpcEndpointServiceConfigurationResult: + raise NotImplementedError + + @handler("ModifyVpcEndpointServicePayerResponsibility") + def modify_vpc_endpoint_service_payer_responsibility( + self, + context: RequestContext, + service_id: VpcEndpointServiceId, + payer_responsibility: PayerResponsibility, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVpcEndpointServicePayerResponsibilityResult: + raise NotImplementedError + + @handler("ModifyVpcEndpointServicePermissions") + def modify_vpc_endpoint_service_permissions( + self, + context: RequestContext, + service_id: VpcEndpointServiceId, + dry_run: Boolean | None = None, + add_allowed_principals: ValueStringList | None = None, + remove_allowed_principals: ValueStringList | None = None, + **kwargs, + ) -> ModifyVpcEndpointServicePermissionsResult: + raise NotImplementedError + + @handler("ModifyVpcPeeringConnectionOptions") + def modify_vpc_peering_connection_options( + self, + context: RequestContext, + vpc_peering_connection_id: VpcPeeringConnectionId, + accepter_peering_connection_options: PeeringConnectionOptionsRequest | None = None, + dry_run: Boolean | None = None, + requester_peering_connection_options: PeeringConnectionOptionsRequest | None = None, + **kwargs, + ) -> ModifyVpcPeeringConnectionOptionsResult: + raise NotImplementedError + + @handler("ModifyVpcTenancy") + def modify_vpc_tenancy( + self, + context: RequestContext, + vpc_id: VpcId, + instance_tenancy: VpcTenancy, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVpcTenancyResult: + raise NotImplementedError + + @handler("ModifyVpnConnection") + def modify_vpn_connection( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + transit_gateway_id: TransitGatewayId | None = None, + customer_gateway_id: CustomerGatewayId | None = None, + vpn_gateway_id: VpnGatewayId | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVpnConnectionResult: + raise NotImplementedError + + @handler("ModifyVpnConnectionOptions") + def modify_vpn_connection_options( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + local_ipv4_network_cidr: String | None = None, + remote_ipv4_network_cidr: String | None = None, + local_ipv6_network_cidr: String | None = None, + remote_ipv6_network_cidr: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVpnConnectionOptionsResult: + raise NotImplementedError + + @handler("ModifyVpnTunnelCertificate") + def modify_vpn_tunnel_certificate( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + vpn_tunnel_outside_ip_address: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> ModifyVpnTunnelCertificateResult: + raise NotImplementedError + + @handler("ModifyVpnTunnelOptions") + def modify_vpn_tunnel_options( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + vpn_tunnel_outside_ip_address: String, + tunnel_options: ModifyVpnTunnelOptionsSpecification, + dry_run: Boolean | None = None, + skip_tunnel_replacement: Boolean | None = None, + pre_shared_key_storage: String | None = None, + **kwargs, + ) -> ModifyVpnTunnelOptionsResult: + raise NotImplementedError + + @handler("MonitorInstances") + def monitor_instances( + self, + context: RequestContext, + instance_ids: InstanceIdStringList, + dry_run: Boolean | None = None, + **kwargs, + ) -> MonitorInstancesResult: + raise NotImplementedError + + @handler("MoveAddressToVpc") + def move_address_to_vpc( + self, context: RequestContext, public_ip: String, dry_run: Boolean | None = None, **kwargs + ) -> MoveAddressToVpcResult: + raise NotImplementedError + + @handler("MoveByoipCidrToIpam") + def move_byoip_cidr_to_ipam( + self, + context: RequestContext, + cidr: String, + ipam_pool_id: IpamPoolId, + ipam_pool_owner: String, + dry_run: Boolean | None = None, + **kwargs, + ) -> MoveByoipCidrToIpamResult: + raise NotImplementedError + + @handler("MoveCapacityReservationInstances") + def move_capacity_reservation_instances( + self, + context: RequestContext, + source_capacity_reservation_id: CapacityReservationId, + destination_capacity_reservation_id: CapacityReservationId, + instance_count: Integer, + dry_run: Boolean | None = None, + client_token: String | None = None, + **kwargs, + ) -> MoveCapacityReservationInstancesResult: + raise NotImplementedError + + @handler("ProvisionByoipCidr") + def provision_byoip_cidr( + self, + context: RequestContext, + cidr: String, + cidr_authorization_context: CidrAuthorizationContext | None = None, + publicly_advertisable: Boolean | None = None, + description: String | None = None, + dry_run: Boolean | None = None, + pool_tag_specifications: TagSpecificationList | None = None, + multi_region: Boolean | None = None, + network_border_group: String | None = None, + **kwargs, + ) -> ProvisionByoipCidrResult: + raise NotImplementedError + + @handler("ProvisionIpamByoasn") + def provision_ipam_byoasn( + self, + context: RequestContext, + ipam_id: IpamId, + asn: String, + asn_authorization_context: AsnAuthorizationContext, + dry_run: Boolean | None = None, + **kwargs, + ) -> ProvisionIpamByoasnResult: + raise NotImplementedError + + @handler("ProvisionIpamPoolCidr") + def provision_ipam_pool_cidr( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + dry_run: Boolean | None = None, + cidr: String | None = None, + cidr_authorization_context: IpamCidrAuthorizationContext | None = None, + netmask_length: Integer | None = None, + client_token: String | None = None, + verification_method: VerificationMethod | None = None, + ipam_external_resource_verification_token_id: IpamExternalResourceVerificationTokenId + | None = None, + **kwargs, + ) -> ProvisionIpamPoolCidrResult: + raise NotImplementedError + + @handler("ProvisionPublicIpv4PoolCidr") + def provision_public_ipv4_pool_cidr( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + pool_id: Ipv4PoolEc2Id, + netmask_length: Integer, + dry_run: Boolean | None = None, + network_border_group: String | None = None, + **kwargs, + ) -> ProvisionPublicIpv4PoolCidrResult: + raise NotImplementedError + + @handler("PurchaseCapacityBlock") + def purchase_capacity_block( + self, + context: RequestContext, + capacity_block_offering_id: OfferingId, + instance_platform: CapacityReservationInstancePlatform, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> PurchaseCapacityBlockResult: + raise NotImplementedError + + @handler("PurchaseCapacityBlockExtension") + def purchase_capacity_block_extension( + self, + context: RequestContext, + capacity_block_extension_offering_id: OfferingId, + capacity_reservation_id: CapacityReservationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> PurchaseCapacityBlockExtensionResult: + raise NotImplementedError + + @handler("PurchaseHostReservation") + def purchase_host_reservation( + self, + context: RequestContext, + host_id_set: RequestHostIdSet, + offering_id: OfferingId, + client_token: String | None = None, + currency_code: CurrencyCodeValues | None = None, + limit_price: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> PurchaseHostReservationResult: + raise NotImplementedError + + @handler("PurchaseReservedInstancesOffering") + def purchase_reserved_instances_offering( + self, + context: RequestContext, + instance_count: Integer, + reserved_instances_offering_id: ReservedInstancesOfferingId, + purchase_time: DateTime | None = None, + dry_run: Boolean | None = None, + limit_price: ReservedInstanceLimitPrice | None = None, + **kwargs, + ) -> PurchaseReservedInstancesOfferingResult: + raise NotImplementedError + + @handler("PurchaseScheduledInstances") + def purchase_scheduled_instances( + self, + context: RequestContext, + purchase_requests: PurchaseRequestSet, + client_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> PurchaseScheduledInstancesResult: + raise NotImplementedError + + @handler("RebootInstances") + def reboot_instances( + self, + context: RequestContext, + instance_ids: InstanceIdStringList, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RegisterImage") + def register_image( + self, + context: RequestContext, + name: String, + image_location: String | None = None, + billing_products: BillingProductList | None = None, + boot_mode: BootModeValues | None = None, + tpm_support: TpmSupportValues | None = None, + uefi_data: StringType | None = None, + imds_support: ImdsSupportValues | None = None, + tag_specifications: TagSpecificationList | None = None, + dry_run: Boolean | None = None, + description: String | None = None, + architecture: ArchitectureValues | None = None, + kernel_id: KernelId | None = None, + ramdisk_id: RamdiskId | None = None, + root_device_name: String | None = None, + block_device_mappings: BlockDeviceMappingRequestList | None = None, + virtualization_type: String | None = None, + sriov_net_support: String | None = None, + ena_support: Boolean | None = None, + **kwargs, + ) -> RegisterImageResult: + raise NotImplementedError + + @handler("RegisterInstanceEventNotificationAttributes") + def register_instance_event_notification_attributes( + self, + context: RequestContext, + instance_tag_attribute: RegisterInstanceTagAttributeRequest, + dry_run: Boolean | None = None, + **kwargs, + ) -> RegisterInstanceEventNotificationAttributesResult: + raise NotImplementedError + + @handler("RegisterTransitGatewayMulticastGroupMembers") + def register_transit_gateway_multicast_group_members( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId, + network_interface_ids: TransitGatewayNetworkInterfaceIdList, + group_ip_address: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> RegisterTransitGatewayMulticastGroupMembersResult: + raise NotImplementedError + + @handler("RegisterTransitGatewayMulticastGroupSources") + def register_transit_gateway_multicast_group_sources( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId, + network_interface_ids: TransitGatewayNetworkInterfaceIdList, + group_ip_address: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> RegisterTransitGatewayMulticastGroupSourcesResult: + raise NotImplementedError + + @handler("RejectCapacityReservationBillingOwnership") + def reject_capacity_reservation_billing_ownership( + self, + context: RequestContext, + capacity_reservation_id: CapacityReservationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> RejectCapacityReservationBillingOwnershipResult: + raise NotImplementedError + + @handler("RejectTransitGatewayMulticastDomainAssociations") + def reject_transit_gateway_multicast_domain_associations( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId | None = None, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + subnet_ids: ValueStringList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> RejectTransitGatewayMulticastDomainAssociationsResult: + raise NotImplementedError + + @handler("RejectTransitGatewayPeeringAttachment") + def reject_transit_gateway_peering_attachment( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> RejectTransitGatewayPeeringAttachmentResult: + raise NotImplementedError + + @handler("RejectTransitGatewayVpcAttachment") + def reject_transit_gateway_vpc_attachment( + self, + context: RequestContext, + transit_gateway_attachment_id: TransitGatewayAttachmentId, + dry_run: Boolean | None = None, + **kwargs, + ) -> RejectTransitGatewayVpcAttachmentResult: + raise NotImplementedError + + @handler("RejectVpcEndpointConnections") + def reject_vpc_endpoint_connections( + self, + context: RequestContext, + service_id: VpcEndpointServiceId, + vpc_endpoint_ids: VpcEndpointIdList, + dry_run: Boolean | None = None, + **kwargs, + ) -> RejectVpcEndpointConnectionsResult: + raise NotImplementedError + + @handler("RejectVpcPeeringConnection") + def reject_vpc_peering_connection( + self, + context: RequestContext, + vpc_peering_connection_id: VpcPeeringConnectionId, + dry_run: Boolean | None = None, + **kwargs, + ) -> RejectVpcPeeringConnectionResult: + raise NotImplementedError + + @handler("ReleaseAddress") + def release_address( + self, + context: RequestContext, + allocation_id: AllocationId | None = None, + public_ip: String | None = None, + network_border_group: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ReleaseHosts") + def release_hosts( + self, context: RequestContext, host_ids: RequestHostIdList, **kwargs + ) -> ReleaseHostsResult: + raise NotImplementedError + + @handler("ReleaseIpamPoolAllocation") + def release_ipam_pool_allocation( + self, + context: RequestContext, + ipam_pool_id: IpamPoolId, + cidr: String, + ipam_pool_allocation_id: IpamPoolAllocationId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ReleaseIpamPoolAllocationResult: + raise NotImplementedError + + @handler("ReplaceIamInstanceProfileAssociation") + def replace_iam_instance_profile_association( + self, + context: RequestContext, + iam_instance_profile: IamInstanceProfileSpecification, + association_id: IamInstanceProfileAssociationId, + **kwargs, + ) -> ReplaceIamInstanceProfileAssociationResult: + raise NotImplementedError + + @handler("ReplaceImageCriteriaInAllowedImagesSettings") + def replace_image_criteria_in_allowed_images_settings( + self, + context: RequestContext, + image_criteria: ImageCriterionRequestList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ReplaceImageCriteriaInAllowedImagesSettingsResult: + raise NotImplementedError + + @handler("ReplaceNetworkAclAssociation") + def replace_network_acl_association( + self, + context: RequestContext, + association_id: NetworkAclAssociationId, + network_acl_id: NetworkAclId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ReplaceNetworkAclAssociationResult: + raise NotImplementedError + + @handler("ReplaceNetworkAclEntry") + def replace_network_acl_entry( + self, + context: RequestContext, + network_acl_id: NetworkAclId, + rule_number: Integer, + protocol: String, + rule_action: RuleAction, + egress: Boolean, + dry_run: Boolean | None = None, + cidr_block: String | None = None, + ipv6_cidr_block: String | None = None, + icmp_type_code: IcmpTypeCode | None = None, + port_range: PortRange | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ReplaceRoute") + def replace_route( + self, + context: RequestContext, + route_table_id: RouteTableId, + destination_prefix_list_id: PrefixListResourceId | None = None, + vpc_endpoint_id: VpcEndpointId | None = None, + local_target: Boolean | None = None, + transit_gateway_id: TransitGatewayId | None = None, + local_gateway_id: LocalGatewayId | None = None, + carrier_gateway_id: CarrierGatewayId | None = None, + core_network_arn: CoreNetworkArn | None = None, + odb_network_arn: OdbNetworkArn | None = None, + dry_run: Boolean | None = None, + destination_cidr_block: String | None = None, + gateway_id: RouteGatewayId | None = None, + destination_ipv6_cidr_block: String | None = None, + egress_only_internet_gateway_id: EgressOnlyInternetGatewayId | None = None, + instance_id: InstanceId | None = None, + network_interface_id: NetworkInterfaceId | None = None, + vpc_peering_connection_id: VpcPeeringConnectionId | None = None, + nat_gateway_id: NatGatewayId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ReplaceRouteTableAssociation") + def replace_route_table_association( + self, + context: RequestContext, + association_id: RouteTableAssociationId, + route_table_id: RouteTableId, + dry_run: Boolean | None = None, + **kwargs, + ) -> ReplaceRouteTableAssociationResult: + raise NotImplementedError + + @handler("ReplaceTransitGatewayRoute") + def replace_transit_gateway_route( + self, + context: RequestContext, + destination_cidr_block: String, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + transit_gateway_attachment_id: TransitGatewayAttachmentId | None = None, + blackhole: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ReplaceTransitGatewayRouteResult: + raise NotImplementedError + + @handler("ReplaceVpnTunnel") + def replace_vpn_tunnel( + self, + context: RequestContext, + vpn_connection_id: VpnConnectionId, + vpn_tunnel_outside_ip_address: String, + apply_pending_maintenance: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> ReplaceVpnTunnelResult: + raise NotImplementedError + + @handler("ReportInstanceStatus") + def report_instance_status( + self, + context: RequestContext, + instances: InstanceIdStringList, + status: ReportStatusType, + reason_codes: ReasonCodesList, + dry_run: Boolean | None = None, + start_time: DateTime | None = None, + end_time: DateTime | None = None, + description: ReportInstanceStatusRequestDescription | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RequestSpotFleet") + def request_spot_fleet( + self, + context: RequestContext, + spot_fleet_request_config: SpotFleetRequestConfigData, + dry_run: Boolean | None = None, + **kwargs, + ) -> RequestSpotFleetResponse: + raise NotImplementedError + + @handler("RequestSpotInstances", expand=False) + def request_spot_instances( + self, context: RequestContext, request: RequestSpotInstancesRequest, **kwargs + ) -> RequestSpotInstancesResult: + raise NotImplementedError + + @handler("ResetAddressAttribute") + def reset_address_attribute( + self, + context: RequestContext, + allocation_id: AllocationId, + attribute: AddressAttributeName, + dry_run: Boolean | None = None, + **kwargs, + ) -> ResetAddressAttributeResult: + raise NotImplementedError + + @handler("ResetEbsDefaultKmsKeyId") + def reset_ebs_default_kms_key_id( + self, context: RequestContext, dry_run: Boolean | None = None, **kwargs + ) -> ResetEbsDefaultKmsKeyIdResult: + raise NotImplementedError + + @handler("ResetFpgaImageAttribute") + def reset_fpga_image_attribute( + self, + context: RequestContext, + fpga_image_id: FpgaImageId, + dry_run: Boolean | None = None, + attribute: ResetFpgaImageAttributeName | None = None, + **kwargs, + ) -> ResetFpgaImageAttributeResult: + raise NotImplementedError + + @handler("ResetImageAttribute") + def reset_image_attribute( + self, + context: RequestContext, + attribute: ResetImageAttributeName, + image_id: ImageId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ResetInstanceAttribute") + def reset_instance_attribute( + self, + context: RequestContext, + instance_id: InstanceId, + attribute: InstanceAttributeName, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ResetNetworkInterfaceAttribute") + def reset_network_interface_attribute( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + dry_run: Boolean | None = None, + source_dest_check: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ResetSnapshotAttribute") + def reset_snapshot_attribute( + self, + context: RequestContext, + attribute: SnapshotAttributeName, + snapshot_id: SnapshotId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RestoreAddressToClassic") + def restore_address_to_classic( + self, context: RequestContext, public_ip: String, dry_run: Boolean | None = None, **kwargs + ) -> RestoreAddressToClassicResult: + raise NotImplementedError + + @handler("RestoreImageFromRecycleBin") + def restore_image_from_recycle_bin( + self, context: RequestContext, image_id: ImageId, dry_run: Boolean | None = None, **kwargs + ) -> RestoreImageFromRecycleBinResult: + raise NotImplementedError + + @handler("RestoreManagedPrefixListVersion") + def restore_managed_prefix_list_version( + self, + context: RequestContext, + prefix_list_id: PrefixListResourceId, + previous_version: Long, + current_version: Long, + dry_run: Boolean | None = None, + **kwargs, + ) -> RestoreManagedPrefixListVersionResult: + raise NotImplementedError + + @handler("RestoreSnapshotFromRecycleBin") + def restore_snapshot_from_recycle_bin( + self, + context: RequestContext, + snapshot_id: SnapshotId, + dry_run: Boolean | None = None, + **kwargs, + ) -> RestoreSnapshotFromRecycleBinResult: + raise NotImplementedError + + @handler("RestoreSnapshotTier") + def restore_snapshot_tier( + self, + context: RequestContext, + snapshot_id: SnapshotId, + temporary_restore_days: RestoreSnapshotTierRequestTemporaryRestoreDays | None = None, + permanent_restore: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> RestoreSnapshotTierResult: + raise NotImplementedError + + @handler("RevokeClientVpnIngress") + def revoke_client_vpn_ingress( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + target_network_cidr: String, + access_group_id: String | None = None, + revoke_all_groups: Boolean | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> RevokeClientVpnIngressResult: + raise NotImplementedError + + @handler("RevokeSecurityGroupEgress") + def revoke_security_group_egress( + self, + context: RequestContext, + group_id: SecurityGroupId, + security_group_rule_ids: SecurityGroupRuleIdList | None = None, + dry_run: Boolean | None = None, + source_security_group_name: String | None = None, + source_security_group_owner_id: String | None = None, + ip_protocol: String | None = None, + from_port: Integer | None = None, + to_port: Integer | None = None, + cidr_ip: String | None = None, + ip_permissions: IpPermissionList | None = None, + **kwargs, + ) -> RevokeSecurityGroupEgressResult: + raise NotImplementedError + + @handler("RevokeSecurityGroupIngress") + def revoke_security_group_ingress( + self, + context: RequestContext, + cidr_ip: String | None = None, + from_port: Integer | None = None, + group_id: SecurityGroupId | None = None, + group_name: SecurityGroupName | None = None, + ip_permissions: IpPermissionList | None = None, + ip_protocol: String | None = None, + source_security_group_name: String | None = None, + source_security_group_owner_id: String | None = None, + to_port: Integer | None = None, + security_group_rule_ids: SecurityGroupRuleIdList | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> RevokeSecurityGroupIngressResult: + raise NotImplementedError + + @handler("RunInstances") + def run_instances( + self, + context: RequestContext, + max_count: Integer, + min_count: Integer, + block_device_mappings: BlockDeviceMappingRequestList | None = None, + image_id: ImageId | None = None, + instance_type: InstanceType | None = None, + ipv6_address_count: Integer | None = None, + ipv6_addresses: InstanceIpv6AddressList | None = None, + kernel_id: KernelId | None = None, + key_name: KeyPairName | None = None, + monitoring: RunInstancesMonitoringEnabled | None = None, + placement: Placement | None = None, + ramdisk_id: RamdiskId | None = None, + security_group_ids: SecurityGroupIdStringList | None = None, + security_groups: SecurityGroupStringList | None = None, + subnet_id: SubnetId | None = None, + user_data: RunInstancesUserData | None = None, + elastic_gpu_specification: ElasticGpuSpecifications | None = None, + elastic_inference_accelerators: ElasticInferenceAccelerators | None = None, + tag_specifications: TagSpecificationList | None = None, + launch_template: LaunchTemplateSpecification | None = None, + instance_market_options: InstanceMarketOptionsRequest | None = None, + credit_specification: CreditSpecificationRequest | None = None, + cpu_options: CpuOptionsRequest | None = None, + capacity_reservation_specification: CapacityReservationSpecification | None = None, + hibernation_options: HibernationOptionsRequest | None = None, + license_specifications: LicenseSpecificationListRequest | None = None, + metadata_options: InstanceMetadataOptionsRequest | None = None, + enclave_options: EnclaveOptionsRequest | None = None, + private_dns_name_options: PrivateDnsNameOptionsRequest | None = None, + maintenance_options: InstanceMaintenanceOptionsRequest | None = None, + disable_api_stop: Boolean | None = None, + enable_primary_ipv6: Boolean | None = None, + network_performance_options: InstanceNetworkPerformanceOptionsRequest | None = None, + operator: OperatorRequest | None = None, + dry_run: Boolean | None = None, + disable_api_termination: Boolean | None = None, + instance_initiated_shutdown_behavior: ShutdownBehavior | None = None, + private_ip_address: String | None = None, + client_token: String | None = None, + additional_info: String | None = None, + network_interfaces: InstanceNetworkInterfaceSpecificationList | None = None, + iam_instance_profile: IamInstanceProfileSpecification | None = None, + ebs_optimized: Boolean | None = None, + **kwargs, + ) -> Reservation: + raise NotImplementedError + + @handler("RunScheduledInstances") + def run_scheduled_instances( + self, + context: RequestContext, + launch_specification: ScheduledInstancesLaunchSpecification, + scheduled_instance_id: ScheduledInstanceId, + client_token: String | None = None, + dry_run: Boolean | None = None, + instance_count: Integer | None = None, + **kwargs, + ) -> RunScheduledInstancesResult: + raise NotImplementedError + + @handler("SearchLocalGatewayRoutes") + def search_local_gateway_routes( + self, + context: RequestContext, + local_gateway_route_table_id: LocalGatewayRoutetableId, + filters: FilterList | None = None, + max_results: MaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> SearchLocalGatewayRoutesResult: + raise NotImplementedError + + @handler("SearchTransitGatewayMulticastGroups") + def search_transit_gateway_multicast_groups( + self, + context: RequestContext, + transit_gateway_multicast_domain_id: TransitGatewayMulticastDomainId, + filters: FilterList | None = None, + max_results: TransitGatewayMaxResults | None = None, + next_token: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> SearchTransitGatewayMulticastGroupsResult: + raise NotImplementedError + + @handler("SearchTransitGatewayRoutes") + def search_transit_gateway_routes( + self, + context: RequestContext, + transit_gateway_route_table_id: TransitGatewayRouteTableId, + filters: FilterList, + max_results: TransitGatewayMaxResults | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> SearchTransitGatewayRoutesResult: + raise NotImplementedError + + @handler("SendDiagnosticInterrupt") + def send_diagnostic_interrupt( + self, + context: RequestContext, + instance_id: InstanceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("StartDeclarativePoliciesReport") + def start_declarative_policies_report( + self, + context: RequestContext, + s3_bucket: String, + target_id: String, + dry_run: Boolean | None = None, + s3_prefix: String | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> StartDeclarativePoliciesReportResult: + raise NotImplementedError + + @handler("StartInstances") + def start_instances( + self, + context: RequestContext, + instance_ids: InstanceIdStringList, + additional_info: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> StartInstancesResult: + raise NotImplementedError + + @handler("StartNetworkInsightsAccessScopeAnalysis") + def start_network_insights_access_scope_analysis( + self, + context: RequestContext, + network_insights_access_scope_id: NetworkInsightsAccessScopeId, + client_token: String, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> StartNetworkInsightsAccessScopeAnalysisResult: + raise NotImplementedError + + @handler("StartNetworkInsightsAnalysis") + def start_network_insights_analysis( + self, + context: RequestContext, + network_insights_path_id: NetworkInsightsPathId, + client_token: String, + additional_accounts: ValueStringList | None = None, + filter_in_arns: ArnList | None = None, + filter_out_arns: ArnList | None = None, + dry_run: Boolean | None = None, + tag_specifications: TagSpecificationList | None = None, + **kwargs, + ) -> StartNetworkInsightsAnalysisResult: + raise NotImplementedError + + @handler("StartVpcEndpointServicePrivateDnsVerification") + def start_vpc_endpoint_service_private_dns_verification( + self, + context: RequestContext, + service_id: VpcEndpointServiceId, + dry_run: Boolean | None = None, + **kwargs, + ) -> StartVpcEndpointServicePrivateDnsVerificationResult: + raise NotImplementedError + + @handler("StopInstances") + def stop_instances( + self, + context: RequestContext, + instance_ids: InstanceIdStringList, + hibernate: Boolean | None = None, + dry_run: Boolean | None = None, + force: Boolean | None = None, + **kwargs, + ) -> StopInstancesResult: + raise NotImplementedError + + @handler("TerminateClientVpnConnections") + def terminate_client_vpn_connections( + self, + context: RequestContext, + client_vpn_endpoint_id: ClientVpnEndpointId, + connection_id: String | None = None, + username: String | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> TerminateClientVpnConnectionsResult: + raise NotImplementedError + + @handler("TerminateInstances") + def terminate_instances( + self, + context: RequestContext, + instance_ids: InstanceIdStringList, + dry_run: Boolean | None = None, + **kwargs, + ) -> TerminateInstancesResult: + raise NotImplementedError + + @handler("UnassignIpv6Addresses") + def unassign_ipv6_addresses( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + ipv6_prefixes: IpPrefixList | None = None, + ipv6_addresses: Ipv6AddressList | None = None, + **kwargs, + ) -> UnassignIpv6AddressesResult: + raise NotImplementedError + + @handler("UnassignPrivateIpAddresses") + def unassign_private_ip_addresses( + self, + context: RequestContext, + network_interface_id: NetworkInterfaceId, + ipv4_prefixes: IpPrefixList | None = None, + private_ip_addresses: PrivateIpAddressStringList | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UnassignPrivateNatGatewayAddress") + def unassign_private_nat_gateway_address( + self, + context: RequestContext, + nat_gateway_id: NatGatewayId, + private_ip_addresses: IpList, + max_drain_duration_seconds: DrainSeconds | None = None, + dry_run: Boolean | None = None, + **kwargs, + ) -> UnassignPrivateNatGatewayAddressResult: + raise NotImplementedError + + @handler("UnlockSnapshot") + def unlock_snapshot( + self, + context: RequestContext, + snapshot_id: SnapshotId, + dry_run: Boolean | None = None, + **kwargs, + ) -> UnlockSnapshotResult: + raise NotImplementedError + + @handler("UnmonitorInstances") + def unmonitor_instances( + self, + context: RequestContext, + instance_ids: InstanceIdStringList, + dry_run: Boolean | None = None, + **kwargs, + ) -> UnmonitorInstancesResult: + raise NotImplementedError + + @handler("UpdateSecurityGroupRuleDescriptionsEgress") + def update_security_group_rule_descriptions_egress( + self, + context: RequestContext, + dry_run: Boolean | None = None, + group_id: SecurityGroupId | None = None, + group_name: SecurityGroupName | None = None, + ip_permissions: IpPermissionList | None = None, + security_group_rule_descriptions: SecurityGroupRuleDescriptionList | None = None, + **kwargs, + ) -> UpdateSecurityGroupRuleDescriptionsEgressResult: + raise NotImplementedError + + @handler("UpdateSecurityGroupRuleDescriptionsIngress") + def update_security_group_rule_descriptions_ingress( + self, + context: RequestContext, + dry_run: Boolean | None = None, + group_id: SecurityGroupId | None = None, + group_name: SecurityGroupName | None = None, + ip_permissions: IpPermissionList | None = None, + security_group_rule_descriptions: SecurityGroupRuleDescriptionList | None = None, + **kwargs, + ) -> UpdateSecurityGroupRuleDescriptionsIngressResult: + raise NotImplementedError + + @handler("WithdrawByoipCidr") + def withdraw_byoip_cidr( + self, context: RequestContext, cidr: String, dry_run: Boolean | None = None, **kwargs + ) -> WithdrawByoipCidrResult: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/es/__init__.py b/localstack-core/localstack/aws/api/es/__init__.py new file mode 100644 index 0000000000000..4c5774cbd36fa --- /dev/null +++ b/localstack-core/localstack/aws/api/es/__init__.py @@ -0,0 +1,2078 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ARN = str +AWSAccount = str +BackendRole = str +Boolean = bool +ChangeProgressStageName = str +ChangeProgressStageStatus = str +ClientToken = str +CloudWatchLogsLogGroupArn = str +CommitMessage = str +ConnectionAlias = str +CrossClusterSearchConnectionId = str +CrossClusterSearchConnectionStatusMessage = str +DeploymentType = str +DescribePackagesFilterValue = str +Description = str +DomainArn = str +DomainId = str +DomainName = str +DomainNameFqdn = str +Double = float +DryRun = bool +ElasticsearchVersionString = str +Endpoint = str +ErrorMessage = str +ErrorType = str +GUID = str +IdentityPoolId = str +InstanceCount = int +InstanceRole = str +Integer = int +IntegerClass = int +Issue = str +KmsKeyId = str +LimitName = str +LimitValue = str +MaxResults = int +MaximumInstanceCount = int +Message = str +MinimumInstanceCount = int +NextToken = str +NonEmptyString = str +OwnerId = str +PackageDescription = str +PackageID = str +PackageName = str +PackageVersion = str +Password = str +PolicyDocument = str +ReferencePath = str +Region = str +ReservationToken = str +RoleArn = str +S3BucketName = str +S3Key = str +SAMLEntityId = str +SAMLMetadata = str +ScheduledAutoTuneDescription = str +ServiceUrl = str +StorageSubTypeName = str +StorageTypeName = str +String = str +TagKey = str +TagValue = str +TotalNumberOfStages = int +UIntValue = int +UpgradeName = str +UserPoolId = str +Username = str +VpcEndpointId = str + + +class AutoTuneDesiredState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class AutoTuneState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + ENABLE_IN_PROGRESS = "ENABLE_IN_PROGRESS" + DISABLE_IN_PROGRESS = "DISABLE_IN_PROGRESS" + DISABLED_AND_ROLLBACK_SCHEDULED = "DISABLED_AND_ROLLBACK_SCHEDULED" + DISABLED_AND_ROLLBACK_IN_PROGRESS = "DISABLED_AND_ROLLBACK_IN_PROGRESS" + DISABLED_AND_ROLLBACK_COMPLETE = "DISABLED_AND_ROLLBACK_COMPLETE" + DISABLED_AND_ROLLBACK_ERROR = "DISABLED_AND_ROLLBACK_ERROR" + ERROR = "ERROR" + + +class AutoTuneType(StrEnum): + SCHEDULED_ACTION = "SCHEDULED_ACTION" + + +class ConfigChangeStatus(StrEnum): + Pending = "Pending" + Initializing = "Initializing" + Validating = "Validating" + ValidationFailed = "ValidationFailed" + ApplyingChanges = "ApplyingChanges" + Completed = "Completed" + PendingUserInput = "PendingUserInput" + Cancelled = "Cancelled" + + +class DeploymentStatus(StrEnum): + PENDING_UPDATE = "PENDING_UPDATE" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + NOT_ELIGIBLE = "NOT_ELIGIBLE" + ELIGIBLE = "ELIGIBLE" + + +class DescribePackagesFilterName(StrEnum): + PackageID = "PackageID" + PackageName = "PackageName" + PackageStatus = "PackageStatus" + + +class DomainPackageStatus(StrEnum): + ASSOCIATING = "ASSOCIATING" + ASSOCIATION_FAILED = "ASSOCIATION_FAILED" + ACTIVE = "ACTIVE" + DISSOCIATING = "DISSOCIATING" + DISSOCIATION_FAILED = "DISSOCIATION_FAILED" + + +class DomainProcessingStatusType(StrEnum): + Creating = "Creating" + Active = "Active" + Modifying = "Modifying" + UpgradingEngineVersion = "UpgradingEngineVersion" + UpdatingServiceSoftware = "UpdatingServiceSoftware" + Isolated = "Isolated" + Deleting = "Deleting" + + +class ESPartitionInstanceType(StrEnum): + m3_medium_elasticsearch = "m3.medium.elasticsearch" + m3_large_elasticsearch = "m3.large.elasticsearch" + m3_xlarge_elasticsearch = "m3.xlarge.elasticsearch" + m3_2xlarge_elasticsearch = "m3.2xlarge.elasticsearch" + m4_large_elasticsearch = "m4.large.elasticsearch" + m4_xlarge_elasticsearch = "m4.xlarge.elasticsearch" + m4_2xlarge_elasticsearch = "m4.2xlarge.elasticsearch" + m4_4xlarge_elasticsearch = "m4.4xlarge.elasticsearch" + m4_10xlarge_elasticsearch = "m4.10xlarge.elasticsearch" + m5_large_elasticsearch = "m5.large.elasticsearch" + m5_xlarge_elasticsearch = "m5.xlarge.elasticsearch" + m5_2xlarge_elasticsearch = "m5.2xlarge.elasticsearch" + m5_4xlarge_elasticsearch = "m5.4xlarge.elasticsearch" + m5_12xlarge_elasticsearch = "m5.12xlarge.elasticsearch" + r5_large_elasticsearch = "r5.large.elasticsearch" + r5_xlarge_elasticsearch = "r5.xlarge.elasticsearch" + r5_2xlarge_elasticsearch = "r5.2xlarge.elasticsearch" + r5_4xlarge_elasticsearch = "r5.4xlarge.elasticsearch" + r5_12xlarge_elasticsearch = "r5.12xlarge.elasticsearch" + c5_large_elasticsearch = "c5.large.elasticsearch" + c5_xlarge_elasticsearch = "c5.xlarge.elasticsearch" + c5_2xlarge_elasticsearch = "c5.2xlarge.elasticsearch" + c5_4xlarge_elasticsearch = "c5.4xlarge.elasticsearch" + c5_9xlarge_elasticsearch = "c5.9xlarge.elasticsearch" + c5_18xlarge_elasticsearch = "c5.18xlarge.elasticsearch" + ultrawarm1_medium_elasticsearch = "ultrawarm1.medium.elasticsearch" + ultrawarm1_large_elasticsearch = "ultrawarm1.large.elasticsearch" + t2_micro_elasticsearch = "t2.micro.elasticsearch" + t2_small_elasticsearch = "t2.small.elasticsearch" + t2_medium_elasticsearch = "t2.medium.elasticsearch" + r3_large_elasticsearch = "r3.large.elasticsearch" + r3_xlarge_elasticsearch = "r3.xlarge.elasticsearch" + r3_2xlarge_elasticsearch = "r3.2xlarge.elasticsearch" + r3_4xlarge_elasticsearch = "r3.4xlarge.elasticsearch" + r3_8xlarge_elasticsearch = "r3.8xlarge.elasticsearch" + i2_xlarge_elasticsearch = "i2.xlarge.elasticsearch" + i2_2xlarge_elasticsearch = "i2.2xlarge.elasticsearch" + d2_xlarge_elasticsearch = "d2.xlarge.elasticsearch" + d2_2xlarge_elasticsearch = "d2.2xlarge.elasticsearch" + d2_4xlarge_elasticsearch = "d2.4xlarge.elasticsearch" + d2_8xlarge_elasticsearch = "d2.8xlarge.elasticsearch" + c4_large_elasticsearch = "c4.large.elasticsearch" + c4_xlarge_elasticsearch = "c4.xlarge.elasticsearch" + c4_2xlarge_elasticsearch = "c4.2xlarge.elasticsearch" + c4_4xlarge_elasticsearch = "c4.4xlarge.elasticsearch" + c4_8xlarge_elasticsearch = "c4.8xlarge.elasticsearch" + r4_large_elasticsearch = "r4.large.elasticsearch" + r4_xlarge_elasticsearch = "r4.xlarge.elasticsearch" + r4_2xlarge_elasticsearch = "r4.2xlarge.elasticsearch" + r4_4xlarge_elasticsearch = "r4.4xlarge.elasticsearch" + r4_8xlarge_elasticsearch = "r4.8xlarge.elasticsearch" + r4_16xlarge_elasticsearch = "r4.16xlarge.elasticsearch" + i3_large_elasticsearch = "i3.large.elasticsearch" + i3_xlarge_elasticsearch = "i3.xlarge.elasticsearch" + i3_2xlarge_elasticsearch = "i3.2xlarge.elasticsearch" + i3_4xlarge_elasticsearch = "i3.4xlarge.elasticsearch" + i3_8xlarge_elasticsearch = "i3.8xlarge.elasticsearch" + i3_16xlarge_elasticsearch = "i3.16xlarge.elasticsearch" + + +class ESWarmPartitionInstanceType(StrEnum): + ultrawarm1_medium_elasticsearch = "ultrawarm1.medium.elasticsearch" + ultrawarm1_large_elasticsearch = "ultrawarm1.large.elasticsearch" + + +class EngineType(StrEnum): + OpenSearch = "OpenSearch" + Elasticsearch = "Elasticsearch" + + +class InboundCrossClusterSearchConnectionStatusCode(StrEnum): + PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE" + APPROVED = "APPROVED" + REJECTING = "REJECTING" + REJECTED = "REJECTED" + DELETING = "DELETING" + DELETED = "DELETED" + + +class InitiatedBy(StrEnum): + CUSTOMER = "CUSTOMER" + SERVICE = "SERVICE" + + +class LogType(StrEnum): + INDEX_SLOW_LOGS = "INDEX_SLOW_LOGS" + SEARCH_SLOW_LOGS = "SEARCH_SLOW_LOGS" + ES_APPLICATION_LOGS = "ES_APPLICATION_LOGS" + AUDIT_LOGS = "AUDIT_LOGS" + + +class OptionState(StrEnum): + RequiresIndexDocuments = "RequiresIndexDocuments" + Processing = "Processing" + Active = "Active" + + +class OutboundCrossClusterSearchConnectionStatusCode(StrEnum): + PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE" + VALIDATING = "VALIDATING" + VALIDATION_FAILED = "VALIDATION_FAILED" + PROVISIONING = "PROVISIONING" + ACTIVE = "ACTIVE" + REJECTED = "REJECTED" + DELETING = "DELETING" + DELETED = "DELETED" + + +class OverallChangeStatus(StrEnum): + PENDING = "PENDING" + PROCESSING = "PROCESSING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +class PackageStatus(StrEnum): + COPYING = "COPYING" + COPY_FAILED = "COPY_FAILED" + VALIDATING = "VALIDATING" + VALIDATION_FAILED = "VALIDATION_FAILED" + AVAILABLE = "AVAILABLE" + DELETING = "DELETING" + DELETED = "DELETED" + DELETE_FAILED = "DELETE_FAILED" + + +class PackageType(StrEnum): + TXT_DICTIONARY = "TXT-DICTIONARY" + + +class PrincipalType(StrEnum): + AWS_ACCOUNT = "AWS_ACCOUNT" + AWS_SERVICE = "AWS_SERVICE" + + +class PropertyValueType(StrEnum): + PLAIN_TEXT = "PLAIN_TEXT" + STRINGIFIED_JSON = "STRINGIFIED_JSON" + + +class ReservedElasticsearchInstancePaymentOption(StrEnum): + ALL_UPFRONT = "ALL_UPFRONT" + PARTIAL_UPFRONT = "PARTIAL_UPFRONT" + NO_UPFRONT = "NO_UPFRONT" + + +class RollbackOnDisable(StrEnum): + NO_ROLLBACK = "NO_ROLLBACK" + DEFAULT_ROLLBACK = "DEFAULT_ROLLBACK" + + +class ScheduledAutoTuneActionType(StrEnum): + JVM_HEAP_SIZE_TUNING = "JVM_HEAP_SIZE_TUNING" + JVM_YOUNG_GEN_TUNING = "JVM_YOUNG_GEN_TUNING" + + +class ScheduledAutoTuneSeverityType(StrEnum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + + +class TLSSecurityPolicy(StrEnum): + Policy_Min_TLS_1_0_2019_07 = "Policy-Min-TLS-1-0-2019-07" + Policy_Min_TLS_1_2_2019_07 = "Policy-Min-TLS-1-2-2019-07" + Policy_Min_TLS_1_2_PFS_2023_10 = "Policy-Min-TLS-1-2-PFS-2023-10" + + +class TimeUnit(StrEnum): + HOURS = "HOURS" + + +class UpgradeStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + SUCCEEDED = "SUCCEEDED" + SUCCEEDED_WITH_ISSUES = "SUCCEEDED_WITH_ISSUES" + FAILED = "FAILED" + + +class UpgradeStep(StrEnum): + PRE_UPGRADE_CHECK = "PRE_UPGRADE_CHECK" + SNAPSHOT = "SNAPSHOT" + UPGRADE = "UPGRADE" + + +class VolumeType(StrEnum): + standard = "standard" + gp2 = "gp2" + io1 = "io1" + gp3 = "gp3" + + +class VpcEndpointErrorCode(StrEnum): + ENDPOINT_NOT_FOUND = "ENDPOINT_NOT_FOUND" + SERVER_ERROR = "SERVER_ERROR" + + +class VpcEndpointStatus(StrEnum): + CREATING = "CREATING" + CREATE_FAILED = "CREATE_FAILED" + ACTIVE = "ACTIVE" + UPDATING = "UPDATING" + UPDATE_FAILED = "UPDATE_FAILED" + DELETING = "DELETING" + DELETE_FAILED = "DELETE_FAILED" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 403 + + +class BaseException(ServiceException): + code: str = "BaseException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 409 + + +class DisabledOperationException(ServiceException): + code: str = "DisabledOperationException" + sender_fault: bool = False + status_code: int = 409 + + +class InternalException(ServiceException): + code: str = "InternalException" + sender_fault: bool = False + status_code: int = 500 + + +class InvalidPaginationTokenException(ServiceException): + code: str = "InvalidPaginationTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTypeException(ServiceException): + code: str = "InvalidTypeException" + sender_fault: bool = False + status_code: int = 409 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 409 + + +class ResourceAlreadyExistsException(ServiceException): + code: str = "ResourceAlreadyExistsException" + sender_fault: bool = False + status_code: int = 409 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 409 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + + +class AcceptInboundCrossClusterSearchConnectionRequest(ServiceRequest): + CrossClusterSearchConnectionId: CrossClusterSearchConnectionId + + +class InboundCrossClusterSearchConnectionStatus(TypedDict, total=False): + StatusCode: Optional[InboundCrossClusterSearchConnectionStatusCode] + Message: Optional[CrossClusterSearchConnectionStatusMessage] + + +class DomainInformation(TypedDict, total=False): + OwnerId: Optional[OwnerId] + DomainName: DomainName + Region: Optional[Region] + + +class InboundCrossClusterSearchConnection(TypedDict, total=False): + SourceDomainInfo: Optional[DomainInformation] + DestinationDomainInfo: Optional[DomainInformation] + CrossClusterSearchConnectionId: Optional[CrossClusterSearchConnectionId] + ConnectionStatus: Optional[InboundCrossClusterSearchConnectionStatus] + + +class AcceptInboundCrossClusterSearchConnectionResponse(TypedDict, total=False): + CrossClusterSearchConnection: Optional[InboundCrossClusterSearchConnection] + + +UpdateTimestamp = datetime + + +class OptionStatus(TypedDict, total=False): + CreationDate: UpdateTimestamp + UpdateDate: UpdateTimestamp + UpdateVersion: Optional[UIntValue] + State: OptionState + PendingDeletion: Optional[Boolean] + + +class AccessPoliciesStatus(TypedDict, total=False): + Options: PolicyDocument + Status: OptionStatus + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class AddTagsRequest(ServiceRequest): + ARN: ARN + TagList: TagList + + +LimitValueList = List[LimitValue] + + +class AdditionalLimit(TypedDict, total=False): + LimitName: Optional[LimitName] + LimitValues: Optional[LimitValueList] + + +AdditionalLimitList = List[AdditionalLimit] +AdvancedOptions = Dict[String, String] + + +class AdvancedOptionsStatus(TypedDict, total=False): + Options: AdvancedOptions + Status: OptionStatus + + +DisableTimestamp = datetime + + +class SAMLIdp(TypedDict, total=False): + MetadataContent: SAMLMetadata + EntityId: SAMLEntityId + + +class SAMLOptionsOutput(TypedDict, total=False): + Enabled: Optional[Boolean] + Idp: Optional[SAMLIdp] + SubjectKey: Optional[String] + RolesKey: Optional[String] + SessionTimeoutMinutes: Optional[IntegerClass] + + +class AdvancedSecurityOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + InternalUserDatabaseEnabled: Optional[Boolean] + SAMLOptions: Optional[SAMLOptionsOutput] + AnonymousAuthDisableDate: Optional[DisableTimestamp] + AnonymousAuthEnabled: Optional[Boolean] + + +class SAMLOptionsInput(TypedDict, total=False): + Enabled: Optional[Boolean] + Idp: Optional[SAMLIdp] + MasterUserName: Optional[Username] + MasterBackendRole: Optional[BackendRole] + SubjectKey: Optional[String] + RolesKey: Optional[String] + SessionTimeoutMinutes: Optional[IntegerClass] + + +class MasterUserOptions(TypedDict, total=False): + MasterUserARN: Optional[ARN] + MasterUserName: Optional[Username] + MasterUserPassword: Optional[Password] + + +class AdvancedSecurityOptionsInput(TypedDict, total=False): + Enabled: Optional[Boolean] + InternalUserDatabaseEnabled: Optional[Boolean] + MasterUserOptions: Optional[MasterUserOptions] + SAMLOptions: Optional[SAMLOptionsInput] + AnonymousAuthEnabled: Optional[Boolean] + + +class AdvancedSecurityOptionsStatus(TypedDict, total=False): + Options: AdvancedSecurityOptions + Status: OptionStatus + + +class AssociatePackageRequest(ServiceRequest): + PackageID: PackageID + DomainName: DomainName + + +class ErrorDetails(TypedDict, total=False): + ErrorType: Optional[ErrorType] + ErrorMessage: Optional[ErrorMessage] + + +LastUpdated = datetime + + +class DomainPackageDetails(TypedDict, total=False): + PackageID: Optional[PackageID] + PackageName: Optional[PackageName] + PackageType: Optional[PackageType] + LastUpdated: Optional[LastUpdated] + DomainName: Optional[DomainName] + DomainPackageStatus: Optional[DomainPackageStatus] + PackageVersion: Optional[PackageVersion] + ReferencePath: Optional[ReferencePath] + ErrorDetails: Optional[ErrorDetails] + + +class AssociatePackageResponse(TypedDict, total=False): + DomainPackageDetails: Optional[DomainPackageDetails] + + +class AuthorizeVpcEndpointAccessRequest(ServiceRequest): + DomainName: DomainName + Account: AWSAccount + + +class AuthorizedPrincipal(TypedDict, total=False): + PrincipalType: Optional[PrincipalType] + Principal: Optional[String] + + +class AuthorizeVpcEndpointAccessResponse(TypedDict, total=False): + AuthorizedPrincipal: AuthorizedPrincipal + + +AuthorizedPrincipalList = List[AuthorizedPrincipal] +AutoTuneDate = datetime + + +class ScheduledAutoTuneDetails(TypedDict, total=False): + Date: Optional[AutoTuneDate] + ActionType: Optional[ScheduledAutoTuneActionType] + Action: Optional[ScheduledAutoTuneDescription] + Severity: Optional[ScheduledAutoTuneSeverityType] + + +class AutoTuneDetails(TypedDict, total=False): + ScheduledAutoTuneDetails: Optional[ScheduledAutoTuneDetails] + + +class AutoTune(TypedDict, total=False): + AutoTuneType: Optional[AutoTuneType] + AutoTuneDetails: Optional[AutoTuneDetails] + + +AutoTuneList = List[AutoTune] +DurationValue = int + + +class Duration(TypedDict, total=False): + Value: Optional[DurationValue] + Unit: Optional[TimeUnit] + + +StartAt = datetime + + +class AutoTuneMaintenanceSchedule(TypedDict, total=False): + StartAt: Optional[StartAt] + Duration: Optional[Duration] + CronExpressionForRecurrence: Optional[String] + + +AutoTuneMaintenanceScheduleList = List[AutoTuneMaintenanceSchedule] + + +class AutoTuneOptions(TypedDict, total=False): + DesiredState: Optional[AutoTuneDesiredState] + RollbackOnDisable: Optional[RollbackOnDisable] + MaintenanceSchedules: Optional[AutoTuneMaintenanceScheduleList] + + +class AutoTuneOptionsInput(TypedDict, total=False): + DesiredState: Optional[AutoTuneDesiredState] + MaintenanceSchedules: Optional[AutoTuneMaintenanceScheduleList] + + +class AutoTuneOptionsOutput(TypedDict, total=False): + State: Optional[AutoTuneState] + ErrorMessage: Optional[String] + + +class AutoTuneStatus(TypedDict, total=False): + CreationDate: UpdateTimestamp + UpdateDate: UpdateTimestamp + UpdateVersion: Optional[UIntValue] + State: AutoTuneState + ErrorMessage: Optional[String] + PendingDeletion: Optional[Boolean] + + +class AutoTuneOptionsStatus(TypedDict, total=False): + Options: Optional[AutoTuneOptions] + Status: Optional[AutoTuneStatus] + + +class CancelDomainConfigChangeRequest(ServiceRequest): + DomainName: DomainName + DryRun: Optional[DryRun] + + +class CancelledChangeProperty(TypedDict, total=False): + PropertyName: Optional[String] + CancelledValue: Optional[String] + ActiveValue: Optional[String] + + +CancelledChangePropertyList = List[CancelledChangeProperty] +GUIDList = List[GUID] + + +class CancelDomainConfigChangeResponse(TypedDict, total=False): + DryRun: Optional[DryRun] + CancelledChangeIds: Optional[GUIDList] + CancelledChangeProperties: Optional[CancelledChangePropertyList] + + +class CancelElasticsearchServiceSoftwareUpdateRequest(ServiceRequest): + DomainName: DomainName + + +DeploymentCloseDateTimeStamp = datetime + + +class ServiceSoftwareOptions(TypedDict, total=False): + CurrentVersion: Optional[String] + NewVersion: Optional[String] + UpdateAvailable: Optional[Boolean] + Cancellable: Optional[Boolean] + UpdateStatus: Optional[DeploymentStatus] + Description: Optional[String] + AutomatedUpdateDate: Optional[DeploymentCloseDateTimeStamp] + OptionalDeployment: Optional[Boolean] + + +class CancelElasticsearchServiceSoftwareUpdateResponse(TypedDict, total=False): + ServiceSoftwareOptions: Optional[ServiceSoftwareOptions] + + +class ChangeProgressDetails(TypedDict, total=False): + ChangeId: Optional[GUID] + Message: Optional[Message] + ConfigChangeStatus: Optional[ConfigChangeStatus] + StartTime: Optional[UpdateTimestamp] + LastUpdatedTime: Optional[UpdateTimestamp] + InitiatedBy: Optional[InitiatedBy] + + +class ChangeProgressStage(TypedDict, total=False): + Name: Optional[ChangeProgressStageName] + Status: Optional[ChangeProgressStageStatus] + Description: Optional[Description] + LastUpdated: Optional[LastUpdated] + + +ChangeProgressStageList = List[ChangeProgressStage] +StringList = List[String] + + +class ChangeProgressStatusDetails(TypedDict, total=False): + ChangeId: Optional[GUID] + StartTime: Optional[UpdateTimestamp] + Status: Optional[OverallChangeStatus] + PendingProperties: Optional[StringList] + CompletedProperties: Optional[StringList] + TotalNumberOfStages: Optional[TotalNumberOfStages] + ChangeProgressStages: Optional[ChangeProgressStageList] + ConfigChangeStatus: Optional[ConfigChangeStatus] + LastUpdatedTime: Optional[UpdateTimestamp] + InitiatedBy: Optional[InitiatedBy] + + +class CognitoOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + UserPoolId: Optional[UserPoolId] + IdentityPoolId: Optional[IdentityPoolId] + RoleArn: Optional[RoleArn] + + +class CognitoOptionsStatus(TypedDict, total=False): + Options: CognitoOptions + Status: OptionStatus + + +class ColdStorageOptions(TypedDict, total=False): + Enabled: Boolean + + +ElasticsearchVersionList = List[ElasticsearchVersionString] + + +class CompatibleVersionsMap(TypedDict, total=False): + SourceVersion: Optional[ElasticsearchVersionString] + TargetVersions: Optional[ElasticsearchVersionList] + + +CompatibleElasticsearchVersionsList = List[CompatibleVersionsMap] + + +class DomainEndpointOptions(TypedDict, total=False): + EnforceHTTPS: Optional[Boolean] + TLSSecurityPolicy: Optional[TLSSecurityPolicy] + CustomEndpointEnabled: Optional[Boolean] + CustomEndpoint: Optional[DomainNameFqdn] + CustomEndpointCertificateArn: Optional[ARN] + + +class LogPublishingOption(TypedDict, total=False): + CloudWatchLogsLogGroupArn: Optional[CloudWatchLogsLogGroupArn] + Enabled: Optional[Boolean] + + +LogPublishingOptions = Dict[LogType, LogPublishingOption] + + +class NodeToNodeEncryptionOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class EncryptionAtRestOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + KmsKeyId: Optional[KmsKeyId] + + +class VPCOptions(TypedDict, total=False): + SubnetIds: Optional[StringList] + SecurityGroupIds: Optional[StringList] + + +class SnapshotOptions(TypedDict, total=False): + AutomatedSnapshotStartHour: Optional[IntegerClass] + + +class EBSOptions(TypedDict, total=False): + EBSEnabled: Optional[Boolean] + VolumeType: Optional[VolumeType] + VolumeSize: Optional[IntegerClass] + Iops: Optional[IntegerClass] + Throughput: Optional[IntegerClass] + + +class ZoneAwarenessConfig(TypedDict, total=False): + AvailabilityZoneCount: Optional[IntegerClass] + + +class ElasticsearchClusterConfig(TypedDict, total=False): + InstanceType: Optional[ESPartitionInstanceType] + InstanceCount: Optional[IntegerClass] + DedicatedMasterEnabled: Optional[Boolean] + ZoneAwarenessEnabled: Optional[Boolean] + ZoneAwarenessConfig: Optional[ZoneAwarenessConfig] + DedicatedMasterType: Optional[ESPartitionInstanceType] + DedicatedMasterCount: Optional[IntegerClass] + WarmEnabled: Optional[Boolean] + WarmType: Optional[ESWarmPartitionInstanceType] + WarmCount: Optional[IntegerClass] + ColdStorageOptions: Optional[ColdStorageOptions] + + +class CreateElasticsearchDomainRequest(ServiceRequest): + DomainName: DomainName + ElasticsearchVersion: Optional[ElasticsearchVersionString] + ElasticsearchClusterConfig: Optional[ElasticsearchClusterConfig] + EBSOptions: Optional[EBSOptions] + AccessPolicies: Optional[PolicyDocument] + SnapshotOptions: Optional[SnapshotOptions] + VPCOptions: Optional[VPCOptions] + CognitoOptions: Optional[CognitoOptions] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + AdvancedOptions: Optional[AdvancedOptions] + LogPublishingOptions: Optional[LogPublishingOptions] + DomainEndpointOptions: Optional[DomainEndpointOptions] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsInput] + AutoTuneOptions: Optional[AutoTuneOptionsInput] + TagList: Optional[TagList] + + +class ModifyingProperties(TypedDict, total=False): + Name: Optional[String] + ActiveValue: Optional[String] + PendingValue: Optional[String] + ValueType: Optional[PropertyValueType] + + +ModifyingPropertiesList = List[ModifyingProperties] + + +class VPCDerivedInfo(TypedDict, total=False): + VPCId: Optional[String] + SubnetIds: Optional[StringList] + AvailabilityZones: Optional[StringList] + SecurityGroupIds: Optional[StringList] + + +EndpointsMap = Dict[String, ServiceUrl] + + +class ElasticsearchDomainStatus(TypedDict, total=False): + DomainId: DomainId + DomainName: DomainName + ARN: ARN + Created: Optional[Boolean] + Deleted: Optional[Boolean] + Endpoint: Optional[ServiceUrl] + Endpoints: Optional[EndpointsMap] + Processing: Optional[Boolean] + UpgradeProcessing: Optional[Boolean] + ElasticsearchVersion: Optional[ElasticsearchVersionString] + ElasticsearchClusterConfig: ElasticsearchClusterConfig + EBSOptions: Optional[EBSOptions] + AccessPolicies: Optional[PolicyDocument] + SnapshotOptions: Optional[SnapshotOptions] + VPCOptions: Optional[VPCDerivedInfo] + CognitoOptions: Optional[CognitoOptions] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + AdvancedOptions: Optional[AdvancedOptions] + LogPublishingOptions: Optional[LogPublishingOptions] + ServiceSoftwareOptions: Optional[ServiceSoftwareOptions] + DomainEndpointOptions: Optional[DomainEndpointOptions] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptions] + AutoTuneOptions: Optional[AutoTuneOptionsOutput] + ChangeProgressDetails: Optional[ChangeProgressDetails] + DomainProcessingStatus: Optional[DomainProcessingStatusType] + ModifyingProperties: Optional[ModifyingPropertiesList] + + +class CreateElasticsearchDomainResponse(TypedDict, total=False): + DomainStatus: Optional[ElasticsearchDomainStatus] + + +class CreateOutboundCrossClusterSearchConnectionRequest(ServiceRequest): + SourceDomainInfo: DomainInformation + DestinationDomainInfo: DomainInformation + ConnectionAlias: ConnectionAlias + + +class OutboundCrossClusterSearchConnectionStatus(TypedDict, total=False): + StatusCode: Optional[OutboundCrossClusterSearchConnectionStatusCode] + Message: Optional[CrossClusterSearchConnectionStatusMessage] + + +class CreateOutboundCrossClusterSearchConnectionResponse(TypedDict, total=False): + SourceDomainInfo: Optional[DomainInformation] + DestinationDomainInfo: Optional[DomainInformation] + ConnectionAlias: Optional[ConnectionAlias] + ConnectionStatus: Optional[OutboundCrossClusterSearchConnectionStatus] + CrossClusterSearchConnectionId: Optional[CrossClusterSearchConnectionId] + + +class PackageSource(TypedDict, total=False): + S3BucketName: Optional[S3BucketName] + S3Key: Optional[S3Key] + + +class CreatePackageRequest(ServiceRequest): + PackageName: PackageName + PackageType: PackageType + PackageDescription: Optional[PackageDescription] + PackageSource: PackageSource + + +CreatedAt = datetime + + +class PackageDetails(TypedDict, total=False): + PackageID: Optional[PackageID] + PackageName: Optional[PackageName] + PackageType: Optional[PackageType] + PackageDescription: Optional[PackageDescription] + PackageStatus: Optional[PackageStatus] + CreatedAt: Optional[CreatedAt] + LastUpdatedAt: Optional[LastUpdated] + AvailablePackageVersion: Optional[PackageVersion] + ErrorDetails: Optional[ErrorDetails] + + +class CreatePackageResponse(TypedDict, total=False): + PackageDetails: Optional[PackageDetails] + + +class CreateVpcEndpointRequest(ServiceRequest): + DomainArn: DomainArn + VpcOptions: VPCOptions + ClientToken: Optional[ClientToken] + + +class VpcEndpoint(TypedDict, total=False): + VpcEndpointId: Optional[VpcEndpointId] + VpcEndpointOwner: Optional[AWSAccount] + DomainArn: Optional[DomainArn] + VpcOptions: Optional[VPCDerivedInfo] + Status: Optional[VpcEndpointStatus] + Endpoint: Optional[Endpoint] + + +class CreateVpcEndpointResponse(TypedDict, total=False): + VpcEndpoint: VpcEndpoint + + +class DeleteElasticsearchDomainRequest(ServiceRequest): + DomainName: DomainName + + +class DeleteElasticsearchDomainResponse(TypedDict, total=False): + DomainStatus: Optional[ElasticsearchDomainStatus] + + +class DeleteInboundCrossClusterSearchConnectionRequest(ServiceRequest): + CrossClusterSearchConnectionId: CrossClusterSearchConnectionId + + +class DeleteInboundCrossClusterSearchConnectionResponse(TypedDict, total=False): + CrossClusterSearchConnection: Optional[InboundCrossClusterSearchConnection] + + +class DeleteOutboundCrossClusterSearchConnectionRequest(ServiceRequest): + CrossClusterSearchConnectionId: CrossClusterSearchConnectionId + + +class OutboundCrossClusterSearchConnection(TypedDict, total=False): + SourceDomainInfo: Optional[DomainInformation] + DestinationDomainInfo: Optional[DomainInformation] + CrossClusterSearchConnectionId: Optional[CrossClusterSearchConnectionId] + ConnectionAlias: Optional[ConnectionAlias] + ConnectionStatus: Optional[OutboundCrossClusterSearchConnectionStatus] + + +class DeleteOutboundCrossClusterSearchConnectionResponse(TypedDict, total=False): + CrossClusterSearchConnection: Optional[OutboundCrossClusterSearchConnection] + + +class DeletePackageRequest(ServiceRequest): + PackageID: PackageID + + +class DeletePackageResponse(TypedDict, total=False): + PackageDetails: Optional[PackageDetails] + + +class DeleteVpcEndpointRequest(ServiceRequest): + VpcEndpointId: VpcEndpointId + + +class VpcEndpointSummary(TypedDict, total=False): + VpcEndpointId: Optional[VpcEndpointId] + VpcEndpointOwner: Optional[String] + DomainArn: Optional[DomainArn] + Status: Optional[VpcEndpointStatus] + + +class DeleteVpcEndpointResponse(TypedDict, total=False): + VpcEndpointSummary: VpcEndpointSummary + + +class DescribeDomainAutoTunesRequest(ServiceRequest): + DomainName: DomainName + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class DescribeDomainAutoTunesResponse(TypedDict, total=False): + AutoTunes: Optional[AutoTuneList] + NextToken: Optional[NextToken] + + +class DescribeDomainChangeProgressRequest(ServiceRequest): + DomainName: DomainName + ChangeId: Optional[GUID] + + +class DescribeDomainChangeProgressResponse(TypedDict, total=False): + ChangeProgressStatus: Optional[ChangeProgressStatusDetails] + + +class DescribeElasticsearchDomainConfigRequest(ServiceRequest): + DomainName: DomainName + + +class DomainEndpointOptionsStatus(TypedDict, total=False): + Options: DomainEndpointOptions + Status: OptionStatus + + +class LogPublishingOptionsStatus(TypedDict, total=False): + Options: Optional[LogPublishingOptions] + Status: Optional[OptionStatus] + + +class NodeToNodeEncryptionOptionsStatus(TypedDict, total=False): + Options: NodeToNodeEncryptionOptions + Status: OptionStatus + + +class EncryptionAtRestOptionsStatus(TypedDict, total=False): + Options: EncryptionAtRestOptions + Status: OptionStatus + + +class VPCDerivedInfoStatus(TypedDict, total=False): + Options: VPCDerivedInfo + Status: OptionStatus + + +class SnapshotOptionsStatus(TypedDict, total=False): + Options: SnapshotOptions + Status: OptionStatus + + +class EBSOptionsStatus(TypedDict, total=False): + Options: EBSOptions + Status: OptionStatus + + +class ElasticsearchClusterConfigStatus(TypedDict, total=False): + Options: ElasticsearchClusterConfig + Status: OptionStatus + + +class ElasticsearchVersionStatus(TypedDict, total=False): + Options: ElasticsearchVersionString + Status: OptionStatus + + +class ElasticsearchDomainConfig(TypedDict, total=False): + ElasticsearchVersion: Optional[ElasticsearchVersionStatus] + ElasticsearchClusterConfig: Optional[ElasticsearchClusterConfigStatus] + EBSOptions: Optional[EBSOptionsStatus] + AccessPolicies: Optional[AccessPoliciesStatus] + SnapshotOptions: Optional[SnapshotOptionsStatus] + VPCOptions: Optional[VPCDerivedInfoStatus] + CognitoOptions: Optional[CognitoOptionsStatus] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptionsStatus] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptionsStatus] + AdvancedOptions: Optional[AdvancedOptionsStatus] + LogPublishingOptions: Optional[LogPublishingOptionsStatus] + DomainEndpointOptions: Optional[DomainEndpointOptionsStatus] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsStatus] + AutoTuneOptions: Optional[AutoTuneOptionsStatus] + ChangeProgressDetails: Optional[ChangeProgressDetails] + ModifyingProperties: Optional[ModifyingPropertiesList] + + +class DescribeElasticsearchDomainConfigResponse(TypedDict, total=False): + DomainConfig: ElasticsearchDomainConfig + + +class DescribeElasticsearchDomainRequest(ServiceRequest): + DomainName: DomainName + + +class DescribeElasticsearchDomainResponse(TypedDict, total=False): + DomainStatus: ElasticsearchDomainStatus + + +DomainNameList = List[DomainName] + + +class DescribeElasticsearchDomainsRequest(ServiceRequest): + DomainNames: DomainNameList + + +ElasticsearchDomainStatusList = List[ElasticsearchDomainStatus] + + +class DescribeElasticsearchDomainsResponse(TypedDict, total=False): + DomainStatusList: ElasticsearchDomainStatusList + + +class DescribeElasticsearchInstanceTypeLimitsRequest(ServiceRequest): + DomainName: Optional[DomainName] + InstanceType: ESPartitionInstanceType + ElasticsearchVersion: ElasticsearchVersionString + + +class InstanceCountLimits(TypedDict, total=False): + MinimumInstanceCount: Optional[MinimumInstanceCount] + MaximumInstanceCount: Optional[MaximumInstanceCount] + + +class InstanceLimits(TypedDict, total=False): + InstanceCountLimits: Optional[InstanceCountLimits] + + +class StorageTypeLimit(TypedDict, total=False): + LimitName: Optional[LimitName] + LimitValues: Optional[LimitValueList] + + +StorageTypeLimitList = List[StorageTypeLimit] + + +class StorageType(TypedDict, total=False): + StorageTypeName: Optional[StorageTypeName] + StorageSubTypeName: Optional[StorageSubTypeName] + StorageTypeLimits: Optional[StorageTypeLimitList] + + +StorageTypeList = List[StorageType] + + +class Limits(TypedDict, total=False): + StorageTypes: Optional[StorageTypeList] + InstanceLimits: Optional[InstanceLimits] + AdditionalLimits: Optional[AdditionalLimitList] + + +LimitsByRole = Dict[InstanceRole, Limits] + + +class DescribeElasticsearchInstanceTypeLimitsResponse(TypedDict, total=False): + LimitsByRole: Optional[LimitsByRole] + + +ValueStringList = List[NonEmptyString] + + +class Filter(TypedDict, total=False): + Name: Optional[NonEmptyString] + Values: Optional[ValueStringList] + + +FilterList = List[Filter] + + +class DescribeInboundCrossClusterSearchConnectionsRequest(ServiceRequest): + Filters: Optional[FilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +InboundCrossClusterSearchConnections = List[InboundCrossClusterSearchConnection] + + +class DescribeInboundCrossClusterSearchConnectionsResponse(TypedDict, total=False): + CrossClusterSearchConnections: Optional[InboundCrossClusterSearchConnections] + NextToken: Optional[NextToken] + + +class DescribeOutboundCrossClusterSearchConnectionsRequest(ServiceRequest): + Filters: Optional[FilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +OutboundCrossClusterSearchConnections = List[OutboundCrossClusterSearchConnection] + + +class DescribeOutboundCrossClusterSearchConnectionsResponse(TypedDict, total=False): + CrossClusterSearchConnections: Optional[OutboundCrossClusterSearchConnections] + NextToken: Optional[NextToken] + + +DescribePackagesFilterValues = List[DescribePackagesFilterValue] + + +class DescribePackagesFilter(TypedDict, total=False): + Name: Optional[DescribePackagesFilterName] + Value: Optional[DescribePackagesFilterValues] + + +DescribePackagesFilterList = List[DescribePackagesFilter] + + +class DescribePackagesRequest(ServiceRequest): + Filters: Optional[DescribePackagesFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +PackageDetailsList = List[PackageDetails] + + +class DescribePackagesResponse(TypedDict, total=False): + PackageDetailsList: Optional[PackageDetailsList] + NextToken: Optional[String] + + +class DescribeReservedElasticsearchInstanceOfferingsRequest(ServiceRequest): + ReservedElasticsearchInstanceOfferingId: Optional[GUID] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class RecurringCharge(TypedDict, total=False): + RecurringChargeAmount: Optional[Double] + RecurringChargeFrequency: Optional[String] + + +RecurringChargeList = List[RecurringCharge] + + +class ReservedElasticsearchInstanceOffering(TypedDict, total=False): + ReservedElasticsearchInstanceOfferingId: Optional[GUID] + ElasticsearchInstanceType: Optional[ESPartitionInstanceType] + Duration: Optional[Integer] + FixedPrice: Optional[Double] + UsagePrice: Optional[Double] + CurrencyCode: Optional[String] + PaymentOption: Optional[ReservedElasticsearchInstancePaymentOption] + RecurringCharges: Optional[RecurringChargeList] + + +ReservedElasticsearchInstanceOfferingList = List[ReservedElasticsearchInstanceOffering] + + +class DescribeReservedElasticsearchInstanceOfferingsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + ReservedElasticsearchInstanceOfferings: Optional[ReservedElasticsearchInstanceOfferingList] + + +class DescribeReservedElasticsearchInstancesRequest(ServiceRequest): + ReservedElasticsearchInstanceId: Optional[GUID] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ReservedElasticsearchInstance(TypedDict, total=False): + ReservationName: Optional[ReservationToken] + ReservedElasticsearchInstanceId: Optional[GUID] + ReservedElasticsearchInstanceOfferingId: Optional[String] + ElasticsearchInstanceType: Optional[ESPartitionInstanceType] + StartTime: Optional[UpdateTimestamp] + Duration: Optional[Integer] + FixedPrice: Optional[Double] + UsagePrice: Optional[Double] + CurrencyCode: Optional[String] + ElasticsearchInstanceCount: Optional[Integer] + State: Optional[String] + PaymentOption: Optional[ReservedElasticsearchInstancePaymentOption] + RecurringCharges: Optional[RecurringChargeList] + + +ReservedElasticsearchInstanceList = List[ReservedElasticsearchInstance] + + +class DescribeReservedElasticsearchInstancesResponse(TypedDict, total=False): + NextToken: Optional[String] + ReservedElasticsearchInstances: Optional[ReservedElasticsearchInstanceList] + + +VpcEndpointIdList = List[VpcEndpointId] + + +class DescribeVpcEndpointsRequest(ServiceRequest): + VpcEndpointIds: VpcEndpointIdList + + +class VpcEndpointError(TypedDict, total=False): + VpcEndpointId: Optional[VpcEndpointId] + ErrorCode: Optional[VpcEndpointErrorCode] + ErrorMessage: Optional[String] + + +VpcEndpointErrorList = List[VpcEndpointError] +VpcEndpoints = List[VpcEndpoint] + + +class DescribeVpcEndpointsResponse(TypedDict, total=False): + VpcEndpoints: VpcEndpoints + VpcEndpointErrors: VpcEndpointErrorList + + +class DissociatePackageRequest(ServiceRequest): + PackageID: PackageID + DomainName: DomainName + + +class DissociatePackageResponse(TypedDict, total=False): + DomainPackageDetails: Optional[DomainPackageDetails] + + +class DomainInfo(TypedDict, total=False): + DomainName: Optional[DomainName] + EngineType: Optional[EngineType] + + +DomainInfoList = List[DomainInfo] +DomainPackageDetailsList = List[DomainPackageDetails] + + +class DryRunResults(TypedDict, total=False): + DeploymentType: Optional[DeploymentType] + Message: Optional[Message] + + +ElasticsearchInstanceTypeList = List[ESPartitionInstanceType] + + +class GetCompatibleElasticsearchVersionsRequest(ServiceRequest): + DomainName: Optional[DomainName] + + +class GetCompatibleElasticsearchVersionsResponse(TypedDict, total=False): + CompatibleElasticsearchVersions: Optional[CompatibleElasticsearchVersionsList] + + +class GetPackageVersionHistoryRequest(ServiceRequest): + PackageID: PackageID + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class PackageVersionHistory(TypedDict, total=False): + PackageVersion: Optional[PackageVersion] + CommitMessage: Optional[CommitMessage] + CreatedAt: Optional[CreatedAt] + + +PackageVersionHistoryList = List[PackageVersionHistory] + + +class GetPackageVersionHistoryResponse(TypedDict, total=False): + PackageID: Optional[PackageID] + PackageVersionHistoryList: Optional[PackageVersionHistoryList] + NextToken: Optional[String] + + +class GetUpgradeHistoryRequest(ServiceRequest): + DomainName: DomainName + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +Issues = List[Issue] + + +class UpgradeStepItem(TypedDict, total=False): + UpgradeStep: Optional[UpgradeStep] + UpgradeStepStatus: Optional[UpgradeStatus] + Issues: Optional[Issues] + ProgressPercent: Optional[Double] + + +UpgradeStepsList = List[UpgradeStepItem] +StartTimestamp = datetime + + +class UpgradeHistory(TypedDict, total=False): + UpgradeName: Optional[UpgradeName] + StartTimestamp: Optional[StartTimestamp] + UpgradeStatus: Optional[UpgradeStatus] + StepsList: Optional[UpgradeStepsList] + + +UpgradeHistoryList = List[UpgradeHistory] + + +class GetUpgradeHistoryResponse(TypedDict, total=False): + UpgradeHistories: Optional[UpgradeHistoryList] + NextToken: Optional[String] + + +class GetUpgradeStatusRequest(ServiceRequest): + DomainName: DomainName + + +class GetUpgradeStatusResponse(TypedDict, total=False): + UpgradeStep: Optional[UpgradeStep] + StepStatus: Optional[UpgradeStatus] + UpgradeName: Optional[UpgradeName] + + +class ListDomainNamesRequest(ServiceRequest): + EngineType: Optional[EngineType] + + +class ListDomainNamesResponse(TypedDict, total=False): + DomainNames: Optional[DomainInfoList] + + +class ListDomainsForPackageRequest(ServiceRequest): + PackageID: PackageID + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListDomainsForPackageResponse(TypedDict, total=False): + DomainPackageDetailsList: Optional[DomainPackageDetailsList] + NextToken: Optional[String] + + +class ListElasticsearchInstanceTypesRequest(ServiceRequest): + ElasticsearchVersion: ElasticsearchVersionString + DomainName: Optional[DomainName] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListElasticsearchInstanceTypesResponse(TypedDict, total=False): + ElasticsearchInstanceTypes: Optional[ElasticsearchInstanceTypeList] + NextToken: Optional[NextToken] + + +class ListElasticsearchVersionsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListElasticsearchVersionsResponse(TypedDict, total=False): + ElasticsearchVersions: Optional[ElasticsearchVersionList] + NextToken: Optional[NextToken] + + +class ListPackagesForDomainRequest(ServiceRequest): + DomainName: DomainName + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListPackagesForDomainResponse(TypedDict, total=False): + DomainPackageDetailsList: Optional[DomainPackageDetailsList] + NextToken: Optional[String] + + +class ListTagsRequest(ServiceRequest): + ARN: ARN + + +class ListTagsResponse(TypedDict, total=False): + TagList: Optional[TagList] + + +class ListVpcEndpointAccessRequest(ServiceRequest): + DomainName: DomainName + NextToken: Optional[NextToken] + + +class ListVpcEndpointAccessResponse(TypedDict, total=False): + AuthorizedPrincipalList: AuthorizedPrincipalList + NextToken: NextToken + + +class ListVpcEndpointsForDomainRequest(ServiceRequest): + DomainName: DomainName + NextToken: Optional[NextToken] + + +VpcEndpointSummaryList = List[VpcEndpointSummary] + + +class ListVpcEndpointsForDomainResponse(TypedDict, total=False): + VpcEndpointSummaryList: VpcEndpointSummaryList + NextToken: NextToken + + +class ListVpcEndpointsRequest(ServiceRequest): + NextToken: Optional[NextToken] + + +class ListVpcEndpointsResponse(TypedDict, total=False): + VpcEndpointSummaryList: VpcEndpointSummaryList + NextToken: NextToken + + +class PurchaseReservedElasticsearchInstanceOfferingRequest(ServiceRequest): + ReservedElasticsearchInstanceOfferingId: GUID + ReservationName: ReservationToken + InstanceCount: Optional[InstanceCount] + + +class PurchaseReservedElasticsearchInstanceOfferingResponse(TypedDict, total=False): + ReservedElasticsearchInstanceId: Optional[GUID] + ReservationName: Optional[ReservationToken] + + +class RejectInboundCrossClusterSearchConnectionRequest(ServiceRequest): + CrossClusterSearchConnectionId: CrossClusterSearchConnectionId + + +class RejectInboundCrossClusterSearchConnectionResponse(TypedDict, total=False): + CrossClusterSearchConnection: Optional[InboundCrossClusterSearchConnection] + + +class RemoveTagsRequest(ServiceRequest): + ARN: ARN + TagKeys: StringList + + +class RevokeVpcEndpointAccessRequest(ServiceRequest): + DomainName: DomainName + Account: AWSAccount + + +class RevokeVpcEndpointAccessResponse(TypedDict, total=False): + pass + + +class StartElasticsearchServiceSoftwareUpdateRequest(ServiceRequest): + DomainName: DomainName + + +class StartElasticsearchServiceSoftwareUpdateResponse(TypedDict, total=False): + ServiceSoftwareOptions: Optional[ServiceSoftwareOptions] + + +class UpdateElasticsearchDomainConfigRequest(ServiceRequest): + DomainName: DomainName + ElasticsearchClusterConfig: Optional[ElasticsearchClusterConfig] + EBSOptions: Optional[EBSOptions] + SnapshotOptions: Optional[SnapshotOptions] + VPCOptions: Optional[VPCOptions] + CognitoOptions: Optional[CognitoOptions] + AdvancedOptions: Optional[AdvancedOptions] + AccessPolicies: Optional[PolicyDocument] + LogPublishingOptions: Optional[LogPublishingOptions] + DomainEndpointOptions: Optional[DomainEndpointOptions] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsInput] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + AutoTuneOptions: Optional[AutoTuneOptions] + DryRun: Optional[DryRun] + + +class UpdateElasticsearchDomainConfigResponse(TypedDict, total=False): + DomainConfig: ElasticsearchDomainConfig + DryRunResults: Optional[DryRunResults] + + +class UpdatePackageRequest(ServiceRequest): + PackageID: PackageID + PackageSource: PackageSource + PackageDescription: Optional[PackageDescription] + CommitMessage: Optional[CommitMessage] + + +class UpdatePackageResponse(TypedDict, total=False): + PackageDetails: Optional[PackageDetails] + + +class UpdateVpcEndpointRequest(ServiceRequest): + VpcEndpointId: VpcEndpointId + VpcOptions: VPCOptions + + +class UpdateVpcEndpointResponse(TypedDict, total=False): + VpcEndpoint: VpcEndpoint + + +class UpgradeElasticsearchDomainRequest(ServiceRequest): + DomainName: DomainName + TargetVersion: ElasticsearchVersionString + PerformCheckOnly: Optional[Boolean] + + +class UpgradeElasticsearchDomainResponse(TypedDict, total=False): + DomainName: Optional[DomainName] + TargetVersion: Optional[ElasticsearchVersionString] + PerformCheckOnly: Optional[Boolean] + ChangeProgressDetails: Optional[ChangeProgressDetails] + + +class EsApi: + service = "es" + version = "2015-01-01" + + @handler("AcceptInboundCrossClusterSearchConnection") + def accept_inbound_cross_cluster_search_connection( + self, + context: RequestContext, + cross_cluster_search_connection_id: CrossClusterSearchConnectionId, + **kwargs, + ) -> AcceptInboundCrossClusterSearchConnectionResponse: + raise NotImplementedError + + @handler("AddTags") + def add_tags(self, context: RequestContext, arn: ARN, tag_list: TagList, **kwargs) -> None: + raise NotImplementedError + + @handler("AssociatePackage") + def associate_package( + self, context: RequestContext, package_id: PackageID, domain_name: DomainName, **kwargs + ) -> AssociatePackageResponse: + raise NotImplementedError + + @handler("AuthorizeVpcEndpointAccess") + def authorize_vpc_endpoint_access( + self, context: RequestContext, domain_name: DomainName, account: AWSAccount, **kwargs + ) -> AuthorizeVpcEndpointAccessResponse: + raise NotImplementedError + + @handler("CancelDomainConfigChange") + def cancel_domain_config_change( + self, + context: RequestContext, + domain_name: DomainName, + dry_run: DryRun | None = None, + **kwargs, + ) -> CancelDomainConfigChangeResponse: + raise NotImplementedError + + @handler("CancelElasticsearchServiceSoftwareUpdate") + def cancel_elasticsearch_service_software_update( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> CancelElasticsearchServiceSoftwareUpdateResponse: + raise NotImplementedError + + @handler("CreateElasticsearchDomain") + def create_elasticsearch_domain( + self, + context: RequestContext, + domain_name: DomainName, + elasticsearch_version: ElasticsearchVersionString | None = None, + elasticsearch_cluster_config: ElasticsearchClusterConfig | None = None, + ebs_options: EBSOptions | None = None, + access_policies: PolicyDocument | None = None, + snapshot_options: SnapshotOptions | None = None, + vpc_options: VPCOptions | None = None, + cognito_options: CognitoOptions | None = None, + encryption_at_rest_options: EncryptionAtRestOptions | None = None, + node_to_node_encryption_options: NodeToNodeEncryptionOptions | None = None, + advanced_options: AdvancedOptions | None = None, + log_publishing_options: LogPublishingOptions | None = None, + domain_endpoint_options: DomainEndpointOptions | None = None, + advanced_security_options: AdvancedSecurityOptionsInput | None = None, + auto_tune_options: AutoTuneOptionsInput | None = None, + tag_list: TagList | None = None, + **kwargs, + ) -> CreateElasticsearchDomainResponse: + raise NotImplementedError + + @handler("CreateOutboundCrossClusterSearchConnection") + def create_outbound_cross_cluster_search_connection( + self, + context: RequestContext, + source_domain_info: DomainInformation, + destination_domain_info: DomainInformation, + connection_alias: ConnectionAlias, + **kwargs, + ) -> CreateOutboundCrossClusterSearchConnectionResponse: + raise NotImplementedError + + @handler("CreatePackage") + def create_package( + self, + context: RequestContext, + package_name: PackageName, + package_type: PackageType, + package_source: PackageSource, + package_description: PackageDescription | None = None, + **kwargs, + ) -> CreatePackageResponse: + raise NotImplementedError + + @handler("CreateVpcEndpoint") + def create_vpc_endpoint( + self, + context: RequestContext, + domain_arn: DomainArn, + vpc_options: VPCOptions, + client_token: ClientToken | None = None, + **kwargs, + ) -> CreateVpcEndpointResponse: + raise NotImplementedError + + @handler("DeleteElasticsearchDomain") + def delete_elasticsearch_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DeleteElasticsearchDomainResponse: + raise NotImplementedError + + @handler("DeleteElasticsearchServiceRole") + def delete_elasticsearch_service_role(self, context: RequestContext, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteInboundCrossClusterSearchConnection") + def delete_inbound_cross_cluster_search_connection( + self, + context: RequestContext, + cross_cluster_search_connection_id: CrossClusterSearchConnectionId, + **kwargs, + ) -> DeleteInboundCrossClusterSearchConnectionResponse: + raise NotImplementedError + + @handler("DeleteOutboundCrossClusterSearchConnection") + def delete_outbound_cross_cluster_search_connection( + self, + context: RequestContext, + cross_cluster_search_connection_id: CrossClusterSearchConnectionId, + **kwargs, + ) -> DeleteOutboundCrossClusterSearchConnectionResponse: + raise NotImplementedError + + @handler("DeletePackage") + def delete_package( + self, context: RequestContext, package_id: PackageID, **kwargs + ) -> DeletePackageResponse: + raise NotImplementedError + + @handler("DeleteVpcEndpoint") + def delete_vpc_endpoint( + self, context: RequestContext, vpc_endpoint_id: VpcEndpointId, **kwargs + ) -> DeleteVpcEndpointResponse: + raise NotImplementedError + + @handler("DescribeDomainAutoTunes") + def describe_domain_auto_tunes( + self, + context: RequestContext, + domain_name: DomainName, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeDomainAutoTunesResponse: + raise NotImplementedError + + @handler("DescribeDomainChangeProgress") + def describe_domain_change_progress( + self, + context: RequestContext, + domain_name: DomainName, + change_id: GUID | None = None, + **kwargs, + ) -> DescribeDomainChangeProgressResponse: + raise NotImplementedError + + @handler("DescribeElasticsearchDomain") + def describe_elasticsearch_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeElasticsearchDomainResponse: + raise NotImplementedError + + @handler("DescribeElasticsearchDomainConfig") + def describe_elasticsearch_domain_config( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeElasticsearchDomainConfigResponse: + raise NotImplementedError + + @handler("DescribeElasticsearchDomains") + def describe_elasticsearch_domains( + self, context: RequestContext, domain_names: DomainNameList, **kwargs + ) -> DescribeElasticsearchDomainsResponse: + raise NotImplementedError + + @handler("DescribeElasticsearchInstanceTypeLimits") + def describe_elasticsearch_instance_type_limits( + self, + context: RequestContext, + instance_type: ESPartitionInstanceType, + elasticsearch_version: ElasticsearchVersionString, + domain_name: DomainName | None = None, + **kwargs, + ) -> DescribeElasticsearchInstanceTypeLimitsResponse: + raise NotImplementedError + + @handler("DescribeInboundCrossClusterSearchConnections") + def describe_inbound_cross_cluster_search_connections( + self, + context: RequestContext, + filters: FilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeInboundCrossClusterSearchConnectionsResponse: + raise NotImplementedError + + @handler("DescribeOutboundCrossClusterSearchConnections") + def describe_outbound_cross_cluster_search_connections( + self, + context: RequestContext, + filters: FilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeOutboundCrossClusterSearchConnectionsResponse: + raise NotImplementedError + + @handler("DescribePackages") + def describe_packages( + self, + context: RequestContext, + filters: DescribePackagesFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribePackagesResponse: + raise NotImplementedError + + @handler("DescribeReservedElasticsearchInstanceOfferings") + def describe_reserved_elasticsearch_instance_offerings( + self, + context: RequestContext, + reserved_elasticsearch_instance_offering_id: GUID | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeReservedElasticsearchInstanceOfferingsResponse: + raise NotImplementedError + + @handler("DescribeReservedElasticsearchInstances") + def describe_reserved_elasticsearch_instances( + self, + context: RequestContext, + reserved_elasticsearch_instance_id: GUID | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeReservedElasticsearchInstancesResponse: + raise NotImplementedError + + @handler("DescribeVpcEndpoints") + def describe_vpc_endpoints( + self, context: RequestContext, vpc_endpoint_ids: VpcEndpointIdList, **kwargs + ) -> DescribeVpcEndpointsResponse: + raise NotImplementedError + + @handler("DissociatePackage") + def dissociate_package( + self, context: RequestContext, package_id: PackageID, domain_name: DomainName, **kwargs + ) -> DissociatePackageResponse: + raise NotImplementedError + + @handler("GetCompatibleElasticsearchVersions") + def get_compatible_elasticsearch_versions( + self, context: RequestContext, domain_name: DomainName | None = None, **kwargs + ) -> GetCompatibleElasticsearchVersionsResponse: + raise NotImplementedError + + @handler("GetPackageVersionHistory") + def get_package_version_history( + self, + context: RequestContext, + package_id: PackageID, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetPackageVersionHistoryResponse: + raise NotImplementedError + + @handler("GetUpgradeHistory") + def get_upgrade_history( + self, + context: RequestContext, + domain_name: DomainName, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetUpgradeHistoryResponse: + raise NotImplementedError + + @handler("GetUpgradeStatus") + def get_upgrade_status( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> GetUpgradeStatusResponse: + raise NotImplementedError + + @handler("ListDomainNames") + def list_domain_names( + self, context: RequestContext, engine_type: EngineType | None = None, **kwargs + ) -> ListDomainNamesResponse: + raise NotImplementedError + + @handler("ListDomainsForPackage") + def list_domains_for_package( + self, + context: RequestContext, + package_id: PackageID, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListDomainsForPackageResponse: + raise NotImplementedError + + @handler("ListElasticsearchInstanceTypes") + def list_elasticsearch_instance_types( + self, + context: RequestContext, + elasticsearch_version: ElasticsearchVersionString, + domain_name: DomainName | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListElasticsearchInstanceTypesResponse: + raise NotImplementedError + + @handler("ListElasticsearchVersions") + def list_elasticsearch_versions( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListElasticsearchVersionsResponse: + raise NotImplementedError + + @handler("ListPackagesForDomain") + def list_packages_for_domain( + self, + context: RequestContext, + domain_name: DomainName, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListPackagesForDomainResponse: + raise NotImplementedError + + @handler("ListTags") + def list_tags(self, context: RequestContext, arn: ARN, **kwargs) -> ListTagsResponse: + raise NotImplementedError + + @handler("ListVpcEndpointAccess") + def list_vpc_endpoint_access( + self, + context: RequestContext, + domain_name: DomainName, + next_token: NextToken | None = None, + **kwargs, + ) -> ListVpcEndpointAccessResponse: + raise NotImplementedError + + @handler("ListVpcEndpoints") + def list_vpc_endpoints( + self, context: RequestContext, next_token: NextToken | None = None, **kwargs + ) -> ListVpcEndpointsResponse: + raise NotImplementedError + + @handler("ListVpcEndpointsForDomain") + def list_vpc_endpoints_for_domain( + self, + context: RequestContext, + domain_name: DomainName, + next_token: NextToken | None = None, + **kwargs, + ) -> ListVpcEndpointsForDomainResponse: + raise NotImplementedError + + @handler("PurchaseReservedElasticsearchInstanceOffering") + def purchase_reserved_elasticsearch_instance_offering( + self, + context: RequestContext, + reserved_elasticsearch_instance_offering_id: GUID, + reservation_name: ReservationToken, + instance_count: InstanceCount | None = None, + **kwargs, + ) -> PurchaseReservedElasticsearchInstanceOfferingResponse: + raise NotImplementedError + + @handler("RejectInboundCrossClusterSearchConnection") + def reject_inbound_cross_cluster_search_connection( + self, + context: RequestContext, + cross_cluster_search_connection_id: CrossClusterSearchConnectionId, + **kwargs, + ) -> RejectInboundCrossClusterSearchConnectionResponse: + raise NotImplementedError + + @handler("RemoveTags") + def remove_tags( + self, context: RequestContext, arn: ARN, tag_keys: StringList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("RevokeVpcEndpointAccess") + def revoke_vpc_endpoint_access( + self, context: RequestContext, domain_name: DomainName, account: AWSAccount, **kwargs + ) -> RevokeVpcEndpointAccessResponse: + raise NotImplementedError + + @handler("StartElasticsearchServiceSoftwareUpdate") + def start_elasticsearch_service_software_update( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> StartElasticsearchServiceSoftwareUpdateResponse: + raise NotImplementedError + + @handler("UpdateElasticsearchDomainConfig") + def update_elasticsearch_domain_config( + self, + context: RequestContext, + domain_name: DomainName, + elasticsearch_cluster_config: ElasticsearchClusterConfig | None = None, + ebs_options: EBSOptions | None = None, + snapshot_options: SnapshotOptions | None = None, + vpc_options: VPCOptions | None = None, + cognito_options: CognitoOptions | None = None, + advanced_options: AdvancedOptions | None = None, + access_policies: PolicyDocument | None = None, + log_publishing_options: LogPublishingOptions | None = None, + domain_endpoint_options: DomainEndpointOptions | None = None, + advanced_security_options: AdvancedSecurityOptionsInput | None = None, + node_to_node_encryption_options: NodeToNodeEncryptionOptions | None = None, + encryption_at_rest_options: EncryptionAtRestOptions | None = None, + auto_tune_options: AutoTuneOptions | None = None, + dry_run: DryRun | None = None, + **kwargs, + ) -> UpdateElasticsearchDomainConfigResponse: + raise NotImplementedError + + @handler("UpdatePackage") + def update_package( + self, + context: RequestContext, + package_id: PackageID, + package_source: PackageSource, + package_description: PackageDescription | None = None, + commit_message: CommitMessage | None = None, + **kwargs, + ) -> UpdatePackageResponse: + raise NotImplementedError + + @handler("UpdateVpcEndpoint") + def update_vpc_endpoint( + self, + context: RequestContext, + vpc_endpoint_id: VpcEndpointId, + vpc_options: VPCOptions, + **kwargs, + ) -> UpdateVpcEndpointResponse: + raise NotImplementedError + + @handler("UpgradeElasticsearchDomain") + def upgrade_elasticsearch_domain( + self, + context: RequestContext, + domain_name: DomainName, + target_version: ElasticsearchVersionString, + perform_check_only: Boolean | None = None, + **kwargs, + ) -> UpgradeElasticsearchDomainResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/events/__init__.py b/localstack-core/localstack/aws/api/events/__init__.py new file mode 100644 index 0000000000000..3ad5d9dcaaaf1 --- /dev/null +++ b/localstack-core/localstack/aws/api/events/__init__.py @@ -0,0 +1,2112 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AccountId = str +Action = str +ApiDestinationArn = str +ApiDestinationDescription = str +ApiDestinationInvocationRateLimitPerSecond = int +ApiDestinationName = str +ArchiveArn = str +ArchiveDescription = str +ArchiveName = str +ArchiveStateReason = str +Arn = str +AuthHeaderParameters = str +AuthHeaderParametersSensitive = str +Boolean = bool +CapacityProvider = str +CapacityProviderStrategyItemBase = int +CapacityProviderStrategyItemWeight = int +ConnectionArn = str +ConnectionDescription = str +ConnectionName = str +ConnectionStateReason = str +CreatedBy = str +Database = str +DbUser = str +EndpointArn = str +EndpointDescription = str +EndpointId = str +EndpointName = str +EndpointStateReason = str +EndpointUrl = str +ErrorCode = str +ErrorMessage = str +EventBusArn = str +EventBusDescription = str +EventBusName = str +EventBusNameOrArn = str +EventId = str +EventPattern = str +EventResource = str +EventSourceName = str +EventSourceNamePrefix = str +GraphQLOperation = str +HeaderKey = str +HeaderValue = str +HeaderValueSensitive = str +HealthCheck = str +HomeRegion = str +HttpsEndpoint = str +IamRoleArn = str +InputTransformerPathKey = str +Integer = int +KmsKeyIdentifier = str +LimitMax100 = int +LimitMin1 = int +ManagedBy = str +MaximumEventAgeInSeconds = int +MaximumRetryAttempts = int +MessageGroupId = str +NextToken = str +NonPartnerEventBusArn = str +NonPartnerEventBusName = str +NonPartnerEventBusNameOrArn = str +PartnerEventSourceNamePrefix = str +PathParameter = str +PlacementConstraintExpression = str +PlacementStrategyField = str +Principal = str +QueryStringKey = str +QueryStringValue = str +QueryStringValueSensitive = str +RedshiftSecretManagerArn = str +ReferenceId = str +ReplayArn = str +ReplayDescription = str +ReplayName = str +ReplayStateReason = str +ResourceArn = str +ResourceAssociationArn = str +ResourceConfigurationArn = str +RetentionDays = int +RoleArn = str +Route = str +RuleArn = str +RuleDescription = str +RuleName = str +RunCommandTargetKey = str +RunCommandTargetValue = str +SageMakerPipelineParameterName = str +SageMakerPipelineParameterValue = str +ScheduleExpression = str +SecretsManagerSecretArn = str +SensitiveString = str +Sql = str +StatementId = str +StatementName = str +String = str +TagKey = str +TagValue = str +TargetArn = str +TargetId = str +TargetInput = str +TargetInputPath = str +TargetPartitionKeyPath = str +TraceHeader = str +TransformerInput = str + + +class ApiDestinationHttpMethod(StrEnum): + POST = "POST" + GET = "GET" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" + + +class ApiDestinationState(StrEnum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + + +class ArchiveState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + CREATING = "CREATING" + UPDATING = "UPDATING" + CREATE_FAILED = "CREATE_FAILED" + UPDATE_FAILED = "UPDATE_FAILED" + + +class AssignPublicIp(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class ConnectionAuthorizationType(StrEnum): + BASIC = "BASIC" + OAUTH_CLIENT_CREDENTIALS = "OAUTH_CLIENT_CREDENTIALS" + API_KEY = "API_KEY" + + +class ConnectionOAuthHttpMethod(StrEnum): + GET = "GET" + POST = "POST" + PUT = "PUT" + + +class ConnectionState(StrEnum): + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + AUTHORIZED = "AUTHORIZED" + DEAUTHORIZED = "DEAUTHORIZED" + AUTHORIZING = "AUTHORIZING" + DEAUTHORIZING = "DEAUTHORIZING" + ACTIVE = "ACTIVE" + FAILED_CONNECTIVITY = "FAILED_CONNECTIVITY" + + +class EndpointState(StrEnum): + ACTIVE = "ACTIVE" + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + CREATE_FAILED = "CREATE_FAILED" + UPDATE_FAILED = "UPDATE_FAILED" + DELETE_FAILED = "DELETE_FAILED" + + +class EventSourceState(StrEnum): + PENDING = "PENDING" + ACTIVE = "ACTIVE" + DELETED = "DELETED" + + +class LaunchType(StrEnum): + EC2 = "EC2" + FARGATE = "FARGATE" + EXTERNAL = "EXTERNAL" + + +class PlacementConstraintType(StrEnum): + distinctInstance = "distinctInstance" + memberOf = "memberOf" + + +class PlacementStrategyType(StrEnum): + random = "random" + spread = "spread" + binpack = "binpack" + + +class PropagateTags(StrEnum): + TASK_DEFINITION = "TASK_DEFINITION" + + +class ReplayState(StrEnum): + STARTING = "STARTING" + RUNNING = "RUNNING" + CANCELLING = "CANCELLING" + COMPLETED = "COMPLETED" + CANCELLED = "CANCELLED" + FAILED = "FAILED" + + +class ReplicationState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class RuleState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS = "ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class ConcurrentModificationException(ServiceException): + code: str = "ConcurrentModificationException" + sender_fault: bool = False + status_code: int = 400 + + +class IllegalStatusException(ServiceException): + code: str = "IllegalStatusException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalException(ServiceException): + code: str = "InternalException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidEventPatternException(ServiceException): + code: str = "InvalidEventPatternException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidStateException(ServiceException): + code: str = "InvalidStateException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ManagedRuleException(ServiceException): + code: str = "ManagedRuleException" + sender_fault: bool = False + status_code: int = 400 + + +class OperationDisabledException(ServiceException): + code: str = "OperationDisabledException" + sender_fault: bool = False + status_code: int = 400 + + +class PolicyLengthExceededException(ServiceException): + code: str = "PolicyLengthExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceAlreadyExistsException(ServiceException): + code: str = "ResourceAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class ActivateEventSourceRequest(ServiceRequest): + Name: EventSourceName + + +Timestamp = datetime + + +class ApiDestination(TypedDict, total=False): + ApiDestinationArn: Optional[ApiDestinationArn] + Name: Optional[ApiDestinationName] + ApiDestinationState: Optional[ApiDestinationState] + ConnectionArn: Optional[ConnectionArn] + InvocationEndpoint: Optional[HttpsEndpoint] + HttpMethod: Optional[ApiDestinationHttpMethod] + InvocationRateLimitPerSecond: Optional[ApiDestinationInvocationRateLimitPerSecond] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +ApiDestinationResponseList = List[ApiDestination] + + +class AppSyncParameters(TypedDict, total=False): + GraphQLOperation: Optional[GraphQLOperation] + + +Long = int + + +class Archive(TypedDict, total=False): + ArchiveName: Optional[ArchiveName] + EventSourceArn: Optional[EventBusArn] + State: Optional[ArchiveState] + StateReason: Optional[ArchiveStateReason] + RetentionDays: Optional[RetentionDays] + SizeBytes: Optional[Long] + EventCount: Optional[Long] + CreationTime: Optional[Timestamp] + + +ArchiveResponseList = List[Archive] +StringList = List[String] + + +class AwsVpcConfiguration(TypedDict, total=False): + Subnets: StringList + SecurityGroups: Optional[StringList] + AssignPublicIp: Optional[AssignPublicIp] + + +class BatchArrayProperties(TypedDict, total=False): + Size: Optional[Integer] + + +class BatchRetryStrategy(TypedDict, total=False): + Attempts: Optional[Integer] + + +class BatchParameters(TypedDict, total=False): + JobDefinition: String + JobName: String + ArrayProperties: Optional[BatchArrayProperties] + RetryStrategy: Optional[BatchRetryStrategy] + + +class CancelReplayRequest(ServiceRequest): + ReplayName: ReplayName + + +class CancelReplayResponse(TypedDict, total=False): + ReplayArn: Optional[ReplayArn] + State: Optional[ReplayState] + StateReason: Optional[ReplayStateReason] + + +class CapacityProviderStrategyItem(TypedDict, total=False): + capacityProvider: CapacityProvider + weight: Optional[CapacityProviderStrategyItemWeight] + base: Optional[CapacityProviderStrategyItemBase] + + +CapacityProviderStrategy = List[CapacityProviderStrategyItem] + + +class Condition(TypedDict, total=False): + Type: String + Key: String + Value: String + + +class Connection(TypedDict, total=False): + ConnectionArn: Optional[ConnectionArn] + Name: Optional[ConnectionName] + ConnectionState: Optional[ConnectionState] + StateReason: Optional[ConnectionStateReason] + AuthorizationType: Optional[ConnectionAuthorizationType] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + LastAuthorizedTime: Optional[Timestamp] + + +class ConnectionApiKeyAuthResponseParameters(TypedDict, total=False): + ApiKeyName: Optional[AuthHeaderParameters] + + +class DescribeConnectionResourceParameters(TypedDict, total=False): + ResourceConfigurationArn: ResourceConfigurationArn + ResourceAssociationArn: ResourceAssociationArn + + +class DescribeConnectionConnectivityParameters(TypedDict, total=False): + ResourceParameters: DescribeConnectionResourceParameters + + +class ConnectionBodyParameter(TypedDict, total=False): + Key: Optional[String] + Value: Optional[SensitiveString] + IsValueSecret: Optional[Boolean] + + +ConnectionBodyParametersList = List[ConnectionBodyParameter] + + +class ConnectionQueryStringParameter(TypedDict, total=False): + Key: Optional[QueryStringKey] + Value: Optional[QueryStringValueSensitive] + IsValueSecret: Optional[Boolean] + + +ConnectionQueryStringParametersList = List[ConnectionQueryStringParameter] + + +class ConnectionHeaderParameter(TypedDict, total=False): + Key: Optional[HeaderKey] + Value: Optional[HeaderValueSensitive] + IsValueSecret: Optional[Boolean] + + +ConnectionHeaderParametersList = List[ConnectionHeaderParameter] + + +class ConnectionHttpParameters(TypedDict, total=False): + HeaderParameters: Optional[ConnectionHeaderParametersList] + QueryStringParameters: Optional[ConnectionQueryStringParametersList] + BodyParameters: Optional[ConnectionBodyParametersList] + + +class ConnectionOAuthClientResponseParameters(TypedDict, total=False): + ClientID: Optional[AuthHeaderParameters] + + +class ConnectionOAuthResponseParameters(TypedDict, total=False): + ClientParameters: Optional[ConnectionOAuthClientResponseParameters] + AuthorizationEndpoint: Optional[HttpsEndpoint] + HttpMethod: Optional[ConnectionOAuthHttpMethod] + OAuthHttpParameters: Optional[ConnectionHttpParameters] + + +class ConnectionBasicAuthResponseParameters(TypedDict, total=False): + Username: Optional[AuthHeaderParameters] + + +class ConnectionAuthResponseParameters(TypedDict, total=False): + BasicAuthParameters: Optional[ConnectionBasicAuthResponseParameters] + OAuthParameters: Optional[ConnectionOAuthResponseParameters] + ApiKeyAuthParameters: Optional[ConnectionApiKeyAuthResponseParameters] + InvocationHttpParameters: Optional[ConnectionHttpParameters] + ConnectivityParameters: Optional[DescribeConnectionConnectivityParameters] + + +ConnectionResponseList = List[Connection] + + +class ConnectivityResourceConfigurationArn(TypedDict, total=False): + ResourceConfigurationArn: ResourceConfigurationArn + + +class ConnectivityResourceParameters(TypedDict, total=False): + ResourceParameters: ConnectivityResourceConfigurationArn + + +class CreateApiDestinationRequest(ServiceRequest): + Name: ApiDestinationName + Description: Optional[ApiDestinationDescription] + ConnectionArn: ConnectionArn + InvocationEndpoint: HttpsEndpoint + HttpMethod: ApiDestinationHttpMethod + InvocationRateLimitPerSecond: Optional[ApiDestinationInvocationRateLimitPerSecond] + + +class CreateApiDestinationResponse(TypedDict, total=False): + ApiDestinationArn: Optional[ApiDestinationArn] + ApiDestinationState: Optional[ApiDestinationState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class CreateArchiveRequest(ServiceRequest): + ArchiveName: ArchiveName + EventSourceArn: EventBusArn + Description: Optional[ArchiveDescription] + EventPattern: Optional[EventPattern] + RetentionDays: Optional[RetentionDays] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + + +class CreateArchiveResponse(TypedDict, total=False): + ArchiveArn: Optional[ArchiveArn] + State: Optional[ArchiveState] + StateReason: Optional[ArchiveStateReason] + CreationTime: Optional[Timestamp] + + +class CreateConnectionApiKeyAuthRequestParameters(TypedDict, total=False): + ApiKeyName: AuthHeaderParameters + ApiKeyValue: AuthHeaderParametersSensitive + + +class CreateConnectionOAuthClientRequestParameters(TypedDict, total=False): + ClientID: AuthHeaderParameters + ClientSecret: AuthHeaderParametersSensitive + + +class CreateConnectionOAuthRequestParameters(TypedDict, total=False): + ClientParameters: CreateConnectionOAuthClientRequestParameters + AuthorizationEndpoint: HttpsEndpoint + HttpMethod: ConnectionOAuthHttpMethod + OAuthHttpParameters: Optional[ConnectionHttpParameters] + + +class CreateConnectionBasicAuthRequestParameters(TypedDict, total=False): + Username: AuthHeaderParameters + Password: AuthHeaderParametersSensitive + + +class CreateConnectionAuthRequestParameters(TypedDict, total=False): + BasicAuthParameters: Optional[CreateConnectionBasicAuthRequestParameters] + OAuthParameters: Optional[CreateConnectionOAuthRequestParameters] + ApiKeyAuthParameters: Optional[CreateConnectionApiKeyAuthRequestParameters] + InvocationHttpParameters: Optional[ConnectionHttpParameters] + ConnectivityParameters: Optional[ConnectivityResourceParameters] + + +class CreateConnectionRequest(ServiceRequest): + Name: ConnectionName + Description: Optional[ConnectionDescription] + AuthorizationType: ConnectionAuthorizationType + AuthParameters: CreateConnectionAuthRequestParameters + InvocationConnectivityParameters: Optional[ConnectivityResourceParameters] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + + +class CreateConnectionResponse(TypedDict, total=False): + ConnectionArn: Optional[ConnectionArn] + ConnectionState: Optional[ConnectionState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class EndpointEventBus(TypedDict, total=False): + EventBusArn: NonPartnerEventBusArn + + +EndpointEventBusList = List[EndpointEventBus] + + +class ReplicationConfig(TypedDict, total=False): + State: Optional[ReplicationState] + + +class Secondary(TypedDict, total=False): + Route: Route + + +class Primary(TypedDict, total=False): + HealthCheck: HealthCheck + + +class FailoverConfig(TypedDict, total=False): + Primary: Primary + Secondary: Secondary + + +class RoutingConfig(TypedDict, total=False): + FailoverConfig: FailoverConfig + + +class CreateEndpointRequest(ServiceRequest): + Name: EndpointName + Description: Optional[EndpointDescription] + RoutingConfig: RoutingConfig + ReplicationConfig: Optional[ReplicationConfig] + EventBuses: EndpointEventBusList + RoleArn: Optional[IamRoleArn] + + +class CreateEndpointResponse(TypedDict, total=False): + Name: Optional[EndpointName] + Arn: Optional[EndpointArn] + RoutingConfig: Optional[RoutingConfig] + ReplicationConfig: Optional[ReplicationConfig] + EventBuses: Optional[EndpointEventBusList] + RoleArn: Optional[IamRoleArn] + State: Optional[EndpointState] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class DeadLetterConfig(TypedDict, total=False): + Arn: Optional[ResourceArn] + + +class CreateEventBusRequest(ServiceRequest): + Name: EventBusName + EventSourceName: Optional[EventSourceName] + Description: Optional[EventBusDescription] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + DeadLetterConfig: Optional[DeadLetterConfig] + Tags: Optional[TagList] + + +class CreateEventBusResponse(TypedDict, total=False): + EventBusArn: Optional[String] + Description: Optional[EventBusDescription] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + DeadLetterConfig: Optional[DeadLetterConfig] + + +class CreatePartnerEventSourceRequest(ServiceRequest): + Name: EventSourceName + Account: AccountId + + +class CreatePartnerEventSourceResponse(TypedDict, total=False): + EventSourceArn: Optional[String] + + +class DeactivateEventSourceRequest(ServiceRequest): + Name: EventSourceName + + +class DeauthorizeConnectionRequest(ServiceRequest): + Name: ConnectionName + + +class DeauthorizeConnectionResponse(TypedDict, total=False): + ConnectionArn: Optional[ConnectionArn] + ConnectionState: Optional[ConnectionState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + LastAuthorizedTime: Optional[Timestamp] + + +class DeleteApiDestinationRequest(ServiceRequest): + Name: ApiDestinationName + + +class DeleteApiDestinationResponse(TypedDict, total=False): + pass + + +class DeleteArchiveRequest(ServiceRequest): + ArchiveName: ArchiveName + + +class DeleteArchiveResponse(TypedDict, total=False): + pass + + +class DeleteConnectionRequest(ServiceRequest): + Name: ConnectionName + + +class DeleteConnectionResponse(TypedDict, total=False): + ConnectionArn: Optional[ConnectionArn] + ConnectionState: Optional[ConnectionState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + LastAuthorizedTime: Optional[Timestamp] + + +class DeleteEndpointRequest(ServiceRequest): + Name: EndpointName + + +class DeleteEndpointResponse(TypedDict, total=False): + pass + + +class DeleteEventBusRequest(ServiceRequest): + Name: EventBusName + + +class DeletePartnerEventSourceRequest(ServiceRequest): + Name: EventSourceName + Account: AccountId + + +class DeleteRuleRequest(ServiceRequest): + Name: RuleName + EventBusName: Optional[EventBusNameOrArn] + Force: Optional[Boolean] + + +class DescribeApiDestinationRequest(ServiceRequest): + Name: ApiDestinationName + + +class DescribeApiDestinationResponse(TypedDict, total=False): + ApiDestinationArn: Optional[ApiDestinationArn] + Name: Optional[ApiDestinationName] + Description: Optional[ApiDestinationDescription] + ApiDestinationState: Optional[ApiDestinationState] + ConnectionArn: Optional[ConnectionArn] + InvocationEndpoint: Optional[HttpsEndpoint] + HttpMethod: Optional[ApiDestinationHttpMethod] + InvocationRateLimitPerSecond: Optional[ApiDestinationInvocationRateLimitPerSecond] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class DescribeArchiveRequest(ServiceRequest): + ArchiveName: ArchiveName + + +class DescribeArchiveResponse(TypedDict, total=False): + ArchiveArn: Optional[ArchiveArn] + ArchiveName: Optional[ArchiveName] + EventSourceArn: Optional[EventBusArn] + Description: Optional[ArchiveDescription] + EventPattern: Optional[EventPattern] + State: Optional[ArchiveState] + StateReason: Optional[ArchiveStateReason] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + RetentionDays: Optional[RetentionDays] + SizeBytes: Optional[Long] + EventCount: Optional[Long] + CreationTime: Optional[Timestamp] + + +class DescribeConnectionRequest(ServiceRequest): + Name: ConnectionName + + +class DescribeConnectionResponse(TypedDict, total=False): + ConnectionArn: Optional[ConnectionArn] + Name: Optional[ConnectionName] + Description: Optional[ConnectionDescription] + InvocationConnectivityParameters: Optional[DescribeConnectionConnectivityParameters] + ConnectionState: Optional[ConnectionState] + StateReason: Optional[ConnectionStateReason] + AuthorizationType: Optional[ConnectionAuthorizationType] + SecretArn: Optional[SecretsManagerSecretArn] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + AuthParameters: Optional[ConnectionAuthResponseParameters] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + LastAuthorizedTime: Optional[Timestamp] + + +class DescribeEndpointRequest(ServiceRequest): + Name: EndpointName + HomeRegion: Optional[HomeRegion] + + +class DescribeEndpointResponse(TypedDict, total=False): + Name: Optional[EndpointName] + Description: Optional[EndpointDescription] + Arn: Optional[EndpointArn] + RoutingConfig: Optional[RoutingConfig] + ReplicationConfig: Optional[ReplicationConfig] + EventBuses: Optional[EndpointEventBusList] + RoleArn: Optional[IamRoleArn] + EndpointId: Optional[EndpointId] + EndpointUrl: Optional[EndpointUrl] + State: Optional[EndpointState] + StateReason: Optional[EndpointStateReason] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class DescribeEventBusRequest(ServiceRequest): + Name: Optional[EventBusNameOrArn] + + +class DescribeEventBusResponse(TypedDict, total=False): + Name: Optional[String] + Arn: Optional[String] + Description: Optional[EventBusDescription] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + DeadLetterConfig: Optional[DeadLetterConfig] + Policy: Optional[String] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class DescribeEventSourceRequest(ServiceRequest): + Name: EventSourceName + + +class DescribeEventSourceResponse(TypedDict, total=False): + Arn: Optional[String] + CreatedBy: Optional[String] + CreationTime: Optional[Timestamp] + ExpirationTime: Optional[Timestamp] + Name: Optional[String] + State: Optional[EventSourceState] + + +class DescribePartnerEventSourceRequest(ServiceRequest): + Name: EventSourceName + + +class DescribePartnerEventSourceResponse(TypedDict, total=False): + Arn: Optional[String] + Name: Optional[String] + + +class DescribeReplayRequest(ServiceRequest): + ReplayName: ReplayName + + +ReplayDestinationFilters = List[Arn] + + +class ReplayDestination(TypedDict, total=False): + Arn: Arn + FilterArns: Optional[ReplayDestinationFilters] + + +class DescribeReplayResponse(TypedDict, total=False): + ReplayName: Optional[ReplayName] + ReplayArn: Optional[ReplayArn] + Description: Optional[ReplayDescription] + State: Optional[ReplayState] + StateReason: Optional[ReplayStateReason] + EventSourceArn: Optional[ArchiveArn] + Destination: Optional[ReplayDestination] + EventStartTime: Optional[Timestamp] + EventEndTime: Optional[Timestamp] + EventLastReplayedTime: Optional[Timestamp] + ReplayStartTime: Optional[Timestamp] + ReplayEndTime: Optional[Timestamp] + + +class DescribeRuleRequest(ServiceRequest): + Name: RuleName + EventBusName: Optional[EventBusNameOrArn] + + +class DescribeRuleResponse(TypedDict, total=False): + Name: Optional[RuleName] + Arn: Optional[RuleArn] + EventPattern: Optional[EventPattern] + ScheduleExpression: Optional[ScheduleExpression] + State: Optional[RuleState] + Description: Optional[RuleDescription] + RoleArn: Optional[RoleArn] + ManagedBy: Optional[ManagedBy] + EventBusName: Optional[EventBusName] + CreatedBy: Optional[CreatedBy] + + +class DisableRuleRequest(ServiceRequest): + Name: RuleName + EventBusName: Optional[EventBusNameOrArn] + + +PlacementStrategy = TypedDict( + "PlacementStrategy", + { + "type": Optional[PlacementStrategyType], + "field": Optional[PlacementStrategyField], + }, + total=False, +) +PlacementStrategies = List[PlacementStrategy] +PlacementConstraint = TypedDict( + "PlacementConstraint", + { + "type": Optional[PlacementConstraintType], + "expression": Optional[PlacementConstraintExpression], + }, + total=False, +) +PlacementConstraints = List[PlacementConstraint] + + +class NetworkConfiguration(TypedDict, total=False): + awsvpcConfiguration: Optional[AwsVpcConfiguration] + + +class EcsParameters(TypedDict, total=False): + TaskDefinitionArn: Arn + TaskCount: Optional[LimitMin1] + LaunchType: Optional[LaunchType] + NetworkConfiguration: Optional[NetworkConfiguration] + PlatformVersion: Optional[String] + Group: Optional[String] + CapacityProviderStrategy: Optional[CapacityProviderStrategy] + EnableECSManagedTags: Optional[Boolean] + EnableExecuteCommand: Optional[Boolean] + PlacementConstraints: Optional[PlacementConstraints] + PlacementStrategy: Optional[PlacementStrategies] + PropagateTags: Optional[PropagateTags] + ReferenceId: Optional[ReferenceId] + Tags: Optional[TagList] + + +class EnableRuleRequest(ServiceRequest): + Name: RuleName + EventBusName: Optional[EventBusNameOrArn] + + +class Endpoint(TypedDict, total=False): + Name: Optional[EndpointName] + Description: Optional[EndpointDescription] + Arn: Optional[EndpointArn] + RoutingConfig: Optional[RoutingConfig] + ReplicationConfig: Optional[ReplicationConfig] + EventBuses: Optional[EndpointEventBusList] + RoleArn: Optional[IamRoleArn] + EndpointId: Optional[EndpointId] + EndpointUrl: Optional[EndpointUrl] + State: Optional[EndpointState] + StateReason: Optional[EndpointStateReason] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +EndpointList = List[Endpoint] + + +class EventBus(TypedDict, total=False): + Name: Optional[String] + Arn: Optional[String] + Description: Optional[EventBusDescription] + Policy: Optional[String] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +EventBusList = List[EventBus] +EventResourceList = List[EventResource] + + +class EventSource(TypedDict, total=False): + Arn: Optional[String] + CreatedBy: Optional[String] + CreationTime: Optional[Timestamp] + ExpirationTime: Optional[Timestamp] + Name: Optional[String] + State: Optional[EventSourceState] + + +EventSourceList = List[EventSource] +EventTime = datetime +HeaderParametersMap = Dict[HeaderKey, HeaderValue] +QueryStringParametersMap = Dict[QueryStringKey, QueryStringValue] +PathParameterList = List[PathParameter] + + +class HttpParameters(TypedDict, total=False): + PathParameterValues: Optional[PathParameterList] + HeaderParameters: Optional[HeaderParametersMap] + QueryStringParameters: Optional[QueryStringParametersMap] + + +TransformerPaths = Dict[InputTransformerPathKey, TargetInputPath] + + +class InputTransformer(TypedDict, total=False): + InputPathsMap: Optional[TransformerPaths] + InputTemplate: TransformerInput + + +class KinesisParameters(TypedDict, total=False): + PartitionKeyPath: TargetPartitionKeyPath + + +class ListApiDestinationsRequest(ServiceRequest): + NamePrefix: Optional[ApiDestinationName] + ConnectionArn: Optional[ConnectionArn] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class ListApiDestinationsResponse(TypedDict, total=False): + ApiDestinations: Optional[ApiDestinationResponseList] + NextToken: Optional[NextToken] + + +class ListArchivesRequest(ServiceRequest): + NamePrefix: Optional[ArchiveName] + EventSourceArn: Optional[EventBusArn] + State: Optional[ArchiveState] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class ListArchivesResponse(TypedDict, total=False): + Archives: Optional[ArchiveResponseList] + NextToken: Optional[NextToken] + + +class ListConnectionsRequest(ServiceRequest): + NamePrefix: Optional[ConnectionName] + ConnectionState: Optional[ConnectionState] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class ListConnectionsResponse(TypedDict, total=False): + Connections: Optional[ConnectionResponseList] + NextToken: Optional[NextToken] + + +class ListEndpointsRequest(ServiceRequest): + NamePrefix: Optional[EndpointName] + HomeRegion: Optional[HomeRegion] + NextToken: Optional[NextToken] + MaxResults: Optional[LimitMax100] + + +class ListEndpointsResponse(TypedDict, total=False): + Endpoints: Optional[EndpointList] + NextToken: Optional[NextToken] + + +class ListEventBusesRequest(ServiceRequest): + NamePrefix: Optional[EventBusName] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class ListEventBusesResponse(TypedDict, total=False): + EventBuses: Optional[EventBusList] + NextToken: Optional[NextToken] + + +class ListEventSourcesRequest(ServiceRequest): + NamePrefix: Optional[EventSourceNamePrefix] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class ListEventSourcesResponse(TypedDict, total=False): + EventSources: Optional[EventSourceList] + NextToken: Optional[NextToken] + + +class ListPartnerEventSourceAccountsRequest(ServiceRequest): + EventSourceName: EventSourceName + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class PartnerEventSourceAccount(TypedDict, total=False): + Account: Optional[AccountId] + CreationTime: Optional[Timestamp] + ExpirationTime: Optional[Timestamp] + State: Optional[EventSourceState] + + +PartnerEventSourceAccountList = List[PartnerEventSourceAccount] + + +class ListPartnerEventSourceAccountsResponse(TypedDict, total=False): + PartnerEventSourceAccounts: Optional[PartnerEventSourceAccountList] + NextToken: Optional[NextToken] + + +class ListPartnerEventSourcesRequest(ServiceRequest): + NamePrefix: PartnerEventSourceNamePrefix + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class PartnerEventSource(TypedDict, total=False): + Arn: Optional[String] + Name: Optional[String] + + +PartnerEventSourceList = List[PartnerEventSource] + + +class ListPartnerEventSourcesResponse(TypedDict, total=False): + PartnerEventSources: Optional[PartnerEventSourceList] + NextToken: Optional[NextToken] + + +class ListReplaysRequest(ServiceRequest): + NamePrefix: Optional[ReplayName] + State: Optional[ReplayState] + EventSourceArn: Optional[ArchiveArn] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class Replay(TypedDict, total=False): + ReplayName: Optional[ReplayName] + EventSourceArn: Optional[ArchiveArn] + State: Optional[ReplayState] + StateReason: Optional[ReplayStateReason] + EventStartTime: Optional[Timestamp] + EventEndTime: Optional[Timestamp] + EventLastReplayedTime: Optional[Timestamp] + ReplayStartTime: Optional[Timestamp] + ReplayEndTime: Optional[Timestamp] + + +ReplayList = List[Replay] + + +class ListReplaysResponse(TypedDict, total=False): + Replays: Optional[ReplayList] + NextToken: Optional[NextToken] + + +class ListRuleNamesByTargetRequest(ServiceRequest): + TargetArn: TargetArn + EventBusName: Optional[EventBusNameOrArn] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +RuleNameList = List[RuleName] + + +class ListRuleNamesByTargetResponse(TypedDict, total=False): + RuleNames: Optional[RuleNameList] + NextToken: Optional[NextToken] + + +class ListRulesRequest(ServiceRequest): + NamePrefix: Optional[RuleName] + EventBusName: Optional[EventBusNameOrArn] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class Rule(TypedDict, total=False): + Name: Optional[RuleName] + Arn: Optional[RuleArn] + EventPattern: Optional[EventPattern] + State: Optional[RuleState] + Description: Optional[RuleDescription] + ScheduleExpression: Optional[ScheduleExpression] + RoleArn: Optional[RoleArn] + ManagedBy: Optional[ManagedBy] + EventBusName: Optional[EventBusName] + + +RuleResponseList = List[Rule] + + +class ListRulesResponse(TypedDict, total=False): + Rules: Optional[RuleResponseList] + NextToken: Optional[NextToken] + + +class ListTagsForResourceRequest(ServiceRequest): + ResourceARN: Arn + + +class ListTagsForResourceResponse(TypedDict, total=False): + Tags: Optional[TagList] + + +class ListTargetsByRuleRequest(ServiceRequest): + Rule: RuleName + EventBusName: Optional[EventBusNameOrArn] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class RetryPolicy(TypedDict, total=False): + MaximumRetryAttempts: Optional[MaximumRetryAttempts] + MaximumEventAgeInSeconds: Optional[MaximumEventAgeInSeconds] + + +class SageMakerPipelineParameter(TypedDict, total=False): + Name: SageMakerPipelineParameterName + Value: SageMakerPipelineParameterValue + + +SageMakerPipelineParameterList = List[SageMakerPipelineParameter] + + +class SageMakerPipelineParameters(TypedDict, total=False): + PipelineParameterList: Optional[SageMakerPipelineParameterList] + + +Sqls = List[Sql] + + +class RedshiftDataParameters(TypedDict, total=False): + SecretManagerArn: Optional[RedshiftSecretManagerArn] + Database: Database + DbUser: Optional[DbUser] + Sql: Optional[Sql] + StatementName: Optional[StatementName] + WithEvent: Optional[Boolean] + Sqls: Optional[Sqls] + + +class SqsParameters(TypedDict, total=False): + MessageGroupId: Optional[MessageGroupId] + + +RunCommandTargetValues = List[RunCommandTargetValue] + + +class RunCommandTarget(TypedDict, total=False): + Key: RunCommandTargetKey + Values: RunCommandTargetValues + + +RunCommandTargets = List[RunCommandTarget] + + +class RunCommandParameters(TypedDict, total=False): + RunCommandTargets: RunCommandTargets + + +class Target(TypedDict, total=False): + Id: TargetId + Arn: TargetArn + RoleArn: Optional[RoleArn] + Input: Optional[TargetInput] + InputPath: Optional[TargetInputPath] + InputTransformer: Optional[InputTransformer] + KinesisParameters: Optional[KinesisParameters] + RunCommandParameters: Optional[RunCommandParameters] + EcsParameters: Optional[EcsParameters] + BatchParameters: Optional[BatchParameters] + SqsParameters: Optional[SqsParameters] + HttpParameters: Optional[HttpParameters] + RedshiftDataParameters: Optional[RedshiftDataParameters] + SageMakerPipelineParameters: Optional[SageMakerPipelineParameters] + DeadLetterConfig: Optional[DeadLetterConfig] + RetryPolicy: Optional[RetryPolicy] + AppSyncParameters: Optional[AppSyncParameters] + + +TargetList = List[Target] + + +class ListTargetsByRuleResponse(TypedDict, total=False): + Targets: Optional[TargetList] + NextToken: Optional[NextToken] + + +class PutEventsRequestEntry(TypedDict, total=False): + Time: Optional[EventTime] + Source: Optional[String] + Resources: Optional[EventResourceList] + DetailType: Optional[String] + Detail: Optional[String] + EventBusName: Optional[NonPartnerEventBusNameOrArn] + TraceHeader: Optional[TraceHeader] + + +PutEventsRequestEntryList = List[PutEventsRequestEntry] + + +class PutEventsRequest(ServiceRequest): + Entries: PutEventsRequestEntryList + EndpointId: Optional[EndpointId] + + +class PutEventsResultEntry(TypedDict, total=False): + EventId: Optional[EventId] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +PutEventsResultEntryList = List[PutEventsResultEntry] + + +class PutEventsResponse(TypedDict, total=False): + FailedEntryCount: Optional[Integer] + Entries: Optional[PutEventsResultEntryList] + + +class PutPartnerEventsRequestEntry(TypedDict, total=False): + Time: Optional[EventTime] + Source: Optional[EventSourceName] + Resources: Optional[EventResourceList] + DetailType: Optional[String] + Detail: Optional[String] + + +PutPartnerEventsRequestEntryList = List[PutPartnerEventsRequestEntry] + + +class PutPartnerEventsRequest(ServiceRequest): + Entries: PutPartnerEventsRequestEntryList + + +class PutPartnerEventsResultEntry(TypedDict, total=False): + EventId: Optional[EventId] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +PutPartnerEventsResultEntryList = List[PutPartnerEventsResultEntry] + + +class PutPartnerEventsResponse(TypedDict, total=False): + FailedEntryCount: Optional[Integer] + Entries: Optional[PutPartnerEventsResultEntryList] + + +class PutPermissionRequest(ServiceRequest): + EventBusName: Optional[NonPartnerEventBusName] + Action: Optional[Action] + Principal: Optional[Principal] + StatementId: Optional[StatementId] + Condition: Optional[Condition] + Policy: Optional[String] + + +class PutRuleRequest(ServiceRequest): + Name: RuleName + ScheduleExpression: Optional[ScheduleExpression] + EventPattern: Optional[EventPattern] + State: Optional[RuleState] + Description: Optional[RuleDescription] + RoleArn: Optional[RoleArn] + Tags: Optional[TagList] + EventBusName: Optional[EventBusNameOrArn] + + +class PutRuleResponse(TypedDict, total=False): + RuleArn: Optional[RuleArn] + + +class PutTargetsRequest(ServiceRequest): + Rule: RuleName + EventBusName: Optional[EventBusNameOrArn] + Targets: TargetList + + +class PutTargetsResultEntry(TypedDict, total=False): + TargetId: Optional[TargetId] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +PutTargetsResultEntryList = List[PutTargetsResultEntry] + + +class PutTargetsResponse(TypedDict, total=False): + FailedEntryCount: Optional[Integer] + FailedEntries: Optional[PutTargetsResultEntryList] + + +class RemovePermissionRequest(ServiceRequest): + StatementId: Optional[StatementId] + RemoveAllPermissions: Optional[Boolean] + EventBusName: Optional[NonPartnerEventBusName] + + +TargetIdList = List[TargetId] + + +class RemoveTargetsRequest(ServiceRequest): + Rule: RuleName + EventBusName: Optional[EventBusNameOrArn] + Ids: TargetIdList + Force: Optional[Boolean] + + +class RemoveTargetsResultEntry(TypedDict, total=False): + TargetId: Optional[TargetId] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +RemoveTargetsResultEntryList = List[RemoveTargetsResultEntry] + + +class RemoveTargetsResponse(TypedDict, total=False): + FailedEntryCount: Optional[Integer] + FailedEntries: Optional[RemoveTargetsResultEntryList] + + +class StartReplayRequest(ServiceRequest): + ReplayName: ReplayName + Description: Optional[ReplayDescription] + EventSourceArn: ArchiveArn + EventStartTime: Timestamp + EventEndTime: Timestamp + Destination: ReplayDestination + + +class StartReplayResponse(TypedDict, total=False): + ReplayArn: Optional[ReplayArn] + State: Optional[ReplayState] + StateReason: Optional[ReplayStateReason] + ReplayStartTime: Optional[Timestamp] + + +TagKeyList = List[TagKey] + + +class TagResourceRequest(ServiceRequest): + ResourceARN: Arn + Tags: TagList + + +class TagResourceResponse(TypedDict, total=False): + pass + + +class TestEventPatternRequest(ServiceRequest): + EventPattern: EventPattern + Event: String + + +class TestEventPatternResponse(TypedDict, total=False): + Result: Optional[Boolean] + + +class UntagResourceRequest(ServiceRequest): + ResourceARN: Arn + TagKeys: TagKeyList + + +class UntagResourceResponse(TypedDict, total=False): + pass + + +class UpdateApiDestinationRequest(ServiceRequest): + Name: ApiDestinationName + Description: Optional[ApiDestinationDescription] + ConnectionArn: Optional[ConnectionArn] + InvocationEndpoint: Optional[HttpsEndpoint] + HttpMethod: Optional[ApiDestinationHttpMethod] + InvocationRateLimitPerSecond: Optional[ApiDestinationInvocationRateLimitPerSecond] + + +class UpdateApiDestinationResponse(TypedDict, total=False): + ApiDestinationArn: Optional[ApiDestinationArn] + ApiDestinationState: Optional[ApiDestinationState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class UpdateArchiveRequest(ServiceRequest): + ArchiveName: ArchiveName + Description: Optional[ArchiveDescription] + EventPattern: Optional[EventPattern] + RetentionDays: Optional[RetentionDays] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + + +class UpdateArchiveResponse(TypedDict, total=False): + ArchiveArn: Optional[ArchiveArn] + State: Optional[ArchiveState] + StateReason: Optional[ArchiveStateReason] + CreationTime: Optional[Timestamp] + + +class UpdateConnectionApiKeyAuthRequestParameters(TypedDict, total=False): + ApiKeyName: Optional[AuthHeaderParameters] + ApiKeyValue: Optional[AuthHeaderParametersSensitive] + + +class UpdateConnectionOAuthClientRequestParameters(TypedDict, total=False): + ClientID: Optional[AuthHeaderParameters] + ClientSecret: Optional[AuthHeaderParametersSensitive] + + +class UpdateConnectionOAuthRequestParameters(TypedDict, total=False): + ClientParameters: Optional[UpdateConnectionOAuthClientRequestParameters] + AuthorizationEndpoint: Optional[HttpsEndpoint] + HttpMethod: Optional[ConnectionOAuthHttpMethod] + OAuthHttpParameters: Optional[ConnectionHttpParameters] + + +class UpdateConnectionBasicAuthRequestParameters(TypedDict, total=False): + Username: Optional[AuthHeaderParameters] + Password: Optional[AuthHeaderParametersSensitive] + + +class UpdateConnectionAuthRequestParameters(TypedDict, total=False): + BasicAuthParameters: Optional[UpdateConnectionBasicAuthRequestParameters] + OAuthParameters: Optional[UpdateConnectionOAuthRequestParameters] + ApiKeyAuthParameters: Optional[UpdateConnectionApiKeyAuthRequestParameters] + InvocationHttpParameters: Optional[ConnectionHttpParameters] + ConnectivityParameters: Optional[ConnectivityResourceParameters] + + +class UpdateConnectionRequest(ServiceRequest): + Name: ConnectionName + Description: Optional[ConnectionDescription] + AuthorizationType: Optional[ConnectionAuthorizationType] + AuthParameters: Optional[UpdateConnectionAuthRequestParameters] + InvocationConnectivityParameters: Optional[ConnectivityResourceParameters] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + + +class UpdateConnectionResponse(TypedDict, total=False): + ConnectionArn: Optional[ConnectionArn] + ConnectionState: Optional[ConnectionState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + LastAuthorizedTime: Optional[Timestamp] + + +class UpdateEndpointRequest(ServiceRequest): + Name: EndpointName + Description: Optional[EndpointDescription] + RoutingConfig: Optional[RoutingConfig] + ReplicationConfig: Optional[ReplicationConfig] + EventBuses: Optional[EndpointEventBusList] + RoleArn: Optional[IamRoleArn] + + +class UpdateEndpointResponse(TypedDict, total=False): + Name: Optional[EndpointName] + Arn: Optional[EndpointArn] + RoutingConfig: Optional[RoutingConfig] + ReplicationConfig: Optional[ReplicationConfig] + EventBuses: Optional[EndpointEventBusList] + RoleArn: Optional[IamRoleArn] + EndpointId: Optional[EndpointId] + EndpointUrl: Optional[EndpointUrl] + State: Optional[EndpointState] + + +class UpdateEventBusRequest(ServiceRequest): + Name: Optional[EventBusName] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + Description: Optional[EventBusDescription] + DeadLetterConfig: Optional[DeadLetterConfig] + + +class UpdateEventBusResponse(TypedDict, total=False): + Arn: Optional[String] + Name: Optional[EventBusName] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + Description: Optional[EventBusDescription] + DeadLetterConfig: Optional[DeadLetterConfig] + + +class EventsApi: + service = "events" + version = "2015-10-07" + + @handler("ActivateEventSource") + def activate_event_source( + self, context: RequestContext, name: EventSourceName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("CancelReplay") + def cancel_replay( + self, context: RequestContext, replay_name: ReplayName, **kwargs + ) -> CancelReplayResponse: + raise NotImplementedError + + @handler("CreateApiDestination") + def create_api_destination( + self, + context: RequestContext, + name: ApiDestinationName, + connection_arn: ConnectionArn, + invocation_endpoint: HttpsEndpoint, + http_method: ApiDestinationHttpMethod, + description: ApiDestinationDescription | None = None, + invocation_rate_limit_per_second: ApiDestinationInvocationRateLimitPerSecond | None = None, + **kwargs, + ) -> CreateApiDestinationResponse: + raise NotImplementedError + + @handler("CreateArchive") + def create_archive( + self, + context: RequestContext, + archive_name: ArchiveName, + event_source_arn: EventBusArn, + description: ArchiveDescription | None = None, + event_pattern: EventPattern | None = None, + retention_days: RetentionDays | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + **kwargs, + ) -> CreateArchiveResponse: + raise NotImplementedError + + @handler("CreateConnection") + def create_connection( + self, + context: RequestContext, + name: ConnectionName, + authorization_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters, + description: ConnectionDescription | None = None, + invocation_connectivity_parameters: ConnectivityResourceParameters | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + **kwargs, + ) -> CreateConnectionResponse: + raise NotImplementedError + + @handler("CreateEndpoint") + def create_endpoint( + self, + context: RequestContext, + name: EndpointName, + routing_config: RoutingConfig, + event_buses: EndpointEventBusList, + description: EndpointDescription | None = None, + replication_config: ReplicationConfig | None = None, + role_arn: IamRoleArn | None = None, + **kwargs, + ) -> CreateEndpointResponse: + raise NotImplementedError + + @handler("CreateEventBus") + def create_event_bus( + self, + context: RequestContext, + name: EventBusName, + event_source_name: EventSourceName | None = None, + description: EventBusDescription | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + dead_letter_config: DeadLetterConfig | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateEventBusResponse: + raise NotImplementedError + + @handler("CreatePartnerEventSource") + def create_partner_event_source( + self, context: RequestContext, name: EventSourceName, account: AccountId, **kwargs + ) -> CreatePartnerEventSourceResponse: + raise NotImplementedError + + @handler("DeactivateEventSource") + def deactivate_event_source( + self, context: RequestContext, name: EventSourceName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeauthorizeConnection") + def deauthorize_connection( + self, context: RequestContext, name: ConnectionName, **kwargs + ) -> DeauthorizeConnectionResponse: + raise NotImplementedError + + @handler("DeleteApiDestination") + def delete_api_destination( + self, context: RequestContext, name: ApiDestinationName, **kwargs + ) -> DeleteApiDestinationResponse: + raise NotImplementedError + + @handler("DeleteArchive") + def delete_archive( + self, context: RequestContext, archive_name: ArchiveName, **kwargs + ) -> DeleteArchiveResponse: + raise NotImplementedError + + @handler("DeleteConnection") + def delete_connection( + self, context: RequestContext, name: ConnectionName, **kwargs + ) -> DeleteConnectionResponse: + raise NotImplementedError + + @handler("DeleteEndpoint") + def delete_endpoint( + self, context: RequestContext, name: EndpointName, **kwargs + ) -> DeleteEndpointResponse: + raise NotImplementedError + + @handler("DeleteEventBus") + def delete_event_bus(self, context: RequestContext, name: EventBusName, **kwargs) -> None: + raise NotImplementedError + + @handler("DeletePartnerEventSource") + def delete_partner_event_source( + self, context: RequestContext, name: EventSourceName, account: AccountId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteRule") + def delete_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn | None = None, + force: Boolean | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DescribeApiDestination") + def describe_api_destination( + self, context: RequestContext, name: ApiDestinationName, **kwargs + ) -> DescribeApiDestinationResponse: + raise NotImplementedError + + @handler("DescribeArchive") + def describe_archive( + self, context: RequestContext, archive_name: ArchiveName, **kwargs + ) -> DescribeArchiveResponse: + raise NotImplementedError + + @handler("DescribeConnection") + def describe_connection( + self, context: RequestContext, name: ConnectionName, **kwargs + ) -> DescribeConnectionResponse: + raise NotImplementedError + + @handler("DescribeEndpoint") + def describe_endpoint( + self, + context: RequestContext, + name: EndpointName, + home_region: HomeRegion | None = None, + **kwargs, + ) -> DescribeEndpointResponse: + raise NotImplementedError + + @handler("DescribeEventBus") + def describe_event_bus( + self, context: RequestContext, name: EventBusNameOrArn | None = None, **kwargs + ) -> DescribeEventBusResponse: + raise NotImplementedError + + @handler("DescribeEventSource") + def describe_event_source( + self, context: RequestContext, name: EventSourceName, **kwargs + ) -> DescribeEventSourceResponse: + raise NotImplementedError + + @handler("DescribePartnerEventSource") + def describe_partner_event_source( + self, context: RequestContext, name: EventSourceName, **kwargs + ) -> DescribePartnerEventSourceResponse: + raise NotImplementedError + + @handler("DescribeReplay") + def describe_replay( + self, context: RequestContext, replay_name: ReplayName, **kwargs + ) -> DescribeReplayResponse: + raise NotImplementedError + + @handler("DescribeRule") + def describe_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn | None = None, + **kwargs, + ) -> DescribeRuleResponse: + raise NotImplementedError + + @handler("DisableRule") + def disable_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("EnableRule") + def enable_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ListApiDestinations") + def list_api_destinations( + self, + context: RequestContext, + name_prefix: ApiDestinationName | None = None, + connection_arn: ConnectionArn | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListApiDestinationsResponse: + raise NotImplementedError + + @handler("ListArchives") + def list_archives( + self, + context: RequestContext, + name_prefix: ArchiveName | None = None, + event_source_arn: EventBusArn | None = None, + state: ArchiveState | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListArchivesResponse: + raise NotImplementedError + + @handler("ListConnections") + def list_connections( + self, + context: RequestContext, + name_prefix: ConnectionName | None = None, + connection_state: ConnectionState | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListConnectionsResponse: + raise NotImplementedError + + @handler("ListEndpoints") + def list_endpoints( + self, + context: RequestContext, + name_prefix: EndpointName | None = None, + home_region: HomeRegion | None = None, + next_token: NextToken | None = None, + max_results: LimitMax100 | None = None, + **kwargs, + ) -> ListEndpointsResponse: + raise NotImplementedError + + @handler("ListEventBuses") + def list_event_buses( + self, + context: RequestContext, + name_prefix: EventBusName | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListEventBusesResponse: + raise NotImplementedError + + @handler("ListEventSources") + def list_event_sources( + self, + context: RequestContext, + name_prefix: EventSourceNamePrefix | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListEventSourcesResponse: + raise NotImplementedError + + @handler("ListPartnerEventSourceAccounts") + def list_partner_event_source_accounts( + self, + context: RequestContext, + event_source_name: EventSourceName, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListPartnerEventSourceAccountsResponse: + raise NotImplementedError + + @handler("ListPartnerEventSources") + def list_partner_event_sources( + self, + context: RequestContext, + name_prefix: PartnerEventSourceNamePrefix, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListPartnerEventSourcesResponse: + raise NotImplementedError + + @handler("ListReplays") + def list_replays( + self, + context: RequestContext, + name_prefix: ReplayName | None = None, + state: ReplayState | None = None, + event_source_arn: ArchiveArn | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListReplaysResponse: + raise NotImplementedError + + @handler("ListRuleNamesByTarget") + def list_rule_names_by_target( + self, + context: RequestContext, + target_arn: TargetArn, + event_bus_name: EventBusNameOrArn | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListRuleNamesByTargetResponse: + raise NotImplementedError + + @handler("ListRules") + def list_rules( + self, + context: RequestContext, + name_prefix: RuleName | None = None, + event_bus_name: EventBusNameOrArn | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListRulesResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: Arn, **kwargs + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("ListTargetsByRule") + def list_targets_by_rule( + self, + context: RequestContext, + rule: RuleName, + event_bus_name: EventBusNameOrArn | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListTargetsByRuleResponse: + raise NotImplementedError + + @handler("PutEvents") + def put_events( + self, + context: RequestContext, + entries: PutEventsRequestEntryList, + endpoint_id: EndpointId | None = None, + **kwargs, + ) -> PutEventsResponse: + raise NotImplementedError + + @handler("PutPartnerEvents") + def put_partner_events( + self, context: RequestContext, entries: PutPartnerEventsRequestEntryList, **kwargs + ) -> PutPartnerEventsResponse: + raise NotImplementedError + + @handler("PutPermission") + def put_permission( + self, + context: RequestContext, + event_bus_name: NonPartnerEventBusName | None = None, + action: Action | None = None, + principal: Principal | None = None, + statement_id: StatementId | None = None, + condition: Condition | None = None, + policy: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutRule") + def put_rule( + self, + context: RequestContext, + name: RuleName, + schedule_expression: ScheduleExpression | None = None, + event_pattern: EventPattern | None = None, + state: RuleState | None = None, + description: RuleDescription | None = None, + role_arn: RoleArn | None = None, + tags: TagList | None = None, + event_bus_name: EventBusNameOrArn | None = None, + **kwargs, + ) -> PutRuleResponse: + raise NotImplementedError + + @handler("PutTargets") + def put_targets( + self, + context: RequestContext, + rule: RuleName, + targets: TargetList, + event_bus_name: EventBusNameOrArn | None = None, + **kwargs, + ) -> PutTargetsResponse: + raise NotImplementedError + + @handler("RemovePermission") + def remove_permission( + self, + context: RequestContext, + statement_id: StatementId | None = None, + remove_all_permissions: Boolean | None = None, + event_bus_name: NonPartnerEventBusName | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RemoveTargets") + def remove_targets( + self, + context: RequestContext, + rule: RuleName, + ids: TargetIdList, + event_bus_name: EventBusNameOrArn | None = None, + force: Boolean | None = None, + **kwargs, + ) -> RemoveTargetsResponse: + raise NotImplementedError + + @handler("StartReplay") + def start_replay( + self, + context: RequestContext, + replay_name: ReplayName, + event_source_arn: ArchiveArn, + event_start_time: Timestamp, + event_end_time: Timestamp, + destination: ReplayDestination, + description: ReplayDescription | None = None, + **kwargs, + ) -> StartReplayResponse: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: Arn, tags: TagList, **kwargs + ) -> TagResourceResponse: + raise NotImplementedError + + @handler("TestEventPattern") + def test_event_pattern( + self, context: RequestContext, event_pattern: EventPattern, event: String, **kwargs + ) -> TestEventPatternResponse: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: Arn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceResponse: + raise NotImplementedError + + @handler("UpdateApiDestination") + def update_api_destination( + self, + context: RequestContext, + name: ApiDestinationName, + description: ApiDestinationDescription | None = None, + connection_arn: ConnectionArn | None = None, + invocation_endpoint: HttpsEndpoint | None = None, + http_method: ApiDestinationHttpMethod | None = None, + invocation_rate_limit_per_second: ApiDestinationInvocationRateLimitPerSecond | None = None, + **kwargs, + ) -> UpdateApiDestinationResponse: + raise NotImplementedError + + @handler("UpdateArchive") + def update_archive( + self, + context: RequestContext, + archive_name: ArchiveName, + description: ArchiveDescription | None = None, + event_pattern: EventPattern | None = None, + retention_days: RetentionDays | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + **kwargs, + ) -> UpdateArchiveResponse: + raise NotImplementedError + + @handler("UpdateConnection") + def update_connection( + self, + context: RequestContext, + name: ConnectionName, + description: ConnectionDescription | None = None, + authorization_type: ConnectionAuthorizationType | None = None, + auth_parameters: UpdateConnectionAuthRequestParameters | None = None, + invocation_connectivity_parameters: ConnectivityResourceParameters | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + **kwargs, + ) -> UpdateConnectionResponse: + raise NotImplementedError + + @handler("UpdateEndpoint") + def update_endpoint( + self, + context: RequestContext, + name: EndpointName, + description: EndpointDescription | None = None, + routing_config: RoutingConfig | None = None, + replication_config: ReplicationConfig | None = None, + event_buses: EndpointEventBusList | None = None, + role_arn: IamRoleArn | None = None, + **kwargs, + ) -> UpdateEndpointResponse: + raise NotImplementedError + + @handler("UpdateEventBus") + def update_event_bus( + self, + context: RequestContext, + name: EventBusName | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + description: EventBusDescription | None = None, + dead_letter_config: DeadLetterConfig | None = None, + **kwargs, + ) -> UpdateEventBusResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/firehose/__init__.py b/localstack-core/localstack/aws/api/firehose/__init__.py new file mode 100644 index 0000000000000..f1b3c79ac204d --- /dev/null +++ b/localstack-core/localstack/aws/api/firehose/__init__.py @@ -0,0 +1,1636 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AWSKMSKeyARN = str +AmazonOpenSearchServerlessBufferingIntervalInSeconds = int +AmazonOpenSearchServerlessBufferingSizeInMBs = int +AmazonOpenSearchServerlessCollectionEndpoint = str +AmazonOpenSearchServerlessIndexName = str +AmazonOpenSearchServerlessRetryDurationInSeconds = int +AmazonopensearchserviceBufferingIntervalInSeconds = int +AmazonopensearchserviceBufferingSizeInMBs = int +AmazonopensearchserviceClusterEndpoint = str +AmazonopensearchserviceDomainARN = str +AmazonopensearchserviceIndexName = str +AmazonopensearchserviceRetryDurationInSeconds = int +AmazonopensearchserviceTypeName = str +BlockSizeBytes = int +BooleanObject = bool +BucketARN = str +ClusterJDBCURL = str +CopyOptions = str +CustomTimeZone = str +DataTableColumns = str +DataTableName = str +DatabaseColumnName = str +DatabaseEndpoint = str +DatabaseName = str +DatabasePort = int +DatabaseTableName = str +DeliveryStreamARN = str +DeliveryStreamName = str +DeliveryStreamVersionId = str +DescribeDeliveryStreamInputLimit = int +DestinationId = str +ElasticsearchBufferingIntervalInSeconds = int +ElasticsearchBufferingSizeInMBs = int +ElasticsearchClusterEndpoint = str +ElasticsearchDomainARN = str +ElasticsearchIndexName = str +ElasticsearchRetryDurationInSeconds = int +ElasticsearchTypeName = str +ErrorCode = str +ErrorMessage = str +ErrorOutputPrefix = str +FileExtension = str +GlueDataCatalogARN = str +HECAcknowledgmentTimeoutInSeconds = int +HECEndpoint = str +HECToken = str +HttpEndpointAccessKey = str +HttpEndpointAttributeName = str +HttpEndpointAttributeValue = str +HttpEndpointBufferingIntervalInSeconds = int +HttpEndpointBufferingSizeInMBs = int +HttpEndpointName = str +HttpEndpointRetryDurationInSeconds = int +HttpEndpointUrl = str +IntervalInSeconds = int +KinesisStreamARN = str +ListDeliveryStreamsInputLimit = int +ListTagsForDeliveryStreamInputLimit = int +LogGroupName = str +LogStreamName = str +MSKClusterARN = str +NonEmptyString = str +NonEmptyStringWithoutWhitespace = str +NonNegativeIntegerObject = int +OrcRowIndexStride = int +OrcStripeSizeBytes = int +ParquetPageSizeBytes = int +Password = str +Prefix = str +ProcessorParameterValue = str +Proportion = float +PutResponseRecordId = str +RedshiftRetryDurationInSeconds = int +RetryDurationInSeconds = int +RoleARN = str +SecretARN = str +SizeInMBs = int +SnowflakeAccountUrl = str +SnowflakeBufferingIntervalInSeconds = int +SnowflakeBufferingSizeInMBs = int +SnowflakeContentColumnName = str +SnowflakeDatabase = str +SnowflakeKeyPassphrase = str +SnowflakeMetaDataColumnName = str +SnowflakePrivateKey = str +SnowflakePrivateLinkVpceId = str +SnowflakeRetryDurationInSeconds = int +SnowflakeRole = str +SnowflakeSchema = str +SnowflakeTable = str +SnowflakeUser = str +SplunkBufferingIntervalInSeconds = int +SplunkBufferingSizeInMBs = int +SplunkRetryDurationInSeconds = int +StringWithLettersDigitsUnderscoresDots = str +TagKey = str +TagValue = str +ThroughputHintInMBs = int +TopicName = str +Username = str +VpcEndpointServiceName = str +WarehouseLocation = str + + +class AmazonOpenSearchServerlessS3BackupMode(StrEnum): + FailedDocumentsOnly = "FailedDocumentsOnly" + AllDocuments = "AllDocuments" + + +class AmazonopensearchserviceIndexRotationPeriod(StrEnum): + NoRotation = "NoRotation" + OneHour = "OneHour" + OneDay = "OneDay" + OneWeek = "OneWeek" + OneMonth = "OneMonth" + + +class AmazonopensearchserviceS3BackupMode(StrEnum): + FailedDocumentsOnly = "FailedDocumentsOnly" + AllDocuments = "AllDocuments" + + +class CompressionFormat(StrEnum): + UNCOMPRESSED = "UNCOMPRESSED" + GZIP = "GZIP" + ZIP = "ZIP" + Snappy = "Snappy" + HADOOP_SNAPPY = "HADOOP_SNAPPY" + + +class Connectivity(StrEnum): + PUBLIC = "PUBLIC" + PRIVATE = "PRIVATE" + + +class ContentEncoding(StrEnum): + NONE = "NONE" + GZIP = "GZIP" + + +class DatabaseType(StrEnum): + MySQL = "MySQL" + PostgreSQL = "PostgreSQL" + + +class DefaultDocumentIdFormat(StrEnum): + FIREHOSE_DEFAULT = "FIREHOSE_DEFAULT" + NO_DOCUMENT_ID = "NO_DOCUMENT_ID" + + +class DeliveryStreamEncryptionStatus(StrEnum): + ENABLED = "ENABLED" + ENABLING = "ENABLING" + ENABLING_FAILED = "ENABLING_FAILED" + DISABLED = "DISABLED" + DISABLING = "DISABLING" + DISABLING_FAILED = "DISABLING_FAILED" + + +class DeliveryStreamFailureType(StrEnum): + VPC_ENDPOINT_SERVICE_NAME_NOT_FOUND = "VPC_ENDPOINT_SERVICE_NAME_NOT_FOUND" + VPC_INTERFACE_ENDPOINT_SERVICE_ACCESS_DENIED = "VPC_INTERFACE_ENDPOINT_SERVICE_ACCESS_DENIED" + RETIRE_KMS_GRANT_FAILED = "RETIRE_KMS_GRANT_FAILED" + CREATE_KMS_GRANT_FAILED = "CREATE_KMS_GRANT_FAILED" + KMS_ACCESS_DENIED = "KMS_ACCESS_DENIED" + DISABLED_KMS_KEY = "DISABLED_KMS_KEY" + INVALID_KMS_KEY = "INVALID_KMS_KEY" + KMS_KEY_NOT_FOUND = "KMS_KEY_NOT_FOUND" + KMS_OPT_IN_REQUIRED = "KMS_OPT_IN_REQUIRED" + CREATE_ENI_FAILED = "CREATE_ENI_FAILED" + DELETE_ENI_FAILED = "DELETE_ENI_FAILED" + SUBNET_NOT_FOUND = "SUBNET_NOT_FOUND" + SECURITY_GROUP_NOT_FOUND = "SECURITY_GROUP_NOT_FOUND" + ENI_ACCESS_DENIED = "ENI_ACCESS_DENIED" + SUBNET_ACCESS_DENIED = "SUBNET_ACCESS_DENIED" + SECURITY_GROUP_ACCESS_DENIED = "SECURITY_GROUP_ACCESS_DENIED" + UNKNOWN_ERROR = "UNKNOWN_ERROR" + + +class DeliveryStreamStatus(StrEnum): + CREATING = "CREATING" + CREATING_FAILED = "CREATING_FAILED" + DELETING = "DELETING" + DELETING_FAILED = "DELETING_FAILED" + ACTIVE = "ACTIVE" + + +class DeliveryStreamType(StrEnum): + DirectPut = "DirectPut" + KinesisStreamAsSource = "KinesisStreamAsSource" + MSKAsSource = "MSKAsSource" + DatabaseAsSource = "DatabaseAsSource" + + +class ElasticsearchIndexRotationPeriod(StrEnum): + NoRotation = "NoRotation" + OneHour = "OneHour" + OneDay = "OneDay" + OneWeek = "OneWeek" + OneMonth = "OneMonth" + + +class ElasticsearchS3BackupMode(StrEnum): + FailedDocumentsOnly = "FailedDocumentsOnly" + AllDocuments = "AllDocuments" + + +class HECEndpointType(StrEnum): + Raw = "Raw" + Event = "Event" + + +class HttpEndpointS3BackupMode(StrEnum): + FailedDataOnly = "FailedDataOnly" + AllData = "AllData" + + +class IcebergS3BackupMode(StrEnum): + FailedDataOnly = "FailedDataOnly" + AllData = "AllData" + + +class KeyType(StrEnum): + AWS_OWNED_CMK = "AWS_OWNED_CMK" + CUSTOMER_MANAGED_CMK = "CUSTOMER_MANAGED_CMK" + + +class NoEncryptionConfig(StrEnum): + NoEncryption = "NoEncryption" + + +class OrcCompression(StrEnum): + NONE = "NONE" + ZLIB = "ZLIB" + SNAPPY = "SNAPPY" + + +class OrcFormatVersion(StrEnum): + V0_11 = "V0_11" + V0_12 = "V0_12" + + +class ParquetCompression(StrEnum): + UNCOMPRESSED = "UNCOMPRESSED" + GZIP = "GZIP" + SNAPPY = "SNAPPY" + + +class ParquetWriterVersion(StrEnum): + V1 = "V1" + V2 = "V2" + + +class ProcessorParameterName(StrEnum): + LambdaArn = "LambdaArn" + NumberOfRetries = "NumberOfRetries" + MetadataExtractionQuery = "MetadataExtractionQuery" + JsonParsingEngine = "JsonParsingEngine" + RoleArn = "RoleArn" + BufferSizeInMBs = "BufferSizeInMBs" + BufferIntervalInSeconds = "BufferIntervalInSeconds" + SubRecordType = "SubRecordType" + Delimiter = "Delimiter" + CompressionFormat = "CompressionFormat" + DataMessageExtraction = "DataMessageExtraction" + + +class ProcessorType(StrEnum): + RecordDeAggregation = "RecordDeAggregation" + Decompression = "Decompression" + CloudWatchLogProcessing = "CloudWatchLogProcessing" + Lambda = "Lambda" + MetadataExtraction = "MetadataExtraction" + AppendDelimiterToRecord = "AppendDelimiterToRecord" + + +class RedshiftS3BackupMode(StrEnum): + Disabled = "Disabled" + Enabled = "Enabled" + + +class S3BackupMode(StrEnum): + Disabled = "Disabled" + Enabled = "Enabled" + + +class SSLMode(StrEnum): + Disabled = "Disabled" + Enabled = "Enabled" + + +class SnapshotRequestedBy(StrEnum): + USER = "USER" + FIREHOSE = "FIREHOSE" + + +class SnapshotStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + COMPLETE = "COMPLETE" + SUSPENDED = "SUSPENDED" + + +class SnowflakeDataLoadingOption(StrEnum): + JSON_MAPPING = "JSON_MAPPING" + VARIANT_CONTENT_MAPPING = "VARIANT_CONTENT_MAPPING" + VARIANT_CONTENT_AND_METADATA_MAPPING = "VARIANT_CONTENT_AND_METADATA_MAPPING" + + +class SnowflakeS3BackupMode(StrEnum): + FailedDataOnly = "FailedDataOnly" + AllData = "AllData" + + +class SplunkS3BackupMode(StrEnum): + FailedEventsOnly = "FailedEventsOnly" + AllEvents = "AllEvents" + + +class ConcurrentModificationException(ServiceException): + code: str = "ConcurrentModificationException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidArgumentException(ServiceException): + code: str = "InvalidArgumentException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidKMSResourceException(ServiceException): + code: str = "InvalidKMSResourceException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidSourceException(ServiceException): + code: str = "InvalidSourceException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ServiceUnavailableException(ServiceException): + code: str = "ServiceUnavailableException" + sender_fault: bool = False + status_code: int = 400 + + +class AmazonOpenSearchServerlessBufferingHints(TypedDict, total=False): + IntervalInSeconds: Optional[AmazonOpenSearchServerlessBufferingIntervalInSeconds] + SizeInMBs: Optional[AmazonOpenSearchServerlessBufferingSizeInMBs] + + +SecurityGroupIdList = List[NonEmptyStringWithoutWhitespace] +SubnetIdList = List[NonEmptyStringWithoutWhitespace] + + +class VpcConfiguration(TypedDict, total=False): + SubnetIds: SubnetIdList + RoleARN: RoleARN + SecurityGroupIds: SecurityGroupIdList + + +class CloudWatchLoggingOptions(TypedDict, total=False): + Enabled: Optional[BooleanObject] + LogGroupName: Optional[LogGroupName] + LogStreamName: Optional[LogStreamName] + + +class ProcessorParameter(TypedDict, total=False): + ParameterName: ProcessorParameterName + ParameterValue: ProcessorParameterValue + + +ProcessorParameterList = List[ProcessorParameter] + + +class Processor(TypedDict, total=False): + Type: ProcessorType + Parameters: Optional[ProcessorParameterList] + + +ProcessorList = List[Processor] + + +class ProcessingConfiguration(TypedDict, total=False): + Enabled: Optional[BooleanObject] + Processors: Optional[ProcessorList] + + +class KMSEncryptionConfig(TypedDict, total=False): + AWSKMSKeyARN: AWSKMSKeyARN + + +class EncryptionConfiguration(TypedDict, total=False): + NoEncryptionConfig: Optional[NoEncryptionConfig] + KMSEncryptionConfig: Optional[KMSEncryptionConfig] + + +class BufferingHints(TypedDict, total=False): + SizeInMBs: Optional[SizeInMBs] + IntervalInSeconds: Optional[IntervalInSeconds] + + +class S3DestinationConfiguration(TypedDict, total=False): + RoleARN: RoleARN + BucketARN: BucketARN + Prefix: Optional[Prefix] + ErrorOutputPrefix: Optional[ErrorOutputPrefix] + BufferingHints: Optional[BufferingHints] + CompressionFormat: Optional[CompressionFormat] + EncryptionConfiguration: Optional[EncryptionConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + + +class AmazonOpenSearchServerlessRetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[AmazonOpenSearchServerlessRetryDurationInSeconds] + + +class AmazonOpenSearchServerlessDestinationConfiguration(TypedDict, total=False): + RoleARN: RoleARN + CollectionEndpoint: Optional[AmazonOpenSearchServerlessCollectionEndpoint] + IndexName: AmazonOpenSearchServerlessIndexName + BufferingHints: Optional[AmazonOpenSearchServerlessBufferingHints] + RetryOptions: Optional[AmazonOpenSearchServerlessRetryOptions] + S3BackupMode: Optional[AmazonOpenSearchServerlessS3BackupMode] + S3Configuration: S3DestinationConfiguration + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + VpcConfiguration: Optional[VpcConfiguration] + + +class VpcConfigurationDescription(TypedDict, total=False): + SubnetIds: SubnetIdList + RoleARN: RoleARN + SecurityGroupIds: SecurityGroupIdList + VpcId: NonEmptyStringWithoutWhitespace + + +class S3DestinationDescription(TypedDict, total=False): + RoleARN: RoleARN + BucketARN: BucketARN + Prefix: Optional[Prefix] + ErrorOutputPrefix: Optional[ErrorOutputPrefix] + BufferingHints: BufferingHints + CompressionFormat: CompressionFormat + EncryptionConfiguration: EncryptionConfiguration + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + + +class AmazonOpenSearchServerlessDestinationDescription(TypedDict, total=False): + RoleARN: Optional[RoleARN] + CollectionEndpoint: Optional[AmazonOpenSearchServerlessCollectionEndpoint] + IndexName: Optional[AmazonOpenSearchServerlessIndexName] + BufferingHints: Optional[AmazonOpenSearchServerlessBufferingHints] + RetryOptions: Optional[AmazonOpenSearchServerlessRetryOptions] + S3BackupMode: Optional[AmazonOpenSearchServerlessS3BackupMode] + S3DestinationDescription: Optional[S3DestinationDescription] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + VpcConfigurationDescription: Optional[VpcConfigurationDescription] + + +class S3DestinationUpdate(TypedDict, total=False): + RoleARN: Optional[RoleARN] + BucketARN: Optional[BucketARN] + Prefix: Optional[Prefix] + ErrorOutputPrefix: Optional[ErrorOutputPrefix] + BufferingHints: Optional[BufferingHints] + CompressionFormat: Optional[CompressionFormat] + EncryptionConfiguration: Optional[EncryptionConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + + +class AmazonOpenSearchServerlessDestinationUpdate(TypedDict, total=False): + RoleARN: Optional[RoleARN] + CollectionEndpoint: Optional[AmazonOpenSearchServerlessCollectionEndpoint] + IndexName: Optional[AmazonOpenSearchServerlessIndexName] + BufferingHints: Optional[AmazonOpenSearchServerlessBufferingHints] + RetryOptions: Optional[AmazonOpenSearchServerlessRetryOptions] + S3Update: Optional[S3DestinationUpdate] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + + +class AmazonopensearchserviceBufferingHints(TypedDict, total=False): + IntervalInSeconds: Optional[AmazonopensearchserviceBufferingIntervalInSeconds] + SizeInMBs: Optional[AmazonopensearchserviceBufferingSizeInMBs] + + +class DocumentIdOptions(TypedDict, total=False): + DefaultDocumentIdFormat: DefaultDocumentIdFormat + + +class AmazonopensearchserviceRetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[AmazonopensearchserviceRetryDurationInSeconds] + + +class AmazonopensearchserviceDestinationConfiguration(TypedDict, total=False): + RoleARN: RoleARN + DomainARN: Optional[AmazonopensearchserviceDomainARN] + ClusterEndpoint: Optional[AmazonopensearchserviceClusterEndpoint] + IndexName: AmazonopensearchserviceIndexName + TypeName: Optional[AmazonopensearchserviceTypeName] + IndexRotationPeriod: Optional[AmazonopensearchserviceIndexRotationPeriod] + BufferingHints: Optional[AmazonopensearchserviceBufferingHints] + RetryOptions: Optional[AmazonopensearchserviceRetryOptions] + S3BackupMode: Optional[AmazonopensearchserviceS3BackupMode] + S3Configuration: S3DestinationConfiguration + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + VpcConfiguration: Optional[VpcConfiguration] + DocumentIdOptions: Optional[DocumentIdOptions] + + +class AmazonopensearchserviceDestinationDescription(TypedDict, total=False): + RoleARN: Optional[RoleARN] + DomainARN: Optional[AmazonopensearchserviceDomainARN] + ClusterEndpoint: Optional[AmazonopensearchserviceClusterEndpoint] + IndexName: Optional[AmazonopensearchserviceIndexName] + TypeName: Optional[AmazonopensearchserviceTypeName] + IndexRotationPeriod: Optional[AmazonopensearchserviceIndexRotationPeriod] + BufferingHints: Optional[AmazonopensearchserviceBufferingHints] + RetryOptions: Optional[AmazonopensearchserviceRetryOptions] + S3BackupMode: Optional[AmazonopensearchserviceS3BackupMode] + S3DestinationDescription: Optional[S3DestinationDescription] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + VpcConfigurationDescription: Optional[VpcConfigurationDescription] + DocumentIdOptions: Optional[DocumentIdOptions] + + +class AmazonopensearchserviceDestinationUpdate(TypedDict, total=False): + RoleARN: Optional[RoleARN] + DomainARN: Optional[AmazonopensearchserviceDomainARN] + ClusterEndpoint: Optional[AmazonopensearchserviceClusterEndpoint] + IndexName: Optional[AmazonopensearchserviceIndexName] + TypeName: Optional[AmazonopensearchserviceTypeName] + IndexRotationPeriod: Optional[AmazonopensearchserviceIndexRotationPeriod] + BufferingHints: Optional[AmazonopensearchserviceBufferingHints] + RetryOptions: Optional[AmazonopensearchserviceRetryOptions] + S3Update: Optional[S3DestinationUpdate] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + DocumentIdOptions: Optional[DocumentIdOptions] + + +class AuthenticationConfiguration(TypedDict, total=False): + RoleARN: RoleARN + Connectivity: Connectivity + + +class CatalogConfiguration(TypedDict, total=False): + CatalogARN: Optional[GlueDataCatalogARN] + WarehouseLocation: Optional[WarehouseLocation] + + +ColumnToJsonKeyMappings = Dict[NonEmptyStringWithoutWhitespace, NonEmptyString] + + +class CopyCommand(TypedDict, total=False): + DataTableName: DataTableName + DataTableColumns: Optional[DataTableColumns] + CopyOptions: Optional[CopyOptions] + + +class DatabaseSourceVPCConfiguration(TypedDict, total=False): + VpcEndpointServiceName: VpcEndpointServiceName + + +class SecretsManagerConfiguration(TypedDict, total=False): + SecretARN: Optional[SecretARN] + RoleARN: Optional[RoleARN] + Enabled: BooleanObject + + +class DatabaseSourceAuthenticationConfiguration(TypedDict, total=False): + SecretsManagerConfiguration: SecretsManagerConfiguration + + +DatabaseSurrogateKeyList = List[NonEmptyStringWithoutWhitespace] +DatabaseColumnIncludeOrExcludeList = List[DatabaseColumnName] + + +class DatabaseColumnList(TypedDict, total=False): + Include: Optional[DatabaseColumnIncludeOrExcludeList] + Exclude: Optional[DatabaseColumnIncludeOrExcludeList] + + +DatabaseTableIncludeOrExcludeList = List[DatabaseTableName] + + +class DatabaseTableList(TypedDict, total=False): + Include: Optional[DatabaseTableIncludeOrExcludeList] + Exclude: Optional[DatabaseTableIncludeOrExcludeList] + + +DatabaseIncludeOrExcludeList = List[DatabaseName] + + +class DatabaseList(TypedDict, total=False): + Include: Optional[DatabaseIncludeOrExcludeList] + Exclude: Optional[DatabaseIncludeOrExcludeList] + + +class DatabaseSourceConfiguration(TypedDict, total=False): + Type: DatabaseType + Endpoint: DatabaseEndpoint + Port: DatabasePort + SSLMode: Optional[SSLMode] + Databases: DatabaseList + Tables: DatabaseTableList + Columns: Optional[DatabaseColumnList] + SurrogateKeys: Optional[DatabaseSurrogateKeyList] + SnapshotWatermarkTable: DatabaseTableName + DatabaseSourceAuthenticationConfiguration: DatabaseSourceAuthenticationConfiguration + DatabaseSourceVPCConfiguration: DatabaseSourceVPCConfiguration + + +class RetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[RetryDurationInSeconds] + + +class TableCreationConfiguration(TypedDict, total=False): + Enabled: BooleanObject + + +class SchemaEvolutionConfiguration(TypedDict, total=False): + Enabled: BooleanObject + + +class PartitionField(TypedDict, total=False): + SourceName: NonEmptyStringWithoutWhitespace + + +PartitionFields = List[PartitionField] + + +class PartitionSpec(TypedDict, total=False): + Identity: Optional[PartitionFields] + + +ListOfNonEmptyStringsWithoutWhitespace = List[NonEmptyStringWithoutWhitespace] + + +class DestinationTableConfiguration(TypedDict, total=False): + DestinationTableName: StringWithLettersDigitsUnderscoresDots + DestinationDatabaseName: StringWithLettersDigitsUnderscoresDots + UniqueKeys: Optional[ListOfNonEmptyStringsWithoutWhitespace] + PartitionSpec: Optional[PartitionSpec] + S3ErrorOutputPrefix: Optional[ErrorOutputPrefix] + + +DestinationTableConfigurationList = List[DestinationTableConfiguration] + + +class IcebergDestinationConfiguration(TypedDict, total=False): + DestinationTableConfigurationList: Optional[DestinationTableConfigurationList] + SchemaEvolutionConfiguration: Optional[SchemaEvolutionConfiguration] + TableCreationConfiguration: Optional[TableCreationConfiguration] + BufferingHints: Optional[BufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[IcebergS3BackupMode] + RetryOptions: Optional[RetryOptions] + RoleARN: RoleARN + AppendOnly: Optional[BooleanObject] + CatalogConfiguration: CatalogConfiguration + S3Configuration: S3DestinationConfiguration + + +class SnowflakeBufferingHints(TypedDict, total=False): + SizeInMBs: Optional[SnowflakeBufferingSizeInMBs] + IntervalInSeconds: Optional[SnowflakeBufferingIntervalInSeconds] + + +class SnowflakeRetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[SnowflakeRetryDurationInSeconds] + + +class SnowflakeVpcConfiguration(TypedDict, total=False): + PrivateLinkVpceId: SnowflakePrivateLinkVpceId + + +class SnowflakeRoleConfiguration(TypedDict, total=False): + Enabled: Optional[BooleanObject] + SnowflakeRole: Optional[SnowflakeRole] + + +class SnowflakeDestinationConfiguration(TypedDict, total=False): + AccountUrl: SnowflakeAccountUrl + PrivateKey: Optional[SnowflakePrivateKey] + KeyPassphrase: Optional[SnowflakeKeyPassphrase] + User: Optional[SnowflakeUser] + Database: SnowflakeDatabase + Schema: SnowflakeSchema + Table: SnowflakeTable + SnowflakeRoleConfiguration: Optional[SnowflakeRoleConfiguration] + DataLoadingOption: Optional[SnowflakeDataLoadingOption] + MetaDataColumnName: Optional[SnowflakeMetaDataColumnName] + ContentColumnName: Optional[SnowflakeContentColumnName] + SnowflakeVpcConfiguration: Optional[SnowflakeVpcConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RoleARN: RoleARN + RetryOptions: Optional[SnowflakeRetryOptions] + S3BackupMode: Optional[SnowflakeS3BackupMode] + S3Configuration: S3DestinationConfiguration + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + BufferingHints: Optional[SnowflakeBufferingHints] + + +ReadFromTimestamp = datetime + + +class MSKSourceConfiguration(TypedDict, total=False): + MSKClusterARN: MSKClusterARN + TopicName: TopicName + AuthenticationConfiguration: AuthenticationConfiguration + ReadFromTimestamp: Optional[ReadFromTimestamp] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: Optional[TagValue] + + +TagDeliveryStreamInputTagList = List[Tag] + + +class HttpEndpointRetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[HttpEndpointRetryDurationInSeconds] + + +class HttpEndpointCommonAttribute(TypedDict, total=False): + AttributeName: HttpEndpointAttributeName + AttributeValue: HttpEndpointAttributeValue + + +HttpEndpointCommonAttributesList = List[HttpEndpointCommonAttribute] + + +class HttpEndpointRequestConfiguration(TypedDict, total=False): + ContentEncoding: Optional[ContentEncoding] + CommonAttributes: Optional[HttpEndpointCommonAttributesList] + + +class HttpEndpointBufferingHints(TypedDict, total=False): + SizeInMBs: Optional[HttpEndpointBufferingSizeInMBs] + IntervalInSeconds: Optional[HttpEndpointBufferingIntervalInSeconds] + + +class HttpEndpointConfiguration(TypedDict, total=False): + Url: HttpEndpointUrl + Name: Optional[HttpEndpointName] + AccessKey: Optional[HttpEndpointAccessKey] + + +class HttpEndpointDestinationConfiguration(TypedDict, total=False): + EndpointConfiguration: HttpEndpointConfiguration + BufferingHints: Optional[HttpEndpointBufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + RequestConfiguration: Optional[HttpEndpointRequestConfiguration] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RoleARN: Optional[RoleARN] + RetryOptions: Optional[HttpEndpointRetryOptions] + S3BackupMode: Optional[HttpEndpointS3BackupMode] + S3Configuration: S3DestinationConfiguration + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class SplunkBufferingHints(TypedDict, total=False): + IntervalInSeconds: Optional[SplunkBufferingIntervalInSeconds] + SizeInMBs: Optional[SplunkBufferingSizeInMBs] + + +class SplunkRetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[SplunkRetryDurationInSeconds] + + +class SplunkDestinationConfiguration(TypedDict, total=False): + HECEndpoint: HECEndpoint + HECEndpointType: HECEndpointType + HECToken: Optional[HECToken] + HECAcknowledgmentTimeoutInSeconds: Optional[HECAcknowledgmentTimeoutInSeconds] + RetryOptions: Optional[SplunkRetryOptions] + S3BackupMode: Optional[SplunkS3BackupMode] + S3Configuration: S3DestinationConfiguration + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + BufferingHints: Optional[SplunkBufferingHints] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class ElasticsearchRetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[ElasticsearchRetryDurationInSeconds] + + +class ElasticsearchBufferingHints(TypedDict, total=False): + IntervalInSeconds: Optional[ElasticsearchBufferingIntervalInSeconds] + SizeInMBs: Optional[ElasticsearchBufferingSizeInMBs] + + +class ElasticsearchDestinationConfiguration(TypedDict, total=False): + RoleARN: RoleARN + DomainARN: Optional[ElasticsearchDomainARN] + ClusterEndpoint: Optional[ElasticsearchClusterEndpoint] + IndexName: ElasticsearchIndexName + TypeName: Optional[ElasticsearchTypeName] + IndexRotationPeriod: Optional[ElasticsearchIndexRotationPeriod] + BufferingHints: Optional[ElasticsearchBufferingHints] + RetryOptions: Optional[ElasticsearchRetryOptions] + S3BackupMode: Optional[ElasticsearchS3BackupMode] + S3Configuration: S3DestinationConfiguration + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + VpcConfiguration: Optional[VpcConfiguration] + DocumentIdOptions: Optional[DocumentIdOptions] + + +class RedshiftRetryOptions(TypedDict, total=False): + DurationInSeconds: Optional[RedshiftRetryDurationInSeconds] + + +class RedshiftDestinationConfiguration(TypedDict, total=False): + RoleARN: RoleARN + ClusterJDBCURL: ClusterJDBCURL + CopyCommand: CopyCommand + Username: Optional[Username] + Password: Optional[Password] + RetryOptions: Optional[RedshiftRetryOptions] + S3Configuration: S3DestinationConfiguration + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[RedshiftS3BackupMode] + S3BackupConfiguration: Optional[S3DestinationConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class DynamicPartitioningConfiguration(TypedDict, total=False): + RetryOptions: Optional[RetryOptions] + Enabled: Optional[BooleanObject] + + +class OrcSerDe(TypedDict, total=False): + StripeSizeBytes: Optional[OrcStripeSizeBytes] + BlockSizeBytes: Optional[BlockSizeBytes] + RowIndexStride: Optional[OrcRowIndexStride] + EnablePadding: Optional[BooleanObject] + PaddingTolerance: Optional[Proportion] + Compression: Optional[OrcCompression] + BloomFilterColumns: Optional[ListOfNonEmptyStringsWithoutWhitespace] + BloomFilterFalsePositiveProbability: Optional[Proportion] + DictionaryKeyThreshold: Optional[Proportion] + FormatVersion: Optional[OrcFormatVersion] + + +class ParquetSerDe(TypedDict, total=False): + BlockSizeBytes: Optional[BlockSizeBytes] + PageSizeBytes: Optional[ParquetPageSizeBytes] + Compression: Optional[ParquetCompression] + EnableDictionaryCompression: Optional[BooleanObject] + MaxPaddingBytes: Optional[NonNegativeIntegerObject] + WriterVersion: Optional[ParquetWriterVersion] + + +class Serializer(TypedDict, total=False): + ParquetSerDe: Optional[ParquetSerDe] + OrcSerDe: Optional[OrcSerDe] + + +class OutputFormatConfiguration(TypedDict, total=False): + Serializer: Optional[Serializer] + + +ListOfNonEmptyStrings = List[NonEmptyString] + + +class HiveJsonSerDe(TypedDict, total=False): + TimestampFormats: Optional[ListOfNonEmptyStrings] + + +class OpenXJsonSerDe(TypedDict, total=False): + ConvertDotsInJsonKeysToUnderscores: Optional[BooleanObject] + CaseInsensitive: Optional[BooleanObject] + ColumnToJsonKeyMappings: Optional[ColumnToJsonKeyMappings] + + +class Deserializer(TypedDict, total=False): + OpenXJsonSerDe: Optional[OpenXJsonSerDe] + HiveJsonSerDe: Optional[HiveJsonSerDe] + + +class InputFormatConfiguration(TypedDict, total=False): + Deserializer: Optional[Deserializer] + + +class SchemaConfiguration(TypedDict, total=False): + RoleARN: Optional[NonEmptyStringWithoutWhitespace] + CatalogId: Optional[NonEmptyStringWithoutWhitespace] + DatabaseName: Optional[NonEmptyStringWithoutWhitespace] + TableName: Optional[NonEmptyStringWithoutWhitespace] + Region: Optional[NonEmptyStringWithoutWhitespace] + VersionId: Optional[NonEmptyStringWithoutWhitespace] + + +class DataFormatConversionConfiguration(TypedDict, total=False): + SchemaConfiguration: Optional[SchemaConfiguration] + InputFormatConfiguration: Optional[InputFormatConfiguration] + OutputFormatConfiguration: Optional[OutputFormatConfiguration] + Enabled: Optional[BooleanObject] + + +class ExtendedS3DestinationConfiguration(TypedDict, total=False): + RoleARN: RoleARN + BucketARN: BucketARN + Prefix: Optional[Prefix] + ErrorOutputPrefix: Optional[ErrorOutputPrefix] + BufferingHints: Optional[BufferingHints] + CompressionFormat: Optional[CompressionFormat] + EncryptionConfiguration: Optional[EncryptionConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[S3BackupMode] + S3BackupConfiguration: Optional[S3DestinationConfiguration] + DataFormatConversionConfiguration: Optional[DataFormatConversionConfiguration] + DynamicPartitioningConfiguration: Optional[DynamicPartitioningConfiguration] + FileExtension: Optional[FileExtension] + CustomTimeZone: Optional[CustomTimeZone] + + +class DeliveryStreamEncryptionConfigurationInput(TypedDict, total=False): + KeyARN: Optional[AWSKMSKeyARN] + KeyType: KeyType + + +class KinesisStreamSourceConfiguration(TypedDict, total=False): + KinesisStreamARN: KinesisStreamARN + RoleARN: RoleARN + + +class DirectPutSourceConfiguration(TypedDict, total=False): + ThroughputHintInMBs: ThroughputHintInMBs + + +class CreateDeliveryStreamInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + DeliveryStreamType: Optional[DeliveryStreamType] + DirectPutSourceConfiguration: Optional[DirectPutSourceConfiguration] + KinesisStreamSourceConfiguration: Optional[KinesisStreamSourceConfiguration] + DeliveryStreamEncryptionConfigurationInput: Optional[DeliveryStreamEncryptionConfigurationInput] + S3DestinationConfiguration: Optional[S3DestinationConfiguration] + ExtendedS3DestinationConfiguration: Optional[ExtendedS3DestinationConfiguration] + RedshiftDestinationConfiguration: Optional[RedshiftDestinationConfiguration] + ElasticsearchDestinationConfiguration: Optional[ElasticsearchDestinationConfiguration] + AmazonopensearchserviceDestinationConfiguration: Optional[ + AmazonopensearchserviceDestinationConfiguration + ] + SplunkDestinationConfiguration: Optional[SplunkDestinationConfiguration] + HttpEndpointDestinationConfiguration: Optional[HttpEndpointDestinationConfiguration] + Tags: Optional[TagDeliveryStreamInputTagList] + AmazonOpenSearchServerlessDestinationConfiguration: Optional[ + AmazonOpenSearchServerlessDestinationConfiguration + ] + MSKSourceConfiguration: Optional[MSKSourceConfiguration] + SnowflakeDestinationConfiguration: Optional[SnowflakeDestinationConfiguration] + IcebergDestinationConfiguration: Optional[IcebergDestinationConfiguration] + DatabaseSourceConfiguration: Optional[DatabaseSourceConfiguration] + + +class CreateDeliveryStreamOutput(TypedDict, total=False): + DeliveryStreamARN: Optional[DeliveryStreamARN] + + +Data = bytes + + +class FailureDescription(TypedDict, total=False): + Type: DeliveryStreamFailureType + Details: NonEmptyString + + +Timestamp = datetime + + +class DatabaseSnapshotInfo(TypedDict, total=False): + Id: NonEmptyStringWithoutWhitespace + Table: DatabaseTableName + RequestTimestamp: Timestamp + RequestedBy: SnapshotRequestedBy + Status: SnapshotStatus + FailureDescription: Optional[FailureDescription] + + +DatabaseSnapshotInfoList = List[DatabaseSnapshotInfo] + + +class DatabaseSourceDescription(TypedDict, total=False): + Type: Optional[DatabaseType] + Endpoint: Optional[DatabaseEndpoint] + Port: Optional[DatabasePort] + SSLMode: Optional[SSLMode] + Databases: Optional[DatabaseList] + Tables: Optional[DatabaseTableList] + Columns: Optional[DatabaseColumnList] + SurrogateKeys: Optional[DatabaseColumnIncludeOrExcludeList] + SnapshotWatermarkTable: Optional[DatabaseTableName] + SnapshotInfo: Optional[DatabaseSnapshotInfoList] + DatabaseSourceAuthenticationConfiguration: Optional[DatabaseSourceAuthenticationConfiguration] + DatabaseSourceVPCConfiguration: Optional[DatabaseSourceVPCConfiguration] + + +class DeleteDeliveryStreamInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + AllowForceDelete: Optional[BooleanObject] + + +class DeleteDeliveryStreamOutput(TypedDict, total=False): + pass + + +DeliveryStartTimestamp = datetime + + +class IcebergDestinationDescription(TypedDict, total=False): + DestinationTableConfigurationList: Optional[DestinationTableConfigurationList] + SchemaEvolutionConfiguration: Optional[SchemaEvolutionConfiguration] + TableCreationConfiguration: Optional[TableCreationConfiguration] + BufferingHints: Optional[BufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[IcebergS3BackupMode] + RetryOptions: Optional[RetryOptions] + RoleARN: Optional[RoleARN] + AppendOnly: Optional[BooleanObject] + CatalogConfiguration: Optional[CatalogConfiguration] + S3DestinationDescription: Optional[S3DestinationDescription] + + +class SnowflakeDestinationDescription(TypedDict, total=False): + AccountUrl: Optional[SnowflakeAccountUrl] + User: Optional[SnowflakeUser] + Database: Optional[SnowflakeDatabase] + Schema: Optional[SnowflakeSchema] + Table: Optional[SnowflakeTable] + SnowflakeRoleConfiguration: Optional[SnowflakeRoleConfiguration] + DataLoadingOption: Optional[SnowflakeDataLoadingOption] + MetaDataColumnName: Optional[SnowflakeMetaDataColumnName] + ContentColumnName: Optional[SnowflakeContentColumnName] + SnowflakeVpcConfiguration: Optional[SnowflakeVpcConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RoleARN: Optional[RoleARN] + RetryOptions: Optional[SnowflakeRetryOptions] + S3BackupMode: Optional[SnowflakeS3BackupMode] + S3DestinationDescription: Optional[S3DestinationDescription] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + BufferingHints: Optional[SnowflakeBufferingHints] + + +class HttpEndpointDescription(TypedDict, total=False): + Url: Optional[HttpEndpointUrl] + Name: Optional[HttpEndpointName] + + +class HttpEndpointDestinationDescription(TypedDict, total=False): + EndpointConfiguration: Optional[HttpEndpointDescription] + BufferingHints: Optional[HttpEndpointBufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + RequestConfiguration: Optional[HttpEndpointRequestConfiguration] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RoleARN: Optional[RoleARN] + RetryOptions: Optional[HttpEndpointRetryOptions] + S3BackupMode: Optional[HttpEndpointS3BackupMode] + S3DestinationDescription: Optional[S3DestinationDescription] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class SplunkDestinationDescription(TypedDict, total=False): + HECEndpoint: Optional[HECEndpoint] + HECEndpointType: Optional[HECEndpointType] + HECToken: Optional[HECToken] + HECAcknowledgmentTimeoutInSeconds: Optional[HECAcknowledgmentTimeoutInSeconds] + RetryOptions: Optional[SplunkRetryOptions] + S3BackupMode: Optional[SplunkS3BackupMode] + S3DestinationDescription: Optional[S3DestinationDescription] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + BufferingHints: Optional[SplunkBufferingHints] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class ElasticsearchDestinationDescription(TypedDict, total=False): + RoleARN: Optional[RoleARN] + DomainARN: Optional[ElasticsearchDomainARN] + ClusterEndpoint: Optional[ElasticsearchClusterEndpoint] + IndexName: Optional[ElasticsearchIndexName] + TypeName: Optional[ElasticsearchTypeName] + IndexRotationPeriod: Optional[ElasticsearchIndexRotationPeriod] + BufferingHints: Optional[ElasticsearchBufferingHints] + RetryOptions: Optional[ElasticsearchRetryOptions] + S3BackupMode: Optional[ElasticsearchS3BackupMode] + S3DestinationDescription: Optional[S3DestinationDescription] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + VpcConfigurationDescription: Optional[VpcConfigurationDescription] + DocumentIdOptions: Optional[DocumentIdOptions] + + +class RedshiftDestinationDescription(TypedDict, total=False): + RoleARN: RoleARN + ClusterJDBCURL: ClusterJDBCURL + CopyCommand: CopyCommand + Username: Optional[Username] + RetryOptions: Optional[RedshiftRetryOptions] + S3DestinationDescription: S3DestinationDescription + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[RedshiftS3BackupMode] + S3BackupDescription: Optional[S3DestinationDescription] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class ExtendedS3DestinationDescription(TypedDict, total=False): + RoleARN: RoleARN + BucketARN: BucketARN + Prefix: Optional[Prefix] + ErrorOutputPrefix: Optional[ErrorOutputPrefix] + BufferingHints: BufferingHints + CompressionFormat: CompressionFormat + EncryptionConfiguration: EncryptionConfiguration + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[S3BackupMode] + S3BackupDescription: Optional[S3DestinationDescription] + DataFormatConversionConfiguration: Optional[DataFormatConversionConfiguration] + DynamicPartitioningConfiguration: Optional[DynamicPartitioningConfiguration] + FileExtension: Optional[FileExtension] + CustomTimeZone: Optional[CustomTimeZone] + + +class DestinationDescription(TypedDict, total=False): + DestinationId: DestinationId + S3DestinationDescription: Optional[S3DestinationDescription] + ExtendedS3DestinationDescription: Optional[ExtendedS3DestinationDescription] + RedshiftDestinationDescription: Optional[RedshiftDestinationDescription] + ElasticsearchDestinationDescription: Optional[ElasticsearchDestinationDescription] + AmazonopensearchserviceDestinationDescription: Optional[ + AmazonopensearchserviceDestinationDescription + ] + SplunkDestinationDescription: Optional[SplunkDestinationDescription] + HttpEndpointDestinationDescription: Optional[HttpEndpointDestinationDescription] + SnowflakeDestinationDescription: Optional[SnowflakeDestinationDescription] + AmazonOpenSearchServerlessDestinationDescription: Optional[ + AmazonOpenSearchServerlessDestinationDescription + ] + IcebergDestinationDescription: Optional[IcebergDestinationDescription] + + +DestinationDescriptionList = List[DestinationDescription] + + +class MSKSourceDescription(TypedDict, total=False): + MSKClusterARN: Optional[MSKClusterARN] + TopicName: Optional[TopicName] + AuthenticationConfiguration: Optional[AuthenticationConfiguration] + DeliveryStartTimestamp: Optional[DeliveryStartTimestamp] + ReadFromTimestamp: Optional[ReadFromTimestamp] + + +class KinesisStreamSourceDescription(TypedDict, total=False): + KinesisStreamARN: Optional[KinesisStreamARN] + RoleARN: Optional[RoleARN] + DeliveryStartTimestamp: Optional[DeliveryStartTimestamp] + + +class DirectPutSourceDescription(TypedDict, total=False): + ThroughputHintInMBs: Optional[ThroughputHintInMBs] + + +class SourceDescription(TypedDict, total=False): + DirectPutSourceDescription: Optional[DirectPutSourceDescription] + KinesisStreamSourceDescription: Optional[KinesisStreamSourceDescription] + MSKSourceDescription: Optional[MSKSourceDescription] + DatabaseSourceDescription: Optional[DatabaseSourceDescription] + + +class DeliveryStreamEncryptionConfiguration(TypedDict, total=False): + KeyARN: Optional[AWSKMSKeyARN] + KeyType: Optional[KeyType] + Status: Optional[DeliveryStreamEncryptionStatus] + FailureDescription: Optional[FailureDescription] + + +class DeliveryStreamDescription(TypedDict, total=False): + DeliveryStreamName: DeliveryStreamName + DeliveryStreamARN: DeliveryStreamARN + DeliveryStreamStatus: DeliveryStreamStatus + FailureDescription: Optional[FailureDescription] + DeliveryStreamEncryptionConfiguration: Optional[DeliveryStreamEncryptionConfiguration] + DeliveryStreamType: DeliveryStreamType + VersionId: DeliveryStreamVersionId + CreateTimestamp: Optional[Timestamp] + LastUpdateTimestamp: Optional[Timestamp] + Source: Optional[SourceDescription] + Destinations: DestinationDescriptionList + HasMoreDestinations: BooleanObject + + +DeliveryStreamNameList = List[DeliveryStreamName] + + +class DescribeDeliveryStreamInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + Limit: Optional[DescribeDeliveryStreamInputLimit] + ExclusiveStartDestinationId: Optional[DestinationId] + + +class DescribeDeliveryStreamOutput(TypedDict, total=False): + DeliveryStreamDescription: DeliveryStreamDescription + + +class ElasticsearchDestinationUpdate(TypedDict, total=False): + RoleARN: Optional[RoleARN] + DomainARN: Optional[ElasticsearchDomainARN] + ClusterEndpoint: Optional[ElasticsearchClusterEndpoint] + IndexName: Optional[ElasticsearchIndexName] + TypeName: Optional[ElasticsearchTypeName] + IndexRotationPeriod: Optional[ElasticsearchIndexRotationPeriod] + BufferingHints: Optional[ElasticsearchBufferingHints] + RetryOptions: Optional[ElasticsearchRetryOptions] + S3Update: Optional[S3DestinationUpdate] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + DocumentIdOptions: Optional[DocumentIdOptions] + + +class ExtendedS3DestinationUpdate(TypedDict, total=False): + RoleARN: Optional[RoleARN] + BucketARN: Optional[BucketARN] + Prefix: Optional[Prefix] + ErrorOutputPrefix: Optional[ErrorOutputPrefix] + BufferingHints: Optional[BufferingHints] + CompressionFormat: Optional[CompressionFormat] + EncryptionConfiguration: Optional[EncryptionConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[S3BackupMode] + S3BackupUpdate: Optional[S3DestinationUpdate] + DataFormatConversionConfiguration: Optional[DataFormatConversionConfiguration] + DynamicPartitioningConfiguration: Optional[DynamicPartitioningConfiguration] + FileExtension: Optional[FileExtension] + CustomTimeZone: Optional[CustomTimeZone] + + +class HttpEndpointDestinationUpdate(TypedDict, total=False): + EndpointConfiguration: Optional[HttpEndpointConfiguration] + BufferingHints: Optional[HttpEndpointBufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + RequestConfiguration: Optional[HttpEndpointRequestConfiguration] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RoleARN: Optional[RoleARN] + RetryOptions: Optional[HttpEndpointRetryOptions] + S3BackupMode: Optional[HttpEndpointS3BackupMode] + S3Update: Optional[S3DestinationUpdate] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class IcebergDestinationUpdate(TypedDict, total=False): + DestinationTableConfigurationList: Optional[DestinationTableConfigurationList] + SchemaEvolutionConfiguration: Optional[SchemaEvolutionConfiguration] + TableCreationConfiguration: Optional[TableCreationConfiguration] + BufferingHints: Optional[BufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[IcebergS3BackupMode] + RetryOptions: Optional[RetryOptions] + RoleARN: Optional[RoleARN] + AppendOnly: Optional[BooleanObject] + CatalogConfiguration: Optional[CatalogConfiguration] + S3Configuration: Optional[S3DestinationConfiguration] + + +class ListDeliveryStreamsInput(ServiceRequest): + Limit: Optional[ListDeliveryStreamsInputLimit] + DeliveryStreamType: Optional[DeliveryStreamType] + ExclusiveStartDeliveryStreamName: Optional[DeliveryStreamName] + + +class ListDeliveryStreamsOutput(TypedDict, total=False): + DeliveryStreamNames: DeliveryStreamNameList + HasMoreDeliveryStreams: BooleanObject + + +class ListTagsForDeliveryStreamInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + ExclusiveStartTagKey: Optional[TagKey] + Limit: Optional[ListTagsForDeliveryStreamInputLimit] + + +ListTagsForDeliveryStreamOutputTagList = List[Tag] + + +class ListTagsForDeliveryStreamOutput(TypedDict, total=False): + Tags: ListTagsForDeliveryStreamOutputTagList + HasMoreTags: BooleanObject + + +class Record(TypedDict, total=False): + Data: Data + + +PutRecordBatchRequestEntryList = List[Record] + + +class PutRecordBatchInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + Records: PutRecordBatchRequestEntryList + + +class PutRecordBatchResponseEntry(TypedDict, total=False): + RecordId: Optional[PutResponseRecordId] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +PutRecordBatchResponseEntryList = List[PutRecordBatchResponseEntry] + + +class PutRecordBatchOutput(TypedDict, total=False): + FailedPutCount: NonNegativeIntegerObject + Encrypted: Optional[BooleanObject] + RequestResponses: PutRecordBatchResponseEntryList + + +class PutRecordInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + Record: Record + + +class PutRecordOutput(TypedDict, total=False): + RecordId: PutResponseRecordId + Encrypted: Optional[BooleanObject] + + +class RedshiftDestinationUpdate(TypedDict, total=False): + RoleARN: Optional[RoleARN] + ClusterJDBCURL: Optional[ClusterJDBCURL] + CopyCommand: Optional[CopyCommand] + Username: Optional[Username] + Password: Optional[Password] + RetryOptions: Optional[RedshiftRetryOptions] + S3Update: Optional[S3DestinationUpdate] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupMode: Optional[RedshiftS3BackupMode] + S3BackupUpdate: Optional[S3DestinationUpdate] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class SnowflakeDestinationUpdate(TypedDict, total=False): + AccountUrl: Optional[SnowflakeAccountUrl] + PrivateKey: Optional[SnowflakePrivateKey] + KeyPassphrase: Optional[SnowflakeKeyPassphrase] + User: Optional[SnowflakeUser] + Database: Optional[SnowflakeDatabase] + Schema: Optional[SnowflakeSchema] + Table: Optional[SnowflakeTable] + SnowflakeRoleConfiguration: Optional[SnowflakeRoleConfiguration] + DataLoadingOption: Optional[SnowflakeDataLoadingOption] + MetaDataColumnName: Optional[SnowflakeMetaDataColumnName] + ContentColumnName: Optional[SnowflakeContentColumnName] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RoleARN: Optional[RoleARN] + RetryOptions: Optional[SnowflakeRetryOptions] + S3BackupMode: Optional[SnowflakeS3BackupMode] + S3Update: Optional[S3DestinationUpdate] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + BufferingHints: Optional[SnowflakeBufferingHints] + + +class SplunkDestinationUpdate(TypedDict, total=False): + HECEndpoint: Optional[HECEndpoint] + HECEndpointType: Optional[HECEndpointType] + HECToken: Optional[HECToken] + HECAcknowledgmentTimeoutInSeconds: Optional[HECAcknowledgmentTimeoutInSeconds] + RetryOptions: Optional[SplunkRetryOptions] + S3BackupMode: Optional[SplunkS3BackupMode] + S3Update: Optional[S3DestinationUpdate] + ProcessingConfiguration: Optional[ProcessingConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + BufferingHints: Optional[SplunkBufferingHints] + SecretsManagerConfiguration: Optional[SecretsManagerConfiguration] + + +class StartDeliveryStreamEncryptionInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + DeliveryStreamEncryptionConfigurationInput: Optional[DeliveryStreamEncryptionConfigurationInput] + + +class StartDeliveryStreamEncryptionOutput(TypedDict, total=False): + pass + + +class StopDeliveryStreamEncryptionInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + + +class StopDeliveryStreamEncryptionOutput(TypedDict, total=False): + pass + + +class TagDeliveryStreamInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + Tags: TagDeliveryStreamInputTagList + + +class TagDeliveryStreamOutput(TypedDict, total=False): + pass + + +TagKeyList = List[TagKey] + + +class UntagDeliveryStreamInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + TagKeys: TagKeyList + + +class UntagDeliveryStreamOutput(TypedDict, total=False): + pass + + +class UpdateDestinationInput(ServiceRequest): + DeliveryStreamName: DeliveryStreamName + CurrentDeliveryStreamVersionId: DeliveryStreamVersionId + DestinationId: DestinationId + S3DestinationUpdate: Optional[S3DestinationUpdate] + ExtendedS3DestinationUpdate: Optional[ExtendedS3DestinationUpdate] + RedshiftDestinationUpdate: Optional[RedshiftDestinationUpdate] + ElasticsearchDestinationUpdate: Optional[ElasticsearchDestinationUpdate] + AmazonopensearchserviceDestinationUpdate: Optional[AmazonopensearchserviceDestinationUpdate] + SplunkDestinationUpdate: Optional[SplunkDestinationUpdate] + HttpEndpointDestinationUpdate: Optional[HttpEndpointDestinationUpdate] + AmazonOpenSearchServerlessDestinationUpdate: Optional[ + AmazonOpenSearchServerlessDestinationUpdate + ] + SnowflakeDestinationUpdate: Optional[SnowflakeDestinationUpdate] + IcebergDestinationUpdate: Optional[IcebergDestinationUpdate] + + +class UpdateDestinationOutput(TypedDict, total=False): + pass + + +class FirehoseApi: + service = "firehose" + version = "2015-08-04" + + @handler("CreateDeliveryStream") + def create_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + delivery_stream_type: DeliveryStreamType | None = None, + direct_put_source_configuration: DirectPutSourceConfiguration | None = None, + kinesis_stream_source_configuration: KinesisStreamSourceConfiguration | None = None, + delivery_stream_encryption_configuration_input: DeliveryStreamEncryptionConfigurationInput + | None = None, + s3_destination_configuration: S3DestinationConfiguration | None = None, + extended_s3_destination_configuration: ExtendedS3DestinationConfiguration | None = None, + redshift_destination_configuration: RedshiftDestinationConfiguration | None = None, + elasticsearch_destination_configuration: ElasticsearchDestinationConfiguration + | None = None, + amazonopensearchservice_destination_configuration: AmazonopensearchserviceDestinationConfiguration + | None = None, + splunk_destination_configuration: SplunkDestinationConfiguration | None = None, + http_endpoint_destination_configuration: HttpEndpointDestinationConfiguration | None = None, + tags: TagDeliveryStreamInputTagList | None = None, + amazon_open_search_serverless_destination_configuration: AmazonOpenSearchServerlessDestinationConfiguration + | None = None, + msk_source_configuration: MSKSourceConfiguration | None = None, + snowflake_destination_configuration: SnowflakeDestinationConfiguration | None = None, + iceberg_destination_configuration: IcebergDestinationConfiguration | None = None, + database_source_configuration: DatabaseSourceConfiguration | None = None, + **kwargs, + ) -> CreateDeliveryStreamOutput: + raise NotImplementedError + + @handler("DeleteDeliveryStream") + def delete_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + allow_force_delete: BooleanObject | None = None, + **kwargs, + ) -> DeleteDeliveryStreamOutput: + raise NotImplementedError + + @handler("DescribeDeliveryStream") + def describe_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + limit: DescribeDeliveryStreamInputLimit | None = None, + exclusive_start_destination_id: DestinationId | None = None, + **kwargs, + ) -> DescribeDeliveryStreamOutput: + raise NotImplementedError + + @handler("ListDeliveryStreams") + def list_delivery_streams( + self, + context: RequestContext, + limit: ListDeliveryStreamsInputLimit | None = None, + delivery_stream_type: DeliveryStreamType | None = None, + exclusive_start_delivery_stream_name: DeliveryStreamName | None = None, + **kwargs, + ) -> ListDeliveryStreamsOutput: + raise NotImplementedError + + @handler("ListTagsForDeliveryStream") + def list_tags_for_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + exclusive_start_tag_key: TagKey | None = None, + limit: ListTagsForDeliveryStreamInputLimit | None = None, + **kwargs, + ) -> ListTagsForDeliveryStreamOutput: + raise NotImplementedError + + @handler("PutRecord") + def put_record( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + record: Record, + **kwargs, + ) -> PutRecordOutput: + raise NotImplementedError + + @handler("PutRecordBatch") + def put_record_batch( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + records: PutRecordBatchRequestEntryList, + **kwargs, + ) -> PutRecordBatchOutput: + raise NotImplementedError + + @handler("StartDeliveryStreamEncryption") + def start_delivery_stream_encryption( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + delivery_stream_encryption_configuration_input: DeliveryStreamEncryptionConfigurationInput + | None = None, + **kwargs, + ) -> StartDeliveryStreamEncryptionOutput: + raise NotImplementedError + + @handler("StopDeliveryStreamEncryption") + def stop_delivery_stream_encryption( + self, context: RequestContext, delivery_stream_name: DeliveryStreamName, **kwargs + ) -> StopDeliveryStreamEncryptionOutput: + raise NotImplementedError + + @handler("TagDeliveryStream") + def tag_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + tags: TagDeliveryStreamInputTagList, + **kwargs, + ) -> TagDeliveryStreamOutput: + raise NotImplementedError + + @handler("UntagDeliveryStream") + def untag_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagDeliveryStreamOutput: + raise NotImplementedError + + @handler("UpdateDestination") + def update_destination( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + current_delivery_stream_version_id: DeliveryStreamVersionId, + destination_id: DestinationId, + s3_destination_update: S3DestinationUpdate | None = None, + extended_s3_destination_update: ExtendedS3DestinationUpdate | None = None, + redshift_destination_update: RedshiftDestinationUpdate | None = None, + elasticsearch_destination_update: ElasticsearchDestinationUpdate | None = None, + amazonopensearchservice_destination_update: AmazonopensearchserviceDestinationUpdate + | None = None, + splunk_destination_update: SplunkDestinationUpdate | None = None, + http_endpoint_destination_update: HttpEndpointDestinationUpdate | None = None, + amazon_open_search_serverless_destination_update: AmazonOpenSearchServerlessDestinationUpdate + | None = None, + snowflake_destination_update: SnowflakeDestinationUpdate | None = None, + iceberg_destination_update: IcebergDestinationUpdate | None = None, + **kwargs, + ) -> UpdateDestinationOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/iam/__init__.py b/localstack-core/localstack/aws/api/iam/__init__.py new file mode 100644 index 0000000000000..6967249f66bf8 --- /dev/null +++ b/localstack-core/localstack/aws/api/iam/__init__.py @@ -0,0 +1,3953 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ActionNameType = str +CertificationKeyType = str +CertificationValueType = str +ColumnNumber = int +ConcurrentModificationMessage = str +ContextKeyNameType = str +ContextKeyValueType = str +DeletionTaskIdType = str +EvalDecisionSourceType = str +LineNumber = int +OpenIDConnectProviderUrlType = str +OrganizationIdType = str +PolicyIdentifierType = str +ReasonType = str +RegionNameType = str +ReportStateDescriptionType = str +ResourceHandlingOptionType = str +ResourceNameType = str +SAMLMetadataDocumentType = str +SAMLProviderNameType = str +accessKeyIdType = str +accessKeySecretType = str +accountAliasType = str +allUsers = bool +arnType = str +attachmentCountType = int +authenticationCodeType = str +booleanObjectType = bool +booleanType = bool +certificateBodyType = str +certificateChainType = str +certificateIdType = str +clientIDType = str +credentialAgeDays = int +credentialReportExpiredExceptionMessage = str +credentialReportNotPresentExceptionMessage = str +credentialReportNotReadyExceptionMessage = str +customSuffixType = str +deleteConflictMessage = str +duplicateCertificateMessage = str +duplicateSSHPublicKeyMessage = str +entityAlreadyExistsMessage = str +entityNameType = str +entityTemporarilyUnmodifiableMessage = str +existingUserNameType = str +groupNameType = str +idType = str +instanceProfileNameType = str +integerType = int +invalidAuthenticationCodeMessage = str +invalidCertificateMessage = str +invalidInputMessage = str +invalidPublicKeyMessage = str +invalidUserTypeMessage = str +jobIDType = str +keyPairMismatchMessage = str +limitExceededMessage = str +malformedCertificateMessage = str +malformedPolicyDocumentMessage = str +markerType = str +maxItemsType = int +maxPasswordAgeType = int +minimumPasswordLengthType = int +noSuchEntityMessage = str +openIdIdpCommunicationErrorExceptionMessage = str +organizationsEntityPathType = str +organizationsPolicyIdType = str +passwordPolicyViolationMessage = str +passwordReusePreventionType = int +passwordType = str +pathPrefixType = str +pathType = str +policyDescriptionType = str +policyDocumentType = str +policyEvaluationErrorMessage = str +policyNameType = str +policyNotAttachableMessage = str +policyPathType = str +policyVersionIdType = str +privateKeyIdType = str +privateKeyType = str +publicKeyFingerprintType = str +publicKeyIdType = str +publicKeyMaterialType = str +reportGenerationLimitExceededMessage = str +responseMarkerType = str +roleDescriptionType = str +roleMaxSessionDurationType = int +roleNameType = str +serialNumberType = str +serverCertificateNameType = str +serviceCredentialAlias = str +serviceCredentialSecret = str +serviceFailureExceptionMessage = str +serviceName = str +serviceNameType = str +serviceNamespaceType = str +serviceNotSupportedMessage = str +servicePassword = str +serviceSpecificCredentialId = str +serviceUserName = str +stringType = str +summaryValueType = int +tagKeyType = str +tagValueType = str +thumbprintType = str +unmodifiableEntityMessage = str +unrecognizedPublicKeyEncodingMessage = str +userNameType = str +virtualMFADeviceName = str + + +class AccessAdvisorUsageGranularityType(StrEnum): + SERVICE_LEVEL = "SERVICE_LEVEL" + ACTION_LEVEL = "ACTION_LEVEL" + + +class ContextKeyTypeEnum(StrEnum): + string = "string" + stringList = "stringList" + numeric = "numeric" + numericList = "numericList" + boolean = "boolean" + booleanList = "booleanList" + ip = "ip" + ipList = "ipList" + binary = "binary" + binaryList = "binaryList" + date = "date" + dateList = "dateList" + + +class DeletionTaskStatusType(StrEnum): + SUCCEEDED = "SUCCEEDED" + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + NOT_STARTED = "NOT_STARTED" + + +class EntityType(StrEnum): + User = "User" + Role = "Role" + Group = "Group" + LocalManagedPolicy = "LocalManagedPolicy" + AWSManagedPolicy = "AWSManagedPolicy" + + +class FeatureType(StrEnum): + RootCredentialsManagement = "RootCredentialsManagement" + RootSessions = "RootSessions" + + +class PermissionsBoundaryAttachmentType(StrEnum): + PermissionsBoundaryPolicy = "PermissionsBoundaryPolicy" + + +class PolicyEvaluationDecisionType(StrEnum): + allowed = "allowed" + explicitDeny = "explicitDeny" + implicitDeny = "implicitDeny" + + +class PolicySourceType(StrEnum): + user = "user" + group = "group" + role = "role" + aws_managed = "aws-managed" + user_managed = "user-managed" + resource = "resource" + none = "none" + + +class PolicyUsageType(StrEnum): + PermissionsPolicy = "PermissionsPolicy" + PermissionsBoundary = "PermissionsBoundary" + + +class ReportFormatType(StrEnum): + text_csv = "text/csv" + + +class ReportStateType(StrEnum): + STARTED = "STARTED" + INPROGRESS = "INPROGRESS" + COMPLETE = "COMPLETE" + + +class assertionEncryptionModeType(StrEnum): + Required = "Required" + Allowed = "Allowed" + + +class assignmentStatusType(StrEnum): + Assigned = "Assigned" + Unassigned = "Unassigned" + Any = "Any" + + +class encodingType(StrEnum): + SSH = "SSH" + PEM = "PEM" + + +class globalEndpointTokenVersion(StrEnum): + v1Token = "v1Token" + v2Token = "v2Token" + + +class jobStatusType(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +class policyOwnerEntityType(StrEnum): + USER = "USER" + ROLE = "ROLE" + GROUP = "GROUP" + + +class policyScopeType(StrEnum): + All = "All" + AWS = "AWS" + Local = "Local" + + +class policyType(StrEnum): + INLINE = "INLINE" + MANAGED = "MANAGED" + + +class sortKeyType(StrEnum): + SERVICE_NAMESPACE_ASCENDING = "SERVICE_NAMESPACE_ASCENDING" + SERVICE_NAMESPACE_DESCENDING = "SERVICE_NAMESPACE_DESCENDING" + LAST_AUTHENTICATED_TIME_ASCENDING = "LAST_AUTHENTICATED_TIME_ASCENDING" + LAST_AUTHENTICATED_TIME_DESCENDING = "LAST_AUTHENTICATED_TIME_DESCENDING" + + +class statusType(StrEnum): + Active = "Active" + Inactive = "Inactive" + Expired = "Expired" + + +class summaryKeyType(StrEnum): + Users = "Users" + UsersQuota = "UsersQuota" + Groups = "Groups" + GroupsQuota = "GroupsQuota" + ServerCertificates = "ServerCertificates" + ServerCertificatesQuota = "ServerCertificatesQuota" + UserPolicySizeQuota = "UserPolicySizeQuota" + GroupPolicySizeQuota = "GroupPolicySizeQuota" + GroupsPerUserQuota = "GroupsPerUserQuota" + SigningCertificatesPerUserQuota = "SigningCertificatesPerUserQuota" + AccessKeysPerUserQuota = "AccessKeysPerUserQuota" + MFADevices = "MFADevices" + MFADevicesInUse = "MFADevicesInUse" + AccountMFAEnabled = "AccountMFAEnabled" + AccountAccessKeysPresent = "AccountAccessKeysPresent" + AccountPasswordPresent = "AccountPasswordPresent" + AccountSigningCertificatesPresent = "AccountSigningCertificatesPresent" + AttachedPoliciesPerGroupQuota = "AttachedPoliciesPerGroupQuota" + AttachedPoliciesPerRoleQuota = "AttachedPoliciesPerRoleQuota" + AttachedPoliciesPerUserQuota = "AttachedPoliciesPerUserQuota" + Policies = "Policies" + PoliciesQuota = "PoliciesQuota" + PolicySizeQuota = "PolicySizeQuota" + PolicyVersionsInUse = "PolicyVersionsInUse" + PolicyVersionsInUseQuota = "PolicyVersionsInUseQuota" + VersionsPerPolicyQuota = "VersionsPerPolicyQuota" + GlobalEndpointTokenVersion = "GlobalEndpointTokenVersion" + + +class AccountNotManagementOrDelegatedAdministratorException(ServiceException): + code: str = "AccountNotManagementOrDelegatedAdministratorException" + sender_fault: bool = False + status_code: int = 400 + + +class CallerIsNotManagementAccountException(ServiceException): + code: str = "CallerIsNotManagementAccountException" + sender_fault: bool = False + status_code: int = 400 + + +class ConcurrentModificationException(ServiceException): + code: str = "ConcurrentModification" + sender_fault: bool = True + status_code: int = 409 + + +class CredentialReportExpiredException(ServiceException): + code: str = "ReportExpired" + sender_fault: bool = True + status_code: int = 410 + + +class CredentialReportNotPresentException(ServiceException): + code: str = "ReportNotPresent" + sender_fault: bool = True + status_code: int = 410 + + +class CredentialReportNotReadyException(ServiceException): + code: str = "ReportInProgress" + sender_fault: bool = True + status_code: int = 404 + + +class DeleteConflictException(ServiceException): + code: str = "DeleteConflict" + sender_fault: bool = True + status_code: int = 409 + + +class DuplicateCertificateException(ServiceException): + code: str = "DuplicateCertificate" + sender_fault: bool = True + status_code: int = 409 + + +class DuplicateSSHPublicKeyException(ServiceException): + code: str = "DuplicateSSHPublicKey" + sender_fault: bool = True + status_code: int = 400 + + +class EntityAlreadyExistsException(ServiceException): + code: str = "EntityAlreadyExists" + sender_fault: bool = True + status_code: int = 409 + + +class EntityTemporarilyUnmodifiableException(ServiceException): + code: str = "EntityTemporarilyUnmodifiable" + sender_fault: bool = True + status_code: int = 409 + + +class InvalidAuthenticationCodeException(ServiceException): + code: str = "InvalidAuthenticationCode" + sender_fault: bool = True + status_code: int = 403 + + +class InvalidCertificateException(ServiceException): + code: str = "InvalidCertificate" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidInputException(ServiceException): + code: str = "InvalidInput" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidPublicKeyException(ServiceException): + code: str = "InvalidPublicKey" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidUserTypeException(ServiceException): + code: str = "InvalidUserType" + sender_fault: bool = True + status_code: int = 400 + + +class KeyPairMismatchException(ServiceException): + code: str = "KeyPairMismatch" + sender_fault: bool = True + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceeded" + sender_fault: bool = True + status_code: int = 409 + + +class MalformedCertificateException(ServiceException): + code: str = "MalformedCertificate" + sender_fault: bool = True + status_code: int = 400 + + +class MalformedPolicyDocumentException(ServiceException): + code: str = "MalformedPolicyDocument" + sender_fault: bool = True + status_code: int = 400 + + +class NoSuchEntityException(ServiceException): + code: str = "NoSuchEntity" + sender_fault: bool = True + status_code: int = 404 + + +class OpenIdIdpCommunicationErrorException(ServiceException): + code: str = "OpenIdIdpCommunicationError" + sender_fault: bool = True + status_code: int = 400 + + +class OrganizationNotFoundException(ServiceException): + code: str = "OrganizationNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class OrganizationNotInAllFeaturesModeException(ServiceException): + code: str = "OrganizationNotInAllFeaturesModeException" + sender_fault: bool = False + status_code: int = 400 + + +class PasswordPolicyViolationException(ServiceException): + code: str = "PasswordPolicyViolation" + sender_fault: bool = True + status_code: int = 400 + + +class PolicyEvaluationException(ServiceException): + code: str = "PolicyEvaluation" + sender_fault: bool = False + status_code: int = 500 + + +class PolicyNotAttachableException(ServiceException): + code: str = "PolicyNotAttachable" + sender_fault: bool = True + status_code: int = 400 + + +class ReportGenerationLimitExceededException(ServiceException): + code: str = "ReportGenerationLimitExceeded" + sender_fault: bool = True + status_code: int = 409 + + +class ServiceAccessNotEnabledException(ServiceException): + code: str = "ServiceAccessNotEnabledException" + sender_fault: bool = False + status_code: int = 400 + + +class ServiceFailureException(ServiceException): + code: str = "ServiceFailure" + sender_fault: bool = False + status_code: int = 500 + + +class ServiceNotSupportedException(ServiceException): + code: str = "NotSupportedService" + sender_fault: bool = True + status_code: int = 404 + + +class UnmodifiableEntityException(ServiceException): + code: str = "UnmodifiableEntity" + sender_fault: bool = True + status_code: int = 400 + + +class UnrecognizedPublicKeyEncodingException(ServiceException): + code: str = "UnrecognizedPublicKeyEncoding" + sender_fault: bool = True + status_code: int = 400 + + +dateType = datetime + + +class AccessDetail(TypedDict, total=False): + ServiceName: serviceNameType + ServiceNamespace: serviceNamespaceType + Region: Optional[stringType] + EntityPath: Optional[organizationsEntityPathType] + LastAuthenticatedTime: Optional[dateType] + TotalAuthenticatedEntities: Optional[integerType] + + +AccessDetails = List[AccessDetail] + + +class AccessKey(TypedDict, total=False): + UserName: userNameType + AccessKeyId: accessKeyIdType + Status: statusType + SecretAccessKey: accessKeySecretType + CreateDate: Optional[dateType] + + +class AccessKeyLastUsed(TypedDict, total=False): + LastUsedDate: Optional[dateType] + ServiceName: stringType + Region: stringType + + +class AccessKeyMetadata(TypedDict, total=False): + UserName: Optional[userNameType] + AccessKeyId: Optional[accessKeyIdType] + Status: Optional[statusType] + CreateDate: Optional[dateType] + + +ActionNameListType = List[ActionNameType] + + +class AddClientIDToOpenIDConnectProviderRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + ClientID: clientIDType + + +class AddRoleToInstanceProfileRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + RoleName: roleNameType + + +class AddUserToGroupRequest(ServiceRequest): + GroupName: groupNameType + UserName: existingUserNameType + + +ArnListType = List[arnType] + + +class AttachGroupPolicyRequest(ServiceRequest): + GroupName: groupNameType + PolicyArn: arnType + + +class AttachRolePolicyRequest(ServiceRequest): + RoleName: roleNameType + PolicyArn: arnType + + +class AttachUserPolicyRequest(ServiceRequest): + UserName: userNameType + PolicyArn: arnType + + +class AttachedPermissionsBoundary(TypedDict, total=False): + PermissionsBoundaryType: Optional[PermissionsBoundaryAttachmentType] + PermissionsBoundaryArn: Optional[arnType] + + +class AttachedPolicy(TypedDict, total=False): + PolicyName: Optional[policyNameType] + PolicyArn: Optional[arnType] + + +BootstrapDatum = bytes +CertificationMapType = Dict[CertificationKeyType, CertificationValueType] + + +class ChangePasswordRequest(ServiceRequest): + OldPassword: passwordType + NewPassword: passwordType + + +ContextKeyValueListType = List[ContextKeyValueType] + + +class ContextEntry(TypedDict, total=False): + ContextKeyName: Optional[ContextKeyNameType] + ContextKeyValues: Optional[ContextKeyValueListType] + ContextKeyType: Optional[ContextKeyTypeEnum] + + +ContextEntryListType = List[ContextEntry] +ContextKeyNamesResultListType = List[ContextKeyNameType] + + +class CreateAccessKeyRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + + +class CreateAccessKeyResponse(TypedDict, total=False): + AccessKey: AccessKey + + +class CreateAccountAliasRequest(ServiceRequest): + AccountAlias: accountAliasType + + +class CreateGroupRequest(ServiceRequest): + Path: Optional[pathType] + GroupName: groupNameType + + +class Group(TypedDict, total=False): + Path: pathType + GroupName: groupNameType + GroupId: idType + Arn: arnType + CreateDate: dateType + + +class CreateGroupResponse(TypedDict, total=False): + Group: Group + + +class Tag(TypedDict, total=False): + Key: tagKeyType + Value: tagValueType + + +tagListType = List[Tag] + + +class CreateInstanceProfileRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + Path: Optional[pathType] + Tags: Optional[tagListType] + + +class RoleLastUsed(TypedDict, total=False): + LastUsedDate: Optional[dateType] + Region: Optional[stringType] + + +class Role(TypedDict, total=False): + Path: pathType + RoleName: roleNameType + RoleId: idType + Arn: arnType + CreateDate: dateType + AssumeRolePolicyDocument: Optional[policyDocumentType] + Description: Optional[roleDescriptionType] + MaxSessionDuration: Optional[roleMaxSessionDurationType] + PermissionsBoundary: Optional[AttachedPermissionsBoundary] + Tags: Optional[tagListType] + RoleLastUsed: Optional[RoleLastUsed] + + +roleListType = List[Role] + + +class InstanceProfile(TypedDict, total=False): + Path: pathType + InstanceProfileName: instanceProfileNameType + InstanceProfileId: idType + Arn: arnType + CreateDate: dateType + Roles: roleListType + Tags: Optional[tagListType] + + +class CreateInstanceProfileResponse(TypedDict, total=False): + InstanceProfile: InstanceProfile + + +class CreateLoginProfileRequest(ServiceRequest): + UserName: Optional[userNameType] + Password: Optional[passwordType] + PasswordResetRequired: Optional[booleanType] + + +class LoginProfile(TypedDict, total=False): + UserName: userNameType + CreateDate: dateType + PasswordResetRequired: Optional[booleanType] + + +class CreateLoginProfileResponse(TypedDict, total=False): + LoginProfile: LoginProfile + + +thumbprintListType = List[thumbprintType] +clientIDListType = List[clientIDType] + + +class CreateOpenIDConnectProviderRequest(ServiceRequest): + Url: OpenIDConnectProviderUrlType + ClientIDList: Optional[clientIDListType] + ThumbprintList: Optional[thumbprintListType] + Tags: Optional[tagListType] + + +class CreateOpenIDConnectProviderResponse(TypedDict, total=False): + OpenIDConnectProviderArn: Optional[arnType] + Tags: Optional[tagListType] + + +class CreatePolicyRequest(ServiceRequest): + PolicyName: policyNameType + Path: Optional[policyPathType] + PolicyDocument: policyDocumentType + Description: Optional[policyDescriptionType] + Tags: Optional[tagListType] + + +class Policy(TypedDict, total=False): + PolicyName: Optional[policyNameType] + PolicyId: Optional[idType] + Arn: Optional[arnType] + Path: Optional[policyPathType] + DefaultVersionId: Optional[policyVersionIdType] + AttachmentCount: Optional[attachmentCountType] + PermissionsBoundaryUsageCount: Optional[attachmentCountType] + IsAttachable: Optional[booleanType] + Description: Optional[policyDescriptionType] + CreateDate: Optional[dateType] + UpdateDate: Optional[dateType] + Tags: Optional[tagListType] + + +class CreatePolicyResponse(TypedDict, total=False): + Policy: Optional[Policy] + + +class CreatePolicyVersionRequest(ServiceRequest): + PolicyArn: arnType + PolicyDocument: policyDocumentType + SetAsDefault: Optional[booleanType] + + +class PolicyVersion(TypedDict, total=False): + Document: Optional[policyDocumentType] + VersionId: Optional[policyVersionIdType] + IsDefaultVersion: Optional[booleanType] + CreateDate: Optional[dateType] + + +class CreatePolicyVersionResponse(TypedDict, total=False): + PolicyVersion: Optional[PolicyVersion] + + +class CreateRoleRequest(ServiceRequest): + Path: Optional[pathType] + RoleName: roleNameType + AssumeRolePolicyDocument: policyDocumentType + Description: Optional[roleDescriptionType] + MaxSessionDuration: Optional[roleMaxSessionDurationType] + PermissionsBoundary: Optional[arnType] + Tags: Optional[tagListType] + + +class CreateRoleResponse(TypedDict, total=False): + Role: Role + + +class CreateSAMLProviderRequest(ServiceRequest): + SAMLMetadataDocument: SAMLMetadataDocumentType + Name: SAMLProviderNameType + Tags: Optional[tagListType] + AssertionEncryptionMode: Optional[assertionEncryptionModeType] + AddPrivateKey: Optional[privateKeyType] + + +class CreateSAMLProviderResponse(TypedDict, total=False): + SAMLProviderArn: Optional[arnType] + Tags: Optional[tagListType] + + +class CreateServiceLinkedRoleRequest(ServiceRequest): + AWSServiceName: groupNameType + Description: Optional[roleDescriptionType] + CustomSuffix: Optional[customSuffixType] + + +class CreateServiceLinkedRoleResponse(TypedDict, total=False): + Role: Optional[Role] + + +class CreateServiceSpecificCredentialRequest(ServiceRequest): + UserName: userNameType + ServiceName: serviceName + CredentialAgeDays: Optional[credentialAgeDays] + + +class ServiceSpecificCredential(TypedDict, total=False): + CreateDate: dateType + ExpirationDate: Optional[dateType] + ServiceName: serviceName + ServiceUserName: Optional[serviceUserName] + ServicePassword: Optional[servicePassword] + ServiceCredentialAlias: Optional[serviceCredentialAlias] + ServiceCredentialSecret: Optional[serviceCredentialSecret] + ServiceSpecificCredentialId: serviceSpecificCredentialId + UserName: userNameType + Status: statusType + + +class CreateServiceSpecificCredentialResponse(TypedDict, total=False): + ServiceSpecificCredential: Optional[ServiceSpecificCredential] + + +class CreateUserRequest(ServiceRequest): + Path: Optional[pathType] + UserName: userNameType + PermissionsBoundary: Optional[arnType] + Tags: Optional[tagListType] + + +class User(TypedDict, total=False): + Path: pathType + UserName: userNameType + UserId: idType + Arn: arnType + CreateDate: dateType + PasswordLastUsed: Optional[dateType] + PermissionsBoundary: Optional[AttachedPermissionsBoundary] + Tags: Optional[tagListType] + + +class CreateUserResponse(TypedDict, total=False): + User: Optional[User] + + +class CreateVirtualMFADeviceRequest(ServiceRequest): + Path: Optional[pathType] + VirtualMFADeviceName: virtualMFADeviceName + Tags: Optional[tagListType] + + +class VirtualMFADevice(TypedDict, total=False): + SerialNumber: serialNumberType + Base32StringSeed: Optional[BootstrapDatum] + QRCodePNG: Optional[BootstrapDatum] + User: Optional[User] + EnableDate: Optional[dateType] + Tags: Optional[tagListType] + + +class CreateVirtualMFADeviceResponse(TypedDict, total=False): + VirtualMFADevice: VirtualMFADevice + + +class DeactivateMFADeviceRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + SerialNumber: serialNumberType + + +class DeleteAccessKeyRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + AccessKeyId: accessKeyIdType + + +class DeleteAccountAliasRequest(ServiceRequest): + AccountAlias: accountAliasType + + +class DeleteGroupPolicyRequest(ServiceRequest): + GroupName: groupNameType + PolicyName: policyNameType + + +class DeleteGroupRequest(ServiceRequest): + GroupName: groupNameType + + +class DeleteInstanceProfileRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + + +class DeleteLoginProfileRequest(ServiceRequest): + UserName: Optional[userNameType] + + +class DeleteOpenIDConnectProviderRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + + +class DeletePolicyRequest(ServiceRequest): + PolicyArn: arnType + + +class DeletePolicyVersionRequest(ServiceRequest): + PolicyArn: arnType + VersionId: policyVersionIdType + + +class DeleteRolePermissionsBoundaryRequest(ServiceRequest): + RoleName: roleNameType + + +class DeleteRolePolicyRequest(ServiceRequest): + RoleName: roleNameType + PolicyName: policyNameType + + +class DeleteRoleRequest(ServiceRequest): + RoleName: roleNameType + + +class DeleteSAMLProviderRequest(ServiceRequest): + SAMLProviderArn: arnType + + +class DeleteSSHPublicKeyRequest(ServiceRequest): + UserName: userNameType + SSHPublicKeyId: publicKeyIdType + + +class DeleteServerCertificateRequest(ServiceRequest): + ServerCertificateName: serverCertificateNameType + + +class DeleteServiceLinkedRoleRequest(ServiceRequest): + RoleName: roleNameType + + +class DeleteServiceLinkedRoleResponse(TypedDict, total=False): + DeletionTaskId: DeletionTaskIdType + + +class DeleteServiceSpecificCredentialRequest(ServiceRequest): + UserName: Optional[userNameType] + ServiceSpecificCredentialId: serviceSpecificCredentialId + + +class DeleteSigningCertificateRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + CertificateId: certificateIdType + + +class DeleteUserPermissionsBoundaryRequest(ServiceRequest): + UserName: userNameType + + +class DeleteUserPolicyRequest(ServiceRequest): + UserName: existingUserNameType + PolicyName: policyNameType + + +class DeleteUserRequest(ServiceRequest): + UserName: existingUserNameType + + +class DeleteVirtualMFADeviceRequest(ServiceRequest): + SerialNumber: serialNumberType + + +class RoleUsageType(TypedDict, total=False): + Region: Optional[RegionNameType] + Resources: Optional[ArnListType] + + +RoleUsageListType = List[RoleUsageType] + + +class DeletionTaskFailureReasonType(TypedDict, total=False): + Reason: Optional[ReasonType] + RoleUsageList: Optional[RoleUsageListType] + + +class DetachGroupPolicyRequest(ServiceRequest): + GroupName: groupNameType + PolicyArn: arnType + + +class DetachRolePolicyRequest(ServiceRequest): + RoleName: roleNameType + PolicyArn: arnType + + +class DetachUserPolicyRequest(ServiceRequest): + UserName: userNameType + PolicyArn: arnType + + +class DisableOrganizationsRootCredentialsManagementRequest(ServiceRequest): + pass + + +FeaturesListType = List[FeatureType] + + +class DisableOrganizationsRootCredentialsManagementResponse(TypedDict, total=False): + OrganizationId: Optional[OrganizationIdType] + EnabledFeatures: Optional[FeaturesListType] + + +class DisableOrganizationsRootSessionsRequest(ServiceRequest): + pass + + +class DisableOrganizationsRootSessionsResponse(TypedDict, total=False): + OrganizationId: Optional[OrganizationIdType] + EnabledFeatures: Optional[FeaturesListType] + + +class EnableMFADeviceRequest(ServiceRequest): + UserName: existingUserNameType + SerialNumber: serialNumberType + AuthenticationCode1: authenticationCodeType + AuthenticationCode2: authenticationCodeType + + +class EnableOrganizationsRootCredentialsManagementRequest(ServiceRequest): + pass + + +class EnableOrganizationsRootCredentialsManagementResponse(TypedDict, total=False): + OrganizationId: Optional[OrganizationIdType] + EnabledFeatures: Optional[FeaturesListType] + + +class EnableOrganizationsRootSessionsRequest(ServiceRequest): + pass + + +class EnableOrganizationsRootSessionsResponse(TypedDict, total=False): + OrganizationId: Optional[OrganizationIdType] + EnabledFeatures: Optional[FeaturesListType] + + +class EntityInfo(TypedDict, total=False): + Arn: arnType + Name: userNameType + Type: policyOwnerEntityType + Id: idType + Path: Optional[pathType] + + +class EntityDetails(TypedDict, total=False): + EntityInfo: EntityInfo + LastAuthenticated: Optional[dateType] + + +class ErrorDetails(TypedDict, total=False): + Message: stringType + Code: stringType + + +EvalDecisionDetailsType = Dict[EvalDecisionSourceType, PolicyEvaluationDecisionType] + + +class PermissionsBoundaryDecisionDetail(TypedDict, total=False): + AllowedByPermissionsBoundary: Optional[booleanType] + + +class Position(TypedDict, total=False): + Line: Optional[LineNumber] + Column: Optional[ColumnNumber] + + +class Statement(TypedDict, total=False): + SourcePolicyId: Optional[PolicyIdentifierType] + SourcePolicyType: Optional[PolicySourceType] + StartPosition: Optional[Position] + EndPosition: Optional[Position] + + +StatementListType = List[Statement] + + +class ResourceSpecificResult(TypedDict, total=False): + EvalResourceName: ResourceNameType + EvalResourceDecision: PolicyEvaluationDecisionType + MatchedStatements: Optional[StatementListType] + MissingContextValues: Optional[ContextKeyNamesResultListType] + EvalDecisionDetails: Optional[EvalDecisionDetailsType] + PermissionsBoundaryDecisionDetail: Optional[PermissionsBoundaryDecisionDetail] + + +ResourceSpecificResultListType = List[ResourceSpecificResult] + + +class OrganizationsDecisionDetail(TypedDict, total=False): + AllowedByOrganizations: Optional[booleanType] + + +class EvaluationResult(TypedDict, total=False): + EvalActionName: ActionNameType + EvalResourceName: Optional[ResourceNameType] + EvalDecision: PolicyEvaluationDecisionType + MatchedStatements: Optional[StatementListType] + MissingContextValues: Optional[ContextKeyNamesResultListType] + OrganizationsDecisionDetail: Optional[OrganizationsDecisionDetail] + PermissionsBoundaryDecisionDetail: Optional[PermissionsBoundaryDecisionDetail] + EvalDecisionDetails: Optional[EvalDecisionDetailsType] + ResourceSpecificResults: Optional[ResourceSpecificResultListType] + + +EvaluationResultsListType = List[EvaluationResult] + + +class GenerateCredentialReportResponse(TypedDict, total=False): + State: Optional[ReportStateType] + Description: Optional[ReportStateDescriptionType] + + +class GenerateOrganizationsAccessReportRequest(ServiceRequest): + EntityPath: organizationsEntityPathType + OrganizationsPolicyId: Optional[organizationsPolicyIdType] + + +class GenerateOrganizationsAccessReportResponse(TypedDict, total=False): + JobId: Optional[jobIDType] + + +class GenerateServiceLastAccessedDetailsRequest(ServiceRequest): + Arn: arnType + Granularity: Optional[AccessAdvisorUsageGranularityType] + + +class GenerateServiceLastAccessedDetailsResponse(TypedDict, total=False): + JobId: Optional[jobIDType] + + +class GetAccessKeyLastUsedRequest(ServiceRequest): + AccessKeyId: accessKeyIdType + + +class GetAccessKeyLastUsedResponse(TypedDict, total=False): + UserName: Optional[existingUserNameType] + AccessKeyLastUsed: Optional[AccessKeyLastUsed] + + +entityListType = List[EntityType] + + +class GetAccountAuthorizationDetailsRequest(ServiceRequest): + Filter: Optional[entityListType] + MaxItems: Optional[maxItemsType] + Marker: Optional[markerType] + + +policyDocumentVersionListType = List[PolicyVersion] + + +class ManagedPolicyDetail(TypedDict, total=False): + PolicyName: Optional[policyNameType] + PolicyId: Optional[idType] + Arn: Optional[arnType] + Path: Optional[policyPathType] + DefaultVersionId: Optional[policyVersionIdType] + AttachmentCount: Optional[attachmentCountType] + PermissionsBoundaryUsageCount: Optional[attachmentCountType] + IsAttachable: Optional[booleanType] + Description: Optional[policyDescriptionType] + CreateDate: Optional[dateType] + UpdateDate: Optional[dateType] + PolicyVersionList: Optional[policyDocumentVersionListType] + + +ManagedPolicyDetailListType = List[ManagedPolicyDetail] +attachedPoliciesListType = List[AttachedPolicy] + + +class PolicyDetail(TypedDict, total=False): + PolicyName: Optional[policyNameType] + PolicyDocument: Optional[policyDocumentType] + + +policyDetailListType = List[PolicyDetail] +instanceProfileListType = List[InstanceProfile] + + +class RoleDetail(TypedDict, total=False): + Path: Optional[pathType] + RoleName: Optional[roleNameType] + RoleId: Optional[idType] + Arn: Optional[arnType] + CreateDate: Optional[dateType] + AssumeRolePolicyDocument: Optional[policyDocumentType] + InstanceProfileList: Optional[instanceProfileListType] + RolePolicyList: Optional[policyDetailListType] + AttachedManagedPolicies: Optional[attachedPoliciesListType] + PermissionsBoundary: Optional[AttachedPermissionsBoundary] + Tags: Optional[tagListType] + RoleLastUsed: Optional[RoleLastUsed] + + +roleDetailListType = List[RoleDetail] + + +class GroupDetail(TypedDict, total=False): + Path: Optional[pathType] + GroupName: Optional[groupNameType] + GroupId: Optional[idType] + Arn: Optional[arnType] + CreateDate: Optional[dateType] + GroupPolicyList: Optional[policyDetailListType] + AttachedManagedPolicies: Optional[attachedPoliciesListType] + + +groupDetailListType = List[GroupDetail] +groupNameListType = List[groupNameType] + + +class UserDetail(TypedDict, total=False): + Path: Optional[pathType] + UserName: Optional[userNameType] + UserId: Optional[idType] + Arn: Optional[arnType] + CreateDate: Optional[dateType] + UserPolicyList: Optional[policyDetailListType] + GroupList: Optional[groupNameListType] + AttachedManagedPolicies: Optional[attachedPoliciesListType] + PermissionsBoundary: Optional[AttachedPermissionsBoundary] + Tags: Optional[tagListType] + + +userDetailListType = List[UserDetail] + + +class GetAccountAuthorizationDetailsResponse(TypedDict, total=False): + UserDetailList: Optional[userDetailListType] + GroupDetailList: Optional[groupDetailListType] + RoleDetailList: Optional[roleDetailListType] + Policies: Optional[ManagedPolicyDetailListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class PasswordPolicy(TypedDict, total=False): + MinimumPasswordLength: Optional[minimumPasswordLengthType] + RequireSymbols: Optional[booleanType] + RequireNumbers: Optional[booleanType] + RequireUppercaseCharacters: Optional[booleanType] + RequireLowercaseCharacters: Optional[booleanType] + AllowUsersToChangePassword: Optional[booleanType] + ExpirePasswords: Optional[booleanType] + MaxPasswordAge: Optional[maxPasswordAgeType] + PasswordReusePrevention: Optional[passwordReusePreventionType] + HardExpiry: Optional[booleanObjectType] + + +class GetAccountPasswordPolicyResponse(TypedDict, total=False): + PasswordPolicy: PasswordPolicy + + +summaryMapType = Dict[summaryKeyType, summaryValueType] + + +class GetAccountSummaryResponse(TypedDict, total=False): + SummaryMap: Optional[summaryMapType] + + +SimulationPolicyListType = List[policyDocumentType] + + +class GetContextKeysForCustomPolicyRequest(ServiceRequest): + PolicyInputList: SimulationPolicyListType + + +class GetContextKeysForPolicyResponse(TypedDict, total=False): + ContextKeyNames: Optional[ContextKeyNamesResultListType] + + +class GetContextKeysForPrincipalPolicyRequest(ServiceRequest): + PolicySourceArn: arnType + PolicyInputList: Optional[SimulationPolicyListType] + + +ReportContentType = bytes + + +class GetCredentialReportResponse(TypedDict, total=False): + Content: Optional[ReportContentType] + ReportFormat: Optional[ReportFormatType] + GeneratedTime: Optional[dateType] + + +class GetGroupPolicyRequest(ServiceRequest): + GroupName: groupNameType + PolicyName: policyNameType + + +class GetGroupPolicyResponse(TypedDict, total=False): + GroupName: groupNameType + PolicyName: policyNameType + PolicyDocument: policyDocumentType + + +class GetGroupRequest(ServiceRequest): + GroupName: groupNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +userListType = List[User] + + +class GetGroupResponse(TypedDict, total=False): + Group: Group + Users: userListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class GetInstanceProfileRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + + +class GetInstanceProfileResponse(TypedDict, total=False): + InstanceProfile: InstanceProfile + + +class GetLoginProfileRequest(ServiceRequest): + UserName: Optional[userNameType] + + +class GetLoginProfileResponse(TypedDict, total=False): + LoginProfile: LoginProfile + + +class GetMFADeviceRequest(ServiceRequest): + SerialNumber: serialNumberType + UserName: Optional[userNameType] + + +class GetMFADeviceResponse(TypedDict, total=False): + UserName: Optional[userNameType] + SerialNumber: serialNumberType + EnableDate: Optional[dateType] + Certifications: Optional[CertificationMapType] + + +class GetOpenIDConnectProviderRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + + +class GetOpenIDConnectProviderResponse(TypedDict, total=False): + Url: Optional[OpenIDConnectProviderUrlType] + ClientIDList: Optional[clientIDListType] + ThumbprintList: Optional[thumbprintListType] + CreateDate: Optional[dateType] + Tags: Optional[tagListType] + + +class GetOrganizationsAccessReportRequest(ServiceRequest): + JobId: jobIDType + MaxItems: Optional[maxItemsType] + Marker: Optional[markerType] + SortKey: Optional[sortKeyType] + + +class GetOrganizationsAccessReportResponse(TypedDict, total=False): + JobStatus: jobStatusType + JobCreationDate: dateType + JobCompletionDate: Optional[dateType] + NumberOfServicesAccessible: Optional[integerType] + NumberOfServicesNotAccessed: Optional[integerType] + AccessDetails: Optional[AccessDetails] + IsTruncated: Optional[booleanType] + Marker: Optional[markerType] + ErrorDetails: Optional[ErrorDetails] + + +class GetPolicyRequest(ServiceRequest): + PolicyArn: arnType + + +class GetPolicyResponse(TypedDict, total=False): + Policy: Optional[Policy] + + +class GetPolicyVersionRequest(ServiceRequest): + PolicyArn: arnType + VersionId: policyVersionIdType + + +class GetPolicyVersionResponse(TypedDict, total=False): + PolicyVersion: Optional[PolicyVersion] + + +class GetRolePolicyRequest(ServiceRequest): + RoleName: roleNameType + PolicyName: policyNameType + + +class GetRolePolicyResponse(TypedDict, total=False): + RoleName: roleNameType + PolicyName: policyNameType + PolicyDocument: policyDocumentType + + +class GetRoleRequest(ServiceRequest): + RoleName: roleNameType + + +class GetRoleResponse(TypedDict, total=False): + Role: Role + + +class GetSAMLProviderRequest(ServiceRequest): + SAMLProviderArn: arnType + + +class SAMLPrivateKey(TypedDict, total=False): + KeyId: Optional[privateKeyIdType] + Timestamp: Optional[dateType] + + +privateKeyList = List[SAMLPrivateKey] + + +class GetSAMLProviderResponse(TypedDict, total=False): + SAMLProviderUUID: Optional[privateKeyIdType] + SAMLMetadataDocument: Optional[SAMLMetadataDocumentType] + CreateDate: Optional[dateType] + ValidUntil: Optional[dateType] + Tags: Optional[tagListType] + AssertionEncryptionMode: Optional[assertionEncryptionModeType] + PrivateKeyList: Optional[privateKeyList] + + +class GetSSHPublicKeyRequest(ServiceRequest): + UserName: userNameType + SSHPublicKeyId: publicKeyIdType + Encoding: encodingType + + +class SSHPublicKey(TypedDict, total=False): + UserName: userNameType + SSHPublicKeyId: publicKeyIdType + Fingerprint: publicKeyFingerprintType + SSHPublicKeyBody: publicKeyMaterialType + Status: statusType + UploadDate: Optional[dateType] + + +class GetSSHPublicKeyResponse(TypedDict, total=False): + SSHPublicKey: Optional[SSHPublicKey] + + +class GetServerCertificateRequest(ServiceRequest): + ServerCertificateName: serverCertificateNameType + + +class ServerCertificateMetadata(TypedDict, total=False): + Path: pathType + ServerCertificateName: serverCertificateNameType + ServerCertificateId: idType + Arn: arnType + UploadDate: Optional[dateType] + Expiration: Optional[dateType] + + +class ServerCertificate(TypedDict, total=False): + ServerCertificateMetadata: ServerCertificateMetadata + CertificateBody: certificateBodyType + CertificateChain: Optional[certificateChainType] + Tags: Optional[tagListType] + + +class GetServerCertificateResponse(TypedDict, total=False): + ServerCertificate: ServerCertificate + + +class GetServiceLastAccessedDetailsRequest(ServiceRequest): + JobId: jobIDType + MaxItems: Optional[maxItemsType] + Marker: Optional[markerType] + + +class TrackedActionLastAccessed(TypedDict, total=False): + ActionName: Optional[stringType] + LastAccessedEntity: Optional[arnType] + LastAccessedTime: Optional[dateType] + LastAccessedRegion: Optional[stringType] + + +TrackedActionsLastAccessed = List[TrackedActionLastAccessed] + + +class ServiceLastAccessed(TypedDict, total=False): + ServiceName: serviceNameType + LastAuthenticated: Optional[dateType] + ServiceNamespace: serviceNamespaceType + LastAuthenticatedEntity: Optional[arnType] + LastAuthenticatedRegion: Optional[stringType] + TotalAuthenticatedEntities: Optional[integerType] + TrackedActionsLastAccessed: Optional[TrackedActionsLastAccessed] + + +ServicesLastAccessed = List[ServiceLastAccessed] + + +class GetServiceLastAccessedDetailsResponse(TypedDict, total=False): + JobStatus: jobStatusType + JobType: Optional[AccessAdvisorUsageGranularityType] + JobCreationDate: dateType + ServicesLastAccessed: ServicesLastAccessed + JobCompletionDate: dateType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + Error: Optional[ErrorDetails] + + +class GetServiceLastAccessedDetailsWithEntitiesRequest(ServiceRequest): + JobId: jobIDType + ServiceNamespace: serviceNamespaceType + MaxItems: Optional[maxItemsType] + Marker: Optional[markerType] + + +entityDetailsListType = List[EntityDetails] + + +class GetServiceLastAccessedDetailsWithEntitiesResponse(TypedDict, total=False): + JobStatus: jobStatusType + JobCreationDate: dateType + JobCompletionDate: dateType + EntityDetailsList: entityDetailsListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + Error: Optional[ErrorDetails] + + +class GetServiceLinkedRoleDeletionStatusRequest(ServiceRequest): + DeletionTaskId: DeletionTaskIdType + + +class GetServiceLinkedRoleDeletionStatusResponse(TypedDict, total=False): + Status: DeletionTaskStatusType + Reason: Optional[DeletionTaskFailureReasonType] + + +class GetUserPolicyRequest(ServiceRequest): + UserName: existingUserNameType + PolicyName: policyNameType + + +class GetUserPolicyResponse(TypedDict, total=False): + UserName: existingUserNameType + PolicyName: policyNameType + PolicyDocument: policyDocumentType + + +class GetUserRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + + +class GetUserResponse(TypedDict, total=False): + User: User + + +class ListAccessKeysRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +accessKeyMetadataListType = List[AccessKeyMetadata] + + +class ListAccessKeysResponse(TypedDict, total=False): + AccessKeyMetadata: accessKeyMetadataListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListAccountAliasesRequest(ServiceRequest): + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +accountAliasListType = List[accountAliasType] + + +class ListAccountAliasesResponse(TypedDict, total=False): + AccountAliases: accountAliasListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListAttachedGroupPoliciesRequest(ServiceRequest): + GroupName: groupNameType + PathPrefix: Optional[policyPathType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListAttachedGroupPoliciesResponse(TypedDict, total=False): + AttachedPolicies: Optional[attachedPoliciesListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListAttachedRolePoliciesRequest(ServiceRequest): + RoleName: roleNameType + PathPrefix: Optional[policyPathType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListAttachedRolePoliciesResponse(TypedDict, total=False): + AttachedPolicies: Optional[attachedPoliciesListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListAttachedUserPoliciesRequest(ServiceRequest): + UserName: userNameType + PathPrefix: Optional[policyPathType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListAttachedUserPoliciesResponse(TypedDict, total=False): + AttachedPolicies: Optional[attachedPoliciesListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListEntitiesForPolicyRequest(ServiceRequest): + PolicyArn: arnType + EntityFilter: Optional[EntityType] + PathPrefix: Optional[pathType] + PolicyUsageFilter: Optional[PolicyUsageType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class PolicyRole(TypedDict, total=False): + RoleName: Optional[roleNameType] + RoleId: Optional[idType] + + +PolicyRoleListType = List[PolicyRole] + + +class PolicyUser(TypedDict, total=False): + UserName: Optional[userNameType] + UserId: Optional[idType] + + +PolicyUserListType = List[PolicyUser] + + +class PolicyGroup(TypedDict, total=False): + GroupName: Optional[groupNameType] + GroupId: Optional[idType] + + +PolicyGroupListType = List[PolicyGroup] + + +class ListEntitiesForPolicyResponse(TypedDict, total=False): + PolicyGroups: Optional[PolicyGroupListType] + PolicyUsers: Optional[PolicyUserListType] + PolicyRoles: Optional[PolicyRoleListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListGroupPoliciesRequest(ServiceRequest): + GroupName: groupNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +policyNameListType = List[policyNameType] + + +class ListGroupPoliciesResponse(TypedDict, total=False): + PolicyNames: policyNameListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListGroupsForUserRequest(ServiceRequest): + UserName: existingUserNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +groupListType = List[Group] + + +class ListGroupsForUserResponse(TypedDict, total=False): + Groups: groupListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListGroupsRequest(ServiceRequest): + PathPrefix: Optional[pathPrefixType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListGroupsResponse(TypedDict, total=False): + Groups: groupListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListInstanceProfileTagsRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListInstanceProfileTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListInstanceProfilesForRoleRequest(ServiceRequest): + RoleName: roleNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListInstanceProfilesForRoleResponse(TypedDict, total=False): + InstanceProfiles: instanceProfileListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListInstanceProfilesRequest(ServiceRequest): + PathPrefix: Optional[pathPrefixType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListInstanceProfilesResponse(TypedDict, total=False): + InstanceProfiles: instanceProfileListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListMFADeviceTagsRequest(ServiceRequest): + SerialNumber: serialNumberType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListMFADeviceTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListMFADevicesRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class MFADevice(TypedDict, total=False): + UserName: userNameType + SerialNumber: serialNumberType + EnableDate: dateType + + +mfaDeviceListType = List[MFADevice] + + +class ListMFADevicesResponse(TypedDict, total=False): + MFADevices: mfaDeviceListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListOpenIDConnectProviderTagsRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListOpenIDConnectProviderTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListOpenIDConnectProvidersRequest(ServiceRequest): + pass + + +class OpenIDConnectProviderListEntry(TypedDict, total=False): + Arn: Optional[arnType] + + +OpenIDConnectProviderListType = List[OpenIDConnectProviderListEntry] + + +class ListOpenIDConnectProvidersResponse(TypedDict, total=False): + OpenIDConnectProviderList: Optional[OpenIDConnectProviderListType] + + +class ListOrganizationsFeaturesRequest(ServiceRequest): + pass + + +class ListOrganizationsFeaturesResponse(TypedDict, total=False): + OrganizationId: Optional[OrganizationIdType] + EnabledFeatures: Optional[FeaturesListType] + + +class PolicyGrantingServiceAccess(TypedDict, total=False): + PolicyName: policyNameType + PolicyType: policyType + PolicyArn: Optional[arnType] + EntityType: Optional[policyOwnerEntityType] + EntityName: Optional[entityNameType] + + +policyGrantingServiceAccessListType = List[PolicyGrantingServiceAccess] + + +class ListPoliciesGrantingServiceAccessEntry(TypedDict, total=False): + ServiceNamespace: Optional[serviceNamespaceType] + Policies: Optional[policyGrantingServiceAccessListType] + + +serviceNamespaceListType = List[serviceNamespaceType] + + +class ListPoliciesGrantingServiceAccessRequest(ServiceRequest): + Marker: Optional[markerType] + Arn: arnType + ServiceNamespaces: serviceNamespaceListType + + +listPolicyGrantingServiceAccessResponseListType = List[ListPoliciesGrantingServiceAccessEntry] + + +class ListPoliciesGrantingServiceAccessResponse(TypedDict, total=False): + PoliciesGrantingServiceAccess: listPolicyGrantingServiceAccessResponseListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListPoliciesRequest(ServiceRequest): + Scope: Optional[policyScopeType] + OnlyAttached: Optional[booleanType] + PathPrefix: Optional[policyPathType] + PolicyUsageFilter: Optional[PolicyUsageType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +policyListType = List[Policy] + + +class ListPoliciesResponse(TypedDict, total=False): + Policies: Optional[policyListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListPolicyTagsRequest(ServiceRequest): + PolicyArn: arnType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListPolicyTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListPolicyVersionsRequest(ServiceRequest): + PolicyArn: arnType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListPolicyVersionsResponse(TypedDict, total=False): + Versions: Optional[policyDocumentVersionListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListRolePoliciesRequest(ServiceRequest): + RoleName: roleNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListRolePoliciesResponse(TypedDict, total=False): + PolicyNames: policyNameListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListRoleTagsRequest(ServiceRequest): + RoleName: roleNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListRoleTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListRolesRequest(ServiceRequest): + PathPrefix: Optional[pathPrefixType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListRolesResponse(TypedDict, total=False): + Roles: roleListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListSAMLProviderTagsRequest(ServiceRequest): + SAMLProviderArn: arnType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListSAMLProviderTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListSAMLProvidersRequest(ServiceRequest): + pass + + +class SAMLProviderListEntry(TypedDict, total=False): + Arn: Optional[arnType] + ValidUntil: Optional[dateType] + CreateDate: Optional[dateType] + + +SAMLProviderListType = List[SAMLProviderListEntry] + + +class ListSAMLProvidersResponse(TypedDict, total=False): + SAMLProviderList: Optional[SAMLProviderListType] + + +class ListSSHPublicKeysRequest(ServiceRequest): + UserName: Optional[userNameType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class SSHPublicKeyMetadata(TypedDict, total=False): + UserName: userNameType + SSHPublicKeyId: publicKeyIdType + Status: statusType + UploadDate: dateType + + +SSHPublicKeyListType = List[SSHPublicKeyMetadata] + + +class ListSSHPublicKeysResponse(TypedDict, total=False): + SSHPublicKeys: Optional[SSHPublicKeyListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListServerCertificateTagsRequest(ServiceRequest): + ServerCertificateName: serverCertificateNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListServerCertificateTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListServerCertificatesRequest(ServiceRequest): + PathPrefix: Optional[pathPrefixType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +serverCertificateMetadataListType = List[ServerCertificateMetadata] + + +class ListServerCertificatesResponse(TypedDict, total=False): + ServerCertificateMetadataList: serverCertificateMetadataListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListServiceSpecificCredentialsRequest(ServiceRequest): + UserName: Optional[userNameType] + ServiceName: Optional[serviceName] + AllUsers: Optional[allUsers] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ServiceSpecificCredentialMetadata(TypedDict, total=False): + UserName: userNameType + Status: statusType + ServiceUserName: Optional[serviceUserName] + ServiceCredentialAlias: Optional[serviceCredentialAlias] + CreateDate: dateType + ExpirationDate: Optional[dateType] + ServiceSpecificCredentialId: serviceSpecificCredentialId + ServiceName: serviceName + + +ServiceSpecificCredentialsListType = List[ServiceSpecificCredentialMetadata] + + +class ListServiceSpecificCredentialsResponse(TypedDict, total=False): + ServiceSpecificCredentials: Optional[ServiceSpecificCredentialsListType] + Marker: Optional[responseMarkerType] + IsTruncated: Optional[booleanType] + + +class ListSigningCertificatesRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class SigningCertificate(TypedDict, total=False): + UserName: userNameType + CertificateId: certificateIdType + CertificateBody: certificateBodyType + Status: statusType + UploadDate: Optional[dateType] + + +certificateListType = List[SigningCertificate] + + +class ListSigningCertificatesResponse(TypedDict, total=False): + Certificates: certificateListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListUserPoliciesRequest(ServiceRequest): + UserName: existingUserNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListUserPoliciesResponse(TypedDict, total=False): + PolicyNames: policyNameListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListUserTagsRequest(ServiceRequest): + UserName: existingUserNameType + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListUserTagsResponse(TypedDict, total=False): + Tags: tagListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListUsersRequest(ServiceRequest): + PathPrefix: Optional[pathPrefixType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +class ListUsersResponse(TypedDict, total=False): + Users: userListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class ListVirtualMFADevicesRequest(ServiceRequest): + AssignmentStatus: Optional[assignmentStatusType] + Marker: Optional[markerType] + MaxItems: Optional[maxItemsType] + + +virtualMFADeviceListType = List[VirtualMFADevice] + + +class ListVirtualMFADevicesResponse(TypedDict, total=False): + VirtualMFADevices: virtualMFADeviceListType + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class PutGroupPolicyRequest(ServiceRequest): + GroupName: groupNameType + PolicyName: policyNameType + PolicyDocument: policyDocumentType + + +class PutRolePermissionsBoundaryRequest(ServiceRequest): + RoleName: roleNameType + PermissionsBoundary: arnType + + +class PutRolePolicyRequest(ServiceRequest): + RoleName: roleNameType + PolicyName: policyNameType + PolicyDocument: policyDocumentType + + +class PutUserPermissionsBoundaryRequest(ServiceRequest): + UserName: userNameType + PermissionsBoundary: arnType + + +class PutUserPolicyRequest(ServiceRequest): + UserName: existingUserNameType + PolicyName: policyNameType + PolicyDocument: policyDocumentType + + +class RemoveClientIDFromOpenIDConnectProviderRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + ClientID: clientIDType + + +class RemoveRoleFromInstanceProfileRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + RoleName: roleNameType + + +class RemoveUserFromGroupRequest(ServiceRequest): + GroupName: groupNameType + UserName: existingUserNameType + + +class ResetServiceSpecificCredentialRequest(ServiceRequest): + UserName: Optional[userNameType] + ServiceSpecificCredentialId: serviceSpecificCredentialId + + +class ResetServiceSpecificCredentialResponse(TypedDict, total=False): + ServiceSpecificCredential: Optional[ServiceSpecificCredential] + + +ResourceNameListType = List[ResourceNameType] + + +class ResyncMFADeviceRequest(ServiceRequest): + UserName: existingUserNameType + SerialNumber: serialNumberType + AuthenticationCode1: authenticationCodeType + AuthenticationCode2: authenticationCodeType + + +class SetDefaultPolicyVersionRequest(ServiceRequest): + PolicyArn: arnType + VersionId: policyVersionIdType + + +class SetSecurityTokenServicePreferencesRequest(ServiceRequest): + GlobalEndpointTokenVersion: globalEndpointTokenVersion + + +class SimulateCustomPolicyRequest(ServiceRequest): + PolicyInputList: SimulationPolicyListType + PermissionsBoundaryPolicyInputList: Optional[SimulationPolicyListType] + ActionNames: ActionNameListType + ResourceArns: Optional[ResourceNameListType] + ResourcePolicy: Optional[policyDocumentType] + ResourceOwner: Optional[ResourceNameType] + CallerArn: Optional[ResourceNameType] + ContextEntries: Optional[ContextEntryListType] + ResourceHandlingOption: Optional[ResourceHandlingOptionType] + MaxItems: Optional[maxItemsType] + Marker: Optional[markerType] + + +class SimulatePolicyResponse(TypedDict, total=False): + EvaluationResults: Optional[EvaluationResultsListType] + IsTruncated: Optional[booleanType] + Marker: Optional[responseMarkerType] + + +class SimulatePrincipalPolicyRequest(ServiceRequest): + PolicySourceArn: arnType + PolicyInputList: Optional[SimulationPolicyListType] + PermissionsBoundaryPolicyInputList: Optional[SimulationPolicyListType] + ActionNames: ActionNameListType + ResourceArns: Optional[ResourceNameListType] + ResourcePolicy: Optional[policyDocumentType] + ResourceOwner: Optional[ResourceNameType] + CallerArn: Optional[ResourceNameType] + ContextEntries: Optional[ContextEntryListType] + ResourceHandlingOption: Optional[ResourceHandlingOptionType] + MaxItems: Optional[maxItemsType] + Marker: Optional[markerType] + + +class TagInstanceProfileRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + Tags: tagListType + + +class TagMFADeviceRequest(ServiceRequest): + SerialNumber: serialNumberType + Tags: tagListType + + +class TagOpenIDConnectProviderRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + Tags: tagListType + + +class TagPolicyRequest(ServiceRequest): + PolicyArn: arnType + Tags: tagListType + + +class TagRoleRequest(ServiceRequest): + RoleName: roleNameType + Tags: tagListType + + +class TagSAMLProviderRequest(ServiceRequest): + SAMLProviderArn: arnType + Tags: tagListType + + +class TagServerCertificateRequest(ServiceRequest): + ServerCertificateName: serverCertificateNameType + Tags: tagListType + + +class TagUserRequest(ServiceRequest): + UserName: existingUserNameType + Tags: tagListType + + +tagKeyListType = List[tagKeyType] + + +class UntagInstanceProfileRequest(ServiceRequest): + InstanceProfileName: instanceProfileNameType + TagKeys: tagKeyListType + + +class UntagMFADeviceRequest(ServiceRequest): + SerialNumber: serialNumberType + TagKeys: tagKeyListType + + +class UntagOpenIDConnectProviderRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + TagKeys: tagKeyListType + + +class UntagPolicyRequest(ServiceRequest): + PolicyArn: arnType + TagKeys: tagKeyListType + + +class UntagRoleRequest(ServiceRequest): + RoleName: roleNameType + TagKeys: tagKeyListType + + +class UntagSAMLProviderRequest(ServiceRequest): + SAMLProviderArn: arnType + TagKeys: tagKeyListType + + +class UntagServerCertificateRequest(ServiceRequest): + ServerCertificateName: serverCertificateNameType + TagKeys: tagKeyListType + + +class UntagUserRequest(ServiceRequest): + UserName: existingUserNameType + TagKeys: tagKeyListType + + +class UpdateAccessKeyRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + AccessKeyId: accessKeyIdType + Status: statusType + + +class UpdateAccountPasswordPolicyRequest(ServiceRequest): + MinimumPasswordLength: Optional[minimumPasswordLengthType] + RequireSymbols: Optional[booleanType] + RequireNumbers: Optional[booleanType] + RequireUppercaseCharacters: Optional[booleanType] + RequireLowercaseCharacters: Optional[booleanType] + AllowUsersToChangePassword: Optional[booleanType] + MaxPasswordAge: Optional[maxPasswordAgeType] + PasswordReusePrevention: Optional[passwordReusePreventionType] + HardExpiry: Optional[booleanObjectType] + + +class UpdateAssumeRolePolicyRequest(ServiceRequest): + RoleName: roleNameType + PolicyDocument: policyDocumentType + + +class UpdateGroupRequest(ServiceRequest): + GroupName: groupNameType + NewPath: Optional[pathType] + NewGroupName: Optional[groupNameType] + + +class UpdateLoginProfileRequest(ServiceRequest): + UserName: userNameType + Password: Optional[passwordType] + PasswordResetRequired: Optional[booleanObjectType] + + +class UpdateOpenIDConnectProviderThumbprintRequest(ServiceRequest): + OpenIDConnectProviderArn: arnType + ThumbprintList: thumbprintListType + + +class UpdateRoleDescriptionRequest(ServiceRequest): + RoleName: roleNameType + Description: roleDescriptionType + + +class UpdateRoleDescriptionResponse(TypedDict, total=False): + Role: Optional[Role] + + +class UpdateRoleRequest(ServiceRequest): + RoleName: roleNameType + Description: Optional[roleDescriptionType] + MaxSessionDuration: Optional[roleMaxSessionDurationType] + + +class UpdateRoleResponse(TypedDict, total=False): + pass + + +class UpdateSAMLProviderRequest(ServiceRequest): + SAMLMetadataDocument: Optional[SAMLMetadataDocumentType] + SAMLProviderArn: arnType + AssertionEncryptionMode: Optional[assertionEncryptionModeType] + AddPrivateKey: Optional[privateKeyType] + RemovePrivateKey: Optional[privateKeyIdType] + + +class UpdateSAMLProviderResponse(TypedDict, total=False): + SAMLProviderArn: Optional[arnType] + + +class UpdateSSHPublicKeyRequest(ServiceRequest): + UserName: userNameType + SSHPublicKeyId: publicKeyIdType + Status: statusType + + +class UpdateServerCertificateRequest(ServiceRequest): + ServerCertificateName: serverCertificateNameType + NewPath: Optional[pathType] + NewServerCertificateName: Optional[serverCertificateNameType] + + +class UpdateServiceSpecificCredentialRequest(ServiceRequest): + UserName: Optional[userNameType] + ServiceSpecificCredentialId: serviceSpecificCredentialId + Status: statusType + + +class UpdateSigningCertificateRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + CertificateId: certificateIdType + Status: statusType + + +class UpdateUserRequest(ServiceRequest): + UserName: existingUserNameType + NewPath: Optional[pathType] + NewUserName: Optional[userNameType] + + +class UploadSSHPublicKeyRequest(ServiceRequest): + UserName: userNameType + SSHPublicKeyBody: publicKeyMaterialType + + +class UploadSSHPublicKeyResponse(TypedDict, total=False): + SSHPublicKey: Optional[SSHPublicKey] + + +class UploadServerCertificateRequest(ServiceRequest): + Path: Optional[pathType] + ServerCertificateName: serverCertificateNameType + CertificateBody: certificateBodyType + PrivateKey: privateKeyType + CertificateChain: Optional[certificateChainType] + Tags: Optional[tagListType] + + +class UploadServerCertificateResponse(TypedDict, total=False): + ServerCertificateMetadata: Optional[ServerCertificateMetadata] + Tags: Optional[tagListType] + + +class UploadSigningCertificateRequest(ServiceRequest): + UserName: Optional[existingUserNameType] + CertificateBody: certificateBodyType + + +class UploadSigningCertificateResponse(TypedDict, total=False): + Certificate: SigningCertificate + + +class IamApi: + service = "iam" + version = "2010-05-08" + + @handler("AddClientIDToOpenIDConnectProvider") + def add_client_id_to_open_id_connect_provider( + self, + context: RequestContext, + open_id_connect_provider_arn: arnType, + client_id: clientIDType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("AddRoleToInstanceProfile") + def add_role_to_instance_profile( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + role_name: roleNameType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("AddUserToGroup") + def add_user_to_group( + self, + context: RequestContext, + group_name: groupNameType, + user_name: existingUserNameType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("AttachGroupPolicy") + def attach_group_policy( + self, context: RequestContext, group_name: groupNameType, policy_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("AttachRolePolicy") + def attach_role_policy( + self, context: RequestContext, role_name: roleNameType, policy_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("AttachUserPolicy") + def attach_user_policy( + self, context: RequestContext, user_name: userNameType, policy_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("ChangePassword") + def change_password( + self, + context: RequestContext, + old_password: passwordType, + new_password: passwordType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateAccessKey") + def create_access_key( + self, context: RequestContext, user_name: existingUserNameType | None = None, **kwargs + ) -> CreateAccessKeyResponse: + raise NotImplementedError + + @handler("CreateAccountAlias") + def create_account_alias( + self, context: RequestContext, account_alias: accountAliasType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("CreateGroup") + def create_group( + self, + context: RequestContext, + group_name: groupNameType, + path: pathType | None = None, + **kwargs, + ) -> CreateGroupResponse: + raise NotImplementedError + + @handler("CreateInstanceProfile") + def create_instance_profile( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + path: pathType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> CreateInstanceProfileResponse: + raise NotImplementedError + + @handler("CreateLoginProfile") + def create_login_profile( + self, + context: RequestContext, + user_name: userNameType | None = None, + password: passwordType | None = None, + password_reset_required: booleanType | None = None, + **kwargs, + ) -> CreateLoginProfileResponse: + raise NotImplementedError + + @handler("CreateOpenIDConnectProvider") + def create_open_id_connect_provider( + self, + context: RequestContext, + url: OpenIDConnectProviderUrlType, + client_id_list: clientIDListType | None = None, + thumbprint_list: thumbprintListType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> CreateOpenIDConnectProviderResponse: + raise NotImplementedError + + @handler("CreatePolicy") + def create_policy( + self, + context: RequestContext, + policy_name: policyNameType, + policy_document: policyDocumentType, + path: policyPathType | None = None, + description: policyDescriptionType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> CreatePolicyResponse: + raise NotImplementedError + + @handler("CreatePolicyVersion") + def create_policy_version( + self, + context: RequestContext, + policy_arn: arnType, + policy_document: policyDocumentType, + set_as_default: booleanType | None = None, + **kwargs, + ) -> CreatePolicyVersionResponse: + raise NotImplementedError + + @handler("CreateRole") + def create_role( + self, + context: RequestContext, + role_name: roleNameType, + assume_role_policy_document: policyDocumentType, + path: pathType | None = None, + description: roleDescriptionType | None = None, + max_session_duration: roleMaxSessionDurationType | None = None, + permissions_boundary: arnType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> CreateRoleResponse: + raise NotImplementedError + + @handler("CreateSAMLProvider") + def create_saml_provider( + self, + context: RequestContext, + saml_metadata_document: SAMLMetadataDocumentType, + name: SAMLProviderNameType, + tags: tagListType | None = None, + assertion_encryption_mode: assertionEncryptionModeType | None = None, + add_private_key: privateKeyType | None = None, + **kwargs, + ) -> CreateSAMLProviderResponse: + raise NotImplementedError + + @handler("CreateServiceLinkedRole") + def create_service_linked_role( + self, + context: RequestContext, + aws_service_name: groupNameType, + description: roleDescriptionType | None = None, + custom_suffix: customSuffixType | None = None, + **kwargs, + ) -> CreateServiceLinkedRoleResponse: + raise NotImplementedError + + @handler("CreateServiceSpecificCredential") + def create_service_specific_credential( + self, + context: RequestContext, + user_name: userNameType, + service_name: serviceName, + credential_age_days: credentialAgeDays | None = None, + **kwargs, + ) -> CreateServiceSpecificCredentialResponse: + raise NotImplementedError + + @handler("CreateUser") + def create_user( + self, + context: RequestContext, + user_name: userNameType, + path: pathType | None = None, + permissions_boundary: arnType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> CreateUserResponse: + raise NotImplementedError + + @handler("CreateVirtualMFADevice") + def create_virtual_mfa_device( + self, + context: RequestContext, + virtual_mfa_device_name: virtualMFADeviceName, + path: pathType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> CreateVirtualMFADeviceResponse: + raise NotImplementedError + + @handler("DeactivateMFADevice") + def deactivate_mfa_device( + self, + context: RequestContext, + serial_number: serialNumberType, + user_name: existingUserNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessKey") + def delete_access_key( + self, + context: RequestContext, + access_key_id: accessKeyIdType, + user_name: existingUserNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccountAlias") + def delete_account_alias( + self, context: RequestContext, account_alias: accountAliasType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteAccountPasswordPolicy") + def delete_account_password_policy(self, context: RequestContext, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteGroup") + def delete_group(self, context: RequestContext, group_name: groupNameType, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteGroupPolicy") + def delete_group_policy( + self, + context: RequestContext, + group_name: groupNameType, + policy_name: policyNameType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteInstanceProfile") + def delete_instance_profile( + self, context: RequestContext, instance_profile_name: instanceProfileNameType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteLoginProfile") + def delete_login_profile( + self, context: RequestContext, user_name: userNameType | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteOpenIDConnectProvider") + def delete_open_id_connect_provider( + self, context: RequestContext, open_id_connect_provider_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeletePolicy") + def delete_policy(self, context: RequestContext, policy_arn: arnType, **kwargs) -> None: + raise NotImplementedError + + @handler("DeletePolicyVersion") + def delete_policy_version( + self, + context: RequestContext, + policy_arn: arnType, + version_id: policyVersionIdType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteRole") + def delete_role(self, context: RequestContext, role_name: roleNameType, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteRolePermissionsBoundary") + def delete_role_permissions_boundary( + self, context: RequestContext, role_name: roleNameType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteRolePolicy") + def delete_role_policy( + self, + context: RequestContext, + role_name: roleNameType, + policy_name: policyNameType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteSAMLProvider") + def delete_saml_provider( + self, context: RequestContext, saml_provider_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteSSHPublicKey") + def delete_ssh_public_key( + self, + context: RequestContext, + user_name: userNameType, + ssh_public_key_id: publicKeyIdType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteServerCertificate") + def delete_server_certificate( + self, context: RequestContext, server_certificate_name: serverCertificateNameType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteServiceLinkedRole") + def delete_service_linked_role( + self, context: RequestContext, role_name: roleNameType, **kwargs + ) -> DeleteServiceLinkedRoleResponse: + raise NotImplementedError + + @handler("DeleteServiceSpecificCredential") + def delete_service_specific_credential( + self, + context: RequestContext, + service_specific_credential_id: serviceSpecificCredentialId, + user_name: userNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteSigningCertificate") + def delete_signing_certificate( + self, + context: RequestContext, + certificate_id: certificateIdType, + user_name: existingUserNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteUser") + def delete_user( + self, context: RequestContext, user_name: existingUserNameType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteUserPermissionsBoundary") + def delete_user_permissions_boundary( + self, context: RequestContext, user_name: userNameType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteUserPolicy") + def delete_user_policy( + self, + context: RequestContext, + user_name: existingUserNameType, + policy_name: policyNameType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteVirtualMFADevice") + def delete_virtual_mfa_device( + self, context: RequestContext, serial_number: serialNumberType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DetachGroupPolicy") + def detach_group_policy( + self, context: RequestContext, group_name: groupNameType, policy_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DetachRolePolicy") + def detach_role_policy( + self, context: RequestContext, role_name: roleNameType, policy_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DetachUserPolicy") + def detach_user_policy( + self, context: RequestContext, user_name: userNameType, policy_arn: arnType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DisableOrganizationsRootCredentialsManagement") + def disable_organizations_root_credentials_management( + self, context: RequestContext, **kwargs + ) -> DisableOrganizationsRootCredentialsManagementResponse: + raise NotImplementedError + + @handler("DisableOrganizationsRootSessions") + def disable_organizations_root_sessions( + self, context: RequestContext, **kwargs + ) -> DisableOrganizationsRootSessionsResponse: + raise NotImplementedError + + @handler("EnableMFADevice") + def enable_mfa_device( + self, + context: RequestContext, + user_name: existingUserNameType, + serial_number: serialNumberType, + authentication_code1: authenticationCodeType, + authentication_code2: authenticationCodeType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("EnableOrganizationsRootCredentialsManagement") + def enable_organizations_root_credentials_management( + self, context: RequestContext, **kwargs + ) -> EnableOrganizationsRootCredentialsManagementResponse: + raise NotImplementedError + + @handler("EnableOrganizationsRootSessions") + def enable_organizations_root_sessions( + self, context: RequestContext, **kwargs + ) -> EnableOrganizationsRootSessionsResponse: + raise NotImplementedError + + @handler("GenerateCredentialReport") + def generate_credential_report( + self, context: RequestContext, **kwargs + ) -> GenerateCredentialReportResponse: + raise NotImplementedError + + @handler("GenerateOrganizationsAccessReport") + def generate_organizations_access_report( + self, + context: RequestContext, + entity_path: organizationsEntityPathType, + organizations_policy_id: organizationsPolicyIdType | None = None, + **kwargs, + ) -> GenerateOrganizationsAccessReportResponse: + raise NotImplementedError + + @handler("GenerateServiceLastAccessedDetails") + def generate_service_last_accessed_details( + self, + context: RequestContext, + arn: arnType, + granularity: AccessAdvisorUsageGranularityType | None = None, + **kwargs, + ) -> GenerateServiceLastAccessedDetailsResponse: + raise NotImplementedError + + @handler("GetAccessKeyLastUsed") + def get_access_key_last_used( + self, context: RequestContext, access_key_id: accessKeyIdType, **kwargs + ) -> GetAccessKeyLastUsedResponse: + raise NotImplementedError + + @handler("GetAccountAuthorizationDetails") + def get_account_authorization_details( + self, + context: RequestContext, + filter: entityListType | None = None, + max_items: maxItemsType | None = None, + marker: markerType | None = None, + **kwargs, + ) -> GetAccountAuthorizationDetailsResponse: + raise NotImplementedError + + @handler("GetAccountPasswordPolicy") + def get_account_password_policy( + self, context: RequestContext, **kwargs + ) -> GetAccountPasswordPolicyResponse: + raise NotImplementedError + + @handler("GetAccountSummary") + def get_account_summary(self, context: RequestContext, **kwargs) -> GetAccountSummaryResponse: + raise NotImplementedError + + @handler("GetContextKeysForCustomPolicy") + def get_context_keys_for_custom_policy( + self, context: RequestContext, policy_input_list: SimulationPolicyListType, **kwargs + ) -> GetContextKeysForPolicyResponse: + raise NotImplementedError + + @handler("GetContextKeysForPrincipalPolicy") + def get_context_keys_for_principal_policy( + self, + context: RequestContext, + policy_source_arn: arnType, + policy_input_list: SimulationPolicyListType | None = None, + **kwargs, + ) -> GetContextKeysForPolicyResponse: + raise NotImplementedError + + @handler("GetCredentialReport") + def get_credential_report( + self, context: RequestContext, **kwargs + ) -> GetCredentialReportResponse: + raise NotImplementedError + + @handler("GetGroup") + def get_group( + self, + context: RequestContext, + group_name: groupNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> GetGroupResponse: + raise NotImplementedError + + @handler("GetGroupPolicy") + def get_group_policy( + self, + context: RequestContext, + group_name: groupNameType, + policy_name: policyNameType, + **kwargs, + ) -> GetGroupPolicyResponse: + raise NotImplementedError + + @handler("GetInstanceProfile") + def get_instance_profile( + self, context: RequestContext, instance_profile_name: instanceProfileNameType, **kwargs + ) -> GetInstanceProfileResponse: + raise NotImplementedError + + @handler("GetLoginProfile") + def get_login_profile( + self, context: RequestContext, user_name: userNameType | None = None, **kwargs + ) -> GetLoginProfileResponse: + raise NotImplementedError + + @handler("GetMFADevice") + def get_mfa_device( + self, + context: RequestContext, + serial_number: serialNumberType, + user_name: userNameType | None = None, + **kwargs, + ) -> GetMFADeviceResponse: + raise NotImplementedError + + @handler("GetOpenIDConnectProvider") + def get_open_id_connect_provider( + self, context: RequestContext, open_id_connect_provider_arn: arnType, **kwargs + ) -> GetOpenIDConnectProviderResponse: + raise NotImplementedError + + @handler("GetOrganizationsAccessReport") + def get_organizations_access_report( + self, + context: RequestContext, + job_id: jobIDType, + max_items: maxItemsType | None = None, + marker: markerType | None = None, + sort_key: sortKeyType | None = None, + **kwargs, + ) -> GetOrganizationsAccessReportResponse: + raise NotImplementedError + + @handler("GetPolicy") + def get_policy( + self, context: RequestContext, policy_arn: arnType, **kwargs + ) -> GetPolicyResponse: + raise NotImplementedError + + @handler("GetPolicyVersion") + def get_policy_version( + self, + context: RequestContext, + policy_arn: arnType, + version_id: policyVersionIdType, + **kwargs, + ) -> GetPolicyVersionResponse: + raise NotImplementedError + + @handler("GetRole") + def get_role( + self, context: RequestContext, role_name: roleNameType, **kwargs + ) -> GetRoleResponse: + raise NotImplementedError + + @handler("GetRolePolicy") + def get_role_policy( + self, + context: RequestContext, + role_name: roleNameType, + policy_name: policyNameType, + **kwargs, + ) -> GetRolePolicyResponse: + raise NotImplementedError + + @handler("GetSAMLProvider") + def get_saml_provider( + self, context: RequestContext, saml_provider_arn: arnType, **kwargs + ) -> GetSAMLProviderResponse: + raise NotImplementedError + + @handler("GetSSHPublicKey") + def get_ssh_public_key( + self, + context: RequestContext, + user_name: userNameType, + ssh_public_key_id: publicKeyIdType, + encoding: encodingType, + **kwargs, + ) -> GetSSHPublicKeyResponse: + raise NotImplementedError + + @handler("GetServerCertificate") + def get_server_certificate( + self, context: RequestContext, server_certificate_name: serverCertificateNameType, **kwargs + ) -> GetServerCertificateResponse: + raise NotImplementedError + + @handler("GetServiceLastAccessedDetails") + def get_service_last_accessed_details( + self, + context: RequestContext, + job_id: jobIDType, + max_items: maxItemsType | None = None, + marker: markerType | None = None, + **kwargs, + ) -> GetServiceLastAccessedDetailsResponse: + raise NotImplementedError + + @handler("GetServiceLastAccessedDetailsWithEntities") + def get_service_last_accessed_details_with_entities( + self, + context: RequestContext, + job_id: jobIDType, + service_namespace: serviceNamespaceType, + max_items: maxItemsType | None = None, + marker: markerType | None = None, + **kwargs, + ) -> GetServiceLastAccessedDetailsWithEntitiesResponse: + raise NotImplementedError + + @handler("GetServiceLinkedRoleDeletionStatus") + def get_service_linked_role_deletion_status( + self, context: RequestContext, deletion_task_id: DeletionTaskIdType, **kwargs + ) -> GetServiceLinkedRoleDeletionStatusResponse: + raise NotImplementedError + + @handler("GetUser") + def get_user( + self, context: RequestContext, user_name: existingUserNameType | None = None, **kwargs + ) -> GetUserResponse: + raise NotImplementedError + + @handler("GetUserPolicy") + def get_user_policy( + self, + context: RequestContext, + user_name: existingUserNameType, + policy_name: policyNameType, + **kwargs, + ) -> GetUserPolicyResponse: + raise NotImplementedError + + @handler("ListAccessKeys") + def list_access_keys( + self, + context: RequestContext, + user_name: existingUserNameType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListAccessKeysResponse: + raise NotImplementedError + + @handler("ListAccountAliases") + def list_account_aliases( + self, + context: RequestContext, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListAccountAliasesResponse: + raise NotImplementedError + + @handler("ListAttachedGroupPolicies") + def list_attached_group_policies( + self, + context: RequestContext, + group_name: groupNameType, + path_prefix: policyPathType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListAttachedGroupPoliciesResponse: + raise NotImplementedError + + @handler("ListAttachedRolePolicies") + def list_attached_role_policies( + self, + context: RequestContext, + role_name: roleNameType, + path_prefix: policyPathType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListAttachedRolePoliciesResponse: + raise NotImplementedError + + @handler("ListAttachedUserPolicies") + def list_attached_user_policies( + self, + context: RequestContext, + user_name: userNameType, + path_prefix: policyPathType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListAttachedUserPoliciesResponse: + raise NotImplementedError + + @handler("ListEntitiesForPolicy") + def list_entities_for_policy( + self, + context: RequestContext, + policy_arn: arnType, + entity_filter: EntityType | None = None, + path_prefix: pathType | None = None, + policy_usage_filter: PolicyUsageType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListEntitiesForPolicyResponse: + raise NotImplementedError + + @handler("ListGroupPolicies") + def list_group_policies( + self, + context: RequestContext, + group_name: groupNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListGroupPoliciesResponse: + raise NotImplementedError + + @handler("ListGroups") + def list_groups( + self, + context: RequestContext, + path_prefix: pathPrefixType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListGroupsResponse: + raise NotImplementedError + + @handler("ListGroupsForUser") + def list_groups_for_user( + self, + context: RequestContext, + user_name: existingUserNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListGroupsForUserResponse: + raise NotImplementedError + + @handler("ListInstanceProfileTags") + def list_instance_profile_tags( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListInstanceProfileTagsResponse: + raise NotImplementedError + + @handler("ListInstanceProfiles") + def list_instance_profiles( + self, + context: RequestContext, + path_prefix: pathPrefixType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListInstanceProfilesResponse: + raise NotImplementedError + + @handler("ListInstanceProfilesForRole") + def list_instance_profiles_for_role( + self, + context: RequestContext, + role_name: roleNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListInstanceProfilesForRoleResponse: + raise NotImplementedError + + @handler("ListMFADeviceTags") + def list_mfa_device_tags( + self, + context: RequestContext, + serial_number: serialNumberType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListMFADeviceTagsResponse: + raise NotImplementedError + + @handler("ListMFADevices") + def list_mfa_devices( + self, + context: RequestContext, + user_name: existingUserNameType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListMFADevicesResponse: + raise NotImplementedError + + @handler("ListOpenIDConnectProviderTags") + def list_open_id_connect_provider_tags( + self, + context: RequestContext, + open_id_connect_provider_arn: arnType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListOpenIDConnectProviderTagsResponse: + raise NotImplementedError + + @handler("ListOpenIDConnectProviders") + def list_open_id_connect_providers( + self, context: RequestContext, **kwargs + ) -> ListOpenIDConnectProvidersResponse: + raise NotImplementedError + + @handler("ListOrganizationsFeatures") + def list_organizations_features( + self, context: RequestContext, **kwargs + ) -> ListOrganizationsFeaturesResponse: + raise NotImplementedError + + @handler("ListPolicies") + def list_policies( + self, + context: RequestContext, + scope: policyScopeType | None = None, + only_attached: booleanType | None = None, + path_prefix: policyPathType | None = None, + policy_usage_filter: PolicyUsageType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListPoliciesResponse: + raise NotImplementedError + + @handler("ListPoliciesGrantingServiceAccess") + def list_policies_granting_service_access( + self, + context: RequestContext, + arn: arnType, + service_namespaces: serviceNamespaceListType, + marker: markerType | None = None, + **kwargs, + ) -> ListPoliciesGrantingServiceAccessResponse: + raise NotImplementedError + + @handler("ListPolicyTags") + def list_policy_tags( + self, + context: RequestContext, + policy_arn: arnType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListPolicyTagsResponse: + raise NotImplementedError + + @handler("ListPolicyVersions") + def list_policy_versions( + self, + context: RequestContext, + policy_arn: arnType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListPolicyVersionsResponse: + raise NotImplementedError + + @handler("ListRolePolicies") + def list_role_policies( + self, + context: RequestContext, + role_name: roleNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListRolePoliciesResponse: + raise NotImplementedError + + @handler("ListRoleTags") + def list_role_tags( + self, + context: RequestContext, + role_name: roleNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListRoleTagsResponse: + raise NotImplementedError + + @handler("ListRoles") + def list_roles( + self, + context: RequestContext, + path_prefix: pathPrefixType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListRolesResponse: + raise NotImplementedError + + @handler("ListSAMLProviderTags") + def list_saml_provider_tags( + self, + context: RequestContext, + saml_provider_arn: arnType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListSAMLProviderTagsResponse: + raise NotImplementedError + + @handler("ListSAMLProviders") + def list_saml_providers(self, context: RequestContext, **kwargs) -> ListSAMLProvidersResponse: + raise NotImplementedError + + @handler("ListSSHPublicKeys") + def list_ssh_public_keys( + self, + context: RequestContext, + user_name: userNameType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListSSHPublicKeysResponse: + raise NotImplementedError + + @handler("ListServerCertificateTags") + def list_server_certificate_tags( + self, + context: RequestContext, + server_certificate_name: serverCertificateNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListServerCertificateTagsResponse: + raise NotImplementedError + + @handler("ListServerCertificates") + def list_server_certificates( + self, + context: RequestContext, + path_prefix: pathPrefixType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListServerCertificatesResponse: + raise NotImplementedError + + @handler("ListServiceSpecificCredentials") + def list_service_specific_credentials( + self, + context: RequestContext, + user_name: userNameType | None = None, + service_name: serviceName | None = None, + all_users: allUsers | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListServiceSpecificCredentialsResponse: + raise NotImplementedError + + @handler("ListSigningCertificates") + def list_signing_certificates( + self, + context: RequestContext, + user_name: existingUserNameType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListSigningCertificatesResponse: + raise NotImplementedError + + @handler("ListUserPolicies") + def list_user_policies( + self, + context: RequestContext, + user_name: existingUserNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListUserPoliciesResponse: + raise NotImplementedError + + @handler("ListUserTags") + def list_user_tags( + self, + context: RequestContext, + user_name: existingUserNameType, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListUserTagsResponse: + raise NotImplementedError + + @handler("ListUsers") + def list_users( + self, + context: RequestContext, + path_prefix: pathPrefixType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListUsersResponse: + raise NotImplementedError + + @handler("ListVirtualMFADevices") + def list_virtual_mfa_devices( + self, + context: RequestContext, + assignment_status: assignmentStatusType | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListVirtualMFADevicesResponse: + raise NotImplementedError + + @handler("PutGroupPolicy") + def put_group_policy( + self, + context: RequestContext, + group_name: groupNameType, + policy_name: policyNameType, + policy_document: policyDocumentType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutRolePermissionsBoundary") + def put_role_permissions_boundary( + self, + context: RequestContext, + role_name: roleNameType, + permissions_boundary: arnType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutRolePolicy") + def put_role_policy( + self, + context: RequestContext, + role_name: roleNameType, + policy_name: policyNameType, + policy_document: policyDocumentType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutUserPermissionsBoundary") + def put_user_permissions_boundary( + self, + context: RequestContext, + user_name: userNameType, + permissions_boundary: arnType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutUserPolicy") + def put_user_policy( + self, + context: RequestContext, + user_name: existingUserNameType, + policy_name: policyNameType, + policy_document: policyDocumentType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RemoveClientIDFromOpenIDConnectProvider") + def remove_client_id_from_open_id_connect_provider( + self, + context: RequestContext, + open_id_connect_provider_arn: arnType, + client_id: clientIDType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RemoveRoleFromInstanceProfile") + def remove_role_from_instance_profile( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + role_name: roleNameType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RemoveUserFromGroup") + def remove_user_from_group( + self, + context: RequestContext, + group_name: groupNameType, + user_name: existingUserNameType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ResetServiceSpecificCredential") + def reset_service_specific_credential( + self, + context: RequestContext, + service_specific_credential_id: serviceSpecificCredentialId, + user_name: userNameType | None = None, + **kwargs, + ) -> ResetServiceSpecificCredentialResponse: + raise NotImplementedError + + @handler("ResyncMFADevice") + def resync_mfa_device( + self, + context: RequestContext, + user_name: existingUserNameType, + serial_number: serialNumberType, + authentication_code1: authenticationCodeType, + authentication_code2: authenticationCodeType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SetDefaultPolicyVersion") + def set_default_policy_version( + self, + context: RequestContext, + policy_arn: arnType, + version_id: policyVersionIdType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SetSecurityTokenServicePreferences") + def set_security_token_service_preferences( + self, + context: RequestContext, + global_endpoint_token_version: globalEndpointTokenVersion, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SimulateCustomPolicy") + def simulate_custom_policy( + self, + context: RequestContext, + policy_input_list: SimulationPolicyListType, + action_names: ActionNameListType, + permissions_boundary_policy_input_list: SimulationPolicyListType | None = None, + resource_arns: ResourceNameListType | None = None, + resource_policy: policyDocumentType | None = None, + resource_owner: ResourceNameType | None = None, + caller_arn: ResourceNameType | None = None, + context_entries: ContextEntryListType | None = None, + resource_handling_option: ResourceHandlingOptionType | None = None, + max_items: maxItemsType | None = None, + marker: markerType | None = None, + **kwargs, + ) -> SimulatePolicyResponse: + raise NotImplementedError + + @handler("SimulatePrincipalPolicy") + def simulate_principal_policy( + self, + context: RequestContext, + policy_source_arn: arnType, + action_names: ActionNameListType, + policy_input_list: SimulationPolicyListType | None = None, + permissions_boundary_policy_input_list: SimulationPolicyListType | None = None, + resource_arns: ResourceNameListType | None = None, + resource_policy: policyDocumentType | None = None, + resource_owner: ResourceNameType | None = None, + caller_arn: ResourceNameType | None = None, + context_entries: ContextEntryListType | None = None, + resource_handling_option: ResourceHandlingOptionType | None = None, + max_items: maxItemsType | None = None, + marker: markerType | None = None, + **kwargs, + ) -> SimulatePolicyResponse: + raise NotImplementedError + + @handler("TagInstanceProfile") + def tag_instance_profile( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + tags: tagListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("TagMFADevice") + def tag_mfa_device( + self, context: RequestContext, serial_number: serialNumberType, tags: tagListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TagOpenIDConnectProvider") + def tag_open_id_connect_provider( + self, + context: RequestContext, + open_id_connect_provider_arn: arnType, + tags: tagListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("TagPolicy") + def tag_policy( + self, context: RequestContext, policy_arn: arnType, tags: tagListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TagRole") + def tag_role( + self, context: RequestContext, role_name: roleNameType, tags: tagListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TagSAMLProvider") + def tag_saml_provider( + self, context: RequestContext, saml_provider_arn: arnType, tags: tagListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TagServerCertificate") + def tag_server_certificate( + self, + context: RequestContext, + server_certificate_name: serverCertificateNameType, + tags: tagListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("TagUser") + def tag_user( + self, context: RequestContext, user_name: existingUserNameType, tags: tagListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagInstanceProfile") + def untag_instance_profile( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + tag_keys: tagKeyListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UntagMFADevice") + def untag_mfa_device( + self, + context: RequestContext, + serial_number: serialNumberType, + tag_keys: tagKeyListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UntagOpenIDConnectProvider") + def untag_open_id_connect_provider( + self, + context: RequestContext, + open_id_connect_provider_arn: arnType, + tag_keys: tagKeyListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UntagPolicy") + def untag_policy( + self, context: RequestContext, policy_arn: arnType, tag_keys: tagKeyListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagRole") + def untag_role( + self, context: RequestContext, role_name: roleNameType, tag_keys: tagKeyListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagSAMLProvider") + def untag_saml_provider( + self, + context: RequestContext, + saml_provider_arn: arnType, + tag_keys: tagKeyListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UntagServerCertificate") + def untag_server_certificate( + self, + context: RequestContext, + server_certificate_name: serverCertificateNameType, + tag_keys: tagKeyListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UntagUser") + def untag_user( + self, + context: RequestContext, + user_name: existingUserNameType, + tag_keys: tagKeyListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateAccessKey") + def update_access_key( + self, + context: RequestContext, + access_key_id: accessKeyIdType, + status: statusType, + user_name: existingUserNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateAccountPasswordPolicy") + def update_account_password_policy( + self, + context: RequestContext, + minimum_password_length: minimumPasswordLengthType | None = None, + require_symbols: booleanType | None = None, + require_numbers: booleanType | None = None, + require_uppercase_characters: booleanType | None = None, + require_lowercase_characters: booleanType | None = None, + allow_users_to_change_password: booleanType | None = None, + max_password_age: maxPasswordAgeType | None = None, + password_reuse_prevention: passwordReusePreventionType | None = None, + hard_expiry: booleanObjectType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateAssumeRolePolicy") + def update_assume_role_policy( + self, + context: RequestContext, + role_name: roleNameType, + policy_document: policyDocumentType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateGroup") + def update_group( + self, + context: RequestContext, + group_name: groupNameType, + new_path: pathType | None = None, + new_group_name: groupNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateLoginProfile") + def update_login_profile( + self, + context: RequestContext, + user_name: userNameType, + password: passwordType | None = None, + password_reset_required: booleanObjectType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateOpenIDConnectProviderThumbprint") + def update_open_id_connect_provider_thumbprint( + self, + context: RequestContext, + open_id_connect_provider_arn: arnType, + thumbprint_list: thumbprintListType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateRole") + def update_role( + self, + context: RequestContext, + role_name: roleNameType, + description: roleDescriptionType | None = None, + max_session_duration: roleMaxSessionDurationType | None = None, + **kwargs, + ) -> UpdateRoleResponse: + raise NotImplementedError + + @handler("UpdateRoleDescription") + def update_role_description( + self, + context: RequestContext, + role_name: roleNameType, + description: roleDescriptionType, + **kwargs, + ) -> UpdateRoleDescriptionResponse: + raise NotImplementedError + + @handler("UpdateSAMLProvider") + def update_saml_provider( + self, + context: RequestContext, + saml_provider_arn: arnType, + saml_metadata_document: SAMLMetadataDocumentType | None = None, + assertion_encryption_mode: assertionEncryptionModeType | None = None, + add_private_key: privateKeyType | None = None, + remove_private_key: privateKeyIdType | None = None, + **kwargs, + ) -> UpdateSAMLProviderResponse: + raise NotImplementedError + + @handler("UpdateSSHPublicKey") + def update_ssh_public_key( + self, + context: RequestContext, + user_name: userNameType, + ssh_public_key_id: publicKeyIdType, + status: statusType, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateServerCertificate") + def update_server_certificate( + self, + context: RequestContext, + server_certificate_name: serverCertificateNameType, + new_path: pathType | None = None, + new_server_certificate_name: serverCertificateNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateServiceSpecificCredential") + def update_service_specific_credential( + self, + context: RequestContext, + service_specific_credential_id: serviceSpecificCredentialId, + status: statusType, + user_name: userNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateSigningCertificate") + def update_signing_certificate( + self, + context: RequestContext, + certificate_id: certificateIdType, + status: statusType, + user_name: existingUserNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateUser") + def update_user( + self, + context: RequestContext, + user_name: existingUserNameType, + new_path: pathType | None = None, + new_user_name: userNameType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UploadSSHPublicKey") + def upload_ssh_public_key( + self, + context: RequestContext, + user_name: userNameType, + ssh_public_key_body: publicKeyMaterialType, + **kwargs, + ) -> UploadSSHPublicKeyResponse: + raise NotImplementedError + + @handler("UploadServerCertificate") + def upload_server_certificate( + self, + context: RequestContext, + server_certificate_name: serverCertificateNameType, + certificate_body: certificateBodyType, + private_key: privateKeyType, + path: pathType | None = None, + certificate_chain: certificateChainType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> UploadServerCertificateResponse: + raise NotImplementedError + + @handler("UploadSigningCertificate") + def upload_signing_certificate( + self, + context: RequestContext, + certificate_body: certificateBodyType, + user_name: existingUserNameType | None = None, + **kwargs, + ) -> UploadSigningCertificateResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/kinesis/__init__.py b/localstack-core/localstack/aws/api/kinesis/__init__.py new file mode 100644 index 0000000000000..61f6f105fac9c --- /dev/null +++ b/localstack-core/localstack/aws/api/kinesis/__init__.py @@ -0,0 +1,1055 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, Iterator, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +BooleanObject = bool +ConsumerARN = str +ConsumerCountObject = int +ConsumerName = str +DescribeStreamInputLimit = int +ErrorCode = str +ErrorMessage = str +GetRecordsInputLimit = int +HashKey = str +KeyId = str +ListShardsInputLimit = int +ListStreamConsumersInputLimit = int +ListStreamsInputLimit = int +ListTagsForStreamInputLimit = int +NextToken = str +OnDemandStreamCountLimitObject = int +OnDemandStreamCountObject = int +PartitionKey = str +Policy = str +PositiveIntegerObject = int +ResourceARN = str +RetentionPeriodHours = int +SequenceNumber = str +ShardCountObject = int +ShardId = str +ShardIterator = str +StreamARN = str +StreamName = str +TagKey = str +TagValue = str + + +class ConsumerStatus(StrEnum): + CREATING = "CREATING" + DELETING = "DELETING" + ACTIVE = "ACTIVE" + + +class EncryptionType(StrEnum): + NONE = "NONE" + KMS = "KMS" + + +class MetricsName(StrEnum): + IncomingBytes = "IncomingBytes" + IncomingRecords = "IncomingRecords" + OutgoingBytes = "OutgoingBytes" + OutgoingRecords = "OutgoingRecords" + WriteProvisionedThroughputExceeded = "WriteProvisionedThroughputExceeded" + ReadProvisionedThroughputExceeded = "ReadProvisionedThroughputExceeded" + IteratorAgeMilliseconds = "IteratorAgeMilliseconds" + ALL = "ALL" + + +class ScalingType(StrEnum): + UNIFORM_SCALING = "UNIFORM_SCALING" + + +class ShardFilterType(StrEnum): + AFTER_SHARD_ID = "AFTER_SHARD_ID" + AT_TRIM_HORIZON = "AT_TRIM_HORIZON" + FROM_TRIM_HORIZON = "FROM_TRIM_HORIZON" + AT_LATEST = "AT_LATEST" + AT_TIMESTAMP = "AT_TIMESTAMP" + FROM_TIMESTAMP = "FROM_TIMESTAMP" + + +class ShardIteratorType(StrEnum): + AT_SEQUENCE_NUMBER = "AT_SEQUENCE_NUMBER" + AFTER_SEQUENCE_NUMBER = "AFTER_SEQUENCE_NUMBER" + TRIM_HORIZON = "TRIM_HORIZON" + LATEST = "LATEST" + AT_TIMESTAMP = "AT_TIMESTAMP" + + +class StreamMode(StrEnum): + PROVISIONED = "PROVISIONED" + ON_DEMAND = "ON_DEMAND" + + +class StreamStatus(StrEnum): + CREATING = "CREATING" + DELETING = "DELETING" + ACTIVE = "ACTIVE" + UPDATING = "UPDATING" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class ExpiredIteratorException(ServiceException): + code: str = "ExpiredIteratorException" + sender_fault: bool = False + status_code: int = 400 + + +class ExpiredNextTokenException(ServiceException): + code: str = "ExpiredNextTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalFailureException(ServiceException): + code: str = "InternalFailureException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidArgumentException(ServiceException): + code: str = "InvalidArgumentException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSAccessDeniedException(ServiceException): + code: str = "KMSAccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSDisabledException(ServiceException): + code: str = "KMSDisabledException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSInvalidStateException(ServiceException): + code: str = "KMSInvalidStateException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSNotFoundException(ServiceException): + code: str = "KMSNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSOptInRequired(ServiceException): + code: str = "KMSOptInRequired" + sender_fault: bool = False + status_code: int = 400 + + +class KMSThrottlingException(ServiceException): + code: str = "KMSThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ProvisionedThroughputExceededException(ServiceException): + code: str = "ProvisionedThroughputExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + + +TagMap = Dict[TagKey, TagValue] + + +class AddTagsToStreamInput(ServiceRequest): + StreamName: Optional[StreamName] + Tags: TagMap + StreamARN: Optional[StreamARN] + + +class HashKeyRange(TypedDict, total=False): + StartingHashKey: HashKey + EndingHashKey: HashKey + + +ShardIdList = List[ShardId] + + +class ChildShard(TypedDict, total=False): + ShardId: ShardId + ParentShards: ShardIdList + HashKeyRange: HashKeyRange + + +ChildShardList = List[ChildShard] +Timestamp = datetime + + +class Consumer(TypedDict, total=False): + ConsumerName: ConsumerName + ConsumerARN: ConsumerARN + ConsumerStatus: ConsumerStatus + ConsumerCreationTimestamp: Timestamp + + +class ConsumerDescription(TypedDict, total=False): + ConsumerName: ConsumerName + ConsumerARN: ConsumerARN + ConsumerStatus: ConsumerStatus + ConsumerCreationTimestamp: Timestamp + StreamARN: StreamARN + + +ConsumerList = List[Consumer] + + +class StreamModeDetails(TypedDict, total=False): + StreamMode: StreamMode + + +class CreateStreamInput(ServiceRequest): + StreamName: StreamName + ShardCount: Optional[PositiveIntegerObject] + StreamModeDetails: Optional[StreamModeDetails] + Tags: Optional[TagMap] + + +Data = bytes + + +class DecreaseStreamRetentionPeriodInput(ServiceRequest): + StreamName: Optional[StreamName] + RetentionPeriodHours: RetentionPeriodHours + StreamARN: Optional[StreamARN] + + +class DeleteResourcePolicyInput(ServiceRequest): + ResourceARN: ResourceARN + + +class DeleteStreamInput(ServiceRequest): + StreamName: Optional[StreamName] + EnforceConsumerDeletion: Optional[BooleanObject] + StreamARN: Optional[StreamARN] + + +class DeregisterStreamConsumerInput(ServiceRequest): + StreamARN: Optional[StreamARN] + ConsumerName: Optional[ConsumerName] + ConsumerARN: Optional[ConsumerARN] + + +class DescribeLimitsInput(ServiceRequest): + pass + + +class DescribeLimitsOutput(TypedDict, total=False): + ShardLimit: ShardCountObject + OpenShardCount: ShardCountObject + OnDemandStreamCount: OnDemandStreamCountObject + OnDemandStreamCountLimit: OnDemandStreamCountLimitObject + + +class DescribeStreamConsumerInput(ServiceRequest): + StreamARN: Optional[StreamARN] + ConsumerName: Optional[ConsumerName] + ConsumerARN: Optional[ConsumerARN] + + +class DescribeStreamConsumerOutput(TypedDict, total=False): + ConsumerDescription: ConsumerDescription + + +class DescribeStreamInput(ServiceRequest): + StreamName: Optional[StreamName] + Limit: Optional[DescribeStreamInputLimit] + ExclusiveStartShardId: Optional[ShardId] + StreamARN: Optional[StreamARN] + + +MetricsNameList = List[MetricsName] + + +class EnhancedMetrics(TypedDict, total=False): + ShardLevelMetrics: Optional[MetricsNameList] + + +EnhancedMonitoringList = List[EnhancedMetrics] + + +class SequenceNumberRange(TypedDict, total=False): + StartingSequenceNumber: SequenceNumber + EndingSequenceNumber: Optional[SequenceNumber] + + +class Shard(TypedDict, total=False): + ShardId: ShardId + ParentShardId: Optional[ShardId] + AdjacentParentShardId: Optional[ShardId] + HashKeyRange: HashKeyRange + SequenceNumberRange: SequenceNumberRange + + +ShardList = List[Shard] + + +class StreamDescription(TypedDict, total=False): + StreamName: StreamName + StreamARN: StreamARN + StreamStatus: StreamStatus + StreamModeDetails: Optional[StreamModeDetails] + Shards: ShardList + HasMoreShards: BooleanObject + RetentionPeriodHours: RetentionPeriodHours + StreamCreationTimestamp: Timestamp + EnhancedMonitoring: EnhancedMonitoringList + EncryptionType: Optional[EncryptionType] + KeyId: Optional[KeyId] + + +class DescribeStreamOutput(TypedDict, total=False): + StreamDescription: StreamDescription + + +class DescribeStreamSummaryInput(ServiceRequest): + StreamName: Optional[StreamName] + StreamARN: Optional[StreamARN] + + +class StreamDescriptionSummary(TypedDict, total=False): + StreamName: StreamName + StreamARN: StreamARN + StreamStatus: StreamStatus + StreamModeDetails: Optional[StreamModeDetails] + RetentionPeriodHours: RetentionPeriodHours + StreamCreationTimestamp: Timestamp + EnhancedMonitoring: EnhancedMonitoringList + EncryptionType: Optional[EncryptionType] + KeyId: Optional[KeyId] + OpenShardCount: ShardCountObject + ConsumerCount: Optional[ConsumerCountObject] + + +class DescribeStreamSummaryOutput(TypedDict, total=False): + StreamDescriptionSummary: StreamDescriptionSummary + + +class DisableEnhancedMonitoringInput(ServiceRequest): + StreamName: Optional[StreamName] + ShardLevelMetrics: MetricsNameList + StreamARN: Optional[StreamARN] + + +class EnableEnhancedMonitoringInput(ServiceRequest): + StreamName: Optional[StreamName] + ShardLevelMetrics: MetricsNameList + StreamARN: Optional[StreamARN] + + +class EnhancedMonitoringOutput(TypedDict, total=False): + StreamName: Optional[StreamName] + CurrentShardLevelMetrics: Optional[MetricsNameList] + DesiredShardLevelMetrics: Optional[MetricsNameList] + StreamARN: Optional[StreamARN] + + +class GetRecordsInput(ServiceRequest): + ShardIterator: ShardIterator + Limit: Optional[GetRecordsInputLimit] + StreamARN: Optional[StreamARN] + + +MillisBehindLatest = int + + +class Record(TypedDict, total=False): + SequenceNumber: SequenceNumber + ApproximateArrivalTimestamp: Optional[Timestamp] + Data: Data + PartitionKey: PartitionKey + EncryptionType: Optional[EncryptionType] + + +RecordList = List[Record] + + +class GetRecordsOutput(TypedDict, total=False): + Records: RecordList + NextShardIterator: Optional[ShardIterator] + MillisBehindLatest: Optional[MillisBehindLatest] + ChildShards: Optional[ChildShardList] + + +class GetResourcePolicyInput(ServiceRequest): + ResourceARN: ResourceARN + + +class GetResourcePolicyOutput(TypedDict, total=False): + Policy: Policy + + +class GetShardIteratorInput(ServiceRequest): + StreamName: Optional[StreamName] + ShardId: ShardId + ShardIteratorType: ShardIteratorType + StartingSequenceNumber: Optional[SequenceNumber] + Timestamp: Optional[Timestamp] + StreamARN: Optional[StreamARN] + + +class GetShardIteratorOutput(TypedDict, total=False): + ShardIterator: Optional[ShardIterator] + + +class IncreaseStreamRetentionPeriodInput(ServiceRequest): + StreamName: Optional[StreamName] + RetentionPeriodHours: RetentionPeriodHours + StreamARN: Optional[StreamARN] + + +class ShardFilter(TypedDict, total=False): + Type: ShardFilterType + ShardId: Optional[ShardId] + Timestamp: Optional[Timestamp] + + +class ListShardsInput(ServiceRequest): + StreamName: Optional[StreamName] + NextToken: Optional[NextToken] + ExclusiveStartShardId: Optional[ShardId] + MaxResults: Optional[ListShardsInputLimit] + StreamCreationTimestamp: Optional[Timestamp] + ShardFilter: Optional[ShardFilter] + StreamARN: Optional[StreamARN] + + +class ListShardsOutput(TypedDict, total=False): + Shards: Optional[ShardList] + NextToken: Optional[NextToken] + + +class ListStreamConsumersInput(ServiceRequest): + StreamARN: StreamARN + NextToken: Optional[NextToken] + MaxResults: Optional[ListStreamConsumersInputLimit] + StreamCreationTimestamp: Optional[Timestamp] + + +class ListStreamConsumersOutput(TypedDict, total=False): + Consumers: Optional[ConsumerList] + NextToken: Optional[NextToken] + + +class ListStreamsInput(ServiceRequest): + Limit: Optional[ListStreamsInputLimit] + ExclusiveStartStreamName: Optional[StreamName] + NextToken: Optional[NextToken] + + +class StreamSummary(TypedDict, total=False): + StreamName: StreamName + StreamARN: StreamARN + StreamStatus: StreamStatus + StreamModeDetails: Optional[StreamModeDetails] + StreamCreationTimestamp: Optional[Timestamp] + + +StreamSummaryList = List[StreamSummary] +StreamNameList = List[StreamName] + + +class ListStreamsOutput(TypedDict, total=False): + StreamNames: StreamNameList + HasMoreStreams: BooleanObject + NextToken: Optional[NextToken] + StreamSummaries: Optional[StreamSummaryList] + + +class ListTagsForResourceInput(ServiceRequest): + ResourceARN: ResourceARN + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: Optional[TagValue] + + +TagList = List[Tag] + + +class ListTagsForResourceOutput(TypedDict, total=False): + Tags: Optional[TagList] + + +class ListTagsForStreamInput(ServiceRequest): + StreamName: Optional[StreamName] + ExclusiveStartTagKey: Optional[TagKey] + Limit: Optional[ListTagsForStreamInputLimit] + StreamARN: Optional[StreamARN] + + +class ListTagsForStreamOutput(TypedDict, total=False): + Tags: TagList + HasMoreTags: BooleanObject + + +class MergeShardsInput(ServiceRequest): + StreamName: Optional[StreamName] + ShardToMerge: ShardId + AdjacentShardToMerge: ShardId + StreamARN: Optional[StreamARN] + + +class PutRecordInput(ServiceRequest): + StreamName: Optional[StreamName] + Data: Data + PartitionKey: PartitionKey + ExplicitHashKey: Optional[HashKey] + SequenceNumberForOrdering: Optional[SequenceNumber] + StreamARN: Optional[StreamARN] + + +class PutRecordOutput(TypedDict, total=False): + ShardId: ShardId + SequenceNumber: SequenceNumber + EncryptionType: Optional[EncryptionType] + + +class PutRecordsRequestEntry(TypedDict, total=False): + Data: Data + ExplicitHashKey: Optional[HashKey] + PartitionKey: PartitionKey + + +PutRecordsRequestEntryList = List[PutRecordsRequestEntry] + + +class PutRecordsInput(ServiceRequest): + Records: PutRecordsRequestEntryList + StreamName: Optional[StreamName] + StreamARN: Optional[StreamARN] + + +class PutRecordsResultEntry(TypedDict, total=False): + SequenceNumber: Optional[SequenceNumber] + ShardId: Optional[ShardId] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +PutRecordsResultEntryList = List[PutRecordsResultEntry] + + +class PutRecordsOutput(TypedDict, total=False): + FailedRecordCount: Optional[PositiveIntegerObject] + Records: PutRecordsResultEntryList + EncryptionType: Optional[EncryptionType] + + +class PutResourcePolicyInput(ServiceRequest): + ResourceARN: ResourceARN + Policy: Policy + + +class RegisterStreamConsumerInput(ServiceRequest): + StreamARN: StreamARN + ConsumerName: ConsumerName + Tags: Optional[TagMap] + + +class RegisterStreamConsumerOutput(TypedDict, total=False): + Consumer: Consumer + + +TagKeyList = List[TagKey] + + +class RemoveTagsFromStreamInput(ServiceRequest): + StreamName: Optional[StreamName] + TagKeys: TagKeyList + StreamARN: Optional[StreamARN] + + +class SplitShardInput(ServiceRequest): + StreamName: Optional[StreamName] + ShardToSplit: ShardId + NewStartingHashKey: HashKey + StreamARN: Optional[StreamARN] + + +class StartStreamEncryptionInput(ServiceRequest): + StreamName: Optional[StreamName] + EncryptionType: EncryptionType + KeyId: KeyId + StreamARN: Optional[StreamARN] + + +class StartingPosition(TypedDict, total=False): + Type: ShardIteratorType + SequenceNumber: Optional[SequenceNumber] + Timestamp: Optional[Timestamp] + + +class StopStreamEncryptionInput(ServiceRequest): + StreamName: Optional[StreamName] + EncryptionType: EncryptionType + KeyId: KeyId + StreamARN: Optional[StreamARN] + + +class SubscribeToShardEvent(TypedDict, total=False): + Records: RecordList + ContinuationSequenceNumber: SequenceNumber + MillisBehindLatest: MillisBehindLatest + ChildShards: Optional[ChildShardList] + + +class SubscribeToShardEventStream(TypedDict, total=False): + SubscribeToShardEvent: SubscribeToShardEvent + ResourceNotFoundException: Optional[ResourceNotFoundException] + ResourceInUseException: Optional[ResourceInUseException] + KMSDisabledException: Optional[KMSDisabledException] + KMSInvalidStateException: Optional[KMSInvalidStateException] + KMSAccessDeniedException: Optional[KMSAccessDeniedException] + KMSNotFoundException: Optional[KMSNotFoundException] + KMSOptInRequired: Optional[KMSOptInRequired] + KMSThrottlingException: Optional[KMSThrottlingException] + InternalFailureException: Optional[InternalFailureException] + + +class SubscribeToShardInput(ServiceRequest): + ConsumerARN: ConsumerARN + ShardId: ShardId + StartingPosition: StartingPosition + + +class SubscribeToShardOutput(TypedDict, total=False): + EventStream: Iterator[SubscribeToShardEventStream] + + +class TagResourceInput(ServiceRequest): + Tags: TagMap + ResourceARN: ResourceARN + + +class UntagResourceInput(ServiceRequest): + TagKeys: TagKeyList + ResourceARN: ResourceARN + + +class UpdateShardCountInput(ServiceRequest): + StreamName: Optional[StreamName] + TargetShardCount: PositiveIntegerObject + ScalingType: ScalingType + StreamARN: Optional[StreamARN] + + +class UpdateShardCountOutput(TypedDict, total=False): + StreamName: Optional[StreamName] + CurrentShardCount: Optional[PositiveIntegerObject] + TargetShardCount: Optional[PositiveIntegerObject] + StreamARN: Optional[StreamARN] + + +class UpdateStreamModeInput(ServiceRequest): + StreamARN: StreamARN + StreamModeDetails: StreamModeDetails + + +class KinesisApi: + service = "kinesis" + version = "2013-12-02" + + @handler("AddTagsToStream") + def add_tags_to_stream( + self, + context: RequestContext, + tags: TagMap, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateStream") + def create_stream( + self, + context: RequestContext, + stream_name: StreamName, + shard_count: PositiveIntegerObject | None = None, + stream_mode_details: StreamModeDetails | None = None, + tags: TagMap | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DecreaseStreamRetentionPeriod") + def decrease_stream_retention_period( + self, + context: RequestContext, + retention_period_hours: RetentionPeriodHours, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteResourcePolicy") + def delete_resource_policy( + self, context: RequestContext, resource_arn: ResourceARN, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteStream") + def delete_stream( + self, + context: RequestContext, + stream_name: StreamName | None = None, + enforce_consumer_deletion: BooleanObject | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeregisterStreamConsumer") + def deregister_stream_consumer( + self, + context: RequestContext, + stream_arn: StreamARN | None = None, + consumer_name: ConsumerName | None = None, + consumer_arn: ConsumerARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DescribeLimits") + def describe_limits(self, context: RequestContext, **kwargs) -> DescribeLimitsOutput: + raise NotImplementedError + + @handler("DescribeStream") + def describe_stream( + self, + context: RequestContext, + stream_name: StreamName | None = None, + limit: DescribeStreamInputLimit | None = None, + exclusive_start_shard_id: ShardId | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> DescribeStreamOutput: + raise NotImplementedError + + @handler("DescribeStreamConsumer") + def describe_stream_consumer( + self, + context: RequestContext, + stream_arn: StreamARN | None = None, + consumer_name: ConsumerName | None = None, + consumer_arn: ConsumerARN | None = None, + **kwargs, + ) -> DescribeStreamConsumerOutput: + raise NotImplementedError + + @handler("DescribeStreamSummary") + def describe_stream_summary( + self, + context: RequestContext, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> DescribeStreamSummaryOutput: + raise NotImplementedError + + @handler("DisableEnhancedMonitoring") + def disable_enhanced_monitoring( + self, + context: RequestContext, + shard_level_metrics: MetricsNameList, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> EnhancedMonitoringOutput: + raise NotImplementedError + + @handler("EnableEnhancedMonitoring") + def enable_enhanced_monitoring( + self, + context: RequestContext, + shard_level_metrics: MetricsNameList, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> EnhancedMonitoringOutput: + raise NotImplementedError + + @handler("GetRecords") + def get_records( + self, + context: RequestContext, + shard_iterator: ShardIterator, + limit: GetRecordsInputLimit | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> GetRecordsOutput: + raise NotImplementedError + + @handler("GetResourcePolicy") + def get_resource_policy( + self, context: RequestContext, resource_arn: ResourceARN, **kwargs + ) -> GetResourcePolicyOutput: + raise NotImplementedError + + @handler("GetShardIterator") + def get_shard_iterator( + self, + context: RequestContext, + shard_id: ShardId, + shard_iterator_type: ShardIteratorType, + stream_name: StreamName | None = None, + starting_sequence_number: SequenceNumber | None = None, + timestamp: Timestamp | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> GetShardIteratorOutput: + raise NotImplementedError + + @handler("IncreaseStreamRetentionPeriod") + def increase_stream_retention_period( + self, + context: RequestContext, + retention_period_hours: RetentionPeriodHours, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ListShards") + def list_shards( + self, + context: RequestContext, + stream_name: StreamName | None = None, + next_token: NextToken | None = None, + exclusive_start_shard_id: ShardId | None = None, + max_results: ListShardsInputLimit | None = None, + stream_creation_timestamp: Timestamp | None = None, + shard_filter: ShardFilter | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> ListShardsOutput: + raise NotImplementedError + + @handler("ListStreamConsumers") + def list_stream_consumers( + self, + context: RequestContext, + stream_arn: StreamARN, + next_token: NextToken | None = None, + max_results: ListStreamConsumersInputLimit | None = None, + stream_creation_timestamp: Timestamp | None = None, + **kwargs, + ) -> ListStreamConsumersOutput: + raise NotImplementedError + + @handler("ListStreams") + def list_streams( + self, + context: RequestContext, + limit: ListStreamsInputLimit | None = None, + exclusive_start_stream_name: StreamName | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListStreamsOutput: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: ResourceARN, **kwargs + ) -> ListTagsForResourceOutput: + raise NotImplementedError + + @handler("ListTagsForStream") + def list_tags_for_stream( + self, + context: RequestContext, + stream_name: StreamName | None = None, + exclusive_start_tag_key: TagKey | None = None, + limit: ListTagsForStreamInputLimit | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> ListTagsForStreamOutput: + raise NotImplementedError + + @handler("MergeShards") + def merge_shards( + self, + context: RequestContext, + shard_to_merge: ShardId, + adjacent_shard_to_merge: ShardId, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutRecord") + def put_record( + self, + context: RequestContext, + data: Data, + partition_key: PartitionKey, + stream_name: StreamName | None = None, + explicit_hash_key: HashKey | None = None, + sequence_number_for_ordering: SequenceNumber | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> PutRecordOutput: + raise NotImplementedError + + @handler("PutRecords") + def put_records( + self, + context: RequestContext, + records: PutRecordsRequestEntryList, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> PutRecordsOutput: + raise NotImplementedError + + @handler("PutResourcePolicy") + def put_resource_policy( + self, context: RequestContext, resource_arn: ResourceARN, policy: Policy, **kwargs + ) -> None: + raise NotImplementedError + + @handler("RegisterStreamConsumer") + def register_stream_consumer( + self, + context: RequestContext, + stream_arn: StreamARN, + consumer_name: ConsumerName, + tags: TagMap | None = None, + **kwargs, + ) -> RegisterStreamConsumerOutput: + raise NotImplementedError + + @handler("RemoveTagsFromStream") + def remove_tags_from_stream( + self, + context: RequestContext, + tag_keys: TagKeyList, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SplitShard") + def split_shard( + self, + context: RequestContext, + shard_to_split: ShardId, + new_starting_hash_key: HashKey, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("StartStreamEncryption") + def start_stream_encryption( + self, + context: RequestContext, + encryption_type: EncryptionType, + key_id: KeyId, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("StopStreamEncryption") + def stop_stream_encryption( + self, + context: RequestContext, + encryption_type: EncryptionType, + key_id: KeyId, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SubscribeToShard") + def subscribe_to_shard( + self, + context: RequestContext, + consumer_arn: ConsumerARN, + shard_id: ShardId, + starting_position: StartingPosition, + **kwargs, + ) -> SubscribeToShardOutput: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, tags: TagMap, resource_arn: ResourceARN, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, tag_keys: TagKeyList, resource_arn: ResourceARN, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdateShardCount") + def update_shard_count( + self, + context: RequestContext, + target_shard_count: PositiveIntegerObject, + scaling_type: ScalingType, + stream_name: StreamName | None = None, + stream_arn: StreamARN | None = None, + **kwargs, + ) -> UpdateShardCountOutput: + raise NotImplementedError + + @handler("UpdateStreamMode") + def update_stream_mode( + self, + context: RequestContext, + stream_arn: StreamARN, + stream_mode_details: StreamModeDetails, + **kwargs, + ) -> None: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/kms/__init__.py b/localstack-core/localstack/aws/api/kms/__init__.py new file mode 100644 index 0000000000000..b5e0fec886732 --- /dev/null +++ b/localstack-core/localstack/aws/api/kms/__init__.py @@ -0,0 +1,1912 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AWSAccountIdType = str +AliasNameType = str +ArnType = str +BackingKeyIdResponseType = str +BackingKeyIdType = str +BooleanType = bool +CloudHsmClusterIdType = str +CustomKeyStoreIdType = str +CustomKeyStoreNameType = str +DescriptionType = str +EncryptionContextKey = str +EncryptionContextValue = str +ErrorMessageType = str +GrantIdType = str +GrantNameType = str +GrantTokenType = str +KeyIdType = str +KeyMaterialDescriptionType = str +KeyStorePasswordType = str +LimitType = int +MarkerType = str +NullableBooleanType = bool +NumberOfBytesType = int +PendingWindowInDaysType = int +PolicyNameType = str +PolicyType = str +PrincipalIdType = str +RegionType = str +RotationPeriodInDaysType = int +TagKeyType = str +TagValueType = str +TrustAnchorCertificateType = str +XksKeyIdType = str +XksProxyAuthenticationAccessKeyIdType = str +XksProxyAuthenticationRawSecretAccessKeyType = str +XksProxyUriEndpointType = str +XksProxyUriPathType = str +XksProxyVpcEndpointServiceNameType = str + + +class AlgorithmSpec(StrEnum): + RSAES_PKCS1_V1_5 = "RSAES_PKCS1_V1_5" + RSAES_OAEP_SHA_1 = "RSAES_OAEP_SHA_1" + RSAES_OAEP_SHA_256 = "RSAES_OAEP_SHA_256" + RSA_AES_KEY_WRAP_SHA_1 = "RSA_AES_KEY_WRAP_SHA_1" + RSA_AES_KEY_WRAP_SHA_256 = "RSA_AES_KEY_WRAP_SHA_256" + SM2PKE = "SM2PKE" + + +class ConnectionErrorCodeType(StrEnum): + INVALID_CREDENTIALS = "INVALID_CREDENTIALS" + CLUSTER_NOT_FOUND = "CLUSTER_NOT_FOUND" + NETWORK_ERRORS = "NETWORK_ERRORS" + INTERNAL_ERROR = "INTERNAL_ERROR" + INSUFFICIENT_CLOUDHSM_HSMS = "INSUFFICIENT_CLOUDHSM_HSMS" + USER_LOCKED_OUT = "USER_LOCKED_OUT" + USER_NOT_FOUND = "USER_NOT_FOUND" + USER_LOGGED_IN = "USER_LOGGED_IN" + SUBNET_NOT_FOUND = "SUBNET_NOT_FOUND" + INSUFFICIENT_FREE_ADDRESSES_IN_SUBNET = "INSUFFICIENT_FREE_ADDRESSES_IN_SUBNET" + XKS_PROXY_ACCESS_DENIED = "XKS_PROXY_ACCESS_DENIED" + XKS_PROXY_NOT_REACHABLE = "XKS_PROXY_NOT_REACHABLE" + XKS_VPC_ENDPOINT_SERVICE_NOT_FOUND = "XKS_VPC_ENDPOINT_SERVICE_NOT_FOUND" + XKS_PROXY_INVALID_RESPONSE = "XKS_PROXY_INVALID_RESPONSE" + XKS_PROXY_INVALID_CONFIGURATION = "XKS_PROXY_INVALID_CONFIGURATION" + XKS_VPC_ENDPOINT_SERVICE_INVALID_CONFIGURATION = ( + "XKS_VPC_ENDPOINT_SERVICE_INVALID_CONFIGURATION" + ) + XKS_PROXY_TIMED_OUT = "XKS_PROXY_TIMED_OUT" + XKS_PROXY_INVALID_TLS_CONFIGURATION = "XKS_PROXY_INVALID_TLS_CONFIGURATION" + + +class ConnectionStateType(StrEnum): + CONNECTED = "CONNECTED" + CONNECTING = "CONNECTING" + FAILED = "FAILED" + DISCONNECTED = "DISCONNECTED" + DISCONNECTING = "DISCONNECTING" + + +class CustomKeyStoreType(StrEnum): + AWS_CLOUDHSM = "AWS_CLOUDHSM" + EXTERNAL_KEY_STORE = "EXTERNAL_KEY_STORE" + + +class CustomerMasterKeySpec(StrEnum): + RSA_2048 = "RSA_2048" + RSA_3072 = "RSA_3072" + RSA_4096 = "RSA_4096" + ECC_NIST_P256 = "ECC_NIST_P256" + ECC_NIST_P384 = "ECC_NIST_P384" + ECC_NIST_P521 = "ECC_NIST_P521" + ECC_SECG_P256K1 = "ECC_SECG_P256K1" + SYMMETRIC_DEFAULT = "SYMMETRIC_DEFAULT" + HMAC_224 = "HMAC_224" + HMAC_256 = "HMAC_256" + HMAC_384 = "HMAC_384" + HMAC_512 = "HMAC_512" + SM2 = "SM2" + + +class DataKeyPairSpec(StrEnum): + RSA_2048 = "RSA_2048" + RSA_3072 = "RSA_3072" + RSA_4096 = "RSA_4096" + ECC_NIST_P256 = "ECC_NIST_P256" + ECC_NIST_P384 = "ECC_NIST_P384" + ECC_NIST_P521 = "ECC_NIST_P521" + ECC_SECG_P256K1 = "ECC_SECG_P256K1" + SM2 = "SM2" + + +class DataKeySpec(StrEnum): + AES_256 = "AES_256" + AES_128 = "AES_128" + + +class EncryptionAlgorithmSpec(StrEnum): + SYMMETRIC_DEFAULT = "SYMMETRIC_DEFAULT" + RSAES_OAEP_SHA_1 = "RSAES_OAEP_SHA_1" + RSAES_OAEP_SHA_256 = "RSAES_OAEP_SHA_256" + SM2PKE = "SM2PKE" + + +class ExpirationModelType(StrEnum): + KEY_MATERIAL_EXPIRES = "KEY_MATERIAL_EXPIRES" + KEY_MATERIAL_DOES_NOT_EXPIRE = "KEY_MATERIAL_DOES_NOT_EXPIRE" + + +class GrantOperation(StrEnum): + Decrypt = "Decrypt" + Encrypt = "Encrypt" + GenerateDataKey = "GenerateDataKey" + GenerateDataKeyWithoutPlaintext = "GenerateDataKeyWithoutPlaintext" + ReEncryptFrom = "ReEncryptFrom" + ReEncryptTo = "ReEncryptTo" + Sign = "Sign" + Verify = "Verify" + GetPublicKey = "GetPublicKey" + CreateGrant = "CreateGrant" + RetireGrant = "RetireGrant" + DescribeKey = "DescribeKey" + GenerateDataKeyPair = "GenerateDataKeyPair" + GenerateDataKeyPairWithoutPlaintext = "GenerateDataKeyPairWithoutPlaintext" + GenerateMac = "GenerateMac" + VerifyMac = "VerifyMac" + DeriveSharedSecret = "DeriveSharedSecret" + + +class ImportState(StrEnum): + IMPORTED = "IMPORTED" + PENDING_IMPORT = "PENDING_IMPORT" + + +class ImportType(StrEnum): + NEW_KEY_MATERIAL = "NEW_KEY_MATERIAL" + EXISTING_KEY_MATERIAL = "EXISTING_KEY_MATERIAL" + + +class IncludeKeyMaterial(StrEnum): + ALL_KEY_MATERIAL = "ALL_KEY_MATERIAL" + ROTATIONS_ONLY = "ROTATIONS_ONLY" + + +class KeyAgreementAlgorithmSpec(StrEnum): + ECDH = "ECDH" + + +class KeyEncryptionMechanism(StrEnum): + RSAES_OAEP_SHA_256 = "RSAES_OAEP_SHA_256" + + +class KeyManagerType(StrEnum): + AWS = "AWS" + CUSTOMER = "CUSTOMER" + + +class KeyMaterialState(StrEnum): + NON_CURRENT = "NON_CURRENT" + CURRENT = "CURRENT" + PENDING_ROTATION = "PENDING_ROTATION" + + +class KeySpec(StrEnum): + RSA_2048 = "RSA_2048" + RSA_3072 = "RSA_3072" + RSA_4096 = "RSA_4096" + ECC_NIST_P256 = "ECC_NIST_P256" + ECC_NIST_P384 = "ECC_NIST_P384" + ECC_NIST_P521 = "ECC_NIST_P521" + ECC_SECG_P256K1 = "ECC_SECG_P256K1" + SYMMETRIC_DEFAULT = "SYMMETRIC_DEFAULT" + HMAC_224 = "HMAC_224" + HMAC_256 = "HMAC_256" + HMAC_384 = "HMAC_384" + HMAC_512 = "HMAC_512" + SM2 = "SM2" + ML_DSA_44 = "ML_DSA_44" + ML_DSA_65 = "ML_DSA_65" + ML_DSA_87 = "ML_DSA_87" + + +class KeyState(StrEnum): + Creating = "Creating" + Enabled = "Enabled" + Disabled = "Disabled" + PendingDeletion = "PendingDeletion" + PendingImport = "PendingImport" + PendingReplicaDeletion = "PendingReplicaDeletion" + Unavailable = "Unavailable" + Updating = "Updating" + + +class KeyUsageType(StrEnum): + SIGN_VERIFY = "SIGN_VERIFY" + ENCRYPT_DECRYPT = "ENCRYPT_DECRYPT" + GENERATE_VERIFY_MAC = "GENERATE_VERIFY_MAC" + KEY_AGREEMENT = "KEY_AGREEMENT" + + +class MacAlgorithmSpec(StrEnum): + HMAC_SHA_224 = "HMAC_SHA_224" + HMAC_SHA_256 = "HMAC_SHA_256" + HMAC_SHA_384 = "HMAC_SHA_384" + HMAC_SHA_512 = "HMAC_SHA_512" + + +class MessageType(StrEnum): + RAW = "RAW" + DIGEST = "DIGEST" + EXTERNAL_MU = "EXTERNAL_MU" + + +class MultiRegionKeyType(StrEnum): + PRIMARY = "PRIMARY" + REPLICA = "REPLICA" + + +class OriginType(StrEnum): + AWS_KMS = "AWS_KMS" + EXTERNAL = "EXTERNAL" + AWS_CLOUDHSM = "AWS_CLOUDHSM" + EXTERNAL_KEY_STORE = "EXTERNAL_KEY_STORE" + + +class RotationType(StrEnum): + AUTOMATIC = "AUTOMATIC" + ON_DEMAND = "ON_DEMAND" + + +class SigningAlgorithmSpec(StrEnum): + RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256" + RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384" + RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512" + RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256" + RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384" + RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512" + ECDSA_SHA_256 = "ECDSA_SHA_256" + ECDSA_SHA_384 = "ECDSA_SHA_384" + ECDSA_SHA_512 = "ECDSA_SHA_512" + SM2DSA = "SM2DSA" + ML_DSA_SHAKE_256 = "ML_DSA_SHAKE_256" + + +class WrappingKeySpec(StrEnum): + RSA_2048 = "RSA_2048" + RSA_3072 = "RSA_3072" + RSA_4096 = "RSA_4096" + SM2 = "SM2" + + +class XksProxyConnectivityType(StrEnum): + PUBLIC_ENDPOINT = "PUBLIC_ENDPOINT" + VPC_ENDPOINT_SERVICE = "VPC_ENDPOINT_SERVICE" + + +class AlreadyExistsException(ServiceException): + code: str = "AlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class CloudHsmClusterInUseException(ServiceException): + code: str = "CloudHsmClusterInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class CloudHsmClusterInvalidConfigurationException(ServiceException): + code: str = "CloudHsmClusterInvalidConfigurationException" + sender_fault: bool = False + status_code: int = 400 + + +class CloudHsmClusterNotActiveException(ServiceException): + code: str = "CloudHsmClusterNotActiveException" + sender_fault: bool = False + status_code: int = 400 + + +class CloudHsmClusterNotFoundException(ServiceException): + code: str = "CloudHsmClusterNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class CloudHsmClusterNotRelatedException(ServiceException): + code: str = "CloudHsmClusterNotRelatedException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class CustomKeyStoreHasCMKsException(ServiceException): + code: str = "CustomKeyStoreHasCMKsException" + sender_fault: bool = False + status_code: int = 400 + + +class CustomKeyStoreInvalidStateException(ServiceException): + code: str = "CustomKeyStoreInvalidStateException" + sender_fault: bool = False + status_code: int = 400 + + +class CustomKeyStoreNameInUseException(ServiceException): + code: str = "CustomKeyStoreNameInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class CustomKeyStoreNotFoundException(ServiceException): + code: str = "CustomKeyStoreNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class DependencyTimeoutException(ServiceException): + code: str = "DependencyTimeoutException" + sender_fault: bool = False + status_code: int = 400 + + +class DisabledException(ServiceException): + code: str = "DisabledException" + sender_fault: bool = False + status_code: int = 400 + + +class DryRunOperationException(ServiceException): + code: str = "DryRunOperationException" + sender_fault: bool = False + status_code: int = 400 + + +class ExpiredImportTokenException(ServiceException): + code: str = "ExpiredImportTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class IncorrectKeyException(ServiceException): + code: str = "IncorrectKeyException" + sender_fault: bool = False + status_code: int = 400 + + +class IncorrectKeyMaterialException(ServiceException): + code: str = "IncorrectKeyMaterialException" + sender_fault: bool = False + status_code: int = 400 + + +class IncorrectTrustAnchorException(ServiceException): + code: str = "IncorrectTrustAnchorException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAliasNameException(ServiceException): + code: str = "InvalidAliasNameException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidArnException(ServiceException): + code: str = "InvalidArnException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidCiphertextException(ServiceException): + code: str = "InvalidCiphertextException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidGrantIdException(ServiceException): + code: str = "InvalidGrantIdException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidGrantTokenException(ServiceException): + code: str = "InvalidGrantTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidImportTokenException(ServiceException): + code: str = "InvalidImportTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidKeyUsageException(ServiceException): + code: str = "InvalidKeyUsageException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidMarkerException(ServiceException): + code: str = "InvalidMarkerException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSInternalException(ServiceException): + code: str = "KMSInternalException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSInvalidMacException(ServiceException): + code: str = "KMSInvalidMacException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSInvalidSignatureException(ServiceException): + code: str = "KMSInvalidSignatureException" + sender_fault: bool = False + status_code: int = 400 + + +class KMSInvalidStateException(ServiceException): + code: str = "KMSInvalidStateException" + sender_fault: bool = False + status_code: int = 400 + + +class KeyUnavailableException(ServiceException): + code: str = "KeyUnavailableException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MalformedPolicyDocumentException(ServiceException): + code: str = "MalformedPolicyDocumentException" + sender_fault: bool = False + status_code: int = 400 + + +class NotFoundException(ServiceException): + code: str = "NotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class TagException(ServiceException): + code: str = "TagException" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedOperationException(ServiceException): + code: str = "UnsupportedOperationException" + sender_fault: bool = False + status_code: int = 400 + + +class XksKeyAlreadyInUseException(ServiceException): + code: str = "XksKeyAlreadyInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class XksKeyInvalidConfigurationException(ServiceException): + code: str = "XksKeyInvalidConfigurationException" + sender_fault: bool = False + status_code: int = 400 + + +class XksKeyNotFoundException(ServiceException): + code: str = "XksKeyNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyIncorrectAuthenticationCredentialException(ServiceException): + code: str = "XksProxyIncorrectAuthenticationCredentialException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyInvalidConfigurationException(ServiceException): + code: str = "XksProxyInvalidConfigurationException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyInvalidResponseException(ServiceException): + code: str = "XksProxyInvalidResponseException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyUriEndpointInUseException(ServiceException): + code: str = "XksProxyUriEndpointInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyUriInUseException(ServiceException): + code: str = "XksProxyUriInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyUriUnreachableException(ServiceException): + code: str = "XksProxyUriUnreachableException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyVpcEndpointServiceInUseException(ServiceException): + code: str = "XksProxyVpcEndpointServiceInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyVpcEndpointServiceInvalidConfigurationException(ServiceException): + code: str = "XksProxyVpcEndpointServiceInvalidConfigurationException" + sender_fault: bool = False + status_code: int = 400 + + +class XksProxyVpcEndpointServiceNotFoundException(ServiceException): + code: str = "XksProxyVpcEndpointServiceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +DateType = datetime + + +class AliasListEntry(TypedDict, total=False): + AliasName: Optional[AliasNameType] + AliasArn: Optional[ArnType] + TargetKeyId: Optional[KeyIdType] + CreationDate: Optional[DateType] + LastUpdatedDate: Optional[DateType] + + +AliasList = List[AliasListEntry] +AttestationDocumentType = bytes + + +class CancelKeyDeletionRequest(ServiceRequest): + KeyId: KeyIdType + + +class CancelKeyDeletionResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + + +CiphertextType = bytes + + +class ConnectCustomKeyStoreRequest(ServiceRequest): + CustomKeyStoreId: CustomKeyStoreIdType + + +class ConnectCustomKeyStoreResponse(TypedDict, total=False): + pass + + +class CreateAliasRequest(ServiceRequest): + AliasName: AliasNameType + TargetKeyId: KeyIdType + + +class XksProxyAuthenticationCredentialType(TypedDict, total=False): + AccessKeyId: XksProxyAuthenticationAccessKeyIdType + RawSecretAccessKey: XksProxyAuthenticationRawSecretAccessKeyType + + +class CreateCustomKeyStoreRequest(ServiceRequest): + CustomKeyStoreName: CustomKeyStoreNameType + CloudHsmClusterId: Optional[CloudHsmClusterIdType] + TrustAnchorCertificate: Optional[TrustAnchorCertificateType] + KeyStorePassword: Optional[KeyStorePasswordType] + CustomKeyStoreType: Optional[CustomKeyStoreType] + XksProxyUriEndpoint: Optional[XksProxyUriEndpointType] + XksProxyUriPath: Optional[XksProxyUriPathType] + XksProxyVpcEndpointServiceName: Optional[XksProxyVpcEndpointServiceNameType] + XksProxyAuthenticationCredential: Optional[XksProxyAuthenticationCredentialType] + XksProxyConnectivity: Optional[XksProxyConnectivityType] + + +class CreateCustomKeyStoreResponse(TypedDict, total=False): + CustomKeyStoreId: Optional[CustomKeyStoreIdType] + + +GrantTokenList = List[GrantTokenType] +EncryptionContextType = Dict[EncryptionContextKey, EncryptionContextValue] + + +class GrantConstraints(TypedDict, total=False): + EncryptionContextSubset: Optional[EncryptionContextType] + EncryptionContextEquals: Optional[EncryptionContextType] + + +GrantOperationList = List[GrantOperation] + + +class CreateGrantRequest(ServiceRequest): + KeyId: KeyIdType + GranteePrincipal: PrincipalIdType + RetiringPrincipal: Optional[PrincipalIdType] + Operations: GrantOperationList + Constraints: Optional[GrantConstraints] + GrantTokens: Optional[GrantTokenList] + Name: Optional[GrantNameType] + DryRun: Optional[NullableBooleanType] + + +class CreateGrantResponse(TypedDict, total=False): + GrantToken: Optional[GrantTokenType] + GrantId: Optional[GrantIdType] + + +class Tag(TypedDict, total=False): + TagKey: TagKeyType + TagValue: TagValueType + + +TagList = List[Tag] + + +class CreateKeyRequest(ServiceRequest): + Policy: Optional[PolicyType] + Description: Optional[DescriptionType] + KeyUsage: Optional[KeyUsageType] + CustomerMasterKeySpec: Optional[CustomerMasterKeySpec] + KeySpec: Optional[KeySpec] + Origin: Optional[OriginType] + CustomKeyStoreId: Optional[CustomKeyStoreIdType] + BypassPolicyLockoutSafetyCheck: Optional[BooleanType] + Tags: Optional[TagList] + MultiRegion: Optional[NullableBooleanType] + XksKeyId: Optional[XksKeyIdType] + + +class XksKeyConfigurationType(TypedDict, total=False): + Id: Optional[XksKeyIdType] + + +MacAlgorithmSpecList = List[MacAlgorithmSpec] + + +class MultiRegionKey(TypedDict, total=False): + Arn: Optional[ArnType] + Region: Optional[RegionType] + + +MultiRegionKeyList = List[MultiRegionKey] + + +class MultiRegionConfiguration(TypedDict, total=False): + MultiRegionKeyType: Optional[MultiRegionKeyType] + PrimaryKey: Optional[MultiRegionKey] + ReplicaKeys: Optional[MultiRegionKeyList] + + +KeyAgreementAlgorithmSpecList = List[KeyAgreementAlgorithmSpec] +SigningAlgorithmSpecList = List[SigningAlgorithmSpec] +EncryptionAlgorithmSpecList = List[EncryptionAlgorithmSpec] + + +class KeyMetadata(TypedDict, total=False): + AWSAccountId: Optional[AWSAccountIdType] + KeyId: KeyIdType + Arn: Optional[ArnType] + CreationDate: Optional[DateType] + Enabled: Optional[BooleanType] + Description: Optional[DescriptionType] + KeyUsage: Optional[KeyUsageType] + KeyState: Optional[KeyState] + DeletionDate: Optional[DateType] + ValidTo: Optional[DateType] + Origin: Optional[OriginType] + CustomKeyStoreId: Optional[CustomKeyStoreIdType] + CloudHsmClusterId: Optional[CloudHsmClusterIdType] + ExpirationModel: Optional[ExpirationModelType] + KeyManager: Optional[KeyManagerType] + CustomerMasterKeySpec: Optional[CustomerMasterKeySpec] + KeySpec: Optional[KeySpec] + EncryptionAlgorithms: Optional[EncryptionAlgorithmSpecList] + SigningAlgorithms: Optional[SigningAlgorithmSpecList] + KeyAgreementAlgorithms: Optional[KeyAgreementAlgorithmSpecList] + MultiRegion: Optional[NullableBooleanType] + MultiRegionConfiguration: Optional[MultiRegionConfiguration] + PendingDeletionWindowInDays: Optional[PendingWindowInDaysType] + MacAlgorithms: Optional[MacAlgorithmSpecList] + XksKeyConfiguration: Optional[XksKeyConfigurationType] + CurrentKeyMaterialId: Optional[BackingKeyIdType] + + +class CreateKeyResponse(TypedDict, total=False): + KeyMetadata: Optional[KeyMetadata] + + +class XksProxyConfigurationType(TypedDict, total=False): + Connectivity: Optional[XksProxyConnectivityType] + AccessKeyId: Optional[XksProxyAuthenticationAccessKeyIdType] + UriEndpoint: Optional[XksProxyUriEndpointType] + UriPath: Optional[XksProxyUriPathType] + VpcEndpointServiceName: Optional[XksProxyVpcEndpointServiceNameType] + + +class CustomKeyStoresListEntry(TypedDict, total=False): + CustomKeyStoreId: Optional[CustomKeyStoreIdType] + CustomKeyStoreName: Optional[CustomKeyStoreNameType] + CloudHsmClusterId: Optional[CloudHsmClusterIdType] + TrustAnchorCertificate: Optional[TrustAnchorCertificateType] + ConnectionState: Optional[ConnectionStateType] + ConnectionErrorCode: Optional[ConnectionErrorCodeType] + CreationDate: Optional[DateType] + CustomKeyStoreType: Optional[CustomKeyStoreType] + XksProxyConfiguration: Optional[XksProxyConfigurationType] + + +CustomKeyStoresList = List[CustomKeyStoresListEntry] + + +class RecipientInfo(TypedDict, total=False): + KeyEncryptionAlgorithm: Optional[KeyEncryptionMechanism] + AttestationDocument: Optional[AttestationDocumentType] + + +class DecryptRequest(ServiceRequest): + CiphertextBlob: CiphertextType + EncryptionContext: Optional[EncryptionContextType] + GrantTokens: Optional[GrantTokenList] + KeyId: Optional[KeyIdType] + EncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + Recipient: Optional[RecipientInfo] + DryRun: Optional[NullableBooleanType] + + +PlaintextType = bytes + + +class DecryptResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + Plaintext: Optional[PlaintextType] + EncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + CiphertextForRecipient: Optional[CiphertextType] + KeyMaterialId: Optional[BackingKeyIdType] + + +class DeleteAliasRequest(ServiceRequest): + AliasName: AliasNameType + + +class DeleteCustomKeyStoreRequest(ServiceRequest): + CustomKeyStoreId: CustomKeyStoreIdType + + +class DeleteCustomKeyStoreResponse(TypedDict, total=False): + pass + + +class DeleteImportedKeyMaterialRequest(ServiceRequest): + KeyId: KeyIdType + KeyMaterialId: Optional[BackingKeyIdType] + + +class DeleteImportedKeyMaterialResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + KeyMaterialId: Optional[BackingKeyIdResponseType] + + +PublicKeyType = bytes + + +class DeriveSharedSecretRequest(ServiceRequest): + KeyId: KeyIdType + KeyAgreementAlgorithm: KeyAgreementAlgorithmSpec + PublicKey: PublicKeyType + GrantTokens: Optional[GrantTokenList] + DryRun: Optional[NullableBooleanType] + Recipient: Optional[RecipientInfo] + + +class DeriveSharedSecretResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + SharedSecret: Optional[PlaintextType] + CiphertextForRecipient: Optional[CiphertextType] + KeyAgreementAlgorithm: Optional[KeyAgreementAlgorithmSpec] + KeyOrigin: Optional[OriginType] + + +class DescribeCustomKeyStoresRequest(ServiceRequest): + CustomKeyStoreId: Optional[CustomKeyStoreIdType] + CustomKeyStoreName: Optional[CustomKeyStoreNameType] + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + + +class DescribeCustomKeyStoresResponse(TypedDict, total=False): + CustomKeyStores: Optional[CustomKeyStoresList] + NextMarker: Optional[MarkerType] + Truncated: Optional[BooleanType] + + +class DescribeKeyRequest(ServiceRequest): + KeyId: KeyIdType + GrantTokens: Optional[GrantTokenList] + + +class DescribeKeyResponse(TypedDict, total=False): + KeyMetadata: Optional[KeyMetadata] + + +class DisableKeyRequest(ServiceRequest): + KeyId: KeyIdType + + +class DisableKeyRotationRequest(ServiceRequest): + KeyId: KeyIdType + + +class DisconnectCustomKeyStoreRequest(ServiceRequest): + CustomKeyStoreId: CustomKeyStoreIdType + + +class DisconnectCustomKeyStoreResponse(TypedDict, total=False): + pass + + +class EnableKeyRequest(ServiceRequest): + KeyId: KeyIdType + + +class EnableKeyRotationRequest(ServiceRequest): + KeyId: KeyIdType + RotationPeriodInDays: Optional[RotationPeriodInDaysType] + + +class EncryptRequest(ServiceRequest): + KeyId: KeyIdType + Plaintext: PlaintextType + EncryptionContext: Optional[EncryptionContextType] + GrantTokens: Optional[GrantTokenList] + EncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + DryRun: Optional[NullableBooleanType] + + +class EncryptResponse(TypedDict, total=False): + CiphertextBlob: Optional[CiphertextType] + KeyId: Optional[KeyIdType] + EncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + + +class GenerateDataKeyPairRequest(ServiceRequest): + EncryptionContext: Optional[EncryptionContextType] + KeyId: KeyIdType + KeyPairSpec: DataKeyPairSpec + GrantTokens: Optional[GrantTokenList] + Recipient: Optional[RecipientInfo] + DryRun: Optional[NullableBooleanType] + + +class GenerateDataKeyPairResponse(TypedDict, total=False): + PrivateKeyCiphertextBlob: Optional[CiphertextType] + PrivateKeyPlaintext: Optional[PlaintextType] + PublicKey: Optional[PublicKeyType] + KeyId: Optional[KeyIdType] + KeyPairSpec: Optional[DataKeyPairSpec] + CiphertextForRecipient: Optional[CiphertextType] + KeyMaterialId: Optional[BackingKeyIdType] + + +class GenerateDataKeyPairWithoutPlaintextRequest(ServiceRequest): + EncryptionContext: Optional[EncryptionContextType] + KeyId: KeyIdType + KeyPairSpec: DataKeyPairSpec + GrantTokens: Optional[GrantTokenList] + DryRun: Optional[NullableBooleanType] + + +class GenerateDataKeyPairWithoutPlaintextResponse(TypedDict, total=False): + PrivateKeyCiphertextBlob: Optional[CiphertextType] + PublicKey: Optional[PublicKeyType] + KeyId: Optional[KeyIdType] + KeyPairSpec: Optional[DataKeyPairSpec] + KeyMaterialId: Optional[BackingKeyIdType] + + +class GenerateDataKeyRequest(ServiceRequest): + KeyId: KeyIdType + EncryptionContext: Optional[EncryptionContextType] + NumberOfBytes: Optional[NumberOfBytesType] + KeySpec: Optional[DataKeySpec] + GrantTokens: Optional[GrantTokenList] + Recipient: Optional[RecipientInfo] + DryRun: Optional[NullableBooleanType] + + +class GenerateDataKeyResponse(TypedDict, total=False): + CiphertextBlob: Optional[CiphertextType] + Plaintext: Optional[PlaintextType] + KeyId: Optional[KeyIdType] + CiphertextForRecipient: Optional[CiphertextType] + KeyMaterialId: Optional[BackingKeyIdType] + + +class GenerateDataKeyWithoutPlaintextRequest(ServiceRequest): + KeyId: KeyIdType + EncryptionContext: Optional[EncryptionContextType] + KeySpec: Optional[DataKeySpec] + NumberOfBytes: Optional[NumberOfBytesType] + GrantTokens: Optional[GrantTokenList] + DryRun: Optional[NullableBooleanType] + + +class GenerateDataKeyWithoutPlaintextResponse(TypedDict, total=False): + CiphertextBlob: Optional[CiphertextType] + KeyId: Optional[KeyIdType] + KeyMaterialId: Optional[BackingKeyIdType] + + +class GenerateMacRequest(ServiceRequest): + Message: PlaintextType + KeyId: KeyIdType + MacAlgorithm: MacAlgorithmSpec + GrantTokens: Optional[GrantTokenList] + DryRun: Optional[NullableBooleanType] + + +class GenerateMacResponse(TypedDict, total=False): + Mac: Optional[CiphertextType] + MacAlgorithm: Optional[MacAlgorithmSpec] + KeyId: Optional[KeyIdType] + + +class GenerateRandomRequest(ServiceRequest): + NumberOfBytes: Optional[NumberOfBytesType] + CustomKeyStoreId: Optional[CustomKeyStoreIdType] + Recipient: Optional[RecipientInfo] + + +class GenerateRandomResponse(TypedDict, total=False): + Plaintext: Optional[PlaintextType] + CiphertextForRecipient: Optional[CiphertextType] + + +class GetKeyPolicyRequest(ServiceRequest): + KeyId: KeyIdType + PolicyName: Optional[PolicyNameType] + + +class GetKeyPolicyResponse(TypedDict, total=False): + Policy: Optional[PolicyType] + PolicyName: Optional[PolicyNameType] + + +class GetKeyRotationStatusRequest(ServiceRequest): + KeyId: KeyIdType + + +class GetKeyRotationStatusResponse(TypedDict, total=False): + KeyRotationEnabled: Optional[BooleanType] + KeyId: Optional[KeyIdType] + RotationPeriodInDays: Optional[RotationPeriodInDaysType] + NextRotationDate: Optional[DateType] + OnDemandRotationStartDate: Optional[DateType] + + +class GetParametersForImportRequest(ServiceRequest): + KeyId: KeyIdType + WrappingAlgorithm: AlgorithmSpec + WrappingKeySpec: WrappingKeySpec + + +class GetParametersForImportResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + ImportToken: Optional[CiphertextType] + PublicKey: Optional[PlaintextType] + ParametersValidTo: Optional[DateType] + + +class GetPublicKeyRequest(ServiceRequest): + KeyId: KeyIdType + GrantTokens: Optional[GrantTokenList] + + +class GetPublicKeyResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + PublicKey: Optional[PublicKeyType] + CustomerMasterKeySpec: Optional[CustomerMasterKeySpec] + KeySpec: Optional[KeySpec] + KeyUsage: Optional[KeyUsageType] + EncryptionAlgorithms: Optional[EncryptionAlgorithmSpecList] + SigningAlgorithms: Optional[SigningAlgorithmSpecList] + KeyAgreementAlgorithms: Optional[KeyAgreementAlgorithmSpecList] + + +class GrantListEntry(TypedDict, total=False): + KeyId: Optional[KeyIdType] + GrantId: Optional[GrantIdType] + Name: Optional[GrantNameType] + CreationDate: Optional[DateType] + GranteePrincipal: Optional[PrincipalIdType] + RetiringPrincipal: Optional[PrincipalIdType] + IssuingAccount: Optional[PrincipalIdType] + Operations: Optional[GrantOperationList] + Constraints: Optional[GrantConstraints] + + +GrantList = List[GrantListEntry] + + +class ImportKeyMaterialRequest(ServiceRequest): + KeyId: KeyIdType + ImportToken: CiphertextType + EncryptedKeyMaterial: CiphertextType + ValidTo: Optional[DateType] + ExpirationModel: Optional[ExpirationModelType] + ImportType: Optional[ImportType] + KeyMaterialDescription: Optional[KeyMaterialDescriptionType] + KeyMaterialId: Optional[BackingKeyIdType] + + +class ImportKeyMaterialResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + KeyMaterialId: Optional[BackingKeyIdType] + + +class KeyListEntry(TypedDict, total=False): + KeyId: Optional[KeyIdType] + KeyArn: Optional[ArnType] + + +KeyList = List[KeyListEntry] + + +class ListAliasesRequest(ServiceRequest): + KeyId: Optional[KeyIdType] + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + + +class ListAliasesResponse(TypedDict, total=False): + Aliases: Optional[AliasList] + NextMarker: Optional[MarkerType] + Truncated: Optional[BooleanType] + + +class ListGrantsRequest(ServiceRequest): + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + KeyId: KeyIdType + GrantId: Optional[GrantIdType] + GranteePrincipal: Optional[PrincipalIdType] + + +class ListGrantsResponse(TypedDict, total=False): + Grants: Optional[GrantList] + NextMarker: Optional[MarkerType] + Truncated: Optional[BooleanType] + + +class ListKeyPoliciesRequest(ServiceRequest): + KeyId: KeyIdType + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + + +PolicyNameList = List[PolicyNameType] + + +class ListKeyPoliciesResponse(TypedDict, total=False): + PolicyNames: Optional[PolicyNameList] + NextMarker: Optional[MarkerType] + Truncated: Optional[BooleanType] + + +class ListKeyRotationsRequest(ServiceRequest): + KeyId: KeyIdType + IncludeKeyMaterial: Optional[IncludeKeyMaterial] + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + + +class RotationsListEntry(TypedDict, total=False): + KeyId: Optional[KeyIdType] + KeyMaterialId: Optional[BackingKeyIdType] + KeyMaterialDescription: Optional[KeyMaterialDescriptionType] + ImportState: Optional[ImportState] + KeyMaterialState: Optional[KeyMaterialState] + ExpirationModel: Optional[ExpirationModelType] + ValidTo: Optional[DateType] + RotationDate: Optional[DateType] + RotationType: Optional[RotationType] + + +RotationsList = List[RotationsListEntry] + + +class ListKeyRotationsResponse(TypedDict, total=False): + Rotations: Optional[RotationsList] + NextMarker: Optional[MarkerType] + Truncated: Optional[BooleanType] + + +class ListKeysRequest(ServiceRequest): + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + + +class ListKeysResponse(TypedDict, total=False): + Keys: Optional[KeyList] + NextMarker: Optional[MarkerType] + Truncated: Optional[BooleanType] + + +class ListResourceTagsRequest(ServiceRequest): + KeyId: KeyIdType + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + + +class ListResourceTagsResponse(TypedDict, total=False): + Tags: Optional[TagList] + NextMarker: Optional[MarkerType] + Truncated: Optional[BooleanType] + + +class ListRetirableGrantsRequest(ServiceRequest): + Limit: Optional[LimitType] + Marker: Optional[MarkerType] + RetiringPrincipal: PrincipalIdType + + +class PutKeyPolicyRequest(ServiceRequest): + KeyId: KeyIdType + PolicyName: Optional[PolicyNameType] + Policy: PolicyType + BypassPolicyLockoutSafetyCheck: Optional[BooleanType] + + +class ReEncryptRequest(ServiceRequest): + CiphertextBlob: CiphertextType + SourceEncryptionContext: Optional[EncryptionContextType] + SourceKeyId: Optional[KeyIdType] + DestinationKeyId: KeyIdType + DestinationEncryptionContext: Optional[EncryptionContextType] + SourceEncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + DestinationEncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + GrantTokens: Optional[GrantTokenList] + DryRun: Optional[NullableBooleanType] + + +class ReEncryptResponse(TypedDict, total=False): + CiphertextBlob: Optional[CiphertextType] + SourceKeyId: Optional[KeyIdType] + KeyId: Optional[KeyIdType] + SourceEncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + DestinationEncryptionAlgorithm: Optional[EncryptionAlgorithmSpec] + SourceKeyMaterialId: Optional[BackingKeyIdType] + DestinationKeyMaterialId: Optional[BackingKeyIdType] + + +class ReplicateKeyRequest(ServiceRequest): + KeyId: KeyIdType + ReplicaRegion: RegionType + Policy: Optional[PolicyType] + BypassPolicyLockoutSafetyCheck: Optional[BooleanType] + Description: Optional[DescriptionType] + Tags: Optional[TagList] + + +class ReplicateKeyResponse(TypedDict, total=False): + ReplicaKeyMetadata: Optional[KeyMetadata] + ReplicaPolicy: Optional[PolicyType] + ReplicaTags: Optional[TagList] + + +class RetireGrantRequest(ServiceRequest): + GrantToken: Optional[GrantTokenType] + KeyId: Optional[KeyIdType] + GrantId: Optional[GrantIdType] + DryRun: Optional[NullableBooleanType] + + +class RevokeGrantRequest(ServiceRequest): + KeyId: KeyIdType + GrantId: GrantIdType + DryRun: Optional[NullableBooleanType] + + +class RotateKeyOnDemandRequest(ServiceRequest): + KeyId: KeyIdType + + +class RotateKeyOnDemandResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + + +class ScheduleKeyDeletionRequest(ServiceRequest): + KeyId: KeyIdType + PendingWindowInDays: Optional[PendingWindowInDaysType] + + +class ScheduleKeyDeletionResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + DeletionDate: Optional[DateType] + KeyState: Optional[KeyState] + PendingWindowInDays: Optional[PendingWindowInDaysType] + + +class SignRequest(ServiceRequest): + KeyId: KeyIdType + Message: PlaintextType + MessageType: Optional[MessageType] + GrantTokens: Optional[GrantTokenList] + SigningAlgorithm: SigningAlgorithmSpec + DryRun: Optional[NullableBooleanType] + + +class SignResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + Signature: Optional[CiphertextType] + SigningAlgorithm: Optional[SigningAlgorithmSpec] + + +TagKeyList = List[TagKeyType] + + +class TagResourceRequest(ServiceRequest): + KeyId: KeyIdType + Tags: TagList + + +class UntagResourceRequest(ServiceRequest): + KeyId: KeyIdType + TagKeys: TagKeyList + + +class UpdateAliasRequest(ServiceRequest): + AliasName: AliasNameType + TargetKeyId: KeyIdType + + +class UpdateCustomKeyStoreRequest(ServiceRequest): + CustomKeyStoreId: CustomKeyStoreIdType + NewCustomKeyStoreName: Optional[CustomKeyStoreNameType] + KeyStorePassword: Optional[KeyStorePasswordType] + CloudHsmClusterId: Optional[CloudHsmClusterIdType] + XksProxyUriEndpoint: Optional[XksProxyUriEndpointType] + XksProxyUriPath: Optional[XksProxyUriPathType] + XksProxyVpcEndpointServiceName: Optional[XksProxyVpcEndpointServiceNameType] + XksProxyAuthenticationCredential: Optional[XksProxyAuthenticationCredentialType] + XksProxyConnectivity: Optional[XksProxyConnectivityType] + + +class UpdateCustomKeyStoreResponse(TypedDict, total=False): + pass + + +class UpdateKeyDescriptionRequest(ServiceRequest): + KeyId: KeyIdType + Description: DescriptionType + + +class UpdatePrimaryRegionRequest(ServiceRequest): + KeyId: KeyIdType + PrimaryRegion: RegionType + + +class VerifyMacRequest(ServiceRequest): + Message: PlaintextType + KeyId: KeyIdType + MacAlgorithm: MacAlgorithmSpec + Mac: CiphertextType + GrantTokens: Optional[GrantTokenList] + DryRun: Optional[NullableBooleanType] + + +class VerifyMacResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + MacValid: Optional[BooleanType] + MacAlgorithm: Optional[MacAlgorithmSpec] + + +class VerifyRequest(ServiceRequest): + KeyId: KeyIdType + Message: PlaintextType + MessageType: Optional[MessageType] + Signature: CiphertextType + SigningAlgorithm: SigningAlgorithmSpec + GrantTokens: Optional[GrantTokenList] + DryRun: Optional[NullableBooleanType] + + +class VerifyResponse(TypedDict, total=False): + KeyId: Optional[KeyIdType] + SignatureValid: Optional[BooleanType] + SigningAlgorithm: Optional[SigningAlgorithmSpec] + + +class KmsApi: + service = "kms" + version = "2014-11-01" + + @handler("CancelKeyDeletion") + def cancel_key_deletion( + self, context: RequestContext, key_id: KeyIdType, **kwargs + ) -> CancelKeyDeletionResponse: + raise NotImplementedError + + @handler("ConnectCustomKeyStore") + def connect_custom_key_store( + self, context: RequestContext, custom_key_store_id: CustomKeyStoreIdType, **kwargs + ) -> ConnectCustomKeyStoreResponse: + raise NotImplementedError + + @handler("CreateAlias") + def create_alias( + self, context: RequestContext, alias_name: AliasNameType, target_key_id: KeyIdType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("CreateCustomKeyStore") + def create_custom_key_store( + self, + context: RequestContext, + custom_key_store_name: CustomKeyStoreNameType, + cloud_hsm_cluster_id: CloudHsmClusterIdType | None = None, + trust_anchor_certificate: TrustAnchorCertificateType | None = None, + key_store_password: KeyStorePasswordType | None = None, + custom_key_store_type: CustomKeyStoreType | None = None, + xks_proxy_uri_endpoint: XksProxyUriEndpointType | None = None, + xks_proxy_uri_path: XksProxyUriPathType | None = None, + xks_proxy_vpc_endpoint_service_name: XksProxyVpcEndpointServiceNameType | None = None, + xks_proxy_authentication_credential: XksProxyAuthenticationCredentialType | None = None, + xks_proxy_connectivity: XksProxyConnectivityType | None = None, + **kwargs, + ) -> CreateCustomKeyStoreResponse: + raise NotImplementedError + + @handler("CreateGrant") + def create_grant( + self, + context: RequestContext, + key_id: KeyIdType, + grantee_principal: PrincipalIdType, + operations: GrantOperationList, + retiring_principal: PrincipalIdType | None = None, + constraints: GrantConstraints | None = None, + grant_tokens: GrantTokenList | None = None, + name: GrantNameType | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> CreateGrantResponse: + raise NotImplementedError + + @handler("CreateKey") + def create_key( + self, + context: RequestContext, + policy: PolicyType | None = None, + description: DescriptionType | None = None, + key_usage: KeyUsageType | None = None, + customer_master_key_spec: CustomerMasterKeySpec | None = None, + key_spec: KeySpec | None = None, + origin: OriginType | None = None, + custom_key_store_id: CustomKeyStoreIdType | None = None, + bypass_policy_lockout_safety_check: BooleanType | None = None, + tags: TagList | None = None, + multi_region: NullableBooleanType | None = None, + xks_key_id: XksKeyIdType | None = None, + **kwargs, + ) -> CreateKeyResponse: + raise NotImplementedError + + @handler("Decrypt") + def decrypt( + self, + context: RequestContext, + ciphertext_blob: CiphertextType, + encryption_context: EncryptionContextType | None = None, + grant_tokens: GrantTokenList | None = None, + key_id: KeyIdType | None = None, + encryption_algorithm: EncryptionAlgorithmSpec | None = None, + recipient: RecipientInfo | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> DecryptResponse: + raise NotImplementedError + + @handler("DeleteAlias") + def delete_alias(self, context: RequestContext, alias_name: AliasNameType, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteCustomKeyStore") + def delete_custom_key_store( + self, context: RequestContext, custom_key_store_id: CustomKeyStoreIdType, **kwargs + ) -> DeleteCustomKeyStoreResponse: + raise NotImplementedError + + @handler("DeleteImportedKeyMaterial") + def delete_imported_key_material( + self, + context: RequestContext, + key_id: KeyIdType, + key_material_id: BackingKeyIdType | None = None, + **kwargs, + ) -> DeleteImportedKeyMaterialResponse: + raise NotImplementedError + + @handler("DeriveSharedSecret") + def derive_shared_secret( + self, + context: RequestContext, + key_id: KeyIdType, + key_agreement_algorithm: KeyAgreementAlgorithmSpec, + public_key: PublicKeyType, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + recipient: RecipientInfo | None = None, + **kwargs, + ) -> DeriveSharedSecretResponse: + raise NotImplementedError + + @handler("DescribeCustomKeyStores") + def describe_custom_key_stores( + self, + context: RequestContext, + custom_key_store_id: CustomKeyStoreIdType | None = None, + custom_key_store_name: CustomKeyStoreNameType | None = None, + limit: LimitType | None = None, + marker: MarkerType | None = None, + **kwargs, + ) -> DescribeCustomKeyStoresResponse: + raise NotImplementedError + + @handler("DescribeKey") + def describe_key( + self, + context: RequestContext, + key_id: KeyIdType, + grant_tokens: GrantTokenList | None = None, + **kwargs, + ) -> DescribeKeyResponse: + raise NotImplementedError + + @handler("DisableKey") + def disable_key(self, context: RequestContext, key_id: KeyIdType, **kwargs) -> None: + raise NotImplementedError + + @handler("DisableKeyRotation") + def disable_key_rotation(self, context: RequestContext, key_id: KeyIdType, **kwargs) -> None: + raise NotImplementedError + + @handler("DisconnectCustomKeyStore") + def disconnect_custom_key_store( + self, context: RequestContext, custom_key_store_id: CustomKeyStoreIdType, **kwargs + ) -> DisconnectCustomKeyStoreResponse: + raise NotImplementedError + + @handler("EnableKey") + def enable_key(self, context: RequestContext, key_id: KeyIdType, **kwargs) -> None: + raise NotImplementedError + + @handler("EnableKeyRotation") + def enable_key_rotation( + self, + context: RequestContext, + key_id: KeyIdType, + rotation_period_in_days: RotationPeriodInDaysType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("Encrypt") + def encrypt( + self, + context: RequestContext, + key_id: KeyIdType, + plaintext: PlaintextType, + encryption_context: EncryptionContextType | None = None, + grant_tokens: GrantTokenList | None = None, + encryption_algorithm: EncryptionAlgorithmSpec | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> EncryptResponse: + raise NotImplementedError + + @handler("GenerateDataKey") + def generate_data_key( + self, + context: RequestContext, + key_id: KeyIdType, + encryption_context: EncryptionContextType | None = None, + number_of_bytes: NumberOfBytesType | None = None, + key_spec: DataKeySpec | None = None, + grant_tokens: GrantTokenList | None = None, + recipient: RecipientInfo | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> GenerateDataKeyResponse: + raise NotImplementedError + + @handler("GenerateDataKeyPair") + def generate_data_key_pair( + self, + context: RequestContext, + key_id: KeyIdType, + key_pair_spec: DataKeyPairSpec, + encryption_context: EncryptionContextType | None = None, + grant_tokens: GrantTokenList | None = None, + recipient: RecipientInfo | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> GenerateDataKeyPairResponse: + raise NotImplementedError + + @handler("GenerateDataKeyPairWithoutPlaintext") + def generate_data_key_pair_without_plaintext( + self, + context: RequestContext, + key_id: KeyIdType, + key_pair_spec: DataKeyPairSpec, + encryption_context: EncryptionContextType | None = None, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> GenerateDataKeyPairWithoutPlaintextResponse: + raise NotImplementedError + + @handler("GenerateDataKeyWithoutPlaintext") + def generate_data_key_without_plaintext( + self, + context: RequestContext, + key_id: KeyIdType, + encryption_context: EncryptionContextType | None = None, + key_spec: DataKeySpec | None = None, + number_of_bytes: NumberOfBytesType | None = None, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> GenerateDataKeyWithoutPlaintextResponse: + raise NotImplementedError + + @handler("GenerateMac") + def generate_mac( + self, + context: RequestContext, + message: PlaintextType, + key_id: KeyIdType, + mac_algorithm: MacAlgorithmSpec, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> GenerateMacResponse: + raise NotImplementedError + + @handler("GenerateRandom") + def generate_random( + self, + context: RequestContext, + number_of_bytes: NumberOfBytesType | None = None, + custom_key_store_id: CustomKeyStoreIdType | None = None, + recipient: RecipientInfo | None = None, + **kwargs, + ) -> GenerateRandomResponse: + raise NotImplementedError + + @handler("GetKeyPolicy") + def get_key_policy( + self, + context: RequestContext, + key_id: KeyIdType, + policy_name: PolicyNameType | None = None, + **kwargs, + ) -> GetKeyPolicyResponse: + raise NotImplementedError + + @handler("GetKeyRotationStatus") + def get_key_rotation_status( + self, context: RequestContext, key_id: KeyIdType, **kwargs + ) -> GetKeyRotationStatusResponse: + raise NotImplementedError + + @handler("GetParametersForImport") + def get_parameters_for_import( + self, + context: RequestContext, + key_id: KeyIdType, + wrapping_algorithm: AlgorithmSpec, + wrapping_key_spec: WrappingKeySpec, + **kwargs, + ) -> GetParametersForImportResponse: + raise NotImplementedError + + @handler("GetPublicKey") + def get_public_key( + self, + context: RequestContext, + key_id: KeyIdType, + grant_tokens: GrantTokenList | None = None, + **kwargs, + ) -> GetPublicKeyResponse: + raise NotImplementedError + + @handler("ImportKeyMaterial") + def import_key_material( + self, + context: RequestContext, + key_id: KeyIdType, + import_token: CiphertextType, + encrypted_key_material: CiphertextType, + valid_to: DateType | None = None, + expiration_model: ExpirationModelType | None = None, + import_type: ImportType | None = None, + key_material_description: KeyMaterialDescriptionType | None = None, + key_material_id: BackingKeyIdType | None = None, + **kwargs, + ) -> ImportKeyMaterialResponse: + raise NotImplementedError + + @handler("ListAliases") + def list_aliases( + self, + context: RequestContext, + key_id: KeyIdType | None = None, + limit: LimitType | None = None, + marker: MarkerType | None = None, + **kwargs, + ) -> ListAliasesResponse: + raise NotImplementedError + + @handler("ListGrants") + def list_grants( + self, + context: RequestContext, + key_id: KeyIdType, + limit: LimitType | None = None, + marker: MarkerType | None = None, + grant_id: GrantIdType | None = None, + grantee_principal: PrincipalIdType | None = None, + **kwargs, + ) -> ListGrantsResponse: + raise NotImplementedError + + @handler("ListKeyPolicies") + def list_key_policies( + self, + context: RequestContext, + key_id: KeyIdType, + limit: LimitType | None = None, + marker: MarkerType | None = None, + **kwargs, + ) -> ListKeyPoliciesResponse: + raise NotImplementedError + + @handler("ListKeyRotations") + def list_key_rotations( + self, + context: RequestContext, + key_id: KeyIdType, + include_key_material: IncludeKeyMaterial | None = None, + limit: LimitType | None = None, + marker: MarkerType | None = None, + **kwargs, + ) -> ListKeyRotationsResponse: + raise NotImplementedError + + @handler("ListKeys") + def list_keys( + self, + context: RequestContext, + limit: LimitType | None = None, + marker: MarkerType | None = None, + **kwargs, + ) -> ListKeysResponse: + raise NotImplementedError + + @handler("ListResourceTags") + def list_resource_tags( + self, + context: RequestContext, + key_id: KeyIdType, + limit: LimitType | None = None, + marker: MarkerType | None = None, + **kwargs, + ) -> ListResourceTagsResponse: + raise NotImplementedError + + @handler("ListRetirableGrants") + def list_retirable_grants( + self, + context: RequestContext, + retiring_principal: PrincipalIdType, + limit: LimitType | None = None, + marker: MarkerType | None = None, + **kwargs, + ) -> ListGrantsResponse: + raise NotImplementedError + + @handler("PutKeyPolicy") + def put_key_policy( + self, + context: RequestContext, + key_id: KeyIdType, + policy: PolicyType, + policy_name: PolicyNameType | None = None, + bypass_policy_lockout_safety_check: BooleanType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ReEncrypt") + def re_encrypt( + self, + context: RequestContext, + ciphertext_blob: CiphertextType, + destination_key_id: KeyIdType, + source_encryption_context: EncryptionContextType | None = None, + source_key_id: KeyIdType | None = None, + destination_encryption_context: EncryptionContextType | None = None, + source_encryption_algorithm: EncryptionAlgorithmSpec | None = None, + destination_encryption_algorithm: EncryptionAlgorithmSpec | None = None, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> ReEncryptResponse: + raise NotImplementedError + + @handler("ReplicateKey") + def replicate_key( + self, + context: RequestContext, + key_id: KeyIdType, + replica_region: RegionType, + policy: PolicyType | None = None, + bypass_policy_lockout_safety_check: BooleanType | None = None, + description: DescriptionType | None = None, + tags: TagList | None = None, + **kwargs, + ) -> ReplicateKeyResponse: + raise NotImplementedError + + @handler("RetireGrant") + def retire_grant( + self, + context: RequestContext, + grant_token: GrantTokenType | None = None, + key_id: KeyIdType | None = None, + grant_id: GrantIdType | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RevokeGrant") + def revoke_grant( + self, + context: RequestContext, + key_id: KeyIdType, + grant_id: GrantIdType, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RotateKeyOnDemand") + def rotate_key_on_demand( + self, context: RequestContext, key_id: KeyIdType, **kwargs + ) -> RotateKeyOnDemandResponse: + raise NotImplementedError + + @handler("ScheduleKeyDeletion") + def schedule_key_deletion( + self, + context: RequestContext, + key_id: KeyIdType, + pending_window_in_days: PendingWindowInDaysType | None = None, + **kwargs, + ) -> ScheduleKeyDeletionResponse: + raise NotImplementedError + + @handler("Sign") + def sign( + self, + context: RequestContext, + key_id: KeyIdType, + message: PlaintextType, + signing_algorithm: SigningAlgorithmSpec, + message_type: MessageType | None = None, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> SignResponse: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, key_id: KeyIdType, tags: TagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, key_id: KeyIdType, tag_keys: TagKeyList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdateAlias") + def update_alias( + self, context: RequestContext, alias_name: AliasNameType, target_key_id: KeyIdType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdateCustomKeyStore") + def update_custom_key_store( + self, + context: RequestContext, + custom_key_store_id: CustomKeyStoreIdType, + new_custom_key_store_name: CustomKeyStoreNameType | None = None, + key_store_password: KeyStorePasswordType | None = None, + cloud_hsm_cluster_id: CloudHsmClusterIdType | None = None, + xks_proxy_uri_endpoint: XksProxyUriEndpointType | None = None, + xks_proxy_uri_path: XksProxyUriPathType | None = None, + xks_proxy_vpc_endpoint_service_name: XksProxyVpcEndpointServiceNameType | None = None, + xks_proxy_authentication_credential: XksProxyAuthenticationCredentialType | None = None, + xks_proxy_connectivity: XksProxyConnectivityType | None = None, + **kwargs, + ) -> UpdateCustomKeyStoreResponse: + raise NotImplementedError + + @handler("UpdateKeyDescription") + def update_key_description( + self, context: RequestContext, key_id: KeyIdType, description: DescriptionType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdatePrimaryRegion") + def update_primary_region( + self, context: RequestContext, key_id: KeyIdType, primary_region: RegionType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("Verify") + def verify( + self, + context: RequestContext, + key_id: KeyIdType, + message: PlaintextType, + signature: CiphertextType, + signing_algorithm: SigningAlgorithmSpec, + message_type: MessageType | None = None, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> VerifyResponse: + raise NotImplementedError + + @handler("VerifyMac") + def verify_mac( + self, + context: RequestContext, + message: PlaintextType, + key_id: KeyIdType, + mac_algorithm: MacAlgorithmSpec, + mac: CiphertextType, + grant_tokens: GrantTokenList | None = None, + dry_run: NullableBooleanType | None = None, + **kwargs, + ) -> VerifyMacResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/lambda_/__init__.py b/localstack-core/localstack/aws/api/lambda_/__init__.py new file mode 100644 index 0000000000000..0f1e716980e9e --- /dev/null +++ b/localstack-core/localstack/aws/api/lambda_/__init__.py @@ -0,0 +1,2646 @@ +from datetime import datetime +from enum import StrEnum +from typing import IO, Dict, Iterable, Iterator, List, Optional, TypedDict, Union + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Action = str +AdditionalVersion = str +Alias = str +AllowCredentials = bool +Arn = str +BatchSize = int +BisectBatchOnFunctionError = bool +Boolean = bool +CodeSigningConfigArn = str +CodeSigningConfigId = str +CollectionName = str +DatabaseName = str +Description = str +DestinationArn = str +Enabled = bool +Endpoint = str +EnvironmentVariableName = str +EnvironmentVariableValue = str +EphemeralStorageSize = int +EventSourceMappingArn = str +EventSourceToken = str +FileSystemArn = str +FilterCriteriaErrorCode = str +FilterCriteriaErrorMessage = str +FunctionArn = str +FunctionName = str +FunctionUrl = str +FunctionUrlQualifier = str +Handler = str +Header = str +HttpStatus = int +Integer = int +KMSKeyArn = str +LastUpdateStatusReason = str +LayerArn = str +LayerName = str +LayerPermissionAllowedAction = str +LayerPermissionAllowedPrincipal = str +LayerVersionArn = str +LicenseInfo = str +LocalMountPath = str +LogGroup = str +MasterRegion = str +MaxAge = int +MaxFunctionEventInvokeConfigListItems = int +MaxItems = int +MaxLayerListItems = int +MaxListItems = int +MaxProvisionedConcurrencyConfigListItems = int +MaximumBatchingWindowInSeconds = int +MaximumConcurrency = int +MaximumEventAgeInSeconds = int +MaximumNumberOfPollers = int +MaximumRecordAgeInSeconds = int +MaximumRetryAttempts = int +MaximumRetryAttemptsEventSourceMapping = int +MemorySize = int +Method = str +MinimumNumberOfPollers = int +NameSpacedFunctionArn = str +NamespacedFunctionName = str +NamespacedStatementId = str +NonNegativeInteger = int +NullableBoolean = bool +OrganizationId = str +Origin = str +ParallelizationFactor = int +Pattern = str +PositiveInteger = int +Principal = str +PrincipalOrgID = str +Qualifier = str +Queue = str +ReservedConcurrentExecutions = int +ResourceArn = str +RoleArn = str +RuntimeVersionArn = str +S3Bucket = str +S3Key = str +S3ObjectVersion = str +SchemaRegistryUri = str +SecurityGroupId = str +SensitiveString = str +SourceOwner = str +StateReason = str +StatementId = str +String = str +SubnetId = str +TagKey = str +TagValue = str +TaggableResource = str +TagsErrorCode = str +TagsErrorMessage = str +Timeout = int +Timestamp = str +Topic = str +TumblingWindowInSeconds = int +URI = str +UnqualifiedFunctionName = str +UnreservedConcurrentExecutions = int +Version = str +VpcId = str +Weight = float +WorkingDirectory = str + + +class ApplicationLogLevel(StrEnum): + TRACE = "TRACE" + DEBUG = "DEBUG" + INFO = "INFO" + WARN = "WARN" + ERROR = "ERROR" + FATAL = "FATAL" + + +class Architecture(StrEnum): + x86_64 = "x86_64" + arm64 = "arm64" + + +class CodeSigningPolicy(StrEnum): + Warn = "Warn" + Enforce = "Enforce" + + +class EndPointType(StrEnum): + KAFKA_BOOTSTRAP_SERVERS = "KAFKA_BOOTSTRAP_SERVERS" + + +class EventSourceMappingMetric(StrEnum): + EventCount = "EventCount" + + +class EventSourcePosition(StrEnum): + TRIM_HORIZON = "TRIM_HORIZON" + LATEST = "LATEST" + AT_TIMESTAMP = "AT_TIMESTAMP" + + +class FullDocument(StrEnum): + UpdateLookup = "UpdateLookup" + Default = "Default" + + +class FunctionResponseType(StrEnum): + ReportBatchItemFailures = "ReportBatchItemFailures" + + +class FunctionUrlAuthType(StrEnum): + NONE = "NONE" + AWS_IAM = "AWS_IAM" + + +class FunctionVersion(StrEnum): + ALL = "ALL" + + +class InvocationType(StrEnum): + Event = "Event" + RequestResponse = "RequestResponse" + DryRun = "DryRun" + + +class InvokeMode(StrEnum): + BUFFERED = "BUFFERED" + RESPONSE_STREAM = "RESPONSE_STREAM" + + +class KafkaSchemaRegistryAuthType(StrEnum): + BASIC_AUTH = "BASIC_AUTH" + CLIENT_CERTIFICATE_TLS_AUTH = "CLIENT_CERTIFICATE_TLS_AUTH" + SERVER_ROOT_CA_CERTIFICATE = "SERVER_ROOT_CA_CERTIFICATE" + + +class KafkaSchemaValidationAttribute(StrEnum): + KEY = "KEY" + VALUE = "VALUE" + + +class LastUpdateStatus(StrEnum): + Successful = "Successful" + Failed = "Failed" + InProgress = "InProgress" + + +class LastUpdateStatusReasonCode(StrEnum): + EniLimitExceeded = "EniLimitExceeded" + InsufficientRolePermissions = "InsufficientRolePermissions" + InvalidConfiguration = "InvalidConfiguration" + InternalError = "InternalError" + SubnetOutOfIPAddresses = "SubnetOutOfIPAddresses" + InvalidSubnet = "InvalidSubnet" + InvalidSecurityGroup = "InvalidSecurityGroup" + ImageDeleted = "ImageDeleted" + ImageAccessDenied = "ImageAccessDenied" + InvalidImage = "InvalidImage" + KMSKeyAccessDenied = "KMSKeyAccessDenied" + KMSKeyNotFound = "KMSKeyNotFound" + InvalidStateKMSKey = "InvalidStateKMSKey" + DisabledKMSKey = "DisabledKMSKey" + EFSIOError = "EFSIOError" + EFSMountConnectivityError = "EFSMountConnectivityError" + EFSMountFailure = "EFSMountFailure" + EFSMountTimeout = "EFSMountTimeout" + InvalidRuntime = "InvalidRuntime" + InvalidZipFileException = "InvalidZipFileException" + FunctionError = "FunctionError" + + +class LogFormat(StrEnum): + JSON = "JSON" + Text = "Text" + + +class LogType(StrEnum): + None_ = "None" + Tail = "Tail" + + +class PackageType(StrEnum): + Zip = "Zip" + Image = "Image" + + +class ProvisionedConcurrencyStatusEnum(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + READY = "READY" + FAILED = "FAILED" + + +class RecursiveLoop(StrEnum): + Allow = "Allow" + Terminate = "Terminate" + + +class ResponseStreamingInvocationType(StrEnum): + RequestResponse = "RequestResponse" + DryRun = "DryRun" + + +class Runtime(StrEnum): + nodejs = "nodejs" + nodejs4_3 = "nodejs4.3" + nodejs6_10 = "nodejs6.10" + nodejs8_10 = "nodejs8.10" + nodejs10_x = "nodejs10.x" + nodejs12_x = "nodejs12.x" + nodejs14_x = "nodejs14.x" + nodejs16_x = "nodejs16.x" + java8 = "java8" + java8_al2 = "java8.al2" + java11 = "java11" + python2_7 = "python2.7" + python3_6 = "python3.6" + python3_7 = "python3.7" + python3_8 = "python3.8" + python3_9 = "python3.9" + dotnetcore1_0 = "dotnetcore1.0" + dotnetcore2_0 = "dotnetcore2.0" + dotnetcore2_1 = "dotnetcore2.1" + dotnetcore3_1 = "dotnetcore3.1" + dotnet6 = "dotnet6" + dotnet8 = "dotnet8" + nodejs4_3_edge = "nodejs4.3-edge" + go1_x = "go1.x" + ruby2_5 = "ruby2.5" + ruby2_7 = "ruby2.7" + provided = "provided" + provided_al2 = "provided.al2" + nodejs18_x = "nodejs18.x" + python3_10 = "python3.10" + java17 = "java17" + ruby3_2 = "ruby3.2" + ruby3_3 = "ruby3.3" + ruby3_4 = "ruby3.4" + python3_11 = "python3.11" + nodejs20_x = "nodejs20.x" + provided_al2023 = "provided.al2023" + python3_12 = "python3.12" + java21 = "java21" + python3_13 = "python3.13" + nodejs22_x = "nodejs22.x" + + +class SchemaRegistryEventRecordFormat(StrEnum): + JSON = "JSON" + SOURCE = "SOURCE" + + +class SnapStartApplyOn(StrEnum): + PublishedVersions = "PublishedVersions" + None_ = "None" + + +class SnapStartOptimizationStatus(StrEnum): + On = "On" + Off = "Off" + + +class SourceAccessType(StrEnum): + BASIC_AUTH = "BASIC_AUTH" + VPC_SUBNET = "VPC_SUBNET" + VPC_SECURITY_GROUP = "VPC_SECURITY_GROUP" + SASL_SCRAM_512_AUTH = "SASL_SCRAM_512_AUTH" + SASL_SCRAM_256_AUTH = "SASL_SCRAM_256_AUTH" + VIRTUAL_HOST = "VIRTUAL_HOST" + CLIENT_CERTIFICATE_TLS_AUTH = "CLIENT_CERTIFICATE_TLS_AUTH" + SERVER_ROOT_CA_CERTIFICATE = "SERVER_ROOT_CA_CERTIFICATE" + + +class State(StrEnum): + Pending = "Pending" + Active = "Active" + Inactive = "Inactive" + Failed = "Failed" + + +class StateReasonCode(StrEnum): + Idle = "Idle" + Creating = "Creating" + Restoring = "Restoring" + EniLimitExceeded = "EniLimitExceeded" + InsufficientRolePermissions = "InsufficientRolePermissions" + InvalidConfiguration = "InvalidConfiguration" + InternalError = "InternalError" + SubnetOutOfIPAddresses = "SubnetOutOfIPAddresses" + InvalidSubnet = "InvalidSubnet" + InvalidSecurityGroup = "InvalidSecurityGroup" + ImageDeleted = "ImageDeleted" + ImageAccessDenied = "ImageAccessDenied" + InvalidImage = "InvalidImage" + KMSKeyAccessDenied = "KMSKeyAccessDenied" + KMSKeyNotFound = "KMSKeyNotFound" + InvalidStateKMSKey = "InvalidStateKMSKey" + DisabledKMSKey = "DisabledKMSKey" + EFSIOError = "EFSIOError" + EFSMountConnectivityError = "EFSMountConnectivityError" + EFSMountFailure = "EFSMountFailure" + EFSMountTimeout = "EFSMountTimeout" + InvalidRuntime = "InvalidRuntime" + InvalidZipFileException = "InvalidZipFileException" + FunctionError = "FunctionError" + + +class SystemLogLevel(StrEnum): + DEBUG = "DEBUG" + INFO = "INFO" + WARN = "WARN" + + +class ThrottleReason(StrEnum): + ConcurrentInvocationLimitExceeded = "ConcurrentInvocationLimitExceeded" + FunctionInvocationRateLimitExceeded = "FunctionInvocationRateLimitExceeded" + ReservedFunctionConcurrentInvocationLimitExceeded = ( + "ReservedFunctionConcurrentInvocationLimitExceeded" + ) + ReservedFunctionInvocationRateLimitExceeded = "ReservedFunctionInvocationRateLimitExceeded" + CallerRateLimitExceeded = "CallerRateLimitExceeded" + ConcurrentSnapshotCreateLimitExceeded = "ConcurrentSnapshotCreateLimitExceeded" + + +class TracingMode(StrEnum): + Active = "Active" + PassThrough = "PassThrough" + + +class UpdateRuntimeOn(StrEnum): + Auto = "Auto" + Manual = "Manual" + FunctionUpdate = "FunctionUpdate" + + +class CodeSigningConfigNotFoundException(ServiceException): + code: str = "CodeSigningConfigNotFoundException" + sender_fault: bool = False + status_code: int = 404 + Type: Optional[String] + + +class CodeStorageExceededException(ServiceException): + code: str = "CodeStorageExceededException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class CodeVerificationFailedException(ServiceException): + code: str = "CodeVerificationFailedException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class EC2AccessDeniedException(ServiceException): + code: str = "EC2AccessDeniedException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class EC2ThrottledException(ServiceException): + code: str = "EC2ThrottledException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class EC2UnexpectedException(ServiceException): + code: str = "EC2UnexpectedException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + EC2ErrorCode: Optional[String] + + +class EFSIOException(ServiceException): + code: str = "EFSIOException" + sender_fault: bool = False + status_code: int = 410 + Type: Optional[String] + + +class EFSMountConnectivityException(ServiceException): + code: str = "EFSMountConnectivityException" + sender_fault: bool = False + status_code: int = 408 + Type: Optional[String] + + +class EFSMountFailureException(ServiceException): + code: str = "EFSMountFailureException" + sender_fault: bool = False + status_code: int = 403 + Type: Optional[String] + + +class EFSMountTimeoutException(ServiceException): + code: str = "EFSMountTimeoutException" + sender_fault: bool = False + status_code: int = 408 + Type: Optional[String] + + +class ENILimitReachedException(ServiceException): + code: str = "ENILimitReachedException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class InvalidCodeSignatureException(ServiceException): + code: str = "InvalidCodeSignatureException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class InvalidParameterValueException(ServiceException): + code: str = "InvalidParameterValueException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class InvalidRequestContentException(ServiceException): + code: str = "InvalidRequestContentException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class InvalidRuntimeException(ServiceException): + code: str = "InvalidRuntimeException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class InvalidSecurityGroupIDException(ServiceException): + code: str = "InvalidSecurityGroupIDException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class InvalidSubnetIDException(ServiceException): + code: str = "InvalidSubnetIDException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class InvalidZipFileException(ServiceException): + code: str = "InvalidZipFileException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class KMSAccessDeniedException(ServiceException): + code: str = "KMSAccessDeniedException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class KMSDisabledException(ServiceException): + code: str = "KMSDisabledException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class KMSInvalidStateException(ServiceException): + code: str = "KMSInvalidStateException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class KMSNotFoundException(ServiceException): + code: str = "KMSNotFoundException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class PolicyLengthExceededException(ServiceException): + code: str = "PolicyLengthExceededException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class PreconditionFailedException(ServiceException): + code: str = "PreconditionFailedException" + sender_fault: bool = False + status_code: int = 412 + Type: Optional[String] + + +class ProvisionedConcurrencyConfigNotFoundException(ServiceException): + code: str = "ProvisionedConcurrencyConfigNotFoundException" + sender_fault: bool = False + status_code: int = 404 + Type: Optional[String] + + +class RecursiveInvocationException(ServiceException): + code: str = "RecursiveInvocationException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class RequestTooLargeException(ServiceException): + code: str = "RequestTooLargeException" + sender_fault: bool = False + status_code: int = 413 + Type: Optional[String] + + +class ResourceConflictException(ServiceException): + code: str = "ResourceConflictException" + sender_fault: bool = False + status_code: int = 409 + Type: Optional[String] + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 404 + Type: Optional[String] + + +class ResourceNotReadyException(ServiceException): + code: str = "ResourceNotReadyException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class ServiceException(ServiceException): + code: str = "ServiceException" + sender_fault: bool = False + status_code: int = 500 + Type: Optional[String] + + +class SnapStartException(ServiceException): + code: str = "SnapStartException" + sender_fault: bool = False + status_code: int = 400 + Type: Optional[String] + + +class SnapStartNotReadyException(ServiceException): + code: str = "SnapStartNotReadyException" + sender_fault: bool = False + status_code: int = 409 + Type: Optional[String] + + +class SnapStartTimeoutException(ServiceException): + code: str = "SnapStartTimeoutException" + sender_fault: bool = False + status_code: int = 408 + Type: Optional[String] + + +class SubnetIPAddressLimitReachedException(ServiceException): + code: str = "SubnetIPAddressLimitReachedException" + sender_fault: bool = False + status_code: int = 502 + Type: Optional[String] + + +class TooManyRequestsException(ServiceException): + code: str = "TooManyRequestsException" + sender_fault: bool = False + status_code: int = 429 + retryAfterSeconds: Optional[String] + Type: Optional[String] + Reason: Optional[ThrottleReason] + + +class UnsupportedMediaTypeException(ServiceException): + code: str = "UnsupportedMediaTypeException" + sender_fault: bool = False + status_code: int = 415 + Type: Optional[String] + + +Long = int + + +class AccountLimit(TypedDict, total=False): + TotalCodeSize: Optional[Long] + CodeSizeUnzipped: Optional[Long] + CodeSizeZipped: Optional[Long] + ConcurrentExecutions: Optional[Integer] + UnreservedConcurrentExecutions: Optional[UnreservedConcurrentExecutions] + + +class AccountUsage(TypedDict, total=False): + TotalCodeSize: Optional[Long] + FunctionCount: Optional[Long] + + +LayerVersionNumber = int + + +class AddLayerVersionPermissionRequest(ServiceRequest): + LayerName: LayerName + VersionNumber: LayerVersionNumber + StatementId: StatementId + Action: LayerPermissionAllowedAction + Principal: LayerPermissionAllowedPrincipal + OrganizationId: Optional[OrganizationId] + RevisionId: Optional[String] + + +class AddLayerVersionPermissionResponse(TypedDict, total=False): + Statement: Optional[String] + RevisionId: Optional[String] + + +class AddPermissionRequest(ServiceRequest): + FunctionName: FunctionName + StatementId: StatementId + Action: Action + Principal: Principal + SourceArn: Optional[Arn] + SourceAccount: Optional[SourceOwner] + EventSourceToken: Optional[EventSourceToken] + Qualifier: Optional[Qualifier] + RevisionId: Optional[String] + PrincipalOrgID: Optional[PrincipalOrgID] + FunctionUrlAuthType: Optional[FunctionUrlAuthType] + + +class AddPermissionResponse(TypedDict, total=False): + Statement: Optional[String] + + +AdditionalVersionWeights = Dict[AdditionalVersion, Weight] + + +class AliasRoutingConfiguration(TypedDict, total=False): + AdditionalVersionWeights: Optional[AdditionalVersionWeights] + + +class AliasConfiguration(TypedDict, total=False): + AliasArn: Optional[FunctionArn] + Name: Optional[Alias] + FunctionVersion: Optional[Version] + Description: Optional[Description] + RoutingConfig: Optional[AliasRoutingConfiguration] + RevisionId: Optional[String] + + +AliasList = List[AliasConfiguration] +AllowMethodsList = List[Method] +AllowOriginsList = List[Origin] +SigningProfileVersionArns = List[Arn] + + +class AllowedPublishers(TypedDict, total=False): + SigningProfileVersionArns: SigningProfileVersionArns + + +class KafkaSchemaValidationConfig(TypedDict, total=False): + Attribute: Optional[KafkaSchemaValidationAttribute] + + +KafkaSchemaValidationConfigList = List[KafkaSchemaValidationConfig] + + +class KafkaSchemaRegistryAccessConfig(TypedDict, total=False): + Type: Optional[KafkaSchemaRegistryAuthType] + URI: Optional[Arn] + + +KafkaSchemaRegistryAccessConfigList = List[KafkaSchemaRegistryAccessConfig] + + +class KafkaSchemaRegistryConfig(TypedDict, total=False): + SchemaRegistryURI: Optional[SchemaRegistryUri] + EventRecordFormat: Optional[SchemaRegistryEventRecordFormat] + AccessConfigs: Optional[KafkaSchemaRegistryAccessConfigList] + SchemaValidationConfigs: Optional[KafkaSchemaValidationConfigList] + + +class AmazonManagedKafkaEventSourceConfig(TypedDict, total=False): + ConsumerGroupId: Optional[URI] + SchemaRegistryConfig: Optional[KafkaSchemaRegistryConfig] + + +ArchitecturesList = List[Architecture] +Blob = bytes +BlobStream = bytes + + +class CodeSigningPolicies(TypedDict, total=False): + UntrustedArtifactOnDeployment: Optional[CodeSigningPolicy] + + +class CodeSigningConfig(TypedDict, total=False): + CodeSigningConfigId: CodeSigningConfigId + CodeSigningConfigArn: CodeSigningConfigArn + Description: Optional[Description] + AllowedPublishers: AllowedPublishers + CodeSigningPolicies: CodeSigningPolicies + LastModified: Timestamp + + +CodeSigningConfigList = List[CodeSigningConfig] +CompatibleArchitectures = List[Architecture] +CompatibleRuntimes = List[Runtime] + + +class Concurrency(TypedDict, total=False): + ReservedConcurrentExecutions: Optional[ReservedConcurrentExecutions] + + +HeadersList = List[Header] + + +class Cors(TypedDict, total=False): + AllowCredentials: Optional[AllowCredentials] + AllowHeaders: Optional[HeadersList] + AllowMethods: Optional[AllowMethodsList] + AllowOrigins: Optional[AllowOriginsList] + ExposeHeaders: Optional[HeadersList] + MaxAge: Optional[MaxAge] + + +class CreateAliasRequest(ServiceRequest): + FunctionName: FunctionName + Name: Alias + FunctionVersion: Version + Description: Optional[Description] + RoutingConfig: Optional[AliasRoutingConfiguration] + + +Tags = Dict[TagKey, TagValue] + + +class CreateCodeSigningConfigRequest(ServiceRequest): + Description: Optional[Description] + AllowedPublishers: AllowedPublishers + CodeSigningPolicies: Optional[CodeSigningPolicies] + Tags: Optional[Tags] + + +class CreateCodeSigningConfigResponse(TypedDict, total=False): + CodeSigningConfig: CodeSigningConfig + + +class ProvisionedPollerConfig(TypedDict, total=False): + MinimumPollers: Optional[MinimumNumberOfPollers] + MaximumPollers: Optional[MaximumNumberOfPollers] + + +EventSourceMappingMetricList = List[EventSourceMappingMetric] + + +class EventSourceMappingMetricsConfig(TypedDict, total=False): + Metrics: Optional[EventSourceMappingMetricList] + + +class DocumentDBEventSourceConfig(TypedDict, total=False): + DatabaseName: Optional[DatabaseName] + CollectionName: Optional[CollectionName] + FullDocument: Optional[FullDocument] + + +class ScalingConfig(TypedDict, total=False): + MaximumConcurrency: Optional[MaximumConcurrency] + + +class SelfManagedKafkaEventSourceConfig(TypedDict, total=False): + ConsumerGroupId: Optional[URI] + SchemaRegistryConfig: Optional[KafkaSchemaRegistryConfig] + + +FunctionResponseTypeList = List[FunctionResponseType] +EndpointLists = List[Endpoint] +Endpoints = Dict[EndPointType, EndpointLists] + + +class SelfManagedEventSource(TypedDict, total=False): + Endpoints: Optional[Endpoints] + + +class SourceAccessConfiguration(TypedDict, total=False): + Type: Optional[SourceAccessType] + URI: Optional[URI] + + +SourceAccessConfigurations = List[SourceAccessConfiguration] +Queues = List[Queue] +Topics = List[Topic] + + +class OnFailure(TypedDict, total=False): + Destination: Optional[DestinationArn] + + +class OnSuccess(TypedDict, total=False): + Destination: Optional[DestinationArn] + + +class DestinationConfig(TypedDict, total=False): + OnSuccess: Optional[OnSuccess] + OnFailure: Optional[OnFailure] + + +Date = datetime + + +class Filter(TypedDict, total=False): + Pattern: Optional[Pattern] + + +FilterList = List[Filter] + + +class FilterCriteria(TypedDict, total=False): + Filters: Optional[FilterList] + + +class CreateEventSourceMappingRequest(ServiceRequest): + EventSourceArn: Optional[Arn] + FunctionName: FunctionName + Enabled: Optional[Enabled] + BatchSize: Optional[BatchSize] + FilterCriteria: Optional[FilterCriteria] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + ParallelizationFactor: Optional[ParallelizationFactor] + StartingPosition: Optional[EventSourcePosition] + StartingPositionTimestamp: Optional[Date] + DestinationConfig: Optional[DestinationConfig] + MaximumRecordAgeInSeconds: Optional[MaximumRecordAgeInSeconds] + BisectBatchOnFunctionError: Optional[BisectBatchOnFunctionError] + MaximumRetryAttempts: Optional[MaximumRetryAttemptsEventSourceMapping] + Tags: Optional[Tags] + TumblingWindowInSeconds: Optional[TumblingWindowInSeconds] + Topics: Optional[Topics] + Queues: Optional[Queues] + SourceAccessConfigurations: Optional[SourceAccessConfigurations] + SelfManagedEventSource: Optional[SelfManagedEventSource] + FunctionResponseTypes: Optional[FunctionResponseTypeList] + AmazonManagedKafkaEventSourceConfig: Optional[AmazonManagedKafkaEventSourceConfig] + SelfManagedKafkaEventSourceConfig: Optional[SelfManagedKafkaEventSourceConfig] + ScalingConfig: Optional[ScalingConfig] + DocumentDBEventSourceConfig: Optional[DocumentDBEventSourceConfig] + KMSKeyArn: Optional[KMSKeyArn] + MetricsConfig: Optional[EventSourceMappingMetricsConfig] + ProvisionedPollerConfig: Optional[ProvisionedPollerConfig] + + +class LoggingConfig(TypedDict, total=False): + LogFormat: Optional[LogFormat] + ApplicationLogLevel: Optional[ApplicationLogLevel] + SystemLogLevel: Optional[SystemLogLevel] + LogGroup: Optional[LogGroup] + + +class SnapStart(TypedDict, total=False): + ApplyOn: Optional[SnapStartApplyOn] + + +class EphemeralStorage(TypedDict, total=False): + Size: EphemeralStorageSize + + +StringList = List[String] + + +class ImageConfig(TypedDict, total=False): + EntryPoint: Optional[StringList] + Command: Optional[StringList] + WorkingDirectory: Optional[WorkingDirectory] + + +class FileSystemConfig(TypedDict, total=False): + Arn: FileSystemArn + LocalMountPath: LocalMountPath + + +FileSystemConfigList = List[FileSystemConfig] +LayerList = List[LayerVersionArn] + + +class TracingConfig(TypedDict, total=False): + Mode: Optional[TracingMode] + + +EnvironmentVariables = Dict[EnvironmentVariableName, EnvironmentVariableValue] + + +class Environment(TypedDict, total=False): + Variables: Optional[EnvironmentVariables] + + +class DeadLetterConfig(TypedDict, total=False): + TargetArn: Optional[ResourceArn] + + +SecurityGroupIds = List[SecurityGroupId] +SubnetIds = List[SubnetId] + + +class VpcConfig(TypedDict, total=False): + SubnetIds: Optional[SubnetIds] + SecurityGroupIds: Optional[SecurityGroupIds] + Ipv6AllowedForDualStack: Optional[NullableBoolean] + + +class FunctionCode(TypedDict, total=False): + ZipFile: Optional[Blob] + S3Bucket: Optional[S3Bucket] + S3Key: Optional[S3Key] + S3ObjectVersion: Optional[S3ObjectVersion] + ImageUri: Optional[String] + SourceKMSKeyArn: Optional[KMSKeyArn] + + +class CreateFunctionRequest(ServiceRequest): + FunctionName: FunctionName + Runtime: Optional[Runtime] + Role: RoleArn + Handler: Optional[Handler] + Code: FunctionCode + Description: Optional[Description] + Timeout: Optional[Timeout] + MemorySize: Optional[MemorySize] + Publish: Optional[Boolean] + VpcConfig: Optional[VpcConfig] + PackageType: Optional[PackageType] + DeadLetterConfig: Optional[DeadLetterConfig] + Environment: Optional[Environment] + KMSKeyArn: Optional[KMSKeyArn] + TracingConfig: Optional[TracingConfig] + Tags: Optional[Tags] + Layers: Optional[LayerList] + FileSystemConfigs: Optional[FileSystemConfigList] + ImageConfig: Optional[ImageConfig] + CodeSigningConfigArn: Optional[CodeSigningConfigArn] + Architectures: Optional[ArchitecturesList] + EphemeralStorage: Optional[EphemeralStorage] + SnapStart: Optional[SnapStart] + LoggingConfig: Optional[LoggingConfig] + + +class CreateFunctionUrlConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[FunctionUrlQualifier] + AuthType: FunctionUrlAuthType + Cors: Optional[Cors] + InvokeMode: Optional[InvokeMode] + + +class CreateFunctionUrlConfigResponse(TypedDict, total=False): + FunctionUrl: FunctionUrl + FunctionArn: FunctionArn + AuthType: FunctionUrlAuthType + Cors: Optional[Cors] + CreationTime: Timestamp + InvokeMode: Optional[InvokeMode] + + +class DeleteAliasRequest(ServiceRequest): + FunctionName: FunctionName + Name: Alias + + +class DeleteCodeSigningConfigRequest(ServiceRequest): + CodeSigningConfigArn: CodeSigningConfigArn + + +class DeleteCodeSigningConfigResponse(TypedDict, total=False): + pass + + +class DeleteEventSourceMappingRequest(ServiceRequest): + UUID: String + + +class DeleteFunctionCodeSigningConfigRequest(ServiceRequest): + FunctionName: FunctionName + + +class DeleteFunctionConcurrencyRequest(ServiceRequest): + FunctionName: FunctionName + + +class DeleteFunctionEventInvokeConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[Qualifier] + + +class DeleteFunctionRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[Qualifier] + + +class DeleteFunctionUrlConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[FunctionUrlQualifier] + + +class DeleteLayerVersionRequest(ServiceRequest): + LayerName: LayerName + VersionNumber: LayerVersionNumber + + +class DeleteProvisionedConcurrencyConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Qualifier + + +class EnvironmentError(TypedDict, total=False): + ErrorCode: Optional[String] + Message: Optional[SensitiveString] + + +class EnvironmentResponse(TypedDict, total=False): + Variables: Optional[EnvironmentVariables] + Error: Optional[EnvironmentError] + + +class FilterCriteriaError(TypedDict, total=False): + ErrorCode: Optional[FilterCriteriaErrorCode] + Message: Optional[FilterCriteriaErrorMessage] + + +class EventSourceMappingConfiguration(TypedDict, total=False): + UUID: Optional[String] + StartingPosition: Optional[EventSourcePosition] + StartingPositionTimestamp: Optional[Date] + BatchSize: Optional[BatchSize] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + ParallelizationFactor: Optional[ParallelizationFactor] + EventSourceArn: Optional[Arn] + FilterCriteria: Optional[FilterCriteria] + FunctionArn: Optional[FunctionArn] + LastModified: Optional[Date] + LastProcessingResult: Optional[String] + State: Optional[String] + StateTransitionReason: Optional[String] + DestinationConfig: Optional[DestinationConfig] + Topics: Optional[Topics] + Queues: Optional[Queues] + SourceAccessConfigurations: Optional[SourceAccessConfigurations] + SelfManagedEventSource: Optional[SelfManagedEventSource] + MaximumRecordAgeInSeconds: Optional[MaximumRecordAgeInSeconds] + BisectBatchOnFunctionError: Optional[BisectBatchOnFunctionError] + MaximumRetryAttempts: Optional[MaximumRetryAttemptsEventSourceMapping] + TumblingWindowInSeconds: Optional[TumblingWindowInSeconds] + FunctionResponseTypes: Optional[FunctionResponseTypeList] + AmazonManagedKafkaEventSourceConfig: Optional[AmazonManagedKafkaEventSourceConfig] + SelfManagedKafkaEventSourceConfig: Optional[SelfManagedKafkaEventSourceConfig] + ScalingConfig: Optional[ScalingConfig] + DocumentDBEventSourceConfig: Optional[DocumentDBEventSourceConfig] + KMSKeyArn: Optional[KMSKeyArn] + FilterCriteriaError: Optional[FilterCriteriaError] + EventSourceMappingArn: Optional[EventSourceMappingArn] + MetricsConfig: Optional[EventSourceMappingMetricsConfig] + ProvisionedPollerConfig: Optional[ProvisionedPollerConfig] + + +EventSourceMappingsList = List[EventSourceMappingConfiguration] +FunctionArnList = List[FunctionArn] + + +class FunctionCodeLocation(TypedDict, total=False): + RepositoryType: Optional[String] + Location: Optional[String] + ImageUri: Optional[String] + ResolvedImageUri: Optional[String] + SourceKMSKeyArn: Optional[String] + + +class RuntimeVersionError(TypedDict, total=False): + ErrorCode: Optional[String] + Message: Optional[SensitiveString] + + +class RuntimeVersionConfig(TypedDict, total=False): + RuntimeVersionArn: Optional[RuntimeVersionArn] + Error: Optional[RuntimeVersionError] + + +class SnapStartResponse(TypedDict, total=False): + ApplyOn: Optional[SnapStartApplyOn] + OptimizationStatus: Optional[SnapStartOptimizationStatus] + + +class ImageConfigError(TypedDict, total=False): + ErrorCode: Optional[String] + Message: Optional[SensitiveString] + + +class ImageConfigResponse(TypedDict, total=False): + ImageConfig: Optional[ImageConfig] + Error: Optional[ImageConfigError] + + +class Layer(TypedDict, total=False): + Arn: Optional[LayerVersionArn] + CodeSize: Optional[Long] + SigningProfileVersionArn: Optional[Arn] + SigningJobArn: Optional[Arn] + + +LayersReferenceList = List[Layer] + + +class TracingConfigResponse(TypedDict, total=False): + Mode: Optional[TracingMode] + + +class VpcConfigResponse(TypedDict, total=False): + SubnetIds: Optional[SubnetIds] + SecurityGroupIds: Optional[SecurityGroupIds] + VpcId: Optional[VpcId] + Ipv6AllowedForDualStack: Optional[NullableBoolean] + + +class FunctionConfiguration(TypedDict, total=False): + FunctionName: Optional[NamespacedFunctionName] + FunctionArn: Optional[NameSpacedFunctionArn] + Runtime: Optional[Runtime] + Role: Optional[RoleArn] + Handler: Optional[Handler] + CodeSize: Optional[Long] + Description: Optional[Description] + Timeout: Optional[Timeout] + MemorySize: Optional[MemorySize] + LastModified: Optional[Timestamp] + CodeSha256: Optional[String] + Version: Optional[Version] + VpcConfig: Optional[VpcConfigResponse] + DeadLetterConfig: Optional[DeadLetterConfig] + Environment: Optional[EnvironmentResponse] + KMSKeyArn: Optional[KMSKeyArn] + TracingConfig: Optional[TracingConfigResponse] + MasterArn: Optional[FunctionArn] + RevisionId: Optional[String] + Layers: Optional[LayersReferenceList] + State: Optional[State] + StateReason: Optional[StateReason] + StateReasonCode: Optional[StateReasonCode] + LastUpdateStatus: Optional[LastUpdateStatus] + LastUpdateStatusReason: Optional[LastUpdateStatusReason] + LastUpdateStatusReasonCode: Optional[LastUpdateStatusReasonCode] + FileSystemConfigs: Optional[FileSystemConfigList] + PackageType: Optional[PackageType] + ImageConfigResponse: Optional[ImageConfigResponse] + SigningProfileVersionArn: Optional[Arn] + SigningJobArn: Optional[Arn] + Architectures: Optional[ArchitecturesList] + EphemeralStorage: Optional[EphemeralStorage] + SnapStart: Optional[SnapStartResponse] + RuntimeVersionConfig: Optional[RuntimeVersionConfig] + LoggingConfig: Optional[LoggingConfig] + + +class FunctionEventInvokeConfig(TypedDict, total=False): + LastModified: Optional[Date] + FunctionArn: Optional[FunctionArn] + MaximumRetryAttempts: Optional[MaximumRetryAttempts] + MaximumEventAgeInSeconds: Optional[MaximumEventAgeInSeconds] + DestinationConfig: Optional[DestinationConfig] + + +FunctionEventInvokeConfigList = List[FunctionEventInvokeConfig] +FunctionList = List[FunctionConfiguration] + + +class FunctionUrlConfig(TypedDict, total=False): + FunctionUrl: FunctionUrl + FunctionArn: FunctionArn + CreationTime: Timestamp + LastModifiedTime: Timestamp + Cors: Optional[Cors] + AuthType: FunctionUrlAuthType + InvokeMode: Optional[InvokeMode] + + +FunctionUrlConfigList = List[FunctionUrlConfig] + + +class GetAccountSettingsRequest(ServiceRequest): + pass + + +class GetAccountSettingsResponse(TypedDict, total=False): + AccountLimit: Optional[AccountLimit] + AccountUsage: Optional[AccountUsage] + + +class GetAliasRequest(ServiceRequest): + FunctionName: FunctionName + Name: Alias + + +class GetCodeSigningConfigRequest(ServiceRequest): + CodeSigningConfigArn: CodeSigningConfigArn + + +class GetCodeSigningConfigResponse(TypedDict, total=False): + CodeSigningConfig: CodeSigningConfig + + +class GetEventSourceMappingRequest(ServiceRequest): + UUID: String + + +class GetFunctionCodeSigningConfigRequest(ServiceRequest): + FunctionName: FunctionName + + +class GetFunctionCodeSigningConfigResponse(TypedDict, total=False): + CodeSigningConfigArn: CodeSigningConfigArn + FunctionName: FunctionName + + +class GetFunctionConcurrencyRequest(ServiceRequest): + FunctionName: FunctionName + + +class GetFunctionConcurrencyResponse(TypedDict, total=False): + ReservedConcurrentExecutions: Optional[ReservedConcurrentExecutions] + + +class GetFunctionConfigurationRequest(ServiceRequest): + FunctionName: NamespacedFunctionName + Qualifier: Optional[Qualifier] + + +class GetFunctionEventInvokeConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[Qualifier] + + +class GetFunctionRecursionConfigRequest(ServiceRequest): + FunctionName: UnqualifiedFunctionName + + +class GetFunctionRecursionConfigResponse(TypedDict, total=False): + RecursiveLoop: Optional[RecursiveLoop] + + +class GetFunctionRequest(ServiceRequest): + FunctionName: NamespacedFunctionName + Qualifier: Optional[Qualifier] + + +class TagsError(TypedDict, total=False): + ErrorCode: TagsErrorCode + Message: TagsErrorMessage + + +class GetFunctionResponse(TypedDict, total=False): + Configuration: Optional[FunctionConfiguration] + Code: Optional[FunctionCodeLocation] + Tags: Optional[Tags] + TagsError: Optional[TagsError] + Concurrency: Optional[Concurrency] + + +class GetFunctionUrlConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[FunctionUrlQualifier] + + +class GetFunctionUrlConfigResponse(TypedDict, total=False): + FunctionUrl: FunctionUrl + FunctionArn: FunctionArn + AuthType: FunctionUrlAuthType + Cors: Optional[Cors] + CreationTime: Timestamp + LastModifiedTime: Timestamp + InvokeMode: Optional[InvokeMode] + + +class GetLayerVersionByArnRequest(ServiceRequest): + Arn: LayerVersionArn + + +class GetLayerVersionPolicyRequest(ServiceRequest): + LayerName: LayerName + VersionNumber: LayerVersionNumber + + +class GetLayerVersionPolicyResponse(TypedDict, total=False): + Policy: Optional[String] + RevisionId: Optional[String] + + +class GetLayerVersionRequest(ServiceRequest): + LayerName: LayerName + VersionNumber: LayerVersionNumber + + +class LayerVersionContentOutput(TypedDict, total=False): + Location: Optional[String] + CodeSha256: Optional[String] + CodeSize: Optional[Long] + SigningProfileVersionArn: Optional[String] + SigningJobArn: Optional[String] + + +class GetLayerVersionResponse(TypedDict, total=False): + Content: Optional[LayerVersionContentOutput] + LayerArn: Optional[LayerArn] + LayerVersionArn: Optional[LayerVersionArn] + Description: Optional[Description] + CreatedDate: Optional[Timestamp] + Version: Optional[LayerVersionNumber] + CompatibleRuntimes: Optional[CompatibleRuntimes] + LicenseInfo: Optional[LicenseInfo] + CompatibleArchitectures: Optional[CompatibleArchitectures] + + +class GetPolicyRequest(ServiceRequest): + FunctionName: NamespacedFunctionName + Qualifier: Optional[Qualifier] + + +class GetPolicyResponse(TypedDict, total=False): + Policy: Optional[String] + RevisionId: Optional[String] + + +class GetProvisionedConcurrencyConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Qualifier + + +class GetProvisionedConcurrencyConfigResponse(TypedDict, total=False): + RequestedProvisionedConcurrentExecutions: Optional[PositiveInteger] + AvailableProvisionedConcurrentExecutions: Optional[NonNegativeInteger] + AllocatedProvisionedConcurrentExecutions: Optional[NonNegativeInteger] + Status: Optional[ProvisionedConcurrencyStatusEnum] + StatusReason: Optional[String] + LastModified: Optional[Timestamp] + + +class GetRuntimeManagementConfigRequest(ServiceRequest): + FunctionName: NamespacedFunctionName + Qualifier: Optional[Qualifier] + + +class GetRuntimeManagementConfigResponse(TypedDict, total=False): + UpdateRuntimeOn: Optional[UpdateRuntimeOn] + RuntimeVersionArn: Optional[RuntimeVersionArn] + FunctionArn: Optional[NameSpacedFunctionArn] + + +class InvocationRequest(ServiceRequest): + Payload: Optional[IO[Blob]] + FunctionName: NamespacedFunctionName + InvocationType: Optional[InvocationType] + LogType: Optional[LogType] + ClientContext: Optional[String] + Qualifier: Optional[Qualifier] + + +class InvocationResponse(TypedDict, total=False): + Payload: Optional[Union[Blob, IO[Blob], Iterable[Blob]]] + StatusCode: Optional[Integer] + FunctionError: Optional[String] + LogResult: Optional[String] + ExecutedVersion: Optional[Version] + + +class InvokeAsyncRequest(ServiceRequest): + InvokeArgs: IO[BlobStream] + FunctionName: NamespacedFunctionName + + +class InvokeAsyncResponse(TypedDict, total=False): + Status: Optional[HttpStatus] + + +class InvokeResponseStreamUpdate(TypedDict, total=False): + Payload: Optional[Blob] + + +class InvokeWithResponseStreamCompleteEvent(TypedDict, total=False): + ErrorCode: Optional[String] + ErrorDetails: Optional[String] + LogResult: Optional[String] + + +class InvokeWithResponseStreamRequest(ServiceRequest): + Payload: Optional[IO[Blob]] + FunctionName: NamespacedFunctionName + InvocationType: Optional[ResponseStreamingInvocationType] + LogType: Optional[LogType] + ClientContext: Optional[String] + Qualifier: Optional[Qualifier] + + +class InvokeWithResponseStreamResponseEvent(TypedDict, total=False): + PayloadChunk: Optional[InvokeResponseStreamUpdate] + InvokeComplete: Optional[InvokeWithResponseStreamCompleteEvent] + + +class InvokeWithResponseStreamResponse(TypedDict, total=False): + StatusCode: Optional[Integer] + ExecutedVersion: Optional[Version] + EventStream: Iterator[InvokeWithResponseStreamResponseEvent] + ResponseStreamContentType: Optional[String] + + +class LayerVersionContentInput(TypedDict, total=False): + S3Bucket: Optional[S3Bucket] + S3Key: Optional[S3Key] + S3ObjectVersion: Optional[S3ObjectVersion] + ZipFile: Optional[Blob] + + +class LayerVersionsListItem(TypedDict, total=False): + LayerVersionArn: Optional[LayerVersionArn] + Version: Optional[LayerVersionNumber] + Description: Optional[Description] + CreatedDate: Optional[Timestamp] + CompatibleRuntimes: Optional[CompatibleRuntimes] + LicenseInfo: Optional[LicenseInfo] + CompatibleArchitectures: Optional[CompatibleArchitectures] + + +LayerVersionsList = List[LayerVersionsListItem] + + +class LayersListItem(TypedDict, total=False): + LayerName: Optional[LayerName] + LayerArn: Optional[LayerArn] + LatestMatchingVersion: Optional[LayerVersionsListItem] + + +LayersList = List[LayersListItem] + + +class ListAliasesRequest(ServiceRequest): + FunctionName: FunctionName + FunctionVersion: Optional[Version] + Marker: Optional[String] + MaxItems: Optional[MaxListItems] + + +class ListAliasesResponse(TypedDict, total=False): + NextMarker: Optional[String] + Aliases: Optional[AliasList] + + +class ListCodeSigningConfigsRequest(ServiceRequest): + Marker: Optional[String] + MaxItems: Optional[MaxListItems] + + +class ListCodeSigningConfigsResponse(TypedDict, total=False): + NextMarker: Optional[String] + CodeSigningConfigs: Optional[CodeSigningConfigList] + + +class ListEventSourceMappingsRequest(ServiceRequest): + EventSourceArn: Optional[Arn] + FunctionName: Optional[FunctionName] + Marker: Optional[String] + MaxItems: Optional[MaxListItems] + + +class ListEventSourceMappingsResponse(TypedDict, total=False): + NextMarker: Optional[String] + EventSourceMappings: Optional[EventSourceMappingsList] + + +class ListFunctionEventInvokeConfigsRequest(ServiceRequest): + FunctionName: FunctionName + Marker: Optional[String] + MaxItems: Optional[MaxFunctionEventInvokeConfigListItems] + + +class ListFunctionEventInvokeConfigsResponse(TypedDict, total=False): + FunctionEventInvokeConfigs: Optional[FunctionEventInvokeConfigList] + NextMarker: Optional[String] + + +class ListFunctionUrlConfigsRequest(ServiceRequest): + FunctionName: FunctionName + Marker: Optional[String] + MaxItems: Optional[MaxItems] + + +class ListFunctionUrlConfigsResponse(TypedDict, total=False): + FunctionUrlConfigs: FunctionUrlConfigList + NextMarker: Optional[String] + + +class ListFunctionsByCodeSigningConfigRequest(ServiceRequest): + CodeSigningConfigArn: CodeSigningConfigArn + Marker: Optional[String] + MaxItems: Optional[MaxListItems] + + +class ListFunctionsByCodeSigningConfigResponse(TypedDict, total=False): + NextMarker: Optional[String] + FunctionArns: Optional[FunctionArnList] + + +class ListFunctionsRequest(ServiceRequest): + MasterRegion: Optional[MasterRegion] + FunctionVersion: Optional[FunctionVersion] + Marker: Optional[String] + MaxItems: Optional[MaxListItems] + + +class ListFunctionsResponse(TypedDict, total=False): + NextMarker: Optional[String] + Functions: Optional[FunctionList] + + +class ListLayerVersionsRequest(ServiceRequest): + CompatibleRuntime: Optional[Runtime] + LayerName: LayerName + Marker: Optional[String] + MaxItems: Optional[MaxLayerListItems] + CompatibleArchitecture: Optional[Architecture] + + +class ListLayerVersionsResponse(TypedDict, total=False): + NextMarker: Optional[String] + LayerVersions: Optional[LayerVersionsList] + + +class ListLayersRequest(ServiceRequest): + CompatibleRuntime: Optional[Runtime] + Marker: Optional[String] + MaxItems: Optional[MaxLayerListItems] + CompatibleArchitecture: Optional[Architecture] + + +class ListLayersResponse(TypedDict, total=False): + NextMarker: Optional[String] + Layers: Optional[LayersList] + + +class ListProvisionedConcurrencyConfigsRequest(ServiceRequest): + FunctionName: FunctionName + Marker: Optional[String] + MaxItems: Optional[MaxProvisionedConcurrencyConfigListItems] + + +class ProvisionedConcurrencyConfigListItem(TypedDict, total=False): + FunctionArn: Optional[FunctionArn] + RequestedProvisionedConcurrentExecutions: Optional[PositiveInteger] + AvailableProvisionedConcurrentExecutions: Optional[NonNegativeInteger] + AllocatedProvisionedConcurrentExecutions: Optional[NonNegativeInteger] + Status: Optional[ProvisionedConcurrencyStatusEnum] + StatusReason: Optional[String] + LastModified: Optional[Timestamp] + + +ProvisionedConcurrencyConfigList = List[ProvisionedConcurrencyConfigListItem] + + +class ListProvisionedConcurrencyConfigsResponse(TypedDict, total=False): + ProvisionedConcurrencyConfigs: Optional[ProvisionedConcurrencyConfigList] + NextMarker: Optional[String] + + +class ListTagsRequest(ServiceRequest): + Resource: TaggableResource + + +class ListTagsResponse(TypedDict, total=False): + Tags: Optional[Tags] + + +class ListVersionsByFunctionRequest(ServiceRequest): + FunctionName: NamespacedFunctionName + Marker: Optional[String] + MaxItems: Optional[MaxListItems] + + +class ListVersionsByFunctionResponse(TypedDict, total=False): + NextMarker: Optional[String] + Versions: Optional[FunctionList] + + +class PublishLayerVersionRequest(ServiceRequest): + LayerName: LayerName + Description: Optional[Description] + Content: LayerVersionContentInput + CompatibleRuntimes: Optional[CompatibleRuntimes] + LicenseInfo: Optional[LicenseInfo] + CompatibleArchitectures: Optional[CompatibleArchitectures] + + +class PublishLayerVersionResponse(TypedDict, total=False): + Content: Optional[LayerVersionContentOutput] + LayerArn: Optional[LayerArn] + LayerVersionArn: Optional[LayerVersionArn] + Description: Optional[Description] + CreatedDate: Optional[Timestamp] + Version: Optional[LayerVersionNumber] + CompatibleRuntimes: Optional[CompatibleRuntimes] + LicenseInfo: Optional[LicenseInfo] + CompatibleArchitectures: Optional[CompatibleArchitectures] + + +class PublishVersionRequest(ServiceRequest): + FunctionName: FunctionName + CodeSha256: Optional[String] + Description: Optional[Description] + RevisionId: Optional[String] + + +class PutFunctionCodeSigningConfigRequest(ServiceRequest): + CodeSigningConfigArn: CodeSigningConfigArn + FunctionName: FunctionName + + +class PutFunctionCodeSigningConfigResponse(TypedDict, total=False): + CodeSigningConfigArn: CodeSigningConfigArn + FunctionName: FunctionName + + +class PutFunctionConcurrencyRequest(ServiceRequest): + FunctionName: FunctionName + ReservedConcurrentExecutions: ReservedConcurrentExecutions + + +class PutFunctionEventInvokeConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[Qualifier] + MaximumRetryAttempts: Optional[MaximumRetryAttempts] + MaximumEventAgeInSeconds: Optional[MaximumEventAgeInSeconds] + DestinationConfig: Optional[DestinationConfig] + + +class PutFunctionRecursionConfigRequest(ServiceRequest): + FunctionName: UnqualifiedFunctionName + RecursiveLoop: RecursiveLoop + + +class PutFunctionRecursionConfigResponse(TypedDict, total=False): + RecursiveLoop: Optional[RecursiveLoop] + + +class PutProvisionedConcurrencyConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Qualifier + ProvisionedConcurrentExecutions: PositiveInteger + + +class PutProvisionedConcurrencyConfigResponse(TypedDict, total=False): + RequestedProvisionedConcurrentExecutions: Optional[PositiveInteger] + AvailableProvisionedConcurrentExecutions: Optional[NonNegativeInteger] + AllocatedProvisionedConcurrentExecutions: Optional[NonNegativeInteger] + Status: Optional[ProvisionedConcurrencyStatusEnum] + StatusReason: Optional[String] + LastModified: Optional[Timestamp] + + +class PutRuntimeManagementConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[Qualifier] + UpdateRuntimeOn: UpdateRuntimeOn + RuntimeVersionArn: Optional[RuntimeVersionArn] + + +class PutRuntimeManagementConfigResponse(TypedDict, total=False): + UpdateRuntimeOn: UpdateRuntimeOn + FunctionArn: FunctionArn + RuntimeVersionArn: Optional[RuntimeVersionArn] + + +class RemoveLayerVersionPermissionRequest(ServiceRequest): + LayerName: LayerName + VersionNumber: LayerVersionNumber + StatementId: StatementId + RevisionId: Optional[String] + + +class RemovePermissionRequest(ServiceRequest): + FunctionName: FunctionName + StatementId: NamespacedStatementId + Qualifier: Optional[Qualifier] + RevisionId: Optional[String] + + +TagKeyList = List[TagKey] + + +class TagResourceRequest(ServiceRequest): + Resource: TaggableResource + Tags: Tags + + +class UntagResourceRequest(ServiceRequest): + Resource: TaggableResource + TagKeys: TagKeyList + + +class UpdateAliasRequest(ServiceRequest): + FunctionName: FunctionName + Name: Alias + FunctionVersion: Optional[Version] + Description: Optional[Description] + RoutingConfig: Optional[AliasRoutingConfiguration] + RevisionId: Optional[String] + + +class UpdateCodeSigningConfigRequest(ServiceRequest): + CodeSigningConfigArn: CodeSigningConfigArn + Description: Optional[Description] + AllowedPublishers: Optional[AllowedPublishers] + CodeSigningPolicies: Optional[CodeSigningPolicies] + + +class UpdateCodeSigningConfigResponse(TypedDict, total=False): + CodeSigningConfig: CodeSigningConfig + + +class UpdateEventSourceMappingRequest(ServiceRequest): + UUID: String + FunctionName: Optional[FunctionName] + Enabled: Optional[Enabled] + BatchSize: Optional[BatchSize] + FilterCriteria: Optional[FilterCriteria] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + DestinationConfig: Optional[DestinationConfig] + MaximumRecordAgeInSeconds: Optional[MaximumRecordAgeInSeconds] + BisectBatchOnFunctionError: Optional[BisectBatchOnFunctionError] + MaximumRetryAttempts: Optional[MaximumRetryAttemptsEventSourceMapping] + ParallelizationFactor: Optional[ParallelizationFactor] + SourceAccessConfigurations: Optional[SourceAccessConfigurations] + TumblingWindowInSeconds: Optional[TumblingWindowInSeconds] + FunctionResponseTypes: Optional[FunctionResponseTypeList] + ScalingConfig: Optional[ScalingConfig] + AmazonManagedKafkaEventSourceConfig: Optional[AmazonManagedKafkaEventSourceConfig] + SelfManagedKafkaEventSourceConfig: Optional[SelfManagedKafkaEventSourceConfig] + DocumentDBEventSourceConfig: Optional[DocumentDBEventSourceConfig] + KMSKeyArn: Optional[KMSKeyArn] + MetricsConfig: Optional[EventSourceMappingMetricsConfig] + ProvisionedPollerConfig: Optional[ProvisionedPollerConfig] + + +class UpdateFunctionCodeRequest(ServiceRequest): + FunctionName: FunctionName + ZipFile: Optional[Blob] + S3Bucket: Optional[S3Bucket] + S3Key: Optional[S3Key] + S3ObjectVersion: Optional[S3ObjectVersion] + ImageUri: Optional[String] + Publish: Optional[Boolean] + DryRun: Optional[Boolean] + RevisionId: Optional[String] + Architectures: Optional[ArchitecturesList] + SourceKMSKeyArn: Optional[KMSKeyArn] + + +class UpdateFunctionConfigurationRequest(ServiceRequest): + FunctionName: FunctionName + Role: Optional[RoleArn] + Handler: Optional[Handler] + Description: Optional[Description] + Timeout: Optional[Timeout] + MemorySize: Optional[MemorySize] + VpcConfig: Optional[VpcConfig] + Environment: Optional[Environment] + Runtime: Optional[Runtime] + DeadLetterConfig: Optional[DeadLetterConfig] + KMSKeyArn: Optional[KMSKeyArn] + TracingConfig: Optional[TracingConfig] + RevisionId: Optional[String] + Layers: Optional[LayerList] + FileSystemConfigs: Optional[FileSystemConfigList] + ImageConfig: Optional[ImageConfig] + EphemeralStorage: Optional[EphemeralStorage] + SnapStart: Optional[SnapStart] + LoggingConfig: Optional[LoggingConfig] + + +class UpdateFunctionEventInvokeConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[Qualifier] + MaximumRetryAttempts: Optional[MaximumRetryAttempts] + MaximumEventAgeInSeconds: Optional[MaximumEventAgeInSeconds] + DestinationConfig: Optional[DestinationConfig] + + +class UpdateFunctionUrlConfigRequest(ServiceRequest): + FunctionName: FunctionName + Qualifier: Optional[FunctionUrlQualifier] + AuthType: Optional[FunctionUrlAuthType] + Cors: Optional[Cors] + InvokeMode: Optional[InvokeMode] + + +class UpdateFunctionUrlConfigResponse(TypedDict, total=False): + FunctionUrl: FunctionUrl + FunctionArn: FunctionArn + AuthType: FunctionUrlAuthType + Cors: Optional[Cors] + CreationTime: Timestamp + LastModifiedTime: Timestamp + InvokeMode: Optional[InvokeMode] + + +class LambdaApi: + service = "lambda" + version = "2015-03-31" + + @handler("AddLayerVersionPermission") + def add_layer_version_permission( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + statement_id: StatementId, + action: LayerPermissionAllowedAction, + principal: LayerPermissionAllowedPrincipal, + organization_id: OrganizationId | None = None, + revision_id: String | None = None, + **kwargs, + ) -> AddLayerVersionPermissionResponse: + raise NotImplementedError + + @handler("AddPermission") + def add_permission( + self, + context: RequestContext, + function_name: FunctionName, + statement_id: StatementId, + action: Action, + principal: Principal, + source_arn: Arn | None = None, + source_account: SourceOwner | None = None, + event_source_token: EventSourceToken | None = None, + qualifier: Qualifier | None = None, + revision_id: String | None = None, + principal_org_id: PrincipalOrgID | None = None, + function_url_auth_type: FunctionUrlAuthType | None = None, + **kwargs, + ) -> AddPermissionResponse: + raise NotImplementedError + + @handler("CreateAlias") + def create_alias( + self, + context: RequestContext, + function_name: FunctionName, + name: Alias, + function_version: Version, + description: Description | None = None, + routing_config: AliasRoutingConfiguration | None = None, + **kwargs, + ) -> AliasConfiguration: + raise NotImplementedError + + @handler("CreateCodeSigningConfig") + def create_code_signing_config( + self, + context: RequestContext, + allowed_publishers: AllowedPublishers, + description: Description | None = None, + code_signing_policies: CodeSigningPolicies | None = None, + tags: Tags | None = None, + **kwargs, + ) -> CreateCodeSigningConfigResponse: + raise NotImplementedError + + @handler("CreateEventSourceMapping") + def create_event_source_mapping( + self, + context: RequestContext, + function_name: FunctionName, + event_source_arn: Arn | None = None, + enabled: Enabled | None = None, + batch_size: BatchSize | None = None, + filter_criteria: FilterCriteria | None = None, + maximum_batching_window_in_seconds: MaximumBatchingWindowInSeconds | None = None, + parallelization_factor: ParallelizationFactor | None = None, + starting_position: EventSourcePosition | None = None, + starting_position_timestamp: Date | None = None, + destination_config: DestinationConfig | None = None, + maximum_record_age_in_seconds: MaximumRecordAgeInSeconds | None = None, + bisect_batch_on_function_error: BisectBatchOnFunctionError | None = None, + maximum_retry_attempts: MaximumRetryAttemptsEventSourceMapping | None = None, + tags: Tags | None = None, + tumbling_window_in_seconds: TumblingWindowInSeconds | None = None, + topics: Topics | None = None, + queues: Queues | None = None, + source_access_configurations: SourceAccessConfigurations | None = None, + self_managed_event_source: SelfManagedEventSource | None = None, + function_response_types: FunctionResponseTypeList | None = None, + amazon_managed_kafka_event_source_config: AmazonManagedKafkaEventSourceConfig | None = None, + self_managed_kafka_event_source_config: SelfManagedKafkaEventSourceConfig | None = None, + scaling_config: ScalingConfig | None = None, + document_db_event_source_config: DocumentDBEventSourceConfig | None = None, + kms_key_arn: KMSKeyArn | None = None, + metrics_config: EventSourceMappingMetricsConfig | None = None, + provisioned_poller_config: ProvisionedPollerConfig | None = None, + **kwargs, + ) -> EventSourceMappingConfiguration: + raise NotImplementedError + + @handler("CreateFunction") + def create_function( + self, + context: RequestContext, + function_name: FunctionName, + role: RoleArn, + code: FunctionCode, + runtime: Runtime | None = None, + handler: Handler | None = None, + description: Description | None = None, + timeout: Timeout | None = None, + memory_size: MemorySize | None = None, + publish: Boolean | None = None, + vpc_config: VpcConfig | None = None, + package_type: PackageType | None = None, + dead_letter_config: DeadLetterConfig | None = None, + environment: Environment | None = None, + kms_key_arn: KMSKeyArn | None = None, + tracing_config: TracingConfig | None = None, + tags: Tags | None = None, + layers: LayerList | None = None, + file_system_configs: FileSystemConfigList | None = None, + image_config: ImageConfig | None = None, + code_signing_config_arn: CodeSigningConfigArn | None = None, + architectures: ArchitecturesList | None = None, + ephemeral_storage: EphemeralStorage | None = None, + snap_start: SnapStart | None = None, + logging_config: LoggingConfig | None = None, + **kwargs, + ) -> FunctionConfiguration: + raise NotImplementedError + + @handler("CreateFunctionUrlConfig") + def create_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + auth_type: FunctionUrlAuthType, + qualifier: FunctionUrlQualifier | None = None, + cors: Cors | None = None, + invoke_mode: InvokeMode | None = None, + **kwargs, + ) -> CreateFunctionUrlConfigResponse: + raise NotImplementedError + + @handler("DeleteAlias") + def delete_alias( + self, context: RequestContext, function_name: FunctionName, name: Alias, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteCodeSigningConfig") + def delete_code_signing_config( + self, context: RequestContext, code_signing_config_arn: CodeSigningConfigArn, **kwargs + ) -> DeleteCodeSigningConfigResponse: + raise NotImplementedError + + @handler("DeleteEventSourceMapping") + def delete_event_source_mapping( + self, context: RequestContext, uuid: String, **kwargs + ) -> EventSourceMappingConfiguration: + raise NotImplementedError + + @handler("DeleteFunction") + def delete_function( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteFunctionCodeSigningConfig") + def delete_function_code_signing_config( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteFunctionConcurrency") + def delete_function_concurrency( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteFunctionEventInvokeConfig") + def delete_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteFunctionUrlConfig") + def delete_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: FunctionUrlQualifier | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteLayerVersion") + def delete_layer_version( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteProvisionedConcurrencyConfig") + def delete_provisioned_concurrency_config( + self, context: RequestContext, function_name: FunctionName, qualifier: Qualifier, **kwargs + ) -> None: + raise NotImplementedError + + @handler("GetAccountSettings") + def get_account_settings(self, context: RequestContext, **kwargs) -> GetAccountSettingsResponse: + raise NotImplementedError + + @handler("GetAlias") + def get_alias( + self, context: RequestContext, function_name: FunctionName, name: Alias, **kwargs + ) -> AliasConfiguration: + raise NotImplementedError + + @handler("GetCodeSigningConfig") + def get_code_signing_config( + self, context: RequestContext, code_signing_config_arn: CodeSigningConfigArn, **kwargs + ) -> GetCodeSigningConfigResponse: + raise NotImplementedError + + @handler("GetEventSourceMapping") + def get_event_source_mapping( + self, context: RequestContext, uuid: String, **kwargs + ) -> EventSourceMappingConfiguration: + raise NotImplementedError + + @handler("GetFunction") + def get_function( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + qualifier: Qualifier | None = None, + **kwargs, + ) -> GetFunctionResponse: + raise NotImplementedError + + @handler("GetFunctionCodeSigningConfig") + def get_function_code_signing_config( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> GetFunctionCodeSigningConfigResponse: + raise NotImplementedError + + @handler("GetFunctionConcurrency") + def get_function_concurrency( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> GetFunctionConcurrencyResponse: + raise NotImplementedError + + @handler("GetFunctionConfiguration") + def get_function_configuration( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + qualifier: Qualifier | None = None, + **kwargs, + ) -> FunctionConfiguration: + raise NotImplementedError + + @handler("GetFunctionEventInvokeConfig") + def get_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier | None = None, + **kwargs, + ) -> FunctionEventInvokeConfig: + raise NotImplementedError + + @handler("GetFunctionRecursionConfig") + def get_function_recursion_config( + self, context: RequestContext, function_name: UnqualifiedFunctionName, **kwargs + ) -> GetFunctionRecursionConfigResponse: + raise NotImplementedError + + @handler("GetFunctionUrlConfig") + def get_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: FunctionUrlQualifier | None = None, + **kwargs, + ) -> GetFunctionUrlConfigResponse: + raise NotImplementedError + + @handler("GetLayerVersion") + def get_layer_version( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + **kwargs, + ) -> GetLayerVersionResponse: + raise NotImplementedError + + @handler("GetLayerVersionByArn") + def get_layer_version_by_arn( + self, context: RequestContext, arn: LayerVersionArn, **kwargs + ) -> GetLayerVersionResponse: + raise NotImplementedError + + @handler("GetLayerVersionPolicy") + def get_layer_version_policy( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + **kwargs, + ) -> GetLayerVersionPolicyResponse: + raise NotImplementedError + + @handler("GetPolicy") + def get_policy( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + qualifier: Qualifier | None = None, + **kwargs, + ) -> GetPolicyResponse: + raise NotImplementedError + + @handler("GetProvisionedConcurrencyConfig") + def get_provisioned_concurrency_config( + self, context: RequestContext, function_name: FunctionName, qualifier: Qualifier, **kwargs + ) -> GetProvisionedConcurrencyConfigResponse: + raise NotImplementedError + + @handler("GetRuntimeManagementConfig") + def get_runtime_management_config( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + qualifier: Qualifier | None = None, + **kwargs, + ) -> GetRuntimeManagementConfigResponse: + raise NotImplementedError + + @handler("Invoke") + def invoke( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + invocation_type: InvocationType | None = None, + log_type: LogType | None = None, + client_context: String | None = None, + payload: IO[Blob] | None = None, + qualifier: Qualifier | None = None, + **kwargs, + ) -> InvocationResponse: + raise NotImplementedError + + @handler("InvokeAsync") + def invoke_async( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + invoke_args: IO[BlobStream], + **kwargs, + ) -> InvokeAsyncResponse: + raise NotImplementedError + + @handler("InvokeWithResponseStream") + def invoke_with_response_stream( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + invocation_type: ResponseStreamingInvocationType | None = None, + log_type: LogType | None = None, + client_context: String | None = None, + qualifier: Qualifier | None = None, + payload: IO[Blob] | None = None, + **kwargs, + ) -> InvokeWithResponseStreamResponse: + raise NotImplementedError + + @handler("ListAliases") + def list_aliases( + self, + context: RequestContext, + function_name: FunctionName, + function_version: Version | None = None, + marker: String | None = None, + max_items: MaxListItems | None = None, + **kwargs, + ) -> ListAliasesResponse: + raise NotImplementedError + + @handler("ListCodeSigningConfigs") + def list_code_signing_configs( + self, + context: RequestContext, + marker: String | None = None, + max_items: MaxListItems | None = None, + **kwargs, + ) -> ListCodeSigningConfigsResponse: + raise NotImplementedError + + @handler("ListEventSourceMappings") + def list_event_source_mappings( + self, + context: RequestContext, + event_source_arn: Arn | None = None, + function_name: FunctionName | None = None, + marker: String | None = None, + max_items: MaxListItems | None = None, + **kwargs, + ) -> ListEventSourceMappingsResponse: + raise NotImplementedError + + @handler("ListFunctionEventInvokeConfigs") + def list_function_event_invoke_configs( + self, + context: RequestContext, + function_name: FunctionName, + marker: String | None = None, + max_items: MaxFunctionEventInvokeConfigListItems | None = None, + **kwargs, + ) -> ListFunctionEventInvokeConfigsResponse: + raise NotImplementedError + + @handler("ListFunctionUrlConfigs") + def list_function_url_configs( + self, + context: RequestContext, + function_name: FunctionName, + marker: String | None = None, + max_items: MaxItems | None = None, + **kwargs, + ) -> ListFunctionUrlConfigsResponse: + raise NotImplementedError + + @handler("ListFunctions") + def list_functions( + self, + context: RequestContext, + master_region: MasterRegion | None = None, + function_version: FunctionVersion | None = None, + marker: String | None = None, + max_items: MaxListItems | None = None, + **kwargs, + ) -> ListFunctionsResponse: + raise NotImplementedError + + @handler("ListFunctionsByCodeSigningConfig") + def list_functions_by_code_signing_config( + self, + context: RequestContext, + code_signing_config_arn: CodeSigningConfigArn, + marker: String | None = None, + max_items: MaxListItems | None = None, + **kwargs, + ) -> ListFunctionsByCodeSigningConfigResponse: + raise NotImplementedError + + @handler("ListLayerVersions") + def list_layer_versions( + self, + context: RequestContext, + layer_name: LayerName, + compatible_runtime: Runtime | None = None, + marker: String | None = None, + max_items: MaxLayerListItems | None = None, + compatible_architecture: Architecture | None = None, + **kwargs, + ) -> ListLayerVersionsResponse: + raise NotImplementedError + + @handler("ListLayers") + def list_layers( + self, + context: RequestContext, + compatible_runtime: Runtime | None = None, + marker: String | None = None, + max_items: MaxLayerListItems | None = None, + compatible_architecture: Architecture | None = None, + **kwargs, + ) -> ListLayersResponse: + raise NotImplementedError + + @handler("ListProvisionedConcurrencyConfigs") + def list_provisioned_concurrency_configs( + self, + context: RequestContext, + function_name: FunctionName, + marker: String | None = None, + max_items: MaxProvisionedConcurrencyConfigListItems | None = None, + **kwargs, + ) -> ListProvisionedConcurrencyConfigsResponse: + raise NotImplementedError + + @handler("ListTags") + def list_tags( + self, context: RequestContext, resource: TaggableResource, **kwargs + ) -> ListTagsResponse: + raise NotImplementedError + + @handler("ListVersionsByFunction") + def list_versions_by_function( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + marker: String | None = None, + max_items: MaxListItems | None = None, + **kwargs, + ) -> ListVersionsByFunctionResponse: + raise NotImplementedError + + @handler("PublishLayerVersion") + def publish_layer_version( + self, + context: RequestContext, + layer_name: LayerName, + content: LayerVersionContentInput, + description: Description | None = None, + compatible_runtimes: CompatibleRuntimes | None = None, + license_info: LicenseInfo | None = None, + compatible_architectures: CompatibleArchitectures | None = None, + **kwargs, + ) -> PublishLayerVersionResponse: + raise NotImplementedError + + @handler("PublishVersion") + def publish_version( + self, + context: RequestContext, + function_name: FunctionName, + code_sha256: String | None = None, + description: Description | None = None, + revision_id: String | None = None, + **kwargs, + ) -> FunctionConfiguration: + raise NotImplementedError + + @handler("PutFunctionCodeSigningConfig") + def put_function_code_signing_config( + self, + context: RequestContext, + code_signing_config_arn: CodeSigningConfigArn, + function_name: FunctionName, + **kwargs, + ) -> PutFunctionCodeSigningConfigResponse: + raise NotImplementedError + + @handler("PutFunctionConcurrency") + def put_function_concurrency( + self, + context: RequestContext, + function_name: FunctionName, + reserved_concurrent_executions: ReservedConcurrentExecutions, + **kwargs, + ) -> Concurrency: + raise NotImplementedError + + @handler("PutFunctionEventInvokeConfig") + def put_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier | None = None, + maximum_retry_attempts: MaximumRetryAttempts | None = None, + maximum_event_age_in_seconds: MaximumEventAgeInSeconds | None = None, + destination_config: DestinationConfig | None = None, + **kwargs, + ) -> FunctionEventInvokeConfig: + raise NotImplementedError + + @handler("PutFunctionRecursionConfig") + def put_function_recursion_config( + self, + context: RequestContext, + function_name: UnqualifiedFunctionName, + recursive_loop: RecursiveLoop, + **kwargs, + ) -> PutFunctionRecursionConfigResponse: + raise NotImplementedError + + @handler("PutProvisionedConcurrencyConfig") + def put_provisioned_concurrency_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier, + provisioned_concurrent_executions: PositiveInteger, + **kwargs, + ) -> PutProvisionedConcurrencyConfigResponse: + raise NotImplementedError + + @handler("PutRuntimeManagementConfig") + def put_runtime_management_config( + self, + context: RequestContext, + function_name: FunctionName, + update_runtime_on: UpdateRuntimeOn, + qualifier: Qualifier | None = None, + runtime_version_arn: RuntimeVersionArn | None = None, + **kwargs, + ) -> PutRuntimeManagementConfigResponse: + raise NotImplementedError + + @handler("RemoveLayerVersionPermission") + def remove_layer_version_permission( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + statement_id: StatementId, + revision_id: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RemovePermission") + def remove_permission( + self, + context: RequestContext, + function_name: FunctionName, + statement_id: NamespacedStatementId, + qualifier: Qualifier | None = None, + revision_id: String | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource: TaggableResource, tags: Tags, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource: TaggableResource, tag_keys: TagKeyList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdateAlias") + def update_alias( + self, + context: RequestContext, + function_name: FunctionName, + name: Alias, + function_version: Version | None = None, + description: Description | None = None, + routing_config: AliasRoutingConfiguration | None = None, + revision_id: String | None = None, + **kwargs, + ) -> AliasConfiguration: + raise NotImplementedError + + @handler("UpdateCodeSigningConfig") + def update_code_signing_config( + self, + context: RequestContext, + code_signing_config_arn: CodeSigningConfigArn, + description: Description | None = None, + allowed_publishers: AllowedPublishers | None = None, + code_signing_policies: CodeSigningPolicies | None = None, + **kwargs, + ) -> UpdateCodeSigningConfigResponse: + raise NotImplementedError + + @handler("UpdateEventSourceMapping") + def update_event_source_mapping( + self, + context: RequestContext, + uuid: String, + function_name: FunctionName | None = None, + enabled: Enabled | None = None, + batch_size: BatchSize | None = None, + filter_criteria: FilterCriteria | None = None, + maximum_batching_window_in_seconds: MaximumBatchingWindowInSeconds | None = None, + destination_config: DestinationConfig | None = None, + maximum_record_age_in_seconds: MaximumRecordAgeInSeconds | None = None, + bisect_batch_on_function_error: BisectBatchOnFunctionError | None = None, + maximum_retry_attempts: MaximumRetryAttemptsEventSourceMapping | None = None, + parallelization_factor: ParallelizationFactor | None = None, + source_access_configurations: SourceAccessConfigurations | None = None, + tumbling_window_in_seconds: TumblingWindowInSeconds | None = None, + function_response_types: FunctionResponseTypeList | None = None, + scaling_config: ScalingConfig | None = None, + amazon_managed_kafka_event_source_config: AmazonManagedKafkaEventSourceConfig | None = None, + self_managed_kafka_event_source_config: SelfManagedKafkaEventSourceConfig | None = None, + document_db_event_source_config: DocumentDBEventSourceConfig | None = None, + kms_key_arn: KMSKeyArn | None = None, + metrics_config: EventSourceMappingMetricsConfig | None = None, + provisioned_poller_config: ProvisionedPollerConfig | None = None, + **kwargs, + ) -> EventSourceMappingConfiguration: + raise NotImplementedError + + @handler("UpdateFunctionCode") + def update_function_code( + self, + context: RequestContext, + function_name: FunctionName, + zip_file: Blob | None = None, + s3_bucket: S3Bucket | None = None, + s3_key: S3Key | None = None, + s3_object_version: S3ObjectVersion | None = None, + image_uri: String | None = None, + publish: Boolean | None = None, + dry_run: Boolean | None = None, + revision_id: String | None = None, + architectures: ArchitecturesList | None = None, + source_kms_key_arn: KMSKeyArn | None = None, + **kwargs, + ) -> FunctionConfiguration: + raise NotImplementedError + + @handler("UpdateFunctionConfiguration") + def update_function_configuration( + self, + context: RequestContext, + function_name: FunctionName, + role: RoleArn | None = None, + handler: Handler | None = None, + description: Description | None = None, + timeout: Timeout | None = None, + memory_size: MemorySize | None = None, + vpc_config: VpcConfig | None = None, + environment: Environment | None = None, + runtime: Runtime | None = None, + dead_letter_config: DeadLetterConfig | None = None, + kms_key_arn: KMSKeyArn | None = None, + tracing_config: TracingConfig | None = None, + revision_id: String | None = None, + layers: LayerList | None = None, + file_system_configs: FileSystemConfigList | None = None, + image_config: ImageConfig | None = None, + ephemeral_storage: EphemeralStorage | None = None, + snap_start: SnapStart | None = None, + logging_config: LoggingConfig | None = None, + **kwargs, + ) -> FunctionConfiguration: + raise NotImplementedError + + @handler("UpdateFunctionEventInvokeConfig") + def update_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier | None = None, + maximum_retry_attempts: MaximumRetryAttempts | None = None, + maximum_event_age_in_seconds: MaximumEventAgeInSeconds | None = None, + destination_config: DestinationConfig | None = None, + **kwargs, + ) -> FunctionEventInvokeConfig: + raise NotImplementedError + + @handler("UpdateFunctionUrlConfig") + def update_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: FunctionUrlQualifier | None = None, + auth_type: FunctionUrlAuthType | None = None, + cors: Cors | None = None, + invoke_mode: InvokeMode | None = None, + **kwargs, + ) -> UpdateFunctionUrlConfigResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/logs/__init__.py b/localstack-core/localstack/aws/api/logs/__init__.py new file mode 100644 index 0000000000000..1d93648315038 --- /dev/null +++ b/localstack-core/localstack/aws/api/logs/__init__.py @@ -0,0 +1,3050 @@ +from enum import StrEnum +from typing import Dict, Iterator, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AccessPolicy = str +AccountId = str +AccountPolicyDocument = str +AddKeyValue = str +AllowedActionForAllowVendedLogsDeliveryForResource = str +AmazonResourceName = str +AnomalyDetectorArn = str +AnomalyId = str +ApplyOnTransformedLogs = bool +Arn = str +Baseline = bool +Boolean = bool +ClientToken = str +CollectionRetentionDays = int +Column = str +DataProtectionPolicyDocument = str +Days = int +DefaultValue = float +Delimiter = str +DeliveryDestinationName = str +DeliveryDestinationPolicy = str +DeliveryId = str +DeliverySourceName = str +DeliverySuffixPath = str +Descending = bool +DescribeLimit = int +DescribeQueriesMaxResults = int +Description = str +DestinationArn = str +DestinationField = str +DestinationName = str +DetectorKmsKeyArn = str +DetectorName = str +DimensionsKey = str +DimensionsValue = str +DynamicTokenPosition = int +EncryptionKey = str +EntityAttributesKey = str +EntityAttributesValue = str +EntityKeyAttributesKey = str +EntityKeyAttributesValue = str +EventId = str +EventMessage = str +EventsLimit = int +ExportDestinationBucket = str +ExportDestinationPrefix = str +ExportTaskId = str +ExportTaskName = str +ExportTaskStatusMessage = str +Field = str +FieldDelimiter = str +FieldHeader = str +FieldIndexName = str +FilterCount = int +FilterName = str +FilterPattern = str +Flatten = bool +Force = bool +ForceUpdate = bool +FromKey = str +GrokMatch = str +IncludeLinkedAccounts = bool +InferredTokenName = str +Integer = int +IntegrationName = str +IntegrationNamePrefix = str +IntegrationStatusMessage = str +Interleaved = bool +IsSampled = bool +Key = str +KeyPrefix = str +KeyValueDelimiter = str +KmsKeyId = str +ListAnomaliesLimit = int +ListLimit = int +ListLogAnomalyDetectorsLimit = int +ListLogGroupsForQueryMaxResults = int +Locale = str +LogEventIndex = int +LogGroupArn = str +LogGroupIdentifier = str +LogGroupName = str +LogGroupNamePattern = str +LogGroupNameRegexPattern = str +LogRecordPointer = str +LogStreamName = str +LogStreamSearchedCompletely = bool +LogType = str +MatchPattern = str +Message = str +MetricName = str +MetricNamespace = str +MetricValue = str +NextToken = str +NonMatchValue = str +OpenSearchApplicationEndpoint = str +OpenSearchApplicationId = str +OpenSearchCollectionEndpoint = str +OpenSearchDataSourceName = str +OpenSearchPolicyName = str +OpenSearchWorkspaceId = str +OverwriteIfExists = bool +ParserFieldDelimiter = str +PatternId = str +PatternRegex = str +PatternString = str +Percentage = int +PolicyDocument = str +PolicyName = str +Priority = str +QueryCharOffset = int +QueryDefinitionName = str +QueryDefinitionString = str +QueryId = str +QueryListMaxResults = int +QueryString = str +QuoteCharacter = str +RenameTo = str +RequestId = str +ResourceIdentifier = str +ResourceType = str +RoleArn = str +SelectionCriteria = str +SequenceToken = str +Service = str +SessionId = str +Source = str +SourceTimezone = str +SplitStringDelimiter = str +StartFromHead = bool +StatsValue = float +Success = bool +TagKey = str +TagValue = str +Target = str +TargetArn = str +TargetFormat = str +TargetTimezone = str +Time = str +ToKey = str +Token = str +TokenString = str +TransformedEventMessage = str +Unmask = bool +Value = str +ValueKey = str +WithKey = str + + +class AnomalyDetectorStatus(StrEnum): + INITIALIZING = "INITIALIZING" + TRAINING = "TRAINING" + ANALYZING = "ANALYZING" + FAILED = "FAILED" + DELETED = "DELETED" + PAUSED = "PAUSED" + + +class DataProtectionStatus(StrEnum): + ACTIVATED = "ACTIVATED" + DELETED = "DELETED" + ARCHIVED = "ARCHIVED" + DISABLED = "DISABLED" + + +class DeliveryDestinationType(StrEnum): + S3 = "S3" + CWL = "CWL" + FH = "FH" + + +class Distribution(StrEnum): + Random = "Random" + ByLogStream = "ByLogStream" + + +class EntityRejectionErrorType(StrEnum): + InvalidEntity = "InvalidEntity" + InvalidTypeValue = "InvalidTypeValue" + InvalidKeyAttributes = "InvalidKeyAttributes" + InvalidAttributes = "InvalidAttributes" + EntitySizeTooLarge = "EntitySizeTooLarge" + UnsupportedLogGroupType = "UnsupportedLogGroupType" + MissingRequiredFields = "MissingRequiredFields" + + +class EvaluationFrequency(StrEnum): + ONE_MIN = "ONE_MIN" + FIVE_MIN = "FIVE_MIN" + TEN_MIN = "TEN_MIN" + FIFTEEN_MIN = "FIFTEEN_MIN" + THIRTY_MIN = "THIRTY_MIN" + ONE_HOUR = "ONE_HOUR" + + +class EventSource(StrEnum): + CloudTrail = "CloudTrail" + Route53Resolver = "Route53Resolver" + VPCFlow = "VPCFlow" + EKSAudit = "EKSAudit" + AWSWAF = "AWSWAF" + + +class ExportTaskStatusCode(StrEnum): + CANCELLED = "CANCELLED" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + PENDING = "PENDING" + PENDING_CANCEL = "PENDING_CANCEL" + RUNNING = "RUNNING" + + +class FlattenedElement(StrEnum): + first = "first" + last = "last" + + +class IndexSource(StrEnum): + ACCOUNT = "ACCOUNT" + LOG_GROUP = "LOG_GROUP" + + +class InheritedProperty(StrEnum): + ACCOUNT_DATA_PROTECTION = "ACCOUNT_DATA_PROTECTION" + + +class IntegrationStatus(StrEnum): + PROVISIONING = "PROVISIONING" + ACTIVE = "ACTIVE" + FAILED = "FAILED" + + +class IntegrationType(StrEnum): + OPENSEARCH = "OPENSEARCH" + + +class LogGroupClass(StrEnum): + STANDARD = "STANDARD" + INFREQUENT_ACCESS = "INFREQUENT_ACCESS" + DELIVERY = "DELIVERY" + + +class OCSFVersion(StrEnum): + V1_1 = "V1.1" + + +class OpenSearchResourceStatusType(StrEnum): + ACTIVE = "ACTIVE" + NOT_FOUND = "NOT_FOUND" + ERROR = "ERROR" + + +class OrderBy(StrEnum): + LogStreamName = "LogStreamName" + LastEventTime = "LastEventTime" + + +class OutputFormat(StrEnum): + json = "json" + plain = "plain" + w3c = "w3c" + raw = "raw" + parquet = "parquet" + + +class PolicyType(StrEnum): + DATA_PROTECTION_POLICY = "DATA_PROTECTION_POLICY" + SUBSCRIPTION_FILTER_POLICY = "SUBSCRIPTION_FILTER_POLICY" + FIELD_INDEX_POLICY = "FIELD_INDEX_POLICY" + TRANSFORMER_POLICY = "TRANSFORMER_POLICY" + + +class QueryLanguage(StrEnum): + CWLI = "CWLI" + SQL = "SQL" + PPL = "PPL" + + +class QueryStatus(StrEnum): + Scheduled = "Scheduled" + Running = "Running" + Complete = "Complete" + Failed = "Failed" + Cancelled = "Cancelled" + Timeout = "Timeout" + Unknown = "Unknown" + + +class Scope(StrEnum): + ALL = "ALL" + + +class StandardUnit(StrEnum): + Seconds = "Seconds" + Microseconds = "Microseconds" + Milliseconds = "Milliseconds" + Bytes = "Bytes" + Kilobytes = "Kilobytes" + Megabytes = "Megabytes" + Gigabytes = "Gigabytes" + Terabytes = "Terabytes" + Bits = "Bits" + Kilobits = "Kilobits" + Megabits = "Megabits" + Gigabits = "Gigabits" + Terabits = "Terabits" + Percent = "Percent" + Count = "Count" + Bytes_Second = "Bytes/Second" + Kilobytes_Second = "Kilobytes/Second" + Megabytes_Second = "Megabytes/Second" + Gigabytes_Second = "Gigabytes/Second" + Terabytes_Second = "Terabytes/Second" + Bits_Second = "Bits/Second" + Kilobits_Second = "Kilobits/Second" + Megabits_Second = "Megabits/Second" + Gigabits_Second = "Gigabits/Second" + Terabits_Second = "Terabits/Second" + Count_Second = "Count/Second" + None_ = "None" + + +class State(StrEnum): + Active = "Active" + Suppressed = "Suppressed" + Baseline = "Baseline" + + +class SuppressionState(StrEnum): + SUPPRESSED = "SUPPRESSED" + UNSUPPRESSED = "UNSUPPRESSED" + + +class SuppressionType(StrEnum): + LIMITED = "LIMITED" + INFINITE = "INFINITE" + + +class SuppressionUnit(StrEnum): + SECONDS = "SECONDS" + MINUTES = "MINUTES" + HOURS = "HOURS" + + +class Type(StrEnum): + boolean = "boolean" + integer = "integer" + double = "double" + string = "string" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class DataAlreadyAcceptedException(ServiceException): + code: str = "DataAlreadyAcceptedException" + sender_fault: bool = False + status_code: int = 400 + expectedSequenceToken: Optional[SequenceToken] + + +class InvalidOperationException(ServiceException): + code: str = "InvalidOperationException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidParameterException(ServiceException): + code: str = "InvalidParameterException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidSequenceTokenException(ServiceException): + code: str = "InvalidSequenceTokenException" + sender_fault: bool = False + status_code: int = 400 + expectedSequenceToken: Optional[SequenceToken] + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class QueryCompileErrorLocation(TypedDict, total=False): + startCharOffset: Optional[QueryCharOffset] + endCharOffset: Optional[QueryCharOffset] + + +class QueryCompileError(TypedDict, total=False): + location: Optional[QueryCompileErrorLocation] + message: Optional[Message] + + +class MalformedQueryException(ServiceException): + code: str = "MalformedQueryException" + sender_fault: bool = False + status_code: int = 400 + queryCompileError: Optional[QueryCompileError] + + +class OperationAbortedException(ServiceException): + code: str = "OperationAbortedException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceAlreadyExistsException(ServiceException): + code: str = "ResourceAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ServiceQuotaExceededException(ServiceException): + code: str = "ServiceQuotaExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ServiceUnavailableException(ServiceException): + code: str = "ServiceUnavailableException" + sender_fault: bool = False + status_code: int = 400 + + +class SessionStreamingException(ServiceException): + code: str = "SessionStreamingException" + sender_fault: bool = False + status_code: int = 400 + + +class SessionTimeoutException(ServiceException): + code: str = "SessionTimeoutException" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTagsException(ServiceException): + code: str = "TooManyTagsException" + sender_fault: bool = False + status_code: int = 400 + resourceName: Optional[AmazonResourceName] + + +class UnrecognizedClientException(ServiceException): + code: str = "UnrecognizedClientException" + sender_fault: bool = False + status_code: int = 400 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + + +AccountIds = List[AccountId] +Timestamp = int + + +class AccountPolicy(TypedDict, total=False): + policyName: Optional[PolicyName] + policyDocument: Optional[AccountPolicyDocument] + lastUpdatedTime: Optional[Timestamp] + policyType: Optional[PolicyType] + scope: Optional[Scope] + selectionCriteria: Optional[SelectionCriteria] + accountId: Optional[AccountId] + + +AccountPolicies = List[AccountPolicy] + + +class AddKeyEntry(TypedDict, total=False): + key: Key + value: AddKeyValue + overwriteIfExists: Optional[OverwriteIfExists] + + +AddKeyEntries = List[AddKeyEntry] + + +class AddKeys(TypedDict, total=False): + entries: AddKeyEntries + + +AllowedFieldDelimiters = List[FieldDelimiter] + + +class RecordField(TypedDict, total=False): + name: Optional[FieldHeader] + mandatory: Optional[Boolean] + + +AllowedFields = List[RecordField] +EpochMillis = int +LogGroupArnList = List[LogGroupArn] +TokenValue = int +Enumerations = Dict[TokenString, TokenValue] + + +class PatternToken(TypedDict, total=False): + dynamicTokenPosition: Optional[DynamicTokenPosition] + isDynamic: Optional[Boolean] + tokenString: Optional[TokenString] + enumerations: Optional[Enumerations] + inferredTokenName: Optional[InferredTokenName] + + +PatternTokens = List[PatternToken] + + +class LogEvent(TypedDict, total=False): + timestamp: Optional[Timestamp] + message: Optional[EventMessage] + + +LogSamples = List[LogEvent] +Count = int +Histogram = Dict[Time, Count] + + +class Anomaly(TypedDict, total=False): + anomalyId: AnomalyId + patternId: PatternId + anomalyDetectorArn: AnomalyDetectorArn + patternString: PatternString + patternRegex: Optional[PatternRegex] + priority: Optional[Priority] + firstSeen: EpochMillis + lastSeen: EpochMillis + description: Description + active: Boolean + state: State + histogram: Histogram + logSamples: LogSamples + patternTokens: PatternTokens + logGroupArnList: LogGroupArnList + suppressed: Optional[Boolean] + suppressedDate: Optional[EpochMillis] + suppressedUntil: Optional[EpochMillis] + isPatternLevelSuppression: Optional[Boolean] + + +Anomalies = List[Anomaly] +AnomalyVisibilityTime = int + + +class AnomalyDetector(TypedDict, total=False): + anomalyDetectorArn: Optional[AnomalyDetectorArn] + detectorName: Optional[DetectorName] + logGroupArnList: Optional[LogGroupArnList] + evaluationFrequency: Optional[EvaluationFrequency] + filterPattern: Optional[FilterPattern] + anomalyDetectorStatus: Optional[AnomalyDetectorStatus] + kmsKeyId: Optional[KmsKeyId] + creationTimeStamp: Optional[EpochMillis] + lastModifiedTimeStamp: Optional[EpochMillis] + anomalyVisibilityTime: Optional[AnomalyVisibilityTime] + + +AnomalyDetectors = List[AnomalyDetector] + + +class AssociateKmsKeyRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + kmsKeyId: KmsKeyId + resourceIdentifier: Optional[ResourceIdentifier] + + +Columns = List[Column] + + +class CSV(TypedDict, total=False): + quoteCharacter: Optional[QuoteCharacter] + delimiter: Optional[Delimiter] + columns: Optional[Columns] + source: Optional[Source] + + +class CancelExportTaskRequest(ServiceRequest): + taskId: ExportTaskId + + +RecordFields = List[FieldHeader] +OutputFormats = List[OutputFormat] + + +class S3DeliveryConfiguration(TypedDict, total=False): + suffixPath: Optional[DeliverySuffixPath] + enableHiveCompatiblePath: Optional[Boolean] + + +class ConfigurationTemplateDeliveryConfigValues(TypedDict, total=False): + recordFields: Optional[RecordFields] + fieldDelimiter: Optional[FieldDelimiter] + s3DeliveryConfiguration: Optional[S3DeliveryConfiguration] + + +class ConfigurationTemplate(TypedDict, total=False): + service: Optional[Service] + logType: Optional[LogType] + resourceType: Optional[ResourceType] + deliveryDestinationType: Optional[DeliveryDestinationType] + defaultDeliveryConfigValues: Optional[ConfigurationTemplateDeliveryConfigValues] + allowedFields: Optional[AllowedFields] + allowedOutputFormats: Optional[OutputFormats] + allowedActionForAllowVendedLogsDeliveryForResource: Optional[ + AllowedActionForAllowVendedLogsDeliveryForResource + ] + allowedFieldDelimiters: Optional[AllowedFieldDelimiters] + allowedSuffixPathFields: Optional[RecordFields] + + +ConfigurationTemplates = List[ConfigurationTemplate] + + +class CopyValueEntry(TypedDict, total=False): + source: Source + target: Target + overwriteIfExists: Optional[OverwriteIfExists] + + +CopyValueEntries = List[CopyValueEntry] + + +class CopyValue(TypedDict, total=False): + entries: CopyValueEntries + + +Tags = Dict[TagKey, TagValue] + + +class CreateDeliveryRequest(ServiceRequest): + deliverySourceName: DeliverySourceName + deliveryDestinationArn: Arn + recordFields: Optional[RecordFields] + fieldDelimiter: Optional[FieldDelimiter] + s3DeliveryConfiguration: Optional[S3DeliveryConfiguration] + tags: Optional[Tags] + + +class Delivery(TypedDict, total=False): + id: Optional[DeliveryId] + arn: Optional[Arn] + deliverySourceName: Optional[DeliverySourceName] + deliveryDestinationArn: Optional[Arn] + deliveryDestinationType: Optional[DeliveryDestinationType] + recordFields: Optional[RecordFields] + fieldDelimiter: Optional[FieldDelimiter] + s3DeliveryConfiguration: Optional[S3DeliveryConfiguration] + tags: Optional[Tags] + + +class CreateDeliveryResponse(TypedDict, total=False): + delivery: Optional[Delivery] + + +CreateExportTaskRequest = TypedDict( + "CreateExportTaskRequest", + { + "taskName": Optional[ExportTaskName], + "logGroupName": LogGroupName, + "logStreamNamePrefix": Optional[LogStreamName], + "from": Timestamp, + "to": Timestamp, + "destination": ExportDestinationBucket, + "destinationPrefix": Optional[ExportDestinationPrefix], + }, + total=False, +) + + +class CreateExportTaskResponse(TypedDict, total=False): + taskId: Optional[ExportTaskId] + + +class CreateLogAnomalyDetectorRequest(ServiceRequest): + logGroupArnList: LogGroupArnList + detectorName: Optional[DetectorName] + evaluationFrequency: Optional[EvaluationFrequency] + filterPattern: Optional[FilterPattern] + kmsKeyId: Optional[DetectorKmsKeyArn] + anomalyVisibilityTime: Optional[AnomalyVisibilityTime] + tags: Optional[Tags] + + +class CreateLogAnomalyDetectorResponse(TypedDict, total=False): + anomalyDetectorArn: Optional[AnomalyDetectorArn] + + +class CreateLogGroupRequest(ServiceRequest): + logGroupName: LogGroupName + kmsKeyId: Optional[KmsKeyId] + tags: Optional[Tags] + logGroupClass: Optional[LogGroupClass] + + +class CreateLogStreamRequest(ServiceRequest): + logGroupName: LogGroupName + logStreamName: LogStreamName + + +DashboardViewerPrincipals = List[Arn] +MatchPatterns = List[MatchPattern] + + +class DateTimeConverter(TypedDict, total=False): + source: Source + target: Target + targetFormat: Optional[TargetFormat] + matchPatterns: MatchPatterns + sourceTimezone: Optional[SourceTimezone] + targetTimezone: Optional[TargetTimezone] + locale: Optional[Locale] + + +class DeleteAccountPolicyRequest(ServiceRequest): + policyName: PolicyName + policyType: PolicyType + + +class DeleteDataProtectionPolicyRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + + +class DeleteDeliveryDestinationPolicyRequest(ServiceRequest): + deliveryDestinationName: DeliveryDestinationName + + +class DeleteDeliveryDestinationRequest(ServiceRequest): + name: DeliveryDestinationName + + +class DeleteDeliveryRequest(ServiceRequest): + id: DeliveryId + + +class DeleteDeliverySourceRequest(ServiceRequest): + name: DeliverySourceName + + +class DeleteDestinationRequest(ServiceRequest): + destinationName: DestinationName + + +class DeleteIndexPolicyRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + + +class DeleteIndexPolicyResponse(TypedDict, total=False): + pass + + +class DeleteIntegrationRequest(ServiceRequest): + integrationName: IntegrationName + force: Optional[Force] + + +class DeleteIntegrationResponse(TypedDict, total=False): + pass + + +DeleteWithKeys = List[WithKey] + + +class DeleteKeys(TypedDict, total=False): + withKeys: DeleteWithKeys + + +class DeleteLogAnomalyDetectorRequest(ServiceRequest): + anomalyDetectorArn: AnomalyDetectorArn + + +class DeleteLogGroupRequest(ServiceRequest): + logGroupName: LogGroupName + + +class DeleteLogStreamRequest(ServiceRequest): + logGroupName: LogGroupName + logStreamName: LogStreamName + + +class DeleteMetricFilterRequest(ServiceRequest): + logGroupName: LogGroupName + filterName: FilterName + + +class DeleteQueryDefinitionRequest(ServiceRequest): + queryDefinitionId: QueryId + + +class DeleteQueryDefinitionResponse(TypedDict, total=False): + success: Optional[Success] + + +class DeleteResourcePolicyRequest(ServiceRequest): + policyName: Optional[PolicyName] + + +class DeleteRetentionPolicyRequest(ServiceRequest): + logGroupName: LogGroupName + + +class DeleteSubscriptionFilterRequest(ServiceRequest): + logGroupName: LogGroupName + filterName: FilterName + + +class DeleteTransformerRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + + +Deliveries = List[Delivery] + + +class DeliveryDestinationConfiguration(TypedDict, total=False): + destinationResourceArn: Arn + + +class DeliveryDestination(TypedDict, total=False): + name: Optional[DeliveryDestinationName] + arn: Optional[Arn] + deliveryDestinationType: Optional[DeliveryDestinationType] + outputFormat: Optional[OutputFormat] + deliveryDestinationConfiguration: Optional[DeliveryDestinationConfiguration] + tags: Optional[Tags] + + +DeliveryDestinationTypes = List[DeliveryDestinationType] +DeliveryDestinations = List[DeliveryDestination] +ResourceArns = List[Arn] + + +class DeliverySource(TypedDict, total=False): + name: Optional[DeliverySourceName] + arn: Optional[Arn] + resourceArns: Optional[ResourceArns] + service: Optional[Service] + logType: Optional[LogType] + tags: Optional[Tags] + + +DeliverySources = List[DeliverySource] + + +class DescribeAccountPoliciesRequest(ServiceRequest): + policyType: PolicyType + policyName: Optional[PolicyName] + accountIdentifiers: Optional[AccountIds] + nextToken: Optional[NextToken] + + +class DescribeAccountPoliciesResponse(TypedDict, total=False): + accountPolicies: Optional[AccountPolicies] + nextToken: Optional[NextToken] + + +ResourceTypes = List[ResourceType] +LogTypes = List[LogType] + + +class DescribeConfigurationTemplatesRequest(ServiceRequest): + service: Optional[Service] + logTypes: Optional[LogTypes] + resourceTypes: Optional[ResourceTypes] + deliveryDestinationTypes: Optional[DeliveryDestinationTypes] + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class DescribeConfigurationTemplatesResponse(TypedDict, total=False): + configurationTemplates: Optional[ConfigurationTemplates] + nextToken: Optional[NextToken] + + +class DescribeDeliveriesRequest(ServiceRequest): + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class DescribeDeliveriesResponse(TypedDict, total=False): + deliveries: Optional[Deliveries] + nextToken: Optional[NextToken] + + +class DescribeDeliveryDestinationsRequest(ServiceRequest): + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class DescribeDeliveryDestinationsResponse(TypedDict, total=False): + deliveryDestinations: Optional[DeliveryDestinations] + nextToken: Optional[NextToken] + + +class DescribeDeliverySourcesRequest(ServiceRequest): + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class DescribeDeliverySourcesResponse(TypedDict, total=False): + deliverySources: Optional[DeliverySources] + nextToken: Optional[NextToken] + + +class DescribeDestinationsRequest(ServiceRequest): + DestinationNamePrefix: Optional[DestinationName] + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class Destination(TypedDict, total=False): + destinationName: Optional[DestinationName] + targetArn: Optional[TargetArn] + roleArn: Optional[RoleArn] + accessPolicy: Optional[AccessPolicy] + arn: Optional[Arn] + creationTime: Optional[Timestamp] + + +Destinations = List[Destination] + + +class DescribeDestinationsResponse(TypedDict, total=False): + destinations: Optional[Destinations] + nextToken: Optional[NextToken] + + +class DescribeExportTasksRequest(ServiceRequest): + taskId: Optional[ExportTaskId] + statusCode: Optional[ExportTaskStatusCode] + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class ExportTaskExecutionInfo(TypedDict, total=False): + creationTime: Optional[Timestamp] + completionTime: Optional[Timestamp] + + +class ExportTaskStatus(TypedDict, total=False): + code: Optional[ExportTaskStatusCode] + message: Optional[ExportTaskStatusMessage] + + +ExportTask = TypedDict( + "ExportTask", + { + "taskId": Optional[ExportTaskId], + "taskName": Optional[ExportTaskName], + "logGroupName": Optional[LogGroupName], + "from": Optional[Timestamp], + "to": Optional[Timestamp], + "destination": Optional[ExportDestinationBucket], + "destinationPrefix": Optional[ExportDestinationPrefix], + "status": Optional[ExportTaskStatus], + "executionInfo": Optional[ExportTaskExecutionInfo], + }, + total=False, +) +ExportTasks = List[ExportTask] + + +class DescribeExportTasksResponse(TypedDict, total=False): + exportTasks: Optional[ExportTasks] + nextToken: Optional[NextToken] + + +DescribeFieldIndexesLogGroupIdentifiers = List[LogGroupIdentifier] + + +class DescribeFieldIndexesRequest(ServiceRequest): + logGroupIdentifiers: DescribeFieldIndexesLogGroupIdentifiers + nextToken: Optional[NextToken] + + +class FieldIndex(TypedDict, total=False): + logGroupIdentifier: Optional[LogGroupIdentifier] + fieldIndexName: Optional[FieldIndexName] + lastScanTime: Optional[Timestamp] + firstEventTime: Optional[Timestamp] + lastEventTime: Optional[Timestamp] + + +FieldIndexes = List[FieldIndex] + + +class DescribeFieldIndexesResponse(TypedDict, total=False): + fieldIndexes: Optional[FieldIndexes] + nextToken: Optional[NextToken] + + +DescribeIndexPoliciesLogGroupIdentifiers = List[LogGroupIdentifier] + + +class DescribeIndexPoliciesRequest(ServiceRequest): + logGroupIdentifiers: DescribeIndexPoliciesLogGroupIdentifiers + nextToken: Optional[NextToken] + + +class IndexPolicy(TypedDict, total=False): + logGroupIdentifier: Optional[LogGroupIdentifier] + lastUpdateTime: Optional[Timestamp] + policyDocument: Optional[PolicyDocument] + policyName: Optional[PolicyName] + source: Optional[IndexSource] + + +IndexPolicies = List[IndexPolicy] + + +class DescribeIndexPoliciesResponse(TypedDict, total=False): + indexPolicies: Optional[IndexPolicies] + nextToken: Optional[NextToken] + + +DescribeLogGroupsLogGroupIdentifiers = List[LogGroupIdentifier] + + +class DescribeLogGroupsRequest(ServiceRequest): + accountIdentifiers: Optional[AccountIds] + logGroupNamePrefix: Optional[LogGroupName] + logGroupNamePattern: Optional[LogGroupNamePattern] + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + includeLinkedAccounts: Optional[IncludeLinkedAccounts] + logGroupClass: Optional[LogGroupClass] + logGroupIdentifiers: Optional[DescribeLogGroupsLogGroupIdentifiers] + + +InheritedProperties = List[InheritedProperty] +StoredBytes = int + + +class LogGroup(TypedDict, total=False): + logGroupName: Optional[LogGroupName] + creationTime: Optional[Timestamp] + retentionInDays: Optional[Days] + metricFilterCount: Optional[FilterCount] + arn: Optional[Arn] + storedBytes: Optional[StoredBytes] + kmsKeyId: Optional[KmsKeyId] + dataProtectionStatus: Optional[DataProtectionStatus] + inheritedProperties: Optional[InheritedProperties] + logGroupClass: Optional[LogGroupClass] + logGroupArn: Optional[Arn] + + +LogGroups = List[LogGroup] + + +class DescribeLogGroupsResponse(TypedDict, total=False): + logGroups: Optional[LogGroups] + nextToken: Optional[NextToken] + + +class DescribeLogStreamsRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + logGroupIdentifier: Optional[LogGroupIdentifier] + logStreamNamePrefix: Optional[LogStreamName] + orderBy: Optional[OrderBy] + descending: Optional[Descending] + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class LogStream(TypedDict, total=False): + logStreamName: Optional[LogStreamName] + creationTime: Optional[Timestamp] + firstEventTimestamp: Optional[Timestamp] + lastEventTimestamp: Optional[Timestamp] + lastIngestionTime: Optional[Timestamp] + uploadSequenceToken: Optional[SequenceToken] + arn: Optional[Arn] + storedBytes: Optional[StoredBytes] + + +LogStreams = List[LogStream] + + +class DescribeLogStreamsResponse(TypedDict, total=False): + logStreams: Optional[LogStreams] + nextToken: Optional[NextToken] + + +class DescribeMetricFiltersRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + filterNamePrefix: Optional[FilterName] + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + metricName: Optional[MetricName] + metricNamespace: Optional[MetricNamespace] + + +Dimensions = Dict[DimensionsKey, DimensionsValue] + + +class MetricTransformation(TypedDict, total=False): + metricName: MetricName + metricNamespace: MetricNamespace + metricValue: MetricValue + defaultValue: Optional[DefaultValue] + dimensions: Optional[Dimensions] + unit: Optional[StandardUnit] + + +MetricTransformations = List[MetricTransformation] + + +class MetricFilter(TypedDict, total=False): + filterName: Optional[FilterName] + filterPattern: Optional[FilterPattern] + metricTransformations: Optional[MetricTransformations] + creationTime: Optional[Timestamp] + logGroupName: Optional[LogGroupName] + applyOnTransformedLogs: Optional[ApplyOnTransformedLogs] + + +MetricFilters = List[MetricFilter] + + +class DescribeMetricFiltersResponse(TypedDict, total=False): + metricFilters: Optional[MetricFilters] + nextToken: Optional[NextToken] + + +class DescribeQueriesRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + status: Optional[QueryStatus] + maxResults: Optional[DescribeQueriesMaxResults] + nextToken: Optional[NextToken] + queryLanguage: Optional[QueryLanguage] + + +class QueryInfo(TypedDict, total=False): + queryLanguage: Optional[QueryLanguage] + queryId: Optional[QueryId] + queryString: Optional[QueryString] + status: Optional[QueryStatus] + createTime: Optional[Timestamp] + logGroupName: Optional[LogGroupName] + + +QueryInfoList = List[QueryInfo] + + +class DescribeQueriesResponse(TypedDict, total=False): + queries: Optional[QueryInfoList] + nextToken: Optional[NextToken] + + +class DescribeQueryDefinitionsRequest(ServiceRequest): + queryLanguage: Optional[QueryLanguage] + queryDefinitionNamePrefix: Optional[QueryDefinitionName] + maxResults: Optional[QueryListMaxResults] + nextToken: Optional[NextToken] + + +LogGroupNames = List[LogGroupName] + + +class QueryDefinition(TypedDict, total=False): + queryLanguage: Optional[QueryLanguage] + queryDefinitionId: Optional[QueryId] + name: Optional[QueryDefinitionName] + queryString: Optional[QueryDefinitionString] + lastModified: Optional[Timestamp] + logGroupNames: Optional[LogGroupNames] + + +QueryDefinitionList = List[QueryDefinition] + + +class DescribeQueryDefinitionsResponse(TypedDict, total=False): + queryDefinitions: Optional[QueryDefinitionList] + nextToken: Optional[NextToken] + + +class DescribeResourcePoliciesRequest(ServiceRequest): + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class ResourcePolicy(TypedDict, total=False): + policyName: Optional[PolicyName] + policyDocument: Optional[PolicyDocument] + lastUpdatedTime: Optional[Timestamp] + + +ResourcePolicies = List[ResourcePolicy] + + +class DescribeResourcePoliciesResponse(TypedDict, total=False): + resourcePolicies: Optional[ResourcePolicies] + nextToken: Optional[NextToken] + + +class DescribeSubscriptionFiltersRequest(ServiceRequest): + logGroupName: LogGroupName + filterNamePrefix: Optional[FilterName] + nextToken: Optional[NextToken] + limit: Optional[DescribeLimit] + + +class SubscriptionFilter(TypedDict, total=False): + filterName: Optional[FilterName] + logGroupName: Optional[LogGroupName] + filterPattern: Optional[FilterPattern] + destinationArn: Optional[DestinationArn] + roleArn: Optional[RoleArn] + distribution: Optional[Distribution] + applyOnTransformedLogs: Optional[ApplyOnTransformedLogs] + creationTime: Optional[Timestamp] + + +SubscriptionFilters = List[SubscriptionFilter] + + +class DescribeSubscriptionFiltersResponse(TypedDict, total=False): + subscriptionFilters: Optional[SubscriptionFilters] + nextToken: Optional[NextToken] + + +class DisassociateKmsKeyRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + resourceIdentifier: Optional[ResourceIdentifier] + + +EntityAttributes = Dict[EntityAttributesKey, EntityAttributesValue] +EntityKeyAttributes = Dict[EntityKeyAttributesKey, EntityKeyAttributesValue] + + +class Entity(TypedDict, total=False): + keyAttributes: Optional[EntityKeyAttributes] + attributes: Optional[EntityAttributes] + + +EventNumber = int +ExtractedValues = Dict[Token, Value] +InputLogStreamNames = List[LogStreamName] + + +class FilterLogEventsRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + logGroupIdentifier: Optional[LogGroupIdentifier] + logStreamNames: Optional[InputLogStreamNames] + logStreamNamePrefix: Optional[LogStreamName] + startTime: Optional[Timestamp] + endTime: Optional[Timestamp] + filterPattern: Optional[FilterPattern] + nextToken: Optional[NextToken] + limit: Optional[EventsLimit] + interleaved: Optional[Interleaved] + unmask: Optional[Unmask] + + +class SearchedLogStream(TypedDict, total=False): + logStreamName: Optional[LogStreamName] + searchedCompletely: Optional[LogStreamSearchedCompletely] + + +SearchedLogStreams = List[SearchedLogStream] + + +class FilteredLogEvent(TypedDict, total=False): + logStreamName: Optional[LogStreamName] + timestamp: Optional[Timestamp] + message: Optional[EventMessage] + ingestionTime: Optional[Timestamp] + eventId: Optional[EventId] + + +FilteredLogEvents = List[FilteredLogEvent] + + +class FilterLogEventsResponse(TypedDict, total=False): + events: Optional[FilteredLogEvents] + searchedLogStreams: Optional[SearchedLogStreams] + nextToken: Optional[NextToken] + + +class GetDataProtectionPolicyRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + + +class GetDataProtectionPolicyResponse(TypedDict, total=False): + logGroupIdentifier: Optional[LogGroupIdentifier] + policyDocument: Optional[DataProtectionPolicyDocument] + lastUpdatedTime: Optional[Timestamp] + + +class GetDeliveryDestinationPolicyRequest(ServiceRequest): + deliveryDestinationName: DeliveryDestinationName + + +class Policy(TypedDict, total=False): + deliveryDestinationPolicy: Optional[DeliveryDestinationPolicy] + + +class GetDeliveryDestinationPolicyResponse(TypedDict, total=False): + policy: Optional[Policy] + + +class GetDeliveryDestinationRequest(ServiceRequest): + name: DeliveryDestinationName + + +class GetDeliveryDestinationResponse(TypedDict, total=False): + deliveryDestination: Optional[DeliveryDestination] + + +class GetDeliveryRequest(ServiceRequest): + id: DeliveryId + + +class GetDeliveryResponse(TypedDict, total=False): + delivery: Optional[Delivery] + + +class GetDeliverySourceRequest(ServiceRequest): + name: DeliverySourceName + + +class GetDeliverySourceResponse(TypedDict, total=False): + deliverySource: Optional[DeliverySource] + + +class GetIntegrationRequest(ServiceRequest): + integrationName: IntegrationName + + +class OpenSearchResourceStatus(TypedDict, total=False): + status: Optional[OpenSearchResourceStatusType] + statusMessage: Optional[IntegrationStatusMessage] + + +class OpenSearchLifecyclePolicy(TypedDict, total=False): + policyName: Optional[OpenSearchPolicyName] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchDataAccessPolicy(TypedDict, total=False): + policyName: Optional[OpenSearchPolicyName] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchNetworkPolicy(TypedDict, total=False): + policyName: Optional[OpenSearchPolicyName] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchEncryptionPolicy(TypedDict, total=False): + policyName: Optional[OpenSearchPolicyName] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchWorkspace(TypedDict, total=False): + workspaceId: Optional[OpenSearchWorkspaceId] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchCollection(TypedDict, total=False): + collectionEndpoint: Optional[OpenSearchCollectionEndpoint] + collectionArn: Optional[Arn] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchApplication(TypedDict, total=False): + applicationEndpoint: Optional[OpenSearchApplicationEndpoint] + applicationArn: Optional[Arn] + applicationId: Optional[OpenSearchApplicationId] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchDataSource(TypedDict, total=False): + dataSourceName: Optional[OpenSearchDataSourceName] + status: Optional[OpenSearchResourceStatus] + + +class OpenSearchIntegrationDetails(TypedDict, total=False): + dataSource: Optional[OpenSearchDataSource] + application: Optional[OpenSearchApplication] + collection: Optional[OpenSearchCollection] + workspace: Optional[OpenSearchWorkspace] + encryptionPolicy: Optional[OpenSearchEncryptionPolicy] + networkPolicy: Optional[OpenSearchNetworkPolicy] + accessPolicy: Optional[OpenSearchDataAccessPolicy] + lifecyclePolicy: Optional[OpenSearchLifecyclePolicy] + + +class IntegrationDetails(TypedDict, total=False): + openSearchIntegrationDetails: Optional[OpenSearchIntegrationDetails] + + +class GetIntegrationResponse(TypedDict, total=False): + integrationName: Optional[IntegrationName] + integrationType: Optional[IntegrationType] + integrationStatus: Optional[IntegrationStatus] + integrationDetails: Optional[IntegrationDetails] + + +class GetLogAnomalyDetectorRequest(ServiceRequest): + anomalyDetectorArn: AnomalyDetectorArn + + +class GetLogAnomalyDetectorResponse(TypedDict, total=False): + detectorName: Optional[DetectorName] + logGroupArnList: Optional[LogGroupArnList] + evaluationFrequency: Optional[EvaluationFrequency] + filterPattern: Optional[FilterPattern] + anomalyDetectorStatus: Optional[AnomalyDetectorStatus] + kmsKeyId: Optional[KmsKeyId] + creationTimeStamp: Optional[EpochMillis] + lastModifiedTimeStamp: Optional[EpochMillis] + anomalyVisibilityTime: Optional[AnomalyVisibilityTime] + + +class GetLogEventsRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + logGroupIdentifier: Optional[LogGroupIdentifier] + logStreamName: LogStreamName + startTime: Optional[Timestamp] + endTime: Optional[Timestamp] + nextToken: Optional[NextToken] + limit: Optional[EventsLimit] + startFromHead: Optional[StartFromHead] + unmask: Optional[Unmask] + + +class OutputLogEvent(TypedDict, total=False): + timestamp: Optional[Timestamp] + message: Optional[EventMessage] + ingestionTime: Optional[Timestamp] + + +OutputLogEvents = List[OutputLogEvent] + + +class GetLogEventsResponse(TypedDict, total=False): + events: Optional[OutputLogEvents] + nextForwardToken: Optional[NextToken] + nextBackwardToken: Optional[NextToken] + + +class GetLogGroupFieldsRequest(ServiceRequest): + logGroupName: Optional[LogGroupName] + time: Optional[Timestamp] + logGroupIdentifier: Optional[LogGroupIdentifier] + + +class LogGroupField(TypedDict, total=False): + name: Optional[Field] + percent: Optional[Percentage] + + +LogGroupFieldList = List[LogGroupField] + + +class GetLogGroupFieldsResponse(TypedDict, total=False): + logGroupFields: Optional[LogGroupFieldList] + + +class GetLogRecordRequest(ServiceRequest): + logRecordPointer: LogRecordPointer + unmask: Optional[Unmask] + + +LogRecord = Dict[Field, Value] + + +class GetLogRecordResponse(TypedDict, total=False): + logRecord: Optional[LogRecord] + + +class GetQueryResultsRequest(ServiceRequest): + queryId: QueryId + + +class QueryStatistics(TypedDict, total=False): + recordsMatched: Optional[StatsValue] + recordsScanned: Optional[StatsValue] + estimatedRecordsSkipped: Optional[StatsValue] + bytesScanned: Optional[StatsValue] + estimatedBytesSkipped: Optional[StatsValue] + logGroupsScanned: Optional[StatsValue] + + +class ResultField(TypedDict, total=False): + field: Optional[Field] + value: Optional[Value] + + +ResultRows = List[ResultField] +QueryResults = List[ResultRows] + + +class GetQueryResultsResponse(TypedDict, total=False): + queryLanguage: Optional[QueryLanguage] + results: Optional[QueryResults] + statistics: Optional[QueryStatistics] + status: Optional[QueryStatus] + encryptionKey: Optional[EncryptionKey] + + +class GetTransformerRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + + +UpperCaseStringWithKeys = List[WithKey] + + +class UpperCaseString(TypedDict, total=False): + withKeys: UpperCaseStringWithKeys + + +TypeConverterEntry = TypedDict( + "TypeConverterEntry", + { + "key": Key, + "type": Type, + }, + total=False, +) +TypeConverterEntries = List[TypeConverterEntry] + + +class TypeConverter(TypedDict, total=False): + entries: TypeConverterEntries + + +TrimStringWithKeys = List[WithKey] + + +class TrimString(TypedDict, total=False): + withKeys: TrimStringWithKeys + + +SubstituteStringEntry = TypedDict( + "SubstituteStringEntry", + { + "source": Source, + "from": FromKey, + "to": ToKey, + }, + total=False, +) +SubstituteStringEntries = List[SubstituteStringEntry] + + +class SubstituteString(TypedDict, total=False): + entries: SubstituteStringEntries + + +class SplitStringEntry(TypedDict, total=False): + source: Source + delimiter: SplitStringDelimiter + + +SplitStringEntries = List[SplitStringEntry] + + +class SplitString(TypedDict, total=False): + entries: SplitStringEntries + + +class RenameKeyEntry(TypedDict, total=False): + key: Key + renameTo: RenameTo + overwriteIfExists: Optional[OverwriteIfExists] + + +RenameKeyEntries = List[RenameKeyEntry] + + +class RenameKeys(TypedDict, total=False): + entries: RenameKeyEntries + + +class ParseWAF(TypedDict, total=False): + source: Optional[Source] + + +class ParseVPC(TypedDict, total=False): + source: Optional[Source] + + +class ParsePostgres(TypedDict, total=False): + source: Optional[Source] + + +class ParseToOCSF(TypedDict, total=False): + source: Optional[Source] + eventSource: EventSource + ocsfVersion: OCSFVersion + + +class ParseRoute53(TypedDict, total=False): + source: Optional[Source] + + +class ParseKeyValue(TypedDict, total=False): + source: Optional[Source] + destination: Optional[DestinationField] + fieldDelimiter: Optional[ParserFieldDelimiter] + keyValueDelimiter: Optional[KeyValueDelimiter] + keyPrefix: Optional[KeyPrefix] + nonMatchValue: Optional[NonMatchValue] + overwriteIfExists: Optional[OverwriteIfExists] + + +class ParseJSON(TypedDict, total=False): + source: Optional[Source] + destination: Optional[DestinationField] + + +class ParseCloudfront(TypedDict, total=False): + source: Optional[Source] + + +class MoveKeyEntry(TypedDict, total=False): + source: Source + target: Target + overwriteIfExists: Optional[OverwriteIfExists] + + +MoveKeyEntries = List[MoveKeyEntry] + + +class MoveKeys(TypedDict, total=False): + entries: MoveKeyEntries + + +LowerCaseStringWithKeys = List[WithKey] + + +class LowerCaseString(TypedDict, total=False): + withKeys: LowerCaseStringWithKeys + + +class ListToMap(TypedDict, total=False): + source: Source + key: Key + valueKey: Optional[ValueKey] + target: Optional[Target] + flatten: Optional[Flatten] + flattenedElement: Optional[FlattenedElement] + + +class Grok(TypedDict, total=False): + source: Optional[Source] + match: GrokMatch + + +class Processor(TypedDict, total=False): + addKeys: Optional[AddKeys] + copyValue: Optional[CopyValue] + csv: Optional[CSV] + dateTimeConverter: Optional[DateTimeConverter] + deleteKeys: Optional[DeleteKeys] + grok: Optional[Grok] + listToMap: Optional[ListToMap] + lowerCaseString: Optional[LowerCaseString] + moveKeys: Optional[MoveKeys] + parseCloudfront: Optional[ParseCloudfront] + parseJSON: Optional[ParseJSON] + parseKeyValue: Optional[ParseKeyValue] + parseRoute53: Optional[ParseRoute53] + parseToOCSF: Optional[ParseToOCSF] + parsePostgres: Optional[ParsePostgres] + parseVPC: Optional[ParseVPC] + parseWAF: Optional[ParseWAF] + renameKeys: Optional[RenameKeys] + splitString: Optional[SplitString] + substituteString: Optional[SubstituteString] + trimString: Optional[TrimString] + typeConverter: Optional[TypeConverter] + upperCaseString: Optional[UpperCaseString] + + +Processors = List[Processor] + + +class GetTransformerResponse(TypedDict, total=False): + logGroupIdentifier: Optional[LogGroupIdentifier] + creationTime: Optional[Timestamp] + lastModifiedTime: Optional[Timestamp] + transformerConfig: Optional[Processors] + + +class InputLogEvent(TypedDict, total=False): + timestamp: Timestamp + message: EventMessage + + +InputLogEvents = List[InputLogEvent] + + +class IntegrationSummary(TypedDict, total=False): + integrationName: Optional[IntegrationName] + integrationType: Optional[IntegrationType] + integrationStatus: Optional[IntegrationStatus] + + +IntegrationSummaries = List[IntegrationSummary] + + +class ListAnomaliesRequest(ServiceRequest): + anomalyDetectorArn: Optional[AnomalyDetectorArn] + suppressionState: Optional[SuppressionState] + limit: Optional[ListAnomaliesLimit] + nextToken: Optional[NextToken] + + +class ListAnomaliesResponse(TypedDict, total=False): + anomalies: Optional[Anomalies] + nextToken: Optional[NextToken] + + +class ListIntegrationsRequest(ServiceRequest): + integrationNamePrefix: Optional[IntegrationNamePrefix] + integrationType: Optional[IntegrationType] + integrationStatus: Optional[IntegrationStatus] + + +class ListIntegrationsResponse(TypedDict, total=False): + integrationSummaries: Optional[IntegrationSummaries] + + +class ListLogAnomalyDetectorsRequest(ServiceRequest): + filterLogGroupArn: Optional[LogGroupArn] + limit: Optional[ListLogAnomalyDetectorsLimit] + nextToken: Optional[NextToken] + + +class ListLogAnomalyDetectorsResponse(TypedDict, total=False): + anomalyDetectors: Optional[AnomalyDetectors] + nextToken: Optional[NextToken] + + +class ListLogGroupsForQueryRequest(ServiceRequest): + queryId: QueryId + nextToken: Optional[NextToken] + maxResults: Optional[ListLogGroupsForQueryMaxResults] + + +LogGroupIdentifiers = List[LogGroupIdentifier] + + +class ListLogGroupsForQueryResponse(TypedDict, total=False): + logGroupIdentifiers: Optional[LogGroupIdentifiers] + nextToken: Optional[NextToken] + + +class ListLogGroupsRequest(ServiceRequest): + logGroupNamePattern: Optional[LogGroupNameRegexPattern] + logGroupClass: Optional[LogGroupClass] + includeLinkedAccounts: Optional[IncludeLinkedAccounts] + accountIdentifiers: Optional[AccountIds] + nextToken: Optional[NextToken] + limit: Optional[ListLimit] + + +class LogGroupSummary(TypedDict, total=False): + logGroupName: Optional[LogGroupName] + logGroupArn: Optional[Arn] + logGroupClass: Optional[LogGroupClass] + + +LogGroupSummaries = List[LogGroupSummary] + + +class ListLogGroupsResponse(TypedDict, total=False): + logGroups: Optional[LogGroupSummaries] + nextToken: Optional[NextToken] + + +class ListTagsForResourceRequest(ServiceRequest): + resourceArn: AmazonResourceName + + +class ListTagsForResourceResponse(TypedDict, total=False): + tags: Optional[Tags] + + +class ListTagsLogGroupRequest(ServiceRequest): + logGroupName: LogGroupName + + +class ListTagsLogGroupResponse(TypedDict, total=False): + tags: Optional[Tags] + + +class LiveTailSessionLogEvent(TypedDict, total=False): + logStreamName: Optional[LogStreamName] + logGroupIdentifier: Optional[LogGroupIdentifier] + message: Optional[EventMessage] + timestamp: Optional[Timestamp] + ingestionTime: Optional[Timestamp] + + +class LiveTailSessionMetadata(TypedDict, total=False): + sampled: Optional[IsSampled] + + +LiveTailSessionResults = List[LiveTailSessionLogEvent] +StartLiveTailLogGroupIdentifiers = List[LogGroupIdentifier] + + +class LiveTailSessionStart(TypedDict, total=False): + requestId: Optional[RequestId] + sessionId: Optional[SessionId] + logGroupIdentifiers: Optional[StartLiveTailLogGroupIdentifiers] + logStreamNames: Optional[InputLogStreamNames] + logStreamNamePrefixes: Optional[InputLogStreamNames] + logEventFilterPattern: Optional[FilterPattern] + + +class LiveTailSessionUpdate(TypedDict, total=False): + sessionMetadata: Optional[LiveTailSessionMetadata] + sessionResults: Optional[LiveTailSessionResults] + + +class MetricFilterMatchRecord(TypedDict, total=False): + eventNumber: Optional[EventNumber] + eventMessage: Optional[EventMessage] + extractedValues: Optional[ExtractedValues] + + +MetricFilterMatches = List[MetricFilterMatchRecord] + + +class OpenSearchResourceConfig(TypedDict, total=False): + kmsKeyArn: Optional[Arn] + dataSourceRoleArn: Arn + dashboardViewerPrincipals: DashboardViewerPrincipals + applicationArn: Optional[Arn] + retentionDays: CollectionRetentionDays + + +class PutAccountPolicyRequest(ServiceRequest): + policyName: PolicyName + policyDocument: AccountPolicyDocument + policyType: PolicyType + scope: Optional[Scope] + selectionCriteria: Optional[SelectionCriteria] + + +class PutAccountPolicyResponse(TypedDict, total=False): + accountPolicy: Optional[AccountPolicy] + + +class PutDataProtectionPolicyRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + policyDocument: DataProtectionPolicyDocument + + +class PutDataProtectionPolicyResponse(TypedDict, total=False): + logGroupIdentifier: Optional[LogGroupIdentifier] + policyDocument: Optional[DataProtectionPolicyDocument] + lastUpdatedTime: Optional[Timestamp] + + +class PutDeliveryDestinationPolicyRequest(ServiceRequest): + deliveryDestinationName: DeliveryDestinationName + deliveryDestinationPolicy: DeliveryDestinationPolicy + + +class PutDeliveryDestinationPolicyResponse(TypedDict, total=False): + policy: Optional[Policy] + + +class PutDeliveryDestinationRequest(ServiceRequest): + name: DeliveryDestinationName + outputFormat: Optional[OutputFormat] + deliveryDestinationConfiguration: DeliveryDestinationConfiguration + tags: Optional[Tags] + + +class PutDeliveryDestinationResponse(TypedDict, total=False): + deliveryDestination: Optional[DeliveryDestination] + + +class PutDeliverySourceRequest(ServiceRequest): + name: DeliverySourceName + resourceArn: Arn + logType: LogType + tags: Optional[Tags] + + +class PutDeliverySourceResponse(TypedDict, total=False): + deliverySource: Optional[DeliverySource] + + +class PutDestinationPolicyRequest(ServiceRequest): + destinationName: DestinationName + accessPolicy: AccessPolicy + forceUpdate: Optional[ForceUpdate] + + +class PutDestinationRequest(ServiceRequest): + destinationName: DestinationName + targetArn: TargetArn + roleArn: RoleArn + tags: Optional[Tags] + + +class PutDestinationResponse(TypedDict, total=False): + destination: Optional[Destination] + + +class PutIndexPolicyRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + policyDocument: PolicyDocument + + +class PutIndexPolicyResponse(TypedDict, total=False): + indexPolicy: Optional[IndexPolicy] + + +class ResourceConfig(TypedDict, total=False): + openSearchResourceConfig: Optional[OpenSearchResourceConfig] + + +class PutIntegrationRequest(ServiceRequest): + integrationName: IntegrationName + resourceConfig: ResourceConfig + integrationType: IntegrationType + + +class PutIntegrationResponse(TypedDict, total=False): + integrationName: Optional[IntegrationName] + integrationStatus: Optional[IntegrationStatus] + + +class PutLogEventsRequest(ServiceRequest): + logGroupName: LogGroupName + logStreamName: LogStreamName + logEvents: InputLogEvents + sequenceToken: Optional[SequenceToken] + entity: Optional[Entity] + + +class RejectedEntityInfo(TypedDict, total=False): + errorType: EntityRejectionErrorType + + +class RejectedLogEventsInfo(TypedDict, total=False): + tooNewLogEventStartIndex: Optional[LogEventIndex] + tooOldLogEventEndIndex: Optional[LogEventIndex] + expiredLogEventEndIndex: Optional[LogEventIndex] + + +class PutLogEventsResponse(TypedDict, total=False): + nextSequenceToken: Optional[SequenceToken] + rejectedLogEventsInfo: Optional[RejectedLogEventsInfo] + rejectedEntityInfo: Optional[RejectedEntityInfo] + + +class PutMetricFilterRequest(ServiceRequest): + logGroupName: LogGroupName + filterName: FilterName + filterPattern: FilterPattern + metricTransformations: MetricTransformations + applyOnTransformedLogs: Optional[ApplyOnTransformedLogs] + + +class PutQueryDefinitionRequest(ServiceRequest): + queryLanguage: Optional[QueryLanguage] + name: QueryDefinitionName + queryDefinitionId: Optional[QueryId] + logGroupNames: Optional[LogGroupNames] + queryString: QueryDefinitionString + clientToken: Optional[ClientToken] + + +class PutQueryDefinitionResponse(TypedDict, total=False): + queryDefinitionId: Optional[QueryId] + + +class PutResourcePolicyRequest(ServiceRequest): + policyName: Optional[PolicyName] + policyDocument: Optional[PolicyDocument] + + +class PutResourcePolicyResponse(TypedDict, total=False): + resourcePolicy: Optional[ResourcePolicy] + + +class PutRetentionPolicyRequest(ServiceRequest): + logGroupName: LogGroupName + retentionInDays: Days + + +class PutSubscriptionFilterRequest(ServiceRequest): + logGroupName: LogGroupName + filterName: FilterName + filterPattern: FilterPattern + destinationArn: DestinationArn + roleArn: Optional[RoleArn] + distribution: Optional[Distribution] + applyOnTransformedLogs: Optional[ApplyOnTransformedLogs] + + +class PutTransformerRequest(ServiceRequest): + logGroupIdentifier: LogGroupIdentifier + transformerConfig: Processors + + +class StartLiveTailRequest(ServiceRequest): + logGroupIdentifiers: StartLiveTailLogGroupIdentifiers + logStreamNames: Optional[InputLogStreamNames] + logStreamNamePrefixes: Optional[InputLogStreamNames] + logEventFilterPattern: Optional[FilterPattern] + + +class StartLiveTailResponseStream(TypedDict, total=False): + sessionStart: Optional[LiveTailSessionStart] + sessionUpdate: Optional[LiveTailSessionUpdate] + SessionTimeoutException: Optional[SessionTimeoutException] + SessionStreamingException: Optional[SessionStreamingException] + + +class StartLiveTailResponse(TypedDict, total=False): + responseStream: Iterator[StartLiveTailResponseStream] + + +class StartQueryRequest(ServiceRequest): + queryLanguage: Optional[QueryLanguage] + logGroupName: Optional[LogGroupName] + logGroupNames: Optional[LogGroupNames] + logGroupIdentifiers: Optional[LogGroupIdentifiers] + startTime: Timestamp + endTime: Timestamp + queryString: QueryString + limit: Optional[EventsLimit] + + +class StartQueryResponse(TypedDict, total=False): + queryId: Optional[QueryId] + + +class StopQueryRequest(ServiceRequest): + queryId: QueryId + + +class StopQueryResponse(TypedDict, total=False): + success: Optional[Success] + + +class SuppressionPeriod(TypedDict, total=False): + value: Optional[Integer] + suppressionUnit: Optional[SuppressionUnit] + + +TagKeyList = List[TagKey] +TagList = List[TagKey] + + +class TagLogGroupRequest(ServiceRequest): + logGroupName: LogGroupName + tags: Tags + + +class TagResourceRequest(ServiceRequest): + resourceArn: AmazonResourceName + tags: Tags + + +TestEventMessages = List[EventMessage] + + +class TestMetricFilterRequest(ServiceRequest): + filterPattern: FilterPattern + logEventMessages: TestEventMessages + + +class TestMetricFilterResponse(TypedDict, total=False): + matches: Optional[MetricFilterMatches] + + +class TestTransformerRequest(ServiceRequest): + transformerConfig: Processors + logEventMessages: TestEventMessages + + +class TransformedLogRecord(TypedDict, total=False): + eventNumber: Optional[EventNumber] + eventMessage: Optional[EventMessage] + transformedEventMessage: Optional[TransformedEventMessage] + + +TransformedLogs = List[TransformedLogRecord] + + +class TestTransformerResponse(TypedDict, total=False): + transformedLogs: Optional[TransformedLogs] + + +class UntagLogGroupRequest(ServiceRequest): + logGroupName: LogGroupName + tags: TagList + + +class UntagResourceRequest(ServiceRequest): + resourceArn: AmazonResourceName + tagKeys: TagKeyList + + +class UpdateAnomalyRequest(ServiceRequest): + anomalyId: Optional[AnomalyId] + patternId: Optional[PatternId] + anomalyDetectorArn: AnomalyDetectorArn + suppressionType: Optional[SuppressionType] + suppressionPeriod: Optional[SuppressionPeriod] + baseline: Optional[Baseline] + + +class UpdateDeliveryConfigurationRequest(ServiceRequest): + id: DeliveryId + recordFields: Optional[RecordFields] + fieldDelimiter: Optional[FieldDelimiter] + s3DeliveryConfiguration: Optional[S3DeliveryConfiguration] + + +class UpdateDeliveryConfigurationResponse(TypedDict, total=False): + pass + + +class UpdateLogAnomalyDetectorRequest(ServiceRequest): + anomalyDetectorArn: AnomalyDetectorArn + evaluationFrequency: Optional[EvaluationFrequency] + filterPattern: Optional[FilterPattern] + anomalyVisibilityTime: Optional[AnomalyVisibilityTime] + enabled: Boolean + + +class LogsApi: + service = "logs" + version = "2014-03-28" + + @handler("AssociateKmsKey") + def associate_kms_key( + self, + context: RequestContext, + kms_key_id: KmsKeyId, + log_group_name: LogGroupName | None = None, + resource_identifier: ResourceIdentifier | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CancelExportTask") + def cancel_export_task(self, context: RequestContext, task_id: ExportTaskId, **kwargs) -> None: + raise NotImplementedError + + @handler("CreateDelivery") + def create_delivery( + self, + context: RequestContext, + delivery_source_name: DeliverySourceName, + delivery_destination_arn: Arn, + record_fields: RecordFields | None = None, + field_delimiter: FieldDelimiter | None = None, + s3_delivery_configuration: S3DeliveryConfiguration | None = None, + tags: Tags | None = None, + **kwargs, + ) -> CreateDeliveryResponse: + raise NotImplementedError + + @handler("CreateExportTask", expand=False) + def create_export_task( + self, context: RequestContext, request: CreateExportTaskRequest, **kwargs + ) -> CreateExportTaskResponse: + raise NotImplementedError + + @handler("CreateLogAnomalyDetector") + def create_log_anomaly_detector( + self, + context: RequestContext, + log_group_arn_list: LogGroupArnList, + detector_name: DetectorName | None = None, + evaluation_frequency: EvaluationFrequency | None = None, + filter_pattern: FilterPattern | None = None, + kms_key_id: DetectorKmsKeyArn | None = None, + anomaly_visibility_time: AnomalyVisibilityTime | None = None, + tags: Tags | None = None, + **kwargs, + ) -> CreateLogAnomalyDetectorResponse: + raise NotImplementedError + + @handler("CreateLogGroup") + def create_log_group( + self, + context: RequestContext, + log_group_name: LogGroupName, + kms_key_id: KmsKeyId | None = None, + tags: Tags | None = None, + log_group_class: LogGroupClass | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateLogStream") + def create_log_stream( + self, + context: RequestContext, + log_group_name: LogGroupName, + log_stream_name: LogStreamName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccountPolicy") + def delete_account_policy( + self, context: RequestContext, policy_name: PolicyName, policy_type: PolicyType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDataProtectionPolicy") + def delete_data_protection_policy( + self, context: RequestContext, log_group_identifier: LogGroupIdentifier, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDelivery") + def delete_delivery(self, context: RequestContext, id: DeliveryId, **kwargs) -> None: + raise NotImplementedError + + @handler("DeleteDeliveryDestination") + def delete_delivery_destination( + self, context: RequestContext, name: DeliveryDestinationName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDeliveryDestinationPolicy") + def delete_delivery_destination_policy( + self, context: RequestContext, delivery_destination_name: DeliveryDestinationName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDeliverySource") + def delete_delivery_source( + self, context: RequestContext, name: DeliverySourceName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDestination") + def delete_destination( + self, context: RequestContext, destination_name: DestinationName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteIndexPolicy") + def delete_index_policy( + self, context: RequestContext, log_group_identifier: LogGroupIdentifier, **kwargs + ) -> DeleteIndexPolicyResponse: + raise NotImplementedError + + @handler("DeleteIntegration") + def delete_integration( + self, + context: RequestContext, + integration_name: IntegrationName, + force: Force | None = None, + **kwargs, + ) -> DeleteIntegrationResponse: + raise NotImplementedError + + @handler("DeleteLogAnomalyDetector") + def delete_log_anomaly_detector( + self, context: RequestContext, anomaly_detector_arn: AnomalyDetectorArn, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteLogGroup") + def delete_log_group( + self, context: RequestContext, log_group_name: LogGroupName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteLogStream") + def delete_log_stream( + self, + context: RequestContext, + log_group_name: LogGroupName, + log_stream_name: LogStreamName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteMetricFilter") + def delete_metric_filter( + self, + context: RequestContext, + log_group_name: LogGroupName, + filter_name: FilterName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteQueryDefinition") + def delete_query_definition( + self, context: RequestContext, query_definition_id: QueryId, **kwargs + ) -> DeleteQueryDefinitionResponse: + raise NotImplementedError + + @handler("DeleteResourcePolicy") + def delete_resource_policy( + self, context: RequestContext, policy_name: PolicyName | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteRetentionPolicy") + def delete_retention_policy( + self, context: RequestContext, log_group_name: LogGroupName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteSubscriptionFilter") + def delete_subscription_filter( + self, + context: RequestContext, + log_group_name: LogGroupName, + filter_name: FilterName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteTransformer") + def delete_transformer( + self, context: RequestContext, log_group_identifier: LogGroupIdentifier, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DescribeAccountPolicies") + def describe_account_policies( + self, + context: RequestContext, + policy_type: PolicyType, + policy_name: PolicyName | None = None, + account_identifiers: AccountIds | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAccountPoliciesResponse: + raise NotImplementedError + + @handler("DescribeConfigurationTemplates") + def describe_configuration_templates( + self, + context: RequestContext, + service: Service | None = None, + log_types: LogTypes | None = None, + resource_types: ResourceTypes | None = None, + delivery_destination_types: DeliveryDestinationTypes | None = None, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeConfigurationTemplatesResponse: + raise NotImplementedError + + @handler("DescribeDeliveries") + def describe_deliveries( + self, + context: RequestContext, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeDeliveriesResponse: + raise NotImplementedError + + @handler("DescribeDeliveryDestinations") + def describe_delivery_destinations( + self, + context: RequestContext, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeDeliveryDestinationsResponse: + raise NotImplementedError + + @handler("DescribeDeliverySources") + def describe_delivery_sources( + self, + context: RequestContext, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeDeliverySourcesResponse: + raise NotImplementedError + + @handler("DescribeDestinations") + def describe_destinations( + self, + context: RequestContext, + destination_name_prefix: DestinationName | None = None, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeDestinationsResponse: + raise NotImplementedError + + @handler("DescribeExportTasks") + def describe_export_tasks( + self, + context: RequestContext, + task_id: ExportTaskId | None = None, + status_code: ExportTaskStatusCode | None = None, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeExportTasksResponse: + raise NotImplementedError + + @handler("DescribeFieldIndexes") + def describe_field_indexes( + self, + context: RequestContext, + log_group_identifiers: DescribeFieldIndexesLogGroupIdentifiers, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeFieldIndexesResponse: + raise NotImplementedError + + @handler("DescribeIndexPolicies") + def describe_index_policies( + self, + context: RequestContext, + log_group_identifiers: DescribeIndexPoliciesLogGroupIdentifiers, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeIndexPoliciesResponse: + raise NotImplementedError + + @handler("DescribeLogGroups") + def describe_log_groups( + self, + context: RequestContext, + account_identifiers: AccountIds | None = None, + log_group_name_prefix: LogGroupName | None = None, + log_group_name_pattern: LogGroupNamePattern | None = None, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + include_linked_accounts: IncludeLinkedAccounts | None = None, + log_group_class: LogGroupClass | None = None, + log_group_identifiers: DescribeLogGroupsLogGroupIdentifiers | None = None, + **kwargs, + ) -> DescribeLogGroupsResponse: + raise NotImplementedError + + @handler("DescribeLogStreams") + def describe_log_streams( + self, + context: RequestContext, + log_group_name: LogGroupName | None = None, + log_group_identifier: LogGroupIdentifier | None = None, + log_stream_name_prefix: LogStreamName | None = None, + order_by: OrderBy | None = None, + descending: Descending | None = None, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeLogStreamsResponse: + raise NotImplementedError + + @handler("DescribeMetricFilters") + def describe_metric_filters( + self, + context: RequestContext, + log_group_name: LogGroupName | None = None, + filter_name_prefix: FilterName | None = None, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + metric_name: MetricName | None = None, + metric_namespace: MetricNamespace | None = None, + **kwargs, + ) -> DescribeMetricFiltersResponse: + raise NotImplementedError + + @handler("DescribeQueries") + def describe_queries( + self, + context: RequestContext, + log_group_name: LogGroupName | None = None, + status: QueryStatus | None = None, + max_results: DescribeQueriesMaxResults | None = None, + next_token: NextToken | None = None, + query_language: QueryLanguage | None = None, + **kwargs, + ) -> DescribeQueriesResponse: + raise NotImplementedError + + @handler("DescribeQueryDefinitions") + def describe_query_definitions( + self, + context: RequestContext, + query_language: QueryLanguage | None = None, + query_definition_name_prefix: QueryDefinitionName | None = None, + max_results: QueryListMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeQueryDefinitionsResponse: + raise NotImplementedError + + @handler("DescribeResourcePolicies") + def describe_resource_policies( + self, + context: RequestContext, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeResourcePoliciesResponse: + raise NotImplementedError + + @handler("DescribeSubscriptionFilters") + def describe_subscription_filters( + self, + context: RequestContext, + log_group_name: LogGroupName, + filter_name_prefix: FilterName | None = None, + next_token: NextToken | None = None, + limit: DescribeLimit | None = None, + **kwargs, + ) -> DescribeSubscriptionFiltersResponse: + raise NotImplementedError + + @handler("DisassociateKmsKey") + def disassociate_kms_key( + self, + context: RequestContext, + log_group_name: LogGroupName | None = None, + resource_identifier: ResourceIdentifier | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("FilterLogEvents") + def filter_log_events( + self, + context: RequestContext, + log_group_name: LogGroupName | None = None, + log_group_identifier: LogGroupIdentifier | None = None, + log_stream_names: InputLogStreamNames | None = None, + log_stream_name_prefix: LogStreamName | None = None, + start_time: Timestamp | None = None, + end_time: Timestamp | None = None, + filter_pattern: FilterPattern | None = None, + next_token: NextToken | None = None, + limit: EventsLimit | None = None, + interleaved: Interleaved | None = None, + unmask: Unmask | None = None, + **kwargs, + ) -> FilterLogEventsResponse: + raise NotImplementedError + + @handler("GetDataProtectionPolicy") + def get_data_protection_policy( + self, context: RequestContext, log_group_identifier: LogGroupIdentifier, **kwargs + ) -> GetDataProtectionPolicyResponse: + raise NotImplementedError + + @handler("GetDelivery") + def get_delivery( + self, context: RequestContext, id: DeliveryId, **kwargs + ) -> GetDeliveryResponse: + raise NotImplementedError + + @handler("GetDeliveryDestination") + def get_delivery_destination( + self, context: RequestContext, name: DeliveryDestinationName, **kwargs + ) -> GetDeliveryDestinationResponse: + raise NotImplementedError + + @handler("GetDeliveryDestinationPolicy") + def get_delivery_destination_policy( + self, context: RequestContext, delivery_destination_name: DeliveryDestinationName, **kwargs + ) -> GetDeliveryDestinationPolicyResponse: + raise NotImplementedError + + @handler("GetDeliverySource") + def get_delivery_source( + self, context: RequestContext, name: DeliverySourceName, **kwargs + ) -> GetDeliverySourceResponse: + raise NotImplementedError + + @handler("GetIntegration") + def get_integration( + self, context: RequestContext, integration_name: IntegrationName, **kwargs + ) -> GetIntegrationResponse: + raise NotImplementedError + + @handler("GetLogAnomalyDetector") + def get_log_anomaly_detector( + self, context: RequestContext, anomaly_detector_arn: AnomalyDetectorArn, **kwargs + ) -> GetLogAnomalyDetectorResponse: + raise NotImplementedError + + @handler("GetLogEvents") + def get_log_events( + self, + context: RequestContext, + log_stream_name: LogStreamName, + log_group_name: LogGroupName | None = None, + log_group_identifier: LogGroupIdentifier | None = None, + start_time: Timestamp | None = None, + end_time: Timestamp | None = None, + next_token: NextToken | None = None, + limit: EventsLimit | None = None, + start_from_head: StartFromHead | None = None, + unmask: Unmask | None = None, + **kwargs, + ) -> GetLogEventsResponse: + raise NotImplementedError + + @handler("GetLogGroupFields") + def get_log_group_fields( + self, + context: RequestContext, + log_group_name: LogGroupName | None = None, + time: Timestamp | None = None, + log_group_identifier: LogGroupIdentifier | None = None, + **kwargs, + ) -> GetLogGroupFieldsResponse: + raise NotImplementedError + + @handler("GetLogRecord") + def get_log_record( + self, + context: RequestContext, + log_record_pointer: LogRecordPointer, + unmask: Unmask | None = None, + **kwargs, + ) -> GetLogRecordResponse: + raise NotImplementedError + + @handler("GetQueryResults") + def get_query_results( + self, context: RequestContext, query_id: QueryId, **kwargs + ) -> GetQueryResultsResponse: + raise NotImplementedError + + @handler("GetTransformer") + def get_transformer( + self, context: RequestContext, log_group_identifier: LogGroupIdentifier, **kwargs + ) -> GetTransformerResponse: + raise NotImplementedError + + @handler("ListAnomalies") + def list_anomalies( + self, + context: RequestContext, + anomaly_detector_arn: AnomalyDetectorArn | None = None, + suppression_state: SuppressionState | None = None, + limit: ListAnomaliesLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListAnomaliesResponse: + raise NotImplementedError + + @handler("ListIntegrations") + def list_integrations( + self, + context: RequestContext, + integration_name_prefix: IntegrationNamePrefix | None = None, + integration_type: IntegrationType | None = None, + integration_status: IntegrationStatus | None = None, + **kwargs, + ) -> ListIntegrationsResponse: + raise NotImplementedError + + @handler("ListLogAnomalyDetectors") + def list_log_anomaly_detectors( + self, + context: RequestContext, + filter_log_group_arn: LogGroupArn | None = None, + limit: ListLogAnomalyDetectorsLimit | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListLogAnomalyDetectorsResponse: + raise NotImplementedError + + @handler("ListLogGroups") + def list_log_groups( + self, + context: RequestContext, + log_group_name_pattern: LogGroupNameRegexPattern | None = None, + log_group_class: LogGroupClass | None = None, + include_linked_accounts: IncludeLinkedAccounts | None = None, + account_identifiers: AccountIds | None = None, + next_token: NextToken | None = None, + limit: ListLimit | None = None, + **kwargs, + ) -> ListLogGroupsResponse: + raise NotImplementedError + + @handler("ListLogGroupsForQuery") + def list_log_groups_for_query( + self, + context: RequestContext, + query_id: QueryId, + next_token: NextToken | None = None, + max_results: ListLogGroupsForQueryMaxResults | None = None, + **kwargs, + ) -> ListLogGroupsForQueryResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("ListTagsLogGroup") + def list_tags_log_group( + self, context: RequestContext, log_group_name: LogGroupName, **kwargs + ) -> ListTagsLogGroupResponse: + raise NotImplementedError + + @handler("PutAccountPolicy") + def put_account_policy( + self, + context: RequestContext, + policy_name: PolicyName, + policy_document: AccountPolicyDocument, + policy_type: PolicyType, + scope: Scope | None = None, + selection_criteria: SelectionCriteria | None = None, + **kwargs, + ) -> PutAccountPolicyResponse: + raise NotImplementedError + + @handler("PutDataProtectionPolicy") + def put_data_protection_policy( + self, + context: RequestContext, + log_group_identifier: LogGroupIdentifier, + policy_document: DataProtectionPolicyDocument, + **kwargs, + ) -> PutDataProtectionPolicyResponse: + raise NotImplementedError + + @handler("PutDeliveryDestination") + def put_delivery_destination( + self, + context: RequestContext, + name: DeliveryDestinationName, + delivery_destination_configuration: DeliveryDestinationConfiguration, + output_format: OutputFormat | None = None, + tags: Tags | None = None, + **kwargs, + ) -> PutDeliveryDestinationResponse: + raise NotImplementedError + + @handler("PutDeliveryDestinationPolicy") + def put_delivery_destination_policy( + self, + context: RequestContext, + delivery_destination_name: DeliveryDestinationName, + delivery_destination_policy: DeliveryDestinationPolicy, + **kwargs, + ) -> PutDeliveryDestinationPolicyResponse: + raise NotImplementedError + + @handler("PutDeliverySource") + def put_delivery_source( + self, + context: RequestContext, + name: DeliverySourceName, + resource_arn: Arn, + log_type: LogType, + tags: Tags | None = None, + **kwargs, + ) -> PutDeliverySourceResponse: + raise NotImplementedError + + @handler("PutDestination") + def put_destination( + self, + context: RequestContext, + destination_name: DestinationName, + target_arn: TargetArn, + role_arn: RoleArn, + tags: Tags | None = None, + **kwargs, + ) -> PutDestinationResponse: + raise NotImplementedError + + @handler("PutDestinationPolicy") + def put_destination_policy( + self, + context: RequestContext, + destination_name: DestinationName, + access_policy: AccessPolicy, + force_update: ForceUpdate | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutIndexPolicy") + def put_index_policy( + self, + context: RequestContext, + log_group_identifier: LogGroupIdentifier, + policy_document: PolicyDocument, + **kwargs, + ) -> PutIndexPolicyResponse: + raise NotImplementedError + + @handler("PutIntegration") + def put_integration( + self, + context: RequestContext, + integration_name: IntegrationName, + resource_config: ResourceConfig, + integration_type: IntegrationType, + **kwargs, + ) -> PutIntegrationResponse: + raise NotImplementedError + + @handler("PutLogEvents") + def put_log_events( + self, + context: RequestContext, + log_group_name: LogGroupName, + log_stream_name: LogStreamName, + log_events: InputLogEvents, + sequence_token: SequenceToken | None = None, + entity: Entity | None = None, + **kwargs, + ) -> PutLogEventsResponse: + raise NotImplementedError + + @handler("PutMetricFilter") + def put_metric_filter( + self, + context: RequestContext, + log_group_name: LogGroupName, + filter_name: FilterName, + filter_pattern: FilterPattern, + metric_transformations: MetricTransformations, + apply_on_transformed_logs: ApplyOnTransformedLogs | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutQueryDefinition") + def put_query_definition( + self, + context: RequestContext, + name: QueryDefinitionName, + query_string: QueryDefinitionString, + query_language: QueryLanguage | None = None, + query_definition_id: QueryId | None = None, + log_group_names: LogGroupNames | None = None, + client_token: ClientToken | None = None, + **kwargs, + ) -> PutQueryDefinitionResponse: + raise NotImplementedError + + @handler("PutResourcePolicy") + def put_resource_policy( + self, + context: RequestContext, + policy_name: PolicyName | None = None, + policy_document: PolicyDocument | None = None, + **kwargs, + ) -> PutResourcePolicyResponse: + raise NotImplementedError + + @handler("PutRetentionPolicy") + def put_retention_policy( + self, + context: RequestContext, + log_group_name: LogGroupName, + retention_in_days: Days, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutSubscriptionFilter") + def put_subscription_filter( + self, + context: RequestContext, + log_group_name: LogGroupName, + filter_name: FilterName, + filter_pattern: FilterPattern, + destination_arn: DestinationArn, + role_arn: RoleArn | None = None, + distribution: Distribution | None = None, + apply_on_transformed_logs: ApplyOnTransformedLogs | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutTransformer") + def put_transformer( + self, + context: RequestContext, + log_group_identifier: LogGroupIdentifier, + transformer_config: Processors, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("StartLiveTail") + def start_live_tail( + self, + context: RequestContext, + log_group_identifiers: StartLiveTailLogGroupIdentifiers, + log_stream_names: InputLogStreamNames | None = None, + log_stream_name_prefixes: InputLogStreamNames | None = None, + log_event_filter_pattern: FilterPattern | None = None, + **kwargs, + ) -> StartLiveTailResponse: + raise NotImplementedError + + @handler("StartQuery") + def start_query( + self, + context: RequestContext, + start_time: Timestamp, + end_time: Timestamp, + query_string: QueryString, + query_language: QueryLanguage | None = None, + log_group_name: LogGroupName | None = None, + log_group_names: LogGroupNames | None = None, + log_group_identifiers: LogGroupIdentifiers | None = None, + limit: EventsLimit | None = None, + **kwargs, + ) -> StartQueryResponse: + raise NotImplementedError + + @handler("StopQuery") + def stop_query(self, context: RequestContext, query_id: QueryId, **kwargs) -> StopQueryResponse: + raise NotImplementedError + + @handler("TagLogGroup") + def tag_log_group( + self, context: RequestContext, log_group_name: LogGroupName, tags: Tags, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: Tags, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TestMetricFilter") + def test_metric_filter( + self, + context: RequestContext, + filter_pattern: FilterPattern, + log_event_messages: TestEventMessages, + **kwargs, + ) -> TestMetricFilterResponse: + raise NotImplementedError + + @handler("TestTransformer") + def test_transformer( + self, + context: RequestContext, + transformer_config: Processors, + log_event_messages: TestEventMessages, + **kwargs, + ) -> TestTransformerResponse: + raise NotImplementedError + + @handler("UntagLogGroup") + def untag_log_group( + self, context: RequestContext, log_group_name: LogGroupName, tags: TagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateAnomaly") + def update_anomaly( + self, + context: RequestContext, + anomaly_detector_arn: AnomalyDetectorArn, + anomaly_id: AnomalyId | None = None, + pattern_id: PatternId | None = None, + suppression_type: SuppressionType | None = None, + suppression_period: SuppressionPeriod | None = None, + baseline: Baseline | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateDeliveryConfiguration") + def update_delivery_configuration( + self, + context: RequestContext, + id: DeliveryId, + record_fields: RecordFields | None = None, + field_delimiter: FieldDelimiter | None = None, + s3_delivery_configuration: S3DeliveryConfiguration | None = None, + **kwargs, + ) -> UpdateDeliveryConfigurationResponse: + raise NotImplementedError + + @handler("UpdateLogAnomalyDetector") + def update_log_anomaly_detector( + self, + context: RequestContext, + anomaly_detector_arn: AnomalyDetectorArn, + enabled: Boolean, + evaluation_frequency: EvaluationFrequency | None = None, + filter_pattern: FilterPattern | None = None, + anomaly_visibility_time: AnomalyVisibilityTime | None = None, + **kwargs, + ) -> None: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/opensearch/__init__.py b/localstack-core/localstack/aws/api/opensearch/__init__.py new file mode 100644 index 0000000000000..73c9074d0a619 --- /dev/null +++ b/localstack-core/localstack/aws/api/opensearch/__init__.py @@ -0,0 +1,3338 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ARN = str +AWSAccount = str +AppConfigValue = str +ApplicationName = str +AvailabilityZone = str +BackendRole = str +Boolean = bool +ChangeProgressStageName = str +ChangeProgressStageStatus = str +ClientToken = str +CloudWatchLogsLogGroupArn = str +CommitMessage = str +ConnectionAlias = str +ConnectionId = str +ConnectionStatusMessage = str +DataSourceDescription = str +DataSourceName = str +DeploymentType = str +DescribePackagesFilterValue = str +Description = str +DirectQueryDataSourceDescription = str +DirectQueryDataSourceName = str +DirectQueryDataSourceRoleArn = str +DomainArn = str +DomainId = str +DomainName = str +DomainNameFqdn = str +Double = float +DryRun = bool +Endpoint = str +EngineVersion = str +ErrorMessage = str +ErrorType = str +GUID = str +HostedZoneId = str +Id = str +IdentityCenterApplicationARN = str +IdentityCenterInstanceARN = str +IdentityPoolId = str +IdentityStoreId = str +InstanceCount = int +InstanceRole = str +InstanceTypeString = str +Integer = int +IntegerClass = int +Issue = str +KmsKeyId = str +LicenseFilepath = str +LimitName = str +LimitValue = str +MaintenanceStatusMessage = str +MaxResults = int +MaximumInstanceCount = int +Message = str +MinimumInstanceCount = int +NextToken = str +NodeId = str +NonEmptyString = str +NumberOfAZs = str +NumberOfNodes = str +NumberOfShards = str +OwnerId = str +PackageDescription = str +PackageID = str +PackageName = str +PackageOwner = str +PackageUser = str +PackageVersion = str +Password = str +PluginClassName = str +PluginDescription = str +PluginName = str +PluginVersion = str +PolicyDocument = str +ReferencePath = str +Region = str +RequestId = str +ReservationToken = str +RoleArn = str +RolesKey = str +S3BucketName = str +S3Key = str +SAMLEntityId = str +SAMLMetadata = str +ScheduledAutoTuneDescription = str +ServiceUrl = str +StorageSubTypeName = str +StorageTypeName = str +String = str +SubjectKey = str +TagKey = str +TagValue = str +TotalNumberOfStages = int +UIntValue = int +UpgradeName = str +UserPoolId = str +Username = str +VersionString = str +VolumeSize = str +VpcEndpointId = str + + +class AWSServicePrincipal(StrEnum): + application_opensearchservice_amazonaws_com = "application.opensearchservice.amazonaws.com" + + +class ActionSeverity(StrEnum): + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + + +class ActionStatus(StrEnum): + PENDING_UPDATE = "PENDING_UPDATE" + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + COMPLETED = "COMPLETED" + NOT_ELIGIBLE = "NOT_ELIGIBLE" + ELIGIBLE = "ELIGIBLE" + + +class ActionType(StrEnum): + SERVICE_SOFTWARE_UPDATE = "SERVICE_SOFTWARE_UPDATE" + JVM_HEAP_SIZE_TUNING = "JVM_HEAP_SIZE_TUNING" + JVM_YOUNG_GEN_TUNING = "JVM_YOUNG_GEN_TUNING" + + +class AppConfigType(StrEnum): + opensearchDashboards_dashboardAdmin_users = "opensearchDashboards.dashboardAdmin.users" + opensearchDashboards_dashboardAdmin_groups = "opensearchDashboards.dashboardAdmin.groups" + + +class ApplicationStatus(StrEnum): + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + ACTIVE = "ACTIVE" + FAILED = "FAILED" + + +class AutoTuneDesiredState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class AutoTuneState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + ENABLE_IN_PROGRESS = "ENABLE_IN_PROGRESS" + DISABLE_IN_PROGRESS = "DISABLE_IN_PROGRESS" + DISABLED_AND_ROLLBACK_SCHEDULED = "DISABLED_AND_ROLLBACK_SCHEDULED" + DISABLED_AND_ROLLBACK_IN_PROGRESS = "DISABLED_AND_ROLLBACK_IN_PROGRESS" + DISABLED_AND_ROLLBACK_COMPLETE = "DISABLED_AND_ROLLBACK_COMPLETE" + DISABLED_AND_ROLLBACK_ERROR = "DISABLED_AND_ROLLBACK_ERROR" + ERROR = "ERROR" + + +class AutoTuneType(StrEnum): + SCHEDULED_ACTION = "SCHEDULED_ACTION" + + +class ConfigChangeStatus(StrEnum): + Pending = "Pending" + Initializing = "Initializing" + Validating = "Validating" + ValidationFailed = "ValidationFailed" + ApplyingChanges = "ApplyingChanges" + Completed = "Completed" + PendingUserInput = "PendingUserInput" + Cancelled = "Cancelled" + + +class ConnectionMode(StrEnum): + DIRECT = "DIRECT" + VPC_ENDPOINT = "VPC_ENDPOINT" + + +class DataSourceStatus(StrEnum): + ACTIVE = "ACTIVE" + DISABLED = "DISABLED" + + +class DeploymentStatus(StrEnum): + PENDING_UPDATE = "PENDING_UPDATE" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + NOT_ELIGIBLE = "NOT_ELIGIBLE" + ELIGIBLE = "ELIGIBLE" + + +class DescribePackagesFilterName(StrEnum): + PackageID = "PackageID" + PackageName = "PackageName" + PackageStatus = "PackageStatus" + PackageType = "PackageType" + EngineVersion = "EngineVersion" + PackageOwner = "PackageOwner" + + +class DomainHealth(StrEnum): + Red = "Red" + Yellow = "Yellow" + Green = "Green" + NotAvailable = "NotAvailable" + + +class DomainPackageStatus(StrEnum): + ASSOCIATING = "ASSOCIATING" + ASSOCIATION_FAILED = "ASSOCIATION_FAILED" + ACTIVE = "ACTIVE" + DISSOCIATING = "DISSOCIATING" + DISSOCIATION_FAILED = "DISSOCIATION_FAILED" + + +class DomainProcessingStatusType(StrEnum): + Creating = "Creating" + Active = "Active" + Modifying = "Modifying" + UpgradingEngineVersion = "UpgradingEngineVersion" + UpdatingServiceSoftware = "UpdatingServiceSoftware" + Isolated = "Isolated" + Deleting = "Deleting" + + +class DomainState(StrEnum): + Active = "Active" + Processing = "Processing" + NotAvailable = "NotAvailable" + + +class DryRunMode(StrEnum): + Basic = "Basic" + Verbose = "Verbose" + + +class EngineType(StrEnum): + OpenSearch = "OpenSearch" + Elasticsearch = "Elasticsearch" + + +class IPAddressType(StrEnum): + ipv4 = "ipv4" + dualstack = "dualstack" + + +class InboundConnectionStatusCode(StrEnum): + PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE" + APPROVED = "APPROVED" + PROVISIONING = "PROVISIONING" + ACTIVE = "ACTIVE" + REJECTING = "REJECTING" + REJECTED = "REJECTED" + DELETING = "DELETING" + DELETED = "DELETED" + + +class InitiatedBy(StrEnum): + CUSTOMER = "CUSTOMER" + SERVICE = "SERVICE" + + +class LogType(StrEnum): + INDEX_SLOW_LOGS = "INDEX_SLOW_LOGS" + SEARCH_SLOW_LOGS = "SEARCH_SLOW_LOGS" + ES_APPLICATION_LOGS = "ES_APPLICATION_LOGS" + AUDIT_LOGS = "AUDIT_LOGS" + + +class MaintenanceStatus(StrEnum): + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + TIMED_OUT = "TIMED_OUT" + + +class MaintenanceType(StrEnum): + REBOOT_NODE = "REBOOT_NODE" + RESTART_SEARCH_PROCESS = "RESTART_SEARCH_PROCESS" + RESTART_DASHBOARD = "RESTART_DASHBOARD" + + +class MasterNodeStatus(StrEnum): + Available = "Available" + UnAvailable = "UnAvailable" + + +class NaturalLanguageQueryGenerationCurrentState(StrEnum): + NOT_ENABLED = "NOT_ENABLED" + ENABLE_COMPLETE = "ENABLE_COMPLETE" + ENABLE_IN_PROGRESS = "ENABLE_IN_PROGRESS" + ENABLE_FAILED = "ENABLE_FAILED" + DISABLE_COMPLETE = "DISABLE_COMPLETE" + DISABLE_IN_PROGRESS = "DISABLE_IN_PROGRESS" + DISABLE_FAILED = "DISABLE_FAILED" + + +class NaturalLanguageQueryGenerationDesiredState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class NodeOptionsNodeType(StrEnum): + coordinator = "coordinator" + + +class NodeStatus(StrEnum): + Active = "Active" + StandBy = "StandBy" + NotAvailable = "NotAvailable" + + +class NodeType(StrEnum): + Data = "Data" + Ultrawarm = "Ultrawarm" + Master = "Master" + + +class OpenSearchPartitionInstanceType(StrEnum): + m3_medium_search = "m3.medium.search" + m3_large_search = "m3.large.search" + m3_xlarge_search = "m3.xlarge.search" + m3_2xlarge_search = "m3.2xlarge.search" + m4_large_search = "m4.large.search" + m4_xlarge_search = "m4.xlarge.search" + m4_2xlarge_search = "m4.2xlarge.search" + m4_4xlarge_search = "m4.4xlarge.search" + m4_10xlarge_search = "m4.10xlarge.search" + m5_large_search = "m5.large.search" + m5_xlarge_search = "m5.xlarge.search" + m5_2xlarge_search = "m5.2xlarge.search" + m5_4xlarge_search = "m5.4xlarge.search" + m5_12xlarge_search = "m5.12xlarge.search" + m5_24xlarge_search = "m5.24xlarge.search" + r5_large_search = "r5.large.search" + r5_xlarge_search = "r5.xlarge.search" + r5_2xlarge_search = "r5.2xlarge.search" + r5_4xlarge_search = "r5.4xlarge.search" + r5_12xlarge_search = "r5.12xlarge.search" + r5_24xlarge_search = "r5.24xlarge.search" + c5_large_search = "c5.large.search" + c5_xlarge_search = "c5.xlarge.search" + c5_2xlarge_search = "c5.2xlarge.search" + c5_4xlarge_search = "c5.4xlarge.search" + c5_9xlarge_search = "c5.9xlarge.search" + c5_18xlarge_search = "c5.18xlarge.search" + t3_nano_search = "t3.nano.search" + t3_micro_search = "t3.micro.search" + t3_small_search = "t3.small.search" + t3_medium_search = "t3.medium.search" + t3_large_search = "t3.large.search" + t3_xlarge_search = "t3.xlarge.search" + t3_2xlarge_search = "t3.2xlarge.search" + or1_medium_search = "or1.medium.search" + or1_large_search = "or1.large.search" + or1_xlarge_search = "or1.xlarge.search" + or1_2xlarge_search = "or1.2xlarge.search" + or1_4xlarge_search = "or1.4xlarge.search" + or1_8xlarge_search = "or1.8xlarge.search" + or1_12xlarge_search = "or1.12xlarge.search" + or1_16xlarge_search = "or1.16xlarge.search" + ultrawarm1_medium_search = "ultrawarm1.medium.search" + ultrawarm1_large_search = "ultrawarm1.large.search" + ultrawarm1_xlarge_search = "ultrawarm1.xlarge.search" + t2_micro_search = "t2.micro.search" + t2_small_search = "t2.small.search" + t2_medium_search = "t2.medium.search" + r3_large_search = "r3.large.search" + r3_xlarge_search = "r3.xlarge.search" + r3_2xlarge_search = "r3.2xlarge.search" + r3_4xlarge_search = "r3.4xlarge.search" + r3_8xlarge_search = "r3.8xlarge.search" + i2_xlarge_search = "i2.xlarge.search" + i2_2xlarge_search = "i2.2xlarge.search" + d2_xlarge_search = "d2.xlarge.search" + d2_2xlarge_search = "d2.2xlarge.search" + d2_4xlarge_search = "d2.4xlarge.search" + d2_8xlarge_search = "d2.8xlarge.search" + c4_large_search = "c4.large.search" + c4_xlarge_search = "c4.xlarge.search" + c4_2xlarge_search = "c4.2xlarge.search" + c4_4xlarge_search = "c4.4xlarge.search" + c4_8xlarge_search = "c4.8xlarge.search" + r4_large_search = "r4.large.search" + r4_xlarge_search = "r4.xlarge.search" + r4_2xlarge_search = "r4.2xlarge.search" + r4_4xlarge_search = "r4.4xlarge.search" + r4_8xlarge_search = "r4.8xlarge.search" + r4_16xlarge_search = "r4.16xlarge.search" + i3_large_search = "i3.large.search" + i3_xlarge_search = "i3.xlarge.search" + i3_2xlarge_search = "i3.2xlarge.search" + i3_4xlarge_search = "i3.4xlarge.search" + i3_8xlarge_search = "i3.8xlarge.search" + i3_16xlarge_search = "i3.16xlarge.search" + r6g_large_search = "r6g.large.search" + r6g_xlarge_search = "r6g.xlarge.search" + r6g_2xlarge_search = "r6g.2xlarge.search" + r6g_4xlarge_search = "r6g.4xlarge.search" + r6g_8xlarge_search = "r6g.8xlarge.search" + r6g_12xlarge_search = "r6g.12xlarge.search" + m6g_large_search = "m6g.large.search" + m6g_xlarge_search = "m6g.xlarge.search" + m6g_2xlarge_search = "m6g.2xlarge.search" + m6g_4xlarge_search = "m6g.4xlarge.search" + m6g_8xlarge_search = "m6g.8xlarge.search" + m6g_12xlarge_search = "m6g.12xlarge.search" + c6g_large_search = "c6g.large.search" + c6g_xlarge_search = "c6g.xlarge.search" + c6g_2xlarge_search = "c6g.2xlarge.search" + c6g_4xlarge_search = "c6g.4xlarge.search" + c6g_8xlarge_search = "c6g.8xlarge.search" + c6g_12xlarge_search = "c6g.12xlarge.search" + r6gd_large_search = "r6gd.large.search" + r6gd_xlarge_search = "r6gd.xlarge.search" + r6gd_2xlarge_search = "r6gd.2xlarge.search" + r6gd_4xlarge_search = "r6gd.4xlarge.search" + r6gd_8xlarge_search = "r6gd.8xlarge.search" + r6gd_12xlarge_search = "r6gd.12xlarge.search" + r6gd_16xlarge_search = "r6gd.16xlarge.search" + t4g_small_search = "t4g.small.search" + t4g_medium_search = "t4g.medium.search" + + +class OpenSearchWarmPartitionInstanceType(StrEnum): + ultrawarm1_medium_search = "ultrawarm1.medium.search" + ultrawarm1_large_search = "ultrawarm1.large.search" + ultrawarm1_xlarge_search = "ultrawarm1.xlarge.search" + + +class OptionState(StrEnum): + RequiresIndexDocuments = "RequiresIndexDocuments" + Processing = "Processing" + Active = "Active" + + +class OutboundConnectionStatusCode(StrEnum): + VALIDATING = "VALIDATING" + VALIDATION_FAILED = "VALIDATION_FAILED" + PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE" + APPROVED = "APPROVED" + PROVISIONING = "PROVISIONING" + ACTIVE = "ACTIVE" + REJECTING = "REJECTING" + REJECTED = "REJECTED" + DELETING = "DELETING" + DELETED = "DELETED" + + +class OverallChangeStatus(StrEnum): + PENDING = "PENDING" + PROCESSING = "PROCESSING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +class PackageScopeOperationEnum(StrEnum): + ADD = "ADD" + OVERRIDE = "OVERRIDE" + REMOVE = "REMOVE" + + +class PackageStatus(StrEnum): + COPYING = "COPYING" + COPY_FAILED = "COPY_FAILED" + VALIDATING = "VALIDATING" + VALIDATION_FAILED = "VALIDATION_FAILED" + AVAILABLE = "AVAILABLE" + DELETING = "DELETING" + DELETED = "DELETED" + DELETE_FAILED = "DELETE_FAILED" + + +class PackageType(StrEnum): + TXT_DICTIONARY = "TXT-DICTIONARY" + ZIP_PLUGIN = "ZIP-PLUGIN" + PACKAGE_LICENSE = "PACKAGE-LICENSE" + PACKAGE_CONFIG = "PACKAGE-CONFIG" + + +class PrincipalType(StrEnum): + AWS_ACCOUNT = "AWS_ACCOUNT" + AWS_SERVICE = "AWS_SERVICE" + + +class PropertyValueType(StrEnum): + PLAIN_TEXT = "PLAIN_TEXT" + STRINGIFIED_JSON = "STRINGIFIED_JSON" + + +class RequirementLevel(StrEnum): + REQUIRED = "REQUIRED" + OPTIONAL = "OPTIONAL" + NONE = "NONE" + + +class ReservedInstancePaymentOption(StrEnum): + ALL_UPFRONT = "ALL_UPFRONT" + PARTIAL_UPFRONT = "PARTIAL_UPFRONT" + NO_UPFRONT = "NO_UPFRONT" + + +class RolesKeyIdCOption(StrEnum): + GroupName = "GroupName" + GroupId = "GroupId" + + +class RollbackOnDisable(StrEnum): + NO_ROLLBACK = "NO_ROLLBACK" + DEFAULT_ROLLBACK = "DEFAULT_ROLLBACK" + + +class ScheduleAt(StrEnum): + NOW = "NOW" + TIMESTAMP = "TIMESTAMP" + OFF_PEAK_WINDOW = "OFF_PEAK_WINDOW" + + +class ScheduledAutoTuneActionType(StrEnum): + JVM_HEAP_SIZE_TUNING = "JVM_HEAP_SIZE_TUNING" + JVM_YOUNG_GEN_TUNING = "JVM_YOUNG_GEN_TUNING" + + +class ScheduledAutoTuneSeverityType(StrEnum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + + +class ScheduledBy(StrEnum): + CUSTOMER = "CUSTOMER" + SYSTEM = "SYSTEM" + + +class SkipUnavailableStatus(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class SubjectKeyIdCOption(StrEnum): + UserName = "UserName" + UserId = "UserId" + Email = "Email" + + +class TLSSecurityPolicy(StrEnum): + Policy_Min_TLS_1_0_2019_07 = "Policy-Min-TLS-1-0-2019-07" + Policy_Min_TLS_1_2_2019_07 = "Policy-Min-TLS-1-2-2019-07" + Policy_Min_TLS_1_2_PFS_2023_10 = "Policy-Min-TLS-1-2-PFS-2023-10" + + +class TimeUnit(StrEnum): + HOURS = "HOURS" + + +class UpgradeStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + SUCCEEDED = "SUCCEEDED" + SUCCEEDED_WITH_ISSUES = "SUCCEEDED_WITH_ISSUES" + FAILED = "FAILED" + + +class UpgradeStep(StrEnum): + PRE_UPGRADE_CHECK = "PRE_UPGRADE_CHECK" + SNAPSHOT = "SNAPSHOT" + UPGRADE = "UPGRADE" + + +class VolumeType(StrEnum): + standard = "standard" + gp2 = "gp2" + io1 = "io1" + gp3 = "gp3" + + +class VpcEndpointErrorCode(StrEnum): + ENDPOINT_NOT_FOUND = "ENDPOINT_NOT_FOUND" + SERVER_ERROR = "SERVER_ERROR" + + +class VpcEndpointStatus(StrEnum): + CREATING = "CREATING" + CREATE_FAILED = "CREATE_FAILED" + ACTIVE = "ACTIVE" + UPDATING = "UPDATING" + UPDATE_FAILED = "UPDATE_FAILED" + DELETING = "DELETING" + DELETE_FAILED = "DELETE_FAILED" + + +class ZoneStatus(StrEnum): + Active = "Active" + StandBy = "StandBy" + NotAvailable = "NotAvailable" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 403 + + +class BaseException(ServiceException): + code: str = "BaseException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 409 + + +class DependencyFailureException(ServiceException): + code: str = "DependencyFailureException" + sender_fault: bool = False + status_code: int = 424 + + +class DisabledOperationException(ServiceException): + code: str = "DisabledOperationException" + sender_fault: bool = False + status_code: int = 409 + + +class InternalException(ServiceException): + code: str = "InternalException" + sender_fault: bool = False + status_code: int = 500 + + +class InvalidPaginationTokenException(ServiceException): + code: str = "InvalidPaginationTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTypeException(ServiceException): + code: str = "InvalidTypeException" + sender_fault: bool = False + status_code: int = 409 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 409 + + +class ResourceAlreadyExistsException(ServiceException): + code: str = "ResourceAlreadyExistsException" + sender_fault: bool = False + status_code: int = 409 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 409 + + +Long = int +SlotList = List[Long] + + +class SlotNotAvailableException(ServiceException): + code: str = "SlotNotAvailableException" + sender_fault: bool = False + status_code: int = 409 + SlotSuggestions: Optional[SlotList] + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + + +class NaturalLanguageQueryGenerationOptionsInput(TypedDict, total=False): + DesiredState: Optional[NaturalLanguageQueryGenerationDesiredState] + + +class AIMLOptionsInput(TypedDict, total=False): + NaturalLanguageQueryGenerationOptions: Optional[NaturalLanguageQueryGenerationOptionsInput] + + +class NaturalLanguageQueryGenerationOptionsOutput(TypedDict, total=False): + DesiredState: Optional[NaturalLanguageQueryGenerationDesiredState] + CurrentState: Optional[NaturalLanguageQueryGenerationCurrentState] + + +class AIMLOptionsOutput(TypedDict, total=False): + NaturalLanguageQueryGenerationOptions: Optional[NaturalLanguageQueryGenerationOptionsOutput] + + +UpdateTimestamp = datetime + + +class OptionStatus(TypedDict, total=False): + CreationDate: UpdateTimestamp + UpdateDate: UpdateTimestamp + UpdateVersion: Optional[UIntValue] + State: OptionState + PendingDeletion: Optional[Boolean] + + +class AIMLOptionsStatus(TypedDict, total=False): + Options: Optional[AIMLOptionsOutput] + Status: Optional[OptionStatus] + + +class AWSDomainInformation(TypedDict, total=False): + OwnerId: Optional[OwnerId] + DomainName: DomainName + Region: Optional[Region] + + +class AcceptInboundConnectionRequest(ServiceRequest): + ConnectionId: ConnectionId + + +class InboundConnectionStatus(TypedDict, total=False): + StatusCode: Optional[InboundConnectionStatusCode] + Message: Optional[ConnectionStatusMessage] + + +class DomainInformationContainer(TypedDict, total=False): + AWSDomainInformation: Optional[AWSDomainInformation] + + +class InboundConnection(TypedDict, total=False): + LocalDomainInfo: Optional[DomainInformationContainer] + RemoteDomainInfo: Optional[DomainInformationContainer] + ConnectionId: Optional[ConnectionId] + ConnectionStatus: Optional[InboundConnectionStatus] + ConnectionMode: Optional[ConnectionMode] + + +class AcceptInboundConnectionResponse(TypedDict, total=False): + Connection: Optional[InboundConnection] + + +class AccessPoliciesStatus(TypedDict, total=False): + Options: PolicyDocument + Status: OptionStatus + + +class S3GlueDataCatalog(TypedDict, total=False): + RoleArn: Optional[RoleArn] + + +class DataSourceType(TypedDict, total=False): + S3GlueDataCatalog: Optional[S3GlueDataCatalog] + + +class AddDataSourceRequest(ServiceRequest): + DomainName: DomainName + Name: DataSourceName + DataSourceType: DataSourceType + Description: Optional[DataSourceDescription] + + +class AddDataSourceResponse(TypedDict, total=False): + Message: Optional[String] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] +DirectQueryOpenSearchARNList = List[ARN] + + +class SecurityLakeDirectQueryDataSource(TypedDict, total=False): + RoleArn: DirectQueryDataSourceRoleArn + + +class CloudWatchDirectQueryDataSource(TypedDict, total=False): + RoleArn: DirectQueryDataSourceRoleArn + + +class DirectQueryDataSourceType(TypedDict, total=False): + CloudWatchLog: Optional[CloudWatchDirectQueryDataSource] + SecurityLake: Optional[SecurityLakeDirectQueryDataSource] + + +class AddDirectQueryDataSourceRequest(ServiceRequest): + DataSourceName: DirectQueryDataSourceName + DataSourceType: DirectQueryDataSourceType + Description: Optional[DirectQueryDataSourceDescription] + OpenSearchArns: DirectQueryOpenSearchARNList + TagList: Optional[TagList] + + +class AddDirectQueryDataSourceResponse(TypedDict, total=False): + DataSourceArn: Optional[String] + + +class AddTagsRequest(ServiceRequest): + ARN: ARN + TagList: TagList + + +LimitValueList = List[LimitValue] + + +class AdditionalLimit(TypedDict, total=False): + LimitName: Optional[LimitName] + LimitValues: Optional[LimitValueList] + + +AdditionalLimitList = List[AdditionalLimit] +AdvancedOptions = Dict[String, String] + + +class AdvancedOptionsStatus(TypedDict, total=False): + Options: AdvancedOptions + Status: OptionStatus + + +DisableTimestamp = datetime + + +class JWTOptionsOutput(TypedDict, total=False): + Enabled: Optional[Boolean] + SubjectKey: Optional[String] + RolesKey: Optional[String] + PublicKey: Optional[String] + + +class SAMLIdp(TypedDict, total=False): + MetadataContent: SAMLMetadata + EntityId: SAMLEntityId + + +class SAMLOptionsOutput(TypedDict, total=False): + Enabled: Optional[Boolean] + Idp: Optional[SAMLIdp] + SubjectKey: Optional[String] + RolesKey: Optional[String] + SessionTimeoutMinutes: Optional[IntegerClass] + + +class AdvancedSecurityOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + InternalUserDatabaseEnabled: Optional[Boolean] + SAMLOptions: Optional[SAMLOptionsOutput] + JWTOptions: Optional[JWTOptionsOutput] + AnonymousAuthDisableDate: Optional[DisableTimestamp] + AnonymousAuthEnabled: Optional[Boolean] + + +class JWTOptionsInput(TypedDict, total=False): + Enabled: Optional[Boolean] + SubjectKey: Optional[SubjectKey] + RolesKey: Optional[RolesKey] + PublicKey: Optional[String] + + +class SAMLOptionsInput(TypedDict, total=False): + Enabled: Optional[Boolean] + Idp: Optional[SAMLIdp] + MasterUserName: Optional[Username] + MasterBackendRole: Optional[BackendRole] + SubjectKey: Optional[String] + RolesKey: Optional[String] + SessionTimeoutMinutes: Optional[IntegerClass] + + +class MasterUserOptions(TypedDict, total=False): + MasterUserARN: Optional[ARN] + MasterUserName: Optional[Username] + MasterUserPassword: Optional[Password] + + +class AdvancedSecurityOptionsInput(TypedDict, total=False): + Enabled: Optional[Boolean] + InternalUserDatabaseEnabled: Optional[Boolean] + MasterUserOptions: Optional[MasterUserOptions] + SAMLOptions: Optional[SAMLOptionsInput] + JWTOptions: Optional[JWTOptionsInput] + AnonymousAuthEnabled: Optional[Boolean] + + +class AdvancedSecurityOptionsStatus(TypedDict, total=False): + Options: AdvancedSecurityOptions + Status: OptionStatus + + +class AppConfig(TypedDict, total=False): + key: Optional[AppConfigType] + value: Optional[AppConfigValue] + + +AppConfigs = List[AppConfig] +ApplicationStatuses = List[ApplicationStatus] +Timestamp = datetime + + +class ApplicationSummary(TypedDict, total=False): + id: Optional[Id] + arn: Optional[ARN] + name: Optional[ApplicationName] + endpoint: Optional[String] + status: Optional[ApplicationStatus] + createdAt: Optional[Timestamp] + lastUpdatedAt: Optional[Timestamp] + + +ApplicationSummaries = List[ApplicationSummary] + + +class KeyStoreAccessOption(TypedDict, total=False): + KeyAccessRoleArn: Optional[RoleArn] + KeyStoreAccessEnabled: Boolean + + +class PackageAssociationConfiguration(TypedDict, total=False): + KeyStoreAccessOption: Optional[KeyStoreAccessOption] + + +PackageIDList = List[PackageID] + + +class AssociatePackageRequest(ServiceRequest): + PackageID: PackageID + DomainName: DomainName + PrerequisitePackageIDList: Optional[PackageIDList] + AssociationConfiguration: Optional[PackageAssociationConfiguration] + + +class ErrorDetails(TypedDict, total=False): + ErrorType: Optional[ErrorType] + ErrorMessage: Optional[ErrorMessage] + + +LastUpdated = datetime + + +class DomainPackageDetails(TypedDict, total=False): + PackageID: Optional[PackageID] + PackageName: Optional[PackageName] + PackageType: Optional[PackageType] + LastUpdated: Optional[LastUpdated] + DomainName: Optional[DomainName] + DomainPackageStatus: Optional[DomainPackageStatus] + PackageVersion: Optional[PackageVersion] + PrerequisitePackageIDList: Optional[PackageIDList] + ReferencePath: Optional[ReferencePath] + ErrorDetails: Optional[ErrorDetails] + AssociationConfiguration: Optional[PackageAssociationConfiguration] + + +class AssociatePackageResponse(TypedDict, total=False): + DomainPackageDetails: Optional[DomainPackageDetails] + + +class PackageDetailsForAssociation(TypedDict, total=False): + PackageID: PackageID + PrerequisitePackageIDList: Optional[PackageIDList] + AssociationConfiguration: Optional[PackageAssociationConfiguration] + + +PackageDetailsForAssociationList = List[PackageDetailsForAssociation] + + +class AssociatePackagesRequest(ServiceRequest): + PackageList: PackageDetailsForAssociationList + DomainName: DomainName + + +DomainPackageDetailsList = List[DomainPackageDetails] + + +class AssociatePackagesResponse(TypedDict, total=False): + DomainPackageDetailsList: Optional[DomainPackageDetailsList] + + +class AuthorizeVpcEndpointAccessRequest(ServiceRequest): + DomainName: DomainName + Account: Optional[AWSAccount] + Service: Optional[AWSServicePrincipal] + + +class AuthorizedPrincipal(TypedDict, total=False): + PrincipalType: Optional[PrincipalType] + Principal: Optional[String] + + +class AuthorizeVpcEndpointAccessResponse(TypedDict, total=False): + AuthorizedPrincipal: AuthorizedPrincipal + + +AuthorizedPrincipalList = List[AuthorizedPrincipal] +AutoTuneDate = datetime + + +class ScheduledAutoTuneDetails(TypedDict, total=False): + Date: Optional[AutoTuneDate] + ActionType: Optional[ScheduledAutoTuneActionType] + Action: Optional[ScheduledAutoTuneDescription] + Severity: Optional[ScheduledAutoTuneSeverityType] + + +class AutoTuneDetails(TypedDict, total=False): + ScheduledAutoTuneDetails: Optional[ScheduledAutoTuneDetails] + + +class AutoTune(TypedDict, total=False): + AutoTuneType: Optional[AutoTuneType] + AutoTuneDetails: Optional[AutoTuneDetails] + + +AutoTuneList = List[AutoTune] +DurationValue = int + + +class Duration(TypedDict, total=False): + Value: Optional[DurationValue] + Unit: Optional[TimeUnit] + + +StartAt = datetime + + +class AutoTuneMaintenanceSchedule(TypedDict, total=False): + StartAt: Optional[StartAt] + Duration: Optional[Duration] + CronExpressionForRecurrence: Optional[String] + + +AutoTuneMaintenanceScheduleList = List[AutoTuneMaintenanceSchedule] + + +class AutoTuneOptions(TypedDict, total=False): + DesiredState: Optional[AutoTuneDesiredState] + RollbackOnDisable: Optional[RollbackOnDisable] + MaintenanceSchedules: Optional[AutoTuneMaintenanceScheduleList] + UseOffPeakWindow: Optional[Boolean] + + +class AutoTuneOptionsInput(TypedDict, total=False): + DesiredState: Optional[AutoTuneDesiredState] + MaintenanceSchedules: Optional[AutoTuneMaintenanceScheduleList] + UseOffPeakWindow: Optional[Boolean] + + +class AutoTuneOptionsOutput(TypedDict, total=False): + State: Optional[AutoTuneState] + ErrorMessage: Optional[String] + UseOffPeakWindow: Optional[Boolean] + + +class AutoTuneStatus(TypedDict, total=False): + CreationDate: UpdateTimestamp + UpdateDate: UpdateTimestamp + UpdateVersion: Optional[UIntValue] + State: AutoTuneState + ErrorMessage: Optional[String] + PendingDeletion: Optional[Boolean] + + +class AutoTuneOptionsStatus(TypedDict, total=False): + Options: Optional[AutoTuneOptions] + Status: Optional[AutoTuneStatus] + + +class AvailabilityZoneInfo(TypedDict, total=False): + AvailabilityZoneName: Optional[AvailabilityZone] + ZoneStatus: Optional[ZoneStatus] + ConfiguredDataNodeCount: Optional[NumberOfNodes] + AvailableDataNodeCount: Optional[NumberOfNodes] + TotalShards: Optional[NumberOfShards] + TotalUnAssignedShards: Optional[NumberOfShards] + + +AvailabilityZoneInfoList = List[AvailabilityZoneInfo] +AvailabilityZoneList = List[AvailabilityZone] + + +class CancelDomainConfigChangeRequest(ServiceRequest): + DomainName: DomainName + DryRun: Optional[DryRun] + + +class CancelledChangeProperty(TypedDict, total=False): + PropertyName: Optional[String] + CancelledValue: Optional[String] + ActiveValue: Optional[String] + + +CancelledChangePropertyList = List[CancelledChangeProperty] +GUIDList = List[GUID] + + +class CancelDomainConfigChangeResponse(TypedDict, total=False): + CancelledChangeIds: Optional[GUIDList] + CancelledChangeProperties: Optional[CancelledChangePropertyList] + DryRun: Optional[DryRun] + + +class CancelServiceSoftwareUpdateRequest(ServiceRequest): + DomainName: DomainName + + +DeploymentCloseDateTimeStamp = datetime + + +class ServiceSoftwareOptions(TypedDict, total=False): + CurrentVersion: Optional[String] + NewVersion: Optional[String] + UpdateAvailable: Optional[Boolean] + Cancellable: Optional[Boolean] + UpdateStatus: Optional[DeploymentStatus] + Description: Optional[String] + AutomatedUpdateDate: Optional[DeploymentCloseDateTimeStamp] + OptionalDeployment: Optional[Boolean] + + +class CancelServiceSoftwareUpdateResponse(TypedDict, total=False): + ServiceSoftwareOptions: Optional[ServiceSoftwareOptions] + + +class ChangeProgressDetails(TypedDict, total=False): + ChangeId: Optional[GUID] + Message: Optional[Message] + ConfigChangeStatus: Optional[ConfigChangeStatus] + InitiatedBy: Optional[InitiatedBy] + StartTime: Optional[UpdateTimestamp] + LastUpdatedTime: Optional[UpdateTimestamp] + + +class ChangeProgressStage(TypedDict, total=False): + Name: Optional[ChangeProgressStageName] + Status: Optional[ChangeProgressStageStatus] + Description: Optional[Description] + LastUpdated: Optional[LastUpdated] + + +ChangeProgressStageList = List[ChangeProgressStage] +StringList = List[String] + + +class ChangeProgressStatusDetails(TypedDict, total=False): + ChangeId: Optional[GUID] + StartTime: Optional[UpdateTimestamp] + Status: Optional[OverallChangeStatus] + PendingProperties: Optional[StringList] + CompletedProperties: Optional[StringList] + TotalNumberOfStages: Optional[TotalNumberOfStages] + ChangeProgressStages: Optional[ChangeProgressStageList] + LastUpdatedTime: Optional[UpdateTimestamp] + ConfigChangeStatus: Optional[ConfigChangeStatus] + InitiatedBy: Optional[InitiatedBy] + + +class NodeConfig(TypedDict, total=False): + Enabled: Optional[Boolean] + Type: Optional[OpenSearchPartitionInstanceType] + Count: Optional[IntegerClass] + + +class NodeOption(TypedDict, total=False): + NodeType: Optional[NodeOptionsNodeType] + NodeConfig: Optional[NodeConfig] + + +NodeOptionsList = List[NodeOption] + + +class ColdStorageOptions(TypedDict, total=False): + Enabled: Boolean + + +class ZoneAwarenessConfig(TypedDict, total=False): + AvailabilityZoneCount: Optional[IntegerClass] + + +class ClusterConfig(TypedDict, total=False): + InstanceType: Optional[OpenSearchPartitionInstanceType] + InstanceCount: Optional[IntegerClass] + DedicatedMasterEnabled: Optional[Boolean] + ZoneAwarenessEnabled: Optional[Boolean] + ZoneAwarenessConfig: Optional[ZoneAwarenessConfig] + DedicatedMasterType: Optional[OpenSearchPartitionInstanceType] + DedicatedMasterCount: Optional[IntegerClass] + WarmEnabled: Optional[Boolean] + WarmType: Optional[OpenSearchWarmPartitionInstanceType] + WarmCount: Optional[IntegerClass] + ColdStorageOptions: Optional[ColdStorageOptions] + MultiAZWithStandbyEnabled: Optional[Boolean] + NodeOptions: Optional[NodeOptionsList] + + +class ClusterConfigStatus(TypedDict, total=False): + Options: ClusterConfig + Status: OptionStatus + + +class CognitoOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + UserPoolId: Optional[UserPoolId] + IdentityPoolId: Optional[IdentityPoolId] + RoleArn: Optional[RoleArn] + + +class CognitoOptionsStatus(TypedDict, total=False): + Options: CognitoOptions + Status: OptionStatus + + +VersionList = List[VersionString] + + +class CompatibleVersionsMap(TypedDict, total=False): + SourceVersion: Optional[VersionString] + TargetVersions: Optional[VersionList] + + +CompatibleVersionsList = List[CompatibleVersionsMap] + + +class CrossClusterSearchConnectionProperties(TypedDict, total=False): + SkipUnavailable: Optional[SkipUnavailableStatus] + + +class ConnectionProperties(TypedDict, total=False): + Endpoint: Optional[Endpoint] + CrossClusterSearch: Optional[CrossClusterSearchConnectionProperties] + + +class IamIdentityCenterOptionsInput(TypedDict, total=False): + enabled: Optional[Boolean] + iamIdentityCenterInstanceArn: Optional[ARN] + iamRoleForIdentityCenterApplicationArn: Optional[RoleArn] + + +class DataSource(TypedDict, total=False): + dataSourceArn: Optional[ARN] + dataSourceDescription: Optional[DataSourceDescription] + + +DataSources = List[DataSource] + + +class CreateApplicationRequest(ServiceRequest): + clientToken: Optional[ClientToken] + name: ApplicationName + dataSources: Optional[DataSources] + iamIdentityCenterOptions: Optional[IamIdentityCenterOptionsInput] + appConfigs: Optional[AppConfigs] + tagList: Optional[TagList] + + +class IamIdentityCenterOptions(TypedDict, total=False): + enabled: Optional[Boolean] + iamIdentityCenterInstanceArn: Optional[ARN] + iamRoleForIdentityCenterApplicationArn: Optional[RoleArn] + iamIdentityCenterApplicationArn: Optional[ARN] + + +class CreateApplicationResponse(TypedDict, total=False): + id: Optional[Id] + name: Optional[ApplicationName] + arn: Optional[ARN] + dataSources: Optional[DataSources] + iamIdentityCenterOptions: Optional[IamIdentityCenterOptions] + appConfigs: Optional[AppConfigs] + tagList: Optional[TagList] + createdAt: Optional[Timestamp] + + +class SoftwareUpdateOptions(TypedDict, total=False): + AutoSoftwareUpdateEnabled: Optional[Boolean] + + +StartTimeMinutes = int +StartTimeHours = int + + +class WindowStartTime(TypedDict, total=False): + Hours: StartTimeHours + Minutes: StartTimeMinutes + + +class OffPeakWindow(TypedDict, total=False): + WindowStartTime: Optional[WindowStartTime] + + +class OffPeakWindowOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + OffPeakWindow: Optional[OffPeakWindow] + + +class IdentityCenterOptionsInput(TypedDict, total=False): + EnabledAPIAccess: Optional[Boolean] + IdentityCenterInstanceARN: Optional[IdentityCenterInstanceARN] + SubjectKey: Optional[SubjectKeyIdCOption] + RolesKey: Optional[RolesKeyIdCOption] + + +class DomainEndpointOptions(TypedDict, total=False): + EnforceHTTPS: Optional[Boolean] + TLSSecurityPolicy: Optional[TLSSecurityPolicy] + CustomEndpointEnabled: Optional[Boolean] + CustomEndpoint: Optional[DomainNameFqdn] + CustomEndpointCertificateArn: Optional[ARN] + + +class LogPublishingOption(TypedDict, total=False): + CloudWatchLogsLogGroupArn: Optional[CloudWatchLogsLogGroupArn] + Enabled: Optional[Boolean] + + +LogPublishingOptions = Dict[LogType, LogPublishingOption] + + +class NodeToNodeEncryptionOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + + +class EncryptionAtRestOptions(TypedDict, total=False): + Enabled: Optional[Boolean] + KmsKeyId: Optional[KmsKeyId] + + +class VPCOptions(TypedDict, total=False): + SubnetIds: Optional[StringList] + SecurityGroupIds: Optional[StringList] + + +class SnapshotOptions(TypedDict, total=False): + AutomatedSnapshotStartHour: Optional[IntegerClass] + + +class EBSOptions(TypedDict, total=False): + EBSEnabled: Optional[Boolean] + VolumeType: Optional[VolumeType] + VolumeSize: Optional[IntegerClass] + Iops: Optional[IntegerClass] + Throughput: Optional[IntegerClass] + + +class CreateDomainRequest(ServiceRequest): + DomainName: DomainName + EngineVersion: Optional[VersionString] + ClusterConfig: Optional[ClusterConfig] + EBSOptions: Optional[EBSOptions] + AccessPolicies: Optional[PolicyDocument] + IPAddressType: Optional[IPAddressType] + SnapshotOptions: Optional[SnapshotOptions] + VPCOptions: Optional[VPCOptions] + CognitoOptions: Optional[CognitoOptions] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + AdvancedOptions: Optional[AdvancedOptions] + LogPublishingOptions: Optional[LogPublishingOptions] + DomainEndpointOptions: Optional[DomainEndpointOptions] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsInput] + IdentityCenterOptions: Optional[IdentityCenterOptionsInput] + TagList: Optional[TagList] + AutoTuneOptions: Optional[AutoTuneOptionsInput] + OffPeakWindowOptions: Optional[OffPeakWindowOptions] + SoftwareUpdateOptions: Optional[SoftwareUpdateOptions] + AIMLOptions: Optional[AIMLOptionsInput] + + +class ModifyingProperties(TypedDict, total=False): + Name: Optional[String] + ActiveValue: Optional[String] + PendingValue: Optional[String] + ValueType: Optional[PropertyValueType] + + +ModifyingPropertiesList = List[ModifyingProperties] + + +class IdentityCenterOptions(TypedDict, total=False): + EnabledAPIAccess: Optional[Boolean] + IdentityCenterInstanceARN: Optional[IdentityCenterInstanceARN] + SubjectKey: Optional[SubjectKeyIdCOption] + RolesKey: Optional[RolesKeyIdCOption] + IdentityCenterApplicationARN: Optional[IdentityCenterApplicationARN] + IdentityStoreId: Optional[IdentityStoreId] + + +class VPCDerivedInfo(TypedDict, total=False): + VPCId: Optional[String] + SubnetIds: Optional[StringList] + AvailabilityZones: Optional[StringList] + SecurityGroupIds: Optional[StringList] + + +EndpointsMap = Dict[String, ServiceUrl] + + +class DomainStatus(TypedDict, total=False): + DomainId: DomainId + DomainName: DomainName + ARN: ARN + Created: Optional[Boolean] + Deleted: Optional[Boolean] + Endpoint: Optional[ServiceUrl] + EndpointV2: Optional[ServiceUrl] + Endpoints: Optional[EndpointsMap] + DomainEndpointV2HostedZoneId: Optional[HostedZoneId] + Processing: Optional[Boolean] + UpgradeProcessing: Optional[Boolean] + EngineVersion: Optional[VersionString] + ClusterConfig: ClusterConfig + EBSOptions: Optional[EBSOptions] + AccessPolicies: Optional[PolicyDocument] + IPAddressType: Optional[IPAddressType] + SnapshotOptions: Optional[SnapshotOptions] + VPCOptions: Optional[VPCDerivedInfo] + CognitoOptions: Optional[CognitoOptions] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + AdvancedOptions: Optional[AdvancedOptions] + LogPublishingOptions: Optional[LogPublishingOptions] + ServiceSoftwareOptions: Optional[ServiceSoftwareOptions] + DomainEndpointOptions: Optional[DomainEndpointOptions] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptions] + IdentityCenterOptions: Optional[IdentityCenterOptions] + AutoTuneOptions: Optional[AutoTuneOptionsOutput] + ChangeProgressDetails: Optional[ChangeProgressDetails] + OffPeakWindowOptions: Optional[OffPeakWindowOptions] + SoftwareUpdateOptions: Optional[SoftwareUpdateOptions] + DomainProcessingStatus: Optional[DomainProcessingStatusType] + ModifyingProperties: Optional[ModifyingPropertiesList] + AIMLOptions: Optional[AIMLOptionsOutput] + + +class CreateDomainResponse(TypedDict, total=False): + DomainStatus: Optional[DomainStatus] + + +class CreateOutboundConnectionRequest(ServiceRequest): + LocalDomainInfo: DomainInformationContainer + RemoteDomainInfo: DomainInformationContainer + ConnectionAlias: ConnectionAlias + ConnectionMode: Optional[ConnectionMode] + ConnectionProperties: Optional[ConnectionProperties] + + +class OutboundConnectionStatus(TypedDict, total=False): + StatusCode: Optional[OutboundConnectionStatusCode] + Message: Optional[ConnectionStatusMessage] + + +class CreateOutboundConnectionResponse(TypedDict, total=False): + LocalDomainInfo: Optional[DomainInformationContainer] + RemoteDomainInfo: Optional[DomainInformationContainer] + ConnectionAlias: Optional[ConnectionAlias] + ConnectionStatus: Optional[OutboundConnectionStatus] + ConnectionId: Optional[ConnectionId] + ConnectionMode: Optional[ConnectionMode] + ConnectionProperties: Optional[ConnectionProperties] + + +class PackageEncryptionOptions(TypedDict, total=False): + KmsKeyIdentifier: Optional[KmsKeyId] + EncryptionEnabled: Boolean + + +class PackageVendingOptions(TypedDict, total=False): + VendingEnabled: Boolean + + +class PackageConfiguration(TypedDict, total=False): + LicenseRequirement: RequirementLevel + LicenseFilepath: Optional[LicenseFilepath] + ConfigurationRequirement: RequirementLevel + RequiresRestartForConfigurationUpdate: Optional[Boolean] + + +class PackageSource(TypedDict, total=False): + S3BucketName: Optional[S3BucketName] + S3Key: Optional[S3Key] + + +class CreatePackageRequest(ServiceRequest): + PackageName: PackageName + PackageType: PackageType + PackageDescription: Optional[PackageDescription] + PackageSource: PackageSource + PackageConfiguration: Optional[PackageConfiguration] + EngineVersion: Optional[EngineVersion] + PackageVendingOptions: Optional[PackageVendingOptions] + PackageEncryptionOptions: Optional[PackageEncryptionOptions] + + +PackageUserList = List[PackageUser] +UncompressedPluginSizeInBytes = int + + +class PluginProperties(TypedDict, total=False): + Name: Optional[PluginName] + Description: Optional[PluginDescription] + Version: Optional[PluginVersion] + ClassName: Optional[PluginClassName] + UncompressedSizeInBytes: Optional[UncompressedPluginSizeInBytes] + + +CreatedAt = datetime + + +class PackageDetails(TypedDict, total=False): + PackageID: Optional[PackageID] + PackageName: Optional[PackageName] + PackageType: Optional[PackageType] + PackageDescription: Optional[PackageDescription] + PackageStatus: Optional[PackageStatus] + CreatedAt: Optional[CreatedAt] + LastUpdatedAt: Optional[LastUpdated] + AvailablePackageVersion: Optional[PackageVersion] + ErrorDetails: Optional[ErrorDetails] + EngineVersion: Optional[EngineVersion] + AvailablePluginProperties: Optional[PluginProperties] + AvailablePackageConfiguration: Optional[PackageConfiguration] + AllowListedUserList: Optional[PackageUserList] + PackageOwner: Optional[PackageOwner] + PackageVendingOptions: Optional[PackageVendingOptions] + PackageEncryptionOptions: Optional[PackageEncryptionOptions] + + +class CreatePackageResponse(TypedDict, total=False): + PackageDetails: Optional[PackageDetails] + + +class CreateVpcEndpointRequest(ServiceRequest): + DomainArn: DomainArn + VpcOptions: VPCOptions + ClientToken: Optional[ClientToken] + + +class VpcEndpoint(TypedDict, total=False): + VpcEndpointId: Optional[VpcEndpointId] + VpcEndpointOwner: Optional[AWSAccount] + DomainArn: Optional[DomainArn] + VpcOptions: Optional[VPCDerivedInfo] + Status: Optional[VpcEndpointStatus] + Endpoint: Optional[Endpoint] + + +class CreateVpcEndpointResponse(TypedDict, total=False): + VpcEndpoint: VpcEndpoint + + +class DataSourceDetails(TypedDict, total=False): + DataSourceType: Optional[DataSourceType] + Name: Optional[DataSourceName] + Description: Optional[DataSourceDescription] + Status: Optional[DataSourceStatus] + + +DataSourceList = List[DataSourceDetails] + + +class DeleteApplicationRequest(ServiceRequest): + id: Id + + +class DeleteApplicationResponse(TypedDict, total=False): + pass + + +class DeleteDataSourceRequest(ServiceRequest): + DomainName: DomainName + Name: DataSourceName + + +class DeleteDataSourceResponse(TypedDict, total=False): + Message: Optional[String] + + +class DeleteDirectQueryDataSourceRequest(ServiceRequest): + DataSourceName: DirectQueryDataSourceName + + +class DeleteDomainRequest(ServiceRequest): + DomainName: DomainName + + +class DeleteDomainResponse(TypedDict, total=False): + DomainStatus: Optional[DomainStatus] + + +class DeleteInboundConnectionRequest(ServiceRequest): + ConnectionId: ConnectionId + + +class DeleteInboundConnectionResponse(TypedDict, total=False): + Connection: Optional[InboundConnection] + + +class DeleteOutboundConnectionRequest(ServiceRequest): + ConnectionId: ConnectionId + + +class OutboundConnection(TypedDict, total=False): + LocalDomainInfo: Optional[DomainInformationContainer] + RemoteDomainInfo: Optional[DomainInformationContainer] + ConnectionId: Optional[ConnectionId] + ConnectionAlias: Optional[ConnectionAlias] + ConnectionStatus: Optional[OutboundConnectionStatus] + ConnectionMode: Optional[ConnectionMode] + ConnectionProperties: Optional[ConnectionProperties] + + +class DeleteOutboundConnectionResponse(TypedDict, total=False): + Connection: Optional[OutboundConnection] + + +class DeletePackageRequest(ServiceRequest): + PackageID: PackageID + + +class DeletePackageResponse(TypedDict, total=False): + PackageDetails: Optional[PackageDetails] + + +class DeleteVpcEndpointRequest(ServiceRequest): + VpcEndpointId: VpcEndpointId + + +class VpcEndpointSummary(TypedDict, total=False): + VpcEndpointId: Optional[VpcEndpointId] + VpcEndpointOwner: Optional[String] + DomainArn: Optional[DomainArn] + Status: Optional[VpcEndpointStatus] + + +class DeleteVpcEndpointResponse(TypedDict, total=False): + VpcEndpointSummary: VpcEndpointSummary + + +class DescribeDomainAutoTunesRequest(ServiceRequest): + DomainName: DomainName + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class DescribeDomainAutoTunesResponse(TypedDict, total=False): + AutoTunes: Optional[AutoTuneList] + NextToken: Optional[NextToken] + + +class DescribeDomainChangeProgressRequest(ServiceRequest): + DomainName: DomainName + ChangeId: Optional[GUID] + + +class DescribeDomainChangeProgressResponse(TypedDict, total=False): + ChangeProgressStatus: Optional[ChangeProgressStatusDetails] + + +class DescribeDomainConfigRequest(ServiceRequest): + DomainName: DomainName + + +class SoftwareUpdateOptionsStatus(TypedDict, total=False): + Options: Optional[SoftwareUpdateOptions] + Status: Optional[OptionStatus] + + +class OffPeakWindowOptionsStatus(TypedDict, total=False): + Options: Optional[OffPeakWindowOptions] + Status: Optional[OptionStatus] + + +class IdentityCenterOptionsStatus(TypedDict, total=False): + Options: IdentityCenterOptions + Status: OptionStatus + + +class DomainEndpointOptionsStatus(TypedDict, total=False): + Options: DomainEndpointOptions + Status: OptionStatus + + +class LogPublishingOptionsStatus(TypedDict, total=False): + Options: Optional[LogPublishingOptions] + Status: Optional[OptionStatus] + + +class NodeToNodeEncryptionOptionsStatus(TypedDict, total=False): + Options: NodeToNodeEncryptionOptions + Status: OptionStatus + + +class EncryptionAtRestOptionsStatus(TypedDict, total=False): + Options: EncryptionAtRestOptions + Status: OptionStatus + + +class VPCDerivedInfoStatus(TypedDict, total=False): + Options: VPCDerivedInfo + Status: OptionStatus + + +class SnapshotOptionsStatus(TypedDict, total=False): + Options: SnapshotOptions + Status: OptionStatus + + +class IPAddressTypeStatus(TypedDict, total=False): + Options: IPAddressType + Status: OptionStatus + + +class EBSOptionsStatus(TypedDict, total=False): + Options: EBSOptions + Status: OptionStatus + + +class VersionStatus(TypedDict, total=False): + Options: VersionString + Status: OptionStatus + + +class DomainConfig(TypedDict, total=False): + EngineVersion: Optional[VersionStatus] + ClusterConfig: Optional[ClusterConfigStatus] + EBSOptions: Optional[EBSOptionsStatus] + AccessPolicies: Optional[AccessPoliciesStatus] + IPAddressType: Optional[IPAddressTypeStatus] + SnapshotOptions: Optional[SnapshotOptionsStatus] + VPCOptions: Optional[VPCDerivedInfoStatus] + CognitoOptions: Optional[CognitoOptionsStatus] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptionsStatus] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptionsStatus] + AdvancedOptions: Optional[AdvancedOptionsStatus] + LogPublishingOptions: Optional[LogPublishingOptionsStatus] + DomainEndpointOptions: Optional[DomainEndpointOptionsStatus] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsStatus] + IdentityCenterOptions: Optional[IdentityCenterOptionsStatus] + AutoTuneOptions: Optional[AutoTuneOptionsStatus] + ChangeProgressDetails: Optional[ChangeProgressDetails] + OffPeakWindowOptions: Optional[OffPeakWindowOptionsStatus] + SoftwareUpdateOptions: Optional[SoftwareUpdateOptionsStatus] + ModifyingProperties: Optional[ModifyingPropertiesList] + AIMLOptions: Optional[AIMLOptionsStatus] + + +class DescribeDomainConfigResponse(TypedDict, total=False): + DomainConfig: DomainConfig + + +class DescribeDomainHealthRequest(ServiceRequest): + DomainName: DomainName + + +class EnvironmentInfo(TypedDict, total=False): + AvailabilityZoneInformation: Optional[AvailabilityZoneInfoList] + + +EnvironmentInfoList = List[EnvironmentInfo] + + +class DescribeDomainHealthResponse(TypedDict, total=False): + DomainState: Optional[DomainState] + AvailabilityZoneCount: Optional[NumberOfAZs] + ActiveAvailabilityZoneCount: Optional[NumberOfAZs] + StandByAvailabilityZoneCount: Optional[NumberOfAZs] + DataNodeCount: Optional[NumberOfNodes] + DedicatedMaster: Optional[Boolean] + MasterEligibleNodeCount: Optional[NumberOfNodes] + WarmNodeCount: Optional[NumberOfNodes] + MasterNode: Optional[MasterNodeStatus] + ClusterHealth: Optional[DomainHealth] + TotalShards: Optional[NumberOfShards] + TotalUnAssignedShards: Optional[NumberOfShards] + EnvironmentInformation: Optional[EnvironmentInfoList] + + +class DescribeDomainNodesRequest(ServiceRequest): + DomainName: DomainName + + +class DomainNodesStatus(TypedDict, total=False): + NodeId: Optional[NodeId] + NodeType: Optional[NodeType] + AvailabilityZone: Optional[AvailabilityZone] + InstanceType: Optional[OpenSearchPartitionInstanceType] + NodeStatus: Optional[NodeStatus] + StorageType: Optional[StorageTypeName] + StorageVolumeType: Optional[VolumeType] + StorageSize: Optional[VolumeSize] + + +DomainNodesStatusList = List[DomainNodesStatus] + + +class DescribeDomainNodesResponse(TypedDict, total=False): + DomainNodesStatusList: Optional[DomainNodesStatusList] + + +class DescribeDomainRequest(ServiceRequest): + DomainName: DomainName + + +class DescribeDomainResponse(TypedDict, total=False): + DomainStatus: DomainStatus + + +DomainNameList = List[DomainName] + + +class DescribeDomainsRequest(ServiceRequest): + DomainNames: DomainNameList + + +DomainStatusList = List[DomainStatus] + + +class DescribeDomainsResponse(TypedDict, total=False): + DomainStatusList: DomainStatusList + + +class DescribeDryRunProgressRequest(ServiceRequest): + DomainName: DomainName + DryRunId: Optional[GUID] + LoadDryRunConfig: Optional[Boolean] + + +class DryRunResults(TypedDict, total=False): + DeploymentType: Optional[DeploymentType] + Message: Optional[Message] + + +class ValidationFailure(TypedDict, total=False): + Code: Optional[String] + Message: Optional[String] + + +ValidationFailures = List[ValidationFailure] + + +class DryRunProgressStatus(TypedDict, total=False): + DryRunId: GUID + DryRunStatus: String + CreationDate: String + UpdateDate: String + ValidationFailures: Optional[ValidationFailures] + + +class DescribeDryRunProgressResponse(TypedDict, total=False): + DryRunProgressStatus: Optional[DryRunProgressStatus] + DryRunConfig: Optional[DomainStatus] + DryRunResults: Optional[DryRunResults] + + +ValueStringList = List[NonEmptyString] + + +class Filter(TypedDict, total=False): + Name: Optional[NonEmptyString] + Values: Optional[ValueStringList] + + +FilterList = List[Filter] + + +class DescribeInboundConnectionsRequest(ServiceRequest): + Filters: Optional[FilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +InboundConnections = List[InboundConnection] + + +class DescribeInboundConnectionsResponse(TypedDict, total=False): + Connections: Optional[InboundConnections] + NextToken: Optional[NextToken] + + +class DescribeInstanceTypeLimitsRequest(ServiceRequest): + DomainName: Optional[DomainName] + InstanceType: OpenSearchPartitionInstanceType + EngineVersion: VersionString + + +class InstanceCountLimits(TypedDict, total=False): + MinimumInstanceCount: Optional[MinimumInstanceCount] + MaximumInstanceCount: Optional[MaximumInstanceCount] + + +class InstanceLimits(TypedDict, total=False): + InstanceCountLimits: Optional[InstanceCountLimits] + + +class StorageTypeLimit(TypedDict, total=False): + LimitName: Optional[LimitName] + LimitValues: Optional[LimitValueList] + + +StorageTypeLimitList = List[StorageTypeLimit] + + +class StorageType(TypedDict, total=False): + StorageTypeName: Optional[StorageTypeName] + StorageSubTypeName: Optional[StorageSubTypeName] + StorageTypeLimits: Optional[StorageTypeLimitList] + + +StorageTypeList = List[StorageType] + + +class Limits(TypedDict, total=False): + StorageTypes: Optional[StorageTypeList] + InstanceLimits: Optional[InstanceLimits] + AdditionalLimits: Optional[AdditionalLimitList] + + +LimitsByRole = Dict[InstanceRole, Limits] + + +class DescribeInstanceTypeLimitsResponse(TypedDict, total=False): + LimitsByRole: Optional[LimitsByRole] + + +class DescribeOutboundConnectionsRequest(ServiceRequest): + Filters: Optional[FilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +OutboundConnections = List[OutboundConnection] + + +class DescribeOutboundConnectionsResponse(TypedDict, total=False): + Connections: Optional[OutboundConnections] + NextToken: Optional[NextToken] + + +DescribePackagesFilterValues = List[DescribePackagesFilterValue] + + +class DescribePackagesFilter(TypedDict, total=False): + Name: Optional[DescribePackagesFilterName] + Value: Optional[DescribePackagesFilterValues] + + +DescribePackagesFilterList = List[DescribePackagesFilter] + + +class DescribePackagesRequest(ServiceRequest): + Filters: Optional[DescribePackagesFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +PackageDetailsList = List[PackageDetails] + + +class DescribePackagesResponse(TypedDict, total=False): + PackageDetailsList: Optional[PackageDetailsList] + NextToken: Optional[String] + + +class DescribeReservedInstanceOfferingsRequest(ServiceRequest): + ReservedInstanceOfferingId: Optional[GUID] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class RecurringCharge(TypedDict, total=False): + RecurringChargeAmount: Optional[Double] + RecurringChargeFrequency: Optional[String] + + +RecurringChargeList = List[RecurringCharge] + + +class ReservedInstanceOffering(TypedDict, total=False): + ReservedInstanceOfferingId: Optional[GUID] + InstanceType: Optional[OpenSearchPartitionInstanceType] + Duration: Optional[Integer] + FixedPrice: Optional[Double] + UsagePrice: Optional[Double] + CurrencyCode: Optional[String] + PaymentOption: Optional[ReservedInstancePaymentOption] + RecurringCharges: Optional[RecurringChargeList] + + +ReservedInstanceOfferingList = List[ReservedInstanceOffering] + + +class DescribeReservedInstanceOfferingsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + ReservedInstanceOfferings: Optional[ReservedInstanceOfferingList] + + +class DescribeReservedInstancesRequest(ServiceRequest): + ReservedInstanceId: Optional[GUID] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ReservedInstance(TypedDict, total=False): + ReservationName: Optional[ReservationToken] + ReservedInstanceId: Optional[GUID] + BillingSubscriptionId: Optional[Long] + ReservedInstanceOfferingId: Optional[String] + InstanceType: Optional[OpenSearchPartitionInstanceType] + StartTime: Optional[UpdateTimestamp] + Duration: Optional[Integer] + FixedPrice: Optional[Double] + UsagePrice: Optional[Double] + CurrencyCode: Optional[String] + InstanceCount: Optional[Integer] + State: Optional[String] + PaymentOption: Optional[ReservedInstancePaymentOption] + RecurringCharges: Optional[RecurringChargeList] + + +ReservedInstanceList = List[ReservedInstance] + + +class DescribeReservedInstancesResponse(TypedDict, total=False): + NextToken: Optional[String] + ReservedInstances: Optional[ReservedInstanceList] + + +VpcEndpointIdList = List[VpcEndpointId] + + +class DescribeVpcEndpointsRequest(ServiceRequest): + VpcEndpointIds: VpcEndpointIdList + + +class VpcEndpointError(TypedDict, total=False): + VpcEndpointId: Optional[VpcEndpointId] + ErrorCode: Optional[VpcEndpointErrorCode] + ErrorMessage: Optional[String] + + +VpcEndpointErrorList = List[VpcEndpointError] +VpcEndpoints = List[VpcEndpoint] + + +class DescribeVpcEndpointsResponse(TypedDict, total=False): + VpcEndpoints: VpcEndpoints + VpcEndpointErrors: VpcEndpointErrorList + + +class DirectQueryDataSource(TypedDict, total=False): + DataSourceName: Optional[DirectQueryDataSourceName] + DataSourceType: Optional[DirectQueryDataSourceType] + Description: Optional[DirectQueryDataSourceDescription] + OpenSearchArns: Optional[DirectQueryOpenSearchARNList] + DataSourceArn: Optional[String] + TagList: Optional[TagList] + + +DirectQueryDataSourceList = List[DirectQueryDataSource] + + +class DissociatePackageRequest(ServiceRequest): + PackageID: PackageID + DomainName: DomainName + + +class DissociatePackageResponse(TypedDict, total=False): + DomainPackageDetails: Optional[DomainPackageDetails] + + +class DissociatePackagesRequest(ServiceRequest): + PackageList: PackageIDList + DomainName: DomainName + + +class DissociatePackagesResponse(TypedDict, total=False): + DomainPackageDetailsList: Optional[DomainPackageDetailsList] + + +class DomainInfo(TypedDict, total=False): + DomainName: Optional[DomainName] + EngineType: Optional[EngineType] + + +DomainInfoList = List[DomainInfo] + + +class DomainMaintenanceDetails(TypedDict, total=False): + MaintenanceId: Optional[RequestId] + DomainName: Optional[DomainName] + Action: Optional[MaintenanceType] + NodeId: Optional[NodeId] + Status: Optional[MaintenanceStatus] + StatusMessage: Optional[MaintenanceStatusMessage] + CreatedAt: Optional[UpdateTimestamp] + UpdatedAt: Optional[UpdateTimestamp] + + +DomainMaintenanceList = List[DomainMaintenanceDetails] + + +class GetApplicationRequest(ServiceRequest): + id: Id + + +class GetApplicationResponse(TypedDict, total=False): + id: Optional[Id] + arn: Optional[ARN] + name: Optional[ApplicationName] + endpoint: Optional[String] + status: Optional[ApplicationStatus] + iamIdentityCenterOptions: Optional[IamIdentityCenterOptions] + dataSources: Optional[DataSources] + appConfigs: Optional[AppConfigs] + createdAt: Optional[Timestamp] + lastUpdatedAt: Optional[Timestamp] + + +class GetCompatibleVersionsRequest(ServiceRequest): + DomainName: Optional[DomainName] + + +class GetCompatibleVersionsResponse(TypedDict, total=False): + CompatibleVersions: Optional[CompatibleVersionsList] + + +class GetDataSourceRequest(ServiceRequest): + DomainName: DomainName + Name: DataSourceName + + +class GetDataSourceResponse(TypedDict, total=False): + DataSourceType: Optional[DataSourceType] + Name: Optional[DataSourceName] + Description: Optional[DataSourceDescription] + Status: Optional[DataSourceStatus] + + +class GetDirectQueryDataSourceRequest(ServiceRequest): + DataSourceName: DirectQueryDataSourceName + + +class GetDirectQueryDataSourceResponse(TypedDict, total=False): + DataSourceName: Optional[DirectQueryDataSourceName] + DataSourceType: Optional[DirectQueryDataSourceType] + Description: Optional[DirectQueryDataSourceDescription] + OpenSearchArns: Optional[DirectQueryOpenSearchARNList] + DataSourceArn: Optional[String] + + +class GetDomainMaintenanceStatusRequest(ServiceRequest): + DomainName: DomainName + MaintenanceId: RequestId + + +class GetDomainMaintenanceStatusResponse(TypedDict, total=False): + Status: Optional[MaintenanceStatus] + StatusMessage: Optional[MaintenanceStatusMessage] + NodeId: Optional[NodeId] + Action: Optional[MaintenanceType] + CreatedAt: Optional[UpdateTimestamp] + UpdatedAt: Optional[UpdateTimestamp] + + +class GetPackageVersionHistoryRequest(ServiceRequest): + PackageID: PackageID + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class PackageVersionHistory(TypedDict, total=False): + PackageVersion: Optional[PackageVersion] + CommitMessage: Optional[CommitMessage] + CreatedAt: Optional[CreatedAt] + PluginProperties: Optional[PluginProperties] + PackageConfiguration: Optional[PackageConfiguration] + + +PackageVersionHistoryList = List[PackageVersionHistory] + + +class GetPackageVersionHistoryResponse(TypedDict, total=False): + PackageID: Optional[PackageID] + PackageVersionHistoryList: Optional[PackageVersionHistoryList] + NextToken: Optional[String] + + +class GetUpgradeHistoryRequest(ServiceRequest): + DomainName: DomainName + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +Issues = List[Issue] + + +class UpgradeStepItem(TypedDict, total=False): + UpgradeStep: Optional[UpgradeStep] + UpgradeStepStatus: Optional[UpgradeStatus] + Issues: Optional[Issues] + ProgressPercent: Optional[Double] + + +UpgradeStepsList = List[UpgradeStepItem] +StartTimestamp = datetime + + +class UpgradeHistory(TypedDict, total=False): + UpgradeName: Optional[UpgradeName] + StartTimestamp: Optional[StartTimestamp] + UpgradeStatus: Optional[UpgradeStatus] + StepsList: Optional[UpgradeStepsList] + + +UpgradeHistoryList = List[UpgradeHistory] + + +class GetUpgradeHistoryResponse(TypedDict, total=False): + UpgradeHistories: Optional[UpgradeHistoryList] + NextToken: Optional[String] + + +class GetUpgradeStatusRequest(ServiceRequest): + DomainName: DomainName + + +class GetUpgradeStatusResponse(TypedDict, total=False): + UpgradeStep: Optional[UpgradeStep] + StepStatus: Optional[UpgradeStatus] + UpgradeName: Optional[UpgradeName] + + +InstanceRoleList = List[InstanceRole] + + +class InstanceTypeDetails(TypedDict, total=False): + InstanceType: Optional[OpenSearchPartitionInstanceType] + EncryptionEnabled: Optional[Boolean] + CognitoEnabled: Optional[Boolean] + AppLogsEnabled: Optional[Boolean] + AdvancedSecurityEnabled: Optional[Boolean] + WarmEnabled: Optional[Boolean] + InstanceRole: Optional[InstanceRoleList] + AvailabilityZones: Optional[AvailabilityZoneList] + + +InstanceTypeDetailsList = List[InstanceTypeDetails] + + +class ListApplicationsRequest(ServiceRequest): + nextToken: Optional[NextToken] + statuses: Optional[ApplicationStatuses] + maxResults: Optional[MaxResults] + + +class ListApplicationsResponse(TypedDict, total=False): + ApplicationSummaries: Optional[ApplicationSummaries] + nextToken: Optional[NextToken] + + +class ListDataSourcesRequest(ServiceRequest): + DomainName: DomainName + + +class ListDataSourcesResponse(TypedDict, total=False): + DataSources: Optional[DataSourceList] + + +class ListDirectQueryDataSourcesRequest(ServiceRequest): + NextToken: Optional[NextToken] + + +class ListDirectQueryDataSourcesResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + DirectQueryDataSources: Optional[DirectQueryDataSourceList] + + +class ListDomainMaintenancesRequest(ServiceRequest): + DomainName: DomainName + Action: Optional[MaintenanceType] + Status: Optional[MaintenanceStatus] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListDomainMaintenancesResponse(TypedDict, total=False): + DomainMaintenances: Optional[DomainMaintenanceList] + NextToken: Optional[NextToken] + + +class ListDomainNamesRequest(ServiceRequest): + EngineType: Optional[EngineType] + + +class ListDomainNamesResponse(TypedDict, total=False): + DomainNames: Optional[DomainInfoList] + + +class ListDomainsForPackageRequest(ServiceRequest): + PackageID: PackageID + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListDomainsForPackageResponse(TypedDict, total=False): + DomainPackageDetailsList: Optional[DomainPackageDetailsList] + NextToken: Optional[String] + + +class ListInstanceTypeDetailsRequest(ServiceRequest): + EngineVersion: VersionString + DomainName: Optional[DomainName] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + RetrieveAZs: Optional[Boolean] + InstanceType: Optional[InstanceTypeString] + + +class ListInstanceTypeDetailsResponse(TypedDict, total=False): + InstanceTypeDetails: Optional[InstanceTypeDetailsList] + NextToken: Optional[NextToken] + + +class ListPackagesForDomainRequest(ServiceRequest): + DomainName: DomainName + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListPackagesForDomainResponse(TypedDict, total=False): + DomainPackageDetailsList: Optional[DomainPackageDetailsList] + NextToken: Optional[String] + + +class ListScheduledActionsRequest(ServiceRequest): + DomainName: DomainName + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ScheduledAction(TypedDict, total=False): + Id: String + Type: ActionType + Severity: ActionSeverity + ScheduledTime: Long + Description: Optional[String] + ScheduledBy: Optional[ScheduledBy] + Status: Optional[ActionStatus] + Mandatory: Optional[Boolean] + Cancellable: Optional[Boolean] + + +ScheduledActionsList = List[ScheduledAction] + + +class ListScheduledActionsResponse(TypedDict, total=False): + ScheduledActions: Optional[ScheduledActionsList] + NextToken: Optional[NextToken] + + +class ListTagsRequest(ServiceRequest): + ARN: ARN + + +class ListTagsResponse(TypedDict, total=False): + TagList: Optional[TagList] + + +class ListVersionsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListVersionsResponse(TypedDict, total=False): + Versions: Optional[VersionList] + NextToken: Optional[NextToken] + + +class ListVpcEndpointAccessRequest(ServiceRequest): + DomainName: DomainName + NextToken: Optional[NextToken] + + +class ListVpcEndpointAccessResponse(TypedDict, total=False): + AuthorizedPrincipalList: AuthorizedPrincipalList + NextToken: NextToken + + +class ListVpcEndpointsForDomainRequest(ServiceRequest): + DomainName: DomainName + NextToken: Optional[NextToken] + + +VpcEndpointSummaryList = List[VpcEndpointSummary] + + +class ListVpcEndpointsForDomainResponse(TypedDict, total=False): + VpcEndpointSummaryList: VpcEndpointSummaryList + NextToken: NextToken + + +class ListVpcEndpointsRequest(ServiceRequest): + NextToken: Optional[NextToken] + + +class ListVpcEndpointsResponse(TypedDict, total=False): + VpcEndpointSummaryList: VpcEndpointSummaryList + NextToken: NextToken + + +class PurchaseReservedInstanceOfferingRequest(ServiceRequest): + ReservedInstanceOfferingId: GUID + ReservationName: ReservationToken + InstanceCount: Optional[InstanceCount] + + +class PurchaseReservedInstanceOfferingResponse(TypedDict, total=False): + ReservedInstanceId: Optional[GUID] + ReservationName: Optional[ReservationToken] + + +class RejectInboundConnectionRequest(ServiceRequest): + ConnectionId: ConnectionId + + +class RejectInboundConnectionResponse(TypedDict, total=False): + Connection: Optional[InboundConnection] + + +class RemoveTagsRequest(ServiceRequest): + ARN: ARN + TagKeys: StringList + + +class RevokeVpcEndpointAccessRequest(ServiceRequest): + DomainName: DomainName + Account: Optional[AWSAccount] + Service: Optional[AWSServicePrincipal] + + +class RevokeVpcEndpointAccessResponse(TypedDict, total=False): + pass + + +class StartDomainMaintenanceRequest(ServiceRequest): + DomainName: DomainName + Action: MaintenanceType + NodeId: Optional[NodeId] + + +class StartDomainMaintenanceResponse(TypedDict, total=False): + MaintenanceId: Optional[RequestId] + + +class StartServiceSoftwareUpdateRequest(ServiceRequest): + DomainName: DomainName + ScheduleAt: Optional[ScheduleAt] + DesiredStartTime: Optional[Long] + + +class StartServiceSoftwareUpdateResponse(TypedDict, total=False): + ServiceSoftwareOptions: Optional[ServiceSoftwareOptions] + + +class UpdateApplicationRequest(ServiceRequest): + id: Id + dataSources: Optional[DataSources] + appConfigs: Optional[AppConfigs] + + +class UpdateApplicationResponse(TypedDict, total=False): + id: Optional[Id] + name: Optional[ApplicationName] + arn: Optional[ARN] + dataSources: Optional[DataSources] + iamIdentityCenterOptions: Optional[IamIdentityCenterOptions] + appConfigs: Optional[AppConfigs] + createdAt: Optional[Timestamp] + lastUpdatedAt: Optional[Timestamp] + + +class UpdateDataSourceRequest(ServiceRequest): + DomainName: DomainName + Name: DataSourceName + DataSourceType: DataSourceType + Description: Optional[DataSourceDescription] + Status: Optional[DataSourceStatus] + + +class UpdateDataSourceResponse(TypedDict, total=False): + Message: Optional[String] + + +class UpdateDirectQueryDataSourceRequest(ServiceRequest): + DataSourceName: DirectQueryDataSourceName + DataSourceType: DirectQueryDataSourceType + Description: Optional[DirectQueryDataSourceDescription] + OpenSearchArns: DirectQueryOpenSearchARNList + + +class UpdateDirectQueryDataSourceResponse(TypedDict, total=False): + DataSourceArn: Optional[String] + + +class UpdateDomainConfigRequest(ServiceRequest): + DomainName: DomainName + ClusterConfig: Optional[ClusterConfig] + EBSOptions: Optional[EBSOptions] + SnapshotOptions: Optional[SnapshotOptions] + VPCOptions: Optional[VPCOptions] + CognitoOptions: Optional[CognitoOptions] + AdvancedOptions: Optional[AdvancedOptions] + AccessPolicies: Optional[PolicyDocument] + IPAddressType: Optional[IPAddressType] + LogPublishingOptions: Optional[LogPublishingOptions] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + DomainEndpointOptions: Optional[DomainEndpointOptions] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsInput] + IdentityCenterOptions: Optional[IdentityCenterOptionsInput] + AutoTuneOptions: Optional[AutoTuneOptions] + DryRun: Optional[DryRun] + DryRunMode: Optional[DryRunMode] + OffPeakWindowOptions: Optional[OffPeakWindowOptions] + SoftwareUpdateOptions: Optional[SoftwareUpdateOptions] + AIMLOptions: Optional[AIMLOptionsInput] + + +class UpdateDomainConfigResponse(TypedDict, total=False): + DomainConfig: DomainConfig + DryRunResults: Optional[DryRunResults] + DryRunProgressStatus: Optional[DryRunProgressStatus] + + +class UpdatePackageRequest(ServiceRequest): + PackageID: PackageID + PackageSource: PackageSource + PackageDescription: Optional[PackageDescription] + CommitMessage: Optional[CommitMessage] + PackageConfiguration: Optional[PackageConfiguration] + PackageEncryptionOptions: Optional[PackageEncryptionOptions] + + +class UpdatePackageResponse(TypedDict, total=False): + PackageDetails: Optional[PackageDetails] + + +class UpdatePackageScopeRequest(ServiceRequest): + PackageID: PackageID + Operation: PackageScopeOperationEnum + PackageUserList: PackageUserList + + +class UpdatePackageScopeResponse(TypedDict, total=False): + PackageID: Optional[PackageID] + Operation: Optional[PackageScopeOperationEnum] + PackageUserList: Optional[PackageUserList] + + +class UpdateScheduledActionRequest(ServiceRequest): + DomainName: DomainName + ActionID: String + ActionType: ActionType + ScheduleAt: ScheduleAt + DesiredStartTime: Optional[Long] + + +class UpdateScheduledActionResponse(TypedDict, total=False): + ScheduledAction: Optional[ScheduledAction] + + +class UpdateVpcEndpointRequest(ServiceRequest): + VpcEndpointId: VpcEndpointId + VpcOptions: VPCOptions + + +class UpdateVpcEndpointResponse(TypedDict, total=False): + VpcEndpoint: VpcEndpoint + + +class UpgradeDomainRequest(ServiceRequest): + DomainName: DomainName + TargetVersion: VersionString + PerformCheckOnly: Optional[Boolean] + AdvancedOptions: Optional[AdvancedOptions] + + +class UpgradeDomainResponse(TypedDict, total=False): + UpgradeId: Optional[String] + DomainName: Optional[DomainName] + TargetVersion: Optional[VersionString] + PerformCheckOnly: Optional[Boolean] + AdvancedOptions: Optional[AdvancedOptions] + ChangeProgressDetails: Optional[ChangeProgressDetails] + + +class OpensearchApi: + service = "opensearch" + version = "2021-01-01" + + @handler("AcceptInboundConnection") + def accept_inbound_connection( + self, context: RequestContext, connection_id: ConnectionId, **kwargs + ) -> AcceptInboundConnectionResponse: + raise NotImplementedError + + @handler("AddDataSource") + def add_data_source( + self, + context: RequestContext, + domain_name: DomainName, + name: DataSourceName, + data_source_type: DataSourceType, + description: DataSourceDescription | None = None, + **kwargs, + ) -> AddDataSourceResponse: + raise NotImplementedError + + @handler("AddDirectQueryDataSource") + def add_direct_query_data_source( + self, + context: RequestContext, + data_source_name: DirectQueryDataSourceName, + data_source_type: DirectQueryDataSourceType, + open_search_arns: DirectQueryOpenSearchARNList, + description: DirectQueryDataSourceDescription | None = None, + tag_list: TagList | None = None, + **kwargs, + ) -> AddDirectQueryDataSourceResponse: + raise NotImplementedError + + @handler("AddTags") + def add_tags(self, context: RequestContext, arn: ARN, tag_list: TagList, **kwargs) -> None: + raise NotImplementedError + + @handler("AssociatePackage") + def associate_package( + self, + context: RequestContext, + package_id: PackageID, + domain_name: DomainName, + prerequisite_package_id_list: PackageIDList | None = None, + association_configuration: PackageAssociationConfiguration | None = None, + **kwargs, + ) -> AssociatePackageResponse: + raise NotImplementedError + + @handler("AssociatePackages") + def associate_packages( + self, + context: RequestContext, + package_list: PackageDetailsForAssociationList, + domain_name: DomainName, + **kwargs, + ) -> AssociatePackagesResponse: + raise NotImplementedError + + @handler("AuthorizeVpcEndpointAccess") + def authorize_vpc_endpoint_access( + self, + context: RequestContext, + domain_name: DomainName, + account: AWSAccount | None = None, + service: AWSServicePrincipal | None = None, + **kwargs, + ) -> AuthorizeVpcEndpointAccessResponse: + raise NotImplementedError + + @handler("CancelDomainConfigChange") + def cancel_domain_config_change( + self, + context: RequestContext, + domain_name: DomainName, + dry_run: DryRun | None = None, + **kwargs, + ) -> CancelDomainConfigChangeResponse: + raise NotImplementedError + + @handler("CancelServiceSoftwareUpdate") + def cancel_service_software_update( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> CancelServiceSoftwareUpdateResponse: + raise NotImplementedError + + @handler("CreateApplication") + def create_application( + self, + context: RequestContext, + name: ApplicationName, + client_token: ClientToken | None = None, + data_sources: DataSources | None = None, + iam_identity_center_options: IamIdentityCenterOptionsInput | None = None, + app_configs: AppConfigs | None = None, + tag_list: TagList | None = None, + **kwargs, + ) -> CreateApplicationResponse: + raise NotImplementedError + + @handler("CreateDomain") + def create_domain( + self, + context: RequestContext, + domain_name: DomainName, + engine_version: VersionString | None = None, + cluster_config: ClusterConfig | None = None, + ebs_options: EBSOptions | None = None, + access_policies: PolicyDocument | None = None, + ip_address_type: IPAddressType | None = None, + snapshot_options: SnapshotOptions | None = None, + vpc_options: VPCOptions | None = None, + cognito_options: CognitoOptions | None = None, + encryption_at_rest_options: EncryptionAtRestOptions | None = None, + node_to_node_encryption_options: NodeToNodeEncryptionOptions | None = None, + advanced_options: AdvancedOptions | None = None, + log_publishing_options: LogPublishingOptions | None = None, + domain_endpoint_options: DomainEndpointOptions | None = None, + advanced_security_options: AdvancedSecurityOptionsInput | None = None, + identity_center_options: IdentityCenterOptionsInput | None = None, + tag_list: TagList | None = None, + auto_tune_options: AutoTuneOptionsInput | None = None, + off_peak_window_options: OffPeakWindowOptions | None = None, + software_update_options: SoftwareUpdateOptions | None = None, + aiml_options: AIMLOptionsInput | None = None, + **kwargs, + ) -> CreateDomainResponse: + raise NotImplementedError + + @handler("CreateOutboundConnection") + def create_outbound_connection( + self, + context: RequestContext, + local_domain_info: DomainInformationContainer, + remote_domain_info: DomainInformationContainer, + connection_alias: ConnectionAlias, + connection_mode: ConnectionMode | None = None, + connection_properties: ConnectionProperties | None = None, + **kwargs, + ) -> CreateOutboundConnectionResponse: + raise NotImplementedError + + @handler("CreatePackage") + def create_package( + self, + context: RequestContext, + package_name: PackageName, + package_type: PackageType, + package_source: PackageSource, + package_description: PackageDescription | None = None, + package_configuration: PackageConfiguration | None = None, + engine_version: EngineVersion | None = None, + package_vending_options: PackageVendingOptions | None = None, + package_encryption_options: PackageEncryptionOptions | None = None, + **kwargs, + ) -> CreatePackageResponse: + raise NotImplementedError + + @handler("CreateVpcEndpoint") + def create_vpc_endpoint( + self, + context: RequestContext, + domain_arn: DomainArn, + vpc_options: VPCOptions, + client_token: ClientToken | None = None, + **kwargs, + ) -> CreateVpcEndpointResponse: + raise NotImplementedError + + @handler("DeleteApplication") + def delete_application( + self, context: RequestContext, id: Id, **kwargs + ) -> DeleteApplicationResponse: + raise NotImplementedError + + @handler("DeleteDataSource") + def delete_data_source( + self, context: RequestContext, domain_name: DomainName, name: DataSourceName, **kwargs + ) -> DeleteDataSourceResponse: + raise NotImplementedError + + @handler("DeleteDirectQueryDataSource") + def delete_direct_query_data_source( + self, context: RequestContext, data_source_name: DirectQueryDataSourceName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteDomain") + def delete_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DeleteDomainResponse: + raise NotImplementedError + + @handler("DeleteInboundConnection") + def delete_inbound_connection( + self, context: RequestContext, connection_id: ConnectionId, **kwargs + ) -> DeleteInboundConnectionResponse: + raise NotImplementedError + + @handler("DeleteOutboundConnection") + def delete_outbound_connection( + self, context: RequestContext, connection_id: ConnectionId, **kwargs + ) -> DeleteOutboundConnectionResponse: + raise NotImplementedError + + @handler("DeletePackage") + def delete_package( + self, context: RequestContext, package_id: PackageID, **kwargs + ) -> DeletePackageResponse: + raise NotImplementedError + + @handler("DeleteVpcEndpoint") + def delete_vpc_endpoint( + self, context: RequestContext, vpc_endpoint_id: VpcEndpointId, **kwargs + ) -> DeleteVpcEndpointResponse: + raise NotImplementedError + + @handler("DescribeDomain") + def describe_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeDomainResponse: + raise NotImplementedError + + @handler("DescribeDomainAutoTunes") + def describe_domain_auto_tunes( + self, + context: RequestContext, + domain_name: DomainName, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeDomainAutoTunesResponse: + raise NotImplementedError + + @handler("DescribeDomainChangeProgress") + def describe_domain_change_progress( + self, + context: RequestContext, + domain_name: DomainName, + change_id: GUID | None = None, + **kwargs, + ) -> DescribeDomainChangeProgressResponse: + raise NotImplementedError + + @handler("DescribeDomainConfig") + def describe_domain_config( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeDomainConfigResponse: + raise NotImplementedError + + @handler("DescribeDomainHealth") + def describe_domain_health( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeDomainHealthResponse: + raise NotImplementedError + + @handler("DescribeDomainNodes") + def describe_domain_nodes( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeDomainNodesResponse: + raise NotImplementedError + + @handler("DescribeDomains") + def describe_domains( + self, context: RequestContext, domain_names: DomainNameList, **kwargs + ) -> DescribeDomainsResponse: + raise NotImplementedError + + @handler("DescribeDryRunProgress") + def describe_dry_run_progress( + self, + context: RequestContext, + domain_name: DomainName, + dry_run_id: GUID | None = None, + load_dry_run_config: Boolean | None = None, + **kwargs, + ) -> DescribeDryRunProgressResponse: + raise NotImplementedError + + @handler("DescribeInboundConnections") + def describe_inbound_connections( + self, + context: RequestContext, + filters: FilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeInboundConnectionsResponse: + raise NotImplementedError + + @handler("DescribeInstanceTypeLimits") + def describe_instance_type_limits( + self, + context: RequestContext, + instance_type: OpenSearchPartitionInstanceType, + engine_version: VersionString, + domain_name: DomainName | None = None, + **kwargs, + ) -> DescribeInstanceTypeLimitsResponse: + raise NotImplementedError + + @handler("DescribeOutboundConnections") + def describe_outbound_connections( + self, + context: RequestContext, + filters: FilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeOutboundConnectionsResponse: + raise NotImplementedError + + @handler("DescribePackages") + def describe_packages( + self, + context: RequestContext, + filters: DescribePackagesFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribePackagesResponse: + raise NotImplementedError + + @handler("DescribeReservedInstanceOfferings") + def describe_reserved_instance_offerings( + self, + context: RequestContext, + reserved_instance_offering_id: GUID | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeReservedInstanceOfferingsResponse: + raise NotImplementedError + + @handler("DescribeReservedInstances") + def describe_reserved_instances( + self, + context: RequestContext, + reserved_instance_id: GUID | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeReservedInstancesResponse: + raise NotImplementedError + + @handler("DescribeVpcEndpoints") + def describe_vpc_endpoints( + self, context: RequestContext, vpc_endpoint_ids: VpcEndpointIdList, **kwargs + ) -> DescribeVpcEndpointsResponse: + raise NotImplementedError + + @handler("DissociatePackage") + def dissociate_package( + self, context: RequestContext, package_id: PackageID, domain_name: DomainName, **kwargs + ) -> DissociatePackageResponse: + raise NotImplementedError + + @handler("DissociatePackages") + def dissociate_packages( + self, + context: RequestContext, + package_list: PackageIDList, + domain_name: DomainName, + **kwargs, + ) -> DissociatePackagesResponse: + raise NotImplementedError + + @handler("GetApplication") + def get_application(self, context: RequestContext, id: Id, **kwargs) -> GetApplicationResponse: + raise NotImplementedError + + @handler("GetCompatibleVersions") + def get_compatible_versions( + self, context: RequestContext, domain_name: DomainName | None = None, **kwargs + ) -> GetCompatibleVersionsResponse: + raise NotImplementedError + + @handler("GetDataSource") + def get_data_source( + self, context: RequestContext, domain_name: DomainName, name: DataSourceName, **kwargs + ) -> GetDataSourceResponse: + raise NotImplementedError + + @handler("GetDirectQueryDataSource") + def get_direct_query_data_source( + self, context: RequestContext, data_source_name: DirectQueryDataSourceName, **kwargs + ) -> GetDirectQueryDataSourceResponse: + raise NotImplementedError + + @handler("GetDomainMaintenanceStatus") + def get_domain_maintenance_status( + self, context: RequestContext, domain_name: DomainName, maintenance_id: RequestId, **kwargs + ) -> GetDomainMaintenanceStatusResponse: + raise NotImplementedError + + @handler("GetPackageVersionHistory") + def get_package_version_history( + self, + context: RequestContext, + package_id: PackageID, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetPackageVersionHistoryResponse: + raise NotImplementedError + + @handler("GetUpgradeHistory") + def get_upgrade_history( + self, + context: RequestContext, + domain_name: DomainName, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetUpgradeHistoryResponse: + raise NotImplementedError + + @handler("GetUpgradeStatus") + def get_upgrade_status( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> GetUpgradeStatusResponse: + raise NotImplementedError + + @handler("ListApplications") + def list_applications( + self, + context: RequestContext, + next_token: NextToken | None = None, + statuses: ApplicationStatuses | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListApplicationsResponse: + raise NotImplementedError + + @handler("ListDataSources") + def list_data_sources( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> ListDataSourcesResponse: + raise NotImplementedError + + @handler("ListDirectQueryDataSources") + def list_direct_query_data_sources( + self, context: RequestContext, next_token: NextToken | None = None, **kwargs + ) -> ListDirectQueryDataSourcesResponse: + raise NotImplementedError + + @handler("ListDomainMaintenances") + def list_domain_maintenances( + self, + context: RequestContext, + domain_name: DomainName, + action: MaintenanceType | None = None, + status: MaintenanceStatus | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListDomainMaintenancesResponse: + raise NotImplementedError + + @handler("ListDomainNames") + def list_domain_names( + self, context: RequestContext, engine_type: EngineType | None = None, **kwargs + ) -> ListDomainNamesResponse: + raise NotImplementedError + + @handler("ListDomainsForPackage") + def list_domains_for_package( + self, + context: RequestContext, + package_id: PackageID, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListDomainsForPackageResponse: + raise NotImplementedError + + @handler("ListInstanceTypeDetails") + def list_instance_type_details( + self, + context: RequestContext, + engine_version: VersionString, + domain_name: DomainName | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + retrieve_azs: Boolean | None = None, + instance_type: InstanceTypeString | None = None, + **kwargs, + ) -> ListInstanceTypeDetailsResponse: + raise NotImplementedError + + @handler("ListPackagesForDomain") + def list_packages_for_domain( + self, + context: RequestContext, + domain_name: DomainName, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListPackagesForDomainResponse: + raise NotImplementedError + + @handler("ListScheduledActions") + def list_scheduled_actions( + self, + context: RequestContext, + domain_name: DomainName, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListScheduledActionsResponse: + raise NotImplementedError + + @handler("ListTags") + def list_tags(self, context: RequestContext, arn: ARN, **kwargs) -> ListTagsResponse: + raise NotImplementedError + + @handler("ListVersions") + def list_versions( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListVersionsResponse: + raise NotImplementedError + + @handler("ListVpcEndpointAccess") + def list_vpc_endpoint_access( + self, + context: RequestContext, + domain_name: DomainName, + next_token: NextToken | None = None, + **kwargs, + ) -> ListVpcEndpointAccessResponse: + raise NotImplementedError + + @handler("ListVpcEndpoints") + def list_vpc_endpoints( + self, context: RequestContext, next_token: NextToken | None = None, **kwargs + ) -> ListVpcEndpointsResponse: + raise NotImplementedError + + @handler("ListVpcEndpointsForDomain") + def list_vpc_endpoints_for_domain( + self, + context: RequestContext, + domain_name: DomainName, + next_token: NextToken | None = None, + **kwargs, + ) -> ListVpcEndpointsForDomainResponse: + raise NotImplementedError + + @handler("PurchaseReservedInstanceOffering") + def purchase_reserved_instance_offering( + self, + context: RequestContext, + reserved_instance_offering_id: GUID, + reservation_name: ReservationToken, + instance_count: InstanceCount | None = None, + **kwargs, + ) -> PurchaseReservedInstanceOfferingResponse: + raise NotImplementedError + + @handler("RejectInboundConnection") + def reject_inbound_connection( + self, context: RequestContext, connection_id: ConnectionId, **kwargs + ) -> RejectInboundConnectionResponse: + raise NotImplementedError + + @handler("RemoveTags") + def remove_tags( + self, context: RequestContext, arn: ARN, tag_keys: StringList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("RevokeVpcEndpointAccess") + def revoke_vpc_endpoint_access( + self, + context: RequestContext, + domain_name: DomainName, + account: AWSAccount | None = None, + service: AWSServicePrincipal | None = None, + **kwargs, + ) -> RevokeVpcEndpointAccessResponse: + raise NotImplementedError + + @handler("StartDomainMaintenance") + def start_domain_maintenance( + self, + context: RequestContext, + domain_name: DomainName, + action: MaintenanceType, + node_id: NodeId | None = None, + **kwargs, + ) -> StartDomainMaintenanceResponse: + raise NotImplementedError + + @handler("StartServiceSoftwareUpdate") + def start_service_software_update( + self, + context: RequestContext, + domain_name: DomainName, + schedule_at: ScheduleAt | None = None, + desired_start_time: Long | None = None, + **kwargs, + ) -> StartServiceSoftwareUpdateResponse: + raise NotImplementedError + + @handler("UpdateApplication") + def update_application( + self, + context: RequestContext, + id: Id, + data_sources: DataSources | None = None, + app_configs: AppConfigs | None = None, + **kwargs, + ) -> UpdateApplicationResponse: + raise NotImplementedError + + @handler("UpdateDataSource") + def update_data_source( + self, + context: RequestContext, + domain_name: DomainName, + name: DataSourceName, + data_source_type: DataSourceType, + description: DataSourceDescription | None = None, + status: DataSourceStatus | None = None, + **kwargs, + ) -> UpdateDataSourceResponse: + raise NotImplementedError + + @handler("UpdateDirectQueryDataSource") + def update_direct_query_data_source( + self, + context: RequestContext, + data_source_name: DirectQueryDataSourceName, + data_source_type: DirectQueryDataSourceType, + open_search_arns: DirectQueryOpenSearchARNList, + description: DirectQueryDataSourceDescription | None = None, + **kwargs, + ) -> UpdateDirectQueryDataSourceResponse: + raise NotImplementedError + + @handler("UpdateDomainConfig") + def update_domain_config( + self, + context: RequestContext, + domain_name: DomainName, + cluster_config: ClusterConfig | None = None, + ebs_options: EBSOptions | None = None, + snapshot_options: SnapshotOptions | None = None, + vpc_options: VPCOptions | None = None, + cognito_options: CognitoOptions | None = None, + advanced_options: AdvancedOptions | None = None, + access_policies: PolicyDocument | None = None, + ip_address_type: IPAddressType | None = None, + log_publishing_options: LogPublishingOptions | None = None, + encryption_at_rest_options: EncryptionAtRestOptions | None = None, + domain_endpoint_options: DomainEndpointOptions | None = None, + node_to_node_encryption_options: NodeToNodeEncryptionOptions | None = None, + advanced_security_options: AdvancedSecurityOptionsInput | None = None, + identity_center_options: IdentityCenterOptionsInput | None = None, + auto_tune_options: AutoTuneOptions | None = None, + dry_run: DryRun | None = None, + dry_run_mode: DryRunMode | None = None, + off_peak_window_options: OffPeakWindowOptions | None = None, + software_update_options: SoftwareUpdateOptions | None = None, + aiml_options: AIMLOptionsInput | None = None, + **kwargs, + ) -> UpdateDomainConfigResponse: + raise NotImplementedError + + @handler("UpdatePackage") + def update_package( + self, + context: RequestContext, + package_id: PackageID, + package_source: PackageSource, + package_description: PackageDescription | None = None, + commit_message: CommitMessage | None = None, + package_configuration: PackageConfiguration | None = None, + package_encryption_options: PackageEncryptionOptions | None = None, + **kwargs, + ) -> UpdatePackageResponse: + raise NotImplementedError + + @handler("UpdatePackageScope") + def update_package_scope( + self, + context: RequestContext, + package_id: PackageID, + operation: PackageScopeOperationEnum, + package_user_list: PackageUserList, + **kwargs, + ) -> UpdatePackageScopeResponse: + raise NotImplementedError + + @handler("UpdateScheduledAction") + def update_scheduled_action( + self, + context: RequestContext, + domain_name: DomainName, + action_id: String, + action_type: ActionType, + schedule_at: ScheduleAt, + desired_start_time: Long | None = None, + **kwargs, + ) -> UpdateScheduledActionResponse: + raise NotImplementedError + + @handler("UpdateVpcEndpoint") + def update_vpc_endpoint( + self, + context: RequestContext, + vpc_endpoint_id: VpcEndpointId, + vpc_options: VPCOptions, + **kwargs, + ) -> UpdateVpcEndpointResponse: + raise NotImplementedError + + @handler("UpgradeDomain") + def upgrade_domain( + self, + context: RequestContext, + domain_name: DomainName, + target_version: VersionString, + perform_check_only: Boolean | None = None, + advanced_options: AdvancedOptions | None = None, + **kwargs, + ) -> UpgradeDomainResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/pipes/__init__.py b/localstack-core/localstack/aws/api/pipes/__init__.py new file mode 100644 index 0000000000000..6fe68d846fa23 --- /dev/null +++ b/localstack-core/localstack/aws/api/pipes/__init__.py @@ -0,0 +1,1114 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Arn = str +ArnOrJsonPath = str +ArnOrUrl = str +BatchArraySize = int +BatchRetryAttempts = int +Boolean = bool +CapacityProvider = str +CapacityProviderStrategyItemBase = int +CapacityProviderStrategyItemWeight = int +CloudwatchLogGroupArn = str +Database = str +DbUser = str +DimensionName = str +DimensionValue = str +EndpointString = str +EphemeralStorageSize = int +ErrorMessage = str +EventBridgeDetailType = str +EventBridgeEndpointId = str +EventBridgeEventSource = str +EventPattern = str +FirehoseArn = str +HeaderKey = str +HeaderValue = str +InputTemplate = str +Integer = int +JsonPath = str +KafkaTopicName = str +KinesisPartitionKey = str +KmsKeyIdentifier = str +LimitMax10 = int +LimitMax100 = int +LimitMax10000 = int +LimitMin1 = int +LogStreamName = str +MQBrokerQueueName = str +MaximumBatchingWindowInSeconds = int +MaximumRecordAgeInSeconds = int +MaximumRetryAttemptsESM = int +MeasureName = str +MeasureValue = str +MessageDeduplicationId = str +MessageGroupId = str +MultiMeasureAttributeName = str +MultiMeasureName = str +NextToken = str +OptionalArn = str +PathParameter = str +PipeArn = str +PipeDescription = str +PipeName = str +PipeStateReason = str +PlacementConstraintExpression = str +PlacementStrategyField = str +QueryStringKey = str +QueryStringValue = str +ReferenceId = str +ResourceArn = str +RoleArn = str +S3LogDestinationParametersBucketNameString = str +S3LogDestinationParametersBucketOwnerString = str +S3LogDestinationParametersPrefixString = str +SageMakerPipelineParameterName = str +SageMakerPipelineParameterValue = str +SecretManagerArn = str +SecretManagerArnOrJsonPath = str +SecurityGroup = str +SecurityGroupId = str +Sql = str +StatementName = str +String = str +Subnet = str +SubnetId = str +TagKey = str +TagValue = str +TimeValue = str +TimestampFormat = str +URI = str +VersionValue = str + + +class AssignPublicIp(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class BatchJobDependencyType(StrEnum): + N_TO_N = "N_TO_N" + SEQUENTIAL = "SEQUENTIAL" + + +class BatchResourceRequirementType(StrEnum): + GPU = "GPU" + MEMORY = "MEMORY" + VCPU = "VCPU" + + +class DimensionValueType(StrEnum): + VARCHAR = "VARCHAR" + + +class DynamoDBStreamStartPosition(StrEnum): + TRIM_HORIZON = "TRIM_HORIZON" + LATEST = "LATEST" + + +class EcsEnvironmentFileType(StrEnum): + s3 = "s3" + + +class EcsResourceRequirementType(StrEnum): + GPU = "GPU" + InferenceAccelerator = "InferenceAccelerator" + + +class EpochTimeUnit(StrEnum): + MILLISECONDS = "MILLISECONDS" + SECONDS = "SECONDS" + MICROSECONDS = "MICROSECONDS" + NANOSECONDS = "NANOSECONDS" + + +class IncludeExecutionDataOption(StrEnum): + ALL = "ALL" + + +class KinesisStreamStartPosition(StrEnum): + TRIM_HORIZON = "TRIM_HORIZON" + LATEST = "LATEST" + AT_TIMESTAMP = "AT_TIMESTAMP" + + +class LaunchType(StrEnum): + EC2 = "EC2" + FARGATE = "FARGATE" + EXTERNAL = "EXTERNAL" + + +class LogLevel(StrEnum): + OFF = "OFF" + ERROR = "ERROR" + INFO = "INFO" + TRACE = "TRACE" + + +class MSKStartPosition(StrEnum): + TRIM_HORIZON = "TRIM_HORIZON" + LATEST = "LATEST" + + +class MeasureValueType(StrEnum): + DOUBLE = "DOUBLE" + BIGINT = "BIGINT" + VARCHAR = "VARCHAR" + BOOLEAN = "BOOLEAN" + TIMESTAMP = "TIMESTAMP" + + +class OnPartialBatchItemFailureStreams(StrEnum): + AUTOMATIC_BISECT = "AUTOMATIC_BISECT" + + +class PipeState(StrEnum): + RUNNING = "RUNNING" + STOPPED = "STOPPED" + CREATING = "CREATING" + UPDATING = "UPDATING" + DELETING = "DELETING" + STARTING = "STARTING" + STOPPING = "STOPPING" + CREATE_FAILED = "CREATE_FAILED" + UPDATE_FAILED = "UPDATE_FAILED" + START_FAILED = "START_FAILED" + STOP_FAILED = "STOP_FAILED" + DELETE_FAILED = "DELETE_FAILED" + CREATE_ROLLBACK_FAILED = "CREATE_ROLLBACK_FAILED" + DELETE_ROLLBACK_FAILED = "DELETE_ROLLBACK_FAILED" + UPDATE_ROLLBACK_FAILED = "UPDATE_ROLLBACK_FAILED" + + +class PipeTargetInvocationType(StrEnum): + REQUEST_RESPONSE = "REQUEST_RESPONSE" + FIRE_AND_FORGET = "FIRE_AND_FORGET" + + +class PlacementConstraintType(StrEnum): + distinctInstance = "distinctInstance" + memberOf = "memberOf" + + +class PlacementStrategyType(StrEnum): + random = "random" + spread = "spread" + binpack = "binpack" + + +class PropagateTags(StrEnum): + TASK_DEFINITION = "TASK_DEFINITION" + + +class RequestedPipeState(StrEnum): + RUNNING = "RUNNING" + STOPPED = "STOPPED" + + +class RequestedPipeStateDescribeResponse(StrEnum): + RUNNING = "RUNNING" + STOPPED = "STOPPED" + DELETED = "DELETED" + + +class S3OutputFormat(StrEnum): + json = "json" + plain = "plain" + w3c = "w3c" + + +class SelfManagedKafkaStartPosition(StrEnum): + TRIM_HORIZON = "TRIM_HORIZON" + LATEST = "LATEST" + + +class TimeFieldType(StrEnum): + EPOCH = "EPOCH" + TIMESTAMP_FORMAT = "TIMESTAMP_FORMAT" + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = True + status_code: int = 409 + resourceId: String + resourceType: String + + +class InternalException(ServiceException): + code: str = "InternalException" + sender_fault: bool = False + status_code: int = 500 + retryAfterSeconds: Optional[Integer] + + +class NotFoundException(ServiceException): + code: str = "NotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class ServiceQuotaExceededException(ServiceException): + code: str = "ServiceQuotaExceededException" + sender_fault: bool = True + status_code: int = 402 + resourceId: String + resourceType: String + serviceCode: String + quotaCode: String + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = True + status_code: int = 429 + serviceCode: Optional[String] + quotaCode: Optional[String] + retryAfterSeconds: Optional[Integer] + + +class ValidationExceptionField(TypedDict, total=False): + name: String + message: ErrorMessage + + +ValidationExceptionFieldList = List[ValidationExceptionField] + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = True + status_code: int = 400 + fieldList: Optional[ValidationExceptionFieldList] + + +SecurityGroups = List[SecurityGroup] +Subnets = List[Subnet] + + +class AwsVpcConfiguration(TypedDict, total=False): + Subnets: Subnets + SecurityGroups: Optional[SecurityGroups] + AssignPublicIp: Optional[AssignPublicIp] + + +class BatchArrayProperties(TypedDict, total=False): + Size: Optional[BatchArraySize] + + +class BatchResourceRequirement(TypedDict, total=False): + Type: BatchResourceRequirementType + Value: String + + +BatchResourceRequirementsList = List[BatchResourceRequirement] + + +class BatchEnvironmentVariable(TypedDict, total=False): + Name: Optional[String] + Value: Optional[String] + + +BatchEnvironmentVariableList = List[BatchEnvironmentVariable] +StringList = List[String] + + +class BatchContainerOverrides(TypedDict, total=False): + Command: Optional[StringList] + Environment: Optional[BatchEnvironmentVariableList] + InstanceType: Optional[String] + ResourceRequirements: Optional[BatchResourceRequirementsList] + + +class BatchJobDependency(TypedDict, total=False): + JobId: Optional[String] + Type: Optional[BatchJobDependencyType] + + +BatchDependsOn = List[BatchJobDependency] +BatchParametersMap = Dict[String, String] + + +class BatchRetryStrategy(TypedDict, total=False): + Attempts: Optional[BatchRetryAttempts] + + +class CapacityProviderStrategyItem(TypedDict, total=False): + capacityProvider: CapacityProvider + weight: Optional[CapacityProviderStrategyItemWeight] + base: Optional[CapacityProviderStrategyItemBase] + + +CapacityProviderStrategy = List[CapacityProviderStrategyItem] + + +class CloudwatchLogsLogDestination(TypedDict, total=False): + LogGroupArn: Optional[CloudwatchLogGroupArn] + + +class CloudwatchLogsLogDestinationParameters(TypedDict, total=False): + LogGroupArn: CloudwatchLogGroupArn + + +IncludeExecutionData = List[IncludeExecutionDataOption] + + +class FirehoseLogDestinationParameters(TypedDict, total=False): + DeliveryStreamArn: FirehoseArn + + +class S3LogDestinationParameters(TypedDict, total=False): + BucketName: S3LogDestinationParametersBucketNameString + BucketOwner: S3LogDestinationParametersBucketOwnerString + OutputFormat: Optional[S3OutputFormat] + Prefix: Optional[S3LogDestinationParametersPrefixString] + + +class PipeLogConfigurationParameters(TypedDict, total=False): + S3LogDestination: Optional[S3LogDestinationParameters] + FirehoseLogDestination: Optional[FirehoseLogDestinationParameters] + CloudwatchLogsLogDestination: Optional[CloudwatchLogsLogDestinationParameters] + Level: LogLevel + IncludeExecutionData: Optional[IncludeExecutionData] + + +TagMap = Dict[TagKey, TagValue] + + +class MultiMeasureAttributeMapping(TypedDict, total=False): + MeasureValue: MeasureValue + MeasureValueType: MeasureValueType + MultiMeasureAttributeName: MultiMeasureAttributeName + + +MultiMeasureAttributeMappings = List[MultiMeasureAttributeMapping] + + +class MultiMeasureMapping(TypedDict, total=False): + MultiMeasureName: MultiMeasureName + MultiMeasureAttributeMappings: MultiMeasureAttributeMappings + + +MultiMeasureMappings = List[MultiMeasureMapping] + + +class SingleMeasureMapping(TypedDict, total=False): + MeasureValue: MeasureValue + MeasureValueType: MeasureValueType + MeasureName: MeasureName + + +SingleMeasureMappings = List[SingleMeasureMapping] + + +class DimensionMapping(TypedDict, total=False): + DimensionValue: DimensionValue + DimensionValueType: DimensionValueType + DimensionName: DimensionName + + +DimensionMappings = List[DimensionMapping] + + +class PipeTargetTimestreamParameters(TypedDict, total=False): + TimeValue: TimeValue + EpochTimeUnit: Optional[EpochTimeUnit] + TimeFieldType: Optional[TimeFieldType] + TimestampFormat: Optional[TimestampFormat] + VersionValue: VersionValue + DimensionMappings: DimensionMappings + SingleMeasureMappings: Optional[SingleMeasureMappings] + MultiMeasureMappings: Optional[MultiMeasureMappings] + + +class PipeTargetCloudWatchLogsParameters(TypedDict, total=False): + LogStreamName: Optional[LogStreamName] + Timestamp: Optional[JsonPath] + + +EventBridgeEventResourceList = List[ArnOrJsonPath] + + +class PipeTargetEventBridgeEventBusParameters(TypedDict, total=False): + EndpointId: Optional[EventBridgeEndpointId] + DetailType: Optional[EventBridgeDetailType] + Source: Optional[EventBridgeEventSource] + Resources: Optional[EventBridgeEventResourceList] + Time: Optional[JsonPath] + + +class SageMakerPipelineParameter(TypedDict, total=False): + Name: SageMakerPipelineParameterName + Value: SageMakerPipelineParameterValue + + +SageMakerPipelineParameterList = List[SageMakerPipelineParameter] + + +class PipeTargetSageMakerPipelineParameters(TypedDict, total=False): + PipelineParameterList: Optional[SageMakerPipelineParameterList] + + +Sqls = List[Sql] + + +class PipeTargetRedshiftDataParameters(TypedDict, total=False): + SecretManagerArn: Optional[SecretManagerArnOrJsonPath] + Database: Database + DbUser: Optional[DbUser] + StatementName: Optional[StatementName] + WithEvent: Optional[Boolean] + Sqls: Sqls + + +QueryStringParametersMap = Dict[QueryStringKey, QueryStringValue] +HeaderParametersMap = Dict[HeaderKey, HeaderValue] +PathParameterList = List[PathParameter] + + +class PipeTargetHttpParameters(TypedDict, total=False): + PathParameterValues: Optional[PathParameterList] + HeaderParameters: Optional[HeaderParametersMap] + QueryStringParameters: Optional[QueryStringParametersMap] + + +class PipeTargetSqsQueueParameters(TypedDict, total=False): + MessageGroupId: Optional[MessageGroupId] + MessageDeduplicationId: Optional[MessageDeduplicationId] + + +class PipeTargetBatchJobParameters(TypedDict, total=False): + JobDefinition: String + JobName: String + ArrayProperties: Optional[BatchArrayProperties] + RetryStrategy: Optional[BatchRetryStrategy] + ContainerOverrides: Optional[BatchContainerOverrides] + DependsOn: Optional[BatchDependsOn] + Parameters: Optional[BatchParametersMap] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class EcsInferenceAcceleratorOverride(TypedDict, total=False): + deviceName: Optional[String] + deviceType: Optional[String] + + +EcsInferenceAcceleratorOverrideList = List[EcsInferenceAcceleratorOverride] + + +class EcsEphemeralStorage(TypedDict, total=False): + sizeInGiB: EphemeralStorageSize + + +EcsResourceRequirement = TypedDict( + "EcsResourceRequirement", + { + "type": EcsResourceRequirementType, + "value": String, + }, + total=False, +) +EcsResourceRequirementsList = List[EcsResourceRequirement] +EcsEnvironmentFile = TypedDict( + "EcsEnvironmentFile", + { + "type": EcsEnvironmentFileType, + "value": String, + }, + total=False, +) +EcsEnvironmentFileList = List[EcsEnvironmentFile] + + +class EcsEnvironmentVariable(TypedDict, total=False): + name: Optional[String] + value: Optional[String] + + +EcsEnvironmentVariableList = List[EcsEnvironmentVariable] + + +class EcsContainerOverride(TypedDict, total=False): + Command: Optional[StringList] + Cpu: Optional[Integer] + Environment: Optional[EcsEnvironmentVariableList] + EnvironmentFiles: Optional[EcsEnvironmentFileList] + Memory: Optional[Integer] + MemoryReservation: Optional[Integer] + Name: Optional[String] + ResourceRequirements: Optional[EcsResourceRequirementsList] + + +EcsContainerOverrideList = List[EcsContainerOverride] + + +class EcsTaskOverride(TypedDict, total=False): + ContainerOverrides: Optional[EcsContainerOverrideList] + Cpu: Optional[String] + EphemeralStorage: Optional[EcsEphemeralStorage] + ExecutionRoleArn: Optional[ArnOrJsonPath] + InferenceAcceleratorOverrides: Optional[EcsInferenceAcceleratorOverrideList] + Memory: Optional[String] + TaskRoleArn: Optional[ArnOrJsonPath] + + +PlacementStrategy = TypedDict( + "PlacementStrategy", + { + "type": Optional[PlacementStrategyType], + "field": Optional[PlacementStrategyField], + }, + total=False, +) +PlacementStrategies = List[PlacementStrategy] +PlacementConstraint = TypedDict( + "PlacementConstraint", + { + "type": Optional[PlacementConstraintType], + "expression": Optional[PlacementConstraintExpression], + }, + total=False, +) +PlacementConstraints = List[PlacementConstraint] + + +class NetworkConfiguration(TypedDict, total=False): + awsvpcConfiguration: Optional[AwsVpcConfiguration] + + +class PipeTargetEcsTaskParameters(TypedDict, total=False): + TaskDefinitionArn: ArnOrJsonPath + TaskCount: Optional[LimitMin1] + LaunchType: Optional[LaunchType] + NetworkConfiguration: Optional[NetworkConfiguration] + PlatformVersion: Optional[String] + Group: Optional[String] + CapacityProviderStrategy: Optional[CapacityProviderStrategy] + EnableECSManagedTags: Optional[Boolean] + EnableExecuteCommand: Optional[Boolean] + PlacementConstraints: Optional[PlacementConstraints] + PlacementStrategy: Optional[PlacementStrategies] + PropagateTags: Optional[PropagateTags] + ReferenceId: Optional[ReferenceId] + Overrides: Optional[EcsTaskOverride] + Tags: Optional[TagList] + + +class PipeTargetKinesisStreamParameters(TypedDict, total=False): + PartitionKey: KinesisPartitionKey + + +class PipeTargetStateMachineParameters(TypedDict, total=False): + InvocationType: Optional[PipeTargetInvocationType] + + +class PipeTargetLambdaFunctionParameters(TypedDict, total=False): + InvocationType: Optional[PipeTargetInvocationType] + + +class PipeTargetParameters(TypedDict, total=False): + InputTemplate: Optional[InputTemplate] + LambdaFunctionParameters: Optional[PipeTargetLambdaFunctionParameters] + StepFunctionStateMachineParameters: Optional[PipeTargetStateMachineParameters] + KinesisStreamParameters: Optional[PipeTargetKinesisStreamParameters] + EcsTaskParameters: Optional[PipeTargetEcsTaskParameters] + BatchJobParameters: Optional[PipeTargetBatchJobParameters] + SqsQueueParameters: Optional[PipeTargetSqsQueueParameters] + HttpParameters: Optional[PipeTargetHttpParameters] + RedshiftDataParameters: Optional[PipeTargetRedshiftDataParameters] + SageMakerPipelineParameters: Optional[PipeTargetSageMakerPipelineParameters] + EventBridgeEventBusParameters: Optional[PipeTargetEventBridgeEventBusParameters] + CloudWatchLogsParameters: Optional[PipeTargetCloudWatchLogsParameters] + TimestreamParameters: Optional[PipeTargetTimestreamParameters] + + +class PipeEnrichmentHttpParameters(TypedDict, total=False): + PathParameterValues: Optional[PathParameterList] + HeaderParameters: Optional[HeaderParametersMap] + QueryStringParameters: Optional[QueryStringParametersMap] + + +class PipeEnrichmentParameters(TypedDict, total=False): + InputTemplate: Optional[InputTemplate] + HttpParameters: Optional[PipeEnrichmentHttpParameters] + + +SecurityGroupIds = List[SecurityGroupId] +SubnetIds = List[SubnetId] + + +class SelfManagedKafkaAccessConfigurationVpc(TypedDict, total=False): + Subnets: Optional[SubnetIds] + SecurityGroup: Optional[SecurityGroupIds] + + +class SelfManagedKafkaAccessConfigurationCredentials(TypedDict, total=False): + BasicAuth: Optional[SecretManagerArn] + SaslScram512Auth: Optional[SecretManagerArn] + SaslScram256Auth: Optional[SecretManagerArn] + ClientCertificateTlsAuth: Optional[SecretManagerArn] + + +KafkaBootstrapServers = List[EndpointString] + + +class PipeSourceSelfManagedKafkaParameters(TypedDict, total=False): + TopicName: KafkaTopicName + StartingPosition: Optional[SelfManagedKafkaStartPosition] + AdditionalBootstrapServers: Optional[KafkaBootstrapServers] + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + ConsumerGroupID: Optional[URI] + Credentials: Optional[SelfManagedKafkaAccessConfigurationCredentials] + ServerRootCaCertificate: Optional[SecretManagerArn] + Vpc: Optional[SelfManagedKafkaAccessConfigurationVpc] + + +class MSKAccessCredentials(TypedDict, total=False): + SaslScram512Auth: Optional[SecretManagerArn] + ClientCertificateTlsAuth: Optional[SecretManagerArn] + + +class PipeSourceManagedStreamingKafkaParameters(TypedDict, total=False): + TopicName: KafkaTopicName + StartingPosition: Optional[MSKStartPosition] + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + ConsumerGroupID: Optional[URI] + Credentials: Optional[MSKAccessCredentials] + + +class MQBrokerAccessCredentials(TypedDict, total=False): + BasicAuth: Optional[SecretManagerArn] + + +class PipeSourceRabbitMQBrokerParameters(TypedDict, total=False): + Credentials: MQBrokerAccessCredentials + QueueName: MQBrokerQueueName + VirtualHost: Optional[URI] + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + + +class PipeSourceActiveMQBrokerParameters(TypedDict, total=False): + Credentials: MQBrokerAccessCredentials + QueueName: MQBrokerQueueName + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + + +class PipeSourceSqsQueueParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + + +class DeadLetterConfig(TypedDict, total=False): + Arn: Optional[Arn] + + +class PipeSourceDynamoDBStreamParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + DeadLetterConfig: Optional[DeadLetterConfig] + OnPartialBatchItemFailure: Optional[OnPartialBatchItemFailureStreams] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + MaximumRecordAgeInSeconds: Optional[MaximumRecordAgeInSeconds] + MaximumRetryAttempts: Optional[MaximumRetryAttemptsESM] + ParallelizationFactor: Optional[LimitMax10] + StartingPosition: DynamoDBStreamStartPosition + + +Timestamp = datetime + + +class PipeSourceKinesisStreamParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + DeadLetterConfig: Optional[DeadLetterConfig] + OnPartialBatchItemFailure: Optional[OnPartialBatchItemFailureStreams] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + MaximumRecordAgeInSeconds: Optional[MaximumRecordAgeInSeconds] + MaximumRetryAttempts: Optional[MaximumRetryAttemptsESM] + ParallelizationFactor: Optional[LimitMax10] + StartingPosition: KinesisStreamStartPosition + StartingPositionTimestamp: Optional[Timestamp] + + +class Filter(TypedDict, total=False): + Pattern: Optional[EventPattern] + + +FilterList = List[Filter] + + +class FilterCriteria(TypedDict, total=False): + Filters: Optional[FilterList] + + +class PipeSourceParameters(TypedDict, total=False): + FilterCriteria: Optional[FilterCriteria] + KinesisStreamParameters: Optional[PipeSourceKinesisStreamParameters] + DynamoDBStreamParameters: Optional[PipeSourceDynamoDBStreamParameters] + SqsQueueParameters: Optional[PipeSourceSqsQueueParameters] + ActiveMQBrokerParameters: Optional[PipeSourceActiveMQBrokerParameters] + RabbitMQBrokerParameters: Optional[PipeSourceRabbitMQBrokerParameters] + ManagedStreamingKafkaParameters: Optional[PipeSourceManagedStreamingKafkaParameters] + SelfManagedKafkaParameters: Optional[PipeSourceSelfManagedKafkaParameters] + + +class CreatePipeRequest(ServiceRequest): + Name: PipeName + Description: Optional[PipeDescription] + DesiredState: Optional[RequestedPipeState] + Source: ArnOrUrl + SourceParameters: Optional[PipeSourceParameters] + Enrichment: Optional[OptionalArn] + EnrichmentParameters: Optional[PipeEnrichmentParameters] + Target: Arn + TargetParameters: Optional[PipeTargetParameters] + RoleArn: RoleArn + Tags: Optional[TagMap] + LogConfiguration: Optional[PipeLogConfigurationParameters] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + + +class CreatePipeResponse(TypedDict, total=False): + Arn: Optional[PipeArn] + Name: Optional[PipeName] + DesiredState: Optional[RequestedPipeState] + CurrentState: Optional[PipeState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class DeletePipeRequest(ServiceRequest): + Name: PipeName + + +class DeletePipeResponse(TypedDict, total=False): + Arn: Optional[PipeArn] + Name: Optional[PipeName] + DesiredState: Optional[RequestedPipeStateDescribeResponse] + CurrentState: Optional[PipeState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class DescribePipeRequest(ServiceRequest): + Name: PipeName + + +class FirehoseLogDestination(TypedDict, total=False): + DeliveryStreamArn: Optional[FirehoseArn] + + +class S3LogDestination(TypedDict, total=False): + BucketName: Optional[String] + Prefix: Optional[String] + BucketOwner: Optional[String] + OutputFormat: Optional[S3OutputFormat] + + +class PipeLogConfiguration(TypedDict, total=False): + S3LogDestination: Optional[S3LogDestination] + FirehoseLogDestination: Optional[FirehoseLogDestination] + CloudwatchLogsLogDestination: Optional[CloudwatchLogsLogDestination] + Level: Optional[LogLevel] + IncludeExecutionData: Optional[IncludeExecutionData] + + +class DescribePipeResponse(TypedDict, total=False): + Arn: Optional[PipeArn] + Name: Optional[PipeName] + Description: Optional[PipeDescription] + DesiredState: Optional[RequestedPipeStateDescribeResponse] + CurrentState: Optional[PipeState] + StateReason: Optional[PipeStateReason] + Source: Optional[ArnOrUrl] + SourceParameters: Optional[PipeSourceParameters] + Enrichment: Optional[OptionalArn] + EnrichmentParameters: Optional[PipeEnrichmentParameters] + Target: Optional[Arn] + TargetParameters: Optional[PipeTargetParameters] + RoleArn: Optional[RoleArn] + Tags: Optional[TagMap] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + LogConfiguration: Optional[PipeLogConfiguration] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + + +class ListPipesRequest(ServiceRequest): + NamePrefix: Optional[PipeName] + DesiredState: Optional[RequestedPipeState] + CurrentState: Optional[PipeState] + SourcePrefix: Optional[ResourceArn] + TargetPrefix: Optional[ResourceArn] + NextToken: Optional[NextToken] + Limit: Optional[LimitMax100] + + +class Pipe(TypedDict, total=False): + Name: Optional[PipeName] + Arn: Optional[PipeArn] + DesiredState: Optional[RequestedPipeState] + CurrentState: Optional[PipeState] + StateReason: Optional[PipeStateReason] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + Source: Optional[ArnOrUrl] + Target: Optional[Arn] + Enrichment: Optional[OptionalArn] + + +PipeList = List[Pipe] + + +class ListPipesResponse(TypedDict, total=False): + Pipes: Optional[PipeList] + NextToken: Optional[NextToken] + + +class ListTagsForResourceRequest(ServiceRequest): + resourceArn: PipeArn + + +class ListTagsForResourceResponse(TypedDict, total=False): + tags: Optional[TagMap] + + +class StartPipeRequest(ServiceRequest): + Name: PipeName + + +class StartPipeResponse(TypedDict, total=False): + Arn: Optional[PipeArn] + Name: Optional[PipeName] + DesiredState: Optional[RequestedPipeState] + CurrentState: Optional[PipeState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class StopPipeRequest(ServiceRequest): + Name: PipeName + + +class StopPipeResponse(TypedDict, total=False): + Arn: Optional[PipeArn] + Name: Optional[PipeName] + DesiredState: Optional[RequestedPipeState] + CurrentState: Optional[PipeState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +TagKeyList = List[TagKey] + + +class TagResourceRequest(ServiceRequest): + resourceArn: PipeArn + tags: TagMap + + +class TagResourceResponse(TypedDict, total=False): + pass + + +class UntagResourceRequest(ServiceRequest): + resourceArn: PipeArn + tagKeys: TagKeyList + + +class UntagResourceResponse(TypedDict, total=False): + pass + + +class UpdatePipeSourceSelfManagedKafkaParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + Credentials: Optional[SelfManagedKafkaAccessConfigurationCredentials] + ServerRootCaCertificate: Optional[SecretManagerArn] + Vpc: Optional[SelfManagedKafkaAccessConfigurationVpc] + + +class UpdatePipeSourceManagedStreamingKafkaParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + Credentials: Optional[MSKAccessCredentials] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + + +class UpdatePipeSourceRabbitMQBrokerParameters(TypedDict, total=False): + Credentials: MQBrokerAccessCredentials + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + + +class UpdatePipeSourceActiveMQBrokerParameters(TypedDict, total=False): + Credentials: MQBrokerAccessCredentials + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + + +class UpdatePipeSourceSqsQueueParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + + +class UpdatePipeSourceDynamoDBStreamParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + DeadLetterConfig: Optional[DeadLetterConfig] + OnPartialBatchItemFailure: Optional[OnPartialBatchItemFailureStreams] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + MaximumRecordAgeInSeconds: Optional[MaximumRecordAgeInSeconds] + MaximumRetryAttempts: Optional[MaximumRetryAttemptsESM] + ParallelizationFactor: Optional[LimitMax10] + + +class UpdatePipeSourceKinesisStreamParameters(TypedDict, total=False): + BatchSize: Optional[LimitMax10000] + DeadLetterConfig: Optional[DeadLetterConfig] + OnPartialBatchItemFailure: Optional[OnPartialBatchItemFailureStreams] + MaximumBatchingWindowInSeconds: Optional[MaximumBatchingWindowInSeconds] + MaximumRecordAgeInSeconds: Optional[MaximumRecordAgeInSeconds] + MaximumRetryAttempts: Optional[MaximumRetryAttemptsESM] + ParallelizationFactor: Optional[LimitMax10] + + +class UpdatePipeSourceParameters(TypedDict, total=False): + FilterCriteria: Optional[FilterCriteria] + KinesisStreamParameters: Optional[UpdatePipeSourceKinesisStreamParameters] + DynamoDBStreamParameters: Optional[UpdatePipeSourceDynamoDBStreamParameters] + SqsQueueParameters: Optional[UpdatePipeSourceSqsQueueParameters] + ActiveMQBrokerParameters: Optional[UpdatePipeSourceActiveMQBrokerParameters] + RabbitMQBrokerParameters: Optional[UpdatePipeSourceRabbitMQBrokerParameters] + ManagedStreamingKafkaParameters: Optional[UpdatePipeSourceManagedStreamingKafkaParameters] + SelfManagedKafkaParameters: Optional[UpdatePipeSourceSelfManagedKafkaParameters] + + +class UpdatePipeRequest(ServiceRequest): + Name: PipeName + Description: Optional[PipeDescription] + DesiredState: Optional[RequestedPipeState] + SourceParameters: Optional[UpdatePipeSourceParameters] + Enrichment: Optional[OptionalArn] + EnrichmentParameters: Optional[PipeEnrichmentParameters] + Target: Optional[Arn] + TargetParameters: Optional[PipeTargetParameters] + RoleArn: RoleArn + LogConfiguration: Optional[PipeLogConfigurationParameters] + KmsKeyIdentifier: Optional[KmsKeyIdentifier] + + +class UpdatePipeResponse(TypedDict, total=False): + Arn: Optional[PipeArn] + Name: Optional[PipeName] + DesiredState: Optional[RequestedPipeState] + CurrentState: Optional[PipeState] + CreationTime: Optional[Timestamp] + LastModifiedTime: Optional[Timestamp] + + +class PipesApi: + service = "pipes" + version = "2015-10-07" + + @handler("CreatePipe") + def create_pipe( + self, + context: RequestContext, + name: PipeName, + source: ArnOrUrl, + target: Arn, + role_arn: RoleArn, + description: PipeDescription | None = None, + desired_state: RequestedPipeState | None = None, + source_parameters: PipeSourceParameters | None = None, + enrichment: OptionalArn | None = None, + enrichment_parameters: PipeEnrichmentParameters | None = None, + target_parameters: PipeTargetParameters | None = None, + tags: TagMap | None = None, + log_configuration: PipeLogConfigurationParameters | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + **kwargs, + ) -> CreatePipeResponse: + raise NotImplementedError + + @handler("DeletePipe") + def delete_pipe(self, context: RequestContext, name: PipeName, **kwargs) -> DeletePipeResponse: + raise NotImplementedError + + @handler("DescribePipe") + def describe_pipe( + self, context: RequestContext, name: PipeName, **kwargs + ) -> DescribePipeResponse: + raise NotImplementedError + + @handler("ListPipes") + def list_pipes( + self, + context: RequestContext, + name_prefix: PipeName | None = None, + desired_state: RequestedPipeState | None = None, + current_state: PipeState | None = None, + source_prefix: ResourceArn | None = None, + target_prefix: ResourceArn | None = None, + next_token: NextToken | None = None, + limit: LimitMax100 | None = None, + **kwargs, + ) -> ListPipesResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: PipeArn, **kwargs + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("StartPipe") + def start_pipe(self, context: RequestContext, name: PipeName, **kwargs) -> StartPipeResponse: + raise NotImplementedError + + @handler("StopPipe") + def stop_pipe(self, context: RequestContext, name: PipeName, **kwargs) -> StopPipeResponse: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: PipeArn, tags: TagMap, **kwargs + ) -> TagResourceResponse: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: PipeArn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceResponse: + raise NotImplementedError + + @handler("UpdatePipe") + def update_pipe( + self, + context: RequestContext, + name: PipeName, + role_arn: RoleArn, + description: PipeDescription | None = None, + desired_state: RequestedPipeState | None = None, + source_parameters: UpdatePipeSourceParameters | None = None, + enrichment: OptionalArn | None = None, + enrichment_parameters: PipeEnrichmentParameters | None = None, + target: Arn | None = None, + target_parameters: PipeTargetParameters | None = None, + log_configuration: PipeLogConfigurationParameters | None = None, + kms_key_identifier: KmsKeyIdentifier | None = None, + **kwargs, + ) -> UpdatePipeResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/redshift/__init__.py b/localstack-core/localstack/aws/api/redshift/__init__.py new file mode 100644 index 0000000000000..1bcc3ad7816ad --- /dev/null +++ b/localstack-core/localstack/aws/api/redshift/__init__.py @@ -0,0 +1,5186 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AuthenticationProfileNameString = str +Boolean = bool +BooleanOptional = bool +CustomDomainCertificateArnString = str +CustomDomainNameString = str +Description = str +Double = float +DoubleOptional = float +IdcDisplayNameString = str +IdentityNamespaceString = str +InboundIntegrationArn = str +Integer = int +IntegerOptional = int +IntegrationArn = str +IntegrationDescription = str +IntegrationName = str +PartnerIntegrationAccountId = str +PartnerIntegrationClusterIdentifier = str +PartnerIntegrationDatabaseName = str +PartnerIntegrationPartnerName = str +PartnerIntegrationStatusMessage = str +RedshiftIdcApplicationName = str +S3KeyPrefixValue = str +SensitiveString = str +SourceArn = str +String = str +TargetArn = str + + +class ActionType(StrEnum): + restore_cluster = "restore-cluster" + recommend_node_config = "recommend-node-config" + resize_cluster = "resize-cluster" + + +class AquaConfigurationStatus(StrEnum): + enabled = "enabled" + disabled = "disabled" + auto = "auto" + + +class AquaStatus(StrEnum): + enabled = "enabled" + disabled = "disabled" + applying = "applying" + + +class AuthorizationStatus(StrEnum): + Authorized = "Authorized" + Revoking = "Revoking" + + +class DataShareStatus(StrEnum): + ACTIVE = "ACTIVE" + PENDING_AUTHORIZATION = "PENDING_AUTHORIZATION" + AUTHORIZED = "AUTHORIZED" + DEAUTHORIZED = "DEAUTHORIZED" + REJECTED = "REJECTED" + AVAILABLE = "AVAILABLE" + + +class DataShareStatusForConsumer(StrEnum): + ACTIVE = "ACTIVE" + AVAILABLE = "AVAILABLE" + + +class DataShareStatusForProducer(StrEnum): + ACTIVE = "ACTIVE" + AUTHORIZED = "AUTHORIZED" + PENDING_AUTHORIZATION = "PENDING_AUTHORIZATION" + DEAUTHORIZED = "DEAUTHORIZED" + REJECTED = "REJECTED" + + +class DataShareType(StrEnum): + INTERNAL = "INTERNAL" + + +class DescribeIntegrationsFilterName(StrEnum): + integration_arn = "integration-arn" + source_arn = "source-arn" + source_types = "source-types" + status = "status" + + +class ImpactRankingType(StrEnum): + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + + +class LogDestinationType(StrEnum): + s3 = "s3" + cloudwatch = "cloudwatch" + + +class Mode(StrEnum): + standard = "standard" + high_performance = "high-performance" + + +class NamespaceRegistrationStatus(StrEnum): + Registering = "Registering" + Deregistering = "Deregistering" + + +class NodeConfigurationOptionsFilterName(StrEnum): + NodeType = "NodeType" + NumberOfNodes = "NumberOfNodes" + EstimatedDiskUtilizationPercent = "EstimatedDiskUtilizationPercent" + Mode = "Mode" + + +class OperatorType(StrEnum): + eq = "eq" + lt = "lt" + gt = "gt" + le = "le" + ge = "ge" + in_ = "in" + between = "between" + + +class ParameterApplyType(StrEnum): + static = "static" + dynamic = "dynamic" + + +class PartnerIntegrationStatus(StrEnum): + Active = "Active" + Inactive = "Inactive" + RuntimeFailure = "RuntimeFailure" + ConnectionFailure = "ConnectionFailure" + + +class RecommendedActionType(StrEnum): + SQL = "SQL" + CLI = "CLI" + + +class ReservedNodeExchangeActionType(StrEnum): + restore_cluster = "restore-cluster" + resize_cluster = "resize-cluster" + + +class ReservedNodeExchangeStatusType(StrEnum): + REQUESTED = "REQUESTED" + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + RETRYING = "RETRYING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + + +class ReservedNodeOfferingType(StrEnum): + Regular = "Regular" + Upgradable = "Upgradable" + + +class ScheduleState(StrEnum): + MODIFYING = "MODIFYING" + ACTIVE = "ACTIVE" + FAILED = "FAILED" + + +class ScheduledActionFilterName(StrEnum): + cluster_identifier = "cluster-identifier" + iam_role = "iam-role" + + +class ScheduledActionState(StrEnum): + ACTIVE = "ACTIVE" + DISABLED = "DISABLED" + + +class ScheduledActionTypeValues(StrEnum): + ResizeCluster = "ResizeCluster" + PauseCluster = "PauseCluster" + ResumeCluster = "ResumeCluster" + + +class ServiceAuthorization(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class SnapshotAttributeToSortBy(StrEnum): + SOURCE_TYPE = "SOURCE_TYPE" + TOTAL_SIZE = "TOTAL_SIZE" + CREATE_TIME = "CREATE_TIME" + + +class SortByOrder(StrEnum): + ASC = "ASC" + DESC = "DESC" + + +class SourceType(StrEnum): + cluster = "cluster" + cluster_parameter_group = "cluster-parameter-group" + cluster_security_group = "cluster-security-group" + cluster_snapshot = "cluster-snapshot" + scheduled_action = "scheduled-action" + + +class TableRestoreStatusType(StrEnum): + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + CANCELED = "CANCELED" + + +class UsageLimitBreachAction(StrEnum): + log = "log" + emit_metric = "emit-metric" + disable = "disable" + + +class UsageLimitFeatureType(StrEnum): + spectrum = "spectrum" + concurrency_scaling = "concurrency-scaling" + cross_region_datasharing = "cross-region-datasharing" + + +class UsageLimitLimitType(StrEnum): + time = "time" + data_scanned = "data-scanned" + + +class UsageLimitPeriod(StrEnum): + daily = "daily" + weekly = "weekly" + monthly = "monthly" + + +class ZeroETLIntegrationStatus(StrEnum): + creating = "creating" + active = "active" + modifying = "modifying" + failed = "failed" + deleting = "deleting" + syncing = "syncing" + needs_attention = "needs_attention" + + +class AccessToClusterDeniedFault(ServiceException): + code: str = "AccessToClusterDenied" + sender_fault: bool = True + status_code: int = 400 + + +class AccessToSnapshotDeniedFault(ServiceException): + code: str = "AccessToSnapshotDenied" + sender_fault: bool = True + status_code: int = 400 + + +class AuthenticationProfileAlreadyExistsFault(ServiceException): + code: str = "AuthenticationProfileAlreadyExistsFault" + sender_fault: bool = True + status_code: int = 400 + + +class AuthenticationProfileNotFoundFault(ServiceException): + code: str = "AuthenticationProfileNotFoundFault" + sender_fault: bool = True + status_code: int = 404 + + +class AuthenticationProfileQuotaExceededFault(ServiceException): + code: str = "AuthenticationProfileQuotaExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class AuthorizationAlreadyExistsFault(ServiceException): + code: str = "AuthorizationAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class AuthorizationNotFoundFault(ServiceException): + code: str = "AuthorizationNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class AuthorizationQuotaExceededFault(ServiceException): + code: str = "AuthorizationQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class BatchDeleteRequestSizeExceededFault(ServiceException): + code: str = "BatchDeleteRequestSizeExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class BatchModifyClusterSnapshotsLimitExceededFault(ServiceException): + code: str = "BatchModifyClusterSnapshotsLimitExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class BucketNotFoundFault(ServiceException): + code: str = "BucketNotFoundFault" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterAlreadyExistsFault(ServiceException): + code: str = "ClusterAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterNotFoundFault(ServiceException): + code: str = "ClusterNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ClusterOnLatestRevisionFault(ServiceException): + code: str = "ClusterOnLatestRevision" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterParameterGroupAlreadyExistsFault(ServiceException): + code: str = "ClusterParameterGroupAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterParameterGroupNotFoundFault(ServiceException): + code: str = "ClusterParameterGroupNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ClusterParameterGroupQuotaExceededFault(ServiceException): + code: str = "ClusterParameterGroupQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterQuotaExceededFault(ServiceException): + code: str = "ClusterQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSecurityGroupAlreadyExistsFault(ServiceException): + code: str = "ClusterSecurityGroupAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSecurityGroupNotFoundFault(ServiceException): + code: str = "ClusterSecurityGroupNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ClusterSecurityGroupQuotaExceededFault(ServiceException): + code: str = "QuotaExceeded.ClusterSecurityGroup" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSnapshotAlreadyExistsFault(ServiceException): + code: str = "ClusterSnapshotAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSnapshotNotFoundFault(ServiceException): + code: str = "ClusterSnapshotNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ClusterSnapshotQuotaExceededFault(ServiceException): + code: str = "ClusterSnapshotQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSubnetGroupAlreadyExistsFault(ServiceException): + code: str = "ClusterSubnetGroupAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSubnetGroupNotFoundFault(ServiceException): + code: str = "ClusterSubnetGroupNotFoundFault" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSubnetGroupQuotaExceededFault(ServiceException): + code: str = "ClusterSubnetGroupQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ClusterSubnetQuotaExceededFault(ServiceException): + code: str = "ClusterSubnetQuotaExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class ConflictPolicyUpdateFault(ServiceException): + code: str = "ConflictPolicyUpdateFault" + sender_fault: bool = True + status_code: int = 409 + + +class CopyToRegionDisabledFault(ServiceException): + code: str = "CopyToRegionDisabledFault" + sender_fault: bool = True + status_code: int = 400 + + +class CustomCnameAssociationFault(ServiceException): + code: str = "CustomCnameAssociationFault" + sender_fault: bool = True + status_code: int = 400 + + +class CustomDomainAssociationNotFoundFault(ServiceException): + code: str = "CustomDomainAssociationNotFoundFault" + sender_fault: bool = True + status_code: int = 404 + + +class DependentServiceAccessDeniedFault(ServiceException): + code: str = "DependentServiceAccessDenied" + sender_fault: bool = True + status_code: int = 403 + + +class DependentServiceRequestThrottlingFault(ServiceException): + code: str = "DependentServiceRequestThrottlingFault" + sender_fault: bool = True + status_code: int = 400 + + +class DependentServiceUnavailableFault(ServiceException): + code: str = "DependentServiceUnavailableFault" + sender_fault: bool = True + status_code: int = 400 + + +class EndpointAlreadyExistsFault(ServiceException): + code: str = "EndpointAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class EndpointAuthorizationAlreadyExistsFault(ServiceException): + code: str = "EndpointAuthorizationAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class EndpointAuthorizationNotFoundFault(ServiceException): + code: str = "EndpointAuthorizationNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class EndpointAuthorizationsPerClusterLimitExceededFault(ServiceException): + code: str = "EndpointAuthorizationsPerClusterLimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class EndpointNotFoundFault(ServiceException): + code: str = "EndpointNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class EndpointsPerAuthorizationLimitExceededFault(ServiceException): + code: str = "EndpointsPerAuthorizationLimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class EndpointsPerClusterLimitExceededFault(ServiceException): + code: str = "EndpointsPerClusterLimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class EventSubscriptionQuotaExceededFault(ServiceException): + code: str = "EventSubscriptionQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class HsmClientCertificateAlreadyExistsFault(ServiceException): + code: str = "HsmClientCertificateAlreadyExistsFault" + sender_fault: bool = True + status_code: int = 400 + + +class HsmClientCertificateNotFoundFault(ServiceException): + code: str = "HsmClientCertificateNotFoundFault" + sender_fault: bool = True + status_code: int = 400 + + +class HsmClientCertificateQuotaExceededFault(ServiceException): + code: str = "HsmClientCertificateQuotaExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class HsmConfigurationAlreadyExistsFault(ServiceException): + code: str = "HsmConfigurationAlreadyExistsFault" + sender_fault: bool = True + status_code: int = 400 + + +class HsmConfigurationNotFoundFault(ServiceException): + code: str = "HsmConfigurationNotFoundFault" + sender_fault: bool = True + status_code: int = 400 + + +class HsmConfigurationQuotaExceededFault(ServiceException): + code: str = "HsmConfigurationQuotaExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class InProgressTableRestoreQuotaExceededFault(ServiceException): + code: str = "InProgressTableRestoreQuotaExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class IncompatibleOrderableOptions(ServiceException): + code: str = "IncompatibleOrderableOptions" + sender_fault: bool = True + status_code: int = 400 + + +class InsufficientClusterCapacityFault(ServiceException): + code: str = "InsufficientClusterCapacity" + sender_fault: bool = True + status_code: int = 400 + + +class InsufficientS3BucketPolicyFault(ServiceException): + code: str = "InsufficientS3BucketPolicyFault" + sender_fault: bool = True + status_code: int = 400 + + +class IntegrationAlreadyExistsFault(ServiceException): + code: str = "IntegrationAlreadyExistsFault" + sender_fault: bool = True + status_code: int = 400 + + +class IntegrationConflictOperationFault(ServiceException): + code: str = "IntegrationConflictOperationFault" + sender_fault: bool = True + status_code: int = 400 + + +class IntegrationConflictStateFault(ServiceException): + code: str = "IntegrationConflictStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class IntegrationNotFoundFault(ServiceException): + code: str = "IntegrationNotFoundFault" + sender_fault: bool = True + status_code: int = 404 + + +class IntegrationQuotaExceededFault(ServiceException): + code: str = "IntegrationQuotaExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class IntegrationSourceNotFoundFault(ServiceException): + code: str = "IntegrationSourceNotFoundFault" + sender_fault: bool = True + status_code: int = 404 + + +class IntegrationTargetNotFoundFault(ServiceException): + code: str = "IntegrationTargetNotFoundFault" + sender_fault: bool = True + status_code: int = 404 + + +class InvalidAuthenticationProfileRequestFault(ServiceException): + code: str = "InvalidAuthenticationProfileRequestFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidAuthorizationStateFault(ServiceException): + code: str = "InvalidAuthorizationState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterParameterGroupStateFault(ServiceException): + code: str = "InvalidClusterParameterGroupState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterSecurityGroupStateFault(ServiceException): + code: str = "InvalidClusterSecurityGroupState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterSnapshotScheduleStateFault(ServiceException): + code: str = "InvalidClusterSnapshotScheduleState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterSnapshotStateFault(ServiceException): + code: str = "InvalidClusterSnapshotState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterStateFault(ServiceException): + code: str = "InvalidClusterState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterSubnetGroupStateFault(ServiceException): + code: str = "InvalidClusterSubnetGroupStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterSubnetStateFault(ServiceException): + code: str = "InvalidClusterSubnetStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidClusterTrackFault(ServiceException): + code: str = "InvalidClusterTrack" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidDataShareFault(ServiceException): + code: str = "InvalidDataShareFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidElasticIpFault(ServiceException): + code: str = "InvalidElasticIpFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidEndpointStateFault(ServiceException): + code: str = "InvalidEndpointState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidHsmClientCertificateStateFault(ServiceException): + code: str = "InvalidHsmClientCertificateStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidHsmConfigurationStateFault(ServiceException): + code: str = "InvalidHsmConfigurationStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidNamespaceFault(ServiceException): + code: str = "InvalidNamespaceFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidPolicyFault(ServiceException): + code: str = "InvalidPolicyFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidReservedNodeStateFault(ServiceException): + code: str = "InvalidReservedNodeState" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidRestoreFault(ServiceException): + code: str = "InvalidRestore" + sender_fault: bool = True + status_code: int = 406 + + +class InvalidRetentionPeriodFault(ServiceException): + code: str = "InvalidRetentionPeriodFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidS3BucketNameFault(ServiceException): + code: str = "InvalidS3BucketNameFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidS3KeyPrefixFault(ServiceException): + code: str = "InvalidS3KeyPrefixFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidScheduleFault(ServiceException): + code: str = "InvalidSchedule" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidScheduledActionFault(ServiceException): + code: str = "InvalidScheduledAction" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidSnapshotCopyGrantStateFault(ServiceException): + code: str = "InvalidSnapshotCopyGrantStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidSubnet(ServiceException): + code: str = "InvalidSubnet" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidSubscriptionStateFault(ServiceException): + code: str = "InvalidSubscriptionStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidTableRestoreArgumentFault(ServiceException): + code: str = "InvalidTableRestoreArgument" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidTagFault(ServiceException): + code: str = "InvalidTagFault" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidUsageLimitFault(ServiceException): + code: str = "InvalidUsageLimit" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidVPCNetworkStateFault(ServiceException): + code: str = "InvalidVPCNetworkStateFault" + sender_fault: bool = True + status_code: int = 400 + + +class Ipv6CidrBlockNotFoundFault(ServiceException): + code: str = "Ipv6CidrBlockNotFoundFault" + sender_fault: bool = True + status_code: int = 400 + + +class LimitExceededFault(ServiceException): + code: str = "LimitExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class NumberOfNodesPerClusterLimitExceededFault(ServiceException): + code: str = "NumberOfNodesPerClusterLimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class NumberOfNodesQuotaExceededFault(ServiceException): + code: str = "NumberOfNodesQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class PartnerNotFoundFault(ServiceException): + code: str = "PartnerNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class RedshiftIdcApplicationAlreadyExistsFault(ServiceException): + code: str = "RedshiftIdcApplicationAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class RedshiftIdcApplicationNotExistsFault(ServiceException): + code: str = "RedshiftIdcApplicationNotExists" + sender_fault: bool = True + status_code: int = 404 + + +class RedshiftIdcApplicationQuotaExceededFault(ServiceException): + code: str = "RedshiftIdcApplicationQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ReservedNodeAlreadyExistsFault(ServiceException): + code: str = "ReservedNodeAlreadyExists" + sender_fault: bool = True + status_code: int = 404 + + +class ReservedNodeAlreadyMigratedFault(ServiceException): + code: str = "ReservedNodeAlreadyMigrated" + sender_fault: bool = True + status_code: int = 400 + + +class ReservedNodeExchangeNotFoundFault(ServiceException): + code: str = "ReservedNodeExchangeNotFond" + sender_fault: bool = True + status_code: int = 404 + + +class ReservedNodeNotFoundFault(ServiceException): + code: str = "ReservedNodeNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ReservedNodeOfferingNotFoundFault(ServiceException): + code: str = "ReservedNodeOfferingNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ReservedNodeQuotaExceededFault(ServiceException): + code: str = "ReservedNodeQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ResizeNotFoundFault(ServiceException): + code: str = "ResizeNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ResourceNotFoundFault(ServiceException): + code: str = "ResourceNotFoundFault" + sender_fault: bool = True + status_code: int = 404 + + +class SNSInvalidTopicFault(ServiceException): + code: str = "SNSInvalidTopic" + sender_fault: bool = True + status_code: int = 400 + + +class SNSNoAuthorizationFault(ServiceException): + code: str = "SNSNoAuthorization" + sender_fault: bool = True + status_code: int = 400 + + +class SNSTopicArnNotFoundFault(ServiceException): + code: str = "SNSTopicArnNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class ScheduleDefinitionTypeUnsupportedFault(ServiceException): + code: str = "ScheduleDefinitionTypeUnsupported" + sender_fault: bool = True + status_code: int = 400 + + +class ScheduledActionAlreadyExistsFault(ServiceException): + code: str = "ScheduledActionAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class ScheduledActionNotFoundFault(ServiceException): + code: str = "ScheduledActionNotFound" + sender_fault: bool = True + status_code: int = 400 + + +class ScheduledActionQuotaExceededFault(ServiceException): + code: str = "ScheduledActionQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class ScheduledActionTypeUnsupportedFault(ServiceException): + code: str = "ScheduledActionTypeUnsupported" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotCopyAlreadyDisabledFault(ServiceException): + code: str = "SnapshotCopyAlreadyDisabledFault" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotCopyAlreadyEnabledFault(ServiceException): + code: str = "SnapshotCopyAlreadyEnabledFault" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotCopyDisabledFault(ServiceException): + code: str = "SnapshotCopyDisabledFault" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotCopyGrantAlreadyExistsFault(ServiceException): + code: str = "SnapshotCopyGrantAlreadyExistsFault" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotCopyGrantNotFoundFault(ServiceException): + code: str = "SnapshotCopyGrantNotFoundFault" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotCopyGrantQuotaExceededFault(ServiceException): + code: str = "SnapshotCopyGrantQuotaExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotScheduleAlreadyExistsFault(ServiceException): + code: str = "SnapshotScheduleAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotScheduleNotFoundFault(ServiceException): + code: str = "SnapshotScheduleNotFound" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotScheduleQuotaExceededFault(ServiceException): + code: str = "SnapshotScheduleQuotaExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class SnapshotScheduleUpdateInProgressFault(ServiceException): + code: str = "SnapshotScheduleUpdateInProgress" + sender_fault: bool = True + status_code: int = 400 + + +class SourceNotFoundFault(ServiceException): + code: str = "SourceNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class SubnetAlreadyInUse(ServiceException): + code: str = "SubnetAlreadyInUse" + sender_fault: bool = True + status_code: int = 400 + + +class SubscriptionAlreadyExistFault(ServiceException): + code: str = "SubscriptionAlreadyExist" + sender_fault: bool = True + status_code: int = 400 + + +class SubscriptionCategoryNotFoundFault(ServiceException): + code: str = "SubscriptionCategoryNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class SubscriptionEventIdNotFoundFault(ServiceException): + code: str = "SubscriptionEventIdNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class SubscriptionNotFoundFault(ServiceException): + code: str = "SubscriptionNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class SubscriptionSeverityNotFoundFault(ServiceException): + code: str = "SubscriptionSeverityNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class TableLimitExceededFault(ServiceException): + code: str = "TableLimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class TableRestoreNotFoundFault(ServiceException): + code: str = "TableRestoreNotFoundFault" + sender_fault: bool = True + status_code: int = 400 + + +class TagLimitExceededFault(ServiceException): + code: str = "TagLimitExceededFault" + sender_fault: bool = True + status_code: int = 400 + + +class UnauthorizedOperation(ServiceException): + code: str = "UnauthorizedOperation" + sender_fault: bool = True + status_code: int = 400 + + +class UnauthorizedPartnerIntegrationFault(ServiceException): + code: str = "UnauthorizedPartnerIntegration" + sender_fault: bool = True + status_code: int = 401 + + +class UnknownSnapshotCopyRegionFault(ServiceException): + code: str = "UnknownSnapshotCopyRegionFault" + sender_fault: bool = True + status_code: int = 404 + + +class UnsupportedOperationFault(ServiceException): + code: str = "UnsupportedOperation" + sender_fault: bool = True + status_code: int = 400 + + +class UnsupportedOptionFault(ServiceException): + code: str = "UnsupportedOptionFault" + sender_fault: bool = True + status_code: int = 400 + + +class UsageLimitAlreadyExistsFault(ServiceException): + code: str = "UsageLimitAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + + +class UsageLimitNotFoundFault(ServiceException): + code: str = "UsageLimitNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class AcceptReservedNodeExchangeInputMessage(ServiceRequest): + ReservedNodeId: String + TargetReservedNodeOfferingId: String + + +class RecurringCharge(TypedDict, total=False): + RecurringChargeAmount: Optional[Double] + RecurringChargeFrequency: Optional[String] + + +RecurringChargeList = List[RecurringCharge] +TStamp = datetime + + +class ReservedNode(TypedDict, total=False): + ReservedNodeId: Optional[String] + ReservedNodeOfferingId: Optional[String] + NodeType: Optional[String] + StartTime: Optional[TStamp] + Duration: Optional[Integer] + FixedPrice: Optional[Double] + UsagePrice: Optional[Double] + CurrencyCode: Optional[String] + NodeCount: Optional[Integer] + State: Optional[String] + OfferingType: Optional[String] + RecurringCharges: Optional[RecurringChargeList] + ReservedNodeOfferingType: Optional[ReservedNodeOfferingType] + + +class AcceptReservedNodeExchangeOutputMessage(TypedDict, total=False): + ExchangedReservedNode: Optional[ReservedNode] + + +class AttributeValueTarget(TypedDict, total=False): + AttributeValue: Optional[String] + + +AttributeValueList = List[AttributeValueTarget] + + +class AccountAttribute(TypedDict, total=False): + AttributeName: Optional[String] + AttributeValues: Optional[AttributeValueList] + + +AttributeList = List[AccountAttribute] + + +class AccountAttributeList(TypedDict, total=False): + AccountAttributes: Optional[AttributeList] + + +class AccountWithRestoreAccess(TypedDict, total=False): + AccountId: Optional[String] + AccountAlias: Optional[String] + + +AccountsWithRestoreAccessList = List[AccountWithRestoreAccess] + + +class AquaConfiguration(TypedDict, total=False): + AquaStatus: Optional[AquaStatus] + AquaConfigurationStatus: Optional[AquaConfigurationStatus] + + +class AssociateDataShareConsumerMessage(ServiceRequest): + DataShareArn: String + AssociateEntireAccount: Optional[BooleanOptional] + ConsumerArn: Optional[String] + ConsumerRegion: Optional[String] + AllowWrites: Optional[BooleanOptional] + + +class ClusterAssociatedToSchedule(TypedDict, total=False): + ClusterIdentifier: Optional[String] + ScheduleAssociationState: Optional[ScheduleState] + + +AssociatedClusterList = List[ClusterAssociatedToSchedule] + + +class CertificateAssociation(TypedDict, total=False): + CustomDomainName: Optional[String] + ClusterIdentifier: Optional[String] + + +CertificateAssociationList = List[CertificateAssociation] + + +class Association(TypedDict, total=False): + CustomDomainCertificateArn: Optional[String] + CustomDomainCertificateExpiryDate: Optional[TStamp] + CertificateAssociations: Optional[CertificateAssociationList] + + +AssociationList = List[Association] +AttributeNameList = List[String] + + +class AuthenticationProfile(TypedDict, total=False): + AuthenticationProfileName: Optional[AuthenticationProfileNameString] + AuthenticationProfileContent: Optional[String] + + +AuthenticationProfileList = List[AuthenticationProfile] + + +class AuthorizeClusterSecurityGroupIngressMessage(ServiceRequest): + ClusterSecurityGroupName: String + CIDRIP: Optional[String] + EC2SecurityGroupName: Optional[String] + EC2SecurityGroupOwnerId: Optional[String] + + +class Tag(TypedDict, total=False): + Key: Optional[String] + Value: Optional[String] + + +TagList = List[Tag] + + +class IPRange(TypedDict, total=False): + Status: Optional[String] + CIDRIP: Optional[String] + Tags: Optional[TagList] + + +IPRangeList = List[IPRange] + + +class EC2SecurityGroup(TypedDict, total=False): + Status: Optional[String] + EC2SecurityGroupName: Optional[String] + EC2SecurityGroupOwnerId: Optional[String] + Tags: Optional[TagList] + + +EC2SecurityGroupList = List[EC2SecurityGroup] + + +class ClusterSecurityGroup(TypedDict, total=False): + ClusterSecurityGroupName: Optional[String] + Description: Optional[String] + EC2SecurityGroups: Optional[EC2SecurityGroupList] + IPRanges: Optional[IPRangeList] + Tags: Optional[TagList] + + +class AuthorizeClusterSecurityGroupIngressResult(TypedDict, total=False): + ClusterSecurityGroup: Optional[ClusterSecurityGroup] + + +class AuthorizeDataShareMessage(ServiceRequest): + DataShareArn: String + ConsumerIdentifier: String + AllowWrites: Optional[BooleanOptional] + + +VpcIdentifierList = List[String] + + +class AuthorizeEndpointAccessMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + Account: String + VpcIds: Optional[VpcIdentifierList] + + +class AuthorizeSnapshotAccessMessage(ServiceRequest): + SnapshotIdentifier: Optional[String] + SnapshotArn: Optional[String] + SnapshotClusterIdentifier: Optional[String] + AccountWithRestoreAccess: String + + +RestorableNodeTypeList = List[String] +Long = int + + +class Snapshot(TypedDict, total=False): + SnapshotIdentifier: Optional[String] + ClusterIdentifier: Optional[String] + SnapshotCreateTime: Optional[TStamp] + Status: Optional[String] + Port: Optional[Integer] + AvailabilityZone: Optional[String] + ClusterCreateTime: Optional[TStamp] + MasterUsername: Optional[String] + ClusterVersion: Optional[String] + EngineFullVersion: Optional[String] + SnapshotType: Optional[String] + NodeType: Optional[String] + NumberOfNodes: Optional[Integer] + DBName: Optional[String] + VpcId: Optional[String] + Encrypted: Optional[Boolean] + KmsKeyId: Optional[String] + EncryptedWithHSM: Optional[Boolean] + AccountsWithRestoreAccess: Optional[AccountsWithRestoreAccessList] + OwnerAccount: Optional[String] + TotalBackupSizeInMegaBytes: Optional[Double] + ActualIncrementalBackupSizeInMegaBytes: Optional[Double] + BackupProgressInMegaBytes: Optional[Double] + CurrentBackupRateInMegaBytesPerSecond: Optional[Double] + EstimatedSecondsToCompletion: Optional[Long] + ElapsedTimeInSeconds: Optional[Long] + SourceRegion: Optional[String] + Tags: Optional[TagList] + RestorableNodeTypes: Optional[RestorableNodeTypeList] + EnhancedVpcRouting: Optional[Boolean] + MaintenanceTrackName: Optional[String] + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + ManualSnapshotRemainingDays: Optional[IntegerOptional] + SnapshotRetentionStartTime: Optional[TStamp] + MasterPasswordSecretArn: Optional[String] + MasterPasswordSecretKmsKeyId: Optional[String] + SnapshotArn: Optional[String] + + +class AuthorizeSnapshotAccessResult(TypedDict, total=False): + Snapshot: Optional[Snapshot] + + +AuthorizedAudienceList = List[String] + + +class AuthorizedTokenIssuer(TypedDict, total=False): + TrustedTokenIssuerArn: Optional[String] + AuthorizedAudiencesList: Optional[AuthorizedAudienceList] + + +AuthorizedTokenIssuerList = List[AuthorizedTokenIssuer] + + +class SupportedPlatform(TypedDict, total=False): + Name: Optional[String] + + +SupportedPlatformsList = List[SupportedPlatform] + + +class AvailabilityZone(TypedDict, total=False): + Name: Optional[String] + SupportedPlatforms: Optional[SupportedPlatformsList] + + +AvailabilityZoneList = List[AvailabilityZone] + + +class DeleteClusterSnapshotMessage(ServiceRequest): + SnapshotIdentifier: String + SnapshotClusterIdentifier: Optional[String] + + +DeleteClusterSnapshotMessageList = List[DeleteClusterSnapshotMessage] + + +class BatchDeleteClusterSnapshotsRequest(ServiceRequest): + Identifiers: DeleteClusterSnapshotMessageList + + +class SnapshotErrorMessage(TypedDict, total=False): + SnapshotIdentifier: Optional[String] + SnapshotClusterIdentifier: Optional[String] + FailureCode: Optional[String] + FailureReason: Optional[String] + + +BatchSnapshotOperationErrorList = List[SnapshotErrorMessage] +SnapshotIdentifierList = List[String] + + +class BatchDeleteClusterSnapshotsResult(TypedDict, total=False): + Resources: Optional[SnapshotIdentifierList] + Errors: Optional[BatchSnapshotOperationErrorList] + + +class BatchModifyClusterSnapshotsMessage(ServiceRequest): + SnapshotIdentifierList: SnapshotIdentifierList + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + Force: Optional[Boolean] + + +BatchSnapshotOperationErrors = List[SnapshotErrorMessage] + + +class BatchModifyClusterSnapshotsOutputMessage(TypedDict, total=False): + Resources: Optional[SnapshotIdentifierList] + Errors: Optional[BatchSnapshotOperationErrors] + + +class CancelResizeMessage(ServiceRequest): + ClusterIdentifier: String + + +class ClusterNode(TypedDict, total=False): + NodeRole: Optional[String] + PrivateIPAddress: Optional[String] + PublicIPAddress: Optional[String] + + +ClusterNodesList = List[ClusterNode] + + +class SecondaryClusterInfo(TypedDict, total=False): + AvailabilityZone: Optional[String] + ClusterNodes: Optional[ClusterNodesList] + + +class ReservedNodeExchangeStatus(TypedDict, total=False): + ReservedNodeExchangeRequestId: Optional[String] + Status: Optional[ReservedNodeExchangeStatusType] + RequestTime: Optional[TStamp] + SourceReservedNodeId: Optional[String] + SourceReservedNodeType: Optional[String] + SourceReservedNodeCount: Optional[Integer] + TargetReservedNodeOfferingId: Optional[String] + TargetReservedNodeType: Optional[String] + TargetReservedNodeCount: Optional[Integer] + + +LongOptional = int + + +class ResizeInfo(TypedDict, total=False): + ResizeType: Optional[String] + AllowCancelResize: Optional[Boolean] + + +class DeferredMaintenanceWindow(TypedDict, total=False): + DeferMaintenanceIdentifier: Optional[String] + DeferMaintenanceStartTime: Optional[TStamp] + DeferMaintenanceEndTime: Optional[TStamp] + + +DeferredMaintenanceWindowsList = List[DeferredMaintenanceWindow] +PendingActionsList = List[String] + + +class ClusterIamRole(TypedDict, total=False): + IamRoleArn: Optional[String] + ApplyStatus: Optional[String] + + +ClusterIamRoleList = List[ClusterIamRole] + + +class ElasticIpStatus(TypedDict, total=False): + ElasticIp: Optional[String] + Status: Optional[String] + + +class ClusterSnapshotCopyStatus(TypedDict, total=False): + DestinationRegion: Optional[String] + RetentionPeriod: Optional[Long] + ManualSnapshotRetentionPeriod: Optional[Integer] + SnapshotCopyGrantName: Optional[String] + + +class HsmStatus(TypedDict, total=False): + HsmClientCertificateIdentifier: Optional[String] + HsmConfigurationIdentifier: Optional[String] + Status: Optional[String] + + +class DataTransferProgress(TypedDict, total=False): + Status: Optional[String] + CurrentRateInMegaBytesPerSecond: Optional[DoubleOptional] + TotalDataInMegaBytes: Optional[Long] + DataTransferredInMegaBytes: Optional[Long] + EstimatedTimeToCompletionInSeconds: Optional[LongOptional] + ElapsedTimeInSeconds: Optional[LongOptional] + + +class RestoreStatus(TypedDict, total=False): + Status: Optional[String] + CurrentRestoreRateInMegaBytesPerSecond: Optional[Double] + SnapshotSizeInMegaBytes: Optional[Long] + ProgressInMegaBytes: Optional[Long] + ElapsedTimeInSeconds: Optional[Long] + EstimatedTimeToCompletionInSeconds: Optional[Long] + + +class PendingModifiedValues(TypedDict, total=False): + MasterUserPassword: Optional[SensitiveString] + NodeType: Optional[String] + NumberOfNodes: Optional[IntegerOptional] + ClusterType: Optional[String] + ClusterVersion: Optional[String] + AutomatedSnapshotRetentionPeriod: Optional[IntegerOptional] + ClusterIdentifier: Optional[String] + PubliclyAccessible: Optional[BooleanOptional] + EnhancedVpcRouting: Optional[BooleanOptional] + MaintenanceTrackName: Optional[String] + EncryptionType: Optional[String] + + +class ClusterParameterStatus(TypedDict, total=False): + ParameterName: Optional[String] + ParameterApplyStatus: Optional[String] + ParameterApplyErrorDescription: Optional[String] + + +ClusterParameterStatusList = List[ClusterParameterStatus] + + +class ClusterParameterGroupStatus(TypedDict, total=False): + ParameterGroupName: Optional[String] + ParameterApplyStatus: Optional[String] + ClusterParameterStatusList: Optional[ClusterParameterStatusList] + + +ClusterParameterGroupStatusList = List[ClusterParameterGroupStatus] + + +class VpcSecurityGroupMembership(TypedDict, total=False): + VpcSecurityGroupId: Optional[String] + Status: Optional[String] + + +VpcSecurityGroupMembershipList = List[VpcSecurityGroupMembership] + + +class ClusterSecurityGroupMembership(TypedDict, total=False): + ClusterSecurityGroupName: Optional[String] + Status: Optional[String] + + +ClusterSecurityGroupMembershipList = List[ClusterSecurityGroupMembership] + + +class NetworkInterface(TypedDict, total=False): + NetworkInterfaceId: Optional[String] + SubnetId: Optional[String] + PrivateIpAddress: Optional[String] + AvailabilityZone: Optional[String] + Ipv6Address: Optional[String] + + +NetworkInterfaceList = List[NetworkInterface] + + +class VpcEndpoint(TypedDict, total=False): + VpcEndpointId: Optional[String] + VpcId: Optional[String] + NetworkInterfaces: Optional[NetworkInterfaceList] + + +VpcEndpointsList = List[VpcEndpoint] + + +class Endpoint(TypedDict, total=False): + Address: Optional[String] + Port: Optional[Integer] + VpcEndpoints: Optional[VpcEndpointsList] + + +class Cluster(TypedDict, total=False): + ClusterIdentifier: Optional[String] + NodeType: Optional[String] + ClusterStatus: Optional[String] + ClusterAvailabilityStatus: Optional[String] + ModifyStatus: Optional[String] + MasterUsername: Optional[String] + DBName: Optional[String] + Endpoint: Optional[Endpoint] + ClusterCreateTime: Optional[TStamp] + AutomatedSnapshotRetentionPeriod: Optional[Integer] + ManualSnapshotRetentionPeriod: Optional[Integer] + ClusterSecurityGroups: Optional[ClusterSecurityGroupMembershipList] + VpcSecurityGroups: Optional[VpcSecurityGroupMembershipList] + ClusterParameterGroups: Optional[ClusterParameterGroupStatusList] + ClusterSubnetGroupName: Optional[String] + VpcId: Optional[String] + AvailabilityZone: Optional[String] + PreferredMaintenanceWindow: Optional[String] + PendingModifiedValues: Optional[PendingModifiedValues] + ClusterVersion: Optional[String] + AllowVersionUpgrade: Optional[Boolean] + NumberOfNodes: Optional[Integer] + PubliclyAccessible: Optional[Boolean] + Encrypted: Optional[Boolean] + RestoreStatus: Optional[RestoreStatus] + DataTransferProgress: Optional[DataTransferProgress] + HsmStatus: Optional[HsmStatus] + ClusterSnapshotCopyStatus: Optional[ClusterSnapshotCopyStatus] + ClusterPublicKey: Optional[String] + ClusterNodes: Optional[ClusterNodesList] + ElasticIpStatus: Optional[ElasticIpStatus] + ClusterRevisionNumber: Optional[String] + Tags: Optional[TagList] + KmsKeyId: Optional[String] + EnhancedVpcRouting: Optional[Boolean] + IamRoles: Optional[ClusterIamRoleList] + PendingActions: Optional[PendingActionsList] + MaintenanceTrackName: Optional[String] + ElasticResizeNumberOfNodeOptions: Optional[String] + DeferredMaintenanceWindows: Optional[DeferredMaintenanceWindowsList] + SnapshotScheduleIdentifier: Optional[String] + SnapshotScheduleState: Optional[ScheduleState] + ExpectedNextSnapshotScheduleTime: Optional[TStamp] + ExpectedNextSnapshotScheduleTimeStatus: Optional[String] + NextMaintenanceWindowStartTime: Optional[TStamp] + ResizeInfo: Optional[ResizeInfo] + AvailabilityZoneRelocationStatus: Optional[String] + ClusterNamespaceArn: Optional[String] + TotalStorageCapacityInMegaBytes: Optional[LongOptional] + AquaConfiguration: Optional[AquaConfiguration] + DefaultIamRoleArn: Optional[String] + ReservedNodeExchangeStatus: Optional[ReservedNodeExchangeStatus] + CustomDomainName: Optional[String] + CustomDomainCertificateArn: Optional[String] + CustomDomainCertificateExpiryDate: Optional[TStamp] + MasterPasswordSecretArn: Optional[String] + MasterPasswordSecretKmsKeyId: Optional[String] + IpAddressType: Optional[String] + MultiAZ: Optional[String] + MultiAZSecondary: Optional[SecondaryClusterInfo] + + +class ClusterCredentials(TypedDict, total=False): + DbUser: Optional[String] + DbPassword: Optional[SensitiveString] + Expiration: Optional[TStamp] + + +class RevisionTarget(TypedDict, total=False): + DatabaseRevision: Optional[String] + Description: Optional[String] + DatabaseRevisionReleaseDate: Optional[TStamp] + + +RevisionTargetsList = List[RevisionTarget] + + +class ClusterDbRevision(TypedDict, total=False): + ClusterIdentifier: Optional[String] + CurrentDatabaseRevision: Optional[String] + DatabaseRevisionReleaseDate: Optional[TStamp] + RevisionTargets: Optional[RevisionTargetsList] + + +ClusterDbRevisionsList = List[ClusterDbRevision] + + +class ClusterDbRevisionsMessage(TypedDict, total=False): + Marker: Optional[String] + ClusterDbRevisions: Optional[ClusterDbRevisionsList] + + +class ClusterExtendedCredentials(TypedDict, total=False): + DbUser: Optional[String] + DbPassword: Optional[SensitiveString] + Expiration: Optional[TStamp] + NextRefreshTime: Optional[TStamp] + + +ClusterList = List[Cluster] + + +class ClusterParameterGroup(TypedDict, total=False): + ParameterGroupName: Optional[String] + ParameterGroupFamily: Optional[String] + Description: Optional[String] + Tags: Optional[TagList] + + +class Parameter(TypedDict, total=False): + ParameterName: Optional[String] + ParameterValue: Optional[String] + Description: Optional[String] + Source: Optional[String] + DataType: Optional[String] + AllowedValues: Optional[String] + ApplyType: Optional[ParameterApplyType] + IsModifiable: Optional[Boolean] + MinimumEngineVersion: Optional[String] + + +ParametersList = List[Parameter] + + +class ClusterParameterGroupDetails(TypedDict, total=False): + Parameters: Optional[ParametersList] + Marker: Optional[String] + + +class ClusterParameterGroupNameMessage(TypedDict, total=False): + ParameterGroupName: Optional[String] + ParameterGroupStatus: Optional[String] + + +ParameterGroupList = List[ClusterParameterGroup] + + +class ClusterParameterGroupsMessage(TypedDict, total=False): + Marker: Optional[String] + ParameterGroups: Optional[ParameterGroupList] + + +ClusterSecurityGroups = List[ClusterSecurityGroup] + + +class ClusterSecurityGroupMessage(TypedDict, total=False): + Marker: Optional[String] + ClusterSecurityGroups: Optional[ClusterSecurityGroups] + + +ClusterSecurityGroupNameList = List[String] +ValueStringList = List[String] + + +class Subnet(TypedDict, total=False): + SubnetIdentifier: Optional[String] + SubnetAvailabilityZone: Optional[AvailabilityZone] + SubnetStatus: Optional[String] + + +SubnetList = List[Subnet] + + +class ClusterSubnetGroup(TypedDict, total=False): + ClusterSubnetGroupName: Optional[String] + Description: Optional[String] + VpcId: Optional[String] + SubnetGroupStatus: Optional[String] + Subnets: Optional[SubnetList] + Tags: Optional[TagList] + SupportedClusterIpAddressTypes: Optional[ValueStringList] + + +ClusterSubnetGroups = List[ClusterSubnetGroup] + + +class ClusterSubnetGroupMessage(TypedDict, total=False): + Marker: Optional[String] + ClusterSubnetGroups: Optional[ClusterSubnetGroups] + + +class ClusterVersion(TypedDict, total=False): + ClusterVersion: Optional[String] + ClusterParameterGroupFamily: Optional[String] + Description: Optional[String] + + +ClusterVersionList = List[ClusterVersion] + + +class ClusterVersionsMessage(TypedDict, total=False): + Marker: Optional[String] + ClusterVersions: Optional[ClusterVersionList] + + +class ClustersMessage(TypedDict, total=False): + Marker: Optional[String] + Clusters: Optional[ClusterList] + + +ConsumerIdentifierList = List[String] + + +class CopyClusterSnapshotMessage(ServiceRequest): + SourceSnapshotIdentifier: String + SourceSnapshotClusterIdentifier: Optional[String] + TargetSnapshotIdentifier: String + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + + +class CopyClusterSnapshotResult(TypedDict, total=False): + Snapshot: Optional[Snapshot] + + +class CreateAuthenticationProfileMessage(ServiceRequest): + AuthenticationProfileName: AuthenticationProfileNameString + AuthenticationProfileContent: String + + +class CreateAuthenticationProfileResult(TypedDict, total=False): + AuthenticationProfileName: Optional[AuthenticationProfileNameString] + AuthenticationProfileContent: Optional[String] + + +IamRoleArnList = List[String] +VpcSecurityGroupIdList = List[String] + + +class CreateClusterMessage(ServiceRequest): + DBName: Optional[String] + ClusterIdentifier: String + ClusterType: Optional[String] + NodeType: String + MasterUsername: String + MasterUserPassword: Optional[SensitiveString] + ClusterSecurityGroups: Optional[ClusterSecurityGroupNameList] + VpcSecurityGroupIds: Optional[VpcSecurityGroupIdList] + ClusterSubnetGroupName: Optional[String] + AvailabilityZone: Optional[String] + PreferredMaintenanceWindow: Optional[String] + ClusterParameterGroupName: Optional[String] + AutomatedSnapshotRetentionPeriod: Optional[IntegerOptional] + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + Port: Optional[IntegerOptional] + ClusterVersion: Optional[String] + AllowVersionUpgrade: Optional[BooleanOptional] + NumberOfNodes: Optional[IntegerOptional] + PubliclyAccessible: Optional[BooleanOptional] + Encrypted: Optional[BooleanOptional] + HsmClientCertificateIdentifier: Optional[String] + HsmConfigurationIdentifier: Optional[String] + ElasticIp: Optional[String] + Tags: Optional[TagList] + KmsKeyId: Optional[String] + EnhancedVpcRouting: Optional[BooleanOptional] + AdditionalInfo: Optional[String] + IamRoles: Optional[IamRoleArnList] + MaintenanceTrackName: Optional[String] + SnapshotScheduleIdentifier: Optional[String] + AvailabilityZoneRelocation: Optional[BooleanOptional] + AquaConfigurationStatus: Optional[AquaConfigurationStatus] + DefaultIamRoleArn: Optional[String] + LoadSampleData: Optional[String] + ManageMasterPassword: Optional[BooleanOptional] + MasterPasswordSecretKmsKeyId: Optional[String] + IpAddressType: Optional[String] + MultiAZ: Optional[BooleanOptional] + RedshiftIdcApplicationArn: Optional[String] + + +class CreateClusterParameterGroupMessage(ServiceRequest): + ParameterGroupName: String + ParameterGroupFamily: String + Description: String + Tags: Optional[TagList] + + +class CreateClusterParameterGroupResult(TypedDict, total=False): + ClusterParameterGroup: Optional[ClusterParameterGroup] + + +class CreateClusterResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class CreateClusterSecurityGroupMessage(ServiceRequest): + ClusterSecurityGroupName: String + Description: String + Tags: Optional[TagList] + + +class CreateClusterSecurityGroupResult(TypedDict, total=False): + ClusterSecurityGroup: Optional[ClusterSecurityGroup] + + +class CreateClusterSnapshotMessage(ServiceRequest): + SnapshotIdentifier: String + ClusterIdentifier: String + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + Tags: Optional[TagList] + + +class CreateClusterSnapshotResult(TypedDict, total=False): + Snapshot: Optional[Snapshot] + + +SubnetIdentifierList = List[String] + + +class CreateClusterSubnetGroupMessage(ServiceRequest): + ClusterSubnetGroupName: String + Description: String + SubnetIds: SubnetIdentifierList + Tags: Optional[TagList] + + +class CreateClusterSubnetGroupResult(TypedDict, total=False): + ClusterSubnetGroup: Optional[ClusterSubnetGroup] + + +class CreateCustomDomainAssociationMessage(ServiceRequest): + CustomDomainName: CustomDomainNameString + CustomDomainCertificateArn: CustomDomainCertificateArnString + ClusterIdentifier: String + + +class CreateCustomDomainAssociationResult(TypedDict, total=False): + CustomDomainName: Optional[CustomDomainNameString] + CustomDomainCertificateArn: Optional[CustomDomainCertificateArnString] + ClusterIdentifier: Optional[String] + CustomDomainCertExpiryTime: Optional[String] + + +class CreateEndpointAccessMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + ResourceOwner: Optional[String] + EndpointName: String + SubnetGroupName: String + VpcSecurityGroupIds: Optional[VpcSecurityGroupIdList] + + +EventCategoriesList = List[String] +SourceIdsList = List[String] + + +class CreateEventSubscriptionMessage(ServiceRequest): + SubscriptionName: String + SnsTopicArn: String + SourceType: Optional[String] + SourceIds: Optional[SourceIdsList] + EventCategories: Optional[EventCategoriesList] + Severity: Optional[String] + Enabled: Optional[BooleanOptional] + Tags: Optional[TagList] + + +class EventSubscription(TypedDict, total=False): + CustomerAwsId: Optional[String] + CustSubscriptionId: Optional[String] + SnsTopicArn: Optional[String] + Status: Optional[String] + SubscriptionCreationTime: Optional[TStamp] + SourceType: Optional[String] + SourceIdsList: Optional[SourceIdsList] + EventCategoriesList: Optional[EventCategoriesList] + Severity: Optional[String] + Enabled: Optional[Boolean] + Tags: Optional[TagList] + + +class CreateEventSubscriptionResult(TypedDict, total=False): + EventSubscription: Optional[EventSubscription] + + +class CreateHsmClientCertificateMessage(ServiceRequest): + HsmClientCertificateIdentifier: String + Tags: Optional[TagList] + + +class HsmClientCertificate(TypedDict, total=False): + HsmClientCertificateIdentifier: Optional[String] + HsmClientCertificatePublicKey: Optional[String] + Tags: Optional[TagList] + + +class CreateHsmClientCertificateResult(TypedDict, total=False): + HsmClientCertificate: Optional[HsmClientCertificate] + + +class CreateHsmConfigurationMessage(ServiceRequest): + HsmConfigurationIdentifier: String + Description: String + HsmIpAddress: String + HsmPartitionName: String + HsmPartitionPassword: String + HsmServerPublicCertificate: String + Tags: Optional[TagList] + + +class HsmConfiguration(TypedDict, total=False): + HsmConfigurationIdentifier: Optional[String] + Description: Optional[String] + HsmIpAddress: Optional[String] + HsmPartitionName: Optional[String] + Tags: Optional[TagList] + + +class CreateHsmConfigurationResult(TypedDict, total=False): + HsmConfiguration: Optional[HsmConfiguration] + + +EncryptionContextMap = Dict[String, String] + + +class CreateIntegrationMessage(ServiceRequest): + SourceArn: SourceArn + TargetArn: TargetArn + IntegrationName: IntegrationName + KMSKeyId: Optional[String] + TagList: Optional[TagList] + AdditionalEncryptionContext: Optional[EncryptionContextMap] + Description: Optional[IntegrationDescription] + + +class ReadWriteAccess(TypedDict, total=False): + Authorization: ServiceAuthorization + + +class S3AccessGrantsScopeUnion(TypedDict, total=False): + ReadWriteAccess: Optional[ReadWriteAccess] + + +S3AccessGrantsServiceIntegrations = List[S3AccessGrantsScopeUnion] + + +class LakeFormationQuery(TypedDict, total=False): + Authorization: ServiceAuthorization + + +class LakeFormationScopeUnion(TypedDict, total=False): + LakeFormationQuery: Optional[LakeFormationQuery] + + +LakeFormationServiceIntegrations = List[LakeFormationScopeUnion] + + +class ServiceIntegrationsUnion(TypedDict, total=False): + LakeFormation: Optional[LakeFormationServiceIntegrations] + S3AccessGrants: Optional[S3AccessGrantsServiceIntegrations] + + +ServiceIntegrationList = List[ServiceIntegrationsUnion] + + +class CreateRedshiftIdcApplicationMessage(ServiceRequest): + IdcInstanceArn: String + RedshiftIdcApplicationName: RedshiftIdcApplicationName + IdentityNamespace: Optional[IdentityNamespaceString] + IdcDisplayName: IdcDisplayNameString + IamRoleArn: String + AuthorizedTokenIssuerList: Optional[AuthorizedTokenIssuerList] + ServiceIntegrations: Optional[ServiceIntegrationList] + + +class RedshiftIdcApplication(TypedDict, total=False): + IdcInstanceArn: Optional[String] + RedshiftIdcApplicationName: Optional[RedshiftIdcApplicationName] + RedshiftIdcApplicationArn: Optional[String] + IdentityNamespace: Optional[IdentityNamespaceString] + IdcDisplayName: Optional[IdcDisplayNameString] + IamRoleArn: Optional[String] + IdcManagedApplicationArn: Optional[String] + IdcOnboardStatus: Optional[String] + AuthorizedTokenIssuerList: Optional[AuthorizedTokenIssuerList] + ServiceIntegrations: Optional[ServiceIntegrationList] + + +class CreateRedshiftIdcApplicationResult(TypedDict, total=False): + RedshiftIdcApplication: Optional[RedshiftIdcApplication] + + +class ResumeClusterMessage(ServiceRequest): + ClusterIdentifier: String + + +class PauseClusterMessage(ServiceRequest): + ClusterIdentifier: String + + +class ResizeClusterMessage(ServiceRequest): + ClusterIdentifier: String + ClusterType: Optional[String] + NodeType: Optional[String] + NumberOfNodes: Optional[IntegerOptional] + Classic: Optional[BooleanOptional] + ReservedNodeId: Optional[String] + TargetReservedNodeOfferingId: Optional[String] + + +class ScheduledActionType(TypedDict, total=False): + ResizeCluster: Optional[ResizeClusterMessage] + PauseCluster: Optional[PauseClusterMessage] + ResumeCluster: Optional[ResumeClusterMessage] + + +class CreateScheduledActionMessage(ServiceRequest): + ScheduledActionName: String + TargetAction: ScheduledActionType + Schedule: String + IamRole: String + ScheduledActionDescription: Optional[String] + StartTime: Optional[TStamp] + EndTime: Optional[TStamp] + Enable: Optional[BooleanOptional] + + +class CreateSnapshotCopyGrantMessage(ServiceRequest): + SnapshotCopyGrantName: String + KmsKeyId: Optional[String] + Tags: Optional[TagList] + + +class SnapshotCopyGrant(TypedDict, total=False): + SnapshotCopyGrantName: Optional[String] + KmsKeyId: Optional[String] + Tags: Optional[TagList] + + +class CreateSnapshotCopyGrantResult(TypedDict, total=False): + SnapshotCopyGrant: Optional[SnapshotCopyGrant] + + +ScheduleDefinitionList = List[String] + + +class CreateSnapshotScheduleMessage(ServiceRequest): + ScheduleDefinitions: Optional[ScheduleDefinitionList] + ScheduleIdentifier: Optional[String] + ScheduleDescription: Optional[String] + Tags: Optional[TagList] + DryRun: Optional[BooleanOptional] + NextInvocations: Optional[IntegerOptional] + + +class CreateTagsMessage(ServiceRequest): + ResourceName: String + Tags: TagList + + +class CreateUsageLimitMessage(ServiceRequest): + ClusterIdentifier: String + FeatureType: UsageLimitFeatureType + LimitType: UsageLimitLimitType + Amount: Long + Period: Optional[UsageLimitPeriod] + BreachAction: Optional[UsageLimitBreachAction] + Tags: Optional[TagList] + + +class CustomDomainAssociationsMessage(TypedDict, total=False): + Marker: Optional[String] + Associations: Optional[AssociationList] + + +class CustomerStorageMessage(TypedDict, total=False): + TotalBackupSizeInMegaBytes: Optional[Double] + TotalProvisionedStorageInMegaBytes: Optional[Double] + + +class DataShareAssociation(TypedDict, total=False): + ConsumerIdentifier: Optional[String] + Status: Optional[DataShareStatus] + ConsumerRegion: Optional[String] + CreatedDate: Optional[TStamp] + StatusChangeDate: Optional[TStamp] + ProducerAllowedWrites: Optional[BooleanOptional] + ConsumerAcceptedWrites: Optional[BooleanOptional] + + +DataShareAssociationList = List[DataShareAssociation] + + +class DataShare(TypedDict, total=False): + DataShareArn: Optional[String] + ProducerArn: Optional[String] + AllowPubliclyAccessibleConsumers: Optional[Boolean] + DataShareAssociations: Optional[DataShareAssociationList] + ManagedBy: Optional[String] + DataShareType: Optional[DataShareType] + + +DataShareList = List[DataShare] +DbGroupList = List[String] + + +class DeauthorizeDataShareMessage(ServiceRequest): + DataShareArn: String + ConsumerIdentifier: String + + +class DefaultClusterParameters(TypedDict, total=False): + ParameterGroupFamily: Optional[String] + Marker: Optional[String] + Parameters: Optional[ParametersList] + + +class DeleteAuthenticationProfileMessage(ServiceRequest): + AuthenticationProfileName: AuthenticationProfileNameString + + +class DeleteAuthenticationProfileResult(TypedDict, total=False): + AuthenticationProfileName: Optional[AuthenticationProfileNameString] + + +class DeleteClusterMessage(ServiceRequest): + ClusterIdentifier: String + SkipFinalClusterSnapshot: Optional[Boolean] + FinalClusterSnapshotIdentifier: Optional[String] + FinalClusterSnapshotRetentionPeriod: Optional[IntegerOptional] + + +class DeleteClusterParameterGroupMessage(ServiceRequest): + ParameterGroupName: String + + +class DeleteClusterResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class DeleteClusterSecurityGroupMessage(ServiceRequest): + ClusterSecurityGroupName: String + + +class DeleteClusterSnapshotResult(TypedDict, total=False): + Snapshot: Optional[Snapshot] + + +class DeleteClusterSubnetGroupMessage(ServiceRequest): + ClusterSubnetGroupName: String + + +class DeleteCustomDomainAssociationMessage(ServiceRequest): + ClusterIdentifier: String + CustomDomainName: CustomDomainNameString + + +class DeleteEndpointAccessMessage(ServiceRequest): + EndpointName: String + + +class DeleteEventSubscriptionMessage(ServiceRequest): + SubscriptionName: String + + +class DeleteHsmClientCertificateMessage(ServiceRequest): + HsmClientCertificateIdentifier: String + + +class DeleteHsmConfigurationMessage(ServiceRequest): + HsmConfigurationIdentifier: String + + +class DeleteIntegrationMessage(ServiceRequest): + IntegrationArn: IntegrationArn + + +class DeleteRedshiftIdcApplicationMessage(ServiceRequest): + RedshiftIdcApplicationArn: String + + +class DeleteResourcePolicyMessage(ServiceRequest): + ResourceArn: String + + +class DeleteScheduledActionMessage(ServiceRequest): + ScheduledActionName: String + + +class DeleteSnapshotCopyGrantMessage(ServiceRequest): + SnapshotCopyGrantName: String + + +class DeleteSnapshotScheduleMessage(ServiceRequest): + ScheduleIdentifier: String + + +TagKeyList = List[String] + + +class DeleteTagsMessage(ServiceRequest): + ResourceName: String + TagKeys: TagKeyList + + +class DeleteUsageLimitMessage(ServiceRequest): + UsageLimitId: String + + +class ProvisionedIdentifier(TypedDict, total=False): + ClusterIdentifier: String + + +class ServerlessIdentifier(TypedDict, total=False): + NamespaceIdentifier: String + WorkgroupIdentifier: String + + +class NamespaceIdentifierUnion(TypedDict, total=False): + ServerlessIdentifier: Optional[ServerlessIdentifier] + ProvisionedIdentifier: Optional[ProvisionedIdentifier] + + +class DeregisterNamespaceInputMessage(ServiceRequest): + NamespaceIdentifier: NamespaceIdentifierUnion + ConsumerIdentifiers: ConsumerIdentifierList + + +class DeregisterNamespaceOutputMessage(TypedDict, total=False): + Status: Optional[NamespaceRegistrationStatus] + + +class DescribeAccountAttributesMessage(ServiceRequest): + AttributeNames: Optional[AttributeNameList] + + +class DescribeAuthenticationProfilesMessage(ServiceRequest): + AuthenticationProfileName: Optional[AuthenticationProfileNameString] + + +class DescribeAuthenticationProfilesResult(TypedDict, total=False): + AuthenticationProfiles: Optional[AuthenticationProfileList] + + +class DescribeClusterDbRevisionsMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +TagValueList = List[String] + + +class DescribeClusterParameterGroupsMessage(ServiceRequest): + ParameterGroupName: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeClusterParametersMessage(ServiceRequest): + ParameterGroupName: String + Source: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeClusterSecurityGroupsMessage(ServiceRequest): + ClusterSecurityGroupName: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class SnapshotSortingEntity(TypedDict, total=False): + Attribute: SnapshotAttributeToSortBy + SortOrder: Optional[SortByOrder] + + +SnapshotSortingEntityList = List[SnapshotSortingEntity] + + +class DescribeClusterSnapshotsMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + SnapshotIdentifier: Optional[String] + SnapshotArn: Optional[String] + SnapshotType: Optional[String] + StartTime: Optional[TStamp] + EndTime: Optional[TStamp] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + OwnerAccount: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + ClusterExists: Optional[BooleanOptional] + SortingEntities: Optional[SnapshotSortingEntityList] + + +class DescribeClusterSubnetGroupsMessage(ServiceRequest): + ClusterSubnetGroupName: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeClusterTracksMessage(ServiceRequest): + MaintenanceTrackName: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeClusterVersionsMessage(ServiceRequest): + ClusterVersion: Optional[String] + ClusterParameterGroupFamily: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeClustersMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeCustomDomainAssociationsMessage(ServiceRequest): + CustomDomainName: Optional[CustomDomainNameString] + CustomDomainCertificateArn: Optional[CustomDomainCertificateArnString] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeDataSharesForConsumerMessage(ServiceRequest): + ConsumerArn: Optional[String] + Status: Optional[DataShareStatusForConsumer] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeDataSharesForConsumerResult(TypedDict, total=False): + DataShares: Optional[DataShareList] + Marker: Optional[String] + + +class DescribeDataSharesForProducerMessage(ServiceRequest): + ProducerArn: Optional[String] + Status: Optional[DataShareStatusForProducer] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeDataSharesForProducerResult(TypedDict, total=False): + DataShares: Optional[DataShareList] + Marker: Optional[String] + + +class DescribeDataSharesMessage(ServiceRequest): + DataShareArn: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeDataSharesResult(TypedDict, total=False): + DataShares: Optional[DataShareList] + Marker: Optional[String] + + +class DescribeDefaultClusterParametersMessage(ServiceRequest): + ParameterGroupFamily: String + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeDefaultClusterParametersResult(TypedDict, total=False): + DefaultClusterParameters: Optional[DefaultClusterParameters] + + +class DescribeEndpointAccessMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + ResourceOwner: Optional[String] + EndpointName: Optional[String] + VpcId: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeEndpointAuthorizationMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + Account: Optional[String] + Grantee: Optional[BooleanOptional] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeEventCategoriesMessage(ServiceRequest): + SourceType: Optional[String] + + +class DescribeEventSubscriptionsMessage(ServiceRequest): + SubscriptionName: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeEventsMessage(ServiceRequest): + SourceIdentifier: Optional[String] + SourceType: Optional[SourceType] + StartTime: Optional[TStamp] + EndTime: Optional[TStamp] + Duration: Optional[IntegerOptional] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeHsmClientCertificatesMessage(ServiceRequest): + HsmClientCertificateIdentifier: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeHsmConfigurationsMessage(ServiceRequest): + HsmConfigurationIdentifier: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeInboundIntegrationsMessage(ServiceRequest): + IntegrationArn: Optional[InboundIntegrationArn] + TargetArn: Optional[TargetArn] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +DescribeIntegrationsFilterValueList = List[String] + + +class DescribeIntegrationsFilter(TypedDict, total=False): + Name: DescribeIntegrationsFilterName + Values: DescribeIntegrationsFilterValueList + + +DescribeIntegrationsFilterList = List[DescribeIntegrationsFilter] + + +class DescribeIntegrationsMessage(ServiceRequest): + IntegrationArn: Optional[IntegrationArn] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + Filters: Optional[DescribeIntegrationsFilterList] + + +class DescribeLoggingStatusMessage(ServiceRequest): + ClusterIdentifier: String + + +class NodeConfigurationOptionsFilter(TypedDict, total=False): + Name: Optional[NodeConfigurationOptionsFilterName] + Operator: Optional[OperatorType] + Values: Optional[ValueStringList] + + +NodeConfigurationOptionsFilterList = List[NodeConfigurationOptionsFilter] + + +class DescribeNodeConfigurationOptionsMessage(ServiceRequest): + ActionType: ActionType + ClusterIdentifier: Optional[String] + SnapshotIdentifier: Optional[String] + SnapshotArn: Optional[String] + OwnerAccount: Optional[String] + Filters: Optional[NodeConfigurationOptionsFilterList] + Marker: Optional[String] + MaxRecords: Optional[IntegerOptional] + + +class DescribeOrderableClusterOptionsMessage(ServiceRequest): + ClusterVersion: Optional[String] + NodeType: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribePartnersInputMessage(ServiceRequest): + AccountId: PartnerIntegrationAccountId + ClusterIdentifier: PartnerIntegrationClusterIdentifier + DatabaseName: Optional[PartnerIntegrationDatabaseName] + PartnerName: Optional[PartnerIntegrationPartnerName] + + +class PartnerIntegrationInfo(TypedDict, total=False): + DatabaseName: Optional[PartnerIntegrationDatabaseName] + PartnerName: Optional[PartnerIntegrationPartnerName] + Status: Optional[PartnerIntegrationStatus] + StatusMessage: Optional[PartnerIntegrationStatusMessage] + CreatedAt: Optional[TStamp] + UpdatedAt: Optional[TStamp] + + +PartnerIntegrationInfoList = List[PartnerIntegrationInfo] + + +class DescribePartnersOutputMessage(TypedDict, total=False): + PartnerIntegrationInfoList: Optional[PartnerIntegrationInfoList] + + +class DescribeRedshiftIdcApplicationsMessage(ServiceRequest): + RedshiftIdcApplicationArn: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +RedshiftIdcApplicationList = List[RedshiftIdcApplication] + + +class DescribeRedshiftIdcApplicationsResult(TypedDict, total=False): + RedshiftIdcApplications: Optional[RedshiftIdcApplicationList] + Marker: Optional[String] + + +class DescribeReservedNodeExchangeStatusInputMessage(ServiceRequest): + ReservedNodeId: Optional[String] + ReservedNodeExchangeRequestId: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +ReservedNodeExchangeStatusList = List[ReservedNodeExchangeStatus] + + +class DescribeReservedNodeExchangeStatusOutputMessage(TypedDict, total=False): + ReservedNodeExchangeStatusDetails: Optional[ReservedNodeExchangeStatusList] + Marker: Optional[String] + + +class DescribeReservedNodeOfferingsMessage(ServiceRequest): + ReservedNodeOfferingId: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeReservedNodesMessage(ServiceRequest): + ReservedNodeId: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeResizeMessage(ServiceRequest): + ClusterIdentifier: String + + +class ScheduledActionFilter(TypedDict, total=False): + Name: ScheduledActionFilterName + Values: ValueStringList + + +ScheduledActionFilterList = List[ScheduledActionFilter] + + +class DescribeScheduledActionsMessage(ServiceRequest): + ScheduledActionName: Optional[String] + TargetActionType: Optional[ScheduledActionTypeValues] + StartTime: Optional[TStamp] + EndTime: Optional[TStamp] + Active: Optional[BooleanOptional] + Filters: Optional[ScheduledActionFilterList] + Marker: Optional[String] + MaxRecords: Optional[IntegerOptional] + + +class DescribeSnapshotCopyGrantsMessage(ServiceRequest): + SnapshotCopyGrantName: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeSnapshotSchedulesMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + ScheduleIdentifier: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + Marker: Optional[String] + MaxRecords: Optional[IntegerOptional] + + +ScheduledSnapshotTimeList = List[TStamp] + + +class SnapshotSchedule(TypedDict, total=False): + ScheduleDefinitions: Optional[ScheduleDefinitionList] + ScheduleIdentifier: Optional[String] + ScheduleDescription: Optional[String] + Tags: Optional[TagList] + NextInvocations: Optional[ScheduledSnapshotTimeList] + AssociatedClusterCount: Optional[IntegerOptional] + AssociatedClusters: Optional[AssociatedClusterList] + + +SnapshotScheduleList = List[SnapshotSchedule] + + +class DescribeSnapshotSchedulesOutputMessage(TypedDict, total=False): + SnapshotSchedules: Optional[SnapshotScheduleList] + Marker: Optional[String] + + +class DescribeTableRestoreStatusMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + TableRestoreRequestId: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class DescribeTagsMessage(ServiceRequest): + ResourceName: Optional[String] + ResourceType: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DescribeUsageLimitsMessage(ServiceRequest): + UsageLimitId: Optional[String] + ClusterIdentifier: Optional[String] + FeatureType: Optional[UsageLimitFeatureType] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + TagKeys: Optional[TagKeyList] + TagValues: Optional[TagValueList] + + +class DisableLoggingMessage(ServiceRequest): + ClusterIdentifier: String + + +class DisableSnapshotCopyMessage(ServiceRequest): + ClusterIdentifier: String + + +class DisableSnapshotCopyResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class DisassociateDataShareConsumerMessage(ServiceRequest): + DataShareArn: String + DisassociateEntireAccount: Optional[BooleanOptional] + ConsumerArn: Optional[String] + ConsumerRegion: Optional[String] + + +class SupportedOperation(TypedDict, total=False): + OperationName: Optional[String] + + +SupportedOperationList = List[SupportedOperation] + + +class UpdateTarget(TypedDict, total=False): + MaintenanceTrackName: Optional[String] + DatabaseVersion: Optional[String] + SupportedOperations: Optional[SupportedOperationList] + + +EligibleTracksToUpdateList = List[UpdateTarget] +LogTypeList = List[String] + + +class EnableLoggingMessage(ServiceRequest): + ClusterIdentifier: String + BucketName: Optional[String] + S3KeyPrefix: Optional[S3KeyPrefixValue] + LogDestinationType: Optional[LogDestinationType] + LogExports: Optional[LogTypeList] + + +class EnableSnapshotCopyMessage(ServiceRequest): + ClusterIdentifier: String + DestinationRegion: String + RetentionPeriod: Optional[IntegerOptional] + SnapshotCopyGrantName: Optional[String] + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + + +class EnableSnapshotCopyResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class EndpointAccess(TypedDict, total=False): + ClusterIdentifier: Optional[String] + ResourceOwner: Optional[String] + SubnetGroupName: Optional[String] + EndpointStatus: Optional[String] + EndpointName: Optional[String] + EndpointCreateTime: Optional[TStamp] + Port: Optional[Integer] + Address: Optional[String] + VpcSecurityGroups: Optional[VpcSecurityGroupMembershipList] + VpcEndpoint: Optional[VpcEndpoint] + + +EndpointAccesses = List[EndpointAccess] + + +class EndpointAccessList(TypedDict, total=False): + EndpointAccessList: Optional[EndpointAccesses] + Marker: Optional[String] + + +class EndpointAuthorization(TypedDict, total=False): + Grantor: Optional[String] + Grantee: Optional[String] + ClusterIdentifier: Optional[String] + AuthorizeTime: Optional[TStamp] + ClusterStatus: Optional[String] + Status: Optional[AuthorizationStatus] + AllowedAllVPCs: Optional[Boolean] + AllowedVPCs: Optional[VpcIdentifierList] + EndpointCount: Optional[Integer] + + +EndpointAuthorizations = List[EndpointAuthorization] + + +class EndpointAuthorizationList(TypedDict, total=False): + EndpointAuthorizationList: Optional[EndpointAuthorizations] + Marker: Optional[String] + + +class Event(TypedDict, total=False): + SourceIdentifier: Optional[String] + SourceType: Optional[SourceType] + Message: Optional[String] + EventCategories: Optional[EventCategoriesList] + Severity: Optional[String] + Date: Optional[TStamp] + EventId: Optional[String] + + +class EventInfoMap(TypedDict, total=False): + EventId: Optional[String] + EventCategories: Optional[EventCategoriesList] + EventDescription: Optional[String] + Severity: Optional[String] + + +EventInfoMapList = List[EventInfoMap] + + +class EventCategoriesMap(TypedDict, total=False): + SourceType: Optional[String] + Events: Optional[EventInfoMapList] + + +EventCategoriesMapList = List[EventCategoriesMap] + + +class EventCategoriesMessage(TypedDict, total=False): + EventCategoriesMapList: Optional[EventCategoriesMapList] + + +EventList = List[Event] +EventSubscriptionsList = List[EventSubscription] + + +class EventSubscriptionsMessage(TypedDict, total=False): + Marker: Optional[String] + EventSubscriptionsList: Optional[EventSubscriptionsList] + + +class EventsMessage(TypedDict, total=False): + Marker: Optional[String] + Events: Optional[EventList] + + +class FailoverPrimaryComputeInputMessage(ServiceRequest): + ClusterIdentifier: String + + +class FailoverPrimaryComputeResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class GetClusterCredentialsMessage(ServiceRequest): + DbUser: String + DbName: Optional[String] + ClusterIdentifier: Optional[String] + DurationSeconds: Optional[IntegerOptional] + AutoCreate: Optional[BooleanOptional] + DbGroups: Optional[DbGroupList] + CustomDomainName: Optional[String] + + +class GetClusterCredentialsWithIAMMessage(ServiceRequest): + DbName: Optional[String] + ClusterIdentifier: Optional[String] + DurationSeconds: Optional[IntegerOptional] + CustomDomainName: Optional[String] + + +class GetReservedNodeExchangeConfigurationOptionsInputMessage(ServiceRequest): + ActionType: ReservedNodeExchangeActionType + ClusterIdentifier: Optional[String] + SnapshotIdentifier: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class ReservedNodeOffering(TypedDict, total=False): + ReservedNodeOfferingId: Optional[String] + NodeType: Optional[String] + Duration: Optional[Integer] + FixedPrice: Optional[Double] + UsagePrice: Optional[Double] + CurrencyCode: Optional[String] + OfferingType: Optional[String] + RecurringCharges: Optional[RecurringChargeList] + ReservedNodeOfferingType: Optional[ReservedNodeOfferingType] + + +class ReservedNodeConfigurationOption(TypedDict, total=False): + SourceReservedNode: Optional[ReservedNode] + TargetReservedNodeCount: Optional[Integer] + TargetReservedNodeOffering: Optional[ReservedNodeOffering] + + +ReservedNodeConfigurationOptionList = List[ReservedNodeConfigurationOption] + + +class GetReservedNodeExchangeConfigurationOptionsOutputMessage(TypedDict, total=False): + Marker: Optional[String] + ReservedNodeConfigurationOptionList: Optional[ReservedNodeConfigurationOptionList] + + +class GetReservedNodeExchangeOfferingsInputMessage(ServiceRequest): + ReservedNodeId: String + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +ReservedNodeOfferingList = List[ReservedNodeOffering] + + +class GetReservedNodeExchangeOfferingsOutputMessage(TypedDict, total=False): + Marker: Optional[String] + ReservedNodeOfferings: Optional[ReservedNodeOfferingList] + + +class GetResourcePolicyMessage(ServiceRequest): + ResourceArn: String + + +class ResourcePolicy(TypedDict, total=False): + ResourceArn: Optional[String] + Policy: Optional[String] + + +class GetResourcePolicyResult(TypedDict, total=False): + ResourcePolicy: Optional[ResourcePolicy] + + +HsmClientCertificateList = List[HsmClientCertificate] + + +class HsmClientCertificateMessage(TypedDict, total=False): + Marker: Optional[String] + HsmClientCertificates: Optional[HsmClientCertificateList] + + +HsmConfigurationList = List[HsmConfiguration] + + +class HsmConfigurationMessage(TypedDict, total=False): + Marker: Optional[String] + HsmConfigurations: Optional[HsmConfigurationList] + + +ImportTablesCompleted = List[String] +ImportTablesInProgress = List[String] +ImportTablesNotStarted = List[String] + + +class IntegrationError(TypedDict, total=False): + ErrorCode: String + ErrorMessage: Optional[String] + + +IntegrationErrorList = List[IntegrationError] + + +class InboundIntegration(TypedDict, total=False): + IntegrationArn: Optional[InboundIntegrationArn] + SourceArn: Optional[String] + TargetArn: Optional[TargetArn] + Status: Optional[ZeroETLIntegrationStatus] + Errors: Optional[IntegrationErrorList] + CreateTime: Optional[TStamp] + + +InboundIntegrationList = List[InboundIntegration] + + +class InboundIntegrationsMessage(TypedDict, total=False): + Marker: Optional[String] + InboundIntegrations: Optional[InboundIntegrationList] + + +class Integration(TypedDict, total=False): + IntegrationArn: Optional[IntegrationArn] + IntegrationName: Optional[IntegrationName] + SourceArn: Optional[SourceArn] + TargetArn: Optional[TargetArn] + Status: Optional[ZeroETLIntegrationStatus] + Errors: Optional[IntegrationErrorList] + CreateTime: Optional[TStamp] + Description: Optional[Description] + KMSKeyId: Optional[String] + AdditionalEncryptionContext: Optional[EncryptionContextMap] + Tags: Optional[TagList] + + +IntegrationList = List[Integration] + + +class IntegrationsMessage(TypedDict, total=False): + Marker: Optional[String] + Integrations: Optional[IntegrationList] + + +class ListRecommendationsMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + NamespaceArn: Optional[String] + MaxRecords: Optional[IntegerOptional] + Marker: Optional[String] + + +class ReferenceLink(TypedDict, total=False): + Text: Optional[String] + Link: Optional[String] + + +ReferenceLinkList = List[ReferenceLink] + + +class RecommendedAction(TypedDict, total=False): + Text: Optional[String] + Database: Optional[String] + Command: Optional[String] + Type: Optional[RecommendedActionType] + + +RecommendedActionList = List[RecommendedAction] + + +class Recommendation(TypedDict, total=False): + Id: Optional[String] + ClusterIdentifier: Optional[String] + NamespaceArn: Optional[String] + CreatedAt: Optional[TStamp] + RecommendationType: Optional[String] + Title: Optional[String] + Description: Optional[String] + Observation: Optional[String] + ImpactRanking: Optional[ImpactRankingType] + RecommendationText: Optional[String] + RecommendedActions: Optional[RecommendedActionList] + ReferenceLinks: Optional[ReferenceLinkList] + + +RecommendationList = List[Recommendation] + + +class ListRecommendationsResult(TypedDict, total=False): + Recommendations: Optional[RecommendationList] + Marker: Optional[String] + + +class LoggingStatus(TypedDict, total=False): + LoggingEnabled: Optional[Boolean] + BucketName: Optional[String] + S3KeyPrefix: Optional[S3KeyPrefixValue] + LastSuccessfulDeliveryTime: Optional[TStamp] + LastFailureTime: Optional[TStamp] + LastFailureMessage: Optional[String] + LogDestinationType: Optional[LogDestinationType] + LogExports: Optional[LogTypeList] + + +class MaintenanceTrack(TypedDict, total=False): + MaintenanceTrackName: Optional[String] + DatabaseVersion: Optional[String] + UpdateTargets: Optional[EligibleTracksToUpdateList] + + +class ModifyAquaInputMessage(ServiceRequest): + ClusterIdentifier: String + AquaConfigurationStatus: Optional[AquaConfigurationStatus] + + +class ModifyAquaOutputMessage(TypedDict, total=False): + AquaConfiguration: Optional[AquaConfiguration] + + +class ModifyAuthenticationProfileMessage(ServiceRequest): + AuthenticationProfileName: AuthenticationProfileNameString + AuthenticationProfileContent: String + + +class ModifyAuthenticationProfileResult(TypedDict, total=False): + AuthenticationProfileName: Optional[AuthenticationProfileNameString] + AuthenticationProfileContent: Optional[String] + + +class ModifyClusterDbRevisionMessage(ServiceRequest): + ClusterIdentifier: String + RevisionTarget: String + + +class ModifyClusterDbRevisionResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class ModifyClusterIamRolesMessage(ServiceRequest): + ClusterIdentifier: String + AddIamRoles: Optional[IamRoleArnList] + RemoveIamRoles: Optional[IamRoleArnList] + DefaultIamRoleArn: Optional[String] + + +class ModifyClusterIamRolesResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class ModifyClusterMaintenanceMessage(ServiceRequest): + ClusterIdentifier: String + DeferMaintenance: Optional[BooleanOptional] + DeferMaintenanceIdentifier: Optional[String] + DeferMaintenanceStartTime: Optional[TStamp] + DeferMaintenanceEndTime: Optional[TStamp] + DeferMaintenanceDuration: Optional[IntegerOptional] + + +class ModifyClusterMaintenanceResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class ModifyClusterMessage(ServiceRequest): + ClusterIdentifier: String + ClusterType: Optional[String] + NodeType: Optional[String] + NumberOfNodes: Optional[IntegerOptional] + ClusterSecurityGroups: Optional[ClusterSecurityGroupNameList] + VpcSecurityGroupIds: Optional[VpcSecurityGroupIdList] + MasterUserPassword: Optional[SensitiveString] + ClusterParameterGroupName: Optional[String] + AutomatedSnapshotRetentionPeriod: Optional[IntegerOptional] + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + PreferredMaintenanceWindow: Optional[String] + ClusterVersion: Optional[String] + AllowVersionUpgrade: Optional[BooleanOptional] + HsmClientCertificateIdentifier: Optional[String] + HsmConfigurationIdentifier: Optional[String] + NewClusterIdentifier: Optional[String] + PubliclyAccessible: Optional[BooleanOptional] + ElasticIp: Optional[String] + EnhancedVpcRouting: Optional[BooleanOptional] + MaintenanceTrackName: Optional[String] + Encrypted: Optional[BooleanOptional] + KmsKeyId: Optional[String] + AvailabilityZoneRelocation: Optional[BooleanOptional] + AvailabilityZone: Optional[String] + Port: Optional[IntegerOptional] + ManageMasterPassword: Optional[BooleanOptional] + MasterPasswordSecretKmsKeyId: Optional[String] + IpAddressType: Optional[String] + MultiAZ: Optional[BooleanOptional] + + +class ModifyClusterParameterGroupMessage(ServiceRequest): + ParameterGroupName: String + Parameters: ParametersList + + +class ModifyClusterResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class ModifyClusterSnapshotMessage(ServiceRequest): + SnapshotIdentifier: String + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + Force: Optional[Boolean] + + +class ModifyClusterSnapshotResult(TypedDict, total=False): + Snapshot: Optional[Snapshot] + + +class ModifyClusterSnapshotScheduleMessage(ServiceRequest): + ClusterIdentifier: String + ScheduleIdentifier: Optional[String] + DisassociateSchedule: Optional[BooleanOptional] + + +class ModifyClusterSubnetGroupMessage(ServiceRequest): + ClusterSubnetGroupName: String + Description: Optional[String] + SubnetIds: SubnetIdentifierList + + +class ModifyClusterSubnetGroupResult(TypedDict, total=False): + ClusterSubnetGroup: Optional[ClusterSubnetGroup] + + +class ModifyCustomDomainAssociationMessage(ServiceRequest): + CustomDomainName: CustomDomainNameString + CustomDomainCertificateArn: CustomDomainCertificateArnString + ClusterIdentifier: String + + +class ModifyCustomDomainAssociationResult(TypedDict, total=False): + CustomDomainName: Optional[CustomDomainNameString] + CustomDomainCertificateArn: Optional[CustomDomainCertificateArnString] + ClusterIdentifier: Optional[String] + CustomDomainCertExpiryTime: Optional[String] + + +class ModifyEndpointAccessMessage(ServiceRequest): + EndpointName: String + VpcSecurityGroupIds: Optional[VpcSecurityGroupIdList] + + +class ModifyEventSubscriptionMessage(ServiceRequest): + SubscriptionName: String + SnsTopicArn: Optional[String] + SourceType: Optional[String] + SourceIds: Optional[SourceIdsList] + EventCategories: Optional[EventCategoriesList] + Severity: Optional[String] + Enabled: Optional[BooleanOptional] + + +class ModifyEventSubscriptionResult(TypedDict, total=False): + EventSubscription: Optional[EventSubscription] + + +class ModifyIntegrationMessage(ServiceRequest): + IntegrationArn: IntegrationArn + Description: Optional[IntegrationDescription] + IntegrationName: Optional[IntegrationName] + + +class ModifyRedshiftIdcApplicationMessage(ServiceRequest): + RedshiftIdcApplicationArn: String + IdentityNamespace: Optional[IdentityNamespaceString] + IamRoleArn: Optional[String] + IdcDisplayName: Optional[IdcDisplayNameString] + AuthorizedTokenIssuerList: Optional[AuthorizedTokenIssuerList] + ServiceIntegrations: Optional[ServiceIntegrationList] + + +class ModifyRedshiftIdcApplicationResult(TypedDict, total=False): + RedshiftIdcApplication: Optional[RedshiftIdcApplication] + + +class ModifyScheduledActionMessage(ServiceRequest): + ScheduledActionName: String + TargetAction: Optional[ScheduledActionType] + Schedule: Optional[String] + IamRole: Optional[String] + ScheduledActionDescription: Optional[String] + StartTime: Optional[TStamp] + EndTime: Optional[TStamp] + Enable: Optional[BooleanOptional] + + +class ModifySnapshotCopyRetentionPeriodMessage(ServiceRequest): + ClusterIdentifier: String + RetentionPeriod: Integer + Manual: Optional[Boolean] + + +class ModifySnapshotCopyRetentionPeriodResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class ModifySnapshotScheduleMessage(ServiceRequest): + ScheduleIdentifier: String + ScheduleDefinitions: ScheduleDefinitionList + + +class ModifyUsageLimitMessage(ServiceRequest): + UsageLimitId: String + Amount: Optional[LongOptional] + BreachAction: Optional[UsageLimitBreachAction] + + +class NodeConfigurationOption(TypedDict, total=False): + NodeType: Optional[String] + NumberOfNodes: Optional[Integer] + EstimatedDiskUtilizationPercent: Optional[DoubleOptional] + Mode: Optional[Mode] + + +NodeConfigurationOptionList = List[NodeConfigurationOption] + + +class NodeConfigurationOptionsMessage(TypedDict, total=False): + NodeConfigurationOptionList: Optional[NodeConfigurationOptionList] + Marker: Optional[String] + + +class OrderableClusterOption(TypedDict, total=False): + ClusterVersion: Optional[String] + ClusterType: Optional[String] + NodeType: Optional[String] + AvailabilityZones: Optional[AvailabilityZoneList] + + +OrderableClusterOptionsList = List[OrderableClusterOption] + + +class OrderableClusterOptionsMessage(TypedDict, total=False): + OrderableClusterOptions: Optional[OrderableClusterOptionsList] + Marker: Optional[String] + + +class PartnerIntegrationInputMessage(ServiceRequest): + AccountId: PartnerIntegrationAccountId + ClusterIdentifier: PartnerIntegrationClusterIdentifier + DatabaseName: PartnerIntegrationDatabaseName + PartnerName: PartnerIntegrationPartnerName + + +class PartnerIntegrationOutputMessage(TypedDict, total=False): + DatabaseName: Optional[PartnerIntegrationDatabaseName] + PartnerName: Optional[PartnerIntegrationPartnerName] + + +class PauseClusterResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class PurchaseReservedNodeOfferingMessage(ServiceRequest): + ReservedNodeOfferingId: String + NodeCount: Optional[IntegerOptional] + + +class PurchaseReservedNodeOfferingResult(TypedDict, total=False): + ReservedNode: Optional[ReservedNode] + + +class PutResourcePolicyMessage(ServiceRequest): + ResourceArn: String + Policy: String + + +class PutResourcePolicyResult(TypedDict, total=False): + ResourcePolicy: Optional[ResourcePolicy] + + +class RebootClusterMessage(ServiceRequest): + ClusterIdentifier: String + + +class RebootClusterResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class RegisterNamespaceInputMessage(ServiceRequest): + NamespaceIdentifier: NamespaceIdentifierUnion + ConsumerIdentifiers: ConsumerIdentifierList + + +class RegisterNamespaceOutputMessage(TypedDict, total=False): + Status: Optional[NamespaceRegistrationStatus] + + +class RejectDataShareMessage(ServiceRequest): + DataShareArn: String + + +ReservedNodeList = List[ReservedNode] + + +class ReservedNodeOfferingsMessage(TypedDict, total=False): + Marker: Optional[String] + ReservedNodeOfferings: Optional[ReservedNodeOfferingList] + + +class ReservedNodesMessage(TypedDict, total=False): + Marker: Optional[String] + ReservedNodes: Optional[ReservedNodeList] + + +class ResetClusterParameterGroupMessage(ServiceRequest): + ParameterGroupName: String + ResetAllParameters: Optional[Boolean] + Parameters: Optional[ParametersList] + + +class ResizeClusterResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class ResizeProgressMessage(TypedDict, total=False): + TargetNodeType: Optional[String] + TargetNumberOfNodes: Optional[IntegerOptional] + TargetClusterType: Optional[String] + Status: Optional[String] + ImportTablesCompleted: Optional[ImportTablesCompleted] + ImportTablesInProgress: Optional[ImportTablesInProgress] + ImportTablesNotStarted: Optional[ImportTablesNotStarted] + AvgResizeRateInMegaBytesPerSecond: Optional[DoubleOptional] + TotalResizeDataInMegaBytes: Optional[LongOptional] + ProgressInMegaBytes: Optional[LongOptional] + ElapsedTimeInSeconds: Optional[LongOptional] + EstimatedTimeToCompletionInSeconds: Optional[LongOptional] + ResizeType: Optional[String] + Message: Optional[String] + TargetEncryptionType: Optional[String] + DataTransferProgressPercent: Optional[DoubleOptional] + + +class RestoreFromClusterSnapshotMessage(ServiceRequest): + ClusterIdentifier: String + SnapshotIdentifier: Optional[String] + SnapshotArn: Optional[String] + SnapshotClusterIdentifier: Optional[String] + Port: Optional[IntegerOptional] + AvailabilityZone: Optional[String] + AllowVersionUpgrade: Optional[BooleanOptional] + ClusterSubnetGroupName: Optional[String] + PubliclyAccessible: Optional[BooleanOptional] + OwnerAccount: Optional[String] + HsmClientCertificateIdentifier: Optional[String] + HsmConfigurationIdentifier: Optional[String] + ElasticIp: Optional[String] + ClusterParameterGroupName: Optional[String] + ClusterSecurityGroups: Optional[ClusterSecurityGroupNameList] + VpcSecurityGroupIds: Optional[VpcSecurityGroupIdList] + PreferredMaintenanceWindow: Optional[String] + AutomatedSnapshotRetentionPeriod: Optional[IntegerOptional] + ManualSnapshotRetentionPeriod: Optional[IntegerOptional] + KmsKeyId: Optional[String] + NodeType: Optional[String] + EnhancedVpcRouting: Optional[BooleanOptional] + AdditionalInfo: Optional[String] + IamRoles: Optional[IamRoleArnList] + MaintenanceTrackName: Optional[String] + SnapshotScheduleIdentifier: Optional[String] + NumberOfNodes: Optional[IntegerOptional] + AvailabilityZoneRelocation: Optional[BooleanOptional] + AquaConfigurationStatus: Optional[AquaConfigurationStatus] + DefaultIamRoleArn: Optional[String] + ReservedNodeId: Optional[String] + TargetReservedNodeOfferingId: Optional[String] + Encrypted: Optional[BooleanOptional] + ManageMasterPassword: Optional[BooleanOptional] + MasterPasswordSecretKmsKeyId: Optional[String] + IpAddressType: Optional[String] + MultiAZ: Optional[BooleanOptional] + + +class RestoreFromClusterSnapshotResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class RestoreTableFromClusterSnapshotMessage(ServiceRequest): + ClusterIdentifier: String + SnapshotIdentifier: String + SourceDatabaseName: String + SourceSchemaName: Optional[String] + SourceTableName: String + TargetDatabaseName: Optional[String] + TargetSchemaName: Optional[String] + NewTableName: String + EnableCaseSensitiveIdentifier: Optional[BooleanOptional] + + +class TableRestoreStatus(TypedDict, total=False): + TableRestoreRequestId: Optional[String] + Status: Optional[TableRestoreStatusType] + Message: Optional[String] + RequestTime: Optional[TStamp] + ProgressInMegaBytes: Optional[LongOptional] + TotalDataInMegaBytes: Optional[LongOptional] + ClusterIdentifier: Optional[String] + SnapshotIdentifier: Optional[String] + SourceDatabaseName: Optional[String] + SourceSchemaName: Optional[String] + SourceTableName: Optional[String] + TargetDatabaseName: Optional[String] + TargetSchemaName: Optional[String] + NewTableName: Optional[String] + + +class RestoreTableFromClusterSnapshotResult(TypedDict, total=False): + TableRestoreStatus: Optional[TableRestoreStatus] + + +class ResumeClusterResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +class RevokeClusterSecurityGroupIngressMessage(ServiceRequest): + ClusterSecurityGroupName: String + CIDRIP: Optional[String] + EC2SecurityGroupName: Optional[String] + EC2SecurityGroupOwnerId: Optional[String] + + +class RevokeClusterSecurityGroupIngressResult(TypedDict, total=False): + ClusterSecurityGroup: Optional[ClusterSecurityGroup] + + +class RevokeEndpointAccessMessage(ServiceRequest): + ClusterIdentifier: Optional[String] + Account: Optional[String] + VpcIds: Optional[VpcIdentifierList] + Force: Optional[Boolean] + + +class RevokeSnapshotAccessMessage(ServiceRequest): + SnapshotIdentifier: Optional[String] + SnapshotArn: Optional[String] + SnapshotClusterIdentifier: Optional[String] + AccountWithRestoreAccess: String + + +class RevokeSnapshotAccessResult(TypedDict, total=False): + Snapshot: Optional[Snapshot] + + +class RotateEncryptionKeyMessage(ServiceRequest): + ClusterIdentifier: String + + +class RotateEncryptionKeyResult(TypedDict, total=False): + Cluster: Optional[Cluster] + + +ScheduledActionTimeList = List[TStamp] + + +class ScheduledAction(TypedDict, total=False): + ScheduledActionName: Optional[String] + TargetAction: Optional[ScheduledActionType] + Schedule: Optional[String] + IamRole: Optional[String] + ScheduledActionDescription: Optional[String] + State: Optional[ScheduledActionState] + NextInvocations: Optional[ScheduledActionTimeList] + StartTime: Optional[TStamp] + EndTime: Optional[TStamp] + + +ScheduledActionList = List[ScheduledAction] + + +class ScheduledActionsMessage(TypedDict, total=False): + Marker: Optional[String] + ScheduledActions: Optional[ScheduledActionList] + + +SnapshotCopyGrantList = List[SnapshotCopyGrant] + + +class SnapshotCopyGrantMessage(TypedDict, total=False): + Marker: Optional[String] + SnapshotCopyGrants: Optional[SnapshotCopyGrantList] + + +SnapshotList = List[Snapshot] + + +class SnapshotMessage(TypedDict, total=False): + Marker: Optional[String] + Snapshots: Optional[SnapshotList] + + +TableRestoreStatusList = List[TableRestoreStatus] + + +class TableRestoreStatusMessage(TypedDict, total=False): + TableRestoreStatusDetails: Optional[TableRestoreStatusList] + Marker: Optional[String] + + +class TaggedResource(TypedDict, total=False): + Tag: Optional[Tag] + ResourceName: Optional[String] + ResourceType: Optional[String] + + +TaggedResourceList = List[TaggedResource] + + +class TaggedResourceListMessage(TypedDict, total=False): + TaggedResources: Optional[TaggedResourceList] + Marker: Optional[String] + + +TrackList = List[MaintenanceTrack] + + +class TrackListMessage(TypedDict, total=False): + MaintenanceTracks: Optional[TrackList] + Marker: Optional[String] + + +class UpdatePartnerStatusInputMessage(ServiceRequest): + AccountId: PartnerIntegrationAccountId + ClusterIdentifier: PartnerIntegrationClusterIdentifier + DatabaseName: PartnerIntegrationDatabaseName + PartnerName: PartnerIntegrationPartnerName + Status: PartnerIntegrationStatus + StatusMessage: Optional[PartnerIntegrationStatusMessage] + + +class UsageLimit(TypedDict, total=False): + UsageLimitId: Optional[String] + ClusterIdentifier: Optional[String] + FeatureType: Optional[UsageLimitFeatureType] + LimitType: Optional[UsageLimitLimitType] + Amount: Optional[Long] + Period: Optional[UsageLimitPeriod] + BreachAction: Optional[UsageLimitBreachAction] + Tags: Optional[TagList] + + +UsageLimits = List[UsageLimit] + + +class UsageLimitList(TypedDict, total=False): + UsageLimits: Optional[UsageLimits] + Marker: Optional[String] + + +class RedshiftApi: + service = "redshift" + version = "2012-12-01" + + @handler("AcceptReservedNodeExchange") + def accept_reserved_node_exchange( + self, + context: RequestContext, + reserved_node_id: String, + target_reserved_node_offering_id: String, + **kwargs, + ) -> AcceptReservedNodeExchangeOutputMessage: + raise NotImplementedError + + @handler("AddPartner") + def add_partner( + self, + context: RequestContext, + account_id: PartnerIntegrationAccountId, + cluster_identifier: PartnerIntegrationClusterIdentifier, + database_name: PartnerIntegrationDatabaseName, + partner_name: PartnerIntegrationPartnerName, + **kwargs, + ) -> PartnerIntegrationOutputMessage: + raise NotImplementedError + + @handler("AssociateDataShareConsumer") + def associate_data_share_consumer( + self, + context: RequestContext, + data_share_arn: String, + associate_entire_account: BooleanOptional | None = None, + consumer_arn: String | None = None, + consumer_region: String | None = None, + allow_writes: BooleanOptional | None = None, + **kwargs, + ) -> DataShare: + raise NotImplementedError + + @handler("AuthorizeClusterSecurityGroupIngress") + def authorize_cluster_security_group_ingress( + self, + context: RequestContext, + cluster_security_group_name: String, + cidrip: String | None = None, + ec2_security_group_name: String | None = None, + ec2_security_group_owner_id: String | None = None, + **kwargs, + ) -> AuthorizeClusterSecurityGroupIngressResult: + raise NotImplementedError + + @handler("AuthorizeDataShare") + def authorize_data_share( + self, + context: RequestContext, + data_share_arn: String, + consumer_identifier: String, + allow_writes: BooleanOptional | None = None, + **kwargs, + ) -> DataShare: + raise NotImplementedError + + @handler("AuthorizeEndpointAccess") + def authorize_endpoint_access( + self, + context: RequestContext, + account: String, + cluster_identifier: String | None = None, + vpc_ids: VpcIdentifierList | None = None, + **kwargs, + ) -> EndpointAuthorization: + raise NotImplementedError + + @handler("AuthorizeSnapshotAccess") + def authorize_snapshot_access( + self, + context: RequestContext, + account_with_restore_access: String, + snapshot_identifier: String | None = None, + snapshot_arn: String | None = None, + snapshot_cluster_identifier: String | None = None, + **kwargs, + ) -> AuthorizeSnapshotAccessResult: + raise NotImplementedError + + @handler("BatchDeleteClusterSnapshots") + def batch_delete_cluster_snapshots( + self, context: RequestContext, identifiers: DeleteClusterSnapshotMessageList, **kwargs + ) -> BatchDeleteClusterSnapshotsResult: + raise NotImplementedError + + @handler("BatchModifyClusterSnapshots") + def batch_modify_cluster_snapshots( + self, + context: RequestContext, + snapshot_identifier_list: SnapshotIdentifierList, + manual_snapshot_retention_period: IntegerOptional | None = None, + force: Boolean | None = None, + **kwargs, + ) -> BatchModifyClusterSnapshotsOutputMessage: + raise NotImplementedError + + @handler("CancelResize") + def cancel_resize( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> ResizeProgressMessage: + raise NotImplementedError + + @handler("CopyClusterSnapshot") + def copy_cluster_snapshot( + self, + context: RequestContext, + source_snapshot_identifier: String, + target_snapshot_identifier: String, + source_snapshot_cluster_identifier: String | None = None, + manual_snapshot_retention_period: IntegerOptional | None = None, + **kwargs, + ) -> CopyClusterSnapshotResult: + raise NotImplementedError + + @handler("CreateAuthenticationProfile") + def create_authentication_profile( + self, + context: RequestContext, + authentication_profile_name: AuthenticationProfileNameString, + authentication_profile_content: String, + **kwargs, + ) -> CreateAuthenticationProfileResult: + raise NotImplementedError + + @handler("CreateCluster") + def create_cluster( + self, + context: RequestContext, + cluster_identifier: String, + node_type: String, + master_username: String, + db_name: String | None = None, + cluster_type: String | None = None, + master_user_password: SensitiveString | None = None, + cluster_security_groups: ClusterSecurityGroupNameList | None = None, + vpc_security_group_ids: VpcSecurityGroupIdList | None = None, + cluster_subnet_group_name: String | None = None, + availability_zone: String | None = None, + preferred_maintenance_window: String | None = None, + cluster_parameter_group_name: String | None = None, + automated_snapshot_retention_period: IntegerOptional | None = None, + manual_snapshot_retention_period: IntegerOptional | None = None, + port: IntegerOptional | None = None, + cluster_version: String | None = None, + allow_version_upgrade: BooleanOptional | None = None, + number_of_nodes: IntegerOptional | None = None, + publicly_accessible: BooleanOptional | None = None, + encrypted: BooleanOptional | None = None, + hsm_client_certificate_identifier: String | None = None, + hsm_configuration_identifier: String | None = None, + elastic_ip: String | None = None, + tags: TagList | None = None, + kms_key_id: String | None = None, + enhanced_vpc_routing: BooleanOptional | None = None, + additional_info: String | None = None, + iam_roles: IamRoleArnList | None = None, + maintenance_track_name: String | None = None, + snapshot_schedule_identifier: String | None = None, + availability_zone_relocation: BooleanOptional | None = None, + aqua_configuration_status: AquaConfigurationStatus | None = None, + default_iam_role_arn: String | None = None, + load_sample_data: String | None = None, + manage_master_password: BooleanOptional | None = None, + master_password_secret_kms_key_id: String | None = None, + ip_address_type: String | None = None, + multi_az: BooleanOptional | None = None, + redshift_idc_application_arn: String | None = None, + **kwargs, + ) -> CreateClusterResult: + raise NotImplementedError + + @handler("CreateClusterParameterGroup") + def create_cluster_parameter_group( + self, + context: RequestContext, + parameter_group_name: String, + parameter_group_family: String, + description: String, + tags: TagList | None = None, + **kwargs, + ) -> CreateClusterParameterGroupResult: + raise NotImplementedError + + @handler("CreateClusterSecurityGroup") + def create_cluster_security_group( + self, + context: RequestContext, + cluster_security_group_name: String, + description: String, + tags: TagList | None = None, + **kwargs, + ) -> CreateClusterSecurityGroupResult: + raise NotImplementedError + + @handler("CreateClusterSnapshot") + def create_cluster_snapshot( + self, + context: RequestContext, + snapshot_identifier: String, + cluster_identifier: String, + manual_snapshot_retention_period: IntegerOptional | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateClusterSnapshotResult: + raise NotImplementedError + + @handler("CreateClusterSubnetGroup") + def create_cluster_subnet_group( + self, + context: RequestContext, + cluster_subnet_group_name: String, + description: String, + subnet_ids: SubnetIdentifierList, + tags: TagList | None = None, + **kwargs, + ) -> CreateClusterSubnetGroupResult: + raise NotImplementedError + + @handler("CreateCustomDomainAssociation") + def create_custom_domain_association( + self, + context: RequestContext, + custom_domain_name: CustomDomainNameString, + custom_domain_certificate_arn: CustomDomainCertificateArnString, + cluster_identifier: String, + **kwargs, + ) -> CreateCustomDomainAssociationResult: + raise NotImplementedError + + @handler("CreateEndpointAccess") + def create_endpoint_access( + self, + context: RequestContext, + endpoint_name: String, + subnet_group_name: String, + cluster_identifier: String | None = None, + resource_owner: String | None = None, + vpc_security_group_ids: VpcSecurityGroupIdList | None = None, + **kwargs, + ) -> EndpointAccess: + raise NotImplementedError + + @handler("CreateEventSubscription") + def create_event_subscription( + self, + context: RequestContext, + subscription_name: String, + sns_topic_arn: String, + source_type: String | None = None, + source_ids: SourceIdsList | None = None, + event_categories: EventCategoriesList | None = None, + severity: String | None = None, + enabled: BooleanOptional | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateEventSubscriptionResult: + raise NotImplementedError + + @handler("CreateHsmClientCertificate") + def create_hsm_client_certificate( + self, + context: RequestContext, + hsm_client_certificate_identifier: String, + tags: TagList | None = None, + **kwargs, + ) -> CreateHsmClientCertificateResult: + raise NotImplementedError + + @handler("CreateHsmConfiguration") + def create_hsm_configuration( + self, + context: RequestContext, + hsm_configuration_identifier: String, + description: String, + hsm_ip_address: String, + hsm_partition_name: String, + hsm_partition_password: String, + hsm_server_public_certificate: String, + tags: TagList | None = None, + **kwargs, + ) -> CreateHsmConfigurationResult: + raise NotImplementedError + + @handler("CreateIntegration") + def create_integration( + self, + context: RequestContext, + source_arn: SourceArn, + target_arn: TargetArn, + integration_name: IntegrationName, + kms_key_id: String | None = None, + tag_list: TagList | None = None, + additional_encryption_context: EncryptionContextMap | None = None, + description: IntegrationDescription | None = None, + **kwargs, + ) -> Integration: + raise NotImplementedError + + @handler("CreateRedshiftIdcApplication") + def create_redshift_idc_application( + self, + context: RequestContext, + idc_instance_arn: String, + redshift_idc_application_name: RedshiftIdcApplicationName, + idc_display_name: IdcDisplayNameString, + iam_role_arn: String, + identity_namespace: IdentityNamespaceString | None = None, + authorized_token_issuer_list: AuthorizedTokenIssuerList | None = None, + service_integrations: ServiceIntegrationList | None = None, + **kwargs, + ) -> CreateRedshiftIdcApplicationResult: + raise NotImplementedError + + @handler("CreateScheduledAction") + def create_scheduled_action( + self, + context: RequestContext, + scheduled_action_name: String, + target_action: ScheduledActionType, + schedule: String, + iam_role: String, + scheduled_action_description: String | None = None, + start_time: TStamp | None = None, + end_time: TStamp | None = None, + enable: BooleanOptional | None = None, + **kwargs, + ) -> ScheduledAction: + raise NotImplementedError + + @handler("CreateSnapshotCopyGrant") + def create_snapshot_copy_grant( + self, + context: RequestContext, + snapshot_copy_grant_name: String, + kms_key_id: String | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateSnapshotCopyGrantResult: + raise NotImplementedError + + @handler("CreateSnapshotSchedule") + def create_snapshot_schedule( + self, + context: RequestContext, + schedule_definitions: ScheduleDefinitionList | None = None, + schedule_identifier: String | None = None, + schedule_description: String | None = None, + tags: TagList | None = None, + dry_run: BooleanOptional | None = None, + next_invocations: IntegerOptional | None = None, + **kwargs, + ) -> SnapshotSchedule: + raise NotImplementedError + + @handler("CreateTags") + def create_tags( + self, context: RequestContext, resource_name: String, tags: TagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("CreateUsageLimit") + def create_usage_limit( + self, + context: RequestContext, + cluster_identifier: String, + feature_type: UsageLimitFeatureType, + limit_type: UsageLimitLimitType, + amount: Long, + period: UsageLimitPeriod | None = None, + breach_action: UsageLimitBreachAction | None = None, + tags: TagList | None = None, + **kwargs, + ) -> UsageLimit: + raise NotImplementedError + + @handler("DeauthorizeDataShare") + def deauthorize_data_share( + self, context: RequestContext, data_share_arn: String, consumer_identifier: String, **kwargs + ) -> DataShare: + raise NotImplementedError + + @handler("DeleteAuthenticationProfile") + def delete_authentication_profile( + self, + context: RequestContext, + authentication_profile_name: AuthenticationProfileNameString, + **kwargs, + ) -> DeleteAuthenticationProfileResult: + raise NotImplementedError + + @handler("DeleteCluster") + def delete_cluster( + self, + context: RequestContext, + cluster_identifier: String, + skip_final_cluster_snapshot: Boolean | None = None, + final_cluster_snapshot_identifier: String | None = None, + final_cluster_snapshot_retention_period: IntegerOptional | None = None, + **kwargs, + ) -> DeleteClusterResult: + raise NotImplementedError + + @handler("DeleteClusterParameterGroup") + def delete_cluster_parameter_group( + self, context: RequestContext, parameter_group_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteClusterSecurityGroup") + def delete_cluster_security_group( + self, context: RequestContext, cluster_security_group_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteClusterSnapshot") + def delete_cluster_snapshot( + self, + context: RequestContext, + snapshot_identifier: String, + snapshot_cluster_identifier: String | None = None, + **kwargs, + ) -> DeleteClusterSnapshotResult: + raise NotImplementedError + + @handler("DeleteClusterSubnetGroup") + def delete_cluster_subnet_group( + self, context: RequestContext, cluster_subnet_group_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteCustomDomainAssociation") + def delete_custom_domain_association( + self, + context: RequestContext, + cluster_identifier: String, + custom_domain_name: CustomDomainNameString, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteEndpointAccess") + def delete_endpoint_access( + self, context: RequestContext, endpoint_name: String, **kwargs + ) -> EndpointAccess: + raise NotImplementedError + + @handler("DeleteEventSubscription") + def delete_event_subscription( + self, context: RequestContext, subscription_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteHsmClientCertificate") + def delete_hsm_client_certificate( + self, context: RequestContext, hsm_client_certificate_identifier: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteHsmConfiguration") + def delete_hsm_configuration( + self, context: RequestContext, hsm_configuration_identifier: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteIntegration") + def delete_integration( + self, context: RequestContext, integration_arn: IntegrationArn, **kwargs + ) -> Integration: + raise NotImplementedError + + @handler("DeletePartner") + def delete_partner( + self, + context: RequestContext, + account_id: PartnerIntegrationAccountId, + cluster_identifier: PartnerIntegrationClusterIdentifier, + database_name: PartnerIntegrationDatabaseName, + partner_name: PartnerIntegrationPartnerName, + **kwargs, + ) -> PartnerIntegrationOutputMessage: + raise NotImplementedError + + @handler("DeleteRedshiftIdcApplication") + def delete_redshift_idc_application( + self, context: RequestContext, redshift_idc_application_arn: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteResourcePolicy") + def delete_resource_policy( + self, context: RequestContext, resource_arn: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteScheduledAction") + def delete_scheduled_action( + self, context: RequestContext, scheduled_action_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteSnapshotCopyGrant") + def delete_snapshot_copy_grant( + self, context: RequestContext, snapshot_copy_grant_name: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteSnapshotSchedule") + def delete_snapshot_schedule( + self, context: RequestContext, schedule_identifier: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteTags") + def delete_tags( + self, context: RequestContext, resource_name: String, tag_keys: TagKeyList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteUsageLimit") + def delete_usage_limit(self, context: RequestContext, usage_limit_id: String, **kwargs) -> None: + raise NotImplementedError + + @handler("DeregisterNamespace") + def deregister_namespace( + self, + context: RequestContext, + namespace_identifier: NamespaceIdentifierUnion, + consumer_identifiers: ConsumerIdentifierList, + **kwargs, + ) -> DeregisterNamespaceOutputMessage: + raise NotImplementedError + + @handler("DescribeAccountAttributes") + def describe_account_attributes( + self, context: RequestContext, attribute_names: AttributeNameList | None = None, **kwargs + ) -> AccountAttributeList: + raise NotImplementedError + + @handler("DescribeAuthenticationProfiles") + def describe_authentication_profiles( + self, + context: RequestContext, + authentication_profile_name: AuthenticationProfileNameString | None = None, + **kwargs, + ) -> DescribeAuthenticationProfilesResult: + raise NotImplementedError + + @handler("DescribeClusterDbRevisions") + def describe_cluster_db_revisions( + self, + context: RequestContext, + cluster_identifier: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> ClusterDbRevisionsMessage: + raise NotImplementedError + + @handler("DescribeClusterParameterGroups") + def describe_cluster_parameter_groups( + self, + context: RequestContext, + parameter_group_name: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> ClusterParameterGroupsMessage: + raise NotImplementedError + + @handler("DescribeClusterParameters") + def describe_cluster_parameters( + self, + context: RequestContext, + parameter_group_name: String, + source: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> ClusterParameterGroupDetails: + raise NotImplementedError + + @handler("DescribeClusterSecurityGroups") + def describe_cluster_security_groups( + self, + context: RequestContext, + cluster_security_group_name: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> ClusterSecurityGroupMessage: + raise NotImplementedError + + @handler("DescribeClusterSnapshots") + def describe_cluster_snapshots( + self, + context: RequestContext, + cluster_identifier: String | None = None, + snapshot_identifier: String | None = None, + snapshot_arn: String | None = None, + snapshot_type: String | None = None, + start_time: TStamp | None = None, + end_time: TStamp | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + owner_account: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + cluster_exists: BooleanOptional | None = None, + sorting_entities: SnapshotSortingEntityList | None = None, + **kwargs, + ) -> SnapshotMessage: + raise NotImplementedError + + @handler("DescribeClusterSubnetGroups") + def describe_cluster_subnet_groups( + self, + context: RequestContext, + cluster_subnet_group_name: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> ClusterSubnetGroupMessage: + raise NotImplementedError + + @handler("DescribeClusterTracks") + def describe_cluster_tracks( + self, + context: RequestContext, + maintenance_track_name: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> TrackListMessage: + raise NotImplementedError + + @handler("DescribeClusterVersions") + def describe_cluster_versions( + self, + context: RequestContext, + cluster_version: String | None = None, + cluster_parameter_group_family: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> ClusterVersionsMessage: + raise NotImplementedError + + @handler("DescribeClusters") + def describe_clusters( + self, + context: RequestContext, + cluster_identifier: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> ClustersMessage: + raise NotImplementedError + + @handler("DescribeCustomDomainAssociations") + def describe_custom_domain_associations( + self, + context: RequestContext, + custom_domain_name: CustomDomainNameString | None = None, + custom_domain_certificate_arn: CustomDomainCertificateArnString | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> CustomDomainAssociationsMessage: + raise NotImplementedError + + @handler("DescribeDataShares") + def describe_data_shares( + self, + context: RequestContext, + data_share_arn: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> DescribeDataSharesResult: + raise NotImplementedError + + @handler("DescribeDataSharesForConsumer") + def describe_data_shares_for_consumer( + self, + context: RequestContext, + consumer_arn: String | None = None, + status: DataShareStatusForConsumer | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> DescribeDataSharesForConsumerResult: + raise NotImplementedError + + @handler("DescribeDataSharesForProducer") + def describe_data_shares_for_producer( + self, + context: RequestContext, + producer_arn: String | None = None, + status: DataShareStatusForProducer | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> DescribeDataSharesForProducerResult: + raise NotImplementedError + + @handler("DescribeDefaultClusterParameters") + def describe_default_cluster_parameters( + self, + context: RequestContext, + parameter_group_family: String, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> DescribeDefaultClusterParametersResult: + raise NotImplementedError + + @handler("DescribeEndpointAccess") + def describe_endpoint_access( + self, + context: RequestContext, + cluster_identifier: String | None = None, + resource_owner: String | None = None, + endpoint_name: String | None = None, + vpc_id: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> EndpointAccessList: + raise NotImplementedError + + @handler("DescribeEndpointAuthorization") + def describe_endpoint_authorization( + self, + context: RequestContext, + cluster_identifier: String | None = None, + account: String | None = None, + grantee: BooleanOptional | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> EndpointAuthorizationList: + raise NotImplementedError + + @handler("DescribeEventCategories") + def describe_event_categories( + self, context: RequestContext, source_type: String | None = None, **kwargs + ) -> EventCategoriesMessage: + raise NotImplementedError + + @handler("DescribeEventSubscriptions") + def describe_event_subscriptions( + self, + context: RequestContext, + subscription_name: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> EventSubscriptionsMessage: + raise NotImplementedError + + @handler("DescribeEvents") + def describe_events( + self, + context: RequestContext, + source_identifier: String | None = None, + source_type: SourceType | None = None, + start_time: TStamp | None = None, + end_time: TStamp | None = None, + duration: IntegerOptional | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> EventsMessage: + raise NotImplementedError + + @handler("DescribeHsmClientCertificates") + def describe_hsm_client_certificates( + self, + context: RequestContext, + hsm_client_certificate_identifier: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> HsmClientCertificateMessage: + raise NotImplementedError + + @handler("DescribeHsmConfigurations") + def describe_hsm_configurations( + self, + context: RequestContext, + hsm_configuration_identifier: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> HsmConfigurationMessage: + raise NotImplementedError + + @handler("DescribeInboundIntegrations") + def describe_inbound_integrations( + self, + context: RequestContext, + integration_arn: InboundIntegrationArn | None = None, + target_arn: TargetArn | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> InboundIntegrationsMessage: + raise NotImplementedError + + @handler("DescribeIntegrations") + def describe_integrations( + self, + context: RequestContext, + integration_arn: IntegrationArn | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + filters: DescribeIntegrationsFilterList | None = None, + **kwargs, + ) -> IntegrationsMessage: + raise NotImplementedError + + @handler("DescribeLoggingStatus") + def describe_logging_status( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> LoggingStatus: + raise NotImplementedError + + @handler("DescribeNodeConfigurationOptions") + def describe_node_configuration_options( + self, + context: RequestContext, + action_type: ActionType, + cluster_identifier: String | None = None, + snapshot_identifier: String | None = None, + snapshot_arn: String | None = None, + owner_account: String | None = None, + filters: NodeConfigurationOptionsFilterList | None = None, + marker: String | None = None, + max_records: IntegerOptional | None = None, + **kwargs, + ) -> NodeConfigurationOptionsMessage: + raise NotImplementedError + + @handler("DescribeOrderableClusterOptions") + def describe_orderable_cluster_options( + self, + context: RequestContext, + cluster_version: String | None = None, + node_type: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> OrderableClusterOptionsMessage: + raise NotImplementedError + + @handler("DescribePartners") + def describe_partners( + self, + context: RequestContext, + account_id: PartnerIntegrationAccountId, + cluster_identifier: PartnerIntegrationClusterIdentifier, + database_name: PartnerIntegrationDatabaseName | None = None, + partner_name: PartnerIntegrationPartnerName | None = None, + **kwargs, + ) -> DescribePartnersOutputMessage: + raise NotImplementedError + + @handler("DescribeRedshiftIdcApplications") + def describe_redshift_idc_applications( + self, + context: RequestContext, + redshift_idc_application_arn: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> DescribeRedshiftIdcApplicationsResult: + raise NotImplementedError + + @handler("DescribeReservedNodeExchangeStatus") + def describe_reserved_node_exchange_status( + self, + context: RequestContext, + reserved_node_id: String | None = None, + reserved_node_exchange_request_id: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> DescribeReservedNodeExchangeStatusOutputMessage: + raise NotImplementedError + + @handler("DescribeReservedNodeOfferings") + def describe_reserved_node_offerings( + self, + context: RequestContext, + reserved_node_offering_id: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> ReservedNodeOfferingsMessage: + raise NotImplementedError + + @handler("DescribeReservedNodes") + def describe_reserved_nodes( + self, + context: RequestContext, + reserved_node_id: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> ReservedNodesMessage: + raise NotImplementedError + + @handler("DescribeResize") + def describe_resize( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> ResizeProgressMessage: + raise NotImplementedError + + @handler("DescribeScheduledActions") + def describe_scheduled_actions( + self, + context: RequestContext, + scheduled_action_name: String | None = None, + target_action_type: ScheduledActionTypeValues | None = None, + start_time: TStamp | None = None, + end_time: TStamp | None = None, + active: BooleanOptional | None = None, + filters: ScheduledActionFilterList | None = None, + marker: String | None = None, + max_records: IntegerOptional | None = None, + **kwargs, + ) -> ScheduledActionsMessage: + raise NotImplementedError + + @handler("DescribeSnapshotCopyGrants") + def describe_snapshot_copy_grants( + self, + context: RequestContext, + snapshot_copy_grant_name: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> SnapshotCopyGrantMessage: + raise NotImplementedError + + @handler("DescribeSnapshotSchedules") + def describe_snapshot_schedules( + self, + context: RequestContext, + cluster_identifier: String | None = None, + schedule_identifier: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + marker: String | None = None, + max_records: IntegerOptional | None = None, + **kwargs, + ) -> DescribeSnapshotSchedulesOutputMessage: + raise NotImplementedError + + @handler("DescribeStorage") + def describe_storage(self, context: RequestContext, **kwargs) -> CustomerStorageMessage: + raise NotImplementedError + + @handler("DescribeTableRestoreStatus") + def describe_table_restore_status( + self, + context: RequestContext, + cluster_identifier: String | None = None, + table_restore_request_id: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> TableRestoreStatusMessage: + raise NotImplementedError + + @handler("DescribeTags") + def describe_tags( + self, + context: RequestContext, + resource_name: String | None = None, + resource_type: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> TaggedResourceListMessage: + raise NotImplementedError + + @handler("DescribeUsageLimits") + def describe_usage_limits( + self, + context: RequestContext, + usage_limit_id: String | None = None, + cluster_identifier: String | None = None, + feature_type: UsageLimitFeatureType | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + tag_keys: TagKeyList | None = None, + tag_values: TagValueList | None = None, + **kwargs, + ) -> UsageLimitList: + raise NotImplementedError + + @handler("DisableLogging") + def disable_logging( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> LoggingStatus: + raise NotImplementedError + + @handler("DisableSnapshotCopy") + def disable_snapshot_copy( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> DisableSnapshotCopyResult: + raise NotImplementedError + + @handler("DisassociateDataShareConsumer") + def disassociate_data_share_consumer( + self, + context: RequestContext, + data_share_arn: String, + disassociate_entire_account: BooleanOptional | None = None, + consumer_arn: String | None = None, + consumer_region: String | None = None, + **kwargs, + ) -> DataShare: + raise NotImplementedError + + @handler("EnableLogging") + def enable_logging( + self, + context: RequestContext, + cluster_identifier: String, + bucket_name: String | None = None, + s3_key_prefix: S3KeyPrefixValue | None = None, + log_destination_type: LogDestinationType | None = None, + log_exports: LogTypeList | None = None, + **kwargs, + ) -> LoggingStatus: + raise NotImplementedError + + @handler("EnableSnapshotCopy") + def enable_snapshot_copy( + self, + context: RequestContext, + cluster_identifier: String, + destination_region: String, + retention_period: IntegerOptional | None = None, + snapshot_copy_grant_name: String | None = None, + manual_snapshot_retention_period: IntegerOptional | None = None, + **kwargs, + ) -> EnableSnapshotCopyResult: + raise NotImplementedError + + @handler("FailoverPrimaryCompute") + def failover_primary_compute( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> FailoverPrimaryComputeResult: + raise NotImplementedError + + @handler("GetClusterCredentials") + def get_cluster_credentials( + self, + context: RequestContext, + db_user: String, + db_name: String | None = None, + cluster_identifier: String | None = None, + duration_seconds: IntegerOptional | None = None, + auto_create: BooleanOptional | None = None, + db_groups: DbGroupList | None = None, + custom_domain_name: String | None = None, + **kwargs, + ) -> ClusterCredentials: + raise NotImplementedError + + @handler("GetClusterCredentialsWithIAM") + def get_cluster_credentials_with_iam( + self, + context: RequestContext, + db_name: String | None = None, + cluster_identifier: String | None = None, + duration_seconds: IntegerOptional | None = None, + custom_domain_name: String | None = None, + **kwargs, + ) -> ClusterExtendedCredentials: + raise NotImplementedError + + @handler("GetReservedNodeExchangeConfigurationOptions") + def get_reserved_node_exchange_configuration_options( + self, + context: RequestContext, + action_type: ReservedNodeExchangeActionType, + cluster_identifier: String | None = None, + snapshot_identifier: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> GetReservedNodeExchangeConfigurationOptionsOutputMessage: + raise NotImplementedError + + @handler("GetReservedNodeExchangeOfferings") + def get_reserved_node_exchange_offerings( + self, + context: RequestContext, + reserved_node_id: String, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> GetReservedNodeExchangeOfferingsOutputMessage: + raise NotImplementedError + + @handler("GetResourcePolicy") + def get_resource_policy( + self, context: RequestContext, resource_arn: String, **kwargs + ) -> GetResourcePolicyResult: + raise NotImplementedError + + @handler("ListRecommendations") + def list_recommendations( + self, + context: RequestContext, + cluster_identifier: String | None = None, + namespace_arn: String | None = None, + max_records: IntegerOptional | None = None, + marker: String | None = None, + **kwargs, + ) -> ListRecommendationsResult: + raise NotImplementedError + + @handler("ModifyAquaConfiguration") + def modify_aqua_configuration( + self, + context: RequestContext, + cluster_identifier: String, + aqua_configuration_status: AquaConfigurationStatus | None = None, + **kwargs, + ) -> ModifyAquaOutputMessage: + raise NotImplementedError + + @handler("ModifyAuthenticationProfile") + def modify_authentication_profile( + self, + context: RequestContext, + authentication_profile_name: AuthenticationProfileNameString, + authentication_profile_content: String, + **kwargs, + ) -> ModifyAuthenticationProfileResult: + raise NotImplementedError + + @handler("ModifyCluster") + def modify_cluster( + self, + context: RequestContext, + cluster_identifier: String, + cluster_type: String | None = None, + node_type: String | None = None, + number_of_nodes: IntegerOptional | None = None, + cluster_security_groups: ClusterSecurityGroupNameList | None = None, + vpc_security_group_ids: VpcSecurityGroupIdList | None = None, + master_user_password: SensitiveString | None = None, + cluster_parameter_group_name: String | None = None, + automated_snapshot_retention_period: IntegerOptional | None = None, + manual_snapshot_retention_period: IntegerOptional | None = None, + preferred_maintenance_window: String | None = None, + cluster_version: String | None = None, + allow_version_upgrade: BooleanOptional | None = None, + hsm_client_certificate_identifier: String | None = None, + hsm_configuration_identifier: String | None = None, + new_cluster_identifier: String | None = None, + publicly_accessible: BooleanOptional | None = None, + elastic_ip: String | None = None, + enhanced_vpc_routing: BooleanOptional | None = None, + maintenance_track_name: String | None = None, + encrypted: BooleanOptional | None = None, + kms_key_id: String | None = None, + availability_zone_relocation: BooleanOptional | None = None, + availability_zone: String | None = None, + port: IntegerOptional | None = None, + manage_master_password: BooleanOptional | None = None, + master_password_secret_kms_key_id: String | None = None, + ip_address_type: String | None = None, + multi_az: BooleanOptional | None = None, + **kwargs, + ) -> ModifyClusterResult: + raise NotImplementedError + + @handler("ModifyClusterDbRevision") + def modify_cluster_db_revision( + self, context: RequestContext, cluster_identifier: String, revision_target: String, **kwargs + ) -> ModifyClusterDbRevisionResult: + raise NotImplementedError + + @handler("ModifyClusterIamRoles") + def modify_cluster_iam_roles( + self, + context: RequestContext, + cluster_identifier: String, + add_iam_roles: IamRoleArnList | None = None, + remove_iam_roles: IamRoleArnList | None = None, + default_iam_role_arn: String | None = None, + **kwargs, + ) -> ModifyClusterIamRolesResult: + raise NotImplementedError + + @handler("ModifyClusterMaintenance") + def modify_cluster_maintenance( + self, + context: RequestContext, + cluster_identifier: String, + defer_maintenance: BooleanOptional | None = None, + defer_maintenance_identifier: String | None = None, + defer_maintenance_start_time: TStamp | None = None, + defer_maintenance_end_time: TStamp | None = None, + defer_maintenance_duration: IntegerOptional | None = None, + **kwargs, + ) -> ModifyClusterMaintenanceResult: + raise NotImplementedError + + @handler("ModifyClusterParameterGroup") + def modify_cluster_parameter_group( + self, + context: RequestContext, + parameter_group_name: String, + parameters: ParametersList, + **kwargs, + ) -> ClusterParameterGroupNameMessage: + raise NotImplementedError + + @handler("ModifyClusterSnapshot") + def modify_cluster_snapshot( + self, + context: RequestContext, + snapshot_identifier: String, + manual_snapshot_retention_period: IntegerOptional | None = None, + force: Boolean | None = None, + **kwargs, + ) -> ModifyClusterSnapshotResult: + raise NotImplementedError + + @handler("ModifyClusterSnapshotSchedule") + def modify_cluster_snapshot_schedule( + self, + context: RequestContext, + cluster_identifier: String, + schedule_identifier: String | None = None, + disassociate_schedule: BooleanOptional | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ModifyClusterSubnetGroup") + def modify_cluster_subnet_group( + self, + context: RequestContext, + cluster_subnet_group_name: String, + subnet_ids: SubnetIdentifierList, + description: String | None = None, + **kwargs, + ) -> ModifyClusterSubnetGroupResult: + raise NotImplementedError + + @handler("ModifyCustomDomainAssociation") + def modify_custom_domain_association( + self, + context: RequestContext, + custom_domain_name: CustomDomainNameString, + custom_domain_certificate_arn: CustomDomainCertificateArnString, + cluster_identifier: String, + **kwargs, + ) -> ModifyCustomDomainAssociationResult: + raise NotImplementedError + + @handler("ModifyEndpointAccess") + def modify_endpoint_access( + self, + context: RequestContext, + endpoint_name: String, + vpc_security_group_ids: VpcSecurityGroupIdList | None = None, + **kwargs, + ) -> EndpointAccess: + raise NotImplementedError + + @handler("ModifyEventSubscription") + def modify_event_subscription( + self, + context: RequestContext, + subscription_name: String, + sns_topic_arn: String | None = None, + source_type: String | None = None, + source_ids: SourceIdsList | None = None, + event_categories: EventCategoriesList | None = None, + severity: String | None = None, + enabled: BooleanOptional | None = None, + **kwargs, + ) -> ModifyEventSubscriptionResult: + raise NotImplementedError + + @handler("ModifyIntegration") + def modify_integration( + self, + context: RequestContext, + integration_arn: IntegrationArn, + description: IntegrationDescription | None = None, + integration_name: IntegrationName | None = None, + **kwargs, + ) -> Integration: + raise NotImplementedError + + @handler("ModifyRedshiftIdcApplication") + def modify_redshift_idc_application( + self, + context: RequestContext, + redshift_idc_application_arn: String, + identity_namespace: IdentityNamespaceString | None = None, + iam_role_arn: String | None = None, + idc_display_name: IdcDisplayNameString | None = None, + authorized_token_issuer_list: AuthorizedTokenIssuerList | None = None, + service_integrations: ServiceIntegrationList | None = None, + **kwargs, + ) -> ModifyRedshiftIdcApplicationResult: + raise NotImplementedError + + @handler("ModifyScheduledAction") + def modify_scheduled_action( + self, + context: RequestContext, + scheduled_action_name: String, + target_action: ScheduledActionType | None = None, + schedule: String | None = None, + iam_role: String | None = None, + scheduled_action_description: String | None = None, + start_time: TStamp | None = None, + end_time: TStamp | None = None, + enable: BooleanOptional | None = None, + **kwargs, + ) -> ScheduledAction: + raise NotImplementedError + + @handler("ModifySnapshotCopyRetentionPeriod") + def modify_snapshot_copy_retention_period( + self, + context: RequestContext, + cluster_identifier: String, + retention_period: Integer, + manual: Boolean | None = None, + **kwargs, + ) -> ModifySnapshotCopyRetentionPeriodResult: + raise NotImplementedError + + @handler("ModifySnapshotSchedule") + def modify_snapshot_schedule( + self, + context: RequestContext, + schedule_identifier: String, + schedule_definitions: ScheduleDefinitionList, + **kwargs, + ) -> SnapshotSchedule: + raise NotImplementedError + + @handler("ModifyUsageLimit") + def modify_usage_limit( + self, + context: RequestContext, + usage_limit_id: String, + amount: LongOptional | None = None, + breach_action: UsageLimitBreachAction | None = None, + **kwargs, + ) -> UsageLimit: + raise NotImplementedError + + @handler("PauseCluster") + def pause_cluster( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> PauseClusterResult: + raise NotImplementedError + + @handler("PurchaseReservedNodeOffering") + def purchase_reserved_node_offering( + self, + context: RequestContext, + reserved_node_offering_id: String, + node_count: IntegerOptional | None = None, + **kwargs, + ) -> PurchaseReservedNodeOfferingResult: + raise NotImplementedError + + @handler("PutResourcePolicy") + def put_resource_policy( + self, context: RequestContext, resource_arn: String, policy: String, **kwargs + ) -> PutResourcePolicyResult: + raise NotImplementedError + + @handler("RebootCluster") + def reboot_cluster( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> RebootClusterResult: + raise NotImplementedError + + @handler("RegisterNamespace") + def register_namespace( + self, + context: RequestContext, + namespace_identifier: NamespaceIdentifierUnion, + consumer_identifiers: ConsumerIdentifierList, + **kwargs, + ) -> RegisterNamespaceOutputMessage: + raise NotImplementedError + + @handler("RejectDataShare") + def reject_data_share( + self, context: RequestContext, data_share_arn: String, **kwargs + ) -> DataShare: + raise NotImplementedError + + @handler("ResetClusterParameterGroup") + def reset_cluster_parameter_group( + self, + context: RequestContext, + parameter_group_name: String, + reset_all_parameters: Boolean | None = None, + parameters: ParametersList | None = None, + **kwargs, + ) -> ClusterParameterGroupNameMessage: + raise NotImplementedError + + @handler("ResizeCluster") + def resize_cluster( + self, + context: RequestContext, + cluster_identifier: String, + cluster_type: String | None = None, + node_type: String | None = None, + number_of_nodes: IntegerOptional | None = None, + classic: BooleanOptional | None = None, + reserved_node_id: String | None = None, + target_reserved_node_offering_id: String | None = None, + **kwargs, + ) -> ResizeClusterResult: + raise NotImplementedError + + @handler("RestoreFromClusterSnapshot") + def restore_from_cluster_snapshot( + self, + context: RequestContext, + cluster_identifier: String, + snapshot_identifier: String | None = None, + snapshot_arn: String | None = None, + snapshot_cluster_identifier: String | None = None, + port: IntegerOptional | None = None, + availability_zone: String | None = None, + allow_version_upgrade: BooleanOptional | None = None, + cluster_subnet_group_name: String | None = None, + publicly_accessible: BooleanOptional | None = None, + owner_account: String | None = None, + hsm_client_certificate_identifier: String | None = None, + hsm_configuration_identifier: String | None = None, + elastic_ip: String | None = None, + cluster_parameter_group_name: String | None = None, + cluster_security_groups: ClusterSecurityGroupNameList | None = None, + vpc_security_group_ids: VpcSecurityGroupIdList | None = None, + preferred_maintenance_window: String | None = None, + automated_snapshot_retention_period: IntegerOptional | None = None, + manual_snapshot_retention_period: IntegerOptional | None = None, + kms_key_id: String | None = None, + node_type: String | None = None, + enhanced_vpc_routing: BooleanOptional | None = None, + additional_info: String | None = None, + iam_roles: IamRoleArnList | None = None, + maintenance_track_name: String | None = None, + snapshot_schedule_identifier: String | None = None, + number_of_nodes: IntegerOptional | None = None, + availability_zone_relocation: BooleanOptional | None = None, + aqua_configuration_status: AquaConfigurationStatus | None = None, + default_iam_role_arn: String | None = None, + reserved_node_id: String | None = None, + target_reserved_node_offering_id: String | None = None, + encrypted: BooleanOptional | None = None, + manage_master_password: BooleanOptional | None = None, + master_password_secret_kms_key_id: String | None = None, + ip_address_type: String | None = None, + multi_az: BooleanOptional | None = None, + **kwargs, + ) -> RestoreFromClusterSnapshotResult: + raise NotImplementedError + + @handler("RestoreTableFromClusterSnapshot") + def restore_table_from_cluster_snapshot( + self, + context: RequestContext, + cluster_identifier: String, + snapshot_identifier: String, + source_database_name: String, + source_table_name: String, + new_table_name: String, + source_schema_name: String | None = None, + target_database_name: String | None = None, + target_schema_name: String | None = None, + enable_case_sensitive_identifier: BooleanOptional | None = None, + **kwargs, + ) -> RestoreTableFromClusterSnapshotResult: + raise NotImplementedError + + @handler("ResumeCluster") + def resume_cluster( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> ResumeClusterResult: + raise NotImplementedError + + @handler("RevokeClusterSecurityGroupIngress") + def revoke_cluster_security_group_ingress( + self, + context: RequestContext, + cluster_security_group_name: String, + cidrip: String | None = None, + ec2_security_group_name: String | None = None, + ec2_security_group_owner_id: String | None = None, + **kwargs, + ) -> RevokeClusterSecurityGroupIngressResult: + raise NotImplementedError + + @handler("RevokeEndpointAccess") + def revoke_endpoint_access( + self, + context: RequestContext, + cluster_identifier: String | None = None, + account: String | None = None, + vpc_ids: VpcIdentifierList | None = None, + force: Boolean | None = None, + **kwargs, + ) -> EndpointAuthorization: + raise NotImplementedError + + @handler("RevokeSnapshotAccess") + def revoke_snapshot_access( + self, + context: RequestContext, + account_with_restore_access: String, + snapshot_identifier: String | None = None, + snapshot_arn: String | None = None, + snapshot_cluster_identifier: String | None = None, + **kwargs, + ) -> RevokeSnapshotAccessResult: + raise NotImplementedError + + @handler("RotateEncryptionKey") + def rotate_encryption_key( + self, context: RequestContext, cluster_identifier: String, **kwargs + ) -> RotateEncryptionKeyResult: + raise NotImplementedError + + @handler("UpdatePartnerStatus") + def update_partner_status( + self, + context: RequestContext, + account_id: PartnerIntegrationAccountId, + cluster_identifier: PartnerIntegrationClusterIdentifier, + database_name: PartnerIntegrationDatabaseName, + partner_name: PartnerIntegrationPartnerName, + status: PartnerIntegrationStatus, + status_message: PartnerIntegrationStatusMessage | None = None, + **kwargs, + ) -> PartnerIntegrationOutputMessage: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/resource_groups/__init__.py b/localstack-core/localstack/aws/api/resource_groups/__init__.py new file mode 100644 index 0000000000000..b7511726ef579 --- /dev/null +++ b/localstack-core/localstack/aws/api/resource_groups/__init__.py @@ -0,0 +1,805 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ApplicationArn = str +ApplicationTagKey = str +CreateGroupName = str +Criticality = int +Description = str +DisplayName = str +ErrorCode = str +ErrorMessage = str +GroupArn = str +GroupArnV2 = str +GroupConfigurationFailureReason = str +GroupConfigurationParameterName = str +GroupConfigurationParameterValue = str +GroupConfigurationType = str +GroupFilterValue = str +GroupLifecycleEventsStatusMessage = str +GroupName = str +GroupString = str +GroupStringV2 = str +ListGroupingStatusesFilterValue = str +MaxResults = int +NextToken = str +Owner = str +Query = str +QueryErrorMessage = str +ResourceArn = str +ResourceFilterValue = str +ResourceType = str +RoleArn = str +TagKey = str +TagSyncTaskArn = str +TagValue = str + + +class GroupConfigurationStatus(StrEnum): + UPDATING = "UPDATING" + UPDATE_COMPLETE = "UPDATE_COMPLETE" + UPDATE_FAILED = "UPDATE_FAILED" + + +class GroupFilterName(StrEnum): + resource_type = "resource-type" + configuration_type = "configuration-type" + owner = "owner" + display_name = "display-name" + criticality = "criticality" + + +class GroupLifecycleEventsDesiredStatus(StrEnum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + + +class GroupLifecycleEventsStatus(StrEnum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + IN_PROGRESS = "IN_PROGRESS" + ERROR = "ERROR" + + +class GroupingStatus(StrEnum): + SUCCESS = "SUCCESS" + FAILED = "FAILED" + IN_PROGRESS = "IN_PROGRESS" + SKIPPED = "SKIPPED" + + +class GroupingType(StrEnum): + GROUP = "GROUP" + UNGROUP = "UNGROUP" + + +class ListGroupingStatusesFilterName(StrEnum): + status = "status" + resource_arn = "resource-arn" + + +class QueryErrorCode(StrEnum): + CLOUDFORMATION_STACK_INACTIVE = "CLOUDFORMATION_STACK_INACTIVE" + CLOUDFORMATION_STACK_NOT_EXISTING = "CLOUDFORMATION_STACK_NOT_EXISTING" + CLOUDFORMATION_STACK_UNASSUMABLE_ROLE = "CLOUDFORMATION_STACK_UNASSUMABLE_ROLE" + RESOURCE_TYPE_NOT_SUPPORTED = "RESOURCE_TYPE_NOT_SUPPORTED" + + +class QueryType(StrEnum): + TAG_FILTERS_1_0 = "TAG_FILTERS_1_0" + CLOUDFORMATION_STACK_1_0 = "CLOUDFORMATION_STACK_1_0" + + +class ResourceFilterName(StrEnum): + resource_type = "resource-type" + + +class ResourceStatusValue(StrEnum): + PENDING = "PENDING" + + +class TagSyncTaskStatus(StrEnum): + ACTIVE = "ACTIVE" + ERROR = "ERROR" + + +class BadRequestException(ServiceException): + code: str = "BadRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class ForbiddenException(ServiceException): + code: str = "ForbiddenException" + sender_fault: bool = False + status_code: int = 403 + + +class InternalServerErrorException(ServiceException): + code: str = "InternalServerErrorException" + sender_fault: bool = False + status_code: int = 500 + + +class MethodNotAllowedException(ServiceException): + code: str = "MethodNotAllowedException" + sender_fault: bool = False + status_code: int = 405 + + +class NotFoundException(ServiceException): + code: str = "NotFoundException" + sender_fault: bool = False + status_code: int = 404 + + +class TooManyRequestsException(ServiceException): + code: str = "TooManyRequestsException" + sender_fault: bool = False + status_code: int = 429 + + +class UnauthorizedException(ServiceException): + code: str = "UnauthorizedException" + sender_fault: bool = False + status_code: int = 401 + + +class AccountSettings(TypedDict, total=False): + GroupLifecycleEventsDesiredStatus: Optional[GroupLifecycleEventsDesiredStatus] + GroupLifecycleEventsStatus: Optional[GroupLifecycleEventsStatus] + GroupLifecycleEventsStatusMessage: Optional[GroupLifecycleEventsStatusMessage] + + +ApplicationTag = Dict[ApplicationTagKey, ApplicationArn] + + +class CancelTagSyncTaskInput(ServiceRequest): + TaskArn: TagSyncTaskArn + + +GroupConfigurationParameterValueList = List[GroupConfigurationParameterValue] + + +class GroupConfigurationParameter(TypedDict, total=False): + Name: GroupConfigurationParameterName + Values: Optional[GroupConfigurationParameterValueList] + + +GroupParameterList = List[GroupConfigurationParameter] + + +class GroupConfigurationItem(TypedDict, total=False): + Type: GroupConfigurationType + Parameters: Optional[GroupParameterList] + + +GroupConfigurationList = List[GroupConfigurationItem] +Tags = Dict[TagKey, TagValue] + + +class ResourceQuery(TypedDict, total=False): + Type: QueryType + Query: Query + + +class CreateGroupInput(ServiceRequest): + Name: CreateGroupName + Description: Optional[Description] + ResourceQuery: Optional[ResourceQuery] + Tags: Optional[Tags] + Configuration: Optional[GroupConfigurationList] + Criticality: Optional[Criticality] + Owner: Optional[Owner] + DisplayName: Optional[DisplayName] + + +class GroupConfiguration(TypedDict, total=False): + Configuration: Optional[GroupConfigurationList] + ProposedConfiguration: Optional[GroupConfigurationList] + Status: Optional[GroupConfigurationStatus] + FailureReason: Optional[GroupConfigurationFailureReason] + + +class Group(TypedDict, total=False): + GroupArn: GroupArnV2 + Name: GroupName + Description: Optional[Description] + Criticality: Optional[Criticality] + Owner: Optional[Owner] + DisplayName: Optional[DisplayName] + ApplicationTag: Optional[ApplicationTag] + + +class CreateGroupOutput(TypedDict, total=False): + Group: Optional[Group] + ResourceQuery: Optional[ResourceQuery] + Tags: Optional[Tags] + GroupConfiguration: Optional[GroupConfiguration] + + +class DeleteGroupInput(ServiceRequest): + GroupName: Optional[GroupName] + Group: Optional[GroupStringV2] + + +class DeleteGroupOutput(TypedDict, total=False): + Group: Optional[Group] + + +class FailedResource(TypedDict, total=False): + ResourceArn: Optional[ResourceArn] + ErrorMessage: Optional[ErrorMessage] + ErrorCode: Optional[ErrorCode] + + +FailedResourceList = List[FailedResource] + + +class GetAccountSettingsOutput(TypedDict, total=False): + AccountSettings: Optional[AccountSettings] + + +class GetGroupConfigurationInput(ServiceRequest): + Group: Optional[GroupString] + + +class GetGroupConfigurationOutput(TypedDict, total=False): + GroupConfiguration: Optional[GroupConfiguration] + + +class GetGroupInput(ServiceRequest): + GroupName: Optional[GroupName] + Group: Optional[GroupStringV2] + + +class GetGroupOutput(TypedDict, total=False): + Group: Optional[Group] + + +class GetGroupQueryInput(ServiceRequest): + GroupName: Optional[GroupName] + Group: Optional[GroupString] + + +class GroupQuery(TypedDict, total=False): + GroupName: GroupName + ResourceQuery: ResourceQuery + + +class GetGroupQueryOutput(TypedDict, total=False): + GroupQuery: Optional[GroupQuery] + + +class GetTagSyncTaskInput(ServiceRequest): + TaskArn: TagSyncTaskArn + + +timestamp = datetime + + +class GetTagSyncTaskOutput(TypedDict, total=False): + GroupArn: Optional[GroupArnV2] + GroupName: Optional[GroupName] + TaskArn: Optional[TagSyncTaskArn] + TagKey: Optional[TagKey] + TagValue: Optional[TagValue] + ResourceQuery: Optional[ResourceQuery] + RoleArn: Optional[RoleArn] + Status: Optional[TagSyncTaskStatus] + ErrorMessage: Optional[ErrorMessage] + CreatedAt: Optional[timestamp] + + +class GetTagsInput(ServiceRequest): + Arn: GroupArnV2 + + +class GetTagsOutput(TypedDict, total=False): + Arn: Optional[GroupArnV2] + Tags: Optional[Tags] + + +GroupFilterValues = List[GroupFilterValue] + + +class GroupFilter(TypedDict, total=False): + Name: GroupFilterName + Values: GroupFilterValues + + +GroupFilterList = List[GroupFilter] + + +class GroupIdentifier(TypedDict, total=False): + GroupName: Optional[GroupName] + GroupArn: Optional[GroupArn] + Description: Optional[Description] + Criticality: Optional[Criticality] + Owner: Optional[Owner] + DisplayName: Optional[DisplayName] + + +GroupIdentifierList = List[GroupIdentifier] +GroupList = List[Group] +ResourceArnList = List[ResourceArn] + + +class GroupResourcesInput(ServiceRequest): + Group: GroupStringV2 + ResourceArns: ResourceArnList + + +class PendingResource(TypedDict, total=False): + ResourceArn: Optional[ResourceArn] + + +PendingResourceList = List[PendingResource] + + +class GroupResourcesOutput(TypedDict, total=False): + Succeeded: Optional[ResourceArnList] + Failed: Optional[FailedResourceList] + Pending: Optional[PendingResourceList] + + +class GroupingStatusesItem(TypedDict, total=False): + ResourceArn: Optional[ResourceArn] + Action: Optional[GroupingType] + Status: Optional[GroupingStatus] + ErrorMessage: Optional[ErrorMessage] + ErrorCode: Optional[ErrorCode] + UpdatedAt: Optional[timestamp] + + +GroupingStatusesList = List[GroupingStatusesItem] +ResourceFilterValues = List[ResourceFilterValue] + + +class ResourceFilter(TypedDict, total=False): + Name: ResourceFilterName + Values: ResourceFilterValues + + +ResourceFilterList = List[ResourceFilter] + + +class ListGroupResourcesInput(ServiceRequest): + GroupName: Optional[GroupName] + Group: Optional[GroupStringV2] + Filters: Optional[ResourceFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ResourceStatus(TypedDict, total=False): + Name: Optional[ResourceStatusValue] + + +class ResourceIdentifier(TypedDict, total=False): + ResourceArn: Optional[ResourceArn] + ResourceType: Optional[ResourceType] + + +class ListGroupResourcesItem(TypedDict, total=False): + Identifier: Optional[ResourceIdentifier] + Status: Optional[ResourceStatus] + + +ListGroupResourcesItemList = List[ListGroupResourcesItem] + + +class QueryError(TypedDict, total=False): + ErrorCode: Optional[QueryErrorCode] + Message: Optional[QueryErrorMessage] + + +QueryErrorList = List[QueryError] +ResourceIdentifierList = List[ResourceIdentifier] + + +class ListGroupResourcesOutput(TypedDict, total=False): + Resources: Optional[ListGroupResourcesItemList] + ResourceIdentifiers: Optional[ResourceIdentifierList] + NextToken: Optional[NextToken] + QueryErrors: Optional[QueryErrorList] + + +ListGroupingStatusesFilterValues = List[ListGroupingStatusesFilterValue] + + +class ListGroupingStatusesFilter(TypedDict, total=False): + Name: ListGroupingStatusesFilterName + Values: ListGroupingStatusesFilterValues + + +ListGroupingStatusesFilterList = List[ListGroupingStatusesFilter] + + +class ListGroupingStatusesInput(ServiceRequest): + Group: GroupStringV2 + MaxResults: Optional[MaxResults] + Filters: Optional[ListGroupingStatusesFilterList] + NextToken: Optional[NextToken] + + +class ListGroupingStatusesOutput(TypedDict, total=False): + Group: Optional[GroupStringV2] + GroupingStatuses: Optional[GroupingStatusesList] + NextToken: Optional[NextToken] + + +class ListGroupsInput(ServiceRequest): + Filters: Optional[GroupFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListGroupsOutput(TypedDict, total=False): + GroupIdentifiers: Optional[GroupIdentifierList] + Groups: Optional[GroupList] + NextToken: Optional[NextToken] + + +class ListTagSyncTasksFilter(TypedDict, total=False): + GroupArn: Optional[GroupArnV2] + GroupName: Optional[GroupName] + + +ListTagSyncTasksFilterList = List[ListTagSyncTasksFilter] + + +class ListTagSyncTasksInput(ServiceRequest): + Filters: Optional[ListTagSyncTasksFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class TagSyncTaskItem(TypedDict, total=False): + GroupArn: Optional[GroupArnV2] + GroupName: Optional[GroupName] + TaskArn: Optional[TagSyncTaskArn] + TagKey: Optional[TagKey] + TagValue: Optional[TagValue] + ResourceQuery: Optional[ResourceQuery] + RoleArn: Optional[RoleArn] + Status: Optional[TagSyncTaskStatus] + ErrorMessage: Optional[ErrorMessage] + CreatedAt: Optional[timestamp] + + +TagSyncTaskList = List[TagSyncTaskItem] + + +class ListTagSyncTasksOutput(TypedDict, total=False): + TagSyncTasks: Optional[TagSyncTaskList] + NextToken: Optional[NextToken] + + +class PutGroupConfigurationInput(ServiceRequest): + Group: Optional[GroupString] + Configuration: Optional[GroupConfigurationList] + + +class PutGroupConfigurationOutput(TypedDict, total=False): + pass + + +class SearchResourcesInput(ServiceRequest): + ResourceQuery: ResourceQuery + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class SearchResourcesOutput(TypedDict, total=False): + ResourceIdentifiers: Optional[ResourceIdentifierList] + NextToken: Optional[NextToken] + QueryErrors: Optional[QueryErrorList] + + +class StartTagSyncTaskInput(ServiceRequest): + Group: GroupStringV2 + TagKey: Optional[TagKey] + TagValue: Optional[TagValue] + ResourceQuery: Optional[ResourceQuery] + RoleArn: RoleArn + + +class StartTagSyncTaskOutput(TypedDict, total=False): + GroupArn: Optional[GroupArnV2] + GroupName: Optional[GroupName] + TaskArn: Optional[TagSyncTaskArn] + TagKey: Optional[TagKey] + TagValue: Optional[TagValue] + ResourceQuery: Optional[ResourceQuery] + RoleArn: Optional[RoleArn] + + +class TagInput(ServiceRequest): + Arn: GroupArnV2 + Tags: Tags + + +TagKeyList = List[TagKey] + + +class TagOutput(TypedDict, total=False): + Arn: Optional[GroupArnV2] + Tags: Optional[Tags] + + +class UngroupResourcesInput(ServiceRequest): + Group: GroupStringV2 + ResourceArns: ResourceArnList + + +class UngroupResourcesOutput(TypedDict, total=False): + Succeeded: Optional[ResourceArnList] + Failed: Optional[FailedResourceList] + Pending: Optional[PendingResourceList] + + +class UntagInput(ServiceRequest): + Arn: GroupArnV2 + Keys: TagKeyList + + +class UntagOutput(TypedDict, total=False): + Arn: Optional[GroupArnV2] + Keys: Optional[TagKeyList] + + +class UpdateAccountSettingsInput(ServiceRequest): + GroupLifecycleEventsDesiredStatus: Optional[GroupLifecycleEventsDesiredStatus] + + +class UpdateAccountSettingsOutput(TypedDict, total=False): + AccountSettings: Optional[AccountSettings] + + +class UpdateGroupInput(ServiceRequest): + GroupName: Optional[GroupName] + Group: Optional[GroupStringV2] + Description: Optional[Description] + Criticality: Optional[Criticality] + Owner: Optional[Owner] + DisplayName: Optional[DisplayName] + + +class UpdateGroupOutput(TypedDict, total=False): + Group: Optional[Group] + + +class UpdateGroupQueryInput(ServiceRequest): + GroupName: Optional[GroupName] + Group: Optional[GroupString] + ResourceQuery: ResourceQuery + + +class UpdateGroupQueryOutput(TypedDict, total=False): + GroupQuery: Optional[GroupQuery] + + +class ResourceGroupsApi: + service = "resource-groups" + version = "2017-11-27" + + @handler("CancelTagSyncTask") + def cancel_tag_sync_task( + self, context: RequestContext, task_arn: TagSyncTaskArn, **kwargs + ) -> None: + raise NotImplementedError + + @handler("CreateGroup") + def create_group( + self, + context: RequestContext, + name: CreateGroupName, + description: Description | None = None, + resource_query: ResourceQuery | None = None, + tags: Tags | None = None, + configuration: GroupConfigurationList | None = None, + criticality: Criticality | None = None, + owner: Owner | None = None, + display_name: DisplayName | None = None, + **kwargs, + ) -> CreateGroupOutput: + raise NotImplementedError + + @handler("DeleteGroup") + def delete_group( + self, + context: RequestContext, + group_name: GroupName | None = None, + group: GroupStringV2 | None = None, + **kwargs, + ) -> DeleteGroupOutput: + raise NotImplementedError + + @handler("GetAccountSettings") + def get_account_settings(self, context: RequestContext, **kwargs) -> GetAccountSettingsOutput: + raise NotImplementedError + + @handler("GetGroup") + def get_group( + self, + context: RequestContext, + group_name: GroupName | None = None, + group: GroupStringV2 | None = None, + **kwargs, + ) -> GetGroupOutput: + raise NotImplementedError + + @handler("GetGroupConfiguration") + def get_group_configuration( + self, context: RequestContext, group: GroupString | None = None, **kwargs + ) -> GetGroupConfigurationOutput: + raise NotImplementedError + + @handler("GetGroupQuery") + def get_group_query( + self, + context: RequestContext, + group_name: GroupName | None = None, + group: GroupString | None = None, + **kwargs, + ) -> GetGroupQueryOutput: + raise NotImplementedError + + @handler("GetTagSyncTask") + def get_tag_sync_task( + self, context: RequestContext, task_arn: TagSyncTaskArn, **kwargs + ) -> GetTagSyncTaskOutput: + raise NotImplementedError + + @handler("GetTags") + def get_tags(self, context: RequestContext, arn: GroupArnV2, **kwargs) -> GetTagsOutput: + raise NotImplementedError + + @handler("GroupResources") + def group_resources( + self, + context: RequestContext, + group: GroupStringV2, + resource_arns: ResourceArnList, + **kwargs, + ) -> GroupResourcesOutput: + raise NotImplementedError + + @handler("ListGroupResources") + def list_group_resources( + self, + context: RequestContext, + group_name: GroupName | None = None, + group: GroupStringV2 | None = None, + filters: ResourceFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListGroupResourcesOutput: + raise NotImplementedError + + @handler("ListGroupingStatuses") + def list_grouping_statuses( + self, + context: RequestContext, + group: GroupStringV2, + max_results: MaxResults | None = None, + filters: ListGroupingStatusesFilterList | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListGroupingStatusesOutput: + raise NotImplementedError + + @handler("ListGroups") + def list_groups( + self, + context: RequestContext, + filters: GroupFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListGroupsOutput: + raise NotImplementedError + + @handler("ListTagSyncTasks") + def list_tag_sync_tasks( + self, + context: RequestContext, + filters: ListTagSyncTasksFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListTagSyncTasksOutput: + raise NotImplementedError + + @handler("PutGroupConfiguration") + def put_group_configuration( + self, + context: RequestContext, + group: GroupString | None = None, + configuration: GroupConfigurationList | None = None, + **kwargs, + ) -> PutGroupConfigurationOutput: + raise NotImplementedError + + @handler("SearchResources") + def search_resources( + self, + context: RequestContext, + resource_query: ResourceQuery, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> SearchResourcesOutput: + raise NotImplementedError + + @handler("StartTagSyncTask") + def start_tag_sync_task( + self, + context: RequestContext, + group: GroupStringV2, + role_arn: RoleArn, + tag_key: TagKey | None = None, + tag_value: TagValue | None = None, + resource_query: ResourceQuery | None = None, + **kwargs, + ) -> StartTagSyncTaskOutput: + raise NotImplementedError + + @handler("Tag") + def tag(self, context: RequestContext, arn: GroupArnV2, tags: Tags, **kwargs) -> TagOutput: + raise NotImplementedError + + @handler("UngroupResources") + def ungroup_resources( + self, + context: RequestContext, + group: GroupStringV2, + resource_arns: ResourceArnList, + **kwargs, + ) -> UngroupResourcesOutput: + raise NotImplementedError + + @handler("Untag") + def untag( + self, context: RequestContext, arn: GroupArnV2, keys: TagKeyList, **kwargs + ) -> UntagOutput: + raise NotImplementedError + + @handler("UpdateAccountSettings") + def update_account_settings( + self, + context: RequestContext, + group_lifecycle_events_desired_status: GroupLifecycleEventsDesiredStatus | None = None, + **kwargs, + ) -> UpdateAccountSettingsOutput: + raise NotImplementedError + + @handler("UpdateGroup") + def update_group( + self, + context: RequestContext, + group_name: GroupName | None = None, + group: GroupStringV2 | None = None, + description: Description | None = None, + criticality: Criticality | None = None, + owner: Owner | None = None, + display_name: DisplayName | None = None, + **kwargs, + ) -> UpdateGroupOutput: + raise NotImplementedError + + @handler("UpdateGroupQuery") + def update_group_query( + self, + context: RequestContext, + resource_query: ResourceQuery, + group_name: GroupName | None = None, + group: GroupString | None = None, + **kwargs, + ) -> UpdateGroupQueryOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/resourcegroupstaggingapi/__init__.py b/localstack-core/localstack/aws/api/resourcegroupstaggingapi/__init__.py new file mode 100644 index 0000000000000..cc496818d3120 --- /dev/null +++ b/localstack-core/localstack/aws/api/resourcegroupstaggingapi/__init__.py @@ -0,0 +1,325 @@ +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AmazonResourceType = str +ComplianceStatus = bool +ErrorMessage = str +ExceptionMessage = str +ExcludeCompliantResources = bool +IncludeComplianceDetails = bool +LastUpdated = str +MaxResultsGetComplianceSummary = int +PaginationToken = str +Region = str +ResourceARN = str +ResourcesPerPage = int +S3Bucket = str +S3Location = str +Status = str +StatusCode = int +TagKey = str +TagValue = str +TagsPerPage = int +TargetId = str + + +class ErrorCode(StrEnum): + InternalServiceException = "InternalServiceException" + InvalidParameterException = "InvalidParameterException" + + +class GroupByAttribute(StrEnum): + TARGET_ID = "TARGET_ID" + REGION = "REGION" + RESOURCE_TYPE = "RESOURCE_TYPE" + + +class TargetIdType(StrEnum): + ACCOUNT = "ACCOUNT" + OU = "OU" + ROOT = "ROOT" + + +class ConcurrentModificationException(ServiceException): + code: str = "ConcurrentModificationException" + sender_fault: bool = False + status_code: int = 400 + + +class ConstraintViolationException(ServiceException): + code: str = "ConstraintViolationException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServiceException(ServiceException): + code: str = "InternalServiceException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidParameterException(ServiceException): + code: str = "InvalidParameterException" + sender_fault: bool = False + status_code: int = 400 + + +class PaginationTokenExpiredException(ServiceException): + code: str = "PaginationTokenExpiredException" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottledException(ServiceException): + code: str = "ThrottledException" + sender_fault: bool = False + status_code: int = 400 + + +TagKeyList = List[TagKey] + + +class ComplianceDetails(TypedDict, total=False): + NoncompliantKeys: Optional[TagKeyList] + KeysWithNoncompliantValues: Optional[TagKeyList] + ComplianceStatus: Optional[ComplianceStatus] + + +class DescribeReportCreationInput(ServiceRequest): + pass + + +class DescribeReportCreationOutput(TypedDict, total=False): + Status: Optional[Status] + S3Location: Optional[S3Location] + ErrorMessage: Optional[ErrorMessage] + + +class FailureInfo(TypedDict, total=False): + StatusCode: Optional[StatusCode] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +FailedResourcesMap = Dict[ResourceARN, FailureInfo] +GroupBy = List[GroupByAttribute] +TagKeyFilterList = List[TagKey] +ResourceTypeFilterList = List[AmazonResourceType] +RegionFilterList = List[Region] +TargetIdFilterList = List[TargetId] + + +class GetComplianceSummaryInput(ServiceRequest): + TargetIdFilters: Optional[TargetIdFilterList] + RegionFilters: Optional[RegionFilterList] + ResourceTypeFilters: Optional[ResourceTypeFilterList] + TagKeyFilters: Optional[TagKeyFilterList] + GroupBy: Optional[GroupBy] + MaxResults: Optional[MaxResultsGetComplianceSummary] + PaginationToken: Optional[PaginationToken] + + +NonCompliantResources = int + + +class Summary(TypedDict, total=False): + LastUpdated: Optional[LastUpdated] + TargetId: Optional[TargetId] + TargetIdType: Optional[TargetIdType] + Region: Optional[Region] + ResourceType: Optional[AmazonResourceType] + NonCompliantResources: Optional[NonCompliantResources] + + +SummaryList = List[Summary] + + +class GetComplianceSummaryOutput(TypedDict, total=False): + SummaryList: Optional[SummaryList] + PaginationToken: Optional[PaginationToken] + + +ResourceARNListForGet = List[ResourceARN] +TagValueList = List[TagValue] + + +class TagFilter(TypedDict, total=False): + Key: Optional[TagKey] + Values: Optional[TagValueList] + + +TagFilterList = List[TagFilter] + + +class GetResourcesInput(ServiceRequest): + PaginationToken: Optional[PaginationToken] + TagFilters: Optional[TagFilterList] + ResourcesPerPage: Optional[ResourcesPerPage] + TagsPerPage: Optional[TagsPerPage] + ResourceTypeFilters: Optional[ResourceTypeFilterList] + IncludeComplianceDetails: Optional[IncludeComplianceDetails] + ExcludeCompliantResources: Optional[ExcludeCompliantResources] + ResourceARNList: Optional[ResourceARNListForGet] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class ResourceTagMapping(TypedDict, total=False): + ResourceARN: Optional[ResourceARN] + Tags: Optional[TagList] + ComplianceDetails: Optional[ComplianceDetails] + + +ResourceTagMappingList = List[ResourceTagMapping] + + +class GetResourcesOutput(TypedDict, total=False): + PaginationToken: Optional[PaginationToken] + ResourceTagMappingList: Optional[ResourceTagMappingList] + + +class GetTagKeysInput(ServiceRequest): + PaginationToken: Optional[PaginationToken] + + +class GetTagKeysOutput(TypedDict, total=False): + PaginationToken: Optional[PaginationToken] + TagKeys: Optional[TagKeyList] + + +class GetTagValuesInput(ServiceRequest): + PaginationToken: Optional[PaginationToken] + Key: TagKey + + +TagValuesOutputList = List[TagValue] + + +class GetTagValuesOutput(TypedDict, total=False): + PaginationToken: Optional[PaginationToken] + TagValues: Optional[TagValuesOutputList] + + +ResourceARNListForTagUntag = List[ResourceARN] + + +class StartReportCreationInput(ServiceRequest): + S3Bucket: S3Bucket + + +class StartReportCreationOutput(TypedDict, total=False): + pass + + +TagKeyListForUntag = List[TagKey] +TagMap = Dict[TagKey, TagValue] + + +class TagResourcesInput(ServiceRequest): + ResourceARNList: ResourceARNListForTagUntag + Tags: TagMap + + +class TagResourcesOutput(TypedDict, total=False): + FailedResourcesMap: Optional[FailedResourcesMap] + + +class UntagResourcesInput(ServiceRequest): + ResourceARNList: ResourceARNListForTagUntag + TagKeys: TagKeyListForUntag + + +class UntagResourcesOutput(TypedDict, total=False): + FailedResourcesMap: Optional[FailedResourcesMap] + + +class ResourcegroupstaggingapiApi: + service = "resourcegroupstaggingapi" + version = "2017-01-26" + + @handler("DescribeReportCreation") + def describe_report_creation( + self, context: RequestContext, **kwargs + ) -> DescribeReportCreationOutput: + raise NotImplementedError + + @handler("GetComplianceSummary") + def get_compliance_summary( + self, + context: RequestContext, + target_id_filters: TargetIdFilterList | None = None, + region_filters: RegionFilterList | None = None, + resource_type_filters: ResourceTypeFilterList | None = None, + tag_key_filters: TagKeyFilterList | None = None, + group_by: GroupBy | None = None, + max_results: MaxResultsGetComplianceSummary | None = None, + pagination_token: PaginationToken | None = None, + **kwargs, + ) -> GetComplianceSummaryOutput: + raise NotImplementedError + + @handler("GetResources") + def get_resources( + self, + context: RequestContext, + pagination_token: PaginationToken | None = None, + tag_filters: TagFilterList | None = None, + resources_per_page: ResourcesPerPage | None = None, + tags_per_page: TagsPerPage | None = None, + resource_type_filters: ResourceTypeFilterList | None = None, + include_compliance_details: IncludeComplianceDetails | None = None, + exclude_compliant_resources: ExcludeCompliantResources | None = None, + resource_arn_list: ResourceARNListForGet | None = None, + **kwargs, + ) -> GetResourcesOutput: + raise NotImplementedError + + @handler("GetTagKeys") + def get_tag_keys( + self, context: RequestContext, pagination_token: PaginationToken | None = None, **kwargs + ) -> GetTagKeysOutput: + raise NotImplementedError + + @handler("GetTagValues") + def get_tag_values( + self, + context: RequestContext, + key: TagKey, + pagination_token: PaginationToken | None = None, + **kwargs, + ) -> GetTagValuesOutput: + raise NotImplementedError + + @handler("StartReportCreation") + def start_report_creation( + self, context: RequestContext, s3_bucket: S3Bucket, **kwargs + ) -> StartReportCreationOutput: + raise NotImplementedError + + @handler("TagResources") + def tag_resources( + self, + context: RequestContext, + resource_arn_list: ResourceARNListForTagUntag, + tags: TagMap, + **kwargs, + ) -> TagResourcesOutput: + raise NotImplementedError + + @handler("UntagResources") + def untag_resources( + self, + context: RequestContext, + resource_arn_list: ResourceARNListForTagUntag, + tag_keys: TagKeyListForUntag, + **kwargs, + ) -> UntagResourcesOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/route53/__init__.py b/localstack-core/localstack/aws/api/route53/__init__.py new file mode 100644 index 0000000000000..cc139c41afd03 --- /dev/null +++ b/localstack-core/localstack/aws/api/route53/__init__.py @@ -0,0 +1,2562 @@ +from datetime import datetime +from enum import StrEnum +from typing import List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ARN = str +AWSAccountID = str +AWSRegion = str +AlarmName = str +AliasHealthEnabled = bool +AssociateVPCComment = str +Bias = int +ChangeId = str +Cidr = str +CidrLocationNameDefaultAllowed = str +CidrLocationNameDefaultNotAllowed = str +CidrNonce = str +CloudWatchLogsLogGroupArn = str +CollectionName = str +DNSName = str +DNSRCode = str +DimensionField = str +Disabled = bool +DisassociateVPCComment = str +EnableSNI = bool +ErrorMessage = str +EvaluationPeriods = int +FailureThreshold = int +FullyQualifiedDomainName = str +GeoLocationContinentCode = str +GeoLocationContinentName = str +GeoLocationCountryCode = str +GeoLocationCountryName = str +GeoLocationSubdivisionCode = str +GeoLocationSubdivisionName = str +HealthCheckId = str +HealthCheckNonce = str +HealthThreshold = int +HostedZoneOwningService = str +IPAddress = str +IPAddressCidr = str +Inverted = bool +IsPrivateZone = bool +Latitude = str +LocalZoneGroup = str +Longitude = str +MaxResults = str +MeasureLatency = bool +Message = str +MetricName = str +Nameserver = str +Namespace = str +Nonce = str +PageMarker = str +PageMaxItems = str +PageTruncated = bool +PaginationToken = str +Period = int +Port = int +QueryLoggingConfigId = str +RData = str +RecordDataEntry = str +RequestInterval = int +ResourceDescription = str +ResourceId = str +ResourcePath = str +ResourceRecordSetIdentifier = str +ResourceRecordSetMultiValueAnswer = bool +ResourceURI = str +RoutingControlArn = str +SearchString = str +ServeSignature = str +ServicePrincipal = str +SigningKeyInteger = int +SigningKeyName = str +SigningKeyStatus = str +SigningKeyStatusMessage = str +SigningKeyString = str +SigningKeyTag = int +Status = str +SubnetMask = str +TagKey = str +TagResourceId = str +TagValue = str +Threshold = float +TrafficPolicyComment = str +TrafficPolicyDocument = str +TrafficPolicyId = str +TrafficPolicyInstanceCount = int +TrafficPolicyInstanceId = str +TrafficPolicyInstanceState = str +TrafficPolicyName = str +TrafficPolicyVersion = int +TrafficPolicyVersionMarker = str +TransportProtocol = str +UUID = str +VPCId = str + + +class AccountLimitType(StrEnum): + MAX_HEALTH_CHECKS_BY_OWNER = "MAX_HEALTH_CHECKS_BY_OWNER" + MAX_HOSTED_ZONES_BY_OWNER = "MAX_HOSTED_ZONES_BY_OWNER" + MAX_TRAFFIC_POLICY_INSTANCES_BY_OWNER = "MAX_TRAFFIC_POLICY_INSTANCES_BY_OWNER" + MAX_REUSABLE_DELEGATION_SETS_BY_OWNER = "MAX_REUSABLE_DELEGATION_SETS_BY_OWNER" + MAX_TRAFFIC_POLICIES_BY_OWNER = "MAX_TRAFFIC_POLICIES_BY_OWNER" + + +class ChangeAction(StrEnum): + CREATE = "CREATE" + DELETE = "DELETE" + UPSERT = "UPSERT" + + +class ChangeStatus(StrEnum): + PENDING = "PENDING" + INSYNC = "INSYNC" + + +class CidrCollectionChangeAction(StrEnum): + PUT = "PUT" + DELETE_IF_EXISTS = "DELETE_IF_EXISTS" + + +class CloudWatchRegion(StrEnum): + us_east_1 = "us-east-1" + us_east_2 = "us-east-2" + us_west_1 = "us-west-1" + us_west_2 = "us-west-2" + ca_central_1 = "ca-central-1" + eu_central_1 = "eu-central-1" + eu_central_2 = "eu-central-2" + eu_west_1 = "eu-west-1" + eu_west_2 = "eu-west-2" + eu_west_3 = "eu-west-3" + ap_east_1 = "ap-east-1" + me_south_1 = "me-south-1" + me_central_1 = "me-central-1" + ap_south_1 = "ap-south-1" + ap_south_2 = "ap-south-2" + ap_southeast_1 = "ap-southeast-1" + ap_southeast_2 = "ap-southeast-2" + ap_southeast_3 = "ap-southeast-3" + ap_northeast_1 = "ap-northeast-1" + ap_northeast_2 = "ap-northeast-2" + ap_northeast_3 = "ap-northeast-3" + eu_north_1 = "eu-north-1" + sa_east_1 = "sa-east-1" + cn_northwest_1 = "cn-northwest-1" + cn_north_1 = "cn-north-1" + af_south_1 = "af-south-1" + eu_south_1 = "eu-south-1" + eu_south_2 = "eu-south-2" + us_gov_west_1 = "us-gov-west-1" + us_gov_east_1 = "us-gov-east-1" + us_iso_east_1 = "us-iso-east-1" + us_iso_west_1 = "us-iso-west-1" + us_isob_east_1 = "us-isob-east-1" + ap_southeast_4 = "ap-southeast-4" + il_central_1 = "il-central-1" + ca_west_1 = "ca-west-1" + ap_southeast_5 = "ap-southeast-5" + mx_central_1 = "mx-central-1" + us_isof_south_1 = "us-isof-south-1" + us_isof_east_1 = "us-isof-east-1" + ap_southeast_7 = "ap-southeast-7" + ap_east_2 = "ap-east-2" + eu_isoe_west_1 = "eu-isoe-west-1" + + +class ComparisonOperator(StrEnum): + GreaterThanOrEqualToThreshold = "GreaterThanOrEqualToThreshold" + GreaterThanThreshold = "GreaterThanThreshold" + LessThanThreshold = "LessThanThreshold" + LessThanOrEqualToThreshold = "LessThanOrEqualToThreshold" + + +class HealthCheckRegion(StrEnum): + us_east_1 = "us-east-1" + us_west_1 = "us-west-1" + us_west_2 = "us-west-2" + eu_west_1 = "eu-west-1" + ap_southeast_1 = "ap-southeast-1" + ap_southeast_2 = "ap-southeast-2" + ap_northeast_1 = "ap-northeast-1" + sa_east_1 = "sa-east-1" + + +class HealthCheckType(StrEnum): + HTTP = "HTTP" + HTTPS = "HTTPS" + HTTP_STR_MATCH = "HTTP_STR_MATCH" + HTTPS_STR_MATCH = "HTTPS_STR_MATCH" + TCP = "TCP" + CALCULATED = "CALCULATED" + CLOUDWATCH_METRIC = "CLOUDWATCH_METRIC" + RECOVERY_CONTROL = "RECOVERY_CONTROL" + + +class HostedZoneLimitType(StrEnum): + MAX_RRSETS_BY_ZONE = "MAX_RRSETS_BY_ZONE" + MAX_VPCS_ASSOCIATED_BY_ZONE = "MAX_VPCS_ASSOCIATED_BY_ZONE" + + +class HostedZoneType(StrEnum): + PrivateHostedZone = "PrivateHostedZone" + + +class InsufficientDataHealthStatus(StrEnum): + Healthy = "Healthy" + Unhealthy = "Unhealthy" + LastKnownStatus = "LastKnownStatus" + + +class RRType(StrEnum): + SOA = "SOA" + A = "A" + TXT = "TXT" + NS = "NS" + CNAME = "CNAME" + MX = "MX" + NAPTR = "NAPTR" + PTR = "PTR" + SRV = "SRV" + SPF = "SPF" + AAAA = "AAAA" + CAA = "CAA" + DS = "DS" + TLSA = "TLSA" + SSHFP = "SSHFP" + SVCB = "SVCB" + HTTPS = "HTTPS" + + +class ResettableElementName(StrEnum): + FullyQualifiedDomainName = "FullyQualifiedDomainName" + Regions = "Regions" + ResourcePath = "ResourcePath" + ChildHealthChecks = "ChildHealthChecks" + + +class ResourceRecordSetFailover(StrEnum): + PRIMARY = "PRIMARY" + SECONDARY = "SECONDARY" + + +class ResourceRecordSetRegion(StrEnum): + us_east_1 = "us-east-1" + us_east_2 = "us-east-2" + us_west_1 = "us-west-1" + us_west_2 = "us-west-2" + ca_central_1 = "ca-central-1" + eu_west_1 = "eu-west-1" + eu_west_2 = "eu-west-2" + eu_west_3 = "eu-west-3" + eu_central_1 = "eu-central-1" + eu_central_2 = "eu-central-2" + ap_southeast_1 = "ap-southeast-1" + ap_southeast_2 = "ap-southeast-2" + ap_southeast_3 = "ap-southeast-3" + ap_northeast_1 = "ap-northeast-1" + ap_northeast_2 = "ap-northeast-2" + ap_northeast_3 = "ap-northeast-3" + eu_north_1 = "eu-north-1" + sa_east_1 = "sa-east-1" + cn_north_1 = "cn-north-1" + cn_northwest_1 = "cn-northwest-1" + ap_east_1 = "ap-east-1" + me_south_1 = "me-south-1" + me_central_1 = "me-central-1" + ap_south_1 = "ap-south-1" + ap_south_2 = "ap-south-2" + af_south_1 = "af-south-1" + eu_south_1 = "eu-south-1" + eu_south_2 = "eu-south-2" + ap_southeast_4 = "ap-southeast-4" + il_central_1 = "il-central-1" + ca_west_1 = "ca-west-1" + ap_southeast_5 = "ap-southeast-5" + mx_central_1 = "mx-central-1" + ap_southeast_7 = "ap-southeast-7" + us_gov_east_1 = "us-gov-east-1" + us_gov_west_1 = "us-gov-west-1" + ap_east_2 = "ap-east-2" + + +class ReusableDelegationSetLimitType(StrEnum): + MAX_ZONES_BY_REUSABLE_DELEGATION_SET = "MAX_ZONES_BY_REUSABLE_DELEGATION_SET" + + +class Statistic(StrEnum): + Average = "Average" + Sum = "Sum" + SampleCount = "SampleCount" + Maximum = "Maximum" + Minimum = "Minimum" + + +class TagResourceType(StrEnum): + healthcheck = "healthcheck" + hostedzone = "hostedzone" + + +class VPCRegion(StrEnum): + us_east_1 = "us-east-1" + us_east_2 = "us-east-2" + us_west_1 = "us-west-1" + us_west_2 = "us-west-2" + eu_west_1 = "eu-west-1" + eu_west_2 = "eu-west-2" + eu_west_3 = "eu-west-3" + eu_central_1 = "eu-central-1" + eu_central_2 = "eu-central-2" + ap_east_1 = "ap-east-1" + me_south_1 = "me-south-1" + us_gov_west_1 = "us-gov-west-1" + us_gov_east_1 = "us-gov-east-1" + us_iso_east_1 = "us-iso-east-1" + us_iso_west_1 = "us-iso-west-1" + us_isob_east_1 = "us-isob-east-1" + me_central_1 = "me-central-1" + ap_southeast_1 = "ap-southeast-1" + ap_southeast_2 = "ap-southeast-2" + ap_southeast_3 = "ap-southeast-3" + ap_south_1 = "ap-south-1" + ap_south_2 = "ap-south-2" + ap_northeast_1 = "ap-northeast-1" + ap_northeast_2 = "ap-northeast-2" + ap_northeast_3 = "ap-northeast-3" + eu_north_1 = "eu-north-1" + sa_east_1 = "sa-east-1" + ca_central_1 = "ca-central-1" + cn_north_1 = "cn-north-1" + cn_northwest_1 = "cn-northwest-1" + af_south_1 = "af-south-1" + eu_south_1 = "eu-south-1" + eu_south_2 = "eu-south-2" + ap_southeast_4 = "ap-southeast-4" + il_central_1 = "il-central-1" + ca_west_1 = "ca-west-1" + ap_southeast_5 = "ap-southeast-5" + mx_central_1 = "mx-central-1" + us_isof_south_1 = "us-isof-south-1" + us_isof_east_1 = "us-isof-east-1" + ap_southeast_7 = "ap-southeast-7" + ap_east_2 = "ap-east-2" + eu_isoe_west_1 = "eu-isoe-west-1" + + +class CidrBlockInUseException(ServiceException): + code: str = "CidrBlockInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class CidrCollectionAlreadyExistsException(ServiceException): + code: str = "CidrCollectionAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class CidrCollectionInUseException(ServiceException): + code: str = "CidrCollectionInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class CidrCollectionVersionMismatchException(ServiceException): + code: str = "CidrCollectionVersionMismatchException" + sender_fault: bool = False + status_code: int = 409 + + +class ConcurrentModification(ServiceException): + code: str = "ConcurrentModification" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictingDomainExists(ServiceException): + code: str = "ConflictingDomainExists" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictingTypes(ServiceException): + code: str = "ConflictingTypes" + sender_fault: bool = False + status_code: int = 400 + + +class DNSSECNotFound(ServiceException): + code: str = "DNSSECNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class DelegationSetAlreadyCreated(ServiceException): + code: str = "DelegationSetAlreadyCreated" + sender_fault: bool = False + status_code: int = 400 + + +class DelegationSetAlreadyReusable(ServiceException): + code: str = "DelegationSetAlreadyReusable" + sender_fault: bool = False + status_code: int = 400 + + +class DelegationSetInUse(ServiceException): + code: str = "DelegationSetInUse" + sender_fault: bool = False + status_code: int = 400 + + +class DelegationSetNotAvailable(ServiceException): + code: str = "DelegationSetNotAvailable" + sender_fault: bool = False + status_code: int = 400 + + +class DelegationSetNotReusable(ServiceException): + code: str = "DelegationSetNotReusable" + sender_fault: bool = False + status_code: int = 400 + + +class HealthCheckAlreadyExists(ServiceException): + code: str = "HealthCheckAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + +class HealthCheckInUse(ServiceException): + code: str = "HealthCheckInUse" + sender_fault: bool = False + status_code: int = 400 + + +class HealthCheckVersionMismatch(ServiceException): + code: str = "HealthCheckVersionMismatch" + sender_fault: bool = False + status_code: int = 409 + + +class HostedZoneAlreadyExists(ServiceException): + code: str = "HostedZoneAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + +class HostedZoneNotEmpty(ServiceException): + code: str = "HostedZoneNotEmpty" + sender_fault: bool = False + status_code: int = 400 + + +class HostedZoneNotFound(ServiceException): + code: str = "HostedZoneNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class HostedZoneNotPrivate(ServiceException): + code: str = "HostedZoneNotPrivate" + sender_fault: bool = False + status_code: int = 400 + + +class HostedZonePartiallyDelegated(ServiceException): + code: str = "HostedZonePartiallyDelegated" + sender_fault: bool = False + status_code: int = 400 + + +class IncompatibleVersion(ServiceException): + code: str = "IncompatibleVersion" + sender_fault: bool = False + status_code: int = 400 + + +class InsufficientCloudWatchLogsResourcePolicy(ServiceException): + code: str = "InsufficientCloudWatchLogsResourcePolicy" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidArgument(ServiceException): + code: str = "InvalidArgument" + sender_fault: bool = False + status_code: int = 400 + + +ErrorMessages = List[ErrorMessage] + + +class InvalidChangeBatch(ServiceException): + code: str = "InvalidChangeBatch" + sender_fault: bool = False + status_code: int = 400 + messages: Optional[ErrorMessages] + + +class InvalidDomainName(ServiceException): + code: str = "InvalidDomainName" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidInput(ServiceException): + code: str = "InvalidInput" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidKMSArn(ServiceException): + code: str = "InvalidKMSArn" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidKeySigningKeyName(ServiceException): + code: str = "InvalidKeySigningKeyName" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidKeySigningKeyStatus(ServiceException): + code: str = "InvalidKeySigningKeyStatus" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidPaginationToken(ServiceException): + code: str = "InvalidPaginationToken" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidSigningStatus(ServiceException): + code: str = "InvalidSigningStatus" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTrafficPolicyDocument(ServiceException): + code: str = "InvalidTrafficPolicyDocument" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidVPCId(ServiceException): + code: str = "InvalidVPCId" + sender_fault: bool = False + status_code: int = 400 + + +class KeySigningKeyAlreadyExists(ServiceException): + code: str = "KeySigningKeyAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + +class KeySigningKeyInParentDSRecord(ServiceException): + code: str = "KeySigningKeyInParentDSRecord" + sender_fault: bool = False + status_code: int = 400 + + +class KeySigningKeyInUse(ServiceException): + code: str = "KeySigningKeyInUse" + sender_fault: bool = False + status_code: int = 400 + + +class KeySigningKeyWithActiveStatusNotFound(ServiceException): + code: str = "KeySigningKeyWithActiveStatusNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class LastVPCAssociation(ServiceException): + code: str = "LastVPCAssociation" + sender_fault: bool = False + status_code: int = 400 + + +class LimitsExceeded(ServiceException): + code: str = "LimitsExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchChange(ServiceException): + code: str = "NoSuchChange" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchCidrCollectionException(ServiceException): + code: str = "NoSuchCidrCollectionException" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchCidrLocationException(ServiceException): + code: str = "NoSuchCidrLocationException" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchCloudWatchLogsLogGroup(ServiceException): + code: str = "NoSuchCloudWatchLogsLogGroup" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchDelegationSet(ServiceException): + code: str = "NoSuchDelegationSet" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchGeoLocation(ServiceException): + code: str = "NoSuchGeoLocation" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchHealthCheck(ServiceException): + code: str = "NoSuchHealthCheck" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchHostedZone(ServiceException): + code: str = "NoSuchHostedZone" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchKeySigningKey(ServiceException): + code: str = "NoSuchKeySigningKey" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchQueryLoggingConfig(ServiceException): + code: str = "NoSuchQueryLoggingConfig" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchTrafficPolicy(ServiceException): + code: str = "NoSuchTrafficPolicy" + sender_fault: bool = False + status_code: int = 404 + + +class NoSuchTrafficPolicyInstance(ServiceException): + code: str = "NoSuchTrafficPolicyInstance" + sender_fault: bool = False + status_code: int = 404 + + +class NotAuthorizedException(ServiceException): + code: str = "NotAuthorizedException" + sender_fault: bool = False + status_code: int = 401 + + +class PriorRequestNotComplete(ServiceException): + code: str = "PriorRequestNotComplete" + sender_fault: bool = False + status_code: int = 400 + + +class PublicZoneVPCAssociation(ServiceException): + code: str = "PublicZoneVPCAssociation" + sender_fault: bool = False + status_code: int = 400 + + +class QueryLoggingConfigAlreadyExists(ServiceException): + code: str = "QueryLoggingConfigAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyHealthChecks(ServiceException): + code: str = "TooManyHealthChecks" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyHostedZones(ServiceException): + code: str = "TooManyHostedZones" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyKeySigningKeys(ServiceException): + code: str = "TooManyKeySigningKeys" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTrafficPolicies(ServiceException): + code: str = "TooManyTrafficPolicies" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTrafficPolicyInstances(ServiceException): + code: str = "TooManyTrafficPolicyInstances" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTrafficPolicyVersionsForCurrentPolicy(ServiceException): + code: str = "TooManyTrafficPolicyVersionsForCurrentPolicy" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyVPCAssociationAuthorizations(ServiceException): + code: str = "TooManyVPCAssociationAuthorizations" + sender_fault: bool = False + status_code: int = 400 + + +class TrafficPolicyAlreadyExists(ServiceException): + code: str = "TrafficPolicyAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + +class TrafficPolicyInUse(ServiceException): + code: str = "TrafficPolicyInUse" + sender_fault: bool = False + status_code: int = 400 + + +class TrafficPolicyInstanceAlreadyExists(ServiceException): + code: str = "TrafficPolicyInstanceAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + +class VPCAssociationAuthorizationNotFound(ServiceException): + code: str = "VPCAssociationAuthorizationNotFound" + sender_fault: bool = False + status_code: int = 404 + + +class VPCAssociationNotFound(ServiceException): + code: str = "VPCAssociationNotFound" + sender_fault: bool = False + status_code: int = 404 + + +LimitValue = int + + +class AccountLimit(TypedDict, total=False): + Type: AccountLimitType + Value: LimitValue + + +class ActivateKeySigningKeyRequest(ServiceRequest): + HostedZoneId: ResourceId + Name: SigningKeyName + + +TimeStamp = datetime + + +class ChangeInfo(TypedDict, total=False): + Id: ResourceId + Status: ChangeStatus + SubmittedAt: TimeStamp + Comment: Optional[ResourceDescription] + + +class ActivateKeySigningKeyResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class AlarmIdentifier(TypedDict, total=False): + Region: CloudWatchRegion + Name: AlarmName + + +class AliasTarget(TypedDict, total=False): + HostedZoneId: ResourceId + DNSName: DNSName + EvaluateTargetHealth: AliasHealthEnabled + + +class VPC(TypedDict, total=False): + VPCRegion: Optional[VPCRegion] + VPCId: Optional[VPCId] + + +class AssociateVPCWithHostedZoneRequest(ServiceRequest): + HostedZoneId: ResourceId + VPC: VPC + Comment: Optional[AssociateVPCComment] + + +class AssociateVPCWithHostedZoneResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class Coordinates(TypedDict, total=False): + Latitude: Latitude + Longitude: Longitude + + +class GeoProximityLocation(TypedDict, total=False): + AWSRegion: Optional[AWSRegion] + LocalZoneGroup: Optional[LocalZoneGroup] + Coordinates: Optional[Coordinates] + Bias: Optional[Bias] + + +class CidrRoutingConfig(TypedDict, total=False): + CollectionId: UUID + LocationName: CidrLocationNameDefaultAllowed + + +class ResourceRecord(TypedDict, total=False): + Value: RData + + +ResourceRecords = List[ResourceRecord] +TTL = int + + +class GeoLocation(TypedDict, total=False): + ContinentCode: Optional[GeoLocationContinentCode] + CountryCode: Optional[GeoLocationCountryCode] + SubdivisionCode: Optional[GeoLocationSubdivisionCode] + + +ResourceRecordSetWeight = int + + +class ResourceRecordSet(TypedDict, total=False): + Name: DNSName + Type: RRType + SetIdentifier: Optional[ResourceRecordSetIdentifier] + Weight: Optional[ResourceRecordSetWeight] + Region: Optional[ResourceRecordSetRegion] + GeoLocation: Optional[GeoLocation] + Failover: Optional[ResourceRecordSetFailover] + MultiValueAnswer: Optional[ResourceRecordSetMultiValueAnswer] + TTL: Optional[TTL] + ResourceRecords: Optional[ResourceRecords] + AliasTarget: Optional[AliasTarget] + HealthCheckId: Optional[HealthCheckId] + TrafficPolicyInstanceId: Optional[TrafficPolicyInstanceId] + CidrRoutingConfig: Optional[CidrRoutingConfig] + GeoProximityLocation: Optional[GeoProximityLocation] + + +class Change(TypedDict, total=False): + Action: ChangeAction + ResourceRecordSet: ResourceRecordSet + + +Changes = List[Change] + + +class ChangeBatch(TypedDict, total=False): + Comment: Optional[ResourceDescription] + Changes: Changes + + +CidrList = List[Cidr] + + +class CidrCollectionChange(TypedDict, total=False): + LocationName: CidrLocationNameDefaultNotAllowed + Action: CidrCollectionChangeAction + CidrList: CidrList + + +CidrCollectionChanges = List[CidrCollectionChange] +CollectionVersion = int + + +class ChangeCidrCollectionRequest(ServiceRequest): + Id: UUID + CollectionVersion: Optional[CollectionVersion] + Changes: CidrCollectionChanges + + +class ChangeCidrCollectionResponse(TypedDict, total=False): + Id: ChangeId + + +class ChangeResourceRecordSetsRequest(ServiceRequest): + HostedZoneId: ResourceId + ChangeBatch: ChangeBatch + + +class ChangeResourceRecordSetsResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +TagKeyList = List[TagKey] + + +class Tag(TypedDict, total=False): + Key: Optional[TagKey] + Value: Optional[TagValue] + + +TagList = List[Tag] + + +class ChangeTagsForResourceRequest(ServiceRequest): + ResourceType: TagResourceType + ResourceId: TagResourceId + AddTags: Optional[TagList] + RemoveTagKeys: Optional[TagKeyList] + + +class ChangeTagsForResourceResponse(TypedDict, total=False): + pass + + +CheckerIpRanges = List[IPAddressCidr] +ChildHealthCheckList = List[HealthCheckId] + + +class CidrBlockSummary(TypedDict, total=False): + CidrBlock: Optional[Cidr] + LocationName: Optional[CidrLocationNameDefaultNotAllowed] + + +CidrBlockSummaries = List[CidrBlockSummary] + + +class CidrCollection(TypedDict, total=False): + Arn: Optional[ARN] + Id: Optional[UUID] + Name: Optional[CollectionName] + Version: Optional[CollectionVersion] + + +class Dimension(TypedDict, total=False): + Name: DimensionField + Value: DimensionField + + +DimensionList = List[Dimension] + + +class CloudWatchAlarmConfiguration(TypedDict, total=False): + EvaluationPeriods: EvaluationPeriods + Threshold: Threshold + ComparisonOperator: ComparisonOperator + Period: Period + MetricName: MetricName + Namespace: Namespace + Statistic: Statistic + Dimensions: Optional[DimensionList] + + +class CollectionSummary(TypedDict, total=False): + Arn: Optional[ARN] + Id: Optional[UUID] + Name: Optional[CollectionName] + Version: Optional[CollectionVersion] + + +CollectionSummaries = List[CollectionSummary] + + +class CreateCidrCollectionRequest(ServiceRequest): + Name: CollectionName + CallerReference: CidrNonce + + +class CreateCidrCollectionResponse(TypedDict, total=False): + Collection: Optional[CidrCollection] + Location: Optional[ResourceURI] + + +HealthCheckRegionList = List[HealthCheckRegion] + + +class HealthCheckConfig(TypedDict, total=False): + IPAddress: Optional[IPAddress] + Port: Optional[Port] + Type: HealthCheckType + ResourcePath: Optional[ResourcePath] + FullyQualifiedDomainName: Optional[FullyQualifiedDomainName] + SearchString: Optional[SearchString] + RequestInterval: Optional[RequestInterval] + FailureThreshold: Optional[FailureThreshold] + MeasureLatency: Optional[MeasureLatency] + Inverted: Optional[Inverted] + Disabled: Optional[Disabled] + HealthThreshold: Optional[HealthThreshold] + ChildHealthChecks: Optional[ChildHealthCheckList] + EnableSNI: Optional[EnableSNI] + Regions: Optional[HealthCheckRegionList] + AlarmIdentifier: Optional[AlarmIdentifier] + InsufficientDataHealthStatus: Optional[InsufficientDataHealthStatus] + RoutingControlArn: Optional[RoutingControlArn] + + +class CreateHealthCheckRequest(ServiceRequest): + CallerReference: HealthCheckNonce + HealthCheckConfig: HealthCheckConfig + + +HealthCheckVersion = int + + +class LinkedService(TypedDict, total=False): + ServicePrincipal: Optional[ServicePrincipal] + Description: Optional[ResourceDescription] + + +class HealthCheck(TypedDict, total=False): + Id: HealthCheckId + CallerReference: HealthCheckNonce + LinkedService: Optional[LinkedService] + HealthCheckConfig: HealthCheckConfig + HealthCheckVersion: HealthCheckVersion + CloudWatchAlarmConfiguration: Optional[CloudWatchAlarmConfiguration] + + +class CreateHealthCheckResponse(TypedDict, total=False): + HealthCheck: HealthCheck + Location: ResourceURI + + +class HostedZoneConfig(TypedDict, total=False): + Comment: Optional[ResourceDescription] + PrivateZone: Optional[IsPrivateZone] + + +class CreateHostedZoneRequest(ServiceRequest): + Name: DNSName + VPC: Optional[VPC] + CallerReference: Nonce + HostedZoneConfig: Optional[HostedZoneConfig] + DelegationSetId: Optional[ResourceId] + + +DelegationSetNameServers = List[DNSName] + + +class DelegationSet(TypedDict, total=False): + Id: Optional[ResourceId] + CallerReference: Optional[Nonce] + NameServers: DelegationSetNameServers + + +HostedZoneRRSetCount = int + + +class HostedZone(TypedDict, total=False): + Id: ResourceId + Name: DNSName + CallerReference: Nonce + Config: Optional[HostedZoneConfig] + ResourceRecordSetCount: Optional[HostedZoneRRSetCount] + LinkedService: Optional[LinkedService] + + +class CreateHostedZoneResponse(TypedDict, total=False): + HostedZone: HostedZone + ChangeInfo: ChangeInfo + DelegationSet: DelegationSet + VPC: Optional[VPC] + Location: ResourceURI + + +class CreateKeySigningKeyRequest(ServiceRequest): + CallerReference: Nonce + HostedZoneId: ResourceId + KeyManagementServiceArn: SigningKeyString + Name: SigningKeyName + Status: SigningKeyStatus + + +class KeySigningKey(TypedDict, total=False): + Name: Optional[SigningKeyName] + KmsArn: Optional[SigningKeyString] + Flag: Optional[SigningKeyInteger] + SigningAlgorithmMnemonic: Optional[SigningKeyString] + SigningAlgorithmType: Optional[SigningKeyInteger] + DigestAlgorithmMnemonic: Optional[SigningKeyString] + DigestAlgorithmType: Optional[SigningKeyInteger] + KeyTag: Optional[SigningKeyTag] + DigestValue: Optional[SigningKeyString] + PublicKey: Optional[SigningKeyString] + DSRecord: Optional[SigningKeyString] + DNSKEYRecord: Optional[SigningKeyString] + Status: Optional[SigningKeyStatus] + StatusMessage: Optional[SigningKeyStatusMessage] + CreatedDate: Optional[TimeStamp] + LastModifiedDate: Optional[TimeStamp] + + +class CreateKeySigningKeyResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + KeySigningKey: KeySigningKey + Location: ResourceURI + + +class CreateQueryLoggingConfigRequest(ServiceRequest): + HostedZoneId: ResourceId + CloudWatchLogsLogGroupArn: CloudWatchLogsLogGroupArn + + +class QueryLoggingConfig(TypedDict, total=False): + Id: QueryLoggingConfigId + HostedZoneId: ResourceId + CloudWatchLogsLogGroupArn: CloudWatchLogsLogGroupArn + + +class CreateQueryLoggingConfigResponse(TypedDict, total=False): + QueryLoggingConfig: QueryLoggingConfig + Location: ResourceURI + + +class CreateReusableDelegationSetRequest(ServiceRequest): + CallerReference: Nonce + HostedZoneId: Optional[ResourceId] + + +class CreateReusableDelegationSetResponse(TypedDict, total=False): + DelegationSet: DelegationSet + Location: ResourceURI + + +class CreateTrafficPolicyInstanceRequest(ServiceRequest): + HostedZoneId: ResourceId + Name: DNSName + TTL: TTL + TrafficPolicyId: TrafficPolicyId + TrafficPolicyVersion: TrafficPolicyVersion + + +class TrafficPolicyInstance(TypedDict, total=False): + Id: TrafficPolicyInstanceId + HostedZoneId: ResourceId + Name: DNSName + TTL: TTL + State: TrafficPolicyInstanceState + Message: Message + TrafficPolicyId: TrafficPolicyId + TrafficPolicyVersion: TrafficPolicyVersion + TrafficPolicyType: RRType + + +class CreateTrafficPolicyInstanceResponse(TypedDict, total=False): + TrafficPolicyInstance: TrafficPolicyInstance + Location: ResourceURI + + +class CreateTrafficPolicyRequest(ServiceRequest): + Name: TrafficPolicyName + Document: TrafficPolicyDocument + Comment: Optional[TrafficPolicyComment] + + +class TrafficPolicy(TypedDict, total=False): + Id: TrafficPolicyId + Version: TrafficPolicyVersion + Name: TrafficPolicyName + Type: RRType + Document: TrafficPolicyDocument + Comment: Optional[TrafficPolicyComment] + + +class CreateTrafficPolicyResponse(TypedDict, total=False): + TrafficPolicy: TrafficPolicy + Location: ResourceURI + + +class CreateTrafficPolicyVersionRequest(ServiceRequest): + Id: TrafficPolicyId + Document: TrafficPolicyDocument + Comment: Optional[TrafficPolicyComment] + + +class CreateTrafficPolicyVersionResponse(TypedDict, total=False): + TrafficPolicy: TrafficPolicy + Location: ResourceURI + + +class CreateVPCAssociationAuthorizationRequest(ServiceRequest): + HostedZoneId: ResourceId + VPC: VPC + + +class CreateVPCAssociationAuthorizationResponse(TypedDict, total=False): + HostedZoneId: ResourceId + VPC: VPC + + +class DNSSECStatus(TypedDict, total=False): + ServeSignature: Optional[ServeSignature] + StatusMessage: Optional[SigningKeyStatusMessage] + + +class DeactivateKeySigningKeyRequest(ServiceRequest): + HostedZoneId: ResourceId + Name: SigningKeyName + + +class DeactivateKeySigningKeyResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +DelegationSets = List[DelegationSet] + + +class DeleteCidrCollectionRequest(ServiceRequest): + Id: UUID + + +class DeleteCidrCollectionResponse(TypedDict, total=False): + pass + + +class DeleteHealthCheckRequest(ServiceRequest): + HealthCheckId: HealthCheckId + + +class DeleteHealthCheckResponse(TypedDict, total=False): + pass + + +class DeleteHostedZoneRequest(ServiceRequest): + Id: ResourceId + + +class DeleteHostedZoneResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class DeleteKeySigningKeyRequest(ServiceRequest): + HostedZoneId: ResourceId + Name: SigningKeyName + + +class DeleteKeySigningKeyResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class DeleteQueryLoggingConfigRequest(ServiceRequest): + Id: QueryLoggingConfigId + + +class DeleteQueryLoggingConfigResponse(TypedDict, total=False): + pass + + +class DeleteReusableDelegationSetRequest(ServiceRequest): + Id: ResourceId + + +class DeleteReusableDelegationSetResponse(TypedDict, total=False): + pass + + +class DeleteTrafficPolicyInstanceRequest(ServiceRequest): + Id: TrafficPolicyInstanceId + + +class DeleteTrafficPolicyInstanceResponse(TypedDict, total=False): + pass + + +class DeleteTrafficPolicyRequest(ServiceRequest): + Id: TrafficPolicyId + Version: TrafficPolicyVersion + + +class DeleteTrafficPolicyResponse(TypedDict, total=False): + pass + + +class DeleteVPCAssociationAuthorizationRequest(ServiceRequest): + HostedZoneId: ResourceId + VPC: VPC + + +class DeleteVPCAssociationAuthorizationResponse(TypedDict, total=False): + pass + + +class DisableHostedZoneDNSSECRequest(ServiceRequest): + HostedZoneId: ResourceId + + +class DisableHostedZoneDNSSECResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class DisassociateVPCFromHostedZoneRequest(ServiceRequest): + HostedZoneId: ResourceId + VPC: VPC + Comment: Optional[DisassociateVPCComment] + + +class DisassociateVPCFromHostedZoneResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class EnableHostedZoneDNSSECRequest(ServiceRequest): + HostedZoneId: ResourceId + + +class EnableHostedZoneDNSSECResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class GeoLocationDetails(TypedDict, total=False): + ContinentCode: Optional[GeoLocationContinentCode] + ContinentName: Optional[GeoLocationContinentName] + CountryCode: Optional[GeoLocationCountryCode] + CountryName: Optional[GeoLocationCountryName] + SubdivisionCode: Optional[GeoLocationSubdivisionCode] + SubdivisionName: Optional[GeoLocationSubdivisionName] + + +GeoLocationDetailsList = List[GeoLocationDetails] + + +class GetAccountLimitRequest(ServiceRequest): + Type: AccountLimitType + + +UsageCount = int + + +class GetAccountLimitResponse(TypedDict, total=False): + Limit: AccountLimit + Count: UsageCount + + +class GetChangeRequest(ServiceRequest): + Id: ChangeId + + +class GetChangeResponse(TypedDict, total=False): + ChangeInfo: ChangeInfo + + +class GetCheckerIpRangesRequest(ServiceRequest): + pass + + +class GetCheckerIpRangesResponse(TypedDict, total=False): + CheckerIpRanges: CheckerIpRanges + + +class GetDNSSECRequest(ServiceRequest): + HostedZoneId: ResourceId + + +KeySigningKeys = List[KeySigningKey] + + +class GetDNSSECResponse(TypedDict, total=False): + Status: DNSSECStatus + KeySigningKeys: KeySigningKeys + + +class GetGeoLocationRequest(ServiceRequest): + ContinentCode: Optional[GeoLocationContinentCode] + CountryCode: Optional[GeoLocationCountryCode] + SubdivisionCode: Optional[GeoLocationSubdivisionCode] + + +class GetGeoLocationResponse(TypedDict, total=False): + GeoLocationDetails: GeoLocationDetails + + +class GetHealthCheckCountRequest(ServiceRequest): + pass + + +HealthCheckCount = int + + +class GetHealthCheckCountResponse(TypedDict, total=False): + HealthCheckCount: HealthCheckCount + + +class GetHealthCheckLastFailureReasonRequest(ServiceRequest): + HealthCheckId: HealthCheckId + + +class StatusReport(TypedDict, total=False): + Status: Optional[Status] + CheckedTime: Optional[TimeStamp] + + +class HealthCheckObservation(TypedDict, total=False): + Region: Optional[HealthCheckRegion] + IPAddress: Optional[IPAddress] + StatusReport: Optional[StatusReport] + + +HealthCheckObservations = List[HealthCheckObservation] + + +class GetHealthCheckLastFailureReasonResponse(TypedDict, total=False): + HealthCheckObservations: HealthCheckObservations + + +class GetHealthCheckRequest(ServiceRequest): + HealthCheckId: HealthCheckId + + +class GetHealthCheckResponse(TypedDict, total=False): + HealthCheck: HealthCheck + + +class GetHealthCheckStatusRequest(ServiceRequest): + HealthCheckId: HealthCheckId + + +class GetHealthCheckStatusResponse(TypedDict, total=False): + HealthCheckObservations: HealthCheckObservations + + +class GetHostedZoneCountRequest(ServiceRequest): + pass + + +HostedZoneCount = int + + +class GetHostedZoneCountResponse(TypedDict, total=False): + HostedZoneCount: HostedZoneCount + + +class GetHostedZoneLimitRequest(ServiceRequest): + Type: HostedZoneLimitType + HostedZoneId: ResourceId + + +class HostedZoneLimit(TypedDict, total=False): + Type: HostedZoneLimitType + Value: LimitValue + + +class GetHostedZoneLimitResponse(TypedDict, total=False): + Limit: HostedZoneLimit + Count: UsageCount + + +class GetHostedZoneRequest(ServiceRequest): + Id: ResourceId + + +VPCs = List[VPC] + + +class GetHostedZoneResponse(TypedDict, total=False): + HostedZone: HostedZone + DelegationSet: Optional[DelegationSet] + VPCs: Optional[VPCs] + + +class GetQueryLoggingConfigRequest(ServiceRequest): + Id: QueryLoggingConfigId + + +class GetQueryLoggingConfigResponse(TypedDict, total=False): + QueryLoggingConfig: QueryLoggingConfig + + +class GetReusableDelegationSetLimitRequest(ServiceRequest): + Type: ReusableDelegationSetLimitType + DelegationSetId: ResourceId + + +class ReusableDelegationSetLimit(TypedDict, total=False): + Type: ReusableDelegationSetLimitType + Value: LimitValue + + +class GetReusableDelegationSetLimitResponse(TypedDict, total=False): + Limit: ReusableDelegationSetLimit + Count: UsageCount + + +class GetReusableDelegationSetRequest(ServiceRequest): + Id: ResourceId + + +class GetReusableDelegationSetResponse(TypedDict, total=False): + DelegationSet: DelegationSet + + +class GetTrafficPolicyInstanceCountRequest(ServiceRequest): + pass + + +class GetTrafficPolicyInstanceCountResponse(TypedDict, total=False): + TrafficPolicyInstanceCount: TrafficPolicyInstanceCount + + +class GetTrafficPolicyInstanceRequest(ServiceRequest): + Id: TrafficPolicyInstanceId + + +class GetTrafficPolicyInstanceResponse(TypedDict, total=False): + TrafficPolicyInstance: TrafficPolicyInstance + + +class GetTrafficPolicyRequest(ServiceRequest): + Id: TrafficPolicyId + Version: TrafficPolicyVersion + + +class GetTrafficPolicyResponse(TypedDict, total=False): + TrafficPolicy: TrafficPolicy + + +HealthChecks = List[HealthCheck] + + +class HostedZoneOwner(TypedDict, total=False): + OwningAccount: Optional[AWSAccountID] + OwningService: Optional[HostedZoneOwningService] + + +class HostedZoneSummary(TypedDict, total=False): + HostedZoneId: ResourceId + Name: DNSName + Owner: HostedZoneOwner + + +HostedZoneSummaries = List[HostedZoneSummary] +HostedZones = List[HostedZone] + + +class ListCidrBlocksRequest(ServiceRequest): + CollectionId: UUID + LocationName: Optional[CidrLocationNameDefaultNotAllowed] + NextToken: Optional[PaginationToken] + MaxResults: Optional[MaxResults] + + +class ListCidrBlocksResponse(TypedDict, total=False): + NextToken: Optional[PaginationToken] + CidrBlocks: Optional[CidrBlockSummaries] + + +class ListCidrCollectionsRequest(ServiceRequest): + NextToken: Optional[PaginationToken] + MaxResults: Optional[MaxResults] + + +class ListCidrCollectionsResponse(TypedDict, total=False): + NextToken: Optional[PaginationToken] + CidrCollections: Optional[CollectionSummaries] + + +class ListCidrLocationsRequest(ServiceRequest): + CollectionId: UUID + NextToken: Optional[PaginationToken] + MaxResults: Optional[MaxResults] + + +class LocationSummary(TypedDict, total=False): + LocationName: Optional[CidrLocationNameDefaultAllowed] + + +LocationSummaries = List[LocationSummary] + + +class ListCidrLocationsResponse(TypedDict, total=False): + NextToken: Optional[PaginationToken] + CidrLocations: Optional[LocationSummaries] + + +class ListGeoLocationsRequest(ServiceRequest): + StartContinentCode: Optional[GeoLocationContinentCode] + StartCountryCode: Optional[GeoLocationCountryCode] + StartSubdivisionCode: Optional[GeoLocationSubdivisionCode] + MaxItems: Optional[PageMaxItems] + + +class ListGeoLocationsResponse(TypedDict, total=False): + GeoLocationDetailsList: GeoLocationDetailsList + IsTruncated: PageTruncated + NextContinentCode: Optional[GeoLocationContinentCode] + NextCountryCode: Optional[GeoLocationCountryCode] + NextSubdivisionCode: Optional[GeoLocationSubdivisionCode] + MaxItems: PageMaxItems + + +class ListHealthChecksRequest(ServiceRequest): + Marker: Optional[PageMarker] + MaxItems: Optional[PageMaxItems] + + +class ListHealthChecksResponse(TypedDict, total=False): + HealthChecks: HealthChecks + Marker: PageMarker + IsTruncated: PageTruncated + NextMarker: Optional[PageMarker] + MaxItems: PageMaxItems + + +class ListHostedZonesByNameRequest(ServiceRequest): + DNSName: Optional[DNSName] + HostedZoneId: Optional[ResourceId] + MaxItems: Optional[PageMaxItems] + + +class ListHostedZonesByNameResponse(TypedDict, total=False): + HostedZones: HostedZones + DNSName: Optional[DNSName] + HostedZoneId: Optional[ResourceId] + IsTruncated: PageTruncated + NextDNSName: Optional[DNSName] + NextHostedZoneId: Optional[ResourceId] + MaxItems: PageMaxItems + + +class ListHostedZonesByVPCRequest(ServiceRequest): + VPCId: VPCId + VPCRegion: VPCRegion + MaxItems: Optional[PageMaxItems] + NextToken: Optional[PaginationToken] + + +class ListHostedZonesByVPCResponse(TypedDict, total=False): + HostedZoneSummaries: HostedZoneSummaries + MaxItems: PageMaxItems + NextToken: Optional[PaginationToken] + + +class ListHostedZonesRequest(ServiceRequest): + Marker: Optional[PageMarker] + MaxItems: Optional[PageMaxItems] + DelegationSetId: Optional[ResourceId] + HostedZoneType: Optional[HostedZoneType] + + +class ListHostedZonesResponse(TypedDict, total=False): + HostedZones: HostedZones + Marker: PageMarker + IsTruncated: PageTruncated + NextMarker: Optional[PageMarker] + MaxItems: PageMaxItems + + +class ListQueryLoggingConfigsRequest(ServiceRequest): + HostedZoneId: Optional[ResourceId] + NextToken: Optional[PaginationToken] + MaxResults: Optional[MaxResults] + + +QueryLoggingConfigs = List[QueryLoggingConfig] + + +class ListQueryLoggingConfigsResponse(TypedDict, total=False): + QueryLoggingConfigs: QueryLoggingConfigs + NextToken: Optional[PaginationToken] + + +class ListResourceRecordSetsRequest(ServiceRequest): + HostedZoneId: ResourceId + StartRecordName: Optional[DNSName] + StartRecordType: Optional[RRType] + StartRecordIdentifier: Optional[ResourceRecordSetIdentifier] + MaxItems: Optional[PageMaxItems] + + +ResourceRecordSets = List[ResourceRecordSet] + + +class ListResourceRecordSetsResponse(TypedDict, total=False): + ResourceRecordSets: ResourceRecordSets + IsTruncated: PageTruncated + NextRecordName: Optional[DNSName] + NextRecordType: Optional[RRType] + NextRecordIdentifier: Optional[ResourceRecordSetIdentifier] + MaxItems: PageMaxItems + + +class ListReusableDelegationSetsRequest(ServiceRequest): + Marker: Optional[PageMarker] + MaxItems: Optional[PageMaxItems] + + +class ListReusableDelegationSetsResponse(TypedDict, total=False): + DelegationSets: DelegationSets + Marker: PageMarker + IsTruncated: PageTruncated + NextMarker: Optional[PageMarker] + MaxItems: PageMaxItems + + +class ListTagsForResourceRequest(ServiceRequest): + ResourceType: TagResourceType + ResourceId: TagResourceId + + +class ResourceTagSet(TypedDict, total=False): + ResourceType: Optional[TagResourceType] + ResourceId: Optional[TagResourceId] + Tags: Optional[TagList] + + +class ListTagsForResourceResponse(TypedDict, total=False): + ResourceTagSet: ResourceTagSet + + +TagResourceIdList = List[TagResourceId] + + +class ListTagsForResourcesRequest(ServiceRequest): + ResourceType: TagResourceType + ResourceIds: TagResourceIdList + + +ResourceTagSetList = List[ResourceTagSet] + + +class ListTagsForResourcesResponse(TypedDict, total=False): + ResourceTagSets: ResourceTagSetList + + +class ListTrafficPoliciesRequest(ServiceRequest): + TrafficPolicyIdMarker: Optional[TrafficPolicyId] + MaxItems: Optional[PageMaxItems] + + +class TrafficPolicySummary(TypedDict, total=False): + Id: TrafficPolicyId + Name: TrafficPolicyName + Type: RRType + LatestVersion: TrafficPolicyVersion + TrafficPolicyCount: TrafficPolicyVersion + + +TrafficPolicySummaries = List[TrafficPolicySummary] + + +class ListTrafficPoliciesResponse(TypedDict, total=False): + TrafficPolicySummaries: TrafficPolicySummaries + IsTruncated: PageTruncated + TrafficPolicyIdMarker: TrafficPolicyId + MaxItems: PageMaxItems + + +class ListTrafficPolicyInstancesByHostedZoneRequest(ServiceRequest): + HostedZoneId: ResourceId + TrafficPolicyInstanceNameMarker: Optional[DNSName] + TrafficPolicyInstanceTypeMarker: Optional[RRType] + MaxItems: Optional[PageMaxItems] + + +TrafficPolicyInstances = List[TrafficPolicyInstance] + + +class ListTrafficPolicyInstancesByHostedZoneResponse(TypedDict, total=False): + TrafficPolicyInstances: TrafficPolicyInstances + TrafficPolicyInstanceNameMarker: Optional[DNSName] + TrafficPolicyInstanceTypeMarker: Optional[RRType] + IsTruncated: PageTruncated + MaxItems: PageMaxItems + + +class ListTrafficPolicyInstancesByPolicyRequest(ServiceRequest): + TrafficPolicyId: TrafficPolicyId + TrafficPolicyVersion: TrafficPolicyVersion + HostedZoneIdMarker: Optional[ResourceId] + TrafficPolicyInstanceNameMarker: Optional[DNSName] + TrafficPolicyInstanceTypeMarker: Optional[RRType] + MaxItems: Optional[PageMaxItems] + + +class ListTrafficPolicyInstancesByPolicyResponse(TypedDict, total=False): + TrafficPolicyInstances: TrafficPolicyInstances + HostedZoneIdMarker: Optional[ResourceId] + TrafficPolicyInstanceNameMarker: Optional[DNSName] + TrafficPolicyInstanceTypeMarker: Optional[RRType] + IsTruncated: PageTruncated + MaxItems: PageMaxItems + + +class ListTrafficPolicyInstancesRequest(ServiceRequest): + HostedZoneIdMarker: Optional[ResourceId] + TrafficPolicyInstanceNameMarker: Optional[DNSName] + TrafficPolicyInstanceTypeMarker: Optional[RRType] + MaxItems: Optional[PageMaxItems] + + +class ListTrafficPolicyInstancesResponse(TypedDict, total=False): + TrafficPolicyInstances: TrafficPolicyInstances + HostedZoneIdMarker: Optional[ResourceId] + TrafficPolicyInstanceNameMarker: Optional[DNSName] + TrafficPolicyInstanceTypeMarker: Optional[RRType] + IsTruncated: PageTruncated + MaxItems: PageMaxItems + + +class ListTrafficPolicyVersionsRequest(ServiceRequest): + Id: TrafficPolicyId + TrafficPolicyVersionMarker: Optional[TrafficPolicyVersionMarker] + MaxItems: Optional[PageMaxItems] + + +TrafficPolicies = List[TrafficPolicy] + + +class ListTrafficPolicyVersionsResponse(TypedDict, total=False): + TrafficPolicies: TrafficPolicies + IsTruncated: PageTruncated + TrafficPolicyVersionMarker: TrafficPolicyVersionMarker + MaxItems: PageMaxItems + + +class ListVPCAssociationAuthorizationsRequest(ServiceRequest): + HostedZoneId: ResourceId + NextToken: Optional[PaginationToken] + MaxResults: Optional[MaxResults] + + +class ListVPCAssociationAuthorizationsResponse(TypedDict, total=False): + HostedZoneId: ResourceId + NextToken: Optional[PaginationToken] + VPCs: VPCs + + +RecordData = List[RecordDataEntry] +ResettableElementNameList = List[ResettableElementName] + + +class TestDNSAnswerRequest(ServiceRequest): + HostedZoneId: ResourceId + RecordName: DNSName + RecordType: RRType + ResolverIP: Optional[IPAddress] + EDNS0ClientSubnetIP: Optional[IPAddress] + EDNS0ClientSubnetMask: Optional[SubnetMask] + + +class TestDNSAnswerResponse(TypedDict, total=False): + Nameserver: Nameserver + RecordName: DNSName + RecordType: RRType + RecordData: RecordData + ResponseCode: DNSRCode + Protocol: TransportProtocol + + +class UpdateHealthCheckRequest(ServiceRequest): + HealthCheckId: HealthCheckId + HealthCheckVersion: Optional[HealthCheckVersion] + IPAddress: Optional[IPAddress] + Port: Optional[Port] + ResourcePath: Optional[ResourcePath] + FullyQualifiedDomainName: Optional[FullyQualifiedDomainName] + SearchString: Optional[SearchString] + FailureThreshold: Optional[FailureThreshold] + Inverted: Optional[Inverted] + Disabled: Optional[Disabled] + HealthThreshold: Optional[HealthThreshold] + ChildHealthChecks: Optional[ChildHealthCheckList] + EnableSNI: Optional[EnableSNI] + Regions: Optional[HealthCheckRegionList] + AlarmIdentifier: Optional[AlarmIdentifier] + InsufficientDataHealthStatus: Optional[InsufficientDataHealthStatus] + ResetElements: Optional[ResettableElementNameList] + + +class UpdateHealthCheckResponse(TypedDict, total=False): + HealthCheck: HealthCheck + + +class UpdateHostedZoneCommentRequest(ServiceRequest): + Id: ResourceId + Comment: Optional[ResourceDescription] + + +class UpdateHostedZoneCommentResponse(TypedDict, total=False): + HostedZone: HostedZone + + +class UpdateTrafficPolicyCommentRequest(ServiceRequest): + Id: TrafficPolicyId + Version: TrafficPolicyVersion + Comment: TrafficPolicyComment + + +class UpdateTrafficPolicyCommentResponse(TypedDict, total=False): + TrafficPolicy: TrafficPolicy + + +class UpdateTrafficPolicyInstanceRequest(ServiceRequest): + Id: TrafficPolicyInstanceId + TTL: TTL + TrafficPolicyId: TrafficPolicyId + TrafficPolicyVersion: TrafficPolicyVersion + + +class UpdateTrafficPolicyInstanceResponse(TypedDict, total=False): + TrafficPolicyInstance: TrafficPolicyInstance + + +class Route53Api: + service = "route53" + version = "2013-04-01" + + @handler("ActivateKeySigningKey") + def activate_key_signing_key( + self, context: RequestContext, hosted_zone_id: ResourceId, name: SigningKeyName, **kwargs + ) -> ActivateKeySigningKeyResponse: + raise NotImplementedError + + @handler("AssociateVPCWithHostedZone") + def associate_vpc_with_hosted_zone( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + vpc: VPC, + comment: AssociateVPCComment | None = None, + **kwargs, + ) -> AssociateVPCWithHostedZoneResponse: + raise NotImplementedError + + @handler("ChangeCidrCollection") + def change_cidr_collection( + self, + context: RequestContext, + id: UUID, + changes: CidrCollectionChanges, + collection_version: CollectionVersion | None = None, + **kwargs, + ) -> ChangeCidrCollectionResponse: + raise NotImplementedError + + @handler("ChangeResourceRecordSets") + def change_resource_record_sets( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + change_batch: ChangeBatch, + **kwargs, + ) -> ChangeResourceRecordSetsResponse: + raise NotImplementedError + + @handler("ChangeTagsForResource") + def change_tags_for_resource( + self, + context: RequestContext, + resource_type: TagResourceType, + resource_id: TagResourceId, + add_tags: TagList | None = None, + remove_tag_keys: TagKeyList | None = None, + **kwargs, + ) -> ChangeTagsForResourceResponse: + raise NotImplementedError + + @handler("CreateCidrCollection") + def create_cidr_collection( + self, context: RequestContext, name: CollectionName, caller_reference: CidrNonce, **kwargs + ) -> CreateCidrCollectionResponse: + raise NotImplementedError + + @handler("CreateHealthCheck") + def create_health_check( + self, + context: RequestContext, + caller_reference: HealthCheckNonce, + health_check_config: HealthCheckConfig, + **kwargs, + ) -> CreateHealthCheckResponse: + raise NotImplementedError + + @handler("CreateHostedZone") + def create_hosted_zone( + self, + context: RequestContext, + name: DNSName, + caller_reference: Nonce, + vpc: VPC | None = None, + hosted_zone_config: HostedZoneConfig | None = None, + delegation_set_id: ResourceId | None = None, + **kwargs, + ) -> CreateHostedZoneResponse: + raise NotImplementedError + + @handler("CreateKeySigningKey") + def create_key_signing_key( + self, + context: RequestContext, + caller_reference: Nonce, + hosted_zone_id: ResourceId, + key_management_service_arn: SigningKeyString, + name: SigningKeyName, + status: SigningKeyStatus, + **kwargs, + ) -> CreateKeySigningKeyResponse: + raise NotImplementedError + + @handler("CreateQueryLoggingConfig") + def create_query_logging_config( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + cloud_watch_logs_log_group_arn: CloudWatchLogsLogGroupArn, + **kwargs, + ) -> CreateQueryLoggingConfigResponse: + raise NotImplementedError + + @handler("CreateReusableDelegationSet") + def create_reusable_delegation_set( + self, + context: RequestContext, + caller_reference: Nonce, + hosted_zone_id: ResourceId | None = None, + **kwargs, + ) -> CreateReusableDelegationSetResponse: + raise NotImplementedError + + @handler("CreateTrafficPolicy") + def create_traffic_policy( + self, + context: RequestContext, + name: TrafficPolicyName, + document: TrafficPolicyDocument, + comment: TrafficPolicyComment | None = None, + **kwargs, + ) -> CreateTrafficPolicyResponse: + raise NotImplementedError + + @handler("CreateTrafficPolicyInstance") + def create_traffic_policy_instance( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + name: DNSName, + ttl: TTL, + traffic_policy_id: TrafficPolicyId, + traffic_policy_version: TrafficPolicyVersion, + **kwargs, + ) -> CreateTrafficPolicyInstanceResponse: + raise NotImplementedError + + @handler("CreateTrafficPolicyVersion") + def create_traffic_policy_version( + self, + context: RequestContext, + id: TrafficPolicyId, + document: TrafficPolicyDocument, + comment: TrafficPolicyComment | None = None, + **kwargs, + ) -> CreateTrafficPolicyVersionResponse: + raise NotImplementedError + + @handler("CreateVPCAssociationAuthorization") + def create_vpc_association_authorization( + self, context: RequestContext, hosted_zone_id: ResourceId, vpc: VPC, **kwargs + ) -> CreateVPCAssociationAuthorizationResponse: + raise NotImplementedError + + @handler("DeactivateKeySigningKey") + def deactivate_key_signing_key( + self, context: RequestContext, hosted_zone_id: ResourceId, name: SigningKeyName, **kwargs + ) -> DeactivateKeySigningKeyResponse: + raise NotImplementedError + + @handler("DeleteCidrCollection") + def delete_cidr_collection( + self, context: RequestContext, id: UUID, **kwargs + ) -> DeleteCidrCollectionResponse: + raise NotImplementedError + + @handler("DeleteHealthCheck") + def delete_health_check( + self, context: RequestContext, health_check_id: HealthCheckId, **kwargs + ) -> DeleteHealthCheckResponse: + raise NotImplementedError + + @handler("DeleteHostedZone") + def delete_hosted_zone( + self, context: RequestContext, id: ResourceId, **kwargs + ) -> DeleteHostedZoneResponse: + raise NotImplementedError + + @handler("DeleteKeySigningKey") + def delete_key_signing_key( + self, context: RequestContext, hosted_zone_id: ResourceId, name: SigningKeyName, **kwargs + ) -> DeleteKeySigningKeyResponse: + raise NotImplementedError + + @handler("DeleteQueryLoggingConfig") + def delete_query_logging_config( + self, context: RequestContext, id: QueryLoggingConfigId, **kwargs + ) -> DeleteQueryLoggingConfigResponse: + raise NotImplementedError + + @handler("DeleteReusableDelegationSet") + def delete_reusable_delegation_set( + self, context: RequestContext, id: ResourceId, **kwargs + ) -> DeleteReusableDelegationSetResponse: + raise NotImplementedError + + @handler("DeleteTrafficPolicy") + def delete_traffic_policy( + self, context: RequestContext, id: TrafficPolicyId, version: TrafficPolicyVersion, **kwargs + ) -> DeleteTrafficPolicyResponse: + raise NotImplementedError + + @handler("DeleteTrafficPolicyInstance") + def delete_traffic_policy_instance( + self, context: RequestContext, id: TrafficPolicyInstanceId, **kwargs + ) -> DeleteTrafficPolicyInstanceResponse: + raise NotImplementedError + + @handler("DeleteVPCAssociationAuthorization") + def delete_vpc_association_authorization( + self, context: RequestContext, hosted_zone_id: ResourceId, vpc: VPC, **kwargs + ) -> DeleteVPCAssociationAuthorizationResponse: + raise NotImplementedError + + @handler("DisableHostedZoneDNSSEC") + def disable_hosted_zone_dnssec( + self, context: RequestContext, hosted_zone_id: ResourceId, **kwargs + ) -> DisableHostedZoneDNSSECResponse: + raise NotImplementedError + + @handler("DisassociateVPCFromHostedZone") + def disassociate_vpc_from_hosted_zone( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + vpc: VPC, + comment: DisassociateVPCComment | None = None, + **kwargs, + ) -> DisassociateVPCFromHostedZoneResponse: + raise NotImplementedError + + @handler("EnableHostedZoneDNSSEC") + def enable_hosted_zone_dnssec( + self, context: RequestContext, hosted_zone_id: ResourceId, **kwargs + ) -> EnableHostedZoneDNSSECResponse: + raise NotImplementedError + + @handler("GetAccountLimit", expand=False) + def get_account_limit( + self, context: RequestContext, request: GetAccountLimitRequest, **kwargs + ) -> GetAccountLimitResponse: + raise NotImplementedError + + @handler("GetChange") + def get_change(self, context: RequestContext, id: ChangeId, **kwargs) -> GetChangeResponse: + raise NotImplementedError + + @handler("GetCheckerIpRanges") + def get_checker_ip_ranges( + self, context: RequestContext, **kwargs + ) -> GetCheckerIpRangesResponse: + raise NotImplementedError + + @handler("GetDNSSEC") + def get_dnssec( + self, context: RequestContext, hosted_zone_id: ResourceId, **kwargs + ) -> GetDNSSECResponse: + raise NotImplementedError + + @handler("GetGeoLocation") + def get_geo_location( + self, + context: RequestContext, + continent_code: GeoLocationContinentCode | None = None, + country_code: GeoLocationCountryCode | None = None, + subdivision_code: GeoLocationSubdivisionCode | None = None, + **kwargs, + ) -> GetGeoLocationResponse: + raise NotImplementedError + + @handler("GetHealthCheck") + def get_health_check( + self, context: RequestContext, health_check_id: HealthCheckId, **kwargs + ) -> GetHealthCheckResponse: + raise NotImplementedError + + @handler("GetHealthCheckCount") + def get_health_check_count( + self, context: RequestContext, **kwargs + ) -> GetHealthCheckCountResponse: + raise NotImplementedError + + @handler("GetHealthCheckLastFailureReason") + def get_health_check_last_failure_reason( + self, context: RequestContext, health_check_id: HealthCheckId, **kwargs + ) -> GetHealthCheckLastFailureReasonResponse: + raise NotImplementedError + + @handler("GetHealthCheckStatus") + def get_health_check_status( + self, context: RequestContext, health_check_id: HealthCheckId, **kwargs + ) -> GetHealthCheckStatusResponse: + raise NotImplementedError + + @handler("GetHostedZone") + def get_hosted_zone( + self, context: RequestContext, id: ResourceId, **kwargs + ) -> GetHostedZoneResponse: + raise NotImplementedError + + @handler("GetHostedZoneCount") + def get_hosted_zone_count( + self, context: RequestContext, **kwargs + ) -> GetHostedZoneCountResponse: + raise NotImplementedError + + @handler("GetHostedZoneLimit", expand=False) + def get_hosted_zone_limit( + self, context: RequestContext, request: GetHostedZoneLimitRequest, **kwargs + ) -> GetHostedZoneLimitResponse: + raise NotImplementedError + + @handler("GetQueryLoggingConfig") + def get_query_logging_config( + self, context: RequestContext, id: QueryLoggingConfigId, **kwargs + ) -> GetQueryLoggingConfigResponse: + raise NotImplementedError + + @handler("GetReusableDelegationSet") + def get_reusable_delegation_set( + self, context: RequestContext, id: ResourceId, **kwargs + ) -> GetReusableDelegationSetResponse: + raise NotImplementedError + + @handler("GetReusableDelegationSetLimit", expand=False) + def get_reusable_delegation_set_limit( + self, context: RequestContext, request: GetReusableDelegationSetLimitRequest, **kwargs + ) -> GetReusableDelegationSetLimitResponse: + raise NotImplementedError + + @handler("GetTrafficPolicy") + def get_traffic_policy( + self, context: RequestContext, id: TrafficPolicyId, version: TrafficPolicyVersion, **kwargs + ) -> GetTrafficPolicyResponse: + raise NotImplementedError + + @handler("GetTrafficPolicyInstance") + def get_traffic_policy_instance( + self, context: RequestContext, id: TrafficPolicyInstanceId, **kwargs + ) -> GetTrafficPolicyInstanceResponse: + raise NotImplementedError + + @handler("GetTrafficPolicyInstanceCount") + def get_traffic_policy_instance_count( + self, context: RequestContext, **kwargs + ) -> GetTrafficPolicyInstanceCountResponse: + raise NotImplementedError + + @handler("ListCidrBlocks") + def list_cidr_blocks( + self, + context: RequestContext, + collection_id: UUID, + location_name: CidrLocationNameDefaultNotAllowed | None = None, + next_token: PaginationToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListCidrBlocksResponse: + raise NotImplementedError + + @handler("ListCidrCollections") + def list_cidr_collections( + self, + context: RequestContext, + next_token: PaginationToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListCidrCollectionsResponse: + raise NotImplementedError + + @handler("ListCidrLocations") + def list_cidr_locations( + self, + context: RequestContext, + collection_id: UUID, + next_token: PaginationToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListCidrLocationsResponse: + raise NotImplementedError + + @handler("ListGeoLocations") + def list_geo_locations( + self, + context: RequestContext, + start_continent_code: GeoLocationContinentCode | None = None, + start_country_code: GeoLocationCountryCode | None = None, + start_subdivision_code: GeoLocationSubdivisionCode | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListGeoLocationsResponse: + raise NotImplementedError + + @handler("ListHealthChecks") + def list_health_checks( + self, + context: RequestContext, + marker: PageMarker | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListHealthChecksResponse: + raise NotImplementedError + + @handler("ListHostedZones") + def list_hosted_zones( + self, + context: RequestContext, + marker: PageMarker | None = None, + max_items: PageMaxItems | None = None, + delegation_set_id: ResourceId | None = None, + hosted_zone_type: HostedZoneType | None = None, + **kwargs, + ) -> ListHostedZonesResponse: + raise NotImplementedError + + @handler("ListHostedZonesByName") + def list_hosted_zones_by_name( + self, + context: RequestContext, + dns_name: DNSName | None = None, + hosted_zone_id: ResourceId | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListHostedZonesByNameResponse: + raise NotImplementedError + + @handler("ListHostedZonesByVPC") + def list_hosted_zones_by_vpc( + self, + context: RequestContext, + vpc_id: VPCId, + vpc_region: VPCRegion, + max_items: PageMaxItems | None = None, + next_token: PaginationToken | None = None, + **kwargs, + ) -> ListHostedZonesByVPCResponse: + raise NotImplementedError + + @handler("ListQueryLoggingConfigs") + def list_query_logging_configs( + self, + context: RequestContext, + hosted_zone_id: ResourceId | None = None, + next_token: PaginationToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListQueryLoggingConfigsResponse: + raise NotImplementedError + + @handler("ListResourceRecordSets") + def list_resource_record_sets( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + start_record_name: DNSName | None = None, + start_record_type: RRType | None = None, + start_record_identifier: ResourceRecordSetIdentifier | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListResourceRecordSetsResponse: + raise NotImplementedError + + @handler("ListReusableDelegationSets") + def list_reusable_delegation_sets( + self, + context: RequestContext, + marker: PageMarker | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListReusableDelegationSetsResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, + context: RequestContext, + resource_type: TagResourceType, + resource_id: TagResourceId, + **kwargs, + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("ListTagsForResources") + def list_tags_for_resources( + self, + context: RequestContext, + resource_type: TagResourceType, + resource_ids: TagResourceIdList, + **kwargs, + ) -> ListTagsForResourcesResponse: + raise NotImplementedError + + @handler("ListTrafficPolicies") + def list_traffic_policies( + self, + context: RequestContext, + traffic_policy_id_marker: TrafficPolicyId | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListTrafficPoliciesResponse: + raise NotImplementedError + + @handler("ListTrafficPolicyInstances") + def list_traffic_policy_instances( + self, + context: RequestContext, + hosted_zone_id_marker: ResourceId | None = None, + traffic_policy_instance_name_marker: DNSName | None = None, + traffic_policy_instance_type_marker: RRType | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListTrafficPolicyInstancesResponse: + raise NotImplementedError + + @handler("ListTrafficPolicyInstancesByHostedZone") + def list_traffic_policy_instances_by_hosted_zone( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + traffic_policy_instance_name_marker: DNSName | None = None, + traffic_policy_instance_type_marker: RRType | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListTrafficPolicyInstancesByHostedZoneResponse: + raise NotImplementedError + + @handler("ListTrafficPolicyInstancesByPolicy") + def list_traffic_policy_instances_by_policy( + self, + context: RequestContext, + traffic_policy_id: TrafficPolicyId, + traffic_policy_version: TrafficPolicyVersion, + hosted_zone_id_marker: ResourceId | None = None, + traffic_policy_instance_name_marker: DNSName | None = None, + traffic_policy_instance_type_marker: RRType | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListTrafficPolicyInstancesByPolicyResponse: + raise NotImplementedError + + @handler("ListTrafficPolicyVersions") + def list_traffic_policy_versions( + self, + context: RequestContext, + id: TrafficPolicyId, + traffic_policy_version_marker: TrafficPolicyVersionMarker | None = None, + max_items: PageMaxItems | None = None, + **kwargs, + ) -> ListTrafficPolicyVersionsResponse: + raise NotImplementedError + + @handler("ListVPCAssociationAuthorizations") + def list_vpc_association_authorizations( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + next_token: PaginationToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListVPCAssociationAuthorizationsResponse: + raise NotImplementedError + + @handler("TestDNSAnswer") + def test_dns_answer( + self, + context: RequestContext, + hosted_zone_id: ResourceId, + record_name: DNSName, + record_type: RRType, + resolver_ip: IPAddress | None = None, + edns0_client_subnet_ip: IPAddress | None = None, + edns0_client_subnet_mask: SubnetMask | None = None, + **kwargs, + ) -> TestDNSAnswerResponse: + raise NotImplementedError + + @handler("UpdateHealthCheck") + def update_health_check( + self, + context: RequestContext, + health_check_id: HealthCheckId, + health_check_version: HealthCheckVersion | None = None, + ip_address: IPAddress | None = None, + port: Port | None = None, + resource_path: ResourcePath | None = None, + fully_qualified_domain_name: FullyQualifiedDomainName | None = None, + search_string: SearchString | None = None, + failure_threshold: FailureThreshold | None = None, + inverted: Inverted | None = None, + disabled: Disabled | None = None, + health_threshold: HealthThreshold | None = None, + child_health_checks: ChildHealthCheckList | None = None, + enable_sni: EnableSNI | None = None, + regions: HealthCheckRegionList | None = None, + alarm_identifier: AlarmIdentifier | None = None, + insufficient_data_health_status: InsufficientDataHealthStatus | None = None, + reset_elements: ResettableElementNameList | None = None, + **kwargs, + ) -> UpdateHealthCheckResponse: + raise NotImplementedError + + @handler("UpdateHostedZoneComment") + def update_hosted_zone_comment( + self, + context: RequestContext, + id: ResourceId, + comment: ResourceDescription | None = None, + **kwargs, + ) -> UpdateHostedZoneCommentResponse: + raise NotImplementedError + + @handler("UpdateTrafficPolicyComment") + def update_traffic_policy_comment( + self, + context: RequestContext, + id: TrafficPolicyId, + version: TrafficPolicyVersion, + comment: TrafficPolicyComment, + **kwargs, + ) -> UpdateTrafficPolicyCommentResponse: + raise NotImplementedError + + @handler("UpdateTrafficPolicyInstance") + def update_traffic_policy_instance( + self, + context: RequestContext, + id: TrafficPolicyInstanceId, + ttl: TTL, + traffic_policy_id: TrafficPolicyId, + traffic_policy_version: TrafficPolicyVersion, + **kwargs, + ) -> UpdateTrafficPolicyInstanceResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/route53resolver/__init__.py b/localstack-core/localstack/aws/api/route53resolver/__init__.py new file mode 100644 index 0000000000000..35e718630ef4b --- /dev/null +++ b/localstack-core/localstack/aws/api/route53resolver/__init__.py @@ -0,0 +1,2041 @@ +from enum import StrEnum +from typing import List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AccountId = str +Arn = str +BlockOverrideDomain = str +BlockOverrideTtl = int +Boolean = bool +Count = int +CreatorRequestId = str +DelegationRecord = str +DestinationArn = str +DomainListFileUrl = str +DomainName = str +ExceptionMessage = str +FilterName = str +FilterValue = str +FirewallDomainName = str +FirewallRuleGroupPolicy = str +InstanceCount = int +Ip = str +IpAddressCount = int +Ipv6 = str +ListDomainMaxResults = int +ListFirewallConfigsMaxResult = int +ListResolverConfigsMaxResult = int +MaxResults = int +Name = str +NextToken = str +OutpostArn = str +OutpostInstanceType = str +OutpostResolverName = str +OutpostResolverStatusMessage = str +Port = int +Priority = int +Qtype = str +ResolverQueryLogConfigAssociationErrorMessage = str +ResolverQueryLogConfigName = str +ResolverQueryLogConfigPolicy = str +ResolverRulePolicy = str +ResourceId = str +Rfc3339TimeString = str +ServerNameIndication = str +ServicePrinciple = str +SortByKey = str +StatusMessage = str +String = str +SubnetId = str +TagKey = str +TagValue = str +Unsigned = int + + +class Action(StrEnum): + ALLOW = "ALLOW" + BLOCK = "BLOCK" + ALERT = "ALERT" + + +class AutodefinedReverseFlag(StrEnum): + ENABLE = "ENABLE" + DISABLE = "DISABLE" + USE_LOCAL_RESOURCE_SETTING = "USE_LOCAL_RESOURCE_SETTING" + + +class BlockOverrideDnsType(StrEnum): + CNAME = "CNAME" + + +class BlockResponse(StrEnum): + NODATA = "NODATA" + NXDOMAIN = "NXDOMAIN" + OVERRIDE = "OVERRIDE" + + +class ConfidenceThreshold(StrEnum): + LOW = "LOW" + MEDIUM = "MEDIUM" + HIGH = "HIGH" + + +class DnsThreatProtection(StrEnum): + DGA = "DGA" + DNS_TUNNELING = "DNS_TUNNELING" + + +class FirewallDomainImportOperation(StrEnum): + REPLACE = "REPLACE" + + +class FirewallDomainListStatus(StrEnum): + COMPLETE = "COMPLETE" + COMPLETE_IMPORT_FAILED = "COMPLETE_IMPORT_FAILED" + IMPORTING = "IMPORTING" + DELETING = "DELETING" + UPDATING = "UPDATING" + + +class FirewallDomainRedirectionAction(StrEnum): + INSPECT_REDIRECTION_DOMAIN = "INSPECT_REDIRECTION_DOMAIN" + TRUST_REDIRECTION_DOMAIN = "TRUST_REDIRECTION_DOMAIN" + + +class FirewallDomainUpdateOperation(StrEnum): + ADD = "ADD" + REMOVE = "REMOVE" + REPLACE = "REPLACE" + + +class FirewallFailOpenStatus(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + USE_LOCAL_RESOURCE_SETTING = "USE_LOCAL_RESOURCE_SETTING" + + +class FirewallRuleGroupAssociationStatus(StrEnum): + COMPLETE = "COMPLETE" + DELETING = "DELETING" + UPDATING = "UPDATING" + + +class FirewallRuleGroupStatus(StrEnum): + COMPLETE = "COMPLETE" + DELETING = "DELETING" + UPDATING = "UPDATING" + + +class IpAddressStatus(StrEnum): + CREATING = "CREATING" + FAILED_CREATION = "FAILED_CREATION" + ATTACHING = "ATTACHING" + ATTACHED = "ATTACHED" + REMAP_DETACHING = "REMAP_DETACHING" + REMAP_ATTACHING = "REMAP_ATTACHING" + DETACHING = "DETACHING" + FAILED_RESOURCE_GONE = "FAILED_RESOURCE_GONE" + DELETING = "DELETING" + DELETE_FAILED_FAS_EXPIRED = "DELETE_FAILED_FAS_EXPIRED" + UPDATING = "UPDATING" + UPDATE_FAILED = "UPDATE_FAILED" + ISOLATED = "ISOLATED" + + +class MutationProtectionStatus(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class OutpostResolverStatus(StrEnum): + CREATING = "CREATING" + OPERATIONAL = "OPERATIONAL" + UPDATING = "UPDATING" + DELETING = "DELETING" + ACTION_NEEDED = "ACTION_NEEDED" + FAILED_CREATION = "FAILED_CREATION" + FAILED_DELETION = "FAILED_DELETION" + + +class Protocol(StrEnum): + DoH = "DoH" + Do53 = "Do53" + DoH_FIPS = "DoH-FIPS" + + +class ResolverAutodefinedReverseStatus(StrEnum): + ENABLING = "ENABLING" + ENABLED = "ENABLED" + DISABLING = "DISABLING" + DISABLED = "DISABLED" + UPDATING_TO_USE_LOCAL_RESOURCE_SETTING = "UPDATING_TO_USE_LOCAL_RESOURCE_SETTING" + USE_LOCAL_RESOURCE_SETTING = "USE_LOCAL_RESOURCE_SETTING" + + +class ResolverDNSSECValidationStatus(StrEnum): + ENABLING = "ENABLING" + ENABLED = "ENABLED" + DISABLING = "DISABLING" + DISABLED = "DISABLED" + UPDATING_TO_USE_LOCAL_RESOURCE_SETTING = "UPDATING_TO_USE_LOCAL_RESOURCE_SETTING" + USE_LOCAL_RESOURCE_SETTING = "USE_LOCAL_RESOURCE_SETTING" + + +class ResolverEndpointDirection(StrEnum): + INBOUND = "INBOUND" + OUTBOUND = "OUTBOUND" + INBOUND_DELEGATION = "INBOUND_DELEGATION" + + +class ResolverEndpointStatus(StrEnum): + CREATING = "CREATING" + OPERATIONAL = "OPERATIONAL" + UPDATING = "UPDATING" + AUTO_RECOVERING = "AUTO_RECOVERING" + ACTION_NEEDED = "ACTION_NEEDED" + DELETING = "DELETING" + + +class ResolverEndpointType(StrEnum): + IPV6 = "IPV6" + IPV4 = "IPV4" + DUALSTACK = "DUALSTACK" + + +class ResolverQueryLogConfigAssociationError(StrEnum): + NONE = "NONE" + DESTINATION_NOT_FOUND = "DESTINATION_NOT_FOUND" + ACCESS_DENIED = "ACCESS_DENIED" + INTERNAL_SERVICE_ERROR = "INTERNAL_SERVICE_ERROR" + + +class ResolverQueryLogConfigAssociationStatus(StrEnum): + CREATING = "CREATING" + ACTIVE = "ACTIVE" + ACTION_NEEDED = "ACTION_NEEDED" + DELETING = "DELETING" + FAILED = "FAILED" + + +class ResolverQueryLogConfigStatus(StrEnum): + CREATING = "CREATING" + CREATED = "CREATED" + DELETING = "DELETING" + FAILED = "FAILED" + + +class ResolverRuleAssociationStatus(StrEnum): + CREATING = "CREATING" + COMPLETE = "COMPLETE" + DELETING = "DELETING" + FAILED = "FAILED" + OVERRIDDEN = "OVERRIDDEN" + + +class ResolverRuleStatus(StrEnum): + COMPLETE = "COMPLETE" + DELETING = "DELETING" + UPDATING = "UPDATING" + FAILED = "FAILED" + + +class RuleTypeOption(StrEnum): + FORWARD = "FORWARD" + SYSTEM = "SYSTEM" + RECURSIVE = "RECURSIVE" + DELEGATE = "DELEGATE" + + +class ShareStatus(StrEnum): + NOT_SHARED = "NOT_SHARED" + SHARED_WITH_ME = "SHARED_WITH_ME" + SHARED_BY_ME = "SHARED_BY_ME" + + +class SortOrder(StrEnum): + ASCENDING = "ASCENDING" + DESCENDING = "DESCENDING" + + +class Validation(StrEnum): + ENABLE = "ENABLE" + DISABLE = "DISABLE" + USE_LOCAL_RESOURCE_SETTING = "USE_LOCAL_RESOURCE_SETTING" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServiceErrorException(ServiceException): + code: str = "InternalServiceErrorException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidNextTokenException(ServiceException): + code: str = "InvalidNextTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidParameterException(ServiceException): + code: str = "InvalidParameterException" + sender_fault: bool = False + status_code: int = 400 + FieldName: Optional[String] + + +class InvalidPolicyDocument(ServiceException): + code: str = "InvalidPolicyDocument" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRequestException(ServiceException): + code: str = "InvalidRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTagException(ServiceException): + code: str = "InvalidTagException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + ResourceType: Optional[String] + + +class ResourceExistsException(ServiceException): + code: str = "ResourceExistsException" + sender_fault: bool = False + status_code: int = 400 + ResourceType: Optional[String] + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + ResourceType: Optional[String] + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + ResourceType: Optional[String] + + +class ResourceUnavailableException(ServiceException): + code: str = "ResourceUnavailableException" + sender_fault: bool = False + status_code: int = 400 + ResourceType: Optional[String] + + +class ServiceQuotaExceededException(ServiceException): + code: str = "ServiceQuotaExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class UnknownResourceException(ServiceException): + code: str = "UnknownResourceException" + sender_fault: bool = False + status_code: int = 400 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class AssociateFirewallRuleGroupRequest(ServiceRequest): + CreatorRequestId: CreatorRequestId + FirewallRuleGroupId: ResourceId + VpcId: ResourceId + Priority: Priority + Name: Name + MutationProtection: Optional[MutationProtectionStatus] + Tags: Optional[TagList] + + +class FirewallRuleGroupAssociation(TypedDict, total=False): + Id: Optional[ResourceId] + Arn: Optional[Arn] + FirewallRuleGroupId: Optional[ResourceId] + VpcId: Optional[ResourceId] + Name: Optional[Name] + Priority: Optional[Priority] + MutationProtection: Optional[MutationProtectionStatus] + ManagedOwnerName: Optional[ServicePrinciple] + Status: Optional[FirewallRuleGroupAssociationStatus] + StatusMessage: Optional[StatusMessage] + CreatorRequestId: Optional[CreatorRequestId] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + + +class AssociateFirewallRuleGroupResponse(TypedDict, total=False): + FirewallRuleGroupAssociation: Optional[FirewallRuleGroupAssociation] + + +class IpAddressUpdate(TypedDict, total=False): + IpId: Optional[ResourceId] + SubnetId: Optional[SubnetId] + Ip: Optional[Ip] + Ipv6: Optional[Ipv6] + + +class AssociateResolverEndpointIpAddressRequest(ServiceRequest): + ResolverEndpointId: ResourceId + IpAddress: IpAddressUpdate + + +ProtocolList = List[Protocol] +SecurityGroupIds = List[ResourceId] + + +class ResolverEndpoint(TypedDict, total=False): + Id: Optional[ResourceId] + CreatorRequestId: Optional[CreatorRequestId] + Arn: Optional[Arn] + Name: Optional[Name] + SecurityGroupIds: Optional[SecurityGroupIds] + Direction: Optional[ResolverEndpointDirection] + IpAddressCount: Optional[IpAddressCount] + HostVPCId: Optional[ResourceId] + Status: Optional[ResolverEndpointStatus] + StatusMessage: Optional[StatusMessage] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + OutpostArn: Optional[OutpostArn] + PreferredInstanceType: Optional[OutpostInstanceType] + ResolverEndpointType: Optional[ResolverEndpointType] + Protocols: Optional[ProtocolList] + + +class AssociateResolverEndpointIpAddressResponse(TypedDict, total=False): + ResolverEndpoint: Optional[ResolverEndpoint] + + +class AssociateResolverQueryLogConfigRequest(ServiceRequest): + ResolverQueryLogConfigId: ResourceId + ResourceId: ResourceId + + +class ResolverQueryLogConfigAssociation(TypedDict, total=False): + Id: Optional[ResourceId] + ResolverQueryLogConfigId: Optional[ResourceId] + ResourceId: Optional[ResourceId] + Status: Optional[ResolverQueryLogConfigAssociationStatus] + Error: Optional[ResolverQueryLogConfigAssociationError] + ErrorMessage: Optional[ResolverQueryLogConfigAssociationErrorMessage] + CreationTime: Optional[Rfc3339TimeString] + + +class AssociateResolverQueryLogConfigResponse(TypedDict, total=False): + ResolverQueryLogConfigAssociation: Optional[ResolverQueryLogConfigAssociation] + + +class AssociateResolverRuleRequest(ServiceRequest): + ResolverRuleId: ResourceId + Name: Optional[Name] + VPCId: ResourceId + + +class ResolverRuleAssociation(TypedDict, total=False): + Id: Optional[ResourceId] + ResolverRuleId: Optional[ResourceId] + Name: Optional[Name] + VPCId: Optional[ResourceId] + Status: Optional[ResolverRuleAssociationStatus] + StatusMessage: Optional[StatusMessage] + + +class AssociateResolverRuleResponse(TypedDict, total=False): + ResolverRuleAssociation: Optional[ResolverRuleAssociation] + + +class CreateFirewallDomainListRequest(ServiceRequest): + CreatorRequestId: CreatorRequestId + Name: Name + Tags: Optional[TagList] + + +class FirewallDomainList(TypedDict, total=False): + Id: Optional[ResourceId] + Arn: Optional[Arn] + Name: Optional[Name] + DomainCount: Optional[Unsigned] + Status: Optional[FirewallDomainListStatus] + StatusMessage: Optional[StatusMessage] + ManagedOwnerName: Optional[ServicePrinciple] + CreatorRequestId: Optional[CreatorRequestId] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + + +class CreateFirewallDomainListResponse(TypedDict, total=False): + FirewallDomainList: Optional[FirewallDomainList] + + +class CreateFirewallRuleGroupRequest(ServiceRequest): + CreatorRequestId: CreatorRequestId + Name: Name + Tags: Optional[TagList] + + +class FirewallRuleGroup(TypedDict, total=False): + Id: Optional[ResourceId] + Arn: Optional[Arn] + Name: Optional[Name] + RuleCount: Optional[Unsigned] + Status: Optional[FirewallRuleGroupStatus] + StatusMessage: Optional[StatusMessage] + OwnerId: Optional[AccountId] + CreatorRequestId: Optional[CreatorRequestId] + ShareStatus: Optional[ShareStatus] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + + +class CreateFirewallRuleGroupResponse(TypedDict, total=False): + FirewallRuleGroup: Optional[FirewallRuleGroup] + + +class CreateFirewallRuleRequest(ServiceRequest): + CreatorRequestId: CreatorRequestId + FirewallRuleGroupId: ResourceId + FirewallDomainListId: Optional[ResourceId] + Priority: Priority + Action: Action + BlockResponse: Optional[BlockResponse] + BlockOverrideDomain: Optional[BlockOverrideDomain] + BlockOverrideDnsType: Optional[BlockOverrideDnsType] + BlockOverrideTtl: Optional[BlockOverrideTtl] + Name: Name + FirewallDomainRedirectionAction: Optional[FirewallDomainRedirectionAction] + Qtype: Optional[Qtype] + DnsThreatProtection: Optional[DnsThreatProtection] + ConfidenceThreshold: Optional[ConfidenceThreshold] + + +class FirewallRule(TypedDict, total=False): + FirewallRuleGroupId: Optional[ResourceId] + FirewallDomainListId: Optional[ResourceId] + FirewallThreatProtectionId: Optional[ResourceId] + Name: Optional[Name] + Priority: Optional[Priority] + Action: Optional[Action] + BlockResponse: Optional[BlockResponse] + BlockOverrideDomain: Optional[BlockOverrideDomain] + BlockOverrideDnsType: Optional[BlockOverrideDnsType] + BlockOverrideTtl: Optional[Unsigned] + CreatorRequestId: Optional[CreatorRequestId] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + FirewallDomainRedirectionAction: Optional[FirewallDomainRedirectionAction] + Qtype: Optional[Qtype] + DnsThreatProtection: Optional[DnsThreatProtection] + ConfidenceThreshold: Optional[ConfidenceThreshold] + + +class CreateFirewallRuleResponse(TypedDict, total=False): + FirewallRule: Optional[FirewallRule] + + +class CreateOutpostResolverRequest(ServiceRequest): + CreatorRequestId: CreatorRequestId + Name: OutpostResolverName + InstanceCount: Optional[InstanceCount] + PreferredInstanceType: OutpostInstanceType + OutpostArn: OutpostArn + Tags: Optional[TagList] + + +class OutpostResolver(TypedDict, total=False): + Arn: Optional[Arn] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + CreatorRequestId: Optional[CreatorRequestId] + Id: Optional[ResourceId] + InstanceCount: Optional[InstanceCount] + PreferredInstanceType: Optional[OutpostInstanceType] + Name: Optional[OutpostResolverName] + Status: Optional[OutpostResolverStatus] + StatusMessage: Optional[OutpostResolverStatusMessage] + OutpostArn: Optional[OutpostArn] + + +class CreateOutpostResolverResponse(TypedDict, total=False): + OutpostResolver: Optional[OutpostResolver] + + +class IpAddressRequest(TypedDict, total=False): + SubnetId: SubnetId + Ip: Optional[Ip] + Ipv6: Optional[Ipv6] + + +IpAddressesRequest = List[IpAddressRequest] + + +class CreateResolverEndpointRequest(ServiceRequest): + CreatorRequestId: CreatorRequestId + Name: Optional[Name] + SecurityGroupIds: SecurityGroupIds + Direction: ResolverEndpointDirection + IpAddresses: IpAddressesRequest + OutpostArn: Optional[OutpostArn] + PreferredInstanceType: Optional[OutpostInstanceType] + Tags: Optional[TagList] + ResolverEndpointType: Optional[ResolverEndpointType] + Protocols: Optional[ProtocolList] + + +class CreateResolverEndpointResponse(TypedDict, total=False): + ResolverEndpoint: Optional[ResolverEndpoint] + + +class CreateResolverQueryLogConfigRequest(ServiceRequest): + Name: ResolverQueryLogConfigName + DestinationArn: DestinationArn + CreatorRequestId: CreatorRequestId + Tags: Optional[TagList] + + +class ResolverQueryLogConfig(TypedDict, total=False): + Id: Optional[ResourceId] + OwnerId: Optional[AccountId] + Status: Optional[ResolverQueryLogConfigStatus] + ShareStatus: Optional[ShareStatus] + AssociationCount: Optional[Count] + Arn: Optional[Arn] + Name: Optional[ResolverQueryLogConfigName] + DestinationArn: Optional[DestinationArn] + CreatorRequestId: Optional[CreatorRequestId] + CreationTime: Optional[Rfc3339TimeString] + + +class CreateResolverQueryLogConfigResponse(TypedDict, total=False): + ResolverQueryLogConfig: Optional[ResolverQueryLogConfig] + + +class TargetAddress(TypedDict, total=False): + Ip: Optional[Ip] + Port: Optional[Port] + Ipv6: Optional[Ipv6] + Protocol: Optional[Protocol] + ServerNameIndication: Optional[ServerNameIndication] + + +TargetList = List[TargetAddress] + + +class CreateResolverRuleRequest(ServiceRequest): + CreatorRequestId: CreatorRequestId + Name: Optional[Name] + RuleType: RuleTypeOption + DomainName: Optional[DomainName] + TargetIps: Optional[TargetList] + ResolverEndpointId: Optional[ResourceId] + Tags: Optional[TagList] + DelegationRecord: Optional[DelegationRecord] + + +class ResolverRule(TypedDict, total=False): + Id: Optional[ResourceId] + CreatorRequestId: Optional[CreatorRequestId] + Arn: Optional[Arn] + DomainName: Optional[DomainName] + Status: Optional[ResolverRuleStatus] + StatusMessage: Optional[StatusMessage] + RuleType: Optional[RuleTypeOption] + Name: Optional[Name] + TargetIps: Optional[TargetList] + ResolverEndpointId: Optional[ResourceId] + OwnerId: Optional[AccountId] + ShareStatus: Optional[ShareStatus] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + DelegationRecord: Optional[DelegationRecord] + + +class CreateResolverRuleResponse(TypedDict, total=False): + ResolverRule: Optional[ResolverRule] + + +class DeleteFirewallDomainListRequest(ServiceRequest): + FirewallDomainListId: ResourceId + + +class DeleteFirewallDomainListResponse(TypedDict, total=False): + FirewallDomainList: Optional[FirewallDomainList] + + +class DeleteFirewallRuleGroupRequest(ServiceRequest): + FirewallRuleGroupId: ResourceId + + +class DeleteFirewallRuleGroupResponse(TypedDict, total=False): + FirewallRuleGroup: Optional[FirewallRuleGroup] + + +class DeleteFirewallRuleRequest(ServiceRequest): + FirewallRuleGroupId: ResourceId + FirewallDomainListId: Optional[ResourceId] + FirewallThreatProtectionId: Optional[ResourceId] + Qtype: Optional[Qtype] + + +class DeleteFirewallRuleResponse(TypedDict, total=False): + FirewallRule: Optional[FirewallRule] + + +class DeleteOutpostResolverRequest(ServiceRequest): + Id: ResourceId + + +class DeleteOutpostResolverResponse(TypedDict, total=False): + OutpostResolver: Optional[OutpostResolver] + + +class DeleteResolverEndpointRequest(ServiceRequest): + ResolverEndpointId: ResourceId + + +class DeleteResolverEndpointResponse(TypedDict, total=False): + ResolverEndpoint: Optional[ResolverEndpoint] + + +class DeleteResolverQueryLogConfigRequest(ServiceRequest): + ResolverQueryLogConfigId: ResourceId + + +class DeleteResolverQueryLogConfigResponse(TypedDict, total=False): + ResolverQueryLogConfig: Optional[ResolverQueryLogConfig] + + +class DeleteResolverRuleRequest(ServiceRequest): + ResolverRuleId: ResourceId + + +class DeleteResolverRuleResponse(TypedDict, total=False): + ResolverRule: Optional[ResolverRule] + + +class DisassociateFirewallRuleGroupRequest(ServiceRequest): + FirewallRuleGroupAssociationId: ResourceId + + +class DisassociateFirewallRuleGroupResponse(TypedDict, total=False): + FirewallRuleGroupAssociation: Optional[FirewallRuleGroupAssociation] + + +class DisassociateResolverEndpointIpAddressRequest(ServiceRequest): + ResolverEndpointId: ResourceId + IpAddress: IpAddressUpdate + + +class DisassociateResolverEndpointIpAddressResponse(TypedDict, total=False): + ResolverEndpoint: Optional[ResolverEndpoint] + + +class DisassociateResolverQueryLogConfigRequest(ServiceRequest): + ResolverQueryLogConfigId: ResourceId + ResourceId: ResourceId + + +class DisassociateResolverQueryLogConfigResponse(TypedDict, total=False): + ResolverQueryLogConfigAssociation: Optional[ResolverQueryLogConfigAssociation] + + +class DisassociateResolverRuleRequest(ServiceRequest): + VPCId: ResourceId + ResolverRuleId: ResourceId + + +class DisassociateResolverRuleResponse(TypedDict, total=False): + ResolverRuleAssociation: Optional[ResolverRuleAssociation] + + +FilterValues = List[FilterValue] + + +class Filter(TypedDict, total=False): + Name: Optional[FilterName] + Values: Optional[FilterValues] + + +Filters = List[Filter] + + +class FirewallConfig(TypedDict, total=False): + Id: Optional[ResourceId] + ResourceId: Optional[ResourceId] + OwnerId: Optional[AccountId] + FirewallFailOpen: Optional[FirewallFailOpenStatus] + + +FirewallConfigList = List[FirewallConfig] + + +class FirewallDomainListMetadata(TypedDict, total=False): + Id: Optional[ResourceId] + Arn: Optional[Arn] + Name: Optional[Name] + CreatorRequestId: Optional[CreatorRequestId] + ManagedOwnerName: Optional[ServicePrinciple] + + +FirewallDomainListMetadataList = List[FirewallDomainListMetadata] +FirewallDomains = List[FirewallDomainName] +FirewallRuleGroupAssociations = List[FirewallRuleGroupAssociation] + + +class FirewallRuleGroupMetadata(TypedDict, total=False): + Id: Optional[ResourceId] + Arn: Optional[Arn] + Name: Optional[Name] + OwnerId: Optional[AccountId] + CreatorRequestId: Optional[CreatorRequestId] + ShareStatus: Optional[ShareStatus] + + +FirewallRuleGroupMetadataList = List[FirewallRuleGroupMetadata] +FirewallRules = List[FirewallRule] + + +class GetFirewallConfigRequest(ServiceRequest): + ResourceId: ResourceId + + +class GetFirewallConfigResponse(TypedDict, total=False): + FirewallConfig: Optional[FirewallConfig] + + +class GetFirewallDomainListRequest(ServiceRequest): + FirewallDomainListId: ResourceId + + +class GetFirewallDomainListResponse(TypedDict, total=False): + FirewallDomainList: Optional[FirewallDomainList] + + +class GetFirewallRuleGroupAssociationRequest(ServiceRequest): + FirewallRuleGroupAssociationId: ResourceId + + +class GetFirewallRuleGroupAssociationResponse(TypedDict, total=False): + FirewallRuleGroupAssociation: Optional[FirewallRuleGroupAssociation] + + +class GetFirewallRuleGroupPolicyRequest(ServiceRequest): + Arn: Arn + + +class GetFirewallRuleGroupPolicyResponse(TypedDict, total=False): + FirewallRuleGroupPolicy: Optional[FirewallRuleGroupPolicy] + + +class GetFirewallRuleGroupRequest(ServiceRequest): + FirewallRuleGroupId: ResourceId + + +class GetFirewallRuleGroupResponse(TypedDict, total=False): + FirewallRuleGroup: Optional[FirewallRuleGroup] + + +class GetOutpostResolverRequest(ServiceRequest): + Id: ResourceId + + +class GetOutpostResolverResponse(TypedDict, total=False): + OutpostResolver: Optional[OutpostResolver] + + +class GetResolverConfigRequest(ServiceRequest): + ResourceId: ResourceId + + +class ResolverConfig(TypedDict, total=False): + Id: Optional[ResourceId] + ResourceId: Optional[ResourceId] + OwnerId: Optional[AccountId] + AutodefinedReverse: Optional[ResolverAutodefinedReverseStatus] + + +class GetResolverConfigResponse(TypedDict, total=False): + ResolverConfig: Optional[ResolverConfig] + + +class GetResolverDnssecConfigRequest(ServiceRequest): + ResourceId: ResourceId + + +class ResolverDnssecConfig(TypedDict, total=False): + Id: Optional[ResourceId] + OwnerId: Optional[AccountId] + ResourceId: Optional[ResourceId] + ValidationStatus: Optional[ResolverDNSSECValidationStatus] + + +class GetResolverDnssecConfigResponse(TypedDict, total=False): + ResolverDNSSECConfig: Optional[ResolverDnssecConfig] + + +class GetResolverEndpointRequest(ServiceRequest): + ResolverEndpointId: ResourceId + + +class GetResolverEndpointResponse(TypedDict, total=False): + ResolverEndpoint: Optional[ResolverEndpoint] + + +class GetResolverQueryLogConfigAssociationRequest(ServiceRequest): + ResolverQueryLogConfigAssociationId: ResourceId + + +class GetResolverQueryLogConfigAssociationResponse(TypedDict, total=False): + ResolverQueryLogConfigAssociation: Optional[ResolverQueryLogConfigAssociation] + + +class GetResolverQueryLogConfigPolicyRequest(ServiceRequest): + Arn: Arn + + +class GetResolverQueryLogConfigPolicyResponse(TypedDict, total=False): + ResolverQueryLogConfigPolicy: Optional[ResolverQueryLogConfigPolicy] + + +class GetResolverQueryLogConfigRequest(ServiceRequest): + ResolverQueryLogConfigId: ResourceId + + +class GetResolverQueryLogConfigResponse(TypedDict, total=False): + ResolverQueryLogConfig: Optional[ResolverQueryLogConfig] + + +class GetResolverRuleAssociationRequest(ServiceRequest): + ResolverRuleAssociationId: ResourceId + + +class GetResolverRuleAssociationResponse(TypedDict, total=False): + ResolverRuleAssociation: Optional[ResolverRuleAssociation] + + +class GetResolverRulePolicyRequest(ServiceRequest): + Arn: Arn + + +class GetResolverRulePolicyResponse(TypedDict, total=False): + ResolverRulePolicy: Optional[ResolverRulePolicy] + + +class GetResolverRuleRequest(ServiceRequest): + ResolverRuleId: ResourceId + + +class GetResolverRuleResponse(TypedDict, total=False): + ResolverRule: Optional[ResolverRule] + + +class ImportFirewallDomainsRequest(ServiceRequest): + FirewallDomainListId: ResourceId + Operation: FirewallDomainImportOperation + DomainFileUrl: DomainListFileUrl + + +class ImportFirewallDomainsResponse(TypedDict, total=False): + Id: Optional[ResourceId] + Name: Optional[Name] + Status: Optional[FirewallDomainListStatus] + StatusMessage: Optional[StatusMessage] + + +class IpAddressResponse(TypedDict, total=False): + IpId: Optional[ResourceId] + SubnetId: Optional[SubnetId] + Ip: Optional[Ip] + Ipv6: Optional[Ipv6] + Status: Optional[IpAddressStatus] + StatusMessage: Optional[StatusMessage] + CreationTime: Optional[Rfc3339TimeString] + ModificationTime: Optional[Rfc3339TimeString] + + +IpAddressesResponse = List[IpAddressResponse] + + +class ListFirewallConfigsRequest(ServiceRequest): + MaxResults: Optional[ListFirewallConfigsMaxResult] + NextToken: Optional[NextToken] + + +class ListFirewallConfigsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + FirewallConfigs: Optional[FirewallConfigList] + + +class ListFirewallDomainListsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListFirewallDomainListsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + FirewallDomainLists: Optional[FirewallDomainListMetadataList] + + +class ListFirewallDomainsRequest(ServiceRequest): + FirewallDomainListId: ResourceId + MaxResults: Optional[ListDomainMaxResults] + NextToken: Optional[NextToken] + + +class ListFirewallDomainsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + Domains: Optional[FirewallDomains] + + +class ListFirewallRuleGroupAssociationsRequest(ServiceRequest): + FirewallRuleGroupId: Optional[ResourceId] + VpcId: Optional[ResourceId] + Priority: Optional[Priority] + Status: Optional[FirewallRuleGroupAssociationStatus] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListFirewallRuleGroupAssociationsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + FirewallRuleGroupAssociations: Optional[FirewallRuleGroupAssociations] + + +class ListFirewallRuleGroupsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListFirewallRuleGroupsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + FirewallRuleGroups: Optional[FirewallRuleGroupMetadataList] + + +class ListFirewallRulesRequest(ServiceRequest): + FirewallRuleGroupId: ResourceId + Priority: Optional[Priority] + Action: Optional[Action] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListFirewallRulesResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + FirewallRules: Optional[FirewallRules] + + +class ListOutpostResolversRequest(ServiceRequest): + OutpostArn: Optional[OutpostArn] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +OutpostResolverList = List[OutpostResolver] + + +class ListOutpostResolversResponse(TypedDict, total=False): + OutpostResolvers: Optional[OutpostResolverList] + NextToken: Optional[NextToken] + + +class ListResolverConfigsRequest(ServiceRequest): + MaxResults: Optional[ListResolverConfigsMaxResult] + NextToken: Optional[NextToken] + + +ResolverConfigList = List[ResolverConfig] + + +class ListResolverConfigsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + ResolverConfigs: Optional[ResolverConfigList] + + +class ListResolverDnssecConfigsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + Filters: Optional[Filters] + + +ResolverDnssecConfigList = List[ResolverDnssecConfig] + + +class ListResolverDnssecConfigsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + ResolverDnssecConfigs: Optional[ResolverDnssecConfigList] + + +class ListResolverEndpointIpAddressesRequest(ServiceRequest): + ResolverEndpointId: ResourceId + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListResolverEndpointIpAddressesResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + IpAddresses: Optional[IpAddressesResponse] + + +class ListResolverEndpointsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + Filters: Optional[Filters] + + +ResolverEndpoints = List[ResolverEndpoint] + + +class ListResolverEndpointsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + ResolverEndpoints: Optional[ResolverEndpoints] + + +class ListResolverQueryLogConfigAssociationsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + Filters: Optional[Filters] + SortBy: Optional[SortByKey] + SortOrder: Optional[SortOrder] + + +ResolverQueryLogConfigAssociationList = List[ResolverQueryLogConfigAssociation] + + +class ListResolverQueryLogConfigAssociationsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + TotalCount: Optional[Count] + TotalFilteredCount: Optional[Count] + ResolverQueryLogConfigAssociations: Optional[ResolverQueryLogConfigAssociationList] + + +class ListResolverQueryLogConfigsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + Filters: Optional[Filters] + SortBy: Optional[SortByKey] + SortOrder: Optional[SortOrder] + + +ResolverQueryLogConfigList = List[ResolverQueryLogConfig] + + +class ListResolverQueryLogConfigsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + TotalCount: Optional[Count] + TotalFilteredCount: Optional[Count] + ResolverQueryLogConfigs: Optional[ResolverQueryLogConfigList] + + +class ListResolverRuleAssociationsRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + Filters: Optional[Filters] + + +ResolverRuleAssociations = List[ResolverRuleAssociation] + + +class ListResolverRuleAssociationsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + ResolverRuleAssociations: Optional[ResolverRuleAssociations] + + +class ListResolverRulesRequest(ServiceRequest): + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + Filters: Optional[Filters] + + +ResolverRules = List[ResolverRule] + + +class ListResolverRulesResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + ResolverRules: Optional[ResolverRules] + + +class ListTagsForResourceRequest(ServiceRequest): + ResourceArn: Arn + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListTagsForResourceResponse(TypedDict, total=False): + Tags: Optional[TagList] + NextToken: Optional[NextToken] + + +class PutFirewallRuleGroupPolicyRequest(ServiceRequest): + Arn: Arn + FirewallRuleGroupPolicy: FirewallRuleGroupPolicy + + +class PutFirewallRuleGroupPolicyResponse(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class PutResolverQueryLogConfigPolicyRequest(ServiceRequest): + Arn: Arn + ResolverQueryLogConfigPolicy: ResolverQueryLogConfigPolicy + + +class PutResolverQueryLogConfigPolicyResponse(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class PutResolverRulePolicyRequest(ServiceRequest): + Arn: Arn + ResolverRulePolicy: ResolverRulePolicy + + +class PutResolverRulePolicyResponse(TypedDict, total=False): + ReturnValue: Optional[Boolean] + + +class ResolverRuleConfig(TypedDict, total=False): + Name: Optional[Name] + TargetIps: Optional[TargetList] + ResolverEndpointId: Optional[ResourceId] + + +TagKeyList = List[TagKey] + + +class TagResourceRequest(ServiceRequest): + ResourceArn: Arn + Tags: TagList + + +class TagResourceResponse(TypedDict, total=False): + pass + + +class UntagResourceRequest(ServiceRequest): + ResourceArn: Arn + TagKeys: TagKeyList + + +class UntagResourceResponse(TypedDict, total=False): + pass + + +class UpdateFirewallConfigRequest(ServiceRequest): + ResourceId: ResourceId + FirewallFailOpen: FirewallFailOpenStatus + + +class UpdateFirewallConfigResponse(TypedDict, total=False): + FirewallConfig: Optional[FirewallConfig] + + +class UpdateFirewallDomainsRequest(ServiceRequest): + FirewallDomainListId: ResourceId + Operation: FirewallDomainUpdateOperation + Domains: FirewallDomains + + +class UpdateFirewallDomainsResponse(TypedDict, total=False): + Id: Optional[ResourceId] + Name: Optional[Name] + Status: Optional[FirewallDomainListStatus] + StatusMessage: Optional[StatusMessage] + + +class UpdateFirewallRuleGroupAssociationRequest(ServiceRequest): + FirewallRuleGroupAssociationId: ResourceId + Priority: Optional[Priority] + MutationProtection: Optional[MutationProtectionStatus] + Name: Optional[Name] + + +class UpdateFirewallRuleGroupAssociationResponse(TypedDict, total=False): + FirewallRuleGroupAssociation: Optional[FirewallRuleGroupAssociation] + + +class UpdateFirewallRuleRequest(ServiceRequest): + FirewallRuleGroupId: ResourceId + FirewallDomainListId: Optional[ResourceId] + FirewallThreatProtectionId: Optional[ResourceId] + Priority: Optional[Priority] + Action: Optional[Action] + BlockResponse: Optional[BlockResponse] + BlockOverrideDomain: Optional[BlockOverrideDomain] + BlockOverrideDnsType: Optional[BlockOverrideDnsType] + BlockOverrideTtl: Optional[BlockOverrideTtl] + Name: Optional[Name] + FirewallDomainRedirectionAction: Optional[FirewallDomainRedirectionAction] + Qtype: Optional[Qtype] + DnsThreatProtection: Optional[DnsThreatProtection] + ConfidenceThreshold: Optional[ConfidenceThreshold] + + +class UpdateFirewallRuleResponse(TypedDict, total=False): + FirewallRule: Optional[FirewallRule] + + +class UpdateIpAddress(TypedDict, total=False): + IpId: ResourceId + Ipv6: Ipv6 + + +UpdateIpAddresses = List[UpdateIpAddress] + + +class UpdateOutpostResolverRequest(ServiceRequest): + Id: ResourceId + Name: Optional[OutpostResolverName] + InstanceCount: Optional[InstanceCount] + PreferredInstanceType: Optional[OutpostInstanceType] + + +class UpdateOutpostResolverResponse(TypedDict, total=False): + OutpostResolver: Optional[OutpostResolver] + + +class UpdateResolverConfigRequest(ServiceRequest): + ResourceId: ResourceId + AutodefinedReverseFlag: AutodefinedReverseFlag + + +class UpdateResolverConfigResponse(TypedDict, total=False): + ResolverConfig: Optional[ResolverConfig] + + +class UpdateResolverDnssecConfigRequest(ServiceRequest): + ResourceId: ResourceId + Validation: Validation + + +class UpdateResolverDnssecConfigResponse(TypedDict, total=False): + ResolverDNSSECConfig: Optional[ResolverDnssecConfig] + + +class UpdateResolverEndpointRequest(ServiceRequest): + ResolverEndpointId: ResourceId + Name: Optional[Name] + ResolverEndpointType: Optional[ResolverEndpointType] + UpdateIpAddresses: Optional[UpdateIpAddresses] + Protocols: Optional[ProtocolList] + + +class UpdateResolverEndpointResponse(TypedDict, total=False): + ResolverEndpoint: Optional[ResolverEndpoint] + + +class UpdateResolverRuleRequest(ServiceRequest): + ResolverRuleId: ResourceId + Config: ResolverRuleConfig + + +class UpdateResolverRuleResponse(TypedDict, total=False): + ResolverRule: Optional[ResolverRule] + + +class Route53ResolverApi: + service = "route53resolver" + version = "2018-04-01" + + @handler("AssociateFirewallRuleGroup") + def associate_firewall_rule_group( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + firewall_rule_group_id: ResourceId, + vpc_id: ResourceId, + priority: Priority, + name: Name, + mutation_protection: MutationProtectionStatus | None = None, + tags: TagList | None = None, + **kwargs, + ) -> AssociateFirewallRuleGroupResponse: + raise NotImplementedError + + @handler("AssociateResolverEndpointIpAddress") + def associate_resolver_endpoint_ip_address( + self, + context: RequestContext, + resolver_endpoint_id: ResourceId, + ip_address: IpAddressUpdate, + **kwargs, + ) -> AssociateResolverEndpointIpAddressResponse: + raise NotImplementedError + + @handler("AssociateResolverQueryLogConfig") + def associate_resolver_query_log_config( + self, + context: RequestContext, + resolver_query_log_config_id: ResourceId, + resource_id: ResourceId, + **kwargs, + ) -> AssociateResolverQueryLogConfigResponse: + raise NotImplementedError + + @handler("AssociateResolverRule") + def associate_resolver_rule( + self, + context: RequestContext, + resolver_rule_id: ResourceId, + vpc_id: ResourceId, + name: Name | None = None, + **kwargs, + ) -> AssociateResolverRuleResponse: + raise NotImplementedError + + @handler("CreateFirewallDomainList") + def create_firewall_domain_list( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + name: Name, + tags: TagList | None = None, + **kwargs, + ) -> CreateFirewallDomainListResponse: + raise NotImplementedError + + @handler("CreateFirewallRule") + def create_firewall_rule( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + firewall_rule_group_id: ResourceId, + priority: Priority, + action: Action, + name: Name, + firewall_domain_list_id: ResourceId | None = None, + block_response: BlockResponse | None = None, + block_override_domain: BlockOverrideDomain | None = None, + block_override_dns_type: BlockOverrideDnsType | None = None, + block_override_ttl: BlockOverrideTtl | None = None, + firewall_domain_redirection_action: FirewallDomainRedirectionAction | None = None, + qtype: Qtype | None = None, + dns_threat_protection: DnsThreatProtection | None = None, + confidence_threshold: ConfidenceThreshold | None = None, + **kwargs, + ) -> CreateFirewallRuleResponse: + raise NotImplementedError + + @handler("CreateFirewallRuleGroup") + def create_firewall_rule_group( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + name: Name, + tags: TagList | None = None, + **kwargs, + ) -> CreateFirewallRuleGroupResponse: + raise NotImplementedError + + @handler("CreateOutpostResolver") + def create_outpost_resolver( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + name: OutpostResolverName, + preferred_instance_type: OutpostInstanceType, + outpost_arn: OutpostArn, + instance_count: InstanceCount | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateOutpostResolverResponse: + raise NotImplementedError + + @handler("CreateResolverEndpoint") + def create_resolver_endpoint( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + security_group_ids: SecurityGroupIds, + direction: ResolverEndpointDirection, + ip_addresses: IpAddressesRequest, + name: Name | None = None, + outpost_arn: OutpostArn | None = None, + preferred_instance_type: OutpostInstanceType | None = None, + tags: TagList | None = None, + resolver_endpoint_type: ResolverEndpointType | None = None, + protocols: ProtocolList | None = None, + **kwargs, + ) -> CreateResolverEndpointResponse: + raise NotImplementedError + + @handler("CreateResolverQueryLogConfig") + def create_resolver_query_log_config( + self, + context: RequestContext, + name: ResolverQueryLogConfigName, + destination_arn: DestinationArn, + creator_request_id: CreatorRequestId, + tags: TagList | None = None, + **kwargs, + ) -> CreateResolverQueryLogConfigResponse: + raise NotImplementedError + + @handler("CreateResolverRule") + def create_resolver_rule( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + rule_type: RuleTypeOption, + name: Name | None = None, + domain_name: DomainName | None = None, + target_ips: TargetList | None = None, + resolver_endpoint_id: ResourceId | None = None, + tags: TagList | None = None, + delegation_record: DelegationRecord | None = None, + **kwargs, + ) -> CreateResolverRuleResponse: + raise NotImplementedError + + @handler("DeleteFirewallDomainList") + def delete_firewall_domain_list( + self, context: RequestContext, firewall_domain_list_id: ResourceId, **kwargs + ) -> DeleteFirewallDomainListResponse: + raise NotImplementedError + + @handler("DeleteFirewallRule") + def delete_firewall_rule( + self, + context: RequestContext, + firewall_rule_group_id: ResourceId, + firewall_domain_list_id: ResourceId | None = None, + firewall_threat_protection_id: ResourceId | None = None, + qtype: Qtype | None = None, + **kwargs, + ) -> DeleteFirewallRuleResponse: + raise NotImplementedError + + @handler("DeleteFirewallRuleGroup") + def delete_firewall_rule_group( + self, context: RequestContext, firewall_rule_group_id: ResourceId, **kwargs + ) -> DeleteFirewallRuleGroupResponse: + raise NotImplementedError + + @handler("DeleteOutpostResolver") + def delete_outpost_resolver( + self, context: RequestContext, id: ResourceId, **kwargs + ) -> DeleteOutpostResolverResponse: + raise NotImplementedError + + @handler("DeleteResolverEndpoint") + def delete_resolver_endpoint( + self, context: RequestContext, resolver_endpoint_id: ResourceId, **kwargs + ) -> DeleteResolverEndpointResponse: + raise NotImplementedError + + @handler("DeleteResolverQueryLogConfig") + def delete_resolver_query_log_config( + self, context: RequestContext, resolver_query_log_config_id: ResourceId, **kwargs + ) -> DeleteResolverQueryLogConfigResponse: + raise NotImplementedError + + @handler("DeleteResolverRule") + def delete_resolver_rule( + self, context: RequestContext, resolver_rule_id: ResourceId, **kwargs + ) -> DeleteResolverRuleResponse: + raise NotImplementedError + + @handler("DisassociateFirewallRuleGroup") + def disassociate_firewall_rule_group( + self, context: RequestContext, firewall_rule_group_association_id: ResourceId, **kwargs + ) -> DisassociateFirewallRuleGroupResponse: + raise NotImplementedError + + @handler("DisassociateResolverEndpointIpAddress") + def disassociate_resolver_endpoint_ip_address( + self, + context: RequestContext, + resolver_endpoint_id: ResourceId, + ip_address: IpAddressUpdate, + **kwargs, + ) -> DisassociateResolverEndpointIpAddressResponse: + raise NotImplementedError + + @handler("DisassociateResolverQueryLogConfig") + def disassociate_resolver_query_log_config( + self, + context: RequestContext, + resolver_query_log_config_id: ResourceId, + resource_id: ResourceId, + **kwargs, + ) -> DisassociateResolverQueryLogConfigResponse: + raise NotImplementedError + + @handler("DisassociateResolverRule") + def disassociate_resolver_rule( + self, context: RequestContext, vpc_id: ResourceId, resolver_rule_id: ResourceId, **kwargs + ) -> DisassociateResolverRuleResponse: + raise NotImplementedError + + @handler("GetFirewallConfig") + def get_firewall_config( + self, context: RequestContext, resource_id: ResourceId, **kwargs + ) -> GetFirewallConfigResponse: + raise NotImplementedError + + @handler("GetFirewallDomainList") + def get_firewall_domain_list( + self, context: RequestContext, firewall_domain_list_id: ResourceId, **kwargs + ) -> GetFirewallDomainListResponse: + raise NotImplementedError + + @handler("GetFirewallRuleGroup") + def get_firewall_rule_group( + self, context: RequestContext, firewall_rule_group_id: ResourceId, **kwargs + ) -> GetFirewallRuleGroupResponse: + raise NotImplementedError + + @handler("GetFirewallRuleGroupAssociation") + def get_firewall_rule_group_association( + self, context: RequestContext, firewall_rule_group_association_id: ResourceId, **kwargs + ) -> GetFirewallRuleGroupAssociationResponse: + raise NotImplementedError + + @handler("GetFirewallRuleGroupPolicy") + def get_firewall_rule_group_policy( + self, context: RequestContext, arn: Arn, **kwargs + ) -> GetFirewallRuleGroupPolicyResponse: + raise NotImplementedError + + @handler("GetOutpostResolver") + def get_outpost_resolver( + self, context: RequestContext, id: ResourceId, **kwargs + ) -> GetOutpostResolverResponse: + raise NotImplementedError + + @handler("GetResolverConfig") + def get_resolver_config( + self, context: RequestContext, resource_id: ResourceId, **kwargs + ) -> GetResolverConfigResponse: + raise NotImplementedError + + @handler("GetResolverDnssecConfig") + def get_resolver_dnssec_config( + self, context: RequestContext, resource_id: ResourceId, **kwargs + ) -> GetResolverDnssecConfigResponse: + raise NotImplementedError + + @handler("GetResolverEndpoint") + def get_resolver_endpoint( + self, context: RequestContext, resolver_endpoint_id: ResourceId, **kwargs + ) -> GetResolverEndpointResponse: + raise NotImplementedError + + @handler("GetResolverQueryLogConfig") + def get_resolver_query_log_config( + self, context: RequestContext, resolver_query_log_config_id: ResourceId, **kwargs + ) -> GetResolverQueryLogConfigResponse: + raise NotImplementedError + + @handler("GetResolverQueryLogConfigAssociation") + def get_resolver_query_log_config_association( + self, + context: RequestContext, + resolver_query_log_config_association_id: ResourceId, + **kwargs, + ) -> GetResolverQueryLogConfigAssociationResponse: + raise NotImplementedError + + @handler("GetResolverQueryLogConfigPolicy") + def get_resolver_query_log_config_policy( + self, context: RequestContext, arn: Arn, **kwargs + ) -> GetResolverQueryLogConfigPolicyResponse: + raise NotImplementedError + + @handler("GetResolverRule") + def get_resolver_rule( + self, context: RequestContext, resolver_rule_id: ResourceId, **kwargs + ) -> GetResolverRuleResponse: + raise NotImplementedError + + @handler("GetResolverRuleAssociation") + def get_resolver_rule_association( + self, context: RequestContext, resolver_rule_association_id: ResourceId, **kwargs + ) -> GetResolverRuleAssociationResponse: + raise NotImplementedError + + @handler("GetResolverRulePolicy") + def get_resolver_rule_policy( + self, context: RequestContext, arn: Arn, **kwargs + ) -> GetResolverRulePolicyResponse: + raise NotImplementedError + + @handler("ImportFirewallDomains") + def import_firewall_domains( + self, + context: RequestContext, + firewall_domain_list_id: ResourceId, + operation: FirewallDomainImportOperation, + domain_file_url: DomainListFileUrl, + **kwargs, + ) -> ImportFirewallDomainsResponse: + raise NotImplementedError + + @handler("ListFirewallConfigs") + def list_firewall_configs( + self, + context: RequestContext, + max_results: ListFirewallConfigsMaxResult | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListFirewallConfigsResponse: + raise NotImplementedError + + @handler("ListFirewallDomainLists") + def list_firewall_domain_lists( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListFirewallDomainListsResponse: + raise NotImplementedError + + @handler("ListFirewallDomains") + def list_firewall_domains( + self, + context: RequestContext, + firewall_domain_list_id: ResourceId, + max_results: ListDomainMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListFirewallDomainsResponse: + raise NotImplementedError + + @handler("ListFirewallRuleGroupAssociations") + def list_firewall_rule_group_associations( + self, + context: RequestContext, + firewall_rule_group_id: ResourceId | None = None, + vpc_id: ResourceId | None = None, + priority: Priority | None = None, + status: FirewallRuleGroupAssociationStatus | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListFirewallRuleGroupAssociationsResponse: + raise NotImplementedError + + @handler("ListFirewallRuleGroups") + def list_firewall_rule_groups( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListFirewallRuleGroupsResponse: + raise NotImplementedError + + @handler("ListFirewallRules") + def list_firewall_rules( + self, + context: RequestContext, + firewall_rule_group_id: ResourceId, + priority: Priority | None = None, + action: Action | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListFirewallRulesResponse: + raise NotImplementedError + + @handler("ListOutpostResolvers") + def list_outpost_resolvers( + self, + context: RequestContext, + outpost_arn: OutpostArn | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListOutpostResolversResponse: + raise NotImplementedError + + @handler("ListResolverConfigs") + def list_resolver_configs( + self, + context: RequestContext, + max_results: ListResolverConfigsMaxResult | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListResolverConfigsResponse: + raise NotImplementedError + + @handler("ListResolverDnssecConfigs") + def list_resolver_dnssec_configs( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + filters: Filters | None = None, + **kwargs, + ) -> ListResolverDnssecConfigsResponse: + raise NotImplementedError + + @handler("ListResolverEndpointIpAddresses") + def list_resolver_endpoint_ip_addresses( + self, + context: RequestContext, + resolver_endpoint_id: ResourceId, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListResolverEndpointIpAddressesResponse: + raise NotImplementedError + + @handler("ListResolverEndpoints") + def list_resolver_endpoints( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + filters: Filters | None = None, + **kwargs, + ) -> ListResolverEndpointsResponse: + raise NotImplementedError + + @handler("ListResolverQueryLogConfigAssociations") + def list_resolver_query_log_config_associations( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + filters: Filters | None = None, + sort_by: SortByKey | None = None, + sort_order: SortOrder | None = None, + **kwargs, + ) -> ListResolverQueryLogConfigAssociationsResponse: + raise NotImplementedError + + @handler("ListResolverQueryLogConfigs") + def list_resolver_query_log_configs( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + filters: Filters | None = None, + sort_by: SortByKey | None = None, + sort_order: SortOrder | None = None, + **kwargs, + ) -> ListResolverQueryLogConfigsResponse: + raise NotImplementedError + + @handler("ListResolverRuleAssociations") + def list_resolver_rule_associations( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + filters: Filters | None = None, + **kwargs, + ) -> ListResolverRuleAssociationsResponse: + raise NotImplementedError + + @handler("ListResolverRules") + def list_resolver_rules( + self, + context: RequestContext, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + filters: Filters | None = None, + **kwargs, + ) -> ListResolverRulesResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, + context: RequestContext, + resource_arn: Arn, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("PutFirewallRuleGroupPolicy") + def put_firewall_rule_group_policy( + self, + context: RequestContext, + arn: Arn, + firewall_rule_group_policy: FirewallRuleGroupPolicy, + **kwargs, + ) -> PutFirewallRuleGroupPolicyResponse: + raise NotImplementedError + + @handler("PutResolverQueryLogConfigPolicy") + def put_resolver_query_log_config_policy( + self, + context: RequestContext, + arn: Arn, + resolver_query_log_config_policy: ResolverQueryLogConfigPolicy, + **kwargs, + ) -> PutResolverQueryLogConfigPolicyResponse: + raise NotImplementedError + + @handler("PutResolverRulePolicy") + def put_resolver_rule_policy( + self, context: RequestContext, arn: Arn, resolver_rule_policy: ResolverRulePolicy, **kwargs + ) -> PutResolverRulePolicyResponse: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: Arn, tags: TagList, **kwargs + ) -> TagResourceResponse: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: Arn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceResponse: + raise NotImplementedError + + @handler("UpdateFirewallConfig") + def update_firewall_config( + self, + context: RequestContext, + resource_id: ResourceId, + firewall_fail_open: FirewallFailOpenStatus, + **kwargs, + ) -> UpdateFirewallConfigResponse: + raise NotImplementedError + + @handler("UpdateFirewallDomains") + def update_firewall_domains( + self, + context: RequestContext, + firewall_domain_list_id: ResourceId, + operation: FirewallDomainUpdateOperation, + domains: FirewallDomains, + **kwargs, + ) -> UpdateFirewallDomainsResponse: + raise NotImplementedError + + @handler("UpdateFirewallRule") + def update_firewall_rule( + self, + context: RequestContext, + firewall_rule_group_id: ResourceId, + firewall_domain_list_id: ResourceId | None = None, + firewall_threat_protection_id: ResourceId | None = None, + priority: Priority | None = None, + action: Action | None = None, + block_response: BlockResponse | None = None, + block_override_domain: BlockOverrideDomain | None = None, + block_override_dns_type: BlockOverrideDnsType | None = None, + block_override_ttl: BlockOverrideTtl | None = None, + name: Name | None = None, + firewall_domain_redirection_action: FirewallDomainRedirectionAction | None = None, + qtype: Qtype | None = None, + dns_threat_protection: DnsThreatProtection | None = None, + confidence_threshold: ConfidenceThreshold | None = None, + **kwargs, + ) -> UpdateFirewallRuleResponse: + raise NotImplementedError + + @handler("UpdateFirewallRuleGroupAssociation") + def update_firewall_rule_group_association( + self, + context: RequestContext, + firewall_rule_group_association_id: ResourceId, + priority: Priority | None = None, + mutation_protection: MutationProtectionStatus | None = None, + name: Name | None = None, + **kwargs, + ) -> UpdateFirewallRuleGroupAssociationResponse: + raise NotImplementedError + + @handler("UpdateOutpostResolver") + def update_outpost_resolver( + self, + context: RequestContext, + id: ResourceId, + name: OutpostResolverName | None = None, + instance_count: InstanceCount | None = None, + preferred_instance_type: OutpostInstanceType | None = None, + **kwargs, + ) -> UpdateOutpostResolverResponse: + raise NotImplementedError + + @handler("UpdateResolverConfig") + def update_resolver_config( + self, + context: RequestContext, + resource_id: ResourceId, + autodefined_reverse_flag: AutodefinedReverseFlag, + **kwargs, + ) -> UpdateResolverConfigResponse: + raise NotImplementedError + + @handler("UpdateResolverDnssecConfig") + def update_resolver_dnssec_config( + self, context: RequestContext, resource_id: ResourceId, validation: Validation, **kwargs + ) -> UpdateResolverDnssecConfigResponse: + raise NotImplementedError + + @handler("UpdateResolverEndpoint") + def update_resolver_endpoint( + self, + context: RequestContext, + resolver_endpoint_id: ResourceId, + name: Name | None = None, + resolver_endpoint_type: ResolverEndpointType | None = None, + update_ip_addresses: UpdateIpAddresses | None = None, + protocols: ProtocolList | None = None, + **kwargs, + ) -> UpdateResolverEndpointResponse: + raise NotImplementedError + + @handler("UpdateResolverRule") + def update_resolver_rule( + self, + context: RequestContext, + resolver_rule_id: ResourceId, + config: ResolverRuleConfig, + **kwargs, + ) -> UpdateResolverRuleResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/s3/__init__.py b/localstack-core/localstack/aws/api/s3/__init__.py new file mode 100644 index 0000000000000..85a31139d1dcb --- /dev/null +++ b/localstack-core/localstack/aws/api/s3/__init__.py @@ -0,0 +1,5151 @@ +from datetime import datetime +from enum import StrEnum +from typing import IO, Dict, Iterable, Iterator, List, Optional, TypedDict, Union + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AbortRuleId = str +AcceptRanges = str +AccessKeyIdValue = str +AccessPointAlias = bool +AccessPointArn = str +AccountId = str +AllowQuotedRecordDelimiter = bool +AllowedHeader = str +AllowedMethod = str +AllowedOrigin = str +AnalyticsId = str +BucketKeyEnabled = bool +BucketLocationName = str +BucketName = str +BucketRegion = str +BypassGovernanceRetention = bool +CacheControl = str +ChecksumCRC32 = str +ChecksumCRC32C = str +ChecksumCRC64NVME = str +ChecksumSHA1 = str +ChecksumSHA256 = str +ClientToken = str +CloudFunction = str +CloudFunctionInvocationRole = str +Code = str +Comments = str +ConfirmRemoveSelfBucketAccess = bool +ContentDisposition = str +ContentEncoding = str +ContentLanguage = str +ContentMD5 = str +ContentRange = str +ContentType = str +CopySource = str +CopySourceIfMatch = str +CopySourceIfNoneMatch = str +CopySourceRange = str +CopySourceSSECustomerAlgorithm = str +CopySourceSSECustomerKey = str +CopySourceSSECustomerKeyMD5 = str +CopySourceVersionId = str +Days = int +DaysAfterInitiation = int +DeleteMarker = bool +DeleteMarkerVersionId = str +Delimiter = str +Description = str +DirectoryBucketToken = str +DisplayName = str +ETag = str +EmailAddress = str +EnableRequestProgress = bool +ErrorCode = str +ErrorMessage = str +Expiration = str +ExpiredObjectDeleteMarker = bool +ExposeHeader = str +Expression = str +FetchOwner = bool +FieldDelimiter = str +FilterRuleValue = str +GetObjectResponseStatusCode = int +GrantFullControl = str +GrantRead = str +GrantReadACP = str +GrantWrite = str +GrantWriteACP = str +HostName = str +HttpErrorCodeReturnedEquals = str +HttpRedirectCode = str +ID = str +IfMatch = str +IfNoneMatch = str +IntelligentTieringDays = int +IntelligentTieringId = str +InventoryId = str +IsEnabled = bool +IsLatest = bool +IsPublic = bool +IsRestoreInProgress = bool +IsTruncated = bool +KMSContext = str +KeyCount = int +KeyMarker = str +KeyPrefixEquals = str +LambdaFunctionArn = str +Location = str +LocationNameAsString = str +LocationPrefix = str +MFA = str +Marker = str +MaxAgeSeconds = int +MaxBuckets = int +MaxDirectoryBuckets = int +MaxKeys = int +MaxParts = int +MaxUploads = int +Message = str +MetadataKey = str +MetadataTableStatus = str +MetadataValue = str +MetricsId = str +Minutes = int +MissingMeta = int +MultipartUploadId = str +NextKeyMarker = str +NextMarker = str +NextPartNumberMarker = int +NextToken = str +NextUploadIdMarker = str +NextVersionIdMarker = str +NotificationId = str +ObjectKey = str +ObjectLockEnabledForBucket = bool +ObjectLockToken = str +ObjectVersionId = str +PartNumber = int +PartNumberMarker = int +PartsCount = int +Policy = str +Prefix = str +Priority = int +QueueArn = str +Quiet = bool +QuoteCharacter = str +QuoteEscapeCharacter = str +Range = str +RecordDelimiter = str +Region = str +RenameSource = str +RenameSourceIfMatch = str +RenameSourceIfNoneMatch = str +ReplaceKeyPrefixWith = str +ReplaceKeyWith = str +ReplicaKmsKeyID = str +RequestRoute = str +RequestToken = str +ResponseCacheControl = str +ResponseContentDisposition = str +ResponseContentEncoding = str +ResponseContentLanguage = str +ResponseContentType = str +Restore = str +RestoreOutputPath = str +Role = str +S3RegionalOrS3ExpressBucketArnString = str +S3TablesArn = str +S3TablesBucketArn = str +S3TablesName = str +S3TablesNamespace = str +SSECustomerAlgorithm = str +SSECustomerKey = str +SSECustomerKeyMD5 = str +SSEKMSEncryptionContext = str +SSEKMSKeyId = str +SessionCredentialValue = str +Setting = bool +SkipValidation = bool +StartAfter = str +Suffix = str +TagCount = int +TaggingHeader = str +TargetBucket = str +TargetPrefix = str +Token = str +TopicArn = str +URI = str +UploadIdMarker = str +Value = str +VersionCount = int +VersionIdMarker = str +WebsiteRedirectLocation = str +Years = int +BucketContentType = str +IfCondition = str +RestoreObjectOutputStatusCode = int +ArgumentName = str +ArgumentValue = str +AWSAccessKeyId = str +HostId = str +HeadersNotSigned = str +SignatureProvided = str +StringToSign = str +StringToSignBytes = str +CanonicalRequest = str +CanonicalRequestBytes = str +X_Amz_Expires = int +HttpMethod = str +ResourceType = str +MissingHeaderName = str +KeyLength = str +Header = str +additionalMessage = str + + +class AnalyticsS3ExportFileFormat(StrEnum): + CSV = "CSV" + + +class ArchiveStatus(StrEnum): + ARCHIVE_ACCESS = "ARCHIVE_ACCESS" + DEEP_ARCHIVE_ACCESS = "DEEP_ARCHIVE_ACCESS" + + +class BucketAccelerateStatus(StrEnum): + Enabled = "Enabled" + Suspended = "Suspended" + + +class BucketCannedACL(StrEnum): + private = "private" + public_read = "public-read" + public_read_write = "public-read-write" + authenticated_read = "authenticated-read" + log_delivery_write = "log-delivery-write" + + +class BucketLocationConstraint(StrEnum): + af_south_1 = "af-south-1" + ap_east_1 = "ap-east-1" + ap_northeast_1 = "ap-northeast-1" + ap_northeast_2 = "ap-northeast-2" + ap_northeast_3 = "ap-northeast-3" + ap_south_1 = "ap-south-1" + ap_south_2 = "ap-south-2" + ap_southeast_1 = "ap-southeast-1" + ap_southeast_2 = "ap-southeast-2" + ap_southeast_3 = "ap-southeast-3" + ap_southeast_4 = "ap-southeast-4" + ap_southeast_5 = "ap-southeast-5" + ca_central_1 = "ca-central-1" + cn_north_1 = "cn-north-1" + cn_northwest_1 = "cn-northwest-1" + EU = "EU" + eu_central_1 = "eu-central-1" + eu_central_2 = "eu-central-2" + eu_north_1 = "eu-north-1" + eu_south_1 = "eu-south-1" + eu_south_2 = "eu-south-2" + eu_west_1 = "eu-west-1" + eu_west_2 = "eu-west-2" + eu_west_3 = "eu-west-3" + il_central_1 = "il-central-1" + me_central_1 = "me-central-1" + me_south_1 = "me-south-1" + sa_east_1 = "sa-east-1" + us_east_2 = "us-east-2" + us_gov_east_1 = "us-gov-east-1" + us_gov_west_1 = "us-gov-west-1" + us_west_1 = "us-west-1" + us_west_2 = "us-west-2" + + +class BucketLogsPermission(StrEnum): + FULL_CONTROL = "FULL_CONTROL" + READ = "READ" + WRITE = "WRITE" + + +class BucketType(StrEnum): + Directory = "Directory" + + +class BucketVersioningStatus(StrEnum): + Enabled = "Enabled" + Suspended = "Suspended" + + +class ChecksumAlgorithm(StrEnum): + CRC32 = "CRC32" + CRC32C = "CRC32C" + SHA1 = "SHA1" + SHA256 = "SHA256" + CRC64NVME = "CRC64NVME" + + +class ChecksumMode(StrEnum): + ENABLED = "ENABLED" + + +class ChecksumType(StrEnum): + COMPOSITE = "COMPOSITE" + FULL_OBJECT = "FULL_OBJECT" + + +class CompressionType(StrEnum): + NONE = "NONE" + GZIP = "GZIP" + BZIP2 = "BZIP2" + + +class DataRedundancy(StrEnum): + SingleAvailabilityZone = "SingleAvailabilityZone" + SingleLocalZone = "SingleLocalZone" + + +class DeleteMarkerReplicationStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class EncodingType(StrEnum): + url = "url" + + +class Event(StrEnum): + s3_ReducedRedundancyLostObject = "s3:ReducedRedundancyLostObject" + s3_ObjectCreated_ = "s3:ObjectCreated:*" + s3_ObjectCreated_Put = "s3:ObjectCreated:Put" + s3_ObjectCreated_Post = "s3:ObjectCreated:Post" + s3_ObjectCreated_Copy = "s3:ObjectCreated:Copy" + s3_ObjectCreated_CompleteMultipartUpload = "s3:ObjectCreated:CompleteMultipartUpload" + s3_ObjectRemoved_ = "s3:ObjectRemoved:*" + s3_ObjectRemoved_Delete = "s3:ObjectRemoved:Delete" + s3_ObjectRemoved_DeleteMarkerCreated = "s3:ObjectRemoved:DeleteMarkerCreated" + s3_ObjectRestore_ = "s3:ObjectRestore:*" + s3_ObjectRestore_Post = "s3:ObjectRestore:Post" + s3_ObjectRestore_Completed = "s3:ObjectRestore:Completed" + s3_Replication_ = "s3:Replication:*" + s3_Replication_OperationFailedReplication = "s3:Replication:OperationFailedReplication" + s3_Replication_OperationNotTracked = "s3:Replication:OperationNotTracked" + s3_Replication_OperationMissedThreshold = "s3:Replication:OperationMissedThreshold" + s3_Replication_OperationReplicatedAfterThreshold = ( + "s3:Replication:OperationReplicatedAfterThreshold" + ) + s3_ObjectRestore_Delete = "s3:ObjectRestore:Delete" + s3_LifecycleTransition = "s3:LifecycleTransition" + s3_IntelligentTiering = "s3:IntelligentTiering" + s3_ObjectAcl_Put = "s3:ObjectAcl:Put" + s3_LifecycleExpiration_ = "s3:LifecycleExpiration:*" + s3_LifecycleExpiration_Delete = "s3:LifecycleExpiration:Delete" + s3_LifecycleExpiration_DeleteMarkerCreated = "s3:LifecycleExpiration:DeleteMarkerCreated" + s3_ObjectTagging_ = "s3:ObjectTagging:*" + s3_ObjectTagging_Put = "s3:ObjectTagging:Put" + s3_ObjectTagging_Delete = "s3:ObjectTagging:Delete" + + +class ExistingObjectReplicationStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ExpirationStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ExpressionType(StrEnum): + SQL = "SQL" + + +class FileHeaderInfo(StrEnum): + USE = "USE" + IGNORE = "IGNORE" + NONE = "NONE" + + +class FilterRuleName(StrEnum): + prefix = "prefix" + suffix = "suffix" + + +class IntelligentTieringAccessTier(StrEnum): + ARCHIVE_ACCESS = "ARCHIVE_ACCESS" + DEEP_ARCHIVE_ACCESS = "DEEP_ARCHIVE_ACCESS" + + +class IntelligentTieringStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class InventoryFormat(StrEnum): + CSV = "CSV" + ORC = "ORC" + Parquet = "Parquet" + + +class InventoryFrequency(StrEnum): + Daily = "Daily" + Weekly = "Weekly" + + +class InventoryIncludedObjectVersions(StrEnum): + All = "All" + Current = "Current" + + +class InventoryOptionalField(StrEnum): + Size = "Size" + LastModifiedDate = "LastModifiedDate" + StorageClass = "StorageClass" + ETag = "ETag" + IsMultipartUploaded = "IsMultipartUploaded" + ReplicationStatus = "ReplicationStatus" + EncryptionStatus = "EncryptionStatus" + ObjectLockRetainUntilDate = "ObjectLockRetainUntilDate" + ObjectLockMode = "ObjectLockMode" + ObjectLockLegalHoldStatus = "ObjectLockLegalHoldStatus" + IntelligentTieringAccessTier = "IntelligentTieringAccessTier" + BucketKeyStatus = "BucketKeyStatus" + ChecksumAlgorithm = "ChecksumAlgorithm" + ObjectAccessControlList = "ObjectAccessControlList" + ObjectOwner = "ObjectOwner" + + +class JSONType(StrEnum): + DOCUMENT = "DOCUMENT" + LINES = "LINES" + + +class LocationType(StrEnum): + AvailabilityZone = "AvailabilityZone" + LocalZone = "LocalZone" + + +class MFADelete(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class MFADeleteStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class MetadataDirective(StrEnum): + COPY = "COPY" + REPLACE = "REPLACE" + + +class MetricsStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ObjectAttributes(StrEnum): + ETag = "ETag" + Checksum = "Checksum" + ObjectParts = "ObjectParts" + StorageClass = "StorageClass" + ObjectSize = "ObjectSize" + + +class ObjectCannedACL(StrEnum): + private = "private" + public_read = "public-read" + public_read_write = "public-read-write" + authenticated_read = "authenticated-read" + aws_exec_read = "aws-exec-read" + bucket_owner_read = "bucket-owner-read" + bucket_owner_full_control = "bucket-owner-full-control" + + +class ObjectLockEnabled(StrEnum): + Enabled = "Enabled" + + +class ObjectLockLegalHoldStatus(StrEnum): + ON = "ON" + OFF = "OFF" + + +class ObjectLockMode(StrEnum): + GOVERNANCE = "GOVERNANCE" + COMPLIANCE = "COMPLIANCE" + + +class ObjectLockRetentionMode(StrEnum): + GOVERNANCE = "GOVERNANCE" + COMPLIANCE = "COMPLIANCE" + + +class ObjectOwnership(StrEnum): + BucketOwnerPreferred = "BucketOwnerPreferred" + ObjectWriter = "ObjectWriter" + BucketOwnerEnforced = "BucketOwnerEnforced" + + +class ObjectStorageClass(StrEnum): + STANDARD = "STANDARD" + REDUCED_REDUNDANCY = "REDUCED_REDUNDANCY" + GLACIER = "GLACIER" + STANDARD_IA = "STANDARD_IA" + ONEZONE_IA = "ONEZONE_IA" + INTELLIGENT_TIERING = "INTELLIGENT_TIERING" + DEEP_ARCHIVE = "DEEP_ARCHIVE" + OUTPOSTS = "OUTPOSTS" + GLACIER_IR = "GLACIER_IR" + SNOW = "SNOW" + EXPRESS_ONEZONE = "EXPRESS_ONEZONE" + FSX_OPENZFS = "FSX_OPENZFS" + + +class ObjectVersionStorageClass(StrEnum): + STANDARD = "STANDARD" + + +class OptionalObjectAttributes(StrEnum): + RestoreStatus = "RestoreStatus" + + +class OwnerOverride(StrEnum): + Destination = "Destination" + + +class PartitionDateSource(StrEnum): + EventTime = "EventTime" + DeliveryTime = "DeliveryTime" + + +class Payer(StrEnum): + Requester = "Requester" + BucketOwner = "BucketOwner" + + +class Permission(StrEnum): + FULL_CONTROL = "FULL_CONTROL" + WRITE = "WRITE" + WRITE_ACP = "WRITE_ACP" + READ = "READ" + READ_ACP = "READ_ACP" + + +class Protocol(StrEnum): + http = "http" + https = "https" + + +class QuoteFields(StrEnum): + ALWAYS = "ALWAYS" + ASNEEDED = "ASNEEDED" + + +class ReplicaModificationsStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ReplicationRuleStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ReplicationStatus(StrEnum): + COMPLETE = "COMPLETE" + PENDING = "PENDING" + FAILED = "FAILED" + REPLICA = "REPLICA" + COMPLETED = "COMPLETED" + + +class ReplicationTimeStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class RequestCharged(StrEnum): + requester = "requester" + + +class RequestPayer(StrEnum): + requester = "requester" + + +class RestoreRequestType(StrEnum): + SELECT = "SELECT" + + +class ServerSideEncryption(StrEnum): + AES256 = "AES256" + aws_fsx = "aws:fsx" + aws_kms = "aws:kms" + aws_kms_dsse = "aws:kms:dsse" + + +class SessionMode(StrEnum): + ReadOnly = "ReadOnly" + ReadWrite = "ReadWrite" + + +class SseKmsEncryptedObjectsStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class StorageClass(StrEnum): + STANDARD = "STANDARD" + REDUCED_REDUNDANCY = "REDUCED_REDUNDANCY" + STANDARD_IA = "STANDARD_IA" + ONEZONE_IA = "ONEZONE_IA" + INTELLIGENT_TIERING = "INTELLIGENT_TIERING" + GLACIER = "GLACIER" + DEEP_ARCHIVE = "DEEP_ARCHIVE" + OUTPOSTS = "OUTPOSTS" + GLACIER_IR = "GLACIER_IR" + SNOW = "SNOW" + EXPRESS_ONEZONE = "EXPRESS_ONEZONE" + FSX_OPENZFS = "FSX_OPENZFS" + + +class StorageClassAnalysisSchemaVersion(StrEnum): + V_1 = "V_1" + + +class TaggingDirective(StrEnum): + COPY = "COPY" + REPLACE = "REPLACE" + + +class Tier(StrEnum): + Standard = "Standard" + Bulk = "Bulk" + Expedited = "Expedited" + + +class TransitionDefaultMinimumObjectSize(StrEnum): + varies_by_storage_class = "varies_by_storage_class" + all_storage_classes_128K = "all_storage_classes_128K" + + +class TransitionStorageClass(StrEnum): + GLACIER = "GLACIER" + STANDARD_IA = "STANDARD_IA" + ONEZONE_IA = "ONEZONE_IA" + INTELLIGENT_TIERING = "INTELLIGENT_TIERING" + DEEP_ARCHIVE = "DEEP_ARCHIVE" + GLACIER_IR = "GLACIER_IR" + + +class Type(StrEnum): + CanonicalUser = "CanonicalUser" + AmazonCustomerByEmail = "AmazonCustomerByEmail" + Group = "Group" + + +class BucketAlreadyExists(ServiceException): + code: str = "BucketAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + +class BucketAlreadyOwnedByYou(ServiceException): + code: str = "BucketAlreadyOwnedByYou" + sender_fault: bool = False + status_code: int = 409 + BucketName: Optional[BucketName] + + +class EncryptionTypeMismatch(ServiceException): + code: str = "EncryptionTypeMismatch" + sender_fault: bool = False + status_code: int = 400 + + +class IdempotencyParameterMismatch(ServiceException): + code: str = "IdempotencyParameterMismatch" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidObjectState(ServiceException): + code: str = "InvalidObjectState" + sender_fault: bool = False + status_code: int = 403 + StorageClass: Optional[StorageClass] + AccessTier: Optional[IntelligentTieringAccessTier] + + +class InvalidRequest(ServiceException): + code: str = "InvalidRequest" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidWriteOffset(ServiceException): + code: str = "InvalidWriteOffset" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchBucket(ServiceException): + code: str = "NoSuchBucket" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class NoSuchKey(ServiceException): + code: str = "NoSuchKey" + sender_fault: bool = False + status_code: int = 404 + Key: Optional[ObjectKey] + DeleteMarker: Optional[DeleteMarker] + VersionId: Optional[ObjectVersionId] + + +class NoSuchUpload(ServiceException): + code: str = "NoSuchUpload" + sender_fault: bool = False + status_code: int = 404 + UploadId: Optional[MultipartUploadId] + + +class ObjectAlreadyInActiveTierError(ServiceException): + code: str = "ObjectAlreadyInActiveTierError" + sender_fault: bool = False + status_code: int = 403 + + +class ObjectNotInActiveTierError(ServiceException): + code: str = "ObjectNotInActiveTierError" + sender_fault: bool = False + status_code: int = 403 + + +class TooManyParts(ServiceException): + code: str = "TooManyParts" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchLifecycleConfiguration(ServiceException): + code: str = "NoSuchLifecycleConfiguration" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class InvalidBucketName(ServiceException): + code: str = "InvalidBucketName" + sender_fault: bool = False + status_code: int = 400 + BucketName: Optional[BucketName] + + +class NoSuchVersion(ServiceException): + code: str = "NoSuchVersion" + sender_fault: bool = False + status_code: int = 404 + VersionId: Optional[ObjectVersionId] + Key: Optional[ObjectKey] + + +class PreconditionFailed(ServiceException): + code: str = "PreconditionFailed" + sender_fault: bool = False + status_code: int = 412 + Condition: Optional[IfCondition] + + +ObjectSize = int + + +class InvalidRange(ServiceException): + code: str = "InvalidRange" + sender_fault: bool = False + status_code: int = 416 + ActualObjectSize: Optional[ObjectSize] + RangeRequested: Optional[ContentRange] + + +class InvalidArgument(ServiceException): + code: str = "InvalidArgument" + sender_fault: bool = False + status_code: int = 400 + ArgumentName: Optional[ArgumentName] + ArgumentValue: Optional[ArgumentValue] + HostId: Optional[HostId] + + +class SignatureDoesNotMatch(ServiceException): + code: str = "SignatureDoesNotMatch" + sender_fault: bool = False + status_code: int = 403 + AWSAccessKeyId: Optional[AWSAccessKeyId] + CanonicalRequest: Optional[CanonicalRequest] + CanonicalRequestBytes: Optional[CanonicalRequestBytes] + HostId: Optional[HostId] + SignatureProvided: Optional[SignatureProvided] + StringToSign: Optional[StringToSign] + StringToSignBytes: Optional[StringToSignBytes] + + +ServerTime = datetime +Expires = datetime + + +class AccessDenied(ServiceException): + code: str = "AccessDenied" + sender_fault: bool = False + status_code: int = 403 + Expires: Optional[Expires] + ServerTime: Optional[ServerTime] + X_Amz_Expires: Optional[X_Amz_Expires] + HostId: Optional[HostId] + HeadersNotSigned: Optional[HeadersNotSigned] + + +class AuthorizationQueryParametersError(ServiceException): + code: str = "AuthorizationQueryParametersError" + sender_fault: bool = False + status_code: int = 400 + HostId: Optional[HostId] + + +class NoSuchWebsiteConfiguration(ServiceException): + code: str = "NoSuchWebsiteConfiguration" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class ReplicationConfigurationNotFoundError(ServiceException): + code: str = "ReplicationConfigurationNotFoundError" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class BadRequest(ServiceException): + code: str = "BadRequest" + sender_fault: bool = False + status_code: int = 400 + HostId: Optional[HostId] + + +class AccessForbidden(ServiceException): + code: str = "AccessForbidden" + sender_fault: bool = False + status_code: int = 403 + HostId: Optional[HostId] + Method: Optional[HttpMethod] + ResourceType: Optional[ResourceType] + + +class NoSuchCORSConfiguration(ServiceException): + code: str = "NoSuchCORSConfiguration" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class MissingSecurityHeader(ServiceException): + code: str = "MissingSecurityHeader" + sender_fault: bool = False + status_code: int = 400 + MissingHeaderName: Optional[MissingHeaderName] + + +class InvalidPartOrder(ServiceException): + code: str = "InvalidPartOrder" + sender_fault: bool = False + status_code: int = 400 + UploadId: Optional[MultipartUploadId] + + +class InvalidStorageClass(ServiceException): + code: str = "InvalidStorageClass" + sender_fault: bool = False + status_code: int = 400 + StorageClassRequested: Optional[StorageClass] + + +class MethodNotAllowed(ServiceException): + code: str = "MethodNotAllowed" + sender_fault: bool = False + status_code: int = 405 + Method: Optional[HttpMethod] + ResourceType: Optional[ResourceType] + DeleteMarker: Optional[DeleteMarker] + VersionId: Optional[ObjectVersionId] + Allow: Optional[HttpMethod] + + +class CrossLocationLoggingProhibitted(ServiceException): + code: str = "CrossLocationLoggingProhibitted" + sender_fault: bool = False + status_code: int = 403 + TargetBucketLocation: Optional[BucketRegion] + SourceBucketLocation: Optional[BucketRegion] + + +class InvalidTargetBucketForLogging(ServiceException): + code: str = "InvalidTargetBucketForLogging" + sender_fault: bool = False + status_code: int = 400 + TargetBucket: Optional[BucketName] + + +class BucketNotEmpty(ServiceException): + code: str = "BucketNotEmpty" + sender_fault: bool = False + status_code: int = 409 + BucketName: Optional[BucketName] + + +ProposedSize = int +MinSizeAllowed = int + + +class EntityTooSmall(ServiceException): + code: str = "EntityTooSmall" + sender_fault: bool = False + status_code: int = 400 + ETag: Optional[ETag] + MinSizeAllowed: Optional[MinSizeAllowed] + PartNumber: Optional[PartNumber] + ProposedSize: Optional[ProposedSize] + + +class InvalidPart(ServiceException): + code: str = "InvalidPart" + sender_fault: bool = False + status_code: int = 400 + ETag: Optional[ETag] + UploadId: Optional[MultipartUploadId] + PartNumber: Optional[PartNumber] + + +class NoSuchTagSet(ServiceException): + code: str = "NoSuchTagSet" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class InvalidTag(ServiceException): + code: str = "InvalidTag" + sender_fault: bool = False + status_code: int = 400 + TagKey: Optional[ObjectKey] + TagValue: Optional[Value] + + +class ObjectLockConfigurationNotFoundError(ServiceException): + code: str = "ObjectLockConfigurationNotFoundError" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class InvalidPartNumber(ServiceException): + code: str = "InvalidPartNumber" + sender_fault: bool = False + status_code: int = 416 + PartNumberRequested: Optional[PartNumber] + ActualPartCount: Optional[PartNumber] + + +class OwnershipControlsNotFoundError(ServiceException): + code: str = "OwnershipControlsNotFoundError" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class NoSuchPublicAccessBlockConfiguration(ServiceException): + code: str = "NoSuchPublicAccessBlockConfiguration" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class NoSuchBucketPolicy(ServiceException): + code: str = "NoSuchBucketPolicy" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[BucketName] + + +class InvalidDigest(ServiceException): + code: str = "InvalidDigest" + sender_fault: bool = False + status_code: int = 400 + Content_MD5: Optional[ContentMD5] + + +class KeyTooLongError(ServiceException): + code: str = "KeyTooLongError" + sender_fault: bool = False + status_code: int = 400 + MaxSizeAllowed: Optional[KeyLength] + Size: Optional[KeyLength] + + +class InvalidLocationConstraint(ServiceException): + code: str = "InvalidLocationConstraint" + sender_fault: bool = False + status_code: int = 400 + LocationConstraint: Optional[BucketRegion] + + +class EntityTooLarge(ServiceException): + code: str = "EntityTooLarge" + sender_fault: bool = False + status_code: int = 400 + MaxSizeAllowed: Optional[KeyLength] + HostId: Optional[HostId] + ProposedSize: Optional[ProposedSize] + + +class InvalidEncryptionAlgorithmError(ServiceException): + code: str = "InvalidEncryptionAlgorithmError" + sender_fault: bool = False + status_code: int = 400 + ArgumentName: Optional[ArgumentName] + ArgumentValue: Optional[ArgumentValue] + + +class NotImplemented(ServiceException): + code: str = "NotImplemented" + sender_fault: bool = False + status_code: int = 501 + Header: Optional[Header] + additionalMessage: Optional[additionalMessage] + + +class ConditionalRequestConflict(ServiceException): + code: str = "ConditionalRequestConflict" + sender_fault: bool = False + status_code: int = 409 + Condition: Optional[IfCondition] + Key: Optional[ObjectKey] + + +class BadDigest(ServiceException): + code: str = "BadDigest" + sender_fault: bool = False + status_code: int = 400 + ExpectedDigest: Optional[ContentMD5] + CalculatedDigest: Optional[ContentMD5] + + +AbortDate = datetime + + +class AbortIncompleteMultipartUpload(TypedDict, total=False): + DaysAfterInitiation: Optional[DaysAfterInitiation] + + +class AbortMultipartUploadOutput(TypedDict, total=False): + RequestCharged: Optional[RequestCharged] + + +IfMatchInitiatedTime = datetime + + +class AbortMultipartUploadRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + UploadId: MultipartUploadId + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + IfMatchInitiatedTime: Optional[IfMatchInitiatedTime] + + +class AccelerateConfiguration(TypedDict, total=False): + Status: Optional[BucketAccelerateStatus] + + +class Owner(TypedDict, total=False): + DisplayName: Optional[DisplayName] + ID: Optional[ID] + + +class Grantee(TypedDict, total=False): + DisplayName: Optional[DisplayName] + EmailAddress: Optional[EmailAddress] + ID: Optional[ID] + Type: Type + URI: Optional[URI] + + +class Grant(TypedDict, total=False): + Grantee: Optional[Grantee] + Permission: Optional[Permission] + + +Grants = List[Grant] + + +class AccessControlPolicy(TypedDict, total=False): + Grants: Optional[Grants] + Owner: Optional[Owner] + + +class AccessControlTranslation(TypedDict, total=False): + Owner: OwnerOverride + + +AllowedHeaders = List[AllowedHeader] +AllowedMethods = List[AllowedMethod] +AllowedOrigins = List[AllowedOrigin] + + +class Tag(TypedDict, total=False): + Key: ObjectKey + Value: Value + + +TagSet = List[Tag] + + +class AnalyticsAndOperator(TypedDict, total=False): + Prefix: Optional[Prefix] + Tags: Optional[TagSet] + + +class AnalyticsS3BucketDestination(TypedDict, total=False): + Format: AnalyticsS3ExportFileFormat + BucketAccountId: Optional[AccountId] + Bucket: BucketName + Prefix: Optional[Prefix] + + +class AnalyticsExportDestination(TypedDict, total=False): + S3BucketDestination: AnalyticsS3BucketDestination + + +class StorageClassAnalysisDataExport(TypedDict, total=False): + OutputSchemaVersion: StorageClassAnalysisSchemaVersion + Destination: AnalyticsExportDestination + + +class StorageClassAnalysis(TypedDict, total=False): + DataExport: Optional[StorageClassAnalysisDataExport] + + +class AnalyticsFilter(TypedDict, total=False): + Prefix: Optional[Prefix] + Tag: Optional[Tag] + And: Optional[AnalyticsAndOperator] + + +class AnalyticsConfiguration(TypedDict, total=False): + Id: AnalyticsId + Filter: Optional[AnalyticsFilter] + StorageClassAnalysis: StorageClassAnalysis + + +AnalyticsConfigurationList = List[AnalyticsConfiguration] +Body = bytes +CreationDate = datetime + + +class Bucket(TypedDict, total=False): + Name: Optional[BucketName] + CreationDate: Optional[CreationDate] + BucketRegion: Optional[BucketRegion] + BucketArn: Optional[S3RegionalOrS3ExpressBucketArnString] + + +class BucketInfo(TypedDict, total=False): + DataRedundancy: Optional[DataRedundancy] + Type: Optional[BucketType] + + +class NoncurrentVersionExpiration(TypedDict, total=False): + NoncurrentDays: Optional[Days] + NewerNoncurrentVersions: Optional[VersionCount] + + +class NoncurrentVersionTransition(TypedDict, total=False): + NoncurrentDays: Optional[Days] + StorageClass: Optional[TransitionStorageClass] + NewerNoncurrentVersions: Optional[VersionCount] + + +NoncurrentVersionTransitionList = List[NoncurrentVersionTransition] +Date = datetime + + +class Transition(TypedDict, total=False): + Date: Optional[Date] + Days: Optional[Days] + StorageClass: Optional[TransitionStorageClass] + + +TransitionList = List[Transition] +ObjectSizeLessThanBytes = int +ObjectSizeGreaterThanBytes = int + + +class LifecycleRuleAndOperator(TypedDict, total=False): + Prefix: Optional[Prefix] + Tags: Optional[TagSet] + ObjectSizeGreaterThan: Optional[ObjectSizeGreaterThanBytes] + ObjectSizeLessThan: Optional[ObjectSizeLessThanBytes] + + +class LifecycleRuleFilter(TypedDict, total=False): + Prefix: Optional[Prefix] + Tag: Optional[Tag] + ObjectSizeGreaterThan: Optional[ObjectSizeGreaterThanBytes] + ObjectSizeLessThan: Optional[ObjectSizeLessThanBytes] + And: Optional[LifecycleRuleAndOperator] + + +class LifecycleExpiration(TypedDict, total=False): + Date: Optional[Date] + Days: Optional[Days] + ExpiredObjectDeleteMarker: Optional[ExpiredObjectDeleteMarker] + + +class LifecycleRule(TypedDict, total=False): + Expiration: Optional[LifecycleExpiration] + ID: Optional[ID] + Prefix: Optional[Prefix] + Filter: Optional[LifecycleRuleFilter] + Status: ExpirationStatus + Transitions: Optional[TransitionList] + NoncurrentVersionTransitions: Optional[NoncurrentVersionTransitionList] + NoncurrentVersionExpiration: Optional[NoncurrentVersionExpiration] + AbortIncompleteMultipartUpload: Optional[AbortIncompleteMultipartUpload] + + +LifecycleRules = List[LifecycleRule] + + +class BucketLifecycleConfiguration(TypedDict, total=False): + Rules: LifecycleRules + + +class PartitionedPrefix(TypedDict, total=False): + PartitionDateSource: Optional[PartitionDateSource] + + +class SimplePrefix(TypedDict, total=False): + pass + + +class TargetObjectKeyFormat(TypedDict, total=False): + SimplePrefix: Optional[SimplePrefix] + PartitionedPrefix: Optional[PartitionedPrefix] + + +class TargetGrant(TypedDict, total=False): + Grantee: Optional[Grantee] + Permission: Optional[BucketLogsPermission] + + +TargetGrants = List[TargetGrant] + + +class LoggingEnabled(TypedDict, total=False): + TargetBucket: TargetBucket + TargetGrants: Optional[TargetGrants] + TargetPrefix: TargetPrefix + TargetObjectKeyFormat: Optional[TargetObjectKeyFormat] + + +class BucketLoggingStatus(TypedDict, total=False): + LoggingEnabled: Optional[LoggingEnabled] + + +Buckets = List[Bucket] +BytesProcessed = int +BytesReturned = int +BytesScanned = int +ExposeHeaders = List[ExposeHeader] + + +class CORSRule(TypedDict, total=False): + ID: Optional[ID] + AllowedHeaders: Optional[AllowedHeaders] + AllowedMethods: AllowedMethods + AllowedOrigins: AllowedOrigins + ExposeHeaders: Optional[ExposeHeaders] + MaxAgeSeconds: Optional[MaxAgeSeconds] + + +CORSRules = List[CORSRule] + + +class CORSConfiguration(TypedDict, total=False): + CORSRules: CORSRules + + +class CSVInput(TypedDict, total=False): + FileHeaderInfo: Optional[FileHeaderInfo] + Comments: Optional[Comments] + QuoteEscapeCharacter: Optional[QuoteEscapeCharacter] + RecordDelimiter: Optional[RecordDelimiter] + FieldDelimiter: Optional[FieldDelimiter] + QuoteCharacter: Optional[QuoteCharacter] + AllowQuotedRecordDelimiter: Optional[AllowQuotedRecordDelimiter] + + +class CSVOutput(TypedDict, total=False): + QuoteFields: Optional[QuoteFields] + QuoteEscapeCharacter: Optional[QuoteEscapeCharacter] + RecordDelimiter: Optional[RecordDelimiter] + FieldDelimiter: Optional[FieldDelimiter] + QuoteCharacter: Optional[QuoteCharacter] + + +class Checksum(TypedDict, total=False): + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + ChecksumType: Optional[ChecksumType] + + +ChecksumAlgorithmList = List[ChecksumAlgorithm] +EventList = List[Event] + + +class CloudFunctionConfiguration(TypedDict, total=False): + Id: Optional[NotificationId] + Event: Optional[Event] + Events: Optional[EventList] + CloudFunction: Optional[CloudFunction] + InvocationRole: Optional[CloudFunctionInvocationRole] + + +class CommonPrefix(TypedDict, total=False): + Prefix: Optional[Prefix] + + +CommonPrefixList = List[CommonPrefix] + + +class CompleteMultipartUploadOutput(TypedDict, total=False): + Location: Optional[Location] + Bucket: Optional[BucketName] + Key: Optional[ObjectKey] + Expiration: Optional[Expiration] + ETag: Optional[ETag] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + ChecksumType: Optional[ChecksumType] + ServerSideEncryption: Optional[ServerSideEncryption] + VersionId: Optional[ObjectVersionId] + SSEKMSKeyId: Optional[SSEKMSKeyId] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestCharged: Optional[RequestCharged] + + +MpuObjectSize = int + + +class CompletedPart(TypedDict, total=False): + ETag: Optional[ETag] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + PartNumber: Optional[PartNumber] + + +CompletedPartList = List[CompletedPart] + + +class CompletedMultipartUpload(TypedDict, total=False): + Parts: Optional[CompletedPartList] + + +class CompleteMultipartUploadRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + MultipartUpload: Optional[CompletedMultipartUpload] + UploadId: MultipartUploadId + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + ChecksumType: Optional[ChecksumType] + MpuObjectSize: Optional[MpuObjectSize] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + IfMatch: Optional[IfMatch] + IfNoneMatch: Optional[IfNoneMatch] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + + +class Condition(TypedDict, total=False): + HttpErrorCodeReturnedEquals: Optional[HttpErrorCodeReturnedEquals] + KeyPrefixEquals: Optional[KeyPrefixEquals] + + +ContentLength = int + + +class ContinuationEvent(TypedDict, total=False): + pass + + +LastModified = datetime + + +class CopyObjectResult(TypedDict, total=False): + ETag: Optional[ETag] + LastModified: Optional[LastModified] + ChecksumType: Optional[ChecksumType] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + + +class CopyObjectOutput(TypedDict, total=False): + CopyObjectResult: Optional[CopyObjectResult] + Expiration: Optional[Expiration] + CopySourceVersionId: Optional[CopySourceVersionId] + VersionId: Optional[ObjectVersionId] + ServerSideEncryption: Optional[ServerSideEncryption] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestCharged: Optional[RequestCharged] + + +ObjectLockRetainUntilDate = datetime +Metadata = Dict[MetadataKey, MetadataValue] +CopySourceIfUnmodifiedSince = datetime +CopySourceIfModifiedSince = datetime + + +class CopyObjectRequest(ServiceRequest): + ACL: Optional[ObjectCannedACL] + Bucket: BucketName + CacheControl: Optional[CacheControl] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ContentDisposition: Optional[ContentDisposition] + ContentEncoding: Optional[ContentEncoding] + ContentLanguage: Optional[ContentLanguage] + ContentType: Optional[ContentType] + CopySource: CopySource + CopySourceIfMatch: Optional[CopySourceIfMatch] + CopySourceIfModifiedSince: Optional[CopySourceIfModifiedSince] + CopySourceIfNoneMatch: Optional[CopySourceIfNoneMatch] + CopySourceIfUnmodifiedSince: Optional[CopySourceIfUnmodifiedSince] + Expires: Optional[Expires] + GrantFullControl: Optional[GrantFullControl] + GrantRead: Optional[GrantRead] + GrantReadACP: Optional[GrantReadACP] + GrantWriteACP: Optional[GrantWriteACP] + Key: ObjectKey + Metadata: Optional[Metadata] + MetadataDirective: Optional[MetadataDirective] + TaggingDirective: Optional[TaggingDirective] + ServerSideEncryption: Optional[ServerSideEncryption] + StorageClass: Optional[StorageClass] + WebsiteRedirectLocation: Optional[WebsiteRedirectLocation] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + CopySourceSSECustomerAlgorithm: Optional[CopySourceSSECustomerAlgorithm] + CopySourceSSECustomerKey: Optional[CopySourceSSECustomerKey] + CopySourceSSECustomerKeyMD5: Optional[CopySourceSSECustomerKeyMD5] + RequestPayer: Optional[RequestPayer] + Tagging: Optional[TaggingHeader] + ObjectLockMode: Optional[ObjectLockMode] + ObjectLockRetainUntilDate: Optional[ObjectLockRetainUntilDate] + ObjectLockLegalHoldStatus: Optional[ObjectLockLegalHoldStatus] + ExpectedBucketOwner: Optional[AccountId] + ExpectedSourceBucketOwner: Optional[AccountId] + + +class CopyPartResult(TypedDict, total=False): + ETag: Optional[ETag] + LastModified: Optional[LastModified] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + + +class LocationInfo(TypedDict, total=False): + Type: Optional[LocationType] + Name: Optional[LocationNameAsString] + + +class CreateBucketConfiguration(TypedDict, total=False): + LocationConstraint: Optional[BucketLocationConstraint] + Location: Optional[LocationInfo] + Bucket: Optional[BucketInfo] + Tags: Optional[TagSet] + + +class S3TablesDestination(TypedDict, total=False): + TableBucketArn: S3TablesBucketArn + TableName: S3TablesName + + +class MetadataTableConfiguration(TypedDict, total=False): + S3TablesDestination: S3TablesDestination + + +class CreateBucketMetadataTableConfigurationRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + MetadataTableConfiguration: MetadataTableConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class CreateBucketOutput(TypedDict, total=False): + Location: Optional[Location] + BucketArn: Optional[S3RegionalOrS3ExpressBucketArnString] + + +class CreateBucketRequest(ServiceRequest): + ACL: Optional[BucketCannedACL] + Bucket: BucketName + CreateBucketConfiguration: Optional[CreateBucketConfiguration] + GrantFullControl: Optional[GrantFullControl] + GrantRead: Optional[GrantRead] + GrantReadACP: Optional[GrantReadACP] + GrantWrite: Optional[GrantWrite] + GrantWriteACP: Optional[GrantWriteACP] + ObjectLockEnabledForBucket: Optional[ObjectLockEnabledForBucket] + ObjectOwnership: Optional[ObjectOwnership] + + +class CreateMultipartUploadOutput(TypedDict, total=False): + AbortDate: Optional[AbortDate] + AbortRuleId: Optional[AbortRuleId] + Bucket: Optional[BucketName] + Key: Optional[ObjectKey] + UploadId: Optional[MultipartUploadId] + ServerSideEncryption: Optional[ServerSideEncryption] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestCharged: Optional[RequestCharged] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ChecksumType: Optional[ChecksumType] + + +class CreateMultipartUploadRequest(ServiceRequest): + ACL: Optional[ObjectCannedACL] + Bucket: BucketName + CacheControl: Optional[CacheControl] + ContentDisposition: Optional[ContentDisposition] + ContentEncoding: Optional[ContentEncoding] + ContentLanguage: Optional[ContentLanguage] + ContentType: Optional[ContentType] + Expires: Optional[Expires] + GrantFullControl: Optional[GrantFullControl] + GrantRead: Optional[GrantRead] + GrantReadACP: Optional[GrantReadACP] + GrantWriteACP: Optional[GrantWriteACP] + Key: ObjectKey + Metadata: Optional[Metadata] + ServerSideEncryption: Optional[ServerSideEncryption] + StorageClass: Optional[StorageClass] + WebsiteRedirectLocation: Optional[WebsiteRedirectLocation] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestPayer: Optional[RequestPayer] + Tagging: Optional[TaggingHeader] + ObjectLockMode: Optional[ObjectLockMode] + ObjectLockRetainUntilDate: Optional[ObjectLockRetainUntilDate] + ObjectLockLegalHoldStatus: Optional[ObjectLockLegalHoldStatus] + ExpectedBucketOwner: Optional[AccountId] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ChecksumType: Optional[ChecksumType] + + +SessionExpiration = datetime + + +class SessionCredentials(TypedDict, total=False): + AccessKeyId: AccessKeyIdValue + SecretAccessKey: SessionCredentialValue + SessionToken: SessionCredentialValue + Expiration: SessionExpiration + + +class CreateSessionOutput(TypedDict, total=False): + ServerSideEncryption: Optional[ServerSideEncryption] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + Credentials: SessionCredentials + + +class CreateSessionRequest(ServiceRequest): + SessionMode: Optional[SessionMode] + Bucket: BucketName + ServerSideEncryption: Optional[ServerSideEncryption] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + + +class DefaultRetention(TypedDict, total=False): + Mode: Optional[ObjectLockRetentionMode] + Days: Optional[Days] + Years: Optional[Years] + + +Size = int +LastModifiedTime = datetime + + +class ObjectIdentifier(TypedDict, total=False): + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + ETag: Optional[ETag] + LastModifiedTime: Optional[LastModifiedTime] + Size: Optional[Size] + + +ObjectIdentifierList = List[ObjectIdentifier] + + +class Delete(TypedDict, total=False): + Objects: ObjectIdentifierList + Quiet: Optional[Quiet] + + +class DeleteBucketAnalyticsConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: AnalyticsId + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketCorsRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketEncryptionRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketIntelligentTieringConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: IntelligentTieringId + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketInventoryConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: InventoryId + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketLifecycleRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketMetadataTableConfigurationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketMetricsConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: MetricsId + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketOwnershipControlsRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketPolicyRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketReplicationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketTaggingRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteBucketWebsiteRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class DeleteMarkerEntry(TypedDict, total=False): + Owner: Optional[Owner] + Key: Optional[ObjectKey] + VersionId: Optional[ObjectVersionId] + IsLatest: Optional[IsLatest] + LastModified: Optional[LastModified] + + +class DeleteMarkerReplication(TypedDict, total=False): + Status: Optional[DeleteMarkerReplicationStatus] + + +DeleteMarkers = List[DeleteMarkerEntry] + + +class DeleteObjectOutput(TypedDict, total=False): + DeleteMarker: Optional[DeleteMarker] + VersionId: Optional[ObjectVersionId] + RequestCharged: Optional[RequestCharged] + + +IfMatchSize = int +IfMatchLastModifiedTime = datetime + + +class DeleteObjectRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + MFA: Optional[MFA] + VersionId: Optional[ObjectVersionId] + RequestPayer: Optional[RequestPayer] + BypassGovernanceRetention: Optional[BypassGovernanceRetention] + ExpectedBucketOwner: Optional[AccountId] + IfMatch: Optional[IfMatch] + IfMatchLastModifiedTime: Optional[IfMatchLastModifiedTime] + IfMatchSize: Optional[IfMatchSize] + + +class DeleteObjectTaggingOutput(TypedDict, total=False): + VersionId: Optional[ObjectVersionId] + + +class DeleteObjectTaggingRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + ExpectedBucketOwner: Optional[AccountId] + + +class Error(TypedDict, total=False): + Key: Optional[ObjectKey] + VersionId: Optional[ObjectVersionId] + Code: Optional[Code] + Message: Optional[Message] + + +Errors = List[Error] + + +class DeletedObject(TypedDict, total=False): + Key: Optional[ObjectKey] + VersionId: Optional[ObjectVersionId] + DeleteMarker: Optional[DeleteMarker] + DeleteMarkerVersionId: Optional[DeleteMarkerVersionId] + + +DeletedObjects = List[DeletedObject] + + +class DeleteObjectsOutput(TypedDict, total=False): + Deleted: Optional[DeletedObjects] + RequestCharged: Optional[RequestCharged] + Errors: Optional[Errors] + + +class DeleteObjectsRequest(ServiceRequest): + Bucket: BucketName + Delete: Delete + MFA: Optional[MFA] + RequestPayer: Optional[RequestPayer] + BypassGovernanceRetention: Optional[BypassGovernanceRetention] + ExpectedBucketOwner: Optional[AccountId] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + + +class DeletePublicAccessBlockRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class ReplicationTimeValue(TypedDict, total=False): + Minutes: Optional[Minutes] + + +class Metrics(TypedDict, total=False): + Status: MetricsStatus + EventThreshold: Optional[ReplicationTimeValue] + + +class ReplicationTime(TypedDict, total=False): + Status: ReplicationTimeStatus + Time: ReplicationTimeValue + + +class EncryptionConfiguration(TypedDict, total=False): + ReplicaKmsKeyID: Optional[ReplicaKmsKeyID] + + +class Destination(TypedDict, total=False): + Bucket: BucketName + Account: Optional[AccountId] + StorageClass: Optional[StorageClass] + AccessControlTranslation: Optional[AccessControlTranslation] + EncryptionConfiguration: Optional[EncryptionConfiguration] + ReplicationTime: Optional[ReplicationTime] + Metrics: Optional[Metrics] + + +class Encryption(TypedDict, total=False): + EncryptionType: ServerSideEncryption + KMSKeyId: Optional[SSEKMSKeyId] + KMSContext: Optional[KMSContext] + + +End = int + + +class EndEvent(TypedDict, total=False): + pass + + +class ErrorDetails(TypedDict, total=False): + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + + +class ErrorDocument(TypedDict, total=False): + Key: ObjectKey + + +class EventBridgeConfiguration(TypedDict, total=False): + pass + + +class ExistingObjectReplication(TypedDict, total=False): + Status: ExistingObjectReplicationStatus + + +class FilterRule(TypedDict, total=False): + Name: Optional[FilterRuleName] + Value: Optional[FilterRuleValue] + + +FilterRuleList = List[FilterRule] + + +class GetBucketAccelerateConfigurationOutput(TypedDict, total=False): + Status: Optional[BucketAccelerateStatus] + RequestCharged: Optional[RequestCharged] + + +class GetBucketAccelerateConfigurationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + RequestPayer: Optional[RequestPayer] + + +class GetBucketAclOutput(TypedDict, total=False): + Owner: Optional[Owner] + Grants: Optional[Grants] + + +class GetBucketAclRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketAnalyticsConfigurationOutput(TypedDict, total=False): + AnalyticsConfiguration: Optional[AnalyticsConfiguration] + + +class GetBucketAnalyticsConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: AnalyticsId + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketCorsOutput(TypedDict, total=False): + CORSRules: Optional[CORSRules] + + +class GetBucketCorsRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class ServerSideEncryptionByDefault(TypedDict, total=False): + SSEAlgorithm: ServerSideEncryption + KMSMasterKeyID: Optional[SSEKMSKeyId] + + +class ServerSideEncryptionRule(TypedDict, total=False): + ApplyServerSideEncryptionByDefault: Optional[ServerSideEncryptionByDefault] + BucketKeyEnabled: Optional[BucketKeyEnabled] + + +ServerSideEncryptionRules = List[ServerSideEncryptionRule] + + +class ServerSideEncryptionConfiguration(TypedDict, total=False): + Rules: ServerSideEncryptionRules + + +class GetBucketEncryptionOutput(TypedDict, total=False): + ServerSideEncryptionConfiguration: Optional[ServerSideEncryptionConfiguration] + + +class GetBucketEncryptionRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class Tiering(TypedDict, total=False): + Days: IntelligentTieringDays + AccessTier: IntelligentTieringAccessTier + + +TieringList = List[Tiering] + + +class IntelligentTieringAndOperator(TypedDict, total=False): + Prefix: Optional[Prefix] + Tags: Optional[TagSet] + + +class IntelligentTieringFilter(TypedDict, total=False): + Prefix: Optional[Prefix] + Tag: Optional[Tag] + And: Optional[IntelligentTieringAndOperator] + + +class IntelligentTieringConfiguration(TypedDict, total=False): + Id: IntelligentTieringId + Filter: Optional[IntelligentTieringFilter] + Status: IntelligentTieringStatus + Tierings: TieringList + + +class GetBucketIntelligentTieringConfigurationOutput(TypedDict, total=False): + IntelligentTieringConfiguration: Optional[IntelligentTieringConfiguration] + + +class GetBucketIntelligentTieringConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: IntelligentTieringId + ExpectedBucketOwner: Optional[AccountId] + + +class InventorySchedule(TypedDict, total=False): + Frequency: InventoryFrequency + + +InventoryOptionalFields = List[InventoryOptionalField] + + +class InventoryFilter(TypedDict, total=False): + Prefix: Prefix + + +class SSEKMS(TypedDict, total=False): + KeyId: SSEKMSKeyId + + +class SSES3(TypedDict, total=False): + pass + + +class InventoryEncryption(TypedDict, total=False): + SSES3: Optional[SSES3] + SSEKMS: Optional[SSEKMS] + + +class InventoryS3BucketDestination(TypedDict, total=False): + AccountId: Optional[AccountId] + Bucket: BucketName + Format: InventoryFormat + Prefix: Optional[Prefix] + Encryption: Optional[InventoryEncryption] + + +class InventoryDestination(TypedDict, total=False): + S3BucketDestination: InventoryS3BucketDestination + + +class InventoryConfiguration(TypedDict, total=False): + Destination: InventoryDestination + IsEnabled: IsEnabled + Filter: Optional[InventoryFilter] + Id: InventoryId + IncludedObjectVersions: InventoryIncludedObjectVersions + OptionalFields: Optional[InventoryOptionalFields] + Schedule: InventorySchedule + + +class GetBucketInventoryConfigurationOutput(TypedDict, total=False): + InventoryConfiguration: Optional[InventoryConfiguration] + + +class GetBucketInventoryConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: InventoryId + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketLifecycleConfigurationOutput(TypedDict, total=False): + Rules: Optional[LifecycleRules] + TransitionDefaultMinimumObjectSize: Optional[TransitionDefaultMinimumObjectSize] + + +class GetBucketLifecycleConfigurationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class Rule(TypedDict, total=False): + Expiration: Optional[LifecycleExpiration] + ID: Optional[ID] + Prefix: Prefix + Status: ExpirationStatus + Transition: Optional[Transition] + NoncurrentVersionTransition: Optional[NoncurrentVersionTransition] + NoncurrentVersionExpiration: Optional[NoncurrentVersionExpiration] + AbortIncompleteMultipartUpload: Optional[AbortIncompleteMultipartUpload] + + +Rules = List[Rule] + + +class GetBucketLifecycleOutput(TypedDict, total=False): + Rules: Optional[Rules] + + +class GetBucketLifecycleRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketLocationOutput(TypedDict, total=False): + LocationConstraint: Optional[BucketLocationConstraint] + + +class GetBucketLocationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketLoggingOutput(TypedDict, total=False): + LoggingEnabled: Optional[LoggingEnabled] + + +class GetBucketLoggingRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class S3TablesDestinationResult(TypedDict, total=False): + TableBucketArn: S3TablesBucketArn + TableName: S3TablesName + TableArn: S3TablesArn + TableNamespace: S3TablesNamespace + + +class MetadataTableConfigurationResult(TypedDict, total=False): + S3TablesDestinationResult: S3TablesDestinationResult + + +class GetBucketMetadataTableConfigurationResult(TypedDict, total=False): + MetadataTableConfigurationResult: MetadataTableConfigurationResult + Status: MetadataTableStatus + Error: Optional[ErrorDetails] + + +class GetBucketMetadataTableConfigurationOutput(TypedDict, total=False): + GetBucketMetadataTableConfigurationResult: Optional[GetBucketMetadataTableConfigurationResult] + + +class GetBucketMetadataTableConfigurationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class MetricsAndOperator(TypedDict, total=False): + Prefix: Optional[Prefix] + Tags: Optional[TagSet] + AccessPointArn: Optional[AccessPointArn] + + +class MetricsFilter(TypedDict, total=False): + Prefix: Optional[Prefix] + Tag: Optional[Tag] + AccessPointArn: Optional[AccessPointArn] + And: Optional[MetricsAndOperator] + + +class MetricsConfiguration(TypedDict, total=False): + Id: MetricsId + Filter: Optional[MetricsFilter] + + +class GetBucketMetricsConfigurationOutput(TypedDict, total=False): + MetricsConfiguration: Optional[MetricsConfiguration] + + +class GetBucketMetricsConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: MetricsId + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketNotificationConfigurationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class OwnershipControlsRule(TypedDict, total=False): + ObjectOwnership: ObjectOwnership + + +OwnershipControlsRules = List[OwnershipControlsRule] + + +class OwnershipControls(TypedDict, total=False): + Rules: OwnershipControlsRules + + +class GetBucketOwnershipControlsOutput(TypedDict, total=False): + OwnershipControls: Optional[OwnershipControls] + + +class GetBucketOwnershipControlsRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketPolicyOutput(TypedDict, total=False): + Policy: Optional[Policy] + + +class GetBucketPolicyRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class PolicyStatus(TypedDict, total=False): + IsPublic: Optional[IsPublic] + + +class GetBucketPolicyStatusOutput(TypedDict, total=False): + PolicyStatus: Optional[PolicyStatus] + + +class GetBucketPolicyStatusRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class ReplicaModifications(TypedDict, total=False): + Status: ReplicaModificationsStatus + + +class SseKmsEncryptedObjects(TypedDict, total=False): + Status: SseKmsEncryptedObjectsStatus + + +class SourceSelectionCriteria(TypedDict, total=False): + SseKmsEncryptedObjects: Optional[SseKmsEncryptedObjects] + ReplicaModifications: Optional[ReplicaModifications] + + +class ReplicationRuleAndOperator(TypedDict, total=False): + Prefix: Optional[Prefix] + Tags: Optional[TagSet] + + +class ReplicationRuleFilter(TypedDict, total=False): + Prefix: Optional[Prefix] + Tag: Optional[Tag] + And: Optional[ReplicationRuleAndOperator] + + +class ReplicationRule(TypedDict, total=False): + ID: Optional[ID] + Priority: Optional[Priority] + Prefix: Optional[Prefix] + Filter: Optional[ReplicationRuleFilter] + Status: ReplicationRuleStatus + SourceSelectionCriteria: Optional[SourceSelectionCriteria] + ExistingObjectReplication: Optional[ExistingObjectReplication] + Destination: Destination + DeleteMarkerReplication: Optional[DeleteMarkerReplication] + + +ReplicationRules = List[ReplicationRule] + + +class ReplicationConfiguration(TypedDict, total=False): + Role: Role + Rules: ReplicationRules + + +class GetBucketReplicationOutput(TypedDict, total=False): + ReplicationConfiguration: Optional[ReplicationConfiguration] + + +class GetBucketReplicationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketRequestPaymentOutput(TypedDict, total=False): + Payer: Optional[Payer] + + +class GetBucketRequestPaymentRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketTaggingOutput(TypedDict, total=False): + TagSet: TagSet + + +class GetBucketTaggingRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetBucketVersioningOutput(TypedDict, total=False): + Status: Optional[BucketVersioningStatus] + MFADelete: Optional[MFADeleteStatus] + + +class GetBucketVersioningRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class Redirect(TypedDict, total=False): + HostName: Optional[HostName] + HttpRedirectCode: Optional[HttpRedirectCode] + Protocol: Optional[Protocol] + ReplaceKeyPrefixWith: Optional[ReplaceKeyPrefixWith] + ReplaceKeyWith: Optional[ReplaceKeyWith] + + +class RoutingRule(TypedDict, total=False): + Condition: Optional[Condition] + Redirect: Redirect + + +RoutingRules = List[RoutingRule] + + +class IndexDocument(TypedDict, total=False): + Suffix: Suffix + + +class RedirectAllRequestsTo(TypedDict, total=False): + HostName: HostName + Protocol: Optional[Protocol] + + +class GetBucketWebsiteOutput(TypedDict, total=False): + RedirectAllRequestsTo: Optional[RedirectAllRequestsTo] + IndexDocument: Optional[IndexDocument] + ErrorDocument: Optional[ErrorDocument] + RoutingRules: Optional[RoutingRules] + + +class GetBucketWebsiteRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetObjectAclOutput(TypedDict, total=False): + Owner: Optional[Owner] + Grants: Optional[Grants] + RequestCharged: Optional[RequestCharged] + + +class GetObjectAclRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + + +class ObjectPart(TypedDict, total=False): + PartNumber: Optional[PartNumber] + Size: Optional[Size] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + + +PartsList = List[ObjectPart] + + +class GetObjectAttributesParts(TypedDict, total=False): + TotalPartsCount: Optional[PartsCount] + PartNumberMarker: Optional[PartNumberMarker] + NextPartNumberMarker: Optional[NextPartNumberMarker] + MaxParts: Optional[MaxParts] + IsTruncated: Optional[IsTruncated] + Parts: Optional[PartsList] + + +class GetObjectAttributesOutput(TypedDict, total=False): + DeleteMarker: Optional[DeleteMarker] + LastModified: Optional[LastModified] + VersionId: Optional[ObjectVersionId] + RequestCharged: Optional[RequestCharged] + ETag: Optional[ETag] + Checksum: Optional[Checksum] + ObjectParts: Optional[GetObjectAttributesParts] + StorageClass: Optional[StorageClass] + ObjectSize: Optional[ObjectSize] + + +ObjectAttributesList = List[ObjectAttributes] + + +class GetObjectAttributesRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + MaxParts: Optional[MaxParts] + PartNumberMarker: Optional[PartNumberMarker] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + ObjectAttributes: ObjectAttributesList + + +class ObjectLockLegalHold(TypedDict, total=False): + Status: Optional[ObjectLockLegalHoldStatus] + + +class GetObjectLegalHoldOutput(TypedDict, total=False): + LegalHold: Optional[ObjectLockLegalHold] + + +class GetObjectLegalHoldRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + + +class ObjectLockRule(TypedDict, total=False): + DefaultRetention: Optional[DefaultRetention] + + +class ObjectLockConfiguration(TypedDict, total=False): + ObjectLockEnabled: Optional[ObjectLockEnabled] + Rule: Optional[ObjectLockRule] + + +class GetObjectLockConfigurationOutput(TypedDict, total=False): + ObjectLockConfiguration: Optional[ObjectLockConfiguration] + + +class GetObjectLockConfigurationRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GetObjectOutput(TypedDict, total=False): + Body: Optional[Union[Body, IO[Body], Iterable[Body]]] + DeleteMarker: Optional[DeleteMarker] + AcceptRanges: Optional[AcceptRanges] + Expiration: Optional[Expiration] + Restore: Optional[Restore] + LastModified: Optional[LastModified] + ContentLength: Optional[ContentLength] + ETag: Optional[ETag] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + ChecksumType: Optional[ChecksumType] + MissingMeta: Optional[MissingMeta] + VersionId: Optional[ObjectVersionId] + CacheControl: Optional[CacheControl] + ContentDisposition: Optional[ContentDisposition] + ContentEncoding: Optional[ContentEncoding] + ContentLanguage: Optional[ContentLanguage] + ContentRange: Optional[ContentRange] + ContentType: Optional[ContentType] + Expires: Optional[Expires] + WebsiteRedirectLocation: Optional[WebsiteRedirectLocation] + ServerSideEncryption: Optional[ServerSideEncryption] + Metadata: Optional[Metadata] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + BucketKeyEnabled: Optional[BucketKeyEnabled] + StorageClass: Optional[StorageClass] + RequestCharged: Optional[RequestCharged] + ReplicationStatus: Optional[ReplicationStatus] + PartsCount: Optional[PartsCount] + TagCount: Optional[TagCount] + ObjectLockMode: Optional[ObjectLockMode] + ObjectLockRetainUntilDate: Optional[ObjectLockRetainUntilDate] + ObjectLockLegalHoldStatus: Optional[ObjectLockLegalHoldStatus] + StatusCode: Optional[GetObjectResponseStatusCode] + + +ResponseExpires = datetime +IfUnmodifiedSince = datetime +IfModifiedSince = datetime + + +class GetObjectRequest(ServiceRequest): + Bucket: BucketName + IfMatch: Optional[IfMatch] + IfModifiedSince: Optional[IfModifiedSince] + IfNoneMatch: Optional[IfNoneMatch] + IfUnmodifiedSince: Optional[IfUnmodifiedSince] + Key: ObjectKey + Range: Optional[Range] + ResponseCacheControl: Optional[ResponseCacheControl] + ResponseContentDisposition: Optional[ResponseContentDisposition] + ResponseContentEncoding: Optional[ResponseContentEncoding] + ResponseContentLanguage: Optional[ResponseContentLanguage] + ResponseContentType: Optional[ResponseContentType] + ResponseExpires: Optional[ResponseExpires] + VersionId: Optional[ObjectVersionId] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + RequestPayer: Optional[RequestPayer] + PartNumber: Optional[PartNumber] + ExpectedBucketOwner: Optional[AccountId] + ChecksumMode: Optional[ChecksumMode] + + +class ObjectLockRetention(TypedDict, total=False): + Mode: Optional[ObjectLockRetentionMode] + RetainUntilDate: Optional[Date] + + +class GetObjectRetentionOutput(TypedDict, total=False): + Retention: Optional[ObjectLockRetention] + + +class GetObjectRetentionRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + + +class GetObjectTaggingOutput(TypedDict, total=False): + VersionId: Optional[ObjectVersionId] + TagSet: TagSet + + +class GetObjectTaggingRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + ExpectedBucketOwner: Optional[AccountId] + RequestPayer: Optional[RequestPayer] + + +class GetObjectTorrentOutput(TypedDict, total=False): + Body: Optional[Union[Body, IO[Body], Iterable[Body]]] + RequestCharged: Optional[RequestCharged] + + +class GetObjectTorrentRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + + +class PublicAccessBlockConfiguration(TypedDict, total=False): + BlockPublicAcls: Optional[Setting] + IgnorePublicAcls: Optional[Setting] + BlockPublicPolicy: Optional[Setting] + RestrictPublicBuckets: Optional[Setting] + + +class GetPublicAccessBlockOutput(TypedDict, total=False): + PublicAccessBlockConfiguration: Optional[PublicAccessBlockConfiguration] + + +class GetPublicAccessBlockRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class GlacierJobParameters(TypedDict, total=False): + Tier: Tier + + +class HeadBucketOutput(TypedDict, total=False): + BucketRegion: Optional[BucketRegion] + BucketContentType: Optional[BucketContentType] + + +class HeadBucketRequest(ServiceRequest): + Bucket: BucketName + ExpectedBucketOwner: Optional[AccountId] + + +class HeadObjectOutput(TypedDict, total=False): + DeleteMarker: Optional[DeleteMarker] + AcceptRanges: Optional[AcceptRanges] + Expiration: Optional[Expiration] + Restore: Optional[Restore] + ArchiveStatus: Optional[ArchiveStatus] + LastModified: Optional[LastModified] + ContentLength: Optional[ContentLength] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + ChecksumType: Optional[ChecksumType] + ETag: Optional[ETag] + MissingMeta: Optional[MissingMeta] + VersionId: Optional[ObjectVersionId] + CacheControl: Optional[CacheControl] + ContentDisposition: Optional[ContentDisposition] + ContentEncoding: Optional[ContentEncoding] + ContentLanguage: Optional[ContentLanguage] + ContentType: Optional[ContentType] + ContentRange: Optional[ContentRange] + Expires: Optional[Expires] + WebsiteRedirectLocation: Optional[WebsiteRedirectLocation] + ServerSideEncryption: Optional[ServerSideEncryption] + Metadata: Optional[Metadata] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + BucketKeyEnabled: Optional[BucketKeyEnabled] + StorageClass: Optional[StorageClass] + RequestCharged: Optional[RequestCharged] + ReplicationStatus: Optional[ReplicationStatus] + PartsCount: Optional[PartsCount] + TagCount: Optional[TagCount] + ObjectLockMode: Optional[ObjectLockMode] + ObjectLockRetainUntilDate: Optional[ObjectLockRetainUntilDate] + ObjectLockLegalHoldStatus: Optional[ObjectLockLegalHoldStatus] + StatusCode: Optional[GetObjectResponseStatusCode] + + +class HeadObjectRequest(ServiceRequest): + Bucket: BucketName + IfMatch: Optional[IfMatch] + IfModifiedSince: Optional[IfModifiedSince] + IfNoneMatch: Optional[IfNoneMatch] + IfUnmodifiedSince: Optional[IfUnmodifiedSince] + Key: ObjectKey + Range: Optional[Range] + ResponseCacheControl: Optional[ResponseCacheControl] + ResponseContentDisposition: Optional[ResponseContentDisposition] + ResponseContentEncoding: Optional[ResponseContentEncoding] + ResponseContentLanguage: Optional[ResponseContentLanguage] + ResponseContentType: Optional[ResponseContentType] + ResponseExpires: Optional[ResponseExpires] + VersionId: Optional[ObjectVersionId] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + RequestPayer: Optional[RequestPayer] + PartNumber: Optional[PartNumber] + ExpectedBucketOwner: Optional[AccountId] + ChecksumMode: Optional[ChecksumMode] + + +Initiated = datetime + + +class Initiator(TypedDict, total=False): + ID: Optional[ID] + DisplayName: Optional[DisplayName] + + +class ParquetInput(TypedDict, total=False): + pass + + +class JSONInput(TypedDict, total=False): + Type: Optional[JSONType] + + +class InputSerialization(TypedDict, total=False): + CSV: Optional[CSVInput] + CompressionType: Optional[CompressionType] + JSON: Optional[JSONInput] + Parquet: Optional[ParquetInput] + + +IntelligentTieringConfigurationList = List[IntelligentTieringConfiguration] +InventoryConfigurationList = List[InventoryConfiguration] + + +class JSONOutput(TypedDict, total=False): + RecordDelimiter: Optional[RecordDelimiter] + + +class S3KeyFilter(TypedDict, total=False): + FilterRules: Optional[FilterRuleList] + + +class NotificationConfigurationFilter(TypedDict, total=False): + Key: Optional[S3KeyFilter] + + +class LambdaFunctionConfiguration(TypedDict, total=False): + Id: Optional[NotificationId] + LambdaFunctionArn: LambdaFunctionArn + Events: EventList + Filter: Optional[NotificationConfigurationFilter] + + +LambdaFunctionConfigurationList = List[LambdaFunctionConfiguration] + + +class LifecycleConfiguration(TypedDict, total=False): + Rules: Rules + + +class ListBucketAnalyticsConfigurationsOutput(TypedDict, total=False): + IsTruncated: Optional[IsTruncated] + ContinuationToken: Optional[Token] + NextContinuationToken: Optional[NextToken] + AnalyticsConfigurationList: Optional[AnalyticsConfigurationList] + + +class ListBucketAnalyticsConfigurationsRequest(ServiceRequest): + Bucket: BucketName + ContinuationToken: Optional[Token] + ExpectedBucketOwner: Optional[AccountId] + + +class ListBucketIntelligentTieringConfigurationsOutput(TypedDict, total=False): + IsTruncated: Optional[IsTruncated] + ContinuationToken: Optional[Token] + NextContinuationToken: Optional[NextToken] + IntelligentTieringConfigurationList: Optional[IntelligentTieringConfigurationList] + + +class ListBucketIntelligentTieringConfigurationsRequest(ServiceRequest): + Bucket: BucketName + ContinuationToken: Optional[Token] + ExpectedBucketOwner: Optional[AccountId] + + +class ListBucketInventoryConfigurationsOutput(TypedDict, total=False): + ContinuationToken: Optional[Token] + InventoryConfigurationList: Optional[InventoryConfigurationList] + IsTruncated: Optional[IsTruncated] + NextContinuationToken: Optional[NextToken] + + +class ListBucketInventoryConfigurationsRequest(ServiceRequest): + Bucket: BucketName + ContinuationToken: Optional[Token] + ExpectedBucketOwner: Optional[AccountId] + + +MetricsConfigurationList = List[MetricsConfiguration] + + +class ListBucketMetricsConfigurationsOutput(TypedDict, total=False): + IsTruncated: Optional[IsTruncated] + ContinuationToken: Optional[Token] + NextContinuationToken: Optional[NextToken] + MetricsConfigurationList: Optional[MetricsConfigurationList] + + +class ListBucketMetricsConfigurationsRequest(ServiceRequest): + Bucket: BucketName + ContinuationToken: Optional[Token] + ExpectedBucketOwner: Optional[AccountId] + + +class ListBucketsOutput(TypedDict, total=False): + Owner: Optional[Owner] + ContinuationToken: Optional[NextToken] + Prefix: Optional[Prefix] + Buckets: Optional[Buckets] + + +class ListBucketsRequest(ServiceRequest): + MaxBuckets: Optional[MaxBuckets] + ContinuationToken: Optional[Token] + Prefix: Optional[Prefix] + BucketRegion: Optional[BucketRegion] + + +class ListDirectoryBucketsOutput(TypedDict, total=False): + Buckets: Optional[Buckets] + ContinuationToken: Optional[DirectoryBucketToken] + + +class ListDirectoryBucketsRequest(ServiceRequest): + ContinuationToken: Optional[DirectoryBucketToken] + MaxDirectoryBuckets: Optional[MaxDirectoryBuckets] + + +class MultipartUpload(TypedDict, total=False): + UploadId: Optional[MultipartUploadId] + Key: Optional[ObjectKey] + Initiated: Optional[Initiated] + StorageClass: Optional[StorageClass] + Owner: Optional[Owner] + Initiator: Optional[Initiator] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ChecksumType: Optional[ChecksumType] + + +MultipartUploadList = List[MultipartUpload] + + +class ListMultipartUploadsOutput(TypedDict, total=False): + Bucket: Optional[BucketName] + KeyMarker: Optional[KeyMarker] + UploadIdMarker: Optional[UploadIdMarker] + NextKeyMarker: Optional[NextKeyMarker] + Prefix: Optional[Prefix] + Delimiter: Optional[Delimiter] + NextUploadIdMarker: Optional[NextUploadIdMarker] + MaxUploads: Optional[MaxUploads] + IsTruncated: Optional[IsTruncated] + Uploads: Optional[MultipartUploadList] + CommonPrefixes: Optional[CommonPrefixList] + EncodingType: Optional[EncodingType] + RequestCharged: Optional[RequestCharged] + + +class ListMultipartUploadsRequest(ServiceRequest): + Bucket: BucketName + Delimiter: Optional[Delimiter] + EncodingType: Optional[EncodingType] + KeyMarker: Optional[KeyMarker] + MaxUploads: Optional[MaxUploads] + Prefix: Optional[Prefix] + UploadIdMarker: Optional[UploadIdMarker] + ExpectedBucketOwner: Optional[AccountId] + RequestPayer: Optional[RequestPayer] + + +RestoreExpiryDate = datetime + + +class RestoreStatus(TypedDict, total=False): + IsRestoreInProgress: Optional[IsRestoreInProgress] + RestoreExpiryDate: Optional[RestoreExpiryDate] + + +class ObjectVersion(TypedDict, total=False): + ETag: Optional[ETag] + ChecksumAlgorithm: Optional[ChecksumAlgorithmList] + ChecksumType: Optional[ChecksumType] + Size: Optional[Size] + StorageClass: Optional[ObjectVersionStorageClass] + Key: Optional[ObjectKey] + VersionId: Optional[ObjectVersionId] + IsLatest: Optional[IsLatest] + LastModified: Optional[LastModified] + Owner: Optional[Owner] + RestoreStatus: Optional[RestoreStatus] + + +ObjectVersionList = List[ObjectVersion] + + +class ListObjectVersionsOutput(TypedDict, total=False): + IsTruncated: Optional[IsTruncated] + KeyMarker: Optional[KeyMarker] + VersionIdMarker: Optional[VersionIdMarker] + NextKeyMarker: Optional[NextKeyMarker] + NextVersionIdMarker: Optional[NextVersionIdMarker] + DeleteMarkers: Optional[DeleteMarkers] + Name: Optional[BucketName] + Prefix: Optional[Prefix] + Delimiter: Optional[Delimiter] + MaxKeys: Optional[MaxKeys] + CommonPrefixes: Optional[CommonPrefixList] + EncodingType: Optional[EncodingType] + RequestCharged: Optional[RequestCharged] + Versions: Optional[ObjectVersionList] + + +OptionalObjectAttributesList = List[OptionalObjectAttributes] + + +class ListObjectVersionsRequest(ServiceRequest): + Bucket: BucketName + Delimiter: Optional[Delimiter] + EncodingType: Optional[EncodingType] + KeyMarker: Optional[KeyMarker] + MaxKeys: Optional[MaxKeys] + Prefix: Optional[Prefix] + VersionIdMarker: Optional[VersionIdMarker] + ExpectedBucketOwner: Optional[AccountId] + RequestPayer: Optional[RequestPayer] + OptionalObjectAttributes: Optional[OptionalObjectAttributesList] + + +class Object(TypedDict, total=False): + Key: Optional[ObjectKey] + LastModified: Optional[LastModified] + ETag: Optional[ETag] + ChecksumAlgorithm: Optional[ChecksumAlgorithmList] + ChecksumType: Optional[ChecksumType] + Size: Optional[Size] + StorageClass: Optional[ObjectStorageClass] + Owner: Optional[Owner] + RestoreStatus: Optional[RestoreStatus] + + +ObjectList = List[Object] + + +class ListObjectsOutput(TypedDict, total=False): + IsTruncated: Optional[IsTruncated] + Marker: Optional[Marker] + NextMarker: Optional[NextMarker] + Name: Optional[BucketName] + Prefix: Optional[Prefix] + Delimiter: Optional[Delimiter] + MaxKeys: Optional[MaxKeys] + CommonPrefixes: Optional[CommonPrefixList] + EncodingType: Optional[EncodingType] + RequestCharged: Optional[RequestCharged] + BucketRegion: Optional[BucketRegion] + Contents: Optional[ObjectList] + + +class ListObjectsRequest(ServiceRequest): + Bucket: BucketName + Delimiter: Optional[Delimiter] + EncodingType: Optional[EncodingType] + Marker: Optional[Marker] + MaxKeys: Optional[MaxKeys] + Prefix: Optional[Prefix] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + OptionalObjectAttributes: Optional[OptionalObjectAttributesList] + + +class ListObjectsV2Output(TypedDict, total=False): + IsTruncated: Optional[IsTruncated] + Name: Optional[BucketName] + Prefix: Optional[Prefix] + Delimiter: Optional[Delimiter] + MaxKeys: Optional[MaxKeys] + CommonPrefixes: Optional[CommonPrefixList] + EncodingType: Optional[EncodingType] + KeyCount: Optional[KeyCount] + ContinuationToken: Optional[Token] + NextContinuationToken: Optional[NextToken] + StartAfter: Optional[StartAfter] + RequestCharged: Optional[RequestCharged] + BucketRegion: Optional[BucketRegion] + Contents: Optional[ObjectList] + + +class ListObjectsV2Request(ServiceRequest): + Bucket: BucketName + Delimiter: Optional[Delimiter] + EncodingType: Optional[EncodingType] + MaxKeys: Optional[MaxKeys] + Prefix: Optional[Prefix] + ContinuationToken: Optional[Token] + FetchOwner: Optional[FetchOwner] + StartAfter: Optional[StartAfter] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + OptionalObjectAttributes: Optional[OptionalObjectAttributesList] + + +class Part(TypedDict, total=False): + PartNumber: Optional[PartNumber] + LastModified: Optional[LastModified] + ETag: Optional[ETag] + Size: Optional[Size] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + + +Parts = List[Part] + + +class ListPartsOutput(TypedDict, total=False): + AbortDate: Optional[AbortDate] + AbortRuleId: Optional[AbortRuleId] + Bucket: Optional[BucketName] + Key: Optional[ObjectKey] + UploadId: Optional[MultipartUploadId] + PartNumberMarker: Optional[PartNumberMarker] + NextPartNumberMarker: Optional[NextPartNumberMarker] + MaxParts: Optional[MaxParts] + IsTruncated: Optional[IsTruncated] + Parts: Optional[Parts] + Initiator: Optional[Initiator] + Owner: Optional[Owner] + StorageClass: Optional[StorageClass] + RequestCharged: Optional[RequestCharged] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ChecksumType: Optional[ChecksumType] + + +class ListPartsRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + MaxParts: Optional[MaxParts] + PartNumberMarker: Optional[PartNumberMarker] + UploadId: MultipartUploadId + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + + +class MetadataEntry(TypedDict, total=False): + Name: Optional[MetadataKey] + Value: Optional[MetadataValue] + + +class QueueConfiguration(TypedDict, total=False): + Id: Optional[NotificationId] + QueueArn: QueueArn + Events: EventList + Filter: Optional[NotificationConfigurationFilter] + + +QueueConfigurationList = List[QueueConfiguration] + + +class TopicConfiguration(TypedDict, total=False): + Id: Optional[NotificationId] + TopicArn: TopicArn + Events: EventList + Filter: Optional[NotificationConfigurationFilter] + + +TopicConfigurationList = List[TopicConfiguration] + + +class NotificationConfiguration(TypedDict, total=False): + TopicConfigurations: Optional[TopicConfigurationList] + QueueConfigurations: Optional[QueueConfigurationList] + LambdaFunctionConfigurations: Optional[LambdaFunctionConfigurationList] + EventBridgeConfiguration: Optional[EventBridgeConfiguration] + + +class QueueConfigurationDeprecated(TypedDict, total=False): + Id: Optional[NotificationId] + Event: Optional[Event] + Events: Optional[EventList] + Queue: Optional[QueueArn] + + +class TopicConfigurationDeprecated(TypedDict, total=False): + Id: Optional[NotificationId] + Events: Optional[EventList] + Event: Optional[Event] + Topic: Optional[TopicArn] + + +class NotificationConfigurationDeprecated(TypedDict, total=False): + TopicConfiguration: Optional[TopicConfigurationDeprecated] + QueueConfiguration: Optional[QueueConfigurationDeprecated] + CloudFunctionConfiguration: Optional[CloudFunctionConfiguration] + + +UserMetadata = List[MetadataEntry] + + +class Tagging(TypedDict, total=False): + TagSet: TagSet + + +class S3Location(TypedDict, total=False): + BucketName: BucketName + Prefix: LocationPrefix + Encryption: Optional[Encryption] + CannedACL: Optional[ObjectCannedACL] + AccessControlList: Optional[Grants] + Tagging: Optional[Tagging] + UserMetadata: Optional[UserMetadata] + StorageClass: Optional[StorageClass] + + +class OutputLocation(TypedDict, total=False): + S3: Optional[S3Location] + + +class OutputSerialization(TypedDict, total=False): + CSV: Optional[CSVOutput] + JSON: Optional[JSONOutput] + + +class Progress(TypedDict, total=False): + BytesScanned: Optional[BytesScanned] + BytesProcessed: Optional[BytesProcessed] + BytesReturned: Optional[BytesReturned] + + +class ProgressEvent(TypedDict, total=False): + Details: Optional[Progress] + + +class PutBucketAccelerateConfigurationRequest(ServiceRequest): + Bucket: BucketName + AccelerateConfiguration: AccelerateConfiguration + ExpectedBucketOwner: Optional[AccountId] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + + +class PutBucketAclRequest(ServiceRequest): + ACL: Optional[BucketCannedACL] + AccessControlPolicy: Optional[AccessControlPolicy] + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + GrantFullControl: Optional[GrantFullControl] + GrantRead: Optional[GrantRead] + GrantReadACP: Optional[GrantReadACP] + GrantWrite: Optional[GrantWrite] + GrantWriteACP: Optional[GrantWriteACP] + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketAnalyticsConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: AnalyticsId + AnalyticsConfiguration: AnalyticsConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketCorsRequest(ServiceRequest): + Bucket: BucketName + CORSConfiguration: CORSConfiguration + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketEncryptionRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ServerSideEncryptionConfiguration: ServerSideEncryptionConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketIntelligentTieringConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: IntelligentTieringId + ExpectedBucketOwner: Optional[AccountId] + IntelligentTieringConfiguration: IntelligentTieringConfiguration + + +class PutBucketInventoryConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: InventoryId + InventoryConfiguration: InventoryConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketLifecycleConfigurationOutput(TypedDict, total=False): + TransitionDefaultMinimumObjectSize: Optional[TransitionDefaultMinimumObjectSize] + + +class PutBucketLifecycleConfigurationRequest(ServiceRequest): + Bucket: BucketName + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + LifecycleConfiguration: Optional[BucketLifecycleConfiguration] + ExpectedBucketOwner: Optional[AccountId] + TransitionDefaultMinimumObjectSize: Optional[TransitionDefaultMinimumObjectSize] + + +class PutBucketLifecycleRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + LifecycleConfiguration: Optional[LifecycleConfiguration] + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketLoggingRequest(ServiceRequest): + Bucket: BucketName + BucketLoggingStatus: BucketLoggingStatus + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketMetricsConfigurationRequest(ServiceRequest): + Bucket: BucketName + Id: MetricsId + MetricsConfiguration: MetricsConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketNotificationConfigurationRequest(ServiceRequest): + Bucket: BucketName + NotificationConfiguration: NotificationConfiguration + ExpectedBucketOwner: Optional[AccountId] + SkipDestinationValidation: Optional[SkipValidation] + + +class PutBucketNotificationRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + NotificationConfiguration: NotificationConfigurationDeprecated + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketOwnershipControlsRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ExpectedBucketOwner: Optional[AccountId] + OwnershipControls: OwnershipControls + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + + +class PutBucketPolicyRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ConfirmRemoveSelfBucketAccess: Optional[ConfirmRemoveSelfBucketAccess] + Policy: Policy + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketReplicationRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ReplicationConfiguration: ReplicationConfiguration + Token: Optional[ObjectLockToken] + ExpectedBucketOwner: Optional[AccountId] + + +class RequestPaymentConfiguration(TypedDict, total=False): + Payer: Payer + + +class PutBucketRequestPaymentRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + RequestPaymentConfiguration: RequestPaymentConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class PutBucketTaggingRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + Tagging: Tagging + ExpectedBucketOwner: Optional[AccountId] + + +class VersioningConfiguration(TypedDict, total=False): + MFADelete: Optional[MFADelete] + Status: Optional[BucketVersioningStatus] + + +class PutBucketVersioningRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + MFA: Optional[MFA] + VersioningConfiguration: VersioningConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class WebsiteConfiguration(TypedDict, total=False): + ErrorDocument: Optional[ErrorDocument] + IndexDocument: Optional[IndexDocument] + RedirectAllRequestsTo: Optional[RedirectAllRequestsTo] + RoutingRules: Optional[RoutingRules] + + +class PutBucketWebsiteRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + WebsiteConfiguration: WebsiteConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class PutObjectAclOutput(TypedDict, total=False): + RequestCharged: Optional[RequestCharged] + + +class PutObjectAclRequest(ServiceRequest): + ACL: Optional[ObjectCannedACL] + AccessControlPolicy: Optional[AccessControlPolicy] + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + GrantFullControl: Optional[GrantFullControl] + GrantRead: Optional[GrantRead] + GrantReadACP: Optional[GrantReadACP] + GrantWrite: Optional[GrantWrite] + GrantWriteACP: Optional[GrantWriteACP] + Key: ObjectKey + RequestPayer: Optional[RequestPayer] + VersionId: Optional[ObjectVersionId] + ExpectedBucketOwner: Optional[AccountId] + + +class PutObjectLegalHoldOutput(TypedDict, total=False): + RequestCharged: Optional[RequestCharged] + + +class PutObjectLegalHoldRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + LegalHold: Optional[ObjectLockLegalHold] + RequestPayer: Optional[RequestPayer] + VersionId: Optional[ObjectVersionId] + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ExpectedBucketOwner: Optional[AccountId] + + +class PutObjectLockConfigurationOutput(TypedDict, total=False): + RequestCharged: Optional[RequestCharged] + + +class PutObjectLockConfigurationRequest(ServiceRequest): + Bucket: BucketName + ObjectLockConfiguration: Optional[ObjectLockConfiguration] + RequestPayer: Optional[RequestPayer] + Token: Optional[ObjectLockToken] + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ExpectedBucketOwner: Optional[AccountId] + + +class PutObjectOutput(TypedDict, total=False): + Expiration: Optional[Expiration] + ETag: Optional[ETag] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + ChecksumType: Optional[ChecksumType] + ServerSideEncryption: Optional[ServerSideEncryption] + VersionId: Optional[ObjectVersionId] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + Size: Optional[Size] + RequestCharged: Optional[RequestCharged] + + +WriteOffsetBytes = int + + +class PutObjectRequest(ServiceRequest): + Body: Optional[IO[Body]] + ACL: Optional[ObjectCannedACL] + Bucket: BucketName + CacheControl: Optional[CacheControl] + ContentDisposition: Optional[ContentDisposition] + ContentEncoding: Optional[ContentEncoding] + ContentLanguage: Optional[ContentLanguage] + ContentLength: Optional[ContentLength] + ContentMD5: Optional[ContentMD5] + ContentType: Optional[ContentType] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + Expires: Optional[Expires] + IfMatch: Optional[IfMatch] + IfNoneMatch: Optional[IfNoneMatch] + GrantFullControl: Optional[GrantFullControl] + GrantRead: Optional[GrantRead] + GrantReadACP: Optional[GrantReadACP] + GrantWriteACP: Optional[GrantWriteACP] + Key: ObjectKey + WriteOffsetBytes: Optional[WriteOffsetBytes] + Metadata: Optional[Metadata] + ServerSideEncryption: Optional[ServerSideEncryption] + StorageClass: Optional[StorageClass] + WebsiteRedirectLocation: Optional[WebsiteRedirectLocation] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestPayer: Optional[RequestPayer] + Tagging: Optional[TaggingHeader] + ObjectLockMode: Optional[ObjectLockMode] + ObjectLockRetainUntilDate: Optional[ObjectLockRetainUntilDate] + ObjectLockLegalHoldStatus: Optional[ObjectLockLegalHoldStatus] + ExpectedBucketOwner: Optional[AccountId] + + +class PutObjectRetentionOutput(TypedDict, total=False): + RequestCharged: Optional[RequestCharged] + + +class PutObjectRetentionRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + Retention: Optional[ObjectLockRetention] + RequestPayer: Optional[RequestPayer] + VersionId: Optional[ObjectVersionId] + BypassGovernanceRetention: Optional[BypassGovernanceRetention] + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ExpectedBucketOwner: Optional[AccountId] + + +class PutObjectTaggingOutput(TypedDict, total=False): + VersionId: Optional[ObjectVersionId] + + +class PutObjectTaggingRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + Tagging: Tagging + ExpectedBucketOwner: Optional[AccountId] + RequestPayer: Optional[RequestPayer] + + +class PutPublicAccessBlockRequest(ServiceRequest): + Bucket: BucketName + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + PublicAccessBlockConfiguration: PublicAccessBlockConfiguration + ExpectedBucketOwner: Optional[AccountId] + + +class RecordsEvent(TypedDict, total=False): + Payload: Optional[Body] + + +class RenameObjectOutput(TypedDict, total=False): + pass + + +RenameSourceIfUnmodifiedSince = datetime +RenameSourceIfModifiedSince = datetime + + +class RenameObjectRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + RenameSource: RenameSource + DestinationIfMatch: Optional[IfMatch] + DestinationIfNoneMatch: Optional[IfNoneMatch] + DestinationIfModifiedSince: Optional[IfModifiedSince] + DestinationIfUnmodifiedSince: Optional[IfUnmodifiedSince] + SourceIfMatch: Optional[RenameSourceIfMatch] + SourceIfNoneMatch: Optional[RenameSourceIfNoneMatch] + SourceIfModifiedSince: Optional[RenameSourceIfModifiedSince] + SourceIfUnmodifiedSince: Optional[RenameSourceIfUnmodifiedSince] + ClientToken: Optional[ClientToken] + + +class RequestProgress(TypedDict, total=False): + Enabled: Optional[EnableRequestProgress] + + +class RestoreObjectOutput(TypedDict, total=False): + RequestCharged: Optional[RequestCharged] + RestoreOutputPath: Optional[RestoreOutputPath] + StatusCode: Optional[RestoreObjectOutputStatusCode] + + +class SelectParameters(TypedDict, total=False): + InputSerialization: InputSerialization + ExpressionType: ExpressionType + Expression: Expression + OutputSerialization: OutputSerialization + + +class RestoreRequest(TypedDict, total=False): + Days: Optional[Days] + GlacierJobParameters: Optional[GlacierJobParameters] + Type: Optional[RestoreRequestType] + Tier: Optional[Tier] + Description: Optional[Description] + SelectParameters: Optional[SelectParameters] + OutputLocation: Optional[OutputLocation] + + +class RestoreObjectRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + VersionId: Optional[ObjectVersionId] + RestoreRequest: Optional[RestoreRequest] + RequestPayer: Optional[RequestPayer] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ExpectedBucketOwner: Optional[AccountId] + + +Start = int + + +class ScanRange(TypedDict, total=False): + Start: Optional[Start] + End: Optional[End] + + +class Stats(TypedDict, total=False): + BytesScanned: Optional[BytesScanned] + BytesProcessed: Optional[BytesProcessed] + BytesReturned: Optional[BytesReturned] + + +class StatsEvent(TypedDict, total=False): + Details: Optional[Stats] + + +class SelectObjectContentEventStream(TypedDict, total=False): + Records: Optional[RecordsEvent] + Stats: Optional[StatsEvent] + Progress: Optional[ProgressEvent] + Cont: Optional[ContinuationEvent] + End: Optional[EndEvent] + + +class SelectObjectContentOutput(TypedDict, total=False): + Payload: Iterator[SelectObjectContentEventStream] + + +class SelectObjectContentRequest(ServiceRequest): + Bucket: BucketName + Key: ObjectKey + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + Expression: Expression + ExpressionType: ExpressionType + RequestProgress: Optional[RequestProgress] + InputSerialization: InputSerialization + OutputSerialization: OutputSerialization + ScanRange: Optional[ScanRange] + ExpectedBucketOwner: Optional[AccountId] + + +class UploadPartCopyOutput(TypedDict, total=False): + CopySourceVersionId: Optional[CopySourceVersionId] + CopyPartResult: Optional[CopyPartResult] + ServerSideEncryption: Optional[ServerSideEncryption] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestCharged: Optional[RequestCharged] + + +class UploadPartCopyRequest(ServiceRequest): + Bucket: BucketName + CopySource: CopySource + CopySourceIfMatch: Optional[CopySourceIfMatch] + CopySourceIfModifiedSince: Optional[CopySourceIfModifiedSince] + CopySourceIfNoneMatch: Optional[CopySourceIfNoneMatch] + CopySourceIfUnmodifiedSince: Optional[CopySourceIfUnmodifiedSince] + CopySourceRange: Optional[CopySourceRange] + Key: ObjectKey + PartNumber: PartNumber + UploadId: MultipartUploadId + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + CopySourceSSECustomerAlgorithm: Optional[CopySourceSSECustomerAlgorithm] + CopySourceSSECustomerKey: Optional[CopySourceSSECustomerKey] + CopySourceSSECustomerKeyMD5: Optional[CopySourceSSECustomerKeyMD5] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + ExpectedSourceBucketOwner: Optional[AccountId] + + +class UploadPartOutput(TypedDict, total=False): + ServerSideEncryption: Optional[ServerSideEncryption] + ETag: Optional[ETag] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestCharged: Optional[RequestCharged] + + +class UploadPartRequest(ServiceRequest): + Body: Optional[IO[Body]] + Bucket: BucketName + ContentLength: Optional[ContentLength] + ContentMD5: Optional[ContentMD5] + ChecksumAlgorithm: Optional[ChecksumAlgorithm] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + Key: ObjectKey + PartNumber: PartNumber + UploadId: MultipartUploadId + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKey: Optional[SSECustomerKey] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + RequestPayer: Optional[RequestPayer] + ExpectedBucketOwner: Optional[AccountId] + + +class WriteGetObjectResponseRequest(ServiceRequest): + Body: Optional[IO[Body]] + RequestRoute: RequestRoute + RequestToken: RequestToken + StatusCode: Optional[GetObjectResponseStatusCode] + ErrorCode: Optional[ErrorCode] + ErrorMessage: Optional[ErrorMessage] + AcceptRanges: Optional[AcceptRanges] + CacheControl: Optional[CacheControl] + ContentDisposition: Optional[ContentDisposition] + ContentEncoding: Optional[ContentEncoding] + ContentLanguage: Optional[ContentLanguage] + ContentLength: Optional[ContentLength] + ContentRange: Optional[ContentRange] + ContentType: Optional[ContentType] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + DeleteMarker: Optional[DeleteMarker] + ETag: Optional[ETag] + Expires: Optional[Expires] + Expiration: Optional[Expiration] + LastModified: Optional[LastModified] + MissingMeta: Optional[MissingMeta] + Metadata: Optional[Metadata] + ObjectLockMode: Optional[ObjectLockMode] + ObjectLockLegalHoldStatus: Optional[ObjectLockLegalHoldStatus] + ObjectLockRetainUntilDate: Optional[ObjectLockRetainUntilDate] + PartsCount: Optional[PartsCount] + ReplicationStatus: Optional[ReplicationStatus] + RequestCharged: Optional[RequestCharged] + Restore: Optional[Restore] + ServerSideEncryption: Optional[ServerSideEncryption] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + StorageClass: Optional[StorageClass] + TagCount: Optional[TagCount] + VersionId: Optional[ObjectVersionId] + BucketKeyEnabled: Optional[BucketKeyEnabled] + + +class PostObjectRequest(ServiceRequest): + Body: Optional[IO[Body]] + Bucket: BucketName + + +class PostResponse(TypedDict, total=False): + StatusCode: Optional[GetObjectResponseStatusCode] + Location: Optional[Location] + LocationHeader: Optional[Location] + Bucket: Optional[BucketName] + Key: Optional[ObjectKey] + Expiration: Optional[Expiration] + ETag: Optional[ETag] + ETagHeader: Optional[ETag] + ChecksumCRC32: Optional[ChecksumCRC32] + ChecksumCRC32C: Optional[ChecksumCRC32C] + ChecksumCRC64NVME: Optional[ChecksumCRC64NVME] + ChecksumSHA1: Optional[ChecksumSHA1] + ChecksumSHA256: Optional[ChecksumSHA256] + ChecksumType: Optional[ChecksumType] + ServerSideEncryption: Optional[ServerSideEncryption] + VersionId: Optional[ObjectVersionId] + SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + SSEKMSKeyId: Optional[SSEKMSKeyId] + SSEKMSEncryptionContext: Optional[SSEKMSEncryptionContext] + BucketKeyEnabled: Optional[BucketKeyEnabled] + RequestCharged: Optional[RequestCharged] + + +class S3Api: + service = "s3" + version = "2006-03-01" + + @handler("AbortMultipartUpload") + def abort_multipart_upload( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + upload_id: MultipartUploadId, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + if_match_initiated_time: IfMatchInitiatedTime | None = None, + **kwargs, + ) -> AbortMultipartUploadOutput: + raise NotImplementedError + + @handler("CompleteMultipartUpload") + def complete_multipart_upload( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + upload_id: MultipartUploadId, + multipart_upload: CompletedMultipartUpload | None = None, + checksum_crc32: ChecksumCRC32 | None = None, + checksum_crc32_c: ChecksumCRC32C | None = None, + checksum_crc64_nvme: ChecksumCRC64NVME | None = None, + checksum_sha1: ChecksumSHA1 | None = None, + checksum_sha256: ChecksumSHA256 | None = None, + checksum_type: ChecksumType | None = None, + mpu_object_size: MpuObjectSize | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + if_match: IfMatch | None = None, + if_none_match: IfNoneMatch | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + **kwargs, + ) -> CompleteMultipartUploadOutput: + raise NotImplementedError + + @handler("CopyObject") + def copy_object( + self, + context: RequestContext, + bucket: BucketName, + copy_source: CopySource, + key: ObjectKey, + acl: ObjectCannedACL | None = None, + cache_control: CacheControl | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + content_disposition: ContentDisposition | None = None, + content_encoding: ContentEncoding | None = None, + content_language: ContentLanguage | None = None, + content_type: ContentType | None = None, + copy_source_if_match: CopySourceIfMatch | None = None, + copy_source_if_modified_since: CopySourceIfModifiedSince | None = None, + copy_source_if_none_match: CopySourceIfNoneMatch | None = None, + copy_source_if_unmodified_since: CopySourceIfUnmodifiedSince | None = None, + expires: Expires | None = None, + grant_full_control: GrantFullControl | None = None, + grant_read: GrantRead | None = None, + grant_read_acp: GrantReadACP | None = None, + grant_write_acp: GrantWriteACP | None = None, + metadata: Metadata | None = None, + metadata_directive: MetadataDirective | None = None, + tagging_directive: TaggingDirective | None = None, + server_side_encryption: ServerSideEncryption | None = None, + storage_class: StorageClass | None = None, + website_redirect_location: WebsiteRedirectLocation | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + ssekms_key_id: SSEKMSKeyId | None = None, + ssekms_encryption_context: SSEKMSEncryptionContext | None = None, + bucket_key_enabled: BucketKeyEnabled | None = None, + copy_source_sse_customer_algorithm: CopySourceSSECustomerAlgorithm | None = None, + copy_source_sse_customer_key: CopySourceSSECustomerKey | None = None, + copy_source_sse_customer_key_md5: CopySourceSSECustomerKeyMD5 | None = None, + request_payer: RequestPayer | None = None, + tagging: TaggingHeader | None = None, + object_lock_mode: ObjectLockMode | None = None, + object_lock_retain_until_date: ObjectLockRetainUntilDate | None = None, + object_lock_legal_hold_status: ObjectLockLegalHoldStatus | None = None, + expected_bucket_owner: AccountId | None = None, + expected_source_bucket_owner: AccountId | None = None, + **kwargs, + ) -> CopyObjectOutput: + raise NotImplementedError + + @handler("CreateBucket") + def create_bucket( + self, + context: RequestContext, + bucket: BucketName, + acl: BucketCannedACL | None = None, + create_bucket_configuration: CreateBucketConfiguration | None = None, + grant_full_control: GrantFullControl | None = None, + grant_read: GrantRead | None = None, + grant_read_acp: GrantReadACP | None = None, + grant_write: GrantWrite | None = None, + grant_write_acp: GrantWriteACP | None = None, + object_lock_enabled_for_bucket: ObjectLockEnabledForBucket | None = None, + object_ownership: ObjectOwnership | None = None, + **kwargs, + ) -> CreateBucketOutput: + raise NotImplementedError + + @handler("CreateBucketMetadataTableConfiguration") + def create_bucket_metadata_table_configuration( + self, + context: RequestContext, + bucket: BucketName, + metadata_table_configuration: MetadataTableConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateMultipartUpload") + def create_multipart_upload( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + acl: ObjectCannedACL | None = None, + cache_control: CacheControl | None = None, + content_disposition: ContentDisposition | None = None, + content_encoding: ContentEncoding | None = None, + content_language: ContentLanguage | None = None, + content_type: ContentType | None = None, + expires: Expires | None = None, + grant_full_control: GrantFullControl | None = None, + grant_read: GrantRead | None = None, + grant_read_acp: GrantReadACP | None = None, + grant_write_acp: GrantWriteACP | None = None, + metadata: Metadata | None = None, + server_side_encryption: ServerSideEncryption | None = None, + storage_class: StorageClass | None = None, + website_redirect_location: WebsiteRedirectLocation | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + ssekms_key_id: SSEKMSKeyId | None = None, + ssekms_encryption_context: SSEKMSEncryptionContext | None = None, + bucket_key_enabled: BucketKeyEnabled | None = None, + request_payer: RequestPayer | None = None, + tagging: TaggingHeader | None = None, + object_lock_mode: ObjectLockMode | None = None, + object_lock_retain_until_date: ObjectLockRetainUntilDate | None = None, + object_lock_legal_hold_status: ObjectLockLegalHoldStatus | None = None, + expected_bucket_owner: AccountId | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + checksum_type: ChecksumType | None = None, + **kwargs, + ) -> CreateMultipartUploadOutput: + raise NotImplementedError + + @handler("CreateSession") + def create_session( + self, + context: RequestContext, + bucket: BucketName, + session_mode: SessionMode | None = None, + server_side_encryption: ServerSideEncryption | None = None, + ssekms_key_id: SSEKMSKeyId | None = None, + ssekms_encryption_context: SSEKMSEncryptionContext | None = None, + bucket_key_enabled: BucketKeyEnabled | None = None, + **kwargs, + ) -> CreateSessionOutput: + raise NotImplementedError + + @handler("DeleteBucket") + def delete_bucket( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketAnalyticsConfiguration") + def delete_bucket_analytics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: AnalyticsId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketCors") + def delete_bucket_cors( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketEncryption") + def delete_bucket_encryption( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketIntelligentTieringConfiguration") + def delete_bucket_intelligent_tiering_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: IntelligentTieringId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketInventoryConfiguration") + def delete_bucket_inventory_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: InventoryId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketLifecycle") + def delete_bucket_lifecycle( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketMetadataTableConfiguration") + def delete_bucket_metadata_table_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketMetricsConfiguration") + def delete_bucket_metrics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: MetricsId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketOwnershipControls") + def delete_bucket_ownership_controls( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketPolicy") + def delete_bucket_policy( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketReplication") + def delete_bucket_replication( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketTagging") + def delete_bucket_tagging( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketWebsite") + def delete_bucket_website( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteObject") + def delete_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + mfa: MFA | None = None, + version_id: ObjectVersionId | None = None, + request_payer: RequestPayer | None = None, + bypass_governance_retention: BypassGovernanceRetention | None = None, + expected_bucket_owner: AccountId | None = None, + if_match: IfMatch | None = None, + if_match_last_modified_time: IfMatchLastModifiedTime | None = None, + if_match_size: IfMatchSize | None = None, + **kwargs, + ) -> DeleteObjectOutput: + raise NotImplementedError + + @handler("DeleteObjectTagging") + def delete_object_tagging( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> DeleteObjectTaggingOutput: + raise NotImplementedError + + @handler("DeleteObjects") + def delete_objects( + self, + context: RequestContext, + bucket: BucketName, + delete: Delete, + mfa: MFA | None = None, + request_payer: RequestPayer | None = None, + bypass_governance_retention: BypassGovernanceRetention | None = None, + expected_bucket_owner: AccountId | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + **kwargs, + ) -> DeleteObjectsOutput: + raise NotImplementedError + + @handler("DeletePublicAccessBlock") + def delete_public_access_block( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("GetBucketAccelerateConfiguration") + def get_bucket_accelerate_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + request_payer: RequestPayer | None = None, + **kwargs, + ) -> GetBucketAccelerateConfigurationOutput: + raise NotImplementedError + + @handler("GetBucketAcl") + def get_bucket_acl( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketAclOutput: + raise NotImplementedError + + @handler("GetBucketAnalyticsConfiguration") + def get_bucket_analytics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: AnalyticsId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketAnalyticsConfigurationOutput: + raise NotImplementedError + + @handler("GetBucketCors") + def get_bucket_cors( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketCorsOutput: + raise NotImplementedError + + @handler("GetBucketEncryption") + def get_bucket_encryption( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketEncryptionOutput: + raise NotImplementedError + + @handler("GetBucketIntelligentTieringConfiguration") + def get_bucket_intelligent_tiering_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: IntelligentTieringId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketIntelligentTieringConfigurationOutput: + raise NotImplementedError + + @handler("GetBucketInventoryConfiguration") + def get_bucket_inventory_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: InventoryId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketInventoryConfigurationOutput: + raise NotImplementedError + + @handler("GetBucketLifecycle") + def get_bucket_lifecycle( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketLifecycleOutput: + raise NotImplementedError + + @handler("GetBucketLifecycleConfiguration") + def get_bucket_lifecycle_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketLifecycleConfigurationOutput: + raise NotImplementedError + + @handler("GetBucketLocation") + def get_bucket_location( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketLocationOutput: + raise NotImplementedError + + @handler("GetBucketLogging") + def get_bucket_logging( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketLoggingOutput: + raise NotImplementedError + + @handler("GetBucketMetadataTableConfiguration") + def get_bucket_metadata_table_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketMetadataTableConfigurationOutput: + raise NotImplementedError + + @handler("GetBucketMetricsConfiguration") + def get_bucket_metrics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: MetricsId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketMetricsConfigurationOutput: + raise NotImplementedError + + @handler("GetBucketNotification") + def get_bucket_notification( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> NotificationConfigurationDeprecated: + raise NotImplementedError + + @handler("GetBucketNotificationConfiguration") + def get_bucket_notification_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> NotificationConfiguration: + raise NotImplementedError + + @handler("GetBucketOwnershipControls") + def get_bucket_ownership_controls( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketOwnershipControlsOutput: + raise NotImplementedError + + @handler("GetBucketPolicy") + def get_bucket_policy( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketPolicyOutput: + raise NotImplementedError + + @handler("GetBucketPolicyStatus") + def get_bucket_policy_status( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketPolicyStatusOutput: + raise NotImplementedError + + @handler("GetBucketReplication") + def get_bucket_replication( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketReplicationOutput: + raise NotImplementedError + + @handler("GetBucketRequestPayment") + def get_bucket_request_payment( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketRequestPaymentOutput: + raise NotImplementedError + + @handler("GetBucketTagging") + def get_bucket_tagging( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketTaggingOutput: + raise NotImplementedError + + @handler("GetBucketVersioning") + def get_bucket_versioning( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketVersioningOutput: + raise NotImplementedError + + @handler("GetBucketWebsite") + def get_bucket_website( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketWebsiteOutput: + raise NotImplementedError + + @handler("GetObject") + def get_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + if_match: IfMatch | None = None, + if_modified_since: IfModifiedSince | None = None, + if_none_match: IfNoneMatch | None = None, + if_unmodified_since: IfUnmodifiedSince | None = None, + range: Range | None = None, + response_cache_control: ResponseCacheControl | None = None, + response_content_disposition: ResponseContentDisposition | None = None, + response_content_encoding: ResponseContentEncoding | None = None, + response_content_language: ResponseContentLanguage | None = None, + response_content_type: ResponseContentType | None = None, + response_expires: ResponseExpires | None = None, + version_id: ObjectVersionId | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + request_payer: RequestPayer | None = None, + part_number: PartNumber | None = None, + expected_bucket_owner: AccountId | None = None, + checksum_mode: ChecksumMode | None = None, + **kwargs, + ) -> GetObjectOutput: + raise NotImplementedError + + @handler("GetObjectAcl") + def get_object_acl( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetObjectAclOutput: + raise NotImplementedError + + @handler("GetObjectAttributes") + def get_object_attributes( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + object_attributes: ObjectAttributesList, + version_id: ObjectVersionId | None = None, + max_parts: MaxParts | None = None, + part_number_marker: PartNumberMarker | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetObjectAttributesOutput: + raise NotImplementedError + + @handler("GetObjectLegalHold") + def get_object_legal_hold( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetObjectLegalHoldOutput: + raise NotImplementedError + + @handler("GetObjectLockConfiguration") + def get_object_lock_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetObjectLockConfigurationOutput: + raise NotImplementedError + + @handler("GetObjectRetention") + def get_object_retention( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetObjectRetentionOutput: + raise NotImplementedError + + @handler("GetObjectTagging") + def get_object_tagging( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId | None = None, + expected_bucket_owner: AccountId | None = None, + request_payer: RequestPayer | None = None, + **kwargs, + ) -> GetObjectTaggingOutput: + raise NotImplementedError + + @handler("GetObjectTorrent") + def get_object_torrent( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetObjectTorrentOutput: + raise NotImplementedError + + @handler("GetPublicAccessBlock") + def get_public_access_block( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetPublicAccessBlockOutput: + raise NotImplementedError + + @handler("HeadBucket") + def head_bucket( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> HeadBucketOutput: + raise NotImplementedError + + @handler("HeadObject") + def head_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + if_match: IfMatch | None = None, + if_modified_since: IfModifiedSince | None = None, + if_none_match: IfNoneMatch | None = None, + if_unmodified_since: IfUnmodifiedSince | None = None, + range: Range | None = None, + response_cache_control: ResponseCacheControl | None = None, + response_content_disposition: ResponseContentDisposition | None = None, + response_content_encoding: ResponseContentEncoding | None = None, + response_content_language: ResponseContentLanguage | None = None, + response_content_type: ResponseContentType | None = None, + response_expires: ResponseExpires | None = None, + version_id: ObjectVersionId | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + request_payer: RequestPayer | None = None, + part_number: PartNumber | None = None, + expected_bucket_owner: AccountId | None = None, + checksum_mode: ChecksumMode | None = None, + **kwargs, + ) -> HeadObjectOutput: + raise NotImplementedError + + @handler("ListBucketAnalyticsConfigurations") + def list_bucket_analytics_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> ListBucketAnalyticsConfigurationsOutput: + raise NotImplementedError + + @handler("ListBucketIntelligentTieringConfigurations") + def list_bucket_intelligent_tiering_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> ListBucketIntelligentTieringConfigurationsOutput: + raise NotImplementedError + + @handler("ListBucketInventoryConfigurations") + def list_bucket_inventory_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> ListBucketInventoryConfigurationsOutput: + raise NotImplementedError + + @handler("ListBucketMetricsConfigurations") + def list_bucket_metrics_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> ListBucketMetricsConfigurationsOutput: + raise NotImplementedError + + @handler("ListBuckets") + def list_buckets( + self, + context: RequestContext, + max_buckets: MaxBuckets | None = None, + continuation_token: Token | None = None, + prefix: Prefix | None = None, + bucket_region: BucketRegion | None = None, + **kwargs, + ) -> ListBucketsOutput: + raise NotImplementedError + + @handler("ListDirectoryBuckets") + def list_directory_buckets( + self, + context: RequestContext, + continuation_token: DirectoryBucketToken | None = None, + max_directory_buckets: MaxDirectoryBuckets | None = None, + **kwargs, + ) -> ListDirectoryBucketsOutput: + raise NotImplementedError + + @handler("ListMultipartUploads") + def list_multipart_uploads( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter | None = None, + encoding_type: EncodingType | None = None, + key_marker: KeyMarker | None = None, + max_uploads: MaxUploads | None = None, + prefix: Prefix | None = None, + upload_id_marker: UploadIdMarker | None = None, + expected_bucket_owner: AccountId | None = None, + request_payer: RequestPayer | None = None, + **kwargs, + ) -> ListMultipartUploadsOutput: + raise NotImplementedError + + @handler("ListObjectVersions") + def list_object_versions( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter | None = None, + encoding_type: EncodingType | None = None, + key_marker: KeyMarker | None = None, + max_keys: MaxKeys | None = None, + prefix: Prefix | None = None, + version_id_marker: VersionIdMarker | None = None, + expected_bucket_owner: AccountId | None = None, + request_payer: RequestPayer | None = None, + optional_object_attributes: OptionalObjectAttributesList | None = None, + **kwargs, + ) -> ListObjectVersionsOutput: + raise NotImplementedError + + @handler("ListObjects") + def list_objects( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter | None = None, + encoding_type: EncodingType | None = None, + marker: Marker | None = None, + max_keys: MaxKeys | None = None, + prefix: Prefix | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + optional_object_attributes: OptionalObjectAttributesList | None = None, + **kwargs, + ) -> ListObjectsOutput: + raise NotImplementedError + + @handler("ListObjectsV2") + def list_objects_v2( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter | None = None, + encoding_type: EncodingType | None = None, + max_keys: MaxKeys | None = None, + prefix: Prefix | None = None, + continuation_token: Token | None = None, + fetch_owner: FetchOwner | None = None, + start_after: StartAfter | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + optional_object_attributes: OptionalObjectAttributesList | None = None, + **kwargs, + ) -> ListObjectsV2Output: + raise NotImplementedError + + @handler("ListParts") + def list_parts( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + upload_id: MultipartUploadId, + max_parts: MaxParts | None = None, + part_number_marker: PartNumberMarker | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + **kwargs, + ) -> ListPartsOutput: + raise NotImplementedError + + @handler("PutBucketAccelerateConfiguration") + def put_bucket_accelerate_configuration( + self, + context: RequestContext, + bucket: BucketName, + accelerate_configuration: AccelerateConfiguration, + expected_bucket_owner: AccountId | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketAcl") + def put_bucket_acl( + self, + context: RequestContext, + bucket: BucketName, + acl: BucketCannedACL | None = None, + access_control_policy: AccessControlPolicy | None = None, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + grant_full_control: GrantFullControl | None = None, + grant_read: GrantRead | None = None, + grant_read_acp: GrantReadACP | None = None, + grant_write: GrantWrite | None = None, + grant_write_acp: GrantWriteACP | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketAnalyticsConfiguration") + def put_bucket_analytics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: AnalyticsId, + analytics_configuration: AnalyticsConfiguration, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketCors") + def put_bucket_cors( + self, + context: RequestContext, + bucket: BucketName, + cors_configuration: CORSConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketEncryption") + def put_bucket_encryption( + self, + context: RequestContext, + bucket: BucketName, + server_side_encryption_configuration: ServerSideEncryptionConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketIntelligentTieringConfiguration") + def put_bucket_intelligent_tiering_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: IntelligentTieringId, + intelligent_tiering_configuration: IntelligentTieringConfiguration, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketInventoryConfiguration") + def put_bucket_inventory_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: InventoryId, + inventory_configuration: InventoryConfiguration, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketLifecycle") + def put_bucket_lifecycle( + self, + context: RequestContext, + bucket: BucketName, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + lifecycle_configuration: LifecycleConfiguration | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketLifecycleConfiguration") + def put_bucket_lifecycle_configuration( + self, + context: RequestContext, + bucket: BucketName, + checksum_algorithm: ChecksumAlgorithm | None = None, + lifecycle_configuration: BucketLifecycleConfiguration | None = None, + expected_bucket_owner: AccountId | None = None, + transition_default_minimum_object_size: TransitionDefaultMinimumObjectSize | None = None, + **kwargs, + ) -> PutBucketLifecycleConfigurationOutput: + raise NotImplementedError + + @handler("PutBucketLogging") + def put_bucket_logging( + self, + context: RequestContext, + bucket: BucketName, + bucket_logging_status: BucketLoggingStatus, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketMetricsConfiguration") + def put_bucket_metrics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: MetricsId, + metrics_configuration: MetricsConfiguration, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketNotification") + def put_bucket_notification( + self, + context: RequestContext, + bucket: BucketName, + notification_configuration: NotificationConfigurationDeprecated, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketNotificationConfiguration") + def put_bucket_notification_configuration( + self, + context: RequestContext, + bucket: BucketName, + notification_configuration: NotificationConfiguration, + expected_bucket_owner: AccountId | None = None, + skip_destination_validation: SkipValidation | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketOwnershipControls") + def put_bucket_ownership_controls( + self, + context: RequestContext, + bucket: BucketName, + ownership_controls: OwnershipControls, + content_md5: ContentMD5 | None = None, + expected_bucket_owner: AccountId | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketPolicy") + def put_bucket_policy( + self, + context: RequestContext, + bucket: BucketName, + policy: Policy, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + confirm_remove_self_bucket_access: ConfirmRemoveSelfBucketAccess | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketReplication") + def put_bucket_replication( + self, + context: RequestContext, + bucket: BucketName, + replication_configuration: ReplicationConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + token: ObjectLockToken | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketRequestPayment") + def put_bucket_request_payment( + self, + context: RequestContext, + bucket: BucketName, + request_payment_configuration: RequestPaymentConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketTagging") + def put_bucket_tagging( + self, + context: RequestContext, + bucket: BucketName, + tagging: Tagging, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketVersioning") + def put_bucket_versioning( + self, + context: RequestContext, + bucket: BucketName, + versioning_configuration: VersioningConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + mfa: MFA | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketWebsite") + def put_bucket_website( + self, + context: RequestContext, + bucket: BucketName, + website_configuration: WebsiteConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutObject") + def put_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + acl: ObjectCannedACL | None = None, + body: IO[Body] | None = None, + cache_control: CacheControl | None = None, + content_disposition: ContentDisposition | None = None, + content_encoding: ContentEncoding | None = None, + content_language: ContentLanguage | None = None, + content_length: ContentLength | None = None, + content_md5: ContentMD5 | None = None, + content_type: ContentType | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + checksum_crc32: ChecksumCRC32 | None = None, + checksum_crc32_c: ChecksumCRC32C | None = None, + checksum_crc64_nvme: ChecksumCRC64NVME | None = None, + checksum_sha1: ChecksumSHA1 | None = None, + checksum_sha256: ChecksumSHA256 | None = None, + expires: Expires | None = None, + if_match: IfMatch | None = None, + if_none_match: IfNoneMatch | None = None, + grant_full_control: GrantFullControl | None = None, + grant_read: GrantRead | None = None, + grant_read_acp: GrantReadACP | None = None, + grant_write_acp: GrantWriteACP | None = None, + write_offset_bytes: WriteOffsetBytes | None = None, + metadata: Metadata | None = None, + server_side_encryption: ServerSideEncryption | None = None, + storage_class: StorageClass | None = None, + website_redirect_location: WebsiteRedirectLocation | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + ssekms_key_id: SSEKMSKeyId | None = None, + ssekms_encryption_context: SSEKMSEncryptionContext | None = None, + bucket_key_enabled: BucketKeyEnabled | None = None, + request_payer: RequestPayer | None = None, + tagging: TaggingHeader | None = None, + object_lock_mode: ObjectLockMode | None = None, + object_lock_retain_until_date: ObjectLockRetainUntilDate | None = None, + object_lock_legal_hold_status: ObjectLockLegalHoldStatus | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> PutObjectOutput: + raise NotImplementedError + + @handler("PutObjectAcl") + def put_object_acl( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + acl: ObjectCannedACL | None = None, + access_control_policy: AccessControlPolicy | None = None, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + grant_full_control: GrantFullControl | None = None, + grant_read: GrantRead | None = None, + grant_read_acp: GrantReadACP | None = None, + grant_write: GrantWrite | None = None, + grant_write_acp: GrantWriteACP | None = None, + request_payer: RequestPayer | None = None, + version_id: ObjectVersionId | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> PutObjectAclOutput: + raise NotImplementedError + + @handler("PutObjectLegalHold") + def put_object_legal_hold( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + legal_hold: ObjectLockLegalHold | None = None, + request_payer: RequestPayer | None = None, + version_id: ObjectVersionId | None = None, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> PutObjectLegalHoldOutput: + raise NotImplementedError + + @handler("PutObjectLockConfiguration") + def put_object_lock_configuration( + self, + context: RequestContext, + bucket: BucketName, + object_lock_configuration: ObjectLockConfiguration | None = None, + request_payer: RequestPayer | None = None, + token: ObjectLockToken | None = None, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> PutObjectLockConfigurationOutput: + raise NotImplementedError + + @handler("PutObjectRetention") + def put_object_retention( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + retention: ObjectLockRetention | None = None, + request_payer: RequestPayer | None = None, + version_id: ObjectVersionId | None = None, + bypass_governance_retention: BypassGovernanceRetention | None = None, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> PutObjectRetentionOutput: + raise NotImplementedError + + @handler("PutObjectTagging") + def put_object_tagging( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + tagging: Tagging, + version_id: ObjectVersionId | None = None, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + request_payer: RequestPayer | None = None, + **kwargs, + ) -> PutObjectTaggingOutput: + raise NotImplementedError + + @handler("PutPublicAccessBlock") + def put_public_access_block( + self, + context: RequestContext, + bucket: BucketName, + public_access_block_configuration: PublicAccessBlockConfiguration, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RenameObject") + def rename_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + rename_source: RenameSource, + destination_if_match: IfMatch | None = None, + destination_if_none_match: IfNoneMatch | None = None, + destination_if_modified_since: IfModifiedSince | None = None, + destination_if_unmodified_since: IfUnmodifiedSince | None = None, + source_if_match: RenameSourceIfMatch | None = None, + source_if_none_match: RenameSourceIfNoneMatch | None = None, + source_if_modified_since: RenameSourceIfModifiedSince | None = None, + source_if_unmodified_since: RenameSourceIfUnmodifiedSince | None = None, + client_token: ClientToken | None = None, + **kwargs, + ) -> RenameObjectOutput: + raise NotImplementedError + + @handler("RestoreObject") + def restore_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId | None = None, + restore_request: RestoreRequest | None = None, + request_payer: RequestPayer | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> RestoreObjectOutput: + raise NotImplementedError + + @handler("SelectObjectContent") + def select_object_content( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + expression: Expression, + expression_type: ExpressionType, + input_serialization: InputSerialization, + output_serialization: OutputSerialization, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + request_progress: RequestProgress | None = None, + scan_range: ScanRange | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> SelectObjectContentOutput: + raise NotImplementedError + + @handler("UploadPart") + def upload_part( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + part_number: PartNumber, + upload_id: MultipartUploadId, + body: IO[Body] | None = None, + content_length: ContentLength | None = None, + content_md5: ContentMD5 | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + checksum_crc32: ChecksumCRC32 | None = None, + checksum_crc32_c: ChecksumCRC32C | None = None, + checksum_crc64_nvme: ChecksumCRC64NVME | None = None, + checksum_sha1: ChecksumSHA1 | None = None, + checksum_sha256: ChecksumSHA256 | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> UploadPartOutput: + raise NotImplementedError + + @handler("UploadPartCopy") + def upload_part_copy( + self, + context: RequestContext, + bucket: BucketName, + copy_source: CopySource, + key: ObjectKey, + part_number: PartNumber, + upload_id: MultipartUploadId, + copy_source_if_match: CopySourceIfMatch | None = None, + copy_source_if_modified_since: CopySourceIfModifiedSince | None = None, + copy_source_if_none_match: CopySourceIfNoneMatch | None = None, + copy_source_if_unmodified_since: CopySourceIfUnmodifiedSince | None = None, + copy_source_range: CopySourceRange | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + sse_customer_key: SSECustomerKey | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + copy_source_sse_customer_algorithm: CopySourceSSECustomerAlgorithm | None = None, + copy_source_sse_customer_key: CopySourceSSECustomerKey | None = None, + copy_source_sse_customer_key_md5: CopySourceSSECustomerKeyMD5 | None = None, + request_payer: RequestPayer | None = None, + expected_bucket_owner: AccountId | None = None, + expected_source_bucket_owner: AccountId | None = None, + **kwargs, + ) -> UploadPartCopyOutput: + raise NotImplementedError + + @handler("WriteGetObjectResponse") + def write_get_object_response( + self, + context: RequestContext, + request_route: RequestRoute, + request_token: RequestToken, + body: IO[Body] | None = None, + status_code: GetObjectResponseStatusCode | None = None, + error_code: ErrorCode | None = None, + error_message: ErrorMessage | None = None, + accept_ranges: AcceptRanges | None = None, + cache_control: CacheControl | None = None, + content_disposition: ContentDisposition | None = None, + content_encoding: ContentEncoding | None = None, + content_language: ContentLanguage | None = None, + content_length: ContentLength | None = None, + content_range: ContentRange | None = None, + content_type: ContentType | None = None, + checksum_crc32: ChecksumCRC32 | None = None, + checksum_crc32_c: ChecksumCRC32C | None = None, + checksum_crc64_nvme: ChecksumCRC64NVME | None = None, + checksum_sha1: ChecksumSHA1 | None = None, + checksum_sha256: ChecksumSHA256 | None = None, + delete_marker: DeleteMarker | None = None, + e_tag: ETag | None = None, + expires: Expires | None = None, + expiration: Expiration | None = None, + last_modified: LastModified | None = None, + missing_meta: MissingMeta | None = None, + metadata: Metadata | None = None, + object_lock_mode: ObjectLockMode | None = None, + object_lock_legal_hold_status: ObjectLockLegalHoldStatus | None = None, + object_lock_retain_until_date: ObjectLockRetainUntilDate | None = None, + parts_count: PartsCount | None = None, + replication_status: ReplicationStatus | None = None, + request_charged: RequestCharged | None = None, + restore: Restore | None = None, + server_side_encryption: ServerSideEncryption | None = None, + sse_customer_algorithm: SSECustomerAlgorithm | None = None, + ssekms_key_id: SSEKMSKeyId | None = None, + sse_customer_key_md5: SSECustomerKeyMD5 | None = None, + storage_class: StorageClass | None = None, + tag_count: TagCount | None = None, + version_id: ObjectVersionId | None = None, + bucket_key_enabled: BucketKeyEnabled | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PostObject") + def post_object( + self, context: RequestContext, bucket: BucketName, body: IO[Body] | None = None, **kwargs + ) -> PostResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/s3control/__init__.py b/localstack-core/localstack/aws/api/s3control/__init__.py new file mode 100644 index 0000000000000..429e3219630d2 --- /dev/null +++ b/localstack-core/localstack/aws/api/s3control/__init__.py @@ -0,0 +1,3278 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AccessGrantArn = str +AccessGrantId = str +AccessGrantsInstanceArn = str +AccessGrantsInstanceId = str +AccessGrantsLocationArn = str +AccessGrantsLocationId = str +AccessKeyId = str +AccessPointBucketName = str +AccessPointName = str +AccountId = str +Alias = str +AsyncRequestStatus = str +AsyncRequestTokenARN = str +AwsLambdaTransformationPayload = str +AwsOrgArn = str +Boolean = bool +BucketIdentifierString = str +BucketName = str +ConfigId = str +ConfirmRemoveSelfBucketAccess = bool +ConfirmationRequired = bool +ContinuationToken = str +DataSourceId = str +DataSourceType = str +Days = int +DaysAfterInitiation = int +DurationSeconds = int +ExceptionMessage = str +ExpiredObjectDeleteMarker = bool +FunctionArnString = str +GrantFullControl = str +GrantRead = str +GrantReadACP = str +GrantWrite = str +GrantWriteACP = str +GranteeIdentifier = str +IAMRoleArn = str +ID = str +IdentityCenterApplicationArn = str +IdentityCenterArn = str +IsEnabled = bool +IsPublic = bool +JobArn = str +JobFailureCode = str +JobFailureReason = str +JobId = str +JobPriority = int +JobStatusUpdateReason = str +KmsKeyArnString = str +Location = str +MFA = str +ManifestPrefixString = str +MaxLength1024String = str +MaxResults = int +MinStorageBytesPercentage = float +Minutes = int +MultiRegionAccessPointAlias = str +MultiRegionAccessPointClientToken = str +MultiRegionAccessPointId = str +MultiRegionAccessPointName = str +NoSuchPublicAccessBlockConfigurationMessage = str +NonEmptyMaxLength1024String = str +NonEmptyMaxLength2048String = str +NonEmptyMaxLength256String = str +NonEmptyMaxLength64String = str +NoncurrentVersionCount = int +ObjectAgeValue = int +ObjectLambdaAccessPointAliasValue = str +ObjectLambdaAccessPointArn = str +ObjectLambdaAccessPointName = str +ObjectLambdaPolicy = str +ObjectLambdaSupportingAccessPointArn = str +ObjectLockEnabledForBucket = bool +Organization = str +Policy = str +PolicyDocument = str +Prefix = str +Priority = int +PublicAccessBlockEnabled = bool +RegionName = str +ReplicaKmsKeyID = str +ReportPrefixString = str +Role = str +S3AWSRegion = str +S3AccessPointArn = str +S3BucketArnString = str +S3ExpirationInDays = int +S3KeyArnString = str +S3ObjectVersionId = str +S3Prefix = str +S3RegionalBucketArn = str +S3RegionalOrS3ExpressBucketArnString = str +S3ResourceArn = str +SSEKMSKeyId = str +SecretAccessKey = str +SessionToken = str +Setting = bool +StorageLensArn = str +StorageLensGroupArn = str +StorageLensGroupName = str +StorageLensPrefixLevelDelimiter = str +StorageLensPrefixLevelMaxDepth = int +StringForNextToken = str +Suffix = str +SuspendedCause = str +TagKeyString = str +TagValueString = str +TrafficDialPercentage = int +VpcId = str + + +class AsyncOperationName(StrEnum): + CreateMultiRegionAccessPoint = "CreateMultiRegionAccessPoint" + DeleteMultiRegionAccessPoint = "DeleteMultiRegionAccessPoint" + PutMultiRegionAccessPointPolicy = "PutMultiRegionAccessPointPolicy" + + +class BucketCannedACL(StrEnum): + private = "private" + public_read = "public-read" + public_read_write = "public-read-write" + authenticated_read = "authenticated-read" + + +class BucketLocationConstraint(StrEnum): + EU = "EU" + eu_west_1 = "eu-west-1" + us_west_1 = "us-west-1" + us_west_2 = "us-west-2" + ap_south_1 = "ap-south-1" + ap_southeast_1 = "ap-southeast-1" + ap_southeast_2 = "ap-southeast-2" + ap_northeast_1 = "ap-northeast-1" + sa_east_1 = "sa-east-1" + cn_north_1 = "cn-north-1" + eu_central_1 = "eu-central-1" + + +class BucketVersioningStatus(StrEnum): + Enabled = "Enabled" + Suspended = "Suspended" + + +class DeleteMarkerReplicationStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ExistingObjectReplicationStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ExpirationStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class Format(StrEnum): + CSV = "CSV" + Parquet = "Parquet" + + +class GeneratedManifestFormat(StrEnum): + S3InventoryReport_CSV_20211130 = "S3InventoryReport_CSV_20211130" + + +class GranteeType(StrEnum): + DIRECTORY_USER = "DIRECTORY_USER" + DIRECTORY_GROUP = "DIRECTORY_GROUP" + IAM = "IAM" + + +class JobManifestFieldName(StrEnum): + Ignore = "Ignore" + Bucket = "Bucket" + Key = "Key" + VersionId = "VersionId" + + +class JobManifestFormat(StrEnum): + S3BatchOperations_CSV_20180820 = "S3BatchOperations_CSV_20180820" + S3InventoryReport_CSV_20161130 = "S3InventoryReport_CSV_20161130" + + +class JobReportFormat(StrEnum): + Report_CSV_20180820 = "Report_CSV_20180820" + + +class JobReportScope(StrEnum): + AllTasks = "AllTasks" + FailedTasksOnly = "FailedTasksOnly" + + +class JobStatus(StrEnum): + Active = "Active" + Cancelled = "Cancelled" + Cancelling = "Cancelling" + Complete = "Complete" + Completing = "Completing" + Failed = "Failed" + Failing = "Failing" + New = "New" + Paused = "Paused" + Pausing = "Pausing" + Preparing = "Preparing" + Ready = "Ready" + Suspended = "Suspended" + + +class MFADelete(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class MFADeleteStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class MetricsStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class MultiRegionAccessPointStatus(StrEnum): + READY = "READY" + INCONSISTENT_ACROSS_REGIONS = "INCONSISTENT_ACROSS_REGIONS" + CREATING = "CREATING" + PARTIALLY_CREATED = "PARTIALLY_CREATED" + PARTIALLY_DELETED = "PARTIALLY_DELETED" + DELETING = "DELETING" + + +class NetworkOrigin(StrEnum): + Internet = "Internet" + VPC = "VPC" + + +class ObjectLambdaAccessPointAliasStatus(StrEnum): + PROVISIONING = "PROVISIONING" + READY = "READY" + + +class ObjectLambdaAllowedFeature(StrEnum): + GetObject_Range = "GetObject-Range" + GetObject_PartNumber = "GetObject-PartNumber" + HeadObject_Range = "HeadObject-Range" + HeadObject_PartNumber = "HeadObject-PartNumber" + + +class ObjectLambdaTransformationConfigurationAction(StrEnum): + GetObject = "GetObject" + HeadObject = "HeadObject" + ListObjects = "ListObjects" + ListObjectsV2 = "ListObjectsV2" + + +class OperationName(StrEnum): + LambdaInvoke = "LambdaInvoke" + S3PutObjectCopy = "S3PutObjectCopy" + S3PutObjectAcl = "S3PutObjectAcl" + S3PutObjectTagging = "S3PutObjectTagging" + S3DeleteObjectTagging = "S3DeleteObjectTagging" + S3InitiateRestoreObject = "S3InitiateRestoreObject" + S3PutObjectLegalHold = "S3PutObjectLegalHold" + S3PutObjectRetention = "S3PutObjectRetention" + S3ReplicateObject = "S3ReplicateObject" + + +class OutputSchemaVersion(StrEnum): + V_1 = "V_1" + + +class OwnerOverride(StrEnum): + Destination = "Destination" + + +class Permission(StrEnum): + READ = "READ" + WRITE = "WRITE" + READWRITE = "READWRITE" + + +class Privilege(StrEnum): + Minimal = "Minimal" + Default = "Default" + + +class ReplicaModificationsStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ReplicationRuleStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class ReplicationStatus(StrEnum): + COMPLETED = "COMPLETED" + FAILED = "FAILED" + REPLICA = "REPLICA" + NONE = "NONE" + + +class ReplicationStorageClass(StrEnum): + STANDARD = "STANDARD" + REDUCED_REDUNDANCY = "REDUCED_REDUNDANCY" + STANDARD_IA = "STANDARD_IA" + ONEZONE_IA = "ONEZONE_IA" + INTELLIGENT_TIERING = "INTELLIGENT_TIERING" + GLACIER = "GLACIER" + DEEP_ARCHIVE = "DEEP_ARCHIVE" + OUTPOSTS = "OUTPOSTS" + GLACIER_IR = "GLACIER_IR" + + +class ReplicationTimeStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class RequestedJobStatus(StrEnum): + Cancelled = "Cancelled" + Ready = "Ready" + + +class S3CannedAccessControlList(StrEnum): + private = "private" + public_read = "public-read" + public_read_write = "public-read-write" + aws_exec_read = "aws-exec-read" + authenticated_read = "authenticated-read" + bucket_owner_read = "bucket-owner-read" + bucket_owner_full_control = "bucket-owner-full-control" + + +class S3ChecksumAlgorithm(StrEnum): + CRC32 = "CRC32" + CRC32C = "CRC32C" + SHA1 = "SHA1" + SHA256 = "SHA256" + CRC64NVME = "CRC64NVME" + + +class S3GlacierJobTier(StrEnum): + BULK = "BULK" + STANDARD = "STANDARD" + + +class S3GranteeTypeIdentifier(StrEnum): + id = "id" + emailAddress = "emailAddress" + uri = "uri" + + +class S3MetadataDirective(StrEnum): + COPY = "COPY" + REPLACE = "REPLACE" + + +class S3ObjectLockLegalHoldStatus(StrEnum): + OFF = "OFF" + ON = "ON" + + +class S3ObjectLockMode(StrEnum): + COMPLIANCE = "COMPLIANCE" + GOVERNANCE = "GOVERNANCE" + + +class S3ObjectLockRetentionMode(StrEnum): + COMPLIANCE = "COMPLIANCE" + GOVERNANCE = "GOVERNANCE" + + +class S3Permission(StrEnum): + FULL_CONTROL = "FULL_CONTROL" + READ = "READ" + WRITE = "WRITE" + READ_ACP = "READ_ACP" + WRITE_ACP = "WRITE_ACP" + + +class S3PrefixType(StrEnum): + Object = "Object" + + +class S3SSEAlgorithm(StrEnum): + AES256 = "AES256" + KMS = "KMS" + + +class S3StorageClass(StrEnum): + STANDARD = "STANDARD" + STANDARD_IA = "STANDARD_IA" + ONEZONE_IA = "ONEZONE_IA" + GLACIER = "GLACIER" + INTELLIGENT_TIERING = "INTELLIGENT_TIERING" + DEEP_ARCHIVE = "DEEP_ARCHIVE" + GLACIER_IR = "GLACIER_IR" + + +class ScopePermission(StrEnum): + GetObject = "GetObject" + GetObjectAttributes = "GetObjectAttributes" + ListMultipartUploadParts = "ListMultipartUploadParts" + ListBucket = "ListBucket" + ListBucketMultipartUploads = "ListBucketMultipartUploads" + PutObject = "PutObject" + DeleteObject = "DeleteObject" + AbortMultipartUpload = "AbortMultipartUpload" + + +class SseKmsEncryptedObjectsStatus(StrEnum): + Enabled = "Enabled" + Disabled = "Disabled" + + +class TransitionStorageClass(StrEnum): + GLACIER = "GLACIER" + STANDARD_IA = "STANDARD_IA" + ONEZONE_IA = "ONEZONE_IA" + INTELLIGENT_TIERING = "INTELLIGENT_TIERING" + DEEP_ARCHIVE = "DEEP_ARCHIVE" + + +class BadRequestException(ServiceException): + code: str = "BadRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class BucketAlreadyExists(ServiceException): + code: str = "BucketAlreadyExists" + sender_fault: bool = False + status_code: int = 400 + + +class BucketAlreadyOwnedByYou(ServiceException): + code: str = "BucketAlreadyOwnedByYou" + sender_fault: bool = False + status_code: int = 400 + + +class IdempotencyException(ServiceException): + code: str = "IdempotencyException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServiceException(ServiceException): + code: str = "InternalServiceException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidNextTokenException(ServiceException): + code: str = "InvalidNextTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRequestException(ServiceException): + code: str = "InvalidRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class JobStatusException(ServiceException): + code: str = "JobStatusException" + sender_fault: bool = False + status_code: int = 400 + + +class NoSuchPublicAccessBlockConfiguration(ServiceException): + code: str = "NoSuchPublicAccessBlockConfiguration" + sender_fault: bool = False + status_code: int = 404 + + +class NotFoundException(ServiceException): + code: str = "NotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyRequestsException(ServiceException): + code: str = "TooManyRequestsException" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTagsException(ServiceException): + code: str = "TooManyTagsException" + sender_fault: bool = False + status_code: int = 400 + + +class AbortIncompleteMultipartUpload(TypedDict, total=False): + DaysAfterInitiation: Optional[DaysAfterInitiation] + + +class AccessControlTranslation(TypedDict, total=False): + Owner: OwnerOverride + + +CreationTimestamp = datetime + + +class ListAccessGrantsInstanceEntry(TypedDict, total=False): + AccessGrantsInstanceId: Optional[AccessGrantsInstanceId] + AccessGrantsInstanceArn: Optional[AccessGrantsInstanceArn] + CreatedAt: Optional[CreationTimestamp] + IdentityCenterArn: Optional[IdentityCenterArn] + IdentityCenterInstanceArn: Optional[IdentityCenterArn] + IdentityCenterApplicationArn: Optional[IdentityCenterApplicationArn] + + +AccessGrantsInstancesList = List[ListAccessGrantsInstanceEntry] + + +class AccessGrantsLocationConfiguration(TypedDict, total=False): + S3SubPrefix: Optional[S3Prefix] + + +class Grantee(TypedDict, total=False): + GranteeType: Optional[GranteeType] + GranteeIdentifier: Optional[GranteeIdentifier] + + +class ListAccessGrantEntry(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantId: Optional[AccessGrantId] + AccessGrantArn: Optional[AccessGrantArn] + Grantee: Optional[Grantee] + Permission: Optional[Permission] + AccessGrantsLocationId: Optional[AccessGrantsLocationId] + AccessGrantsLocationConfiguration: Optional[AccessGrantsLocationConfiguration] + GrantScope: Optional[S3Prefix] + ApplicationArn: Optional[IdentityCenterApplicationArn] + + +AccessGrantsList = List[ListAccessGrantEntry] + + +class ListAccessGrantsLocationsEntry(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantsLocationId: Optional[AccessGrantsLocationId] + AccessGrantsLocationArn: Optional[AccessGrantsLocationArn] + LocationScope: Optional[S3Prefix] + IAMRoleArn: Optional[IAMRoleArn] + + +AccessGrantsLocationsList = List[ListAccessGrantsLocationsEntry] + + +class VpcConfiguration(TypedDict, total=False): + VpcId: VpcId + + +class AccessPoint(TypedDict, total=False): + Name: AccessPointName + NetworkOrigin: NetworkOrigin + VpcConfiguration: Optional[VpcConfiguration] + Bucket: AccessPointBucketName + AccessPointArn: Optional[S3AccessPointArn] + Alias: Optional[Alias] + BucketAccountId: Optional[AccountId] + DataSourceId: Optional[DataSourceId] + DataSourceType: Optional[DataSourceType] + + +AccessPointList = List[AccessPoint] +StorageLensGroupLevelExclude = List[StorageLensGroupArn] +StorageLensGroupLevelInclude = List[StorageLensGroupArn] + + +class StorageLensGroupLevelSelectionCriteria(TypedDict, total=False): + Include: Optional[StorageLensGroupLevelInclude] + Exclude: Optional[StorageLensGroupLevelExclude] + + +class StorageLensGroupLevel(TypedDict, total=False): + SelectionCriteria: Optional[StorageLensGroupLevelSelectionCriteria] + + +class DetailedStatusCodesMetrics(TypedDict, total=False): + IsEnabled: Optional[IsEnabled] + + +class AdvancedDataProtectionMetrics(TypedDict, total=False): + IsEnabled: Optional[IsEnabled] + + +class AdvancedCostOptimizationMetrics(TypedDict, total=False): + IsEnabled: Optional[IsEnabled] + + +class SelectionCriteria(TypedDict, total=False): + Delimiter: Optional[StorageLensPrefixLevelDelimiter] + MaxDepth: Optional[StorageLensPrefixLevelMaxDepth] + MinStorageBytesPercentage: Optional[MinStorageBytesPercentage] + + +class PrefixLevelStorageMetrics(TypedDict, total=False): + IsEnabled: Optional[IsEnabled] + SelectionCriteria: Optional[SelectionCriteria] + + +class PrefixLevel(TypedDict, total=False): + StorageMetrics: PrefixLevelStorageMetrics + + +class ActivityMetrics(TypedDict, total=False): + IsEnabled: Optional[IsEnabled] + + +class BucketLevel(TypedDict, total=False): + ActivityMetrics: Optional[ActivityMetrics] + PrefixLevel: Optional[PrefixLevel] + AdvancedCostOptimizationMetrics: Optional[AdvancedCostOptimizationMetrics] + AdvancedDataProtectionMetrics: Optional[AdvancedDataProtectionMetrics] + DetailedStatusCodesMetrics: Optional[DetailedStatusCodesMetrics] + + +class AccountLevel(TypedDict, total=False): + ActivityMetrics: Optional[ActivityMetrics] + BucketLevel: BucketLevel + AdvancedCostOptimizationMetrics: Optional[AdvancedCostOptimizationMetrics] + AdvancedDataProtectionMetrics: Optional[AdvancedDataProtectionMetrics] + DetailedStatusCodesMetrics: Optional[DetailedStatusCodesMetrics] + StorageLensGroupLevel: Optional[StorageLensGroupLevel] + + +class AssociateAccessGrantsIdentityCenterRequest(ServiceRequest): + AccountId: AccountId + IdentityCenterArn: IdentityCenterArn + + +AsyncCreationTimestamp = datetime + + +class AsyncErrorDetails(TypedDict, total=False): + Code: Optional[MaxLength1024String] + Message: Optional[MaxLength1024String] + Resource: Optional[MaxLength1024String] + RequestId: Optional[MaxLength1024String] + + +class MultiRegionAccessPointRegionalResponse(TypedDict, total=False): + Name: Optional[RegionName] + RequestStatus: Optional[AsyncRequestStatus] + + +MultiRegionAccessPointRegionalResponseList = List[MultiRegionAccessPointRegionalResponse] + + +class MultiRegionAccessPointsAsyncResponse(TypedDict, total=False): + Regions: Optional[MultiRegionAccessPointRegionalResponseList] + + +class AsyncResponseDetails(TypedDict, total=False): + MultiRegionAccessPointDetails: Optional[MultiRegionAccessPointsAsyncResponse] + ErrorDetails: Optional[AsyncErrorDetails] + + +class PutMultiRegionAccessPointPolicyInput(TypedDict, total=False): + Name: MultiRegionAccessPointName + Policy: Policy + + +class DeleteMultiRegionAccessPointInput(TypedDict, total=False): + Name: MultiRegionAccessPointName + + +class Region(TypedDict, total=False): + Bucket: BucketName + BucketAccountId: Optional[AccountId] + + +RegionCreationList = List[Region] + + +class PublicAccessBlockConfiguration(TypedDict, total=False): + BlockPublicAcls: Optional[Setting] + IgnorePublicAcls: Optional[Setting] + BlockPublicPolicy: Optional[Setting] + RestrictPublicBuckets: Optional[Setting] + + +class CreateMultiRegionAccessPointInput(TypedDict, total=False): + Name: MultiRegionAccessPointName + PublicAccessBlock: Optional[PublicAccessBlockConfiguration] + Regions: RegionCreationList + + +class AsyncRequestParameters(TypedDict, total=False): + CreateMultiRegionAccessPointRequest: Optional[CreateMultiRegionAccessPointInput] + DeleteMultiRegionAccessPointRequest: Optional[DeleteMultiRegionAccessPointInput] + PutMultiRegionAccessPointPolicyRequest: Optional[PutMultiRegionAccessPointPolicyInput] + + +class AsyncOperation(TypedDict, total=False): + CreationTime: Optional[AsyncCreationTimestamp] + Operation: Optional[AsyncOperationName] + RequestTokenARN: Optional[AsyncRequestTokenARN] + RequestParameters: Optional[AsyncRequestParameters] + RequestStatus: Optional[AsyncRequestStatus] + ResponseDetails: Optional[AsyncResponseDetails] + + +class AwsLambdaTransformation(TypedDict, total=False): + FunctionArn: FunctionArnString + FunctionPayload: Optional[AwsLambdaTransformationPayload] + + +Buckets = List[S3BucketArnString] + + +class ListCallerAccessGrantsEntry(TypedDict, total=False): + Permission: Optional[Permission] + GrantScope: Optional[S3Prefix] + ApplicationArn: Optional[IdentityCenterApplicationArn] + + +CallerAccessGrantsList = List[ListCallerAccessGrantsEntry] + + +class CloudWatchMetrics(TypedDict, total=False): + IsEnabled: IsEnabled + + +class Tag(TypedDict, total=False): + Key: TagKeyString + Value: TagValueString + + +TagList = List[Tag] + + +class CreateAccessGrantRequest(ServiceRequest): + AccountId: AccountId + AccessGrantsLocationId: AccessGrantsLocationId + AccessGrantsLocationConfiguration: Optional[AccessGrantsLocationConfiguration] + Grantee: Grantee + Permission: Permission + ApplicationArn: Optional[IdentityCenterApplicationArn] + S3PrefixType: Optional[S3PrefixType] + Tags: Optional[TagList] + + +class CreateAccessGrantResult(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantId: Optional[AccessGrantId] + AccessGrantArn: Optional[AccessGrantArn] + Grantee: Optional[Grantee] + AccessGrantsLocationId: Optional[AccessGrantsLocationId] + AccessGrantsLocationConfiguration: Optional[AccessGrantsLocationConfiguration] + Permission: Optional[Permission] + ApplicationArn: Optional[IdentityCenterApplicationArn] + GrantScope: Optional[S3Prefix] + + +class CreateAccessGrantsInstanceRequest(ServiceRequest): + AccountId: AccountId + IdentityCenterArn: Optional[IdentityCenterArn] + Tags: Optional[TagList] + + +class CreateAccessGrantsInstanceResult(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantsInstanceId: Optional[AccessGrantsInstanceId] + AccessGrantsInstanceArn: Optional[AccessGrantsInstanceArn] + IdentityCenterArn: Optional[IdentityCenterArn] + IdentityCenterInstanceArn: Optional[IdentityCenterArn] + IdentityCenterApplicationArn: Optional[IdentityCenterApplicationArn] + + +class CreateAccessGrantsLocationRequest(ServiceRequest): + AccountId: AccountId + LocationScope: S3Prefix + IAMRoleArn: IAMRoleArn + Tags: Optional[TagList] + + +class CreateAccessGrantsLocationResult(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantsLocationId: Optional[AccessGrantsLocationId] + AccessGrantsLocationArn: Optional[AccessGrantsLocationArn] + LocationScope: Optional[S3Prefix] + IAMRoleArn: Optional[IAMRoleArn] + + +class ObjectLambdaContentTransformation(TypedDict, total=False): + AwsLambda: Optional[AwsLambdaTransformation] + + +ObjectLambdaTransformationConfigurationActionsList = List[ + ObjectLambdaTransformationConfigurationAction +] + + +class ObjectLambdaTransformationConfiguration(TypedDict, total=False): + Actions: ObjectLambdaTransformationConfigurationActionsList + ContentTransformation: ObjectLambdaContentTransformation + + +ObjectLambdaTransformationConfigurationsList = List[ObjectLambdaTransformationConfiguration] +ObjectLambdaAllowedFeaturesList = List[ObjectLambdaAllowedFeature] + + +class ObjectLambdaConfiguration(TypedDict, total=False): + SupportingAccessPoint: ObjectLambdaSupportingAccessPointArn + CloudWatchMetricsEnabled: Optional[Boolean] + AllowedFeatures: Optional[ObjectLambdaAllowedFeaturesList] + TransformationConfigurations: ObjectLambdaTransformationConfigurationsList + + +class CreateAccessPointForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + Configuration: ObjectLambdaConfiguration + + +class ObjectLambdaAccessPointAlias(TypedDict, total=False): + Value: Optional[ObjectLambdaAccessPointAliasValue] + Status: Optional[ObjectLambdaAccessPointAliasStatus] + + +class CreateAccessPointForObjectLambdaResult(TypedDict, total=False): + ObjectLambdaAccessPointArn: Optional[ObjectLambdaAccessPointArn] + Alias: Optional[ObjectLambdaAccessPointAlias] + + +ScopePermissionList = List[ScopePermission] +PrefixesList = List[Prefix] + + +class Scope(TypedDict, total=False): + Prefixes: Optional[PrefixesList] + Permissions: Optional[ScopePermissionList] + + +class CreateAccessPointRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + Bucket: BucketName + VpcConfiguration: Optional[VpcConfiguration] + PublicAccessBlockConfiguration: Optional[PublicAccessBlockConfiguration] + BucketAccountId: Optional[AccountId] + Scope: Optional[Scope] + + +class CreateAccessPointResult(TypedDict, total=False): + AccessPointArn: Optional[S3AccessPointArn] + Alias: Optional[Alias] + + +class CreateBucketConfiguration(TypedDict, total=False): + LocationConstraint: Optional[BucketLocationConstraint] + + +class CreateBucketRequest(ServiceRequest): + ACL: Optional[BucketCannedACL] + Bucket: BucketName + CreateBucketConfiguration: Optional[CreateBucketConfiguration] + GrantFullControl: Optional[GrantFullControl] + GrantRead: Optional[GrantRead] + GrantReadACP: Optional[GrantReadACP] + GrantWrite: Optional[GrantWrite] + GrantWriteACP: Optional[GrantWriteACP] + ObjectLockEnabledForBucket: Optional[ObjectLockEnabledForBucket] + OutpostId: Optional[NonEmptyMaxLength64String] + + +class CreateBucketResult(TypedDict, total=False): + Location: Optional[Location] + BucketArn: Optional[S3RegionalBucketArn] + + +StorageClassList = List[S3StorageClass] +ObjectSizeLessThanBytes = int +ObjectSizeGreaterThanBytes = int +NonEmptyMaxLength1024StringList = List[NonEmptyMaxLength1024String] + + +class KeyNameConstraint(TypedDict, total=False): + MatchAnyPrefix: Optional[NonEmptyMaxLength1024StringList] + MatchAnySuffix: Optional[NonEmptyMaxLength1024StringList] + MatchAnySubstring: Optional[NonEmptyMaxLength1024StringList] + + +ReplicationStatusFilterList = List[ReplicationStatus] +ObjectCreationTime = datetime + + +class JobManifestGeneratorFilter(TypedDict, total=False): + EligibleForReplication: Optional[Boolean] + CreatedAfter: Optional[ObjectCreationTime] + CreatedBefore: Optional[ObjectCreationTime] + ObjectReplicationStatuses: Optional[ReplicationStatusFilterList] + KeyNameConstraint: Optional[KeyNameConstraint] + ObjectSizeGreaterThanBytes: Optional[ObjectSizeGreaterThanBytes] + ObjectSizeLessThanBytes: Optional[ObjectSizeLessThanBytes] + MatchAnyStorageClass: Optional[StorageClassList] + + +class SSEKMSEncryption(TypedDict, total=False): + KeyId: KmsKeyArnString + + +class SSES3Encryption(TypedDict, total=False): + pass + + +class GeneratedManifestEncryption(TypedDict, total=False): + SSES3: Optional[SSES3Encryption] + SSEKMS: Optional[SSEKMSEncryption] + + +class S3ManifestOutputLocation(TypedDict, total=False): + ExpectedManifestBucketOwner: Optional[AccountId] + Bucket: S3BucketArnString + ManifestPrefix: Optional[ManifestPrefixString] + ManifestEncryption: Optional[GeneratedManifestEncryption] + ManifestFormat: GeneratedManifestFormat + + +class S3JobManifestGenerator(TypedDict, total=False): + ExpectedBucketOwner: Optional[AccountId] + SourceBucket: S3BucketArnString + ManifestOutputLocation: Optional[S3ManifestOutputLocation] + Filter: Optional[JobManifestGeneratorFilter] + EnableManifestOutput: Boolean + + +class JobManifestGenerator(TypedDict, total=False): + S3JobManifestGenerator: Optional[S3JobManifestGenerator] + + +class S3Tag(TypedDict, total=False): + Key: TagKeyString + Value: TagValueString + + +S3TagSet = List[S3Tag] + + +class JobManifestLocation(TypedDict, total=False): + ObjectArn: S3KeyArnString + ObjectVersionId: Optional[S3ObjectVersionId] + ETag: NonEmptyMaxLength1024String + + +JobManifestFieldList = List[JobManifestFieldName] + + +class JobManifestSpec(TypedDict, total=False): + Format: JobManifestFormat + Fields: Optional[JobManifestFieldList] + + +class JobManifest(TypedDict, total=False): + Spec: JobManifestSpec + Location: JobManifestLocation + + +class JobReport(TypedDict, total=False): + Bucket: Optional[S3BucketArnString] + Format: Optional[JobReportFormat] + Enabled: Boolean + Prefix: Optional[ReportPrefixString] + ReportScope: Optional[JobReportScope] + + +class S3ReplicateObjectOperation(TypedDict, total=False): + pass + + +TimeStamp = datetime + + +class S3Retention(TypedDict, total=False): + RetainUntilDate: Optional[TimeStamp] + Mode: Optional[S3ObjectLockRetentionMode] + + +class S3SetObjectRetentionOperation(TypedDict, total=False): + BypassGovernanceRetention: Optional[Boolean] + Retention: S3Retention + + +class S3ObjectLockLegalHold(TypedDict, total=False): + Status: S3ObjectLockLegalHoldStatus + + +class S3SetObjectLegalHoldOperation(TypedDict, total=False): + LegalHold: S3ObjectLockLegalHold + + +class S3InitiateRestoreObjectOperation(TypedDict, total=False): + ExpirationInDays: Optional[S3ExpirationInDays] + GlacierJobTier: Optional[S3GlacierJobTier] + + +class S3DeleteObjectTaggingOperation(TypedDict, total=False): + pass + + +class S3SetObjectTaggingOperation(TypedDict, total=False): + TagSet: Optional[S3TagSet] + + +class S3Grantee(TypedDict, total=False): + TypeIdentifier: Optional[S3GranteeTypeIdentifier] + Identifier: Optional[NonEmptyMaxLength1024String] + DisplayName: Optional[NonEmptyMaxLength1024String] + + +class S3Grant(TypedDict, total=False): + Grantee: Optional[S3Grantee] + Permission: Optional[S3Permission] + + +S3GrantList = List[S3Grant] + + +class S3ObjectOwner(TypedDict, total=False): + ID: Optional[NonEmptyMaxLength1024String] + DisplayName: Optional[NonEmptyMaxLength1024String] + + +class S3AccessControlList(TypedDict, total=False): + Owner: S3ObjectOwner + Grants: Optional[S3GrantList] + + +class S3AccessControlPolicy(TypedDict, total=False): + AccessControlList: Optional[S3AccessControlList] + CannedAccessControlList: Optional[S3CannedAccessControlList] + + +class S3SetObjectAclOperation(TypedDict, total=False): + AccessControlPolicy: Optional[S3AccessControlPolicy] + + +S3ContentLength = int +S3UserMetadata = Dict[NonEmptyMaxLength1024String, MaxLength1024String] + + +class S3ObjectMetadata(TypedDict, total=False): + CacheControl: Optional[NonEmptyMaxLength1024String] + ContentDisposition: Optional[NonEmptyMaxLength1024String] + ContentEncoding: Optional[NonEmptyMaxLength1024String] + ContentLanguage: Optional[NonEmptyMaxLength1024String] + UserMetadata: Optional[S3UserMetadata] + ContentLength: Optional[S3ContentLength] + ContentMD5: Optional[NonEmptyMaxLength1024String] + ContentType: Optional[NonEmptyMaxLength1024String] + HttpExpiresDate: Optional[TimeStamp] + RequesterCharged: Optional[Boolean] + SSEAlgorithm: Optional[S3SSEAlgorithm] + + +class S3CopyObjectOperation(TypedDict, total=False): + TargetResource: Optional[S3RegionalOrS3ExpressBucketArnString] + CannedAccessControlList: Optional[S3CannedAccessControlList] + AccessControlGrants: Optional[S3GrantList] + MetadataDirective: Optional[S3MetadataDirective] + ModifiedSinceConstraint: Optional[TimeStamp] + NewObjectMetadata: Optional[S3ObjectMetadata] + NewObjectTagging: Optional[S3TagSet] + RedirectLocation: Optional[NonEmptyMaxLength2048String] + RequesterPays: Optional[Boolean] + StorageClass: Optional[S3StorageClass] + UnModifiedSinceConstraint: Optional[TimeStamp] + SSEAwsKmsKeyId: Optional[KmsKeyArnString] + TargetKeyPrefix: Optional[NonEmptyMaxLength1024String] + ObjectLockLegalHoldStatus: Optional[S3ObjectLockLegalHoldStatus] + ObjectLockMode: Optional[S3ObjectLockMode] + ObjectLockRetainUntilDate: Optional[TimeStamp] + BucketKeyEnabled: Optional[Boolean] + ChecksumAlgorithm: Optional[S3ChecksumAlgorithm] + + +UserArguments = Dict[NonEmptyMaxLength64String, MaxLength1024String] + + +class LambdaInvokeOperation(TypedDict, total=False): + FunctionArn: Optional[FunctionArnString] + InvocationSchemaVersion: Optional[NonEmptyMaxLength64String] + UserArguments: Optional[UserArguments] + + +class JobOperation(TypedDict, total=False): + LambdaInvoke: Optional[LambdaInvokeOperation] + S3PutObjectCopy: Optional[S3CopyObjectOperation] + S3PutObjectAcl: Optional[S3SetObjectAclOperation] + S3PutObjectTagging: Optional[S3SetObjectTaggingOperation] + S3DeleteObjectTagging: Optional[S3DeleteObjectTaggingOperation] + S3InitiateRestoreObject: Optional[S3InitiateRestoreObjectOperation] + S3PutObjectLegalHold: Optional[S3SetObjectLegalHoldOperation] + S3PutObjectRetention: Optional[S3SetObjectRetentionOperation] + S3ReplicateObject: Optional[S3ReplicateObjectOperation] + + +class CreateJobRequest(ServiceRequest): + AccountId: AccountId + ConfirmationRequired: Optional[ConfirmationRequired] + Operation: JobOperation + Report: JobReport + ClientRequestToken: NonEmptyMaxLength64String + Manifest: Optional[JobManifest] + Description: Optional[NonEmptyMaxLength256String] + Priority: JobPriority + RoleArn: IAMRoleArn + Tags: Optional[S3TagSet] + ManifestGenerator: Optional[JobManifestGenerator] + + +class CreateJobResult(TypedDict, total=False): + JobId: Optional[JobId] + + +class CreateMultiRegionAccessPointRequest(ServiceRequest): + AccountId: AccountId + ClientToken: MultiRegionAccessPointClientToken + Details: CreateMultiRegionAccessPointInput + + +class CreateMultiRegionAccessPointResult(TypedDict, total=False): + RequestTokenARN: Optional[AsyncRequestTokenARN] + + +ObjectSizeValue = int + + +class MatchObjectSize(TypedDict, total=False): + BytesGreaterThan: Optional[ObjectSizeValue] + BytesLessThan: Optional[ObjectSizeValue] + + +class MatchObjectAge(TypedDict, total=False): + DaysGreaterThan: Optional[ObjectAgeValue] + DaysLessThan: Optional[ObjectAgeValue] + + +MatchAnyTag = List[S3Tag] +MatchAnySuffix = List[Suffix] +MatchAnyPrefix = List[Prefix] + + +class StorageLensGroupOrOperator(TypedDict, total=False): + MatchAnyPrefix: Optional[MatchAnyPrefix] + MatchAnySuffix: Optional[MatchAnySuffix] + MatchAnyTag: Optional[MatchAnyTag] + MatchObjectAge: Optional[MatchObjectAge] + MatchObjectSize: Optional[MatchObjectSize] + + +class StorageLensGroupAndOperator(TypedDict, total=False): + MatchAnyPrefix: Optional[MatchAnyPrefix] + MatchAnySuffix: Optional[MatchAnySuffix] + MatchAnyTag: Optional[MatchAnyTag] + MatchObjectAge: Optional[MatchObjectAge] + MatchObjectSize: Optional[MatchObjectSize] + + +class StorageLensGroupFilter(TypedDict, total=False): + MatchAnyPrefix: Optional[MatchAnyPrefix] + MatchAnySuffix: Optional[MatchAnySuffix] + MatchAnyTag: Optional[MatchAnyTag] + MatchObjectAge: Optional[MatchObjectAge] + MatchObjectSize: Optional[MatchObjectSize] + And: Optional[StorageLensGroupAndOperator] + Or: Optional[StorageLensGroupOrOperator] + + +class StorageLensGroup(TypedDict, total=False): + Name: StorageLensGroupName + Filter: StorageLensGroupFilter + StorageLensGroupArn: Optional[StorageLensGroupArn] + + +class CreateStorageLensGroupRequest(ServiceRequest): + AccountId: AccountId + StorageLensGroup: StorageLensGroup + Tags: Optional[TagList] + + +CreationDate = datetime +Expiration = datetime + + +class Credentials(TypedDict, total=False): + AccessKeyId: Optional[AccessKeyId] + SecretAccessKey: Optional[SecretAccessKey] + SessionToken: Optional[SessionToken] + Expiration: Optional[Expiration] + + +Date = datetime + + +class DeleteAccessGrantRequest(ServiceRequest): + AccountId: AccountId + AccessGrantId: AccessGrantId + + +class DeleteAccessGrantsInstanceRequest(ServiceRequest): + AccountId: AccountId + + +class DeleteAccessGrantsInstanceResourcePolicyRequest(ServiceRequest): + AccountId: AccountId + + +class DeleteAccessGrantsLocationRequest(ServiceRequest): + AccountId: AccountId + AccessGrantsLocationId: AccessGrantsLocationId + + +class DeleteAccessPointForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + + +class DeleteAccessPointPolicyForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + + +class DeleteAccessPointPolicyRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + + +class DeleteAccessPointRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + + +class DeleteAccessPointScopeRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + + +class DeleteBucketLifecycleConfigurationRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class DeleteBucketPolicyRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class DeleteBucketReplicationRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class DeleteBucketRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class DeleteBucketTaggingRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class DeleteJobTaggingRequest(ServiceRequest): + AccountId: AccountId + JobId: JobId + + +class DeleteJobTaggingResult(TypedDict, total=False): + pass + + +class DeleteMarkerReplication(TypedDict, total=False): + Status: DeleteMarkerReplicationStatus + + +class DeleteMultiRegionAccessPointRequest(ServiceRequest): + AccountId: AccountId + ClientToken: MultiRegionAccessPointClientToken + Details: DeleteMultiRegionAccessPointInput + + +class DeleteMultiRegionAccessPointResult(TypedDict, total=False): + RequestTokenARN: Optional[AsyncRequestTokenARN] + + +class DeletePublicAccessBlockRequest(ServiceRequest): + AccountId: AccountId + + +class DeleteStorageLensConfigurationRequest(ServiceRequest): + ConfigId: ConfigId + AccountId: AccountId + + +class DeleteStorageLensConfigurationTaggingRequest(ServiceRequest): + ConfigId: ConfigId + AccountId: AccountId + + +class DeleteStorageLensConfigurationTaggingResult(TypedDict, total=False): + pass + + +class DeleteStorageLensGroupRequest(ServiceRequest): + Name: StorageLensGroupName + AccountId: AccountId + + +class DescribeJobRequest(ServiceRequest): + AccountId: AccountId + JobId: JobId + + +class S3GeneratedManifestDescriptor(TypedDict, total=False): + Format: Optional[GeneratedManifestFormat] + Location: Optional[JobManifestLocation] + + +SuspendedDate = datetime +JobTerminationDate = datetime +JobCreationTime = datetime + + +class JobFailure(TypedDict, total=False): + FailureCode: Optional[JobFailureCode] + FailureReason: Optional[JobFailureReason] + + +JobFailureList = List[JobFailure] +JobTimeInStateSeconds = int + + +class JobTimers(TypedDict, total=False): + ElapsedTimeInActiveSeconds: Optional[JobTimeInStateSeconds] + + +JobNumberOfTasksFailed = int +JobNumberOfTasksSucceeded = int +JobTotalNumberOfTasks = int + + +class JobProgressSummary(TypedDict, total=False): + TotalNumberOfTasks: Optional[JobTotalNumberOfTasks] + NumberOfTasksSucceeded: Optional[JobNumberOfTasksSucceeded] + NumberOfTasksFailed: Optional[JobNumberOfTasksFailed] + Timers: Optional[JobTimers] + + +class JobDescriptor(TypedDict, total=False): + JobId: Optional[JobId] + ConfirmationRequired: Optional[ConfirmationRequired] + Description: Optional[NonEmptyMaxLength256String] + JobArn: Optional[JobArn] + Status: Optional[JobStatus] + Manifest: Optional[JobManifest] + Operation: Optional[JobOperation] + Priority: Optional[JobPriority] + ProgressSummary: Optional[JobProgressSummary] + StatusUpdateReason: Optional[JobStatusUpdateReason] + FailureReasons: Optional[JobFailureList] + Report: Optional[JobReport] + CreationTime: Optional[JobCreationTime] + TerminationDate: Optional[JobTerminationDate] + RoleArn: Optional[IAMRoleArn] + SuspendedDate: Optional[SuspendedDate] + SuspendedCause: Optional[SuspendedCause] + ManifestGenerator: Optional[JobManifestGenerator] + GeneratedManifestDescriptor: Optional[S3GeneratedManifestDescriptor] + + +class DescribeJobResult(TypedDict, total=False): + Job: Optional[JobDescriptor] + + +class DescribeMultiRegionAccessPointOperationRequest(ServiceRequest): + AccountId: AccountId + RequestTokenARN: AsyncRequestTokenARN + + +class DescribeMultiRegionAccessPointOperationResult(TypedDict, total=False): + AsyncOperation: Optional[AsyncOperation] + + +class ReplicationTimeValue(TypedDict, total=False): + Minutes: Optional[Minutes] + + +class Metrics(TypedDict, total=False): + Status: MetricsStatus + EventThreshold: Optional[ReplicationTimeValue] + + +class EncryptionConfiguration(TypedDict, total=False): + ReplicaKmsKeyID: Optional[ReplicaKmsKeyID] + + +class ReplicationTime(TypedDict, total=False): + Status: ReplicationTimeStatus + Time: ReplicationTimeValue + + +class Destination(TypedDict, total=False): + Account: Optional[AccountId] + Bucket: BucketIdentifierString + ReplicationTime: Optional[ReplicationTime] + AccessControlTranslation: Optional[AccessControlTranslation] + EncryptionConfiguration: Optional[EncryptionConfiguration] + Metrics: Optional[Metrics] + StorageClass: Optional[ReplicationStorageClass] + + +class DissociateAccessGrantsIdentityCenterRequest(ServiceRequest): + AccountId: AccountId + + +Endpoints = Dict[NonEmptyMaxLength64String, NonEmptyMaxLength1024String] + + +class EstablishedMultiRegionAccessPointPolicy(TypedDict, total=False): + Policy: Optional[Policy] + + +Regions = List[S3AWSRegion] + + +class Exclude(TypedDict, total=False): + Buckets: Optional[Buckets] + Regions: Optional[Regions] + + +class ExistingObjectReplication(TypedDict, total=False): + Status: ExistingObjectReplicationStatus + + +class GetAccessGrantRequest(ServiceRequest): + AccountId: AccountId + AccessGrantId: AccessGrantId + + +class GetAccessGrantResult(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantId: Optional[AccessGrantId] + AccessGrantArn: Optional[AccessGrantArn] + Grantee: Optional[Grantee] + Permission: Optional[Permission] + AccessGrantsLocationId: Optional[AccessGrantsLocationId] + AccessGrantsLocationConfiguration: Optional[AccessGrantsLocationConfiguration] + GrantScope: Optional[S3Prefix] + ApplicationArn: Optional[IdentityCenterApplicationArn] + + +class GetAccessGrantsInstanceForPrefixRequest(ServiceRequest): + AccountId: AccountId + S3Prefix: S3Prefix + + +class GetAccessGrantsInstanceForPrefixResult(TypedDict, total=False): + AccessGrantsInstanceArn: Optional[AccessGrantsInstanceArn] + AccessGrantsInstanceId: Optional[AccessGrantsInstanceId] + + +class GetAccessGrantsInstanceRequest(ServiceRequest): + AccountId: AccountId + + +class GetAccessGrantsInstanceResourcePolicyRequest(ServiceRequest): + AccountId: AccountId + + +class GetAccessGrantsInstanceResourcePolicyResult(TypedDict, total=False): + Policy: Optional[PolicyDocument] + Organization: Optional[Organization] + CreatedAt: Optional[CreationTimestamp] + + +class GetAccessGrantsInstanceResult(TypedDict, total=False): + AccessGrantsInstanceArn: Optional[AccessGrantsInstanceArn] + AccessGrantsInstanceId: Optional[AccessGrantsInstanceId] + IdentityCenterArn: Optional[IdentityCenterArn] + IdentityCenterInstanceArn: Optional[IdentityCenterArn] + IdentityCenterApplicationArn: Optional[IdentityCenterApplicationArn] + CreatedAt: Optional[CreationTimestamp] + + +class GetAccessGrantsLocationRequest(ServiceRequest): + AccountId: AccountId + AccessGrantsLocationId: AccessGrantsLocationId + + +class GetAccessGrantsLocationResult(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantsLocationId: Optional[AccessGrantsLocationId] + AccessGrantsLocationArn: Optional[AccessGrantsLocationArn] + LocationScope: Optional[S3Prefix] + IAMRoleArn: Optional[IAMRoleArn] + + +class GetAccessPointConfigurationForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + + +class GetAccessPointConfigurationForObjectLambdaResult(TypedDict, total=False): + Configuration: Optional[ObjectLambdaConfiguration] + + +class GetAccessPointForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + + +class GetAccessPointForObjectLambdaResult(TypedDict, total=False): + Name: Optional[ObjectLambdaAccessPointName] + PublicAccessBlockConfiguration: Optional[PublicAccessBlockConfiguration] + CreationDate: Optional[CreationDate] + Alias: Optional[ObjectLambdaAccessPointAlias] + + +class GetAccessPointPolicyForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + + +class GetAccessPointPolicyForObjectLambdaResult(TypedDict, total=False): + Policy: Optional[ObjectLambdaPolicy] + + +class GetAccessPointPolicyRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + + +class GetAccessPointPolicyResult(TypedDict, total=False): + Policy: Optional[Policy] + + +class GetAccessPointPolicyStatusForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + + +class PolicyStatus(TypedDict, total=False): + IsPublic: Optional[IsPublic] + + +class GetAccessPointPolicyStatusForObjectLambdaResult(TypedDict, total=False): + PolicyStatus: Optional[PolicyStatus] + + +class GetAccessPointPolicyStatusRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + + +class GetAccessPointPolicyStatusResult(TypedDict, total=False): + PolicyStatus: Optional[PolicyStatus] + + +class GetAccessPointRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + + +class GetAccessPointResult(TypedDict, total=False): + Name: Optional[AccessPointName] + Bucket: Optional[AccessPointBucketName] + NetworkOrigin: Optional[NetworkOrigin] + VpcConfiguration: Optional[VpcConfiguration] + PublicAccessBlockConfiguration: Optional[PublicAccessBlockConfiguration] + CreationDate: Optional[CreationDate] + Alias: Optional[Alias] + AccessPointArn: Optional[S3AccessPointArn] + Endpoints: Optional[Endpoints] + BucketAccountId: Optional[AccountId] + DataSourceId: Optional[DataSourceId] + DataSourceType: Optional[DataSourceType] + + +class GetAccessPointScopeRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + + +class GetAccessPointScopeResult(TypedDict, total=False): + Scope: Optional[Scope] + + +class GetBucketLifecycleConfigurationRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class NoncurrentVersionExpiration(TypedDict, total=False): + NoncurrentDays: Optional[Days] + NewerNoncurrentVersions: Optional[NoncurrentVersionCount] + + +class NoncurrentVersionTransition(TypedDict, total=False): + NoncurrentDays: Optional[Days] + StorageClass: Optional[TransitionStorageClass] + + +NoncurrentVersionTransitionList = List[NoncurrentVersionTransition] + + +class Transition(TypedDict, total=False): + Date: Optional[Date] + Days: Optional[Days] + StorageClass: Optional[TransitionStorageClass] + + +TransitionList = List[Transition] + + +class LifecycleRuleAndOperator(TypedDict, total=False): + Prefix: Optional[Prefix] + Tags: Optional[S3TagSet] + ObjectSizeGreaterThan: Optional[ObjectSizeGreaterThanBytes] + ObjectSizeLessThan: Optional[ObjectSizeLessThanBytes] + + +class LifecycleRuleFilter(TypedDict, total=False): + Prefix: Optional[Prefix] + Tag: Optional[S3Tag] + And: Optional[LifecycleRuleAndOperator] + ObjectSizeGreaterThan: Optional[ObjectSizeGreaterThanBytes] + ObjectSizeLessThan: Optional[ObjectSizeLessThanBytes] + + +class LifecycleExpiration(TypedDict, total=False): + Date: Optional[Date] + Days: Optional[Days] + ExpiredObjectDeleteMarker: Optional[ExpiredObjectDeleteMarker] + + +class LifecycleRule(TypedDict, total=False): + Expiration: Optional[LifecycleExpiration] + ID: Optional[ID] + Filter: Optional[LifecycleRuleFilter] + Status: ExpirationStatus + Transitions: Optional[TransitionList] + NoncurrentVersionTransitions: Optional[NoncurrentVersionTransitionList] + NoncurrentVersionExpiration: Optional[NoncurrentVersionExpiration] + AbortIncompleteMultipartUpload: Optional[AbortIncompleteMultipartUpload] + + +LifecycleRules = List[LifecycleRule] + + +class GetBucketLifecycleConfigurationResult(TypedDict, total=False): + Rules: Optional[LifecycleRules] + + +class GetBucketPolicyRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class GetBucketPolicyResult(TypedDict, total=False): + Policy: Optional[Policy] + + +class GetBucketReplicationRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class ReplicaModifications(TypedDict, total=False): + Status: ReplicaModificationsStatus + + +class SseKmsEncryptedObjects(TypedDict, total=False): + Status: SseKmsEncryptedObjectsStatus + + +class SourceSelectionCriteria(TypedDict, total=False): + SseKmsEncryptedObjects: Optional[SseKmsEncryptedObjects] + ReplicaModifications: Optional[ReplicaModifications] + + +class ReplicationRuleAndOperator(TypedDict, total=False): + Prefix: Optional[Prefix] + Tags: Optional[S3TagSet] + + +class ReplicationRuleFilter(TypedDict, total=False): + Prefix: Optional[Prefix] + Tag: Optional[S3Tag] + And: Optional[ReplicationRuleAndOperator] + + +class ReplicationRule(TypedDict, total=False): + ID: Optional[ID] + Priority: Optional[Priority] + Prefix: Optional[Prefix] + Filter: Optional[ReplicationRuleFilter] + Status: ReplicationRuleStatus + SourceSelectionCriteria: Optional[SourceSelectionCriteria] + ExistingObjectReplication: Optional[ExistingObjectReplication] + Destination: Destination + DeleteMarkerReplication: Optional[DeleteMarkerReplication] + Bucket: BucketIdentifierString + + +ReplicationRules = List[ReplicationRule] + + +class ReplicationConfiguration(TypedDict, total=False): + Role: Role + Rules: ReplicationRules + + +class GetBucketReplicationResult(TypedDict, total=False): + ReplicationConfiguration: Optional[ReplicationConfiguration] + + +class GetBucketRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class GetBucketResult(TypedDict, total=False): + Bucket: Optional[BucketName] + PublicAccessBlockEnabled: Optional[PublicAccessBlockEnabled] + CreationDate: Optional[CreationDate] + + +class GetBucketTaggingRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class GetBucketTaggingResult(TypedDict, total=False): + TagSet: S3TagSet + + +class GetBucketVersioningRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + + +class GetBucketVersioningResult(TypedDict, total=False): + Status: Optional[BucketVersioningStatus] + MFADelete: Optional[MFADeleteStatus] + + +class GetDataAccessRequest(ServiceRequest): + AccountId: AccountId + Target: S3Prefix + Permission: Permission + DurationSeconds: Optional[DurationSeconds] + Privilege: Optional[Privilege] + TargetType: Optional[S3PrefixType] + + +class GetDataAccessResult(TypedDict, total=False): + Credentials: Optional[Credentials] + MatchedGrantTarget: Optional[S3Prefix] + Grantee: Optional[Grantee] + + +class GetJobTaggingRequest(ServiceRequest): + AccountId: AccountId + JobId: JobId + + +class GetJobTaggingResult(TypedDict, total=False): + Tags: Optional[S3TagSet] + + +class GetMultiRegionAccessPointPolicyRequest(ServiceRequest): + AccountId: AccountId + Name: MultiRegionAccessPointName + + +class ProposedMultiRegionAccessPointPolicy(TypedDict, total=False): + Policy: Optional[Policy] + + +class MultiRegionAccessPointPolicyDocument(TypedDict, total=False): + Established: Optional[EstablishedMultiRegionAccessPointPolicy] + Proposed: Optional[ProposedMultiRegionAccessPointPolicy] + + +class GetMultiRegionAccessPointPolicyResult(TypedDict, total=False): + Policy: Optional[MultiRegionAccessPointPolicyDocument] + + +class GetMultiRegionAccessPointPolicyStatusRequest(ServiceRequest): + AccountId: AccountId + Name: MultiRegionAccessPointName + + +class GetMultiRegionAccessPointPolicyStatusResult(TypedDict, total=False): + Established: Optional[PolicyStatus] + + +class GetMultiRegionAccessPointRequest(ServiceRequest): + AccountId: AccountId + Name: MultiRegionAccessPointName + + +class RegionReport(TypedDict, total=False): + Bucket: Optional[BucketName] + Region: Optional[RegionName] + BucketAccountId: Optional[AccountId] + + +RegionReportList = List[RegionReport] + + +class MultiRegionAccessPointReport(TypedDict, total=False): + Name: Optional[MultiRegionAccessPointName] + Alias: Optional[MultiRegionAccessPointAlias] + CreatedAt: Optional[CreationTimestamp] + PublicAccessBlock: Optional[PublicAccessBlockConfiguration] + Status: Optional[MultiRegionAccessPointStatus] + Regions: Optional[RegionReportList] + + +class GetMultiRegionAccessPointResult(TypedDict, total=False): + AccessPoint: Optional[MultiRegionAccessPointReport] + + +class GetMultiRegionAccessPointRoutesRequest(ServiceRequest): + AccountId: AccountId + Mrap: MultiRegionAccessPointId + + +class MultiRegionAccessPointRoute(TypedDict, total=False): + Bucket: Optional[BucketName] + Region: Optional[RegionName] + TrafficDialPercentage: TrafficDialPercentage + + +RouteList = List[MultiRegionAccessPointRoute] + + +class GetMultiRegionAccessPointRoutesResult(TypedDict, total=False): + Mrap: Optional[MultiRegionAccessPointId] + Routes: Optional[RouteList] + + +class GetPublicAccessBlockOutput(TypedDict, total=False): + PublicAccessBlockConfiguration: Optional[PublicAccessBlockConfiguration] + + +class GetPublicAccessBlockRequest(ServiceRequest): + AccountId: AccountId + + +class GetStorageLensConfigurationRequest(ServiceRequest): + ConfigId: ConfigId + AccountId: AccountId + + +class StorageLensAwsOrg(TypedDict, total=False): + Arn: AwsOrgArn + + +class SSEKMS(TypedDict, total=False): + KeyId: SSEKMSKeyId + + +class SSES3(TypedDict, total=False): + pass + + +class StorageLensDataExportEncryption(TypedDict, total=False): + SSES3: Optional[SSES3] + SSEKMS: Optional[SSEKMS] + + +class S3BucketDestination(TypedDict, total=False): + Format: Format + OutputSchemaVersion: OutputSchemaVersion + AccountId: AccountId + Arn: S3BucketArnString + Prefix: Optional[Prefix] + Encryption: Optional[StorageLensDataExportEncryption] + + +class StorageLensDataExport(TypedDict, total=False): + S3BucketDestination: Optional[S3BucketDestination] + CloudWatchMetrics: Optional[CloudWatchMetrics] + + +class Include(TypedDict, total=False): + Buckets: Optional[Buckets] + Regions: Optional[Regions] + + +class StorageLensConfiguration(TypedDict, total=False): + Id: ConfigId + AccountLevel: AccountLevel + Include: Optional[Include] + Exclude: Optional[Exclude] + DataExport: Optional[StorageLensDataExport] + IsEnabled: IsEnabled + AwsOrg: Optional[StorageLensAwsOrg] + StorageLensArn: Optional[StorageLensArn] + + +class GetStorageLensConfigurationResult(TypedDict, total=False): + StorageLensConfiguration: Optional[StorageLensConfiguration] + + +class GetStorageLensConfigurationTaggingRequest(ServiceRequest): + ConfigId: ConfigId + AccountId: AccountId + + +class StorageLensTag(TypedDict, total=False): + Key: TagKeyString + Value: TagValueString + + +StorageLensTags = List[StorageLensTag] + + +class GetStorageLensConfigurationTaggingResult(TypedDict, total=False): + Tags: Optional[StorageLensTags] + + +class GetStorageLensGroupRequest(ServiceRequest): + Name: StorageLensGroupName + AccountId: AccountId + + +class GetStorageLensGroupResult(TypedDict, total=False): + StorageLensGroup: Optional[StorageLensGroup] + + +class JobListDescriptor(TypedDict, total=False): + JobId: Optional[JobId] + Description: Optional[NonEmptyMaxLength256String] + Operation: Optional[OperationName] + Priority: Optional[JobPriority] + Status: Optional[JobStatus] + CreationTime: Optional[JobCreationTime] + TerminationDate: Optional[JobTerminationDate] + ProgressSummary: Optional[JobProgressSummary] + + +JobListDescriptorList = List[JobListDescriptor] +JobStatusList = List[JobStatus] + + +class LifecycleConfiguration(TypedDict, total=False): + Rules: Optional[LifecycleRules] + + +class ListAccessGrantsInstancesRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[ContinuationToken] + MaxResults: Optional[MaxResults] + + +class ListAccessGrantsInstancesResult(TypedDict, total=False): + NextToken: Optional[ContinuationToken] + AccessGrantsInstancesList: Optional[AccessGrantsInstancesList] + + +class ListAccessGrantsLocationsRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[ContinuationToken] + MaxResults: Optional[MaxResults] + LocationScope: Optional[S3Prefix] + + +class ListAccessGrantsLocationsResult(TypedDict, total=False): + NextToken: Optional[ContinuationToken] + AccessGrantsLocationsList: Optional[AccessGrantsLocationsList] + + +class ListAccessGrantsRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[ContinuationToken] + MaxResults: Optional[MaxResults] + GranteeType: Optional[GranteeType] + GranteeIdentifier: Optional[GranteeIdentifier] + Permission: Optional[Permission] + GrantScope: Optional[S3Prefix] + ApplicationArn: Optional[IdentityCenterApplicationArn] + + +class ListAccessGrantsResult(TypedDict, total=False): + NextToken: Optional[ContinuationToken] + AccessGrantsList: Optional[AccessGrantsList] + + +class ListAccessPointsForDirectoryBucketsRequest(ServiceRequest): + AccountId: AccountId + DirectoryBucket: Optional[BucketName] + NextToken: Optional[NonEmptyMaxLength1024String] + MaxResults: Optional[MaxResults] + + +class ListAccessPointsForDirectoryBucketsResult(TypedDict, total=False): + AccessPointList: Optional[AccessPointList] + NextToken: Optional[NonEmptyMaxLength1024String] + + +class ListAccessPointsForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[NonEmptyMaxLength1024String] + MaxResults: Optional[MaxResults] + + +class ObjectLambdaAccessPoint(TypedDict, total=False): + Name: ObjectLambdaAccessPointName + ObjectLambdaAccessPointArn: Optional[ObjectLambdaAccessPointArn] + Alias: Optional[ObjectLambdaAccessPointAlias] + + +ObjectLambdaAccessPointList = List[ObjectLambdaAccessPoint] + + +class ListAccessPointsForObjectLambdaResult(TypedDict, total=False): + ObjectLambdaAccessPointList: Optional[ObjectLambdaAccessPointList] + NextToken: Optional[NonEmptyMaxLength1024String] + + +class ListAccessPointsRequest(ServiceRequest): + AccountId: AccountId + Bucket: Optional[BucketName] + NextToken: Optional[NonEmptyMaxLength1024String] + MaxResults: Optional[MaxResults] + DataSourceId: Optional[DataSourceId] + DataSourceType: Optional[DataSourceType] + + +class ListAccessPointsResult(TypedDict, total=False): + AccessPointList: Optional[AccessPointList] + NextToken: Optional[NonEmptyMaxLength1024String] + + +class ListCallerAccessGrantsRequest(ServiceRequest): + AccountId: AccountId + GrantScope: Optional[S3Prefix] + NextToken: Optional[ContinuationToken] + MaxResults: Optional[MaxResults] + AllowedByApplication: Optional[Boolean] + + +class ListCallerAccessGrantsResult(TypedDict, total=False): + NextToken: Optional[ContinuationToken] + CallerAccessGrantsList: Optional[CallerAccessGrantsList] + + +class ListJobsRequest(ServiceRequest): + AccountId: AccountId + JobStatuses: Optional[JobStatusList] + NextToken: Optional[StringForNextToken] + MaxResults: Optional[MaxResults] + + +class ListJobsResult(TypedDict, total=False): + NextToken: Optional[StringForNextToken] + Jobs: Optional[JobListDescriptorList] + + +class ListMultiRegionAccessPointsRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[NonEmptyMaxLength1024String] + MaxResults: Optional[MaxResults] + + +MultiRegionAccessPointReportList = List[MultiRegionAccessPointReport] + + +class ListMultiRegionAccessPointsResult(TypedDict, total=False): + AccessPoints: Optional[MultiRegionAccessPointReportList] + NextToken: Optional[NonEmptyMaxLength1024String] + + +class ListRegionalBucketsRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[NonEmptyMaxLength1024String] + MaxResults: Optional[MaxResults] + OutpostId: Optional[NonEmptyMaxLength64String] + + +class RegionalBucket(TypedDict, total=False): + Bucket: BucketName + BucketArn: Optional[S3RegionalBucketArn] + PublicAccessBlockEnabled: PublicAccessBlockEnabled + CreationDate: CreationDate + OutpostId: Optional[NonEmptyMaxLength64String] + + +RegionalBucketList = List[RegionalBucket] + + +class ListRegionalBucketsResult(TypedDict, total=False): + RegionalBucketList: Optional[RegionalBucketList] + NextToken: Optional[NonEmptyMaxLength1024String] + + +class ListStorageLensConfigurationEntry(TypedDict, total=False): + Id: ConfigId + StorageLensArn: StorageLensArn + HomeRegion: S3AWSRegion + IsEnabled: Optional[IsEnabled] + + +class ListStorageLensConfigurationsRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[ContinuationToken] + + +StorageLensConfigurationList = List[ListStorageLensConfigurationEntry] + + +class ListStorageLensConfigurationsResult(TypedDict, total=False): + NextToken: Optional[ContinuationToken] + StorageLensConfigurationList: Optional[StorageLensConfigurationList] + + +class ListStorageLensGroupEntry(TypedDict, total=False): + Name: StorageLensGroupName + StorageLensGroupArn: StorageLensGroupArn + HomeRegion: S3AWSRegion + + +class ListStorageLensGroupsRequest(ServiceRequest): + AccountId: AccountId + NextToken: Optional[ContinuationToken] + + +StorageLensGroupList = List[ListStorageLensGroupEntry] + + +class ListStorageLensGroupsResult(TypedDict, total=False): + NextToken: Optional[ContinuationToken] + StorageLensGroupList: Optional[StorageLensGroupList] + + +class ListTagsForResourceRequest(ServiceRequest): + AccountId: AccountId + ResourceArn: S3ResourceArn + + +class ListTagsForResourceResult(TypedDict, total=False): + Tags: Optional[TagList] + + +class PutAccessGrantsInstanceResourcePolicyRequest(ServiceRequest): + AccountId: AccountId + Policy: PolicyDocument + Organization: Optional[Organization] + + +class PutAccessGrantsInstanceResourcePolicyResult(TypedDict, total=False): + Policy: Optional[PolicyDocument] + Organization: Optional[Organization] + CreatedAt: Optional[CreationTimestamp] + + +class PutAccessPointConfigurationForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + Configuration: ObjectLambdaConfiguration + + +class PutAccessPointPolicyForObjectLambdaRequest(ServiceRequest): + AccountId: AccountId + Name: ObjectLambdaAccessPointName + Policy: ObjectLambdaPolicy + + +class PutAccessPointPolicyRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + Policy: Policy + + +class PutAccessPointScopeRequest(ServiceRequest): + AccountId: AccountId + Name: AccessPointName + Scope: Scope + + +class PutBucketLifecycleConfigurationRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + LifecycleConfiguration: Optional[LifecycleConfiguration] + + +class PutBucketPolicyRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + ConfirmRemoveSelfBucketAccess: Optional[ConfirmRemoveSelfBucketAccess] + Policy: Policy + + +class PutBucketReplicationRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + ReplicationConfiguration: ReplicationConfiguration + + +class Tagging(TypedDict, total=False): + TagSet: S3TagSet + + +class PutBucketTaggingRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + Tagging: Tagging + + +class VersioningConfiguration(TypedDict, total=False): + MFADelete: Optional[MFADelete] + Status: Optional[BucketVersioningStatus] + + +class PutBucketVersioningRequest(ServiceRequest): + AccountId: AccountId + Bucket: BucketName + MFA: Optional[MFA] + VersioningConfiguration: VersioningConfiguration + + +class PutJobTaggingRequest(ServiceRequest): + AccountId: AccountId + JobId: JobId + Tags: S3TagSet + + +class PutJobTaggingResult(TypedDict, total=False): + pass + + +class PutMultiRegionAccessPointPolicyRequest(ServiceRequest): + AccountId: AccountId + ClientToken: MultiRegionAccessPointClientToken + Details: PutMultiRegionAccessPointPolicyInput + + +class PutMultiRegionAccessPointPolicyResult(TypedDict, total=False): + RequestTokenARN: Optional[AsyncRequestTokenARN] + + +class PutPublicAccessBlockRequest(ServiceRequest): + PublicAccessBlockConfiguration: PublicAccessBlockConfiguration + AccountId: AccountId + + +class PutStorageLensConfigurationRequest(ServiceRequest): + ConfigId: ConfigId + AccountId: AccountId + StorageLensConfiguration: StorageLensConfiguration + Tags: Optional[StorageLensTags] + + +class PutStorageLensConfigurationTaggingRequest(ServiceRequest): + ConfigId: ConfigId + AccountId: AccountId + Tags: StorageLensTags + + +class PutStorageLensConfigurationTaggingResult(TypedDict, total=False): + pass + + +class SubmitMultiRegionAccessPointRoutesRequest(ServiceRequest): + AccountId: AccountId + Mrap: MultiRegionAccessPointId + RouteUpdates: RouteList + + +class SubmitMultiRegionAccessPointRoutesResult(TypedDict, total=False): + pass + + +TagKeyList = List[TagKeyString] + + +class TagResourceRequest(ServiceRequest): + AccountId: AccountId + ResourceArn: S3ResourceArn + Tags: TagList + + +class TagResourceResult(TypedDict, total=False): + pass + + +class UntagResourceRequest(ServiceRequest): + AccountId: AccountId + ResourceArn: S3ResourceArn + TagKeys: TagKeyList + + +class UntagResourceResult(TypedDict, total=False): + pass + + +class UpdateAccessGrantsLocationRequest(ServiceRequest): + AccountId: AccountId + AccessGrantsLocationId: AccessGrantsLocationId + IAMRoleArn: IAMRoleArn + + +class UpdateAccessGrantsLocationResult(TypedDict, total=False): + CreatedAt: Optional[CreationTimestamp] + AccessGrantsLocationId: Optional[AccessGrantsLocationId] + AccessGrantsLocationArn: Optional[AccessGrantsLocationArn] + LocationScope: Optional[S3Prefix] + IAMRoleArn: Optional[IAMRoleArn] + + +class UpdateJobPriorityRequest(ServiceRequest): + AccountId: AccountId + JobId: JobId + Priority: JobPriority + + +class UpdateJobPriorityResult(TypedDict, total=False): + JobId: JobId + Priority: JobPriority + + +class UpdateJobStatusRequest(ServiceRequest): + AccountId: AccountId + JobId: JobId + RequestedJobStatus: RequestedJobStatus + StatusUpdateReason: Optional[JobStatusUpdateReason] + + +class UpdateJobStatusResult(TypedDict, total=False): + JobId: Optional[JobId] + Status: Optional[JobStatus] + StatusUpdateReason: Optional[JobStatusUpdateReason] + + +class UpdateStorageLensGroupRequest(ServiceRequest): + Name: StorageLensGroupName + AccountId: AccountId + StorageLensGroup: StorageLensGroup + + +class S3ControlApi: + service = "s3control" + version = "2018-08-20" + + @handler("AssociateAccessGrantsIdentityCenter") + def associate_access_grants_identity_center( + self, + context: RequestContext, + account_id: AccountId, + identity_center_arn: IdentityCenterArn, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateAccessGrant") + def create_access_grant( + self, + context: RequestContext, + account_id: AccountId, + access_grants_location_id: AccessGrantsLocationId, + grantee: Grantee, + permission: Permission, + access_grants_location_configuration: AccessGrantsLocationConfiguration | None = None, + application_arn: IdentityCenterApplicationArn | None = None, + s3_prefix_type: S3PrefixType | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateAccessGrantResult: + raise NotImplementedError + + @handler("CreateAccessGrantsInstance") + def create_access_grants_instance( + self, + context: RequestContext, + account_id: AccountId, + identity_center_arn: IdentityCenterArn | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateAccessGrantsInstanceResult: + raise NotImplementedError + + @handler("CreateAccessGrantsLocation") + def create_access_grants_location( + self, + context: RequestContext, + account_id: AccountId, + location_scope: S3Prefix, + iam_role_arn: IAMRoleArn, + tags: TagList | None = None, + **kwargs, + ) -> CreateAccessGrantsLocationResult: + raise NotImplementedError + + @handler("CreateAccessPoint") + def create_access_point( + self, + context: RequestContext, + account_id: AccountId, + name: AccessPointName, + bucket: BucketName, + vpc_configuration: VpcConfiguration | None = None, + public_access_block_configuration: PublicAccessBlockConfiguration | None = None, + bucket_account_id: AccountId | None = None, + scope: Scope | None = None, + **kwargs, + ) -> CreateAccessPointResult: + raise NotImplementedError + + @handler("CreateAccessPointForObjectLambda") + def create_access_point_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + configuration: ObjectLambdaConfiguration, + **kwargs, + ) -> CreateAccessPointForObjectLambdaResult: + raise NotImplementedError + + @handler("CreateBucket") + def create_bucket( + self, + context: RequestContext, + bucket: BucketName, + acl: BucketCannedACL | None = None, + create_bucket_configuration: CreateBucketConfiguration | None = None, + grant_full_control: GrantFullControl | None = None, + grant_read: GrantRead | None = None, + grant_read_acp: GrantReadACP | None = None, + grant_write: GrantWrite | None = None, + grant_write_acp: GrantWriteACP | None = None, + object_lock_enabled_for_bucket: ObjectLockEnabledForBucket | None = None, + outpost_id: NonEmptyMaxLength64String | None = None, + **kwargs, + ) -> CreateBucketResult: + raise NotImplementedError + + @handler("CreateJob") + def create_job( + self, + context: RequestContext, + account_id: AccountId, + operation: JobOperation, + report: JobReport, + client_request_token: NonEmptyMaxLength64String, + priority: JobPriority, + role_arn: IAMRoleArn, + confirmation_required: ConfirmationRequired | None = None, + manifest: JobManifest | None = None, + description: NonEmptyMaxLength256String | None = None, + tags: S3TagSet | None = None, + manifest_generator: JobManifestGenerator | None = None, + **kwargs, + ) -> CreateJobResult: + raise NotImplementedError + + @handler("CreateMultiRegionAccessPoint") + def create_multi_region_access_point( + self, + context: RequestContext, + account_id: AccountId, + client_token: MultiRegionAccessPointClientToken, + details: CreateMultiRegionAccessPointInput, + **kwargs, + ) -> CreateMultiRegionAccessPointResult: + raise NotImplementedError + + @handler("CreateStorageLensGroup") + def create_storage_lens_group( + self, + context: RequestContext, + account_id: AccountId, + storage_lens_group: StorageLensGroup, + tags: TagList | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessGrant") + def delete_access_grant( + self, + context: RequestContext, + account_id: AccountId, + access_grant_id: AccessGrantId, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessGrantsInstance") + def delete_access_grants_instance( + self, context: RequestContext, account_id: AccountId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessGrantsInstanceResourcePolicy") + def delete_access_grants_instance_resource_policy( + self, context: RequestContext, account_id: AccountId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessGrantsLocation") + def delete_access_grants_location( + self, + context: RequestContext, + account_id: AccountId, + access_grants_location_id: AccessGrantsLocationId, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessPoint") + def delete_access_point( + self, context: RequestContext, account_id: AccountId, name: AccessPointName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessPointForObjectLambda") + def delete_access_point_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessPointPolicy") + def delete_access_point_policy( + self, context: RequestContext, account_id: AccountId, name: AccessPointName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessPointPolicyForObjectLambda") + def delete_access_point_policy_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteAccessPointScope") + def delete_access_point_scope( + self, context: RequestContext, account_id: AccountId, name: AccessPointName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteBucket") + def delete_bucket( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketLifecycleConfiguration") + def delete_bucket_lifecycle_configuration( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketPolicy") + def delete_bucket_policy( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketReplication") + def delete_bucket_replication( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteBucketTagging") + def delete_bucket_tagging( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteJobTagging") + def delete_job_tagging( + self, context: RequestContext, account_id: AccountId, job_id: JobId, **kwargs + ) -> DeleteJobTaggingResult: + raise NotImplementedError + + @handler("DeleteMultiRegionAccessPoint") + def delete_multi_region_access_point( + self, + context: RequestContext, + account_id: AccountId, + client_token: MultiRegionAccessPointClientToken, + details: DeleteMultiRegionAccessPointInput, + **kwargs, + ) -> DeleteMultiRegionAccessPointResult: + raise NotImplementedError + + @handler("DeletePublicAccessBlock") + def delete_public_access_block( + self, context: RequestContext, account_id: AccountId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteStorageLensConfiguration") + def delete_storage_lens_configuration( + self, context: RequestContext, config_id: ConfigId, account_id: AccountId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteStorageLensConfigurationTagging") + def delete_storage_lens_configuration_tagging( + self, context: RequestContext, config_id: ConfigId, account_id: AccountId, **kwargs + ) -> DeleteStorageLensConfigurationTaggingResult: + raise NotImplementedError + + @handler("DeleteStorageLensGroup") + def delete_storage_lens_group( + self, context: RequestContext, name: StorageLensGroupName, account_id: AccountId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DescribeJob") + def describe_job( + self, context: RequestContext, account_id: AccountId, job_id: JobId, **kwargs + ) -> DescribeJobResult: + raise NotImplementedError + + @handler("DescribeMultiRegionAccessPointOperation") + def describe_multi_region_access_point_operation( + self, + context: RequestContext, + account_id: AccountId, + request_token_arn: AsyncRequestTokenARN, + **kwargs, + ) -> DescribeMultiRegionAccessPointOperationResult: + raise NotImplementedError + + @handler("DissociateAccessGrantsIdentityCenter") + def dissociate_access_grants_identity_center( + self, context: RequestContext, account_id: AccountId, **kwargs + ) -> None: + raise NotImplementedError + + @handler("GetAccessGrant") + def get_access_grant( + self, + context: RequestContext, + account_id: AccountId, + access_grant_id: AccessGrantId, + **kwargs, + ) -> GetAccessGrantResult: + raise NotImplementedError + + @handler("GetAccessGrantsInstance") + def get_access_grants_instance( + self, context: RequestContext, account_id: AccountId, **kwargs + ) -> GetAccessGrantsInstanceResult: + raise NotImplementedError + + @handler("GetAccessGrantsInstanceForPrefix") + def get_access_grants_instance_for_prefix( + self, context: RequestContext, account_id: AccountId, s3_prefix: S3Prefix, **kwargs + ) -> GetAccessGrantsInstanceForPrefixResult: + raise NotImplementedError + + @handler("GetAccessGrantsInstanceResourcePolicy") + def get_access_grants_instance_resource_policy( + self, context: RequestContext, account_id: AccountId, **kwargs + ) -> GetAccessGrantsInstanceResourcePolicyResult: + raise NotImplementedError + + @handler("GetAccessGrantsLocation") + def get_access_grants_location( + self, + context: RequestContext, + account_id: AccountId, + access_grants_location_id: AccessGrantsLocationId, + **kwargs, + ) -> GetAccessGrantsLocationResult: + raise NotImplementedError + + @handler("GetAccessPoint") + def get_access_point( + self, context: RequestContext, account_id: AccountId, name: AccessPointName, **kwargs + ) -> GetAccessPointResult: + raise NotImplementedError + + @handler("GetAccessPointConfigurationForObjectLambda") + def get_access_point_configuration_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + **kwargs, + ) -> GetAccessPointConfigurationForObjectLambdaResult: + raise NotImplementedError + + @handler("GetAccessPointForObjectLambda") + def get_access_point_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + **kwargs, + ) -> GetAccessPointForObjectLambdaResult: + raise NotImplementedError + + @handler("GetAccessPointPolicy") + def get_access_point_policy( + self, context: RequestContext, account_id: AccountId, name: AccessPointName, **kwargs + ) -> GetAccessPointPolicyResult: + raise NotImplementedError + + @handler("GetAccessPointPolicyForObjectLambda") + def get_access_point_policy_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + **kwargs, + ) -> GetAccessPointPolicyForObjectLambdaResult: + raise NotImplementedError + + @handler("GetAccessPointPolicyStatus") + def get_access_point_policy_status( + self, context: RequestContext, account_id: AccountId, name: AccessPointName, **kwargs + ) -> GetAccessPointPolicyStatusResult: + raise NotImplementedError + + @handler("GetAccessPointPolicyStatusForObjectLambda") + def get_access_point_policy_status_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + **kwargs, + ) -> GetAccessPointPolicyStatusForObjectLambdaResult: + raise NotImplementedError + + @handler("GetAccessPointScope") + def get_access_point_scope( + self, context: RequestContext, account_id: AccountId, name: AccessPointName, **kwargs + ) -> GetAccessPointScopeResult: + raise NotImplementedError + + @handler("GetBucket") + def get_bucket( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> GetBucketResult: + raise NotImplementedError + + @handler("GetBucketLifecycleConfiguration") + def get_bucket_lifecycle_configuration( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> GetBucketLifecycleConfigurationResult: + raise NotImplementedError + + @handler("GetBucketPolicy") + def get_bucket_policy( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> GetBucketPolicyResult: + raise NotImplementedError + + @handler("GetBucketReplication") + def get_bucket_replication( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> GetBucketReplicationResult: + raise NotImplementedError + + @handler("GetBucketTagging") + def get_bucket_tagging( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> GetBucketTaggingResult: + raise NotImplementedError + + @handler("GetBucketVersioning") + def get_bucket_versioning( + self, context: RequestContext, account_id: AccountId, bucket: BucketName, **kwargs + ) -> GetBucketVersioningResult: + raise NotImplementedError + + @handler("GetDataAccess") + def get_data_access( + self, + context: RequestContext, + account_id: AccountId, + target: S3Prefix, + permission: Permission, + duration_seconds: DurationSeconds | None = None, + privilege: Privilege | None = None, + target_type: S3PrefixType | None = None, + **kwargs, + ) -> GetDataAccessResult: + raise NotImplementedError + + @handler("GetJobTagging") + def get_job_tagging( + self, context: RequestContext, account_id: AccountId, job_id: JobId, **kwargs + ) -> GetJobTaggingResult: + raise NotImplementedError + + @handler("GetMultiRegionAccessPoint") + def get_multi_region_access_point( + self, + context: RequestContext, + account_id: AccountId, + name: MultiRegionAccessPointName, + **kwargs, + ) -> GetMultiRegionAccessPointResult: + raise NotImplementedError + + @handler("GetMultiRegionAccessPointPolicy") + def get_multi_region_access_point_policy( + self, + context: RequestContext, + account_id: AccountId, + name: MultiRegionAccessPointName, + **kwargs, + ) -> GetMultiRegionAccessPointPolicyResult: + raise NotImplementedError + + @handler("GetMultiRegionAccessPointPolicyStatus") + def get_multi_region_access_point_policy_status( + self, + context: RequestContext, + account_id: AccountId, + name: MultiRegionAccessPointName, + **kwargs, + ) -> GetMultiRegionAccessPointPolicyStatusResult: + raise NotImplementedError + + @handler("GetMultiRegionAccessPointRoutes") + def get_multi_region_access_point_routes( + self, + context: RequestContext, + account_id: AccountId, + mrap: MultiRegionAccessPointId, + **kwargs, + ) -> GetMultiRegionAccessPointRoutesResult: + raise NotImplementedError + + @handler("GetPublicAccessBlock") + def get_public_access_block( + self, context: RequestContext, account_id: AccountId, **kwargs + ) -> GetPublicAccessBlockOutput: + raise NotImplementedError + + @handler("GetStorageLensConfiguration") + def get_storage_lens_configuration( + self, context: RequestContext, config_id: ConfigId, account_id: AccountId, **kwargs + ) -> GetStorageLensConfigurationResult: + raise NotImplementedError + + @handler("GetStorageLensConfigurationTagging") + def get_storage_lens_configuration_tagging( + self, context: RequestContext, config_id: ConfigId, account_id: AccountId, **kwargs + ) -> GetStorageLensConfigurationTaggingResult: + raise NotImplementedError + + @handler("GetStorageLensGroup") + def get_storage_lens_group( + self, context: RequestContext, name: StorageLensGroupName, account_id: AccountId, **kwargs + ) -> GetStorageLensGroupResult: + raise NotImplementedError + + @handler("ListAccessGrants") + def list_access_grants( + self, + context: RequestContext, + account_id: AccountId, + next_token: ContinuationToken | None = None, + max_results: MaxResults | None = None, + grantee_type: GranteeType | None = None, + grantee_identifier: GranteeIdentifier | None = None, + permission: Permission | None = None, + grant_scope: S3Prefix | None = None, + application_arn: IdentityCenterApplicationArn | None = None, + **kwargs, + ) -> ListAccessGrantsResult: + raise NotImplementedError + + @handler("ListAccessGrantsInstances") + def list_access_grants_instances( + self, + context: RequestContext, + account_id: AccountId, + next_token: ContinuationToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListAccessGrantsInstancesResult: + raise NotImplementedError + + @handler("ListAccessGrantsLocations") + def list_access_grants_locations( + self, + context: RequestContext, + account_id: AccountId, + next_token: ContinuationToken | None = None, + max_results: MaxResults | None = None, + location_scope: S3Prefix | None = None, + **kwargs, + ) -> ListAccessGrantsLocationsResult: + raise NotImplementedError + + @handler("ListAccessPoints") + def list_access_points( + self, + context: RequestContext, + account_id: AccountId, + bucket: BucketName | None = None, + next_token: NonEmptyMaxLength1024String | None = None, + max_results: MaxResults | None = None, + data_source_id: DataSourceId | None = None, + data_source_type: DataSourceType | None = None, + **kwargs, + ) -> ListAccessPointsResult: + raise NotImplementedError + + @handler("ListAccessPointsForDirectoryBuckets") + def list_access_points_for_directory_buckets( + self, + context: RequestContext, + account_id: AccountId, + directory_bucket: BucketName | None = None, + next_token: NonEmptyMaxLength1024String | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListAccessPointsForDirectoryBucketsResult: + raise NotImplementedError + + @handler("ListAccessPointsForObjectLambda") + def list_access_points_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + next_token: NonEmptyMaxLength1024String | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListAccessPointsForObjectLambdaResult: + raise NotImplementedError + + @handler("ListCallerAccessGrants") + def list_caller_access_grants( + self, + context: RequestContext, + account_id: AccountId, + grant_scope: S3Prefix | None = None, + next_token: ContinuationToken | None = None, + max_results: MaxResults | None = None, + allowed_by_application: Boolean | None = None, + **kwargs, + ) -> ListCallerAccessGrantsResult: + raise NotImplementedError + + @handler("ListJobs") + def list_jobs( + self, + context: RequestContext, + account_id: AccountId, + job_statuses: JobStatusList | None = None, + next_token: StringForNextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListJobsResult: + raise NotImplementedError + + @handler("ListMultiRegionAccessPoints") + def list_multi_region_access_points( + self, + context: RequestContext, + account_id: AccountId, + next_token: NonEmptyMaxLength1024String | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListMultiRegionAccessPointsResult: + raise NotImplementedError + + @handler("ListRegionalBuckets") + def list_regional_buckets( + self, + context: RequestContext, + account_id: AccountId, + next_token: NonEmptyMaxLength1024String | None = None, + max_results: MaxResults | None = None, + outpost_id: NonEmptyMaxLength64String | None = None, + **kwargs, + ) -> ListRegionalBucketsResult: + raise NotImplementedError + + @handler("ListStorageLensConfigurations") + def list_storage_lens_configurations( + self, + context: RequestContext, + account_id: AccountId, + next_token: ContinuationToken | None = None, + **kwargs, + ) -> ListStorageLensConfigurationsResult: + raise NotImplementedError + + @handler("ListStorageLensGroups") + def list_storage_lens_groups( + self, + context: RequestContext, + account_id: AccountId, + next_token: ContinuationToken | None = None, + **kwargs, + ) -> ListStorageLensGroupsResult: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, account_id: AccountId, resource_arn: S3ResourceArn, **kwargs + ) -> ListTagsForResourceResult: + raise NotImplementedError + + @handler("PutAccessGrantsInstanceResourcePolicy") + def put_access_grants_instance_resource_policy( + self, + context: RequestContext, + account_id: AccountId, + policy: PolicyDocument, + organization: Organization | None = None, + **kwargs, + ) -> PutAccessGrantsInstanceResourcePolicyResult: + raise NotImplementedError + + @handler("PutAccessPointConfigurationForObjectLambda") + def put_access_point_configuration_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + configuration: ObjectLambdaConfiguration, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutAccessPointPolicy") + def put_access_point_policy( + self, + context: RequestContext, + account_id: AccountId, + name: AccessPointName, + policy: Policy, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutAccessPointPolicyForObjectLambda") + def put_access_point_policy_for_object_lambda( + self, + context: RequestContext, + account_id: AccountId, + name: ObjectLambdaAccessPointName, + policy: ObjectLambdaPolicy, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutAccessPointScope") + def put_access_point_scope( + self, + context: RequestContext, + account_id: AccountId, + name: AccessPointName, + scope: Scope, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketLifecycleConfiguration") + def put_bucket_lifecycle_configuration( + self, + context: RequestContext, + account_id: AccountId, + bucket: BucketName, + lifecycle_configuration: LifecycleConfiguration | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketPolicy") + def put_bucket_policy( + self, + context: RequestContext, + account_id: AccountId, + bucket: BucketName, + policy: Policy, + confirm_remove_self_bucket_access: ConfirmRemoveSelfBucketAccess | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketReplication") + def put_bucket_replication( + self, + context: RequestContext, + account_id: AccountId, + bucket: BucketName, + replication_configuration: ReplicationConfiguration, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketTagging") + def put_bucket_tagging( + self, + context: RequestContext, + account_id: AccountId, + bucket: BucketName, + tagging: Tagging, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutBucketVersioning") + def put_bucket_versioning( + self, + context: RequestContext, + account_id: AccountId, + bucket: BucketName, + versioning_configuration: VersioningConfiguration, + mfa: MFA | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutJobTagging") + def put_job_tagging( + self, + context: RequestContext, + account_id: AccountId, + job_id: JobId, + tags: S3TagSet, + **kwargs, + ) -> PutJobTaggingResult: + raise NotImplementedError + + @handler("PutMultiRegionAccessPointPolicy") + def put_multi_region_access_point_policy( + self, + context: RequestContext, + account_id: AccountId, + client_token: MultiRegionAccessPointClientToken, + details: PutMultiRegionAccessPointPolicyInput, + **kwargs, + ) -> PutMultiRegionAccessPointPolicyResult: + raise NotImplementedError + + @handler("PutPublicAccessBlock") + def put_public_access_block( + self, + context: RequestContext, + public_access_block_configuration: PublicAccessBlockConfiguration, + account_id: AccountId, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutStorageLensConfiguration") + def put_storage_lens_configuration( + self, + context: RequestContext, + config_id: ConfigId, + account_id: AccountId, + storage_lens_configuration: StorageLensConfiguration, + tags: StorageLensTags | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("PutStorageLensConfigurationTagging") + def put_storage_lens_configuration_tagging( + self, + context: RequestContext, + config_id: ConfigId, + account_id: AccountId, + tags: StorageLensTags, + **kwargs, + ) -> PutStorageLensConfigurationTaggingResult: + raise NotImplementedError + + @handler("SubmitMultiRegionAccessPointRoutes") + def submit_multi_region_access_point_routes( + self, + context: RequestContext, + account_id: AccountId, + mrap: MultiRegionAccessPointId, + route_updates: RouteList, + **kwargs, + ) -> SubmitMultiRegionAccessPointRoutesResult: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, + context: RequestContext, + account_id: AccountId, + resource_arn: S3ResourceArn, + tags: TagList, + **kwargs, + ) -> TagResourceResult: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, + context: RequestContext, + account_id: AccountId, + resource_arn: S3ResourceArn, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagResourceResult: + raise NotImplementedError + + @handler("UpdateAccessGrantsLocation") + def update_access_grants_location( + self, + context: RequestContext, + account_id: AccountId, + access_grants_location_id: AccessGrantsLocationId, + iam_role_arn: IAMRoleArn, + **kwargs, + ) -> UpdateAccessGrantsLocationResult: + raise NotImplementedError + + @handler("UpdateJobPriority") + def update_job_priority( + self, + context: RequestContext, + account_id: AccountId, + job_id: JobId, + priority: JobPriority, + **kwargs, + ) -> UpdateJobPriorityResult: + raise NotImplementedError + + @handler("UpdateJobStatus") + def update_job_status( + self, + context: RequestContext, + account_id: AccountId, + job_id: JobId, + requested_job_status: RequestedJobStatus, + status_update_reason: JobStatusUpdateReason | None = None, + **kwargs, + ) -> UpdateJobStatusResult: + raise NotImplementedError + + @handler("UpdateStorageLensGroup") + def update_storage_lens_group( + self, + context: RequestContext, + name: StorageLensGroupName, + account_id: AccountId, + storage_lens_group: StorageLensGroup, + **kwargs, + ) -> None: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/scheduler/__init__.py b/localstack-core/localstack/aws/api/scheduler/__init__.py new file mode 100644 index 0000000000000..696814447cd11 --- /dev/null +++ b/localstack-core/localstack/aws/api/scheduler/__init__.py @@ -0,0 +1,588 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +CapacityProvider = str +CapacityProviderStrategyItemBase = int +CapacityProviderStrategyItemWeight = int +ClientToken = str +DeadLetterConfigArnString = str +Description = str +DetailType = str +EnableECSManagedTags = bool +EnableExecuteCommand = bool +Group = str +KmsKeyArn = str +MaxResults = int +MaximumEventAgeInSeconds = int +MaximumRetryAttempts = int +MaximumWindowInMinutes = int +MessageGroupId = str +Name = str +NamePrefix = str +NextToken = str +PlacementConstraintExpression = str +PlacementStrategyField = str +PlatformVersion = str +ReferenceId = str +RoleArn = str +SageMakerPipelineParameterName = str +SageMakerPipelineParameterValue = str +ScheduleArn = str +ScheduleExpression = str +ScheduleExpressionTimezone = str +ScheduleGroupArn = str +ScheduleGroupName = str +ScheduleGroupNamePrefix = str +SecurityGroup = str +Source = str +String = str +Subnet = str +TagKey = str +TagResourceArn = str +TagValue = str +TargetArn = str +TargetInput = str +TargetPartitionKey = str +TaskCount = int +TaskDefinitionArn = str + + +class ActionAfterCompletion(StrEnum): + NONE = "NONE" + DELETE = "DELETE" + + +class AssignPublicIp(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class FlexibleTimeWindowMode(StrEnum): + OFF = "OFF" + FLEXIBLE = "FLEXIBLE" + + +class LaunchType(StrEnum): + EC2 = "EC2" + FARGATE = "FARGATE" + EXTERNAL = "EXTERNAL" + + +class PlacementConstraintType(StrEnum): + distinctInstance = "distinctInstance" + memberOf = "memberOf" + + +class PlacementStrategyType(StrEnum): + random = "random" + spread = "spread" + binpack = "binpack" + + +class PropagateTags(StrEnum): + TASK_DEFINITION = "TASK_DEFINITION" + + +class ScheduleGroupState(StrEnum): + ACTIVE = "ACTIVE" + DELETING = "DELETING" + + +class ScheduleState(StrEnum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = True + status_code: int = 409 + + +class InternalServerException(ServiceException): + code: str = "InternalServerException" + sender_fault: bool = False + status_code: int = 500 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = True + status_code: int = 404 + + +class ServiceQuotaExceededException(ServiceException): + code: str = "ServiceQuotaExceededException" + sender_fault: bool = True + status_code: int = 402 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = True + status_code: int = 429 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = True + status_code: int = 400 + + +Subnets = List[Subnet] +SecurityGroups = List[SecurityGroup] + + +class AwsVpcConfiguration(TypedDict, total=False): + AssignPublicIp: Optional[AssignPublicIp] + SecurityGroups: Optional[SecurityGroups] + Subnets: Subnets + + +class CapacityProviderStrategyItem(TypedDict, total=False): + base: Optional[CapacityProviderStrategyItemBase] + capacityProvider: CapacityProvider + weight: Optional[CapacityProviderStrategyItemWeight] + + +CapacityProviderStrategy = List[CapacityProviderStrategyItem] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class CreateScheduleGroupInput(ServiceRequest): + ClientToken: Optional[ClientToken] + Name: ScheduleGroupName + Tags: Optional[TagList] + + +class CreateScheduleGroupOutput(TypedDict, total=False): + ScheduleGroupArn: ScheduleGroupArn + + +class SqsParameters(TypedDict, total=False): + MessageGroupId: Optional[MessageGroupId] + + +class SageMakerPipelineParameter(TypedDict, total=False): + Name: SageMakerPipelineParameterName + Value: SageMakerPipelineParameterValue + + +SageMakerPipelineParameterList = List[SageMakerPipelineParameter] + + +class SageMakerPipelineParameters(TypedDict, total=False): + PipelineParameterList: Optional[SageMakerPipelineParameterList] + + +class RetryPolicy(TypedDict, total=False): + MaximumEventAgeInSeconds: Optional[MaximumEventAgeInSeconds] + MaximumRetryAttempts: Optional[MaximumRetryAttempts] + + +class KinesisParameters(TypedDict, total=False): + PartitionKey: TargetPartitionKey + + +class EventBridgeParameters(TypedDict, total=False): + DetailType: DetailType + Source: Source + + +TagMap = Dict[TagKey, TagValue] +Tags = List[TagMap] +PlacementStrategy = TypedDict( + "PlacementStrategy", + { + "field": Optional[PlacementStrategyField], + "type": Optional[PlacementStrategyType], + }, + total=False, +) +PlacementStrategies = List[PlacementStrategy] +PlacementConstraint = TypedDict( + "PlacementConstraint", + { + "expression": Optional[PlacementConstraintExpression], + "type": Optional[PlacementConstraintType], + }, + total=False, +) +PlacementConstraints = List[PlacementConstraint] + + +class NetworkConfiguration(TypedDict, total=False): + awsvpcConfiguration: Optional[AwsVpcConfiguration] + + +class EcsParameters(TypedDict, total=False): + CapacityProviderStrategy: Optional[CapacityProviderStrategy] + EnableECSManagedTags: Optional[EnableECSManagedTags] + EnableExecuteCommand: Optional[EnableExecuteCommand] + Group: Optional[Group] + LaunchType: Optional[LaunchType] + NetworkConfiguration: Optional[NetworkConfiguration] + PlacementConstraints: Optional[PlacementConstraints] + PlacementStrategy: Optional[PlacementStrategies] + PlatformVersion: Optional[PlatformVersion] + PropagateTags: Optional[PropagateTags] + ReferenceId: Optional[ReferenceId] + Tags: Optional[Tags] + TaskCount: Optional[TaskCount] + TaskDefinitionArn: TaskDefinitionArn + + +class DeadLetterConfig(TypedDict, total=False): + Arn: Optional[DeadLetterConfigArnString] + + +class Target(TypedDict, total=False): + Arn: TargetArn + DeadLetterConfig: Optional[DeadLetterConfig] + EcsParameters: Optional[EcsParameters] + EventBridgeParameters: Optional[EventBridgeParameters] + Input: Optional[TargetInput] + KinesisParameters: Optional[KinesisParameters] + RetryPolicy: Optional[RetryPolicy] + RoleArn: RoleArn + SageMakerPipelineParameters: Optional[SageMakerPipelineParameters] + SqsParameters: Optional[SqsParameters] + + +StartDate = datetime + + +class FlexibleTimeWindow(TypedDict, total=False): + MaximumWindowInMinutes: Optional[MaximumWindowInMinutes] + Mode: FlexibleTimeWindowMode + + +EndDate = datetime + + +class CreateScheduleInput(ServiceRequest): + ActionAfterCompletion: Optional[ActionAfterCompletion] + ClientToken: Optional[ClientToken] + Description: Optional[Description] + EndDate: Optional[EndDate] + FlexibleTimeWindow: FlexibleTimeWindow + GroupName: Optional[ScheduleGroupName] + KmsKeyArn: Optional[KmsKeyArn] + Name: Name + ScheduleExpression: ScheduleExpression + ScheduleExpressionTimezone: Optional[ScheduleExpressionTimezone] + StartDate: Optional[StartDate] + State: Optional[ScheduleState] + Target: Target + + +class CreateScheduleOutput(TypedDict, total=False): + ScheduleArn: ScheduleArn + + +CreationDate = datetime + + +class DeleteScheduleGroupInput(ServiceRequest): + ClientToken: Optional[ClientToken] + Name: ScheduleGroupName + + +class DeleteScheduleGroupOutput(TypedDict, total=False): + pass + + +class DeleteScheduleInput(ServiceRequest): + ClientToken: Optional[ClientToken] + GroupName: Optional[ScheduleGroupName] + Name: Name + + +class DeleteScheduleOutput(TypedDict, total=False): + pass + + +class GetScheduleGroupInput(ServiceRequest): + Name: ScheduleGroupName + + +LastModificationDate = datetime + + +class GetScheduleGroupOutput(TypedDict, total=False): + Arn: Optional[ScheduleGroupArn] + CreationDate: Optional[CreationDate] + LastModificationDate: Optional[LastModificationDate] + Name: Optional[ScheduleGroupName] + State: Optional[ScheduleGroupState] + + +class GetScheduleInput(ServiceRequest): + GroupName: Optional[ScheduleGroupName] + Name: Name + + +class GetScheduleOutput(TypedDict, total=False): + ActionAfterCompletion: Optional[ActionAfterCompletion] + Arn: Optional[ScheduleArn] + CreationDate: Optional[CreationDate] + Description: Optional[Description] + EndDate: Optional[EndDate] + FlexibleTimeWindow: Optional[FlexibleTimeWindow] + GroupName: Optional[ScheduleGroupName] + KmsKeyArn: Optional[KmsKeyArn] + LastModificationDate: Optional[LastModificationDate] + Name: Optional[Name] + ScheduleExpression: Optional[ScheduleExpression] + ScheduleExpressionTimezone: Optional[ScheduleExpressionTimezone] + StartDate: Optional[StartDate] + State: Optional[ScheduleState] + Target: Optional[Target] + + +class ListScheduleGroupsInput(ServiceRequest): + MaxResults: Optional[MaxResults] + NamePrefix: Optional[ScheduleGroupNamePrefix] + NextToken: Optional[NextToken] + + +class ScheduleGroupSummary(TypedDict, total=False): + Arn: Optional[ScheduleGroupArn] + CreationDate: Optional[CreationDate] + LastModificationDate: Optional[LastModificationDate] + Name: Optional[ScheduleGroupName] + State: Optional[ScheduleGroupState] + + +ScheduleGroupList = List[ScheduleGroupSummary] + + +class ListScheduleGroupsOutput(TypedDict, total=False): + NextToken: Optional[NextToken] + ScheduleGroups: ScheduleGroupList + + +class ListSchedulesInput(ServiceRequest): + GroupName: Optional[ScheduleGroupName] + MaxResults: Optional[MaxResults] + NamePrefix: Optional[NamePrefix] + NextToken: Optional[NextToken] + State: Optional[ScheduleState] + + +class TargetSummary(TypedDict, total=False): + Arn: TargetArn + + +class ScheduleSummary(TypedDict, total=False): + Arn: Optional[ScheduleArn] + CreationDate: Optional[CreationDate] + GroupName: Optional[ScheduleGroupName] + LastModificationDate: Optional[LastModificationDate] + Name: Optional[Name] + State: Optional[ScheduleState] + Target: Optional[TargetSummary] + + +ScheduleList = List[ScheduleSummary] + + +class ListSchedulesOutput(TypedDict, total=False): + NextToken: Optional[NextToken] + Schedules: ScheduleList + + +class ListTagsForResourceInput(ServiceRequest): + ResourceArn: TagResourceArn + + +class ListTagsForResourceOutput(TypedDict, total=False): + Tags: Optional[TagList] + + +TagKeyList = List[TagKey] + + +class TagResourceInput(ServiceRequest): + ResourceArn: TagResourceArn + Tags: TagList + + +class TagResourceOutput(TypedDict, total=False): + pass + + +class UntagResourceInput(ServiceRequest): + ResourceArn: TagResourceArn + TagKeys: TagKeyList + + +class UntagResourceOutput(TypedDict, total=False): + pass + + +class UpdateScheduleInput(ServiceRequest): + ActionAfterCompletion: Optional[ActionAfterCompletion] + ClientToken: Optional[ClientToken] + Description: Optional[Description] + EndDate: Optional[EndDate] + FlexibleTimeWindow: FlexibleTimeWindow + GroupName: Optional[ScheduleGroupName] + KmsKeyArn: Optional[KmsKeyArn] + Name: Name + ScheduleExpression: ScheduleExpression + ScheduleExpressionTimezone: Optional[ScheduleExpressionTimezone] + StartDate: Optional[StartDate] + State: Optional[ScheduleState] + Target: Target + + +class UpdateScheduleOutput(TypedDict, total=False): + ScheduleArn: ScheduleArn + + +class SchedulerApi: + service = "scheduler" + version = "2021-06-30" + + @handler("CreateSchedule") + def create_schedule( + self, + context: RequestContext, + flexible_time_window: FlexibleTimeWindow, + name: Name, + schedule_expression: ScheduleExpression, + target: Target, + action_after_completion: ActionAfterCompletion | None = None, + client_token: ClientToken | None = None, + description: Description | None = None, + end_date: EndDate | None = None, + group_name: ScheduleGroupName | None = None, + kms_key_arn: KmsKeyArn | None = None, + schedule_expression_timezone: ScheduleExpressionTimezone | None = None, + start_date: StartDate | None = None, + state: ScheduleState | None = None, + **kwargs, + ) -> CreateScheduleOutput: + raise NotImplementedError + + @handler("CreateScheduleGroup") + def create_schedule_group( + self, + context: RequestContext, + name: ScheduleGroupName, + client_token: ClientToken | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateScheduleGroupOutput: + raise NotImplementedError + + @handler("DeleteSchedule") + def delete_schedule( + self, + context: RequestContext, + name: Name, + client_token: ClientToken | None = None, + group_name: ScheduleGroupName | None = None, + **kwargs, + ) -> DeleteScheduleOutput: + raise NotImplementedError + + @handler("DeleteScheduleGroup") + def delete_schedule_group( + self, + context: RequestContext, + name: ScheduleGroupName, + client_token: ClientToken | None = None, + **kwargs, + ) -> DeleteScheduleGroupOutput: + raise NotImplementedError + + @handler("GetSchedule") + def get_schedule( + self, + context: RequestContext, + name: Name, + group_name: ScheduleGroupName | None = None, + **kwargs, + ) -> GetScheduleOutput: + raise NotImplementedError + + @handler("GetScheduleGroup") + def get_schedule_group( + self, context: RequestContext, name: ScheduleGroupName, **kwargs + ) -> GetScheduleGroupOutput: + raise NotImplementedError + + @handler("ListScheduleGroups") + def list_schedule_groups( + self, + context: RequestContext, + max_results: MaxResults | None = None, + name_prefix: ScheduleGroupNamePrefix | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListScheduleGroupsOutput: + raise NotImplementedError + + @handler("ListSchedules") + def list_schedules( + self, + context: RequestContext, + group_name: ScheduleGroupName | None = None, + max_results: MaxResults | None = None, + name_prefix: NamePrefix | None = None, + next_token: NextToken | None = None, + state: ScheduleState | None = None, + **kwargs, + ) -> ListSchedulesOutput: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: TagResourceArn, **kwargs + ) -> ListTagsForResourceOutput: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: TagResourceArn, tags: TagList, **kwargs + ) -> TagResourceOutput: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: TagResourceArn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceOutput: + raise NotImplementedError + + @handler("UpdateSchedule") + def update_schedule( + self, + context: RequestContext, + flexible_time_window: FlexibleTimeWindow, + name: Name, + schedule_expression: ScheduleExpression, + target: Target, + action_after_completion: ActionAfterCompletion | None = None, + client_token: ClientToken | None = None, + description: Description | None = None, + end_date: EndDate | None = None, + group_name: ScheduleGroupName | None = None, + kms_key_arn: KmsKeyArn | None = None, + schedule_expression_timezone: ScheduleExpressionTimezone | None = None, + start_date: StartDate | None = None, + state: ScheduleState | None = None, + **kwargs, + ) -> UpdateScheduleOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/secretsmanager/__init__.py b/localstack-core/localstack/aws/api/secretsmanager/__init__.py new file mode 100644 index 0000000000000..7e4704d8f34ac --- /dev/null +++ b/localstack-core/localstack/aws/api/secretsmanager/__init__.py @@ -0,0 +1,801 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +BooleanType = bool +ClientRequestTokenType = str +DescriptionType = str +DurationType = str +ErrorCode = str +ErrorMessage = str +ExcludeCharactersType = str +ExcludeLowercaseType = bool +ExcludeNumbersType = bool +ExcludePunctuationType = bool +ExcludeUppercaseType = bool +FilterValueStringType = str +IncludeSpaceType = bool +KmsKeyIdType = str +MaxResultsBatchType = int +MaxResultsType = int +NameType = str +NextTokenType = str +NonEmptyResourcePolicyType = str +OwningServiceType = str +RandomPasswordType = str +RegionType = str +RequireEachIncludedTypeType = bool +RotationEnabledType = bool +RotationLambdaARNType = str +RotationTokenType = str +ScheduleExpressionType = str +SecretARNType = str +SecretIdType = str +SecretNameType = str +SecretStringType = str +SecretVersionIdType = str +SecretVersionStageType = str +StatusMessageType = str +TagKeyType = str +TagValueType = str + + +class FilterNameStringType(StrEnum): + description = "description" + name = "name" + tag_key = "tag-key" + tag_value = "tag-value" + primary_region = "primary-region" + owning_service = "owning-service" + all = "all" + + +class SortOrderType(StrEnum): + asc = "asc" + desc = "desc" + + +class StatusType(StrEnum): + InSync = "InSync" + Failed = "Failed" + InProgress = "InProgress" + + +class DecryptionFailure(ServiceException): + code: str = "DecryptionFailure" + sender_fault: bool = False + status_code: int = 400 + + +class EncryptionFailure(ServiceException): + code: str = "EncryptionFailure" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServiceError(ServiceException): + code: str = "InternalServiceError" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidNextTokenException(ServiceException): + code: str = "InvalidNextTokenException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidParameterException(ServiceException): + code: str = "InvalidParameterException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRequestException(ServiceException): + code: str = "InvalidRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class MalformedPolicyDocumentException(ServiceException): + code: str = "MalformedPolicyDocumentException" + sender_fault: bool = False + status_code: int = 400 + + +class PreconditionNotMetException(ServiceException): + code: str = "PreconditionNotMetException" + sender_fault: bool = False + status_code: int = 400 + + +class PublicPolicyException(ServiceException): + code: str = "PublicPolicyException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceExistsException(ServiceException): + code: str = "ResourceExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class APIErrorType(TypedDict, total=False): + SecretId: Optional[SecretIdType] + ErrorCode: Optional[ErrorCode] + Message: Optional[ErrorMessage] + + +APIErrorListType = List[APIErrorType] + + +class ReplicaRegionType(TypedDict, total=False): + Region: Optional[RegionType] + KmsKeyId: Optional[KmsKeyIdType] + + +AddReplicaRegionListType = List[ReplicaRegionType] +AutomaticallyRotateAfterDaysType = int +FilterValuesStringList = List[FilterValueStringType] + + +class Filter(TypedDict, total=False): + Key: Optional[FilterNameStringType] + Values: Optional[FilterValuesStringList] + + +FiltersListType = List[Filter] +SecretIdListType = List[SecretIdType] + + +class BatchGetSecretValueRequest(ServiceRequest): + SecretIdList: Optional[SecretIdListType] + Filters: Optional[FiltersListType] + MaxResults: Optional[MaxResultsBatchType] + NextToken: Optional[NextTokenType] + + +CreatedDateType = datetime +SecretVersionStagesType = List[SecretVersionStageType] +SecretBinaryType = bytes + + +class SecretValueEntry(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + VersionId: Optional[SecretVersionIdType] + SecretBinary: Optional[SecretBinaryType] + SecretString: Optional[SecretStringType] + VersionStages: Optional[SecretVersionStagesType] + CreatedDate: Optional[CreatedDateType] + + +SecretValuesType = List[SecretValueEntry] + + +class BatchGetSecretValueResponse(TypedDict, total=False): + SecretValues: Optional[SecretValuesType] + NextToken: Optional[NextTokenType] + Errors: Optional[APIErrorListType] + + +class CancelRotateSecretRequest(ServiceRequest): + SecretId: SecretIdType + + +class CancelRotateSecretResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + VersionId: Optional[SecretVersionIdType] + + +class Tag(TypedDict, total=False): + Key: Optional[TagKeyType] + Value: Optional[TagValueType] + + +TagListType = List[Tag] + + +class CreateSecretRequest(ServiceRequest): + Name: NameType + ClientRequestToken: Optional[ClientRequestTokenType] + Description: Optional[DescriptionType] + KmsKeyId: Optional[KmsKeyIdType] + SecretBinary: Optional[SecretBinaryType] + SecretString: Optional[SecretStringType] + Tags: Optional[TagListType] + AddReplicaRegions: Optional[AddReplicaRegionListType] + ForceOverwriteReplicaSecret: Optional[BooleanType] + + +LastAccessedDateType = datetime + + +class ReplicationStatusType(TypedDict, total=False): + Region: Optional[RegionType] + KmsKeyId: Optional[KmsKeyIdType] + Status: Optional[StatusType] + StatusMessage: Optional[StatusMessageType] + LastAccessedDate: Optional[LastAccessedDateType] + + +ReplicationStatusListType = List[ReplicationStatusType] + + +class CreateSecretResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + VersionId: Optional[SecretVersionIdType] + ReplicationStatus: Optional[ReplicationStatusListType] + + +class DeleteResourcePolicyRequest(ServiceRequest): + SecretId: SecretIdType + + +class DeleteResourcePolicyResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[NameType] + + +RecoveryWindowInDaysType = int + + +class DeleteSecretRequest(ServiceRequest): + SecretId: SecretIdType + RecoveryWindowInDays: Optional[RecoveryWindowInDaysType] + ForceDeleteWithoutRecovery: Optional[BooleanType] + + +DeletionDateType = datetime + + +class DeleteSecretResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + DeletionDate: Optional[DeletionDateType] + + +DeletedDateType = datetime + + +class DescribeSecretRequest(ServiceRequest): + SecretId: SecretIdType + + +TimestampType = datetime +SecretVersionsToStagesMapType = Dict[SecretVersionIdType, SecretVersionStagesType] +NextRotationDateType = datetime +LastChangedDateType = datetime +LastRotatedDateType = datetime + + +class RotationRulesType(TypedDict, total=False): + AutomaticallyAfterDays: Optional[AutomaticallyRotateAfterDaysType] + Duration: Optional[DurationType] + ScheduleExpression: Optional[ScheduleExpressionType] + + +class DescribeSecretResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + Description: Optional[DescriptionType] + KmsKeyId: Optional[KmsKeyIdType] + RotationEnabled: Optional[RotationEnabledType] + RotationLambdaARN: Optional[RotationLambdaARNType] + RotationRules: Optional[RotationRulesType] + LastRotatedDate: Optional[LastRotatedDateType] + LastChangedDate: Optional[LastChangedDateType] + LastAccessedDate: Optional[LastAccessedDateType] + DeletedDate: Optional[DeletedDateType] + NextRotationDate: Optional[NextRotationDateType] + Tags: Optional[TagListType] + VersionIdsToStages: Optional[SecretVersionsToStagesMapType] + OwningService: Optional[OwningServiceType] + CreatedDate: Optional[TimestampType] + PrimaryRegion: Optional[RegionType] + ReplicationStatus: Optional[ReplicationStatusListType] + + +PasswordLengthType = int + + +class GetRandomPasswordRequest(ServiceRequest): + PasswordLength: Optional[PasswordLengthType] + ExcludeCharacters: Optional[ExcludeCharactersType] + ExcludeNumbers: Optional[ExcludeNumbersType] + ExcludePunctuation: Optional[ExcludePunctuationType] + ExcludeUppercase: Optional[ExcludeUppercaseType] + ExcludeLowercase: Optional[ExcludeLowercaseType] + IncludeSpace: Optional[IncludeSpaceType] + RequireEachIncludedType: Optional[RequireEachIncludedTypeType] + + +class GetRandomPasswordResponse(TypedDict, total=False): + RandomPassword: Optional[RandomPasswordType] + + +class GetResourcePolicyRequest(ServiceRequest): + SecretId: SecretIdType + + +class GetResourcePolicyResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[NameType] + ResourcePolicy: Optional[NonEmptyResourcePolicyType] + + +class GetSecretValueRequest(ServiceRequest): + SecretId: SecretIdType + VersionId: Optional[SecretVersionIdType] + VersionStage: Optional[SecretVersionStageType] + + +class GetSecretValueResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + VersionId: Optional[SecretVersionIdType] + SecretBinary: Optional[SecretBinaryType] + SecretString: Optional[SecretStringType] + VersionStages: Optional[SecretVersionStagesType] + CreatedDate: Optional[CreatedDateType] + + +KmsKeyIdListType = List[KmsKeyIdType] + + +class ListSecretVersionIdsRequest(ServiceRequest): + SecretId: SecretIdType + MaxResults: Optional[MaxResultsType] + NextToken: Optional[NextTokenType] + IncludeDeprecated: Optional[BooleanType] + + +class SecretVersionsListEntry(TypedDict, total=False): + VersionId: Optional[SecretVersionIdType] + VersionStages: Optional[SecretVersionStagesType] + LastAccessedDate: Optional[LastAccessedDateType] + CreatedDate: Optional[CreatedDateType] + KmsKeyIds: Optional[KmsKeyIdListType] + + +SecretVersionsListType = List[SecretVersionsListEntry] + + +class ListSecretVersionIdsResponse(TypedDict, total=False): + Versions: Optional[SecretVersionsListType] + NextToken: Optional[NextTokenType] + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + + +class ListSecretsRequest(ServiceRequest): + IncludePlannedDeletion: Optional[BooleanType] + MaxResults: Optional[MaxResultsType] + NextToken: Optional[NextTokenType] + Filters: Optional[FiltersListType] + SortOrder: Optional[SortOrderType] + + +class SecretListEntry(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + Description: Optional[DescriptionType] + KmsKeyId: Optional[KmsKeyIdType] + RotationEnabled: Optional[RotationEnabledType] + RotationLambdaARN: Optional[RotationLambdaARNType] + RotationRules: Optional[RotationRulesType] + LastRotatedDate: Optional[LastRotatedDateType] + LastChangedDate: Optional[LastChangedDateType] + LastAccessedDate: Optional[LastAccessedDateType] + DeletedDate: Optional[DeletedDateType] + NextRotationDate: Optional[NextRotationDateType] + Tags: Optional[TagListType] + SecretVersionsToStages: Optional[SecretVersionsToStagesMapType] + OwningService: Optional[OwningServiceType] + CreatedDate: Optional[TimestampType] + PrimaryRegion: Optional[RegionType] + + +SecretListType = List[SecretListEntry] + + +class ListSecretsResponse(TypedDict, total=False): + SecretList: Optional[SecretListType] + NextToken: Optional[NextTokenType] + + +class PutResourcePolicyRequest(ServiceRequest): + SecretId: SecretIdType + ResourcePolicy: NonEmptyResourcePolicyType + BlockPublicPolicy: Optional[BooleanType] + + +class PutResourcePolicyResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[NameType] + + +class PutSecretValueRequest(ServiceRequest): + SecretId: SecretIdType + ClientRequestToken: Optional[ClientRequestTokenType] + SecretBinary: Optional[SecretBinaryType] + SecretString: Optional[SecretStringType] + VersionStages: Optional[SecretVersionStagesType] + RotationToken: Optional[RotationTokenType] + + +class PutSecretValueResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + VersionId: Optional[SecretVersionIdType] + VersionStages: Optional[SecretVersionStagesType] + + +RemoveReplicaRegionListType = List[RegionType] + + +class RemoveRegionsFromReplicationRequest(ServiceRequest): + SecretId: SecretIdType + RemoveReplicaRegions: RemoveReplicaRegionListType + + +class RemoveRegionsFromReplicationResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + ReplicationStatus: Optional[ReplicationStatusListType] + + +class ReplicateSecretToRegionsRequest(ServiceRequest): + SecretId: SecretIdType + AddReplicaRegions: AddReplicaRegionListType + ForceOverwriteReplicaSecret: Optional[BooleanType] + + +class ReplicateSecretToRegionsResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + ReplicationStatus: Optional[ReplicationStatusListType] + + +class RestoreSecretRequest(ServiceRequest): + SecretId: SecretIdType + + +class RestoreSecretResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + + +class RotateSecretRequest(ServiceRequest): + SecretId: SecretIdType + ClientRequestToken: Optional[ClientRequestTokenType] + RotationLambdaARN: Optional[RotationLambdaARNType] + RotationRules: Optional[RotationRulesType] + RotateImmediately: Optional[BooleanType] + + +class RotateSecretResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + VersionId: Optional[SecretVersionIdType] + + +class StopReplicationToReplicaRequest(ServiceRequest): + SecretId: SecretIdType + + +class StopReplicationToReplicaResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + + +TagKeyListType = List[TagKeyType] + + +class TagResourceRequest(ServiceRequest): + SecretId: SecretIdType + Tags: TagListType + + +class UntagResourceRequest(ServiceRequest): + SecretId: SecretIdType + TagKeys: TagKeyListType + + +class UpdateSecretRequest(ServiceRequest): + SecretId: SecretIdType + ClientRequestToken: Optional[ClientRequestTokenType] + Description: Optional[DescriptionType] + KmsKeyId: Optional[KmsKeyIdType] + SecretBinary: Optional[SecretBinaryType] + SecretString: Optional[SecretStringType] + + +class UpdateSecretResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + VersionId: Optional[SecretVersionIdType] + + +class UpdateSecretVersionStageRequest(ServiceRequest): + SecretId: SecretIdType + VersionStage: SecretVersionStageType + RemoveFromVersionId: Optional[SecretVersionIdType] + MoveToVersionId: Optional[SecretVersionIdType] + + +class UpdateSecretVersionStageResponse(TypedDict, total=False): + ARN: Optional[SecretARNType] + Name: Optional[SecretNameType] + + +class ValidateResourcePolicyRequest(ServiceRequest): + SecretId: Optional[SecretIdType] + ResourcePolicy: NonEmptyResourcePolicyType + + +class ValidationErrorsEntry(TypedDict, total=False): + CheckName: Optional[NameType] + ErrorMessage: Optional[ErrorMessage] + + +ValidationErrorsType = List[ValidationErrorsEntry] + + +class ValidateResourcePolicyResponse(TypedDict, total=False): + PolicyValidationPassed: Optional[BooleanType] + ValidationErrors: Optional[ValidationErrorsType] + + +class SecretsmanagerApi: + service = "secretsmanager" + version = "2017-10-17" + + @handler("BatchGetSecretValue") + def batch_get_secret_value( + self, + context: RequestContext, + secret_id_list: SecretIdListType | None = None, + filters: FiltersListType | None = None, + max_results: MaxResultsBatchType | None = None, + next_token: NextTokenType | None = None, + **kwargs, + ) -> BatchGetSecretValueResponse: + raise NotImplementedError + + @handler("CancelRotateSecret") + def cancel_rotate_secret( + self, context: RequestContext, secret_id: SecretIdType, **kwargs + ) -> CancelRotateSecretResponse: + raise NotImplementedError + + @handler("CreateSecret") + def create_secret( + self, + context: RequestContext, + name: NameType, + client_request_token: ClientRequestTokenType | None = None, + description: DescriptionType | None = None, + kms_key_id: KmsKeyIdType | None = None, + secret_binary: SecretBinaryType | None = None, + secret_string: SecretStringType | None = None, + tags: TagListType | None = None, + add_replica_regions: AddReplicaRegionListType | None = None, + force_overwrite_replica_secret: BooleanType | None = None, + **kwargs, + ) -> CreateSecretResponse: + raise NotImplementedError + + @handler("DeleteResourcePolicy") + def delete_resource_policy( + self, context: RequestContext, secret_id: SecretIdType, **kwargs + ) -> DeleteResourcePolicyResponse: + raise NotImplementedError + + @handler("DeleteSecret") + def delete_secret( + self, + context: RequestContext, + secret_id: SecretIdType, + recovery_window_in_days: RecoveryWindowInDaysType | None = None, + force_delete_without_recovery: BooleanType | None = None, + **kwargs, + ) -> DeleteSecretResponse: + raise NotImplementedError + + @handler("DescribeSecret") + def describe_secret( + self, context: RequestContext, secret_id: SecretIdType, **kwargs + ) -> DescribeSecretResponse: + raise NotImplementedError + + @handler("GetRandomPassword") + def get_random_password( + self, + context: RequestContext, + password_length: PasswordLengthType | None = None, + exclude_characters: ExcludeCharactersType | None = None, + exclude_numbers: ExcludeNumbersType | None = None, + exclude_punctuation: ExcludePunctuationType | None = None, + exclude_uppercase: ExcludeUppercaseType | None = None, + exclude_lowercase: ExcludeLowercaseType | None = None, + include_space: IncludeSpaceType | None = None, + require_each_included_type: RequireEachIncludedTypeType | None = None, + **kwargs, + ) -> GetRandomPasswordResponse: + raise NotImplementedError + + @handler("GetResourcePolicy") + def get_resource_policy( + self, context: RequestContext, secret_id: SecretIdType, **kwargs + ) -> GetResourcePolicyResponse: + raise NotImplementedError + + @handler("GetSecretValue") + def get_secret_value( + self, + context: RequestContext, + secret_id: SecretIdType, + version_id: SecretVersionIdType | None = None, + version_stage: SecretVersionStageType | None = None, + **kwargs, + ) -> GetSecretValueResponse: + raise NotImplementedError + + @handler("ListSecretVersionIds") + def list_secret_version_ids( + self, + context: RequestContext, + secret_id: SecretIdType, + max_results: MaxResultsType | None = None, + next_token: NextTokenType | None = None, + include_deprecated: BooleanType | None = None, + **kwargs, + ) -> ListSecretVersionIdsResponse: + raise NotImplementedError + + @handler("ListSecrets") + def list_secrets( + self, + context: RequestContext, + include_planned_deletion: BooleanType | None = None, + max_results: MaxResultsType | None = None, + next_token: NextTokenType | None = None, + filters: FiltersListType | None = None, + sort_order: SortOrderType | None = None, + **kwargs, + ) -> ListSecretsResponse: + raise NotImplementedError + + @handler("PutResourcePolicy") + def put_resource_policy( + self, + context: RequestContext, + secret_id: SecretIdType, + resource_policy: NonEmptyResourcePolicyType, + block_public_policy: BooleanType | None = None, + **kwargs, + ) -> PutResourcePolicyResponse: + raise NotImplementedError + + @handler("PutSecretValue") + def put_secret_value( + self, + context: RequestContext, + secret_id: SecretIdType, + client_request_token: ClientRequestTokenType | None = None, + secret_binary: SecretBinaryType | None = None, + secret_string: SecretStringType | None = None, + version_stages: SecretVersionStagesType | None = None, + rotation_token: RotationTokenType | None = None, + **kwargs, + ) -> PutSecretValueResponse: + raise NotImplementedError + + @handler("RemoveRegionsFromReplication") + def remove_regions_from_replication( + self, + context: RequestContext, + secret_id: SecretIdType, + remove_replica_regions: RemoveReplicaRegionListType, + **kwargs, + ) -> RemoveRegionsFromReplicationResponse: + raise NotImplementedError + + @handler("ReplicateSecretToRegions") + def replicate_secret_to_regions( + self, + context: RequestContext, + secret_id: SecretIdType, + add_replica_regions: AddReplicaRegionListType, + force_overwrite_replica_secret: BooleanType | None = None, + **kwargs, + ) -> ReplicateSecretToRegionsResponse: + raise NotImplementedError + + @handler("RestoreSecret") + def restore_secret( + self, context: RequestContext, secret_id: SecretIdType, **kwargs + ) -> RestoreSecretResponse: + raise NotImplementedError + + @handler("RotateSecret") + def rotate_secret( + self, + context: RequestContext, + secret_id: SecretIdType, + client_request_token: ClientRequestTokenType | None = None, + rotation_lambda_arn: RotationLambdaARNType | None = None, + rotation_rules: RotationRulesType | None = None, + rotate_immediately: BooleanType | None = None, + **kwargs, + ) -> RotateSecretResponse: + raise NotImplementedError + + @handler("StopReplicationToReplica") + def stop_replication_to_replica( + self, context: RequestContext, secret_id: SecretIdType, **kwargs + ) -> StopReplicationToReplicaResponse: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, secret_id: SecretIdType, tags: TagListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, secret_id: SecretIdType, tag_keys: TagKeyListType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdateSecret") + def update_secret( + self, + context: RequestContext, + secret_id: SecretIdType, + client_request_token: ClientRequestTokenType | None = None, + description: DescriptionType | None = None, + kms_key_id: KmsKeyIdType | None = None, + secret_binary: SecretBinaryType | None = None, + secret_string: SecretStringType | None = None, + **kwargs, + ) -> UpdateSecretResponse: + raise NotImplementedError + + @handler("UpdateSecretVersionStage") + def update_secret_version_stage( + self, + context: RequestContext, + secret_id: SecretIdType, + version_stage: SecretVersionStageType, + remove_from_version_id: SecretVersionIdType | None = None, + move_to_version_id: SecretVersionIdType | None = None, + **kwargs, + ) -> UpdateSecretVersionStageResponse: + raise NotImplementedError + + @handler("ValidateResourcePolicy") + def validate_resource_policy( + self, + context: RequestContext, + resource_policy: NonEmptyResourcePolicyType, + secret_id: SecretIdType | None = None, + **kwargs, + ) -> ValidateResourcePolicyResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/ses/__init__.py b/localstack-core/localstack/aws/api/ses/__init__.py new file mode 100644 index 0000000000000..26e3b38f45cf1 --- /dev/null +++ b/localstack-core/localstack/aws/api/ses/__init__.py @@ -0,0 +1,1991 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Address = str +AmazonResourceName = str +BounceMessage = str +BounceSmtpReplyCode = str +BounceStatusCode = str +Charset = str +Cidr = str +ConfigurationSetName = str +ConnectInstanceArn = str +CustomRedirectDomain = str +DefaultDimensionValue = str +DiagnosticCode = str +DimensionName = str +Domain = str +DsnStatus = str +Enabled = bool +Error = str +EventDestinationName = str +Explanation = str +ExtensionFieldName = str +ExtensionFieldValue = str +FailureRedirectionURL = str +FromAddress = str +HeaderName = str +HeaderValue = str +HtmlPart = str +IAMRoleARN = str +Identity = str +MailFromDomainName = str +Max24HourSend = float +MaxItems = int +MaxResults = int +MaxSendRate = float +MessageData = str +MessageId = str +MessageTagName = str +MessageTagValue = str +NextToken = str +NotificationTopic = str +Policy = str +PolicyName = str +ReceiptFilterName = str +ReceiptRuleName = str +ReceiptRuleSetName = str +Recipient = str +RemoteMta = str +RenderedTemplate = str +ReportingMta = str +RuleOrRuleSetName = str +S3BucketName = str +S3KeyPrefix = str +SentLast24Hours = float +Subject = str +SubjectPart = str +SuccessRedirectionURL = str +TemplateContent = str +TemplateData = str +TemplateName = str +TextPart = str +VerificationToken = str + + +class BehaviorOnMXFailure(StrEnum): + UseDefaultValue = "UseDefaultValue" + RejectMessage = "RejectMessage" + + +class BounceType(StrEnum): + DoesNotExist = "DoesNotExist" + MessageTooLarge = "MessageTooLarge" + ExceededQuota = "ExceededQuota" + ContentRejected = "ContentRejected" + Undefined = "Undefined" + TemporaryFailure = "TemporaryFailure" + + +class BulkEmailStatus(StrEnum): + Success = "Success" + MessageRejected = "MessageRejected" + MailFromDomainNotVerified = "MailFromDomainNotVerified" + ConfigurationSetDoesNotExist = "ConfigurationSetDoesNotExist" + TemplateDoesNotExist = "TemplateDoesNotExist" + AccountSuspended = "AccountSuspended" + AccountThrottled = "AccountThrottled" + AccountDailyQuotaExceeded = "AccountDailyQuotaExceeded" + InvalidSendingPoolName = "InvalidSendingPoolName" + AccountSendingPaused = "AccountSendingPaused" + ConfigurationSetSendingPaused = "ConfigurationSetSendingPaused" + InvalidParameterValue = "InvalidParameterValue" + TransientFailure = "TransientFailure" + Failed = "Failed" + + +class ConfigurationSetAttribute(StrEnum): + eventDestinations = "eventDestinations" + trackingOptions = "trackingOptions" + deliveryOptions = "deliveryOptions" + reputationOptions = "reputationOptions" + + +class CustomMailFromStatus(StrEnum): + Pending = "Pending" + Success = "Success" + Failed = "Failed" + TemporaryFailure = "TemporaryFailure" + + +class DimensionValueSource(StrEnum): + messageTag = "messageTag" + emailHeader = "emailHeader" + linkTag = "linkTag" + + +class DsnAction(StrEnum): + failed = "failed" + delayed = "delayed" + delivered = "delivered" + relayed = "relayed" + expanded = "expanded" + + +class EventType(StrEnum): + send = "send" + reject = "reject" + bounce = "bounce" + complaint = "complaint" + delivery = "delivery" + open = "open" + click = "click" + renderingFailure = "renderingFailure" + + +class IdentityType(StrEnum): + EmailAddress = "EmailAddress" + Domain = "Domain" + + +class InvocationType(StrEnum): + Event = "Event" + RequestResponse = "RequestResponse" + + +class NotificationType(StrEnum): + Bounce = "Bounce" + Complaint = "Complaint" + Delivery = "Delivery" + + +class ReceiptFilterPolicy(StrEnum): + Block = "Block" + Allow = "Allow" + + +class SNSActionEncoding(StrEnum): + UTF_8 = "UTF-8" + Base64 = "Base64" + + +class StopScope(StrEnum): + RuleSet = "RuleSet" + + +class TlsPolicy(StrEnum): + Require = "Require" + Optional_ = "Optional" + + +class VerificationStatus(StrEnum): + Pending = "Pending" + Success = "Success" + Failed = "Failed" + TemporaryFailure = "TemporaryFailure" + NotStarted = "NotStarted" + + +class AccountSendingPausedException(ServiceException): + code: str = "AccountSendingPausedException" + sender_fault: bool = True + status_code: int = 400 + + +class AlreadyExistsException(ServiceException): + code: str = "AlreadyExists" + sender_fault: bool = True + status_code: int = 400 + Name: Optional[RuleOrRuleSetName] + + +class CannotDeleteException(ServiceException): + code: str = "CannotDelete" + sender_fault: bool = True + status_code: int = 400 + Name: Optional[RuleOrRuleSetName] + + +class ConfigurationSetAlreadyExistsException(ServiceException): + code: str = "ConfigurationSetAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + + +class ConfigurationSetDoesNotExistException(ServiceException): + code: str = "ConfigurationSetDoesNotExist" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + + +class ConfigurationSetSendingPausedException(ServiceException): + code: str = "ConfigurationSetSendingPausedException" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + + +class CustomVerificationEmailInvalidContentException(ServiceException): + code: str = "CustomVerificationEmailInvalidContent" + sender_fault: bool = True + status_code: int = 400 + + +class CustomVerificationEmailTemplateAlreadyExistsException(ServiceException): + code: str = "CustomVerificationEmailTemplateAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + CustomVerificationEmailTemplateName: Optional[TemplateName] + + +class CustomVerificationEmailTemplateDoesNotExistException(ServiceException): + code: str = "CustomVerificationEmailTemplateDoesNotExist" + sender_fault: bool = True + status_code: int = 400 + CustomVerificationEmailTemplateName: Optional[TemplateName] + + +class EventDestinationAlreadyExistsException(ServiceException): + code: str = "EventDestinationAlreadyExists" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + EventDestinationName: Optional[EventDestinationName] + + +class EventDestinationDoesNotExistException(ServiceException): + code: str = "EventDestinationDoesNotExist" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + EventDestinationName: Optional[EventDestinationName] + + +class FromEmailAddressNotVerifiedException(ServiceException): + code: str = "FromEmailAddressNotVerified" + sender_fault: bool = True + status_code: int = 400 + FromEmailAddress: Optional[FromAddress] + + +class InvalidCloudWatchDestinationException(ServiceException): + code: str = "InvalidCloudWatchDestination" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + EventDestinationName: Optional[EventDestinationName] + + +class InvalidConfigurationSetException(ServiceException): + code: str = "InvalidConfigurationSet" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidDeliveryOptionsException(ServiceException): + code: str = "InvalidDeliveryOptions" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidFirehoseDestinationException(ServiceException): + code: str = "InvalidFirehoseDestination" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + EventDestinationName: Optional[EventDestinationName] + + +class InvalidLambdaFunctionException(ServiceException): + code: str = "InvalidLambdaFunction" + sender_fault: bool = True + status_code: int = 400 + FunctionArn: Optional[AmazonResourceName] + + +class InvalidPolicyException(ServiceException): + code: str = "InvalidPolicy" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidRenderingParameterException(ServiceException): + code: str = "InvalidRenderingParameter" + sender_fault: bool = True + status_code: int = 400 + TemplateName: Optional[TemplateName] + + +class InvalidS3ConfigurationException(ServiceException): + code: str = "InvalidS3Configuration" + sender_fault: bool = True + status_code: int = 400 + Bucket: Optional[S3BucketName] + + +class InvalidSNSDestinationException(ServiceException): + code: str = "InvalidSNSDestination" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + EventDestinationName: Optional[EventDestinationName] + + +class InvalidSnsTopicException(ServiceException): + code: str = "InvalidSnsTopic" + sender_fault: bool = True + status_code: int = 400 + Topic: Optional[AmazonResourceName] + + +class InvalidTemplateException(ServiceException): + code: str = "InvalidTemplate" + sender_fault: bool = True + status_code: int = 400 + TemplateName: Optional[TemplateName] + + +class InvalidTrackingOptionsException(ServiceException): + code: str = "InvalidTrackingOptions" + sender_fault: bool = True + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class MailFromDomainNotVerifiedException(ServiceException): + code: str = "MailFromDomainNotVerifiedException" + sender_fault: bool = True + status_code: int = 400 + + +class MessageRejected(ServiceException): + code: str = "MessageRejected" + sender_fault: bool = True + status_code: int = 400 + + +class MissingRenderingAttributeException(ServiceException): + code: str = "MissingRenderingAttribute" + sender_fault: bool = True + status_code: int = 400 + TemplateName: Optional[TemplateName] + + +class ProductionAccessNotGrantedException(ServiceException): + code: str = "ProductionAccessNotGranted" + sender_fault: bool = True + status_code: int = 400 + + +class RuleDoesNotExistException(ServiceException): + code: str = "RuleDoesNotExist" + sender_fault: bool = True + status_code: int = 400 + Name: Optional[RuleOrRuleSetName] + + +class RuleSetDoesNotExistException(ServiceException): + code: str = "RuleSetDoesNotExist" + sender_fault: bool = True + status_code: int = 400 + Name: Optional[RuleOrRuleSetName] + + +class TemplateDoesNotExistException(ServiceException): + code: str = "TemplateDoesNotExist" + sender_fault: bool = True + status_code: int = 400 + TemplateName: Optional[TemplateName] + + +class TrackingOptionsAlreadyExistsException(ServiceException): + code: str = "TrackingOptionsAlreadyExistsException" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + + +class TrackingOptionsDoesNotExistException(ServiceException): + code: str = "TrackingOptionsDoesNotExistException" + sender_fault: bool = True + status_code: int = 400 + ConfigurationSetName: Optional[ConfigurationSetName] + + +class AddHeaderAction(TypedDict, total=False): + HeaderName: HeaderName + HeaderValue: HeaderValue + + +AddressList = List[Address] +ArrivalDate = datetime + + +class Content(TypedDict, total=False): + Data: MessageData + Charset: Optional[Charset] + + +class Body(TypedDict, total=False): + Text: Optional[Content] + Html: Optional[Content] + + +class BounceAction(TypedDict, total=False): + TopicArn: Optional[AmazonResourceName] + SmtpReplyCode: BounceSmtpReplyCode + StatusCode: Optional[BounceStatusCode] + Message: BounceMessage + Sender: Address + + +class ExtensionField(TypedDict, total=False): + Name: ExtensionFieldName + Value: ExtensionFieldValue + + +ExtensionFieldList = List[ExtensionField] +LastAttemptDate = datetime + + +class RecipientDsnFields(TypedDict, total=False): + FinalRecipient: Optional[Address] + Action: DsnAction + RemoteMta: Optional[RemoteMta] + Status: DsnStatus + DiagnosticCode: Optional[DiagnosticCode] + LastAttemptDate: Optional[LastAttemptDate] + ExtensionFields: Optional[ExtensionFieldList] + + +class BouncedRecipientInfo(TypedDict, total=False): + Recipient: Address + RecipientArn: Optional[AmazonResourceName] + BounceType: Optional[BounceType] + RecipientDsnFields: Optional[RecipientDsnFields] + + +BouncedRecipientInfoList = List[BouncedRecipientInfo] + + +class MessageTag(TypedDict, total=False): + Name: MessageTagName + Value: MessageTagValue + + +MessageTagList = List[MessageTag] + + +class Destination(TypedDict, total=False): + ToAddresses: Optional[AddressList] + CcAddresses: Optional[AddressList] + BccAddresses: Optional[AddressList] + + +class BulkEmailDestination(TypedDict, total=False): + Destination: Destination + ReplacementTags: Optional[MessageTagList] + ReplacementTemplateData: Optional[TemplateData] + + +BulkEmailDestinationList = List[BulkEmailDestination] + + +class BulkEmailDestinationStatus(TypedDict, total=False): + Status: Optional[BulkEmailStatus] + Error: Optional[Error] + MessageId: Optional[MessageId] + + +BulkEmailDestinationStatusList = List[BulkEmailDestinationStatus] + + +class CloneReceiptRuleSetRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + OriginalRuleSetName: ReceiptRuleSetName + + +class CloneReceiptRuleSetResponse(TypedDict, total=False): + pass + + +class CloudWatchDimensionConfiguration(TypedDict, total=False): + DimensionName: DimensionName + DimensionValueSource: DimensionValueSource + DefaultDimensionValue: DefaultDimensionValue + + +CloudWatchDimensionConfigurations = List[CloudWatchDimensionConfiguration] + + +class CloudWatchDestination(TypedDict, total=False): + DimensionConfigurations: CloudWatchDimensionConfigurations + + +class ConfigurationSet(TypedDict, total=False): + Name: ConfigurationSetName + + +ConfigurationSetAttributeList = List[ConfigurationSetAttribute] +ConfigurationSets = List[ConfigurationSet] + + +class ConnectAction(TypedDict, total=False): + InstanceARN: ConnectInstanceArn + IAMRoleARN: IAMRoleARN + + +Counter = int + + +class SNSDestination(TypedDict, total=False): + TopicARN: AmazonResourceName + + +class KinesisFirehoseDestination(TypedDict, total=False): + IAMRoleARN: AmazonResourceName + DeliveryStreamARN: AmazonResourceName + + +EventTypes = List[EventType] + + +class EventDestination(TypedDict, total=False): + Name: EventDestinationName + Enabled: Optional[Enabled] + MatchingEventTypes: EventTypes + KinesisFirehoseDestination: Optional[KinesisFirehoseDestination] + CloudWatchDestination: Optional[CloudWatchDestination] + SNSDestination: Optional[SNSDestination] + + +class CreateConfigurationSetEventDestinationRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + EventDestination: EventDestination + + +class CreateConfigurationSetEventDestinationResponse(TypedDict, total=False): + pass + + +class CreateConfigurationSetRequest(ServiceRequest): + ConfigurationSet: ConfigurationSet + + +class CreateConfigurationSetResponse(TypedDict, total=False): + pass + + +class TrackingOptions(TypedDict, total=False): + CustomRedirectDomain: Optional[CustomRedirectDomain] + + +class CreateConfigurationSetTrackingOptionsRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + TrackingOptions: TrackingOptions + + +class CreateConfigurationSetTrackingOptionsResponse(TypedDict, total=False): + pass + + +class CreateCustomVerificationEmailTemplateRequest(ServiceRequest): + TemplateName: TemplateName + FromEmailAddress: FromAddress + TemplateSubject: Subject + TemplateContent: TemplateContent + SuccessRedirectionURL: SuccessRedirectionURL + FailureRedirectionURL: FailureRedirectionURL + + +class ReceiptIpFilter(TypedDict, total=False): + Policy: ReceiptFilterPolicy + Cidr: Cidr + + +class ReceiptFilter(TypedDict, total=False): + Name: ReceiptFilterName + IpFilter: ReceiptIpFilter + + +class CreateReceiptFilterRequest(ServiceRequest): + Filter: ReceiptFilter + + +class CreateReceiptFilterResponse(TypedDict, total=False): + pass + + +class SNSAction(TypedDict, total=False): + TopicArn: AmazonResourceName + Encoding: Optional[SNSActionEncoding] + + +class StopAction(TypedDict, total=False): + Scope: StopScope + TopicArn: Optional[AmazonResourceName] + + +class LambdaAction(TypedDict, total=False): + TopicArn: Optional[AmazonResourceName] + FunctionArn: AmazonResourceName + InvocationType: Optional[InvocationType] + + +class WorkmailAction(TypedDict, total=False): + TopicArn: Optional[AmazonResourceName] + OrganizationArn: AmazonResourceName + + +class S3Action(TypedDict, total=False): + TopicArn: Optional[AmazonResourceName] + BucketName: S3BucketName + ObjectKeyPrefix: Optional[S3KeyPrefix] + KmsKeyArn: Optional[AmazonResourceName] + IamRoleArn: Optional[IAMRoleARN] + + +class ReceiptAction(TypedDict, total=False): + S3Action: Optional[S3Action] + BounceAction: Optional[BounceAction] + WorkmailAction: Optional[WorkmailAction] + LambdaAction: Optional[LambdaAction] + StopAction: Optional[StopAction] + AddHeaderAction: Optional[AddHeaderAction] + SNSAction: Optional[SNSAction] + ConnectAction: Optional[ConnectAction] + + +ReceiptActionsList = List[ReceiptAction] +RecipientsList = List[Recipient] + + +class ReceiptRule(TypedDict, total=False): + Name: ReceiptRuleName + Enabled: Optional[Enabled] + TlsPolicy: Optional[TlsPolicy] + Recipients: Optional[RecipientsList] + Actions: Optional[ReceiptActionsList] + ScanEnabled: Optional[Enabled] + + +class CreateReceiptRuleRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + After: Optional[ReceiptRuleName] + Rule: ReceiptRule + + +class CreateReceiptRuleResponse(TypedDict, total=False): + pass + + +class CreateReceiptRuleSetRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + + +class CreateReceiptRuleSetResponse(TypedDict, total=False): + pass + + +class Template(TypedDict, total=False): + TemplateName: TemplateName + SubjectPart: Optional[SubjectPart] + TextPart: Optional[TextPart] + HtmlPart: Optional[HtmlPart] + + +class CreateTemplateRequest(ServiceRequest): + Template: Template + + +class CreateTemplateResponse(TypedDict, total=False): + pass + + +class CustomVerificationEmailTemplate(TypedDict, total=False): + TemplateName: Optional[TemplateName] + FromEmailAddress: Optional[FromAddress] + TemplateSubject: Optional[Subject] + SuccessRedirectionURL: Optional[SuccessRedirectionURL] + FailureRedirectionURL: Optional[FailureRedirectionURL] + + +CustomVerificationEmailTemplates = List[CustomVerificationEmailTemplate] + + +class DeleteConfigurationSetEventDestinationRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + EventDestinationName: EventDestinationName + + +class DeleteConfigurationSetEventDestinationResponse(TypedDict, total=False): + pass + + +class DeleteConfigurationSetRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + + +class DeleteConfigurationSetResponse(TypedDict, total=False): + pass + + +class DeleteConfigurationSetTrackingOptionsRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + + +class DeleteConfigurationSetTrackingOptionsResponse(TypedDict, total=False): + pass + + +class DeleteCustomVerificationEmailTemplateRequest(ServiceRequest): + TemplateName: TemplateName + + +class DeleteIdentityPolicyRequest(ServiceRequest): + Identity: Identity + PolicyName: PolicyName + + +class DeleteIdentityPolicyResponse(TypedDict, total=False): + pass + + +class DeleteIdentityRequest(ServiceRequest): + Identity: Identity + + +class DeleteIdentityResponse(TypedDict, total=False): + pass + + +class DeleteReceiptFilterRequest(ServiceRequest): + FilterName: ReceiptFilterName + + +class DeleteReceiptFilterResponse(TypedDict, total=False): + pass + + +class DeleteReceiptRuleRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + RuleName: ReceiptRuleName + + +class DeleteReceiptRuleResponse(TypedDict, total=False): + pass + + +class DeleteReceiptRuleSetRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + + +class DeleteReceiptRuleSetResponse(TypedDict, total=False): + pass + + +class DeleteTemplateRequest(ServiceRequest): + TemplateName: TemplateName + + +class DeleteTemplateResponse(TypedDict, total=False): + pass + + +class DeleteVerifiedEmailAddressRequest(ServiceRequest): + EmailAddress: Address + + +class DeliveryOptions(TypedDict, total=False): + TlsPolicy: Optional[TlsPolicy] + + +class DescribeActiveReceiptRuleSetRequest(ServiceRequest): + pass + + +ReceiptRulesList = List[ReceiptRule] +Timestamp = datetime + + +class ReceiptRuleSetMetadata(TypedDict, total=False): + Name: Optional[ReceiptRuleSetName] + CreatedTimestamp: Optional[Timestamp] + + +class DescribeActiveReceiptRuleSetResponse(TypedDict, total=False): + Metadata: Optional[ReceiptRuleSetMetadata] + Rules: Optional[ReceiptRulesList] + + +class DescribeConfigurationSetRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + ConfigurationSetAttributeNames: Optional[ConfigurationSetAttributeList] + + +LastFreshStart = datetime + + +class ReputationOptions(TypedDict, total=False): + SendingEnabled: Optional[Enabled] + ReputationMetricsEnabled: Optional[Enabled] + LastFreshStart: Optional[LastFreshStart] + + +EventDestinations = List[EventDestination] + + +class DescribeConfigurationSetResponse(TypedDict, total=False): + ConfigurationSet: Optional[ConfigurationSet] + EventDestinations: Optional[EventDestinations] + TrackingOptions: Optional[TrackingOptions] + DeliveryOptions: Optional[DeliveryOptions] + ReputationOptions: Optional[ReputationOptions] + + +class DescribeReceiptRuleRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + RuleName: ReceiptRuleName + + +class DescribeReceiptRuleResponse(TypedDict, total=False): + Rule: Optional[ReceiptRule] + + +class DescribeReceiptRuleSetRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + + +class DescribeReceiptRuleSetResponse(TypedDict, total=False): + Metadata: Optional[ReceiptRuleSetMetadata] + Rules: Optional[ReceiptRulesList] + + +VerificationTokenList = List[VerificationToken] + + +class IdentityDkimAttributes(TypedDict, total=False): + DkimEnabled: Enabled + DkimVerificationStatus: VerificationStatus + DkimTokens: Optional[VerificationTokenList] + + +DkimAttributes = Dict[Identity, IdentityDkimAttributes] + + +class GetAccountSendingEnabledResponse(TypedDict, total=False): + Enabled: Optional[Enabled] + + +class GetCustomVerificationEmailTemplateRequest(ServiceRequest): + TemplateName: TemplateName + + +class GetCustomVerificationEmailTemplateResponse(TypedDict, total=False): + TemplateName: Optional[TemplateName] + FromEmailAddress: Optional[FromAddress] + TemplateSubject: Optional[Subject] + TemplateContent: Optional[TemplateContent] + SuccessRedirectionURL: Optional[SuccessRedirectionURL] + FailureRedirectionURL: Optional[FailureRedirectionURL] + + +IdentityList = List[Identity] + + +class GetIdentityDkimAttributesRequest(ServiceRequest): + Identities: IdentityList + + +class GetIdentityDkimAttributesResponse(TypedDict, total=False): + DkimAttributes: DkimAttributes + + +class GetIdentityMailFromDomainAttributesRequest(ServiceRequest): + Identities: IdentityList + + +class IdentityMailFromDomainAttributes(TypedDict, total=False): + MailFromDomain: MailFromDomainName + MailFromDomainStatus: CustomMailFromStatus + BehaviorOnMXFailure: BehaviorOnMXFailure + + +MailFromDomainAttributes = Dict[Identity, IdentityMailFromDomainAttributes] + + +class GetIdentityMailFromDomainAttributesResponse(TypedDict, total=False): + MailFromDomainAttributes: MailFromDomainAttributes + + +class GetIdentityNotificationAttributesRequest(ServiceRequest): + Identities: IdentityList + + +class IdentityNotificationAttributes(TypedDict, total=False): + BounceTopic: NotificationTopic + ComplaintTopic: NotificationTopic + DeliveryTopic: NotificationTopic + ForwardingEnabled: Enabled + HeadersInBounceNotificationsEnabled: Optional[Enabled] + HeadersInComplaintNotificationsEnabled: Optional[Enabled] + HeadersInDeliveryNotificationsEnabled: Optional[Enabled] + + +NotificationAttributes = Dict[Identity, IdentityNotificationAttributes] + + +class GetIdentityNotificationAttributesResponse(TypedDict, total=False): + NotificationAttributes: NotificationAttributes + + +PolicyNameList = List[PolicyName] + + +class GetIdentityPoliciesRequest(ServiceRequest): + Identity: Identity + PolicyNames: PolicyNameList + + +PolicyMap = Dict[PolicyName, Policy] + + +class GetIdentityPoliciesResponse(TypedDict, total=False): + Policies: PolicyMap + + +class GetIdentityVerificationAttributesRequest(ServiceRequest): + Identities: IdentityList + + +class IdentityVerificationAttributes(TypedDict, total=False): + VerificationStatus: VerificationStatus + VerificationToken: Optional[VerificationToken] + + +VerificationAttributes = Dict[Identity, IdentityVerificationAttributes] + + +class GetIdentityVerificationAttributesResponse(TypedDict, total=False): + VerificationAttributes: VerificationAttributes + + +class GetSendQuotaResponse(TypedDict, total=False): + Max24HourSend: Optional[Max24HourSend] + MaxSendRate: Optional[MaxSendRate] + SentLast24Hours: Optional[SentLast24Hours] + + +class SendDataPoint(TypedDict, total=False): + Timestamp: Optional[Timestamp] + DeliveryAttempts: Optional[Counter] + Bounces: Optional[Counter] + Complaints: Optional[Counter] + Rejects: Optional[Counter] + + +SendDataPointList = List[SendDataPoint] + + +class GetSendStatisticsResponse(TypedDict, total=False): + SendDataPoints: Optional[SendDataPointList] + + +class GetTemplateRequest(ServiceRequest): + TemplateName: TemplateName + + +class GetTemplateResponse(TypedDict, total=False): + Template: Optional[Template] + + +class ListConfigurationSetsRequest(ServiceRequest): + NextToken: Optional[NextToken] + MaxItems: Optional[MaxItems] + + +class ListConfigurationSetsResponse(TypedDict, total=False): + ConfigurationSets: Optional[ConfigurationSets] + NextToken: Optional[NextToken] + + +class ListCustomVerificationEmailTemplatesRequest(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ListCustomVerificationEmailTemplatesResponse(TypedDict, total=False): + CustomVerificationEmailTemplates: Optional[CustomVerificationEmailTemplates] + NextToken: Optional[NextToken] + + +class ListIdentitiesRequest(ServiceRequest): + IdentityType: Optional[IdentityType] + NextToken: Optional[NextToken] + MaxItems: Optional[MaxItems] + + +class ListIdentitiesResponse(TypedDict, total=False): + Identities: IdentityList + NextToken: Optional[NextToken] + + +class ListIdentityPoliciesRequest(ServiceRequest): + Identity: Identity + + +class ListIdentityPoliciesResponse(TypedDict, total=False): + PolicyNames: PolicyNameList + + +class ListReceiptFiltersRequest(ServiceRequest): + pass + + +ReceiptFilterList = List[ReceiptFilter] + + +class ListReceiptFiltersResponse(TypedDict, total=False): + Filters: Optional[ReceiptFilterList] + + +class ListReceiptRuleSetsRequest(ServiceRequest): + NextToken: Optional[NextToken] + + +ReceiptRuleSetsLists = List[ReceiptRuleSetMetadata] + + +class ListReceiptRuleSetsResponse(TypedDict, total=False): + RuleSets: Optional[ReceiptRuleSetsLists] + NextToken: Optional[NextToken] + + +class ListTemplatesRequest(ServiceRequest): + NextToken: Optional[NextToken] + MaxItems: Optional[MaxItems] + + +class TemplateMetadata(TypedDict, total=False): + Name: Optional[TemplateName] + CreatedTimestamp: Optional[Timestamp] + + +TemplateMetadataList = List[TemplateMetadata] + + +class ListTemplatesResponse(TypedDict, total=False): + TemplatesMetadata: Optional[TemplateMetadataList] + NextToken: Optional[NextToken] + + +class ListVerifiedEmailAddressesResponse(TypedDict, total=False): + VerifiedEmailAddresses: Optional[AddressList] + + +class Message(TypedDict, total=False): + Subject: Content + Body: Body + + +class MessageDsn(TypedDict, total=False): + ReportingMta: ReportingMta + ArrivalDate: Optional[ArrivalDate] + ExtensionFields: Optional[ExtensionFieldList] + + +class PutConfigurationSetDeliveryOptionsRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + DeliveryOptions: Optional[DeliveryOptions] + + +class PutConfigurationSetDeliveryOptionsResponse(TypedDict, total=False): + pass + + +class PutIdentityPolicyRequest(ServiceRequest): + Identity: Identity + PolicyName: PolicyName + Policy: Policy + + +class PutIdentityPolicyResponse(TypedDict, total=False): + pass + + +RawMessageData = bytes + + +class RawMessage(TypedDict, total=False): + Data: RawMessageData + + +ReceiptRuleNamesList = List[ReceiptRuleName] + + +class ReorderReceiptRuleSetRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + RuleNames: ReceiptRuleNamesList + + +class ReorderReceiptRuleSetResponse(TypedDict, total=False): + pass + + +class SendBounceRequest(ServiceRequest): + OriginalMessageId: MessageId + BounceSender: Address + Explanation: Optional[Explanation] + MessageDsn: Optional[MessageDsn] + BouncedRecipientInfoList: BouncedRecipientInfoList + BounceSenderArn: Optional[AmazonResourceName] + + +class SendBounceResponse(TypedDict, total=False): + MessageId: Optional[MessageId] + + +class SendBulkTemplatedEmailRequest(ServiceRequest): + Source: Address + SourceArn: Optional[AmazonResourceName] + ReplyToAddresses: Optional[AddressList] + ReturnPath: Optional[Address] + ReturnPathArn: Optional[AmazonResourceName] + ConfigurationSetName: Optional[ConfigurationSetName] + DefaultTags: Optional[MessageTagList] + Template: TemplateName + TemplateArn: Optional[AmazonResourceName] + DefaultTemplateData: TemplateData + Destinations: BulkEmailDestinationList + + +class SendBulkTemplatedEmailResponse(TypedDict, total=False): + Status: BulkEmailDestinationStatusList + + +class SendCustomVerificationEmailRequest(ServiceRequest): + EmailAddress: Address + TemplateName: TemplateName + ConfigurationSetName: Optional[ConfigurationSetName] + + +class SendCustomVerificationEmailResponse(TypedDict, total=False): + MessageId: Optional[MessageId] + + +class SendEmailRequest(ServiceRequest): + Source: Address + Destination: Destination + Message: Message + ReplyToAddresses: Optional[AddressList] + ReturnPath: Optional[Address] + SourceArn: Optional[AmazonResourceName] + ReturnPathArn: Optional[AmazonResourceName] + Tags: Optional[MessageTagList] + ConfigurationSetName: Optional[ConfigurationSetName] + + +class SendEmailResponse(TypedDict, total=False): + MessageId: MessageId + + +class SendRawEmailRequest(ServiceRequest): + Source: Optional[Address] + Destinations: Optional[AddressList] + RawMessage: RawMessage + FromArn: Optional[AmazonResourceName] + SourceArn: Optional[AmazonResourceName] + ReturnPathArn: Optional[AmazonResourceName] + Tags: Optional[MessageTagList] + ConfigurationSetName: Optional[ConfigurationSetName] + + +class SendRawEmailResponse(TypedDict, total=False): + MessageId: MessageId + + +class SendTemplatedEmailRequest(ServiceRequest): + Source: Address + Destination: Destination + ReplyToAddresses: Optional[AddressList] + ReturnPath: Optional[Address] + SourceArn: Optional[AmazonResourceName] + ReturnPathArn: Optional[AmazonResourceName] + Tags: Optional[MessageTagList] + ConfigurationSetName: Optional[ConfigurationSetName] + Template: TemplateName + TemplateArn: Optional[AmazonResourceName] + TemplateData: TemplateData + + +class SendTemplatedEmailResponse(TypedDict, total=False): + MessageId: MessageId + + +class SetActiveReceiptRuleSetRequest(ServiceRequest): + RuleSetName: Optional[ReceiptRuleSetName] + + +class SetActiveReceiptRuleSetResponse(TypedDict, total=False): + pass + + +class SetIdentityDkimEnabledRequest(ServiceRequest): + Identity: Identity + DkimEnabled: Enabled + + +class SetIdentityDkimEnabledResponse(TypedDict, total=False): + pass + + +class SetIdentityFeedbackForwardingEnabledRequest(ServiceRequest): + Identity: Identity + ForwardingEnabled: Enabled + + +class SetIdentityFeedbackForwardingEnabledResponse(TypedDict, total=False): + pass + + +class SetIdentityHeadersInNotificationsEnabledRequest(ServiceRequest): + Identity: Identity + NotificationType: NotificationType + Enabled: Enabled + + +class SetIdentityHeadersInNotificationsEnabledResponse(TypedDict, total=False): + pass + + +class SetIdentityMailFromDomainRequest(ServiceRequest): + Identity: Identity + MailFromDomain: Optional[MailFromDomainName] + BehaviorOnMXFailure: Optional[BehaviorOnMXFailure] + + +class SetIdentityMailFromDomainResponse(TypedDict, total=False): + pass + + +class SetIdentityNotificationTopicRequest(ServiceRequest): + Identity: Identity + NotificationType: NotificationType + SnsTopic: Optional[NotificationTopic] + + +class SetIdentityNotificationTopicResponse(TypedDict, total=False): + pass + + +class SetReceiptRulePositionRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + RuleName: ReceiptRuleName + After: Optional[ReceiptRuleName] + + +class SetReceiptRulePositionResponse(TypedDict, total=False): + pass + + +class TestRenderTemplateRequest(ServiceRequest): + TemplateName: TemplateName + TemplateData: TemplateData + + +class TestRenderTemplateResponse(TypedDict, total=False): + RenderedTemplate: Optional[RenderedTemplate] + + +class UpdateAccountSendingEnabledRequest(ServiceRequest): + Enabled: Optional[Enabled] + + +class UpdateConfigurationSetEventDestinationRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + EventDestination: EventDestination + + +class UpdateConfigurationSetEventDestinationResponse(TypedDict, total=False): + pass + + +class UpdateConfigurationSetReputationMetricsEnabledRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + Enabled: Enabled + + +class UpdateConfigurationSetSendingEnabledRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + Enabled: Enabled + + +class UpdateConfigurationSetTrackingOptionsRequest(ServiceRequest): + ConfigurationSetName: ConfigurationSetName + TrackingOptions: TrackingOptions + + +class UpdateConfigurationSetTrackingOptionsResponse(TypedDict, total=False): + pass + + +class UpdateCustomVerificationEmailTemplateRequest(ServiceRequest): + TemplateName: TemplateName + FromEmailAddress: Optional[FromAddress] + TemplateSubject: Optional[Subject] + TemplateContent: Optional[TemplateContent] + SuccessRedirectionURL: Optional[SuccessRedirectionURL] + FailureRedirectionURL: Optional[FailureRedirectionURL] + + +class UpdateReceiptRuleRequest(ServiceRequest): + RuleSetName: ReceiptRuleSetName + Rule: ReceiptRule + + +class UpdateReceiptRuleResponse(TypedDict, total=False): + pass + + +class UpdateTemplateRequest(ServiceRequest): + Template: Template + + +class UpdateTemplateResponse(TypedDict, total=False): + pass + + +class VerifyDomainDkimRequest(ServiceRequest): + Domain: Domain + + +class VerifyDomainDkimResponse(TypedDict, total=False): + DkimTokens: VerificationTokenList + + +class VerifyDomainIdentityRequest(ServiceRequest): + Domain: Domain + + +class VerifyDomainIdentityResponse(TypedDict, total=False): + VerificationToken: VerificationToken + + +class VerifyEmailAddressRequest(ServiceRequest): + EmailAddress: Address + + +class VerifyEmailIdentityRequest(ServiceRequest): + EmailAddress: Address + + +class VerifyEmailIdentityResponse(TypedDict, total=False): + pass + + +class SesApi: + service = "ses" + version = "2010-12-01" + + @handler("CloneReceiptRuleSet") + def clone_receipt_rule_set( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + original_rule_set_name: ReceiptRuleSetName, + **kwargs, + ) -> CloneReceiptRuleSetResponse: + raise NotImplementedError + + @handler("CreateConfigurationSet") + def create_configuration_set( + self, context: RequestContext, configuration_set: ConfigurationSet, **kwargs + ) -> CreateConfigurationSetResponse: + raise NotImplementedError + + @handler("CreateConfigurationSetEventDestination") + def create_configuration_set_event_destination( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + event_destination: EventDestination, + **kwargs, + ) -> CreateConfigurationSetEventDestinationResponse: + raise NotImplementedError + + @handler("CreateConfigurationSetTrackingOptions") + def create_configuration_set_tracking_options( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + tracking_options: TrackingOptions, + **kwargs, + ) -> CreateConfigurationSetTrackingOptionsResponse: + raise NotImplementedError + + @handler("CreateCustomVerificationEmailTemplate") + def create_custom_verification_email_template( + self, + context: RequestContext, + template_name: TemplateName, + from_email_address: FromAddress, + template_subject: Subject, + template_content: TemplateContent, + success_redirection_url: SuccessRedirectionURL, + failure_redirection_url: FailureRedirectionURL, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CreateReceiptFilter") + def create_receipt_filter( + self, context: RequestContext, filter: ReceiptFilter, **kwargs + ) -> CreateReceiptFilterResponse: + raise NotImplementedError + + @handler("CreateReceiptRule") + def create_receipt_rule( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + rule: ReceiptRule, + after: ReceiptRuleName | None = None, + **kwargs, + ) -> CreateReceiptRuleResponse: + raise NotImplementedError + + @handler("CreateReceiptRuleSet") + def create_receipt_rule_set( + self, context: RequestContext, rule_set_name: ReceiptRuleSetName, **kwargs + ) -> CreateReceiptRuleSetResponse: + raise NotImplementedError + + @handler("CreateTemplate") + def create_template( + self, context: RequestContext, template: Template, **kwargs + ) -> CreateTemplateResponse: + raise NotImplementedError + + @handler("DeleteConfigurationSet") + def delete_configuration_set( + self, context: RequestContext, configuration_set_name: ConfigurationSetName, **kwargs + ) -> DeleteConfigurationSetResponse: + raise NotImplementedError + + @handler("DeleteConfigurationSetEventDestination") + def delete_configuration_set_event_destination( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + event_destination_name: EventDestinationName, + **kwargs, + ) -> DeleteConfigurationSetEventDestinationResponse: + raise NotImplementedError + + @handler("DeleteConfigurationSetTrackingOptions") + def delete_configuration_set_tracking_options( + self, context: RequestContext, configuration_set_name: ConfigurationSetName, **kwargs + ) -> DeleteConfigurationSetTrackingOptionsResponse: + raise NotImplementedError + + @handler("DeleteCustomVerificationEmailTemplate") + def delete_custom_verification_email_template( + self, context: RequestContext, template_name: TemplateName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteIdentity") + def delete_identity( + self, context: RequestContext, identity: Identity, **kwargs + ) -> DeleteIdentityResponse: + raise NotImplementedError + + @handler("DeleteIdentityPolicy") + def delete_identity_policy( + self, context: RequestContext, identity: Identity, policy_name: PolicyName, **kwargs + ) -> DeleteIdentityPolicyResponse: + raise NotImplementedError + + @handler("DeleteReceiptFilter") + def delete_receipt_filter( + self, context: RequestContext, filter_name: ReceiptFilterName, **kwargs + ) -> DeleteReceiptFilterResponse: + raise NotImplementedError + + @handler("DeleteReceiptRule") + def delete_receipt_rule( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + rule_name: ReceiptRuleName, + **kwargs, + ) -> DeleteReceiptRuleResponse: + raise NotImplementedError + + @handler("DeleteReceiptRuleSet") + def delete_receipt_rule_set( + self, context: RequestContext, rule_set_name: ReceiptRuleSetName, **kwargs + ) -> DeleteReceiptRuleSetResponse: + raise NotImplementedError + + @handler("DeleteTemplate") + def delete_template( + self, context: RequestContext, template_name: TemplateName, **kwargs + ) -> DeleteTemplateResponse: + raise NotImplementedError + + @handler("DeleteVerifiedEmailAddress") + def delete_verified_email_address( + self, context: RequestContext, email_address: Address, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DescribeActiveReceiptRuleSet") + def describe_active_receipt_rule_set( + self, context: RequestContext, **kwargs + ) -> DescribeActiveReceiptRuleSetResponse: + raise NotImplementedError + + @handler("DescribeConfigurationSet") + def describe_configuration_set( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + configuration_set_attribute_names: ConfigurationSetAttributeList | None = None, + **kwargs, + ) -> DescribeConfigurationSetResponse: + raise NotImplementedError + + @handler("DescribeReceiptRule") + def describe_receipt_rule( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + rule_name: ReceiptRuleName, + **kwargs, + ) -> DescribeReceiptRuleResponse: + raise NotImplementedError + + @handler("DescribeReceiptRuleSet") + def describe_receipt_rule_set( + self, context: RequestContext, rule_set_name: ReceiptRuleSetName, **kwargs + ) -> DescribeReceiptRuleSetResponse: + raise NotImplementedError + + @handler("GetAccountSendingEnabled") + def get_account_sending_enabled( + self, context: RequestContext, **kwargs + ) -> GetAccountSendingEnabledResponse: + raise NotImplementedError + + @handler("GetCustomVerificationEmailTemplate") + def get_custom_verification_email_template( + self, context: RequestContext, template_name: TemplateName, **kwargs + ) -> GetCustomVerificationEmailTemplateResponse: + raise NotImplementedError + + @handler("GetIdentityDkimAttributes") + def get_identity_dkim_attributes( + self, context: RequestContext, identities: IdentityList, **kwargs + ) -> GetIdentityDkimAttributesResponse: + raise NotImplementedError + + @handler("GetIdentityMailFromDomainAttributes") + def get_identity_mail_from_domain_attributes( + self, context: RequestContext, identities: IdentityList, **kwargs + ) -> GetIdentityMailFromDomainAttributesResponse: + raise NotImplementedError + + @handler("GetIdentityNotificationAttributes") + def get_identity_notification_attributes( + self, context: RequestContext, identities: IdentityList, **kwargs + ) -> GetIdentityNotificationAttributesResponse: + raise NotImplementedError + + @handler("GetIdentityPolicies") + def get_identity_policies( + self, context: RequestContext, identity: Identity, policy_names: PolicyNameList, **kwargs + ) -> GetIdentityPoliciesResponse: + raise NotImplementedError + + @handler("GetIdentityVerificationAttributes") + def get_identity_verification_attributes( + self, context: RequestContext, identities: IdentityList, **kwargs + ) -> GetIdentityVerificationAttributesResponse: + raise NotImplementedError + + @handler("GetSendQuota") + def get_send_quota(self, context: RequestContext, **kwargs) -> GetSendQuotaResponse: + raise NotImplementedError + + @handler("GetSendStatistics") + def get_send_statistics(self, context: RequestContext, **kwargs) -> GetSendStatisticsResponse: + raise NotImplementedError + + @handler("GetTemplate") + def get_template( + self, context: RequestContext, template_name: TemplateName, **kwargs + ) -> GetTemplateResponse: + raise NotImplementedError + + @handler("ListConfigurationSets") + def list_configuration_sets( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_items: MaxItems | None = None, + **kwargs, + ) -> ListConfigurationSetsResponse: + raise NotImplementedError + + @handler("ListCustomVerificationEmailTemplates") + def list_custom_verification_email_templates( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListCustomVerificationEmailTemplatesResponse: + raise NotImplementedError + + @handler("ListIdentities") + def list_identities( + self, + context: RequestContext, + identity_type: IdentityType | None = None, + next_token: NextToken | None = None, + max_items: MaxItems | None = None, + **kwargs, + ) -> ListIdentitiesResponse: + raise NotImplementedError + + @handler("ListIdentityPolicies") + def list_identity_policies( + self, context: RequestContext, identity: Identity, **kwargs + ) -> ListIdentityPoliciesResponse: + raise NotImplementedError + + @handler("ListReceiptFilters") + def list_receipt_filters(self, context: RequestContext, **kwargs) -> ListReceiptFiltersResponse: + raise NotImplementedError + + @handler("ListReceiptRuleSets") + def list_receipt_rule_sets( + self, context: RequestContext, next_token: NextToken | None = None, **kwargs + ) -> ListReceiptRuleSetsResponse: + raise NotImplementedError + + @handler("ListTemplates") + def list_templates( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_items: MaxItems | None = None, + **kwargs, + ) -> ListTemplatesResponse: + raise NotImplementedError + + @handler("ListVerifiedEmailAddresses") + def list_verified_email_addresses( + self, context: RequestContext, **kwargs + ) -> ListVerifiedEmailAddressesResponse: + raise NotImplementedError + + @handler("PutConfigurationSetDeliveryOptions") + def put_configuration_set_delivery_options( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + delivery_options: DeliveryOptions | None = None, + **kwargs, + ) -> PutConfigurationSetDeliveryOptionsResponse: + raise NotImplementedError + + @handler("PutIdentityPolicy") + def put_identity_policy( + self, + context: RequestContext, + identity: Identity, + policy_name: PolicyName, + policy: Policy, + **kwargs, + ) -> PutIdentityPolicyResponse: + raise NotImplementedError + + @handler("ReorderReceiptRuleSet") + def reorder_receipt_rule_set( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + rule_names: ReceiptRuleNamesList, + **kwargs, + ) -> ReorderReceiptRuleSetResponse: + raise NotImplementedError + + @handler("SendBounce") + def send_bounce( + self, + context: RequestContext, + original_message_id: MessageId, + bounce_sender: Address, + bounced_recipient_info_list: BouncedRecipientInfoList, + explanation: Explanation | None = None, + message_dsn: MessageDsn | None = None, + bounce_sender_arn: AmazonResourceName | None = None, + **kwargs, + ) -> SendBounceResponse: + raise NotImplementedError + + @handler("SendBulkTemplatedEmail") + def send_bulk_templated_email( + self, + context: RequestContext, + source: Address, + template: TemplateName, + default_template_data: TemplateData, + destinations: BulkEmailDestinationList, + source_arn: AmazonResourceName | None = None, + reply_to_addresses: AddressList | None = None, + return_path: Address | None = None, + return_path_arn: AmazonResourceName | None = None, + configuration_set_name: ConfigurationSetName | None = None, + default_tags: MessageTagList | None = None, + template_arn: AmazonResourceName | None = None, + **kwargs, + ) -> SendBulkTemplatedEmailResponse: + raise NotImplementedError + + @handler("SendCustomVerificationEmail") + def send_custom_verification_email( + self, + context: RequestContext, + email_address: Address, + template_name: TemplateName, + configuration_set_name: ConfigurationSetName | None = None, + **kwargs, + ) -> SendCustomVerificationEmailResponse: + raise NotImplementedError + + @handler("SendEmail") + def send_email( + self, + context: RequestContext, + source: Address, + destination: Destination, + message: Message, + reply_to_addresses: AddressList | None = None, + return_path: Address | None = None, + source_arn: AmazonResourceName | None = None, + return_path_arn: AmazonResourceName | None = None, + tags: MessageTagList | None = None, + configuration_set_name: ConfigurationSetName | None = None, + **kwargs, + ) -> SendEmailResponse: + raise NotImplementedError + + @handler("SendRawEmail") + def send_raw_email( + self, + context: RequestContext, + raw_message: RawMessage, + source: Address | None = None, + destinations: AddressList | None = None, + from_arn: AmazonResourceName | None = None, + source_arn: AmazonResourceName | None = None, + return_path_arn: AmazonResourceName | None = None, + tags: MessageTagList | None = None, + configuration_set_name: ConfigurationSetName | None = None, + **kwargs, + ) -> SendRawEmailResponse: + raise NotImplementedError + + @handler("SendTemplatedEmail") + def send_templated_email( + self, + context: RequestContext, + source: Address, + destination: Destination, + template: TemplateName, + template_data: TemplateData, + reply_to_addresses: AddressList | None = None, + return_path: Address | None = None, + source_arn: AmazonResourceName | None = None, + return_path_arn: AmazonResourceName | None = None, + tags: MessageTagList | None = None, + configuration_set_name: ConfigurationSetName | None = None, + template_arn: AmazonResourceName | None = None, + **kwargs, + ) -> SendTemplatedEmailResponse: + raise NotImplementedError + + @handler("SetActiveReceiptRuleSet") + def set_active_receipt_rule_set( + self, context: RequestContext, rule_set_name: ReceiptRuleSetName | None = None, **kwargs + ) -> SetActiveReceiptRuleSetResponse: + raise NotImplementedError + + @handler("SetIdentityDkimEnabled") + def set_identity_dkim_enabled( + self, context: RequestContext, identity: Identity, dkim_enabled: Enabled, **kwargs + ) -> SetIdentityDkimEnabledResponse: + raise NotImplementedError + + @handler("SetIdentityFeedbackForwardingEnabled") + def set_identity_feedback_forwarding_enabled( + self, context: RequestContext, identity: Identity, forwarding_enabled: Enabled, **kwargs + ) -> SetIdentityFeedbackForwardingEnabledResponse: + raise NotImplementedError + + @handler("SetIdentityHeadersInNotificationsEnabled") + def set_identity_headers_in_notifications_enabled( + self, + context: RequestContext, + identity: Identity, + notification_type: NotificationType, + enabled: Enabled, + **kwargs, + ) -> SetIdentityHeadersInNotificationsEnabledResponse: + raise NotImplementedError + + @handler("SetIdentityMailFromDomain") + def set_identity_mail_from_domain( + self, + context: RequestContext, + identity: Identity, + mail_from_domain: MailFromDomainName | None = None, + behavior_on_mx_failure: BehaviorOnMXFailure | None = None, + **kwargs, + ) -> SetIdentityMailFromDomainResponse: + raise NotImplementedError + + @handler("SetIdentityNotificationTopic") + def set_identity_notification_topic( + self, + context: RequestContext, + identity: Identity, + notification_type: NotificationType, + sns_topic: NotificationTopic | None = None, + **kwargs, + ) -> SetIdentityNotificationTopicResponse: + raise NotImplementedError + + @handler("SetReceiptRulePosition") + def set_receipt_rule_position( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + rule_name: ReceiptRuleName, + after: ReceiptRuleName | None = None, + **kwargs, + ) -> SetReceiptRulePositionResponse: + raise NotImplementedError + + @handler("TestRenderTemplate") + def test_render_template( + self, + context: RequestContext, + template_name: TemplateName, + template_data: TemplateData, + **kwargs, + ) -> TestRenderTemplateResponse: + raise NotImplementedError + + @handler("UpdateAccountSendingEnabled") + def update_account_sending_enabled( + self, context: RequestContext, enabled: Enabled | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UpdateConfigurationSetEventDestination") + def update_configuration_set_event_destination( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + event_destination: EventDestination, + **kwargs, + ) -> UpdateConfigurationSetEventDestinationResponse: + raise NotImplementedError + + @handler("UpdateConfigurationSetReputationMetricsEnabled") + def update_configuration_set_reputation_metrics_enabled( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + enabled: Enabled, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateConfigurationSetSendingEnabled") + def update_configuration_set_sending_enabled( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + enabled: Enabled, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateConfigurationSetTrackingOptions") + def update_configuration_set_tracking_options( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + tracking_options: TrackingOptions, + **kwargs, + ) -> UpdateConfigurationSetTrackingOptionsResponse: + raise NotImplementedError + + @handler("UpdateCustomVerificationEmailTemplate") + def update_custom_verification_email_template( + self, + context: RequestContext, + template_name: TemplateName, + from_email_address: FromAddress | None = None, + template_subject: Subject | None = None, + template_content: TemplateContent | None = None, + success_redirection_url: SuccessRedirectionURL | None = None, + failure_redirection_url: FailureRedirectionURL | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UpdateReceiptRule") + def update_receipt_rule( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + rule: ReceiptRule, + **kwargs, + ) -> UpdateReceiptRuleResponse: + raise NotImplementedError + + @handler("UpdateTemplate") + def update_template( + self, context: RequestContext, template: Template, **kwargs + ) -> UpdateTemplateResponse: + raise NotImplementedError + + @handler("VerifyDomainDkim") + def verify_domain_dkim( + self, context: RequestContext, domain: Domain, **kwargs + ) -> VerifyDomainDkimResponse: + raise NotImplementedError + + @handler("VerifyDomainIdentity") + def verify_domain_identity( + self, context: RequestContext, domain: Domain, **kwargs + ) -> VerifyDomainIdentityResponse: + raise NotImplementedError + + @handler("VerifyEmailAddress") + def verify_email_address( + self, context: RequestContext, email_address: Address, **kwargs + ) -> None: + raise NotImplementedError + + @handler("VerifyEmailIdentity") + def verify_email_identity( + self, context: RequestContext, email_address: Address, **kwargs + ) -> VerifyEmailIdentityResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/sns/__init__.py b/localstack-core/localstack/aws/api/sns/__init__.py new file mode 100644 index 0000000000000..df5f5618138b5 --- /dev/null +++ b/localstack-core/localstack/aws/api/sns/__init__.py @@ -0,0 +1,1095 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AmazonResourceName = str +Iso2CountryCode = str +MaxItems = int +MaxItemsListOriginationNumbers = int +OTPCode = str +PhoneNumber = str +PhoneNumberString = str +String = str +TagKey = str +TagValue = str +account = str +action = str +attributeName = str +attributeValue = str +authenticateOnUnsubscribe = str +boolean = bool +delegate = str +endpoint = str +label = str +message = str +messageId = str +messageStructure = str +nextToken = str +protocol = str +string = str +subject = str +subscriptionARN = str +token = str +topicARN = str +topicName = str + + +class LanguageCodeString(StrEnum): + en_US = "en-US" + en_GB = "en-GB" + es_419 = "es-419" + es_ES = "es-ES" + de_DE = "de-DE" + fr_CA = "fr-CA" + fr_FR = "fr-FR" + it_IT = "it-IT" + ja_JP = "ja-JP" + pt_BR = "pt-BR" + kr_KR = "kr-KR" + zh_CN = "zh-CN" + zh_TW = "zh-TW" + + +class NumberCapability(StrEnum): + SMS = "SMS" + MMS = "MMS" + VOICE = "VOICE" + + +class RouteType(StrEnum): + Transactional = "Transactional" + Promotional = "Promotional" + Premium = "Premium" + + +class SMSSandboxPhoneNumberVerificationStatus(StrEnum): + Pending = "Pending" + Verified = "Verified" + + +class AuthorizationErrorException(ServiceException): + code: str = "AuthorizationError" + sender_fault: bool = True + status_code: int = 403 + + +class BatchEntryIdsNotDistinctException(ServiceException): + code: str = "BatchEntryIdsNotDistinct" + sender_fault: bool = True + status_code: int = 400 + + +class BatchRequestTooLongException(ServiceException): + code: str = "BatchRequestTooLong" + sender_fault: bool = True + status_code: int = 400 + + +class ConcurrentAccessException(ServiceException): + code: str = "ConcurrentAccess" + sender_fault: bool = True + status_code: int = 400 + + +class EmptyBatchRequestException(ServiceException): + code: str = "EmptyBatchRequest" + sender_fault: bool = True + status_code: int = 400 + + +class EndpointDisabledException(ServiceException): + code: str = "EndpointDisabled" + sender_fault: bool = True + status_code: int = 400 + + +class FilterPolicyLimitExceededException(ServiceException): + code: str = "FilterPolicyLimitExceeded" + sender_fault: bool = True + status_code: int = 403 + + +class InternalErrorException(ServiceException): + code: str = "InternalError" + sender_fault: bool = False + status_code: int = 500 + + +class InvalidBatchEntryIdException(ServiceException): + code: str = "InvalidBatchEntryId" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidParameterException(ServiceException): + code: str = "InvalidParameter" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidParameterValueException(ServiceException): + code: str = "ParameterValueInvalid" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidSecurityException(ServiceException): + code: str = "InvalidSecurity" + sender_fault: bool = True + status_code: int = 403 + + +class InvalidStateException(ServiceException): + code: str = "InvalidState" + sender_fault: bool = True + status_code: int = 400 + + +class KMSAccessDeniedException(ServiceException): + code: str = "KMSAccessDenied" + sender_fault: bool = True + status_code: int = 400 + + +class KMSDisabledException(ServiceException): + code: str = "KMSDisabled" + sender_fault: bool = True + status_code: int = 400 + + +class KMSInvalidStateException(ServiceException): + code: str = "KMSInvalidState" + sender_fault: bool = True + status_code: int = 400 + + +class KMSNotFoundException(ServiceException): + code: str = "KMSNotFound" + sender_fault: bool = True + status_code: int = 400 + + +class KMSOptInRequired(ServiceException): + code: str = "KMSOptInRequired" + sender_fault: bool = True + status_code: int = 403 + + +class KMSThrottlingException(ServiceException): + code: str = "KMSThrottling" + sender_fault: bool = True + status_code: int = 400 + + +class NotFoundException(ServiceException): + code: str = "NotFound" + sender_fault: bool = True + status_code: int = 404 + + +class OptedOutException(ServiceException): + code: str = "OptedOut" + sender_fault: bool = True + status_code: int = 400 + + +class PlatformApplicationDisabledException(ServiceException): + code: str = "PlatformApplicationDisabled" + sender_fault: bool = True + status_code: int = 400 + + +class ReplayLimitExceededException(ServiceException): + code: str = "ReplayLimitExceeded" + sender_fault: bool = True + status_code: int = 403 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFound" + sender_fault: bool = True + status_code: int = 404 + + +class StaleTagException(ServiceException): + code: str = "StaleTag" + sender_fault: bool = True + status_code: int = 400 + + +class SubscriptionLimitExceededException(ServiceException): + code: str = "SubscriptionLimitExceeded" + sender_fault: bool = True + status_code: int = 403 + + +class TagLimitExceededException(ServiceException): + code: str = "TagLimitExceeded" + sender_fault: bool = True + status_code: int = 400 + + +class TagPolicyException(ServiceException): + code: str = "TagPolicy" + sender_fault: bool = True + status_code: int = 400 + + +class ThrottledException(ServiceException): + code: str = "Throttled" + sender_fault: bool = True + status_code: int = 429 + + +class TooManyEntriesInBatchRequestException(ServiceException): + code: str = "TooManyEntriesInBatchRequest" + sender_fault: bool = True + status_code: int = 400 + + +class TopicLimitExceededException(ServiceException): + code: str = "TopicLimitExceeded" + sender_fault: bool = True + status_code: int = 403 + + +class UserErrorException(ServiceException): + code: str = "UserError" + sender_fault: bool = True + status_code: int = 400 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = True + status_code: int = 400 + + +class VerificationException(ServiceException): + code: str = "VerificationException" + sender_fault: bool = False + status_code: int = 400 + Status: string + + +ActionsList = List[action] +DelegatesList = List[delegate] + + +class AddPermissionInput(ServiceRequest): + TopicArn: topicARN + Label: label + AWSAccountId: DelegatesList + ActionName: ActionsList + + +class BatchResultErrorEntry(TypedDict, total=False): + Id: String + Code: String + Message: Optional[String] + SenderFault: boolean + + +BatchResultErrorEntryList = List[BatchResultErrorEntry] +Binary = bytes + + +class CheckIfPhoneNumberIsOptedOutInput(ServiceRequest): + phoneNumber: PhoneNumber + + +class CheckIfPhoneNumberIsOptedOutResponse(TypedDict, total=False): + isOptedOut: Optional[boolean] + + +class ConfirmSubscriptionInput(ServiceRequest): + TopicArn: topicARN + Token: token + AuthenticateOnUnsubscribe: Optional[authenticateOnUnsubscribe] + + +class ConfirmSubscriptionResponse(TypedDict, total=False): + SubscriptionArn: Optional[subscriptionARN] + + +class CreateEndpointResponse(TypedDict, total=False): + EndpointArn: Optional[String] + + +MapStringToString = Dict[String, String] + + +class CreatePlatformApplicationInput(ServiceRequest): + Name: String + Platform: String + Attributes: MapStringToString + + +class CreatePlatformApplicationResponse(TypedDict, total=False): + PlatformApplicationArn: Optional[String] + + +class CreatePlatformEndpointInput(ServiceRequest): + PlatformApplicationArn: String + Token: String + CustomUserData: Optional[String] + Attributes: Optional[MapStringToString] + + +class CreateSMSSandboxPhoneNumberInput(ServiceRequest): + PhoneNumber: PhoneNumberString + LanguageCode: Optional[LanguageCodeString] + + +class CreateSMSSandboxPhoneNumberResult(TypedDict, total=False): + pass + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] +TopicAttributesMap = Dict[attributeName, attributeValue] + + +class CreateTopicInput(ServiceRequest): + Name: topicName + Attributes: Optional[TopicAttributesMap] + Tags: Optional[TagList] + DataProtectionPolicy: Optional[attributeValue] + + +class CreateTopicResponse(TypedDict, total=False): + TopicArn: Optional[topicARN] + + +class DeleteEndpointInput(ServiceRequest): + EndpointArn: String + + +class DeletePlatformApplicationInput(ServiceRequest): + PlatformApplicationArn: String + + +class DeleteSMSSandboxPhoneNumberInput(ServiceRequest): + PhoneNumber: PhoneNumberString + + +class DeleteSMSSandboxPhoneNumberResult(TypedDict, total=False): + pass + + +class DeleteTopicInput(ServiceRequest): + TopicArn: topicARN + + +class Endpoint(TypedDict, total=False): + EndpointArn: Optional[String] + Attributes: Optional[MapStringToString] + + +class GetDataProtectionPolicyInput(ServiceRequest): + ResourceArn: topicARN + + +class GetDataProtectionPolicyResponse(TypedDict, total=False): + DataProtectionPolicy: Optional[attributeValue] + + +class GetEndpointAttributesInput(ServiceRequest): + EndpointArn: String + + +class GetEndpointAttributesResponse(TypedDict, total=False): + Attributes: Optional[MapStringToString] + + +class GetPlatformApplicationAttributesInput(ServiceRequest): + PlatformApplicationArn: String + + +class GetPlatformApplicationAttributesResponse(TypedDict, total=False): + Attributes: Optional[MapStringToString] + + +ListString = List[String] + + +class GetSMSAttributesInput(ServiceRequest): + attributes: Optional[ListString] + + +class GetSMSAttributesResponse(TypedDict, total=False): + attributes: Optional[MapStringToString] + + +class GetSMSSandboxAccountStatusInput(ServiceRequest): + pass + + +class GetSMSSandboxAccountStatusResult(TypedDict, total=False): + IsInSandbox: boolean + + +class GetSubscriptionAttributesInput(ServiceRequest): + SubscriptionArn: subscriptionARN + + +SubscriptionAttributesMap = Dict[attributeName, attributeValue] + + +class GetSubscriptionAttributesResponse(TypedDict, total=False): + Attributes: Optional[SubscriptionAttributesMap] + + +class GetTopicAttributesInput(ServiceRequest): + TopicArn: topicARN + + +class GetTopicAttributesResponse(TypedDict, total=False): + Attributes: Optional[TopicAttributesMap] + + +class ListEndpointsByPlatformApplicationInput(ServiceRequest): + PlatformApplicationArn: String + NextToken: Optional[String] + + +ListOfEndpoints = List[Endpoint] + + +class ListEndpointsByPlatformApplicationResponse(TypedDict, total=False): + Endpoints: Optional[ListOfEndpoints] + NextToken: Optional[String] + + +class PlatformApplication(TypedDict, total=False): + PlatformApplicationArn: Optional[String] + Attributes: Optional[MapStringToString] + + +ListOfPlatformApplications = List[PlatformApplication] + + +class ListOriginationNumbersRequest(ServiceRequest): + NextToken: Optional[nextToken] + MaxResults: Optional[MaxItemsListOriginationNumbers] + + +NumberCapabilityList = List[NumberCapability] +Timestamp = datetime + + +class PhoneNumberInformation(TypedDict, total=False): + CreatedAt: Optional[Timestamp] + PhoneNumber: Optional[PhoneNumber] + Status: Optional[String] + Iso2CountryCode: Optional[Iso2CountryCode] + RouteType: Optional[RouteType] + NumberCapabilities: Optional[NumberCapabilityList] + + +PhoneNumberInformationList = List[PhoneNumberInformation] + + +class ListOriginationNumbersResult(TypedDict, total=False): + NextToken: Optional[nextToken] + PhoneNumbers: Optional[PhoneNumberInformationList] + + +class ListPhoneNumbersOptedOutInput(ServiceRequest): + nextToken: Optional[string] + + +PhoneNumberList = List[PhoneNumber] + + +class ListPhoneNumbersOptedOutResponse(TypedDict, total=False): + phoneNumbers: Optional[PhoneNumberList] + nextToken: Optional[string] + + +class ListPlatformApplicationsInput(ServiceRequest): + NextToken: Optional[String] + + +class ListPlatformApplicationsResponse(TypedDict, total=False): + PlatformApplications: Optional[ListOfPlatformApplications] + NextToken: Optional[String] + + +class ListSMSSandboxPhoneNumbersInput(ServiceRequest): + NextToken: Optional[nextToken] + MaxResults: Optional[MaxItems] + + +class SMSSandboxPhoneNumber(TypedDict, total=False): + PhoneNumber: Optional[PhoneNumberString] + Status: Optional[SMSSandboxPhoneNumberVerificationStatus] + + +SMSSandboxPhoneNumberList = List[SMSSandboxPhoneNumber] + + +class ListSMSSandboxPhoneNumbersResult(TypedDict, total=False): + PhoneNumbers: SMSSandboxPhoneNumberList + NextToken: Optional[string] + + +class ListSubscriptionsByTopicInput(ServiceRequest): + TopicArn: topicARN + NextToken: Optional[nextToken] + + +class Subscription(TypedDict, total=False): + SubscriptionArn: Optional[subscriptionARN] + Owner: Optional[account] + Protocol: Optional[protocol] + Endpoint: Optional[endpoint] + TopicArn: Optional[topicARN] + + +SubscriptionsList = List[Subscription] + + +class ListSubscriptionsByTopicResponse(TypedDict, total=False): + Subscriptions: Optional[SubscriptionsList] + NextToken: Optional[nextToken] + + +class ListSubscriptionsInput(ServiceRequest): + NextToken: Optional[nextToken] + + +class ListSubscriptionsResponse(TypedDict, total=False): + Subscriptions: Optional[SubscriptionsList] + NextToken: Optional[nextToken] + + +class ListTagsForResourceRequest(ServiceRequest): + ResourceArn: AmazonResourceName + + +class ListTagsForResourceResponse(TypedDict, total=False): + Tags: Optional[TagList] + + +class ListTopicsInput(ServiceRequest): + NextToken: Optional[nextToken] + + +class Topic(TypedDict, total=False): + TopicArn: Optional[topicARN] + + +TopicsList = List[Topic] + + +class ListTopicsResponse(TypedDict, total=False): + Topics: Optional[TopicsList] + NextToken: Optional[nextToken] + + +class MessageAttributeValue(TypedDict, total=False): + DataType: String + StringValue: Optional[String] + BinaryValue: Optional[Binary] + + +MessageAttributeMap = Dict[String, MessageAttributeValue] + + +class OptInPhoneNumberInput(ServiceRequest): + phoneNumber: PhoneNumber + + +class OptInPhoneNumberResponse(TypedDict, total=False): + pass + + +class PublishBatchRequestEntry(TypedDict, total=False): + Id: String + Message: message + Subject: Optional[subject] + MessageStructure: Optional[messageStructure] + MessageAttributes: Optional[MessageAttributeMap] + MessageDeduplicationId: Optional[String] + MessageGroupId: Optional[String] + + +PublishBatchRequestEntryList = List[PublishBatchRequestEntry] + + +class PublishBatchInput(ServiceRequest): + TopicArn: topicARN + PublishBatchRequestEntries: PublishBatchRequestEntryList + + +class PublishBatchResultEntry(TypedDict, total=False): + Id: Optional[String] + MessageId: Optional[messageId] + SequenceNumber: Optional[String] + + +PublishBatchResultEntryList = List[PublishBatchResultEntry] + + +class PublishBatchResponse(TypedDict, total=False): + Successful: Optional[PublishBatchResultEntryList] + Failed: Optional[BatchResultErrorEntryList] + + +class PublishInput(ServiceRequest): + TopicArn: Optional[topicARN] + TargetArn: Optional[String] + PhoneNumber: Optional[PhoneNumber] + Message: message + Subject: Optional[subject] + MessageStructure: Optional[messageStructure] + MessageAttributes: Optional[MessageAttributeMap] + MessageDeduplicationId: Optional[String] + MessageGroupId: Optional[String] + + +class PublishResponse(TypedDict, total=False): + MessageId: Optional[messageId] + SequenceNumber: Optional[String] + + +class PutDataProtectionPolicyInput(ServiceRequest): + ResourceArn: topicARN + DataProtectionPolicy: attributeValue + + +class RemovePermissionInput(ServiceRequest): + TopicArn: topicARN + Label: label + + +class SetEndpointAttributesInput(ServiceRequest): + EndpointArn: String + Attributes: MapStringToString + + +class SetPlatformApplicationAttributesInput(ServiceRequest): + PlatformApplicationArn: String + Attributes: MapStringToString + + +class SetSMSAttributesInput(ServiceRequest): + attributes: MapStringToString + + +class SetSMSAttributesResponse(TypedDict, total=False): + pass + + +class SetSubscriptionAttributesInput(ServiceRequest): + SubscriptionArn: subscriptionARN + AttributeName: attributeName + AttributeValue: Optional[attributeValue] + + +class SetTopicAttributesInput(ServiceRequest): + TopicArn: topicARN + AttributeName: attributeName + AttributeValue: Optional[attributeValue] + + +class SubscribeInput(ServiceRequest): + TopicArn: topicARN + Protocol: protocol + Endpoint: Optional[endpoint] + Attributes: Optional[SubscriptionAttributesMap] + ReturnSubscriptionArn: Optional[boolean] + + +class SubscribeResponse(TypedDict, total=False): + SubscriptionArn: Optional[subscriptionARN] + + +TagKeyList = List[TagKey] + + +class TagResourceRequest(ServiceRequest): + ResourceArn: AmazonResourceName + Tags: TagList + + +class TagResourceResponse(TypedDict, total=False): + pass + + +class UnsubscribeInput(ServiceRequest): + SubscriptionArn: subscriptionARN + + +class UntagResourceRequest(ServiceRequest): + ResourceArn: AmazonResourceName + TagKeys: TagKeyList + + +class UntagResourceResponse(TypedDict, total=False): + pass + + +class VerifySMSSandboxPhoneNumberInput(ServiceRequest): + PhoneNumber: PhoneNumberString + OneTimePassword: OTPCode + + +class VerifySMSSandboxPhoneNumberResult(TypedDict, total=False): + pass + + +class SnsApi: + service = "sns" + version = "2010-03-31" + + @handler("AddPermission") + def add_permission( + self, + context: RequestContext, + topic_arn: topicARN, + label: label, + aws_account_id: DelegatesList, + action_name: ActionsList, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CheckIfPhoneNumberIsOptedOut") + def check_if_phone_number_is_opted_out( + self, context: RequestContext, phone_number: PhoneNumber, **kwargs + ) -> CheckIfPhoneNumberIsOptedOutResponse: + raise NotImplementedError + + @handler("ConfirmSubscription") + def confirm_subscription( + self, + context: RequestContext, + topic_arn: topicARN, + token: token, + authenticate_on_unsubscribe: authenticateOnUnsubscribe | None = None, + **kwargs, + ) -> ConfirmSubscriptionResponse: + raise NotImplementedError + + @handler("CreatePlatformApplication") + def create_platform_application( + self, + context: RequestContext, + name: String, + platform: String, + attributes: MapStringToString, + **kwargs, + ) -> CreatePlatformApplicationResponse: + raise NotImplementedError + + @handler("CreatePlatformEndpoint") + def create_platform_endpoint( + self, + context: RequestContext, + platform_application_arn: String, + token: String, + custom_user_data: String | None = None, + attributes: MapStringToString | None = None, + **kwargs, + ) -> CreateEndpointResponse: + raise NotImplementedError + + @handler("CreateSMSSandboxPhoneNumber") + def create_sms_sandbox_phone_number( + self, + context: RequestContext, + phone_number: PhoneNumberString, + language_code: LanguageCodeString | None = None, + **kwargs, + ) -> CreateSMSSandboxPhoneNumberResult: + raise NotImplementedError + + @handler("CreateTopic") + def create_topic( + self, + context: RequestContext, + name: topicName, + attributes: TopicAttributesMap | None = None, + tags: TagList | None = None, + data_protection_policy: attributeValue | None = None, + **kwargs, + ) -> CreateTopicResponse: + raise NotImplementedError + + @handler("DeleteEndpoint") + def delete_endpoint(self, context: RequestContext, endpoint_arn: String, **kwargs) -> None: + raise NotImplementedError + + @handler("DeletePlatformApplication") + def delete_platform_application( + self, context: RequestContext, platform_application_arn: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteSMSSandboxPhoneNumber") + def delete_sms_sandbox_phone_number( + self, context: RequestContext, phone_number: PhoneNumberString, **kwargs + ) -> DeleteSMSSandboxPhoneNumberResult: + raise NotImplementedError + + @handler("DeleteTopic") + def delete_topic(self, context: RequestContext, topic_arn: topicARN, **kwargs) -> None: + raise NotImplementedError + + @handler("GetDataProtectionPolicy") + def get_data_protection_policy( + self, context: RequestContext, resource_arn: topicARN, **kwargs + ) -> GetDataProtectionPolicyResponse: + raise NotImplementedError + + @handler("GetEndpointAttributes") + def get_endpoint_attributes( + self, context: RequestContext, endpoint_arn: String, **kwargs + ) -> GetEndpointAttributesResponse: + raise NotImplementedError + + @handler("GetPlatformApplicationAttributes") + def get_platform_application_attributes( + self, context: RequestContext, platform_application_arn: String, **kwargs + ) -> GetPlatformApplicationAttributesResponse: + raise NotImplementedError + + @handler("GetSMSAttributes") + def get_sms_attributes( + self, context: RequestContext, attributes: ListString | None = None, **kwargs + ) -> GetSMSAttributesResponse: + raise NotImplementedError + + @handler("GetSMSSandboxAccountStatus") + def get_sms_sandbox_account_status( + self, context: RequestContext, **kwargs + ) -> GetSMSSandboxAccountStatusResult: + raise NotImplementedError + + @handler("GetSubscriptionAttributes") + def get_subscription_attributes( + self, context: RequestContext, subscription_arn: subscriptionARN, **kwargs + ) -> GetSubscriptionAttributesResponse: + raise NotImplementedError + + @handler("GetTopicAttributes") + def get_topic_attributes( + self, context: RequestContext, topic_arn: topicARN, **kwargs + ) -> GetTopicAttributesResponse: + raise NotImplementedError + + @handler("ListEndpointsByPlatformApplication") + def list_endpoints_by_platform_application( + self, + context: RequestContext, + platform_application_arn: String, + next_token: String | None = None, + **kwargs, + ) -> ListEndpointsByPlatformApplicationResponse: + raise NotImplementedError + + @handler("ListOriginationNumbers") + def list_origination_numbers( + self, + context: RequestContext, + next_token: nextToken | None = None, + max_results: MaxItemsListOriginationNumbers | None = None, + **kwargs, + ) -> ListOriginationNumbersResult: + raise NotImplementedError + + @handler("ListPhoneNumbersOptedOut") + def list_phone_numbers_opted_out( + self, context: RequestContext, next_token: string | None = None, **kwargs + ) -> ListPhoneNumbersOptedOutResponse: + raise NotImplementedError + + @handler("ListPlatformApplications") + def list_platform_applications( + self, context: RequestContext, next_token: String | None = None, **kwargs + ) -> ListPlatformApplicationsResponse: + raise NotImplementedError + + @handler("ListSMSSandboxPhoneNumbers") + def list_sms_sandbox_phone_numbers( + self, + context: RequestContext, + next_token: nextToken | None = None, + max_results: MaxItems | None = None, + **kwargs, + ) -> ListSMSSandboxPhoneNumbersResult: + raise NotImplementedError + + @handler("ListSubscriptions") + def list_subscriptions( + self, context: RequestContext, next_token: nextToken | None = None, **kwargs + ) -> ListSubscriptionsResponse: + raise NotImplementedError + + @handler("ListSubscriptionsByTopic") + def list_subscriptions_by_topic( + self, + context: RequestContext, + topic_arn: topicARN, + next_token: nextToken | None = None, + **kwargs, + ) -> ListSubscriptionsByTopicResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("ListTopics") + def list_topics( + self, context: RequestContext, next_token: nextToken | None = None, **kwargs + ) -> ListTopicsResponse: + raise NotImplementedError + + @handler("OptInPhoneNumber") + def opt_in_phone_number( + self, context: RequestContext, phone_number: PhoneNumber, **kwargs + ) -> OptInPhoneNumberResponse: + raise NotImplementedError + + @handler("Publish") + def publish( + self, + context: RequestContext, + message: message, + topic_arn: topicARN | None = None, + target_arn: String | None = None, + phone_number: PhoneNumber | None = None, + subject: subject | None = None, + message_structure: messageStructure | None = None, + message_attributes: MessageAttributeMap | None = None, + message_deduplication_id: String | None = None, + message_group_id: String | None = None, + **kwargs, + ) -> PublishResponse: + raise NotImplementedError + + @handler("PublishBatch") + def publish_batch( + self, + context: RequestContext, + topic_arn: topicARN, + publish_batch_request_entries: PublishBatchRequestEntryList, + **kwargs, + ) -> PublishBatchResponse: + raise NotImplementedError + + @handler("PutDataProtectionPolicy") + def put_data_protection_policy( + self, + context: RequestContext, + resource_arn: topicARN, + data_protection_policy: attributeValue, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RemovePermission") + def remove_permission( + self, context: RequestContext, topic_arn: topicARN, label: label, **kwargs + ) -> None: + raise NotImplementedError + + @handler("SetEndpointAttributes") + def set_endpoint_attributes( + self, context: RequestContext, endpoint_arn: String, attributes: MapStringToString, **kwargs + ) -> None: + raise NotImplementedError + + @handler("SetPlatformApplicationAttributes") + def set_platform_application_attributes( + self, + context: RequestContext, + platform_application_arn: String, + attributes: MapStringToString, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SetSMSAttributes") + def set_sms_attributes( + self, context: RequestContext, attributes: MapStringToString, **kwargs + ) -> SetSMSAttributesResponse: + raise NotImplementedError + + @handler("SetSubscriptionAttributes") + def set_subscription_attributes( + self, + context: RequestContext, + subscription_arn: subscriptionARN, + attribute_name: attributeName, + attribute_value: attributeValue | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SetTopicAttributes") + def set_topic_attributes( + self, + context: RequestContext, + topic_arn: topicARN, + attribute_name: attributeName, + attribute_value: attributeValue | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("Subscribe") + def subscribe( + self, + context: RequestContext, + topic_arn: topicARN, + protocol: protocol, + endpoint: endpoint | None = None, + attributes: SubscriptionAttributesMap | None = None, + return_subscription_arn: boolean | None = None, + **kwargs, + ) -> SubscribeResponse: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: TagList, **kwargs + ) -> TagResourceResponse: + raise NotImplementedError + + @handler("Unsubscribe") + def unsubscribe( + self, context: RequestContext, subscription_arn: subscriptionARN, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagResourceResponse: + raise NotImplementedError + + @handler("VerifySMSSandboxPhoneNumber") + def verify_sms_sandbox_phone_number( + self, + context: RequestContext, + phone_number: PhoneNumberString, + one_time_password: OTPCode, + **kwargs, + ) -> VerifySMSSandboxPhoneNumberResult: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/sqs/__init__.py b/localstack-core/localstack/aws/api/sqs/__init__.py new file mode 100644 index 0000000000000..a09978ffe8046 --- /dev/null +++ b/localstack-core/localstack/aws/api/sqs/__init__.py @@ -0,0 +1,778 @@ +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Boolean = bool +BoxedInteger = int +ExceptionMessage = str +MessageAttributeName = str +NullableInteger = int +String = str +TagKey = str +TagValue = str +Token = str + + +class MessageSystemAttributeName(StrEnum): + All = "All" + SenderId = "SenderId" + SentTimestamp = "SentTimestamp" + ApproximateReceiveCount = "ApproximateReceiveCount" + ApproximateFirstReceiveTimestamp = "ApproximateFirstReceiveTimestamp" + SequenceNumber = "SequenceNumber" + MessageDeduplicationId = "MessageDeduplicationId" + MessageGroupId = "MessageGroupId" + AWSTraceHeader = "AWSTraceHeader" + DeadLetterQueueSourceArn = "DeadLetterQueueSourceArn" + + +class MessageSystemAttributeNameForSends(StrEnum): + AWSTraceHeader = "AWSTraceHeader" + + +class QueueAttributeName(StrEnum): + All = "All" + Policy = "Policy" + VisibilityTimeout = "VisibilityTimeout" + MaximumMessageSize = "MaximumMessageSize" + MessageRetentionPeriod = "MessageRetentionPeriod" + ApproximateNumberOfMessages = "ApproximateNumberOfMessages" + ApproximateNumberOfMessagesNotVisible = "ApproximateNumberOfMessagesNotVisible" + CreatedTimestamp = "CreatedTimestamp" + LastModifiedTimestamp = "LastModifiedTimestamp" + QueueArn = "QueueArn" + ApproximateNumberOfMessagesDelayed = "ApproximateNumberOfMessagesDelayed" + DelaySeconds = "DelaySeconds" + ReceiveMessageWaitTimeSeconds = "ReceiveMessageWaitTimeSeconds" + RedrivePolicy = "RedrivePolicy" + FifoQueue = "FifoQueue" + ContentBasedDeduplication = "ContentBasedDeduplication" + KmsMasterKeyId = "KmsMasterKeyId" + KmsDataKeyReusePeriodSeconds = "KmsDataKeyReusePeriodSeconds" + DeduplicationScope = "DeduplicationScope" + FifoThroughputLimit = "FifoThroughputLimit" + RedriveAllowPolicy = "RedriveAllowPolicy" + SqsManagedSseEnabled = "SqsManagedSseEnabled" + + +class BatchEntryIdsNotDistinct(ServiceException): + code: str = "BatchEntryIdsNotDistinct" + sender_fault: bool = False + status_code: int = 400 + + +class BatchRequestTooLong(ServiceException): + code: str = "BatchRequestTooLong" + sender_fault: bool = False + status_code: int = 400 + + +class EmptyBatchRequest(ServiceException): + code: str = "EmptyBatchRequest" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAddress(ServiceException): + code: str = "InvalidAddress" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAttributeName(ServiceException): + code: str = "InvalidAttributeName" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAttributeValue(ServiceException): + code: str = "InvalidAttributeValue" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidBatchEntryId(ServiceException): + code: str = "InvalidBatchEntryId" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidIdFormat(ServiceException): + code: str = "InvalidIdFormat" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidMessageContents(ServiceException): + code: str = "InvalidMessageContents" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidSecurity(ServiceException): + code: str = "InvalidSecurity" + sender_fault: bool = False + status_code: int = 400 + + +class KmsAccessDenied(ServiceException): + code: str = "KmsAccessDenied" + sender_fault: bool = False + status_code: int = 400 + + +class KmsDisabled(ServiceException): + code: str = "KmsDisabled" + sender_fault: bool = False + status_code: int = 400 + + +class KmsInvalidKeyUsage(ServiceException): + code: str = "KmsInvalidKeyUsage" + sender_fault: bool = False + status_code: int = 400 + + +class KmsInvalidState(ServiceException): + code: str = "KmsInvalidState" + sender_fault: bool = False + status_code: int = 400 + + +class KmsNotFound(ServiceException): + code: str = "KmsNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class KmsOptInRequired(ServiceException): + code: str = "KmsOptInRequired" + sender_fault: bool = False + status_code: int = 400 + + +class KmsThrottled(ServiceException): + code: str = "KmsThrottled" + sender_fault: bool = False + status_code: int = 400 + + +class MessageNotInflight(ServiceException): + code: str = "MessageNotInflight" + sender_fault: bool = False + status_code: int = 400 + + +class OverLimit(ServiceException): + code: str = "OverLimit" + sender_fault: bool = False + status_code: int = 400 + + +class PurgeQueueInProgress(ServiceException): + code: str = "PurgeQueueInProgress" + sender_fault: bool = False + status_code: int = 400 + + +class QueueDeletedRecently(ServiceException): + code: str = "QueueDeletedRecently" + sender_fault: bool = False + status_code: int = 400 + + +class QueueDoesNotExist(ServiceException): + code: str = "QueueDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class QueueNameExists(ServiceException): + code: str = "QueueNameExists" + sender_fault: bool = False + status_code: int = 400 + + +class ReceiptHandleIsInvalid(ServiceException): + code: str = "ReceiptHandleIsInvalid" + sender_fault: bool = False + status_code: int = 400 + + +class RequestThrottled(ServiceException): + code: str = "RequestThrottled" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyEntriesInBatchRequest(ServiceException): + code: str = "TooManyEntriesInBatchRequest" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedOperation(ServiceException): + code: str = "UnsupportedOperation" + sender_fault: bool = False + status_code: int = 400 + + +AWSAccountIdList = List[String] +ActionNameList = List[String] + + +class AddPermissionRequest(ServiceRequest): + QueueUrl: String + Label: String + AWSAccountIds: AWSAccountIdList + Actions: ActionNameList + + +AttributeNameList = List[QueueAttributeName] + + +class BatchResultErrorEntry(TypedDict, total=False): + Id: String + SenderFault: Boolean + Code: String + Message: Optional[String] + + +BatchResultErrorEntryList = List[BatchResultErrorEntry] +Binary = bytes +BinaryList = List[Binary] + + +class CancelMessageMoveTaskRequest(ServiceRequest): + TaskHandle: String + + +Long = int + + +class CancelMessageMoveTaskResult(TypedDict, total=False): + ApproximateNumberOfMessagesMoved: Optional[Long] + + +class ChangeMessageVisibilityBatchRequestEntry(TypedDict, total=False): + Id: String + ReceiptHandle: String + VisibilityTimeout: Optional[NullableInteger] + + +ChangeMessageVisibilityBatchRequestEntryList = List[ChangeMessageVisibilityBatchRequestEntry] + + +class ChangeMessageVisibilityBatchRequest(ServiceRequest): + QueueUrl: String + Entries: ChangeMessageVisibilityBatchRequestEntryList + + +class ChangeMessageVisibilityBatchResultEntry(TypedDict, total=False): + Id: String + + +ChangeMessageVisibilityBatchResultEntryList = List[ChangeMessageVisibilityBatchResultEntry] + + +class ChangeMessageVisibilityBatchResult(TypedDict, total=False): + Successful: ChangeMessageVisibilityBatchResultEntryList + Failed: BatchResultErrorEntryList + + +class ChangeMessageVisibilityRequest(ServiceRequest): + QueueUrl: String + ReceiptHandle: String + VisibilityTimeout: NullableInteger + + +TagMap = Dict[TagKey, TagValue] +QueueAttributeMap = Dict[QueueAttributeName, String] + + +class CreateQueueRequest(ServiceRequest): + QueueName: String + Attributes: Optional[QueueAttributeMap] + tags: Optional[TagMap] + + +class CreateQueueResult(TypedDict, total=False): + QueueUrl: Optional[String] + + +class DeleteMessageBatchRequestEntry(TypedDict, total=False): + Id: String + ReceiptHandle: String + + +DeleteMessageBatchRequestEntryList = List[DeleteMessageBatchRequestEntry] + + +class DeleteMessageBatchRequest(ServiceRequest): + QueueUrl: String + Entries: DeleteMessageBatchRequestEntryList + + +class DeleteMessageBatchResultEntry(TypedDict, total=False): + Id: String + + +DeleteMessageBatchResultEntryList = List[DeleteMessageBatchResultEntry] + + +class DeleteMessageBatchResult(TypedDict, total=False): + Successful: DeleteMessageBatchResultEntryList + Failed: BatchResultErrorEntryList + + +class DeleteMessageRequest(ServiceRequest): + QueueUrl: String + ReceiptHandle: String + + +class DeleteQueueRequest(ServiceRequest): + QueueUrl: String + + +class GetQueueAttributesRequest(ServiceRequest): + QueueUrl: String + AttributeNames: Optional[AttributeNameList] + + +class GetQueueAttributesResult(TypedDict, total=False): + Attributes: Optional[QueueAttributeMap] + + +class GetQueueUrlRequest(ServiceRequest): + QueueName: String + QueueOwnerAWSAccountId: Optional[String] + + +class GetQueueUrlResult(TypedDict, total=False): + QueueUrl: Optional[String] + + +class ListDeadLetterSourceQueuesRequest(ServiceRequest): + QueueUrl: String + NextToken: Optional[Token] + MaxResults: Optional[BoxedInteger] + + +QueueUrlList = List[String] + + +class ListDeadLetterSourceQueuesResult(TypedDict, total=False): + queueUrls: QueueUrlList + NextToken: Optional[Token] + + +class ListMessageMoveTasksRequest(ServiceRequest): + SourceArn: String + MaxResults: Optional[NullableInteger] + + +NullableLong = int + + +class ListMessageMoveTasksResultEntry(TypedDict, total=False): + TaskHandle: Optional[String] + Status: Optional[String] + SourceArn: Optional[String] + DestinationArn: Optional[String] + MaxNumberOfMessagesPerSecond: Optional[NullableInteger] + ApproximateNumberOfMessagesMoved: Optional[Long] + ApproximateNumberOfMessagesToMove: Optional[NullableLong] + FailureReason: Optional[String] + StartedTimestamp: Optional[Long] + + +ListMessageMoveTasksResultEntryList = List[ListMessageMoveTasksResultEntry] + + +class ListMessageMoveTasksResult(TypedDict, total=False): + Results: Optional[ListMessageMoveTasksResultEntryList] + + +class ListQueueTagsRequest(ServiceRequest): + QueueUrl: String + + +class ListQueueTagsResult(TypedDict, total=False): + Tags: Optional[TagMap] + + +class ListQueuesRequest(ServiceRequest): + QueueNamePrefix: Optional[String] + NextToken: Optional[Token] + MaxResults: Optional[BoxedInteger] + + +class ListQueuesResult(TypedDict, total=False): + QueueUrls: Optional[QueueUrlList] + NextToken: Optional[Token] + + +StringList = List[String] + + +class MessageAttributeValue(TypedDict, total=False): + StringValue: Optional[String] + BinaryValue: Optional[Binary] + StringListValues: Optional[StringList] + BinaryListValues: Optional[BinaryList] + DataType: String + + +MessageBodyAttributeMap = Dict[String, MessageAttributeValue] +MessageSystemAttributeMap = Dict[MessageSystemAttributeName, String] + + +class Message(TypedDict, total=False): + MessageId: Optional[String] + ReceiptHandle: Optional[String] + MD5OfBody: Optional[String] + Body: Optional[String] + Attributes: Optional[MessageSystemAttributeMap] + MD5OfMessageAttributes: Optional[String] + MessageAttributes: Optional[MessageBodyAttributeMap] + + +MessageAttributeNameList = List[MessageAttributeName] + + +class MessageSystemAttributeValue(TypedDict, total=False): + StringValue: Optional[String] + BinaryValue: Optional[Binary] + StringListValues: Optional[StringList] + BinaryListValues: Optional[BinaryList] + DataType: String + + +MessageBodySystemAttributeMap = Dict[ + MessageSystemAttributeNameForSends, MessageSystemAttributeValue +] +MessageList = List[Message] +MessageSystemAttributeList = List[MessageSystemAttributeName] + + +class PurgeQueueRequest(ServiceRequest): + QueueUrl: String + + +class ReceiveMessageRequest(ServiceRequest): + QueueUrl: String + AttributeNames: Optional[AttributeNameList] + MessageSystemAttributeNames: Optional[MessageSystemAttributeList] + MessageAttributeNames: Optional[MessageAttributeNameList] + MaxNumberOfMessages: Optional[NullableInteger] + VisibilityTimeout: Optional[NullableInteger] + WaitTimeSeconds: Optional[NullableInteger] + ReceiveRequestAttemptId: Optional[String] + + +class ReceiveMessageResult(TypedDict, total=False): + Messages: Optional[MessageList] + + +class RemovePermissionRequest(ServiceRequest): + QueueUrl: String + Label: String + + +class SendMessageBatchRequestEntry(TypedDict, total=False): + Id: String + MessageBody: String + DelaySeconds: Optional[NullableInteger] + MessageAttributes: Optional[MessageBodyAttributeMap] + MessageSystemAttributes: Optional[MessageBodySystemAttributeMap] + MessageDeduplicationId: Optional[String] + MessageGroupId: Optional[String] + + +SendMessageBatchRequestEntryList = List[SendMessageBatchRequestEntry] + + +class SendMessageBatchRequest(ServiceRequest): + QueueUrl: String + Entries: SendMessageBatchRequestEntryList + + +class SendMessageBatchResultEntry(TypedDict, total=False): + Id: String + MessageId: String + MD5OfMessageBody: String + MD5OfMessageAttributes: Optional[String] + MD5OfMessageSystemAttributes: Optional[String] + SequenceNumber: Optional[String] + + +SendMessageBatchResultEntryList = List[SendMessageBatchResultEntry] + + +class SendMessageBatchResult(TypedDict, total=False): + Successful: SendMessageBatchResultEntryList + Failed: BatchResultErrorEntryList + + +class SendMessageRequest(ServiceRequest): + QueueUrl: String + MessageBody: String + DelaySeconds: Optional[NullableInteger] + MessageAttributes: Optional[MessageBodyAttributeMap] + MessageSystemAttributes: Optional[MessageBodySystemAttributeMap] + MessageDeduplicationId: Optional[String] + MessageGroupId: Optional[String] + + +class SendMessageResult(TypedDict, total=False): + MD5OfMessageBody: Optional[String] + MD5OfMessageAttributes: Optional[String] + MD5OfMessageSystemAttributes: Optional[String] + MessageId: Optional[String] + SequenceNumber: Optional[String] + + +class SetQueueAttributesRequest(ServiceRequest): + QueueUrl: String + Attributes: QueueAttributeMap + + +class StartMessageMoveTaskRequest(ServiceRequest): + SourceArn: String + DestinationArn: Optional[String] + MaxNumberOfMessagesPerSecond: Optional[NullableInteger] + + +class StartMessageMoveTaskResult(TypedDict, total=False): + TaskHandle: Optional[String] + + +TagKeyList = List[TagKey] + + +class TagQueueRequest(ServiceRequest): + QueueUrl: String + Tags: TagMap + + +class UntagQueueRequest(ServiceRequest): + QueueUrl: String + TagKeys: TagKeyList + + +class SqsApi: + service = "sqs" + version = "2012-11-05" + + @handler("AddPermission") + def add_permission( + self, + context: RequestContext, + queue_url: String, + label: String, + aws_account_ids: AWSAccountIdList, + actions: ActionNameList, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("CancelMessageMoveTask") + def cancel_message_move_task( + self, context: RequestContext, task_handle: String, **kwargs + ) -> CancelMessageMoveTaskResult: + raise NotImplementedError + + @handler("ChangeMessageVisibility") + def change_message_visibility( + self, + context: RequestContext, + queue_url: String, + receipt_handle: String, + visibility_timeout: NullableInteger, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("ChangeMessageVisibilityBatch") + def change_message_visibility_batch( + self, + context: RequestContext, + queue_url: String, + entries: ChangeMessageVisibilityBatchRequestEntryList, + **kwargs, + ) -> ChangeMessageVisibilityBatchResult: + raise NotImplementedError + + @handler("CreateQueue") + def create_queue( + self, + context: RequestContext, + queue_name: String, + attributes: QueueAttributeMap | None = None, + tags: TagMap | None = None, + **kwargs, + ) -> CreateQueueResult: + raise NotImplementedError + + @handler("DeleteMessage") + def delete_message( + self, context: RequestContext, queue_url: String, receipt_handle: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteMessageBatch") + def delete_message_batch( + self, + context: RequestContext, + queue_url: String, + entries: DeleteMessageBatchRequestEntryList, + **kwargs, + ) -> DeleteMessageBatchResult: + raise NotImplementedError + + @handler("DeleteQueue") + def delete_queue(self, context: RequestContext, queue_url: String, **kwargs) -> None: + raise NotImplementedError + + @handler("GetQueueAttributes") + def get_queue_attributes( + self, + context: RequestContext, + queue_url: String, + attribute_names: AttributeNameList | None = None, + **kwargs, + ) -> GetQueueAttributesResult: + raise NotImplementedError + + @handler("GetQueueUrl") + def get_queue_url( + self, + context: RequestContext, + queue_name: String, + queue_owner_aws_account_id: String | None = None, + **kwargs, + ) -> GetQueueUrlResult: + raise NotImplementedError + + @handler("ListDeadLetterSourceQueues") + def list_dead_letter_source_queues( + self, + context: RequestContext, + queue_url: String, + next_token: Token | None = None, + max_results: BoxedInteger | None = None, + **kwargs, + ) -> ListDeadLetterSourceQueuesResult: + raise NotImplementedError + + @handler("ListMessageMoveTasks") + def list_message_move_tasks( + self, + context: RequestContext, + source_arn: String, + max_results: NullableInteger | None = None, + **kwargs, + ) -> ListMessageMoveTasksResult: + raise NotImplementedError + + @handler("ListQueueTags") + def list_queue_tags( + self, context: RequestContext, queue_url: String, **kwargs + ) -> ListQueueTagsResult: + raise NotImplementedError + + @handler("ListQueues") + def list_queues( + self, + context: RequestContext, + queue_name_prefix: String | None = None, + next_token: Token | None = None, + max_results: BoxedInteger | None = None, + **kwargs, + ) -> ListQueuesResult: + raise NotImplementedError + + @handler("PurgeQueue") + def purge_queue(self, context: RequestContext, queue_url: String, **kwargs) -> None: + raise NotImplementedError + + @handler("ReceiveMessage") + def receive_message( + self, + context: RequestContext, + queue_url: String, + attribute_names: AttributeNameList | None = None, + message_system_attribute_names: MessageSystemAttributeList | None = None, + message_attribute_names: MessageAttributeNameList | None = None, + max_number_of_messages: NullableInteger | None = None, + visibility_timeout: NullableInteger | None = None, + wait_time_seconds: NullableInteger | None = None, + receive_request_attempt_id: String | None = None, + **kwargs, + ) -> ReceiveMessageResult: + raise NotImplementedError + + @handler("RemovePermission") + def remove_permission( + self, context: RequestContext, queue_url: String, label: String, **kwargs + ) -> None: + raise NotImplementedError + + @handler("SendMessage") + def send_message( + self, + context: RequestContext, + queue_url: String, + message_body: String, + delay_seconds: NullableInteger | None = None, + message_attributes: MessageBodyAttributeMap | None = None, + message_system_attributes: MessageBodySystemAttributeMap | None = None, + message_deduplication_id: String | None = None, + message_group_id: String | None = None, + **kwargs, + ) -> SendMessageResult: + raise NotImplementedError + + @handler("SendMessageBatch") + def send_message_batch( + self, + context: RequestContext, + queue_url: String, + entries: SendMessageBatchRequestEntryList, + **kwargs, + ) -> SendMessageBatchResult: + raise NotImplementedError + + @handler("SetQueueAttributes") + def set_queue_attributes( + self, context: RequestContext, queue_url: String, attributes: QueueAttributeMap, **kwargs + ) -> None: + raise NotImplementedError + + @handler("StartMessageMoveTask") + def start_message_move_task( + self, + context: RequestContext, + source_arn: String, + destination_arn: String | None = None, + max_number_of_messages_per_second: NullableInteger | None = None, + **kwargs, + ) -> StartMessageMoveTaskResult: + raise NotImplementedError + + @handler("TagQueue") + def tag_queue(self, context: RequestContext, queue_url: String, tags: TagMap, **kwargs) -> None: + raise NotImplementedError + + @handler("UntagQueue") + def untag_queue( + self, context: RequestContext, queue_url: String, tag_keys: TagKeyList, **kwargs + ) -> None: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/ssm/__init__.py b/localstack-core/localstack/aws/api/ssm/__init__.py new file mode 100644 index 0000000000000..bf32cd2834bc2 --- /dev/null +++ b/localstack-core/localstack/aws/api/ssm/__init__.py @@ -0,0 +1,7666 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AccessKeyIdType = str +AccessKeySecretType = str +AccessRequestId = str +Account = str +AccountId = str +ActivationCode = str +ActivationDescription = str +ActivationId = str +AgentErrorCode = str +AgentType = str +AgentVersion = str +AggregatorSchemaOnly = bool +AlarmName = str +AllowedPattern = str +ApplyOnlyAtCronInterval = bool +ApproveAfterDays = int +Architecture = str +AssociationExecutionFilterValue = str +AssociationExecutionId = str +AssociationExecutionTargetsFilterValue = str +AssociationFilterValue = str +AssociationId = str +AssociationName = str +AssociationResourceId = str +AssociationResourceType = str +AssociationVersion = str +AttachmentHash = str +AttachmentIdentifier = str +AttachmentName = str +AttachmentUrl = str +AttachmentsSourceValue = str +AttributeName = str +AttributeValue = str +AutomationActionName = str +AutomationExecutionFilterValue = str +AutomationExecutionId = str +AutomationParameterKey = str +AutomationParameterValue = str +AutomationTargetParameterName = str +BaselineDescription = str +BaselineId = str +BaselineName = str +BatchErrorMessage = str +Boolean = bool +CalendarNameOrARN = str +Category = str +ChangeDetailsValue = str +ChangeRequestName = str +ClientToken = str +CloudWatchLogGroupName = str +CloudWatchOutputEnabled = bool +CommandFilterValue = str +CommandId = str +CommandMaxResults = int +CommandPluginName = str +CommandPluginOutput = str +Comment = str +CompletedCount = int +ComplianceExecutionId = str +ComplianceExecutionType = str +ComplianceFilterValue = str +ComplianceItemContentHash = str +ComplianceItemId = str +ComplianceItemTitle = str +ComplianceResourceId = str +ComplianceResourceType = str +ComplianceStringFilterKey = str +ComplianceSummaryCount = int +ComplianceTypeName = str +ComputerName = str +DefaultBaseline = bool +DefaultInstanceName = str +DeliveryTimedOutCount = int +DescribeInstancePropertiesMaxResults = int +DescriptionInDocument = str +DocumentARN = str +DocumentAuthor = str +DocumentContent = str +DocumentDisplayName = str +DocumentFilterValue = str +DocumentHash = str +DocumentKeyValuesFilterKey = str +DocumentKeyValuesFilterValue = str +DocumentName = str +DocumentOwner = str +DocumentParameterDefaultValue = str +DocumentParameterDescrption = str +DocumentParameterName = str +DocumentPermissionMaxResults = int +DocumentReviewComment = str +DocumentSchemaVersion = str +DocumentSha1 = str +DocumentStatusInformation = str +DocumentVersion = str +DocumentVersionName = str +DocumentVersionNumber = str +DryRun = bool +Duration = int +EffectiveInstanceAssociationMaxResults = int +ErrorCount = int +ExcludeAccount = str +ExecutionPreviewId = str +ExecutionRoleName = str +GetInventorySchemaMaxResults = int +GetOpsMetadataMaxResults = int +GetParametersByPathMaxResults = int +IPAddress = str +ISO8601String = str +IamRole = str +IdempotencyToken = str +InstallOverrideList = str +InstanceAssociationExecutionSummary = str +InstanceCount = int +InstanceId = str +InstanceInformationFilterValue = str +InstanceInformationStringFilterKey = str +InstanceName = str +InstancePatchStateFilterKey = str +InstancePatchStateFilterValue = str +InstancePropertyFilterValue = str +InstancePropertyStringFilterKey = str +InstanceRole = str +InstanceState = str +InstanceStatus = str +InstanceTagName = str +InstanceType = str +InstancesCount = int +Integer = int +InventoryAggregatorExpression = str +InventoryDeletionLastStatusMessage = str +InventoryFilterKey = str +InventoryFilterValue = str +InventoryGroupName = str +InventoryItemAttributeName = str +InventoryItemCaptureTime = str +InventoryItemContentHash = str +InventoryItemSchemaVersion = str +InventoryItemTypeName = str +InventoryItemTypeNameFilter = str +InventoryResultEntityId = str +InventoryResultItemKey = str +InventoryTypeDisplayName = str +InvocationTraceOutput = str +IpAddress = str +IsSubTypeSchema = bool +KeyName = str +LastResourceDataSyncMessage = str +ListOpsMetadataMaxResults = int +MaintenanceWindowAllowUnassociatedTargets = bool +MaintenanceWindowCutoff = int +MaintenanceWindowDescription = str +MaintenanceWindowDurationHours = int +MaintenanceWindowEnabled = bool +MaintenanceWindowExecutionId = str +MaintenanceWindowExecutionStatusDetails = str +MaintenanceWindowExecutionTaskExecutionId = str +MaintenanceWindowExecutionTaskId = str +MaintenanceWindowExecutionTaskInvocationId = str +MaintenanceWindowExecutionTaskInvocationParameters = str +MaintenanceWindowFilterKey = str +MaintenanceWindowFilterValue = str +MaintenanceWindowId = str +MaintenanceWindowLambdaClientContext = str +MaintenanceWindowLambdaQualifier = str +MaintenanceWindowMaxResults = int +MaintenanceWindowName = str +MaintenanceWindowOffset = int +MaintenanceWindowSchedule = str +MaintenanceWindowSearchMaxResults = int +MaintenanceWindowStepFunctionsInput = str +MaintenanceWindowStepFunctionsName = str +MaintenanceWindowStringDateTime = str +MaintenanceWindowTargetId = str +MaintenanceWindowTaskArn = str +MaintenanceWindowTaskId = str +MaintenanceWindowTaskParameterName = str +MaintenanceWindowTaskParameterValue = str +MaintenanceWindowTaskPriority = int +MaintenanceWindowTaskTargetId = str +MaintenanceWindowTimezone = str +ManagedInstanceId = str +MaxConcurrency = str +MaxErrors = str +MaxResults = int +MaxResultsEC2Compatible = int +MaxSessionDuration = str +MetadataKey = str +MetadataValueString = str +NextToken = str +NodeAccountId = str +NodeFilterValue = str +NodeId = str +NodeOrganizationalUnitId = str +NodeOrganizationalUnitPath = str +NodeRegion = str +NotificationArn = str +OpsAggregatorType = str +OpsAggregatorValue = str +OpsAggregatorValueKey = str +OpsDataAttributeName = str +OpsDataTypeName = str +OpsEntityId = str +OpsEntityItemCaptureTime = str +OpsEntityItemKey = str +OpsFilterKey = str +OpsFilterValue = str +OpsItemAccountId = str +OpsItemArn = str +OpsItemCategory = str +OpsItemDataKey = str +OpsItemDataValueString = str +OpsItemDescription = str +OpsItemEventFilterValue = str +OpsItemEventMaxResults = int +OpsItemFilterValue = str +OpsItemId = str +OpsItemMaxResults = int +OpsItemPriority = int +OpsItemRelatedItemAssociationId = str +OpsItemRelatedItemAssociationResourceType = str +OpsItemRelatedItemAssociationResourceUri = str +OpsItemRelatedItemAssociationType = str +OpsItemRelatedItemsFilterValue = str +OpsItemRelatedItemsMaxResults = int +OpsItemSeverity = str +OpsItemSource = str +OpsItemTitle = str +OpsItemType = str +OpsMetadataArn = str +OpsMetadataFilterKey = str +OpsMetadataFilterValue = str +OpsMetadataResourceId = str +OutputSourceId = str +OutputSourceType = str +OwnerInformation = str +PSParameterName = str +PSParameterSelector = str +PSParameterValue = str +ParameterDataType = str +ParameterDescription = str +ParameterKeyId = str +ParameterLabel = str +ParameterName = str +ParameterPolicies = str +ParameterStringFilterKey = str +ParameterStringFilterValue = str +ParameterStringQueryOption = str +ParameterValue = str +ParametersFilterValue = str +PatchAdvisoryId = str +PatchArch = str +PatchAvailableSecurityUpdateCount = int +PatchBaselineMaxResults = int +PatchBugzillaId = str +PatchCVEId = str +PatchCVEIds = str +PatchClassification = str +PatchComplianceMaxResults = int +PatchContentUrl = str +PatchCriticalNonCompliantCount = int +PatchDescription = str +PatchEpoch = int +PatchFailedCount = int +PatchFilterValue = str +PatchGroup = str +PatchId = str +PatchInstalledCount = int +PatchInstalledOtherCount = int +PatchInstalledPendingRebootCount = int +PatchInstalledRejectedCount = int +PatchKbNumber = str +PatchLanguage = str +PatchMissingCount = int +PatchMsrcNumber = str +PatchMsrcSeverity = str +PatchName = str +PatchNotApplicableCount = int +PatchOrchestratorFilterKey = str +PatchOrchestratorFilterValue = str +PatchOtherNonCompliantCount = int +PatchProduct = str +PatchProductFamily = str +PatchRelease = str +PatchRepository = str +PatchSecurityNonCompliantCount = int +PatchSeverity = str +PatchSourceConfiguration = str +PatchSourceName = str +PatchSourceProduct = str +PatchStringDateTime = str +PatchTitle = str +PatchUnreportedNotApplicableCount = int +PatchVendor = str +PatchVersion = str +PlatformName = str +PlatformVersion = str +Policy = str +PolicyHash = str +PolicyId = str +Product = str +PutInventoryMessage = str +Region = str +RegistrationLimit = int +RegistrationMetadataKey = str +RegistrationMetadataValue = str +RegistrationsCount = int +RemainingCount = int +RequireType = str +ResourceArnString = str +ResourceCount = int +ResourceCountByStatus = str +ResourceDataSyncAWSKMSKeyARN = str +ResourceDataSyncDestinationDataSharingType = str +ResourceDataSyncEnableAllOpsDataSources = bool +ResourceDataSyncIncludeFutureRegions = bool +ResourceDataSyncName = str +ResourceDataSyncOrganizationSourceType = str +ResourceDataSyncOrganizationalUnitId = str +ResourceDataSyncS3BucketName = str +ResourceDataSyncS3Prefix = str +ResourceDataSyncS3Region = str +ResourceDataSyncSourceRegion = str +ResourceDataSyncSourceType = str +ResourceDataSyncState = str +ResourceDataSyncType = str +ResourceId = str +ResourcePolicyMaxResults = int +ResponseCode = int +Reviewer = str +S3BucketName = str +S3KeyPrefix = str +S3Region = str +ScheduleExpression = str +ScheduleOffset = int +ServiceRole = str +ServiceSettingId = str +ServiceSettingValue = str +SessionDetails = str +SessionFilterValue = str +SessionId = str +SessionManagerCloudWatchOutputUrl = str +SessionManagerParameterName = str +SessionManagerParameterValue = str +SessionManagerS3OutputUrl = str +SessionMaxResults = int +SessionOwner = str +SessionReason = str +SessionTarget = str +SessionTokenType = str +SharedDocumentVersion = str +SnapshotDownloadUrl = str +SnapshotId = str +SourceId = str +StandardErrorContent = str +StandardOutputContent = str +StatusAdditionalInfo = str +StatusDetails = str +StatusMessage = str +StatusName = str +StepExecutionFilterValue = str +StreamUrl = str +String = str +String1to256 = str +StringDateTime = str +TagKey = str +TagValue = str +TargetCount = int +TargetKey = str +TargetLocationsURL = str +TargetMapKey = str +TargetMapValue = str +TargetType = str +TargetValue = str +TimeoutSeconds = int +TokenValue = str +TotalCount = int +UUID = str +Url = str +ValidNextStep = str +Version = str + + +class AccessRequestStatus(StrEnum): + Approved = "Approved" + Rejected = "Rejected" + Revoked = "Revoked" + Expired = "Expired" + Pending = "Pending" + + +class AccessType(StrEnum): + Standard = "Standard" + JustInTime = "JustInTime" + + +class AssociationComplianceSeverity(StrEnum): + CRITICAL = "CRITICAL" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + UNSPECIFIED = "UNSPECIFIED" + + +class AssociationExecutionFilterKey(StrEnum): + ExecutionId = "ExecutionId" + Status = "Status" + CreatedTime = "CreatedTime" + + +class AssociationExecutionTargetsFilterKey(StrEnum): + Status = "Status" + ResourceId = "ResourceId" + ResourceType = "ResourceType" + + +class AssociationFilterKey(StrEnum): + InstanceId = "InstanceId" + Name = "Name" + AssociationId = "AssociationId" + AssociationStatusName = "AssociationStatusName" + LastExecutedBefore = "LastExecutedBefore" + LastExecutedAfter = "LastExecutedAfter" + AssociationName = "AssociationName" + ResourceGroupName = "ResourceGroupName" + + +class AssociationFilterOperatorType(StrEnum): + EQUAL = "EQUAL" + LESS_THAN = "LESS_THAN" + GREATER_THAN = "GREATER_THAN" + + +class AssociationStatusName(StrEnum): + Pending = "Pending" + Success = "Success" + Failed = "Failed" + + +class AssociationSyncCompliance(StrEnum): + AUTO = "AUTO" + MANUAL = "MANUAL" + + +class AttachmentHashType(StrEnum): + Sha256 = "Sha256" + + +class AttachmentsSourceKey(StrEnum): + SourceUrl = "SourceUrl" + S3FileUrl = "S3FileUrl" + AttachmentReference = "AttachmentReference" + + +class AutomationExecutionFilterKey(StrEnum): + DocumentNamePrefix = "DocumentNamePrefix" + ExecutionStatus = "ExecutionStatus" + ExecutionId = "ExecutionId" + ParentExecutionId = "ParentExecutionId" + CurrentAction = "CurrentAction" + StartTimeBefore = "StartTimeBefore" + StartTimeAfter = "StartTimeAfter" + AutomationType = "AutomationType" + TagKey = "TagKey" + TargetResourceGroup = "TargetResourceGroup" + AutomationSubtype = "AutomationSubtype" + OpsItemId = "OpsItemId" + + +class AutomationExecutionStatus(StrEnum): + Pending = "Pending" + InProgress = "InProgress" + Waiting = "Waiting" + Success = "Success" + TimedOut = "TimedOut" + Cancelling = "Cancelling" + Cancelled = "Cancelled" + Failed = "Failed" + PendingApproval = "PendingApproval" + Approved = "Approved" + Rejected = "Rejected" + Scheduled = "Scheduled" + RunbookInProgress = "RunbookInProgress" + PendingChangeCalendarOverride = "PendingChangeCalendarOverride" + ChangeCalendarOverrideApproved = "ChangeCalendarOverrideApproved" + ChangeCalendarOverrideRejected = "ChangeCalendarOverrideRejected" + CompletedWithSuccess = "CompletedWithSuccess" + CompletedWithFailure = "CompletedWithFailure" + Exited = "Exited" + + +class AutomationSubtype(StrEnum): + ChangeRequest = "ChangeRequest" + AccessRequest = "AccessRequest" + + +class AutomationType(StrEnum): + CrossAccount = "CrossAccount" + Local = "Local" + + +class CalendarState(StrEnum): + OPEN = "OPEN" + CLOSED = "CLOSED" + + +class CommandFilterKey(StrEnum): + InvokedAfter = "InvokedAfter" + InvokedBefore = "InvokedBefore" + Status = "Status" + ExecutionStage = "ExecutionStage" + DocumentName = "DocumentName" + + +class CommandInvocationStatus(StrEnum): + Pending = "Pending" + InProgress = "InProgress" + Delayed = "Delayed" + Success = "Success" + Cancelled = "Cancelled" + TimedOut = "TimedOut" + Failed = "Failed" + Cancelling = "Cancelling" + + +class CommandPluginStatus(StrEnum): + Pending = "Pending" + InProgress = "InProgress" + Success = "Success" + TimedOut = "TimedOut" + Cancelled = "Cancelled" + Failed = "Failed" + + +class CommandStatus(StrEnum): + Pending = "Pending" + InProgress = "InProgress" + Success = "Success" + Cancelled = "Cancelled" + Failed = "Failed" + TimedOut = "TimedOut" + Cancelling = "Cancelling" + + +class ComplianceQueryOperatorType(StrEnum): + EQUAL = "EQUAL" + NOT_EQUAL = "NOT_EQUAL" + BEGIN_WITH = "BEGIN_WITH" + LESS_THAN = "LESS_THAN" + GREATER_THAN = "GREATER_THAN" + + +class ComplianceSeverity(StrEnum): + CRITICAL = "CRITICAL" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + INFORMATIONAL = "INFORMATIONAL" + UNSPECIFIED = "UNSPECIFIED" + + +class ComplianceStatus(StrEnum): + COMPLIANT = "COMPLIANT" + NON_COMPLIANT = "NON_COMPLIANT" + + +class ComplianceUploadType(StrEnum): + COMPLETE = "COMPLETE" + PARTIAL = "PARTIAL" + + +class ConnectionStatus(StrEnum): + connected = "connected" + notconnected = "notconnected" + + +class DescribeActivationsFilterKeys(StrEnum): + ActivationIds = "ActivationIds" + DefaultInstanceName = "DefaultInstanceName" + IamRole = "IamRole" + + +class DocumentFilterKey(StrEnum): + Name = "Name" + Owner = "Owner" + PlatformTypes = "PlatformTypes" + DocumentType = "DocumentType" + + +class DocumentFormat(StrEnum): + YAML = "YAML" + JSON = "JSON" + TEXT = "TEXT" + + +class DocumentHashType(StrEnum): + Sha256 = "Sha256" + Sha1 = "Sha1" + + +class DocumentMetadataEnum(StrEnum): + DocumentReviews = "DocumentReviews" + + +class DocumentParameterType(StrEnum): + String = "String" + StringList = "StringList" + + +class DocumentPermissionType(StrEnum): + Share = "Share" + + +class DocumentReviewAction(StrEnum): + SendForReview = "SendForReview" + UpdateReview = "UpdateReview" + Approve = "Approve" + Reject = "Reject" + + +class DocumentReviewCommentType(StrEnum): + Comment = "Comment" + + +class DocumentStatus(StrEnum): + Creating = "Creating" + Active = "Active" + Updating = "Updating" + Deleting = "Deleting" + Failed = "Failed" + + +class DocumentType(StrEnum): + Command = "Command" + Policy = "Policy" + Automation = "Automation" + Session = "Session" + Package = "Package" + ApplicationConfiguration = "ApplicationConfiguration" + ApplicationConfigurationSchema = "ApplicationConfigurationSchema" + DeploymentStrategy = "DeploymentStrategy" + ChangeCalendar = "ChangeCalendar" + Automation_ChangeTemplate = "Automation.ChangeTemplate" + ProblemAnalysis = "ProblemAnalysis" + ProblemAnalysisTemplate = "ProblemAnalysisTemplate" + CloudFormation = "CloudFormation" + ConformancePackTemplate = "ConformancePackTemplate" + QuickSetup = "QuickSetup" + ManualApprovalPolicy = "ManualApprovalPolicy" + AutoApprovalPolicy = "AutoApprovalPolicy" + + +class ExecutionMode(StrEnum): + Auto = "Auto" + Interactive = "Interactive" + + +class ExecutionPreviewStatus(StrEnum): + Pending = "Pending" + InProgress = "InProgress" + Success = "Success" + Failed = "Failed" + + +class ExternalAlarmState(StrEnum): + UNKNOWN = "UNKNOWN" + ALARM = "ALARM" + + +class Fault(StrEnum): + Client = "Client" + Server = "Server" + Unknown = "Unknown" + + +class ImpactType(StrEnum): + Mutating = "Mutating" + NonMutating = "NonMutating" + Undetermined = "Undetermined" + + +class InstanceInformationFilterKey(StrEnum): + InstanceIds = "InstanceIds" + AgentVersion = "AgentVersion" + PingStatus = "PingStatus" + PlatformTypes = "PlatformTypes" + ActivationIds = "ActivationIds" + IamRole = "IamRole" + ResourceType = "ResourceType" + AssociationStatus = "AssociationStatus" + + +class InstancePatchStateOperatorType(StrEnum): + Equal = "Equal" + NotEqual = "NotEqual" + LessThan = "LessThan" + GreaterThan = "GreaterThan" + + +class InstancePropertyFilterKey(StrEnum): + InstanceIds = "InstanceIds" + AgentVersion = "AgentVersion" + PingStatus = "PingStatus" + PlatformTypes = "PlatformTypes" + DocumentName = "DocumentName" + ActivationIds = "ActivationIds" + IamRole = "IamRole" + ResourceType = "ResourceType" + AssociationStatus = "AssociationStatus" + + +class InstancePropertyFilterOperator(StrEnum): + Equal = "Equal" + NotEqual = "NotEqual" + BeginWith = "BeginWith" + LessThan = "LessThan" + GreaterThan = "GreaterThan" + + +class InventoryAttributeDataType(StrEnum): + string = "string" + number = "number" + + +class InventoryDeletionStatus(StrEnum): + InProgress = "InProgress" + Complete = "Complete" + + +class InventoryQueryOperatorType(StrEnum): + Equal = "Equal" + NotEqual = "NotEqual" + BeginWith = "BeginWith" + LessThan = "LessThan" + GreaterThan = "GreaterThan" + Exists = "Exists" + + +class InventorySchemaDeleteOption(StrEnum): + DisableSchema = "DisableSchema" + DeleteSchema = "DeleteSchema" + + +class LastResourceDataSyncStatus(StrEnum): + Successful = "Successful" + Failed = "Failed" + InProgress = "InProgress" + + +class MaintenanceWindowExecutionStatus(StrEnum): + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + SUCCESS = "SUCCESS" + FAILED = "FAILED" + TIMED_OUT = "TIMED_OUT" + CANCELLING = "CANCELLING" + CANCELLED = "CANCELLED" + SKIPPED_OVERLAPPING = "SKIPPED_OVERLAPPING" + + +class MaintenanceWindowResourceType(StrEnum): + INSTANCE = "INSTANCE" + RESOURCE_GROUP = "RESOURCE_GROUP" + + +class MaintenanceWindowTaskCutoffBehavior(StrEnum): + CONTINUE_TASK = "CONTINUE_TASK" + CANCEL_TASK = "CANCEL_TASK" + + +class MaintenanceWindowTaskType(StrEnum): + RUN_COMMAND = "RUN_COMMAND" + AUTOMATION = "AUTOMATION" + STEP_FUNCTIONS = "STEP_FUNCTIONS" + LAMBDA = "LAMBDA" + + +class ManagedStatus(StrEnum): + All = "All" + Managed = "Managed" + Unmanaged = "Unmanaged" + + +class NodeAggregatorType(StrEnum): + Count = "Count" + + +class NodeAttributeName(StrEnum): + AgentVersion = "AgentVersion" + PlatformName = "PlatformName" + PlatformType = "PlatformType" + PlatformVersion = "PlatformVersion" + Region = "Region" + ResourceType = "ResourceType" + + +class NodeFilterKey(StrEnum): + AgentType = "AgentType" + AgentVersion = "AgentVersion" + ComputerName = "ComputerName" + InstanceId = "InstanceId" + InstanceStatus = "InstanceStatus" + IpAddress = "IpAddress" + ManagedStatus = "ManagedStatus" + PlatformName = "PlatformName" + PlatformType = "PlatformType" + PlatformVersion = "PlatformVersion" + ResourceType = "ResourceType" + OrganizationalUnitId = "OrganizationalUnitId" + OrganizationalUnitPath = "OrganizationalUnitPath" + Region = "Region" + AccountId = "AccountId" + + +class NodeFilterOperatorType(StrEnum): + Equal = "Equal" + NotEqual = "NotEqual" + BeginWith = "BeginWith" + + +class NodeTypeName(StrEnum): + Instance = "Instance" + + +class NotificationEvent(StrEnum): + All = "All" + InProgress = "InProgress" + Success = "Success" + TimedOut = "TimedOut" + Cancelled = "Cancelled" + Failed = "Failed" + + +class NotificationType(StrEnum): + Command = "Command" + Invocation = "Invocation" + + +class OperatingSystem(StrEnum): + WINDOWS = "WINDOWS" + AMAZON_LINUX = "AMAZON_LINUX" + AMAZON_LINUX_2 = "AMAZON_LINUX_2" + AMAZON_LINUX_2022 = "AMAZON_LINUX_2022" + UBUNTU = "UBUNTU" + REDHAT_ENTERPRISE_LINUX = "REDHAT_ENTERPRISE_LINUX" + SUSE = "SUSE" + CENTOS = "CENTOS" + ORACLE_LINUX = "ORACLE_LINUX" + DEBIAN = "DEBIAN" + MACOS = "MACOS" + RASPBIAN = "RASPBIAN" + ROCKY_LINUX = "ROCKY_LINUX" + ALMA_LINUX = "ALMA_LINUX" + AMAZON_LINUX_2023 = "AMAZON_LINUX_2023" + + +class OpsFilterOperatorType(StrEnum): + Equal = "Equal" + NotEqual = "NotEqual" + BeginWith = "BeginWith" + LessThan = "LessThan" + GreaterThan = "GreaterThan" + Exists = "Exists" + + +class OpsItemDataType(StrEnum): + SearchableString = "SearchableString" + String = "String" + + +class OpsItemEventFilterKey(StrEnum): + OpsItemId = "OpsItemId" + + +class OpsItemEventFilterOperator(StrEnum): + Equal = "Equal" + + +class OpsItemFilterKey(StrEnum): + Status = "Status" + CreatedBy = "CreatedBy" + Source = "Source" + Priority = "Priority" + Title = "Title" + OpsItemId = "OpsItemId" + CreatedTime = "CreatedTime" + LastModifiedTime = "LastModifiedTime" + ActualStartTime = "ActualStartTime" + ActualEndTime = "ActualEndTime" + PlannedStartTime = "PlannedStartTime" + PlannedEndTime = "PlannedEndTime" + OperationalData = "OperationalData" + OperationalDataKey = "OperationalDataKey" + OperationalDataValue = "OperationalDataValue" + ResourceId = "ResourceId" + AutomationId = "AutomationId" + Category = "Category" + Severity = "Severity" + OpsItemType = "OpsItemType" + AccessRequestByRequesterArn = "AccessRequestByRequesterArn" + AccessRequestByRequesterId = "AccessRequestByRequesterId" + AccessRequestByApproverArn = "AccessRequestByApproverArn" + AccessRequestByApproverId = "AccessRequestByApproverId" + AccessRequestBySourceAccountId = "AccessRequestBySourceAccountId" + AccessRequestBySourceOpsItemId = "AccessRequestBySourceOpsItemId" + AccessRequestBySourceRegion = "AccessRequestBySourceRegion" + AccessRequestByIsReplica = "AccessRequestByIsReplica" + AccessRequestByTargetResourceId = "AccessRequestByTargetResourceId" + ChangeRequestByRequesterArn = "ChangeRequestByRequesterArn" + ChangeRequestByRequesterName = "ChangeRequestByRequesterName" + ChangeRequestByApproverArn = "ChangeRequestByApproverArn" + ChangeRequestByApproverName = "ChangeRequestByApproverName" + ChangeRequestByTemplate = "ChangeRequestByTemplate" + ChangeRequestByTargetsResourceGroup = "ChangeRequestByTargetsResourceGroup" + InsightByType = "InsightByType" + AccountId = "AccountId" + + +class OpsItemFilterOperator(StrEnum): + Equal = "Equal" + Contains = "Contains" + GreaterThan = "GreaterThan" + LessThan = "LessThan" + + +class OpsItemRelatedItemsFilterKey(StrEnum): + ResourceType = "ResourceType" + AssociationId = "AssociationId" + ResourceUri = "ResourceUri" + + +class OpsItemRelatedItemsFilterOperator(StrEnum): + Equal = "Equal" + + +class OpsItemStatus(StrEnum): + Open = "Open" + InProgress = "InProgress" + Resolved = "Resolved" + Pending = "Pending" + TimedOut = "TimedOut" + Cancelling = "Cancelling" + Cancelled = "Cancelled" + Failed = "Failed" + CompletedWithSuccess = "CompletedWithSuccess" + CompletedWithFailure = "CompletedWithFailure" + Scheduled = "Scheduled" + RunbookInProgress = "RunbookInProgress" + PendingChangeCalendarOverride = "PendingChangeCalendarOverride" + ChangeCalendarOverrideApproved = "ChangeCalendarOverrideApproved" + ChangeCalendarOverrideRejected = "ChangeCalendarOverrideRejected" + PendingApproval = "PendingApproval" + Approved = "Approved" + Revoked = "Revoked" + Rejected = "Rejected" + Closed = "Closed" + + +class ParameterTier(StrEnum): + Standard = "Standard" + Advanced = "Advanced" + Intelligent_Tiering = "Intelligent-Tiering" + + +class ParameterType(StrEnum): + String = "String" + StringList = "StringList" + SecureString = "SecureString" + + +class ParametersFilterKey(StrEnum): + Name = "Name" + Type = "Type" + KeyId = "KeyId" + + +class PatchAction(StrEnum): + ALLOW_AS_DEPENDENCY = "ALLOW_AS_DEPENDENCY" + BLOCK = "BLOCK" + + +class PatchComplianceDataState(StrEnum): + INSTALLED = "INSTALLED" + INSTALLED_OTHER = "INSTALLED_OTHER" + INSTALLED_PENDING_REBOOT = "INSTALLED_PENDING_REBOOT" + INSTALLED_REJECTED = "INSTALLED_REJECTED" + MISSING = "MISSING" + NOT_APPLICABLE = "NOT_APPLICABLE" + FAILED = "FAILED" + AVAILABLE_SECURITY_UPDATE = "AVAILABLE_SECURITY_UPDATE" + + +class PatchComplianceLevel(StrEnum): + CRITICAL = "CRITICAL" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + INFORMATIONAL = "INFORMATIONAL" + UNSPECIFIED = "UNSPECIFIED" + + +class PatchComplianceStatus(StrEnum): + COMPLIANT = "COMPLIANT" + NON_COMPLIANT = "NON_COMPLIANT" + + +class PatchDeploymentStatus(StrEnum): + APPROVED = "APPROVED" + PENDING_APPROVAL = "PENDING_APPROVAL" + EXPLICIT_APPROVED = "EXPLICIT_APPROVED" + EXPLICIT_REJECTED = "EXPLICIT_REJECTED" + + +class PatchFilterKey(StrEnum): + ARCH = "ARCH" + ADVISORY_ID = "ADVISORY_ID" + BUGZILLA_ID = "BUGZILLA_ID" + PATCH_SET = "PATCH_SET" + PRODUCT = "PRODUCT" + PRODUCT_FAMILY = "PRODUCT_FAMILY" + CLASSIFICATION = "CLASSIFICATION" + CVE_ID = "CVE_ID" + EPOCH = "EPOCH" + MSRC_SEVERITY = "MSRC_SEVERITY" + NAME = "NAME" + PATCH_ID = "PATCH_ID" + SECTION = "SECTION" + PRIORITY = "PRIORITY" + REPOSITORY = "REPOSITORY" + RELEASE = "RELEASE" + SEVERITY = "SEVERITY" + SECURITY = "SECURITY" + VERSION = "VERSION" + + +class PatchOperationType(StrEnum): + Scan = "Scan" + Install = "Install" + + +class PatchProperty(StrEnum): + PRODUCT = "PRODUCT" + PRODUCT_FAMILY = "PRODUCT_FAMILY" + CLASSIFICATION = "CLASSIFICATION" + MSRC_SEVERITY = "MSRC_SEVERITY" + PRIORITY = "PRIORITY" + SEVERITY = "SEVERITY" + + +class PatchSet(StrEnum): + OS = "OS" + APPLICATION = "APPLICATION" + + +class PingStatus(StrEnum): + Online = "Online" + ConnectionLost = "ConnectionLost" + Inactive = "Inactive" + + +class PlatformType(StrEnum): + Windows = "Windows" + Linux = "Linux" + MacOS = "MacOS" + + +class RebootOption(StrEnum): + RebootIfNeeded = "RebootIfNeeded" + NoReboot = "NoReboot" + + +class ResourceDataSyncS3Format(StrEnum): + JsonSerDe = "JsonSerDe" + + +class ResourceType(StrEnum): + ManagedInstance = "ManagedInstance" + EC2Instance = "EC2Instance" + + +class ResourceTypeForTagging(StrEnum): + Document = "Document" + ManagedInstance = "ManagedInstance" + MaintenanceWindow = "MaintenanceWindow" + Parameter = "Parameter" + PatchBaseline = "PatchBaseline" + OpsItem = "OpsItem" + OpsMetadata = "OpsMetadata" + Automation = "Automation" + Association = "Association" + + +class ReviewStatus(StrEnum): + APPROVED = "APPROVED" + NOT_REVIEWED = "NOT_REVIEWED" + PENDING = "PENDING" + REJECTED = "REJECTED" + + +class SessionFilterKey(StrEnum): + InvokedAfter = "InvokedAfter" + InvokedBefore = "InvokedBefore" + Target = "Target" + Owner = "Owner" + Status = "Status" + SessionId = "SessionId" + AccessType = "AccessType" + + +class SessionState(StrEnum): + Active = "Active" + History = "History" + + +class SessionStatus(StrEnum): + Connected = "Connected" + Connecting = "Connecting" + Disconnected = "Disconnected" + Terminated = "Terminated" + Terminating = "Terminating" + Failed = "Failed" + + +class SignalType(StrEnum): + Approve = "Approve" + Reject = "Reject" + StartStep = "StartStep" + StopStep = "StopStep" + Resume = "Resume" + Revoke = "Revoke" + + +class SourceType(StrEnum): + AWS_EC2_Instance = "AWS::EC2::Instance" + AWS_IoT_Thing = "AWS::IoT::Thing" + AWS_SSM_ManagedInstance = "AWS::SSM::ManagedInstance" + + +class StepExecutionFilterKey(StrEnum): + StartTimeBefore = "StartTimeBefore" + StartTimeAfter = "StartTimeAfter" + StepExecutionStatus = "StepExecutionStatus" + StepExecutionId = "StepExecutionId" + StepName = "StepName" + Action = "Action" + ParentStepExecutionId = "ParentStepExecutionId" + ParentStepIteration = "ParentStepIteration" + ParentStepIteratorValue = "ParentStepIteratorValue" + + +class StopType(StrEnum): + Complete = "Complete" + Cancel = "Cancel" + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class AlreadyExistsException(ServiceException): + code: str = "AlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class AssociatedInstances(ServiceException): + code: str = "AssociatedInstances" + sender_fault: bool = False + status_code: int = 400 + + +class AssociationAlreadyExists(ServiceException): + code: str = "AssociationAlreadyExists" + sender_fault: bool = False + status_code: int = 400 + + +class AssociationDoesNotExist(ServiceException): + code: str = "AssociationDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class AssociationExecutionDoesNotExist(ServiceException): + code: str = "AssociationExecutionDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class AssociationLimitExceeded(ServiceException): + code: str = "AssociationLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class AssociationVersionLimitExceeded(ServiceException): + code: str = "AssociationVersionLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class AutomationDefinitionNotApprovedException(ServiceException): + code: str = "AutomationDefinitionNotApprovedException" + sender_fault: bool = False + status_code: int = 400 + + +class AutomationDefinitionNotFoundException(ServiceException): + code: str = "AutomationDefinitionNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class AutomationDefinitionVersionNotFoundException(ServiceException): + code: str = "AutomationDefinitionVersionNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class AutomationExecutionLimitExceededException(ServiceException): + code: str = "AutomationExecutionLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class AutomationExecutionNotFoundException(ServiceException): + code: str = "AutomationExecutionNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class AutomationStepNotFoundException(ServiceException): + code: str = "AutomationStepNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ComplianceTypeCountLimitExceededException(ServiceException): + code: str = "ComplianceTypeCountLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class CustomSchemaCountLimitExceededException(ServiceException): + code: str = "CustomSchemaCountLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class DocumentAlreadyExists(ServiceException): + code: str = "DocumentAlreadyExists" + sender_fault: bool = False + status_code: int = 400 + + +class DocumentLimitExceeded(ServiceException): + code: str = "DocumentLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class DocumentPermissionLimit(ServiceException): + code: str = "DocumentPermissionLimit" + sender_fault: bool = False + status_code: int = 400 + + +class DocumentVersionLimitExceeded(ServiceException): + code: str = "DocumentVersionLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class DoesNotExistException(ServiceException): + code: str = "DoesNotExistException" + sender_fault: bool = False + status_code: int = 400 + + +class DuplicateDocumentContent(ServiceException): + code: str = "DuplicateDocumentContent" + sender_fault: bool = False + status_code: int = 400 + + +class DuplicateDocumentVersionName(ServiceException): + code: str = "DuplicateDocumentVersionName" + sender_fault: bool = False + status_code: int = 400 + + +class DuplicateInstanceId(ServiceException): + code: str = "DuplicateInstanceId" + sender_fault: bool = False + status_code: int = 400 + + +class FeatureNotAvailableException(ServiceException): + code: str = "FeatureNotAvailableException" + sender_fault: bool = False + status_code: int = 400 + + +class HierarchyLevelLimitExceededException(ServiceException): + code: str = "HierarchyLevelLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class HierarchyTypeMismatchException(ServiceException): + code: str = "HierarchyTypeMismatchException" + sender_fault: bool = False + status_code: int = 400 + + +class IdempotentParameterMismatch(ServiceException): + code: str = "IdempotentParameterMismatch" + sender_fault: bool = False + status_code: int = 400 + + +class IncompatiblePolicyException(ServiceException): + code: str = "IncompatiblePolicyException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServerError(ServiceException): + code: str = "InternalServerError" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidActivation(ServiceException): + code: str = "InvalidActivation" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidActivationId(ServiceException): + code: str = "InvalidActivationId" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAggregatorException(ServiceException): + code: str = "InvalidAggregatorException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAllowedPatternException(ServiceException): + code: str = "InvalidAllowedPatternException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAssociation(ServiceException): + code: str = "InvalidAssociation" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAssociationVersion(ServiceException): + code: str = "InvalidAssociationVersion" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAutomationExecutionParametersException(ServiceException): + code: str = "InvalidAutomationExecutionParametersException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAutomationSignalException(ServiceException): + code: str = "InvalidAutomationSignalException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidAutomationStatusUpdateException(ServiceException): + code: str = "InvalidAutomationStatusUpdateException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidCommandId(ServiceException): + code: str = "InvalidCommandId" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDeleteInventoryParametersException(ServiceException): + code: str = "InvalidDeleteInventoryParametersException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDeletionIdException(ServiceException): + code: str = "InvalidDeletionIdException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDocument(ServiceException): + code: str = "InvalidDocument" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDocumentContent(ServiceException): + code: str = "InvalidDocumentContent" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDocumentOperation(ServiceException): + code: str = "InvalidDocumentOperation" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDocumentSchemaVersion(ServiceException): + code: str = "InvalidDocumentSchemaVersion" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDocumentType(ServiceException): + code: str = "InvalidDocumentType" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDocumentVersion(ServiceException): + code: str = "InvalidDocumentVersion" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidFilter(ServiceException): + code: str = "InvalidFilter" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidFilterKey(ServiceException): + code: str = "InvalidFilterKey" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidFilterOption(ServiceException): + code: str = "InvalidFilterOption" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidFilterValue(ServiceException): + code: str = "InvalidFilterValue" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidInstanceId(ServiceException): + code: str = "InvalidInstanceId" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidInstanceInformationFilterValue(ServiceException): + code: str = "InvalidInstanceInformationFilterValue" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidInstancePropertyFilterValue(ServiceException): + code: str = "InvalidInstancePropertyFilterValue" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidInventoryGroupException(ServiceException): + code: str = "InvalidInventoryGroupException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidInventoryItemContextException(ServiceException): + code: str = "InvalidInventoryItemContextException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidInventoryRequestException(ServiceException): + code: str = "InvalidInventoryRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidItemContentException(ServiceException): + code: str = "InvalidItemContentException" + sender_fault: bool = False + status_code: int = 400 + TypeName: Optional[InventoryItemTypeName] + + +class InvalidKeyId(ServiceException): + code: str = "InvalidKeyId" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidNextToken(ServiceException): + code: str = "InvalidNextToken" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidNotificationConfig(ServiceException): + code: str = "InvalidNotificationConfig" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidOptionException(ServiceException): + code: str = "InvalidOptionException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidOutputFolder(ServiceException): + code: str = "InvalidOutputFolder" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidOutputLocation(ServiceException): + code: str = "InvalidOutputLocation" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidParameters(ServiceException): + code: str = "InvalidParameters" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidPermissionType(ServiceException): + code: str = "InvalidPermissionType" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidPluginName(ServiceException): + code: str = "InvalidPluginName" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidPolicyAttributeException(ServiceException): + code: str = "InvalidPolicyAttributeException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidPolicyTypeException(ServiceException): + code: str = "InvalidPolicyTypeException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidResourceId(ServiceException): + code: str = "InvalidResourceId" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidResourceType(ServiceException): + code: str = "InvalidResourceType" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidResultAttributeException(ServiceException): + code: str = "InvalidResultAttributeException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidRole(ServiceException): + code: str = "InvalidRole" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidSchedule(ServiceException): + code: str = "InvalidSchedule" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTag(ServiceException): + code: str = "InvalidTag" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTarget(ServiceException): + code: str = "InvalidTarget" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTargetMaps(ServiceException): + code: str = "InvalidTargetMaps" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTypeNameException(ServiceException): + code: str = "InvalidTypeNameException" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidUpdate(ServiceException): + code: str = "InvalidUpdate" + sender_fault: bool = False + status_code: int = 400 + + +class InvocationDoesNotExist(ServiceException): + code: str = "InvocationDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class ItemContentMismatchException(ServiceException): + code: str = "ItemContentMismatchException" + sender_fault: bool = False + status_code: int = 400 + TypeName: Optional[InventoryItemTypeName] + + +class ItemSizeLimitExceededException(ServiceException): + code: str = "ItemSizeLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + TypeName: Optional[InventoryItemTypeName] + + +class MalformedResourcePolicyDocumentException(ServiceException): + code: str = "MalformedResourcePolicyDocumentException" + sender_fault: bool = False + status_code: int = 400 + + +class MaxDocumentSizeExceeded(ServiceException): + code: str = "MaxDocumentSizeExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class OpsItemAccessDeniedException(ServiceException): + code: str = "OpsItemAccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsItemAlreadyExistsException(ServiceException): + code: str = "OpsItemAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + OpsItemId: Optional[String] + + +class OpsItemConflictException(ServiceException): + code: str = "OpsItemConflictException" + sender_fault: bool = False + status_code: int = 400 + + +OpsItemParameterNamesList = List[String] + + +class OpsItemInvalidParameterException(ServiceException): + code: str = "OpsItemInvalidParameterException" + sender_fault: bool = False + status_code: int = 400 + ParameterNames: Optional[OpsItemParameterNamesList] + + +class OpsItemLimitExceededException(ServiceException): + code: str = "OpsItemLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + ResourceTypes: Optional[OpsItemParameterNamesList] + Limit: Optional[Integer] + LimitType: Optional[String] + + +class OpsItemNotFoundException(ServiceException): + code: str = "OpsItemNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsItemRelatedItemAlreadyExistsException(ServiceException): + code: str = "OpsItemRelatedItemAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + ResourceUri: Optional[OpsItemRelatedItemAssociationResourceUri] + OpsItemId: Optional[OpsItemId] + + +class OpsItemRelatedItemAssociationNotFoundException(ServiceException): + code: str = "OpsItemRelatedItemAssociationNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsMetadataAlreadyExistsException(ServiceException): + code: str = "OpsMetadataAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsMetadataInvalidArgumentException(ServiceException): + code: str = "OpsMetadataInvalidArgumentException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsMetadataKeyLimitExceededException(ServiceException): + code: str = "OpsMetadataKeyLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsMetadataLimitExceededException(ServiceException): + code: str = "OpsMetadataLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsMetadataNotFoundException(ServiceException): + code: str = "OpsMetadataNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class OpsMetadataTooManyUpdatesException(ServiceException): + code: str = "OpsMetadataTooManyUpdatesException" + sender_fault: bool = False + status_code: int = 400 + + +class ParameterAlreadyExists(ServiceException): + code: str = "ParameterAlreadyExists" + sender_fault: bool = False + status_code: int = 400 + + +class ParameterLimitExceeded(ServiceException): + code: str = "ParameterLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class ParameterMaxVersionLimitExceeded(ServiceException): + code: str = "ParameterMaxVersionLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class ParameterNotFound(ServiceException): + code: str = "ParameterNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class ParameterPatternMismatchException(ServiceException): + code: str = "ParameterPatternMismatchException" + sender_fault: bool = False + status_code: int = 400 + + +class ParameterVersionLabelLimitExceeded(ServiceException): + code: str = "ParameterVersionLabelLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class ParameterVersionNotFound(ServiceException): + code: str = "ParameterVersionNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class PoliciesLimitExceededException(ServiceException): + code: str = "PoliciesLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceDataSyncAlreadyExistsException(ServiceException): + code: str = "ResourceDataSyncAlreadyExistsException" + sender_fault: bool = False + status_code: int = 400 + SyncName: Optional[ResourceDataSyncName] + + +class ResourceDataSyncConflictException(ServiceException): + code: str = "ResourceDataSyncConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceDataSyncCountExceededException(ServiceException): + code: str = "ResourceDataSyncCountExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceDataSyncInvalidConfigurationException(ServiceException): + code: str = "ResourceDataSyncInvalidConfigurationException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceDataSyncNotFoundException(ServiceException): + code: str = "ResourceDataSyncNotFoundException" + sender_fault: bool = False + status_code: int = 400 + SyncName: Optional[ResourceDataSyncName] + SyncType: Optional[ResourceDataSyncType] + + +class ResourceInUseException(ServiceException): + code: str = "ResourceInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceLimitExceededException(ServiceException): + code: str = "ResourceLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFoundException(ServiceException): + code: str = "ResourceNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ResourcePolicyConflictException(ServiceException): + code: str = "ResourcePolicyConflictException" + sender_fault: bool = False + status_code: int = 400 + + +ResourcePolicyParameterNamesList = List[String] + + +class ResourcePolicyInvalidParameterException(ServiceException): + code: str = "ResourcePolicyInvalidParameterException" + sender_fault: bool = False + status_code: int = 400 + ParameterNames: Optional[ResourcePolicyParameterNamesList] + + +class ResourcePolicyLimitExceededException(ServiceException): + code: str = "ResourcePolicyLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + Limit: Optional[Integer] + LimitType: Optional[String] + + +class ResourcePolicyNotFoundException(ServiceException): + code: str = "ResourcePolicyNotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +class ServiceQuotaExceededException(ServiceException): + code: str = "ServiceQuotaExceededException" + sender_fault: bool = False + status_code: int = 400 + ResourceId: Optional[String] + ResourceType: Optional[String] + QuotaCode: String + ServiceCode: String + + +class ServiceSettingNotFound(ServiceException): + code: str = "ServiceSettingNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class StatusUnchanged(ServiceException): + code: str = "StatusUnchanged" + sender_fault: bool = False + status_code: int = 400 + + +class SubTypeCountLimitExceededException(ServiceException): + code: str = "SubTypeCountLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class TargetInUseException(ServiceException): + code: str = "TargetInUseException" + sender_fault: bool = False + status_code: int = 400 + + +class TargetNotConnected(ServiceException): + code: str = "TargetNotConnected" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + QuotaCode: Optional[String] + ServiceCode: Optional[String] + + +class TooManyTagsError(ServiceException): + code: str = "TooManyTagsError" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyUpdates(ServiceException): + code: str = "TooManyUpdates" + sender_fault: bool = False + status_code: int = 400 + + +class TotalSizeLimitExceededException(ServiceException): + code: str = "TotalSizeLimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedCalendarException(ServiceException): + code: str = "UnsupportedCalendarException" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedFeatureRequiredException(ServiceException): + code: str = "UnsupportedFeatureRequiredException" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedInventoryItemContextException(ServiceException): + code: str = "UnsupportedInventoryItemContextException" + sender_fault: bool = False + status_code: int = 400 + TypeName: Optional[InventoryItemTypeName] + + +class UnsupportedInventorySchemaVersionException(ServiceException): + code: str = "UnsupportedInventorySchemaVersionException" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedOperatingSystem(ServiceException): + code: str = "UnsupportedOperatingSystem" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedOperationException(ServiceException): + code: str = "UnsupportedOperationException" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedParameterType(ServiceException): + code: str = "UnsupportedParameterType" + sender_fault: bool = False + status_code: int = 400 + + +class UnsupportedPlatformType(ServiceException): + code: str = "UnsupportedPlatformType" + sender_fault: bool = False + status_code: int = 400 + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + ReasonCode: Optional[String] + + +AccountIdList = List[AccountId] + + +class AccountSharingInfo(TypedDict, total=False): + AccountId: Optional[AccountId] + SharedDocumentVersion: Optional[SharedDocumentVersion] + + +AccountSharingInfoList = List[AccountSharingInfo] +Accounts = List[Account] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] +CreatedDate = datetime +ExpirationDate = datetime + + +class Activation(TypedDict, total=False): + ActivationId: Optional[ActivationId] + Description: Optional[ActivationDescription] + DefaultInstanceName: Optional[DefaultInstanceName] + IamRole: Optional[IamRole] + RegistrationLimit: Optional[RegistrationLimit] + RegistrationsCount: Optional[RegistrationsCount] + ExpirationDate: Optional[ExpirationDate] + Expired: Optional[Boolean] + CreatedDate: Optional[CreatedDate] + Tags: Optional[TagList] + + +ActivationList = List[Activation] + + +class AddTagsToResourceRequest(ServiceRequest): + ResourceType: ResourceTypeForTagging + ResourceId: ResourceId + Tags: TagList + + +class AddTagsToResourceResult(TypedDict, total=False): + pass + + +class Alarm(TypedDict, total=False): + Name: AlarmName + + +AlarmList = List[Alarm] + + +class AlarmConfiguration(TypedDict, total=False): + IgnorePollAlarmFailure: Optional[Boolean] + Alarms: AlarmList + + +class AlarmStateInformation(TypedDict, total=False): + Name: AlarmName + State: ExternalAlarmState + + +AlarmStateInformationList = List[AlarmStateInformation] + + +class AssociateOpsItemRelatedItemRequest(ServiceRequest): + OpsItemId: OpsItemId + AssociationType: OpsItemRelatedItemAssociationType + ResourceType: OpsItemRelatedItemAssociationResourceType + ResourceUri: OpsItemRelatedItemAssociationResourceUri + + +class AssociateOpsItemRelatedItemResponse(TypedDict, total=False): + AssociationId: Optional[OpsItemRelatedItemAssociationId] + + +TargetMapValueList = List[TargetMapValue] +TargetMap = Dict[TargetMapKey, TargetMapValueList] +TargetMaps = List[TargetMap] +AssociationStatusAggregatedCount = Dict[StatusName, InstanceCount] + + +class AssociationOverview(TypedDict, total=False): + Status: Optional[StatusName] + DetailedStatus: Optional[StatusName] + AssociationStatusAggregatedCount: Optional[AssociationStatusAggregatedCount] + + +DateTime = datetime +TargetValues = List[TargetValue] + + +class Target(TypedDict, total=False): + Key: Optional[TargetKey] + Values: Optional[TargetValues] + + +Targets = List[Target] + + +class Association(TypedDict, total=False): + Name: Optional[DocumentARN] + InstanceId: Optional[InstanceId] + AssociationId: Optional[AssociationId] + AssociationVersion: Optional[AssociationVersion] + DocumentVersion: Optional[DocumentVersion] + Targets: Optional[Targets] + LastExecutionDate: Optional[DateTime] + Overview: Optional[AssociationOverview] + ScheduleExpression: Optional[ScheduleExpression] + AssociationName: Optional[AssociationName] + ScheduleOffset: Optional[ScheduleOffset] + Duration: Optional[Duration] + TargetMaps: Optional[TargetMaps] + + +ExcludeAccounts = List[ExcludeAccount] +Regions = List[Region] + + +class TargetLocation(TypedDict, total=False): + Accounts: Optional[Accounts] + Regions: Optional[Regions] + TargetLocationMaxConcurrency: Optional[MaxConcurrency] + TargetLocationMaxErrors: Optional[MaxErrors] + ExecutionRoleName: Optional[ExecutionRoleName] + TargetLocationAlarmConfiguration: Optional[AlarmConfiguration] + IncludeChildOrganizationUnits: Optional[Boolean] + ExcludeAccounts: Optional[ExcludeAccounts] + Targets: Optional[Targets] + TargetsMaxConcurrency: Optional[MaxConcurrency] + TargetsMaxErrors: Optional[MaxErrors] + + +TargetLocations = List[TargetLocation] +CalendarNameOrARNList = List[CalendarNameOrARN] + + +class S3OutputLocation(TypedDict, total=False): + OutputS3Region: Optional[S3Region] + OutputS3BucketName: Optional[S3BucketName] + OutputS3KeyPrefix: Optional[S3KeyPrefix] + + +class InstanceAssociationOutputLocation(TypedDict, total=False): + S3Location: Optional[S3OutputLocation] + + +ParameterValueList = List[ParameterValue] +Parameters = Dict[ParameterName, ParameterValueList] + + +class AssociationStatus(TypedDict, total=False): + Date: DateTime + Name: AssociationStatusName + Message: StatusMessage + AdditionalInfo: Optional[StatusAdditionalInfo] + + +class AssociationDescription(TypedDict, total=False): + Name: Optional[DocumentARN] + InstanceId: Optional[InstanceId] + AssociationVersion: Optional[AssociationVersion] + Date: Optional[DateTime] + LastUpdateAssociationDate: Optional[DateTime] + Status: Optional[AssociationStatus] + Overview: Optional[AssociationOverview] + DocumentVersion: Optional[DocumentVersion] + AutomationTargetParameterName: Optional[AutomationTargetParameterName] + Parameters: Optional[Parameters] + AssociationId: Optional[AssociationId] + Targets: Optional[Targets] + ScheduleExpression: Optional[ScheduleExpression] + OutputLocation: Optional[InstanceAssociationOutputLocation] + LastExecutionDate: Optional[DateTime] + LastSuccessfulExecutionDate: Optional[DateTime] + AssociationName: Optional[AssociationName] + MaxErrors: Optional[MaxErrors] + MaxConcurrency: Optional[MaxConcurrency] + ComplianceSeverity: Optional[AssociationComplianceSeverity] + SyncCompliance: Optional[AssociationSyncCompliance] + ApplyOnlyAtCronInterval: Optional[ApplyOnlyAtCronInterval] + CalendarNames: Optional[CalendarNameOrARNList] + TargetLocations: Optional[TargetLocations] + ScheduleOffset: Optional[ScheduleOffset] + Duration: Optional[Duration] + TargetMaps: Optional[TargetMaps] + AlarmConfiguration: Optional[AlarmConfiguration] + TriggeredAlarms: Optional[AlarmStateInformationList] + + +AssociationDescriptionList = List[AssociationDescription] + + +class AssociationExecution(TypedDict, total=False): + AssociationId: Optional[AssociationId] + AssociationVersion: Optional[AssociationVersion] + ExecutionId: Optional[AssociationExecutionId] + Status: Optional[StatusName] + DetailedStatus: Optional[StatusName] + CreatedTime: Optional[DateTime] + LastExecutionDate: Optional[DateTime] + ResourceCountByStatus: Optional[ResourceCountByStatus] + AlarmConfiguration: Optional[AlarmConfiguration] + TriggeredAlarms: Optional[AlarmStateInformationList] + + +class AssociationExecutionFilter(TypedDict, total=False): + Key: AssociationExecutionFilterKey + Value: AssociationExecutionFilterValue + Type: AssociationFilterOperatorType + + +AssociationExecutionFilterList = List[AssociationExecutionFilter] + + +class OutputSource(TypedDict, total=False): + OutputSourceId: Optional[OutputSourceId] + OutputSourceType: Optional[OutputSourceType] + + +class AssociationExecutionTarget(TypedDict, total=False): + AssociationId: Optional[AssociationId] + AssociationVersion: Optional[AssociationVersion] + ExecutionId: Optional[AssociationExecutionId] + ResourceId: Optional[AssociationResourceId] + ResourceType: Optional[AssociationResourceType] + Status: Optional[StatusName] + DetailedStatus: Optional[StatusName] + LastExecutionDate: Optional[DateTime] + OutputSource: Optional[OutputSource] + + +class AssociationExecutionTargetsFilter(TypedDict, total=False): + Key: AssociationExecutionTargetsFilterKey + Value: AssociationExecutionTargetsFilterValue + + +AssociationExecutionTargetsFilterList = List[AssociationExecutionTargetsFilter] +AssociationExecutionTargetsList = List[AssociationExecutionTarget] +AssociationExecutionsList = List[AssociationExecution] + + +class AssociationFilter(TypedDict, total=False): + key: AssociationFilterKey + value: AssociationFilterValue + + +AssociationFilterList = List[AssociationFilter] +AssociationIdList = List[AssociationId] +AssociationList = List[Association] + + +class AssociationVersionInfo(TypedDict, total=False): + AssociationId: Optional[AssociationId] + AssociationVersion: Optional[AssociationVersion] + CreatedDate: Optional[DateTime] + Name: Optional[DocumentARN] + DocumentVersion: Optional[DocumentVersion] + Parameters: Optional[Parameters] + Targets: Optional[Targets] + ScheduleExpression: Optional[ScheduleExpression] + OutputLocation: Optional[InstanceAssociationOutputLocation] + AssociationName: Optional[AssociationName] + MaxErrors: Optional[MaxErrors] + MaxConcurrency: Optional[MaxConcurrency] + ComplianceSeverity: Optional[AssociationComplianceSeverity] + SyncCompliance: Optional[AssociationSyncCompliance] + ApplyOnlyAtCronInterval: Optional[ApplyOnlyAtCronInterval] + CalendarNames: Optional[CalendarNameOrARNList] + TargetLocations: Optional[TargetLocations] + ScheduleOffset: Optional[ScheduleOffset] + Duration: Optional[Duration] + TargetMaps: Optional[TargetMaps] + + +AssociationVersionList = List[AssociationVersionInfo] +ContentLength = int + + +class AttachmentContent(TypedDict, total=False): + Name: Optional[AttachmentName] + Size: Optional[ContentLength] + Hash: Optional[AttachmentHash] + HashType: Optional[AttachmentHashType] + Url: Optional[AttachmentUrl] + + +AttachmentContentList = List[AttachmentContent] + + +class AttachmentInformation(TypedDict, total=False): + Name: Optional[AttachmentName] + + +AttachmentInformationList = List[AttachmentInformation] +AttachmentsSourceValues = List[AttachmentsSourceValue] + + +class AttachmentsSource(TypedDict, total=False): + Key: Optional[AttachmentsSourceKey] + Values: Optional[AttachmentsSourceValues] + Name: Optional[AttachmentIdentifier] + + +AttachmentsSourceList = List[AttachmentsSource] +AutomationParameterValueList = List[AutomationParameterValue] +AutomationParameterMap = Dict[AutomationParameterKey, AutomationParameterValueList] + + +class Runbook(TypedDict, total=False): + DocumentName: DocumentARN + DocumentVersion: Optional[DocumentVersion] + Parameters: Optional[AutomationParameterMap] + TargetParameterName: Optional[AutomationParameterKey] + Targets: Optional[Targets] + TargetMaps: Optional[TargetMaps] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + TargetLocations: Optional[TargetLocations] + + +Runbooks = List[Runbook] + + +class ProgressCounters(TypedDict, total=False): + TotalSteps: Optional[Integer] + SuccessSteps: Optional[Integer] + FailedSteps: Optional[Integer] + CancelledSteps: Optional[Integer] + TimedOutSteps: Optional[Integer] + + +TargetParameterList = List[ParameterValue] + + +class ResolvedTargets(TypedDict, total=False): + ParameterValues: Optional[TargetParameterList] + Truncated: Optional[Boolean] + + +class ParentStepDetails(TypedDict, total=False): + StepExecutionId: Optional[String] + StepName: Optional[String] + Action: Optional[AutomationActionName] + Iteration: Optional[Integer] + IteratorValue: Optional[String] + + +ValidNextStepList = List[ValidNextStep] + + +class FailureDetails(TypedDict, total=False): + FailureStage: Optional[String] + FailureType: Optional[String] + Details: Optional[AutomationParameterMap] + + +NormalStringMap = Dict[String, String] +Long = int + + +class StepExecution(TypedDict, total=False): + StepName: Optional[String] + Action: Optional[AutomationActionName] + TimeoutSeconds: Optional[Long] + OnFailure: Optional[String] + MaxAttempts: Optional[Integer] + ExecutionStartTime: Optional[DateTime] + ExecutionEndTime: Optional[DateTime] + StepStatus: Optional[AutomationExecutionStatus] + ResponseCode: Optional[String] + Inputs: Optional[NormalStringMap] + Outputs: Optional[AutomationParameterMap] + Response: Optional[String] + FailureMessage: Optional[String] + FailureDetails: Optional[FailureDetails] + StepExecutionId: Optional[String] + OverriddenParameters: Optional[AutomationParameterMap] + IsEnd: Optional[Boolean] + NextStep: Optional[String] + IsCritical: Optional[Boolean] + ValidNextSteps: Optional[ValidNextStepList] + Targets: Optional[Targets] + TargetLocation: Optional[TargetLocation] + TriggeredAlarms: Optional[AlarmStateInformationList] + ParentStepDetails: Optional[ParentStepDetails] + + +StepExecutionList = List[StepExecution] + + +class AutomationExecution(TypedDict, total=False): + AutomationExecutionId: Optional[AutomationExecutionId] + DocumentName: Optional[DocumentName] + DocumentVersion: Optional[DocumentVersion] + ExecutionStartTime: Optional[DateTime] + ExecutionEndTime: Optional[DateTime] + AutomationExecutionStatus: Optional[AutomationExecutionStatus] + StepExecutions: Optional[StepExecutionList] + StepExecutionsTruncated: Optional[Boolean] + Parameters: Optional[AutomationParameterMap] + Outputs: Optional[AutomationParameterMap] + FailureMessage: Optional[String] + Mode: Optional[ExecutionMode] + ParentAutomationExecutionId: Optional[AutomationExecutionId] + ExecutedBy: Optional[String] + CurrentStepName: Optional[String] + CurrentAction: Optional[String] + TargetParameterName: Optional[AutomationParameterKey] + Targets: Optional[Targets] + TargetMaps: Optional[TargetMaps] + ResolvedTargets: Optional[ResolvedTargets] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + Target: Optional[String] + TargetLocations: Optional[TargetLocations] + ProgressCounters: Optional[ProgressCounters] + AlarmConfiguration: Optional[AlarmConfiguration] + TriggeredAlarms: Optional[AlarmStateInformationList] + TargetLocationsURL: Optional[TargetLocationsURL] + AutomationSubtype: Optional[AutomationSubtype] + ScheduledTime: Optional[DateTime] + Runbooks: Optional[Runbooks] + OpsItemId: Optional[String] + AssociationId: Optional[String] + ChangeRequestName: Optional[ChangeRequestName] + Variables: Optional[AutomationParameterMap] + + +AutomationExecutionFilterValueList = List[AutomationExecutionFilterValue] + + +class AutomationExecutionFilter(TypedDict, total=False): + Key: AutomationExecutionFilterKey + Values: AutomationExecutionFilterValueList + + +AutomationExecutionFilterList = List[AutomationExecutionFilter] + + +class AutomationExecutionInputs(TypedDict, total=False): + Parameters: Optional[AutomationParameterMap] + TargetParameterName: Optional[AutomationParameterKey] + Targets: Optional[Targets] + TargetMaps: Optional[TargetMaps] + TargetLocations: Optional[TargetLocations] + TargetLocationsURL: Optional[TargetLocationsURL] + + +class AutomationExecutionMetadata(TypedDict, total=False): + AutomationExecutionId: Optional[AutomationExecutionId] + DocumentName: Optional[DocumentName] + DocumentVersion: Optional[DocumentVersion] + AutomationExecutionStatus: Optional[AutomationExecutionStatus] + ExecutionStartTime: Optional[DateTime] + ExecutionEndTime: Optional[DateTime] + ExecutedBy: Optional[String] + LogFile: Optional[String] + Outputs: Optional[AutomationParameterMap] + Mode: Optional[ExecutionMode] + ParentAutomationExecutionId: Optional[AutomationExecutionId] + CurrentStepName: Optional[String] + CurrentAction: Optional[String] + FailureMessage: Optional[String] + TargetParameterName: Optional[AutomationParameterKey] + Targets: Optional[Targets] + TargetMaps: Optional[TargetMaps] + ResolvedTargets: Optional[ResolvedTargets] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + Target: Optional[String] + AutomationType: Optional[AutomationType] + AlarmConfiguration: Optional[AlarmConfiguration] + TriggeredAlarms: Optional[AlarmStateInformationList] + TargetLocationsURL: Optional[TargetLocationsURL] + AutomationSubtype: Optional[AutomationSubtype] + ScheduledTime: Optional[DateTime] + Runbooks: Optional[Runbooks] + OpsItemId: Optional[String] + AssociationId: Optional[String] + ChangeRequestName: Optional[ChangeRequestName] + + +AutomationExecutionMetadataList = List[AutomationExecutionMetadata] + + +class TargetPreview(TypedDict, total=False): + Count: Optional[Integer] + TargetType: Optional[String] + + +TargetPreviewList = List[TargetPreview] +RegionList = List[Region] +StepPreviewMap = Dict[ImpactType, Integer] + + +class AutomationExecutionPreview(TypedDict, total=False): + StepPreviews: Optional[StepPreviewMap] + Regions: Optional[RegionList] + TargetPreviews: Optional[TargetPreviewList] + TotalAccounts: Optional[Integer] + + +PatchSourceProductList = List[PatchSourceProduct] + + +class PatchSource(TypedDict, total=False): + Name: PatchSourceName + Products: PatchSourceProductList + Configuration: PatchSourceConfiguration + + +PatchSourceList = List[PatchSource] +PatchIdList = List[PatchId] +PatchFilterValueList = List[PatchFilterValue] + + +class PatchFilter(TypedDict, total=False): + Key: PatchFilterKey + Values: PatchFilterValueList + + +PatchFilterList = List[PatchFilter] + + +class PatchFilterGroup(TypedDict, total=False): + PatchFilters: PatchFilterList + + +class PatchRule(TypedDict, total=False): + PatchFilterGroup: PatchFilterGroup + ComplianceLevel: Optional[PatchComplianceLevel] + ApproveAfterDays: Optional[ApproveAfterDays] + ApproveUntilDate: Optional[PatchStringDateTime] + EnableNonSecurity: Optional[Boolean] + + +PatchRuleList = List[PatchRule] + + +class PatchRuleGroup(TypedDict, total=False): + PatchRules: PatchRuleList + + +class BaselineOverride(TypedDict, total=False): + OperatingSystem: Optional[OperatingSystem] + GlobalFilters: Optional[PatchFilterGroup] + ApprovalRules: Optional[PatchRuleGroup] + ApprovedPatches: Optional[PatchIdList] + ApprovedPatchesComplianceLevel: Optional[PatchComplianceLevel] + RejectedPatches: Optional[PatchIdList] + RejectedPatchesAction: Optional[PatchAction] + ApprovedPatchesEnableNonSecurity: Optional[Boolean] + Sources: Optional[PatchSourceList] + AvailableSecurityUpdatesComplianceStatus: Optional[PatchComplianceStatus] + + +InstanceIdList = List[InstanceId] + + +class CancelCommandRequest(ServiceRequest): + CommandId: CommandId + InstanceIds: Optional[InstanceIdList] + + +class CancelCommandResult(TypedDict, total=False): + pass + + +class CancelMaintenanceWindowExecutionRequest(ServiceRequest): + WindowExecutionId: MaintenanceWindowExecutionId + + +class CancelMaintenanceWindowExecutionResult(TypedDict, total=False): + WindowExecutionId: Optional[MaintenanceWindowExecutionId] + + +CategoryEnumList = List[Category] +CategoryList = List[Category] + + +class CloudWatchOutputConfig(TypedDict, total=False): + CloudWatchLogGroupName: Optional[CloudWatchLogGroupName] + CloudWatchOutputEnabled: Optional[CloudWatchOutputEnabled] + + +NotificationEventList = List[NotificationEvent] + + +class NotificationConfig(TypedDict, total=False): + NotificationArn: Optional[NotificationArn] + NotificationEvents: Optional[NotificationEventList] + NotificationType: Optional[NotificationType] + + +class Command(TypedDict, total=False): + CommandId: Optional[CommandId] + DocumentName: Optional[DocumentName] + DocumentVersion: Optional[DocumentVersion] + Comment: Optional[Comment] + ExpiresAfter: Optional[DateTime] + Parameters: Optional[Parameters] + InstanceIds: Optional[InstanceIdList] + Targets: Optional[Targets] + RequestedDateTime: Optional[DateTime] + Status: Optional[CommandStatus] + StatusDetails: Optional[StatusDetails] + OutputS3Region: Optional[S3Region] + OutputS3BucketName: Optional[S3BucketName] + OutputS3KeyPrefix: Optional[S3KeyPrefix] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + TargetCount: Optional[TargetCount] + CompletedCount: Optional[CompletedCount] + ErrorCount: Optional[ErrorCount] + DeliveryTimedOutCount: Optional[DeliveryTimedOutCount] + ServiceRole: Optional[ServiceRole] + NotificationConfig: Optional[NotificationConfig] + CloudWatchOutputConfig: Optional[CloudWatchOutputConfig] + TimeoutSeconds: Optional[TimeoutSeconds] + AlarmConfiguration: Optional[AlarmConfiguration] + TriggeredAlarms: Optional[AlarmStateInformationList] + + +class CommandFilter(TypedDict, total=False): + key: CommandFilterKey + value: CommandFilterValue + + +CommandFilterList = List[CommandFilter] + + +class CommandPlugin(TypedDict, total=False): + Name: Optional[CommandPluginName] + Status: Optional[CommandPluginStatus] + StatusDetails: Optional[StatusDetails] + ResponseCode: Optional[ResponseCode] + ResponseStartDateTime: Optional[DateTime] + ResponseFinishDateTime: Optional[DateTime] + Output: Optional[CommandPluginOutput] + StandardOutputUrl: Optional[Url] + StandardErrorUrl: Optional[Url] + OutputS3Region: Optional[S3Region] + OutputS3BucketName: Optional[S3BucketName] + OutputS3KeyPrefix: Optional[S3KeyPrefix] + + +CommandPluginList = List[CommandPlugin] + + +class CommandInvocation(TypedDict, total=False): + CommandId: Optional[CommandId] + InstanceId: Optional[InstanceId] + InstanceName: Optional[InstanceTagName] + Comment: Optional[Comment] + DocumentName: Optional[DocumentName] + DocumentVersion: Optional[DocumentVersion] + RequestedDateTime: Optional[DateTime] + Status: Optional[CommandInvocationStatus] + StatusDetails: Optional[StatusDetails] + TraceOutput: Optional[InvocationTraceOutput] + StandardOutputUrl: Optional[Url] + StandardErrorUrl: Optional[Url] + CommandPlugins: Optional[CommandPluginList] + ServiceRole: Optional[ServiceRole] + NotificationConfig: Optional[NotificationConfig] + CloudWatchOutputConfig: Optional[CloudWatchOutputConfig] + + +CommandInvocationList = List[CommandInvocation] +CommandList = List[Command] + + +class ComplianceExecutionSummary(TypedDict, total=False): + ExecutionTime: DateTime + ExecutionId: Optional[ComplianceExecutionId] + ExecutionType: Optional[ComplianceExecutionType] + + +ComplianceItemDetails = Dict[AttributeName, AttributeValue] + + +class ComplianceItem(TypedDict, total=False): + ComplianceType: Optional[ComplianceTypeName] + ResourceType: Optional[ComplianceResourceType] + ResourceId: Optional[ComplianceResourceId] + Id: Optional[ComplianceItemId] + Title: Optional[ComplianceItemTitle] + Status: Optional[ComplianceStatus] + Severity: Optional[ComplianceSeverity] + ExecutionSummary: Optional[ComplianceExecutionSummary] + Details: Optional[ComplianceItemDetails] + + +class ComplianceItemEntry(TypedDict, total=False): + Id: Optional[ComplianceItemId] + Title: Optional[ComplianceItemTitle] + Severity: ComplianceSeverity + Status: ComplianceStatus + Details: Optional[ComplianceItemDetails] + + +ComplianceItemEntryList = List[ComplianceItemEntry] +ComplianceItemList = List[ComplianceItem] +ComplianceResourceIdList = List[ComplianceResourceId] +ComplianceResourceTypeList = List[ComplianceResourceType] +ComplianceStringFilterValueList = List[ComplianceFilterValue] + + +class ComplianceStringFilter(TypedDict, total=False): + Key: Optional[ComplianceStringFilterKey] + Values: Optional[ComplianceStringFilterValueList] + Type: Optional[ComplianceQueryOperatorType] + + +ComplianceStringFilterList = List[ComplianceStringFilter] + + +class SeveritySummary(TypedDict, total=False): + CriticalCount: Optional[ComplianceSummaryCount] + HighCount: Optional[ComplianceSummaryCount] + MediumCount: Optional[ComplianceSummaryCount] + LowCount: Optional[ComplianceSummaryCount] + InformationalCount: Optional[ComplianceSummaryCount] + UnspecifiedCount: Optional[ComplianceSummaryCount] + + +class NonCompliantSummary(TypedDict, total=False): + NonCompliantCount: Optional[ComplianceSummaryCount] + SeveritySummary: Optional[SeveritySummary] + + +class CompliantSummary(TypedDict, total=False): + CompliantCount: Optional[ComplianceSummaryCount] + SeveritySummary: Optional[SeveritySummary] + + +class ComplianceSummaryItem(TypedDict, total=False): + ComplianceType: Optional[ComplianceTypeName] + CompliantSummary: Optional[CompliantSummary] + NonCompliantSummary: Optional[NonCompliantSummary] + + +ComplianceSummaryItemList = List[ComplianceSummaryItem] + + +class RegistrationMetadataItem(TypedDict, total=False): + Key: RegistrationMetadataKey + Value: RegistrationMetadataValue + + +RegistrationMetadataList = List[RegistrationMetadataItem] + + +class CreateActivationRequest(ServiceRequest): + Description: Optional[ActivationDescription] + DefaultInstanceName: Optional[DefaultInstanceName] + IamRole: IamRole + RegistrationLimit: Optional[RegistrationLimit] + ExpirationDate: Optional[ExpirationDate] + Tags: Optional[TagList] + RegistrationMetadata: Optional[RegistrationMetadataList] + + +class CreateActivationResult(TypedDict, total=False): + ActivationId: Optional[ActivationId] + ActivationCode: Optional[ActivationCode] + + +class CreateAssociationBatchRequestEntry(TypedDict, total=False): + Name: DocumentARN + InstanceId: Optional[InstanceId] + Parameters: Optional[Parameters] + AutomationTargetParameterName: Optional[AutomationTargetParameterName] + DocumentVersion: Optional[DocumentVersion] + Targets: Optional[Targets] + ScheduleExpression: Optional[ScheduleExpression] + OutputLocation: Optional[InstanceAssociationOutputLocation] + AssociationName: Optional[AssociationName] + MaxErrors: Optional[MaxErrors] + MaxConcurrency: Optional[MaxConcurrency] + ComplianceSeverity: Optional[AssociationComplianceSeverity] + SyncCompliance: Optional[AssociationSyncCompliance] + ApplyOnlyAtCronInterval: Optional[ApplyOnlyAtCronInterval] + CalendarNames: Optional[CalendarNameOrARNList] + TargetLocations: Optional[TargetLocations] + ScheduleOffset: Optional[ScheduleOffset] + Duration: Optional[Duration] + TargetMaps: Optional[TargetMaps] + AlarmConfiguration: Optional[AlarmConfiguration] + + +CreateAssociationBatchRequestEntries = List[CreateAssociationBatchRequestEntry] + + +class CreateAssociationBatchRequest(ServiceRequest): + Entries: CreateAssociationBatchRequestEntries + + +class FailedCreateAssociation(TypedDict, total=False): + Entry: Optional[CreateAssociationBatchRequestEntry] + Message: Optional[BatchErrorMessage] + Fault: Optional[Fault] + + +FailedCreateAssociationList = List[FailedCreateAssociation] + + +class CreateAssociationBatchResult(TypedDict, total=False): + Successful: Optional[AssociationDescriptionList] + Failed: Optional[FailedCreateAssociationList] + + +class CreateAssociationRequest(ServiceRequest): + Name: DocumentARN + DocumentVersion: Optional[DocumentVersion] + InstanceId: Optional[InstanceId] + Parameters: Optional[Parameters] + Targets: Optional[Targets] + ScheduleExpression: Optional[ScheduleExpression] + OutputLocation: Optional[InstanceAssociationOutputLocation] + AssociationName: Optional[AssociationName] + AutomationTargetParameterName: Optional[AutomationTargetParameterName] + MaxErrors: Optional[MaxErrors] + MaxConcurrency: Optional[MaxConcurrency] + ComplianceSeverity: Optional[AssociationComplianceSeverity] + SyncCompliance: Optional[AssociationSyncCompliance] + ApplyOnlyAtCronInterval: Optional[ApplyOnlyAtCronInterval] + CalendarNames: Optional[CalendarNameOrARNList] + TargetLocations: Optional[TargetLocations] + ScheduleOffset: Optional[ScheduleOffset] + Duration: Optional[Duration] + TargetMaps: Optional[TargetMaps] + Tags: Optional[TagList] + AlarmConfiguration: Optional[AlarmConfiguration] + + +class CreateAssociationResult(TypedDict, total=False): + AssociationDescription: Optional[AssociationDescription] + + +class DocumentRequires(TypedDict, total=False): + Name: DocumentARN + Version: Optional[DocumentVersion] + RequireType: Optional[RequireType] + VersionName: Optional[DocumentVersionName] + + +DocumentRequiresList = List[DocumentRequires] + + +class CreateDocumentRequest(ServiceRequest): + Content: DocumentContent + Requires: Optional[DocumentRequiresList] + Attachments: Optional[AttachmentsSourceList] + Name: DocumentName + DisplayName: Optional[DocumentDisplayName] + VersionName: Optional[DocumentVersionName] + DocumentType: Optional[DocumentType] + DocumentFormat: Optional[DocumentFormat] + TargetType: Optional[TargetType] + Tags: Optional[TagList] + + +class ReviewInformation(TypedDict, total=False): + ReviewedTime: Optional[DateTime] + Status: Optional[ReviewStatus] + Reviewer: Optional[Reviewer] + + +ReviewInformationList = List[ReviewInformation] +PlatformTypeList = List[PlatformType] + + +class DocumentParameter(TypedDict, total=False): + Name: Optional[DocumentParameterName] + Type: Optional[DocumentParameterType] + Description: Optional[DocumentParameterDescrption] + DefaultValue: Optional[DocumentParameterDefaultValue] + + +DocumentParameterList = List[DocumentParameter] + + +class DocumentDescription(TypedDict, total=False): + Sha1: Optional[DocumentSha1] + Hash: Optional[DocumentHash] + HashType: Optional[DocumentHashType] + Name: Optional[DocumentARN] + DisplayName: Optional[DocumentDisplayName] + VersionName: Optional[DocumentVersionName] + Owner: Optional[DocumentOwner] + CreatedDate: Optional[DateTime] + Status: Optional[DocumentStatus] + StatusInformation: Optional[DocumentStatusInformation] + DocumentVersion: Optional[DocumentVersion] + Description: Optional[DescriptionInDocument] + Parameters: Optional[DocumentParameterList] + PlatformTypes: Optional[PlatformTypeList] + DocumentType: Optional[DocumentType] + SchemaVersion: Optional[DocumentSchemaVersion] + LatestVersion: Optional[DocumentVersion] + DefaultVersion: Optional[DocumentVersion] + DocumentFormat: Optional[DocumentFormat] + TargetType: Optional[TargetType] + Tags: Optional[TagList] + AttachmentsInformation: Optional[AttachmentInformationList] + Requires: Optional[DocumentRequiresList] + Author: Optional[DocumentAuthor] + ReviewInformation: Optional[ReviewInformationList] + ApprovedVersion: Optional[DocumentVersion] + PendingReviewVersion: Optional[DocumentVersion] + ReviewStatus: Optional[ReviewStatus] + Category: Optional[CategoryList] + CategoryEnum: Optional[CategoryEnumList] + + +class CreateDocumentResult(TypedDict, total=False): + DocumentDescription: Optional[DocumentDescription] + + +class CreateMaintenanceWindowRequest(ServiceRequest): + Name: MaintenanceWindowName + Description: Optional[MaintenanceWindowDescription] + StartDate: Optional[MaintenanceWindowStringDateTime] + EndDate: Optional[MaintenanceWindowStringDateTime] + Schedule: MaintenanceWindowSchedule + ScheduleTimezone: Optional[MaintenanceWindowTimezone] + ScheduleOffset: Optional[MaintenanceWindowOffset] + Duration: MaintenanceWindowDurationHours + Cutoff: MaintenanceWindowCutoff + AllowUnassociatedTargets: MaintenanceWindowAllowUnassociatedTargets + ClientToken: Optional[ClientToken] + Tags: Optional[TagList] + + +class CreateMaintenanceWindowResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + + +class RelatedOpsItem(TypedDict, total=False): + OpsItemId: String + + +RelatedOpsItems = List[RelatedOpsItem] + + +class OpsItemNotification(TypedDict, total=False): + Arn: Optional[String] + + +OpsItemNotifications = List[OpsItemNotification] + + +class OpsItemDataValue(TypedDict, total=False): + Value: Optional[OpsItemDataValueString] + Type: Optional[OpsItemDataType] + + +OpsItemOperationalData = Dict[OpsItemDataKey, OpsItemDataValue] + + +class CreateOpsItemRequest(ServiceRequest): + Description: OpsItemDescription + OpsItemType: Optional[OpsItemType] + OperationalData: Optional[OpsItemOperationalData] + Notifications: Optional[OpsItemNotifications] + Priority: Optional[OpsItemPriority] + RelatedOpsItems: Optional[RelatedOpsItems] + Source: OpsItemSource + Title: OpsItemTitle + Tags: Optional[TagList] + Category: Optional[OpsItemCategory] + Severity: Optional[OpsItemSeverity] + ActualStartTime: Optional[DateTime] + ActualEndTime: Optional[DateTime] + PlannedStartTime: Optional[DateTime] + PlannedEndTime: Optional[DateTime] + AccountId: Optional[OpsItemAccountId] + + +class CreateOpsItemResponse(TypedDict, total=False): + OpsItemId: Optional[String] + OpsItemArn: Optional[OpsItemArn] + + +class MetadataValue(TypedDict, total=False): + Value: Optional[MetadataValueString] + + +MetadataMap = Dict[MetadataKey, MetadataValue] + + +class CreateOpsMetadataRequest(ServiceRequest): + ResourceId: OpsMetadataResourceId + Metadata: Optional[MetadataMap] + Tags: Optional[TagList] + + +class CreateOpsMetadataResult(TypedDict, total=False): + OpsMetadataArn: Optional[OpsMetadataArn] + + +class CreatePatchBaselineRequest(ServiceRequest): + OperatingSystem: Optional[OperatingSystem] + Name: BaselineName + GlobalFilters: Optional[PatchFilterGroup] + ApprovalRules: Optional[PatchRuleGroup] + ApprovedPatches: Optional[PatchIdList] + ApprovedPatchesComplianceLevel: Optional[PatchComplianceLevel] + ApprovedPatchesEnableNonSecurity: Optional[Boolean] + RejectedPatches: Optional[PatchIdList] + RejectedPatchesAction: Optional[PatchAction] + Description: Optional[BaselineDescription] + Sources: Optional[PatchSourceList] + AvailableSecurityUpdatesComplianceStatus: Optional[PatchComplianceStatus] + ClientToken: Optional[ClientToken] + Tags: Optional[TagList] + + +class CreatePatchBaselineResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + + +ResourceDataSyncSourceRegionList = List[ResourceDataSyncSourceRegion] + + +class ResourceDataSyncOrganizationalUnit(TypedDict, total=False): + OrganizationalUnitId: Optional[ResourceDataSyncOrganizationalUnitId] + + +ResourceDataSyncOrganizationalUnitList = List[ResourceDataSyncOrganizationalUnit] + + +class ResourceDataSyncAwsOrganizationsSource(TypedDict, total=False): + OrganizationSourceType: ResourceDataSyncOrganizationSourceType + OrganizationalUnits: Optional[ResourceDataSyncOrganizationalUnitList] + + +class ResourceDataSyncSource(TypedDict, total=False): + SourceType: ResourceDataSyncSourceType + AwsOrganizationsSource: Optional[ResourceDataSyncAwsOrganizationsSource] + SourceRegions: ResourceDataSyncSourceRegionList + IncludeFutureRegions: Optional[ResourceDataSyncIncludeFutureRegions] + EnableAllOpsDataSources: Optional[ResourceDataSyncEnableAllOpsDataSources] + + +class ResourceDataSyncDestinationDataSharing(TypedDict, total=False): + DestinationDataSharingType: Optional[ResourceDataSyncDestinationDataSharingType] + + +class ResourceDataSyncS3Destination(TypedDict, total=False): + BucketName: ResourceDataSyncS3BucketName + Prefix: Optional[ResourceDataSyncS3Prefix] + SyncFormat: ResourceDataSyncS3Format + Region: ResourceDataSyncS3Region + AWSKMSKeyARN: Optional[ResourceDataSyncAWSKMSKeyARN] + DestinationDataSharing: Optional[ResourceDataSyncDestinationDataSharing] + + +class CreateResourceDataSyncRequest(ServiceRequest): + SyncName: ResourceDataSyncName + S3Destination: Optional[ResourceDataSyncS3Destination] + SyncType: Optional[ResourceDataSyncType] + SyncSource: Optional[ResourceDataSyncSource] + + +class CreateResourceDataSyncResult(TypedDict, total=False): + pass + + +class Credentials(TypedDict, total=False): + AccessKeyId: AccessKeyIdType + SecretAccessKey: AccessKeySecretType + SessionToken: SessionTokenType + ExpirationTime: DateTime + + +class DeleteActivationRequest(ServiceRequest): + ActivationId: ActivationId + + +class DeleteActivationResult(TypedDict, total=False): + pass + + +class DeleteAssociationRequest(ServiceRequest): + Name: Optional[DocumentARN] + InstanceId: Optional[InstanceId] + AssociationId: Optional[AssociationId] + + +class DeleteAssociationResult(TypedDict, total=False): + pass + + +class DeleteDocumentRequest(ServiceRequest): + Name: DocumentName + DocumentVersion: Optional[DocumentVersion] + VersionName: Optional[DocumentVersionName] + Force: Optional[Boolean] + + +class DeleteDocumentResult(TypedDict, total=False): + pass + + +class DeleteInventoryRequest(ServiceRequest): + TypeName: InventoryItemTypeName + SchemaDeleteOption: Optional[InventorySchemaDeleteOption] + DryRun: Optional[DryRun] + ClientToken: Optional[UUID] + + +class InventoryDeletionSummaryItem(TypedDict, total=False): + Version: Optional[InventoryItemSchemaVersion] + Count: Optional[ResourceCount] + RemainingCount: Optional[RemainingCount] + + +InventoryDeletionSummaryItems = List[InventoryDeletionSummaryItem] + + +class InventoryDeletionSummary(TypedDict, total=False): + TotalCount: Optional[TotalCount] + RemainingCount: Optional[RemainingCount] + SummaryItems: Optional[InventoryDeletionSummaryItems] + + +class DeleteInventoryResult(TypedDict, total=False): + DeletionId: Optional[UUID] + TypeName: Optional[InventoryItemTypeName] + DeletionSummary: Optional[InventoryDeletionSummary] + + +class DeleteMaintenanceWindowRequest(ServiceRequest): + WindowId: MaintenanceWindowId + + +class DeleteMaintenanceWindowResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + + +class DeleteOpsItemRequest(ServiceRequest): + OpsItemId: OpsItemId + + +class DeleteOpsItemResponse(TypedDict, total=False): + pass + + +class DeleteOpsMetadataRequest(ServiceRequest): + OpsMetadataArn: OpsMetadataArn + + +class DeleteOpsMetadataResult(TypedDict, total=False): + pass + + +class DeleteParameterRequest(ServiceRequest): + Name: PSParameterName + + +class DeleteParameterResult(TypedDict, total=False): + pass + + +ParameterNameList = List[PSParameterName] + + +class DeleteParametersRequest(ServiceRequest): + Names: ParameterNameList + + +class DeleteParametersResult(TypedDict, total=False): + DeletedParameters: Optional[ParameterNameList] + InvalidParameters: Optional[ParameterNameList] + + +class DeletePatchBaselineRequest(ServiceRequest): + BaselineId: BaselineId + + +class DeletePatchBaselineResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + + +class DeleteResourceDataSyncRequest(ServiceRequest): + SyncName: ResourceDataSyncName + SyncType: Optional[ResourceDataSyncType] + + +class DeleteResourceDataSyncResult(TypedDict, total=False): + pass + + +class DeleteResourcePolicyRequest(ServiceRequest): + ResourceArn: ResourceArnString + PolicyId: PolicyId + PolicyHash: PolicyHash + + +class DeleteResourcePolicyResponse(TypedDict, total=False): + pass + + +class DeregisterManagedInstanceRequest(ServiceRequest): + InstanceId: ManagedInstanceId + + +class DeregisterManagedInstanceResult(TypedDict, total=False): + pass + + +class DeregisterPatchBaselineForPatchGroupRequest(ServiceRequest): + BaselineId: BaselineId + PatchGroup: PatchGroup + + +class DeregisterPatchBaselineForPatchGroupResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + PatchGroup: Optional[PatchGroup] + + +class DeregisterTargetFromMaintenanceWindowRequest(ServiceRequest): + WindowId: MaintenanceWindowId + WindowTargetId: MaintenanceWindowTargetId + Safe: Optional[Boolean] + + +class DeregisterTargetFromMaintenanceWindowResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowTargetId: Optional[MaintenanceWindowTargetId] + + +class DeregisterTaskFromMaintenanceWindowRequest(ServiceRequest): + WindowId: MaintenanceWindowId + WindowTaskId: MaintenanceWindowTaskId + + +class DeregisterTaskFromMaintenanceWindowResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowTaskId: Optional[MaintenanceWindowTaskId] + + +StringList = List[String] + + +class DescribeActivationsFilter(TypedDict, total=False): + FilterKey: Optional[DescribeActivationsFilterKeys] + FilterValues: Optional[StringList] + + +DescribeActivationsFilterList = List[DescribeActivationsFilter] + + +class DescribeActivationsRequest(ServiceRequest): + Filters: Optional[DescribeActivationsFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class DescribeActivationsResult(TypedDict, total=False): + ActivationList: Optional[ActivationList] + NextToken: Optional[NextToken] + + +class DescribeAssociationExecutionTargetsRequest(ServiceRequest): + AssociationId: AssociationId + ExecutionId: AssociationExecutionId + Filters: Optional[AssociationExecutionTargetsFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class DescribeAssociationExecutionTargetsResult(TypedDict, total=False): + AssociationExecutionTargets: Optional[AssociationExecutionTargetsList] + NextToken: Optional[NextToken] + + +class DescribeAssociationExecutionsRequest(ServiceRequest): + AssociationId: AssociationId + Filters: Optional[AssociationExecutionFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class DescribeAssociationExecutionsResult(TypedDict, total=False): + AssociationExecutions: Optional[AssociationExecutionsList] + NextToken: Optional[NextToken] + + +class DescribeAssociationRequest(ServiceRequest): + Name: Optional[DocumentARN] + InstanceId: Optional[InstanceId] + AssociationId: Optional[AssociationId] + AssociationVersion: Optional[AssociationVersion] + + +class DescribeAssociationResult(TypedDict, total=False): + AssociationDescription: Optional[AssociationDescription] + + +class DescribeAutomationExecutionsRequest(ServiceRequest): + Filters: Optional[AutomationExecutionFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class DescribeAutomationExecutionsResult(TypedDict, total=False): + AutomationExecutionMetadataList: Optional[AutomationExecutionMetadataList] + NextToken: Optional[NextToken] + + +StepExecutionFilterValueList = List[StepExecutionFilterValue] + + +class StepExecutionFilter(TypedDict, total=False): + Key: StepExecutionFilterKey + Values: StepExecutionFilterValueList + + +StepExecutionFilterList = List[StepExecutionFilter] + + +class DescribeAutomationStepExecutionsRequest(ServiceRequest): + AutomationExecutionId: AutomationExecutionId + Filters: Optional[StepExecutionFilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + ReverseOrder: Optional[Boolean] + + +class DescribeAutomationStepExecutionsResult(TypedDict, total=False): + StepExecutions: Optional[StepExecutionList] + NextToken: Optional[NextToken] + + +PatchOrchestratorFilterValues = List[PatchOrchestratorFilterValue] + + +class PatchOrchestratorFilter(TypedDict, total=False): + Key: Optional[PatchOrchestratorFilterKey] + Values: Optional[PatchOrchestratorFilterValues] + + +PatchOrchestratorFilterList = List[PatchOrchestratorFilter] + + +class DescribeAvailablePatchesRequest(ServiceRequest): + Filters: Optional[PatchOrchestratorFilterList] + MaxResults: Optional[PatchBaselineMaxResults] + NextToken: Optional[NextToken] + + +PatchCVEIdList = List[PatchCVEId] +PatchBugzillaIdList = List[PatchBugzillaId] +PatchAdvisoryIdList = List[PatchAdvisoryId] + + +class Patch(TypedDict, total=False): + Id: Optional[PatchId] + ReleaseDate: Optional[DateTime] + Title: Optional[PatchTitle] + Description: Optional[PatchDescription] + ContentUrl: Optional[PatchContentUrl] + Vendor: Optional[PatchVendor] + ProductFamily: Optional[PatchProductFamily] + Product: Optional[PatchProduct] + Classification: Optional[PatchClassification] + MsrcSeverity: Optional[PatchMsrcSeverity] + KbNumber: Optional[PatchKbNumber] + MsrcNumber: Optional[PatchMsrcNumber] + Language: Optional[PatchLanguage] + AdvisoryIds: Optional[PatchAdvisoryIdList] + BugzillaIds: Optional[PatchBugzillaIdList] + CVEIds: Optional[PatchCVEIdList] + Name: Optional[PatchName] + Epoch: Optional[PatchEpoch] + Version: Optional[PatchVersion] + Release: Optional[PatchRelease] + Arch: Optional[PatchArch] + Severity: Optional[PatchSeverity] + Repository: Optional[PatchRepository] + + +PatchList = List[Patch] + + +class DescribeAvailablePatchesResult(TypedDict, total=False): + Patches: Optional[PatchList] + NextToken: Optional[NextToken] + + +class DescribeDocumentPermissionRequest(ServiceRequest): + Name: DocumentName + PermissionType: DocumentPermissionType + MaxResults: Optional[DocumentPermissionMaxResults] + NextToken: Optional[NextToken] + + +class DescribeDocumentPermissionResponse(TypedDict, total=False): + AccountIds: Optional[AccountIdList] + AccountSharingInfoList: Optional[AccountSharingInfoList] + NextToken: Optional[NextToken] + + +class DescribeDocumentRequest(ServiceRequest): + Name: DocumentARN + DocumentVersion: Optional[DocumentVersion] + VersionName: Optional[DocumentVersionName] + + +class DescribeDocumentResult(TypedDict, total=False): + Document: Optional[DocumentDescription] + + +class DescribeEffectiveInstanceAssociationsRequest(ServiceRequest): + InstanceId: InstanceId + MaxResults: Optional[EffectiveInstanceAssociationMaxResults] + NextToken: Optional[NextToken] + + +class InstanceAssociation(TypedDict, total=False): + AssociationId: Optional[AssociationId] + InstanceId: Optional[InstanceId] + Content: Optional[DocumentContent] + AssociationVersion: Optional[AssociationVersion] + + +InstanceAssociationList = List[InstanceAssociation] + + +class DescribeEffectiveInstanceAssociationsResult(TypedDict, total=False): + Associations: Optional[InstanceAssociationList] + NextToken: Optional[NextToken] + + +class DescribeEffectivePatchesForPatchBaselineRequest(ServiceRequest): + BaselineId: BaselineId + MaxResults: Optional[PatchBaselineMaxResults] + NextToken: Optional[NextToken] + + +class PatchStatus(TypedDict, total=False): + DeploymentStatus: Optional[PatchDeploymentStatus] + ComplianceLevel: Optional[PatchComplianceLevel] + ApprovalDate: Optional[DateTime] + + +class EffectivePatch(TypedDict, total=False): + Patch: Optional[Patch] + PatchStatus: Optional[PatchStatus] + + +EffectivePatchList = List[EffectivePatch] + + +class DescribeEffectivePatchesForPatchBaselineResult(TypedDict, total=False): + EffectivePatches: Optional[EffectivePatchList] + NextToken: Optional[NextToken] + + +class DescribeInstanceAssociationsStatusRequest(ServiceRequest): + InstanceId: InstanceId + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class S3OutputUrl(TypedDict, total=False): + OutputUrl: Optional[Url] + + +class InstanceAssociationOutputUrl(TypedDict, total=False): + S3OutputUrl: Optional[S3OutputUrl] + + +class InstanceAssociationStatusInfo(TypedDict, total=False): + AssociationId: Optional[AssociationId] + Name: Optional[DocumentARN] + DocumentVersion: Optional[DocumentVersion] + AssociationVersion: Optional[AssociationVersion] + InstanceId: Optional[InstanceId] + ExecutionDate: Optional[DateTime] + Status: Optional[StatusName] + DetailedStatus: Optional[StatusName] + ExecutionSummary: Optional[InstanceAssociationExecutionSummary] + ErrorCode: Optional[AgentErrorCode] + OutputUrl: Optional[InstanceAssociationOutputUrl] + AssociationName: Optional[AssociationName] + + +InstanceAssociationStatusInfos = List[InstanceAssociationStatusInfo] + + +class DescribeInstanceAssociationsStatusResult(TypedDict, total=False): + InstanceAssociationStatusInfos: Optional[InstanceAssociationStatusInfos] + NextToken: Optional[NextToken] + + +InstanceInformationFilterValueSet = List[InstanceInformationFilterValue] + + +class InstanceInformationStringFilter(TypedDict, total=False): + Key: InstanceInformationStringFilterKey + Values: InstanceInformationFilterValueSet + + +InstanceInformationStringFilterList = List[InstanceInformationStringFilter] + + +class InstanceInformationFilter(TypedDict, total=False): + key: InstanceInformationFilterKey + valueSet: InstanceInformationFilterValueSet + + +InstanceInformationFilterList = List[InstanceInformationFilter] + + +class DescribeInstanceInformationRequest(ServiceRequest): + InstanceInformationFilterList: Optional[InstanceInformationFilterList] + Filters: Optional[InstanceInformationStringFilterList] + MaxResults: Optional[MaxResultsEC2Compatible] + NextToken: Optional[NextToken] + + +InstanceAssociationStatusAggregatedCount = Dict[StatusName, InstanceCount] + + +class InstanceAggregatedAssociationOverview(TypedDict, total=False): + DetailedStatus: Optional[StatusName] + InstanceAssociationStatusAggregatedCount: Optional[InstanceAssociationStatusAggregatedCount] + + +class InstanceInformation(TypedDict, total=False): + InstanceId: Optional[InstanceId] + PingStatus: Optional[PingStatus] + LastPingDateTime: Optional[DateTime] + AgentVersion: Optional[Version] + IsLatestVersion: Optional[Boolean] + PlatformType: Optional[PlatformType] + PlatformName: Optional[String] + PlatformVersion: Optional[String] + ActivationId: Optional[ActivationId] + IamRole: Optional[IamRole] + RegistrationDate: Optional[DateTime] + ResourceType: Optional[ResourceType] + Name: Optional[String] + IPAddress: Optional[IPAddress] + ComputerName: Optional[ComputerName] + AssociationStatus: Optional[StatusName] + LastAssociationExecutionDate: Optional[DateTime] + LastSuccessfulAssociationExecutionDate: Optional[DateTime] + AssociationOverview: Optional[InstanceAggregatedAssociationOverview] + SourceId: Optional[SourceId] + SourceType: Optional[SourceType] + + +InstanceInformationList = List[InstanceInformation] + + +class DescribeInstanceInformationResult(TypedDict, total=False): + InstanceInformationList: Optional[InstanceInformationList] + NextToken: Optional[NextToken] + + +InstancePatchStateFilterValues = List[InstancePatchStateFilterValue] + + +class InstancePatchStateFilter(TypedDict, total=False): + Key: InstancePatchStateFilterKey + Values: InstancePatchStateFilterValues + Type: InstancePatchStateOperatorType + + +InstancePatchStateFilterList = List[InstancePatchStateFilter] + + +class DescribeInstancePatchStatesForPatchGroupRequest(ServiceRequest): + PatchGroup: PatchGroup + Filters: Optional[InstancePatchStateFilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[PatchComplianceMaxResults] + + +class InstancePatchState(TypedDict, total=False): + InstanceId: InstanceId + PatchGroup: PatchGroup + BaselineId: BaselineId + SnapshotId: Optional[SnapshotId] + InstallOverrideList: Optional[InstallOverrideList] + OwnerInformation: Optional[OwnerInformation] + InstalledCount: Optional[PatchInstalledCount] + InstalledOtherCount: Optional[PatchInstalledOtherCount] + InstalledPendingRebootCount: Optional[PatchInstalledPendingRebootCount] + InstalledRejectedCount: Optional[PatchInstalledRejectedCount] + MissingCount: Optional[PatchMissingCount] + FailedCount: Optional[PatchFailedCount] + UnreportedNotApplicableCount: Optional[PatchUnreportedNotApplicableCount] + NotApplicableCount: Optional[PatchNotApplicableCount] + AvailableSecurityUpdateCount: Optional[PatchAvailableSecurityUpdateCount] + OperationStartTime: DateTime + OperationEndTime: DateTime + Operation: PatchOperationType + LastNoRebootInstallOperationTime: Optional[DateTime] + RebootOption: Optional[RebootOption] + CriticalNonCompliantCount: Optional[PatchCriticalNonCompliantCount] + SecurityNonCompliantCount: Optional[PatchSecurityNonCompliantCount] + OtherNonCompliantCount: Optional[PatchOtherNonCompliantCount] + + +InstancePatchStatesList = List[InstancePatchState] + + +class DescribeInstancePatchStatesForPatchGroupResult(TypedDict, total=False): + InstancePatchStates: Optional[InstancePatchStatesList] + NextToken: Optional[NextToken] + + +class DescribeInstancePatchStatesRequest(ServiceRequest): + InstanceIds: InstanceIdList + NextToken: Optional[NextToken] + MaxResults: Optional[PatchComplianceMaxResults] + + +InstancePatchStateList = List[InstancePatchState] + + +class DescribeInstancePatchStatesResult(TypedDict, total=False): + InstancePatchStates: Optional[InstancePatchStateList] + NextToken: Optional[NextToken] + + +class DescribeInstancePatchesRequest(ServiceRequest): + InstanceId: InstanceId + Filters: Optional[PatchOrchestratorFilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[PatchComplianceMaxResults] + + +class PatchComplianceData(TypedDict, total=False): + Title: PatchTitle + KBId: PatchKbNumber + Classification: PatchClassification + Severity: PatchSeverity + State: PatchComplianceDataState + InstalledTime: DateTime + CVEIds: Optional[PatchCVEIds] + + +PatchComplianceDataList = List[PatchComplianceData] + + +class DescribeInstancePatchesResult(TypedDict, total=False): + Patches: Optional[PatchComplianceDataList] + NextToken: Optional[NextToken] + + +InstancePropertyFilterValueSet = List[InstancePropertyFilterValue] + + +class InstancePropertyStringFilter(TypedDict, total=False): + Key: InstancePropertyStringFilterKey + Values: InstancePropertyFilterValueSet + Operator: Optional[InstancePropertyFilterOperator] + + +InstancePropertyStringFilterList = List[InstancePropertyStringFilter] + + +class InstancePropertyFilter(TypedDict, total=False): + key: InstancePropertyFilterKey + valueSet: InstancePropertyFilterValueSet + + +InstancePropertyFilterList = List[InstancePropertyFilter] + + +class DescribeInstancePropertiesRequest(ServiceRequest): + InstancePropertyFilterList: Optional[InstancePropertyFilterList] + FiltersWithOperator: Optional[InstancePropertyStringFilterList] + MaxResults: Optional[DescribeInstancePropertiesMaxResults] + NextToken: Optional[NextToken] + + +class InstanceProperty(TypedDict, total=False): + Name: Optional[InstanceName] + InstanceId: Optional[InstanceId] + InstanceType: Optional[InstanceType] + InstanceRole: Optional[InstanceRole] + KeyName: Optional[KeyName] + InstanceState: Optional[InstanceState] + Architecture: Optional[Architecture] + IPAddress: Optional[IPAddress] + LaunchTime: Optional[DateTime] + PingStatus: Optional[PingStatus] + LastPingDateTime: Optional[DateTime] + AgentVersion: Optional[Version] + PlatformType: Optional[PlatformType] + PlatformName: Optional[PlatformName] + PlatformVersion: Optional[PlatformVersion] + ActivationId: Optional[ActivationId] + IamRole: Optional[IamRole] + RegistrationDate: Optional[DateTime] + ResourceType: Optional[String] + ComputerName: Optional[ComputerName] + AssociationStatus: Optional[StatusName] + LastAssociationExecutionDate: Optional[DateTime] + LastSuccessfulAssociationExecutionDate: Optional[DateTime] + AssociationOverview: Optional[InstanceAggregatedAssociationOverview] + SourceId: Optional[SourceId] + SourceType: Optional[SourceType] + + +InstanceProperties = List[InstanceProperty] + + +class DescribeInstancePropertiesResult(TypedDict, total=False): + InstanceProperties: Optional[InstanceProperties] + NextToken: Optional[NextToken] + + +class DescribeInventoryDeletionsRequest(ServiceRequest): + DeletionId: Optional[UUID] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +InventoryDeletionLastStatusUpdateTime = datetime +InventoryDeletionStartTime = datetime + + +class InventoryDeletionStatusItem(TypedDict, total=False): + DeletionId: Optional[UUID] + TypeName: Optional[InventoryItemTypeName] + DeletionStartTime: Optional[InventoryDeletionStartTime] + LastStatus: Optional[InventoryDeletionStatus] + LastStatusMessage: Optional[InventoryDeletionLastStatusMessage] + DeletionSummary: Optional[InventoryDeletionSummary] + LastStatusUpdateTime: Optional[InventoryDeletionLastStatusUpdateTime] + + +InventoryDeletionsList = List[InventoryDeletionStatusItem] + + +class DescribeInventoryDeletionsResult(TypedDict, total=False): + InventoryDeletions: Optional[InventoryDeletionsList] + NextToken: Optional[NextToken] + + +MaintenanceWindowFilterValues = List[MaintenanceWindowFilterValue] + + +class MaintenanceWindowFilter(TypedDict, total=False): + Key: Optional[MaintenanceWindowFilterKey] + Values: Optional[MaintenanceWindowFilterValues] + + +MaintenanceWindowFilterList = List[MaintenanceWindowFilter] + + +class DescribeMaintenanceWindowExecutionTaskInvocationsRequest(ServiceRequest): + WindowExecutionId: MaintenanceWindowExecutionId + TaskId: MaintenanceWindowExecutionTaskId + Filters: Optional[MaintenanceWindowFilterList] + MaxResults: Optional[MaintenanceWindowMaxResults] + NextToken: Optional[NextToken] + + +class MaintenanceWindowExecutionTaskInvocationIdentity(TypedDict, total=False): + WindowExecutionId: Optional[MaintenanceWindowExecutionId] + TaskExecutionId: Optional[MaintenanceWindowExecutionTaskId] + InvocationId: Optional[MaintenanceWindowExecutionTaskInvocationId] + ExecutionId: Optional[MaintenanceWindowExecutionTaskExecutionId] + TaskType: Optional[MaintenanceWindowTaskType] + Parameters: Optional[MaintenanceWindowExecutionTaskInvocationParameters] + Status: Optional[MaintenanceWindowExecutionStatus] + StatusDetails: Optional[MaintenanceWindowExecutionStatusDetails] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + OwnerInformation: Optional[OwnerInformation] + WindowTargetId: Optional[MaintenanceWindowTaskTargetId] + + +MaintenanceWindowExecutionTaskInvocationIdentityList = List[ + MaintenanceWindowExecutionTaskInvocationIdentity +] + + +class DescribeMaintenanceWindowExecutionTaskInvocationsResult(TypedDict, total=False): + WindowExecutionTaskInvocationIdentities: Optional[ + MaintenanceWindowExecutionTaskInvocationIdentityList + ] + NextToken: Optional[NextToken] + + +class DescribeMaintenanceWindowExecutionTasksRequest(ServiceRequest): + WindowExecutionId: MaintenanceWindowExecutionId + Filters: Optional[MaintenanceWindowFilterList] + MaxResults: Optional[MaintenanceWindowMaxResults] + NextToken: Optional[NextToken] + + +class MaintenanceWindowExecutionTaskIdentity(TypedDict, total=False): + WindowExecutionId: Optional[MaintenanceWindowExecutionId] + TaskExecutionId: Optional[MaintenanceWindowExecutionTaskId] + Status: Optional[MaintenanceWindowExecutionStatus] + StatusDetails: Optional[MaintenanceWindowExecutionStatusDetails] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + TaskArn: Optional[MaintenanceWindowTaskArn] + TaskType: Optional[MaintenanceWindowTaskType] + AlarmConfiguration: Optional[AlarmConfiguration] + TriggeredAlarms: Optional[AlarmStateInformationList] + + +MaintenanceWindowExecutionTaskIdentityList = List[MaintenanceWindowExecutionTaskIdentity] + + +class DescribeMaintenanceWindowExecutionTasksResult(TypedDict, total=False): + WindowExecutionTaskIdentities: Optional[MaintenanceWindowExecutionTaskIdentityList] + NextToken: Optional[NextToken] + + +class DescribeMaintenanceWindowExecutionsRequest(ServiceRequest): + WindowId: MaintenanceWindowId + Filters: Optional[MaintenanceWindowFilterList] + MaxResults: Optional[MaintenanceWindowMaxResults] + NextToken: Optional[NextToken] + + +class MaintenanceWindowExecution(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowExecutionId: Optional[MaintenanceWindowExecutionId] + Status: Optional[MaintenanceWindowExecutionStatus] + StatusDetails: Optional[MaintenanceWindowExecutionStatusDetails] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + + +MaintenanceWindowExecutionList = List[MaintenanceWindowExecution] + + +class DescribeMaintenanceWindowExecutionsResult(TypedDict, total=False): + WindowExecutions: Optional[MaintenanceWindowExecutionList] + NextToken: Optional[NextToken] + + +class DescribeMaintenanceWindowScheduleRequest(ServiceRequest): + WindowId: Optional[MaintenanceWindowId] + Targets: Optional[Targets] + ResourceType: Optional[MaintenanceWindowResourceType] + Filters: Optional[PatchOrchestratorFilterList] + MaxResults: Optional[MaintenanceWindowSearchMaxResults] + NextToken: Optional[NextToken] + + +class ScheduledWindowExecution(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + Name: Optional[MaintenanceWindowName] + ExecutionTime: Optional[MaintenanceWindowStringDateTime] + + +ScheduledWindowExecutionList = List[ScheduledWindowExecution] + + +class DescribeMaintenanceWindowScheduleResult(TypedDict, total=False): + ScheduledWindowExecutions: Optional[ScheduledWindowExecutionList] + NextToken: Optional[NextToken] + + +class DescribeMaintenanceWindowTargetsRequest(ServiceRequest): + WindowId: MaintenanceWindowId + Filters: Optional[MaintenanceWindowFilterList] + MaxResults: Optional[MaintenanceWindowMaxResults] + NextToken: Optional[NextToken] + + +class MaintenanceWindowTarget(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowTargetId: Optional[MaintenanceWindowTargetId] + ResourceType: Optional[MaintenanceWindowResourceType] + Targets: Optional[Targets] + OwnerInformation: Optional[OwnerInformation] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + + +MaintenanceWindowTargetList = List[MaintenanceWindowTarget] + + +class DescribeMaintenanceWindowTargetsResult(TypedDict, total=False): + Targets: Optional[MaintenanceWindowTargetList] + NextToken: Optional[NextToken] + + +class DescribeMaintenanceWindowTasksRequest(ServiceRequest): + WindowId: MaintenanceWindowId + Filters: Optional[MaintenanceWindowFilterList] + MaxResults: Optional[MaintenanceWindowMaxResults] + NextToken: Optional[NextToken] + + +class LoggingInfo(TypedDict, total=False): + S3BucketName: S3BucketName + S3KeyPrefix: Optional[S3KeyPrefix] + S3Region: S3Region + + +MaintenanceWindowTaskParameterValueList = List[MaintenanceWindowTaskParameterValue] + + +class MaintenanceWindowTaskParameterValueExpression(TypedDict, total=False): + Values: Optional[MaintenanceWindowTaskParameterValueList] + + +MaintenanceWindowTaskParameters = Dict[ + MaintenanceWindowTaskParameterName, MaintenanceWindowTaskParameterValueExpression +] + + +class MaintenanceWindowTask(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowTaskId: Optional[MaintenanceWindowTaskId] + TaskArn: Optional[MaintenanceWindowTaskArn] + Type: Optional[MaintenanceWindowTaskType] + Targets: Optional[Targets] + TaskParameters: Optional[MaintenanceWindowTaskParameters] + Priority: Optional[MaintenanceWindowTaskPriority] + LoggingInfo: Optional[LoggingInfo] + ServiceRoleArn: Optional[ServiceRole] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + CutoffBehavior: Optional[MaintenanceWindowTaskCutoffBehavior] + AlarmConfiguration: Optional[AlarmConfiguration] + + +MaintenanceWindowTaskList = List[MaintenanceWindowTask] + + +class DescribeMaintenanceWindowTasksResult(TypedDict, total=False): + Tasks: Optional[MaintenanceWindowTaskList] + NextToken: Optional[NextToken] + + +class DescribeMaintenanceWindowsForTargetRequest(ServiceRequest): + Targets: Targets + ResourceType: MaintenanceWindowResourceType + MaxResults: Optional[MaintenanceWindowSearchMaxResults] + NextToken: Optional[NextToken] + + +class MaintenanceWindowIdentityForTarget(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + Name: Optional[MaintenanceWindowName] + + +MaintenanceWindowsForTargetList = List[MaintenanceWindowIdentityForTarget] + + +class DescribeMaintenanceWindowsForTargetResult(TypedDict, total=False): + WindowIdentities: Optional[MaintenanceWindowsForTargetList] + NextToken: Optional[NextToken] + + +class DescribeMaintenanceWindowsRequest(ServiceRequest): + Filters: Optional[MaintenanceWindowFilterList] + MaxResults: Optional[MaintenanceWindowMaxResults] + NextToken: Optional[NextToken] + + +class MaintenanceWindowIdentity(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + Enabled: Optional[MaintenanceWindowEnabled] + Duration: Optional[MaintenanceWindowDurationHours] + Cutoff: Optional[MaintenanceWindowCutoff] + Schedule: Optional[MaintenanceWindowSchedule] + ScheduleTimezone: Optional[MaintenanceWindowTimezone] + ScheduleOffset: Optional[MaintenanceWindowOffset] + EndDate: Optional[MaintenanceWindowStringDateTime] + StartDate: Optional[MaintenanceWindowStringDateTime] + NextExecutionTime: Optional[MaintenanceWindowStringDateTime] + + +MaintenanceWindowIdentityList = List[MaintenanceWindowIdentity] + + +class DescribeMaintenanceWindowsResult(TypedDict, total=False): + WindowIdentities: Optional[MaintenanceWindowIdentityList] + NextToken: Optional[NextToken] + + +OpsItemFilterValues = List[OpsItemFilterValue] + + +class OpsItemFilter(TypedDict, total=False): + Key: OpsItemFilterKey + Values: OpsItemFilterValues + Operator: OpsItemFilterOperator + + +OpsItemFilters = List[OpsItemFilter] + + +class DescribeOpsItemsRequest(ServiceRequest): + OpsItemFilters: Optional[OpsItemFilters] + MaxResults: Optional[OpsItemMaxResults] + NextToken: Optional[String] + + +class OpsItemSummary(TypedDict, total=False): + CreatedBy: Optional[String] + CreatedTime: Optional[DateTime] + LastModifiedBy: Optional[String] + LastModifiedTime: Optional[DateTime] + Priority: Optional[OpsItemPriority] + Source: Optional[OpsItemSource] + Status: Optional[OpsItemStatus] + OpsItemId: Optional[OpsItemId] + Title: Optional[OpsItemTitle] + OperationalData: Optional[OpsItemOperationalData] + Category: Optional[OpsItemCategory] + Severity: Optional[OpsItemSeverity] + OpsItemType: Optional[OpsItemType] + ActualStartTime: Optional[DateTime] + ActualEndTime: Optional[DateTime] + PlannedStartTime: Optional[DateTime] + PlannedEndTime: Optional[DateTime] + + +OpsItemSummaries = List[OpsItemSummary] + + +class DescribeOpsItemsResponse(TypedDict, total=False): + NextToken: Optional[String] + OpsItemSummaries: Optional[OpsItemSummaries] + + +ParameterStringFilterValueList = List[ParameterStringFilterValue] + + +class ParameterStringFilter(TypedDict, total=False): + Key: ParameterStringFilterKey + Option: Optional[ParameterStringQueryOption] + Values: Optional[ParameterStringFilterValueList] + + +ParameterStringFilterList = List[ParameterStringFilter] +ParametersFilterValueList = List[ParametersFilterValue] + + +class ParametersFilter(TypedDict, total=False): + Key: ParametersFilterKey + Values: ParametersFilterValueList + + +ParametersFilterList = List[ParametersFilter] + + +class DescribeParametersRequest(ServiceRequest): + Filters: Optional[ParametersFilterList] + ParameterFilters: Optional[ParameterStringFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + Shared: Optional[Boolean] + + +class ParameterInlinePolicy(TypedDict, total=False): + PolicyText: Optional[String] + PolicyType: Optional[String] + PolicyStatus: Optional[String] + + +ParameterPolicyList = List[ParameterInlinePolicy] +PSParameterVersion = int + + +class ParameterMetadata(TypedDict, total=False): + Name: Optional[PSParameterName] + ARN: Optional[String] + Type: Optional[ParameterType] + KeyId: Optional[ParameterKeyId] + LastModifiedDate: Optional[DateTime] + LastModifiedUser: Optional[String] + Description: Optional[ParameterDescription] + AllowedPattern: Optional[AllowedPattern] + Version: Optional[PSParameterVersion] + Tier: Optional[ParameterTier] + Policies: Optional[ParameterPolicyList] + DataType: Optional[ParameterDataType] + + +ParameterMetadataList = List[ParameterMetadata] + + +class DescribeParametersResult(TypedDict, total=False): + Parameters: Optional[ParameterMetadataList] + NextToken: Optional[NextToken] + + +class DescribePatchBaselinesRequest(ServiceRequest): + Filters: Optional[PatchOrchestratorFilterList] + MaxResults: Optional[PatchBaselineMaxResults] + NextToken: Optional[NextToken] + + +class PatchBaselineIdentity(TypedDict, total=False): + BaselineId: Optional[BaselineId] + BaselineName: Optional[BaselineName] + OperatingSystem: Optional[OperatingSystem] + BaselineDescription: Optional[BaselineDescription] + DefaultBaseline: Optional[DefaultBaseline] + + +PatchBaselineIdentityList = List[PatchBaselineIdentity] + + +class DescribePatchBaselinesResult(TypedDict, total=False): + BaselineIdentities: Optional[PatchBaselineIdentityList] + NextToken: Optional[NextToken] + + +class DescribePatchGroupStateRequest(ServiceRequest): + PatchGroup: PatchGroup + + +class DescribePatchGroupStateResult(TypedDict, total=False): + Instances: Optional[Integer] + InstancesWithInstalledPatches: Optional[Integer] + InstancesWithInstalledOtherPatches: Optional[Integer] + InstancesWithInstalledPendingRebootPatches: Optional[InstancesCount] + InstancesWithInstalledRejectedPatches: Optional[InstancesCount] + InstancesWithMissingPatches: Optional[Integer] + InstancesWithFailedPatches: Optional[Integer] + InstancesWithNotApplicablePatches: Optional[Integer] + InstancesWithUnreportedNotApplicablePatches: Optional[Integer] + InstancesWithCriticalNonCompliantPatches: Optional[InstancesCount] + InstancesWithSecurityNonCompliantPatches: Optional[InstancesCount] + InstancesWithOtherNonCompliantPatches: Optional[InstancesCount] + InstancesWithAvailableSecurityUpdates: Optional[Integer] + + +class DescribePatchGroupsRequest(ServiceRequest): + MaxResults: Optional[PatchBaselineMaxResults] + Filters: Optional[PatchOrchestratorFilterList] + NextToken: Optional[NextToken] + + +class PatchGroupPatchBaselineMapping(TypedDict, total=False): + PatchGroup: Optional[PatchGroup] + BaselineIdentity: Optional[PatchBaselineIdentity] + + +PatchGroupPatchBaselineMappingList = List[PatchGroupPatchBaselineMapping] + + +class DescribePatchGroupsResult(TypedDict, total=False): + Mappings: Optional[PatchGroupPatchBaselineMappingList] + NextToken: Optional[NextToken] + + +class DescribePatchPropertiesRequest(ServiceRequest): + OperatingSystem: OperatingSystem + Property: PatchProperty + PatchSet: Optional[PatchSet] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +PatchPropertyEntry = Dict[AttributeName, AttributeValue] +PatchPropertiesList = List[PatchPropertyEntry] + + +class DescribePatchPropertiesResult(TypedDict, total=False): + Properties: Optional[PatchPropertiesList] + NextToken: Optional[NextToken] + + +class SessionFilter(TypedDict, total=False): + key: SessionFilterKey + value: SessionFilterValue + + +SessionFilterList = List[SessionFilter] + + +class DescribeSessionsRequest(ServiceRequest): + State: SessionState + MaxResults: Optional[SessionMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[SessionFilterList] + + +class SessionManagerOutputUrl(TypedDict, total=False): + S3OutputUrl: Optional[SessionManagerS3OutputUrl] + CloudWatchOutputUrl: Optional[SessionManagerCloudWatchOutputUrl] + + +class Session(TypedDict, total=False): + SessionId: Optional[SessionId] + Target: Optional[SessionTarget] + Status: Optional[SessionStatus] + StartDate: Optional[DateTime] + EndDate: Optional[DateTime] + DocumentName: Optional[DocumentName] + Owner: Optional[SessionOwner] + Reason: Optional[SessionReason] + Details: Optional[SessionDetails] + OutputUrl: Optional[SessionManagerOutputUrl] + MaxSessionDuration: Optional[MaxSessionDuration] + AccessType: Optional[AccessType] + + +SessionList = List[Session] + + +class DescribeSessionsResponse(TypedDict, total=False): + Sessions: Optional[SessionList] + NextToken: Optional[NextToken] + + +class DisassociateOpsItemRelatedItemRequest(ServiceRequest): + OpsItemId: OpsItemId + AssociationId: OpsItemRelatedItemAssociationId + + +class DisassociateOpsItemRelatedItemResponse(TypedDict, total=False): + pass + + +class DocumentDefaultVersionDescription(TypedDict, total=False): + Name: Optional[DocumentName] + DefaultVersion: Optional[DocumentVersion] + DefaultVersionName: Optional[DocumentVersionName] + + +class DocumentFilter(TypedDict, total=False): + key: DocumentFilterKey + value: DocumentFilterValue + + +DocumentFilterList = List[DocumentFilter] + + +class DocumentIdentifier(TypedDict, total=False): + Name: Optional[DocumentARN] + CreatedDate: Optional[DateTime] + DisplayName: Optional[DocumentDisplayName] + Owner: Optional[DocumentOwner] + VersionName: Optional[DocumentVersionName] + PlatformTypes: Optional[PlatformTypeList] + DocumentVersion: Optional[DocumentVersion] + DocumentType: Optional[DocumentType] + SchemaVersion: Optional[DocumentSchemaVersion] + DocumentFormat: Optional[DocumentFormat] + TargetType: Optional[TargetType] + Tags: Optional[TagList] + Requires: Optional[DocumentRequiresList] + ReviewStatus: Optional[ReviewStatus] + Author: Optional[DocumentAuthor] + + +DocumentIdentifierList = List[DocumentIdentifier] +DocumentKeyValuesFilterValues = List[DocumentKeyValuesFilterValue] + + +class DocumentKeyValuesFilter(TypedDict, total=False): + Key: Optional[DocumentKeyValuesFilterKey] + Values: Optional[DocumentKeyValuesFilterValues] + + +DocumentKeyValuesFilterList = List[DocumentKeyValuesFilter] + + +class DocumentReviewCommentSource(TypedDict, total=False): + Type: Optional[DocumentReviewCommentType] + Content: Optional[DocumentReviewComment] + + +DocumentReviewCommentList = List[DocumentReviewCommentSource] + + +class DocumentReviewerResponseSource(TypedDict, total=False): + CreateTime: Optional[DateTime] + UpdatedTime: Optional[DateTime] + ReviewStatus: Optional[ReviewStatus] + Comment: Optional[DocumentReviewCommentList] + Reviewer: Optional[Reviewer] + + +DocumentReviewerResponseList = List[DocumentReviewerResponseSource] + + +class DocumentMetadataResponseInfo(TypedDict, total=False): + ReviewerResponse: Optional[DocumentReviewerResponseList] + + +class DocumentReviews(TypedDict, total=False): + Action: DocumentReviewAction + Comment: Optional[DocumentReviewCommentList] + + +class DocumentVersionInfo(TypedDict, total=False): + Name: Optional[DocumentName] + DisplayName: Optional[DocumentDisplayName] + DocumentVersion: Optional[DocumentVersion] + VersionName: Optional[DocumentVersionName] + CreatedDate: Optional[DateTime] + IsDefaultVersion: Optional[Boolean] + DocumentFormat: Optional[DocumentFormat] + Status: Optional[DocumentStatus] + StatusInformation: Optional[DocumentStatusInformation] + ReviewStatus: Optional[ReviewStatus] + + +DocumentVersionList = List[DocumentVersionInfo] + + +class ExecutionInputs(TypedDict, total=False): + Automation: Optional[AutomationExecutionInputs] + + +class ExecutionPreview(TypedDict, total=False): + Automation: Optional[AutomationExecutionPreview] + + +class GetAccessTokenRequest(ServiceRequest): + AccessRequestId: AccessRequestId + + +class GetAccessTokenResponse(TypedDict, total=False): + Credentials: Optional[Credentials] + AccessRequestStatus: Optional[AccessRequestStatus] + + +class GetAutomationExecutionRequest(ServiceRequest): + AutomationExecutionId: AutomationExecutionId + + +class GetAutomationExecutionResult(TypedDict, total=False): + AutomationExecution: Optional[AutomationExecution] + + +class GetCalendarStateRequest(ServiceRequest): + CalendarNames: CalendarNameOrARNList + AtTime: Optional[ISO8601String] + + +class GetCalendarStateResponse(TypedDict, total=False): + State: Optional[CalendarState] + AtTime: Optional[ISO8601String] + NextTransitionTime: Optional[ISO8601String] + + +class GetCommandInvocationRequest(ServiceRequest): + CommandId: CommandId + InstanceId: InstanceId + PluginName: Optional[CommandPluginName] + + +class GetCommandInvocationResult(TypedDict, total=False): + CommandId: Optional[CommandId] + InstanceId: Optional[InstanceId] + Comment: Optional[Comment] + DocumentName: Optional[DocumentName] + DocumentVersion: Optional[DocumentVersion] + PluginName: Optional[CommandPluginName] + ResponseCode: Optional[ResponseCode] + ExecutionStartDateTime: Optional[StringDateTime] + ExecutionElapsedTime: Optional[StringDateTime] + ExecutionEndDateTime: Optional[StringDateTime] + Status: Optional[CommandInvocationStatus] + StatusDetails: Optional[StatusDetails] + StandardOutputContent: Optional[StandardOutputContent] + StandardOutputUrl: Optional[Url] + StandardErrorContent: Optional[StandardErrorContent] + StandardErrorUrl: Optional[Url] + CloudWatchOutputConfig: Optional[CloudWatchOutputConfig] + + +class GetConnectionStatusRequest(ServiceRequest): + Target: SessionTarget + + +class GetConnectionStatusResponse(TypedDict, total=False): + Target: Optional[SessionTarget] + Status: Optional[ConnectionStatus] + + +class GetDefaultPatchBaselineRequest(ServiceRequest): + OperatingSystem: Optional[OperatingSystem] + + +class GetDefaultPatchBaselineResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + OperatingSystem: Optional[OperatingSystem] + + +class GetDeployablePatchSnapshotForInstanceRequest(ServiceRequest): + InstanceId: InstanceId + SnapshotId: SnapshotId + BaselineOverride: Optional[BaselineOverride] + + +class GetDeployablePatchSnapshotForInstanceResult(TypedDict, total=False): + InstanceId: Optional[InstanceId] + SnapshotId: Optional[SnapshotId] + SnapshotDownloadUrl: Optional[SnapshotDownloadUrl] + Product: Optional[Product] + + +class GetDocumentRequest(ServiceRequest): + Name: DocumentARN + VersionName: Optional[DocumentVersionName] + DocumentVersion: Optional[DocumentVersion] + DocumentFormat: Optional[DocumentFormat] + + +class GetDocumentResult(TypedDict, total=False): + Name: Optional[DocumentARN] + CreatedDate: Optional[DateTime] + DisplayName: Optional[DocumentDisplayName] + VersionName: Optional[DocumentVersionName] + DocumentVersion: Optional[DocumentVersion] + Status: Optional[DocumentStatus] + StatusInformation: Optional[DocumentStatusInformation] + Content: Optional[DocumentContent] + DocumentType: Optional[DocumentType] + DocumentFormat: Optional[DocumentFormat] + Requires: Optional[DocumentRequiresList] + AttachmentsContent: Optional[AttachmentContentList] + ReviewStatus: Optional[ReviewStatus] + + +class GetExecutionPreviewRequest(ServiceRequest): + ExecutionPreviewId: ExecutionPreviewId + + +class GetExecutionPreviewResponse(TypedDict, total=False): + ExecutionPreviewId: Optional[ExecutionPreviewId] + EndedAt: Optional[DateTime] + Status: Optional[ExecutionPreviewStatus] + StatusMessage: Optional[String] + ExecutionPreview: Optional[ExecutionPreview] + + +class ResultAttribute(TypedDict, total=False): + TypeName: InventoryItemTypeName + + +ResultAttributeList = List[ResultAttribute] +InventoryFilterValueList = List[InventoryFilterValue] + + +class InventoryFilter(TypedDict, total=False): + Key: InventoryFilterKey + Values: InventoryFilterValueList + Type: Optional[InventoryQueryOperatorType] + + +InventoryFilterList = List[InventoryFilter] + + +class InventoryGroup(TypedDict, total=False): + Name: InventoryGroupName + Filters: InventoryFilterList + + +InventoryGroupList = List[InventoryGroup] +InventoryAggregatorList = List["InventoryAggregator"] + + +class InventoryAggregator(TypedDict, total=False): + Expression: Optional[InventoryAggregatorExpression] + Aggregators: Optional[InventoryAggregatorList] + Groups: Optional[InventoryGroupList] + + +class GetInventoryRequest(ServiceRequest): + Filters: Optional[InventoryFilterList] + Aggregators: Optional[InventoryAggregatorList] + ResultAttributes: Optional[ResultAttributeList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +InventoryItemEntry = Dict[AttributeName, AttributeValue] +InventoryItemEntryList = List[InventoryItemEntry] + + +class InventoryResultItem(TypedDict, total=False): + TypeName: InventoryItemTypeName + SchemaVersion: InventoryItemSchemaVersion + CaptureTime: Optional[InventoryItemCaptureTime] + ContentHash: Optional[InventoryItemContentHash] + Content: InventoryItemEntryList + + +InventoryResultItemMap = Dict[InventoryResultItemKey, InventoryResultItem] + + +class InventoryResultEntity(TypedDict, total=False): + Id: Optional[InventoryResultEntityId] + Data: Optional[InventoryResultItemMap] + + +InventoryResultEntityList = List[InventoryResultEntity] + + +class GetInventoryResult(TypedDict, total=False): + Entities: Optional[InventoryResultEntityList] + NextToken: Optional[NextToken] + + +class GetInventorySchemaRequest(ServiceRequest): + TypeName: Optional[InventoryItemTypeNameFilter] + NextToken: Optional[NextToken] + MaxResults: Optional[GetInventorySchemaMaxResults] + Aggregator: Optional[AggregatorSchemaOnly] + SubType: Optional[IsSubTypeSchema] + + +class InventoryItemAttribute(TypedDict, total=False): + Name: InventoryItemAttributeName + DataType: InventoryAttributeDataType + + +InventoryItemAttributeList = List[InventoryItemAttribute] + + +class InventoryItemSchema(TypedDict, total=False): + TypeName: InventoryItemTypeName + Version: Optional[InventoryItemSchemaVersion] + Attributes: InventoryItemAttributeList + DisplayName: Optional[InventoryTypeDisplayName] + + +InventoryItemSchemaResultList = List[InventoryItemSchema] + + +class GetInventorySchemaResult(TypedDict, total=False): + Schemas: Optional[InventoryItemSchemaResultList] + NextToken: Optional[NextToken] + + +class GetMaintenanceWindowExecutionRequest(ServiceRequest): + WindowExecutionId: MaintenanceWindowExecutionId + + +MaintenanceWindowExecutionTaskIdList = List[MaintenanceWindowExecutionTaskId] + + +class GetMaintenanceWindowExecutionResult(TypedDict, total=False): + WindowExecutionId: Optional[MaintenanceWindowExecutionId] + TaskIds: Optional[MaintenanceWindowExecutionTaskIdList] + Status: Optional[MaintenanceWindowExecutionStatus] + StatusDetails: Optional[MaintenanceWindowExecutionStatusDetails] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + + +class GetMaintenanceWindowExecutionTaskInvocationRequest(ServiceRequest): + WindowExecutionId: MaintenanceWindowExecutionId + TaskId: MaintenanceWindowExecutionTaskId + InvocationId: MaintenanceWindowExecutionTaskInvocationId + + +class GetMaintenanceWindowExecutionTaskInvocationResult(TypedDict, total=False): + WindowExecutionId: Optional[MaintenanceWindowExecutionId] + TaskExecutionId: Optional[MaintenanceWindowExecutionTaskId] + InvocationId: Optional[MaintenanceWindowExecutionTaskInvocationId] + ExecutionId: Optional[MaintenanceWindowExecutionTaskExecutionId] + TaskType: Optional[MaintenanceWindowTaskType] + Parameters: Optional[MaintenanceWindowExecutionTaskInvocationParameters] + Status: Optional[MaintenanceWindowExecutionStatus] + StatusDetails: Optional[MaintenanceWindowExecutionStatusDetails] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + OwnerInformation: Optional[OwnerInformation] + WindowTargetId: Optional[MaintenanceWindowTaskTargetId] + + +class GetMaintenanceWindowExecutionTaskRequest(ServiceRequest): + WindowExecutionId: MaintenanceWindowExecutionId + TaskId: MaintenanceWindowExecutionTaskId + + +MaintenanceWindowTaskParametersList = List[MaintenanceWindowTaskParameters] + + +class GetMaintenanceWindowExecutionTaskResult(TypedDict, total=False): + WindowExecutionId: Optional[MaintenanceWindowExecutionId] + TaskExecutionId: Optional[MaintenanceWindowExecutionTaskId] + TaskArn: Optional[MaintenanceWindowTaskArn] + ServiceRole: Optional[ServiceRole] + Type: Optional[MaintenanceWindowTaskType] + TaskParameters: Optional[MaintenanceWindowTaskParametersList] + Priority: Optional[MaintenanceWindowTaskPriority] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + Status: Optional[MaintenanceWindowExecutionStatus] + StatusDetails: Optional[MaintenanceWindowExecutionStatusDetails] + StartTime: Optional[DateTime] + EndTime: Optional[DateTime] + AlarmConfiguration: Optional[AlarmConfiguration] + TriggeredAlarms: Optional[AlarmStateInformationList] + + +class GetMaintenanceWindowRequest(ServiceRequest): + WindowId: MaintenanceWindowId + + +class GetMaintenanceWindowResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + StartDate: Optional[MaintenanceWindowStringDateTime] + EndDate: Optional[MaintenanceWindowStringDateTime] + Schedule: Optional[MaintenanceWindowSchedule] + ScheduleTimezone: Optional[MaintenanceWindowTimezone] + ScheduleOffset: Optional[MaintenanceWindowOffset] + NextExecutionTime: Optional[MaintenanceWindowStringDateTime] + Duration: Optional[MaintenanceWindowDurationHours] + Cutoff: Optional[MaintenanceWindowCutoff] + AllowUnassociatedTargets: Optional[MaintenanceWindowAllowUnassociatedTargets] + Enabled: Optional[MaintenanceWindowEnabled] + CreatedDate: Optional[DateTime] + ModifiedDate: Optional[DateTime] + + +class GetMaintenanceWindowTaskRequest(ServiceRequest): + WindowId: MaintenanceWindowId + WindowTaskId: MaintenanceWindowTaskId + + +MaintenanceWindowLambdaPayload = bytes + + +class MaintenanceWindowLambdaParameters(TypedDict, total=False): + ClientContext: Optional[MaintenanceWindowLambdaClientContext] + Qualifier: Optional[MaintenanceWindowLambdaQualifier] + Payload: Optional[MaintenanceWindowLambdaPayload] + + +class MaintenanceWindowStepFunctionsParameters(TypedDict, total=False): + Input: Optional[MaintenanceWindowStepFunctionsInput] + Name: Optional[MaintenanceWindowStepFunctionsName] + + +class MaintenanceWindowAutomationParameters(TypedDict, total=False): + DocumentVersion: Optional[DocumentVersion] + Parameters: Optional[AutomationParameterMap] + + +class MaintenanceWindowRunCommandParameters(TypedDict, total=False): + Comment: Optional[Comment] + CloudWatchOutputConfig: Optional[CloudWatchOutputConfig] + DocumentHash: Optional[DocumentHash] + DocumentHashType: Optional[DocumentHashType] + DocumentVersion: Optional[DocumentVersion] + NotificationConfig: Optional[NotificationConfig] + OutputS3BucketName: Optional[S3BucketName] + OutputS3KeyPrefix: Optional[S3KeyPrefix] + Parameters: Optional[Parameters] + ServiceRoleArn: Optional[ServiceRole] + TimeoutSeconds: Optional[TimeoutSeconds] + + +class MaintenanceWindowTaskInvocationParameters(TypedDict, total=False): + RunCommand: Optional[MaintenanceWindowRunCommandParameters] + Automation: Optional[MaintenanceWindowAutomationParameters] + StepFunctions: Optional[MaintenanceWindowStepFunctionsParameters] + Lambda: Optional[MaintenanceWindowLambdaParameters] + + +class GetMaintenanceWindowTaskResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowTaskId: Optional[MaintenanceWindowTaskId] + Targets: Optional[Targets] + TaskArn: Optional[MaintenanceWindowTaskArn] + ServiceRoleArn: Optional[ServiceRole] + TaskType: Optional[MaintenanceWindowTaskType] + TaskParameters: Optional[MaintenanceWindowTaskParameters] + TaskInvocationParameters: Optional[MaintenanceWindowTaskInvocationParameters] + Priority: Optional[MaintenanceWindowTaskPriority] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + LoggingInfo: Optional[LoggingInfo] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + CutoffBehavior: Optional[MaintenanceWindowTaskCutoffBehavior] + AlarmConfiguration: Optional[AlarmConfiguration] + + +class GetOpsItemRequest(ServiceRequest): + OpsItemId: OpsItemId + OpsItemArn: Optional[OpsItemArn] + + +class OpsItem(TypedDict, total=False): + CreatedBy: Optional[String] + OpsItemType: Optional[OpsItemType] + CreatedTime: Optional[DateTime] + Description: Optional[OpsItemDescription] + LastModifiedBy: Optional[String] + LastModifiedTime: Optional[DateTime] + Notifications: Optional[OpsItemNotifications] + Priority: Optional[OpsItemPriority] + RelatedOpsItems: Optional[RelatedOpsItems] + Status: Optional[OpsItemStatus] + OpsItemId: Optional[OpsItemId] + Version: Optional[String] + Title: Optional[OpsItemTitle] + Source: Optional[OpsItemSource] + OperationalData: Optional[OpsItemOperationalData] + Category: Optional[OpsItemCategory] + Severity: Optional[OpsItemSeverity] + ActualStartTime: Optional[DateTime] + ActualEndTime: Optional[DateTime] + PlannedStartTime: Optional[DateTime] + PlannedEndTime: Optional[DateTime] + OpsItemArn: Optional[OpsItemArn] + + +class GetOpsItemResponse(TypedDict, total=False): + OpsItem: Optional[OpsItem] + + +class GetOpsMetadataRequest(ServiceRequest): + OpsMetadataArn: OpsMetadataArn + MaxResults: Optional[GetOpsMetadataMaxResults] + NextToken: Optional[NextToken] + + +class GetOpsMetadataResult(TypedDict, total=False): + ResourceId: Optional[OpsMetadataResourceId] + Metadata: Optional[MetadataMap] + NextToken: Optional[NextToken] + + +class OpsResultAttribute(TypedDict, total=False): + TypeName: OpsDataTypeName + + +OpsResultAttributeList = List[OpsResultAttribute] +OpsAggregatorList = List["OpsAggregator"] +OpsFilterValueList = List[OpsFilterValue] + + +class OpsFilter(TypedDict, total=False): + Key: OpsFilterKey + Values: OpsFilterValueList + Type: Optional[OpsFilterOperatorType] + + +OpsFilterList = List[OpsFilter] +OpsAggregatorValueMap = Dict[OpsAggregatorValueKey, OpsAggregatorValue] + + +class OpsAggregator(TypedDict, total=False): + AggregatorType: Optional[OpsAggregatorType] + TypeName: Optional[OpsDataTypeName] + AttributeName: Optional[OpsDataAttributeName] + Values: Optional[OpsAggregatorValueMap] + Filters: Optional[OpsFilterList] + Aggregators: Optional[OpsAggregatorList] + + +class GetOpsSummaryRequest(ServiceRequest): + SyncName: Optional[ResourceDataSyncName] + Filters: Optional[OpsFilterList] + Aggregators: Optional[OpsAggregatorList] + ResultAttributes: Optional[OpsResultAttributeList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +OpsEntityItemEntry = Dict[AttributeName, AttributeValue] +OpsEntityItemEntryList = List[OpsEntityItemEntry] + + +class OpsEntityItem(TypedDict, total=False): + CaptureTime: Optional[OpsEntityItemCaptureTime] + Content: Optional[OpsEntityItemEntryList] + + +OpsEntityItemMap = Dict[OpsEntityItemKey, OpsEntityItem] + + +class OpsEntity(TypedDict, total=False): + Id: Optional[OpsEntityId] + Data: Optional[OpsEntityItemMap] + + +OpsEntityList = List[OpsEntity] + + +class GetOpsSummaryResult(TypedDict, total=False): + Entities: Optional[OpsEntityList] + NextToken: Optional[NextToken] + + +class GetParameterHistoryRequest(ServiceRequest): + Name: PSParameterName + WithDecryption: Optional[Boolean] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +ParameterLabelList = List[ParameterLabel] + + +class ParameterHistory(TypedDict, total=False): + Name: Optional[PSParameterName] + Type: Optional[ParameterType] + KeyId: Optional[ParameterKeyId] + LastModifiedDate: Optional[DateTime] + LastModifiedUser: Optional[String] + Description: Optional[ParameterDescription] + Value: Optional[PSParameterValue] + AllowedPattern: Optional[AllowedPattern] + Version: Optional[PSParameterVersion] + Labels: Optional[ParameterLabelList] + Tier: Optional[ParameterTier] + Policies: Optional[ParameterPolicyList] + DataType: Optional[ParameterDataType] + + +ParameterHistoryList = List[ParameterHistory] + + +class GetParameterHistoryResult(TypedDict, total=False): + Parameters: Optional[ParameterHistoryList] + NextToken: Optional[NextToken] + + +class GetParameterRequest(ServiceRequest): + Name: PSParameterName + WithDecryption: Optional[Boolean] + + +class Parameter(TypedDict, total=False): + Name: Optional[PSParameterName] + Type: Optional[ParameterType] + Value: Optional[PSParameterValue] + Version: Optional[PSParameterVersion] + Selector: Optional[PSParameterSelector] + SourceResult: Optional[String] + LastModifiedDate: Optional[DateTime] + ARN: Optional[String] + DataType: Optional[ParameterDataType] + + +class GetParameterResult(TypedDict, total=False): + Parameter: Optional[Parameter] + + +class GetParametersByPathRequest(ServiceRequest): + Path: PSParameterName + Recursive: Optional[Boolean] + ParameterFilters: Optional[ParameterStringFilterList] + WithDecryption: Optional[Boolean] + MaxResults: Optional[GetParametersByPathMaxResults] + NextToken: Optional[NextToken] + + +ParameterList = List[Parameter] + + +class GetParametersByPathResult(TypedDict, total=False): + Parameters: Optional[ParameterList] + NextToken: Optional[NextToken] + + +class GetParametersRequest(ServiceRequest): + Names: ParameterNameList + WithDecryption: Optional[Boolean] + + +class GetParametersResult(TypedDict, total=False): + Parameters: Optional[ParameterList] + InvalidParameters: Optional[ParameterNameList] + + +class GetPatchBaselineForPatchGroupRequest(ServiceRequest): + PatchGroup: PatchGroup + OperatingSystem: Optional[OperatingSystem] + + +class GetPatchBaselineForPatchGroupResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + PatchGroup: Optional[PatchGroup] + OperatingSystem: Optional[OperatingSystem] + + +class GetPatchBaselineRequest(ServiceRequest): + BaselineId: BaselineId + + +PatchGroupList = List[PatchGroup] + + +class GetPatchBaselineResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + Name: Optional[BaselineName] + OperatingSystem: Optional[OperatingSystem] + GlobalFilters: Optional[PatchFilterGroup] + ApprovalRules: Optional[PatchRuleGroup] + ApprovedPatches: Optional[PatchIdList] + ApprovedPatchesComplianceLevel: Optional[PatchComplianceLevel] + ApprovedPatchesEnableNonSecurity: Optional[Boolean] + RejectedPatches: Optional[PatchIdList] + RejectedPatchesAction: Optional[PatchAction] + PatchGroups: Optional[PatchGroupList] + CreatedDate: Optional[DateTime] + ModifiedDate: Optional[DateTime] + Description: Optional[BaselineDescription] + Sources: Optional[PatchSourceList] + AvailableSecurityUpdatesComplianceStatus: Optional[PatchComplianceStatus] + + +class GetResourcePoliciesRequest(ServiceRequest): + ResourceArn: ResourceArnString + NextToken: Optional[String] + MaxResults: Optional[ResourcePolicyMaxResults] + + +class GetResourcePoliciesResponseEntry(TypedDict, total=False): + PolicyId: Optional[PolicyId] + PolicyHash: Optional[PolicyHash] + Policy: Optional[Policy] + + +GetResourcePoliciesResponseEntries = List[GetResourcePoliciesResponseEntry] + + +class GetResourcePoliciesResponse(TypedDict, total=False): + NextToken: Optional[String] + Policies: Optional[GetResourcePoliciesResponseEntries] + + +class GetServiceSettingRequest(ServiceRequest): + SettingId: ServiceSettingId + + +class ServiceSetting(TypedDict, total=False): + SettingId: Optional[ServiceSettingId] + SettingValue: Optional[ServiceSettingValue] + LastModifiedDate: Optional[DateTime] + LastModifiedUser: Optional[String] + ARN: Optional[String] + Status: Optional[String] + + +class GetServiceSettingResult(TypedDict, total=False): + ServiceSetting: Optional[ServiceSetting] + + +class InstanceInfo(TypedDict, total=False): + AgentType: Optional[AgentType] + AgentVersion: Optional[AgentVersion] + ComputerName: Optional[ComputerName] + InstanceStatus: Optional[InstanceStatus] + IpAddress: Optional[IpAddress] + ManagedStatus: Optional[ManagedStatus] + PlatformType: Optional[PlatformType] + PlatformName: Optional[PlatformName] + PlatformVersion: Optional[PlatformVersion] + ResourceType: Optional[ResourceType] + + +InventoryItemContentContext = Dict[AttributeName, AttributeValue] + + +class InventoryItem(TypedDict, total=False): + TypeName: InventoryItemTypeName + SchemaVersion: InventoryItemSchemaVersion + CaptureTime: InventoryItemCaptureTime + ContentHash: Optional[InventoryItemContentHash] + Content: Optional[InventoryItemEntryList] + Context: Optional[InventoryItemContentContext] + + +InventoryItemList = List[InventoryItem] +KeyList = List[TagKey] + + +class LabelParameterVersionRequest(ServiceRequest): + Name: PSParameterName + ParameterVersion: Optional[PSParameterVersion] + Labels: ParameterLabelList + + +class LabelParameterVersionResult(TypedDict, total=False): + InvalidLabels: Optional[ParameterLabelList] + ParameterVersion: Optional[PSParameterVersion] + + +LastResourceDataSyncTime = datetime +LastSuccessfulResourceDataSyncTime = datetime + + +class ListAssociationVersionsRequest(ServiceRequest): + AssociationId: AssociationId + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListAssociationVersionsResult(TypedDict, total=False): + AssociationVersions: Optional[AssociationVersionList] + NextToken: Optional[NextToken] + + +class ListAssociationsRequest(ServiceRequest): + AssociationFilterList: Optional[AssociationFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListAssociationsResult(TypedDict, total=False): + Associations: Optional[AssociationList] + NextToken: Optional[NextToken] + + +class ListCommandInvocationsRequest(ServiceRequest): + CommandId: Optional[CommandId] + InstanceId: Optional[InstanceId] + MaxResults: Optional[CommandMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[CommandFilterList] + Details: Optional[Boolean] + + +class ListCommandInvocationsResult(TypedDict, total=False): + CommandInvocations: Optional[CommandInvocationList] + NextToken: Optional[NextToken] + + +class ListCommandsRequest(ServiceRequest): + CommandId: Optional[CommandId] + InstanceId: Optional[InstanceId] + MaxResults: Optional[CommandMaxResults] + NextToken: Optional[NextToken] + Filters: Optional[CommandFilterList] + + +class ListCommandsResult(TypedDict, total=False): + Commands: Optional[CommandList] + NextToken: Optional[NextToken] + + +class ListComplianceItemsRequest(ServiceRequest): + Filters: Optional[ComplianceStringFilterList] + ResourceIds: Optional[ComplianceResourceIdList] + ResourceTypes: Optional[ComplianceResourceTypeList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ListComplianceItemsResult(TypedDict, total=False): + ComplianceItems: Optional[ComplianceItemList] + NextToken: Optional[NextToken] + + +class ListComplianceSummariesRequest(ServiceRequest): + Filters: Optional[ComplianceStringFilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ListComplianceSummariesResult(TypedDict, total=False): + ComplianceSummaryItems: Optional[ComplianceSummaryItemList] + NextToken: Optional[NextToken] + + +class ListDocumentMetadataHistoryRequest(ServiceRequest): + Name: DocumentName + DocumentVersion: Optional[DocumentVersion] + Metadata: DocumentMetadataEnum + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ListDocumentMetadataHistoryResponse(TypedDict, total=False): + Name: Optional[DocumentName] + DocumentVersion: Optional[DocumentVersion] + Author: Optional[DocumentAuthor] + Metadata: Optional[DocumentMetadataResponseInfo] + NextToken: Optional[NextToken] + + +class ListDocumentVersionsRequest(ServiceRequest): + Name: DocumentARN + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListDocumentVersionsResult(TypedDict, total=False): + DocumentVersions: Optional[DocumentVersionList] + NextToken: Optional[NextToken] + + +class ListDocumentsRequest(ServiceRequest): + DocumentFilterList: Optional[DocumentFilterList] + Filters: Optional[DocumentKeyValuesFilterList] + MaxResults: Optional[MaxResults] + NextToken: Optional[NextToken] + + +class ListDocumentsResult(TypedDict, total=False): + DocumentIdentifiers: Optional[DocumentIdentifierList] + NextToken: Optional[NextToken] + + +class ListInventoryEntriesRequest(ServiceRequest): + InstanceId: InstanceId + TypeName: InventoryItemTypeName + Filters: Optional[InventoryFilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ListInventoryEntriesResult(TypedDict, total=False): + TypeName: Optional[InventoryItemTypeName] + InstanceId: Optional[InstanceId] + SchemaVersion: Optional[InventoryItemSchemaVersion] + CaptureTime: Optional[InventoryItemCaptureTime] + Entries: Optional[InventoryItemEntryList] + NextToken: Optional[NextToken] + + +NodeFilterValueList = List[NodeFilterValue] + + +class NodeFilter(TypedDict, total=False): + Key: NodeFilterKey + Values: NodeFilterValueList + Type: Optional[NodeFilterOperatorType] + + +NodeFilterList = List[NodeFilter] + + +class ListNodesRequest(ServiceRequest): + SyncName: Optional[ResourceDataSyncName] + Filters: Optional[NodeFilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class NodeType(TypedDict, total=False): + Instance: Optional[InstanceInfo] + + +class NodeOwnerInfo(TypedDict, total=False): + AccountId: Optional[NodeAccountId] + OrganizationalUnitId: Optional[NodeOrganizationalUnitId] + OrganizationalUnitPath: Optional[NodeOrganizationalUnitPath] + + +NodeCaptureTime = datetime + + +class Node(TypedDict, total=False): + CaptureTime: Optional[NodeCaptureTime] + Id: Optional[NodeId] + Owner: Optional[NodeOwnerInfo] + Region: Optional[NodeRegion] + NodeType: Optional[NodeType] + + +NodeList = List[Node] + + +class ListNodesResult(TypedDict, total=False): + Nodes: Optional[NodeList] + NextToken: Optional[NextToken] + + +NodeAggregatorList = List["NodeAggregator"] + + +class NodeAggregator(TypedDict, total=False): + AggregatorType: NodeAggregatorType + TypeName: NodeTypeName + AttributeName: NodeAttributeName + Aggregators: Optional[NodeAggregatorList] + + +class ListNodesSummaryRequest(ServiceRequest): + SyncName: Optional[ResourceDataSyncName] + Filters: Optional[NodeFilterList] + Aggregators: NodeAggregatorList + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +NodeSummary = Dict[AttributeName, AttributeValue] +NodeSummaryList = List[NodeSummary] + + +class ListNodesSummaryResult(TypedDict, total=False): + Summary: Optional[NodeSummaryList] + NextToken: Optional[NextToken] + + +OpsItemEventFilterValues = List[OpsItemEventFilterValue] + + +class OpsItemEventFilter(TypedDict, total=False): + Key: OpsItemEventFilterKey + Values: OpsItemEventFilterValues + Operator: OpsItemEventFilterOperator + + +OpsItemEventFilters = List[OpsItemEventFilter] + + +class ListOpsItemEventsRequest(ServiceRequest): + Filters: Optional[OpsItemEventFilters] + MaxResults: Optional[OpsItemEventMaxResults] + NextToken: Optional[String] + + +class OpsItemIdentity(TypedDict, total=False): + Arn: Optional[String] + + +class OpsItemEventSummary(TypedDict, total=False): + OpsItemId: Optional[String] + EventId: Optional[String] + Source: Optional[String] + DetailType: Optional[String] + Detail: Optional[String] + CreatedBy: Optional[OpsItemIdentity] + CreatedTime: Optional[DateTime] + + +OpsItemEventSummaries = List[OpsItemEventSummary] + + +class ListOpsItemEventsResponse(TypedDict, total=False): + NextToken: Optional[String] + Summaries: Optional[OpsItemEventSummaries] + + +OpsItemRelatedItemsFilterValues = List[OpsItemRelatedItemsFilterValue] + + +class OpsItemRelatedItemsFilter(TypedDict, total=False): + Key: OpsItemRelatedItemsFilterKey + Values: OpsItemRelatedItemsFilterValues + Operator: OpsItemRelatedItemsFilterOperator + + +OpsItemRelatedItemsFilters = List[OpsItemRelatedItemsFilter] + + +class ListOpsItemRelatedItemsRequest(ServiceRequest): + OpsItemId: Optional[OpsItemId] + Filters: Optional[OpsItemRelatedItemsFilters] + MaxResults: Optional[OpsItemRelatedItemsMaxResults] + NextToken: Optional[String] + + +class OpsItemRelatedItemSummary(TypedDict, total=False): + OpsItemId: Optional[OpsItemId] + AssociationId: Optional[OpsItemRelatedItemAssociationId] + ResourceType: Optional[OpsItemRelatedItemAssociationResourceType] + AssociationType: Optional[OpsItemRelatedItemAssociationType] + ResourceUri: Optional[OpsItemRelatedItemAssociationResourceUri] + CreatedBy: Optional[OpsItemIdentity] + CreatedTime: Optional[DateTime] + LastModifiedBy: Optional[OpsItemIdentity] + LastModifiedTime: Optional[DateTime] + + +OpsItemRelatedItemSummaries = List[OpsItemRelatedItemSummary] + + +class ListOpsItemRelatedItemsResponse(TypedDict, total=False): + NextToken: Optional[String] + Summaries: Optional[OpsItemRelatedItemSummaries] + + +OpsMetadataFilterValueList = List[OpsMetadataFilterValue] + + +class OpsMetadataFilter(TypedDict, total=False): + Key: OpsMetadataFilterKey + Values: OpsMetadataFilterValueList + + +OpsMetadataFilterList = List[OpsMetadataFilter] + + +class ListOpsMetadataRequest(ServiceRequest): + Filters: Optional[OpsMetadataFilterList] + MaxResults: Optional[ListOpsMetadataMaxResults] + NextToken: Optional[NextToken] + + +class OpsMetadata(TypedDict, total=False): + ResourceId: Optional[OpsMetadataResourceId] + OpsMetadataArn: Optional[OpsMetadataArn] + LastModifiedDate: Optional[DateTime] + LastModifiedUser: Optional[String] + CreationDate: Optional[DateTime] + + +OpsMetadataList = List[OpsMetadata] + + +class ListOpsMetadataResult(TypedDict, total=False): + OpsMetadataList: Optional[OpsMetadataList] + NextToken: Optional[NextToken] + + +class ListResourceComplianceSummariesRequest(ServiceRequest): + Filters: Optional[ComplianceStringFilterList] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ResourceComplianceSummaryItem(TypedDict, total=False): + ComplianceType: Optional[ComplianceTypeName] + ResourceType: Optional[ComplianceResourceType] + ResourceId: Optional[ComplianceResourceId] + Status: Optional[ComplianceStatus] + OverallSeverity: Optional[ComplianceSeverity] + ExecutionSummary: Optional[ComplianceExecutionSummary] + CompliantSummary: Optional[CompliantSummary] + NonCompliantSummary: Optional[NonCompliantSummary] + + +ResourceComplianceSummaryItemList = List[ResourceComplianceSummaryItem] + + +class ListResourceComplianceSummariesResult(TypedDict, total=False): + ResourceComplianceSummaryItems: Optional[ResourceComplianceSummaryItemList] + NextToken: Optional[NextToken] + + +class ListResourceDataSyncRequest(ServiceRequest): + SyncType: Optional[ResourceDataSyncType] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +ResourceDataSyncCreatedTime = datetime +ResourceDataSyncLastModifiedTime = datetime + + +class ResourceDataSyncSourceWithState(TypedDict, total=False): + SourceType: Optional[ResourceDataSyncSourceType] + AwsOrganizationsSource: Optional[ResourceDataSyncAwsOrganizationsSource] + SourceRegions: Optional[ResourceDataSyncSourceRegionList] + IncludeFutureRegions: Optional[ResourceDataSyncIncludeFutureRegions] + State: Optional[ResourceDataSyncState] + EnableAllOpsDataSources: Optional[ResourceDataSyncEnableAllOpsDataSources] + + +class ResourceDataSyncItem(TypedDict, total=False): + SyncName: Optional[ResourceDataSyncName] + SyncType: Optional[ResourceDataSyncType] + SyncSource: Optional[ResourceDataSyncSourceWithState] + S3Destination: Optional[ResourceDataSyncS3Destination] + LastSyncTime: Optional[LastResourceDataSyncTime] + LastSuccessfulSyncTime: Optional[LastSuccessfulResourceDataSyncTime] + SyncLastModifiedTime: Optional[ResourceDataSyncLastModifiedTime] + LastStatus: Optional[LastResourceDataSyncStatus] + SyncCreatedTime: Optional[ResourceDataSyncCreatedTime] + LastSyncStatusMessage: Optional[LastResourceDataSyncMessage] + + +ResourceDataSyncItemList = List[ResourceDataSyncItem] + + +class ListResourceDataSyncResult(TypedDict, total=False): + ResourceDataSyncItems: Optional[ResourceDataSyncItemList] + NextToken: Optional[NextToken] + + +class ListTagsForResourceRequest(ServiceRequest): + ResourceType: ResourceTypeForTagging + ResourceId: ResourceId + + +class ListTagsForResourceResult(TypedDict, total=False): + TagList: Optional[TagList] + + +MetadataKeysToDeleteList = List[MetadataKey] + + +class ModifyDocumentPermissionRequest(ServiceRequest): + Name: DocumentName + PermissionType: DocumentPermissionType + AccountIdsToAdd: Optional[AccountIdList] + AccountIdsToRemove: Optional[AccountIdList] + SharedDocumentVersion: Optional[SharedDocumentVersion] + + +class ModifyDocumentPermissionResponse(TypedDict, total=False): + pass + + +OpsItemOpsDataKeysList = List[String] + + +class PutComplianceItemsRequest(ServiceRequest): + ResourceId: ComplianceResourceId + ResourceType: ComplianceResourceType + ComplianceType: ComplianceTypeName + ExecutionSummary: ComplianceExecutionSummary + Items: ComplianceItemEntryList + ItemContentHash: Optional[ComplianceItemContentHash] + UploadType: Optional[ComplianceUploadType] + + +class PutComplianceItemsResult(TypedDict, total=False): + pass + + +class PutInventoryRequest(ServiceRequest): + InstanceId: InstanceId + Items: InventoryItemList + + +class PutInventoryResult(TypedDict, total=False): + Message: Optional[PutInventoryMessage] + + +class PutParameterRequest(ServiceRequest): + Name: PSParameterName + Description: Optional[ParameterDescription] + Value: PSParameterValue + Type: Optional[ParameterType] + KeyId: Optional[ParameterKeyId] + Overwrite: Optional[Boolean] + AllowedPattern: Optional[AllowedPattern] + Tags: Optional[TagList] + Tier: Optional[ParameterTier] + Policies: Optional[ParameterPolicies] + DataType: Optional[ParameterDataType] + + +class PutParameterResult(TypedDict, total=False): + Version: Optional[PSParameterVersion] + Tier: Optional[ParameterTier] + + +class PutResourcePolicyRequest(ServiceRequest): + ResourceArn: ResourceArnString + Policy: Policy + PolicyId: Optional[PolicyId] + PolicyHash: Optional[PolicyHash] + + +class PutResourcePolicyResponse(TypedDict, total=False): + PolicyId: Optional[PolicyId] + PolicyHash: Optional[PolicyHash] + + +class RegisterDefaultPatchBaselineRequest(ServiceRequest): + BaselineId: BaselineId + + +class RegisterDefaultPatchBaselineResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + + +class RegisterPatchBaselineForPatchGroupRequest(ServiceRequest): + BaselineId: BaselineId + PatchGroup: PatchGroup + + +class RegisterPatchBaselineForPatchGroupResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + PatchGroup: Optional[PatchGroup] + + +class RegisterTargetWithMaintenanceWindowRequest(ServiceRequest): + WindowId: MaintenanceWindowId + ResourceType: MaintenanceWindowResourceType + Targets: Targets + OwnerInformation: Optional[OwnerInformation] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + ClientToken: Optional[ClientToken] + + +class RegisterTargetWithMaintenanceWindowResult(TypedDict, total=False): + WindowTargetId: Optional[MaintenanceWindowTargetId] + + +class RegisterTaskWithMaintenanceWindowRequest(ServiceRequest): + WindowId: MaintenanceWindowId + Targets: Optional[Targets] + TaskArn: MaintenanceWindowTaskArn + ServiceRoleArn: Optional[ServiceRole] + TaskType: MaintenanceWindowTaskType + TaskParameters: Optional[MaintenanceWindowTaskParameters] + TaskInvocationParameters: Optional[MaintenanceWindowTaskInvocationParameters] + Priority: Optional[MaintenanceWindowTaskPriority] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + LoggingInfo: Optional[LoggingInfo] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + ClientToken: Optional[ClientToken] + CutoffBehavior: Optional[MaintenanceWindowTaskCutoffBehavior] + AlarmConfiguration: Optional[AlarmConfiguration] + + +class RegisterTaskWithMaintenanceWindowResult(TypedDict, total=False): + WindowTaskId: Optional[MaintenanceWindowTaskId] + + +class RemoveTagsFromResourceRequest(ServiceRequest): + ResourceType: ResourceTypeForTagging + ResourceId: ResourceId + TagKeys: KeyList + + +class RemoveTagsFromResourceResult(TypedDict, total=False): + pass + + +class ResetServiceSettingRequest(ServiceRequest): + SettingId: ServiceSettingId + + +class ResetServiceSettingResult(TypedDict, total=False): + ServiceSetting: Optional[ServiceSetting] + + +class ResumeSessionRequest(ServiceRequest): + SessionId: SessionId + + +class ResumeSessionResponse(TypedDict, total=False): + SessionId: Optional[SessionId] + TokenValue: Optional[TokenValue] + StreamUrl: Optional[StreamUrl] + + +class SendAutomationSignalRequest(ServiceRequest): + AutomationExecutionId: AutomationExecutionId + SignalType: SignalType + Payload: Optional[AutomationParameterMap] + + +class SendAutomationSignalResult(TypedDict, total=False): + pass + + +class SendCommandRequest(ServiceRequest): + InstanceIds: Optional[InstanceIdList] + Targets: Optional[Targets] + DocumentName: DocumentARN + DocumentVersion: Optional[DocumentVersion] + DocumentHash: Optional[DocumentHash] + DocumentHashType: Optional[DocumentHashType] + TimeoutSeconds: Optional[TimeoutSeconds] + Comment: Optional[Comment] + Parameters: Optional[Parameters] + OutputS3Region: Optional[S3Region] + OutputS3BucketName: Optional[S3BucketName] + OutputS3KeyPrefix: Optional[S3KeyPrefix] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + ServiceRoleArn: Optional[ServiceRole] + NotificationConfig: Optional[NotificationConfig] + CloudWatchOutputConfig: Optional[CloudWatchOutputConfig] + AlarmConfiguration: Optional[AlarmConfiguration] + + +class SendCommandResult(TypedDict, total=False): + Command: Optional[Command] + + +SessionManagerParameterValueList = List[SessionManagerParameterValue] +SessionManagerParameters = Dict[SessionManagerParameterName, SessionManagerParameterValueList] + + +class StartAccessRequestRequest(ServiceRequest): + Reason: String1to256 + Targets: Targets + Tags: Optional[TagList] + + +class StartAccessRequestResponse(TypedDict, total=False): + AccessRequestId: Optional[AccessRequestId] + + +class StartAssociationsOnceRequest(ServiceRequest): + AssociationIds: AssociationIdList + + +class StartAssociationsOnceResult(TypedDict, total=False): + pass + + +class StartAutomationExecutionRequest(ServiceRequest): + DocumentName: DocumentARN + DocumentVersion: Optional[DocumentVersion] + Parameters: Optional[AutomationParameterMap] + ClientToken: Optional[IdempotencyToken] + Mode: Optional[ExecutionMode] + TargetParameterName: Optional[AutomationParameterKey] + Targets: Optional[Targets] + TargetMaps: Optional[TargetMaps] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + TargetLocations: Optional[TargetLocations] + Tags: Optional[TagList] + AlarmConfiguration: Optional[AlarmConfiguration] + TargetLocationsURL: Optional[TargetLocationsURL] + + +class StartAutomationExecutionResult(TypedDict, total=False): + AutomationExecutionId: Optional[AutomationExecutionId] + + +class StartChangeRequestExecutionRequest(ServiceRequest): + ScheduledTime: Optional[DateTime] + DocumentName: DocumentARN + DocumentVersion: Optional[DocumentVersion] + Parameters: Optional[AutomationParameterMap] + ChangeRequestName: Optional[ChangeRequestName] + ClientToken: Optional[IdempotencyToken] + AutoApprove: Optional[Boolean] + Runbooks: Runbooks + Tags: Optional[TagList] + ScheduledEndTime: Optional[DateTime] + ChangeDetails: Optional[ChangeDetailsValue] + + +class StartChangeRequestExecutionResult(TypedDict, total=False): + AutomationExecutionId: Optional[AutomationExecutionId] + + +class StartExecutionPreviewRequest(ServiceRequest): + DocumentName: DocumentName + DocumentVersion: Optional[DocumentVersion] + ExecutionInputs: Optional[ExecutionInputs] + + +class StartExecutionPreviewResponse(TypedDict, total=False): + ExecutionPreviewId: Optional[ExecutionPreviewId] + + +class StartSessionRequest(ServiceRequest): + Target: SessionTarget + DocumentName: Optional[DocumentARN] + Reason: Optional[SessionReason] + Parameters: Optional[SessionManagerParameters] + + +class StartSessionResponse(TypedDict, total=False): + SessionId: Optional[SessionId] + TokenValue: Optional[TokenValue] + StreamUrl: Optional[StreamUrl] + + +class StopAutomationExecutionRequest(ServiceRequest): + AutomationExecutionId: AutomationExecutionId + Type: Optional[StopType] + + +class StopAutomationExecutionResult(TypedDict, total=False): + pass + + +class TerminateSessionRequest(ServiceRequest): + SessionId: SessionId + + +class TerminateSessionResponse(TypedDict, total=False): + SessionId: Optional[SessionId] + + +class UnlabelParameterVersionRequest(ServiceRequest): + Name: PSParameterName + ParameterVersion: PSParameterVersion + Labels: ParameterLabelList + + +class UnlabelParameterVersionResult(TypedDict, total=False): + RemovedLabels: Optional[ParameterLabelList] + InvalidLabels: Optional[ParameterLabelList] + + +class UpdateAssociationRequest(ServiceRequest): + AssociationId: AssociationId + Parameters: Optional[Parameters] + DocumentVersion: Optional[DocumentVersion] + ScheduleExpression: Optional[ScheduleExpression] + OutputLocation: Optional[InstanceAssociationOutputLocation] + Name: Optional[DocumentARN] + Targets: Optional[Targets] + AssociationName: Optional[AssociationName] + AssociationVersion: Optional[AssociationVersion] + AutomationTargetParameterName: Optional[AutomationTargetParameterName] + MaxErrors: Optional[MaxErrors] + MaxConcurrency: Optional[MaxConcurrency] + ComplianceSeverity: Optional[AssociationComplianceSeverity] + SyncCompliance: Optional[AssociationSyncCompliance] + ApplyOnlyAtCronInterval: Optional[ApplyOnlyAtCronInterval] + CalendarNames: Optional[CalendarNameOrARNList] + TargetLocations: Optional[TargetLocations] + ScheduleOffset: Optional[ScheduleOffset] + Duration: Optional[Duration] + TargetMaps: Optional[TargetMaps] + AlarmConfiguration: Optional[AlarmConfiguration] + + +class UpdateAssociationResult(TypedDict, total=False): + AssociationDescription: Optional[AssociationDescription] + + +class UpdateAssociationStatusRequest(ServiceRequest): + Name: DocumentARN + InstanceId: InstanceId + AssociationStatus: AssociationStatus + + +class UpdateAssociationStatusResult(TypedDict, total=False): + AssociationDescription: Optional[AssociationDescription] + + +class UpdateDocumentDefaultVersionRequest(ServiceRequest): + Name: DocumentName + DocumentVersion: DocumentVersionNumber + + +class UpdateDocumentDefaultVersionResult(TypedDict, total=False): + Description: Optional[DocumentDefaultVersionDescription] + + +class UpdateDocumentMetadataRequest(ServiceRequest): + Name: DocumentName + DocumentVersion: Optional[DocumentVersion] + DocumentReviews: DocumentReviews + + +class UpdateDocumentMetadataResponse(TypedDict, total=False): + pass + + +class UpdateDocumentRequest(ServiceRequest): + Content: DocumentContent + Attachments: Optional[AttachmentsSourceList] + Name: DocumentName + DisplayName: Optional[DocumentDisplayName] + VersionName: Optional[DocumentVersionName] + DocumentVersion: Optional[DocumentVersion] + DocumentFormat: Optional[DocumentFormat] + TargetType: Optional[TargetType] + + +class UpdateDocumentResult(TypedDict, total=False): + DocumentDescription: Optional[DocumentDescription] + + +class UpdateMaintenanceWindowRequest(ServiceRequest): + WindowId: MaintenanceWindowId + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + StartDate: Optional[MaintenanceWindowStringDateTime] + EndDate: Optional[MaintenanceWindowStringDateTime] + Schedule: Optional[MaintenanceWindowSchedule] + ScheduleTimezone: Optional[MaintenanceWindowTimezone] + ScheduleOffset: Optional[MaintenanceWindowOffset] + Duration: Optional[MaintenanceWindowDurationHours] + Cutoff: Optional[MaintenanceWindowCutoff] + AllowUnassociatedTargets: Optional[MaintenanceWindowAllowUnassociatedTargets] + Enabled: Optional[MaintenanceWindowEnabled] + Replace: Optional[Boolean] + + +class UpdateMaintenanceWindowResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + StartDate: Optional[MaintenanceWindowStringDateTime] + EndDate: Optional[MaintenanceWindowStringDateTime] + Schedule: Optional[MaintenanceWindowSchedule] + ScheduleTimezone: Optional[MaintenanceWindowTimezone] + ScheduleOffset: Optional[MaintenanceWindowOffset] + Duration: Optional[MaintenanceWindowDurationHours] + Cutoff: Optional[MaintenanceWindowCutoff] + AllowUnassociatedTargets: Optional[MaintenanceWindowAllowUnassociatedTargets] + Enabled: Optional[MaintenanceWindowEnabled] + + +class UpdateMaintenanceWindowTargetRequest(ServiceRequest): + WindowId: MaintenanceWindowId + WindowTargetId: MaintenanceWindowTargetId + Targets: Optional[Targets] + OwnerInformation: Optional[OwnerInformation] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + Replace: Optional[Boolean] + + +class UpdateMaintenanceWindowTargetResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowTargetId: Optional[MaintenanceWindowTargetId] + Targets: Optional[Targets] + OwnerInformation: Optional[OwnerInformation] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + + +class UpdateMaintenanceWindowTaskRequest(ServiceRequest): + WindowId: MaintenanceWindowId + WindowTaskId: MaintenanceWindowTaskId + Targets: Optional[Targets] + TaskArn: Optional[MaintenanceWindowTaskArn] + ServiceRoleArn: Optional[ServiceRole] + TaskParameters: Optional[MaintenanceWindowTaskParameters] + TaskInvocationParameters: Optional[MaintenanceWindowTaskInvocationParameters] + Priority: Optional[MaintenanceWindowTaskPriority] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + LoggingInfo: Optional[LoggingInfo] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + Replace: Optional[Boolean] + CutoffBehavior: Optional[MaintenanceWindowTaskCutoffBehavior] + AlarmConfiguration: Optional[AlarmConfiguration] + + +class UpdateMaintenanceWindowTaskResult(TypedDict, total=False): + WindowId: Optional[MaintenanceWindowId] + WindowTaskId: Optional[MaintenanceWindowTaskId] + Targets: Optional[Targets] + TaskArn: Optional[MaintenanceWindowTaskArn] + ServiceRoleArn: Optional[ServiceRole] + TaskParameters: Optional[MaintenanceWindowTaskParameters] + TaskInvocationParameters: Optional[MaintenanceWindowTaskInvocationParameters] + Priority: Optional[MaintenanceWindowTaskPriority] + MaxConcurrency: Optional[MaxConcurrency] + MaxErrors: Optional[MaxErrors] + LoggingInfo: Optional[LoggingInfo] + Name: Optional[MaintenanceWindowName] + Description: Optional[MaintenanceWindowDescription] + CutoffBehavior: Optional[MaintenanceWindowTaskCutoffBehavior] + AlarmConfiguration: Optional[AlarmConfiguration] + + +class UpdateManagedInstanceRoleRequest(ServiceRequest): + InstanceId: ManagedInstanceId + IamRole: IamRole + + +class UpdateManagedInstanceRoleResult(TypedDict, total=False): + pass + + +class UpdateOpsItemRequest(ServiceRequest): + Description: Optional[OpsItemDescription] + OperationalData: Optional[OpsItemOperationalData] + OperationalDataToDelete: Optional[OpsItemOpsDataKeysList] + Notifications: Optional[OpsItemNotifications] + Priority: Optional[OpsItemPriority] + RelatedOpsItems: Optional[RelatedOpsItems] + Status: Optional[OpsItemStatus] + OpsItemId: OpsItemId + Title: Optional[OpsItemTitle] + Category: Optional[OpsItemCategory] + Severity: Optional[OpsItemSeverity] + ActualStartTime: Optional[DateTime] + ActualEndTime: Optional[DateTime] + PlannedStartTime: Optional[DateTime] + PlannedEndTime: Optional[DateTime] + OpsItemArn: Optional[OpsItemArn] + + +class UpdateOpsItemResponse(TypedDict, total=False): + pass + + +class UpdateOpsMetadataRequest(ServiceRequest): + OpsMetadataArn: OpsMetadataArn + MetadataToUpdate: Optional[MetadataMap] + KeysToDelete: Optional[MetadataKeysToDeleteList] + + +class UpdateOpsMetadataResult(TypedDict, total=False): + OpsMetadataArn: Optional[OpsMetadataArn] + + +class UpdatePatchBaselineRequest(ServiceRequest): + BaselineId: BaselineId + Name: Optional[BaselineName] + GlobalFilters: Optional[PatchFilterGroup] + ApprovalRules: Optional[PatchRuleGroup] + ApprovedPatches: Optional[PatchIdList] + ApprovedPatchesComplianceLevel: Optional[PatchComplianceLevel] + ApprovedPatchesEnableNonSecurity: Optional[Boolean] + RejectedPatches: Optional[PatchIdList] + RejectedPatchesAction: Optional[PatchAction] + Description: Optional[BaselineDescription] + Sources: Optional[PatchSourceList] + AvailableSecurityUpdatesComplianceStatus: Optional[PatchComplianceStatus] + Replace: Optional[Boolean] + + +class UpdatePatchBaselineResult(TypedDict, total=False): + BaselineId: Optional[BaselineId] + Name: Optional[BaselineName] + OperatingSystem: Optional[OperatingSystem] + GlobalFilters: Optional[PatchFilterGroup] + ApprovalRules: Optional[PatchRuleGroup] + ApprovedPatches: Optional[PatchIdList] + ApprovedPatchesComplianceLevel: Optional[PatchComplianceLevel] + ApprovedPatchesEnableNonSecurity: Optional[Boolean] + RejectedPatches: Optional[PatchIdList] + RejectedPatchesAction: Optional[PatchAction] + CreatedDate: Optional[DateTime] + ModifiedDate: Optional[DateTime] + Description: Optional[BaselineDescription] + Sources: Optional[PatchSourceList] + AvailableSecurityUpdatesComplianceStatus: Optional[PatchComplianceStatus] + + +class UpdateResourceDataSyncRequest(ServiceRequest): + SyncName: ResourceDataSyncName + SyncType: ResourceDataSyncType + SyncSource: ResourceDataSyncSource + + +class UpdateResourceDataSyncResult(TypedDict, total=False): + pass + + +class UpdateServiceSettingRequest(ServiceRequest): + SettingId: ServiceSettingId + SettingValue: ServiceSettingValue + + +class UpdateServiceSettingResult(TypedDict, total=False): + pass + + +class SsmApi: + service = "ssm" + version = "2014-11-06" + + @handler("AddTagsToResource") + def add_tags_to_resource( + self, + context: RequestContext, + resource_type: ResourceTypeForTagging, + resource_id: ResourceId, + tags: TagList, + **kwargs, + ) -> AddTagsToResourceResult: + raise NotImplementedError + + @handler("AssociateOpsItemRelatedItem") + def associate_ops_item_related_item( + self, + context: RequestContext, + ops_item_id: OpsItemId, + association_type: OpsItemRelatedItemAssociationType, + resource_type: OpsItemRelatedItemAssociationResourceType, + resource_uri: OpsItemRelatedItemAssociationResourceUri, + **kwargs, + ) -> AssociateOpsItemRelatedItemResponse: + raise NotImplementedError + + @handler("CancelCommand") + def cancel_command( + self, + context: RequestContext, + command_id: CommandId, + instance_ids: InstanceIdList | None = None, + **kwargs, + ) -> CancelCommandResult: + raise NotImplementedError + + @handler("CancelMaintenanceWindowExecution") + def cancel_maintenance_window_execution( + self, context: RequestContext, window_execution_id: MaintenanceWindowExecutionId, **kwargs + ) -> CancelMaintenanceWindowExecutionResult: + raise NotImplementedError + + @handler("CreateActivation") + def create_activation( + self, + context: RequestContext, + iam_role: IamRole, + description: ActivationDescription | None = None, + default_instance_name: DefaultInstanceName | None = None, + registration_limit: RegistrationLimit | None = None, + expiration_date: ExpirationDate | None = None, + tags: TagList | None = None, + registration_metadata: RegistrationMetadataList | None = None, + **kwargs, + ) -> CreateActivationResult: + raise NotImplementedError + + @handler("CreateAssociation") + def create_association( + self, + context: RequestContext, + name: DocumentARN, + document_version: DocumentVersion | None = None, + instance_id: InstanceId | None = None, + parameters: Parameters | None = None, + targets: Targets | None = None, + schedule_expression: ScheduleExpression | None = None, + output_location: InstanceAssociationOutputLocation | None = None, + association_name: AssociationName | None = None, + automation_target_parameter_name: AutomationTargetParameterName | None = None, + max_errors: MaxErrors | None = None, + max_concurrency: MaxConcurrency | None = None, + compliance_severity: AssociationComplianceSeverity | None = None, + sync_compliance: AssociationSyncCompliance | None = None, + apply_only_at_cron_interval: ApplyOnlyAtCronInterval | None = None, + calendar_names: CalendarNameOrARNList | None = None, + target_locations: TargetLocations | None = None, + schedule_offset: ScheduleOffset | None = None, + duration: Duration | None = None, + target_maps: TargetMaps | None = None, + tags: TagList | None = None, + alarm_configuration: AlarmConfiguration | None = None, + **kwargs, + ) -> CreateAssociationResult: + raise NotImplementedError + + @handler("CreateAssociationBatch") + def create_association_batch( + self, context: RequestContext, entries: CreateAssociationBatchRequestEntries, **kwargs + ) -> CreateAssociationBatchResult: + raise NotImplementedError + + @handler("CreateDocument") + def create_document( + self, + context: RequestContext, + content: DocumentContent, + name: DocumentName, + requires: DocumentRequiresList | None = None, + attachments: AttachmentsSourceList | None = None, + display_name: DocumentDisplayName | None = None, + version_name: DocumentVersionName | None = None, + document_type: DocumentType | None = None, + document_format: DocumentFormat | None = None, + target_type: TargetType | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateDocumentResult: + raise NotImplementedError + + @handler("CreateMaintenanceWindow") + def create_maintenance_window( + self, + context: RequestContext, + name: MaintenanceWindowName, + schedule: MaintenanceWindowSchedule, + duration: MaintenanceWindowDurationHours, + cutoff: MaintenanceWindowCutoff, + allow_unassociated_targets: MaintenanceWindowAllowUnassociatedTargets, + description: MaintenanceWindowDescription | None = None, + start_date: MaintenanceWindowStringDateTime | None = None, + end_date: MaintenanceWindowStringDateTime | None = None, + schedule_timezone: MaintenanceWindowTimezone | None = None, + schedule_offset: MaintenanceWindowOffset | None = None, + client_token: ClientToken | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateMaintenanceWindowResult: + raise NotImplementedError + + @handler("CreateOpsItem") + def create_ops_item( + self, + context: RequestContext, + description: OpsItemDescription, + source: OpsItemSource, + title: OpsItemTitle, + ops_item_type: OpsItemType | None = None, + operational_data: OpsItemOperationalData | None = None, + notifications: OpsItemNotifications | None = None, + priority: OpsItemPriority | None = None, + related_ops_items: RelatedOpsItems | None = None, + tags: TagList | None = None, + category: OpsItemCategory | None = None, + severity: OpsItemSeverity | None = None, + actual_start_time: DateTime | None = None, + actual_end_time: DateTime | None = None, + planned_start_time: DateTime | None = None, + planned_end_time: DateTime | None = None, + account_id: OpsItemAccountId | None = None, + **kwargs, + ) -> CreateOpsItemResponse: + raise NotImplementedError + + @handler("CreateOpsMetadata") + def create_ops_metadata( + self, + context: RequestContext, + resource_id: OpsMetadataResourceId, + metadata: MetadataMap | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreateOpsMetadataResult: + raise NotImplementedError + + @handler("CreatePatchBaseline") + def create_patch_baseline( + self, + context: RequestContext, + name: BaselineName, + operating_system: OperatingSystem | None = None, + global_filters: PatchFilterGroup | None = None, + approval_rules: PatchRuleGroup | None = None, + approved_patches: PatchIdList | None = None, + approved_patches_compliance_level: PatchComplianceLevel | None = None, + approved_patches_enable_non_security: Boolean | None = None, + rejected_patches: PatchIdList | None = None, + rejected_patches_action: PatchAction | None = None, + description: BaselineDescription | None = None, + sources: PatchSourceList | None = None, + available_security_updates_compliance_status: PatchComplianceStatus | None = None, + client_token: ClientToken | None = None, + tags: TagList | None = None, + **kwargs, + ) -> CreatePatchBaselineResult: + raise NotImplementedError + + @handler("CreateResourceDataSync") + def create_resource_data_sync( + self, + context: RequestContext, + sync_name: ResourceDataSyncName, + s3_destination: ResourceDataSyncS3Destination | None = None, + sync_type: ResourceDataSyncType | None = None, + sync_source: ResourceDataSyncSource | None = None, + **kwargs, + ) -> CreateResourceDataSyncResult: + raise NotImplementedError + + @handler("DeleteActivation") + def delete_activation( + self, context: RequestContext, activation_id: ActivationId, **kwargs + ) -> DeleteActivationResult: + raise NotImplementedError + + @handler("DeleteAssociation") + def delete_association( + self, + context: RequestContext, + name: DocumentARN | None = None, + instance_id: InstanceId | None = None, + association_id: AssociationId | None = None, + **kwargs, + ) -> DeleteAssociationResult: + raise NotImplementedError + + @handler("DeleteDocument") + def delete_document( + self, + context: RequestContext, + name: DocumentName, + document_version: DocumentVersion | None = None, + version_name: DocumentVersionName | None = None, + force: Boolean | None = None, + **kwargs, + ) -> DeleteDocumentResult: + raise NotImplementedError + + @handler("DeleteInventory") + def delete_inventory( + self, + context: RequestContext, + type_name: InventoryItemTypeName, + schema_delete_option: InventorySchemaDeleteOption | None = None, + dry_run: DryRun | None = None, + client_token: UUID | None = None, + **kwargs, + ) -> DeleteInventoryResult: + raise NotImplementedError + + @handler("DeleteMaintenanceWindow") + def delete_maintenance_window( + self, context: RequestContext, window_id: MaintenanceWindowId, **kwargs + ) -> DeleteMaintenanceWindowResult: + raise NotImplementedError + + @handler("DeleteOpsItem") + def delete_ops_item( + self, context: RequestContext, ops_item_id: OpsItemId, **kwargs + ) -> DeleteOpsItemResponse: + raise NotImplementedError + + @handler("DeleteOpsMetadata") + def delete_ops_metadata( + self, context: RequestContext, ops_metadata_arn: OpsMetadataArn, **kwargs + ) -> DeleteOpsMetadataResult: + raise NotImplementedError + + @handler("DeleteParameter") + def delete_parameter( + self, context: RequestContext, name: PSParameterName, **kwargs + ) -> DeleteParameterResult: + raise NotImplementedError + + @handler("DeleteParameters") + def delete_parameters( + self, context: RequestContext, names: ParameterNameList, **kwargs + ) -> DeleteParametersResult: + raise NotImplementedError + + @handler("DeletePatchBaseline") + def delete_patch_baseline( + self, context: RequestContext, baseline_id: BaselineId, **kwargs + ) -> DeletePatchBaselineResult: + raise NotImplementedError + + @handler("DeleteResourceDataSync") + def delete_resource_data_sync( + self, + context: RequestContext, + sync_name: ResourceDataSyncName, + sync_type: ResourceDataSyncType | None = None, + **kwargs, + ) -> DeleteResourceDataSyncResult: + raise NotImplementedError + + @handler("DeleteResourcePolicy") + def delete_resource_policy( + self, + context: RequestContext, + resource_arn: ResourceArnString, + policy_id: PolicyId, + policy_hash: PolicyHash, + **kwargs, + ) -> DeleteResourcePolicyResponse: + raise NotImplementedError + + @handler("DeregisterManagedInstance") + def deregister_managed_instance( + self, context: RequestContext, instance_id: ManagedInstanceId, **kwargs + ) -> DeregisterManagedInstanceResult: + raise NotImplementedError + + @handler("DeregisterPatchBaselineForPatchGroup") + def deregister_patch_baseline_for_patch_group( + self, context: RequestContext, baseline_id: BaselineId, patch_group: PatchGroup, **kwargs + ) -> DeregisterPatchBaselineForPatchGroupResult: + raise NotImplementedError + + @handler("DeregisterTargetFromMaintenanceWindow") + def deregister_target_from_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + window_target_id: MaintenanceWindowTargetId, + safe: Boolean | None = None, + **kwargs, + ) -> DeregisterTargetFromMaintenanceWindowResult: + raise NotImplementedError + + @handler("DeregisterTaskFromMaintenanceWindow") + def deregister_task_from_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + window_task_id: MaintenanceWindowTaskId, + **kwargs, + ) -> DeregisterTaskFromMaintenanceWindowResult: + raise NotImplementedError + + @handler("DescribeActivations") + def describe_activations( + self, + context: RequestContext, + filters: DescribeActivationsFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeActivationsResult: + raise NotImplementedError + + @handler("DescribeAssociation") + def describe_association( + self, + context: RequestContext, + name: DocumentARN | None = None, + instance_id: InstanceId | None = None, + association_id: AssociationId | None = None, + association_version: AssociationVersion | None = None, + **kwargs, + ) -> DescribeAssociationResult: + raise NotImplementedError + + @handler("DescribeAssociationExecutionTargets") + def describe_association_execution_targets( + self, + context: RequestContext, + association_id: AssociationId, + execution_id: AssociationExecutionId, + filters: AssociationExecutionTargetsFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAssociationExecutionTargetsResult: + raise NotImplementedError + + @handler("DescribeAssociationExecutions") + def describe_association_executions( + self, + context: RequestContext, + association_id: AssociationId, + filters: AssociationExecutionFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAssociationExecutionsResult: + raise NotImplementedError + + @handler("DescribeAutomationExecutions") + def describe_automation_executions( + self, + context: RequestContext, + filters: AutomationExecutionFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAutomationExecutionsResult: + raise NotImplementedError + + @handler("DescribeAutomationStepExecutions") + def describe_automation_step_executions( + self, + context: RequestContext, + automation_execution_id: AutomationExecutionId, + filters: StepExecutionFilterList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + reverse_order: Boolean | None = None, + **kwargs, + ) -> DescribeAutomationStepExecutionsResult: + raise NotImplementedError + + @handler("DescribeAvailablePatches") + def describe_available_patches( + self, + context: RequestContext, + filters: PatchOrchestratorFilterList | None = None, + max_results: PatchBaselineMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeAvailablePatchesResult: + raise NotImplementedError + + @handler("DescribeDocument") + def describe_document( + self, + context: RequestContext, + name: DocumentARN, + document_version: DocumentVersion | None = None, + version_name: DocumentVersionName | None = None, + **kwargs, + ) -> DescribeDocumentResult: + raise NotImplementedError + + @handler("DescribeDocumentPermission") + def describe_document_permission( + self, + context: RequestContext, + name: DocumentName, + permission_type: DocumentPermissionType, + max_results: DocumentPermissionMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeDocumentPermissionResponse: + raise NotImplementedError + + @handler("DescribeEffectiveInstanceAssociations") + def describe_effective_instance_associations( + self, + context: RequestContext, + instance_id: InstanceId, + max_results: EffectiveInstanceAssociationMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeEffectiveInstanceAssociationsResult: + raise NotImplementedError + + @handler("DescribeEffectivePatchesForPatchBaseline") + def describe_effective_patches_for_patch_baseline( + self, + context: RequestContext, + baseline_id: BaselineId, + max_results: PatchBaselineMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeEffectivePatchesForPatchBaselineResult: + raise NotImplementedError + + @handler("DescribeInstanceAssociationsStatus") + def describe_instance_associations_status( + self, + context: RequestContext, + instance_id: InstanceId, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeInstanceAssociationsStatusResult: + raise NotImplementedError + + @handler("DescribeInstanceInformation") + def describe_instance_information( + self, + context: RequestContext, + instance_information_filter_list: InstanceInformationFilterList | None = None, + filters: InstanceInformationStringFilterList | None = None, + max_results: MaxResultsEC2Compatible | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeInstanceInformationResult: + raise NotImplementedError + + @handler("DescribeInstancePatchStates") + def describe_instance_patch_states( + self, + context: RequestContext, + instance_ids: InstanceIdList, + next_token: NextToken | None = None, + max_results: PatchComplianceMaxResults | None = None, + **kwargs, + ) -> DescribeInstancePatchStatesResult: + raise NotImplementedError + + @handler("DescribeInstancePatchStatesForPatchGroup") + def describe_instance_patch_states_for_patch_group( + self, + context: RequestContext, + patch_group: PatchGroup, + filters: InstancePatchStateFilterList | None = None, + next_token: NextToken | None = None, + max_results: PatchComplianceMaxResults | None = None, + **kwargs, + ) -> DescribeInstancePatchStatesForPatchGroupResult: + raise NotImplementedError + + @handler("DescribeInstancePatches") + def describe_instance_patches( + self, + context: RequestContext, + instance_id: InstanceId, + filters: PatchOrchestratorFilterList | None = None, + next_token: NextToken | None = None, + max_results: PatchComplianceMaxResults | None = None, + **kwargs, + ) -> DescribeInstancePatchesResult: + raise NotImplementedError + + @handler("DescribeInstanceProperties") + def describe_instance_properties( + self, + context: RequestContext, + instance_property_filter_list: InstancePropertyFilterList | None = None, + filters_with_operator: InstancePropertyStringFilterList | None = None, + max_results: DescribeInstancePropertiesMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeInstancePropertiesResult: + raise NotImplementedError + + @handler("DescribeInventoryDeletions") + def describe_inventory_deletions( + self, + context: RequestContext, + deletion_id: UUID | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> DescribeInventoryDeletionsResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindowExecutionTaskInvocations") + def describe_maintenance_window_execution_task_invocations( + self, + context: RequestContext, + window_execution_id: MaintenanceWindowExecutionId, + task_id: MaintenanceWindowExecutionTaskId, + filters: MaintenanceWindowFilterList | None = None, + max_results: MaintenanceWindowMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowExecutionTaskInvocationsResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindowExecutionTasks") + def describe_maintenance_window_execution_tasks( + self, + context: RequestContext, + window_execution_id: MaintenanceWindowExecutionId, + filters: MaintenanceWindowFilterList | None = None, + max_results: MaintenanceWindowMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowExecutionTasksResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindowExecutions") + def describe_maintenance_window_executions( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + filters: MaintenanceWindowFilterList | None = None, + max_results: MaintenanceWindowMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowExecutionsResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindowSchedule") + def describe_maintenance_window_schedule( + self, + context: RequestContext, + window_id: MaintenanceWindowId | None = None, + targets: Targets | None = None, + resource_type: MaintenanceWindowResourceType | None = None, + filters: PatchOrchestratorFilterList | None = None, + max_results: MaintenanceWindowSearchMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowScheduleResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindowTargets") + def describe_maintenance_window_targets( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + filters: MaintenanceWindowFilterList | None = None, + max_results: MaintenanceWindowMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowTargetsResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindowTasks") + def describe_maintenance_window_tasks( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + filters: MaintenanceWindowFilterList | None = None, + max_results: MaintenanceWindowMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowTasksResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindows") + def describe_maintenance_windows( + self, + context: RequestContext, + filters: MaintenanceWindowFilterList | None = None, + max_results: MaintenanceWindowMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowsResult: + raise NotImplementedError + + @handler("DescribeMaintenanceWindowsForTarget") + def describe_maintenance_windows_for_target( + self, + context: RequestContext, + targets: Targets, + resource_type: MaintenanceWindowResourceType, + max_results: MaintenanceWindowSearchMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribeMaintenanceWindowsForTargetResult: + raise NotImplementedError + + @handler("DescribeOpsItems") + def describe_ops_items( + self, + context: RequestContext, + ops_item_filters: OpsItemFilters | None = None, + max_results: OpsItemMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> DescribeOpsItemsResponse: + raise NotImplementedError + + @handler("DescribeParameters") + def describe_parameters( + self, + context: RequestContext, + filters: ParametersFilterList | None = None, + parameter_filters: ParameterStringFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + shared: Boolean | None = None, + **kwargs, + ) -> DescribeParametersResult: + raise NotImplementedError + + @handler("DescribePatchBaselines") + def describe_patch_baselines( + self, + context: RequestContext, + filters: PatchOrchestratorFilterList | None = None, + max_results: PatchBaselineMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribePatchBaselinesResult: + raise NotImplementedError + + @handler("DescribePatchGroupState") + def describe_patch_group_state( + self, context: RequestContext, patch_group: PatchGroup, **kwargs + ) -> DescribePatchGroupStateResult: + raise NotImplementedError + + @handler("DescribePatchGroups") + def describe_patch_groups( + self, + context: RequestContext, + max_results: PatchBaselineMaxResults | None = None, + filters: PatchOrchestratorFilterList | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribePatchGroupsResult: + raise NotImplementedError + + @handler("DescribePatchProperties") + def describe_patch_properties( + self, + context: RequestContext, + operating_system: OperatingSystem, + property: PatchProperty, + patch_set: PatchSet | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> DescribePatchPropertiesResult: + raise NotImplementedError + + @handler("DescribeSessions") + def describe_sessions( + self, + context: RequestContext, + state: SessionState, + max_results: SessionMaxResults | None = None, + next_token: NextToken | None = None, + filters: SessionFilterList | None = None, + **kwargs, + ) -> DescribeSessionsResponse: + raise NotImplementedError + + @handler("DisassociateOpsItemRelatedItem") + def disassociate_ops_item_related_item( + self, + context: RequestContext, + ops_item_id: OpsItemId, + association_id: OpsItemRelatedItemAssociationId, + **kwargs, + ) -> DisassociateOpsItemRelatedItemResponse: + raise NotImplementedError + + @handler("GetAccessToken") + def get_access_token( + self, context: RequestContext, access_request_id: AccessRequestId, **kwargs + ) -> GetAccessTokenResponse: + raise NotImplementedError + + @handler("GetAutomationExecution") + def get_automation_execution( + self, context: RequestContext, automation_execution_id: AutomationExecutionId, **kwargs + ) -> GetAutomationExecutionResult: + raise NotImplementedError + + @handler("GetCalendarState") + def get_calendar_state( + self, + context: RequestContext, + calendar_names: CalendarNameOrARNList, + at_time: ISO8601String | None = None, + **kwargs, + ) -> GetCalendarStateResponse: + raise NotImplementedError + + @handler("GetCommandInvocation") + def get_command_invocation( + self, + context: RequestContext, + command_id: CommandId, + instance_id: InstanceId, + plugin_name: CommandPluginName | None = None, + **kwargs, + ) -> GetCommandInvocationResult: + raise NotImplementedError + + @handler("GetConnectionStatus") + def get_connection_status( + self, context: RequestContext, target: SessionTarget, **kwargs + ) -> GetConnectionStatusResponse: + raise NotImplementedError + + @handler("GetDefaultPatchBaseline") + def get_default_patch_baseline( + self, context: RequestContext, operating_system: OperatingSystem | None = None, **kwargs + ) -> GetDefaultPatchBaselineResult: + raise NotImplementedError + + @handler("GetDeployablePatchSnapshotForInstance") + def get_deployable_patch_snapshot_for_instance( + self, + context: RequestContext, + instance_id: InstanceId, + snapshot_id: SnapshotId, + baseline_override: BaselineOverride | None = None, + **kwargs, + ) -> GetDeployablePatchSnapshotForInstanceResult: + raise NotImplementedError + + @handler("GetDocument") + def get_document( + self, + context: RequestContext, + name: DocumentARN, + version_name: DocumentVersionName | None = None, + document_version: DocumentVersion | None = None, + document_format: DocumentFormat | None = None, + **kwargs, + ) -> GetDocumentResult: + raise NotImplementedError + + @handler("GetExecutionPreview") + def get_execution_preview( + self, context: RequestContext, execution_preview_id: ExecutionPreviewId, **kwargs + ) -> GetExecutionPreviewResponse: + raise NotImplementedError + + @handler("GetInventory") + def get_inventory( + self, + context: RequestContext, + filters: InventoryFilterList | None = None, + aggregators: InventoryAggregatorList | None = None, + result_attributes: ResultAttributeList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> GetInventoryResult: + raise NotImplementedError + + @handler("GetInventorySchema") + def get_inventory_schema( + self, + context: RequestContext, + type_name: InventoryItemTypeNameFilter | None = None, + next_token: NextToken | None = None, + max_results: GetInventorySchemaMaxResults | None = None, + aggregator: AggregatorSchemaOnly | None = None, + sub_type: IsSubTypeSchema | None = None, + **kwargs, + ) -> GetInventorySchemaResult: + raise NotImplementedError + + @handler("GetMaintenanceWindow") + def get_maintenance_window( + self, context: RequestContext, window_id: MaintenanceWindowId, **kwargs + ) -> GetMaintenanceWindowResult: + raise NotImplementedError + + @handler("GetMaintenanceWindowExecution") + def get_maintenance_window_execution( + self, context: RequestContext, window_execution_id: MaintenanceWindowExecutionId, **kwargs + ) -> GetMaintenanceWindowExecutionResult: + raise NotImplementedError + + @handler("GetMaintenanceWindowExecutionTask") + def get_maintenance_window_execution_task( + self, + context: RequestContext, + window_execution_id: MaintenanceWindowExecutionId, + task_id: MaintenanceWindowExecutionTaskId, + **kwargs, + ) -> GetMaintenanceWindowExecutionTaskResult: + raise NotImplementedError + + @handler("GetMaintenanceWindowExecutionTaskInvocation") + def get_maintenance_window_execution_task_invocation( + self, + context: RequestContext, + window_execution_id: MaintenanceWindowExecutionId, + task_id: MaintenanceWindowExecutionTaskId, + invocation_id: MaintenanceWindowExecutionTaskInvocationId, + **kwargs, + ) -> GetMaintenanceWindowExecutionTaskInvocationResult: + raise NotImplementedError + + @handler("GetMaintenanceWindowTask") + def get_maintenance_window_task( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + window_task_id: MaintenanceWindowTaskId, + **kwargs, + ) -> GetMaintenanceWindowTaskResult: + raise NotImplementedError + + @handler("GetOpsItem") + def get_ops_item( + self, + context: RequestContext, + ops_item_id: OpsItemId, + ops_item_arn: OpsItemArn | None = None, + **kwargs, + ) -> GetOpsItemResponse: + raise NotImplementedError + + @handler("GetOpsMetadata") + def get_ops_metadata( + self, + context: RequestContext, + ops_metadata_arn: OpsMetadataArn, + max_results: GetOpsMetadataMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetOpsMetadataResult: + raise NotImplementedError + + @handler("GetOpsSummary") + def get_ops_summary( + self, + context: RequestContext, + sync_name: ResourceDataSyncName | None = None, + filters: OpsFilterList | None = None, + aggregators: OpsAggregatorList | None = None, + result_attributes: OpsResultAttributeList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> GetOpsSummaryResult: + raise NotImplementedError + + @handler("GetParameter") + def get_parameter( + self, + context: RequestContext, + name: PSParameterName, + with_decryption: Boolean | None = None, + **kwargs, + ) -> GetParameterResult: + raise NotImplementedError + + @handler("GetParameterHistory") + def get_parameter_history( + self, + context: RequestContext, + name: PSParameterName, + with_decryption: Boolean | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetParameterHistoryResult: + raise NotImplementedError + + @handler("GetParameters") + def get_parameters( + self, + context: RequestContext, + names: ParameterNameList, + with_decryption: Boolean | None = None, + **kwargs, + ) -> GetParametersResult: + raise NotImplementedError + + @handler("GetParametersByPath") + def get_parameters_by_path( + self, + context: RequestContext, + path: PSParameterName, + recursive: Boolean | None = None, + parameter_filters: ParameterStringFilterList | None = None, + with_decryption: Boolean | None = None, + max_results: GetParametersByPathMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> GetParametersByPathResult: + raise NotImplementedError + + @handler("GetPatchBaseline") + def get_patch_baseline( + self, context: RequestContext, baseline_id: BaselineId, **kwargs + ) -> GetPatchBaselineResult: + raise NotImplementedError + + @handler("GetPatchBaselineForPatchGroup") + def get_patch_baseline_for_patch_group( + self, + context: RequestContext, + patch_group: PatchGroup, + operating_system: OperatingSystem | None = None, + **kwargs, + ) -> GetPatchBaselineForPatchGroupResult: + raise NotImplementedError + + @handler("GetResourcePolicies") + def get_resource_policies( + self, + context: RequestContext, + resource_arn: ResourceArnString, + next_token: String | None = None, + max_results: ResourcePolicyMaxResults | None = None, + **kwargs, + ) -> GetResourcePoliciesResponse: + raise NotImplementedError + + @handler("GetServiceSetting") + def get_service_setting( + self, context: RequestContext, setting_id: ServiceSettingId, **kwargs + ) -> GetServiceSettingResult: + raise NotImplementedError + + @handler("LabelParameterVersion") + def label_parameter_version( + self, + context: RequestContext, + name: PSParameterName, + labels: ParameterLabelList, + parameter_version: PSParameterVersion | None = None, + **kwargs, + ) -> LabelParameterVersionResult: + raise NotImplementedError + + @handler("ListAssociationVersions") + def list_association_versions( + self, + context: RequestContext, + association_id: AssociationId, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListAssociationVersionsResult: + raise NotImplementedError + + @handler("ListAssociations") + def list_associations( + self, + context: RequestContext, + association_filter_list: AssociationFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListAssociationsResult: + raise NotImplementedError + + @handler("ListCommandInvocations") + def list_command_invocations( + self, + context: RequestContext, + command_id: CommandId | None = None, + instance_id: InstanceId | None = None, + max_results: CommandMaxResults | None = None, + next_token: NextToken | None = None, + filters: CommandFilterList | None = None, + details: Boolean | None = None, + **kwargs, + ) -> ListCommandInvocationsResult: + raise NotImplementedError + + @handler("ListCommands") + def list_commands( + self, + context: RequestContext, + command_id: CommandId | None = None, + instance_id: InstanceId | None = None, + max_results: CommandMaxResults | None = None, + next_token: NextToken | None = None, + filters: CommandFilterList | None = None, + **kwargs, + ) -> ListCommandsResult: + raise NotImplementedError + + @handler("ListComplianceItems") + def list_compliance_items( + self, + context: RequestContext, + filters: ComplianceStringFilterList | None = None, + resource_ids: ComplianceResourceIdList | None = None, + resource_types: ComplianceResourceTypeList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListComplianceItemsResult: + raise NotImplementedError + + @handler("ListComplianceSummaries") + def list_compliance_summaries( + self, + context: RequestContext, + filters: ComplianceStringFilterList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListComplianceSummariesResult: + raise NotImplementedError + + @handler("ListDocumentMetadataHistory") + def list_document_metadata_history( + self, + context: RequestContext, + name: DocumentName, + metadata: DocumentMetadataEnum, + document_version: DocumentVersion | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListDocumentMetadataHistoryResponse: + raise NotImplementedError + + @handler("ListDocumentVersions") + def list_document_versions( + self, + context: RequestContext, + name: DocumentARN, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListDocumentVersionsResult: + raise NotImplementedError + + @handler("ListDocuments") + def list_documents( + self, + context: RequestContext, + document_filter_list: DocumentFilterList | None = None, + filters: DocumentKeyValuesFilterList | None = None, + max_results: MaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListDocumentsResult: + raise NotImplementedError + + @handler("ListInventoryEntries") + def list_inventory_entries( + self, + context: RequestContext, + instance_id: InstanceId, + type_name: InventoryItemTypeName, + filters: InventoryFilterList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListInventoryEntriesResult: + raise NotImplementedError + + @handler("ListNodes") + def list_nodes( + self, + context: RequestContext, + sync_name: ResourceDataSyncName | None = None, + filters: NodeFilterList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListNodesResult: + raise NotImplementedError + + @handler("ListNodesSummary") + def list_nodes_summary( + self, + context: RequestContext, + aggregators: NodeAggregatorList, + sync_name: ResourceDataSyncName | None = None, + filters: NodeFilterList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListNodesSummaryResult: + raise NotImplementedError + + @handler("ListOpsItemEvents") + def list_ops_item_events( + self, + context: RequestContext, + filters: OpsItemEventFilters | None = None, + max_results: OpsItemEventMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> ListOpsItemEventsResponse: + raise NotImplementedError + + @handler("ListOpsItemRelatedItems") + def list_ops_item_related_items( + self, + context: RequestContext, + ops_item_id: OpsItemId | None = None, + filters: OpsItemRelatedItemsFilters | None = None, + max_results: OpsItemRelatedItemsMaxResults | None = None, + next_token: String | None = None, + **kwargs, + ) -> ListOpsItemRelatedItemsResponse: + raise NotImplementedError + + @handler("ListOpsMetadata") + def list_ops_metadata( + self, + context: RequestContext, + filters: OpsMetadataFilterList | None = None, + max_results: ListOpsMetadataMaxResults | None = None, + next_token: NextToken | None = None, + **kwargs, + ) -> ListOpsMetadataResult: + raise NotImplementedError + + @handler("ListResourceComplianceSummaries") + def list_resource_compliance_summaries( + self, + context: RequestContext, + filters: ComplianceStringFilterList | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListResourceComplianceSummariesResult: + raise NotImplementedError + + @handler("ListResourceDataSync") + def list_resource_data_sync( + self, + context: RequestContext, + sync_type: ResourceDataSyncType | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListResourceDataSyncResult: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, + context: RequestContext, + resource_type: ResourceTypeForTagging, + resource_id: ResourceId, + **kwargs, + ) -> ListTagsForResourceResult: + raise NotImplementedError + + @handler("ModifyDocumentPermission") + def modify_document_permission( + self, + context: RequestContext, + name: DocumentName, + permission_type: DocumentPermissionType, + account_ids_to_add: AccountIdList | None = None, + account_ids_to_remove: AccountIdList | None = None, + shared_document_version: SharedDocumentVersion | None = None, + **kwargs, + ) -> ModifyDocumentPermissionResponse: + raise NotImplementedError + + @handler("PutComplianceItems") + def put_compliance_items( + self, + context: RequestContext, + resource_id: ComplianceResourceId, + resource_type: ComplianceResourceType, + compliance_type: ComplianceTypeName, + execution_summary: ComplianceExecutionSummary, + items: ComplianceItemEntryList, + item_content_hash: ComplianceItemContentHash | None = None, + upload_type: ComplianceUploadType | None = None, + **kwargs, + ) -> PutComplianceItemsResult: + raise NotImplementedError + + @handler("PutInventory") + def put_inventory( + self, context: RequestContext, instance_id: InstanceId, items: InventoryItemList, **kwargs + ) -> PutInventoryResult: + raise NotImplementedError + + @handler("PutParameter", expand=False) + def put_parameter( + self, context: RequestContext, request: PutParameterRequest, **kwargs + ) -> PutParameterResult: + raise NotImplementedError + + @handler("PutResourcePolicy") + def put_resource_policy( + self, + context: RequestContext, + resource_arn: ResourceArnString, + policy: Policy, + policy_id: PolicyId | None = None, + policy_hash: PolicyHash | None = None, + **kwargs, + ) -> PutResourcePolicyResponse: + raise NotImplementedError + + @handler("RegisterDefaultPatchBaseline") + def register_default_patch_baseline( + self, context: RequestContext, baseline_id: BaselineId, **kwargs + ) -> RegisterDefaultPatchBaselineResult: + raise NotImplementedError + + @handler("RegisterPatchBaselineForPatchGroup") + def register_patch_baseline_for_patch_group( + self, context: RequestContext, baseline_id: BaselineId, patch_group: PatchGroup, **kwargs + ) -> RegisterPatchBaselineForPatchGroupResult: + raise NotImplementedError + + @handler("RegisterTargetWithMaintenanceWindow") + def register_target_with_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + resource_type: MaintenanceWindowResourceType, + targets: Targets, + owner_information: OwnerInformation | None = None, + name: MaintenanceWindowName | None = None, + description: MaintenanceWindowDescription | None = None, + client_token: ClientToken | None = None, + **kwargs, + ) -> RegisterTargetWithMaintenanceWindowResult: + raise NotImplementedError + + @handler("RegisterTaskWithMaintenanceWindow") + def register_task_with_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + task_arn: MaintenanceWindowTaskArn, + task_type: MaintenanceWindowTaskType, + targets: Targets | None = None, + service_role_arn: ServiceRole | None = None, + task_parameters: MaintenanceWindowTaskParameters | None = None, + task_invocation_parameters: MaintenanceWindowTaskInvocationParameters | None = None, + priority: MaintenanceWindowTaskPriority | None = None, + max_concurrency: MaxConcurrency | None = None, + max_errors: MaxErrors | None = None, + logging_info: LoggingInfo | None = None, + name: MaintenanceWindowName | None = None, + description: MaintenanceWindowDescription | None = None, + client_token: ClientToken | None = None, + cutoff_behavior: MaintenanceWindowTaskCutoffBehavior | None = None, + alarm_configuration: AlarmConfiguration | None = None, + **kwargs, + ) -> RegisterTaskWithMaintenanceWindowResult: + raise NotImplementedError + + @handler("RemoveTagsFromResource") + def remove_tags_from_resource( + self, + context: RequestContext, + resource_type: ResourceTypeForTagging, + resource_id: ResourceId, + tag_keys: KeyList, + **kwargs, + ) -> RemoveTagsFromResourceResult: + raise NotImplementedError + + @handler("ResetServiceSetting") + def reset_service_setting( + self, context: RequestContext, setting_id: ServiceSettingId, **kwargs + ) -> ResetServiceSettingResult: + raise NotImplementedError + + @handler("ResumeSession") + def resume_session( + self, context: RequestContext, session_id: SessionId, **kwargs + ) -> ResumeSessionResponse: + raise NotImplementedError + + @handler("SendAutomationSignal") + def send_automation_signal( + self, + context: RequestContext, + automation_execution_id: AutomationExecutionId, + signal_type: SignalType, + payload: AutomationParameterMap | None = None, + **kwargs, + ) -> SendAutomationSignalResult: + raise NotImplementedError + + @handler("SendCommand") + def send_command( + self, + context: RequestContext, + document_name: DocumentARN, + instance_ids: InstanceIdList | None = None, + targets: Targets | None = None, + document_version: DocumentVersion | None = None, + document_hash: DocumentHash | None = None, + document_hash_type: DocumentHashType | None = None, + timeout_seconds: TimeoutSeconds | None = None, + comment: Comment | None = None, + parameters: Parameters | None = None, + output_s3_region: S3Region | None = None, + output_s3_bucket_name: S3BucketName | None = None, + output_s3_key_prefix: S3KeyPrefix | None = None, + max_concurrency: MaxConcurrency | None = None, + max_errors: MaxErrors | None = None, + service_role_arn: ServiceRole | None = None, + notification_config: NotificationConfig | None = None, + cloud_watch_output_config: CloudWatchOutputConfig | None = None, + alarm_configuration: AlarmConfiguration | None = None, + **kwargs, + ) -> SendCommandResult: + raise NotImplementedError + + @handler("StartAccessRequest") + def start_access_request( + self, + context: RequestContext, + reason: String1to256, + targets: Targets, + tags: TagList | None = None, + **kwargs, + ) -> StartAccessRequestResponse: + raise NotImplementedError + + @handler("StartAssociationsOnce") + def start_associations_once( + self, context: RequestContext, association_ids: AssociationIdList, **kwargs + ) -> StartAssociationsOnceResult: + raise NotImplementedError + + @handler("StartAutomationExecution") + def start_automation_execution( + self, + context: RequestContext, + document_name: DocumentARN, + document_version: DocumentVersion | None = None, + parameters: AutomationParameterMap | None = None, + client_token: IdempotencyToken | None = None, + mode: ExecutionMode | None = None, + target_parameter_name: AutomationParameterKey | None = None, + targets: Targets | None = None, + target_maps: TargetMaps | None = None, + max_concurrency: MaxConcurrency | None = None, + max_errors: MaxErrors | None = None, + target_locations: TargetLocations | None = None, + tags: TagList | None = None, + alarm_configuration: AlarmConfiguration | None = None, + target_locations_url: TargetLocationsURL | None = None, + **kwargs, + ) -> StartAutomationExecutionResult: + raise NotImplementedError + + @handler("StartChangeRequestExecution") + def start_change_request_execution( + self, + context: RequestContext, + document_name: DocumentARN, + runbooks: Runbooks, + scheduled_time: DateTime | None = None, + document_version: DocumentVersion | None = None, + parameters: AutomationParameterMap | None = None, + change_request_name: ChangeRequestName | None = None, + client_token: IdempotencyToken | None = None, + auto_approve: Boolean | None = None, + tags: TagList | None = None, + scheduled_end_time: DateTime | None = None, + change_details: ChangeDetailsValue | None = None, + **kwargs, + ) -> StartChangeRequestExecutionResult: + raise NotImplementedError + + @handler("StartExecutionPreview") + def start_execution_preview( + self, + context: RequestContext, + document_name: DocumentName, + document_version: DocumentVersion | None = None, + execution_inputs: ExecutionInputs | None = None, + **kwargs, + ) -> StartExecutionPreviewResponse: + raise NotImplementedError + + @handler("StartSession") + def start_session( + self, + context: RequestContext, + target: SessionTarget, + document_name: DocumentARN | None = None, + reason: SessionReason | None = None, + parameters: SessionManagerParameters | None = None, + **kwargs, + ) -> StartSessionResponse: + raise NotImplementedError + + @handler("StopAutomationExecution", expand=False) + def stop_automation_execution( + self, context: RequestContext, request: StopAutomationExecutionRequest, **kwargs + ) -> StopAutomationExecutionResult: + raise NotImplementedError + + @handler("TerminateSession") + def terminate_session( + self, context: RequestContext, session_id: SessionId, **kwargs + ) -> TerminateSessionResponse: + raise NotImplementedError + + @handler("UnlabelParameterVersion") + def unlabel_parameter_version( + self, + context: RequestContext, + name: PSParameterName, + parameter_version: PSParameterVersion, + labels: ParameterLabelList, + **kwargs, + ) -> UnlabelParameterVersionResult: + raise NotImplementedError + + @handler("UpdateAssociation") + def update_association( + self, + context: RequestContext, + association_id: AssociationId, + parameters: Parameters | None = None, + document_version: DocumentVersion | None = None, + schedule_expression: ScheduleExpression | None = None, + output_location: InstanceAssociationOutputLocation | None = None, + name: DocumentARN | None = None, + targets: Targets | None = None, + association_name: AssociationName | None = None, + association_version: AssociationVersion | None = None, + automation_target_parameter_name: AutomationTargetParameterName | None = None, + max_errors: MaxErrors | None = None, + max_concurrency: MaxConcurrency | None = None, + compliance_severity: AssociationComplianceSeverity | None = None, + sync_compliance: AssociationSyncCompliance | None = None, + apply_only_at_cron_interval: ApplyOnlyAtCronInterval | None = None, + calendar_names: CalendarNameOrARNList | None = None, + target_locations: TargetLocations | None = None, + schedule_offset: ScheduleOffset | None = None, + duration: Duration | None = None, + target_maps: TargetMaps | None = None, + alarm_configuration: AlarmConfiguration | None = None, + **kwargs, + ) -> UpdateAssociationResult: + raise NotImplementedError + + @handler("UpdateAssociationStatus") + def update_association_status( + self, + context: RequestContext, + name: DocumentARN, + instance_id: InstanceId, + association_status: AssociationStatus, + **kwargs, + ) -> UpdateAssociationStatusResult: + raise NotImplementedError + + @handler("UpdateDocument") + def update_document( + self, + context: RequestContext, + content: DocumentContent, + name: DocumentName, + attachments: AttachmentsSourceList | None = None, + display_name: DocumentDisplayName | None = None, + version_name: DocumentVersionName | None = None, + document_version: DocumentVersion | None = None, + document_format: DocumentFormat | None = None, + target_type: TargetType | None = None, + **kwargs, + ) -> UpdateDocumentResult: + raise NotImplementedError + + @handler("UpdateDocumentDefaultVersion") + def update_document_default_version( + self, + context: RequestContext, + name: DocumentName, + document_version: DocumentVersionNumber, + **kwargs, + ) -> UpdateDocumentDefaultVersionResult: + raise NotImplementedError + + @handler("UpdateDocumentMetadata") + def update_document_metadata( + self, + context: RequestContext, + name: DocumentName, + document_reviews: DocumentReviews, + document_version: DocumentVersion | None = None, + **kwargs, + ) -> UpdateDocumentMetadataResponse: + raise NotImplementedError + + @handler("UpdateMaintenanceWindow") + def update_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + name: MaintenanceWindowName | None = None, + description: MaintenanceWindowDescription | None = None, + start_date: MaintenanceWindowStringDateTime | None = None, + end_date: MaintenanceWindowStringDateTime | None = None, + schedule: MaintenanceWindowSchedule | None = None, + schedule_timezone: MaintenanceWindowTimezone | None = None, + schedule_offset: MaintenanceWindowOffset | None = None, + duration: MaintenanceWindowDurationHours | None = None, + cutoff: MaintenanceWindowCutoff | None = None, + allow_unassociated_targets: MaintenanceWindowAllowUnassociatedTargets | None = None, + enabled: MaintenanceWindowEnabled | None = None, + replace: Boolean | None = None, + **kwargs, + ) -> UpdateMaintenanceWindowResult: + raise NotImplementedError + + @handler("UpdateMaintenanceWindowTarget") + def update_maintenance_window_target( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + window_target_id: MaintenanceWindowTargetId, + targets: Targets | None = None, + owner_information: OwnerInformation | None = None, + name: MaintenanceWindowName | None = None, + description: MaintenanceWindowDescription | None = None, + replace: Boolean | None = None, + **kwargs, + ) -> UpdateMaintenanceWindowTargetResult: + raise NotImplementedError + + @handler("UpdateMaintenanceWindowTask") + def update_maintenance_window_task( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + window_task_id: MaintenanceWindowTaskId, + targets: Targets | None = None, + task_arn: MaintenanceWindowTaskArn | None = None, + service_role_arn: ServiceRole | None = None, + task_parameters: MaintenanceWindowTaskParameters | None = None, + task_invocation_parameters: MaintenanceWindowTaskInvocationParameters | None = None, + priority: MaintenanceWindowTaskPriority | None = None, + max_concurrency: MaxConcurrency | None = None, + max_errors: MaxErrors | None = None, + logging_info: LoggingInfo | None = None, + name: MaintenanceWindowName | None = None, + description: MaintenanceWindowDescription | None = None, + replace: Boolean | None = None, + cutoff_behavior: MaintenanceWindowTaskCutoffBehavior | None = None, + alarm_configuration: AlarmConfiguration | None = None, + **kwargs, + ) -> UpdateMaintenanceWindowTaskResult: + raise NotImplementedError + + @handler("UpdateManagedInstanceRole") + def update_managed_instance_role( + self, context: RequestContext, instance_id: ManagedInstanceId, iam_role: IamRole, **kwargs + ) -> UpdateManagedInstanceRoleResult: + raise NotImplementedError + + @handler("UpdateOpsItem") + def update_ops_item( + self, + context: RequestContext, + ops_item_id: OpsItemId, + description: OpsItemDescription | None = None, + operational_data: OpsItemOperationalData | None = None, + operational_data_to_delete: OpsItemOpsDataKeysList | None = None, + notifications: OpsItemNotifications | None = None, + priority: OpsItemPriority | None = None, + related_ops_items: RelatedOpsItems | None = None, + status: OpsItemStatus | None = None, + title: OpsItemTitle | None = None, + category: OpsItemCategory | None = None, + severity: OpsItemSeverity | None = None, + actual_start_time: DateTime | None = None, + actual_end_time: DateTime | None = None, + planned_start_time: DateTime | None = None, + planned_end_time: DateTime | None = None, + ops_item_arn: OpsItemArn | None = None, + **kwargs, + ) -> UpdateOpsItemResponse: + raise NotImplementedError + + @handler("UpdateOpsMetadata") + def update_ops_metadata( + self, + context: RequestContext, + ops_metadata_arn: OpsMetadataArn, + metadata_to_update: MetadataMap | None = None, + keys_to_delete: MetadataKeysToDeleteList | None = None, + **kwargs, + ) -> UpdateOpsMetadataResult: + raise NotImplementedError + + @handler("UpdatePatchBaseline") + def update_patch_baseline( + self, + context: RequestContext, + baseline_id: BaselineId, + name: BaselineName | None = None, + global_filters: PatchFilterGroup | None = None, + approval_rules: PatchRuleGroup | None = None, + approved_patches: PatchIdList | None = None, + approved_patches_compliance_level: PatchComplianceLevel | None = None, + approved_patches_enable_non_security: Boolean | None = None, + rejected_patches: PatchIdList | None = None, + rejected_patches_action: PatchAction | None = None, + description: BaselineDescription | None = None, + sources: PatchSourceList | None = None, + available_security_updates_compliance_status: PatchComplianceStatus | None = None, + replace: Boolean | None = None, + **kwargs, + ) -> UpdatePatchBaselineResult: + raise NotImplementedError + + @handler("UpdateResourceDataSync") + def update_resource_data_sync( + self, + context: RequestContext, + sync_name: ResourceDataSyncName, + sync_type: ResourceDataSyncType, + sync_source: ResourceDataSyncSource, + **kwargs, + ) -> UpdateResourceDataSyncResult: + raise NotImplementedError + + @handler("UpdateServiceSetting") + def update_service_setting( + self, + context: RequestContext, + setting_id: ServiceSettingId, + setting_value: ServiceSettingValue, + **kwargs, + ) -> UpdateServiceSettingResult: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/stepfunctions/__init__.py b/localstack-core/localstack/aws/api/stepfunctions/__init__.py new file mode 100644 index 0000000000000..c1dca160d5ffe --- /dev/null +++ b/localstack-core/localstack/aws/api/stepfunctions/__init__.py @@ -0,0 +1,1733 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AliasDescription = str +Arn = str +CharacterRestrictedName = str +ClientToken = str +ConnectorParameters = str +Definition = str +Enabled = bool +ErrorMessage = str +EvaluationFailureLocation = str +HTTPBody = str +HTTPHeaders = str +HTTPMethod = str +HTTPProtocol = str +HTTPStatusCode = str +HTTPStatusMessage = str +Identity = str +IncludeExecutionData = bool +IncludeExecutionDataGetExecutionHistory = bool +KmsDataKeyReusePeriodSeconds = int +KmsKeyId = str +ListExecutionsPageToken = str +LongArn = str +MapRunLabel = str +MaxConcurrency = int +Name = str +PageSize = int +PageToken = str +Publish = bool +RedriveCount = int +RevealSecrets = bool +ReverseOrder = bool +RevisionId = str +SensitiveCause = str +SensitiveData = str +SensitiveDataJobInput = str +SensitiveError = str +StateName = str +TagKey = str +TagValue = str +TaskToken = str +ToleratedFailurePercentage = float +TraceHeader = str +URL = str +UnsignedInteger = int +ValidateStateMachineDefinitionCode = str +ValidateStateMachineDefinitionLocation = str +ValidateStateMachineDefinitionMaxResult = int +ValidateStateMachineDefinitionMessage = str +ValidateStateMachineDefinitionTruncated = bool +VariableName = str +VariableValue = str +VersionDescription = str +VersionWeight = int +includedDetails = bool +truncated = bool + + +class EncryptionType(StrEnum): + AWS_OWNED_KEY = "AWS_OWNED_KEY" + CUSTOMER_MANAGED_KMS_KEY = "CUSTOMER_MANAGED_KMS_KEY" + + +class ExecutionRedriveFilter(StrEnum): + REDRIVEN = "REDRIVEN" + NOT_REDRIVEN = "NOT_REDRIVEN" + + +class ExecutionRedriveStatus(StrEnum): + REDRIVABLE = "REDRIVABLE" + NOT_REDRIVABLE = "NOT_REDRIVABLE" + REDRIVABLE_BY_MAP_RUN = "REDRIVABLE_BY_MAP_RUN" + + +class ExecutionStatus(StrEnum): + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + TIMED_OUT = "TIMED_OUT" + ABORTED = "ABORTED" + PENDING_REDRIVE = "PENDING_REDRIVE" + + +class HistoryEventType(StrEnum): + ActivityFailed = "ActivityFailed" + ActivityScheduled = "ActivityScheduled" + ActivityScheduleFailed = "ActivityScheduleFailed" + ActivityStarted = "ActivityStarted" + ActivitySucceeded = "ActivitySucceeded" + ActivityTimedOut = "ActivityTimedOut" + ChoiceStateEntered = "ChoiceStateEntered" + ChoiceStateExited = "ChoiceStateExited" + ExecutionAborted = "ExecutionAborted" + ExecutionFailed = "ExecutionFailed" + ExecutionStarted = "ExecutionStarted" + ExecutionSucceeded = "ExecutionSucceeded" + ExecutionTimedOut = "ExecutionTimedOut" + FailStateEntered = "FailStateEntered" + LambdaFunctionFailed = "LambdaFunctionFailed" + LambdaFunctionScheduled = "LambdaFunctionScheduled" + LambdaFunctionScheduleFailed = "LambdaFunctionScheduleFailed" + LambdaFunctionStarted = "LambdaFunctionStarted" + LambdaFunctionStartFailed = "LambdaFunctionStartFailed" + LambdaFunctionSucceeded = "LambdaFunctionSucceeded" + LambdaFunctionTimedOut = "LambdaFunctionTimedOut" + MapIterationAborted = "MapIterationAborted" + MapIterationFailed = "MapIterationFailed" + MapIterationStarted = "MapIterationStarted" + MapIterationSucceeded = "MapIterationSucceeded" + MapStateAborted = "MapStateAborted" + MapStateEntered = "MapStateEntered" + MapStateExited = "MapStateExited" + MapStateFailed = "MapStateFailed" + MapStateStarted = "MapStateStarted" + MapStateSucceeded = "MapStateSucceeded" + ParallelStateAborted = "ParallelStateAborted" + ParallelStateEntered = "ParallelStateEntered" + ParallelStateExited = "ParallelStateExited" + ParallelStateFailed = "ParallelStateFailed" + ParallelStateStarted = "ParallelStateStarted" + ParallelStateSucceeded = "ParallelStateSucceeded" + PassStateEntered = "PassStateEntered" + PassStateExited = "PassStateExited" + SucceedStateEntered = "SucceedStateEntered" + SucceedStateExited = "SucceedStateExited" + TaskFailed = "TaskFailed" + TaskScheduled = "TaskScheduled" + TaskStarted = "TaskStarted" + TaskStartFailed = "TaskStartFailed" + TaskStateAborted = "TaskStateAborted" + TaskStateEntered = "TaskStateEntered" + TaskStateExited = "TaskStateExited" + TaskSubmitFailed = "TaskSubmitFailed" + TaskSubmitted = "TaskSubmitted" + TaskSucceeded = "TaskSucceeded" + TaskTimedOut = "TaskTimedOut" + WaitStateAborted = "WaitStateAborted" + WaitStateEntered = "WaitStateEntered" + WaitStateExited = "WaitStateExited" + MapRunAborted = "MapRunAborted" + MapRunFailed = "MapRunFailed" + MapRunStarted = "MapRunStarted" + MapRunSucceeded = "MapRunSucceeded" + ExecutionRedriven = "ExecutionRedriven" + MapRunRedriven = "MapRunRedriven" + EvaluationFailed = "EvaluationFailed" + + +class IncludedData(StrEnum): + ALL_DATA = "ALL_DATA" + METADATA_ONLY = "METADATA_ONLY" + + +class InspectionLevel(StrEnum): + INFO = "INFO" + DEBUG = "DEBUG" + TRACE = "TRACE" + + +class KmsKeyState(StrEnum): + DISABLED = "DISABLED" + PENDING_DELETION = "PENDING_DELETION" + PENDING_IMPORT = "PENDING_IMPORT" + UNAVAILABLE = "UNAVAILABLE" + CREATING = "CREATING" + + +class LogLevel(StrEnum): + ALL = "ALL" + ERROR = "ERROR" + FATAL = "FATAL" + OFF = "OFF" + + +class MapRunStatus(StrEnum): + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + ABORTED = "ABORTED" + + +class StateMachineStatus(StrEnum): + ACTIVE = "ACTIVE" + DELETING = "DELETING" + + +class StateMachineType(StrEnum): + STANDARD = "STANDARD" + EXPRESS = "EXPRESS" + + +class SyncExecutionStatus(StrEnum): + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + TIMED_OUT = "TIMED_OUT" + + +class TestExecutionStatus(StrEnum): + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + RETRIABLE = "RETRIABLE" + CAUGHT_ERROR = "CAUGHT_ERROR" + + +class ValidateStateMachineDefinitionResultCode(StrEnum): + OK = "OK" + FAIL = "FAIL" + + +class ValidateStateMachineDefinitionSeverity(StrEnum): + ERROR = "ERROR" + WARNING = "WARNING" + + +class ValidationExceptionReason(StrEnum): + API_DOES_NOT_SUPPORT_LABELED_ARNS = "API_DOES_NOT_SUPPORT_LABELED_ARNS" + MISSING_REQUIRED_PARAMETER = "MISSING_REQUIRED_PARAMETER" + CANNOT_UPDATE_COMPLETED_MAP_RUN = "CANNOT_UPDATE_COMPLETED_MAP_RUN" + INVALID_ROUTING_CONFIGURATION = "INVALID_ROUTING_CONFIGURATION" + + +class ActivityAlreadyExists(ServiceException): + code: str = "ActivityAlreadyExists" + sender_fault: bool = False + status_code: int = 400 + + +class ActivityDoesNotExist(ServiceException): + code: str = "ActivityDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class ActivityLimitExceeded(ServiceException): + code: str = "ActivityLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class ActivityWorkerLimitExceeded(ServiceException): + code: str = "ActivityWorkerLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class ExecutionAlreadyExists(ServiceException): + code: str = "ExecutionAlreadyExists" + sender_fault: bool = False + status_code: int = 400 + + +class ExecutionDoesNotExist(ServiceException): + code: str = "ExecutionDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class ExecutionLimitExceeded(ServiceException): + code: str = "ExecutionLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class ExecutionNotRedrivable(ServiceException): + code: str = "ExecutionNotRedrivable" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidArn(ServiceException): + code: str = "InvalidArn" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidDefinition(ServiceException): + code: str = "InvalidDefinition" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidEncryptionConfiguration(ServiceException): + code: str = "InvalidEncryptionConfiguration" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidExecutionInput(ServiceException): + code: str = "InvalidExecutionInput" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidLoggingConfiguration(ServiceException): + code: str = "InvalidLoggingConfiguration" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidName(ServiceException): + code: str = "InvalidName" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidOutput(ServiceException): + code: str = "InvalidOutput" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidToken(ServiceException): + code: str = "InvalidToken" + sender_fault: bool = False + status_code: int = 400 + + +class InvalidTracingConfiguration(ServiceException): + code: str = "InvalidTracingConfiguration" + sender_fault: bool = False + status_code: int = 400 + + +class KmsAccessDeniedException(ServiceException): + code: str = "KmsAccessDeniedException" + sender_fault: bool = False + status_code: int = 400 + + +class KmsInvalidStateException(ServiceException): + code: str = "KmsInvalidStateException" + sender_fault: bool = False + status_code: int = 400 + kmsKeyState: Optional[KmsKeyState] + + +class KmsThrottlingException(ServiceException): + code: str = "KmsThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +class MissingRequiredParameter(ServiceException): + code: str = "MissingRequiredParameter" + sender_fault: bool = False + status_code: int = 400 + + +class ResourceNotFound(ServiceException): + code: str = "ResourceNotFound" + sender_fault: bool = False + status_code: int = 400 + resourceName: Optional[Arn] + + +class ServiceQuotaExceededException(ServiceException): + code: str = "ServiceQuotaExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class StateMachineAlreadyExists(ServiceException): + code: str = "StateMachineAlreadyExists" + sender_fault: bool = False + status_code: int = 400 + + +class StateMachineDeleting(ServiceException): + code: str = "StateMachineDeleting" + sender_fault: bool = False + status_code: int = 400 + + +class StateMachineDoesNotExist(ServiceException): + code: str = "StateMachineDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class StateMachineLimitExceeded(ServiceException): + code: str = "StateMachineLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class StateMachineTypeNotSupported(ServiceException): + code: str = "StateMachineTypeNotSupported" + sender_fault: bool = False + status_code: int = 400 + + +class TaskDoesNotExist(ServiceException): + code: str = "TaskDoesNotExist" + sender_fault: bool = False + status_code: int = 400 + + +class TaskTimedOut(ServiceException): + code: str = "TaskTimedOut" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTags(ServiceException): + code: str = "TooManyTags" + sender_fault: bool = False + status_code: int = 400 + resourceName: Optional[Arn] + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = False + status_code: int = 400 + reason: Optional[ValidationExceptionReason] + + +class ActivityFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +Timestamp = datetime + + +class ActivityListItem(TypedDict, total=False): + activityArn: Arn + name: Name + creationDate: Timestamp + + +ActivityList = List[ActivityListItem] + + +class ActivityScheduleFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +TimeoutInSeconds = int + + +class HistoryEventExecutionDataDetails(TypedDict, total=False): + truncated: Optional[truncated] + + +class ActivityScheduledEventDetails(TypedDict, total=False): + resource: Arn + input: Optional[SensitiveData] + inputDetails: Optional[HistoryEventExecutionDataDetails] + timeoutInSeconds: Optional[TimeoutInSeconds] + heartbeatInSeconds: Optional[TimeoutInSeconds] + + +class ActivityStartedEventDetails(TypedDict, total=False): + workerName: Optional[Identity] + + +class ActivitySucceededEventDetails(TypedDict, total=False): + output: Optional[SensitiveData] + outputDetails: Optional[HistoryEventExecutionDataDetails] + + +class ActivityTimedOutEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +AssignedVariables = Dict[VariableName, VariableValue] + + +class AssignedVariablesDetails(TypedDict, total=False): + truncated: Optional[truncated] + + +BilledDuration = int +BilledMemoryUsed = int + + +class BillingDetails(TypedDict, total=False): + billedMemoryUsedInMB: Optional[BilledMemoryUsed] + billedDurationInMilliseconds: Optional[BilledDuration] + + +class CloudWatchEventsExecutionDataDetails(TypedDict, total=False): + included: Optional[includedDetails] + + +class CloudWatchLogsLogGroup(TypedDict, total=False): + logGroupArn: Optional[Arn] + + +EncryptionConfiguration = TypedDict( + "EncryptionConfiguration", + { + "kmsKeyId": Optional[KmsKeyId], + "kmsDataKeyReusePeriodSeconds": Optional[KmsDataKeyReusePeriodSeconds], + "type": EncryptionType, + }, + total=False, +) + + +class Tag(TypedDict, total=False): + key: Optional[TagKey] + value: Optional[TagValue] + + +TagList = List[Tag] + + +class CreateActivityInput(ServiceRequest): + name: Name + tags: Optional[TagList] + encryptionConfiguration: Optional[EncryptionConfiguration] + + +class CreateActivityOutput(TypedDict, total=False): + activityArn: Arn + creationDate: Timestamp + + +class RoutingConfigurationListItem(TypedDict, total=False): + stateMachineVersionArn: Arn + weight: VersionWeight + + +RoutingConfigurationList = List[RoutingConfigurationListItem] + + +class CreateStateMachineAliasInput(ServiceRequest): + description: Optional[AliasDescription] + name: CharacterRestrictedName + routingConfiguration: RoutingConfigurationList + + +class CreateStateMachineAliasOutput(TypedDict, total=False): + stateMachineAliasArn: Arn + creationDate: Timestamp + + +class TracingConfiguration(TypedDict, total=False): + enabled: Optional[Enabled] + + +class LogDestination(TypedDict, total=False): + cloudWatchLogsLogGroup: Optional[CloudWatchLogsLogGroup] + + +LogDestinationList = List[LogDestination] + + +class LoggingConfiguration(TypedDict, total=False): + level: Optional[LogLevel] + includeExecutionData: Optional[IncludeExecutionData] + destinations: Optional[LogDestinationList] + + +CreateStateMachineInput = TypedDict( + "CreateStateMachineInput", + { + "name": Name, + "definition": Definition, + "roleArn": Arn, + "type": Optional[StateMachineType], + "loggingConfiguration": Optional[LoggingConfiguration], + "tags": Optional[TagList], + "tracingConfiguration": Optional[TracingConfiguration], + "publish": Optional[Publish], + "versionDescription": Optional[VersionDescription], + "encryptionConfiguration": Optional[EncryptionConfiguration], + }, + total=False, +) + + +class CreateStateMachineOutput(TypedDict, total=False): + stateMachineArn: Arn + creationDate: Timestamp + stateMachineVersionArn: Optional[Arn] + + +class DeleteActivityInput(ServiceRequest): + activityArn: Arn + + +class DeleteActivityOutput(TypedDict, total=False): + pass + + +class DeleteStateMachineAliasInput(ServiceRequest): + stateMachineAliasArn: Arn + + +class DeleteStateMachineAliasOutput(TypedDict, total=False): + pass + + +class DeleteStateMachineInput(ServiceRequest): + stateMachineArn: Arn + + +class DeleteStateMachineOutput(TypedDict, total=False): + pass + + +class DeleteStateMachineVersionInput(ServiceRequest): + stateMachineVersionArn: LongArn + + +class DeleteStateMachineVersionOutput(TypedDict, total=False): + pass + + +class DescribeActivityInput(ServiceRequest): + activityArn: Arn + + +class DescribeActivityOutput(TypedDict, total=False): + activityArn: Arn + name: Name + creationDate: Timestamp + encryptionConfiguration: Optional[EncryptionConfiguration] + + +class DescribeExecutionInput(ServiceRequest): + executionArn: Arn + includedData: Optional[IncludedData] + + +class DescribeExecutionOutput(TypedDict, total=False): + executionArn: Arn + stateMachineArn: Arn + name: Optional[Name] + status: ExecutionStatus + startDate: Timestamp + stopDate: Optional[Timestamp] + input: Optional[SensitiveData] + inputDetails: Optional[CloudWatchEventsExecutionDataDetails] + output: Optional[SensitiveData] + outputDetails: Optional[CloudWatchEventsExecutionDataDetails] + traceHeader: Optional[TraceHeader] + mapRunArn: Optional[LongArn] + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + stateMachineVersionArn: Optional[Arn] + stateMachineAliasArn: Optional[Arn] + redriveCount: Optional[RedriveCount] + redriveDate: Optional[Timestamp] + redriveStatus: Optional[ExecutionRedriveStatus] + redriveStatusReason: Optional[SensitiveData] + + +class DescribeMapRunInput(ServiceRequest): + mapRunArn: LongArn + + +LongObject = int +UnsignedLong = int + + +class MapRunExecutionCounts(TypedDict, total=False): + pending: UnsignedLong + running: UnsignedLong + succeeded: UnsignedLong + failed: UnsignedLong + timedOut: UnsignedLong + aborted: UnsignedLong + total: UnsignedLong + resultsWritten: UnsignedLong + failuresNotRedrivable: Optional[LongObject] + pendingRedrive: Optional[LongObject] + + +class MapRunItemCounts(TypedDict, total=False): + pending: UnsignedLong + running: UnsignedLong + succeeded: UnsignedLong + failed: UnsignedLong + timedOut: UnsignedLong + aborted: UnsignedLong + total: UnsignedLong + resultsWritten: UnsignedLong + failuresNotRedrivable: Optional[LongObject] + pendingRedrive: Optional[LongObject] + + +ToleratedFailureCount = int + + +class DescribeMapRunOutput(TypedDict, total=False): + mapRunArn: LongArn + executionArn: Arn + status: MapRunStatus + startDate: Timestamp + stopDate: Optional[Timestamp] + maxConcurrency: MaxConcurrency + toleratedFailurePercentage: ToleratedFailurePercentage + toleratedFailureCount: ToleratedFailureCount + itemCounts: MapRunItemCounts + executionCounts: MapRunExecutionCounts + redriveCount: Optional[RedriveCount] + redriveDate: Optional[Timestamp] + + +class DescribeStateMachineAliasInput(ServiceRequest): + stateMachineAliasArn: Arn + + +class DescribeStateMachineAliasOutput(TypedDict, total=False): + stateMachineAliasArn: Optional[Arn] + name: Optional[Name] + description: Optional[AliasDescription] + routingConfiguration: Optional[RoutingConfigurationList] + creationDate: Optional[Timestamp] + updateDate: Optional[Timestamp] + + +class DescribeStateMachineForExecutionInput(ServiceRequest): + executionArn: Arn + includedData: Optional[IncludedData] + + +VariableNameList = List[VariableName] +VariableReferences = Dict[StateName, VariableNameList] + + +class DescribeStateMachineForExecutionOutput(TypedDict, total=False): + stateMachineArn: Arn + name: Name + definition: Definition + roleArn: Arn + updateDate: Timestamp + loggingConfiguration: Optional[LoggingConfiguration] + tracingConfiguration: Optional[TracingConfiguration] + mapRunArn: Optional[LongArn] + label: Optional[MapRunLabel] + revisionId: Optional[RevisionId] + encryptionConfiguration: Optional[EncryptionConfiguration] + variableReferences: Optional[VariableReferences] + + +class DescribeStateMachineInput(ServiceRequest): + stateMachineArn: Arn + includedData: Optional[IncludedData] + + +DescribeStateMachineOutput = TypedDict( + "DescribeStateMachineOutput", + { + "stateMachineArn": Arn, + "name": Name, + "status": Optional[StateMachineStatus], + "definition": Definition, + "roleArn": Arn, + "type": StateMachineType, + "creationDate": Timestamp, + "loggingConfiguration": Optional[LoggingConfiguration], + "tracingConfiguration": Optional[TracingConfiguration], + "label": Optional[MapRunLabel], + "revisionId": Optional[RevisionId], + "description": Optional[VersionDescription], + "encryptionConfiguration": Optional[EncryptionConfiguration], + "variableReferences": Optional[VariableReferences], + }, + total=False, +) + + +class EvaluationFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + location: Optional[EvaluationFailureLocation] + state: StateName + + +EventId = int + + +class ExecutionAbortedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class ExecutionFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class ExecutionListItem(TypedDict, total=False): + executionArn: Arn + stateMachineArn: Arn + name: Name + status: ExecutionStatus + startDate: Timestamp + stopDate: Optional[Timestamp] + mapRunArn: Optional[LongArn] + itemCount: Optional[UnsignedInteger] + stateMachineVersionArn: Optional[Arn] + stateMachineAliasArn: Optional[Arn] + redriveCount: Optional[RedriveCount] + redriveDate: Optional[Timestamp] + + +ExecutionList = List[ExecutionListItem] + + +class ExecutionRedrivenEventDetails(TypedDict, total=False): + redriveCount: Optional[RedriveCount] + + +class ExecutionStartedEventDetails(TypedDict, total=False): + input: Optional[SensitiveData] + inputDetails: Optional[HistoryEventExecutionDataDetails] + roleArn: Optional[Arn] + stateMachineAliasArn: Optional[Arn] + stateMachineVersionArn: Optional[Arn] + + +class ExecutionSucceededEventDetails(TypedDict, total=False): + output: Optional[SensitiveData] + outputDetails: Optional[HistoryEventExecutionDataDetails] + + +class ExecutionTimedOutEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class GetActivityTaskInput(ServiceRequest): + activityArn: Arn + workerName: Optional[Name] + + +class GetActivityTaskOutput(TypedDict, total=False): + taskToken: Optional[TaskToken] + input: Optional[SensitiveDataJobInput] + + +class GetExecutionHistoryInput(ServiceRequest): + executionArn: Arn + maxResults: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + nextToken: Optional[PageToken] + includeExecutionData: Optional[IncludeExecutionDataGetExecutionHistory] + + +class MapRunRedrivenEventDetails(TypedDict, total=False): + mapRunArn: Optional[LongArn] + redriveCount: Optional[RedriveCount] + + +class MapRunFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class MapRunStartedEventDetails(TypedDict, total=False): + mapRunArn: Optional[LongArn] + + +class StateExitedEventDetails(TypedDict, total=False): + name: Name + output: Optional[SensitiveData] + outputDetails: Optional[HistoryEventExecutionDataDetails] + assignedVariables: Optional[AssignedVariables] + assignedVariablesDetails: Optional[AssignedVariablesDetails] + + +class StateEnteredEventDetails(TypedDict, total=False): + name: Name + input: Optional[SensitiveData] + inputDetails: Optional[HistoryEventExecutionDataDetails] + + +class LambdaFunctionTimedOutEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class LambdaFunctionSucceededEventDetails(TypedDict, total=False): + output: Optional[SensitiveData] + outputDetails: Optional[HistoryEventExecutionDataDetails] + + +class LambdaFunctionStartFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class TaskCredentials(TypedDict, total=False): + roleArn: Optional[LongArn] + + +class LambdaFunctionScheduledEventDetails(TypedDict, total=False): + resource: Arn + input: Optional[SensitiveData] + inputDetails: Optional[HistoryEventExecutionDataDetails] + timeoutInSeconds: Optional[TimeoutInSeconds] + taskCredentials: Optional[TaskCredentials] + + +class LambdaFunctionScheduleFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class LambdaFunctionFailedEventDetails(TypedDict, total=False): + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class MapIterationEventDetails(TypedDict, total=False): + name: Optional[Name] + index: Optional[UnsignedInteger] + + +class MapStateStartedEventDetails(TypedDict, total=False): + length: Optional[UnsignedInteger] + + +class TaskTimedOutEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class TaskSucceededEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + output: Optional[SensitiveData] + outputDetails: Optional[HistoryEventExecutionDataDetails] + + +class TaskSubmittedEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + output: Optional[SensitiveData] + outputDetails: Optional[HistoryEventExecutionDataDetails] + + +class TaskSubmitFailedEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class TaskStartedEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + + +class TaskStartFailedEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class TaskScheduledEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + region: Name + parameters: ConnectorParameters + timeoutInSeconds: Optional[TimeoutInSeconds] + heartbeatInSeconds: Optional[TimeoutInSeconds] + taskCredentials: Optional[TaskCredentials] + + +class TaskFailedEventDetails(TypedDict, total=False): + resourceType: Name + resource: Name + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +HistoryEvent = TypedDict( + "HistoryEvent", + { + "timestamp": Timestamp, + "type": HistoryEventType, + "id": EventId, + "previousEventId": Optional[EventId], + "activityFailedEventDetails": Optional[ActivityFailedEventDetails], + "activityScheduleFailedEventDetails": Optional[ActivityScheduleFailedEventDetails], + "activityScheduledEventDetails": Optional[ActivityScheduledEventDetails], + "activityStartedEventDetails": Optional[ActivityStartedEventDetails], + "activitySucceededEventDetails": Optional[ActivitySucceededEventDetails], + "activityTimedOutEventDetails": Optional[ActivityTimedOutEventDetails], + "taskFailedEventDetails": Optional[TaskFailedEventDetails], + "taskScheduledEventDetails": Optional[TaskScheduledEventDetails], + "taskStartFailedEventDetails": Optional[TaskStartFailedEventDetails], + "taskStartedEventDetails": Optional[TaskStartedEventDetails], + "taskSubmitFailedEventDetails": Optional[TaskSubmitFailedEventDetails], + "taskSubmittedEventDetails": Optional[TaskSubmittedEventDetails], + "taskSucceededEventDetails": Optional[TaskSucceededEventDetails], + "taskTimedOutEventDetails": Optional[TaskTimedOutEventDetails], + "executionFailedEventDetails": Optional[ExecutionFailedEventDetails], + "executionStartedEventDetails": Optional[ExecutionStartedEventDetails], + "executionSucceededEventDetails": Optional[ExecutionSucceededEventDetails], + "executionAbortedEventDetails": Optional[ExecutionAbortedEventDetails], + "executionTimedOutEventDetails": Optional[ExecutionTimedOutEventDetails], + "executionRedrivenEventDetails": Optional[ExecutionRedrivenEventDetails], + "mapStateStartedEventDetails": Optional[MapStateStartedEventDetails], + "mapIterationStartedEventDetails": Optional[MapIterationEventDetails], + "mapIterationSucceededEventDetails": Optional[MapIterationEventDetails], + "mapIterationFailedEventDetails": Optional[MapIterationEventDetails], + "mapIterationAbortedEventDetails": Optional[MapIterationEventDetails], + "lambdaFunctionFailedEventDetails": Optional[LambdaFunctionFailedEventDetails], + "lambdaFunctionScheduleFailedEventDetails": Optional[ + LambdaFunctionScheduleFailedEventDetails + ], + "lambdaFunctionScheduledEventDetails": Optional[LambdaFunctionScheduledEventDetails], + "lambdaFunctionStartFailedEventDetails": Optional[LambdaFunctionStartFailedEventDetails], + "lambdaFunctionSucceededEventDetails": Optional[LambdaFunctionSucceededEventDetails], + "lambdaFunctionTimedOutEventDetails": Optional[LambdaFunctionTimedOutEventDetails], + "stateEnteredEventDetails": Optional[StateEnteredEventDetails], + "stateExitedEventDetails": Optional[StateExitedEventDetails], + "mapRunStartedEventDetails": Optional[MapRunStartedEventDetails], + "mapRunFailedEventDetails": Optional[MapRunFailedEventDetails], + "mapRunRedrivenEventDetails": Optional[MapRunRedrivenEventDetails], + "evaluationFailedEventDetails": Optional[EvaluationFailedEventDetails], + }, + total=False, +) +HistoryEventList = List[HistoryEvent] + + +class GetExecutionHistoryOutput(TypedDict, total=False): + events: HistoryEventList + nextToken: Optional[PageToken] + + +class InspectionDataResponse(TypedDict, total=False): + protocol: Optional[HTTPProtocol] + statusCode: Optional[HTTPStatusCode] + statusMessage: Optional[HTTPStatusMessage] + headers: Optional[HTTPHeaders] + body: Optional[HTTPBody] + + +class InspectionDataRequest(TypedDict, total=False): + protocol: Optional[HTTPProtocol] + method: Optional[HTTPMethod] + url: Optional[URL] + headers: Optional[HTTPHeaders] + body: Optional[HTTPBody] + + +class InspectionData(TypedDict, total=False): + input: Optional[SensitiveData] + afterArguments: Optional[SensitiveData] + afterInputPath: Optional[SensitiveData] + afterParameters: Optional[SensitiveData] + result: Optional[SensitiveData] + afterResultSelector: Optional[SensitiveData] + afterResultPath: Optional[SensitiveData] + request: Optional[InspectionDataRequest] + response: Optional[InspectionDataResponse] + variables: Optional[SensitiveData] + + +class ListActivitiesInput(ServiceRequest): + maxResults: Optional[PageSize] + nextToken: Optional[PageToken] + + +class ListActivitiesOutput(TypedDict, total=False): + activities: ActivityList + nextToken: Optional[PageToken] + + +class ListExecutionsInput(ServiceRequest): + stateMachineArn: Optional[Arn] + statusFilter: Optional[ExecutionStatus] + maxResults: Optional[PageSize] + nextToken: Optional[ListExecutionsPageToken] + mapRunArn: Optional[LongArn] + redriveFilter: Optional[ExecutionRedriveFilter] + + +class ListExecutionsOutput(TypedDict, total=False): + executions: ExecutionList + nextToken: Optional[ListExecutionsPageToken] + + +class ListMapRunsInput(ServiceRequest): + executionArn: Arn + maxResults: Optional[PageSize] + nextToken: Optional[PageToken] + + +class MapRunListItem(TypedDict, total=False): + executionArn: Arn + mapRunArn: LongArn + stateMachineArn: Arn + startDate: Timestamp + stopDate: Optional[Timestamp] + + +MapRunList = List[MapRunListItem] + + +class ListMapRunsOutput(TypedDict, total=False): + mapRuns: MapRunList + nextToken: Optional[PageToken] + + +class ListStateMachineAliasesInput(ServiceRequest): + stateMachineArn: Arn + nextToken: Optional[PageToken] + maxResults: Optional[PageSize] + + +class StateMachineAliasListItem(TypedDict, total=False): + stateMachineAliasArn: LongArn + creationDate: Timestamp + + +StateMachineAliasList = List[StateMachineAliasListItem] + + +class ListStateMachineAliasesOutput(TypedDict, total=False): + stateMachineAliases: StateMachineAliasList + nextToken: Optional[PageToken] + + +class ListStateMachineVersionsInput(ServiceRequest): + stateMachineArn: Arn + nextToken: Optional[PageToken] + maxResults: Optional[PageSize] + + +class StateMachineVersionListItem(TypedDict, total=False): + stateMachineVersionArn: LongArn + creationDate: Timestamp + + +StateMachineVersionList = List[StateMachineVersionListItem] + + +class ListStateMachineVersionsOutput(TypedDict, total=False): + stateMachineVersions: StateMachineVersionList + nextToken: Optional[PageToken] + + +class ListStateMachinesInput(ServiceRequest): + maxResults: Optional[PageSize] + nextToken: Optional[PageToken] + + +StateMachineListItem = TypedDict( + "StateMachineListItem", + { + "stateMachineArn": Arn, + "name": Name, + "type": StateMachineType, + "creationDate": Timestamp, + }, + total=False, +) +StateMachineList = List[StateMachineListItem] + + +class ListStateMachinesOutput(TypedDict, total=False): + stateMachines: StateMachineList + nextToken: Optional[PageToken] + + +class ListTagsForResourceInput(ServiceRequest): + resourceArn: Arn + + +class ListTagsForResourceOutput(TypedDict, total=False): + tags: Optional[TagList] + + +class PublishStateMachineVersionInput(ServiceRequest): + stateMachineArn: Arn + revisionId: Optional[RevisionId] + description: Optional[VersionDescription] + + +class PublishStateMachineVersionOutput(TypedDict, total=False): + creationDate: Timestamp + stateMachineVersionArn: Arn + + +class RedriveExecutionInput(ServiceRequest): + executionArn: Arn + clientToken: Optional[ClientToken] + + +class RedriveExecutionOutput(TypedDict, total=False): + redriveDate: Timestamp + + +class SendTaskFailureInput(ServiceRequest): + taskToken: TaskToken + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class SendTaskFailureOutput(TypedDict, total=False): + pass + + +class SendTaskHeartbeatInput(ServiceRequest): + taskToken: TaskToken + + +class SendTaskHeartbeatOutput(TypedDict, total=False): + pass + + +class SendTaskSuccessInput(ServiceRequest): + taskToken: TaskToken + output: SensitiveData + + +class SendTaskSuccessOutput(TypedDict, total=False): + pass + + +class StartExecutionInput(ServiceRequest): + stateMachineArn: Arn + name: Optional[Name] + input: Optional[SensitiveData] + traceHeader: Optional[TraceHeader] + + +class StartExecutionOutput(TypedDict, total=False): + executionArn: Arn + startDate: Timestamp + + +class StartSyncExecutionInput(ServiceRequest): + stateMachineArn: Arn + name: Optional[Name] + input: Optional[SensitiveData] + traceHeader: Optional[TraceHeader] + includedData: Optional[IncludedData] + + +class StartSyncExecutionOutput(TypedDict, total=False): + executionArn: Arn + stateMachineArn: Optional[Arn] + name: Optional[Name] + startDate: Timestamp + stopDate: Timestamp + status: SyncExecutionStatus + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + input: Optional[SensitiveData] + inputDetails: Optional[CloudWatchEventsExecutionDataDetails] + output: Optional[SensitiveData] + outputDetails: Optional[CloudWatchEventsExecutionDataDetails] + traceHeader: Optional[TraceHeader] + billingDetails: Optional[BillingDetails] + + +class StopExecutionInput(ServiceRequest): + executionArn: Arn + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + +class StopExecutionOutput(TypedDict, total=False): + stopDate: Timestamp + + +TagKeyList = List[TagKey] + + +class TagResourceInput(ServiceRequest): + resourceArn: Arn + tags: TagList + + +class TagResourceOutput(TypedDict, total=False): + pass + + +class TestStateInput(ServiceRequest): + definition: Definition + roleArn: Optional[Arn] + input: Optional[SensitiveData] + inspectionLevel: Optional[InspectionLevel] + revealSecrets: Optional[RevealSecrets] + variables: Optional[SensitiveData] + + +class TestStateOutput(TypedDict, total=False): + output: Optional[SensitiveData] + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + inspectionData: Optional[InspectionData] + nextState: Optional[StateName] + status: Optional[TestExecutionStatus] + + +class UntagResourceInput(ServiceRequest): + resourceArn: Arn + tagKeys: TagKeyList + + +class UntagResourceOutput(TypedDict, total=False): + pass + + +class UpdateMapRunInput(ServiceRequest): + mapRunArn: LongArn + maxConcurrency: Optional[MaxConcurrency] + toleratedFailurePercentage: Optional[ToleratedFailurePercentage] + toleratedFailureCount: Optional[ToleratedFailureCount] + + +class UpdateMapRunOutput(TypedDict, total=False): + pass + + +class UpdateStateMachineAliasInput(ServiceRequest): + stateMachineAliasArn: Arn + description: Optional[AliasDescription] + routingConfiguration: Optional[RoutingConfigurationList] + + +class UpdateStateMachineAliasOutput(TypedDict, total=False): + updateDate: Timestamp + + +class UpdateStateMachineInput(ServiceRequest): + stateMachineArn: Arn + definition: Optional[Definition] + roleArn: Optional[Arn] + loggingConfiguration: Optional[LoggingConfiguration] + tracingConfiguration: Optional[TracingConfiguration] + publish: Optional[Publish] + versionDescription: Optional[VersionDescription] + encryptionConfiguration: Optional[EncryptionConfiguration] + + +class UpdateStateMachineOutput(TypedDict, total=False): + updateDate: Timestamp + revisionId: Optional[RevisionId] + stateMachineVersionArn: Optional[Arn] + + +class ValidateStateMachineDefinitionDiagnostic(TypedDict, total=False): + severity: ValidateStateMachineDefinitionSeverity + code: ValidateStateMachineDefinitionCode + message: ValidateStateMachineDefinitionMessage + location: Optional[ValidateStateMachineDefinitionLocation] + + +ValidateStateMachineDefinitionDiagnosticList = List[ValidateStateMachineDefinitionDiagnostic] +ValidateStateMachineDefinitionInput = TypedDict( + "ValidateStateMachineDefinitionInput", + { + "definition": Definition, + "type": Optional[StateMachineType], + "severity": Optional[ValidateStateMachineDefinitionSeverity], + "maxResults": Optional[ValidateStateMachineDefinitionMaxResult], + }, + total=False, +) + + +class ValidateStateMachineDefinitionOutput(TypedDict, total=False): + result: ValidateStateMachineDefinitionResultCode + diagnostics: ValidateStateMachineDefinitionDiagnosticList + truncated: Optional[ValidateStateMachineDefinitionTruncated] + + +class StepfunctionsApi: + service = "stepfunctions" + version = "2016-11-23" + + @handler("CreateActivity") + def create_activity( + self, + context: RequestContext, + name: Name, + tags: TagList | None = None, + encryption_configuration: EncryptionConfiguration | None = None, + **kwargs, + ) -> CreateActivityOutput: + raise NotImplementedError + + @handler("CreateStateMachine", expand=False) + def create_state_machine( + self, context: RequestContext, request: CreateStateMachineInput, **kwargs + ) -> CreateStateMachineOutput: + raise NotImplementedError + + @handler("CreateStateMachineAlias") + def create_state_machine_alias( + self, + context: RequestContext, + name: CharacterRestrictedName, + routing_configuration: RoutingConfigurationList, + description: AliasDescription | None = None, + **kwargs, + ) -> CreateStateMachineAliasOutput: + raise NotImplementedError + + @handler("DeleteActivity") + def delete_activity( + self, context: RequestContext, activity_arn: Arn, **kwargs + ) -> DeleteActivityOutput: + raise NotImplementedError + + @handler("DeleteStateMachine") + def delete_state_machine( + self, context: RequestContext, state_machine_arn: Arn, **kwargs + ) -> DeleteStateMachineOutput: + raise NotImplementedError + + @handler("DeleteStateMachineAlias") + def delete_state_machine_alias( + self, context: RequestContext, state_machine_alias_arn: Arn, **kwargs + ) -> DeleteStateMachineAliasOutput: + raise NotImplementedError + + @handler("DeleteStateMachineVersion") + def delete_state_machine_version( + self, context: RequestContext, state_machine_version_arn: LongArn, **kwargs + ) -> DeleteStateMachineVersionOutput: + raise NotImplementedError + + @handler("DescribeActivity") + def describe_activity( + self, context: RequestContext, activity_arn: Arn, **kwargs + ) -> DescribeActivityOutput: + raise NotImplementedError + + @handler("DescribeExecution") + def describe_execution( + self, + context: RequestContext, + execution_arn: Arn, + included_data: IncludedData | None = None, + **kwargs, + ) -> DescribeExecutionOutput: + raise NotImplementedError + + @handler("DescribeMapRun") + def describe_map_run( + self, context: RequestContext, map_run_arn: LongArn, **kwargs + ) -> DescribeMapRunOutput: + raise NotImplementedError + + @handler("DescribeStateMachine") + def describe_state_machine( + self, + context: RequestContext, + state_machine_arn: Arn, + included_data: IncludedData | None = None, + **kwargs, + ) -> DescribeStateMachineOutput: + raise NotImplementedError + + @handler("DescribeStateMachineAlias") + def describe_state_machine_alias( + self, context: RequestContext, state_machine_alias_arn: Arn, **kwargs + ) -> DescribeStateMachineAliasOutput: + raise NotImplementedError + + @handler("DescribeStateMachineForExecution") + def describe_state_machine_for_execution( + self, + context: RequestContext, + execution_arn: Arn, + included_data: IncludedData | None = None, + **kwargs, + ) -> DescribeStateMachineForExecutionOutput: + raise NotImplementedError + + @handler("GetActivityTask") + def get_activity_task( + self, context: RequestContext, activity_arn: Arn, worker_name: Name | None = None, **kwargs + ) -> GetActivityTaskOutput: + raise NotImplementedError + + @handler("GetExecutionHistory") + def get_execution_history( + self, + context: RequestContext, + execution_arn: Arn, + max_results: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + next_token: PageToken | None = None, + include_execution_data: IncludeExecutionDataGetExecutionHistory | None = None, + **kwargs, + ) -> GetExecutionHistoryOutput: + raise NotImplementedError + + @handler("ListActivities") + def list_activities( + self, + context: RequestContext, + max_results: PageSize | None = None, + next_token: PageToken | None = None, + **kwargs, + ) -> ListActivitiesOutput: + raise NotImplementedError + + @handler("ListExecutions") + def list_executions( + self, + context: RequestContext, + state_machine_arn: Arn | None = None, + status_filter: ExecutionStatus | None = None, + max_results: PageSize | None = None, + next_token: ListExecutionsPageToken | None = None, + map_run_arn: LongArn | None = None, + redrive_filter: ExecutionRedriveFilter | None = None, + **kwargs, + ) -> ListExecutionsOutput: + raise NotImplementedError + + @handler("ListMapRuns") + def list_map_runs( + self, + context: RequestContext, + execution_arn: Arn, + max_results: PageSize | None = None, + next_token: PageToken | None = None, + **kwargs, + ) -> ListMapRunsOutput: + raise NotImplementedError + + @handler("ListStateMachineAliases") + def list_state_machine_aliases( + self, + context: RequestContext, + state_machine_arn: Arn, + next_token: PageToken | None = None, + max_results: PageSize | None = None, + **kwargs, + ) -> ListStateMachineAliasesOutput: + raise NotImplementedError + + @handler("ListStateMachineVersions") + def list_state_machine_versions( + self, + context: RequestContext, + state_machine_arn: Arn, + next_token: PageToken | None = None, + max_results: PageSize | None = None, + **kwargs, + ) -> ListStateMachineVersionsOutput: + raise NotImplementedError + + @handler("ListStateMachines") + def list_state_machines( + self, + context: RequestContext, + max_results: PageSize | None = None, + next_token: PageToken | None = None, + **kwargs, + ) -> ListStateMachinesOutput: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: Arn, **kwargs + ) -> ListTagsForResourceOutput: + raise NotImplementedError + + @handler("PublishStateMachineVersion") + def publish_state_machine_version( + self, + context: RequestContext, + state_machine_arn: Arn, + revision_id: RevisionId | None = None, + description: VersionDescription | None = None, + **kwargs, + ) -> PublishStateMachineVersionOutput: + raise NotImplementedError + + @handler("RedriveExecution") + def redrive_execution( + self, + context: RequestContext, + execution_arn: Arn, + client_token: ClientToken | None = None, + **kwargs, + ) -> RedriveExecutionOutput: + raise NotImplementedError + + @handler("SendTaskFailure") + def send_task_failure( + self, + context: RequestContext, + task_token: TaskToken, + error: SensitiveError | None = None, + cause: SensitiveCause | None = None, + **kwargs, + ) -> SendTaskFailureOutput: + raise NotImplementedError + + @handler("SendTaskHeartbeat") + def send_task_heartbeat( + self, context: RequestContext, task_token: TaskToken, **kwargs + ) -> SendTaskHeartbeatOutput: + raise NotImplementedError + + @handler("SendTaskSuccess") + def send_task_success( + self, context: RequestContext, task_token: TaskToken, output: SensitiveData, **kwargs + ) -> SendTaskSuccessOutput: + raise NotImplementedError + + @handler("StartExecution") + def start_execution( + self, + context: RequestContext, + state_machine_arn: Arn, + name: Name | None = None, + input: SensitiveData | None = None, + trace_header: TraceHeader | None = None, + **kwargs, + ) -> StartExecutionOutput: + raise NotImplementedError + + @handler("StartSyncExecution") + def start_sync_execution( + self, + context: RequestContext, + state_machine_arn: Arn, + name: Name | None = None, + input: SensitiveData | None = None, + trace_header: TraceHeader | None = None, + included_data: IncludedData | None = None, + **kwargs, + ) -> StartSyncExecutionOutput: + raise NotImplementedError + + @handler("StopExecution") + def stop_execution( + self, + context: RequestContext, + execution_arn: Arn, + error: SensitiveError | None = None, + cause: SensitiveCause | None = None, + **kwargs, + ) -> StopExecutionOutput: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: Arn, tags: TagList, **kwargs + ) -> TagResourceOutput: + raise NotImplementedError + + @handler("TestState") + def test_state( + self, + context: RequestContext, + definition: Definition, + role_arn: Arn | None = None, + input: SensitiveData | None = None, + inspection_level: InspectionLevel | None = None, + reveal_secrets: RevealSecrets | None = None, + variables: SensitiveData | None = None, + **kwargs, + ) -> TestStateOutput: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: Arn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceOutput: + raise NotImplementedError + + @handler("UpdateMapRun") + def update_map_run( + self, + context: RequestContext, + map_run_arn: LongArn, + max_concurrency: MaxConcurrency | None = None, + tolerated_failure_percentage: ToleratedFailurePercentage | None = None, + tolerated_failure_count: ToleratedFailureCount | None = None, + **kwargs, + ) -> UpdateMapRunOutput: + raise NotImplementedError + + @handler("UpdateStateMachine") + def update_state_machine( + self, + context: RequestContext, + state_machine_arn: Arn, + definition: Definition | None = None, + role_arn: Arn | None = None, + logging_configuration: LoggingConfiguration | None = None, + tracing_configuration: TracingConfiguration | None = None, + publish: Publish | None = None, + version_description: VersionDescription | None = None, + encryption_configuration: EncryptionConfiguration | None = None, + **kwargs, + ) -> UpdateStateMachineOutput: + raise NotImplementedError + + @handler("UpdateStateMachineAlias") + def update_state_machine_alias( + self, + context: RequestContext, + state_machine_alias_arn: Arn, + description: AliasDescription | None = None, + routing_configuration: RoutingConfigurationList | None = None, + **kwargs, + ) -> UpdateStateMachineAliasOutput: + raise NotImplementedError + + @handler("ValidateStateMachineDefinition", expand=False) + def validate_state_machine_definition( + self, context: RequestContext, request: ValidateStateMachineDefinitionInput, **kwargs + ) -> ValidateStateMachineDefinitionOutput: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/sts/__init__.py b/localstack-core/localstack/aws/api/sts/__init__.py new file mode 100644 index 0000000000000..3a5e4c337c738 --- /dev/null +++ b/localstack-core/localstack/aws/api/sts/__init__.py @@ -0,0 +1,369 @@ +from datetime import datetime +from typing import List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Audience = str +Issuer = str +NameQualifier = str +RootDurationSecondsType = int +SAMLAssertionType = str +Subject = str +SubjectType = str +TargetPrincipalType = str +accessKeyIdType = str +accessKeySecretType = str +accountType = str +arnType = str +assumedRoleIdType = str +clientTokenType = str +contextAssertionType = str +decodedMessageType = str +durationSecondsType = int +encodedMessageType = str +expiredIdentityTokenMessage = str +externalIdType = str +federatedIdType = str +idpCommunicationErrorMessage = str +idpRejectedClaimMessage = str +invalidAuthorizationMessage = str +invalidIdentityTokenMessage = str +malformedPolicyDocumentMessage = str +nonNegativeIntegerType = int +packedPolicyTooLargeMessage = str +regionDisabledMessage = str +roleDurationSecondsType = int +roleSessionNameType = str +serialNumberType = str +sessionPolicyDocumentType = str +sourceIdentityType = str +tagKeyType = str +tagValueType = str +tokenCodeType = str +tokenType = str +unrestrictedSessionPolicyDocumentType = str +urlType = str +userIdType = str +userNameType = str +webIdentitySubjectType = str + + +class ExpiredTokenException(ServiceException): + code: str = "ExpiredTokenException" + sender_fault: bool = True + status_code: int = 400 + + +class IDPCommunicationErrorException(ServiceException): + code: str = "IDPCommunicationError" + sender_fault: bool = True + status_code: int = 400 + + +class IDPRejectedClaimException(ServiceException): + code: str = "IDPRejectedClaim" + sender_fault: bool = True + status_code: int = 403 + + +class InvalidAuthorizationMessageException(ServiceException): + code: str = "InvalidAuthorizationMessageException" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidIdentityTokenException(ServiceException): + code: str = "InvalidIdentityToken" + sender_fault: bool = True + status_code: int = 400 + + +class MalformedPolicyDocumentException(ServiceException): + code: str = "MalformedPolicyDocument" + sender_fault: bool = True + status_code: int = 400 + + +class PackedPolicyTooLargeException(ServiceException): + code: str = "PackedPolicyTooLarge" + sender_fault: bool = True + status_code: int = 400 + + +class RegionDisabledException(ServiceException): + code: str = "RegionDisabledException" + sender_fault: bool = True + status_code: int = 403 + + +class ProvidedContext(TypedDict, total=False): + ProviderArn: Optional[arnType] + ContextAssertion: Optional[contextAssertionType] + + +ProvidedContextsListType = List[ProvidedContext] +tagKeyListType = List[tagKeyType] + + +class Tag(TypedDict, total=False): + Key: tagKeyType + Value: tagValueType + + +tagListType = List[Tag] + + +class PolicyDescriptorType(TypedDict, total=False): + arn: Optional[arnType] + + +policyDescriptorListType = List[PolicyDescriptorType] + + +class AssumeRoleRequest(ServiceRequest): + RoleArn: arnType + RoleSessionName: roleSessionNameType + PolicyArns: Optional[policyDescriptorListType] + Policy: Optional[unrestrictedSessionPolicyDocumentType] + DurationSeconds: Optional[roleDurationSecondsType] + Tags: Optional[tagListType] + TransitiveTagKeys: Optional[tagKeyListType] + ExternalId: Optional[externalIdType] + SerialNumber: Optional[serialNumberType] + TokenCode: Optional[tokenCodeType] + SourceIdentity: Optional[sourceIdentityType] + ProvidedContexts: Optional[ProvidedContextsListType] + + +class AssumedRoleUser(TypedDict, total=False): + AssumedRoleId: assumedRoleIdType + Arn: arnType + + +dateType = datetime + + +class Credentials(TypedDict, total=False): + AccessKeyId: accessKeyIdType + SecretAccessKey: accessKeySecretType + SessionToken: tokenType + Expiration: dateType + + +class AssumeRoleResponse(TypedDict, total=False): + Credentials: Optional[Credentials] + AssumedRoleUser: Optional[AssumedRoleUser] + PackedPolicySize: Optional[nonNegativeIntegerType] + SourceIdentity: Optional[sourceIdentityType] + + +class AssumeRoleWithSAMLRequest(ServiceRequest): + RoleArn: arnType + PrincipalArn: arnType + SAMLAssertion: SAMLAssertionType + PolicyArns: Optional[policyDescriptorListType] + Policy: Optional[sessionPolicyDocumentType] + DurationSeconds: Optional[roleDurationSecondsType] + + +class AssumeRoleWithSAMLResponse(TypedDict, total=False): + Credentials: Optional[Credentials] + AssumedRoleUser: Optional[AssumedRoleUser] + PackedPolicySize: Optional[nonNegativeIntegerType] + Subject: Optional[Subject] + SubjectType: Optional[SubjectType] + Issuer: Optional[Issuer] + Audience: Optional[Audience] + NameQualifier: Optional[NameQualifier] + SourceIdentity: Optional[sourceIdentityType] + + +class AssumeRoleWithWebIdentityRequest(ServiceRequest): + RoleArn: arnType + RoleSessionName: roleSessionNameType + WebIdentityToken: clientTokenType + ProviderId: Optional[urlType] + PolicyArns: Optional[policyDescriptorListType] + Policy: Optional[sessionPolicyDocumentType] + DurationSeconds: Optional[roleDurationSecondsType] + + +class AssumeRoleWithWebIdentityResponse(TypedDict, total=False): + Credentials: Optional[Credentials] + SubjectFromWebIdentityToken: Optional[webIdentitySubjectType] + AssumedRoleUser: Optional[AssumedRoleUser] + PackedPolicySize: Optional[nonNegativeIntegerType] + Provider: Optional[Issuer] + Audience: Optional[Audience] + SourceIdentity: Optional[sourceIdentityType] + + +class AssumeRootRequest(ServiceRequest): + TargetPrincipal: TargetPrincipalType + TaskPolicyArn: PolicyDescriptorType + DurationSeconds: Optional[RootDurationSecondsType] + + +class AssumeRootResponse(TypedDict, total=False): + Credentials: Optional[Credentials] + SourceIdentity: Optional[sourceIdentityType] + + +class DecodeAuthorizationMessageRequest(ServiceRequest): + EncodedMessage: encodedMessageType + + +class DecodeAuthorizationMessageResponse(TypedDict, total=False): + DecodedMessage: Optional[decodedMessageType] + + +class FederatedUser(TypedDict, total=False): + FederatedUserId: federatedIdType + Arn: arnType + + +class GetAccessKeyInfoRequest(ServiceRequest): + AccessKeyId: accessKeyIdType + + +class GetAccessKeyInfoResponse(TypedDict, total=False): + Account: Optional[accountType] + + +class GetCallerIdentityRequest(ServiceRequest): + pass + + +class GetCallerIdentityResponse(TypedDict, total=False): + UserId: Optional[userIdType] + Account: Optional[accountType] + Arn: Optional[arnType] + + +class GetFederationTokenRequest(ServiceRequest): + Name: userNameType + Policy: Optional[sessionPolicyDocumentType] + PolicyArns: Optional[policyDescriptorListType] + DurationSeconds: Optional[durationSecondsType] + Tags: Optional[tagListType] + + +class GetFederationTokenResponse(TypedDict, total=False): + Credentials: Optional[Credentials] + FederatedUser: Optional[FederatedUser] + PackedPolicySize: Optional[nonNegativeIntegerType] + + +class GetSessionTokenRequest(ServiceRequest): + DurationSeconds: Optional[durationSecondsType] + SerialNumber: Optional[serialNumberType] + TokenCode: Optional[tokenCodeType] + + +class GetSessionTokenResponse(TypedDict, total=False): + Credentials: Optional[Credentials] + + +class StsApi: + service = "sts" + version = "2011-06-15" + + @handler("AssumeRole") + def assume_role( + self, + context: RequestContext, + role_arn: arnType, + role_session_name: roleSessionNameType, + policy_arns: policyDescriptorListType | None = None, + policy: unrestrictedSessionPolicyDocumentType | None = None, + duration_seconds: roleDurationSecondsType | None = None, + tags: tagListType | None = None, + transitive_tag_keys: tagKeyListType | None = None, + external_id: externalIdType | None = None, + serial_number: serialNumberType | None = None, + token_code: tokenCodeType | None = None, + source_identity: sourceIdentityType | None = None, + provided_contexts: ProvidedContextsListType | None = None, + **kwargs, + ) -> AssumeRoleResponse: + raise NotImplementedError + + @handler("AssumeRoleWithSAML") + def assume_role_with_saml( + self, + context: RequestContext, + role_arn: arnType, + principal_arn: arnType, + saml_assertion: SAMLAssertionType, + policy_arns: policyDescriptorListType | None = None, + policy: sessionPolicyDocumentType | None = None, + duration_seconds: roleDurationSecondsType | None = None, + **kwargs, + ) -> AssumeRoleWithSAMLResponse: + raise NotImplementedError + + @handler("AssumeRoleWithWebIdentity") + def assume_role_with_web_identity( + self, + context: RequestContext, + role_arn: arnType, + role_session_name: roleSessionNameType, + web_identity_token: clientTokenType, + provider_id: urlType | None = None, + policy_arns: policyDescriptorListType | None = None, + policy: sessionPolicyDocumentType | None = None, + duration_seconds: roleDurationSecondsType | None = None, + **kwargs, + ) -> AssumeRoleWithWebIdentityResponse: + raise NotImplementedError + + @handler("AssumeRoot") + def assume_root( + self, + context: RequestContext, + target_principal: TargetPrincipalType, + task_policy_arn: PolicyDescriptorType, + duration_seconds: RootDurationSecondsType | None = None, + **kwargs, + ) -> AssumeRootResponse: + raise NotImplementedError + + @handler("DecodeAuthorizationMessage") + def decode_authorization_message( + self, context: RequestContext, encoded_message: encodedMessageType, **kwargs + ) -> DecodeAuthorizationMessageResponse: + raise NotImplementedError + + @handler("GetAccessKeyInfo") + def get_access_key_info( + self, context: RequestContext, access_key_id: accessKeyIdType, **kwargs + ) -> GetAccessKeyInfoResponse: + raise NotImplementedError + + @handler("GetCallerIdentity") + def get_caller_identity(self, context: RequestContext, **kwargs) -> GetCallerIdentityResponse: + raise NotImplementedError + + @handler("GetFederationToken") + def get_federation_token( + self, + context: RequestContext, + name: userNameType, + policy: sessionPolicyDocumentType | None = None, + policy_arns: policyDescriptorListType | None = None, + duration_seconds: durationSecondsType | None = None, + tags: tagListType | None = None, + **kwargs, + ) -> GetFederationTokenResponse: + raise NotImplementedError + + @handler("GetSessionToken") + def get_session_token( + self, + context: RequestContext, + duration_seconds: durationSecondsType | None = None, + serial_number: serialNumberType | None = None, + token_code: tokenCodeType | None = None, + **kwargs, + ) -> GetSessionTokenResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/support/__init__.py b/localstack-core/localstack/aws/api/support/__init__.py new file mode 100644 index 0000000000000..c1575127c69e6 --- /dev/null +++ b/localstack-core/localstack/aws/api/support/__init__.py @@ -0,0 +1,622 @@ +from typing import List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +AfterTime = str +AttachmentId = str +AttachmentSetId = str +AvailabilityErrorMessage = str +BeforeTime = str +Boolean = bool +CaseId = str +CaseStatus = str +CategoryCode = str +CategoryName = str +CcEmailAddress = str +Code = str +CommunicationBody = str +Display = str +DisplayId = str +Double = float +EndTime = str +ErrorMessage = str +ExpiryTime = str +FileName = str +IncludeCommunications = bool +IncludeResolvedCases = bool +IssueType = str +Language = str +MaxResults = int +NextToken = str +Result = bool +ServiceCode = str +ServiceName = str +SeverityCode = str +SeverityLevelCode = str +SeverityLevelName = str +StartTime = str +Status = str +String = str +Subject = str +SubmittedBy = str +TimeCreated = str +Type = str +ValidatedCategoryCode = str +ValidatedCommunicationBody = str +ValidatedDateTime = str +ValidatedIssueTypeString = str +ValidatedLanguageAvailability = str +ValidatedServiceCode = str + + +class AttachmentIdNotFound(ServiceException): + code: str = "AttachmentIdNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class AttachmentLimitExceeded(ServiceException): + code: str = "AttachmentLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class AttachmentSetExpired(ServiceException): + code: str = "AttachmentSetExpired" + sender_fault: bool = False + status_code: int = 400 + + +class AttachmentSetIdNotFound(ServiceException): + code: str = "AttachmentSetIdNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class AttachmentSetSizeLimitExceeded(ServiceException): + code: str = "AttachmentSetSizeLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class CaseCreationLimitExceeded(ServiceException): + code: str = "CaseCreationLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class CaseIdNotFound(ServiceException): + code: str = "CaseIdNotFound" + sender_fault: bool = False + status_code: int = 400 + + +class DescribeAttachmentLimitExceeded(ServiceException): + code: str = "DescribeAttachmentLimitExceeded" + sender_fault: bool = False + status_code: int = 400 + + +class InternalServerError(ServiceException): + code: str = "InternalServerError" + sender_fault: bool = False + status_code: int = 400 + + +class ThrottlingException(ServiceException): + code: str = "ThrottlingException" + sender_fault: bool = False + status_code: int = 400 + + +Data = bytes + + +class Attachment(TypedDict, total=False): + fileName: Optional[FileName] + data: Optional[Data] + + +Attachments = List[Attachment] + + +class AddAttachmentsToSetRequest(ServiceRequest): + attachmentSetId: Optional[AttachmentSetId] + attachments: Attachments + + +class AddAttachmentsToSetResponse(TypedDict, total=False): + attachmentSetId: Optional[AttachmentSetId] + expiryTime: Optional[ExpiryTime] + + +CcEmailAddressList = List[CcEmailAddress] + + +class AddCommunicationToCaseRequest(ServiceRequest): + caseId: Optional[CaseId] + communicationBody: CommunicationBody + ccEmailAddresses: Optional[CcEmailAddressList] + attachmentSetId: Optional[AttachmentSetId] + + +class AddCommunicationToCaseResponse(TypedDict, total=False): + result: Optional[Result] + + +class AttachmentDetails(TypedDict, total=False): + attachmentId: Optional[AttachmentId] + fileName: Optional[FileName] + + +AttachmentSet = List[AttachmentDetails] + + +class Communication(TypedDict, total=False): + caseId: Optional[CaseId] + body: Optional[ValidatedCommunicationBody] + submittedBy: Optional[SubmittedBy] + timeCreated: Optional[TimeCreated] + attachmentSet: Optional[AttachmentSet] + + +CommunicationList = List[Communication] + + +class RecentCaseCommunications(TypedDict, total=False): + communications: Optional[CommunicationList] + nextToken: Optional[NextToken] + + +class CaseDetails(TypedDict, total=False): + caseId: Optional[CaseId] + displayId: Optional[DisplayId] + subject: Optional[Subject] + status: Optional[Status] + serviceCode: Optional[ServiceCode] + categoryCode: Optional[CategoryCode] + severityCode: Optional[SeverityCode] + submittedBy: Optional[SubmittedBy] + timeCreated: Optional[TimeCreated] + recentCommunications: Optional[RecentCaseCommunications] + ccEmailAddresses: Optional[CcEmailAddressList] + language: Optional[Language] + + +CaseIdList = List[CaseId] +CaseList = List[CaseDetails] + + +class Category(TypedDict, total=False): + code: Optional[CategoryCode] + name: Optional[CategoryName] + + +CategoryList = List[Category] + + +class DateInterval(TypedDict, total=False): + startDateTime: Optional[ValidatedDateTime] + endDateTime: Optional[ValidatedDateTime] + + +DatesWithoutSupportList = List[DateInterval] + + +class SupportedHour(TypedDict, total=False): + startTime: Optional[StartTime] + endTime: Optional[EndTime] + + +SupportedHoursList = List[SupportedHour] +CommunicationTypeOptions = TypedDict( + "CommunicationTypeOptions", + { + "type": Optional[Type], + "supportedHours": Optional[SupportedHoursList], + "datesWithoutSupport": Optional[DatesWithoutSupportList], + }, + total=False, +) +CommunicationTypeOptionsList = List[CommunicationTypeOptions] + + +class CreateCaseRequest(ServiceRequest): + subject: Subject + serviceCode: Optional[ServiceCode] + severityCode: Optional[SeverityCode] + categoryCode: Optional[CategoryCode] + communicationBody: CommunicationBody + ccEmailAddresses: Optional[CcEmailAddressList] + language: Optional[Language] + issueType: Optional[IssueType] + attachmentSetId: Optional[AttachmentSetId] + + +class CreateCaseResponse(TypedDict, total=False): + caseId: Optional[CaseId] + + +class DescribeAttachmentRequest(ServiceRequest): + attachmentId: AttachmentId + + +class DescribeAttachmentResponse(TypedDict, total=False): + attachment: Optional[Attachment] + + +class DescribeCasesRequest(ServiceRequest): + caseIdList: Optional[CaseIdList] + displayId: Optional[DisplayId] + afterTime: Optional[AfterTime] + beforeTime: Optional[BeforeTime] + includeResolvedCases: Optional[IncludeResolvedCases] + nextToken: Optional[NextToken] + maxResults: Optional[MaxResults] + language: Optional[Language] + includeCommunications: Optional[IncludeCommunications] + + +class DescribeCasesResponse(TypedDict, total=False): + cases: Optional[CaseList] + nextToken: Optional[NextToken] + + +class DescribeCommunicationsRequest(ServiceRequest): + caseId: CaseId + beforeTime: Optional[BeforeTime] + afterTime: Optional[AfterTime] + nextToken: Optional[NextToken] + maxResults: Optional[MaxResults] + + +class DescribeCommunicationsResponse(TypedDict, total=False): + communications: Optional[CommunicationList] + nextToken: Optional[NextToken] + + +class DescribeCreateCaseOptionsRequest(ServiceRequest): + issueType: IssueType + serviceCode: ServiceCode + language: Language + categoryCode: CategoryCode + + +class DescribeCreateCaseOptionsResponse(TypedDict, total=False): + languageAvailability: Optional[ValidatedLanguageAvailability] + communicationTypes: Optional[CommunicationTypeOptionsList] + + +ServiceCodeList = List[ServiceCode] + + +class DescribeServicesRequest(ServiceRequest): + serviceCodeList: Optional[ServiceCodeList] + language: Optional[Language] + + +class Service(TypedDict, total=False): + code: Optional[ServiceCode] + name: Optional[ServiceName] + categories: Optional[CategoryList] + + +ServiceList = List[Service] + + +class DescribeServicesResponse(TypedDict, total=False): + services: Optional[ServiceList] + + +class DescribeSeverityLevelsRequest(ServiceRequest): + language: Optional[Language] + + +class SeverityLevel(TypedDict, total=False): + code: Optional[SeverityLevelCode] + name: Optional[SeverityLevelName] + + +SeverityLevelsList = List[SeverityLevel] + + +class DescribeSeverityLevelsResponse(TypedDict, total=False): + severityLevels: Optional[SeverityLevelsList] + + +class DescribeSupportedLanguagesRequest(ServiceRequest): + issueType: ValidatedIssueTypeString + serviceCode: ValidatedServiceCode + categoryCode: ValidatedCategoryCode + + +class SupportedLanguage(TypedDict, total=False): + code: Optional[Code] + language: Optional[Language] + display: Optional[Display] + + +SupportedLanguagesList = List[SupportedLanguage] + + +class DescribeSupportedLanguagesResponse(TypedDict, total=False): + supportedLanguages: Optional[SupportedLanguagesList] + + +StringList = List[String] + + +class DescribeTrustedAdvisorCheckRefreshStatusesRequest(ServiceRequest): + checkIds: StringList + + +Long = int + + +class TrustedAdvisorCheckRefreshStatus(TypedDict, total=False): + checkId: String + status: String + millisUntilNextRefreshable: Long + + +TrustedAdvisorCheckRefreshStatusList = List[TrustedAdvisorCheckRefreshStatus] + + +class DescribeTrustedAdvisorCheckRefreshStatusesResponse(TypedDict, total=False): + statuses: TrustedAdvisorCheckRefreshStatusList + + +class DescribeTrustedAdvisorCheckResultRequest(ServiceRequest): + checkId: String + language: Optional[String] + + +class TrustedAdvisorResourceDetail(TypedDict, total=False): + status: String + region: Optional[String] + resourceId: String + isSuppressed: Optional[Boolean] + metadata: StringList + + +TrustedAdvisorResourceDetailList = List[TrustedAdvisorResourceDetail] + + +class TrustedAdvisorCostOptimizingSummary(TypedDict, total=False): + estimatedMonthlySavings: Double + estimatedPercentMonthlySavings: Double + + +class TrustedAdvisorCategorySpecificSummary(TypedDict, total=False): + costOptimizing: Optional[TrustedAdvisorCostOptimizingSummary] + + +class TrustedAdvisorResourcesSummary(TypedDict, total=False): + resourcesProcessed: Long + resourcesFlagged: Long + resourcesIgnored: Long + resourcesSuppressed: Long + + +class TrustedAdvisorCheckResult(TypedDict, total=False): + checkId: String + timestamp: String + status: String + resourcesSummary: TrustedAdvisorResourcesSummary + categorySpecificSummary: TrustedAdvisorCategorySpecificSummary + flaggedResources: TrustedAdvisorResourceDetailList + + +class DescribeTrustedAdvisorCheckResultResponse(TypedDict, total=False): + result: Optional[TrustedAdvisorCheckResult] + + +class DescribeTrustedAdvisorCheckSummariesRequest(ServiceRequest): + checkIds: StringList + + +class TrustedAdvisorCheckSummary(TypedDict, total=False): + checkId: String + timestamp: String + status: String + hasFlaggedResources: Optional[Boolean] + resourcesSummary: TrustedAdvisorResourcesSummary + categorySpecificSummary: TrustedAdvisorCategorySpecificSummary + + +TrustedAdvisorCheckSummaryList = List[TrustedAdvisorCheckSummary] + + +class DescribeTrustedAdvisorCheckSummariesResponse(TypedDict, total=False): + summaries: TrustedAdvisorCheckSummaryList + + +class DescribeTrustedAdvisorChecksRequest(ServiceRequest): + language: String + + +class TrustedAdvisorCheckDescription(TypedDict, total=False): + id: String + name: String + description: String + category: String + metadata: StringList + + +TrustedAdvisorCheckList = List[TrustedAdvisorCheckDescription] + + +class DescribeTrustedAdvisorChecksResponse(TypedDict, total=False): + checks: TrustedAdvisorCheckList + + +class RefreshTrustedAdvisorCheckRequest(ServiceRequest): + checkId: String + + +class RefreshTrustedAdvisorCheckResponse(TypedDict, total=False): + status: TrustedAdvisorCheckRefreshStatus + + +class ResolveCaseRequest(ServiceRequest): + caseId: Optional[CaseId] + + +class ResolveCaseResponse(TypedDict, total=False): + initialCaseStatus: Optional[CaseStatus] + finalCaseStatus: Optional[CaseStatus] + + +class SupportApi: + service = "support" + version = "2013-04-15" + + @handler("AddAttachmentsToSet") + def add_attachments_to_set( + self, + context: RequestContext, + attachments: Attachments, + attachment_set_id: AttachmentSetId | None = None, + **kwargs, + ) -> AddAttachmentsToSetResponse: + raise NotImplementedError + + @handler("AddCommunicationToCase") + def add_communication_to_case( + self, + context: RequestContext, + communication_body: CommunicationBody, + case_id: CaseId | None = None, + cc_email_addresses: CcEmailAddressList | None = None, + attachment_set_id: AttachmentSetId | None = None, + **kwargs, + ) -> AddCommunicationToCaseResponse: + raise NotImplementedError + + @handler("CreateCase") + def create_case( + self, + context: RequestContext, + subject: Subject, + communication_body: CommunicationBody, + service_code: ServiceCode | None = None, + severity_code: SeverityCode | None = None, + category_code: CategoryCode | None = None, + cc_email_addresses: CcEmailAddressList | None = None, + language: Language | None = None, + issue_type: IssueType | None = None, + attachment_set_id: AttachmentSetId | None = None, + **kwargs, + ) -> CreateCaseResponse: + raise NotImplementedError + + @handler("DescribeAttachment") + def describe_attachment( + self, context: RequestContext, attachment_id: AttachmentId, **kwargs + ) -> DescribeAttachmentResponse: + raise NotImplementedError + + @handler("DescribeCases") + def describe_cases( + self, + context: RequestContext, + case_id_list: CaseIdList | None = None, + display_id: DisplayId | None = None, + after_time: AfterTime | None = None, + before_time: BeforeTime | None = None, + include_resolved_cases: IncludeResolvedCases | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + language: Language | None = None, + include_communications: IncludeCommunications | None = None, + **kwargs, + ) -> DescribeCasesResponse: + raise NotImplementedError + + @handler("DescribeCommunications") + def describe_communications( + self, + context: RequestContext, + case_id: CaseId, + before_time: BeforeTime | None = None, + after_time: AfterTime | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> DescribeCommunicationsResponse: + raise NotImplementedError + + @handler("DescribeCreateCaseOptions") + def describe_create_case_options( + self, + context: RequestContext, + issue_type: IssueType, + service_code: ServiceCode, + language: Language, + category_code: CategoryCode, + **kwargs, + ) -> DescribeCreateCaseOptionsResponse: + raise NotImplementedError + + @handler("DescribeServices") + def describe_services( + self, + context: RequestContext, + service_code_list: ServiceCodeList | None = None, + language: Language | None = None, + **kwargs, + ) -> DescribeServicesResponse: + raise NotImplementedError + + @handler("DescribeSeverityLevels") + def describe_severity_levels( + self, context: RequestContext, language: Language | None = None, **kwargs + ) -> DescribeSeverityLevelsResponse: + raise NotImplementedError + + @handler("DescribeSupportedLanguages") + def describe_supported_languages( + self, + context: RequestContext, + issue_type: ValidatedIssueTypeString, + service_code: ValidatedServiceCode, + category_code: ValidatedCategoryCode, + **kwargs, + ) -> DescribeSupportedLanguagesResponse: + raise NotImplementedError + + @handler("DescribeTrustedAdvisorCheckRefreshStatuses") + def describe_trusted_advisor_check_refresh_statuses( + self, context: RequestContext, check_ids: StringList, **kwargs + ) -> DescribeTrustedAdvisorCheckRefreshStatusesResponse: + raise NotImplementedError + + @handler("DescribeTrustedAdvisorCheckResult") + def describe_trusted_advisor_check_result( + self, context: RequestContext, check_id: String, language: String | None = None, **kwargs + ) -> DescribeTrustedAdvisorCheckResultResponse: + raise NotImplementedError + + @handler("DescribeTrustedAdvisorCheckSummaries") + def describe_trusted_advisor_check_summaries( + self, context: RequestContext, check_ids: StringList, **kwargs + ) -> DescribeTrustedAdvisorCheckSummariesResponse: + raise NotImplementedError + + @handler("DescribeTrustedAdvisorChecks") + def describe_trusted_advisor_checks( + self, context: RequestContext, language: String, **kwargs + ) -> DescribeTrustedAdvisorChecksResponse: + raise NotImplementedError + + @handler("RefreshTrustedAdvisorCheck") + def refresh_trusted_advisor_check( + self, context: RequestContext, check_id: String, **kwargs + ) -> RefreshTrustedAdvisorCheckResponse: + raise NotImplementedError + + @handler("ResolveCase") + def resolve_case( + self, context: RequestContext, case_id: CaseId | None = None, **kwargs + ) -> ResolveCaseResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/swf/__init__.py b/localstack-core/localstack/aws/api/swf/__init__.py new file mode 100644 index 0000000000000..23653779f7e9f --- /dev/null +++ b/localstack-core/localstack/aws/api/swf/__init__.py @@ -0,0 +1,1861 @@ +from datetime import datetime +from enum import StrEnum +from typing import List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +ActivityId = str +Arn = str +Canceled = bool +CauseMessage = str +Count = int +Data = str +Description = str +DomainName = str +DurationInDays = str +DurationInSeconds = str +DurationInSecondsOptional = str +ErrorMessage = str +FailureReason = str +FunctionId = str +FunctionInput = str +FunctionName = str +Identity = str +LimitedData = str +MarkerName = str +Name = str +OpenDecisionTasksCount = int +PageSize = int +PageToken = str +ResourceTagKey = str +ResourceTagValue = str +ReverseOrder = bool +SignalName = str +StartAtPreviousStartedEvent = bool +Tag = str +TaskPriority = str +TaskToken = str +TerminateReason = str +TimerId = str +Truncated = bool +Version = str +VersionOptional = str +WorkflowId = str +WorkflowRunId = str +WorkflowRunIdOptional = str + + +class ActivityTaskTimeoutType(StrEnum): + START_TO_CLOSE = "START_TO_CLOSE" + SCHEDULE_TO_START = "SCHEDULE_TO_START" + SCHEDULE_TO_CLOSE = "SCHEDULE_TO_CLOSE" + HEARTBEAT = "HEARTBEAT" + + +class CancelTimerFailedCause(StrEnum): + TIMER_ID_UNKNOWN = "TIMER_ID_UNKNOWN" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class CancelWorkflowExecutionFailedCause(StrEnum): + UNHANDLED_DECISION = "UNHANDLED_DECISION" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class ChildPolicy(StrEnum): + TERMINATE = "TERMINATE" + REQUEST_CANCEL = "REQUEST_CANCEL" + ABANDON = "ABANDON" + + +class CloseStatus(StrEnum): + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELED = "CANCELED" + TERMINATED = "TERMINATED" + CONTINUED_AS_NEW = "CONTINUED_AS_NEW" + TIMED_OUT = "TIMED_OUT" + + +class CompleteWorkflowExecutionFailedCause(StrEnum): + UNHANDLED_DECISION = "UNHANDLED_DECISION" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class ContinueAsNewWorkflowExecutionFailedCause(StrEnum): + UNHANDLED_DECISION = "UNHANDLED_DECISION" + WORKFLOW_TYPE_DEPRECATED = "WORKFLOW_TYPE_DEPRECATED" + WORKFLOW_TYPE_DOES_NOT_EXIST = "WORKFLOW_TYPE_DOES_NOT_EXIST" + DEFAULT_EXECUTION_START_TO_CLOSE_TIMEOUT_UNDEFINED = ( + "DEFAULT_EXECUTION_START_TO_CLOSE_TIMEOUT_UNDEFINED" + ) + DEFAULT_TASK_START_TO_CLOSE_TIMEOUT_UNDEFINED = "DEFAULT_TASK_START_TO_CLOSE_TIMEOUT_UNDEFINED" + DEFAULT_TASK_LIST_UNDEFINED = "DEFAULT_TASK_LIST_UNDEFINED" + DEFAULT_CHILD_POLICY_UNDEFINED = "DEFAULT_CHILD_POLICY_UNDEFINED" + CONTINUE_AS_NEW_WORKFLOW_EXECUTION_RATE_EXCEEDED = ( + "CONTINUE_AS_NEW_WORKFLOW_EXECUTION_RATE_EXCEEDED" + ) + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class DecisionTaskTimeoutType(StrEnum): + START_TO_CLOSE = "START_TO_CLOSE" + SCHEDULE_TO_START = "SCHEDULE_TO_START" + + +class DecisionType(StrEnum): + ScheduleActivityTask = "ScheduleActivityTask" + RequestCancelActivityTask = "RequestCancelActivityTask" + CompleteWorkflowExecution = "CompleteWorkflowExecution" + FailWorkflowExecution = "FailWorkflowExecution" + CancelWorkflowExecution = "CancelWorkflowExecution" + ContinueAsNewWorkflowExecution = "ContinueAsNewWorkflowExecution" + RecordMarker = "RecordMarker" + StartTimer = "StartTimer" + CancelTimer = "CancelTimer" + SignalExternalWorkflowExecution = "SignalExternalWorkflowExecution" + RequestCancelExternalWorkflowExecution = "RequestCancelExternalWorkflowExecution" + StartChildWorkflowExecution = "StartChildWorkflowExecution" + ScheduleLambdaFunction = "ScheduleLambdaFunction" + + +class EventType(StrEnum): + WorkflowExecutionStarted = "WorkflowExecutionStarted" + WorkflowExecutionCancelRequested = "WorkflowExecutionCancelRequested" + WorkflowExecutionCompleted = "WorkflowExecutionCompleted" + CompleteWorkflowExecutionFailed = "CompleteWorkflowExecutionFailed" + WorkflowExecutionFailed = "WorkflowExecutionFailed" + FailWorkflowExecutionFailed = "FailWorkflowExecutionFailed" + WorkflowExecutionTimedOut = "WorkflowExecutionTimedOut" + WorkflowExecutionCanceled = "WorkflowExecutionCanceled" + CancelWorkflowExecutionFailed = "CancelWorkflowExecutionFailed" + WorkflowExecutionContinuedAsNew = "WorkflowExecutionContinuedAsNew" + ContinueAsNewWorkflowExecutionFailed = "ContinueAsNewWorkflowExecutionFailed" + WorkflowExecutionTerminated = "WorkflowExecutionTerminated" + DecisionTaskScheduled = "DecisionTaskScheduled" + DecisionTaskStarted = "DecisionTaskStarted" + DecisionTaskCompleted = "DecisionTaskCompleted" + DecisionTaskTimedOut = "DecisionTaskTimedOut" + ActivityTaskScheduled = "ActivityTaskScheduled" + ScheduleActivityTaskFailed = "ScheduleActivityTaskFailed" + ActivityTaskStarted = "ActivityTaskStarted" + ActivityTaskCompleted = "ActivityTaskCompleted" + ActivityTaskFailed = "ActivityTaskFailed" + ActivityTaskTimedOut = "ActivityTaskTimedOut" + ActivityTaskCanceled = "ActivityTaskCanceled" + ActivityTaskCancelRequested = "ActivityTaskCancelRequested" + RequestCancelActivityTaskFailed = "RequestCancelActivityTaskFailed" + WorkflowExecutionSignaled = "WorkflowExecutionSignaled" + MarkerRecorded = "MarkerRecorded" + RecordMarkerFailed = "RecordMarkerFailed" + TimerStarted = "TimerStarted" + StartTimerFailed = "StartTimerFailed" + TimerFired = "TimerFired" + TimerCanceled = "TimerCanceled" + CancelTimerFailed = "CancelTimerFailed" + StartChildWorkflowExecutionInitiated = "StartChildWorkflowExecutionInitiated" + StartChildWorkflowExecutionFailed = "StartChildWorkflowExecutionFailed" + ChildWorkflowExecutionStarted = "ChildWorkflowExecutionStarted" + ChildWorkflowExecutionCompleted = "ChildWorkflowExecutionCompleted" + ChildWorkflowExecutionFailed = "ChildWorkflowExecutionFailed" + ChildWorkflowExecutionTimedOut = "ChildWorkflowExecutionTimedOut" + ChildWorkflowExecutionCanceled = "ChildWorkflowExecutionCanceled" + ChildWorkflowExecutionTerminated = "ChildWorkflowExecutionTerminated" + SignalExternalWorkflowExecutionInitiated = "SignalExternalWorkflowExecutionInitiated" + SignalExternalWorkflowExecutionFailed = "SignalExternalWorkflowExecutionFailed" + ExternalWorkflowExecutionSignaled = "ExternalWorkflowExecutionSignaled" + RequestCancelExternalWorkflowExecutionInitiated = ( + "RequestCancelExternalWorkflowExecutionInitiated" + ) + RequestCancelExternalWorkflowExecutionFailed = "RequestCancelExternalWorkflowExecutionFailed" + ExternalWorkflowExecutionCancelRequested = "ExternalWorkflowExecutionCancelRequested" + LambdaFunctionScheduled = "LambdaFunctionScheduled" + LambdaFunctionStarted = "LambdaFunctionStarted" + LambdaFunctionCompleted = "LambdaFunctionCompleted" + LambdaFunctionFailed = "LambdaFunctionFailed" + LambdaFunctionTimedOut = "LambdaFunctionTimedOut" + ScheduleLambdaFunctionFailed = "ScheduleLambdaFunctionFailed" + StartLambdaFunctionFailed = "StartLambdaFunctionFailed" + + +class ExecutionStatus(StrEnum): + OPEN = "OPEN" + CLOSED = "CLOSED" + + +class FailWorkflowExecutionFailedCause(StrEnum): + UNHANDLED_DECISION = "UNHANDLED_DECISION" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class LambdaFunctionTimeoutType(StrEnum): + START_TO_CLOSE = "START_TO_CLOSE" + + +class RecordMarkerFailedCause(StrEnum): + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class RegistrationStatus(StrEnum): + REGISTERED = "REGISTERED" + DEPRECATED = "DEPRECATED" + + +class RequestCancelActivityTaskFailedCause(StrEnum): + ACTIVITY_ID_UNKNOWN = "ACTIVITY_ID_UNKNOWN" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class RequestCancelExternalWorkflowExecutionFailedCause(StrEnum): + UNKNOWN_EXTERNAL_WORKFLOW_EXECUTION = "UNKNOWN_EXTERNAL_WORKFLOW_EXECUTION" + REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION_RATE_EXCEEDED = ( + "REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION_RATE_EXCEEDED" + ) + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class ScheduleActivityTaskFailedCause(StrEnum): + ACTIVITY_TYPE_DEPRECATED = "ACTIVITY_TYPE_DEPRECATED" + ACTIVITY_TYPE_DOES_NOT_EXIST = "ACTIVITY_TYPE_DOES_NOT_EXIST" + ACTIVITY_ID_ALREADY_IN_USE = "ACTIVITY_ID_ALREADY_IN_USE" + OPEN_ACTIVITIES_LIMIT_EXCEEDED = "OPEN_ACTIVITIES_LIMIT_EXCEEDED" + ACTIVITY_CREATION_RATE_EXCEEDED = "ACTIVITY_CREATION_RATE_EXCEEDED" + DEFAULT_SCHEDULE_TO_CLOSE_TIMEOUT_UNDEFINED = "DEFAULT_SCHEDULE_TO_CLOSE_TIMEOUT_UNDEFINED" + DEFAULT_TASK_LIST_UNDEFINED = "DEFAULT_TASK_LIST_UNDEFINED" + DEFAULT_SCHEDULE_TO_START_TIMEOUT_UNDEFINED = "DEFAULT_SCHEDULE_TO_START_TIMEOUT_UNDEFINED" + DEFAULT_START_TO_CLOSE_TIMEOUT_UNDEFINED = "DEFAULT_START_TO_CLOSE_TIMEOUT_UNDEFINED" + DEFAULT_HEARTBEAT_TIMEOUT_UNDEFINED = "DEFAULT_HEARTBEAT_TIMEOUT_UNDEFINED" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class ScheduleLambdaFunctionFailedCause(StrEnum): + ID_ALREADY_IN_USE = "ID_ALREADY_IN_USE" + OPEN_LAMBDA_FUNCTIONS_LIMIT_EXCEEDED = "OPEN_LAMBDA_FUNCTIONS_LIMIT_EXCEEDED" + LAMBDA_FUNCTION_CREATION_RATE_EXCEEDED = "LAMBDA_FUNCTION_CREATION_RATE_EXCEEDED" + LAMBDA_SERVICE_NOT_AVAILABLE_IN_REGION = "LAMBDA_SERVICE_NOT_AVAILABLE_IN_REGION" + + +class SignalExternalWorkflowExecutionFailedCause(StrEnum): + UNKNOWN_EXTERNAL_WORKFLOW_EXECUTION = "UNKNOWN_EXTERNAL_WORKFLOW_EXECUTION" + SIGNAL_EXTERNAL_WORKFLOW_EXECUTION_RATE_EXCEEDED = ( + "SIGNAL_EXTERNAL_WORKFLOW_EXECUTION_RATE_EXCEEDED" + ) + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class StartChildWorkflowExecutionFailedCause(StrEnum): + WORKFLOW_TYPE_DOES_NOT_EXIST = "WORKFLOW_TYPE_DOES_NOT_EXIST" + WORKFLOW_TYPE_DEPRECATED = "WORKFLOW_TYPE_DEPRECATED" + OPEN_CHILDREN_LIMIT_EXCEEDED = "OPEN_CHILDREN_LIMIT_EXCEEDED" + OPEN_WORKFLOWS_LIMIT_EXCEEDED = "OPEN_WORKFLOWS_LIMIT_EXCEEDED" + CHILD_CREATION_RATE_EXCEEDED = "CHILD_CREATION_RATE_EXCEEDED" + WORKFLOW_ALREADY_RUNNING = "WORKFLOW_ALREADY_RUNNING" + DEFAULT_EXECUTION_START_TO_CLOSE_TIMEOUT_UNDEFINED = ( + "DEFAULT_EXECUTION_START_TO_CLOSE_TIMEOUT_UNDEFINED" + ) + DEFAULT_TASK_LIST_UNDEFINED = "DEFAULT_TASK_LIST_UNDEFINED" + DEFAULT_TASK_START_TO_CLOSE_TIMEOUT_UNDEFINED = "DEFAULT_TASK_START_TO_CLOSE_TIMEOUT_UNDEFINED" + DEFAULT_CHILD_POLICY_UNDEFINED = "DEFAULT_CHILD_POLICY_UNDEFINED" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class StartLambdaFunctionFailedCause(StrEnum): + ASSUME_ROLE_FAILED = "ASSUME_ROLE_FAILED" + + +class StartTimerFailedCause(StrEnum): + TIMER_ID_ALREADY_IN_USE = "TIMER_ID_ALREADY_IN_USE" + OPEN_TIMERS_LIMIT_EXCEEDED = "OPEN_TIMERS_LIMIT_EXCEEDED" + TIMER_CREATION_RATE_EXCEEDED = "TIMER_CREATION_RATE_EXCEEDED" + OPERATION_NOT_PERMITTED = "OPERATION_NOT_PERMITTED" + + +class WorkflowExecutionCancelRequestedCause(StrEnum): + CHILD_POLICY_APPLIED = "CHILD_POLICY_APPLIED" + + +class WorkflowExecutionTerminatedCause(StrEnum): + CHILD_POLICY_APPLIED = "CHILD_POLICY_APPLIED" + EVENT_LIMIT_EXCEEDED = "EVENT_LIMIT_EXCEEDED" + OPERATOR_INITIATED = "OPERATOR_INITIATED" + + +class WorkflowExecutionTimeoutType(StrEnum): + START_TO_CLOSE = "START_TO_CLOSE" + + +class DefaultUndefinedFault(ServiceException): + code: str = "DefaultUndefinedFault" + sender_fault: bool = False + status_code: int = 400 + + +class DomainAlreadyExistsFault(ServiceException): + code: str = "DomainAlreadyExistsFault" + sender_fault: bool = False + status_code: int = 400 + + +class DomainDeprecatedFault(ServiceException): + code: str = "DomainDeprecatedFault" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededFault(ServiceException): + code: str = "LimitExceededFault" + sender_fault: bool = False + status_code: int = 400 + + +class OperationNotPermittedFault(ServiceException): + code: str = "OperationNotPermittedFault" + sender_fault: bool = False + status_code: int = 400 + + +class TooManyTagsFault(ServiceException): + code: str = "TooManyTagsFault" + sender_fault: bool = False + status_code: int = 400 + + +class TypeAlreadyExistsFault(ServiceException): + code: str = "TypeAlreadyExistsFault" + sender_fault: bool = False + status_code: int = 400 + + +class TypeDeprecatedFault(ServiceException): + code: str = "TypeDeprecatedFault" + sender_fault: bool = False + status_code: int = 400 + + +class TypeNotDeprecatedFault(ServiceException): + code: str = "TypeNotDeprecatedFault" + sender_fault: bool = False + status_code: int = 400 + + +class UnknownResourceFault(ServiceException): + code: str = "UnknownResourceFault" + sender_fault: bool = False + status_code: int = 400 + + +class WorkflowExecutionAlreadyStartedFault(ServiceException): + code: str = "WorkflowExecutionAlreadyStartedFault" + sender_fault: bool = False + status_code: int = 400 + + +class ActivityType(TypedDict, total=False): + name: Name + version: Version + + +class WorkflowExecution(TypedDict, total=False): + workflowId: WorkflowId + runId: WorkflowRunId + + +EventId = int + + +class ActivityTask(TypedDict, total=False): + taskToken: TaskToken + activityId: ActivityId + startedEventId: EventId + workflowExecution: WorkflowExecution + activityType: ActivityType + input: Optional[Data] + + +class ActivityTaskCancelRequestedEventAttributes(TypedDict, total=False): + decisionTaskCompletedEventId: EventId + activityId: ActivityId + + +class ActivityTaskCanceledEventAttributes(TypedDict, total=False): + details: Optional[Data] + scheduledEventId: EventId + startedEventId: EventId + latestCancelRequestedEventId: Optional[EventId] + + +class ActivityTaskCompletedEventAttributes(TypedDict, total=False): + result: Optional[Data] + scheduledEventId: EventId + startedEventId: EventId + + +class ActivityTaskFailedEventAttributes(TypedDict, total=False): + reason: Optional[FailureReason] + details: Optional[Data] + scheduledEventId: EventId + startedEventId: EventId + + +class TaskList(TypedDict, total=False): + name: Name + + +class ActivityTaskScheduledEventAttributes(TypedDict, total=False): + activityType: ActivityType + activityId: ActivityId + input: Optional[Data] + control: Optional[Data] + scheduleToStartTimeout: Optional[DurationInSecondsOptional] + scheduleToCloseTimeout: Optional[DurationInSecondsOptional] + startToCloseTimeout: Optional[DurationInSecondsOptional] + taskList: TaskList + taskPriority: Optional[TaskPriority] + decisionTaskCompletedEventId: EventId + heartbeatTimeout: Optional[DurationInSecondsOptional] + + +class ActivityTaskStartedEventAttributes(TypedDict, total=False): + identity: Optional[Identity] + scheduledEventId: EventId + + +class ActivityTaskStatus(TypedDict, total=False): + cancelRequested: Canceled + + +class ActivityTaskTimedOutEventAttributes(TypedDict, total=False): + timeoutType: ActivityTaskTimeoutType + scheduledEventId: EventId + startedEventId: EventId + details: Optional[LimitedData] + + +class ActivityTypeConfiguration(TypedDict, total=False): + defaultTaskStartToCloseTimeout: Optional[DurationInSecondsOptional] + defaultTaskHeartbeatTimeout: Optional[DurationInSecondsOptional] + defaultTaskList: Optional[TaskList] + defaultTaskPriority: Optional[TaskPriority] + defaultTaskScheduleToStartTimeout: Optional[DurationInSecondsOptional] + defaultTaskScheduleToCloseTimeout: Optional[DurationInSecondsOptional] + + +Timestamp = datetime + + +class ActivityTypeInfo(TypedDict, total=False): + activityType: ActivityType + status: RegistrationStatus + description: Optional[Description] + creationDate: Timestamp + deprecationDate: Optional[Timestamp] + + +class ActivityTypeDetail(TypedDict, total=False): + typeInfo: ActivityTypeInfo + configuration: ActivityTypeConfiguration + + +ActivityTypeInfoList = List[ActivityTypeInfo] + + +class ActivityTypeInfos(TypedDict, total=False): + typeInfos: ActivityTypeInfoList + nextPageToken: Optional[PageToken] + + +class CancelTimerDecisionAttributes(TypedDict, total=False): + timerId: TimerId + + +class CancelTimerFailedEventAttributes(TypedDict, total=False): + timerId: TimerId + cause: CancelTimerFailedCause + decisionTaskCompletedEventId: EventId + + +class CancelWorkflowExecutionDecisionAttributes(TypedDict, total=False): + details: Optional[Data] + + +class CancelWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + cause: CancelWorkflowExecutionFailedCause + decisionTaskCompletedEventId: EventId + + +class WorkflowType(TypedDict, total=False): + name: Name + version: Version + + +class ChildWorkflowExecutionCanceledEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + workflowType: WorkflowType + details: Optional[Data] + initiatedEventId: EventId + startedEventId: EventId + + +class ChildWorkflowExecutionCompletedEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + workflowType: WorkflowType + result: Optional[Data] + initiatedEventId: EventId + startedEventId: EventId + + +class ChildWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + workflowType: WorkflowType + reason: Optional[FailureReason] + details: Optional[Data] + initiatedEventId: EventId + startedEventId: EventId + + +class ChildWorkflowExecutionStartedEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + workflowType: WorkflowType + initiatedEventId: EventId + + +class ChildWorkflowExecutionTerminatedEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + workflowType: WorkflowType + initiatedEventId: EventId + startedEventId: EventId + + +class ChildWorkflowExecutionTimedOutEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + workflowType: WorkflowType + timeoutType: WorkflowExecutionTimeoutType + initiatedEventId: EventId + startedEventId: EventId + + +class CloseStatusFilter(TypedDict, total=False): + status: CloseStatus + + +class CompleteWorkflowExecutionDecisionAttributes(TypedDict, total=False): + result: Optional[Data] + + +class CompleteWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + cause: CompleteWorkflowExecutionFailedCause + decisionTaskCompletedEventId: EventId + + +TagList = List[Tag] + + +class ContinueAsNewWorkflowExecutionDecisionAttributes(TypedDict, total=False): + input: Optional[Data] + executionStartToCloseTimeout: Optional[DurationInSecondsOptional] + taskList: Optional[TaskList] + taskPriority: Optional[TaskPriority] + taskStartToCloseTimeout: Optional[DurationInSecondsOptional] + childPolicy: Optional[ChildPolicy] + tagList: Optional[TagList] + workflowTypeVersion: Optional[Version] + lambdaRole: Optional[Arn] + + +class ContinueAsNewWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + cause: ContinueAsNewWorkflowExecutionFailedCause + decisionTaskCompletedEventId: EventId + + +class TagFilter(TypedDict, total=False): + tag: Tag + + +class WorkflowTypeFilter(TypedDict, total=False): + name: Name + version: Optional[VersionOptional] + + +class WorkflowExecutionFilter(TypedDict, total=False): + workflowId: WorkflowId + + +class ExecutionTimeFilter(TypedDict, total=False): + oldestDate: Timestamp + latestDate: Optional[Timestamp] + + +class CountClosedWorkflowExecutionsInput(ServiceRequest): + domain: DomainName + startTimeFilter: Optional[ExecutionTimeFilter] + closeTimeFilter: Optional[ExecutionTimeFilter] + executionFilter: Optional[WorkflowExecutionFilter] + typeFilter: Optional[WorkflowTypeFilter] + tagFilter: Optional[TagFilter] + closeStatusFilter: Optional[CloseStatusFilter] + + +class CountOpenWorkflowExecutionsInput(ServiceRequest): + domain: DomainName + startTimeFilter: ExecutionTimeFilter + typeFilter: Optional[WorkflowTypeFilter] + tagFilter: Optional[TagFilter] + executionFilter: Optional[WorkflowExecutionFilter] + + +class CountPendingActivityTasksInput(ServiceRequest): + domain: DomainName + taskList: TaskList + + +class CountPendingDecisionTasksInput(ServiceRequest): + domain: DomainName + taskList: TaskList + + +class ScheduleLambdaFunctionDecisionAttributes(TypedDict, total=False): + id: FunctionId + name: FunctionName + control: Optional[Data] + input: Optional[FunctionInput] + startToCloseTimeout: Optional[DurationInSecondsOptional] + + +class StartChildWorkflowExecutionDecisionAttributes(TypedDict, total=False): + workflowType: WorkflowType + workflowId: WorkflowId + control: Optional[Data] + input: Optional[Data] + executionStartToCloseTimeout: Optional[DurationInSecondsOptional] + taskList: Optional[TaskList] + taskPriority: Optional[TaskPriority] + taskStartToCloseTimeout: Optional[DurationInSecondsOptional] + childPolicy: Optional[ChildPolicy] + tagList: Optional[TagList] + lambdaRole: Optional[Arn] + + +class RequestCancelExternalWorkflowExecutionDecisionAttributes(TypedDict, total=False): + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + control: Optional[Data] + + +class SignalExternalWorkflowExecutionDecisionAttributes(TypedDict, total=False): + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + signalName: SignalName + input: Optional[Data] + control: Optional[Data] + + +class StartTimerDecisionAttributes(TypedDict, total=False): + timerId: TimerId + control: Optional[Data] + startToFireTimeout: DurationInSeconds + + +class RecordMarkerDecisionAttributes(TypedDict, total=False): + markerName: MarkerName + details: Optional[Data] + + +class FailWorkflowExecutionDecisionAttributes(TypedDict, total=False): + reason: Optional[FailureReason] + details: Optional[Data] + + +class RequestCancelActivityTaskDecisionAttributes(TypedDict, total=False): + activityId: ActivityId + + +class ScheduleActivityTaskDecisionAttributes(TypedDict, total=False): + activityType: ActivityType + activityId: ActivityId + control: Optional[Data] + input: Optional[Data] + scheduleToCloseTimeout: Optional[DurationInSecondsOptional] + taskList: Optional[TaskList] + taskPriority: Optional[TaskPriority] + scheduleToStartTimeout: Optional[DurationInSecondsOptional] + startToCloseTimeout: Optional[DurationInSecondsOptional] + heartbeatTimeout: Optional[DurationInSecondsOptional] + + +class Decision(TypedDict, total=False): + decisionType: DecisionType + scheduleActivityTaskDecisionAttributes: Optional[ScheduleActivityTaskDecisionAttributes] + requestCancelActivityTaskDecisionAttributes: Optional[ + RequestCancelActivityTaskDecisionAttributes + ] + completeWorkflowExecutionDecisionAttributes: Optional[ + CompleteWorkflowExecutionDecisionAttributes + ] + failWorkflowExecutionDecisionAttributes: Optional[FailWorkflowExecutionDecisionAttributes] + cancelWorkflowExecutionDecisionAttributes: Optional[CancelWorkflowExecutionDecisionAttributes] + continueAsNewWorkflowExecutionDecisionAttributes: Optional[ + ContinueAsNewWorkflowExecutionDecisionAttributes + ] + recordMarkerDecisionAttributes: Optional[RecordMarkerDecisionAttributes] + startTimerDecisionAttributes: Optional[StartTimerDecisionAttributes] + cancelTimerDecisionAttributes: Optional[CancelTimerDecisionAttributes] + signalExternalWorkflowExecutionDecisionAttributes: Optional[ + SignalExternalWorkflowExecutionDecisionAttributes + ] + requestCancelExternalWorkflowExecutionDecisionAttributes: Optional[ + RequestCancelExternalWorkflowExecutionDecisionAttributes + ] + startChildWorkflowExecutionDecisionAttributes: Optional[ + StartChildWorkflowExecutionDecisionAttributes + ] + scheduleLambdaFunctionDecisionAttributes: Optional[ScheduleLambdaFunctionDecisionAttributes] + + +DecisionList = List[Decision] + + +class StartLambdaFunctionFailedEventAttributes(TypedDict, total=False): + scheduledEventId: Optional[EventId] + cause: Optional[StartLambdaFunctionFailedCause] + message: Optional[CauseMessage] + + +class ScheduleLambdaFunctionFailedEventAttributes(TypedDict, total=False): + id: FunctionId + name: FunctionName + cause: ScheduleLambdaFunctionFailedCause + decisionTaskCompletedEventId: EventId + + +class LambdaFunctionTimedOutEventAttributes(TypedDict, total=False): + scheduledEventId: EventId + startedEventId: EventId + timeoutType: Optional[LambdaFunctionTimeoutType] + + +class LambdaFunctionFailedEventAttributes(TypedDict, total=False): + scheduledEventId: EventId + startedEventId: EventId + reason: Optional[FailureReason] + details: Optional[Data] + + +class LambdaFunctionCompletedEventAttributes(TypedDict, total=False): + scheduledEventId: EventId + startedEventId: EventId + result: Optional[Data] + + +class LambdaFunctionStartedEventAttributes(TypedDict, total=False): + scheduledEventId: EventId + + +class LambdaFunctionScheduledEventAttributes(TypedDict, total=False): + id: FunctionId + name: FunctionName + control: Optional[Data] + input: Optional[FunctionInput] + startToCloseTimeout: Optional[DurationInSecondsOptional] + decisionTaskCompletedEventId: EventId + + +class StartChildWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + workflowType: WorkflowType + cause: StartChildWorkflowExecutionFailedCause + workflowId: WorkflowId + initiatedEventId: EventId + decisionTaskCompletedEventId: EventId + control: Optional[Data] + + +class StartTimerFailedEventAttributes(TypedDict, total=False): + timerId: TimerId + cause: StartTimerFailedCause + decisionTaskCompletedEventId: EventId + + +class RequestCancelActivityTaskFailedEventAttributes(TypedDict, total=False): + activityId: ActivityId + cause: RequestCancelActivityTaskFailedCause + decisionTaskCompletedEventId: EventId + + +class ScheduleActivityTaskFailedEventAttributes(TypedDict, total=False): + activityType: ActivityType + activityId: ActivityId + cause: ScheduleActivityTaskFailedCause + decisionTaskCompletedEventId: EventId + + +class RequestCancelExternalWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + cause: RequestCancelExternalWorkflowExecutionFailedCause + initiatedEventId: EventId + decisionTaskCompletedEventId: EventId + control: Optional[Data] + + +class RequestCancelExternalWorkflowExecutionInitiatedEventAttributes(TypedDict, total=False): + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + decisionTaskCompletedEventId: EventId + control: Optional[Data] + + +class ExternalWorkflowExecutionCancelRequestedEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + initiatedEventId: EventId + + +class SignalExternalWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + cause: SignalExternalWorkflowExecutionFailedCause + initiatedEventId: EventId + decisionTaskCompletedEventId: EventId + control: Optional[Data] + + +class ExternalWorkflowExecutionSignaledEventAttributes(TypedDict, total=False): + workflowExecution: WorkflowExecution + initiatedEventId: EventId + + +class SignalExternalWorkflowExecutionInitiatedEventAttributes(TypedDict, total=False): + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + signalName: SignalName + input: Optional[Data] + decisionTaskCompletedEventId: EventId + control: Optional[Data] + + +class StartChildWorkflowExecutionInitiatedEventAttributes(TypedDict, total=False): + workflowId: WorkflowId + workflowType: WorkflowType + control: Optional[Data] + input: Optional[Data] + executionStartToCloseTimeout: Optional[DurationInSecondsOptional] + taskList: TaskList + taskPriority: Optional[TaskPriority] + decisionTaskCompletedEventId: EventId + childPolicy: ChildPolicy + taskStartToCloseTimeout: Optional[DurationInSecondsOptional] + tagList: Optional[TagList] + lambdaRole: Optional[Arn] + + +class TimerCanceledEventAttributes(TypedDict, total=False): + timerId: TimerId + startedEventId: EventId + decisionTaskCompletedEventId: EventId + + +class TimerFiredEventAttributes(TypedDict, total=False): + timerId: TimerId + startedEventId: EventId + + +class TimerStartedEventAttributes(TypedDict, total=False): + timerId: TimerId + control: Optional[Data] + startToFireTimeout: DurationInSeconds + decisionTaskCompletedEventId: EventId + + +class RecordMarkerFailedEventAttributes(TypedDict, total=False): + markerName: MarkerName + cause: RecordMarkerFailedCause + decisionTaskCompletedEventId: EventId + + +class MarkerRecordedEventAttributes(TypedDict, total=False): + markerName: MarkerName + details: Optional[Data] + decisionTaskCompletedEventId: EventId + + +class WorkflowExecutionSignaledEventAttributes(TypedDict, total=False): + signalName: SignalName + input: Optional[Data] + externalWorkflowExecution: Optional[WorkflowExecution] + externalInitiatedEventId: Optional[EventId] + + +class DecisionTaskTimedOutEventAttributes(TypedDict, total=False): + timeoutType: DecisionTaskTimeoutType + scheduledEventId: EventId + startedEventId: EventId + + +class DecisionTaskCompletedEventAttributes(TypedDict, total=False): + executionContext: Optional[Data] + scheduledEventId: EventId + startedEventId: EventId + taskList: Optional[TaskList] + taskListScheduleToStartTimeout: Optional[DurationInSecondsOptional] + + +class DecisionTaskStartedEventAttributes(TypedDict, total=False): + identity: Optional[Identity] + scheduledEventId: EventId + + +class DecisionTaskScheduledEventAttributes(TypedDict, total=False): + taskList: TaskList + taskPriority: Optional[TaskPriority] + startToCloseTimeout: Optional[DurationInSecondsOptional] + scheduleToStartTimeout: Optional[DurationInSecondsOptional] + + +class WorkflowExecutionCancelRequestedEventAttributes(TypedDict, total=False): + externalWorkflowExecution: Optional[WorkflowExecution] + externalInitiatedEventId: Optional[EventId] + cause: Optional[WorkflowExecutionCancelRequestedCause] + + +class WorkflowExecutionTerminatedEventAttributes(TypedDict, total=False): + reason: Optional[TerminateReason] + details: Optional[Data] + childPolicy: ChildPolicy + cause: Optional[WorkflowExecutionTerminatedCause] + + +class WorkflowExecutionContinuedAsNewEventAttributes(TypedDict, total=False): + input: Optional[Data] + decisionTaskCompletedEventId: EventId + newExecutionRunId: WorkflowRunId + executionStartToCloseTimeout: Optional[DurationInSecondsOptional] + taskList: TaskList + taskPriority: Optional[TaskPriority] + taskStartToCloseTimeout: Optional[DurationInSecondsOptional] + childPolicy: ChildPolicy + tagList: Optional[TagList] + workflowType: WorkflowType + lambdaRole: Optional[Arn] + + +class WorkflowExecutionCanceledEventAttributes(TypedDict, total=False): + details: Optional[Data] + decisionTaskCompletedEventId: EventId + + +class WorkflowExecutionTimedOutEventAttributes(TypedDict, total=False): + timeoutType: WorkflowExecutionTimeoutType + childPolicy: ChildPolicy + + +class FailWorkflowExecutionFailedEventAttributes(TypedDict, total=False): + cause: FailWorkflowExecutionFailedCause + decisionTaskCompletedEventId: EventId + + +class WorkflowExecutionFailedEventAttributes(TypedDict, total=False): + reason: Optional[FailureReason] + details: Optional[Data] + decisionTaskCompletedEventId: EventId + + +class WorkflowExecutionCompletedEventAttributes(TypedDict, total=False): + result: Optional[Data] + decisionTaskCompletedEventId: EventId + + +class WorkflowExecutionStartedEventAttributes(TypedDict, total=False): + input: Optional[Data] + executionStartToCloseTimeout: Optional[DurationInSecondsOptional] + taskStartToCloseTimeout: Optional[DurationInSecondsOptional] + childPolicy: ChildPolicy + taskList: TaskList + taskPriority: Optional[TaskPriority] + workflowType: WorkflowType + tagList: Optional[TagList] + continuedExecutionRunId: Optional[WorkflowRunIdOptional] + parentWorkflowExecution: Optional[WorkflowExecution] + parentInitiatedEventId: Optional[EventId] + lambdaRole: Optional[Arn] + + +class HistoryEvent(TypedDict, total=False): + eventTimestamp: Timestamp + eventType: EventType + eventId: EventId + workflowExecutionStartedEventAttributes: Optional[WorkflowExecutionStartedEventAttributes] + workflowExecutionCompletedEventAttributes: Optional[WorkflowExecutionCompletedEventAttributes] + completeWorkflowExecutionFailedEventAttributes: Optional[ + CompleteWorkflowExecutionFailedEventAttributes + ] + workflowExecutionFailedEventAttributes: Optional[WorkflowExecutionFailedEventAttributes] + failWorkflowExecutionFailedEventAttributes: Optional[FailWorkflowExecutionFailedEventAttributes] + workflowExecutionTimedOutEventAttributes: Optional[WorkflowExecutionTimedOutEventAttributes] + workflowExecutionCanceledEventAttributes: Optional[WorkflowExecutionCanceledEventAttributes] + cancelWorkflowExecutionFailedEventAttributes: Optional[ + CancelWorkflowExecutionFailedEventAttributes + ] + workflowExecutionContinuedAsNewEventAttributes: Optional[ + WorkflowExecutionContinuedAsNewEventAttributes + ] + continueAsNewWorkflowExecutionFailedEventAttributes: Optional[ + ContinueAsNewWorkflowExecutionFailedEventAttributes + ] + workflowExecutionTerminatedEventAttributes: Optional[WorkflowExecutionTerminatedEventAttributes] + workflowExecutionCancelRequestedEventAttributes: Optional[ + WorkflowExecutionCancelRequestedEventAttributes + ] + decisionTaskScheduledEventAttributes: Optional[DecisionTaskScheduledEventAttributes] + decisionTaskStartedEventAttributes: Optional[DecisionTaskStartedEventAttributes] + decisionTaskCompletedEventAttributes: Optional[DecisionTaskCompletedEventAttributes] + decisionTaskTimedOutEventAttributes: Optional[DecisionTaskTimedOutEventAttributes] + activityTaskScheduledEventAttributes: Optional[ActivityTaskScheduledEventAttributes] + activityTaskStartedEventAttributes: Optional[ActivityTaskStartedEventAttributes] + activityTaskCompletedEventAttributes: Optional[ActivityTaskCompletedEventAttributes] + activityTaskFailedEventAttributes: Optional[ActivityTaskFailedEventAttributes] + activityTaskTimedOutEventAttributes: Optional[ActivityTaskTimedOutEventAttributes] + activityTaskCanceledEventAttributes: Optional[ActivityTaskCanceledEventAttributes] + activityTaskCancelRequestedEventAttributes: Optional[ActivityTaskCancelRequestedEventAttributes] + workflowExecutionSignaledEventAttributes: Optional[WorkflowExecutionSignaledEventAttributes] + markerRecordedEventAttributes: Optional[MarkerRecordedEventAttributes] + recordMarkerFailedEventAttributes: Optional[RecordMarkerFailedEventAttributes] + timerStartedEventAttributes: Optional[TimerStartedEventAttributes] + timerFiredEventAttributes: Optional[TimerFiredEventAttributes] + timerCanceledEventAttributes: Optional[TimerCanceledEventAttributes] + startChildWorkflowExecutionInitiatedEventAttributes: Optional[ + StartChildWorkflowExecutionInitiatedEventAttributes + ] + childWorkflowExecutionStartedEventAttributes: Optional[ + ChildWorkflowExecutionStartedEventAttributes + ] + childWorkflowExecutionCompletedEventAttributes: Optional[ + ChildWorkflowExecutionCompletedEventAttributes + ] + childWorkflowExecutionFailedEventAttributes: Optional[ + ChildWorkflowExecutionFailedEventAttributes + ] + childWorkflowExecutionTimedOutEventAttributes: Optional[ + ChildWorkflowExecutionTimedOutEventAttributes + ] + childWorkflowExecutionCanceledEventAttributes: Optional[ + ChildWorkflowExecutionCanceledEventAttributes + ] + childWorkflowExecutionTerminatedEventAttributes: Optional[ + ChildWorkflowExecutionTerminatedEventAttributes + ] + signalExternalWorkflowExecutionInitiatedEventAttributes: Optional[ + SignalExternalWorkflowExecutionInitiatedEventAttributes + ] + externalWorkflowExecutionSignaledEventAttributes: Optional[ + ExternalWorkflowExecutionSignaledEventAttributes + ] + signalExternalWorkflowExecutionFailedEventAttributes: Optional[ + SignalExternalWorkflowExecutionFailedEventAttributes + ] + externalWorkflowExecutionCancelRequestedEventAttributes: Optional[ + ExternalWorkflowExecutionCancelRequestedEventAttributes + ] + requestCancelExternalWorkflowExecutionInitiatedEventAttributes: Optional[ + RequestCancelExternalWorkflowExecutionInitiatedEventAttributes + ] + requestCancelExternalWorkflowExecutionFailedEventAttributes: Optional[ + RequestCancelExternalWorkflowExecutionFailedEventAttributes + ] + scheduleActivityTaskFailedEventAttributes: Optional[ScheduleActivityTaskFailedEventAttributes] + requestCancelActivityTaskFailedEventAttributes: Optional[ + RequestCancelActivityTaskFailedEventAttributes + ] + startTimerFailedEventAttributes: Optional[StartTimerFailedEventAttributes] + cancelTimerFailedEventAttributes: Optional[CancelTimerFailedEventAttributes] + startChildWorkflowExecutionFailedEventAttributes: Optional[ + StartChildWorkflowExecutionFailedEventAttributes + ] + lambdaFunctionScheduledEventAttributes: Optional[LambdaFunctionScheduledEventAttributes] + lambdaFunctionStartedEventAttributes: Optional[LambdaFunctionStartedEventAttributes] + lambdaFunctionCompletedEventAttributes: Optional[LambdaFunctionCompletedEventAttributes] + lambdaFunctionFailedEventAttributes: Optional[LambdaFunctionFailedEventAttributes] + lambdaFunctionTimedOutEventAttributes: Optional[LambdaFunctionTimedOutEventAttributes] + scheduleLambdaFunctionFailedEventAttributes: Optional[ + ScheduleLambdaFunctionFailedEventAttributes + ] + startLambdaFunctionFailedEventAttributes: Optional[StartLambdaFunctionFailedEventAttributes] + + +HistoryEventList = List[HistoryEvent] + + +class DecisionTask(TypedDict, total=False): + taskToken: TaskToken + startedEventId: EventId + workflowExecution: WorkflowExecution + workflowType: WorkflowType + events: HistoryEventList + nextPageToken: Optional[PageToken] + previousStartedEventId: Optional[EventId] + + +class DeleteActivityTypeInput(ServiceRequest): + domain: DomainName + activityType: ActivityType + + +class DeleteWorkflowTypeInput(ServiceRequest): + domain: DomainName + workflowType: WorkflowType + + +class DeprecateActivityTypeInput(ServiceRequest): + domain: DomainName + activityType: ActivityType + + +class DeprecateDomainInput(ServiceRequest): + name: DomainName + + +class DeprecateWorkflowTypeInput(ServiceRequest): + domain: DomainName + workflowType: WorkflowType + + +class DescribeActivityTypeInput(ServiceRequest): + domain: DomainName + activityType: ActivityType + + +class DescribeDomainInput(ServiceRequest): + name: DomainName + + +class DescribeWorkflowExecutionInput(ServiceRequest): + domain: DomainName + execution: WorkflowExecution + + +class DescribeWorkflowTypeInput(ServiceRequest): + domain: DomainName + workflowType: WorkflowType + + +class DomainConfiguration(TypedDict, total=False): + workflowExecutionRetentionPeriodInDays: DurationInDays + + +class DomainInfo(TypedDict, total=False): + name: DomainName + status: RegistrationStatus + description: Optional[Description] + arn: Optional[Arn] + + +class DomainDetail(TypedDict, total=False): + domainInfo: DomainInfo + configuration: DomainConfiguration + + +DomainInfoList = List[DomainInfo] + + +class DomainInfos(TypedDict, total=False): + domainInfos: DomainInfoList + nextPageToken: Optional[PageToken] + + +class GetWorkflowExecutionHistoryInput(ServiceRequest): + domain: DomainName + execution: WorkflowExecution + nextPageToken: Optional[PageToken] + maximumPageSize: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + + +class History(TypedDict, total=False): + events: HistoryEventList + nextPageToken: Optional[PageToken] + + +class ListActivityTypesInput(ServiceRequest): + domain: DomainName + name: Optional[Name] + registrationStatus: RegistrationStatus + nextPageToken: Optional[PageToken] + maximumPageSize: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + + +class ListClosedWorkflowExecutionsInput(ServiceRequest): + domain: DomainName + startTimeFilter: Optional[ExecutionTimeFilter] + closeTimeFilter: Optional[ExecutionTimeFilter] + executionFilter: Optional[WorkflowExecutionFilter] + closeStatusFilter: Optional[CloseStatusFilter] + typeFilter: Optional[WorkflowTypeFilter] + tagFilter: Optional[TagFilter] + nextPageToken: Optional[PageToken] + maximumPageSize: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + + +class ListDomainsInput(ServiceRequest): + nextPageToken: Optional[PageToken] + registrationStatus: RegistrationStatus + maximumPageSize: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + + +class ListOpenWorkflowExecutionsInput(ServiceRequest): + domain: DomainName + startTimeFilter: ExecutionTimeFilter + typeFilter: Optional[WorkflowTypeFilter] + tagFilter: Optional[TagFilter] + nextPageToken: Optional[PageToken] + maximumPageSize: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + executionFilter: Optional[WorkflowExecutionFilter] + + +class ListTagsForResourceInput(ServiceRequest): + resourceArn: Arn + + +class ResourceTag(TypedDict, total=False): + key: ResourceTagKey + value: Optional[ResourceTagValue] + + +ResourceTagList = List[ResourceTag] + + +class ListTagsForResourceOutput(TypedDict, total=False): + tags: Optional[ResourceTagList] + + +class ListWorkflowTypesInput(ServiceRequest): + domain: DomainName + name: Optional[Name] + registrationStatus: RegistrationStatus + nextPageToken: Optional[PageToken] + maximumPageSize: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + + +class PendingTaskCount(TypedDict, total=False): + count: Count + truncated: Optional[Truncated] + + +class PollForActivityTaskInput(ServiceRequest): + domain: DomainName + taskList: TaskList + identity: Optional[Identity] + + +class PollForDecisionTaskInput(ServiceRequest): + domain: DomainName + taskList: TaskList + identity: Optional[Identity] + nextPageToken: Optional[PageToken] + maximumPageSize: Optional[PageSize] + reverseOrder: Optional[ReverseOrder] + startAtPreviousStartedEvent: Optional[StartAtPreviousStartedEvent] + + +class RecordActivityTaskHeartbeatInput(ServiceRequest): + taskToken: TaskToken + details: Optional[LimitedData] + + +class RegisterActivityTypeInput(ServiceRequest): + domain: DomainName + name: Name + version: Version + description: Optional[Description] + defaultTaskStartToCloseTimeout: Optional[DurationInSecondsOptional] + defaultTaskHeartbeatTimeout: Optional[DurationInSecondsOptional] + defaultTaskList: Optional[TaskList] + defaultTaskPriority: Optional[TaskPriority] + defaultTaskScheduleToStartTimeout: Optional[DurationInSecondsOptional] + defaultTaskScheduleToCloseTimeout: Optional[DurationInSecondsOptional] + + +class RegisterDomainInput(ServiceRequest): + name: DomainName + description: Optional[Description] + workflowExecutionRetentionPeriodInDays: DurationInDays + tags: Optional[ResourceTagList] + + +class RegisterWorkflowTypeInput(ServiceRequest): + domain: DomainName + name: Name + version: Version + description: Optional[Description] + defaultTaskStartToCloseTimeout: Optional[DurationInSecondsOptional] + defaultExecutionStartToCloseTimeout: Optional[DurationInSecondsOptional] + defaultTaskList: Optional[TaskList] + defaultTaskPriority: Optional[TaskPriority] + defaultChildPolicy: Optional[ChildPolicy] + defaultLambdaRole: Optional[Arn] + + +class RequestCancelWorkflowExecutionInput(ServiceRequest): + domain: DomainName + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + + +ResourceTagKeyList = List[ResourceTagKey] + + +class RespondActivityTaskCanceledInput(ServiceRequest): + taskToken: TaskToken + details: Optional[Data] + + +class RespondActivityTaskCompletedInput(ServiceRequest): + taskToken: TaskToken + result: Optional[Data] + + +class RespondActivityTaskFailedInput(ServiceRequest): + taskToken: TaskToken + reason: Optional[FailureReason] + details: Optional[Data] + + +class RespondDecisionTaskCompletedInput(ServiceRequest): + taskToken: TaskToken + decisions: Optional[DecisionList] + executionContext: Optional[Data] + taskList: Optional[TaskList] + taskListScheduleToStartTimeout: Optional[DurationInSecondsOptional] + + +class Run(TypedDict, total=False): + runId: Optional[WorkflowRunId] + + +class SignalWorkflowExecutionInput(ServiceRequest): + domain: DomainName + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + signalName: SignalName + input: Optional[Data] + + +class StartWorkflowExecutionInput(ServiceRequest): + domain: DomainName + workflowId: WorkflowId + workflowType: WorkflowType + taskList: Optional[TaskList] + taskPriority: Optional[TaskPriority] + input: Optional[Data] + executionStartToCloseTimeout: Optional[DurationInSecondsOptional] + tagList: Optional[TagList] + taskStartToCloseTimeout: Optional[DurationInSecondsOptional] + childPolicy: Optional[ChildPolicy] + lambdaRole: Optional[Arn] + + +class TagResourceInput(ServiceRequest): + resourceArn: Arn + tags: ResourceTagList + + +class TerminateWorkflowExecutionInput(ServiceRequest): + domain: DomainName + workflowId: WorkflowId + runId: Optional[WorkflowRunIdOptional] + reason: Optional[TerminateReason] + details: Optional[Data] + childPolicy: Optional[ChildPolicy] + + +class UndeprecateActivityTypeInput(ServiceRequest): + domain: DomainName + activityType: ActivityType + + +class UndeprecateDomainInput(ServiceRequest): + name: DomainName + + +class UndeprecateWorkflowTypeInput(ServiceRequest): + domain: DomainName + workflowType: WorkflowType + + +class UntagResourceInput(ServiceRequest): + resourceArn: Arn + tagKeys: ResourceTagKeyList + + +class WorkflowExecutionConfiguration(TypedDict, total=False): + taskStartToCloseTimeout: DurationInSeconds + executionStartToCloseTimeout: DurationInSeconds + taskList: TaskList + taskPriority: Optional[TaskPriority] + childPolicy: ChildPolicy + lambdaRole: Optional[Arn] + + +class WorkflowExecutionCount(TypedDict, total=False): + count: Count + truncated: Optional[Truncated] + + +class WorkflowExecutionOpenCounts(TypedDict, total=False): + openActivityTasks: Count + openDecisionTasks: OpenDecisionTasksCount + openTimers: Count + openChildWorkflowExecutions: Count + openLambdaFunctions: Optional[Count] + + +class WorkflowExecutionInfo(TypedDict, total=False): + execution: WorkflowExecution + workflowType: WorkflowType + startTimestamp: Timestamp + closeTimestamp: Optional[Timestamp] + executionStatus: ExecutionStatus + closeStatus: Optional[CloseStatus] + parent: Optional[WorkflowExecution] + tagList: Optional[TagList] + cancelRequested: Optional[Canceled] + + +class WorkflowExecutionDetail(TypedDict, total=False): + executionInfo: WorkflowExecutionInfo + executionConfiguration: WorkflowExecutionConfiguration + openCounts: WorkflowExecutionOpenCounts + latestActivityTaskTimestamp: Optional[Timestamp] + latestExecutionContext: Optional[Data] + + +WorkflowExecutionInfoList = List[WorkflowExecutionInfo] + + +class WorkflowExecutionInfos(TypedDict, total=False): + executionInfos: WorkflowExecutionInfoList + nextPageToken: Optional[PageToken] + + +class WorkflowTypeConfiguration(TypedDict, total=False): + defaultTaskStartToCloseTimeout: Optional[DurationInSecondsOptional] + defaultExecutionStartToCloseTimeout: Optional[DurationInSecondsOptional] + defaultTaskList: Optional[TaskList] + defaultTaskPriority: Optional[TaskPriority] + defaultChildPolicy: Optional[ChildPolicy] + defaultLambdaRole: Optional[Arn] + + +class WorkflowTypeInfo(TypedDict, total=False): + workflowType: WorkflowType + status: RegistrationStatus + description: Optional[Description] + creationDate: Timestamp + deprecationDate: Optional[Timestamp] + + +class WorkflowTypeDetail(TypedDict, total=False): + typeInfo: WorkflowTypeInfo + configuration: WorkflowTypeConfiguration + + +WorkflowTypeInfoList = List[WorkflowTypeInfo] + + +class WorkflowTypeInfos(TypedDict, total=False): + typeInfos: WorkflowTypeInfoList + nextPageToken: Optional[PageToken] + + +class SwfApi: + service = "swf" + version = "2012-01-25" + + @handler("CountClosedWorkflowExecutions") + def count_closed_workflow_executions( + self, + context: RequestContext, + domain: DomainName, + start_time_filter: ExecutionTimeFilter | None = None, + close_time_filter: ExecutionTimeFilter | None = None, + execution_filter: WorkflowExecutionFilter | None = None, + type_filter: WorkflowTypeFilter | None = None, + tag_filter: TagFilter | None = None, + close_status_filter: CloseStatusFilter | None = None, + **kwargs, + ) -> WorkflowExecutionCount: + raise NotImplementedError + + @handler("CountOpenWorkflowExecutions") + def count_open_workflow_executions( + self, + context: RequestContext, + domain: DomainName, + start_time_filter: ExecutionTimeFilter, + type_filter: WorkflowTypeFilter | None = None, + tag_filter: TagFilter | None = None, + execution_filter: WorkflowExecutionFilter | None = None, + **kwargs, + ) -> WorkflowExecutionCount: + raise NotImplementedError + + @handler("CountPendingActivityTasks") + def count_pending_activity_tasks( + self, context: RequestContext, domain: DomainName, task_list: TaskList, **kwargs + ) -> PendingTaskCount: + raise NotImplementedError + + @handler("CountPendingDecisionTasks") + def count_pending_decision_tasks( + self, context: RequestContext, domain: DomainName, task_list: TaskList, **kwargs + ) -> PendingTaskCount: + raise NotImplementedError + + @handler("DeleteActivityType") + def delete_activity_type( + self, context: RequestContext, domain: DomainName, activity_type: ActivityType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteWorkflowType") + def delete_workflow_type( + self, context: RequestContext, domain: DomainName, workflow_type: WorkflowType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeprecateActivityType") + def deprecate_activity_type( + self, context: RequestContext, domain: DomainName, activity_type: ActivityType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeprecateDomain") + def deprecate_domain(self, context: RequestContext, name: DomainName, **kwargs) -> None: + raise NotImplementedError + + @handler("DeprecateWorkflowType") + def deprecate_workflow_type( + self, context: RequestContext, domain: DomainName, workflow_type: WorkflowType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DescribeActivityType") + def describe_activity_type( + self, context: RequestContext, domain: DomainName, activity_type: ActivityType, **kwargs + ) -> ActivityTypeDetail: + raise NotImplementedError + + @handler("DescribeDomain") + def describe_domain(self, context: RequestContext, name: DomainName, **kwargs) -> DomainDetail: + raise NotImplementedError + + @handler("DescribeWorkflowExecution") + def describe_workflow_execution( + self, context: RequestContext, domain: DomainName, execution: WorkflowExecution, **kwargs + ) -> WorkflowExecutionDetail: + raise NotImplementedError + + @handler("DescribeWorkflowType") + def describe_workflow_type( + self, context: RequestContext, domain: DomainName, workflow_type: WorkflowType, **kwargs + ) -> WorkflowTypeDetail: + raise NotImplementedError + + @handler("GetWorkflowExecutionHistory") + def get_workflow_execution_history( + self, + context: RequestContext, + domain: DomainName, + execution: WorkflowExecution, + next_page_token: PageToken | None = None, + maximum_page_size: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + **kwargs, + ) -> History: + raise NotImplementedError + + @handler("ListActivityTypes") + def list_activity_types( + self, + context: RequestContext, + domain: DomainName, + registration_status: RegistrationStatus, + name: Name | None = None, + next_page_token: PageToken | None = None, + maximum_page_size: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + **kwargs, + ) -> ActivityTypeInfos: + raise NotImplementedError + + @handler("ListClosedWorkflowExecutions") + def list_closed_workflow_executions( + self, + context: RequestContext, + domain: DomainName, + start_time_filter: ExecutionTimeFilter | None = None, + close_time_filter: ExecutionTimeFilter | None = None, + execution_filter: WorkflowExecutionFilter | None = None, + close_status_filter: CloseStatusFilter | None = None, + type_filter: WorkflowTypeFilter | None = None, + tag_filter: TagFilter | None = None, + next_page_token: PageToken | None = None, + maximum_page_size: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + **kwargs, + ) -> WorkflowExecutionInfos: + raise NotImplementedError + + @handler("ListDomains") + def list_domains( + self, + context: RequestContext, + registration_status: RegistrationStatus, + next_page_token: PageToken | None = None, + maximum_page_size: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + **kwargs, + ) -> DomainInfos: + raise NotImplementedError + + @handler("ListOpenWorkflowExecutions") + def list_open_workflow_executions( + self, + context: RequestContext, + domain: DomainName, + start_time_filter: ExecutionTimeFilter, + type_filter: WorkflowTypeFilter | None = None, + tag_filter: TagFilter | None = None, + next_page_token: PageToken | None = None, + maximum_page_size: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + execution_filter: WorkflowExecutionFilter | None = None, + **kwargs, + ) -> WorkflowExecutionInfos: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: Arn, **kwargs + ) -> ListTagsForResourceOutput: + raise NotImplementedError + + @handler("ListWorkflowTypes") + def list_workflow_types( + self, + context: RequestContext, + domain: DomainName, + registration_status: RegistrationStatus, + name: Name | None = None, + next_page_token: PageToken | None = None, + maximum_page_size: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + **kwargs, + ) -> WorkflowTypeInfos: + raise NotImplementedError + + @handler("PollForActivityTask") + def poll_for_activity_task( + self, + context: RequestContext, + domain: DomainName, + task_list: TaskList, + identity: Identity | None = None, + **kwargs, + ) -> ActivityTask: + raise NotImplementedError + + @handler("PollForDecisionTask") + def poll_for_decision_task( + self, + context: RequestContext, + domain: DomainName, + task_list: TaskList, + identity: Identity | None = None, + next_page_token: PageToken | None = None, + maximum_page_size: PageSize | None = None, + reverse_order: ReverseOrder | None = None, + start_at_previous_started_event: StartAtPreviousStartedEvent | None = None, + **kwargs, + ) -> DecisionTask: + raise NotImplementedError + + @handler("RecordActivityTaskHeartbeat") + def record_activity_task_heartbeat( + self, + context: RequestContext, + task_token: TaskToken, + details: LimitedData | None = None, + **kwargs, + ) -> ActivityTaskStatus: + raise NotImplementedError + + @handler("RegisterActivityType") + def register_activity_type( + self, + context: RequestContext, + domain: DomainName, + name: Name, + version: Version, + description: Description | None = None, + default_task_start_to_close_timeout: DurationInSecondsOptional | None = None, + default_task_heartbeat_timeout: DurationInSecondsOptional | None = None, + default_task_list: TaskList | None = None, + default_task_priority: TaskPriority | None = None, + default_task_schedule_to_start_timeout: DurationInSecondsOptional | None = None, + default_task_schedule_to_close_timeout: DurationInSecondsOptional | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RegisterDomain") + def register_domain( + self, + context: RequestContext, + name: DomainName, + workflow_execution_retention_period_in_days: DurationInDays, + description: Description | None = None, + tags: ResourceTagList | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RegisterWorkflowType") + def register_workflow_type( + self, + context: RequestContext, + domain: DomainName, + name: Name, + version: Version, + description: Description | None = None, + default_task_start_to_close_timeout: DurationInSecondsOptional | None = None, + default_execution_start_to_close_timeout: DurationInSecondsOptional | None = None, + default_task_list: TaskList | None = None, + default_task_priority: TaskPriority | None = None, + default_child_policy: ChildPolicy | None = None, + default_lambda_role: Arn | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RequestCancelWorkflowExecution") + def request_cancel_workflow_execution( + self, + context: RequestContext, + domain: DomainName, + workflow_id: WorkflowId, + run_id: WorkflowRunIdOptional | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RespondActivityTaskCanceled") + def respond_activity_task_canceled( + self, context: RequestContext, task_token: TaskToken, details: Data | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("RespondActivityTaskCompleted") + def respond_activity_task_completed( + self, context: RequestContext, task_token: TaskToken, result: Data | None = None, **kwargs + ) -> None: + raise NotImplementedError + + @handler("RespondActivityTaskFailed") + def respond_activity_task_failed( + self, + context: RequestContext, + task_token: TaskToken, + reason: FailureReason | None = None, + details: Data | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("RespondDecisionTaskCompleted") + def respond_decision_task_completed( + self, + context: RequestContext, + task_token: TaskToken, + decisions: DecisionList | None = None, + execution_context: Data | None = None, + task_list: TaskList | None = None, + task_list_schedule_to_start_timeout: DurationInSecondsOptional | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("SignalWorkflowExecution") + def signal_workflow_execution( + self, + context: RequestContext, + domain: DomainName, + workflow_id: WorkflowId, + signal_name: SignalName, + run_id: WorkflowRunIdOptional | None = None, + input: Data | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("StartWorkflowExecution") + def start_workflow_execution( + self, + context: RequestContext, + domain: DomainName, + workflow_id: WorkflowId, + workflow_type: WorkflowType, + task_list: TaskList | None = None, + task_priority: TaskPriority | None = None, + input: Data | None = None, + execution_start_to_close_timeout: DurationInSecondsOptional | None = None, + tag_list: TagList | None = None, + task_start_to_close_timeout: DurationInSecondsOptional | None = None, + child_policy: ChildPolicy | None = None, + lambda_role: Arn | None = None, + **kwargs, + ) -> Run: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: Arn, tags: ResourceTagList, **kwargs + ) -> None: + raise NotImplementedError + + @handler("TerminateWorkflowExecution") + def terminate_workflow_execution( + self, + context: RequestContext, + domain: DomainName, + workflow_id: WorkflowId, + run_id: WorkflowRunIdOptional | None = None, + reason: TerminateReason | None = None, + details: Data | None = None, + child_policy: ChildPolicy | None = None, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("UndeprecateActivityType") + def undeprecate_activity_type( + self, context: RequestContext, domain: DomainName, activity_type: ActivityType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UndeprecateDomain") + def undeprecate_domain(self, context: RequestContext, name: DomainName, **kwargs) -> None: + raise NotImplementedError + + @handler("UndeprecateWorkflowType") + def undeprecate_workflow_type( + self, context: RequestContext, domain: DomainName, workflow_type: WorkflowType, **kwargs + ) -> None: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: Arn, tag_keys: ResourceTagKeyList, **kwargs + ) -> None: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/api/transcribe/__init__.py b/localstack-core/localstack/aws/api/transcribe/__init__.py new file mode 100644 index 0000000000000..112611949bdcc --- /dev/null +++ b/localstack-core/localstack/aws/api/transcribe/__init__.py @@ -0,0 +1,1674 @@ +from datetime import datetime +from enum import StrEnum +from typing import Dict, List, Optional, TypedDict + +from localstack.aws.api import RequestContext, ServiceException, ServiceRequest, handler + +Boolean = bool +CallAnalyticsJobName = str +CategoryName = str +ChannelId = int +DataAccessRoleArn = str +DurationInSeconds = float +FailureReason = str +IdentifiedLanguageScore = float +KMSKeyId = str +MaxAlternatives = int +MaxResults = int +MaxSpeakers = int +MediaSampleRateHertz = int +MedicalMediaSampleRateHertz = int +MedicalScribeChannelId = int +ModelName = str +NextToken = str +NonEmptyString = str +OutputBucketName = str +OutputKey = str +Percentage = int +Phrase = str +String = str +SubtitleOutputStartIndex = int +TagKey = str +TagValue = str +TranscribeArn = str +TranscriptionJobName = str +Uri = str +VocabularyFilterName = str +VocabularyName = str +Word = str + + +class BaseModelName(StrEnum): + NarrowBand = "NarrowBand" + WideBand = "WideBand" + + +class CLMLanguageCode(StrEnum): + en_US = "en-US" + hi_IN = "hi-IN" + es_US = "es-US" + en_GB = "en-GB" + en_AU = "en-AU" + de_DE = "de-DE" + ja_JP = "ja-JP" + + +class CallAnalyticsFeature(StrEnum): + GENERATIVE_SUMMARIZATION = "GENERATIVE_SUMMARIZATION" + + +class CallAnalyticsJobStatus(StrEnum): + QUEUED = "QUEUED" + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + COMPLETED = "COMPLETED" + + +class CallAnalyticsSkippedReasonCode(StrEnum): + INSUFFICIENT_CONVERSATION_CONTENT = "INSUFFICIENT_CONVERSATION_CONTENT" + FAILED_SAFETY_GUIDELINES = "FAILED_SAFETY_GUIDELINES" + + +class InputType(StrEnum): + REAL_TIME = "REAL_TIME" + POST_CALL = "POST_CALL" + + +class LanguageCode(StrEnum): + af_ZA = "af-ZA" + ar_AE = "ar-AE" + ar_SA = "ar-SA" + da_DK = "da-DK" + de_CH = "de-CH" + de_DE = "de-DE" + en_AB = "en-AB" + en_AU = "en-AU" + en_GB = "en-GB" + en_IE = "en-IE" + en_IN = "en-IN" + en_US = "en-US" + en_WL = "en-WL" + es_ES = "es-ES" + es_US = "es-US" + fa_IR = "fa-IR" + fr_CA = "fr-CA" + fr_FR = "fr-FR" + he_IL = "he-IL" + hi_IN = "hi-IN" + id_ID = "id-ID" + it_IT = "it-IT" + ja_JP = "ja-JP" + ko_KR = "ko-KR" + ms_MY = "ms-MY" + nl_NL = "nl-NL" + pt_BR = "pt-BR" + pt_PT = "pt-PT" + ru_RU = "ru-RU" + ta_IN = "ta-IN" + te_IN = "te-IN" + tr_TR = "tr-TR" + zh_CN = "zh-CN" + zh_TW = "zh-TW" + th_TH = "th-TH" + en_ZA = "en-ZA" + en_NZ = "en-NZ" + vi_VN = "vi-VN" + sv_SE = "sv-SE" + ab_GE = "ab-GE" + ast_ES = "ast-ES" + az_AZ = "az-AZ" + ba_RU = "ba-RU" + be_BY = "be-BY" + bg_BG = "bg-BG" + bn_IN = "bn-IN" + bs_BA = "bs-BA" + ca_ES = "ca-ES" + ckb_IQ = "ckb-IQ" + ckb_IR = "ckb-IR" + cs_CZ = "cs-CZ" + cy_WL = "cy-WL" + el_GR = "el-GR" + et_EE = "et-EE" + et_ET = "et-ET" + eu_ES = "eu-ES" + fi_FI = "fi-FI" + gl_ES = "gl-ES" + gu_IN = "gu-IN" + ha_NG = "ha-NG" + hr_HR = "hr-HR" + hu_HU = "hu-HU" + hy_AM = "hy-AM" + is_IS = "is-IS" + ka_GE = "ka-GE" + kab_DZ = "kab-DZ" + kk_KZ = "kk-KZ" + kn_IN = "kn-IN" + ky_KG = "ky-KG" + lg_IN = "lg-IN" + lt_LT = "lt-LT" + lv_LV = "lv-LV" + mhr_RU = "mhr-RU" + mi_NZ = "mi-NZ" + mk_MK = "mk-MK" + ml_IN = "ml-IN" + mn_MN = "mn-MN" + mr_IN = "mr-IN" + mt_MT = "mt-MT" + no_NO = "no-NO" + or_IN = "or-IN" + pa_IN = "pa-IN" + pl_PL = "pl-PL" + ps_AF = "ps-AF" + ro_RO = "ro-RO" + rw_RW = "rw-RW" + si_LK = "si-LK" + sk_SK = "sk-SK" + sl_SI = "sl-SI" + so_SO = "so-SO" + sr_RS = "sr-RS" + su_ID = "su-ID" + sw_BI = "sw-BI" + sw_KE = "sw-KE" + sw_RW = "sw-RW" + sw_TZ = "sw-TZ" + sw_UG = "sw-UG" + tl_PH = "tl-PH" + tt_RU = "tt-RU" + ug_CN = "ug-CN" + uk_UA = "uk-UA" + uz_UZ = "uz-UZ" + wo_SN = "wo-SN" + zh_HK = "zh-HK" + zu_ZA = "zu-ZA" + + +class MediaFormat(StrEnum): + mp3 = "mp3" + mp4 = "mp4" + wav = "wav" + flac = "flac" + ogg = "ogg" + amr = "amr" + webm = "webm" + m4a = "m4a" + + +class MedicalContentIdentificationType(StrEnum): + PHI = "PHI" + + +class MedicalScribeJobStatus(StrEnum): + QUEUED = "QUEUED" + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + COMPLETED = "COMPLETED" + + +class MedicalScribeLanguageCode(StrEnum): + en_US = "en-US" + + +class MedicalScribeNoteTemplate(StrEnum): + HISTORY_AND_PHYSICAL = "HISTORY_AND_PHYSICAL" + GIRPP = "GIRPP" + BIRP = "BIRP" + SIRP = "SIRP" + DAP = "DAP" + BEHAVIORAL_SOAP = "BEHAVIORAL_SOAP" + PHYSICAL_SOAP = "PHYSICAL_SOAP" + + +class MedicalScribeParticipantRole(StrEnum): + PATIENT = "PATIENT" + CLINICIAN = "CLINICIAN" + + +class ModelStatus(StrEnum): + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + COMPLETED = "COMPLETED" + + +class OutputLocationType(StrEnum): + CUSTOMER_BUCKET = "CUSTOMER_BUCKET" + SERVICE_BUCKET = "SERVICE_BUCKET" + + +class ParticipantRole(StrEnum): + AGENT = "AGENT" + CUSTOMER = "CUSTOMER" + + +class PiiEntityType(StrEnum): + BANK_ACCOUNT_NUMBER = "BANK_ACCOUNT_NUMBER" + BANK_ROUTING = "BANK_ROUTING" + CREDIT_DEBIT_NUMBER = "CREDIT_DEBIT_NUMBER" + CREDIT_DEBIT_CVV = "CREDIT_DEBIT_CVV" + CREDIT_DEBIT_EXPIRY = "CREDIT_DEBIT_EXPIRY" + PIN = "PIN" + EMAIL = "EMAIL" + ADDRESS = "ADDRESS" + NAME = "NAME" + PHONE = "PHONE" + SSN = "SSN" + ALL = "ALL" + + +class RedactionOutput(StrEnum): + redacted = "redacted" + redacted_and_unredacted = "redacted_and_unredacted" + + +class RedactionType(StrEnum): + PII = "PII" + + +class SentimentValue(StrEnum): + POSITIVE = "POSITIVE" + NEGATIVE = "NEGATIVE" + NEUTRAL = "NEUTRAL" + MIXED = "MIXED" + + +class Specialty(StrEnum): + PRIMARYCARE = "PRIMARYCARE" + + +class SubtitleFormat(StrEnum): + vtt = "vtt" + srt = "srt" + + +class ToxicityCategory(StrEnum): + ALL = "ALL" + + +class TranscriptFilterType(StrEnum): + EXACT = "EXACT" + + +class TranscriptionJobStatus(StrEnum): + QUEUED = "QUEUED" + IN_PROGRESS = "IN_PROGRESS" + FAILED = "FAILED" + COMPLETED = "COMPLETED" + + +class Type(StrEnum): + CONVERSATION = "CONVERSATION" + DICTATION = "DICTATION" + + +class VocabularyFilterMethod(StrEnum): + remove = "remove" + mask = "mask" + tag = "tag" + + +class VocabularyState(StrEnum): + PENDING = "PENDING" + READY = "READY" + FAILED = "FAILED" + + +class BadRequestException(ServiceException): + code: str = "BadRequestException" + sender_fault: bool = False + status_code: int = 400 + + +class ConflictException(ServiceException): + code: str = "ConflictException" + sender_fault: bool = False + status_code: int = 400 + + +class InternalFailureException(ServiceException): + code: str = "InternalFailureException" + sender_fault: bool = False + status_code: int = 400 + + +class LimitExceededException(ServiceException): + code: str = "LimitExceededException" + sender_fault: bool = False + status_code: int = 400 + + +class NotFoundException(ServiceException): + code: str = "NotFoundException" + sender_fault: bool = False + status_code: int = 400 + + +TimestampMilliseconds = int + + +class AbsoluteTimeRange(TypedDict, total=False): + StartTime: Optional[TimestampMilliseconds] + EndTime: Optional[TimestampMilliseconds] + First: Optional[TimestampMilliseconds] + Last: Optional[TimestampMilliseconds] + + +class Tag(TypedDict, total=False): + Key: TagKey + Value: TagValue + + +TagList = List[Tag] + + +class ChannelDefinition(TypedDict, total=False): + ChannelId: Optional[ChannelId] + ParticipantRole: Optional[ParticipantRole] + + +ChannelDefinitions = List[ChannelDefinition] + + +class Summarization(TypedDict, total=False): + GenerateAbstractiveSummary: Boolean + + +class LanguageIdSettings(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + VocabularyFilterName: Optional[VocabularyFilterName] + LanguageModelName: Optional[ModelName] + + +LanguageIdSettingsMap = Dict[LanguageCode, LanguageIdSettings] +LanguageOptions = List[LanguageCode] +PiiEntityTypes = List[PiiEntityType] + + +class ContentRedaction(TypedDict, total=False): + RedactionType: RedactionType + RedactionOutput: RedactionOutput + PiiEntityTypes: Optional[PiiEntityTypes] + + +class CallAnalyticsJobSettings(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + VocabularyFilterName: Optional[VocabularyFilterName] + VocabularyFilterMethod: Optional[VocabularyFilterMethod] + LanguageModelName: Optional[ModelName] + ContentRedaction: Optional[ContentRedaction] + LanguageOptions: Optional[LanguageOptions] + LanguageIdSettings: Optional[LanguageIdSettingsMap] + Summarization: Optional[Summarization] + + +DateTime = datetime + + +class Transcript(TypedDict, total=False): + TranscriptFileUri: Optional[Uri] + RedactedTranscriptFileUri: Optional[Uri] + + +class Media(TypedDict, total=False): + MediaFileUri: Optional[Uri] + RedactedMediaFileUri: Optional[Uri] + + +class CallAnalyticsSkippedFeature(TypedDict, total=False): + Feature: Optional[CallAnalyticsFeature] + ReasonCode: Optional[CallAnalyticsSkippedReasonCode] + Message: Optional[String] + + +CallAnalyticsSkippedFeatureList = List[CallAnalyticsSkippedFeature] + + +class CallAnalyticsJobDetails(TypedDict, total=False): + Skipped: Optional[CallAnalyticsSkippedFeatureList] + + +class CallAnalyticsJob(TypedDict, total=False): + CallAnalyticsJobName: Optional[CallAnalyticsJobName] + CallAnalyticsJobStatus: Optional[CallAnalyticsJobStatus] + CallAnalyticsJobDetails: Optional[CallAnalyticsJobDetails] + LanguageCode: Optional[LanguageCode] + MediaSampleRateHertz: Optional[MediaSampleRateHertz] + MediaFormat: Optional[MediaFormat] + Media: Optional[Media] + Transcript: Optional[Transcript] + StartTime: Optional[DateTime] + CreationTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + DataAccessRoleArn: Optional[DataAccessRoleArn] + IdentifiedLanguageScore: Optional[IdentifiedLanguageScore] + Settings: Optional[CallAnalyticsJobSettings] + ChannelDefinitions: Optional[ChannelDefinitions] + Tags: Optional[TagList] + + +class CallAnalyticsJobSummary(TypedDict, total=False): + CallAnalyticsJobName: Optional[CallAnalyticsJobName] + CreationTime: Optional[DateTime] + StartTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + LanguageCode: Optional[LanguageCode] + CallAnalyticsJobStatus: Optional[CallAnalyticsJobStatus] + CallAnalyticsJobDetails: Optional[CallAnalyticsJobDetails] + FailureReason: Optional[FailureReason] + + +CallAnalyticsJobSummaries = List[CallAnalyticsJobSummary] + + +class RelativeTimeRange(TypedDict, total=False): + StartPercentage: Optional[Percentage] + EndPercentage: Optional[Percentage] + First: Optional[Percentage] + Last: Optional[Percentage] + + +SentimentValueList = List[SentimentValue] + + +class SentimentFilter(TypedDict, total=False): + Sentiments: SentimentValueList + AbsoluteTimeRange: Optional[AbsoluteTimeRange] + RelativeTimeRange: Optional[RelativeTimeRange] + ParticipantRole: Optional[ParticipantRole] + Negate: Optional[Boolean] + + +StringTargetList = List[NonEmptyString] + + +class TranscriptFilter(TypedDict, total=False): + TranscriptFilterType: TranscriptFilterType + AbsoluteTimeRange: Optional[AbsoluteTimeRange] + RelativeTimeRange: Optional[RelativeTimeRange] + ParticipantRole: Optional[ParticipantRole] + Negate: Optional[Boolean] + Targets: StringTargetList + + +class InterruptionFilter(TypedDict, total=False): + Threshold: Optional[TimestampMilliseconds] + ParticipantRole: Optional[ParticipantRole] + AbsoluteTimeRange: Optional[AbsoluteTimeRange] + RelativeTimeRange: Optional[RelativeTimeRange] + Negate: Optional[Boolean] + + +class NonTalkTimeFilter(TypedDict, total=False): + Threshold: Optional[TimestampMilliseconds] + AbsoluteTimeRange: Optional[AbsoluteTimeRange] + RelativeTimeRange: Optional[RelativeTimeRange] + Negate: Optional[Boolean] + + +class Rule(TypedDict, total=False): + NonTalkTimeFilter: Optional[NonTalkTimeFilter] + InterruptionFilter: Optional[InterruptionFilter] + TranscriptFilter: Optional[TranscriptFilter] + SentimentFilter: Optional[SentimentFilter] + + +RuleList = List[Rule] + + +class CategoryProperties(TypedDict, total=False): + CategoryName: Optional[CategoryName] + Rules: Optional[RuleList] + CreateTime: Optional[DateTime] + LastUpdateTime: Optional[DateTime] + Tags: Optional[TagList] + InputType: Optional[InputType] + + +CategoryPropertiesList = List[CategoryProperties] + + +class ClinicalNoteGenerationSettings(TypedDict, total=False): + NoteTemplate: Optional[MedicalScribeNoteTemplate] + + +class CreateCallAnalyticsCategoryRequest(ServiceRequest): + CategoryName: CategoryName + Rules: RuleList + Tags: Optional[TagList] + InputType: Optional[InputType] + + +class CreateCallAnalyticsCategoryResponse(TypedDict, total=False): + CategoryProperties: Optional[CategoryProperties] + + +class InputDataConfig(TypedDict, total=False): + S3Uri: Uri + TuningDataS3Uri: Optional[Uri] + DataAccessRoleArn: DataAccessRoleArn + + +class CreateLanguageModelRequest(ServiceRequest): + LanguageCode: CLMLanguageCode + BaseModelName: BaseModelName + ModelName: ModelName + InputDataConfig: InputDataConfig + Tags: Optional[TagList] + + +class CreateLanguageModelResponse(TypedDict, total=False): + LanguageCode: Optional[CLMLanguageCode] + BaseModelName: Optional[BaseModelName] + ModelName: Optional[ModelName] + InputDataConfig: Optional[InputDataConfig] + ModelStatus: Optional[ModelStatus] + + +class CreateMedicalVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + LanguageCode: LanguageCode + VocabularyFileUri: Uri + Tags: Optional[TagList] + + +class CreateMedicalVocabularyResponse(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + LanguageCode: Optional[LanguageCode] + VocabularyState: Optional[VocabularyState] + LastModifiedTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + + +Words = List[Word] + + +class CreateVocabularyFilterRequest(ServiceRequest): + VocabularyFilterName: VocabularyFilterName + LanguageCode: LanguageCode + Words: Optional[Words] + VocabularyFilterFileUri: Optional[Uri] + Tags: Optional[TagList] + DataAccessRoleArn: Optional[DataAccessRoleArn] + + +class CreateVocabularyFilterResponse(TypedDict, total=False): + VocabularyFilterName: Optional[VocabularyFilterName] + LanguageCode: Optional[LanguageCode] + LastModifiedTime: Optional[DateTime] + + +Phrases = List[Phrase] + + +class CreateVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + LanguageCode: LanguageCode + Phrases: Optional[Phrases] + VocabularyFileUri: Optional[Uri] + Tags: Optional[TagList] + DataAccessRoleArn: Optional[DataAccessRoleArn] + + +class CreateVocabularyResponse(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + LanguageCode: Optional[LanguageCode] + VocabularyState: Optional[VocabularyState] + LastModifiedTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + + +class DeleteCallAnalyticsCategoryRequest(ServiceRequest): + CategoryName: CategoryName + + +class DeleteCallAnalyticsCategoryResponse(TypedDict, total=False): + pass + + +class DeleteCallAnalyticsJobRequest(ServiceRequest): + CallAnalyticsJobName: CallAnalyticsJobName + + +class DeleteCallAnalyticsJobResponse(TypedDict, total=False): + pass + + +class DeleteLanguageModelRequest(ServiceRequest): + ModelName: ModelName + + +class DeleteMedicalScribeJobRequest(ServiceRequest): + MedicalScribeJobName: TranscriptionJobName + + +class DeleteMedicalTranscriptionJobRequest(ServiceRequest): + MedicalTranscriptionJobName: TranscriptionJobName + + +class DeleteMedicalVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + + +class DeleteTranscriptionJobRequest(ServiceRequest): + TranscriptionJobName: TranscriptionJobName + + +class DeleteVocabularyFilterRequest(ServiceRequest): + VocabularyFilterName: VocabularyFilterName + + +class DeleteVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + + +class DescribeLanguageModelRequest(ServiceRequest): + ModelName: ModelName + + +class LanguageModel(TypedDict, total=False): + ModelName: Optional[ModelName] + CreateTime: Optional[DateTime] + LastModifiedTime: Optional[DateTime] + LanguageCode: Optional[CLMLanguageCode] + BaseModelName: Optional[BaseModelName] + ModelStatus: Optional[ModelStatus] + UpgradeAvailability: Optional[Boolean] + FailureReason: Optional[FailureReason] + InputDataConfig: Optional[InputDataConfig] + + +class DescribeLanguageModelResponse(TypedDict, total=False): + LanguageModel: Optional[LanguageModel] + + +class GetCallAnalyticsCategoryRequest(ServiceRequest): + CategoryName: CategoryName + + +class GetCallAnalyticsCategoryResponse(TypedDict, total=False): + CategoryProperties: Optional[CategoryProperties] + + +class GetCallAnalyticsJobRequest(ServiceRequest): + CallAnalyticsJobName: CallAnalyticsJobName + + +class GetCallAnalyticsJobResponse(TypedDict, total=False): + CallAnalyticsJob: Optional[CallAnalyticsJob] + + +class GetMedicalScribeJobRequest(ServiceRequest): + MedicalScribeJobName: TranscriptionJobName + + +class MedicalScribeChannelDefinition(TypedDict, total=False): + ChannelId: MedicalScribeChannelId + ParticipantRole: MedicalScribeParticipantRole + + +MedicalScribeChannelDefinitions = List[MedicalScribeChannelDefinition] + + +class MedicalScribeSettings(TypedDict, total=False): + ShowSpeakerLabels: Optional[Boolean] + MaxSpeakerLabels: Optional[MaxSpeakers] + ChannelIdentification: Optional[Boolean] + VocabularyName: Optional[VocabularyName] + VocabularyFilterName: Optional[VocabularyFilterName] + VocabularyFilterMethod: Optional[VocabularyFilterMethod] + ClinicalNoteGenerationSettings: Optional[ClinicalNoteGenerationSettings] + + +class MedicalScribeOutput(TypedDict, total=False): + TranscriptFileUri: Uri + ClinicalDocumentUri: Uri + + +class MedicalScribeJob(TypedDict, total=False): + MedicalScribeJobName: Optional[TranscriptionJobName] + MedicalScribeJobStatus: Optional[MedicalScribeJobStatus] + LanguageCode: Optional[MedicalScribeLanguageCode] + Media: Optional[Media] + MedicalScribeOutput: Optional[MedicalScribeOutput] + StartTime: Optional[DateTime] + CreationTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + Settings: Optional[MedicalScribeSettings] + DataAccessRoleArn: Optional[DataAccessRoleArn] + ChannelDefinitions: Optional[MedicalScribeChannelDefinitions] + Tags: Optional[TagList] + + +class GetMedicalScribeJobResponse(TypedDict, total=False): + MedicalScribeJob: Optional[MedicalScribeJob] + + +class GetMedicalTranscriptionJobRequest(ServiceRequest): + MedicalTranscriptionJobName: TranscriptionJobName + + +class MedicalTranscriptionSetting(TypedDict, total=False): + ShowSpeakerLabels: Optional[Boolean] + MaxSpeakerLabels: Optional[MaxSpeakers] + ChannelIdentification: Optional[Boolean] + ShowAlternatives: Optional[Boolean] + MaxAlternatives: Optional[MaxAlternatives] + VocabularyName: Optional[VocabularyName] + + +class MedicalTranscript(TypedDict, total=False): + TranscriptFileUri: Optional[Uri] + + +class MedicalTranscriptionJob(TypedDict, total=False): + MedicalTranscriptionJobName: Optional[TranscriptionJobName] + TranscriptionJobStatus: Optional[TranscriptionJobStatus] + LanguageCode: Optional[LanguageCode] + MediaSampleRateHertz: Optional[MedicalMediaSampleRateHertz] + MediaFormat: Optional[MediaFormat] + Media: Optional[Media] + Transcript: Optional[MedicalTranscript] + StartTime: Optional[DateTime] + CreationTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + Settings: Optional[MedicalTranscriptionSetting] + ContentIdentificationType: Optional[MedicalContentIdentificationType] + Specialty: Optional[Specialty] + Type: Optional[Type] + Tags: Optional[TagList] + + +class GetMedicalTranscriptionJobResponse(TypedDict, total=False): + MedicalTranscriptionJob: Optional[MedicalTranscriptionJob] + + +class GetMedicalVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + + +class GetMedicalVocabularyResponse(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + LanguageCode: Optional[LanguageCode] + VocabularyState: Optional[VocabularyState] + LastModifiedTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + DownloadUri: Optional[Uri] + + +class GetTranscriptionJobRequest(ServiceRequest): + TranscriptionJobName: TranscriptionJobName + + +ToxicityCategories = List[ToxicityCategory] + + +class ToxicityDetectionSettings(TypedDict, total=False): + ToxicityCategories: ToxicityCategories + + +ToxicityDetection = List[ToxicityDetectionSettings] +SubtitleFileUris = List[Uri] +SubtitleFormats = List[SubtitleFormat] + + +class SubtitlesOutput(TypedDict, total=False): + Formats: Optional[SubtitleFormats] + SubtitleFileUris: Optional[SubtitleFileUris] + OutputStartIndex: Optional[SubtitleOutputStartIndex] + + +class LanguageCodeItem(TypedDict, total=False): + LanguageCode: Optional[LanguageCode] + DurationInSeconds: Optional[DurationInSeconds] + + +LanguageCodeList = List[LanguageCodeItem] + + +class JobExecutionSettings(TypedDict, total=False): + AllowDeferredExecution: Optional[Boolean] + DataAccessRoleArn: Optional[DataAccessRoleArn] + + +class ModelSettings(TypedDict, total=False): + LanguageModelName: Optional[ModelName] + + +class Settings(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + ShowSpeakerLabels: Optional[Boolean] + MaxSpeakerLabels: Optional[MaxSpeakers] + ChannelIdentification: Optional[Boolean] + ShowAlternatives: Optional[Boolean] + MaxAlternatives: Optional[MaxAlternatives] + VocabularyFilterName: Optional[VocabularyFilterName] + VocabularyFilterMethod: Optional[VocabularyFilterMethod] + + +class TranscriptionJob(TypedDict, total=False): + TranscriptionJobName: Optional[TranscriptionJobName] + TranscriptionJobStatus: Optional[TranscriptionJobStatus] + LanguageCode: Optional[LanguageCode] + MediaSampleRateHertz: Optional[MediaSampleRateHertz] + MediaFormat: Optional[MediaFormat] + Media: Optional[Media] + Transcript: Optional[Transcript] + StartTime: Optional[DateTime] + CreationTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + Settings: Optional[Settings] + ModelSettings: Optional[ModelSettings] + JobExecutionSettings: Optional[JobExecutionSettings] + ContentRedaction: Optional[ContentRedaction] + IdentifyLanguage: Optional[Boolean] + IdentifyMultipleLanguages: Optional[Boolean] + LanguageOptions: Optional[LanguageOptions] + IdentifiedLanguageScore: Optional[IdentifiedLanguageScore] + LanguageCodes: Optional[LanguageCodeList] + Tags: Optional[TagList] + Subtitles: Optional[SubtitlesOutput] + LanguageIdSettings: Optional[LanguageIdSettingsMap] + ToxicityDetection: Optional[ToxicityDetection] + + +class GetTranscriptionJobResponse(TypedDict, total=False): + TranscriptionJob: Optional[TranscriptionJob] + + +class GetVocabularyFilterRequest(ServiceRequest): + VocabularyFilterName: VocabularyFilterName + + +class GetVocabularyFilterResponse(TypedDict, total=False): + VocabularyFilterName: Optional[VocabularyFilterName] + LanguageCode: Optional[LanguageCode] + LastModifiedTime: Optional[DateTime] + DownloadUri: Optional[Uri] + + +class GetVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + + +class GetVocabularyResponse(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + LanguageCode: Optional[LanguageCode] + VocabularyState: Optional[VocabularyState] + LastModifiedTime: Optional[DateTime] + FailureReason: Optional[FailureReason] + DownloadUri: Optional[Uri] + + +KMSEncryptionContextMap = Dict[NonEmptyString, NonEmptyString] + + +class ListCallAnalyticsCategoriesRequest(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ListCallAnalyticsCategoriesResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + Categories: Optional[CategoryPropertiesList] + + +class ListCallAnalyticsJobsRequest(ServiceRequest): + Status: Optional[CallAnalyticsJobStatus] + JobNameContains: Optional[CallAnalyticsJobName] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class ListCallAnalyticsJobsResponse(TypedDict, total=False): + Status: Optional[CallAnalyticsJobStatus] + NextToken: Optional[NextToken] + CallAnalyticsJobSummaries: Optional[CallAnalyticsJobSummaries] + + +class ListLanguageModelsRequest(ServiceRequest): + StatusEquals: Optional[ModelStatus] + NameContains: Optional[ModelName] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +Models = List[LanguageModel] + + +class ListLanguageModelsResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + Models: Optional[Models] + + +class ListMedicalScribeJobsRequest(ServiceRequest): + Status: Optional[MedicalScribeJobStatus] + JobNameContains: Optional[TranscriptionJobName] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class MedicalScribeJobSummary(TypedDict, total=False): + MedicalScribeJobName: Optional[TranscriptionJobName] + CreationTime: Optional[DateTime] + StartTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + LanguageCode: Optional[MedicalScribeLanguageCode] + MedicalScribeJobStatus: Optional[MedicalScribeJobStatus] + FailureReason: Optional[FailureReason] + + +MedicalScribeJobSummaries = List[MedicalScribeJobSummary] + + +class ListMedicalScribeJobsResponse(TypedDict, total=False): + Status: Optional[MedicalScribeJobStatus] + NextToken: Optional[NextToken] + MedicalScribeJobSummaries: Optional[MedicalScribeJobSummaries] + + +class ListMedicalTranscriptionJobsRequest(ServiceRequest): + Status: Optional[TranscriptionJobStatus] + JobNameContains: Optional[TranscriptionJobName] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class MedicalTranscriptionJobSummary(TypedDict, total=False): + MedicalTranscriptionJobName: Optional[TranscriptionJobName] + CreationTime: Optional[DateTime] + StartTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + LanguageCode: Optional[LanguageCode] + TranscriptionJobStatus: Optional[TranscriptionJobStatus] + FailureReason: Optional[FailureReason] + OutputLocationType: Optional[OutputLocationType] + Specialty: Optional[Specialty] + ContentIdentificationType: Optional[MedicalContentIdentificationType] + Type: Optional[Type] + + +MedicalTranscriptionJobSummaries = List[MedicalTranscriptionJobSummary] + + +class ListMedicalTranscriptionJobsResponse(TypedDict, total=False): + Status: Optional[TranscriptionJobStatus] + NextToken: Optional[NextToken] + MedicalTranscriptionJobSummaries: Optional[MedicalTranscriptionJobSummaries] + + +class ListMedicalVocabulariesRequest(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + StateEquals: Optional[VocabularyState] + NameContains: Optional[VocabularyName] + + +class VocabularyInfo(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + LanguageCode: Optional[LanguageCode] + LastModifiedTime: Optional[DateTime] + VocabularyState: Optional[VocabularyState] + + +Vocabularies = List[VocabularyInfo] + + +class ListMedicalVocabulariesResponse(TypedDict, total=False): + Status: Optional[VocabularyState] + NextToken: Optional[NextToken] + Vocabularies: Optional[Vocabularies] + + +class ListTagsForResourceRequest(ServiceRequest): + ResourceArn: TranscribeArn + + +class ListTagsForResourceResponse(TypedDict, total=False): + ResourceArn: Optional[TranscribeArn] + Tags: Optional[TagList] + + +class ListTranscriptionJobsRequest(ServiceRequest): + Status: Optional[TranscriptionJobStatus] + JobNameContains: Optional[TranscriptionJobName] + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + + +class TranscriptionJobSummary(TypedDict, total=False): + TranscriptionJobName: Optional[TranscriptionJobName] + CreationTime: Optional[DateTime] + StartTime: Optional[DateTime] + CompletionTime: Optional[DateTime] + LanguageCode: Optional[LanguageCode] + TranscriptionJobStatus: Optional[TranscriptionJobStatus] + FailureReason: Optional[FailureReason] + OutputLocationType: Optional[OutputLocationType] + ContentRedaction: Optional[ContentRedaction] + ModelSettings: Optional[ModelSettings] + IdentifyLanguage: Optional[Boolean] + IdentifyMultipleLanguages: Optional[Boolean] + IdentifiedLanguageScore: Optional[IdentifiedLanguageScore] + LanguageCodes: Optional[LanguageCodeList] + ToxicityDetection: Optional[ToxicityDetection] + + +TranscriptionJobSummaries = List[TranscriptionJobSummary] + + +class ListTranscriptionJobsResponse(TypedDict, total=False): + Status: Optional[TranscriptionJobStatus] + NextToken: Optional[NextToken] + TranscriptionJobSummaries: Optional[TranscriptionJobSummaries] + + +class ListVocabulariesRequest(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + StateEquals: Optional[VocabularyState] + NameContains: Optional[VocabularyName] + + +class ListVocabulariesResponse(TypedDict, total=False): + Status: Optional[VocabularyState] + NextToken: Optional[NextToken] + Vocabularies: Optional[Vocabularies] + + +class ListVocabularyFiltersRequest(ServiceRequest): + NextToken: Optional[NextToken] + MaxResults: Optional[MaxResults] + NameContains: Optional[VocabularyFilterName] + + +class VocabularyFilterInfo(TypedDict, total=False): + VocabularyFilterName: Optional[VocabularyFilterName] + LanguageCode: Optional[LanguageCode] + LastModifiedTime: Optional[DateTime] + + +VocabularyFilters = List[VocabularyFilterInfo] + + +class ListVocabularyFiltersResponse(TypedDict, total=False): + NextToken: Optional[NextToken] + VocabularyFilters: Optional[VocabularyFilters] + + +class StartCallAnalyticsJobRequest(ServiceRequest): + CallAnalyticsJobName: CallAnalyticsJobName + Media: Media + OutputLocation: Optional[Uri] + OutputEncryptionKMSKeyId: Optional[KMSKeyId] + DataAccessRoleArn: Optional[DataAccessRoleArn] + Settings: Optional[CallAnalyticsJobSettings] + Tags: Optional[TagList] + ChannelDefinitions: Optional[ChannelDefinitions] + + +class StartCallAnalyticsJobResponse(TypedDict, total=False): + CallAnalyticsJob: Optional[CallAnalyticsJob] + + +class StartMedicalScribeJobRequest(ServiceRequest): + MedicalScribeJobName: TranscriptionJobName + Media: Media + OutputBucketName: OutputBucketName + OutputEncryptionKMSKeyId: Optional[KMSKeyId] + KMSEncryptionContext: Optional[KMSEncryptionContextMap] + DataAccessRoleArn: DataAccessRoleArn + Settings: MedicalScribeSettings + ChannelDefinitions: Optional[MedicalScribeChannelDefinitions] + Tags: Optional[TagList] + + +class StartMedicalScribeJobResponse(TypedDict, total=False): + MedicalScribeJob: Optional[MedicalScribeJob] + + +class StartMedicalTranscriptionJobRequest(ServiceRequest): + MedicalTranscriptionJobName: TranscriptionJobName + LanguageCode: LanguageCode + MediaSampleRateHertz: Optional[MedicalMediaSampleRateHertz] + MediaFormat: Optional[MediaFormat] + Media: Media + OutputBucketName: OutputBucketName + OutputKey: Optional[OutputKey] + OutputEncryptionKMSKeyId: Optional[KMSKeyId] + KMSEncryptionContext: Optional[KMSEncryptionContextMap] + Settings: Optional[MedicalTranscriptionSetting] + ContentIdentificationType: Optional[MedicalContentIdentificationType] + Specialty: Specialty + Type: Type + Tags: Optional[TagList] + + +class StartMedicalTranscriptionJobResponse(TypedDict, total=False): + MedicalTranscriptionJob: Optional[MedicalTranscriptionJob] + + +class Subtitles(TypedDict, total=False): + Formats: Optional[SubtitleFormats] + OutputStartIndex: Optional[SubtitleOutputStartIndex] + + +class StartTranscriptionJobRequest(ServiceRequest): + TranscriptionJobName: TranscriptionJobName + LanguageCode: Optional[LanguageCode] + MediaSampleRateHertz: Optional[MediaSampleRateHertz] + MediaFormat: Optional[MediaFormat] + Media: Media + OutputBucketName: Optional[OutputBucketName] + OutputKey: Optional[OutputKey] + OutputEncryptionKMSKeyId: Optional[KMSKeyId] + KMSEncryptionContext: Optional[KMSEncryptionContextMap] + Settings: Optional[Settings] + ModelSettings: Optional[ModelSettings] + JobExecutionSettings: Optional[JobExecutionSettings] + ContentRedaction: Optional[ContentRedaction] + IdentifyLanguage: Optional[Boolean] + IdentifyMultipleLanguages: Optional[Boolean] + LanguageOptions: Optional[LanguageOptions] + Subtitles: Optional[Subtitles] + Tags: Optional[TagList] + LanguageIdSettings: Optional[LanguageIdSettingsMap] + ToxicityDetection: Optional[ToxicityDetection] + + +class StartTranscriptionJobResponse(TypedDict, total=False): + TranscriptionJob: Optional[TranscriptionJob] + + +TagKeyList = List[TagKey] + + +class TagResourceRequest(ServiceRequest): + ResourceArn: TranscribeArn + Tags: TagList + + +class TagResourceResponse(TypedDict, total=False): + pass + + +class UntagResourceRequest(ServiceRequest): + ResourceArn: TranscribeArn + TagKeys: TagKeyList + + +class UntagResourceResponse(TypedDict, total=False): + pass + + +class UpdateCallAnalyticsCategoryRequest(ServiceRequest): + CategoryName: CategoryName + Rules: RuleList + InputType: Optional[InputType] + + +class UpdateCallAnalyticsCategoryResponse(TypedDict, total=False): + CategoryProperties: Optional[CategoryProperties] + + +class UpdateMedicalVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + LanguageCode: LanguageCode + VocabularyFileUri: Uri + + +class UpdateMedicalVocabularyResponse(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + LanguageCode: Optional[LanguageCode] + LastModifiedTime: Optional[DateTime] + VocabularyState: Optional[VocabularyState] + + +class UpdateVocabularyFilterRequest(ServiceRequest): + VocabularyFilterName: VocabularyFilterName + Words: Optional[Words] + VocabularyFilterFileUri: Optional[Uri] + DataAccessRoleArn: Optional[DataAccessRoleArn] + + +class UpdateVocabularyFilterResponse(TypedDict, total=False): + VocabularyFilterName: Optional[VocabularyFilterName] + LanguageCode: Optional[LanguageCode] + LastModifiedTime: Optional[DateTime] + + +class UpdateVocabularyRequest(ServiceRequest): + VocabularyName: VocabularyName + LanguageCode: LanguageCode + Phrases: Optional[Phrases] + VocabularyFileUri: Optional[Uri] + DataAccessRoleArn: Optional[DataAccessRoleArn] + + +class UpdateVocabularyResponse(TypedDict, total=False): + VocabularyName: Optional[VocabularyName] + LanguageCode: Optional[LanguageCode] + LastModifiedTime: Optional[DateTime] + VocabularyState: Optional[VocabularyState] + + +class TranscribeApi: + service = "transcribe" + version = "2017-10-26" + + @handler("CreateCallAnalyticsCategory") + def create_call_analytics_category( + self, + context: RequestContext, + category_name: CategoryName, + rules: RuleList, + tags: TagList | None = None, + input_type: InputType | None = None, + **kwargs, + ) -> CreateCallAnalyticsCategoryResponse: + raise NotImplementedError + + @handler("CreateLanguageModel") + def create_language_model( + self, + context: RequestContext, + language_code: CLMLanguageCode, + base_model_name: BaseModelName, + model_name: ModelName, + input_data_config: InputDataConfig, + tags: TagList | None = None, + **kwargs, + ) -> CreateLanguageModelResponse: + raise NotImplementedError + + @handler("CreateMedicalVocabulary") + def create_medical_vocabulary( + self, + context: RequestContext, + vocabulary_name: VocabularyName, + language_code: LanguageCode, + vocabulary_file_uri: Uri, + tags: TagList | None = None, + **kwargs, + ) -> CreateMedicalVocabularyResponse: + raise NotImplementedError + + @handler("CreateVocabulary") + def create_vocabulary( + self, + context: RequestContext, + vocabulary_name: VocabularyName, + language_code: LanguageCode, + phrases: Phrases | None = None, + vocabulary_file_uri: Uri | None = None, + tags: TagList | None = None, + data_access_role_arn: DataAccessRoleArn | None = None, + **kwargs, + ) -> CreateVocabularyResponse: + raise NotImplementedError + + @handler("CreateVocabularyFilter") + def create_vocabulary_filter( + self, + context: RequestContext, + vocabulary_filter_name: VocabularyFilterName, + language_code: LanguageCode, + words: Words | None = None, + vocabulary_filter_file_uri: Uri | None = None, + tags: TagList | None = None, + data_access_role_arn: DataAccessRoleArn | None = None, + **kwargs, + ) -> CreateVocabularyFilterResponse: + raise NotImplementedError + + @handler("DeleteCallAnalyticsCategory") + def delete_call_analytics_category( + self, context: RequestContext, category_name: CategoryName, **kwargs + ) -> DeleteCallAnalyticsCategoryResponse: + raise NotImplementedError + + @handler("DeleteCallAnalyticsJob") + def delete_call_analytics_job( + self, context: RequestContext, call_analytics_job_name: CallAnalyticsJobName, **kwargs + ) -> DeleteCallAnalyticsJobResponse: + raise NotImplementedError + + @handler("DeleteLanguageModel") + def delete_language_model( + self, context: RequestContext, model_name: ModelName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteMedicalScribeJob") + def delete_medical_scribe_job( + self, context: RequestContext, medical_scribe_job_name: TranscriptionJobName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteMedicalTranscriptionJob") + def delete_medical_transcription_job( + self, + context: RequestContext, + medical_transcription_job_name: TranscriptionJobName, + **kwargs, + ) -> None: + raise NotImplementedError + + @handler("DeleteMedicalVocabulary") + def delete_medical_vocabulary( + self, context: RequestContext, vocabulary_name: VocabularyName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteTranscriptionJob") + def delete_transcription_job( + self, context: RequestContext, transcription_job_name: TranscriptionJobName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteVocabulary") + def delete_vocabulary( + self, context: RequestContext, vocabulary_name: VocabularyName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DeleteVocabularyFilter") + def delete_vocabulary_filter( + self, context: RequestContext, vocabulary_filter_name: VocabularyFilterName, **kwargs + ) -> None: + raise NotImplementedError + + @handler("DescribeLanguageModel") + def describe_language_model( + self, context: RequestContext, model_name: ModelName, **kwargs + ) -> DescribeLanguageModelResponse: + raise NotImplementedError + + @handler("GetCallAnalyticsCategory") + def get_call_analytics_category( + self, context: RequestContext, category_name: CategoryName, **kwargs + ) -> GetCallAnalyticsCategoryResponse: + raise NotImplementedError + + @handler("GetCallAnalyticsJob") + def get_call_analytics_job( + self, context: RequestContext, call_analytics_job_name: CallAnalyticsJobName, **kwargs + ) -> GetCallAnalyticsJobResponse: + raise NotImplementedError + + @handler("GetMedicalScribeJob") + def get_medical_scribe_job( + self, context: RequestContext, medical_scribe_job_name: TranscriptionJobName, **kwargs + ) -> GetMedicalScribeJobResponse: + raise NotImplementedError + + @handler("GetMedicalTranscriptionJob") + def get_medical_transcription_job( + self, + context: RequestContext, + medical_transcription_job_name: TranscriptionJobName, + **kwargs, + ) -> GetMedicalTranscriptionJobResponse: + raise NotImplementedError + + @handler("GetMedicalVocabulary") + def get_medical_vocabulary( + self, context: RequestContext, vocabulary_name: VocabularyName, **kwargs + ) -> GetMedicalVocabularyResponse: + raise NotImplementedError + + @handler("GetTranscriptionJob") + def get_transcription_job( + self, context: RequestContext, transcription_job_name: TranscriptionJobName, **kwargs + ) -> GetTranscriptionJobResponse: + raise NotImplementedError + + @handler("GetVocabulary") + def get_vocabulary( + self, context: RequestContext, vocabulary_name: VocabularyName, **kwargs + ) -> GetVocabularyResponse: + raise NotImplementedError + + @handler("GetVocabularyFilter") + def get_vocabulary_filter( + self, context: RequestContext, vocabulary_filter_name: VocabularyFilterName, **kwargs + ) -> GetVocabularyFilterResponse: + raise NotImplementedError + + @handler("ListCallAnalyticsCategories") + def list_call_analytics_categories( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListCallAnalyticsCategoriesResponse: + raise NotImplementedError + + @handler("ListCallAnalyticsJobs") + def list_call_analytics_jobs( + self, + context: RequestContext, + status: CallAnalyticsJobStatus | None = None, + job_name_contains: CallAnalyticsJobName | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListCallAnalyticsJobsResponse: + raise NotImplementedError + + @handler("ListLanguageModels") + def list_language_models( + self, + context: RequestContext, + status_equals: ModelStatus | None = None, + name_contains: ModelName | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListLanguageModelsResponse: + raise NotImplementedError + + @handler("ListMedicalScribeJobs") + def list_medical_scribe_jobs( + self, + context: RequestContext, + status: MedicalScribeJobStatus | None = None, + job_name_contains: TranscriptionJobName | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListMedicalScribeJobsResponse: + raise NotImplementedError + + @handler("ListMedicalTranscriptionJobs") + def list_medical_transcription_jobs( + self, + context: RequestContext, + status: TranscriptionJobStatus | None = None, + job_name_contains: TranscriptionJobName | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListMedicalTranscriptionJobsResponse: + raise NotImplementedError + + @handler("ListMedicalVocabularies") + def list_medical_vocabularies( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + state_equals: VocabularyState | None = None, + name_contains: VocabularyName | None = None, + **kwargs, + ) -> ListMedicalVocabulariesResponse: + raise NotImplementedError + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: TranscribeArn, **kwargs + ) -> ListTagsForResourceResponse: + raise NotImplementedError + + @handler("ListTranscriptionJobs") + def list_transcription_jobs( + self, + context: RequestContext, + status: TranscriptionJobStatus | None = None, + job_name_contains: TranscriptionJobName | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs, + ) -> ListTranscriptionJobsResponse: + raise NotImplementedError + + @handler("ListVocabularies") + def list_vocabularies( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + state_equals: VocabularyState | None = None, + name_contains: VocabularyName | None = None, + **kwargs, + ) -> ListVocabulariesResponse: + raise NotImplementedError + + @handler("ListVocabularyFilters") + def list_vocabulary_filters( + self, + context: RequestContext, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + name_contains: VocabularyFilterName | None = None, + **kwargs, + ) -> ListVocabularyFiltersResponse: + raise NotImplementedError + + @handler("StartCallAnalyticsJob") + def start_call_analytics_job( + self, + context: RequestContext, + call_analytics_job_name: CallAnalyticsJobName, + media: Media, + output_location: Uri | None = None, + output_encryption_kms_key_id: KMSKeyId | None = None, + data_access_role_arn: DataAccessRoleArn | None = None, + settings: CallAnalyticsJobSettings | None = None, + tags: TagList | None = None, + channel_definitions: ChannelDefinitions | None = None, + **kwargs, + ) -> StartCallAnalyticsJobResponse: + raise NotImplementedError + + @handler("StartMedicalScribeJob") + def start_medical_scribe_job( + self, + context: RequestContext, + medical_scribe_job_name: TranscriptionJobName, + media: Media, + output_bucket_name: OutputBucketName, + data_access_role_arn: DataAccessRoleArn, + settings: MedicalScribeSettings, + output_encryption_kms_key_id: KMSKeyId | None = None, + kms_encryption_context: KMSEncryptionContextMap | None = None, + channel_definitions: MedicalScribeChannelDefinitions | None = None, + tags: TagList | None = None, + **kwargs, + ) -> StartMedicalScribeJobResponse: + raise NotImplementedError + + @handler("StartMedicalTranscriptionJob", expand=False) + def start_medical_transcription_job( + self, context: RequestContext, request: StartMedicalTranscriptionJobRequest, **kwargs + ) -> StartMedicalTranscriptionJobResponse: + raise NotImplementedError + + @handler("StartTranscriptionJob") + def start_transcription_job( + self, + context: RequestContext, + transcription_job_name: TranscriptionJobName, + media: Media, + language_code: LanguageCode | None = None, + media_sample_rate_hertz: MediaSampleRateHertz | None = None, + media_format: MediaFormat | None = None, + output_bucket_name: OutputBucketName | None = None, + output_key: OutputKey | None = None, + output_encryption_kms_key_id: KMSKeyId | None = None, + kms_encryption_context: KMSEncryptionContextMap | None = None, + settings: Settings | None = None, + model_settings: ModelSettings | None = None, + job_execution_settings: JobExecutionSettings | None = None, + content_redaction: ContentRedaction | None = None, + identify_language: Boolean | None = None, + identify_multiple_languages: Boolean | None = None, + language_options: LanguageOptions | None = None, + subtitles: Subtitles | None = None, + tags: TagList | None = None, + language_id_settings: LanguageIdSettingsMap | None = None, + toxicity_detection: ToxicityDetection | None = None, + **kwargs, + ) -> StartTranscriptionJobResponse: + raise NotImplementedError + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: TranscribeArn, tags: TagList, **kwargs + ) -> TagResourceResponse: + raise NotImplementedError + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: TranscribeArn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceResponse: + raise NotImplementedError + + @handler("UpdateCallAnalyticsCategory") + def update_call_analytics_category( + self, + context: RequestContext, + category_name: CategoryName, + rules: RuleList, + input_type: InputType | None = None, + **kwargs, + ) -> UpdateCallAnalyticsCategoryResponse: + raise NotImplementedError + + @handler("UpdateMedicalVocabulary") + def update_medical_vocabulary( + self, + context: RequestContext, + vocabulary_name: VocabularyName, + language_code: LanguageCode, + vocabulary_file_uri: Uri, + **kwargs, + ) -> UpdateMedicalVocabularyResponse: + raise NotImplementedError + + @handler("UpdateVocabulary") + def update_vocabulary( + self, + context: RequestContext, + vocabulary_name: VocabularyName, + language_code: LanguageCode, + phrases: Phrases | None = None, + vocabulary_file_uri: Uri | None = None, + data_access_role_arn: DataAccessRoleArn | None = None, + **kwargs, + ) -> UpdateVocabularyResponse: + raise NotImplementedError + + @handler("UpdateVocabularyFilter") + def update_vocabulary_filter( + self, + context: RequestContext, + vocabulary_filter_name: VocabularyFilterName, + words: Words | None = None, + vocabulary_filter_file_uri: Uri | None = None, + data_access_role_arn: DataAccessRoleArn | None = None, + **kwargs, + ) -> UpdateVocabularyFilterResponse: + raise NotImplementedError diff --git a/localstack-core/localstack/aws/app.py b/localstack-core/localstack/aws/app.py new file mode 100644 index 0000000000000..35249aae9d7cd --- /dev/null +++ b/localstack-core/localstack/aws/app.py @@ -0,0 +1,130 @@ +from localstack import config +from localstack.aws import handlers +from localstack.aws.api import RequestContext +from localstack.aws.chain import HandlerChain +from localstack.aws.handlers.metric_handler import MetricHandler +from localstack.aws.handlers.service_plugin import ServiceLoader, ServiceLoaderForDataPlane +from localstack.http.trace import TracingHandlerChain +from localstack.services.plugins import SERVICE_PLUGINS, ServiceManager, ServicePluginManager +from localstack.utils.ssl import create_ssl_cert, install_predefined_cert_if_available + +from .gateway import Gateway +from .handlers.fallback import EmptyResponseHandler +from .handlers.service import ServiceRequestRouter + + +class LocalstackAwsGateway(Gateway): + def __init__(self, service_manager: ServiceManager = None) -> None: + super().__init__(context_class=RequestContext) + + # basic server components + self.service_manager = service_manager or ServicePluginManager() + self.service_request_router = ServiceRequestRouter() + # lazy-loads services into the router + load_service = ServiceLoader(self.service_manager, self.service_request_router) + load_service_for_data_plane = ServiceLoaderForDataPlane(load_service) + + metric_collector = MetricHandler() + # the main request handler chain + self.request_handlers.extend( + [ + handlers.add_internal_request_params, + handlers.handle_runtime_shutdown, + metric_collector.create_metric_handler_item, + load_service_for_data_plane, + handlers.preprocess_request, + handlers.enforce_cors, + handlers.content_decoder, # depends on preprocess_request for the S3 service + handlers.validate_request_schema, # validate request schema for public LS endpoints + handlers.serve_localstack_resources, # try to serve endpoints in /_localstack + handlers.serve_edge_router_rules, + # start aws handler chain + handlers.parse_service_name, + handlers.parse_pre_signed_url_request, + handlers.inject_auth_header_if_missing, + handlers.add_region_from_header, + handlers.rewrite_region, + handlers.add_account_id, + handlers.parse_trace_context, + handlers.parse_service_request, + metric_collector.record_parsed_request, + handlers.serve_custom_service_request_handlers, + load_service, # once we have the service request we can make sure we load the service + self.service_request_router, # once we know the service is loaded we can route the request + # if the chain is still running, set an empty response + EmptyResponseHandler(404, b'{"message": "Not Found"}'), + ] + ) + + # exception handlers in the chain + self.exception_handlers.extend( + [ + handlers.log_exception, + handlers.serve_custom_exception_handlers, + handlers.handle_service_exception, + handlers.handle_internal_failure, + ] + ) + + # response post-processing + self.response_handlers.extend( + [ + handlers.validate_response_schema, # validate response schema for public LS endpoints + handlers.modify_service_response, + handlers.parse_service_response, + handlers.run_custom_response_handlers, + handlers.add_cors_response_headers, + handlers.log_response, + handlers.count_service_request, + metric_collector.update_metric_collection, + ] + ) + + # request chain finalization + self.finalizers.extend( + [ + handlers.set_close_connection_header, + handlers.run_custom_finalizers, + ] + ) + + def new_chain(self) -> HandlerChain: + if config.DEBUG_HANDLER_CHAIN: + return TracingHandlerChain( + self.request_handlers, + self.response_handlers, + self.finalizers, + self.exception_handlers, + ) + return super().new_chain() + + +def main(): + """ + Serve the LocalstackGateway with the default configuration directly through hypercorn. This is mostly for + development purposes and documentation on how to serve the Gateway. + """ + from .serving.hypercorn import serve + + use_ssl = True + port = 4566 + + # serve the LocalStackAwsGateway in a dev app + from localstack.utils.bootstrap import setup_logging + + setup_logging() + + if use_ssl: + install_predefined_cert_if_available() + _, cert_file_name, key_file_name = create_ssl_cert(serial_number=port) + ssl_creds = (cert_file_name, key_file_name) + else: + ssl_creds = None + + gw = LocalstackAwsGateway(SERVICE_PLUGINS) + + serve(gw, use_reloader=True, port=port, ssl_creds=ssl_creds) + + +if __name__ == "__main__": + main() diff --git a/localstack-core/localstack/aws/chain.py b/localstack-core/localstack/aws/chain.py new file mode 100644 index 0000000000000..6702d154cefaf --- /dev/null +++ b/localstack-core/localstack/aws/chain.py @@ -0,0 +1,42 @@ +""" +The core concepts of the HandlerChain. +""" + +from __future__ import annotations + +import logging +from typing import Callable, Type + +from rolo.gateway import ( + CompositeExceptionHandler, + CompositeFinalizer, + CompositeHandler, + CompositeResponseHandler, +) +from rolo.gateway import HandlerChain as RoloHandlerChain +from werkzeug import Response + +from .api import RequestContext + +LOG = logging.getLogger(__name__) + +Handler = Callable[["HandlerChain", RequestContext, Response], None] +"""The signature of request or response handler in the handler chain. Receives the HandlerChain, the +RequestContext, and the Response object to be populated.""" + +ExceptionHandler = Callable[["HandlerChain", Exception, RequestContext, Response], None] +"""The signature of an exception handler in the handler chain. Receives the HandlerChain, the exception that +was raised by the request handler, the RequestContext, and the Response object to be populated.""" + + +HandlerChain: Type[RoloHandlerChain[RequestContext]] = RoloHandlerChain + +__all__ = [ + "HandlerChain", + "Handler", + "ExceptionHandler", + "CompositeHandler", + "CompositeResponseHandler", + "CompositeExceptionHandler", + "CompositeFinalizer", +] diff --git a/localstack-core/localstack/aws/client.py b/localstack-core/localstack/aws/client.py new file mode 100644 index 0000000000000..6d938c086a8cf --- /dev/null +++ b/localstack-core/localstack/aws/client.py @@ -0,0 +1,430 @@ +"""Utils to process AWS requests as a client.""" + +import io +import logging +from datetime import datetime, timezone +from typing import Dict, Iterable, Optional +from urllib.parse import urlsplit + +from botocore import awsrequest +from botocore.endpoint import Endpoint +from botocore.model import OperationModel +from botocore.parsers import ResponseParser, ResponseParserFactory +from werkzeug.datastructures import Headers + +from localstack import config +from localstack.http import Request, Response +from localstack.runtime import hooks +from localstack.utils.patch import Patch, patch +from localstack.utils.strings import to_str + +from .api import CommonServiceException, RequestContext, ServiceException, ServiceResponse +from .connect import get_service_endpoint +from .gateway import Gateway + +LOG = logging.getLogger(__name__) + + +def create_http_request(aws_request: awsrequest.AWSPreparedRequest) -> Request: + """ + Create an ASF HTTP Request from a botocore AWSPreparedRequest. + + :param aws_request: the botocore prepared request + :return: a new Request + """ + split_url = urlsplit(aws_request.url) + host = split_url.netloc.split(":") + if len(host) == 1: + server = (to_str(host[0]), None) + elif len(host) == 2: + server = (to_str(host[0]), int(host[1])) + else: + raise ValueError + + # prepare the RequestContext + headers = Headers() + for k, v in aws_request.headers.items(): + headers[k] = to_str(v) + + return Request( + method=aws_request.method, + path=split_url.path, + query_string=split_url.query, + headers=headers, + body=aws_request.body, + server=server, + ) + + +class _ResponseStream(io.RawIOBase): + """ + Wraps a Response and makes it available as a readable IO stream. If the response stream is used as an iterable, it + will use the underlying response object directly. + + Adapted from https://stackoverflow.com/a/20260030/804840 + """ + + def __init__(self, response: Response): + self.response = response + self.iterator = response.iter_encoded() + self._buf = None + + def stream(self) -> Iterable[bytes]: + # adds compatibility for botocore's client-side AWSResponse.raw attribute. + return self.iterator + + def readable(self): + return True + + def readinto(self, buffer): + try: + upto = len(buffer) # We're supposed to return at most this much + chunk = self._buf or next(self.iterator) + # FIXME: this is very slow as it copies the entire chunk + output, self._buf = chunk[:upto], chunk[upto:] + buffer[: len(output)] = output + return len(output) + except StopIteration: + return 0 # indicate EOF + + def read(self, amt=None) -> bytes | None: + # see https://github.com/python/cpython/blob/main/Lib/_pyio.py + # adds compatibility for botocore's client-side AWSResponse.raw attribute. + # it seems the default implementation of RawIOBase.read to not handle well some cases + if amt is None: + amt = -1 + return super().read(amt) + + def close(self) -> None: + return self.response.close() + + def __iter__(self): + return self.iterator + + def __next__(self): + return next(self.iterator) + + def __str__(self): + length = self.response.content_length + if length is None: + length = "unknown" + + return f"StreamedBytes({length})" + + def __repr__(self): + return self.__str__() + + +class _RawStream: + """This is a compatibility adapter for the raw_stream attribute passed to botocore's EventStream.""" + + def __init__(self, response: Response): + self.response = response + self.iterator = response.iter_encoded() + + def stream(self) -> Iterable[bytes]: + return self.iterator + + def close(self): + pass + + +def _add_modeled_error_fields( + response_dict: Dict, + parsed_response: Dict, + operation_model: OperationModel, + parser: ResponseParser, +): + """ + This function adds additional error shape members (other than message, code, and type) to an already parsed error + response dict. + Port of botocore's Endpoint#_add_modeled_error_fields. + """ + error_code = parsed_response.get("Error", {}).get("Code") + if error_code is None: + return + service_model = operation_model.service_model + error_shape = service_model.shape_for_error_code(error_code) + if error_shape is None: + return + modeled_parse = parser.parse(response_dict, error_shape) + parsed_response.update(modeled_parse) + + +def _cbor_timestamp_parser(value): + return datetime.fromtimestamp(value / 1000) + + +def _cbor_blob_parser(value): + return bytes(value) + + +@hooks.on_infra_start() +def _patch_botocore_json_parser(): + from botocore.parsers import BaseJSONParser + + @patch(BaseJSONParser._parse_body_as_json) + def _parse_body_as_json(fn, self, body_contents): + """ + botocore does not support CBOR encoded response parsing. Since we use the botocore parsers + to parse responses from external backends (like kinesis-mock), we need to patch botocore to + try CBOR decoding in case the JSON decoding fails. + """ + try: + return fn(self, body_contents) + except UnicodeDecodeError as json_exception: + # cbor2: explicitly load from private _decoder module to avoid using the (non-patched) C-version + from cbor2._decoder import loads + + try: + LOG.debug("botocore failed decoding JSON. Trying to decode as CBOR.") + return loads(body_contents) + except Exception as cbor_exception: + LOG.debug("CBOR fallback decoding failed.") + raise cbor_exception from json_exception + + +@hooks.on_infra_start() +def _patch_cbor2(): + """ + Patch fixing the AWS CBOR en-/decoding of datetime fields. + + Unfortunately, Kinesis (the only known service using CBOR) does not use the number of seconds (with floating-point + milliseconds - according to RFC8949), but uses milliseconds. + Python cbor2 is highly optimized by using a C-implementation by default, which cannot be patched. + Instead of `from cbor2 import loads`, directly import the python-native loads implementation to avoid loading the + unpatched C implementation: + ``` + from cbor2._decoder import loads + from cbor2._decoder import dumps + ``` + + See https://github.com/aws/aws-sdk-java-v2/issues/4661 + """ + from cbor2._decoder import CBORDecodeValueError, semantic_decoders + from cbor2._encoder import CBOREncodeValueError, default_encoders + from cbor2._types import CBORTag + + def _patched_decode_epoch_datetime(self) -> datetime: + """ + Replaces `cbor2._decoder.CBORDecoder.decode_epoch_datetime` as default datetime semantic_decoder. + """ + # Semantic tag 1 + value = self._decode() + + try: + # The next line is the only change in this patch compared to the original function. + # AWS breaks the CBOR spec by using the millis (instead of seconds with floating point support for millis) + # https://github.com/aws/aws-sdk-java-v2/issues/4661 + value = value / 1000 + tmp = datetime.fromtimestamp(value, timezone.utc) + except (OverflowError, OSError, ValueError) as exc: + raise CBORDecodeValueError("error decoding datetime from epoch") from exc + + return self.set_shareable(tmp) + + def _patched_encode_datetime(self, value: datetime) -> None: + """ + Replaces `cbor2._encoder.CBOREncoder.encode_datetime` as default datetime default_encoder. + """ + if not value.tzinfo: + if self._timezone: + value = value.replace(tzinfo=self._timezone) + else: + raise CBOREncodeValueError( + f"naive datetime {value!r} encountered and no default timezone has been set" + ) + + if self.datetime_as_timestamp: + from calendar import timegm + + if not value.microsecond: + timestamp: float = timegm(value.utctimetuple()) + else: + timestamp = timegm(value.utctimetuple()) + value.microsecond / 1000000 + # The next line is the only change in this patch compared to the original function. + # - AWS breaks the CBOR spec by using the millis (instead of seconds with floating point support for millis) + # https://github.com/aws/aws-sdk-java-v2/issues/4661 + # - AWS SDKs in addition have very tight assumptions on the type. + # This needs to be an integer, and must not be a floating point number (CBOR is typed)! + timestamp = int(timestamp * 1000) + self.encode_semantic(CBORTag(1, timestamp)) + else: + datestring = value.isoformat().replace("+00:00", "Z") + self.encode_semantic(CBORTag(0, datestring)) + + # overwrite the default epoch datetime en-/decoder with patched versions + default_encoders[datetime] = _patched_encode_datetime + semantic_decoders[1] = _patched_decode_epoch_datetime + + +def _create_and_enrich_aws_request( + fn, self: Endpoint, params: dict, operation_model: OperationModel = None +): + """ + Patch that adds the botocore operation model and request parameters to a newly created AWSPreparedRequest, + which normally only holds low-level HTTP request information. + """ + request: awsrequest.AWSPreparedRequest = fn(self, params, operation_model) + + request.params = params + request.operation_model = operation_model + + return request + + +botocore_in_memory_endpoint_patch = Patch.function( + Endpoint.create_request, _create_and_enrich_aws_request +) + + +@hooks.on_infra_start(should_load=config.IN_MEMORY_CLIENT) +def _patch_botocore_endpoint_in_memory(): + botocore_in_memory_endpoint_patch.apply() + + +def parse_response( + operation: OperationModel, response: Response, include_response_metadata: bool = True +) -> ServiceResponse: + """ + Parses an HTTP Response object into an AWS response object using botocore. It does this by adapting the + procedure of ``botocore.endpoint.convert_to_response_dict`` to work with Werkzeug's server-side response object. + + :param operation: the operation of the original request + :param response: the HTTP response object containing the response of the operation + :param include_response_metadata: True if the ResponseMetadata (typical for boto response dicts) should be included + :return: a parsed dictionary as it is returned by botocore + """ + # this is what botocore.endpoint.convert_to_response_dict normally does + response_dict = { + "headers": dict(response.headers.items()), # boto doesn't like werkzeug headers + "status_code": response.status_code, + "context": { + "operation_name": operation.name, + }, + } + + if response_dict["status_code"] >= 301: + response_dict["body"] = response.data + elif operation.has_event_stream_output: + # TODO test this + response_dict["body"] = _RawStream(response) + elif operation.has_streaming_output: + # for s3.GetObject for example, the Body attribute is actually a stream, not the raw bytes value + response_dict["body"] = _ResponseStream(response) + else: + response_dict["body"] = response.data + + factory = ResponseParserFactory() + if response.content_type and response.content_type.startswith("application/x-amz-cbor"): + # botocore cannot handle CBOR encoded responses (because it never sends them), we need to modify the parser + factory.set_parser_defaults( + timestamp_parser=_cbor_timestamp_parser, blob_parser=_cbor_blob_parser + ) + + parser = factory.create_parser(operation.service_model.protocol) + parsed_response = parser.parse(response_dict, operation.output_shape) + + if response.status_code >= 301: + # Add possible additional error shape members + _add_modeled_error_fields(response_dict, parsed_response, operation, parser) + + if not include_response_metadata: + parsed_response.pop("ResponseMetadata", None) + + return parsed_response + + +def parse_service_exception( + response: Response, parsed_response: Dict +) -> Optional[ServiceException]: + """ + Creates a ServiceException (one ASF can handle) from a parsed response (one that botocore would return). + It does not automatically raise the exception (see #raise_service_exception). + :param response: Un-parsed response + :param parsed_response: Parsed response + :return: ServiceException or None (if it's not an error response) + """ + if response.status_code < 301 or "Error" not in parsed_response: + return None + error = parsed_response["Error"] + service_exception = CommonServiceException( + code=error.get("Code", f"'{response.status_code}'"), + status_code=response.status_code, + message=error.get("Message", ""), + sender_fault=error.get("Type") == "Sender", + ) + # Add all additional fields in the parsed response as members of the exception + for key, value in parsed_response.items(): + if key.lower() not in ["code", "message", "type", "error"] and not hasattr( + service_exception, key + ): + setattr(service_exception, key, value) + return service_exception + + +def raise_service_exception(response: Response, parsed_response: Dict) -> None: + """ + Creates and raises a ServiceException from a parsed response (one that botocore would return). + :param response: Un-parsed response + :param parsed_response: Parsed response + :raise ServiceException: If the response is an error response + :return: None if the response is not an error response + """ + if service_exception := parse_service_exception(response, parsed_response): + raise service_exception + + +class GatewayShortCircuit: + gateway: Gateway + + def __init__(self, gateway: Gateway): + self.gateway = gateway + self._internal_url = get_service_endpoint() + + def __call__( + self, event_name: str, request: awsrequest.AWSPreparedRequest, **kwargs + ) -> awsrequest.AWSResponse | None: + # TODO: we sometimes overrides the endpoint_url to direct it to DynamoDBLocal directly + # if the default endpoint_url is not in the request, just skips the in-memory forwarding + if self._internal_url not in request.url: + return + + # extract extra data from enriched AWSPreparedRequest + params = request.params + operation: OperationModel = request.operation_model + + # create request + context = RequestContext(request=create_http_request(request)) + + # TODO: just a hacky thing to unblock the service model being set to `sqs-query` blocking for now + # this is using the same services as `localstack.aws.protocol.service_router.resolve_conflicts`, maybe + # consolidate. `docdb` and `neptune` uses the RDS API and service. + if operation.service_model.service_name not in { + "sqs-query", + "docdb", + "neptune", + "timestream-write", + }: + context.service = operation.service_model + + context.operation = operation + context.service_request = params["body"] + + # perform request + response = Response() + self.gateway.handle(context, response) + + # transform Werkzeug response to client-side botocore response + aws_response = awsrequest.AWSResponse( + url=context.request.url, + status_code=response.status_code, + headers=response.headers, + raw=_ResponseStream(response), + ) + + return aws_response + + @staticmethod + def modify_client(client, gateway): + client.meta.events.register_first("before-send.*.*", GatewayShortCircuit(gateway)) diff --git a/localstack-core/localstack/aws/components.py b/localstack-core/localstack/aws/components.py new file mode 100644 index 0000000000000..82b203741de60 --- /dev/null +++ b/localstack-core/localstack/aws/components.py @@ -0,0 +1,22 @@ +from functools import cached_property + +from rolo.gateway import Gateway + +from localstack.aws.app import LocalstackAwsGateway +from localstack.runtime.components import BaseComponents + + +class AwsComponents(BaseComponents): + """ + Runtime components specific to the AWS emulator. + """ + + name = "aws" + + @cached_property + def gateway(self) -> Gateway: + # FIXME: the ServiceManager should be reworked to be more generic, and then become part of the + # components + from localstack.services.plugins import SERVICE_PLUGINS + + return LocalstackAwsGateway(SERVICE_PLUGINS) diff --git a/localstack-core/localstack/aws/connect.py b/localstack-core/localstack/aws/connect.py new file mode 100644 index 0000000000000..6a04285e021a2 --- /dev/null +++ b/localstack-core/localstack/aws/connect.py @@ -0,0 +1,782 @@ +""" +LocalStack client stack. + +This module provides the interface to perform cross-service communication between +LocalStack providers. +""" + +import json +import logging +import re +import threading +from abc import ABC, abstractmethod +from functools import lru_cache, partial +from random import choice +from socket import socket +from typing import Any, Callable, Generic, Optional, TypedDict, TypeVar + +import dns.message +import dns.query +from boto3.session import Session +from botocore.awsrequest import ( + AWSHTTPConnection, + AWSHTTPConnectionPool, + AWSHTTPSConnection, + AWSHTTPSConnectionPool, +) +from botocore.client import BaseClient +from botocore.config import Config +from botocore.httpsession import URLLib3Session +from botocore.waiter import Waiter + +from localstack import config as localstack_config +from localstack.aws.spec import LOCALSTACK_BUILTIN_DATA_PATH +from localstack.constants import ( + AWS_REGION_US_EAST_1, + INTERNAL_AWS_ACCESS_KEY_ID, + INTERNAL_AWS_SECRET_ACCESS_KEY, + MAX_POOL_CONNECTIONS, +) +from localstack.utils.aws.aws_stack import get_s3_hostname +from localstack.utils.aws.client_types import ServicePrincipal, TypedServiceClientFactory +from localstack.utils.patch import patch +from localstack.utils.strings import short_uid + +LOG = logging.getLogger(__name__) + + +@patch(target=Waiter.wait, pass_target=True) +def my_patch(fn, self, **kwargs): + """ + We're patching defaults in here that will override the defaults specified in the waiter spec since these are usually way too long + + Alternatively we could also try to find a solution where we patch the loader used in the generated clients + so that we can dynamically fix the waiter config when it's loaded instead of when it's being used for wait execution + """ + + if localstack_config.DISABLE_CUSTOM_BOTO_WAITER_CONFIG: + return fn(self, **kwargs) + else: + patched_kwargs = { + **kwargs, + "WaiterConfig": { + "Delay": localstack_config.BOTO_WAITER_DELAY, + "MaxAttempts": localstack_config.BOTO_WAITER_MAX_ATTEMPTS, + **kwargs.get( + "WaiterConfig", {} + ), # we still allow client users to override these defaults + }, + } + return fn(self, **patched_kwargs) + + +# patch the botocore.Config object to be comparable and hashable. +# this solution does not validates the hashable (https://docs.python.org/3/glossary.html#term-hashable) definition on python +# It would do so only when someone accesses the internals of the Config option to change the dict directly. +# Since this is not a proper way to use the config object (but via config.merge), this should be fine +def make_hash(o): + if isinstance(o, (set, tuple, list)): + return tuple([make_hash(e) for e in o]) + + elif not isinstance(o, dict): + return hash(o) + + new_o = {} + for k, v in o.items(): + new_o[k] = make_hash(v) + + return hash(frozenset(sorted(new_o.items()))) + + +def config_equality_patch(self, other: object): + return type(self) == type(other) and self._user_provided_options == other._user_provided_options + + +def config_hash_patch(self): + return make_hash(self._user_provided_options) + + +Config.__eq__ = config_equality_patch +Config.__hash__ = config_hash_patch + + +def attribute_name_to_service_name(attribute_name): + """ + Converts a python-compatible attribute name to the boto service name + :param attribute_name: Python compatible attribute name using the following replacements: + a) Add an underscore suffix `_` to any reserved Python keyword (PEP-8). + b) Replace any dash `-` with an underscore `_` + :return: + """ + if attribute_name.endswith("_"): + # lambda_ -> lambda + attribute_name = attribute_name[:-1] + # replace all _ with -: cognito_idp -> cognito-idp + return attribute_name.replace("_", "-") + + +def get_service_endpoint() -> str | None: + """ + Returns the endpoint the client should target. + + :return: Endpoint url + """ + if localstack_config.DISTRIBUTED_MODE: + return None + return localstack_config.internal_service_url() + + +# +# Data transfer object +# + +INTERNAL_REQUEST_PARAMS_HEADER = "x-localstack-data" +"""Request header which contains the data transfer object.""" + + +class InternalRequestParameters(TypedDict): + """ + LocalStack Data Transfer Object. + + This is sent with every internal request and contains any additional information + LocalStack might need for the purpose of policy enforcement. It is serialised + into text and sent in the request header. + + Attributes can be added as needed. The keys should roughly correspond to: + https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html + """ + + source_arn: str | None + """ARN of resource which is triggering the call""" + + service_principal: str | None + """Service principal making this call""" + + +def dump_dto(data: InternalRequestParameters) -> str: + # To produce a compact JSON representation of DTO, remove spaces from separators + # If possible, we could use a custom encoder to further decrease header size in the future + return json.dumps(data, separators=(",", ":")) + + +def load_dto(data: str) -> InternalRequestParameters: + return json.loads(data) + + +T = TypeVar("T") + + +class MetadataRequestInjector(Generic[T]): + def __init__(self, client: T, params: dict[str, str] | None = None): + self._client = client + self._params = params + + def __getattr__(self, item): + target = getattr(self._client, item) + if not isinstance(target, Callable): + return target + if self._params: + return partial(target, **self._params) + else: + return target + + def request_metadata( + self, source_arn: str | None = None, service_principal: str | None = None + ) -> T: + """ + Returns a new client instance preset with the given request metadata. + Identical to providing _ServicePrincipal and _SourceArn directly as operation arguments but typing + compatible. + + Raw example: lambda_client.invoke(FunctionName="fn", _SourceArn="...") + Injector example: lambda_client.request_metadata(source_arn="...").invoke(FunctionName="fn") + Cannot be called on objects where the parameters are already set. + + :param source_arn: Arn on which behalf the calls of this client shall be made + :param service_principal: Service principal on which behalf the calls of this client shall be made + :return: A new version of the MetadataRequestInjector + """ + if self._params is not None: + raise TypeError("Request_data cannot be called on it's own return value") + params = {} + if source_arn: + params["_SourceArn"] = source_arn + if service_principal: + params["_ServicePrincipal"] = service_principal + return MetadataRequestInjector(client=self._client, params=params) + + +# +# Factory +# +class ServiceLevelClientFactory(TypedServiceClientFactory): + """ + A service level client factory, preseeded with parameters for the boto3 client creation. + Will create any service client with parameters already provided by the ClientFactory. + """ + + def __init__( + self, + *, + factory: "ClientFactory", + client_creation_params: dict[str, str | Config | None], + request_wrapper_clazz: type, + ): + self._factory = factory + self._client_creation_params = client_creation_params + self._request_wrapper_clazz = request_wrapper_clazz + + def get_client(self, service: str): + return self._request_wrapper_clazz( + client=self._factory.get_client(service_name=service, **self._client_creation_params) + ) + + def __getattr__(self, service: str): + service = attribute_name_to_service_name(service) + return self._request_wrapper_clazz( + client=self._factory.get_client(service_name=service, **self._client_creation_params) + ) + + +class ClientFactory(ABC): + """ + Factory to build the AWS client. + + Boto client creation is resource intensive. This class caches all Boto + clients it creates and must be used instead of directly using boto lib. + """ + + def __init__( + self, + use_ssl: bool = False, + verify: bool = False, + session: Session = None, + config: Config = None, + ): + """ + :param use_ssl: Whether to use SSL + :param verify: Whether to verify SSL certificates + :param session: Session to be used for client creation. Will create a new session if not provided. + Please note that sessions are not generally thread safe. + Either create a new session for each factory or make sure the session is not shared with another thread. + The factory itself has a lock for the session, so as long as you only use the session in one factory, + it should be fine using the factory in a multithreaded context. + :param config: Config used as default for client creation. + """ + self._use_ssl = use_ssl + self._verify = verify + self._config: Config = config or Config(max_pool_connections=MAX_POOL_CONNECTIONS) + self._session: Session = session or Session() + + # make sure we consider our custom data paths for legacy specs (like SQS query protocol) + if LOCALSTACK_BUILTIN_DATA_PATH not in self._session._loader.search_paths: + self._session._loader.search_paths.insert(0, LOCALSTACK_BUILTIN_DATA_PATH) + + self._create_client_lock = threading.RLock() + + def __call__( + self, + *, + region_name: Optional[str] = None, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + endpoint_url: str = None, + config: Config = None, + ) -> ServiceLevelClientFactory: + """ + Get back an object which lets you select the typed service you want to access with the given attributes + + :param region_name: Name of the AWS region to be associated with the client + If set to None, loads from botocore session. + :param aws_access_key_id: Access key to use for the client. + If set to None, loads from botocore session. + :param aws_secret_access_key: Secret key to use for the client. + If set to None, loads from botocore session. + :param aws_session_token: Session token to use for the client. + Not being used if not set. + :param endpoint_url: Full endpoint URL to be used by the client. + Defaults to appropriate LocalStack endpoint. + :param config: Boto config for advanced use. + :return: Service Region Client Creator + """ + params = { + "region_name": region_name, + "aws_access_key_id": aws_access_key_id, + "aws_secret_access_key": aws_secret_access_key, + "aws_session_token": aws_session_token, + "endpoint_url": endpoint_url, + "config": config, + } + return ServiceLevelClientFactory( + factory=self, + client_creation_params=params, + request_wrapper_clazz=MetadataRequestInjector, + ) + + def with_assumed_role( + self, + *, + role_arn: str, + service_principal: Optional[ServicePrincipal] = None, + session_name: Optional[str] = None, + region_name: Optional[str] = None, + endpoint_url: Optional[str] = None, + config: Optional[Config] = None, + ) -> ServiceLevelClientFactory: + """ + Create a service level client factory with credentials from assuming the given role ARN. + The service_principal will only be used for the assume_role call, for all succeeding calls it has to be provided + separately, either as call attribute or using request_metadata() + + :param role_arn: Role to assume + :param service_principal: Service the role should be assumed as, must not be set for test clients + :param session_name: Session name for the role session + :param region_name: Region for the returned client + :param endpoint_url: Endpoint for both the assume_role call and the returned client + :param config: Config for both the assume_role call and the returned client + :return: Service Level Client Factory + """ + session_name = session_name or f"session-{short_uid()}" + sts_client = self(endpoint_url=endpoint_url, config=config, region_name=region_name).sts + + metadata = {} + if service_principal: + metadata["service_principal"] = service_principal + + sts_client = sts_client.request_metadata(**metadata) + credentials = sts_client.assume_role(RoleArn=role_arn, RoleSessionName=session_name)[ + "Credentials" + ] + + return self( + region_name=region_name, + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + endpoint_url=endpoint_url, + config=config, + ) + + @abstractmethod + def get_client( + self, + service_name: str, + region_name: Optional[str] = None, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + endpoint_url: Optional[str] = None, + config: Optional[Config] = None, + ): + raise NotImplementedError() + + def _get_client_post_hook(self, client: BaseClient) -> BaseClient: + """ + This is called after the client is created by Boto. + + Any modifications to the client can be implemented here in subclasses + without affecting the caching mechanism. + """ + return client + + # TODO @lru_cache here might result in a memory leak, as it keeps a reference to `self` + # We might need an alternative caching decorator with a weak ref to `self` + # Otherwise factories might never be garbage collected + @lru_cache(maxsize=256) + def _get_client( + self, + service_name: str, + region_name: str, + use_ssl: bool, + verify: Optional[bool], + endpoint_url: Optional[str], + aws_access_key_id: Optional[str], + aws_secret_access_key: Optional[str], + aws_session_token: Optional[str], + config: Config, + ) -> BaseClient: + """ + Returns a boto3 client with the given configuration, and the hooks added by `_get_client_post_hook`. + This is a cached call, so modifications to the used client will affect others. + Please use another instance of the factory, should you want to modify clients. + Client creation is behind a lock as it is not generally thread safe. + + :param service_name: Service to build the client for, eg. `s3` + :param region_name: Name of the AWS region to be associated with the client + If set to None, loads from botocore session. + :param aws_access_key_id: Access key to use for the client. + If set to None, loads from botocore session. + :param aws_secret_access_key: Secret key to use for the client. + If set to None, loads from botocore session. + :param aws_session_token: Session token to use for the client. + Not being used if not set. + :param endpoint_url: Full endpoint URL to be used by the client. + Defaults to appropriate LocalStack endpoint. + :param config: Boto config for advanced use. + :return: Boto3 client. + """ + with self._create_client_lock: + default_config = ( + Config(retries={"max_attempts": 0}) + if localstack_config.DISABLE_BOTO_RETRIES + else Config() + ) + + client = self._session.client( + service_name=service_name, + region_name=region_name, + use_ssl=use_ssl, + verify=verify, + endpoint_url=endpoint_url, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + config=config.merge(default_config), + ) + + return self._get_client_post_hook(client) + + # + # Boto session utilities + # + def _get_session_region(self) -> str: + """ + Return AWS region as set in the Boto session. + """ + return self._session.region_name + + def _get_region(self) -> str: + """ + Return the AWS region name from following sources, in order of availability. + - LocalStack request context + - Boto session + - us-east-1 + """ + return self._get_session_region() or AWS_REGION_US_EAST_1 + + +class InternalClientFactory(ClientFactory): + def _get_client_post_hook(self, client: BaseClient) -> BaseClient: + """ + Register handlers that enable internal data object transfer mechanism + for internal clients. + """ + client.meta.events.register( + "provide-client-params.*.*", handler=_handler_create_request_parameters + ) + + client.meta.events.register("before-call.*.*", handler=_handler_inject_dto_header) + + if localstack_config.IN_MEMORY_CLIENT: + # this make the client call the gateway directly + from localstack.aws.client import GatewayShortCircuit + from localstack.runtime import get_current_runtime + + GatewayShortCircuit.modify_client(client, get_current_runtime().components.gateway) + + return client + + def get_client( + self, + service_name: str, + region_name: Optional[str] = None, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + endpoint_url: Optional[str] = None, + config: Optional[Config] = None, + ) -> BaseClient: + """ + Build and return client for connections originating within LocalStack. + + All API operation methods (such as `.list_buckets()` or `.run_instances()` + take additional args that start with `_` prefix. These are used to pass + additional information to LocalStack server during internal calls. + + :param service_name: Service to build the client for, eg. `s3` + :param region_name: Region name. See note above. + If set to None, loads from botocore session. + :param aws_access_key_id: Access key to use for the client. + Defaults to LocalStack internal credentials. + :param aws_secret_access_key: Secret key to use for the client. + Defaults to LocalStack internal credentials. + :param aws_session_token: Session token to use for the client. + Not being used if not set. + :param endpoint_url: Full endpoint URL to be used by the client. + Defaults to appropriate LocalStack endpoint. + :param config: Boto config for advanced use. + """ + + if config is None: + config = self._config + else: + config = self._config.merge(config) + + endpoint_url = endpoint_url or get_service_endpoint() + if service_name == "s3" and endpoint_url: + if re.match(r"https?://localhost(:[0-9]+)?", endpoint_url): + endpoint_url = endpoint_url.replace("://localhost", f"://{get_s3_hostname()}") + + return self._get_client( + service_name=service_name, + region_name=region_name or self._get_region(), + use_ssl=self._use_ssl, + verify=self._verify, + endpoint_url=endpoint_url, + aws_access_key_id=aws_access_key_id or INTERNAL_AWS_ACCESS_KEY_ID, + aws_secret_access_key=aws_secret_access_key or INTERNAL_AWS_SECRET_ACCESS_KEY, + aws_session_token=aws_session_token, + config=config, + ) + + +class ExternalClientFactory(ClientFactory): + def get_client( + self, + service_name: str, + region_name: Optional[str] = None, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + endpoint_url: Optional[str] = None, + config: Optional[Config] = None, + ) -> BaseClient: + """ + Build and return client for connections originating outside LocalStack and targeting Localstack. + + If the region is set to None, it is loaded from following + locations: + - AWS environment variables + - Credentials file `~/.aws/credentials` + - Config file `~/.aws/config` + + :param service_name: Service to build the client for, eg. `s3` + :param region_name: Name of the AWS region to be associated with the client + If set to None, loads from botocore session. + :param aws_access_key_id: Access key to use for the client. + If set to None, loads from botocore session. + :param aws_secret_access_key: Secret key to use for the client. + If set to None, uses a placeholder value + :param aws_session_token: Session token to use for the client. + Not being used if not set. + :param endpoint_url: Full endpoint URL to be used by the client. + Defaults to appropriate LocalStack endpoint. + :param config: Boto config for advanced use. + """ + if config is None: + config = self._config + else: + config = self._config.merge(config) + + # Boto has an odd behaviour when using a non-default (any other region than us-east-1) in config + # If the region in arg is non-default, it gives the arg the precedence + # But if the region in arg is default (us-east-1), it gives precedence to one in config + # Below: always give precedence to arg region + if config and config.region_name != AWS_REGION_US_EAST_1: + if region_name == AWS_REGION_US_EAST_1: + config = config.merge(Config(region_name=region_name)) + + endpoint_url = endpoint_url or get_service_endpoint() + if service_name == "s3": + if re.match(r"https?://localhost(:[0-9]+)?", endpoint_url): + endpoint_url = endpoint_url.replace("://localhost", f"://{get_s3_hostname()}") + + # Prevent `PartialCredentialsError` when only access key ID is provided + # The value of secret access key is insignificant and can be set to anything + if aws_access_key_id: + aws_secret_access_key = aws_secret_access_key or INTERNAL_AWS_SECRET_ACCESS_KEY + + return self._get_client( + service_name=service_name, + region_name=region_name or config.region_name or self._get_region(), + use_ssl=self._use_ssl, + verify=self._verify, + endpoint_url=endpoint_url, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + config=config, + ) + + +class ExternalAwsClientFactory(ClientFactory): + def get_client( + self, + service_name: str, + region_name: Optional[str] = None, + aws_access_key_id: Optional[str] = None, + aws_secret_access_key: Optional[str] = None, + aws_session_token: Optional[str] = None, + endpoint_url: Optional[str] = None, + config: Optional[Config] = None, + ) -> BaseClient: + """ + Build and return client for connections originating outside LocalStack and targeting AWS. + + If either of the access keys or region are set to None, they are loaded from following + locations: + - AWS environment variables + - Credentials file `~/.aws/credentials` + - Config file `~/.aws/config` + + :param service_name: Service to build the client for, eg. `s3` + :param region_name: Name of the AWS region to be associated with the client + If set to None, loads from botocore session. + :param aws_access_key_id: Access key to use for the client. + If set to None, loads from botocore session. + :param aws_secret_access_key: Secret key to use for the client. + If set to None, loads from botocore session. + :param aws_session_token: Session token to use for the client. + Not being used if not set. + :param endpoint_url: Full endpoint URL to be used by the client. + Defaults to appropriate AWS endpoint. + :param config: Boto config for advanced use. + """ + if config is None: + config = self._config + else: + config = self._config.merge(config) + + return self._get_client( + config=config, + service_name=service_name, + region_name=region_name or self._get_session_region(), + endpoint_url=endpoint_url, + use_ssl=True, + verify=True, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + ) + + +def resolve_dns_from_upstream(hostname: str) -> str: + from localstack.dns.server import get_fallback_dns_server + + upstream_dns = get_fallback_dns_server() + request = dns.message.make_query(hostname, "A") + response = dns.query.udp(request, upstream_dns, port=53, timeout=5) + if len(response.answer) == 0: + raise ValueError(f"No DNS response found for hostname '{hostname}'") + + ip_addresses = [] + for answer in response.answer: + if answer.match(dns.rdataclass.IN, dns.rdatatype.A, dns.rdatatype.NONE): + ip_addresses.extend(answer.items.keys()) + + if not ip_addresses: + raise ValueError(f"No DNS records of type 'A' found for hostname '{hostname}'") + + return choice(ip_addresses).address + + +class ExternalBypassDnsClientFactory(ExternalAwsClientFactory): + """ + Client factory that makes requests against AWS ensuring that DNS resolution is not affected by the LocalStack DNS + server. + """ + + def __init__( + self, + session: Session = None, + config: Config = None, + ): + super().__init__(use_ssl=True, verify=True, session=session, config=config) + + def _get_client_post_hook(self, client: BaseClient) -> BaseClient: + client = super()._get_client_post_hook(client) + client._endpoint.http_session = ExternalBypassDnsSession() + return client + + +class ExternalBypassDnsHTTPConnection(AWSHTTPConnection): + """ + Connection class that bypasses the LocalStack DNS server for HTTP connections + """ + + def _new_conn(self) -> socket: + orig_host = self._dns_host + try: + self._dns_host = resolve_dns_from_upstream(self._dns_host) + return super()._new_conn() + finally: + self._dns_host = orig_host + + +class ExternalBypassDnsHTTPSConnection(AWSHTTPSConnection): + """ + Connection class that bypasses the LocalStack DNS server for HTTPS connections + """ + + def _new_conn(self) -> socket: + orig_host = self._dns_host + try: + self._dns_host = resolve_dns_from_upstream(self._dns_host) + return super()._new_conn() + finally: + self._dns_host = orig_host + + +class ExternalBypassDnsHTTPConnectionPool(AWSHTTPConnectionPool): + ConnectionCls = ExternalBypassDnsHTTPConnection + + +class ExternalBypassDnsHTTPSConnectionPool(AWSHTTPSConnectionPool): + ConnectionCls = ExternalBypassDnsHTTPSConnection + + +class ExternalBypassDnsSession(URLLib3Session): + """ + urllib3 session wrapper that uses our custom connection pool. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._pool_classes_by_scheme["https"] = ExternalBypassDnsHTTPSConnectionPool + self._pool_classes_by_scheme["http"] = ExternalBypassDnsHTTPConnectionPool + + +connect_to = InternalClientFactory(use_ssl=localstack_config.DISTRIBUTED_MODE) +connect_externally_to = ExternalClientFactory() + + +# +# Handlers +# + + +def _handler_create_request_parameters(params: dict[str, Any], context: dict[str, Any], **kwargs): + """ + Construct the data transfer object at the time of parsing the client + parameters and proxy it via the Boto context dict. + + This handler enables the use of additional keyword parameters in Boto API + operation functions. + + It uses the `InternalRequestParameters` type annotations to handle supported parameters. + The keys supported by this type will be converted to method parameters by prefixing it with an underscore `_` + and converting the snake case to camel case. + Example: + service_principal -> _ServicePrincipal + """ + + # Names of arguments that can be passed to Boto API operation functions. + # These must correspond to entries on the data transfer object. + dto = InternalRequestParameters() + for member in InternalRequestParameters.__annotations__.keys(): + parameter = f"_{''.join([part.title() for part in member.split('_')])}" + if parameter in params: + dto[member] = params.pop(parameter) + + context["_localstack"] = dto + + +def _handler_inject_dto_header(params: dict[str, Any], context: dict[str, Any], **kwargs): + """ + Retrieve the data transfer object from the Boto context dict and serialise + it as part of the request headers. + """ + if (dto := context.pop("_localstack", None)) is not None: + params["headers"][INTERNAL_REQUEST_PARAMS_HEADER] = dump_dto(dto) diff --git a/localstack-core/localstack/aws/data/sqs-query/2012-11-05/README.md b/localstack-core/localstack/aws/data/sqs-query/2012-11-05/README.md new file mode 100644 index 0000000000000..7ca0522be5242 --- /dev/null +++ b/localstack-core/localstack/aws/data/sqs-query/2012-11-05/README.md @@ -0,0 +1,10 @@ +This spec preserves the SQS query protocol spec, which was part of botocore until the protocol was switched to json with `botocore==1.31.81`. +This switch removed a lot of spec data which is necessary for the proper parsing and serialization, which is why we have to preserve them on our own. + +- The spec content was preserved from this state: https://github.com/boto/botocore/blob/79c92132e266b15f62bc743ae0816c27d598c36e/botocore/data/sqs/2012-11-05/service-2.json +- This was the last commit before the protocol switched back (again) to json (with https://github.com/boto/botocore/commit/47a515f6727a7585487d58c069c7c0063c28899e). +- The file is licensed with Apache License 2.0. +- Modifications: + - Removal of documentation strings with the following regex: `(,)?\n\s+"documentation":".*"` + - Added `MessageSystemAttributeNames` to `ReceiveMessageRequest.members` with AWS deprecating `AttributeNames`. + The patches in `spec-patches.json` are not present in the boto client for our sqs-query tests right now because the custom loading is not fully integrated at the moment, so it is changed directly in the spec. diff --git a/localstack-core/localstack/aws/data/sqs-query/2012-11-05/service-2.json b/localstack-core/localstack/aws/data/sqs-query/2012-11-05/service-2.json new file mode 100644 index 0000000000000..37168390cc218 --- /dev/null +++ b/localstack-core/localstack/aws/data/sqs-query/2012-11-05/service-2.json @@ -0,0 +1,1505 @@ +{ + "version":"2.0", + "metadata":{ + "apiVersion":"2012-11-05", + "endpointPrefix":"sqs", + "protocol":"query", + "serviceAbbreviation":"Amazon SQS", + "serviceFullName":"Amazon Simple Queue Service", + "serviceId":"SQS", + "signatureVersion":"v4", + "uid":"sqs-2012-11-05", + "xmlNamespace":"http://queue.amazonaws.com/doc/2012-11-05/" + }, + "operations":{ + "AddPermission":{ + "name":"AddPermission", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"AddPermissionRequest"}, + "errors":[ + {"shape":"OverLimit"} + ] + }, + "CancelMessageMoveTask":{ + "name":"CancelMessageMoveTask", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"CancelMessageMoveTaskRequest"}, + "output":{ + "shape":"CancelMessageMoveTaskResult", + "resultWrapper":"CancelMessageMoveTaskResult" + }, + "errors":[ + {"shape":"ResourceNotFoundException"}, + {"shape":"UnsupportedOperation"} + ] + }, + "ChangeMessageVisibility":{ + "name":"ChangeMessageVisibility", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"ChangeMessageVisibilityRequest"}, + "errors":[ + {"shape":"MessageNotInflight"}, + {"shape":"ReceiptHandleIsInvalid"} + ] + }, + "ChangeMessageVisibilityBatch":{ + "name":"ChangeMessageVisibilityBatch", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"ChangeMessageVisibilityBatchRequest"}, + "output":{ + "shape":"ChangeMessageVisibilityBatchResult", + "resultWrapper":"ChangeMessageVisibilityBatchResult" + }, + "errors":[ + {"shape":"TooManyEntriesInBatchRequest"}, + {"shape":"EmptyBatchRequest"}, + {"shape":"BatchEntryIdsNotDistinct"}, + {"shape":"InvalidBatchEntryId"} + ] + }, + "CreateQueue":{ + "name":"CreateQueue", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"CreateQueueRequest"}, + "output":{ + "shape":"CreateQueueResult", + "resultWrapper":"CreateQueueResult" + }, + "errors":[ + {"shape":"QueueDeletedRecently"}, + {"shape":"QueueNameExists"} + ] + }, + "DeleteMessage":{ + "name":"DeleteMessage", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"DeleteMessageRequest"}, + "errors":[ + {"shape":"InvalidIdFormat"}, + {"shape":"ReceiptHandleIsInvalid"} + ] + }, + "DeleteMessageBatch":{ + "name":"DeleteMessageBatch", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"DeleteMessageBatchRequest"}, + "output":{ + "shape":"DeleteMessageBatchResult", + "resultWrapper":"DeleteMessageBatchResult" + }, + "errors":[ + {"shape":"TooManyEntriesInBatchRequest"}, + {"shape":"EmptyBatchRequest"}, + {"shape":"BatchEntryIdsNotDistinct"}, + {"shape":"InvalidBatchEntryId"} + ] + }, + "DeleteQueue":{ + "name":"DeleteQueue", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"DeleteQueueRequest"} + }, + "GetQueueAttributes":{ + "name":"GetQueueAttributes", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"GetQueueAttributesRequest"}, + "output":{ + "shape":"GetQueueAttributesResult", + "resultWrapper":"GetQueueAttributesResult" + }, + "errors":[ + {"shape":"InvalidAttributeName"} + ] + }, + "GetQueueUrl":{ + "name":"GetQueueUrl", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"GetQueueUrlRequest"}, + "output":{ + "shape":"GetQueueUrlResult", + "resultWrapper":"GetQueueUrlResult" + }, + "errors":[ + {"shape":"QueueDoesNotExist"} + ] + }, + "ListDeadLetterSourceQueues":{ + "name":"ListDeadLetterSourceQueues", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"ListDeadLetterSourceQueuesRequest"}, + "output":{ + "shape":"ListDeadLetterSourceQueuesResult", + "resultWrapper":"ListDeadLetterSourceQueuesResult" + }, + "errors":[ + {"shape":"QueueDoesNotExist"} + ] + }, + "ListMessageMoveTasks":{ + "name":"ListMessageMoveTasks", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"ListMessageMoveTasksRequest"}, + "output":{ + "shape":"ListMessageMoveTasksResult", + "resultWrapper":"ListMessageMoveTasksResult" + }, + "errors":[ + {"shape":"ResourceNotFoundException"}, + {"shape":"UnsupportedOperation"} + ] + }, + "ListQueueTags":{ + "name":"ListQueueTags", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"ListQueueTagsRequest"}, + "output":{ + "shape":"ListQueueTagsResult", + "resultWrapper":"ListQueueTagsResult" + } + }, + "ListQueues":{ + "name":"ListQueues", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"ListQueuesRequest"}, + "output":{ + "shape":"ListQueuesResult", + "resultWrapper":"ListQueuesResult" + } + }, + "PurgeQueue":{ + "name":"PurgeQueue", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"PurgeQueueRequest"}, + "errors":[ + {"shape":"QueueDoesNotExist"}, + {"shape":"PurgeQueueInProgress"} + ] + }, + "ReceiveMessage":{ + "name":"ReceiveMessage", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"ReceiveMessageRequest"}, + "output":{ + "shape":"ReceiveMessageResult", + "resultWrapper":"ReceiveMessageResult" + }, + "errors":[ + {"shape":"OverLimit"} + ] + }, + "RemovePermission":{ + "name":"RemovePermission", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"RemovePermissionRequest"} + }, + "SendMessage":{ + "name":"SendMessage", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"SendMessageRequest"}, + "output":{ + "shape":"SendMessageResult", + "resultWrapper":"SendMessageResult" + }, + "errors":[ + {"shape":"InvalidMessageContents"}, + {"shape":"UnsupportedOperation"} + ] + }, + "SendMessageBatch":{ + "name":"SendMessageBatch", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"SendMessageBatchRequest"}, + "output":{ + "shape":"SendMessageBatchResult", + "resultWrapper":"SendMessageBatchResult" + }, + "errors":[ + {"shape":"TooManyEntriesInBatchRequest"}, + {"shape":"EmptyBatchRequest"}, + {"shape":"BatchEntryIdsNotDistinct"}, + {"shape":"BatchRequestTooLong"}, + {"shape":"InvalidBatchEntryId"}, + {"shape":"UnsupportedOperation"} + ] + }, + "SetQueueAttributes":{ + "name":"SetQueueAttributes", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"SetQueueAttributesRequest"}, + "errors":[ + {"shape":"InvalidAttributeName"} + ] + }, + "StartMessageMoveTask":{ + "name":"StartMessageMoveTask", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"StartMessageMoveTaskRequest"}, + "output":{ + "shape":"StartMessageMoveTaskResult", + "resultWrapper":"StartMessageMoveTaskResult" + }, + "errors":[ + {"shape":"ResourceNotFoundException"}, + {"shape":"UnsupportedOperation"} + ] + }, + "TagQueue":{ + "name":"TagQueue", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"TagQueueRequest"} + }, + "UntagQueue":{ + "name":"UntagQueue", + "http":{ + "method":"POST", + "requestUri":"/" + }, + "input":{"shape":"UntagQueueRequest"} + } + }, + "shapes":{ + "AWSAccountIdList":{ + "type":"list", + "member":{ + "shape":"String", + "locationName":"AWSAccountId" + }, + "flattened":true + }, + "ActionNameList":{ + "type":"list", + "member":{ + "shape":"String", + "locationName":"ActionName" + }, + "flattened":true + }, + "AddPermissionRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "Label", + "AWSAccountIds", + "Actions" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "Label":{ + "shape":"String" + }, + "AWSAccountIds":{ + "shape":"AWSAccountIdList" + }, + "Actions":{ + "shape":"ActionNameList" + } + } + }, + "AttributeNameList":{ + "type":"list", + "member":{ + "shape":"QueueAttributeName", + "locationName":"AttributeName" + }, + "flattened":true + }, + "BatchEntryIdsNotDistinct":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.BatchEntryIdsNotDistinct", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "BatchRequestTooLong":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.BatchRequestTooLong", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "BatchResultErrorEntry":{ + "type":"structure", + "required":[ + "Id", + "SenderFault", + "Code" + ], + "members":{ + "Id":{ + "shape":"String" + }, + "SenderFault":{ + "shape":"Boolean" + }, + "Code":{ + "shape":"String" + }, + "Message":{ + "shape":"String" + } + } + }, + "BatchResultErrorEntryList":{ + "type":"list", + "member":{ + "shape":"BatchResultErrorEntry", + "locationName":"BatchResultErrorEntry" + }, + "flattened":true + }, + "Binary":{"type":"blob"}, + "BinaryList":{ + "type":"list", + "member":{ + "shape":"Binary", + "locationName":"BinaryListValue" + } + }, + "Boolean":{"type":"boolean"}, + "BoxedInteger":{ + "type":"integer", + "box":true + }, + "CancelMessageMoveTaskRequest":{ + "type":"structure", + "required":["TaskHandle"], + "members":{ + "TaskHandle":{ + "shape":"String" + } + } + }, + "CancelMessageMoveTaskResult":{ + "type":"structure", + "members":{ + "ApproximateNumberOfMessagesMoved":{ + "shape":"Long" + } + } + }, + "ChangeMessageVisibilityBatchRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "Entries" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "Entries":{ + "shape":"ChangeMessageVisibilityBatchRequestEntryList" + } + } + }, + "ChangeMessageVisibilityBatchRequestEntry":{ + "type":"structure", + "required":[ + "Id", + "ReceiptHandle" + ], + "members":{ + "Id":{ + "shape":"String" + }, + "ReceiptHandle":{ + "shape":"String" + }, + "VisibilityTimeout":{ + "shape":"Integer" + } + } + }, + "ChangeMessageVisibilityBatchRequestEntryList":{ + "type":"list", + "member":{ + "shape":"ChangeMessageVisibilityBatchRequestEntry", + "locationName":"ChangeMessageVisibilityBatchRequestEntry" + }, + "flattened":true + }, + "ChangeMessageVisibilityBatchResult":{ + "type":"structure", + "required":[ + "Successful", + "Failed" + ], + "members":{ + "Successful":{ + "shape":"ChangeMessageVisibilityBatchResultEntryList" + }, + "Failed":{ + "shape":"BatchResultErrorEntryList" + } + } + }, + "ChangeMessageVisibilityBatchResultEntry":{ + "type":"structure", + "required":["Id"], + "members":{ + "Id":{ + "shape":"String" + } + } + }, + "ChangeMessageVisibilityBatchResultEntryList":{ + "type":"list", + "member":{ + "shape":"ChangeMessageVisibilityBatchResultEntry", + "locationName":"ChangeMessageVisibilityBatchResultEntry" + }, + "flattened":true + }, + "ChangeMessageVisibilityRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "ReceiptHandle", + "VisibilityTimeout" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "ReceiptHandle":{ + "shape":"String" + }, + "VisibilityTimeout":{ + "shape":"Integer" + } + } + }, + "CreateQueueRequest":{ + "type":"structure", + "required":["QueueName"], + "members":{ + "QueueName":{ + "shape":"String" + }, + "Attributes":{ + "shape":"QueueAttributeMap", + "locationName":"Attribute" + }, + "tags":{ + "shape":"TagMap", + "locationName":"Tag" + } + } + }, + "CreateQueueResult":{ + "type":"structure", + "members":{ + "QueueUrl":{ + "shape":"String" + } + } + }, + "DeleteMessageBatchRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "Entries" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "Entries":{ + "shape":"DeleteMessageBatchRequestEntryList" + } + } + }, + "DeleteMessageBatchRequestEntry":{ + "type":"structure", + "required":[ + "Id", + "ReceiptHandle" + ], + "members":{ + "Id":{ + "shape":"String" + }, + "ReceiptHandle":{ + "shape":"String" + } + } + }, + "DeleteMessageBatchRequestEntryList":{ + "type":"list", + "member":{ + "shape":"DeleteMessageBatchRequestEntry", + "locationName":"DeleteMessageBatchRequestEntry" + }, + "flattened":true + }, + "DeleteMessageBatchResult":{ + "type":"structure", + "required":[ + "Successful", + "Failed" + ], + "members":{ + "Successful":{ + "shape":"DeleteMessageBatchResultEntryList" + }, + "Failed":{ + "shape":"BatchResultErrorEntryList" + } + } + }, + "DeleteMessageBatchResultEntry":{ + "type":"structure", + "required":["Id"], + "members":{ + "Id":{ + "shape":"String" + } + } + }, + "DeleteMessageBatchResultEntryList":{ + "type":"list", + "member":{ + "shape":"DeleteMessageBatchResultEntry", + "locationName":"DeleteMessageBatchResultEntry" + }, + "flattened":true + }, + "DeleteMessageRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "ReceiptHandle" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "ReceiptHandle":{ + "shape":"String" + } + } + }, + "DeleteQueueRequest":{ + "type":"structure", + "required":["QueueUrl"], + "members":{ + "QueueUrl":{ + "shape":"String" + } + } + }, + "EmptyBatchRequest":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.EmptyBatchRequest", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "GetQueueAttributesRequest":{ + "type":"structure", + "required":["QueueUrl"], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "AttributeNames":{ + "shape":"AttributeNameList" + } + } + }, + "GetQueueAttributesResult":{ + "type":"structure", + "members":{ + "Attributes":{ + "shape":"QueueAttributeMap", + "locationName":"Attribute" + } + } + }, + "GetQueueUrlRequest":{ + "type":"structure", + "required":["QueueName"], + "members":{ + "QueueName":{ + "shape":"String" + }, + "QueueOwnerAWSAccountId":{ + "shape":"String" + } + } + }, + "GetQueueUrlResult":{ + "type":"structure", + "members":{ + "QueueUrl":{ + "shape":"String" + } + } + }, + "Integer":{"type":"integer"}, + "InvalidAttributeName":{ + "type":"structure", + "members":{ + }, + "exception":true + }, + "InvalidBatchEntryId":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.InvalidBatchEntryId", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "InvalidIdFormat":{ + "type":"structure", + "members":{ + }, + "exception":true + }, + "InvalidMessageContents":{ + "type":"structure", + "members":{ + }, + "exception":true + }, + "ListDeadLetterSourceQueuesRequest":{ + "type":"structure", + "required":["QueueUrl"], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "NextToken":{ + "shape":"Token" + }, + "MaxResults":{ + "shape":"BoxedInteger" + } + } + }, + "ListDeadLetterSourceQueuesResult":{ + "type":"structure", + "required":["queueUrls"], + "members":{ + "queueUrls":{ + "shape":"QueueUrlList" + }, + "NextToken":{ + "shape":"Token" + } + } + }, + "ListMessageMoveTasksRequest":{ + "type":"structure", + "required":["SourceArn"], + "members":{ + "SourceArn":{ + "shape":"String" + }, + "MaxResults":{ + "shape":"Integer" + } + } + }, + "ListMessageMoveTasksResult":{ + "type":"structure", + "members":{ + "Results":{ + "shape":"ListMessageMoveTasksResultEntryList" + } + } + }, + "ListMessageMoveTasksResultEntry":{ + "type":"structure", + "members":{ + "TaskHandle":{ + "shape":"String" + }, + "Status":{ + "shape":"String" + }, + "SourceArn":{ + "shape":"String" + }, + "DestinationArn":{ + "shape":"String" + }, + "MaxNumberOfMessagesPerSecond":{ + "shape":"Integer" + }, + "ApproximateNumberOfMessagesMoved":{ + "shape":"Long" + }, + "ApproximateNumberOfMessagesToMove":{ + "shape":"Long" + }, + "FailureReason":{ + "shape":"String" + }, + "StartedTimestamp":{ + "shape":"Long" + } + } + }, + "ListMessageMoveTasksResultEntryList":{ + "type":"list", + "member":{ + "shape":"ListMessageMoveTasksResultEntry", + "locationName":"ListMessageMoveTasksResultEntry" + }, + "flattened":true + }, + "ListQueueTagsRequest":{ + "type":"structure", + "required":["QueueUrl"], + "members":{ + "QueueUrl":{ + "shape":"String" + } + } + }, + "ListQueueTagsResult":{ + "type":"structure", + "members":{ + "Tags":{ + "shape":"TagMap", + "locationName":"Tag" + } + } + }, + "ListQueuesRequest":{ + "type":"structure", + "members":{ + "QueueNamePrefix":{ + "shape":"String" + }, + "NextToken":{ + "shape":"Token" + }, + "MaxResults":{ + "shape":"BoxedInteger" + } + } + }, + "ListQueuesResult":{ + "type":"structure", + "members":{ + "QueueUrls":{ + "shape":"QueueUrlList" + }, + "NextToken":{ + "shape":"Token" + } + } + }, + "Long":{"type":"long"}, + "Message":{ + "type":"structure", + "members":{ + "MessageId":{ + "shape":"String" + }, + "ReceiptHandle":{ + "shape":"String" + }, + "MD5OfBody":{ + "shape":"String" + }, + "Body":{ + "shape":"String" + }, + "Attributes":{ + "shape":"MessageSystemAttributeMap", + "locationName":"Attribute" + }, + "MD5OfMessageAttributes":{ + "shape":"String" + }, + "MessageAttributes":{ + "shape":"MessageBodyAttributeMap", + "locationName":"MessageAttribute" + } + } + }, + "MessageAttributeName":{"type":"string"}, + "MessageAttributeNameList":{ + "type":"list", + "member":{ + "shape":"MessageAttributeName", + "locationName":"MessageAttributeName" + }, + "flattened":true + }, + "MessageAttributeValue":{ + "type":"structure", + "required":["DataType"], + "members":{ + "StringValue":{ + "shape":"String" + }, + "BinaryValue":{ + "shape":"Binary" + }, + "StringListValues":{ + "shape":"StringList", + "flattened":true, + "locationName":"StringListValue" + }, + "BinaryListValues":{ + "shape":"BinaryList", + "flattened":true, + "locationName":"BinaryListValue" + }, + "DataType":{ + "shape":"String" + } + } + }, + "MessageBodyAttributeMap":{ + "type":"map", + "key":{ + "shape":"String", + "locationName":"Name" + }, + "value":{ + "shape":"MessageAttributeValue", + "locationName":"Value" + }, + "flattened":true + }, + "MessageBodySystemAttributeMap":{ + "type":"map", + "key":{ + "shape":"MessageSystemAttributeNameForSends", + "locationName":"Name" + }, + "value":{ + "shape":"MessageSystemAttributeValue", + "locationName":"Value" + }, + "flattened":true + }, + "MessageList":{ + "type":"list", + "member":{ + "shape":"Message", + "locationName":"Message" + }, + "flattened":true + }, + "MessageNotInflight":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.MessageNotInflight", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "MessageSystemAttributeMap":{ + "type":"map", + "key":{ + "shape":"MessageSystemAttributeName", + "locationName":"Name" + }, + "value":{ + "shape":"String", + "locationName":"Value" + }, + "flattened":true, + "locationName":"Attribute" + }, + "MessageSystemAttributeName":{ + "type":"string", + "enum":[ + "SenderId", + "SentTimestamp", + "ApproximateReceiveCount", + "ApproximateFirstReceiveTimestamp", + "SequenceNumber", + "MessageDeduplicationId", + "MessageGroupId", + "AWSTraceHeader", + "DeadLetterQueueSourceArn" + ] + }, + "MessageSystemAttributeNameForSends":{ + "type":"string", + "enum":["AWSTraceHeader"] + }, + "MessageSystemAttributeValue":{ + "type":"structure", + "required":["DataType"], + "members":{ + "StringValue":{ + "shape":"String" + }, + "BinaryValue":{ + "shape":"Binary" + }, + "StringListValues":{ + "shape":"StringList", + "flattened":true, + "locationName":"StringListValue" + }, + "BinaryListValues":{ + "shape":"BinaryList", + "flattened":true, + "locationName":"BinaryListValue" + }, + "DataType":{ + "shape":"String" + } + } + }, + "OverLimit":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"OverLimit", + "httpStatusCode":403, + "senderFault":true + }, + "exception":true + }, + "PurgeQueueInProgress":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.PurgeQueueInProgress", + "httpStatusCode":403, + "senderFault":true + }, + "exception":true + }, + "PurgeQueueRequest":{ + "type":"structure", + "required":["QueueUrl"], + "members":{ + "QueueUrl":{ + "shape":"String" + } + } + }, + "QueueAttributeMap":{ + "type":"map", + "key":{ + "shape":"QueueAttributeName", + "locationName":"Name" + }, + "value":{ + "shape":"String", + "locationName":"Value" + }, + "flattened":true, + "locationName":"Attribute" + }, + "QueueAttributeName":{ + "type":"string", + "enum":[ + "All", + "Policy", + "VisibilityTimeout", + "MaximumMessageSize", + "MessageRetentionPeriod", + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesNotVisible", + "CreatedTimestamp", + "LastModifiedTimestamp", + "QueueArn", + "ApproximateNumberOfMessagesDelayed", + "DelaySeconds", + "ReceiveMessageWaitTimeSeconds", + "RedrivePolicy", + "FifoQueue", + "ContentBasedDeduplication", + "KmsMasterKeyId", + "KmsDataKeyReusePeriodSeconds", + "DeduplicationScope", + "FifoThroughputLimit", + "RedriveAllowPolicy", + "SqsManagedSseEnabled" + ] + }, + "QueueDeletedRecently":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.QueueDeletedRecently", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "QueueDoesNotExist":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.NonExistentQueue", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "QueueNameExists":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"QueueAlreadyExists", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "QueueUrlList":{ + "type":"list", + "member":{ + "shape":"String", + "locationName":"QueueUrl" + }, + "flattened":true + }, + "ReceiptHandleIsInvalid":{ + "type":"structure", + "members":{ + }, + "exception":true + }, + "ReceiveMessageRequest":{ + "type":"structure", + "required":["QueueUrl"], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "AttributeNames":{ + "shape":"AttributeNameList" + }, + "MessageSystemAttributeNames":{ + "shape":"AttributeNameList" + }, + "MessageAttributeNames":{ + "shape":"MessageAttributeNameList" + }, + "MaxNumberOfMessages":{ + "shape":"Integer" + }, + "VisibilityTimeout":{ + "shape":"Integer" + }, + "WaitTimeSeconds":{ + "shape":"Integer" + }, + "ReceiveRequestAttemptId":{ + "shape":"String" + } + } + }, + "ReceiveMessageResult":{ + "type":"structure", + "members":{ + "Messages":{ + "shape":"MessageList" + } + } + }, + "RemovePermissionRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "Label" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "Label":{ + "shape":"String" + } + } + }, + "ResourceNotFoundException":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"ResourceNotFoundException", + "httpStatusCode":404, + "senderFault":true + }, + "exception":true + }, + "SendMessageBatchRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "Entries" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "Entries":{ + "shape":"SendMessageBatchRequestEntryList" + } + } + }, + "SendMessageBatchRequestEntry":{ + "type":"structure", + "required":[ + "Id", + "MessageBody" + ], + "members":{ + "Id":{ + "shape":"String" + }, + "MessageBody":{ + "shape":"String" + }, + "DelaySeconds":{ + "shape":"Integer" + }, + "MessageAttributes":{ + "shape":"MessageBodyAttributeMap", + "locationName":"MessageAttribute" + }, + "MessageSystemAttributes":{ + "shape":"MessageBodySystemAttributeMap", + "locationName":"MessageSystemAttribute" + }, + "MessageDeduplicationId":{ + "shape":"String" + }, + "MessageGroupId":{ + "shape":"String" + } + } + }, + "SendMessageBatchRequestEntryList":{ + "type":"list", + "member":{ + "shape":"SendMessageBatchRequestEntry", + "locationName":"SendMessageBatchRequestEntry" + }, + "flattened":true + }, + "SendMessageBatchResult":{ + "type":"structure", + "required":[ + "Successful", + "Failed" + ], + "members":{ + "Successful":{ + "shape":"SendMessageBatchResultEntryList" + }, + "Failed":{ + "shape":"BatchResultErrorEntryList" + } + } + }, + "SendMessageBatchResultEntry":{ + "type":"structure", + "required":[ + "Id", + "MessageId", + "MD5OfMessageBody" + ], + "members":{ + "Id":{ + "shape":"String" + }, + "MessageId":{ + "shape":"String" + }, + "MD5OfMessageBody":{ + "shape":"String" + }, + "MD5OfMessageAttributes":{ + "shape":"String" + }, + "MD5OfMessageSystemAttributes":{ + "shape":"String" + }, + "SequenceNumber":{ + "shape":"String" + } + } + }, + "SendMessageBatchResultEntryList":{ + "type":"list", + "member":{ + "shape":"SendMessageBatchResultEntry", + "locationName":"SendMessageBatchResultEntry" + }, + "flattened":true + }, + "SendMessageRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "MessageBody" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "MessageBody":{ + "shape":"String" + }, + "DelaySeconds":{ + "shape":"Integer" + }, + "MessageAttributes":{ + "shape":"MessageBodyAttributeMap", + "locationName":"MessageAttribute" + }, + "MessageSystemAttributes":{ + "shape":"MessageBodySystemAttributeMap", + "locationName":"MessageSystemAttribute" + }, + "MessageDeduplicationId":{ + "shape":"String" + }, + "MessageGroupId":{ + "shape":"String" + } + } + }, + "SendMessageResult":{ + "type":"structure", + "members":{ + "MD5OfMessageBody":{ + "shape":"String" + }, + "MD5OfMessageAttributes":{ + "shape":"String" + }, + "MD5OfMessageSystemAttributes":{ + "shape":"String" + }, + "MessageId":{ + "shape":"String" + }, + "SequenceNumber":{ + "shape":"String" + } + } + }, + "SetQueueAttributesRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "Attributes" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "Attributes":{ + "shape":"QueueAttributeMap", + "locationName":"Attribute" + } + } + }, + "StartMessageMoveTaskRequest":{ + "type":"structure", + "required":["SourceArn"], + "members":{ + "SourceArn":{ + "shape":"String" + }, + "DestinationArn":{ + "shape":"String" + }, + "MaxNumberOfMessagesPerSecond":{ + "shape":"Integer" + } + } + }, + "StartMessageMoveTaskResult":{ + "type":"structure", + "members":{ + "TaskHandle":{ + "shape":"String" + } + } + }, + "String":{"type":"string"}, + "StringList":{ + "type":"list", + "member":{ + "shape":"String", + "locationName":"StringListValue" + } + }, + "TagKey":{"type":"string"}, + "TagKeyList":{ + "type":"list", + "member":{ + "shape":"TagKey", + "locationName":"TagKey" + }, + "flattened":true + }, + "TagMap":{ + "type":"map", + "key":{ + "shape":"TagKey", + "locationName":"Key" + }, + "value":{ + "shape":"TagValue", + "locationName":"Value" + }, + "flattened":true, + "locationName":"Tag" + }, + "TagQueueRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "Tags" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "Tags":{ + "shape":"TagMap" + } + } + }, + "TagValue":{"type":"string"}, + "Token":{"type":"string"}, + "TooManyEntriesInBatchRequest":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "UnsupportedOperation":{ + "type":"structure", + "members":{ + }, + "error":{ + "code":"AWS.SimpleQueueService.UnsupportedOperation", + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + }, + "UntagQueueRequest":{ + "type":"structure", + "required":[ + "QueueUrl", + "TagKeys" + ], + "members":{ + "QueueUrl":{ + "shape":"String" + }, + "TagKeys":{ + "shape":"TagKeyList" + } + } + } + } +} diff --git a/localstack-core/localstack/aws/forwarder.py b/localstack-core/localstack/aws/forwarder.py new file mode 100644 index 0000000000000..c25d4b90f6c09 --- /dev/null +++ b/localstack-core/localstack/aws/forwarder.py @@ -0,0 +1,271 @@ +""" +This module contains utilities to call a backend (e.g., an external service process like +DynamoDBLocal) from a service provider. +""" + +from typing import Any, Callable, Mapping, Optional, Union + +from botocore.awsrequest import AWSPreparedRequest, prepare_request_dict +from botocore.config import Config as BotoConfig +from werkzeug.datastructures import Headers + +from localstack.aws.api.core import ( + RequestContext, + ServiceRequest, + ServiceRequestHandler, + ServiceResponse, +) +from localstack.aws.client import create_http_request, parse_response, raise_service_exception +from localstack.aws.connect import connect_to +from localstack.aws.skeleton import DispatchTable, create_dispatch_table +from localstack.aws.spec import load_service +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.http import Response +from localstack.http.proxy import Proxy + + +class AwsRequestProxy: + """ + Implements the ``ServiceRequestHandler`` protocol to forward AWS requests to a backend. It is stateful and uses a + ``Proxy`` instance for re-using client connections to the backend. + """ + + def __init__( + self, + endpoint_url: str, + parse_response: bool = True, + include_response_metadata: bool = False, + ): + """ + Create a new AwsRequestProxy. ``parse_response`` control the return behavior of ``forward``. If + ``parse_response`` is set, then ``forward`` parses the HTTP response from the backend and returns a + ``ServiceResponse``, otherwise it returns the raw HTTP ``Response`` object. + + :param endpoint_url: the backend to proxy the requests to, used as ``forward_base_url`` for the ``Proxy``. + :param parse_response: whether to parse the response before returning it + :param include_response_metadata: include AWS response metadata, only used with ``parse_response=True`` + """ + self.endpoint_url = endpoint_url + self.parse_response = parse_response + self.include_response_metadata = include_response_metadata + self.proxy = Proxy(forward_base_url=endpoint_url) + + def __call__( + self, + context: RequestContext, + service_request: ServiceRequest = None, + ) -> Optional[Union[ServiceResponse, Response]]: + """Method to satisfy the ``ServiceRequestHandler`` protocol.""" + return self.forward(context, service_request) + + def forward( + self, + context: RequestContext, + service_request: ServiceRequest = None, + ) -> Optional[Union[ServiceResponse, Response]]: + """ + Forwards the given request to the backend configured by ``endpoint_url``. + + :param context: the original request context of the incoming request + :param service_request: optionally a new service + :return: + """ + if service_request is not None: + # if a service request is passed then we need to create a new request context + context = self.new_request_context(context, service_request) + + http_response = self.proxy.forward(context.request, forward_path=context.request.path) + if not self.parse_response: + return http_response + parsed_response = parse_response( + context.operation, http_response, self.include_response_metadata + ) + raise_service_exception(http_response, parsed_response) + return parsed_response + + def new_request_context(self, original: RequestContext, service_request: ServiceRequest): + context = create_aws_request_context( + service_name=original.service.service_name, + action=original.operation.name, + parameters=service_request, + region=original.region, + ) + # update the newly created context with non-payload specific request headers (the payload can differ from + # the original request, f.e. it could be JSON encoded now while the initial request was CBOR encoded) + headers = Headers(original.request.headers) + headers.pop("Content-Type", None) + headers.pop("Content-Length", None) + context.request.headers.update(headers) + return context + + +def ForwardingFallbackDispatcher( + provider: object, request_forwarder: ServiceRequestHandler +) -> DispatchTable: + """ + Wraps a provider with a request forwarder. It does by creating a new DispatchTable from the original + provider, and wrapping each method with a fallthrough method that calls ``request_forwarder`` if the + original provider raises a ``NotImplementedError``. + + :param provider: the ASF provider + :param request_forwarder: callable that forwards the request (e.g., to a backend server) + :return: a modified DispatchTable + """ + table = create_dispatch_table(provider) + + for op, fn in table.items(): + table[op] = _wrap_with_fallthrough(fn, request_forwarder) + + return table + + +class NotImplementedAvoidFallbackError(NotImplementedError): + pass + + +def _wrap_with_fallthrough( + handler: ServiceRequestHandler, fallthrough_handler: ServiceRequestHandler +) -> ServiceRequestHandler: + def _call(context, req) -> ServiceResponse: + try: + # handler will typically be an ASF provider method, and in case it hasn't been + # implemented, we try to fall back to forwarding the request to the backend + return handler(context, req) + except NotImplementedAvoidFallbackError as e: + # if the fallback has been explicitly disabled, don't pass on to the fallback + raise e + except NotImplementedError: + pass + + return fallthrough_handler(context, req) + + return _call + + +def HttpFallbackDispatcher(provider: object, forward_url_getter: Callable[[str, str], str]): + return ForwardingFallbackDispatcher(provider, get_request_forwarder_http(forward_url_getter)) + + +def get_request_forwarder_http( + forward_url_getter: Callable[[str, str], str], +) -> ServiceRequestHandler: + """ + Returns a ServiceRequestHandler that creates for each invocation a new AwsRequestProxy with the result of + forward_url_getter. Note that this is an inefficient method of proxying, since for every call a new client + connection has to be established. Try to instead use static forward URL values and use ``AwsRequestProxy`` directly. + + :param forward_url_getter: a factory method for returning forward base urls for the proxy + :return: a ServiceRequestHandler acting as a proxy + """ + + def _forward_request( + context: RequestContext, service_request: ServiceRequest = None + ) -> ServiceResponse: + return AwsRequestProxy(forward_url_getter(context.account_id, context.region)).forward( + context, service_request + ) + + return _forward_request + + +def dispatch_to_backend( + context: RequestContext, + http_request_dispatcher: Callable[[RequestContext], Response], + include_response_metadata=False, +) -> ServiceResponse: + """ + Dispatch the given request to a backend by using the `request_forwarder` function to + fetch an HTTP response, converting it to a ServiceResponse. + :param context: the request context + :param http_request_dispatcher: dispatcher that performs the request and returns an HTTP response + :param include_response_metadata: whether to include boto3 response metadata in the response + :return: parsed service response + :raises ServiceException: if the dispatcher returned an error response + """ + http_response = http_request_dispatcher(context) + parsed_response = parse_response(context.operation, http_response, include_response_metadata) + raise_service_exception(http_response, parsed_response) + return parsed_response + + +# boto config deactivating param validation to forward to backends (backends are responsible for validating params) +_non_validating_boto_config = BotoConfig(parameter_validation=False) + + +def create_aws_request_context( + service_name: str, + action: str, + parameters: Mapping[str, Any] = None, + region: str = None, + endpoint_url: Optional[str] = None, +) -> RequestContext: + """ + This is a stripped-down version of what the botocore client does to perform an HTTP request from a client call. A + client call looks something like this: boto3.client("sqs").create_queue(QueueName="myqueue"), which will be + serialized into an HTTP request. This method does the same, without performing the actual request, and with a + more low-level interface. An equivalent call would be + + create_aws_request_context("sqs", "CreateQueue", {"QueueName": "myqueue"}) + + :param service_name: the AWS service + :param action: the action to invoke + :param parameters: the invocation parameters + :param region: the region name (default is us-east-1) + :param endpoint_url: the endpoint to call (defaults to localstack) + :return: a RequestContext object that describes this request + """ + if parameters is None: + parameters = {} + if region is None: + region = AWS_REGION_US_EAST_1 + + service = load_service(service_name) + operation = service.operation_model(action) + + # we re-use botocore internals here to serialize the HTTP request, + # but deactivate validation (validation errors should be handled by the backend) + # and don't send it yet + client = connect_to.get_client( + service_name, + endpoint_url=endpoint_url, + region_name=region, + config=_non_validating_boto_config, + ) + request_context = { + "client_region": region, + "has_streaming_input": operation.has_streaming_input, + "auth_type": operation.auth_type, + } + + # The endpoint URL is mandatory here, set a dummy if not given (doesn't _need_ to be localstack specific) + if not endpoint_url: + endpoint_url = "http://localhost.localstack.cloud" + # pre-process the request args (some params are modified using botocore event handlers) + parameters = client._emit_api_params(parameters, operation, request_context) + request_dict = client._convert_to_request_dict( + parameters, operation, endpoint_url, context=request_context + ) + + if auth_path := request_dict.get("auth_path"): + # botocore >= 1.28 might modify the url path of the request dict (specifically for S3). + # It will then set the original url path as "auth_path". If the auth_path is set, we reset the url_path. + # Since botocore 1.31.2, botocore will strip the query from the `authPart` + # We need to add it back from `requestUri` field + # Afterwards the request needs to be prepared again. + path, sep, query = request_dict["url_path"].partition("?") + request_dict["url_path"] = f"{auth_path}{sep}{query}" + prepare_request_dict( + request_dict, + endpoint_url=endpoint_url, + user_agent=client._client_config.user_agent, + context=request_context, + ) + + aws_request: AWSPreparedRequest = client._endpoint.create_request(request_dict, operation) + context = RequestContext(request=create_http_request(aws_request)) + context.service = service + context.operation = operation + context.region = region + context.service_request = parameters + + return context diff --git a/localstack-core/localstack/aws/gateway.py b/localstack-core/localstack/aws/gateway.py new file mode 100644 index 0000000000000..6fd526b6014fc --- /dev/null +++ b/localstack-core/localstack/aws/gateway.py @@ -0,0 +1,32 @@ +import typing as t + +from rolo.gateway import Gateway as RoloGateway +from rolo.response import Response + +from .chain import ExceptionHandler, Handler, RequestContext + +__all__ = [ + "Gateway", +] + + +class Gateway(RoloGateway): + def __init__( + self, + request_handlers: list[Handler] = None, + response_handlers: list[Handler] = None, + finalizers: list[Handler] = None, + exception_handlers: list[ExceptionHandler] = None, + context_class: t.Type[RequestContext] = None, + ) -> None: + super().__init__( + request_handlers, + response_handlers, + finalizers, + exception_handlers, + context_class or RequestContext, + ) + + def handle(self, context: RequestContext, response: Response) -> None: + """Exposes the same interface as ``HandlerChain.handle``.""" + return self.new_chain().handle(context, response) diff --git a/localstack-core/localstack/aws/handlers/__init__.py b/localstack-core/localstack/aws/handlers/__init__.py new file mode 100644 index 0000000000000..a7aea2c69b03d --- /dev/null +++ b/localstack-core/localstack/aws/handlers/__init__.py @@ -0,0 +1,51 @@ +"""A set of common handlers to build an AWS server application.""" + +from .. import chain +from . import ( + analytics, + auth, + codec, + cors, + fallback, + internal, + internal_requests, + legacy, + logging, + presigned_url, + region, + service, + tracing, + validation, +) + +handle_runtime_shutdown = internal.RuntimeShutdownHandler() +enforce_cors = cors.CorsEnforcer() +preprocess_request = chain.CompositeHandler() +add_cors_response_headers = cors.CorsResponseEnricher() +content_decoder = codec.ContentDecoder() +parse_service_name = service.ServiceNameParser() +parse_service_request = service.ServiceRequestParser() +add_account_id = auth.AccountIdEnricher() +inject_auth_header_if_missing = auth.MissingAuthHeaderInjector() +add_region_from_header = region.RegionContextEnricher() +rewrite_region = region.RegionRewriter() +add_internal_request_params = internal_requests.InternalRequestParamsEnricher() +validate_request_schema = validation.OpenAPIRequestValidator() +validate_response_schema = validation.OpenAPIResponseValidator() +log_exception = logging.ExceptionLogger() +log_response = logging.ResponseLogger() +count_service_request = analytics.ServiceRequestCounter() +handle_service_exception = service.ServiceExceptionSerializer() +handle_internal_failure = fallback.InternalFailureHandler() +serve_custom_service_request_handlers = chain.CompositeHandler() +serve_localstack_resources = internal.LocalstackResourceHandler() +run_custom_response_handlers = chain.CompositeResponseHandler() +modify_service_response = service.ServiceResponseHandlers() +parse_service_response = service.ServiceResponseParser() +parse_trace_context = tracing.TraceContextParser() +parse_pre_signed_url_request = presigned_url.ParsePreSignedUrlRequest() +run_custom_finalizers = chain.CompositeFinalizer() +serve_custom_exception_handlers = chain.CompositeExceptionHandler() +# legacy compatibility handlers +serve_edge_router_rules = legacy.EdgeRouterHandler() +set_close_connection_header = legacy.set_close_connection_header diff --git a/localstack-core/localstack/aws/handlers/analytics.py b/localstack-core/localstack/aws/handlers/analytics.py new file mode 100644 index 0000000000000..4e5bbfa8aa085 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/analytics.py @@ -0,0 +1,69 @@ +import logging +import threading +from typing import Optional + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.chain import HandlerChain +from localstack.aws.client import parse_response +from localstack.http import Response +from localstack.utils.analytics.service_request_aggregator import ( + ServiceRequestAggregator, + ServiceRequestInfo, +) + +LOG = logging.getLogger(__name__) + + +class ServiceRequestCounter: + aggregator: ServiceRequestAggregator + + def __init__(self, service_request_aggregator: ServiceRequestAggregator = None): + self.aggregator = service_request_aggregator or ServiceRequestAggregator() + self._mutex = threading.Lock() + self._started = False + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if response is None or context.operation is None: + return + if config.DISABLE_EVENTS: + return + if context.is_internal_call: + # don't count internal requests + return + + # this condition will only be true only for the first call, so it makes sense to not acquire the lock every time + if not self._started: + with self._mutex: + if not self._started: + self._started = True + self.aggregator.start() + + err_type = self._get_err_type(context, response) if response.status_code >= 400 else None + service_name = context.operation.service_model.service_name + operation_name = context.operation.name + + self.aggregator.add_request( + ServiceRequestInfo( + service_name, + operation_name, + response.status_code, + err_type=err_type, + ) + ) + + def _get_err_type(self, context: RequestContext, response: Response) -> Optional[str]: + """ + Attempts to re-use the existing service_response, or parse and return the error type from the response body, + e.g. ``ResourceInUseException``. + """ + try: + if context.service_exception: + return context.service_exception.code + + response = parse_response(context.operation, response) + return response["Error"]["Code"] + except Exception: + if config.DEBUG_ANALYTICS: + LOG.exception("error parsing error response") + return None diff --git a/localstack-core/localstack/aws/handlers/auth.py b/localstack-core/localstack/aws/handlers/auth.py new file mode 100644 index 0000000000000..789734bbb28f9 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/auth.py @@ -0,0 +1,55 @@ +import logging + +from localstack.aws.accounts import ( + get_account_id_from_access_key_id, +) +from localstack.constants import ( + AWS_REGION_US_EAST_1, + DEFAULT_AWS_ACCOUNT_ID, +) +from localstack.http import Response +from localstack.utils.aws.request_context import ( + extract_access_key_id_from_auth_header, + mock_aws_request_headers, +) + +from ..api import RequestContext +from ..chain import Handler, HandlerChain + +LOG = logging.getLogger(__name__) + + +class MissingAuthHeaderInjector(Handler): + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # FIXME: this is needed for allowing access to resources via plain URLs where access is typically restricted ( + # e.g., GET requests on S3 URLs or apigateway routes). this should probably be part of a general IAM middleware + # (that allows access to restricted resources by default) + if not context.service: + return + + api = context.service.service_name + headers = context.request.headers + + if not headers.get("Authorization"): + headers["Authorization"] = mock_aws_request_headers( + api, aws_access_key_id="injectedaccesskey", region_name=AWS_REGION_US_EAST_1 + )["Authorization"] + + +class AccountIdEnricher(Handler): + """ + A handler that sets the AWS account of the request in the RequestContext. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # Obtain the access key ID + access_key_id = ( + extract_access_key_id_from_auth_header(context.request.headers) + or DEFAULT_AWS_ACCOUNT_ID + ) + + # Obtain the account ID from access key ID + context.account_id = get_account_id_from_access_key_id(access_key_id) + + # Make Moto use the same Account ID as LocalStack + context.request.headers.add("x-moto-account-id", context.account_id) diff --git a/localstack-core/localstack/aws/handlers/codec.py b/localstack-core/localstack/aws/handlers/codec.py new file mode 100644 index 0000000000000..5b00d21812dba --- /dev/null +++ b/localstack-core/localstack/aws/handlers/codec.py @@ -0,0 +1,28 @@ +import gzip + +from localstack.aws.api import RequestContext +from localstack.aws.chain import Handler, HandlerChain +from localstack.http import Response + + +class ContentDecoder(Handler): + """ + A handler which takes care of decoding the content of a request (if the header "Content-Encoding" is set). + + The Content-Encoding representation header lists any encodings that have been applied to the representation + (message payload), and in what order. + """ + + # Some services _break_ the specification of Content-Encoding (f.e. in combination with Content-MD5). + SKIP_GZIP_SERVICES = ["s3"] + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if context.service and context.service.service_name in self.SKIP_GZIP_SERVICES: + # Skip the decoding for services which need to do this on their own + return + + # Currently, only GZIP is supported. When supporting multiple types, the order needs to be respected + if context.request.content_encoding and context.request.content_encoding.lower() == "gzip": + # wrap the request's stream with GZip decompression (inspired by flask-inflate) + context.request.stream = gzip.GzipFile(fileobj=context.request.stream) + context.request.headers["Content-Encoding"] = "identity" diff --git a/localstack-core/localstack/aws/handlers/cors.py b/localstack-core/localstack/aws/handlers/cors.py new file mode 100644 index 0000000000000..13540e0165710 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/cors.py @@ -0,0 +1,283 @@ +""" +A set of handlers which handle Cross Origin Resource Sharing (CORS). +""" + +import logging +import re +from typing import List, Set +from urllib.parse import urlparse + +from werkzeug.datastructures import Headers + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.chain import Handler, HandlerChain +from localstack.config import EXTRA_CORS_ALLOWED_HEADERS, EXTRA_CORS_EXPOSE_HEADERS +from localstack.constants import LOCALHOST, LOCALHOST_HOSTNAME, PATH_USER_REQUEST +from localstack.http import Response +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +# CORS headers +ACL_ALLOW_HEADERS = "Access-Control-Allow-Headers" +ACL_CREDENTIALS = "Access-Control-Allow-Credentials" +ACL_EXPOSE_HEADERS = "Access-Control-Expose-Headers" +ACL_METHODS = "Access-Control-Allow-Methods" +ACL_ORIGIN = "Access-Control-Allow-Origin" +ACL_REQUEST_HEADERS = "Access-Control-Request-Headers" + +# header name constants +ACL_REQUEST_PRIVATE_NETWORK = "Access-Control-Request-Private-Network" +ACL_ALLOW_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network" + +# CORS constants below +CORS_ALLOWED_HEADERS = [ + "authorization", + "cache-control", + "content-length", + "content-md5", + "content-type", + "etag", + "location", + "x-amz-acl", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-request-id", + "x-amz-security-token", + "x-amz-tagging", + "x-amz-target", + "x-amz-user-agent", + "x-amz-version-id", + "x-amzn-requestid", + "x-localstack-target", + # for AWS SDK v3 + "amz-sdk-invocation-id", + "amz-sdk-request", + # for lambda + "x-amz-log-type", +] +if EXTRA_CORS_ALLOWED_HEADERS: + CORS_ALLOWED_HEADERS += EXTRA_CORS_ALLOWED_HEADERS.split(",") + +CORS_ALLOWED_METHODS = ("HEAD", "GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH") + +CORS_EXPOSE_HEADERS = ( + "etag", + "x-amz-version-id", + # for lambda + "x-amz-log-result", + "x-amz-executed-version", + "x-amz-function-error", +) +if EXTRA_CORS_EXPOSE_HEADERS: + CORS_EXPOSE_HEADERS += tuple(EXTRA_CORS_EXPOSE_HEADERS.split(",")) + +ALLOWED_CORS_RESPONSE_HEADERS = [ + "Access-Control-Allow-Origin", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + "Access-Control-Max-Age", + "Access-Control-Allow-Credentials", + "Access-Control-Expose-Headers", +] + + +def _get_allowed_cors_internal_domains() -> Set[str]: + """ + Construct the list of allowed internal domains for CORS enforcement purposes + Defined as function to allow easier testing with monkeypatch of config values + """ + return {LOCALHOST, LOCALHOST_HOSTNAME, localstack_host().host} + + +_ALLOWED_INTERNAL_DOMAINS = _get_allowed_cors_internal_domains() + + +def _get_allowed_cors_ports() -> Set[int]: + """ + Construct the list of allowed ports for CORS enforcement purposes + Defined as function to allow easier testing with monkeypatch of config values + """ + return {host_and_port.port for host_and_port in config.GATEWAY_LISTEN} + + +_ALLOWED_INTERNAL_PORTS = _get_allowed_cors_ports() + + +def _get_allowed_cors_origins() -> List[str]: + """Construct the list of allowed origins for CORS enforcement purposes""" + result = [ + # allow access from Web app and localhost domains + "https://app.localstack.cloud", + "http://app.localstack.cloud", + "https://localhost", + "https://localhost.localstack.cloud", + # for requests from Electron apps, e.g., DynamoDB NoSQL Workbench + "file://", + ] + # Add allowed origins for localhost domains, using different protocol/port combinations. + for protocol in {"http", "https"}: + for port in _get_allowed_cors_ports(): + result.append(f"{protocol}://{LOCALHOST}:{port}") + result.append(f"{protocol}://{LOCALHOST_HOSTNAME}:{port}") + + if config.EXTRA_CORS_ALLOWED_ORIGINS: + origins = config.EXTRA_CORS_ALLOWED_ORIGINS.split(",") + origins = [origin.strip() for origin in origins] + origins = [origin for origin in origins if origin != ""] + result += origins + + return result + + +# allowed origins used for CORS / CSRF checks +ALLOWED_CORS_ORIGINS = _get_allowed_cors_origins() + +# allowed dynamic internal origin +# must follow the same pattern with 3 matching group, group 2 being the domain and group 3 the port +# TODO: might need to match/group the scheme also? +DYNAMIC_INTERNAL_ORIGINS = ( + re.compile("(.*)\\.s3-website\\.(.[^:]*)(:[0-9]{2,5})?"), + re.compile("(.*)\\.cloudfront\\.(.[^:]*)(:[0-9]{2,5})?"), +) + + +def is_execute_api_call(context: RequestContext) -> bool: + path = context.request.path + return ( + ".execute-api." in context.request.host + or (path.startswith("/restapis/") and f"/{PATH_USER_REQUEST}" in context.request.path) + or (path.startswith("/_aws/execute-api")) + ) + + +def should_enforce_self_managed_service(context: RequestContext) -> bool: + """ + Some services are handling their CORS checks on their own (depending on config vars). + + :param context: context of the request for which to check if the CORS checks should be executed in here or in + the targeting service + :return: True if the CORS rules should be enforced in here. + """ + # allow only certain api calls without checking origin as those services self-manage CORS + if not config.DISABLE_CUSTOM_CORS_S3: + if context.service and context.service.service_name == "s3": + return False + + if not config.DISABLE_CUSTOM_CORS_APIGATEWAY: + if is_execute_api_call(context): + return False + + return True + + +class CorsEnforcer(Handler): + """ + Handler which enforces Cross-Origin-Resource-Sharing (CORS) rules. + This handler needs to be at the top of the handler chain to ensure that these security rules are enforced before any + commands are executed. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response) -> None: + if not should_enforce_self_managed_service(context): + return + if not config.DISABLE_CORS_CHECKS and not self.is_cors_origin_allowed( + context.request.headers + ): + LOG.info( + "Blocked CORS request from forbidden origin %s", + context.request.headers.get("origin") or context.request.headers.get("referer"), + ) + response.status_code = 403 + chain.terminate() + elif context.request.method == "OPTIONS" and not config.DISABLE_PREFLIGHT_PROCESSING: + # we want to return immediately here, but we do not want to omit our response chain for cors headers + response.status_code = 204 + chain.stop() + + @staticmethod + def is_cors_origin_allowed(headers: Headers) -> bool: + """Returns true if origin is allowed to perform cors requests, false otherwise.""" + origin = headers.get("origin") + referer = headers.get("referer") + if origin: + return CorsEnforcer._is_in_allowed_origins(ALLOWED_CORS_ORIGINS, origin) + elif referer: + referer_uri = "{uri.scheme}://{uri.netloc}".format(uri=urlparse(referer)) + return CorsEnforcer._is_in_allowed_origins(ALLOWED_CORS_ORIGINS, referer_uri) + # If both headers are not set, let it through (awscli etc. do not send these headers) + return True + + @staticmethod + def _is_in_allowed_origins(allowed_origins: List[str], origin: str) -> bool: + """Returns true if the `origin` is in the `allowed_origins`.""" + for allowed_origin in allowed_origins: + if allowed_origin == "*" or origin == allowed_origin: + return True + + # performance wise, this is not very heavy because most of the regular requests will match above + # this would be executed mostly when rejecting or actually using content served by CloudFront or S3 website + for dynamic_origin in DYNAMIC_INTERNAL_ORIGINS: + match = dynamic_origin.match(origin) + if ( + match + and (match.group(2) in _ALLOWED_INTERNAL_DOMAINS) + and (not (port := match.group(3)) or int(port[1:]) in _ALLOWED_INTERNAL_PORTS) + ): + return True + + return False + + +class CorsResponseEnricher(Handler): + """ + ResponseHandler which adds Cross-Origin-Request-Sharing (CORS) headers (Access-Control-*) to the response. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + headers = response.headers + # Remove empty CORS headers + for header in ALLOWED_CORS_RESPONSE_HEADERS: + if headers.get(header) == "": + del headers[header] + + request_headers = context.request.headers + # CORS headers should only be returned when an Origin header is set. + # use DISABLE_CORS_HEADERS to disable returning CORS headers entirely (more restrictive security setting) + # also don't add CORS response headers if the service manages the CORS handling + if ( + "Origin" not in request_headers + or config.DISABLE_CORS_HEADERS + or not should_enforce_self_managed_service(context) + ): + return + + self.add_cors_headers(request_headers, response_headers=headers) + + @staticmethod + def add_cors_headers(request_headers: Headers, response_headers: Headers): + if ACL_ORIGIN not in response_headers: + response_headers[ACL_ORIGIN] = ( + request_headers["Origin"] + if request_headers.get("Origin") and not config.DISABLE_CORS_CHECKS + else "*" + ) + if "*" not in response_headers.get(ACL_ORIGIN, ""): + response_headers[ACL_CREDENTIALS] = "true" + if ACL_METHODS not in response_headers: + response_headers[ACL_METHODS] = ",".join(CORS_ALLOWED_METHODS) + if ACL_ALLOW_HEADERS not in response_headers: + requested_headers = response_headers.get(ACL_REQUEST_HEADERS, "") + requested_headers = re.split(r"[,\s]+", requested_headers) + CORS_ALLOWED_HEADERS + response_headers[ACL_ALLOW_HEADERS] = ",".join([h for h in requested_headers if h]) + if ACL_EXPOSE_HEADERS not in response_headers: + response_headers[ACL_EXPOSE_HEADERS] = ",".join(CORS_EXPOSE_HEADERS) + if ( + request_headers.get(ACL_REQUEST_PRIVATE_NETWORK) == "true" + and ACL_ALLOW_PRIVATE_NETWORK not in response_headers + ): + response_headers[ACL_ALLOW_PRIVATE_NETWORK] = "true" + + # we conditionally apply CORS headers depending on the Origin, so add it to `Vary` + response_headers["Vary"] = "Origin" diff --git a/localstack-core/localstack/aws/handlers/fallback.py b/localstack-core/localstack/aws/handlers/fallback.py new file mode 100644 index 0000000000000..17c30e1a2bbb7 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/fallback.py @@ -0,0 +1,48 @@ +"""Handlers for fallback logic, e.g., populating empty requests or defaulting with default exceptions.""" + +import logging + +from rolo.gateway.handlers import EmptyResponseHandler +from werkzeug.exceptions import HTTPException + +from localstack.http import Response + +from ..api import RequestContext +from ..chain import ExceptionHandler, HandlerChain + +__all__ = ["EmptyResponseHandler", "InternalFailureHandler"] + +LOG = logging.getLogger(__name__) + + +class InternalFailureHandler(ExceptionHandler): + """ + Exception handler that returns a generic error message if there was an exception and there is no response set yet. + """ + + def __call__( + self, + chain: HandlerChain, + exception: Exception, + context: RequestContext, + response: Response, + ): + if response.data: + # response already set + return + + if isinstance(exception, HTTPException): + response.status_code = exception.code + response.headers.update(exception.get_headers()) + response.set_json({"error": exception.name, "message": exception.description}) + return + + LOG.debug("setting internal failure response for %s", exception) + response.status_code = 500 + response.set_json( + { + "error": "Unexpected exception", + "message": str(exception), + "type": str(exception.__class__.__name__), + } + ) diff --git a/localstack-core/localstack/aws/handlers/internal.py b/localstack-core/localstack/aws/handlers/internal.py new file mode 100644 index 0000000000000..ac89d0af1748e --- /dev/null +++ b/localstack-core/localstack/aws/handlers/internal.py @@ -0,0 +1,54 @@ +"""Handler for routing internal localstack resources under /_localstack.""" + +import logging + +from werkzeug.exceptions import NotFound + +from localstack import constants +from localstack.http import Response +from localstack.runtime import events +from localstack.services.internal import LocalstackResources + +from ..api import RequestContext +from ..chain import Handler, HandlerChain + +LOG = logging.getLogger(__name__) + + +class LocalstackResourceHandler(Handler): + """ + Adapter to serve LocalstackResources as a Handler. + """ + + resources: LocalstackResources + + def __init__(self, resources: LocalstackResources = None) -> None: + from localstack.services.internal import get_internal_apis + + self.resources = resources or get_internal_apis() + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + try: + # serve + response.update_from(self.resources.dispatch(context.request)) + chain.stop() + except NotFound: + path = context.request.path + if path.startswith(constants.INTERNAL_RESOURCE_PATH + "/"): + # only return 404 if we're accessing an internal resource, otherwise fall back to the other handlers + LOG.warning("Unable to find resource handler for path: %s", path) + chain.respond(404) + + +class RuntimeShutdownHandler(Handler): + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if events.infra_stopped.is_set(): + chain.respond(503) + elif events.infra_stopping.is_set(): + # if we're in the process of shutting down the infrastructure, only accept internal calls, or calls to + # internal APIs + if context.is_internal_call: + return + if context.request.path.startswith("/_localstack"): + return + chain.respond(503) diff --git a/localstack-core/localstack/aws/handlers/internal_requests.py b/localstack-core/localstack/aws/handlers/internal_requests.py new file mode 100644 index 0000000000000..9e4b0c35fe77b --- /dev/null +++ b/localstack-core/localstack/aws/handlers/internal_requests.py @@ -0,0 +1,26 @@ +import logging +from types import MappingProxyType + +from localstack.http import Response + +from ..api import RequestContext +from ..chain import Handler, HandlerChain +from ..connect import INTERNAL_REQUEST_PARAMS_HEADER, load_dto + +LOG = logging.getLogger(__name__) + + +class InternalRequestParamsEnricher(Handler): + """ + This handler sets the internal call DTO in the request context. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if header := context.request.headers.get(INTERNAL_REQUEST_PARAMS_HEADER): + try: + dto = MappingProxyType(load_dto(header)) + except Exception as e: + LOG.exception("Error loading request parameters '%s', Error: %s", header, e) + return + + context.internal_request_params = dto diff --git a/localstack-core/localstack/aws/handlers/legacy.py b/localstack-core/localstack/aws/handlers/legacy.py new file mode 100644 index 0000000000000..fe8374c1e4b5b --- /dev/null +++ b/localstack-core/localstack/aws/handlers/legacy.py @@ -0,0 +1,33 @@ +"""Handlers for compatibility with legacy thread local storages.""" + +import logging + +from localstack import config +from localstack.http import Response + +from ..api import RequestContext +from ..chain import HandlerChain +from .routes import RouterHandler + +LOG = logging.getLogger(__name__) + + +def set_close_connection_header(_chain: HandlerChain, context: RequestContext, response: Response): + """This is a hack to work around performance issues with h11 and boto. See + https://github.com/localstack/localstack/issues/6557""" + if config.GATEWAY_SERVER != "hypercorn": + return + if conn := context.request.headers.get("Connection"): + if conn.lower() == "keep-alive": + # don't set Connection: close header if keep-alive is explicitly asked for + return + + if "Connection" not in response.headers: + response.headers["Connection"] = "close" + + +class EdgeRouterHandler(RouterHandler): + def __init__(self, respond_not_found=False) -> None: + from localstack.services.edge import ROUTER + + super().__init__(ROUTER, respond_not_found) diff --git a/localstack-core/localstack/aws/handlers/logging.py b/localstack-core/localstack/aws/handlers/logging.py new file mode 100644 index 0000000000000..2113b67fa5176 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/logging.py @@ -0,0 +1,154 @@ +"""Handlers for logging.""" + +import logging +from functools import cached_property +from typing import Type + +from localstack.aws.api import RequestContext, ServiceException +from localstack.aws.chain import ExceptionHandler, HandlerChain +from localstack.http import Response +from localstack.http.request import restore_payload +from localstack.logging.format import AwsTraceLoggingFormatter, TraceLoggingFormatter +from localstack.logging.setup import create_default_handler + +LOG = logging.getLogger(__name__) + + +class ExceptionLogger(ExceptionHandler): + """ + Logs exceptions into a logger. + """ + + def __init__(self, logger=None): + self.logger = logger or LOG + + def __call__( + self, + chain: HandlerChain, + exception: Exception, + context: RequestContext, + response: Response, + ): + if isinstance(exception, ServiceException): + # We do not want to log an error/stacktrace if the handler is working as expected, but chooses to throw + # a service exception + return + if self.logger.isEnabledFor(level=logging.DEBUG): + self.logger.exception("exception during call chain", exc_info=exception) + else: + self.logger.error("exception during call chain: %s", exception) + + +class ResponseLogger: + def __call__(self, _: HandlerChain, context: RequestContext, response: Response): + if context.request.path == "/health" or context.request.path == "/_localstack/health": + # special case so the health check doesn't spam the logs + return + self._log(context, response) + + @cached_property + def aws_logger(self): + return self._prepare_logger( + logging.getLogger("localstack.request.aws"), formatter=AwsTraceLoggingFormatter + ) + + @cached_property + def http_logger(self): + return self._prepare_logger( + logging.getLogger("localstack.request.http"), formatter=TraceLoggingFormatter + ) + + @cached_property + def internal_aws_logger(self): + return self._prepare_logger( + logging.getLogger("localstack.request.internal.aws"), formatter=AwsTraceLoggingFormatter + ) + + @cached_property + def internal_http_logger(self): + return self._prepare_logger( + logging.getLogger("localstack.request.internal.http"), formatter=TraceLoggingFormatter + ) + + # make sure loggers are loaded after logging config is loaded + def _prepare_logger(self, logger: logging.Logger, formatter: Type): + if logger.isEnabledFor(logging.DEBUG): + logger.propagate = False + handler = create_default_handler(logger.level) + handler.setFormatter(formatter()) + logger.addHandler(handler) + return logger + + def _log(self, context: RequestContext, response: Response): + aws_logger = self.aws_logger + http_logger = self.http_logger + if context.is_internal_call: + aws_logger = self.internal_aws_logger + http_logger = self.internal_http_logger + if context.operation: + # log an AWS response + if context.service_exception: + aws_logger.info( + "AWS %s.%s => %d (%s)", + context.service.service_name, + context.operation.name, + response.status_code, + context.service_exception.code, + extra={ + # context + "account_id": context.account_id, + "region": context.region, + # request + "input_type": context.operation.input_shape.name + if context.operation.input_shape + else "Request", + "input": context.service_request, + "request_headers": dict(context.request.headers), + # response + "output_type": context.service_exception.code, + "output": context.service_exception.message, + "response_headers": dict(response.headers), + }, + ) + else: + aws_logger.info( + "AWS %s.%s => %s", + context.service.service_name, + context.operation.name, + response.status_code, + extra={ + # context + "account_id": context.account_id, + "region": context.region, + # request + "input_type": context.operation.input_shape.name + if context.operation.input_shape + else "Request", + "input": context.service_request, + "request_headers": dict(context.request.headers), + # response + "output_type": context.operation.output_shape.name + if context.operation.output_shape + else "Response", + "output": context.service_response, + "response_headers": dict(response.headers), + }, + ) + else: + # log any other HTTP response + http_logger.info( + "%s %s => %d", + context.request.method, + context.request.path, + response.status_code, + extra={ + # request + "input_type": "Request", + "input": restore_payload(context.request), + "request_headers": dict(context.request.headers), + # response + "output_type": "Response", + "output": "StreamingBody(unknown)" if response.is_streamed else response.data, + "response_headers": dict(response.headers), + }, + ) diff --git a/localstack-core/localstack/aws/handlers/metric_handler.py b/localstack-core/localstack/aws/handlers/metric_handler.py new file mode 100644 index 0000000000000..6a1ad8f16b982 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/metric_handler.py @@ -0,0 +1,201 @@ +import logging +from typing import List, Optional + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.chain import HandlerChain +from localstack.http import Response + +LOG = logging.getLogger(__name__) + + +class MetricHandlerItem: + """ + MetricHandlerItem to reference and update requests by the MetricHandler + """ + + request_id: str + request_context: RequestContext + parameters_after_parse: Optional[List[str]] + + def __init__(self, request_contex: RequestContext) -> None: + super().__init__() + self.request_id = str(hash(request_contex)) + self.request_context = request_contex + self.parameters_after_parse = None + + +class Metric: + """ + Data object to store relevant information for a metric entry in the raw-data collection (csv) + """ + + service: str + operation: str + headers: str + parameters: str + status_code: int + response_code: Optional[str] + exception: str + origin: str + xfail: bool + aws_validated: bool + snapshot: bool + node_id: str + + RAW_DATA_HEADER = [ + "service", + "operation", + "request_headers", + "parameters", + "response_code", + "response_data", + "exception", + "origin", + "test_node_id", + "xfail", + "aws_validated", + "snapshot", + "snapshot_skipped_paths", + ] + + def __init__( + self, + service: str, + operation: str, + headers: str, + parameters: str, + response_code: int, + response_data: str, + exception: str, + origin: str, + node_id: str = "", + xfail: bool = False, + aws_validated: bool = False, + snapshot: bool = False, + snapshot_skipped_paths: str = "", + ) -> None: + self.service = service + self.operation = operation + self.headers = headers + self.parameters = parameters + self.response_code = response_code + self.response_data = response_data + self.exception = exception + self.origin = origin + self.node_id = node_id + self.xfail = xfail + self.aws_validated = aws_validated + self.snapshot = snapshot + self.snapshot_skipped_paths = snapshot_skipped_paths + + def __iter__(self): + return iter( + [ + self.service, + self.operation, + self.headers, + self.parameters, + self.response_code, + self.response_data, + self.exception, + self.origin, + self.node_id, + self.xfail, + self.aws_validated, + self.snapshot, + self.snapshot_skipped_paths, + ] + ) + + def __eq__(self, other): + # ignore header in comparison, because timestamp will be different + if self.service != other.service: + return False + if self.operation != other.operation: + return False + if self.parameters != other.parameters: + return False + if self.response_code != other.response_code: + return False + if self.response_data != other.response_data: + return False + if self.exception != other.exception: + return False + if self.origin != other.origin: + return False + if self.xfail != other.xfail: + return False + if self.aws_validated != other.aws_validated: + return False + if self.node_id != other.node_id: + return False + return True + + +class MetricHandler: + metric_data: List[Metric] = [] + + def __init__(self) -> None: + self.metrics_handler_items = {} + + def create_metric_handler_item( + self, chain: HandlerChain, context: RequestContext, response: Response + ): + if not config.is_collect_metrics_mode(): + return + item = MetricHandlerItem(context) + self.metrics_handler_items[context] = item + + def _get_metric_handler_item_for_context(self, context: RequestContext) -> MetricHandlerItem: + return self.metrics_handler_items[context] + + def record_parsed_request( + self, chain: HandlerChain, context: RequestContext, response: Response + ): + if not config.is_collect_metrics_mode(): + return + item = self._get_metric_handler_item_for_context(context) + item.parameters_after_parse = ( + list(context.service_request.keys()) if context.service_request else [] + ) + + def record_exception( + self, chain: HandlerChain, exception: Exception, context: RequestContext, response: Response + ): + if not config.is_collect_metrics_mode(): + return + item = self._get_metric_handler_item_for_context(context) + item.caught_exception_name = exception.__class__.__name__ + + def update_metric_collection( + self, chain: HandlerChain, context: RequestContext, response: Response + ): + if not config.is_collect_metrics_mode() or not context.service_operation: + return + + item = self._get_metric_handler_item_for_context(context) + + # parameters might get changed when dispatched to the service - we use the params stored in + # parameters_after_parse + parameters = ",".join(item.parameters_after_parse or []) + + response_data = response.data.decode("utf-8") if response.status_code >= 300 else "" + metric = Metric( + service=context.service_operation.service, + operation=context.service_operation.operation, + headers=context.request.headers, + parameters=parameters, + response_code=response.status_code, + response_data=response_data, + exception=context.service_exception.__class__.__name__ + if context.service_exception + else "", + origin="internal" if context.is_internal_call else "external", + ) + # refrain from adding duplicates + if metric not in MetricHandler.metric_data: + MetricHandler.metric_data.append(metric) + + # cleanup + del self.metrics_handler_items[context] diff --git a/localstack-core/localstack/aws/handlers/presigned_url.py b/localstack-core/localstack/aws/handlers/presigned_url.py new file mode 100644 index 0000000000000..153aef1521bd9 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/presigned_url.py @@ -0,0 +1,23 @@ +from localstack.http import Response +from localstack.services.s3.presigned_url import S3PreSignedURLRequestHandler + +from ..api import RequestContext +from ..chain import Handler, HandlerChain + + +class ParsePreSignedUrlRequest(Handler): + def __init__(self): + self.pre_signed_handlers: dict[str, Handler] = { + "s3": S3PreSignedURLRequestHandler(), + } + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # TODO: handle other services pre-signed URL (CloudFront) + if not context.service: + return + + # we are handling the pre-signed URL before parsing, because S3 will append typical headers parameters to + # the querystring when generating a pre-signed URL. This handler will move them back into the headers before + # the parsing of the request happens + if handler := self.pre_signed_handlers.get(context.service.service_name): + handler(chain, context, response) diff --git a/localstack-core/localstack/aws/handlers/proxy.py b/localstack-core/localstack/aws/handlers/proxy.py new file mode 100644 index 0000000000000..72f68ec593e79 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/proxy.py @@ -0,0 +1,20 @@ +from ...http import Response +from ...http.proxy import Proxy +from ..api import RequestContext +from ..chain import Handler, HandlerChain + + +class ProxyHandler(Handler): + """ + Directly serves a localstack.http.proxy.Proxy as a HandlerChain Handler. + This handler does not command the handler chain to stop or terminate. + """ + + proxy: Proxy + + def __init__(self, forward_base_url: str) -> None: + self.proxy = Proxy(forward_base_url) + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + proxy_response = self.proxy.forward(context.request) + response.update_from(proxy_response) diff --git a/localstack-core/localstack/aws/handlers/region.py b/localstack-core/localstack/aws/handlers/region.py new file mode 100644 index 0000000000000..492539e0c6e9c --- /dev/null +++ b/localstack-core/localstack/aws/handlers/region.py @@ -0,0 +1,103 @@ +import abc +import logging +import re +from functools import cached_property + +from boto3.session import Session + +from localstack.http import Request, Response +from localstack.utils.aws.arns import get_partition + +from ..api import RequestContext +from ..chain import Handler, HandlerChain + +LOG = logging.getLogger(__name__) + + +class RegionContextEnricher(Handler): + """ + A handler that sets the AWS region of the request in the RequestContext. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + context.region = self.get_region(context.request) + context.partition = get_partition(context.region) + + @staticmethod + def get_region(request: Request) -> str: + from localstack.utils.aws.request_context import extract_region_from_headers + + return extract_region_from_headers(request.headers) + + +class RegionRewriterStrategy(abc.ABC): + @abc.abstractmethod + def apply(self, context: RequestContext): + """ + Apply the region rewriter to the request context + :param context: Request Context + """ + pass + + +class DefaultRegionRewriterStrategy(RegionRewriterStrategy): + """ + If a region is not known, override it to "us-east-1" + """ + + default_region = "us-east-1" + + def apply(self, context: RequestContext): + if not context.region: + return + + if context.region not in self.available_regions: + LOG.warning( + "Region '%s' is not available. Resetting the region to 'us-east-1'. " + "Please consider using a region in the 'aws' partition to avoid any unexpected behavior. " + "Available regions: %s", + context.region, + self.available_regions, + ) + context.region = self.default_region + context.partition = "aws" + self.rewrite_auth_header(context, self.default_region) + + def rewrite_auth_header(self, context: RequestContext, region: str): + """ + Rewrites the `Authorization` header to reflect the specified region. + :param context: Request context + :param region: Region to rewrite the `Authorization` header to. + """ + auth_header = context.request.headers.get("Authorization") + + if auth_header: + regex = r"Credential=([^/]+)/([^/]+)/([^/]+)/" + auth_header = re.sub(regex, rf"Credential=\1/\2/{region}/", auth_header) + context.request.headers["Authorization"] = auth_header + + @cached_property + def available_regions(self) -> list[str]: + """ + Returns a list of supported regions. + :return: List of regions in the `aws` partition. + """ + # We cannot cache the session here, as it is not thread safe. As the entire method is cached, this should not + # have a significant impact. + # using s3 as "everywhere available" service, as it usually is supported in all regions + # the S3 image also deletes other botocore specifications, so it is the easiest possibility + return Session().get_available_regions("s3", "aws") + + +class RegionRewriter(Handler): + """ + A handler that ensures the region being in a list of allowed regions + """ + + region_rewriter_strategy: RegionRewriterStrategy + + def __init__(self): + self.region_rewriter_strategy = DefaultRegionRewriterStrategy() + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + self.region_rewriter_strategy.apply(context) diff --git a/localstack-core/localstack/aws/handlers/response.py b/localstack-core/localstack/aws/handlers/response.py new file mode 100644 index 0000000000000..65046f164d9d7 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/response.py @@ -0,0 +1,28 @@ +import logging + +from localstack import config, constants +from localstack.aws.api import RequestContext +from localstack.aws.chain import Handler, HandlerChain +from localstack.http import Response +from localstack.runtime import hooks + +LOG = logging.getLogger(__name__) + + +class ResponseMetadataEnricher(Handler): + """ + A handler that adds extra metadata to a Response. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # Currently, we just add 'x-localstack' in response headers. + response.headers[constants.HEADER_LOCALSTACK_IDENTIFIER] = "true" + + +@hooks.on_infra_start(should_load=config.LOCALSTACK_RESPONSE_HEADER_ENABLED) +def init_response_mutation_handler(): + from localstack.aws.handlers import run_custom_response_handlers + + # inject enricher into handler chain + enricher = ResponseMetadataEnricher() + run_custom_response_handlers.append(enricher) diff --git a/localstack-core/localstack/aws/handlers/routes.py b/localstack-core/localstack/aws/handlers/routes.py new file mode 100644 index 0000000000000..57114ff569d33 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/routes.py @@ -0,0 +1,5 @@ +from rolo.gateway.handlers import RouterHandler + +__all__ = [ + "RouterHandler", +] diff --git a/localstack-core/localstack/aws/handlers/service.py b/localstack-core/localstack/aws/handlers/service.py new file mode 100644 index 0000000000000..edef0699c3539 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/service.py @@ -0,0 +1,299 @@ +"""A set of common handlers to parse and route AWS service requests.""" + +import logging +import traceback +from collections import defaultdict +from typing import Any, Dict, Union + +from botocore.model import OperationModel, ServiceModel + +from localstack import config +from localstack.http import Response +from localstack.utils.coverage_docs import get_coverage_link_for_service + +from ..api import CommonServiceException, RequestContext, ServiceException +from ..api.core import ServiceOperation +from ..chain import CompositeResponseHandler, ExceptionHandler, Handler, HandlerChain +from ..client import parse_response, parse_service_exception +from ..protocol.parser import RequestParser, create_parser +from ..protocol.serializer import create_serializer +from ..protocol.service_router import determine_aws_service_model +from ..skeleton import Skeleton, create_skeleton + +LOG = logging.getLogger(__name__) + + +class ServiceNameParser(Handler): + """ + A handler that parses heuristically from the request the AWS service the request is addressed to. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # Some early handlers can already determine the AWS service the request is directed to (the S3 CORS handler for + # example). If it is already set, we can skip the parsing of the request. It is very important for S3, because + # parsing the request will consume the data stream and prevent streaming. + if context.service: + return + + service_model = determine_aws_service_model(context.request) + + if not service_model: + return + + context.service = service_model + + +class ServiceRequestParser(Handler): + """ + A Handler that parses the service request operation and the instance from a Request. Requires the service to + already be resolved in the RequestContext (e.g., through a ServiceNameParser) + """ + + parsers: Dict[str, RequestParser] + + def __init__(self): + self.parsers = dict() + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # determine service + if not context.service: + LOG.debug("no service set in context, skipping request parsing") + return + + return self.parse_and_enrich(context) + + def parse_and_enrich(self, context: RequestContext): + parser = create_parser(context.service) + operation, instance = parser.parse(context.request) + + # enrich context + context.operation = operation + context.service_request = instance + + +class SkeletonHandler(Handler): + """ + Expose a Skeleton as a Handler. + """ + + def __init__(self, skeleton: Skeleton): + self.skeleton = skeleton + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + skeleton_response = self.skeleton.invoke(context) + response.update_from(skeleton_response) + + +class ServiceRequestRouter(Handler): + """ + Routes ServiceOperations to Handlers. + """ + + handlers: Dict[ServiceOperation, Handler] + + def __init__(self): + self.handlers = dict() + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if not context.service: + return + + service_name = context.service.service_name + operation_name = context.operation.name + + key = ServiceOperation(service_name, operation_name) + + handler = self.handlers.get(key) + if not handler: + error = self.create_not_implemented_response(context) + response.update_from(error) + chain.stop() + return + + handler(chain, context, response) + + def add_handler(self, key: ServiceOperation, handler: Handler): + if key in self.handlers: + LOG.warning("overwriting existing route for %s", key) + + self.handlers[key] = handler + + def add_provider(self, provider: Any, service: Union[str, ServiceModel]): + self.add_skeleton(create_skeleton(service, provider)) + + def add_skeleton(self, skeleton: Skeleton): + """ + Creates for each entry in the dispatch table of the skeleton a new route. + """ + service = skeleton.service.service_name + handler = SkeletonHandler(skeleton) + + for operation in skeleton.dispatch_table.keys(): + self.add_handler(ServiceOperation(service, operation), handler) + + def create_not_implemented_response(self, context): + operation = context.operation + service_name = operation.service_model.service_name + operation_name = operation.name + message = f"no handler for operation '{operation_name}' on service '{service_name}'" + error = CommonServiceException("InternalFailure", message, status_code=501) + serializer = create_serializer(context.service) + return serializer.serialize_error_to_response( + error, operation, context.request.headers, context.request_id + ) + + +class ServiceExceptionSerializer(ExceptionHandler): + """ + Exception handler that serializes the exception of AWS services. + """ + + handle_internal_failures: bool + + def __init__(self): + self.handle_internal_failures = True + + def __call__( + self, + chain: HandlerChain, + exception: Exception, + context: RequestContext, + response: Response, + ): + if not context.service: + return + + error = self.create_exception_response(exception, context) + if error: + response.update_from(error) + + def create_exception_response(self, exception: Exception, context: RequestContext): + operation = context.operation + service_name = context.service.service_name + error = exception + + if operation and isinstance(exception, NotImplementedError): + action_name = operation.name + exception_message: str | None = exception.args[0] if exception.args else None + message = exception_message or get_coverage_link_for_service(service_name, action_name) + LOG.info(message) + error = CommonServiceException("InternalFailure", message, status_code=501) + context.service_exception = error + + elif not isinstance(exception, ServiceException): + if not self.handle_internal_failures: + return + + if config.DEBUG: + exception = "".join( + traceback.format_exception( + type(exception), value=exception, tb=exception.__traceback__ + ) + ) + + # wrap exception for serialization + if operation: + operation_name = operation.name + msg = "exception while calling %s.%s: %s" % ( + service_name, + operation_name, + exception, + ) + else: + # just use any operation for mocking purposes (the parser needs it to populate the default response) + operation = context.service.operation_model(context.service.operation_names[0]) + msg = "exception while calling %s with unknown operation: %s" % ( + service_name, + exception, + ) + + status_code = 501 if config.FAIL_FAST else 500 + + error = CommonServiceException("InternalError", msg, status_code=status_code) + context.service_exception = error + + serializer = create_serializer(context.service) # TODO: serializer cache + return serializer.serialize_error_to_response( + error, operation, context.request.headers, context.request_id + ) + + +class ServiceResponseParser(Handler): + """ + This response handler makes sure that, if the current request in an AWS request, that either ``service_response`` + or ``service_exception`` of ``RequestContext`` is set to something sensible before other downstream response + handlers are called. When the Skeleton invokes an ASF-native provider, this will mostly return immediately + because the skeleton sets the service response directly to what comes out of the provider. When responses come + back from backends like Moto, we may need to parse the raw HTTP response, since we sometimes proxy directly. If + the ``service_response`` is an error, then we parse the response and create an appropriate exception from the + error response. If ``service_exception`` is set, then we also try to make sure the exception attributes like + code, sender_fault, and message have values. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if not context.operation: + return + + if context.service_response: + return + + if exception := context.service_exception: + if isinstance(exception, ServiceException): + if not hasattr(exception, "code"): + # FIXME: we should set the exception attributes in the scaffold when we generate the exceptions. + # this is a workaround for now, since we are not doing that yet, and the attributes may be unset. + self._set_exception_attributes(context.operation, exception) + return + # this shouldn't happen, but we'll log a warning anyway + else: + LOG.warning("Cannot parse exception %s", context.service_exception) + return + + if response.content_length is None or context.operation.has_event_stream_output: + # cannot/should not parse streaming responses + context.service_response = {} + return + + # in this case we need to parse the raw response + parsed = parse_response(context.operation, response, include_response_metadata=False) + if service_exception := parse_service_exception(response, parsed): + context.service_exception = service_exception + else: + context.service_response = parsed + + @staticmethod + def _set_exception_attributes(operation: OperationModel, error: ServiceException): + """Sets the code, sender_fault, and status_code attributes of the ServiceException from the shape.""" + error_shape_name = error.__class__.__name__ + shape = operation.service_model.shape_for(error_shape_name) + error_spec = shape.metadata.get("error", {}) + error.code = error_spec.get("code", shape.name) + error.sender_fault = error_spec.get("senderFault", False) + error.status_code = error_spec.get("httpStatusCode", 400) + + +class ServiceResponseHandlers(Handler): + """ + A handler that triggers a CompositeResponseHandler based on an association with a particular service. Handlers + are only called if the request context has a service, and there are handlers for that particular service. + """ + + handlers: Dict[str, CompositeResponseHandler] + + def __init__(self): + self.handlers = defaultdict(CompositeResponseHandler) + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if not context.service: + return + + if service_handler := self.handlers.get(context.service.service_name): + service_handler(chain, context, response) + + def append(self, service: str, handler: Handler): + """ + Appends a given handler to the list of service handlers. + :param service: the service name, e.g., "dynamodb", or "sqs" + :param handler: the handler to attach + """ + self.handlers[service].append(handler) diff --git a/localstack-core/localstack/aws/handlers/service_plugin.py b/localstack-core/localstack/aws/handlers/service_plugin.py new file mode 100644 index 0000000000000..c28bbb7e341c1 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/service_plugin.py @@ -0,0 +1,89 @@ +"""Handlers extending the base logic of service handlers with lazy-loading and plugin mechanisms.""" + +import logging +import threading + +from localstack.http import Response +from localstack.services.plugins import Service, ServiceManager +from localstack.utils.sync import SynchronizedDefaultDict + +from ...utils.bootstrap import is_api_enabled +from ..api import RequestContext +from ..chain import Handler, HandlerChain +from ..protocol.service_router import determine_aws_service_model_for_data_plane +from .service import ServiceRequestRouter + +LOG = logging.getLogger(__name__) + + +class ServiceLoader(Handler): + def __init__( + self, service_manager: ServiceManager, service_request_router: ServiceRequestRouter + ): + """ + This handler encapsulates service lazy-loading. It loads services from the given ServiceManager and uses them + to populate the given ServiceRequestRouter. + + :param service_manager: the service manager used to load services + :param service_request_router: the service request router to populate + """ + self.service_manager = service_manager + self.service_request_router = service_request_router + self.service_locks = SynchronizedDefaultDict(threading.RLock) + self.loaded_services = set() + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + return self.require_service(chain, context, response) + + def require_service(self, _: HandlerChain, context: RequestContext, response: Response): + if not context.service: + return + + service_name: str = context.service.service_name + if service_name in self.loaded_services: + return + + if not self.service_manager.exists(service_name): + raise NotImplementedError + elif not is_api_enabled(service_name): + raise NotImplementedError( + f"Service '{service_name}' is not enabled. Please check your 'SERVICES' configuration variable." + ) + + request_router = self.service_request_router + + # Ensure the Service is loaded and set to ServiceState.RUNNING if not in an erroneous state. + service_plugin: Service = self.service_manager.require(service_name) + + with self.service_locks[context.service.service_name]: + # try again to avoid race conditions + if service_name in self.loaded_services: + return + self.loaded_services.add(service_name) + if isinstance(service_plugin, Service): + request_router.add_skeleton(service_plugin.skeleton) + else: + LOG.warning( + "found plugin for '%s', but cannot attach service plugin of type '%s'", + service_name, + type(service_plugin), + ) + + +class ServiceLoaderForDataPlane(Handler): + """ + Specific lightweight service loader that loads services based only on hostname indicators. This allows + us to correctly load services when things like lambda function URLs or APIGW REST APIs are called + before the services were actually loaded. + """ + + def __init__(self, service_loader: ServiceLoader): + self.service_loader = service_loader + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if context.service: + return + + if service := determine_aws_service_model_for_data_plane(context.request): + context.service = service + self.service_loader.require_service(chain, context, response) diff --git a/localstack-core/localstack/aws/handlers/tracing.py b/localstack-core/localstack/aws/handlers/tracing.py new file mode 100644 index 0000000000000..eaea78b8c15d3 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/tracing.py @@ -0,0 +1,23 @@ +from localstack.aws.api import RequestContext +from localstack.aws.chain import Handler, HandlerChain +from localstack.http import Response +from localstack.utils.xray.trace_header import TraceHeader + + +class TraceContextParser(Handler): + """ + A handler that parses trace context headers, including: + * AWS X-Ray trace header: https://docs.aws.amazon.com/xray/latest/devguide/xray-concepts.html#xray-concepts-tracingheader + X-Amzn-Trace-Id: Root=1-5759e988-bd862e3fe1be46a994272793;Sampled=1;Lineage=a87bd80c:1|68fd508a:5|c512fbe3:2 + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # The Werkzeug headers data structure handles case-insensitive HTTP header matching (verified manually) + trace_header_str = context.request.headers.get("X-Amzn-Trace-Id") + # The minimum X-Ray header only contains a Root trace id, missing Sampled and Parent + aws_trace_header = TraceHeader.from_header_str(trace_header_str).ensure_root_exists() + # Naming aws_trace_header inspired by AWSTraceHeader convention for SQS: + # https://docs.aws.amazon.com/xray/latest/devguide/xray-services-sqs.html + context.trace_context["aws_trace_header"] = aws_trace_header + # NOTE: X-Ray sampling might require service-specific decisions: + # https://docs.aws.amazon.com/xray/latest/devguide/xray-console-sampling.html diff --git a/localstack-core/localstack/aws/handlers/validation.py b/localstack-core/localstack/aws/handlers/validation.py new file mode 100644 index 0000000000000..ebfe1da064358 --- /dev/null +++ b/localstack-core/localstack/aws/handlers/validation.py @@ -0,0 +1,100 @@ +""" +Handlers for validating request and response schema against OpenAPI specs. +""" + +import logging + +from openapi_core import OpenAPI +from openapi_core.contrib.werkzeug import WerkzeugOpenAPIRequest, WerkzeugOpenAPIResponse +from openapi_core.exceptions import OpenAPIError +from openapi_core.validation.request.exceptions import ( + RequestValidationError, +) +from openapi_core.validation.response.exceptions import ResponseValidationError +from plux import PluginManager + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.chain import Handler, HandlerChain +from localstack.constants import INTERNAL_RESOURCE_PATH +from localstack.http import Response + +LOG = logging.getLogger(__name__) + + +class OpenAPIValidator(Handler): + open_apis: list["OpenAPI"] + + def __init__(self) -> None: + self._load_specs() + + def _load_specs(self) -> None: + """Load the openapi spec plugins iff at least one between request and response validation is set.""" + if not (config.OPENAPI_VALIDATE_REQUEST or config.OPENAPI_VALIDATE_RESPONSE): + return + specs = PluginManager("localstack.openapi.spec").load_all() + self.open_apis = [] + for spec in specs: + self.open_apis.append(OpenAPI.from_path(spec.spec_path)) + + +class OpenAPIRequestValidator(OpenAPIValidator): + """ + Validates the requests to the LocalStack public endpoints (the ones with a _localstack or _aws prefix) against + a OpenAPI specification. + """ + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + if not config.OPENAPI_VALIDATE_REQUEST: + return + + hasattr(self, "open_apis") or self._load_specs() + path = context.request.path + + if path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/"): + for openapi in self.open_apis: + try: + openapi.validate_request(WerkzeugOpenAPIRequest(context.request)) + # We stop the handler at the first succeeded validation, as the other spec might not even specify + # this path. + break + except RequestValidationError as e: + # Note: in this handler we only check validation errors, e.g., wrong body, missing required. + response.status_code = 400 + response.set_json({"error": "Bad Request", "message": str(e)}) + chain.stop() + except OpenAPIError: + # Other errors can be raised when validating a request against the OpenAPI specification. + # The most common are: ServerNotFound, OperationNotFound, or PathNotFound. + # We explicitly do not check any other error but RequestValidationError ones. + # We shallow the exception to avoid excessive logging (e.g., a lot of ServerNotFound), as the only + # purpose of this handler is to check for request validation errors. + pass + + +class OpenAPIResponseValidator(OpenAPIValidator): + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + # The use of this flag is intended for test only. Eventual errors are due to LocalStack implementation and not + # to improper user usage of the endpoints. + if not config.OPENAPI_VALIDATE_RESPONSE: + return + + hasattr(self, "open_apis") or self._load_specs() + path = context.request.path + + if path.startswith(f"{INTERNAL_RESOURCE_PATH}/") or path.startswith("/_aws/"): + for openapi in self.open_apis: + try: + openapi.validate_response( + WerkzeugOpenAPIRequest(context.request), + WerkzeugOpenAPIResponse(response), + ) + break + except ResponseValidationError as exc: + LOG.error("Response validation failed for %s: %s", path, exc) + response.status_code = 500 + response.set_json({"error": exc.__class__.__name__, "message": str(exc)}) + chain.terminate() + except OpenAPIError: + # Same logic from the request validator applies here. + pass diff --git a/localstack-core/localstack/aws/mocking.py b/localstack-core/localstack/aws/mocking.py new file mode 100644 index 0000000000000..2231b76eddbfb --- /dev/null +++ b/localstack-core/localstack/aws/mocking.py @@ -0,0 +1,433 @@ +import logging +import math +import random +import re +from datetime import date, datetime +from functools import lru_cache, singledispatch +from typing import Dict, List, Optional, Set, Tuple, Union, cast + +import botocore +import networkx +import rstr +from botocore.model import ListShape, MapShape, OperationModel, Shape, StringShape, StructureShape + +from localstack.aws.api import RequestContext, ServiceRequest, ServiceResponse +from localstack.aws.skeleton import DispatchTable, ServiceRequestDispatcher, Skeleton +from localstack.aws.spec import load_service +from localstack.utils.sync import retry + +LOG = logging.getLogger(__name__) + +types = { + "timestamp", + "string", + "blob", + "map", + "list", + "long", + "structure", + "integer", + "double", + "float", + "boolean", +} + +Instance = Union[ + Dict[str, "Instance"], + List["Instance"], + str, + bytes, + map, + list, + float, + int, + bool, + date, +] + +# https://github.com/boto/botocore/issues/2623 +StringShape.METADATA_ATTRS.append("pattern") + +words = [ + # a few snazzy six-letter words + "snazzy", + "mohawk", + "poncho", + "proton", + "foobar", + "python", + "umlaut", + "except", + "global", + "latest", +] + +DEFAULT_ARN = "arn:aws:ec2:us-east-1:1234567890123:instance/i-abcde0123456789f" + + +class ShapeGraph(networkx.DiGraph): + root: Union[ListShape, StructureShape, MapShape] + cycle: List[Tuple[str, str]] + cycle_shapes: List[str] + + +def populate_graph(graph: networkx.DiGraph, root: Shape): + stack: List[Shape] = [root] + visited: Set[str] = set() + + while stack: + cur = stack.pop() + if cur is None: + continue + + if cur.name in visited: + continue + + visited.add(cur.name) + graph.add_node(cur.name, shape=cur) + + if isinstance(cur, ListShape): + graph.add_edge(cur.name, cur.member.name) + stack.append(cur.member) + elif isinstance(cur, StructureShape): + for member in cur.members.values(): + stack.append(member) + graph.add_edge(cur.name, member.name) + elif isinstance(cur, MapShape): + stack.append(cur.key) + stack.append(cur.value) + graph.add_edge(cur.name, cur.key.name) + graph.add_edge(cur.name, cur.value.name) + + else: # leaf types (int, string, bool, ...) + pass + + +def shape_graph(root: Shape) -> ShapeGraph: + graph = networkx.DiGraph() + graph.root = root + populate_graph(graph, root) + + cycles = list() + shapes = set() + for node in graph.nodes: + try: + cycle = networkx.find_cycle(graph, source=node) + for k, v in cycle: + shapes.add(k) + shapes.add(v) + + if cycle not in cycles: + cycles.append(cycle) + except networkx.NetworkXNoCycle: + pass + + graph.cycles = cycles + graph.cycle_shapes = list(shapes) + + return cast(ShapeGraph, graph) + + +def sanitize_pattern(pattern: str) -> str: + if pattern == "^(https|s3)://([^/]+)/?(.*)$": + pattern = "^(https|s3)://(\\w+)$" + pattern = pattern.replace("\\p{XDigit}", "[A-Fa-f0-9]") + pattern = pattern.replace("\\p{P}", "[.,;]") + pattern = pattern.replace("\\p{Punct}", "[.,;]") + pattern = pattern.replace("\\p{N}", "[0-9]") + pattern = pattern.replace("\\p{L}", "[A-Z]") + pattern = pattern.replace("\\p{LD}", "[A-Z]") + pattern = pattern.replace("\\p{Z}", "[ ]") + pattern = pattern.replace("\\p{S}", "[+\\u-*]") + pattern = pattern.replace("\\p{M}", "[`]") + pattern = pattern.replace("\\p{IsLetter}", "[a-zA-Z]") + pattern = pattern.replace("[:alnum:]", "[a-zA-Z0-9]") + pattern = pattern.replace("\\p{ASCII}*", "[a-zA-Z0-9]") + pattern = pattern.replace("\\p{Alnum}", "[a-zA-Z0-9]") + + if "\\p{" in pattern: + LOG.warning("Find potential additional pattern that need to be sanitized: %s", pattern) + return pattern + + +def sanitize_arn_pattern(pattern: str) -> str: + # clown emoji + + # some devs were just lazy ... + if pattern in [ + ".*", + "arn:.*", + "arn:.+", + "^arn:.+", + "arn:aws.*:*", + "^arn:aws.*", + "^arn:.*", + "arn:\\S+", + ".*\\S.*", + "^[A-Za-z0-9:\\/_-]*$", + "^arn[\\/\\:\\-\\_\\.a-zA-Z0-9]+$", + ".{0,1600}", + "^arn:[!-~]+$", + "[\\S]+", + "[\\s\\S]*", + "^([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)$", + "[a-zA-Z0-9_:\\-\\/]+", + ]: + pattern = "arn:aws:[a-z]{4}:us-east-1:[0-9]{12}:[a-z]{8}" + + # common pattern to describe a partition + pattern = pattern.replace("arn:[^:]*:", "arn:aws:") + pattern = pattern.replace("arn:[a-z\\d-]+", "arn:aws") + pattern = pattern.replace("arn:[\\w+=\\/,.@-]+", "arn:aws") + pattern = pattern.replace("arn:[a-z-]+?", "arn:aws") + pattern = pattern.replace("arn:[a-z0-9][-.a-z0-9]{0,62}", "arn:aws") + pattern = pattern.replace(":aws(-\\w+)*", ":aws") + pattern = pattern.replace(":aws[a-z\\-]*", ":aws") + pattern = pattern.replace(":aws(-[\\w]+)*", ":aws") + pattern = pattern.replace(":aws[^:\\s]*", ":aws") + pattern = pattern.replace(":aws[A-Za-z0-9-]{0,64}", ":aws") + # often the account-id + pattern = pattern.replace(":[0-9]+:", ":[0-9]{13}:") + pattern = pattern.replace(":\\w{12}:", ":[0-9]{13}:") + # substitutions + pattern = pattern.replace("[a-z\\-\\d]", "[a-z0-9]") + pattern = pattern.replace( + "[\\u0020-\\uD7FF\\uE000-\\uFFFD\\uD800\\uDC00-\\uDBFF\\uDFFF\\r\\n\\t]", "[a-z0-9]" + ) + pattern = pattern.replace("[\\w\\d-]", "[a-z0-9]") + pattern = pattern.replace("[\\w+=/,.@-]", "[a-z]") + pattern = pattern.replace("[^:]", "[a-z]") + pattern = pattern.replace("[^/]", "[a-z]") + pattern = pattern.replace("\\d+", "[0-9]+") + pattern = pattern.replace("\\d*", "[0-9]*") + pattern = pattern.replace("\\S+", "[a-z]{4}") + pattern = pattern.replace("\\d]", "0-9]") + pattern = pattern.replace("[a-z\\d", "[a-z0-9") + pattern = pattern.replace("[a-zA-Z\\d", "[a-z0-9") + pattern = pattern.replace("^$|", "") + pattern = pattern.replace("(^$)|", "") + pattern = pattern.replace("[:/]", "[a-z]") + pattern = pattern.replace("/.{", "/[a-z]{") + pattern = pattern.replace(".{", "[a-z]{") + pattern = pattern.replace("-*", "-") + pattern = pattern.replace("\\n", "") + pattern = pattern.replace("\\r", "") + # quantifiers + pattern = pattern.replace("{11}{0,1011}", "{11}") + pattern = pattern.replace("}+", "}") + pattern = pattern.replace("]*", "]{6}") + pattern = pattern.replace("]+", "]{6}") + pattern = pattern.replace(".*", "[a-z]{6}") + pattern = pattern.replace(".+", "[a-z]{6}") + + return pattern + + +custom_arns = { + "DeviceFarmArn": "arn:aws:devicefarm:us-east-1:1234567890123:mydevicefarm", + "KmsKeyArn": "arn:aws:kms:us-east-1:1234567890123:key/somekmskeythatisawesome", +} + + +@singledispatch +def generate_instance(shape: Shape, graph: ShapeGraph) -> Optional[Instance]: + if shape is None: + return None + raise ValueError("could not generate shape for type %s" % shape.type_name) + + +@generate_instance.register +def _(shape: StructureShape, graph: ShapeGraph) -> Dict[str, Instance]: + if shape.is_tagged_union: + k, v = random.choice(list(shape.members.items())) + members = {k: v} + else: + members = shape.members + + if shape.name in graph.cycle_shapes: + return {} + + return { + name: generate_instance(member_shape, graph) + for name, member_shape in members.items() + if member_shape.name != shape.name + } + + +@generate_instance.register +def _(shape: ListShape, graph: ShapeGraph) -> List[Instance]: + if shape.name in graph.cycle_shapes: + return [] + return [generate_instance(shape.member, graph) for _ in range(shape.metadata.get("min", 1))] + + +@generate_instance.register +def _(shape: MapShape, graph: ShapeGraph) -> Dict[str, Instance]: + if shape.name in graph.cycle_shapes: + return {} + return {generate_instance(shape.key, graph): generate_instance(shape.value, graph)} + + +def generate_arn(shape: StringShape): + if not shape.metadata: + return DEFAULT_ARN + + def _generate_arn(): + # some custom hacks + if shape.name in custom_arns: + return custom_arns[shape.name] + + max_len = shape.metadata.get("max") or math.inf + min_len = shape.metadata.get("min") or 0 + + pattern = shape.metadata.get("pattern") + if pattern: + # FIXME: also conforming to length may be difficult + pattern = sanitize_arn_pattern(pattern) + pattern = sanitize_pattern(pattern) + arn = rstr.xeger(pattern) + else: + arn = DEFAULT_ARN + + # if there's a value set for the region, replace with a randomly picked region + # TODO: splitting the ARNs here by ":" sometimes fails for some reason (e.g. or dynamodb for some reason) + arn_parts = arn.split(":") + if len(arn_parts) >= 4: + region = arn_parts[3] + if region: + # TODO: check service in ARN and try to get the actual region for the service + regions = botocore.session.Session().get_available_regions("lambda") + picked_region = random.choice(regions) + arn_parts[3] = picked_region + arn = ":".join(arn_parts) + + if len(arn) > max_len: + arn = arn[:max_len] + + if len(arn) < min_len or len(arn) > max_len: + raise ValueError( + f"generated arn {arn} for shape {shape.name} does not match constraints {shape.metadata}" + ) + + return arn + + return retry(_generate_arn, retries=10, sleep_before=0, sleep=0) + + +custom_strings = {"DailyTime": "12:10", "WeeklyTime": "1:12:10"} + + +@generate_instance.register +def _(shape: StringShape, graph: ShapeGraph) -> str: + if shape.enum: + return shape.enum[0] + + if shape.name in custom_strings: + return custom_strings[shape.name] + + if ( + shape.name.endswith("ARN") + or shape.name.endswith("Arn") + or shape.name.endswith("ArnString") + or shape.name == "AmazonResourceName" + ): + try: + return generate_arn(shape) + except re.error: + LOG.error( + "Could not generate arn pattern for %s, with pattern %s", + shape.name, + shape.metadata.get("pattern", "(no pattern set)"), + ) + return DEFAULT_ARN + + max_len: int = shape.metadata.get("max") or 256 + min_len: int = shape.metadata.get("min") or 0 + str_len = min(min_len or 6, max_len) + + pattern = shape.metadata.get("pattern") + + if not pattern or pattern in [".*", "^.*$", ".+"]: + if min_len <= 6 and max_len >= 6: + # pick a random six-letter word, to spice things up. this will be the case most of the time. + return random.choice(words) + else: + return "a" * str_len + if shape.name == "EndpointId" and pattern == "^[A-Za-z0-9\\-]+[\\.][A-Za-z0-9\\-]+$": + # there are sometimes issues with this pattern, because it could create invalid host labels, e.g. b6NOZqj5rIMdcta4IKyKRHvZakH90r.-wzuX6tQ-pB-pTNePY2 + # for simplification we just remove the dash for now + pattern = "^[A-Za-z0-9]+[\\.][A-Za-z0-9]+$" + pattern = sanitize_pattern(pattern) + + try: + # try to return something simple first + random_string = "a" * str_len + if re.match(pattern, random_string): + return random_string + + val = rstr.xeger(pattern) + # TODO: this will break the pattern if the string needs to end with something that we may cut off. + return val[: min(max_len, len(val))] + except re.error: + # TODO: this will likely break the pattern + LOG.error( + "Could not generate pattern for %s, with pattern %s", + shape.name, + shape.metadata.get("pattern", "(no pattern set)"), + ) + return "0" * str_len + + +@generate_instance.register +def _(shape: Shape, graph: ShapeGraph) -> Union[int, float, bool, bytes, date]: + if shape.type_name in ["integer", "long"]: + return shape.metadata.get("min", 1) + if shape.type_name in ["float", "double"]: + return shape.metadata.get("min", 1.0) + if shape.type_name == "boolean": + return True + if shape.type_name == "blob": + # TODO: better blob generator + return b"0" * shape.metadata.get("min", 1) + if shape.type_name == "timestamp": + return datetime.now() + + raise ValueError("unknown type %s" % shape.type_name) + + +def generate_response(operation: OperationModel): + graph = shape_graph(operation.output_shape) + response = generate_instance(graph.root, graph) + response.pop("nextToken", None) + return response + + +def generate_request(operation: OperationModel): + graph = shape_graph(operation.input_shape) + return generate_instance(graph.root, graph) + + +def return_mock_response(context: RequestContext, request: ServiceRequest) -> ServiceResponse: + return generate_response(context.operation) + + +def create_mocking_dispatch_table(service) -> DispatchTable: + dispatch_table = {} + + for operation in service.operation_names: + # resolve the bound function of the delegate + # create a dispatcher + dispatch_table[operation] = ServiceRequestDispatcher( + return_mock_response, + operation=operation, + pass_context=True, + expand_parameters=False, + ) + + return dispatch_table + + +@lru_cache() +def get_mocking_skeleton(service: str) -> Skeleton: + service = load_service(service) + return Skeleton(service, create_mocking_dispatch_table(service)) diff --git a/localstack-core/localstack/aws/patches.py b/localstack-core/localstack/aws/patches.py new file mode 100644 index 0000000000000..f73067ad7f878 --- /dev/null +++ b/localstack-core/localstack/aws/patches.py @@ -0,0 +1,55 @@ +from importlib.util import find_spec + +from localstack.runtime import hooks +from localstack.utils.patch import patch + + +def patch_moto_instance_tracker_meta(): + """ + Avoid instance collection for moto dashboard. Introduced in + https://github.com/localstack/localstack/pull/3250. + """ + from moto.core.base_backend import InstanceTrackerMeta + from moto.core.common_models import BaseModel + + if hasattr(InstanceTrackerMeta, "_ls_patch_applied"): + return # ensure we're not applying the patch multiple times + + @patch(InstanceTrackerMeta.__new__, pass_target=False) + def new_instance(meta, name, bases, dct): + cls = super(InstanceTrackerMeta, meta).__new__(meta, name, bases, dct) + if name == "BaseModel": + return cls + cls.instances = [] + return cls + + @patch(BaseModel.__new__, pass_target=False) + def new_basemodel(cls, *args, **kwargs): + # skip cls.instances.append(..) which is done by the original/upstream constructor + instance = super(BaseModel, cls).__new__(cls) + return instance + + InstanceTrackerMeta._ls_patch_applied = True + + +def patch_moto_iam_config(): + """ + Enable loading AWS IAM managed policies in moto by default.Introduced in + https://github.com/localstack/localstack/pull/10112. + """ + from moto.core.config import default_user_config + + default_user_config["iam"]["load_aws_managed_policies"] = True + + +# TODO: this could be improved by introducing a hook specifically for applying global patches that is run +# before any other code is imported. +@hooks.on_infra_start(priority=100) +def apply_aws_runtime_patches(): + """ + Runtime patches specific to the AWS emulator. + """ + if find_spec("moto"): + # only load patches when moto is importable + patch_moto_iam_config() + patch_moto_instance_tracker_meta() diff --git a/localstack/dashboard/__init__.py b/localstack-core/localstack/aws/protocol/__init__.py similarity index 100% rename from localstack/dashboard/__init__.py rename to localstack-core/localstack/aws/protocol/__init__.py diff --git a/localstack-core/localstack/aws/protocol/op_router.py b/localstack-core/localstack/aws/protocol/op_router.py new file mode 100644 index 0000000000000..f4c5f1019aa02 --- /dev/null +++ b/localstack-core/localstack/aws/protocol/op_router.py @@ -0,0 +1,264 @@ +from collections import defaultdict +from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple +from urllib.parse import parse_qs, unquote + +from botocore.model import OperationModel, ServiceModel, StructureShape +from werkzeug.datastructures import Headers, MultiDict +from werkzeug.exceptions import MethodNotAllowed, NotFound +from werkzeug.routing import Map, MapAdapter + +from localstack.aws.protocol.routing import ( + StrictMethodRule, + path_param_regex, + post_process_arg_name, + transform_path_params_to_rule_vars, +) +from localstack.http import Request +from localstack.http.request import get_raw_path +from localstack.http.router import GreedyPathConverter + + +class _HttpOperation(NamedTuple): + """Useful intermediary representation of the 'http' block of an operation to make code cleaner""" + + operation: OperationModel + path: str + method: str + query_args: Mapping[str, List[str]] + header_args: List[str] + deprecated: bool + + @staticmethod + def from_operation(op: OperationModel) -> "_HttpOperation": + # botocore >= 1.28 might modify the internal model (specifically for S3). + # It will modify the request URI to strip the bucket name from the path and set the original value at + # "authPath". + # Since botocore 1.31.2, botocore will strip the query from the `authPart` + # We need to add it back from `requestUri` field + # Use authPath if set, otherwise use the regular requestUri. + if auth_path := op.http.get("authPath"): + path, sep, query = op.http.get("requestUri", "").partition("?") + uri = f"{auth_path.rstrip('/')}{sep}{query}" + else: + uri = op.http.get("requestUri") + + method = op.http.get("method") + deprecated = op.deprecated + + # requestUris can contain mandatory query args (f.e. /apikeys?mode=import) + path_query = uri.split("?") + path = path_query[0] + header_args = [] + query_args: Dict[str, List[str]] = {} + + if len(path_query) > 1: + # parse the query args of the request URI (they are mandatory) + query_args: Dict[str, List[str]] = parse_qs(path_query[1], keep_blank_values=True) + # for mandatory keys without values, keep an empty list (instead of [''] - the result of parse_qs) + query_args = {k: filter(None, v) for k, v in query_args.items()} + + # find the required header and query parameters of the input shape + input_shape = op.input_shape + if isinstance(input_shape, StructureShape): + for required_member in input_shape.required_members: + member_shape = input_shape.members[required_member] + location = member_shape.serialization.get("location") + if location is not None: + if location == "header": + header_name = member_shape.serialization.get("name") + header_args.append(header_name) + elif location == "querystring": + query_name = member_shape.serialization.get("name") + # do not overwrite potentially already existing query params with specific values + if query_name not in query_args: + # an empty list defines a required query param only needs to be present + # (no specific value will be enforced when matching) + query_args[query_name] = [] + + return _HttpOperation(op, path, method, query_args, header_args, deprecated) + + +class _RequiredArgsRule: + """ + Specific Rule implementation which checks if a set of certain required header and query parameters are matched by + a specific request. + """ + + endpoint: Any + required_query_args: Optional[Mapping[str, List[Any]]] + required_header_args: List[str] + match_score: int + + def __init__(self, operation: _HttpOperation) -> None: + super().__init__() + self.endpoint = operation.operation + self.required_query_args = operation.query_args or {} + self.required_header_args = operation.header_args or [] + self.match_score = ( + 10 + 10 * len(self.required_query_args) + 10 * len(self.required_header_args) + ) + # If this operation is deprecated, the score is a bit less high (bot not as much as a matching required arg) + if operation.deprecated: + self.match_score -= 5 + + def matches(self, query_args: MultiDict, headers: Headers) -> bool: + """ + Returns true if the given query args and the given headers of a request match the required query args and + headers of this rule. + :param query_args: query arguments of the incoming request + :param headers: headers of the incoming request + :return: True if the query args and headers match the required args of this rule + """ + if self.required_query_args: + for key, values in self.required_query_args.items(): + if key not in query_args: + return False + # if a required query arg also has a list of required values set, the values need to match as well + if values: + query_arg_values = query_args.getlist(key) + for value in values: + if value not in query_arg_values: + return False + + if self.required_header_args: + for key in self.required_header_args: + if key not in headers: + return False + + return True + + +class _RequestMatchingRule(StrictMethodRule): + """ + A Werkzeug Rule extension which initially acts as a normal rule (i.e. matches a path and method). + + This rule matches if one of its sub-rules _might_ match. + It cannot be assumed that one of the fine-grained rules matches, just because this rule initially matches. + If this rule matches, the caller _must_ call `match_request` in order to find the actual fine-grained matching rule. + The result of `match_request` is only meaningful if this wrapping rule also matches. + """ + + def __init__( + self, string: str, operations: List[_HttpOperation], method: str, **kwargs + ) -> None: + super().__init__(string=string, method=method, **kwargs) + # Create a rule which checks all required arguments (not only the path and method) + rules = [_RequiredArgsRule(op) for op in operations] + # Sort the rules descending based on their rule score + # (i.e. the first matching rule will have the highest score)= + self.rules = sorted(rules, key=lambda rule: rule.match_score, reverse=True) + + def match_request(self, request: Request) -> _RequiredArgsRule: + """ + Function which needs to be called by a caller if the _RequestMatchingRule already matched using Werkzeug's + default matching mechanism. + + :param request: to perform the fine-grained matching on + :return: matching fine-grained rule + :raises: NotFound if none of the fine-grained rules matches + """ + for rule in self.rules: + if rule.matches(request.args, request.headers): + return rule + raise NotFound() + + +def _create_service_map(service: ServiceModel) -> Map: + """ + Creates a Werkzeug Map object with all rules necessary for the specific service. + :param service: botocore service model to create the rules for + :return: a Map instance which is used to perform the in-service operation routing + """ + ops = [service.operation_model(op_name) for op_name in service.operation_names] + + rules = [] + + # group all operations by their path and method + path_index: Dict[(str, str), List[_HttpOperation]] = defaultdict(list) + for op in ops: + http_op = _HttpOperation.from_operation(op) + path_index[(http_op.path, http_op.method)].append(http_op) + + # create a matching rule for each (path, method) combination + for (path, method), ops in path_index.items(): + # translate the requestUri to a Werkzeug rule string + rule_string = path_param_regex.sub(transform_path_params_to_rule_vars, path) + + if len(ops) == 1: + # if there is only a single operation for a (path, method) combination, + # the default Werkzeug rule can be used directly (this is the case for most rules) + op = ops[0] + rules.append(StrictMethodRule(string=rule_string, method=method, endpoint=op.operation)) # type: ignore + else: + # if there is an ambiguity with only the (path, method) combination, + # a custom rule - which can use additional request metadata - needs to be used + rules.append(_RequestMatchingRule(string=rule_string, method=method, operations=ops)) + + return Map( + rules=rules, + # don't be strict about trailing slashes when matching + strict_slashes=False, + # we can't really use werkzeug's merge-slashes since it uses HTTP redirects to solve it + merge_slashes=False, + # get service-specific converters + converters={"path": GreedyPathConverter}, + ) + + +class RestServiceOperationRouter: + """ + A router implementation which abstracts the (quite complex) routing of incoming HTTP requests to a specific + operation within a "REST" service (rest-xml, rest-json). + """ + + _map: Map + + def __init__(self, service: ServiceModel): + self._map = _create_service_map(service) + + def match(self, request: Request) -> Tuple[OperationModel, Mapping[str, Any]]: + """ + Matches the given request to the operation it targets (or raises an exception if no operation matches). + + :param request: The request of which the targeting operation needs to be found + :return: A tuple with the matched operation and the (already parsed) path params + :raises: Werkzeug's NotFound exception in case the given request does not match any operation + """ + + # bind the map to get the actual matcher + matcher: MapAdapter = self._map.bind(request.host) + + # perform the matching + try: + # some services (at least S3) allow OPTIONS request (f.e. for CORS preflight requests) without them being + # specified. the specs do _not_ contain any operations on OPTIONS methods at all. + # avoid matching issues for preflight requests by matching against a similar GET request instead. + method = request.method if request.method != "OPTIONS" else "GET" + + path = get_raw_path(request) + # trailing slashes are ignored in smithy matching, + # see https://smithy.io/1.0/spec/core/http-traits.html#literal-character-sequences and this + # makes sure that, e.g., in s3, `GET /mybucket/` is not matched to `GetBucket` and not to + # `GetObject` and the associated rule. + path = path.rstrip("/") + + rule, args = matcher.match(path, method=method, return_rule=True) + except MethodNotAllowed as e: + # MethodNotAllowed (405) exception is raised if a path is matching, but the method does not. + # Our router handles this as a 404. + raise NotFound() from e + + # if the found rule is a _RequestMatchingRule, the multi rule matching needs to be invoked to perform the + # fine-grained matching based on the whole request + if isinstance(rule, _RequestMatchingRule): + rule = rule.match_request(request) + + # post process the arg keys and values + # - the path param keys need to be "un-sanitized", i.e. sanitized rule variable names need to be reverted + # - the path param values might still be url-encoded + args = {post_process_arg_name(k): unquote(v) for k, v in args.items()} + + # extract the operation model from the rule + operation: OperationModel = rule.endpoint + + return operation, args diff --git a/localstack-core/localstack/aws/protocol/parser.py b/localstack-core/localstack/aws/protocol/parser.py new file mode 100644 index 0000000000000..96fd3d16cf0aa --- /dev/null +++ b/localstack-core/localstack/aws/protocol/parser.py @@ -0,0 +1,1182 @@ +""" +Request parsers for the different AWS service protocols. + +The module contains classes that take an HTTP request to a service, and +given an operation model, parse the HTTP request according to the +specified input shape. + +It can be seen as the counterpart to the ``serialize`` module in +``botocore`` (which serializes the request before sending it to this +parser). It has a lot of similarities with the ``parse`` module in +``botocore``, but serves a different purpose (parsing requests +instead of responses). + +The different protocols have many similarities. The class hierarchy is +designed such that the parsers share as much logic as possible. +The class hierarchy looks as follows: +:: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚RequestParserβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² β–² β–² + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ └────────────────────┐ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚QueryRequestParserβ”‚ β”‚BaseRestRequestParserβ”‚ β”‚BaseJSONRequestParserβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² β–² β–² β–² β–² + β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ + β”‚EC2RequestParserβ”‚ β”‚RestXMLRequestParserβ”‚ β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”΄β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚RestJSONRequestParserβ”‚ β”‚JSONRequestParserβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +:: + +The ``RequestParser`` contains the logic that is used among all the +different protocols (``query``, ``json``, ``rest-json``, ``rest-xml``, +and ``ec2``). +The relation between the different protocols is described in the +``serializer``. + +The classes are structured as follows: + +* The ``RequestParser`` contains all the basic logic for the parsing + which is shared among all different protocols. +* The ``BaseRestRequestParser`` contains the logic for the REST + protocol specifics (i.e. specific HTTP metadata parsing). +* The ``BaseJSONRequestParser`` contains the logic for the JSON body + parsing. +* The ``RestJSONRequestParser`` inherits the ReST specific logic from + the ``BaseRestRequestParser`` and the JSON body parsing from the + ``BaseJSONRequestParser``. +* The ``QueryRequestParser``, ``RestXMLRequestParser``, and the + ``JSONRequestParser`` have a conventional inheritance structure. + +The services and their protocols are defined by using AWS's Smithy +(a language to define services in a - somewhat - protocol-agnostic +way). The "peculiarities" in this parser code usually correspond +to certain so-called "traits" in Smithy. + +The result of the parser methods are the operation model of the +service's action which the request was aiming for, as well as the +parsed parameters for the service's function invocation. +""" + +import abc +import base64 +import datetime +import functools +import re +from abc import ABC +from email.utils import parsedate_to_datetime +from typing import IO, Any, Dict, List, Mapping, Optional, Tuple, Union +from xml.etree import ElementTree as ETree + +import dateutil.parser +from botocore.model import ( + ListShape, + MapShape, + OperationModel, + OperationNotFoundError, + ServiceModel, + Shape, + StructureShape, +) + +# cbor2: explicitly load from private _decoder module to avoid using the (non-patched) C-version +from cbor2._decoder import loads as cbor2_loads +from werkzeug.exceptions import BadRequest, NotFound + +from localstack.aws.protocol.op_router import RestServiceOperationRouter +from localstack.http import Request + + +def _text_content(func): + """ + This decorator hides the difference between an XML node with text or a plain string. + It's used to ensure that scalar processing operates only on text strings, which + allows the same scalar handlers to be used for XML nodes from the body, HTTP headers, + and across different protocols. + + :param func: function which should be wrapped + :return: wrapper function which can be called with a node or a string, where the + wrapped function is always called with a string + """ + + def _get_text_content( + self, + request: Request, + shape: Shape, + node_or_string: Union[ETree.Element, str], + uri_params: Mapping[str, Any] = None, + ): + if hasattr(node_or_string, "text"): + text = node_or_string.text + if text is None: + # If an XML node is empty <foo></foo>, we want to parse that as an empty string, + # not as a null/None value. + text = "" + else: + text = node_or_string + return func(self, request, shape, text, uri_params) + + return _get_text_content + + +class RequestParserError(Exception): + """ + Error which is thrown if the request parsing fails. + Super class of all exceptions raised by the parser. + """ + + pass + + +class UnknownParserError(RequestParserError): + """ + Error which indicates that the raised exception of the parser could be caused by invalid data or by any other + (unknown) issue. Errors like this should be reported and indicate an issue in the parser itself. + """ + + pass + + +class ProtocolParserError(RequestParserError): + """ + Error which indicates that the given data is not compliant with the service's specification and cannot be parsed. + This usually results in a response with an HTTP 4xx status code (client error). + """ + + pass + + +class OperationNotFoundParserError(ProtocolParserError): + """ + Error which indicates that the given data cannot be matched to a specific operation. + The request is likely _not_ meant to be handled by the ASF service provider itself. + """ + + pass + + +def _handle_exceptions(func): + """ + Decorator which handles the exceptions raised by the parser. It ensures that all exceptions raised by the public + methods of the parser are instances of RequestParserError. + :param func: to wrap in order to add the exception handling + :return: wrapped function + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except RequestParserError: + raise + except Exception as e: + raise UnknownParserError( + "An unknown error occurred when trying to parse the request." + ) from e + + return wrapper + + +class RequestParser(abc.ABC): + """ + The request parser is responsible for parsing an incoming HTTP request. + It determines which operation the request was aiming for and parses the incoming request such that the resulting + dictionary can be used to invoke the service's function implementation. + It is the base class for all parsers and therefore contains the basic logic which is used among all of them. + """ + + service: ServiceModel + DEFAULT_ENCODING = "utf-8" + # The default timestamp format is ISO8601, but this can be overwritten by subclasses. + TIMESTAMP_FORMAT = "iso8601" + # The default timestamp format for header fields + HEADER_TIMESTAMP_FORMAT = "rfc822" + # The default timestamp format for query fields + QUERY_TIMESTAMP_FORMAT = "iso8601" + + def __init__(self, service: ServiceModel) -> None: + super().__init__() + self.service = service + + @_handle_exceptions + def parse(self, request: Request) -> Tuple[OperationModel, Any]: + """ + Determines which operation the request was aiming for and parses the incoming request such that the resulting + dictionary can be used to invoke the service's function implementation. + + :param request: to parse + :return: a tuple with the operation model (defining the action / operation which the request aims for), + and the parsed service parameters + :raises: RequestParserError (either a ProtocolParserError or an UnknownParserError) + """ + raise NotImplementedError + + def _parse_shape( + self, request: Request, shape: Shape, node: Any, uri_params: Mapping[str, Any] = None + ) -> Any: + """ + Main parsing method which dynamically calls the parsing function for the specific shape. + + :param request: the complete Request + :param shape: of the node + :param node: the single part of the HTTP request to parse + :param uri_params: the extracted URI path params + :return: result of the parsing operation, the type depends on the shape + """ + if shape is None: + return None + location = shape.serialization.get("location") + if location is not None: + if location == "header": + header_name = shape.serialization.get("name") + payload = request.headers.get(header_name) + if payload and shape.type_name == "list": + # headers may contain a comma separated list of values (e.g., the ObjectAttributes member in + # s3.GetObjectAttributes), so we prepare it here for the handler, which will be `_parse_list`. + # Header lists can contain optional whitespace, so we strip it + # https://www.rfc-editor.org/rfc/rfc9110.html#name-lists-rule-abnf-extension + payload = [value.strip() for value in payload.split(",")] + elif location == "headers": + payload = self._parse_header_map(shape, request.headers) + # shapes with the location trait "headers" only contain strings and are not further processed + return payload + elif location == "querystring": + query_name = shape.serialization.get("name") + parsed_query = request.args + if shape.type_name == "list": + payload = parsed_query.getlist(query_name) + else: + payload = parsed_query.get(query_name) + elif location == "uri": + uri_param_name = shape.serialization.get("name") + if uri_param_name in uri_params: + payload = uri_params[uri_param_name] + else: + raise UnknownParserError("Unknown shape location '%s'." % location) + else: + # If we don't have to use a specific location, we use the node + payload = node + + fn_name = "_parse_%s" % shape.type_name + handler = getattr(self, fn_name, self._noop_parser) + try: + return handler(request, shape, payload, uri_params) if payload is not None else None + except (TypeError, ValueError, AttributeError) as e: + raise ProtocolParserError( + f"Invalid type when parsing {shape.name}: '{payload}' cannot be parsed to {shape.type_name}." + ) from e + + # The parsing functions for primitive types, lists, and timestamps are shared among subclasses. + + def _parse_list( + self, + request: Request, + shape: ListShape, + node: list, + uri_params: Mapping[str, Any] = None, + ): + parsed = [] + member_shape = shape.member + for item in node: + parsed.append(self._parse_shape(request, member_shape, item, uri_params)) + return parsed + + @_text_content + def _parse_integer(self, _, __, node: str, ___) -> int: + return int(node) + + @_text_content + def _parse_float(self, _, __, node: str, ___) -> float: + return float(node) + + @_text_content + def _parse_blob(self, _, __, node: str, ___) -> bytes: + return base64.b64decode(node) + + @_text_content + def _parse_timestamp(self, _, shape: Shape, node: str, ___) -> datetime.datetime: + timestamp_format = shape.serialization.get("timestampFormat") + if not timestamp_format and shape.serialization.get("location") == "header": + timestamp_format = self.HEADER_TIMESTAMP_FORMAT + elif not timestamp_format and shape.serialization.get("location") == "querystring": + timestamp_format = self.QUERY_TIMESTAMP_FORMAT + return self._convert_str_to_timestamp(node, timestamp_format) + + @_text_content + def _parse_boolean(self, _, __, node: str, ___) -> bool: + value = node.lower() + if value == "true": + return True + if value == "false": + return False + raise ValueError("cannot parse boolean value %s" % node) + + @_text_content + def _noop_parser(self, _, __, node: Any, ___): + return node + + _parse_character = _parse_string = _noop_parser + _parse_double = _parse_float + _parse_long = _parse_integer + + def _convert_str_to_timestamp(self, value: str, timestamp_format=None): + if timestamp_format is None: + timestamp_format = self.TIMESTAMP_FORMAT + timestamp_format = timestamp_format.lower() + converter = getattr(self, "_timestamp_%s" % timestamp_format) + final_value = converter(value) + return final_value + + @staticmethod + def _timestamp_iso8601(date_string: str) -> datetime.datetime: + return dateutil.parser.isoparse(date_string) + + @staticmethod + def _timestamp_unixtimestamp(timestamp_string: str) -> datetime.datetime: + return datetime.datetime.utcfromtimestamp(int(timestamp_string)) + + @staticmethod + def _timestamp_unixtimestampmillis(timestamp_string: str) -> datetime.datetime: + return datetime.datetime.utcfromtimestamp(float(timestamp_string) / 1000) + + @staticmethod + def _timestamp_rfc822(datetime_string: str) -> datetime.datetime: + return parsedate_to_datetime(datetime_string) + + @staticmethod + def _parse_header_map(shape: Shape, headers: dict) -> dict: + # Note that headers are case insensitive, so we .lower() all header names and header prefixes. + parsed = {} + prefix = shape.serialization.get("name", "").lower() + for header_name, header_value in headers.items(): + if header_name.lower().startswith(prefix): + # The key name inserted into the parsed hash strips off the prefix. + name = header_name[len(prefix) :] + parsed[name] = header_value + return parsed + + +class QueryRequestParser(RequestParser): + """ + The ``QueryRequestParser`` is responsible for parsing incoming requests for services which use the ``query`` + protocol. The requests for these services encode the majority of their parameters in the URL query string. + """ + + @_handle_exceptions + def parse(self, request: Request) -> Tuple[OperationModel, Any]: + instance = request.values + if "Action" not in instance: + raise ProtocolParserError( + f"Operation detection failed. " + f"Missing Action in request for query-protocol service {self.service}." + ) + action = instance["Action"] + try: + operation: OperationModel = self.service.operation_model(action) + except OperationNotFoundError as e: + raise OperationNotFoundParserError( + f"Operation detection failed." + f"Operation {action} could not be found for service {self.service}." + ) from e + # There are no uri params in the query protocol (all ops are POST on "/") + uri_params = {} + input_shape: StructureShape = operation.input_shape + parsed = self._parse_shape(request, input_shape, instance, uri_params) + if parsed is None: + return operation, {} + return operation, parsed + + def _process_member( + self, + request: Request, + member_name: str, + member_shape: Shape, + node: dict, + uri_params: Mapping[str, Any] = None, + ): + if isinstance(member_shape, (MapShape, ListShape, StructureShape)): + # If we have a complex type, we filter the node and change it's keys to craft a new "context" for the + # new hierarchy level + sub_node = self._filter_node(member_name, node) + else: + # If it is a primitive type we just get the value from the dict + sub_node = node.get(member_name) + # The filtered node is processed and returned (or None if the sub_node is None) + return ( + self._parse_shape(request, member_shape, sub_node, uri_params) + if sub_node is not None + else None + ) + + def _parse_structure( + self, + request: Request, + shape: StructureShape, + node: dict, + uri_params: Mapping[str, Any] = None, + ) -> dict: + result = {} + + for member, member_shape in shape.members.items(): + # The key in the node is either the serialization config "name" of the shape, or the name of the member + member_name = self._get_serialized_name(member_shape, member, node) + # BUT, if it's flattened and a list, the name is defined by the list's member's name + if member_shape.serialization.get("flattened"): + if isinstance(member_shape, ListShape): + member_name = self._get_serialized_name(member_shape.member, member, node) + value = self._process_member(request, member_name, member_shape, node, uri_params) + if value is not None or member in shape.required_members: + # If the member is required, but not existing, we explicitly set None + result[member] = value + + return result if len(result) > 0 else None + + def _parse_map( + self, request: Request, shape: MapShape, node: dict, uri_params: Mapping[str, Any] + ) -> dict: + """ + This is what the node looks like for a flattened map:: + :: + { + "Attribute.1.Name": "MyKey", + "Attribute.1.Value": "MyValue", + "Attribute.2.Name": ..., + ... + } + :: + This function expects an already filtered / pre-processed node. The node dict would therefore look like: + :: + { + "1.Name": "MyKey", + "1.Value": "MyValue", + "2.Name": ... + } + :: + """ + key_prefix = "" + # Non-flattened maps have an additional hierarchy level named "entry" + # https://awslabs.github.io/smithy/1.0/spec/core/xml-traits.html#xmlflattened-trait + if not shape.serialization.get("flattened"): + key_prefix += "entry." + result = {} + + i = 0 + while True: + i += 1 + # The key and value can be renamed (with their serialization config's "name"). + # By default they are called "key" and "value". + key_name = f"{key_prefix}{i}.{self._get_serialized_name(shape.key, 'key', node)}" + value_name = f"{key_prefix}{i}.{self._get_serialized_name(shape.value, 'value', node)}" + + # We process the key and value individually + k = self._process_member(request, key_name, shape.key, node) + v = self._process_member(request, value_name, shape.value, node) + if k is None or v is None: + # technically, if one exists but not the other, then that would be an invalid request + break + result[k] = v + + return result if len(result) > 0 else None + + def _parse_list( + self, + request: Request, + shape: ListShape, + node: dict, + uri_params: Mapping[str, Any] = None, + ) -> list: + """ + Some actions take lists of parameters. These lists are specified using the param.[member.]n notation. + The "member" is used if the list is not flattened. + Values of n are integers starting from 1. + For example, a list with two elements looks like this: + - Flattened: &AttributeName.1=first&AttributeName.2=second + - Non-flattened: &AttributeName.member.1=first&AttributeName.member.2=second + This function expects an already filtered / processed node. The node dict would therefore look like: + :: + { + "1": "first", + "2": "second", + "3": ... + } + :: + """ + # The keys might be prefixed (f.e. for flattened lists) + key_prefix = self._get_list_key_prefix(shape, node) + + # We collect the list value as well as the integer indicating the list position so we can + # later sort the list by the position, in case they attribute values are unordered + result: List[Tuple[int, Any]] = [] + + i = 0 + while True: + i += 1 + key_name = f"{key_prefix}{i}" + value = self._process_member(request, key_name, shape.member, node) + if value is None: + break + result.append((i, value)) + + return [r[1] for r in sorted(result)] if len(result) > 0 else None + + @staticmethod + def _filter_node(name: str, node: dict) -> dict: + """Filters the node dict for entries where the key starts with the given name.""" + filtered = {k[len(name) + 1 :]: v for k, v in node.items() if k.startswith(name)} + return filtered if len(filtered) > 0 else None + + def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str: + """ + Returns the serialized name for the shape if it exists. + Otherwise, it will return the given default_name. + """ + return shape.serialization.get("name", default_name) + + def _get_list_key_prefix(self, shape: ListShape, node: dict): + key_prefix = "" + # Non-flattened lists have an additional hierarchy level: + # https://awslabs.github.io/smithy/1.0/spec/core/xml-traits.html#xmlflattened-trait + # The hierarchy level's name is the serialization name of its member or (by default) "member". + if not shape.serialization.get("flattened"): + key_prefix += f"{self._get_serialized_name(shape.member, 'member', node)}." + return key_prefix + + +class BaseRestRequestParser(RequestParser): + """ + The ``BaseRestRequestParser`` is the base class for all "resty" AWS service protocols. + The operation which should be invoked is determined based on the HTTP method and the path suffix. + The body encoding is done in the respective subclasses. + """ + + def __init__(self, service: ServiceModel) -> None: + super().__init__(service) + self.ignore_get_body_errors = False + self._operation_router = RestServiceOperationRouter(service) + + @_handle_exceptions + def parse(self, request: Request) -> Tuple[OperationModel, Any]: + try: + operation, uri_params = self._operation_router.match(request) + except NotFound as e: + raise OperationNotFoundParserError( + f"Unable to find operation for request to service " + f"{self.service.service_name}: {request.method} {request.path}" + ) from e + + shape: StructureShape = operation.input_shape + final_parsed = {} + if shape is not None: + self._parse_payload(request, shape, shape.members, uri_params, final_parsed) + return operation, final_parsed + + def _parse_payload( + self, + request: Request, + shape: Shape, + member_shapes: Dict[str, Shape], + uri_params: Mapping[str, Any], + final_parsed: dict, + ) -> None: + """Parses all attributes which are located in the payload / body of the incoming request.""" + payload_parsed = {} + non_payload_parsed = {} + if "payload" in shape.serialization: + # If a payload is specified in the output shape, then only that shape is used for the body payload. + payload_member_name = shape.serialization["payload"] + body_shape = member_shapes[payload_member_name] + if body_shape.serialization.get("eventstream"): + body = self._create_event_stream(request, body_shape) + payload_parsed[payload_member_name] = body + elif body_shape.type_name == "string": + # Only set the value if it's not empty (the request's data is an empty binary by default) + if request.data: + body = request.data + if isinstance(body, bytes): + body = body.decode(self.DEFAULT_ENCODING) + payload_parsed[payload_member_name] = body + elif body_shape.type_name == "blob": + # This control path is equivalent to operation.has_streaming_input (shape has a payload which is a blob) + # in which case we assume essentially an IO[bytes] to be passed. Since the payload can be optional, we + # only set the parameter if content_length=0, which indicates an empty request. If the content length is + # not set, it could be a streaming response. + if request.content_length != 0: + payload_parsed[payload_member_name] = self.create_input_stream(request) + else: + original_parsed = self._initial_body_parse(request) + payload_parsed[payload_member_name] = self._parse_shape( + request, body_shape, original_parsed, uri_params + ) + else: + # The payload covers the whole body. We only parse the body if it hasn't been handled by the payload logic. + try: + non_payload_parsed = self._initial_body_parse(request) + except ProtocolParserError: + # GET requests should ignore the body, so we just let them pass + if not (request.method in ["GET", "HEAD"] and self.ignore_get_body_errors): + raise + + # even if the payload has been parsed, the rest of the shape needs to be processed as well + # (for members which are located outside of the body, like uri or header) + non_payload_parsed = self._parse_shape(request, shape, non_payload_parsed, uri_params) + # update the final result with the parsed body and the parsed payload (where the payload has precedence) + final_parsed.update(non_payload_parsed) + final_parsed.update(payload_parsed) + + def _initial_body_parse(self, request: Request) -> Any: + """ + This method executes the initial parsing of the body (XML, JSON, or CBOR). + The parsed body will afterwards still be walked through and the nodes will be converted to the appropriate + types, but this method does the first round of parsing. + + :param request: of which the body should be parsed + :return: depending on the actual implementation + """ + raise NotImplementedError("_initial_body_parse") + + def _create_event_stream(self, request: Request, shape: Shape) -> Any: + # TODO handle event streams + raise NotImplementedError("_create_event_stream") + + def create_input_stream(self, request: Request) -> IO[bytes]: + """ + Returns an IO object that makes the payload of the Request available for streaming. + + :param request: the http request + :return: the input stream that allows services to consume the request payload + """ + # for now _get_stream_for_parsing seems to be a good compromise. it can be used even after `request.data` was + # previously called. however the reverse doesn't work. once the stream has been consumed, `request.data` will + # return b'' + return request._get_stream_for_parsing() + + +class RestXMLRequestParser(BaseRestRequestParser): + """ + The ``RestXMLRequestParser`` is responsible for parsing incoming requests for services which use the ``rest-xml`` + protocol. The requests for these services encode the majority of their parameters as XML in the request body. + """ + + def __init__(self, service_model: ServiceModel): + super(RestXMLRequestParser, self).__init__(service_model) + self.ignore_get_body_errors = True + self._namespace_re = re.compile("{.*}") + + def _initial_body_parse(self, request: Request) -> ETree.Element: + body = request.data + if not body: + return ETree.Element("") + return self._parse_xml_string_to_dom(body) + + def _parse_structure( + self, + request: Request, + shape: StructureShape, + node: ETree.Element, + uri_params: Mapping[str, Any] = None, + ) -> dict: + parsed = {} + xml_dict = self._build_name_to_xml_node(node) + for member_name, member_shape in shape.members.items(): + xml_name = self._member_key_name(member_shape, member_name) + member_node = xml_dict.get(xml_name) + # If a shape defines a location trait, the node might be None (since these are extracted from the request's + # metadata like headers or the URI) + if ( + member_node is not None + or "location" in member_shape.serialization + or member_shape.serialization.get("eventheader") + ): + parsed[member_name] = self._parse_shape( + request, member_shape, member_node, uri_params + ) + elif member_shape.serialization.get("xmlAttribute"): + attributes = {} + location_name = member_shape.serialization["name"] + for key, value in node.attrib.items(): + new_key = self._namespace_re.sub(location_name.split(":")[0] + ":", key) + attributes[new_key] = value + if location_name in attributes: + parsed[member_name] = attributes[location_name] + elif member_name in shape.required_members: + # If the member is required, but not existing, we explicitly set None + parsed[member_name] = None + return parsed + + def _parse_map( + self, + request: Request, + shape: MapShape, + node: dict, + uri_params: Mapping[str, Any] = None, + ) -> dict: + parsed = {} + key_shape = shape.key + value_shape = shape.value + key_location_name = key_shape.serialization.get("name", "key") + value_location_name = value_shape.serialization.get("name", "value") + if shape.serialization.get("flattened") and not isinstance(node, list): + node = [node] + for keyval_node in node: + key_name = val_name = None + for single_pair in keyval_node: + # Within each <entry> there's a <key> and a <value> + tag_name = self._node_tag(single_pair) + if tag_name == key_location_name: + key_name = self._parse_shape(request, key_shape, single_pair, uri_params) + elif tag_name == value_location_name: + val_name = self._parse_shape(request, value_shape, single_pair, uri_params) + else: + raise ProtocolParserError("Unknown tag: %s" % tag_name) + parsed[key_name] = val_name + return parsed + + def _parse_list( + self, + request: Request, + shape: ListShape, + node: dict, + uri_params: Mapping[str, Any] = None, + ) -> list: + # When we use _build_name_to_xml_node, repeated elements are aggregated + # into a list. However, we can't tell the difference between a scalar + # value and a single element flattened list. So before calling the + # real _handle_list, we know that "node" should actually be a list if + # it's flattened, and if it's not, then we make it a one element list. + if shape.serialization.get("flattened") and not isinstance(node, list): + node = [node] + return super(RestXMLRequestParser, self)._parse_list(request, shape, node, uri_params) + + def _node_tag(self, node: ETree.Element) -> str: + return self._namespace_re.sub("", node.tag) + + @staticmethod + def _member_key_name(shape: Shape, member_name: str) -> str: + # This method is needed because we have to special case flattened list + # with a serialization name. If this is the case we use the + # locationName from the list's member shape as the key name for the + # surrounding structure. + if isinstance(shape, ListShape) and shape.serialization.get("flattened"): + list_member_serialized_name = shape.member.serialization.get("name") + if list_member_serialized_name is not None: + return list_member_serialized_name + serialized_name = shape.serialization.get("name") + if serialized_name is not None: + return serialized_name + return member_name + + @staticmethod + def _parse_xml_string_to_dom(xml_string: str) -> ETree.Element: + try: + parser = ETree.XMLParser(target=ETree.TreeBuilder()) + parser.feed(xml_string) + root = parser.close() + except ETree.ParseError as e: + raise ProtocolParserError( + "Unable to parse request (%s), invalid XML received:\n%s" % (e, xml_string) + ) from e + return root + + def _build_name_to_xml_node(self, parent_node: Union[list, ETree.Element]) -> dict: + # If the parent node is actually a list. We should not be trying + # to serialize it to a dictionary. Instead, return the first element + # in the list. + if isinstance(parent_node, list): + return self._build_name_to_xml_node(parent_node[0]) + xml_dict = {} + for item in parent_node: + key = self._node_tag(item) + if key in xml_dict: + # If the key already exists, the most natural + # way to handle this is to aggregate repeated + # keys into a single list. + # <foo>1</foo><foo>2</foo> -> {'foo': [Node(1), Node(2)]} + if isinstance(xml_dict[key], list): + xml_dict[key].append(item) + else: + # Convert from a scalar to a list. + xml_dict[key] = [xml_dict[key], item] + else: + xml_dict[key] = item + return xml_dict + + def _create_event_stream(self, request: Request, shape: Shape) -> Any: + # TODO handle event streams + raise NotImplementedError("_create_event_stream") + + +class BaseJSONRequestParser(RequestParser, ABC): + """ + The ``BaseJSONRequestParser`` is the base class for all JSON-based AWS service protocols. + This base-class handles parsing the payload / body as JSON. + """ + + # default timestamp format for JSON requests + TIMESTAMP_FORMAT = "unixtimestamp" + # timestamp format for requests with CBOR content type + CBOR_TIMESTAMP_FORMAT = "unixtimestampmillis" + + def _parse_structure( + self, + request: Request, + shape: StructureShape, + value: Optional[dict], + uri_params: Mapping[str, Any] = None, + ) -> Optional[dict]: + if shape.is_document_type: + final_parsed = value + else: + if value is None: + # If the comes across the wire as "null" (None in python), + # we should be returning this unchanged, instead of as an + # empty dict. + return None + final_parsed = {} + for member_name, member_shape in shape.members.items(): + json_name = member_shape.serialization.get("name", member_name) + raw_value = value.get(json_name) + parsed = self._parse_shape(request, member_shape, raw_value, uri_params) + if parsed is not None or member_name in shape.required_members: + # If the member is required, but not existing, we set it to None anyways + final_parsed[member_name] = parsed + return final_parsed + + def _parse_map( + self, + request: Request, + shape: MapShape, + value: Optional[dict], + uri_params: Mapping[str, Any] = None, + ) -> Optional[dict]: + if value is None: + return None + parsed = {} + key_shape = shape.key + value_shape = shape.value + for key, val in value.items(): + actual_key = self._parse_shape(request, key_shape, key, uri_params) + actual_value = self._parse_shape(request, value_shape, val, uri_params) + parsed[actual_key] = actual_value + return parsed + + def _parse_body_as_json(self, request: Request) -> dict: + body_contents = request.data + if not body_contents: + return {} + if request.mimetype.startswith("application/x-amz-cbor"): + try: + return cbor2_loads(body_contents) + except ValueError as e: + raise ProtocolParserError("HTTP body could not be parsed as CBOR.") from e + else: + try: + return request.get_json(force=True) + except BadRequest as e: + raise ProtocolParserError("HTTP body could not be parsed as JSON.") from e + + def _parse_boolean( + self, request: Request, shape: Shape, node: bool, uri_params: Mapping[str, Any] = None + ) -> bool: + return super()._noop_parser(request, shape, node, uri_params) + + def _parse_timestamp( + self, request: Request, shape: Shape, node: str, uri_params: Mapping[str, Any] = None + ) -> datetime.datetime: + if not shape.serialization.get("timestampFormat") and request.mimetype.startswith( + "application/x-amz-cbor" + ): + # cbor2 has native support for timestamp decoding, so this node could already have the right type + if isinstance(node, datetime.datetime): + return node + # otherwise parse the timestamp using the AWS CBOR timestamp format + # (non-CBOR-standard conform, uses millis instead of floating-point-millis) + return self._convert_str_to_timestamp(node, self.CBOR_TIMESTAMP_FORMAT) + return super()._parse_timestamp(request, shape, node, uri_params) + + def _parse_blob( + self, request: Request, shape: Shape, node: bool, uri_params: Mapping[str, Any] = None + ) -> bytes: + if isinstance(node, bytes) and request.mimetype.startswith("application/x-amz-cbor"): + # CBOR does not base64 encode binary data + return bytes(node) + else: + return super()._parse_blob(request, shape, node, uri_params) + + +class JSONRequestParser(BaseJSONRequestParser): + """ + The ``JSONRequestParser`` is responsible for parsing incoming requests for services which use the ``json`` + protocol. + The requests for these services encode the majority of their parameters as JSON in the request body. + The operation is defined in an HTTP header field. + """ + + @_handle_exceptions + def parse(self, request: Request) -> Tuple[OperationModel, Any]: + target = request.headers["X-Amz-Target"] + # assuming that the last part of the target string (e.g., "x.y.z.MyAction") contains the operation name + operation_name = target.rpartition(".")[2] + operation = self.service.operation_model(operation_name) + shape = operation.input_shape + # There are no uri params in the query protocol + uri_params = {} + final_parsed = self._do_parse(request, shape, uri_params) + return operation, final_parsed + + def _do_parse( + self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None + ) -> dict: + parsed = {} + if shape is not None: + event_name = shape.event_stream_name + if event_name: + parsed = self._handle_event_stream(request, shape, event_name) + else: + parsed = self._handle_json_body(request, shape, uri_params) + return parsed + + def _handle_event_stream(self, request: Request, shape: Shape, event_name: str): + # TODO handle event streams + raise NotImplementedError + + def _handle_json_body( + self, request: Request, shape: Shape, uri_params: Mapping[str, Any] = None + ) -> Any: + # The json.loads() gives us the primitive JSON types, but we need to traverse the parsed JSON data to convert + # to richer types (blobs, timestamps, etc.) + parsed_json = self._parse_body_as_json(request) + return self._parse_shape(request, shape, parsed_json, uri_params) + + +class RestJSONRequestParser(BaseRestRequestParser, BaseJSONRequestParser): + """ + The ``RestJSONRequestParser`` is responsible for parsing incoming requests for services which use the ``rest-json`` + protocol. + The requests for these services encode the majority of their parameters as JSON in the request body. + The operation is defined by the HTTP method and the path suffix. + """ + + def _initial_body_parse(self, request: Request) -> dict: + return self._parse_body_as_json(request) + + def _create_event_stream(self, request: Request, shape: Shape) -> Any: + raise NotImplementedError + + +class EC2RequestParser(QueryRequestParser): + """ + The ``EC2RequestParser`` is responsible for parsing incoming requests for services which use the ``ec2`` + protocol (which only is EC2). Protocol is quite similar to the ``query`` protocol with some small differences. + """ + + def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str: + # Returns the serialized name for the shape if it exists. + # Otherwise it will return the passed in default_name. + if "queryName" in shape.serialization: + return shape.serialization["queryName"] + elif "name" in shape.serialization: + # A locationName is always capitalized on input for the ec2 protocol. + name = shape.serialization["name"] + return name[0].upper() + name[1:] + else: + return default_name + + def _get_list_key_prefix(self, shape: ListShape, node: dict): + # The EC2 protocol does not use a prefix notation for flattened lists + return "" + + +class S3RequestParser(RestXMLRequestParser): + class VirtualHostRewriter: + """ + Context Manager which rewrites the request object parameters such that - within the context - it looks like a + normal S3 request. + FIXME: this is not optimal because it mutates the Request object. Once we have better utility to create/copy + a request instead of EnvironBuilder, we should copy it before parsing (except the stream). + """ + + def __init__(self, request: Request): + self.request = request + self.old_host = None + self.old_path = None + + def __enter__(self): + # only modify the request if it uses the virtual host addressing + if bucket_name := self._is_vhost_address_get_bucket(self.request): + # save the original path and host for restoring on context exit + self.old_path = self.request.path + self.old_host = self.request.host + self.old_raw_uri = self.request.environ.get("RAW_URI") + + # remove the bucket name from the host part of the request + new_host = self.old_host.removeprefix(f"{bucket_name}.") + + # put the bucket name at the front + new_path = "/" + bucket_name + self.old_path or "/" + + # create a new RAW_URI for the WSGI environment, this is necessary because of our `get_raw_path` utility + if self.old_raw_uri: + new_raw_uri = "/" + bucket_name + self.old_raw_uri or "/" + if qs := self.request.query_string: + new_raw_uri += "?" + qs.decode("utf-8") + else: + new_raw_uri = None + + # set the new path and host + self._set_request_props(self.request, new_path, new_host, new_raw_uri) + return self.request + + def __exit__(self, exc_type, exc_value, exc_traceback): + # reset the original request properties on exit of the context + if self.old_host or self.old_path: + self._set_request_props( + self.request, self.old_path, self.old_host, self.old_raw_uri + ) + + @staticmethod + def _set_request_props( + request: Request, path: str, host: str, raw_uri: Optional[str] = None + ): + """Sets the HTTP request's path and host and clears the cache in the request object.""" + request.path = path + request.headers["Host"] = host + if raw_uri: + request.environ["RAW_URI"] = raw_uri + + try: + # delete the werkzeug request property cache that depends on path, but make sure all of them are + # initialized first, otherwise `del` will raise a key error + request.host = None # noqa + request.url = None # noqa + request.base_url = None # noqa + request.full_path = None # noqa + request.host_url = None # noqa + request.root_url = None # noqa + del request.host # noqa + del request.url # noqa + del request.base_url # noqa + del request.full_path # noqa + del request.host_url # noqa + del request.root_url # noqa + except AttributeError: + pass + + @staticmethod + def _is_vhost_address_get_bucket(request: Request) -> str | None: + from localstack.services.s3.utils import uses_host_addressing + + return uses_host_addressing(request.headers) + + @_handle_exceptions + def parse(self, request: Request) -> Tuple[OperationModel, Any]: + """Handle virtual-host-addressing for S3.""" + with self.VirtualHostRewriter(request): + return super().parse(request) + + def _parse_shape( + self, request: Request, shape: Shape, node: Any, uri_params: Mapping[str, Any] = None + ) -> Any: + """ + Special handling of parsing the shape for s3 object-names (=key): + Trailing '/' are valid and need to be preserved, however, the url-matcher removes it from the key. + We need special logic to compare the parsed Key parameter against the path and add back the missing slashes + """ + if ( + shape is not None + and uri_params is not None + and shape.serialization.get("location") == "uri" + and shape.serialization.get("name") == "Key" + and ( + (trailing_slashes := request.path.rpartition(uri_params["Key"])[2]) + and all(char == "/" for char in trailing_slashes) + ) + ): + uri_params = dict(uri_params) + uri_params["Key"] = uri_params["Key"] + trailing_slashes + return super()._parse_shape(request, shape, node, uri_params) + + @_text_content + def _parse_integer(self, _, shape, node: str, ___) -> int | None: + # S3 accepts empty query string parameters that should be integer + # to not break other cases, validate that the shape is in the querystring + if node == "" and shape.serialization.get("location") == "querystring": + return None + return int(node) + + +class SQSQueryRequestParser(QueryRequestParser): + def _get_serialized_name(self, shape: Shape, default_name: str, node: dict) -> str: + """ + SQS allows using both - the proper serialized name of a map as well as the member name - as name for maps. + For example, both works for the TagQueue operation: + - Using the proper serialized name "Tag": Tag.1.Key=key&Tag.1.Value=value + - Using the member name "Tag" in the parent structure: Tags.1.Key=key&Tags.1.Value=value + - Using "Name" to represent the Key for a nested dict: MessageAttributes.1.Name=key&MessageAttributes.1.Value.StringValue=value + resulting in {MessageAttributes: {key : {StringValue: value}}} + The Java SDK implements the second variant: https://github.com/aws/aws-sdk-java-v2/issues/2524 + This has been approved to be a bug and against the spec, but since the client has a lot of users, and AWS SQS + supports both, we need to handle it here. + """ + # ask the super implementation for the proper serialized name + primary_name = super()._get_serialized_name(shape, default_name, node) + + # determine potential suffixes for the name of the member in the node + suffixes = [] + if shape.type_name == "map": + if not shape.serialization.get("flattened"): + suffixes = [".entry.1.Key", ".entry.1.Name"] + else: + suffixes = [".1.Key", ".1.Name"] + if shape.type_name == "list": + if not shape.serialization.get("flattened"): + suffixes = [".member.1"] + else: + suffixes = [".1"] + + # if the primary name is _not_ available in the node, but the default name is, we use the default name + if not any(f"{primary_name}{suffix}" in node for suffix in suffixes) and any( + f"{default_name}{suffix}" in node for suffix in suffixes + ): + return default_name + # otherwise we use the primary name + return primary_name + + +@functools.cache +def create_parser(service: ServiceModel) -> RequestParser: + """ + Creates the right parser for the given service model. + + :param service: to create the parser for + :return: RequestParser which can handle the protocol of the service + """ + # Unfortunately, some services show subtle differences in their parsing or operation detection behavior, even though + # their specification states they implement the same protocol. + # In order to avoid bundling the whole complexity in the specific protocols, or even have service-distinctions + # within the parser implementations, the service-specific parser implementations (basically the implicit / + # informally more specific protocol implementation) has precedence over the more general protocol-specific parsers. + service_specific_parsers = { + "s3": {"rest-xml": S3RequestParser}, + "sqs": {"query": SQSQueryRequestParser}, + } + protocol_specific_parsers = { + "query": QueryRequestParser, + "json": JSONRequestParser, + "rest-json": RestJSONRequestParser, + "rest-xml": RestXMLRequestParser, + "ec2": EC2RequestParser, + } + + # Try to select a service- and protocol-specific parser implementation + if ( + service.service_name in service_specific_parsers + and service.protocol in service_specific_parsers[service.service_name] + ): + return service_specific_parsers[service.service_name][service.protocol](service) + else: + # Otherwise, pick the protocol-specific parser for the protocol of the service + return protocol_specific_parsers[service.protocol](service) diff --git a/localstack-core/localstack/aws/protocol/routing.py b/localstack-core/localstack/aws/protocol/routing.py new file mode 100644 index 0000000000000..f793bd051ec27 --- /dev/null +++ b/localstack-core/localstack/aws/protocol/routing.py @@ -0,0 +1,69 @@ +import re +from typing import AnyStr + +from werkzeug.routing import Rule + +# Regex to find path parameters in requestUris of AWS service specs (f.e. /{param1}/{param2+}) +path_param_regex = re.compile(r"({.+?})") +# Translation table which replaces characters forbidden in Werkzeug rule names with temporary replacements +# Note: The temporary replacements must not occur in any requestUri of any operation in any service! +_rule_replacements = {"-": "_0_"} +# String translation table for #_rule_replacements for str#translate +_rule_replacement_table = str.maketrans(_rule_replacements) + + +class StrictMethodRule(Rule): + """ + Small extension to Werkzeug's Rule class which reverts unwanted assumptions made by Werkzeug. + Reverted assumptions: + - Werkzeug automatically matches HEAD requests to the corresponding GET request (i.e. Werkzeug's rule automatically + adds the HEAD HTTP method to a rule which should only match GET requests). This is implemented to simplify + implementing an app compliant with HTTP (where a HEAD request needs to return the headers of a corresponding GET + request), but it is unwanted for our strict rule matching in here. + """ + + def __init__(self, string: str, method: str, **kwargs) -> None: + super().__init__(string=string, methods=[method], **kwargs) + + # Make sure Werkzeug's Rule does not add any other methods + # (f.e. the HEAD method even though the rule should only match GET) + self.methods = {method.upper()} + + +def transform_path_params_to_rule_vars(match: re.Match[AnyStr]) -> str: + """ + Transforms a request URI path param to a valid Werkzeug Rule string variable placeholder. + This transformation function should be used in combination with _path_param_regex on the request URIs (without any + query params). + + :param match: Regex match which contains a single group. The match group is a request URI path param, including the + surrounding curly braces. + :return: Werkzeug rule string variable placeholder which is semantically equal to the given request URI path param + + """ + # get the group match and strip the curly braces + request_uri_variable: str = match.group(0)[1:-1] + + # if the request URI param is greedy (f.e. /foo/{Bar+}), add Werkzeug's "path" prefix (/foo/{path:Bar}) + greedy_prefix = "" + if request_uri_variable.endswith("+"): + greedy_prefix = "path:" + request_uri_variable = request_uri_variable.strip("+") + + # replace forbidden chars (not allowed in Werkzeug rule variable names) with their placeholder + escaped_request_uri_variable = request_uri_variable.translate(_rule_replacement_table) + + return f"<{greedy_prefix}{escaped_request_uri_variable}>" + + +def post_process_arg_name(arg_key: str) -> str: + """ + Reverses previous manipulations to the path parameters names (like replacing forbidden characters with + placeholders). + :param arg_key: Path param key name extracted using Werkzeug rules + :return: Post-processed ("un-sanitized") path param key + """ + result = arg_key + for original, substitution in _rule_replacements.items(): + result = result.replace(substitution, original) + return result diff --git a/localstack-core/localstack/aws/protocol/serializer.py b/localstack-core/localstack/aws/protocol/serializer.py new file mode 100644 index 0000000000000..86cabdd3487b6 --- /dev/null +++ b/localstack-core/localstack/aws/protocol/serializer.py @@ -0,0 +1,1860 @@ +""" +Response serializers for the different AWS service protocols. + +The module contains classes that take a service's response dict, and +given an operation model, serialize the HTTP response according to the +specified output shape. + +It can be seen as the counterpart to the ``parse`` module in ``botocore`` +(which parses the result of these serializer). It has a lot of +similarities with the ``serialize`` module in ``botocore``, but +serves a different purpose (serializing responses instead of requests). + +The different protocols have many similarities. The class hierarchy is +designed such that the serializers share as much logic as possible. +The class hierarchy looks as follows: +:: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ResponseSerializer β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² β–² β–² + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ └──────────────────┐ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚BaseXMLResponseSerializerβ”‚ β”‚BaseRestResponseSerializerβ”‚ β”‚JSONResponseSerializerβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² β–² β–² β–² β–² + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β” β”Œβ”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚QueryResponseSerializer β”‚ β”‚RestXMLResponseSerializerβ”‚ β”‚RestJSONResponseSerializerβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–² + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚EC2ResponseSerializerβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +:: + +The ``ResponseSerializer`` contains the logic that is used among all the +different protocols (``query``, ``json``, ``rest-json``, ``rest-xml``, and +``ec2``). +The protocols relate to each other in the following ways: + +* The ``query`` and the ``rest-xml`` protocols both have XML bodies in their + responses which are serialized quite similarly (with some specifics for each + type). +* The ``json`` and the ``rest-json`` protocols both have JSON bodies in their + responses which are serialized the same way. +* The ``rest-json`` and ``rest-xml`` protocols serialize some metadata in + the HTTP response's header fields +* The ``ec2`` protocol is basically similar to the ``query`` protocol with a + specific error response formatting. + +The serializer classes in this module correspond directly to the different +protocols. ``#create_serializer`` shows the explicit mapping between the +classes and the protocols. +The classes are structured as follows: + +* The ``ResponseSerializer`` contains all the basic logic for the + serialization which is shared among all different protocols. +* The ``BaseXMLResponseSerializer`` and the ``JSONResponseSerializer`` + contain the logic for the XML and the JSON serialization respectively. +* The ``BaseRestResponseSerializer`` contains the logic for the REST + protocol specifics (i.e. specific HTTP header serializations). +* The ``RestXMLResponseSerializer`` and the ``RestJSONResponseSerializer`` + inherit the ReST specific logic from the ``BaseRestResponseSerializer`` + and the XML / JSON body serialization from their second super class. + +The services and their protocols are defined by using AWS's Smithy +(a language to define services in a - somewhat - protocol-agnostic +way). The "peculiarities" in this serializer code usually correspond +to certain so-called "traits" in Smithy. + +The result of the serialization methods is the HTTP response which can +be sent back to the calling client. +""" + +import abc +import base64 +import functools +import json +import logging +import string +from abc import ABC +from binascii import crc32 +from datetime import datetime +from email.utils import formatdate +from struct import pack +from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union +from xml.etree import ElementTree as ETree + +import xmltodict +from botocore.model import ListShape, MapShape, OperationModel, ServiceModel, Shape, StructureShape +from botocore.serialize import ISO8601, ISO8601_MICRO +from botocore.utils import calculate_md5, is_json_value_header, parse_to_aware_datetime + +# cbor2: explicitly load from private _encoder module to avoid using the (non-patched) C-version +from cbor2._encoder import dumps as cbor2_dumps +from werkzeug import Request as WerkzeugRequest +from werkzeug import Response as WerkzeugResponse +from werkzeug.datastructures import Headers, MIMEAccept +from werkzeug.http import parse_accept_header + +from localstack.aws.api import CommonServiceException, ServiceException +from localstack.aws.spec import ProtocolName, load_service +from localstack.constants import ( + APPLICATION_AMZ_CBOR_1_1, + APPLICATION_AMZ_JSON_1_0, + APPLICATION_AMZ_JSON_1_1, + APPLICATION_CBOR, + APPLICATION_JSON, + APPLICATION_XML, + TEXT_XML, +) +from localstack.http import Response +from localstack.utils.common import to_bytes, to_str +from localstack.utils.strings import long_uid +from localstack.utils.xml import strip_xmlns + +LOG = logging.getLogger(__name__) + +REQUEST_ID_CHARACTERS = string.digits + string.ascii_uppercase + + +class ResponseSerializerError(Exception): + """ + Error which is thrown if the request serialization fails. + Super class of all exceptions raised by the serializer. + """ + + pass + + +class UnknownSerializerError(ResponseSerializerError): + """ + Error which indicates that the exception raised by the serializer could be caused by invalid data or by any other + (unknown) issue. Errors like this should be reported and indicate an issue in the serializer itself. + """ + + pass + + +class ProtocolSerializerError(ResponseSerializerError): + """ + Error which indicates that the given data is not compliant with the service's specification and cannot be + serialized. This usually results in a response to the client with an HTTP 5xx status code (internal server error). + """ + + pass + + +def _handle_exceptions(func): + """ + Decorator which handles the exceptions raised by the serializer. It ensures that all exceptions raised by the public + methods of the parser are instances of ResponseSerializerError. + :param func: to wrap in order to add the exception handling + :return: wrapped function + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ResponseSerializerError: + raise + except Exception as e: + raise UnknownSerializerError( + "An unknown error occurred when trying to serialize the response." + ) from e + + return wrapper + + +class ResponseSerializer(abc.ABC): + """ + The response serializer is responsible for the serialization of a service implementation's result to an actual + HTTP response (which will be sent to the calling client). + It is the base class of all serializers and therefore contains the basic logic which is used among all of them. + """ + + DEFAULT_ENCODING = "utf-8" + # The default timestamp format is ISO8601, but this can be overwritten by subclasses. + TIMESTAMP_FORMAT = "iso8601" + # Event streaming binary data type mapping for type "string" + AWS_BINARY_DATA_TYPE_STRING = 7 + # Defines the supported mime types of the specific serializer. Sorted by priority (preferred / default first). + # Needs to be specified by subclasses. + SUPPORTED_MIME_TYPES: List[str] = [] + + @_handle_exceptions + def serialize_to_response( + self, + response: dict, + operation_model: OperationModel, + headers: Optional[Dict | Headers], + request_id: str, + ) -> Response: + """ + Takes a response dict and serializes it to an actual HttpResponse. + + :param response: to serialize + :param operation_model: specification of the service & operation containing information about the shape of the + service's output / response + :param headers: the headers of the incoming request this response should be serialized for. This is necessary + for features like Content-Negotiation (define response content type based on request headers). + :param request_id: autogenerated AWS request ID identifying the original request + :return: Response which can be sent to the calling client + :raises: ResponseSerializerError (either a ProtocolSerializerError or an UnknownSerializerError) + """ + + # determine the preferred mime type (based on the serializer's supported mime types and the Accept header) + mime_type = self._get_mime_type(headers) + + # if the operation has a streaming output, handle the serialization differently + if operation_model.has_event_stream_output: + return self._serialize_event_stream(response, operation_model, mime_type, request_id) + + serialized_response = self._create_default_response(operation_model, mime_type) + shape = operation_model.output_shape + # The shape can also be none (for empty responses), but it still needs to be serialized (to add some metadata) + shape_members = shape.members if shape is not None else None + self._serialize_response( + response, + serialized_response, + shape, + shape_members, + operation_model, + mime_type, + request_id, + ) + serialized_response = self._prepare_additional_traits_in_response( + serialized_response, operation_model, request_id + ) + return serialized_response + + @_handle_exceptions + def serialize_error_to_response( + self, + error: ServiceException, + operation_model: OperationModel, + headers: Optional[Dict | Headers], + request_id: str, + ) -> Response: + """ + Takes an error instance and serializes it to an actual HttpResponse. + Therefore, this method is used for errors which should be serialized and transmitted to the calling client. + + :param error: to serialize + :param operation_model: specification of the service & operation containing information about the shape of the + service's output / response + :param headers: the headers of the incoming request this response should be serialized for. This is necessary + for features like Content-Negotiation (define response content type based on request headers). + :param request_id: autogenerated AWS request ID identifying the original request + :return: HttpResponse which can be sent to the calling client + :raises: ResponseSerializerError (either a ProtocolSerializerError or an UnknownSerializerError) + """ + # determine the preferred mime type (based on the serializer's supported mime types and the Accept header) + mime_type = self._get_mime_type(headers) + + # TODO implement streaming error serialization + serialized_response = self._create_default_response(operation_model, mime_type) + if not error or not isinstance(error, ServiceException): + raise ProtocolSerializerError( + f"Error to serialize ({error.__class__.__name__ if error else None}) is not a ServiceException." + ) + shape = operation_model.service_model.shape_for_error_code(error.code) + serialized_response.status_code = error.status_code + + self._serialize_error( + error, serialized_response, shape, operation_model, mime_type, request_id + ) + serialized_response = self._prepare_additional_traits_in_response( + serialized_response, operation_model, request_id + ) + return serialized_response + + def _serialize_response( + self, + parameters: dict, + response: Response, + shape: Optional[Shape], + shape_members: dict, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + raise NotImplementedError + + def _serialize_body_params( + self, + params: dict, + shape: Shape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> Optional[str]: + """ + Actually serializes the given params for the given shape to a string for the transmission in the body of the + response. + :param params: to serialize + :param shape: to know how to serialize the params + :param operation_model: for additional metadata + :param mime_type: Mime type which should be used to encode the payload + :param request_id: autogenerated AWS request ID identifying the original request + :return: string containing the serialized body + """ + raise NotImplementedError + + def _serialize_error( + self, + error: ServiceException, + response: Response, + shape: StructureShape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + raise NotImplementedError + + def _serialize_event_stream( + self, + response: dict, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> Response: + """ + Serializes a given response dict (the return payload of a service implementation) to an _event stream_ using the + given operation model. + + :param response: dictionary containing the payload for the response + :param operation_model: describing the operation the response dict is being returned by + :param mime_type: Mime type which should be used to encode the payload + :param request_id: autogenerated AWS request ID identifying the original request + :return: Response which can directly be sent to the client (in chunks) + """ + event_stream_shape = operation_model.get_event_stream_output() + event_stream_member_name = operation_model.output_shape.event_stream_name + + # wrap the generator in operation specific serialization + def event_stream_serializer() -> Iterable[bytes]: + yield self._encode_event_payload("initial-response") + + # create a default response + serialized_event_response = self._create_default_response(operation_model, mime_type) + # get the members of the event stream shape + event_stream_shape_members = ( + event_stream_shape.members if event_stream_shape is not None else None + ) + # extract the generator from the given response data + event_generator = response.get(event_stream_member_name) + if not isinstance(event_generator, Iterator): + raise ProtocolSerializerError( + "Expected iterator for streaming event serialization." + ) + + # yield one event per generated event + for event in event_generator: + # find the actual event payload (the member with event=true) + event_member_shape = None + event_member_name = None + for member_name, member_shape in event_stream_shape_members.items(): + if member_shape.serialization.get("event") and member_name in event: + event_member_shape = member_shape + event_member_name = member_name + break + if event_member_shape is None: + raise UnknownSerializerError("Couldn't find event shape for serialization.") + + # serialize the part of the response for the event + self._serialize_response( + event.get(event_member_name), + serialized_event_response, + event_member_shape, + event_member_shape.members if event_member_shape is not None else None, + operation_model, + mime_type, + request_id, + ) + # execute additional response traits (might be modifying the response) + serialized_event_response = self._prepare_additional_traits_in_response( + serialized_event_response, operation_model, request_id + ) + # encode the event and yield it + yield self._encode_event_payload( + event_type=event_member_name, content=serialized_event_response.data + ) + + return Response( + response=event_stream_serializer(), + status=operation_model.http.get("responseCode", 200), + ) + + def _encode_event_payload( + self, + event_type: str, + content: Union[str, bytes] = "", + error_code: Optional[str] = None, + error_message: Optional[str] = None, + ) -> bytes: + """ + Encodes the given event payload according to AWS specific binary event encoding. + A specification of the format can be found in the AWS docs: + https://docs.aws.amazon.com/AmazonS3/latest/API/RESTSelectObjectAppendix.html + + :param content: string or bytes of the event payload + :param event_type: type of the event. Usually the name of the event shape or specific event types like + "initial-response". + :param error_code: Optional. Error code if the payload represents an error. + :param error_message: Optional. Error message if the payload represents an error. + :return: bytes with the AWS-specific encoded event payload + """ + + # determine the event type (error if an error message or an error code is set) + if error_message or error_code: + message_type = "error" + else: + message_type = "event" + + # set the headers + headers = {":event-type": event_type, ":message-type": message_type} + if error_message: + headers[":error-message"] = error_message + if error_code: + headers[":error-code"] = error_code + + # construct headers + header_section = b"" + for key, value in headers.items(): + header_name = key.encode(self.DEFAULT_ENCODING) + header_value = to_bytes(value) + header_section += pack("!B", len(header_name)) + header_section += header_name + header_section += pack("!B", self.AWS_BINARY_DATA_TYPE_STRING) + header_section += pack("!H", len(header_value)) + header_section += header_value + + # construct body + if isinstance(content, str): + payload = bytes(content, self.DEFAULT_ENCODING) + else: + payload = content + + # calculate lengths + headers_length = len(header_section) + payload_length = len(payload) + + # construct message + # - prelude + result = pack("!I", payload_length + headers_length + 16) + result += pack("!I", headers_length) + # - prelude crc + prelude_crc = crc32(result) + result += pack("!I", prelude_crc) + # - headers + result += header_section + # - payload + result += payload + # - message crc + payload_crc = crc32(result) + result += pack("!I", payload_crc) + + return result + + def _create_default_response(self, operation_model: OperationModel, mime_type: str) -> Response: + """ + Creates a boilerplate default response to be used by subclasses as starting points. + Uses the default HTTP response status code defined in the operation model (if defined), otherwise 200. + + :param operation_model: to extract the default HTTP status code + :param mime_type: Mime type which should be used to encode the payload + :return: boilerplate HTTP response + """ + return Response(status=operation_model.http.get("responseCode", 200)) + + def _get_mime_type(self, headers: Optional[Dict | Headers]) -> str: + """ + Extracts the accepted mime type from the request headers and returns a matching, supported mime type for the + serializer or the default mime type of the service if there is no match. + :param headers: to extract the "Accept" header from + :return: preferred mime type to be used by the serializer (if it is not accepted by the client, + an error is logged) + """ + accept_header = None + if headers and "Accept" in headers and not headers.get("Accept") == "*/*": + accept_header = headers.get("Accept") + elif headers and headers.get("Content-Type"): + # If there is no specific Accept header given, we use the given Content-Type as a fallback. + # i.e. if the request content was JSON encoded and the client doesn't send a specific an Accept header, the + # serializer should prefer JSON encoding. + content_type = headers.get("Content-Type") + LOG.debug( + "No accept header given. Using request's Content-Type (%s) as preferred response Content-Type.", + content_type, + ) + accept_header = content_type + ", */*" + mime_accept: MIMEAccept = parse_accept_header(accept_header, MIMEAccept) + mime_type = mime_accept.best_match(self.SUPPORTED_MIME_TYPES) + if not mime_type: + # There is no match between the supported mime types and the requested one(s) + mime_type = self.SUPPORTED_MIME_TYPES[0] + LOG.debug( + "Determined accept type (%s) is not supported by this serializer. Using default of this serializer: %s", + accept_header, + mime_type, + ) + return mime_type + + # Some extra utility methods subclasses can use. + + @staticmethod + def _timestamp_iso8601(value: datetime) -> str: + if value.microsecond > 0: + timestamp_format = ISO8601_MICRO + else: + timestamp_format = ISO8601 + return value.strftime(timestamp_format) + + @staticmethod + def _timestamp_unixtimestamp(value: datetime) -> float: + return value.timestamp() + + def _timestamp_rfc822(self, value: datetime) -> str: + if isinstance(value, datetime): + value = self._timestamp_unixtimestamp(value) + return formatdate(value, usegmt=True) + + def _convert_timestamp_to_str( + self, value: Union[int, str, datetime], timestamp_format=None + ) -> str: + if timestamp_format is None: + timestamp_format = self.TIMESTAMP_FORMAT + timestamp_format = timestamp_format.lower() + datetime_obj = parse_to_aware_datetime(value) + converter = getattr(self, "_timestamp_%s" % timestamp_format) + final_value = converter(datetime_obj) + return final_value + + @staticmethod + def _get_serialized_name(shape: Shape, default_name: str) -> str: + """ + Returns the serialized name for the shape if it exists. + Otherwise, it will return the passed in default_name. + """ + return shape.serialization.get("name", default_name) + + def _get_base64(self, value: Union[str, bytes]): + """ + Returns the base64-encoded version of value, handling + both strings and bytes. The returned value is a string + via the default encoding. + """ + if isinstance(value, str): + value = value.encode(self.DEFAULT_ENCODING) + return base64.b64encode(value).strip().decode(self.DEFAULT_ENCODING) + + def _encode_payload(self, body: Union[bytes, str]) -> bytes: + if isinstance(body, str): + return body.encode(self.DEFAULT_ENCODING) + return body + + def _prepare_additional_traits_in_response( + self, response: Response, operation_model: OperationModel, request_id: str + ): + """Applies additional traits on the raw response for a given model or protocol.""" + if operation_model.http_checksum_required: + self._add_md5_header(response) + return response + + def _has_header(self, header_name: str, headers: dict): + """Case-insensitive check for header key.""" + if header_name is None: + return False + else: + return header_name.lower() in [key.lower() for key in headers.keys()] + + def _add_md5_header(self, response: Response): + """Add a Content-MD5 header if not yet there. Adapted from botocore.utils""" + headers = response.headers + body = response.data + if body is not None and "Content-MD5" not in headers: + md5_digest = calculate_md5(body) + headers["Content-MD5"] = md5_digest + + def _get_error_message(self, error: Exception) -> Optional[str]: + return str(error) if error is not None and str(error) != "None" else None + + +class BaseXMLResponseSerializer(ResponseSerializer): + """ + The BaseXMLResponseSerializer performs the basic logic for the XML response serialization. + It is slightly adapted by the QueryResponseSerializer. + While the botocore's RestXMLSerializer is quite similar, there are some subtle differences (since botocore's + implementation handles the serialization of the requests from the client to the service, not the responses from the + service to the client). + """ + + SUPPORTED_MIME_TYPES = [TEXT_XML, APPLICATION_XML, APPLICATION_JSON] + + def _serialize_error( + self, + error: ServiceException, + response: Response, + shape: StructureShape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + # Check if we need to add a namespace + attr = ( + {"xmlns": operation_model.metadata.get("xmlNamespace")} + if "xmlNamespace" in operation_model.metadata + else {} + ) + root = ETree.Element("ErrorResponse", attr) + + error_tag = ETree.SubElement(root, "Error") + self._add_error_tags(error, error_tag, mime_type) + request_id_element = ETree.SubElement(root, "RequestId") + request_id_element.text = request_id + + self._add_additional_error_tags(vars(error), root, shape, mime_type) + + response.set_response(self._encode_payload(self._node_to_string(root, mime_type))) + + def _add_error_tags( + self, error: ServiceException, error_tag: ETree.Element, mime_type: str + ) -> None: + code_tag = ETree.SubElement(error_tag, "Code") + code_tag.text = error.code + message = self._get_error_message(error) + if message: + self._default_serialize(error_tag, message, None, "Message", mime_type) + if error.sender_fault: + # The sender fault is either not set or "Sender" + self._default_serialize(error_tag, "Sender", None, "Type", mime_type) + + def _add_additional_error_tags( + self, parameters: dict, node: ETree, shape: StructureShape, mime_type: str + ): + if shape: + params = {} + # TODO add a possibility to serialize simple non-modelled errors (like S3 NoSuchBucket#BucketName) + for member in shape.members: + # XML protocols do not add modeled default fields to the root node + # (tested for cloudfront, route53, cloudwatch, iam) + if member.lower() not in ["code", "message"] and member in parameters: + params[member] = parameters[member] + + # If there is an error shape with members which should be set, they need to be added to the node + if params: + # Serialize the remaining params + root_name = shape.serialization.get("name", shape.name) + pseudo_root = ETree.Element("") + self._serialize(shape, params, pseudo_root, root_name, mime_type) + real_root = list(pseudo_root)[0] + # Add the child elements to the already created root error element + for child in list(real_root): + node.append(child) + + def _serialize_body_params( + self, + params: dict, + shape: Shape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> Optional[str]: + root = self._serialize_body_params_to_xml(params, shape, operation_model, mime_type) + self._prepare_additional_traits_in_xml(root, request_id) + return self._node_to_string(root, mime_type) + + def _serialize_body_params_to_xml( + self, params: dict, shape: Shape, operation_model: OperationModel, mime_type: str + ) -> Optional[ETree.Element]: + if shape is None: + return + # The botocore serializer expects `shape.serialization["name"]`, but this isn't always present for responses + root_name = shape.serialization.get("name", shape.name) + pseudo_root = ETree.Element("") + self._serialize(shape, params, pseudo_root, root_name, mime_type) + real_root = list(pseudo_root)[0] + return real_root + + def _serialize( + self, shape: Shape, params: Any, xmlnode: ETree.Element, name: str, mime_type: str + ) -> None: + """This method dynamically invokes the correct `_serialize_type_*` method for each shape type.""" + if shape is None: + return + # Some output shapes define a `resultWrapper` in their serialization spec. + # While the name would imply that the result is _wrapped_, it is actually renamed. + if shape.serialization.get("resultWrapper"): + name = shape.serialization.get("resultWrapper") + + try: + method = getattr(self, "_serialize_type_%s" % shape.type_name, self._default_serialize) + method(xmlnode, params, shape, name, mime_type) + except (TypeError, ValueError, AttributeError) as e: + raise ProtocolSerializerError( + f"Invalid type when serializing {shape.name}: '{xmlnode}' cannot be parsed to {shape.type_name}." + ) from e + + def _serialize_type_structure( + self, xmlnode: ETree.Element, params: dict, shape: StructureShape, name: str, mime_type + ) -> None: + structure_node = ETree.SubElement(xmlnode, name) + + if "xmlNamespace" in shape.serialization: + namespace_metadata = shape.serialization["xmlNamespace"] + attribute_name = "xmlns" + if namespace_metadata.get("prefix"): + attribute_name += ":%s" % namespace_metadata["prefix"] + structure_node.attrib[attribute_name] = namespace_metadata["uri"] + for key, value in params.items(): + if value is None: + # Don't serialize any param whose value is None. + continue + try: + member_shape = shape.members[key] + except KeyError: + LOG.warning( + "Response object %s contains a member which is not specified: %s", + shape.name, + key, + ) + continue + member_name = member_shape.serialization.get("name", key) + # We need to special case member shapes that are marked as an xmlAttribute. + # Rather than serializing into an XML child node, we instead serialize the shape to + # an XML attribute of the *current* node. + if member_shape.serialization.get("xmlAttribute"): + # xmlAttributes must have a serialization name. + xml_attribute_name = member_shape.serialization["name"] + structure_node.attrib[xml_attribute_name] = value + continue + self._serialize(member_shape, value, structure_node, member_name, mime_type) + + def _serialize_type_list( + self, xmlnode: ETree.Element, params: list, shape: ListShape, name: str, mime_type: str + ) -> None: + if params is None: + # Don't serialize any param whose value is None. + return + member_shape = shape.member + if shape.serialization.get("flattened"): + # If the list is flattened, either take the member's "name" or the name of the usual name for the parent + # element for the children. + element_name = self._get_serialized_name(member_shape, name) + list_node = xmlnode + else: + element_name = self._get_serialized_name(member_shape, "member") + list_node = ETree.SubElement(xmlnode, name) + for item in params: + # Don't serialize any item which is None + if item is not None: + self._serialize(member_shape, item, list_node, element_name, mime_type) + + def _serialize_type_map( + self, xmlnode: ETree.Element, params: dict, shape: MapShape, name: str, mime_type: str + ) -> None: + """ + Given the ``name`` of MyMap, an input of {"key1": "val1", "key2": "val2"}, and the ``flattened: False`` + we serialize this as: + <MyMap> + <entry> + <key>key1</key> + <value>val1</value> + </entry> + <entry> + <key>key2</key> + <value>val2</value> + </entry> + </MyMap> + If it is flattened, it is serialized as follows: + <MyMap> + <key>key1</key> + <value>val1</value> + </MyMap> + <MyMap> + <key>key2</key> + <value>val2</value> + </MyMap> + """ + if params is None: + # Don't serialize a non-existing map + return + if shape.serialization.get("flattened"): + entries_node = xmlnode + entry_node_name = name + else: + entries_node = ETree.SubElement(xmlnode, name) + entry_node_name = "entry" + + for key, value in params.items(): + if value is None: + # Don't serialize any param whose value is None. + continue + entry_node = ETree.SubElement(entries_node, entry_node_name) + key_name = self._get_serialized_name(shape.key, default_name="key") + val_name = self._get_serialized_name(shape.value, default_name="value") + self._serialize(shape.key, key, entry_node, key_name, mime_type) + self._serialize(shape.value, value, entry_node, val_name, mime_type) + + @staticmethod + def _serialize_type_boolean(xmlnode: ETree.Element, params: bool, _, name: str, __) -> None: + """ + For scalar types, the 'params' attr is actually just a scalar value representing the data + we need to serialize as a boolean. It will either be 'true' or 'false' + """ + node = ETree.SubElement(xmlnode, name) + if params: + str_value = "true" + else: + str_value = "false" + node.text = str_value + + def _serialize_type_blob( + self, xmlnode: ETree.Element, params: Union[str, bytes], _, name: str, __ + ) -> None: + node = ETree.SubElement(xmlnode, name) + node.text = self._get_base64(params) + + def _serialize_type_timestamp( + self, xmlnode: ETree.Element, params: str, shape: Shape, name: str, mime_type: str + ) -> None: + node = ETree.SubElement(xmlnode, name) + if mime_type != APPLICATION_JSON: + # Default XML timestamp serialization + node.text = self._convert_timestamp_to_str( + params, shape.serialization.get("timestampFormat") + ) + else: + # For services with XML protocols, where the Accept header is JSON, timestamps are formatted like for JSON + # protocols, but using the int representation instead of the float representation (f.e. requesting JSON + # responses in STS). + node.text = str( + int(self._convert_timestamp_to_str(params, JSONResponseSerializer.TIMESTAMP_FORMAT)) + ) + + def _default_serialize(self, xmlnode: ETree.Element, params: str, _, name: str, __) -> None: + node = ETree.SubElement(xmlnode, name) + node.text = str(params) + + def _prepare_additional_traits_in_xml(self, root: Optional[ETree.Element], request_id: str): + """ + Prepares the XML root node before being serialized with additional traits (like the Response ID in the Query + protocol). + For some protocols (like rest-xml), the root can be None. + """ + pass + + def _create_default_response(self, operation_model: OperationModel, mime_type: str) -> Response: + response = super()._create_default_response(operation_model, mime_type) + response.headers["Content-Type"] = mime_type + return response + + def _node_to_string(self, root: Optional[ETree.Element], mime_type: str) -> Optional[str]: + """Generates the string representation of the given XML element.""" + if root is not None: + content = ETree.tostring( + element=root, encoding=self.DEFAULT_ENCODING, xml_declaration=True + ) + if mime_type == APPLICATION_JSON: + # FIXME try to directly convert the ElementTree node to JSON + xml_dict = xmltodict.parse(content) + xml_dict = strip_xmlns(xml_dict) + content = json.dumps(xml_dict) + return content + + +class BaseRestResponseSerializer(ResponseSerializer, ABC): + """ + The BaseRestResponseSerializer performs the basic logic for the ReST response serialization. + In our case it basically only adds the request metadata to the HTTP header. + """ + + HEADER_TIMESTAMP_FORMAT = "rfc822" + + def _serialize_response( + self, + parameters: dict, + response: Response, + shape: Optional[Shape], + shape_members: dict, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + header_params, payload_params = self._partition_members(parameters, shape) + self._process_header_members(header_params, response, shape) + # "HEAD" responses are basically "GET" responses without the actual body. + # Do not process the body payload in this case (setting a body could also manipulate the headers) + if operation_model.http.get("method") != "HEAD": + self._serialize_payload( + payload_params, + response, + shape, + shape_members, + operation_model, + mime_type, + request_id, + ) + self._serialize_content_type(response, shape, shape_members, mime_type) + self._prepare_additional_traits_in_response(response, operation_model, request_id) + + def _serialize_payload( + self, + parameters: dict, + response: Response, + shape: Optional[Shape], + shape_members: dict, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + """ + Serializes the given payload. + + :param parameters: The user input params + :param response: The final serialized Response + :param shape: Describes the expected output shape (can be None in case of an "empty" response) + :param shape_members: The members of the output struct shape + :param operation_model: The specification of the operation of which the response is serialized here + :param mime_type: Mime type which should be used to encode the payload + :param request_id: autogenerated AWS request ID identifying the original request + :return: None - the given `serialized` dict is modified + """ + if shape is None: + return + + payload_member = shape.serialization.get("payload") + # If this shape is defined as being an event, we need to search for the payload member + if not payload_member and shape.serialization.get("event"): + for member_name, member_shape in shape_members.items(): + # Try to find the first shape which is marked as "eventpayload" and is given in the params dict + if member_shape.serialization.get("eventpayload") and parameters.get(member_name): + payload_member = member_name + break + if payload_member is not None and shape_members[payload_member].type_name in [ + "blob", + "string", + ]: + # If it's streaming, then the body is just the value of the payload. + body_payload = parameters.get(payload_member, b"") + body_payload = self._encode_payload(body_payload) + response.set_response(body_payload) + elif payload_member is not None: + # If there's a payload member, we serialized that member to the body. + body_params = parameters.get(payload_member) + if body_params is not None: + response.set_response( + self._encode_payload( + self._serialize_body_params( + body_params, + shape_members[payload_member], + operation_model, + mime_type, + request_id, + ) + ) + ) + else: + # Otherwise, we use the "traditional" way of serializing the whole parameters dict recursively. + response.set_response( + self._encode_payload( + self._serialize_body_params( + parameters, shape, operation_model, mime_type, request_id + ) + ) + ) + + def _serialize_content_type( + self, serialized: Response, shape: Shape, shape_members: dict, mime_type: str + ): + """ + Some protocols require varied Content-Type headers depending on user input. + This allows subclasses to apply this conditionally. + """ + pass + + def _has_streaming_payload(self, payload: Optional[str], shape_members): + """Determine if payload is streaming (a blob or string).""" + return payload is not None and shape_members[payload].type_name in ["blob", "string"] + + def _prepare_additional_traits_in_response( + self, response: Response, operation_model: OperationModel, request_id: str + ): + """Adds the request ID to the headers (in contrast to the body - as in the Query protocol).""" + response = super()._prepare_additional_traits_in_response( + response, operation_model, request_id + ) + response.headers["x-amz-request-id"] = request_id + return response + + def _process_header_members(self, parameters: dict, response: Response, shape: Shape): + shape_members = shape.members if isinstance(shape, StructureShape) else [] + for name in shape_members: + member_shape = shape_members[name] + location = member_shape.serialization.get("location") + if not location: + continue + if name not in parameters: + # ignores optional keys + continue + key = member_shape.serialization.get("name", name) + value = parameters[name] + if value is None: + continue + if location == "header": + response.headers[key] = self._serialize_header_value(member_shape, value) + elif location == "headers": + header_prefix = key + self._serialize_header_map(header_prefix, response, value) + elif location == "statusCode": + response.status_code = int(value) + + def _serialize_header_map(self, prefix: str, response: Response, params: dict) -> None: + """Serializes the header map for the location trait "headers".""" + for key, val in params.items(): + actual_key = prefix + key + response.headers[actual_key] = val + + def _serialize_header_value(self, shape: Shape, value: Any): + """Serializes a value for the location trait "header".""" + if shape.type_name == "timestamp": + datetime_obj = parse_to_aware_datetime(value) + timestamp_format = shape.serialization.get( + "timestampFormat", self.HEADER_TIMESTAMP_FORMAT + ) + return self._convert_timestamp_to_str(datetime_obj, timestamp_format) + elif shape.type_name == "list": + converted_value = [ + self._serialize_header_value(shape.member, v) for v in value if v is not None + ] + return ",".join(converted_value) + elif shape.type_name == "boolean": + # Set the header value to "true" if the given value is truthy, otherwise set the header value to "false". + return "true" if value else "false" + elif is_json_value_header(shape): + # Serialize with no spaces after separators to save space in + # the header. + return self._get_base64(json.dumps(value, separators=(",", ":"))) + else: + return value + + def _partition_members(self, parameters: dict, shape: Optional[Shape]) -> Tuple[dict, dict]: + """Separates the top-level keys in the given parameters dict into header- and payload-located params.""" + if not isinstance(shape, StructureShape): + # If the shape isn't a structure, we default to the whole response being parsed in the body. + # Non-payload members are only loaded in the top-level hierarchy and those are always structures. + return {}, parameters + header_params = {} + payload_params = {} + shape_members = shape.members + for name in shape_members: + member_shape = shape_members[name] + if name not in parameters: + continue + location = member_shape.serialization.get("location") + if location: + header_params[name] = parameters[name] + else: + payload_params[name] = parameters[name] + return header_params, payload_params + + +class RestXMLResponseSerializer(BaseRestResponseSerializer, BaseXMLResponseSerializer): + """ + The ``RestXMLResponseSerializer`` is responsible for the serialization of responses from services with the + ``rest-xml`` protocol. + It combines the ``BaseRestResponseSerializer`` (for the ReST specific logic) with the ``BaseXMLResponseSerializer`` + (for the XML body response serialization). + """ + + pass + + +class QueryResponseSerializer(BaseXMLResponseSerializer): + """ + The ``QueryResponseSerializer`` is responsible for the serialization of responses from services which use the + ``query`` protocol. The responses of these services also use XML. It is basically a subset of the features, since it + does not allow any payload or location traits. + """ + + def _serialize_response( + self, + parameters: dict, + response: Response, + shape: Optional[Shape], + shape_members: dict, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + """ + Serializes the given parameters as XML for the query protocol. + + :param parameters: The user input params + :param response: The final serialized Response + :param shape: Describes the expected output shape (can be None in case of an "empty" response) + :param shape_members: The members of the output struct shape + :param operation_model: The specification of the operation of which the response is serialized here + :param mime_type: Mime type which should be used to encode the payload + :param request_id: autogenerated AWS request ID identifying the original request + :return: None - the given `serialized` dict is modified + """ + response.set_response( + self._encode_payload( + self._serialize_body_params( + parameters, shape, operation_model, mime_type, request_id + ) + ) + ) + + def _serialize_body_params_to_xml( + self, params: dict, shape: Shape, operation_model: OperationModel, mime_type: str + ) -> ETree.Element: + # The Query protocol responses have a root element which is not contained in the specification file. + # Therefore, we first call the super function to perform the normal XML serialization, and afterwards wrap the + # result in a root element based on the operation name. + node = super()._serialize_body_params_to_xml(params, shape, operation_model, mime_type) + + # Check if we need to add a namespace + attr = ( + {"xmlns": operation_model.metadata.get("xmlNamespace")} + if "xmlNamespace" in operation_model.metadata + else None + ) + + # Create the root element and add the result of the XML serializer as a child node + root = ETree.Element(f"{operation_model.name}Response", attr) + if node is not None: + root.append(node) + return root + + def _prepare_additional_traits_in_xml(self, root: Optional[ETree.Element], request_id: str): + # Add the response metadata here (it's not defined in the specs) + # For the ec2 and the query protocol, the root cannot be None at this time. + response_metadata = ETree.SubElement(root, "ResponseMetadata") + request_id_element = ETree.SubElement(response_metadata, "RequestId") + request_id_element.text = request_id + + +class EC2ResponseSerializer(QueryResponseSerializer): + """ + The ``EC2ResponseSerializer`` is responsible for the serialization of responses from services which use the + ``ec2`` protocol (basically the EC2 service). This protocol is basically equal to the ``query`` protocol with only + a few subtle differences. + """ + + def _serialize_error( + self, + error: ServiceException, + response: Response, + shape: StructureShape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + # EC2 errors look like: + # <Response> + # <Errors> + # <Error> + # <Code>InvalidInstanceID.Malformed</Code> + # <Message>Invalid id: "1343124"</Message> + # </Error> + # </Errors> + # <RequestID>12345</RequestID> + # </Response> + # This is different from QueryParser in that it's RequestID, not RequestId + # and that the Error tag is in an enclosing Errors tag. + attr = ( + {"xmlns": operation_model.metadata.get("xmlNamespace")} + if "xmlNamespace" in operation_model.metadata + else None + ) + root = ETree.Element("Response", attr) + errors_tag = ETree.SubElement(root, "Errors") + error_tag = ETree.SubElement(errors_tag, "Error") + self._add_error_tags(error, error_tag, mime_type) + request_id_element = ETree.SubElement(root, "RequestID") + request_id_element.text = request_id + response.set_response(self._encode_payload(self._node_to_string(root, mime_type))) + + def _prepare_additional_traits_in_xml(self, root: Optional[ETree.Element], request_id: str): + # The EC2 protocol does not use the root output shape, therefore we need to remove the hierarchy level + # below the root level + if len(root) > 0: + output_node = root[0] + for child in output_node: + root.append(child) + root.remove(output_node) + + # Add the requestId here (it's not defined in the specs) + # For the ec2 and the query protocol, the root cannot be None at this time. + request_id_element = ETree.SubElement(root, "requestId") + request_id_element.text = request_id + + +class JSONResponseSerializer(ResponseSerializer): + """ + The ``JSONResponseSerializer`` is responsible for the serialization of responses from services with the ``json`` + protocol. It implements the JSON response body serialization, which is also used by the + ``RestJSONResponseSerializer``. + """ + + JSON_TYPES = [APPLICATION_JSON, APPLICATION_AMZ_JSON_1_0, APPLICATION_AMZ_JSON_1_1] + CBOR_TYPES = [APPLICATION_CBOR, APPLICATION_AMZ_CBOR_1_1] + SUPPORTED_MIME_TYPES = JSON_TYPES + CBOR_TYPES + + TIMESTAMP_FORMAT = "unixtimestamp" + + def _serialize_error( + self, + error: ServiceException, + response: Response, + shape: StructureShape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + body = dict() + + # TODO implement different service-specific serializer configurations + # - currently we set both, the `__type` member as well as the `X-Amzn-Errortype` header + # - the specification defines that it's either the __type field OR the header + response.headers["X-Amzn-Errortype"] = error.code + body["__type"] = error.code + + if shape: + remaining_params = {} + # TODO add a possibility to serialize simple non-modelled errors (like S3 NoSuchBucket#BucketName) + for member in shape.members: + if hasattr(error, member): + remaining_params[member] = getattr(error, member) + # Default error message fields can sometimes have different casing in the specs + elif member.lower() in ["code", "message"] and hasattr(error, member.lower()): + remaining_params[member] = getattr(error, member.lower()) + self._serialize(body, remaining_params, shape, None, mime_type) + + # Only set the message if it has not been set with the shape members + if "message" not in body and "Message" not in body: + message = self._get_error_message(error) + if message is not None: + body["message"] = message + + if mime_type in self.CBOR_TYPES: + response.set_response(cbor2_dumps(body, datetime_as_timestamp=True)) + response.content_type = mime_type + else: + response.set_json(body) + + def _serialize_response( + self, + parameters: dict, + response: Response, + shape: Optional[Shape], + shape_members: dict, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + if mime_type in self.CBOR_TYPES: + response.content_type = mime_type + else: + json_version = operation_model.metadata.get("jsonVersion") + if json_version is not None: + response.headers["Content-Type"] = "application/x-amz-json-%s" % json_version + response.set_response( + self._serialize_body_params(parameters, shape, operation_model, mime_type, request_id) + ) + + def _serialize_body_params( + self, + params: dict, + shape: Shape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> Optional[str]: + body = {} + if shape is not None: + self._serialize(body, params, shape, None, mime_type) + + if mime_type in self.CBOR_TYPES: + return cbor2_dumps(body, datetime_as_timestamp=True) + else: + return json.dumps(body) + + def _serialize(self, body: dict, value: Any, shape, key: Optional[str], mime_type: str): + """This method dynamically invokes the correct `_serialize_type_*` method for each shape type.""" + try: + method = getattr(self, "_serialize_type_%s" % shape.type_name, self._default_serialize) + method(body, value, shape, key, mime_type) + except (TypeError, ValueError, AttributeError) as e: + raise ProtocolSerializerError( + f"Invalid type when serializing {shape.name}: '{value}' cannot be parsed to {shape.type_name}." + ) from e + + def _serialize_type_structure( + self, body: dict, value: dict, shape: StructureShape, key: Optional[str], mime_type: str + ): + if value is None: + return + if shape.is_document_type: + body[key] = value + else: + if key is not None: + # If a key is provided, this is a result of a recursive + # call, so we need to add a new child dict as the value + # of the passed in serialized dict. We'll then add + # all the structure members as key/vals in the new serialized + # dictionary we just created. + new_serialized = {} + body[key] = new_serialized + body = new_serialized + members = shape.members + for member_key, member_value in value.items(): + if member_value is None: + continue + try: + member_shape = members[member_key] + except KeyError: + LOG.warning( + "Response object %s contains a member which is not specified: %s", + shape.name, + member_key, + ) + continue + if "name" in member_shape.serialization: + member_key = member_shape.serialization["name"] + self._serialize(body, member_value, member_shape, member_key, mime_type) + + def _serialize_type_map( + self, body: dict, value: dict, shape: MapShape, key: str, mime_type: str + ): + if value is None: + return + map_obj = {} + body[key] = map_obj + for sub_key, sub_value in value.items(): + if sub_value is not None: + self._serialize(map_obj, sub_value, shape.value, sub_key, mime_type) + + def _serialize_type_list( + self, body: dict, value: list, shape: ListShape, key: str, mime_type: str + ): + if value is None: + return + list_obj = [] + body[key] = list_obj + for list_item in value: + if list_item is not None: + wrapper = {} + # The JSON list serialization is the only case where we aren't + # setting a key on a dict. We handle this by using + # a __current__ key on a wrapper dict to serialize each + # list item before appending it to the serialized list. + self._serialize(wrapper, list_item, shape.member, "__current__", mime_type) + list_obj.append(wrapper["__current__"]) + + def _default_serialize(self, body: dict, value: Any, _, key: str, __): + body[key] = value + + def _serialize_type_timestamp( + self, body: dict, value: Any, shape: Shape, key: str, mime_type: str + ): + if mime_type in self.CBOR_TYPES: + # CBOR has native support for timestamps + body[key] = value + else: + timestamp_format = shape.serialization.get("timestampFormat") + body[key] = self._convert_timestamp_to_str(value, timestamp_format) + + def _serialize_type_blob( + self, body: dict, value: Union[str, bytes], _, key: str, mime_type: str + ): + if mime_type in self.CBOR_TYPES: + body[key] = value + else: + body[key] = self._get_base64(value) + + def _prepare_additional_traits_in_response( + self, response: Response, operation_model: OperationModel, request_id: str + ): + response.headers["x-amzn-requestid"] = request_id + response = super()._prepare_additional_traits_in_response( + response, operation_model, request_id + ) + return response + + +class RestJSONResponseSerializer(BaseRestResponseSerializer, JSONResponseSerializer): + """ + The ``RestJSONResponseSerializer`` is responsible for the serialization of responses from services with the + ``rest-json`` protocol. + It combines the ``BaseRestResponseSerializer`` (for the ReST specific logic) with the ``JSONResponseSerializer`` + (for the JSOn body response serialization). + """ + + def _serialize_content_type( + self, serialized: Response, shape: Shape, shape_members: dict, mime_type: str + ): + """Set Content-Type to application/json for all structured bodies.""" + payload = shape.serialization.get("payload") if shape is not None else None + if self._has_streaming_payload(payload, shape_members): + # Don't apply content-type to streaming bodies + return + + has_body = serialized.data != b"" + has_content_type = self._has_header("Content-Type", serialized.headers) + if has_body and not has_content_type: + serialized.headers["Content-Type"] = mime_type + + +class S3ResponseSerializer(RestXMLResponseSerializer): + """ + The ``S3ResponseSerializer`` adds some minor logic to handle S3 specific peculiarities with the error response + serialization and the root node tag. + """ + + SUPPORTED_MIME_TYPES = [APPLICATION_XML, TEXT_XML] + _RESPONSE_ROOT_TAGS = { + "CompleteMultipartUploadOutput": "CompleteMultipartUploadResult", + "CopyObjectOutput": "CopyObjectResult", + "CreateMultipartUploadOutput": "InitiateMultipartUploadResult", + "DeleteObjectsOutput": "DeleteResult", + "GetBucketAccelerateConfigurationOutput": "AccelerateConfiguration", + "GetBucketAclOutput": "AccessControlPolicy", + "GetBucketAnalyticsConfigurationOutput": "AnalyticsConfiguration", + "GetBucketCorsOutput": "CORSConfiguration", + "GetBucketEncryptionOutput": "ServerSideEncryptionConfiguration", + "GetBucketIntelligentTieringConfigurationOutput": "IntelligentTieringConfiguration", + "GetBucketInventoryConfigurationOutput": "InventoryConfiguration", + "GetBucketLifecycleOutput": "LifecycleConfiguration", + "GetBucketLifecycleConfigurationOutput": "LifecycleConfiguration", + "GetBucketLoggingOutput": "BucketLoggingStatus", + "GetBucketMetricsConfigurationOutput": "MetricsConfiguration", + "NotificationConfigurationDeprecated": "NotificationConfiguration", + "GetBucketOwnershipControlsOutput": "OwnershipControls", + "GetBucketPolicyStatusOutput": "PolicyStatus", + "GetBucketReplicationOutput": "ReplicationConfiguration", + "GetBucketRequestPaymentOutput": "RequestPaymentConfiguration", + "GetBucketTaggingOutput": "Tagging", + "GetBucketVersioningOutput": "VersioningConfiguration", + "GetBucketWebsiteOutput": "WebsiteConfiguration", + "GetObjectAclOutput": "AccessControlPolicy", + "GetObjectLegalHoldOutput": "LegalHold", + "GetObjectLockConfigurationOutput": "ObjectLockConfiguration", + "GetObjectRetentionOutput": "Retention", + "GetObjectTaggingOutput": "Tagging", + "GetObjectAttributesOutput": "GetObjectAttributesResponse", + "GetPublicAccessBlockOutput": "PublicAccessBlockConfiguration", + "ListBucketAnalyticsConfigurationsOutput": "ListBucketAnalyticsConfigurationResult", + "ListBucketInventoryConfigurationsOutput": "ListInventoryConfigurationsResult", + "ListBucketMetricsConfigurationsOutput": "ListMetricsConfigurationsResult", + "ListBucketsOutput": "ListAllMyBucketsResult", + "ListMultipartUploadsOutput": "ListMultipartUploadsResult", + "ListObjectsOutput": "ListBucketResult", + "ListObjectsV2Output": "ListBucketResult", + "ListObjectVersionsOutput": "ListVersionsResult", + "ListPartsOutput": "ListPartsResult", + "UploadPartCopyOutput": "CopyPartResult", + } + + XML_NAMESPACE = "http://s3.amazonaws.com/doc/2006-03-01/" + + def _serialize_response( + self, + parameters: dict, + response: Response, + shape: Optional[Shape], + shape_members: dict, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + header_params, payload_params = self._partition_members(parameters, shape) + self._process_header_members(header_params, response, shape) + # "HEAD" responses are basically "GET" responses without the actual body. + # Do not process the body payload in this case (setting a body could also manipulate the headers) + # - If the response is a redirection, the body should be empty as well + # - If the response is from a "PUT" request, the body should be empty except if there's a specific "payload" + # field in the serialization (CopyObject and CopyObjectPart) + http_method = operation_model.http.get("method") + if ( + http_method != "HEAD" + and not 300 <= response.status_code < 400 + and not (http_method == "PUT" and shape and not shape.serialization.get("payload")) + ): + self._serialize_payload( + payload_params, + response, + shape, + shape_members, + operation_model, + mime_type, + request_id, + ) + self._serialize_content_type(response, shape, shape_members, mime_type) + + def _serialize_error( + self, + error: ServiceException, + response: Response, + shape: StructureShape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + attr = ( + {"xmlns": operation_model.metadata.get("xmlNamespace")} + if "xmlNamespace" in operation_model.metadata + else {} + ) + root = ETree.Element("Error", attr) + self._add_error_tags(error, root, mime_type) + request_id_element = ETree.SubElement(root, "RequestId") + request_id_element.text = request_id + + header_params, payload_params = self._partition_members(vars(error), shape) + self._add_additional_error_tags(payload_params, root, shape, mime_type) + self._process_header_members(header_params, response, shape) + + response.set_response(self._encode_payload(self._node_to_string(root, mime_type))) + + def _serialize_body_params( + self, + params: dict, + shape: Shape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> Optional[str]: + root = self._serialize_body_params_to_xml(params, shape, operation_model, mime_type) + # S3 does not follow the specs on the root tag name for 41 of 44 operations + root.tag = self._RESPONSE_ROOT_TAGS.get(root.tag, root.tag) + self._prepare_additional_traits_in_xml(root, request_id) + return self._node_to_string(root, mime_type) + + def _prepare_additional_traits_in_response( + self, response: Response, operation_model: OperationModel, request_id: str + ): + """Adds the request ID to the headers (in contrast to the body - as in the Query protocol).""" + response = super()._prepare_additional_traits_in_response( + response, operation_model, request_id + ) + # s3 extended Request ID + # mostly used internally on AWS and corresponds to a HostId + response.headers["x-amz-id-2"] = ( + "s9lzHYrFp76ZVxRcpX9+5cjAnEH2ROuNkd2BHfIa6UkFVdtjf5mKR3/eTPFvsiP/XV/VLi31234=" + ) + return response + + def _add_error_tags( + self, error: ServiceException, error_tag: ETree.Element, mime_type: str + ) -> None: + code_tag = ETree.SubElement(error_tag, "Code") + code_tag.text = error.code + message = self._get_error_message(error) + if message: + self._default_serialize(error_tag, message, None, "Message", mime_type) + else: + # In S3, if there's no message, create an empty node + self._create_empty_node(error_tag, "Message") + if error.sender_fault: + # The sender fault is either not set or "Sender" + self._default_serialize(error_tag, "Sender", None, "Type", mime_type) + + @staticmethod + def _create_empty_node(xmlnode: ETree.Element, name: str) -> None: + ETree.SubElement(xmlnode, name) + + def _prepare_additional_traits_in_xml(self, root: Optional[ETree.Element], request_id: str): + # some tools (Serverless) require a newline after the "<?xml ...>\n" preamble line, e.g., for LocationConstraint + if root and not root.tail: + root.tail = "\n" + + root.attrib["xmlns"] = self.XML_NAMESPACE + + @staticmethod + def _timestamp_iso8601(value: datetime) -> str: + """ + This is very specific to S3, S3 returns an ISO8601 timestamp but with milliseconds always set to 000 + Some SDKs are very picky about the length + """ + return value.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +class SqsQueryResponseSerializer(QueryResponseSerializer): + """ + Unfortunately, SQS uses a rare interpretation of the XML protocol: It uses HTML entities within XML tag text nodes. + For example: + - Normal XML serializers: <Message>No need to escape quotes (like this: ") with HTML entities in XML.</Message> + - SQS XML serializer: <Message>No need to escape quotes (like this: ") with HTML entities in XML.</Message> + + None of the prominent XML frameworks for python allow HTML entity escapes when serializing XML. + This serializer implements the following workaround: + - Escape quotes and \r with their HTML entities (" and ). + - Since & is (correctly) escaped in XML, the serialized string contains &quot; and &#xD; + - These double-escapes are corrected by replacing such strings with their original. + """ + + # those are deleted from the JSON specs, but need to be kept for legacy reason (sent in 'x-amzn-query-error') + QUERY_PREFIXED_ERRORS = { + "BatchEntryIdsNotDistinct", + "BatchRequestTooLong", + "EmptyBatchRequest", + "InvalidBatchEntryId", + "MessageNotInflight", + "PurgeQueueInProgress", + "QueueDeletedRecently", + "TooManyEntriesInBatchRequest", + "UnsupportedOperation", + } + + # Some error code changed between JSON and query, and we need to have a way to map it for legacy reason + JSON_TO_QUERY_ERROR_CODES = { + "InvalidParameterValueException": "InvalidParameterValue", + "MissingRequiredParameterException": "MissingParameter", + "AccessDeniedException": "AccessDenied", + "QueueDoesNotExist": "AWS.SimpleQueueService.NonExistentQueue", + "QueueNameExists": "QueueAlreadyExists", + } + + SENDER_FAULT_ERRORS = ( + QUERY_PREFIXED_ERRORS + | JSON_TO_QUERY_ERROR_CODES.keys() + | {"OverLimit", "ResourceNotFoundException"} + ) + + def _default_serialize(self, xmlnode: ETree.Element, params: str, _, name: str, __) -> None: + """ + Ensures that we "mark" characters in the node's text which need to be specifically encoded. + This is necessary to easily identify these specific characters later, after the standard XML serialization is + done, while not replacing any other occurrences of these characters which might appear in the serialized string. + """ + node = ETree.SubElement(xmlnode, name) + node.text = ( + str(params) + .replace('"', '__marker__"__marker__') + .replace("\r", "__marker__-r__marker__") + ) + + def _node_to_string(self, root: Optional[ETree.ElementTree], mime_type: str) -> Optional[str]: + """Replaces the previously "marked" characters with their encoded value.""" + generated_string = super()._node_to_string(root, mime_type) + if generated_string is None: + return None + generated_string = to_str(generated_string) + # Undo the second escaping of the & + # Undo the second escaping of the carriage return (\r) + if mime_type == APPLICATION_JSON: + # At this point the json was already dumped and escaped, so we replace directly. + generated_string = generated_string.replace(r"__marker__\"__marker__", r"\"").replace( + "__marker__-r__marker__", r"\r" + ) + else: + generated_string = generated_string.replace('__marker__"__marker__', """).replace( + "__marker__-r__marker__", " " + ) + + return to_bytes(generated_string) + + def _add_error_tags( + self, error: ServiceException, error_tag: ETree.Element, mime_type: str + ) -> None: + """The SQS API stubs is now generated from JSON specs, and some fields have been modified""" + code_tag = ETree.SubElement(error_tag, "Code") + + if error.code in self.JSON_TO_QUERY_ERROR_CODES: + error_code = self.JSON_TO_QUERY_ERROR_CODES[error.code] + elif error.code in self.QUERY_PREFIXED_ERRORS: + error_code = f"AWS.SimpleQueueService.{error.code}" + else: + error_code = error.code + code_tag.text = error_code + message = self._get_error_message(error) + if message: + self._default_serialize(error_tag, message, None, "Message", mime_type) + if error.code in self.SENDER_FAULT_ERRORS or error.sender_fault: + # The sender fault is either not set or "Sender" + self._default_serialize(error_tag, "Sender", None, "Type", mime_type) + + +class SqsJsonResponseSerializer(JSONResponseSerializer): + # those are deleted from the JSON specs, but need to be kept for legacy reason (sent in 'x-amzn-query-error') + QUERY_PREFIXED_ERRORS = { + "BatchEntryIdsNotDistinct", + "BatchRequestTooLong", + "EmptyBatchRequest", + "InvalidBatchEntryId", + "MessageNotInflight", + "PurgeQueueInProgress", + "QueueDeletedRecently", + "TooManyEntriesInBatchRequest", + "UnsupportedOperation", + } + + # Some error code changed between JSON and query, and we need to have a way to map it for legacy reason + JSON_TO_QUERY_ERROR_CODES = { + "InvalidParameterValueException": "InvalidParameterValue", + "MissingRequiredParameterException": "MissingParameter", + "AccessDeniedException": "AccessDenied", + "QueueDoesNotExist": "AWS.SimpleQueueService.NonExistentQueue", + "QueueNameExists": "QueueAlreadyExists", + } + + def _serialize_error( + self, + error: ServiceException, + response: Response, + shape: StructureShape, + operation_model: OperationModel, + mime_type: str, + request_id: str, + ) -> None: + """ + Overrides _serialize_error as SQS has a special header for query API legacy reason: 'x-amzn-query-error', + which contained the exception code as well as a Sender field. + Ex: 'x-amzn-query-error': 'InvalidParameterValue;Sender' + """ + # TODO: for body["__type"] = error.code, it seems AWS differs from what we send for SQS + # AWS: "com.amazon.coral.service#InvalidParameterValueException" + # or AWS: "com.amazonaws.sqs#BatchRequestTooLong" + # LocalStack: "InvalidParameterValue" + super()._serialize_error(error, response, shape, operation_model, mime_type, request_id) + # We need to add a prefix to certain errors, as they have been deleted in the specs. These will not change + if error.code in self.JSON_TO_QUERY_ERROR_CODES: + code = self.JSON_TO_QUERY_ERROR_CODES[error.code] + elif error.code in self.QUERY_PREFIXED_ERRORS: + code = f"AWS.SimpleQueueService.{error.code}" + else: + code = error.code + + response.headers["x-amzn-query-error"] = f"{code};Sender" + + +def gen_amzn_requestid(): + """ + Generate generic AWS request ID. + + 3 uses a different format and set of request Ids. + + Examples: + 996d38a0-a4e9-45de-bad4-480cd962d208 + b9260553-df1b-4db6-ae41-97b89a5f85ea + """ + return long_uid() + + +@functools.cache +def create_serializer(service: ServiceModel) -> ResponseSerializer: + """ + Creates the right serializer for the given service model. + + :param service: to create the serializer for + :return: ResponseSerializer which can handle the protocol of the service + """ + + # Unfortunately, some services show subtle differences in their serialized responses, even though their + # specification states they implement the same protocol. + # Since some clients might be stricter / less resilient than others, we need to mimic the serialization of the + # specific services as close as possible. + # Therefore, the service-specific serializer implementations (basically the implicit / informally more specific + # protocol implementation) has precedence over the more general protocol-specific serializers. + service_specific_serializers = { + "sqs": {"json": SqsJsonResponseSerializer, "query": SqsQueryResponseSerializer}, + "s3": {"rest-xml": S3ResponseSerializer}, + } + protocol_specific_serializers = { + "query": QueryResponseSerializer, + "json": JSONResponseSerializer, + "rest-json": RestJSONResponseSerializer, + "rest-xml": RestXMLResponseSerializer, + "ec2": EC2ResponseSerializer, + } + + # Try to select a service- and protocol-specific serializer implementation + if ( + service.service_name in service_specific_serializers + and service.protocol in service_specific_serializers[service.service_name] + ): + return service_specific_serializers[service.service_name][service.protocol]() + else: + # Otherwise, pick the protocol-specific serializer for the protocol of the service + return protocol_specific_serializers[service.protocol]() + + +def aws_response_serializer( + service_name: str, operation: str, protocol: Optional[ProtocolName] = None +): + """ + A decorator for an HTTP route that can serialize return values or exceptions into AWS responses. + This can be used to create AWS request handlers in a convenient way. Example usage:: + + from localstack.http import route, Request + from localstack.aws.api.sqs import ListQueuesResult + + @route("/_aws/sqs/queues") + @aws_response_serializer("sqs", "ListQueues") + def my_route(request: Request): + if some_condition_on_request: + raise CommonServiceError("...") # <- will be serialized into an error response + + return ListQueuesResult(QueueUrls=...) # <- object from the SQS API will be serialized + + :param service_name: the AWS service (e.g., "sqs", "lambda") + :param protocol: the protocol of the AWS service to serialize to. If not set (by default) the default protocol + of the service in botocore is used. + :param operation: the operation name (e.g., "ReceiveMessage", "ListFunctions") + :returns: a decorator + """ + + def _decorate(fn): + service_model = load_service(service_name, protocol=protocol) + operation_model = service_model.operation_model(operation) + serializer = create_serializer(service_model) + + def _proxy(*args, **kwargs) -> WerkzeugResponse: + # extract request from function invocation (decorator can be used for methods as well as for functions). + if len(args) > 0 and isinstance(args[0], WerkzeugRequest): + # function + request = args[0] + elif len(args) > 1 and isinstance(args[1], WerkzeugRequest): + # method (arg[0] == self) + request = args[1] + elif "request" in kwargs: + request = kwargs["request"] + else: + raise ValueError(f"could not find Request in signature of function {fn}") + + # TODO: we have no context here + # TODO: maybe try to get the request ID from the headers first before generating a new one + request_id = gen_amzn_requestid() + + try: + response = fn(*args, **kwargs) + + if isinstance(response, WerkzeugResponse): + return response + + return serializer.serialize_to_response( + response, operation_model, request.headers, request_id + ) + + except ServiceException as e: + return serializer.serialize_error_to_response( + e, operation_model, request.headers, request_id + ) + except Exception as e: + return serializer.serialize_error_to_response( + CommonServiceException( + "InternalError", f"An internal error occurred: {e}", status_code=500 + ), + operation_model, + request.headers, + request_id, + ) + + return _proxy + + return _decorate diff --git a/localstack-core/localstack/aws/protocol/service_router.py b/localstack-core/localstack/aws/protocol/service_router.py new file mode 100644 index 0000000000000..44d70efaac4df --- /dev/null +++ b/localstack-core/localstack/aws/protocol/service_router.py @@ -0,0 +1,401 @@ +import logging +from typing import NamedTuple, Optional, Set + +from botocore.model import ServiceModel +from werkzeug.exceptions import RequestEntityTooLarge +from werkzeug.http import parse_dict_header + +from localstack.aws.spec import ( + ServiceCatalog, + ServiceModelIdentifier, + get_service_catalog, +) +from localstack.http import Request +from localstack.services.s3.utils import uses_host_addressing +from localstack.services.sqs.utils import is_sqs_queue_url +from localstack.utils.strings import to_bytes + +LOG = logging.getLogger(__name__) + + +class _ServiceIndicators(NamedTuple): + """ + Encapsulates the different fields that might indicate which service a request is targeting. + + This class does _not_ contain any data which is parsed from the body of the request in order to defer or even avoid + processing the body. + """ + + # AWS service's "signing name" - Contained in the Authorization header + # (https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html) + signing_name: Optional[str] = None + # Target prefix as defined in the service specs for non-rest protocols - Contained in the X-Amz-Target header + target_prefix: Optional[str] = None + # Targeted operation as defined in the service specs for non-rest protocols - Contained in the X-Amz-Target header + operation: Optional[str] = None + # Host field of the HTTP request + host: Optional[str] = None + # Path of the HTTP request + path: Optional[str] = None + + +def _extract_service_indicators(request: Request) -> _ServiceIndicators: + """Extracts all different fields that might indicate which service a request is targeting.""" + x_amz_target = request.headers.get("x-amz-target") + authorization = request.headers.get("authorization") + + signing_name = None + if authorization: + try: + auth_type, auth_info = authorization.split(None, 1) + auth_type = auth_type.lower().strip() + if auth_type == "aws4-hmac-sha256": + values = parse_dict_header(auth_info) + _, _, _, signing_name, _ = values["Credential"].split("/") + except (ValueError, KeyError): + LOG.debug("auth header could not be parsed for service routing: %s", authorization) + pass + if x_amz_target: + if "." in x_amz_target: + target_prefix, operation = x_amz_target.split(".", 1) + else: + target_prefix = None + operation = x_amz_target + else: + target_prefix, operation = None, None + + return _ServiceIndicators(signing_name, target_prefix, operation, request.host, request.path) + + +signing_name_path_prefix_rules = { + # custom rules based on URI path prefixes that are not easily generalizable + "apigateway": { + "/v2": ServiceModelIdentifier("apigatewayv2"), + }, + "appconfig": { + "/configuration": ServiceModelIdentifier("appconfigdata"), + }, + "bedrock": { + "/guardrail/": ServiceModelIdentifier("bedrock-runtime"), + "/model/": ServiceModelIdentifier("bedrock-runtime"), + "/async-invoke": ServiceModelIdentifier("bedrock-runtime"), + }, + "execute-api": { + "/@connections": ServiceModelIdentifier("apigatewaymanagementapi"), + "/participant": ServiceModelIdentifier("connectparticipant"), + "*": ServiceModelIdentifier("iot"), + }, + "ses": { + "/v2": ServiceModelIdentifier("sesv2"), + "/v1": ServiceModelIdentifier("pinpoint-email"), + }, + "greengrass": { + "/greengrass/v2/": ServiceModelIdentifier("greengrassv2"), + }, + "cloudsearch": { + "/2013-01-01": ServiceModelIdentifier("cloudsearchdomain"), + }, + "s3": {"/v20180820": ServiceModelIdentifier("s3control")}, + "iot1click": { + "/projects": ServiceModelIdentifier("iot1click-projects"), + "/devices": ServiceModelIdentifier("iot1click-devices"), + }, + "es": { + "/2015-01-01": ServiceModelIdentifier("es"), + "/2021-01-01": ServiceModelIdentifier("opensearch"), + }, + "sagemaker": { + "/endpoints": ServiceModelIdentifier("sagemaker-runtime"), + "/human-loops": ServiceModelIdentifier("sagemaker-a2i-runtime"), + }, +} + + +def custom_signing_name_rules(signing_name: str, path: str) -> Optional[ServiceModelIdentifier]: + """ + Rules which are based on the signing name (in the auth header) and the request path. + """ + rules = signing_name_path_prefix_rules.get(signing_name) + + if not rules: + if signing_name == "servicecatalog": + if path == "/": + # servicecatalog uses the protocol json (only uses root-path URIs, i.e. only /) + return ServiceModelIdentifier("servicecatalog") + else: + # servicecatalog-appregistry uses rest-json (only uses non-root-path request URIs) + return ServiceModelIdentifier("servicecatalog-appregistry") + return + + for prefix, service_model_identifier in rules.items(): + if path.startswith(prefix): + return service_model_identifier + + return rules.get("*", ServiceModelIdentifier(signing_name)) + + +def custom_host_addressing_rules(host: str) -> Optional[ServiceModelIdentifier]: + """ + Rules based on the host header of the request, which is typically the data plane of a service. + + Some services are added through a patch in ext. + """ + if ".lambda-url." in host: + return ServiceModelIdentifier("lambda") + + if ".s3-website." in host: + return ServiceModelIdentifier("s3") + + +def custom_path_addressing_rules(path: str) -> Optional[ServiceModelIdentifier]: + """ + Rules which are only based on the request path. + """ + + if is_sqs_queue_url(path): + return ServiceModelIdentifier("sqs", protocol="query") + + if path.startswith("/2015-03-31/functions"): + return ServiceModelIdentifier("lambda") + + +def legacy_s3_rules(request: Request) -> Optional[ServiceModelIdentifier]: + """ + *Legacy* rules which allow us to fallback to S3 if no other service was matched. + All rules which are implemented here should be removed once we make sure it would not break any use-cases. + """ + + path = request.path + method = request.method + + # TODO The remaining rules here are special S3 rules - needs to be discussed how these should be handled. + # Some are similar to other rules and not that greedy, others are nearly general fallbacks. + stripped = path.strip("/") + if method in ["GET", "HEAD"] and stripped: + # assume that this is an S3 GET request with URL path `/<bucket>/<key ...>` + return ServiceModelIdentifier("s3") + + # detect S3 URLs + if stripped and "/" not in stripped: + if method == "PUT": + # assume that this is an S3 PUT bucket request with URL path `/<bucket>` + return ServiceModelIdentifier("s3") + if method == "POST" and "key" in request.values: + # assume that this is an S3 POST request with form parameters or multipart form in the body + return ServiceModelIdentifier("s3") + + # detect S3 requests sent from aws-cli using --no-sign-request option + if "aws-cli/" in str(request.user_agent): + return ServiceModelIdentifier("s3") + + # detect S3 pre-signed URLs (v2 and v4) + values = request.values + if any( + value in values + for value in [ + "AWSAccessKeyId", + "Signature", + "X-Amz-Algorithm", + "X-Amz-Credential", + "X-Amz-Date", + "X-Amz-Expires", + "X-Amz-SignedHeaders", + "X-Amz-Signature", + ] + ): + return ServiceModelIdentifier("s3") + + # S3 delete object requests + if method == "POST" and "delete" in values: + data_bytes = to_bytes(request.data) + if b"<Delete" in data_bytes and b"<Key>" in data_bytes: + return ServiceModelIdentifier("s3") + + # Put Object API can have multiple keys + if stripped.count("/") >= 1 and method == "PUT": + # assume that this is an S3 PUT bucket object request with URL path `/<bucket>/object` + # or `/<bucket>/object/object1/+` + return ServiceModelIdentifier("s3") + + # detect S3 requests with "AWS id:key" Auth headers + auth_header = request.headers.get("Authorization") or "" + if auth_header.startswith("AWS "): + return ServiceModelIdentifier("s3") + + if uses_host_addressing(request.headers): + # Note: This needs to be the last rule (and therefore is not in the host rules), since it is incredibly greedy + return ServiceModelIdentifier("s3") + + +def resolve_conflicts( + candidates: Set[ServiceModelIdentifier], request: Request +) -> ServiceModelIdentifier: + """ + Some service definitions are overlapping to a point where they are _not_ distinguishable at all + (f.e. ``DescribeEndpints`` in timestream-query and timestream-write). + These conflicts need to be resolved manually. + """ + service_name_candidates = {service.name for service in candidates} + if service_name_candidates == {"timestream-query", "timestream-write"}: + return ServiceModelIdentifier("timestream-query") + if service_name_candidates == {"docdb", "neptune", "rds"}: + return ServiceModelIdentifier("rds") + if service_name_candidates == {"sqs"}: + # SQS now have 2 different specs for `query` and `json` protocol. From our current implementation with the + # parser and serializer, we need to have 2 different service names for them, but they share one provider + # implementation. `sqs` represents the `json` protocol spec, and `sqs-query` the `query` protocol + # (default again in botocore starting with 1.32.6). + # The `application/x-amz-json-1.0` header is mandatory for requests targeting SQS with the `json` protocol. We + # can safely route them to the `sqs` JSON parser/serializer. If not present, route the request to the + # sqs-query protocol. + content_type = request.headers.get("Content-Type") + return ( + ServiceModelIdentifier("sqs") + if content_type == "application/x-amz-json-1.0" + else ServiceModelIdentifier("sqs", "query") + ) + + +def determine_aws_service_model_for_data_plane( + request: Request, services: ServiceCatalog = None +) -> Optional[ServiceModel]: + """ + A stripped down version of ``determine_aws_service_model`` which only checks hostname indicators for + the AWS data plane, such as s3 websites, lambda function URLs, or API gateway routes. + """ + custom_host_match = custom_host_addressing_rules(request.host) + if custom_host_match: + services = services or get_service_catalog() + return services.get(*custom_host_match) + + +def determine_aws_service_model( + request: Request, services: ServiceCatalog = None +) -> Optional[ServiceModel]: + """ + Tries to determine the name of the AWS service an incoming request is targeting. + :param request: to determine the target service name of + :param services: service catalog (can be handed in for caching purposes) + :return: service name string (or None if the targeting service could not be determined exactly) + """ + services = services or get_service_catalog() + signing_name, target_prefix, operation, host, path = _extract_service_indicators(request) + candidates = set() + + # 1. check the signing names + if signing_name: + signing_name_candidates = services.by_signing_name(signing_name) + if len(signing_name_candidates) == 1: + # a unique signing-name -> service name mapping is the case for ~75% of service operations + return services.get(*signing_name_candidates[0]) + + # try to find a match with the custom signing name rules + custom_match = custom_signing_name_rules(signing_name, path) + if custom_match: + return services.get(*custom_match) + + # still ambiguous - add the services to the list of candidates + candidates.update(signing_name_candidates) + + # 2. check the target prefix + if target_prefix and operation: + target_candidates = services.by_target_prefix(target_prefix) + if len(target_candidates) == 1: + # a unique target prefix + return services.get(*target_candidates[0]) + + # still ambiguous - add the services to the list of candidates + candidates.update(target_candidates) + + # exclude services where the operation is not contained in the service spec + for service_identifier in list(candidates): + service = services.get(*service_identifier) + if operation not in service.operation_names: + candidates.remove(service_identifier) + else: + # exclude services which have a target prefix (the current request does not have one) + for service_identifier in list(candidates): + service = services.get(*service_identifier) + if service.metadata.get("targetPrefix") is not None: + candidates.remove(service_identifier) + + if len(candidates) == 1: + service_identifier = candidates.pop() + return services.get(*service_identifier) + + # 3. check the path if it is set and not a trivial root path + if path and path != "/": + # try to find a match with the custom path rules + custom_path_match = custom_path_addressing_rules(path) + if custom_path_match: + return services.get(*custom_path_match) + + # 4. check the host (custom host addressing rules) + if host: + # iterate over the service spec's endpoint prefix + for prefix, services_per_prefix in services.endpoint_prefix_index.items(): + # this prevents a virtual host addressed bucket to be wrongly recognized + if host.startswith(f"{prefix}.") and ".s3." not in host: + if len(services_per_prefix) == 1: + return services.get(*services_per_prefix[0]) + candidates.update(services_per_prefix) + + custom_host_match = custom_host_addressing_rules(host) + if custom_host_match: + return services.get(*custom_host_match) + + if request.shallow: + # from here on we would need access to the request body, which doesn't exist for shallow requests like + # WebsocketRequests. + return None + + # 5. check the query / form-data + try: + values = request.values + if "Action" in values: + # query / ec2 protocol requests always have an action and a version (the action is more significant) + query_candidates = [ + service + for service in services.by_operation(values["Action"]) + if service.protocol in ("ec2", "query") + ] + + if len(query_candidates) == 1: + return services.get(*query_candidates[0]) + + if "Version" in values: + for service_identifier in list(query_candidates): + service_model = services.get(*service_identifier) + if values["Version"] != service_model.api_version: + # the combination of Version and Action is not unique, add matches to the candidates + query_candidates.remove(service_identifier) + + if len(query_candidates) == 1: + return services.get(*query_candidates[0]) + + candidates.update(query_candidates) + + except RequestEntityTooLarge: + # Some requests can be form-urlencoded but also contain binary data, which will fail the form parsing (S3 can + # do this). In that case, skip this step and continue to try to determine the service name. The exception is + # RequestEntityTooLarge even if the error is due to failed decoding. + LOG.debug( + "Failed to determine AWS service from request body because the form could not be parsed", + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + # 6. resolve service spec conflicts + resolved_conflict = resolve_conflicts(candidates, request) + if resolved_conflict: + return services.get(*resolved_conflict) + + # 7. check the legacy S3 rules in the end + legacy_match = legacy_s3_rules(request) + if legacy_match: + return services.get(*legacy_match) + + if signing_name: + return services.get(name=signing_name) + if candidates: + return services.get(*candidates.pop()) + return None diff --git a/localstack-core/localstack/aws/protocol/validate.py b/localstack-core/localstack/aws/protocol/validate.py new file mode 100644 index 0000000000000..30d1be4355fb0 --- /dev/null +++ b/localstack-core/localstack/aws/protocol/validate.py @@ -0,0 +1,173 @@ +"""Slightly extends the ``botocore.validate`` package to provide better integration with our parser/serializer.""" + +from typing import Any, Dict, List, NamedTuple + +from botocore.model import OperationModel, Shape +from botocore.validate import ParamValidator as BotocoreParamValidator +from botocore.validate import ValidationErrors as BotocoreValidationErrors +from botocore.validate import type_check + +from localstack.aws.api import ServiceRequest + + +class Error(NamedTuple): + """ + A wrapper around ``botocore.validate`` error tuples. + + Attributes: + reason The error type + name The name of the parameter the error occurred at + attributes Error type-specific attributes + """ + + reason: str + name: str + attributes: Dict[str, Any] + + +class ParameterValidationError(Exception): + error: Error + + def __init__(self, error: Error) -> None: + self.error = error + super().__init__(self.message) + + @property + def reason(self): + return self.error.reason + + @property + def message(self) -> str: + """ + Returns a default message for the error formatted by BotocoreValidationErrors. + :return: the exception message. + """ + return BotocoreValidationErrors()._format_error(self.error) + + +class MissingRequiredField(ParameterValidationError): + @property + def required_name(self) -> str: + return self.error.attributes["required_name"] + + +# TODO: extend subclasses with properties from error arguments as needed. see ValidationErrors._format_error for +# which those are. + + +class UnknownField(ParameterValidationError): + pass + + +class InvalidType(ParameterValidationError): + pass + + +class InvalidRange(ParameterValidationError): + pass + + +class InvalidLength(ParameterValidationError): + pass + + +class JsonEncodingError(ParameterValidationError): + pass + + +class InvalidDocumentType(ParameterValidationError): + pass + + +class MoreThanOneInput(ParameterValidationError): + pass + + +class EmptyInput(ParameterValidationError): + pass + + +class ValidationErrors(BotocoreValidationErrors): + def __init__(self, shape: Shape, params: Dict[str, Any]): + super().__init__() + self.shape = shape + self.params = params + self._exceptions: List[ParameterValidationError] = [] + + @property + def exceptions(self): + return self._exceptions + + def raise_first(self): + for error in self._exceptions: + raise error + + def report(self, name, reason, **kwargs): + error = Error(reason, name, kwargs) + self._errors.append(error) + self._exceptions.append(self.to_exception(error)) + + def to_exception(self, error: Error) -> ParameterValidationError: + error_type, name, additional = error + + if error_type == "missing required field": + return MissingRequiredField(error) + elif error_type == "unknown field": + return UnknownField(error) + elif error_type == "invalid type": + return InvalidType(error) + elif error_type == "invalid range": + return InvalidRange(error) + elif error_type == "invalid length": + return InvalidLength(error) + elif error_type == "unable to encode to json": + return JsonEncodingError(error) + elif error_type == "invalid type for document": + return InvalidDocumentType(error) + elif error_type == "more than one input": + return MoreThanOneInput(error) + elif error_type == "empty input": + return EmptyInput(error) + + return ParameterValidationError(error) + + +class ParamValidator(BotocoreParamValidator): + def validate(self, params: Dict[str, Any], shape: Shape): + """Validate parameters against a shape model. + + This method will validate the parameters against a provided shape model. + All errors will be collected before returning to the caller. This means + that this method will not stop at the first error, it will return all + possible errors. + + :param params: User provided dict of parameters + :param shape: A shape model describing the expected input. + + :return: A list of errors. + + """ + errors = ValidationErrors(shape, params) + self._validate(params, shape, errors, name="") + return errors + + @type_check(valid_types=(dict,)) + def _validate_structure(self, params, shape, errors, name): + # our parser sets the value of required members to None if they are not in the incoming request. we correct + # this behavior here to get the correct error messages. + for required_member in shape.metadata.get("required", []): + if required_member in params and params[required_member] is None: + params.pop(required_member) + + super(ParamValidator, self)._validate_structure(params, shape, errors, name) + + +def validate_request(operation: OperationModel, request: ServiceRequest) -> ValidationErrors: + """ + Validates the service request with the input shape of the given operation. + + :param operation: the operation + :param request: the input shape of the operation being validated + :return: ValidationError object + """ + return ParamValidator().validate(request, operation.input_shape) diff --git a/localstack-core/localstack/aws/scaffold.py b/localstack-core/localstack/aws/scaffold.py new file mode 100644 index 0000000000000..3d9c0e3e55db4 --- /dev/null +++ b/localstack-core/localstack/aws/scaffold.py @@ -0,0 +1,560 @@ +import io +import keyword +import re +from functools import cached_property +from multiprocessing import Pool +from pathlib import Path +from typing import Dict, List, Optional, Set + +import click +from botocore import xform_name +from botocore.exceptions import UnknownServiceError +from botocore.model import ( + ListShape, + MapShape, + OperationModel, + ServiceModel, + Shape, + StringShape, + StructureShape, +) +from typing_extensions import OrderedDict + +from localstack.aws.spec import load_service +from localstack.utils.common import camel_to_snake_case, snake_to_camel_case + +# Some minification packages might treat "type" as a keyword, some specs define shapes called like the type "Optional" +KEYWORDS = list(keyword.kwlist) + ["type", "Optional", "Union"] +is_keyword = KEYWORDS.__contains__ + + +def is_bad_param_name(name: str) -> bool: + if name == "context": + return True + + if is_keyword(name): + return True + + return False + + +def to_valid_python_name(spec_name: str) -> str: + sanitized = re.sub(r"[^0-9a-zA-Z_]+", "_", spec_name) + + if sanitized[0].isnumeric(): + sanitized = "i_" + sanitized + + if is_keyword(sanitized): + sanitized += "_" + + if sanitized.startswith("__"): + sanitized = sanitized[1:] + + return sanitized + + +def html_to_rst(html: str): + import pypandoc + + doc = pypandoc.convert_text(html, "rst", format="html") + doc = doc.replace("\_", "_") # noqa: W605 + doc = doc.replace("\|", "|") # noqa: W605 + doc = doc.replace("\ ", " ") # noqa: W605 + doc = doc.replace("\\", "\\\\") # noqa: W605 + rst = doc.strip() + return rst + + +class ShapeNode: + service: ServiceModel + shape: Shape + + def __init__(self, service: ServiceModel, shape: Shape) -> None: + super().__init__() + self.service = service + self.shape = shape + + @cached_property + def request_operation(self) -> Optional[OperationModel]: + for operation_name in self.service.operation_names: + operation = self.service.operation_model(operation_name) + if operation.input_shape is None: + continue + + if to_valid_python_name(self.shape.name) == to_valid_python_name( + operation.input_shape.name + ): + return operation + + return None + + @cached_property + def response_operation(self) -> Optional[OperationModel]: + for operation_name in self.service.operation_names: + operation = self.service.operation_model(operation_name) + if operation.output_shape is None: + continue + + if to_valid_python_name(self.shape.name) == to_valid_python_name( + operation.output_shape.name + ): + return operation + + return None + + @cached_property + def is_request(self): + return self.request_operation is not None + + @cached_property + def is_response(self): + return self.response_operation is not None + + @property + def name(self) -> str: + return to_valid_python_name(self.shape.name) + + @cached_property + def is_exception(self): + metadata = self.shape.metadata + return metadata.get("error") or metadata.get("exception") + + @property + def is_primitive(self): + return self.shape.type_name in ["integer", "boolean", "float", "double", "string"] + + @property + def is_enum(self): + return isinstance(self.shape, StringShape) and self.shape.enum + + @property + def dependencies(self) -> List[str]: + shape = self.shape + + if isinstance(shape, StructureShape): + return [to_valid_python_name(v.name) for v in shape.members.values()] + if isinstance(shape, ListShape): + return [to_valid_python_name(shape.member.name)] + if isinstance(shape, MapShape): + return [to_valid_python_name(shape.key.name), to_valid_python_name(shape.value.name)] + + return [] + + def _print_structure_declaration(self, output, doc=True, quote_types=False): + if self.is_exception: + self._print_as_class(output, "ServiceException", doc, quote_types) + return + + if any(map(is_keyword, self.shape.members.keys())): + self._print_as_typed_dict(output, doc, quote_types) + return + + if self.is_request: + base = "ServiceRequest" + else: + base = "TypedDict, total=False" + + self._print_as_class(output, base, doc, quote_types) + + def _print_as_class(self, output, base: str, doc=True, quote_types=False): + output.write(f"class {to_valid_python_name(self.shape.name)}({base}):\n") + + q = '"' if quote_types else "" + + if doc: + self.print_shape_doc(output, self.shape) + + if self.is_exception: + error_spec = self.shape.metadata.get("error", {}) + output.write(f' code: str = "{error_spec.get("code", self.shape.name)}"\n') + output.write(f" sender_fault: bool = {error_spec.get('senderFault', False)}\n") + output.write(f" status_code: int = {error_spec.get('httpStatusCode', 400)}\n") + elif not self.shape.members: + output.write(" pass\n") + + # Avoid generating members for the common error members: + # - The message will always be the exception message (first argument of the exception class init) + # - The code is already set above + # - The type is the sender_fault which is already set above + remaining_members = { + k: v + for k, v in self.shape.members.items() + if not self.is_exception or k.lower() not in ["message", "code"] + } + + # render any streaming payload first + if self.is_request and self.request_operation.has_streaming_input: + member: str = self.request_operation.input_shape.serialization.get("payload") + shape: Shape = self.request_operation.get_streaming_input() + if member in self.shape.required_members: + output.write(f" {member}: IO[{q}{to_valid_python_name(shape.name)}{q}]\n") + else: + output.write( + f" {member}: Optional[IO[{q}{to_valid_python_name(shape.name)}{q}]]\n" + ) + del remaining_members[member] + # render the streaming payload first + if self.is_response and self.response_operation.has_streaming_output: + member: str = self.response_operation.output_shape.serialization.get("payload") + shape: Shape = self.response_operation.get_streaming_output() + shape_name = to_valid_python_name(shape.name) + if member in self.shape.required_members: + output.write( + f" {member}: Union[{q}{shape_name}{q}, IO[{q}{shape_name}{q}], Iterable[{q}{shape_name}{q}]]\n" + ) + else: + output.write( + f" {member}: Optional[Union[{q}{shape_name}{q}, IO[{q}{shape_name}{q}], Iterable[{q}{shape_name}{q}]]]\n" + ) + del remaining_members[member] + + for k, v in remaining_members.items(): + if k in self.shape.required_members: + if v.serialization.get("eventstream"): + output.write(f" {k}: Iterator[{q}{to_valid_python_name(v.name)}{q}]\n") + else: + output.write(f" {k}: {q}{to_valid_python_name(v.name)}{q}\n") + else: + if v.serialization.get("eventstream"): + output.write(f" {k}: Iterator[{q}{to_valid_python_name(v.name)}{q}]\n") + else: + output.write(f" {k}: Optional[{q}{to_valid_python_name(v.name)}{q}]\n") + + def _print_as_typed_dict(self, output, doc=True, quote_types=False): + name = to_valid_python_name(self.shape.name) + output.write('%s = TypedDict("%s", {\n' % (name, name)) + for k, v in self.shape.members.items(): + member_name = to_valid_python_name(v.name) + # check if the member name is the same as the type name (recursive types need to use forward references) + recursive_type = name == member_name + q = '"' if quote_types or recursive_type else "" + if k in self.shape.required_members: + if v.serialization.get("eventstream"): + output.write(f' "{k}": Iterator[{q}{member_name}{q}],\n') + else: + output.write(f' "{k}": {q}{member_name}{q},\n') + else: + if v.serialization.get("eventstream"): + output.write(f' "{k}": Iterator[{q}{member_name}{q}],\n') + else: + output.write(f' "{k}": Optional[{q}{member_name}{q}],\n') + output.write("}, total=False)") + + def print_shape_doc(self, output, shape): + html = shape.documentation + rst = html_to_rst(html) + if rst: + output.write(' """') + output.write(f"{rst}\n") + output.write(' """\n') + + def print_declaration(self, output, doc=True, quote_types=False): + shape = self.shape + + q = '"' if quote_types else "" + + if isinstance(shape, StructureShape): + self._print_structure_declaration(output, doc, quote_types) + elif isinstance(shape, ListShape): + output.write( + f"{to_valid_python_name(shape.name)} = List[{q}{to_valid_python_name(shape.member.name)}{q}]" + ) + elif isinstance(shape, MapShape): + output.write( + f"{to_valid_python_name(shape.name)} = Dict[{q}{to_valid_python_name(shape.key.name)}{q}, {q}{to_valid_python_name(shape.value.name)}{q}]" + ) + elif isinstance(shape, StringShape): + if shape.enum: + output.write(f"class {to_valid_python_name(shape.name)}(StrEnum):\n") + for value in shape.enum: + name = to_valid_python_name(value) + output.write(f' {name} = "{value}"\n') + else: + output.write(f"{to_valid_python_name(shape.name)} = str") + elif shape.type_name == "string": + output.write(f"{to_valid_python_name(shape.name)} = str") + elif shape.type_name == "integer": + output.write(f"{to_valid_python_name(shape.name)} = int") + elif shape.type_name == "long": + output.write(f"{to_valid_python_name(shape.name)} = int") + elif shape.type_name == "double": + output.write(f"{to_valid_python_name(shape.name)} = float") + elif shape.type_name == "float": + output.write(f"{to_valid_python_name(shape.name)} = float") + elif shape.type_name == "boolean": + output.write(f"{to_valid_python_name(shape.name)} = bool") + elif shape.type_name == "blob": + # blobs are often associated with streaming payloads, but we handle that on operation level, + # not on shape level + output.write(f"{to_valid_python_name(shape.name)} = bytes") + elif shape.type_name == "timestamp": + output.write(f"{to_valid_python_name(shape.name)} = datetime") + else: + output.write( + f"# unknown shape type for {to_valid_python_name(shape.name)}: {shape.type_name}" + ) + # TODO: BoxedInteger? + + output.write("\n") + + def get_order(self): + """ + Defines a basic order in which to sort the stack of shape nodes before printing. + First all non-enum primitives are printed, then enums, then exceptions, then all other types. + """ + if self.is_primitive: + if self.is_enum: + return 1 + else: + return 0 + + if self.is_exception: + return 2 + + return 3 + + +def generate_service_types(output, service: ServiceModel, doc=True): + output.write("from datetime import datetime\n") + output.write("from enum import StrEnum\n") + output.write( + "from typing import Dict, List, Optional, Iterator, Iterable, IO, Union, TypedDict\n" + ) + output.write("\n") + output.write( + "from localstack.aws.api import handler, RequestContext, ServiceException, ServiceRequest" + ) + output.write("\n") + + # ==================================== print type declarations + nodes: Dict[str, ShapeNode] = {} + + for shape_name in service.shape_names: + shape = service.shape_for(shape_name) + nodes[to_valid_python_name(shape_name)] = ShapeNode(service, shape) + + # output.write("__all__ = [\n") + # for name in nodes.keys(): + # output.write(f' "{name}",\n') + # output.write("]\n") + + printed: Set[str] = set() + visited: Set[str] = set() + stack: List[str] = list(nodes.keys()) + + stack = sorted(stack, key=lambda name: nodes[name].get_order()) + stack.reverse() + + while stack: + name = stack.pop() + if name in printed: + continue + node = nodes[name] + + dependencies = [dep for dep in node.dependencies if dep not in printed] + + if not dependencies: + node.print_declaration(output, doc=doc) + printed.add(name) + elif name in visited: + # break out of circular dependencies + node.print_declaration(output, doc=doc, quote_types=True) + printed.add(name) + else: + stack.append(name) + stack.extend(dependencies) + visited.add(name) + + +def generate_service_api(output, service: ServiceModel, doc=True): + service_name = service.service_name.replace("-", "_") + class_name = service_name + "_api" + class_name = snake_to_camel_case(class_name) + + output.write(f"class {class_name}:\n") + output.write("\n") + output.write(f' service = "{service.service_name}"\n') + output.write(f' version = "{service.api_version}"\n') + for op_name in service.operation_names: + operation: OperationModel = service.operation_model(op_name) + + fn_name = camel_to_snake_case(op_name) + + if operation.output_shape: + output_shape = to_valid_python_name(operation.output_shape.name) + else: + output_shape = "None" + + output.write("\n") + parameters = OrderedDict() + param_shapes = OrderedDict() + + if input_shape := operation.input_shape: + members = list(input_shape.members) + + streaming_payload_member = None + if operation.has_streaming_input: + streaming_payload_member = operation.input_shape.serialization.get("payload") + + for m in input_shape.required_members: + members.remove(m) + m_shape = input_shape.members[m] + type_name = to_valid_python_name(m_shape.name) + if m == streaming_payload_member: + type_name = f"IO[{type_name}]" + parameters[xform_name(m)] = type_name + param_shapes[xform_name(m)] = m_shape + + for m in members: + m_shape = input_shape.members[m] + param_shapes[xform_name(m)] = m_shape + type_name = to_valid_python_name(m_shape.name) + if m == streaming_payload_member: + type_name = f"IO[{type_name}]" + parameters[xform_name(m)] = f"{type_name} | None = None" + + if any(map(is_bad_param_name, parameters.keys())): + # if we cannot render the parameter name, don't expand the parameters in the handler + param_list = f"request: {to_valid_python_name(input_shape.name)}" if input_shape else "" + output.write(f' @handler("{operation.name}", expand=False)\n') + else: + param_list = ", ".join([f"{k}: {v}" for k, v in parameters.items()]) + output.write(f' @handler("{operation.name}")\n') + + # add the **kwargs in the end + if param_list: + param_list += ", **kwargs" + else: + param_list = "**kwargs" + + output.write( + f" def {fn_name}(self, context: RequestContext, {param_list}) -> {output_shape}:\n" + ) + + # convert html documentation to rst and print it into to the signature + if doc: + html = operation.documentation + rst = html_to_rst(html) + output.write(' """') + output.write(f"{rst}\n") + output.write("\n") + + # parameters + for param_name, shape in param_shapes.items(): + # FIXME: this doesn't work properly + rst = html_to_rst(shape.documentation) + rst = rst.strip().split(".")[0] + "." + output.write(f":param {param_name}: {rst}\n") + + # return value + if operation.output_shape: + output.write(f":returns: {to_valid_python_name(operation.output_shape.name)}\n") + + # errors + for error in operation.error_shapes: + output.write(f":raises {to_valid_python_name(error.name)}:\n") + + output.write(' """\n') + + output.write(" raise NotImplementedError\n") + + +@click.group() +def scaffold(): + pass + + +@scaffold.command(name="generate") +@click.argument("service", type=str) +@click.option("--doc/--no-doc", default=False, help="whether or not to generate docstrings") +@click.option( + "--save/--print", + default=False, + help="whether or not to save the result into the api directory", +) +@click.option( + "--path", + default="./localstack-core/localstack/aws/api", + help="the path where the api should be saved", +) +def generate(service: str, doc: bool, save: bool, path: str): + """ + Generate types and API stubs for a given AWS service. + + SERVICE is the service to generate the stubs for (e.g., sqs, or cloudformation) + """ + from click import ClickException + + try: + code = generate_code(service, doc=doc) + except UnknownServiceError: + raise ClickException(f"unknown service {service}") + + if not save: + # either just print the code to stdout + click.echo(code) + return + + # or find the file path and write the code to that location + create_code_directory(service, code, path) + click.echo("done!") + + +def generate_code(service_name: str, doc: bool = False) -> str: + model = load_service(service_name) + output = io.StringIO() + generate_service_types(output, model, doc=doc) + generate_service_api(output, model, doc=doc) + return output.getvalue() + + +def create_code_directory(service_name: str, code: str, base_path: str): + service_name = service_name.replace("-", "_") + # handle service names which are reserved keywords in python (f.e. lambda) + if is_keyword(service_name): + service_name += "_" + path = Path(base_path, service_name) + + if not path.exists(): + click.echo(f"creating directory {path}") + path.mkdir() + + file = path / "__init__.py" + click.echo(f"writing to file {file}") + file.write_text(code) + + +@scaffold.command() +@click.option("--doc/--no-doc", default=False, help="whether or not to generate docstrings") +@click.option( + "--path", + default="./localstack-core/localstack/aws/api", + help="the path in which to upgrade ASF APIs", +) +def upgrade(path: str, doc: bool = False): + """ + Execute the code generation for all existing APIs. + """ + services = [ + d.name.rstrip("_").replace("_", "-") + for d in Path(path).iterdir() + if d.is_dir() and not d.name.startswith("__") + ] + + with Pool() as pool: + pool.starmap(_do_generate_code, [(service, path, doc) for service in services]) + + click.echo("done!") + + +def _do_generate_code(service: str, path: str, doc: bool): + try: + code = generate_code(service, doc) + except UnknownServiceError: + click.echo(f"unknown service {service}! skipping...") + return + create_code_directory(service, code, base_path=path) + + +if __name__ == "__main__": + scaffold() diff --git a/localstack/services/__init__.py b/localstack-core/localstack/aws/serving/__init__.py similarity index 100% rename from localstack/services/__init__.py rename to localstack-core/localstack/aws/serving/__init__.py diff --git a/localstack-core/localstack/aws/serving/asgi.py b/localstack-core/localstack/aws/serving/asgi.py new file mode 100644 index 0000000000000..3bbeefd49944f --- /dev/null +++ b/localstack-core/localstack/aws/serving/asgi.py @@ -0,0 +1,5 @@ +from rolo.gateway.asgi import AsgiGateway + +__all__ = [ + "AsgiGateway", +] diff --git a/localstack-core/localstack/aws/serving/edge.py b/localstack-core/localstack/aws/serving/edge.py new file mode 100644 index 0000000000000..0e204a4d96f88 --- /dev/null +++ b/localstack-core/localstack/aws/serving/edge.py @@ -0,0 +1,119 @@ +import logging +import threading +from typing import List + +from rolo.gateway.wsgi import WsgiGateway + +from localstack import config +from localstack.aws.app import LocalstackAwsGateway +from localstack.config import HostAndPort +from localstack.runtime import get_current_runtime +from localstack.runtime.shutdown import ON_AFTER_SERVICE_SHUTDOWN_HANDLERS +from localstack.utils.collections import ensure_list + +LOG = logging.getLogger(__name__) + + +def serve_gateway( + listen: HostAndPort | List[HostAndPort], use_ssl: bool, asynchronous: bool = False +): + """ + Implementation of the edge.do_start_edge_proxy interface to start a Hypercorn server instance serving the + LocalstackAwsGateway. + """ + + gateway = get_current_runtime().components.gateway + + listens = ensure_list(listen) + + if config.GATEWAY_SERVER == "hypercorn": + return _serve_hypercorn(gateway, listens, use_ssl, asynchronous) + elif config.GATEWAY_SERVER == "werkzeug": + return _serve_werkzeug(gateway, listens, use_ssl, asynchronous) + elif config.GATEWAY_SERVER == "twisted": + return _serve_twisted(gateway, listens, use_ssl, asynchronous) + else: + raise ValueError(f"Unknown gateway server type {config.GATEWAY_SERVER}") + + +def _serve_werkzeug( + gateway: LocalstackAwsGateway, listen: List[HostAndPort], use_ssl: bool, asynchronous: bool +): + from werkzeug.serving import ThreadedWSGIServer + + from .werkzeug import CustomWSGIRequestHandler + + params = { + "app": WsgiGateway(gateway), + "handler": CustomWSGIRequestHandler, + } + + if use_ssl: + from localstack.utils.ssl import create_ssl_cert, install_predefined_cert_if_available + + install_predefined_cert_if_available() + serial_number = listen[0].port + _, cert_file_name, key_file_name = create_ssl_cert(serial_number=serial_number) + params["ssl_context"] = (cert_file_name, key_file_name) + + threads = [] + servers: List[ThreadedWSGIServer] = [] + + for host_port in listen: + kwargs = dict(params) + kwargs["host"] = host_port.host + kwargs["port"] = host_port.port + server = ThreadedWSGIServer(**kwargs) + servers.append(server) + threads.append( + threading.Thread( + target=server.serve_forever, name=f"werkzeug-server-{host_port.port}", daemon=True + ) + ) + + def _shutdown_servers(): + LOG.debug("[shutdown] Shutting down gateway servers") + for _srv in servers: + _srv.shutdown() + + ON_AFTER_SERVICE_SHUTDOWN_HANDLERS.register(_shutdown_servers) + + for thread in threads: + thread.start() + + if not asynchronous: + for thread in threads: + return thread.join() + + # FIXME: thread handling is a bit wonky + return threads[0] + + +def _serve_hypercorn( + gateway: LocalstackAwsGateway, listen: List[HostAndPort], use_ssl: bool, asynchronous: bool +): + from localstack.http.hypercorn import GatewayServer + + # start serving gateway + server = GatewayServer(gateway, listen, use_ssl, config.GATEWAY_WORKER_COUNT) + server.start() + + # with the current way the infrastructure is started, this is the easiest way to shut down the server correctly + # FIXME: but the infrastructure shutdown should be much cleaner, core components like the gateway should be handled + # explicitly by the thing starting the components, not implicitly by the components. + def _shutdown_gateway(): + LOG.debug("[shutdown] Shutting down gateway server") + server.shutdown() + + ON_AFTER_SERVICE_SHUTDOWN_HANDLERS.register(_shutdown_gateway) + if not asynchronous: + server.join() + return server._thread + + +def _serve_twisted( + gateway: LocalstackAwsGateway, listen: List[HostAndPort], use_ssl: bool, asynchronous: bool +): + from .twisted import serve_gateway + + return serve_gateway(gateway, listen, use_ssl, asynchronous) diff --git a/localstack-core/localstack/aws/serving/hypercorn.py b/localstack-core/localstack/aws/serving/hypercorn.py new file mode 100644 index 0000000000000..450d2664badc9 --- /dev/null +++ b/localstack-core/localstack/aws/serving/hypercorn.py @@ -0,0 +1,47 @@ +import asyncio +from typing import Any, Optional, Tuple + +from hypercorn import Config +from hypercorn.asyncio import serve as serve_hypercorn + +from localstack import constants + +from ..gateway import Gateway +from .asgi import AsgiGateway + + +def serve( + gateway: Gateway, + host: str = "localhost", + port: int = constants.DEFAULT_PORT_EDGE, + use_reloader: bool = True, + ssl_creds: Optional[Tuple[Any, Any]] = None, + **kwargs, +) -> None: + """ + Serve the given Gateway through a hypercorn server and block until it is completed. + + :param gateway: the Gateway instance to serve + :param host: the host to expose the server on + :param port: the port to expose the server on + :param use_reloader: whether to use the reloader + :param ssl_creds: the ssl credentials (tuple of certfile and keyfile) + :param kwargs: any oder parameters that can be passed to the hypercorn.Config object + """ + config = Config() + config.h11_pass_raw_headers = True + config.bind = f"{host}:{port}" + config.use_reloader = use_reloader + + if ssl_creds: + cert_file_name, key_file_name = ssl_creds + if cert_file_name: + kwargs["certfile"] = cert_file_name + if key_file_name: + kwargs["keyfile"] = key_file_name + + for k, v in kwargs.items(): + setattr(config, k, v) + + loop = asyncio.new_event_loop() + loop.run_until_complete(serve_hypercorn(AsgiGateway(gateway, event_loop=loop), config)) diff --git a/localstack-core/localstack/aws/serving/twisted.py b/localstack-core/localstack/aws/serving/twisted.py new file mode 100644 index 0000000000000..549150a73ae61 --- /dev/null +++ b/localstack-core/localstack/aws/serving/twisted.py @@ -0,0 +1,173 @@ +""" +Bindings to serve LocalStack using twisted. +""" + +import logging +import time +from typing import List + +from rolo.gateway import Gateway +from rolo.serving.twisted import TwistedGateway +from twisted.internet import endpoints, interfaces, reactor, ssl +from twisted.protocols.policies import ProtocolWrapper, WrappingFactory +from twisted.protocols.tls import BufferingTLSTransport, TLSMemoryBIOFactory +from twisted.python.threadpool import ThreadPool + +from localstack import config +from localstack.config import HostAndPort +from localstack.runtime.shutdown import ON_AFTER_SERVICE_SHUTDOWN_HANDLERS +from localstack.utils.patch import patch +from localstack.utils.ssl import create_ssl_cert, install_predefined_cert_if_available +from localstack.utils.threads import start_worker_thread + +LOG = logging.getLogger(__name__) + + +class TLSMultiplexer(ProtocolWrapper): + """ + Custom protocol to multiplex HTTPS and HTTP connections over the same port. This is the equivalent of + ``DuplexSocket``, but since twisted use its own SSL layer and doesn't use `ssl.SSLSocket``, we need to implement + the multiplexing behavior in the Twisted layer. + + The basic idea is to defer the ``makeConnection`` call until the first data are received, and then re-configure + the underlying ``wrappedProtocol`` if needed with a TLS wrapper. + """ + + tlsProtocol = BufferingTLSTransport + + def __init__( + self, + factory: "WrappingFactory", + wrappedProtocol: interfaces.IProtocol, + ): + super().__init__(factory, wrappedProtocol) + self._isInitialized = False + self._isTLS = None + self._negotiatedProtocol = None + + def makeConnection(self, transport): + self.connected = 1 + self.transport = transport + self.factory.registerProtocol(self) # this is idempotent + # we defer the actual makeConnection call to the first invocation of dataReceived + + def dataReceived(self, data: bytes) -> None: + if self._isInitialized: + super().dataReceived(data) + return + + # once the first data have been received, we can check whether it's a TLS handshake, then we need to run the + # actual makeConnection procedure. + self._isInitialized = True + self._isTLS = data[0] == 22 # 0x16 is the marker byte identifying a TLS handshake + + if self._isTLS: + # wrap protocol again in tls protocol + self.wrappedProtocol = self.tlsProtocol(self.factory, self.wrappedProtocol) + else: + if data.startswith(b"PRI * HTTP/2"): + # TODO: can we do proper protocol negotiation like in ALPN? + # in the TLS case, this is determined by the ALPN procedure by OpenSSL. + self._negotiatedProtocol = b"h2" + + # now that we've set the real wrapped protocol, run the make connection procedure + super().makeConnection(self.transport) + super().dataReceived(data) + + @property + def negotiatedProtocol(self) -> str | None: + if self._negotiatedProtocol: + return self._negotiatedProtocol + return self.wrappedProtocol.negotiatedProtocol + + +class TLSMultiplexerFactory(TLSMemoryBIOFactory): + protocol = TLSMultiplexer + + +def stop_thread_pool(self: ThreadPool, stop, timeout: float = None): + """ + Patch for a custom shutdown procedure for a ThreadPool that waits a given amount of time for all threads. + + :param self: the pool to shut down + :param stop: the original function + :param timeout: the maximum amount of time to wait + """ + # copied from ThreadPool.stop() + if self.joined: + return + if not timeout: + stop() + return + + self.joined = True + self.started = False + self._team.quit() + + # our own joining logic with timeout + remaining = timeout + total_waited = 0 + + for thread in self.threads: + then = time.time() + + # LOG.info("[shutdown] Joining thread %s", thread) + thread.join(remaining) + + waited = time.time() - then + total_waited += waited + remaining -= waited + + if thread.is_alive(): + LOG.warning( + "[shutdown] Request thread %s still alive after %.2f seconds", + thread, + total_waited, + ) + + if remaining <= 0: + remaining = 0 + + +def serve_gateway( + gateway: Gateway, listen: List[HostAndPort], use_ssl: bool, asynchronous: bool = False +): + """ + Serve a Gateway instance using twisted. + """ + # setup reactor + reactor.suggestThreadPoolSize(config.GATEWAY_WORKER_COUNT) + thread_pool = reactor.getThreadPool() + patch(thread_pool.stop)(stop_thread_pool) + + def _shutdown_reactor(): + LOG.debug("[shutdown] Shutting down twisted reactor serving the gateway") + thread_pool.stop(timeout=10) + reactor.stop() + + ON_AFTER_SERVICE_SHUTDOWN_HANDLERS.register(_shutdown_reactor) + + # setup twisted webserver Site + site = TwistedGateway(gateway) + + # configure ssl + if use_ssl: + install_predefined_cert_if_available() + serial_number = listen[0].port + _, cert_file_name, key_file_name = create_ssl_cert(serial_number=serial_number) + context_factory = ssl.DefaultOpenSSLContextFactory(key_file_name, cert_file_name) + context_factory.getContext().use_certificate_chain_file(cert_file_name) + protocol_factory = TLSMultiplexerFactory(context_factory, False, site) + else: + protocol_factory = site + + # add endpoint for each host/port combination + for host_and_port in listen: + # TODO: interface = host? + endpoint = endpoints.TCP4ServerEndpoint(reactor, host_and_port.port) + endpoint.listen(protocol_factory) + + if asynchronous: + return start_worker_thread(reactor.run) + else: + return reactor.run() diff --git a/localstack-core/localstack/aws/serving/werkzeug.py b/localstack-core/localstack/aws/serving/werkzeug.py new file mode 100644 index 0000000000000..22e351adc4842 --- /dev/null +++ b/localstack-core/localstack/aws/serving/werkzeug.py @@ -0,0 +1,58 @@ +import ssl +from typing import TYPE_CHECKING, Any, Optional, Tuple + +from rolo.gateway import Gateway +from rolo.gateway.wsgi import WsgiGateway +from werkzeug import run_simple +from werkzeug.serving import WSGIRequestHandler + +if TYPE_CHECKING: + from _typeshed.wsgi import WSGIEnvironment + +from localstack import constants + + +def serve( + gateway: Gateway, + host: str = "localhost", + port: int = constants.DEFAULT_PORT_EDGE, + use_reloader: bool = True, + ssl_creds: Optional[Tuple[Any, Any]] = None, + **kwargs, +) -> None: + """ + Serve a Gateway as a WSGI application through werkzeug. This is mostly for development purposes. + + :param gateway: the Gateway to serve + :param host: the host to expose the server to + :param port: the port to expose the server to + :param use_reloader: whether to autoreload the server on changes + :param kwargs: any other arguments that can be passed to `werkzeug.run_simple` + """ + kwargs["threaded"] = kwargs.get("threaded", True) # make sure requests don't block + kwargs["ssl_context"] = ssl_creds + kwargs.setdefault("request_handler", CustomWSGIRequestHandler) + run_simple(host, port, WsgiGateway(gateway), use_reloader=use_reloader, **kwargs) + + +class CustomWSGIRequestHandler(WSGIRequestHandler): + def make_environ(self) -> "WSGIEnvironment": + environ = super().make_environ() + + # restore RAW_URI from the requestline will be something like ``GET //foo/?foo=bar%20ed HTTP/1.1`` + environ["RAW_URI"] = " ".join(self.requestline.split(" ")[1:-1]) + + # restore raw headers for rolo + environ["asgi.headers"] = [ + (k.encode("latin-1"), v.encode("latin-1")) for k, v in self.headers.raw_items() + ] + + # the default WSGIRequestHandler does not understand our DuplexSocket, so it will always set https, which we + # correct here + try: + is_ssl = isinstance(self.request, ssl.SSLSocket) + except AttributeError: + is_ssl = False + environ["wsgi.url_scheme"] = "https" if is_ssl else "http" + + return environ diff --git a/localstack-core/localstack/aws/serving/wsgi.py b/localstack-core/localstack/aws/serving/wsgi.py new file mode 100644 index 0000000000000..8ae26b3d8c9df --- /dev/null +++ b/localstack-core/localstack/aws/serving/wsgi.py @@ -0,0 +1,5 @@ +from rolo.gateway.wsgi import WsgiGateway + +__all__ = [ + "WsgiGateway", +] diff --git a/localstack-core/localstack/aws/skeleton.py b/localstack-core/localstack/aws/skeleton.py new file mode 100644 index 0000000000000..9d66fa4b375c1 --- /dev/null +++ b/localstack-core/localstack/aws/skeleton.py @@ -0,0 +1,228 @@ +import inspect +import logging +from typing import Any, Callable, Dict, NamedTuple, Optional, Union + +from botocore import xform_name +from botocore.model import ServiceModel + +from localstack.aws.api import ( + CommonServiceException, + RequestContext, + ServiceException, +) +from localstack.aws.api.core import ServiceRequest, ServiceRequestHandler, ServiceResponse +from localstack.aws.protocol.parser import create_parser +from localstack.aws.protocol.serializer import ResponseSerializer, create_serializer +from localstack.aws.spec import load_service +from localstack.http import Response +from localstack.utils import analytics +from localstack.utils.coverage_docs import get_coverage_link_for_service + +LOG = logging.getLogger(__name__) + +DispatchTable = Dict[str, ServiceRequestHandler] + + +def create_skeleton(service: Union[str, ServiceModel], delegate: Any): + if isinstance(service, str): + service = load_service(service) + + return Skeleton(service, create_dispatch_table(delegate)) + + +class HandlerAttributes(NamedTuple): + """ + Holder object of the attributes added to a function by the @handler decorator. + """ + + function_name: str + operation: str + pass_context: bool + expand_parameters: bool + + +def create_dispatch_table(delegate: object) -> DispatchTable: + """ + Creates a dispatch table for a given object. First, the entire class tree of the object is scanned to find any + functions that are decorated with @handler. It then resolves those functions on the delegate. + """ + # scan class tree for @handler wrapped functions (reverse class tree so that inherited functions overwrite parent + # functions) + cls_tree = inspect.getmro(delegate.__class__) + handlers: Dict[str, HandlerAttributes] = {} + cls_tree = reversed(list(cls_tree)) + for cls in cls_tree: + if cls == object: + continue + + for name, fn in inspect.getmembers(cls, inspect.isfunction): + try: + # attributes come from operation_marker in @handler wrapper + handlers[fn.operation] = HandlerAttributes( + fn.__name__, fn.operation, fn.pass_context, fn.expand_parameters + ) + except AttributeError: + pass + + # create dispatch table from operation handlers by resolving bound functions on the delegate + dispatch_table: DispatchTable = {} + for handler in handlers.values(): + # resolve the bound function of the delegate + bound_function = getattr(delegate, handler.function_name) + # create a dispatcher + dispatch_table[handler.operation] = ServiceRequestDispatcher( + bound_function, + operation=handler.operation, + pass_context=handler.pass_context, + expand_parameters=handler.expand_parameters, + ) + + return dispatch_table + + +class ServiceRequestDispatcher: + fn: Callable + operation: str + expand_parameters: bool = True + pass_context: bool = True + + def __init__( + self, + fn: Callable, + operation: str, + pass_context: bool = True, + expand_parameters: bool = True, + ): + self.fn = fn + self.operation = operation + self.pass_context = pass_context + self.expand_parameters = expand_parameters + + def __call__( + self, context: RequestContext, request: ServiceRequest + ) -> Optional[ServiceResponse]: + args = [] + kwargs = {} + + if not self.expand_parameters: + if self.pass_context: + args.append(context) + args.append(request) + else: + if request is None: + kwargs = {} + else: + kwargs = {xform_name(k): v for k, v in request.items()} + kwargs["context"] = context + + return self.fn(*args, **kwargs) + + +class Skeleton: + service: ServiceModel + dispatch_table: DispatchTable + + def __init__(self, service: ServiceModel, implementation: Union[Any, DispatchTable]): + self.service = service + + if isinstance(implementation, dict): + self.dispatch_table = implementation + else: + self.dispatch_table = create_dispatch_table(implementation) + + def invoke(self, context: RequestContext) -> Response: + serializer = create_serializer(context.service) + + if context.operation and context.service_request: + # if the parsed request is already set in the context, re-use them + operation, instance = context.operation, context.service_request + else: + # otherwise, parse the incoming HTTPRequest + operation, instance = create_parser(context.service).parse(context.request) + context.operation = operation + + try: + # Find the operation's handler in the dispatch table + if operation.name not in self.dispatch_table: + LOG.warning( + "missing entry in dispatch table for %s.%s", + self.service.service_name, + operation.name, + ) + raise NotImplementedError + + return self.dispatch_request(serializer, context, instance) + except ServiceException as e: + return self.on_service_exception(serializer, context, e) + except NotImplementedError as e: + return self.on_not_implemented_error(serializer, context, e) + + def dispatch_request( + self, serializer: ResponseSerializer, context: RequestContext, instance: ServiceRequest + ) -> Response: + operation = context.operation + + handler = self.dispatch_table[operation.name] + + # Call the appropriate handler + result = handler(context, instance) or {} + + # if the service handler returned an HTTP request, forego serialization and return immediately + if isinstance(result, Response): + return result + + context.service_response = result + + # Serialize result dict to a Response and return it + return serializer.serialize_to_response( + result, operation, context.request.headers, context.request_id + ) + + def on_service_exception( + self, serializer: ResponseSerializer, context: RequestContext, exception: ServiceException + ) -> Response: + """ + Called by invoke if the handler of the operation raised a ServiceException. + + :param serializer: serializer which should be used to serialize the exception + :param context: the request context + :param exception: the exception that was raised + :return: a Response object + """ + context.service_exception = exception + + return serializer.serialize_error_to_response( + exception, context.operation, context.request.headers, context.request_id + ) + + def on_not_implemented_error( + self, + serializer: ResponseSerializer, + context: RequestContext, + exception: NotImplementedError, + ) -> Response: + """ + Called by invoke if either the dispatch table did not contain an entry for the operation, or the service + provider raised a NotImplementedError + :param serializer: the serialzier which should be used to serialize the NotImplementedError + :param context: the request context + :param exception: the NotImplementedError that was raised + :return: a Response object + """ + operation = context.operation + + action_name = operation.name + service_name = operation.service_model.service_name + exception_message: str | None = exception.args[0] if exception.args else None + message = exception_message or get_coverage_link_for_service(service_name, action_name) + LOG.info(message) + error = CommonServiceException("InternalFailure", message, status_code=501) + # record event + analytics.log.event( + "services_notimplemented", payload={"s": service_name, "a": action_name} + ) + context.service_exception = error + + return serializer.serialize_error_to_response( + error, operation, context.request.headers, context.request_id + ) diff --git a/localstack-core/localstack/aws/spec-patches.json b/localstack-core/localstack/aws/spec-patches.json new file mode 100644 index 0000000000000..37cc8a5c27001 --- /dev/null +++ b/localstack-core/localstack/aws/spec-patches.json @@ -0,0 +1,1356 @@ +{ + "s3/2006-03-01/service-2": [ + { + "op": "add", + "path": "/shapes/NoSuchBucket/members/BucketName", + "value": { + "shape": "BucketName" + } + }, + { + "op": "add", + "path": "/shapes/NoSuchBucket/error", + "value": { + "httpStatusCode": 404 + } + }, + { + "op": "add", + "path": "/shapes/NoSuchLifecycleConfiguration", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>The lifecycle configuration does not exist</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/InvalidBucketName", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>The specified bucket is not valid.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/BucketRegion", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/BucketContentType", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/HeadBucketOutput", + "value": { + "type": "structure", + "members": { + "BucketRegion": { + "shape": "BucketRegion", + "location": "header", + "locationName": "x-amz-bucket-region" + }, + "BucketContentType": { + "shape": "BucketContentType", + "location": "header", + "locationName": "content-type" + } + } + } + }, + { + "op": "add", + "path": "/operations/HeadBucket/output", + "value": { + "shape": "HeadBucketOutput" + } + }, + { + "op": "add", + "path": "/operations/PutBucketPolicy/http/responseCode", + "value": 204 + }, + { + "op": "add", + "path": "/shapes/GetBucketLocationOutput/payload", + "value": "LocationConstraint" + }, + { + "op": "add", + "path": "/shapes/BucketAlreadyOwnedByYou/members/BucketName", + "value": { + "shape": "BucketName" + } + }, + { + "op": "add", + "path": "/shapes/BucketAlreadyOwnedByYou/error", + "value": { + "httpStatusCode": 409 + } + }, + { + "op": "add", + "path": "/shapes/GetObjectOutput/members/StatusCode", + "value": { + "shape": "GetObjectResponseStatusCode", + "location": "statusCode" + } + }, + { + "op": "add", + "path": "/shapes/HeadObjectOutput/members/StatusCode", + "value": { + "shape": "GetObjectResponseStatusCode", + "location": "statusCode" + } + }, + { + "op": "add", + "path": "/shapes/NoSuchKey/members/Key", + "value": { + "shape": "ObjectKey" + } + }, + { + "op": "add", + "path": "/shapes/NoSuchKey/error", + "value": { + "httpStatusCode": 404 + } + }, + { + "op": "add", + "path": "/shapes/NoSuchKey/members/DeleteMarker", + "value": { + "shape": "DeleteMarker", + "location": "header", + "locationName": "x-amz-delete-marker" + } + }, + { + "op": "add", + "path": "/shapes/NoSuchKey/members/VersionId", + "value": { + "shape": "ObjectVersionId", + "location": "header", + "locationName": "x-amz-version-id" + } + }, + { + "op": "add", + "path": "/shapes/NoSuchVersion", + "value": { + "type": "structure", + "members": { + "VersionId": { + "shape": "ObjectVersionId" + }, + "Key": { + "shape": "ObjectKey" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p></p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/PreconditionFailed", + "value": { + "type": "structure", + "members": { + "Condition": { + "shape": "IfCondition" + } + }, + "error": { + "httpStatusCode": 412 + }, + "documentation": "<p>At least one of the pre-conditions you specified did not hold</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/IfCondition", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/InvalidRange", + "value": { + "type": "structure", + "members": { + "ActualObjectSize": { + "shape": "ObjectSize" + }, + "RangeRequested": { + "shape": "ContentRange" + } + }, + "error": { + "httpStatusCode": 416 + }, + "documentation": "<p>The requested range is not satisfiable</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/HeadObjectOutput/members/Expires", + "value": { + "shape": "Expires", + "documentation": "<p>The date and time at which the object is no longer cacheable.</p>", + "location": "header", + "locationName": "expires" + } + }, + { + "op": "add", + "path": "/shapes/GetObjectOutput/members/Expires", + "value": { + "shape": "Expires", + "documentation": "<p>The date and time at which the object is no longer cacheable.</p>", + "location": "header", + "locationName": "expires" + } + }, + { + "op": "add", + "path": "/shapes/RestoreObjectOutputStatusCode", + "value": { + "type": "integer" + } + }, + { + "op": "add", + "path": "/shapes/RestoreObjectOutput/members/StatusCode", + "value": { + "shape": "RestoreObjectOutputStatusCode", + "location": "statusCode" + } + }, + { + "op": "add", + "path": "/shapes/InvalidArgument", + "value": { + "type": "structure", + "members": { + "ArgumentName": { + "shape": "ArgumentName" + }, + "ArgumentValue": { + "shape": "ArgumentValue" + }, + "HostId": { + "shape": "HostId" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>Invalid Argument</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/ArgumentName", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/ArgumentValue", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/SignatureDoesNotMatch", + "value": { + "type": "structure", + "members": { + "AWSAccessKeyId": { + "shape": "AWSAccessKeyId" + }, + "CanonicalRequest": { + "shape": "CanonicalRequest" + }, + "CanonicalRequestBytes": { + "shape": "CanonicalRequestBytes" + }, + "HostId": { + "shape": "HostId" + }, + "SignatureProvided": { + "shape": "SignatureProvided" + }, + "StringToSign": { + "shape": "StringToSign" + }, + "StringToSignBytes": { + "shape": "StringToSignBytes" + } + }, + "error": { + "httpStatusCode": 403 + }, + "documentation": "<p>The request signature we calculated does not match the signature you provided. Check your key and signing method.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/AccessDenied", + "value": { + "type": "structure", + "members": { + "Expires": { + "shape": "Expires" + }, + "ServerTime": { + "shape": "ServerTime" + }, + "X_Amz_Expires": { + "shape": "X-Amz-Expires", + "locationName":"X-Amz-Expires" + }, + "HostId": { + "shape": "HostId" + }, + "HeadersNotSigned": { + "shape": "HeadersNotSigned" + } + }, + "error": { + "httpStatusCode": 403 + }, + "documentation": "<p>Request has expired</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/AWSAccessKeyId", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/HostId", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/HeadersNotSigned", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/SignatureProvided", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/StringToSign", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/StringToSignBytes", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/CanonicalRequest", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/CanonicalRequestBytes", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/ServerTime", + "value": { + "type": "timestamp" + } + }, + { + "op": "add", + "path": "/shapes/X-Amz-Expires", + "value": { + "type": "integer" + } + }, + { + "op": "add", + "path": "/shapes/AuthorizationQueryParametersError", + "value": { + "type": "structure", + "members": { + "HostId": { + "shape": "HostId" + } + }, + "documentation": "<p>Query-string authentication version 4 requires the X-Amz-Algorithm, X-Amz-Credential, X-Amz-Signature, X-Amz-Date, X-Amz-SignedHeaders, and X-Amz-Expires parameters.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/operations/PostObject", + "value": { + "name":"PostObject", + "http":{ + "method":"POST", + "requestUri":"/{Bucket}" + }, + "input":{"shape":"PostObjectRequest"}, + "output":{"shape":"PostResponse"}, + "documentationUrl":"http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html", + "documentation":"<p>The POST operation adds an object to a specified bucket by using HTML forms. POST is an alternate form of PUT that enables browser-based uploads as a way of putting objects in buckets. Parameters that are passed to PUT through HTTP Headers are instead passed as form fields to POST in the multipart/form-data encoded message body. To add an object to a bucket, you must have WRITE access on the bucket. Amazon S3 never stores partial objects. If you receive a successful response, you can be confident that the entire object was stored.<p>" + } + }, + { + "op": "add", + "path": "/shapes/PostObjectRequest", + "value": { + "type":"structure", + "required":[ + "Bucket" + ], + "members":{ + "Body":{ + "shape":"Body", + "documentation":"<p>Object data.</p>", + "streaming":true + }, + "Bucket":{ + "shape":"BucketName", + "documentation":"<p>The bucket name to which the PUT action was initiated. </p> <p>When using this action with an access point, you must direct requests to the access point hostname. The access point hostname takes the form <i>AccessPointName</i>-<i>AccountId</i>.s3-accesspoint.<i>Region</i>.amazonaws.com. When using this action with an access point through the Amazon Web Services SDKs, you provide the access point ARN in place of the bucket name. For more information about access point ARNs, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-access-points.html\">Using access points</a> in the <i>Amazon S3 User Guide</i>.</p> <p>When using this action with Amazon S3 on Outposts, you must direct requests to the S3 on Outposts hostname. The S3 on Outposts hostname takes the form <code> <i>AccessPointName</i>-<i>AccountId</i>.<i>outpostID</i>.s3-outposts.<i>Region</i>.amazonaws.com</code>. When using this action with S3 on Outposts through the Amazon Web Services SDKs, you provide the Outposts bucket ARN in place of the bucket name. For more information about S3 on Outposts ARNs, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/S3onOutposts.html\">Using Amazon S3 on Outposts</a> in the <i>Amazon S3 User Guide</i>.</p>", + "location":"uri", + "locationName":"Bucket" + } + }, + "payload":"Body" + } + }, + { + "op": "add", + "path": "/shapes/PostResponse", + "value": { + "type":"structure", + "members":{ + "StatusCode": { + "shape": "GetObjectResponseStatusCode", + "location": "statusCode" + }, + "Location":{ + "shape":"Location", + "documentation":"<p>The URI that identifies the newly created object.</p>" + }, + "LocationHeader":{ + "shape":"Location", + "documentation":"<p>The URI that identifies the newly created object.</p>", + "location": "header", + "locationName": "Location" + }, + "Bucket":{ + "shape":"BucketName", + "documentation":"<p>The name of the bucket that contains the newly created object. Does not return the access point ARN or access point alias if used.</p> <p>When using this action with an access point, you must direct requests to the access point hostname. The access point hostname takes the form <i>AccessPointName</i>-<i>AccountId</i>.s3-accesspoint.<i>Region</i>.amazonaws.com. When using this action with an access point through the Amazon Web Services SDKs, you provide the access point ARN in place of the bucket name. For more information about access point ARNs, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-access-points.html\">Using access points</a> in the <i>Amazon S3 User Guide</i>.</p> <p>When using this action with Amazon S3 on Outposts, you must direct requests to the S3 on Outposts hostname. The S3 on Outposts hostname takes the form <code> <i>AccessPointName</i>-<i>AccountId</i>.<i>outpostID</i>.s3-outposts.<i>Region</i>.amazonaws.com</code>. When using this action with S3 on Outposts through the Amazon Web Services SDKs, you provide the Outposts bucket ARN in place of the bucket name. For more information about S3 on Outposts ARNs, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/S3onOutposts.html\">Using Amazon S3 on Outposts</a> in the <i>Amazon S3 User Guide</i>.</p>" + }, + "Key":{ + "shape":"ObjectKey", + "documentation":"<p>The object key of the newly created object.</p>" + }, + "Expiration": { + "shape": "Expiration", + "documentation": "<p>If the expiration is configured for the object (see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketLifecycleConfiguration.html\">PutBucketLifecycleConfiguration</a>), the response includes this header. It includes the <code>expiry-date</code> and <code>rule-id</code> key-value pairs that provide information about object expiration. The value of the <code>rule-id</code> is URL-encoded.</p>", + "location": "header", + "locationName": "x-amz-expiration" + }, + "ETag":{ + "shape":"ETag", + "documentation":"<p>Entity tag that identifies the newly created object's data. Objects with different object data will have different entity tags. The entity tag is an opaque string. The entity tag may or may not be an MD5 digest of the object data. If the entity tag is not an MD5 digest of the object data, it will contain one or more nonhexadecimal characters and/or will consist of less than 32 or more than 32 hexadecimal digits. For more information about how the entity tag is calculated, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html\">Checking object integrity</a> in the <i>Amazon S3 User Guide</i>.</p>" + }, + "ETagHeader":{ + "shape":"ETag", + "documentation":"<p>Entity tag that identifies the newly created object's data. Objects with different object data will have different entity tags. The entity tag is an opaque string. The entity tag may or may not be an MD5 digest of the object data. If the entity tag is not an MD5 digest of the object data, it will contain one or more nonhexadecimal characters and/or will consist of less than 32 or more than 32 hexadecimal digits. For more information about how the entity tag is calculated, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html\">Checking object integrity</a> in the <i>Amazon S3 User Guide</i>.</p>", + "location": "header", + "locationName": "ETag" + }, + "ChecksumCRC32": { + "shape": "ChecksumCRC32", + "documentation": "<p>The base64-encoded, 32-bit CRC32 checksum of the object. This will only be present if it was uploaded with the object. With multipart uploads, this may not be a checksum value of the object. For more information about how checksums are calculated with multipart uploads, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html#large-object-checksums\"> Checking object integrity</a> in the <i>Amazon S3 User Guide</i>.</p>", + "location": "header", + "locationName": "x-amz-checksum-crc32" + }, + "ChecksumCRC32C": { + "shape": "ChecksumCRC32C", + "documentation": "<p>The base64-encoded, 32-bit CRC32C checksum of the object. This will only be present if it was uploaded with the object. With multipart uploads, this may not be a checksum value of the object. For more information about how checksums are calculated with multipart uploads, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html#large-object-checksums\"> Checking object integrity</a> in the <i>Amazon S3 User Guide</i>.</p>", + "location": "header", + "locationName": "x-amz-checksum-crc32c" + }, + "ChecksumCRC64NVME":{ + "shape":"ChecksumCRC64NVME", + "documentation":"<p>This header can be used as a data integrity check to verify that the data received is the same data that was originally sent. This header specifies the Base64 encoded, 64-bit <code>CRC64NVME</code> checksum of the object. The <code>CRC64NVME</code> checksum is always a full object checksum. For more information, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html\">Checking object integrity in the Amazon S3 User Guide</a>.</p>", + "location":"header", + "locationName":"x-amz-checksum-crc64nvme" + }, + "ChecksumSHA1": { + "shape": "ChecksumSHA1", + "documentation": "<p>The base64-encoded, 160-bit SHA-1 digest of the object. This will only be present if it was uploaded with the object. With multipart uploads, this may not be a checksum value of the object. For more information about how checksums are calculated with multipart uploads, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html#large-object-checksums\"> Checking object integrity</a> in the <i>Amazon S3 User Guide</i>.</p>", + "location": "header", + "locationName": "x-amz-checksum-sha1" + }, + "ChecksumSHA256": { + "shape": "ChecksumSHA256", + "documentation": "<p>The base64-encoded, 256-bit SHA-256 digest of the object. This will only be present if it was uploaded with the object. With multipart uploads, this may not be a checksum value of the object. For more information about how checksums are calculated with multipart uploads, see <a href=\"https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html#large-object-checksums\"> Checking object integrity</a> in the <i>Amazon S3 User Guide</i>.</p>", + "location": "header", + "locationName": "x-amz-checksum-sha256" + }, + "ChecksumType":{ + "shape":"ChecksumType", + "documentation":"<p>This header specifies the checksum type of the object, which determines how part-level checksums are combined to create an object-level checksum for multipart objects. You can use this header as a data integrity check to verify that the checksum type that is received is the same checksum that was specified. If the checksum type doesn’t match the checksum type that was specified for the object during the <code>CreateMultipartUpload</code> request, it’ll result in a <code>BadDigest</code> error. For more information, see Checking object integrity in the Amazon S3 User Guide. </p>", + "location":"header", + "locationName":"x-amz-checksum-type" + }, + "ServerSideEncryption": { + "shape": "ServerSideEncryption", + "documentation": "<p>If you specified server-side encryption either with an Amazon Web Services KMS key or Amazon S3-managed encryption key in your PUT request, the response includes this header. It confirms the encryption algorithm that Amazon S3 used to encrypt the object.</p>", + "location": "header", + "locationName": "x-amz-server-side-encryption" + }, + "VersionId": { + "shape": "ObjectVersionId", + "documentation": "<p>Version of the object.</p>", + "location": "header", + "locationName": "x-amz-version-id" + }, + "SSECustomerAlgorithm": { + "shape": "SSECustomerAlgorithm", + "documentation": "<p>If server-side encryption with a customer-provided encryption key was requested, the response will include this header confirming the encryption algorithm used.</p>", + "location": "header", + "locationName": "x-amz-server-side-encryption-customer-algorithm" + }, + "SSECustomerKeyMD5": { + "shape": "SSECustomerKeyMD5", + "documentation": "<p>If server-side encryption with a customer-provided encryption key was requested, the response will include this header to provide round-trip message integrity verification of the customer-provided encryption key.</p>", + "location": "header", + "locationName": "x-amz-server-side-encryption-customer-key-MD5" + }, + "SSEKMSKeyId": { + "shape": "SSEKMSKeyId", + "documentation": "<p>If <code>x-amz-server-side-encryption</code> is present and has the value of <code>aws:kms</code>, this header specifies the ID of the Amazon Web Services Key Management Service (Amazon Web Services KMS) symmetric customer managed key that was used for the object. </p>", + "location": "header", + "locationName": "x-amz-server-side-encryption-aws-kms-key-id" + }, + "SSEKMSEncryptionContext": { + "shape": "SSEKMSEncryptionContext", + "documentation": "<p>If present, specifies the Amazon Web Services KMS Encryption Context to use for object encryption. The value of this header is a base64-encoded UTF-8 string holding JSON with the encryption context key-value pairs.</p>", + "location": "header", + "locationName": "x-amz-server-side-encryption-context" + }, + "BucketKeyEnabled": { + "shape": "BucketKeyEnabled", + "documentation": "<p>Indicates whether the uploaded object uses an S3 Bucket Key for server-side encryption with Amazon Web Services KMS (SSE-KMS).</p>", + "location": "header", + "locationName": "x-amz-server-side-encryption-bucket-key-enabled" + }, + "RequestCharged": { + "shape": "RequestCharged", + "location": "header", + "locationName": "x-amz-request-charged" + } + } + } + }, + { + "op": "add", + "path": "/shapes/NoSuchWebsiteConfiguration", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>The specified bucket does not have a website configuration</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/NoSuchUpload/members/UploadId", + "value": { + "shape": "MultipartUploadId" + } + }, + { + "op": "add", + "path": "/shapes/NoSuchUpload/error", + "value": { + "httpStatusCode": 404 + } + }, + { + "op": "add", + "path": "/shapes/ReplicationConfigurationNotFoundError", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>The replication configuration was not found.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/BucketCannedACL/enum/4", + "value": "log-delivery-write", + "documentation": "<p>Not included in the specs, but valid value according to the docs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl</p>" + }, + { + "op": "add", + "path": "/shapes/BadRequest", + "value": { + "type": "structure", + "members": { + "HostId": { + "shape": "HostId" + } + }, + "documentation": "<p>Insufficient information. Origin request header needed.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/AccessForbidden", + "value": { + "type": "structure", + "members": { + "HostId": { + "shape": "HostId" + }, + "Method": { + "shape": "HttpMethod" + }, + "ResourceType": { + "shape": "ResourceType" + } + }, + "error": { + "httpStatusCode": 403 + }, + "documentation": "<p>CORSResponse</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/HttpMethod", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/ResourceType", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/NoSuchCORSConfiguration", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>The CORS configuration does not exist</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/MissingSecurityHeader", + "value": { + "type": "structure", + "members": { + "MissingHeaderName": { + "shape": "MissingHeaderName" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>Your request was missing a required header</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/MissingHeaderName", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/InvalidPartOrder", + "value": { + "type": "structure", + "members": { + "UploadId": { + "shape": "MultipartUploadId" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>The list of parts was not in ascending order. Parts must be ordered by part number.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/InvalidStorageClass", + "value": { + "type": "structure", + "members": { + "StorageClassRequested": { + "shape": "StorageClass" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>The storage class you specified is not valid</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/ListObjectsOutput/members/BucketRegion", + "value": { + "shape": "BucketRegion", + "location": "header", + "locationName": "x-amz-bucket-region" + } + }, + { + "op": "add", + "path": "/shapes/ListObjectsV2Output/members/BucketRegion", + "value": { + "shape": "BucketRegion", + "location": "header", + "locationName": "x-amz-bucket-region" + } + }, + { + "op": "add", + "path": "/shapes/ResourceType", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/MethodNotAllowed", + "value": { + "type": "structure", + "members": { + "Method": { + "shape": "HttpMethod" + }, + "ResourceType": { + "shape": "ResourceType" + }, + "DeleteMarker": { + "shape": "DeleteMarker", + "location": "header", + "locationName": "x-amz-delete-marker" + }, + "VersionId": { + "shape": "ObjectVersionId", + "location": "header", + "locationName": "x-amz-version-id" + }, + "Allow": { + "shape": "HttpMethod", + "location": "header", + "locationName": "allow" + } + }, + "error": { + "httpStatusCode": 405 + }, + "documentation": "<p>The specified method is not allowed against this resource.</p>", + "exception": true + } + }, + { + "op": "remove", + "path": "/shapes/ListBucketsOutput/members/Buckets" + }, + { + "op": "add", + "path": "/shapes/ListBucketsOutput/members/Buckets", + "value": { + "shape":"Buckets", + "documentation":"<p>The list of buckets owned by the requester.</p>" + } + }, + { + "op": "remove", + "path": "/shapes/ListObjectsOutput/members/Contents" + }, + { + "op": "add", + "path": "/shapes/ListObjectsOutput/members/Contents", + "value": { + "shape":"ObjectList", + "documentation":"<p>Metadata about each object returned.</p>" + } + }, + { + "op": "remove", + "path": "/shapes/ListObjectsV2Output/members/Contents" + }, + { + "op": "add", + "path": "/shapes/ListObjectsV2Output/members/Contents", + "value": { + "shape":"ObjectList", + "documentation":"<p>Metadata about each object returned.</p>" + } + }, + { + "op": "add", + "path": "/shapes/CrossLocationLoggingProhibitted", + "value": { + "type": "structure", + "members": { + "TargetBucketLocation": { + "shape": "BucketRegion" + }, + "SourceBucketLocation": { + "shape": "BucketRegion" + } + }, + "error": { + "httpStatusCode": 403 + }, + "documentation": "<p>Cross S3 location logging not allowed. </p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/InvalidTargetBucketForLogging", + "value": { + "type": "structure", + "members": { + "TargetBucket": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>The target bucket for logging does not exist</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/operations/PutBucketInventoryConfiguration/http/responseCode", + "value": 204 + }, + { + "op": "add", + "path": "/operations/PutBucketAnalyticsConfiguration/http/responseCode", + "value": 204 + }, + { + "op": "add", + "path": "/operations/PutBucketIntelligentTieringConfiguration/http/responseCode", + "value": 204 + }, + { + "op": "add", + "path": "/shapes/BucketNotEmpty", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 409 + }, + "documentation": "<p>The bucket you tried to delete is not empty</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/MinSizeAllowed", + "value": { + "type": "long" + } + }, + { + "op": "add", + "path": "/shapes/ProposedSize", + "value": { + "type": "long" + } + }, + { + "op": "add", + "path": "/shapes/EntityTooSmall", + "value": { + "type": "structure", + "members": { + "ETag": { + "shape": "ETag" + }, + "MinSizeAllowed": { + "shape": "MinSizeAllowed" + }, + "PartNumber": { + "shape": "PartNumber" + }, + "ProposedSize": { + "shape": "ProposedSize" + } + }, + "documentation": "<p>Your proposed upload is smaller than the minimum allowed object size. Each part must be at least 5 MB in size, except the last part.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/InvalidPart", + "value": { + "type": "structure", + "members": { + "ETag": { + "shape": "ETag" + }, + "UploadId": { + "shape": "MultipartUploadId" + }, + "PartNumber": { + "shape": "PartNumber" + } + }, + "documentation": "<p>One or more of the specified parts could not be found. The part might not have been uploaded, or the specified entity tag might not have matched the part's entity tag.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/NoSuchTagSet", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>There is no tag set associated with the bucket.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/operations/PutBucketTagging/http/responseCode", + "value": 204 + }, + { + "op": "add", + "path": "/shapes/InvalidTag", + "value": { + "type": "structure", + "members": { + "TagKey": { + "shape": "ObjectKey" + }, + "TagValue": { + "shape": "Value" + } + }, + "documentation": "<p>The tag provided was not a valid tag. This error can occur if the tag did not pass input validation.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/ObjectLockConfigurationNotFoundError", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>Object Lock configuration does not exist for this bucket</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/InvalidPartNumber", + "value": { + "type": "structure", + "members": { + "PartNumberRequested": { + "shape": "PartNumber" + }, + "ActualPartCount": { + "shape": "PartNumber" + } + }, + "error": { + "httpStatusCode": 416 + }, + "documentation": "<p>The requested partnumber is not satisfiable</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/OwnershipControlsNotFoundError", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>The bucket ownership controls were not found</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/NoSuchPublicAccessBlockConfiguration", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>The public access block configuration was not found</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/NoSuchBucketPolicy", + "value": { + "type": "structure", + "members": { + "BucketName": { + "shape": "BucketName" + } + }, + "error": { + "httpStatusCode": 404 + }, + "documentation": "<p>The bucket policy does not exist</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/InvalidObjectState/error", + "value": { + "httpStatusCode": 403 + } + }, + { + "op": "add", + "path": "/shapes/InvalidDigest", + "value": { + "type": "structure", + "members": { + "Content_MD5": { + "shape": "ContentMD5", + "locationName":"Content-MD5" + } + }, + "documentation": "<p>The Content-MD5 you specified was invalid.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/KeyLength", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/KeyTooLongError", + "value": { + "type": "structure", + "members": { + "MaxSizeAllowed": { + "shape": "KeyLength" + }, + "Size": { + "shape": "KeyLength" + } + }, + "documentation": "<p>Your key is too long</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/InvalidLocationConstraint", + "value": { + "type": "structure", + "members": { + "LocationConstraint": { + "shape": "BucketRegion" + } + }, + "documentation": "<p>The specified location-constraint is not valid</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/EntityTooLarge", + "value": { + "type": "structure", + "members": { + "MaxSizeAllowed": { + "shape": "KeyLength" + }, + "HostId": { + "shape": "HostId" + }, + "ProposedSize": { + "shape": "ProposedSize" + } + }, + "documentation": "<p>Your proposed upload exceeds the maximum allowed size</p>", + "exception": true + } + }, + { + "op": "remove", + "path": "/shapes/ListObjectVersionsOutput/members/Versions" + }, + { + "op": "add", + "path": "/shapes/ListObjectVersionsOutput/members/Versions", + "value": { + "shape":"ObjectVersionList", + "documentation":"<p>Container for version information.</p>", + "locationName":"Version" + } + }, + { + "op": "add", + "path": "/shapes/InvalidEncryptionAlgorithmError", + "value": { + "type": "structure", + "members": { + "ArgumentName": { + "shape": "ArgumentName" + }, + "ArgumentValue": { + "shape": "ArgumentValue" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>The Encryption request you specified is not valid.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/Header", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/additionalMessage", + "value": { + "type": "string" + } + }, + { + "op": "add", + "path": "/shapes/NotImplemented", + "value": { + "type": "structure", + "members": { + "Header": { + "shape": "Header" + }, + "additionalMessage": { + "shape": "additionalMessage" + } + }, + "error": { + "httpStatusCode": 501 + }, + "documentation": "<p>A header you provided implies functionality that is not implemented.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/ConditionalRequestConflict", + "value": { + "type": "structure", + "members": { + "Condition": { + "shape": "IfCondition" + }, + "Key": { + "shape": "ObjectKey" + } + }, + "error": { + "httpStatusCode": 409 + }, + "documentation": "<p>The conditional request cannot succeed due to a conflicting operation against this resource.</p>", + "exception": true + } + }, + { + "op": "add", + "path": "/shapes/BadDigest", + "value": { + "type": "structure", + "members": { + "ExpectedDigest": { + "shape": "ContentMD5" + }, + "CalculatedDigest": { + "shape": "ContentMD5" + } + }, + "error": { + "httpStatusCode": 400 + }, + "documentation": "<p>The Content-MD5 you specified did not match what we received.</p>", + "exception": true + } + } + ], + "apigatewayv2/2018-11-29/service-2": [ + { + "op": "add", + "path": "/operations/UpdateDeployment/http/responseCode", + "value": 201 + }, + { + "op": "add", + "path": "/operations/UpdateApi/http/responseCode", + "value": 201 + }, + { + "op": "add", + "path": "/operations/UpdateRoute/http/responseCode", + "value": 201 + }, + { + "op": "add", + "path": "/operations/CreateApiMapping/http/responseCode", + "value": 200 + } + ] +} diff --git a/localstack-core/localstack/aws/spec.py b/localstack-core/localstack/aws/spec.py new file mode 100644 index 0000000000000..1410ddde3e246 --- /dev/null +++ b/localstack-core/localstack/aws/spec.py @@ -0,0 +1,369 @@ +import dataclasses +import json +import logging +import os +import sys +from collections import defaultdict +from functools import cached_property, lru_cache +from typing import Dict, Generator, List, Literal, NamedTuple, Optional, Tuple + +import botocore +import jsonpatch +from botocore.exceptions import UnknownServiceError +from botocore.loaders import Loader, instance_cache +from botocore.model import OperationModel, ServiceModel + +from localstack import config +from localstack.constants import VERSION +from localstack.utils.objects import singleton_factory + +LOG = logging.getLogger(__name__) + +ServiceName = str +ProtocolName = Literal["query", "json", "rest-json", "rest-xml", "ec2"] + + +class ServiceModelIdentifier(NamedTuple): + """ + Identifies a specific service model. + If the protocol is not given, the default protocol of the service with the specific name is assumed. + Maybe also add versions here in the future (if we can support multiple different versions for one service). + """ + + name: ServiceName + protocol: Optional[ProtocolName] = None + + +spec_patches_json = os.path.join(os.path.dirname(__file__), "spec-patches.json") + + +def load_spec_patches() -> Dict[str, list]: + if not os.path.exists(spec_patches_json): + return {} + with open(spec_patches_json) as fd: + return json.load(fd) + + +# Path for custom specs which are not (anymore) provided by botocore +LOCALSTACK_BUILTIN_DATA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") + + +class LocalStackBuiltInDataLoaderMixin(Loader): + def __init__(self, *args, **kwargs): + # add the builtin data path to the extra_search_paths to ensure they are discovered by the loader + super().__init__(*args, extra_search_paths=[LOCALSTACK_BUILTIN_DATA_PATH], **kwargs) + + +class PatchingLoader(Loader): + """ + A custom botocore Loader that applies JSON patches from the given json patch file to the specs as they are loaded. + """ + + patches: Dict[str, list] + + def __init__(self, patches: Dict[str, list], *args, **kwargs): + # add the builtin data path to the extra_search_paths to ensure they are discovered by the loader + super().__init__(*args, **kwargs) + self.patches = patches + + @instance_cache + def load_data(self, name: str): + result = super(PatchingLoader, self).load_data(name) + + if patches := self.patches.get(name): + return jsonpatch.apply_patch(result, patches) + + return result + + +class CustomLoader(PatchingLoader, LocalStackBuiltInDataLoaderMixin): + # Class mixing the different loader features (patching, localstack specific data) + pass + + +loader = CustomLoader(load_spec_patches()) + + +class UnknownServiceProtocolError(UnknownServiceError): + """Raised when trying to load a service with an unknown protocol. + + :ivar service_name: The name of the service. + :ivar protocol: The name of the unknown protocol. + """ + + fmt = "Unknown service protocol: '{service_name}-{protocol}'." + + +def list_services() -> List[ServiceModel]: + return [load_service(service) for service in loader.list_available_services("service-2")] + + +def load_service( + service: ServiceName, version: Optional[str] = None, protocol: Optional[ProtocolName] = None +) -> ServiceModel: + """ + Loads a service + :param service: to load, f.e. "sqs". For custom, internalized, service protocol specs (f.e. sqs-query) it's also + possible to directly define the protocol in the service name (f.e. use sqs-query) + :param version: of the service to load, f.e. "2012-11-05", by default the latest version will be used + :param protocol: specific protocol to load for the specific service, f.e. "json" for the "sqs" service + if the service cannot be found + :return: Loaded service model of the service + :raises: UnknownServiceError if the service cannot be found + :raises: UnknownServiceProtocolError if the specific protocol of the service cannot be found + """ + service_description = loader.load_service_model(service, "service-2", version) + + # check if the protocol is defined, and if so, if the loaded service defines this protocol + if protocol is not None and protocol != service_description.get("metadata", {}).get("protocol"): + # if the protocol is defined, but not the one of the currently loaded service, + # check if we already loaded the custom spec based on the naming convention (<service>-<protocol>), + # f.e. "sqs-query" + if service.endswith(f"-{protocol}"): + # if so, we raise an exception + raise UnknownServiceProtocolError(service_name=service, protocol=protocol) + # otherwise we try to load it (recursively) + try: + return load_service(f"{service}-{protocol}", version, protocol=protocol) + except UnknownServiceError: + # raise an unknown protocol error in case the service also can't be loaded with the naming convention + raise UnknownServiceProtocolError(service_name=service, protocol=protocol) + + # remove potential protocol names from the service name + # FIXME add more protocols here if we have to internalize more than just sqs-query + # TODO this should not contain specific internalized serivce names + service = {"sqs-query": "sqs"}.get(service, service) + return ServiceModel(service_description, service) + + +def iterate_service_operations() -> Generator[Tuple[ServiceModel, OperationModel], None, None]: + """ + Returns one record per operation in the AWS service spec, where the first item is the service model the operation + belongs to, and the second is the operation model. + + :return: an iterable + """ + for service in list_services(): + for op_name in service.operation_names: + yield service, service.operation_model(op_name) + + +@dataclasses.dataclass +class ServiceCatalogIndex: + """ + The ServiceCatalogIndex enables fast lookups for common operations to determine a service from service indicators. + """ + + service_names: List[ServiceName] + target_prefix_index: Dict[str, List[ServiceModelIdentifier]] + signing_name_index: Dict[str, List[ServiceModelIdentifier]] + operations_index: Dict[str, List[ServiceModelIdentifier]] + endpoint_prefix_index: Dict[str, List[ServiceModelIdentifier]] + + +class LazyServiceCatalogIndex: + """ + A ServiceCatalogIndex that builds indexes in-memory from the spec. + """ + + @cached_property + def service_names(self) -> List[ServiceName]: + return list(self._services.keys()) + + @cached_property + def target_prefix_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + result = defaultdict(list) + for service_models in self._services.values(): + for service_model in service_models: + target_prefix = service_model.metadata.get("targetPrefix") + if target_prefix: + result[target_prefix].append( + ServiceModelIdentifier(service_model.service_name, service_model.protocol) + ) + return dict(result) + + @cached_property + def signing_name_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + result = defaultdict(list) + for service_models in self._services.values(): + for service_model in service_models: + result[service_model.signing_name].append( + ServiceModelIdentifier(service_model.service_name, service_model.protocol) + ) + return dict(result) + + @cached_property + def operations_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + result = defaultdict(list) + for service_models in self._services.values(): + for service_model in service_models: + operations = service_model.operation_names + if operations: + for operation in operations: + result[operation].append( + ServiceModelIdentifier( + service_model.service_name, service_model.protocol + ) + ) + return dict(result) + + @cached_property + def endpoint_prefix_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + result = defaultdict(list) + for service_models in self._services.values(): + for service_model in service_models: + result[service_model.endpoint_prefix].append( + ServiceModelIdentifier(service_model.service_name, service_model.protocol) + ) + return dict(result) + + @cached_property + def _services(self) -> Dict[ServiceName, List[ServiceModel]]: + services = defaultdict(list) + for service in list_services(): + services[service.service_name].append(service) + return services + + +class ServiceCatalog: + index: ServiceCatalogIndex + + def __init__(self, index: ServiceCatalogIndex = None): + self.index = index or LazyServiceCatalogIndex() + + @lru_cache(maxsize=512) + def get( + self, name: ServiceName, protocol: Optional[ProtocolName] = None + ) -> Optional[ServiceModel]: + return load_service(name, protocol=protocol) + + @property + def service_names(self) -> List[ServiceName]: + return self.index.service_names + + @property + def target_prefix_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + return self.index.target_prefix_index + + @property + def signing_name_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + return self.index.signing_name_index + + @property + def operations_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + return self.index.operations_index + + @property + def endpoint_prefix_index(self) -> Dict[str, List[ServiceModelIdentifier]]: + return self.index.endpoint_prefix_index + + def by_target_prefix(self, target_prefix: str) -> List[ServiceModelIdentifier]: + return self.target_prefix_index.get(target_prefix, []) + + def by_signing_name(self, signing_name: str) -> List[ServiceModelIdentifier]: + return self.signing_name_index.get(signing_name, []) + + def by_operation(self, operation_name: str) -> List[ServiceModelIdentifier]: + return self.operations_index.get(operation_name, []) + + +def build_service_index_cache(file_path: str) -> ServiceCatalogIndex: + """ + Creates a new ServiceCatalogIndex and stores it into the given file_path. + + :param file_path: the path to store the file to + :return: the created ServiceCatalogIndex + """ + return save_service_index_cache(LazyServiceCatalogIndex(), file_path) + + +def load_service_index_cache(file: str) -> ServiceCatalogIndex: + """ + Loads from the given file the stored ServiceCatalogIndex. + + :param file: the file to load from + :return: the loaded ServiceCatalogIndex + """ + import dill + + with open(file, "rb") as fd: + return dill.load(fd) + + +def save_service_index_cache(index: LazyServiceCatalogIndex, file_path: str) -> ServiceCatalogIndex: + """ + Creates from the given LazyServiceCatalogIndex a ``ServiceCatalogIndex`, stores its contents into the given file, + and then returns the newly created index. + + :param index: the LazyServiceCatalogIndex to store the index from. + :param file_path: the path to store the binary index cache file to + :return: the created ServiceCatalogIndex + """ + import dill + + cache = ServiceCatalogIndex( + service_names=index.service_names, + endpoint_prefix_index=index.endpoint_prefix_index, + operations_index=index.operations_index, + signing_name_index=index.signing_name_index, + target_prefix_index=index.target_prefix_index, + ) + with open(file_path, "wb") as fd: + # use dill (instead of plain pickle) to avoid issues when serializing the pickle from __main__ + dill.dump(cache, fd) + return cache + + +def _get_catalog_filename(): + ls_ver = VERSION.replace(".", "_") + botocore_ver = botocore.__version__.replace(".", "_") + return f"service-catalog-{ls_ver}-{botocore_ver}.dill" + + +@singleton_factory +def get_service_catalog() -> ServiceCatalog: + """Loads the ServiceCatalog (which contains all the service specs), and potentially re-uses a cached index.""" + + try: + catalog_file_name = _get_catalog_filename() + static_catalog_file = os.path.join(config.dirs.static_libs, catalog_file_name) + + # try to load or load/build/save the service catalog index from the static libs + index = None + if os.path.exists(static_catalog_file): + # load the service catalog from the static libs dir / built at build time + LOG.debug("loading service catalog index cache file %s", static_catalog_file) + index = load_service_index_cache(static_catalog_file) + elif os.path.isdir(config.dirs.cache): + cache_catalog_file = os.path.join(config.dirs.cache, catalog_file_name) + if os.path.exists(cache_catalog_file): + LOG.debug("loading service catalog index cache file %s", cache_catalog_file) + index = load_service_index_cache(cache_catalog_file) + else: + LOG.debug("building service catalog index cache file %s", cache_catalog_file) + index = build_service_index_cache(cache_catalog_file) + return ServiceCatalog(index) + except Exception: + LOG.exception( + "error while processing service catalog index cache, falling back to lazy-loaded index" + ) + return ServiceCatalog() + + +def main(): + catalog_file_name = _get_catalog_filename() + static_catalog_file = os.path.join(config.dirs.static_libs, catalog_file_name) + + if os.path.exists(static_catalog_file): + LOG.error( + "service catalog index cache file (%s) already there. aborting!", static_catalog_file + ) + return 1 + + # load the service catalog from the static libs dir / built at build time + LOG.debug("building service catalog index cache file %s", static_catalog_file) + build_service_index_cache(static_catalog_file) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/localstack-core/localstack/cli/__init__.py b/localstack-core/localstack/cli/__init__.py new file mode 100644 index 0000000000000..fb0407e19e65e --- /dev/null +++ b/localstack-core/localstack/cli/__init__.py @@ -0,0 +1,10 @@ +from .console import console +from .plugin import LocalstackCli, LocalstackCliPlugin + +name = "cli" + +__all__ = [ + "console", + "LocalstackCli", + "LocalstackCliPlugin", +] diff --git a/localstack-core/localstack/cli/console.py b/localstack-core/localstack/cli/console.py new file mode 100644 index 0000000000000..24bda10813744 --- /dev/null +++ b/localstack-core/localstack/cli/console.py @@ -0,0 +1,11 @@ +from rich.console import Console + +BANNER = r""" + __ _______ __ __ + / / ____ _________ _/ / ___// /_____ ______/ /__ + / / / __ \/ ___/ __ `/ /\__ \/ __/ __ `/ ___/ //_/ + / /___/ /_/ / /__/ /_/ / /___/ / /_/ /_/ / /__/ ,< + /_____/\____/\___/\__,_/_//____/\__/\__,_/\___/_/|_| +""" + +console = Console() diff --git a/localstack-core/localstack/cli/exceptions.py b/localstack-core/localstack/cli/exceptions.py new file mode 100644 index 0000000000000..cd65d2ee13d26 --- /dev/null +++ b/localstack-core/localstack/cli/exceptions.py @@ -0,0 +1,19 @@ +import typing as t +from gettext import gettext + +import click +from click import ClickException, echo +from click._compat import get_text_stderr + + +class CLIError(ClickException): + """A ClickException with a red error message""" + + def format_message(self) -> str: + return click.style(f"❌ Error: {self.message}", fg="red") + + def show(self, file: t.Optional[t.IO[t.Any]] = None) -> None: + if file is None: + file = get_text_stderr() + + echo(gettext(self.format_message()), file=file) diff --git a/localstack-core/localstack/cli/localstack.py b/localstack-core/localstack/cli/localstack.py new file mode 100644 index 0000000000000..016834b3e21b3 --- /dev/null +++ b/localstack-core/localstack/cli/localstack.py @@ -0,0 +1,946 @@ +import json +import logging +import os +import sys +import traceback +from typing import Dict, List, Optional, Tuple, TypedDict + +import click +import requests + +from localstack import config +from localstack.cli.exceptions import CLIError +from localstack.constants import VERSION +from localstack.utils.analytics.cli import publish_invocation +from localstack.utils.bootstrap import get_container_default_logfile_location +from localstack.utils.json import CustomEncoder + +from .console import BANNER, console +from .plugin import LocalstackCli, load_cli_plugins + + +class LocalStackCliGroup(click.Group): + """ + A Click group used for the top-level ``localstack`` command group. It implements global exception handling + by: + + - Ignoring click exceptions (already handled) + - Handling common exceptions (like DockerNotAvailable) + - Wrapping all unexpected exceptions in a ClickException (for a unified error message) + + It also implements a custom help formatter to build more fine-grained groups. + """ + + # FIXME: find a way to communicate this from the actual command + advanced_commands = [ + "aws", + "dns", + "extensions", + "license", + "login", + "logout", + "pod", + "state", + "ephemeral", + "replicator", + ] + + def invoke(self, ctx: click.Context): + try: + return super(LocalStackCliGroup, self).invoke(ctx) + except click.exceptions.Exit: + # raise Exit exceptions unmodified (e.g., raised on --help) + raise + except click.ClickException: + # don't handle ClickExceptions, just reraise + if ctx and ctx.params.get("debug"): + click.echo(traceback.format_exc()) + raise + except Exception as e: + if ctx and ctx.params.get("debug"): + click.echo(traceback.format_exc()) + from localstack.utils.container_utils.container_client import ( + ContainerException, + DockerNotAvailable, + ) + + if isinstance(e, DockerNotAvailable): + raise CLIError( + "Docker could not be found on the system.\n" + "Please make sure that you have a working docker environment on your machine." + ) + elif isinstance(e, ContainerException): + raise CLIError(e.message) + else: + # If we have a generic exception, we wrap it in a ClickException + raise CLIError(str(e)) from e + + def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + """Extra format methods for multi methods that adds all the commands after the options. It also + groups commands into command categories.""" + categories = {"Commands": [], "Advanced": [], "Deprecated": []} + + commands = [] + for subcommand in self.list_commands(ctx): + cmd = self.get_command(ctx, subcommand) + # What is this, the tool lied about a command. Ignore it + if cmd is None: + continue + if cmd.hidden: + continue + + commands.append((subcommand, cmd)) + + # allow for 3 times the default spacing + if len(commands): + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) + + for subcommand, cmd in commands: + help = cmd.get_short_help_str(limit) + categories[self._get_category(cmd)].append((subcommand, help)) + + for category, rows in categories.items(): + if rows: + with formatter.section(category): + formatter.write_dl(rows) + + def _get_category(self, cmd) -> str: + if cmd.deprecated: + return "Deprecated" + + if cmd.name in self.advanced_commands: + return "Advanced" + + return "Commands" + + +def create_with_plugins() -> LocalstackCli: + """ + Creates a LocalstackCli instance with all cli plugins loaded. + :return: a LocalstackCli instance + """ + cli = LocalstackCli() + cli.group = localstack + load_cli_plugins(cli) + return cli + + +def _setup_cli_debug() -> None: + from localstack.logging.setup import setup_logging_for_cli + + config.DEBUG = True + os.environ["DEBUG"] = "1" + + setup_logging_for_cli(logging.DEBUG if config.DEBUG else logging.INFO) + + +# Re-usable format option decorator which can be used across multiple commands +_click_format_option = click.option( + "-f", + "--format", + "format_", + type=click.Choice(["table", "plain", "dict", "json"]), + default="table", + help="The formatting style for the command output.", +) + + +@click.group( + name="localstack", + help="The LocalStack Command Line Interface (CLI)", + cls=LocalStackCliGroup, + context_settings={ + # add "-h" as a synonym for "--help" + # https://click.palletsprojects.com/en/8.1.x/documentation/#help-parameter-customization + "help_option_names": ["-h", "--help"], + # show default values for options by default - https://github.com/pallets/click/pull/1225 + "show_default": True, + }, +) +@click.version_option( + VERSION, + "--version", + "-v", + message="LocalStack CLI %(version)s", + help="Show the version of the LocalStack CLI and exit", +) +@click.option("-d", "--debug", is_flag=True, help="Enable CLI debugging mode") +@click.option("-p", "--profile", type=str, help="Set the configuration profile") +def localstack(debug, profile) -> None: + # --profile is read manually in localstack.cli.main because it needs to be read before localstack.config is read + + if debug: + _setup_cli_debug() + + from localstack.utils.files import cache_dir + + # overwrite the config variable here to defer import of cache_dir + if not os.environ.get("LOCALSTACK_VOLUME_DIR", "").strip(): + config.VOLUME_DIR = str(cache_dir() / "volume") + + # FIXME: at some point we should remove the use of `config.dirs` for the CLI, + # see https://github.com/localstack/localstack/pull/7906 + config.dirs.for_cli().mkdirs() + + +@localstack.group( + name="config", + short_help="Manage your LocalStack config", +) +def localstack_config() -> None: + """ + Inspect and validate your LocalStack configuration. + """ + pass + + +@localstack_config.command(name="show", short_help="Show your config") +@_click_format_option +@publish_invocation +def cmd_config_show(format_: str) -> None: + """ + Print the current LocalStack config values. + + This command prints the LocalStack configuration values from your environment. + It analyzes the environment variables as well as the LocalStack CLI profile. + It does _not_ analyze a specific file (like a docker-compose-yml). + """ + # TODO: parse values from potential docker-compose file? + assert config + + try: + # only load the ext config if it's available + from localstack.pro.core import config as ext_config + + assert ext_config + except ImportError: + # the ext package is not available + return None + + if format_ == "table": + _print_config_table() + elif format_ == "plain": + _print_config_pairs() + elif format_ == "dict": + _print_config_dict() + elif format_ == "json": + _print_config_json() + else: + _print_config_pairs() # fall back to plain + + +@localstack_config.command(name="validate", short_help="Validate your config") +@click.option( + "-f", + "--file", + help="Path to compose file", + default="docker-compose.yml", + type=click.Path(exists=True, file_okay=True, readable=True), +) +@publish_invocation +def cmd_config_validate(file: str) -> None: + """ + Validate your LocalStack configuration (docker compose). + + This command inspects the given docker-compose file (by default docker-compose.yml in the current working + directory) and validates if the configuration is valid. + + \b + It will show an error and return a non-zero exit code if: + - The docker-compose file is syntactically incorrect. + - If the file contains common issues when configuring LocalStack. + """ + + from localstack.utils import bootstrap + + if bootstrap.validate_localstack_config(file): + console.print("[green]:heavy_check_mark:[/green] config valid") + sys.exit(0) + else: + console.print("[red]:heavy_multiplication_x:[/red] validation error") + sys.exit(1) + + +def _print_config_json() -> None: + import json + + console.print(json.dumps(dict(config.collect_config_items()), cls=CustomEncoder)) + + +def _print_config_pairs() -> None: + for key, value in config.collect_config_items(): + console.print(f"{key}={value}") + + +def _print_config_dict() -> None: + console.print(dict(config.collect_config_items())) + + +def _print_config_table() -> None: + from rich.table import Table + + grid = Table(show_header=True) + grid.add_column("Key") + grid.add_column("Value") + + for key, value in config.collect_config_items(): + grid.add_row(key, str(value)) + + console.print(grid) + + +@localstack.group( + name="status", + short_help="Query status info", + invoke_without_command=True, +) +@click.pass_context +def localstack_status(ctx: click.Context) -> None: + """ + Query status information about the currently running LocalStack instance. + """ + if ctx.invoked_subcommand is None: + ctx.invoke(localstack_status.get_command(ctx, "docker")) + + +@localstack_status.command(name="docker", short_help="Query LocalStack Docker status") +@_click_format_option +def cmd_status_docker(format_: str) -> None: + """ + Query information about the currently running LocalStack Docker image, its container, + and the LocalStack runtime. + """ + with console.status("Querying Docker status"): + _print_docker_status(format_) + + +class DockerStatus(TypedDict, total=False): + running: bool + runtime_version: str + image_tag: str + image_id: str + image_created: str + container_name: Optional[str] + container_ip: Optional[str] + + +def _print_docker_status(format_: str) -> None: + from localstack.utils import docker_utils + from localstack.utils.bootstrap import get_docker_image_details, get_server_version + from localstack.utils.container_networking import get_main_container_ip, get_main_container_name + + img = get_docker_image_details() + cont_name = config.MAIN_CONTAINER_NAME + running = docker_utils.DOCKER_CLIENT.is_container_running(cont_name) + status = DockerStatus( + runtime_version=get_server_version(), + image_tag=img["tag"], + image_id=img["id"], + image_created=img["created"], + running=running, + ) + if running: + status["container_name"] = get_main_container_name() + status["container_ip"] = get_main_container_ip() + + if format_ == "dict": + console.print(status) + if format_ == "table": + _print_docker_status_table(status) + if format_ == "json": + console.print(json.dumps(status)) + if format_ == "plain": + for key, value in status.items(): + console.print(f"{key}={value}") + + +def _print_docker_status_table(status: DockerStatus) -> None: + from rich.table import Table + + grid = Table(show_header=False) + grid.add_column() + grid.add_column() + + grid.add_row("Runtime version", f"[bold]{status['runtime_version']}[/bold]") + grid.add_row( + "Docker image", + f"tag: {status['image_tag']}, " + f"id: {status['image_id']}, " + f":calendar: {status['image_created']}", + ) + cont_status = "[bold][red]:heavy_multiplication_x: stopped" + if status["running"]: + cont_status = ( + f"[bold][green]:heavy_check_mark: running[/green][/bold] " + f'(name: "[italic]{status["container_name"]}[/italic]", IP: {status["container_ip"]})' + ) + grid.add_row("Runtime status", cont_status) + console.print(grid) + + +@localstack_status.command(name="services", short_help="Query LocalStack services status") +@_click_format_option +def cmd_status_services(format_: str) -> None: + """ + Query information about the services of the currently running LocalStack instance. + """ + url = config.external_service_url() + + try: + health = requests.get(f"{url}/_localstack/health", timeout=2) + doc = health.json() + services = doc.get("services", []) + if format_ == "table": + _print_service_table(services) + if format_ == "plain": + for service, status in services.items(): + console.print(f"{service}={status}") + if format_ == "dict": + console.print(services) + if format_ == "json": + console.print(json.dumps(services)) + except requests.ConnectionError: + if config.DEBUG: + console.print_exception() + raise CLIError(f"could not connect to LocalStack health endpoint at {url}") + + +def _print_service_table(services: Dict[str, str]) -> None: + from rich.table import Table + + status_display = { + "running": "[green]:heavy_check_mark:[/green] running", + "starting": ":hourglass_flowing_sand: starting", + "available": "[grey]:heavy_check_mark:[/grey] available", + "error": "[red]:heavy_multiplication_x:[/red] error", + } + + table = Table() + table.add_column("Service") + table.add_column("Status") + + services = list(services.items()) + services.sort(key=lambda item: item[0]) + + for service, status in services: + if status in status_display: + status = status_display[status] + + table.add_row(service, status) + + console.print(table) + + +@localstack.command(name="start", short_help="Start LocalStack") +@click.option("--docker", is_flag=True, help="Start LocalStack in a docker container [default]") +@click.option("--host", is_flag=True, help="Start LocalStack directly on the host") +@click.option("--no-banner", is_flag=True, help="Disable LocalStack banner", default=False) +@click.option( + "-d", "--detached", is_flag=True, help="Start LocalStack in the background", default=False +) +@click.option( + "--network", + type=str, + help="The container network the LocalStack container should be started in. By default, the default docker bridge network is used.", + required=False, +) +@click.option( + "--env", + "-e", + help="Additional environment variables that are passed to the LocalStack container", + multiple=True, + required=False, +) +@click.option( + "--publish", + "-p", + help="Additional port mappings that are passed to the LocalStack container", + multiple=True, + required=False, +) +@click.option( + "--volume", + "-v", + help="Additional volume mounts that are passed to the LocalStack container", + multiple=True, + required=False, +) +@click.option( + "--host-dns", + help="Expose the LocalStack DNS server to the host using port bindings.", + required=False, + is_flag=True, + default=False, +) +@click.option( + "--stack", + "-s", + type=str, + help="Use a specific stack with optional version. Examples: [localstack:4.5, snowflake]", + required=False, +) +@publish_invocation +def cmd_start( + docker: bool, + host: bool, + no_banner: bool, + detached: bool, + network: str = None, + env: Tuple = (), + publish: Tuple = (), + volume: Tuple = (), + host_dns: bool = False, + stack: str = None, +) -> None: + """ + Start the LocalStack runtime. + + This command starts the LocalStack runtime with your current configuration. + By default, it will start a new Docker container from the latest LocalStack(-Pro) Docker image + with best-practice volume mounts and port mappings. + """ + if docker and host: + raise CLIError("Please specify either --docker or --host") + if host and detached: + raise CLIError("Cannot start detached in host mode") + + if stack: + # Validate allowed stacks + stack_name = stack.split(":")[0] + allowed_stacks = ("localstack", "localstack-pro", "snowflake") + if stack_name.lower() not in allowed_stacks: + raise CLIError(f"Invalid stack '{stack_name}'. Allowed stacks: {allowed_stacks}.") + + # Set IMAGE_NAME, defaulting to :latest if no version specified + if ":" not in stack: + stack = f"{stack}:latest" + os.environ["IMAGE_NAME"] = f"localstack/{stack}" + + if not no_banner: + print_banner() + print_version() + print_profile() + print_app() + console.line() + + from localstack.utils import bootstrap + + if not no_banner: + if host: + console.log("starting LocalStack in host mode :laptop_computer:") + else: + console.log("starting LocalStack in Docker mode :whale:") + + if host: + # call hooks to prepare host + bootstrap.prepare_host(console) + + # from here we abandon the regular CLI control path and start treating the process like a localstack + # runtime process + os.environ["LOCALSTACK_CLI"] = "0" + config.dirs = config.init_directories() + + try: + bootstrap.start_infra_locally() + except ImportError: + if config.DEBUG: + console.print_exception() + raise CLIError( + "It appears you have a light install of localstack which only supports running in docker.\n" + "If you would like to use --host, please install localstack with Python using " + "`pip install localstack[runtime]` instead." + ) + else: + # make sure to initialize the bootstrap environment and directories for the host (even if we're executing + # in Docker), to allow starting the container from within other containers (e.g., Github Codespaces). + config.OVERRIDE_IN_DOCKER = False + config.is_in_docker = False + config.dirs = config.init_directories() + + # call hooks to prepare host (note that this call should stay below the config overrides above) + bootstrap.prepare_host(console) + + # pass the parsed cli params to the start infra command + params = click.get_current_context().params + + if network: + # reconciles the network config and makes sure that MAIN_DOCKER_NETWORK is set automatically if + # `--network` is set. + if config.MAIN_DOCKER_NETWORK: + if config.MAIN_DOCKER_NETWORK != network: + raise CLIError( + f"Values of MAIN_DOCKER_NETWORK={config.MAIN_DOCKER_NETWORK} and --network={network} " + f"do not match" + ) + else: + config.MAIN_DOCKER_NETWORK = network + os.environ["MAIN_DOCKER_NETWORK"] = network + + if detached: + bootstrap.start_infra_in_docker_detached(console, params) + else: + bootstrap.start_infra_in_docker(console, params) + + +@localstack.command(name="stop", short_help="Stop LocalStack") +@publish_invocation +def cmd_stop() -> None: + """ + Stops the current LocalStack runtime. + + This command stops the currently running LocalStack docker container. + By default, this command looks for a container named `localstack-main` (which is the default + container name used by the `localstack start` command). + If your LocalStack container has a different name, set the config variable + `MAIN_CONTAINER_NAME`. + """ + from localstack.utils.docker_utils import DOCKER_CLIENT + + from ..utils.container_utils.container_client import NoSuchContainer + + container_name = config.MAIN_CONTAINER_NAME + + try: + DOCKER_CLIENT.stop_container(container_name) + console.print("container stopped: %s" % container_name) + except NoSuchContainer: + raise CLIError( + f'Expected a running LocalStack container named "{container_name}", but found none' + ) + + +@localstack.command(name="restart", short_help="Restart LocalStack") +@publish_invocation +def cmd_restart() -> None: + """ + Restarts the current LocalStack runtime. + """ + url = config.external_service_url() + + try: + response = requests.post( + f"{url}/_localstack/health", + json={"action": "restart"}, + ) + response.raise_for_status() + console.print("LocalStack restarted within the container.") + except requests.ConnectionError: + if config.DEBUG: + console.print_exception() + raise CLIError("could not restart the LocalStack container") + + +@localstack.command( + name="logs", + short_help="Show LocalStack logs", +) +@click.option( + "-f", + "--follow", + is_flag=True, + help="Block the terminal and follow the log output", + default=False, +) +@click.option( + "-n", + "--tail", + type=int, + help="Print only the last <N> lines of the log output", + default=None, + metavar="N", +) +@publish_invocation +def cmd_logs(follow: bool, tail: int) -> None: + """ + Show the logs of the current LocalStack runtime. + + This command shows the logs of the currently running LocalStack docker container. + By default, this command looks for a container named `localstack-main` (which is the default + container name used by the `localstack start` command). + If your LocalStack container has a different name, set the config variable + `MAIN_CONTAINER_NAME`. + """ + from localstack.utils.docker_utils import DOCKER_CLIENT + + container_name = config.MAIN_CONTAINER_NAME + logfile = get_container_default_logfile_location(container_name) + + if not DOCKER_CLIENT.is_container_running(container_name): + console.print("localstack container not running") + if os.path.exists(logfile): + console.print("printing logs from previous run") + with open(logfile) as fd: + for line in fd: + click.echo(line, nl=False) + sys.exit(1) + + if follow: + num_lines = 0 + for line in DOCKER_CLIENT.stream_container_logs(container_name): + print(line.decode("utf-8").rstrip("\r\n")) + num_lines += 1 + if tail is not None and num_lines >= tail: + break + + else: + logs = DOCKER_CLIENT.get_container_logs(container_name) + if tail is not None: + logs = "\n".join(logs.split("\n")[-tail:]) + print(logs) + + +@localstack.command(name="wait", short_help="Wait for LocalStack") +@click.option( + "-t", + "--timeout", + type=float, + help="Only wait for <N> seconds before raising a timeout error", + default=None, + metavar="N", +) +@publish_invocation +def cmd_wait(timeout: Optional[float] = None) -> None: + """ + Wait for the LocalStack runtime to be up and running. + + This commands waits for a started LocalStack runtime to be up and running, ready to serve + requests. + By default, this command looks for a container named `localstack-main` (which is the default + container name used by the `localstack start` command). + If your LocalStack container has a different name, set the config variable + `MAIN_CONTAINER_NAME`. + """ + from localstack.utils.bootstrap import wait_container_is_ready + + if not wait_container_is_ready(timeout=timeout): + raise CLIError("timeout") + + +@localstack.command(name="ssh", short_help="Obtain a shell in LocalStack") +@publish_invocation +def cmd_ssh() -> None: + """ + Obtain a shell in the current LocalStack runtime. + + This command starts a new interactive shell in the currently running LocalStack container. + By default, this command looks for a container named `localstack-main` (which is the default + container name used by the `localstack start` command). + If your LocalStack container has a different name, set the config variable + `MAIN_CONTAINER_NAME`. + """ + from localstack.utils.docker_utils import DOCKER_CLIENT + + if not DOCKER_CLIENT.is_container_running(config.MAIN_CONTAINER_NAME): + raise CLIError( + f'Expected a running LocalStack container named "{config.MAIN_CONTAINER_NAME}", but found none' + ) + os.execlp("docker", "docker", "exec", "-it", config.MAIN_CONTAINER_NAME, "bash") + + +@localstack.group(name="update", short_help="Update LocalStack") +def localstack_update() -> None: + """ + Update different LocalStack components. + """ + pass + + +@localstack_update.command(name="all", short_help="Update all LocalStack components") +@click.pass_context +@publish_invocation +def cmd_update_all(ctx: click.Context) -> None: + """ + Update all LocalStack components. + + This is the same as executing `localstack update localstack-cli` and + `localstack update docker-images`. + Updating the LocalStack CLI is currently only supported if the CLI + is installed and run via Python / PIP. If you used a different installation method, + please follow the instructions on https://docs.localstack.cloud/. + """ + ctx.invoke(localstack_update.get_command(ctx, "localstack-cli")) + ctx.invoke(localstack_update.get_command(ctx, "docker-images")) + + +@localstack_update.command(name="localstack-cli", short_help="Update LocalStack CLI") +@publish_invocation +def cmd_update_localstack_cli() -> None: + """ + Update the LocalStack CLI. + + This command updates the LocalStack CLI. This is currently only supported if the CLI + is installed and run via Python / PIP. If you used a different installation method, + please follow the instructions on https://docs.localstack.cloud/. + """ + if is_frozen_bundle(): + # "update" can only be performed if running from source / in a non-frozen interpreter + raise CLIError( + "The LocalStack CLI can only update itself if installed via PIP. " + "Please follow the instructions on https://docs.localstack.cloud/ to update your CLI." + ) + + import subprocess + from subprocess import CalledProcessError + + console.rule("Updating LocalStack CLI") + with console.status("Updating LocalStack CLI..."): + try: + subprocess.check_output( + [sys.executable, "-m", "pip", "install", "--upgrade", "localstack"] + ) + console.print(":heavy_check_mark: LocalStack CLI updated") + except CalledProcessError: + console.print(":heavy_multiplication_x: LocalStack CLI update failed", style="bold red") + + +@localstack_update.command( + name="docker-images", short_help="Update docker images LocalStack depends on" +) +@publish_invocation +def cmd_update_docker_images() -> None: + """ + Update all Docker images LocalStack depends on. + + This command updates all Docker LocalStack docker images, as well as other Docker images + LocalStack depends on (and which have been used before / are present on the machine). + """ + from localstack.utils.docker_utils import DOCKER_CLIENT + + console.rule("Updating docker images") + + all_images = DOCKER_CLIENT.get_docker_image_names(strip_latest=False) + image_prefixes = [ + "localstack/", + "public.ecr.aws/lambda", + ] + localstack_images = [ + image + for image in all_images + if any( + image.startswith(image_prefix) or image.startswith(f"docker.io/{image_prefix}") + for image_prefix in image_prefixes + ) + and not image.endswith(":<none>") # ignore dangling images + ] + update_images(localstack_images) + + +def update_images(image_list: List[str]) -> None: + from rich.markup import escape + from rich.progress import MofNCompleteColumn, Progress + + from localstack.utils.container_utils.container_client import ContainerException + from localstack.utils.docker_utils import DOCKER_CLIENT + + updated_count = 0 + failed_count = 0 + progress = Progress( + *Progress.get_default_columns(), MofNCompleteColumn(), transient=True, console=console + ) + with progress: + for image in progress.track(image_list, description="Processing image..."): + try: + updated = False + hash_before_pull = DOCKER_CLIENT.inspect_image(image_name=image, pull=False)["Id"] + DOCKER_CLIENT.pull_image(image) + if ( + hash_before_pull + != DOCKER_CLIENT.inspect_image(image_name=image, pull=False)["Id"] + ): + updated = True + updated_count += 1 + console.print( + f":heavy_check_mark: Image {escape(image)} {'updated' if updated else 'up-to-date'}.", + style="bold" if updated else None, + highlight=False, + ) + except ContainerException as e: + console.print( + f":heavy_multiplication_x: Image {escape(image)} pull failed: {e.message}", + style="bold red", + highlight=False, + ) + failed_count += 1 + console.rule() + console.print( + f"Images updated: {updated_count}, Images failed: {failed_count}, total images processed: {len(image_list)}." + ) + + +@localstack.command(name="completion", short_help="CLI shell completion") +@click.pass_context +@click.argument( + "shell", required=True, type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False) +) +@publish_invocation +def localstack_completion(ctx: click.Context, shell: str) -> None: + """ + Print shell completion code for the specified shell (bash, zsh, or fish). + The shell code must be evaluated to enable the interactive shell completion of LocalStack CLI commands. + This is usually done by sourcing it from the .bash_profile. + + \b + Examples: + # Bash + ## Bash completion on Linux depends on the 'bash-completion' package. + ## Write the LocalStack CLI completion code for bash to a file and source it from .bash_profile + localstack completion bash > ~/.localstack/completion.bash.inc + printf " + # LocalStack CLI bash completion + source '$HOME/.localstack/completion.bash.inc' + " >> $HOME/.bash_profile + source $HOME/.bash_profile + \b + # zsh + ## Set the LocalStack completion code for zsh to autoload on startup: + localstack completion zsh > "${fpath[1]}/_localstack" + \b + # fish + ## Set the LocalStack completion code for fish to autoload on startup: + localstack completion fish > ~/.config/fish/completions/localstack.fish + """ + + # lookup the completion, raise an error if the given completion is not found + import click.shell_completion + + comp_cls = click.shell_completion.get_completion_class(shell) + if comp_cls is None: + raise CLIError("Completion for given shell could not be found.") + + # Click's program name is the base path of sys.argv[0] + path = sys.argv[0] + prog_name = os.path.basename(path) + + # create the completion variable according to the docs + # https://click.palletsprojects.com/en/8.1.x/shell-completion/#enabling-completion + complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() + + # instantiate the completion class and print the completion source + comp = comp_cls(ctx.command, {}, prog_name, complete_var) + click.echo(comp.source()) + + +def print_version() -> None: + console.print(f"- [bold]LocalStack CLI:[/bold] [blue]{VERSION}[/blue]") + + +def print_profile() -> None: + if config.LOADED_PROFILES: + console.print(f"- [bold]Profile:[/bold] [blue]{', '.join(config.LOADED_PROFILES)}[/blue]") + + +def print_app() -> None: + console.print("- [bold]App:[/bold] https://app.localstack.cloud") + + +def print_banner() -> None: + print(BANNER) + + +def is_frozen_bundle() -> bool: + """ + :return: true if we are currently running in a frozen bundle / a pyinstaller binary. + """ + # check if we are in a PyInstaller binary + # https://pyinstaller.org/en/stable/runtime-information.html + return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS") diff --git a/localstack-core/localstack/cli/lpm.py b/localstack-core/localstack/cli/lpm.py new file mode 100644 index 0000000000000..ad4a6f5489d5c --- /dev/null +++ b/localstack-core/localstack/cli/lpm.py @@ -0,0 +1,139 @@ +import itertools +import logging +from multiprocessing.pool import ThreadPool +from typing import List, Optional + +import click +from rich.console import Console + +from localstack import config +from localstack.cli.exceptions import CLIError +from localstack.packages import InstallTarget, Package +from localstack.packages.api import NoSuchPackageException, PackagesPluginManager +from localstack.utils.bootstrap import setup_logging + +LOG = logging.getLogger(__name__) + +console = Console() + + +@click.group() +def cli(): + """ + The LocalStack Package Manager (lpm) CLI is a set of commands to install third-party packages used by localstack + service providers. + + Here are some handy commands: + + List all packages + + python -m localstack.cli.lpm list + + Install DynamoDB Local: + + python -m localstack.cli.install dynamodb-local + + Install all community packages, four in parallel: + + python -m localstack.cli.lpm list | grep "/community" | cut -d'/' -f1 | xargs python -m localstack.cli.lpm install --parallel 4 + """ + setup_logging() + + +def _do_install_package(package: Package, version: str = None, target: InstallTarget = None): + console.print(f"installing... [bold]{package}[/bold]") + try: + package.install(version=version, target=target) + console.print(f"[green]installed[/green] [bold]{package}[/bold]") + except Exception as e: + console.print(f"[red]error[/red] installing {package}: {e}") + raise e + + +@cli.command() +@click.argument("package", nargs=-1, required=True) +@click.option( + "--parallel", + type=int, + default=1, + required=False, + help="how many installers to run in parallel processes", +) +@click.option( + "--version", + type=str, + default=None, + required=False, + help="version to install of a package", +) +@click.option( + "--target", + type=click.Choice([target.name.lower() for target in InstallTarget]), + default=None, + required=False, + help="target of the installation", +) +def install( + package: List[str], + parallel: Optional[int] = 1, + version: Optional[str] = None, + target: Optional[str] = None, +): + """Install one or more packages.""" + try: + if target: + target = InstallTarget[str.upper(target)] + else: + # LPM is meant to be used at build-time, the default target is static_libs + target = InstallTarget.STATIC_LIBS + + # collect installers and install in parallel: + console.print(f"resolving packages: {package}") + package_manager = PackagesPluginManager() + package_manager.load_all() + package_instances = package_manager.get_packages(package, version) + + if parallel > 1: + console.print(f"install {parallel} packages in parallel:") + + config.dirs.mkdirs() + + with ThreadPool(processes=parallel) as pool: + pool.starmap( + _do_install_package, + zip(package_instances, itertools.repeat(version), itertools.repeat(target)), + ) + except NoSuchPackageException as e: + LOG.debug(str(e), exc_info=e) + raise CLIError(str(e)) + except Exception as e: + LOG.debug("one or more package installations failed.", exc_info=e) + raise CLIError("one or more package installations failed.") + + +@cli.command(name="list") +@click.option( + "-v", + "--verbose", + is_flag=True, + default=False, + required=False, + help="Verbose output (show additional info on packages)", +) +def list_packages(verbose: bool): + """List available packages of all repositories""" + package_manager = PackagesPluginManager() + package_manager.load_all() + packages = package_manager.get_all_packages() + for package_name, package_scope, package_instance in packages: + console.print(f"[green]{package_name}[/green]/{package_scope}") + if verbose: + for version in package_instance.get_versions(): + if version == package_instance.default_version: + console.print(f" - [bold]{version} (default)[/bold]", highlight=False) + else: + console.print(f" - {version}", highlight=False) + + +if __name__ == "__main__": + cli() diff --git a/localstack-core/localstack/cli/main.py b/localstack-core/localstack/cli/main.py new file mode 100644 index 0000000000000..de1f04e38cac5 --- /dev/null +++ b/localstack-core/localstack/cli/main.py @@ -0,0 +1,22 @@ +import os + + +def main(): + # indicate to the environment we are starting from the CLI + os.environ["LOCALSTACK_CLI"] = "1" + + # config profiles are the first thing that need to be loaded (especially before localstack.config!) + from .profiles import set_and_remove_profile_from_sys_argv + + # WARNING: This function modifies sys.argv to remove the profile argument. + set_and_remove_profile_from_sys_argv() + + # initialize CLI plugins + from .localstack import create_with_plugins + + cli = create_with_plugins() + cli() + + +if __name__ == "__main__": + main() diff --git a/localstack-core/localstack/cli/plugin.py b/localstack-core/localstack/cli/plugin.py new file mode 100644 index 0000000000000..f9af88474a6d5 --- /dev/null +++ b/localstack-core/localstack/cli/plugin.py @@ -0,0 +1,39 @@ +import abc +import logging +import os + +import click +from plux import Plugin, PluginManager + +LOG = logging.getLogger(__name__) + + +class LocalstackCli: + group: click.Group + + def __call__(self, *args, **kwargs): + self.group(*args, **kwargs) + + +class LocalstackCliPlugin(Plugin): + namespace = "localstack.plugins.cli" + + def load(self, cli) -> None: + self.attach(cli) + + @abc.abstractmethod + def attach(self, cli: LocalstackCli) -> None: + """ + Attach commands to the `localstack` CLI. + + :param cli: the cli object + """ + + +def load_cli_plugins(cli): + if os.environ.get("DEBUG_PLUGINS", "0").lower() in ("true", "1"): + # importing localstack.config is still quite expensive... + logging.basicConfig(level=logging.DEBUG) + + loader = PluginManager("localstack.plugins.cli", load_args=(cli,)) + loader.load_all() diff --git a/localstack-core/localstack/cli/plugins.py b/localstack-core/localstack/cli/plugins.py new file mode 100644 index 0000000000000..c63588161d304 --- /dev/null +++ b/localstack-core/localstack/cli/plugins.py @@ -0,0 +1,134 @@ +import os +import time + +import click +from plux import PluginManager +from plux.build.setuptools import find_plugins +from plux.core.entrypoint import spec_to_entry_point +from rich import print as rprint +from rich.console import Console +from rich.table import Table +from rich.tree import Tree + +from localstack.cli.exceptions import CLIError + +console = Console() + + +@click.group() +def cli(): + """ + The plugins CLI is a set of commands to help troubleshoot LocalStack's plugin mechanism. + """ + pass + + +@cli.command() +@click.option("--where", type=str, default=os.path.abspath(os.curdir)) +@click.option("--exclude", multiple=True, default=()) +@click.option("--include", multiple=True, default=("*",)) +@click.option("--output", type=str, default="tree") +def find(where, exclude, include, output): + """ + Find plugins by scanning the given path for PluginSpecs. + It starts from the current directory if --where is not specified. + This is what a setup.py method would run as a build step, i.e., discovering entry points. + """ + with console.status(f"Scanning path {where}"): + plugins = find_plugins(where, exclude, include) + + if output == "tree": + tree = Tree("Entrypoints") + for namespace, entry_points in plugins.items(): + node = tree.add(f"[bold]{namespace}") + + t = Table() + t.add_column("Name") + t.add_column("Location") + + for ep in entry_points: + key, value = ep.split("=") + t.add_row(key, value) + + node.add(t) + + rprint(tree) + elif output == "dict": + rprint(dict(plugins)) + else: + raise CLIError("unknown output format %s" % output) + + +@cli.command("list") +@click.option("--namespace", type=str, required=True) +def cmd_list(namespace): + """ + List all available plugins using a PluginManager from available endpoints. + """ + manager = PluginManager(namespace) + + t = Table() + t.add_column("Name") + t.add_column("Factory") + + for spec in manager.list_plugin_specs(): + ep = spec_to_entry_point(spec) + t.add_row(spec.name, ep.value) + + rprint(t) + + +@cli.command() +@click.option("--namespace", type=str, required=True) +@click.option("--name", type=str, required=True) +def load(namespace, name): + """ + Attempts to load a plugin using a PluginManager. + """ + manager = PluginManager(namespace) + + with console.status(f"Loading {namespace}:{name}"): + then = time.time() + plugin = manager.load(name) + took = time.time() - then + + rprint( + f":tada: successfully loaded [bold][green]{namespace}[/green][/bold]:[bold][cyan]{name}[/cyan][/bold] ({type(plugin)}" + ) + rprint(f":stopwatch: loading took {took:.4f} s") + + +@cli.command() +@click.option("--namespace", type=str) +def cache(namespace): + """ + Outputs the stevedore entrypoints cache from which plugins are loaded. + """ + from stevedore._cache import _c + + data = _c._get_data_for_path(None) + + tree = Tree("Entrypoints") + for group, entry_points in data.get("groups").items(): + if namespace and group != namespace: + continue + node = tree.add(f"[bold]{group}") + + t = Table() + t.add_column("Name") + t.add_column("Value") + + for key, value, _ in entry_points: + t.add_row(key, value) + + node.add(t) + + if namespace: + rprint(t) + return + + rprint(tree) + + +if __name__ == "__main__": + cli() diff --git a/localstack-core/localstack/cli/profiles.py b/localstack-core/localstack/cli/profiles.py new file mode 100644 index 0000000000000..5af5e089658a4 --- /dev/null +++ b/localstack-core/localstack/cli/profiles.py @@ -0,0 +1,66 @@ +import argparse +import os +import sys +from typing import Optional + +# important: this needs to be free of localstack imports + + +def set_and_remove_profile_from_sys_argv(): + """ + Performs the following steps: + + 1. Use argparse to parse the command line arguments for the --profile flag. + All occurrences are removed from the sys.argv list, and the value from + the last occurrence is used. This allows the user to specify a profile + at any point on the command line. + + 2. If a --profile flag is not found, check for the -p flag. The first + occurrence of the -p flag is used and it is not removed from sys.argv. + The reasoning for this is that at least one of the CLI subcommands has + a -p flag, and we want to keep it in sys.argv for that command to + pick up. An existing bug means that if a -p flag is used with a + subcommand, it could erroneously be used as the profile value as well. + This behaviour is undesired, but we must maintain back-compatibility of + allowing the profile to be specified using -p. + + 3. If a profile is found, the 'CONFIG_PROFILE' os variable is set + accordingly. This is later picked up by ``localstack.config``. + + WARNING: Any --profile options are REMOVED from sys.argv, so that they are + not passed to the localstack CLI. This allows the profile option + to be set at any point on the command line. + """ + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--profile") + namespace, sys.argv = parser.parse_known_args(sys.argv) + profile = namespace.profile + + if not profile: + # if no profile is given, check for the -p argument + profile = parse_p_argument(sys.argv) + + if profile: + os.environ["CONFIG_PROFILE"] = profile.strip() + + +def parse_p_argument(args) -> Optional[str]: + """ + Lightweight arg parsing to find the first occurrence of ``-p <config>``, or ``-p=<config>`` and return the value of + ``<config>`` from the given arguments. + + :param args: list of CLI arguments + :returns: the value of ``-p``. + """ + for i, current_arg in enumerate(args): + if current_arg.startswith("-p="): + # if using the "<arg>=<value>" notation, we remove the "-p=" prefix to get the value + return current_arg[3:] + if current_arg == "-p": + # otherwise use the next arg in the args list as value + try: + return args[i + 1] + except IndexError: + return None + + return None diff --git a/localstack-core/localstack/config.py b/localstack-core/localstack/config.py new file mode 100644 index 0000000000000..efbfbf83e6fd3 --- /dev/null +++ b/localstack-core/localstack/config.py @@ -0,0 +1,1655 @@ +import ipaddress +import logging +import os +import platform +import re +import socket +import subprocess +import tempfile +import time +import warnings +from collections import defaultdict +from typing import Any, Dict, List, Mapping, Optional, Tuple, TypeVar, Union + +from localstack import constants +from localstack.constants import ( + DEFAULT_BUCKET_MARKER_LOCAL, + DEFAULT_DEVELOP_PORT, + DEFAULT_VOLUME_DIR, + ENV_INTERNAL_TEST_COLLECT_METRIC, + ENV_INTERNAL_TEST_RUN, + FALSE_STRINGS, + LOCALHOST, + LOCALHOST_IP, + LOCALSTACK_ROOT_FOLDER, + LOG_LEVELS, + TRACE_LOG_LEVELS, + TRUE_STRINGS, +) + +T = TypeVar("T", str, int) + +# keep track of start time, for performance debugging +load_start_time = time.time() + + +class Directories: + """ + Holds different directories available to localstack. Some directories are shared between the host and the + localstack container, some live only on the host and others in the container. + + Attributes: + static_libs: container only; binaries and libraries statically packaged with the image + var_libs: shared; binaries and libraries+data computed at runtime: lazy-loaded binaries, ssl cert, ... + cache: shared; ephemeral data that has to persist across localstack runs and reboots + tmp: container only; ephemeral data that has to persist across localstack runs but not reboots + mounted_tmp: shared; same as above, but shared for persistence across different containers, tests, ... + functions: shared; volume to communicate between host<->lambda containers + data: shared; holds localstack state, pods, ... + config: host only; pre-defined configuration values, cached credentials, machine id, ... + init: shared; user-defined provisioning scripts executed in the container when it starts + logs: shared; log files produced by localstack + """ + + static_libs: str + var_libs: str + cache: str + tmp: str + mounted_tmp: str + functions: str + data: str + config: str + init: str + logs: str + + def __init__( + self, + static_libs: str, + var_libs: str, + cache: str, + tmp: str, + mounted_tmp: str, + functions: str, + data: str, + config: str, + init: str, + logs: str, + ) -> None: + super().__init__() + self.static_libs = static_libs + self.var_libs = var_libs + self.cache = cache + self.tmp = tmp + self.mounted_tmp = mounted_tmp + self.functions = functions + self.data = data + self.config = config + self.init = init + self.logs = logs + + @staticmethod + def defaults() -> "Directories": + """Returns Localstack directory paths based on the localstack filesystem hierarchy.""" + return Directories( + static_libs="/usr/lib/localstack", + var_libs=f"{DEFAULT_VOLUME_DIR}/lib", + cache=f"{DEFAULT_VOLUME_DIR}/cache", + tmp=os.path.join(tempfile.gettempdir(), "localstack"), + mounted_tmp=f"{DEFAULT_VOLUME_DIR}/tmp", + functions=f"{DEFAULT_VOLUME_DIR}/tmp", # FIXME: remove - this was misconceived + data=f"{DEFAULT_VOLUME_DIR}/state", + logs=f"{DEFAULT_VOLUME_DIR}/logs", + config="/etc/localstack/conf.d", # for future use + init="/etc/localstack/init", + ) + + @staticmethod + def for_container() -> "Directories": + """ + Returns Localstack directory paths as they are defined within the container. Everything shared and writable + lives in /var/lib/localstack or {tempfile.gettempdir()}/localstack. + + :returns: Directories object + """ + defaults = Directories.defaults() + + return Directories( + static_libs=defaults.static_libs, + var_libs=defaults.var_libs, + cache=defaults.cache, + tmp=defaults.tmp, + mounted_tmp=defaults.mounted_tmp, + functions=defaults.functions, + data=defaults.data if PERSISTENCE else os.path.join(defaults.tmp, "state"), + config=defaults.config, + logs=defaults.logs, + init=defaults.init, + ) + + @staticmethod + def for_host() -> "Directories": + """Return directories used for running localstack in host mode. Note that these are *not* the directories + that are mounted into the container when the user starts localstack.""" + root = os.environ.get("FILESYSTEM_ROOT") or os.path.join( + LOCALSTACK_ROOT_FOLDER, ".filesystem" + ) + root = os.path.abspath(root) + + defaults = Directories.for_container() + + tmp = os.path.join(root, defaults.tmp.lstrip("/")) + data = os.path.join(root, defaults.data.lstrip("/")) + + return Directories( + static_libs=os.path.join(root, defaults.static_libs.lstrip("/")), + var_libs=os.path.join(root, defaults.var_libs.lstrip("/")), + cache=os.path.join(root, defaults.cache.lstrip("/")), + tmp=tmp, + mounted_tmp=os.path.join(root, defaults.mounted_tmp.lstrip("/")), + functions=os.path.join(root, defaults.functions.lstrip("/")), + data=data if PERSISTENCE else os.path.join(tmp, "state"), + config=os.path.join(root, defaults.config.lstrip("/")), + init=os.path.join(root, defaults.init.lstrip("/")), + logs=os.path.join(root, defaults.logs.lstrip("/")), + ) + + @staticmethod + def for_cli() -> "Directories": + """Returns directories used for when running localstack CLI commands from the host system. Unlike + ``for_container``, these needs to be cross-platform. Ideally, this should not be needed at all, + because the localstack runtime and CLI do not share any control paths. There are a handful of + situations where directories or files may be created lazily for CLI commands. Some paths are + intentionally set to None to provoke errors if these paths are used from the CLI - which they + shouldn't. This is a symptom of not having a clear separation between CLI/runtime code, which will + be a future project.""" + import tempfile + + from localstack.utils import files + + tmp_dir = os.path.join(tempfile.gettempdir(), "localstack-cli") + cache_dir = (files.get_user_cache_dir()).absolute() / "localstack-cli" + + return Directories( + static_libs=None, + var_libs=None, + cache=str(cache_dir), # used by analytics metadata + tmp=tmp_dir, + mounted_tmp=tmp_dir, + functions=None, + data=os.path.join(tmp_dir, "state"), # used by localstack-pro config TODO: remove + logs=os.path.join(tmp_dir, "logs"), # used for container logs + config=None, # in the context of the CLI, config.CONFIG_DIR should be used + init=None, + ) + + def mkdirs(self): + for folder in [ + self.static_libs, + self.var_libs, + self.cache, + self.tmp, + self.mounted_tmp, + self.functions, + self.data, + self.config, + self.init, + self.logs, + ]: + if folder and not os.path.exists(folder): + try: + os.makedirs(folder) + except Exception: + # this can happen due to a race condition when starting + # multiple processes in parallel. Should be safe to ignore + pass + + def __str__(self): + return str(self.__dict__) + + +def eval_log_type(env_var_name: str) -> Union[str, bool]: + """Get the log type from environment variable""" + ls_log = os.environ.get(env_var_name, "").lower().strip() + return ls_log if ls_log in LOG_LEVELS else False + + +def parse_boolean_env(env_var_name: str) -> Optional[bool]: + """Parse the value of the given env variable and return True/False, or None if it is not a boolean value.""" + value = os.environ.get(env_var_name, "").lower().strip() + if value in TRUE_STRINGS: + return True + if value in FALSE_STRINGS: + return False + return None + + +def is_env_true(env_var_name: str) -> bool: + """Whether the given environment variable has a truthy value.""" + return os.environ.get(env_var_name, "").lower().strip() in TRUE_STRINGS + + +def is_env_not_false(env_var_name: str) -> bool: + """Whether the given environment variable is empty or has a truthy value.""" + return os.environ.get(env_var_name, "").lower().strip() not in FALSE_STRINGS + + +def load_environment(profiles: str = None, env=os.environ) -> List[str]: + """Loads the environment variables from ~/.localstack/{profile}.env, for each profile listed in the profiles. + :param env: environment to load profile to. Defaults to `os.environ` + :param profiles: a comma separated list of profiles to load (defaults to "default") + :returns str: the list of the actually loaded profiles (might be the fallback) + """ + if not profiles: + profiles = "default" + + profiles = profiles.split(",") + environment = {} + import dotenv + + for profile in profiles: + profile = profile.strip() + path = os.path.join(CONFIG_DIR, f"{profile}.env") + if not os.path.exists(path): + continue + environment.update(dotenv.dotenv_values(path)) + + for k, v in environment.items(): + # we do not want to override the environment + if k not in env and v is not None: + env[k] = v + + return profiles + + +def is_persistence_enabled() -> bool: + return PERSISTENCE and dirs.data + + +def is_linux() -> bool: + return platform.system() == "Linux" + + +def is_macos() -> bool: + return platform.system() == "Darwin" + + +def is_windows() -> bool: + return platform.system().lower() == "windows" + + +def is_wsl() -> bool: + return platform.system().lower() == "linux" and os.environ.get("WSL_DISTRO_NAME") is not None + + +def ping(host): + """Returns True if the host responds to a ping request""" + is_in_windows = is_windows() + ping_opts = "-n 1 -w 2000" if is_in_windows else "-c 1 -W 2" + args = "ping %s %s" % (ping_opts, host) + return ( + subprocess.call( + args, shell=not is_in_windows, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + == 0 + ) + + +def in_docker(): + """ + Returns True if running in a docker container, else False + Ref. https://docs.docker.com/config/containers/runmetrics/#control-groups + """ + if OVERRIDE_IN_DOCKER is not None: + return OVERRIDE_IN_DOCKER + + # check some marker files that we create in our Dockerfiles + for path in [ + "/usr/lib/localstack/.community-version", + "/usr/lib/localstack/.pro-version", + "/tmp/localstack/.marker", + ]: + if os.path.isfile(path): + return True + + # details: https://github.com/localstack/localstack/pull/4352 + if os.path.exists("/.dockerenv"): + return True + if os.path.exists("/run/.containerenv"): + return True + + if not os.path.exists("/proc/1/cgroup"): + return False + try: + if any( + [ + os.path.exists("/sys/fs/cgroup/memory/docker/"), + any( + "docker-" in file_names + for file_names in os.listdir("/sys/fs/cgroup/memory/system.slice") + ), + os.path.exists("/sys/fs/cgroup/docker/"), + any( + "docker-" in file_names + for file_names in os.listdir("/sys/fs/cgroup/system.slice/") + ), + ] + ): + return False + except Exception: + pass + with open("/proc/1/cgroup", "rt") as ifh: + content = ifh.read() + if "docker" in content or "buildkit" in content: + return True + os_hostname = socket.gethostname() + if os_hostname and os_hostname in content: + return True + + # containerd does not set any specific file or config, but it does use + # io.containerd.snapshotter.v1.overlayfs as the overlay filesystem for `/`. + try: + with open("/proc/mounts", "rt") as infile: + for line in infile: + line = line.strip() + + if not line: + continue + + # skip comments + if line[0] == "#": + continue + + # format (man 5 fstab) + # <spec> <mount point> <type> <options> <rest>... + parts = line.split() + if len(parts) < 4: + # badly formatted line + continue + + mount_point = parts[1] + options = parts[3] + + # only consider the root filesystem + if mount_point != "/": + continue + + if "io.containerd" in options: + return True + + except FileNotFoundError: + pass + + return False + + +# whether the `in_docker` check should always return True or False +OVERRIDE_IN_DOCKER = parse_boolean_env("OVERRIDE_IN_DOCKER") + +is_in_docker = in_docker() +is_in_linux = is_linux() +is_in_macos = is_macos() +is_in_windows = is_windows() +is_in_wsl = is_wsl() +default_ip = "0.0.0.0" if is_in_docker else "127.0.0.1" + +# CLI specific: the configuration profile to load +CONFIG_PROFILE = os.environ.get("CONFIG_PROFILE", "").strip() + +# CLI specific: host configuration directory +CONFIG_DIR = os.environ.get("CONFIG_DIR", os.path.expanduser("~/.localstack")) + +# keep this on top to populate the environment +try: + # CLI specific: the actually loaded configuration profile + LOADED_PROFILES = load_environment(CONFIG_PROFILE) +except ImportError: + # dotenv may not be available in lambdas or other environments where config is loaded + LOADED_PROFILES = None + +# loaded components name - default: all components are loaded and the first one is chosen +RUNTIME_COMPONENTS = os.environ.get("RUNTIME_COMPONENTS", "").strip() + +# directory for persisting data (TODO: deprecated, simply use PERSISTENCE=1) +DATA_DIR = os.environ.get("DATA_DIR", "").strip() + +# whether localstack should persist service state across localstack runs +PERSISTENCE = is_env_true("PERSISTENCE") + +# the strategy for loading snapshots from disk when `PERSISTENCE=1` is used (on_startup, on_request, manual) +SNAPSHOT_LOAD_STRATEGY = os.environ.get("SNAPSHOT_LOAD_STRATEGY", "").upper() + +# the strategy saving snapshots to disk when `PERSISTENCE=1` is used (on_shutdown, on_request, scheduled, manual) +SNAPSHOT_SAVE_STRATEGY = os.environ.get("SNAPSHOT_SAVE_STRATEGY", "").upper() + +# the flush interval (in seconds) for persistence when the snapshot save strategy is set to "scheduled" +SNAPSHOT_FLUSH_INTERVAL = int(os.environ.get("SNAPSHOT_FLUSH_INTERVAL") or 15) + +# whether to clear config.dirs.tmp on startup and shutdown +CLEAR_TMP_FOLDER = is_env_not_false("CLEAR_TMP_FOLDER") + +# folder for temporary files and data +TMP_FOLDER = os.path.join(tempfile.gettempdir(), "localstack") + +# this is exclusively for the CLI to configure the container mount into /var/lib/localstack +VOLUME_DIR = os.environ.get("LOCALSTACK_VOLUME_DIR", "").strip() or TMP_FOLDER + +# fix for Mac OS, to be able to mount /var/folders in Docker +if TMP_FOLDER.startswith("/var/folders/") and os.path.exists("/private%s" % TMP_FOLDER): + TMP_FOLDER = "/private%s" % TMP_FOLDER + +# whether to enable verbose debug logging ("LOG" is used when using the CLI with LOCALSTACK_LOG instead of LS_LOG) +LS_LOG = eval_log_type("LS_LOG") or eval_log_type("LOG") +DEBUG = is_env_true("DEBUG") or LS_LOG in TRACE_LOG_LEVELS + +# PUBLIC PREVIEW: 0 (default), 1 (preview) +# When enabled it triggers specialised workflows for the debugging. +LAMBDA_DEBUG_MODE = is_env_true("LAMBDA_DEBUG_MODE") + +# path to the lambda debug mode configuration file. +LAMBDA_DEBUG_MODE_CONFIG_PATH = os.environ.get("LAMBDA_DEBUG_MODE_CONFIG_PATH") + +# EXPERIMENTAL: allow setting custom log levels for individual loggers +LOG_LEVEL_OVERRIDES = os.environ.get("LOG_LEVEL_OVERRIDES", "") + +# whether to enable debugpy +DEVELOP = is_env_true("DEVELOP") + +# PORT FOR DEBUGGER +DEVELOP_PORT = int(os.environ.get("DEVELOP_PORT", "").strip() or DEFAULT_DEVELOP_PORT) + +# whether to make debugpy wait for a debbuger client +WAIT_FOR_DEBUGGER = is_env_true("WAIT_FOR_DEBUGGER") + +# whether to assume http or https for `get_protocol` +USE_SSL = is_env_true("USE_SSL") + +# Whether to report internal failures as 500 or 501 errors. +FAIL_FAST = is_env_true("FAIL_FAST") + +# whether to run in TF compatibility mode for TF integration tests +# (e.g., returning verbatim ports for ELB resources, rather than edge port 4566, etc.) +TF_COMPAT_MODE = is_env_true("TF_COMPAT_MODE") + +# default encoding used to convert strings to byte arrays (mainly for Python 3 compatibility) +DEFAULT_ENCODING = "utf-8" + +# path to local Docker UNIX domain socket +DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "").strip() or "/var/run/docker.sock" + +# additional flags to pass to "docker run" when starting the stack in Docker +DOCKER_FLAGS = os.environ.get("DOCKER_FLAGS", "").strip() + +# command used to run Docker containers (e.g., set to "sudo docker" to run as sudo) +DOCKER_CMD = os.environ.get("DOCKER_CMD", "").strip() or "docker" + +# use the command line docker client instead of the new sdk version, might get removed in the future +LEGACY_DOCKER_CLIENT = is_env_true("LEGACY_DOCKER_CLIENT") + +# Docker image to use when starting up containers for port checks +PORTS_CHECK_DOCKER_IMAGE = os.environ.get("PORTS_CHECK_DOCKER_IMAGE", "").strip() + + +def is_trace_logging_enabled(): + if LS_LOG: + log_level = str(LS_LOG).upper() + return log_level.lower() in TRACE_LOG_LEVELS + return False + + +# set log levels immediately, but will be overwritten later by setup_logging +if DEBUG: + logging.getLogger("").setLevel(logging.DEBUG) + logging.getLogger("localstack").setLevel(logging.DEBUG) + +LOG = logging.getLogger(__name__) +if is_trace_logging_enabled(): + load_end_time = time.time() + LOG.debug( + "Initializing the configuration took %s ms", int((load_end_time - load_start_time) * 1000) + ) + + +def is_ipv6_address(host: str) -> bool: + """ + Returns True if the given host is an IPv6 address. + """ + + if not host: + return False + + try: + ipaddress.IPv6Address(host) + return True + except ipaddress.AddressValueError: + return False + + +class HostAndPort: + """ + Definition of an address for a server to listen to. + + Includes a `parse` method to convert from `str`, allowing for default fallbacks, as well as + some helper methods to help tests - particularly testing for equality and a hash function + so that `HostAndPort` instances can be used as keys to dictionaries. + """ + + host: str + port: int + + def __init__(self, host: str, port: int): + self.host = host + self.port = port + + @classmethod + def parse( + cls, + input: str, + default_host: str, + default_port: int, + ) -> "HostAndPort": + """ + Parse a `HostAndPort` from strings like: + - 0.0.0.0:4566 -> host=0.0.0.0, port=4566 + - 0.0.0.0 -> host=0.0.0.0, port=`default_port` + - :4566 -> host=`default_host`, port=4566 + - [::]:4566 -> host=[::], port=4566 + - [::1] -> host=[::1], port=`default_port` + """ + host, port = default_host, default_port + + # recognize IPv6 addresses (+ port) + if input.startswith("["): + ipv6_pattern = re.compile(r"^\[(?P<host>[^]]+)\](:(?P<port>\d+))?$") + match = ipv6_pattern.match(input) + + if match: + host = match.group("host") + if not is_ipv6_address(host): + raise ValueError( + f"input looks like an IPv6 address (is enclosed in square brackets), but is not valid: {host}" + ) + port_s = match.group("port") + if port_s: + port = cls._validate_port(port_s) + else: + raise ValueError( + f'input looks like an IPv6 address, but is invalid. Should be formatted "[ip]:port": {input}' + ) + + # recognize IPv4 address + port + elif ":" in input: + hostname, port_s = input.split(":", 1) + if hostname.strip(): + host = hostname.strip() + port = cls._validate_port(port_s) + else: + if input.strip(): + host = input.strip() + + # validation + if port < 0 or port >= 2**16: + raise ValueError("port out of range") + + return cls(host=host, port=port) + + @classmethod + def _validate_port(cls, port_s: str) -> int: + try: + port = int(port_s) + except ValueError as e: + raise ValueError(f"specified port {port_s} not a number") from e + + return port + + def _get_unprivileged_port_range_start(self) -> int: + try: + with open( + "/proc/sys/net/ipv4/ip_unprivileged_port_start", "rt" + ) as unprivileged_port_start: + port = unprivileged_port_start.read() + return int(port.strip()) + except Exception: + return 1024 + + def is_unprivileged(self) -> bool: + return self.port >= self._get_unprivileged_port_range_start() + + def host_and_port(self) -> str: + formatted_host = f"[{self.host}]" if is_ipv6_address(self.host) else self.host + return f"{formatted_host}:{self.port}" if self.port is not None else formatted_host + + def __hash__(self) -> int: + return hash((self.host, self.port)) + + # easier tests + def __eq__(self, other: "str | HostAndPort") -> bool: + if isinstance(other, self.__class__): + return self.host == other.host and self.port == other.port + elif isinstance(other, str): + return str(self) == other + else: + raise TypeError(f"cannot compare {self.__class__} to {other.__class__}") + + def __str__(self) -> str: + return self.host_and_port() + + def __repr__(self) -> str: + return f"HostAndPort(host={self.host}, port={self.port})" + + +class UniqueHostAndPortList(List[HostAndPort]): + """ + Container type that ensures that ports added to the list are unique based + on these rules: + - :: "trumps" any other binding on the same port, including both IPv6 and IPv4 + addresses. All other bindings for this port are removed, since :: already + covers all interfaces. For example, adding 127.0.0.1:4566, [::1]:4566, + and [::]:4566 would result in only [::]:4566 being preserved. + - 0.0.0.0 "trumps" any other binding on IPv4 addresses only. IPv6 addresses + are not removed. + - Identical identical hosts and ports are de-duped + """ + + def __init__(self, iterable: Union[List[HostAndPort], None] = None): + super().__init__(iterable or []) + self._ensure_unique() + + def _ensure_unique(self): + """ + Ensure that all bindings on the same port are de-duped. + """ + if len(self) <= 1: + return + + unique: List[HostAndPort] = list() + + # Build a dictionary of hosts by port + hosts_by_port: Dict[int, List[str]] = defaultdict(list) + for item in self: + hosts_by_port[item.port].append(item.host) + + # For any given port, dedupe the hosts + for port, hosts in hosts_by_port.items(): + deduped_hosts = set(hosts) + + # IPv6 all interfaces: this is the most general binding. + # Any others should be removed. + if "::" in deduped_hosts: + unique.append(HostAndPort(host="::", port=port)) + continue + # IPv4 all interfaces: this is the next most general binding. + # Any others should be removed. + if "0.0.0.0" in deduped_hosts: + unique.append(HostAndPort(host="0.0.0.0", port=port)) + continue + + # All other bindings just need to be unique + unique.extend([HostAndPort(host=host, port=port) for host in deduped_hosts]) + + self.clear() + self.extend(unique) + + def append(self, value: HostAndPort): + super().append(value) + self._ensure_unique() + + +def populate_edge_configuration( + environment: Mapping[str, str], +) -> Tuple[HostAndPort, UniqueHostAndPortList]: + """Populate the LocalStack edge configuration from environment variables.""" + localstack_host_raw = environment.get("LOCALSTACK_HOST") + gateway_listen_raw = environment.get("GATEWAY_LISTEN") + + # parse gateway listen from multiple components + if gateway_listen_raw is not None: + gateway_listen = [] + for address in gateway_listen_raw.split(","): + gateway_listen.append( + HostAndPort.parse( + address.strip(), + default_host=default_ip, + default_port=constants.DEFAULT_PORT_EDGE, + ) + ) + else: + # use default if gateway listen is not defined + gateway_listen = [HostAndPort(host=default_ip, port=constants.DEFAULT_PORT_EDGE)] + + # the actual value of the LOCALSTACK_HOST port now depends on what gateway listen actually listens to. + if localstack_host_raw is None: + localstack_host = HostAndPort( + host=constants.LOCALHOST_HOSTNAME, port=gateway_listen[0].port + ) + else: + localstack_host = HostAndPort.parse( + localstack_host_raw, + default_host=constants.LOCALHOST_HOSTNAME, + default_port=gateway_listen[0].port, + ) + + assert gateway_listen is not None + assert localstack_host is not None + + return ( + localstack_host, + UniqueHostAndPortList(gateway_listen), + ) + + +# How to access LocalStack +( + # -- Cosmetic + LOCALSTACK_HOST, + # -- Edge configuration + # Main configuration of the listen address of the hypercorn proxy. Of the form + # <ip_address>:<port>(,<ip_address>:port>)* + GATEWAY_LISTEN, +) = populate_edge_configuration(os.environ) + +GATEWAY_WORKER_COUNT = int(os.environ.get("GATEWAY_WORKER_COUNT") or 1000) + +# the gateway server that should be used (supported: hypercorn, twisted dev: werkzeug) +GATEWAY_SERVER = os.environ.get("GATEWAY_SERVER", "").strip() or "twisted" + +# IP of the docker bridge used to enable access between containers +DOCKER_BRIDGE_IP = os.environ.get("DOCKER_BRIDGE_IP", "").strip() + +# Default timeout for Docker API calls sent by the Docker SDK client, in seconds. +DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS = int(os.environ.get("DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS") or 60) + +# Default number of retries to connect to the Docker API by the Docker SDK client. +DOCKER_SDK_DEFAULT_RETRIES = int(os.environ.get("DOCKER_SDK_DEFAULT_RETRIES") or 0) + +# whether to enable API-based updates of configuration variables at runtime +ENABLE_CONFIG_UPDATES = is_env_true("ENABLE_CONFIG_UPDATES") + +# CORS settings +DISABLE_CORS_HEADERS = is_env_true("DISABLE_CORS_HEADERS") +DISABLE_CORS_CHECKS = is_env_true("DISABLE_CORS_CHECKS") +DISABLE_CUSTOM_CORS_S3 = is_env_true("DISABLE_CUSTOM_CORS_S3") +DISABLE_CUSTOM_CORS_APIGATEWAY = is_env_true("DISABLE_CUSTOM_CORS_APIGATEWAY") +EXTRA_CORS_ALLOWED_HEADERS = os.environ.get("EXTRA_CORS_ALLOWED_HEADERS", "").strip() +EXTRA_CORS_EXPOSE_HEADERS = os.environ.get("EXTRA_CORS_EXPOSE_HEADERS", "").strip() +EXTRA_CORS_ALLOWED_ORIGINS = os.environ.get("EXTRA_CORS_ALLOWED_ORIGINS", "").strip() +DISABLE_PREFLIGHT_PROCESSING = is_env_true("DISABLE_PREFLIGHT_PROCESSING") + +# whether to disable publishing events to the API +DISABLE_EVENTS = is_env_true("DISABLE_EVENTS") +DEBUG_ANALYTICS = is_env_true("DEBUG_ANALYTICS") + +# whether to log fine-grained debugging information for the handler chain +DEBUG_HANDLER_CHAIN = is_env_true("DEBUG_HANDLER_CHAIN") + +# whether to eagerly start services +EAGER_SERVICE_LOADING = is_env_true("EAGER_SERVICE_LOADING") + +# whether to selectively load services in SERVICES +STRICT_SERVICE_LOADING = is_env_not_false("STRICT_SERVICE_LOADING") + +# Whether to skip downloading additional infrastructure components (e.g., custom Elasticsearch versions) +SKIP_INFRA_DOWNLOADS = os.environ.get("SKIP_INFRA_DOWNLOADS", "").strip() + +# Whether to skip downloading our signed SSL cert. +SKIP_SSL_CERT_DOWNLOAD = is_env_true("SKIP_SSL_CERT_DOWNLOAD") + +# Absolute path to a custom certificate (pem file) +CUSTOM_SSL_CERT_PATH = os.environ.get("CUSTOM_SSL_CERT_PATH", "").strip() + +# Whether delete the cached signed SSL certificate at startup +REMOVE_SSL_CERT = is_env_true("REMOVE_SSL_CERT") + +# Allow non-standard AWS regions +ALLOW_NONSTANDARD_REGIONS = is_env_true("ALLOW_NONSTANDARD_REGIONS") +if ALLOW_NONSTANDARD_REGIONS: + os.environ["MOTO_ALLOW_NONEXISTENT_REGION"] = "true" + +# name of the main Docker container +MAIN_CONTAINER_NAME = os.environ.get("MAIN_CONTAINER_NAME", "").strip() or "localstack-main" + +# the latest commit id of the repository when the docker image was created +LOCALSTACK_BUILD_GIT_HASH = os.environ.get("LOCALSTACK_BUILD_GIT_HASH", "").strip() or None + +# the date on which the docker image was created +LOCALSTACK_BUILD_DATE = os.environ.get("LOCALSTACK_BUILD_DATE", "").strip() or None + +# Equivalent to HTTP_PROXY, but only applicable for external connections +OUTBOUND_HTTP_PROXY = os.environ.get("OUTBOUND_HTTP_PROXY", "") + +# Equivalent to HTTPS_PROXY, but only applicable for external connections +OUTBOUND_HTTPS_PROXY = os.environ.get("OUTBOUND_HTTPS_PROXY", "") + +# Feature flag to enable validation of internal endpoint responses in the handler chain. For test use only. +OPENAPI_VALIDATE_RESPONSE = is_env_true("OPENAPI_VALIDATE_RESPONSE") +# Flag to enable the validation of the requests made to the LocalStack internal endpoints. Active by default. +OPENAPI_VALIDATE_REQUEST = is_env_true("OPENAPI_VALIDATE_REQUEST") + +# whether to skip waiting for the infrastructure to shut down, or exit immediately +FORCE_SHUTDOWN = is_env_not_false("FORCE_SHUTDOWN") + +# set variables no_proxy, i.e., run internal service calls directly +no_proxy = ",".join([constants.LOCALHOST_HOSTNAME, LOCALHOST, LOCALHOST_IP, "[::1]"]) +if os.environ.get("no_proxy"): + os.environ["no_proxy"] += "," + no_proxy +elif os.environ.get("NO_PROXY"): + os.environ["NO_PROXY"] += "," + no_proxy +else: + os.environ["no_proxy"] = no_proxy + +# additional CLI commands, can be set by plugins +CLI_COMMANDS = {} + +# determine IP of Docker bridge +if not DOCKER_BRIDGE_IP: + DOCKER_BRIDGE_IP = "172.17.0.1" + if is_in_docker: + candidates = (DOCKER_BRIDGE_IP, "172.18.0.1") + for ip in candidates: + # TODO: remove from here - should not perform I/O operations in top-level config.py + if ping(ip): + DOCKER_BRIDGE_IP = ip + break + +# AWS account used to store internal resources such as Lambda archives or internal SQS queues. +# It should not be modified by the user, or visible to him, except as through a presigned url with the +# get-function call. +INTERNAL_RESOURCE_ACCOUNT = os.environ.get("INTERNAL_RESOURCE_ACCOUNT") or "949334387222" + +# TODO: remove with 4.1.0 +# Determine which implementation to use for the event rule / event filtering engine used by multiple services: +# EventBridge, EventBridge Pipes, Lambda Event Source Mapping +# Options: python (default) | java (deprecated since 4.0.3) +EVENT_RULE_ENGINE = os.environ.get("EVENT_RULE_ENGINE", "python").strip() + +# ----- +# SERVICE-SPECIFIC CONFIGS BELOW +# ----- + +# port ranges for external service instances (f.e. elasticsearch clusters, opensearch clusters,...) +EXTERNAL_SERVICE_PORTS_START = int( + os.environ.get("EXTERNAL_SERVICE_PORTS_START") + or os.environ.get("SERVICE_INSTANCES_PORTS_START") + or 4510 +) +EXTERNAL_SERVICE_PORTS_END = int( + os.environ.get("EXTERNAL_SERVICE_PORTS_END") + or os.environ.get("SERVICE_INSTANCES_PORTS_END") + or (EXTERNAL_SERVICE_PORTS_START + 50) +) + +# The default container runtime to use +CONTAINER_RUNTIME = os.environ.get("CONTAINER_RUNTIME", "").strip() or "docker" + +# PUBLIC v1: -Xmx512M (example) Currently not supported in new provider but possible via custom entrypoint. +# Allow passing custom JVM options to Java Lambdas executed in Docker. +LAMBDA_JAVA_OPTS = os.environ.get("LAMBDA_JAVA_OPTS", "").strip() + +# limit in which to kinesis-mock will start throwing exceptions +KINESIS_SHARD_LIMIT = os.environ.get("KINESIS_SHARD_LIMIT", "").strip() or "100" +KINESIS_PERSISTENCE = is_env_not_false("KINESIS_PERSISTENCE") + +# limit in which to kinesis-mock will start throwing exceptions +KINESIS_ON_DEMAND_STREAM_COUNT_LIMIT = ( + os.environ.get("KINESIS_ON_DEMAND_STREAM_COUNT_LIMIT", "").strip() or "10" +) + +# delay in kinesis-mock response when making changes to streams +KINESIS_LATENCY = os.environ.get("KINESIS_LATENCY", "").strip() or "500" + +# Delay between data persistence (in seconds) +KINESIS_MOCK_PERSIST_INTERVAL = os.environ.get("KINESIS_MOCK_PERSIST_INTERVAL", "").strip() or "5s" + +# Kinesis mock log level override when inconsistent with LS_LOG (e.g., when LS_LOG=debug) +KINESIS_MOCK_LOG_LEVEL = os.environ.get("KINESIS_MOCK_LOG_LEVEL", "").strip() + +# randomly inject faults to Kinesis +KINESIS_ERROR_PROBABILITY = float(os.environ.get("KINESIS_ERROR_PROBABILITY", "").strip() or 0.0) + +# SEMI-PUBLIC: "node" (default); not actively communicated +# Select whether to use the node or scala build when running Kinesis Mock +KINESIS_MOCK_PROVIDER_ENGINE = os.environ.get("KINESIS_MOCK_PROVIDER_ENGINE", "").strip() or "node" + +# set the maximum Java heap size corresponding to the '-Xmx<size>' flag +KINESIS_MOCK_MAXIMUM_HEAP_SIZE = ( + os.environ.get("KINESIS_MOCK_MAXIMUM_HEAP_SIZE", "").strip() or "512m" +) + +# set the initial Java heap size corresponding to the '-Xms<size>' flag +KINESIS_MOCK_INITIAL_HEAP_SIZE = ( + os.environ.get("KINESIS_MOCK_INITIAL_HEAP_SIZE", "").strip() or "256m" +) + +# randomly inject faults to DynamoDB +DYNAMODB_ERROR_PROBABILITY = float(os.environ.get("DYNAMODB_ERROR_PROBABILITY", "").strip() or 0.0) +DYNAMODB_READ_ERROR_PROBABILITY = float( + os.environ.get("DYNAMODB_READ_ERROR_PROBABILITY", "").strip() or 0.0 +) +DYNAMODB_WRITE_ERROR_PROBABILITY = float( + os.environ.get("DYNAMODB_WRITE_ERROR_PROBABILITY", "").strip() or 0.0 +) + +# JAVA EE heap size for dynamodb +DYNAMODB_HEAP_SIZE = os.environ.get("DYNAMODB_HEAP_SIZE", "").strip() or "256m" + +# single DB instance across multiple credentials are regions +DYNAMODB_SHARE_DB = int(os.environ.get("DYNAMODB_SHARE_DB") or 0) + +# the port on which to expose dynamodblocal +DYNAMODB_LOCAL_PORT = int(os.environ.get("DYNAMODB_LOCAL_PORT") or 0) + +# Enables the automatic removal of stale KV pais based on TTL +DYNAMODB_REMOVE_EXPIRED_ITEMS = is_env_true("DYNAMODB_REMOVE_EXPIRED_ITEMS") + +# Used to toggle PurgeInProgress exceptions when calling purge within 60 seconds +SQS_DELAY_PURGE_RETRY = is_env_true("SQS_DELAY_PURGE_RETRY") + +# Used to toggle QueueDeletedRecently errors when re-creating a queue within 60 seconds of deleting it +SQS_DELAY_RECENTLY_DELETED = is_env_true("SQS_DELAY_RECENTLY_DELETED") + +# Used to toggle MessageRetentionPeriod functionality in SQS queues +SQS_ENABLE_MESSAGE_RETENTION_PERIOD = is_env_true("SQS_ENABLE_MESSAGE_RETENTION_PERIOD") + +# Strategy used when creating SQS queue urls. can be "off", "standard" (default), "domain", "path", or "dynamic" +SQS_ENDPOINT_STRATEGY = os.environ.get("SQS_ENDPOINT_STRATEGY", "") or "standard" + +# Disable the check for MaxNumberOfMessage in SQS ReceiveMessage +SQS_DISABLE_MAX_NUMBER_OF_MESSAGE_LIMIT = is_env_true("SQS_DISABLE_MAX_NUMBER_OF_MESSAGE_LIMIT") + +# Disable cloudwatch metrics for SQS +SQS_DISABLE_CLOUDWATCH_METRICS = is_env_true("SQS_DISABLE_CLOUDWATCH_METRICS") + +# Interval for reporting "approximate" metrics to cloudwatch, default is 60 seconds +SQS_CLOUDWATCH_METRICS_REPORT_INTERVAL = int( + os.environ.get("SQS_CLOUDWATCH_METRICS_REPORT_INTERVAL") or 60 +) + +# PUBLIC: Endpoint host under which LocalStack APIs are accessible from Lambda Docker containers. +HOSTNAME_FROM_LAMBDA = os.environ.get("HOSTNAME_FROM_LAMBDA", "").strip() + +# PUBLIC: hot-reload (default v2), __local__ (default v1) +# Magic S3 bucket name for Hot Reloading. The S3Key points to the source code on the local file system. +BUCKET_MARKER_LOCAL = ( + os.environ.get("BUCKET_MARKER_LOCAL", "").strip() or DEFAULT_BUCKET_MARKER_LOCAL +) + +# PUBLIC: Opt-out to inject the environment variable AWS_ENDPOINT_URL for automatic configuration of AWS SDKs: +# https://docs.aws.amazon.com/sdkref/latest/guide/feature-ss-endpoints.html +LAMBDA_DISABLE_AWS_ENDPOINT_URL = is_env_true("LAMBDA_DISABLE_AWS_ENDPOINT_URL") + +# PUBLIC: bridge (Docker default) +# Docker network driver for the Lambda and ECS containers. https://docs.docker.com/network/ +LAMBDA_DOCKER_NETWORK = os.environ.get("LAMBDA_DOCKER_NETWORK", "").strip() + +# PUBLIC v1: LocalStack DNS (default) +# Custom DNS server for the container running your lambda function. +LAMBDA_DOCKER_DNS = os.environ.get("LAMBDA_DOCKER_DNS", "").strip() + +# PUBLIC: -e KEY=VALUE -v host:container +# Additional flags passed to Docker run|create commands. +LAMBDA_DOCKER_FLAGS = os.environ.get("LAMBDA_DOCKER_FLAGS", "").strip() + +# PUBLIC: 0 (default) +# Enable this flag to run cross-platform compatible lambda functions natively (i.e., Docker selects architecture) and +# ignore the AWS architectures (i.e., x86_64, arm64) configured for the lambda function. +LAMBDA_IGNORE_ARCHITECTURE = is_env_true("LAMBDA_IGNORE_ARCHITECTURE") + +# TODO: test and add to docs +# EXPERIMENTAL: 0 (default) +# prebuild images before execution? Increased cold start time on the tradeoff of increased time until lambda is ACTIVE +LAMBDA_PREBUILD_IMAGES = is_env_true("LAMBDA_PREBUILD_IMAGES") + +# PUBLIC: docker (default), kubernetes (pro) +# Where Lambdas will be executed. +LAMBDA_RUNTIME_EXECUTOR = os.environ.get("LAMBDA_RUNTIME_EXECUTOR", CONTAINER_RUNTIME).strip() + +# PUBLIC: 20 (default) +# How many seconds Lambda will wait for the runtime environment to start up. +LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT = int(os.environ.get("LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT") or 20) + +# PUBLIC: base images for Lambda (default) https://docs.aws.amazon.com/lambda/latest/dg/runtimes-images.html +# localstack/services/lambda_/invocation/lambda_models.py:IMAGE_MAPPING +# Customize the Docker image of Lambda runtimes, either by: +# a) pattern with <runtime> placeholder, e.g. custom-repo/lambda-<runtime>:2022 +# b) json dict mapping the <runtime> to an image, e.g. {"python3.9": "custom-repo/lambda-py:thon3.9"} +LAMBDA_RUNTIME_IMAGE_MAPPING = os.environ.get("LAMBDA_RUNTIME_IMAGE_MAPPING", "").strip() + + +# PUBLIC: 0 (default) +# Whether to disable usage of deprecated runtimes +LAMBDA_RUNTIME_VALIDATION = int(os.environ.get("LAMBDA_RUNTIME_VALIDATION") or 0) + +# PUBLIC: 1 (default) +# Whether to remove any Lambda Docker containers. +LAMBDA_REMOVE_CONTAINERS = ( + os.environ.get("LAMBDA_REMOVE_CONTAINERS", "").lower().strip() not in FALSE_STRINGS +) + +# PUBLIC: 600000 (default 10min) +# Time in milliseconds until lambda shuts down the execution environment after the last invocation has been processed. +# Set to 0 to immediately shut down the execution environment after an invocation. +LAMBDA_KEEPALIVE_MS = int(os.environ.get("LAMBDA_KEEPALIVE_MS", 600_000)) + +# PUBLIC: 1000 (default) +# The maximum number of events that functions can process simultaneously in the current Region. +# See AWS service quotas: https://docs.aws.amazon.com/general/latest/gr/lambda-service.html +# Concurrency limits. Like on AWS these apply per account and region. +LAMBDA_LIMITS_CONCURRENT_EXECUTIONS = int( + os.environ.get("LAMBDA_LIMITS_CONCURRENT_EXECUTIONS", 1_000) +) +# SEMI-PUBLIC: not actively communicated +# per account/region: there must be at least <LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY> unreserved concurrency. +LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY = int( + os.environ.get("LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY", 100) +) +# SEMI-PUBLIC: not actively communicated +LAMBDA_LIMITS_TOTAL_CODE_SIZE = int(os.environ.get("LAMBDA_LIMITS_TOTAL_CODE_SIZE", 80_530_636_800)) +# PUBLIC: documented after AWS changed validation around 2023-11 +LAMBDA_LIMITS_CODE_SIZE_ZIPPED = int(os.environ.get("LAMBDA_LIMITS_CODE_SIZE_ZIPPED", 52_428_800)) +# SEMI-PUBLIC: not actively communicated +LAMBDA_LIMITS_CODE_SIZE_UNZIPPED = int( + os.environ.get("LAMBDA_LIMITS_CODE_SIZE_UNZIPPED", 262_144_000) +) +# PUBLIC: documented upon customer request +LAMBDA_LIMITS_CREATE_FUNCTION_REQUEST_SIZE = int( + os.environ.get("LAMBDA_LIMITS_CREATE_FUNCTION_REQUEST_SIZE", 70_167_211) +) +# SEMI-PUBLIC: not actively communicated +LAMBDA_LIMITS_MAX_FUNCTION_ENVVAR_SIZE_BYTES = int( + os.environ.get("LAMBDA_LIMITS_MAX_FUNCTION_ENVVAR_SIZE_BYTES", 4 * 1024) +) +# SEMI-PUBLIC: not actively communicated +LAMBDA_LIMITS_MAX_FUNCTION_PAYLOAD_SIZE_BYTES = int( + os.environ.get( + "LAMBDA_LIMITS_MAX_FUNCTION_PAYLOAD_SIZE_BYTES", 6 * 1024 * 1024 + 100 + ) # the 100 comes from the init defaults +) + +# DEV: 0 (default unless in host mode on macOS) For LS developers only. Only applies to Docker mode. +# Whether to explicitly expose a free TCP port in lambda containers when invoking functions in host mode for +# systems that cannot reach the container via its IPv4. For example, macOS cannot reach Docker containers: +# https://docs.docker.com/desktop/networking/#i-cannot-ping-my-containers +LAMBDA_DEV_PORT_EXPOSE = ( + # Enable this dev flag by default on macOS in host mode (i.e., non-Docker environment) + is_env_not_false("LAMBDA_DEV_PORT_EXPOSE") + if not is_in_docker and is_in_macos + else is_env_true("LAMBDA_DEV_PORT_EXPOSE") +) + +# DEV: only applies to new lambda provider. All LAMBDA_INIT_* configuration are for LS developers only. +# There are NO stability guarantees, and they may break at any time. + +# DEV: Release version of https://github.com/localstack/lambda-runtime-init overriding the current default +LAMBDA_INIT_RELEASE_VERSION = os.environ.get("LAMBDA_INIT_RELEASE_VERSION") +# DEV: 0 (default) Enable for mounting of RIE init binary and delve debugger +LAMBDA_INIT_DEBUG = is_env_true("LAMBDA_INIT_DEBUG") +# DEV: path to RIE init binary (e.g., var/rapid/init) +LAMBDA_INIT_BIN_PATH = os.environ.get("LAMBDA_INIT_BIN_PATH") +# DEV: path to entrypoint script (e.g., var/rapid/entrypoint.sh) +LAMBDA_INIT_BOOTSTRAP_PATH = os.environ.get("LAMBDA_INIT_BOOTSTRAP_PATH") +# DEV: path to delve debugger (e.g., var/rapid/dlv) +LAMBDA_INIT_DELVE_PATH = os.environ.get("LAMBDA_INIT_DELVE_PATH") +# DEV: Go Delve debug port +LAMBDA_INIT_DELVE_PORT = int(os.environ.get("LAMBDA_INIT_DELVE_PORT") or 40000) +# DEV: Time to wait after every invoke as a workaround to fix a race condition in persistence tests +LAMBDA_INIT_POST_INVOKE_WAIT_MS = os.environ.get("LAMBDA_INIT_POST_INVOKE_WAIT_MS") +# DEV: sbx_user1051 (default when not provided) Alternative system user or empty string to skip dropping privileges. +LAMBDA_INIT_USER = os.environ.get("LAMBDA_INIT_USER") + +# INTERNAL: 1 (default) +# The duration (in seconds) to wait between each poll call to an event source. +LAMBDA_EVENT_SOURCE_MAPPING_POLL_INTERVAL_SEC = float( + os.environ.get("LAMBDA_EVENT_SOURCE_MAPPING_POLL_INTERVAL_SEC") or 1 +) + +# INTERNAL: 60 (default) +# Maximum duration (in seconds) to wait between retries when an event source poll fails. +LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_ERROR_SEC = float( + os.environ.get("LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_ERROR_SEC") or 60 +) + +# INTERNAL: 10 (default) +# Maximum duration (in seconds) to wait between polls when an event source returns empty results. +LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_EMPTY_POLL_SEC = float( + os.environ.get("LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_EMPTY_POLL_SEC") or 10 +) + +# Specifies the path to the mock configuration file for Step Functions, commonly named MockConfigFile.json. +SFN_MOCK_CONFIG = os.environ.get("SFN_MOCK_CONFIG", "").strip() + +# path prefix for windows volume mounting +WINDOWS_DOCKER_MOUNT_PREFIX = os.environ.get("WINDOWS_DOCKER_MOUNT_PREFIX", "/host_mnt") + +# whether to skip S3 presign URL signature validation (TODO: currently enabled, until all issues are resolved) +S3_SKIP_SIGNATURE_VALIDATION = is_env_not_false("S3_SKIP_SIGNATURE_VALIDATION") +# whether to skip S3 validation of provided KMS key +S3_SKIP_KMS_KEY_VALIDATION = is_env_not_false("S3_SKIP_KMS_KEY_VALIDATION") + +# PUBLIC: 2000 (default) +# Allows increasing the default char limit for truncation of lambda log lines when printed in the console. +# This does not affect the logs processing in CloudWatch. +LAMBDA_TRUNCATE_STDOUT = int(os.getenv("LAMBDA_TRUNCATE_STDOUT") or 2000) + +# INTERNAL: 60 (default matching AWS) only applies to new lambda provider +# Base delay in seconds for async retries. Further retries use: NUM_ATTEMPTS * LAMBDA_RETRY_BASE_DELAY_SECONDS +# 300 (5min) is the maximum because NUM_ATTEMPTS can be at most 3 and SQS has a message timer limit of 15 min. +# For example: +# 1x LAMBDA_RETRY_BASE_DELAY_SECONDS: delay between initial invocation and first retry +# 2x LAMBDA_RETRY_BASE_DELAY_SECONDS: delay between the first retry and the second retry +# 3x LAMBDA_RETRY_BASE_DELAY_SECONDS: delay between the second retry and the third retry +LAMBDA_RETRY_BASE_DELAY_SECONDS = int(os.getenv("LAMBDA_RETRY_BASE_DELAY") or 60) + +# PUBLIC: 0 (default) +# Set to 1 to create lambda functions synchronously (not recommended). +# Whether Lambda.CreateFunction will block until the function is in a terminal state (Active or Failed). +# This technically breaks behavior parity but is provided as a simplification over the default AWS behavior and +# to match the behavior of the old lambda provider. +LAMBDA_SYNCHRONOUS_CREATE = is_env_true("LAMBDA_SYNCHRONOUS_CREATE") + +# URL to a custom OpenSearch/Elasticsearch backend cluster. If this is set to a valid URL, then localstack will not +# create OpenSearch/Elasticsearch cluster instances, but instead forward all domains to the given backend. +OPENSEARCH_CUSTOM_BACKEND = os.environ.get("OPENSEARCH_CUSTOM_BACKEND", "").strip() + +# Strategy used when creating OpenSearch/Elasticsearch domain endpoints routed through the edge proxy +# valid values: domain | path | port (off) +OPENSEARCH_ENDPOINT_STRATEGY = ( + os.environ.get("OPENSEARCH_ENDPOINT_STRATEGY", "").strip() or "domain" +) +if OPENSEARCH_ENDPOINT_STRATEGY == "off": + OPENSEARCH_ENDPOINT_STRATEGY = "port" + +# Whether to start one cluster per domain (default), or multiplex opensearch domains to a single clusters +OPENSEARCH_MULTI_CLUSTER = is_env_not_false("OPENSEARCH_MULTI_CLUSTER") + +# Whether to really publish to GCM while using SNS Platform Application (needs credentials) +LEGACY_SNS_GCM_PUBLISHING = is_env_true("LEGACY_SNS_GCM_PUBLISHING") + +SNS_SES_SENDER_ADDRESS = os.environ.get("SNS_SES_SENDER_ADDRESS", "").strip() + +SNS_CERT_URL_HOST = os.environ.get("SNS_CERT_URL_HOST", "").strip() + +# Whether the Next Gen APIGW invocation logic is enabled (on by default) +APIGW_NEXT_GEN_PROVIDER = os.environ.get("PROVIDER_OVERRIDE_APIGATEWAY", "") in ("next_gen", "") + +# Whether the DynamoDBStreams native provider is enabled +DDB_STREAMS_PROVIDER_V2 = os.environ.get("PROVIDER_OVERRIDE_DYNAMODBSTREAMS", "") == "v2" +_override_dynamodb_v2 = os.environ.get("PROVIDER_OVERRIDE_DYNAMODB", "") +if DDB_STREAMS_PROVIDER_V2: + # in order to not have conflicts between the 2 implementations, as they are tightly coupled, we need to set DDB + # to be v2 as well + if not _override_dynamodb_v2: + os.environ["PROVIDER_OVERRIDE_DYNAMODB"] = "v2" +elif _override_dynamodb_v2 == "v2": + os.environ["PROVIDER_OVERRIDE_DYNAMODBSTREAMS"] = "v2" + DDB_STREAMS_PROVIDER_V2 = True + +# TODO remove fallback to LAMBDA_DOCKER_NETWORK with next minor version +MAIN_DOCKER_NETWORK = os.environ.get("MAIN_DOCKER_NETWORK", "") or LAMBDA_DOCKER_NETWORK + +# Whether to return and parse access key ids starting with an "A", like on AWS +PARITY_AWS_ACCESS_KEY_ID = is_env_true("PARITY_AWS_ACCESS_KEY_ID") + +# Show exceptions for CloudFormation deploy errors +CFN_VERBOSE_ERRORS = is_env_true("CFN_VERBOSE_ERRORS") + +# The CFN_STRING_REPLACEMENT_DENY_LIST env variable is a comma separated list of strings that are not allowed to be +# replaced in CloudFormation templates (e.g. AWS URLs that are usually edited by Localstack to point to itself if found +# in a CFN template). They are extracted to a list of strings if the env variable is set. +CFN_STRING_REPLACEMENT_DENY_LIST = [ + x for x in os.environ.get("CFN_STRING_REPLACEMENT_DENY_LIST", "").split(",") if x +] + +# Set the timeout to deploy each individual CloudFormation resource +CFN_PER_RESOURCE_TIMEOUT = int(os.environ.get("CFN_PER_RESOURCE_TIMEOUT") or 300) + +# How localstack will react to encountering unsupported resource types. +# By default unsupported resource types will be ignored. +# EXPERIMENTAL +CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES = is_env_not_false("CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES") + +# bind address of local DNS server +DNS_ADDRESS = os.environ.get("DNS_ADDRESS") or "0.0.0.0" +# port of the local DNS server +DNS_PORT = int(os.environ.get("DNS_PORT", "53")) + +# Comma-separated list of regex patterns for DNS names to resolve locally. +# Any DNS name not matched against any of the patterns on this whitelist +# will resolve it to the real DNS entry, rather than the local one. +DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM = ( + os.environ.get("DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM") or "" +).strip() +DNS_LOCAL_NAME_PATTERNS = (os.environ.get("DNS_LOCAL_NAME_PATTERNS") or "").strip() # deprecated + +# IP address that AWS endpoints should resolve to in our local DNS server. By default, +# hostnames resolve to 127.0.0.1, which allows to use the LocalStack APIs transparently +# from the host machine. If your code is running in Docker, this should be configured +# to resolve to the Docker bridge network address, e.g., DNS_RESOLVE_IP=172.17.0.1 +DNS_RESOLVE_IP = os.environ.get("DNS_RESOLVE_IP") or LOCALHOST_IP + +# fallback DNS server to send upstream requests to +DNS_SERVER = os.environ.get("DNS_SERVER") +DNS_VERIFICATION_DOMAIN = os.environ.get("DNS_VERIFICATION_DOMAIN") or "localstack.cloud" + + +def use_custom_dns(): + return str(DNS_ADDRESS) not in FALSE_STRINGS + + +# s3 virtual host name +S3_VIRTUAL_HOSTNAME = "s3.%s" % LOCALSTACK_HOST.host +S3_STATIC_WEBSITE_HOSTNAME = "s3-website.%s" % LOCALSTACK_HOST.host + +BOTO_WAITER_DELAY = int(os.environ.get("BOTO_WAITER_DELAY") or "1") +BOTO_WAITER_MAX_ATTEMPTS = int(os.environ.get("BOTO_WAITER_MAX_ATTEMPTS") or "120") +DISABLE_CUSTOM_BOTO_WAITER_CONFIG = is_env_true("DISABLE_CUSTOM_BOTO_WAITER_CONFIG") + +# defaults to false +# if `DISABLE_BOTO_RETRIES=1` is set, all our created boto clients will have retries disabled +DISABLE_BOTO_RETRIES = is_env_true("DISABLE_BOTO_RETRIES") + +DISTRIBUTED_MODE = is_env_true("DISTRIBUTED_MODE") + +# This flag enables `connect_to` to be in-memory only and not do networking calls +IN_MEMORY_CLIENT = is_env_true("IN_MEMORY_CLIENT") + +# This flag enables all responses from LocalStack to contain a `x-localstack` HTTP header. +LOCALSTACK_RESPONSE_HEADER_ENABLED = is_env_not_false("LOCALSTACK_RESPONSE_HEADER_ENABLED") + +# List of environment variable names used for configuration that are passed from the host into the LocalStack container. +# => Synchronize this list with the above and the configuration docs: +# https://docs.localstack.cloud/references/configuration/ +# => Sort this list alphabetically +# => Add deprecated environment variables to deprecations.py and add a comment in this list +# => Move removed legacy variables to the section grouped by release (still relevant for deprecation warnings) +# => Do *not* include any internal developer configurations that apply to host-mode only in this list. +CONFIG_ENV_VARS = [ + "ALLOW_NONSTANDARD_REGIONS", + "BOTO_WAITER_DELAY", + "BOTO_WAITER_MAX_ATTEMPTS", + "BUCKET_MARKER_LOCAL", + "CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES", + "CFN_PER_RESOURCE_TIMEOUT", + "CFN_STRING_REPLACEMENT_DENY_LIST", + "CFN_VERBOSE_ERRORS", + "CI", + "CONTAINER_RUNTIME", + "CUSTOM_SSL_CERT_PATH", + "DEBUG", + "DEBUG_HANDLER_CHAIN", + "DEVELOP", + "DEVELOP_PORT", + "DISABLE_BOTO_RETRIES", + "DISABLE_CORS_CHECKS", + "DISABLE_CORS_HEADERS", + "DISABLE_CUSTOM_BOTO_WAITER_CONFIG", + "DISABLE_CUSTOM_CORS_APIGATEWAY", + "DISABLE_CUSTOM_CORS_S3", + "DISABLE_EVENTS", + "DISTRIBUTED_MODE", + "DNS_ADDRESS", + "DNS_PORT", + "DNS_LOCAL_NAME_PATTERNS", + "DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM", + "DNS_RESOLVE_IP", + "DNS_SERVER", + "DNS_VERIFICATION_DOMAIN", + "DOCKER_BRIDGE_IP", + "DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS", + "DYNAMODB_ERROR_PROBABILITY", + "DYNAMODB_HEAP_SIZE", + "DYNAMODB_IN_MEMORY", + "DYNAMODB_LOCAL_PORT", + "DYNAMODB_SHARE_DB", + "DYNAMODB_READ_ERROR_PROBABILITY", + "DYNAMODB_REMOVE_EXPIRED_ITEMS", + "DYNAMODB_WRITE_ERROR_PROBABILITY", + "EAGER_SERVICE_LOADING", + "ENABLE_CONFIG_UPDATES", + "EVENT_RULE_ENGINE", + "EXTRA_CORS_ALLOWED_HEADERS", + "EXTRA_CORS_ALLOWED_ORIGINS", + "EXTRA_CORS_EXPOSE_HEADERS", + "GATEWAY_LISTEN", + "GATEWAY_SERVER", + "GATEWAY_WORKER_THREAD_COUNT", + "HOSTNAME", + "HOSTNAME_FROM_LAMBDA", + "IN_MEMORY_CLIENT", + "KINESIS_ERROR_PROBABILITY", + "KINESIS_MOCK_PERSIST_INTERVAL", + "KINESIS_MOCK_LOG_LEVEL", + "KINESIS_ON_DEMAND_STREAM_COUNT_LIMIT", + "KINESIS_PERSISTENCE", + "LAMBDA_DEBUG_MODE", + "LAMBDA_DEBUG_MODE_CONFIG", + "LAMBDA_DISABLE_AWS_ENDPOINT_URL", + "LAMBDA_DOCKER_DNS", + "LAMBDA_DOCKER_FLAGS", + "LAMBDA_DOCKER_NETWORK", + "LAMBDA_EVENTS_INTERNAL_SQS", + "LAMBDA_EVENT_SOURCE_MAPPING", + "LAMBDA_IGNORE_ARCHITECTURE", + "LAMBDA_INIT_DEBUG", + "LAMBDA_INIT_BIN_PATH", + "LAMBDA_INIT_BOOTSTRAP_PATH", + "LAMBDA_INIT_DELVE_PATH", + "LAMBDA_INIT_DELVE_PORT", + "LAMBDA_INIT_POST_INVOKE_WAIT_MS", + "LAMBDA_INIT_USER", + "LAMBDA_INIT_RELEASE_VERSION", + "LAMBDA_KEEPALIVE_MS", + "LAMBDA_LIMITS_CONCURRENT_EXECUTIONS", + "LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY", + "LAMBDA_LIMITS_TOTAL_CODE_SIZE", + "LAMBDA_LIMITS_CODE_SIZE_ZIPPED", + "LAMBDA_LIMITS_CODE_SIZE_UNZIPPED", + "LAMBDA_LIMITS_CREATE_FUNCTION_REQUEST_SIZE", + "LAMBDA_LIMITS_MAX_FUNCTION_ENVVAR_SIZE_BYTES", + "LAMBDA_LIMITS_MAX_FUNCTION_PAYLOAD_SIZE_BYTES", + "LAMBDA_PREBUILD_IMAGES", + "LAMBDA_RUNTIME_IMAGE_MAPPING", + "LAMBDA_REMOVE_CONTAINERS", + "LAMBDA_RETRY_BASE_DELAY_SECONDS", + "LAMBDA_RUNTIME_EXECUTOR", + "LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT", + "LAMBDA_RUNTIME_VALIDATION", + "LAMBDA_SYNCHRONOUS_CREATE", + "LAMBDA_SQS_EVENT_SOURCE_MAPPING_INTERVAL", + "LAMBDA_TRUNCATE_STDOUT", + "LEGACY_DOCKER_CLIENT", + "LEGACY_SNS_GCM_PUBLISHING", + "LOCALSTACK_API_KEY", + "LOCALSTACK_AUTH_TOKEN", + "LOCALSTACK_HOST", + "LOCALSTACK_RESPONSE_HEADER_ENABLED", + "LOG_LICENSE_ISSUES", + "LS_LOG", + "MAIN_CONTAINER_NAME", + "MAIN_DOCKER_NETWORK", + "OPENAPI_VALIDATE_REQUEST", + "OPENAPI_VALIDATE_RESPONSE", + "OPENSEARCH_ENDPOINT_STRATEGY", + "OUTBOUND_HTTP_PROXY", + "OUTBOUND_HTTPS_PROXY", + "PARITY_AWS_ACCESS_KEY_ID", + "PERSISTENCE", + "PORTS_CHECK_DOCKER_IMAGE", + "REQUESTS_CA_BUNDLE", + "REMOVE_SSL_CERT", + "S3_SKIP_SIGNATURE_VALIDATION", + "S3_SKIP_KMS_KEY_VALIDATION", + "SERVICES", + "SKIP_INFRA_DOWNLOADS", + "SKIP_SSL_CERT_DOWNLOAD", + "SNAPSHOT_LOAD_STRATEGY", + "SNAPSHOT_SAVE_STRATEGY", + "SNAPSHOT_FLUSH_INTERVAL", + "SNS_SES_SENDER_ADDRESS", + "SQS_DELAY_PURGE_RETRY", + "SQS_DELAY_RECENTLY_DELETED", + "SQS_ENABLE_MESSAGE_RETENTION_PERIOD", + "SQS_ENDPOINT_STRATEGY", + "SQS_DISABLE_CLOUDWATCH_METRICS", + "SQS_CLOUDWATCH_METRICS_REPORT_INTERVAL", + "STRICT_SERVICE_LOADING", + "TF_COMPAT_MODE", + "USE_SSL", + "WAIT_FOR_DEBUGGER", + "WINDOWS_DOCKER_MOUNT_PREFIX", + # Removed legacy variables in 2.0.0 + # DATA_DIR => do *not* include in this list, as it is treated separately. # deprecated since 1.0.0 + "LEGACY_DIRECTORIES", # deprecated since 1.0.0 + "SYNCHRONOUS_API_GATEWAY_EVENTS", # deprecated since 1.3.0 + "SYNCHRONOUS_DYNAMODB_EVENTS", # deprecated since 1.3.0 + "SYNCHRONOUS_SNS_EVENTS", # deprecated since 1.3.0 + "SYNCHRONOUS_SQS_EVENTS", # deprecated since 1.3.0 + # Removed legacy variables in 3.0.0 + "DEFAULT_REGION", # deprecated since 0.12.7 + "EDGE_BIND_HOST", # deprecated since 2.0.0 + "EDGE_FORWARD_URL", # deprecated since 1.4.0 + "EDGE_PORT", # deprecated since 2.0.0 + "EDGE_PORT_HTTP", # deprecated since 2.0.0 + "ES_CUSTOM_BACKEND", # deprecated since 0.14.0 + "ES_ENDPOINT_STRATEGY", # deprecated since 0.14.0 + "ES_MULTI_CLUSTER", # deprecated since 0.14.0 + "HOSTNAME_EXTERNAL", # deprecated since 2.0.0 + "KINESIS_INITIALIZE_STREAMS", # deprecated since 1.4.0 + "KINESIS_PROVIDER", # deprecated since 1.3.0 + "KMS_PROVIDER", # deprecated since 1.4.0 + "LAMBDA_XRAY_INIT", # deprecated since 2.0.0 + "LAMBDA_CODE_EXTRACT_TIME", # deprecated since 2.0.0 + "LAMBDA_CONTAINER_REGISTRY", # deprecated since 2.0.0 + "LAMBDA_EXECUTOR", # deprecated since 2.0.0 + "LAMBDA_FALLBACK_URL", # deprecated since 2.0.0 + "LAMBDA_FORWARD_URL", # deprecated since 2.0.0 + "LAMBDA_JAVA_OPTS", # currently only supported in old Lambda provider but not officially deprecated + "LAMBDA_REMOTE_DOCKER", # deprecated since 2.0.0 + "LAMBDA_STAY_OPEN_MODE", # deprecated since 2.0.0 + "LEGACY_EDGE_PROXY", # deprecated since 1.0.0 + "LOCALSTACK_HOSTNAME", # deprecated since 2.0.0 + "SQS_PORT_EXTERNAL", # deprecated only in docs since 2022-07-13 + "SYNCHRONOUS_KINESIS_EVENTS", # deprecated since 1.3.0 + "USE_SINGLE_REGION", # deprecated since 0.12.7 + "MOCK_UNIMPLEMENTED", # deprecated since 1.3.0 +] + + +def is_local_test_mode() -> bool: + """Returns True if we are running in the context of our local integration tests.""" + return is_env_true(ENV_INTERNAL_TEST_RUN) + + +def is_collect_metrics_mode() -> bool: + """Returns True if metric collection is enabled.""" + return is_env_true(ENV_INTERNAL_TEST_COLLECT_METRIC) + + +def collect_config_items() -> List[Tuple[str, Any]]: + """Returns a list of key-value tuples of LocalStack configuration values.""" + none = object() # sentinel object + + # collect which keys to print + keys = [] + keys.extend(CONFIG_ENV_VARS) + keys.append("DATA_DIR") + keys.sort() + + values = globals() + + result = [] + for k in keys: + v = values.get(k, none) + if v is none: + continue + result.append((k, v)) + result.sort() + return result + + +def populate_config_env_var_names(): + global CONFIG_ENV_VARS + + CONFIG_ENV_VARS += [ + key + for key in [key.upper() for key in os.environ] + if (key.startswith("LOCALSTACK_") or key.startswith("PROVIDER_OVERRIDE_")) + # explicitly exclude LOCALSTACK_CLI (it's prefixed with "LOCALSTACK_", + # but is only used in the CLI (should not be forwarded to the container) + and key != "LOCALSTACK_CLI" + ] + + # create variable aliases prefixed with LOCALSTACK_ (except LOCALSTACK_HOST) + CONFIG_ENV_VARS += [ + "LOCALSTACK_" + v for v in CONFIG_ENV_VARS if not v.startswith("LOCALSTACK_") + ] + + CONFIG_ENV_VARS = list(set(CONFIG_ENV_VARS)) + + +# populate env var names to be passed to the container +populate_config_env_var_names() + + +# helpers to build urls +def get_protocol() -> str: + return "https" if USE_SSL else "http" + + +def external_service_url( + host: Optional[str] = None, + port: Optional[int] = None, + protocol: Optional[str] = None, + subdomains: Optional[str] = None, +) -> str: + """Returns a service URL (e.g., SQS queue URL) to an external client (e.g., boto3) potentially running on another + machine than LocalStack. The configurations LOCALSTACK_HOST and USE_SSL can customize these returned URLs. + The optional parameters can be used to customize the defaults. + Examples with default configuration: + * external_service_url() == http://localhost.localstack.cloud:4566 + * external_service_url(subdomains="s3") == http://s3.localhost.localstack.cloud:4566 + """ + protocol = protocol or get_protocol() + subdomains = f"{subdomains}." if subdomains else "" + host = host or LOCALSTACK_HOST.host + port = port or LOCALSTACK_HOST.port + return f"{protocol}://{subdomains}{host}:{port}" + + +def internal_service_url( + host: Optional[str] = None, + port: Optional[int] = None, + protocol: Optional[str] = None, + subdomains: Optional[str] = None, +) -> str: + """Returns a service URL for internal use within LocalStack (i.e., same host). + The configuration USE_SSL can customize these returned URLs but LOCALSTACK_HOST has no effect. + The optional parameters can be used to customize the defaults. + Examples with default configuration: + * internal_service_url() == http://localhost:4566 + * internal_service_url(port=8080) == http://localhost:8080 + """ + protocol = protocol or get_protocol() + subdomains = f"{subdomains}." if subdomains else "" + host = host or LOCALHOST + port = port or GATEWAY_LISTEN[0].port + return f"{protocol}://{subdomains}{host}:{port}" + + +# DEPRECATED: old helpers for building URLs + + +def service_url(service_key, host=None, port=None): + """@deprecated: Use `internal_service_url()` instead. We assume that most usages are internal + but really need to check and update each usage accordingly. + """ + warnings.warn( + """@deprecated: Use `internal_service_url()` instead. We assume that most usages are + internal but really need to check and update each usage accordingly.""", + DeprecationWarning, + stacklevel=2, + ) + return internal_service_url(host=host, port=port) + + +def service_port(service_key: str, external: bool = False) -> int: + """@deprecated: Use `localstack_host().port` for external and `GATEWAY_LISTEN[0].port` for + internal use.""" + warnings.warn( + "Deprecated: use `localstack_host().port` for external and `GATEWAY_LISTEN[0].port` for " + "internal use.", + DeprecationWarning, + stacklevel=2, + ) + if external: + return LOCALSTACK_HOST.port + return GATEWAY_LISTEN[0].port + + +def get_edge_port_http(): + """@deprecated: Use `localstack_host().port` for external and `GATEWAY_LISTEN[0].port` for + internal use. This function is not needed anymore because we don't separate between HTTP + and HTTP ports anymore since LocalStack listens to both ports.""" + warnings.warn( + """@deprecated: Use `localstack_host().port` for external and `GATEWAY_LISTEN[0].port` + for internal use. This function is also not needed anymore because we don't separate + between HTTP and HTTP ports anymore since LocalStack listens to both.""", + DeprecationWarning, + stacklevel=2, + ) + return GATEWAY_LISTEN[0].port + + +def get_edge_url(localstack_hostname=None, protocol=None): + """@deprecated: Use `internal_service_url()` instead. + We assume that most usages are internal but really need to check and update each usage accordingly. + """ + warnings.warn( + """@deprecated: Use `internal_service_url()` instead. + We assume that most usages are internal but really need to check and update each usage accordingly. + """, + DeprecationWarning, + stacklevel=2, + ) + return internal_service_url(host=localstack_hostname, protocol=protocol) + + +class ServiceProviderConfig(Mapping[str, str]): + _provider_config: Dict[str, str] + default_value: str + override_prefix: str = "PROVIDER_OVERRIDE_" + + def __init__(self, default_value: str): + self._provider_config = {} + self.default_value = default_value + + def load_from_environment(self, env: Mapping[str, str] = None): + if env is None: + env = os.environ + for key, value in env.items(): + if key.startswith(self.override_prefix) and value: + self.set_provider(key[len(self.override_prefix) :].lower().replace("_", "-"), value) + + def get_provider(self, service: str) -> str: + return self._provider_config.get(service, self.default_value) + + def set_provider_if_not_exists(self, service: str, provider: str) -> None: + if service not in self._provider_config: + self._provider_config[service] = provider + + def set_provider(self, service: str, provider: str): + self._provider_config[service] = provider + + def bulk_set_provider_if_not_exists(self, services: List[str], provider: str): + for service in services: + self.set_provider_if_not_exists(service, provider) + + def __getitem__(self, item): + return self.get_provider(item) + + def __setitem__(self, key, value): + self.set_provider(key, value) + + def __len__(self): + return len(self._provider_config) + + def __iter__(self): + return self._provider_config.__iter__() + + +SERVICE_PROVIDER_CONFIG = ServiceProviderConfig("default") + +SERVICE_PROVIDER_CONFIG.load_from_environment() + + +def init_directories() -> Directories: + if is_in_docker: + return Directories.for_container() + else: + if is_env_true("LOCALSTACK_CLI"): + return Directories.for_cli() + + return Directories.for_host() + + +# initialize directories +dirs: Directories +dirs = init_directories() diff --git a/localstack-core/localstack/constants.py b/localstack-core/localstack/constants.py new file mode 100644 index 0000000000000..c8833e557fced --- /dev/null +++ b/localstack-core/localstack/constants.py @@ -0,0 +1,188 @@ +import os + +from localstack.version import __version__ + +VERSION = __version__ + +# HTTP headers used to forward proxy request URLs +HEADER_LOCALSTACK_EDGE_URL = "x-localstack-edge" +HEADER_LOCALSTACK_REQUEST_URL = "x-localstack-request-url" +# HTTP header optionally added to LocalStack responses +HEADER_LOCALSTACK_IDENTIFIER = "x-localstack" +# xXx custom localstack authorization header only used in ext +HEADER_LOCALSTACK_AUTHORIZATION = "x-localstack-authorization" +HEADER_LOCALSTACK_TARGET = "x-localstack-target" +HEADER_AMZN_ERROR_TYPE = "X-Amzn-Errortype" + +# backend service ports, for services that are behind a proxy (counting down from 4566) +DEFAULT_PORT_EDGE = 4566 + +# host name for localhost +LOCALHOST = "localhost" +LOCALHOST_IP = "127.0.0.1" +LOCALHOST_HOSTNAME = "localhost.localstack.cloud" + +# User-agent string used in outgoing HTTP requests made by LocalStack +USER_AGENT_STRING = f"localstack/{VERSION}" + +# version of the Maven dependency with Java utility code +LOCALSTACK_MAVEN_VERSION = "0.2.21" +MAVEN_REPO_URL = "https://repo1.maven.org/maven2" + +# URL of localstack's artifacts repository on GitHub +ARTIFACTS_REPO = "https://github.com/localstack/localstack-artifacts" + +# Artifacts endpoint +ASSETS_ENDPOINT = "https://assets.localstack.cloud" + +# Hugging Face endpoint for localstack +HUGGING_FACE_ENDPOINT = "https://huggingface.co/localstack" + +# host to bind to when starting the services +BIND_HOST = "0.0.0.0" + +# root code folder +MODULE_MAIN_PATH = os.path.dirname(os.path.realpath(__file__)) +# TODO rename to "ROOT_FOLDER"! +LOCALSTACK_ROOT_FOLDER = os.path.realpath(os.path.join(MODULE_MAIN_PATH, "..")) + +# virtualenv folder +LOCALSTACK_VENV_FOLDER: str = os.environ.get("VIRTUAL_ENV") +if not LOCALSTACK_VENV_FOLDER: + # fallback to the previous logic + LOCALSTACK_VENV_FOLDER = os.path.join(LOCALSTACK_ROOT_FOLDER, ".venv") + if not os.path.isdir(LOCALSTACK_VENV_FOLDER): + # assuming this package lives here: <python>/lib/pythonX.X/site-packages/localstack/ + LOCALSTACK_VENV_FOLDER = os.path.realpath( + os.path.join(LOCALSTACK_ROOT_FOLDER, "..", "..", "..") + ) + +# default volume directory containing shared data +DEFAULT_VOLUME_DIR = "/var/lib/localstack" + +# API Gateway path to indicate a user request sent to the gateway +PATH_USER_REQUEST = "_user_request_" + +# name of LocalStack Docker image +DOCKER_IMAGE_NAME = "localstack/localstack" +DOCKER_IMAGE_NAME_PRO = "localstack/localstack-pro" +DOCKER_IMAGE_NAME_FULL = "localstack/localstack-full" + +# backdoor API path used to retrieve or update config variables +CONFIG_UPDATE_PATH = "/?_config_" + +# API path for localstack internal resources +INTERNAL_RESOURCE_PATH = "/_localstack" + +# environment variable name to tag local test runs +ENV_INTERNAL_TEST_RUN = "LOCALSTACK_INTERNAL_TEST_RUN" + +# environment variable name to tag collect metrics during a test run +ENV_INTERNAL_TEST_COLLECT_METRIC = "LOCALSTACK_INTERNAL_TEST_COLLECT_METRIC" + +# environment variable that flags whether pro was activated. do not use it for security purposes! +ENV_PRO_ACTIVATED = "PRO_ACTIVATED" + +# content types / encodings +HEADER_CONTENT_TYPE = "Content-Type" +TEXT_XML = "text/xml" +APPLICATION_AMZ_JSON_1_0 = "application/x-amz-json-1.0" +APPLICATION_AMZ_JSON_1_1 = "application/x-amz-json-1.1" +APPLICATION_AMZ_CBOR_1_1 = "application/x-amz-cbor-1.1" +APPLICATION_CBOR = "application/cbor" +APPLICATION_JSON = "application/json" +APPLICATION_XML = "application/xml" +APPLICATION_OCTET_STREAM = "application/octet-stream" +APPLICATION_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded" +HEADER_ACCEPT_ENCODING = "Accept-Encoding" + +# strings to indicate truthy/falsy values +TRUE_STRINGS = ("1", "true", "True") +FALSE_STRINGS = ("0", "false", "False") +# strings with valid log levels for LS_LOG +LOG_LEVELS = ("trace-internal", "trace", "debug", "info", "warn", "error", "warning") + +# the version of elasticsearch that is pre-seeded into the base image (sync with Dockerfile.base) +ELASTICSEARCH_DEFAULT_VERSION = "Elasticsearch_7.10" +# See https://docs.aws.amazon.com/ja_jp/elasticsearch-service/latest/developerguide/aes-supported-plugins.html +ELASTICSEARCH_PLUGIN_LIST = [ + "analysis-icu", + "ingest-attachment", + "analysis-kuromoji", + "mapper-murmur3", + "mapper-size", + "analysis-phonetic", + "analysis-smartcn", + "analysis-stempel", + "analysis-ukrainian", +] +# Default ES modules to exclude (save apprx 66MB in the final image) +ELASTICSEARCH_DELETE_MODULES = ["ingest-geoip"] + +# the version of opensearch which is used by default +OPENSEARCH_DEFAULT_VERSION = "OpenSearch_2.11" + +# See https://docs.aws.amazon.com/opensearch-service/latest/developerguide/supported-plugins.html +OPENSEARCH_PLUGIN_LIST = [ + "ingest-attachment", + "analysis-kuromoji", +] + +# API endpoint for analytics events +API_ENDPOINT = os.environ.get("API_ENDPOINT") or "https://api.localstack.cloud/v1" +# new analytics API endpoint +ANALYTICS_API = os.environ.get("ANALYTICS_API") or "https://analytics.localstack.cloud/v1" + +# environment variable to indicate this process should run the localstack infrastructure +LOCALSTACK_INFRA_PROCESS = "LOCALSTACK_INFRA_PROCESS" + +# AWS region us-east-1 +AWS_REGION_US_EAST_1 = "us-east-1" + +# environment variable to override max pool connections +try: + MAX_POOL_CONNECTIONS = int(os.environ["MAX_POOL_CONNECTIONS"]) +except Exception: + MAX_POOL_CONNECTIONS = 150 + +# Fallback Account ID if not available in the client request +DEFAULT_AWS_ACCOUNT_ID = "000000000000" + +# Credentials used for internal calls +INTERNAL_AWS_ACCESS_KEY_ID = "__internal_call__" +INTERNAL_AWS_SECRET_ACCESS_KEY = "__internal_call__" + +# trace log levels (excluding/including internal API calls), configurable via $LS_LOG +LS_LOG_TRACE = "trace" +LS_LOG_TRACE_INTERNAL = "trace-internal" +TRACE_LOG_LEVELS = [LS_LOG_TRACE, LS_LOG_TRACE_INTERNAL] + +# list of official docker images +OFFICIAL_IMAGES = [ + "localstack/localstack", + "localstack/localstack-pro", +] + +# port for debug py +DEFAULT_DEVELOP_PORT = 5678 + +# Default bucket name of the s3 bucket used for local lambda development +# This name should be accepted by all IaC tools, so should respect s3 bucket naming conventions +DEFAULT_BUCKET_MARKER_LOCAL = "hot-reload" +LEGACY_DEFAULT_BUCKET_MARKER_LOCAL = "__local__" + +# user that starts the opensearch process if the current user is root +OS_USER_OPENSEARCH = "localstack" + +# output string that indicates that the stack is ready +READY_MARKER_OUTPUT = "Ready." + +# Regex for `Credential` field in the Authorization header in AWS signature version v4 +# The format is as follows: +# Credential=<access-key-id>/<date>/<region-name>/<service-name>/aws4_request +# eg. +# Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request +AUTH_CREDENTIAL_REGEX = r"Credential=(?P<access_key_id>[a-zA-Z0-9-_.]{1,})/(?P<date>\d{8})/(?P<region_name>[a-z0-9-]{1,})/(?P<service_name>[a-z0-9]{1,})/" + +# Custom resource tag to override the generated resource ID. +TAG_KEY_CUSTOM_ID = "_custom_id_" diff --git a/localstack-core/localstack/deprecations.py b/localstack-core/localstack/deprecations.py new file mode 100644 index 0000000000000..1690ca227d878 --- /dev/null +++ b/localstack-core/localstack/deprecations.py @@ -0,0 +1,410 @@ +# A simple module to track deprecations over time / versions, and some simple functions guiding the affected users. +import logging +import os +from dataclasses import dataclass +from typing import Callable, List, Optional + +from localstack.utils.analytics import log + +LOG = logging.getLogger(__name__) + + +@dataclass +class EnvVarDeprecation: + """ + Simple class defining a deprecation of an environment variable config. + It helps keeping track of deprecations over time. + """ + + env_var: str + deprecation_version: str + deprecation_path: str = None + + @property + def is_affected(self) -> bool: + """ + Checks whether an environment is affected. + :return: true if the environment is affected / is using a deprecated config + """ + return os.environ.get(self.env_var) is not None + + +# +# List of deprecations +# +# Please make sure this is in-sync with https://docs.localstack.cloud/references/configuration/ +# +DEPRECATIONS = [ + # Since 0.11.3 - HTTP / HTTPS multiplexing + EnvVarDeprecation( + "USE_SSL", + "0.11.3", + "Each endpoint now supports multiplexing HTTP/HTTPS traffic over the same port. Please remove this environment variable.", # noqa + ), + # Since 0.12.8 - PORT_UI was removed + EnvVarDeprecation( + "PORT_WEB_UI", + "0.12.8", + "PORT_WEB_UI has been removed, and is not available anymore. Please remove this environment variable.", + ), + # Deprecated in 0.12.7, removed in 3.0.0 + EnvVarDeprecation( + "USE_SINGLE_REGION", + "0.12.7", + "LocalStack now has full multi-region support. This option has no effect. Please remove it from your configuration.", # noqa + ), + # Deprecated in 0.12.7, removed in 3.0.0 + EnvVarDeprecation( + "DEFAULT_REGION", + "0.12.7", + "LocalStack now has full multi-region support. This option has no effect. Please remove it from your configuration.", # noqa + ), + # Since 1.0.0 - New Persistence and file system + EnvVarDeprecation( + "DATA_DIR", + "1.0.0", + "Please use PERSISTENCE instead. The state will be stored in your LocalStack volume in the state/ directory.", + ), + EnvVarDeprecation( + "HOST_TMP_FOLDER", + "1.0.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + EnvVarDeprecation( + "LEGACY_DIRECTORIES", + "1.0.0", + "This option has no effect anymore. Please migrate to the new filesystem layout (introduced with v1.0).", + ), + EnvVarDeprecation( + "TMPDIR", "1.0.0", "Please migrate to the new filesystem layout (introduced with v1.0)." + ), + EnvVarDeprecation( + "PERSISTENCE_SINGLE_FILE", + "1.0.0", + "The legacy persistence mechanism is not supported anymore, please migrate to the advanced persistence mechanism of LocalStack Pro.", # noqa + ), + # Since 1.0.0 - New ASF Gateway + EnvVarDeprecation( + "LEGACY_EDGE_PROXY", + "1.0.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + # Since 1.1.0 - Kinesalite removed with 1.3, only kinesis-mock is used as kinesis provider / backend + EnvVarDeprecation( + "KINESIS_PROVIDER", + "1.1.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + # Since 1.1.0 - Init dir has been deprecated in favor of pluggable init hooks + EnvVarDeprecation( + "LEGACY_INIT_DIR", + "1.1.0", + "This option has no effect anymore. " + "Please use the pluggable initialization hooks in /etc/localhost/init/<stage>.d instead.", + ), + EnvVarDeprecation( + "INIT_SCRIPTS_PATH", + "1.1.0", + "This option has no effect anymore. " + "Please use the pluggable initialization hooks in /etc/localhost/init/<stage>.d instead.", + ), + # Since 1.3.0 - Synchronous events break AWS parity + EnvVarDeprecation( + "SYNCHRONOUS_SNS_EVENTS", + "1.3.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + EnvVarDeprecation( + "SYNCHRONOUS_SQS_EVENTS", + "1.3.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + EnvVarDeprecation( + "SYNCHRONOUS_API_GATEWAY_EVENTS", + "1.3.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + EnvVarDeprecation( + "SYNCHRONOUS_KINESIS_EVENTS", + "1.3.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + EnvVarDeprecation( + "SYNCHRONOUS_DYNAMODB_EVENTS", + "1.3.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + # Since 1.3.0 - All non-pre-seeded infra is downloaded asynchronously + EnvVarDeprecation( + "SKIP_INFRA_DOWNLOADS", + "1.3.0", + "Infra downloads are triggered on-demand now. Please remove this environment variable.", + ), + # Since 1.3.0 - Mocking for unimplemented operations will be removed + EnvVarDeprecation( + "MOCK_UNIMPLEMENTED", + "1.3.0", + "This feature is not supported anymore. Please remove this environment variable.", + ), + # Since 1.4.0 - The Edge Forwarding is only used for legacy HTTPS proxying and will be removed + EnvVarDeprecation( + "EDGE_FORWARD_URL", + "1.4.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + # Deprecated in 1.4.0, removed in 3.0.0 + EnvVarDeprecation( + "KMS_PROVIDER", + "1.4.0", + "This option has no effect. Please remove it from your configuration.", + ), + # Since 2.0.0 - HOSTNAME_EXTERNAL will be replaced with LOCALSTACK_HOST + EnvVarDeprecation( + "HOSTNAME_EXTERNAL", + "2.0.0", + "This configuration will be migrated to LOCALSTACK_HOST", + ), + # Since 2.0.0 - LOCALSTACK_HOST will be replaced with LOCALSTACK_HOST + EnvVarDeprecation( + "LOCALSTACK_HOSTNAME", + "2.0.0", + "This configuration will be migrated to LOCALSTACK_HOST", + ), + # Since 2.0.0 - redefined as GATEWAY_LISTEN + EnvVarDeprecation( + "EDGE_BIND_HOST", + "2.0.0", + "This configuration will be migrated to GATEWAY_LISTEN", + ), + # Since 2.0.0 - redefined as GATEWAY_LISTEN + EnvVarDeprecation( + "EDGE_PORT", + "2.0.0", + "This configuration will be migrated to GATEWAY_LISTEN", + ), + # Since 2.0.0 - redefined as GATEWAY_LISTEN + EnvVarDeprecation( + "EDGE_PORT_HTTP", + "2.0.0", + "This configuration will be migrated to GATEWAY_LISTEN", + ), + EnvVarDeprecation( + "LAMBDA_EXECUTOR", + "2.0.0", + "This configuration is obsolete with the new lambda provider " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2\n" + "Please mount the Docker socket /var/run/docker.sock as a volume when starting LocalStack.", + ), + EnvVarDeprecation( + "LAMBDA_STAY_OPEN_MODE", + "2.0.0", + "Stay open mode is the default behavior in the new lambda provider " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "LAMBDA_REMOTE_DOCKER", + "2.0.0", + "The new lambda provider copies zip files by default and automatically configures hot reloading " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "LAMBDA_CODE_EXTRACT_TIME", + "2.0.0", + "Function creation now happens asynchronously in the new lambda provider " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "LAMBDA_CONTAINER_REGISTRY", + "2.0.0", + "The new lambda provider uses LAMBDA_RUNTIME_IMAGE_MAPPING instead " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "LAMBDA_FALLBACK_URL", + "2.0.0", + "This feature is not supported in the new lambda provider " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "LAMBDA_FORWARD_URL", + "2.0.0", + "This feature is not supported in the new lambda provider " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "LAMBDA_XRAY_INIT", + "2.0.0", + "The X-Ray daemon is always initialized in the new lambda provider " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "KINESIS_INITIALIZE_STREAMS", + "1.4.0", + "This option has no effect anymore. Please use the AWS client and init hooks instead.", + ), + EnvVarDeprecation( + "SQS_PORT_EXTERNAL", + "1.0.0", + "This option has no effect anymore. Please use LOCALSTACK_HOST instead.", + ), + EnvVarDeprecation( + "PROVIDER_OVERRIDE_LAMBDA", + "3.0.0", + "This option is ignored because the legacy Lambda provider (v1) has been removed since 3.0.0. " + "Please remove PROVIDER_OVERRIDE_LAMBDA and migrate to our new Lambda provider (v2): " + "https://docs.localstack.cloud/user-guide/aws/lambda/#migrating-to-lambda-v2", + ), + EnvVarDeprecation( + "ES_CUSTOM_BACKEND", + "0.14.0", + "This option has no effect anymore. Please use OPENSEARCH_CUSTOM_BACKEND instead.", + ), + EnvVarDeprecation( + "ES_MULTI_CLUSTER", + "0.14.0", + "This option has no effect anymore. Please use OPENSEARCH_MULTI_CLUSTER instead.", + ), + EnvVarDeprecation( + "ES_ENDPOINT_STRATEGY", + "0.14.0", + "This option has no effect anymore. Please use OPENSEARCH_ENDPOINT_STRATEGY instead.", + ), + EnvVarDeprecation( + "PERSIST_ALL", + "2.3.2", + "LocalStack treats backends and assets the same with respect to persistence. Please remove PERSIST_ALL.", + ), + EnvVarDeprecation( + "DNS_LOCAL_NAME_PATTERNS", + "3.0.0", + "This option was confusingly named. Please use DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM " + "instead.", + ), + EnvVarDeprecation( + "LAMBDA_EVENTS_INTERNAL_SQS", + "4.0.0", + "This option is ignored because the LocalStack SQS dependency for event invokes has been removed since 4.0.0" + " in favor of a lightweight Lambda-internal SQS implementation.", + ), + EnvVarDeprecation( + "LAMBDA_EVENT_SOURCE_MAPPING", + "4.0.0", + "This option has no effect anymore. Please remove this environment variable.", + ), + EnvVarDeprecation( + "LAMBDA_SQS_EVENT_SOURCE_MAPPING_INTERVAL_SEC", + "4.0.0", + "This option is not supported by the new Lambda Event Source Mapping v2 implementation." + " Please create a GitHub issue if you experience any performance challenges.", + ), + EnvVarDeprecation( + "PROVIDER_OVERRIDE_STEPFUNCTIONS", + "4.0.0", + "This option is ignored because the legacy StepFunctions provider (v1) has been removed since 4.0.0." + " Please remove PROVIDER_OVERRIDE_STEPFUNCTIONS.", + ), + EnvVarDeprecation( + "EVENT_RULE_ENGINE", + "4.0.3", + "This option is ignored because the Java-based event ruler has been removed since 4.1.0." + " Our latest Python-native implementation introduced in 4.0.3" + " is faster, achieves great AWS parity, and fixes compatibility issues with the StepFunctions JSONata feature." + " Please remove EVENT_RULE_ENGINE.", + ), + EnvVarDeprecation( + "STEPFUNCTIONS_LAMBDA_ENDPOINT", + "4.0.0", + "This is only supported for the legacy provider. URL to use as the Lambda service endpoint in Step Functions. " + "By default this is the LocalStack Lambda endpoint. Use default to select the original AWS Lambda endpoint.", + ), + EnvVarDeprecation( + "LOCAL_PORT_STEPFUNCTIONS", + "4.0.0", + "This is only supported for the legacy provider." + "It defines the local port to which Step Functions traffic is redirected." + "By default, LocalStack routes Step Functions traffic to its internal runtime. " + "Use this variable only if you need to redirect traffic to a different local Step Functions runtime.", + ), +] + + +def collect_affected_deprecations( + deprecations: Optional[List[EnvVarDeprecation]] = None, +) -> List[EnvVarDeprecation]: + """ + Collects all deprecations which are used in the OS environ. + :param deprecations: List of deprecations to check. Uses DEPRECATIONS list by default. + :return: List of deprecations which are used in the current environment + """ + if deprecations is None: + deprecations = DEPRECATIONS + return [deprecation for deprecation in deprecations if deprecation.is_affected] + + +def log_env_warning(deprecations: List[EnvVarDeprecation]) -> None: + """ + Logs warnings for the given deprecations. + :param deprecations: list of affected deprecations to show a warning for + """ + """ + Logs a warning if a given environment variable is set (no matter what the value is). + :param env_var: to check + :param deprecation_version: version with which the env variable has been deprecated + """ + if deprecations: + env_vars = [] + + # Print warnings for the env vars and collect them (for the analytics event) + for deprecation in deprecations: + LOG.warning( + "%s is deprecated (since %s) and will be removed in upcoming releases of LocalStack! %s", + deprecation.env_var, + deprecation.deprecation_version, + deprecation.deprecation_path, + ) + env_vars.append(deprecation.env_var) + + # Log an event if deprecated env vars are used + log.event(event="deprecated_env_usage", payload={"deprecated_env_vars": env_vars}) + + +def log_deprecation_warnings(deprecations: Optional[List[EnvVarDeprecation]] = None) -> None: + affected_deprecations = collect_affected_deprecations(deprecations) + log_env_warning(affected_deprecations) + + provider_override_events = os.environ.get("PROVIDER_OVERRIDE_EVENTS") + if provider_override_events and provider_override_events in ["v1", "legacy"]: + env_var_value = f"PROVIDER_OVERRIDE_EVENTS={provider_override_events}" + deprecation_version = "4.0.0" + deprecation_path = f"Remove {env_var_value} to use the new EventBridge implementation." + LOG.warning( + "%s is deprecated (since %s) and will be removed in upcoming releases of LocalStack! %s", + env_var_value, + deprecation_version, + deprecation_path, + ) + + +def deprecated_endpoint( + endpoint: Callable, previous_path: str, deprecation_version: str, new_path: str +) -> Callable: + """ + Wrapper function which logs a warning (and a deprecation path) whenever a deprecated URL is invoked by the router. + + :param endpoint: to wrap (log a warning whenever it is invoked) + :param previous_path: route path it is triggered by + :param deprecation_version: version of LocalStack with which this endpoint is deprecated + :param new_path: new route path which should be used instead + :return: wrapped function which can be registered for a route + """ + + def deprecated_wrapper(*args, **kwargs): + LOG.warning( + "%s is deprecated (since %s) and will be removed in upcoming releases of LocalStack! Use %s instead.", + previous_path, + deprecation_version, + new_path, + ) + return endpoint(*args, **kwargs) + + return deprecated_wrapper diff --git a/localstack/services/apigateway/__init__.py b/localstack-core/localstack/dev/__init__.py similarity index 100% rename from localstack/services/apigateway/__init__.py rename to localstack-core/localstack/dev/__init__.py diff --git a/localstack/services/awslambda/__init__.py b/localstack-core/localstack/dev/debugger/__init__.py similarity index 100% rename from localstack/services/awslambda/__init__.py rename to localstack-core/localstack/dev/debugger/__init__.py diff --git a/localstack-core/localstack/dev/debugger/plugins.py b/localstack-core/localstack/dev/debugger/plugins.py new file mode 100644 index 0000000000000..aa1d163f57b85 --- /dev/null +++ b/localstack-core/localstack/dev/debugger/plugins.py @@ -0,0 +1,25 @@ +import logging + +from localstack import config, constants +from localstack.runtime import hooks + +LOG = logging.getLogger(__name__) + + +def enable_debugger(): + from localstack.packages.debugpy import debugpy_package + + debugpy_package.install() + import debugpy # noqa: T100 + + LOG.info("Starting debug server at: %s:%s", constants.BIND_HOST, config.DEVELOP_PORT) + debugpy.listen((constants.BIND_HOST, config.DEVELOP_PORT)) # noqa: T100 + + if config.WAIT_FOR_DEBUGGER: + debugpy.wait_for_client() # noqa: T100 + + +@hooks.on_infra_start() +def conditionally_enable_debugger(): + if config.DEVELOP: + enable_debugger() diff --git a/localstack/services/cloudformation/__init__.py b/localstack-core/localstack/dev/kubernetes/__init__.py similarity index 100% rename from localstack/services/cloudformation/__init__.py rename to localstack-core/localstack/dev/kubernetes/__init__.py diff --git a/localstack-core/localstack/dev/kubernetes/__main__.py b/localstack-core/localstack/dev/kubernetes/__main__.py new file mode 100644 index 0000000000000..8935027298ef0 --- /dev/null +++ b/localstack-core/localstack/dev/kubernetes/__main__.py @@ -0,0 +1,330 @@ +import dataclasses +import os +from typing import Literal + +import click +import yaml + + +@dataclasses.dataclass +class MountPoint: + name: str + host_path: str + container_path: str + node_path: str + read_only: bool = True + volume_type: Literal["Directory", "File"] = "Directory" + + +def generate_mount_points( + pro: bool = False, mount_moto: bool = False, mount_entrypoints: bool = False +) -> list[MountPoint]: + mount_points = [] + # host paths + root_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + localstack_code_path = os.path.join(root_path, "localstack-core", "localstack") + pro_path = os.path.join(root_path, "..", "localstack-ext") + + # container paths + target_path = "/opt/code/localstack/" + venv_path = os.path.join(target_path, ".venv", "lib", "python3.11", "site-packages") + + # Community code + if pro: + # Pro installs community code as a package, so it lives in the venv site-packages + mount_points.append( + MountPoint( + name="localstack", + host_path=os.path.normpath(localstack_code_path), + node_path="/code/localstack", + container_path=os.path.join(venv_path, "localstack"), + # Read only has to be false here, as we mount the pro code into this mount, as it is the entire namespace package + read_only=False, + ) + ) + else: + # Community does not install the localstack package in the venv, but has the code directly in `/opt/code/localstack` + mount_points.append( + MountPoint( + name="localstack", + host_path=os.path.normpath(localstack_code_path), + node_path="/code/localstack", + container_path=os.path.join(target_path, "localstack-core", "localstack"), + ) + ) + + # Pro code + if pro: + pro_code_path = os.path.join(pro_path, "localstack-pro-core", "localstack", "pro", "core") + mount_points.append( + MountPoint( + name="localstack-pro", + host_path=os.path.normpath(pro_code_path), + node_path="/code/localstack-pro", + container_path=os.path.join(venv_path, "localstack", "pro", "core"), + ) + ) + + # entrypoints + if mount_entrypoints: + if pro: + # Community entrypoints in pro image + # TODO actual package version detection + print( + "WARNING: Package version detection is not implemented." + "You need to adapt the version in the .egg-info paths to match the package version installed in the used localstack-pro image." + ) + community_version = "4.1.1.dev14" + pro_version = "4.1.1.dev16" + egg_path = os.path.join( + root_path, "localstack-core", "localstack_core.egg-info/entry_points.txt" + ) + mount_points.append( + MountPoint( + name="entry-points-community", + host_path=os.path.normpath(egg_path), + node_path="/code/entry-points-community", + container_path=os.path.join( + venv_path, f"localstack-{community_version}.egg-info", "entry_points.txt" + ), + volume_type="File", + ) + ) + # Pro entrypoints in pro image + egg_path = os.path.join( + pro_path, "localstack-pro-core", "localstack_ext.egg-info/entry_points.txt" + ) + mount_points.append( + MountPoint( + name="entry-points-pro", + host_path=os.path.normpath(egg_path), + node_path="/code/entry-points-pro", + container_path=os.path.join( + venv_path, f"localstack_ext-{pro_version}.egg-info", "entry_points.txt" + ), + volume_type="File", + ) + ) + else: + # Community entrypoints in community repo + # In the community image, the code is not installed as package, so the paths are predictable + egg_path = os.path.join( + root_path, "localstack-core", "localstack_core.egg-info/entry_points.txt" + ) + mount_points.append( + MountPoint( + name="entry-points-community", + host_path=os.path.normpath(egg_path), + node_path="/code/entry-points-community", + container_path=os.path.join( + target_path, + "localstack-core", + "localstack_core.egg-info", + "entry_points.txt", + ), + volume_type="File", + ) + ) + + if mount_moto: + moto_path = os.path.join(root_path, "..", "moto", "moto") + mount_points.append( + MountPoint( + name="moto", + host_path=os.path.normpath(moto_path), + node_path="/code/moto", + container_path=os.path.join(venv_path, "moto"), + ) + ) + return mount_points + + +def generate_k8s_cluster_config(mount_points: list[MountPoint], port: int = 4566): + volumes = [ + { + "volume": f"{mount_point.host_path}:{mount_point.node_path}", + "nodeFilters": ["server:*", "agent:*"], + } + for mount_point in mount_points + ] + + ports = [{"port": f"{port}:31566", "nodeFilters": ["server:0"]}] + + config = {"apiVersion": "k3d.io/v1alpha5", "kind": "Simple", "volumes": volumes, "ports": ports} + + return config + + +def snake_to_kebab_case(string: str): + return string.lower().replace("_", "-") + + +def generate_k8s_cluster_overrides( + mount_points: list[MountPoint], pro: bool = False, env: list[str] | None = None +): + volumes = [ + { + "name": mount_point.name, + "hostPath": {"path": mount_point.node_path, "type": mount_point.volume_type}, + } + for mount_point in mount_points + ] + + volume_mounts = [ + { + "name": mount_point.name, + "readOnly": mount_point.read_only, + "mountPath": mount_point.container_path, + } + for mount_point in mount_points + ] + + extra_env_vars = [] + if env: + for env_variable in env: + lhs, _, rhs = env_variable.partition("=") + extra_env_vars.append( + { + "name": lhs, + "value": rhs, + } + ) + + if pro: + extra_env_vars += [ + { + "name": "LOCALSTACK_AUTH_TOKEN", + "value": "test", + }, + { + "name": "CONTAINER_RUNTIME", + "value": "kubernetes", + }, + ] + + image_repository = "localstack/localstack-pro" if pro else "localstack/localstack" + + overrides = { + "debug": True, + "volumes": volumes, + "volumeMounts": volume_mounts, + "extraEnvVars": extra_env_vars, + "image": {"repository": image_repository}, + "lambda": {"executor": "kubernetes"}, + } + + return overrides + + +def write_file(content: dict, output_path: str, file_name: str): + path = os.path.join(output_path, file_name) + with open(path, "w") as f: + f.write(yaml.dump(content)) + f.close() + print(f"Generated file at {path}") + + +def print_file(content: dict, file_name: str): + print(f"Generated file:\t{file_name}") + print("=====================================") + print(yaml.dump(content)) + print("=====================================") + + +@click.command("run") +@click.option( + "--pro", is_flag=True, default=None, help="Mount the localstack-pro code into the cluster." +) +@click.option( + "--mount-moto", is_flag=True, default=None, help="Mount the moto code into the cluster." +) +@click.option( + "--mount-entrypoints", is_flag=True, default=None, help="Mount the entrypoints into the pod." +) +@click.option( + "--write", + is_flag=True, + default=None, + help="Write the configuration and overrides to files.", +) +@click.option( + "--output-dir", + "-o", + type=click.Path(exists=True, file_okay=False, resolve_path=True), + help="Output directory for generated files.", +) +@click.option( + "--overrides-file", + "-of", + default=None, + help="Name of the overrides file (default: overrides.yml).", +) +@click.option( + "--config-file", + "-cf", + default=None, + help="Name of the configuration file (default: configuration.yml).", +) +@click.option( + "--env", "-e", default=None, help="Environment variable to set in the pod", multiple=True +) +@click.option( + "--port", + "-p", + default=4566, + help="Port to expose from the kubernetes node", + type=click.IntRange(0, 65535), +) +@click.argument("command", nargs=-1, required=False) +def run( + pro: bool = None, + mount_moto: bool = False, + mount_entrypoints: bool = False, + write: bool = False, + output_dir=None, + overrides_file: str = None, + config_file: str = None, + command: str = None, + env: list[str] = None, + port: int = None, +): + """ + A tool for localstack developers to generate the kubernetes cluster configuration file and the overrides to mount the localstack code into the cluster. + """ + mount_points = generate_mount_points(pro, mount_moto, mount_entrypoints) + + config = generate_k8s_cluster_config(mount_points, port=port) + + overrides = generate_k8s_cluster_overrides(mount_points, pro=pro, env=env) + + output_dir = output_dir or os.getcwd() + overrides_file = overrides_file or "overrides.yml" + config_file = config_file or "configuration.yml" + + if write: + write_file(config, output_dir, config_file) + write_file(overrides, output_dir, overrides_file) + else: + print_file(config, config_file) + print_file(overrides, overrides_file) + + overrides_file_path = os.path.join(output_dir, overrides_file) + config_file_path = os.path.join(output_dir, config_file) + + print("\nTo create a k3d cluster with the generated configuration, follow these steps:") + print("1. Run the following command to create the cluster:") + print(f"\n k3d cluster create --config {config_file_path}\n") + + print("2. Once the cluster is created, start LocalStack with the generated overrides:") + print("\n helm repo add localstack https://localstack.github.io/helm-charts # (if required)") + print( + f"\n helm upgrade --install localstack localstack/localstack -f {overrides_file_path}\n" + ) + + +def main(): + run() + + +if __name__ == "__main__": + main() diff --git a/localstack/services/dynamodb/__init__.py b/localstack-core/localstack/dev/run/__init__.py similarity index 100% rename from localstack/services/dynamodb/__init__.py rename to localstack-core/localstack/dev/run/__init__.py diff --git a/localstack-core/localstack/dev/run/__main__.py b/localstack-core/localstack/dev/run/__main__.py new file mode 100644 index 0000000000000..39ab236c9e3c2 --- /dev/null +++ b/localstack-core/localstack/dev/run/__main__.py @@ -0,0 +1,408 @@ +import dataclasses +import os +from typing import Iterable, Tuple + +import click +from rich.rule import Rule + +from localstack import config +from localstack.cli import console +from localstack.runtime import hooks +from localstack.utils.bootstrap import Container, ContainerConfigurators +from localstack.utils.container_utils.container_client import ( + ContainerConfiguration, + PortMappings, + VolumeMappings, +) +from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient +from localstack.utils.files import cache_dir +from localstack.utils.run import run_interactive +from localstack.utils.strings import short_uid + +from .configurators import ( + ConfigEnvironmentConfigurator, + DependencyMountConfigurator, + EntryPointMountConfigurator, + ImageConfigurator, + PortConfigurator, + SourceVolumeMountConfigurator, +) +from .paths import HOST_PATH_MAPPINGS, HostPaths + + +@click.command("run") +@click.option( + "--image", + type=str, + required=False, + help="Overwrite the container image to be used (defaults to localstack/localstack or " + "localstack/localstack-pro).", +) +@click.option( + "--volume-dir", + type=click.Path(file_okay=False, dir_okay=True), + required=False, + help="The localstack volume on the host, default: ~/.cache/localstack/volume", +) +@click.option( + "--pro/--community", + is_flag=True, + default=None, + help="Whether to start localstack pro or community. If not set, it will guess from the current directory", +) +@click.option( + "--develop/--no-develop", + is_flag=True, + default=False, + help="Install debugpy and expose port 5678", +) +@click.option( + "--randomize", + is_flag=True, + default=False, + help="Randomize container name and ports to start multiple instances", +) +@click.option( + "--mount-source/--no-mount-source", + is_flag=True, + default=True, + help="Mount source files from localstack and localstack-ext. Use --local-packages for optional dependencies such as moto.", +) +@click.option( + "--mount-dependencies/--no-mount-dependencies", + is_flag=True, + default=False, + help="Whether to mount the dependencies of the current .venv directory into the container. Note this only works if the dependencies are compatible with the python and platform version from the venv and the container.", +) +@click.option( + "--mount-entrypoints/--no-mount-entrypoints", + is_flag=True, + default=False, + help="Mount entrypoints", +) +@click.option("--mount-docker-socket/--no-docker-socket", is_flag=True, default=True) +@click.option( + "--env", + "-e", + help="Additional environment variables that are passed to the LocalStack container", + multiple=True, + required=False, +) +@click.option( + "--volume", + "-v", + help="Additional volume mounts that are passed to the LocalStack container", + multiple=True, + required=False, +) +@click.option( + "--publish", + "-p", + help="Additional ports that are published to the host", + multiple=True, + required=False, +) +@click.option( + "--entrypoint", + type=str, + required=False, + help="Additional entrypoint flag passed to docker", +) +@click.option( + "--network", + type=str, + required=False, + help="Docker network to start the container in", +) +@click.option( + "--local-packages", + "-l", + multiple=True, + required=False, + type=click.Choice(HOST_PATH_MAPPINGS.keys(), case_sensitive=False), + help="Mount specified packages into the container", +) +@click.argument("command", nargs=-1, required=False) +def run( + image: str = None, + volume_dir: str = None, + pro: bool = None, + develop: bool = False, + randomize: bool = False, + mount_source: bool = True, + mount_dependencies: bool = False, + mount_entrypoints: bool = False, + mount_docker_socket: bool = True, + env: Tuple = (), + volume: Tuple = (), + publish: Tuple = (), + entrypoint: str = None, + network: str = None, + local_packages: list[str] | None = None, + command: str = None, +): + """ + A tool for localstack developers to start localstack containers. Run this in your localstack or + localstack-ext source tree to mount local source files or dependencies into the container. + Here are some examples:: + + \b + python -m localstack.dev.run + python -m localstack.dev.run -e DEBUG=1 -e LOCALSTACK_AUTH_TOKEN=test + python -m localstack.dev.run -- bash -c 'echo "hello"' + + Explanations and more examples: + + Start a normal container localstack container. If you run this from the localstack-ext repo, + it will start localstack-pro:: + + python -m localstack.dev.run + + If you start localstack-pro, you might also want to add the API KEY as environment variable:: + + python -m localstack.dev.run -e DEBUG=1 -e LOCALSTACK_AUTH_TOKEN=test + + If your local changes are making modifications to plux plugins (e.g., adding new providers or hooks), + then you also want to mount the newly generated entry_point.txt files into the container:: + + python -m localstack.dev.run --mount-entrypoints + + Start a new container with randomized gateway and service ports, and randomized container name:: + + python -m localstack.dev.run --randomize + + You can also run custom commands: + + python -m localstack.dev.run bash -c 'echo "hello"' + + Or use custom entrypoints: + + python -m localstack.dev.run --entrypoint /bin/bash -- echo "hello" + + You can import and expose debugpy: + + python -m localstack.dev.run --develop + + You can also mount local dependencies (e.g., pytest and other test dependencies, and then use that + in the container):: + + \b + python -m localstack.dev.run --mount-dependencies \\ + -v $PWD/tests:/opt/code/localstack/tests \\ + -- .venv/bin/python -m pytest tests/unit/http_/ + + The script generally assumes that you are executing in either localstack or localstack-ext source + repositories that are organized like this:: + + \b + somedir <- your workspace directory + β”œβ”€β”€ localstack <- execute script in here + β”‚ β”œβ”€β”€ ... + β”‚ β”œβ”€β”€ localstack-core + β”‚ β”‚ β”œβ”€β”€ localstack <- will be mounted into the container + β”‚ β”‚ └── localstack_core.egg-info + β”‚ β”œβ”€β”€ pyproject.toml + β”‚ β”œβ”€β”€ tests + β”‚ └── ... + β”œβ”€β”€ localstack-ext <- or execute script in here + β”‚ β”œβ”€β”€ ... + β”‚ β”œβ”€β”€ localstack-pro-core + β”‚ β”‚ β”œβ”€β”€ localstack + β”‚ β”‚ β”‚ └── pro + β”‚ β”‚ β”‚ └── core <- will be mounted into the container + β”‚ β”‚ β”œβ”€β”€ localstack_ext.egg-info + β”‚ β”‚ β”œβ”€β”€ pyproject.toml + β”‚ β”‚ └── tests + β”‚ └── ... + β”œβ”€β”€ moto + β”‚ β”œβ”€β”€ AUTHORS.md + β”‚ β”œβ”€β”€ ... + β”‚ β”œβ”€β”€ moto <- will be mounted into the container + β”‚ β”œβ”€β”€ moto_ext.egg-info + β”‚ β”œβ”€β”€ pyproject.toml + β”‚ β”œβ”€β”€ tests + β”‚ └── ... + + You can choose which local source repositories are mounted in. For example, if `moto` and `rolo` are + both present, only mount `rolo` into the container. + + \b + python -m localstack.dev.run --local-packages rolo + + If both `rolo` and `moto` are available and both should be mounted, use the flag twice. + + \b + python -m localstack.dev.run --local-packages rolo --local-packages moto + """ + with console.status("Configuring") as status: + env_vars = parse_env_vars(env) + configure_licensing_credentials_environment(env_vars) + + # run all prepare_host hooks + hooks.prepare_host.run() + + # set the VOLUME_DIR config variable like in the CLI + if not os.environ.get("LOCALSTACK_VOLUME_DIR", "").strip(): + config.VOLUME_DIR = str(cache_dir() / "volume") + + # setup important paths on the host + host_paths = HostPaths( + # we assume that python -m localstack.dev.run is always executed in the repo source + workspace_dir=os.path.abspath(os.path.join(os.getcwd(), "..")), + volume_dir=volume_dir or config.VOLUME_DIR, + ) + + # auto-set pro flag + if pro is None: + if os.getcwd().endswith("localstack-ext"): + pro = True + else: + pro = False + + # setup base configuration + container_config = ContainerConfiguration( + image_name=image, + name=config.MAIN_CONTAINER_NAME if not randomize else f"localstack-{short_uid()}", + remove=True, + interactive=True, + tty=True, + env_vars=dict(), + volumes=VolumeMappings(), + ports=PortMappings(), + network=network, + ) + + # replicate pro startup + if pro: + try: + from localstack.pro.core.plugins import modify_gateway_listen_config + + modify_gateway_listen_config(config) + except ImportError: + pass + + # setup configurators + configurators = [ + ImageConfigurator(pro, image), + PortConfigurator(randomize), + ConfigEnvironmentConfigurator(pro), + ContainerConfigurators.mount_localstack_volume(host_paths.volume_dir), + ContainerConfigurators.config_env_vars, + ] + + # create stub container with configuration to apply + c = Container(container_config=container_config) + + # apply existing hooks first that can later be overwritten + hooks.configure_localstack_container.run(c) + + if command: + configurators.append(ContainerConfigurators.custom_command(list(command))) + if entrypoint: + container_config.entrypoint = entrypoint + if mount_docker_socket: + configurators.append(ContainerConfigurators.mount_docker_socket) + if mount_source: + configurators.append( + SourceVolumeMountConfigurator( + host_paths=host_paths, + pro=pro, + chosen_packages=local_packages, + ) + ) + if mount_entrypoints: + configurators.append(EntryPointMountConfigurator(host_paths=host_paths, pro=pro)) + if mount_dependencies: + configurators.append(DependencyMountConfigurator(host_paths=host_paths)) + if develop: + configurators.append(ContainerConfigurators.develop) + + # make sure anything coming from CLI arguments has priority + configurators.extend( + [ + ContainerConfigurators.volume_cli_params(volume), + ContainerConfigurators.port_cli_params(publish), + ContainerConfigurators.env_cli_params(env), + ] + ) + + # run configurators + for configurator in configurators: + configurator(container_config) + # print the config + print_config(container_config) + + # run the container + docker = CmdDockerClient() + status.update("Creating container") + container_id = docker.create_container_from_config(container_config) + + rule = Rule(f"Interactive session with {container_id[:12]} πŸ’»") + console.print(rule) + try: + cmd = [*docker._docker_cmd(), "start", "--interactive", "--attach", container_id] + run_interactive(cmd) + finally: + if container_config.remove: + try: + if docker.is_container_running(container_id): + docker.stop_container(container_id) + docker.remove_container(container_id) + except Exception: + pass + + +def print_config(cfg: ContainerConfiguration): + d = dataclasses.asdict(cfg) + + d["volumes"] = [v.to_str() for v in d["volumes"].mappings] + d["ports"] = [p for p in d["ports"].to_list() if p != "-p"] + + for k in list(d.keys()): + if d[k] is None: + d.pop(k) + + console.print(d) + + +def parse_env_vars(params: Iterable[str] = None) -> dict[str, str]: + env = {} + + if not params: + return env + + for e in params: + if "=" in e: + k, v = e.split("=", maxsplit=1) + env[k] = v + else: + # there's currently no way in our abstraction to only pass the variable name (as + # you can do in docker) so we resolve the value here. + env[e] = os.getenv(e) + + return env + + +def configure_licensing_credentials_environment(env_vars: dict[str, str]): + """ + If an api key or auth token is set in the parsed CLI parameters, then we also set them into the OS environment + unless they are already set. This is just convenience so you don't have to set them twice. + + :param env_vars: the environment variables parsed from the CLI parameters + """ + if os.environ.get("LOCALSTACK_API_KEY"): + return + if os.environ.get("LOCALSTACK_AUTH_TOKEN"): + return + if api_key := env_vars.get("LOCALSTACK_API_KEY"): + os.environ["LOCALSTACK_API_KEY"] = api_key + if api_key := env_vars.get("LOCALSTACK_AUTH_TOKEN"): + os.environ["LOCALSTACK_AUTH_TOKEN"] = api_key + + +def main(): + run() + + +if __name__ == "__main__": + main() diff --git a/localstack-core/localstack/dev/run/configurators.py b/localstack-core/localstack/dev/run/configurators.py new file mode 100644 index 0000000000000..4f1b9e3e29cde --- /dev/null +++ b/localstack-core/localstack/dev/run/configurators.py @@ -0,0 +1,375 @@ +""" +Several ContainerConfigurator implementations to set up a development version of a localstack container. +""" + +import gzip +import os +from pathlib import Path, PurePosixPath +from tempfile import gettempdir + +from localstack import config, constants +from localstack.utils.bootstrap import ContainerConfigurators +from localstack.utils.container_utils.container_client import ( + BindMount, + ContainerClient, + ContainerConfiguration, + VolumeMappings, +) +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.files import get_user_cache_dir +from localstack.utils.run import run +from localstack.utils.strings import md5 + +from .paths import ( + HOST_PATH_MAPPINGS, + CommunityContainerPaths, + ContainerPaths, + HostPaths, + ProContainerPaths, +) + + +class ConfigEnvironmentConfigurator: + """Configures the environment variables from the localstack and localstack-pro config.""" + + def __init__(self, pro: bool): + self.pro = pro + + def __call__(self, cfg: ContainerConfiguration): + if cfg.env_vars is None: + cfg.env_vars = {} + + if self.pro: + # import localstack.pro.core.config extends the list of config vars + from localstack.pro.core import config as config_pro # noqa + + ContainerConfigurators.config_env_vars(cfg) + + +class PortConfigurator: + """ + Configures the port mappings. Can be randomized to run multiple localstack instances. + """ + + def __init__(self, randomize: bool = True): + self.randomize = randomize + + def __call__(self, cfg: ContainerConfiguration): + cfg.ports.bind_host = config.GATEWAY_LISTEN[0].host + + if self.randomize: + ContainerConfigurators.random_gateway_port(cfg) + ContainerConfigurators.random_service_port_range()(cfg) + else: + ContainerConfigurators.gateway_listen(config.GATEWAY_LISTEN)(cfg) + ContainerConfigurators.service_port_range(cfg) + + +class ImageConfigurator: + """ + Sets the container image to use for the container (by default either localstack/localstack or + localstack/localstack-pro) + """ + + def __init__(self, pro: bool, image_name: str | None): + self.pro = pro + self.image_name = image_name + + def __call__(self, cfg: ContainerConfiguration): + if self.image_name: + cfg.image_name = self.image_name + else: + if self.pro: + cfg.image_name = constants.DOCKER_IMAGE_NAME_PRO + else: + cfg.image_name = constants.DOCKER_IMAGE_NAME + + +class CustomEntryPointConfigurator: + """ + Creates a ``docker-entrypoint-<hash>.sh`` script from the given source and mounts it into the container. + It also configures the container to then use that entrypoint. + """ + + def __init__(self, script: str, tmp_dir: str = None): + self.script = script.lstrip(os.linesep) + self.container_paths = ProContainerPaths() + self.tmp_dir = tmp_dir + + def __call__(self, cfg: ContainerConfiguration): + h = md5(self.script) + tempdir = gettempdir() if not self.tmp_dir else self.tmp_dir + file_name = f"docker-entrypoint-{h}.sh" + + file = Path(tempdir, file_name) + if not file.exists(): + # newline separator should be '\n' independent of the os, since the entrypoint is executed in the container + # encoding needs to be "utf-8" since scripts could include emojis + file.write_text(self.script, newline="\n", encoding="utf-8") + file.chmod(0o777) + cfg.volumes.add(BindMount(str(file), f"/tmp/{file.name}")) + cfg.entrypoint = f"/tmp/{file.name}" + + +class SourceVolumeMountConfigurator: + """ + Mounts source code of localstack, localstack_ext, and moto into the container. It does this by assuming + that there is a "workspace" directory in which the source repositories are checked out into. + Depending on whether we want to start the pro container, the source paths for localstack are different. + """ + + def __init__( + self, + *, + host_paths: HostPaths = None, + pro: bool = False, + chosen_packages: list[str] | None = None, + ): + self.host_paths = host_paths or HostPaths() + self.container_paths = ProContainerPaths() if pro else CommunityContainerPaths() + self.pro = pro + self.chosen_packages = chosen_packages or [] + + def __call__(self, cfg: ContainerConfiguration): + # localstack source code if available + source = self.host_paths.aws_community_package_dir + if source.exists(): + cfg.volumes.add( + # read_only=False is a temporary workaround to make the mounting of the pro source work + # this can be reverted once we don't need the nested mounting anymore + BindMount(str(source), self.container_paths.localstack_source_dir, read_only=False) + ) + + # ext source code if available + if self.pro: + source = self.host_paths.aws_pro_package_dir + if source.exists(): + cfg.volumes.add( + BindMount( + str(source), self.container_paths.localstack_pro_source_dir, read_only=True + ) + ) + + # mount local code checkouts if possible + for package_name in self.chosen_packages: + # Unconditional lookup because the CLI rejects incorect items + extractor = HOST_PATH_MAPPINGS[package_name] + self.try_mount_to_site_packages(cfg, extractor(self.host_paths)) + + # docker entrypoint + if self.pro: + source = self.host_paths.localstack_pro_project_dir / "bin" / "docker-entrypoint.sh" + else: + source = self.host_paths.localstack_project_dir / "bin" / "docker-entrypoint.sh" + if source.exists(): + cfg.volumes.add( + BindMount(str(source), self.container_paths.docker_entrypoint, read_only=True) + ) + + def try_mount_to_site_packages(self, cfg: ContainerConfiguration, sources_path: Path): + """ + Attempts to mount something like `~/workspace/plux/plugin` on the host into + ``.venv/.../site-packages/plugin``. + + :param cfg: + :param sources_path: + :return: + """ + if sources_path.exists(): + cfg.volumes.add( + BindMount( + str(sources_path), + self.container_paths.dependency_source(sources_path.name), + read_only=True, + ) + ) + + +class EntryPointMountConfigurator: + """ + Mounts ``entry_points.txt`` files of localstack and dependencies into the venv in the container. + + For example, when starting the pro container, the entrypoints of localstack-ext on the host would be in + ``~/workspace/localstack-ext/localstack-pro-core/localstack_ext.egg-info/entry_points.txt`` + which needs to be mounted into the distribution info of the installed dependency within the container: + ``/opt/code/localstack/.venv/.../site-packages/localstack_ext-2.1.0.dev0.dist-info/entry_points.txt``. + """ + + entry_point_glob = ( + "/opt/code/localstack/.venv/lib/python3.*/site-packages/*.dist-info/entry_points.txt" + ) + localstack_community_entry_points = ( + "/opt/code/localstack/localstack_core.egg-info/entry_points.txt" + ) + + def __init__( + self, + *, + host_paths: HostPaths = None, + container_paths: ContainerPaths = None, + pro: bool = False, + ): + self.host_paths = host_paths or HostPaths() + self.pro = pro + self.container_paths = container_paths or None + + def __call__(self, cfg: ContainerConfiguration): + # special case for community code + if not self.pro: + host_path = self.host_paths.aws_community_package_dir + if host_path.exists(): + cfg.volumes.append( + BindMount( + str(host_path), self.localstack_community_entry_points, read_only=True + ) + ) + + # locate all relevant entry_point.txt files within the container + pattern = self.entry_point_glob + files = _list_files_in_container_image(DOCKER_CLIENT, cfg.image_name) + paths = [PurePosixPath(f) for f in files] + paths = [p for p in paths if p.match(pattern)] + + # then, check whether they exist in some form on the host within the workspace directory + for container_path in paths: + dep_path = container_path.parent.name.removesuffix(".dist-info") + dep, ver = dep_path.split("-") + + if dep == "localstack_core": + host_path = ( + self.host_paths.localstack_project_dir + / "localstack-core" + / "localstack_core.egg-info" + / "entry_points.txt" + ) + if host_path.is_file(): + cfg.volumes.add( + BindMount( + str(host_path), + str(container_path), + read_only=True, + ) + ) + continue + elif dep == "localstack_ext": + host_path = ( + self.host_paths.localstack_pro_project_dir + / "localstack-pro-core" + / "localstack_ext.egg-info" + / "entry_points.txt" + ) + if host_path.is_file(): + cfg.volumes.add( + BindMount( + str(host_path), + str(container_path), + read_only=True, + ) + ) + continue + for host_path in self.host_paths.workspace_dir.glob( + f"*/{dep}.egg-info/entry_points.txt" + ): + cfg.volumes.add(BindMount(str(host_path), str(container_path), read_only=True)) + break + + +class DependencyMountConfigurator: + """ + Mounts source folders from your host's .venv directory into the container's .venv. + """ + + dependency_glob = "/opt/code/localstack/.venv/lib/python3.*/site-packages/*" + + # skip mounting dependencies with incompatible binaries (e.g., on macOS) + skipped_dependencies = ["cryptography", "psutil", "rpds"] + + def __init__( + self, + *, + host_paths: HostPaths = None, + container_paths: ContainerPaths = None, + pro: bool = False, + ): + self.host_paths = host_paths or HostPaths() + self.pro = pro + self.container_paths = container_paths or ( + ProContainerPaths() if pro else CommunityContainerPaths() + ) + + def __call__(self, cfg: ContainerConfiguration): + # locate all relevant dependency directories + pattern = self.dependency_glob + files = _list_files_in_container_image(DOCKER_CLIENT, cfg.image_name) + paths = [PurePosixPath(f) for f in files] + # builds an index of "jinja2: /opt/code/.../site-packages/jinja2" + container_path_index = {p.name: p for p in paths if p.match(pattern)} + + # find dependencies from the host + for dep_path in self.host_paths.venv_dir.glob("lib/python3.*/site-packages/*"): + # filter out everything that heuristically cannot be a source path + if not self._can_be_source_path(dep_path): + continue + if dep_path.name.endswith(".dist-info"): + continue + if dep_path.name == "__pycache__": + continue + + if dep_path.name in self.skipped_dependencies: + continue + + if dep_path.name in container_path_index: + # find the target path in the index if it exists + target_path = str(container_path_index[dep_path.name]) + else: + # if the given dependency is not in the container, then we mount it anyway + # FIXME: we should also mount the dist-info directory. perhaps this method should be + # re-written completely + target_path = self.container_paths.dependency_source(dep_path.name) + + if self._has_mount(cfg.volumes, target_path): + continue + + cfg.volumes.append(BindMount(str(dep_path), target_path)) + + def _can_be_source_path(self, path: Path) -> bool: + return path.is_dir() or (path.name.endswith(".py") and not path.name.startswith("__")) + + def _has_mount(self, volumes: VolumeMappings, target_path: str) -> bool: + return True if volumes.find_target_mapping(target_path) else False + + +def _list_files_in_container_image(container_client: ContainerClient, image_name: str) -> list[str]: + """ + Uses ``docker export | tar -t`` to list all files in a given docker image. It caches the result based on + the image ID into a gziped file into ``~/.cache/localstack-dev-cli`` to (significantly) speed up + subsequent calls. + + :param container_client: the container client to use + :param image_name: the container image to analyze + :return: a list of file paths + """ + if not image_name: + raise ValueError("missing image name") + + image_id = container_client.inspect_image(image_name)["Id"] + + cache_dir = get_user_cache_dir() / "localstack-dev-cli" + cache_dir.mkdir(exist_ok=True, parents=True) + cache_file = cache_dir / f"{image_id}.files.txt.gz" + + if not cache_file.exists(): + container_id = container_client.create_container(image_name=image_name) + try: + # docker export yields paths without prefixed slashes, so we add them here + # since the file is pretty big (~4MB for community, ~7MB for pro) we gzip it + cmd = "docker export %s | tar -t | awk '{ print \"/\" $0 }' | gzip > %s" % ( + container_id, + cache_file, + ) + run(cmd, shell=True) + finally: + container_client.remove_container(container_id) + + with gzip.open(cache_file, mode="rt") as fd: + return fd.read().splitlines(keepends=False) diff --git a/localstack-core/localstack/dev/run/paths.py b/localstack-core/localstack/dev/run/paths.py new file mode 100644 index 0000000000000..b1fe9a95f24fd --- /dev/null +++ b/localstack-core/localstack/dev/run/paths.py @@ -0,0 +1,94 @@ +"""Utilities to resolve important paths on the host and in the container.""" + +import os +from pathlib import Path +from typing import Callable, Optional, Union + + +class HostPaths: + workspace_dir: Path + """We assume all repositories live in a workspace directory, e.g., ``~/workspace/ls/localstack``, + ``~/workspace/ls/localstack-ext``, ...""" + + localstack_project_dir: Path + localstack_pro_project_dir: Path + moto_project_dir: Path + postgresql_proxy: Path + rolo_dir: Path + volume_dir: Path + venv_dir: Path + + def __init__( + self, + workspace_dir: Union[os.PathLike, str] = None, + volume_dir: Union[os.PathLike, str] = None, + venv_dir: Union[os.PathLike, str] = None, + ): + self.workspace_dir = Path(workspace_dir or os.path.abspath(os.path.join(os.getcwd(), ".."))) + self.localstack_project_dir = self.workspace_dir / "localstack" + self.localstack_pro_project_dir = self.workspace_dir / "localstack-ext" + self.moto_project_dir = self.workspace_dir / "moto" + self.postgresql_proxy = self.workspace_dir / "postgresql-proxy" + self.rolo_dir = self.workspace_dir / "rolo" + self.volume_dir = Path(volume_dir or "/tmp/localstack") + self.venv_dir = Path( + venv_dir + or os.getenv("VIRTUAL_ENV") + or os.getenv("VENV_DIR") + or os.path.join(os.getcwd(), ".venv") + ) + + @property + def aws_community_package_dir(self) -> Path: + return self.localstack_project_dir / "localstack-core" / "localstack" + + @property + def aws_pro_package_dir(self) -> Path: + return ( + self.localstack_pro_project_dir / "localstack-pro-core" / "localstack" / "pro" / "core" + ) + + +# Type representing how to extract a specific path from a common root path, typically a lambda function +PathMappingExtractor = Callable[[HostPaths], Path] + +# Declaration of which local packages can be mounted into the container, and their locations on the host +HOST_PATH_MAPPINGS: dict[ + str, + PathMappingExtractor, +] = { + "moto": lambda paths: paths.moto_project_dir / "moto", + "postgresql_proxy": lambda paths: paths.postgresql_proxy / "postgresql_proxy", + "rolo": lambda paths: paths.rolo_dir / "rolo", + "plux": lambda paths: paths.workspace_dir / "plux" / "plugin", +} + + +class ContainerPaths: + """Important paths in the container""" + + project_dir: str = "/opt/code/localstack" + site_packages_target_dir: str = "/opt/code/localstack/.venv/lib/python3.11/site-packages" + docker_entrypoint: str = "/usr/local/bin/docker-entrypoint.sh" + localstack_supervisor: str = "/usr/local/bin/localstack-supervisor" + localstack_source_dir: str + localstack_pro_source_dir: Optional[str] + + def dependency_source(self, name: str) -> str: + """Returns path of the given source dependency in the site-packages directory.""" + return self.site_packages_target_dir + f"/{name}" + + +class CommunityContainerPaths(ContainerPaths): + """In the community image, code is copied into /opt/code/localstack/localstack-core/localstack""" + + def __init__(self): + self.localstack_source_dir = f"{self.project_dir}/localstack-core/localstack" + + +class ProContainerPaths(ContainerPaths): + """In the pro image, localstack and ext are installed into the venv as dependency""" + + def __init__(self): + self.localstack_source_dir = self.dependency_source("localstack") + self.localstack_pro_source_dir = self.dependency_source("localstack") + "/pro/core" diff --git a/localstack/services/dynamodbstreams/__init__.py b/localstack-core/localstack/dns/__init__.py similarity index 100% rename from localstack/services/dynamodbstreams/__init__.py rename to localstack-core/localstack/dns/__init__.py diff --git a/localstack-core/localstack/dns/models.py b/localstack-core/localstack/dns/models.py new file mode 100644 index 0000000000000..6df70bf6e0d86 --- /dev/null +++ b/localstack-core/localstack/dns/models.py @@ -0,0 +1,175 @@ +import dataclasses +from enum import Enum, auto +from typing import Callable, Protocol + + +class RecordType(Enum): + A = auto() + AAAA = auto() + CNAME = auto() + TXT = auto() + MX = auto() + SOA = auto() + NS = auto() + SRV = auto() + + +@dataclasses.dataclass(frozen=True) +class NameRecord: + """ + Dataclass of a stored record + """ + + record_type: RecordType + record_id: str | None = None + + +@dataclasses.dataclass(frozen=True) +class _TargetRecordBase: + """ + Dataclass of a stored record + """ + + target: str + + +@dataclasses.dataclass(frozen=True) +class TargetRecord(NameRecord, _TargetRecordBase): + pass + + +@dataclasses.dataclass(frozen=True) +class _SOARecordBase: + m_name: str + r_name: str + + +@dataclasses.dataclass(frozen=True) +class SOARecord(NameRecord, _SOARecordBase): + pass + + +@dataclasses.dataclass(frozen=True) +class AliasTarget: + target: str + alias_id: str | None = None + health_check: Callable[[], bool] | None = None + + +@dataclasses.dataclass(frozen=True) +class _DynamicRecordBase: + """ + Dataclass of a record that is dynamically determined at query time to return the IP address + of the LocalStack container + """ + + record_type: RecordType + + +@dataclasses.dataclass(frozen=True) +class DynamicRecord(NameRecord, _DynamicRecordBase): + pass + + +# TODO decide if we need the whole concept of multiple zones in our DNS implementation +class DnsServerProtocol(Protocol): + def add_host(self, name: str, record: NameRecord) -> None: + """ + Add a host resolution to the DNS server. + This will resolve the given host to the record provided, if it matches. + + :param name: Name pattern to add resolution for. Can be arbitrary regex. + :param record: Record, consisting of a record type, an optional record id, and the attached data. + Has to be a subclass of a NameRecord, not a NameRecord itself to contain some data. + """ + pass + + def delete_host(self, name: str, record: NameRecord) -> None: + """ + Deletes a host resolution from the DNS server. + Only the name, the record type, and optionally the given record id will be used to find entries to delete. + All matching entries will be deleted. + + :param name: Name pattern, identically to the one registered with `add_host` + :param record: Record, ideally identically to the one registered with add_host but only record_type and + record_id have to match to find the record. + + :raises ValueError: If no record that was previously registered with `add_host` was found which matches the provided record + """ + pass + + def add_host_pointing_to_localstack(self, name: str) -> None: + """ + Add a dns name which should be pointing to LocalStack when resolved. + + :param name: Name which should be pointing to LocalStack when resolved + """ + pass + + def delete_host_pointing_to_localstack(self, name: str) -> None: + """ + Removes a dns name from pointing to LocalStack + + :param name: Name to be removed + :raises ValueError: If the host pointing to LocalStack was not previously registered using `add_host_pointing_to_localstack` + """ + pass + + def add_alias(self, source_name: str, record_type: RecordType, target: AliasTarget) -> None: + """ + Adds an alias to the DNS, with an optional healthcheck callback. + When a request which matches `source_name` comes in, the DNS will check the aliases, and if the healthcheck + (if provided) succeeds, the resolution result for the `target_name` will be returned instead. + If multiple aliases are registered for the same source_name record_type tuple, and no health checks interfere, + the server will process requests with the first added alias + + :param source_name: Alias name + :param record_type: Record type of the alias + :param target: Target of the alias + """ + pass + + def delete_alias(self, source_name: str, record_type: RecordType, target: AliasTarget) -> None: + """ + Removes an alias from the DNS. + Only the name, the record type, and optionally the given alias id will be used to find entries to delete. + All matching entries will be deleted. + + :param source_name: Alias name + :param record_type: Record type of the alias to remove + :param target: Target of the alias. Only relevant data for deletion will be its id. + :raises ValueError: If the alias was not previously registered using `add_alias` + """ + pass + + # TODO: support regex or wildcard? + # need to update when custom cloudpod destination is enabled + # has standard list of skips: localstack.services.dns_server.SKIP_PATTERNS + def add_skip(self, skip_pattern: str) -> None: + """ + Add a skip pattern to the DNS server. + + A skip pattern will prevent the DNS server from resolving a matching request against it's internal zones or + aliases, and will directly contact an upstream DNS for resolution. + + This is usually helpful if AWS endpoints are overwritten by internal entries, but we have to reach AWS for + some reason. (Often used for cloudpods or installers). + + :param skip_pattern: Skip pattern to add. Can be a valid regex. + """ + pass + + def delete_skip(self, skip_pattern: str) -> None: + """ + Removes a skip pattern from the DNS server. + + :param skip_pattern: Skip pattern to remove + :raises ValueError: If the skip pattern was not previously registered using `add_skip` + """ + pass + + def clear(self): + """ + Removes all runtime configurations. + """ + pass diff --git a/localstack-core/localstack/dns/plugins.py b/localstack-core/localstack/dns/plugins.py new file mode 100644 index 0000000000000..05566573cfec8 --- /dev/null +++ b/localstack-core/localstack/dns/plugins.py @@ -0,0 +1,45 @@ +import logging + +from localstack import config +from localstack.runtime import hooks + +LOG = logging.getLogger(__name__) + +# Note: Don't want to introduce a possible import order conflict by importing SERVICE_SHUTDOWN_PRIORITY +# TODO: consider extracting these priorities into some static configuration +DNS_SHUTDOWN_PRIORITY = -30 +"""Make sure the DNS server is shut down after the ON_AFTER_SERVICE_SHUTDOWN_HANDLERS, which in turn is after +SERVICE_SHUTDOWN_PRIORITY. Currently this value needs to be less than -20""" + + +@hooks.on_infra_start(priority=10) +def start_dns_server(): + try: + from localstack.dns import server + + server.start_dns_server(port=config.DNS_PORT, asynchronous=True) + except Exception as e: + LOG.warning("Unable to start DNS: %s", e) + + +@hooks.on_infra_start() +def setup_dns_configuration_on_host(): + try: + from localstack.dns import server + + if server.is_server_running(): + # Prepare network interfaces for DNS server for the infra. + server.setup_network_configuration() + except Exception as e: + LOG.warning("error setting up dns server: %s", e) + + +@hooks.on_infra_shutdown(priority=DNS_SHUTDOWN_PRIORITY) +def stop_server(): + try: + from localstack.dns import server + + server.revert_network_configuration() + server.stop_servers() + except Exception as e: + LOG.warning("Unable to stop DNS servers: %s", e) diff --git a/localstack-core/localstack/dns/server.py b/localstack-core/localstack/dns/server.py new file mode 100644 index 0000000000000..f32d81292c75e --- /dev/null +++ b/localstack-core/localstack/dns/server.py @@ -0,0 +1,1003 @@ +import argparse +import copy +import logging +import os +import re +import textwrap +import threading +from datetime import datetime +from functools import cache +from ipaddress import IPv4Address, IPv4Interface +from pathlib import Path +from socket import AddressFamily +from typing import Iterable, Literal, Tuple + +import psutil +from cachetools import TTLCache, cached +from dnslib import ( + AAAA, + CNAME, + MX, + NS, + QTYPE, + RCODE, + RD, + RDMAP, + RR, + SOA, + TXT, + A, + DNSHeader, + DNSLabel, + DNSQuestion, + DNSRecord, +) +from dnslib.server import DNSHandler, DNSServer +from psutil._common import snicaddr + +import dns.flags +import dns.message +import dns.query +from dns.exception import Timeout + +# Note: avoid adding additional imports here, to avoid import issues when running the CLI +from localstack import config +from localstack.constants import LOCALHOST_HOSTNAME, LOCALHOST_IP +from localstack.dns.models import ( + AliasTarget, + DnsServerProtocol, + DynamicRecord, + NameRecord, + RecordType, + SOARecord, + TargetRecord, +) +from localstack.services.edge import run_module_as_sudo +from localstack.utils import iputils +from localstack.utils.net import Port, port_can_be_bound +from localstack.utils.platform import in_docker +from localstack.utils.serving import Server +from localstack.utils.strings import to_bytes, to_str +from localstack.utils.sync import sleep_forever + +EPOCH = datetime(1970, 1, 1) +SERIAL = int((datetime.utcnow() - EPOCH).total_seconds()) + +DEFAULT_FALLBACK_DNS_SERVER = "8.8.8.8" +FALLBACK_DNS_LOCK = threading.RLock() +VERIFICATION_DOMAIN = config.DNS_VERIFICATION_DOMAIN + +RCODE_REFUSED = 5 + +DNS_SERVER: "DnsServerProtocol" = None +PREVIOUS_RESOLV_CONF_FILE: str | None = None + +REQUEST_TIMEOUT_SECS = 7 + +TYPE_LOOKUP = { + A: QTYPE.A, + AAAA: QTYPE.AAAA, + CNAME: QTYPE.CNAME, + MX: QTYPE.MX, + NS: QTYPE.NS, + SOA: QTYPE.SOA, + TXT: QTYPE.TXT, +} + +LOG = logging.getLogger(__name__) + +THREAD_LOCAL = threading.local() + +# Type of the value given by DNSHandler.client_address +# in the form (ip, port) e.g. ("127.0.0.1", 58291) +ClientAddress = Tuple[str, int] + +psutil_cache = TTLCache(maxsize=100, ttl=10) + + +# TODO: update route53 provider to use this util +def normalise_dns_name(name: DNSLabel | str) -> str: + name = str(name) + if not name.endswith("."): + return f"{name}." + + return name + + +@cached(cache=psutil_cache) +def list_network_interface_details() -> dict[str, list[snicaddr]]: + return psutil.net_if_addrs() + + +class Record: + def __init__(self, rdata_type, *args, **kwargs): + rtype = kwargs.get("rtype") + rname = kwargs.get("rname") + ttl = kwargs.get("ttl") + + if isinstance(rdata_type, RD): + # actually an instance, not a type + self._rtype = TYPE_LOOKUP[rdata_type.__class__] + rdata = rdata_type + else: + self._rtype = TYPE_LOOKUP[rdata_type] + if rdata_type == SOA and len(args) == 2: + # add sensible times to SOA + args += ( + ( + SERIAL, # serial number + 60 * 60 * 1, # refresh + 60 * 60 * 3, # retry + 60 * 60 * 24, # expire + 60 * 60 * 1, # minimum + ), + ) + rdata = rdata_type(*args) + + if rtype: + self._rtype = rtype + self._rname = rname + self.kwargs = dict(rdata=rdata, ttl=self.sensible_ttl() if ttl is None else ttl, **kwargs) + + def try_rr(self, q): + if q.qtype == QTYPE.ANY or q.qtype == self._rtype: + return self.as_rr(q.qname) + + def as_rr(self, alt_rname): + return RR(rname=self._rname or alt_rname, rtype=self._rtype, **self.kwargs) + + def sensible_ttl(self): + if self._rtype in (QTYPE.NS, QTYPE.SOA): + return 60 * 60 * 24 + else: + return 300 + + @property + def is_soa(self): + return self._rtype == QTYPE.SOA + + def __str__(self): + return f"{QTYPE[self._rtype]}({self.kwargs})" + + def __repr__(self): + return self.__str__() + + +class RecordConverter: + """ + Handles returning the correct DNS record for the stored name_record. + + Particularly, if the record is a DynamicRecord, then perform dynamic IP address lookup. + """ + + def __init__(self, request: DNSRecord, client_address: ClientAddress): + self.request = request + self.client_address = client_address + + def to_record(self, name_record: NameRecord) -> Record: + """ + :param name_record: Internal representation of the name entry + :return: Record type for the associated name record + """ + match name_record: + case TargetRecord(target=target, record_type=record_type): + return Record(RDMAP.get(record_type.name), target) + case SOARecord(m_name=m_name, r_name=r_name, record_type=_): + return Record(SOA, m_name, r_name) + case DynamicRecord(record_type=record_type): + # Marker indicating that the target of the domain name lookup should be resolved + # dynamically at query time to the most suitable LocalStack container IP address + ip = self._determine_best_ip() + # TODO: be more dynamic with IPv6 + if record_type == RecordType.AAAA: + ip = "::1" + return Record(RDMAP.get(record_type.name), ip) + case _: + raise NotImplementedError(f"Record type '{type(name_record)}' not implemented") + + def _determine_best_ip(self) -> str: + client_ip, _ = self.client_address + # allow for overriding if required + if config.DNS_RESOLVE_IP != LOCALHOST_IP: + return config.DNS_RESOLVE_IP + + # Look up best matching ip address for the client + interfaces = self._fetch_interfaces() + for interface in interfaces: + subnet = interface.network + ip_address = IPv4Address(client_ip) + if ip_address in subnet: + # check if the request has come from the gateway or not. If so + # assume the request has come from the host, and return + # 127.0.0.1 + if config.is_in_docker and self._is_gateway(ip_address): + return LOCALHOST_IP + + return str(interface.ip) + + # no best solution found + LOG.warning( + "could not determine subnet-matched IP address for %s, falling back to %s", + self.request.q.qname, + LOCALHOST_IP, + ) + return LOCALHOST_IP + + @staticmethod + def _is_gateway(ip: IPv4Address) -> bool: + """ + Look up the gateways that this contianer has, and return True if the + supplied ip address is in that list. + """ + return ip == iputils.get_default_gateway() + + @staticmethod + def _fetch_interfaces() -> Iterable[IPv4Interface]: + interfaces = list_network_interface_details() + for _, addresses in interfaces.items(): + for address in addresses: + if address.family != AddressFamily.AF_INET: + # TODO: IPv6 + continue + + # argument is of the form e.g. 127.0.0.1/255.0.0.0 + net = IPv4Interface(f"{address.address}/{address.netmask}") + yield net + + +class NonLoggingHandler(DNSHandler): + """Subclass of DNSHandler that avoids logging to stdout on error""" + + def handle(self, *args, **kwargs): + try: + THREAD_LOCAL.client_address = self.client_address + THREAD_LOCAL.server = self.server + THREAD_LOCAL.request = self.request + return super(NonLoggingHandler, self).handle(*args, **kwargs) + except Exception: + pass + + +# List of unique non-subdomain prefixes (e.g., data-) from endpoint.hostPrefix in the botocore specs. +# Subdomain-prefixes (e.g., api.) work properly unless DNS rebind protection blocks DNS resolution, but +# these `-` dash-prefixes require special consideration. +# IMPORTANT: Adding a new host prefix here requires deploying a public DNS entry to ensure proper DNS resolution for +# such non-dot prefixed domains (e.g., data-localhost.localstack.cloud) +# LIMITATION: As of 2025-05-26, only used prefixes are deployed to our public DNS, including `sync-` and `data-` +HOST_PREFIXES_NO_SUBDOMAIN = [ + "analytics-", + "control-storage-", + "data-", + "query-", + "runtime-", + "storage-", + "streaming-", + "sync-", + "tags-", + "workflows-", +] +HOST_PREFIX_NAME_PATTERNS = [ + f"{host_prefix}{LOCALHOST_HOSTNAME}" for host_prefix in HOST_PREFIXES_NO_SUBDOMAIN +] + +NAME_PATTERNS_POINTING_TO_LOCALSTACK = [ + f".*{LOCALHOST_HOSTNAME}", + *HOST_PREFIX_NAME_PATTERNS, +] + + +def exclude_from_resolution(domain_regex: str): + """ + Excludes the given domain pattern from being resolved to LocalStack. + Currently only works in docker, since in host mode dns is started as separate process + :param domain_regex: Domain regex string + """ + if DNS_SERVER: + DNS_SERVER.add_skip(domain_regex) + + +def revert_exclude_from_resolution(domain_regex: str): + """ + Reverts the exclusion of the given domain pattern + :param domain_regex: Domain regex string + """ + try: + if DNS_SERVER: + DNS_SERVER.delete_skip(domain_regex) + except ValueError: + pass + + +def _should_delete_zone(record_to_delete: NameRecord, record_to_check: NameRecord): + """ + Helper function to check if we should delete the record_to_check from the list we are iterating over + :param record_to_delete: Record which we got from the delete request + :param record_to_check: Record to be checked if it should be included in the records after delete + :return: + """ + if record_to_delete == record_to_check: + return True + return ( + record_to_delete.record_type == record_to_check.record_type + and record_to_delete.record_id == record_to_check.record_id + ) + + +def _should_delete_alias(alias_to_delete: AliasTarget, alias_to_check: AliasTarget): + """ + Helper function to check if we should delete the alias_to_check from the list we are iterating over + :param alias_to_delete: Alias which we got from the delete request + :param alias_to_check: Alias to be checked if it should be included in the records after delete + :return: + """ + return alias_to_delete.alias_id == alias_to_check.alias_id + + +class NoopLogger: + """ + Necessary helper class to avoid logging of any dns records by dnslib + """ + + def __init__(self, *args, **kwargs): + pass + + def log_pass(self, *args, **kwargs): + pass + + def log_prefix(self, *args, **kwargs): + pass + + def log_recv(self, *args, **kwargs): + pass + + def log_send(self, *args, **kwargs): + pass + + def log_request(self, *args, **kwargs): + pass + + def log_reply(self, *args, **kwargs): + pass + + def log_truncated(self, *args, **kwargs): + pass + + def log_error(self, *args, **kwargs): + pass + + def log_data(self, *args, **kwargs): + pass + + +class Resolver(DnsServerProtocol): + # Upstream DNS server + upstream_dns: str + # List of patterns which will be skipped for local resolution and always forwarded to upstream + skip_patterns: list[str] + # Dict of zones: (domain name or pattern) -> list[dns records] + zones: dict[str, list[NameRecord]] + # Alias map (source_name, record_type) => target_name (target name then still has to be resolved!) + aliases: dict[tuple[DNSLabel, RecordType], list[AliasTarget]] + # Lock to prevent issues due to concurrent modifications + lock: threading.RLock + + def __init__(self, upstream_dns: str): + self.upstream_dns = upstream_dns + self.skip_patterns = [] + self.zones = {} + self.aliases = {} + self.lock = threading.RLock() + + def resolve(self, request: DNSRecord, handler: DNSHandler) -> DNSRecord | None: + """ + Resolve a given request, by either checking locally registered records, or forwarding to the defined + upstream DNS server. + + :param request: DNS Request + :param handler: Unused. + :return: DNS Reply + """ + reply = request.reply() + found = False + + try: + if not self._skip_local_resolution(request): + found = self._resolve_name(request, reply, handler.client_address) + except Exception as e: + LOG.info("Unable to get DNS result: %s", e) + + if found: + return reply + + # If we did not find a matching record in our local zones, we forward to our upstream dns + try: + req_parsed = dns.message.from_wire(bytes(request.pack())) + r = dns.query.udp(req_parsed, self.upstream_dns, timeout=REQUEST_TIMEOUT_SECS) + result = self._map_response_dnspython_to_dnslib(r) + return result + except Exception as e: + LOG.info( + "Unable to get DNS result from upstream server %s for domain %s: %s", + self.upstream_dns, + str(request.q.qname), + e, + ) + + # if we cannot reach upstream dns, return SERVFAIL + if not reply.rr and reply.header.get_rcode == RCODE.NOERROR: + # setting this return code will cause commands like 'host' to try the next nameserver + reply.header.set_rcode(RCODE.SERVFAIL) + return None + + return reply + + def _skip_local_resolution(self, request) -> bool: + """ + Check whether we should skip local resolution for the given request, and directly contact upstream + + :param request: DNS Request + :return: Whether the request local resolution should be skipped + """ + request_name = to_str(str(request.q.qname)) + for p in self.skip_patterns: + if re.match(p, request_name): + return True + return False + + def _resolve_alias( + self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress + ) -> bool: + if request.q.qtype in (QTYPE.A, QTYPE.AAAA, QTYPE.CNAME): + key = (DNSLabel(to_bytes(request.q.qname)), RecordType[QTYPE[request.q.qtype]]) + # check if we have aliases defined for our given qname/qtype pair + if aliases := self.aliases.get(key): + for alias in aliases: + # if there is no health check, or the healthcheck is successful, we will consider this alias + # take the first alias passing this check + if not alias.health_check or alias.health_check(): + request_copy: DNSRecord = copy.deepcopy(request) + request_copy.q.qname = alias.target + # check if we can resolve the alias + found = self._resolve_name_from_zones(request_copy, reply, client_address) + if found: + LOG.debug( + "Found entry for AliasTarget '%s' ('%s')", request.q.qname, alias + ) + # change the replaced rr-DNS names back to the original request + for rr in reply.rr: + rr.set_rname(request.q.qname) + else: + reply.header.set_rcode(RCODE.REFUSED) + return True + return False + + def _resolve_name( + self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress + ) -> bool: + if alias_found := self._resolve_alias(request, reply, client_address): + LOG.debug("Alias found: %s", request.q.qname) + return alias_found + return self._resolve_name_from_zones(request, reply, client_address) + + def _resolve_name_from_zones( + self, request: DNSRecord, reply: DNSRecord, client_address: ClientAddress + ) -> bool: + found = False + + converter = RecordConverter(request, client_address) + + # check for direct (not regex based) response + zone = self.zones.get(normalise_dns_name(request.q.qname)) + if zone is not None: + for zone_records in zone: + rr = converter.to_record(zone_records).try_rr(request.q) + if rr: + found = True + reply.add_answer(rr) + else: + # no direct zone so look for an SOA record for a higher level zone + for zone_label, zone_records in self.zones.items(): + # try regex match + pattern = re.sub(r"(^|[^.])\*", ".*", str(zone_label)) + if re.match(pattern, str(request.q.qname)): + for record in zone_records: + rr = converter.to_record(record).try_rr(request.q) + if rr: + found = True + reply.add_answer(rr) + # try suffix match + elif request.q.qname.matchSuffix(to_bytes(zone_label)): + try: + soa_record = next(r for r in zone_records if converter.to_record(r).is_soa) + except StopIteration: + continue + else: + found = True + reply.add_answer(converter.to_record(soa_record).as_rr(zone_label)) + break + return found + + def _parse_section(self, section: str) -> list[RR]: + result = [] + for line in section.split("\n"): + line = line.strip() + if line: + if line.startswith(";"): + # section ended, stop parsing + break + else: + result += RR.fromZone(line) + return result + + def _map_response_dnspython_to_dnslib(self, response): + """Map response object from dnspython to dnslib (looks like we cannot + simply export/import the raw messages from the wire)""" + flags = dns.flags.to_text(response.flags) + + def flag(f): + return 1 if f.upper() in flags else 0 + + questions = [] + for q in response.question: + questions.append(DNSQuestion(qname=str(q.name), qtype=q.rdtype, qclass=q.rdclass)) + + result = DNSRecord( + DNSHeader( + qr=flag("qr"), aa=flag("aa"), ra=flag("ra"), id=response.id, rcode=response.rcode() + ), + q=questions[0], + ) + + # extract answers + answer_parts = str(response).partition(";ANSWER") + result.add_answer(*self._parse_section(answer_parts[2])) + # extract authority information + authority_parts = str(response).partition(";AUTHORITY") + result.add_auth(*self._parse_section(authority_parts[2])) + return result + + def add_host(self, name: str, record: NameRecord): + LOG.debug("Adding host %s with record %s", name, record) + name = normalise_dns_name(name) + with self.lock: + self.zones.setdefault(name, []) + self.zones[name].append(record) + + def delete_host(self, name: str, record: NameRecord): + LOG.debug("Deleting host %s with record %s", name, record) + name = normalise_dns_name(name) + with self.lock: + if not self.zones.get(name): + raise ValueError("Could not find entry %s for name %s in zones", record, name) + self.zones.setdefault(name, []) + current_zones = self.zones[name] + self.zones[name] = [ + zone for zone in self.zones[name] if not _should_delete_zone(record, zone) + ] + if self.zones[name] == current_zones: + raise ValueError("Could not find entry %s for name %s in zones", record, name) + # if we deleted the last entry, clean up + if not self.zones[name]: + del self.zones[name] + + def add_alias(self, source_name: str, record_type: RecordType, target: AliasTarget): + LOG.debug("Adding alias %s with record type %s target %s", source_name, record_type, target) + label = (DNSLabel(to_bytes(source_name)), record_type) + with self.lock: + self.aliases.setdefault(label, []) + self.aliases[label].append(target) + + def delete_alias(self, source_name: str, record_type: RecordType, target: AliasTarget): + LOG.debug( + "Deleting alias %s with record type %s", + source_name, + record_type, + ) + label = (DNSLabel(to_bytes(source_name)), record_type) + with self.lock: + if not self.aliases.get(label): + raise ValueError( + "Could not find entry %s for name %s, record type %s in aliases", + target, + source_name, + record_type, + ) + self.aliases.setdefault(label, []) + current_aliases = self.aliases[label] + self.aliases[label] = [ + alias for alias in self.aliases[label] if not _should_delete_alias(target, alias) + ] + if self.aliases[label] == current_aliases: + raise ValueError( + "Could not find entry %s for name %s, record_type %s in aliases", + target, + source_name, + record_type, + ) + # if we deleted the last entry, clean up + if not self.aliases[label]: + del self.aliases[label] + + def add_host_pointing_to_localstack(self, name: str): + LOG.debug("Adding host %s pointing to LocalStack", name) + self.add_host(name, DynamicRecord(record_type=RecordType.A)) + if config.DNS_RESOLVE_IP == config.LOCALHOST_IP: + self.add_host(name, DynamicRecord(record_type=RecordType.AAAA)) + + def delete_host_pointing_to_localstack(self, name: str): + LOG.debug("Deleting host %s pointing to LocalStack", name) + self.delete_host(name, DynamicRecord(record_type=RecordType.A)) + if config.DNS_RESOLVE_IP == config.LOCALHOST_IP: + self.delete_host(name, DynamicRecord(record_type=RecordType.AAAA)) + + def add_skip(self, skip_pattern: str): + LOG.debug("Adding skip pattern %s", skip_pattern) + self.skip_patterns.append(skip_pattern) + + def delete_skip(self, skip_pattern: str): + LOG.debug("Deleting skip pattern %s", skip_pattern) + self.skip_patterns.remove(skip_pattern) + + def clear(self): + LOG.debug("Clearing DNS zones") + self.skip_patterns.clear() + self.zones.clear() + self.aliases.clear() + + +class DnsServer(Server, DnsServerProtocol): + servers: list[DNSServer] + resolver: Resolver | None + + def __init__( + self, + port: int, + protocols: list[Literal["udp", "tcp"]], + upstream_dns: str, + host: str = "0.0.0.0", + ) -> None: + super().__init__(port, host) + self.resolver = Resolver(upstream_dns=upstream_dns) + self.protocols = protocols + self.servers = [] + self.handler_class = NonLoggingHandler + + def _get_servers(self) -> list[DNSServer]: + servers = [] + for protocol in self.protocols: + # TODO add option to use normal logger instead of NoopLogger for verbose debug mode + servers.append( + DNSServer( + self.resolver, + handler=self.handler_class, + logger=NoopLogger(), + port=self.port, + address=self.host, + tcp=protocol == "tcp", + ) + ) + return servers + + @property + def protocol(self): + return "udp" + + def health(self): + """ + Runs a health check on the server. The default implementation performs is_port_open on the server URL. + """ + try: + request = dns.message.make_query("localhost.localstack.cloud", "A") + answers = dns.query.udp(request, "127.0.0.1", port=self.port, timeout=0.5).answer + return len(answers) > 0 + except Exception: + return False + + def do_run(self): + self.servers = self._get_servers() + for server in self.servers: + server.start_thread() + LOG.debug("DNS Server started") + for server in self.servers: + server.thread.join() + + def do_shutdown(self): + for server in self.servers: + server.stop() + + def add_host(self, name: str, record: NameRecord): + self.resolver.add_host(name, record) + + def delete_host(self, name: str, record: NameRecord): + self.resolver.delete_host(name, record) + + def add_alias(self, source_name: str, record_type: RecordType, target: AliasTarget): + self.resolver.add_alias(source_name, record_type, target) + + def delete_alias(self, source_name: str, record_type: RecordType, target: AliasTarget): + self.resolver.delete_alias(source_name, record_type, target) + + def add_host_pointing_to_localstack(self, name: str): + self.resolver.add_host_pointing_to_localstack(name) + + def delete_host_pointing_to_localstack(self, name: str): + self.resolver.delete_host_pointing_to_localstack(name) + + def add_skip(self, skip_pattern: str): + self.resolver.add_skip(skip_pattern) + + def delete_skip(self, skip_pattern: str): + self.resolver.delete_skip(skip_pattern) + + def clear(self): + self.resolver.clear() + + +class SeparateProcessDNSServer(Server, DnsServerProtocol): + def __init__( + self, + port: int = 53, + host: str = "0.0.0.0", + ) -> None: + super().__init__(port, host) + + @property + def protocol(self): + return "udp" + + def health(self): + """ + Runs a health check on the server. The default implementation performs is_port_open on the server URL. + """ + try: + request = dns.message.make_query("localhost.localstack.cloud", "A") + answers = dns.query.udp(request, "127.0.0.1", port=self.port, timeout=0.5).answer + return len(answers) > 0 + except Exception: + return False + + def do_start_thread(self): + # For host mode + env_vars = {} + for env_var in config.CONFIG_ENV_VARS: + if env_var.startswith("DNS_"): + value = os.environ.get(env_var, None) + if value is not None: + env_vars[env_var] = value + + # note: running in a separate process breaks integration with Route53 (to be fixed for local dev mode!) + thread = run_module_as_sudo( + "localstack.dns.server", + asynchronous=True, + env_vars=env_vars, + arguments=["-p", str(self.port)], + ) + return thread + + +def get_fallback_dns_server(): + return config.DNS_SERVER or get_available_dns_server() + + +@cache +def get_available_dns_server(): + # TODO check if more loop-checks are necessary than just not using our own DNS server + with FALLBACK_DNS_LOCK: + resolver = dns.resolver.Resolver() + # we do not want to include localhost here, or a loop might happen + candidates = [r for r in resolver.nameservers if r != "127.0.0.1"] + result = None + candidates.append(DEFAULT_FALLBACK_DNS_SERVER) + for ns in candidates: + resolver.nameservers = [ns] + try: + try: + answer = resolver.resolve(VERIFICATION_DOMAIN, "a", lifetime=3) + answer = [ + res.to_text() for answers in answer.response.answer for res in answers.items + ] + except Timeout: + answer = None + if not answer: + continue + result = ns + break + except Exception: + pass + + if result: + LOG.debug("Determined fallback dns: %s", result) + else: + LOG.info( + "Unable to determine fallback DNS. Please check if '%s' is reachable by your configured DNS servers" + "DNS fallback will be disabled.", + VERIFICATION_DOMAIN, + ) + return result + + +# ###### LEGACY METHODS ###### +def add_resolv_entry(file_path: Path | str = Path("/etc/resolv.conf")): + global PREVIOUS_RESOLV_CONF_FILE + # never overwrite the host configuration without the user's permission + if not in_docker(): + LOG.warning("Incorrectly attempted to alter host networking config") + return + + LOG.debug("Overwriting container DNS server to point to localhost") + content = textwrap.dedent( + """ + # The following line is required by LocalStack + nameserver 127.0.0.1 + """ + ) + file_path = Path(file_path) + try: + with file_path.open("r+") as outfile: + PREVIOUS_RESOLV_CONF_FILE = outfile.read() + previous_resolv_conf_without_nameservers = [ + line + for line in PREVIOUS_RESOLV_CONF_FILE.splitlines() + if not line.startswith("nameserver") + ] + outfile.seek(0) + outfile.write(content) + outfile.write("\n".join(previous_resolv_conf_without_nameservers)) + outfile.truncate() + except Exception: + LOG.warning( + "Could not update container DNS settings", exc_info=LOG.isEnabledFor(logging.DEBUG) + ) + + +def revert_resolv_entry(file_path: Path | str = Path("/etc/resolv.conf")): + # never overwrite the host configuration without the user's permission + if not in_docker(): + LOG.warning("Incorrectly attempted to alter host networking config") + return + + if not PREVIOUS_RESOLV_CONF_FILE: + LOG.warning("resolv.conf file to restore not found.") + return + + LOG.debug("Reverting container DNS config") + file_path = Path(file_path) + try: + with file_path.open("w") as outfile: + outfile.write(PREVIOUS_RESOLV_CONF_FILE) + except Exception: + LOG.warning( + "Could not revert container DNS settings", exc_info=LOG.isEnabledFor(logging.DEBUG) + ) + + +def setup_network_configuration(): + # check if DNS is disabled + if not config.use_custom_dns(): + return + + # add entry to /etc/resolv.conf + if in_docker(): + add_resolv_entry() + + +def revert_network_configuration(): + # check if DNS is disabled + if not config.use_custom_dns(): + return + + # add entry to /etc/resolv.conf + if in_docker(): + revert_resolv_entry() + + +def start_server(upstream_dns: str, host: str, port: int = config.DNS_PORT): + global DNS_SERVER + + if DNS_SERVER: + # already started - bail + LOG.debug("DNS servers are already started. Avoid starting again.") + return + + LOG.debug("Starting DNS servers (tcp/udp port %s on %s)...", port, host) + dns_server = DnsServer(port, protocols=["tcp", "udp"], host=host, upstream_dns=upstream_dns) + + for name in NAME_PATTERNS_POINTING_TO_LOCALSTACK: + dns_server.add_host_pointing_to_localstack(name) + if config.LOCALSTACK_HOST.host != LOCALHOST_HOSTNAME: + dns_server.add_host_pointing_to_localstack(f".*{config.LOCALSTACK_HOST.host}") + + # support both DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM and DNS_LOCAL_NAME_PATTERNS + # until the next major version change + # TODO(srw): remove the usage of DNS_LOCAL_NAME_PATTERNS + skip_local_resolution = " ".join( + [ + config.DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM, + config.DNS_LOCAL_NAME_PATTERNS, + ] + ).strip() + if skip_local_resolution: + for skip_pattern in re.split(r"[,;\s]+", skip_local_resolution): + dns_server.add_skip(skip_pattern.strip(" \"'")) + + dns_server.start() + if not dns_server.wait_is_up(timeout=5): + LOG.warning("DNS server did not come up within 5 seconds.") + dns_server.shutdown() + return + DNS_SERVER = dns_server + LOG.debug("DNS server startup finished.") + + +def stop_servers(): + if DNS_SERVER: + DNS_SERVER.shutdown() + + +def start_dns_server_as_sudo(port: int): + global DNS_SERVER + LOG.debug( + "Starting the DNS on its privileged port (%s) needs root permissions. Trying to start DNS with sudo.", + config.DNS_PORT, + ) + + dns_server = SeparateProcessDNSServer(port) + dns_server.start() + + if not dns_server.wait_is_up(timeout=5): + LOG.warning("DNS server did not come up within 5 seconds.") + dns_server.shutdown() + return + + DNS_SERVER = dns_server + LOG.debug("DNS server startup finished (as sudo).") + + +def start_dns_server(port: int, asynchronous: bool = False, standalone: bool = False): + if DNS_SERVER: + # already started - bail + LOG.error("DNS servers are already started. Avoid starting again.") + return + + # check if DNS server is disabled + if not config.use_custom_dns(): + LOG.debug("Not starting DNS. DNS_ADDRESS=%s", config.DNS_ADDRESS) + return + + upstream_dns = get_fallback_dns_server() + if not upstream_dns: + LOG.warning("Error starting the DNS server: No upstream dns server found.") + return + + # host to bind the DNS server to. In docker we always want to bind to "0.0.0.0" + host = config.DNS_ADDRESS + if in_docker(): + host = "0.0.0.0" + + if port_can_be_bound(Port(port, "udp"), address=host): + start_server(port=port, host=host, upstream_dns=upstream_dns) + if not asynchronous: + sleep_forever() + return + + if standalone: + LOG.debug("Already in standalone mode and port binding still fails.") + return + + start_dns_server_as_sudo(port) + + +def get_dns_server() -> DnsServerProtocol: + return DNS_SERVER + + +def is_server_running() -> bool: + return DNS_SERVER is not None + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-p", "--port", required=False, default=53, type=int) + args = parser.parse_args() + + start_dns_server(asynchronous=False, port=args.port, standalone=True) diff --git a/localstack-core/localstack/extensions/__init__.py b/localstack-core/localstack/extensions/__init__.py new file mode 100644 index 0000000000000..3b52add044d38 --- /dev/null +++ b/localstack-core/localstack/extensions/__init__.py @@ -0,0 +1,3 @@ +"""Extensions are third-party software modules to customize localstack.""" + +name = "extensions" diff --git a/localstack-core/localstack/extensions/api/__init__.py b/localstack-core/localstack/extensions/api/__init__.py new file mode 100644 index 0000000000000..9335bae5fe7c2 --- /dev/null +++ b/localstack-core/localstack/extensions/api/__init__.py @@ -0,0 +1,7 @@ +"""Public facing API for users to build LocalStack extensions.""" + +from .extension import Extension + +name = "api" + +__all__ = ["Extension"] diff --git a/localstack-core/localstack/extensions/api/aws.py b/localstack-core/localstack/extensions/api/aws.py new file mode 100644 index 0000000000000..871e2e8a583ee --- /dev/null +++ b/localstack-core/localstack/extensions/api/aws.py @@ -0,0 +1,33 @@ +from localstack.aws.api import ( + CommonServiceException, + RequestContext, + ServiceException, + ServiceRequest, + ServiceResponse, +) +from localstack.aws.chain import ( + CompositeExceptionHandler, + CompositeFinalizer, + CompositeHandler, + CompositeResponseHandler, + ExceptionHandler, + HandlerChain, +) +from localstack.aws.chain import Handler as RequestHandler +from localstack.aws.chain import Handler as ResponseHandler + +__all__ = [ + "RequestContext", + "ServiceRequest", + "ServiceResponse", + "ServiceException", + "CommonServiceException", + "RequestHandler", + "ResponseHandler", + "HandlerChain", + "CompositeHandler", + "ExceptionHandler", + "CompositeResponseHandler", + "CompositeExceptionHandler", + "CompositeFinalizer", +] diff --git a/localstack-core/localstack/extensions/api/extension.py b/localstack-core/localstack/extensions/api/extension.py new file mode 100644 index 0000000000000..57a795bfbc2a9 --- /dev/null +++ b/localstack-core/localstack/extensions/api/extension.py @@ -0,0 +1,112 @@ +from plux import Plugin + +from .aws import ( + CompositeExceptionHandler, + CompositeFinalizer, + CompositeHandler, + CompositeResponseHandler, +) +from .http import RouteHandler, Router + + +class BaseExtension(Plugin): + """ + Base extension. + """ + + def load(self, *args, **kwargs): + """ + Provided to plux to load the plugins. Do NOT overwrite! PluginManagers managing extensions expect the load method to return the Extension itself. + + :param args: load arguments + :param kwargs: load keyword arguments + :return: this extension object + """ + return self + + def on_extension_load(self, *args, **kwargs): + """ + Called when LocalStack loads the extension. + """ + raise NotImplementedError + + +class Extension(BaseExtension): + """ + An extension that is loaded into LocalStack dynamically. + + The method execution order of an extension is as follows: + + - on_extension_load + - on_platform_start + - update_gateway_routes + - update_request_handlers + - update_response_handlers + - on_platform_ready + """ + + namespace = "localstack.extensions" + + def on_extension_load(self): + """ + Called when LocalStack loads the extension. + """ + pass + + def on_platform_start(self): + """ + Called when LocalStack starts the main runtime. + """ + pass + + def update_gateway_routes(self, router: Router[RouteHandler]): + """ + Called with the Router attached to the LocalStack gateway. Overwrite this to add or update routes. + + :param router: the Router attached in the gateway + """ + pass + + def update_request_handlers(self, handlers: CompositeHandler): + """ + Called with the custom request handlers of the LocalStack gateway. Overwrite this to add or update handlers. + + :param handlers: custom request handlers of the gateway + """ + pass + + def update_response_handlers(self, handlers: CompositeResponseHandler): + """ + Called with the custom response handlers of the LocalStack gateway. Overwrite this to add or update handlers. + + :param handlers: custom response handlers of the gateway + """ + pass + + def update_exception_handlers(self, handlers: CompositeExceptionHandler): + """ + Called with the custom exception handlers of the LocalStack gateway. Overwrite this to add or update handlers. + + :param handlers: custom exception handlers of the gateway + """ + pass + + def update_finalizers(self, handlers: CompositeFinalizer): + """ + Called with the custom finalizer handlers of the LocalStack gateway. Overwrite this to add or update handlers. + + :param handlers: custom finalizer handlers of the gateway + """ + pass + + def on_platform_ready(self): + """ + Called when LocalStack is ready and the Ready marker has been printed. + """ + pass + + def on_platform_shutdown(self): + """ + Called when LocalStack is shutting down. Can be used to close any resources (threads, processes, sockets, etc.). + """ + pass diff --git a/localstack-core/localstack/extensions/api/http.py b/localstack-core/localstack/extensions/api/http.py new file mode 100644 index 0000000000000..5845856625206 --- /dev/null +++ b/localstack-core/localstack/extensions/api/http.py @@ -0,0 +1,16 @@ +from localstack.http import Request, Response, Router +from localstack.http.client import HttpClient, SimpleRequestsClient +from localstack.http.dispatcher import Handler as RouteHandler +from localstack.http.proxy import Proxy, ProxyHandler, forward + +__all__ = [ + "Request", + "Response", + "Router", + "HttpClient", + "SimpleRequestsClient", + "Proxy", + "ProxyHandler", + "forward", + "RouteHandler", +] diff --git a/localstack-core/localstack/extensions/api/runtime.py b/localstack-core/localstack/extensions/api/runtime.py new file mode 100644 index 0000000000000..426036659c951 --- /dev/null +++ b/localstack-core/localstack/extensions/api/runtime.py @@ -0,0 +1,3 @@ +from localstack.utils.analytics import get_session_id + +__all__ = ["get_session_id"] diff --git a/localstack-core/localstack/extensions/api/services.py b/localstack-core/localstack/extensions/api/services.py new file mode 100644 index 0000000000000..c41152ef0d121 --- /dev/null +++ b/localstack-core/localstack/extensions/api/services.py @@ -0,0 +1,5 @@ +from localstack.utils.common import external_service_ports + +__all__ = [ + "external_service_ports", +] diff --git a/localstack/services/es/__init__.py b/localstack-core/localstack/extensions/patterns/__init__.py similarity index 100% rename from localstack/services/es/__init__.py rename to localstack-core/localstack/extensions/patterns/__init__.py diff --git a/localstack-core/localstack/extensions/patterns/webapp.py b/localstack-core/localstack/extensions/patterns/webapp.py new file mode 100644 index 0000000000000..ab69d935d729c --- /dev/null +++ b/localstack-core/localstack/extensions/patterns/webapp.py @@ -0,0 +1,333 @@ +import importlib +import logging +import mimetypes +import typing as t +from functools import cached_property + +from rolo.gateway import HandlerChain +from rolo.router import RuleAdapter, WithHost +from werkzeug.routing import Submount + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.extensions.api import Extension, http + +if t.TYPE_CHECKING: + # although jinja2 is included transitively via moto, let's make sure jinja2 stays optional + import jinja2 + +LOG = logging.getLogger(__name__) + +_default = object() + + +class WebAppExtension(Extension): + """ + EXPERIMENTAL! This class is experimental and the API may change without notice. + + A webapp extension serves routes, templates, and static files via a submount and a subdomain through + localstack. + + It assumes you have the following directory layout:: + + my_extension + β”œβ”€β”€ extension.py + β”œβ”€β”€ __init__.py + β”œβ”€β”€ static <-- make sure static resources get packaged! + β”‚ β”œβ”€β”€ __init__.py + β”‚ β”œβ”€β”€ favicon.ico + β”‚ └── style.css + └── templates <-- jinja2 templates + └── index.html + + Given this layout, you can define your extensions in ``my_extension.extension`` like this. Routes defined in the + extension itself are automatically registered:: + + class MyExtension(WebAppExtension): + name = "my-extension" + + @route("/") + def index(request: Request) -> Response: + # reference `static/style.css` to serve the static file from your package + return self.render_template_response("index.html") + + @route("/hello") + def hello(request: Request): + return {"message": "Hello World!"} + + This will create an extension that localstack serves via: + + * Submount: https://localhost.localstack.cloud:4566/_extension/my-extension + * Subdomain: https://my-extension.localhost.localstack.cloud:4566/ + + Both are created for full flexibility: + + * Subdomains: create a domain namespace that can be helpful for some extensions, especially when + running on the local machine + * Submounts: for some environments, like in ephemeral instances where subdomains are harder to control, + submounts are more convenient + + Any routes added by the extension will be served relative to these URLs. + """ + + def __init__( + self, + mount: str = None, + submount: str | None = _default, + subdomain: str | None = _default, + template_package_path: str | None = _default, + static_package_path: str | None = _default, + static_url_path: str = None, + ): + """ + Overwrite to customize your extension. For example, you can disable certain behavior by calling + ``super( ).__init__(subdomain=None, static_package_path=None)``, which will disable serving through + a subdomain, and disable static file serving. + + :param mount: the "mount point" which will be used as default value for the submount and + subdirectory, i.e., ``<mount>.localhost.localstack.cloud`` and + ``localhost.localstack.cloud/_extension/<mount>``. Defaults to the extension name. Note that, + in case the mount name clashes with another extension, extensions may overwrite each other's + routes. + :param submount: the submount path, needs to start with a trailing slash (default + ``/_extension/<mount>``) + :param subdomain: the subdomain (defaults to the value of ``mount``) + :param template_package_path: the path to the templates within the module. defaults to + ``templates`` which expands to ``<extension-module>.templates``) + :param static_package_path: the package serving static files. defaults to ``static``, which expands to + ``<extension-module>.static``. + :param static_url_path: the URL path to serve static files from (defaults to `/static`) + """ + mount = mount or self.name + + self.submount = f"/_extension/{mount}" if submount is _default else submount + self.subdomain = mount if subdomain is _default else subdomain + + self.template_package_path = ( + "templates" if template_package_path is _default else template_package_path + ) + self.static_package_path = ( + "static" if static_package_path is _default else static_package_path + ) + self.static_url_path = static_url_path or "/static" + + self.static_resource_module = None + + def collect_routes(self, routes: list[t.Any]): + """ + This method can be overwritten to add more routes to the controller. Everything in ``routes`` will + be added to a ``RuleAdapter`` and subsequently mounted into the gateway router. + + Here are some examples:: + + class MyRoutes: + @route("/hello") + def hello(request): + return "Hello World!" + + class MyExtension(WebAppExtension): + name = "my-extension" + + def collect_routes(self, routes: list[t.Any]): + + # scans all routes of MyRoutes + routes.append(MyRoutes()) + # use rule adapters to add routes without decorators + routes.append(RuleAdapter("/say-hello", self.say_hello)) + + # no idea why you would want to do this, but you can :-) + @route("/empty-dict") + def _inline_handler(request: Request) -> Response: + return Response.for_json({}) + routes.append(_inline_handler) + + def say_hello(request: Request): + return {"message": "Hello World!"} + + This creates the following routes available through both subdomain and submount. + + With subdomain: + + * ``my-extension.localhost.localstack.cloud:4566/hello`` + * ``my-extension.localhost.localstack.cloud:4566/say-hello`` + * ``my-extension.localhost.localstack.cloud:4566/empty-dict`` + * ``my-extension.localhost.localstack.cloud:4566/static`` <- automatically added static file endpoint + + With submount: + + * ``localhost.localstack.cloud:4566/_extension/my-extension/hello`` + * ``localhost.localstack.cloud:4566/_extension/my-extension/say-hello`` + * ``localhost.localstack.cloud:4566/_extension/my-extension/empty-dict`` + * ``localhost.localstack.cloud:4566/_extension/my-extension/static`` <- auto-added static file serving + + :param routes: the routes being collected + """ + pass + + @cached_property + def template_env(self) -> t.Optional["jinja2.Environment"]: + """ + Returns the singleton jinja2 template environment. By default, the environment uses a + ``PackageLoader`` that loads from ``my_extension.templates`` (where ``my_extension`` is the root + module of the extension, and ``templates`` refers to ``self.template_package_path``, + which is ``templates`` by default). + + :return: a template environment + """ + if self.template_package_path: + return self._create_template_env() + return None + + def _create_template_env(self) -> "jinja2.Environment": + """ + Factory method to create the jinja2 template environment. + :return: a new jinja2 environment + """ + import jinja2 + + return jinja2.Environment( + loader=jinja2.PackageLoader( + self.get_extension_module_root(), self.template_package_path + ), + autoescape=jinja2.select_autoescape(), + ) + + def render_template(self, template_name, **context) -> str: + """ + Uses the ``template_env`` to render a template and return the string value. + + :param template_name: the template name + :param context: template context + :return: the rendered result + """ + template = self.template_env.get_template(template_name) + return template.render(**context) + + def render_template_response(self, template_name, **context) -> http.Response: + """ + Uses the ``template_env`` to render a template into an HTTP response. It guesses the mimetype from the + template's file name. + + :param template_name: the template name + :param context: template context + :return: the rendered result as response + """ + template = self.template_env.get_template(template_name) + + mimetype = mimetypes.guess_type(template.filename) + mimetype = mimetype[0] if mimetype and mimetype[0] else "text/plain" + + return http.Response(response=template.render(**context), mimetype=mimetype) + + def on_extension_load(self): + logging.getLogger(self.get_extension_module_root()).setLevel( + logging.DEBUG if config.DEBUG else logging.INFO + ) + + if self.static_package_path and not self.static_resource_module: + try: + self.static_resource_module = importlib.import_module( + self.get_extension_module_root() + "." + self.static_package_path + ) + except ModuleNotFoundError: + LOG.warning("disabling static resources for extension %s", self.name) + + def _preprocess_request( + self, chain: HandlerChain, context: RequestContext, _response: http.Response + ): + """ + Default pre-processor, which implements a default behavior to add a trailing slash to the path if the + submount is used directly. For instance ``/_extension/my-extension``, then it forwards to + ``/_extension/my-extension/``. This is so you can reference relative paths like ``<link + href="static/style.css">`` in your HTML safely, and it will work with both subdomain and submount. + """ + path = context.request.path + + if path == self.submount.rstrip("/"): + chain.respond(301, headers={"Location": context.request.url + "/"}) + + def update_gateway_routes(self, router: http.Router[http.RouteHandler]): + from localstack.aws.handlers import preprocess_request + + if self.submount: + preprocess_request.append(self._preprocess_request) + + # adding self here makes sure that any ``@route`` decorators to the extension are mapped automatically + routes = [self] + + if self.static_resource_module: + routes.append( + RuleAdapter(f"{self.static_url_path}/<path:path>", self._serve_static_file) + ) + + self.collect_routes(routes) + + app = RuleAdapter(routes) + + if self.submount: + router.add(Submount(self.submount, [app])) + LOG.info( + "%s extension available at %s%s", + self.name, + config.external_service_url(), + self.submount, + ) + + if self.subdomain: + router.add(WithHost(f"{self.subdomain}.<__host__>", [app])) + self._configure_cors_for_subdomain() + LOG.info( + "%s extension available at %s", + self.name, + config.external_service_url(subdomains=self.subdomain), + ) + + def _serve_static_file(self, _request: http.Request, path: str): + """Route for serving static files, for ``/_extension/my-extension/static/<path:path>``.""" + return http.Response.for_resource(self.static_resource_module, path) + + def _configure_cors_for_subdomain(self): + """ + Automatically configures CORS for the subdomain, for both HTTP and HTTPS. + """ + from localstack.aws.handlers.cors import ALLOWED_CORS_ORIGINS + + for protocol in ("http", "https"): + url = self.get_subdomain_url(protocol) + LOG.debug("adding %s to ALLOWED_CORS_ORIGINS", url) + ALLOWED_CORS_ORIGINS.append(url) + + def get_subdomain_url(self, protocol: str = "https") -> str: + """ + Returns the URL that serves the extension under its subdomain + ``https://my-extension.localhost.localstack.cloud:4566/``. + + :return: a URL this extension is served at + """ + if not self.subdomain: + raise ValueError(f"Subdomain for extension {self.name} is not set") + return config.external_service_url(subdomains=self.subdomain, protocol=protocol) + + def get_submount_url(self, protocol: str = "https") -> str: + """ + Returns the URL that serves the extension under its submount + ``https://localhost.localstack.cloud:4566/_extension/my-extension``. + + :return: a URL this extension is served at + """ + + if not self.submount: + raise ValueError(f"Submount for extension {self.name} is not set") + + return f"{config.external_service_url(protocol=protocol)}{self.submount}" + + @classmethod + def get_extension_module_root(cls) -> str: + """ + Returns the root of the extension module. For instance, if the extension lives in + ``my_extension/plugins/extension.py``, then this will return ``my_extension``. Used to set up the + logger as well as the template environment and the static file module. + + :return: the root module the extension lives in + """ + return cls.__module__.split(".")[0] diff --git a/localstack-core/localstack/http/__init__.py b/localstack-core/localstack/http/__init__.py new file mode 100644 index 0000000000000..d72ef9d669d66 --- /dev/null +++ b/localstack-core/localstack/http/__init__.py @@ -0,0 +1,6 @@ +from .request import Request +from .resource import Resource, resource +from .response import Response +from .router import Router, route + +__all__ = ["route", "resource", "Resource", "Router", "Response", "Request"] diff --git a/localstack-core/localstack/http/asgi.py b/localstack-core/localstack/http/asgi.py new file mode 100644 index 0000000000000..8ba3dd3454bd3 --- /dev/null +++ b/localstack-core/localstack/http/asgi.py @@ -0,0 +1,21 @@ +from rolo.asgi import ( + ASGIAdapter, + ASGILifespanListener, + RawHTTPRequestEventStreamAdapter, + WebSocketEnvironment, + WebSocketListener, + WsgiStartResponse, + create_wsgi_input, + populate_wsgi_environment, +) + +__all__ = [ + "WebSocketEnvironment", + "populate_wsgi_environment", + "create_wsgi_input", + "RawHTTPRequestEventStreamAdapter", + "WsgiStartResponse", + "ASGILifespanListener", + "WebSocketListener", + "ASGIAdapter", +] diff --git a/localstack-core/localstack/http/client.py b/localstack-core/localstack/http/client.py new file mode 100644 index 0000000000000..cb8f4b33aee31 --- /dev/null +++ b/localstack-core/localstack/http/client.py @@ -0,0 +1,7 @@ +from rolo.client import HttpClient, SimpleRequestsClient, make_request + +__all__ = [ + "HttpClient", + "SimpleRequestsClient", + "make_request", +] diff --git a/localstack-core/localstack/http/dispatcher.py b/localstack-core/localstack/http/dispatcher.py new file mode 100644 index 0000000000000..308450fbd3296 --- /dev/null +++ b/localstack-core/localstack/http/dispatcher.py @@ -0,0 +1,25 @@ +from json import JSONEncoder +from typing import Type + +from rolo.routing.handler import Handler, ResultValue +from rolo.routing.handler import handler_dispatcher as _handler_dispatcher +from rolo.routing.router import Dispatcher + +from localstack.utils.json import CustomEncoder + +__all__ = [ + "ResultValue", + "Handler", + "handler_dispatcher", +] + + +def handler_dispatcher(json_encoder: Type[JSONEncoder] = None) -> Dispatcher[Handler]: + """ + Replacement for ``rolo.dispatcher.handler_dispatcher`` that uses by default LocalStack's CustomEncoder for + serializing JSON documents. + + :param json_encoder: the encoder to use + :return: a Dispatcher that dispatches to instances of a Handler + """ + return _handler_dispatcher(json_encoder or CustomEncoder) diff --git a/localstack-core/localstack/http/duplex_socket.py b/localstack-core/localstack/http/duplex_socket.py new file mode 100644 index 0000000000000..8006f398668e5 --- /dev/null +++ b/localstack-core/localstack/http/duplex_socket.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import logging +import socket +import ssl +from asyncio.selector_events import BaseSelectorEventLoop + +from localstack.utils.asyncio import run_sync +from localstack.utils.objects import singleton_factory +from localstack.utils.patch import Patch, patch + +# set up logger +LOG = logging.getLogger(__name__) + + +class DuplexSocket(ssl.SSLSocket): + """Simple duplex socket wrapper that allows serving HTTP/HTTPS over the same port.""" + + def accept(self): + newsock, addr = socket.socket.accept(self) + if DuplexSocket.is_ssl_socket(newsock) is not False: + newsock = self.context.wrap_socket( + newsock, + do_handshake_on_connect=self.do_handshake_on_connect, + suppress_ragged_eofs=self.suppress_ragged_eofs, + server_side=True, + ) + + return newsock, addr + + @staticmethod + def is_ssl_socket(newsock): + """Returns True/False if the socket uses SSL or not, or None if the status cannot be + determined""" + + def peek_ssl_header(): + peek_bytes = 5 + first_bytes = newsock.recv(peek_bytes, socket.MSG_PEEK) + if len(first_bytes or "") != peek_bytes: + return + first_byte = first_bytes[0] + return first_byte < 32 or first_byte >= 127 + + try: + return peek_ssl_header() + except Exception: + # Fix for "[Errno 11] Resource temporarily unavailable" - This can + # happen if we're using a non-blocking socket in a blocking thread. + newsock.setblocking(1) + newsock.settimeout(1) + try: + return peek_ssl_header() + except Exception: + return False + + +@singleton_factory +def enable_duplex_socket(): + """ + Function which replaces the ssl.SSLContext.sslsocket_class with the DuplexSocket, enabling serving both, + HTTP and HTTPS connections on a single port. + """ + + # set globally defined SSL socket implementation class + Patch(ssl.SSLContext, "sslsocket_class", DuplexSocket).apply() + + if hasattr(BaseSelectorEventLoop, "_accept_connection2"): + + @patch(BaseSelectorEventLoop._accept_connection2) + async def _accept_connection2( + fn, self, protocol_factory, conn, extra, sslcontext, *args, **kwargs + ): + is_ssl_socket = await run_sync(DuplexSocket.is_ssl_socket, conn) + if is_ssl_socket is False: + sslcontext = None + result = await fn(self, protocol_factory, conn, extra, sslcontext, *args, **kwargs) + return result diff --git a/localstack-core/localstack/http/hypercorn.py b/localstack-core/localstack/http/hypercorn.py new file mode 100644 index 0000000000000..e14f2e167c797 --- /dev/null +++ b/localstack-core/localstack/http/hypercorn.py @@ -0,0 +1,146 @@ +import asyncio +import threading +from asyncio import AbstractEventLoop + +from hypercorn import Config +from hypercorn.asyncio import serve +from hypercorn.typing import ASGIFramework + +from localstack.aws.gateway import Gateway +from localstack.aws.handlers.proxy import ProxyHandler +from localstack.aws.serving.asgi import AsgiGateway +from localstack.config import HostAndPort +from localstack.logging.setup import setup_hypercorn_logger +from localstack.utils.collections import ensure_list +from localstack.utils.functions import call_safe +from localstack.utils.serving import Server +from localstack.utils.ssl import create_ssl_cert, install_predefined_cert_if_available + + +class HypercornServer(Server): + """ + A sync wrapper around Hypercorn that implements the ``Server`` interface. + """ + + def __init__(self, app: ASGIFramework, config: Config, loop: AbstractEventLoop = None): + """ + Create a new Hypercorn server instance. Note that, if you pass an event loop to the constructor, + you are yielding control of that event loop to the server, as it will invoke `run_until_complete` and + shutdown the loop. + + :param app: the ASGI3 app + :param config: the hypercorn config + :param loop: optionally the event loop, otherwise ``asyncio.new_event_loop`` will be called + """ + self.app = app + self.config = config + self.loop = loop or asyncio.new_event_loop() + + self._close = asyncio.Event() + self._closed = threading.Event() + + parts = config.bind[0].split(":") + if len(parts) == 1: + # check ssl + host = parts[0] + port = 443 if config.ssl_enabled else 80 + else: + host, port = parts[0], int(parts[1]) + + super().__init__(port, host) + + @property + def protocol(self): + return "https" if self.config.ssl_enabled else "http" + + def do_run(self): + self.loop.run_until_complete( + serve(self.app, self.config, shutdown_trigger=self._shutdown_trigger) + ) + self._closed.set() + + def do_shutdown(self): + asyncio.run_coroutine_threadsafe(self._set_closed(), self.loop) + self._closed.wait(timeout=10) + asyncio.run_coroutine_threadsafe(self.loop.shutdown_asyncgens(), self.loop) + self.loop.shutdown_default_executor() + self.loop.stop() + call_safe(self.loop.close) + + async def _set_closed(self): + self._close.set() + + async def _shutdown_trigger(self): + await self._close.wait() + + +class GatewayServer(HypercornServer): + """ + A Hypercorn-based server implementation which serves a given Gateway. + It can be used to easily spawn new gateway servers, defining their individual request-, response-, and + exception-handlers. + """ + + def __init__( + self, + gateway: Gateway, + listen: HostAndPort | list[HostAndPort], + use_ssl: bool = False, + threads: int | None = None, + ): + """ + Creates a new GatewayServer instance. + + :param gateway: which will be served by this server + :param listen: defining the address and port pairs this server binds to. Can be a list of host and port pairs. + :param use_ssl: True if the LocalStack cert should be loaded and HTTP/HTTPS multiplexing should be enabled. + :param threads: Number of worker threads the gateway will use. + """ + # build server config + config = Config() + config.h11_pass_raw_headers = True + setup_hypercorn_logger(config) + + listens = ensure_list(listen) + config.bind = [str(host_and_port) for host_and_port in listens] + + if use_ssl: + install_predefined_cert_if_available() + serial_number = listens[0].port + _, cert_file_name, key_file_name = create_ssl_cert(serial_number=serial_number) + config.certfile = cert_file_name + config.keyfile = key_file_name + + # build gateway + loop = asyncio.new_event_loop() + app = AsgiGateway(gateway, event_loop=loop, threads=threads) + + # start serving gateway + super().__init__(app, config, loop) + + def do_shutdown(self): + super().do_shutdown() + self.app.close() # noqa (app will be of type AsgiGateway) + + +class ProxyServer(GatewayServer): + """ + Proxy server implementation which uses the localstack.http.proxy module. + These server instances can be spawned easily, while implementing HTTP/HTTPS multiplexing (if enabled), + and just forward all incoming requests to a backend. + """ + + def __init__( + self, forward_base_url: str, listen: HostAndPort | list[HostAndPort], use_ssl: bool = False + ): + """ + Creates a new ProxyServer instance. + + :param forward_base_url: URL of the backend system all requests this server receives should be forwarded to + :param port: defining the port of this server instance + :param bind_address: to bind this server instance to. Can be a host string or a list of host strings. + :param use_ssl: True if the LocalStack cert should be loaded and HTTP/HTTPS multiplexing should be enabled. + """ + gateway = Gateway() + gateway.request_handlers.append(ProxyHandler(forward_base_url=forward_base_url)) + super().__init__(gateway, listen, use_ssl) diff --git a/localstack-core/localstack/http/proxy.py b/localstack-core/localstack/http/proxy.py new file mode 100644 index 0000000000000..35cf74719277a --- /dev/null +++ b/localstack-core/localstack/http/proxy.py @@ -0,0 +1,7 @@ +from rolo.proxy import Proxy, ProxyHandler, forward + +__all__ = [ + "forward", + "Proxy", + "ProxyHandler", +] diff --git a/localstack-core/localstack/http/request.py b/localstack-core/localstack/http/request.py new file mode 100644 index 0000000000000..411ead4ab6bde --- /dev/null +++ b/localstack-core/localstack/http/request.py @@ -0,0 +1,21 @@ +from rolo.request import ( + Request, + dummy_wsgi_environment, + get_full_raw_path, + get_raw_base_url, + get_raw_current_url, + get_raw_path, + restore_payload, + set_environment_headers, +) + +__all__ = [ + "dummy_wsgi_environment", + "set_environment_headers", + "Request", + "get_raw_path", + "get_full_raw_path", + "get_raw_base_url", + "get_raw_current_url", + "restore_payload", +] diff --git a/localstack-core/localstack/http/resource.py b/localstack-core/localstack/http/resource.py new file mode 100644 index 0000000000000..40db6d941b0aa --- /dev/null +++ b/localstack-core/localstack/http/resource.py @@ -0,0 +1,6 @@ +from rolo.resource import Resource, resource + +__all__ = [ + "resource", + "Resource", +] diff --git a/localstack/services/firehose/__init__.py b/localstack-core/localstack/http/resources/__init__.py similarity index 100% rename from localstack/services/firehose/__init__.py rename to localstack-core/localstack/http/resources/__init__.py diff --git a/localstack/services/kinesis/__init__.py b/localstack-core/localstack/http/resources/swagger/__init__.py similarity index 100% rename from localstack/services/kinesis/__init__.py rename to localstack-core/localstack/http/resources/swagger/__init__.py diff --git a/localstack-core/localstack/http/resources/swagger/endpoints.py b/localstack-core/localstack/http/resources/swagger/endpoints.py new file mode 100644 index 0000000000000..f6cef4c9a33f8 --- /dev/null +++ b/localstack-core/localstack/http/resources/swagger/endpoints.py @@ -0,0 +1,25 @@ +import os + +from jinja2 import Environment, FileSystemLoader +from rolo import Request, route + +from localstack.config import external_service_url +from localstack.http import Response + + +def _get_service_url(request: Request) -> str: + # special case for ephemeral instances + if "sandbox.localstack.cloud" in request.host: + return external_service_url(protocol="https", port=443) + return external_service_url(protocol=request.scheme) + + +class SwaggerUIApi: + @route("/_localstack/swagger", methods=["GET"]) + def server_swagger_ui(self, request: Request) -> Response: + init_path = f"{_get_service_url(request)}/openapi.yaml" + oas_path = os.path.join(os.path.dirname(__file__), "templates") + env = Environment(loader=FileSystemLoader(oas_path)) + template = env.get_template("index.html") + rendered_template = template.render(swagger_url=init_path) + return Response(rendered_template, content_type="text/html") diff --git a/localstack-core/localstack/http/resources/swagger/plugins.py b/localstack-core/localstack/http/resources/swagger/plugins.py new file mode 100644 index 0000000000000..2e464f50deacd --- /dev/null +++ b/localstack-core/localstack/http/resources/swagger/plugins.py @@ -0,0 +1,23 @@ +import werkzeug +import yaml +from rolo.routing import RuleAdapter + +from localstack.http.resources.swagger.endpoints import SwaggerUIApi +from localstack.runtime import hooks +from localstack.services.edge import ROUTER +from localstack.services.internal import get_internal_apis +from localstack.utils.openapi import get_localstack_openapi_spec + + +@hooks.on_infra_start() +def register_swagger_endpoints(): + get_internal_apis().add(SwaggerUIApi()) + + def _serve_openapi_spec(_request): + spec = get_localstack_openapi_spec() + response_body = yaml.dump(spec) + return werkzeug.Response( + response_body, content_type="application/yaml", direct_passthrough=True + ) + + ROUTER.add(RuleAdapter("/openapi.yaml", _serve_openapi_spec)) diff --git a/localstack-core/localstack/http/resources/swagger/templates/index.html b/localstack-core/localstack/http/resources/swagger/templates/index.html new file mode 100644 index 0000000000000..a852b132deb56 --- /dev/null +++ b/localstack-core/localstack/http/resources/swagger/templates/index.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="description" content="SwaggerUI" /> + <title>SwaggerUI + + + +
+ + + + diff --git a/localstack-core/localstack/http/response.py b/localstack-core/localstack/http/response.py new file mode 100644 index 0000000000000..66863c147d370 --- /dev/null +++ b/localstack-core/localstack/http/response.py @@ -0,0 +1,22 @@ +from json import JSONEncoder +from typing import Any, Type + +from rolo import Response as RoloResponse + +from localstack.utils.common import CustomEncoder + + +class Response(RoloResponse): + """ + An HTTP Response object, which simply extends werkzeug's Response object with a few convenience methods. + """ + + def set_json(self, doc: Any, cls: Type[JSONEncoder] = CustomEncoder): + """ + Serializes the given dictionary using localstack's ``CustomEncoder`` into a json response, and sets the + mimetype automatically to ``application/json``. + + :param doc: the response dictionary to be serialized as JSON + :param cls: the json encoder used + """ + return super().set_json(doc, cls or CustomEncoder) diff --git a/localstack-core/localstack/http/router.py b/localstack-core/localstack/http/router.py new file mode 100644 index 0000000000000..da3bcdfe043c0 --- /dev/null +++ b/localstack-core/localstack/http/router.py @@ -0,0 +1,52 @@ +from typing import ( + Any, + Mapping, + TypeVar, +) + +from rolo.routing import ( + PortConverter, + RegexConverter, + Router, + RuleAdapter, + RuleGroup, + WithHost, + route, +) +from rolo.routing.router import Dispatcher, call_endpoint +from werkzeug.routing import PathConverter + +HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE") + +E = TypeVar("E") +RequestArguments = Mapping[str, Any] + + +class GreedyPathConverter(PathConverter): + """ + This converter makes sure that the path ``/mybucket//mykey`` can be matched to the pattern + ``/`` and will result in `Key` being `/mykey`. + """ + + regex = ".*?" + + part_isolating = False + """From the werkzeug docs: If a custom converter can match a forward slash, /, it should have the + attribute part_isolating set to False. This will ensure that rules using the custom converter are + correctly matched.""" + + +__all__ = [ + "RequestArguments", + "HTTP_METHODS", + "RegexConverter", + "PortConverter", + "Dispatcher", + "route", + "call_endpoint", + "Router", + "RuleAdapter", + "WithHost", + "RuleGroup", + "GreedyPathConverter", +] diff --git a/localstack-core/localstack/http/trace.py b/localstack-core/localstack/http/trace.py new file mode 100644 index 0000000000000..7d52b9ebf36dc --- /dev/null +++ b/localstack-core/localstack/http/trace.py @@ -0,0 +1,348 @@ +import dataclasses +import inspect +import logging +import time +from typing import Any, Callable + +from rolo import Response +from rolo.gateway import ExceptionHandler, Handler, HandlerChain, RequestContext +from werkzeug.datastructures import Headers + +from localstack.utils.patch import Patch, Patches + +LOG = logging.getLogger(__name__) + + +class Action: + """ + Encapsulates something that the handler performed on the request context, request, or response objects. + """ + + name: str + + def __init__(self, name: str): + self.name = name + + def __repr__(self): + return self.name + + +class SetAttributeAction(Action): + """ + The handler set an attribute of the request context or something else. + """ + + key: str + value: Any | None + + def __init__(self, key: str, value: Any | None = None): + super().__init__("set") + self.key = key + self.value = value + + def __repr__(self): + if self.value is None: + return f"set {self.key}" + return f"set {self.key} = {self.value!r}" + + +class ModifyHeadersAction(Action): + """ + The handler modified headers in some way, either adding, updating, or removing headers. + """ + + def __init__(self, name: str, before: Headers, after: Headers): + super().__init__(name) + self.before = before + self.after = after + + @property + def header_actions(self) -> list[Action]: + after = self.after + before = self.before + + actions = [] + + headers_set = dict(set(after.items()) - set(before.items())) + headers_removed = {k: v for k, v in before.items() if k not in after} + + for k, v in headers_set.items(): + actions.append(Action(f"set '{k}: {v}'")) + for k, v in headers_removed.items(): + actions.append(Action(f"del '{k}: {v}'")) + + return actions + + +@dataclasses.dataclass +class HandlerTrace: + handler: Handler + """The handler""" + duration_ms: float + """The runtime duration of the handler in milliseconds""" + actions: list[Action] + """The actions the handler chain performed""" + + @property + def handler_module(self): + return self.handler.__module__ + + @property + def handler_name(self): + if inspect.isfunction(self.handler): + return self.handler.__name__ + else: + return self.handler.__class__.__name__ + + +def _log_method_call(name: str, actions: list[Action]): + """Creates a wrapper around the original method `_fn`. It appends an action to the `actions` + list indicating that the function was called and then returns the original function.""" + + def _proxy(self, _fn, *args, **kwargs): + actions.append(Action(f"call {name}")) + return _fn(*args, **kwargs) + + return _proxy + + +class TracingHandlerBase: + """ + This class is a Handler that records a trace of the execution of another request handler. It has two + attributes: `trace`, which stores the tracing information, and `delegate`, which is the handler or + exception handler that will be traced. + """ + + trace: HandlerTrace | None + delegate: Handler | ExceptionHandler + + def __init__(self, delegate: Handler | ExceptionHandler): + self.trace = None + self.delegate = delegate + + def do_trace_call( + self, fn: Callable, chain: HandlerChain, context: RequestContext, response: Response + ): + """ + Wraps the function call with the tracing functionality and records a HandlerTrace. + + The method determines changes made by the request handler to specific aspects of the request. + Changes made to the request context and the response headers/status by the request handler are then + examined, and appropriate actions are added to the `actions` list of the trace. + + :param fn: which is the function to be traced, which is the request/response/exception handler + :param chain: the handler chain + :param context: the request context + :param response: the response object + """ + then = time.perf_counter() + + actions = [] + + prev_context = dict(context.__dict__) + prev_stopped = chain.stopped + prev_request_identity = id(context.request) + prev_terminated = chain.terminated + prev_request_headers = context.request.headers.copy() + prev_response_headers = response.headers.copy() + prev_response_status = response.status_code + + # add patches to log invocations or certain functions + patches = Patches( + [ + Patch.function( + context.request.get_data, + _log_method_call("request.get_data", actions), + ), + Patch.function( + context.request._load_form_data, + _log_method_call("request._load_form_data", actions), + ), + Patch.function( + response.get_data, + _log_method_call("response.get_data", actions), + ), + ] + ) + patches.apply() + + try: + return fn() + finally: + now = time.perf_counter() + # determine some basic things the handler changed in the context + patches.undo() + + # chain + if chain.stopped and not prev_stopped: + actions.append(Action("stop chain")) + if chain.terminated and not prev_terminated: + actions.append(Action("terminate chain")) + + # detect when attributes are set in the request contex + context_args = dict(context.__dict__) + context_args.pop("request", None) # request is handled separately + + for k, v in context_args.items(): + if not v: + continue + if prev_context.get(k): + # TODO: we could introduce "ModifyAttributeAction(k,v)" with an additional check + # ``if v != prev_context.get(k)`` + continue + actions.append(SetAttributeAction(k, v)) + + # request + if id(context.request) != prev_request_identity: + actions.append(Action("replaced request object")) + + # response + if response.status_code != prev_response_status: + actions.append(SetAttributeAction("response stats_code", response.status_code)) + if context.request.headers != prev_request_headers: + actions.append( + ModifyHeadersAction( + "modify request headers", + prev_request_headers, + context.request.headers.copy(), + ) + ) + if response.headers != prev_response_headers: + actions.append( + ModifyHeadersAction( + "modify response headers", prev_response_headers, response.headers.copy() + ) + ) + + self.trace = HandlerTrace( + handler=self.delegate, duration_ms=(now - then) * 1000, actions=actions + ) + + +class TracingHandler(TracingHandlerBase): + delegate: Handler + + def __init__(self, delegate: Handler): + super().__init__(delegate) + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + def _call(): + return self.delegate(chain, context, response) + + return self.do_trace_call(_call, chain, context, response) + + +class TracingExceptionHandler(TracingHandlerBase): + delegate: ExceptionHandler + + def __init__(self, delegate: ExceptionHandler): + super().__init__(delegate) + + def __call__( + self, chain: HandlerChain, exception: Exception, context: RequestContext, response: Response + ): + def _call(): + return self.delegate(chain, exception, context, response) + + return self.do_trace_call(_call, chain, context, response) + + +class TracingHandlerChain(HandlerChain): + """ + DebuggingHandlerChain - A subclass of HandlerChain for logging and tracing handlers. + + Attributes: + - duration (float): Total time taken for handling request in milliseconds. + - request_handler_traces (list[HandlerTrace]): List of request handler traces. + - response_handler_traces (list[HandlerTrace]): List of response handler traces. + - finalizer_traces (list[HandlerTrace]): List of finalizer traces. + - exception_handler_traces (list[HandlerTrace]): List of exception handler traces. + """ + + duration: float + request_handler_traces: list[HandlerTrace] + response_handler_traces: list[HandlerTrace] + finalizer_traces: list[HandlerTrace] + exception_handler_traces: list[HandlerTrace] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request_handler_traces = [] + self.response_handler_traces = [] + self.finalizer_traces = [] + self.exception_handler_traces = [] + + def handle(self, context: RequestContext, response: Response): + """Overrides HandlerChain's handle method and adds tracing handler to request handlers. Logs the trace + report with request and response details.""" + then = time.perf_counter() + try: + self.request_handlers = [TracingHandler(handler) for handler in self.request_handlers] + return super().handle(context, response) + finally: + self.duration = (time.perf_counter() - then) * 1000 + self.request_handler_traces = [handler.trace for handler in self.request_handlers] + self._log_report() + + def _call_response_handlers(self, response): + self.response_handlers = [TracingHandler(handler) for handler in self.response_handlers] + try: + return super()._call_response_handlers(response) + finally: + self.response_handler_traces = [handler.trace for handler in self.response_handlers] + + def _call_finalizers(self, response): + self.finalizers = [TracingHandler(handler) for handler in self.finalizers] + try: + return super()._call_response_handlers(response) + finally: + self.finalizer_traces = [handler.trace for handler in self.finalizers] + + def _call_exception_handlers(self, e, response): + self.exception_handlers = [ + TracingExceptionHandler(handler) for handler in self.exception_handlers + ] + try: + return super()._call_exception_handlers(e, response) + finally: + self.exception_handler_traces = [handler.trace for handler in self.exception_handlers] + + def _log_report(self): + report = [] + request = self.context.request + response = self.response + + def _append_traces(traces: list[HandlerTrace]): + """Format and appends a list of traces to the report, and recursively append the trace's + actions (if any).""" + + for trace in traces: + if trace is None: + continue + + report.append( + f"{trace.handler_module:43s} {trace.handler_name:30s} {trace.duration_ms:8.2f}ms" + ) + _append_actions(trace.actions, 46) + + def _append_actions(actions: list[Action], indent: int): + for action in actions: + report.append((" " * indent) + f"- {action!r}") + + if isinstance(action, ModifyHeadersAction): + _append_actions(action.header_actions, indent + 2) + + report.append(f"request: {request.method} {request.url}") + report.append(f"response: {response.status_code}") + report.append("---- request handlers " + ("-" * 63)) + _append_traces(self.request_handler_traces) + report.append("---- response handlers " + ("-" * 63)) + _append_traces(self.response_handler_traces) + report.append("---- finalizers " + ("-" * 63)) + _append_traces(self.finalizer_traces) + report.append("---- exception handlers " + ("-" * 63)) + _append_traces(self.exception_handler_traces) + # Add a separator and total duration value to the end of the report + report.append(f"{'=' * 68} total {self.duration:8.2f}ms") + + LOG.info("handler chain trace report:\n%s\n%s", "=" * 85, "\n".join(report)) diff --git a/localstack-core/localstack/http/websocket.py b/localstack-core/localstack/http/websocket.py new file mode 100644 index 0000000000000..9bd92a927a998 --- /dev/null +++ b/localstack-core/localstack/http/websocket.py @@ -0,0 +1,15 @@ +from rolo.websocket.websocket import ( + WebSocket, + WebSocketDisconnectedError, + WebSocketError, + WebSocketProtocolError, + WebSocketRequest, +) + +__all__ = [ + "WebSocketError", + "WebSocketDisconnectedError", + "WebSocketProtocolError", + "WebSocket", + "WebSocketRequest", +] diff --git a/localstack/services/s3/__init__.py b/localstack-core/localstack/logging/__init__.py similarity index 100% rename from localstack/services/s3/__init__.py rename to localstack-core/localstack/logging/__init__.py diff --git a/localstack-core/localstack/logging/format.py b/localstack-core/localstack/logging/format.py new file mode 100644 index 0000000000000..5f308e34d9ecf --- /dev/null +++ b/localstack-core/localstack/logging/format.py @@ -0,0 +1,194 @@ +"""Tools for formatting localstack logs.""" + +import logging +import re +from functools import lru_cache +from typing import Any, Dict + +from localstack.utils.numbers import format_bytes +from localstack.utils.strings import to_bytes + +MAX_THREAD_NAME_LEN = 12 +MAX_NAME_LEN = 26 + +LOG_FORMAT = f"%(asctime)s.%(msecs)03d %(ls_level)5s --- [%(ls_thread){MAX_THREAD_NAME_LEN}s] %(ls_name)-{MAX_NAME_LEN}s : %(message)s" +LOG_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" +LOG_INPUT_FORMAT = "%(input_type)s(%(input)s, headers=%(request_headers)s)" +LOG_OUTPUT_FORMAT = "%(output_type)s(%(output)s, headers=%(response_headers)s)" +LOG_CONTEXT_FORMAT = "%(account_id)s/%(region)s" + +CUSTOM_LEVEL_NAMES = { + 50: "FATAL", + 40: "ERROR", + 30: "WARN", + 20: "INFO", + 10: "DEBUG", +} + + +class DefaultFormatter(logging.Formatter): + """ + A formatter that uses ``LOG_FORMAT`` and ``LOG_DATE_FORMAT``. + """ + + def __init__(self, fmt=LOG_FORMAT, datefmt=LOG_DATE_FORMAT): + super(DefaultFormatter, self).__init__(fmt=fmt, datefmt=datefmt) + + +class AddFormattedAttributes(logging.Filter): + """ + Filter that adds three attributes to a log record: + + - ls_level: the abbreviated loglevel that's max 5 characters long + - ls_name: the abbreviated name of the logger (e.g., `l.bootstrap.install`), trimmed to ``MAX_NAME_LEN`` + - ls_thread: the abbreviated thread name (prefix trimmed, .e.g, ``omeThread-108``) + """ + + max_name_len: int + max_thread_len: int + + def __init__(self, max_name_len: int = None, max_thread_len: int = None): + super(AddFormattedAttributes, self).__init__() + self.max_name_len = max_name_len if max_name_len else MAX_NAME_LEN + self.max_thread_len = max_thread_len if max_thread_len else MAX_THREAD_NAME_LEN + + def filter(self, record): + record.ls_level = CUSTOM_LEVEL_NAMES.get(record.levelno, record.levelname) + record.ls_name = self._get_compressed_logger_name(record.name) + record.ls_thread = record.threadName[-self.max_thread_len :] + return True + + @lru_cache(maxsize=256) + def _get_compressed_logger_name(self, name): + return compress_logger_name(name, self.max_name_len) + + +class MaskSensitiveInputFilter(logging.Filter): + """ + Filter that hides sensitive from a binary json string in a record input. + It will find the mathing keys and replace their values with "******" + + For example, if initialized with `sensitive_keys=["my_key"]`, the input + b'{"my_key": "sensitive_value"}' would become b'{"my_key": "******"}'. + """ + + patterns: list[tuple[re.Pattern[bytes], bytes]] + + def __init__(self, sensitive_keys: list[str]): + super(MaskSensitiveInputFilter, self).__init__() + + self.patterns = [ + (re.compile(to_bytes(rf'"{key}":\s*"[^"]+"')), to_bytes(f'"{key}": "******"')) + for key in sensitive_keys + ] + + def filter(self, record): + if record.input and isinstance(record.input, bytes): + record.input = self.mask_sensitive_msg(record.input) + return True + + def mask_sensitive_msg(self, message: bytes) -> bytes: + for pattern, replacement in self.patterns: + message = re.sub(pattern, replacement, message) + return message + + +def compress_logger_name(name: str, length: int) -> str: + """ + Creates a short version of a logger name. For example ``my.very.long.logger.name`` with length=17 turns into + ``m.v.l.logger.name``. + + :param name: the logger name + :param length: the max length of the logger name + :return: the compressed name + """ + if len(name) <= length: + return name + + parts = name.split(".") + parts.reverse() + + new_parts = [] + + # we start by assuming that all parts are collapsed + # x.x.x requires 5 = 2n - 1 characters + cur_length = (len(parts) * 2) - 1 + + for i in range(len(parts)): + # try to expand the current part and calculate the resulting length + part = parts[i] + next_len = cur_length + (len(part) - 1) + + if next_len > length: + # if the resulting length would exceed the limit, add only the first letter of the parts of all remaining + # parts + new_parts += [p[0] for p in parts[i:]] + + # but if this is the first item, that means we would display nothing, so at least display as much of the + # max length as possible + if i == 0: + remaining = length - cur_length + if remaining > 0: + new_parts[0] = part[: (remaining + 1)] + + break + + # expanding the current part, i.e., instead of using just the one character, we add the entire part + new_parts.append(part) + cur_length = next_len + + new_parts.reverse() + return ".".join(new_parts) + + +class TraceLoggingFormatter(logging.Formatter): + aws_trace_log_format = "; ".join([LOG_FORMAT, LOG_INPUT_FORMAT, LOG_OUTPUT_FORMAT]) + bytes_length_display_threshold = 512 + + def __init__(self): + super().__init__(fmt=self.aws_trace_log_format, datefmt=LOG_DATE_FORMAT) + + def _replace_large_payloads(self, input: Any) -> Any: + """ + Replaces large payloads in the logs with placeholders to avoid cluttering the logs with huge bytes payloads. + :param input: Input/output extra passed when logging. If it is bytes, it will be replaced if larger than + bytes_length_display_threshold + :return: Input, unless it is bytes and longer than bytes_length_display_threshold, then `Bytes(length_of_input)` + """ + if isinstance(input, bytes) and len(input) > self.bytes_length_display_threshold: + return f"Bytes({format_bytes(len(input))})" + return input + + def format(self, record: logging.LogRecord) -> str: + record.input = self._replace_large_payloads(record.input) + record.output = self._replace_large_payloads(record.output) + return super().format(record=record) + + +class AwsTraceLoggingFormatter(TraceLoggingFormatter): + aws_trace_log_format = "; ".join( + [LOG_FORMAT, LOG_CONTEXT_FORMAT, LOG_INPUT_FORMAT, LOG_OUTPUT_FORMAT] + ) + + def __init__(self): + super().__init__() + + def _copy_service_dict(self, service_dict: Dict) -> Dict: + if not isinstance(service_dict, Dict): + return service_dict + result = {} + for key, value in service_dict.items(): + if isinstance(value, dict): + result[key] = self._copy_service_dict(value) + elif isinstance(value, bytes) and len(value) > self.bytes_length_display_threshold: + result[key] = f"Bytes({format_bytes(len(value))})" + elif isinstance(value, list): + result[key] = [self._copy_service_dict(item) for item in value] + else: + result[key] = value + return result + + def format(self, record: logging.LogRecord) -> str: + record.input = self._copy_service_dict(record.input) + record.output = self._copy_service_dict(record.output) + return super().format(record=record) diff --git a/localstack-core/localstack/logging/setup.py b/localstack-core/localstack/logging/setup.py new file mode 100644 index 0000000000000..4a10d7cb7452d --- /dev/null +++ b/localstack-core/localstack/logging/setup.py @@ -0,0 +1,142 @@ +import logging +import sys +import warnings + +from localstack import config, constants + +from ..utils.strings import key_value_pairs_to_dict +from .format import AddFormattedAttributes, DefaultFormatter + +# The log levels for modules are evaluated incrementally for logging granularity, +# from highest (DEBUG) to lowest (TRACE_INTERNAL). Hence, each module below should have +# higher level which serves as the default. + +default_log_levels = { + "asyncio": logging.INFO, + "boto3": logging.INFO, + "botocore": logging.ERROR, + "docker": logging.WARNING, + "elasticsearch": logging.ERROR, + "hpack": logging.ERROR, + "moto": logging.WARNING, + "requests": logging.WARNING, + "s3transfer": logging.INFO, + "urllib3": logging.WARNING, + "werkzeug": logging.WARNING, + "rolo": logging.WARNING, + "parse": logging.WARNING, + "localstack.aws.accounts": logging.INFO, + "localstack.aws.protocol.serializer": logging.INFO, + "localstack.aws.serving.wsgi": logging.WARNING, + "localstack.request": logging.INFO, + "localstack.request.internal": logging.WARNING, + "localstack.state.inspect": logging.INFO, + "localstack_persistence": logging.INFO, +} + +trace_log_levels = { + "rolo": logging.DEBUG, + "localstack.aws.protocol.serializer": logging.DEBUG, + "localstack.aws.serving.wsgi": logging.DEBUG, + "localstack.request": logging.DEBUG, + "localstack.request.internal": logging.INFO, + "localstack.state.inspect": logging.DEBUG, +} + +trace_internal_log_levels = { + "localstack.aws.accounts": logging.DEBUG, + "localstack.request.internal": logging.DEBUG, +} + + +def setup_logging_for_cli(log_level=logging.INFO): + logging.basicConfig(level=log_level) + + # set log levels of loggers + logging.root.setLevel(log_level) + logging.getLogger("localstack").setLevel(log_level) + for logger, level in default_log_levels.items(): + logging.getLogger(logger).setLevel(level) + + +def get_log_level_from_config(): + # overriding the log level if LS_LOG has been set + if config.LS_LOG: + log_level = str(config.LS_LOG).upper() + if log_level.lower() in constants.TRACE_LOG_LEVELS: + log_level = "DEBUG" + log_level = logging._nameToLevel[log_level] + return log_level + + return logging.DEBUG if config.DEBUG else logging.INFO + + +def setup_logging_from_config(): + log_level = get_log_level_from_config() + setup_logging(log_level) + + if config.is_trace_logging_enabled(): + for name, level in trace_log_levels.items(): + logging.getLogger(name).setLevel(level) + if config.LS_LOG == constants.LS_LOG_TRACE_INTERNAL: + for name, level in trace_internal_log_levels.items(): + logging.getLogger(name).setLevel(level) + + raw_logging_override = config.LOG_LEVEL_OVERRIDES + if raw_logging_override: + logging_overrides = key_value_pairs_to_dict(raw_logging_override) + for logger, level_name in logging_overrides.items(): + level = getattr(logging, level_name, None) + if not level: + raise ValueError( + f"Failed to configure logging overrides ({raw_logging_override}): '{level_name}' is not a valid log level" + ) + logging.getLogger(logger).setLevel(level) + + +def create_default_handler(log_level: int): + log_handler = logging.StreamHandler(stream=sys.stderr) + log_handler.setLevel(log_level) + log_handler.setFormatter(DefaultFormatter()) + log_handler.addFilter(AddFormattedAttributes()) + return log_handler + + +def setup_logging(log_level=logging.INFO) -> None: + """ + Configures the python logging environment for LocalStack. + + :param log_level: the optional log level. + """ + # set create a default handler for the root logger (basically logging.basicConfig but explicit) + log_handler = create_default_handler(log_level) + + # replace any existing handlers + logging.basicConfig(level=log_level, handlers=[log_handler]) + + # disable some logs and warnings + warnings.filterwarnings("ignore") + logging.captureWarnings(True) + + # set log levels of loggers + logging.root.setLevel(log_level) + logging.getLogger("localstack").setLevel(log_level) + for logger, level in default_log_levels.items(): + logging.getLogger(logger).setLevel(level) + + +def setup_hypercorn_logger(hypercorn_config) -> None: + """ + Sets the hypercorn loggers, which are created in a peculiar way, to the localstack settings. + + :param hypercorn_config: a hypercorn.Config object + """ + logger = hypercorn_config.log.access_logger + if logger: + logger.handlers[0].addFilter(AddFormattedAttributes()) + logger.handlers[0].setFormatter(DefaultFormatter()) + + logger = hypercorn_config.log.error_logger + if logger: + logger.handlers[0].addFilter(AddFormattedAttributes()) + logger.handlers[0].setFormatter(DefaultFormatter()) diff --git a/localstack-core/localstack/openapi.yaml b/localstack-core/localstack/openapi.yaml new file mode 100644 index 0000000000000..b3656c3f6f1af --- /dev/null +++ b/localstack-core/localstack/openapi.yaml @@ -0,0 +1,1070 @@ +openapi: 3.1.0 +info: + contact: + email: info@localstack.cloud + name: LocalStack Support + url: https://www.localstack.cloud/contact + summary: The LocalStack REST API exposes functionality related to diagnostics, health + checks, plugins, initialisation hooks, service introspection, and more. + termsOfService: https://www.localstack.cloud/legal/tos + title: LocalStack REST API for Community + version: latest +externalDocs: + description: LocalStack Documentation + url: https://docs.localstack.cloud +servers: + - url: http://{host}:{port} + variables: + port: + default: '4566' + host: + default: 'localhost.localstack.cloud' +components: + parameters: + SesIdFilter: + description: Filter for the `id` field in SES message + in: query + name: id + required: false + schema: + type: string + SesEmailFilter: + description: Filter for the `source` field in SES message + in: query + name: email + required: false + schema: + type: string + SnsAccountId: + description: '`accountId` field of the resource' + in: query + name: accountId + required: false + schema: + default: '000000000000' + type: string + SnsEndpointArn: + description: '`endpointArn` field of the resource' + in: query + name: endpointArn + required: false + schema: + type: string + SnsPhoneNumber: + description: '`phoneNumber` field of the resource' + in: query + name: phoneNumber + required: false + schema: + type: string + SnsRegion: + description: '`region` field of the resource' + in: query + name: region + required: false + schema: + default: us-east-1 + type: string + schemas: + InitScripts: + additionalProperties: false + properties: + completed: + additionalProperties: false + properties: + BOOT: + type: boolean + READY: + type: boolean + SHUTDOWN: + type: boolean + START: + type: boolean + required: + - BOOT + - START + - READY + - SHUTDOWN + type: object + scripts: + items: + additionalProperties: false + properties: + name: + type: string + stage: + type: string + state: + type: string + required: + - stage + - name + - state + type: object + type: array + required: + - completed + - scripts + type: object + InitScriptsStage: + additionalProperties: false + properties: + completed: + type: boolean + scripts: + items: + additionalProperties: false + properties: + name: + type: string + stage: + type: string + state: + type: string + required: + - stage + - name + - state + type: object + type: array + required: + - completed + - scripts + type: object + SESDestination: + type: object + description: Possible destination of a SES message + properties: + ToAddresses: + type: array + items: + type: string + format: email + CcAddresses: + type: array + items: + type: string + format: email + BccAddresses: + type: array + items: + type: string + format: email + additionalProperties: false + SesSentEmail: + additionalProperties: false + properties: + Body: + additionalProperties: false + properties: + html_part: + type: string + text_part: + type: string + required: + - text_part + type: object + Destination: + $ref: '#/components/schemas/SESDestination' + Id: + type: string + RawData: + type: string + Region: + type: string + Source: + type: string + Subject: + type: string + Template: + type: string + TemplateData: + type: string + Timestamp: + type: string + required: + - Id + - Region + - Timestamp + - Source + type: object + SessionInfo: + additionalProperties: false + properties: + edition: + type: string + is_docker: + type: boolean + is_license_activated: + type: boolean + machine_id: + type: string + server_time_utc: + type: string + session_id: + type: string + system: + type: string + uptime: + type: integer + version: + type: string + required: + - version + - edition + - is_license_activated + - session_id + - machine_id + - system + - is_docker + - server_time_utc + - uptime + type: object + SnsSubscriptionTokenError: + additionalProperties: false + properties: + error: + type: string + subscription_arn: + type: string + required: + - error + - subscription_arn + type: object + SNSPlatformEndpointMessage: + type: object + description: Message sent to a platform endpoint via SNS + additionalProperties: false + properties: + TargetArn: + type: string + TopicArn: + type: string + Message: + type: string + MessageAttributes: + type: object + MessageStructure: + type: string + Subject: + type: [string, 'null'] + MessageId: + type: string + SNSMessage: + type: object + description: Message sent via SNS + properties: + PhoneNumber: + type: string + TopicArn: + type: [string, 'null'] + SubscriptionArn: + type: [string, 'null'] + MessageId: + type: string + Message: + type: string + MessageAttributes: + type: object + MessageStructure: + type: [string, 'null'] + Subject: + type: [string, 'null'] + SNSPlatformEndpointMessages: + type: object + description: | + Messages sent to the platform endpoint retrieved via the retrospective endpoint. + The endpoint ARN is the key with a list of messages as value. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SNSPlatformEndpointMessage' + SMSMessages: + type: object + description: | + SMS messages retrieved via the retrospective endpoint. + The phone number is the key with a list of messages as value. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SNSMessage' + SNSPlatformEndpointResponse: + type: object + additionalProperties: false + description: Response payload for the /_aws/sns/platform-endpoint-messages endpoint + properties: + region: + type: string + description: "The AWS region, e.g., us-east-1" + platform_endpoint_messages: + $ref: '#/components/schemas/SNSPlatformEndpointMessages' + required: + - region + - platform_endpoint_messages + SNSSMSMessagesResponse: + type: object + additionalProperties: false + description: Response payload for the /_aws/sns/sms-messages endpoint + properties: + region: + type: string + description: "The AWS region, e.g., us-east-1" + sms_messages: + $ref: '#/components/schemas/SMSMessages' + required: + - region + - sms_messages + ReceiveMessageRequest: + type: object + description: https://github.com/boto/botocore/blob/develop/botocore/data/sqs/2012-11-05/service-2.json + required: + - QueueUrl + properties: + QueueUrl: + type: string + format: uri + AttributeNames: + type: array + items: + type: string + MessageSystemAttributeNames: + type: array + items: + type: string + MessageAttributeNames: + type: array + items: + type: string + MaxNumberOfMessages: + type: integer + VisibilityTimeout: + type: integer + WaitTimeSeconds: + type: integer + ReceiveRequestAttemptId: + type: string + ReceiveMessageResult: + type: object + description: https://github.com/boto/botocore/blob/develop/botocore/data/sqs/2012-11-05/service-2.json + properties: + Messages: + type: array + items: + $ref: '#/components/schemas/Message' + Message: + type: object + properties: + MessageId: + type: [string, 'null'] + ReceiptHandle: + type: [string, 'null'] + MD5OfBody: + type: [string, 'null'] + Body: + type: [string, 'null'] + Attributes: + type: object + MessageAttributes: + type: object + CloudWatchMetrics: + additionalProperties: false + properties: + metrics: + items: + additionalProperties: false + properties: + account: + description: Account ID + type: string + d: + description: Dimensions + items: + additionalProperties: false + properties: + n: + description: Dimension name + type: string + v: + description: Dimension value + oneOf: + - type: string + - type: integer + required: + - n + - v + type: object + type: array + n: + description: Metric name + type: string + ns: + description: Namespace + type: string + region: + description: Region name + type: string + t: + description: Timestamp + oneOf: + - type: string + format: date-time + - type: number + v: + description: Metric value + oneOf: + - type: string + - type: integer + required: + - ns + - n + - v + - t + - d + - account + - region + type: object + type: array + required: + - metrics + type: object +paths: + /_aws/cloudwatch/metrics/raw: + get: + description: Retrieve CloudWatch metrics + operationId: get_cloudwatch_metrics + tags: [aws] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CloudWatchMetrics' + description: CloudWatch metrics + /_aws/dynamodb/expired: + delete: + description: Delete expired items from TTL-enabled DynamoDB tables + operationId: delete_ddb_expired_items + tags: [aws] + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + ExpiredItems: + description: Number of expired items that were deleted + type: integer + required: + - ExpiredItems + type: object + description: Operation was successful + /_aws/events/rules/{rule_arn}/trigger: + get: + description: Trigger a scheduled EventBridge rule + operationId: trigger_event_bridge_rule + tags: [aws] + parameters: + - description: EventBridge rule ARN + in: path + name: rule_arn + required: true + schema: + type: string + responses: + '200': + description: EventBridge rule was triggered + '404': + description: Not found + /_aws/lambda/init: + get: + description: Retrieve Lambda runtime init binary + operationId: get_lambda_init + tags: [aws] + responses: + '200': + content: + application/octet-stream: {} + description: Lambda runtime init binary + /_aws/lambda/runtimes: + get: + description: List available Lambda runtimes + operationId: get_lambda_runtimes + tags: [aws] + parameters: + - in: query + name: filter + required: false + schema: + default: supported + enum: + - all + - deprecated + - supported + type: string + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + Runtimes: + items: + type: string + type: array + required: + - Runtimes + type: object + description: Available Lambda runtimes + /_aws/ses: + delete: + description: Discard sent SES messages + operationId: discard_ses_messages + tags: [aws] + parameters: + - $ref: '#/components/parameters/SesIdFilter' + responses: + '204': + description: Message was successfully discarded + get: + description: Retrieve sent SES messages + operationId: get_ses_messages + tags: [aws] + parameters: + - $ref: '#/components/parameters/SesIdFilter' + - $ref: '#/components/parameters/SesEmailFilter' + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + messages: + items: + $ref: '#/components/schemas/SesSentEmail' + type: array + required: + - messages + type: object + description: List of sent messages + /_aws/sns/platform-endpoint-messages: + delete: + description: Discard the messages published to a platform endpoint via SNS + operationId: discard_sns_endpoint_messages + tags: [aws] + parameters: + - $ref: '#/components/parameters/SnsAccountId' + - $ref: '#/components/parameters/SnsRegion' + - $ref: '#/components/parameters/SnsEndpointArn' + responses: + '204': + description: Platform endpoint message was discarded + get: + description: Retrieve the messages sent to a platform endpoint via SNS + operationId: get_sns_endpoint_messages + tags: [aws] + parameters: + - $ref: '#/components/parameters/SnsAccountId' + - $ref: '#/components/parameters/SnsRegion' + - $ref: '#/components/parameters/SnsEndpointArn' + responses: + '200': + content: + application/json: + schema: + $ref: "#/components/schemas/SNSPlatformEndpointResponse" + description: SNS messages via retrospective access + /_aws/sns/sms-messages: + delete: + description: Discard SNS SMS messages + operationId: discard_sns_sms_messages + tags: [aws] + parameters: + - $ref: '#/components/parameters/SnsAccountId' + - $ref: '#/components/parameters/SnsRegion' + - $ref: '#/components/parameters/SnsPhoneNumber' + responses: + '204': + description: SMS message was discarded + get: + description: Retrieve SNS SMS messages + operationId: get_sns_sms_messages + tags: [aws] + parameters: + - $ref: '#/components/parameters/SnsAccountId' + - $ref: '#/components/parameters/SnsRegion' + - $ref: '#/components/parameters/SnsPhoneNumber' + responses: + '200': + content: + application/json: + schema: + $ref: "#/components/schemas/SNSSMSMessagesResponse" + description: SNS messages via retrospective access + /_aws/sns/subscription-tokens/{subscription_arn}: + get: + description: Retrieve SNS subscription token for confirmation + operationId: get_sns_subscription_token + tags: [aws] + parameters: + - description: '`subscriptionArn` resource of subscription token' + in: path + name: subscription_arn + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + subscription_arn: + type: string + subscription_token: + type: string + required: + - subscription_token + - subscription_arn + type: object + description: Subscription token + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/SnsSubscriptionTokenError' + description: Bad request + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/SnsSubscriptionTokenError' + description: Not found + /_aws/sqs/messages: + get: + description: List SQS queue messages without side effects + operationId: list_all_sqs_messages + tags: [aws] + parameters: + - description: SQS queue URL + in: query + name: QueueUrl + required: false + schema: + type: string + responses: + '200': + content: + text/xml: + schema: + $ref: '#/components/schemas/ReceiveMessageResult' + application/json: + schema: + $ref: '#/components/schemas/ReceiveMessageResult' + description: SQS queue messages + '400': + content: + text/xml: {} + application/json: {} + description: Bad request + '404': + content: + text/xml: {} + application/json: {} + description: Not found + post: + summary: Retrieves one or more messages from the specified queue. + description: | + This API receives messages from an SQS queue. + https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_ReceiveMessage.html#API_ReceiveMessage_ResponseSyntax + operationId: receive_message + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ReceiveMessageRequest' + application/json: + schema: + $ref: '#/components/schemas/ReceiveMessageRequest' + responses: + '200': + content: + text/xml: {} + application/json: + schema: + $ref: '#/components/schemas/ReceiveMessageResult' + description: SQS queue messages + '400': + content: + text/xml: {} + application/json: {} + description: Bad request + '404': + content: + text/xml: {} + application/json: {} + description: Not found + /_aws/sqs/messages/{region}/{account_id}/{queue_name}: + get: + description: List SQS messages without side effects + operationId: list_sqs_messages + tags: [aws] + parameters: + - description: SQS queue region + in: path + name: region + required: true + schema: + type: string + - description: SQS queue account ID + in: path + name: account_id + required: true + schema: + type: string + - description: SQS queue name + in: path + name: queue_name + required: true + schema: + type: string + responses: + '200': + content: + text/xml: {} + application/json: + schema: + $ref: '#/components/schemas/ReceiveMessageResult' + description: SQS queue messages + '400': + content: + text/xml: {} + application/json: {} + description: Bad request + '404': + content: + text/xml: {} + application/json: {} + description: Not found + /_localstack/config: + get: + description: Get current LocalStack configuration + operationId: get_config + tags: [localstack] + responses: + '200': + content: + application/json: + schema: + type: object + description: Current LocalStack configuration + post: + description: Configuration option to update with new value + operationId: update_config_option + tags: [localstack] + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + value: + type: + - number + - string + variable: + pattern: ^[_a-zA-Z0-9]+$ + type: string + required: + - variable + - value + type: object + required: true + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + value: + type: + - number + - string + variable: + type: string + required: + - variable + - value + type: object + description: Configuration option is updated + '400': + content: + application/json: {} + description: Bad request + /_localstack/diagnose: + get: + description: Get diagnostics report + operationId: get_diagnostics + tags: [localstack] + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + config: + type: object + docker-dependent-image-hosts: + type: object + docker-inspect: + type: object + file-tree: + type: object + important-endpoints: + type: object + info: + $ref: '#/components/schemas/SessionInfo' + logs: + additionalProperties: false + properties: + docker: + type: string + required: + - docker + type: object + services: + type: object + usage: + type: object + version: + additionalProperties: false + properties: + host: + additionalProperties: false + properties: + kernel: + type: string + required: + - kernel + type: object + image-version: + additionalProperties: false + properties: + created: + type: string + id: + type: string + sha256: + type: string + tag: + type: string + required: + - id + - sha256 + - tag + - created + type: object + localstack-version: + additionalProperties: false + properties: + build-date: + type: + - string + - 'null' + build-git-hash: + type: + - string + - 'null' + build-version: + type: + - string + - 'null' + required: + - build-date + - build-git-hash + - build-version + type: object + required: + - image-version + - localstack-version + - host + type: object + required: + - version + - info + - services + - config + - docker-inspect + - docker-dependent-image-hosts + - file-tree + - important-endpoints + - logs + - usage + type: object + description: Diagnostics report + /_localstack/health: + get: + description: Get available LocalStack features and AWS services + operationId: get_features_and_services + tags: [localstack] + parameters: + - allowEmptyValue: true + in: query + name: reload + required: false + schema: + type: string + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + edition: + enum: + - community + - pro + - enterprise + - unknown + type: string + features: + type: object + services: + type: object + version: + type: string + required: + - edition + - services + - version + type: object + description: Available LocalStack features and AWS services + head: + tags: [localstack] + operationId: health + responses: + '200': + content: + text/plain: {} + description: '' + post: + description: Restart or terminate LocalStack session + operationId: manage_session + tags: [localstack] + requestBody: + content: + application/json: + schema: + additionalProperties: false + properties: + action: + enum: + - restart + - kill + type: string + required: + - action + type: object + description: Action to perform + required: true + responses: + '200': + content: + text/plain: {} + description: Action was successful + '400': + content: + text/plain: {} + description: Bad request + put: + description: Store arbitrary data to in-memory state + operationId: store_data + tags: [localstack] + requestBody: + content: + application/json: + schema: + type: object + description: Data to save + responses: + '200': + content: + application/json: + schema: + additionalProperties: false + properties: + status: + type: string + required: + - status + type: object + description: Data was saved + /_localstack/info: + get: + description: Get information about the current LocalStack session + operationId: get_session_info + tags: [localstack] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionInfo' + description: Information about the current LocalStack session + /_localstack/init: + get: + description: Get information about init scripts + operationId: get_init_script_info + tags: [localstack] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/InitScripts' + description: Information about init scripts + /_localstack/init/{stage}: + get: + description: Get information about init scripts in a specific stage + operationId: get_init_script_info_stage + tags: [localstack] + parameters: + - in: path + name: stage + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/InitScriptsStage' + description: Information about init scripts in a specific stage + /_localstack/plugins: + get: + description: '' + operationId: get_plugins + tags: [localstack] + responses: + '200': + content: + application/json: {} + description: '' + /_localstack/usage: + get: + description: '' + operationId: get_usage + tags: [localstack] + responses: + '200': + content: + application/json: {} + description: '' diff --git a/localstack-core/localstack/packages/__init__.py b/localstack-core/localstack/packages/__init__.py new file mode 100644 index 0000000000000..f4f7585cfbe95 --- /dev/null +++ b/localstack-core/localstack/packages/__init__.py @@ -0,0 +1,25 @@ +from .api import ( + InstallTarget, + NoSuchVersionException, + Package, + PackageException, + PackageInstaller, + PackagesPlugin, + package, + packages, +) +from .core import DownloadInstaller, GitHubReleaseInstaller, SystemNotSupportedException + +__all__ = [ + "Package", + "PackageInstaller", + "GitHubReleaseInstaller", + "DownloadInstaller", + "InstallTarget", + "PackageException", + "NoSuchVersionException", + "SystemNotSupportedException", + "PackagesPlugin", + "package", + "packages", +] diff --git a/localstack-core/localstack/packages/api.py b/localstack-core/localstack/packages/api.py new file mode 100644 index 0000000000000..bcc8add9577c5 --- /dev/null +++ b/localstack-core/localstack/packages/api.py @@ -0,0 +1,415 @@ +import abc +import functools +import logging +import os +from collections import defaultdict +from enum import Enum +from inspect import getmodule +from threading import RLock +from typing import Any, Callable, Generic, List, Optional, ParamSpec, TypeVar + +from plux import Plugin, PluginManager, PluginSpec # type: ignore + +from localstack import config + +LOG = logging.getLogger(__name__) + + +class PackageException(Exception): + """Basic exception indicating that a package-specific exception occurred.""" + + pass + + +class NoSuchVersionException(PackageException): + """Exception indicating that a requested installer version is not available / supported.""" + + def __init__(self, package: str | None = None, version: str | None = None): + message = "Unable to find requested version" + if package and version: + message += f"Unable to find requested version '{version}' for package '{package}'" + super().__init__(message) + + +class InstallTarget(Enum): + """ + Different installation targets. + Attention: + - These targets are directly used in the LPM API and are therefore part of a public API! + - The order of the entries in the enum define the default lookup order when looking for package installations. + + These targets refer to the directories in config#Directories. + - VAR_LIBS: Used for packages installed at runtime. They are installed in a host-mounted volume. + This directory / these installations persist across multiple containers. + - STATIC_LIBS: Used for packages installed at build time. They are installed in a non-host-mounted volume. + This directory is re-created whenever a container is recreated. + """ + + VAR_LIBS = config.dirs.var_libs + STATIC_LIBS = config.dirs.static_libs + + +class PackageInstaller(abc.ABC): + """ + Base class for a specific installer. + An instance of an installer manages the installation of a specific Package (in a specific version, if there are + multiple versions). + """ + + def __init__(self, name: str, version: str, install_lock: Optional[RLock] = None): + """ + :param name: technical package name, f.e. "opensearch" + :param version: version of the package to install + :param install_lock: custom lock which should be used for this package installer instance for the + complete #install call. Defaults to a per-instance reentrant lock (RLock). + Package instances create one installer per version. Therefore, by default, the lock + ensures that package installations of the same package and version are mutually exclusive. + """ + self.name = name + self.version = version + self.install_lock = install_lock or RLock() + self._setup_for_target: dict[InstallTarget, bool] = defaultdict(lambda: False) + + def install(self, target: Optional[InstallTarget] = None) -> None: + """ + Performs the package installation. + + :param target: preferred installation target. Default is VAR_LIBS. + :return: None + :raises PackageException: if the installation fails + """ + try: + if not target: + target = InstallTarget.VAR_LIBS + # We have to acquire the lock before checking if the package is installed, as the is_installed check + # is _only_ reliable if no other thread is currently actually installing + with self.install_lock: + # Skip the installation if it's already installed + if not self.is_installed(): + LOG.debug("Starting installation of %s %s...", self.name, self.version) + self._prepare_installation(target) + self._install(target) + self._post_process(target) + LOG.debug("Installation of %s %s finished.", self.name, self.version) + else: + LOG.debug( + "Installation of %s %s skipped (already installed).", + self.name, + self.version, + ) + if not self._setup_for_target[target]: + LOG.debug("Performing runtime setup for already installed package.") + self._setup_existing_installation(target) + except PackageException as e: + raise e + except Exception as e: + raise PackageException(f"Installation of {self.name} {self.version} failed.") from e + + def is_installed(self) -> bool: + """ + Checks if the package is already installed. + + :return: True if the package is already installed (i.e. an installation is not necessary). + """ + return self.get_installed_dir() is not None + + def get_installed_dir(self) -> str | None: + """ + Returns the directory of an existing installation. The directory can differ based on the installation target + and version. + :return: str representation of the installation directory path or None if the package is not installed anywhere + """ + for target in InstallTarget: + directory = self._get_install_dir(target) + if directory and os.path.exists(self._get_install_marker_path(directory)): + return directory + return None + + def _get_install_dir(self, target: InstallTarget) -> str: + """ + Builds the installation directory for a specific target. + :param target: to create the installation directory path for + :return: str representation of the installation directory for the given target + """ + return os.path.join(target.value, self.name, self.version) + + def _get_install_marker_path(self, install_dir: str) -> str: + """ + Builds the path for a specific "marker" whose presence indicates that the package has been installed + successfully in the given directory. + + :param install_dir: base path for the check (f.e. /var/lib/localstack/lib/dynamodblocal/latest/) + :return: path which should be checked to indicate if the package has been installed successfully + (f.e. /var/lib/localstack/lib/dynamodblocal/latest/DynamoDBLocal.jar) + """ + raise NotImplementedError() + + def _setup_existing_installation(self, target: InstallTarget) -> None: + """ + Internal function to perform the setup for an existing installation, f.e. adding a path to an environment. + This is only necessary for certain installers (like the PythonPackageInstaller). + This function will _always_ be executed _exactly_ once within a Python session for a specific installer + instance and target, if #install is called for the respective target. + :param target: of the installation + :return: None + """ + pass + + def _prepare_installation(self, target: InstallTarget) -> None: + """ + Internal function to prepare an installation, f.e. by downloading some data or installing an OS package repo. + Can be implemented by specific installers. + :param target: of the installation + :return: None + """ + pass + + def _install(self, target: InstallTarget) -> None: + """ + Internal function to perform the actual installation. + Must be implemented by specific installers. + :param target: of the installation + :return: None + """ + raise NotImplementedError() + + def _post_process(self, target: InstallTarget) -> None: + """ + Internal function to perform some post-processing, f.e. patching an installation or creating symlinks. + :param target: of the installation + :return: None + """ + pass + + +# With Python 3.13 we should be able to set PackageInstaller as the default +# https://typing.python.org/en/latest/spec/generics.html#type-parameter-defaults +T = TypeVar("T", bound=PackageInstaller) + + +class Package(abc.ABC, Generic[T]): + """ + A Package defines a specific kind of software, mostly used as backends or supporting system for service + implementations. + """ + + def __init__(self, name: str, default_version: str): + """ + :param name: Human readable name of the package, f.e. "PostgreSQL" + :param default_version: Default version of the package which is used for installations if no version is defined + """ + self.name = name + self.default_version = default_version + + def get_installed_dir(self, version: str | None = None) -> str | None: + """ + Finds a directory where the package (in the specific version) is installed. + :param version: of the package to look for. If None, the default version of the package is used. + :return: str representation of the path to the existing installation directory or None if the package in this + version is not yet installed. + """ + return self.get_installer(version).get_installed_dir() + + def install(self, version: str | None = None, target: Optional[InstallTarget] = None) -> None: + """ + Installs the package in the given version in the preferred target location. + :param version: version of the package to install. If None, the default version of the package will be used. + :param target: preferred installation target. If None, the var_libs directory is used. + :raises NoSuchVersionException: If the given version is not supported. + """ + self.get_installer(version).install(target) + + @functools.lru_cache() + def get_installer(self, version: str | None = None) -> T: + """ + Returns the installer instance for a specific version of the package. + + It is important that this be LRU cached. Installers have a mutex lock to prevent races, and it is necessary + that this method returns the same installer instance for a given version. + + :param version: version of the package to install. If None, the default version of the package will be used. + :return: PackageInstaller instance for the given version. + :raises NoSuchVersionException: If the given version is not supported. + """ + if not version: + return self.get_installer(self.default_version) + if version not in self.get_versions(): + raise NoSuchVersionException(package=self.name, version=version) + return self._get_installer(version) + + def get_versions(self) -> List[str]: + """ + :return: List of all versions available for this package. + """ + raise NotImplementedError() + + def _get_installer(self, version: str) -> T: + """ + Internal lookup function which needs to be implemented by specific packages. + It creates PackageInstaller instances for the specific version. + + :param version: to find the installer for + :return: PackageInstaller instance responsible for installing the given version of the package. + """ + raise NotImplementedError() + + def __str__(self) -> str: + return self.name + + +class MultiPackageInstaller(PackageInstaller): + """ + PackageInstaller implementation which composes of multiple package installers. + """ + + def __init__(self, name: str, version: str, package_installer: List[PackageInstaller]): + """ + :param name: of the (multi-)package installer + :param version: of this (multi-)package installer + :param package_installer: List of installers this multi-package installer consists of + """ + super().__init__(name=name, version=version) + + assert isinstance(package_installer, list) + assert len(package_installer) > 0 + self.package_installer = package_installer + + def install(self, target: Optional[InstallTarget] = None) -> None: + """ + Installs the different packages this installer is composed of. + + :param target: which defines where to install the packages. + :return: None + """ + for package_installer in self.package_installer: + package_installer.install(target=target) + + def get_installed_dir(self) -> str | None: + # By default, use the installed-dir of the first package + return self.package_installer[0].get_installed_dir() + + def _install(self, target: InstallTarget) -> None: + # This package installer actually only calls other installers, we pass here + pass + + def _get_install_dir(self, target: InstallTarget) -> str: + # By default, use the install-dir of the first package + return self.package_installer[0]._get_install_dir(target) + + def _get_install_marker_path(self, install_dir: str) -> str: + # By default, use the install-marker-path of the first package + return self.package_installer[0]._get_install_marker_path(install_dir) + + +PLUGIN_NAMESPACE = "localstack.packages" + + +class PackagesPlugin(Plugin): # type: ignore[misc] + """ + Plugin implementation for Package plugins. + A package plugin exposes a specific package instance. + """ + + api: str + name: str + + def __init__( + self, + name: str, + scope: str, + get_package: Callable[[], Package[PackageInstaller] | List[Package[PackageInstaller]]], + should_load: Callable[[], bool] | None = None, + ) -> None: + super().__init__() + self.name = name + self.scope = scope + self._get_package = get_package + self._should_load = should_load + + def should_load(self) -> bool: + if self._should_load: + return self._should_load() + return True + + def get_package(self) -> Package[PackageInstaller]: + """ + :return: returns the package instance of this package plugin + """ + return self._get_package() # type: ignore[return-value] + + +class NoSuchPackageException(PackageException): + """Exception raised by the PackagesPluginManager to indicate that a package / version is not available.""" + + pass + + +class PackagesPluginManager(PluginManager[PackagesPlugin]): # type: ignore[misc] + """PluginManager which simplifies the loading / access of PackagesPlugins and their exposed package instances.""" + + def __init__(self) -> None: + super().__init__(PLUGIN_NAMESPACE) + + def get_all_packages(self) -> list[tuple[str, str, Package[PackageInstaller]]]: + return sorted( + [(plugin.name, plugin.scope, plugin.get_package()) for plugin in self.load_all()] + ) + + def get_packages( + self, package_names: list[str], version: Optional[str] = None + ) -> list[Package[PackageInstaller]]: + # Plugin names are unique, but there could be multiple packages with the same name in different scopes + plugin_specs_per_name = defaultdict(list) + # Plugin names have the format "/", build a dict of specs per package name for the lookup + for plugin_spec in self.list_plugin_specs(): + (package_name, _, _) = plugin_spec.name.rpartition("/") + plugin_specs_per_name[package_name].append(plugin_spec) + + package_instances: list[Package[PackageInstaller]] = [] + for package_name in package_names: + plugin_specs = plugin_specs_per_name.get(package_name) + if not plugin_specs: + raise NoSuchPackageException( + f"unable to locate installer for package {package_name}" + ) + for plugin_spec in plugin_specs: + package_instance = self.load(plugin_spec.name).get_package() + package_instances.append(package_instance) + if version and version not in package_instance.get_versions(): + raise NoSuchPackageException( + f"unable to locate installer for package {package_name} and version {version}" + ) + + return package_instances + + +P = ParamSpec("P") +T2 = TypeVar("T2") + + +def package( + name: str | None = None, + scope: str = "community", + should_load: Optional[Callable[[], bool]] = None, +) -> Callable[[Callable[[], Package[Any] | list[Package[Any]]]], PluginSpec]: + """ + Decorator for marking methods that create Package instances as a PackagePlugin. + Methods marked with this decorator are discoverable as a PluginSpec within the namespace "localstack.packages", + with the name ":". If api is not explicitly specified, then the parent module name is used as + service name. + """ + + def wrapper(fn: Callable[[], Package[Any] | list[Package[Any]]]) -> PluginSpec: + _name = name or getmodule(fn).__name__.split(".")[-2] # type: ignore[union-attr] + + @functools.wraps(fn) + def factory() -> PackagesPlugin: + return PackagesPlugin(name=_name, scope=scope, get_package=fn, should_load=should_load) + + return PluginSpec(PLUGIN_NAMESPACE, f"{_name}/{scope}", factory=factory) + + return wrapper + + +# TODO remove (only used for migrating to new #package decorator) +packages = package diff --git a/localstack-core/localstack/packages/core.py b/localstack-core/localstack/packages/core.py new file mode 100644 index 0000000000000..fde294492cc3a --- /dev/null +++ b/localstack-core/localstack/packages/core.py @@ -0,0 +1,416 @@ +import logging +import os +import re +from abc import ABC +from functools import lru_cache +from sys import version_info +from typing import Any, Optional, Tuple + +import requests + +from localstack import config + +from ..constants import LOCALSTACK_VENV_FOLDER, MAVEN_REPO_URL +from ..utils.archives import download_and_extract +from ..utils.files import chmod_r, chown_r, mkdir, rm_rf +from ..utils.http import download +from ..utils.run import is_root, run +from ..utils.venv import VirtualEnvironment +from .api import InstallTarget, PackageException, PackageInstaller + +LOG = logging.getLogger(__name__) + + +class SystemNotSupportedException(PackageException): + """Exception indicating that the current system is not allowed.""" + + pass + + +class ExecutableInstaller(PackageInstaller, ABC): + """ + This installer simply adds a clean interface for accessing a downloaded executable directly + """ + + def get_executable_path(self) -> str | None: + """ + :return: the path to the downloaded binary or None if it's not yet downloaded / installed. + """ + install_dir = self.get_installed_dir() + if install_dir: + return self._get_install_marker_path(install_dir) + return None + + +class DownloadInstaller(ExecutableInstaller): + def __init__(self, name: str, version: str): + super().__init__(name, version) + + def _get_download_url(self) -> str: + raise NotImplementedError() + + def _get_install_marker_path(self, install_dir: str) -> str: + url = self._get_download_url() + binary_name = os.path.basename(url) + return os.path.join(install_dir, binary_name) + + def _install(self, target: InstallTarget) -> None: + target_directory = self._get_install_dir(target) + mkdir(target_directory) + download_url = self._get_download_url() + target_path = self._get_install_marker_path(target_directory) + download(download_url, target_path) + + +class ArchiveDownloadAndExtractInstaller(ExecutableInstaller): + def __init__( + self, + name: str, + version: str, + extract_single_directory: bool = False, + ): + """ + :param name: technical package name, f.e. "opensearch" + :param version: version of the package to install + :param extract_single_directory: whether to extract files from single root folder in the archive + """ + super().__init__(name, version) + self.extract_single_directory = extract_single_directory + + def _get_install_marker_path(self, install_dir: str) -> str: + raise NotImplementedError() + + def _get_download_url(self) -> str: + raise NotImplementedError() + + def _get_checksum_url(self) -> str | None: + """ + Checksum URL for the archive. This is used to verify the integrity of the downloaded archive. + This method can be implemented by subclasses to provide the correct URL for the checksum file. + If not implemented, checksum verification will be skipped. + + :return: URL to the checksum file for the archive, or None if not available. + """ + return None + + def get_installed_dir(self) -> str | None: + installed_dir = super().get_installed_dir() + subdir = self._get_archive_subdir() + + # If the specific installer defines a subdirectory, we return the subdirectory. + # f.e. /var/lib/localstack/lib/amazon-mq/5.16.5/apache-activemq-5.16.5/ + if installed_dir and subdir: + return os.path.join(installed_dir, subdir) + + return installed_dir + + def _get_archive_subdir(self) -> str | None: + """ + :return: name of the subdirectory contained in the archive or none if the package content is at the root level + of the archive + """ + return None + + def get_executable_path(self) -> str | None: + subdir = self._get_archive_subdir() + if subdir is None: + return super().get_executable_path() + else: + install_dir = self.get_installed_dir() + if install_dir: + install_dir = install_dir[: -len(subdir)] + return self._get_install_marker_path(install_dir) + return None + + def _handle_single_directory_extraction(self, target_directory: str) -> None: + """ + Handle extraction of archives that contain a single root directory. + Moves the contents up one level if extract_single_directory is True. + + :param target_directory: The target extraction directory + :return: None + """ + if not self.extract_single_directory: + return + + dir_contents = os.listdir(target_directory) + if len(dir_contents) != 1: + return + target_subdir = os.path.join(target_directory, dir_contents[0]) + if not os.path.isdir(target_subdir): + return + os.rename(target_subdir, f"{target_directory}.backup") + rm_rf(target_directory) + os.rename(f"{target_directory}.backup", target_directory) + + def _download_archive( + self, + target: InstallTarget, + download_url: str, + ) -> None: + target_directory = self._get_install_dir(target) + mkdir(target_directory) + download_url = download_url or self._get_download_url() + archive_name = os.path.basename(download_url) + archive_path = os.path.join(config.dirs.tmp, archive_name) + + # Get checksum info if available + checksum_url = self._get_checksum_url() + + try: + download_and_extract( + download_url, + retries=3, + tmp_archive=archive_path, + target_dir=target_directory, + checksum_url=checksum_url, + ) + self._handle_single_directory_extraction(target_directory) + finally: + rm_rf(archive_path) + + def _install(self, target: InstallTarget) -> None: + self._download_archive(target, self._get_download_url()) + + +class PermissionDownloadInstaller(DownloadInstaller, ABC): + def _install(self, target: InstallTarget) -> None: + super()._install(target) + chmod_r(self.get_executable_path(), 0o777) # type: ignore[arg-type] + + +class GitHubReleaseInstaller(PermissionDownloadInstaller): + """ + Installer which downloads an asset from a GitHub project's tag. + """ + + def __init__(self, name: str, tag: str, github_slug: str): + super().__init__(name, tag) + self.github_tag_url = ( + f"https://api.github.com/repos/{github_slug}/releases/tags/{self.version}" + ) + + @lru_cache() + def _get_download_url(self) -> str: + asset_name = self._get_github_asset_name() + # try to use a token when calling the GH API for increased API rate limits + headers = None + gh_token = os.environ.get("GITHUB_API_TOKEN") + if gh_token: + headers = {"authorization": f"Bearer {gh_token}"} + response = requests.get(self.github_tag_url, headers=headers) + if not response.ok: + raise PackageException( + f"Could not get list of releases from {self.github_tag_url}: {response.text}" + ) + github_release = response.json() + download_url = None + for asset in github_release.get("assets", []): + # find the correct binary in the release + if asset["name"] == asset_name: + download_url = asset["browser_download_url"] + break + if download_url is None: + raise PackageException( + f"Could not find required binary {asset_name} in release {self.github_tag_url}" + ) + return download_url + + def _get_install_marker_path(self, install_dir: str) -> str: + # Use the GitHub asset name instead of the download URL (since the download URL needs to be fetched online). + return os.path.join(install_dir, self._get_github_asset_name()) + + def _get_github_asset_name(self) -> str: + """ + Determines the name of the asset to download. + The asset name must be determinable without having any online data (because it is used in offline scenarios to + determine if the package is already installed). + + :return: name of the asset to download from the GitHub project's tag / version + """ + raise NotImplementedError() + + +class NodePackageInstaller(ExecutableInstaller): + """Package installer for Node / NPM packages.""" + + def __init__( + self, + package_name: str, + version: str, + package_spec: Optional[str] = None, + main_module: str = "main.js", + ): + """ + Initializes the Node / NPM package installer. + :param package_name: npm package name + :param version: version of the package which should be installed + :param package_spec: optional package spec for the installation. + If not set, the package name and version will be used for the installation. + :param main_module: main module file of the package + """ + super().__init__(package_name, version) + self.package_name = package_name + # If the package spec is not explicitly set (f.e. to a repo), we build it and pin the version + self.package_spec = package_spec or f"{self.package_name}@{version}" + self.main_module = main_module + + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, "node_modules", self.package_name, self.main_module) + + def _install(self, target: InstallTarget) -> None: + target_dir = self._get_install_dir(target) + + run( + [ + "npm", + "install", + "--prefix", + target_dir, + self.package_spec, + ] + ) + # npm 9+ does _not_ set the ownership of files anymore if run as root + # - https://github.blog/changelog/2022-10-24-npm-v9-0-0-released/ + # - https://github.com/npm/cli/pull/5704 + # - https://github.com/localstack/localstack/issues/7620 + if is_root(): + # if the package was installed as root, set the ownership manually + LOG.debug("Setting ownership root:root on %s", target_dir) + chown_r(target_dir, "root") + + +LOCALSTACK_VENV = VirtualEnvironment(LOCALSTACK_VENV_FOLDER) + + +class PythonPackageInstaller(PackageInstaller): + """ + Package installer which allows the runtime-installation of additional python packages used by certain services. + f.e. vosk as offline speech recognition toolkit (which is ~7MB in size compressed and ~26MB uncompressed). + """ + + normalized_name: str + """Normalized package name according to PEP440.""" + + def __init__(self, name: str, version: str, *args: Any, **kwargs: Any): + super().__init__(name, version, *args, **kwargs) + self.normalized_name = self._normalize_package_name(name) + + def _normalize_package_name(self, name: str) -> str: + """ + Normalized the Python package name according to PEP440. + https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization + """ + return re.sub(r"[-_.]+", "-", name).lower() + + def _get_install_dir(self, target: InstallTarget) -> str: + # all python installers share a venv + return os.path.join(target.value, "python-packages") + + def _get_install_marker_path(self, install_dir: str) -> str: + python_subdir = f"python{version_info[0]}.{version_info[1]}" + dist_info_dir = f"{self.normalized_name}-{self.version}.dist-info" + # the METADATA file is mandatory, use it as install marker + return os.path.join( + install_dir, "lib", python_subdir, "site-packages", dist_info_dir, "METADATA" + ) + + def _get_venv(self, target: InstallTarget) -> VirtualEnvironment: + venv_dir = self._get_install_dir(target) + return VirtualEnvironment(venv_dir) + + def _prepare_installation(self, target: InstallTarget) -> None: + # make sure the venv is properly set up before installing the package + venv = self._get_venv(target) + if not venv.exists: + LOG.info("creating virtual environment at %s", venv.venv_dir) + venv.create() + LOG.info("adding localstack venv path %s", venv.venv_dir) + venv.add_pth("localstack-venv", LOCALSTACK_VENV) + LOG.debug("injecting venv into path %s", venv.venv_dir) + venv.inject_to_sys_path() + + def _install(self, target: InstallTarget) -> None: + venv = self._get_venv(target) + python_bin = os.path.join(venv.venv_dir, "bin/python") + + # run pip via the python binary of the venv + run([python_bin, "-m", "pip", "install", f"{self.name}=={self.version}"], print_error=False) + + def _setup_existing_installation(self, target: InstallTarget) -> None: + """If the venv is already present, it just needs to be initialized once.""" + self._prepare_installation(target) + + +class MavenDownloadInstaller(DownloadInstaller): + """The packageURL is easy copy/pastable from the Maven central repository and the first package URL + defines the package name and version. + Example package_url: pkg:maven/software.amazon.event.ruler/event-ruler@1.7.3 + => name: event-ruler + => version: 1.7.3 + """ + + # Example: software.amazon.event.ruler + group_id: str + # Example: event-ruler + artifact_id: str + + # Custom installation directory + install_dir_suffix: str | None + + def __init__(self, package_url: str, install_dir_suffix: str | None = None): + self.group_id, self.artifact_id, version = parse_maven_package_url(package_url) + super().__init__(self.artifact_id, version) + self.install_dir_suffix = install_dir_suffix + + def _get_download_url(self) -> str: + group_id_path = self.group_id.replace(".", "/") + return f"{MAVEN_REPO_URL}/{group_id_path}/{self.artifact_id}/{self.version}/{self.artifact_id}-{self.version}.jar" + + def _get_install_dir(self, target: InstallTarget) -> str: + """Allow to overwrite the default installation directory. + This enables downloading transitive dependencies into the same directory. + """ + if self.install_dir_suffix: + return os.path.join(target.value, self.install_dir_suffix) + else: + return super()._get_install_dir(target) + + +class MavenPackageInstaller(MavenDownloadInstaller): + """Package installer for downloading Maven JARs, including optional dependencies. + The first Maven package is used as main LPM package and other dependencies are installed additionally. + Follows the Maven naming conventions: https://maven.apache.org/guides/mini/guide-naming-conventions.html + """ + + # Installers for Maven dependencies + dependencies: list[MavenDownloadInstaller] + + def __init__(self, *package_urls: str): + super().__init__(package_urls[0]) + self.dependencies = [] + + # Create installers for dependencies + for package_url in package_urls[1:]: + install_dir_suffix = os.path.join(self.name, self.version) + self.dependencies.append(MavenDownloadInstaller(package_url, install_dir_suffix)) + + def _install(self, target: InstallTarget) -> None: + # Install all dependencies first + for dependency in self.dependencies: + dependency._install(target) + # Install the main Maven package once all dependencies are installed. + # This main package indicates whether all dependencies are installed. + super()._install(target) + + +def parse_maven_package_url(package_url: str) -> Tuple[str, str, str]: + """Example: parse_maven_package_url("pkg:maven/software.amazon.event.ruler/event-ruler@1.7.3") + -> software.amazon.event.ruler, event-ruler, 1.7.3 + """ + parts = package_url.split("/") + group_id = parts[1] + sub_parts = parts[2].split("@") + artifact_id = sub_parts[0] + version = sub_parts[1] + return group_id, artifact_id, version diff --git a/localstack-core/localstack/packages/debugpy.py b/localstack-core/localstack/packages/debugpy.py new file mode 100644 index 0000000000000..2731236f747a1 --- /dev/null +++ b/localstack-core/localstack/packages/debugpy.py @@ -0,0 +1,42 @@ +from typing import List + +from localstack.packages import InstallTarget, Package, PackageInstaller +from localstack.utils.run import run + + +class DebugPyPackage(Package["DebugPyPackageInstaller"]): + def __init__(self) -> None: + super().__init__("DebugPy", "latest") + + def get_versions(self) -> List[str]: + return ["latest"] + + def _get_installer(self, version: str) -> "DebugPyPackageInstaller": + return DebugPyPackageInstaller("debugpy", version) + + +class DebugPyPackageInstaller(PackageInstaller): + # TODO: migrate this to the upcoming pip installer + + def is_installed(self) -> bool: + try: + import debugpy # type: ignore[import-not-found] # noqa: T100 + + assert debugpy + return True + except ModuleNotFoundError: + return False + + def _get_install_marker_path(self, install_dir: str) -> str: + # TODO: This method currently does not provide the actual install_marker. + # Since we overwrote is_installed(), this installer does not install anything under + # var/static libs, and we also don't need an executable, we don't need it to operate the installer. + # fix with migration to pip installer + return install_dir + + def _install(self, target: InstallTarget) -> None: + cmd = "pip install debugpy" + run(cmd) + + +debugpy_package = DebugPyPackage() diff --git a/localstack-core/localstack/packages/ffmpeg.py b/localstack-core/localstack/packages/ffmpeg.py new file mode 100644 index 0000000000000..230d114347b68 --- /dev/null +++ b/localstack-core/localstack/packages/ffmpeg.py @@ -0,0 +1,51 @@ +import os +from typing import List + +from localstack.packages import Package +from localstack.packages.core import ArchiveDownloadAndExtractInstaller +from localstack.utils.platform import Arch, get_arch + +# Mapping LocalStack architecture to BtbN's naming convention +ARCH_MAPPING = {Arch.amd64: "linux64", Arch.arm64: "linuxarm64"} + +# Download URL template for ffmpeg 7.1 LGPL builds from BtbN GitHub Releases +FFMPEG_BASE_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest" +FFMPEG_STATIC_BIN_URL = FFMPEG_BASE_URL + "/ffmpeg-n{version}-latest-{arch}-lgpl-{version}.tar.xz" +FFMPEG_STATIC_CHECKSUM_URL = FFMPEG_BASE_URL + "/checksums.sha256" + + +class FfmpegPackage(Package["FfmpegPackageInstaller"]): + def __init__(self) -> None: + super().__init__(name="ffmpeg", default_version="7.1") + + def _get_installer(self, version: str) -> "FfmpegPackageInstaller": + return FfmpegPackageInstaller(version) + + def get_versions(self) -> List[str]: + return ["7.1"] + + +class FfmpegPackageInstaller(ArchiveDownloadAndExtractInstaller): + def __init__(self, version: str): + super().__init__("ffmpeg", version) + + def _get_download_url(self) -> str: + return FFMPEG_STATIC_BIN_URL.format(arch=ARCH_MAPPING.get(get_arch()), version=self.version) + + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, self._get_archive_subdir()) + + def _get_archive_subdir(self) -> str: + return f"ffmpeg-n{self.version}-latest-{ARCH_MAPPING.get(get_arch())}-lgpl-{self.version}" + + def get_ffmpeg_path(self) -> str: + return os.path.join(self.get_installed_dir(), "bin", "ffmpeg") # type: ignore[arg-type] + + def get_ffprobe_path(self) -> str: + return os.path.join(self.get_installed_dir(), "bin", "ffprobe") # type: ignore[arg-type] + + def _get_checksum_url(self) -> str | None: + return FFMPEG_STATIC_CHECKSUM_URL + + +ffmpeg_package = FfmpegPackage() diff --git a/localstack-core/localstack/packages/java.py b/localstack-core/localstack/packages/java.py new file mode 100644 index 0000000000000..c8a2e9f7c7f21 --- /dev/null +++ b/localstack-core/localstack/packages/java.py @@ -0,0 +1,205 @@ +import logging +import os +from typing import List + +import requests + +from localstack.constants import USER_AGENT_STRING +from localstack.packages import InstallTarget, Package +from localstack.packages.core import ArchiveDownloadAndExtractInstaller +from localstack.utils.files import rm_rf +from localstack.utils.platform import Arch, get_arch, is_linux, is_mac_os +from localstack.utils.run import run + +LOG = logging.getLogger(__name__) + +# Default version if not specified +DEFAULT_JAVA_VERSION = "11" + +# Supported Java LTS versions mapped with Eclipse Temurin build semvers +JAVA_VERSIONS = { + "8": "8u432-b06", + "11": "11.0.25+9", + "17": "17.0.13+11", + "21": "21.0.5+11", +} + + +class JavaInstallerMixin: + """ + Mixin class for packages that depend on Java. It introduces methods that install Java and help build environment. + """ + + def _prepare_installation(self, target: InstallTarget) -> None: + java_package.install(target=target) + + def get_java_home(self) -> str | None: + """ + Returns path to JRE installation. + """ + return java_package.get_installer().get_java_home() + + def get_java_lib_path(self) -> str | None: + """ + Returns the path to the Java shared library. + """ + if java_home := self.get_java_home(): + if is_mac_os(): + return os.path.join(java_home, "lib", "jli", "libjli.dylib") + return os.path.join(java_home, "lib", "server", "libjvm.so") + return None + + def get_java_env_vars( + self, path: str | None = None, ld_library_path: str | None = None + ) -> dict[str, str]: + """ + Returns environment variables pointing to the Java installation. This is useful to build the environment where + the application will run. + + :param path: If not specified, the value of PATH will be obtained from the environment + :param ld_library_path: If not specified, the value of LD_LIBRARY_PATH will be obtained from the environment + :return: dict consisting of two items: + - JAVA_HOME: path to JRE installation + - PATH: the env path variable updated with JRE bin path + """ + java_home = self.get_java_home() + java_bin = f"{java_home}/bin" + + path = path or os.environ["PATH"] + + library_path = ld_library_path or os.environ.get("LD_LIBRARY_PATH") + # null paths (e.g. `:/foo`) have a special meaning according to the manpages + if library_path is None: + full_library_path = f"{java_home}/lib:{java_home}/lib/server" + else: + full_library_path = f"{java_home}/lib:{java_home}/lib/server:{library_path}" + + return { + "JAVA_HOME": java_home, # type: ignore[dict-item] + "LD_LIBRARY_PATH": full_library_path, + "PATH": f"{java_bin}:{path}", + } + + +class JavaPackageInstaller(ArchiveDownloadAndExtractInstaller): + def __init__(self, version: str): + super().__init__("java", version, extract_single_directory=True) + + def _get_install_marker_path(self, install_dir: str) -> str: + if is_mac_os(): + return os.path.join(install_dir, "Contents", "Home", "bin", "java") + return os.path.join(install_dir, "bin", "java") + + def _get_download_url(self) -> str: + # Note: Eclipse Temurin does not provide Mac aarch64 Java 8 builds. + # See https://adoptium.net/en-GB/supported-platforms/ + try: + LOG.debug("Determining the latest Java build version") + return self._download_url_latest_release() + except Exception as exc: # noqa + LOG.debug( + "Unable to determine the latest Java build version. Using pinned versions: %s", exc + ) + return self._download_url_fallback() + + def _post_process(self, target: InstallTarget) -> None: + target_directory = self._get_install_dir(target) + minimal_jre_path = os.path.join(target.value, self.name, f"{self.version}.minimal") + rm_rf(minimal_jre_path) + + # If jlink is not available, use the environment as is + if not os.path.exists(os.path.join(target_directory, "bin", "jlink")): + LOG.warning("Skipping JRE optimisation because jlink is not available") + return + + # Build a custom JRE with only the necessary bits to minimise disk footprint + LOG.debug("Optimising JRE installation") + cmd = ( + "bin/jlink --add-modules " + # Required modules + "java.base,java.desktop,java.instrument,java.management," + "java.naming,java.scripting,java.sql,java.xml,jdk.compiler," + # jdk.unsupported contains sun.misc.Unsafe which is required by some dependencies + "jdk.unsupported," + # Additional cipher suites + "jdk.crypto.cryptoki," + # Archive support + "jdk.zipfs," + # Required by MQ broker + "jdk.httpserver,jdk.management,jdk.management.agent," + # Required by Spark and Hadoop + "java.security.jgss,jdk.security.auth," + # Include required locales + "jdk.localedata --include-locales en " + # Supplementary args + "--compress 2 --strip-debug --no-header-files --no-man-pages " + # Output directory + "--output " + minimal_jre_path + ) + run(cmd, cwd=target_directory) + + rm_rf(target_directory) + os.rename(minimal_jre_path, target_directory) + + def get_java_home(self) -> str | None: + """ + Get JAVA_HOME for this installation of Java. + """ + installed_dir = self.get_installed_dir() + if is_mac_os(): + return os.path.join(installed_dir, "Contents", "Home") # type: ignore[arg-type] + return installed_dir + + @property + def arch(self) -> str | None: + return ( + "x64" if get_arch() == Arch.amd64 else "aarch64" if get_arch() == Arch.arm64 else None + ) + + @property + def os_name(self) -> str | None: + return "linux" if is_linux() else "mac" if is_mac_os() else None + + def _download_url_latest_release(self) -> str: + """ + Return the download URL for latest stable JDK build. + """ + endpoint = ( + f"https://api.adoptium.net/v3/assets/latest/{self.version}/hotspot?" + f"os={self.os_name}&architecture={self.arch}&image_type=jdk" + ) + # Override user-agent because Adoptium API denies service to `requests` library + response = requests.get(endpoint, headers={"user-agent": USER_AGENT_STRING}).json() + return response[0]["binary"]["package"]["link"] + + def _download_url_fallback(self) -> str: + """ + Return the download URL for pinned JDK build. + """ + semver = JAVA_VERSIONS[self.version] + tag_slug = f"jdk-{semver}" + semver_safe = semver.replace("+", "_") + + # v8 uses a different tag and version scheme + if self.version == "8": + semver_safe = semver_safe.replace("-", "") + tag_slug = f"jdk{semver}" + + return ( + f"https://github.com/adoptium/temurin{self.version}-binaries/releases/download/{tag_slug}/" + f"OpenJDK{self.version}U-jdk_{self.arch}_{self.os_name}_hotspot_{semver_safe}.tar.gz" + ) + + +class JavaPackage(Package[JavaPackageInstaller]): + def __init__(self, default_version: str = DEFAULT_JAVA_VERSION): + super().__init__(name="Java", default_version=default_version) + + def get_versions(self) -> List[str]: + return list(JAVA_VERSIONS.keys()) + + def _get_installer(self, version: str) -> JavaPackageInstaller: + return JavaPackageInstaller(version) + + +java_package = JavaPackage() diff --git a/localstack-core/localstack/packages/plugins.py b/localstack-core/localstack/packages/plugins.py new file mode 100644 index 0000000000000..fdeba86a04204 --- /dev/null +++ b/localstack-core/localstack/packages/plugins.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +from localstack.packages.api import Package, package + +if TYPE_CHECKING: + from localstack.packages.ffmpeg import FfmpegPackageInstaller + from localstack.packages.java import JavaPackageInstaller + from localstack.packages.terraform import TerraformPackageInstaller + + +@package(name="terraform") +def terraform_package() -> Package["TerraformPackageInstaller"]: + from .terraform import terraform_package + + return terraform_package + + +@package(name="ffmpeg") +def ffmpeg_package() -> Package["FfmpegPackageInstaller"]: + from localstack.packages.ffmpeg import ffmpeg_package + + return ffmpeg_package + + +@package(name="java") +def java_package() -> Package["JavaPackageInstaller"]: + from localstack.packages.java import java_package + + return java_package diff --git a/localstack-core/localstack/packages/terraform.py b/localstack-core/localstack/packages/terraform.py new file mode 100644 index 0000000000000..2a5da95b8472a --- /dev/null +++ b/localstack-core/localstack/packages/terraform.py @@ -0,0 +1,47 @@ +import os +import platform +from typing import List + +from localstack.packages import InstallTarget, Package +from localstack.packages.core import ArchiveDownloadAndExtractInstaller +from localstack.utils.files import chmod_r +from localstack.utils.platform import get_arch + +TERRAFORM_VERSION = os.getenv("TERRAFORM_VERSION", "1.5.7") +TERRAFORM_URL_TEMPLATE = ( + "https://releases.hashicorp.com/terraform/{version}/terraform_{version}_{os}_{arch}.zip" +) +TERRAFORM_CHECKSUM_URL_TEMPLATE = ( + "https://releases.hashicorp.com/terraform/{version}/terraform_{version}_SHA256SUMS" +) + + +class TerraformPackage(Package["TerraformPackageInstaller"]): + def __init__(self) -> None: + super().__init__("Terraform", TERRAFORM_VERSION) + + def get_versions(self) -> List[str]: + return [TERRAFORM_VERSION] + + def _get_installer(self, version: str) -> "TerraformPackageInstaller": + return TerraformPackageInstaller("terraform", version) + + +class TerraformPackageInstaller(ArchiveDownloadAndExtractInstaller): + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, "terraform") + + def _get_download_url(self) -> str: + system = platform.system().lower() + arch = get_arch() + return TERRAFORM_URL_TEMPLATE.format(version=TERRAFORM_VERSION, os=system, arch=arch) + + def _install(self, target: InstallTarget) -> None: + super()._install(target) + chmod_r(self.get_executable_path(), 0o777) # type: ignore[arg-type] + + def _get_checksum_url(self) -> str | None: + return TERRAFORM_CHECKSUM_URL_TEMPLATE.format(version=TERRAFORM_VERSION) + + +terraform_package = TerraformPackage() diff --git a/localstack-core/localstack/plugins.py b/localstack-core/localstack/plugins.py new file mode 100644 index 0000000000000..a313032547bba --- /dev/null +++ b/localstack-core/localstack/plugins.py @@ -0,0 +1,76 @@ +import logging +import os +import sys +from pathlib import Path + +import yaml +from plux import Plugin + +from localstack import config +from localstack.runtime import hooks +from localstack.utils.files import rm_rf +from localstack.utils.ssl import get_cert_pem_file_path + +LOG = logging.getLogger(__name__) + + +@hooks.on_infra_start() +def deprecation_warnings() -> None: + LOG.debug("Checking for the usage of deprecated community features and configs...") + from localstack.deprecations import log_deprecation_warnings + + log_deprecation_warnings() + + +@hooks.on_infra_start(should_load=lambda: config.REMOVE_SSL_CERT) +def delete_cached_certificate(): + LOG.debug("Removing the cached local SSL certificate") + target_file = get_cert_pem_file_path() + rm_rf(target_file) + + +class OASPlugin(Plugin): + """ + This plugin allows to register an arbitrary number of OpenAPI specs, e.g., the spec for the public endpoints + of localstack.core. + The OpenAPIValidator handler uses (as opt-in) all the collected specs to validate the requests and the responses + to these public endpoints. + + An OAS plugin assumes the following directory layout. + + my_package + β”œβ”€β”€ sub_package + β”‚ β”œβ”€β”€ __init__.py <-- spec file + β”‚ β”œβ”€β”€ openapi.yaml + β”‚ └── plugins.py <-- plugins + β”œβ”€β”€ plugins.py <-- plugins + └── openapi.yaml <-- spec file + + Each package can have its own OpenAPI yaml spec which is loaded by the correspondent plugin in plugins.py + You can simply create a plugin like the following: + + class MyPackageOASPlugin(OASPlugin): + name = "my_package" + + The only convention is that plugins.py and openapi.yaml have the same pathname. + """ + + namespace = "localstack.openapi.spec" + + def __init__(self) -> None: + # By convention a plugins.py is at the same level (i.e., same pathname) of the openapi.yaml file. + # importlib.resources would be a better approach but has issues with namespace packages in editable mode + _module = sys.modules[self.__module__] + self.spec_path = Path( + os.path.join(os.path.dirname(os.path.abspath(_module.__file__)), "openapi.yaml") + ) + assert self.spec_path.exists() + self.spec = {} + + def load(self): + with self.spec_path.open("r") as f: + self.spec = yaml.safe_load(f) + + +class CoreOASPlugin(OASPlugin): + name = "localstack" diff --git a/localstack/services/sns/__init__.py b/localstack-core/localstack/py.typed similarity index 100% rename from localstack/services/sns/__init__.py rename to localstack-core/localstack/py.typed diff --git a/localstack-core/localstack/runtime/__init__.py b/localstack-core/localstack/runtime/__init__.py new file mode 100644 index 0000000000000..99044a674080a --- /dev/null +++ b/localstack-core/localstack/runtime/__init__.py @@ -0,0 +1,5 @@ +from .current import get_current_runtime + +__all__ = [ + "get_current_runtime", +] diff --git a/localstack-core/localstack/runtime/analytics.py b/localstack-core/localstack/runtime/analytics.py new file mode 100644 index 0000000000000..2612ee8637bf9 --- /dev/null +++ b/localstack-core/localstack/runtime/analytics.py @@ -0,0 +1,136 @@ +import logging +import os + +from localstack import config +from localstack.runtime import hooks +from localstack.utils.analytics import log + +LOG = logging.getLogger(__name__) + +TRACKED_ENV_VAR = [ + "ALLOW_NONSTANDARD_REGIONS", + "BEDROCK_PREWARM", + "CLOUDFRONT_LAMBDA_EDGE", + "CONTAINER_RUNTIME", + "DEBUG", + "DEFAULT_REGION", # Not functional; deprecated in 0.12.7, removed in 3.0.0 + "DEFAULT_BEDROCK_MODEL", + "DISABLE_CORS_CHECK", + "DISABLE_CORS_HEADERS", + "DMS_SERVERLESS_DEPROVISIONING_DELAY", + "DMS_SERVERLESS_STATUS_CHANGE_WAITING_TIME", + "DNS_ADDRESS", + "DYNAMODB_ERROR_PROBABILITY", + "DYNAMODB_IN_MEMORY", + "DYNAMODB_REMOVE_EXPIRED_ITEMS", + "EAGER_SERVICE_LOADING", + "EC2_VM_MANAGER", + "ECS_TASK_EXECUTOR", + "EDGE_PORT", + "ENABLE_REPLICATOR", + "ENFORCE_IAM", + "ES_CUSTOM_BACKEND", # deprecated in 0.14.0, removed in 3.0.0 + "ES_MULTI_CLUSTER", # deprecated in 0.14.0, removed in 3.0.0 + "ES_ENDPOINT_STRATEGY", # deprecated in 0.14.0, removed in 3.0.0 + "EVENT_RULE_ENGINE", + "IAM_SOFT_MODE", + "KINESIS_PROVIDER", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "KINESIS_ERROR_PROBABILITY", + "KMS_PROVIDER", # defunct since 1.4.0 + "LAMBDA_DEBUG_MODE", + "LAMBDA_DOWNLOAD_AWS_LAYERS", + "LAMBDA_EXECUTOR", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_STAY_OPEN_MODE", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_REMOTE_DOCKER", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_CODE_EXTRACT_TIME", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_CONTAINER_REGISTRY", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_FALLBACK_URL", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_FORWARD_URL", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_XRAY_INIT", # Not functional; deprecated in 2.0.0, removed in 3.0.0 + "LAMBDA_PREBUILD_IMAGES", + "LAMBDA_RUNTIME_EXECUTOR", + "LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT", + "LEGACY_EDGE_PROXY", # Not functional; deprecated in 1.0.0, removed in 2.0.0 + "LS_LOG", + "MOCK_UNIMPLEMENTED", # Not functional; deprecated in 1.3.0, removed in 3.0.0 + "OPENSEARCH_ENDPOINT_STRATEGY", + "PERSISTENCE", + "PERSISTENCE_SINGLE_FILE", + "PERSIST_ALL", # defunct since 2.3.2 + "PORT_WEB_UI", + "RDS_MYSQL_DOCKER", + "REQUIRE_PRO", + "SERVICES", + "STRICT_SERVICE_LOADING", + "SKIP_INFRA_DOWNLOADS", + "SQS_ENDPOINT_STRATEGY", + "USE_SINGLE_REGION", # Not functional; deprecated in 0.12.7, removed in 3.0.0 + "USE_SSL", +] + +PRESENCE_ENV_VAR = [ + "DATA_DIR", + "EDGE_FORWARD_URL", # Not functional; deprecated in 1.4.0, removed in 3.0.0 + "GATEWAY_LISTEN", + "HOSTNAME", + "HOSTNAME_EXTERNAL", + "HOSTNAME_FROM_LAMBDA", + "HOST_TMP_FOLDER", # Not functional; deprecated in 1.0.0, removed in 2.0.0 + "INIT_SCRIPTS_PATH", # Not functional; deprecated in 1.1.0, removed in 2.0.0 + "LAMBDA_DEBUG_MODE_CONFIG_PATH", + "LEGACY_DIRECTORIES", # Not functional; deprecated in 1.1.0, removed in 2.0.0 + "LEGACY_INIT_DIR", # Not functional; deprecated in 1.1.0, removed in 2.0.0 + "LOCALSTACK_HOST", + "LOCALSTACK_HOSTNAME", + "OUTBOUND_HTTP_PROXY", + "OUTBOUND_HTTPS_PROXY", + "S3_DIR", + "SFN_MOCK_CONFIG", + "TMPDIR", +] + + +@hooks.on_infra_start() +def _publish_config_as_analytics_event(): + env_vars = list(TRACKED_ENV_VAR) + + for key, value in os.environ.items(): + if key.startswith("PROVIDER_OVERRIDE_"): + env_vars.append(key) + elif key.startswith("SYNCHRONOUS_") and key.endswith("_EVENTS"): + # these config variables have been removed with 3.0.0 + env_vars.append(key) + + env_vars = {key: os.getenv(key) for key in env_vars} + present_env_vars = {env_var: 1 for env_var in PRESENCE_ENV_VAR if os.getenv(env_var)} + + log.event("config", env_vars=env_vars, set_vars=present_env_vars) + + +class LocalstackContainerInfo: + def get_image_variant(self) -> str: + for f in os.listdir("/usr/lib/localstack"): + if f.startswith(".") and f.endswith("-version"): + return f[1:-8] + return "unknown" + + def has_docker_socket(self) -> bool: + return os.path.exists("/run/docker.sock") + + def to_dict(self): + return { + "variant": self.get_image_variant(), + "has_docker_socket": self.has_docker_socket(), + } + + +@hooks.on_infra_start() +def _publish_container_info(): + if not config.is_in_docker: + return + + try: + log.event("container_info", payload=LocalstackContainerInfo().to_dict()) + except Exception as e: + if config.DEBUG_ANALYTICS: + LOG.debug("error gathering container information: %s", e) diff --git a/localstack-core/localstack/runtime/components.py b/localstack-core/localstack/runtime/components.py new file mode 100644 index 0000000000000..db9662b2e030b --- /dev/null +++ b/localstack-core/localstack/runtime/components.py @@ -0,0 +1,56 @@ +""" +This package contains code to define and manage the core components that make up a ``LocalstackRuntime``. +These include: + - A ``Gateway`` + - A ``RuntimeServer`` as the main control loop + - A ``ServiceManager`` to manage service plugins (TODO: once the Service concept has been generalized) + - ... ? + +Components can then be accessed via ``get_current_runtime()``. +""" + +from functools import cached_property + +from plux import Plugin, PluginManager +from rolo.gateway import Gateway + +from .server.core import RuntimeServer, RuntimeServerPlugin + + +class Components(Plugin): + """ + A Plugin that allows a specific localstack runtime implementation (aws, snowflake, ...) to expose its + own component factory. + """ + + namespace = "localstack.runtime.components" + + @cached_property + def gateway(self) -> Gateway: + raise NotImplementedError + + @cached_property + def runtime_server(self) -> RuntimeServer: + raise NotImplementedError + + +class BaseComponents(Components): + """ + A component base, which includes a ``RuntimeServer`` created from the config variable, and a default + ServicePluginManager as ServiceManager. + """ + + @cached_property + def runtime_server(self) -> RuntimeServer: + from localstack import config + + # TODO: rename to RUNTIME_SERVER + server_type = config.GATEWAY_SERVER + + plugins = PluginManager(RuntimeServerPlugin.namespace) + + if not plugins.exists(server_type): + raise ValueError(f"Unknown gateway server type {server_type}") + + plugins.load(server_type) + return plugins.get_container(server_type).load_value diff --git a/localstack-core/localstack/runtime/current.py b/localstack-core/localstack/runtime/current.py new file mode 100644 index 0000000000000..fa033c58844fa --- /dev/null +++ b/localstack-core/localstack/runtime/current.py @@ -0,0 +1,40 @@ +"""This package gives access to the singleton ``LocalstackRuntime`` instance. This is the only global state +that should exist within localstack, which contains the singleton ``LocalstackRuntime`` which is currently +running.""" + +import threading +import typing + +if typing.TYPE_CHECKING: + # make sure we don't have any imports here at runtime, so it can be imported anywhere without conflicts + from .runtime import LocalstackRuntime + +_runtime: typing.Optional["LocalstackRuntime"] = None +"""The singleton LocalStack Runtime""" +_runtime_lock = threading.RLock() + + +def get_current_runtime() -> "LocalstackRuntime": + with _runtime_lock: + if not _runtime: + raise ValueError("LocalStack runtime has not yet been set") + return _runtime + + +def set_current_runtime(runtime: "LocalstackRuntime"): + with _runtime_lock: + global _runtime + _runtime = runtime + + +def initialize_runtime() -> "LocalstackRuntime": + from localstack.runtime import runtime + + with _runtime_lock: + try: + return get_current_runtime() + except ValueError: + pass + rt = runtime.create_from_environment() + set_current_runtime(rt) + return rt diff --git a/localstack-core/localstack/runtime/events.py b/localstack-core/localstack/runtime/events.py new file mode 100644 index 0000000000000..2382fab6a47a2 --- /dev/null +++ b/localstack-core/localstack/runtime/events.py @@ -0,0 +1,7 @@ +import threading + +# TODO: deprecate and replace access with ``get_current_runtime().starting``, ... +infra_starting = threading.Event() +infra_ready = threading.Event() +infra_stopping = threading.Event() +infra_stopped = threading.Event() diff --git a/localstack-core/localstack/runtime/exceptions.py b/localstack-core/localstack/runtime/exceptions.py new file mode 100644 index 0000000000000..b4a4f72e65066 --- /dev/null +++ b/localstack-core/localstack/runtime/exceptions.py @@ -0,0 +1,9 @@ +class LocalstackExit(Exception): + """ + This exception can be raised during the startup procedure to terminate localstack with an exit code and + a reason. + """ + + def __init__(self, reason: str = None, code: int = 0): + super().__init__(reason) + self.code = code diff --git a/localstack-core/localstack/runtime/hooks.py b/localstack-core/localstack/runtime/hooks.py new file mode 100644 index 0000000000000..05161679cf54e --- /dev/null +++ b/localstack-core/localstack/runtime/hooks.py @@ -0,0 +1,104 @@ +import functools + +from plux import PluginManager, plugin + +# plugin namespace constants +HOOKS_CONFIGURE_LOCALSTACK_CONTAINER = "localstack.hooks.configure_localstack_container" +HOOKS_ON_RUNTIME_CREATE = "localstack.hooks.on_runtime_create" +HOOKS_ON_INFRA_READY = "localstack.hooks.on_infra_ready" +HOOKS_ON_INFRA_START = "localstack.hooks.on_infra_start" +HOOKS_ON_PRO_INFRA_START = "localstack.hooks.on_pro_infra_start" +HOOKS_ON_INFRA_SHUTDOWN = "localstack.hooks.on_infra_shutdown" +HOOKS_PREPARE_HOST = "localstack.hooks.prepare_host" + + +def hook(namespace: str, priority: int = 0, **kwargs): + """ + Decorator for creating functional plugins that have a hook_priority attribute. Hooks with a higher priority value + will be executed earlier. + """ + + def wrapper(fn): + fn.hook_priority = priority + return plugin(namespace=namespace, **kwargs)(fn) + + return wrapper + + +def hook_spec(namespace: str): + """ + Creates a new hook decorator bound to a namespace. + + on_infra_start = hook_spec("localstack.hooks.on_infra_start") + + @on_infra_start() + def foo(): + pass + + # run all hooks in order + on_infra_start.run() + """ + fn = functools.partial(hook, namespace=namespace) + # attach hook manager and run method to decorator for convenience calls + fn.manager = HookManager(namespace) + fn.run = fn.manager.run_in_order + return fn + + +class HookManager(PluginManager): + def load_all_sorted(self, propagate_exceptions=False): + """ + Loads all hook plugins and sorts them by their hook_priority attribute. + """ + plugins = self.load_all(propagate_exceptions) + # the hook_priority attribute is part of the function wrapped in the FunctionPlugin + plugins.sort( + key=lambda _fn_plugin: getattr(_fn_plugin.fn, "hook_priority", 0), reverse=True + ) + return plugins + + def run_in_order(self, *args, **kwargs): + """ + Loads and runs all plugins in order them with the given arguments. + """ + for fn_plugin in self.load_all_sorted(): + fn_plugin(*args, **kwargs) + + def __str__(self): + return "HookManager(%s)" % self.namespace + + def __repr__(self): + return self.__str__() + + +configure_localstack_container = hook_spec(HOOKS_CONFIGURE_LOCALSTACK_CONTAINER) +"""Hooks to configure the LocalStack container before it starts. Executed on the host when invoking the CLI.""" + +prepare_host = hook_spec(HOOKS_PREPARE_HOST) +"""Hooks to prepare the host that's starting LocalStack. Executed on the host when invoking the CLI.""" + +on_infra_start = hook_spec(HOOKS_ON_INFRA_START) +"""Hooks that are executed right before starting the LocalStack infrastructure.""" + +on_runtime_create = hook_spec(HOOKS_ON_RUNTIME_CREATE) +"""Hooks that are executed right before the LocalstackRuntime is created. These can be used to apply +patches or otherwise configure the interpreter before any other code is imported.""" + +on_runtime_start = on_infra_start +"""Alias for on_infra_start. TODO: switch and deprecated `infra` naming.""" + +on_pro_infra_start = hook_spec(HOOKS_ON_PRO_INFRA_START) +"""Hooks that are executed after on_infra_start hooks, and only if LocalStack pro has been activated.""" + +on_infra_ready = hook_spec(HOOKS_ON_INFRA_READY) +"""Hooks that are execute after all startup hooks have been executed, and the LocalStack infrastructure has become +available.""" + +on_runtime_ready = on_infra_ready +"""Alias for on_infra_ready. TODO: switch and deprecated `infra` naming.""" + +on_infra_shutdown = hook_spec(HOOKS_ON_INFRA_SHUTDOWN) +"""Hooks that are execute when localstack shuts down.""" + +on_runtime_shutdown = on_infra_shutdown +"""Alias for on_infra_shutdown. TODO: switch and deprecated `infra` naming.""" diff --git a/localstack-core/localstack/runtime/init.py b/localstack-core/localstack/runtime/init.py new file mode 100644 index 0000000000000..e9b2f97dccf9e --- /dev/null +++ b/localstack-core/localstack/runtime/init.py @@ -0,0 +1,283 @@ +"""Module for initialization hooks https://docs.localstack.cloud/references/init-hooks/""" + +import dataclasses +import logging +import os.path +import subprocess +import time +from enum import Enum +from functools import cached_property +from typing import Dict, List, Optional + +from plux import Plugin, PluginManager + +from localstack.runtime import hooks +from localstack.utils.objects import singleton_factory + +LOG = logging.getLogger(__name__) + + +class State(Enum): + UNKNOWN = "UNKNOWN" + RUNNING = "RUNNING" + SUCCESSFUL = "SUCCESSFUL" + ERROR = "ERROR" + + def __str__(self): + return self.name + + def __repr__(self): + return self.name + + +class Stage(Enum): + BOOT = 0 + START = 1 + READY = 2 + SHUTDOWN = 3 + + def __str__(self): + return self.name + + def __repr__(self): + return self.name + + +@dataclasses.dataclass +class Script: + path: str + stage: Stage + state: State = State.UNKNOWN + + +class ScriptRunner(Plugin): + """ + Interface for running scripts. + """ + + namespace = "localstack.init.runner" + suffixes = [] + + def run(self, path: str) -> None: + """ + Run the given script with the appropriate runtime. + + :param path: the path to the script + """ + raise NotImplementedError + + def should_run(self, script_file: str) -> bool: + """ + Checks whether the given file should be run with this script runner. In case multiple runners + evaluate this condition to true on the same file (ideally this doesn't happen), the first one + loaded will be used, which is potentially indeterministic. + + :param script_file: the script file to run + :return: True if this runner should be used, False otherwise + """ + for suffix in self.suffixes: + if script_file.endswith(suffix): + return True + return False + + +class ShellScriptRunner(ScriptRunner): + """ + Runner that interprets scripts as shell scripts and calls them directly. + """ + + name = "sh" + suffixes = [".sh"] + + def run(self, path: str) -> None: + exit_code = subprocess.call(args=[], executable=path) + if exit_code != 0: + raise OSError("Script %s returned a non-zero exit code %s" % (path, exit_code)) + + +class PythonScriptRunner(ScriptRunner): + """ + Runner that uses ``exec`` to run a python script. + """ + + name = "py" + suffixes = [".py"] + + def run(self, path: str) -> None: + with open(path, "rb") as fd: + exec(fd.read(), {}) + + +class InitScriptManager: + _stage_directories: Dict[Stage, str] = { + Stage.BOOT: "boot.d", + Stage.START: "start.d", + Stage.READY: "ready.d", + Stage.SHUTDOWN: "shutdown.d", + } + + script_root: str + stage_completed: Dict[Stage, bool] + + def __init__(self, script_root: str): + self.script_root = script_root + self.stage_completed = dict.fromkeys(Stage, False) + self.runner_manager: PluginManager[ScriptRunner] = PluginManager(ScriptRunner.namespace) + + @cached_property + def scripts(self) -> Dict[Stage, List[Script]]: + return self._find_scripts() + + def get_script_runner(self, script_file: str) -> Optional[ScriptRunner]: + runners = self.runner_manager.load_all() + for runner in runners: + if runner.should_run(script_file): + return runner + return None + + def has_script_runner(self, script_file: str) -> bool: + return self.get_script_runner(script_file) is not None + + def run_stage(self, stage: Stage) -> List[Script]: + """ + Runs all scripts in the given stage. + + :param stage: the stage to run + :return: the scripts that were in the stage + """ + scripts = self.scripts.get(stage, []) + + if self.stage_completed[stage]: + LOG.debug("Stage %s already completed, skipping", stage) + return scripts + + try: + for script in scripts: + LOG.debug("Running %s script %s", script.stage, script.path) + + env_original = os.environ.copy() + + try: + script.state = State.RUNNING + runner = self.get_script_runner(script.path) + runner.run(script.path) + except Exception as e: + script.state = State.ERROR + if LOG.isEnabledFor(logging.DEBUG): + LOG.exception("Error while running script %s", script) + else: + LOG.error("Error while running script %s: %s", script, e) + else: + script.state = State.SUCCESSFUL + finally: + # Discard env variables overridden in startup script that may cause side-effects + for env_var in ( + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "AWS_REGION", + ): + if env_var in env_original: + os.environ[env_var] = env_original[env_var] + else: + os.environ.pop(env_var, None) + finally: + self.stage_completed[stage] = True + + return scripts + + def _find_scripts(self) -> Dict[Stage, List[Script]]: + scripts = {} + + if self.script_root is None: + LOG.debug("Unable to discover init scripts as script_root is None") + return {} + + for stage in Stage: + scripts[stage] = [] + + stage_dir = self._stage_directories[stage] + if not stage_dir: + continue + + stage_path = os.path.join(self.script_root, stage_dir) + if not os.path.isdir(stage_path): + continue + + for root, dirs, files in os.walk(stage_path, topdown=True): + # from the docs: "When topdown is true, the caller can modify the dirnames list in-place" + dirs.sort() + files.sort() + for file in files: + script_path = os.path.abspath(os.path.join(root, file)) + if not os.path.isfile(script_path): + continue + + # only add the script if there's a runner for it + if not self.has_script_runner(script_path): + LOG.debug("No runner available for script %s", script_path) + continue + + scripts[stage].append(Script(path=script_path, stage=stage)) + LOG.debug("Init scripts discovered: %s", scripts) + + return scripts + + +# runtime integration + + +@singleton_factory +def init_script_manager() -> InitScriptManager: + from localstack import config + + return InitScriptManager(script_root=config.dirs.init) + + +@hooks.on_infra_start() +def _run_init_scripts_on_start(): + # this is a hack since we currently cannot know whether boot scripts have been executed or not + init_script_manager().stage_completed[Stage.BOOT] = True + _run_and_log(Stage.START) + + +@hooks.on_infra_ready() +def _run_init_scripts_on_ready(): + _run_and_log(Stage.READY) + + +@hooks.on_infra_shutdown() +def _run_init_scripts_on_shutdown(): + _run_and_log(Stage.SHUTDOWN) + + +def _run_and_log(stage: Stage): + from localstack.utils.analytics import log + + then = time.time() + scripts = init_script_manager().run_stage(stage) + took = (time.time() - then) * 1000 + + if scripts: + log.event("run_init", {"stage": stage.name, "scripts": len(scripts), "duration": took}) + + +def main(): + """ + Run the init scripts for a particular stage. For example, to run all boot scripts run:: + + python -m localstack.runtime.init BOOT + + The __main__ entrypoint is currently mainly used for the docker-entrypoint.sh. Other stages + are executed from runtime hooks. + """ + import sys + + stage = Stage[sys.argv[1]] + init_script_manager().run_stage(stage) + + +if __name__ == "__main__": + main() diff --git a/localstack-core/localstack/runtime/legacy.py b/localstack-core/localstack/runtime/legacy.py new file mode 100644 index 0000000000000..2a2f54c562929 --- /dev/null +++ b/localstack-core/localstack/runtime/legacy.py @@ -0,0 +1,17 @@ +"""Adapter code for the legacy runtime to make sure the new runtime is compatible with the old one, +and at the same time doesn't need ``localstack.services.infra``, which imports AWS-specific modules.""" + +import logging +import os +import signal + +LOG = logging.getLogger(__name__) + + +def signal_supervisor_restart(): + # TODO: we should think about moving the localstack-supervisor into a script in the runtime, + # and make `signal_supervisor_restart` part of the supervisor code. + if pid := os.environ.get("SUPERVISOR_PID"): + os.kill(int(pid), signal.SIGUSR1) + else: + LOG.warning("could not signal supervisor to restart localstack") diff --git a/localstack-core/localstack/runtime/main.py b/localstack-core/localstack/runtime/main.py new file mode 100644 index 0000000000000..3a0357e230ad0 --- /dev/null +++ b/localstack-core/localstack/runtime/main.py @@ -0,0 +1,93 @@ +"""This is the entrypoint used to start the localstack runtime. It starts the infrastructure and also +manages the interaction with the operating system - mostly signal handlers for now.""" + +import signal +import sys +import traceback + +from localstack import config, constants +from localstack.runtime.exceptions import LocalstackExit + + +def print_runtime_information(in_docker: bool = False): + # FIXME: this is legacy code from the old CLI, reconcile with new CLI and runtime output + from localstack.utils.container_networking import get_main_container_name + from localstack.utils.container_utils.container_client import ContainerException + from localstack.utils.docker_utils import DOCKER_CLIENT + + print() + print(f"LocalStack version: {constants.VERSION}") + if in_docker: + try: + container_name = get_main_container_name() + print("LocalStack Docker container name: %s" % container_name) + inspect_result = DOCKER_CLIENT.inspect_container(container_name) + container_id = inspect_result["Id"] + print("LocalStack Docker container id: %s" % container_id[:12]) + image_details = DOCKER_CLIENT.inspect_image(inspect_result["Image"]) + digests = image_details.get("RepoDigests") or ["Unavailable"] + print("LocalStack Docker image sha: %s" % digests[0]) + except ContainerException: + print( + "LocalStack Docker container info: Failed to inspect the LocalStack docker container. " + "This is likely because the docker socket was not mounted into the container. " + "Without access to the docker socket, LocalStack will not function properly. Please " + "consult the LocalStack documentation on how to correctly start up LocalStack. ", + end="", + ) + if config.DEBUG: + print("Docker debug information:") + traceback.print_exc() + else: + print( + "You can run LocalStack with `DEBUG=1` to get more information about the error." + ) + + if config.LOCALSTACK_BUILD_DATE: + print("LocalStack build date: %s" % config.LOCALSTACK_BUILD_DATE) + + if config.LOCALSTACK_BUILD_GIT_HASH: + print("LocalStack build git hash: %s" % config.LOCALSTACK_BUILD_GIT_HASH) + + print() + + +def main(): + from localstack.logging.setup import setup_logging_from_config + from localstack.runtime import current + + try: + setup_logging_from_config() + runtime = current.initialize_runtime() + except Exception as e: + sys.stdout.write(f"ERROR: The LocalStack Runtime could not be initialized: {e}\n") + sys.stdout.flush() + raise + + # TODO: where should this go? + print_runtime_information() + + # signal handler to make sure SIGTERM properly shuts down localstack + def _terminate_localstack(sig: int, frame): + sys.stdout.write(f"Localstack runtime received signal {sig}\n") + sys.stdout.flush() + runtime.exit(0) + + signal.signal(signal.SIGINT, _terminate_localstack) + signal.signal(signal.SIGTERM, _terminate_localstack) + + try: + runtime.run() + except LocalstackExit as e: + sys.stdout.write(f"Localstack returning with exit code {e.code}. Reason: {e}") + sys.exit(e.code) + except Exception as e: + sys.stdout.write(f"ERROR: the LocalStack runtime exited unexpectedly: {e}\n") + sys.stdout.flush() + raise + + sys.exit(runtime.exit_code) + + +if __name__ == "__main__": + main() diff --git a/localstack-core/localstack/runtime/patches.py b/localstack-core/localstack/runtime/patches.py new file mode 100644 index 0000000000000..4772a480bfee1 --- /dev/null +++ b/localstack-core/localstack/runtime/patches.py @@ -0,0 +1,70 @@ +""" +System-wide patches that should be applied. +""" + +from localstack.runtime import hooks +from localstack.utils.patch import patch + + +def patch_thread_pool(): + """ + This patch to ThreadPoolExecutor makes the executor remove the threads it creates from the global + ``_thread_queues`` of ``concurrent.futures.thread``, which joins all created threads at python exit and + will block interpreter shutdown if any threads are still running, even if they are daemon threads. + """ + + import concurrent.futures.thread + + @patch(concurrent.futures.thread.ThreadPoolExecutor._adjust_thread_count) + def _adjust_thread_count(fn, self) -> None: + fn(self) + + for t in self._threads: + if not t.daemon: + continue + try: + del concurrent.futures.thread._threads_queues[t] + except KeyError: + pass + + +def patch_urllib3_connection_pool(**constructor_kwargs): + """ + Override the default parameters of HTTPConnectionPool, e.g., set the pool size via maxsize=16 + """ + try: + from urllib3 import connectionpool, poolmanager + + class MyHTTPSConnectionPool(connectionpool.HTTPSConnectionPool): + def __init__(self, *args, **kwargs): + kwargs.update(constructor_kwargs) + super(MyHTTPSConnectionPool, self).__init__(*args, **kwargs) + + poolmanager.pool_classes_by_scheme["https"] = MyHTTPSConnectionPool + + class MyHTTPConnectionPool(connectionpool.HTTPConnectionPool): + def __init__(self, *args, **kwargs): + kwargs.update(constructor_kwargs) + super(MyHTTPConnectionPool, self).__init__(*args, **kwargs) + + poolmanager.pool_classes_by_scheme["http"] = MyHTTPConnectionPool + except Exception: + pass + + +_applied = False + + +@hooks.on_runtime_start(priority=100) # apply patches earlier than other hooks +def apply_runtime_patches(): + # FIXME: find a better way to apply system-wide patches + global _applied + if _applied: + return + _applied = True + + from localstack.http.duplex_socket import enable_duplex_socket + + patch_urllib3_connection_pool(maxsize=128) + patch_thread_pool() + enable_duplex_socket() diff --git a/localstack-core/localstack/runtime/runtime.py b/localstack-core/localstack/runtime/runtime.py new file mode 100644 index 0000000000000..1e5d4e6ab5b21 --- /dev/null +++ b/localstack-core/localstack/runtime/runtime.py @@ -0,0 +1,203 @@ +import logging +import os +import threading + +from plux import PluginManager + +from localstack import config, constants +from localstack.runtime import events, hooks +from localstack.utils import files, functions, net, sync, threads + +from .components import Components + +LOG = logging.getLogger(__name__) + + +class LocalstackRuntime: + """ + The localstack runtime. It has the following responsibilities: + + - Manage localstack filesystem directories + - Execute runtime lifecycle hook plugins from ``localstack.runtime.hooks``. + - Manage the localstack SSL certificate + - Serve the gateway (It uses a ``RuntimeServer`` to serve a ``Gateway`` instance coming from the + ``Components`` factory.) + """ + + def __init__(self, components: Components): + self.components = components + + # at some point, far far in the future, we should no longer access a global config object, but rather + # the one from the current runtime. This will allow us to truly instantiate multiple localstack + # runtime instances in one process, which can be useful for many different things. but there is too + # much global state at the moment think about this seriously. however, this assignment here can + # serve as a reminder to avoid global state in general. + self.config = config + + # TODO: move away from `localstack.runtime.events` and instantiate new `threading.Event()` here + # instead + self.starting = events.infra_starting + self.ready = events.infra_ready + self.stopping = events.infra_stopping + self.stopped = events.infra_stopped + self.exit_code = 0 + self._lifecycle_lock = threading.RLock() + + def run(self): + """ + Start the main control loop of the runtime and block the thread. This will initialize the + filesystem, run all lifecycle hooks, initialize the gateway server, and then serve the + ``RuntimeServer`` until ``shutdown()`` is called. + """ + # indicates to the environment that this is an "infra process" (old terminology referring to the + # localstack runtime). this is necessary for disabling certain hooks that may run in the context of + # the CLI host mode. TODO: should not be needed over time. + os.environ[constants.LOCALSTACK_INFRA_PROCESS] = "1" + + self._init_filesystem() + self._on_starting() + self._init_gateway_server() + + # since we are blocking the main thread with the runtime server, we need to run the monitor that + # prints the ready marker asynchronously. this is different from how the runtime was started in the + # past, where the server was running in a thread. + # TODO: ideally we pass down a `shutdown` event that can be waited on so we can cancel the thread + # if the runtime shuts down beforehand + threading.Thread(target=self._run_ready_monitor, daemon=True).start() + + # run the main control loop of the server and block execution + try: + self.components.runtime_server.run() + finally: + self._on_return() + + def exit(self, code: int = 0): + """ + Sets the exit code and runs ``shutdown``. It does not actually call ``sys.exit``, this is for the + caller to do. + + :param code: the exit code to be set + """ + self.exit_code = code + # we don't know yet why, but shutdown does not work on the main thread + threading.Thread(target=self.shutdown, name="Runtime-Shutdown").start() + + def shutdown(self): + """ + Initiates an orderly shutdown of the runtime by stopping the main control loop of the + ``RuntimeServer``. The shutdown hooks are actually called by the main control loop (in the main + thread) after it returns. + """ + with self._lifecycle_lock: + if self.stopping.is_set(): + return + self.stopping.set() + + LOG.debug("[shutdown] Running shutdown hooks ...") + functions.call_safe( + hooks.on_runtime_shutdown.run, + exception_message="[shutdown] error calling shutdown hook", + ) + LOG.debug("[shutdown] Shutting down runtime server ...") + self.components.runtime_server.shutdown() + + def is_ready(self) -> bool: + return self.ready.is_set() + + def _init_filesystem(self): + self._clear_tmp_directory() + self.config.dirs.mkdirs() + + def _init_gateway_server(self): + from localstack.utils.ssl import create_ssl_cert, install_predefined_cert_if_available + + install_predefined_cert_if_available() + serial_number = self.config.GATEWAY_LISTEN[0].port + _, cert_file_name, key_file_name = create_ssl_cert(serial_number=serial_number) + ssl_creds = (cert_file_name, key_file_name) + + self.components.runtime_server.register( + self.components.gateway, self.config.GATEWAY_LISTEN, ssl_creds + ) + + def _on_starting(self): + self.starting.set() + hooks.on_runtime_start.run() + + def _on_ready(self): + hooks.on_runtime_ready.run() + print(constants.READY_MARKER_OUTPUT, flush=True) + self.ready.set() + + def _on_return(self): + LOG.debug("[shutdown] Cleaning up resources ...") + self._cleanup_resources() + self.stopped.set() + LOG.debug("[shutdown] Completed, bye!") + + def _run_ready_monitor(self): + self._wait_for_gateway() + self._on_ready() + + def _wait_for_gateway(self): + host_and_port = self.config.GATEWAY_LISTEN[0] + + if not sync.poll_condition( + lambda: net.is_port_open(host_and_port.port), timeout=15, interval=0.3 + ): + if LOG.isEnabledFor(logging.DEBUG): + # make another call with quiet=False to print detailed error logs + net.is_port_open(host_and_port.port, quiet=False) + raise TimeoutError(f"gave up waiting for gateway server to start on {host_and_port}") + + def _clear_tmp_directory(self): + if self.config.CLEAR_TMP_FOLDER: + # try to clear temp dir on startup + try: + files.rm_rf(self.config.dirs.tmp) + except PermissionError as e: + LOG.error( + "unable to delete temp folder %s: %s, please delete manually or you will " + "keep seeing these errors.", + self.config.dirs.tmp, + e, + ) + + def _cleanup_resources(self): + threads.cleanup_threads_and_processes() + self._clear_tmp_directory() + + +def create_from_environment() -> LocalstackRuntime: + """ + Creates a new runtime instance from the current environment. It uses a plugin manager to resolve the + necessary components from the ``localstack.runtime.components`` plugin namespace to start the runtime. + + :return: a new LocalstackRuntime instance + """ + hooks.on_runtime_create.run() + + plugin_manager = PluginManager(Components.namespace) + if config.RUNTIME_COMPONENTS: + try: + component = plugin_manager.load(config.RUNTIME_COMPONENTS) + return LocalstackRuntime(component) + except Exception as e: + raise ValueError( + f"Could not load runtime components from config RUNTIME_COMPONENTS={config.RUNTIME_COMPONENTS}: {e}." + ) from e + components = plugin_manager.load_all() + + if not components: + raise ValueError( + f"No component plugins found in namespace {Components.namespace}. Are entry points created " + f"correctly?" + ) + + if len(components) > 1: + LOG.warning( + "There are more than one component plugins, using the first one which is %s", + components[0].name, + ) + + return LocalstackRuntime(components[0]) diff --git a/localstack-core/localstack/runtime/server/__init__.py b/localstack-core/localstack/runtime/server/__init__.py new file mode 100644 index 0000000000000..808f22795246a --- /dev/null +++ b/localstack-core/localstack/runtime/server/__init__.py @@ -0,0 +1,5 @@ +from localstack.runtime.server.core import RuntimeServer + +__all__ = [ + "RuntimeServer", +] diff --git a/localstack-core/localstack/runtime/server/core.py b/localstack-core/localstack/runtime/server/core.py new file mode 100644 index 0000000000000..137f276f3d496 --- /dev/null +++ b/localstack-core/localstack/runtime/server/core.py @@ -0,0 +1,51 @@ +from plux import Plugin +from rolo.gateway import Gateway + +from localstack import config + + +class RuntimeServer: + """ + The main network IO loop of LocalStack. This could be twisted, hypercorn, or any other server + implementation. + """ + + def register( + self, + gateway: Gateway, + listen: list[config.HostAndPort], + ssl_creds: tuple[str, str] | None = None, + ): + """ + Registers the Gateway and the port configuration into the server. Some servers like ``twisted`` or + ``hypercorn`` support multiple calls to ``register``, allowing you to serve several Gateways + through a single event loop. + + :param gateway: the gateway to serve + :param listen: the host and port configuration + :param ssl_creds: ssl credentials (certificate file path, key file path) + """ + raise NotImplementedError + + def run(self): + """ + Run the server and block the thread. + """ + raise NotImplementedError + + def shutdown(self): + """ + Shutdown the running server. + """ + raise NotImplementedError + + +class RuntimeServerPlugin(Plugin): + """ + Plugin that serves as a factory for specific ```RuntimeServer`` implementations. + """ + + namespace = "localstack.runtime.server" + + def load(self, *args, **kwargs) -> RuntimeServer: + raise NotImplementedError diff --git a/localstack-core/localstack/runtime/server/hypercorn.py b/localstack-core/localstack/runtime/server/hypercorn.py new file mode 100644 index 0000000000000..ce15ea3d043e0 --- /dev/null +++ b/localstack-core/localstack/runtime/server/hypercorn.py @@ -0,0 +1,68 @@ +import asyncio +import threading + +from hypercorn import Config +from hypercorn.asyncio import serve +from rolo.gateway import Gateway +from rolo.gateway.asgi import AsgiGateway + +from localstack import config +from localstack.logging.setup import setup_hypercorn_logger + +from .core import RuntimeServer + + +class HypercornRuntimeServer(RuntimeServer): + def __init__(self): + self.loop = asyncio.get_event_loop() + + self._close = asyncio.Event() + self._closed = threading.Event() + + self._futures = [] + + def register( + self, + gateway: Gateway, + listen: list[config.HostAndPort], + ssl_creds: tuple[str, str] | None = None, + ): + hypercorn_config = Config() + hypercorn_config.h11_pass_raw_headers = True + hypercorn_config.bind = [str(host_and_port) for host_and_port in listen] + # hypercorn_config.use_reloader = use_reloader + + setup_hypercorn_logger(hypercorn_config) + + if ssl_creds: + cert_file_name, key_file_name = ssl_creds + hypercorn_config.certfile = cert_file_name + hypercorn_config.keyfile = key_file_name + + app = AsgiGateway(gateway, event_loop=self.loop) + + future = asyncio.run_coroutine_threadsafe( + serve(app, hypercorn_config, shutdown_trigger=self._shutdown_trigger), + self.loop, + ) + self._futures.append(future) + + def run(self): + self.loop.run_forever() + + def shutdown(self): + self._close.set() + asyncio.run_coroutine_threadsafe(self._set_closed(), self.loop) + # TODO: correctly wait for all hypercorn serve coroutines to finish + asyncio.run_coroutine_threadsafe(self.loop.shutdown_asyncgens(), self.loop) + self.loop.shutdown_default_executor() + self.loop.stop() + + async def _wait_server_stopped(self): + self._closed.set() + + async def _set_closed(self): + self._close.set() + + async def _shutdown_trigger(self): + await self._close.wait() diff --git a/localstack-core/localstack/runtime/server/plugins.py b/localstack-core/localstack/runtime/server/plugins.py new file mode 100644 index 0000000000000..95746e110375d --- /dev/null +++ b/localstack-core/localstack/runtime/server/plugins.py @@ -0,0 +1,19 @@ +from localstack.runtime.server.core import RuntimeServer, RuntimeServerPlugin + + +class TwistedRuntimeServerPlugin(RuntimeServerPlugin): + name = "twisted" + + def load(self, *args, **kwargs) -> RuntimeServer: + from .twisted import TwistedRuntimeServer + + return TwistedRuntimeServer() + + +class HypercornRuntimeServerPlugin(RuntimeServerPlugin): + name = "hypercorn" + + def load(self, *args, **kwargs) -> RuntimeServer: + from .hypercorn import HypercornRuntimeServer + + return HypercornRuntimeServer() diff --git a/localstack-core/localstack/runtime/server/twisted.py b/localstack-core/localstack/runtime/server/twisted.py new file mode 100644 index 0000000000000..eba02ae16422c --- /dev/null +++ b/localstack-core/localstack/runtime/server/twisted.py @@ -0,0 +1,57 @@ +from rolo.gateway import Gateway +from rolo.serving.twisted import TwistedGateway +from twisted.internet import endpoints, reactor, ssl + +from localstack import config +from localstack.aws.serving.twisted import TLSMultiplexerFactory, stop_thread_pool +from localstack.utils import patch + +from .core import RuntimeServer + + +class TwistedRuntimeServer(RuntimeServer): + def __init__(self): + self.thread_pool = None + + def register( + self, + gateway: Gateway, + listen: list[config.HostAndPort], + ssl_creds: tuple[str, str] | None = None, + ): + # setup twisted webserver Site + site = TwistedGateway(gateway) + + # configure ssl + if ssl_creds: + cert_file_name, key_file_name = ssl_creds + context_factory = ssl.DefaultOpenSSLContextFactory(key_file_name, cert_file_name) + context_factory.getContext().use_certificate_chain_file(cert_file_name) + protocol_factory = TLSMultiplexerFactory(context_factory, False, site) + else: + protocol_factory = site + + # add endpoint for each host/port combination + for host_and_port in listen: + if config.is_ipv6_address(host_and_port.host): + endpoint = endpoints.TCP6ServerEndpoint( + reactor, host_and_port.port, interface=host_and_port.host + ) + else: + # TODO: interface = host? + endpoint = endpoints.TCP4ServerEndpoint(reactor, host_and_port.port) + endpoint.listen(protocol_factory) + + def run(self): + reactor.suggestThreadPoolSize(config.GATEWAY_WORKER_COUNT) + self.thread_pool = reactor.getThreadPool() + patch.patch(self.thread_pool.stop)(stop_thread_pool) + + # we don't need signal handlers, since all they do is call ``reactor`` stop, which we expect the + # caller to do via ``shutdown``. + return reactor.run(installSignalHandlers=False) + + def shutdown(self): + if self.thread_pool: + self.thread_pool.stop(timeout=10) + reactor.stop() diff --git a/localstack-core/localstack/runtime/shutdown.py b/localstack-core/localstack/runtime/shutdown.py new file mode 100644 index 0000000000000..a64dab86ef930 --- /dev/null +++ b/localstack-core/localstack/runtime/shutdown.py @@ -0,0 +1,73 @@ +import logging +from typing import Any, Callable + +from localstack.runtime import hooks +from localstack.utils.functions import call_safe + +LOG = logging.getLogger(__name__) + +SERVICE_SHUTDOWN_PRIORITY = -10 +"""Shutdown hook priority for shutting down service plugins.""" + + +class ShutdownHandlers: + """ + Register / unregister shutdown handlers. All registered shutdown handlers should execute as fast as possible. + Blocking shutdown handlers will block infra shutdown. + """ + + def __init__(self): + self._callbacks = [] + + def register(self, shutdown_handler: Callable[[], Any]) -> None: + """ + Register shutdown handler. Handler should not block or take more than a couple seconds. + + :param shutdown_handler: Callable without parameters + """ + self._callbacks.append(shutdown_handler) + + def unregister(self, shutdown_handler: Callable[[], Any]) -> None: + """ + Unregister a handler. Idempotent operation. + + :param shutdown_handler: Shutdown handler which was previously registered + """ + try: + self._callbacks.remove(shutdown_handler) + except ValueError: + pass + + def run(self) -> None: + """ + Execute shutdown handlers in reverse order of registration. + Should only be called once, on shutdown. + """ + for callback in reversed(list(self._callbacks)): + call_safe(callback) + + +SHUTDOWN_HANDLERS = ShutdownHandlers() +"""Shutdown handlers run with default priority in an on_infra_shutdown hook.""" + +ON_AFTER_SERVICE_SHUTDOWN_HANDLERS = ShutdownHandlers() +"""Shutdown handlers that are executed after all services have been shut down.""" + + +@hooks.on_infra_shutdown() +def run_shutdown_handlers(): + SHUTDOWN_HANDLERS.run() + + +@hooks.on_infra_shutdown(priority=SERVICE_SHUTDOWN_PRIORITY) +def shutdown_services(): + # TODO: this belongs into the shutdown procedure of a `Platform` or `RuntimeContainer` class. + from localstack.services.plugins import SERVICE_PLUGINS + + LOG.info("[shutdown] Stopping all services") + SERVICE_PLUGINS.stop_all_services() + + +@hooks.on_infra_shutdown(priority=SERVICE_SHUTDOWN_PRIORITY - 10) +def run_on_after_service_shutdown_handlers(): + ON_AFTER_SERVICE_SHUTDOWN_HANDLERS.run() diff --git a/localstack/services/sqs/__init__.py b/localstack-core/localstack/services/__init__.py similarity index 100% rename from localstack/services/sqs/__init__.py rename to localstack-core/localstack/services/__init__.py diff --git a/localstack/utils/__init__.py b/localstack-core/localstack/services/acm/__init__.py similarity index 100% rename from localstack/utils/__init__.py rename to localstack-core/localstack/services/acm/__init__.py diff --git a/localstack-core/localstack/services/acm/provider.py b/localstack-core/localstack/services/acm/provider.py new file mode 100644 index 0000000000000..7425b88832e6b --- /dev/null +++ b/localstack-core/localstack/services/acm/provider.py @@ -0,0 +1,136 @@ +from moto import settings as moto_settings +from moto.acm import models as acm_models + +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.acm import ( + AcmApi, + ListCertificatesRequest, + ListCertificatesResponse, + RequestCertificateRequest, + RequestCertificateResponse, +) +from localstack.services import moto +from localstack.utils.patch import patch + +# reduce the validation wait time from 60 (default) to 10 seconds +moto_settings.ACM_VALIDATION_WAIT = min(10, moto_settings.ACM_VALIDATION_WAIT) + + +@patch(acm_models.CertBundle.describe) +def describe(describe_orig, self): + # TODO fix! Terrible hack (for parity). Moto adds certain required fields only if status is PENDING_VALIDATION. + cert_status = self.status + self.status = "PENDING_VALIDATION" + try: + result = describe_orig(self) + finally: + self.status = cert_status + + cert = result.get("Certificate", {}) + cert["Status"] = cert_status + sans = cert.setdefault("SubjectAlternativeNames", []) + sans_summaries = cert.setdefault("SubjectAlternativeNameSummaries", sans) + + # add missing attributes in ACM certs that cause Terraform to fail + addenda = { + "RenewalEligibility": "INELIGIBLE", + "KeyUsages": [{"Name": "DIGITAL_SIGNATURE"}, {"Name": "KEY_ENCIPHERMENT"}], + "ExtendedKeyUsages": [], + "Options": {"CertificateTransparencyLoggingPreference": "ENABLED"}, + } + addenda["DomainValidationOptions"] = options = cert.get("DomainValidationOptions") + if not options: + options = addenda["DomainValidationOptions"] = [ + {"ValidationMethod": cert.get("ValidationMethod")} + ] + + for option in options: + option["DomainName"] = domain_name = option.get("DomainName") or cert.get("DomainName") + validation_domain = option.get("ValidationDomain") or f"test.{domain_name.lstrip('*.')}" + option["ValidationDomain"] = validation_domain + option["ValidationMethod"] = option.get("ValidationMethod") or "DNS" + status = option.get("ValidationStatus") + option["ValidationStatus"] = ( + "SUCCESS" if (status is None or cert_status == "ISSUED") else status + ) + if option["ValidationMethod"] == "EMAIL": + option["ValidationEmails"] = option.get("ValidationEmails") or [ + f"admin@{self.common_name}" + ] + test_record = { + "Name": validation_domain, + "Type": "CNAME", + "Value": "test123", + } + option["ResourceRecord"] = option.get("ResourceRecord") or test_record + option["ResourceRecord"]["Name"] = option["ResourceRecord"]["Name"].replace(".*.", ".") + + for key, value in addenda.items(): + if not cert.get(key): + cert[key] = value + cert["Serial"] = str(cert.get("Serial") or "") + + if cert.get("KeyAlgorithm") in ["RSA_1024", "RSA_2048"]: + cert["KeyAlgorithm"] = cert["KeyAlgorithm"].replace("RSA_", "RSA-") + + # add subject alternative names + if cert["DomainName"] not in sans: + sans.append(cert["DomainName"]) + if cert["DomainName"] not in sans_summaries: + sans_summaries.append(cert["DomainName"]) + + if "HasAdditionalSubjectAlternativeNames" not in cert: + cert["HasAdditionalSubjectAlternativeNames"] = False + + if not cert.get("ExtendedKeyUsages"): + cert["ExtendedKeyUsages"] = [ + {"Name": "TLS_WEB_SERVER_AUTHENTICATION", "OID": "1.3.6.1.0.1.2.3.0"}, + {"Name": "TLS_WEB_CLIENT_AUTHENTICATION", "OID": "1.3.6.1.0.1.2.3.4"}, + ] + + # remove attributes prior to validation + if not cert.get("Status") == "ISSUED": + attrs = ["CertificateAuthorityArn", "IssuedAt", "NotAfter", "NotBefore", "Serial"] + for attr in attrs: + cert.pop(attr, None) + cert["KeyUsages"] = [] + cert["ExtendedKeyUsages"] = [] + + return result + + +class AcmProvider(AcmApi): + @handler("RequestCertificate", expand=False) + def request_certificate( + self, + context: RequestContext, + request: RequestCertificateRequest, + ) -> RequestCertificateResponse: + response: RequestCertificateResponse = moto.call_moto(context) + + cert_arn = response["CertificateArn"] + backend = acm_models.acm_backends[context.account_id][context.region] + cert = backend._certificates[cert_arn] + if not hasattr(cert, "domain_validation_options"): + cert.domain_validation_options = request.get("DomainValidationOptions") + + return response + + @handler("ListCertificates", expand=False) + def list_certificates( + self, + context: RequestContext, + request: ListCertificatesRequest, + ) -> ListCertificatesResponse: + response = moto.call_moto(context) + summaries = response.get("CertificateSummaryList") or [] + for summary in summaries: + if "KeyUsages" in summary: + summary["KeyUsages"] = [ + k["Name"] if isinstance(k, dict) else k for k in summary["KeyUsages"] + ] + if "ExtendedKeyUsages" in summary: + summary["ExtendedKeyUsages"] = [ + k["Name"] if isinstance(k, dict) else k for k in summary["ExtendedKeyUsages"] + ] + return response diff --git a/localstack/utils/analytics/__init__.py b/localstack-core/localstack/services/apigateway/__init__.py similarity index 100% rename from localstack/utils/analytics/__init__.py rename to localstack-core/localstack/services/apigateway/__init__.py diff --git a/localstack-core/localstack/services/apigateway/analytics.py b/localstack-core/localstack/services/apigateway/analytics.py new file mode 100644 index 0000000000000..d01d93a943f65 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/analytics.py @@ -0,0 +1,5 @@ +from localstack.utils.analytics.metrics import LabeledCounter + +invocation_counter = LabeledCounter( + namespace="apigateway", name="rest_api_execute", labels=["invocation_type"] +) diff --git a/localstack-core/localstack/services/apigateway/exporter.py b/localstack-core/localstack/services/apigateway/exporter.py new file mode 100644 index 0000000000000..0706e794c1651 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/exporter.py @@ -0,0 +1,341 @@ +import abc +import json +from typing import Type + +from apispec import APISpec + +from localstack.aws.api.apigateway import ListOfModel +from localstack.aws.connect import connect_to +from localstack.utils.time import TIMESTAMP_FORMAT_TZ, timestamp + +from .helpers import OpenAPIExt + +# TODO: +# - handle more extensions +# see the list in OpenAPIExt +# currently handled: +# - x-amazon-apigateway-integration +# + + +class _BaseOpenApiExporter(abc.ABC): + VERSION = None + + def __init__(self): + self.export_formats = {"application/json": "to_dict", "application/yaml": "to_yaml"} + + def _add_models(self, spec: APISpec, models: ListOfModel, base_path: str): + for model in models: + model_def = json.loads(model["schema"]) + self._resolve_refs(model_def, base_path) + spec.components.schema( + component_id=model["name"], + component=model_def, + ) + + def _resolve_refs(self, schema: dict, base_path: str): + if "$ref" in schema: + schema["$ref"] = f"{base_path}/{schema['$ref'].rsplit('/', maxsplit=1)[-1]}" + for value in schema.values(): + if isinstance(value, dict): + self._resolve_refs(value, base_path) + + @staticmethod + def _get_integration(method_integration: dict) -> dict: + fields = { + "type", + "passthroughBehavior", + "requestParameters", + "requestTemplates", + "httpMethod", + "uri", + } + integration = {k: v for k, v in method_integration.items() if k in fields} + integration["type"] = integration["type"].lower() + integration["passthroughBehavior"] = integration["passthroughBehavior"].lower() + if responses := method_integration.get("integrationResponses"): + integration["responses"] = {"default": responses.get("200")} + return integration + + @abc.abstractmethod + def export( + self, + api_id: str, + stage: str, + export_format: str, + with_extension: bool, + account_id: str, + region_name: str, + ) -> str | dict: ... + + @abc.abstractmethod + def _add_paths(self, spec: APISpec, resources: dict, with_extension: bool): + """ + This method iterates over the different REST resources and its methods to add the APISpec paths using the + `apispec` module. + The path format is different between Swagger (OpenAPI 2.0) and OpenAPI 3.0 + :param spec: an APISpec object representing the exported API Gateway REST API + :param resources: the API Gateway REST API resources (methods, methods integrations, responses...) + :param with_extension: flag to add the custom OpenAPI extension `apigateway`, allowing to properly import + integrations for example, or authorizers. (all the `x-amazon` fields contained in `OpenAPIExt`). + :return: None + """ + ... + + +class _OpenApiSwaggerExporter(_BaseOpenApiExporter): + VERSION = "2.0" + + def _add_paths(self, spec, resources, with_extension): + for item in resources.get("items"): + path = item.get("path") + for method, method_config in item.get("resourceMethods", {}).items(): + method = method.lower() + + method_integration = method_config.get("methodIntegration", {}) + integration_responses = method_integration.get("integrationResponses", {}) + method_responses = method_config.get("methodResponses") + responses = {} + produces = set() + for status_code, values in method_responses.items(): + response = {"description": f"{status_code} response"} + if response_parameters := values.get("responseParameters"): + headers = {} + for parameter in response_parameters: + in_, name = parameter.removeprefix("method.response.").split(".") + # TODO: other type? + if in_ == "header": + headers[name] = {"type": "string"} + + if headers: + response["headers"] = headers + if response_models := values.get("responseModels"): + for content_type, model_name in response_models.items(): + produces.add(content_type) + response["schema"] = model_name + if integration_response := integration_responses.get(status_code, {}): + produces.update(integration_response.get("responseTemplates", {}).keys()) + + responses[status_code] = response + + request_parameters = method_config.get("requestParameters", {}) + parameters = [] + for parameter, required in request_parameters.items(): + in_, name = parameter.removeprefix("method.request.").split(".") + in_ = in_ if in_ != "querystring" else "query" + parameters.append( + {"name": name, "in": in_, "required": required, "type": "string"} + ) + + request_models = method_config.get("requestModels", {}) + for model_name in request_models.values(): + parameter = { + "in": "body", + "name": model_name, + "required": True, + "schema": {"$ref": f"#/definitions/{model_name}"}, + } + parameters.append(parameter) + + method_operations = {"responses": responses} + if parameters: + method_operations["parameters"] = parameters + if produces: + method_operations["produces"] = list(produces) + if content_types := request_models | method_integration.get("requestTemplates", {}): + method_operations["consumes"] = list(content_types.keys()) + if operation_name := method_config.get("operationName"): + method_operations["operationId"] = operation_name + if with_extension and method_integration: + method_operations[OpenAPIExt.INTEGRATION] = self._get_integration( + method_integration + ) + + spec.path(path=path, operations={method: method_operations}) + + def export( + self, + api_id: str, + stage: str, + export_format: str, + with_extension: bool, + account_id: str, + region_name: str, + ) -> str: + """ + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md + """ + apigateway_client = connect_to( + aws_access_key_id=account_id, region_name=region_name + ).apigateway + + rest_api = apigateway_client.get_rest_api(restApiId=api_id) + resources = apigateway_client.get_resources(restApiId=api_id) + models = apigateway_client.get_models(restApiId=api_id) + + info = {} + if (description := rest_api.get("description")) is not None: + info["description"] = description + + spec = APISpec( + title=rest_api.get("name"), + version=rest_api.get("version") + or timestamp(rest_api.get("createdDate"), format=TIMESTAMP_FORMAT_TZ), + info=info, + openapi_version=self.VERSION, + basePath=f"/{stage}", + schemes=["https"], + ) + + self._add_paths(spec, resources, with_extension) + self._add_models(spec, models["items"], "#/definitions") + + response = getattr(spec, self.export_formats.get(export_format))() + if ( + with_extension + and isinstance(response, dict) + and (binary_media_types := rest_api.get("binaryMediaTypes")) is not None + ): + response[OpenAPIExt.BINARY_MEDIA_TYPES] = binary_media_types + + return response + + +class _OpenApiOAS30Exporter(_BaseOpenApiExporter): + VERSION = "3.0.1" + + def _add_paths(self, spec, resources, with_extension): + for item in resources.get("items"): + path = item.get("path") + for method, method_config in item.get("resourceMethods", {}).items(): + method = method.lower() + + method_integration = method_config.get("methodIntegration", {}) + integration_responses = method_integration.get("integrationResponses", {}) + method_responses = method_config.get("methodResponses") + responses = {} + produces = set() + for status_code, values in method_responses.items(): + response = {"description": f"{status_code} response"} + content = {} + if response_parameters := values.get("responseParameters"): + headers = {} + for parameter in response_parameters: + in_, name = parameter.removeprefix("method.response.").split(".") + # TODO: other type? query? + if in_ == "header": + headers[name] = {"schema": {"type": "string"}} + + if headers: + response["headers"] = headers + if response_models := values.get("responseModels"): + for content_type, model_name in response_models.items(): + content[content_type] = { + "schema": {"$ref": f"#/components/schemas/{model_name}"} + } + if integration_response := integration_responses.get(status_code, {}): + produces.update(integration_response.get("responseTemplates", {}).keys()) + + response["content"] = content + responses[status_code] = response + + request_parameters = method_config.get("requestParameters", {}) + parameters = [] + for parameter, required in request_parameters.items(): + in_, name = parameter.removeprefix("method.request.").split(".") + in_ = in_ if in_ != "querystring" else "query" + parameters.append({"name": name, "in": in_, "schema": {"type": "string"}}) + + request_body = {"content": {}} + request_models = method_config.get("requestModels", {}) + for content_type, model_name in request_models.items(): + request_body["content"][content_type] = { + "schema": {"$ref": f"#/components/schemas/{model_name}"}, + } + request_body["required"] = True + + method_operations = {"responses": responses} + if parameters: + method_operations["parameters"] = parameters + if request_body["content"]: + method_operations["requestBody"] = request_body + if operation_name := method_config.get("operationName"): + method_operations["operationId"] = operation_name + if with_extension and method_integration: + method_operations[OpenAPIExt.INTEGRATION] = self._get_integration( + method_integration + ) + + spec.path(path=path, operations={method: method_operations}) + + def export( + self, + api_id: str, + stage: str, + export_format: str, + with_extension: bool, + account_id: str, + region_name: str, + ) -> str: + """ + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md + """ + apigateway_client = connect_to( + aws_access_key_id=account_id, region_name=region_name + ).apigateway + + rest_api = apigateway_client.get_rest_api(restApiId=api_id) + resources = apigateway_client.get_resources(restApiId=api_id) + models = apigateway_client.get_models(restApiId=api_id) + + info = {} + + if (description := rest_api.get("description")) is not None: + info["description"] = description + + spec = APISpec( + title=rest_api.get("name"), + version=rest_api.get("version") + or timestamp(rest_api.get("createdDate"), format=TIMESTAMP_FORMAT_TZ), + info=info, + openapi_version=self.VERSION, + servers=[{"variables": {"basePath": {"default": stage}}}], + ) + + self._add_paths(spec, resources, with_extension) + self._add_models(spec, models["items"], "#/components/schemas") + + response = getattr(spec, self.export_formats.get(export_format))() + if isinstance(response, dict): + if "components" not in response: + response["components"] = {} + + if ( + with_extension + and (binary_media_types := rest_api.get("binaryMediaTypes")) is not None + ): + response[OpenAPIExt.BINARY_MEDIA_TYPES] = binary_media_types + + return response + + +class OpenApiExporter: + exporters: dict[str, Type[_BaseOpenApiExporter]] + + def __init__(self): + self.exporters = {"swagger": _OpenApiSwaggerExporter, "oas30": _OpenApiOAS30Exporter} + + def export_api( + self, + api_id: str, + stage: str, + export_type: str, + account_id: str, + region_name: str, + export_format: str = "application/json", + with_extension=False, + ) -> str: + exporter = self.exporters.get(export_type)() + return exporter.export( + api_id, stage, export_format, with_extension, account_id, region_name + ) diff --git a/localstack-core/localstack/services/apigateway/helpers.py b/localstack-core/localstack/services/apigateway/helpers.py new file mode 100644 index 0000000000000..8e69a9218e6e2 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/helpers.py @@ -0,0 +1,1010 @@ +import contextlib +import copy +import hashlib +import json +import logging +from typing import List, Optional, TypedDict, Union +from urllib import parse as urlparse + +from jsonpatch import apply_patch +from jsonpointer import JsonPointerException +from moto.apigateway import models as apigw_models +from moto.apigateway.models import APIGatewayBackend, Integration, Resource +from moto.apigateway.models import RestAPI as MotoRestAPI +from moto.apigateway.utils import ApigwAuthorizerIdentifier, ApigwResourceIdentifier + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.api.apigateway import ( + Authorizer, + ConnectionType, + DocumentationPart, + DocumentationPartLocation, + IntegrationType, + Model, + NotFoundException, + PutRestApiRequest, + RequestValidator, +) +from localstack.constants import ( + APPLICATION_JSON, + AWS_REGION_US_EAST_1, + DEFAULT_AWS_ACCOUNT_ID, + PATH_USER_REQUEST, +) +from localstack.services.apigateway.legacy.context import ApiInvocationContext +from localstack.services.apigateway.models import ( + ApiGatewayStore, + RestApiContainer, + apigateway_stores, +) +from localstack.utils import common +from localstack.utils.json import parse_json_or_yaml +from localstack.utils.strings import short_uid, to_bytes, to_str +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +REQUEST_TIME_DATE_FORMAT = "%d/%b/%Y:%H:%M:%S %z" + +INVOKE_TEST_LOG_TEMPLATE = """Execution log for request {request_id} + {formatted_date} : Starting execution for request: {request_id} + {formatted_date} : HTTP Method: {http_method}, Resource Path: {resource_path} + {formatted_date} : Method request path: {request_path} + {formatted_date} : Method request query string: {query_string} + {formatted_date} : Method request headers: {request_headers} + {formatted_date} : Method request body before transformations: {request_body} + {formatted_date} : Method response body after transformations: {response_body} + {formatted_date} : Method response headers: {response_headers} + {formatted_date} : Successfully completed execution + {formatted_date} : Method completed with status: {status_code} + """ + +EMPTY_MODEL = "Empty" +ERROR_MODEL = "Error" + + +# TODO: we could actually parse the schema to get TypedDicts with the proper schema/types for each properties +class OpenAPIExt: + """ + Represents the specific OpenAPI extensions for API Gateway + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions.html + """ + + ANY_METHOD = "x-amazon-apigateway-any-method" + CORS = "x-amazon-apigateway-cors" + API_KEY_SOURCE = "x-amazon-apigateway-api-key-source" + AUTH = "x-amazon-apigateway-auth" + AUTHORIZER = "x-amazon-apigateway-authorizer" + AUTHTYPE = "x-amazon-apigateway-authtype" + BINARY_MEDIA_TYPES = "x-amazon-apigateway-binary-media-types" + DOCUMENTATION = "x-amazon-apigateway-documentation" + ENDPOINT_CONFIGURATION = "x-amazon-apigateway-endpoint-configuration" + GATEWAY_RESPONSES = "x-amazon-apigateway-gateway-responses" + IMPORTEXPORT_VERSION = "x-amazon-apigateway-importexport-version" + INTEGRATION = "x-amazon-apigateway-integration" + INTEGRATIONS = "x-amazon-apigateway-integrations" # used in components + MINIMUM_COMPRESSION_SIZE = "x-amazon-apigateway-minimum-compression-size" + POLICY = "x-amazon-apigateway-policy" + REQUEST_VALIDATOR = "x-amazon-apigateway-request-validator" + REQUEST_VALIDATORS = "x-amazon-apigateway-request-validators" + TAG_VALUE = "x-amazon-apigateway-tag-value" + + +class AuthorizerConfig(TypedDict): + authorizer: Authorizer + authorization_scopes: Optional[list[str]] + + +# TODO: make the CRUD operations in this file generic for the different model types (authorizes, validators, ...) + + +def get_apigateway_store(context: RequestContext) -> ApiGatewayStore: + return apigateway_stores[context.account_id][context.region] + + +def get_apigateway_store_for_invocation(context: ApiInvocationContext) -> ApiGatewayStore: + account_id = context.account_id or DEFAULT_AWS_ACCOUNT_ID + region_name = context.region_name or AWS_REGION_US_EAST_1 + return apigateway_stores[account_id][region_name] + + +def get_moto_backend(account_id: str, region: str) -> APIGatewayBackend: + return apigw_models.apigateway_backends[account_id][region] + + +def get_moto_rest_api(context: RequestContext, rest_api_id: str) -> MotoRestAPI: + moto_backend = apigw_models.apigateway_backends[context.account_id][context.region] + if rest_api := moto_backend.apis.get(rest_api_id): + return rest_api + else: + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + +def get_rest_api_container(context: RequestContext, rest_api_id: str) -> RestApiContainer: + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + return rest_api_container + + +class OpenAPISpecificationResolver: + def __init__(self, document: dict, rest_api_id: str, allow_recursive=True): + self.document = document + self.allow_recursive = allow_recursive + # cache which maps known refs to part of the document + self._cache = {} + self._refpaths = ["#"] + host_definition = localstack_host() + self._base_url = f"{config.get_protocol()}://apigateway.{host_definition.host_and_port()}/restapis/{rest_api_id}/models/" + + def _is_ref(self, item) -> bool: + return isinstance(item, dict) and "$ref" in item + + def _is_internal_ref(self, refpath) -> bool: + return str(refpath).startswith("#/") + + @property + def current_path(self): + return self._refpaths[-1] + + @contextlib.contextmanager + def _pathctx(self, refpath: str): + if not self._is_internal_ref(refpath): + refpath = "/".join((self.current_path, refpath)) + + self._refpaths.append(refpath) + yield + self._refpaths.pop() + + def _resolve_refpath(self, refpath: str) -> dict: + if refpath in self._refpaths and not self.allow_recursive: + raise Exception("recursion detected with allow_recursive=False") + + # We don't resolve the Model definition, we will return a absolute reference to the model like AWS + # When validating the schema, we will need to resolve the $ref there + # Because if we resolved all $ref in schema, it can lead to circular references in complex schemas + if self.current_path.startswith("#/definitions") or self.current_path.startswith( + "#/components/schemas" + ): + return {"$ref": f"{self._base_url}{refpath.rsplit('/', maxsplit=1)[-1]}"} + + # We should not resolve the Model either, because we need its name to set it to the Request/ResponseModels, + # it just makes our job more difficult to retrieve the Model name + # We still need to verify that the ref exists + is_schema = self.current_path.endswith("schema") + + if refpath in self._cache and not is_schema: + return self._cache.get(refpath) + + with self._pathctx(refpath): + if self._is_internal_ref(self.current_path): + cur = self.document + else: + raise NotImplementedError("External references not yet supported.") + + for step in self.current_path.split("/")[1:]: + cur = cur.get(step) + + self._cache[self.current_path] = cur + + if is_schema: + # If the $ref doesn't exist in our schema, return None, otherwise return the ref + return {"$ref": refpath} if cur else None + + return cur + + def _namespaced_resolution(self, namespace: str, data: Union[dict, list]) -> Union[dict, list]: + with self._pathctx(namespace): + return self._resolve_references(data) + + def _resolve_references(self, data) -> Union[dict, list]: + if self._is_ref(data): + return self._resolve_refpath(data["$ref"]) + + if isinstance(data, dict): + for k, v in data.items(): + data[k] = self._namespaced_resolution(k, v) + elif isinstance(data, list): + for i, v in enumerate(data): + data[i] = self._namespaced_resolution(str(i), v) + + return data + + def resolve_references(self) -> dict: + return self._resolve_references(self.document) + + +class ModelResolver: + """ + This class allows a Model to use recursive and circular references to other Models. + To be able to JSON dump Models, AWS will not resolve Models but will use their absolute $ref instead. + When validating, we need to resolve those references, using JSON schema tricks to allow recursion. + See: https://json-schema.org/understanding-json-schema/structuring.html#recursion + + To allow a simpler structure, we're not replacing directly the reference with the schema, but instead create + a map of all used schema in $defs, as advised on JSON schema: + See: https://json-schema.org/understanding-json-schema/structuring.html#defs + + This allows us to not render every sub schema/models, but instead keep a clean map of used schemas. + """ + + def __init__(self, rest_api_container: RestApiContainer, model_name: str): + self.rest_api_container = rest_api_container + self.model_name = model_name + self._deps = {} + self._current_resolving_name = None + + @contextlib.contextmanager + def _resolving_ctx(self, current_resolving_name: str): + self._current_resolving_name = current_resolving_name + yield + self._current_resolving_name = None + + def resolve_model(self, model: dict) -> dict | None: + resolved_model = copy.deepcopy(model) + model_names = set() + + def _look_for_ref(sub_model): + for key, value in sub_model.items(): + if key == "$ref": + ref_name = value.rsplit("/", maxsplit=1)[-1] + if ref_name == self.model_name: + # if we reference our main Model, use the # for recursive access + sub_model[key] = "#" + continue + # otherwise, this Model will be available in $defs + sub_model[key] = f"#/$defs/{ref_name}" + + if ref_name != self._current_resolving_name: + # add the ref to the next ref to resolve and to $deps + model_names.add(ref_name) + + elif isinstance(value, dict): + _look_for_ref(value) + elif isinstance(value, list): + for val in value: + if isinstance(val, dict): + _look_for_ref(val) + + if isinstance(resolved_model, dict): + _look_for_ref(resolved_model) + + if model_names: + for ref_model_name in model_names: + if ref_model_name in self._deps: + continue + + def_resolved, was_resolved = self._get_resolved_submodel(model_name=ref_model_name) + + if not def_resolved: + LOG.debug( + "Failed to resolve submodel %s for model %s", + ref_model_name, + self._current_resolving_name, + ) + return + # if the ref was already resolved, we copy the result to not alter the already resolved schema + if was_resolved: + def_resolved = copy.deepcopy(def_resolved) + + self._remove_self_ref(def_resolved) + + if "$deps" in def_resolved: + # this will happen only if the schema was already resolved, otherwise the deps would be in _deps + # remove own definition in case of recursive / circular Models + def_resolved["$defs"].pop(self.model_name, None) + # remove the $defs from the schema, we don't want nested $defs + def_resolved_defs = def_resolved.pop("$defs") + # merge the resolved sub model $defs to the main schema + self._deps.update(def_resolved_defs) + + # add the dependencies to the global $deps + self._deps[ref_model_name] = def_resolved + + return resolved_model + + def _remove_self_ref(self, resolved_schema: dict): + for key, value in resolved_schema.items(): + if key == "$ref": + ref_name = value.rsplit("/", maxsplit=1)[-1] + if ref_name == self.model_name: + resolved_schema[key] = "#" + + elif isinstance(value, dict): + self._remove_self_ref(value) + + def get_resolved_model(self) -> dict | None: + if not (resolved_model := self.rest_api_container.resolved_models.get(self.model_name)): + model = self.rest_api_container.models.get(self.model_name) + if not model: + return None + schema = json.loads(model["schema"]) + resolved_model = self.resolve_model(schema) + if not resolved_model: + return None + # attach the resolved dependencies of the schema + if self._deps: + resolved_model["$defs"] = self._deps + self.rest_api_container.resolved_models[self.model_name] = resolved_model + + return resolved_model + + def _get_resolved_submodel(self, model_name: str) -> tuple[dict | None, bool | None]: + was_resolved = True + if not (resolved_model := self.rest_api_container.resolved_models.get(model_name)): + was_resolved = False + model = self.rest_api_container.models.get(model_name) + if not model: + LOG.warning( + "Error while validating the request body, could not the find the Model: '%s'", + model_name, + ) + return None, was_resolved + schema = json.loads(model["schema"]) + + with self._resolving_ctx(model_name): + resolved_model = self.resolve_model(schema) + + return resolved_model, was_resolved + + +def resolve_references(data: dict, rest_api_id, allow_recursive=True) -> dict: + resolver = OpenAPISpecificationResolver( + data, allow_recursive=allow_recursive, rest_api_id=rest_api_id + ) + return resolver.resolve_references() + + +# --------------- +# UTIL FUNCTIONS +# --------------- + + +def path_based_url(api_id: str, stage_name: str, path: str) -> str: + """Return URL for inbound API gateway for given API ID, stage name, and path""" + pattern = "%s/restapis/{api_id}/{stage_name}/%s{path}" % ( + config.external_service_url(), + PATH_USER_REQUEST, + ) + return pattern.format(api_id=api_id, stage_name=stage_name, path=path) + + +def localstack_path_based_url(api_id: str, stage_name: str, path: str) -> str: + """Return URL for inbound API gateway for given API ID, stage name, and path on the _aws namespace""" + return f"{config.external_service_url()}/_aws/execute-api/{api_id}/{stage_name}{path}" + + +def host_based_url(rest_api_id: str, path: str, stage_name: str = None): + """Return URL for inbound API gateway for given API ID, stage name, and path with custom dns + format""" + pattern = "{endpoint}{stage}{path}" + stage = stage_name and f"/{stage_name}" or "" + return pattern.format(endpoint=get_execute_api_endpoint(rest_api_id), stage=stage, path=path) + + +def get_execute_api_endpoint(api_id: str, protocol: str | None = None) -> str: + host = localstack_host() + protocol = protocol or config.get_protocol() + return f"{protocol}://{api_id}.execute-api.{host.host_and_port()}" + + +def apply_json_patch_safe(subject, patch_operations, in_place=True, return_list=False): + """Apply JSONPatch operations, using some customizations for compatibility with API GW + resources.""" + + results = [] + patch_operations = ( + [patch_operations] if isinstance(patch_operations, dict) else patch_operations + ) + for operation in patch_operations: + try: + # special case: for "replace" operations, assume "" as the default value + if operation["op"] == "replace" and operation.get("value") is None: + operation["value"] = "" + + if operation["op"] != "remove" and operation.get("value") is None: + LOG.info('Missing "value" in JSONPatch operation for %s: %s', subject, operation) + continue + + if operation["op"] == "add": + path = operation["path"] + target = subject.get(path.strip("/")) + target = target or common.extract_from_jsonpointer_path(subject, path) + if not isinstance(target, list): + # for `add` operation, if the target does not exist, set it to an empty dict (default behaviour) + # previous behaviour was an empty list. Revisit this if issues arise. + # TODO: we are assigning a value, even if not `in_place=True` + common.assign_to_path(subject, path, value={}, delimiter="/") + + target = common.extract_from_jsonpointer_path(subject, path) + if isinstance(target, list) and not path.endswith("/-"): + # if "path" is an attribute name pointing to an array in "subject", and we're running + # an "add" operation, then we should use the standard-compliant notation "/path/-" + operation["path"] = f"{path}/-" + + if operation["op"] == "remove": + path = operation["path"] + common.assign_to_path(subject, path, value={}, delimiter="/") + + result = apply_patch(subject, [operation], in_place=in_place) + if not in_place: + subject = result + results.append(result) + except JsonPointerException: + pass # path cannot be found - ignore + except Exception as e: + if "non-existent object" in str(e): + if operation["op"] == "replace": + # fall back to an ADD operation if the REPLACE fails + operation["op"] = "add" + result = apply_patch(subject, [operation], in_place=in_place) + results.append(result) + continue + if operation["op"] == "remove" and isinstance(subject, dict): + result = subject.pop(operation["path"], None) + results.append(result) + continue + raise + if return_list: + return results + return (results or [subject])[-1] + + +def add_documentation_parts(rest_api_container, documentation): + for doc_part in documentation.get("documentationParts", []): + entity_id = short_uid()[:6] + location = doc_part["location"] + rest_api_container.documentation_parts[entity_id] = DocumentationPart( + id=entity_id, + location=DocumentationPartLocation( + type=location.get("type"), + path=location.get("path", "/") + if location.get("type") not in ["API", "MODEL"] + else None, + method=location.get("method"), + statusCode=location.get("statusCode"), + name=location.get("name"), + ), + properties=doc_part["properties"], + ) + + +def import_api_from_openapi_spec( + rest_api: MotoRestAPI, context: RequestContext, request: PutRestApiRequest +) -> tuple[MotoRestAPI, list[str]]: + """Import an API from an OpenAPI spec document""" + body = parse_json_or_yaml(to_str(request["body"].read())) + + warnings = [] + + # TODO There is an issue with the botocore specs so the parameters doesn't get populated as it should + # Once this is fixed we can uncomment the code below instead of taking the parameters the context request + # query_params = request.get("parameters") or {} + query_params: dict = context.request.values.to_dict() + + resolved_schema = resolve_references(copy.deepcopy(body), rest_api_id=rest_api.id) + account_id = context.account_id + region_name = context.region + + # TODO: + # 1. validate the "mode" property of the spec document, "merge" or "overwrite", and properly apply it + # for now, it only considers it for the binaryMediaTypes + # 2. validate the document type, "swagger" or "openapi" + mode = request.get("mode", "merge") + + rest_api.version = ( + str(version) if (version := resolved_schema.get("info", {}).get("version")) else None + ) + # XXX for some reason this makes cf tests fail that's why is commented. + # test_cfn_handle_serverless_api_resource + # rest_api.name = resolved_schema.get("info", {}).get("title") + rest_api.description = resolved_schema.get("info", {}).get("description") + + # authorizers map to avoid duplication + authorizers = {} + + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis[rest_api.id] + + def is_api_key_required(path_payload: dict) -> bool: + # TODO: consolidate and refactor with `create_authorizer`, duplicate logic for now + if not (security_schemes := path_payload.get("security")): + return False + + for security_scheme in security_schemes: + for security_scheme_name in security_scheme.keys(): + # $.securityDefinitions is Swagger 2.0 + # $.components.SecuritySchemes is OpenAPI 3.0 + security_definitions = resolved_schema.get( + "securityDefinitions" + ) or resolved_schema.get("components", {}).get("securitySchemes", {}) + if security_scheme_name in security_definitions: + security_config = security_definitions.get(security_scheme_name) + if ( + OpenAPIExt.AUTHORIZER not in security_config + and security_config.get("type") == "apiKey" + and security_config.get("name", "").lower() == "x-api-key" + ): + return True + return False + + def create_authorizers(security_schemes: dict) -> None: + for security_scheme_name, security_config in security_schemes.items(): + aws_apigateway_authorizer = security_config.get(OpenAPIExt.AUTHORIZER, {}) + if not aws_apigateway_authorizer: + continue + + if security_scheme_name in authorizers: + continue + + authorizer_type = aws_apigateway_authorizer.get("type", "").upper() + # TODO: do we need validation of resources here? + authorizer = Authorizer( + id=ApigwAuthorizerIdentifier( + account_id, region_name, security_scheme_name + ).generate(), + name=security_scheme_name, + type=authorizer_type, + authorizerResultTtlInSeconds=aws_apigateway_authorizer.get( + "authorizerResultTtlInSeconds", None + ), + ) + if provider_arns := aws_apigateway_authorizer.get("providerARNs"): + authorizer["providerARNs"] = provider_arns + if auth_type := security_config.get(OpenAPIExt.AUTHTYPE): + authorizer["authType"] = auth_type + if authorizer_uri := aws_apigateway_authorizer.get("authorizerUri"): + authorizer["authorizerUri"] = authorizer_uri + if authorizer_credentials := aws_apigateway_authorizer.get("authorizerCredentials"): + authorizer["authorizerCredentials"] = authorizer_credentials + if authorizer_type in ("TOKEN", "COGNITO_USER_POOLS"): + header_name = security_config.get("name") + authorizer["identitySource"] = f"method.request.header.{header_name}" + elif identity_source := aws_apigateway_authorizer.get("identitySource"): + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-authorizer.html + # Applicable for the authorizer of the request and jwt type only + authorizer["identitySource"] = identity_source + if identity_validation_expression := aws_apigateway_authorizer.get( + "identityValidationExpression" + ): + authorizer["identityValidationExpression"] = identity_validation_expression + + rest_api_container.authorizers[authorizer["id"]] = authorizer + + authorizers[security_scheme_name] = authorizer + + def get_authorizer(path_payload: dict) -> Optional[AuthorizerConfig]: + if not (security_schemes := path_payload.get("security")): + return None + + for security_scheme in security_schemes: + for security_scheme_name, scopes in security_scheme.items(): + if authorizer := authorizers.get(security_scheme_name): + return AuthorizerConfig(authorizer=authorizer, authorization_scopes=scopes) + + def get_or_create_path(abs_path: str, base_path: str): + parts = abs_path.rstrip("/").replace("//", "/").split("/") + parent_id = "" + if len(parts) > 1: + parent_path = "/".join(parts[:-1]) + parent = get_or_create_path(parent_path, base_path=base_path) + parent_id = parent.id + if existing := [ + r + for r in rest_api.resources.values() + if r.path_part == (parts[-1] or "/") and (r.parent_id or "") == (parent_id or "") + ]: + return existing[0] + + # construct relative path (without base path), then add field resources for this path + rel_path = abs_path.removeprefix(base_path) + return add_path_methods(rel_path, parts, parent_id=parent_id) + + def add_path_methods(rel_path: str, parts: List[str], parent_id=""): + rel_path = rel_path or "/" + child_id = ApigwResourceIdentifier(account_id, region_name, parent_id, rel_path).generate() + + # Create a `Resource` for the passed `rel_path` + resource = Resource( + account_id=rest_api.account_id, + resource_id=child_id, + region_name=rest_api.region_name, + api_id=rest_api.id, + path_part=parts[-1] or "/", + parent_id=parent_id, + ) + + paths_dict = resolved_schema["paths"] + method_paths = paths_dict.get(rel_path, {}) + # Iterate over each field of the `path` to try to find the methods defined + for field, field_schema in method_paths.items(): + if field in [ + "parameters", + "servers", + "description", + "summary", + "$ref", + ] or not isinstance(field_schema, dict): + LOG.warning("Ignoring unsupported field %s in path %s", field, rel_path) + # TODO: check if we should skip parameters, those are global parameters applied to every routes but + # can be overridden at the operation level + continue + + method_name = field.upper() + if method_name == OpenAPIExt.ANY_METHOD.upper(): + method_name = "ANY" + + # Create the `Method` resource for each method path + method_resource = create_method_resource(resource, method_name, field_schema) + + # Get the `Method` requestParameters and requestModels + request_parameters_schema = field_schema.get("parameters", []) + request_parameters = {} + request_models = {} + if request_parameters_schema: + for req_param_data in request_parameters_schema: + # For Swagger 2.0, possible values for `in` from the specs are "query", "header", "path", + # "formData" or "body". + # For OpenAPI 3.0, values are "query", "header", "path" or "cookie". + # Only "path", "header" and "query" are supported in API Gateway for requestParameters + # "body" is mapped to a requestModel + param_location = req_param_data.get("in") + param_name = req_param_data.get("name") + param_required = req_param_data.get("required", False) + if param_location in ("query", "header", "path"): + if param_location == "query": + param_location = "querystring" + + request_parameters[f"method.request.{param_location}.{param_name}"] = ( + param_required + ) + + elif param_location == "body": + request_models = {APPLICATION_JSON: param_name} + + else: + LOG.warning( + "Ignoring unsupported requestParameters/requestModels location value for %s: %s", + param_name, + param_location, + ) + continue + + # this replaces 'body' in Parameters for OpenAPI 3.0, a requestBody Object + # https://swagger.io/specification/v3/#request-body-object + if request_models_schema := field_schema.get("requestBody"): + model_ref = None + for content_type, media_type in request_models_schema.get("content", {}).items(): + # we're iterating over the Media Type object: + # https://swagger.io/specification/v3/#media-type-object + if content_type == APPLICATION_JSON: + model_ref = media_type.get("schema", {}).get("$ref") + continue + LOG.warning( + "Found '%s' content-type for the MethodResponse model for path '%s' and method '%s', not adding the model as currently not supported", + content_type, + rel_path, + method_name, + ) + if model_ref: + model_schema = model_ref.rsplit("/", maxsplit=1)[-1] + request_models = {APPLICATION_JSON: model_schema} + + method_resource.request_models = request_models or None + + # check if there's a request validator set in the method + request_validator_name = field_schema.get( + OpenAPIExt.REQUEST_VALIDATOR, default_req_validator_name + ) + if request_validator_name: + if not ( + req_validator_id := request_validator_name_id_map.get(request_validator_name) + ): + # Might raise an exception here if we properly validate the template + LOG.warning( + "A validator ('%s') was referenced for %s.(%s), but is not defined", + request_validator_name, + rel_path, + method_name, + ) + method_resource.request_validator_id = req_validator_id + + # we check if there's a path parameter, AWS adds the requestParameter automatically + resource_path_part = parts[-1].strip("/") + if is_variable_path(resource_path_part) and not is_greedy_path(resource_path_part): + path_parameter = resource_path_part[1:-1] # remove the curly braces + request_parameters[f"method.request.path.{path_parameter}"] = True + + method_resource.request_parameters = request_parameters or None + + # Create the `MethodResponse` for the previously created `Method` + method_responses = field_schema.get("responses", {}) + for method_status_code, method_response in method_responses.items(): + method_status_code = str(method_status_code) + method_response_model = None + model_ref = None + # separating the two different versions, Swagger (2.0) and OpenAPI 3.0 + if "schema" in method_response: # this is Swagger + model_ref = method_response["schema"].get("$ref") + elif "content" in method_response: # this is OpenAPI 3.0 + for content_type, media_type in method_response["content"].items(): + # we're iterating over the Media Type object: + # https://swagger.io/specification/v3/#media-type-object + if content_type == APPLICATION_JSON: + model_ref = media_type.get("schema", {}).get("$ref") + continue + LOG.warning( + "Found '%s' content-type for the MethodResponse model for path '%s' and method '%s', not adding the model as currently not supported", + content_type, + rel_path, + method_name, + ) + + if model_ref: + model_schema = model_ref.rsplit("/", maxsplit=1)[-1] + + method_response_model = {APPLICATION_JSON: model_schema} + + method_response_parameters = {} + if response_param_headers := method_response.get("headers"): + for header, header_info in response_param_headers.items(): + # TODO: make use of `header_info` + method_response_parameters[f"method.response.header.{header}"] = False + + method_resource.create_response( + method_status_code, + method_response_model, + method_response_parameters or None, + ) + + # Create the `Integration` for the previously created `Method` + method_integration = field_schema.get(OpenAPIExt.INTEGRATION, {}) + + integration_type = ( + i_type.upper() if (i_type := method_integration.get("type")) else None + ) + + match integration_type: + case "AWS_PROXY": + # if the integration is AWS_PROXY with lambda, the only accepted integration method is POST + integration_method = "POST" + case _: + integration_method = ( + method_integration.get("httpMethod") or method_name + ).upper() + + connection_type = ( + ConnectionType.INTERNET + if integration_type in (IntegrationType.HTTP, IntegrationType.HTTP_PROXY) + else None + ) + + if integration_request_parameters := method_integration.get("requestParameters"): + validated_parameters = {} + for k, v in integration_request_parameters.items(): + if isinstance(v, str): + validated_parameters[k] = v + else: + # TODO This fixes for boolean serialization. We should validate how other types behave + value = str(v).lower() + warnings.append( + "Invalid format for 'requestParameters'. Expected type string for property " + f"'{k}' of resource '{resource.get_path()}' and method '{method_name}' but got '{value}'" + ) + + integration_request_parameters = validated_parameters + + integration = Integration( + http_method=integration_method, + uri=method_integration.get("uri"), + integration_type=integration_type, + passthrough_behavior=method_integration.get( + "passthroughBehavior", "WHEN_NO_MATCH" + ).upper(), + request_templates=method_integration.get("requestTemplates"), + request_parameters=integration_request_parameters, + cache_namespace=resource.id, + timeout_in_millis=method_integration.get("timeoutInMillis") or "29000", + content_handling=method_integration.get("contentHandling"), + connection_type=connection_type, + ) + + # Create the `IntegrationResponse` for the previously created `Integration` + if method_integration_responses := method_integration.get("responses"): + for pattern, integration_responses in method_integration_responses.items(): + integration_response_templates = integration_responses.get("responseTemplates") + integration_response_parameters = integration_responses.get( + "responseParameters" + ) + + integration_response = integration.create_integration_response( + status_code=str(integration_responses.get("statusCode", 200)), + selection_pattern=pattern if pattern != "default" else None, + response_templates=integration_response_templates, + response_parameters=integration_response_parameters, + content_handling=None, + ) + # moto set the responseTemplates to an empty dict when it should be None if not defined + if integration_response_templates is None: + integration_response.response_templates = None + + resource.resource_methods[method_name].method_integration = integration + + rest_api.resources[child_id] = resource + rest_api_container.resource_children.setdefault(parent_id, []).append(child_id) + return resource + + def create_method_resource(child, method, method_schema): + authorization_type = "NONE" + api_key_required = is_api_key_required(method_schema) + kwargs = {} + + if authorizer := get_authorizer(method_schema) or default_authorizer: + method_authorizer = authorizer["authorizer"] + # override the authorizer_type if it's a TOKEN or REQUEST to CUSTOM + if (authorizer_type := method_authorizer["type"]) in ("TOKEN", "REQUEST"): + authorization_type = "CUSTOM" + else: + authorization_type = authorizer_type + + kwargs["authorizer_id"] = method_authorizer["id"] + + if authorization_scopes := authorizer.get("authorization_scopes"): + kwargs["authorization_scopes"] = authorization_scopes + + return child.add_method( + method, + api_key_required=api_key_required, + authorization_type=authorization_type, + operation_name=method_schema.get("operationId"), + **kwargs, + ) + + models = resolved_schema.get("definitions") or resolved_schema.get("components", {}).get( + "schemas", {} + ) + for name, model_data in models.items(): + model_id = short_uid()[:6] # length 6 to make TF tests pass + model = Model( + id=model_id, + name=name, + contentType=APPLICATION_JSON, + description=model_data.get("description"), + schema=json.dumps(model_data), + ) + store.rest_apis[rest_api.id].models[name] = model + + # create the RequestValidators defined at the top-level field `x-amazon-apigateway-request-validators` + request_validators = resolved_schema.get(OpenAPIExt.REQUEST_VALIDATORS, {}) + request_validator_name_id_map = {} + for validator_name, validator_schema in request_validators.items(): + validator_id = short_uid()[:6] + + validator = RequestValidator( + id=validator_id, + name=validator_name, + validateRequestBody=validator_schema.get("validateRequestBody") or False, + validateRequestParameters=validator_schema.get("validateRequestParameters") or False, + ) + + store.rest_apis[rest_api.id].validators[validator_id] = validator + request_validator_name_id_map[validator_name] = validator_id + + # get default requestValidator if present + default_req_validator_name = resolved_schema.get(OpenAPIExt.REQUEST_VALIDATOR) + + # $.securityDefinitions is Swagger 2.0 + # $.components.SecuritySchemes is OpenAPI 3.0 + security_data = resolved_schema.get("securityDefinitions") or resolved_schema.get( + "components", {} + ).get("securitySchemes", {}) + # create the defined authorizers, even if they're not used by any routes + if security_data: + create_authorizers(security_data) + + # create default authorizer if present + default_authorizer = get_authorizer(resolved_schema) + + # determine base path + # default basepath mode is "ignore" + # see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-import-api-basePath.html + basepath_mode = query_params.get("basepath") or "ignore" + base_path = "" + + if basepath_mode != "ignore": + # in Swagger 2.0, the basePath is a top-level property + if "basePath" in resolved_schema: + base_path = resolved_schema["basePath"] + + # in OpenAPI 3.0, the basePath is contained in the server object + elif "servers" in resolved_schema: + servers_property = resolved_schema.get("servers", []) + for server in servers_property: + # first, we check if there are a basePath variable (1st choice) + if "basePath" in server.get("variables", {}): + base_path = server["variables"]["basePath"].get("default", "") + break + # TODO: this allows both absolute and relative part, but AWS might not manage relative + url_path = urlparse.urlparse(server.get("url", "")).path + if url_path: + base_path = url_path if url_path != "/" else "" + break + + if basepath_mode == "split": + base_path = base_path.strip("/").partition("/")[-1] + base_path = f"/{base_path}" if base_path else "" + + api_paths = resolved_schema.get("paths", {}) + if api_paths: + # Remove default root, then add paths from API spec + # TODO: the default mode is now `merge`, not `overwrite` if using `PutRestApi` + # TODO: quick hack for now, but do not remove the rootResource if the OpenAPI file is empty + rest_api.resources = {} + + for path in api_paths: + get_or_create_path(base_path + path, base_path=base_path) + + # binary types + if mode == "merge": + existing_binary_media_types = rest_api.binaryMediaTypes or [] + else: + existing_binary_media_types = [] + + rest_api.binaryMediaTypes = existing_binary_media_types + resolved_schema.get( + OpenAPIExt.BINARY_MEDIA_TYPES, [] + ) + + policy = resolved_schema.get(OpenAPIExt.POLICY) + if policy: + policy = json.dumps(policy) if isinstance(policy, dict) else str(policy) + rest_api.policy = policy + minimum_compression_size = resolved_schema.get(OpenAPIExt.MINIMUM_COMPRESSION_SIZE) + if minimum_compression_size is not None: + rest_api.minimum_compression_size = int(minimum_compression_size) + endpoint_config = resolved_schema.get(OpenAPIExt.ENDPOINT_CONFIGURATION) + if endpoint_config: + if endpoint_config.get("vpcEndpointIds"): + endpoint_config.setdefault("types", ["PRIVATE"]) + rest_api.endpoint_configuration = endpoint_config + + api_key_source = resolved_schema.get(OpenAPIExt.API_KEY_SOURCE) + if api_key_source is not None: + rest_api.api_key_source = api_key_source.upper() + + documentation = resolved_schema.get(OpenAPIExt.DOCUMENTATION) + if documentation: + add_documentation_parts(rest_api_container, documentation) + + return rest_api, warnings + + +def is_greedy_path(path_part: str) -> bool: + return path_part.startswith("{") and path_part.endswith("+}") + + +def is_variable_path(path_part: str) -> bool: + return path_part.startswith("{") and path_part.endswith("}") + + +def get_domain_name_hash(domain_name: str) -> str: + """ + Return a hash of the given domain name, which help construct regional domain names for APIs. + TODO: use this in the future to dispatch API Gateway API invocations made to the regional domain name + """ + return hashlib.shake_128(to_bytes(domain_name)).hexdigest(4) + + +def get_regional_domain_name(domain_name: str) -> str: + """ + Return the regional domain name for the given domain name. + In real AWS, this would look something like: "d-oplm2qchq0.execute-api.us-east-1.amazonaws.com" + In LocalStack, we're returning this format: "d-.execute-api.localhost.localstack.cloud" + """ + domain_name_hash = get_domain_name_hash(domain_name) + host = localstack_host().host + return f"d-{domain_name_hash}.execute-api.{host}" diff --git a/localstack/utils/aws/__init__.py b/localstack-core/localstack/services/apigateway/legacy/__init__.py similarity index 100% rename from localstack/utils/aws/__init__.py rename to localstack-core/localstack/services/apigateway/legacy/__init__.py diff --git a/localstack-core/localstack/services/apigateway/legacy/context.py b/localstack-core/localstack/services/apigateway/legacy/context.py new file mode 100644 index 0000000000000..37b9725f3feb8 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/legacy/context.py @@ -0,0 +1,201 @@ +import base64 +import json +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from responses import Response + +from localstack.constants import HEADER_LOCALSTACK_EDGE_URL +from localstack.utils.aws.aws_responses import parse_query_string +from localstack.utils.strings import short_uid, to_str + +# type definition for data parameters (i.e., invocation payloads) +InvocationPayload = Union[Dict, str, bytes] + + +class ApiGatewayVersion(Enum): + V1 = "v1" + V2 = "v2" + + +class ApiInvocationContext: + """Represents the context for an incoming API Gateway invocation.""" + + # basic (raw) HTTP invocation details (method, path, data, headers) + method: str + path: str + data: InvocationPayload + headers: Dict[str, str] + + # raw URI (including query string) retired from werkzeug "RAW_URI" environment variable + raw_uri: str + + # invocation context + context: Dict[str, Any] + # authentication info for this invocation + auth_context: Dict[str, Any] + + # target API/resource details extracted from the invocation + apigw_version: ApiGatewayVersion + api_id: str + stage: str + account_id: str + region_name: str + # resource path, including any path parameter placeholders (e.g., "/my/path/{id}") + resource_path: str + integration: Dict + resource: Dict + # Invocation path with query string, e.g., "/my/path?test". Defaults to "path", can be used + # to overwrite the actual API path, in case the path format "../_user_request_/.." is used. + _path_with_query_string: str + + # response templates to be applied to the invocation result + response_templates: Dict + + route: Dict + connection_id: str + path_params: Dict + + # response object + response: Response + + # dict of stage variables (mapping names to values) + stage_variables: Dict[str, str] + + # websockets route selection + ws_route: str + + def __init__( + self, + method: str, + path: str, + data: Union[str, bytes], + headers: Dict[str, str], + api_id: str = None, + stage: str = None, + context: Dict[str, Any] = None, + auth_context: Dict[str, Any] = None, + ): + self.method = method + self._path = path + self.data = data + self.headers = headers + self.context = {"requestId": short_uid()} if context is None else context + self.auth_context = {} if auth_context is None else auth_context + self.apigw_version = None + self.api_id = api_id + self.stage = stage + self.region_name = None + self.account_id = None + self.integration = None + self.resource = None + self.resource_path = None + self.path_with_query_string = None + self.response_templates = {} + self.stage_variables = {} + self.path_params = {} + self.route = None + self.ws_route = None + self.response = None + + @property + def path(self) -> str: + return self._path + + @path.setter + def path(self, new_path: str): + if isinstance(new_path, str): + new_path = "/" + new_path.lstrip("/") + self._path = new_path + + @property + def resource_id(self) -> Optional[str]: + return (self.resource or {}).get("id") + + @property + def invocation_path(self) -> str: + """Return the plain invocation path, without query parameters.""" + path = self.path_with_query_string or self.path + return path.split("?")[0] + + @property + def path_with_query_string(self) -> str: + """Return invocation path with query string - defaults to the value of 'path', unless customized.""" + return self._path_with_query_string or self.path + + @path_with_query_string.setter + def path_with_query_string(self, new_path: str): + """Set a custom invocation path with query string (used to handle "../_user_request_/.." paths).""" + if isinstance(new_path, str): + new_path = "/" + new_path.lstrip("/") + self._path_with_query_string = new_path + + def query_params(self) -> Dict[str, str]: + """Extract the query parameters from the target URL or path in this request context.""" + query_string = self.path_with_query_string.partition("?")[2] + return parse_query_string(query_string) + + @property + def integration_uri(self) -> Optional[str]: + integration = self.integration or {} + return integration.get("uri") or integration.get("integrationUri") + + @property + def auth_identity(self) -> Optional[Dict]: + if isinstance(self.auth_context, dict): + if self.auth_context.get("identity") is None: + self.auth_context["identity"] = {} + return self.auth_context["identity"] + + @property + def authorizer_type(self) -> str: + if isinstance(self.auth_context, dict): + return self.auth_context.get("authorizer_type") if self.auth_context else None + + @property + def authorizer_result(self) -> Dict[str, Any]: + if isinstance(self.auth_context, dict): + return self.auth_context.get("authorizer") if self.auth_context else {} + + def is_websocket_request(self) -> bool: + upgrade_header = str(self.headers.get("upgrade") or "") + return upgrade_header.lower() == "websocket" + + def is_v1(self) -> bool: + """Whether this is an API Gateway v1 request""" + return self.apigw_version == ApiGatewayVersion.V1 + + def cookies(self) -> Optional[List[str]]: + if cookies := self.headers.get("cookie") or "": + return list(cookies.split(";")) + return None + + @property + def is_data_base64_encoded(self) -> bool: + try: + json.dumps(self.data) if isinstance(self.data, (dict, list)) else to_str(self.data) + return False + except UnicodeDecodeError: + return True + + def data_as_string(self) -> str: + try: + return ( + json.dumps(self.data) if isinstance(self.data, (dict, list)) else to_str(self.data) + ) + except UnicodeDecodeError: + # we string encode our base64 as string as well + return to_str(base64.b64encode(self.data)) + + def _extract_host_from_header(self) -> str: + host = self.headers.get(HEADER_LOCALSTACK_EDGE_URL) or self.headers.get("host", "") + return host.split("://")[-1].split("/")[0].split(":")[0] + + @property + def domain_name(self) -> str: + return self._extract_host_from_header() + + @property + def domain_prefix(self) -> str: + host = self._extract_host_from_header() + return host.split(".")[0] diff --git a/localstack-core/localstack/services/apigateway/legacy/helpers.py b/localstack-core/localstack/services/apigateway/legacy/helpers.py new file mode 100644 index 0000000000000..62a91a32e78b0 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/legacy/helpers.py @@ -0,0 +1,711 @@ +import json +import logging +import re +import time +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union +from urllib import parse as urlparse + +from botocore.utils import InvalidArnException +from moto.apigateway.models import apigateway_backends +from requests.models import Response + +from localstack.aws.connect import connect_to +from localstack.constants import ( + APPLICATION_JSON, + DEFAULT_AWS_ACCOUNT_ID, + HEADER_LOCALSTACK_EDGE_URL, + PATH_USER_REQUEST, +) +from localstack.services.apigateway.helpers import REQUEST_TIME_DATE_FORMAT +from localstack.services.apigateway.legacy.context import ApiInvocationContext +from localstack.utils import common +from localstack.utils.aws import resources as resource_utils +from localstack.utils.aws.arns import get_partition, parse_arn +from localstack.utils.aws.aws_responses import requests_error_response_json, requests_response +from localstack.utils.json import try_json +from localstack.utils.numbers import is_number +from localstack.utils.strings import canonicalize_bool_to_str, long_uid, to_str + +LOG = logging.getLogger(__name__) + +# regex path patterns +PATH_REGEX_MAIN = r"^/restapis/([A-Za-z0-9_\-]+)/[a-z]+(\?.*)?" +PATH_REGEX_SUB = r"^/restapis/([A-Za-z0-9_\-]+)/[a-z]+/([A-Za-z0-9_\-]+)/.*" +PATH_REGEX_TEST_INVOKE_API = r"^\/restapis\/([A-Za-z0-9_\-]+)\/resources\/([A-Za-z0-9_\-]+)\/methods\/([A-Za-z0-9_\-]+)/?(\?.*)?" + +# regex path pattern for user requests, handles stages like $default +PATH_REGEX_USER_REQUEST = ( + r"^/restapis/([A-Za-z0-9_\\-]+)(?:/([A-Za-z0-9\_($|%%24)\\-]+))?/%s/(.*)$" % PATH_USER_REQUEST +) +# URL pattern for invocations +HOST_REGEX_EXECUTE_API = r"(?:.*://)?([a-zA-Z0-9]+)(?:(-vpce-[^.]+))?\.execute-api\.(.*)" + +# template for SQS inbound data +APIGATEWAY_SQS_DATA_INBOUND_TEMPLATE = ( + "Action=SendMessage&MessageBody=$util.base64Encode($input.json('$'))" +) + + +class ApiGatewayIntegrationError(Exception): + """ + Base class for all ApiGateway Integration errors. + Can be used as is or extended for common error types. + These exceptions should be handled in one place, and bubble up from all others. + """ + + message: str + status_code: int + + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.message = message + self.status_code = status_code + + def to_response(self): + return requests_response({"message": self.message}, status_code=self.status_code) + + +class IntegrationParameters(TypedDict): + path: dict[str, str] + querystring: dict[str, str] + headers: dict[str, str] + + +class RequestParametersResolver: + """ + Integration request data mapping expressions + https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html + + Note: Use on REST APIs only + """ + + def resolve(self, context: ApiInvocationContext) -> IntegrationParameters: + """ + Resolve method request parameters into integration request parameters. + Integration request parameters, in the form of path variables, query strings + or headers, can be mapped from any defined method request parameters + and the payload. + + :return: IntegrationParameters + """ + method_request_params: Dict[str, Any] = self.method_request_dict(context) + + # requestParameters: { + # "integration.request.path.pathParam": "method.request.header.Content-Type" + # "integration.request.querystring.who": "method.request.querystring.who", + # "integration.request.header.Content-Type": "'application/json'", + # } + request_params = context.integration.get("requestParameters", {}) + + # resolve all integration request parameters with the already resolved method request parameters + integrations_parameters = {} + for k, v in request_params.items(): + if v.lower() in method_request_params: + integrations_parameters[k] = method_request_params[v.lower()] + else: + # static values + integrations_parameters[k] = v.replace("'", "") + + # build the integration parameters + result: IntegrationParameters = IntegrationParameters(path={}, querystring={}, headers={}) + for k, v in integrations_parameters.items(): + # headers + if k.startswith("integration.request.header."): + header_name = k.split(".")[-1] + result["headers"].update({header_name: v}) + + # querystring + if k.startswith("integration.request.querystring."): + param_name = k.split(".")[-1] + result["querystring"].update({param_name: v}) + + # path + if k.startswith("integration.request.path."): + path_name = k.split(".")[-1] + result["path"].update({path_name: v}) + + return result + + def method_request_dict(self, context: ApiInvocationContext) -> Dict[str, Any]: + """ + Build a dict with all method request parameters and their values. + :return: dict with all method request parameters and their values, + and all keys in lowercase + """ + params: Dict[str, str] = {} + + # TODO: add support for multi-values headers and multi-values querystring + + for k, v in context.query_params().items(): + params[f"method.request.querystring.{k}"] = v + + for k, v in context.headers.items(): + params[f"method.request.header.{k}"] = v + + for k, v in context.path_params.items(): + params[f"method.request.path.{k}"] = v + + for k, v in context.stage_variables.items(): + params[f"stagevariables.{k}"] = v + + # TODO: add support for missing context variables, use `context.context` which contains most of the variables + # see https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#context-variable-reference + # - all `context.identity` fields + # - protocol + # - requestId, extendedRequestId + # - all requestOverride, responseOverride + # - requestTime, requestTimeEpoch + # - resourcePath + # - wafResponseCode, webaclArn + params["context.accountId"] = context.account_id + params["context.apiId"] = context.api_id + params["context.domainName"] = context.domain_name + params["context.httpMethod"] = context.method + params["context.path"] = context.path + params["context.resourceId"] = context.resource_id + params["context.stage"] = context.stage + + auth_context_authorizer = context.auth_context.get("authorizer") or {} + for k, v in auth_context_authorizer.items(): + if isinstance(v, bool): + v = canonicalize_bool_to_str(v) + elif is_number(v): + v = str(v) + + params[f"context.authorizer.{k.lower()}"] = v + + if context.data: + params["method.request.body"] = context.data + + return {key.lower(): val for key, val in params.items()} + + +class ResponseParametersResolver: + def resolve(self, context: ApiInvocationContext) -> Dict[str, str]: + """ + Resolve integration response parameters into method response parameters. + Integration response parameters can map header, body, + or static values to the header type of the method response. + + :return: dict with all method response parameters and their values + """ + integration_request_params: Dict[str, Any] = self.integration_request_dict(context) + + # "responseParameters" : { + # "method.response.header.Location" : "integration.response.body.redirect.url", + # "method.response.header.x-user-id" : "integration.response.header.x-userid" + # } + integration_responses = context.integration.get("integrationResponses", {}) + # XXX Fix for other status codes context.response contains a response status code, but response + # can be a LambdaResponse or Response object and the field is not the same, normalize it or use introspection + response_params = integration_responses.get("200", {}).get("responseParameters", {}) + + # resolve all integration request parameters with the already resolved method + # request parameters + method_parameters = {} + for k, v in response_params.items(): + if v.lower() in integration_request_params: + method_parameters[k] = integration_request_params[v.lower()] + else: + # static values + method_parameters[k] = v.replace("'", "") + + # build the integration parameters + result: Dict[str, str] = {} + for k, v in method_parameters.items(): + # headers + if k.startswith("method.response.header."): + header_name = k.split(".")[-1] + result[header_name] = v + + return result + + def integration_request_dict(self, context: ApiInvocationContext) -> Dict[str, Any]: + params: Dict[str, str] = {} + + for k, v in context.headers.items(): + params[f"integration.request.header.{k}"] = v + + if context.data: + params["integration.request.body"] = try_json(context.data) + + return {key.lower(): val for key, val in params.items()} + + +def make_json_response(message): + return requests_response(json.dumps(message), headers={"Content-Type": APPLICATION_JSON}) + + +def make_error_response(message, code=400, error_type=None): + if code == 404 and not error_type: + error_type = "NotFoundException" + error_type = error_type or "InvalidRequest" + return requests_error_response_json(message, code=code, error_type=error_type) + + +def select_integration_response(matched_part: str, invocation_context: ApiInvocationContext): + int_responses = invocation_context.integration.get("integrationResponses") or {} + if select_by_pattern := [ + response + for response in int_responses.values() + if response.get("selectionPattern") + and re.match(response.get("selectionPattern"), matched_part) + ]: + selected_response = select_by_pattern[0] + if len(select_by_pattern) > 1: + LOG.warning( + "Multiple integration responses matching '%s' statuscode. Choosing '%s' (first).", + matched_part, + selected_response["statusCode"], + ) + else: + # choose default return code + default_responses = [ + response for response in int_responses.values() if not response.get("selectionPattern") + ] + if not default_responses: + raise ApiGatewayIntegrationError("Internal server error", 500) + + selected_response = default_responses[0] + if len(default_responses) > 1: + LOG.warning( + "Multiple default integration responses. Choosing %s (first).", + selected_response["statusCode"], + ) + return selected_response + + +def make_accepted_response(): + response = Response() + response.status_code = 202 + return response + + +def get_api_id_from_path(path): + if match := re.match(PATH_REGEX_SUB, path): + return match.group(1) + return re.match(PATH_REGEX_MAIN, path).group(1) + + +def is_test_invoke_method(method, path): + return method == "POST" and bool(re.match(PATH_REGEX_TEST_INVOKE_API, path)) + + +def get_stage_variables(context: ApiInvocationContext) -> Optional[Dict[str, str]]: + if is_test_invoke_method(context.method, context.path): + return None + + if not context.stage: + return {} + + account_id, region_name = get_api_account_id_and_region(context.api_id) + api_gateway_client = connect_to( + aws_access_key_id=account_id, region_name=region_name + ).apigateway + try: + response = api_gateway_client.get_stage(restApiId=context.api_id, stageName=context.stage) + return response.get("variables", {}) + except Exception: + LOG.info("Failed to get stage %s for API id %s", context.stage, context.api_id) + return {} + + +def tokenize_path(path): + return path.lstrip("/").split("/") + + +def extract_path_params(path: str, extracted_path: str) -> Dict[str, str]: + tokenized_extracted_path = tokenize_path(extracted_path) + # Looks for '{' in the tokenized extracted path + path_params_list = [(i, v) for i, v in enumerate(tokenized_extracted_path) if "{" in v] + tokenized_path = tokenize_path(path) + path_params = {} + for param in path_params_list: + path_param_name = param[1][1:-1] + path_param_position = param[0] + if path_param_name.endswith("+"): + path_params[path_param_name.rstrip("+")] = "/".join( + tokenized_path[path_param_position:] + ) + else: + path_params[path_param_name] = tokenized_path[path_param_position] + path_params = common.json_safe(path_params) + return path_params + + +def extract_query_string_params(path: str) -> Tuple[str, Dict[str, str]]: + parsed_path = urlparse.urlparse(path) + if not path.startswith("//"): + path = parsed_path.path + parsed_query_string_params = urlparse.parse_qs(parsed_path.query) + + query_string_params = {} + for query_param_name, query_param_values in parsed_query_string_params.items(): + if len(query_param_values) == 1: + query_string_params[query_param_name] = query_param_values[0] + else: + query_string_params[query_param_name] = query_param_values + + path = path or "/" + return path, query_string_params + + +def get_cors_response(headers): + # TODO: for now we simply return "allow-all" CORS headers, but in the future + # we should implement custom headers for CORS rules, as supported by API Gateway: + # http://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-cors.html + response = Response() + response.status_code = 200 + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, PATCH" + response.headers["Access-Control-Allow-Headers"] = "*" + response._content = "" + return response + + +def get_apigateway_path_for_resource( + api_id, resource_id, path_suffix="", resources=None, region_name=None +): + if resources is None: + apigateway = connect_to(region_name=region_name).apigateway + resources = apigateway.get_resources(restApiId=api_id, limit=100)["items"] + target_resource = list(filter(lambda res: res["id"] == resource_id, resources))[0] + path_part = target_resource.get("pathPart", "") + if path_suffix: + if path_part: + path_suffix = "%s/%s" % (path_part, path_suffix) + else: + path_suffix = path_part + parent_id = target_resource.get("parentId") + if not parent_id: + return "/%s" % path_suffix + return get_apigateway_path_for_resource( + api_id, + parent_id, + path_suffix=path_suffix, + resources=resources, + region_name=region_name, + ) + + +def get_rest_api_paths(account_id: str, region_name: str, rest_api_id: str): + apigateway = connect_to(aws_access_key_id=account_id, region_name=region_name).apigateway + resources = apigateway.get_resources(restApiId=rest_api_id, limit=100) + resource_map = {} + for resource in resources["items"]: + path = resource.get("path") + # TODO: check if this is still required in the general case (can we rely on "path" being + # present?) + path = path or get_apigateway_path_for_resource( + rest_api_id, resource["id"], region_name=region_name + ) + resource_map[path] = resource + return resource_map + + +# TODO: Extract this to a set of rules that have precedence and easy to test individually. +# +# https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-settings +# -method-request.html +# https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-routes.html +def get_resource_for_path( + path: str, method: str, path_map: Dict[str, Dict] +) -> tuple[Optional[str], Optional[dict]]: + matches = [] + # creates a regex from the input path if there are parameters, e.g /foo/{bar}/baz -> /foo/[ + # ^\]+/baz, otherwise is a direct match. + for api_path, details in path_map.items(): + api_path_regex = re.sub(r"{[^+]+\+}", r"[^\?#]+", api_path) + api_path_regex = re.sub(r"{[^}]+}", r"[^/]+", api_path_regex) + if re.match(r"^%s$" % api_path_regex, path): + matches.append((api_path, details)) + + # if there are no matches, it's not worth to proceed, bail here! + if not matches: + LOG.debug("No match found for path: '%s' and method: '%s'", path, method) + return None, None + + if len(matches) == 1: + LOG.debug("Match found for path: '%s' and method: '%s'", path, method) + return matches[0] + + # so we have more than one match + # /{proxy+} and /api/{proxy+} for inputs like /api/foo/bar + # /foo/{param1}/baz and /foo/{param1}/{param2} for inputs like /for/bar/baz + proxy_matches = [] + param_matches = [] + for match in matches: + match_methods = list(match[1].get("resourceMethods", {}).keys()) + # only look for path matches if the request method is in the resource + if method.upper() in match_methods or "ANY" in match_methods: + # check if we have an exact match (exact matches take precedence) if the method is the same + if match[0] == path: + return match + + elif path_matches_pattern(path, match[0]): + # parameters can fit in + param_matches.append(match) + continue + + proxy_matches.append(match) + + if param_matches: + # count the amount of parameters, return the one with the least which is the most precise + sorted_matches = sorted(param_matches, key=lambda x: x[0].count("{")) + LOG.debug("Match found for path: '%s' and method: '%s'", path, method) + return sorted_matches[0] + + if proxy_matches: + # at this stage, we still have more than one match, but we have an eager example like + # /{proxy+} or /api/{proxy+}, so we pick the best match by sorting by length, only if they have a method + # that could match + sorted_matches = sorted(proxy_matches, key=lambda x: len(x[0]), reverse=True) + LOG.debug("Match found for path: '%s' and method: '%s'", path, method) + return sorted_matches[0] + + # if there are no matches with a method that would match, return + LOG.debug("No match found for method: '%s' for matched path: %s", method, path) + return None, None + + +def path_matches_pattern(path, api_path): + api_paths = api_path.split("/") + paths = path.split("/") + reg_check = re.compile(r"{(.*)}") + if len(api_paths) != len(paths): + return False + results = [ + part == paths[indx] + for indx, part in enumerate(api_paths) + if reg_check.match(part) is None and part + ] + + return len(results) > 0 and all(results) + + +def connect_api_gateway_to_sqs(gateway_name, stage_name, queue_arn, path, account_id, region_name): + resources = {} + template = APIGATEWAY_SQS_DATA_INBOUND_TEMPLATE + resource_path = path.replace("/", "") + + try: + arn = parse_arn(queue_arn) + queue_name = arn["resource"] + sqs_account = arn["account"] + sqs_region = arn["region"] + except InvalidArnException: + queue_name = queue_arn + sqs_account = account_id + sqs_region = region_name + + partition = get_partition(region_name) + resources[resource_path] = [ + { + "httpMethod": "POST", + "authorizationType": "NONE", + "integrations": [ + { + "type": "AWS", + "uri": "arn:%s:apigateway:%s:sqs:path/%s/%s" + % (partition, sqs_region, sqs_account, queue_name), + "requestTemplates": {"application/json": template}, + "requestParameters": { + "integration.request.header.Content-Type": "'application/x-www-form-urlencoded'" + }, + } + ], + } + ] + return resource_utils.create_api_gateway( + name=gateway_name, + resources=resources, + stage_name=stage_name, + client=connect_to(aws_access_key_id=sqs_account, region_name=sqs_region).apigateway, + ) + + +def get_target_resource_details( + invocation_context: ApiInvocationContext, +) -> Tuple[Optional[str], Optional[dict]]: + """Look up and return the API GW resource (path pattern + resource dict) for the given invocation context.""" + path_map = get_rest_api_paths( + account_id=invocation_context.account_id, + region_name=invocation_context.region_name, + rest_api_id=invocation_context.api_id, + ) + relative_path = invocation_context.invocation_path.rstrip("/") or "/" + try: + extracted_path, resource = get_resource_for_path( + path=relative_path, method=invocation_context.method, path_map=path_map + ) + if not extracted_path: + return None, None + invocation_context.resource = resource + invocation_context.resource_path = extracted_path + try: + invocation_context.path_params = extract_path_params( + path=relative_path, extracted_path=extracted_path + ) + except Exception: + invocation_context.path_params = {} + + return extracted_path, resource + + except Exception: + return None, None + + +def get_target_resource_method(invocation_context: ApiInvocationContext) -> Optional[Dict]: + """Look up and return the API GW resource method for the given invocation context.""" + _, resource = get_target_resource_details(invocation_context) + if not resource: + return None + methods = resource.get("resourceMethods") or {} + return methods.get(invocation_context.method.upper()) or methods.get("ANY") + + +def event_type_from_route_key(invocation_context): + action = invocation_context.route["RouteKey"] + return ( + "CONNECT" + if action == "$connect" + else "DISCONNECT" + if action == "$disconnect" + else "MESSAGE" + ) + + +def get_event_request_context(invocation_context: ApiInvocationContext): + method = invocation_context.method + path = invocation_context.path + headers = invocation_context.headers + integration_uri = invocation_context.integration_uri + resource_path = invocation_context.resource_path + resource_id = invocation_context.resource_id + + set_api_id_stage_invocation_path(invocation_context) + api_id = invocation_context.api_id + stage = invocation_context.stage + + if "_user_request_" in invocation_context.raw_uri: + full_path = invocation_context.raw_uri.partition("_user_request_")[2] + else: + full_path = invocation_context.raw_uri.removeprefix(f"/{stage}") + relative_path, query_string_params = extract_query_string_params(path=full_path) + + source_ip = invocation_context.auth_identity.get("sourceIp") + integration_uri = integration_uri or "" + account_id = integration_uri.split(":lambda:path")[-1].split(":function:")[0].split(":")[-1] + account_id = account_id or DEFAULT_AWS_ACCOUNT_ID + request_context = { + "accountId": account_id, + "apiId": api_id, + "resourcePath": resource_path or relative_path, + "domainPrefix": invocation_context.domain_prefix, + "domainName": invocation_context.domain_name, + "resourceId": resource_id, + "requestId": long_uid(), + "identity": { + "accountId": account_id, + "sourceIp": source_ip, + "userAgent": headers.get("User-Agent"), + }, + "httpMethod": method, + "protocol": "HTTP/1.1", + "requestTime": datetime.now(timezone.utc).strftime(REQUEST_TIME_DATE_FORMAT), + "requestTimeEpoch": int(time.time() * 1000), + "authorizer": {}, + } + + if invocation_context.is_websocket_request(): + request_context["connectionId"] = invocation_context.connection_id + + # set "authorizer" and "identity" event attributes from request context + authorizer_result = invocation_context.authorizer_result + if authorizer_result: + request_context["authorizer"] = authorizer_result + request_context["identity"].update(invocation_context.auth_identity or {}) + + if not is_test_invoke_method(method, path): + request_context["path"] = (f"/{stage}" if stage else "") + relative_path + request_context["stage"] = stage + return request_context + + +def set_api_id_stage_invocation_path( + invocation_context: ApiInvocationContext, +) -> ApiInvocationContext: + # skip if all details are already available + values = ( + invocation_context.api_id, + invocation_context.stage, + invocation_context.path_with_query_string, + ) + if all(values): + return invocation_context + + # skip if this is a websocket request + if invocation_context.is_websocket_request(): + return invocation_context + + path = invocation_context.path + headers = invocation_context.headers + + path_match = re.search(PATH_REGEX_USER_REQUEST, path) + host_header = headers.get(HEADER_LOCALSTACK_EDGE_URL, "") or headers.get("Host") or "" + host_match = re.search(HOST_REGEX_EXECUTE_API, host_header) + test_invoke_match = re.search(PATH_REGEX_TEST_INVOKE_API, path) + if path_match: + api_id = path_match.group(1) + stage = path_match.group(2) + relative_path_w_query_params = "/%s" % path_match.group(3) + elif host_match: + api_id = extract_api_id_from_hostname_in_url(host_header) + stage = path.strip("/").split("/")[0] + relative_path_w_query_params = "/%s" % path.lstrip("/").partition("/")[2] + elif test_invoke_match: + stage = invocation_context.stage + api_id = invocation_context.api_id + relative_path_w_query_params = invocation_context.path_with_query_string + else: + raise Exception( + f"Unable to extract API Gateway details from request: {path} {dict(headers)}" + ) + + # set details in invocation context + invocation_context.api_id = api_id + invocation_context.stage = stage + invocation_context.path_with_query_string = relative_path_w_query_params + return invocation_context + + +def get_api_account_id_and_region(api_id: str) -> Tuple[Optional[str], Optional[str]]: + """Return the region name for the given REST API ID""" + for account_id, account in apigateway_backends.items(): + for region_name, region in account.items(): + # compare low case keys to avoid case sensitivity issues + for key in region.apis.keys(): + if key.lower() == api_id.lower(): + return account_id, region_name + return None, None + + +def extract_api_id_from_hostname_in_url(hostname: str) -> str: + """Extract API ID 'id123' from URLs like https://id123.execute-api.localhost.localstack.cloud:4566""" + match = re.match(HOST_REGEX_EXECUTE_API, hostname) + return match.group(1) + + +def multi_value_dict_for_list(elements: Union[List, Dict]) -> Dict: + temp_mv_dict = defaultdict(list) + for key in elements: + if isinstance(key, (list, tuple)): + key, value = key + else: + value = elements[key] + + key = to_str(key) + temp_mv_dict[key].append(value) + return {k: tuple(v) for k, v in temp_mv_dict.items()} diff --git a/localstack-core/localstack/services/apigateway/legacy/integration.py b/localstack-core/localstack/services/apigateway/legacy/integration.py new file mode 100644 index 0000000000000..12852fff266af --- /dev/null +++ b/localstack-core/localstack/services/apigateway/legacy/integration.py @@ -0,0 +1,1119 @@ +import base64 +import json +import logging +import re +from abc import ABC, abstractmethod +from functools import lru_cache +from http import HTTPMethod, HTTPStatus +from typing import Any, Dict +from urllib.parse import urljoin + +import requests +from botocore.exceptions import ClientError +from moto.apigatewayv2.exceptions import BadRequestException +from requests import Response + +from localstack import config +from localstack.aws.connect import ( + INTERNAL_REQUEST_PARAMS_HEADER, + InternalRequestParameters, + connect_to, + dump_dto, +) +from localstack.constants import APPLICATION_JSON, HEADER_CONTENT_TYPE +from localstack.services.apigateway.legacy.context import ApiInvocationContext +from localstack.services.apigateway.legacy.helpers import ( + ApiGatewayIntegrationError, + IntegrationParameters, + RequestParametersResolver, + ResponseParametersResolver, + extract_path_params, + extract_query_string_params, + get_event_request_context, + get_stage_variables, + make_error_response, + multi_value_dict_for_list, +) +from localstack.services.apigateway.legacy.templates import ( + MappingTemplates, + RequestTemplates, + ResponseTemplates, +) +from localstack.services.stepfunctions.stepfunctions_utils import await_sfn_execution_result +from localstack.utils import common +from localstack.utils.aws.arns import ARN_PARTITION_REGEX, extract_region_from_arn, get_partition +from localstack.utils.aws.aws_responses import ( + LambdaResponse, + request_response_stream, + requests_response, +) +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.aws.templating import VtlTemplate +from localstack.utils.collections import dict_multi_values, remove_attributes +from localstack.utils.common import make_http_request, to_str +from localstack.utils.http import add_query_params_to_url, canonicalize_headers, parse_request_data +from localstack.utils.json import json_safe, try_json +from localstack.utils.strings import camel_to_snake_case, to_bytes + +LOG = logging.getLogger(__name__) + + +class IntegrationAccessError(ApiGatewayIntegrationError): + """ + Error message when an integration cannot be accessed. + """ + + def __init__(self): + super().__init__("Internal server error", 500) + + +class BackendIntegration(ABC): + """Abstract base class representing a backend integration""" + + def __init__(self): + self.request_templates = RequestTemplates() + self.response_templates = ResponseTemplates() + self.request_params_resolver = RequestParametersResolver() + self.response_params_resolver = ResponseParametersResolver() + + @abstractmethod + def invoke(self, invocation_context: ApiInvocationContext): + pass + + @classmethod + def _create_response(cls, status_code, headers, data=""): + response = Response() + response.status_code = status_code + response.headers = headers + response._content = data + return response + + @classmethod + def apply_request_parameters( + cls, integration_params: IntegrationParameters, headers: Dict[str, Any] + ): + for k, v in integration_params.get("headers").items(): + headers.update({k: v}) + + @classmethod + def apply_response_parameters( + cls, invocation_context: ApiInvocationContext, response: Response + ): + integration = invocation_context.integration + integration_responses = integration.get("integrationResponses") or {} + if not integration_responses: + return response + entries = list(integration_responses.keys()) + return_code = str(response.status_code) + if return_code not in entries: + if len(entries) > 1: + LOG.info("Found multiple integration response status codes: %s", entries) + return response + return_code = entries[0] + response_params = integration_responses[return_code].get("responseParameters", {}) + for key, value in response_params.items(): + # TODO: add support for method.response.body, etc ... + if str(key).lower().startswith("method.response.header."): + header_name = key[len("method.response.header.") :] + response.headers[header_name] = value.strip("'") + return response + + @classmethod + def render_template_selection_expression(cls, invocation_context: ApiInvocationContext): + integration = invocation_context.integration + template_selection_expression = integration.get("templateSelectionExpression") + + # AWS template selection relies on the content type + # to select an input template or output mapping AND template selection expressions. + # All of them will fall back to the $default template if a matching template is not found. + if not template_selection_expression: + content_type = invocation_context.headers.get(HEADER_CONTENT_TYPE, APPLICATION_JSON) + if integration.get("RequestTemplates", {}).get(content_type): + return content_type + return "$default" + + data = try_json(invocation_context.data) + variables = { + "request": { + "header": invocation_context.headers, + "querystring": invocation_context.query_params(), + "body": data, + "context": invocation_context.context or {}, + "stage_variables": invocation_context.stage_variables or {}, + } + } + return VtlTemplate().render_vtl(template_selection_expression, variables) or "$default" + + +@lru_cache(maxsize=64) +def get_service_factory(region_name: str, role_arn: str): + if role_arn: + return connect_to.with_assumed_role( + role_arn=role_arn, + region_name=region_name, + service_principal=ServicePrincipal.apigateway, + session_name="BackplaneAssumeRoleSession", + ) + else: + return connect_to(region_name=region_name) + + +@lru_cache(maxsize=64) +def get_internal_mocked_headers( + service_name: str, + region_name: str, + source_arn: str, + role_arn: str | None, +) -> dict[str, str]: + if role_arn: + access_key_id = ( + connect_to(region_name=region_name) + .sts.request_metadata(service_principal=ServicePrincipal.apigateway) + .assume_role(RoleArn=role_arn, RoleSessionName="BackplaneAssumeRoleSession")[ + "Credentials" + ]["AccessKeyId"] + ) + else: + access_key_id = None + headers = mock_aws_request_headers( + service=service_name, aws_access_key_id=access_key_id, region_name=region_name + ) + + dto = InternalRequestParameters( + service_principal=ServicePrincipal.apigateway, source_arn=source_arn + ) + headers[INTERNAL_REQUEST_PARAMS_HEADER] = dump_dto(dto) + return headers + + +def get_source_arn(invocation_context: ApiInvocationContext): + return f"arn:{get_partition(invocation_context.region_name)}:execute-api:{invocation_context.region_name}:{invocation_context.account_id}:{invocation_context.api_id}/{invocation_context.stage}/{invocation_context.method}{invocation_context.path}" + + +def call_lambda( + function_arn: str, event: bytes, asynchronous: bool, invocation_context: ApiInvocationContext +) -> str: + clients = get_service_factory( + region_name=extract_region_from_arn(function_arn), + role_arn=invocation_context.integration.get("credentials"), + ) + inv_result = clients.lambda_.request_metadata( + service_principal=ServicePrincipal.apigateway, source_arn=get_source_arn(invocation_context) + ).invoke( + FunctionName=function_arn, + Payload=event, + InvocationType="Event" if asynchronous else "RequestResponse", + ) + if payload := inv_result.get("Payload"): + payload = to_str(payload.read()) + return payload + return "" + + +class LambdaProxyIntegration(BackendIntegration): + @classmethod + def update_content_length(cls, response: Response): + if response and response.content is not None: + response.headers["Content-Length"] = str(len(response.content)) + + @classmethod + def lambda_result_to_response(cls, result) -> LambdaResponse: + response = LambdaResponse() + response.headers.update({"content-type": "application/json"}) + parsed_result = result if isinstance(result, dict) else json.loads(str(result or "{}")) + parsed_result = common.json_safe(parsed_result) + parsed_result = {} if parsed_result is None else parsed_result + + if set(parsed_result) - { + "body", + "statusCode", + "headers", + "isBase64Encoded", + "multiValueHeaders", + }: + LOG.warning( + 'Lambda output should follow the next JSON format: { "isBase64Encoded": true|false, "statusCode": httpStatusCode, "headers": { "headerName": "headerValue", ... },"body": "..."}\n Lambda output: %s', + parsed_result, + ) + response.status_code = 502 + response._content = json.dumps({"message": "Internal server error"}) + return response + + response.status_code = int(parsed_result.get("statusCode", 200)) + parsed_headers = parsed_result.get("headers", {}) + if parsed_headers is not None: + response.headers.update(parsed_headers) + try: + result_body = parsed_result.get("body") + if isinstance(result_body, dict): + response._content = json.dumps(result_body) + else: + body_bytes = to_bytes(to_str(result_body or "")) + if parsed_result.get("isBase64Encoded", False): + body_bytes = base64.b64decode(body_bytes) + response._content = body_bytes + except Exception as e: + LOG.warning("Couldn't set Lambda response content: %s", e) + response._content = "{}" + response.multi_value_headers = parsed_result.get("multiValueHeaders") or {} + return response + + @staticmethod + def fix_proxy_path_params(path_params): + proxy_path_param_value = path_params.get("proxy+") + if not proxy_path_param_value: + return + del path_params["proxy+"] + path_params["proxy"] = proxy_path_param_value + + @staticmethod + def validate_integration_method(invocation_context: ApiInvocationContext): + if invocation_context.integration["httpMethod"] != HTTPMethod.POST: + raise ApiGatewayIntegrationError("Internal server error", status_code=500) + + @classmethod + def construct_invocation_event( + cls, method, path, headers, data, query_string_params=None, is_base64_encoded=False + ): + query_string_params = query_string_params or parse_request_data(method, path, "") + + single_value_query_string_params = { + k: v[-1] if isinstance(v, list) else v for k, v in query_string_params.items() + } + # Some headers get capitalized like in CloudFront, see + # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html#add-origin-custom-headers-forward-authorization + # It seems AWS_PROXY lambda integrations are behind cloudfront, as seen by the returned headers in AWS + to_capitalize: list[str] = ["authorization"] # some headers get capitalized + headers = { + k.capitalize() if k.lower() in to_capitalize else k: v for k, v in headers.items() + } + + # AWS canonical header names, converting them to lower-case + headers = canonicalize_headers(headers) + + return { + "path": "/" + path.lstrip("/"), + "headers": headers, + "multiValueHeaders": multi_value_dict_for_list(headers), + "body": data, + "isBase64Encoded": is_base64_encoded, + "httpMethod": method, + "queryStringParameters": single_value_query_string_params or None, + "multiValueQueryStringParameters": dict_multi_values(query_string_params) or None, + } + + @classmethod + def process_apigateway_invocation( + cls, + func_arn, + path, + payload, + invocation_context: ApiInvocationContext, + query_string_params=None, + ) -> str: + if (path_params := invocation_context.path_params) is None: + path_params = {} + if (request_context := invocation_context.context) is None: + request_context = {} + try: + resource_path = invocation_context.resource_path or path + event = cls.construct_invocation_event( + invocation_context.method, + path, + invocation_context.headers, + payload, + query_string_params, + invocation_context.is_data_base64_encoded, + ) + path_params = dict(path_params) + cls.fix_proxy_path_params(path_params) + event["pathParameters"] = path_params + event["resource"] = resource_path + event["requestContext"] = request_context + event["stageVariables"] = invocation_context.stage_variables + LOG.debug( + "Running Lambda function %s from API Gateway invocation: %s %s", + func_arn, + invocation_context.method or "GET", + path, + ) + asynchronous = invocation_context.headers.get("X-Amz-Invocation-Type") == "'Event'" + return call_lambda( + function_arn=func_arn, + event=to_bytes(json.dumps(event)), + asynchronous=asynchronous, + invocation_context=invocation_context, + ) + except ClientError as e: + raise IntegrationAccessError() from e + except Exception as e: + LOG.warning( + "Unable to run Lambda function on API Gateway message: %s", + e, + ) + + def invoke(self, invocation_context: ApiInvocationContext): + self.validate_integration_method(invocation_context) + uri = ( + invocation_context.integration.get("uri") + or invocation_context.integration.get("integrationUri") + or "" + ) + invocation_context.context = get_event_request_context(invocation_context) + relative_path, query_string_params = extract_query_string_params( + path=invocation_context.path_with_query_string + ) + try: + path_params = extract_path_params( + path=relative_path, extracted_path=invocation_context.resource_path + ) + invocation_context.path_params = path_params + except Exception: + pass + + func_arn = uri + if ":lambda:path" in uri: + func_arn = uri.split(":lambda:path")[1].split("functions/")[1].split("/invocations")[0] + + if invocation_context.authorizer_type: + invocation_context.context["authorizer"] = invocation_context.authorizer_result + + payload = self.request_templates.render(invocation_context) + + result = self.process_apigateway_invocation( + func_arn=func_arn, + path=relative_path, + payload=payload, + invocation_context=invocation_context, + query_string_params=query_string_params, + ) + + response = LambdaResponse() + response.headers.update({"content-type": "application/json"}) + parsed_result = json.loads(str(result or "{}")) + parsed_result = common.json_safe(parsed_result) + parsed_result = {} if parsed_result is None else parsed_result + + if set(parsed_result) - { + "body", + "statusCode", + "headers", + "isBase64Encoded", + "multiValueHeaders", + }: + LOG.warning( + 'Lambda output should follow the next JSON format: { "isBase64Encoded": true|false, "statusCode": httpStatusCode, "headers": { "headerName": "headerValue", ... },"body": "..."}\n Lambda output: %s', + parsed_result, + ) + response.status_code = 502 + response._content = json.dumps({"message": "Internal server error"}) + return response + + response.status_code = int(parsed_result.get("statusCode", 200)) + parsed_headers = parsed_result.get("headers", {}) + if parsed_headers is not None: + response.headers.update(parsed_headers) + try: + result_body = parsed_result.get("body") + if isinstance(result_body, dict): + response._content = json.dumps(result_body) + else: + body_bytes = to_bytes(result_body or "") + if parsed_result.get("isBase64Encoded", False): + body_bytes = base64.b64decode(body_bytes) + response._content = body_bytes + except Exception as e: + LOG.warning("Couldn't set Lambda response content: %s", e) + response._content = "{}" + response.multi_value_headers = parsed_result.get("multiValueHeaders") or {} + + # apply custom response template + self.update_content_length(response) + invocation_context.response = response + + return invocation_context.response + + +class LambdaIntegration(BackendIntegration): + def invoke(self, invocation_context: ApiInvocationContext): + invocation_context.stage_variables = get_stage_variables(invocation_context) + headers = invocation_context.headers + + # resolve integration parameters + integration_parameters = self.request_params_resolver.resolve(context=invocation_context) + headers.update(integration_parameters.get("headers", {})) + + if invocation_context.authorizer_type: + invocation_context.context["authorizer"] = invocation_context.authorizer_result + + func_arn = self._lambda_integration_uri(invocation_context) + # integration type "AWS" is only supported for WebSocket APIs and REST + # API (v1), but the template selection expression is only supported for + # Websockets + if invocation_context.is_websocket_request(): + template_key = self.render_template_selection_expression(invocation_context) + payload = self.request_templates.render(invocation_context, template_key) + else: + payload = self.request_templates.render(invocation_context) + + asynchronous = headers.get("X-Amz-Invocation-Type", "").strip("'") == "Event" + try: + result = call_lambda( + function_arn=func_arn, + event=to_bytes(payload or ""), + asynchronous=asynchronous, + invocation_context=invocation_context, + ) + except ClientError as e: + raise IntegrationAccessError() from e + + # default lambda status code is 200 + response = LambdaResponse() + response.status_code = 200 + response._content = result + + if asynchronous: + response._content = "" + + # response template + invocation_context.response = response + self.response_templates.render(invocation_context) + invocation_context.response.headers["Content-Length"] = str(len(response.content or "")) + + headers = self.response_params_resolver.resolve(invocation_context) + invocation_context.response.headers.update(headers) + + return invocation_context.response + + def _lambda_integration_uri(self, invocation_context: ApiInvocationContext): + """ + https://docs.aws.amazon.com/apigateway/latest/developerguide/aws-api-gateway-stage-variables-reference.html + """ + uri = ( + invocation_context.integration.get("uri") + or invocation_context.integration.get("integrationUri") + or "" + ) + variables = {"stageVariables": invocation_context.stage_variables} + uri = VtlTemplate().render_vtl(uri, variables) + if ":lambda:path" in uri: + uri = uri.split(":lambda:path")[1].split("functions/")[1].split("/invocations")[0] + return uri + + +class KinesisIntegration(BackendIntegration): + def invoke(self, invocation_context: ApiInvocationContext): + integration = invocation_context.integration + integration_type_orig = integration.get("type") or integration.get("integrationType") or "" + integration_type = integration_type_orig.upper() + uri = integration.get("uri") or integration.get("integrationUri") or "" + integration_subtype = integration.get("integrationSubtype") + + if uri.endswith("kinesis:action/PutRecord") or integration_subtype == "Kinesis-PutRecord": + target = "Kinesis_20131202.PutRecord" + elif uri.endswith("kinesis:action/PutRecords"): + target = "Kinesis_20131202.PutRecords" + elif uri.endswith("kinesis:action/ListStreams"): + target = "Kinesis_20131202.ListStreams" + else: + LOG.info( + "Unexpected API Gateway integration URI '%s' for integration type %s", + uri, + integration_type, + ) + target = "" + + try: + # xXx this "event" request context is used in multiple places, we probably + # want to refactor this into a model class. + # I'd argue we should not make a decision on the event_request_context inside the integration because, + # it's different between API types (REST, HTTP, WebSocket) and per event version + invocation_context.context = get_event_request_context(invocation_context) + invocation_context.stage_variables = get_stage_variables(invocation_context) + + # integration type "AWS" is only supported for WebSocket APIs and REST + # API (v1), but the template selection expression is only supported for + # Websockets + if invocation_context.is_websocket_request(): + template_key = self.render_template_selection_expression(invocation_context) + payload = self.request_templates.render(invocation_context, template_key) + else: + # For HTTP APIs with a specified integration_subtype, + # a key-value map specifying parameters that are passed to AWS_PROXY integrations + if integration_type == "AWS_PROXY" and integration_subtype == "Kinesis-PutRecord": + payload = self._create_request_parameters(invocation_context) + else: + payload = self.request_templates.render(invocation_context) + + except Exception as e: + LOG.warning("Unable to convert API Gateway payload to str", e) + raise + + # forward records to target kinesis stream + headers = get_internal_mocked_headers( + service_name="kinesis", + region_name=invocation_context.region_name, + role_arn=invocation_context.integration.get("credentials"), + source_arn=get_source_arn(invocation_context), + ) + headers["X-Amz-Target"] = target + + result = common.make_http_request( + url=config.internal_service_url(), data=payload, headers=headers, method="POST" + ) + + # apply response template + invocation_context.response = result + self.response_templates.render(invocation_context) + return invocation_context.response + + @classmethod + def _validate_required_params(cls, request_parameters: Dict[str, Any]) -> None: + if not request_parameters: + raise BadRequestException("Missing required parameters") + # https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-aws-services-reference.html#Kinesis-PutRecord + stream_name = request_parameters.get("StreamName") + partition_key = request_parameters.get("PartitionKey") + data = request_parameters.get("Data") + + if not stream_name: + raise BadRequestException("StreamName") + + if not partition_key: + raise BadRequestException("PartitionKey") + + if not data: + raise BadRequestException("Data") + + def _create_request_parameters( + self, invocation_context: ApiInvocationContext + ) -> Dict[str, Any]: + request_parameters = invocation_context.integration.get("requestParameters", {}) + self._validate_required_params(request_parameters) + + variables = { + "request": { + "header": invocation_context.headers, + "querystring": invocation_context.query_params(), + "body": invocation_context.data_as_string(), + "context": invocation_context.context or {}, + "stage_variables": invocation_context.stage_variables or {}, + } + } + + if invocation_context.headers.get("Content-Type") == "application/json": + variables["request"]["body"] = json.loads(invocation_context.data_as_string()) + else: + # AWS parity no content type still yields a valid response from Kinesis + variables["request"]["body"] = try_json(invocation_context.data_as_string()) + + # Required parameters + payload = { + "StreamName": VtlTemplate().render_vtl(request_parameters.get("StreamName"), variables), + "Data": VtlTemplate().render_vtl(request_parameters.get("Data"), variables), + "PartitionKey": VtlTemplate().render_vtl( + request_parameters.get("PartitionKey"), variables + ), + } + # Optional Parameters + if "ExplicitHashKey" in request_parameters: + payload["ExplicitHashKey"] = VtlTemplate().render_vtl( + request_parameters.get("ExplicitHashKey"), variables + ) + if "SequenceNumberForOrdering" in request_parameters: + payload["SequenceNumberForOrdering"] = VtlTemplate().render_vtl( + request_parameters.get("SequenceNumberForOrdering"), variables + ) + # TODO: XXX we don't support the Region parameter + # if "Region" in request_parameters: + # payload["Region"] = VtlTemplate().render_vtl( + # request_parameters.get("Region"), variables + # ) + return json.dumps(payload) + + +class DynamoDBIntegration(BackendIntegration): + def invoke(self, invocation_context: ApiInvocationContext): + # TODO we might want to do it plain http instead of using boto here, like kinesis + integration = invocation_context.integration + uri = integration.get("uri") or integration.get("integrationUri") or "" + + # example: arn:aws:apigateway:us-east-1:dynamodb:action/PutItem&Table=MusicCollection + action = uri.split(":dynamodb:action/")[1].split("&")[0] + + # render request template + payload = self.request_templates.render(invocation_context) + payload = json.loads(payload) + + # determine target method via reflection + clients = get_service_factory( + region_name=invocation_context.region_name, + role_arn=invocation_context.integration.get("credentials"), + ) + dynamo_client = clients.dynamodb.request_metadata( + service_principal=ServicePrincipal.apigateway, + source_arn=get_source_arn(invocation_context), + ) + method_name = camel_to_snake_case(action) + client_method = getattr(dynamo_client, method_name, None) + if not client_method: + raise Exception(f"Unsupported action {action} in API Gateway integration URI {uri}") + + # run request against DynamoDB backend + try: + response = client_method(**payload) + except ClientError as e: + response = e.response + # The request body is packed into the "Error" field. To make the response match AWS, we will remove that + # field and merge with the response dict + error = response.pop("Error", {}) + error.pop("Code", None) # the Code is also something not relayed + response |= error + + status_code = response.get("ResponseMetadata", {}).get("HTTPStatusCode", 200) + # apply response templates + response_content = json.dumps(remove_attributes(response, ["ResponseMetadata"])) + response_obj = requests_response(content=response_content) + response = self.response_templates.render(invocation_context, response=response_obj) + + # construct final response + # TODO: set response header based on response templates + headers = {HEADER_CONTENT_TYPE: APPLICATION_JSON} + response = requests_response(response, headers=headers, status_code=status_code) + + return response + + +class S3Integration(BackendIntegration): + # target ARN patterns + TARGET_REGEX_PATH_S3_URI = rf"{ARN_PARTITION_REGEX}:apigateway:[a-zA-Z0-9\-]+:s3:path/(?P[^/]+)/(?P.+)$" + TARGET_REGEX_ACTION_S3_URI = rf"{ARN_PARTITION_REGEX}:apigateway:[a-zA-Z0-9\-]+:s3:action/(?:GetObject&Bucket\=(?P[^&]+)&Key\=(?P.+))$" + + def invoke(self, invocation_context: ApiInvocationContext): + invocation_path = invocation_context.path_with_query_string + integration = invocation_context.integration + path_params = invocation_context.path_params + relative_path, query_string_params = extract_query_string_params(path=invocation_path) + uri = integration.get("uri") or integration.get("integrationUri") or "" + + s3 = connect_to().s3 + uri = apply_request_parameters( + uri, + integration=integration, + path_params=path_params, + query_params=query_string_params, + ) + uri_match = re.match(self.TARGET_REGEX_PATH_S3_URI, uri) or re.match( + self.TARGET_REGEX_ACTION_S3_URI, uri + ) + if not uri_match: + msg = "Request URI does not match s3 specifications" + LOG.warning(msg) + return make_error_response(msg, 400) + + bucket, object_key = uri_match.group("bucket", "object") + LOG.debug("Getting request for bucket %s object %s", bucket, object_key) + + action = None + invoke_args = {"Bucket": bucket, "Key": object_key} + match invocation_context.method: + case HTTPMethod.GET: + action = s3.get_object + case HTTPMethod.PUT: + invoke_args["Body"] = invocation_context.data + action = s3.put_object + case HTTPMethod.DELETE: + action = s3.delete_object + case _: + make_error_response( + "The specified method is not allowed against this resource.", 405 + ) + + try: + object = action(**invoke_args) + except s3.exceptions.NoSuchKey: + msg = f"Object {object_key} not found" + LOG.debug(msg) + return make_error_response(msg, 404) + + headers = mock_aws_request_headers( + service="s3", + aws_access_key_id=invocation_context.account_id, + region_name=invocation_context.region_name, + ) + + if object.get("ContentType"): + headers["Content-Type"] = object["ContentType"] + + # stream used so large files do not fill memory + if body := object.get("Body"): + response = request_response_stream(stream=body, headers=headers) + else: + response = requests_response(content="", headers=headers) + return response + + +class HTTPIntegration(BackendIntegration): + @staticmethod + def _set_http_apigw_headers(headers: Dict[str, Any], invocation_context: ApiInvocationContext): + del headers["host"] + headers["x-amzn-apigateway-api-id"] = invocation_context.api_id + return headers + + def invoke(self, invocation_context: ApiInvocationContext): + invocation_path = invocation_context.path_with_query_string + integration = invocation_context.integration + path_params = invocation_context.path_params + method = invocation_context.method + headers = invocation_context.headers + + relative_path, query_string_params = extract_query_string_params(path=invocation_path) + uri = integration.get("uri") or integration.get("integrationUri") or "" + + # resolve integration parameters + integration_parameters = self.request_params_resolver.resolve(context=invocation_context) + headers.update(integration_parameters.get("headers", {})) + self._set_http_apigw_headers(headers, invocation_context) + + if ":servicediscovery:" in uri: + # check if this is a servicediscovery integration URI + client = connect_to().servicediscovery + service_id = uri.split("/")[-1] + instances = client.list_instances(ServiceId=service_id)["Instances"] + instance = (instances or [None])[0] + if instance and instance.get("Id"): + uri = "http://%s/%s" % (instance["Id"], invocation_path.lstrip("/")) + + # apply custom request template + invocation_context.context = get_event_request_context(invocation_context) + invocation_context.stage_variables = get_stage_variables(invocation_context) + payload = self.request_templates.render(invocation_context) + + if isinstance(payload, dict): + payload = json.dumps(payload) + + # https://docs.aws.amazon.com/apigateway/latest/developerguide/aws-api-gateway-stage-variables-reference.html + # HTTP integration URIs + # + # A stage variable can be used as part of an HTTP integration URL, as shown in the following examples: + # + # A full URI without protocol – http://${stageVariables.} + # A full domain – http://${stageVariables.}/resource/operation + # A subdomain – http://${stageVariables.}.example.com/resource/operation + # A path – http://example.com/${stageVariables.}/bar + # A query string – http://example.com/foo?q=${stageVariables.} + render_vars = {"stageVariables": invocation_context.stage_variables} + rendered_uri = VtlTemplate().render_vtl(uri, render_vars) + + uri = apply_request_parameters( + rendered_uri, + integration=integration, + path_params=path_params, + query_params=query_string_params, + ) + result = requests.request(method=method, url=uri, data=payload, headers=headers) + if not result.ok: + LOG.debug( + "Upstream response from <%s> %s returned with status code: %s", + method, + uri, + result.status_code, + ) + # apply custom response template for non-proxy integration + invocation_context.response = result + if integration["type"] != "HTTP_PROXY": + self.response_templates.render(invocation_context) + return invocation_context.response + + +class SQSIntegration(BackendIntegration): + def invoke(self, invocation_context: ApiInvocationContext): + integration = invocation_context.integration + uri = integration.get("uri") or integration.get("integrationUri") or "" + account_id, queue = uri.split("/")[-2:] + region_name = uri.split(":")[3] + + headers = get_internal_mocked_headers( + service_name="sqs", + region_name=region_name, + role_arn=invocation_context.integration.get("credentials"), + source_arn=get_source_arn(invocation_context), + ) + + # integration parameters can override headers + integration_parameters = self.request_params_resolver.resolve(context=invocation_context) + headers.update(integration_parameters.get("headers", {})) + if "Accept" not in headers: + headers["Accept"] = "application/json" + + if invocation_context.is_websocket_request(): + template_key = self.render_template_selection_expression(invocation_context) + payload = self.request_templates.render(invocation_context, template_key) + else: + payload = self.request_templates.render(invocation_context) + + # not sure what the purpose of this is, but it's in the original code + # TODO: check if this is still needed + if "GetQueueUrl" in payload or "CreateQueue" in payload: + new_request = f"{payload}&QueueName={queue}" + else: + queue_url = f"{config.internal_service_url()}/queue/{region_name}/{account_id}/{queue}" + new_request = f"{payload}&QueueUrl={queue_url}" + + url = urljoin(config.internal_service_url(), f"/queue/{region_name}/{account_id}/{queue}") + response = common.make_http_request(url, method="POST", headers=headers, data=new_request) + + # apply response template + invocation_context.response = response + response._content = self.response_templates.render(invocation_context) + return response + + +class SNSIntegration(BackendIntegration): + def invoke(self, invocation_context: ApiInvocationContext) -> Response: + # TODO: check if the logic below is accurate - cover with snapshot tests! + invocation_context.context = get_event_request_context(invocation_context) + invocation_context.stage_variables = get_stage_variables(invocation_context) + integration = invocation_context.integration + uri = integration.get("uri") or integration.get("integrationUri") or "" + + try: + if invocation_context.is_websocket_request(): + template_key = self.render_template_selection_expression(invocation_context) + payload = self.request_templates.render(invocation_context, template_key) + else: + payload = self.request_templates.render(invocation_context) + except Exception as e: + LOG.warning("Failed to apply template for SNS integration", e) + raise + region_name = uri.split(":")[3] + headers = mock_aws_request_headers( + service="sns", aws_access_key_id=invocation_context.account_id, region_name=region_name + ) + response = make_http_request( + config.internal_service_url(), method="POST", headers=headers, data=payload + ) + + invocation_context.response = response + response._content = self.response_templates.render(invocation_context) + return self.apply_response_parameters(invocation_context, response) + + +class StepFunctionIntegration(BackendIntegration): + @classmethod + def _validate_required_params(cls, request_parameters: Dict[str, Any]) -> None: + if not request_parameters: + raise BadRequestException("Missing required parameters") + # stateMachineArn and input are required + state_machine_arn_param = request_parameters.get("StateMachineArn") + input_param = request_parameters.get("Input") + + if not state_machine_arn_param: + raise BadRequestException("StateMachineArn") + + if not input_param: + raise BadRequestException("Input") + + def invoke(self, invocation_context: ApiInvocationContext): + uri = ( + invocation_context.integration.get("uri") + or invocation_context.integration.get("integrationUri") + or "" + ) + action = uri.split("/")[-1] + + if invocation_context.integration.get("IntegrationType") == "AWS_PROXY": + payload = self._create_request_parameters(invocation_context) + elif APPLICATION_JSON in invocation_context.integration.get("requestTemplates", {}): + payload = self.request_templates.render(invocation_context) + payload = json.loads(payload) + else: + payload = json.loads(invocation_context.data) + + client = get_service_factory( + region_name=invocation_context.region_name, + role_arn=invocation_context.integration.get("credentials"), + ).stepfunctions + + if isinstance(payload.get("input"), dict): + payload["input"] = json.dumps(payload["input"]) + + # Hot fix since step functions local package responses: Unsupported Operation: 'StartSyncExecution' + method_name = ( + camel_to_snake_case(action) if action != "StartSyncExecution" else "start_execution" + ) + + try: + # call method on step function client + method = getattr(client, method_name) + except AttributeError: + msg = f"Invalid step function action: {method_name}" + LOG.error(msg) + return StepFunctionIntegration._create_response( + HTTPStatus.BAD_REQUEST.value, + headers={"Content-Type": APPLICATION_JSON}, + data=json.dumps({"message": msg}), + ) + + result = method(**payload) + result = json_safe(remove_attributes(result, ["ResponseMetadata"])) + response = StepFunctionIntegration._create_response( + HTTPStatus.OK.value, + mock_aws_request_headers( + "stepfunctions", + aws_access_key_id=invocation_context.account_id, + region_name=invocation_context.region_name, + ), + data=json.dumps(result), + ) + if action == "StartSyncExecution": + # poll for the execution result and return it + result = await_sfn_execution_result(result["executionArn"]) + result_status = result.get("status") + if result_status != "SUCCEEDED": + return StepFunctionIntegration._create_response( + HTTPStatus.INTERNAL_SERVER_ERROR.value, + headers={"Content-Type": APPLICATION_JSON}, + data=json.dumps( + { + "message": "StepFunctions execution %s failed with status '%s'" + % (result["executionArn"], result_status) + } + ), + ) + + result = json_safe(result) + response = requests_response(content=result) + + # apply response templates + invocation_context.response = response + response._content = self.response_templates.render(invocation_context) + return response + + def _create_request_parameters(self, invocation_context): + request_parameters = invocation_context.integration.get("requestParameters", {}) + self._validate_required_params(request_parameters) + + variables = { + "request": { + "header": invocation_context.headers, + "querystring": invocation_context.query_params(), + "body": invocation_context.data_as_string(), + "context": invocation_context.context or {}, + "stage_variables": invocation_context.stage_variables or {}, + } + } + rendered_input = VtlTemplate().render_vtl(request_parameters.get("Input"), variables) + return { + "stateMachineArn": request_parameters.get("StateMachineArn"), + "input": rendered_input, + } + + +class MockIntegration(BackendIntegration): + @classmethod + def check_passthrough_behavior(cls, passthrough_behavior: str, request_template: str): + return MappingTemplates(passthrough_behavior).check_passthrough_behavior(request_template) + + def invoke(self, invocation_context: ApiInvocationContext) -> Response: + passthrough_behavior = invocation_context.integration.get("passthroughBehavior") or "" + request_template = invocation_context.integration.get("requestTemplates", {}).get( + invocation_context.headers.get(HEADER_CONTENT_TYPE, APPLICATION_JSON) + ) + + # based on the configured passthrough behavior and the existence of template or not, + # we proceed calling the integration or raise an exception. + try: + self.check_passthrough_behavior(passthrough_behavior, request_template) + except MappingTemplates.UnsupportedMediaType: + return MockIntegration._create_response( + HTTPStatus.UNSUPPORTED_MEDIA_TYPE.value, + headers={"Content-Type": APPLICATION_JSON}, + data=json.dumps({"message": f"{HTTPStatus.UNSUPPORTED_MEDIA_TYPE.phrase}"}), + ) + + # request template rendering + request_payload = self.request_templates.render(invocation_context) + + # mapping is done based on "statusCode" field, we default to 200 + status_code = 200 + if invocation_context.headers.get(HEADER_CONTENT_TYPE) == APPLICATION_JSON: + try: + mock_response = json.loads(request_payload) + status_code = mock_response.get("statusCode", status_code) + except Exception as e: + LOG.warning("failed to deserialize request payload after transformation: %s", e) + http_status = HTTPStatus(500) + return MockIntegration._create_response( + http_status.value, + headers={"Content-Type": APPLICATION_JSON}, + data=json.dumps({"message": f"{http_status.phrase}"}), + ) + + # response template + response = MockIntegration._create_response( + status_code, invocation_context.headers, data=request_payload + ) + response._content = self.response_templates.render(invocation_context, response=response) + # apply response parameters + response = self.apply_response_parameters(invocation_context, response) + if not invocation_context.headers.get(HEADER_CONTENT_TYPE): + invocation_context.headers.update({HEADER_CONTENT_TYPE: APPLICATION_JSON}) + return response + + +# TODO: remove once we migrate all usages to `apply_request_parameters` on BackendIntegration +def apply_request_parameters( + uri: str, integration: Dict[str, Any], path_params: Dict[str, str], query_params: Dict[str, str] +): + request_parameters = integration.get("requestParameters") + uri = uri or integration.get("uri") or integration.get("integrationUri") or "" + if request_parameters: + for key in path_params: + # check if path_params is present in the integration request parameters + request_param_key = f"integration.request.path.{key}" + request_param_value = f"method.request.path.{key}" + if request_parameters.get(request_param_key) == request_param_value: + uri = uri.replace(f"{{{key}}}", path_params[key]) + + if integration.get("type") != "HTTP_PROXY" and request_parameters: + for key in query_params.copy(): + request_query_key = f"integration.request.querystring.{key}" + request_param_val = f"method.request.querystring.{key}" + if request_parameters.get(request_query_key, None) != request_param_val: + query_params.pop(key) + + return add_query_params_to_url(uri, query_params) + + +class EventBridgeIntegration(BackendIntegration): + def invoke(self, invocation_context: ApiInvocationContext): + invocation_context.context = get_event_request_context(invocation_context) + try: + payload = self.request_templates.render(invocation_context) + except Exception as e: + LOG.warning("Failed to apply template for EventBridge integration: %s", e) + raise + uri = ( + invocation_context.integration.get("uri") + or invocation_context.integration.get("integrationUri") + or "" + ) + region_name = uri.split(":")[3] + headers = get_internal_mocked_headers( + service_name="events", + region_name=region_name, + role_arn=invocation_context.integration.get("credentials"), + source_arn=get_source_arn(invocation_context), + ) + headers.update({"X-Amz-Target": invocation_context.headers.get("X-Amz-Target")}) + response = make_http_request( + config.internal_service_url(), method="POST", headers=headers, data=payload + ) + + invocation_context.response = response + + self.response_templates.render(invocation_context) + invocation_context.response.headers["Content-Length"] = str(len(response.content or "")) + return invocation_context.response diff --git a/localstack-core/localstack/services/apigateway/legacy/invocations.py b/localstack-core/localstack/services/apigateway/legacy/invocations.py new file mode 100644 index 0000000000000..18085fc52e22e --- /dev/null +++ b/localstack-core/localstack/services/apigateway/legacy/invocations.py @@ -0,0 +1,400 @@ +import json +import logging +import re + +from jsonschema import ValidationError, validate +from requests.models import Response +from werkzeug.exceptions import NotFound + +from localstack.aws.connect import connect_to +from localstack.constants import APPLICATION_JSON +from localstack.services.apigateway.helpers import ( + EMPTY_MODEL, + ModelResolver, + get_apigateway_store_for_invocation, +) +from localstack.services.apigateway.legacy.context import ApiInvocationContext +from localstack.services.apigateway.legacy.helpers import ( + get_cors_response, + get_event_request_context, + get_target_resource_details, + make_error_response, + set_api_id_stage_invocation_path, +) +from localstack.services.apigateway.legacy.integration import ( + ApiGatewayIntegrationError, + DynamoDBIntegration, + EventBridgeIntegration, + HTTPIntegration, + KinesisIntegration, + LambdaIntegration, + LambdaProxyIntegration, + MockIntegration, + S3Integration, + SNSIntegration, + SQSIntegration, + StepFunctionIntegration, +) +from localstack.services.apigateway.models import ApiGatewayStore +from localstack.utils.aws.arns import ARN_PARTITION_REGEX +from localstack.utils.aws.aws_responses import requests_response + +LOG = logging.getLogger(__name__) + + +class AuthorizationError(Exception): + message: str + status_code: int + + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.message = message + self.status_code = status_code + + def to_response(self): + return requests_response({"message": self.message}, status_code=self.status_code) + + +# we separate those 2 exceptions to allow better GatewayResponse support later on +class BadRequestParameters(Exception): + message: str + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + def to_response(self): + return requests_response({"message": self.message}, status_code=400) + + +class BadRequestBody(Exception): + message: str + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + def to_response(self): + return requests_response({"message": self.message}, status_code=400) + + +class RequestValidator: + __slots__ = ["context", "rest_api_container"] + + def __init__(self, context: ApiInvocationContext, store: ApiGatewayStore = None): + self.context = context + store = store or get_apigateway_store_for_invocation(context=context) + if not (container := store.rest_apis.get(context.api_id)): + # TODO: find the right exception + raise NotFound() + self.rest_api_container = container + + def validate_request(self) -> None: + """ + :raises BadRequestParameters if the request has required parameters which are not present + :raises BadRequestBody if the request has required body validation with a model and it does not respect it + :return: None + """ + # make all the positive checks first + if self.context.resource is None or "resourceMethods" not in self.context.resource: + return + + resource_methods = self.context.resource["resourceMethods"] + if self.context.method not in resource_methods and "ANY" not in resource_methods: + return + + # check if there is validator for the resource + resource = resource_methods.get(self.context.method, resource_methods.get("ANY", {})) + if not (resource.get("requestValidatorId") or "").strip(): + return + + # check if there is a validator for this request + validator = self.rest_api_container.validators.get(resource["requestValidatorId"]) + if not validator: + return + + if self.should_validate_request(validator) and ( + missing_parameters := self._get_missing_required_parameters(resource) + ): + message = f"Missing required request parameters: [{', '.join(missing_parameters)}]" + raise BadRequestParameters(message=message) + + if self.should_validate_body(validator) and not self._is_body_valid(resource): + raise BadRequestBody(message="Invalid request body") + + return + + def _is_body_valid(self, resource) -> bool: + # if there's no model to validate the body, use the Empty model + # https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-apigateway.EmptyModel.html + if not (request_models := resource.get("requestModels")): + model_name = EMPTY_MODEL + else: + model_name = request_models.get( + APPLICATION_JSON, request_models.get("$default", EMPTY_MODEL) + ) + + model_resolver = ModelResolver( + rest_api_container=self.rest_api_container, + model_name=model_name, + ) + + # try to get the resolved model first + resolved_schema = model_resolver.get_resolved_model() + if not resolved_schema: + LOG.exception( + "An exception occurred while trying to validate the request: could not find the model" + ) + return False + + try: + # if the body is empty, replace it with an empty JSON body + validate( + instance=json.loads(self.context.data or "{}"), + schema=resolved_schema, + ) + return True + except ValidationError as e: + LOG.warning("failed to validate request body %s", e) + return False + except json.JSONDecodeError as e: + LOG.warning("failed to validate request body, request data is not valid JSON %s", e) + return False + + def _get_missing_required_parameters(self, resource) -> list[str]: + missing_params = [] + if not (request_parameters := resource.get("requestParameters")): + return missing_params + + for request_parameter, required in sorted(request_parameters.items()): + if not required: + continue + + param_type, param_value = request_parameter.removeprefix("method.request.").split(".") + match param_type: + case "header": + is_missing = param_value not in self.context.headers + case "path": + is_missing = param_value not in self.context.resource_path + case "querystring": + is_missing = param_value not in self.context.query_params() + case _: + # TODO: method.request.body is not specified in the documentation, and requestModels should do it + # verify this + is_missing = False + + if is_missing: + missing_params.append(param_value) + + return missing_params + + @staticmethod + def should_validate_body(validator): + return validator["validateRequestBody"] + + @staticmethod + def should_validate_request(validator): + return validator.get("validateRequestParameters") + + +# ------------ +# API METHODS +# ------------ + + +def validate_api_key(api_key: str, invocation_context: ApiInvocationContext): + usage_plan_ids = [] + client = connect_to( + aws_access_key_id=invocation_context.account_id, region_name=invocation_context.region_name + ).apigateway + + usage_plans = client.get_usage_plans() + for item in usage_plans.get("items", []): + api_stages = item.get("apiStages", []) + usage_plan_ids.extend( + item.get("id") + for api_stage in api_stages + if ( + api_stage.get("stage") == invocation_context.stage + and api_stage.get("apiId") == invocation_context.api_id + ) + ) + for usage_plan_id in usage_plan_ids: + usage_plan_keys = client.get_usage_plan_keys(usagePlanId=usage_plan_id) + for key in usage_plan_keys.get("items", []): + if key.get("value") == api_key: + # check if the key is enabled + api_key = client.get_api_key(apiKey=key.get("id")) + return api_key.get("enabled") in ("true", True) + + return False + + +def is_api_key_valid(invocation_context: ApiInvocationContext) -> bool: + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-key-source.html + client = connect_to( + aws_access_key_id=invocation_context.account_id, region_name=invocation_context.region_name + ).apigateway + rest_api = client.get_rest_api(restApiId=invocation_context.api_id) + + # The source of the API key for metering requests according to a usage plan. + # Valid values are: + # - HEADER to read the API key from the X-API-Key header of a request. + # - AUTHORIZER to read the API key from the UsageIdentifierKey from a custom authorizer. + + api_key_source = rest_api.get("apiKeySource") + match api_key_source: + case "HEADER": + api_key = invocation_context.headers.get("X-API-Key") + return validate_api_key(api_key, invocation_context) if api_key else False + case "AUTHORIZER": + api_key = invocation_context.auth_identity.get("apiKey") + return validate_api_key(api_key, invocation_context) if api_key else False + + +def update_content_length(response: Response): + if response and response.content is not None: + response.headers["Content-Length"] = str(len(response.content)) + + +def invoke_rest_api_from_request(invocation_context: ApiInvocationContext): + set_api_id_stage_invocation_path(invocation_context) + try: + return invoke_rest_api(invocation_context) + except AuthorizationError as e: + LOG.warning( + "Authorization error while invoking API Gateway ID %s: %s", + invocation_context.api_id, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + return e.to_response() + + +def invoke_rest_api(invocation_context: ApiInvocationContext): + invocation_path = invocation_context.path_with_query_string + raw_path = invocation_context.path or invocation_path + method = invocation_context.method + headers = invocation_context.headers + + extracted_path, resource = get_target_resource_details(invocation_context) + if not resource: + return make_error_response("Unable to find path %s" % invocation_context.path, 404) + + # validate request + validator = RequestValidator(invocation_context) + try: + validator.validate_request() + except (BadRequestParameters, BadRequestBody) as e: + return e.to_response() + + api_key_required = resource.get("resourceMethods", {}).get(method, {}).get("apiKeyRequired") + if api_key_required and not is_api_key_valid(invocation_context): + raise AuthorizationError("Forbidden", 403) + + resource_methods = resource.get("resourceMethods", {}) + resource_method = resource_methods.get(method, {}) + if not resource_method: + # HttpMethod: '*' + # ResourcePath: '/*' - produces 'X-AMAZON-APIGATEWAY-ANY-METHOD' + resource_method = resource_methods.get("ANY", {}) or resource_methods.get( + "X-AMAZON-APIGATEWAY-ANY-METHOD", {} + ) + method_integration = resource_method.get("methodIntegration") + if not method_integration: + if method == "OPTIONS" and "Origin" in headers: + # default to returning CORS headers if this is an OPTIONS request + return get_cors_response(headers) + return make_error_response( + "Unable to find integration for: %s %s (%s)" % (method, invocation_path, raw_path), + 404, + ) + + # update fields in invocation context, then forward request to next handler + invocation_context.resource_path = extracted_path + invocation_context.integration = method_integration + + return invoke_rest_api_integration(invocation_context) + + +def invoke_rest_api_integration(invocation_context: ApiInvocationContext): + try: + response = invoke_rest_api_integration_backend(invocation_context) + # TODO remove this setter once all the integrations are migrated to the new response + # handling + invocation_context.response = response + return response + except ApiGatewayIntegrationError as e: + LOG.warning( + "Error while invoking integration for ApiGateway ID %s: %s", + invocation_context.api_id, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + return e.to_response() + except Exception as e: + msg = f"Error invoking integration for API Gateway ID '{invocation_context.api_id}': {e}" + LOG.exception(msg) + return make_error_response(msg, 400) + + +# This function is patched downstream for backend integrations that are only available +# in Pro (potentially to be replaced with a runtime hook in the future). +def invoke_rest_api_integration_backend(invocation_context: ApiInvocationContext): + # define local aliases from invocation context + method = invocation_context.method + headers = invocation_context.headers + integration = invocation_context.integration + integration_type_orig = integration.get("type") or integration.get("integrationType") or "" + integration_type = integration_type_orig.upper() + integration_method = integration.get("httpMethod") + uri = integration.get("uri") or integration.get("integrationUri") or "" + + if (re.match(f"{ARN_PARTITION_REGEX}:apigateway:", uri) and ":lambda:path" in uri) or re.match( + f"{ARN_PARTITION_REGEX}:lambda", uri + ): + invocation_context.context = get_event_request_context(invocation_context) + if integration_type == "AWS_PROXY": + return LambdaProxyIntegration().invoke(invocation_context) + elif integration_type == "AWS": + return LambdaIntegration().invoke(invocation_context) + + elif integration_type == "AWS": + if "kinesis:action/" in uri: + return KinesisIntegration().invoke(invocation_context) + + if "states:action/" in uri: + return StepFunctionIntegration().invoke(invocation_context) + + if ":dynamodb:action" in uri: + return DynamoDBIntegration().invoke(invocation_context) + + if "s3:path/" in uri or "s3:action/" in uri: + return S3Integration().invoke(invocation_context) + + if integration_method == "POST" and ":sqs:path" in uri: + return SQSIntegration().invoke(invocation_context) + + if method == "POST" and ":sns:path" in uri: + return SNSIntegration().invoke(invocation_context) + + if ( + method == "POST" + and re.match(f"{ARN_PARTITION_REGEX}:apigateway:", uri) + and "events:action/PutEvents" in uri + ): + return EventBridgeIntegration().invoke(invocation_context) + + elif integration_type in ["HTTP_PROXY", "HTTP"]: + return HTTPIntegration().invoke(invocation_context) + + elif integration_type == "MOCK": + return MockIntegration().invoke(invocation_context) + + if method == "OPTIONS": + # fall back to returning CORS headers if this is an OPTIONS request + return get_cors_response(headers) + + raise Exception( + f'API Gateway integration type "{integration_type}", method "{method}", URI "{uri}" not yet implemented' + ) diff --git a/localstack-core/localstack/services/apigateway/legacy/provider.py b/localstack-core/localstack/services/apigateway/legacy/provider.py new file mode 100644 index 0000000000000..846a965628402 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/legacy/provider.py @@ -0,0 +1,3266 @@ +import copy +import io +import json +import logging +import re +from copy import deepcopy +from datetime import datetime +from typing import IO, Any + +from moto.apigateway import models as apigw_models +from moto.apigateway.models import Resource as MotoResource +from moto.apigateway.models import RestAPI as MotoRestAPI +from moto.core.utils import camelcase_to_underscores + +from localstack.aws.api import CommonServiceException, RequestContext, ServiceRequest, handler +from localstack.aws.api.apigateway import ( + Account, + ApigatewayApi, + ApiKey, + ApiKeys, + Authorizer, + Authorizers, + BadRequestException, + BasePathMapping, + BasePathMappings, + Blob, + Boolean, + ClientCertificate, + ClientCertificates, + ConflictException, + ConnectionType, + CreateAuthorizerRequest, + CreateRestApiRequest, + CreateStageRequest, + Deployment, + DocumentationPart, + DocumentationPartIds, + DocumentationPartLocation, + DocumentationParts, + DocumentationVersion, + DocumentationVersions, + DomainName, + DomainNames, + DomainNameStatus, + EndpointConfiguration, + EndpointType, + ExportResponse, + GatewayResponse, + GatewayResponses, + GatewayResponseType, + GetDocumentationPartsRequest, + Integration, + IntegrationResponse, + IntegrationType, + IpAddressType, + ListOfApiStage, + ListOfPatchOperation, + ListOfStageKeys, + ListOfString, + MapOfStringToBoolean, + MapOfStringToString, + Method, + MethodResponse, + Model, + Models, + MutualTlsAuthenticationInput, + NotFoundException, + NullableBoolean, + NullableInteger, + PutIntegrationRequest, + PutIntegrationResponseRequest, + PutMode, + PutRestApiRequest, + QuotaSettings, + RequestValidator, + RequestValidators, + Resource, + ResourceOwner, + RestApi, + RestApis, + RoutingMode, + SecurityPolicy, + Stage, + Stages, + StatusCode, + String, + Tags, + TestInvokeMethodRequest, + TestInvokeMethodResponse, + ThrottleSettings, + UsagePlan, + UsagePlanKeys, + UsagePlans, + VpcLink, + VpcLinks, +) +from localstack.aws.connect import connect_to +from localstack.aws.forwarder import create_aws_request_context +from localstack.constants import APPLICATION_JSON +from localstack.services.apigateway.exporter import OpenApiExporter +from localstack.services.apigateway.helpers import ( + EMPTY_MODEL, + ERROR_MODEL, + INVOKE_TEST_LOG_TEMPLATE, + OpenAPIExt, + apply_json_patch_safe, + get_apigateway_store, + get_moto_backend, + get_moto_rest_api, + get_regional_domain_name, + get_rest_api_container, + import_api_from_openapi_spec, + is_greedy_path, + is_variable_path, + resolve_references, +) +from localstack.services.apigateway.legacy.helpers import multi_value_dict_for_list +from localstack.services.apigateway.legacy.invocations import invoke_rest_api_from_request +from localstack.services.apigateway.legacy.router_asf import ApigatewayRouter, to_invocation_context +from localstack.services.apigateway.models import ApiGatewayStore, RestApiContainer +from localstack.services.apigateway.next_gen.execute_api.router import ( + ApiGatewayRouter as ApiGatewayRouterNextGen, +) +from localstack.services.apigateway.patches import apply_patches +from localstack.services.edge import ROUTER +from localstack.services.moto import call_moto, call_moto_with_request +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.aws.arns import InvalidArnException, get_partition, parse_arn +from localstack.utils.collections import ( + DelSafeDict, + PaginatedList, + ensure_list, + select_from_typed_dict, +) +from localstack.utils.json import parse_json_or_yaml +from localstack.utils.strings import md5, short_uid, str_to_bool, to_bytes, to_str +from localstack.utils.time import TIMESTAMP_FORMAT_TZ, now_utc, timestamp + +LOG = logging.getLogger(__name__) + +# list of valid paths for Stage update patch operations (extracted from AWS responses via snapshot tests) +STAGE_UPDATE_PATHS = [ + "/deploymentId", + "/description", + "/cacheClusterEnabled", + "/cacheClusterSize", + "/clientCertificateId", + "/accessLogSettings", + "/accessLogSettings/destinationArn", + "/accessLogSettings/format", + "/{resourcePath}/{httpMethod}/metrics/enabled", + "/{resourcePath}/{httpMethod}/logging/dataTrace", + "/{resourcePath}/{httpMethod}/logging/loglevel", + "/{resourcePath}/{httpMethod}/throttling/burstLimit", + "/{resourcePath}/{httpMethod}/throttling/rateLimit", + "/{resourcePath}/{httpMethod}/caching/ttlInSeconds", + "/{resourcePath}/{httpMethod}/caching/enabled", + "/{resourcePath}/{httpMethod}/caching/dataEncrypted", + "/{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl", + "/{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy", + "/*/*/metrics/enabled", + "/*/*/logging/dataTrace", + "/*/*/logging/loglevel", + "/*/*/throttling/burstLimit", + "/*/*/throttling/rateLimit", + "/*/*/caching/ttlInSeconds", + "/*/*/caching/enabled", + "/*/*/caching/dataEncrypted", + "/*/*/caching/requireAuthorizationForCacheControl", + "/*/*/caching/unauthorizedCacheControlHeaderStrategy", + "/variables/{variable_name}", + "/tracingEnabled", +] + +VALID_INTEGRATION_TYPES = { + IntegrationType.AWS, + IntegrationType.AWS_PROXY, + IntegrationType.HTTP, + IntegrationType.HTTP_PROXY, + IntegrationType.MOCK, +} + + +class ApigatewayProvider(ApigatewayApi, ServiceLifecycleHook): + router: ApigatewayRouter | ApiGatewayRouterNextGen + + def __init__(self, router: ApigatewayRouter | ApiGatewayRouterNextGen = None): + self.router = router or ApigatewayRouter(ROUTER) + + def on_after_init(self): + apply_patches() + self.router.register_routes() + + @handler("TestInvokeMethod", expand=False) + def test_invoke_method( + self, context: RequestContext, request: TestInvokeMethodRequest + ) -> TestInvokeMethodResponse: + invocation_context = to_invocation_context(context.request) + invocation_context.method = request.get("httpMethod") + invocation_context.api_id = request.get("restApiId") + invocation_context.path_with_query_string = request.get("pathWithQueryString") + invocation_context.region_name = context.region + invocation_context.account_id = context.account_id + + moto_rest_api = get_moto_rest_api(context=context, rest_api_id=invocation_context.api_id) + resource = moto_rest_api.resources.get(request["resourceId"]) + if not resource: + raise NotFoundException("Invalid Resource identifier specified") + + invocation_context.resource = {"id": resource.id} + invocation_context.resource_path = resource.path_part + + if data := parse_json_or_yaml(to_str(invocation_context.data or b"")): + invocation_context.data = data.get("body") + invocation_context.headers = data.get("headers", {}) + + req_start_time = datetime.now() + result = invoke_rest_api_from_request(invocation_context) + req_end_time = datetime.now() + + # TODO: add the missing fields to the log. Next iteration will add helpers to extract the missing fields + # from the apicontext + formatted_date = req_start_time.strftime("%a %b %d %H:%M:%S %Z %Y") + log = INVOKE_TEST_LOG_TEMPLATE.format( + request_id=invocation_context.context["requestId"], + formatted_date=formatted_date, + http_method=invocation_context.method, + resource_path=invocation_context.invocation_path, + request_path="", + query_string="", + request_headers="", + request_body="", + response_body="", + response_headers=result.headers, + status_code=result.status_code, + ) + + return TestInvokeMethodResponse( + status=result.status_code, + headers=dict(result.headers), + body=to_str(result.content), + log=log, + latency=int((req_end_time - req_start_time).total_seconds()), + multiValueHeaders=multi_value_dict_for_list(result.headers), + ) + + @handler("CreateRestApi", expand=False) + def create_rest_api(self, context: RequestContext, request: CreateRestApiRequest) -> RestApi: + endpoint_configuration = request.get("endpointConfiguration", {}) + types = endpoint_configuration.get("types", [EndpointType.EDGE]) + ip_address_type = endpoint_configuration.get("ipAddressType") + + if not types: + raise BadRequestException( + "REGIONAL Configuration and EDGE Configuration cannot be both DISABLED." + ) + elif len(types) > 1: + raise BadRequestException("Cannot create an api with multiple Endpoint Types.") + endpoint_type = types[0] + + error_messages = [] + if endpoint_type not in (EndpointType.PRIVATE, EndpointType.EDGE, EndpointType.REGIONAL): + error_messages.append( + f"Value '[{endpoint_type}]' at 'createRestApiInput.endpointConfiguration.types' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [PRIVATE, EDGE, REGIONAL]]", + ) + elif not ip_address_type: + if endpoint_type in (EndpointType.EDGE, EndpointType.REGIONAL): + ip_address_type = IpAddressType.ipv4 + else: + ip_address_type = IpAddressType.dualstack + + if ip_address_type not in (IpAddressType.ipv4, IpAddressType.dualstack, None): + error_messages.append( + f"Value '{ip_address_type}' at 'createRestApiInput.endpointConfiguration.ipAddressType' failed to satisfy constraint: Member must satisfy enum value set: [ipv4, dualstack]", + ) + if error_messages: + prefix = f"{len(error_messages)} validation error{'s' if len(error_messages) > 1 else ''} detected: " + raise CommonServiceException( + code="ValidationException", + message=prefix + "; ".join(error_messages), + ) + if request.get("description") == "": + raise BadRequestException("Description cannot be an empty string") + if types == [EndpointType.PRIVATE] and ip_address_type == IpAddressType.ipv4: + raise BadRequestException("Only dualstack ipAddressType is supported for Private APIs.") + + minimum_compression_size = request.get("minimumCompressionSize") + if minimum_compression_size is not None and ( + minimum_compression_size < 0 or minimum_compression_size > 10485760 + ): + raise BadRequestException( + "Invalid minimum compression size, must be between 0 and 10485760" + ) + + result = call_moto(context) + rest_api = get_moto_rest_api(context, rest_api_id=result["id"]) + rest_api.version = request.get("version") + if binary_media_types := request.get("binaryMediaTypes"): + rest_api.binaryMediaTypes = binary_media_types + + response: RestApi = rest_api.to_dict() + response["endpointConfiguration"]["ipAddressType"] = ip_address_type + remove_empty_attributes_from_rest_api(response) + store = get_apigateway_store(context=context) + rest_api_container = RestApiContainer(rest_api=response) + store.rest_apis[result["id"]] = rest_api_container + # add the 2 default models + rest_api_container.models[EMPTY_MODEL] = DEFAULT_EMPTY_MODEL + rest_api_container.models[ERROR_MODEL] = DEFAULT_ERROR_MODEL + + return response + + def create_api_key( + self, + context: RequestContext, + name: String = None, + description: String = None, + enabled: Boolean = None, + generate_distinct_id: Boolean = None, + value: String = None, + stage_keys: ListOfStageKeys = None, + customer_id: String = None, + tags: MapOfStringToString = None, + **kwargs, + ) -> ApiKey: + api_key = call_moto(context) + + # transform array of stage keys [{'restApiId': '0iscapk09u', 'stageName': 'dev'}] into + # array of strings ['0iscapk09u/dev'] + stage_keys = api_key.get("stageKeys", []) + api_key["stageKeys"] = [f"{sk['restApiId']}/{sk['stageName']}" for sk in stage_keys] + + return api_key + + def get_rest_api(self, context: RequestContext, rest_api_id: String, **kwargs) -> RestApi: + rest_api: RestApi = call_moto(context) + remove_empty_attributes_from_rest_api(rest_api) + return rest_api + + def update_rest_api( + self, + context: RequestContext, + rest_api_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> RestApi: + rest_api = get_moto_rest_api(context, rest_api_id) + + fixed_patch_ops = [] + binary_media_types_path = "/binaryMediaTypes" + # TODO: validate a bit more patch operations + for patch_op in patch_operations: + if patch_op["op"] not in ("add", "remove", "move", "test", "replace", "copy"): + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{patch_op['op']}' at 'updateRestApiInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]", + ) + patch_op_path = patch_op.get("path", "") + # binaryMediaTypes has a specific way of being set + # see https://docs.aws.amazon.com/apigateway/latest/api/API_PatchOperation.html + # TODO: maybe implement a more generalized way if this happens anywhere else + if patch_op_path.startswith(binary_media_types_path): + if patch_op_path == binary_media_types_path: + raise BadRequestException(f"Invalid patch path {patch_op_path}") + value = patch_op_path.rsplit("/", maxsplit=1)[-1] + path_value = value.replace("~1", "/") + patch_op["path"] = binary_media_types_path + + if patch_op["op"] == "add": + patch_op["value"] = path_value + + elif patch_op["op"] == "remove": + remove_index = rest_api.binaryMediaTypes.index(path_value) + patch_op["path"] = f"{binary_media_types_path}/{remove_index}" + + elif patch_op["op"] == "replace": + # AWS is behaving weirdly, and will actually remove/add instead of replacing in place + # it will put the replaced value last in the array + replace_index = rest_api.binaryMediaTypes.index(path_value) + fixed_patch_ops.append( + {"op": "remove", "path": f"{binary_media_types_path}/{replace_index}"} + ) + patch_op["op"] = "add" + + elif patch_op_path == "/minimumCompressionSize": + if patch_op["op"] != "replace": + raise BadRequestException( + "Invalid patch operation specified. Must be one of: [replace]" + ) + + try: + # try to cast the value to integer if truthy, else reject + value = int(val) if (val := patch_op.get("value")) else None + except ValueError: + raise BadRequestException( + "Invalid minimum compression size, must be between 0 and 10485760" + ) + + if value is not None and (value < 0 or value > 10485760): + raise BadRequestException( + "Invalid minimum compression size, must be between 0 and 10485760" + ) + patch_op["value"] = value + + elif patch_op_path.startswith("/endpointConfiguration/types"): + if patch_op["op"] != "replace": + raise BadRequestException( + "Invalid patch operation specified. Must be 'add'|'remove'|'replace'" + ) + if patch_op.get("value") not in ( + EndpointType.REGIONAL, + EndpointType.EDGE, + EndpointType.PRIVATE, + ): + raise BadRequestException( + "Invalid EndpointTypes specified. Valid options are REGIONAL,EDGE,PRIVATE" + ) + if patch_op.get("value") == EndpointType.PRIVATE: + fixed_patch_ops.append(patch_op) + patch_op = { + "op": "replace", + "path": "/endpointConfiguration/ipAddressType", + "value": IpAddressType.dualstack, + } + fixed_patch_ops.append(patch_op) + continue + + elif patch_op_path.startswith("/endpointConfiguration/ipAddressType"): + if patch_op["op"] != "replace": + raise BadRequestException( + "Invalid patch operation specified. Must be one of: [replace]" + ) + if (ipAddressType := patch_op.get("value")) not in ( + IpAddressType.ipv4, + IpAddressType.dualstack, + ): + raise BadRequestException("ipAddressType must be either ipv4 or dualstack.") + if ( + rest_api.endpoint_configuration["types"] == [EndpointType.PRIVATE] + and ipAddressType == IpAddressType.ipv4 + ): + raise BadRequestException( + "Only dualstack ipAddressType is supported for Private APIs." + ) + + fixed_patch_ops.append(patch_op) + + patch_api_gateway_entity(rest_api, fixed_patch_ops) + + # fix data types after patches have been applied + endpoint_configs = rest_api.endpoint_configuration or {} + if isinstance(endpoint_configs.get("vpcEndpointIds"), str): + endpoint_configs["vpcEndpointIds"] = [endpoint_configs["vpcEndpointIds"]] + + # minimum_compression_size is a unique path as it's a nullable integer, + # it would throw an error if it stays an empty string + if rest_api.minimum_compression_size == "": + rest_api.minimum_compression_size = None + + response = rest_api.to_dict() + + remove_empty_attributes_from_rest_api(response, remove_tags=False) + store = get_apigateway_store(context=context) + store.rest_apis[rest_api_id].rest_api = response + return response + + @handler("PutRestApi", expand=False) + def put_rest_api(self, context: RequestContext, request: PutRestApiRequest) -> RestApi: + # TODO: take into account the mode: overwrite or merge + # the default is now `merge`, but we are removing everything + rest_api = get_moto_rest_api(context, request["restApiId"]) + rest_api, warnings = import_api_from_openapi_spec( + rest_api, context=context, request=request + ) + + rest_api.root_resource_id = get_moto_rest_api_root_resource(rest_api) + response = rest_api.to_dict() + remove_empty_attributes_from_rest_api(response) + store = get_apigateway_store(context=context) + store.rest_apis[request["restApiId"]].rest_api = response + # TODO: verify this + response = to_rest_api_response_json(response) + response.setdefault("tags", {}) + + # TODO Failing still keeps all applied mutations. We need to revert to the previous state instead + if warnings: + response["warnings"] = warnings + + return response + + @handler("CreateDomainName") + def create_domain_name( + self, + context: RequestContext, + domain_name: String, + certificate_name: String = None, + certificate_body: String = None, + certificate_private_key: String = None, + certificate_chain: String = None, + certificate_arn: String = None, + regional_certificate_name: String = None, + regional_certificate_arn: String = None, + endpoint_configuration: EndpointConfiguration = None, + tags: MapOfStringToString = None, + security_policy: SecurityPolicy = None, + mutual_tls_authentication: MutualTlsAuthenticationInput = None, + ownership_verification_certificate_arn: String = None, + policy: String = None, + routing_mode: RoutingMode = None, + **kwargs, + ) -> DomainName: + if not domain_name: + raise BadRequestException("No Domain Name specified") + + store: ApiGatewayStore = get_apigateway_store(context=context) + if store.domain_names.get(domain_name): + raise ConflictException(f"Domain name with ID {domain_name} already exists") + + # find matching hosted zone + zone_id = None + # TODO check if this call is IAM enforced + route53 = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).route53 + hosted_zones = route53.list_hosted_zones().get("HostedZones", []) + hosted_zones = [hz for hz in hosted_zones if domain_name.endswith(hz["Name"].strip("."))] + zone_id = hosted_zones[0]["Id"].replace("/hostedzone/", "") if hosted_zones else zone_id + + domain: DomainName = DomainName( + domainName=domain_name, + certificateName=certificate_name, + certificateArn=certificate_arn, + regionalDomainName=get_regional_domain_name(domain_name), + domainNameStatus=DomainNameStatus.AVAILABLE, + regionalHostedZoneId=zone_id, + regionalCertificateName=regional_certificate_name, + regionalCertificateArn=regional_certificate_arn, + securityPolicy=SecurityPolicy.TLS_1_2, + endpointConfiguration=endpoint_configuration, + routingMode=routing_mode, + ) + store.domain_names[domain_name] = domain + return domain + + @handler("GetDomainName") + def get_domain_name( + self, context: RequestContext, domain_name: String, domain_name_id: String = None, **kwargs + ) -> DomainName: + store: ApiGatewayStore = get_apigateway_store(context=context) + if domain := store.domain_names.get(domain_name): + return domain + raise NotFoundException("Invalid domain name identifier specified") + + @handler("GetDomainNames") + def get_domain_names( + self, + context: RequestContext, + position: String = None, + limit: NullableInteger = None, + resource_owner: ResourceOwner = None, + **kwargs, + ) -> DomainNames: + store = get_apigateway_store(context=context) + domain_names = store.domain_names.values() + return DomainNames(items=list(domain_names), position=position) + + @handler("DeleteDomainName") + def delete_domain_name( + self, context: RequestContext, domain_name: String, domain_name_id: String = None, **kwargs + ) -> None: + store: ApiGatewayStore = get_apigateway_store(context=context) + if not store.domain_names.pop(domain_name, None): + raise NotFoundException("Invalid domain name identifier specified") + + def delete_rest_api(self, context: RequestContext, rest_api_id: String, **kwargs) -> None: + try: + store = get_apigateway_store(context=context) + store.rest_apis.pop(rest_api_id, None) + call_moto(context) + except KeyError as e: + # moto raises a key error if we're trying to delete an API that doesn't exist + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) from e + + def get_rest_apis( + self, + context: RequestContext, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> RestApis: + response: RestApis = call_moto(context) + for rest_api in response["items"]: + remove_empty_attributes_from_rest_api(rest_api) + return response + + # resources + + def create_resource( + self, + context: RequestContext, + rest_api_id: String, + parent_id: String, + path_part: String, + **kwargs, + ) -> Resource: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + parent_moto_resource: MotoResource = moto_rest_api.resources.get(parent_id, None) + # validate here if the parent exists. Moto would first create then validate, which would lead to the resource + # being created anyway + if not parent_moto_resource: + raise NotFoundException("Invalid Resource identifier specified") + + parent_path = parent_moto_resource.path_part + if is_greedy_path(parent_path): + raise BadRequestException( + f"Cannot create a child of a resource with a greedy path variable: {parent_path}" + ) + + store = get_apigateway_store(context=context) + rest_api = store.rest_apis.get(rest_api_id) + children = rest_api.resource_children.setdefault(parent_id, []) + + if is_variable_path(path_part): + for sibling in children: + sibling_resource: MotoResource = moto_rest_api.resources.get(sibling, None) + if is_variable_path(sibling_resource.path_part): + raise BadRequestException( + f"A sibling ({sibling_resource.path_part}) of this resource already has a variable path part -- only one is allowed" + ) + + response: Resource = call_moto(context) + + # save children to allow easy deletion of all children if we delete a parent route + children.append(response["id"]) + + return response + + def delete_resource( + self, context: RequestContext, rest_api_id: String, resource_id: String, **kwargs + ) -> None: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + + moto_resource: MotoResource = moto_rest_api.resources.pop(resource_id, None) + if not moto_resource: + raise NotFoundException("Invalid Resource identifier specified") + + store = get_apigateway_store(context=context) + rest_api = store.rest_apis.get(rest_api_id) + api_resources = rest_api.resource_children + # we need to recursively delete all children resources of the resource we're deleting + + def _delete_children(resource_to_delete: str): + children = api_resources.get(resource_to_delete, []) + for child in children: + moto_rest_api.resources.pop(child) + _delete_children(child) + + api_resources.pop(resource_to_delete, None) + + _delete_children(resource_id) + + # remove the resource as a child from its parent + parent_id = moto_resource.parent_id + api_resources[parent_id].remove(resource_id) + + def update_integration_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> IntegrationResponse: + # XXX: THIS IS NOT A COMPLETE IMPLEMENTATION, just the minimum required to get tests going + # TODO: validate patch operations + + moto_rest_api = get_moto_rest_api(context, rest_api_id) + moto_resource = moto_rest_api.resources.get(resource_id) + if not moto_resource: + raise NotFoundException("Invalid Resource identifier specified") + + moto_method = moto_resource.resource_methods.get(http_method) + if not moto_method: + raise NotFoundException("Invalid Method identifier specified") + + integration_response = moto_method.method_integration.integration_responses.get(status_code) + if not integration_response: + raise NotFoundException("Invalid Integration Response identifier specified") + + for patch_operation in patch_operations: + op = patch_operation.get("op") + path = patch_operation.get("path") + + # for path "/responseTemplates/application~1json" + if "/responseTemplates" in path: + integration_response.response_templates = ( + integration_response.response_templates or {} + ) + value = patch_operation.get("value") + if not isinstance(value, str): + raise BadRequestException( + f"Invalid patch value '{value}' specified for op '{op}'. Must be a string" + ) + param = path.removeprefix("/responseTemplates/") + param = param.replace("~1", "/") + if op == "remove": + integration_response.response_templates.pop(param) + elif op in ("add", "replace"): + integration_response.response_templates[param] = value + + elif "/contentHandling" in path and op == "replace": + integration_response.content_handling = patch_operation.get("value") + + elif "/selectionPattern" in path and op == "replace": + integration_response.selection_pattern = patch_operation.get("value") + + response: IntegrationResponse = integration_response.to_json() + # in case it's empty, we still want to pass it on as "" + # TODO: add a test case for this + response["selectionPattern"] = integration_response.selection_pattern + + return response + + def update_resource( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Resource: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + moto_resource = moto_rest_api.resources.get(resource_id) + if not moto_resource: + raise NotFoundException("Invalid Resource identifier specified") + + store = get_apigateway_store(context=context) + + rest_api = store.rest_apis.get(rest_api_id) + api_resources = rest_api.resource_children + + future_path_part = moto_resource.path_part + current_parent_id = moto_resource.parent_id + + for patch_operation in patch_operations: + op = patch_operation.get("op") + if (path := patch_operation.get("path")) not in ("/pathPart", "/parentId"): + raise BadRequestException( + f"Invalid patch path '{path}' specified for op '{op}'. Must be one of: [/parentId, /pathPart]" + ) + if op != "replace": + raise BadRequestException( + f"Invalid patch path '{path}' specified for op '{op}'. Please choose supported operations" + ) + + if path == "/parentId": + value = patch_operation.get("value") + future_parent_resource = moto_rest_api.resources.get(value) + if not future_parent_resource: + raise NotFoundException("Invalid Resource identifier specified") + + children_resources = api_resources.get(resource_id, []) + if value in children_resources: + raise BadRequestException("Resources cannot be cyclical.") + + new_sibling_resources = api_resources.get(value, []) + + else: # path == "/pathPart" + future_path_part = patch_operation.get("value") + new_sibling_resources = api_resources.get(moto_resource.parent_id, []) + + for sibling in new_sibling_resources: + sibling_resource = moto_rest_api.resources[sibling] + if sibling_resource.path_part == future_path_part: + raise ConflictException( + f"Another resource with the same parent already has this name: {future_path_part}" + ) + + # TODO: test with multiple patch operations which would not be compatible between each other + patch_api_gateway_entity(moto_resource, patch_operations) + + # after setting it, mutate the store + if moto_resource.parent_id != current_parent_id: + current_sibling_resources = api_resources.get(current_parent_id) + if current_sibling_resources: + current_sibling_resources.remove(resource_id) + # if the parent does not have children anymore, remove from the list + if not current_sibling_resources: + api_resources.pop(current_parent_id) + + # add it to the new parent children + future_sibling_resources = api_resources[moto_resource.parent_id] + future_sibling_resources.append(resource_id) + + response = moto_resource.to_dict() + return response + + # resource method + + def get_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> Method: + response: Method = call_moto(context) + remove_empty_attributes_from_method(response) + if method_integration := response.get("methodIntegration"): + remove_empty_attributes_from_integration(method_integration) + # moto will not return `responseParameters` field if it's not truthy, but AWS will return an empty dict + # if it was set to an empty dict + if "responseParameters" not in method_integration: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + moto_resource = moto_rest_api.resources[resource_id] + moto_method_integration = moto_resource.resource_methods[ + http_method + ].method_integration + if moto_method_integration.integration_responses: + for ( + status_code, + integration_response, + ) in moto_method_integration.integration_responses.items(): + if integration_response.response_parameters == {}: + method_integration["integrationResponses"][str(status_code)][ + "responseParameters" + ] = {} + + return response + + def put_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + authorization_type: String, + authorizer_id: String = None, + api_key_required: Boolean = None, + operation_name: String = None, + request_parameters: MapOfStringToBoolean = None, + request_models: MapOfStringToString = None, + request_validator_id: String = None, + authorization_scopes: ListOfString = None, + **kwargs, + ) -> Method: + # TODO: add missing validation? check order of validation as well + moto_backend = get_moto_backend(context.account_id, context.region) + moto_rest_api: MotoRestAPI = moto_backend.apis.get(rest_api_id) + if not moto_rest_api or not (moto_resource := moto_rest_api.resources.get(resource_id)): + raise NotFoundException("Invalid Resource identifier specified") + + if http_method not in ("GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS", "HEAD", "ANY"): + raise BadRequestException( + "Invalid HttpMethod specified. " + "Valid options are GET,PUT,POST,DELETE,PATCH,OPTIONS,HEAD,ANY" + ) + + if request_parameters: + request_parameters_names = { + name.rsplit(".", maxsplit=1)[-1] for name in request_parameters.keys() + } + if len(request_parameters_names) != len(request_parameters): + raise BadRequestException( + "Parameter names must be unique across querystring, header and path" + ) + need_authorizer_id = authorization_type in ("CUSTOM", "COGNITO_USER_POOLS") + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis[rest_api_id] + if need_authorizer_id and ( + not authorizer_id or authorizer_id not in rest_api_container.authorizers + ): + # TODO: will be cleaner with https://github.com/localstack/localstack/pull/7750 + raise BadRequestException( + "Invalid authorizer ID specified. " + "Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer." + ) + + if request_validator_id and request_validator_id not in rest_api_container.validators: + raise BadRequestException("Invalid Request Validator identifier specified") + + if request_models: + for content_type, model_name in request_models.items(): + # FIXME: add Empty model to rest api at creation + if model_name == EMPTY_MODEL: + continue + if model_name not in rest_api_container.models: + raise BadRequestException(f"Invalid model identifier specified: {model_name}") + + response: Method = call_moto(context) + remove_empty_attributes_from_method(response) + moto_http_method = moto_resource.resource_methods[http_method] + moto_http_method.authorization_type = moto_http_method.authorization_type.upper() + + # this is straight from the moto patch, did not test it yet but has the same functionality + # FIXME: check if still necessary after testing Authorizers + if need_authorizer_id and "authorizerId" not in response: + response["authorizerId"] = authorizer_id + + response["authorizationType"] = response["authorizationType"].upper() + + return response + + def update_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Method: + # see https://www.linkedin.com/pulse/updating-aws-cli-patch-operations-rest-api-yitzchak-meirovich/ + # for path construction + moto_backend = get_moto_backend(context.account_id, context.region) + moto_rest_api: MotoRestAPI = moto_backend.apis.get(rest_api_id) + if not moto_rest_api or not (moto_resource := moto_rest_api.resources.get(resource_id)): + raise NotFoundException("Invalid Resource identifier specified") + + if not (moto_method := moto_resource.resource_methods.get(http_method)): + raise NotFoundException("Invalid Method identifier specified") + store = get_apigateway_store(context=context) + rest_api = store.rest_apis[rest_api_id] + applicable_patch_operations = [] + modifying_auth_type = False + modified_authorizer_id = False + had_req_params = bool(moto_method.request_parameters) + had_req_models = bool(moto_method.request_models) + + for patch_operation in patch_operations: + op = patch_operation.get("op") + path = patch_operation.get("path") + # if the path is not supported at all, raise an Exception + if len(path.split("/")) > 3 or not any( + path.startswith(s_path) for s_path in UPDATE_METHOD_PATCH_PATHS["supported_paths"] + ): + raise BadRequestException(f"Invalid patch path {path}") + + # if the path is not supported by the operation, ignore it and skip + op_supported_path = UPDATE_METHOD_PATCH_PATHS.get(op, []) + if not any(path.startswith(s_path) for s_path in op_supported_path): + available_ops = [ + available_op + for available_op in ("add", "replace", "delete") + if available_op != op + ] + supported_ops = ", ".join( + [ + supported_op + for supported_op in available_ops + if any( + path.startswith(s_path) + for s_path in UPDATE_METHOD_PATCH_PATHS.get(supported_op, []) + ) + ] + ) + raise BadRequestException( + f"Invalid patch operation specified. Must be one of: [{supported_ops}]" + ) + + value = patch_operation.get("value") + if op not in ("add", "replace"): + # skip + applicable_patch_operations.append(patch_operation) + continue + + if path == "/authorizationType" and value in ("CUSTOM", "COGNITO_USER_POOLS"): + modifying_auth_type = True + + elif path == "/authorizerId": + modified_authorizer_id = value + + if any( + path.startswith(s_path) for s_path in ("/apiKeyRequired", "/requestParameters/") + ): + patch_op = {"op": op, "path": path, "value": str_to_bool(value)} + applicable_patch_operations.append(patch_op) + continue + + elif path == "/requestValidatorId" and value not in rest_api.validators: + if not value: + # you can remove a requestValidator by passing an empty string as a value + patch_op = {"op": "remove", "path": path, "value": value} + applicable_patch_operations.append(patch_op) + continue + raise BadRequestException("Invalid Request Validator identifier specified") + + elif path.startswith("/requestModels/"): + if value != EMPTY_MODEL and value not in rest_api.models: + raise BadRequestException(f"Invalid model identifier specified: {value}") + + applicable_patch_operations.append(patch_operation) + + if modifying_auth_type: + if not modified_authorizer_id or modified_authorizer_id not in rest_api.authorizers: + raise BadRequestException( + "Invalid authorizer ID specified. " + "Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer." + ) + elif modified_authorizer_id: + if moto_method.authorization_type not in ("CUSTOM", "COGNITO_USER_POOLS"): + # AWS will ignore this patch if the method does not have a proper authorization type + # filter the patches to remove the modified authorizerId + applicable_patch_operations = [ + op for op in applicable_patch_operations if op.get("path") != "/authorizerId" + ] + + # TODO: test with multiple patch operations which would not be compatible between each other + patch_api_gateway_entity(moto_method, applicable_patch_operations) + + # if we removed all values of those fields, set them to None so that they're not returned anymore + if had_req_params and len(moto_method.request_parameters) == 0: + moto_method.request_parameters = None + if had_req_models and len(moto_method.request_models) == 0: + moto_method.request_models = None + + response = moto_method.to_json() + remove_empty_attributes_from_method(response) + remove_empty_attributes_from_integration(response.get("methodIntegration")) + return response + + def delete_method( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> None: + moto_backend = get_moto_backend(context.account_id, context.region) + moto_rest_api: MotoRestAPI = moto_backend.apis.get(rest_api_id) + if not moto_rest_api or not (moto_resource := moto_rest_api.resources.get(resource_id)): + raise NotFoundException("Invalid Resource identifier specified") + + if not (moto_resource.resource_methods.get(http_method)): + raise NotFoundException("Invalid Method identifier specified") + + call_moto(context) + + # method responses + + def get_method_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + **kwargs, + ) -> MethodResponse: + # this could probably be easier in a patch? + moto_backend = get_moto_backend(context.account_id, context.region) + moto_rest_api: MotoRestAPI = moto_backend.apis.get(rest_api_id) + # TODO: snapshot test different possibilities + if not moto_rest_api or not (moto_resource := moto_rest_api.resources.get(resource_id)): + raise NotFoundException("Invalid Resource identifier specified") + + if not (moto_method := moto_resource.resource_methods.get(http_method)): + raise NotFoundException("Invalid Method identifier specified") + + if not (moto_method_response := moto_method.get_response(status_code)): + raise NotFoundException("Invalid Response status code specified") + + method_response = moto_method_response.to_json() + return method_response + + @handler("UpdateMethodResponse") + def update_method_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + patch_operations: ListOfPatchOperation | None = None, + **kwargs, + ) -> MethodResponse: + error_messages = [] + for index, operation in enumerate(patch_operations): + op = operation.get("op") + if op not in VALID_PATCH_OPERATIONS: + error_messages.append( + f"Value '{op}' at 'updateMethodResponseInput.patchOperations.{index + 1}.member.op' " + f"failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(VALID_PATCH_OPERATIONS)}]" + ) + + if not re.fullmatch(r"[1-5]\d\d", status_code): + error_messages.append( + f"Value '{status_code}' at 'statusCode' failed to satisfy constraint: " + "Member must satisfy regular expression pattern: [1-5]\\d\\d" + ) + + if error_messages: + prefix = f"{len(error_messages)} validation error{'s' if len(error_messages) > 1 else ''} detected: " + raise CommonServiceException( + code="ValidationException", + message=prefix + "; ".join(error_messages), + ) + + moto_rest_api = get_moto_rest_api(context, rest_api_id) + moto_resource = moto_rest_api.resources.get(resource_id) + if not moto_resource: + raise NotFoundException("Invalid Resource identifier specified") + + moto_method = moto_resource.resource_methods.get(http_method) + if not moto_method: + raise NotFoundException("Invalid Method identifier specified") + + method_response = moto_method.method_responses.get(status_code) + if not method_response: + raise NotFoundException("Invalid Response status code specified") + + if method_response.response_models is None: + method_response.response_models = {} + if method_response.response_parameters is None: + method_response.response_parameters = {} + + for patch_operation in patch_operations: + op = patch_operation["op"] + path = patch_operation["path"] + value = patch_operation.get("value") + + if path.startswith("/responseParameters/"): + param_name = path.removeprefix("/responseParameters/") + if param_name not in method_response.response_parameters and op in ( + "replace", + "remove", + ): + raise NotFoundException("Invalid parameter name specified") + if op in ("add", "replace"): + method_response.response_parameters[param_name] = value == "true" + elif op == "remove": + method_response.response_parameters.pop(param_name) + + elif path.startswith("/responseModels/"): + param_name = path.removeprefix("/responseModels/") + param_name = param_name.replace("~1", "/") + if param_name not in method_response.response_models and op in ( + "replace", + "remove", + ): + raise NotFoundException("Content-Type specified was not found") + if op in ("add", "replace"): + method_response.response_models[param_name] = value + elif op == "remove": + method_response.response_models.pop(param_name) + else: + raise BadRequestException(f"Invalid patch path {path}") + + response: MethodResponse = method_response.to_json() + + # AWS doesn't send back empty responseParameters or responseModels + if not method_response.response_parameters: + response.pop("responseParameters") + if not method_response.response_models: + response.pop("responseModels") + + return response + + # stages + + # TODO: add createdDate / lastUpdatedDate in Stage operations below! + @handler("CreateStage", expand=False) + def create_stage(self, context: RequestContext, request: CreateStageRequest) -> Stage: + call_moto(context) + moto_api = get_moto_rest_api(context, rest_api_id=request["restApiId"]) + stage = moto_api.stages.get(request["stageName"]) + if not stage: + raise NotFoundException("Invalid Stage identifier specified") + + if not hasattr(stage, "documentation_version"): + stage.documentation_version = request.get("documentationVersion") + + # make sure we update the stage_name on the deployment entity in moto + deployment = moto_api.deployments.get(request["deploymentId"]) + deployment.stage_name = stage.name + + response = stage.to_json() + self._patch_stage_response(response) + return response + + def get_stage( + self, context: RequestContext, rest_api_id: String, stage_name: String, **kwargs + ) -> Stage: + response = call_moto(context) + self._patch_stage_response(response) + return response + + def get_stages( + self, context: RequestContext, rest_api_id: String, deployment_id: String = None, **kwargs + ) -> Stages: + response = call_moto(context) + for stage in response["item"]: + self._patch_stage_response(stage) + if not stage.get("description"): + stage.pop("description", None) + return Stages(**response) + + @handler("UpdateStage") + def update_stage( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Stage: + call_moto(context) + + moto_backend = get_moto_backend(context.account_id, context.region) + moto_rest_api: MotoRestAPI = moto_backend.apis.get(rest_api_id) + if not (moto_stage := moto_rest_api.stages.get(stage_name)): + raise NotFoundException("Invalid Stage identifier specified") + + # construct list of path regexes for validation + path_regexes = [re.sub("{[^}]+}", ".+", path) for path in STAGE_UPDATE_PATHS] + + # copy the patch operations to not mutate them, so that we're logging the correct input + patch_operations = copy.deepcopy(patch_operations) or [] + for patch_operation in patch_operations: + patch_path = patch_operation["path"] + + # special case: handle updates (op=remove) for wildcard method settings + patch_path_stripped = patch_path.strip("/") + if patch_path_stripped == "*/*" and patch_operation["op"] == "remove": + if not moto_stage.method_settings.pop(patch_path_stripped, None): + raise BadRequestException( + "Cannot remove method setting */* because there is no method setting for this method " + ) + response = moto_stage.to_json() + self._patch_stage_response(response) + return response + + path_valid = patch_path in STAGE_UPDATE_PATHS or any( + re.match(regex, patch_path) for regex in path_regexes + ) + if not path_valid: + valid_paths = f"[{', '.join(STAGE_UPDATE_PATHS)}]" + # note: weird formatting in AWS - required for snapshot testing + valid_paths = valid_paths.replace( + "/{resourcePath}/{httpMethod}/throttling/burstLimit, /{resourcePath}/{httpMethod}/throttling/rateLimit, /{resourcePath}/{httpMethod}/caching/ttlInSeconds", + "/{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds", + ) + valid_paths = valid_paths.replace("/burstLimit, /", "/burstLimit /") + valid_paths = valid_paths.replace("/rateLimit, /", "/rateLimit /") + raise BadRequestException( + f"Invalid method setting path: {patch_operation['path']}. Must be one of: {valid_paths}" + ) + + # TODO: check if there are other boolean, maybe add a global step in _patch_api_gateway_entity + if patch_path == "/tracingEnabled" and (value := patch_operation.get("value")): + patch_operation["value"] = value and value.lower() == "true" or False + + patch_api_gateway_entity(moto_stage, patch_operations) + moto_stage.apply_operations(patch_operations) + + response = moto_stage.to_json() + self._patch_stage_response(response) + return response + + def _patch_stage_response(self, response: dict): + """Apply a few patches required for AWS parity""" + response.setdefault("cacheClusterStatus", "NOT_AVAILABLE") + response.setdefault("tracingEnabled", False) + if not response.get("variables"): + response.pop("variables", None) + + def update_deployment( + self, + context: RequestContext, + rest_api_id: String, + deployment_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Deployment: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + try: + deployment = moto_rest_api.get_deployment(deployment_id) + except KeyError: + raise NotFoundException("Invalid Deployment identifier specified") + + for patch_operation in patch_operations: + # TODO: add validation for unsupported paths + # see https://docs.aws.amazon.com/apigateway/latest/api/patch-operations.html#UpdateDeployment-Patch + if ( + patch_operation.get("path") == "/description" + and patch_operation.get("op") == "replace" + ): + deployment.description = patch_operation["value"] + + deployment_response: Deployment = deployment.to_json() or {} + return deployment_response + + # authorizers + + @handler("CreateAuthorizer", expand=False) + def create_authorizer( + self, context: RequestContext, request: CreateAuthorizerRequest + ) -> Authorizer: + # TODO: add validation + api_id = request["restApiId"] + store = get_apigateway_store(context=context) + if api_id not in store.rest_apis: + # this seems like a weird exception to throw, but couldn't get anything different + # we might need to have a look again + raise ConflictException( + "Unable to complete operation due to concurrent modification. Please try again later." + ) + + authorizer_id = short_uid()[:6] # length 6 to make TF tests pass + authorizer = deepcopy(select_from_typed_dict(Authorizer, request)) + authorizer["id"] = authorizer_id + authorizer["authorizerResultTtlInSeconds"] = int( + authorizer.get("authorizerResultTtlInSeconds", 300) + ) + store.rest_apis[api_id].authorizers[authorizer_id] = authorizer + + response = to_authorizer_response_json(api_id, authorizer) + return response + + def get_authorizers( + self, + context: RequestContext, + rest_api_id: String, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> Authorizers: + # TODO add paging, validation + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + result = [ + to_authorizer_response_json(rest_api_id, a) + for a in rest_api_container.authorizers.values() + ] + return Authorizers(items=result) + + def get_authorizer( + self, context: RequestContext, rest_api_id: String, authorizer_id: String, **kwargs + ) -> Authorizer: + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + # TODO: validate the restAPI id to remove the conditional + authorizer = ( + rest_api_container.authorizers.get(authorizer_id) if rest_api_container else None + ) + + if authorizer is None: + raise NotFoundException(f"Authorizer not found: {authorizer_id}") + return to_authorizer_response_json(rest_api_id, authorizer) + + def delete_authorizer( + self, context: RequestContext, rest_api_id: String, authorizer_id: String, **kwargs + ) -> None: + # TODO: add validation if authorizer does not exist + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + if rest_api_container: + rest_api_container.authorizers.pop(authorizer_id, None) + + def update_authorizer( + self, + context: RequestContext, + rest_api_id: String, + authorizer_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Authorizer: + # TODO: add validation + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + # TODO: validate the restAPI id to remove the conditional + authorizer = ( + rest_api_container.authorizers.get(authorizer_id) if rest_api_container else None + ) + + if authorizer is None: + raise NotFoundException(f"Authorizer not found: {authorizer_id}") + + patched_authorizer = apply_json_patch_safe(authorizer, patch_operations) + # terraform sends this as a string in patch, so convert to int + patched_authorizer["authorizerResultTtlInSeconds"] = int( + patched_authorizer.get("authorizerResultTtlInSeconds", 300) + ) + + # store the updated Authorizer + rest_api_container.authorizers[authorizer_id] = patched_authorizer + + result = to_authorizer_response_json(rest_api_id, patched_authorizer) + return result + + # accounts + + def get_account(self, context: RequestContext, **kwargs) -> Account: + region_details = get_apigateway_store(context=context) + result = to_account_response_json(region_details.account) + return Account(**result) + + def update_account( + self, context: RequestContext, patch_operations: ListOfPatchOperation = None, **kwargs + ) -> Account: + region_details = get_apigateway_store(context=context) + apply_json_patch_safe(region_details.account, patch_operations, in_place=True) + result = to_account_response_json(region_details.account) + return Account(**result) + + # documentation parts + + def get_documentation_parts( + self, context: RequestContext, request: GetDocumentationPartsRequest, **kwargs + ) -> DocumentationParts: + # TODO: add validation + api_id = request["restApiId"] + rest_api_container = get_rest_api_container(context, rest_api_id=api_id) + + result = [ + to_documentation_part_response_json(api_id, a) + for a in rest_api_container.documentation_parts.values() + ] + return DocumentationParts(items=result) + + def get_documentation_part( + self, context: RequestContext, rest_api_id: String, documentation_part_id: String, **kwargs + ) -> DocumentationPart: + # TODO: add validation + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + # TODO: validate the restAPI id to remove the conditional + documentation_part = ( + rest_api_container.documentation_parts.get(documentation_part_id) + if rest_api_container + else None + ) + + if documentation_part is None: + raise NotFoundException("Invalid Documentation part identifier specified") + return to_documentation_part_response_json(rest_api_id, documentation_part) + + def create_documentation_part( + self, + context: RequestContext, + rest_api_id: String, + location: DocumentationPartLocation, + properties: String, + **kwargs, + ) -> DocumentationPart: + entity_id = short_uid()[:6] # length 6 for AWS parity / Terraform compatibility + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + + # TODO: add complete validation for + # location parameter: https://docs.aws.amazon.com/apigateway/latest/api/API_DocumentationPartLocation.html + # As of now we validate only "type" + location_type = location.get("type") + valid_location_types = [ + "API", + "AUTHORIZER", + "MODEL", + "RESOURCE", + "METHOD", + "PATH_PARAMETER", + "QUERY_PARAMETER", + "REQUEST_HEADER", + "REQUEST_BODY", + "RESPONSE", + "RESPONSE_HEADER", + "RESPONSE_BODY", + ] + if location_type not in valid_location_types: + raise CommonServiceException( + "ValidationException", + f"1 validation error detected: Value '{location_type}' at " + f"'createDocumentationPartInput.location.type' failed to satisfy constraint: " + f"Member must satisfy enum value set: " + f"[RESPONSE_BODY, RESPONSE, METHOD, MODEL, AUTHORIZER, RESPONSE_HEADER, " + f"RESOURCE, PATH_PARAMETER, REQUEST_BODY, QUERY_PARAMETER, API, REQUEST_HEADER]", + ) + + doc_part = DocumentationPart( + id=entity_id, + location=location, + properties=properties, + ) + rest_api_container.documentation_parts[entity_id] = doc_part + + result = to_documentation_part_response_json(rest_api_id, doc_part) + return DocumentationPart(**result) + + def update_documentation_part( + self, + context: RequestContext, + rest_api_id: String, + documentation_part_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> DocumentationPart: + # TODO: add validation + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + # TODO: validate the restAPI id to remove the conditional + doc_part = ( + rest_api_container.documentation_parts.get(documentation_part_id) + if rest_api_container + else None + ) + + if doc_part is None: + raise NotFoundException("Invalid Documentation part identifier specified") + + for patch_operation in patch_operations: + path = patch_operation.get("path") + operation = patch_operation.get("op") + if operation != "replace": + raise BadRequestException( + f"Invalid patch path '{path}' specified for op '{operation}'. " + f"Please choose supported operations" + ) + + if path != "/properties": + raise BadRequestException( + f"Invalid patch path '{path}' specified for op 'replace'. " + f"Must be one of: [/properties]" + ) + + key = path[1:] + if key == "properties" and not patch_operation.get("value"): + raise BadRequestException("Documentation part properties must be non-empty") + + patched_doc_part = apply_json_patch_safe(doc_part, patch_operations) + + rest_api_container.documentation_parts[documentation_part_id] = patched_doc_part + + return to_documentation_part_response_json(rest_api_id, patched_doc_part) + + def delete_documentation_part( + self, context: RequestContext, rest_api_id: String, documentation_part_id: String, **kwargs + ) -> None: + # TODO: add validation if document_part does not exist, or rest_api + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + + documentation_part = rest_api_container.documentation_parts.get(documentation_part_id) + + if documentation_part is None: + raise NotFoundException("Invalid Documentation part identifier specified") + + if rest_api_container: + rest_api_container.documentation_parts.pop(documentation_part_id, None) + + def import_documentation_parts( + self, + context: RequestContext, + rest_api_id: String, + body: IO[Blob], + mode: PutMode = None, + fail_on_warnings: Boolean = None, + **kwargs, + ) -> DocumentationPartIds: + body_data = body.read() + openapi_spec = parse_json_or_yaml(to_str(body_data)) + + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-documenting-api-quick-start-import-export.html + resolved_schema = resolve_references(openapi_spec, rest_api_id=rest_api_id) + documentation = resolved_schema.get(OpenAPIExt.DOCUMENTATION) + + ids = [] + # overwrite mode + if mode == PutMode.overwrite: + rest_api_container.documentation_parts.clear() + for doc_part in documentation["documentationParts"]: + entity_id = short_uid()[:6] + rest_api_container.documentation_parts[entity_id] = DocumentationPart( + id=entity_id, **doc_part + ) + ids.append(entity_id) + # TODO: implement the merge mode + return DocumentationPartIds(ids=ids) + + # documentation versions + + def create_documentation_version( + self, + context: RequestContext, + rest_api_id: String, + documentation_version: String, + stage_name: String = None, + description: String = None, + **kwargs, + ) -> DocumentationVersion: + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + + result = DocumentationVersion( + version=documentation_version, createdDate=datetime.now(), description=description + ) + rest_api_container.documentation_versions[documentation_version] = result + + return result + + def get_documentation_version( + self, context: RequestContext, rest_api_id: String, documentation_version: String, **kwargs + ) -> DocumentationVersion: + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + + result = rest_api_container.documentation_versions.get(documentation_version) + if not result: + raise NotFoundException(f"Documentation version not found: {documentation_version}") + + return result + + def get_documentation_versions( + self, + context: RequestContext, + rest_api_id: String, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> DocumentationVersions: + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + result = list(rest_api_container.documentation_versions.values()) + return DocumentationVersions(items=result) + + def delete_documentation_version( + self, context: RequestContext, rest_api_id: String, documentation_version: String, **kwargs + ) -> None: + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + + result = rest_api_container.documentation_versions.pop(documentation_version, None) + if not result: + raise NotFoundException(f"Documentation version not found: {documentation_version}") + + def update_documentation_version( + self, + context: RequestContext, + rest_api_id: String, + documentation_version: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> DocumentationVersion: + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + + result = rest_api_container.documentation_versions.get(documentation_version) + if not result: + raise NotFoundException(f"Documentation version not found: {documentation_version}") + + patch_api_gateway_entity(result, patch_operations) + + return result + + # base path mappings + + def get_base_path_mappings( + self, + context: RequestContext, + domain_name: String, + domain_name_id: String = None, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> BasePathMappings: + region_details = get_apigateway_store(context=context) + + mappings_list = region_details.base_path_mappings.get(domain_name) or [] + + result = [ + to_base_mapping_response_json(domain_name, m["basePath"], m) for m in mappings_list + ] + return BasePathMappings(items=result) + + def get_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + base_path: String, + domain_name_id: String = None, + **kwargs, + ) -> BasePathMapping: + region_details = get_apigateway_store(context=context) + + mappings_list = region_details.base_path_mappings.get(domain_name) or [] + mapping = ([m for m in mappings_list if m["basePath"] == base_path] or [None])[0] + if mapping is None: + raise NotFoundException(f"Base path mapping not found: {domain_name} - {base_path}") + + result = to_base_mapping_response_json(domain_name, base_path, mapping) + return BasePathMapping(**result) + + def create_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + rest_api_id: String, + domain_name_id: String = None, + base_path: String = None, + stage: String = None, + **kwargs, + ) -> BasePathMapping: + region_details = get_apigateway_store(context=context) + + # Note: "(none)" is a special value in API GW: + # https://docs.aws.amazon.com/apigateway/api-reference/link-relation/basepathmapping-by-base-path + base_path = base_path or "(none)" + + entry = { + "domainName": domain_name, + "restApiId": rest_api_id, + "basePath": base_path, + "stage": stage, + } + region_details.base_path_mappings.setdefault(domain_name, []).append(entry) + + result = to_base_mapping_response_json(domain_name, base_path, entry) + return BasePathMapping(**result) + + def update_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + base_path: String, + domain_name_id: String = None, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> BasePathMapping: + region_details = get_apigateway_store(context=context) + + mappings_list = region_details.base_path_mappings.get(domain_name) or [] + + mapping = ([m for m in mappings_list if m["basePath"] == base_path] or [None])[0] + if mapping is None: + raise NotFoundException( + f"Not found: mapping for domain name {domain_name}, " + f"base path {base_path} in list {mappings_list}" + ) + + patch_operations = ensure_list(patch_operations) + for operation in patch_operations: + if operation["path"] == "/restapiId": + operation["path"] = "/restApiId" + result = apply_json_patch_safe(mapping, patch_operations) + + for i in range(len(mappings_list)): + if mappings_list[i]["basePath"] == base_path: + mappings_list[i] = result + + result = to_base_mapping_response_json(domain_name, base_path, result) + return BasePathMapping(**result) + + def delete_base_path_mapping( + self, + context: RequestContext, + domain_name: String, + base_path: String, + domain_name_id: String = None, + **kwargs, + ) -> None: + region_details = get_apigateway_store(context=context) + + mappings_list = region_details.base_path_mappings.get(domain_name) or [] + for i in range(len(mappings_list)): + if mappings_list[i]["basePath"] == base_path: + del mappings_list[i] + return + + raise NotFoundException(f"Base path mapping {base_path} for domain {domain_name} not found") + + # client certificates + + def get_client_certificate( + self, context: RequestContext, client_certificate_id: String, **kwargs + ) -> ClientCertificate: + region_details = get_apigateway_store(context=context) + result = region_details.client_certificates.get(client_certificate_id) + if result is None: + raise NotFoundException(f"Client certificate ID {client_certificate_id} not found") + return ClientCertificate(**result) + + def get_client_certificates( + self, + context: RequestContext, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> ClientCertificates: + region_details = get_apigateway_store(context=context) + result = list(region_details.client_certificates.values()) + return ClientCertificates(items=result) + + def generate_client_certificate( + self, + context: RequestContext, + description: String = None, + tags: MapOfStringToString = None, + **kwargs, + ) -> ClientCertificate: + region_details = get_apigateway_store(context=context) + cert_id = short_uid() + creation_time = now_utc() + entry = { + "description": description, + "tags": tags, + "clientCertificateId": cert_id, + "createdDate": creation_time, + "expirationDate": creation_time + 60 * 60 * 24 * 30, # assume 30 days validity + "pemEncodedCertificate": "testcert-123", # TODO return proper certificate! + } + region_details.client_certificates[cert_id] = entry + result = to_client_cert_response_json(entry) + return ClientCertificate(**result) + + def update_client_certificate( + self, + context: RequestContext, + client_certificate_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> ClientCertificate: + region_details = get_apigateway_store(context=context) + entity = region_details.client_certificates.get(client_certificate_id) + if entity is None: + raise NotFoundException(f'Client certificate ID "{client_certificate_id}" not found') + result = apply_json_patch_safe(entity, patch_operations) + result = to_client_cert_response_json(result) + return ClientCertificate(**result) + + def delete_client_certificate( + self, context: RequestContext, client_certificate_id: String, **kwargs + ) -> None: + region_details = get_apigateway_store(context=context) + entity = region_details.client_certificates.pop(client_certificate_id, None) + if entity is None: + raise NotFoundException(f'VPC link ID "{client_certificate_id}" not found for deletion') + + # VPC links + + def create_vpc_link( + self, + context: RequestContext, + name: String, + target_arns: ListOfString, + description: String = None, + tags: MapOfStringToString = None, + **kwargs, + ) -> VpcLink: + region_details = get_apigateway_store(context=context) + link_id = short_uid() + entry = {"id": link_id, "status": "AVAILABLE"} + region_details.vpc_links[link_id] = entry + result = to_vpc_link_response_json(entry) + return VpcLink(**result) + + def get_vpc_links( + self, + context: RequestContext, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> VpcLinks: + region_details = get_apigateway_store(context=context) + result = region_details.vpc_links.values() + result = [to_vpc_link_response_json(r) for r in result] + result = {"items": result} + return result + + def get_vpc_link(self, context: RequestContext, vpc_link_id: String, **kwargs) -> VpcLink: + region_details = get_apigateway_store(context=context) + vpc_link = region_details.vpc_links.get(vpc_link_id) + if vpc_link is None: + raise NotFoundException(f'VPC link ID "{vpc_link_id}" not found') + result = to_vpc_link_response_json(vpc_link) + return VpcLink(**result) + + def update_vpc_link( + self, + context: RequestContext, + vpc_link_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> VpcLink: + region_details = get_apigateway_store(context=context) + vpc_link = region_details.vpc_links.get(vpc_link_id) + if vpc_link is None: + raise NotFoundException(f'VPC link ID "{vpc_link_id}" not found') + result = apply_json_patch_safe(vpc_link, patch_operations) + result = to_vpc_link_response_json(result) + return VpcLink(**result) + + def delete_vpc_link(self, context: RequestContext, vpc_link_id: String, **kwargs) -> None: + region_details = get_apigateway_store(context=context) + vpc_link = region_details.vpc_links.pop(vpc_link_id, None) + if vpc_link is None: + raise NotFoundException(f'VPC link ID "{vpc_link_id}" not found for deletion') + + # request validators + + def get_request_validators( + self, + context: RequestContext, + rest_api_id: String, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> RequestValidators: + # TODO: add validation and pagination? + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + result = [ + to_validator_response_json(rest_api_id, a) + for a in rest_api_container.validators.values() + ] + return RequestValidators(items=result) + + def get_request_validator( + self, context: RequestContext, rest_api_id: String, request_validator_id: String, **kwargs + ) -> RequestValidator: + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + # TODO: validate the restAPI id to remove the conditional + validator = ( + rest_api_container.validators.get(request_validator_id) if rest_api_container else None + ) + + if validator is None: + raise NotFoundException("Invalid Request Validator identifier specified") + + result = to_validator_response_json(rest_api_id, validator) + return result + + def create_request_validator( + self, + context: RequestContext, + rest_api_id: String, + name: String = None, + validate_request_body: Boolean = None, + validate_request_parameters: Boolean = None, + **kwargs, + ) -> RequestValidator: + # TODO: add validation (ex: name cannot be blank) + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise BadRequestException("Invalid REST API identifier specified") + # length 6 for AWS parity and TF compatibility + validator_id = short_uid()[:6] + + validator = RequestValidator( + id=validator_id, + name=name, + validateRequestBody=validate_request_body or False, + validateRequestParameters=validate_request_parameters or False, + ) + + rest_api_container.validators[validator_id] = validator + + # missing to_validator_response_json ? + return validator + + def update_request_validator( + self, + context: RequestContext, + rest_api_id: String, + request_validator_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> RequestValidator: + # TODO: add validation + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + # TODO: validate the restAPI id to remove the conditional + validator = ( + rest_api_container.validators.get(request_validator_id) if rest_api_container else None + ) + + if validator is None: + raise NotFoundException( + f"Validator {request_validator_id} for API Gateway {rest_api_id} not found" + ) + + for patch_operation in patch_operations: + path = patch_operation.get("path") + operation = patch_operation.get("op") + if operation != "replace": + raise BadRequestException( + f"Invalid patch path '{path}' specified for op '{operation}'. " + f"Please choose supported operations" + ) + if path not in ("/name", "/validateRequestBody", "/validateRequestParameters"): + raise BadRequestException( + f"Invalid patch path '{path}' specified for op 'replace'. " + f"Must be one of: [/name, /validateRequestParameters, /validateRequestBody]" + ) + + key = path[1:] + value = patch_operation.get("value") + if key == "name" and not value: + raise BadRequestException("Request Validator name cannot be blank") + + elif key in ("validateRequestParameters", "validateRequestBody"): + value = value and value.lower() == "true" or False + + rest_api_container.validators[request_validator_id][key] = value + + return to_validator_response_json( + rest_api_id, rest_api_container.validators[request_validator_id] + ) + + def delete_request_validator( + self, context: RequestContext, rest_api_id: String, request_validator_id: String, **kwargs + ) -> None: + # TODO: add validation if rest api does not exist + store = get_apigateway_store(context=context) + rest_api_container = store.rest_apis.get(rest_api_id) + if not rest_api_container: + raise NotFoundException("Invalid Request Validator identifier specified") + + validator = rest_api_container.validators.pop(request_validator_id, None) + if not validator: + raise NotFoundException("Invalid Request Validator identifier specified") + + # tags + + def get_tags( + self, + context: RequestContext, + resource_arn: String, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> Tags: + result = get_apigateway_store(context=context).TAGS.get(resource_arn, {}) + return Tags(tags=result) + + def tag_resource( + self, context: RequestContext, resource_arn: String, tags: MapOfStringToString, **kwargs + ) -> None: + resource_tags = get_apigateway_store(context=context).TAGS.setdefault(resource_arn, {}) + resource_tags.update(tags) + + def untag_resource( + self, context: RequestContext, resource_arn: String, tag_keys: ListOfString, **kwargs + ) -> None: + resource_tags = get_apigateway_store(context=context).TAGS.setdefault(resource_arn, {}) + for key in tag_keys: + resource_tags.pop(key, None) + + def import_rest_api( + self, + context: RequestContext, + body: IO[Blob], + fail_on_warnings: Boolean = None, + parameters: MapOfStringToString = None, + **kwargs, + ) -> RestApi: + body_data = body.read() + + # create rest api + openapi_spec = parse_json_or_yaml(to_str(body_data)) + create_api_request = CreateRestApiRequest(name=openapi_spec.get("info").get("title")) + create_api_context = create_custom_context( + context, + "CreateRestApi", + create_api_request, + ) + response = self.create_rest_api(create_api_context, create_api_request) + api_id = response.get("id") + # remove the 2 default models automatically created, but not when importing + store = get_apigateway_store(context=context) + store.rest_apis[api_id].models = {} + + # put rest api + put_api_request = PutRestApiRequest( + restApiId=api_id, + failOnWarnings=str_to_bool(fail_on_warnings) or False, + parameters=parameters or {}, + body=io.BytesIO(body_data), + ) + put_api_context = create_custom_context( + context, + "PutRestApi", + put_api_request, + ) + put_api_response = self.put_rest_api(put_api_context, put_api_request) + if not put_api_response.get("tags"): + put_api_response.pop("tags", None) + return put_api_response + + # integrations + + def get_integration( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> Integration: + try: + response: Integration = call_moto(context) + except CommonServiceException as e: + # the Exception raised by moto does not have the right message not status code + if e.code == "NotFoundException": + raise NotFoundException("Invalid Integration identifier specified") + raise + + if integration_responses := response.get("integrationResponses"): + for integration_response in integration_responses.values(): + remove_empty_attributes_from_integration_response(integration_response) + + return response + + def put_integration( + self, context: RequestContext, request: PutIntegrationRequest, **kwargs + ) -> Integration: + if (integration_type := request.get("type")) not in VALID_INTEGRATION_TYPES: + raise CommonServiceException( + "ValidationException", + f"1 validation error detected: Value '{integration_type}' at " + f"'putIntegrationInput.type' failed to satisfy constraint: " + f"Member must satisfy enum value set: [HTTP, MOCK, AWS_PROXY, HTTP_PROXY, AWS]", + ) + + elif integration_type in (IntegrationType.AWS_PROXY, IntegrationType.AWS): + if not request.get("integrationHttpMethod"): + raise BadRequestException("Enumeration value for HttpMethod must be non-empty") + if not (integration_uri := request.get("uri") or "").startswith("arn:"): + raise BadRequestException("Invalid ARN specified in the request") + + try: + parsed_arn = parse_arn(integration_uri) + except InvalidArnException: + raise BadRequestException("Invalid ARN specified in the request") + + if not any( + parsed_arn["resource"].startswith(action_type) for action_type in ("path", "action") + ): + raise BadRequestException("AWS ARN for integration must contain path or action") + + if integration_type == IntegrationType.AWS_PROXY and ( + parsed_arn["account"] != "lambda" + or not parsed_arn["resource"].startswith("path/2015-03-31/functions/") + ): + # the Firehose message is misleading, this is not implemented in AWS + raise BadRequestException( + "Integrations of type 'AWS_PROXY' currently only supports " + "Lambda function and Firehose stream invocations." + ) + + moto_rest_api = get_moto_rest_api(context=context, rest_api_id=request.get("restApiId")) + resource = moto_rest_api.resources.get(request.get("resourceId")) + if not resource: + raise NotFoundException("Invalid Resource identifier specified") + + method = resource.resource_methods.get(request.get("httpMethod")) + if not method: + raise NotFoundException("Invalid Method identifier specified") + + # TODO: if the IntegrationType is AWS, `credentials` is mandatory + moto_request = copy.copy(request) + moto_request.setdefault("passthroughBehavior", "WHEN_NO_MATCH") + moto_request.setdefault("timeoutInMillis", 29000) + if integration_type in (IntegrationType.HTTP, IntegrationType.HTTP_PROXY): + moto_request.setdefault("connectionType", ConnectionType.INTERNET) + response = call_moto_with_request(context, moto_request) + remove_empty_attributes_from_integration(integration=response) + + # TODO: should fix fundamentally once we move away from moto + if integration_type == "MOCK": + response.pop("uri", None) + + return response + + def update_integration( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Integration: + moto_rest_api = get_moto_rest_api(context=context, rest_api_id=rest_api_id) + resource = moto_rest_api.resources.get(resource_id) + if not resource: + raise NotFoundException("Invalid Resource identifier specified") + + method = resource.resource_methods.get(http_method) + if not method: + raise NotFoundException("Invalid Integration identifier specified") + + integration = method.method_integration + patch_api_gateway_entity(integration, patch_operations) + + # fix data types + if integration.timeout_in_millis: + integration.timeout_in_millis = int(integration.timeout_in_millis) + if skip_verification := (integration.tls_config or {}).get("insecureSkipVerification"): + integration.tls_config["insecureSkipVerification"] = str_to_bool(skip_verification) + + integration_dict: Integration = integration.to_json() + return integration_dict + + def delete_integration( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + **kwargs, + ) -> None: + try: + call_moto(context) + except Exception as e: + raise NotFoundException("Invalid Resource identifier specified") from e + + # integration responses + + def get_integration_response( + self, + context: RequestContext, + rest_api_id: String, + resource_id: String, + http_method: String, + status_code: StatusCode, + **kwargs, + ) -> IntegrationResponse: + if not re.fullmatch(r"[1-5]\d\d", status_code): + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{status_code}' at 'statusCode' failed to " + f"satisfy constraint: Member must satisfy regular expression pattern: [1-5]\\d\\d", + ) + try: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + except NotFoundException: + raise NotFoundException("Invalid Resource identifier specified") + + if not (moto_resource := moto_rest_api.resources.get(resource_id)): + raise NotFoundException("Invalid Resource identifier specified") + + if not (moto_method := moto_resource.resource_methods.get(http_method)): + raise NotFoundException("Invalid Method identifier specified") + + if not moto_method.method_integration: + raise NotFoundException("Invalid Integration identifier specified") + if not ( + integration_responses := moto_method.method_integration.integration_responses + ) or not (integration_response := integration_responses.get(status_code)): + raise NotFoundException("Invalid Response status code specified") + + response: IntegrationResponse = call_moto(context) + remove_empty_attributes_from_integration_response(response) + # moto does not return selectionPattern is set to an empty string + # TODO: fix upstream + if ( + "selectionPattern" not in response + and integration_response.selection_pattern is not None + ): + response["selectionPattern"] = integration_response.selection_pattern + return response + + @handler("PutIntegrationResponse", expand=False) + def put_integration_response( + self, + context: RequestContext, + request: PutIntegrationResponseRequest, + ) -> IntegrationResponse: + status_code = request.get("statusCode") + if not re.fullmatch(r"[1-5]\d\d", status_code): + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{status_code}' at 'statusCode' failed to " + f"satisfy constraint: Member must satisfy regular expression pattern: [1-5]\\d\\d", + ) + try: + # put integration response doesn't return the right exception compared to AWS + moto_rest_api = get_moto_rest_api(context=context, rest_api_id=request.get("restApiId")) + except NotFoundException: + raise NotFoundException("Invalid Resource identifier specified") + + moto_resource = moto_rest_api.resources.get(request.get("resourceId")) + if not moto_resource: + raise NotFoundException("Invalid Resource identifier specified") + + method = moto_resource.resource_methods.get(request.get("httpMethod")) + if not method: + raise NotFoundException("Invalid Method identifier specified") + + response = call_moto(context) + # Moto has a specific case where it will set a None to an empty dict, but AWS does not behave the same + if request.get("responseTemplates") is None: + method_integration = moto_resource.resource_methods[ + request["httpMethod"] + ].method_integration + integration_response = method_integration.integration_responses[request["statusCode"]] + integration_response.response_templates = None + response.pop("responseTemplates", None) + + # Moto also does not return the selection pattern if it is set to an empty string + # TODO: fix upstream + if (selection_pattern := request.get("selectionPattern")) is not None: + response["selectionPattern"] = selection_pattern + + return response + + def get_export( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String, + export_type: String, + parameters: MapOfStringToString = None, + accepts: String = None, + **kwargs, + ) -> ExportResponse: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + openapi_exporter = OpenApiExporter() + # FIXME: look into parser why `parameters` is always None + has_extension = context.request.values.get("extensions") == "apigateway" + result = openapi_exporter.export_api( + api_id=rest_api_id, + stage=stage_name, + export_type=export_type, + export_format=accepts, + with_extension=has_extension, + account_id=context.account_id, + region_name=context.region, + ) + + accepts = accepts or APPLICATION_JSON + + if accepts == APPLICATION_JSON: + result = json.dumps(result, indent=2) + + file_ext = accepts.split("/")[-1] + version = moto_rest_api.version or timestamp( + moto_rest_api.create_date, format=TIMESTAMP_FORMAT_TZ + ) + return ExportResponse( + body=to_bytes(result), + contentType="application/octet-stream", + contentDisposition=f'attachment; filename="{export_type}_{version}.{file_ext}"', + ) + + def get_api_keys( + self, + context: RequestContext, + position: String = None, + limit: NullableInteger = None, + name_query: String = None, + customer_id: String = None, + include_values: NullableBoolean = None, + **kwargs, + ) -> ApiKeys: + # TODO: migrate API keys in our store + moto_backend = get_moto_backend(context.account_id, context.region) + api_keys = [api_key.to_json() for api_key in reversed(moto_backend.keys.values())] + if not include_values: + for api_key in api_keys: + api_key.pop("value") + + item_list = PaginatedList(api_keys) + + def token_generator(item): + return md5(item["id"]) + + def filter_function(item): + return item["name"].startswith(name_query) + + paginated_list, next_token = item_list.get_page( + token_generator=token_generator, + next_token=position, + page_size=limit, + filter_function=filter_function if name_query else None, + ) + + return ApiKeys(items=paginated_list, position=next_token) + + def update_api_key( + self, + context: RequestContext, + api_key: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> ApiKey: + response: ApiKey = call_moto(context) + if "value" in response: + response.pop("value", None) + + if "tags" not in response: + response["tags"] = {} + + return response + + def create_model( + self, + context: RequestContext, + rest_api_id: String, + name: String, + content_type: String, + description: String = None, + schema: String = None, + **kwargs, + ) -> Model: + store = get_apigateway_store(context=context) + if rest_api_id not in store.rest_apis: + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + if not name: + raise BadRequestException("Model name must be non-empty") + + if name in store.rest_apis[rest_api_id].models: + raise ConflictException("Model name already exists for this REST API") + + if not schema: + # TODO: maybe add more validation around the schema, valid json string? + raise BadRequestException( + "Model schema must have at least 1 property or array items defined" + ) + + model_id = short_uid()[:6] # length 6 to make TF tests pass + model = Model( + id=model_id, name=name, contentType=content_type, description=description, schema=schema + ) + store.rest_apis[rest_api_id].models[name] = model + remove_empty_attributes_from_model(model) + return model + + def get_models( + self, + context: RequestContext, + rest_api_id: String, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> Models: + store = get_apigateway_store(context=context) + if rest_api_id not in store.rest_apis: + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + models = [ + remove_empty_attributes_from_model(model) + for model in store.rest_apis[rest_api_id].models.values() + ] + return Models(items=models) + + def get_model( + self, + context: RequestContext, + rest_api_id: String, + model_name: String, + flatten: Boolean = None, + **kwargs, + ) -> Model: + store = get_apigateway_store(context=context) + if rest_api_id not in store.rest_apis or not ( + model := store.rest_apis[rest_api_id].models.get(model_name) + ): + raise NotFoundException(f"Invalid model name specified: {model_name}") + + return model + + def update_model( + self, + context: RequestContext, + rest_api_id: String, + model_name: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Model: + # manually update the model, not need for JSON patch, only 2 path supported with replace operation + # /schema + # /description + store = get_apigateway_store(context=context) + if rest_api_id not in store.rest_apis or not ( + model := store.rest_apis[rest_api_id].models.get(model_name) + ): + raise NotFoundException(f"Invalid model name specified: {model_name}") + + for operation in patch_operations: + path = operation.get("path") + if operation.get("op") != "replace": + raise BadRequestException( + f"Invalid patch path '{path}' specified for op 'add'. Please choose supported operations" + ) + if path not in ("/schema", "/description"): + raise BadRequestException( + f"Invalid patch path '{path}' specified for op 'replace'. Must be one of: [/description, /schema]" + ) + + key = path[1:] # remove the leading slash + value = operation.get("value") + if key == "schema": + if not value: + raise BadRequestException( + "Model schema must have at least 1 property or array items defined" + ) + # delete the resolved model to invalidate it + store.rest_apis[rest_api_id].resolved_models.pop(model_name, None) + model[key] = value + remove_empty_attributes_from_model(model) + return model + + def delete_model( + self, context: RequestContext, rest_api_id: String, model_name: String, **kwargs + ) -> None: + store = get_apigateway_store(context=context) + + if ( + rest_api_id not in store.rest_apis + or model_name not in store.rest_apis[rest_api_id].models + ): + raise NotFoundException(f"Invalid model name specified: {model_name}") + + moto_rest_api = get_moto_rest_api(context, rest_api_id) + validate_model_in_use(moto_rest_api, model_name) + + store.rest_apis[rest_api_id].models.pop(model_name, None) + store.rest_apis[rest_api_id].resolved_models.pop(model_name, None) + + @handler("CreateUsagePlan") + def create_usage_plan( + self, + context: RequestContext, + name: String, + description: String = None, + api_stages: ListOfApiStage = None, + throttle: ThrottleSettings = None, + quota: QuotaSettings = None, + tags: MapOfStringToString = None, + **kwargs, + ) -> UsagePlan: + usage_plan: UsagePlan = call_moto(context=context) + if not usage_plan.get("quota"): + usage_plan.pop("quota", None) + + fix_throttle_and_quota_from_usage_plan(usage_plan) + + return usage_plan + + def update_usage_plan( + self, + context: RequestContext, + usage_plan_id: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> UsagePlan: + for patch_op in patch_operations: + if patch_op.get("op") == "remove" and patch_op.get("path") == "/apiStages": + if not (api_stage_id := patch_op.get("value")): + raise BadRequestException("Invalid API Stage specified") + if not len(split_stage_id := api_stage_id.split(":")) == 2: + raise BadRequestException("Invalid API Stage specified") + rest_api_id, stage_name = split_stage_id + moto_backend = apigw_models.apigateway_backends[context.account_id][context.region] + if not (rest_api := moto_backend.apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API Stage {{api: {rest_api_id}, stage: {stage_name}}} specified for usageplan {usage_plan_id}" + ) + if stage_name not in rest_api.stages: + raise NotFoundException( + f"Invalid API Stage {{api: {rest_api_id}, stage: {stage_name}}} specified for usageplan {usage_plan_id}" + ) + + usage_plan = call_moto(context=context) + if not usage_plan.get("quota"): + usage_plan.pop("quota", None) + + usage_plan_arn = f"arn:{get_partition(context.region)}:apigateway:{context.region}::/usageplans/{usage_plan_id}" + existing_tags = get_apigateway_store(context=context).TAGS.get(usage_plan_arn, {}) + if "tags" not in usage_plan: + usage_plan["tags"] = existing_tags + else: + usage_plan["tags"].update(existing_tags) + + fix_throttle_and_quota_from_usage_plan(usage_plan) + + return usage_plan + + def get_usage_plan(self, context: RequestContext, usage_plan_id: String, **kwargs) -> UsagePlan: + usage_plan: UsagePlan = call_moto(context=context) + if not usage_plan.get("quota"): + usage_plan.pop("quota", None) + + fix_throttle_and_quota_from_usage_plan(usage_plan) + + usage_plan_arn = f"arn:{get_partition(context.region)}:apigateway:{context.region}::/usageplans/{usage_plan_id}" + existing_tags = get_apigateway_store(context=context).TAGS.get(usage_plan_arn, {}) + if "tags" not in usage_plan: + usage_plan["tags"] = existing_tags + else: + usage_plan["tags"].update(existing_tags) + + return usage_plan + + @handler("GetUsagePlans") + def get_usage_plans( + self, + context: RequestContext, + position: String = None, + key_id: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> UsagePlans: + usage_plans: UsagePlans = call_moto(context=context) + if not usage_plans.get("items"): + usage_plans["items"] = [] + + items = usage_plans["items"] + for up in items: + if not up.get("quota"): + up.pop("quota", None) + + fix_throttle_and_quota_from_usage_plan(up) + + if "tags" not in up: + up.pop("tags", None) + + return usage_plans + + def get_usage_plan_keys( + self, + context: RequestContext, + usage_plan_id: String, + position: String = None, + limit: NullableInteger = None, + name_query: String = None, + **kwargs, + ) -> UsagePlanKeys: + # TODO: migrate Usage Plan and UsagePlan Keys to our store + moto_backend = get_moto_backend(context.account_id, context.region) + + if not (usage_plan_keys := moto_backend.usage_plan_keys.get(usage_plan_id)): + return UsagePlanKeys(items=[]) + + usage_plan_keys = [ + usage_plan_key.to_json() + for usage_plan_key in reversed(usage_plan_keys.values()) + if usage_plan_key.id in moto_backend.keys + ] + + item_list = PaginatedList(usage_plan_keys) + + def token_generator(item): + return md5(item["id"]) + + def filter_function(item): + return item["name"].startswith(name_query) + + paginated_list, next_token = item_list.get_page( + token_generator=token_generator, + next_token=position, + page_size=limit, + filter_function=filter_function if name_query else None, + ) + + return UsagePlanKeys(items=paginated_list, position=next_token) + + def put_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + status_code: StatusCode = None, + response_parameters: MapOfStringToString = None, + response_templates: MapOfStringToString = None, + **kwargs, + ) -> GatewayResponse: + # There were no validation in moto, so implementing as is + # TODO: add validation + # TODO: this is only the CRUD implementation, implement it in the invocation part of the code + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + if response_type not in DEFAULT_GATEWAY_RESPONSES: + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{response_type}' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(DEFAULT_GATEWAY_RESPONSES)}]", + ) + + gateway_response = GatewayResponse( + statusCode=status_code, + responseParameters=response_parameters, + responseTemplates=response_templates, + responseType=response_type, + defaultResponse=False, + ) + rest_api_container.gateway_responses[response_type] = gateway_response + return gateway_response + + def get_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + **kwargs, + ) -> GatewayResponse: + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + if response_type not in DEFAULT_GATEWAY_RESPONSES: + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{response_type}' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(DEFAULT_GATEWAY_RESPONSES)}]", + ) + + gateway_response = rest_api_container.gateway_responses.get( + response_type, DEFAULT_GATEWAY_RESPONSES[response_type] + ) + # TODO: add validation with the parameters? seems like it validated client side? how to try? + return gateway_response + + def get_gateway_responses( + self, + context: RequestContext, + rest_api_id: String, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> GatewayResponses: + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + user_gateway_resp = rest_api_container.gateway_responses + gateway_responses = [ + user_gateway_resp.get(key) or value for key, value in DEFAULT_GATEWAY_RESPONSES.items() + ] + return GatewayResponses(items=gateway_responses) + + def delete_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + **kwargs, + ) -> None: + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + if response_type not in DEFAULT_GATEWAY_RESPONSES: + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{response_type}' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(DEFAULT_GATEWAY_RESPONSES)}]", + ) + + if not rest_api_container.gateway_responses.pop(response_type, None): + raise NotFoundException("Gateway response type not defined on api") + + def update_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> GatewayResponse: + """ + Support operations table: + Path | op:add | op:replace | op:remove | op:copy + /statusCode | Not supported | Supported | Not supported | Not supported + /responseParameters | Supported | Supported | Supported | Not supported + /responseTemplates | Supported | Supported | Supported | Not supported + See https://docs.aws.amazon.com/apigateway/latest/api/patch-operations.html#UpdateGatewayResponse-Patch + """ + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + if response_type not in DEFAULT_GATEWAY_RESPONSES: + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{response_type}' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(DEFAULT_GATEWAY_RESPONSES)}]", + ) + + if response_type not in rest_api_container.gateway_responses: + # deep copy to avoid in place mutation of the default response when update using JSON patch + rest_api_container.gateway_responses[response_type] = copy.deepcopy( + DEFAULT_GATEWAY_RESPONSES[response_type] + ) + rest_api_container.gateway_responses[response_type]["defaultResponse"] = False + + patched_entity = rest_api_container.gateway_responses[response_type] + + for index, operation in enumerate(patch_operations): + if (op := operation.get("op")) not in VALID_PATCH_OPERATIONS: + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{op}' at 'updateGatewayResponseInput.patchOperations.{index + 1}.member.op' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(VALID_PATCH_OPERATIONS)}]", + ) + + path = operation.get("path", "null") + if not any( + path.startswith(s_path) + for s_path in ("/statusCode", "/responseParameters", "/responseTemplates") + ): + raise BadRequestException(f"Invalid patch path {path}") + + if op in ("add", "remove") and path == "/statusCode": + raise BadRequestException(f"Invalid patch path {path}") + + elif op in ("add", "replace"): + for param_type in ("responseParameters", "responseTemplates"): + if path.startswith(f"/{param_type}"): + if op == "replace": + param = path.removeprefix(f"/{param_type}/") + param = param.replace("~1", "/") + if param not in patched_entity.get(param_type): + raise NotFoundException("Invalid parameter name specified") + if operation.get("value") is None: + raise BadRequestException( + f"Invalid null or empty value in {param_type}" + ) + + patch_api_gateway_entity(patched_entity, patch_operations) + + return patched_entity + + # TODO + + +# --------------- +# UTIL FUNCTIONS +# --------------- + + +def remove_empty_attributes_from_rest_api(rest_api: RestApi, remove_tags=True) -> RestApi: + if not rest_api.get("binaryMediaTypes"): + rest_api.pop("binaryMediaTypes", None) + + if not isinstance(rest_api.get("minimumCompressionSize"), int): + rest_api.pop("minimumCompressionSize", None) + + if not rest_api.get("tags"): + if remove_tags: + rest_api.pop("tags", None) + else: + # if `tags` is falsy, set it to an empty dict + rest_api["tags"] = {} + + if not rest_api.get("version"): + rest_api.pop("version", None) + if not rest_api.get("description"): + rest_api.pop("description", None) + + return rest_api + + +def remove_empty_attributes_from_method(method: Method) -> Method: + if not method.get("methodResponses"): + method.pop("methodResponses", None) + + if method.get("requestModels") is None: + method.pop("requestModels", None) + + if method.get("requestParameters") is None: + method.pop("requestParameters", None) + + return method + + +def remove_empty_attributes_from_integration(integration: Integration): + if not integration: + return integration + + if not integration.get("integrationResponses"): + integration.pop("integrationResponses", None) + + if integration.get("requestParameters") is None: + integration.pop("requestParameters", None) + + return integration + + +def remove_empty_attributes_from_model(model: Model) -> Model: + if not model.get("description"): + model.pop("description", None) + + return model + + +def remove_empty_attributes_from_integration_response(integration_response: IntegrationResponse): + if integration_response.get("responseTemplates") is None: + integration_response.pop("responseTemplates", None) + + return integration_response + + +def fix_throttle_and_quota_from_usage_plan(usage_plan: UsagePlan) -> None: + if quota := usage_plan.get("quota"): + if "offset" not in quota: + quota["offset"] = 0 + else: + usage_plan.pop("quota", None) + + if throttle := usage_plan.get("throttle"): + if rate_limit := throttle.get("rateLimit"): + throttle["rateLimit"] = float(rate_limit) + + if burst_limit := throttle.get("burstLimit"): + throttle["burstLimit"] = int(burst_limit) + else: + usage_plan.pop("throttle", None) + + +def validate_model_in_use(moto_rest_api: MotoRestAPI, model_name: str) -> None: + for resource in moto_rest_api.resources.values(): + for method in resource.resource_methods.values(): + if method.request_models and model_name in set(method.request_models.values()): + path = f"{resource.get_path()}/{method.http_method}" + raise ConflictException( + f"Cannot delete model '{model_name}', is referenced in method request: {path}" + ) + + +def get_moto_rest_api_root_resource(moto_rest_api: MotoRestAPI) -> str: + for res_id, res_obj in moto_rest_api.resources.items(): + if res_obj.path_part == "/" and not res_obj.parent_id: + return res_id + raise Exception(f"Unable to find root resource for API {moto_rest_api.id}") + + +def create_custom_context( + context: RequestContext, action: str, parameters: ServiceRequest +) -> RequestContext: + ctx = create_aws_request_context( + service_name=context.service.service_name, + action=action, + parameters=parameters, + region=context.region, + ) + ctx.request.headers.update(context.request.headers) + ctx.account_id = context.account_id + return ctx + + +def patch_api_gateway_entity(entity: Any, patch_operations: ListOfPatchOperation): + patch_operations = patch_operations or [] + + if isinstance(entity, dict): + entity_dict = entity + else: + if not isinstance(entity.__dict__, DelSafeDict): + entity.__dict__ = DelSafeDict(entity.__dict__) + entity_dict = entity.__dict__ + + not_supported_attributes = {"/id", "/region_name", "/create_date"} + + model_attributes = list(entity_dict.keys()) + for operation in patch_operations: + path_start = operation["path"].strip("/").split("/")[0] + path_start_usc = camelcase_to_underscores(path_start) + if path_start not in model_attributes and path_start_usc in model_attributes: + operation["path"] = operation["path"].replace(path_start, path_start_usc) + if operation["path"] in not_supported_attributes: + raise BadRequestException(f"Invalid patch path {operation['path']}") + + apply_json_patch_safe(entity_dict, patch_operations, in_place=True) + + +def to_authorizer_response_json(api_id, data): + result = to_response_json("authorizer", data, api_id=api_id) + result = select_from_typed_dict(Authorizer, result) + return result + + +def to_validator_response_json(api_id, data): + result = to_response_json("validator", data, api_id=api_id) + result = select_from_typed_dict(RequestValidator, result) + return result + + +def to_documentation_part_response_json(api_id, data): + result = to_response_json("documentationpart", data, api_id=api_id) + result = select_from_typed_dict(DocumentationPart, result) + return result + + +def to_base_mapping_response_json(domain_name, base_path, data): + self_link = "/domainnames/%s/basepathmappings/%s" % (domain_name, base_path) + result = to_response_json("basepathmapping", data, self_link=self_link) + result = select_from_typed_dict(BasePathMapping, result) + return result + + +def to_account_response_json(data): + result = to_response_json("account", data, self_link="/account") + result = select_from_typed_dict(Account, result) + return result + + +def to_vpc_link_response_json(data): + result = to_response_json("vpclink", data) + result = select_from_typed_dict(VpcLink, result) + return result + + +def to_client_cert_response_json(data): + result = to_response_json("clientcertificate", data, id_attr="clientCertificateId") + result = select_from_typed_dict(ClientCertificate, result) + return result + + +def to_rest_api_response_json(data): + result = to_response_json("restapi", data) + result = select_from_typed_dict(RestApi, result) + return result + + +def to_response_json(model_type, data, api_id=None, self_link=None, id_attr=None): + if isinstance(data, list) and len(data) == 1: + data = data[0] + id_attr = id_attr or "id" + result = deepcopy(data) + if not self_link: + self_link = "/%ss/%s" % (model_type, data[id_attr]) + if api_id: + self_link = "/restapis/%s/%s" % (api_id, self_link) + # TODO: check if this is still required - "_links" are listed in the sample responses in the docs, but + # recent parity tests indicate that this field is not returned by real AWS... + # https://docs.aws.amazon.com/apigateway/latest/api/API_GetAuthorizers.html#API_GetAuthorizers_Example_1_Response + if "_links" not in result: + result["_links"] = {} + result["_links"]["self"] = {"href": self_link} + result["_links"]["curies"] = { + "href": "https://docs.aws.amazon.com/apigateway/latest/developerguide/restapi-authorizer-latest.html", + "name": model_type, + "templated": True, + } + result["_links"]["%s:delete" % model_type] = {"href": self_link} + return result + + +DEFAULT_EMPTY_MODEL = Model( + id=short_uid()[:6], + name=EMPTY_MODEL, + contentType="application/json", + description="This is a default empty schema model", + schema=json.dumps( + { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Empty Schema", + "type": "object", + } + ), +) + +DEFAULT_ERROR_MODEL = Model( + id=short_uid()[:6], + name=ERROR_MODEL, + contentType="application/json", + description="This is a default error schema model", + schema=json.dumps( + { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Error Schema", + "type": "object", + "properties": {"message": {"type": "string"}}, + } + ), +) + + +# TODO: maybe extract this in its own files, or find a better generalizable way +UPDATE_METHOD_PATCH_PATHS = { + "supported_paths": [ + "/authorizationScopes", + "/authorizationType", + "/authorizerId", + "/apiKeyRequired", + "/operationName", + "/requestParameters/", + "/requestModels/", + "/requestValidatorId", + ], + "add": [ + "/authorizationScopes", + "/requestParameters/", + "/requestModels/", + ], + "remove": [ + "/authorizationScopes", + "/requestParameters/", + "/requestModels/", + ], + "replace": [ + "/authorizationType", + "/authorizerId", + "/apiKeyRequired", + "/operationName", + "/requestParameters/", + "/requestModels/", + "/requestValidatorId", + ], +} + +DEFAULT_GATEWAY_RESPONSES: dict[GatewayResponseType, GatewayResponse] = { + GatewayResponseType.REQUEST_TOO_LARGE: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "REQUEST_TOO_LARGE", + "statusCode": "413", + }, + GatewayResponseType.RESOURCE_NOT_FOUND: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "RESOURCE_NOT_FOUND", + "statusCode": "404", + }, + GatewayResponseType.AUTHORIZER_CONFIGURATION_ERROR: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "AUTHORIZER_CONFIGURATION_ERROR", + "statusCode": "500", + }, + GatewayResponseType.MISSING_AUTHENTICATION_TOKEN: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "403", + }, + GatewayResponseType.BAD_REQUEST_BODY: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "BAD_REQUEST_BODY", + "statusCode": "400", + }, + GatewayResponseType.INVALID_SIGNATURE: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "INVALID_SIGNATURE", + "statusCode": "403", + }, + GatewayResponseType.INVALID_API_KEY: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "INVALID_API_KEY", + "statusCode": "403", + }, + GatewayResponseType.BAD_REQUEST_PARAMETERS: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "BAD_REQUEST_PARAMETERS", + "statusCode": "400", + }, + GatewayResponseType.AUTHORIZER_FAILURE: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "AUTHORIZER_FAILURE", + "statusCode": "500", + }, + GatewayResponseType.UNAUTHORIZED: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "UNAUTHORIZED", + "statusCode": "401", + }, + GatewayResponseType.INTEGRATION_TIMEOUT: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "INTEGRATION_TIMEOUT", + "statusCode": "504", + }, + GatewayResponseType.ACCESS_DENIED: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "ACCESS_DENIED", + "statusCode": "403", + }, + GatewayResponseType.DEFAULT_4XX: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "DEFAULT_4XX", + }, + GatewayResponseType.DEFAULT_5XX: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "DEFAULT_5XX", + }, + GatewayResponseType.WAF_FILTERED: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "WAF_FILTERED", + "statusCode": "403", + }, + GatewayResponseType.QUOTA_EXCEEDED: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "QUOTA_EXCEEDED", + "statusCode": "429", + }, + GatewayResponseType.THROTTLED: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "THROTTLED", + "statusCode": "429", + }, + GatewayResponseType.API_CONFIGURATION_ERROR: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "API_CONFIGURATION_ERROR", + "statusCode": "500", + }, + GatewayResponseType.UNSUPPORTED_MEDIA_TYPE: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "UNSUPPORTED_MEDIA_TYPE", + "statusCode": "415", + }, + GatewayResponseType.INTEGRATION_FAILURE: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "INTEGRATION_FAILURE", + "statusCode": "504", + }, + GatewayResponseType.EXPIRED_TOKEN: { + "defaultResponse": True, + "responseParameters": {}, + "responseTemplates": {"application/json": '{"message":$context.error.messageString}'}, + "responseType": "EXPIRED_TOKEN", + "statusCode": "403", + }, +} + +VALID_PATCH_OPERATIONS = ["add", "remove", "move", "test", "replace", "copy"] diff --git a/localstack-core/localstack/services/apigateway/legacy/router_asf.py b/localstack-core/localstack/services/apigateway/legacy/router_asf.py new file mode 100644 index 0000000000000..0664c98c56f20 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/legacy/router_asf.py @@ -0,0 +1,160 @@ +import logging +from typing import Any, Dict + +from requests.models import Response as RequestsResponse +from werkzeug.datastructures import Headers +from werkzeug.exceptions import NotFound + +from localstack.constants import HEADER_LOCALSTACK_EDGE_URL +from localstack.http import Request, Response, Router +from localstack.http.dispatcher import Handler +from localstack.http.request import restore_payload +from localstack.services.apigateway.legacy.context import ApiInvocationContext +from localstack.services.apigateway.legacy.helpers import get_api_account_id_and_region +from localstack.services.apigateway.legacy.invocations import invoke_rest_api_from_request +from localstack.utils.aws.aws_responses import LambdaResponse +from localstack.utils.strings import remove_leading_extra_slashes + +LOG = logging.getLogger(__name__) + + +# TODO: with the latest snapshot tests, we might start moving away from the +# invocation context property decorators and use the url_params directly, +# something asked for a long time. +def to_invocation_context( + request: Request, url_params: Dict[str, Any] = None +) -> ApiInvocationContext: + """ + Converts an HTTP Request object into an ApiInvocationContext. + + :param request: the original request + :param url_params: the parameters extracted from the URL matching rules + :return: the ApiInvocationContext + """ + if url_params is None: + url_params = {} + + method = request.method + # Base path is not URL-decoded. + # Example: test%2Balias@gmail.com => test%2Balias@gmail.com + raw_uri = path = request.environ.get("RAW_URI") + if raw_uri.startswith("//"): + # if starts with //, then replace the first // with / + path = remove_leading_extra_slashes(raw_uri) + + data = restore_payload(request) + headers = Headers(request.headers) + + # TODO: verify that this is needed + # adjust the X-Forwarded-For header + x_forwarded_for = headers.getlist("X-Forwarded-For") + x_forwarded_for.append(request.remote_addr) + x_forwarded_for.append(request.host) + headers["X-Forwarded-For"] = ", ".join(x_forwarded_for) + + # set the x-localstack-edge header, it is used to parse the domain + headers[HEADER_LOCALSTACK_EDGE_URL] = request.host_url.strip("/") + + # FIXME: Use the already parsed url params instead of parsing them into the ApiInvocationContext part-by-part. + # We already would have all params at hand to avoid _all_ the parsing, but the parsing + # has side-effects (f.e. setting the region in a thread local)! + # It would be best to use a small (immutable) context for the already parsed params and the Request object + # and use it everywhere. + ctx = ApiInvocationContext(method, path, data, headers, stage=url_params.get("stage")) + ctx.raw_uri = raw_uri + ctx.auth_identity["sourceIp"] = request.remote_addr + + return ctx + + +def convert_response(result: RequestsResponse) -> Response: + """ + Utility function to convert a response for the requests library to our internal (Werkzeug based) Response object. + """ + if result is None: + return Response() + + if isinstance(result, LambdaResponse): + headers = Headers(dict(result.headers)) + for k, values in result.multi_value_headers.items(): + for value in values: + headers.add(k, value) + else: + headers = dict(result.headers) + + response = Response(status=result.status_code, headers=headers) + + if isinstance(result.content, dict): + response.set_json(result.content) + elif isinstance(result.content, (str, bytes)): + response.data = result.content + else: + raise ValueError(f"Unhandled content type {type(result.content)}") + + return response + + +class ApigatewayRouter: + """ + Simple implementation around a Router to manage dynamic restapi routes (routes added by a user through the + apigateway API). + """ + + router: Router[Handler] + + def __init__(self, router: Router[Handler]): + self.router = router + self.registered = False + + def register_routes(self) -> None: + """Registers parameterized routes for API Gateway user invocations.""" + if self.registered: + LOG.debug("Skipped API Gateway route registration (routes already registered).") + return + self.registered = True + LOG.debug("Registering parameterized API Gateway routes.") + host_pattern = ".execute-api." + self.router.add( + "/", + host=host_pattern, + endpoint=self.invoke_rest_api, + defaults={"path": "", "stage": None}, + strict_slashes=True, + ) + self.router.add( + "//", + host=host_pattern, + endpoint=self.invoke_rest_api, + defaults={"path": ""}, + strict_slashes=False, + ) + self.router.add( + "//", + host=host_pattern, + endpoint=self.invoke_rest_api, + strict_slashes=True, + ) + + # add the localstack-specific _user_request_ routes + self.router.add( + "/restapis///_user_request_", + endpoint=self.invoke_rest_api, + defaults={"path": ""}, + ) + self.router.add( + "/restapis///_user_request_/", + endpoint=self.invoke_rest_api, + strict_slashes=True, + ) + + def invoke_rest_api(self, request: Request, **url_params: str) -> Response: + account_id, region_name = get_api_account_id_and_region(url_params["api_id"]) + if not region_name: + return Response(status=404) + invocation_context = to_invocation_context(request, url_params) + invocation_context.region_name = region_name + invocation_context.account_id = account_id + result = invoke_rest_api_from_request(invocation_context) + if result is not None: + return convert_response(result) + raise NotFound() diff --git a/localstack-core/localstack/services/apigateway/legacy/templates.py b/localstack-core/localstack/services/apigateway/legacy/templates.py new file mode 100644 index 0000000000000..0ae853981ac02 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/legacy/templates.py @@ -0,0 +1,381 @@ +import base64 +import copy +import json +import logging +from enum import Enum +from typing import Any, Dict, Union +from urllib.parse import quote_plus, unquote_plus + +import xmltodict + +from localstack import config +from localstack.constants import APPLICATION_JSON, APPLICATION_XML +from localstack.services.apigateway.legacy.context import ApiInvocationContext +from localstack.services.apigateway.legacy.helpers import select_integration_response +from localstack.utils.aws.templating import APIGW_SOURCE, VelocityUtil, VtlTemplate +from localstack.utils.json import extract_jsonpath, json_safe, try_json +from localstack.utils.strings import to_str + +LOG = logging.getLogger(__name__) + + +class PassthroughBehavior(Enum): + WHEN_NO_MATCH = "WHEN_NO_MATCH" + WHEN_NO_TEMPLATES = "WHEN_NO_TEMPLATES" + NEVER = "NEVER" + + +class MappingTemplates: + """ + API Gateway uses mapping templates to transform incoming requests before they are sent to the + integration back end. With API Gateway, you can define one mapping template for each possible + content type. The content type selection is based on the Content-Type header of the incoming + request. If no content type is specified in the request, API Gateway uses an application/json + mapping template. By default, mapping templates are configured to simply pass through the + request input. Mapping templates use Apache Velocity to generate a request to your back end. + """ + + passthrough_behavior: PassthroughBehavior + + class UnsupportedMediaType(Exception): + pass + + def __init__(self, passthrough_behaviour: str): + self.passthrough_behavior = self.get_passthrough_behavior(passthrough_behaviour) + + def check_passthrough_behavior(self, request_template): + """ + Specifies how the method request body of an unmapped content type will be passed through + the integration request to the back end without transformation. + A content type is unmapped if no mapping template is defined in the integration or the + content type does not match any of the mapped content types, as specified in requestTemplates + """ + if not request_template and self.passthrough_behavior in { + PassthroughBehavior.NEVER, + PassthroughBehavior.WHEN_NO_TEMPLATES, + }: + raise MappingTemplates.UnsupportedMediaType() + + @staticmethod + def get_passthrough_behavior(passthrough_behaviour: str): + return getattr(PassthroughBehavior, passthrough_behaviour, None) + + +class AttributeDict(dict): + """ + Wrapper returned by VelocityUtilApiGateway.parseJson to allow access to dict values as attributes (dot notation), + e.g.: $util.parseJson('$.foo').bar + """ + + def __init__(self, *args, **kwargs): + super(AttributeDict, self).__init__(*args, **kwargs) + for key, value in self.items(): + if isinstance(value, dict): + self[key] = AttributeDict(value) + + def __getattr__(self, name): + if name in self: + return self[name] + raise AttributeError(f"'AttributeDict' object has no attribute '{name}'") + + def __setattr__(self, name, value): + self[name] = value + + def __delattr__(self, name): + if name in self: + del self[name] + else: + raise AttributeError(f"'AttributeDict' object has no attribute '{name}'") + + +class VelocityUtilApiGateway(VelocityUtil): + """ + Simple class to mimic the behavior of variable '$util' in AWS API Gateway integration + velocity templates. + See: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + """ + + def base64Encode(self, s): + if not isinstance(s, str): + s = json.dumps(s) + encoded_str = s.encode(config.DEFAULT_ENCODING) + encoded_b64_str = base64.b64encode(encoded_str) + return encoded_b64_str.decode(config.DEFAULT_ENCODING) + + def base64Decode(self, s): + if not isinstance(s, str): + s = json.dumps(s) + return base64.b64decode(s) + + def toJson(self, obj): + return obj and json.dumps(obj) + + def urlEncode(self, s): + return quote_plus(s) + + def urlDecode(self, s): + return unquote_plus(s) + + def escapeJavaScript(self, obj: Any) -> str: + """ + Converts the given object to a string and escapes any regular single quotes (') into escaped ones (\'). + JSON dumps will escape the single quotes. + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + """ + if obj is None: + return "null" + if isinstance(obj, str): + # empty string escapes to empty object + if len(obj.strip()) == 0: + return "{}" + return json.dumps(obj)[1:-1] + if obj in (True, False): + return str(obj).lower() + return str(obj) + + def parseJson(self, s: str): + obj = json.loads(s) + return AttributeDict(obj) if isinstance(obj, dict) else obj + + +class VelocityInput: + """ + Simple class to mimic the behavior of variable '$input' in AWS API Gateway integration + velocity templates. + See: http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + """ + + def __init__(self, body, params): + self.parameters = params or {} + self.value = body + + def path(self, path): + if not self.value: + return {} + value = self.value if isinstance(self.value, dict) else json.loads(self.value) + return extract_jsonpath(value, path) + + def json(self, path): + path = path or "$" + matching = self.path(path) + if isinstance(matching, (list, dict)): + matching = json_safe(matching) + return json.dumps(matching) + + @property + def body(self): + return self.value + + def params(self, name=None): + if not name: + return self.parameters + for k in ["path", "querystring", "header"]: + if val := self.parameters.get(k).get(name): + return val + return "" + + def __getattr__(self, name): + return self.value.get(name) + + def __repr__(self): + return "$input" + + +class ApiGatewayVtlTemplate(VtlTemplate): + """Util class for rendering VTL templates with API Gateway specific extensions""" + + def prepare_namespace(self, variables, source: str = APIGW_SOURCE) -> Dict[str, Any]: + namespace = super().prepare_namespace(variables, source) + if stage_var := variables.get("stage_variables") or {}: + namespace["stageVariables"] = stage_var + input_var = variables.get("input") or {} + variables = { + "input": VelocityInput(input_var.get("body"), input_var.get("params")), + "util": VelocityUtilApiGateway(), + } + namespace.update(variables) + return namespace + + +class Templates: + __slots__ = ["vtl"] + + def __init__(self): + self.vtl = ApiGatewayVtlTemplate() + + def render(self, api_context: ApiInvocationContext) -> Union[bytes, str]: + pass + + def render_vtl(self, template, variables): + return self.vtl.render_vtl(template, variables=variables) + + @staticmethod + def build_variables_mapping(api_context: ApiInvocationContext) -> dict[str, Any]: + # TODO: make this (dict) an object so usages of "render_vtl" variables are defined + ctx = copy.deepcopy(api_context.context or {}) + # https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-override-request-response-parameters.html + # create namespace for request override + ctx["requestOverride"] = { + "header": {}, + "path": {}, + "querystring": {}, + } + + ctx["responseOverride"] = { + "header": {}, + "status": 200, + } + + return { + "context": ctx, + "stage_variables": api_context.stage_variables or {}, + "input": { + "body": api_context.data_as_string(), + "params": { + "path": api_context.path_params, + "querystring": api_context.query_params(), + # Sometimes we get a werkzeug.datastructures.Headers object, sometimes a dict + # depending on the request. We need to convert to a dict to be able to render + # the template. + "header": dict(api_context.headers), + }, + }, + } + + +class RequestTemplates(Templates): + """ + Handles request template rendering + """ + + def render( + self, api_context: ApiInvocationContext, template_key: str = APPLICATION_JSON + ) -> Union[bytes, str]: + LOG.debug( + "Method request body before transformations: %s", to_str(api_context.data_as_string()) + ) + request_templates = api_context.integration.get("requestTemplates", {}) + template = request_templates.get(template_key) + if not template: + return api_context.data_as_string() + + variables = self.build_variables_mapping(api_context) + result = self.render_vtl(template.strip(), variables=variables) + + # set the request overrides into context + api_context.headers.update( + variables.get("context", {}).get("requestOverride", {}).get("header", {}) + ) + + LOG.debug("Endpoint request body after transformations:\n%s", result) + return result + + +class ResponseTemplates(Templates): + """ + Handles response template rendering. The integration response status code is used to select + the correct template to render, if there is no template for the status code, the default + template is used. + """ + + def render(self, api_context: ApiInvocationContext, **kwargs) -> Union[bytes, str]: + # XXX: keep backwards compatibility until we migrate all integrations to this new classes + # api_context contains a response object that we want slowly remove from it + data = kwargs.get("response", "") + response = data or api_context.response + integration = api_context.integration + # we set context data with the response content because later on we use context data as + # the body field in the template. We need to improve this by using the right source + # depending on the type of templates. + api_context.data = response._content + + # status code returned by the integration + status_code = str(response.status_code) + + # get the integration responses configuration from the integration object + integration_responses = integration.get("integrationResponses") + if not integration_responses: + return response._content + + # get the configured integration response status codes, + # e.g. ["200", "400", "500"] + integration_status_codes = [str(code) for code in list(integration_responses.keys())] + # if there are no integration responses, we return the response as is + if not integration_status_codes: + return response.content + + # The following code handles two use cases.If there is an integration response for the status code returned + # by the integration, we use the template configured for that status code (1) or the errorMessage (2) for + # lambda integrations. + # For an HTTP integration, API Gateway matches the regex to the HTTP status code to return + # For a Lambda function, API Gateway matches the regex to the errorMessage header to + # return a status code. + # For example, to set a 400 response for any error that starts with Malformed, + # set the method response status code to 400 and the Lambda error regex to Malformed.*. + match_resp = status_code + if isinstance(try_json(response._content), dict): + resp_dict = try_json(response._content) + if "errorMessage" in resp_dict: + match_resp = resp_dict.get("errorMessage") + + selected_integration_response = select_integration_response(match_resp, api_context) + response.status_code = int(selected_integration_response.get("statusCode", 200)) + response_templates = selected_integration_response.get("responseTemplates", {}) + + # we only support JSON and XML templates for now - if there is no template we return the response as is + # If the content type is not supported we always use application/json as default value + # TODO - support other content types, besides application/json and application/xml + # see https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html#selecting-mapping-templates + accept = api_context.headers.get("accept", APPLICATION_JSON) + supported_types = [APPLICATION_JSON, APPLICATION_XML] + media_type = accept if accept in supported_types else APPLICATION_JSON + if not (template := response_templates.get(media_type, {})): + return response._content + + # we render the template with the context data and the response content + variables = self.build_variables_mapping(api_context) + # update the response body + response._content = self._render_as_text(template, variables) + if media_type == APPLICATION_JSON: + self._validate_json(response.content) + elif media_type == APPLICATION_XML: + self._validate_xml(response.content) + + if response_overrides := variables.get("context", {}).get("responseOverride", {}): + response.headers.update(response_overrides.get("header", {}).items()) + response.status_code = response_overrides.get("status", 200) + + LOG.debug("Endpoint response body after transformations:\n%s", response._content) + return response._content + + def _render_as_text(self, template: str, variables: dict[str, Any]) -> str: + """ + Render the given Velocity template string + variables into a plain string. + :return: the template rendering result as a string + """ + rendered_tpl = self.render_vtl(template, variables=variables) + return rendered_tpl.strip() + + @staticmethod + def _validate_json(content: str): + """ + Checks that the content received is a valid JSON. + :raise JSONDecodeError: if content is not valid JSON + """ + try: + json.loads(content) + except Exception as e: + LOG.info("Unable to parse template result as JSON: %s - %s", e, content) + raise + + @staticmethod + def _validate_xml(content: str): + """ + Checks that the content received is a valid XML. + :raise xml.parsers.expat.ExpatError: if content is not valid XML + """ + try: + xmltodict.parse(content) + except Exception as e: + LOG.info("Unable to parse template result as XML: %s - %s", e, content) + raise diff --git a/localstack-core/localstack/services/apigateway/models.py b/localstack-core/localstack/services/apigateway/models.py new file mode 100644 index 0000000000000..44fca6b65ae29 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/models.py @@ -0,0 +1,155 @@ +from typing import Any, Dict, List + +from requests.structures import CaseInsensitiveDict + +from localstack.aws.api.apigateway import ( + Authorizer, + DocumentationPart, + DocumentationVersion, + DomainName, + GatewayResponse, + GatewayResponseType, + Model, + RequestValidator, + Resource, + RestApi, +) +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossAccountAttribute, + CrossRegionAttribute, + LocalAttribute, +) +from localstack.utils.aws import arns + + +class RestApiContainer: + # contains the RestApi dictionary. We're not making use of it yet, still using moto data. + rest_api: RestApi + # maps AuthorizerId -> Authorizer + authorizers: Dict[str, Authorizer] + # maps RequestValidatorId -> RequestValidator + validators: Dict[str, RequestValidator] + # map DocumentationPartId -> DocumentationPart + documentation_parts: Dict[str, DocumentationPart] + # map doc version name -> DocumentationVersion + documentation_versions: Dict[str, DocumentationVersion] + # not used yet, still in moto + gateway_responses: Dict[GatewayResponseType, GatewayResponse] + # maps Model name -> Model + models: Dict[str, Model] + # maps Model name -> resolved dict Model, so we don't need to load the JSON everytime + resolved_models: Dict[str, dict] + # maps ResourceId of a Resource to its children ResourceIds + resource_children: Dict[str, List[str]] + + def __init__(self, rest_api: RestApi): + self.rest_api = rest_api + self.authorizers = {} + self.validators = {} + self.documentation_parts = {} + self.documentation_versions = {} + self.gateway_responses = {} + self.models = {} + self.resolved_models = {} + self.resource_children = {} + + +class MergedRestApi(RestApiContainer): + """Merged REST API between Moto data and LocalStack data, used in our Invocation logic""" + + # TODO: when migrating away from Moto, RestApiContainer and MergedRestApi will have the same signature, so we can + # safely remove it and only use RestApiContainer in our invocation logic + resources: dict[str, Resource] + + def __init__(self, rest_api: RestApi): + super().__init__(rest_api) + self.resources = {} + + @classmethod + def from_rest_api_container( + cls, + rest_api_container: RestApiContainer, + resources: dict[str, Resource], + ) -> "MergedRestApi": + merged = cls(rest_api=rest_api_container.rest_api) + merged.authorizers = rest_api_container.authorizers + merged.validators = rest_api_container.validators + merged.documentation_parts = rest_api_container.documentation_parts + merged.documentation_versions = rest_api_container.documentation_versions + merged.gateway_responses = rest_api_container.gateway_responses + merged.models = rest_api_container.models + merged.resolved_models = rest_api_container.resolved_models + merged.resource_children = rest_api_container.resource_children + merged.resources = resources + + return merged + + +class RestApiDeployment: + def __init__( + self, + account_id: str, + region: str, + rest_api: MergedRestApi, + ): + self.rest_api = rest_api + self.account_id = account_id + self.region = region + + +class ApiGatewayStore(BaseStore): + # maps (API id) -> RestApiContainer + # TODO: remove CaseInsensitiveDict, and lower the value of the ID when getting it from the tags + rest_apis: Dict[str, RestApiContainer] = LocalAttribute(default=CaseInsensitiveDict) + + # account details + _account: Dict[str, Any] = LocalAttribute(default=dict) + + # maps (domain_name) -> [path_mappings] + base_path_mappings: Dict[str, List[Dict]] = LocalAttribute(default=dict) + + # maps ID to VPC link details + vpc_links: Dict[str, Dict] = LocalAttribute(default=dict) + + # maps cert ID to client certificate details + client_certificates: Dict[str, Dict] = LocalAttribute(default=dict) + + # maps domain name to domain name model + domain_names: Dict[str, DomainName] = LocalAttribute(default=dict) + + # maps resource ARN to tags + TAGS: Dict[str, Dict[str, str]] = CrossRegionAttribute(default=dict) + + # internal deployments, represents a frozen REST API for a deployment, used in our router + # TODO: make sure API ID are unique across all accounts + # maps ApiID to a map of deploymentId and RestApiDeployment, an executable/snapshot of a REST API + internal_deployments: dict[str, dict[str, RestApiDeployment]] = CrossAccountAttribute( + default=dict + ) + + # active deployments, mapping API ID to a map of Stage and deployment ID + # TODO: make sure API ID are unique across all accounts + active_deployments: dict[str, dict[str, str]] = CrossAccountAttribute(dict) + + def __init__(self): + super().__init__() + + @property + def account(self): + if not self._account: + self._account.update( + { + "cloudwatchRoleArn": arns.iam_role_arn( + "api-gw-cw-role", self._account_id, self._region_name + ), + "throttleSettings": {"burstLimit": 1000, "rateLimit": 500}, + "features": ["UsagePlans"], + "apiKeyVersion": "1", + } + ) + return self._account + + +apigateway_stores = AccountRegionBundle("apigateway", ApiGatewayStore) diff --git a/localstack/utils/cloudformation/__init__.py b/localstack-core/localstack/services/apigateway/next_gen/__init__.py similarity index 100% rename from localstack/utils/cloudformation/__init__.py rename to localstack-core/localstack/services/apigateway/next_gen/__init__.py diff --git a/localstack/utils/cloudwatch/__init__.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/__init__.py similarity index 100% rename from localstack/utils/cloudwatch/__init__.py rename to localstack-core/localstack/services/apigateway/next_gen/execute_api/__init__.py diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/api.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/api.py new file mode 100644 index 0000000000000..843938e0611ed --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/api.py @@ -0,0 +1,17 @@ +from typing import Callable, Type + +from rolo import Response +from rolo.gateway.chain import HandlerChain as RoloHandlerChain + +from .context import RestApiInvocationContext + +RestApiGatewayHandler = Callable[ + [RoloHandlerChain[RestApiInvocationContext], RestApiInvocationContext, Response], None +] + +RestApiGatewayExceptionHandler = Callable[ + [RoloHandlerChain[RestApiInvocationContext], Exception, RestApiInvocationContext, Response], + None, +] + +RestApiGatewayHandlerChain: Type[RoloHandlerChain[RestApiInvocationContext]] = RoloHandlerChain diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py new file mode 100644 index 0000000000000..9f6be795d9af8 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/context.py @@ -0,0 +1,141 @@ +from http import HTTPMethod +from typing import Optional, TypedDict + +from rolo import Request +from rolo.gateway import RequestContext +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import Integration, Method, Resource, Stage +from localstack.services.apigateway.models import RestApiDeployment + +from .variables import ContextVariableOverrides, ContextVariables, LoggingContextVariables + + +class InvocationRequest(TypedDict, total=False): + http_method: HTTPMethod + """HTTP Method of the incoming request""" + raw_path: Optional[str] + # TODO: verify if raw_path is needed + """Raw path of the incoming request with no modification, needed to keep double forward slashes""" + path: Optional[str] + """Path of the request with no URL decoding""" + path_parameters: Optional[dict[str, str]] + """Path parameters of the request""" + query_string_parameters: dict[str, str] + """Query string parameters of the request""" + headers: Headers + """Raw headers using the Headers datastructure which allows access with no regards to casing""" + multi_value_query_string_parameters: dict[str, list[str]] + """Multi value query string parameters of the request""" + body: bytes + """Body content of the request""" + + +class IntegrationRequest(TypedDict, total=False): + http_method: HTTPMethod + """HTTP Method of the incoming request""" + uri: str + """URI of the integration""" + query_string_parameters: dict[str, str | list[str]] + """Query string parameters of the request""" + headers: Headers + """Headers of the request""" + body: bytes + """Body content of the request""" + + +class BaseResponse(TypedDict): + """Base class for Response objects in the context""" + + status_code: int + """Status code of the response""" + headers: Headers + """Headers of the response""" + body: bytes + """Body content of the response""" + + +class EndpointResponse(BaseResponse): + """Represents the response coming from an integration, called Endpoint Response in AWS""" + + pass + + +class InvocationResponse(BaseResponse): + """Represents the response coming after being serialized in an Integration Response in AWS""" + + pass + + +class RestApiInvocationContext(RequestContext): + """ + This context is going to be used to pass relevant information across an API Gateway invocation. + """ + + deployment: Optional[RestApiDeployment] + """Contains the invoked REST API Resources""" + integration: Optional[Integration] + """The Method Integration for the invoked request""" + api_id: Optional[str] + """The REST API identifier of the invoked API""" + stage: Optional[str] + """The REST API stage name linked to this invocation""" + base_path: Optional[str] + """The REST API base path mapped to the stage of this invocation""" + deployment_id: Optional[str] + """The REST API deployment linked to this invocation""" + region: Optional[str] + """The region the REST API is living in.""" + account_id: Optional[str] + """The account the REST API is living in.""" + trace_id: Optional[str] + """The X-Ray trace ID for the request.""" + resource: Optional[Resource] + """The resource the invocation matched""" + resource_method: Optional[Method] + """The method of the resource the invocation matched""" + stage_variables: Optional[dict[str, str]] + """The Stage variables, also used in parameters mapping and mapping templates""" + stage_configuration: Optional[Stage] + """The Stage configuration, containing canary deployment settings""" + is_canary: Optional[bool] + """If the current call was directed to a canary deployment""" + context_variables: Optional[ContextVariables] + """The $context used in data models, authorizers, mapping templates, and CloudWatch access logging""" + context_variable_overrides: Optional[ContextVariableOverrides] + """requestOverrides and responseOverrides are passed from request templates to response templates but are + not in the integration context""" + logging_context_variables: Optional[LoggingContextVariables] + """Additional $context variables available only for access logging, not yet implemented""" + invocation_request: Optional[InvocationRequest] + """Contains the data relative to the invocation request""" + integration_request: Optional[IntegrationRequest] + """Contains the data needed to construct an HTTP request to an Integration""" + endpoint_response: Optional[EndpointResponse] + """Contains the data returned by an Integration""" + invocation_response: Optional[InvocationResponse] + """Contains the data serialized and to be returned by an invocation""" + + def __init__(self, request: Request): + super().__init__(request) + self.deployment = None + self.api_id = None + self.stage = None + self.base_path = None + self.deployment_id = None + self.account_id = None + self.region = None + self.invocation_request = None + self.resource = None + self.resource_method = None + self.integration = None + self.stage_variables = None + self.stage_configuration = None + self.is_canary = None + self.context_variables = None + self.logging_context_variables = None + self.integration_request = None + self.endpoint_response = None + self.invocation_response = None + self.trace_id = None + self.context_variable_overrides = None diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/gateway.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/gateway.py new file mode 100644 index 0000000000000..85a31da903fde --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/gateway.py @@ -0,0 +1,50 @@ +from rolo import Response +from rolo.gateway import Gateway + +from . import handlers +from .context import RestApiInvocationContext + + +class RestApiGateway(Gateway): + """ + This class controls the main path of an API Gateway REST API. It contains the definitions of the different handlers + to be called as part of the different steps of the invocation of the API. + + For now, you can extend the behavior of the invocation by adding handlers to the `preprocess_request` + CompositeHandler. + The documentation of this class will be extended as more behavior will be added to its handlers, as well as more + ways to extend it. + """ + + def __init__(self): + super().__init__(context_class=RestApiInvocationContext) + self.request_handlers.extend( + [ + handlers.parse_request, + handlers.modify_request, + handlers.route_request, + handlers.preprocess_request, + handlers.api_key_validation_handler, + handlers.method_request_handler, + handlers.integration_request_handler, + handlers.integration_handler, + handlers.integration_response_handler, + handlers.method_response_handler, + ] + ) + self.exception_handlers.extend( + [ + handlers.gateway_exception_handler, + ] + ) + self.response_handlers.extend( + [ + handlers.response_enricher, + handlers.usage_counter, + # add composite response handlers? + ] + ) + + def process_with_context(self, context: RestApiInvocationContext, response: Response): + chain = self.new_chain() + chain.handle(context, response) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/gateway_response.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/gateway_response.py new file mode 100644 index 0000000000000..a0e9935ccf775 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/gateway_response.py @@ -0,0 +1,298 @@ +from enum import Enum + +from localstack.aws.api.apigateway import ( + GatewayResponse, + GatewayResponseType, + MapOfStringToString, + StatusCode, +) +from localstack.constants import APPLICATION_JSON + + +class GatewayResponseCode(StatusCode, Enum): + REQUEST_TOO_LARGE = "413" + RESOURCE_NOT_FOUND = "404" + AUTHORIZER_CONFIGURATION_ERROR = "500" + MISSING_AUTHENTICATION_TOKEN = "403" + BAD_REQUEST_BODY = "400" + INVALID_SIGNATURE = "403" + INVALID_API_KEY = "403" + BAD_REQUEST_PARAMETERS = "400" + AUTHORIZER_FAILURE = "500" + UNAUTHORIZED = "401" + INTEGRATION_TIMEOUT = "504" + ACCESS_DENIED = "403" + DEFAULT_4XX = "" + DEFAULT_5XX = "" + WAF_FILTERED = "403" + QUOTA_EXCEEDED = "429" + THROTTLED = "429" + API_CONFIGURATION_ERROR = "500" + UNSUPPORTED_MEDIA_TYPE = "415" + INTEGRATION_FAILURE = "504" + EXPIRED_TOKEN = "403" + + +class BaseGatewayException(Exception): + """ + Base class for all Gateway exceptions + Do not raise from this class directly. Instead, raise the specific Exception + """ + + message: str = "Unimplemented Response" + type: GatewayResponseType = None + status_code: int | str = None + code: str = "" + + def __init__(self, message: str = None, status_code: int | str = None): + if message is not None: + self.message = message + if status_code is not None: + self.status_code = status_code + elif self.status_code is None and self.type: + # Fallback to the default value + self.status_code = GatewayResponseCode[self.type] + + +class Default4xxError(BaseGatewayException): + """Do not raise from this class directly. + Use one of the subclasses instead, as they contain the appropriate header + """ + + type = GatewayResponseType.DEFAULT_4XX + status_code = 400 + + +class Default5xxError(BaseGatewayException): + """Do not raise from this class directly. + Use one of the subclasses instead, as they contain the appropriate header + """ + + type = GatewayResponseType.DEFAULT_5XX + status_code = 500 + + +class BadRequestException(Default4xxError): + code = "BadRequestException" + + +class InternalFailureException(Default5xxError): + code = "InternalFailureException" + + +class InternalServerError(Default5xxError): + code = "InternalServerErrorException" + + +class AccessDeniedError(BaseGatewayException): + type = GatewayResponseType.ACCESS_DENIED + # TODO validate this header with aws validated tests + code = "AccessDeniedException" + + +class ApiConfigurationError(BaseGatewayException): + type = GatewayResponseType.API_CONFIGURATION_ERROR + # TODO validate this header with aws validated tests + code = "ApiConfigurationException" + + +class AuthorizerConfigurationError(BaseGatewayException): + type = GatewayResponseType.AUTHORIZER_CONFIGURATION_ERROR + # TODO validate this header with aws validated tests + code = "AuthorizerConfigurationException" + # the message is set to None by default in AWS + message = None + + +class AuthorizerFailureError(BaseGatewayException): + type = GatewayResponseType.AUTHORIZER_FAILURE + # TODO validate this header with aws validated tests + code = "AuthorizerFailureException" + + +class BadRequestParametersError(BaseGatewayException): + type = GatewayResponseType.BAD_REQUEST_PARAMETERS + code = "BadRequestException" + + +class BadRequestBodyError(BaseGatewayException): + type = GatewayResponseType.BAD_REQUEST_BODY + code = "BadRequestException" + + +class ExpiredTokenError(BaseGatewayException): + type = GatewayResponseType.EXPIRED_TOKEN + # TODO validate this header with aws validated tests + code = "ExpiredTokenException" + + +class IntegrationFailureError(BaseGatewayException): + type = GatewayResponseType.INTEGRATION_FAILURE + code = "InternalServerErrorException" + status_code = 500 + + +class IntegrationTimeoutError(BaseGatewayException): + type = GatewayResponseType.INTEGRATION_TIMEOUT + code = "InternalServerErrorException" + + +class InvalidAPIKeyError(BaseGatewayException): + type = GatewayResponseType.INVALID_API_KEY + code = "ForbiddenException" + + +class InvalidSignatureError(BaseGatewayException): + type = GatewayResponseType.INVALID_SIGNATURE + # TODO validate this header with aws validated tests + code = "InvalidSignatureException" + + +class MissingAuthTokenError(BaseGatewayException): + type = GatewayResponseType.MISSING_AUTHENTICATION_TOKEN + code = "MissingAuthenticationTokenException" + + +class QuotaExceededError(BaseGatewayException): + type = GatewayResponseType.QUOTA_EXCEEDED + code = "LimitExceededException" + + +class RequestTooLargeError(BaseGatewayException): + type = GatewayResponseType.REQUEST_TOO_LARGE + # TODO validate this header with aws validated tests + code = "RequestTooLargeException" + + +class ResourceNotFoundError(BaseGatewayException): + type = GatewayResponseType.RESOURCE_NOT_FOUND + # TODO validate this header with aws validated tests + code = "ResourceNotFoundException" + + +class ThrottledError(BaseGatewayException): + type = GatewayResponseType.THROTTLED + code = "TooManyRequestsException" + + +class UnauthorizedError(BaseGatewayException): + type = GatewayResponseType.UNAUTHORIZED + code = "UnauthorizedException" + + +class UnsupportedMediaTypeError(BaseGatewayException): + type = GatewayResponseType.UNSUPPORTED_MEDIA_TYPE + code = "BadRequestException" + + +class WafFilteredError(BaseGatewayException): + type = GatewayResponseType.WAF_FILTERED + # TODO validate this header with aws validated tests + code = "WafFilteredException" + + +def build_gateway_response( + response_type: GatewayResponseType, + status_code: StatusCode = None, + response_parameters: MapOfStringToString = None, + response_templates: MapOfStringToString = None, + default_response: bool = True, +) -> GatewayResponse: + """Building a Gateway Response. Non provided attributes will use default.""" + response = GatewayResponse( + responseParameters=response_parameters or {}, + responseTemplates=response_templates + or {APPLICATION_JSON: '{"message":$context.error.messageString}'}, + responseType=response_type, + defaultResponse=default_response, + statusCode=status_code, + ) + + return response + + +def get_gateway_response_or_default( + response_type: GatewayResponseType, + gateway_responses: dict[GatewayResponseType, GatewayResponse], +) -> GatewayResponse: + """Utility function that will look for a matching Gateway Response in the following order. + - If provided in the gateway_response, return the dicts value + - If the DEFAULT_XXX was configured will create a new response + - Otherwise we return from DEFAULT_GATEWAY_RESPONSE""" + + if response := gateway_responses.get(response_type): + # User configured response + return response + response_code = GatewayResponseCode[response_type] + if response_code == "": + # DEFAULT_XXX response do not have a default code + return DEFAULT_GATEWAY_RESPONSES.get(response_type) + if response_code >= "500": + # 5XX response will either get a user configured DEFAULT_5XX or the DEFAULT_GATEWAY_RESPONSES + default = gateway_responses.get(GatewayResponseType.DEFAULT_5XX) + else: + # 4XX response will either get a user configured DEFAULT_4XX or the DEFAULT_GATEWAY_RESPONSES + default = gateway_responses.get(GatewayResponseType.DEFAULT_4XX) + + if not default: + # If DEFAULT_XXX was not provided return default + return DEFAULT_GATEWAY_RESPONSES.get(response_type) + + return build_gateway_response( + # Build a new response from default + response_type, + status_code=default.get("statusCode"), + response_parameters=default.get("responseParameters"), + response_templates=default.get("responseTemplates"), + ) + + +DEFAULT_GATEWAY_RESPONSES = { + GatewayResponseType.REQUEST_TOO_LARGE: build_gateway_response( + GatewayResponseType.REQUEST_TOO_LARGE + ), + GatewayResponseType.RESOURCE_NOT_FOUND: build_gateway_response( + GatewayResponseType.RESOURCE_NOT_FOUND + ), + GatewayResponseType.AUTHORIZER_CONFIGURATION_ERROR: build_gateway_response( + GatewayResponseType.AUTHORIZER_CONFIGURATION_ERROR + ), + GatewayResponseType.MISSING_AUTHENTICATION_TOKEN: build_gateway_response( + GatewayResponseType.MISSING_AUTHENTICATION_TOKEN + ), + GatewayResponseType.BAD_REQUEST_BODY: build_gateway_response( + GatewayResponseType.BAD_REQUEST_BODY + ), + GatewayResponseType.INVALID_SIGNATURE: build_gateway_response( + GatewayResponseType.INVALID_SIGNATURE + ), + GatewayResponseType.INVALID_API_KEY: build_gateway_response( + GatewayResponseType.INVALID_API_KEY + ), + GatewayResponseType.BAD_REQUEST_PARAMETERS: build_gateway_response( + GatewayResponseType.BAD_REQUEST_PARAMETERS + ), + GatewayResponseType.AUTHORIZER_FAILURE: build_gateway_response( + GatewayResponseType.AUTHORIZER_FAILURE + ), + GatewayResponseType.UNAUTHORIZED: build_gateway_response(GatewayResponseType.UNAUTHORIZED), + GatewayResponseType.INTEGRATION_TIMEOUT: build_gateway_response( + GatewayResponseType.INTEGRATION_TIMEOUT + ), + GatewayResponseType.ACCESS_DENIED: build_gateway_response(GatewayResponseType.ACCESS_DENIED), + GatewayResponseType.DEFAULT_4XX: build_gateway_response(GatewayResponseType.DEFAULT_4XX), + GatewayResponseType.DEFAULT_5XX: build_gateway_response(GatewayResponseType.DEFAULT_5XX), + GatewayResponseType.WAF_FILTERED: build_gateway_response(GatewayResponseType.WAF_FILTERED), + GatewayResponseType.QUOTA_EXCEEDED: build_gateway_response(GatewayResponseType.QUOTA_EXCEEDED), + GatewayResponseType.THROTTLED: build_gateway_response(GatewayResponseType.THROTTLED), + GatewayResponseType.API_CONFIGURATION_ERROR: build_gateway_response( + GatewayResponseType.API_CONFIGURATION_ERROR + ), + GatewayResponseType.UNSUPPORTED_MEDIA_TYPE: build_gateway_response( + GatewayResponseType.UNSUPPORTED_MEDIA_TYPE + ), + GatewayResponseType.INTEGRATION_FAILURE: build_gateway_response( + GatewayResponseType.INTEGRATION_FAILURE + ), + GatewayResponseType.EXPIRED_TOKEN: build_gateway_response(GatewayResponseType.EXPIRED_TOKEN), +} diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/__init__.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/__init__.py new file mode 100644 index 0000000000000..e9e1dcb618166 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/__init__.py @@ -0,0 +1,29 @@ +from rolo.gateway import CompositeHandler + +from localstack.services.apigateway.analytics import invocation_counter + +from .analytics import IntegrationUsageCounter +from .api_key_validation import ApiKeyValidationHandler +from .gateway_exception import GatewayExceptionHandler +from .integration import IntegrationHandler +from .integration_request import IntegrationRequestHandler +from .integration_response import IntegrationResponseHandler +from .method_request import MethodRequestHandler +from .method_response import MethodResponseHandler +from .parse import InvocationRequestParser +from .resource_router import InvocationRequestRouter +from .response_enricher import InvocationResponseEnricher + +parse_request = InvocationRequestParser() +modify_request = CompositeHandler() +route_request = InvocationRequestRouter() +preprocess_request = CompositeHandler() +method_request_handler = MethodRequestHandler() +integration_request_handler = IntegrationRequestHandler() +integration_handler = IntegrationHandler() +integration_response_handler = IntegrationResponseHandler() +method_response_handler = MethodResponseHandler() +gateway_exception_handler = GatewayExceptionHandler() +api_key_validation_handler = ApiKeyValidationHandler() +response_enricher = InvocationResponseEnricher() +usage_counter = IntegrationUsageCounter(counter=invocation_counter) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py new file mode 100644 index 0000000000000..46fe8d06a9e9e --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/analytics.py @@ -0,0 +1,48 @@ +import logging + +from localstack.http import Response +from localstack.utils.analytics.metrics import LabeledCounter + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import RestApiInvocationContext + +LOG = logging.getLogger(__name__) + + +class IntegrationUsageCounter(RestApiGatewayHandler): + counter: LabeledCounter + + def __init__(self, counter: LabeledCounter): + self.counter = counter + + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + if context.integration: + invocation_type = context.integration["type"] + if invocation_type == "AWS": + service_name = self._get_aws_integration_service(context.integration.get("uri")) + invocation_type = f"{invocation_type}:{service_name}" + else: + # if the invocation does not have an integration attached, it probably failed before routing the request, + # hence we should count it as a NOT_FOUND invocation + invocation_type = "NOT_FOUND" + + self.counter.labels(invocation_type=invocation_type).increment() + + @staticmethod + def _get_aws_integration_service(integration_uri: str) -> str: + if not integration_uri: + return "null" + + if len(split_arn := integration_uri.split(":", maxsplit=5)) < 4: + return "null" + + service = split_arn[4] + # the URI can also contain some .-api kind of route like `execute-api` or `appsync-api` + # we need to make sure we do not pass the full value back + service = service.split(".")[-1] + return service diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/api_key_validation.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/api_key_validation.py new file mode 100644 index 0000000000000..ba8ada9769f17 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/api_key_validation.py @@ -0,0 +1,113 @@ +import logging +from typing import Optional + +from localstack.aws.api.apigateway import ApiKey, ApiKeySourceType, RestApi +from localstack.http import Response + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import InvocationRequest, RestApiInvocationContext +from ..gateway_response import InvalidAPIKeyError +from ..moto_helpers import get_api_key, get_usage_plan_keys, get_usage_plans +from ..variables import ContextVarsIdentity + +LOG = logging.getLogger(__name__) + + +class ApiKeyValidationHandler(RestApiGatewayHandler): + """ + Handles Api key validation. + If an api key is required, we will validate that a usage plan associated with that stage + has a usage plan key with the corresponding value. + """ + + # TODO We currently do not support rate limiting or quota limit. As such we are not raising any related Exception + + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + method = context.resource_method + request = context.invocation_request + rest_api = context.deployment.rest_api.rest_api + + # If api key is not required by the method, we can exit the handler + if not method.get("apiKeyRequired"): + return + + identity = context.context_variables.get("identity") + + # Look for the api key value in the request. If it is not found, raise an exception + if not (api_key_value := self.get_request_api_key(rest_api, request, identity)): + LOG.debug("API Key is empty") + raise InvalidAPIKeyError("Forbidden") + + # Get the validated key, if no key is found, raise an exception + if not (validated_key := self.validate_api_key(api_key_value, context)): + LOG.debug("Provided API Key is not valid") + raise InvalidAPIKeyError("Forbidden") + + # Update the context's identity with the key value and id + if not identity.get("apiKey"): + LOG.debug("Updating $context.identity.apiKey='%s'", validated_key["value"]) + identity["apiKey"] = validated_key["value"] + + LOG.debug("Updating $context.identity.apiKeyId='%s'", validated_key["id"]) + identity["apiKeyId"] = validated_key["id"] + + def validate_api_key( + self, api_key_value, context: RestApiInvocationContext + ) -> Optional[ApiKey]: + api_id = context.api_id + stage = context.stage + account_id = context.account_id + region = context.region + + # Get usage plans from the store + usage_plans = get_usage_plans(account_id=account_id, region_name=region) + + # Loop through usage plans and keep ids of the plans associated with the deployment stage + usage_plan_ids = [] + for usage_plan in usage_plans: + api_stages = usage_plan.get("apiStages", []) + usage_plan_ids.extend( + usage_plan.get("id") + for api_stage in api_stages + if (api_stage.get("stage") == stage and api_stage.get("apiId") == api_id) + ) + if not usage_plan_ids: + LOG.debug("No associated usage plans found stage '%s'", stage) + return + + # Loop through plans with an association with the stage find a key with matching value + for usage_plan_id in usage_plan_ids: + usage_plan_keys = get_usage_plan_keys( + usage_plan_id=usage_plan_id, account_id=account_id, region_name=region + ) + for key in usage_plan_keys: + if key["value"] == api_key_value: + api_key = get_api_key( + api_key_id=key["id"], account_id=account_id, region_name=region + ) + LOG.debug("Found Api Key '%s'", api_key["id"]) + return api_key if api_key["enabled"] else None + + def get_request_api_key( + self, rest_api: RestApi, request: InvocationRequest, identity: ContextVarsIdentity + ) -> Optional[str]: + """https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-key-source.html + The source of the API key for metering requests according to a usage plan. + Valid values are: + - HEADER to read the API key from the X-API-Key header of a request. + - AUTHORIZER to read the API key from the Context Variables. + """ + match api_key_source := rest_api.get("apiKeySource"): + case ApiKeySourceType.HEADER: + LOG.debug("Looking for api key in header 'X-API-Key'") + return request.get("headers", {}).get("X-API-Key") + case ApiKeySourceType.AUTHORIZER: + LOG.debug("Looking for api key in Identity Context") + return identity.get("apiKey") + case _: + LOG.debug("Api Key Source is not valid: '%s'", api_key_source) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/gateway_exception.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/gateway_exception.py new file mode 100644 index 0000000000000..174b2cf8c1bc2 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/gateway_exception.py @@ -0,0 +1,98 @@ +import json +import logging + +from rolo import Response +from werkzeug.datastructures import Headers + +from localstack.constants import APPLICATION_JSON +from localstack.services.apigateway.next_gen.execute_api.api import ( + RestApiGatewayExceptionHandler, + RestApiGatewayHandlerChain, +) +from localstack.services.apigateway.next_gen.execute_api.context import RestApiInvocationContext +from localstack.services.apigateway.next_gen.execute_api.gateway_response import ( + AccessDeniedError, + BaseGatewayException, + get_gateway_response_or_default, +) +from localstack.services.apigateway.next_gen.execute_api.variables import ( + GatewayResponseContextVarsError, +) + +LOG = logging.getLogger(__name__) + + +class GatewayExceptionHandler(RestApiGatewayExceptionHandler): + """ + Exception handler that serializes the Gateway Exceptions into Gateway Responses + """ + + def __call__( + self, + chain: RestApiGatewayHandlerChain, + exception: Exception, + context: RestApiInvocationContext, + response: Response, + ): + if not isinstance(exception, BaseGatewayException): + LOG.warning( + "Non Gateway Exception raised: %s", + exception, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + response.update_from( + Response(response=f"Error in apigateway invocation: {exception}", status="500") + ) + return + + LOG.info("Error raised during invocation: %s", exception.type) + self.set_error_context(exception, context) + error = self.create_exception_response(exception, context) + if error: + response.update_from(error) + + @staticmethod + def set_error_context(exception: BaseGatewayException, context: RestApiInvocationContext): + context.context_variables["error"] = GatewayResponseContextVarsError( + message=exception.message, + messageString=exception.message, + responseType=exception.type, + validationErrorString="", # TODO + ) + + def create_exception_response( + self, exception: BaseGatewayException, context: RestApiInvocationContext + ): + gateway_response = get_gateway_response_or_default( + exception.type, context.deployment.rest_api.gateway_responses + ) + + content = self._build_response_content(exception) + + headers = self._build_response_headers(exception) + + status_code = gateway_response.get("statusCode") + if not status_code: + status_code = exception.status_code or 500 + + response = Response(response=content, headers=headers, status=status_code) + return response + + @staticmethod + def _build_response_content(exception: BaseGatewayException) -> str: + # TODO apply responseTemplates to the content. We should also handle the default simply by managing the default + # template body `{"message":$context.error.messageString}` + + # TODO: remove this workaround by properly managing the responseTemplate for UnauthorizedError + # on the CRUD level, it returns the same template as all other errors but in reality the message field is + # capitalized + if isinstance(exception, AccessDeniedError): + return json.dumps({"Message": exception.message}, separators=(",", ":")) + + return json.dumps({"message": exception.message}) + + @staticmethod + def _build_response_headers(exception: BaseGatewayException) -> dict: + # TODO apply responseParameters to the headers and get content-type from the gateway_response + headers = Headers({"Content-Type": APPLICATION_JSON, "x-amzn-ErrorType": exception.code}) + return headers diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration.py new file mode 100644 index 0000000000000..a05e87e201cd4 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration.py @@ -0,0 +1,33 @@ +import logging + +from localstack.http import Response + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import EndpointResponse, RestApiInvocationContext +from ..integrations import REST_API_INTEGRATIONS + +LOG = logging.getLogger(__name__) + + +class IntegrationHandler(RestApiGatewayHandler): + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + integration_type = context.integration["type"] + is_proxy = "PROXY" in integration_type + + integration = REST_API_INTEGRATIONS.get(integration_type) + + if not integration: + # this should not happen, as we validated the type in the provider + raise NotImplementedError( + f"This integration type is not yet supported: {integration_type}" + ) + + endpoint_response: EndpointResponse = integration.invoke(context) + context.endpoint_response = endpoint_response + if is_proxy: + context.invocation_response = endpoint_response diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration_request.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration_request.py new file mode 100644 index 0000000000000..b9cf68b1ab006 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration_request.py @@ -0,0 +1,349 @@ +import base64 +import logging +from http import HTTPMethod + +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import ContentHandlingStrategy, Integration, IntegrationType +from localstack.constants import APPLICATION_JSON +from localstack.http import Request, Response +from localstack.utils.collections import merge_recursive +from localstack.utils.strings import to_bytes, to_str + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import IntegrationRequest, InvocationRequest, RestApiInvocationContext +from ..gateway_response import InternalServerError, UnsupportedMediaTypeError +from ..header_utils import drop_headers, set_default_headers +from ..helpers import mime_type_matches_binary_media_types, render_integration_uri +from ..parameters_mapping import ParametersMapper, RequestDataMapping +from ..template_mapping import ( + ApiGatewayVtlTemplate, + MappingTemplateInput, + MappingTemplateParams, + MappingTemplateVariables, +) +from ..variables import ContextVariableOverrides, ContextVarsRequestOverride + +LOG = logging.getLogger(__name__) + +# Illegal headers to include in transformation +ILLEGAL_INTEGRATION_REQUESTS_COMMON = [ + "content-length", + "transfer-encoding", + "x-amzn-trace-id", + "X-Amzn-Apigateway-Api-Id", +] +ILLEGAL_INTEGRATION_REQUESTS_AWS = [ + *ILLEGAL_INTEGRATION_REQUESTS_COMMON, + "authorization", + "connection", + "expect", + "proxy-authenticate", + "te", +] + +# These are dropped after the templates override were applied. they will never make it to the requests. +DROPPED_FROM_INTEGRATION_REQUESTS_COMMON = ["Expect", "Proxy-Authenticate", "TE"] +DROPPED_FROM_INTEGRATION_REQUESTS_AWS = [*DROPPED_FROM_INTEGRATION_REQUESTS_COMMON, "Referer"] +DROPPED_FROM_INTEGRATION_REQUESTS_HTTP = [*DROPPED_FROM_INTEGRATION_REQUESTS_COMMON, "Via"] + +# Default headers +DEFAULT_REQUEST_HEADERS = {"Accept": APPLICATION_JSON, "Connection": "keep-alive"} + + +class PassthroughBehavior(str): + # TODO maybe this class should be moved where it can also be used for validation in + # the provider when we switch out of moto + WHEN_NO_MATCH = "WHEN_NO_MATCH" + WHEN_NO_TEMPLATES = "WHEN_NO_TEMPLATES" + NEVER = "NEVER" + + +class IntegrationRequestHandler(RestApiGatewayHandler): + """ + This class will take care of the Integration Request part, which is mostly linked to template mapping + See https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-integration-settings-integration-request.html + """ + + def __init__(self): + self._param_mapper = ParametersMapper() + self._vtl_template = ApiGatewayVtlTemplate() + + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + integration: Integration = context.integration + integration_type = integration["type"] + + integration_request_parameters = integration["requestParameters"] or {} + request_data_mapping = self.get_integration_request_data( + context, integration_request_parameters + ) + path_parameters = request_data_mapping["path"] + + if integration_type in (IntegrationType.AWS_PROXY, IntegrationType.HTTP_PROXY): + # `PROXY` types cannot use integration mapping templates, they pass most of the data straight + # We make a copy to avoid modifying the invocation headers and keep a cleaner history + headers = context.invocation_request["headers"].copy() + query_string_parameters: dict[str, list[str]] = context.invocation_request[ + "multi_value_query_string_parameters" + ] + body = context.invocation_request["body"] + + # HTTP_PROXY still make uses of the request data mappings, and merges it with the invocation request + # this is undocumented but validated behavior + if integration_type == IntegrationType.HTTP_PROXY: + # These headers won't be passed through by default from the invocation. + # They can however be added through request mappings. + drop_headers(headers, ["Host", "Content-Encoding"]) + headers.update(request_data_mapping["header"]) + + query_string_parameters = self._merge_http_proxy_query_string( + query_string_parameters, request_data_mapping["querystring"] + ) + + else: + self._set_proxy_headers(headers, context.request) + # AWS_PROXY does not allow URI path rendering + # TODO: verify this + path_parameters = {} + + else: + # find request template to raise UnsupportedMediaTypeError early + request_template = self.get_request_template( + integration=integration, request=context.invocation_request + ) + + converted_body = self.convert_body(context) + + body, mapped_overrides = self.render_request_template_mapping( + context=context, body=converted_body, template=request_template + ) + # Update the context with the returned mapped overrides + context.context_variable_overrides = mapped_overrides + # mutate the ContextVariables with the requestOverride result, as we copy the context when rendering the + # template to avoid mutation on other fields + request_override: ContextVarsRequestOverride = mapped_overrides.get( + "requestOverride", {} + ) + # TODO: log every override that happens afterwards (in a loop on `request_override`) + merge_recursive(request_override, request_data_mapping, overwrite=True) + + headers = Headers(request_data_mapping["header"]) + query_string_parameters = request_data_mapping["querystring"] + + # Some headers can't be modified by parameter mappings or mapping templates. + # Aws will raise in those were present. Even for AWS_PROXY, where it is not applying them. + if header_mappings := request_data_mapping["header"]: + self._validate_headers_mapping(header_mappings, integration_type) + + self._apply_header_transforms(headers, integration_type, context) + + # looks like the stageVariables rendering part is done in the Integration part in AWS + # but we can avoid duplication by doing it here for now + # TODO: if the integration if of AWS Lambda type and the Lambda is in another account, we cannot render + # stageVariables. Work on that special case later (we can add a quick check for the URI region and set the + # stage variables to an empty dict) + rendered_integration_uri = render_integration_uri( + uri=integration["uri"], + path_parameters=path_parameters, + stage_variables=context.stage_variables, + ) + + # if the integration method is defined and is not ANY, we can use it for the integration + if not (integration_method := integration["httpMethod"]) or integration_method == "ANY": + # otherwise, fallback to the request's method + integration_method = context.invocation_request["http_method"] + + integration_request = IntegrationRequest( + http_method=integration_method, + uri=rendered_integration_uri, + query_string_parameters=query_string_parameters, + headers=headers, + body=body, + ) + + context.integration_request = integration_request + + def get_integration_request_data( + self, context: RestApiInvocationContext, request_parameters: dict[str, str] + ) -> RequestDataMapping: + return self._param_mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=context.invocation_request, + context_variables=context.context_variables, + stage_variables=context.stage_variables, + ) + + def render_request_template_mapping( + self, + context: RestApiInvocationContext, + body: str | bytes, + template: str, + ) -> tuple[bytes, ContextVariableOverrides]: + request: InvocationRequest = context.invocation_request + + if not template: + return to_bytes(body), context.context_variable_overrides + + try: + body_utf8 = to_str(body) + except UnicodeError: + raise InternalServerError("Internal server error") + + body, mapped_overrides = self._vtl_template.render_request( + template=template, + variables=MappingTemplateVariables( + context=context.context_variables, + stageVariables=context.stage_variables or {}, + input=MappingTemplateInput( + body=body_utf8, + params=MappingTemplateParams( + path=request.get("path_parameters"), + querystring=request.get("query_string_parameters", {}), + header=request.get("headers"), + ), + ), + ), + context_overrides=context.context_variable_overrides, + ) + return to_bytes(body), mapped_overrides + + @staticmethod + def get_request_template(integration: Integration, request: InvocationRequest) -> str: + """ + Attempts to return the request template. + Will raise UnsupportedMediaTypeError if there are no match according to passthrough behavior. + """ + request_templates = integration.get("requestTemplates") or {} + passthrough_behavior = integration.get("passthroughBehavior") + # If content-type is not provided aws assumes application/json + content_type = request["headers"].get("Content-Type", APPLICATION_JSON) + # first look to for a template associated to the content-type, otherwise look for the $default template + request_template = request_templates.get(content_type) or request_templates.get("$default") + + if request_template or passthrough_behavior == PassthroughBehavior.WHEN_NO_MATCH: + return request_template + + match passthrough_behavior: + case PassthroughBehavior.NEVER: + LOG.debug( + "No request template found for '%s' and passthrough behavior set to NEVER", + content_type, + ) + raise UnsupportedMediaTypeError("Unsupported Media Type") + case PassthroughBehavior.WHEN_NO_TEMPLATES: + if request_templates: + LOG.debug( + "No request template found for '%s' and passthrough behavior set to WHEN_NO_TEMPLATES", + content_type, + ) + raise UnsupportedMediaTypeError("Unsupported Media Type") + case _: + LOG.debug("Unknown passthrough behavior: '%s'", passthrough_behavior) + + return request_template + + @staticmethod + def convert_body(context: RestApiInvocationContext) -> bytes | str: + """ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html + :param context: + :return: the body, either as is, or converted depending on the table in the second link + """ + request: InvocationRequest = context.invocation_request + body = request["body"] + + is_binary_request = mime_type_matches_binary_media_types( + mime_type=request["headers"].get("Content-Type"), + binary_media_types=context.deployment.rest_api.rest_api.get("binaryMediaTypes", []), + ) + content_handling = context.integration.get("contentHandling") + if is_binary_request: + if content_handling and content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT: + body = base64.b64encode(body) + # if the content handling is not defined, or CONVERT_TO_BINARY, we do not touch the body and leave it as + # proper binary + else: + if not content_handling or content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT: + body = body.decode(encoding="UTF-8", errors="replace") + else: + # it means we have CONVERT_TO_BINARY, so we need to try to decode the base64 string + try: + body = base64.b64decode(body) + except ValueError: + raise InternalServerError("Internal server error") + + return body + + @staticmethod + def _merge_http_proxy_query_string( + query_string_parameters: dict[str, list[str]], + mapped_query_string: dict[str, str | list[str]], + ): + new_query_string_parameters = {k: v.copy() for k, v in query_string_parameters.items()} + for param, value in mapped_query_string.items(): + if existing := new_query_string_parameters.get(param): + if isinstance(value, list): + existing.extend(value) + else: + existing.append(value) + else: + new_query_string_parameters[param] = value + + return new_query_string_parameters + + @staticmethod + def _set_proxy_headers(headers: Headers, request: Request): + headers.set("X-Forwarded-For", request.remote_addr) + headers.set("X-Forwarded-Port", request.environ.get("SERVER_PORT")) + headers.set( + "X-Forwarded-Proto", + request.environ.get("SERVER_PROTOCOL", "").split("/")[0], + ) + + @staticmethod + def _apply_header_transforms( + headers: Headers, integration_type: IntegrationType, context: RestApiInvocationContext + ): + # Dropping matching headers for the provided integration type + match integration_type: + case IntegrationType.AWS: + drop_headers(headers, DROPPED_FROM_INTEGRATION_REQUESTS_AWS) + case IntegrationType.HTTP | IntegrationType.HTTP_PROXY: + drop_headers(headers, DROPPED_FROM_INTEGRATION_REQUESTS_HTTP) + case _: + drop_headers(headers, DROPPED_FROM_INTEGRATION_REQUESTS_COMMON) + + # Adding default headers to the requests headers + default_headers = { + **DEFAULT_REQUEST_HEADERS, + "User-Agent": f"AmazonAPIGateway_{context.api_id}", + } + if ( + content_type := context.request.headers.get("Content-Type") + ) and context.request.method not in {HTTPMethod.OPTIONS, HTTPMethod.GET, HTTPMethod.HEAD}: + default_headers["Content-Type"] = content_type + + set_default_headers(headers, default_headers) + headers.set("X-Amzn-Trace-Id", context.trace_id) + if integration_type not in (IntegrationType.AWS_PROXY, IntegrationType.AWS): + headers.set("X-Amzn-Apigateway-Api-Id", context.api_id) + + @staticmethod + def _validate_headers_mapping(headers: dict[str, str], integration_type: IntegrationType): + """Validates and raises an error when attempting to set an illegal header""" + to_validate = ILLEGAL_INTEGRATION_REQUESTS_COMMON + if integration_type in {IntegrationType.AWS, IntegrationType.AWS_PROXY}: + to_validate = ILLEGAL_INTEGRATION_REQUESTS_AWS + + for header in headers: + if header.lower() in to_validate: + LOG.debug( + "Execution failed due to configuration error: %s header already present", header + ) + raise InternalServerError("Internal server error") diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration_response.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration_response.py new file mode 100644 index 0000000000000..2dccb39c74a6b --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/integration_response.py @@ -0,0 +1,312 @@ +import base64 +import json +import logging +import re + +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import ( + ContentHandlingStrategy, + Integration, + IntegrationResponse, + IntegrationType, +) +from localstack.constants import APPLICATION_JSON +from localstack.http import Response +from localstack.utils.strings import to_bytes + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import ( + EndpointResponse, + InvocationRequest, + InvocationResponse, + RestApiInvocationContext, +) +from ..gateway_response import ApiConfigurationError, InternalServerError +from ..helpers import mime_type_matches_binary_media_types +from ..parameters_mapping import ParametersMapper, ResponseDataMapping +from ..template_mapping import ( + ApiGatewayVtlTemplate, + MappingTemplateInput, + MappingTemplateParams, + MappingTemplateVariables, +) +from ..variables import ContextVarsResponseOverride + +LOG = logging.getLogger(__name__) + + +class IntegrationResponseHandler(RestApiGatewayHandler): + """ + This class will take care of the Integration Response part, which is mostly linked to template mapping + See https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-integration-settings-integration-response.html + """ + + def __init__(self): + self._param_mapper = ParametersMapper() + self._vtl_template = ApiGatewayVtlTemplate() + + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + # TODO: we should log the response coming in from the Integration, either in Integration or here. + # before modification / after? + integration: Integration = context.integration + integration_type = integration["type"] + + if integration_type in (IntegrationType.AWS_PROXY, IntegrationType.HTTP_PROXY): + # `PROXY` types cannot use integration response mapping templates + # TODO: verify assumptions against AWS + return + + endpoint_response: EndpointResponse = context.endpoint_response + status_code = endpoint_response["status_code"] + body = endpoint_response["body"] + + # we first need to find the right IntegrationResponse based on their selection template, linked to the status + # code of the Response + if integration_type == IntegrationType.AWS and "lambda:path/" in integration["uri"]: + selection_value = self.parse_error_message_from_lambda(body) + else: + selection_value = str(status_code) + + integration_response: IntegrationResponse = self.select_integration_response( + selection_value, + integration["integrationResponses"], + ) + + # we then need to apply Integration Response parameters mapping, to only return select headers + response_parameters = integration_response.get("responseParameters") or {} + response_data_mapping = self.get_method_response_data( + context=context, + response=endpoint_response, + response_parameters=response_parameters, + ) + + # We then fetch a response templates and apply the template mapping + response_template = self.get_response_template( + integration_response=integration_response, request=context.invocation_request + ) + # binary support + converted_body = self.convert_body( + context, + body=body, + content_handling=integration_response.get("contentHandling"), + ) + + body, response_override = self.render_response_template_mapping( + context=context, template=response_template, body=converted_body + ) + + # We basically need to remove all headers and replace them with the mapping, then + # override them if there are overrides. + # The status code is pretty straight forward. By default, it would be set by the integration response, + # unless there was an override + response_status_code = int(integration_response["statusCode"]) + if response_status_override := response_override["status"]: + # maybe make a better error message format, same for the overrides for request too + LOG.debug("Overriding response status code: '%s'", response_status_override) + response_status_code = response_status_override + + # Create a new headers object that we can manipulate before overriding the original response headers + response_headers = Headers(response_data_mapping.get("header")) + if header_override := response_override["header"]: + LOG.debug("Response header overrides: %s", header_override) + response_headers.update(header_override) + + LOG.debug("Method response body after transformations: %s", body) + context.invocation_response = InvocationResponse( + body=body, + headers=response_headers, + status_code=response_status_code, + ) + + def get_method_response_data( + self, + context: RestApiInvocationContext, + response: EndpointResponse, + response_parameters: dict[str, str], + ) -> ResponseDataMapping: + return self._param_mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=response, + context_variables=context.context_variables, + stage_variables=context.stage_variables, + ) + + @staticmethod + def select_integration_response( + selection_value: str, integration_responses: dict[str, IntegrationResponse] + ) -> IntegrationResponse: + if not integration_responses: + LOG.warning( + "Configuration error: No match for output mapping and no default output mapping configured. " + "Endpoint Response Status Code: %s", + selection_value, + ) + raise ApiConfigurationError("Internal server error") + + if select_by_pattern := [ + response + for response in integration_responses.values() + if (selectionPatten := response.get("selectionPattern")) + and re.match(selectionPatten, selection_value) + ]: + selected_response = select_by_pattern[0] + if len(select_by_pattern) > 1: + LOG.warning( + "Multiple integration responses matching '%s' statuscode. Choosing '%s' (first).", + selection_value, + selected_response["statusCode"], + ) + else: + # choose default return code + # TODO: the provider should check this, as we should only have one default with no value in selectionPattern + default_responses = [ + response + for response in integration_responses.values() + if not response.get("selectionPattern") + ] + if not default_responses: + # TODO: verify log message when the selection_value is a lambda errorMessage + LOG.warning( + "Configuration error: No match for output mapping and no default output mapping configured. " + "Endpoint Response Status Code: %s", + selection_value, + ) + raise ApiConfigurationError("Internal server error") + + selected_response = default_responses[0] + if len(default_responses) > 1: + LOG.warning( + "Multiple default integration responses. Choosing %s (first).", + selected_response["statusCode"], + ) + return selected_response + + @staticmethod + def get_response_template( + integration_response: IntegrationResponse, request: InvocationRequest + ) -> str: + """The Response Template is selected from the response templates. + If there are no templates defined, the body will pass through. + Apigateway looks at the integration request `Accept` header and defaults to `application/json`. + If no template is matched, Apigateway will use the "first" existing template and use it as default. + https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html#transforming-request-response-body + """ + if not (response_templates := integration_response["responseTemplates"]): + return "" + + # The invocation request header is used to find the right response templated + accepts = request["headers"].getlist("accept") + if accepts and (template := response_templates.get(accepts[-1])): + return template + # TODO aws seemed to favor application/json as default when unmatched regardless of "first" + if template := response_templates.get(APPLICATION_JSON): + return template + # TODO What is first? do we need to keep an order as to when they were added/modified? + template = next(iter(response_templates.values())) + LOG.warning("No templates were matched, Using template: %s", template) + return template + + @staticmethod + def convert_body( + context: RestApiInvocationContext, + body: bytes, + content_handling: ContentHandlingStrategy | None, + ) -> bytes | str: + """ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html + :param context: RestApiInvocationContext + :param body: the endpoint response body + :param content_handling: the contentHandling of the IntegrationResponse + :return: the body, either as is, or converted depending on the table in the second link + """ + + request: InvocationRequest = context.invocation_request + response: EndpointResponse = context.endpoint_response + binary_media_types = context.deployment.rest_api.rest_api.get("binaryMediaTypes", []) + + is_binary_payload = mime_type_matches_binary_media_types( + mime_type=response["headers"].get("Content-Type"), + binary_media_types=binary_media_types, + ) + is_binary_accept = mime_type_matches_binary_media_types( + mime_type=request["headers"].get("Accept"), + binary_media_types=binary_media_types, + ) + + if is_binary_payload: + if ( + content_handling and content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT + ) or (not content_handling and not is_binary_accept): + body = base64.b64encode(body) + else: + # this means the Payload is of type `Text` in AWS terms for the table + if ( + content_handling and content_handling == ContentHandlingStrategy.CONVERT_TO_TEXT + ) or (not content_handling and not is_binary_accept): + body = body.decode(encoding="UTF-8", errors="replace") + else: + try: + body = base64.b64decode(body) + except ValueError: + raise InternalServerError("Internal server error") + + return body + + def render_response_template_mapping( + self, context: RestApiInvocationContext, template: str, body: bytes | str + ) -> tuple[bytes, ContextVarsResponseOverride]: + if not template: + return to_bytes(body), context.context_variable_overrides["responseOverride"] + + # if there are no template, we can pass binary data through + if not isinstance(body, str): + # TODO: check, this might be ApiConfigurationError + raise InternalServerError("Internal server error") + + body, response_override = self._vtl_template.render_response( + template=template, + variables=MappingTemplateVariables( + context=context.context_variables, + stageVariables=context.stage_variables or {}, + input=MappingTemplateInput( + body=body, + params=MappingTemplateParams( + path=context.invocation_request.get("path_parameters"), + querystring=context.invocation_request.get("query_string_parameters", {}), + header=context.invocation_request.get("headers", {}), + ), + ), + ), + context_overrides=context.context_variable_overrides, + ) + + # AWS ignores the status if the override isn't an integer between 100 and 599 + if (status := response_override["status"]) and not ( + isinstance(status, int) and 100 <= status < 600 + ): + response_override["status"] = 0 + return to_bytes(body), response_override + + @staticmethod + def parse_error_message_from_lambda(payload: bytes) -> str: + try: + lambda_response = json.loads(payload) + if not isinstance(lambda_response, dict): + return "" + + # very weird case, but AWS will not return the Error from Lambda in AWS integration, where it does for + # Kinesis and such. The AWS Lambda only behavior is concentrated in this method + if lambda_response.get("__type") == "AccessDeniedException": + raise InternalServerError("Internal server error") + + return lambda_response.get("errorMessage", "") + + except json.JSONDecodeError: + return "" diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/method_request.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/method_request.py new file mode 100644 index 0000000000000..00a35129225b1 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/method_request.py @@ -0,0 +1,147 @@ +import json +import logging + +from jsonschema import ValidationError, validate + +from localstack.aws.api.apigateway import Method +from localstack.constants import APPLICATION_JSON +from localstack.http import Response +from localstack.services.apigateway.helpers import EMPTY_MODEL, ModelResolver +from localstack.services.apigateway.models import RestApiContainer + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import InvocationRequest, RestApiInvocationContext +from ..gateway_response import BadRequestBodyError, BadRequestParametersError + +LOG = logging.getLogger(__name__) + + +class MethodRequestHandler(RestApiGatewayHandler): + """ + This class will mostly take care of Request validation with Models + See https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-settings-method-request.html + """ + + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + self.validate_request( + context.resource_method, + context.deployment.rest_api, + context.invocation_request, + ) + + def validate_request( + self, method: Method, rest_api: RestApiContainer, request: InvocationRequest + ) -> None: + """ + :raises BadRequestParametersError if the request has required parameters which are not present + :raises BadRequestBodyError if the request has required body validation with a model and it does not respect it + :return: None + """ + + # check if there is validator for the method + if not (request_validator_id := method.get("requestValidatorId") or "").strip(): + return + + # check if there is a validator for this request + if not (validator := rest_api.validators.get(request_validator_id)): + # TODO Should we raise an exception instead? + LOG.exception("No validator were found with matching id: '%s'", request_validator_id) + return + + if self.should_validate_request(validator) and ( + missing_parameters := self._get_missing_required_parameters(method, request) + ): + message = f"Missing required request parameters: [{', '.join(missing_parameters)}]" + raise BadRequestParametersError(message=message) + + if self.should_validate_body(validator) and not self._is_body_valid( + method, rest_api, request + ): + raise BadRequestBodyError(message="Invalid request body") + + return + + @staticmethod + def _is_body_valid( + method: Method, rest_api: RestApiContainer, request: InvocationRequest + ) -> bool: + # if there's no model to validate the body, use the Empty model + # https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-apigateway.EmptyModel.html + if not (request_models := method.get("requestModels")): + model_name = EMPTY_MODEL + else: + model_name = request_models.get( + APPLICATION_JSON, request_models.get("$default", EMPTY_MODEL) + ) + + model_resolver = ModelResolver( + rest_api_container=rest_api, + model_name=model_name, + ) + + # try to get the resolved model first + resolved_schema = model_resolver.get_resolved_model() + if not resolved_schema: + LOG.exception( + "An exception occurred while trying to validate the request: could not resolve the model '%s'", + model_name, + ) + return False + + try: + # if the body is empty, replace it with an empty JSON body + validate( + instance=json.loads(request.get("body") or "{}"), + schema=resolved_schema, + ) + return True + except ValidationError as e: + LOG.debug("failed to validate request body %s", e) + return False + except json.JSONDecodeError as e: + LOG.debug("failed to validate request body, request data is not valid JSON %s", e) + return False + + @staticmethod + def _get_missing_required_parameters(method: Method, request: InvocationRequest) -> list[str]: + missing_params = [] + if not (request_parameters := method.get("requestParameters")): + return missing_params + + case_sensitive_headers = list(request.get("headers").keys()) + + for request_parameter, required in sorted(request_parameters.items()): + if not required: + continue + + param_type, param_value = request_parameter.removeprefix("method.request.").split(".") + match param_type: + case "header": + is_missing = param_value not in case_sensitive_headers + case "path": + path = request.get("path_parameters", "") + is_missing = param_value not in path + case "querystring": + is_missing = param_value not in request.get("query_string_parameters", []) + case _: + # This shouldn't happen + LOG.debug("Found an invalid request parameter: %s", request_parameter) + is_missing = False + + if is_missing: + missing_params.append(param_value) + + return missing_params + + @staticmethod + def should_validate_body(validator): + return validator.get("validateRequestBody") + + @staticmethod + def should_validate_request(validator): + return validator.get("validateRequestParameters") diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/method_response.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/method_response.py new file mode 100644 index 0000000000000..004f99b98a4da --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/method_response.py @@ -0,0 +1,96 @@ +import logging + +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import IntegrationType +from localstack.http import Response + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import InvocationResponse, RestApiInvocationContext +from ..header_utils import drop_headers + +LOG = logging.getLogger(__name__) + +# These are dropped after the templates override were applied. they will never make it to the requests. +DROPPED_FROM_INTEGRATION_RESPONSES_COMMON = ["Transfer-Encoding"] +DROPPED_FROM_INTEGRATION_RESPONSES_HTTP_PROXY = [ + *DROPPED_FROM_INTEGRATION_RESPONSES_COMMON, + "Content-Encoding", + "Via", +] + + +# Headers that will receive a remap +REMAPPED_FROM_INTEGRATION_RESPONSE_COMMON = [ + "Connection", + "Content-Length", + "Date", + "Server", +] +REMAPPED_FROM_INTEGRATION_RESPONSE_NON_PROXY = [ + *REMAPPED_FROM_INTEGRATION_RESPONSE_COMMON, + "Authorization", + "Content-MD5", + "Expect", + "Host", + "Max-Forwards", + "Proxy-Authenticate", + "Trailer", + "Upgrade", + "User-Agent", + "WWW-Authenticate", +] + + +class MethodResponseHandler(RestApiGatewayHandler): + """ + Last handler of the chain, responsible for serializing the Response object + """ + + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + invocation_response = context.invocation_response + integration_type = context.integration["type"] + headers = invocation_response["headers"] + + self._transform_headers(headers, integration_type) + + method_response = self.serialize_invocation_response(invocation_response) + response.update_from(method_response) + + @staticmethod + def serialize_invocation_response(invocation_response: InvocationResponse) -> Response: + is_content_type_set = invocation_response["headers"].get("content-type") is not None + response = Response( + response=invocation_response["body"], + headers=invocation_response["headers"], + status=invocation_response["status_code"], + ) + if not is_content_type_set: + # Response sets a content-type by default. This will always be ignored. + response.headers.remove("content-type") + return response + + @staticmethod + def _transform_headers(headers: Headers, integration_type: IntegrationType): + """Remaps the provided headers in-place. Adding new `x-amzn-Remapped-` headers and dropping the original headers""" + to_remap = REMAPPED_FROM_INTEGRATION_RESPONSE_COMMON + to_drop = DROPPED_FROM_INTEGRATION_RESPONSES_COMMON + + match integration_type: + case IntegrationType.HTTP | IntegrationType.AWS: + to_remap = REMAPPED_FROM_INTEGRATION_RESPONSE_NON_PROXY + case IntegrationType.HTTP_PROXY: + to_drop = DROPPED_FROM_INTEGRATION_RESPONSES_HTTP_PROXY + + for header in to_remap: + if headers.get(header): + LOG.debug("Remapping header: %s", header) + remapped = headers.pop(header) + headers[f"x-amzn-Remapped-{header}"] = remapped + + drop_headers(headers, to_drop) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py new file mode 100644 index 0000000000000..3da898bf8845e --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/parse.py @@ -0,0 +1,204 @@ +import datetime +import logging +import re +from collections import defaultdict +from typing import Optional +from urllib.parse import urlparse + +from rolo.request import restore_payload +from werkzeug.datastructures import Headers, MultiDict + +from localstack.http import Response +from localstack.services.apigateway.helpers import REQUEST_TIME_DATE_FORMAT +from localstack.utils.strings import long_uid, short_uid +from localstack.utils.time import timestamp + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import InvocationRequest, RestApiInvocationContext +from ..header_utils import should_drop_header_from_invocation +from ..helpers import generate_trace_id, generate_trace_parent, parse_trace_id +from ..variables import ( + ContextVariableOverrides, + ContextVariables, + ContextVarsIdentity, + ContextVarsRequestOverride, + ContextVarsResponseOverride, +) + +LOG = logging.getLogger(__name__) + + +class InvocationRequestParser(RestApiGatewayHandler): + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + context.account_id = context.deployment.account_id + context.region = context.deployment.region + self.parse_and_enrich(context) + + def parse_and_enrich(self, context: RestApiInvocationContext): + # first, create the InvocationRequest with the incoming request + context.invocation_request = self.create_invocation_request(context) + # then we can create the ContextVariables, used throughout the invocation as payload and to render authorizer + # payload, mapping templates and such. + context.context_variables = self.create_context_variables(context) + context.context_variable_overrides = ContextVariableOverrides( + requestOverride=ContextVarsRequestOverride(header={}, querystring={}, path={}), + responseOverride=ContextVarsResponseOverride(header={}, status=0), + ) + # TODO: maybe adjust the logging + LOG.debug("Initializing $context='%s'", context.context_variables) + # then populate the stage variables + context.stage_variables = self.get_stage_variables(context) + LOG.debug("Initializing $stageVariables='%s'", context.stage_variables) + + context.trace_id = self.populate_trace_id(context.request.headers) + + def create_invocation_request(self, context: RestApiInvocationContext) -> InvocationRequest: + request = context.request + params, multi_value_params = self._get_single_and_multi_values_from_multidict(request.args) + headers = self._get_invocation_headers(request.headers) + invocation_request = InvocationRequest( + http_method=request.method, + query_string_parameters=params, + multi_value_query_string_parameters=multi_value_params, + headers=headers, + body=restore_payload(request), + ) + self._enrich_with_raw_path(context, invocation_request) + + return invocation_request + + @staticmethod + def _enrich_with_raw_path( + context: RestApiInvocationContext, invocation_request: InvocationRequest + ): + # Base path is not URL-decoded, so we need to get the `RAW_URI` from the request + request = context.request + raw_uri = request.environ.get("RAW_URI") or request.path + + # if the request comes from the LocalStack only `_user_request_` route, we need to remove this prefix from the + # path, in order to properly route the request + if "_user_request_" in raw_uri: + # in this format, the stage is before `_user_request_`, so we don't need to remove it + raw_uri = raw_uri.partition("_user_request_")[2] + else: + if raw_uri.startswith("/_aws/execute-api"): + # the API can be cased in the path, so we need to ignore it to remove it + raw_uri = re.sub( + f"^/_aws/execute-api/{context.api_id}", + "", + raw_uri, + flags=re.IGNORECASE, + ) + + # remove the stage from the path, only replace the first occurrence + raw_uri = raw_uri.replace(f"/{context.stage}", "", 1) + + if raw_uri.startswith("//"): + # TODO: AWS validate this assumption + # if the RAW_URI starts with double slashes, `urlparse` will fail to decode it as path only + # it also means that we already only have the path, so we just need to remove the query string + raw_uri = raw_uri.split("?")[0] + raw_path = "/" + raw_uri.lstrip("/") + + else: + # we need to make sure we have a path here, sometimes RAW_URI can be a full URI (when proxied) + raw_path = raw_uri = urlparse(raw_uri).path + + invocation_request["path"] = raw_path + invocation_request["raw_path"] = raw_uri + + @staticmethod + def _get_single_and_multi_values_from_multidict( + multi_dict: MultiDict, + ) -> tuple[dict[str, str], dict[str, list[str]]]: + single_values = {} + multi_values = defaultdict(list) + + for key, value in multi_dict.items(multi=True): + multi_values[key].append(value) + # for the single value parameters, AWS only keeps the last value of the list + single_values[key] = value + + return single_values, dict(multi_values) + + @staticmethod + def _get_invocation_headers(headers: Headers) -> Headers: + invocation_headers = Headers() + for key, value in headers: + if should_drop_header_from_invocation(key): + LOG.debug("Dropping header from invocation request: '%s'", key) + continue + invocation_headers.add(key, value) + return invocation_headers + + @staticmethod + def create_context_variables(context: RestApiInvocationContext) -> ContextVariables: + invocation_request: InvocationRequest = context.invocation_request + domain_name = invocation_request["headers"].get("Host", "") + domain_prefix = domain_name.split(".")[0] + now = datetime.datetime.now() + + context_variables = ContextVariables( + accountId=context.account_id, + apiId=context.api_id, + deploymentId=context.deployment_id, + domainName=domain_name, + domainPrefix=domain_prefix, + extendedRequestId=short_uid(), # TODO: use snapshot tests to verify format + httpMethod=invocation_request["http_method"], + identity=ContextVarsIdentity( + accountId=None, + accessKey=None, + caller=None, + cognitoAuthenticationProvider=None, + cognitoAuthenticationType=None, + cognitoIdentityId=None, + cognitoIdentityPoolId=None, + principalOrgId=None, + sourceIp="127.0.0.1", # TODO: get the sourceIp from the Request + user=None, + userAgent=invocation_request["headers"].get("User-Agent"), + userArn=None, + ), + path=f"/{context.stage}{invocation_request['raw_path']}", + protocol="HTTP/1.1", + requestId=long_uid(), + requestTime=timestamp(time=now, format=REQUEST_TIME_DATE_FORMAT), + requestTimeEpoch=int(now.timestamp() * 1000), + stage=context.stage, + ) + if context.is_canary is not None: + context_variables["isCanaryRequest"] = context.is_canary + + return context_variables + + @staticmethod + def get_stage_variables(context: RestApiInvocationContext) -> Optional[dict[str, str]]: + stage_variables = context.stage_configuration.get("variables") + if context.is_canary: + overrides = ( + context.stage_configuration["canarySettings"].get("stageVariableOverrides") or {} + ) + stage_variables = (stage_variables or {}) | overrides + + if not stage_variables: + return None + + return stage_variables + + @staticmethod + def populate_trace_id(headers: Headers) -> str: + incoming_trace = parse_trace_id(headers.get("x-amzn-trace-id", "")) + # parse_trace_id always return capitalized keys + + trace = incoming_trace.get("Root", generate_trace_id()) + incoming_parent = incoming_trace.get("Parent") + parent = incoming_parent or generate_trace_parent() + sampled = incoming_trace.get("Sampled", "1" if incoming_parent else "0") + # TODO: lineage? not sure what it related to + return f"Root={trace};Parent={parent};Sampled={sampled}" diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py new file mode 100644 index 0000000000000..4dfe6f95dbcbe --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/resource_router.py @@ -0,0 +1,170 @@ +import logging +from functools import cache +from http import HTTPMethod +from typing import Iterable + +from werkzeug.exceptions import MethodNotAllowed, NotFound +from werkzeug.routing import Map, MapAdapter, Rule + +from localstack.aws.api.apigateway import Resource +from localstack.aws.protocol.routing import ( + path_param_regex, + post_process_arg_name, + transform_path_params_to_rule_vars, +) +from localstack.http import Response +from localstack.http.router import GreedyPathConverter +from localstack.services.apigateway.models import RestApiDeployment + +from ..api import RestApiGatewayHandler, RestApiGatewayHandlerChain +from ..context import RestApiInvocationContext +from ..gateway_response import MissingAuthTokenError +from ..variables import ContextVariables + +LOG = logging.getLogger(__name__) + + +class ApiGatewayMethodRule(Rule): + """ + Small extension to Werkzeug's Rule class which reverts unwanted assumptions made by Werkzeug. + Reverted assumptions: + - Werkzeug automatically matches HEAD requests to the corresponding GET request (i.e. Werkzeug's rule + automatically adds the HEAD HTTP method to a rule which should only match GET requests). + Added behavior: + - ANY is equivalent to 7 HTTP methods listed. We manually set them to the rule's methods + """ + + def __init__(self, string: str, method: str, **kwargs) -> None: + super().__init__(string=string, methods=[method], **kwargs) + + if method == "ANY": + self.methods = { + HTTPMethod.DELETE, + HTTPMethod.GET, + HTTPMethod.HEAD, + HTTPMethod.OPTIONS, + HTTPMethod.PATCH, + HTTPMethod.POST, + HTTPMethod.PUT, + } + else: + # Make sure Werkzeug's Rule does not add any other methods + # (f.e. the HEAD method even though the rule should only match GET) + self.methods = {method.upper()} + + +class RestAPIResourceRouter: + """ + A router implementation which abstracts the routing of incoming REST API Context to a specific + resource of the Deployment. + """ + + _map: Map + + def __init__(self, deployment: RestApiDeployment): + self._resources = deployment.rest_api.resources + self._map = get_rule_map_for_resources(self._resources.values()) + + def match(self, context: RestApiInvocationContext) -> tuple[Resource, dict[str, str]]: + """ + Matches the given request to the resource it targets (or raises an exception if no resource matches). + + :param context: + :return: A tuple with the matched resource and the (already parsed) path params + :raises: MissingAuthTokenError, weird naming but that is the default NotFound for REST API + """ + + request = context.request + # bind the map to get the actual matcher + matcher: MapAdapter = self._map.bind(context.request.host) + + # perform the matching + # trailing slashes are ignored in APIGW + path = context.invocation_request["path"].rstrip("/") + try: + rule, args = matcher.match(path, method=request.method, return_rule=True) + except (MethodNotAllowed, NotFound) as e: + # MethodNotAllowed (405) exception is raised if a path is matching, but the method does not. + # Our router might handle this as a 404, validate with AWS. + LOG.warning( + "API Gateway: No resource or method was found for: %s %s", + request.method, + path, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + raise MissingAuthTokenError("Missing Authentication Token") from e + + # post process the arg keys and values + # - the path param keys need to be "un-sanitized", i.e. sanitized rule variable names need to be reverted + # - the path param values might still be url-encoded + args = {post_process_arg_name(k): v for k, v in args.items()} + + # extract the operation model from the rule + resource_id: str = rule.endpoint + resource = self._resources[resource_id] + + return resource, args + + +class InvocationRequestRouter(RestApiGatewayHandler): + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + self.route_and_enrich(context) + + def route_and_enrich(self, context: RestApiInvocationContext): + router = self.get_router_for_deployment(context.deployment) + + resource, path_parameters = router.match(context) + resource: Resource + + context.invocation_request["path_parameters"] = path_parameters + context.resource = resource + + method = ( + resource["resourceMethods"].get(context.request.method) + or resource["resourceMethods"]["ANY"] + ) + context.resource_method = method + context.integration = method["methodIntegration"] + + self.update_context_variables_with_resource(context.context_variables, resource) + + @staticmethod + def update_context_variables_with_resource( + context_variables: ContextVariables, resource: Resource + ): + LOG.debug("Updating $context.resourcePath='%s'", resource["path"]) + context_variables["resourcePath"] = resource["path"] + LOG.debug("Updating $context.resourceId='%s'", resource["id"]) + context_variables["resourceId"] = resource["id"] + + @staticmethod + @cache + def get_router_for_deployment(deployment: RestApiDeployment) -> RestAPIResourceRouter: + return RestAPIResourceRouter(deployment) + + +def get_rule_map_for_resources(resources: Iterable[Resource]) -> Map: + rules = [] + for resource in resources: + for method, resource_method in resource.get("resourceMethods", {}).items(): + path = resource["path"] + # translate the requestUri to a Werkzeug rule string + rule_string = path_param_regex.sub(transform_path_params_to_rule_vars, path) + rules.append( + ApiGatewayMethodRule(string=rule_string, method=method, endpoint=resource["id"]) + ) # type: ignore + + return Map( + rules=rules, + # don't be strict about trailing slashes when matching + strict_slashes=False, + # we can't really use werkzeug's merge-slashes since it uses HTTP redirects to solve it + merge_slashes=False, + # get service-specific converters + converters={"path": GreedyPathConverter}, + ) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/response_enricher.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/response_enricher.py new file mode 100644 index 0000000000000..8b6308e7e3d2c --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/handlers/response_enricher.py @@ -0,0 +1,30 @@ +from localstack.aws.api.apigateway import IntegrationType +from localstack.http import Response +from localstack.services.apigateway.next_gen.execute_api.api import ( + RestApiGatewayHandler, + RestApiGatewayHandlerChain, +) +from localstack.services.apigateway.next_gen.execute_api.context import RestApiInvocationContext +from localstack.utils.strings import short_uid + + +class InvocationResponseEnricher(RestApiGatewayHandler): + def __call__( + self, + chain: RestApiGatewayHandlerChain, + context: RestApiInvocationContext, + response: Response, + ): + headers = response.headers + + headers.set("x-amzn-RequestId", context.context_variables["requestId"]) + + # Todo, as we go into monitoring, we will want to have these values come from the context? + headers.set("x-amz-apigw-id", short_uid() + "=") + if ( + context.integration + and context.integration["type"] + not in (IntegrationType.HTTP_PROXY, IntegrationType.MOCK) + and not context.context_variables.get("error") + ): + headers.set("X-Amzn-Trace-Id", context.trace_id) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/header_utils.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/header_utils.py new file mode 100644 index 0000000000000..1b1fcbfa3f35a --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/header_utils.py @@ -0,0 +1,56 @@ +import logging +from collections import defaultdict +from typing import Iterable + +from werkzeug.datastructures.headers import Headers + +LOG = logging.getLogger(__name__) + +# Headers dropped at the request parsing. They will never make it to the invocation requests. +# And won't be available for request mapping. +DROPPED_FROM_REQUEST_COMMON = [ + "Connection", + "Content-Length", + "Content-MD5", + "Expect", + "Max-Forwards", + "Proxy-Authenticate", + "Server", + "TE", + "Transfer-Encoding", + "Trailer", + "Upgrade", + "WWW-Authenticate", +] +DROPPED_FROM_REQUEST_COMMON_LOWER = [header.lower() for header in DROPPED_FROM_REQUEST_COMMON] + + +def should_drop_header_from_invocation(header: str) -> bool: + """These headers are not making it to the invocation requests. Even Proxy integrations are not sending them.""" + return header.lower() in DROPPED_FROM_REQUEST_COMMON_LOWER + + +def build_multi_value_headers(headers: Headers) -> dict[str, list[str]]: + multi_value_headers = defaultdict(list) + for key, value in headers: + multi_value_headers[key].append(value) + + return multi_value_headers + + +def drop_headers(headers: Headers, to_drop: Iterable[str]): + """Will modify the provided headers in-place. Dropping matching headers from the provided list""" + dropped_headers = [] + + for header in to_drop: + if headers.get(header): + headers.remove(header) + dropped_headers.append(header) + + LOG.debug("Dropping headers: %s", dropped_headers) + + +def set_default_headers(headers: Headers, default_headers: dict[str, str]): + for header, value in default_headers.items(): + if not headers.get(header): + headers.set(header, value) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py new file mode 100644 index 0000000000000..33999b69ea1a9 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/helpers.py @@ -0,0 +1,183 @@ +import copy +import logging +import random +import re +import time +from secrets import token_hex +from typing import Type, TypedDict + +from moto.apigateway.models import RestAPI as MotoRestAPI + +from localstack.services.apigateway.models import MergedRestApi, RestApiContainer, RestApiDeployment +from localstack.utils.aws.arns import get_partition + +from .context import RestApiInvocationContext +from .moto_helpers import get_resources_from_moto_rest_api + +LOG = logging.getLogger(__name__) + +_stage_variable_pattern = re.compile(r"\${stageVariables\.(?P.*?)}") + + +def freeze_rest_api( + account_id: str, region: str, moto_rest_api: MotoRestAPI, localstack_rest_api: RestApiContainer +) -> RestApiDeployment: + """ + Snapshot a REST API in time to create a deployment + This will merge the Moto and LocalStack data into one `MergedRestApi` + """ + moto_resources = get_resources_from_moto_rest_api(moto_rest_api) + + rest_api = MergedRestApi.from_rest_api_container( + rest_api_container=localstack_rest_api, + resources=moto_resources, + ) + + return RestApiDeployment( + account_id=account_id, + region=region, + rest_api=copy.deepcopy(rest_api), + ) + + +def render_uri_with_stage_variables( + uri: str | None, stage_variables: dict[str, str] | None +) -> str | None: + """ + https://docs.aws.amazon.com/apigateway/latest/developerguide/aws-api-gateway-stage-variables-reference.html#stage-variables-in-integration-HTTP-uris + URI=https://${stageVariables.} + This format is the same as VTL, but we're using a simplified version to only replace `${stageVariables.}` + values, as AWS will ignore `${path}` for example + """ + if not uri: + return uri + stage_vars = stage_variables or {} + + def replace_match(match_obj: re.Match) -> str: + return stage_vars.get(match_obj.group("varName"), "") + + return _stage_variable_pattern.sub(replace_match, uri) + + +def render_uri_with_path_parameters(uri: str | None, path_parameters: dict[str, str]) -> str | None: + if not uri: + return uri + + for key, value in path_parameters.items(): + uri = uri.replace(f"{{{key}}}", value) + + return uri + + +def render_integration_uri( + uri: str | None, path_parameters: dict[str, str], stage_variables: dict[str, str] +) -> str: + """ + A URI can contain different value to interpolate / render + It will have path parameters substitutions with this shape (can also add a querystring). + URI=http://myhost.test/rootpath/{path} + + It can also have another format, for stage variables, documented here: + https://docs.aws.amazon.com/apigateway/latest/developerguide/aws-api-gateway-stage-variables-reference.html#stage-variables-in-integration-HTTP-uris + URI=https://${stageVariables.} + This format is the same as VTL. + + :param uri: the integration URI + :param path_parameters: the list of path parameters, coming from the parameters mapping and override + :param stage_variables: - + :return: the rendered URI + """ + if not uri: + return "" + + uri_with_path = render_uri_with_path_parameters(uri, path_parameters) + return render_uri_with_stage_variables(uri_with_path, stage_variables) + + +def get_source_arn(context: RestApiInvocationContext): + method = context.resource_method["httpMethod"] + path = context.resource["path"] + return ( + f"arn:{get_partition(context.region)}:execute-api" + f":{context.region}" + f":{context.account_id}" + f":{context.api_id}" + f"/{context.stage}/{method}{path}" + ) + + +def get_lambda_function_arn_from_invocation_uri(uri: str) -> str: + """ + "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:SimpleLambda4ProxyResource/invocations", + :param uri: the integration URI value for a lambda function + :return: the lambda function ARN + """ + return uri.split("functions/")[1].removesuffix("/invocations") + + +def validate_sub_dict_of_typed_dict(typed_dict: Type[TypedDict], obj: dict) -> bool: + """ + Validate that the object is a subset off the keys of a given `TypedDict`. + :param typed_dict: the `TypedDict` blueprint + :param obj: the object to validate + :return: True if it is a subset, False otherwise + """ + typed_dict_keys = {*typed_dict.__required_keys__, *typed_dict.__optional_keys__} + + return not bool(set(obj) - typed_dict_keys) + + +def generate_trace_id(): + """https://docs.aws.amazon.com/xray/latest/devguide/xray-api-sendingdata.html#xray-api-traceids""" + original_request_epoch = int(time.time()) + timestamp_hex = hex(original_request_epoch)[2:] + version_number = "1" + unique_id = token_hex(12) + return f"{version_number}-{timestamp_hex}-{unique_id}" + + +def generate_trace_parent(): + return token_hex(8) + + +def parse_trace_id(trace_id: str) -> dict[str, str]: + split_trace = trace_id.split(";") + trace_values = {} + for trace_part in split_trace: + key_value = trace_part.split("=") + if len(key_value) == 2: + trace_values[key_value[0].capitalize()] = key_value[1] + + return trace_values + + +def mime_type_matches_binary_media_types(mime_type: str | None, binary_media_types: list[str]): + if not mime_type or not binary_media_types: + return False + + mime_type_and_subtype = mime_type.split(",")[0].split(";")[0].split("/") + if len(mime_type_and_subtype) != 2: + return False + mime_type, mime_subtype = mime_type_and_subtype + + for bmt in binary_media_types: + type_and_subtype = bmt.split(";")[0].split("/") + if len(type_and_subtype) != 2: + continue + _type, subtype = type_and_subtype + if _type == "*": + continue + + if subtype == "*" and mime_type == _type: + return True + + if mime_type == _type and mime_subtype == subtype: + return True + + return False + + +def should_divert_to_canary(percent_traffic: float) -> bool: + if int(percent_traffic) == 100: + return True + return percent_traffic > random.random() * 100 diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/__init__.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/__init__.py new file mode 100644 index 0000000000000..7900965784631 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/__init__.py @@ -0,0 +1,15 @@ +from .aws import RestApiAwsIntegration, RestApiAwsProxyIntegration +from .http import RestApiHttpIntegration, RestApiHttpProxyIntegration +from .mock import RestApiMockIntegration + +REST_API_INTEGRATIONS = { + RestApiAwsIntegration.name: RestApiAwsIntegration(), + RestApiAwsProxyIntegration.name: RestApiAwsProxyIntegration(), + RestApiHttpIntegration.name: RestApiHttpIntegration(), + RestApiHttpProxyIntegration.name: RestApiHttpProxyIntegration(), + RestApiMockIntegration.name: RestApiMockIntegration(), +} + +__all__ = [ + "REST_API_INTEGRATIONS", +] diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/aws.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/aws.py new file mode 100644 index 0000000000000..5e65458ed4ac3 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/aws.py @@ -0,0 +1,598 @@ +import base64 +import json +import logging +from functools import lru_cache +from http import HTTPMethod +from typing import Literal, Optional, TypedDict +from urllib.parse import urlparse + +import requests +from botocore.exceptions import ClientError +from werkzeug.datastructures import Headers + +from localstack import config +from localstack.aws.connect import ( + INTERNAL_REQUEST_PARAMS_HEADER, + InternalRequestParameters, + connect_to, + dump_dto, +) +from localstack.aws.spec import get_service_catalog +from localstack.constants import APPLICATION_JSON, INTERNAL_AWS_ACCESS_KEY_ID +from localstack.utils.aws.arns import extract_region_from_arn +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.strings import to_bytes, to_str + +from ..context import ( + EndpointResponse, + IntegrationRequest, + InvocationRequest, + RestApiInvocationContext, +) +from ..gateway_response import IntegrationFailureError, InternalServerError +from ..header_utils import build_multi_value_headers +from ..helpers import ( + get_lambda_function_arn_from_invocation_uri, + get_source_arn, + mime_type_matches_binary_media_types, + render_uri_with_stage_variables, + validate_sub_dict_of_typed_dict, +) +from ..variables import ContextVariables +from .core import RestApiIntegration + +LOG = logging.getLogger(__name__) + +NO_BODY_METHODS = { + HTTPMethod.OPTIONS, + HTTPMethod.GET, + HTTPMethod.HEAD, +} + + +class LambdaProxyResponse(TypedDict, total=False): + body: Optional[str] + statusCode: Optional[int | str] + headers: Optional[dict[str, str]] + isBase64Encoded: Optional[bool] + multiValueHeaders: Optional[dict[str, list[str]]] + + +class LambdaInputEvent(TypedDict, total=False): + body: str + isBase64Encoded: bool + httpMethod: str | HTTPMethod + resource: str + path: str + headers: dict[str, str] + multiValueHeaders: dict[str, list[str]] + queryStringParameters: dict[str, str] + multiValueQueryStringParameters: dict[str, list[str]] + requestContext: ContextVariables + pathParameters: dict[str, str] + stageVariables: dict[str, str] + + +class ParsedAwsIntegrationUri(TypedDict): + service_name: str + region_name: str + action_type: Literal["path", "action"] + path: str + + +@lru_cache(maxsize=64) +def get_service_factory(region_name: str, role_arn: str): + if role_arn: + return connect_to.with_assumed_role( + role_arn=role_arn, + region_name=region_name, + service_principal=ServicePrincipal.apigateway, + session_name="BackplaneAssumeRoleSession", + ) + else: + return connect_to(region_name=region_name) + + +@lru_cache(maxsize=64) +def get_internal_mocked_headers( + service_name: str, + region_name: str, + source_arn: str, + role_arn: str | None, +) -> dict[str, str]: + if role_arn: + access_key_id = ( + connect_to() + .sts.request_metadata(service_principal=ServicePrincipal.apigateway) + .assume_role(RoleArn=role_arn, RoleSessionName="BackplaneAssumeRoleSession")[ + "Credentials" + ]["AccessKeyId"] + ) + else: + access_key_id = INTERNAL_AWS_ACCESS_KEY_ID + + dto = InternalRequestParameters( + service_principal=ServicePrincipal.apigateway, source_arn=source_arn + ) + # TODO: maybe use the localstack.utils.aws.client.SigningHttpClient instead of directly mocking the Authorization + # header (but will need to select the right signer depending on the service?) + headers = { + "Authorization": ( + "AWS4-HMAC-SHA256 " + + f"Credential={access_key_id}/20160623/{region_name}/{service_name}/aws4_request, " + + "SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234" + ), + INTERNAL_REQUEST_PARAMS_HEADER: dump_dto(dto), + } + + return headers + + +@lru_cache(maxsize=64) +def get_target_prefix_for_service(service_name: str) -> str | None: + return get_service_catalog().get(service_name).metadata.get("targetPrefix") + + +class RestApiAwsIntegration(RestApiIntegration): + """ + This is a REST API integration responsible to directly interact with AWS services. It uses the `uri` to + map the incoming request to the concerned AWS service, and can have 2 types. + - `path`: the request is targeting the direct URI of the AWS service, like you would with an HTTP client + example: For S3 GetObject call: arn:aws:apigateway:us-west-2:s3:path/{bucket}/{key} + - `action`: this is a simpler way, where you can pass the request parameters like you would do with an SDK, and you + can specify the service action (for ex. here S3 `GetObject`). It seems the request parameters can be pass as query + string parameters, JSON body and maybe more. TODO: verify, 2 documentation pages indicates divergent information. + (one indicates parameters through QS, one through request body) + example: arn:aws:apigateway:us-west-2:s3:action/GetObject&Bucket={bucket}&Key={key} + + https://docs.aws.amazon.com/apigateway/latest/developerguide/integration-request-basic-setup.html + + + TODO: it seems we can global AWS integration type, we should not need to subclass for each service + we just need to separate usage between the `path` URI type and the `action` URI type. + - `path`, we can simply pass along the full rendered request along with specific `mocked` AWS headers + that are dependant of the service (retrieving for the ARN in the uri) + - `action`, we might need either a full Boto call or use the Boto request serializer, as it seems the request + parameters are expected as parameters + """ + + name = "AWS" + + # TODO: it seems in AWS, you don't need to manually set the `X-Amz-Target` header when using the `action` type. + # for now, we know `events` needs the user to manually add the header, but Kinesis and DynamoDB don't. + # Maybe reverse the list to exclude instead of include. + SERVICES_AUTO_TARGET = ["dynamodb", "kinesis", "ssm", "stepfunctions"] + + # TODO: some services still target the Query protocol (validated with AWS), even though SSM for example is JSON for + # as long as the Boto SDK exists. We will need to emulate the Query protocol and translate it to JSON + SERVICES_LEGACY_QUERY_PROTOCOL = ["ssm"] + + SERVICE_MAP = { + "states": "stepfunctions", + } + + def __init__(self): + self._base_domain = config.internal_service_url() + self._base_host = "" + self._service_names = get_service_catalog().service_names + + def invoke(self, context: RestApiInvocationContext) -> EndpointResponse: + integration_req: IntegrationRequest = context.integration_request + method = integration_req["http_method"] + parsed_uri = self.parse_aws_integration_uri(integration_req["uri"]) + service_name = parsed_uri["service_name"] + integration_region = parsed_uri["region_name"] + + if credentials := context.integration.get("credentials"): + credentials = render_uri_with_stage_variables(credentials, context.stage_variables) + + headers = integration_req["headers"] + # Some integrations will use a special format for the service in the URI, like AppSync, and so those requests + # are not directed to a service directly, so need to add the Authorization header. It would fail parsing + # by our service name parser anyway + if service_name in self._service_names: + headers.update( + get_internal_mocked_headers( + service_name=service_name, + region_name=integration_region, + source_arn=get_source_arn(context), + role_arn=credentials, + ) + ) + query_params = integration_req["query_string_parameters"].copy() + data = integration_req["body"] + + if parsed_uri["action_type"] == "path": + # the Path action type allows you to override the path the request is sent to, like you would send to AWS + path = f"/{parsed_uri['path']}" + else: + # Action passes the `Action` query string parameter + path = "" + action = parsed_uri["path"] + + if target := self.get_action_service_target(service_name, action): + headers["X-Amz-Target"] = target + + query_params["Action"] = action + + if service_name in self.SERVICES_LEGACY_QUERY_PROTOCOL: + # this has been tested in AWS: for `ssm`, it fully overrides the body because SSM uses the Query + # protocol, so we simulate it that way + data = self.get_payload_from_query_string(query_params) + + url = f"{self._base_domain}{path}" + headers["Host"] = self.get_internal_host_for_service( + service_name=service_name, region_name=integration_region + ) + + request_parameters = { + "method": method, + "url": url, + "params": query_params, + "headers": headers, + } + + if method not in NO_BODY_METHODS: + request_parameters["data"] = data + + request_response = requests.request(**request_parameters) + response_content = request_response.content + + if ( + parsed_uri["action_type"] == "action" + and service_name in self.SERVICES_LEGACY_QUERY_PROTOCOL + ): + response_content = self.format_response_content_legacy( + payload=response_content, + service_name=service_name, + action=parsed_uri["path"], + request_id=context.context_variables["requestId"], + ) + + return EndpointResponse( + body=response_content, + status_code=request_response.status_code, + headers=Headers(dict(request_response.headers)), + ) + + def parse_aws_integration_uri(self, uri: str) -> ParsedAwsIntegrationUri: + """ + The URI can be of 2 shapes: Path or Action. + Path : arn:aws:apigateway:us-west-2:s3:path/{bucket}/{key} + Action: arn:aws:apigateway:us-east-1:kinesis:action/PutRecord + :param uri: the URI of the AWS integration + :return: a ParsedAwsIntegrationUri containing the service name, the region and the type of action + """ + arn, _, path = uri.partition("/") + split_arn = arn.split(":", maxsplit=5) + *_, region_name, service_name, action_type = split_arn + boto_service_name = self.SERVICE_MAP.get(service_name, service_name) + return ParsedAwsIntegrationUri( + region_name=region_name, + service_name=boto_service_name, + action_type=action_type, + path=path, + ) + + def get_action_service_target(self, service_name: str, action: str) -> str | None: + if service_name not in self.SERVICES_AUTO_TARGET: + return None + + target_prefix = get_target_prefix_for_service(service_name) + if not target_prefix: + return None + + return f"{target_prefix}.{action}" + + def get_internal_host_for_service(self, service_name: str, region_name: str): + url = self._base_domain + if service_name == "sqs": + # This follow the new SQS_ENDPOINT_STRATEGY=standard + url = config.external_service_url(subdomains=f"sqs.{region_name}") + elif "-api" in service_name: + # this could be an `.-api`, used by some services + url = config.external_service_url(subdomains=service_name) + + return urlparse(url).netloc + + @staticmethod + def get_payload_from_query_string(query_string_parameters: dict) -> str: + return json.dumps(query_string_parameters) + + @staticmethod + def format_response_content_legacy( + service_name: str, action: str, payload: bytes, request_id: str + ) -> bytes: + # TODO: not sure how much we need to support this, this supports SSM for now, once we write more tests for + # `action` type, see if we can generalize more + data = json.loads(payload) + try: + # we try to populate the missing fields from the OperationModel of the operation + operation_model = get_service_catalog().get(service_name).operation_model(action) + for key in operation_model.output_shape.members: + if key not in data: + data[key] = None + + except Exception: + # the operation above is only for parity reason, skips if it fails + pass + + wrapped = { + f"{action}Response": { + f"{action}Result": data, + "ResponseMetadata": { + "RequestId": request_id, + }, + } + } + return to_bytes(json.dumps(wrapped)) + + +class RestApiAwsProxyIntegration(RestApiIntegration): + """ + This is a custom, simplified REST API integration focused only on the Lambda service, with minimal modification from + API Gateway. It passes the incoming request almost as is, in a custom created event payload, to the configured + Lambda function. + + https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + """ + + name = "AWS_PROXY" + + def invoke(self, context: RestApiInvocationContext) -> EndpointResponse: + integration_req: IntegrationRequest = context.integration_request + method = integration_req["http_method"] + + if method != HTTPMethod.POST: + LOG.warning( + "The 'AWS_PROXY' integration can only be used with the POST integration method.", + ) + raise IntegrationFailureError("Internal server error") + + input_event = self.create_lambda_input_event(context) + + # TODO: verify stage variables rendering in AWS_PROXY + integration_uri = integration_req["uri"] + + function_arn = get_lambda_function_arn_from_invocation_uri(integration_uri) + source_arn = get_source_arn(context) + + # TODO: write test for credentials rendering + if credentials := context.integration.get("credentials"): + credentials = render_uri_with_stage_variables(credentials, context.stage_variables) + + try: + lambda_payload = self.call_lambda( + function_arn=function_arn, + event=to_bytes(json.dumps(input_event)), + source_arn=source_arn, + credentials=credentials, + ) + + except ClientError as e: + LOG.warning( + "Exception during integration invocation: '%s'", + e, + ) + status_code = 502 + if e.response["Error"]["Code"] == "AccessDeniedException": + status_code = 500 + raise IntegrationFailureError("Internal server error", status_code=status_code) from e + + except Exception as e: + LOG.warning( + "Unexpected exception during integration invocation: '%s'", + e, + ) + raise IntegrationFailureError("Internal server error", status_code=502) from e + + lambda_response = self.parse_lambda_response(lambda_payload) + + headers = Headers({"Content-Type": APPLICATION_JSON}) + + response_headers = self._merge_lambda_response_headers(lambda_response) + headers.update(response_headers) + + # TODO: maybe centralize this flag inside the context, when we are also using it for other integration types + # AWS_PROXY behaves a bit differently, but this could checked only once earlier + binary_response_accepted = mime_type_matches_binary_media_types( + mime_type=context.invocation_request["headers"].get("Accept"), + binary_media_types=context.deployment.rest_api.rest_api.get("binaryMediaTypes", []), + ) + body = self._parse_body( + body=lambda_response.get("body"), + is_base64_encoded=binary_response_accepted and lambda_response.get("isBase64Encoded"), + ) + + return EndpointResponse( + headers=headers, + body=body, + status_code=int(lambda_response.get("statusCode") or 200), + ) + + @staticmethod + def call_lambda( + function_arn: str, + event: bytes, + source_arn: str, + credentials: str = None, + ) -> bytes: + lambda_client = get_service_factory( + region_name=extract_region_from_arn(function_arn), + role_arn=credentials, + ).lambda_ + inv_result = lambda_client.request_metadata( + service_principal=ServicePrincipal.apigateway, + source_arn=source_arn, + ).invoke( + FunctionName=function_arn, + Payload=event, + InvocationType="RequestResponse", + ) + if payload := inv_result.get("Payload"): + return payload.read() + return b"" + + def parse_lambda_response(self, payload: bytes) -> LambdaProxyResponse: + try: + lambda_response = json.loads(payload) + except json.JSONDecodeError: + LOG.warning( + 'Lambda output should follow the next JSON format: { "isBase64Encoded": true|false, "statusCode": httpStatusCode, "headers": { "headerName": "headerValue", ... },"body": "..."} but was: %s', + payload, + ) + LOG.debug( + "Execution failed due to configuration error: Malformed Lambda proxy response" + ) + raise InternalServerError("Internal server error", status_code=502) + + # none of the lambda response fields are mandatory, but you cannot return any other fields + if not self._is_lambda_response_valid(lambda_response): + if "errorMessage" in lambda_response: + LOG.debug( + "Lambda execution failed with status 200 due to customer function error: %s. Lambda request id: %s", + lambda_response["errorMessage"], + lambda_response.get("requestId", ""), + ) + else: + LOG.warning( + 'Lambda output should follow the next JSON format: { "isBase64Encoded": true|false, "statusCode": httpStatusCode, "headers": { "headerName": "headerValue", ... },"body": "..."} but was: %s', + payload, + ) + LOG.debug( + "Execution failed due to configuration error: Malformed Lambda proxy response" + ) + raise InternalServerError("Internal server error", status_code=502) + + def serialize_header(value: bool | str) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return value + + if headers := lambda_response.get("headers"): + lambda_response["headers"] = {k: serialize_header(v) for k, v in headers.items()} + + if multi_value_headers := lambda_response.get("multiValueHeaders"): + lambda_response["multiValueHeaders"] = { + k: [serialize_header(v) for v in values] + for k, values in multi_value_headers.items() + } + + return lambda_response + + @staticmethod + def _is_lambda_response_valid(lambda_response: dict) -> bool: + if not isinstance(lambda_response, dict): + return False + + if not validate_sub_dict_of_typed_dict(LambdaProxyResponse, lambda_response): + return False + + if (headers := lambda_response.get("headers")) is not None: + if not isinstance(headers, dict): + return False + if any(not isinstance(header_value, (str, bool)) for header_value in headers.values()): + return False + + if (multi_value_headers := lambda_response.get("multiValueHeaders")) is not None: + if not isinstance(multi_value_headers, dict): + return False + if any( + not isinstance(header_value, list) for header_value in multi_value_headers.values() + ): + return False + + if "statusCode" in lambda_response: + try: + int(lambda_response["statusCode"]) + except ValueError: + return False + + # TODO: add more validations of the values' type + return True + + def create_lambda_input_event(self, context: RestApiInvocationContext) -> LambdaInputEvent: + # https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + # for building the Lambda Payload, we need access to the Invocation Request, as some data is not available in + # the integration request and does not make sense for it + invocation_req: InvocationRequest = context.invocation_request + integration_req: IntegrationRequest = context.integration_request + + body, is_b64_encoded = self._format_body(integration_req["body"]) + + if context.base_path: + path = context.context_variables["path"] + else: + path = invocation_req["path"] + + input_event = LambdaInputEvent( + headers=self._format_headers(dict(integration_req["headers"])), + multiValueHeaders=self._format_headers( + build_multi_value_headers(integration_req["headers"]) + ), + body=body or None, + isBase64Encoded=is_b64_encoded, + requestContext=context.context_variables, + stageVariables=context.stage_variables, + # still using the InvocationRequest query string parameters as the logic is the same, maybe refactor? + queryStringParameters=invocation_req["query_string_parameters"] or None, + multiValueQueryStringParameters=invocation_req["multi_value_query_string_parameters"] + or None, + pathParameters=invocation_req["path_parameters"] or None, + httpMethod=invocation_req["http_method"], + path=path, + resource=context.resource["path"], + ) + + return input_event + + @staticmethod + def _format_headers(headers: dict[str, str | list[str]]) -> dict[str, str | list[str]]: + # Some headers get capitalized like in CloudFront, see + # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html#add-origin-custom-headers-forward-authorization + # It seems AWS_PROXY lambda integrations are behind CloudFront, as seen by the returned headers in AWS + to_capitalize: list[str] = ["authorization", "user-agent"] # some headers get capitalized + to_filter: list[str] = ["content-length", "connection"] + headers = { + k.title() if k.lower() in to_capitalize else k: v + for k, v in headers.items() + if k.lower() not in to_filter + } + + return headers + + @staticmethod + def _format_body(body: bytes) -> tuple[str, bool]: + try: + return body.decode("utf-8"), False + except UnicodeDecodeError: + return to_str(base64.b64encode(body)), True + + @staticmethod + def _parse_body(body: str | None, is_base64_encoded: bool) -> bytes: + if not body: + return b"" + + if is_base64_encoded: + try: + return base64.b64decode(body) + except Exception: + raise InternalServerError("Internal server error", status_code=500) + + return to_bytes(body) + + @staticmethod + def _merge_lambda_response_headers(lambda_response: LambdaProxyResponse) -> dict: + headers = lambda_response.get("headers") or {} + + if multi_value_headers := lambda_response.get("multiValueHeaders"): + # multiValueHeaders has the priority and will decide the casing of the final headers, as they are merged + headers_low_keys = {k.lower(): v for k, v in headers.items()} + + for k, values in multi_value_headers.items(): + if (k_lower := k.lower()) in headers_low_keys: + headers[k] = [*values, headers_low_keys[k_lower]] + else: + headers[k] = values + + return headers diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/core.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/core.py new file mode 100644 index 0000000000000..c65b1a9539d7f --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/core.py @@ -0,0 +1,19 @@ +from abc import abstractmethod + +from ..api import RestApiInvocationContext +from ..context import EndpointResponse + + +class RestApiIntegration: + """ + This REST API Integration exposes an API to invoke the specific Integration with a common interface. + + https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-integration-settings.html + TODO: Add more abstractmethods when starting to work on the Integration handler + """ + + name: str + + @abstractmethod + def invoke(self, context: RestApiInvocationContext) -> EndpointResponse: + pass diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/http.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/http.py new file mode 100644 index 0000000000000..fa0511072c9d1 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/http.py @@ -0,0 +1,147 @@ +import logging +from http import HTTPMethod +from typing import Optional, TypedDict + +import requests +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import Integration + +from ..context import EndpointResponse, IntegrationRequest, RestApiInvocationContext +from ..gateway_response import ApiConfigurationError, IntegrationFailureError +from ..header_utils import build_multi_value_headers +from .core import RestApiIntegration + +LOG = logging.getLogger(__name__) + +NO_BODY_METHODS = {HTTPMethod.OPTIONS, HTTPMethod.GET, HTTPMethod.HEAD} + + +class SimpleHttpRequest(TypedDict, total=False): + method: HTTPMethod | str + url: str + params: Optional[dict[str, str | list[str]]] + data: bytes + headers: Optional[dict[str, str]] + cookies: Optional[dict[str, str]] + timeout: Optional[int] + allow_redirects: Optional[bool] + stream: Optional[bool] + verify: Optional[bool] + # TODO: check if there was a situation where we'd pass certs? + cert: Optional[str | tuple[str, str]] + + +class BaseRestApiHttpIntegration(RestApiIntegration): + @staticmethod + def _get_integration_timeout(integration: Integration) -> float: + return int(integration.get("timeoutInMillis", 29000)) / 1000 + + +class RestApiHttpIntegration(BaseRestApiHttpIntegration): + """ + This is a REST API integration responsible to send a request to another HTTP API. + https://docs.aws.amazon.com/apigateway/latest/developerguide/setup-http-integrations.html#api-gateway-set-up-http-proxy-integration-on-proxy-resource + """ + + name = "HTTP" + + def invoke(self, context: RestApiInvocationContext) -> EndpointResponse: + integration_req: IntegrationRequest = context.integration_request + method = integration_req["http_method"] + uri = integration_req["uri"] + + request_parameters: SimpleHttpRequest = { + "method": method, + "url": uri, + "params": integration_req["query_string_parameters"], + "headers": integration_req["headers"], + } + + if method not in NO_BODY_METHODS: + request_parameters["data"] = integration_req["body"] + + # TODO: configurable timeout (29 by default) (check type and default value in provider) + # integration: Integration = context.resource_method["methodIntegration"] + # request_parameters["timeout"] = self._get_integration_timeout(integration) + # TODO: check for redirects + # request_parameters["allow_redirects"] = False + try: + request_response = requests.request(**request_parameters) + + except (requests.exceptions.InvalidURL, requests.exceptions.InvalidSchema) as e: + LOG.warning("Execution failed due to configuration error: Invalid endpoint address") + LOG.debug("The URI specified for the HTTP/HTTP_PROXY integration is invalid: %s", uri) + raise ApiConfigurationError("Internal server error") from e + + except (requests.exceptions.Timeout, requests.exceptions.SSLError) as e: + # TODO make the exception catching more fine grained + # this can be reproduced in AWS if you try to hit an HTTP endpoint which is HTTPS only like lambda URL + LOG.warning("Execution failed due to a network error communicating with endpoint") + raise IntegrationFailureError("Network error communicating with endpoint") from e + + except requests.exceptions.ConnectionError as e: + raise ApiConfigurationError("Internal server error") from e + + return EndpointResponse( + body=request_response.content, + status_code=request_response.status_code, + headers=Headers(dict(request_response.headers)), + ) + + +class RestApiHttpProxyIntegration(BaseRestApiHttpIntegration): + """ + This is a simplified REST API integration responsible to send a request to another HTTP API by proxying it almost + directly. + https://docs.aws.amazon.com/apigateway/latest/developerguide/setup-http-integrations.html#api-gateway-set-up-http-proxy-integration-on-proxy-resource + """ + + name = "HTTP_PROXY" + + def invoke(self, context: RestApiInvocationContext) -> EndpointResponse: + integration_req: IntegrationRequest = context.integration_request + method = integration_req["http_method"] + uri = integration_req["uri"] + + multi_value_headers = build_multi_value_headers(integration_req["headers"]) + request_headers = {key: ",".join(value) for key, value in multi_value_headers.items()} + + request_parameters: SimpleHttpRequest = { + "method": method, + "url": uri, + "params": integration_req["query_string_parameters"], + "headers": request_headers, + } + + # TODO: validate this for HTTP_PROXY + if method not in NO_BODY_METHODS: + request_parameters["data"] = integration_req["body"] + + # TODO: configurable timeout (29 by default) (check type and default value in provider) + # integration: Integration = context.resource_method["methodIntegration"] + # request_parameters["timeout"] = self._get_integration_timeout(integration) + try: + request_response = requests.request(**request_parameters) + + except (requests.exceptions.InvalidURL, requests.exceptions.InvalidSchema) as e: + LOG.warning("Execution failed due to configuration error: Invalid endpoint address") + LOG.debug("The URI specified for the HTTP/HTTP_PROXY integration is invalid: %s", uri) + raise ApiConfigurationError("Internal server error") from e + + except (requests.exceptions.Timeout, requests.exceptions.SSLError): + # TODO make the exception catching more fine grained + # this can be reproduced in AWS if you try to hit an HTTP endpoint which is HTTPS only like lambda URL + LOG.warning("Execution failed due to a network error communicating with endpoint") + raise IntegrationFailureError("Network error communicating with endpoint") + + except requests.exceptions.ConnectionError: + raise ApiConfigurationError("Internal server error") + + response_headers = Headers(dict(request_response.headers)) + + return EndpointResponse( + body=request_response.content, + status_code=request_response.status_code, + headers=response_headers, + ) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/mock.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/mock.py new file mode 100644 index 0000000000000..84ddecc05862e --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/integrations/mock.py @@ -0,0 +1,108 @@ +import json +import logging +import re +from json import JSONDecodeError + +from werkzeug.datastructures import Headers + +from localstack.utils.strings import to_str + +from ..context import EndpointResponse, IntegrationRequest, RestApiInvocationContext +from ..gateway_response import InternalServerError +from .core import RestApiIntegration + +LOG = logging.getLogger(__name__) + + +class RestApiMockIntegration(RestApiIntegration): + """ + This is a simple REST API integration but quite limited, allowing you to quickly test your APIs or return + hardcoded responses to the client. + This integration can never return a proper response, and all the work is done with integration request and response + mappings. + This can be used to set up CORS response for `OPTIONS` requests. + https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-mock-integration.html + """ + + name = "MOCK" + + def invoke(self, context: RestApiInvocationContext) -> EndpointResponse: + integration_req: IntegrationRequest = context.integration_request + + status_code = self.get_status_code(integration_req) + + if status_code is None: + LOG.debug( + "Execution failed due to configuration error: Unable to parse statusCode. " + "It should be an integer that is defined in the request template." + ) + raise InternalServerError("Internal server error") + + return EndpointResponse(status_code=status_code, body=b"", headers=Headers()) + + def get_status_code(self, integration_req: IntegrationRequest) -> int | None: + try: + body = json.loads(integration_req["body"]) + except JSONDecodeError as e: + LOG.debug( + "Exception while JSON parsing integration request body: %s" + "Falling back to custom parser", + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + body = self.parse_invalid_json(to_str(integration_req["body"])) + + status_code = body.get("statusCode") + if not isinstance(status_code, int): + return + + return status_code + + def parse_invalid_json(self, body: str) -> dict: + """This is a quick fix to unblock cdk users setting cors policy for rest apis. + CDK creates a MOCK OPTIONS route with in valid json. `{statusCode: 200}` + Aws probably has a custom token parser. We can implement one + at some point if we have user requests for it""" + + def convert_null_value(value) -> str: + if (value := value.strip()) in ("null", ""): + return '""' + return value + + try: + statuscode = "" + matched = re.match(r"^\s*{(.+)}\s*$", body).group(1) + pairs = [m.strip() for m in matched.split(",")] + # TODO this is not right, but nested object would otherwise break the parsing + key_values = [s.split(":", maxsplit=1) for s in pairs if s] + for key_value in key_values: + assert len(key_value) == 2 + key, value = [convert_null_value(el) for el in key_value] + + if key in ("statusCode", "'statusCode'", '"statusCode"'): + statuscode = int(value) + continue + + assert (leading_key_char := key[0]) not in "[{" + if leading_key_char in "'\"": + assert len(key) >= 2 + assert key[-1] == leading_key_char + + if (leading_value_char := value[0]) in "[{'\"": + assert len(value) >= 2 + if leading_value_char == "{": + # TODO reparse objects + assert value[-1] == "}" + elif leading_value_char == "[": + # TODO validate arrays + assert value[-1] == "]" + else: + assert value[-1] == leading_value_char + + return {"statusCode": statuscode} + + except Exception as e: + LOG.debug( + "Error Parsing an invalid json, %s", e, exc_info=LOG.isEnabledFor(logging.DEBUG) + ) + return {"statusCode": ""} diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py new file mode 100644 index 0000000000000..d54b25b560759 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/moto_helpers.py @@ -0,0 +1,82 @@ +from moto.apigateway.models import APIGatewayBackend, apigateway_backends +from moto.apigateway.models import RestAPI as MotoRestAPI + +from localstack.aws.api.apigateway import ( + ApiKey, + ListOfUsagePlan, + ListOfUsagePlanKey, + Resource, + Stage, +) + + +def get_resources_from_moto_rest_api(moto_rest_api: MotoRestAPI) -> dict[str, Resource]: + """ + This returns the `Resources` from a Moto REST API + This allows to decouple the underlying split of resources between Moto and LocalStack, and always return the right + format. + """ + moto_resources = moto_rest_api.resources + + resources: dict[str, Resource] = {} + for moto_resource in moto_resources.values(): + resource = Resource( + id=moto_resource.id, + parentId=moto_resource.parent_id, + pathPart=moto_resource.path_part, + path=moto_resource.get_path(), + resourceMethods={ + # TODO: check if resource_methods.to_json() returns everything we need/want + k: v.to_json() + for k, v in moto_resource.resource_methods.items() + }, + ) + + resources[moto_resource.id] = resource + + return resources + + +def get_stage_variables( + account_id: str, region: str, api_id: str, stage_name: str +) -> dict[str, str]: + apigateway_backend: APIGatewayBackend = apigateway_backends[account_id][region] + moto_rest_api = apigateway_backend.get_rest_api(api_id) + stage = moto_rest_api.stages[stage_name] + return stage.variables + + +def get_stage_configuration(account_id: str, region: str, api_id: str, stage_name: str) -> Stage: + apigateway_backend: APIGatewayBackend = apigateway_backends[account_id][region] + moto_rest_api = apigateway_backend.get_rest_api(api_id) + stage = moto_rest_api.stages[stage_name] + return stage.to_json() + + +def get_usage_plans(account_id: str, region_name: str) -> ListOfUsagePlan: + """ + Will return a list of usage plans from the moto store. + """ + apigateway_backend: APIGatewayBackend = apigateway_backends[account_id][region_name] + return [usage_plan.to_json() for usage_plan in apigateway_backend.usage_plans.values()] + + +def get_api_key(api_key_id: str, account_id: str, region_name: str) -> ApiKey: + """ + Will return an api key from the moto store. + """ + apigateway_backend: APIGatewayBackend = apigateway_backends[account_id][region_name] + return apigateway_backend.keys[api_key_id].to_json() + + +def get_usage_plan_keys( + usage_plan_id: str, account_id: str, region_name: str +) -> ListOfUsagePlanKey: + """ + Will return a list of usage plan keys from the moto store. + """ + apigateway_backend: APIGatewayBackend = apigateway_backends[account_id][region_name] + return [ + usage_plan_key.to_json() + for usage_plan_key in apigateway_backend.usage_plan_keys.get(usage_plan_id, {}).values() + ] diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/parameters_mapping.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/parameters_mapping.py new file mode 100644 index 0000000000000..bb723e58ea4ef --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/parameters_mapping.py @@ -0,0 +1,298 @@ +# > This section explains how to set up data mappings from an API's method request data, including other data +# stored in context, stage, or util variables, to the corresponding integration request parameters and from an +# integration response data, including the other data, to the method response parameters. The method request +# data includes request parameters (path, query string, headers) and the body. The integration response data +# includes response parameters (headers) and the body. For more information about using the stage variables, +# see API Gateway stage variables reference. +# +# https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html +import json +import logging +from typing import Any, TypedDict + +from localstack.utils.json import extract_jsonpath +from localstack.utils.strings import to_str + +from .context import EndpointResponse, InvocationRequest +from .gateway_response import BadRequestException, InternalFailureException +from .header_utils import build_multi_value_headers +from .variables import ContextVariables + +LOG = logging.getLogger(__name__) + + +class RequestDataMapping(TypedDict): + # Integration request parameters, in the form of path variables, query strings or headers, can be mapped from any + # defined method request parameters and the payload. + header: dict[str, str] + path: dict[str, str] + querystring: dict[str, str | list[str]] + + +class ResponseDataMapping(TypedDict): + # Method response header parameters can be mapped from any integration response header or integration response body, + # $context variables, or static values. + header: dict[str, str] + + +class ParametersMapper: + def map_integration_request( + self, + request_parameters: dict[str, str], + invocation_request: InvocationRequest, + context_variables: ContextVariables, + stage_variables: dict[str, str], + ) -> RequestDataMapping: + request_data_mapping = RequestDataMapping( + header={}, + path={}, + querystring={}, + ) + # storing the case-sensitive headers once, the mapping is strict + case_sensitive_headers = build_multi_value_headers(invocation_request["headers"]) + + for integration_mapping, request_mapping in request_parameters.items(): + # TODO: remove this once the validation has been added to the provider, to avoid breaking + if not isinstance(integration_mapping, str) or not isinstance(request_mapping, str): + LOG.warning( + "Wrong parameter mapping value type: %s: %s. They should both be string. Skipping this mapping.", + integration_mapping, + request_mapping, + ) + continue + + integration_param_location, param_name = integration_mapping.removeprefix( + "integration.request." + ).split(".") + + if request_mapping.startswith("method.request."): + method_req_expr = request_mapping.removeprefix("method.request.") + value = self._retrieve_parameter_from_invocation_request( + method_req_expr, invocation_request, case_sensitive_headers + ) + + else: + value = self._retrieve_parameter_from_variables_and_static( + mapping_value=request_mapping, + context_variables=context_variables, + stage_variables=stage_variables, + ) + + if value: + request_data_mapping[integration_param_location][param_name] = value + + return request_data_mapping + + def map_integration_response( + self, + response_parameters: dict[str, str], + integration_response: EndpointResponse, + context_variables: ContextVariables, + stage_variables: dict[str, str], + ) -> ResponseDataMapping: + response_data_mapping = ResponseDataMapping(header={}) + + # storing the case-sensitive headers once, the mapping is strict + case_sensitive_headers = build_multi_value_headers(integration_response["headers"]) + + for response_mapping, integration_mapping in response_parameters.items(): + header_name = response_mapping.removeprefix("method.response.header.") + + if integration_mapping.startswith("integration.response."): + method_req_expr = integration_mapping.removeprefix("integration.response.") + value = self._retrieve_parameter_from_integration_response( + method_req_expr, integration_response, case_sensitive_headers + ) + else: + value = self._retrieve_parameter_from_variables_and_static( + mapping_value=integration_mapping, + context_variables=context_variables, + stage_variables=stage_variables, + ) + + if value: + response_data_mapping["header"][header_name] = value + + return response_data_mapping + + def _retrieve_parameter_from_variables_and_static( + self, + mapping_value: str, + context_variables: dict[str, Any], + stage_variables: dict[str, str], + ) -> str | None: + if mapping_value.startswith("context."): + context_var_expr = mapping_value.removeprefix("context.") + return self._retrieve_parameter_from_context_variables( + context_var_expr, context_variables + ) + + elif mapping_value.startswith("stageVariables."): + stage_var_name = mapping_value.removeprefix("stageVariables.") + return self._retrieve_parameter_from_stage_variables(stage_var_name, stage_variables) + + elif mapping_value.startswith("'") and mapping_value.endswith("'"): + return mapping_value.strip("'") + + else: + LOG.warning( + "Unrecognized parameter mapping value: '%s'. Skipping this mapping.", + mapping_value, + ) + return None + + def _retrieve_parameter_from_integration_response( + self, + expr: str, + integration_response: EndpointResponse, + case_sensitive_headers: dict[str, list[str]], + ) -> str | None: + """ + See https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html#mapping-response-parameters + :param expr: mapping expression stripped from `integration.response.`: + Can be of the following: `header.`, multivalueheader., `body` and + `body..` + :param integration_response: the Response to map parameters from + :return: the value to map in the ResponseDataMapping + """ + if expr.startswith("body"): + body = integration_response.get("body") or b"{}" + body = body.strip() + try: + decoded_body = self._json_load(body) + except ValueError: + raise InternalFailureException(message="Internal server error") + + if expr == "body": + return to_str(body) + + elif expr.startswith("body."): + json_path = expr.removeprefix("body.") + return self._get_json_path_from_dict(decoded_body, json_path) + else: + LOG.warning( + "Unrecognized integration.response parameter: '%s'. Skipping the parameter mapping.", + expr, + ) + return None + + param_type, param_name = expr.split(".") + + if param_type == "header": + if header := case_sensitive_headers.get(param_name): + return header[-1] + + elif param_type == "multivalueheader": + if header := case_sensitive_headers.get(param_name): + return ",".join(header) + + else: + LOG.warning( + "Unrecognized integration.response parameter: '%s'. Skipping the parameter mapping.", + expr, + ) + + def _retrieve_parameter_from_invocation_request( + self, + expr: str, + invocation_request: InvocationRequest, + case_sensitive_headers: dict[str, list[str]], + ) -> str | list[str] | None: + """ + See https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html#mapping-response-parameters + :param expr: mapping expression stripped from `method.request.`: + Can be of the following: `path.`, `querystring.`, + `multivaluequerystring.`, `header.`, `multivalueheader.`, + `body` and `body..` + :param invocation_request: the InvocationRequest to map parameters from + :return: the value to map in the RequestDataMapping + """ + if expr.startswith("body"): + body = invocation_request["body"] or b"{}" + body = body.strip() + try: + decoded_body = self._json_load(body) + except ValueError: + raise BadRequestException(message="Invalid JSON in request body") + + if expr == "body": + return to_str(body) + + elif expr.startswith("body."): + json_path = expr.removeprefix("body.") + return self._get_json_path_from_dict(decoded_body, json_path) + else: + LOG.warning( + "Unrecognized method.request parameter: '%s'. Skipping the parameter mapping.", + expr, + ) + return None + + param_type, param_name = expr.split(".") + if param_type == "path": + return invocation_request["path_parameters"].get(param_name) + + elif param_type == "querystring": + multi_qs_params = invocation_request["multi_value_query_string_parameters"].get( + param_name + ) + if multi_qs_params: + return multi_qs_params[-1] + + elif param_type == "multivaluequerystring": + multi_qs_params = invocation_request["multi_value_query_string_parameters"].get( + param_name + ) + if len(multi_qs_params) == 1: + return multi_qs_params[0] + return multi_qs_params + + elif param_type == "header": + if header := case_sensitive_headers.get(param_name): + return header[-1] + + elif param_type == "multivalueheader": + if header := case_sensitive_headers.get(param_name): + return ",".join(header) + + else: + LOG.warning( + "Unrecognized method.request parameter: '%s'. Skipping the parameter mapping.", + expr, + ) + + def _retrieve_parameter_from_context_variables( + self, expr: str, context_variables: dict[str, Any] + ) -> str | None: + # we're using JSON path here because we could access nested properties like `context.identity.sourceIp` + if (value := self._get_json_path_from_dict(context_variables, expr)) and isinstance( + value, str + ): + return value + + @staticmethod + def _retrieve_parameter_from_stage_variables( + stage_var_name: str, stage_variables: dict[str, str] + ) -> str | None: + return stage_variables.get(stage_var_name) + + @staticmethod + def _get_json_path_from_dict(body: dict, path: str) -> str | None: + # TODO: verify we don't have special cases + try: + return extract_jsonpath(body, f"$.{path}") + except KeyError: + return None + + @staticmethod + def _json_load(body: bytes) -> dict | list: + """ + AWS only tries to JSON decode the body if it starts with some leading characters ({, [, ", ') + otherwise, it ignores it + :param body: + :return: + """ + if any(body.startswith(c) for c in (b"{", b"[", b"'", b'"')): + return json.loads(body) + + return {} diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py new file mode 100644 index 0000000000000..6c0ca3245164b --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/router.py @@ -0,0 +1,222 @@ +import logging +from typing import TypedDict, Unpack + +from rolo import Request, Router +from rolo.routing.handler import Handler +from werkzeug.routing import Rule + +from localstack.aws.api.apigateway import Stage +from localstack.constants import APPLICATION_JSON, AWS_REGION_US_EAST_1, DEFAULT_AWS_ACCOUNT_ID +from localstack.deprecations import deprecated_endpoint +from localstack.http import Response +from localstack.services.apigateway.models import ApiGatewayStore, apigateway_stores +from localstack.services.edge import ROUTER +from localstack.services.stores import AccountRegionBundle + +from .context import RestApiInvocationContext +from .gateway import RestApiGateway +from .helpers import should_divert_to_canary +from .moto_helpers import get_stage_configuration + +LOG = logging.getLogger(__name__) + + +class RouteHostPathParameters(TypedDict, total=False): + """ + Represents the kwargs typing for calling APIGatewayEndpoint. + Each field might be populated from the route host and path parameters, defined when registering a route. + """ + + api_id: str + path: str + port: int | None + server: str | None + stage: str | None + vpce_suffix: str | None + + +class ApiGatewayEndpoint: + """ + This class is the endpoint for API Gateway invocations of the `execute-api` route. It will take the incoming + invocation request, create a context from the API matching the route parameters, and dispatch the request to the + Gateway to be processed by the handler chain. + """ + + def __init__(self, rest_gateway: RestApiGateway = None, store: AccountRegionBundle = None): + self.rest_gateway = rest_gateway or RestApiGateway() + # we only access CrossAccount attributes in the handler, so we use a global store in default account and region + self._store = store or apigateway_stores + + @property + def _global_store(self) -> ApiGatewayStore: + return self._store[DEFAULT_AWS_ACCOUNT_ID][AWS_REGION_US_EAST_1] + + def __call__(self, request: Request, **kwargs: Unpack[RouteHostPathParameters]) -> Response: + """ + :param request: the incoming Request object + :param kwargs: can contain all the field of RouteHostPathParameters. Those values are defined on the registered + routes in ApiGatewayRouter, through host and path parameters in the shape or only. + :return: the Response object to return to the client + """ + # api_id can be cased because of custom-tag id + api_id, stage = kwargs.get("api_id", "").lower(), kwargs.get("stage") + if self.is_rest_api(api_id, stage): + context, response = self.prepare_rest_api_invocation(request, api_id, stage) + self.rest_gateway.process_with_context(context, response) + return response + else: + return self.create_not_found_response(api_id) + + def prepare_rest_api_invocation( + self, request: Request, api_id: str, stage: str + ) -> tuple[RestApiInvocationContext, Response]: + LOG.debug("APIGW v1 Endpoint called") + response = self.create_response(request) + context = RestApiInvocationContext(request) + self.populate_rest_api_invocation_context(context, api_id, stage) + + return context, response + + def is_rest_api(self, api_id: str, stage: str): + return stage in self._global_store.active_deployments.get(api_id, {}) + + def populate_rest_api_invocation_context( + self, context: RestApiInvocationContext, api_id: str, stage: str + ): + try: + deployment_id = self._global_store.active_deployments[api_id][stage] + frozen_deployment = self._global_store.internal_deployments[api_id][deployment_id] + + except KeyError: + # TODO: find proper error when trying to hit an API with no deployment/stage linked + return + + stage_configuration = self.fetch_stage_configuration( + account_id=frozen_deployment.account_id, + region=frozen_deployment.region, + api_id=api_id, + stage_name=stage, + ) + if canary_settings := stage_configuration.get("canarySettings"): + if should_divert_to_canary(canary_settings["percentTraffic"]): + deployment_id = canary_settings["deploymentId"] + frozen_deployment = self._global_store.internal_deployments[api_id][deployment_id] + context.is_canary = True + else: + context.is_canary = False + + context.deployment = frozen_deployment + context.api_id = api_id + context.stage = stage + context.stage_configuration = stage_configuration + context.deployment_id = deployment_id + + @staticmethod + def fetch_stage_configuration( + account_id: str, region: str, api_id: str, stage_name: str + ) -> Stage: + # this will be migrated once we move away from Moto, so we won't need the helper anymore and the logic will + # be implemented here + stage_variables = get_stage_configuration( + account_id=account_id, + region=region, + api_id=api_id, + stage_name=stage_name, + ) + + return stage_variables + + @staticmethod + def create_response(request: Request) -> Response: + # Creates a default apigw response. + response = Response(headers={"Content-Type": APPLICATION_JSON}) + if not (connection := request.headers.get("Connection")) or connection != "close": + # We only set the connection if it isn't close. + # There appears to be in issue in Localstack, where setting "close" will result in "close, close" + response.headers.set("Connection", "keep-alive") + return response + + @staticmethod + def create_not_found_response(api_id: str) -> Response: + not_found = Response(status=404) + not_found.set_json( + {"message": f"The API id '{api_id}' does not correspond to a deployed API Gateway API"} + ) + return not_found + + +class ApiGatewayRouter: + router: Router[Handler] + handler: ApiGatewayEndpoint + EXECUTE_API_INTERNAL_PATH = "/_aws/execute-api" + + def __init__(self, router: Router[Handler] = None, handler: ApiGatewayEndpoint = None): + self.router = router or ROUTER + self.handler = handler or ApiGatewayEndpoint() + self.registered_rules: list[Rule] = [] + + def register_routes(self) -> None: + LOG.debug("Registering API Gateway routes.") + host_pattern = ".execute-api." + deprecated_route_endpoint = deprecated_endpoint( + endpoint=self.handler, + previous_path="/restapis///_user_request_", + deprecation_version="3.8.0", + new_path=f"{self.EXECUTE_API_INTERNAL_PATH}//", + ) + rules = [ + self.router.add( + path="/", + host=host_pattern, + endpoint=self.handler, + defaults={"path": "", "stage": None}, + strict_slashes=True, + ), + self.router.add( + path="//", + host=host_pattern, + endpoint=self.handler, + defaults={"path": ""}, + strict_slashes=False, + ), + self.router.add( + path="//", + host=host_pattern, + endpoint=self.handler, + strict_slashes=True, + ), + # add the deprecated localstack-specific _user_request_ routes + self.router.add( + path="/restapis///_user_request_", + endpoint=deprecated_route_endpoint, + defaults={"path": "", "random": "?"}, + ), + self.router.add( + path="/restapis///_user_request_/", + endpoint=deprecated_route_endpoint, + strict_slashes=True, + ), + # add the localstack-specific so-called "path-style" routes when DNS resolving is not possible + self.router.add( + path=f"{self.EXECUTE_API_INTERNAL_PATH}//", + endpoint=self.handler, + defaults={"path": "", "stage": None}, + strict_slashes=True, + ), + self.router.add( + path=f"{self.EXECUTE_API_INTERNAL_PATH}///", + endpoint=self.handler, + defaults={"path": ""}, + strict_slashes=False, + ), + self.router.add( + path=f"{self.EXECUTE_API_INTERNAL_PATH}///", + endpoint=self.handler, + strict_slashes=True, + ), + ] + for rule in rules: + self.registered_rules.append(rule) + + def unregister_routes(self): + self.router.remove(self.registered_rules) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/template_mapping.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/template_mapping.py new file mode 100644 index 0000000000000..fd729f853d187 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/template_mapping.py @@ -0,0 +1,315 @@ +# > In API Gateway, an API's method request or response can take a payload in a different format from the integration +# request or response. +# +# You can transform your data to: +# - Match the payload to an API-specified format. +# - Override an API's request and response parameters and status codes. +# - Return client selected response headers. +# - Associate path parameters, query string parameters, or header parameters in the method request of HTTP proxy +# or AWS service proxy. TODO: this is from the documentation. Can we use requestOverides for proxy integrations? +# - Select which data to send using integration with AWS services, such as Amazon DynamoDB or Lambda functions, +# or HTTP endpoints. +# +# You can use mapping templates to transform your data. A mapping template is a script expressed in Velocity Template +# Language (VTL) and applied to the payload using JSONPath . +# +# https://docs.aws.amazon.com/apigateway/latest/developerguide/models-mappings.html +import base64 +import copy +import json +import logging +from typing import Any, TypedDict +from urllib.parse import quote_plus, unquote_plus + +import airspeed +from airspeed.operators import dict_to_string +from jsonpath_rw import parse + +from localstack import config +from localstack.services.apigateway.next_gen.execute_api.variables import ( + ContextVariableOverrides, + ContextVariables, + ContextVarsResponseOverride, +) +from localstack.utils.aws.templating import APIGW_SOURCE, VelocityUtil, VtlTemplate +from localstack.utils.json import json_safe + +LOG = logging.getLogger(__name__) + + +class MappingTemplateParams(TypedDict, total=False): + path: dict[str, str] + querystring: dict[str, str] + header: dict[str, str] + + +class MappingTemplateInput(TypedDict, total=False): + body: str + params: MappingTemplateParams + + +class MappingTemplateVariables(TypedDict, total=False): + context: ContextVariables + input: MappingTemplateInput + stageVariables: dict[str, str] + + +def cast_to_vtl_object(value): + if isinstance(value, dict): + return VTLMap(value) + if isinstance(value, list): + return [cast_to_vtl_object(item) for item in value] + return value + + +def cast_to_vtl_json_object(value: Any) -> Any: + if isinstance(value, dict): + return VTLJsonDict(value) + if isinstance(value, list): + return VTLJsonList(value) + return value + + +def extract_jsonpath(value: dict | list, path: str): + jsonpath_expr = parse(path) + result = [match.value for match in jsonpath_expr.find(value)] + if not result: + return None + result = result[0] if len(result) == 1 else result + return result + + +class VTLMap(dict): + """Overrides __str__ of python dict (and all child dict) to return a Java like string representation""" + + # TODO apply this class more generally through the template mappings + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.update(*args, **kwargs) + + @staticmethod + def cast_factory(value: Any) -> Any: + return cast_to_vtl_object(value) + + def update(self, *args, **kwargs): + for k, v in self.items(): + self[k] = self.cast_factory(v) + + def __str__(self) -> str: + return dict_to_string(self) + + +class VTLJsonList(list): + """Some VTL List behave differently when being represented as string and everything + inside will be represented as a json string + + Example: $input.path('$').b // Where path is {"a": 1, "b": [{"c": 5}]} + Results: '[{"c":5}]' // Where everything inside the list is a valid json object + """ + + def __init__(self, *args): + super(VTLJsonList, self).__init__(*args) + for idx, item in enumerate(self): + self[idx] = cast_to_vtl_json_object(item) + + def __str__(self): + if isinstance(self, list): + return json.dumps(self, separators=(",", ":")) + + +class VTLJsonDict(VTLMap): + """Some VTL Map behave differently when being represented as string and a list + encountered in the dictionary will be represented as a json string + + Example: $input.path('$') // Where path is {"a": 1, "b": [{"c": 5}]} + Results: '{a=1, b=[{"c":5}]}' // Where everything inside the list is a valid json object + """ + + @staticmethod + def cast_factory(value: Any) -> Any: + return cast_to_vtl_json_object(value) + + +class AttributeDict(dict): + """ + Wrapper returned by VelocityUtilApiGateway.parseJson to allow access to dict values as attributes (dot notation), + e.g.: $util.parseJson('$.foo').bar + """ + + def __init__(self, *args, **kwargs): + super(AttributeDict, self).__init__(*args, **kwargs) + for key, value in self.items(): + if isinstance(value, dict): + self[key] = AttributeDict(value) + + def __getattr__(self, name): + if name in self: + return self[name] + raise AttributeError(f"'AttributeDict' object has no attribute '{name}'") + + def __setattr__(self, name, value): + self[name] = value + + def __delattr__(self, name): + if name in self: + del self[name] + else: + raise AttributeError(f"'AttributeDict' object has no attribute '{name}'") + + +class VelocityUtilApiGateway(VelocityUtil): + """ + Simple class to mimic the behavior of variable '$util' in AWS API Gateway integration + velocity templates. + See: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + """ + + def base64Encode(self, s): + if not isinstance(s, str): + s = json.dumps(s) + encoded_str = s.encode(config.DEFAULT_ENCODING) + encoded_b64_str = base64.b64encode(encoded_str) + return encoded_b64_str.decode(config.DEFAULT_ENCODING) + + def base64Decode(self, s): + if not isinstance(s, str): + s = json.dumps(s) + return base64.b64decode(s) + + def toJson(self, obj): + return obj and json.dumps(obj) + + def urlEncode(self, s): + return quote_plus(s) + + def urlDecode(self, s): + return unquote_plus(s) + + def escapeJavaScript(self, obj: Any) -> str: + """ + Converts the given object to a string and escapes any regular single quotes (') into escaped ones (\'). + JSON dumps will escape the single quotes. + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + """ + if obj is None: + return "null" + if isinstance(obj, str): + # empty string escapes to empty object + if len(obj.strip()) == 0: + return "{}" + return json.dumps(obj)[1:-1] + if obj in (True, False): + return str(obj).lower() + return str(obj) + + def parseJson(self, s: str): + obj = json.loads(s) + return AttributeDict(obj) if isinstance(obj, dict) else obj + + +class VelocityInput: + """ + Simple class to mimic the behavior of variable '$input' in AWS API Gateway integration + velocity templates. + See: http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + """ + + def __init__(self, body, params): + self.parameters = params or {} + self.value = body + + def _extract_json_path(self, path): + if not self.value: + return None + if isinstance(self.value, dict): + value = self.value + else: + try: + value = json.loads(self.value) + except json.JSONDecodeError: + return None + + return extract_jsonpath(value, path) + + def path(self, path): + return cast_to_vtl_json_object(self._extract_json_path(path)) + + def json(self, path): + path = path or "$" + matching = self._extract_json_path(path) + if matching is None: + matching = "" + elif isinstance(matching, (list, dict)): + matching = json_safe(matching) + return json.dumps(matching) + + @property + def body(self): + if not self.value: + return "{}" + + return self.value + + def params(self, name=None): + if not name: + return self.parameters + for k in ["path", "querystring", "header"]: + if val := self.parameters.get(k).get(name): + return val + return "" + + def __getattr__(self, name): + return self.value.get(name) + + def __repr__(self): + return "$input" + + +class ApiGatewayVtlTemplate(VtlTemplate): + """Util class for rendering VTL templates with API Gateway specific extensions""" + + def prepare_namespace(self, variables, source: str = APIGW_SOURCE) -> dict[str, Any]: + namespace = super().prepare_namespace(variables, source) + input_var = variables.get("input") or {} + variables = { + "input": VelocityInput(input_var.get("body"), input_var.get("params")), + "util": VelocityUtilApiGateway(), + } + namespace.update(variables) + return namespace + + def render_request( + self, + template: str, + variables: MappingTemplateVariables, + context_overrides: ContextVariableOverrides, + ) -> tuple[str, ContextVariableOverrides]: + variables_copy: MappingTemplateVariables = copy.deepcopy(variables) + variables_copy["context"].update(copy.deepcopy(context_overrides)) + result = self.render_vtl(template=template.strip(), variables=variables_copy) + return result, ContextVariableOverrides( + requestOverride=variables_copy["context"]["requestOverride"], + responseOverride=variables_copy["context"]["responseOverride"], + ) + + def render_response( + self, + template: str, + variables: MappingTemplateVariables, + context_overrides: ContextVariableOverrides, + ) -> tuple[str, ContextVarsResponseOverride]: + variables_copy: MappingTemplateVariables = copy.deepcopy(variables) + variables_copy["context"].update(copy.deepcopy(context_overrides)) + result = self.render_vtl(template=template.strip(), variables=variables_copy) + return result, variables_copy["context"]["responseOverride"] + + +# patches required to allow our custom class operations in VTL templates processed by airspeed +airspeed.operators.__additional_methods__[VTLMap] = airspeed.operators.__additional_methods__[dict] +airspeed.operators.__additional_methods__[VTLJsonDict] = airspeed.operators.__additional_methods__[ + dict +] +airspeed.operators.__additional_methods__[VTLJsonList] = airspeed.operators.__additional_methods__[ + list +] diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/test_invoke.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/test_invoke.py new file mode 100644 index 0000000000000..0d871077aa707 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/test_invoke.py @@ -0,0 +1,214 @@ +import datetime +from urllib.parse import parse_qs + +from rolo import Request +from rolo.gateway.chain import HandlerChain +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import TestInvokeMethodRequest, TestInvokeMethodResponse +from localstack.constants import APPLICATION_JSON +from localstack.http import Response +from localstack.utils.strings import to_bytes, to_str + +from ...models import RestApiDeployment +from . import handlers +from .context import InvocationRequest, RestApiInvocationContext +from .handlers.resource_router import RestAPIResourceRouter +from .header_utils import build_multi_value_headers +from .template_mapping import dict_to_string +from .variables import ( + ContextVariableOverrides, + ContextVarsRequestOverride, + ContextVarsResponseOverride, +) + +# TODO: we probably need to write and populate those logs as part of the handler chain itself +# and store it in the InvocationContext. That way, we could also retrieve in when calling TestInvoke + +TEST_INVOKE_TEMPLATE = """Execution log for request {request_id} +{formatted_date} : Starting execution for request: {request_id} +{formatted_date} : HTTP Method: {request_method}, Resource Path: {resource_path} +{formatted_date} : Method request path: {method_request_path_parameters} +{formatted_date} : Method request query string: {method_request_query_string} +{formatted_date} : Method request headers: {method_request_headers} +{formatted_date} : Method request body before transformations: {method_request_body} +{formatted_date} : Endpoint request URI: {endpoint_uri} +{formatted_date} : Endpoint request headers: {endpoint_request_headers} +{formatted_date} : Endpoint request body after transformations: {endpoint_request_body} +{formatted_date} : Sending request to {endpoint_uri} +{formatted_date} : Received response. Status: {endpoint_response_status_code}, Integration latency: {endpoint_response_latency} ms +{formatted_date} : Endpoint response headers: {endpoint_response_headers} +{formatted_date} : Endpoint response body before transformations: {endpoint_response_body} +{formatted_date} : Method response body after transformations: {method_response_body} +{formatted_date} : Method response headers: {method_response_headers} +{formatted_date} : Successfully completed execution +{formatted_date} : Method completed with status: {method_response_status} +""" + + +def _dump_headers(headers: Headers) -> str: + if not headers: + return "{}" + multi_headers = {key: ",".join(headers.getlist(key)) for key in headers.keys()} + string_headers = dict_to_string(multi_headers) + if len(string_headers) > 998: + return f"{string_headers[:998]} [TRUNCATED]" + + return string_headers + + +def log_template(invocation_context: RestApiInvocationContext, response_headers: Headers) -> str: + # TODO: funny enough, in AWS for the `endpoint_response_headers` in AWS_PROXY, they log the response headers from + # lambda HTTP Invoke call even though we use the headers from the lambda response itself + formatted_date = datetime.datetime.now(tz=datetime.UTC).strftime("%a %b %d %H:%M:%S %Z %Y") + request = invocation_context.invocation_request + context_var = invocation_context.context_variables + integration_req = invocation_context.integration_request + endpoint_resp = invocation_context.endpoint_response + method_resp = invocation_context.invocation_response + # TODO: if endpoint_uri is an ARN, it means it's an AWS_PROXY integration + # this should be transformed to the true URL of a lambda invoke call + endpoint_uri = integration_req.get("uri", "") + + return TEST_INVOKE_TEMPLATE.format( + formatted_date=formatted_date, + request_id=context_var["requestId"], + resource_path=request["path"], + request_method=request["http_method"], + method_request_path_parameters=dict_to_string(request["path_parameters"]), + method_request_query_string=dict_to_string(request["query_string_parameters"]), + method_request_headers=_dump_headers(request.get("headers")), + method_request_body=to_str(request.get("body", "")), + endpoint_uri=endpoint_uri, + endpoint_request_headers=_dump_headers(integration_req.get("headers")), + endpoint_request_body=to_str(integration_req.get("body", "")), + # TODO: measure integration latency + endpoint_response_latency=150, + endpoint_response_status_code=endpoint_resp.get("status_code"), + endpoint_response_body=to_str(endpoint_resp.get("body", "")), + endpoint_response_headers=_dump_headers(endpoint_resp.get("headers")), + method_response_status=method_resp.get("status_code"), + method_response_body=to_str(method_resp.get("body", "")), + method_response_headers=_dump_headers(response_headers), + ) + + +def create_test_chain() -> HandlerChain[RestApiInvocationContext]: + return HandlerChain( + request_handlers=[ + handlers.method_request_handler, + handlers.integration_request_handler, + handlers.integration_handler, + handlers.integration_response_handler, + handlers.method_response_handler, + ], + exception_handlers=[ + handlers.gateway_exception_handler, + ], + ) + + +def create_test_invocation_context( + test_request: TestInvokeMethodRequest, + deployment: RestApiDeployment, +) -> RestApiInvocationContext: + parse_handler = handlers.parse_request + http_method = test_request["httpMethod"] + + # we do not need a true HTTP request for the context, as we are skipping all the parsing steps and using the + # provider data + invocation_context = RestApiInvocationContext( + request=Request(method=http_method), + ) + path_query = test_request.get("pathWithQueryString", "/").split("?") + path = path_query[0] + multi_query_args: dict[str, list[str]] = {} + + if len(path_query) > 1: + multi_query_args = parse_qs(path_query[1]) + + # for the single value parameters, AWS only keeps the last value of the list + single_query_args = {k: v[-1] for k, v in multi_query_args.items()} + + invocation_request = InvocationRequest( + http_method=http_method, + path=path, + raw_path=path, + query_string_parameters=single_query_args, + multi_value_query_string_parameters=multi_query_args, + headers=Headers(test_request.get("headers")), + # TODO: handle multiValueHeaders + body=to_bytes(test_request.get("body") or ""), + ) + invocation_context.invocation_request = invocation_request + + _, path_parameters = RestAPIResourceRouter(deployment).match(invocation_context) + invocation_request["path_parameters"] = path_parameters + + invocation_context.deployment = deployment + invocation_context.api_id = test_request["restApiId"] + invocation_context.stage = None + invocation_context.deployment_id = "" + invocation_context.account_id = deployment.account_id + invocation_context.region = deployment.region + invocation_context.stage_variables = test_request.get("stageVariables", {}) + invocation_context.context_variables = parse_handler.create_context_variables( + invocation_context + ) + invocation_context.context_variable_overrides = ContextVariableOverrides( + requestOverride=ContextVarsRequestOverride(header={}, path={}, querystring={}), + responseOverride=ContextVarsResponseOverride(header={}, status=0), + ) + invocation_context.trace_id = parse_handler.populate_trace_id({}) + resource = deployment.rest_api.resources[test_request["resourceId"]] + resource_method = resource["resourceMethods"][http_method] + invocation_context.resource = resource + invocation_context.resource_method = resource_method + invocation_context.integration = resource_method["methodIntegration"] + handlers.route_request.update_context_variables_with_resource( + invocation_context.context_variables, resource + ) + + return invocation_context + + +def run_test_invocation( + test_request: TestInvokeMethodRequest, deployment: RestApiDeployment +) -> TestInvokeMethodResponse: + # validate resource exists in deployment + invocation_context = create_test_invocation_context(test_request, deployment) + + test_chain = create_test_chain() + # header order is important + if invocation_context.integration["type"] == "MOCK": + base_headers = {"Content-Type": APPLICATION_JSON} + else: + # we manually add the trace-id, as it is normally added by handlers.response_enricher which adds to much data + # for the TestInvoke. It needs to be first + base_headers = { + "X-Amzn-Trace-Id": invocation_context.trace_id, + "Content-Type": APPLICATION_JSON, + } + + test_response = Response(headers=base_headers) + start_time = datetime.datetime.now() + test_chain.handle(context=invocation_context, response=test_response) + end_time = datetime.datetime.now() + + response_headers = test_response.headers.copy() + # AWS does not return the Content-Length for TestInvokeMethod + response_headers.remove("Content-Length") + + log = log_template(invocation_context, response_headers) + + headers = dict(response_headers) + multi_value_headers = build_multi_value_headers(response_headers) + + return TestInvokeMethodResponse( + log=log, + status=test_response.status_code, + body=test_response.get_data(as_text=True), + headers=headers, + multiValueHeaders=multi_value_headers, + latency=int((end_time - start_time).total_seconds()), + ) diff --git a/localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py b/localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py new file mode 100644 index 0000000000000..e457c61180353 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/execute_api/variables.py @@ -0,0 +1,195 @@ +from typing import Optional, TypedDict + + +class ContextVarsAuthorizer(TypedDict, total=False): + # this is merged with the Context returned by the Authorizer, which can attach any property to this dict in string + # format + + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + claims: Optional[dict[str, str]] + """Claims returned from the Amazon Cognito user pool after the method caller is successfully authenticated""" + principalId: Optional[str] + """The principal user identification associated with the token sent by the client and returned from an API Gateway Lambda authorizer""" + + +class ContextVarsIdentityClientCertValidity(TypedDict, total=False): + notBefore: str + notAfter: str + + +class ContextVarsIdentityClientCert(TypedDict, total=False): + """Certificate that a client presents. Present only in access logs if mutual TLS authentication fails.""" + + clientCertPem: str + subjectDN: str + issuerDN: str + serialNumber: str + validity: ContextVarsIdentityClientCertValidity + + +class ContextVarsIdentity(TypedDict, total=False): + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html + accountId: Optional[str] + """The AWS account ID associated with the request.""" + accessKey: Optional[str] + """The AWS access key associated with the request.""" + apiKey: Optional[str] + """For API methods that require an API key, this variable is the API key associated with the method request.""" + apiKeyId: Optional[str] + """The API key ID associated with an API request that requires an API key.""" + caller: Optional[str] + """The principal identifier of the caller that signed the request. Supported for resources that use IAM authorization.""" + cognitoAuthenticationProvider: Optional[str] + """A comma-separated list of the Amazon Cognito authentication providers used by the caller making the request""" + cognitoAuthenticationType: Optional[str] + """The Amazon Cognito authentication type of the caller making the request""" + cognitoIdentityId: Optional[str] + """The Amazon Cognito identity ID of the caller making the request""" + cognitoIdentityPoolId: Optional[str] + """The Amazon Cognito identity pool ID of the caller making the request""" + principalOrgId: Optional[str] + """The AWS organization ID.""" + sourceIp: Optional[str] + """The source IP address of the immediate TCP connection making the request to the API Gateway endpoint""" + clientCert: ContextVarsIdentityClientCert + vpcId: Optional[str] + """The VPC ID of the VPC making the request to the API Gateway endpoint.""" + vpceId: Optional[str] + """The VPC endpoint ID of the VPC endpoint making the request to the API Gateway endpoint.""" + user: Optional[str] + """The principal identifier of the user that will be authorized against resource access for resources that use IAM authorization.""" + userAgent: Optional[str] + """The User-Agent header of the API caller.""" + userArn: Optional[str] + """The Amazon Resource Name (ARN) of the effective user identified after authentication.""" + + +class ContextVarsRequestOverride(TypedDict, total=False): + header: dict[str, str] + path: dict[str, str] + querystring: dict[str, str] + + +class ContextVarsResponseOverride(TypedDict): + header: dict[str, str] + status: int + + +class ContextVariableOverrides(TypedDict): + requestOverride: ContextVarsRequestOverride + responseOverride: ContextVarsResponseOverride + + +class GatewayResponseContextVarsError(TypedDict, total=False): + # This variable can only be used for simple variable substitution in a GatewayResponse body-mapping template, + # which is not processed by the Velocity Template Language engine, and in access logging. + message: str + messageString: str + responseType: str + validationErrorString: str + + +class ContextVariables(TypedDict, total=False): + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#context-variable-reference + accountId: str + """The API owner's AWS account ID.""" + apiId: str + """The identifier API Gateway assigns to your API.""" + authorizer: Optional[ContextVarsAuthorizer] + """The principal user identification associated with the token.""" + awsEndpointRequestId: Optional[str] + """The AWS endpoint's request ID.""" + deploymentId: str + """The ID of the API deployment.""" + domainName: str + """The full domain name used to invoke the API. This should be the same as the incoming Host header.""" + domainPrefix: str + """The first label of the $context.domainName.""" + error: GatewayResponseContextVarsError + """The error context variables.""" + extendedRequestId: str + """The extended ID that API Gateway generates and assigns to the API request. """ + httpMethod: str + """The HTTP method used""" + identity: Optional[ContextVarsIdentity] + isCanaryRequest: Optional[bool] + """Indicates if the request was directed to the canary""" + path: str + """The request path.""" + protocol: str + """The request protocol""" + requestId: str + """An ID for the request. Clients can override this request ID. """ + requestOverride: Optional[ContextVarsRequestOverride] + """Request override. Only exists for request mapping template""" + requestTime: str + """The CLF-formatted request time (dd/MMM/yyyy:HH:mm:ss +-hhmm).""" + requestTimeEpoch: int + """The Epoch-formatted request time, in milliseconds.""" + resourceId: Optional[str] + """The identifier that API Gateway assigns to your resource.""" + resourcePath: Optional[str] + """The path to your resource""" + responseOverride: Optional[ContextVarsResponseOverride] + """Response override. Only exists for response mapping template""" + stage: str + """The deployment stage of the API request """ + wafResponseCode: Optional[str] + """The response received from AWS WAF: WAF_ALLOW or WAF_BLOCK. Will not be set if the stage is not associated with a web ACL""" + webaclArn: Optional[str] + """The complete ARN of the web ACL that is used to decide whether to allow or block the request. Will not be set if the stage is not associated with a web ACL.""" + + +class LoggingContextVarsAuthorize(TypedDict, total=False): + error: Optional[str] + latency: Optional[str] + status: Optional[str] + + +class LoggingContextVarsAuthorizer(TypedDict, total=False): + error: Optional[str] + integrationLatency: Optional[str] + integrationStatus: Optional[str] + latency: Optional[str] + requestId: Optional[str] + status: Optional[str] + + +class LoggingContextVarsAuthenticate(TypedDict, total=False): + error: Optional[str] + latency: Optional[str] + status: Optional[str] + + +class LoggingContextVarsCustomDomain(TypedDict, total=False): + basePathMatched: Optional[str] + + +class LoggingContextVarsIntegration(TypedDict, total=False): + error: Optional[str] + integrationStatus: Optional[str] + latency: Optional[str] + requestId: Optional[str] + status: Optional[str] + + +class LoggingContextVarsWaf(TypedDict, total=False): + error: Optional[str] + latency: Optional[str] + status: Optional[str] + + +class LoggingContextVariables(TypedDict, total=False): + authorize: Optional[LoggingContextVarsAuthorize] + authorizer: Optional[LoggingContextVarsAuthorizer] + authenticate: Optional[LoggingContextVarsAuthenticate] + customDomain: Optional[LoggingContextVarsCustomDomain] + endpointType: Optional[str] + integration: Optional[LoggingContextVarsIntegration] + integrationLatency: Optional[str] + integrationStatus: Optional[str] + responseLatency: Optional[str] + responseLength: Optional[str] + status: Optional[str] + waf: Optional[LoggingContextVarsWaf] + xrayTraceId: Optional[str] diff --git a/localstack-core/localstack/services/apigateway/next_gen/provider.py b/localstack-core/localstack/services/apigateway/next_gen/provider.py new file mode 100644 index 0000000000000..5153463c60a4c --- /dev/null +++ b/localstack-core/localstack/services/apigateway/next_gen/provider.py @@ -0,0 +1,490 @@ +import copy +import datetime +import re + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.apigateway import ( + BadRequestException, + CacheClusterSize, + CreateStageRequest, + Deployment, + DeploymentCanarySettings, + GatewayResponse, + GatewayResponses, + GatewayResponseType, + ListOfPatchOperation, + MapOfStringToString, + NotFoundException, + NullableBoolean, + NullableInteger, + Stage, + StatusCode, + String, + TestInvokeMethodRequest, + TestInvokeMethodResponse, +) +from localstack.services.apigateway.helpers import ( + get_apigateway_store, + get_moto_rest_api, + get_rest_api_container, +) +from localstack.services.apigateway.legacy.provider import ( + STAGE_UPDATE_PATHS, + ApigatewayProvider, + patch_api_gateway_entity, +) +from localstack.services.apigateway.patches import apply_patches +from localstack.services.edge import ROUTER +from localstack.services.moto import call_moto + +from ..models import apigateway_stores +from .execute_api.gateway_response import ( + DEFAULT_GATEWAY_RESPONSES, + GatewayResponseCode, + build_gateway_response, + get_gateway_response_or_default, +) +from .execute_api.helpers import freeze_rest_api +from .execute_api.router import ApiGatewayEndpoint, ApiGatewayRouter +from .execute_api.test_invoke import run_test_invocation + + +class ApigatewayNextGenProvider(ApigatewayProvider): + router: ApiGatewayRouter + + def __init__(self, router: ApiGatewayRouter = None): + # we initialize the route handler with a global store with default account and region, because it only ever + # access values with CrossAccount attributes + if not router: + route_handler = ApiGatewayEndpoint(store=apigateway_stores) + router = ApiGatewayRouter(ROUTER, handler=route_handler) + + super().__init__(router=router) + + def on_after_init(self): + apply_patches() + self.router.register_routes() + + @handler("DeleteRestApi") + def delete_rest_api(self, context: RequestContext, rest_api_id: String, **kwargs) -> None: + super().delete_rest_api(context, rest_api_id, **kwargs) + store = get_apigateway_store(context=context) + api_id_lower = rest_api_id.lower() + store.active_deployments.pop(api_id_lower, None) + store.internal_deployments.pop(api_id_lower, None) + + @handler("CreateStage", expand=False) + def create_stage(self, context: RequestContext, request: CreateStageRequest) -> Stage: + # TODO: we need to internalize Stages and Deployments in LocalStack, we have a lot of split logic + super().create_stage(context, request) + rest_api_id = request["restApiId"].lower() + stage_name = request["stageName"] + moto_api = get_moto_rest_api(context, rest_api_id) + stage = moto_api.stages[stage_name] + + if canary_settings := request.get("canarySettings"): + if ( + deployment_id := canary_settings.get("deploymentId") + ) and deployment_id not in moto_api.deployments: + raise BadRequestException("Deployment id does not exist") + + default_settings = { + "deploymentId": stage.deployment_id, + "percentTraffic": 0.0, + "useStageCache": False, + } + default_settings.update(canary_settings) + stage.canary_settings = default_settings + else: + stage.canary_settings = None + + store = get_apigateway_store(context=context) + + store.active_deployments.setdefault(rest_api_id, {}) + store.active_deployments[rest_api_id][stage_name] = request["deploymentId"] + response: Stage = stage.to_json() + self._patch_stage_response(response) + return response + + @handler("UpdateStage") + def update_stage( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String, + patch_operations: ListOfPatchOperation = None, + **kwargs, + ) -> Stage: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + if not (moto_stage := moto_rest_api.stages.get(stage_name)): + raise NotFoundException("Invalid Stage identifier specified") + + # construct list of path regexes for validation + path_regexes = [re.sub("{[^}]+}", ".+", path) for path in STAGE_UPDATE_PATHS] + + # copy the patch operations to not mutate them, so that we're logging the correct input + patch_operations = copy.deepcopy(patch_operations) or [] + # we are only passing a subset of operations to Moto as it does not handle properly all of them + moto_patch_operations = [] + moto_stage_copy = copy.deepcopy(moto_stage) + for patch_operation in patch_operations: + skip_moto_apply = False + patch_path = patch_operation["path"] + patch_op = patch_operation["op"] + + # special case: handle updates (op=remove) for wildcard method settings + patch_path_stripped = patch_path.strip("/") + if patch_path_stripped == "*/*" and patch_op == "remove": + if not moto_stage.method_settings.pop(patch_path_stripped, None): + raise BadRequestException( + "Cannot remove method setting */* because there is no method setting for this method " + ) + response = moto_stage.to_json() + self._patch_stage_response(response) + return response + + path_valid = patch_path in STAGE_UPDATE_PATHS or any( + re.match(regex, patch_path) for regex in path_regexes + ) + if is_canary := patch_path.startswith("/canarySettings"): + skip_moto_apply = True + path_valid = is_canary_settings_update_patch_valid(op=patch_op, path=patch_path) + # it seems our JSON Patch utility does not handle replace properly if the value does not exists before + # it seems to maybe be a Stage-only thing, so replacing it here + if patch_op == "replace": + patch_operation["op"] = "add" + + if patch_op == "copy": + copy_from = patch_operation.get("from") + if patch_path not in ("/deploymentId", "/variables") or copy_from not in ( + "/canarySettings/deploymentId", + "/canarySettings/stageVariableOverrides", + ): + raise BadRequestException( + "Invalid copy operation with path: /canarySettings/stageVariableOverrides and from /variables. Valid copy:path are [/deploymentId, /variables] and valid copy:from are [/canarySettings/deploymentId, /canarySettings/stageVariableOverrides]" + ) + + if copy_from.startswith("/canarySettings") and not getattr( + moto_stage_copy, "canary_settings", None + ): + raise BadRequestException("Promotion not available. Canary does not exist.") + + if patch_path == "/variables": + moto_stage_copy.variables.update( + moto_stage_copy.canary_settings.get("stageVariableOverrides", {}) + ) + elif patch_path == "/deploymentId": + moto_stage_copy.deployment_id = moto_stage_copy.canary_settings["deploymentId"] + + # we manually assign `copy` ops, no need to apply them + continue + + if not path_valid: + valid_paths = f"[{', '.join(STAGE_UPDATE_PATHS)}]" + # note: weird formatting in AWS - required for snapshot testing + valid_paths = valid_paths.replace( + "/{resourcePath}/{httpMethod}/throttling/burstLimit, /{resourcePath}/{httpMethod}/throttling/rateLimit, /{resourcePath}/{httpMethod}/caching/ttlInSeconds", + "/{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds", + ) + valid_paths = valid_paths.replace("/burstLimit, /", "/burstLimit /") + valid_paths = valid_paths.replace("/rateLimit, /", "/rateLimit /") + raise BadRequestException( + f"Invalid method setting path: {patch_operation['path']}. Must be one of: {valid_paths}" + ) + + # TODO: check if there are other boolean, maybe add a global step in _patch_api_gateway_entity + if patch_path == "/tracingEnabled" and (value := patch_operation.get("value")): + patch_operation["value"] = value and value.lower() == "true" or False + + elif patch_path in ("/canarySettings/deploymentId", "/deploymentId"): + if patch_op != "copy" and not moto_rest_api.deployments.get( + patch_operation.get("value") + ): + raise BadRequestException("Deployment id does not exist") + + if not skip_moto_apply: + # we need to copy the patch operation because `_patch_api_gateway_entity` is mutating it in place + moto_patch_operations.append(dict(patch_operation)) + + # we need to apply patch operation individually to be able to validate the logic + # TODO: rework the patching logic + patch_api_gateway_entity(moto_stage_copy, [patch_operation]) + if is_canary and (canary_settings := getattr(moto_stage_copy, "canary_settings", None)): + default_canary_settings = { + "deploymentId": moto_stage_copy.deployment_id, + "percentTraffic": 0.0, + "useStageCache": False, + } + default_canary_settings.update(canary_settings) + default_canary_settings["percentTraffic"] = float( + default_canary_settings["percentTraffic"] + ) + moto_stage_copy.canary_settings = default_canary_settings + + moto_rest_api.stages[stage_name] = moto_stage_copy + moto_stage_copy.apply_operations(moto_patch_operations) + if moto_stage.deployment_id != moto_stage_copy.deployment_id: + store = get_apigateway_store(context=context) + store.active_deployments.setdefault(rest_api_id.lower(), {})[stage_name] = ( + moto_stage_copy.deployment_id + ) + + moto_stage_copy.last_updated_date = datetime.datetime.now(tz=datetime.UTC) + + response = moto_stage_copy.to_json() + self._patch_stage_response(response) + return response + + def delete_stage( + self, context: RequestContext, rest_api_id: String, stage_name: String, **kwargs + ) -> None: + call_moto(context) + store = get_apigateway_store(context=context) + store.active_deployments[rest_api_id.lower()].pop(stage_name, None) + + def create_deployment( + self, + context: RequestContext, + rest_api_id: String, + stage_name: String = None, + stage_description: String = None, + description: String = None, + cache_cluster_enabled: NullableBoolean = None, + cache_cluster_size: CacheClusterSize = None, + variables: MapOfStringToString = None, + canary_settings: DeploymentCanarySettings = None, + tracing_enabled: NullableBoolean = None, + **kwargs, + ) -> Deployment: + moto_rest_api = get_moto_rest_api(context, rest_api_id) + if canary_settings: + # TODO: add validation to the canary settings + if not stage_name: + error_stage = stage_name if stage_name is not None else "null" + raise BadRequestException( + f"Invalid deployment content specified.Non null and non empty stageName must be provided for canary deployment. Provided value is {error_stage}" + ) + if stage_name not in moto_rest_api.stages: + raise BadRequestException( + "Invalid deployment content specified.Stage non-existing must already be created before making a canary release deployment" + ) + + # FIXME: moto has an issue and is not handling canarySettings, hence overwriting the current stage with the + # canary deployment + current_stage = None + if stage_name: + current_stage = copy.deepcopy(moto_rest_api.stages.get(stage_name)) + + # TODO: if the REST API does not contain any method, we should raise an exception + deployment: Deployment = call_moto(context) + # https://docs.aws.amazon.com/apigateway/latest/developerguide/updating-api.html + # TODO: the deployment is not accessible until it is linked to a stage + # you can combine a stage or later update the deployment with a stage id + store = get_apigateway_store(context=context) + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + frozen_deployment = freeze_rest_api( + account_id=context.account_id, + region=context.region, + moto_rest_api=moto_rest_api, + localstack_rest_api=rest_api_container, + ) + router_api_id = rest_api_id.lower() + deployment_id = deployment["id"] + store.internal_deployments.setdefault(router_api_id, {})[deployment_id] = frozen_deployment + + if stage_name: + moto_stage = moto_rest_api.stages[stage_name] + if canary_settings: + moto_stage = current_stage + moto_rest_api.stages[stage_name] = current_stage + + default_settings = { + "deploymentId": deployment_id, + "percentTraffic": 0.0, + "useStageCache": False, + } + default_settings.update(canary_settings) + moto_stage.canary_settings = default_settings + else: + store.active_deployments.setdefault(router_api_id, {})[stage_name] = deployment_id + moto_stage.canary_settings = None + + if variables: + moto_stage.variables = variables + + moto_stage.description = stage_description or moto_stage.description or None + + if cache_cluster_enabled is not None: + moto_stage.cache_cluster_enabled = cache_cluster_enabled + + if cache_cluster_size is not None: + moto_stage.cache_cluster_size = cache_cluster_size + + if tracing_enabled is not None: + moto_stage.tracing_enabled = tracing_enabled + + return deployment + + def delete_deployment( + self, context: RequestContext, rest_api_id: String, deployment_id: String, **kwargs + ) -> None: + call_moto(context) + store = get_apigateway_store(context=context) + store.internal_deployments.get(rest_api_id.lower(), {}).pop(deployment_id, None) + + def put_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + status_code: StatusCode = None, + response_parameters: MapOfStringToString = None, + response_templates: MapOfStringToString = None, + **kwargs, + ) -> GatewayResponse: + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + if response_type not in DEFAULT_GATEWAY_RESPONSES: + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{response_type}' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(DEFAULT_GATEWAY_RESPONSES)}]", + ) + + gateway_response = build_gateway_response( + status_code=status_code, + response_parameters=response_parameters, + response_templates=response_templates, + response_type=response_type, + default_response=False, + ) + + rest_api_container.gateway_responses[response_type] = gateway_response + + # The CRUD provider has a weird behavior: for some responses (for now, INTEGRATION_FAILURE), it sets the default + # status code to `504`. However, in the actual invocation logic, it returns 500. To deal with the inconsistency, + # we need to set the value to None if not provided by the user, so that the invocation logic can properly return + # 500, and the CRUD layer can still return 504 even though it is technically wrong. + response = gateway_response.copy() + if response.get("statusCode") is None: + response["statusCode"] = GatewayResponseCode[response_type] + + return response + + def get_gateway_response( + self, + context: RequestContext, + rest_api_id: String, + response_type: GatewayResponseType, + **kwargs, + ) -> GatewayResponse: + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + if response_type not in DEFAULT_GATEWAY_RESPONSES: + raise CommonServiceException( + code="ValidationException", + message=f"1 validation error detected: Value '{response_type}' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(DEFAULT_GATEWAY_RESPONSES)}]", + ) + + gateway_response = _get_gateway_response_or_default( + response_type, rest_api_container.gateway_responses + ) + # TODO: add validation with the parameters? seems like it validated client side? how to try? + return gateway_response + + def get_gateway_responses( + self, + context: RequestContext, + rest_api_id: String, + position: String = None, + limit: NullableInteger = None, + **kwargs, + ) -> GatewayResponses: + store = get_apigateway_store(context=context) + if not (rest_api_container := store.rest_apis.get(rest_api_id)): + raise NotFoundException( + f"Invalid API identifier specified {context.account_id}:{rest_api_id}" + ) + + user_gateway_resp = rest_api_container.gateway_responses + gateway_responses = [ + _get_gateway_response_or_default(response_type, user_gateway_resp) + for response_type in DEFAULT_GATEWAY_RESPONSES + ] + return GatewayResponses(items=gateway_responses) + + def test_invoke_method( + self, context: RequestContext, request: TestInvokeMethodRequest + ) -> TestInvokeMethodResponse: + rest_api_id = request["restApiId"] + moto_rest_api = get_moto_rest_api(context=context, rest_api_id=rest_api_id) + resource = moto_rest_api.resources.get(request["resourceId"]) + if not resource: + raise NotFoundException("Invalid Resource identifier specified") + + # test httpMethod + + rest_api_container = get_rest_api_container(context, rest_api_id=rest_api_id) + frozen_deployment = freeze_rest_api( + account_id=context.account_id, + region=context.region, + moto_rest_api=moto_rest_api, + localstack_rest_api=rest_api_container, + ) + + response = run_test_invocation( + test_request=request, + deployment=frozen_deployment, + ) + + return response + + +def is_canary_settings_update_patch_valid(op: str, path: str) -> bool: + path_regexes = ( + r"\/canarySettings\/percentTraffic", + r"\/canarySettings\/deploymentId", + r"\/canarySettings\/stageVariableOverrides\/.+", + r"\/canarySettings\/useStageCache", + ) + if path == "/canarySettings" and op == "remove": + return True + + matches_path = any(re.match(regex, path) for regex in path_regexes) + + if op not in ("replace", "copy"): + if matches_path: + raise BadRequestException(f"Invalid {op} operation with path: {path}") + + raise BadRequestException( + f"Cannot {op} method setting {path.lstrip('/')} because there is no method setting for this method " + ) + + # stageVariableOverrides is a bit special as it's nested, it doesn't return the same error message + if not matches_path and path != "/canarySettings/stageVariableOverrides": + return False + + return True + + +def _get_gateway_response_or_default( + response_type: GatewayResponseType, + gateway_responses: dict[GatewayResponseType, GatewayResponse], +) -> GatewayResponse: + """ + Utility function that overrides the behavior of `get_gateway_response_or_default` by setting a default status code + from the `GatewayResponseCode` values. In reality, some default values in the invocation layer are different from + what the CRUD layer of API Gateway is returning. + """ + response = get_gateway_response_or_default(response_type, gateway_responses) + if response.get("statusCode") is None and (status_code := GatewayResponseCode[response_type]): + response["statusCode"] = status_code + + return response diff --git a/localstack-core/localstack/services/apigateway/patches.py b/localstack-core/localstack/services/apigateway/patches.py new file mode 100644 index 0000000000000..ca12f96284fff --- /dev/null +++ b/localstack-core/localstack/services/apigateway/patches.py @@ -0,0 +1,207 @@ +import datetime +import json +import logging + +from moto.apigateway import models as apigateway_models +from moto.apigateway.exceptions import ( + DeploymentNotFoundException, + NoIntegrationDefined, + RestAPINotFound, + StageStillActive, +) +from moto.apigateway.responses import APIGatewayResponse +from moto.core.utils import camelcase_to_underscores + +from localstack.constants import TAG_KEY_CUSTOM_ID +from localstack.services.apigateway.helpers import apply_json_patch_safe +from localstack.utils.common import str_to_bool +from localstack.utils.patch import patch + +LOG = logging.getLogger(__name__) + + +def apply_patches(): + # TODO refactor patches in this module (e.g., use @patch decorator, simplify, ...) + + def apigateway_models_Stage_init( + self, cacheClusterEnabled=False, cacheClusterSize=None, **kwargs + ): + apigateway_models_Stage_init_orig( + self, + cacheClusterEnabled=cacheClusterEnabled, + cacheClusterSize=cacheClusterSize, + **kwargs, + ) + + if (cacheClusterSize or cacheClusterEnabled) and not self.cache_cluster_status: + self.cache_cluster_status = "AVAILABLE" + + now = datetime.datetime.now(tz=datetime.UTC) + self.created_date = now + self.last_updated_date = now + + apigateway_models_Stage_init_orig = apigateway_models.Stage.__init__ + apigateway_models.Stage.__init__ = apigateway_models_Stage_init + + @patch(APIGatewayResponse.put_integration) + def apigateway_put_integration(fn, self, *args, **kwargs): + # TODO: verify if this patch is still necessary, this might have been fixed upstream + fn(self, *args, **kwargs) + + url_path_parts = self.path.split("/") + function_id = url_path_parts[2] + resource_id = url_path_parts[4] + method_type = url_path_parts[6] + integration = self.backend.get_integration(function_id, resource_id, method_type) + + timeout_milliseconds = self._get_param("timeoutInMillis") + cache_key_parameters = self._get_param("cacheKeyParameters") or [] + content_handling = self._get_param("contentHandling") + integration.cache_namespace = resource_id + integration.timeout_in_millis = timeout_milliseconds + integration.cache_key_parameters = cache_key_parameters + integration.content_handling = content_handling + return 201, {}, json.dumps(integration.to_json()) + + # define json-patch operations for backend models + + def backend_model_apply_operations(self, patch_operations): + # run pre-actions + if isinstance(self, apigateway_models.Stage) and [ + op for op in patch_operations if "/accessLogSettings" in op.get("path", "") + ]: + self.access_log_settings = self.access_log_settings or {} + # apply patches + apply_json_patch_safe(self, patch_operations, in_place=True) + # run post-actions + if isinstance(self, apigateway_models.Stage): + bool_params = ["cacheClusterEnabled", "tracingEnabled"] + for bool_param in bool_params: + if getattr(self, camelcase_to_underscores(bool_param), None): + value = getattr(self, camelcase_to_underscores(bool_param), None) + setattr(self, camelcase_to_underscores(bool_param), str_to_bool(value)) + return self + + model_classes = [ + apigateway_models.Authorizer, + apigateway_models.DomainName, + apigateway_models.MethodResponse, + ] + for model_class in model_classes: + model_class.apply_operations = model_class.apply_patch_operations = ( + backend_model_apply_operations + ) + + # fix data types for some json-patch operation values + + @patch(apigateway_models.Stage._get_default_method_settings) + def _get_default_method_settings(fn, self): + result = fn(self) + default_settings = self.method_settings.get("*/*", {}) + result["cacheDataEncrypted"] = default_settings.get("cacheDataEncrypted", False) + result["throttlingRateLimit"] = default_settings.get("throttlingRateLimit", 10000.0) + result["throttlingBurstLimit"] = default_settings.get("throttlingBurstLimit", 5000) + result["metricsEnabled"] = default_settings.get("metricsEnabled", False) + result["dataTraceEnabled"] = default_settings.get("dataTraceEnabled", False) + result["unauthorizedCacheControlHeaderStrategy"] = default_settings.get( + "unauthorizedCacheControlHeaderStrategy", "SUCCEED_WITH_RESPONSE_HEADER" + ) + result["cacheTtlInSeconds"] = default_settings.get("cacheTtlInSeconds", 300) + result["cachingEnabled"] = default_settings.get("cachingEnabled", False) + result["requireAuthorizationForCacheControl"] = default_settings.get( + "requireAuthorizationForCacheControl", True + ) + return result + + # patch integration error responses + @patch(apigateway_models.Resource.get_integration) + def apigateway_models_resource_get_integration(fn, self, method_type): + resource_method = self.resource_methods.get(method_type, {}) + if not resource_method.method_integration: + raise NoIntegrationDefined() + return resource_method.method_integration + + @patch(apigateway_models.RestAPI.to_dict) + def apigateway_models_rest_api_to_dict(fn, self): + resp = fn(self) + resp["policy"] = None + if self.policy: + # Strip whitespaces for TF compatibility (not entirely sure why we need double-dumps, + # but otherwise: "error normalizing policy JSON: invalid character 'V' after top-level value") + resp["policy"] = json.dumps(json.dumps(json.loads(self.policy), separators=(",", ":")))[ + 1:-1 + ] + + if not self.tags: + resp["tags"] = None + + resp["disableExecuteApiEndpoint"] = ( + str(resp.get("disableExecuteApiEndpoint")).lower() == "true" + ) + + return resp + + @patch(apigateway_models.Stage.to_json) + def apigateway_models_stage_to_json(fn, self): + result = fn(self) + + if "documentationVersion" not in result: + result["documentationVersion"] = getattr(self, "documentation_version", None) + + if "canarySettings" not in result: + result["canarySettings"] = getattr(self, "canary_settings", None) + + if "createdDate" not in result: + created_date = getattr(self, "created_date", None) + if created_date: + created_date = int(created_date.timestamp()) + result["createdDate"] = created_date + + if "lastUpdatedDate" not in result: + last_updated_date = getattr(self, "last_updated_date", None) + if last_updated_date: + last_updated_date = int(last_updated_date.timestamp()) + result["lastUpdatedDate"] = last_updated_date + + return result + + @patch(apigateway_models.Stage._str2bool, pass_target=False) + def apigateway_models_stage_str_to_bool(self, v: bool | str) -> bool: + return str_to_bool(v) + + # TODO remove this patch when the behavior is implemented in moto + @patch(apigateway_models.APIGatewayBackend.create_rest_api) + def create_rest_api(fn, self, *args, tags=None, **kwargs): + """ + https://github.com/localstack/localstack/pull/4413/files + Add ability to specify custom IDs for API GW REST APIs via tags + """ + tags = tags or {} + result = fn(self, *args, tags=tags, **kwargs) + # TODO: lower the custom_id when getting it from the tags, as AWS is case insensitive + if custom_id := tags.get(TAG_KEY_CUSTOM_ID): + self.apis.pop(result.id) + result.id = custom_id + self.apis[custom_id] = result + return result + + @patch(apigateway_models.APIGatewayBackend.get_rest_api, pass_target=False) + def get_rest_api(self, function_id): + for key in self.apis.keys(): + if key.lower() == function_id.lower(): + return self.apis[key] + raise RestAPINotFound() + + @patch(apigateway_models.RestAPI.delete_deployment, pass_target=False) + def patch_delete_deployment(self, deployment_id: str) -> apigateway_models.Deployment: + if deployment_id not in self.deployments: + raise DeploymentNotFoundException() + deployment = self.deployments[deployment_id] + if deployment.stage_name and ( + (stage := self.stages.get(deployment.stage_name)) + and stage.deployment_id == deployment.id + ): + # Stage is still active + raise StageStillActive() + + return self.deployments.pop(deployment_id) diff --git a/localstack/utils/kinesis/__init__.py b/localstack-core/localstack/services/apigateway/resource_providers/__init__.py similarity index 100% rename from localstack/utils/kinesis/__init__.py rename to localstack-core/localstack/services/apigateway/resource_providers/__init__.py diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account.py new file mode 100644 index 0000000000000..8c78925a5a8b8 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account.py @@ -0,0 +1,110 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ApiGatewayAccountProperties(TypedDict): + CloudWatchRoleArn: Optional[str] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayAccountProvider(ResourceProvider[ApiGatewayAccountProperties]): + TYPE = "AWS::ApiGateway::Account" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayAccountProperties], + ) -> ProgressEvent[ApiGatewayAccountProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + + + + + Read-only properties: + - /properties/Id + + IAM permissions required: + - apigateway:PATCH + - iam:GetRole + - iam:PassRole + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + role_arn = model["CloudWatchRoleArn"] + apigw.update_account( + patchOperations=[{"op": "replace", "path": "/cloudwatchRoleArn", "value": role_arn}] + ) + + model["Id"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayAccountProperties], + ) -> ProgressEvent[ApiGatewayAccountProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayAccountProperties], + ) -> ProgressEvent[ApiGatewayAccountProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + + # note: deletion of accounts is currently a no-op + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayAccountProperties], + ) -> ProgressEvent[ApiGatewayAccountProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:PATCH + - iam:GetRole + - iam:PassRole + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account.schema.json new file mode 100644 index 0000000000000..3192ca8c3b443 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account.schema.json @@ -0,0 +1,46 @@ +{ + "typeName": "AWS::ApiGateway::Account", + "description": "Resource Type definition for AWS::ApiGateway::Account", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "Id": { + "description": "Primary identifier which is manually generated.", + "type": "string" + }, + "CloudWatchRoleArn": { + "description": "The Amazon Resource Name (ARN) of an IAM role that has write access to CloudWatch Logs in your account.", + "type": "string" + } + }, + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:PATCH", + "iam:GetRole", + "iam:PassRole" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:PATCH", + "iam:GetRole", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account_plugin.py new file mode 100644 index 0000000000000..d7dc5c91ce0d1 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_account_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayAccountProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::Account" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_account import ( + ApiGatewayAccountProvider, + ) + + self.factory = ApiGatewayAccountProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey.py new file mode 100644 index 0000000000000..1385cd6c5d01c --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey.py @@ -0,0 +1,136 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.objects import keys_to_lower + + +class ApiGatewayApiKeyProperties(TypedDict): + APIKeyId: Optional[str] + CustomerId: Optional[str] + Description: Optional[str] + Enabled: Optional[bool] + GenerateDistinctId: Optional[bool] + Name: Optional[str] + StageKeys: Optional[list[StageKey]] + Tags: Optional[list[Tag]] + Value: Optional[str] + + +class StageKey(TypedDict): + RestApiId: Optional[str] + StageName: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayApiKeyProvider(ResourceProvider[ApiGatewayApiKeyProperties]): + TYPE = "AWS::ApiGateway::ApiKey" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayApiKeyProperties], + ) -> ProgressEvent[ApiGatewayApiKeyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/APIKeyId + + + Create-only properties: + - /properties/GenerateDistinctId + - /properties/Name + - /properties/Value + + Read-only properties: + - /properties/APIKeyId + + IAM permissions required: + - apigateway:POST + - apigateway:GET + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + params = util.select_attributes( + model, ["Description", "CustomerId", "Name", "Value", "Enabled", "StageKeys"] + ) + params = keys_to_lower(params.copy()) + if "enabled" in params: + params["enabled"] = bool(params["enabled"]) + + if model.get("Tags"): + params["tags"] = {tag["Key"]: tag["Value"] for tag in model["Tags"]} + + response = apigw.create_api_key(**params) + model["APIKeyId"] = response["id"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayApiKeyProperties], + ) -> ProgressEvent[ApiGatewayApiKeyProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayApiKeyProperties], + ) -> ProgressEvent[ApiGatewayApiKeyProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + apigw.delete_api_key(apiKey=model["APIKeyId"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayApiKeyProperties], + ) -> ProgressEvent[ApiGatewayApiKeyProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:PATCH + - apigateway:PUT + - apigateway:DELETE + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey.schema.json new file mode 100644 index 0000000000000..4d58557451ff8 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey.schema.json @@ -0,0 +1,135 @@ +{ + "typeName": "AWS::ApiGateway::ApiKey", + "description": "Resource Type definition for AWS::ApiGateway::ApiKey", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "APIKeyId": { + "description": "A Unique Key ID which identifies the API Key. Generated by the Create API and returned by the Read and List APIs ", + "type": "string" + }, + "CustomerId": { + "description": "An AWS Marketplace customer identifier to use when integrating with the AWS SaaS Marketplace.", + "type": "string" + }, + "Description": { + "description": "A description of the purpose of the API key.", + "type": "string" + }, + "Enabled": { + "description": "Indicates whether the API key can be used by clients.", + "default": false, + "type": "boolean" + }, + "GenerateDistinctId": { + "description": "Specifies whether the key identifier is distinct from the created API key value. This parameter is deprecated and should not be used.", + "type": "boolean" + }, + "Name": { + "description": "A name for the API key. If you don't specify a name, AWS CloudFormation generates a unique physical ID and uses that ID for the API key name.", + "type": "string" + }, + "StageKeys": { + "description": "A list of stages to associate with this API key.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/StageKey" + } + }, + "Tags": { + "description": "An array of arbitrary tags (key-value pairs) to associate with the API key.", + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Value": { + "description": "The value of the API key. Must be at least 20 characters long.", + "type": "string" + } + }, + "definitions": { + "StageKey": { + "type": "object", + "additionalProperties": false, + "properties": { + "RestApiId": { + "description": "The ID of a RestApi resource that includes the stage with which you want to associate the API key.", + "type": "string" + }, + "StageName": { + "description": "The name of the stage with which to associate the API key. The stage must be included in the RestApi resource that you specified in the RestApiId property. ", + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -. ", + "type": "string", + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "createOnlyProperties": [ + "/properties/GenerateDistinctId", + "/properties/Name", + "/properties/Value" + ], + "writeOnlyProperties": [ + "/properties/GenerateDistinctId" + ], + "primaryIdentifier": [ + "/properties/APIKeyId" + ], + "readOnlyProperties": [ + "/properties/APIKeyId" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:POST", + "apigateway:GET" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:PATCH", + "apigateway:PUT", + "apigateway:DELETE" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "list": { + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey_plugin.py new file mode 100644 index 0000000000000..352ec19eec4d3 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_apikey_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayApiKeyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::ApiKey" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_apikey import ( + ApiGatewayApiKeyProvider, + ) + + self.factory = ApiGatewayApiKeyProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping.py new file mode 100644 index 0000000000000..51debd7811631 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping.py @@ -0,0 +1,122 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ApiGatewayBasePathMappingProperties(TypedDict): + DomainName: Optional[str] + BasePath: Optional[str] + RestApiId: Optional[str] + Stage: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayBasePathMappingProvider(ResourceProvider[ApiGatewayBasePathMappingProperties]): + TYPE = "AWS::ApiGateway::BasePathMapping" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayBasePathMappingProperties], + ) -> ProgressEvent[ApiGatewayBasePathMappingProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/DomainName + - /properties/BasePath + + Required properties: + - DomainName + + Create-only properties: + - /properties/DomainName + - /properties/BasePath + + + + IAM permissions required: + - apigateway:POST + - apigateway:GET + + """ + + # TODO we are using restApiId for PhysicalResourceId + # check if we need to change it + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + params = { + "domainName": model.get("DomainName"), + "restApiId": model.get("RestApiId"), + **({"basePath": model.get("BasePath")} if model.get("BasePath") else {}), + **({"stage": model.get("Stage")} if model.get("Stage") else {}), + } + response = apigw.create_base_path_mapping(**params) + model["RestApiId"] = response["restApiId"] + # TODO: validations + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayBasePathMappingProperties], + ) -> ProgressEvent[ApiGatewayBasePathMappingProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayBasePathMappingProperties], + ) -> ProgressEvent[ApiGatewayBasePathMappingProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + apigw.delete_base_path_mapping(domainName=model["DomainName"], basePath=model["BasePath"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayBasePathMappingProperties], + ) -> ProgressEvent[ApiGatewayBasePathMappingProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:DELETE + - apigateway:PATCH + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping.schema.json new file mode 100644 index 0000000000000..ded5541adedac --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping.schema.json @@ -0,0 +1,81 @@ +{ + "typeName": "AWS::ApiGateway::BasePathMapping", + "description": "Resource Type definition for AWS::ApiGateway::BasePathMapping", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "BasePath": { + "type": "string", + "description": "The base path name that callers of the API must provide in the URL after the domain name." + }, + "DomainName": { + "type": "string", + "description": "The DomainName of an AWS::ApiGateway::DomainName resource." + }, + "RestApiId": { + "type": "string", + "description": "The ID of the API." + }, + "Stage": { + "type": "string", + "description": "The name of the API's stage." + } + }, + "required": [ + "DomainName" + ], + "createOnlyProperties": [ + "/properties/DomainName", + "/properties/BasePath" + ], + "primaryIdentifier": [ + "/properties/DomainName", + "/properties/BasePath" + ], + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "handlers": { + "create": { + "permissions": [ + "apigateway:POST", + "apigateway:GET" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:DELETE", + "apigateway:PATCH" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "list": { + "handlerSchema": { + "properties": { + "DomainName": { + "$ref": "resource-schema.json#/properties/DomainName" + } + }, + "required": [ + "DomainName" + ] + }, + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping_plugin.py new file mode 100644 index 0000000000000..2dcb4b036e9ef --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_basepathmapping_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayBasePathMappingProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::BasePathMapping" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_basepathmapping import ( + ApiGatewayBasePathMappingProvider, + ) + + self.factory = ApiGatewayBasePathMappingProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment.py new file mode 100644 index 0000000000000..68bae12d2af24 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment.py @@ -0,0 +1,196 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ApiGatewayDeploymentProperties(TypedDict): + RestApiId: Optional[str] + DeploymentCanarySettings: Optional[DeploymentCanarySettings] + DeploymentId: Optional[str] + Description: Optional[str] + StageDescription: Optional[StageDescription] + StageName: Optional[str] + + +class DeploymentCanarySettings(TypedDict): + PercentTraffic: Optional[float] + StageVariableOverrides: Optional[dict] + UseStageCache: Optional[bool] + + +class AccessLogSetting(TypedDict): + DestinationArn: Optional[str] + Format: Optional[str] + + +class CanarySetting(TypedDict): + PercentTraffic: Optional[float] + StageVariableOverrides: Optional[dict] + UseStageCache: Optional[bool] + + +class MethodSetting(TypedDict): + CacheDataEncrypted: Optional[bool] + CacheTtlInSeconds: Optional[int] + CachingEnabled: Optional[bool] + DataTraceEnabled: Optional[bool] + HttpMethod: Optional[str] + LoggingLevel: Optional[str] + MetricsEnabled: Optional[bool] + ResourcePath: Optional[str] + ThrottlingBurstLimit: Optional[int] + ThrottlingRateLimit: Optional[float] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class StageDescription(TypedDict): + AccessLogSetting: Optional[AccessLogSetting] + CacheClusterEnabled: Optional[bool] + CacheClusterSize: Optional[str] + CacheDataEncrypted: Optional[bool] + CacheTtlInSeconds: Optional[int] + CachingEnabled: Optional[bool] + CanarySetting: Optional[CanarySetting] + ClientCertificateId: Optional[str] + DataTraceEnabled: Optional[bool] + Description: Optional[str] + DocumentationVersion: Optional[str] + LoggingLevel: Optional[str] + MethodSettings: Optional[list[MethodSetting]] + MetricsEnabled: Optional[bool] + Tags: Optional[list[Tag]] + ThrottlingBurstLimit: Optional[int] + ThrottlingRateLimit: Optional[float] + TracingEnabled: Optional[bool] + Variables: Optional[dict] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayDeploymentProvider(ResourceProvider[ApiGatewayDeploymentProperties]): + TYPE = "AWS::ApiGateway::Deployment" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayDeploymentProperties], + ) -> ProgressEvent[ApiGatewayDeploymentProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/DeploymentId + - /properties/RestApiId + + Required properties: + - RestApiId + + Create-only properties: + - /properties/DeploymentCanarySettings + - /properties/RestApiId + + Read-only properties: + - /properties/DeploymentId + + IAM permissions required: + - apigateway:POST + + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + + params = {"restApiId": model["RestApiId"]} + + if model.get("StageName"): + params["stageName"] = model["StageName"] + + if model.get("StageDescription"): + params["stageDescription"] = json.dumps(model["StageDescription"]) + + if model.get("Description"): + params["description"] = model["Description"] + + response = api.create_deployment(**params) + + model["DeploymentId"] = response["id"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayDeploymentProperties], + ) -> ProgressEvent[ApiGatewayDeploymentProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayDeploymentProperties], + ) -> ProgressEvent[ApiGatewayDeploymentProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:GET + - apigateway:DELETE + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + + try: + # TODO: verify if AWS behaves the same? + get_stages = api.get_stages( + restApiId=model["RestApiId"], deploymentId=model["DeploymentId"] + ) + if stages := get_stages["item"]: + for stage in stages: + api.delete_stage(restApiId=model["RestApiId"], stageName=stage["stageName"]) + + api.delete_deployment(restApiId=model["RestApiId"], deploymentId=model["DeploymentId"]) + except api.exceptions.NotFoundException: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayDeploymentProperties], + ) -> ProgressEvent[ApiGatewayDeploymentProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:PATCH + - apigateway:GET + - apigateway:PUT + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment.schema.json new file mode 100644 index 0000000000000..ab10bbf5e2a7a --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment.schema.json @@ -0,0 +1,318 @@ +{ + "typeName": "AWS::ApiGateway::Deployment", + "description": "Resource Type definition for AWS::ApiGateway::Deployment", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "DeploymentId": { + "type": "string", + "description": "Primary Id for this resource" + }, + "DeploymentCanarySettings": { + "$ref": "#/definitions/DeploymentCanarySettings", + "description": "Specifies settings for the canary deployment." + }, + "Description": { + "type": "string", + "description": "A description of the purpose of the API Gateway deployment." + }, + "RestApiId": { + "type": "string", + "description": "The ID of the RestApi resource to deploy. " + }, + "StageDescription": { + "$ref": "#/definitions/StageDescription", + "description": "Configures the stage that API Gateway creates with this deployment." + }, + "StageName": { + "type": "string", + "description": "A name for the stage that API Gateway creates with this deployment. Use only alphanumeric characters." + } + }, + "definitions": { + "StageDescription": { + "type": "object", + "additionalProperties": false, + "properties": { + "AccessLogSetting": { + "description": "Specifies settings for logging access in this stage.", + "$ref": "#/definitions/AccessLogSetting" + }, + "CacheClusterEnabled": { + "description": "Indicates whether cache clustering is enabled for the stage.", + "type": "boolean" + }, + "CacheClusterSize": { + "description": "The size of the stage's cache cluster.", + "type": "string" + }, + "CacheDataEncrypted": { + "description": "The time-to-live (TTL) period, in seconds, that specifies how long API Gateway caches responses. ", + "type": "boolean" + }, + "CacheTtlInSeconds": { + "description": "The time-to-live (TTL) period, in seconds, that specifies how long API Gateway caches responses. ", + "type": "integer" + }, + "CachingEnabled": { + "description": "Indicates whether responses are cached and returned for requests. You must enable a cache cluster on the stage to cache responses.", + "type": "boolean" + }, + "CanarySetting": { + "description": "Specifies settings for the canary deployment in this stage.", + "$ref": "#/definitions/CanarySetting" + }, + "ClientCertificateId": { + "description": "The identifier of the client certificate that API Gateway uses to call your integration endpoints in the stage. ", + "type": "string" + }, + "DataTraceEnabled": { + "description": "Indicates whether data trace logging is enabled for methods in the stage. API Gateway pushes these logs to Amazon CloudWatch Logs. ", + "type": "boolean" + }, + "Description": { + "description": "A description of the purpose of the stage.", + "type": "string" + }, + "DocumentationVersion": { + "description": "The version identifier of the API documentation snapshot.", + "type": "string" + }, + "LoggingLevel": { + "description": "The logging level for this method. For valid values, see the loggingLevel property of the Stage resource in the Amazon API Gateway API Reference. ", + "type": "string" + }, + "MethodSettings": { + "description": "Configures settings for all of the stage's methods.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/MethodSetting" + } + }, + "MetricsEnabled": { + "description": "Indicates whether Amazon CloudWatch metrics are enabled for methods in the stage.", + "type": "boolean" + }, + "Tags": { + "description": "An array of arbitrary tags (key-value pairs) to associate with the stage.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "ThrottlingBurstLimit": { + "description": "The number of burst requests per second that API Gateway permits across all APIs, stages, and methods in your AWS account.", + "type": "integer" + }, + "ThrottlingRateLimit": { + "description": "The number of steady-state requests per second that API Gateway permits across all APIs, stages, and methods in your AWS account.", + "type": "number" + }, + "TracingEnabled": { + "description": "Specifies whether active tracing with X-ray is enabled for this stage.", + "type": "boolean" + }, + "Variables": { + "description": "A map that defines the stage variables. Variable names must consist of alphanumeric characters, and the values must match the following regular expression: [A-Za-z0-9-._~:/?#&=,]+. ", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + } + } + }, + "DeploymentCanarySettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "PercentTraffic": { + "description": "The percentage (0-100) of traffic diverted to a canary deployment.", + "type": "number" + }, + "StageVariableOverrides": { + "description": "Stage variables overridden for a canary release deployment, including new stage variables introduced in the canary. These stage variables are represented as a string-to-string map between stage variable names and their values. Duplicates are not allowed.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "UseStageCache": { + "description": "Whether the canary deployment uses the stage cache.", + "type": "boolean" + } + } + }, + "AccessLogSetting": { + "type": "object", + "additionalProperties": false, + "properties": { + "DestinationArn": { + "description": "The Amazon Resource Name (ARN) of the CloudWatch Logs log group or Kinesis Data Firehose delivery stream to receive access logs. If you specify a Kinesis Data Firehose delivery stream, the stream name must begin with amazon-apigateway-. ", + "type": "string" + }, + "Format": { + "description": "A single line format of the access logs of data, as specified by selected $context variables. The format must include at least $context.requestId. ", + "type": "string" + } + } + }, + "CanarySetting": { + "type": "object", + "additionalProperties": false, + "properties": { + "PercentTraffic": { + "description": "The percent (0-100) of traffic diverted to a canary deployment.", + "type": "number" + }, + "StageVariableOverrides": { + "description": "Stage variables overridden for a canary release deployment, including new stage variables introduced in the canary. These stage variables are represented as a string-to-string map between stage variable names and their values. ", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "UseStageCache": { + "description": "Whether the canary deployment uses the stage cache or not.", + "type": "boolean" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "description": "The key name of the tag", + "type": "string" + }, + "Value": { + "description": "The value for the tag", + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "MethodSetting": { + "type": "object", + "additionalProperties": false, + "properties": { + "CacheDataEncrypted": { + "description": "Indicates whether the cached responses are encrypted", + "type": "boolean" + }, + "CacheTtlInSeconds": { + "description": "The time-to-live (TTL) period, in seconds, that specifies how long API Gateway caches responses. ", + "type": "integer" + }, + "CachingEnabled": { + "description": "Indicates whether responses are cached and returned for requests. You must enable a cache cluster on the stage to cache responses.", + "type": "boolean" + }, + "DataTraceEnabled": { + "description": "Indicates whether data trace logging is enabled for methods in the stage. API Gateway pushes these logs to Amazon CloudWatch Logs. ", + "type": "boolean" + }, + "HttpMethod": { + "description": "The HTTP method.", + "type": "string" + }, + "LoggingLevel": { + "description": "The logging level for this method. For valid values, see the loggingLevel property of the Stage resource in the Amazon API Gateway API Reference. ", + "type": "string" + }, + "MetricsEnabled": { + "description": "Indicates whether Amazon CloudWatch metrics are enabled for methods in the stage.", + "type": "boolean" + }, + "ResourcePath": { + "description": "The resource path for this method. Forward slashes (/) are encoded as ~1 and the initial slash must include a forward slash. ", + "type": "string" + }, + "ThrottlingBurstLimit": { + "description": "The number of burst requests per second that API Gateway permits across all APIs, stages, and methods in your AWS account.", + "type": "integer" + }, + "ThrottlingRateLimit": { + "description": "The number of steady-state requests per second that API Gateway permits across all APIs, stages, and methods in your AWS account.", + "type": "number" + } + } + } + }, + "taggable": true, + "required": [ + "RestApiId" + ], + "createOnlyProperties": [ + "/properties/DeploymentCanarySettings", + "/properties/RestApiId" + ], + "primaryIdentifier": [ + "/properties/DeploymentId", + "/properties/RestApiId" + ], + "readOnlyProperties": [ + "/properties/DeploymentId" + ], + "writeOnlyProperties": [ + "/properties/StageName", + "/properties/StageDescription", + "/properties/DeploymentCanarySettings" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:POST" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:PATCH", + "apigateway:GET", + "apigateway:PUT" + ] + }, + "delete": { + "permissions": [ + "apigateway:GET", + "apigateway:DELETE" + ] + }, + "list": { + "handlerSchema": { + "properties": { + "RestApiId": { + "$ref": "resource-schema.json#/properties/RestApiId" + } + }, + "required": [ + "RestApiId" + ] + }, + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment_plugin.py new file mode 100644 index 0000000000000..80ff9801a1ed5 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_deployment_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayDeploymentProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::Deployment" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_deployment import ( + ApiGatewayDeploymentProvider, + ) + + self.factory = ApiGatewayDeploymentProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname.py new file mode 100644 index 0000000000000..778ec9da3cbf8 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname.py @@ -0,0 +1,164 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.objects import keys_to_lower + + +class ApiGatewayDomainNameProperties(TypedDict): + CertificateArn: Optional[str] + DistributionDomainName: Optional[str] + DistributionHostedZoneId: Optional[str] + DomainName: Optional[str] + EndpointConfiguration: Optional[EndpointConfiguration] + MutualTlsAuthentication: Optional[MutualTlsAuthentication] + OwnershipVerificationCertificateArn: Optional[str] + RegionalCertificateArn: Optional[str] + RegionalDomainName: Optional[str] + RegionalHostedZoneId: Optional[str] + SecurityPolicy: Optional[str] + Tags: Optional[list[Tag]] + + +class EndpointConfiguration(TypedDict): + Types: Optional[list[str]] + + +class MutualTlsAuthentication(TypedDict): + TruststoreUri: Optional[str] + TruststoreVersion: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayDomainNameProvider(ResourceProvider[ApiGatewayDomainNameProperties]): + TYPE = "AWS::ApiGateway::DomainName" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayDomainNameProperties], + ) -> ProgressEvent[ApiGatewayDomainNameProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/DomainName + + Create-only properties: + - /properties/DomainName + + Read-only properties: + - /properties/RegionalHostedZoneId + - /properties/DistributionDomainName + - /properties/RegionalDomainName + - /properties/DistributionHostedZoneId + + IAM permissions required: + - apigateway:* + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + params = keys_to_lower(model.copy()) + param_names = [ + "certificateArn", + "domainName", + "endpointConfiguration", + "mutualTlsAuthentication", + "ownershipVerificationCertificateArn", + "regionalCertificateArn", + "securityPolicy", + ] + params = util.select_attributes(params, param_names) + if model.get("Tags"): + params["tags"] = {tag["key"]: tag["value"] for tag in model["Tags"]} + + result = apigw.create_domain_name(**params) + + hosted_zones = request.aws_client_factory.route53.list_hosted_zones() + """ + The hardcoded value is the only one that should be returned but due limitations it is not possible to + use it. + """ + if hosted_zones["HostedZones"]: + model["DistributionHostedZoneId"] = hosted_zones["HostedZones"][0]["Id"] + else: + model["DistributionHostedZoneId"] = "Z2FDTNDATAQYW2" + + model["DistributionDomainName"] = result.get("distributionDomainName") or result.get( + "domainName" + ) + model["RegionalDomainName"] = ( + result.get("regionalDomainName") or model["DistributionDomainName"] + ) + model["RegionalHostedZoneId"] = ( + result.get("regionalHostedZoneId") or model["DistributionHostedZoneId"] + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayDomainNameProperties], + ) -> ProgressEvent[ApiGatewayDomainNameProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:* + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayDomainNameProperties], + ) -> ProgressEvent[ApiGatewayDomainNameProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:* + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + apigw.delete_domain_name(domainName=model["DomainName"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayDomainNameProperties], + ) -> ProgressEvent[ApiGatewayDomainNameProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:* + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname.schema.json new file mode 100644 index 0000000000000..c0b50b24f2c33 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname.schema.json @@ -0,0 +1,124 @@ +{ + "typeName": "AWS::ApiGateway::DomainName", + "description": "Resource Type definition for AWS::ApiGateway::DomainName.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "EndpointConfiguration": { + "type": "object", + "properties": { + "Types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "MutualTlsAuthentication": { + "type": "object", + "properties": { + "TruststoreUri": { + "type": "string" + }, + "TruststoreVersion": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Tag": { + "type": "object", + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "properties": { + "DomainName": { + "type": "string" + }, + "DistributionDomainName": { + "type": "string" + }, + "DistributionHostedZoneId": { + "type": "string" + }, + "EndpointConfiguration": { + "$ref": "#/definitions/EndpointConfiguration" + }, + "MutualTlsAuthentication": { + "$ref": "#/definitions/MutualTlsAuthentication" + }, + "RegionalDomainName": { + "type": "string" + }, + "RegionalHostedZoneId": { + "type": "string" + }, + "CertificateArn": { + "type": "string" + }, + "RegionalCertificateArn": { + "type": "string" + }, + "OwnershipVerificationCertificateArn": { + "type": "string" + }, + "SecurityPolicy": { + "type": "string" + }, + "Tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "additionalProperties": false, + "primaryIdentifier": [ + "/properties/DomainName" + ], + "createOnlyProperties": [ + "/properties/DomainName" + ], + "readOnlyProperties": [ + "/properties/RegionalHostedZoneId", + "/properties/DistributionDomainName", + "/properties/RegionalDomainName", + "/properties/DistributionHostedZoneId" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:*" + ] + }, + "read": { + "permissions": [ + "apigateway:*" + ] + }, + "update": { + "permissions": [ + "apigateway:*" + ] + }, + "delete": { + "permissions": [ + "apigateway:*" + ] + }, + "list": { + "permissions": [ + "apigateway:*" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname_plugin.py new file mode 100644 index 0000000000000..49e6db22f12d8 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_domainname_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayDomainNameProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::DomainName" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_domainname import ( + ApiGatewayDomainNameProvider, + ) + + self.factory = ApiGatewayDomainNameProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse.py new file mode 100644 index 0000000000000..bb52d43256e7b --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse.py @@ -0,0 +1,122 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.objects import keys_to_lower + + +class ApiGatewayGatewayResponseProperties(TypedDict): + ResponseType: Optional[str] + RestApiId: Optional[str] + Id: Optional[str] + ResponseParameters: Optional[dict] + ResponseTemplates: Optional[dict] + StatusCode: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayGatewayResponseProvider(ResourceProvider[ApiGatewayGatewayResponseProperties]): + TYPE = "AWS::ApiGateway::GatewayResponse" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayGatewayResponseProperties], + ) -> ProgressEvent[ApiGatewayGatewayResponseProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - ResponseType + - RestApiId + + Create-only properties: + - /properties/ResponseType + - /properties/RestApiId + + Read-only properties: + - /properties/Id + + IAM permissions required: + - apigateway:PUT + - apigateway:GET + + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + # TODO: validations + model["Id"] = util.generate_default_name_without_stack(request.logical_resource_id) + + params = util.select_attributes( + model, + ["RestApiId", "ResponseType", "StatusCode", "ResponseParameters", "ResponseTemplates"], + ) + params = keys_to_lower(params.copy()) + + api.put_gateway_response(**params) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayGatewayResponseProperties], + ) -> ProgressEvent[ApiGatewayGatewayResponseProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayGatewayResponseProperties], + ) -> ProgressEvent[ApiGatewayGatewayResponseProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:GET + - apigateway:DELETE + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + + api.delete_gateway_response( + restApiId=model["RestApiId"], responseType=model["ResponseType"] + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayGatewayResponseProperties], + ) -> ProgressEvent[ApiGatewayGatewayResponseProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:PUT + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse.schema.json new file mode 100644 index 0000000000000..063b2c6c91ca4 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse.schema.json @@ -0,0 +1,84 @@ +{ + "typeName": "AWS::ApiGateway::GatewayResponse", + "description": "Resource Type definition for AWS::ApiGateway::GatewayResponse", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "additionalProperties": false, + "properties": { + "Id": { + "description": "A Cloudformation auto generated ID.", + "type": "string" + }, + "RestApiId": { + "description": "The identifier of the API.", + "type": "string" + }, + "ResponseType": { + "description": "The type of the Gateway Response.", + "type": "string" + }, + "StatusCode": { + "description": "The HTTP status code for the response.", + "type": "string" + }, + "ResponseParameters": { + "description": "The response parameters (paths, query strings, and headers) for the response.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "ResponseTemplates": { + "description": "The response templates for the response.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + } + }, + "required": [ + "ResponseType", + "RestApiId" + ], + "createOnlyProperties": [ + "/properties/ResponseType", + "/properties/RestApiId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "taggable": false, + "handlers": { + "create": { + "permissions": [ + "apigateway:PUT", + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:PUT" + ] + }, + "delete": { + "permissions": [ + "apigateway:GET", + "apigateway:DELETE" + ] + }, + "list": { + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse_plugin.py new file mode 100644 index 0000000000000..86f43d46cdd21 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_gatewayresponse_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayGatewayResponseProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::GatewayResponse" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_gatewayresponse import ( + ApiGatewayGatewayResponseProvider, + ) + + self.factory = ApiGatewayGatewayResponseProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method.py new file mode 100644 index 0000000000000..64598a4463898 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method.py @@ -0,0 +1,234 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ApiGatewayMethodProperties(TypedDict): + HttpMethod: Optional[str] + ResourceId: Optional[str] + RestApiId: Optional[str] + ApiKeyRequired: Optional[bool] + AuthorizationScopes: Optional[list[str]] + AuthorizationType: Optional[str] + AuthorizerId: Optional[str] + Integration: Optional[Integration] + MethodResponses: Optional[list[MethodResponse]] + OperationName: Optional[str] + RequestModels: Optional[dict] + RequestParameters: Optional[dict] + RequestValidatorId: Optional[str] + + +class IntegrationResponse(TypedDict): + StatusCode: Optional[str] + ContentHandling: Optional[str] + ResponseParameters: Optional[dict] + ResponseTemplates: Optional[dict] + SelectionPattern: Optional[str] + + +class Integration(TypedDict): + Type: Optional[str] + CacheKeyParameters: Optional[list[str]] + CacheNamespace: Optional[str] + ConnectionId: Optional[str] + ConnectionType: Optional[str] + ContentHandling: Optional[str] + Credentials: Optional[str] + IntegrationHttpMethod: Optional[str] + IntegrationResponses: Optional[list[IntegrationResponse]] + PassthroughBehavior: Optional[str] + RequestParameters: Optional[dict] + RequestTemplates: Optional[dict] + TimeoutInMillis: Optional[int] + Uri: Optional[str] + + +class MethodResponse(TypedDict): + StatusCode: Optional[str] + ResponseModels: Optional[dict] + ResponseParameters: Optional[dict] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayMethodProvider(ResourceProvider[ApiGatewayMethodProperties]): + TYPE = "AWS::ApiGateway::Method" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayMethodProperties], + ) -> ProgressEvent[ApiGatewayMethodProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RestApiId + - /properties/ResourceId + - /properties/HttpMethod + + Required properties: + - RestApiId + - ResourceId + - HttpMethod + + Create-only properties: + - /properties/RestApiId + - /properties/ResourceId + - /properties/HttpMethod + + + + IAM permissions required: + - apigateway:PUT + - apigateway:GET + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + operation_model = apigw.meta.service_model.operation_model + + apigw.put_method( + **util.convert_request_kwargs(model, operation_model("PutMethod").input_shape) + ) + + # setting up integrations + integration = model.get("Integration") + if integration: + apigw.put_integration( + restApiId=model.get("RestApiId"), + resourceId=model.get("ResourceId"), + httpMethod=model.get("HttpMethod"), + **util.convert_request_kwargs( + integration, operation_model("PutIntegration").input_shape + ), + ) + + integration_responses = integration.pop("IntegrationResponses", []) + for integration_response in integration_responses: + apigw.put_integration_response( + restApiId=model.get("RestApiId"), + resourceId=model.get("ResourceId"), + httpMethod=model.get("HttpMethod"), + **util.convert_request_kwargs( + integration_response, operation_model("PutIntegrationResponse").input_shape + ), + ) + + responses = model.get("MethodResponses", []) + for response in responses: + apigw.put_method_response( + restApiId=model.get("RestApiId"), + resourceId=model.get("ResourceId"), + httpMethod=model.get("HttpMethod"), + **util.convert_request_kwargs( + response, operation_model("PutMethodResponse").input_shape + ), + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayMethodProperties], + ) -> ProgressEvent[ApiGatewayMethodProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayMethodProperties], + ) -> ProgressEvent[ApiGatewayMethodProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + + # FIXME we sometimes get warnings when calling this method, probably because + # restAPI or resource has been already deleted + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + try: + apigw.delete_method( + **util.convert_request_kwargs( + model, apigw.meta.service_model.operation_model("DeleteMethod").input_shape + ) + ) + except apigw.exceptions.NotFoundException: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayMethodProperties], + ) -> ProgressEvent[ApiGatewayMethodProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:DELETE + - apigateway:PUT + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + operation_model = apigw.meta.service_model.operation_model + + must_params = util.select_attributes( + model, + [ + "RestApiId", + "ResourceId", + "HttpMethod", + ], + ) + + if integration := deepcopy(model.get("Integration")): + integration.update(must_params) + apigw.put_integration( + **util.convert_request_kwargs( + integration, operation_model("PutIntegration").input_shape + ) + ) + + else: + must_params.update({"AuthorizationType": model.get("AuthorizationType")}) + apigw.put_method( + **util.convert_request_kwargs(must_params, operation_model("PutMethod").input_shape) + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method.schema.json new file mode 100644 index 0000000000000..1b64f208e9c6d --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method.schema.json @@ -0,0 +1,318 @@ +{ + "typeName": "AWS::ApiGateway::Method", + "description": "Resource Type definition for AWS::ApiGateway::Method", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway.git", + "definitions": { + "Integration": { + "type": "object", + "additionalProperties": false, + "properties": { + "CacheKeyParameters": { + "description": "A list of request parameters whose values API Gateway caches.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "CacheNamespace": { + "description": "An API-specific tag group of related cached parameters.", + "type": "string" + }, + "ConnectionId": { + "description": "The ID of the VpcLink used for the integration when connectionType=VPC_LINK, otherwise undefined.", + "type": "string" + }, + "ConnectionType": { + "description": "The type of the network connection to the integration endpoint.", + "type": "string", + "enum": [ + "INTERNET", + "VPC_LINK" + ] + }, + "ContentHandling": { + "description": "Specifies how to handle request payload content type conversions.", + "type": "string", + "enum": [ + "CONVERT_TO_BINARY", + "CONVERT_TO_TEXT" + ] + }, + "Credentials": { + "description": "The credentials that are required for the integration.", + "type": "string" + }, + "IntegrationHttpMethod": { + "description": "The integration's HTTP method type.", + "type": "string" + }, + "IntegrationResponses": { + "description": "The response that API Gateway provides after a method's backend completes processing a request.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/IntegrationResponse" + } + }, + "PassthroughBehavior": { + "description": "Indicates when API Gateway passes requests to the targeted backend.", + "type": "string", + "enum": [ + "WHEN_NO_MATCH", + "WHEN_NO_TEMPLATES", + "NEVER" + ] + }, + "RequestParameters": { + "description": "The request parameters that API Gateway sends with the backend request.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "RequestTemplates": { + "description": "A map of Apache Velocity templates that are applied on the request payload.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "TimeoutInMillis": { + "description": "Custom timeout between 50 and 29,000 milliseconds.", + "type": "integer", + "minimum": 50, + "maximum": 29000 + }, + "Type": { + "description": "The type of backend that your method is running.", + "type": "string", + "enum": [ + "AWS", + "AWS_PROXY", + "HTTP", + "HTTP_PROXY", + "MOCK" + ] + }, + "Uri": { + "description": "The Uniform Resource Identifier (URI) for the integration.", + "type": "string" + } + }, + "required": [ + "Type" + ] + }, + "MethodResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "ResponseModels": { + "description": "The resources used for the response's content type. Specify response models as key-value pairs (string-to-string maps), with a content type as the key and a Model resource name as the value.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "ResponseParameters": { + "description": "Response parameters that API Gateway sends to the client that called a method. Specify response parameters as key-value pairs (string-to-Boolean maps), with a destination as the key and a Boolean as the value.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "boolean" + } + } + }, + "StatusCode": { + "description": "The method response's status code, which you map to an IntegrationResponse.", + "type": "string" + } + }, + "required": [ + "StatusCode" + ] + }, + "IntegrationResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "ContentHandling": { + "description": "Specifies how to handle request payload content type conversions.", + "type": "string", + "enum": [ + "CONVERT_TO_BINARY", + "CONVERT_TO_TEXT" + ] + }, + "ResponseParameters": { + "description": "The response parameters from the backend response that API Gateway sends to the method response.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "ResponseTemplates": { + "description": "The templates that are used to transform the integration response body. Specify templates as key-value pairs (string-to-string mappings), with a content type as the key and a template as the value.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "SelectionPattern": { + "description": "A regular expression that specifies which error strings or status codes from the backend map to the integration response.", + "type": "string" + }, + "StatusCode": { + "description": "The status code that API Gateway uses to map the integration response to a MethodResponse status code.", + "type": "string" + } + }, + "required": [ + "StatusCode" + ] + } + }, + "properties": { + "ApiKeyRequired": { + "description": "Indicates whether the method requires clients to submit a valid API key.", + "type": "boolean" + }, + "AuthorizationScopes": { + "description": "A list of authorization scopes configured on the method.", + "type": "array", + "items": { + "type": "string" + } + }, + "AuthorizationType": { + "description": "The method's authorization type.", + "type": "string", + "enum": [ + "NONE", + "AWS_IAM", + "CUSTOM", + "COGNITO_USER_POOLS" + ] + }, + "AuthorizerId": { + "description": "The identifier of the authorizer to use on this method.", + "type": "string" + }, + "HttpMethod": { + "description": "The backend system that the method calls when it receives a request.", + "type": "string" + }, + "Integration": { + "description": "The backend system that the method calls when it receives a request.", + "$ref": "#/definitions/Integration" + }, + "MethodResponses": { + "description": "The responses that can be sent to the client who calls the method.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/MethodResponse" + } + }, + "OperationName": { + "description": "A friendly operation name for the method.", + "type": "string" + }, + "RequestModels": { + "description": "The resources that are used for the request's content type. Specify request models as key-value pairs (string-to-string mapping), with a content type as the key and a Model resource name as the value.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "RequestParameters": { + "description": "The request parameters that API Gateway accepts. Specify request parameters as key-value pairs (string-to-Boolean mapping), with a source as the key and a Boolean as the value.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "boolean" + } + } + }, + "RequestValidatorId": { + "description": "The ID of the associated request validator.", + "type": "string" + }, + "ResourceId": { + "description": "The ID of an API Gateway resource.", + "type": "string" + }, + "RestApiId": { + "description": "The ID of the RestApi resource in which API Gateway creates the method.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "RestApiId", + "ResourceId", + "HttpMethod" + ], + "primaryIdentifier": [ + "/properties/RestApiId", + "/properties/ResourceId", + "/properties/HttpMethod" + ], + "createOnlyProperties": [ + "/properties/RestApiId", + "/properties/ResourceId", + "/properties/HttpMethod" + ], + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "handlers": { + "create": { + "permissions": [ + "apigateway:PUT", + "apigateway:GET" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:DELETE", + "apigateway:PUT" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method_plugin.py new file mode 100644 index 0000000000000..34e0cec7971a9 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_method_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayMethodProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::Method" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_method import ( + ApiGatewayMethodProvider, + ) + + self.factory = ApiGatewayMethodProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model.py new file mode 100644 index 0000000000000..07883e62983ca --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model.py @@ -0,0 +1,134 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ApiGatewayModelProperties(TypedDict): + RestApiId: Optional[str] + ContentType: Optional[str] + Description: Optional[str] + Name: Optional[str] + Schema: Optional[dict | str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayModelProvider(ResourceProvider[ApiGatewayModelProperties]): + TYPE = "AWS::ApiGateway::Model" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayModelProperties], + ) -> ProgressEvent[ApiGatewayModelProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RestApiId + - /properties/Name + + Required properties: + - RestApiId + + Create-only properties: + - /properties/ContentType + - /properties/Name + - /properties/RestApiId + + + + IAM permissions required: + - apigateway:POST + - apigateway:GET + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + if not model.get("Name"): + model["Name"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + if not model.get("ContentType"): + model["ContentType"] = "application/json" + + schema = json.dumps(model.get("Schema", {})) + + apigw.create_model( + restApiId=model["RestApiId"], + name=model["Name"], + contentType=model["ContentType"], + schema=schema, + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayModelProperties], + ) -> ProgressEvent[ApiGatewayModelProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayModelProperties], + ) -> ProgressEvent[ApiGatewayModelProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:GET + - apigateway:DELETE + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + try: + apigw.delete_model(modelName=model["Name"], restApiId=model["RestApiId"]) + except apigw.exceptions.NotFoundException: + # We are using try/except since at the moment + # CFN doesn't properly resolve dependency between resources + # so this resource could be deleted if parent resource was deleted first + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayModelProperties], + ) -> ProgressEvent[ApiGatewayModelProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:PATCH + - apigateway:GET + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model.schema.json new file mode 100644 index 0000000000000..7196fd5cc44b0 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model.schema.json @@ -0,0 +1,83 @@ +{ + "typeName": "AWS::ApiGateway::Model", + "description": "Resource Type definition for AWS::ApiGateway::Model", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "ContentType": { + "type": "string", + "description": "The content type for the model." + }, + "Description": { + "type": "string", + "description": "A description that identifies this model." + }, + "Name": { + "type": "string", + "description": "A name for the model. If you don't specify a name, AWS CloudFormation generates a unique physical ID and uses that ID for the model name." + }, + "RestApiId": { + "type": "string", + "description": "The ID of a REST API with which to associate this model." + }, + "Schema": { + "description": "The schema to use to transform data to one or more output formats. Specify null ({}) if you don't want to specify a schema.", + "type": [ + "object", + "string" + ] + } + }, + "required": [ + "RestApiId" + ], + "createOnlyProperties": [ + "/properties/ContentType", + "/properties/Name", + "/properties/RestApiId" + ], + "primaryIdentifier": [ + "/properties/RestApiId", + "/properties/Name" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:POST", + "apigateway:GET" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:PATCH", + "apigateway:GET" + ] + }, + "delete": { + "permissions": [ + "apigateway:GET", + "apigateway:DELETE" + ] + }, + "list": { + "handlerSchema": { + "properties": { + "RestApiId": { + "$ref": "resource-schema.json#/properties/RestApiId" + } + }, + "required": [ + "RestApiId" + ] + }, + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model_plugin.py new file mode 100644 index 0000000000000..d1bd727b602e5 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_model_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayModelProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::Model" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_model import ( + ApiGatewayModelProvider, + ) + + self.factory = ApiGatewayModelProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator.py new file mode 100644 index 0000000000000..55d2a3bc4964e --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator.py @@ -0,0 +1,125 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ApiGatewayRequestValidatorProperties(TypedDict): + RestApiId: Optional[str] + Name: Optional[str] + RequestValidatorId: Optional[str] + ValidateRequestBody: Optional[bool] + ValidateRequestParameters: Optional[bool] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayRequestValidatorProvider(ResourceProvider[ApiGatewayRequestValidatorProperties]): + TYPE = "AWS::ApiGateway::RequestValidator" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayRequestValidatorProperties], + ) -> ProgressEvent[ApiGatewayRequestValidatorProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RestApiId + - /properties/RequestValidatorId + + Required properties: + - RestApiId + + Create-only properties: + - /properties/Name + - /properties/RestApiId + + Read-only properties: + - /properties/RequestValidatorId + + IAM permissions required: + - apigateway:POST + - apigateway:GET + + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + + if not model.get("Name"): + model["Name"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + response = api.create_request_validator( + name=model["Name"], + restApiId=model["RestApiId"], + validateRequestBody=model.get("ValidateRequestBody", False), + validateRequestParameters=model.get("ValidateRequestParameters", False), + ) + model["RequestValidatorId"] = response["id"] + # FIXME error happens when other resources try to reference this one + # "An error occurred (BadRequestException) when calling the PutMethod operation: + # Invalid Request Validator identifier specified" + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayRequestValidatorProperties], + ) -> ProgressEvent[ApiGatewayRequestValidatorProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayRequestValidatorProperties], + ) -> ProgressEvent[ApiGatewayRequestValidatorProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + + api.delete_request_validator( + restApiId=model["RestApiId"], requestValidatorId=model["RequestValidatorId"] + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayRequestValidatorProperties], + ) -> ProgressEvent[ApiGatewayRequestValidatorProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:PATCH + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator.schema.json new file mode 100644 index 0000000000000..39d00e7be7d6d --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator.schema.json @@ -0,0 +1,80 @@ +{ + "typeName": "AWS::ApiGateway::RequestValidator", + "description": "Resource Type definition for AWS::ApiGateway::RequestValidator", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "RequestValidatorId": { + "description": "ID of the request validator.", + "type": "string" + }, + "Name": { + "description": "Name of the request validator.", + "type": "string" + }, + "RestApiId": { + "description": "The identifier of the targeted API entity.", + "type": "string" + }, + "ValidateRequestBody": { + "description": "Indicates whether to validate the request body according to the configured schema for the targeted API and method. ", + "type": "boolean" + }, + "ValidateRequestParameters": { + "description": "Indicates whether to validate request parameters.", + "type": "boolean" + } + }, + "required": [ + "RestApiId" + ], + "createOnlyProperties": [ + "/properties/Name", + "/properties/RestApiId" + ], + "readOnlyProperties": [ + "/properties/RequestValidatorId" + ], + "primaryIdentifier": [ + "/properties/RestApiId", + "/properties/RequestValidatorId" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:POST", + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:PATCH" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "list": { + "handlerSchema": { + "properties": { + "RestApiId": { + "$ref": "resource-schema.json#/properties/RestApiId" + } + }, + "required": [ + "RestApiId" + ] + }, + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator_plugin.py new file mode 100644 index 0000000000000..41175341a69de --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_requestvalidator_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayRequestValidatorProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::RequestValidator" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_requestvalidator import ( + ApiGatewayRequestValidatorProvider, + ) + + self.factory = ApiGatewayRequestValidatorProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource.py new file mode 100644 index 0000000000000..89b868306e68d --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource.py @@ -0,0 +1,168 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +from botocore.exceptions import ClientError + +import localstack.services.cloudformation.provider_utils as util +from localstack.aws.api.cloudcontrol import InvalidRequestException, ResourceNotFoundException +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ApiGatewayResourceProperties(TypedDict): + ParentId: Optional[str] + PathPart: Optional[str] + RestApiId: Optional[str] + ResourceId: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayResourceProvider(ResourceProvider[ApiGatewayResourceProperties]): + TYPE = "AWS::ApiGateway::Resource" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayResourceProperties], + ) -> ProgressEvent[ApiGatewayResourceProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RestApiId + - /properties/ResourceId + + Required properties: + - ParentId + - PathPart + - RestApiId + + Create-only properties: + - /properties/PathPart + - /properties/ParentId + - /properties/RestApiId + + Read-only properties: + - /properties/ResourceId + + IAM permissions required: + - apigateway:POST + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + params = { + "restApiId": model.get("RestApiId"), + "pathPart": model.get("PathPart"), + "parentId": model.get("ParentId"), + } + if not params.get("parentId"): + # get root resource id + resources = apigw.get_resources(restApiId=params["restApiId"])["items"] + root_resource = ([r for r in resources if r["path"] == "/"] or [None])[0] + if not root_resource: + raise Exception( + "Unable to find root resource for REST API %s" % params["restApiId"] + ) + params["parentId"] = root_resource["id"] + response = apigw.create_resource(**params) + + model["ResourceId"] = response["id"] + model["ParentId"] = response["parentId"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayResourceProperties], + ) -> ProgressEvent[ApiGatewayResourceProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[ApiGatewayResourceProperties], + ) -> ProgressEvent[ApiGatewayResourceProperties]: + if "RestApiId" not in request.desired_state: + # TODO: parity + raise InvalidRequestException( + f"Missing or invalid ResourceModel property in {self.TYPE} list handler request input: 'RestApiId'" + ) + + rest_api_id = request.desired_state["RestApiId"] + try: + resources = request.aws_client_factory.apigateway.get_resources(restApiId=rest_api_id)[ + "items" + ] + except ClientError as exc: + if exc.response.get("Error", {}).get("Code", {}) == "NotFoundException": + raise ResourceNotFoundException(f"Invalid API identifier specified: {rest_api_id}") + raise + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + ApiGatewayResourceProperties( + RestApiId=rest_api_id, + ResourceId=resource["id"], + ParentId=resource.get("parentId"), + PathPart=resource.get("path"), + ) + for resource in resources + ], + ) + + def delete( + self, + request: ResourceRequest[ApiGatewayResourceProperties], + ) -> ProgressEvent[ApiGatewayResourceProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + try: + apigw.delete_resource(restApiId=model["RestApiId"], resourceId=model["ResourceId"]) + except apigw.exceptions.NotFoundException: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayResourceProperties], + ) -> ProgressEvent[ApiGatewayResourceProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:PATCH + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource.schema.json new file mode 100644 index 0000000000000..7eaa8175b1827 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource.schema.json @@ -0,0 +1,80 @@ +{ + "typeName": "AWS::ApiGateway::Resource", + "description": "Resource Type definition for AWS::ApiGateway::Resource", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "ResourceId": { + "description": "A unique primary identifier for a Resource", + "type": "string" + }, + "RestApiId": { + "description": "The ID of the RestApi resource in which you want to create this resource..", + "type": "string" + }, + "ParentId": { + "description": "The parent resource's identifier.", + "type": "string" + }, + "PathPart": { + "description": "The last path segment for this resource.", + "type": "string" + } + }, + "taggable": false, + "required": [ + "ParentId", + "PathPart", + "RestApiId" + ], + "createOnlyProperties": [ + "/properties/PathPart", + "/properties/ParentId", + "/properties/RestApiId" + ], + "primaryIdentifier": [ + "/properties/RestApiId", + "/properties/ResourceId" + ], + "readOnlyProperties": [ + "/properties/ResourceId" + ], + "handlers": { + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "create": { + "permissions": [ + "apigateway:POST" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:PATCH" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "list": { + "handlerSchema": { + "properties": { + "RestApiId": { + "$ref": "resource-schema.json#/properties/RestApiId" + } + }, + "required": [ + "RestApiId" + ] + }, + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource_plugin.py new file mode 100644 index 0000000000000..f7ece7204435d --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_resource_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayResourceProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::Resource" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_resource import ( + ApiGatewayResourceProvider, + ) + + self.factory = ApiGatewayResourceProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi.py new file mode 100644 index 0000000000000..c90e2b36f328b --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi.py @@ -0,0 +1,245 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.objects import keys_to_lower +from localstack.utils.strings import to_bytes + + +class ApiGatewayRestApiProperties(TypedDict): + ApiKeySourceType: Optional[str] + BinaryMediaTypes: Optional[list[str]] + Body: Optional[dict | str] + BodyS3Location: Optional[S3Location] + CloneFrom: Optional[str] + Description: Optional[str] + DisableExecuteApiEndpoint: Optional[bool] + EndpointConfiguration: Optional[EndpointConfiguration] + FailOnWarnings: Optional[bool] + MinimumCompressionSize: Optional[int] + Mode: Optional[str] + Name: Optional[str] + Parameters: Optional[dict | str] + Policy: Optional[dict | str] + RestApiId: Optional[str] + RootResourceId: Optional[str] + Tags: Optional[list[Tag]] + + +class S3Location(TypedDict): + Bucket: Optional[str] + ETag: Optional[str] + Key: Optional[str] + Version: Optional[str] + + +class EndpointConfiguration(TypedDict): + Types: Optional[list[str]] + VpcEndpointIds: Optional[list[str]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayRestApiProvider(ResourceProvider[ApiGatewayRestApiProperties]): + TYPE = "AWS::ApiGateway::RestApi" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayRestApiProperties], + ) -> ProgressEvent[ApiGatewayRestApiProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RestApiId + + + Read-only properties: + - /properties/RestApiId + - /properties/RootResourceId + + IAM permissions required: + - apigateway:GET + - apigateway:POST + - apigateway:UpdateRestApiPolicy + - s3:GetObject + - iam:PassRole + + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + + # FIXME: this is only when Body or BodyS3Location is set, otherwise the deployment should fail without a name + role_name = model.get("Name") + if not role_name: + model["Name"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + params = util.select_attributes( + model, + [ + "Name", + "Description", + "Version", + "CloneFrom", + "BinaryMediaTypes", + "MinimumCompressionSize", + "ApiKeySource", + "EndpointConfiguration", + "Policy", + "Tags", + "DisableExecuteApiEndpoint", + ], + ) + params = keys_to_lower(params, skip_children_of=["policy"]) + params["tags"] = {tag["key"]: tag["value"] for tag in params.get("tags", [])} + + cfn_client = request.aws_client_factory.cloudformation + stack_id = cfn_client.describe_stacks(StackName=request.stack_name)["Stacks"][0]["StackId"] + params["tags"].update( + { + "aws:cloudformation:logical-id": request.logical_resource_id, + "aws:cloudformation:stack-name": request.stack_name, + "aws:cloudformation:stack-id": stack_id, + } + ) + if isinstance(params.get("policy"), dict): + params["policy"] = json.dumps(params["policy"]) + + result = api.create_rest_api(**params) + model["RestApiId"] = result["id"] + + body = model.get("Body") + s3_body_location = model.get("BodyS3Location") + if body or s3_body_location: + # the default behavior for imports via CFn is basepath=ignore (validated against AWS) + import_parameters = model.get("Parameters", {}) + import_parameters.setdefault("basepath", "ignore") + + if body: + body = json.dumps(body) if isinstance(body, dict) else body + else: + get_obj_kwargs = {} + if version_id := s3_body_location.get("Version"): + get_obj_kwargs["VersionId"] = version_id + + # what is the approach when client call fail? Do we bubble it up? + s3_client = request.aws_client_factory.s3 + get_obj_req = s3_client.get_object( + Bucket=s3_body_location.get("Bucket"), + Key=s3_body_location.get("Key"), + **get_obj_kwargs, + ) + if etag := s3_body_location.get("ETag"): + if etag != get_obj_req["ETag"]: + # TODO: validate the exception message + raise Exception( + "The ETag provided for the S3BodyLocation does not match the S3 Object" + ) + body = get_obj_req["Body"].read() + + put_kwargs = {} + if import_mode := model.get("Mode"): + put_kwargs["mode"] = import_mode + if fail_on_warnings_mode := model.get("FailOnWarnings"): + put_kwargs["failOnWarnings"] = fail_on_warnings_mode + + api.put_rest_api( + restApiId=result["id"], + body=to_bytes(body), + parameters=import_parameters, + **put_kwargs, + ) + + resources = api.get_resources(restApiId=result["id"])["items"] + for res in resources: + if res["path"] == "/" and not res.get("parentId"): + model["RootResourceId"] = res["id"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayRestApiProperties], + ) -> ProgressEvent[ApiGatewayRestApiProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[ApiGatewayRestApiProperties], + ) -> ProgressEvent[ApiGatewayRestApiProperties]: + # TODO: pagination + resources = request.aws_client_factory.apigateway.get_rest_apis()["items"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + ApiGatewayRestApiProperties(RestApiId=resource["id"], Name=resource["name"]) + for resource in resources + ], + ) + + def delete( + self, + request: ResourceRequest[ApiGatewayRestApiProperties], + ) -> ProgressEvent[ApiGatewayRestApiProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + api = request.aws_client_factory.apigateway + + api.delete_rest_api(restApiId=model["RestApiId"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayRestApiProperties], + ) -> ProgressEvent[ApiGatewayRestApiProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:DELETE + - apigateway:PATCH + - apigateway:PUT + - apigateway:UpdateRestApiPolicy + - s3:GetObject + - iam:PassRole + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi.schema.json new file mode 100644 index 0000000000000..73e6f5dda9447 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi.schema.json @@ -0,0 +1,197 @@ +{ + "typeName": "AWS::ApiGateway::RestApi", + "description": "Resource Type definition for AWS::ApiGateway::RestApi.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "additionalProperties": false, + "definitions": { + "EndpointConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Types": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "VpcEndpointIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Key", + "Value" + ] + }, + "S3Location": { + "type": "object", + "additionalProperties": false, + "properties": { + "Bucket": { + "type": "string" + }, + "ETag": { + "type": "string" + }, + "Version": { + "type": "string" + }, + "Key": { + "type": "string" + } + } + } + }, + "properties": { + "RestApiId": { + "type": "string" + }, + "RootResourceId": { + "type": "string" + }, + "ApiKeySourceType": { + "type": "string" + }, + "BinaryMediaTypes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Body": { + "type": [ + "object", + "string" + ] + }, + "BodyS3Location": { + "$ref": "#/definitions/S3Location" + }, + "CloneFrom": { + "type": "string" + }, + "EndpointConfiguration": { + "$ref": "#/definitions/EndpointConfiguration" + }, + "Description": { + "type": "string" + }, + "DisableExecuteApiEndpoint": { + "type": "boolean" + }, + "FailOnWarnings": { + "type": "boolean" + }, + "Name": { + "type": "string" + }, + "MinimumCompressionSize": { + "type": "integer" + }, + "Mode": { + "type": "string" + }, + "Policy": { + "type": [ + "object", + "string" + ] + }, + "Parameters": { + "type": [ + "object", + "string" + ], + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "primaryIdentifier": [ + "/properties/RestApiId" + ], + "readOnlyProperties": [ + "/properties/RestApiId", + "/properties/RootResourceId" + ], + "writeOnlyProperties": [ + "/properties/Body", + "/properties/BodyS3Location", + "/properties/CloneFrom", + "/properties/FailOnWarnings", + "/properties/Mode", + "/properties/Parameters" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:GET", + "apigateway:POST", + "apigateway:UpdateRestApiPolicy", + "s3:GetObject", + "iam:PassRole" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:DELETE", + "apigateway:PATCH", + "apigateway:PUT", + "apigateway:UpdateRestApiPolicy", + "s3:GetObject", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "list": { + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi_plugin.py new file mode 100644 index 0000000000000..e53c4a4d8205f --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_restapi_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayRestApiProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::RestApi" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_restapi import ( + ApiGatewayRestApiProvider, + ) + + self.factory = ApiGatewayRestApiProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage.py new file mode 100644 index 0000000000000..b2b98bc715455 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage.py @@ -0,0 +1,183 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.objects import keys_to_lower + + +class ApiGatewayStageProperties(TypedDict): + RestApiId: Optional[str] + AccessLogSetting: Optional[AccessLogSetting] + CacheClusterEnabled: Optional[bool] + CacheClusterSize: Optional[str] + CanarySetting: Optional[CanarySetting] + ClientCertificateId: Optional[str] + DeploymentId: Optional[str] + Description: Optional[str] + DocumentationVersion: Optional[str] + MethodSettings: Optional[list[MethodSetting]] + StageName: Optional[str] + Tags: Optional[list[Tag]] + TracingEnabled: Optional[bool] + Variables: Optional[dict] + + +class AccessLogSetting(TypedDict): + DestinationArn: Optional[str] + Format: Optional[str] + + +class CanarySetting(TypedDict): + DeploymentId: Optional[str] + PercentTraffic: Optional[float] + StageVariableOverrides: Optional[dict] + UseStageCache: Optional[bool] + + +class MethodSetting(TypedDict): + CacheDataEncrypted: Optional[bool] + CacheTtlInSeconds: Optional[int] + CachingEnabled: Optional[bool] + DataTraceEnabled: Optional[bool] + HttpMethod: Optional[str] + LoggingLevel: Optional[str] + MetricsEnabled: Optional[bool] + ResourcePath: Optional[str] + ThrottlingBurstLimit: Optional[int] + ThrottlingRateLimit: Optional[float] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayStageProvider(ResourceProvider[ApiGatewayStageProperties]): + TYPE = "AWS::ApiGateway::Stage" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayStageProperties], + ) -> ProgressEvent[ApiGatewayStageProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RestApiId + - /properties/StageName + + Required properties: + - RestApiId + + Create-only properties: + - /properties/RestApiId + - /properties/StageName + + + + IAM permissions required: + - apigateway:POST + - apigateway:PATCH + - apigateway:GET + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + stage_name = model.get("StageName", "default") + stage_variables = model.get("Variables") + # we need to deep copy as several fields are nested dict and arrays + params = keys_to_lower(copy.deepcopy(model)) + # TODO: add methodSettings + # TODO: add custom CfN tags + param_names = [ + "restApiId", + "deploymentId", + "description", + "cacheClusterEnabled", + "cacheClusterSize", + "documentationVersion", + "canarySettings", + "tracingEnabled", + "tags", + ] + params = util.select_attributes(params, param_names) + params["tags"] = {t["key"]: t["value"] for t in params.get("tags", [])} + params["stageName"] = stage_name + if stage_variables: + params["variables"] = stage_variables + + result = apigw.create_stage(**params) + model["StageName"] = result["stageName"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayStageProperties], + ) -> ProgressEvent[ApiGatewayStageProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayStageProperties], + ) -> ProgressEvent[ApiGatewayStageProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + try: + # we are checking if stage api has already been deleted before calling delete + apigw.get_stage(restApiId=model["RestApiId"], stageName=model["StageName"]) + apigw.delete_stage(restApiId=model["RestApiId"], stageName=model["StageName"]) + except apigw.exceptions.NotFoundException: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayStageProperties], + ) -> ProgressEvent[ApiGatewayStageProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:PATCH + - apigateway:PUT + - apigateway:DELETE + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage.schema.json new file mode 100644 index 0000000000000..fe67c2c0c626f --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage.schema.json @@ -0,0 +1,260 @@ +{ + "typeName": "AWS::ApiGateway::Stage", + "description": "Resource Type definition for AWS::ApiGateway::Stage", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "AccessLogSetting": { + "description": "Specifies settings for logging access in this stage.", + "$ref": "#/definitions/AccessLogSetting" + }, + "CacheClusterEnabled": { + "description": "Indicates whether cache clustering is enabled for the stage.", + "type": "boolean" + }, + "CacheClusterSize": { + "description": "The stage's cache cluster size.", + "type": "string" + }, + "CanarySetting": { + "description": "Specifies settings for the canary deployment in this stage.", + "$ref": "#/definitions/CanarySetting" + }, + "ClientCertificateId": { + "description": "The ID of the client certificate that API Gateway uses to call your integration endpoints in the stage. ", + "type": "string" + }, + "DeploymentId": { + "description": "The ID of the deployment that the stage is associated with. This parameter is required to create a stage. ", + "type": "string" + }, + "Description": { + "description": "A description of the stage.", + "type": "string" + }, + "DocumentationVersion": { + "description": "The version ID of the API documentation snapshot.", + "type": "string" + }, + "MethodSettings": { + "description": "Settings for all methods in the stage.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/MethodSetting" + } + }, + "RestApiId": { + "description": "The ID of the RestApi resource that you're deploying with this stage.", + "type": "string" + }, + "StageName": { + "description": "The name of the stage, which API Gateway uses as the first path segment in the invoked Uniform Resource Identifier (URI).", + "type": "string" + }, + "Tags": { + "description": "An array of arbitrary tags (key-value pairs) to associate with the stage.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "TracingEnabled": { + "description": "Specifies whether active X-Ray tracing is enabled for this stage.", + "type": "boolean" + }, + "Variables": { + "description": "A map (string-to-string map) that defines the stage variables, where the variable name is the key and the variable value is the value.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + } + }, + "definitions": { + "CanarySetting": { + "description": "Specifies settings for the canary deployment in this stage.", + "type": "object", + "additionalProperties": false, + "properties": { + "DeploymentId": { + "description": "The identifier of the deployment that the stage points to.", + "type": "string" + }, + "PercentTraffic": { + "description": "The percentage (0-100) of traffic diverted to a canary deployment.", + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "StageVariableOverrides": { + "description": "Stage variables overridden for a canary release deployment, including new stage variables introduced in the canary. These stage variables are represented as a string-to-string map between stage variable names and their values.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "UseStageCache": { + "description": "Whether the canary deployment uses the stage cache or not.", + "type": "boolean" + } + } + }, + "AccessLogSetting": { + "description": "Specifies settings for logging access in this stage.", + "type": "object", + "additionalProperties": false, + "properties": { + "DestinationArn": { + "description": "The Amazon Resource Name (ARN) of the CloudWatch Logs log group or Kinesis Data Firehose delivery stream to receive access logs. If you specify a Kinesis Data Firehose delivery stream, the stream name must begin with amazon-apigateway-. This parameter is required to enable access logging.", + "type": "string" + }, + "Format": { + "description": "A single line format of the access logs of data, as specified by selected $context variables (https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#context-variable-reference). The format must include at least $context.requestId. This parameter is required to enable access logging.", + "type": "string" + } + } + }, + "MethodSetting": { + "description": "Configures settings for all methods in a stage.", + "type": "object", + "additionalProperties": false, + "properties": { + "CacheDataEncrypted": { + "description": "Indicates whether the cached responses are encrypted.", + "type": "boolean" + }, + "CacheTtlInSeconds": { + "description": "The time-to-live (TTL) period, in seconds, that specifies how long API Gateway caches responses.", + "type": "integer" + }, + "CachingEnabled": { + "description": "Indicates whether responses are cached and returned for requests. You must enable a cache cluster on the stage to cache responses.", + "type": "boolean" + }, + "DataTraceEnabled": { + "description": "Indicates whether data trace logging is enabled for methods in the stage. API Gateway pushes these logs to Amazon CloudWatch Logs.", + "type": "boolean" + }, + "HttpMethod": { + "description": "The HTTP method. You can use an asterisk (*) as a wildcard to apply method settings to multiple methods.", + "type": "string" + }, + "LoggingLevel": { + "description": "The logging level for this method. For valid values, see the loggingLevel property of the Stage (https://docs.aws.amazon.com/apigateway/api-reference/resource/stage/#loggingLevel) resource in the Amazon API Gateway API Reference.", + "type": "string" + }, + "MetricsEnabled": { + "description": "Indicates whether Amazon CloudWatch metrics are enabled for methods in the stage.", + "type": "boolean" + }, + "ResourcePath": { + "description": "The resource path for this method. Forward slashes (/) are encoded as ~1 and the initial slash must include a forward slash. For example, the path value /resource/subresource must be encoded as /~1resource~1subresource. To specify the root path, use only a slash (/). You can use an asterisk (*) as a wildcard to apply method settings to multiple methods.", + "type": "string" + }, + "ThrottlingBurstLimit": { + "description": "The number of burst requests per second that API Gateway permits across all APIs, stages, and methods in your AWS account.", + "type": "integer", + "minimum": 0 + }, + "ThrottlingRateLimit": { + "description": "The number of steady-state requests per second that API Gateway permits across all APIs, stages, and methods in your AWS account.", + "type": "number", + "minimum": 0 + } + } + }, + "Tag": { + "description": "Identify and categorize resources.", + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:.", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:.", + "type": "string", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, + "required": [ + "RestApiId" + ], + "createOnlyProperties": [ + "/properties/RestApiId", + "/properties/StageName" + ], + "primaryIdentifier": [ + "/properties/RestApiId", + "/properties/StageName" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "apigateway:POST", + "apigateway:PATCH", + "apigateway:GET" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:PATCH", + "apigateway:PUT", + "apigateway:DELETE" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "list": { + "handlerSchema": { + "properties": { + "RestApiId": { + "$ref": "resource-schema.json#/properties/RestApiId" + } + }, + "required": [ + "RestApiId" + ] + }, + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage_plugin.py new file mode 100644 index 0000000000000..e0898bae2c695 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_stage_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayStageProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::Stage" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_stage import ( + ApiGatewayStageProvider, + ) + + self.factory = ApiGatewayStageProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan.py new file mode 100644 index 0000000000000..1e10c9badfc3f --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan.py @@ -0,0 +1,215 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.aws.arns import get_partition +from localstack.utils.objects import keys_to_lower +from localstack.utils.strings import first_char_to_lower + + +class ApiGatewayUsagePlanProperties(TypedDict): + ApiStages: Optional[list[ApiStage]] + Description: Optional[str] + Id: Optional[str] + Quota: Optional[QuotaSettings] + Tags: Optional[list[Tag]] + Throttle: Optional[ThrottleSettings] + UsagePlanName: Optional[str] + + +class ApiStage(TypedDict): + ApiId: Optional[str] + Stage: Optional[str] + Throttle: Optional[dict] + + +class QuotaSettings(TypedDict): + Limit: Optional[int] + Offset: Optional[int] + Period: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class ThrottleSettings(TypedDict): + BurstLimit: Optional[int] + RateLimit: Optional[float] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayUsagePlanProvider(ResourceProvider[ApiGatewayUsagePlanProperties]): + TYPE = "AWS::ApiGateway::UsagePlan" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayUsagePlanProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Read-only properties: + - /properties/Id + + IAM permissions required: + - apigateway:POST + - apigateway:GET + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + plan_name = model.get("UsagePlanName") + if not plan_name: + model["UsagePlanName"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + params = util.select_attributes(model, ["Description", "ApiStages", "Quota", "Throttle"]) + params = keys_to_lower(params.copy()) + params["name"] = model["UsagePlanName"] + + if model.get("Tags"): + params["tags"] = {tag["Key"]: tag["Value"] for tag in model["Tags"]} + + # set int and float types + if params.get("quota"): + params["quota"]["limit"] = int(params["quota"]["limit"]) + + if params.get("throttle"): + params["throttle"]["burstLimit"] = int(params["throttle"]["burstLimit"]) + params["throttle"]["rateLimit"] = float(params["throttle"]["rateLimit"]) + + response = apigw.create_usage_plan(**params) + + model["Id"] = response["id"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayUsagePlanProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayUsagePlanProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + apigw.delete_usage_plan(usagePlanId=model["Id"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayUsagePlanProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanProperties]: + """ + Update a resource + + IAM permissions required: + - apigateway:GET + - apigateway:DELETE + - apigateway:PATCH + - apigateway:PUT + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + parameters_to_select = [ + "UsagePlanName", + "Description", + "ApiStages", + "Quota", + "Throttle", + "Tags", + ] + update_config_props = util.select_attributes(model, parameters_to_select) + + updated_tags = update_config_props.pop("Tags", []) + + usage_plan_id = request.previous_state["Id"] + + patch_operations = [] + + for parameter in update_config_props: + value = update_config_props[parameter] + if parameter == "ApiStages": + for stage in value: + patch_operations.append( + { + "op": "replace", + "path": f"/{first_char_to_lower(parameter)}", + "value": f"{stage['ApiId']}:{stage['Stage']}", + } + ) + + if "Throttle" in stage: + patch_operations.append( + { + "op": "replace", + "path": f"/{first_char_to_lower(parameter)}/{stage['ApiId']}:{stage['Stage']}", + "value": json.dumps(stage["Throttle"]), + } + ) + + elif isinstance(value, dict): + for item in value: + last_value = value[item] + path = f"/{first_char_to_lower(parameter)}/{first_char_to_lower(item)}" + patch_operations.append({"op": "replace", "path": path, "value": last_value}) + else: + patch_operations.append( + {"op": "replace", "path": f"/{first_char_to_lower(parameter)}", "value": value} + ) + apigw.update_usage_plan(usagePlanId=usage_plan_id, patchOperations=patch_operations) + + if updated_tags: + tags = {tag["Key"]: tag["Value"] for tag in updated_tags} + usage_plan_arn = f"arn:{get_partition(request.region_name)}:apigateway:{request.region_name}::/usageplans/{usage_plan_id}" + apigw.tag_resource(resourceArn=usage_plan_arn, tags=tags) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={**request.previous_state, **request.desired_state}, + custom_context=request.custom_context, + ) diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan.schema.json new file mode 100644 index 0000000000000..96f6f07bb01ca --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan.schema.json @@ -0,0 +1,173 @@ +{ + "typeName": "AWS::ApiGateway::UsagePlan", + "description": "Resource Type definition for AWS::ApiGateway::UsagePlan", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway.git", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string", + "description": "The provider-assigned unique ID for this managed resource." + }, + "ApiStages": { + "type": "array", + "description": "The API stages to associate with this usage plan.", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/ApiStage" + } + }, + "Description": { + "type": "string", + "description": "A description of the usage plan." + }, + "Quota": { + "$ref": "#/definitions/QuotaSettings", + "description": "Configures the number of requests that users can make within a given interval." + }, + "Tags": { + "type": "array", + "description": "An array of arbitrary tags (key-value pairs) to associate with the usage plan.", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Throttle": { + "$ref": "#/definitions/ThrottleSettings", + "description": "Configures the overall request rate (average requests per second) and burst capacity." + }, + "UsagePlanName": { + "type": "string", + "description": "A name for the usage plan." + } + }, + "definitions": { + "ApiStage": { + "type": "object", + "additionalProperties": false, + "properties": { + "ApiId": { + "type": "string", + "description": "The ID of an API that is in the specified Stage property that you want to associate with the usage plan." + }, + "Stage": { + "type": "string", + "description": "The name of the stage to associate with the usage plan." + }, + "Throttle": { + "type": "object", + "description": "Map containing method-level throttling information for an API stage in a usage plan. The key for the map is the path and method for which to configure custom throttling, for example, '/pets/GET'. Duplicates are not allowed.", + "additionalProperties": false, + "patternProperties": { + ".*": { + "$ref": "#/definitions/ThrottleSettings" + } + } + } + } + }, + "ThrottleSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "BurstLimit": { + "type": "integer", + "minimum": 0, + "description": "The maximum API request rate limit over a time ranging from one to a few seconds. The maximum API request rate limit depends on whether the underlying token bucket is at its full capacity." + }, + "RateLimit": { + "type": "number", + "minimum": 0, + "description": "The API request steady-state rate limit (average requests per second over an extended period of time)." + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -." + }, + "Value": { + "type": "string", + "minLength": 0, + "maxLength": 256, + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -." + } + }, + "required": [ + "Value", + "Key" + ] + }, + "QuotaSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "Limit": { + "type": "integer", + "minimum": 0, + "description": "The maximum number of requests that users can make within the specified time period." + }, + "Offset": { + "type": "integer", + "minimum": 0, + "description": "For the initial time period, the number of requests to subtract from the specified limit. When you first implement a usage plan, the plan might start in the middle of the week or month. With this property, you can decrease the limit for this initial time period." + }, + "Period": { + "type": "string", + "description": "The time period for which the maximum limit of requests applies, such as DAY or WEEK. For valid values, see the period property for the UsagePlan resource in the Amazon API Gateway REST API Reference." + } + } + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:POST", + "apigateway:GET" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "update": { + "permissions": [ + "apigateway:GET", + "apigateway:DELETE", + "apigateway:PATCH", + "apigateway:PUT" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "list": { + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan_plugin.py new file mode 100644 index 0000000000000..154207ac69b58 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplan_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayUsagePlanProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::UsagePlan" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_usageplan import ( + ApiGatewayUsagePlanProvider, + ) + + self.factory = ApiGatewayUsagePlanProvider diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey.py new file mode 100644 index 0000000000000..33a6e155d5c4f --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey.py @@ -0,0 +1,114 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.objects import keys_to_lower + + +class ApiGatewayUsagePlanKeyProperties(TypedDict): + KeyId: Optional[str] + KeyType: Optional[str] + UsagePlanId: Optional[str] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ApiGatewayUsagePlanKeyProvider(ResourceProvider[ApiGatewayUsagePlanKeyProperties]): + TYPE = "AWS::ApiGateway::UsagePlanKey" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ApiGatewayUsagePlanKeyProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanKeyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - KeyType + - UsagePlanId + - KeyId + + Create-only properties: + - /properties/KeyId + - /properties/UsagePlanId + - /properties/KeyType + + Read-only properties: + - /properties/Id + + IAM permissions required: + - apigateway:POST + - apigateway:GET + + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + params = keys_to_lower(model.copy()) + result = apigw.create_usage_plan_key(**params) + + model["Id"] = result["id"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ApiGatewayUsagePlanKeyProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanKeyProperties]: + """ + Fetch resource information + + IAM permissions required: + - apigateway:GET + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ApiGatewayUsagePlanKeyProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanKeyProperties]: + """ + Delete a resource + + IAM permissions required: + - apigateway:DELETE + """ + model = request.desired_state + apigw = request.aws_client_factory.apigateway + + apigw.delete_usage_plan_key(usagePlanId=model["UsagePlanId"], keyId=model["KeyId"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ApiGatewayUsagePlanKeyProperties], + ) -> ProgressEvent[ApiGatewayUsagePlanKeyProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey.schema.json b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey.schema.json new file mode 100644 index 0000000000000..997f3be9a0d49 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey.schema.json @@ -0,0 +1,77 @@ +{ + "typeName": "AWS::ApiGateway::UsagePlanKey", + "description": "Resource Type definition for AWS::ApiGateway::UsagePlanKey", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-apigateway", + "additionalProperties": false, + "properties": { + "KeyId": { + "description": "The ID of the usage plan key.", + "type": "string" + }, + "KeyType": { + "description": "The type of usage plan key. Currently, the only valid key type is API_KEY.", + "type": "string", + "enum": [ + "API_KEY" + ] + }, + "UsagePlanId": { + "description": "The ID of the usage plan.", + "type": "string" + }, + "Id": { + "description": "An autogenerated ID which is a combination of the ID of the key and ID of the usage plan combined with a : such as 123abcdef:abc123.", + "type": "string" + } + }, + "taggable": false, + "required": [ + "KeyType", + "UsagePlanId", + "KeyId" + ], + "createOnlyProperties": [ + "/properties/KeyId", + "/properties/UsagePlanId", + "/properties/KeyType" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "handlers": { + "create": { + "permissions": [ + "apigateway:POST", + "apigateway:GET" + ] + }, + "read": { + "permissions": [ + "apigateway:GET" + ] + }, + "delete": { + "permissions": [ + "apigateway:DELETE" + ] + }, + "list": { + "handlerSchema": { + "properties": { + "UsagePlanId": { + "$ref": "resource-schema.json#/properties/UsagePlanId" + } + }, + "required": [ + "UsagePlanId" + ] + }, + "permissions": [ + "apigateway:GET" + ] + } + } +} diff --git a/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey_plugin.py b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey_plugin.py new file mode 100644 index 0000000000000..eb21b610bfc22 --- /dev/null +++ b/localstack-core/localstack/services/apigateway/resource_providers/aws_apigateway_usageplankey_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ApiGatewayUsagePlanKeyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ApiGateway::UsagePlanKey" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.apigateway.resource_providers.aws_apigateway_usageplankey import ( + ApiGatewayUsagePlanKeyProvider, + ) + + self.factory = ApiGatewayUsagePlanKeyProvider diff --git a/tests/integration/lambdas/__init__.py b/localstack-core/localstack/services/cdk/__init__.py similarity index 100% rename from tests/integration/lambdas/__init__.py rename to localstack-core/localstack/services/cdk/__init__.py diff --git a/localstack-core/localstack/services/cdk/resource_providers/__init__.py b/localstack-core/localstack/services/cdk/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata.py b/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata.py new file mode 100644 index 0000000000000..7e5eb5ca2f988 --- /dev/null +++ b/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata.py @@ -0,0 +1,90 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class CDKMetadataProperties(TypedDict): + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CDKMetadataProvider(ResourceProvider[CDKMetadataProperties]): + TYPE = "AWS::CDK::Metadata" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CDKMetadataProperties], + ) -> ProgressEvent[CDKMetadataProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + + + """ + model = request.desired_state + model["Id"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[CDKMetadataProperties], + ) -> ProgressEvent[CDKMetadataProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CDKMetadataProperties], + ) -> ProgressEvent[CDKMetadataProperties]: + """ + Delete a resource + + + """ + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=request.previous_state, + ) + + def update( + self, + request: ResourceRequest[CDKMetadataProperties], + ) -> ProgressEvent[CDKMetadataProperties]: + """ + Update a resource + + + """ + model = request.desired_state + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) diff --git a/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata.schema.json b/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata.schema.json new file mode 100644 index 0000000000000..636fc68e2e9c0 --- /dev/null +++ b/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata.schema.json @@ -0,0 +1,22 @@ +{ + "typeName": "AWS::CDK::Metadata" , + "description": "Resource Type definition for AWS::CDK::Metadata", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + } + }, + "definitions": { + }, + "required": [ + ], + "createOnlyProperties": [ + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata_plugin.py b/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata_plugin.py new file mode 100644 index 0000000000000..924ca3cb79eae --- /dev/null +++ b/localstack-core/localstack/services/cdk/resource_providers/cdk_metadata_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaAliasProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CDK::Metadata" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.cdk.resource_providers.cdk_metadata import CDKMetadataProvider + + self.factory = CDKMetadataProvider diff --git a/localstack-core/localstack/services/certificatemanager/__init__.py b/localstack-core/localstack/services/certificatemanager/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/certificatemanager/resource_providers/__init__.py b/localstack-core/localstack/services/certificatemanager/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate.py b/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate.py new file mode 100644 index 0000000000000..d79d62975e87f --- /dev/null +++ b/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate.py @@ -0,0 +1,151 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class CertificateManagerCertificateProperties(TypedDict): + DomainName: Optional[str] + CertificateAuthorityArn: Optional[str] + CertificateTransparencyLoggingPreference: Optional[str] + DomainValidationOptions: Optional[list[DomainValidationOption]] + Id: Optional[str] + SubjectAlternativeNames: Optional[list[str]] + Tags: Optional[list[Tag]] + ValidationMethod: Optional[str] + + +class DomainValidationOption(TypedDict): + DomainName: Optional[str] + HostedZoneId: Optional[str] + ValidationDomain: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CertificateManagerCertificateProvider( + ResourceProvider[CertificateManagerCertificateProperties] +): + TYPE = "AWS::CertificateManager::Certificate" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CertificateManagerCertificateProperties], + ) -> ProgressEvent[CertificateManagerCertificateProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - DomainName + + Create-only properties: + - /properties/SubjectAlternativeNames + - /properties/DomainValidationOptions + - /properties/ValidationMethod + - /properties/DomainName + - /properties/CertificateAuthorityArn + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + acm = request.aws_client_factory.acm + + params = util.select_attributes( + model, + [ + "CertificateAuthorityArn", + "DomainName", + "DomainValidationOptions", + "SubjectAlternativeNames", + "Tags", + "ValidationMethod", + ], + ) + # adjust domain validation options + valid_opts = params.get("DomainValidationOptions") + if valid_opts: + + def _convert(opt): + res = util.select_attributes(opt, ["DomainName", "ValidationDomain"]) + res.setdefault("ValidationDomain", res["DomainName"]) + return res + + params["DomainValidationOptions"] = [_convert(opt) for opt in valid_opts] + + # adjust logging preferences + logging_pref = params.get("CertificateTransparencyLoggingPreference") + if logging_pref: + params["Options"] = {"CertificateTransparencyLoggingPreference": logging_pref} + + response = acm.request_certificate(**params) + model["Id"] = response["CertificateArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[CertificateManagerCertificateProperties], + ) -> ProgressEvent[CertificateManagerCertificateProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CertificateManagerCertificateProperties], + ) -> ProgressEvent[CertificateManagerCertificateProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + acm = request.aws_client_factory.acm + + acm.delete_certificate(CertificateArn=model["Id"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[CertificateManagerCertificateProperties], + ) -> ProgressEvent[CertificateManagerCertificateProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate.schema.json b/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate.schema.json new file mode 100644 index 0000000000000..a4d90a42f0839 --- /dev/null +++ b/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate.schema.json @@ -0,0 +1,95 @@ +{ + "typeName": "AWS::CertificateManager::Certificate", + "description": "Resource Type definition for AWS::CertificateManager::Certificate", + "additionalProperties": false, + "properties": { + "CertificateAuthorityArn": { + "type": "string" + }, + "DomainValidationOptions": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/DomainValidationOption" + } + }, + "CertificateTransparencyLoggingPreference": { + "type": "string" + }, + "DomainName": { + "type": "string" + }, + "ValidationMethod": { + "type": "string" + }, + "SubjectAlternativeNames": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Id": { + "type": "string" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "DomainValidationOption": { + "type": "object", + "additionalProperties": false, + "properties": { + "DomainName": { + "type": "string" + }, + "ValidationDomain": { + "type": "string" + }, + "HostedZoneId": { + "type": "string" + } + }, + "required": [ + "DomainName" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "DomainName" + ], + "createOnlyProperties": [ + "/properties/SubjectAlternativeNames", + "/properties/DomainValidationOptions", + "/properties/ValidationMethod", + "/properties/DomainName", + "/properties/CertificateAuthorityArn" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate_plugin.py b/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate_plugin.py new file mode 100644 index 0000000000000..5aae4de01c7b3 --- /dev/null +++ b/localstack-core/localstack/services/certificatemanager/resource_providers/aws_certificatemanager_certificate_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class CertificateManagerCertificateProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CertificateManager::Certificate" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.certificatemanager.resource_providers.aws_certificatemanager_certificate import ( + CertificateManagerCertificateProvider, + ) + + self.factory = CertificateManagerCertificateProvider diff --git a/localstack-core/localstack/services/cloudformation/__init__.py b/localstack-core/localstack/services/cloudformation/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cloudformation/analytics.py b/localstack-core/localstack/services/cloudformation/analytics.py new file mode 100644 index 0000000000000..80ec4d1960005 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/analytics.py @@ -0,0 +1,67 @@ +import enum +from typing import Self + +from localstack.aws.api.cloudformation import ChangeAction +from localstack.utils.analytics.metrics import LabeledCounter + +COUNTER_NAMESPACE = "cloudformation" +COUNTER_VERSION = 2 + + +class ActionOptions(enum.StrEnum): + """ + Available actions that can be performed on a resource. + + Must support both CFn and CloudControl. + """ + + CREATE = "create" + DELETE = "delete" + UPDATE = "update" + # for cloudcontrol + READ = "read" + LIST = "list" + + @classmethod + def from_action(cls, action: Self | str | ChangeAction) -> Self: + if isinstance(action, cls): + return action + + # only used in CFn + if isinstance(action, ChangeAction): + action = action.value + + match action: + case "Add": + return cls.CREATE + case "Modify" | "Dynamic": + return cls.UPDATE + case "Remove": + return cls.DELETE + case "Read": + return cls.READ + case "List": + return cls.LIST + case _: + available_values = [every.value for every in cls] + raise ValueError( + f"Invalid action option '{action}', should be one of {available_values}" + ) + + +resources = LabeledCounter( + namespace=COUNTER_NAMESPACE, + name="resources", + labels=["resource_type", "missing", "action"], + schema_version=COUNTER_VERSION, +) + + +def track_resource_operation( + action: ActionOptions | str, expected_resource_type: str, *, missing: bool +): + resources.labels( + resource_type=expected_resource_type, + missing=missing, + action=ActionOptions.from_action(action), + ).increment() diff --git a/localstack-core/localstack/services/cloudformation/api_utils.py b/localstack-core/localstack/services/cloudformation/api_utils.py new file mode 100644 index 0000000000000..c4172974cec35 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/api_utils.py @@ -0,0 +1,162 @@ +import logging +import re +from urllib.parse import urlparse + +from localstack import config, constants +from localstack.aws.connect import connect_to +from localstack.services.cloudformation.engine.validations import ValidationError +from localstack.services.s3.utils import ( + extract_bucket_name_and_key_from_headers_and_path, + normalize_bucket_name, +) +from localstack.utils.functions import run_safe +from localstack.utils.http import safe_requests +from localstack.utils.strings import to_str +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + + +def prepare_template_body(req_data: dict) -> str | bytes | None: # TODO: mutating and returning + template_url = req_data.get("TemplateURL") + if template_url: + req_data["TemplateURL"] = convert_s3_to_local_url(template_url) + url = req_data.get("TemplateURL", "") + if is_local_service_url(url): + modified_template_body = get_template_body(req_data) + if modified_template_body: + req_data.pop("TemplateURL", None) + req_data["TemplateBody"] = modified_template_body + modified_template_body = get_template_body(req_data) + if modified_template_body: + req_data["TemplateBody"] = modified_template_body + return modified_template_body + + +def extract_template_body(request: dict) -> str: + """ + Given a request payload, fetch the body of the template either from S3 or from the payload itself + """ + if template_body := request.get("TemplateBody"): + if request.get("TemplateURL"): + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + return template_body + + elif template_url := request.get("TemplateURL"): + template_url = convert_s3_to_local_url(template_url) + return get_remote_template_body(template_url) + + else: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + +def get_remote_template_body(url: str) -> str: + response = run_safe(lambda: safe_requests.get(url, verify=False)) + # check error codes, and code 301 - fixes https://github.com/localstack/localstack/issues/1884 + status_code = 0 if response is None else response.status_code + if 200 <= status_code < 300: + # request was ok + return response.text + elif response is None or status_code == 301 or status_code >= 400: + # check if this is an S3 URL, then get the file directly from there + url = convert_s3_to_local_url(url) + if is_local_service_url(url): + parsed_path = urlparse(url).path.lstrip("/") + parts = parsed_path.partition("/") + client = connect_to().s3 + LOG.debug( + "Download CloudFormation template content from local S3: %s - %s", + parts[0], + parts[2], + ) + result = client.get_object(Bucket=parts[0], Key=parts[2]) + body = to_str(result["Body"].read()) + return body + raise RuntimeError( + "Unable to fetch template body (code %s) from URL %s" % (status_code, url) + ) + else: + raise RuntimeError( + f"Bad status code from fetching template from url '{url}' ({status_code})", + url, + status_code, + ) + + +def get_template_body(req_data: dict) -> str: + body = req_data.get("TemplateBody") + if body: + return body + url = req_data.get("TemplateURL") + if url: + response = run_safe(lambda: safe_requests.get(url, verify=False)) + # check error codes, and code 301 - fixes https://github.com/localstack/localstack/issues/1884 + status_code = 0 if response is None else response.status_code + if response is None or status_code == 301 or status_code >= 400: + # check if this is an S3 URL, then get the file directly from there + url = convert_s3_to_local_url(url) + if is_local_service_url(url): + parsed_path = urlparse(url).path.lstrip("/") + parts = parsed_path.partition("/") + client = connect_to().s3 + LOG.debug( + "Download CloudFormation template content from local S3: %s - %s", + parts[0], + parts[2], + ) + result = client.get_object(Bucket=parts[0], Key=parts[2]) + body = to_str(result["Body"].read()) + return body + raise Exception( + "Unable to fetch template body (code %s) from URL %s" % (status_code, url) + ) + return to_str(response.content) + raise Exception("Unable to get template body from input: %s" % req_data) + + +def is_local_service_url(url: str) -> bool: + if not url: + return False + candidates = ( + constants.LOCALHOST, + constants.LOCALHOST_HOSTNAME, + localstack_host().host, + ) + if any(re.match(r"^[^:]+://[^:/]*%s([:/]|$)" % host, url) for host in candidates): + return True + host = url.split("://")[-1].split("/")[0] + return "localhost" in host + + +def convert_s3_to_local_url(url: str) -> str: + from localstack.services.cloudformation.provider import ValidationError + + url_parsed = urlparse(url) + path = url_parsed.path + + headers = {"host": url_parsed.netloc} + bucket_name, key_name = extract_bucket_name_and_key_from_headers_and_path(headers, path) + + if url_parsed.scheme == "s3": + raise ValidationError( + f"S3 error: Domain name specified in {url_parsed.netloc} is not a valid S3 domain" + ) + + if not bucket_name or not key_name: + if not (url_parsed.netloc.startswith("s3.") or ".s3." in url_parsed.netloc): + raise ValidationError("TemplateURL must be a supported URL.") + + # note: make sure to normalize the bucket name here! + bucket_name = normalize_bucket_name(bucket_name) + local_url = f"{config.internal_service_url()}/{bucket_name}/{key_name}" + return local_url + + +def validate_stack_name(stack_name): + pattern = r"[a-zA-Z][-a-zA-Z0-9]*|arn:[-a-zA-Z0-9:/._+]*" + return re.match(pattern, stack_name) is not None diff --git a/localstack-core/localstack/services/cloudformation/cfn_utils.py b/localstack-core/localstack/services/cloudformation/cfn_utils.py new file mode 100644 index 0000000000000..6fcc5d16fb573 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/cfn_utils.py @@ -0,0 +1,84 @@ +import json +from typing import Callable + +from localstack.utils.objects import recurse_object + + +def rename_params(func, rename_map): + def do_rename(account_id, region_name, params, logical_resource_id, *args, **kwargs): + values = ( + func(account_id, region_name, params, logical_resource_id, *args, **kwargs) + if func + else params + ) + for old_param, new_param in rename_map.items(): + values[new_param] = values.pop(old_param, None) + return values + + return do_rename + + +def lambda_convert_types(func, types): + return ( + lambda account_id, region_name, params, logical_resource_id, *args, **kwargs: convert_types( + func(account_id, region_name, params, *args, **kwargs), types + ) + ) + + +def lambda_to_json(attr): + return lambda account_id, region_name, params, logical_resource_id, *args, **kwargs: json.dumps( + params[attr] + ) + + +def lambda_rename_attributes(attrs, func=None): + def recurse(o, path): + if isinstance(o, dict): + for k in list(o.keys()): + for a in attrs.keys(): + if k == a: + o[attrs[k]] = o.pop(k) + return o + + func = func or (lambda account_id, region_name, x, logical_resource_id, *args, **kwargs: x) + return ( + lambda account_id, + region_name, + params, + logical_resource_id, + *args, + **kwargs: recurse_object( + func(account_id, region_name, params, logical_resource_id, *args, **kwargs), recurse + ) + ) + + +def convert_types(obj, types): + def fix_types(key, type_class): + def recurse(o, path): + if isinstance(o, dict): + for k, v in dict(o).items(): + key_path = "%s%s" % (path or ".", k) + if key in [k, key_path]: + o[k] = type_class(v) + return o + + return recurse_object(obj, recurse) + + for key, type_class in types.items(): + fix_types(key, type_class) + return obj + + +def get_tags_param(resource_type: str) -> Callable: + """Return a tag parameters creation function for the given resource type""" + + def _param(account_id: str, region_name: str, params, logical_resource_id, *args, **kwargs): + tags = params.get("Tags") + if not tags: + return None + + return [{"ResourceType": resource_type, "Tags": tags}] + + return _param diff --git a/localstack-core/localstack/services/cloudformation/deploy.html b/localstack-core/localstack/services/cloudformation/deploy.html new file mode 100644 index 0000000000000..47af619288057 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/deploy.html @@ -0,0 +1,144 @@ + + + + + LocalStack - CloudFormation Deployment + + + + + + + + +
+ + + diff --git a/localstack-core/localstack/services/cloudformation/deploy_ui.py b/localstack-core/localstack/services/cloudformation/deploy_ui.py new file mode 100644 index 0000000000000..deac95b408b1f --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/deploy_ui.py @@ -0,0 +1,47 @@ +import json +import logging +import os + +import requests +from rolo import Response + +from localstack import constants +from localstack.utils.files import load_file +from localstack.utils.json import parse_json_or_yaml + +LOG = logging.getLogger(__name__) + + +class CloudFormationUi: + def on_get(self, request): + from localstack.utils.aws.aws_stack import get_valid_regions + + deploy_html_file = os.path.join( + constants.MODULE_MAIN_PATH, "services", "cloudformation", "deploy.html" + ) + deploy_html = load_file(deploy_html_file) + req_params = request.values + params = { + "stackName": "stack1", + "templateBody": "{}", + "errorMessage": "''", + "regions": json.dumps(sorted(get_valid_regions())), + } + + download_url = req_params.get("templateURL") + if download_url: + try: + LOG.debug("Attempting to download CloudFormation template URL: %s", download_url) + template_body = requests.get(download_url).text + template_body = parse_json_or_yaml(template_body) + params["templateBody"] = json.dumps(template_body) + except Exception as e: + msg = f"Unable to download CloudFormation template URL: {e}" + LOG.info(msg) + params["errorMessage"] = json.dumps(msg.replace("\n", " - ")) + + # using simple string replacement here, for simplicity (could be replaced with, e.g., jinja) + for key, value in params.items(): + deploy_html = deploy_html.replace(f"<{key}>", value) + + return Response(deploy_html, mimetype="text/html") diff --git a/localstack-core/localstack/services/cloudformation/deployment_utils.py b/localstack-core/localstack/services/cloudformation/deployment_utils.py new file mode 100644 index 0000000000000..6355db6b5c27a --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/deployment_utils.py @@ -0,0 +1,319 @@ +import builtins +import json +import logging +import re +from copy import deepcopy +from typing import Callable, List + +from localstack import config +from localstack.utils import common +from localstack.utils.aws import aws_stack +from localstack.utils.common import select_attributes, short_uid +from localstack.utils.functions import run_safe +from localstack.utils.json import json_safe +from localstack.utils.objects import recurse_object +from localstack.utils.strings import is_string + +# placeholders +PLACEHOLDER_AWS_NO_VALUE = "__aws_no_value__" + +LOG = logging.getLogger(__name__) + + +def dump_json_params(param_func=None, *param_names): + def replace(account_id: str, region_name: str, params, logical_resource_id, *args, **kwargs): + result = ( + param_func(account_id, region_name, params, logical_resource_id, *args, **kwargs) + if param_func + else params + ) + for name in param_names: + if isinstance(result.get(name), (dict, list)): + # Fix for https://github.com/localstack/localstack/issues/2022 + # Convert any date instances to date strings, etc, Version: "2012-10-17" + param_value = common.json_safe(result[name]) + result[name] = json.dumps(param_value) + return result + + return replace + + +# TODO: remove +def param_defaults(param_func, defaults): + def replace( + account_id: str, + region_name: str, + properties: dict, + logical_resource_id: str, + *args, + **kwargs, + ): + result = param_func( + account_id, region_name, properties, logical_resource_id, *args, **kwargs + ) + for key, value in defaults.items(): + if result.get(key) in ["", None]: + result[key] = value + return result + + return replace + + +def remove_none_values(params): + """Remove None values and AWS::NoValue placeholders (recursively) in the given object.""" + + def remove_nones(o, **kwargs): + if isinstance(o, dict): + for k, v in dict(o).items(): + if v in [None, PLACEHOLDER_AWS_NO_VALUE]: + o.pop(k) + if isinstance(o, list): + common.run_safe(o.remove, None) + common.run_safe(o.remove, PLACEHOLDER_AWS_NO_VALUE) + return o + + result = common.recurse_object(params, remove_nones) + return result + + +def params_list_to_dict(param_name, key_attr_name="Key", value_attr_name="Value"): + def do_replace(account_id: str, region_name: str, params, logical_resource_id, *args, **kwargs): + result = {} + for entry in params.get(param_name, []): + key = entry[key_attr_name] + value = entry[value_attr_name] + result[key] = value + return result + + return do_replace + + +def lambda_keys_to_lower(key=None, skip_children_of: List[str] = None): + return ( + lambda account_id, + region_name, + params, + logical_resource_id, + *args, + **kwargs: common.keys_to_lower( + obj=(params.get(key) if key else params), skip_children_of=skip_children_of + ) + ) + + +def merge_parameters(func1, func2): + return ( + lambda account_id, + region_name, + properties, + logical_resource_id, + *args, + **kwargs: common.merge_dicts( + func1(account_id, region_name, properties, logical_resource_id, *args, **kwargs), + func2(account_id, region_name, properties, logical_resource_id, *args, **kwargs), + ) + ) + + +def str_or_none(o): + return o if o is None else json.dumps(o) if isinstance(o, (dict, list)) else str(o) + + +def params_dict_to_list(param_name, key_attr_name="Key", value_attr_name="Value", wrapper=None): + def do_replace(account_id: str, region_name: str, params, logical_resource_id, *args, **kwargs): + result = [] + for key, value in params.get(param_name, {}).items(): + result.append({key_attr_name: key, value_attr_name: value}) + if wrapper: + result = {wrapper: result} + return result + + return do_replace + + +# TODO: remove +def params_select_attributes(*attrs): + def do_select(account_id: str, region_name: str, params, logical_resource_id, *args, **kwargs): + result = {} + for attr in attrs: + if params.get(attr) is not None: + result[attr] = str_or_none(params.get(attr)) + return result + + return do_select + + +def param_json_to_str(name): + def _convert(account_id: str, region_name: str, params, logical_resource_id, *args, **kwargs): + result = params.get(name) + if result: + result = json.dumps(result) + return result + + return _convert + + +def lambda_select_params(*selected): + # TODO: remove and merge with function below + return select_parameters(*selected) + + +def select_parameters(*param_names): + return ( + lambda account_id, + region_name, + properties, + logical_resource_id, + *args, + **kwargs: select_attributes(properties, param_names) + ) + + +def is_none_or_empty_value(value): + return not value or value == PLACEHOLDER_AWS_NO_VALUE + + +def generate_default_name(stack_name: str, logical_resource_id: str): + random_id_part = short_uid() + resource_id_part = logical_resource_id[:24] + stack_name_part = stack_name[: 63 - 2 - (len(random_id_part) + len(resource_id_part))] + return f"{stack_name_part}-{resource_id_part}-{random_id_part}" + + +def generate_default_name_without_stack(logical_resource_id: str): + random_id_part = short_uid() + resource_id_part = logical_resource_id[: 63 - 1 - len(random_id_part)] + return f"{resource_id_part}-{random_id_part}" + + +# Utils for parameter conversion + +# TODO: handling of multiple valid types +param_validation = re.compile( + r"Invalid type for parameter (?P[\w.]+), value: (?P\w+), type: \w+)'>, valid types: \w+)'>" +) + + +def get_nested(obj: dict, path: str): + parts = path.split(".") + result = obj + for p in parts[:-1]: + result = result.get(p, {}) + return result.get(parts[-1]) + + +def set_nested(obj: dict, path: str, value): + parts = path.split(".") + result = obj + for p in parts[:-1]: + result = result.get(p, {}) + result[parts[-1]] = value + + +def fix_boto_parameters_based_on_report(original_params: dict, report: str) -> dict: + """ + Fix invalid type parameter validation errors in boto request parameters + + :param original_params: original boto request parameters that lead to the parameter validation error + :param report: error report from botocore ParamValidator + :return: a copy of original_params with all values replaced by their correctly cast ones + """ + params = deepcopy(original_params) + for found in param_validation.findall(report): + param_name, value, wrong_class, valid_class = found + cast_class = getattr(builtins, valid_class) + old_value = get_nested(params, param_name) + + if cast_class == bool and str(old_value).lower() in ["true", "false"]: + new_value = str(old_value).lower() == "true" + else: + new_value = cast_class(old_value) + set_nested(params, param_name, new_value) + return params + + +def fix_account_id_in_arns(params: dict, replacement_account_id: str) -> dict: + def fix_ids(o, **kwargs): + if isinstance(o, dict): + for k, v in o.items(): + if is_string(v, exclude_binary=True): + o[k] = aws_stack.fix_account_id_in_arns(v, replacement=replacement_account_id) + elif is_string(o, exclude_binary=True): + o = aws_stack.fix_account_id_in_arns(o, replacement=replacement_account_id) + return o + + result = recurse_object(params, fix_ids) + return result + + +def convert_data_types(type_conversions: dict[str, Callable], params: dict) -> dict: + """Convert data types in the "params" object, with the type defs + specified in the 'types' attribute of "func_details".""" + attr_names = type_conversions.keys() or [] + + def cast(_obj, _type): + if _type == bool: + return _obj in ["True", "true", True] + if _type == str: + if isinstance(_obj, bool): + return str(_obj).lower() + return str(_obj) + if _type in (int, float): + return _type(_obj) + return _obj + + def fix_types(o, **kwargs): + if isinstance(o, dict): + for k, v in o.items(): + if k in attr_names: + o[k] = cast(v, type_conversions[k]) + return o + + result = recurse_object(params, fix_types) + return result + + +def log_not_available_message(resource_type: str, message: str): + LOG.warning( + "%s. To find out if %s is supported in LocalStack Pro, " + "please check out our docs at https://docs.localstack.cloud/user-guide/aws/cloudformation/#resources-pro--enterprise-edition", + message, + resource_type, + ) + + +def dump_resource_as_json(resource: dict) -> str: + return str(run_safe(lambda: json.dumps(json_safe(resource))) or resource) + + +def get_action_name_for_resource_change(res_change: str) -> str: + return {"Add": "CREATE", "Remove": "DELETE", "Modify": "UPDATE"}.get(res_change) + + +def check_not_found_exception(e, resource_type, resource, resource_status=None): + # we expect this to be a "not found" exception + markers = [ + "NoSuchBucket", + "ResourceNotFound", + "NoSuchEntity", + "NotFoundException", + "404", + "not found", + "not exist", + ] + + markers_hit = [m for m in markers if m in str(e)] + if not markers_hit: + LOG.warning( + "Unexpected error processing resource type %s: Exception: %s - %s - status: %s", + resource_type, + str(e), + resource, + resource_status, + ) + if config.CFN_VERBOSE_ERRORS: + raise e + else: + return False + + return True diff --git a/localstack-core/localstack/services/cloudformation/engine/__init__.py b/localstack-core/localstack/services/cloudformation/engine/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cloudformation/engine/changes.py b/localstack-core/localstack/services/cloudformation/engine/changes.py new file mode 100644 index 0000000000000..ae6ced9e5563e --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/changes.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional, TypedDict + +Action = str + + +class ResourceChange(TypedDict): + Action: Action + LogicalResourceId: str + PhysicalResourceId: Optional[str] + ResourceType: str + Scope: list + Details: list + Replacement: Optional[Literal["False"]] + + +class ChangeConfig(TypedDict): + Type: str + ResourceChange: ResourceChange diff --git a/localstack-core/localstack/services/cloudformation/engine/entities.py b/localstack-core/localstack/services/cloudformation/engine/entities.py new file mode 100644 index 0000000000000..e1498258694ee --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/entities.py @@ -0,0 +1,442 @@ +import logging +from typing import Optional, TypedDict + +from localstack.aws.api.cloudformation import Capability, ChangeSetType, Parameter +from localstack.services.cloudformation.engine.parameters import ( + StackParameter, + convert_stack_parameters_to_list, + mask_no_echo, + strip_parameter_type, +) +from localstack.services.cloudformation.engine.v2.change_set_model import ( + ChangeSetModel, + NodeTemplate, +) +from localstack.utils.aws import arns +from localstack.utils.collections import select_attributes +from localstack.utils.id_generator import ExistingIds, ResourceIdentifier, Tags, generate_short_uid +from localstack.utils.json import clone_safe +from localstack.utils.objects import recurse_object +from localstack.utils.strings import long_uid, short_uid +from localstack.utils.time import timestamp_millis + +LOG = logging.getLogger(__name__) + + +class StackSet: + """A stack set contains multiple stack instances.""" + + # FIXME: confusing name. metadata is the complete incoming request object + def __init__(self, metadata: dict): + self.metadata = metadata + # list of stack instances + self.stack_instances = [] + # maps operation ID to stack set operation details + self.operations = {} + + @property + def stack_set_name(self): + return self.metadata.get("StackSetName") + + +class StackInstance: + """A stack instance belongs to a stack set and is specific to a region / account ID.""" + + # FIXME: confusing name. metadata is the complete incoming request object + def __init__(self, metadata: dict): + self.metadata = metadata + # reference to the deployed stack belonging to this stack instance + self.stack = None + + +class CreateChangeSetInput(TypedDict): + StackName: str + Capabilities: list[Capability] + ChangeSetName: Optional[str] + ChangSetType: Optional[ChangeSetType] + Parameters: list[Parameter] + + +class StackTemplate(TypedDict): + StackName: str + ChangeSetName: Optional[str] + Outputs: dict + Resources: dict + + +class StackIdentifier(ResourceIdentifier): + service = "cloudformation" + resource = "stack" + + def __init__(self, account_id: str, region: str, stack_name: str): + super().__init__(account_id, region, stack_name) + + def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str: + return generate_short_uid(resource_identifier=self, existing_ids=existing_ids, tags=tags) + + +# TODO: remove metadata (flatten into individual fields) +class Stack: + change_sets: list["StackChangeSet"] + + def __init__( + self, + account_id: str, + region_name: str, + metadata: Optional[CreateChangeSetInput] = None, + template: Optional[StackTemplate] = None, + template_body: Optional[str] = None, + ): + self.account_id = account_id + self.region_name = region_name + + if template is None: + template = {} + + self.resolved_outputs = list() # TODO + self.resolved_parameters: dict[str, StackParameter] = {} + self.resolved_conditions: dict[str, bool] = {} + + self.metadata = metadata or {} + self.template = template or {} + self.template_body = template_body + self._template_raw = clone_safe(self.template) + self.template_original = clone_safe(self.template) + # initialize resources + for resource_id, resource in self.template_resources.items(): + # HACK: if the resource is a Fn::ForEach intrinsic call from the LanguageExtensions transform, then it is not a dictionary but a list + if resource_id.startswith("Fn::ForEach"): + # we are operating on an untransformed template, so ignore for now + continue + resource["LogicalResourceId"] = self.template_original["Resources"][resource_id][ + "LogicalResourceId" + ] = resource.get("LogicalResourceId") or resource_id + # initialize stack template attributes + stack_id = self.metadata.get("StackId") or arns.cloudformation_stack_arn( + self.stack_name, + stack_id=StackIdentifier( + account_id=account_id, region=region_name, stack_name=metadata.get("StackName") + ).generate(tags=metadata.get("tags")), + account_id=account_id, + region_name=region_name, + ) + self.template["StackId"] = self.metadata["StackId"] = stack_id + self.template["Parameters"] = self.template.get("Parameters") or {} + self.template["Outputs"] = self.template.get("Outputs") or {} + self.template["Conditions"] = self.template.get("Conditions") or {} + # initialize metadata + self.metadata["Parameters"] = self.metadata.get("Parameters") or [] + self.metadata["StackStatus"] = "CREATE_IN_PROGRESS" + self.metadata["CreationTime"] = self.metadata.get("CreationTime") or timestamp_millis() + self.metadata["LastUpdatedTime"] = self.metadata["CreationTime"] + self.metadata.setdefault("Description", self.template.get("Description")) + self.metadata.setdefault("RollbackConfiguration", {}) + self.metadata.setdefault("DisableRollback", False) + self.metadata.setdefault("EnableTerminationProtection", False) + # maps resource id to resource state + self._resource_states = {} + # list of stack events + self.events = [] + # list of stack change sets + self.change_sets = [] + # self.evaluated_conditions = {} + + def set_resolved_parameters(self, resolved_parameters: dict[str, StackParameter]): + self.resolved_parameters = resolved_parameters + if resolved_parameters: + self.metadata["Parameters"] = list(resolved_parameters.values()) + + def set_resolved_stack_conditions(self, resolved_conditions: dict[str, bool]): + self.resolved_conditions = resolved_conditions + + def describe_details(self): + attrs = [ + "StackId", + "StackName", + "Description", + "StackStatusReason", + "StackStatus", + "Capabilities", + "ParentId", + "RootId", + "RoleARN", + "CreationTime", + "DeletionTime", + "LastUpdatedTime", + "ChangeSetId", + "RollbackConfiguration", + "DisableRollback", + "EnableTerminationProtection", + "DriftInformation", + ] + result = select_attributes(self.metadata, attrs) + result["Tags"] = self.tags + outputs = self.resolved_outputs + if outputs: + result["Outputs"] = outputs + stack_parameters = convert_stack_parameters_to_list(self.resolved_parameters) + if stack_parameters: + result["Parameters"] = [ + mask_no_echo(strip_parameter_type(sp)) for sp in stack_parameters + ] + if not result.get("DriftInformation"): + result["DriftInformation"] = {"StackDriftStatus": "NOT_CHECKED"} + for attr in ["Tags", "NotificationARNs"]: + result.setdefault(attr, []) + return result + + def set_stack_status(self, status: str, status_reason: Optional[str] = None): + self.metadata["StackStatus"] = status + if "FAILED" in status: + self.metadata["StackStatusReason"] = status_reason or "Deployment failed" + self.log_stack_errors() + self.add_stack_event( + self.stack_name, self.stack_id, status, status_reason=status_reason or "" + ) + + def log_stack_errors(self, level=logging.WARNING): + for event in self.events: + if event["ResourceStatus"].endswith("FAILED"): + if reason := event.get("ResourceStatusReason"): + reason = reason.replace("\n", "; ") + LOG.log( + level, + "CFn resource failed to deploy: %s (%s)", + event["LogicalResourceId"], + reason, + ) + else: + LOG.warning("CFn resource failed to deploy: %s", event["LogicalResourceId"]) + + def set_time_attribute(self, attribute, new_time=None): + self.metadata[attribute] = new_time or timestamp_millis() + + def add_stack_event( + self, + resource_id: str = None, + physical_res_id: str = None, + status: str = "", + status_reason: str = "", + ): + resource_id = resource_id or self.stack_name + physical_res_id = physical_res_id or self.stack_id + resource_type = ( + self.template.get("Resources", {}) + .get(resource_id, {}) + .get("Type", "AWS::CloudFormation::Stack") + ) + + event = { + "EventId": long_uid(), + "Timestamp": timestamp_millis(), + "StackId": self.stack_id, + "StackName": self.stack_name, + "LogicalResourceId": resource_id, + "PhysicalResourceId": physical_res_id, + "ResourceStatus": status, + "ResourceType": resource_type, + } + + if status_reason: + event["ResourceStatusReason"] = status_reason + + self.events.insert(0, event) + + def set_resource_status(self, resource_id: str, status: str, status_reason: str = ""): + """Update the deployment status of the given resource ID and publish a corresponding stack event.""" + physical_res_id = self.resources.get(resource_id, {}).get("PhysicalResourceId") + self._set_resource_status_details(resource_id, physical_res_id=physical_res_id) + state = self.resource_states.setdefault(resource_id, {}) + state["PreviousResourceStatus"] = state.get("ResourceStatus") + state["ResourceStatus"] = status + state["LastUpdatedTimestamp"] = timestamp_millis() + self.add_stack_event(resource_id, physical_res_id, status, status_reason=status_reason) + + def _set_resource_status_details(self, resource_id: str, physical_res_id: str = None): + """Helper function to ensure that the status details for the given resource ID are up-to-date.""" + resource = self.resources.get(resource_id) + if resource is None or resource.get("Type") == "Parameter": + # make sure we delete the states for any non-existing/deleted resources + self._resource_states.pop(resource_id, None) + return + state = self._resource_states.setdefault(resource_id, {}) + attr_defaults = ( + ("LogicalResourceId", resource_id), + ("PhysicalResourceId", physical_res_id), + ) + for res in [resource, state]: + for attr, default in attr_defaults: + res[attr] = res.get(attr) or default + state["StackName"] = state.get("StackName") or self.stack_name + state["StackId"] = state.get("StackId") or self.stack_id + state["ResourceType"] = state.get("ResourceType") or self.resources[resource_id].get("Type") + state["Timestamp"] = timestamp_millis() + return state + + def resource_status(self, resource_id: str): + result = self._lookup(self.resource_states, resource_id) + return result + + def latest_template_raw(self): + if self.change_sets: + return self.change_sets[-1]._template_raw + return self._template_raw + + @property + def resource_states(self): + for resource_id in list(self._resource_states.keys()): + self._set_resource_status_details(resource_id) + return self._resource_states + + @property + def stack_name(self): + return self.metadata["StackName"] + + @property + def stack_id(self): + return self.metadata["StackId"] + + @property + def resources(self): + """Return dict of resources""" + return dict(self.template_resources) + + @resources.setter + def resources(self, resources: dict): + self.template["Resources"] = resources + + @property + def template_resources(self): + return self.template.setdefault("Resources", {}) + + @property + def tags(self): + return self.metadata.get("Tags", []) + + @property + def imports(self): + def _collect(o, **kwargs): + if isinstance(o, dict): + import_val = o.get("Fn::ImportValue") + if import_val: + result.add(import_val) + return o + + result = set() + recurse_object(self.resources, _collect) + return result + + @property + def template_parameters(self): + return self.template["Parameters"] + + @property + def conditions(self): + """Returns the (mutable) dict of stack conditions.""" + return self.template.setdefault("Conditions", {}) + + @property + def mappings(self): + """Returns the (mutable) dict of stack mappings.""" + return self.template.setdefault("Mappings", {}) + + @property + def outputs(self): + """Returns the (mutable) dict of stack outputs.""" + return self.template.setdefault("Outputs", {}) + + @property + def status(self): + return self.metadata["StackStatus"] + + @property + def resource_types(self): + return [r.get("Type") for r in self.template_resources.values()] + + def resource(self, resource_id): + return self._lookup(self.resources, resource_id) + + def _lookup(self, resource_map, resource_id): + resource = resource_map.get(resource_id) + if not resource: + raise Exception( + 'Unable to find details for resource "%s" in stack "%s"' + % (resource_id, self.stack_name) + ) + return resource + + def copy(self): + return Stack( + account_id=self.account_id, + region_name=self.region_name, + metadata=dict(self.metadata), + template=dict(self.template), + ) + + +# FIXME: remove inheritance +# TODO: what functionality of the Stack object do we rely on here? +class StackChangeSet(Stack): + update_graph: NodeTemplate | None + change_set_type: ChangeSetType | None + + def __init__( + self, + account_id: str, + region_name: str, + stack: Stack, + params=None, + template=None, + change_set_type: ChangeSetType | None = None, + ): + if template is None: + template = {} + if params is None: + params = {} + super(StackChangeSet, self).__init__(account_id, region_name, params, template) + + name = self.metadata["ChangeSetName"] + if not self.metadata.get("ChangeSetId"): + self.metadata["ChangeSetId"] = arns.cloudformation_change_set_arn( + name, change_set_id=short_uid(), account_id=account_id, region_name=region_name + ) + + self.account_id = account_id + self.region_name = region_name + self.stack = stack + self.metadata["StackId"] = stack.stack_id + self.metadata["Status"] = "CREATE_PENDING" + self.change_set_type = change_set_type + + @property + def change_set_id(self): + return self.metadata["ChangeSetId"] + + @property + def change_set_name(self): + return self.metadata["ChangeSetName"] + + @property + def resources(self): + return dict(self.stack.resources) + + @property + def changes(self): + result = self.metadata["Changes"] = self.metadata.get("Changes", []) + return result + + # V2 only + def populate_update_graph( + self, + before_template: Optional[dict], + after_template: Optional[dict], + before_parameters: Optional[dict], + after_parameters: Optional[dict], + ) -> None: + change_set_model = ChangeSetModel( + before_template=before_template, + after_template=after_template, + before_parameters=before_parameters, + after_parameters=after_parameters, + ) + self.update_graph = change_set_model.get_update_model() diff --git a/localstack-core/localstack/services/cloudformation/engine/errors.py b/localstack-core/localstack/services/cloudformation/engine/errors.py new file mode 100644 index 0000000000000..0ee44f3530e58 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/errors.py @@ -0,0 +1,4 @@ +class TemplateError(RuntimeError): + """ + Error thrown on a programming error from the user + """ diff --git a/localstack-core/localstack/services/cloudformation/engine/parameters.py b/localstack-core/localstack/services/cloudformation/engine/parameters.py new file mode 100644 index 0000000000000..ba39fafc40db2 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/parameters.py @@ -0,0 +1,209 @@ +""" +TODO: ordering & grouping of parameters +TODO: design proper structure for parameters to facilitate validation etc. +TODO: clearer language around both parameters and "resolving" + +Documentation extracted from AWS docs (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html): + The following requirements apply when using parameters: + + You can have a maximum of 200 parameters in an AWS CloudFormation template. + Each parameter must be given a logical name (also called logical ID), which must be alphanumeric and unique among all logical names within the template. + Each parameter must be assigned a parameter type that is supported by AWS CloudFormation. For more information, see Type. + Each parameter must be assigned a value at runtime for AWS CloudFormation to successfully provision the stack. You can optionally specify a default value for AWS CloudFormation to use unless another value is provided. + Parameters must be declared and referenced from within the same template. You can reference parameters from the Resources and Outputs sections of the template. + + When you create or update stacks and create change sets, AWS CloudFormation uses whatever values exist in Parameter Store at the time the operation is run. If a specified parameter doesn't exist in Parameter Store under the caller's AWS account, AWS CloudFormation returns a validation error. + + For stack updates, the Use existing value option in the console and the UsePreviousValue attribute for update-stack tell AWS CloudFormation to use the existing Systems Manager parameter keyβ€”not its value. AWS CloudFormation always fetches the latest values from Parameter Store when it updates stacks. + +""" + +import logging +from typing import Literal, Optional, TypedDict + +from botocore.exceptions import ClientError + +from localstack.aws.api.cloudformation import Parameter, ParameterDeclaration +from localstack.aws.connect import connect_to + +LOG = logging.getLogger(__name__) + + +def extract_stack_parameter_declarations(template: dict) -> dict[str, ParameterDeclaration]: + """ + Extract and build a dict of stack parameter declarations from a CloudFormation stack templatef + + :param template: the parsed CloudFormation stack template + :return: a dictionary of declared parameters, mapping logical IDs to the corresponding parameter declaration + """ + result = {} + for param_key, param in template.get("Parameters", {}).items(): + result[param_key] = ParameterDeclaration( + ParameterKey=param_key, + DefaultValue=param.get("Default"), + ParameterType=param.get("Type"), + NoEcho=param.get("NoEcho", False), + # TODO: test & implement rest here + # ParameterConstraints=?, + # Description=? + ) + return result + + +class StackParameter(Parameter): + # we need the type information downstream when actually using the resolved value + # e.g. in case of lists so that we know that we should interpret the string as a comma-separated list. + ParameterType: str + + +def resolve_parameters( + account_id: str, + region_name: str, + parameter_declarations: dict[str, ParameterDeclaration], + new_parameters: dict[str, Parameter], + old_parameters: dict[str, Parameter], +) -> dict[str, StackParameter]: + """ + Resolves stack parameters or raises an exception if any parameter can not be resolved. + + Assumptions: + - There are no extra undeclared parameters given (validate before calling this method) + + TODO: is UsePreviousValue=False equivalent to not specifying it, in all situations? + + :param parameter_declarations: The parameter declaration from the (potentially new) template, i.e. the "Parameters" section + :param new_parameters: The parameters to resolve + :param old_parameters: The old parameters from the previous stack deployment, if available + :return: a copy of new_parameters with resolved values + """ + resolved_parameters = dict() + + # populate values for every parameter declared in the template + for pm in parameter_declarations.values(): + pm_key = pm["ParameterKey"] + resolved_param = StackParameter(ParameterKey=pm_key, ParameterType=pm["ParameterType"]) + new_parameter = new_parameters.get(pm_key) + old_parameter = old_parameters.get(pm_key) + + if new_parameter is None: + # since no value has been specified for the deployment, we need to be able to resolve the default or fail + default_value = pm["DefaultValue"] + if default_value is None: + LOG.error("New parameter without a default value: %s", pm_key) + raise Exception( + f"Invalid. Parameter '{pm_key}' needs to have either param specified or Default." + ) # TODO: test and verify + + resolved_param["ParameterValue"] = default_value + else: + if ( + new_parameter.get("UsePreviousValue", False) + and new_parameter.get("ParameterValue") is not None + ): + raise Exception( + f"Can't set both 'UsePreviousValue' and a concrete value for parameter '{pm_key}'." + ) # TODO: test and verify + + if new_parameter.get("UsePreviousValue", False): + if old_parameter is None: + raise Exception( + f"Set 'UsePreviousValue' but stack has no previous value for parameter '{pm_key}'." + ) # TODO: test and verify + + resolved_param["ParameterValue"] = old_parameter["ParameterValue"] + else: + resolved_param["ParameterValue"] = new_parameter["ParameterValue"] + + resolved_param["NoEcho"] = pm.get("NoEcho", False) + resolved_parameters[pm_key] = resolved_param + + # Note that SSM parameters always need to be resolved anew here + # TODO: support more parameter types + if pm["ParameterType"].startswith("AWS::SSM"): + if pm["ParameterType"] in [ + "AWS::SSM::Parameter::Value", + "AWS::SSM::Parameter::Value", + "AWS::SSM::Parameter::Value", + ]: + # TODO: error handling (e.g. no permission to lookup SSM parameter or SSM parameter doesn't exist) + resolved_param["ResolvedValue"] = resolve_ssm_parameter( + account_id, region_name, resolved_param["ParameterValue"] + ) + else: + raise Exception(f"Unsupported stack parameter type: {pm['ParameterType']}") + + return resolved_parameters + + +# TODO: inject credentials / client factory for proper account/region lookup +def resolve_ssm_parameter(account_id: str, region_name: str, stack_parameter_value: str) -> str: + """ + Resolve the SSM stack parameter from the SSM service with a name equal to the stack parameter value. + """ + ssm_client = connect_to(aws_access_key_id=account_id, region_name=region_name).ssm + try: + return ssm_client.get_parameter(Name=stack_parameter_value)["Parameter"]["Value"] + except ClientError: + LOG.error("client error fetching parameter '%s'", stack_parameter_value) + raise + + +def strip_parameter_type(in_param: StackParameter) -> Parameter: + result = in_param.copy() + result.pop("ParameterType", None) + return result + + +def mask_no_echo(in_param: StackParameter) -> Parameter: + result = in_param.copy() + no_echo = result.pop("NoEcho", False) + if no_echo: + result["ParameterValue"] = "****" + return result + + +def convert_stack_parameters_to_list( + in_params: dict[str, StackParameter] | None, +) -> list[StackParameter]: + if not in_params: + return [] + return list(in_params.values()) + + +def convert_stack_parameters_to_dict(in_params: list[Parameter] | None) -> dict[str, Parameter]: + if not in_params: + return {} + return {p["ParameterKey"]: p for p in in_params} + + +class LegacyParameterProperties(TypedDict): + Value: str + ParameterType: str + ParameterValue: Optional[str] + ResolvedValue: Optional[str] + + +class LegacyParameter(TypedDict): + LogicalResourceId: str + Type: Literal["Parameter"] + Properties: LegacyParameterProperties + + +# TODO: not actually parameter_type but the logical "ID" +def map_to_legacy_structure(parameter_name: str, new_parameter: StackParameter) -> LegacyParameter: + """ + Helper util to convert a normal (resolved) stack parameter to a legacy parameter structure that can then be merged with stack resources. + + :param new_parameter: a resolved stack parameter + :return: legacy parameter that can be merged with stack resources for uniform lookup based on logical ID + """ + return LegacyParameter( + LogicalResourceId=new_parameter["ParameterKey"], + Type="Parameter", + Properties=LegacyParameterProperties( + ParameterType=new_parameter.get("ParameterType"), + ParameterValue=new_parameter.get("ParameterValue"), + ResolvedValue=new_parameter.get("ResolvedValue"), + Value=new_parameter.get("ResolvedValue", new_parameter.get("ParameterValue")), + ), + ) diff --git a/localstack-core/localstack/services/cloudformation/engine/policy_loader.py b/localstack-core/localstack/services/cloudformation/engine/policy_loader.py new file mode 100644 index 0000000000000..8f3d11be79244 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/policy_loader.py @@ -0,0 +1,18 @@ +import logging + +from samtranslator.translator.managed_policy_translator import ManagedPolicyLoader + +from localstack.aws.connect import connect_to + +LOG = logging.getLogger(__name__) + + +policy_loader = None + + +def create_policy_loader() -> ManagedPolicyLoader: + global policy_loader + if not policy_loader: + iam_client = connect_to().iam + policy_loader = ManagedPolicyLoader(iam_client=iam_client) + return policy_loader diff --git a/localstack-core/localstack/services/cloudformation/engine/quirks.py b/localstack-core/localstack/services/cloudformation/engine/quirks.py new file mode 100644 index 0000000000000..964d5b603d960 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/quirks.py @@ -0,0 +1,68 @@ +""" +We can't always automatically determine which value serves as the physical resource ID. +=> This needs to be determined manually by testing against AWS (!) + +There's also a reason that the mapping is located here instead of closer to the resource providers themselves. +If the resources were compliant with the generic AWS resource provider framework that AWS provides for your own resource types, we wouldn't need this. +For legacy resources (and even some of the ones where they are open-sourced), AWS still has a layer of "secret sauce" that defines what the actual physical resource ID is. +An extension schema only defines the primary identifiers but not directly the physical resource ID that is generated based on those. +Since this is therefore rather part of the cloudformation layer and *not* the resource providers responsibility, we've put the mapping closer to the cloudformation engine. +""" + +# note: format here is subject to change (e.g. it might not be a pure str -> str mapping, it could also involve more sophisticated handlers +PHYSICAL_RESOURCE_ID_SPECIAL_CASES = { + "AWS::ApiGateway::Authorizer": "/properties/AuthorizerId", + "AWS::ApiGateway::RequestValidator": "/properties/RequestValidatorId", + "AWS::ApiGatewayV2::Authorizer": "/properties/AuthorizerId", + "AWS::ApiGatewayV2::Deployment": "/properties/DeploymentId", + "AWS::ApiGatewayV2::IntegrationResponse": "/properties/IntegrationResponseId", + "AWS::ApiGatewayV2::Route": "/properties/RouteId", + "AWS::ApiGateway::BasePathMapping": "/properties/RestApiId", + "AWS::ApiGateway::Deployment": "/properties/DeploymentId", + "AWS::ApiGateway::Model": "/properties/Name", + "AWS::ApiGateway::Resource": "/properties/ResourceId", + "AWS::ApiGateway::Stage": "/properties/StageName", + "AWS::Cognito::UserPoolClient": "/properties/ClientId", + "AWS::ECS::Service": "/properties/ServiceArn", + "AWS::EKS::FargateProfile": "|", # composite + "AWS::Events::EventBus": "/properties/Name", + "AWS::Logs::LogStream": "/properties/LogStreamName", + "AWS::Logs::SubscriptionFilter": "/properties/LogGroupName", + "AWS::RDS::DBProxyTargetGroup": "/properties/TargetGroupName", + "AWS::Glue::SchemaVersionMetadata": "||", # composite + "AWS::VerifiedPermissions::IdentitySource": "|", # composite + "AWS::VerifiedPermissions::Policy": "|", # composite + "AWS::VerifiedPermissions::PolicyTemplate": "|", # composite + "AWS::WAFv2::WebACL": "||", + "AWS::WAFv2::WebACLAssociation": "|", + "AWS::WAFv2::IPSet": "||", + # composite +} + +# You can usually find the available GetAtt targets in the official resource documentation: +# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html +# Use the scaffolded exploration test to verify against AWS which attributes you can access. +# This mapping is not in use yet (!) +VALID_GETATT_PROPERTIES = { + # Other Examples + # "AWS::ApiGateway::Resource": ["ResourceId"], + # "AWS::IAM::User": ["Arn"], # TODO: not validated yet + "AWS::SSM::Parameter": ["Type", "Value"], # TODO: not validated yet + # "AWS::OpenSearchService::Domain": [ + # "AdvancedSecurityOptions.AnonymousAuthDisableDate", + # "Arn", + # "DomainArn", + # "DomainEndpoint", + # "DomainEndpoints", + # "Id", + # "ServiceSoftwareOptions", + # "ServiceSoftwareOptions.AutomatedUpdateDate", + # "ServiceSoftwareOptions.Cancellable", + # "ServiceSoftwareOptions.CurrentVersion", + # "ServiceSoftwareOptions.Description", + # "ServiceSoftwareOptions.NewVersion", + # "ServiceSoftwareOptions.OptionalDeployment", + # "ServiceSoftwareOptions.UpdateAvailable", + # "ServiceSoftwareOptions.UpdateStatus", + # ], # TODO: not validated yet +} diff --git a/localstack-core/localstack/services/cloudformation/engine/resource_ordering.py b/localstack-core/localstack/services/cloudformation/engine/resource_ordering.py new file mode 100644 index 0000000000000..f65f57093ed50 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/resource_ordering.py @@ -0,0 +1,109 @@ +from collections import OrderedDict + +from localstack.services.cloudformation.engine.changes import ChangeConfig +from localstack.services.cloudformation.engine.parameters import StackParameter +from localstack.services.cloudformation.engine.template_utils import get_deps_for_resource + + +class NoResourceInStack(ValueError): + """Raised when we preprocess the template and do not find a resource""" + + def __init__(self, logical_resource_id: str): + msg = f"Template format error: Unresolved resource dependencies [{logical_resource_id}] in the Resources block of the template" + + super().__init__(msg) + + +def order_resources( + resources: dict, + resolved_parameters: dict[str, StackParameter], + resolved_conditions: dict[str, bool], + reverse: bool = False, +) -> OrderedDict: + """ + Given a dictionary of resources, topologically sort the resources based on + inter-resource dependencies (e.g. usages of intrinsic functions). + """ + nodes: dict[str, list[str]] = {} + for logical_resource_id, properties in resources.items(): + nodes.setdefault(logical_resource_id, []) + deps = get_deps_for_resource(properties, resolved_conditions) + for dep in deps: + if dep in resolved_parameters: + # we only care about other resources + continue + nodes.setdefault(dep, []) + nodes[dep].append(logical_resource_id) + + # implementation from https://dev.to/leopfeiffer/topological-sort-with-kahns-algorithm-3dl1 + indegrees = dict.fromkeys(nodes.keys(), 0) + for dependencies in nodes.values(): + for dependency in dependencies: + indegrees[dependency] += 1 + + # Place all elements with indegree 0 in queue + queue = [k for k in nodes.keys() if indegrees[k] == 0] + + sorted_logical_resource_ids = [] + + # Continue until all nodes have been dealt with + while len(queue) > 0: + # node of current iteration is the first one from the queue + curr = queue.pop(0) + sorted_logical_resource_ids.append(curr) + + # remove the current node from other dependencies + for dependency in nodes[curr]: + indegrees[dependency] -= 1 + + if indegrees[dependency] == 0: + queue.append(dependency) + + # check for circular dependencies + if len(sorted_logical_resource_ids) != len(nodes): + raise Exception("Circular dependency found.") + + sorted_mapping = [] + for logical_resource_id in sorted_logical_resource_ids: + if properties := resources.get(logical_resource_id): + sorted_mapping.append((logical_resource_id, properties)) + else: + if ( + logical_resource_id not in resolved_parameters + and logical_resource_id not in resolved_conditions + ): + raise NoResourceInStack(logical_resource_id) + + if reverse: + sorted_mapping = sorted_mapping[::-1] + return OrderedDict(sorted_mapping) + + +def order_changes( + given_changes: list[ChangeConfig], + resources: dict, + resolved_parameters: dict[str, StackParameter], + # TODO: remove resolved conditions somehow + resolved_conditions: dict[str, bool], + reverse: bool = False, +) -> list[ChangeConfig]: + """ + Given a list of changes, a dictionary of resources and a dictionary of resolved conditions, topologically sort the + changes based on inter-resource dependencies (e.g. usages of intrinsic functions). + """ + ordered_resources = order_resources( + resources=resources, + resolved_parameters=resolved_parameters, + resolved_conditions=resolved_conditions, + reverse=reverse, + ) + sorted_changes = [] + for logical_resource_id in ordered_resources.keys(): + for change in given_changes: + if change["ResourceChange"]["LogicalResourceId"] == logical_resource_id: + sorted_changes.append(change) + break + assert len(sorted_changes) > 0 + if reverse: + sorted_changes = sorted_changes[::-1] + return sorted_changes diff --git a/localstack-core/localstack/services/cloudformation/engine/schema.py b/localstack-core/localstack/services/cloudformation/engine/schema.py new file mode 100644 index 0000000000000..1a8e3d0a9d402 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/schema.py @@ -0,0 +1,15 @@ +import json +import os +import zipfile + + +# TODO: unify with scaffolding +class SchemaProvider: + def __init__(self, zipfile_path: str | os.PathLike[str]): + self.schemas = {} + with zipfile.ZipFile(zipfile_path) as infile: + for filename in infile.namelist(): + with infile.open(filename) as schema_file: + schema = json.load(schema_file) + typename = schema["typeName"] + self.schemas[typename] = schema diff --git a/localstack-core/localstack/services/cloudformation/engine/template_deployer.py b/localstack-core/localstack/services/cloudformation/engine/template_deployer.py new file mode 100644 index 0000000000000..e3a0802c54bed --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/template_deployer.py @@ -0,0 +1,1633 @@ +import base64 +import json +import logging +import re +import traceback +import uuid +from typing import Optional + +from botocore.exceptions import ClientError + +from localstack import config +from localstack.aws.connect import connect_to +from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY +from localstack.services.cloudformation.analytics import track_resource_operation +from localstack.services.cloudformation.deployment_utils import ( + PLACEHOLDER_AWS_NO_VALUE, + get_action_name_for_resource_change, + log_not_available_message, + remove_none_values, +) +from localstack.services.cloudformation.engine.changes import ChangeConfig, ResourceChange +from localstack.services.cloudformation.engine.entities import Stack, StackChangeSet +from localstack.services.cloudformation.engine.parameters import StackParameter +from localstack.services.cloudformation.engine.quirks import VALID_GETATT_PROPERTIES +from localstack.services.cloudformation.engine.resource_ordering import ( + order_changes, + order_resources, +) +from localstack.services.cloudformation.engine.template_utils import ( + AWS_URL_SUFFIX, + fn_equals_type_conversion, + get_deps_for_resource, +) +from localstack.services.cloudformation.resource_provider import ( + Credentials, + NoResourceProvider, + OperationStatus, + ProgressEvent, + ResourceProviderExecutor, + ResourceProviderPayload, + get_resource_type, +) +from localstack.services.cloudformation.service_models import ( + DependencyNotYetSatisfied, +) +from localstack.services.cloudformation.stores import exports_map, find_stack +from localstack.utils.aws.arns import get_partition +from localstack.utils.functions import prevent_stack_overflow +from localstack.utils.json import clone_safe +from localstack.utils.strings import to_bytes, to_str +from localstack.utils.threads import start_worker_thread + +from localstack.services.cloudformation.models import * # noqa: F401, F403, isort:skip +from localstack.utils.urls import localstack_host + +ACTION_CREATE = "create" +ACTION_DELETE = "delete" + +REGEX_OUTPUT_APIGATEWAY = re.compile( + rf"^(https?://.+\.execute-api\.)(?:[^-]+-){{2,3}}\d\.(amazonaws\.com|{AWS_URL_SUFFIX})/?(.*)$" +) +REGEX_DYNAMIC_REF = re.compile("{{resolve:([^:]+):(.+)}}") + +LOG = logging.getLogger(__name__) + +# list of static attribute references to be replaced in {'Fn::Sub': '...'} strings +STATIC_REFS = ["AWS::Region", "AWS::Partition", "AWS::StackName", "AWS::AccountId"] + +# Mock value for unsupported type references +MOCK_REFERENCE = "unknown" + + +class NoStackUpdates(Exception): + """Exception indicating that no actions are to be performed in a stack update (which is not allowed)""" + + pass + + +# --------------------- +# CF TEMPLATE HANDLING +# --------------------- + + +def get_attr_from_model_instance( + resource: dict, + attribute_name: str, + resource_type: str, + resource_id: str, + attribute_sub_name: Optional[str] = None, +) -> str: + if resource["PhysicalResourceId"] == MOCK_REFERENCE: + LOG.warning( + "Attribute '%s' requested from unsupported resource with id %s", + attribute_name, + resource_id, + ) + return MOCK_REFERENCE + + properties = resource.get("Properties", {}) + # if there's no entry in VALID_GETATT_PROPERTIES for the resource type we still default to "open" and accept anything + valid_atts = VALID_GETATT_PROPERTIES.get(resource_type) + if valid_atts is not None and attribute_name not in valid_atts: + LOG.warning( + "Invalid attribute in Fn::GetAtt for %s: | %s.%s", + resource_type, + resource_id, + attribute_name, + ) + raise Exception( + f"Resource type {resource_type} does not support attribute {{{attribute_name}}}" + ) # TODO: check CFn behavior via snapshot + + attribute_candidate = properties.get(attribute_name) + if attribute_sub_name: + return attribute_candidate.get(attribute_sub_name) + if "." in attribute_name: + # was used for legacy, but keeping it since it might have to work for a custom resource as well + if attribute_candidate: + return attribute_candidate + + # some resources (e.g. ElastiCache) have their readOnly attributes defined as Aa.Bb but the property is named AaBb + if attribute_candidate := properties.get(attribute_name.replace(".", "")): + return attribute_candidate + + # accessing nested properties + parts = attribute_name.split(".") + attribute = properties + # TODO: the attribute fetching below is a temporary workaround for the dependency resolution. + # It is caused by trying to access the resource attribute that has not been deployed yet. + # This should be a hard error.β€œ + for part in parts: + if attribute is None: + return None + attribute = attribute.get(part) + return attribute + + # If we couldn't find the attribute, this is actually an irrecoverable error. + # After the resource has a state of CREATE_COMPLETE, all attributes should already be set. + # TODO: raise here instead + # if attribute_candidate is None: + # raise Exception( + # f"Failed to resolve attribute for Fn::GetAtt in {resource_type}: {resource_id}.{attribute_name}" + # ) # TODO: check CFn behavior via snapshot + return attribute_candidate + + +def resolve_ref( + account_id: str, + region_name: str, + stack_name: str, + resources: dict, + parameters: dict[str, StackParameter], + ref: str, +): + """ + ref always needs to be a static string + ref can be one of these: + 1. a pseudo-parameter (e.g. AWS::Region) + 2. a parameter + 3. the id of a resource (PhysicalResourceId + """ + # pseudo parameter + if ref == "AWS::Region": + return region_name + if ref == "AWS::Partition": + return get_partition(region_name) + if ref == "AWS::StackName": + return stack_name + if ref == "AWS::StackId": + stack = find_stack(account_id, region_name, stack_name) + if not stack: + raise ValueError(f"No stack {stack_name} found") + return stack.stack_id + if ref == "AWS::AccountId": + return account_id + if ref == "AWS::NoValue": + return PLACEHOLDER_AWS_NO_VALUE + if ref == "AWS::NotificationARNs": + # TODO! + return {} + if ref == "AWS::URLSuffix": + return AWS_URL_SUFFIX + + # parameter + if parameter := parameters.get(ref): + parameter_type: str = parameter["ParameterType"] + parameter_value = parameter.get("ResolvedValue") or parameter.get("ParameterValue") + + if "CommaDelimitedList" in parameter_type or parameter_type.startswith("List<"): + return [p.strip() for p in parameter_value.split(",")] + else: + return parameter_value + + # resource + resource = resources.get(ref) + if not resource: + raise Exception( + f"Resource target for `Ref {ref}` could not be found. Is there a resource with name {ref} in your stack?" + ) + + return resources[ref].get("PhysicalResourceId") + + +# Using a @prevent_stack_overflow decorator here to avoid infinite recursion +# in case we load stack exports that have circular dependencies (see issue 3438) +# TODO: Potentially think about a better approach in the future +@prevent_stack_overflow(match_parameters=True) +def resolve_refs_recursively( + account_id: str, + region_name: str, + stack_name: str, + resources: dict, + mappings: dict, + conditions: dict[str, bool], + parameters: dict, + value, +): + result = _resolve_refs_recursively( + account_id, region_name, stack_name, resources, mappings, conditions, parameters, value + ) + + # localstack specific patches + if isinstance(result, str): + # we're trying to filter constructed API urls here (e.g. via Join in the template) + api_match = REGEX_OUTPUT_APIGATEWAY.match(result) + if api_match and result in config.CFN_STRING_REPLACEMENT_DENY_LIST: + return result + elif api_match: + prefix = api_match[1] + host = api_match[2] + path = api_match[3] + port = localstack_host().port + return f"{prefix}{host}:{port}/{path}" + + # basic dynamic reference support + # see: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references.html + # technically there are more restrictions for each of these services but checking each of these + # isn't really necessary for the current level of emulation + dynamic_ref_match = REGEX_DYNAMIC_REF.match(result) + if dynamic_ref_match: + service_name = dynamic_ref_match[1] + reference_key = dynamic_ref_match[2] + + # only these 3 services are supported for dynamic references right now + if service_name == "ssm": + ssm_client = connect_to(aws_access_key_id=account_id, region_name=region_name).ssm + try: + return ssm_client.get_parameter(Name=reference_key)["Parameter"]["Value"] + except ClientError as e: + LOG.error("client error accessing SSM parameter '%s': %s", reference_key, e) + raise + elif service_name == "ssm-secure": + ssm_client = connect_to(aws_access_key_id=account_id, region_name=region_name).ssm + try: + return ssm_client.get_parameter(Name=reference_key, WithDecryption=True)[ + "Parameter" + ]["Value"] + except ClientError as e: + LOG.error("client error accessing SSM parameter '%s': %s", reference_key, e) + raise + elif service_name == "secretsmanager": + # reference key needs to be parsed further + # because {{resolve:secretsmanager:secret-id:secret-string:json-key:version-stage:version-id}} + # we match for "secret-id:secret-string:json-key:version-stage:version-id" + # where + # secret-id can either be the secret name or the full ARN of the secret + # secret-string *must* be SecretString + # all other values are optional + secret_id = reference_key + [json_key, version_stage, version_id] = [None, None, None] + if "SecretString" in reference_key: + parts = reference_key.split(":SecretString:") + secret_id = parts[0] + # json-key, version-stage and version-id are optional. + [json_key, version_stage, version_id] = f"{parts[1]}::".split(":")[:3] + + kwargs = {} # optional args for get_secret_value + if version_id: + kwargs["VersionId"] = version_id + if version_stage: + kwargs["VersionStage"] = version_stage + + secretsmanager_client = connect_to( + aws_access_key_id=account_id, region_name=region_name + ).secretsmanager + try: + secret_value = secretsmanager_client.get_secret_value( + SecretId=secret_id, **kwargs + )["SecretString"] + except ClientError: + LOG.error("client error while trying to access key '%s': %s", secret_id) + raise + + if json_key: + json_secret = json.loads(secret_value) + if json_key not in json_secret: + raise DependencyNotYetSatisfied( + resource_ids=secret_id, + message=f"Key {json_key} is not yet available in secret {secret_id}.", + ) + return json_secret[json_key] + else: + return secret_value + else: + LOG.warning( + "Unsupported service for dynamic parameter: service_name=%s", service_name + ) + + return result + + +@prevent_stack_overflow(match_parameters=True) +def _resolve_refs_recursively( + account_id: str, + region_name: str, + stack_name: str, + resources: dict, + mappings: dict, + conditions: dict, + parameters: dict, + value: dict | list | str | bytes | None, +): + if isinstance(value, dict): + keys_list = list(value.keys()) + stripped_fn_lower = keys_list[0].lower().split("::")[-1] if len(keys_list) == 1 else None + + # process special operators + if keys_list == ["Ref"]: + ref = resolve_ref( + account_id, region_name, stack_name, resources, parameters, value["Ref"] + ) + if ref is None: + msg = 'Unable to resolve Ref for resource "%s" (yet)' % value["Ref"] + LOG.debug("%s - %s", msg, resources.get(value["Ref"]) or set(resources.keys())) + + raise DependencyNotYetSatisfied(resource_ids=value["Ref"], message=msg) + + ref = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + ref, + ) + return ref + + if stripped_fn_lower == "getatt": + attr_ref = value[keys_list[0]] + attr_ref = attr_ref.split(".") if isinstance(attr_ref, str) else attr_ref + resource_logical_id = attr_ref[0] + attribute_name = attr_ref[1] + attribute_sub_name = attr_ref[2] if len(attr_ref) > 2 else None + + # the attribute name can be a Ref + attribute_name = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + attribute_name, + ) + resource = resources.get(resource_logical_id) + + resource_type = get_resource_type(resource) + resolved_getatt = get_attr_from_model_instance( + resource, + attribute_name, + resource_type, + resource_logical_id, + attribute_sub_name, + ) + + # TODO: we should check the deployment state and not try to GetAtt from a resource that is still IN_PROGRESS or hasn't started yet. + if resolved_getatt is None: + raise DependencyNotYetSatisfied( + resource_ids=resource_logical_id, + message=f"Could not resolve attribute '{attribute_name}' on resource '{resource_logical_id}'", + ) + + return resolved_getatt + + if stripped_fn_lower == "join": + join_values = value[keys_list[0]][1] + + # this can actually be another ref that produces a list as output + if isinstance(join_values, dict): + join_values = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + join_values, + ) + + # resolve reference in the items list + assert isinstance(join_values, list) + join_values = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + join_values, + ) + + none_values = [v for v in join_values if v is None] + if none_values: + LOG.warning( + "Cannot resolve Fn::Join '%s' due to null values: '%s'", value, join_values + ) + raise Exception( + f"Cannot resolve CF Fn::Join {value} due to null values: {join_values}" + ) + return value[keys_list[0]][0].join([str(v) for v in join_values]) + + if stripped_fn_lower == "sub": + item_to_sub = value[keys_list[0]] + + attr_refs = {r: {"Ref": r} for r in STATIC_REFS} + if not isinstance(item_to_sub, list): + item_to_sub = [item_to_sub, {}] + result = item_to_sub[0] + item_to_sub[1].update(attr_refs) + + for key, val in item_to_sub[1].items(): + resolved_val = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + val, + ) + + if isinstance(resolved_val, (list, dict, tuple)): + # We don't have access to the resource that's a dependency in this case, + # so do the best we can with the resource ids + raise DependencyNotYetSatisfied( + resource_ids=key, message=f"Could not resolve {val} to terminal value type" + ) + result = result.replace("${%s}" % key, str(resolved_val)) + + # resolve placeholders + result = resolve_placeholders_in_string( + account_id, + region_name, + result, + stack_name, + resources, + mappings, + conditions, + parameters, + ) + return result + + if stripped_fn_lower == "findinmap": + # "Fn::FindInMap" + mapping_id = value[keys_list[0]][0] + + if isinstance(mapping_id, dict) and "Ref" in mapping_id: + # TODO: ?? + mapping_id = resolve_ref( + account_id, region_name, stack_name, resources, parameters, mapping_id["Ref"] + ) + + selected_map = mappings.get(mapping_id) + if not selected_map: + raise Exception( + f"Cannot find Mapping with ID {mapping_id} for Fn::FindInMap: {value[keys_list[0]]} {list(resources.keys())}" + # TODO: verify + ) + + first_level_attribute = value[keys_list[0]][1] + first_level_attribute = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + first_level_attribute, + ) + + if first_level_attribute not in selected_map: + raise Exception( + f"Cannot find map key '{first_level_attribute}' in mapping '{mapping_id}'" + ) + first_level_mapping = selected_map[first_level_attribute] + + second_level_attribute = value[keys_list[0]][2] + if not isinstance(second_level_attribute, str): + second_level_attribute = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + second_level_attribute, + ) + if second_level_attribute not in first_level_mapping: + raise Exception( + f"Cannot find map key '{second_level_attribute}' in mapping '{mapping_id}' under key '{first_level_attribute}'" + ) + + return first_level_mapping[second_level_attribute] + + if stripped_fn_lower == "importvalue": + import_value_key = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + value[keys_list[0]], + ) + exports = exports_map(account_id, region_name) + stack_export = exports.get(import_value_key) or {} + if not stack_export.get("Value"): + LOG.info( + 'Unable to find export "%s" in stack "%s", existing export names: %s', + import_value_key, + stack_name, + list(exports.keys()), + ) + return None + return stack_export["Value"] + + if stripped_fn_lower == "if": + condition, option1, option2 = value[keys_list[0]] + condition = conditions.get(condition) + if condition is None: + LOG.warning( + "Cannot find condition '%s' in conditions mapping: '%s'", + condition, + conditions.keys(), + ) + raise KeyError( + f"Cannot find condition '{condition}' in conditions mapping: '{conditions.keys()}'" + ) + + result = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + option1 if condition else option2, + ) + return result + + if stripped_fn_lower == "condition": + # FIXME: this should only allow strings, no evaluation should be performed here + # see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-condition.html + key = value[keys_list[0]] + result = conditions.get(key) + if result is None: + LOG.warning("Cannot find key '%s' in conditions: '%s'", key, conditions.keys()) + raise KeyError(f"Cannot find key '{key}' in conditions: '{conditions.keys()}'") + return result + + if stripped_fn_lower == "not": + condition = value[keys_list[0]][0] + condition = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + condition, + ) + return not condition + + if stripped_fn_lower in ["and", "or"]: + conditions = value[keys_list[0]] + results = [ + resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + cond, + ) + for cond in conditions + ] + result = all(results) if stripped_fn_lower == "and" else any(results) + return result + + if stripped_fn_lower == "equals": + operand1, operand2 = value[keys_list[0]] + operand1 = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + operand1, + ) + operand2 = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + operand2, + ) + # TODO: investigate type coercion here + return fn_equals_type_conversion(operand1) == fn_equals_type_conversion(operand2) + + if stripped_fn_lower == "select": + index, values = value[keys_list[0]] + index = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + index, + ) + values = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + values, + ) + try: + return values[index] + except TypeError: + return values[int(index)] + + if stripped_fn_lower == "split": + delimiter, string = value[keys_list[0]] + delimiter = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + delimiter, + ) + string = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + string, + ) + return string.split(delimiter) + + if stripped_fn_lower == "getazs": + region = ( + resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + value["Fn::GetAZs"], + ) + or region_name + ) + + ec2_client = connect_to(aws_access_key_id=account_id, region_name=region).ec2 + try: + get_availability_zones = ec2_client.describe_availability_zones()[ + "AvailabilityZones" + ] + except ClientError: + LOG.error("client error describing availability zones") + raise + + azs = [az["ZoneName"] for az in get_availability_zones] + + return azs + + if stripped_fn_lower == "base64": + value_to_encode = value[keys_list[0]] + value_to_encode = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + value_to_encode, + ) + return to_str(base64.b64encode(to_bytes(value_to_encode))) + + for key, val in dict(value).items(): + value[key] = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + val, + ) + + if isinstance(value, list): + # in some cases, intrinsic functions are passed in as, e.g., `[['Fn::Sub', '${MyRef}']]` + if len(value) == 1 and isinstance(value[0], list) and len(value[0]) == 2: + inner_list = value[0] + if str(inner_list[0]).lower().startswith("fn::"): + return resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + {inner_list[0]: inner_list[1]}, + ) + + # remove _aws_no_value_ from resulting references + clean_list = [] + for item in value: + temp_value = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + item, + ) + if not (isinstance(temp_value, str) and temp_value == PLACEHOLDER_AWS_NO_VALUE): + clean_list.append(temp_value) + value = clean_list + + return value + + +def resolve_placeholders_in_string( + account_id: str, + region_name: str, + result, + stack_name: str, + resources: dict, + mappings: dict, + conditions: dict[str, bool], + parameters: dict, +): + """ + Resolve individual Fn::Sub variable replacements + + Variables can be template parameter names, resource logical IDs, resource attributes, or a variable in a key-value map + """ + + def _validate_result_type(value: str): + is_another_account_id = value.isdigit() and len(value) == len(account_id) + if value == account_id or is_another_account_id: + return value + + if value.isdigit(): + return int(value) + else: + try: + res = float(value) + return res + except ValueError: + return value + + def _replace(match): + ref_expression = match.group(1) + parts = ref_expression.split(".") + if len(parts) >= 2: + # Resource attributes specified => Use GetAtt to resolve + logical_resource_id, _, attr_name = ref_expression.partition(".") + resolved = get_attr_from_model_instance( + resources[logical_resource_id], + attr_name, + get_resource_type(resources[logical_resource_id]), + logical_resource_id, + ) + if resolved is None: + raise DependencyNotYetSatisfied( + resource_ids=logical_resource_id, + message=f"Unable to resolve attribute ref {ref_expression}", + ) + if not isinstance(resolved, str): + resolved = str(resolved) + return resolved + if len(parts) == 1: + if parts[0] in resources or parts[0].startswith("AWS::"): + # Logical resource ID or parameter name specified => Use Ref for lookup + result = resolve_ref( + account_id, region_name, stack_name, resources, parameters, parts[0] + ) + + if result is None: + raise DependencyNotYetSatisfied( + resource_ids=parts[0], + message=f"Unable to resolve attribute ref {ref_expression}", + ) + # TODO: is this valid? + # make sure we resolve any functions/placeholders in the extracted string + result = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + parameters, + result, + ) + # make sure we convert the result to string + # TODO: do this more systematically + result = "" if result is None else str(result) + return result + elif parts[0] in parameters: + parameter = parameters[parts[0]] + parameter_type: str = parameter["ParameterType"] + parameter_value = parameter.get("ResolvedValue") or parameter.get("ParameterValue") + + if parameter_type in ["CommaDelimitedList"] or parameter_type.startswith("List<"): + return [p.strip() for p in parameter_value.split(",")] + elif parameter_type == "Number": + return str(parameter_value) + else: + return parameter_value + else: + raise DependencyNotYetSatisfied( + resource_ids=parts[0], + message=f"Unable to resolve attribute ref {ref_expression}", + ) + # TODO raise exception here? + return match.group(0) + + regex = r"\$\{([^\}]+)\}" + result = re.sub(regex, _replace, result) + return _validate_result_type(result) + + +def evaluate_resource_condition(conditions: dict[str, bool], resource: dict) -> bool: + if condition := resource.get("Condition"): + return conditions.get(condition, True) + return True + + +# ----------------------- +# MAIN TEMPLATE DEPLOYER +# ----------------------- + + +class TemplateDeployer: + def __init__(self, account_id: str, region_name: str, stack): + self.stack = stack + self.account_id = account_id + self.region_name = region_name + + @property + def resources(self): + return self.stack.resources + + @property + def mappings(self): + return self.stack.mappings + + @property + def stack_name(self): + return self.stack.stack_name + + # ------------------ + # MAIN ENTRY POINTS + # ------------------ + + def deploy_stack(self): + self.stack.set_stack_status("CREATE_IN_PROGRESS") + try: + self.apply_changes( + self.stack, + self.stack, + initialize=True, + action="CREATE", + ) + except Exception as e: + log_method = LOG.info + if config.CFN_VERBOSE_ERRORS: + log_method = LOG.exception + log_method("Unable to create stack %s: %s", self.stack.stack_name, e) + self.stack.set_stack_status("CREATE_FAILED") + raise + + def apply_change_set(self, change_set: StackChangeSet): + action = ( + "UPDATE" + if change_set.stack.status in {"CREATE_COMPLETE", "UPDATE_COMPLETE"} + else "CREATE" + ) + change_set.stack.set_stack_status(f"{action}_IN_PROGRESS") + # update parameters on parent stack + change_set.stack.set_resolved_parameters(change_set.resolved_parameters) + # update conditions on parent stack + change_set.stack.set_resolved_stack_conditions(change_set.resolved_conditions) + + # update attributes that the stack inherits from the changeset + change_set.stack.metadata["Capabilities"] = change_set.metadata.get("Capabilities") + + try: + self.apply_changes( + change_set.stack, + change_set, + action=action, + ) + except Exception as e: + LOG.info( + "Unable to apply change set %s: %s", change_set.metadata.get("ChangeSetName"), e + ) + change_set.metadata["Status"] = f"{action}_FAILED" + self.stack.set_stack_status(f"{action}_FAILED") + raise + + def update_stack(self, new_stack): + self.stack.set_stack_status("UPDATE_IN_PROGRESS") + # apply changes + self.apply_changes(self.stack, new_stack, action="UPDATE") + self.stack.set_time_attribute("LastUpdatedTime") + + # ---------------------------- + # DEPENDENCY RESOLUTION UTILS + # ---------------------------- + + def is_deployed(self, resource): + return self.stack.resource_states.get(resource["LogicalResourceId"], {}).get( + "ResourceStatus" + ) in [ + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + ] + + def all_resource_dependencies_satisfied(self, resource) -> bool: + unsatisfied = self.get_unsatisfied_dependencies(resource) + return not unsatisfied + + def get_unsatisfied_dependencies(self, resource): + res_deps = self.get_resource_dependencies( + resource + ) # the output here is currently a set of merged IDs from both resources and parameters + parameter_deps = {d for d in res_deps if d in self.stack.resolved_parameters} + resource_deps = res_deps.difference(parameter_deps) + res_deps_mapped = {v: self.stack.resources.get(v) for v in resource_deps} + return self.get_unsatisfied_dependencies_for_resources(res_deps_mapped, resource) + + def get_unsatisfied_dependencies_for_resources( + self, resources, depending_resource=None, return_first=True + ): + result = {} + for resource_id, resource in resources.items(): + if not resource: + raise Exception( + f"Resource '{resource_id}' not found in stack {self.stack.stack_name}" + ) + if not self.is_deployed(resource): + LOG.debug( + "Dependency for resource %s not yet deployed: %s %s", + depending_resource, + resource_id, + resource, + ) + result[resource_id] = resource + if return_first: + break + return result + + def get_resource_dependencies(self, resource: dict) -> set[str]: + """ + Takes a resource and returns its dependencies on other resources via a str -> str mapping + """ + # Note: using the original, unmodified template here to preserve Ref's ... + raw_resources = self.stack.template_original["Resources"] + raw_resource = raw_resources[resource["LogicalResourceId"]] + return get_deps_for_resource(raw_resource, self.stack.resolved_conditions) + + # ----------------- + # DEPLOYMENT UTILS + # ----------------- + + def init_resource_status(self, resources=None, stack=None, action="CREATE"): + resources = resources or self.resources + stack = stack or self.stack + for resource_id, resource in resources.items(): + stack.set_resource_status(resource_id, f"{action}_IN_PROGRESS") + + def get_change_config( + self, action: str, resource: dict, change_set_id: Optional[str] = None + ) -> ChangeConfig: + result = ChangeConfig( + **{ + "Type": "Resource", + "ResourceChange": ResourceChange( + **{ + "Action": action, + # TODO(srw): how can the resource not contain a logical resource id? + "LogicalResourceId": resource.get("LogicalResourceId"), + "PhysicalResourceId": resource.get("PhysicalResourceId"), + "ResourceType": resource["Type"], + # TODO ChangeSetId is only set for *nested* change sets + # "ChangeSetId": change_set_id, + "Scope": [], # TODO + "Details": [], # TODO + } + ), + } + ) + if action == "Modify": + result["ResourceChange"]["Replacement"] = "False" + return result + + def resource_config_differs(self, resource_new): + """Return whether the given resource properties differ from the existing config (for stack updates).""" + # TODO: this is broken for default fields and result_handler property modifications when they're added to the properties in the model + resource_id = resource_new["LogicalResourceId"] + resource_old = self.resources[resource_id] + props_old = resource_old.get("SpecifiedProperties", {}) + props_new = resource_new["Properties"] + ignored_keys = ["LogicalResourceId", "PhysicalResourceId"] + old_keys = set(props_old.keys()) - set(ignored_keys) + new_keys = set(props_new.keys()) - set(ignored_keys) + if old_keys != new_keys: + return True + for key in old_keys: + if props_old[key] != props_new[key]: + return True + old_status = self.stack.resource_states.get(resource_id) or {} + previous_state = ( + old_status.get("PreviousResourceStatus") or old_status.get("ResourceStatus") or "" + ) + if old_status and "DELETE" in previous_state: + return True + + # TODO: ? + def merge_properties(self, resource_id: str, old_stack, new_stack) -> None: + old_resources = old_stack.template["Resources"] + new_resources = new_stack.template["Resources"] + new_resource = new_resources[resource_id] + + old_resource = old_resources[resource_id] = old_resources.get(resource_id) or {} + for key, value in new_resource.items(): + if key == "Properties": + continue + old_resource[key] = old_resource.get(key, value) + old_res_props = old_resource["Properties"] = old_resource.get("Properties", {}) + for key, value in new_resource["Properties"].items(): + old_res_props[key] = value + + old_res_props = { + k: v for k, v in old_res_props.items() if k in new_resource["Properties"].keys() + } + old_resource["Properties"] = old_res_props + + # overwrite original template entirely + old_stack.template_original["Resources"][resource_id] = new_stack.template_original[ + "Resources" + ][resource_id] + + def construct_changes( + self, + existing_stack, + new_stack, + # TODO: remove initialize argument from here, and determine action based on resource status + initialize: Optional[bool] = False, + change_set_id=None, + append_to_changeset: Optional[bool] = False, + filter_unchanged_resources: Optional[bool] = False, + ) -> list[ChangeConfig]: + old_resources = existing_stack.template["Resources"] + new_resources = new_stack.template["Resources"] + deletes = [val for key, val in old_resources.items() if key not in new_resources] + adds = [val for key, val in new_resources.items() if initialize or key not in old_resources] + modifies = [ + val for key, val in new_resources.items() if not initialize and key in old_resources + ] + + changes = [] + for action, items in (("Remove", deletes), ("Add", adds), ("Modify", modifies)): + for item in items: + item["Properties"] = item.get("Properties", {}) + if ( + not filter_unchanged_resources # TODO: find out purpose of this + or action != "Modify" + or self.resource_config_differs(item) + ): + change = self.get_change_config(action, item, change_set_id=change_set_id) + changes.append(change) + + # append changes to change set + if append_to_changeset and isinstance(new_stack, StackChangeSet): + new_stack.changes.extend(changes) + + return changes + + def apply_changes( + self, + existing_stack: Stack, + new_stack: StackChangeSet, + change_set_id: Optional[str] = None, + initialize: Optional[bool] = False, + action: Optional[str] = None, + ): + old_resources = existing_stack.template["Resources"] + new_resources = new_stack.template["Resources"] + action = action or "CREATE" + # TODO: this seems wrong, not every resource here will be in an UPDATE_IN_PROGRESS state? (only the ones that will actually be updated) + self.init_resource_status(old_resources, action="UPDATE") + + # apply parameter changes to existing stack + # self.apply_parameter_changes(existing_stack, new_stack) + + # construct changes + changes = self.construct_changes( + existing_stack, + new_stack, + initialize=initialize, + change_set_id=change_set_id, + ) + + # check if we have actual changes in the stack, and prepare properties + contains_changes = False + for change in changes: + res_action = change["ResourceChange"]["Action"] + resource = new_resources.get(change["ResourceChange"]["LogicalResourceId"]) + # FIXME: we need to resolve refs before diffing to detect if for example a parameter causes the change or not + # unfortunately this would currently cause issues because we might not be able to resolve everything yet + # resource = resolve_refs_recursively( + # self.stack_name, + # self.resources, + # self.mappings, + # self.stack.resolved_conditions, + # self.stack.resolved_parameters, + # resource, + # ) + if res_action in ["Add", "Remove"] or self.resource_config_differs(resource): + contains_changes = True + if res_action in ["Modify", "Add"]: + # mutating call that overwrites resource properties with new properties and overwrites the template in old stack with new template + self.merge_properties(resource["LogicalResourceId"], existing_stack, new_stack) + if not contains_changes: + raise NoStackUpdates("No updates are to be performed.") + + # merge stack outputs and conditions + existing_stack.outputs.update(new_stack.outputs) + existing_stack.conditions.update(new_stack.conditions) + + # TODO: ideally the entire template has to be replaced, but tricky at this point + existing_stack.template["Metadata"] = new_stack.template.get("Metadata") + existing_stack.template_body = new_stack.template_body + + # start deployment loop + return self.apply_changes_in_loop( + changes, existing_stack, action=action, new_stack=new_stack + ) + + def apply_changes_in_loop( + self, + changes: list[ChangeConfig], + stack: Stack, + action: Optional[str] = None, + new_stack=None, + ): + def _run(*args): + status_reason = None + try: + self.do_apply_changes_in_loop(changes, stack) + status = f"{action}_COMPLETE" + except Exception as e: + log_method = LOG.debug + if config.CFN_VERBOSE_ERRORS: + log_method = LOG.exception + log_method( + 'Error applying changes for CloudFormation stack "%s": %s %s', + stack.stack_name, + e, + traceback.format_exc(), + ) + status = f"{action}_FAILED" + status_reason = str(e) + stack.set_stack_status(status, status_reason) + if isinstance(new_stack, StackChangeSet): + new_stack.metadata["Status"] = status + exec_result = "EXECUTE_FAILED" if "FAILED" in status else "EXECUTE_COMPLETE" + new_stack.metadata["ExecutionStatus"] = exec_result + result = "failed" if "FAILED" in status else "succeeded" + new_stack.metadata["StatusReason"] = status_reason or f"Deployment {result}" + + # run deployment in background loop, to avoid client network timeouts + return start_worker_thread(_run) + + def prepare_should_deploy_change( + self, resource_id: str, change: ResourceChange, stack, new_resources: dict + ) -> bool: + """ + TODO: document + """ + resource = new_resources[resource_id] + res_change = change["ResourceChange"] + action = res_change["Action"] + + # check resource condition, if present + if not evaluate_resource_condition(stack.resolved_conditions, resource): + LOG.debug( + 'Skipping deployment of "%s", as resource condition evaluates to false', resource_id + ) + return False + + # resolve refs in resource details + resolve_refs_recursively( + self.account_id, + self.region_name, + stack.stack_name, + stack.resources, + stack.mappings, + stack.resolved_conditions, + stack.resolved_parameters, + resource, + ) + + if action in ["Add", "Modify"]: + is_deployed = self.is_deployed(resource) + # TODO: Attaching the cached _deployed info here, as we should not change the "Add"/"Modify" attribute + # here, which is used further down the line to determine the resource action CREATE/UPDATE. This is a + # temporary workaround for now - to be refactored once we introduce proper stack resource state models. + res_change["_deployed"] = is_deployed + if not is_deployed: + return True + if action == "Add": + return False + elif action == "Remove": + return True + return True + + # Stack is needed here + def apply_change(self, change: ChangeConfig, stack: Stack) -> None: + change_details = change["ResourceChange"] + action = change_details["Action"] + resource_id = change_details["LogicalResourceId"] + resources = stack.resources + resource = resources[resource_id] + + # TODO: this should not be needed as resources are filtered out if the + # condition evaluates to False. + if not evaluate_resource_condition(stack.resolved_conditions, resource): + return + + # remove AWS::NoValue entries + resource_props = resource.get("Properties") + if resource_props: + resource["Properties"] = remove_none_values(resource_props) + + executor = self.create_resource_provider_executor() + resource_provider_payload = self.create_resource_provider_payload( + action, logical_resource_id=resource_id + ) + + resource_type = get_resource_type(resource) + resource_provider = executor.try_load_resource_provider(resource_type) + track_resource_operation(action, resource_type, missing=resource_provider is None) + if resource_provider is not None: + # add in-progress event + resource_status = f"{get_action_name_for_resource_change(action)}_IN_PROGRESS" + physical_resource_id = None + if action in ("Modify", "Remove"): + previous_state = self.resources[resource_id].get("_last_deployed_state") + if not previous_state: + # TODO: can this happen? + previous_state = self.resources[resource_id]["Properties"] + physical_resource_id = executor.extract_physical_resource_id_from_model_with_schema( + resource_model=previous_state, + resource_type=resource["Type"], + resource_type_schema=resource_provider.SCHEMA, + ) + stack.add_stack_event( + resource_id=resource_id, + physical_res_id=physical_resource_id, + status=resource_status, + ) + + # perform the deploy + progress_event = executor.deploy_loop( + resource_provider, resource, resource_provider_payload + ) + else: + # track that we don't handle the resource, and possibly raise an exception + log_not_available_message( + resource_type, + f'No resource provider found for "{resource_type}"', + ) + + if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES: + raise NoResourceProvider + + resource["PhysicalResourceId"] = MOCK_REFERENCE + progress_event = ProgressEvent(OperationStatus.SUCCESS, resource_model={}) + + # TODO: clean up the surrounding loop (do_apply_changes_in_loop) so that the responsibilities are clearer + stack_action = get_action_name_for_resource_change(action) + match progress_event.status: + case OperationStatus.FAILED: + stack.set_resource_status( + resource_id, + f"{stack_action}_FAILED", + status_reason=progress_event.message or "", + ) + # TODO: remove exception raising here? + # TODO: fix request token + raise Exception( + f'Resource handler returned message: "{progress_event.message}" (RequestToken: 10c10335-276a-33d3-5c07-018b684c3d26, HandlerErrorCode: InvalidRequest){progress_event.error_code}' + ) + case OperationStatus.SUCCESS: + stack.set_resource_status(resource_id, f"{stack_action}_COMPLETE") + case OperationStatus.PENDING: + # signal to the main loop that we should come back to this resource in the future + raise DependencyNotYetSatisfied( + resource_ids=[], message="Resource dependencies not yet satisfied" + ) + case OperationStatus.IN_PROGRESS: + raise Exception("Resource deployment loop should not finish in this state") + case unknown_status: + raise Exception(f"Unknown operation status: {unknown_status}") + + # TODO: this is probably already done in executor, try removing this + resource["Properties"] = progress_event.resource_model + + def create_resource_provider_executor(self) -> ResourceProviderExecutor: + return ResourceProviderExecutor( + stack_name=self.stack.stack_name, + stack_id=self.stack.stack_id, + ) + + def create_resource_provider_payload( + self, action: str, logical_resource_id: str + ) -> ResourceProviderPayload: + # FIXME: use proper credentials + creds: Credentials = { + "accessKeyId": self.account_id, + "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY, + "sessionToken": "", + } + resource = self.resources[logical_resource_id] + + resource_provider_payload: ResourceProviderPayload = { + "awsAccountId": self.account_id, + "callbackContext": {}, + "stackId": self.stack.stack_name, + "resourceType": resource["Type"], + "resourceTypeVersion": "000000", + # TODO: not actually a UUID + "bearerToken": str(uuid.uuid4()), + "region": self.region_name, + "action": action, + "requestData": { + "logicalResourceId": logical_resource_id, + "resourceProperties": resource["Properties"], + "previousResourceProperties": resource.get("_last_deployed_state"), # TODO + "callerCredentials": creds, + "providerCredentials": creds, + "systemTags": {}, + "previousSystemTags": {}, + "stackTags": {}, + "previousStackTags": {}, + }, + } + return resource_provider_payload + + def delete_stack(self): + if not self.stack: + return + self.stack.set_stack_status("DELETE_IN_PROGRESS") + stack_resources = list(self.stack.resources.values()) + resources = {r["LogicalResourceId"]: clone_safe(r) for r in stack_resources} + original_resources = self.stack.template_original["Resources"] + + # TODO: what is this doing? + for key, resource in resources.items(): + resource["Properties"] = resource.get( + "Properties", clone_safe(resource) + ) # TODO: why is there a fallback? + resource["ResourceType"] = get_resource_type(resource) + + ordered_resource_ids = list( + order_resources( + resources=original_resources, + resolved_conditions=self.stack.resolved_conditions, + resolved_parameters=self.stack.resolved_parameters, + reverse=True, + ).keys() + ) + for i, resource_id in enumerate(ordered_resource_ids): + resource = resources[resource_id] + resource_type = get_resource_type(resource) + try: + # TODO: cache condition value in resource details on deployment and use cached value here + if not evaluate_resource_condition( + self.stack.resolved_conditions, + resource, + ): + continue + + action = "Remove" + executor = self.create_resource_provider_executor() + resource_provider_payload = self.create_resource_provider_payload( + action, logical_resource_id=resource_id + ) + LOG.debug( + 'Handling "Remove" for resource "%s" (%s/%s) type "%s"', + resource_id, + i + 1, + len(resources), + resource_type, + ) + resource_provider = executor.try_load_resource_provider(resource_type) + track_resource_operation(action, resource_type, missing=resource_provider is None) + if resource_provider is not None: + event = executor.deploy_loop( + resource_provider, resource, resource_provider_payload + ) + else: + log_not_available_message( + resource_type, + f'No resource provider found for "{resource_type}"', + ) + + if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES: + raise NoResourceProvider + event = ProgressEvent(OperationStatus.SUCCESS, resource_model={}) + match event.status: + case OperationStatus.SUCCESS: + self.stack.set_resource_status(resource_id, "DELETE_COMPLETE") + case OperationStatus.PENDING: + # the resource is still being deleted, specifically the provider has + # signalled that the deployment loop should skip this resource this + # time and come back to it later, likely due to unmet child + # resources still existing because we don't delete things in the + # correct order yet. + continue + case OperationStatus.FAILED: + LOG.exception( + "Failed to delete resource with id %s. Reason: %s", + resource_id, + event.message or "unknown", + ) + case OperationStatus.IN_PROGRESS: + # the resource provider executor should not return this state, so + # this state is a programming error + raise Exception( + "Programming error: ResourceProviderExecutor cannot return IN_PROGRESS" + ) + case other_status: + raise Exception(f"Use of unsupported status found: {other_status}") + + except Exception as e: + LOG.exception( + "Failed to delete resource with id %s. Final exception: %s", + resource_id, + e, + ) + + # update status + self.stack.set_stack_status("DELETE_COMPLETE") + self.stack.set_time_attribute("DeletionTime") + + def do_apply_changes_in_loop(self, changes: list[ChangeConfig], stack: Stack) -> list: + # apply changes in a retry loop, to resolve resource dependencies and converge to the target state + changes_done = [] + new_resources = stack.resources + + sorted_changes = order_changes( + given_changes=changes, + resources=new_resources, + resolved_conditions=stack.resolved_conditions, + resolved_parameters=stack.resolved_parameters, + ) + for change_idx, change in enumerate(sorted_changes): + res_change = change["ResourceChange"] + action = res_change["Action"] + is_add_or_modify = action in ["Add", "Modify"] + resource_id = res_change["LogicalResourceId"] + + # TODO: do resolve_refs_recursively once here + try: + if is_add_or_modify: + should_deploy = self.prepare_should_deploy_change( + resource_id, change, stack, new_resources + ) + LOG.debug( + 'Handling "%s" for resource "%s" (%s/%s) type "%s" (should_deploy=%s)', + action, + resource_id, + change_idx + 1, + len(changes), + res_change["ResourceType"], + should_deploy, + ) + if not should_deploy: + stack_action = get_action_name_for_resource_change(action) + stack.set_resource_status(resource_id, f"{stack_action}_COMPLETE") + continue + elif action == "Remove": + should_remove = self.prepare_should_deploy_change( + resource_id, change, stack, new_resources + ) + if not should_remove: + continue + LOG.debug( + 'Handling "%s" for resource "%s" (%s/%s) type "%s"', + action, + resource_id, + change_idx + 1, + len(changes), + res_change["ResourceType"], + ) + self.apply_change(change, stack=stack) + changes_done.append(change) + except Exception as e: + status_action = { + "Add": "CREATE", + "Modify": "UPDATE", + "Dynamic": "UPDATE", + "Remove": "DELETE", + }[action] + stack.add_stack_event( + resource_id=resource_id, + physical_res_id=new_resources[resource_id].get("PhysicalResourceId"), + status=f"{status_action}_FAILED", + status_reason=str(e), + ) + if config.CFN_VERBOSE_ERRORS: + LOG.exception("Failed to deploy resource %s, stack deploy failed", resource_id) + raise + + # clean up references to deleted resources in stack + deletes = [c for c in changes_done if c["ResourceChange"]["Action"] == "Remove"] + for delete in deletes: + stack.template["Resources"].pop(delete["ResourceChange"]["LogicalResourceId"], None) + + # resolve outputs + stack.resolved_outputs = resolve_outputs(self.account_id, self.region_name, stack) + + return changes_done + + +# FIXME: resolve_refs_recursively should not be needed, the resources themselves should have those values available already +def resolve_outputs(account_id: str, region_name: str, stack) -> list[dict]: + result = [] + for k, details in stack.outputs.items(): + if not evaluate_resource_condition(stack.resolved_conditions, details): + continue + value = None + try: + resolve_refs_recursively( + account_id, + region_name, + stack.stack_name, + stack.resources, + stack.mappings, + stack.resolved_conditions, + stack.resolved_parameters, + details, + ) + value = details["Value"] + except Exception as e: + log_method = LOG.debug + if config.CFN_VERBOSE_ERRORS: + raise # unresolvable outputs cause a stack failure + # log_method = getattr(LOG, "exception") + log_method("Unable to resolve references in stack outputs: %s - %s", details, e) + exports = details.get("Export") or {} + export = exports.get("Name") + export = resolve_refs_recursively( + account_id, + region_name, + stack.stack_name, + stack.resources, + stack.mappings, + stack.resolved_conditions, + stack.resolved_parameters, + export, + ) + description = details.get("Description") + entry = { + "OutputKey": k, + "OutputValue": value, + "Description": description, + "ExportName": export, + } + result.append(entry) + return result diff --git a/localstack-core/localstack/services/cloudformation/engine/template_preparer.py b/localstack-core/localstack/services/cloudformation/engine/template_preparer.py new file mode 100644 index 0000000000000..8206a7d6a99fc --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/template_preparer.py @@ -0,0 +1,68 @@ +import json +import logging + +from localstack.services.cloudformation.engine import yaml_parser +from localstack.services.cloudformation.engine.transformers import ( + apply_global_transformations, + apply_intrinsic_transformations, +) +from localstack.utils.json import clone_safe + +LOG = logging.getLogger(__name__) + + +def parse_template(template: str) -> dict: + try: + return json.loads(template) + except Exception: + try: + return clone_safe(yaml_parser.parse_yaml(template)) + except Exception as e: + LOG.debug("Unable to parse CloudFormation template (%s): %s", e, template) + raise + + +def template_to_json(template: str) -> str: + template = parse_template(template) + return json.dumps(template) + + +# TODO: consider moving to transformers.py as well +def transform_template( + account_id: str, + region_name: str, + template: dict, + stack_name: str, + resources: dict, + mappings: dict, + conditions: dict[str, bool], + resolved_parameters: dict, +) -> dict: + proccesed_template = dict(template) + + # apply 'Fn::Transform' intrinsic functions (note: needs to be applied before global + # transforms below, as some utils - incl samtransformer - expect them to be resolved already) + proccesed_template = apply_intrinsic_transformations( + account_id, + region_name, + proccesed_template, + stack_name, + resources, + mappings, + conditions, + resolved_parameters, + ) + + # apply global transforms + proccesed_template = apply_global_transformations( + account_id, + region_name, + proccesed_template, + stack_name, + resources, + mappings, + conditions, + resolved_parameters, + ) + + return proccesed_template diff --git a/localstack-core/localstack/services/cloudformation/engine/template_utils.py b/localstack-core/localstack/services/cloudformation/engine/template_utils.py new file mode 100644 index 0000000000000..062e4a3f1f840 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/template_utils.py @@ -0,0 +1,430 @@ +import re +from typing import Any + +from localstack.services.cloudformation.deployment_utils import PLACEHOLDER_AWS_NO_VALUE +from localstack.services.cloudformation.engine.errors import TemplateError +from localstack.utils.urls import localstack_host + +AWS_URL_SUFFIX = localstack_host().host # value is "amazonaws.com" in real AWS + + +def get_deps_for_resource(resource: dict, evaluated_conditions: dict[str, bool]) -> set[str]: + """ + :param resource: the resource definition to be checked for dependencies + :param evaluated_conditions: + :return: a set of logical resource IDs which this resource depends on + """ + property_dependencies = resolve_dependencies( + resource.get("Properties", {}), evaluated_conditions + ) + explicit_dependencies = resource.get("DependsOn", []) + if not isinstance(explicit_dependencies, list): + explicit_dependencies = [explicit_dependencies] + return property_dependencies.union(explicit_dependencies) + + +def resolve_dependencies(d: dict, evaluated_conditions: dict[str, bool]) -> set[str]: + items = set() + + if isinstance(d, dict): + for k, v in d.items(): + if k == "Fn::If": + # check the condition and only traverse down the correct path + condition_name, true_value, false_value = v + if evaluated_conditions[condition_name]: + items = items.union(resolve_dependencies(true_value, evaluated_conditions)) + else: + items = items.union(resolve_dependencies(false_value, evaluated_conditions)) + elif k == "Ref": + items.add(v) + elif k == "Fn::GetAtt": + items.add(v[0] if isinstance(v, list) else v.split(".")[0]) + elif k == "Fn::Sub": + # we can assume anything in there is a ref + if isinstance(v, str): + # { "Fn::Sub" : "Hello ${Name}" } + variables_found = re.findall("\\${([^}]+)}", v) + for var in variables_found: + if "." in var: + var = var.split(".")[0] + items.add(var) + elif isinstance(v, list): + # { "Fn::Sub" : [ "Hello ${Name}", { "Name": "SomeName" } ] } + variables_found = re.findall("\\${([^}]+)}", v[0]) + for var in variables_found: + if var in v[1]: + # variable is included in provided mapping and can either be a static value or another reference + if isinstance(v[1][var], dict): + # e.g. { "Fn::Sub" : [ "Hello ${Name}", { "Name": {"Ref": "NameParam"} } ] } + # the values can have references, so we need to go deeper + items = items.union( + resolve_dependencies(v[1][var], evaluated_conditions) + ) + else: + # it's now either a GetAtt call or a direct reference + if "." in var: + var = var.split(".")[0] + items.add(var) + else: + raise Exception(f"Invalid template structure in Fn::Sub: {v}") + elif isinstance(v, dict): + items = items.union(resolve_dependencies(v, evaluated_conditions)) + elif isinstance(v, list): + for item in v: + # TODO: assumption that every element is a dict might not be true + items = items.union(resolve_dependencies(item, evaluated_conditions)) + else: + pass + elif isinstance(d, list): + for item in d: + items = items.union(resolve_dependencies(item, evaluated_conditions)) + r = {i for i in items if not i.startswith("AWS::")} + return r + + +def resolve_stack_conditions( + account_id: str, + region_name: str, + conditions: dict, + parameters: dict, + mappings: dict, + stack_name: str, +) -> dict[str, bool]: + """ + Within each condition, you can reference another: + condition + parameter value + mapping + + You can use the following intrinsic functions to define conditions: + Fn::And + Fn::Equals + Fn::If + Fn::Not + Fn::Or + + TODO: more checks on types from references (e.g. in a mapping value) + TODO: does a ref ever return a non-string value? + TODO: when unifying/reworking intrinsic functions rework this to a class structure + """ + result = {} + for condition_name, condition in conditions.items(): + result[condition_name] = resolve_condition( + account_id, region_name, condition, conditions, parameters, mappings, stack_name + ) + return result + + +def resolve_pseudo_parameter( + account_id: str, region_name: str, pseudo_parameter: str, stack_name: str +) -> Any: + """ + TODO: this function needs access to more stack context + """ + # pseudo parameters + match pseudo_parameter: + case "AWS::Region": + return region_name + case "AWS::Partition": + return "aws" + case "AWS::StackName": + return stack_name + case "AWS::StackId": + # TODO return proper stack id! + return stack_name + case "AWS::AccountId": + return account_id + case "AWS::NoValue": + return PLACEHOLDER_AWS_NO_VALUE + case "AWS::NotificationARNs": + # TODO! + return {} + case "AWS::URLSuffix": + return AWS_URL_SUFFIX + + +def resolve_conditional_mapping_ref( + ref_name, account_id: str, region_name: str, stack_name: str, parameters +): + if ref_name.startswith("AWS::"): + ref_value = resolve_pseudo_parameter(account_id, region_name, ref_name, stack_name) + if ref_value is None: + raise TemplateError(f"Invalid pseudo parameter '{ref_name}'") + else: + param = parameters.get(ref_name) + if not param: + raise TemplateError( + f"Invalid reference: '{ref_name}' does not exist in parameters: '{parameters}'" + ) + ref_value = param.get("ResolvedValue") or param.get("ParameterValue") + + return ref_value + + +def resolve_condition( + account_id: str, region_name: str, condition, conditions, parameters, mappings, stack_name +): + if isinstance(condition, dict): + for k, v in condition.items(): + match k: + case "Ref": + if isinstance(v, str) and v.startswith("AWS::"): + return resolve_pseudo_parameter( + account_id, region_name, v, stack_name + ) # TODO: this pseudo parameter resolving needs context(!) + # TODO: add util function for resolving individual refs (e.g. one util for resolving pseudo parameters) + # TODO: pseudo-parameters like AWS::Region + # can only really be a parameter here + # TODO: how are conditions references written here? as {"Condition": "ConditionA"} or via Ref? + # TODO: test for a boolean parameter? + param = parameters[v] + parameter_type: str = param["ParameterType"] + parameter_value = param.get("ResolvedValue") or param.get("ParameterValue") + + if parameter_type in ["CommaDelimitedList"] or parameter_type.startswith( + "List<" + ): + return [p.strip() for p in parameter_value.split(",")] + else: + return parameter_value + + case "Condition": + return resolve_condition( + account_id, + region_name, + conditions[v], + conditions, + parameters, + mappings, + stack_name, + ) + case "Fn::FindInMap": + map_name, top_level_key, second_level_key = v + if isinstance(map_name, dict) and "Ref" in map_name: + ref_name = map_name["Ref"] + map_name = resolve_conditional_mapping_ref( + ref_name, account_id, region_name, stack_name, parameters + ) + + if isinstance(top_level_key, dict) and "Ref" in top_level_key: + ref_name = top_level_key["Ref"] + top_level_key = resolve_conditional_mapping_ref( + ref_name, account_id, region_name, stack_name, parameters + ) + + if isinstance(second_level_key, dict) and "Ref" in second_level_key: + ref_name = second_level_key["Ref"] + second_level_key = resolve_conditional_mapping_ref( + ref_name, account_id, region_name, stack_name, parameters + ) + + mapping = mappings.get(map_name) + if not mapping: + raise TemplateError( + f"Invalid reference: '{map_name}' could not be found in the template mappings: '{list(mappings.keys())}'" + ) + + top_level_map = mapping.get(top_level_key) + if not top_level_map: + raise TemplateError( + f"Invalid reference: '{top_level_key}' could not be found in the '{map_name}' mapping: '{list(mapping.keys())}'" + ) + + value = top_level_map.get(second_level_key) + if not value: + raise TemplateError( + f"Invalid reference: '{second_level_key}' could not be found in the '{top_level_key}' mapping: '{top_level_map}'" + ) + + return value + case "Fn::If": + if_condition_name, true_branch, false_branch = v + if resolve_condition( + account_id, + region_name, + if_condition_name, + conditions, + parameters, + mappings, + stack_name, + ): + return resolve_condition( + account_id, + region_name, + true_branch, + conditions, + parameters, + mappings, + stack_name, + ) + else: + return resolve_condition( + account_id, + region_name, + false_branch, + conditions, + parameters, + mappings, + stack_name, + ) + case "Fn::Not": + return not resolve_condition( + account_id, region_name, v[0], conditions, parameters, mappings, stack_name + ) + case "Fn::And": + # TODO: should actually restrict this a bit + return resolve_condition( + account_id, region_name, v[0], conditions, parameters, mappings, stack_name + ) and resolve_condition( + account_id, region_name, v[1], conditions, parameters, mappings, stack_name + ) + case "Fn::Or": + return resolve_condition( + account_id, region_name, v[0], conditions, parameters, mappings, stack_name + ) or resolve_condition( + account_id, region_name, v[1], conditions, parameters, mappings, stack_name + ) + case "Fn::Equals": + left = resolve_condition( + account_id, region_name, v[0], conditions, parameters, mappings, stack_name + ) + right = resolve_condition( + account_id, region_name, v[1], conditions, parameters, mappings, stack_name + ) + return fn_equals_type_conversion(left) == fn_equals_type_conversion(right) + case "Fn::Join": + join_list = v[1] + if isinstance(v[1], dict): + join_list = resolve_condition( + account_id, + region_name, + v[1], + conditions, + parameters, + mappings, + stack_name, + ) + result = v[0].join( + [ + resolve_condition( + account_id, + region_name, + x, + conditions, + parameters, + mappings, + stack_name, + ) + for x in join_list + ] + ) + return result + case "Fn::Select": + index = v[0] + options = v[1] + for i, option in enumerate(options): + if isinstance(option, dict): + options[i] = resolve_condition( + account_id, + region_name, + option, + conditions, + parameters, + mappings, + stack_name, + ) + return options[index] + case "Fn::Sub": + # we can assume anything in there is a ref + if isinstance(v, str): + # { "Fn::Sub" : "Hello ${Name}" } + result = v + variables_found = re.findall("\\${([^}]+)}", v) + for var in variables_found: + # can't be a resource here (!), so also not attribute access + if var.startswith("AWS::"): + # pseudo-parameter + resolved_pseudo_param = resolve_pseudo_parameter( + account_id, region_name, var, stack_name + ) + result = result.replace(f"${{{var}}}", resolved_pseudo_param) + else: + # parameter + param = parameters[var] + parameter_type: str = param["ParameterType"] + resolved_parameter = param.get("ResolvedValue") or param.get( + "ParameterValue" + ) + + if parameter_type in [ + "CommaDelimitedList" + ] or parameter_type.startswith("List<"): + resolved_parameter = [ + p.strip() for p in resolved_parameter.split(",") + ] + + result = result.replace(f"${{{var}}}", resolved_parameter) + + return result + elif isinstance(v, list): + # { "Fn::Sub" : [ "Hello ${Name}", { "Name": "SomeName" } ] } + result = v[0] + variables_found = re.findall("\\${([^}]+)}", v[0]) + for var in variables_found: + if var in v[1]: + # variable is included in provided mapping and can either be a static value or another reference + if isinstance(v[1][var], dict): + # e.g. { "Fn::Sub" : [ "Hello ${Name}", { "Name": {"Ref": "NameParam"} } ] } + # the values can have references, so we need to go deeper + resolved_var = resolve_condition( + account_id, + region_name, + v[1][var], + conditions, + parameters, + mappings, + stack_name, + ) + result = result.replace(f"${{{var}}}", resolved_var) + else: + result = result.replace(f"${{{var}}}", v[1][var]) + else: + # it's now either a GetAtt call or a direct reference + if var.startswith("AWS::"): + # pseudo-parameter + resolved_pseudo_param = resolve_pseudo_parameter( + account_id, region_name, var, stack_name + ) + result = result.replace(f"${{{var}}}", resolved_pseudo_param) + else: + # parameter + param = parameters[var] + parameter_type: str = param["ParameterType"] + resolved_parameter = param.get("ResolvedValue") or param.get( + "ParameterValue" + ) + + if parameter_type in [ + "CommaDelimitedList" + ] or parameter_type.startswith("List<"): + resolved_parameter = [ + p.strip() for p in resolved_parameter.split(",") + ] + + result = result.replace(f"${{{var}}}", resolved_parameter) + return result + else: + raise Exception(f"Invalid template structure in Fn::Sub: {v}") + case _: + raise Exception(f"Invalid condition structure encountered: {condition=}") + else: + return condition + + +def fn_equals_type_conversion(value) -> str: + if isinstance(value, str): + return value + elif isinstance(value, bool): + return "true" if value else "false" + else: + return str(value) # TODO: investigate correct behavior diff --git a/localstack-core/localstack/services/cloudformation/engine/transformers.py b/localstack-core/localstack/services/cloudformation/engine/transformers.py new file mode 100644 index 0000000000000..1518750fb1bc7 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/transformers.py @@ -0,0 +1,482 @@ +import copy +import json +import logging +import os +import re +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional, Type, Union + +import boto3 +from botocore.exceptions import ClientError +from samtranslator.translator.transform import transform as transform_sam + +from localstack.aws.api import CommonServiceException +from localstack.aws.connect import connect_to +from localstack.services.cloudformation.engine.policy_loader import create_policy_loader +from localstack.services.cloudformation.engine.template_deployer import resolve_refs_recursively +from localstack.services.cloudformation.engine.validations import ValidationError +from localstack.services.cloudformation.stores import get_cloudformation_store +from localstack.utils import testutil +from localstack.utils.objects import recurse_object +from localstack.utils.strings import long_uid + +LOG = logging.getLogger(__name__) + +SERVERLESS_TRANSFORM = "AWS::Serverless-2016-10-31" +EXTENSIONS_TRANSFORM = "AWS::LanguageExtensions" +SECRETSMANAGER_TRANSFORM = "AWS::SecretsManager-2020-07-23" + +TransformResult = Union[dict, str] + + +@dataclass +class ResolveRefsRecursivelyContext: + account_id: str + region_name: str + stack_name: str + resources: dict + mappings: dict + conditions: dict + parameters: dict + + def resolve(self, value: Any) -> Any: + return resolve_refs_recursively( + self.account_id, + self.region_name, + self.stack_name, + self.resources, + self.mappings, + self.conditions, + self.parameters, + value, + ) + + +class Transformer: + """Abstract class for Fn::Transform intrinsic functions""" + + def transform(self, account_id: str, region_name: str, parameters: dict) -> TransformResult: + """Apply the transformer to the given parameters and return the modified construct""" + + +class AwsIncludeTransformer(Transformer): + """Implements the 'AWS::Include' transform intrinsic function""" + + def transform(self, account_id: str, region_name: str, parameters: dict) -> TransformResult: + from localstack.services.cloudformation.engine.template_preparer import parse_template + + location = parameters.get("Location") + if location and location.startswith("s3://"): + s3_client = connect_to(aws_access_key_id=account_id, region_name=region_name).s3 + bucket, _, path = location.removeprefix("s3://").partition("/") + try: + content = testutil.download_s3_object(s3_client, bucket, path) + except ClientError: + LOG.error("client error downloading S3 object '%s/%s'", bucket, path) + raise + content = parse_template(content) + return content + else: + LOG.warning("Unexpected Location parameter for AWS::Include transformer: %s", location) + return parameters + + +# maps transformer names to implementing classes +transformers: Dict[str, Type] = {"AWS::Include": AwsIncludeTransformer} + + +def apply_intrinsic_transformations( + account_id: str, + region_name: str, + template: dict, + stack_name: str, + resources: dict, + mappings: dict, + conditions: dict[str, bool], + stack_parameters: dict, +) -> dict: + """Resolve constructs using the 'Fn::Transform' intrinsic function.""" + + def _visit(obj, path, **_): + if isinstance(obj, dict) and "Fn::Transform" in obj: + transform = ( + obj["Fn::Transform"] + if isinstance(obj["Fn::Transform"], dict) + else {"Name": obj["Fn::Transform"]} + ) + transform_name = transform.get("Name") + transformer_class = transformers.get(transform_name) + macro_store = get_cloudformation_store(account_id, region_name).macros + parameters = transform.get("Parameters") or {} + parameters = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + stack_parameters, + parameters, + ) + if transformer_class: + transformer = transformer_class() + transformed = transformer.transform(account_id, region_name, parameters) + obj_copy = deepcopy(obj) + obj_copy.pop("Fn::Transform") + obj_copy.update(transformed) + return obj_copy + + elif transform_name in macro_store: + obj_copy = deepcopy(obj) + obj_copy.pop("Fn::Transform") + result = execute_macro( + account_id, region_name, obj_copy, transform, stack_parameters, parameters, True + ) + return result + else: + LOG.warning( + "Unsupported transform function '%s' used in %s", transform_name, stack_name + ) + return obj + + return recurse_object(template, _visit) + + +def apply_global_transformations( + account_id: str, + region_name: str, + template: dict, + stack_name: str, + resources: dict, + mappings: dict, + conditions: dict[str, bool], + stack_parameters: dict, +) -> dict: + processed_template = deepcopy(template) + transformations = format_template_transformations_into_list( + processed_template.get("Transform", []) + ) + for transformation in transformations: + transformation_parameters = resolve_refs_recursively( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + stack_parameters, + transformation.get("Parameters", {}), + ) + + if not isinstance(transformation["Name"], str): + # TODO this should be done during template validation + raise CommonServiceException( + code="ValidationError", + status_code=400, + message="Key Name of transform definition must be a string.", + sender_fault=True, + ) + elif transformation["Name"] == SERVERLESS_TRANSFORM: + processed_template = apply_serverless_transformation( + account_id, region_name, processed_template, stack_parameters + ) + elif transformation["Name"] == EXTENSIONS_TRANSFORM: + resolve_context = ResolveRefsRecursivelyContext( + account_id, + region_name, + stack_name, + resources, + mappings, + conditions, + stack_parameters, + ) + + processed_template = apply_language_extensions_transform( + processed_template, + resolve_context, + ) + elif transformation["Name"] == SECRETSMANAGER_TRANSFORM: + # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/transform-aws-secretsmanager.html + LOG.warning("%s is not yet supported. Ignoring.", SECRETSMANAGER_TRANSFORM) + else: + processed_template = execute_macro( + account_id, + region_name, + parsed_template=template, + macro=transformation, + stack_parameters=stack_parameters, + transformation_parameters=transformation_parameters, + ) + + return processed_template + + +def format_template_transformations_into_list(transforms: list | dict | str) -> list[dict]: + """ + The value of the Transform attribute can be: + - a transformation name + - an object like {Name: transformation, Parameters:{}} + - a list a list of names of the transformations to apply + - a list of objects defining a transformation + so the objective of this function is to normalize the list of transformations to apply into a list of transformation objects + """ + formatted_transformations = [] + if isinstance(transforms, str): + formatted_transformations.append({"Name": transforms}) + + if isinstance(transforms, dict): + formatted_transformations.append(transforms) + + if isinstance(transforms, list): + for transformation in transforms: + if isinstance(transformation, str): + formatted_transformations.append({"Name": transformation}) + if isinstance(transformation, dict): + formatted_transformations.append(transformation) + + return formatted_transformations + + +def execute_macro( + account_id: str, + region_name: str, + parsed_template: dict, + macro: dict, + stack_parameters: dict, + transformation_parameters: dict, + is_intrinsic=False, +) -> str: + macro_definition = get_cloudformation_store(account_id, region_name).macros.get(macro["Name"]) + if not macro_definition: + raise FailedTransformationException( + macro["Name"], f"Transformation {macro['Name']} is not supported." + ) + + formatted_stack_parameters = {} + for key, value in stack_parameters.items(): + # TODO: we want to support other types of parameters + if value.get("ParameterType") == "CommaDelimitedList": + formatted_stack_parameters[key] = value.get("ParameterValue").split(",") + else: + formatted_stack_parameters[key] = value.get("ParameterValue") + + transformation_id = f"{account_id}::{macro['Name']}" + event = { + "region": region_name, + "accountId": account_id, + "fragment": parsed_template, + "transformId": transformation_id, + "params": transformation_parameters, + "requestId": long_uid(), + "templateParameterValues": formatted_stack_parameters, + } + + client = connect_to(aws_access_key_id=account_id, region_name=region_name).lambda_ + try: + invocation = client.invoke( + FunctionName=macro_definition["FunctionName"], Payload=json.dumps(event) + ) + except ClientError: + LOG.error( + "client error executing lambda function '%s' with payload '%s'", + macro_definition["FunctionName"], + json.dumps(event), + ) + raise + if invocation.get("StatusCode") != 200 or invocation.get("FunctionError") == "Unhandled": + raise FailedTransformationException( + transformation=macro["Name"], + message=f"Received malformed response from transform {transformation_id}. Rollback requested by user.", + ) + result = json.loads(invocation["Payload"].read()) + + if result.get("status") != "success": + error_message = result.get("errorMessage") + message = ( + f"Transform {transformation_id} failed with: {error_message}. Rollback requested by user." + if error_message + else f"Transform {transformation_id} failed without an error message.. Rollback requested by user." + ) + raise FailedTransformationException(transformation=macro["Name"], message=message) + + if not isinstance(result.get("fragment"), dict) and not is_intrinsic: + raise FailedTransformationException( + transformation=macro["Name"], + message="Template format error: unsupported structure.. Rollback requested by user.", + ) + + return result.get("fragment") + + +def apply_language_extensions_transform( + template: dict, + resolve_context: ResolveRefsRecursivelyContext, +) -> dict: + """ + Resolve language extensions constructs + """ + + def _visit(obj, path, **_): + # Fn::ForEach + # TODO: can this be used in non-resource positions? + if isinstance(obj, dict) and any("Fn::ForEach" in key for key in obj): + newobj = {} + for key in obj: + if "Fn::ForEach" not in key: + newobj[key] = obj[key] + continue + + new_entries = expand_fn_foreach(obj[key], resolve_context) + newobj.update(**new_entries) + return newobj + # Fn::Length + elif isinstance(obj, dict) and "Fn::Length" in obj: + value = obj["Fn::Length"] + if isinstance(value, dict): + value = resolve_context.resolve(value) + + if isinstance(value, list): + # TODO: what if one of the elements was AWS::NoValue? + # no conversion required + return len(value) + elif isinstance(value, str): + length = len(value.split(",")) + return length + return obj + elif isinstance(obj, dict) and "Fn::ToJsonString" in obj: + # TODO: is the default representation ok here? + return json.dumps(obj["Fn::ToJsonString"], default=str, separators=(",", ":")) + + # reference + return obj + + return recurse_object(template, _visit) + + +def expand_fn_foreach( + foreach_defn: list, + resolve_context: ResolveRefsRecursivelyContext, + extra_replace_mapping: dict | None = None, +) -> dict: + if len(foreach_defn) != 3: + raise ValidationError( + f"Fn::ForEach: invalid number of arguments, expected 3 got {len(foreach_defn)}" + ) + output = {} + iteration_name, iteration_value, template = foreach_defn + if not isinstance(iteration_name, str): + raise ValidationError( + f"Fn::ForEach: incorrect type for iteration name '{iteration_name}', expected str" + ) + if isinstance(iteration_value, dict): + # we have a reference + if "Ref" in iteration_value: + iteration_value = resolve_context.resolve(iteration_value) + else: + raise NotImplementedError( + f"Fn::Transform: intrinsic {iteration_value} not supported in this position yet" + ) + if not isinstance(iteration_value, list): + raise ValidationError( + f"Fn::ForEach: incorrect type for iteration variables '{iteration_value}', expected list" + ) + + if not isinstance(template, dict): + raise ValidationError( + f"Fn::ForEach: incorrect type for template '{template}', expected dict" + ) + + # TODO: locations other than resources + replace_template_value = "${" + iteration_name + "}" + for variable in iteration_value: + # there might be multiple children, which could themselves be a `Fn::ForEach` call + for logical_resource_id_template in template: + if logical_resource_id_template.startswith("Fn::ForEach"): + result = expand_fn_foreach( + template[logical_resource_id_template], + resolve_context, + {iteration_name: variable}, + ) + output.update(**result) + continue + + if replace_template_value not in logical_resource_id_template: + raise ValidationError("Fn::ForEach: no placeholder in logical resource id") + + def gen_visit(variable: str) -> Callable: + def _visit(obj: Any, path: Any): + if isinstance(obj, dict) and "Ref" in obj: + ref_variable = obj["Ref"] + if ref_variable == iteration_name: + return variable + elif isinstance(obj, dict) and "Fn::Sub" in obj: + arguments = recurse_object(obj["Fn::Sub"], _visit) + if isinstance(arguments, str): + # simple case + # TODO: can this reference anything outside of the template? + result = arguments + variables_found = re.findall("\\${([^}]+)}", arguments) + for var in variables_found: + if var == iteration_name: + result = result.replace(f"${{{var}}}", variable) + return result + else: + raise NotImplementedError + elif isinstance(obj, dict) and "Fn::Join" in obj: + # first visit arguments + arguments = recurse_object( + obj["Fn::Join"], + _visit, + ) + separator, items = arguments + return separator.join(items) + return obj + + return _visit + + logical_resource_id = logical_resource_id_template.replace( + replace_template_value, variable + ) + for key, value in (extra_replace_mapping or {}).items(): + logical_resource_id = logical_resource_id.replace("${" + key + "}", value) + resource_body = copy.deepcopy(template[logical_resource_id_template]) + body = recurse_object(resource_body, gen_visit(variable)) + output[logical_resource_id] = body + + return output + + +def apply_serverless_transformation( + account_id: str, region_name: str, parsed_template: dict, template_parameters: dict +) -> Optional[str]: + """only returns string when parsing SAM template, otherwise None""" + # TODO: we might also want to override the access key ID to account ID + region_before = os.environ.get("AWS_DEFAULT_REGION") + if boto3.session.Session().region_name is None: + os.environ["AWS_DEFAULT_REGION"] = region_name + loader = create_policy_loader() + simplified_parameters = { + k: v.get("ResolvedValue") or v["ParameterValue"] for k, v in template_parameters.items() + } + + try: + transformed = transform_sam(parsed_template, simplified_parameters, loader) + return transformed + except Exception as e: + raise FailedTransformationException(transformation=SERVERLESS_TRANSFORM, message=str(e)) + finally: + # Note: we need to fix boto3 region, otherwise AWS SAM transformer fails + os.environ.pop("AWS_DEFAULT_REGION", None) + if region_before is not None: + os.environ["AWS_DEFAULT_REGION"] = region_before + + +class FailedTransformationException(Exception): + transformation: str + msg: str + + def __init__(self, transformation: str, message: str = ""): + self.transformation = transformation + self.message = message + super().__init__(self.message) diff --git a/localstack-core/localstack/services/cloudformation/engine/types.py b/localstack-core/localstack/services/cloudformation/engine/types.py new file mode 100644 index 0000000000000..2a4f6efa06031 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/types.py @@ -0,0 +1,45 @@ +from typing import Any, Callable, Optional, TypedDict + +# --------------------- +# TYPES +# --------------------- + +# Callable here takes the arguments: +# - resource_props +# - stack_name +# - resources +# - resource_id +ResourceProp = str | Callable[[dict, str, dict, str], dict] +ResourceDefinition = dict[str, ResourceProp] + + +class FuncDetailsValue(TypedDict): + # Callable here takes the arguments: + # - logical_resource_id + # - resource + # - stack_name + function: str | Callable[[str, dict, str], Any] + """Either an api method to call directly with `parameters` or a callable to directly invoke""" + # Callable here takes the arguments: + # - resource_props + # - stack_name + # - resources + # - resource_id + parameters: Optional[ResourceDefinition | Callable[[dict, str, list[dict], str], dict]] + """arguments to the function, or a function that generates the arguments to the function""" + # Callable here takes the arguments + # - result + # - resource_id + # - resources + # - resource_type + result_handler: Optional[Callable[[dict, str, list[dict], str], None]] + """Take the result of the operation and patch the state of the resources, yuck...""" + types: Optional[dict[str, Callable]] + """Possible type conversions""" + + +# Type definition for func_details supplied to invoke_function +FuncDetails = list[FuncDetailsValue] | FuncDetailsValue + +# Type definition returned by GenericBaseModel.get_deploy_templates +DeployTemplates = dict[str, FuncDetails | Callable] diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/__init__.py b/localstack-core/localstack/services/cloudformation/engine/v2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py new file mode 100644 index 0000000000000..ce0cd63f00912 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model.py @@ -0,0 +1,1409 @@ +from __future__ import annotations + +import abc +import enum +from itertools import zip_longest +from typing import Any, Final, Generator, Optional, TypedDict, Union, cast + +from typing_extensions import TypeVar + +from localstack.utils.strings import camel_to_snake_case + +T = TypeVar("T") + + +class NothingType: + """A sentinel that denotes 'no value' (distinct from None).""" + + _singleton = None + __slots__ = () + + def __new__(cls): + if cls._singleton is None: + cls._singleton = super().__new__(cls) + return cls._singleton + + def __eq__(self, other): + return is_nothing(other) + + def __str__(self): + return repr(self) + + def __repr__(self) -> str: + return "Nothing" + + def __bool__(self): + return False + + def __iter__(self): + return iter(()) + + def __contains__(self, item): + return False + + +Maybe = Union[T, NothingType] +Nothing = NothingType() + + +def is_nothing(value: Any) -> bool: + return isinstance(value, NothingType) + + +def is_created(before: Maybe[Any], after: Maybe[Any]) -> bool: + return is_nothing(before) and not is_nothing(after) + + +def is_removed(before: Maybe[Any], after: Maybe[Any]) -> bool: + return not is_nothing(before) and is_nothing(after) + + +def parent_change_type_of(children: list[Maybe[ChangeSetEntity]]): + change_types = [c.change_type for c in children if not is_nothing(c)] + if not change_types: + return ChangeType.UNCHANGED + first_type = change_types[0] + if all(ct == first_type for ct in change_types): + return first_type + return ChangeType.MODIFIED + + +def change_type_of(before: Maybe[Any], after: Maybe[Any], children: list[Maybe[ChangeSetEntity]]): + if is_created(before, after): + change_type = ChangeType.CREATED + elif is_removed(before, after): + change_type = ChangeType.REMOVED + else: + change_type = parent_change_type_of(children) + return change_type + + +class NormalisedGlobalTransformDefinition(TypedDict): + Name: Any + Parameters: Maybe[Any] + + +class Scope(str): + _ROOT_SCOPE: Final[str] = str() + _SEPARATOR: Final[str] = "/" + + def __new__(cls, scope: str = _ROOT_SCOPE) -> Scope: + return cast(Scope, super().__new__(cls, scope)) + + def open_scope(self, name: Scope | str) -> Scope: + return Scope(self._SEPARATOR.join([self, name])) + + def open_index(self, index: int) -> Scope: + return Scope(self._SEPARATOR.join([self, str(index)])) + + def unwrap(self) -> list[str]: + return self.split(self._SEPARATOR) + + +class ChangeType(enum.Enum): + UNCHANGED = "Unchanged" + CREATED = "Created" + MODIFIED = "Modified" + REMOVED = "Removed" + + def __str__(self): + return self.value + + +class ChangeSetEntity(abc.ABC): + scope: Final[Scope] + change_type: Final[ChangeType] + + def __init__(self, scope: Scope, change_type: ChangeType): + self.scope = scope + self.change_type = change_type + + def get_children(self) -> Generator[ChangeSetEntity]: + for child in self.__dict__.values(): + yield from self._get_children_in(child) + + @staticmethod + def _get_children_in(obj: Any) -> Generator[ChangeSetEntity]: + # TODO: could avoid the inductive logic here, and check for loops? + if isinstance(obj, ChangeSetEntity): + yield obj + elif isinstance(obj, list): + for item in obj: + yield from ChangeSetEntity._get_children_in(item) + elif isinstance(obj, dict): + for item in obj.values(): + yield from ChangeSetEntity._get_children_in(item) + + def __str__(self): + return f"({self.__class__.__name__}| {vars(self)}" + + def __repr__(self): + return str(self) + + +class ChangeSetNode(ChangeSetEntity, abc.ABC): ... + + +class ChangeSetTerminal(ChangeSetEntity, abc.ABC): ... + + +class UpdateModel: + # TODO: may be expanded to keep track of other runtime values such as resolved_parameters. + + node_template: Final[NodeTemplate] + before_runtime_cache: Final[dict] + after_runtime_cache: Final[dict] + + def __init__( + self, + node_template: NodeTemplate, + ): + self.node_template = node_template + self.before_runtime_cache = dict() + self.after_runtime_cache = dict() + + +class NodeTemplate(ChangeSetNode): + transform: Final[NodeTransform] + mappings: Final[NodeMappings] + parameters: Final[NodeParameters] + conditions: Final[NodeConditions] + resources: Final[NodeResources] + outputs: Final[NodeOutputs] + + def __init__( + self, + scope: Scope, + transform: NodeTransform, + mappings: NodeMappings, + parameters: NodeParameters, + conditions: NodeConditions, + resources: NodeResources, + outputs: NodeOutputs, + ): + change_type = parent_change_type_of([transform, resources, outputs]) + super().__init__(scope=scope, change_type=change_type) + self.transform = transform + self.mappings = mappings + self.parameters = parameters + self.conditions = conditions + self.resources = resources + self.outputs = outputs + + +class NodeDivergence(ChangeSetNode): + value: Final[ChangeSetEntity] + divergence: Final[ChangeSetEntity] + + def __init__(self, scope: Scope, value: ChangeSetEntity, divergence: ChangeSetEntity): + super().__init__(scope=scope, change_type=ChangeType.MODIFIED) + self.value = value + self.divergence = divergence + + +class NodeParameter(ChangeSetNode): + name: Final[str] + type_: Final[ChangeSetEntity] + dynamic_value: Final[ChangeSetEntity] + default_value: Final[Maybe[ChangeSetEntity]] + + def __init__( + self, + scope: Scope, + name: str, + type_: ChangeSetEntity, + dynamic_value: ChangeSetEntity, + default_value: Maybe[ChangeSetEntity], + ): + change_type = parent_change_type_of([type_, default_value, dynamic_value]) + super().__init__(scope=scope, change_type=change_type) + self.name = name + self.type_ = type_ + self.dynamic_value = dynamic_value + self.default_value = default_value + + +class NodeParameters(ChangeSetNode): + parameters: Final[list[NodeParameter]] + + def __init__(self, scope: Scope, parameters: list[NodeParameter]): + change_type = parent_change_type_of(parameters) + super().__init__(scope=scope, change_type=change_type) + self.parameters = parameters + + +class NodeMapping(ChangeSetNode): + name: Final[str] + bindings: Final[NodeObject] + + def __init__(self, scope: Scope, name: str, bindings: NodeObject): + super().__init__(scope=scope, change_type=bindings.change_type) + self.name = name + self.bindings = bindings + + +class NodeMappings(ChangeSetNode): + mappings: Final[list[NodeMapping]] + + def __init__(self, scope: Scope, mappings: list[NodeMapping]): + change_type = parent_change_type_of(mappings) + super().__init__(scope=scope, change_type=change_type) + self.mappings = mappings + + +class NodeOutput(ChangeSetNode): + name: Final[str] + value: Final[ChangeSetEntity] + export: Final[Maybe[ChangeSetEntity]] + condition_reference: Final[Maybe[TerminalValue]] + + def __init__( + self, + scope: Scope, + name: str, + value: ChangeSetEntity, + export: Maybe[ChangeSetEntity], + conditional_reference: Maybe[TerminalValue], + ): + change_type = parent_change_type_of([value, export, conditional_reference]) + super().__init__(scope=scope, change_type=change_type) + self.name = name + self.value = value + self.export = export + self.condition_reference = conditional_reference + + +class NodeOutputs(ChangeSetNode): + outputs: Final[list[NodeOutput]] + + def __init__(self, scope: Scope, outputs: list[NodeOutput]): + change_type = parent_change_type_of(outputs) + super().__init__(scope=scope, change_type=change_type) + self.outputs = outputs + + +class NodeCondition(ChangeSetNode): + name: Final[str] + body: Final[ChangeSetEntity] + + def __init__(self, scope: Scope, name: str, body: ChangeSetEntity): + super().__init__(scope=scope, change_type=body.change_type) + self.name = name + self.body = body + + +class NodeConditions(ChangeSetNode): + conditions: Final[list[NodeCondition]] + + def __init__(self, scope: Scope, conditions: list[NodeCondition]): + change_type = parent_change_type_of(conditions) + super().__init__(scope=scope, change_type=change_type) + self.conditions = conditions + + +class NodeGlobalTransform(ChangeSetNode): + name: Final[TerminalValue] + parameters: Final[Maybe[ChangeSetEntity]] + + def __init__(self, scope: Scope, name: TerminalValue, parameters: Maybe[ChangeSetEntity]): + if not is_nothing(parameters): + change_type = parent_change_type_of([name, parameters]) + else: + change_type = name.change_type + super().__init__(scope=scope, change_type=change_type) + self.name = name + self.parameters = parameters + + +class NodeTransform(ChangeSetNode): + global_transforms: Final[list[NodeGlobalTransform]] + + def __init__(self, scope: Scope, global_transforms: list[NodeGlobalTransform]): + change_type = parent_change_type_of(global_transforms) + super().__init__(scope=scope, change_type=change_type) + self.global_transforms = global_transforms + + +class NodeResources(ChangeSetNode): + resources: Final[list[NodeResource]] + + def __init__(self, scope: Scope, resources: list[NodeResource]): + change_type = parent_change_type_of(resources) + super().__init__(scope=scope, change_type=change_type) + self.resources = resources + + +class NodeResource(ChangeSetNode): + name: Final[str] + type_: Final[ChangeSetTerminal] + properties: Final[NodeProperties] + condition_reference: Final[Maybe[TerminalValue]] + depends_on: Final[Maybe[NodeDependsOn]] + + def __init__( + self, + scope: Scope, + change_type: ChangeType, + name: str, + type_: ChangeSetTerminal, + properties: NodeProperties, + condition_reference: Maybe[TerminalValue], + depends_on: Maybe[NodeDependsOn], + ): + super().__init__(scope=scope, change_type=change_type) + self.name = name + self.type_ = type_ + self.properties = properties + self.condition_reference = condition_reference + self.depends_on = depends_on + + +class NodeProperties(ChangeSetNode): + properties: Final[list[NodeProperty]] + + def __init__(self, scope: Scope, properties: list[NodeProperty]): + change_type = parent_change_type_of(properties) + super().__init__(scope=scope, change_type=change_type) + self.properties = properties + + +class NodeDependsOn(ChangeSetNode): + depends_on: Final[NodeArray] + + def __init__(self, scope: Scope, depends_on: NodeArray): + super().__init__(scope=scope, change_type=depends_on.change_type) + self.depends_on = depends_on + + +class NodeProperty(ChangeSetNode): + name: Final[str] + value: Final[ChangeSetEntity] + + def __init__(self, scope: Scope, name: str, value: ChangeSetEntity): + super().__init__(scope=scope, change_type=value.change_type) + self.name = name + self.value = value + + +class NodeIntrinsicFunction(ChangeSetNode): + intrinsic_function: Final[str] + arguments: Final[ChangeSetEntity] + + def __init__( + self, + scope: Scope, + change_type: ChangeType, + intrinsic_function: str, + arguments: ChangeSetEntity, + ): + super().__init__(scope=scope, change_type=change_type) + self.intrinsic_function = intrinsic_function + self.arguments = arguments + + +class NodeObject(ChangeSetNode): + bindings: Final[dict[str, ChangeSetEntity]] + + def __init__(self, scope: Scope, change_type: ChangeType, bindings: dict[str, ChangeSetEntity]): + super().__init__(scope=scope, change_type=change_type) + self.bindings = bindings + + +class NodeArray(ChangeSetNode): + array: Final[list[ChangeSetEntity]] + + def __init__(self, scope: Scope, change_type: ChangeType, array: list[ChangeSetEntity]): + super().__init__(scope=scope, change_type=change_type) + self.array = array + + +class TerminalValue(ChangeSetTerminal, abc.ABC): + value: Final[Any] + + def __init__(self, scope: Scope, change_type: ChangeType, value: Any): + super().__init__(scope=scope, change_type=change_type) + self.value = value + + +class TerminalValueModified(TerminalValue): + modified_value: Final[Any] + + def __init__(self, scope: Scope, value: Any, modified_value: Any): + super().__init__(scope=scope, change_type=ChangeType.MODIFIED, value=value) + self.modified_value = modified_value + + +class TerminalValueCreated(TerminalValue): + def __init__(self, scope: Scope, value: Any): + super().__init__(scope=scope, change_type=ChangeType.CREATED, value=value) + + +class TerminalValueRemoved(TerminalValue): + def __init__(self, scope: Scope, value: Any): + super().__init__(scope=scope, change_type=ChangeType.REMOVED, value=value) + + +class TerminalValueUnchanged(TerminalValue): + def __init__(self, scope: Scope, value: Any): + super().__init__(scope=scope, change_type=ChangeType.UNCHANGED, value=value) + + +NameKey: Final[str] = "Name" +TransformKey: Final[str] = "Transform" +TypeKey: Final[str] = "Type" +ConditionKey: Final[str] = "Condition" +ConditionsKey: Final[str] = "Conditions" +MappingsKey: Final[str] = "Mappings" +ResourcesKey: Final[str] = "Resources" +PropertiesKey: Final[str] = "Properties" +ParametersKey: Final[str] = "Parameters" +DefaultKey: Final[str] = "Default" +ValueKey: Final[str] = "Value" +ExportKey: Final[str] = "Export" +OutputsKey: Final[str] = "Outputs" +DependsOnKey: Final[str] = "DependsOn" +# TODO: expand intrinsic functions set. +RefKey: Final[str] = "Ref" +RefConditionKey: Final[str] = "Condition" +FnIfKey: Final[str] = "Fn::If" +FnAnd: Final[str] = "Fn::And" +FnOr: Final[str] = "Fn::Or" +FnNotKey: Final[str] = "Fn::Not" +FnJoinKey: Final[str] = "Fn::Join" +FnGetAttKey: Final[str] = "Fn::GetAtt" +FnEqualsKey: Final[str] = "Fn::Equals" +FnFindInMapKey: Final[str] = "Fn::FindInMap" +FnSubKey: Final[str] = "Fn::Sub" +FnTransform: Final[str] = "Fn::Transform" +FnSelect: Final[str] = "Fn::Select" +FnSplit: Final[str] = "Fn::Split" +FnGetAZs: Final[str] = "Fn::GetAZs" +FnBase64: Final[str] = "Fn::Base64" +INTRINSIC_FUNCTIONS: Final[set[str]] = { + RefKey, + RefConditionKey, + FnIfKey, + FnAnd, + FnOr, + FnNotKey, + FnJoinKey, + FnEqualsKey, + FnGetAttKey, + FnFindInMapKey, + FnSubKey, + FnTransform, + FnSelect, + FnSplit, + FnGetAZs, + FnBase64, +} + + +class ChangeSetModel: + # TODO: should this instead be generalised to work on "Stack" objects instead of just "Template"s? + + # TODO: can probably improve the typehints to use CFN's 'language' eg. dict -> Template|Properties, etc. + + # TODO: add support for 'replacement' computation, and ensure this state is propagated in tree traversals + # such as intrinsic functions. + + _before_template: Final[Maybe[dict]] + _after_template: Final[Maybe[dict]] + _before_parameters: Final[Maybe[dict]] + _after_parameters: Final[Maybe[dict]] + _visited_scopes: Final[dict[str, ChangeSetEntity]] + _node_template: Final[NodeTemplate] + + def __init__( + self, + before_template: Optional[dict], + after_template: Optional[dict], + before_parameters: Optional[dict], + after_parameters: Optional[dict], + ): + self._before_template = before_template or Nothing + self._after_template = after_template or Nothing + self._before_parameters = before_parameters or Nothing + self._after_parameters = after_parameters or Nothing + self._visited_scopes = dict() + self._node_template = self._model( + before_template=self._before_template, after_template=self._after_template + ) + # TODO: need to do template preprocessing e.g. parameter resolution, conditions etc. + + def get_update_model(self) -> UpdateModel: + return UpdateModel(node_template=self._node_template) + + def _visit_terminal_value( + self, scope: Scope, before_value: Maybe[Any], after_value: Maybe[Any] + ) -> TerminalValue: + terminal_value = self._visited_scopes.get(scope) + if isinstance(terminal_value, TerminalValue): + return terminal_value + if is_created(before=before_value, after=after_value): + terminal_value = TerminalValueCreated(scope=scope, value=after_value) + elif is_removed(before=before_value, after=after_value): + terminal_value = TerminalValueRemoved(scope=scope, value=before_value) + elif before_value == after_value: + terminal_value = TerminalValueUnchanged(scope=scope, value=before_value) + else: + terminal_value = TerminalValueModified( + scope=scope, value=before_value, modified_value=after_value + ) + self._visited_scopes[scope] = terminal_value + return terminal_value + + def _visit_intrinsic_function( + self, + scope: Scope, + intrinsic_function: str, + before_arguments: Maybe[Any], + after_arguments: Maybe[Any], + ) -> NodeIntrinsicFunction: + node_intrinsic_function = self._visited_scopes.get(scope) + if isinstance(node_intrinsic_function, NodeIntrinsicFunction): + return node_intrinsic_function + arguments_scope = scope.open_scope("args") + arguments = self._visit_value( + scope=arguments_scope, before_value=before_arguments, after_value=after_arguments + ) + if is_created(before=before_arguments, after=after_arguments): + change_type = ChangeType.CREATED + elif is_removed(before=before_arguments, after=after_arguments): + change_type = ChangeType.REMOVED + else: + function_name = intrinsic_function.replace("::", "_") + function_name = camel_to_snake_case(function_name) + resolve_function_name = f"_resolve_intrinsic_function_{function_name}" + if hasattr(self, resolve_function_name): + resolve_function = getattr(self, resolve_function_name) + change_type = resolve_function(arguments) + else: + change_type = arguments.change_type + node_intrinsic_function = NodeIntrinsicFunction( + scope=scope, + change_type=change_type, + intrinsic_function=intrinsic_function, + arguments=arguments, + ) + self._visited_scopes[scope] = node_intrinsic_function + return node_intrinsic_function + + def _resolve_intrinsic_function_fn_sub(self, arguments: ChangeSetEntity) -> ChangeType: + # TODO: This routine should instead export the implicit Ref and GetAtt calls within the first + # string template parameter and compute the respective change set types. Currently, + # changes referenced by Fn::Sub templates are only picked up during preprocessing; not + # at modelling. + return arguments.change_type + + def _resolve_intrinsic_function_fn_get_att(self, arguments: ChangeSetEntity) -> ChangeType: + # TODO: add support for nested intrinsic functions. + # TODO: validate arguments structure and type. + # TODO: should this check for deletion of resources and/or properties, if so what error should be raised? + + if not isinstance(arguments, NodeArray) or not arguments.array: + raise RuntimeError() + logical_name_of_resource_entity = arguments.array[0] + if not isinstance(logical_name_of_resource_entity, TerminalValue): + raise RuntimeError() + logical_name_of_resource: str = logical_name_of_resource_entity.value + if not isinstance(logical_name_of_resource, str): + raise RuntimeError() + node_resource: NodeResource = self._retrieve_or_visit_resource( + resource_name=logical_name_of_resource + ) + + node_property_attribute_name = arguments.array[1] + if not isinstance(node_property_attribute_name, TerminalValue): + raise RuntimeError() + if isinstance(node_property_attribute_name, TerminalValueModified): + attribute_name = node_property_attribute_name.modified_value + else: + attribute_name = node_property_attribute_name.value + + # TODO: this is another use case for which properties should be referenced by name + for node_property in node_resource.properties.properties: + if node_property.name == attribute_name: + return node_property.change_type + + return ChangeType.UNCHANGED + + def _resolve_intrinsic_function_ref(self, arguments: ChangeSetEntity) -> ChangeType: + if arguments.change_type != ChangeType.UNCHANGED: + return arguments.change_type + if not isinstance(arguments, TerminalValue): + return arguments.change_type + + logical_id = arguments.value + + node_condition = self._retrieve_condition_if_exists(condition_name=logical_id) + if isinstance(node_condition, NodeCondition): + return node_condition.change_type + + node_parameter = self._retrieve_parameter_if_exists(parameter_name=logical_id) + if isinstance(node_parameter, NodeParameter): + return node_parameter.change_type + + # TODO: this should check the replacement flag for a resource update. + node_resource = self._retrieve_or_visit_resource(resource_name=logical_id) + return node_resource.change_type + + def _resolve_intrinsic_function_condition(self, arguments: ChangeSetEntity) -> ChangeType: + if arguments.change_type != ChangeType.UNCHANGED: + return arguments.change_type + if not isinstance(arguments, TerminalValue): + return arguments.change_type + + condition_name = arguments.value + node_condition = self._retrieve_condition_if_exists(condition_name=condition_name) + if isinstance(node_condition, NodeCondition): + return node_condition.change_type + raise RuntimeError(f"Undefined condition '{condition_name}'") + + def _resolve_intrinsic_function_fn_find_in_map(self, arguments: ChangeSetEntity) -> ChangeType: + if arguments.change_type != ChangeType.UNCHANGED: + return arguments.change_type + # TODO: validate arguments structure and type. + # TODO: add support for nested functions, here we assume the arguments are string literals. + + if not isinstance(arguments, NodeArray) or not arguments.array: + raise RuntimeError() + argument_mapping_name = arguments.array[0] + if not isinstance(argument_mapping_name, TerminalValue): + raise NotImplementedError() + argument_top_level_key = arguments.array[1] + if not isinstance(argument_top_level_key, TerminalValue): + raise NotImplementedError() + argument_second_level_key = arguments.array[2] + if not isinstance(argument_second_level_key, TerminalValue): + raise NotImplementedError() + mapping_name = argument_mapping_name.value + top_level_key = argument_top_level_key.value + second_level_key = argument_second_level_key.value + + node_mapping = self._retrieve_mapping(mapping_name=mapping_name) + # TODO: a lookup would be beneficial in this scenario too; + # consider implications downstream and for replication. + top_level_object = node_mapping.bindings.bindings.get(top_level_key) + if not isinstance(top_level_object, NodeObject): + raise RuntimeError() + target_map_value = top_level_object.bindings.get(second_level_key) + return target_map_value.change_type + + def _resolve_intrinsic_function_fn_if(self, arguments: ChangeSetEntity) -> ChangeType: + # TODO: validate arguments structure and type. + if not isinstance(arguments, NodeArray) or not arguments.array: + raise RuntimeError() + logical_name_of_condition_entity = arguments.array[0] + if not isinstance(logical_name_of_condition_entity, TerminalValue): + raise RuntimeError() + logical_name_of_condition: str = logical_name_of_condition_entity.value + if not isinstance(logical_name_of_condition, str): + raise RuntimeError() + + node_condition = self._retrieve_condition_if_exists( + condition_name=logical_name_of_condition + ) + if not isinstance(node_condition, NodeCondition): + raise RuntimeError() + change_type = parent_change_type_of([node_condition, *arguments[1:]]) + return change_type + + def _visit_array( + self, scope: Scope, before_array: Maybe[list], after_array: Maybe[list] + ) -> NodeArray: + array: list[ChangeSetEntity] = list() + for index, (before_value, after_value) in enumerate( + zip_longest(before_array, after_array, fillvalue=Nothing) + ): + value_scope = scope.open_index(index=index) + value = self._visit_value( + scope=value_scope, before_value=before_value, after_value=after_value + ) + array.append(value) + change_type = change_type_of(before_array, after_array, array) + return NodeArray(scope=scope, change_type=change_type, array=array) + + def _visit_object( + self, scope: Scope, before_object: Maybe[dict], after_object: Maybe[dict] + ) -> NodeObject: + node_object = self._visited_scopes.get(scope) + if isinstance(node_object, NodeObject): + return node_object + binding_names = self._safe_keys_of(before_object, after_object) + bindings: dict[str, ChangeSetEntity] = dict() + for binding_name in binding_names: + binding_scope, (before_value, after_value) = self._safe_access_in( + scope, binding_name, before_object, after_object + ) + value = self._visit_value( + scope=binding_scope, before_value=before_value, after_value=after_value + ) + bindings[binding_name] = value + change_type = change_type_of(before_object, after_object, list(bindings.values())) + node_object = NodeObject(scope=scope, change_type=change_type, bindings=bindings) + self._visited_scopes[scope] = node_object + return node_object + + def _visit_divergence( + self, scope: Scope, before_value: Maybe[Any], after_value: Maybe[Any] + ) -> NodeDivergence: + scope_value = scope.open_scope("value") + value = self._visit_value(scope=scope_value, before_value=before_value, after_value=Nothing) + scope_divergence = scope.open_scope("divergence") + divergence = self._visit_value( + scope=scope_divergence, before_value=Nothing, after_value=after_value + ) + return NodeDivergence(scope=scope, value=value, divergence=divergence) + + def _visit_value( + self, scope: Scope, before_value: Maybe[Any], after_value: Maybe[Any] + ) -> ChangeSetEntity: + value = self._visited_scopes.get(scope) + if isinstance(value, ChangeSetEntity): + return value + + before_type_name = self._type_name_of(before_value) + after_type_name = self._type_name_of(after_value) + unset = object() + if before_type_name == after_type_name: + dominant_value = before_value + elif is_created(before=before_value, after=after_value): + dominant_value = after_value + elif is_removed(before=before_value, after=after_value): + dominant_value = before_value + else: + dominant_value = unset + if dominant_value is not unset: + dominant_type_name = self._type_name_of(dominant_value) + if self._is_terminal(value=dominant_value): + value = self._visit_terminal_value( + scope=scope, before_value=before_value, after_value=after_value + ) + elif self._is_object(value=dominant_value): + value = self._visit_object( + scope=scope, before_object=before_value, after_object=after_value + ) + elif self._is_array(value=dominant_value): + value = self._visit_array( + scope=scope, before_array=before_value, after_array=after_value + ) + elif self._is_intrinsic_function_name(dominant_type_name): + intrinsic_function_scope, (before_arguments, after_arguments) = ( + self._safe_access_in(scope, dominant_type_name, before_value, after_value) + ) + value = self._visit_intrinsic_function( + scope=intrinsic_function_scope, + intrinsic_function=dominant_type_name, + before_arguments=before_arguments, + after_arguments=after_arguments, + ) + else: + raise RuntimeError(f"Unsupported type {type(dominant_value)}") + # Case: type divergence. + else: + value = self._visit_divergence( + scope=scope, before_value=before_value, after_value=after_value + ) + self._visited_scopes[scope] = value + return value + + def _visit_property( + self, + scope: Scope, + property_name: str, + before_property: Maybe[Any], + after_property: Maybe[Any], + ) -> NodeProperty: + node_property = self._visited_scopes.get(scope) + if isinstance(node_property, NodeProperty): + return node_property + # TODO: Review the use of Fn::Transform as resource properties. + value = self._visit_value( + scope=scope, before_value=before_property, after_value=after_property + ) + node_property = NodeProperty(scope=scope, name=property_name, value=value) + self._visited_scopes[scope] = node_property + return node_property + + def _visit_properties( + self, scope: Scope, before_properties: Maybe[dict], after_properties: Maybe[dict] + ) -> NodeProperties: + node_properties = self._visited_scopes.get(scope) + if isinstance(node_properties, NodeProperties): + return node_properties + property_names: list[str] = self._safe_keys_of(before_properties, after_properties) + properties: list[NodeProperty] = list() + for property_name in property_names: + property_scope, (before_property, after_property) = self._safe_access_in( + scope, property_name, before_properties, after_properties + ) + property_ = self._visit_property( + scope=property_scope, + property_name=property_name, + before_property=before_property, + after_property=after_property, + ) + properties.append(property_) + node_properties = NodeProperties(scope=scope, properties=properties) + self._visited_scopes[scope] = node_properties + return node_properties + + def _visit_type(self, scope: Scope, before_type: Any, after_type: Any) -> TerminalValue: + value = self._visit_value(scope=scope, before_value=before_type, after_value=after_type) + if not isinstance(value, TerminalValue): + # TODO: decide where template schema validation should occur. + raise RuntimeError() + return value + + def _visit_resource( + self, + scope: Scope, + resource_name: str, + before_resource: Maybe[dict], + after_resource: Maybe[dict], + ) -> NodeResource: + node_resource = self._visited_scopes.get(scope) + if isinstance(node_resource, NodeResource): + return node_resource + + scope_type, (before_type, after_type) = self._safe_access_in( + scope, TypeKey, before_resource, after_resource + ) + terminal_value_type = self._visit_type( + scope=scope_type, before_type=before_type, after_type=after_type + ) + + condition_reference = Nothing + scope_condition, (before_condition, after_condition) = self._safe_access_in( + scope, ConditionKey, before_resource, after_resource + ) + if before_condition or after_condition: + condition_reference = self._visit_terminal_value( + scope_condition, before_condition, after_condition + ) + + depends_on = Nothing + scope_depends_on, (before_depends_on, after_depends_on) = self._safe_access_in( + scope, DependsOnKey, before_resource, after_resource + ) + if before_depends_on or after_depends_on: + depends_on = self._visit_depends_on( + scope_depends_on, before_depends_on, after_depends_on + ) + + scope_properties, (before_properties, after_properties) = self._safe_access_in( + scope, PropertiesKey, before_resource, after_resource + ) + properties = self._visit_properties( + scope=scope_properties, + before_properties=before_properties, + after_properties=after_properties, + ) + + change_type = change_type_of( + before_resource, after_resource, [properties, condition_reference, depends_on] + ) + node_resource = NodeResource( + scope=scope, + change_type=change_type, + name=resource_name, + type_=terminal_value_type, + properties=properties, + condition_reference=condition_reference, + depends_on=depends_on, + ) + self._visited_scopes[scope] = node_resource + return node_resource + + def _visit_resources( + self, scope: Scope, before_resources: Maybe[dict], after_resources: Maybe[dict] + ) -> NodeResources: + # TODO: investigate type changes behavior. + resources: list[NodeResource] = list() + resource_names = self._safe_keys_of(before_resources, after_resources) + for resource_name in resource_names: + resource_scope, (before_resource, after_resource) = self._safe_access_in( + scope, resource_name, before_resources, after_resources + ) + resource = self._visit_resource( + scope=resource_scope, + resource_name=resource_name, + before_resource=before_resource, + after_resource=after_resource, + ) + resources.append(resource) + return NodeResources(scope=scope, resources=resources) + + def _visit_mapping( + self, scope: Scope, name: str, before_mapping: Maybe[dict], after_mapping: Maybe[dict] + ) -> NodeMapping: + bindings = self._visit_object( + scope=scope, before_object=before_mapping, after_object=after_mapping + ) + return NodeMapping(scope=scope, name=name, bindings=bindings) + + def _visit_mappings( + self, scope: Scope, before_mappings: Maybe[dict], after_mappings: Maybe[dict] + ) -> NodeMappings: + mappings: list[NodeMapping] = list() + mapping_names = self._safe_keys_of(before_mappings, after_mappings) + for mapping_name in mapping_names: + scope_mapping, (before_mapping, after_mapping) = self._safe_access_in( + scope, mapping_name, before_mappings, after_mappings + ) + mapping = self._visit_mapping( + scope=scope_mapping, + name=mapping_name, + before_mapping=before_mapping, + after_mapping=after_mapping, + ) + mappings.append(mapping) + return NodeMappings(scope=scope, mappings=mappings) + + def _visit_dynamic_parameter(self, parameter_name: str) -> ChangeSetEntity: + scope = Scope("Dynamic").open_scope("Parameters") + scope_parameter, (before_parameter, after_parameter) = self._safe_access_in( + scope, parameter_name, self._before_parameters, self._after_parameters + ) + parameter = self._visit_value( + scope=scope_parameter, before_value=before_parameter, after_value=after_parameter + ) + return parameter + + def _visit_parameter( + self, + scope: Scope, + parameter_name: str, + before_parameter: Maybe[dict], + after_parameter: Maybe[dict], + ) -> NodeParameter: + node_parameter = self._visited_scopes.get(scope) + if isinstance(node_parameter, NodeParameter): + return node_parameter + + type_scope, (before_type, after_type) = self._safe_access_in( + scope, TypeKey, before_parameter, after_parameter + ) + type_ = self._visit_value(type_scope, before_type, after_type) + + default_scope, (before_default, after_default) = self._safe_access_in( + scope, DefaultKey, before_parameter, after_parameter + ) + default_value = self._visit_value(default_scope, before_default, after_default) + + dynamic_value = self._visit_dynamic_parameter(parameter_name=parameter_name) + + node_parameter = NodeParameter( + scope=scope, + name=parameter_name, + type_=type_, + default_value=default_value, + dynamic_value=dynamic_value, + ) + self._visited_scopes[scope] = node_parameter + return node_parameter + + def _visit_parameters( + self, scope: Scope, before_parameters: Maybe[dict], after_parameters: Maybe[dict] + ) -> NodeParameters: + node_parameters = self._visited_scopes.get(scope) + if isinstance(node_parameters, NodeParameters): + return node_parameters + parameter_names: list[str] = self._safe_keys_of(before_parameters, after_parameters) + parameters: list[NodeParameter] = list() + for parameter_name in parameter_names: + parameter_scope, (before_parameter, after_parameter) = self._safe_access_in( + scope, parameter_name, before_parameters, after_parameters + ) + parameter = self._visit_parameter( + scope=parameter_scope, + parameter_name=parameter_name, + before_parameter=before_parameter, + after_parameter=after_parameter, + ) + parameters.append(parameter) + node_parameters = NodeParameters(scope=scope, parameters=parameters) + self._visited_scopes[scope] = node_parameters + return node_parameters + + @staticmethod + def _normalise_depends_on_value(value: Maybe[str | list[str]]) -> Maybe[list[str]]: + # To simplify downstream logics, reduce the type options to array of strings. + # TODO: Add integrations tests for DependsOn validations (invalid types, duplicate identifiers, etc.) + if isinstance(value, NothingType): + return value + if isinstance(value, str): + value = [value] + elif isinstance(value, list): + value.sort() + else: + raise RuntimeError( + f"Invalid type for DependsOn, expected a String or Array of String, but got: '{value}'" + ) + return value + + def _visit_depends_on( + self, + scope: Scope, + before_depends_on: Maybe[str | list[str]], + after_depends_on: Maybe[str | list[str]], + ) -> NodeDependsOn: + before_depends_on = self._normalise_depends_on_value(value=before_depends_on) + after_depends_on = self._normalise_depends_on_value(value=after_depends_on) + node_array = self._visit_array( + scope=scope, before_array=before_depends_on, after_array=after_depends_on + ) + node_depends_on = NodeDependsOn(scope=scope, depends_on=node_array) + return node_depends_on + + def _visit_condition( + self, + scope: Scope, + condition_name: str, + before_condition: Maybe[dict], + after_condition: Maybe[dict], + ) -> NodeCondition: + node_condition = self._visited_scopes.get(scope) + if isinstance(node_condition, NodeCondition): + return node_condition + body = self._visit_value( + scope=scope, before_value=before_condition, after_value=after_condition + ) + node_condition = NodeCondition(scope=scope, name=condition_name, body=body) + self._visited_scopes[scope] = node_condition + return node_condition + + def _visit_conditions( + self, scope: Scope, before_conditions: Maybe[dict], after_conditions: Maybe[dict] + ) -> NodeConditions: + node_conditions = self._visited_scopes.get(scope) + if isinstance(node_conditions, NodeConditions): + return node_conditions + condition_names: list[str] = self._safe_keys_of(before_conditions, after_conditions) + conditions: list[NodeCondition] = list() + for condition_name in condition_names: + condition_scope, (before_condition, after_condition) = self._safe_access_in( + scope, condition_name, before_conditions, after_conditions + ) + condition = self._visit_condition( + scope=condition_scope, + condition_name=condition_name, + before_condition=before_condition, + after_condition=after_condition, + ) + conditions.append(condition) + node_conditions = NodeConditions(scope=scope, conditions=conditions) + self._visited_scopes[scope] = node_conditions + return node_conditions + + def _visit_output( + self, scope: Scope, name: str, before_output: Maybe[dict], after_output: Maybe[dict] + ) -> NodeOutput: + scope_value, (before_value, after_value) = self._safe_access_in( + scope, ValueKey, before_output, after_output + ) + value = self._visit_value(scope_value, before_value, after_value) + + export: Maybe[ChangeSetEntity] = Nothing + scope_export, (before_export, after_export) = self._safe_access_in( + scope, ExportKey, before_output, after_output + ) + if before_export or after_export: + export = self._visit_value(scope_export, before_export, after_export) + + # TODO: condition references should be resolved for the condition's change_type? + condition_reference: Maybe[TerminalValue] = Nothing + scope_condition, (before_condition, after_condition) = self._safe_access_in( + scope, ConditionKey, before_output, after_output + ) + if before_condition or after_condition: + condition_reference = self._visit_terminal_value( + scope_condition, before_condition, after_condition + ) + + return NodeOutput( + scope=scope, + name=name, + value=value, + export=export, + conditional_reference=condition_reference, + ) + + def _visit_outputs( + self, scope: Scope, before_outputs: Maybe[dict], after_outputs: Maybe[dict] + ) -> NodeOutputs: + outputs: list[NodeOutput] = list() + output_names: list[str] = self._safe_keys_of(before_outputs, after_outputs) + for output_name in output_names: + scope_output, (before_output, after_output) = self._safe_access_in( + scope, output_name, before_outputs, after_outputs + ) + output = self._visit_output( + scope=scope_output, + name=output_name, + before_output=before_output, + after_output=after_output, + ) + outputs.append(output) + return NodeOutputs(scope=scope, outputs=outputs) + + def _visit_global_transform( + self, + scope: Scope, + before_global_transform: Maybe[NormalisedGlobalTransformDefinition], + after_global_transform: Maybe[NormalisedGlobalTransformDefinition], + ) -> NodeGlobalTransform: + name_scope, (before_name, after_name) = self._safe_access_in( + scope, NameKey, before_global_transform, after_global_transform + ) + name = self._visit_terminal_value( + scope=name_scope, before_value=before_name, after_value=after_name + ) + + parameters_scope, (before_parameters, after_parameters) = self._safe_access_in( + scope, ParametersKey, before_global_transform, after_global_transform + ) + parameters = self._visit_value( + scope=parameters_scope, before_value=before_parameters, after_value=after_parameters + ) + + return NodeGlobalTransform(scope=scope, name=name, parameters=parameters) + + @staticmethod + def _normalise_transformer_value(value: Maybe[str | list[Any]]) -> Maybe[list[Any]]: + # To simplify downstream logics, reduce the type options to array of transformations. + # TODO: add further validation logic + # TODO: should we sort to avoid detecting user-side ordering changes as template changes? + if isinstance(value, NothingType): + return value + elif isinstance(value, str): + value = [NormalisedGlobalTransformDefinition(Name=value, Parameters=Nothing)] + elif isinstance(value, list): + tmp_value = list() + for item in value: + if isinstance(item, str): + tmp_value.append( + NormalisedGlobalTransformDefinition(Name=item, Parameters=Nothing) + ) + else: + tmp_value.append(item) + value = tmp_value + elif isinstance(value, dict): + if "Name" not in value: + raise RuntimeError(f"Missing 'Name' field in Transform definition '{value}'") + name = value["Name"] + parameters = value.get("Parameters", Nothing) + value = [NormalisedGlobalTransformDefinition(Name=name, Parameters=parameters)] + else: + raise RuntimeError(f"Invalid Transform definition: '{value}'") + return value + + def _visit_transform( + self, scope: Scope, before_transform: Maybe[Any], after_transform: Maybe[Any] + ) -> NodeTransform: + before_transform_normalised = self._normalise_transformer_value(before_transform) + after_transform_normalised = self._normalise_transformer_value(after_transform) + global_transforms = list() + for index, (before_global_transform, after_global_transform) in enumerate( + zip_longest(before_transform_normalised, after_transform_normalised, fillvalue=Nothing) + ): + global_transform_scope = scope.open_index(index=index) + global_transform: NodeGlobalTransform = self._visit_global_transform( + scope=global_transform_scope, + before_global_transform=before_global_transform, + after_global_transform=after_global_transform, + ) + global_transforms.append(global_transform) + return NodeTransform(scope=scope, global_transforms=global_transforms) + + def _model(self, before_template: Maybe[dict], after_template: Maybe[dict]) -> NodeTemplate: + root_scope = Scope() + # TODO: visit other child types + + transform_scope, (before_transform, after_transform) = self._safe_access_in( + root_scope, TransformKey, before_template, after_template + ) + transform = self._visit_transform( + scope=transform_scope, + before_transform=before_transform, + after_transform=after_transform, + ) + + mappings_scope, (before_mappings, after_mappings) = self._safe_access_in( + root_scope, MappingsKey, before_template, after_template + ) + mappings = self._visit_mappings( + scope=mappings_scope, before_mappings=before_mappings, after_mappings=after_mappings + ) + + parameters_scope, (before_parameters, after_parameters) = self._safe_access_in( + root_scope, ParametersKey, before_template, after_template + ) + parameters = self._visit_parameters( + scope=parameters_scope, + before_parameters=before_parameters, + after_parameters=after_parameters, + ) + + conditions_scope, (before_conditions, after_conditions) = self._safe_access_in( + root_scope, ConditionsKey, before_template, after_template + ) + conditions = self._visit_conditions( + scope=conditions_scope, + before_conditions=before_conditions, + after_conditions=after_conditions, + ) + + resources_scope, (before_resources, after_resources) = self._safe_access_in( + root_scope, ResourcesKey, before_template, after_template + ) + resources = self._visit_resources( + scope=resources_scope, + before_resources=before_resources, + after_resources=after_resources, + ) + + outputs_scope, (before_outputs, after_outputs) = self._safe_access_in( + root_scope, OutputsKey, before_template, after_template + ) + outputs = self._visit_outputs( + scope=outputs_scope, before_outputs=before_outputs, after_outputs=after_outputs + ) + + return NodeTemplate( + scope=root_scope, + transform=transform, + mappings=mappings, + parameters=parameters, + conditions=conditions, + resources=resources, + outputs=outputs, + ) + + def _retrieve_condition_if_exists(self, condition_name: str) -> Maybe[NodeCondition]: + conditions_scope, (before_conditions, after_conditions) = self._safe_access_in( + Scope(), ConditionsKey, self._before_template, self._after_template + ) + before_conditions = before_conditions or dict() + after_conditions = after_conditions or dict() + if condition_name in before_conditions or condition_name in after_conditions: + condition_scope, (before_condition, after_condition) = self._safe_access_in( + conditions_scope, condition_name, before_conditions, after_conditions + ) + node_condition = self._visit_condition( + conditions_scope, + condition_name, + before_condition=before_condition, + after_condition=after_condition, + ) + return node_condition + return Nothing + + def _retrieve_parameter_if_exists(self, parameter_name: str) -> Maybe[NodeParameter]: + parameters_scope, (before_parameters, after_parameters) = self._safe_access_in( + Scope(), ParametersKey, self._before_template, self._after_template + ) + if parameter_name in before_parameters or parameter_name in after_parameters: + parameter_scope, (before_parameter, after_parameter) = self._safe_access_in( + parameters_scope, parameter_name, before_parameters, after_parameters + ) + node_parameter = self._visit_parameter( + parameter_scope, + parameter_name, + before_parameter=before_parameter, + after_parameter=after_parameter, + ) + return node_parameter + return Nothing + + def _retrieve_mapping(self, mapping_name) -> NodeMapping: + # TODO: add caching mechanism, and raise appropriate error if missing. + scope_mappings, (before_mappings, after_mappings) = self._safe_access_in( + Scope(), MappingsKey, self._before_template, self._after_template + ) + if mapping_name in before_mappings or mapping_name in after_mappings: + scope_mapping, (before_mapping, after_mapping) = self._safe_access_in( + scope_mappings, mapping_name, before_mappings, after_mappings + ) + node_mapping = self._visit_mapping( + scope_mapping, mapping_name, before_mapping, after_mapping + ) + return node_mapping + raise RuntimeError() + + def _retrieve_or_visit_resource(self, resource_name: str) -> NodeResource: + resources_scope, (before_resources, after_resources) = self._safe_access_in( + Scope(), + ResourcesKey, + self._before_template, + self._after_template, + ) + resource_scope, (before_resource, after_resource) = self._safe_access_in( + resources_scope, resource_name, before_resources, after_resources + ) + return self._visit_resource( + scope=resource_scope, + resource_name=resource_name, + before_resource=before_resource, + after_resource=after_resource, + ) + + @staticmethod + def _is_intrinsic_function_name(function_name: str) -> bool: + # TODO: are intrinsic functions soft keywords? + return function_name in INTRINSIC_FUNCTIONS + + @staticmethod + def _safe_access_in(scope: Scope, key: str, *objects: Maybe[dict]) -> tuple[Scope, Maybe[Any]]: + results = list() + for obj in objects: + if not isinstance(obj, (dict, NothingType)): + raise RuntimeError(f"Invalid definition type at '{obj}'") + if not isinstance(obj, NothingType): + results.append(obj.get(key, Nothing)) + else: + results.append(obj) + new_scope = scope.open_scope(name=key) + return new_scope, results[0] if len(objects) == 1 else tuple(results) + + @staticmethod + def _safe_keys_of(*objects: Maybe[dict]) -> list[str]: + key_set: set[str] = set() + for obj in objects: + # TODO: raise errors if not dict + if isinstance(obj, dict): + key_set.update(obj.keys()) + # The keys list is sorted to increase reproducibility of the + # update graph build process or downstream logics. + keys = sorted(key_set) + return keys + + @staticmethod + def _name_if_intrinsic_function(value: Maybe[Any]) -> Optional[str]: + if isinstance(value, dict): + keys = ChangeSetModel._safe_keys_of(value) + if len(keys) == 1: + key_name = keys[0] + if ChangeSetModel._is_intrinsic_function_name(key_name): + return key_name + return None + + @staticmethod + def _type_name_of(value: Maybe[Any]) -> str: + maybe_intrinsic_function_name = ChangeSetModel._name_if_intrinsic_function(value) + if maybe_intrinsic_function_name is not None: + return maybe_intrinsic_function_name + return type(value).__name__ + + @staticmethod + def _is_terminal(value: Any) -> bool: + return type(value) in {int, float, bool, str, None, NothingType} + + @staticmethod + def _is_object(value: Any) -> bool: + return isinstance(value, dict) and ChangeSetModel._name_if_intrinsic_function(value) is None + + @staticmethod + def _is_array(value: Any) -> bool: + return isinstance(value, list) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py new file mode 100644 index 0000000000000..14535d44a6f40 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_describer.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import json +from typing import Final, Optional + +import localstack.aws.api.cloudformation as cfn_api +from localstack.services.cloudformation.engine.v2.change_set_model import ( + NodeIntrinsicFunction, + NodeProperty, + NodeResource, + NodeResources, + PropertiesKey, + is_nothing, +) +from localstack.services.cloudformation.engine.v2.change_set_model_preproc import ( + ChangeSetModelPreproc, + PreprocEntityDelta, + PreprocProperties, + PreprocResource, +) +from localstack.services.cloudformation.v2.entities import ChangeSet + +CHANGESET_KNOWN_AFTER_APPLY: Final[str] = "{{changeSet:KNOWN_AFTER_APPLY}}" + + +class ChangeSetModelDescriber(ChangeSetModelPreproc): + _include_property_values: Final[bool] + _changes: Final[cfn_api.Changes] + + def __init__( + self, + change_set: ChangeSet, + include_property_values: bool, + ): + super().__init__(change_set=change_set) + self._include_property_values = include_property_values + self._changes = list() + + def get_changes(self) -> cfn_api.Changes: + self._changes.clear() + self.process() + return self._changes + + def _setup_runtime_cache(self) -> None: + # The describer can output {{changeSet:KNOWN_AFTER_APPLY}} values as not every field + # is computable at describe time. Until a filtering logic or executor override logic + # is available, the describer cannot benefit of previous evaluations to compute + # change set resource changes. + pass + + def _save_runtime_cache(self) -> None: + # The describer can output {{changeSet:KNOWN_AFTER_APPLY}} values as not every field + # is computable at describe time. Until a filtering logic or executor override logic + # is available, there are no benefits in having the describer saving its runtime cache + # for future changes chains. + pass + + def _resolve_attribute(self, arguments: str | list[str], select_before: bool) -> str: + if select_before: + return super()._resolve_attribute(arguments=arguments, select_before=select_before) + + # Replicate AWS's limitations in describing change set's updated values. + # Consideration: If we can properly compute the before and after value, why should we + # artificially limit the precision of our output to match AWS's? + + arguments_list: list[str] + if isinstance(arguments, str): + arguments_list = arguments.split(".") + else: + arguments_list = arguments + logical_name_of_resource = arguments_list[0] + attribute_name = arguments_list[1] + + node_resource = self._get_node_resource_for( + resource_name=logical_name_of_resource, + node_template=self._change_set.update_model.node_template, + ) + node_property: Optional[NodeProperty] = self._get_node_property_for( + property_name=attribute_name, node_resource=node_resource + ) + if node_property is not None: + property_delta = self.visit(node_property) + if property_delta.before == property_delta.after: + value = property_delta.after + else: + value = CHANGESET_KNOWN_AFTER_APPLY + else: + try: + value = self._after_deployed_property_value_of( + resource_logical_id=logical_name_of_resource, + property_name=attribute_name, + ) + except RuntimeError: + value = CHANGESET_KNOWN_AFTER_APPLY + + return value + + def visit_node_intrinsic_function_fn_join( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + # TODO: investigate the behaviour and impact of this logic with the user defining + # {{changeSet:KNOWN_AFTER_APPLY}} string literals as delimiters or arguments. + delta = super().visit_node_intrinsic_function_fn_join( + node_intrinsic_function=node_intrinsic_function + ) + delta_before = delta.before + if isinstance(delta_before, str) and CHANGESET_KNOWN_AFTER_APPLY in delta_before: + delta.before = CHANGESET_KNOWN_AFTER_APPLY + delta_after = delta.after + if isinstance(delta_after, str) and CHANGESET_KNOWN_AFTER_APPLY in delta_after: + delta.after = CHANGESET_KNOWN_AFTER_APPLY + return delta + + def _register_resource_change( + self, + logical_id: str, + type_: str, + physical_id: Optional[str], + before_properties: Optional[PreprocProperties], + after_properties: Optional[PreprocProperties], + ) -> None: + action = cfn_api.ChangeAction.Modify + if before_properties is None: + action = cfn_api.ChangeAction.Add + elif after_properties is None: + action = cfn_api.ChangeAction.Remove + + resource_change = cfn_api.ResourceChange() + resource_change["Action"] = action + resource_change["LogicalResourceId"] = logical_id + resource_change["ResourceType"] = type_ + if physical_id: + resource_change["PhysicalResourceId"] = physical_id + if self._include_property_values and before_properties is not None: + before_context_properties = {PropertiesKey: before_properties.properties} + before_context_properties_json_str = json.dumps(before_context_properties) + resource_change["BeforeContext"] = before_context_properties_json_str + if self._include_property_values and after_properties is not None: + after_context_properties = {PropertiesKey: after_properties.properties} + after_context_properties_json_str = json.dumps(after_context_properties) + resource_change["AfterContext"] = after_context_properties_json_str + self._changes.append( + cfn_api.Change(Type=cfn_api.ChangeType.Resource, ResourceChange=resource_change) + ) + + def _describe_resource_change( + self, name: str, before: Optional[PreprocResource], after: Optional[PreprocResource] + ) -> None: + if before == after: + # unchanged: nothing to do. + return + if not is_nothing(before) and not is_nothing(after): + # Case: change on same type. + if before.resource_type == after.resource_type: + # Register a Modified if changed. + self._register_resource_change( + logical_id=name, + physical_id=before.physical_resource_id, + type_=before.resource_type, + before_properties=before.properties, + after_properties=after.properties, + ) + # Case: type migration. + # TODO: Add test to assert that on type change the resources are replaced. + else: + # Register a Removed for the previous type. + self._register_resource_change( + logical_id=name, + physical_id=before.physical_resource_id, + type_=before.resource_type, + before_properties=before.properties, + after_properties=None, + ) + # Register a Create for the next type. + self._register_resource_change( + logical_id=name, + physical_id=None, + type_=after.resource_type, + before_properties=None, + after_properties=after.properties, + ) + elif not is_nothing(before): + # Case: removal + self._register_resource_change( + logical_id=name, + physical_id=before.physical_resource_id, + type_=before.resource_type, + before_properties=before.properties, + after_properties=None, + ) + elif not is_nothing(after): + # Case: addition + self._register_resource_change( + logical_id=name, + physical_id=None, + type_=after.resource_type, + before_properties=None, + after_properties=after.properties, + ) + + def visit_node_resources(self, node_resources: NodeResources) -> None: + for node_resource in node_resources.resources: + delta_resource = self.visit(node_resource) + self._describe_resource_change( + name=node_resource.name, before=delta_resource.before, after=delta_resource.after + ) + + def visit_node_resource( + self, node_resource: NodeResource + ) -> PreprocEntityDelta[PreprocResource, PreprocResource]: + delta = super().visit_node_resource(node_resource=node_resource) + after_resource = delta.after + if not is_nothing(after_resource) and after_resource.physical_resource_id is None: + after_resource.physical_resource_id = CHANGESET_KNOWN_AFTER_APPLY + return delta diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py new file mode 100644 index 0000000000000..e308512e74ddb --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_executor.py @@ -0,0 +1,484 @@ +import copy +import logging +import uuid +from dataclasses import dataclass +from typing import Final, Optional + +from localstack import config +from localstack.aws.api.cloudformation import ( + ChangeAction, + ResourceStatus, + StackStatus, +) +from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY +from localstack.services.cloudformation.analytics import track_resource_operation +from localstack.services.cloudformation.deployment_utils import log_not_available_message +from localstack.services.cloudformation.engine.parameters import resolve_ssm_parameter +from localstack.services.cloudformation.engine.v2.change_set_model import ( + NodeDependsOn, + NodeOutput, + NodeParameter, + NodeResource, + is_nothing, +) +from localstack.services.cloudformation.engine.v2.change_set_model_preproc import ( + ChangeSetModelPreproc, + PreprocEntityDelta, + PreprocOutput, + PreprocProperties, + PreprocResource, +) +from localstack.services.cloudformation.resource_provider import ( + Credentials, + NoResourceProvider, + OperationStatus, + ProgressEvent, + ResourceProviderExecutor, + ResourceProviderPayload, +) +from localstack.services.cloudformation.v2.entities import ChangeSet + +LOG = logging.getLogger(__name__) + +EventOperationFromAction = {"Add": "CREATE", "Modify": "UPDATE", "Remove": "DELETE"} + + +@dataclass +class ChangeSetModelExecutorResult: + resources: dict + parameters: dict + outputs: dict + + +class ChangeSetModelExecutor(ChangeSetModelPreproc): + # TODO: add typing for resolved resources and parameters. + resources: Final[dict] + outputs: Final[dict] + resolved_parameters: Final[dict] + + def __init__(self, change_set: ChangeSet): + super().__init__(change_set=change_set) + self.resources = dict() + self.outputs = dict() + self.resolved_parameters = dict() + + # TODO: use a structured type for the return value + def execute(self) -> ChangeSetModelExecutorResult: + self.process() + return ChangeSetModelExecutorResult( + resources=self.resources, parameters=self.resolved_parameters, outputs=self.outputs + ) + + def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDelta: + delta = super().visit_node_parameter(node_parameter) + + # handle dynamic references, e.g. references to SSM parameters + # TODO: support more parameter types + parameter_type: str = node_parameter.type_.value + if parameter_type.startswith("AWS::SSM"): + if parameter_type in [ + "AWS::SSM::Parameter::Value", + "AWS::SSM::Parameter::Value", + "AWS::SSM::Parameter::Value", + ]: + delta.after = resolve_ssm_parameter( + account_id=self._change_set.account_id, + region_name=self._change_set.region_name, + stack_parameter_value=delta.after, + ) + else: + raise Exception(f"Unsupported stack parameter type: {parameter_type}") + + self.resolved_parameters[node_parameter.name] = delta.after + return delta + + def _get_physical_id(self, logical_resource_id, strict: bool = True) -> str | None: + physical_resource_id = None + try: + physical_resource_id = self._after_resource_physical_id(logical_resource_id) + except RuntimeError: + # The physical id is missing or is set to None, which is invalid. + pass + if physical_resource_id is None: + # The physical resource id is None after an update that didn't rewrite the resource, the previous + # resource id is therefore the current physical id of this resource. + + try: + physical_resource_id = self._before_resource_physical_id(logical_resource_id) + except RuntimeError as e: + if strict: + raise e + return physical_resource_id + + def _process_event( + self, + action: ChangeAction, + logical_resource_id, + event_status: OperationStatus, + special_action: str = None, + reason: str = None, + resource_type=None, + ): + status_from_action = special_action or EventOperationFromAction[action.value] + if event_status == OperationStatus.SUCCESS: + status = f"{status_from_action}_COMPLETE" + else: + status = f"{status_from_action}_{event_status.name}" + + self._change_set.stack.set_resource_status( + logical_resource_id=logical_resource_id, + physical_resource_id=self._get_physical_id(logical_resource_id, False), + resource_type=resource_type, + status=ResourceStatus(status), + resource_status_reason=reason, + ) + + if event_status == OperationStatus.FAILED: + self._change_set.stack.set_stack_status(StackStatus(status)) + + def _after_deployed_property_value_of( + self, resource_logical_id: str, property_name: str + ) -> str: + after_resolved_resources = self.resources + return self._deployed_property_value_of( + resource_logical_id=resource_logical_id, + property_name=property_name, + resolved_resources=after_resolved_resources, + ) + + def _after_resource_physical_id(self, resource_logical_id: str) -> str: + after_resolved_resources = self.resources + return self._resource_physical_resource_id_from( + logical_resource_id=resource_logical_id, resolved_resources=after_resolved_resources + ) + + def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntityDelta: + array_identifiers_delta = super().visit_node_depends_on(node_depends_on=node_depends_on) + + # Visit depends_on resources before returning. + depends_on_resource_logical_ids: set[str] = set() + if array_identifiers_delta.before: + depends_on_resource_logical_ids.update(array_identifiers_delta.before) + if array_identifiers_delta.after: + depends_on_resource_logical_ids.update(array_identifiers_delta.after) + for depends_on_resource_logical_id in depends_on_resource_logical_ids: + node_resource = self._get_node_resource_for( + resource_name=depends_on_resource_logical_id, + node_template=self._change_set.update_model.node_template, + ) + self.visit(node_resource) + + return array_identifiers_delta + + def visit_node_resource( + self, node_resource: NodeResource + ) -> PreprocEntityDelta[PreprocResource, PreprocResource]: + """ + Overrides the default preprocessing for NodeResource objects by annotating the + `after` delta with the physical resource ID, if side effects resulted in an update. + """ + delta = super().visit_node_resource(node_resource=node_resource) + before = delta.before + after = delta.after + + if before != after: + # There are changes for this resource. + self._execute_resource_change(name=node_resource.name, before=before, after=after) + else: + # There are no updates for this resource; iff the resource was previously + # deployed, then the resolved details are copied in the current state for + # references or other downstream operations. + if not is_nothing(before): + before_logical_id = delta.before.logical_id + before_resource = self._before_resolved_resources.get(before_logical_id, dict()) + self.resources[before_logical_id] = before_resource + + # Update the latest version of this resource for downstream references. + if not is_nothing(after): + after_logical_id = after.logical_id + after_physical_id: str = self._after_resource_physical_id( + resource_logical_id=after_logical_id + ) + after.physical_resource_id = after_physical_id + return delta + + def visit_node_output( + self, node_output: NodeOutput + ) -> PreprocEntityDelta[PreprocOutput, PreprocOutput]: + delta = super().visit_node_output(node_output=node_output) + after = delta.after + if is_nothing(after) or (isinstance(after, PreprocOutput) and after.condition is False): + return delta + self.outputs[delta.after.name] = delta.after.value + return delta + + def _execute_resource_change( + self, name: str, before: Optional[PreprocResource], after: Optional[PreprocResource] + ) -> None: + # Changes are to be made about this resource. + # TODO: this logic is a POC and should be revised. + if not is_nothing(before) and not is_nothing(after): + # Case: change on same type. + if before.resource_type == after.resource_type: + # Register a Modified if changed. + # XXX hacky, stick the previous resources' properties into the payload + before_properties = self._merge_before_properties(name, before) + + self._process_event(ChangeAction.Modify, name, OperationStatus.IN_PROGRESS) + event = self._execute_resource_action( + action=ChangeAction.Modify, + logical_resource_id=name, + resource_type=before.resource_type, + before_properties=before_properties, + after_properties=after.properties, + ) + self._process_event( + ChangeAction.Modify, + name, + event.status, + reason=event.message, + resource_type=before.resource_type, + ) + # Case: type migration. + # TODO: Add test to assert that on type change the resources are replaced. + else: + # XXX hacky, stick the previous resources' properties into the payload + before_properties = self._merge_before_properties(name, before) + # Register a Removed for the previous type. + + event = self._execute_resource_action( + action=ChangeAction.Remove, + logical_resource_id=name, + resource_type=before.resource_type, + before_properties=before_properties, + after_properties=None, + ) + # Register a Create for the next type. + self._process_event( + ChangeAction.Modify, + name, + event.status, + reason=event.message, + resource_type=before.resource_type, + ) + event = self._execute_resource_action( + action=ChangeAction.Add, + logical_resource_id=name, + resource_type=after.resource_type, + before_properties=None, + after_properties=after.properties, + ) + self._process_event( + ChangeAction.Modify, + name, + event.status, + reason=event.message, + resource_type=before.resource_type, + ) + elif not is_nothing(before): + # Case: removal + # XXX hacky, stick the previous resources' properties into the payload + # XXX hacky, stick the previous resources' properties into the payload + before_properties = self._merge_before_properties(name, before) + self._process_event( + ChangeAction.Remove, + name, + OperationStatus.IN_PROGRESS, + resource_type=before.resource_type, + ) + event = self._execute_resource_action( + action=ChangeAction.Remove, + logical_resource_id=name, + resource_type=before.resource_type, + before_properties=before_properties, + after_properties=None, + ) + self._process_event( + ChangeAction.Remove, + name, + event.status, + reason=event.message, + resource_type=before.resource_type, + ) + elif not is_nothing(after): + # Case: addition + self._process_event( + ChangeAction.Add, + name, + OperationStatus.IN_PROGRESS, + resource_type=after.resource_type, + ) + event = self._execute_resource_action( + action=ChangeAction.Add, + logical_resource_id=name, + resource_type=after.resource_type, + before_properties=None, + after_properties=after.properties, + ) + self._process_event( + ChangeAction.Add, + name, + event.status, + reason=event.message, + resource_type=after.resource_type, + ) + + def _merge_before_properties( + self, name: str, preproc_resource: PreprocResource + ) -> PreprocProperties: + if previous_resource_properties := self._change_set.stack.resolved_resources.get( + name, {} + ).get("Properties"): + return PreprocProperties(properties=previous_resource_properties) + + # XXX fall back to returning the input value + return copy.deepcopy(preproc_resource.properties) + + def _execute_resource_action( + self, + action: ChangeAction, + logical_resource_id: str, + resource_type: str, + before_properties: Optional[PreprocProperties], + after_properties: Optional[PreprocProperties], + ) -> ProgressEvent: + LOG.debug("Executing resource action: %s for resource '%s'", action, logical_resource_id) + resource_provider_executor = ResourceProviderExecutor( + stack_name=self._change_set.stack.stack_name, stack_id=self._change_set.stack.stack_id + ) + payload = self.create_resource_provider_payload( + action=action, + logical_resource_id=logical_resource_id, + resource_type=resource_type, + before_properties=before_properties, + after_properties=after_properties, + ) + resource_provider = resource_provider_executor.try_load_resource_provider(resource_type) + track_resource_operation(action, resource_type, missing=resource_provider is not None) + if resource_provider is None: + log_not_available_message( + resource_type, + f'No resource provider found for "{resource_type}"', + ) + if not config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES: + raise NoResourceProvider + + extra_resource_properties = {} + event = ProgressEvent(OperationStatus.SUCCESS, resource_model={}) + if resource_provider is not None: + # TODO: stack events + try: + event = resource_provider_executor.deploy_loop( + resource_provider, extra_resource_properties, payload + ) + except Exception as e: + reason = str(e) + LOG.warning( + "Resource provider operation failed: '%s'", + reason, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + stack = self._change_set.stack + stack.set_resource_status( + logical_resource_id=logical_resource_id, + # TODO, + physical_resource_id="", + resource_type=resource_type, + status=ResourceStatus.CREATE_FAILED + if action == ChangeAction.Add + else ResourceStatus.UPDATE_FAILED, + resource_status_reason=reason, + ) + event = ProgressEvent( + OperationStatus.FAILED, + resource_model={}, + message=f"Resource provider operation failed: {reason}", + ) + + self.resources.setdefault(logical_resource_id, {"Properties": {}}) + match event.status: + case OperationStatus.SUCCESS: + # merge the resources state with the external state + # TODO: this is likely a duplicate of updating from extra_resource_properties + + # TODO: add typing + # TODO: avoid the use of string literals for sampling from the object, use typed classes instead + # TODO: avoid sampling from resources and use tmp var reference + # TODO: add utils functions to abstract this logic away (resource.update(..)) + # TODO: avoid the use of setdefault (debuggability/readability) + # TODO: review the use of merge + + self.resources[logical_resource_id]["Properties"].update(event.resource_model) + self.resources[logical_resource_id].update(extra_resource_properties) + # XXX for legacy delete_stack compatibility + self.resources[logical_resource_id]["LogicalResourceId"] = logical_resource_id + self.resources[logical_resource_id]["Type"] = resource_type + + physical_resource_id = self._get_physical_id(logical_resource_id) + self.resources[logical_resource_id]["PhysicalResourceId"] = physical_resource_id + + case OperationStatus.FAILED: + reason = event.message + LOG.warning( + "Resource provider operation failed: '%s'", + reason, + ) + case other: + raise NotImplementedError(f"Event status '{other}' not handled") + return event + + def create_resource_provider_payload( + self, + action: ChangeAction, + logical_resource_id: str, + resource_type: str, + before_properties: Optional[PreprocProperties], + after_properties: Optional[PreprocProperties], + ) -> Optional[ResourceProviderPayload]: + # FIXME: use proper credentials + creds: Credentials = { + "accessKeyId": self._change_set.stack.account_id, + "secretAccessKey": INTERNAL_AWS_SECRET_ACCESS_KEY, + "sessionToken": "", + } + before_properties_value = before_properties.properties if before_properties else None + after_properties_value = after_properties.properties if after_properties else None + + match action: + case ChangeAction.Add: + resource_properties = after_properties_value or {} + previous_resource_properties = None + case ChangeAction.Modify | ChangeAction.Dynamic: + resource_properties = after_properties_value or {} + previous_resource_properties = before_properties_value or {} + case ChangeAction.Remove: + resource_properties = before_properties_value or {} + # previous_resource_properties = None + # HACK: our providers use a mix of `desired_state` and `previous_state` so ensure the payload is present for both + previous_resource_properties = resource_properties + case _: + raise NotImplementedError(f"Action '{action}' not handled") + + resource_provider_payload: ResourceProviderPayload = { + "awsAccountId": self._change_set.stack.account_id, + "callbackContext": {}, + "stackId": self._change_set.stack.stack_name, + "resourceType": resource_type, + "resourceTypeVersion": "000000", + # TODO: not actually a UUID + "bearerToken": str(uuid.uuid4()), + "region": self._change_set.stack.region_name, + "action": str(action), + "requestData": { + "logicalResourceId": logical_resource_id, + "resourceProperties": resource_properties, + "previousResourceProperties": previous_resource_properties, + "callerCredentials": creds, + "providerCredentials": creds, + "systemTags": {}, + "previousSystemTags": {}, + "stackTags": {}, + "previousStackTags": {}, + }, + } + return resource_provider_payload diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py new file mode 100644 index 0000000000000..abaae139c741f --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py @@ -0,0 +1,1177 @@ +from __future__ import annotations + +import base64 +import copy +import re +from typing import Any, Callable, Final, Generic, Optional, TypeVar + +from botocore.exceptions import ClientError + +from localstack.aws.api.ec2 import AvailabilityZoneList, DescribeAvailabilityZonesResult +from localstack.aws.connect import connect_to +from localstack.services.cloudformation.engine.transformers import ( + Transformer, + execute_macro, + transformers, +) +from localstack.services.cloudformation.engine.v2.change_set_model import ( + ChangeSetEntity, + ChangeType, + Maybe, + NodeArray, + NodeCondition, + NodeDependsOn, + NodeDivergence, + NodeIntrinsicFunction, + NodeMapping, + NodeObject, + NodeOutput, + NodeOutputs, + NodeParameter, + NodeParameters, + NodeProperties, + NodeProperty, + NodeResource, + NodeTemplate, + Nothing, + NothingType, + Scope, + TerminalValue, + TerminalValueCreated, + TerminalValueModified, + TerminalValueRemoved, + TerminalValueUnchanged, + is_nothing, +) +from localstack.services.cloudformation.engine.v2.change_set_model_visitor import ( + ChangeSetModelVisitor, +) +from localstack.services.cloudformation.stores import get_cloudformation_store +from localstack.services.cloudformation.v2.entities import ChangeSet +from localstack.utils.aws.arns import get_partition +from localstack.utils.run import to_str +from localstack.utils.strings import to_bytes +from localstack.utils.urls import localstack_host + +_AWS_URL_SUFFIX = localstack_host().host # The value in AWS is "amazonaws.com" + +_PSEUDO_PARAMETERS: Final[set[str]] = { + "AWS::Partition", + "AWS::AccountId", + "AWS::Region", + "AWS::StackName", + "AWS::StackId", + "AWS::URLSuffix", + "AWS::NoValue", + "AWS::NotificationARNs", +} + +TBefore = TypeVar("TBefore") +TAfter = TypeVar("TAfter") + + +class PreprocEntityDelta(Generic[TBefore, TAfter]): + before: Maybe[TBefore] + after: Maybe[TAfter] + + def __init__(self, before: Maybe[TBefore] = Nothing, after: Maybe[TAfter] = Nothing): + self.before = before + self.after = after + + def __eq__(self, other): + if not isinstance(other, PreprocEntityDelta): + return False + return self.before == other.before and self.after == other.after + + +class PreprocProperties: + properties: dict[str, Any] + + def __init__(self, properties: dict[str, Any]): + self.properties = properties + + def __eq__(self, other): + if not isinstance(other, PreprocProperties): + return False + return self.properties == other.properties + + +class PreprocResource: + logical_id: str + physical_resource_id: Optional[str] + condition: Optional[bool] + resource_type: str + properties: PreprocProperties + depends_on: Optional[list[str]] + + def __init__( + self, + logical_id: str, + physical_resource_id: str, + condition: Optional[bool], + resource_type: str, + properties: PreprocProperties, + depends_on: Optional[list[str]], + ): + self.logical_id = logical_id + self.physical_resource_id = physical_resource_id + self.condition = condition + self.resource_type = resource_type + self.properties = properties + self.depends_on = depends_on + + @staticmethod + def _compare_conditions(c1: bool, c2: bool): + # The lack of condition equates to a true condition. + c1 = c1 if isinstance(c1, bool) else True + c2 = c2 if isinstance(c2, bool) else True + return c1 == c2 + + def __eq__(self, other): + if not isinstance(other, PreprocResource): + return False + return all( + [ + self.logical_id == other.logical_id, + self._compare_conditions(self.condition, other.condition), + self.resource_type == other.resource_type, + self.properties == other.properties, + ] + ) + + +class PreprocOutput: + name: str + value: Any + export: Optional[Any] + condition: Optional[bool] + + def __init__(self, name: str, value: Any, export: Optional[Any], condition: Optional[bool]): + self.name = name + self.value = value + self.export = export + self.condition = condition + + def __eq__(self, other): + if not isinstance(other, PreprocOutput): + return False + return all( + [ + self.name == other.name, + self.value == other.value, + self.export == other.export, + self.condition == other.condition, + ] + ) + + +class ChangeSetModelPreproc(ChangeSetModelVisitor): + _change_set: Final[ChangeSet] + _before_resolved_resources: Final[dict] + _before_cache: Final[dict[Scope, Any]] + _after_cache: Final[dict[Scope, Any]] + + def __init__(self, change_set: ChangeSet): + self._change_set = change_set + self._before_resolved_resources = change_set.stack.resolved_resources + self._before_cache = dict() + self._after_cache = dict() + + def _setup_runtime_cache(self) -> None: + runtime_cache_key = self.__class__.__name__ + + self._before_cache.clear() + self._after_cache.clear() + + before_runtime_cache = self._change_set.update_model.before_runtime_cache + if cache := before_runtime_cache.get(runtime_cache_key): + self._before_cache.update(cache) + + after_runtime_cache = self._change_set.update_model.after_runtime_cache + if cache := after_runtime_cache.get(runtime_cache_key): + self._after_cache.update(cache) + + def _save_runtime_cache(self) -> None: + runtime_cache_key = self.__class__.__name__ + + before_runtime_cache = self._change_set.update_model.before_runtime_cache + before_runtime_cache[runtime_cache_key] = copy.deepcopy(self._before_cache) + + after_runtime_cache = self._change_set.update_model.after_runtime_cache + after_runtime_cache[runtime_cache_key] = copy.deepcopy(self._after_cache) + + def process(self) -> None: + self._setup_runtime_cache() + node_template = self._change_set.update_model.node_template + self.visit(node_template) + self._save_runtime_cache() + + def _get_node_resource_for( + self, resource_name: str, node_template: NodeTemplate + ) -> NodeResource: + # TODO: this could be improved with hashmap lookups if the Node contained bindings and not lists. + for node_resource in node_template.resources.resources: + if node_resource.name == resource_name: + self.visit(node_resource) + return node_resource + raise RuntimeError(f"No resource '{resource_name}' was found") + + def _get_node_property_for( + self, property_name: str, node_resource: NodeResource + ) -> Optional[NodeProperty]: + # TODO: this could be improved with hashmap lookups if the Node contained bindings and not lists. + for node_property in node_resource.properties.properties: + if node_property.name == property_name: + self.visit(node_property) + return node_property + return None + + def _deployed_property_value_of( + self, resource_logical_id: str, property_name: str, resolved_resources: dict + ) -> Any: + # TODO: typing around resolved resources is needed and should be reflected here. + + # Before we can obtain deployed value for a resource, we need to first ensure to + # process the resource if this wasn't processed already. Ideally, values should only + # be accessible through delta objects, to ensure computation is always complete at + # every level. + _ = self._get_node_resource_for( + resource_name=resource_logical_id, + node_template=self._change_set.update_model.node_template, + ) + resolved_resource = resolved_resources.get(resource_logical_id) + if resolved_resource is None: + raise RuntimeError( + f"No deployed instances of resource '{resource_logical_id}' were found" + ) + properties = resolved_resource.get("Properties", dict()) + property_value: Optional[Any] = properties.get(property_name) + if property_value is None: + raise RuntimeError( + f"No '{property_name}' found for deployed resource '{resource_logical_id}' was found" + ) + return property_value + + def _before_deployed_property_value_of( + self, resource_logical_id: str, property_name: str + ) -> Any: + return self._deployed_property_value_of( + resource_logical_id=resource_logical_id, + property_name=property_name, + resolved_resources=self._before_resolved_resources, + ) + + def _after_deployed_property_value_of( + self, resource_logical_id: str, property_name: str + ) -> Optional[str]: + return self._before_deployed_property_value_of( + resource_logical_id=resource_logical_id, property_name=property_name + ) + + def _get_node_mapping(self, map_name: str) -> NodeMapping: + mappings: list[NodeMapping] = self._change_set.update_model.node_template.mappings.mappings + # TODO: another scenarios suggesting property lookups might be preferable. + for mapping in mappings: + if mapping.name == map_name: + self.visit(mapping) + return mapping + raise RuntimeError(f"Undefined '{map_name}' mapping") + + def _get_node_parameter_if_exists(self, parameter_name: str) -> Maybe[NodeParameter]: + parameters: list[NodeParameter] = ( + self._change_set.update_model.node_template.parameters.parameters + ) + # TODO: another scenarios suggesting property lookups might be preferable. + for parameter in parameters: + if parameter.name == parameter_name: + self.visit(parameter) + return parameter + return Nothing + + def _get_node_condition_if_exists(self, condition_name: str) -> Maybe[NodeCondition]: + conditions: list[NodeCondition] = ( + self._change_set.update_model.node_template.conditions.conditions + ) + # TODO: another scenarios suggesting property lookups might be preferable. + for condition in conditions: + if condition.name == condition_name: + self.visit(condition) + return condition + return Nothing + + def _resolve_condition(self, logical_id: str) -> PreprocEntityDelta: + node_condition = self._get_node_condition_if_exists(condition_name=logical_id) + if isinstance(node_condition, NodeCondition): + condition_delta = self.visit(node_condition) + return condition_delta + raise RuntimeError(f"No condition '{logical_id}' was found.") + + def _resolve_pseudo_parameter(self, pseudo_parameter_name: str) -> Any: + match pseudo_parameter_name: + case "AWS::Partition": + return get_partition(self._change_set.region_name) + case "AWS::AccountId": + return self._change_set.stack.account_id + case "AWS::Region": + return self._change_set.stack.region_name + case "AWS::StackName": + return self._change_set.stack.stack_name + case "AWS::StackId": + return self._change_set.stack.stack_id + case "AWS::URLSuffix": + return _AWS_URL_SUFFIX + case "AWS::NoValue": + return None + case _: + raise RuntimeError(f"The use of '{pseudo_parameter_name}' is currently unsupported") + + def _resolve_reference(self, logical_id: str) -> PreprocEntityDelta: + if logical_id in _PSEUDO_PARAMETERS: + pseudo_parameter_value = self._resolve_pseudo_parameter( + pseudo_parameter_name=logical_id + ) + # Pseudo parameters are constants within the lifecycle of a template. + return PreprocEntityDelta(before=pseudo_parameter_value, after=pseudo_parameter_value) + + node_parameter = self._get_node_parameter_if_exists(parameter_name=logical_id) + if isinstance(node_parameter, NodeParameter): + parameter_delta = self.visit(node_parameter) + return parameter_delta + + node_resource = self._get_node_resource_for( + resource_name=logical_id, node_template=self._change_set.update_model.node_template + ) + resource_delta = self.visit(node_resource) + before = resource_delta.before + after = resource_delta.after + return PreprocEntityDelta(before=before, after=after) + + def _resolve_mapping( + self, map_name: str, top_level_key: str, second_level_key + ) -> PreprocEntityDelta: + # TODO: add support for nested intrinsic functions, and KNOWN AFTER APPLY logical ids. + node_mapping: NodeMapping = self._get_node_mapping(map_name=map_name) + top_level_value = node_mapping.bindings.bindings.get(top_level_key) + if not isinstance(top_level_value, NodeObject): + raise RuntimeError() + second_level_value = top_level_value.bindings.get(second_level_key) + mapping_value_delta = self.visit(second_level_value) + return mapping_value_delta + + def visit(self, change_set_entity: ChangeSetEntity) -> PreprocEntityDelta: + entity_scope = change_set_entity.scope + if entity_scope in self._before_cache and entity_scope in self._after_cache: + before = self._before_cache[entity_scope] + after = self._after_cache[entity_scope] + return PreprocEntityDelta(before=before, after=after) + delta = super().visit(change_set_entity=change_set_entity) + if isinstance(delta, PreprocEntityDelta): + self._before_cache[entity_scope] = delta.before + self._after_cache[entity_scope] = delta.after + return delta + + def _cached_apply( + self, scope: Scope, arguments_delta: PreprocEntityDelta, resolver: Callable[[Any], Any] + ) -> PreprocEntityDelta: + """ + Applies the resolver function to the given input delta if and only if the required + values are not already present in the runtime caches. This function handles both + the 'before' and 'after' components of the delta independently. + + The resolver function receives either the 'before' or 'after' value from the input + delta and returns a resolved value. If the result returned by the resolver is + itself a PreprocEntityDelta, the function automatically extracts the appropriate + component from it: the 'before' value if the input was 'before', and the 'after' + value if the input was 'after'. + + This function only reads from the cache and does not update it. It is the caller's + responsibility to handle caching, either manually or via the upstream visit method + of this class. + + Args: + scope (Scope): The current scope used as a key for cache lookup. + arguments_delta (PreprocEntityDelta): The delta containing 'before' and 'after' values to resolve. + resolver (Callable[[Any], Any]): Function to apply on uncached 'before' or 'after' argument values. + + Returns: + PreprocEntityDelta: A new delta with resolved 'before' and 'after' values. + """ + + # TODO: Update all visit_* methods in this class and its subclasses to use this function. + # This ensures maximal reuse of precomputed 'before' (and 'after') values from + # prior runtimes on the change sets template, thus avoiding unnecessary recomputation. + + arguments_before = arguments_delta.before + arguments_after = arguments_delta.after + + before = self._before_cache.get(scope, Nothing) + if is_nothing(before) and not is_nothing(arguments_before): + before = resolver(arguments_before) + if isinstance(before, PreprocEntityDelta): + before = before.before + + after = self._after_cache.get(scope, Nothing) + if is_nothing(after) and not is_nothing(arguments_after): + after = resolver(arguments_after) + if isinstance(after, PreprocEntityDelta): + after = after.after + + return PreprocEntityDelta(before=before, after=after) + + def visit_terminal_value_modified( + self, terminal_value_modified: TerminalValueModified + ) -> PreprocEntityDelta: + return PreprocEntityDelta( + before=terminal_value_modified.value, + after=terminal_value_modified.modified_value, + ) + + def visit_terminal_value_created( + self, terminal_value_created: TerminalValueCreated + ) -> PreprocEntityDelta: + return PreprocEntityDelta(after=terminal_value_created.value) + + def visit_terminal_value_removed( + self, terminal_value_removed: TerminalValueRemoved + ) -> PreprocEntityDelta: + return PreprocEntityDelta(before=terminal_value_removed.value) + + def visit_terminal_value_unchanged( + self, terminal_value_unchanged: TerminalValueUnchanged + ) -> PreprocEntityDelta: + return PreprocEntityDelta( + before=terminal_value_unchanged.value, + after=terminal_value_unchanged.value, + ) + + def visit_node_divergence(self, node_divergence: NodeDivergence) -> PreprocEntityDelta: + before_delta = self.visit(node_divergence.value) + after_delta = self.visit(node_divergence.divergence) + return PreprocEntityDelta(before=before_delta.before, after=after_delta.after) + + def visit_node_object(self, node_object: NodeObject) -> PreprocEntityDelta: + node_change_type = node_object.change_type + before = dict() if node_change_type != ChangeType.CREATED else Nothing + after = dict() if node_change_type != ChangeType.REMOVED else Nothing + for name, change_set_entity in node_object.bindings.items(): + delta: PreprocEntityDelta = self.visit(change_set_entity=change_set_entity) + delta_before = delta.before + delta_after = delta.after + if not is_nothing(before) and not is_nothing(delta_before) and delta_before is not None: + before[name] = delta_before + if not is_nothing(after) and not is_nothing(delta_after) and delta_after is not None: + after[name] = delta_after + return PreprocEntityDelta(before=before, after=after) + + def _resolve_attribute(self, arguments: str | list[str], select_before: bool) -> str: + # TODO: add arguments validation. + arguments_list: list[str] + if isinstance(arguments, str): + arguments_list = arguments.split(".") + else: + arguments_list = arguments + logical_name_of_resource = arguments_list[0] + attribute_name = arguments_list[1] + + node_resource = self._get_node_resource_for( + resource_name=logical_name_of_resource, + node_template=self._change_set.update_model.node_template, + ) + node_property: Optional[NodeProperty] = self._get_node_property_for( + property_name=attribute_name, node_resource=node_resource + ) + if node_property is not None: + # The property is statically defined in the template and its value can be computed. + property_delta = self.visit(node_property) + value = property_delta.before if select_before else property_delta.after + else: + # The property is not statically defined and must therefore be available in + # the properties deployed set. + if select_before: + value = self._before_deployed_property_value_of( + resource_logical_id=logical_name_of_resource, + property_name=attribute_name, + ) + else: + value = self._after_deployed_property_value_of( + resource_logical_id=logical_name_of_resource, + property_name=attribute_name, + ) + return value + + def visit_node_intrinsic_function_fn_get_att( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + # TODO: validate the return value according to the spec. + arguments_delta = self.visit(node_intrinsic_function.arguments) + before_arguments: Maybe[str | list[str]] = arguments_delta.before + after_arguments: Maybe[str | list[str]] = arguments_delta.after + + before = self._before_cache.get(node_intrinsic_function.scope, Nothing) + if is_nothing(before) and not is_nothing(before_arguments): + before = self._resolve_attribute(arguments=before_arguments, select_before=True) + + after = self._after_cache.get(node_intrinsic_function.scope, Nothing) + if is_nothing(after) and not is_nothing(after_arguments): + after = self._resolve_attribute(arguments=after_arguments, select_before=False) + + return PreprocEntityDelta(before=before, after=after) + + def visit_node_intrinsic_function_fn_equals( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + # TODO: add argument shape validation. + def _compute_fn_equals(args: list[Any]) -> bool: + return args[0] == args[1] + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_equals, + ) + return delta + + def visit_node_intrinsic_function_fn_if( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + def _compute_delta_for_if_statement(args: list[Any]) -> PreprocEntityDelta: + condition_name = args[0] + boolean_expression_delta = self._resolve_condition(logical_id=condition_name) + return PreprocEntityDelta( + before=args[1] if boolean_expression_delta.before else args[2], + after=args[1] if boolean_expression_delta.after else args[2], + ) + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_delta_for_if_statement, + ) + return delta + + def visit_node_intrinsic_function_fn_and( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + def _compute_fn_and(args: list[bool]) -> bool: + result = all(args) + return result + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_and, + ) + return delta + + def visit_node_intrinsic_function_fn_or( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + def _compute_fn_or(args: list[bool]): + result = any(args) + return result + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_or, + ) + return delta + + def visit_node_intrinsic_function_fn_not( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + def _compute_fn_not(arg: bool) -> bool: + return not arg + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_not, + ) + return delta + + def _compute_fn_transform(self, args: dict[str, Any]) -> Any: + # TODO: add typing to arguments before this level. + # TODO: add schema validation + # TODO: add support for other transform types + + account_id = self._change_set.account_id + region_name = self._change_set.region_name + transform_name: str = args.get("Name") + if not isinstance(transform_name, str): + raise RuntimeError("Invalid or missing Fn::Transform 'Name' argument") + transform_parameters: dict = args.get("Parameters") + if not isinstance(transform_parameters, dict): + raise RuntimeError("Invalid or missing Fn::Transform 'Parameters' argument") + + if transform_name in transformers: + # TODO: port and refactor this 'transformers' logic to this package. + builtin_transformer_class = transformers[transform_name] + builtin_transformer: Transformer = builtin_transformer_class() + transform_output: Any = builtin_transformer.transform( + account_id=account_id, region_name=region_name, parameters=transform_parameters + ) + return transform_output + + macros_store = get_cloudformation_store( + account_id=account_id, region_name=region_name + ).macros + if transform_name in macros_store: + # TODO: this formatting of stack parameters is odd but required to integrate with v1 execute_macro util. + # consider porting this utils and passing the plain list of parameters instead. + stack_parameters = { + parameter["ParameterKey"]: parameter + for parameter in self._change_set.stack.parameters + } + transform_output: Any = execute_macro( + account_id=account_id, + region_name=region_name, + parsed_template=dict(), # TODO: review the requirements for this argument. + macro=args, # TODO: review support for non dict bindings (v1). + stack_parameters=stack_parameters, + transformation_parameters=transform_parameters, + is_intrinsic=True, + ) + return transform_output + + raise RuntimeError( + f"Unsupported transform function '{transform_name}' in '{self._change_set.stack.stack_name}'" + ) + + def visit_node_intrinsic_function_fn_transform( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=self._compute_fn_transform, + ) + return delta + + def visit_node_intrinsic_function_fn_sub( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + def _compute_sub(args: str | list[Any], select_before: bool) -> str: + # TODO: add further schema validation. + string_template: str + sub_parameters: dict + if isinstance(args, str): + string_template = args + sub_parameters = dict() + elif ( + isinstance(args, list) + and len(args) == 2 + and isinstance(args[0], str) + and isinstance(args[1], dict) + ): + string_template = args[0] + sub_parameters = args[1] + else: + raise RuntimeError( + "Invalid arguments shape for Fn::Sub, expected a String " + f"or a Tuple of String and Map but got '{args}'" + ) + sub_string = string_template + template_variable_names = re.findall("\\${([^}]+)}", string_template) + for template_variable_name in template_variable_names: + template_variable_value = Nothing + + # Try to resolve the variable name as pseudo parameter. + if template_variable_name in _PSEUDO_PARAMETERS: + template_variable_value = self._resolve_pseudo_parameter( + pseudo_parameter_name=template_variable_name + ) + + # Try to resolve the variable name as an entry to the defined parameters. + elif template_variable_name in sub_parameters: + template_variable_value = sub_parameters[template_variable_name] + + # Try to resolve the variable name as GetAtt. + elif "." in template_variable_name: + try: + template_variable_value = self._resolve_attribute( + arguments=template_variable_name, select_before=select_before + ) + except RuntimeError: + pass + + # Try to resolve the variable name as Ref. + else: + try: + resource_delta = self._resolve_reference(logical_id=template_variable_name) + template_variable_value = ( + resource_delta.before if select_before else resource_delta.after + ) + if isinstance(template_variable_value, PreprocResource): + template_variable_value = template_variable_value.physical_resource_id + except RuntimeError: + pass + + if is_nothing(template_variable_value): + raise RuntimeError( + f"Undefined variable name in Fn::Sub string template '{template_variable_name}'" + ) + + if not isinstance(template_variable_value, str): + template_variable_value = str(template_variable_value) + + sub_string = sub_string.replace( + f"${{{template_variable_name}}}", template_variable_value + ) + + # FIXME: the following type reduction is ported from v1; however it appears as though such + # reduction is not performed by the engine, and certainly not at this depth given the + # lack of context. This section should be removed with Fn::Sub always retuning a string + # and the resource providers reviewed. + account_id = self._change_set.account_id + is_another_account_id = sub_string.isdigit() and len(sub_string) == len(account_id) + if sub_string == account_id or is_another_account_id: + result = sub_string + elif sub_string.isdigit(): + result = int(sub_string) + else: + try: + result = float(sub_string) + except ValueError: + result = sub_string + return result + + arguments_delta = self.visit(node_intrinsic_function.arguments) + arguments_before = arguments_delta.before + arguments_after = arguments_delta.after + before = self._before_cache.get(node_intrinsic_function.scope, Nothing) + if is_nothing(before) and not is_nothing(arguments_before): + before = _compute_sub(args=arguments_before, select_before=True) + after = self._after_cache.get(node_intrinsic_function.scope, Nothing) + if is_nothing(after) and not is_nothing(arguments_after): + after = _compute_sub(args=arguments_after, select_before=False) + return PreprocEntityDelta(before=before, after=after) + + def visit_node_intrinsic_function_fn_join( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + # TODO: add support for schema validation. + # TODO: add tests for joining non string values. + def _compute_fn_join(args: list[Any]) -> str | NothingType: + if not (isinstance(args, list) and len(args) == 2): + return Nothing + delimiter: str = str(args[0]) + values: list[Any] = args[1] + if not isinstance(values, list): + # shortcut if values is the empty string, for example: + # {"Fn::Join": ["", {"Ref": }]} + # CDK bootstrap does this + if values == "": + return "" + raise RuntimeError(f"Invalid arguments list definition for Fn::Join: '{args}'") + str_values: list[str] = list() + for value in values: + if value is None: + continue + str_value = str(value) + str_values.append(str_value) + join_result = delimiter.join(str_values) + return join_result + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_join, + ) + return delta + + def visit_node_intrinsic_function_fn_select( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + # TODO: add further support for schema validation + def _compute_fn_select(args: list[Any]) -> Any: + values: list[Any] = args[1] + if not isinstance(values, list) or not values: + raise RuntimeError(f"Invalid arguments list value for Fn::Select: '{values}'") + values_len = len(values) + index: int = int(args[0]) + if not isinstance(index, int) or index < 0 or index > values_len: + raise RuntimeError(f"Invalid or out of range index value for Fn::Select: '{index}'") + selection = values[index] + return selection + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_select, + ) + return delta + + def visit_node_intrinsic_function_fn_split( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + # TODO: add further support for schema validation + def _compute_fn_split(args: list[Any]) -> Any: + delimiter = args[0] + if not isinstance(delimiter, str) or not delimiter: + raise RuntimeError(f"Invalid delimiter value for Fn::Split: '{delimiter}'") + source_string = args[1] + if not isinstance(source_string, str): + raise RuntimeError(f"Invalid source string value for Fn::Split: '{source_string}'") + split_string = source_string.split(delimiter) + return split_string + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_split, + ) + return delta + + def visit_node_intrinsic_function_fn_get_a_zs( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + # TODO: add further support for schema validation + + def _compute_fn_get_a_zs(region) -> Any: + if not isinstance(region, str): + raise RuntimeError(f"Invalid region value for Fn::GetAZs: '{region}'") + + if not region: + region = self._change_set.region_name + + account_id = self._change_set.account_id + ec2_client = connect_to(aws_access_key_id=account_id, region_name=region).ec2 + try: + get_availability_zones_result: DescribeAvailabilityZonesResult = ( + ec2_client.describe_availability_zones() + ) + except ClientError: + raise RuntimeError( + "Could not describe zones availability whilst evaluating Fn::GetAZs" + ) + availability_zones: AvailabilityZoneList = get_availability_zones_result[ + "AvailabilityZones" + ] + azs = [az["ZoneName"] for az in availability_zones] + return azs + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_get_a_zs, + ) + return delta + + def visit_node_intrinsic_function_fn_base64( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + # TODO: add further support for schema validation + def _compute_fn_base_64(string) -> Any: + if not isinstance(string, str): + raise RuntimeError(f"Invalid valueToEncode for Fn::Base64: '{string}'") + # Ported from v1: + base64_string = to_str(base64.b64encode(to_bytes(string))) + return base64_string + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_base_64, + ) + return delta + + def visit_node_intrinsic_function_fn_find_in_map( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + # TODO: add type checking/validation for result unit? + arguments_delta = self.visit(node_intrinsic_function.arguments) + before_arguments = arguments_delta.before + after_arguments = arguments_delta.after + before = Nothing + if before_arguments: + before_value_delta = self._resolve_mapping(*before_arguments) + before = before_value_delta.before + after = Nothing + if after_arguments: + after_value_delta = self._resolve_mapping(*after_arguments) + after = after_value_delta.after + return PreprocEntityDelta(before=before, after=after) + + def visit_node_mapping(self, node_mapping: NodeMapping) -> PreprocEntityDelta: + bindings_delta = self.visit(node_mapping.bindings) + return bindings_delta + + def visit_node_parameters( + self, node_parameters: NodeParameters + ) -> PreprocEntityDelta[dict[str, Any], dict[str, Any]]: + before_parameters = dict() + after_parameters = dict() + for parameter in node_parameters.parameters: + parameter_delta = self.visit(parameter) + parameter_before = parameter_delta.before + if not is_nothing(parameter_before): + before_parameters[parameter.name] = parameter_before + parameter_after = parameter_delta.after + if not is_nothing(parameter_after): + after_parameters[parameter.name] = parameter_after + return PreprocEntityDelta(before=before_parameters, after=after_parameters) + + def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDelta: + dynamic_value = node_parameter.dynamic_value + dynamic_delta = self.visit(dynamic_value) + + default_value = node_parameter.default_value + default_delta = self.visit(default_value) + + before = dynamic_delta.before or default_delta.before + after = dynamic_delta.after or default_delta.after + + return PreprocEntityDelta(before=before, after=after) + + def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntityDelta: + array_identifiers_delta = self.visit(node_depends_on.depends_on) + return array_identifiers_delta + + def visit_node_condition(self, node_condition: NodeCondition) -> PreprocEntityDelta: + delta = self.visit(node_condition.body) + return delta + + def _resource_physical_resource_id_from( + self, logical_resource_id: str, resolved_resources: dict + ) -> str: + # TODO: typing around resolved resources is needed and should be reflected here. + resolved_resource = resolved_resources.get(logical_resource_id, dict()) + physical_resource_id: Optional[str] = resolved_resource.get("PhysicalResourceId") + if not isinstance(physical_resource_id, str): + raise RuntimeError(f"No PhysicalResourceId found for resource '{logical_resource_id}'") + return physical_resource_id + + def _before_resource_physical_id(self, resource_logical_id: str) -> str: + # TODO: typing around resolved resources is needed and should be reflected here. + return self._resource_physical_resource_id_from( + logical_resource_id=resource_logical_id, + resolved_resources=self._before_resolved_resources, + ) + + def _after_resource_physical_id(self, resource_logical_id: str) -> str: + return self._before_resource_physical_id(resource_logical_id=resource_logical_id) + + def visit_node_intrinsic_function_ref( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + def _compute_fn_ref(logical_id: str) -> PreprocEntityDelta: + reference_delta: PreprocEntityDelta = self._resolve_reference(logical_id=logical_id) + if isinstance(before := reference_delta.before, PreprocResource): + reference_delta.before = before.physical_resource_id + if isinstance(after := reference_delta.after, PreprocResource): + reference_delta.after = after.physical_resource_id + return reference_delta + + arguments_delta = self.visit(node_intrinsic_function.arguments) + delta = self._cached_apply( + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + resolver=_compute_fn_ref, + ) + return delta + + def visit_node_intrinsic_function_condition( + self, node_intrinsic_function: NodeIntrinsicFunction + ) -> PreprocEntityDelta: + arguments_delta = self.visit(node_intrinsic_function.arguments) + + def _delta_of_condition(name: str) -> PreprocEntityDelta: + node_condition = self._get_node_condition_if_exists(condition_name=name) + if is_nothing(node_condition): + raise RuntimeError(f"Undefined condition '{name}'") + condition_delta = self.visit(node_condition) + return condition_delta + + delta = self._cached_apply( + resolver=_delta_of_condition, + scope=node_intrinsic_function.scope, + arguments_delta=arguments_delta, + ) + return delta + + def visit_node_array(self, node_array: NodeArray) -> PreprocEntityDelta: + node_change_type = node_array.change_type + before = list() if node_change_type != ChangeType.CREATED else Nothing + after = list() if node_change_type != ChangeType.REMOVED else Nothing + for change_set_entity in node_array.array: + delta: PreprocEntityDelta = self.visit(change_set_entity=change_set_entity) + delta_before = delta.before + delta_after = delta.after + if not is_nothing(before) and not is_nothing(delta_before): + before.append(delta_before) + if not is_nothing(after) and not is_nothing(delta_after): + after.append(delta_after) + return PreprocEntityDelta(before=before, after=after) + + def visit_node_property(self, node_property: NodeProperty) -> PreprocEntityDelta: + return self.visit(node_property.value) + + def visit_node_properties( + self, node_properties: NodeProperties + ) -> PreprocEntityDelta[PreprocProperties, PreprocProperties]: + node_change_type = node_properties.change_type + before_bindings = dict() if node_change_type != ChangeType.CREATED else Nothing + after_bindings = dict() if node_change_type != ChangeType.REMOVED else Nothing + for node_property in node_properties.properties: + property_name = node_property.name + delta = self.visit(node_property) + delta_before = delta.before + delta_after = delta.after + if ( + not is_nothing(before_bindings) + and not is_nothing(delta_before) + and delta_before is not None + ): + before_bindings[property_name] = delta_before + if ( + not is_nothing(after_bindings) + and not is_nothing(delta_after) + and delta_after is not None + ): + after_bindings[property_name] = delta_after + before = Nothing + if not is_nothing(before_bindings): + before = PreprocProperties(properties=before_bindings) + after = Nothing + if not is_nothing(after_bindings): + after = PreprocProperties(properties=after_bindings) + return PreprocEntityDelta(before=before, after=after) + + def _resolve_resource_condition_reference(self, reference: TerminalValue) -> PreprocEntityDelta: + reference_delta = self.visit(reference) + before_reference = reference_delta.before + before = Nothing + if isinstance(before_reference, str): + before_delta = self._resolve_condition(logical_id=before_reference) + before = before_delta.before + after = Nothing + after_reference = reference_delta.after + if isinstance(after_reference, str): + after_delta = self._resolve_condition(logical_id=after_reference) + after = after_delta.after + return PreprocEntityDelta(before=before, after=after) + + def visit_node_resource( + self, node_resource: NodeResource + ) -> PreprocEntityDelta[PreprocResource, PreprocResource]: + change_type = node_resource.change_type + condition_before = Nothing + condition_after = Nothing + if not is_nothing(node_resource.condition_reference): + condition_delta = self._resolve_resource_condition_reference( + node_resource.condition_reference + ) + condition_before = condition_delta.before + condition_after = condition_delta.after + + depends_on_before = Nothing + depends_on_after = Nothing + if not is_nothing(node_resource.depends_on): + depends_on_delta = self.visit(node_resource.depends_on) + depends_on_before = depends_on_delta.before + depends_on_after = depends_on_delta.after + + type_delta = self.visit(node_resource.type_) + properties_delta: PreprocEntityDelta[PreprocProperties, PreprocProperties] = self.visit( + node_resource.properties + ) + + before = Nothing + after = Nothing + if change_type != ChangeType.CREATED and is_nothing(condition_before) or condition_before: + logical_resource_id = node_resource.name + before_physical_resource_id = self._before_resource_physical_id( + resource_logical_id=logical_resource_id + ) + before = PreprocResource( + logical_id=logical_resource_id, + physical_resource_id=before_physical_resource_id, + condition=condition_before, + resource_type=type_delta.before, + properties=properties_delta.before, + depends_on=depends_on_before, + ) + if change_type != ChangeType.REMOVED and is_nothing(condition_after) or condition_after: + logical_resource_id = node_resource.name + try: + after_physical_resource_id = self._after_resource_physical_id( + resource_logical_id=logical_resource_id + ) + except RuntimeError: + after_physical_resource_id = None + after = PreprocResource( + logical_id=logical_resource_id, + physical_resource_id=after_physical_resource_id, + condition=condition_after, + resource_type=type_delta.after, + properties=properties_delta.after, + depends_on=depends_on_after, + ) + return PreprocEntityDelta(before=before, after=after) + + def visit_node_output( + self, node_output: NodeOutput + ) -> PreprocEntityDelta[PreprocOutput, PreprocOutput]: + change_type = node_output.change_type + value_delta = self.visit(node_output.value) + + condition_delta = Nothing + if not is_nothing(node_output.condition_reference): + condition_delta = self._resolve_resource_condition_reference( + node_output.condition_reference + ) + condition_before = condition_delta.before + condition_after = condition_delta.after + if not condition_before and condition_after: + change_type = ChangeType.CREATED + elif condition_before and not condition_after: + change_type = ChangeType.REMOVED + + export_delta = Nothing + if not is_nothing(node_output.export): + export_delta = self.visit(node_output.export) + + before: Maybe[PreprocOutput] = Nothing + if change_type != ChangeType.CREATED: + before = PreprocOutput( + name=node_output.name, + value=value_delta.before, + export=export_delta.before if export_delta else None, + condition=condition_delta.before if condition_delta else None, + ) + after: Maybe[PreprocOutput] = Nothing + if change_type != ChangeType.REMOVED: + after = PreprocOutput( + name=node_output.name, + value=value_delta.after, + export=export_delta.after if export_delta else None, + condition=condition_delta.after if condition_delta else None, + ) + return PreprocEntityDelta(before=before, after=after) + + def visit_node_outputs( + self, node_outputs: NodeOutputs + ) -> PreprocEntityDelta[list[PreprocOutput], list[PreprocOutput]]: + before: list[PreprocOutput] = list() + after: list[PreprocOutput] = list() + for node_output in node_outputs.outputs: + output_delta: PreprocEntityDelta[PreprocOutput, PreprocOutput] = self.visit(node_output) + output_before = output_delta.before + output_after = output_delta.after + if not is_nothing(output_before): + before.append(output_before) + if not is_nothing(output_after): + after.append(output_after) + return PreprocEntityDelta(before=before, after=after) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_transform.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_transform.py new file mode 100644 index 0000000000000..70981d014747c --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_transform.py @@ -0,0 +1,272 @@ +import copy +import logging +import os +from typing import Any, Final, Optional, TypedDict + +import boto3 +from samtranslator.translator.transform import transform as transform_sam + +from localstack.services.cloudformation.engine.policy_loader import create_policy_loader +from localstack.services.cloudformation.engine.transformers import ( + FailedTransformationException, + execute_macro, +) +from localstack.services.cloudformation.engine.v2.change_set_model import ( + ChangeType, + Maybe, + NodeGlobalTransform, + NodeParameter, + NodeTransform, + Nothing, + Scope, + is_nothing, +) +from localstack.services.cloudformation.engine.v2.change_set_model_preproc import ( + ChangeSetModelPreproc, + PreprocEntityDelta, +) +from localstack.services.cloudformation.stores import get_cloudformation_store +from localstack.services.cloudformation.v2.entities import ChangeSet + +LOG = logging.getLogger(__name__) + +SERVERLESS_TRANSFORM = "AWS::Serverless-2016-10-31" +EXTENSIONS_TRANSFORM = "AWS::LanguageExtensions" +SECRETSMANAGER_TRANSFORM = "AWS::SecretsManager-2020-07-23" + +_SCOPE_TRANSFORM_TEMPLATE_OUTCOME: Final[Scope] = Scope("TRANSFORM_TEMPLATE_OUTCOME") + + +# TODO: evaluate the use of subtypes to represent and validate types of transforms +class GlobalTransform: + name: str + parameters: Maybe[dict] + + def __init__(self, name: str, parameters: Maybe[dict]): + self.name = name + self.parameters = parameters + + +class TransformPreprocParameter(TypedDict): + # TODO: expand + ParameterKey: str + ParameterValue: Any + ParameterType: Optional[str] + + +class ChangeSetModelTransform(ChangeSetModelPreproc): + _before_parameters: Final[dict] + _after_parameters: Final[dict] + _before_template: Final[Maybe[dict]] + _after_template: Final[Maybe[dict]] + + def __init__( + self, + change_set: ChangeSet, + before_parameters: dict, + after_parameters: dict, + before_template: Optional[dict], + after_template: Optional[dict], + ): + super().__init__(change_set=change_set) + self._before_parameters = before_parameters + self._after_parameters = after_parameters + self._before_template = before_template or Nothing + self._after_template = after_template or Nothing + + def visit_node_parameter( + self, node_parameter: NodeParameter + ) -> PreprocEntityDelta[ + dict[str, TransformPreprocParameter], dict[str, TransformPreprocParameter] + ]: + # Enable compatability with v1 util. + # TODO: port v1's SSM parameter resolution + + parameter_value_delta = super().visit_node_parameter(node_parameter=node_parameter) + parameter_value_before = parameter_value_delta.before + parameter_value_after = parameter_value_delta.after + + parameter_type_delta = self.visit(node_parameter.type_) + parameter_type_before = parameter_type_delta.before + parameter_type_after = parameter_type_delta.after + + parameter_key = node_parameter.name + + before = Nothing + if not is_nothing(parameter_value_before): + before = TransformPreprocParameter( + ParameterKey=parameter_key, + ParameterValue=parameter_value_before, + ParameterType=parameter_type_before + if not is_nothing(parameter_type_before) + else None, + ) + after = Nothing + if not is_nothing(parameter_value_after): + after = TransformPreprocParameter( + ParameterKey=parameter_key, + ParameterValue=parameter_value_after, + ParameterType=parameter_type_after + if not is_nothing(parameter_type_after) + else None, + ) + + return PreprocEntityDelta(before=before, after=after) + + # Ported from v1: + @staticmethod + def _apply_global_serverless_transformation( + region_name: str, template: dict, parameters: dict + ) -> dict: + """only returns string when parsing SAM template, otherwise None""" + # TODO: we might also want to override the access key ID to account ID + region_before = os.environ.get("AWS_DEFAULT_REGION") + if boto3.session.Session().region_name is None: + os.environ["AWS_DEFAULT_REGION"] = region_name + loader = create_policy_loader() + # The following transformation function can carry out in-place changes ensure this cannot occur. + template = copy.deepcopy(template) + parameters = copy.deepcopy(parameters) + try: + transformed = transform_sam(template, parameters, loader) + return transformed + except Exception as e: + raise FailedTransformationException(transformation=SERVERLESS_TRANSFORM, message=str(e)) + finally: + # Note: we need to fix boto3 region, otherwise AWS SAM transformer fails + os.environ.pop("AWS_DEFAULT_REGION", None) + if region_before is not None: + os.environ["AWS_DEFAULT_REGION"] = region_before + + @staticmethod + def _apply_global_macro_transformation( + account_id: str, + region_name, + global_transform: GlobalTransform, + template: dict, + parameters: dict, + ) -> Optional[dict]: + macro_name = global_transform.name + macros_store = get_cloudformation_store( + account_id=account_id, region_name=region_name + ).macros + macro = macros_store.get(macro_name) + if macro is None: + raise RuntimeError(f"No definitions for global transform '{macro_name}'") + transformation_parameters = global_transform.parameters or dict() + transformed_template = execute_macro( + account_id, + region_name, + parsed_template=template, + macro=macro, + stack_parameters=parameters, + transformation_parameters=transformation_parameters, + ) + # The type annotation on the v1 util appears to be incorrect. + return transformed_template # noqa + + def _apply_global_transform( + self, global_transform: GlobalTransform, template: dict, parameters: dict + ) -> dict: + transform_name = global_transform.name + if transform_name == EXTENSIONS_TRANSFORM: + # Applied lazily in downstream tasks (see ChangeSetModelPreproc). + transformed_template = template + elif transform_name == SERVERLESS_TRANSFORM: + transformed_template = self._apply_global_serverless_transformation( + region_name=self._change_set.region_name, + template=template, + parameters=parameters, + ) + elif transform_name == SECRETSMANAGER_TRANSFORM: + # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/transform-aws-secretsmanager.html + LOG.warning("%s is not yet supported. Ignoring.", SECRETSMANAGER_TRANSFORM) + transformed_template = template + else: + transformed_template = self._apply_global_macro_transformation( + account_id=self._change_set.account_id, + region_name=self._change_set.region_name, + global_transform=global_transform, + template=template, + parameters=parameters, + ) + return transformed_template + + def transform(self) -> tuple[dict, dict]: + self._setup_runtime_cache() + + node_template = self._change_set.update_model.node_template + + parameters_delta = self.visit_node_parameters(node_template.parameters) + parameters_before = parameters_delta.before + parameters_after = parameters_delta.after + + transform_delta: PreprocEntityDelta[list[GlobalTransform], list[GlobalTransform]] = ( + self.visit_node_transform(node_template.transform) + ) + transform_before: Maybe[list[GlobalTransform]] = transform_delta.before + transform_after: Maybe[list[GlobalTransform]] = transform_delta.after + + transformed_before_template = self._before_template + if transform_before and not is_nothing(self._before_template): + transformed_before_template = self._before_cache.get(_SCOPE_TRANSFORM_TEMPLATE_OUTCOME) + if not transformed_before_template: + transformed_before_template = self._before_template + for before_global_transform in transform_before: + transformed_before_template = self._apply_global_transform( + global_transform=before_global_transform, + parameters=parameters_before, + template=transformed_before_template, + ) + self._before_cache[_SCOPE_TRANSFORM_TEMPLATE_OUTCOME] = transformed_before_template + + transformed_after_template = self._after_template + if transform_after and not is_nothing(self._after_template): + transformed_after_template = self._after_cache.get(_SCOPE_TRANSFORM_TEMPLATE_OUTCOME) + if not transformed_after_template: + transformed_after_template = self._after_template + for after_global_transform in transform_after: + transformed_after_template = self._apply_global_transform( + global_transform=after_global_transform, + parameters=parameters_after, + template=transformed_after_template, + ) + self._after_cache[_SCOPE_TRANSFORM_TEMPLATE_OUTCOME] = transformed_after_template + + self._save_runtime_cache() + + return transformed_before_template, transformed_after_template + + def visit_node_global_transform( + self, node_global_transform: NodeGlobalTransform + ) -> PreprocEntityDelta[GlobalTransform, GlobalTransform]: + change_type = node_global_transform.change_type + + name_delta = self.visit(node_global_transform.name) + parameters_delta = self.visit(node_global_transform.parameters) + + before = Nothing + if change_type != ChangeType.CREATED: + before = GlobalTransform(name=name_delta.before, parameters=parameters_delta.before) + after = Nothing + if change_type != ChangeType.REMOVED: + after = GlobalTransform(name=name_delta.after, parameters=parameters_delta.after) + return PreprocEntityDelta(before=before, after=after) + + def visit_node_transform( + self, node_transform: NodeTransform + ) -> PreprocEntityDelta[list[GlobalTransform], list[GlobalTransform]]: + change_type = node_transform.change_type + before = list() if change_type != ChangeType.CREATED else Nothing + after = list() if change_type != ChangeType.REMOVED else Nothing + for change_set_entity in node_transform.global_transforms: + delta: PreprocEntityDelta[GlobalTransform, GlobalTransform] = self.visit( + change_set_entity=change_set_entity + ) + delta_before = delta.before + delta_after = delta.after + if not is_nothing(before) and not is_nothing(delta_before): + before.append(delta_before) + if not is_nothing(after) and not is_nothing(delta_after): + after.append(delta_after) + return PreprocEntityDelta(before=before, after=after) diff --git a/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_visitor.py b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_visitor.py new file mode 100644 index 0000000000000..6333e9f8dbae2 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_visitor.py @@ -0,0 +1,199 @@ +import abc + +from localstack.services.cloudformation.engine.v2.change_set_model import ( + ChangeSetEntity, + NodeArray, + NodeCondition, + NodeConditions, + NodeDependsOn, + NodeDivergence, + NodeGlobalTransform, + NodeIntrinsicFunction, + NodeMapping, + NodeMappings, + NodeObject, + NodeOutput, + NodeOutputs, + NodeParameter, + NodeParameters, + NodeProperties, + NodeProperty, + NodeResource, + NodeResources, + NodeTemplate, + NodeTransform, + TerminalValueCreated, + TerminalValueModified, + TerminalValueRemoved, + TerminalValueUnchanged, +) +from localstack.utils.strings import camel_to_snake_case + + +class ChangeSetModelVisitor(abc.ABC): + # TODO: this class should be auto generated. + + # TODO: add visitors for abstract classes so shared logic can be implemented + # just once in classes extending this. + + def visit(self, change_set_entity: ChangeSetEntity): + # TODO: speed up this lookup logic + type_str = change_set_entity.__class__.__name__ + type_str = camel_to_snake_case(type_str) + visit_function_name = f"visit_{type_str}" + visit_function = getattr(self, visit_function_name) + return visit_function(change_set_entity) + + def visit_children(self, change_set_entity: ChangeSetEntity): + children = change_set_entity.get_children() + for child in children: + self.visit(child) + + def visit_node_template(self, node_template: NodeTemplate): + # Visit the resources, which will lazily evaluate all the referenced (direct and indirect) + # entities (parameters, mappings, conditions, etc.). Then compute the output fields; computing + # only the output fields would only result in the deployment logic of the referenced outputs + # being evaluated, hence enforce the visiting of all the resources first. + self.visit(node_template.resources) + self.visit(node_template.outputs) + + def visit_node_transform(self, node_transform: NodeTransform): + self.visit_children(node_transform) + + def visit_node_global_transform(self, node_global_transform: NodeGlobalTransform): + self.visit_children(node_global_transform) + + def visit_node_outputs(self, node_outputs: NodeOutputs): + self.visit_children(node_outputs) + + def visit_node_output(self, node_output: NodeOutput): + self.visit_children(node_output) + + def visit_node_mapping(self, node_mapping: NodeMapping): + self.visit_children(node_mapping) + + def visit_node_mappings(self, node_mappings: NodeMappings): + self.visit_children(node_mappings) + + def visit_node_parameters(self, node_parameters: NodeParameters): + self.visit_children(node_parameters) + + def visit_node_parameter(self, node_parameter: NodeParameter): + self.visit_children(node_parameter) + + def visit_node_conditions(self, node_conditions: NodeConditions): + self.visit_children(node_conditions) + + def visit_node_condition(self, node_condition: NodeCondition): + self.visit_children(node_condition) + + def visit_node_depends_on(self, node_depends_on: NodeDependsOn): + self.visit_children(node_depends_on) + + def visit_node_resources(self, node_resources: NodeResources): + self.visit_children(node_resources) + + def visit_node_resource(self, node_resource: NodeResource): + self.visit_children(node_resource) + + def visit_node_properties(self, node_properties: NodeProperties): + self.visit_children(node_properties) + + def visit_node_property(self, node_property: NodeProperty): + self.visit_children(node_property) + + def visit_node_intrinsic_function(self, node_intrinsic_function: NodeIntrinsicFunction): + # TODO: speed up this lookup logic + function_name = node_intrinsic_function.intrinsic_function + function_name = function_name.replace("::", "_") + function_name = camel_to_snake_case(function_name) + visit_function_name = f"visit_node_intrinsic_function_{function_name}" + visit_function = getattr(self, visit_function_name) + return visit_function(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_get_att( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_equals( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_transform( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_select( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_split( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_get_a_zs( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_base64( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_sub(self, node_intrinsic_function: NodeIntrinsicFunction): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_if(self, node_intrinsic_function: NodeIntrinsicFunction): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_and(self, node_intrinsic_function: NodeIntrinsicFunction): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_or(self, node_intrinsic_function: NodeIntrinsicFunction): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_not(self, node_intrinsic_function: NodeIntrinsicFunction): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_join(self, node_intrinsic_function: NodeIntrinsicFunction): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_fn_find_in_map( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_ref(self, node_intrinsic_function: NodeIntrinsicFunction): + self.visit_children(node_intrinsic_function) + + def visit_node_intrinsic_function_condition( + self, node_intrinsic_function: NodeIntrinsicFunction + ): + self.visit_children(node_intrinsic_function) + + def visit_node_divergence(self, node_divergence: NodeDivergence): + self.visit_children(node_divergence) + + def visit_node_object(self, node_object: NodeObject): + self.visit_children(node_object) + + def visit_node_array(self, node_array: NodeArray): + self.visit_children(node_array) + + def visit_terminal_value_modified(self, terminal_value_modified: TerminalValueModified): + self.visit_children(terminal_value_modified) + + def visit_terminal_value_created(self, terminal_value_created: TerminalValueCreated): + self.visit_children(terminal_value_created) + + def visit_terminal_value_removed(self, terminal_value_removed: TerminalValueRemoved): + self.visit_children(terminal_value_removed) + + def visit_terminal_value_unchanged(self, terminal_value_unchanged: TerminalValueUnchanged): + self.visit_children(terminal_value_unchanged) diff --git a/localstack-core/localstack/services/cloudformation/engine/validations.py b/localstack-core/localstack/services/cloudformation/engine/validations.py new file mode 100644 index 0000000000000..c65d0a5b307fc --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/validations.py @@ -0,0 +1,86 @@ +""" +Provide validations for use within the CFn engine +""" + +from typing import Protocol + +from localstack.aws.api import CommonServiceException + + +class ValidationError(CommonServiceException): + """General validation error type (defined in the AWS docs, but not part of the botocore spec)""" + + def __init__(self, message=None): + super().__init__("ValidationError", message=message, sender_fault=True) + + +class TemplateValidationStep(Protocol): + """ + Base class for static analysis of the template + """ + + def __call__(self, template: dict): + """ + Execute a specific validation on the template + """ + + +def outputs_have_values(template: dict): + outputs: dict[str, dict] = template.get("Outputs", {}) + + for output_name, output_defn in outputs.items(): + if "Value" not in output_defn: + raise ValidationError( + "Template format error: Every Outputs member must contain a Value object" + ) + + if output_defn["Value"] is None: + key = f"/Outputs/{output_name}/Value" + raise ValidationError(f"[{key}] 'null' values are not allowed in templates") + + +# TODO: this would need to be split into different validations pre- and post- transform +def resources_top_level_keys(template: dict): + """ + Validate that each resource + - there is a resources key + - includes the `Properties` key + - does not include any other keys that should not be there + """ + resources = template.get("Resources") + if resources is None: + raise ValidationError( + "Template format error: At least one Resources member must be defined." + ) + + allowed_keys = { + "Type", + "Properties", + "DependsOn", + "CreationPolicy", + "DeletionPolicy", + "Metadata", + "UpdatePolicy", + "UpdateReplacePolicy", + "Condition", + } + for resource_id, resource in resources.items(): + if "Type" not in resource: + raise ValidationError( + f"Template format error: [/Resources/{resource_id}] Every Resources object must contain a Type member." + ) + + # check for invalid keys + for key in resource: + if key not in allowed_keys: + raise ValidationError(f"Invalid template resource property '{key}'") + + +DEFAULT_TEMPLATE_VALIDATIONS: list[TemplateValidationStep] = [ + # FIXME: disabled for now due to the template validation not fitting well with the template that we use here. + # We don't have access to a "raw" processed template here and it's questionable if we should have it at all, + # since later transformations can again introduce issues. + # => Reevaluate this when reworking how we mutate the template dict in the provider + # outputs_have_values, + # resources_top_level_keys, +] diff --git a/localstack-core/localstack/services/cloudformation/engine/yaml_parser.py b/localstack-core/localstack/services/cloudformation/engine/yaml_parser.py new file mode 100644 index 0000000000000..c0b72ead58f8f --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/engine/yaml_parser.py @@ -0,0 +1,64 @@ +import yaml + + +def construct_raw(_, node): + return node.value + + +class NoDatesSafeLoader(yaml.SafeLoader): + @classmethod + def remove_tag_constructor(cls, tag): + """ + Remove the YAML constructor for a given tag and replace it with a raw constructor + """ + # needed to make sure we're not changing the constructors of the base class + # otherwise usage across the code base is affected as well + if "yaml_constructors" not in cls.__dict__: + cls.yaml_constructors = cls.yaml_constructors.copy() + + cls.yaml_constructors[tag] = construct_raw + + +NoDatesSafeLoader.remove_tag_constructor("tag:yaml.org,2002:timestamp") + + +def shorthand_constructor(loader: yaml.Loader, tag_suffix: str, node: yaml.Node): + """ + TODO: proper exceptions (introduce this when fixing the provider) + TODO: fix select & split (is this even necessary?) + { "Fn::Select" : [ "2", { "Fn::Split": [",", {"Fn::ImportValue": "AccountSubnetIDs"}]}] } + !Select [2, !Split [",", !ImportValue AccountSubnetIDs]] + shorthand: 2 => canonical "2" + """ + match tag_suffix: + case "Ref": + fn_name = "Ref" + case "Condition": + fn_name = "Condition" + case _: + fn_name = f"Fn::{tag_suffix}" + + if tag_suffix == "GetAtt" and isinstance(node, yaml.ScalarNode): + # !GetAtt A.B.C => {"Fn::GetAtt": ["A", "B.C"]} + parts = node.value.partition(".") + if len(parts) != 3: + raise ValueError(f"Node value contains unexpected format for !GetAtt: {parts}") + return {fn_name: [parts[0], parts[2]]} + + if isinstance(node, yaml.ScalarNode): + return {fn_name: node.value} + elif isinstance(node, yaml.SequenceNode): + return {fn_name: loader.construct_sequence(node)} + elif isinstance(node, yaml.MappingNode): + return {fn_name: loader.construct_mapping(node)} + else: + raise ValueError(f"Unexpected yaml Node type: {type(node)}") + + +customloader = NoDatesSafeLoader + +yaml.add_multi_constructor("!", shorthand_constructor, customloader) + + +def parse_yaml(input_data: str): + return yaml.load(input_data, customloader) diff --git a/localstack-core/localstack/services/cloudformation/models/__init__.py b/localstack-core/localstack/services/cloudformation/models/__init__.py new file mode 100644 index 0000000000000..a9a2c5b3bb437 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/models/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/localstack-core/localstack/services/cloudformation/plugins.py b/localstack-core/localstack/services/cloudformation/plugins.py new file mode 100644 index 0000000000000..72ef0104aaeb2 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/plugins.py @@ -0,0 +1,12 @@ +from rolo import Resource + +from localstack.runtime import hooks + + +@hooks.on_infra_start() +def register_cloudformation_deploy_ui(): + from localstack.services.internal import get_internal_apis + + from .deploy_ui import CloudFormationUi + + get_internal_apis().add(Resource("/_localstack/cloudformation/deploy", CloudFormationUi())) diff --git a/localstack-core/localstack/services/cloudformation/provider.py b/localstack-core/localstack/services/cloudformation/provider.py new file mode 100644 index 0000000000000..4d41a4576ae91 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/provider.py @@ -0,0 +1,1346 @@ +import copy +import json +import logging +import re +from collections import defaultdict +from copy import deepcopy + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.cloudformation import ( + AlreadyExistsException, + CallAs, + ChangeSetNameOrId, + ChangeSetNotFoundException, + ChangeSetType, + ClientRequestToken, + CloudformationApi, + CreateChangeSetInput, + CreateChangeSetOutput, + CreateStackInput, + CreateStackInstancesInput, + CreateStackInstancesOutput, + CreateStackOutput, + CreateStackSetInput, + CreateStackSetOutput, + DeleteChangeSetOutput, + DeleteStackInstancesInput, + DeleteStackInstancesOutput, + DeleteStackSetOutput, + DeletionMode, + DescribeChangeSetOutput, + DescribeStackEventsOutput, + DescribeStackResourceOutput, + DescribeStackResourcesOutput, + DescribeStackSetOperationOutput, + DescribeStackSetOutput, + DescribeStacksOutput, + DisableRollback, + EnableTerminationProtection, + ExecuteChangeSetOutput, + ExecutionStatus, + ExportName, + GetTemplateOutput, + GetTemplateSummaryInput, + GetTemplateSummaryOutput, + IncludePropertyValues, + InsufficientCapabilitiesException, + InvalidChangeSetStatusException, + ListChangeSetsOutput, + ListExportsOutput, + ListImportsOutput, + ListStackInstancesInput, + ListStackInstancesOutput, + ListStackResourcesOutput, + ListStackSetsInput, + ListStackSetsOutput, + ListStacksOutput, + ListTypesInput, + ListTypesOutput, + LogicalResourceId, + NextToken, + Parameter, + PhysicalResourceId, + RegisterTypeInput, + RegisterTypeOutput, + RegistryType, + RetainExceptOnCreate, + RetainResources, + RoleARN, + StackName, + StackNameOrId, + StackSetName, + StackStatus, + StackStatusFilter, + TemplateParameter, + TemplateStage, + TypeSummary, + UpdateStackInput, + UpdateStackOutput, + UpdateStackSetInput, + UpdateStackSetOutput, + UpdateTerminationProtectionOutput, + ValidateTemplateInput, + ValidateTemplateOutput, +) +from localstack.aws.connect import connect_to +from localstack.services.cloudformation import api_utils +from localstack.services.cloudformation.engine import parameters as param_resolver +from localstack.services.cloudformation.engine import template_deployer, template_preparer +from localstack.services.cloudformation.engine.entities import ( + Stack, + StackChangeSet, + StackInstance, + StackSet, +) +from localstack.services.cloudformation.engine.parameters import mask_no_echo, strip_parameter_type +from localstack.services.cloudformation.engine.resource_ordering import ( + NoResourceInStack, + order_resources, +) +from localstack.services.cloudformation.engine.template_deployer import ( + NoStackUpdates, +) +from localstack.services.cloudformation.engine.template_utils import resolve_stack_conditions +from localstack.services.cloudformation.engine.transformers import ( + FailedTransformationException, +) +from localstack.services.cloudformation.engine.validations import ( + DEFAULT_TEMPLATE_VALIDATIONS, + ValidationError, +) +from localstack.services.cloudformation.resource_provider import ( + PRO_RESOURCE_PROVIDERS, + ResourceProvider, +) +from localstack.services.cloudformation.stores import ( + cloudformation_stores, + find_active_stack_by_name_or_id, + find_change_set, + find_stack, + find_stack_by_id, + get_cloudformation_store, +) +from localstack.state import StateVisitor +from localstack.utils.collections import ( + remove_attributes, + select_attributes, + select_from_typed_dict, +) +from localstack.utils.json import clone +from localstack.utils.strings import long_uid, short_uid + +LOG = logging.getLogger(__name__) + +ARN_CHANGESET_REGEX = re.compile( + r"arn:(aws|aws-us-gov|aws-cn):cloudformation:[-a-zA-Z0-9]+:\d{12}:changeSet/[a-zA-Z][-a-zA-Z0-9]*/[-a-zA-Z0-9:/._+]+" +) +ARN_STACK_REGEX = re.compile( + r"arn:(aws|aws-us-gov|aws-cn):cloudformation:[-a-zA-Z0-9]+:\d{12}:stack/[a-zA-Z][-a-zA-Z0-9]*/[-a-zA-Z0-9:/._+]+" +) + + +def clone_stack_params(stack_params): + try: + return clone(stack_params) + except Exception as e: + LOG.info("Unable to clone stack parameters: %s", e) + return stack_params + + +def find_stack_instance(stack_set: StackSet, account: str, region: str): + for instance in stack_set.stack_instances: + if instance.metadata["Account"] == account and instance.metadata["Region"] == region: + return instance + return None + + +def stack_not_found_error(stack_name: str): + # FIXME + raise ValidationError("Stack with id %s does not exist" % stack_name) + + +def not_found_error(message: str): + # FIXME + raise ResourceNotFoundException(message) + + +class ResourceNotFoundException(CommonServiceException): + def __init__(self, message=None): + super().__init__("ResourceNotFoundException", status_code=404, message=message) + + +class InternalFailure(CommonServiceException): + def __init__(self, message=None): + super().__init__("InternalFailure", status_code=500, message=message, sender_fault=False) + + +class CloudformationProvider(CloudformationApi): + def _stack_status_is_active(self, stack_status: str) -> bool: + return stack_status not in [StackStatus.DELETE_COMPLETE] + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(cloudformation_stores) + + @handler("CreateStack", expand=False) + def create_stack(self, context: RequestContext, request: CreateStackInput) -> CreateStackOutput: + # TODO: test what happens when both TemplateUrl and Body are specified + state = get_cloudformation_store(context.account_id, context.region) + + stack_name = request.get("StackName") + + # get stacks by name + active_stack_candidates = [ + s + for s in state.stacks.values() + if s.stack_name == stack_name and self._stack_status_is_active(s.status) + ] + + # TODO: fix/implement this code path + # this needs more investigation how Cloudformation handles it (e.g. normal stack create or does it create a separate changeset?) + # REVIEW_IN_PROGRESS is another special status + # in this case existing changesets are set to obsolete and the stack is created + # review_stack_candidates = [s for s in stack_candidates if s.status == StackStatus.REVIEW_IN_PROGRESS] + # if review_stack_candidates: + # set changesets to obsolete + # for cs in review_stack_candidates[0].change_sets: + # cs.execution_status = ExecutionStatus.OBSOLETE + + if active_stack_candidates: + raise AlreadyExistsException(f"Stack [{stack_name}] already exists") + + template_body = request.get("TemplateBody") or "" + if len(template_body) > 51200: + raise ValidationError( + f"1 validation error detected: Value '{request['TemplateBody']}' at 'templateBody' " + "failed to satisfy constraint: Member must have length less than or equal to 51200" + ) + api_utils.prepare_template_body(request) # TODO: avoid mutating request directly + + template = template_preparer.parse_template(request["TemplateBody"]) + + stack_name = template["StackName"] = request.get("StackName") + if api_utils.validate_stack_name(stack_name) is False: + raise ValidationError( + f"1 validation error detected: Value '{stack_name}' at 'stackName' failed to satisfy constraint:\ + Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*|arn:[-a-zA-Z0-9:/._+]*" + ) + + if ( + "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", []) + and "Transform" in template.keys() + ): + raise InsufficientCapabilitiesException( + "Requires capabilities : [CAPABILITY_AUTO_EXPAND]" + ) + + # resolve stack parameters + new_parameters = param_resolver.convert_stack_parameters_to_dict(request.get("Parameters")) + parameter_declarations = param_resolver.extract_stack_parameter_declarations(template) + resolved_parameters = param_resolver.resolve_parameters( + account_id=context.account_id, + region_name=context.region, + parameter_declarations=parameter_declarations, + new_parameters=new_parameters, + old_parameters={}, + ) + + stack = Stack(context.account_id, context.region, request, template) + + try: + template = template_preparer.transform_template( + context.account_id, + context.region, + template, + stack.stack_name, + stack.resources, + stack.mappings, + {}, # TODO + resolved_parameters, + ) + except FailedTransformationException as e: + stack.add_stack_event( + stack.stack_name, + stack.stack_id, + status="ROLLBACK_IN_PROGRESS", + status_reason=e.message, + ) + stack.set_stack_status("ROLLBACK_COMPLETE") + state.stacks[stack.stack_id] = stack + return CreateStackOutput(StackId=stack.stack_id) + + # HACK: recreate the stack (including all of its confusing processes in the __init__ method + # to set the stack template to be the transformed template, rather than the untransformed + # template + stack = Stack(context.account_id, context.region, request, template) + + # perform basic static analysis on the template + for validation_fn in DEFAULT_TEMPLATE_VALIDATIONS: + validation_fn(template) + + # resolve conditions + raw_conditions = template.get("Conditions", {}) + resolved_stack_conditions = resolve_stack_conditions( + account_id=context.account_id, + region_name=context.region, + conditions=raw_conditions, + parameters=resolved_parameters, + mappings=stack.mappings, + stack_name=stack_name, + ) + stack.set_resolved_stack_conditions(resolved_stack_conditions) + + stack.set_resolved_parameters(resolved_parameters) + stack.template_body = template_body + state.stacks[stack.stack_id] = stack + LOG.debug( + 'Creating stack "%s" with %s resources ...', + stack.stack_name, + len(stack.template_resources), + ) + deployer = template_deployer.TemplateDeployer(context.account_id, context.region, stack) + try: + deployer.deploy_stack() + except Exception as e: + stack.set_stack_status("CREATE_FAILED") + msg = 'Unable to create stack "%s": %s' % (stack.stack_name, e) + LOG.exception("%s") + raise ValidationError(msg) from e + + return CreateStackOutput(StackId=stack.stack_id) + + @handler("DeleteStack") + def delete_stack( + self, + context: RequestContext, + stack_name: StackName, + retain_resources: RetainResources = None, + role_arn: RoleARN = None, + client_request_token: ClientRequestToken = None, + deletion_mode: DeletionMode = None, + **kwargs, + ) -> None: + stack = find_active_stack_by_name_or_id(context.account_id, context.region, stack_name) + if not stack: + # aws will silently ignore invalid stack names - we should do the same + return + deployer = template_deployer.TemplateDeployer(context.account_id, context.region, stack) + deployer.delete_stack() + + @handler("UpdateStack", expand=False) + def update_stack( + self, + context: RequestContext, + request: UpdateStackInput, + ) -> UpdateStackOutput: + stack_name = request.get("StackName") + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + return not_found_error(f'Unable to update non-existing stack "{stack_name}"') + + api_utils.prepare_template_body(request) + template = template_preparer.parse_template(request["TemplateBody"]) + + if ( + "CAPABILITY_AUTO_EXPAND" not in request.get("Capabilities", []) + and "Transform" in template.keys() + ): + raise InsufficientCapabilitiesException( + "Requires capabilities : [CAPABILITY_AUTO_EXPAND]" + ) + + new_parameters: dict[str, Parameter] = param_resolver.convert_stack_parameters_to_dict( + request.get("Parameters") + ) + parameter_declarations = param_resolver.extract_stack_parameter_declarations(template) + resolved_parameters = param_resolver.resolve_parameters( + account_id=context.account_id, + region_name=context.region, + parameter_declarations=parameter_declarations, + new_parameters=new_parameters, + old_parameters=stack.resolved_parameters, + ) + + resolved_stack_conditions = resolve_stack_conditions( + account_id=context.account_id, + region_name=context.region, + conditions=template.get("Conditions", {}), + parameters=resolved_parameters, + mappings=template.get("Mappings", {}), + stack_name=stack_name, + ) + + raw_new_template = copy.deepcopy(template) + try: + template = template_preparer.transform_template( + context.account_id, + context.region, + template, + stack.stack_name, + stack.resources, + stack.mappings, + resolved_stack_conditions, + resolved_parameters, + ) + processed_template = copy.deepcopy( + template + ) # copying it here since it's being mutated somewhere downstream + except FailedTransformationException as e: + stack.add_stack_event( + stack.stack_name, + stack.stack_id, + status="ROLLBACK_IN_PROGRESS", + status_reason=e.message, + ) + stack.set_stack_status("ROLLBACK_COMPLETE") + return CreateStackOutput(StackId=stack.stack_id) + + # perform basic static analysis on the template + for validation_fn in DEFAULT_TEMPLATE_VALIDATIONS: + validation_fn(template) + + # update the template + stack.template_original = template + + deployer = template_deployer.TemplateDeployer(context.account_id, context.region, stack) + # TODO: there shouldn't be a "new" stack on update + new_stack = Stack( + context.account_id, context.region, request, template, request["TemplateBody"] + ) + new_stack.set_resolved_parameters(resolved_parameters) + stack.set_resolved_parameters(resolved_parameters) + stack.set_resolved_stack_conditions(resolved_stack_conditions) + try: + deployer.update_stack(new_stack) + except NoStackUpdates as e: + stack.set_stack_status("UPDATE_COMPLETE") + if raw_new_template != processed_template: + # processed templates seem to never return an exception here + return UpdateStackOutput(StackId=stack.stack_id) + raise ValidationError(str(e)) + except Exception as e: + stack.set_stack_status("UPDATE_FAILED") + msg = f'Unable to update stack "{stack_name}": {e}' + LOG.exception("%s", msg) + raise ValidationError(msg) from e + + return UpdateStackOutput(StackId=stack.stack_id) + + @handler("DescribeStacks") + def describe_stacks( + self, + context: RequestContext, + stack_name: StackName = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeStacksOutput: + # TODO: test & implement pagination + state = get_cloudformation_store(context.account_id, context.region) + + if stack_name: + if ARN_STACK_REGEX.match(stack_name): + # we can get the stack directly since we index the store by ARN/stackID + stack = state.stacks.get(stack_name) + stacks = [stack.describe_details()] if stack else [] + else: + # otherwise we have to find the active stack with the given name + stack_candidates: list[Stack] = [ + s for stack_arn, s in state.stacks.items() if s.stack_name == stack_name + ] + active_stack_candidates = [ + s for s in stack_candidates if self._stack_status_is_active(s.status) + ] + stacks = [s.describe_details() for s in active_stack_candidates] + else: + # return all active stacks + stack_list = list(state.stacks.values()) + stacks = [ + s.describe_details() for s in stack_list if self._stack_status_is_active(s.status) + ] + + if stack_name and not stacks: + raise ValidationError(f"Stack with id {stack_name} does not exist") + + return DescribeStacksOutput(Stacks=stacks) + + @handler("ListStacks") + def list_stacks( + self, + context: RequestContext, + next_token: NextToken = None, + stack_status_filter: StackStatusFilter = None, + **kwargs, + ) -> ListStacksOutput: + state = get_cloudformation_store(context.account_id, context.region) + + stacks = [ + s.describe_details() + for s in state.stacks.values() + if not stack_status_filter or s.status in stack_status_filter + ] + + attrs = [ + "StackId", + "StackName", + "TemplateDescription", + "CreationTime", + "LastUpdatedTime", + "DeletionTime", + "StackStatus", + "StackStatusReason", + "ParentId", + "RootId", + "DriftInformation", + ] + stacks = [select_attributes(stack, attrs) for stack in stacks] + return ListStacksOutput(StackSummaries=stacks) + + @handler("GetTemplate") + def get_template( + self, + context: RequestContext, + stack_name: StackName = None, + change_set_name: ChangeSetNameOrId = None, + template_stage: TemplateStage = None, + **kwargs, + ) -> GetTemplateOutput: + if change_set_name: + stack = find_change_set( + context.account_id, context.region, stack_name=stack_name, cs_name=change_set_name + ) + else: + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + return stack_not_found_error(stack_name) + + if template_stage == TemplateStage.Processed and "Transform" in stack.template_body: + copy_template = clone(stack.template_original) + for key in [ + "ChangeSetName", + "StackName", + "StackId", + "Transform", + "Conditions", + "Mappings", + ]: + copy_template.pop(key, None) + for key in ["Parameters", "Outputs"]: + if key in copy_template and not copy_template[key]: + copy_template.pop(key) + for resource in copy_template.get("Resources", {}).values(): + resource.pop("LogicalResourceId", None) + template_body = json.dumps(copy_template) + else: + template_body = stack.template_body + + return GetTemplateOutput( + TemplateBody=template_body, + StagesAvailable=[TemplateStage.Original, TemplateStage.Processed], + ) + + @handler("GetTemplateSummary", expand=False) + def get_template_summary( + self, + context: RequestContext, + request: GetTemplateSummaryInput, + ) -> GetTemplateSummaryOutput: + stack_name = request.get("StackName") + + if stack_name: + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + return stack_not_found_error(stack_name) + template = stack.template + else: + api_utils.prepare_template_body(request) + template = template_preparer.parse_template(request["TemplateBody"]) + request["StackName"] = "tmp-stack" + stack = Stack(context.account_id, context.region, request, template) + + result: GetTemplateSummaryOutput = stack.describe_details() + + # build parameter declarations + result["Parameters"] = list( + param_resolver.extract_stack_parameter_declarations(template).values() + ) + + id_summaries = defaultdict(list) + for resource_id, resource in stack.template_resources.items(): + res_type = resource["Type"] + id_summaries[res_type].append(resource_id) + + result["ResourceTypes"] = list(id_summaries.keys()) + result["ResourceIdentifierSummaries"] = [ + {"ResourceType": key, "LogicalResourceIds": values} + for key, values in id_summaries.items() + ] + result["Metadata"] = stack.template.get("Metadata") + result["Version"] = stack.template.get("AWSTemplateFormatVersion", "2010-09-09") + # these do not appear in the output + result.pop("Capabilities", None) + + return select_from_typed_dict(GetTemplateSummaryOutput, result) + + def update_termination_protection( + self, + context: RequestContext, + enable_termination_protection: EnableTerminationProtection, + stack_name: StackNameOrId, + **kwargs, + ) -> UpdateTerminationProtectionOutput: + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + raise ValidationError(f"Stack '{stack_name}' does not exist.") + stack.metadata["EnableTerminationProtection"] = enable_termination_protection + return UpdateTerminationProtectionOutput(StackId=stack.stack_id) + + @handler("CreateChangeSet", expand=False) + def create_change_set( + self, context: RequestContext, request: CreateChangeSetInput + ) -> CreateChangeSetOutput: + state = get_cloudformation_store(context.account_id, context.region) + + req_params = request + change_set_type = req_params.get("ChangeSetType", "UPDATE") + stack_name = req_params.get("StackName") + change_set_name = req_params.get("ChangeSetName") + template_body = req_params.get("TemplateBody") + # s3 or secretsmanager url + template_url = req_params.get("TemplateURL") + + # validate and resolve template + if template_body and template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + if not template_body and not template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + api_utils.prepare_template_body( + req_params + ) # TODO: function has too many unclear responsibilities + if not template_body: + template_body = req_params[ + "TemplateBody" + ] # should then have been set by prepare_template_body + template = template_preparer.parse_template(req_params["TemplateBody"]) + + del req_params["TemplateBody"] # TODO: stop mutating req_params + template["StackName"] = stack_name + # TODO: validate with AWS what this is actually doing? + template["ChangeSetName"] = change_set_name + + # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing + # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet) + if ARN_STACK_REGEX.match(stack_name): + if not (stack := state.stacks.get(stack_name)): + raise ValidationError(f"Stack '{stack_name}' does not exist.") + else: + # stack name specified, so fetch the stack by name + stack_candidates: list[Stack] = [ + s for stack_arn, s in state.stacks.items() if s.stack_name == stack_name + ] + active_stack_candidates = [ + s for s in stack_candidates if self._stack_status_is_active(s.status) + ] + + # on a CREATE an empty Stack should be generated if we didn't find an active one + if not active_stack_candidates and change_set_type == ChangeSetType.CREATE: + empty_stack_template = dict(template) + empty_stack_template["Resources"] = {} + req_params_copy = clone_stack_params(req_params) + stack = Stack( + context.account_id, + context.region, + req_params_copy, + empty_stack_template, + template_body=template_body, + ) + state.stacks[stack.stack_id] = stack + stack.set_stack_status("REVIEW_IN_PROGRESS") + else: + if not active_stack_candidates: + raise ValidationError(f"Stack '{stack_name}' does not exist.") + stack = active_stack_candidates[0] + + # TODO: test if rollback status is allowed as well + if ( + change_set_type == ChangeSetType.CREATE + and stack.status != StackStatus.REVIEW_IN_PROGRESS + ): + raise ValidationError( + f"Stack [{stack_name}] already exists and cannot be created again with the changeSet [{change_set_name}]." + ) + + old_parameters: dict[str, Parameter] = {} + match change_set_type: + case ChangeSetType.UPDATE: + # add changeset to existing stack + old_parameters = { + k: mask_no_echo(strip_parameter_type(v)) + for k, v in stack.resolved_parameters.items() + } + case ChangeSetType.IMPORT: + raise NotImplementedError() # TODO: implement importing resources + case ChangeSetType.CREATE: + pass + case _: + msg = ( + f"1 validation error detected: Value '{change_set_type}' at 'changeSetType' failed to satisfy " + f"constraint: Member must satisfy enum value set: [IMPORT, UPDATE, CREATE] " + ) + raise ValidationError(msg) + + # resolve parameters + new_parameters: dict[str, Parameter] = param_resolver.convert_stack_parameters_to_dict( + request.get("Parameters") + ) + parameter_declarations = param_resolver.extract_stack_parameter_declarations(template) + resolved_parameters = param_resolver.resolve_parameters( + account_id=context.account_id, + region_name=context.region, + parameter_declarations=parameter_declarations, + new_parameters=new_parameters, + old_parameters=old_parameters, + ) + + # TODO: remove this when fixing Stack.resources and transformation order + # currently we need to create a stack with existing resources + parameters so that resolve refs recursively in here will work. + # The correct way to do it would be at a later stage anyway just like a normal intrinsic function + req_params_copy = clone_stack_params(req_params) + temp_stack = Stack(context.account_id, context.region, req_params_copy, template) + temp_stack.set_resolved_parameters(resolved_parameters) + + # TODO: everything below should be async + # apply template transformations + transformed_template = template_preparer.transform_template( + context.account_id, + context.region, + template, + stack_name=temp_stack.stack_name, + resources=temp_stack.resources, + mappings=temp_stack.mappings, + conditions={}, # TODO: we don't have any resolved conditions yet at this point but we need the conditions because of the samtranslator... + resolved_parameters=resolved_parameters, + ) + + # perform basic static analysis on the template + for validation_fn in DEFAULT_TEMPLATE_VALIDATIONS: + validation_fn(template) + + # create change set for the stack and apply changes + change_set = StackChangeSet( + context.account_id, context.region, stack, req_params, transformed_template + ) + # only set parameters for the changeset, then switch to stack on execute_change_set + change_set.set_resolved_parameters(resolved_parameters) + change_set.template_body = template_body + + # TODO: evaluate conditions + raw_conditions = transformed_template.get("Conditions", {}) + resolved_stack_conditions = resolve_stack_conditions( + account_id=context.account_id, + region_name=context.region, + conditions=raw_conditions, + parameters=resolved_parameters, + mappings=temp_stack.mappings, + stack_name=stack_name, + ) + change_set.set_resolved_stack_conditions(resolved_stack_conditions) + + # a bit gross but use the template ordering to validate missing resources + try: + order_resources( + transformed_template["Resources"], + resolved_parameters=resolved_parameters, + resolved_conditions=resolved_stack_conditions, + ) + except NoResourceInStack as e: + raise ValidationError(str(e)) from e + + deployer = template_deployer.TemplateDeployer( + context.account_id, context.region, change_set + ) + changes = deployer.construct_changes( + stack, + change_set, + change_set_id=change_set.change_set_id, + append_to_changeset=True, + filter_unchanged_resources=True, + ) + stack.change_sets.append(change_set) + if not changes: + change_set.metadata["Status"] = "FAILED" + change_set.metadata["ExecutionStatus"] = "UNAVAILABLE" + change_set.metadata["StatusReason"] = ( + "The submitted information didn't contain changes. Submit different information to create a change set." + ) + else: + change_set.metadata["Status"] = ( + "CREATE_COMPLETE" # technically for some time this should first be CREATE_PENDING + ) + change_set.metadata["ExecutionStatus"] = ( + "AVAILABLE" # technically for some time this should first be UNAVAILABLE + ) + + return CreateChangeSetOutput(StackId=change_set.stack_id, Id=change_set.change_set_id) + + @handler("DescribeChangeSet") + def describe_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId = None, + next_token: NextToken = None, + include_property_values: IncludePropertyValues = None, + **kwargs, + ) -> DescribeChangeSetOutput: + # TODO add support for include_property_values + # only relevant if change_set_name isn't an ARN + if not ARN_CHANGESET_REGEX.match(change_set_name): + if not stack_name: + raise ValidationError( + "StackName must be specified if ChangeSetName is not specified as an ARN." + ) + + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + raise ValidationError(f"Stack [{stack_name}] does not exist") + + change_set = find_change_set( + context.account_id, context.region, change_set_name, stack_name=stack_name + ) + if not change_set: + raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist") + + attrs = [ + "ChangeSetType", + "StackStatus", + "LastUpdatedTime", + "DisableRollback", + "EnableTerminationProtection", + "Transform", + ] + result = remove_attributes(deepcopy(change_set.metadata), attrs) + # TODO: replace this patch with a better solution + result["Parameters"] = [ + mask_no_echo(strip_parameter_type(p)) for p in result.get("Parameters", []) + ] + return result + + @handler("DeleteChangeSet") + def delete_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId = None, + **kwargs, + ) -> DeleteChangeSetOutput: + # only relevant if change_set_name isn't an ARN + if not ARN_CHANGESET_REGEX.match(change_set_name): + if not stack_name: + raise ValidationError( + "StackName must be specified if ChangeSetName is not specified as an ARN." + ) + + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + raise ValidationError(f"Stack [{stack_name}] does not exist") + + change_set = find_change_set( + context.account_id, context.region, change_set_name, stack_name=stack_name + ) + if not change_set: + raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist") + change_set.stack.change_sets = [ + cs + for cs in change_set.stack.change_sets + if change_set_name not in (cs.change_set_name, cs.change_set_id) + ] + return DeleteChangeSetOutput() + + @handler("ExecuteChangeSet") + def execute_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId = None, + client_request_token: ClientRequestToken = None, + disable_rollback: DisableRollback = None, + retain_except_on_create: RetainExceptOnCreate = None, + **kwargs, + ) -> ExecuteChangeSetOutput: + change_set = find_change_set( + context.account_id, + context.region, + change_set_name, + stack_name=stack_name, + active_only=True, + ) + if not change_set: + raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist") + if change_set.metadata.get("ExecutionStatus") != ExecutionStatus.AVAILABLE: + LOG.debug("Change set %s not in execution status 'AVAILABLE'", change_set_name) + raise InvalidChangeSetStatusException( + f"ChangeSet [{change_set.metadata['ChangeSetId']}] cannot be executed in its current status of [{change_set.metadata.get('Status')}]" + ) + stack_name = change_set.stack.stack_name + LOG.debug( + 'Executing change set "%s" for stack "%s" with %s resources ...', + change_set_name, + stack_name, + len(change_set.template_resources), + ) + deployer = template_deployer.TemplateDeployer( + context.account_id, context.region, change_set.stack + ) + try: + deployer.apply_change_set(change_set) + change_set.stack.metadata["ChangeSetId"] = change_set.change_set_id + except NoStackUpdates: + # TODO: parity-check if this exception should be re-raised or swallowed + raise ValidationError("No updates to be performed for stack change set") + + return ExecuteChangeSetOutput() + + @handler("ListChangeSets") + def list_change_sets( + self, + context: RequestContext, + stack_name: StackNameOrId, + next_token: NextToken = None, + **kwargs, + ) -> ListChangeSetsOutput: + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + return not_found_error(f'Unable to find stack "{stack_name}"') + result = [cs.metadata for cs in stack.change_sets] + return ListChangeSetsOutput(Summaries=result) + + @handler("ListExports") + def list_exports( + self, context: RequestContext, next_token: NextToken = None, **kwargs + ) -> ListExportsOutput: + state = get_cloudformation_store(context.account_id, context.region) + return ListExportsOutput(Exports=state.exports) + + @handler("ListImports") + def list_imports( + self, + context: RequestContext, + export_name: ExportName, + next_token: NextToken = None, + **kwargs, + ) -> ListImportsOutput: + state = get_cloudformation_store(context.account_id, context.region) + + importing_stack_names = [] + for stack in state.stacks.values(): + if export_name in stack.imports: + importing_stack_names.append(stack.stack_name) + + return ListImportsOutput(Imports=importing_stack_names) + + @handler("DescribeStackEvents") + def describe_stack_events( + self, + context: RequestContext, + stack_name: StackName = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeStackEventsOutput: + if stack_name is None: + raise ValidationError( + "1 validation error detected: Value null at 'stackName' failed to satisfy constraint: Member must not be null" + ) + + stack = find_active_stack_by_name_or_id(context.account_id, context.region, stack_name) + if not stack: + stack = find_stack_by_id( + account_id=context.account_id, region_name=context.region, stack_id=stack_name + ) + if not stack: + raise ValidationError(f"Stack [{stack_name}] does not exist") + return DescribeStackEventsOutput(StackEvents=stack.events) + + @handler("DescribeStackResource") + def describe_stack_resource( + self, + context: RequestContext, + stack_name: StackName, + logical_resource_id: LogicalResourceId, + **kwargs, + ) -> DescribeStackResourceOutput: + stack = find_stack(context.account_id, context.region, stack_name) + + if not stack: + return stack_not_found_error(stack_name) + + try: + details = stack.resource_status(logical_resource_id) + except Exception as e: + if "Unable to find details" in str(e): + raise ValidationError( + f"Resource {logical_resource_id} does not exist for stack {stack_name}" + ) + raise + + return DescribeStackResourceOutput(StackResourceDetail=details) + + @handler("DescribeStackResources") + def describe_stack_resources( + self, + context: RequestContext, + stack_name: StackName = None, + logical_resource_id: LogicalResourceId = None, + physical_resource_id: PhysicalResourceId = None, + **kwargs, + ) -> DescribeStackResourcesOutput: + if physical_resource_id and stack_name: + raise ValidationError("Cannot specify both StackName and PhysicalResourceId") + # TODO: filter stack by PhysicalResourceId! + stack = find_stack(context.account_id, context.region, stack_name) + if not stack: + return stack_not_found_error(stack_name) + statuses = [ + res_status + for res_id, res_status in stack.resource_states.items() + if logical_resource_id in [res_id, None] + ] + for status in statuses: + status.setdefault("DriftInformation", {"StackResourceDriftStatus": "NOT_CHECKED"}) + return DescribeStackResourcesOutput(StackResources=statuses) + + @handler("ListStackResources") + def list_stack_resources( + self, context: RequestContext, stack_name: StackName, next_token: NextToken = None, **kwargs + ) -> ListStackResourcesOutput: + result = self.describe_stack_resources(context, stack_name) + + resources = deepcopy(result.get("StackResources", [])) + for resource in resources: + attrs = ["StackName", "StackId", "Timestamp", "PreviousResourceStatus"] + remove_attributes(resource, attrs) + + return ListStackResourcesOutput(StackResourceSummaries=resources) + + @handler("ValidateTemplate", expand=False) + def validate_template( + self, context: RequestContext, request: ValidateTemplateInput + ) -> ValidateTemplateOutput: + try: + # TODO implement actual validation logic + template_body = api_utils.get_template_body(request) + valid_template = json.loads(template_preparer.template_to_json(template_body)) + + parameters = [ + TemplateParameter( + ParameterKey=k, + DefaultValue=v.get("Default", ""), + NoEcho=v.get("NoEcho", False), + Description=v.get("Description", ""), + ) + for k, v in valid_template.get("Parameters", {}).items() + ] + + return ValidateTemplateOutput( + Description=valid_template.get("Description"), Parameters=parameters + ) + except Exception as e: + LOG.exception("Error validating template") + raise ValidationError("Template Validation Error") from e + + # ======================================= + # ============= Stack Set ============= + # ======================================= + + @handler("CreateStackSet", expand=False) + def create_stack_set( + self, context: RequestContext, request: CreateStackSetInput + ) -> CreateStackSetOutput: + state = get_cloudformation_store(context.account_id, context.region) + stack_set = StackSet(request) + stack_set_id = f"{stack_set.stack_set_name}:{long_uid()}" + stack_set.metadata["StackSetId"] = stack_set_id + state.stack_sets[stack_set_id] = stack_set + + return CreateStackSetOutput(StackSetId=stack_set_id) + + @handler("DescribeStackSetOperation") + def describe_stack_set_operation( + self, + context: RequestContext, + stack_set_name: StackSetName, + operation_id: ClientRequestToken, + call_as: CallAs = None, + **kwargs, + ) -> DescribeStackSetOperationOutput: + state = get_cloudformation_store(context.account_id, context.region) + + set_name = stack_set_name + + stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name] + if not stack_set: + return not_found_error(f'Unable to find stack set "{set_name}"') + stack_set = stack_set[0] + result = stack_set.operations.get(operation_id) + if not result: + LOG.debug( + 'Unable to find operation ID "%s" for stack set "%s" in list: %s', + operation_id, + set_name, + list(stack_set.operations.keys()), + ) + return not_found_error( + f'Unable to find operation ID "{operation_id}" for stack set "{set_name}"' + ) + + return DescribeStackSetOperationOutput(StackSetOperation=result) + + @handler("DescribeStackSet") + def describe_stack_set( + self, + context: RequestContext, + stack_set_name: StackSetName, + call_as: CallAs = None, + **kwargs, + ) -> DescribeStackSetOutput: + state = get_cloudformation_store(context.account_id, context.region) + result = [ + sset.metadata + for sset in state.stack_sets.values() + if sset.stack_set_name == stack_set_name + ] + if not result: + return not_found_error(f'Unable to find stack set "{stack_set_name}"') + + return DescribeStackSetOutput(StackSet=result[0]) + + @handler("ListStackSets", expand=False) + def list_stack_sets( + self, context: RequestContext, request: ListStackSetsInput + ) -> ListStackSetsOutput: + state = get_cloudformation_store(context.account_id, context.region) + result = [sset.metadata for sset in state.stack_sets.values()] + return ListStackSetsOutput(Summaries=result) + + @handler("UpdateStackSet", expand=False) + def update_stack_set( + self, context: RequestContext, request: UpdateStackSetInput + ) -> UpdateStackSetOutput: + state = get_cloudformation_store(context.account_id, context.region) + set_name = request.get("StackSetName") + stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name] + if not stack_set: + return not_found_error(f'Stack set named "{set_name}" does not exist') + stack_set = stack_set[0] + stack_set.metadata.update(request) + op_id = request.get("OperationId") or short_uid() + operation = { + "OperationId": op_id, + "StackSetId": stack_set.metadata["StackSetId"], + "Action": "UPDATE", + "Status": "SUCCEEDED", + } + stack_set.operations[op_id] = operation + return UpdateStackSetOutput(OperationId=op_id) + + @handler("DeleteStackSet") + def delete_stack_set( + self, + context: RequestContext, + stack_set_name: StackSetName, + call_as: CallAs = None, + **kwargs, + ) -> DeleteStackSetOutput: + state = get_cloudformation_store(context.account_id, context.region) + stack_set = [ + sset for sset in state.stack_sets.values() if sset.stack_set_name == stack_set_name + ] + + if not stack_set: + return not_found_error(f'Stack set named "{stack_set_name}" does not exist') + + # TODO: add a check for remaining stack instances + + for instance in stack_set[0].stack_instances: + deployer = template_deployer.TemplateDeployer( + context.account_id, context.region, instance.stack + ) + deployer.delete_stack() + return DeleteStackSetOutput() + + @handler("CreateStackInstances", expand=False) + def create_stack_instances( + self, + context: RequestContext, + request: CreateStackInstancesInput, + ) -> CreateStackInstancesOutput: + state = get_cloudformation_store(context.account_id, context.region) + + set_name = request.get("StackSetName") + stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name] + + if not stack_set: + return not_found_error(f'Stack set named "{set_name}" does not exist') + + stack_set = stack_set[0] + op_id = request.get("OperationId") or short_uid() + sset_meta = stack_set.metadata + accounts = request["Accounts"] + regions = request["Regions"] + + stacks_to_await = [] + for account in accounts: + for region in regions: + # deploy new stack + LOG.debug( + 'Deploying instance for stack set "%s" in account: %s region %s', + set_name, + account, + region, + ) + cf_client = connect_to(aws_access_key_id=account, region_name=region).cloudformation + kwargs = select_attributes(sset_meta, ["TemplateBody"]) or select_attributes( + sset_meta, ["TemplateURL"] + ) + stack_name = f"sset-{set_name}-{account}" + + # skip creation of existing stacks + if find_stack(context.account_id, context.region, stack_name): + continue + + result = cf_client.create_stack(StackName=stack_name, **kwargs) + stacks_to_await.append((stack_name, account, region)) + # store stack instance + instance = { + "StackSetId": sset_meta["StackSetId"], + "OperationId": op_id, + "Account": account, + "Region": region, + "StackId": result["StackId"], + "Status": "CURRENT", + "StackInstanceStatus": {"DetailedStatus": "SUCCEEDED"}, + } + instance = StackInstance(instance) + stack_set.stack_instances.append(instance) + + # wait for completion of stack + for stack_name, account_id, region_name in stacks_to_await: + client = connect_to( + aws_access_key_id=account_id, region_name=region_name + ).cloudformation + client.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # record operation + operation = { + "OperationId": op_id, + "StackSetId": stack_set.metadata["StackSetId"], + "Action": "CREATE", + "Status": "SUCCEEDED", + } + stack_set.operations[op_id] = operation + + return CreateStackInstancesOutput(OperationId=op_id) + + @handler("ListStackInstances", expand=False) + def list_stack_instances( + self, + context: RequestContext, + request: ListStackInstancesInput, + ) -> ListStackInstancesOutput: + set_name = request.get("StackSetName") + state = get_cloudformation_store(context.account_id, context.region) + stack_set = [sset for sset in state.stack_sets.values() if sset.stack_set_name == set_name] + if not stack_set: + return not_found_error(f'Stack set named "{set_name}" does not exist') + + stack_set = stack_set[0] + result = [inst.metadata for inst in stack_set.stack_instances] + return ListStackInstancesOutput(Summaries=result) + + @handler("DeleteStackInstances", expand=False) + def delete_stack_instances( + self, + context: RequestContext, + request: DeleteStackInstancesInput, + ) -> DeleteStackInstancesOutput: + op_id = request.get("OperationId") or short_uid() + + accounts = request["Accounts"] + regions = request["Regions"] + + state = get_cloudformation_store(context.account_id, context.region) + stack_sets = state.stack_sets.values() + + set_name = request.get("StackSetName") + stack_set = next((sset for sset in stack_sets if sset.stack_set_name == set_name), None) + + if not stack_set: + return not_found_error(f'Stack set named "{set_name}" does not exist') + + for account in accounts: + for region in regions: + instance = find_stack_instance(stack_set, account, region) + if instance: + stack_set.stack_instances.remove(instance) + + # record operation + operation = { + "OperationId": op_id, + "StackSetId": stack_set.metadata["StackSetId"], + "Action": "DELETE", + "Status": "SUCCEEDED", + } + stack_set.operations[op_id] = operation + + return DeleteStackInstancesOutput(OperationId=op_id) + + @handler("RegisterType", expand=False) + def register_type( + self, + context: RequestContext, + request: RegisterTypeInput, + ) -> RegisterTypeOutput: + return RegisterTypeOutput() + + def list_types( + self, context: RequestContext, request: ListTypesInput, **kwargs + ) -> ListTypesOutput: + def is_list_overridden(child_class, parent_class): + if hasattr(child_class, "list"): + import inspect + + child_method = child_class.list + parent_method = parent_class.list + return inspect.unwrap(child_method) is not inspect.unwrap(parent_method) + return False + + def get_listable_types_summaries(plugin_manager): + plugins = plugin_manager.list_names() + type_summaries = [] + for plugin in plugins: + type_summary = TypeSummary( + Type=RegistryType.RESOURCE, + TypeName=plugin, + ) + provider = plugin_manager.load(plugin) + if is_list_overridden(provider.factory, ResourceProvider): + type_summaries.append(type_summary) + return type_summaries + + from localstack.services.cloudformation.resource_provider import ( + plugin_manager, + ) + + type_summaries = get_listable_types_summaries(plugin_manager) + if PRO_RESOURCE_PROVIDERS: + from localstack.services.cloudformation.resource_provider import ( + pro_plugin_manager, + ) + + type_summaries.extend(get_listable_types_summaries(pro_plugin_manager)) + + return ListTypesOutput(TypeSummaries=type_summaries) diff --git a/localstack-core/localstack/services/cloudformation/provider_utils.py b/localstack-core/localstack/services/cloudformation/provider_utils.py new file mode 100644 index 0000000000000..d7e3eb49b79f2 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/provider_utils.py @@ -0,0 +1,252 @@ +""" +A set of utils for use in resource providers. + +Avoid any imports to localstack here and keep external imports to a minimum! +This is because we want to be able to package a resource provider without including localstack code. +""" + +import builtins +import json +import re +import uuid +from copy import deepcopy +from pathlib import Path +from typing import Callable, List, Optional + +from botocore.model import Shape, StructureShape + + +def generate_default_name(stack_name: str, logical_resource_id: str): + random_id_part = str(uuid.uuid4())[0:8] + resource_id_part = logical_resource_id[:24] + stack_name_part = stack_name[: 63 - 2 - (len(random_id_part) + len(resource_id_part))] + return f"{stack_name_part}-{resource_id_part}-{random_id_part}" + + +def generate_default_name_without_stack(logical_resource_id: str): + random_id_part = str(uuid.uuid4())[0:8] + resource_id_part = logical_resource_id[: 63 - 1 - len(random_id_part)] + return f"{resource_id_part}-{random_id_part}" + + +# ========= Helpers for boto calls ========== +# (equivalent to the old ones in deployment_utils.py) + + +def deselect_attributes(model: dict, params: list[str]) -> dict: + return {k: v for k, v in model.items() if k not in params} + + +def select_attributes(model: dict, params: list[str]) -> dict: + return {k: v for k, v in model.items() if k in params} + + +def keys_lower(model: dict) -> dict: + return {k.lower(): v for k, v in model.items()} + + +def convert_pascalcase_to_lower_camelcase(item: str) -> str: + if len(item) <= 1: + return item.lower() + else: + return f"{item[0].lower()}{item[1:]}" + + +def convert_lower_camelcase_to_pascalcase(item: str) -> str: + if len(item) <= 1: + return item.upper() + else: + return f"{item[0].upper()}{item[1:]}" + + +def _recurse_properties(obj: dict | list, fn: Callable) -> dict | list: + obj = fn(obj) + if isinstance(obj, dict): + return {k: _recurse_properties(v, fn) for k, v in obj.items()} + elif isinstance(obj, list): + return [_recurse_properties(v, fn) for v in obj] + else: + return obj + + +def recurse_properties(properties: dict, fn: Callable) -> dict: + return _recurse_properties(deepcopy(properties), fn) + + +def keys_pascalcase_to_lower_camelcase(model: dict) -> dict: + """Recursively change any dicts keys to lower camelcase""" + + def _keys_pascalcase_to_lower_camelcase(obj): + if isinstance(obj, dict): + return {convert_pascalcase_to_lower_camelcase(k): v for k, v in obj.items()} + else: + return obj + + return _recurse_properties(model, _keys_pascalcase_to_lower_camelcase) + + +def keys_lower_camelcase_to_pascalcase(model: dict) -> dict: + """Recursively change any dicts keys to PascalCase""" + + def _keys_lower_camelcase_to_pascalcase(obj): + if isinstance(obj, dict): + return {convert_lower_camelcase_to_pascalcase(k): v for k, v in obj.items()} + else: + return obj + + return _recurse_properties(model, _keys_lower_camelcase_to_pascalcase) + + +def transform_list_to_dict(param, key_attr_name="Key", value_attr_name="Value"): + result = {} + for entry in param: + key = entry[key_attr_name] + value = entry[value_attr_name] + result[key] = value + return result + + +def remove_none_values(obj): + """Remove None values (recursively) in the given object.""" + if isinstance(obj, dict): + return {k: remove_none_values(v) for k, v in obj.items() if v is not None} + elif isinstance(obj, list): + return [o for o in obj if o is not None] + else: + return obj + + +# FIXME: this shouldn't be necessary in the future +param_validation = re.compile( + r"Invalid type for parameter (?P[\w.]+), value: (?P\w+), type: \w+)'>, valid types: \w+)'>" +) + + +def get_nested(obj: dict, path: str): + parts = path.split(".") + result = obj + for p in parts[:-1]: + result = result.get(p, {}) + return result.get(parts[-1]) + + +def set_nested(obj: dict, path: str, value): + parts = path.split(".") + result = obj + for p in parts[:-1]: + result = result.get(p, {}) + result[parts[-1]] = value + + +def fix_boto_parameters_based_on_report(original_params: dict, report: str) -> dict: + """ + Fix invalid type parameter validation errors in boto request parameters + + :param original_params: original boto request parameters that lead to the parameter validation error + :param report: error report from botocore ParamValidator + :return: a copy of original_params with all values replaced by their correctly cast ones + """ + params = deepcopy(original_params) + for found in param_validation.findall(report): + param_name, value, wrong_class, valid_class = found + cast_class = getattr(builtins, valid_class) + old_value = get_nested(params, param_name) + + if cast_class == bool and str(old_value).lower() in ["true", "false"]: + new_value = str(old_value).lower() == "true" + else: + new_value = cast_class(old_value) + set_nested(params, param_name, new_value) + return params + + +def convert_request_kwargs(parameters: dict, input_shape: StructureShape) -> dict: + """ + Transform a dict of request kwargs for a boto3 request by making sure the keys in the structure recursively conform to the specified input shape. + :param parameters: the kwargs that would be passed to the boto3 client call, e.g. boto3.client("s3").create_bucket(**parameters) + :param input_shape: The botocore input shape of the operation that you want to call later with the fixed inputs + :return: a transformed dictionary with the correct casing recursively applied + """ + + def get_fixed_key(key: str, members: dict[str, Shape]) -> str: + """return the case-insensitively matched key from the shape or default to the current key""" + for k in members: + if k.lower() == key.lower(): + return k + return key + + def transform_value(value, member_shape): + if isinstance(value, dict) and hasattr(member_shape, "members"): + return convert_request_kwargs(value, member_shape) + elif isinstance(value, list) and hasattr(member_shape, "member"): + return [transform_value(item, member_shape.member) for item in value] + + # fix the typing of the value + match member_shape.type_name: + case "string": + return str(value) + case "integer" | "long": + return int(value) + case "boolean": + if isinstance(value, bool): + return value + return True if value.lower() == "true" else False + case _: + return value + + transformed_dict = {} + for key, value in parameters.items(): + correct_key = get_fixed_key(key, input_shape.members) + member_shape = input_shape.members.get(correct_key) + + if member_shape is None: + continue # skipping this entry, so it's not included in the transformed dict + elif isinstance(value, dict) and hasattr(member_shape, "members"): + transformed_dict[correct_key] = convert_request_kwargs(value, member_shape) + elif isinstance(value, list) and hasattr(member_shape, "member"): + transformed_dict[correct_key] = [ + transform_value(item, member_shape.member) for item in value + ] + else: + transformed_dict[correct_key] = transform_value(value, member_shape) + + return transformed_dict + + +def convert_values_to_numbers(input_dict: dict, keys_to_skip: Optional[List[str]] = None): + """ + Recursively converts all string values that represent valid integers + in a dictionary (including nested dictionaries and lists) to integers. + + Example: + original_dict = {'Gid': '1322', 'SecondaryGids': ['1344', '1452'], 'Uid': '13234'} + output_dict = {'Gid': 1322, 'SecondaryGids': [1344, 1452], 'Uid': 13234} + + :param input_dict input dict with values to convert + :param keys_to_skip keys to which values are not meant to be converted + :return output_dict + """ + + keys_to_skip = keys_to_skip or [] + + def recursive_convert(obj): + if isinstance(obj, dict): + return { + key: recursive_convert(value) if key not in keys_to_skip else value + for key, value in obj.items() + } + elif isinstance(obj, list): + return [recursive_convert(item) for item in obj] + elif isinstance(obj, str) and obj.isdigit(): + return int(obj) + else: + return obj + + return recursive_convert(input_dict) + + +# LocalStack specific utilities +def get_schema_path(file_path: Path) -> dict: + file_name_base = file_path.name.removesuffix(".py").removesuffix(".py.enc") + with Path(file_path).parent.joinpath(f"{file_name_base}.schema.json").open() as fd: + return json.load(fd) diff --git a/localstack-core/localstack/services/cloudformation/resource_provider.py b/localstack-core/localstack/services/cloudformation/resource_provider.py new file mode 100644 index 0000000000000..421ad8ecd2b30 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_provider.py @@ -0,0 +1,630 @@ +from __future__ import annotations + +import copy +import logging +import re +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum, auto +from logging import Logger +from math import ceil +from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, Type, TypedDict, TypeVar + +import botocore +from botocore.client import BaseClient +from botocore.exceptions import ClientError +from botocore.model import OperationModel +from plux import Plugin, PluginManager + +from localstack import config +from localstack.aws.connect import InternalClientFactory, ServiceLevelClientFactory +from localstack.services.cloudformation.deployment_utils import ( + check_not_found_exception, + convert_data_types, + fix_account_id_in_arns, + fix_boto_parameters_based_on_report, + remove_none_values, +) +from localstack.services.cloudformation.engine.quirks import PHYSICAL_RESOURCE_ID_SPECIAL_CASES +from localstack.services.cloudformation.provider_utils import convert_request_kwargs +from localstack.services.cloudformation.service_models import KEY_RESOURCE_STATE + +PRO_RESOURCE_PROVIDERS = False +try: + from localstack.pro.core.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPluginExt, + ) + + PRO_RESOURCE_PROVIDERS = True +except ImportError: + pass + +if TYPE_CHECKING: + from localstack.services.cloudformation.engine.types import ( + FuncDetails, + FuncDetailsValue, + ResourceDefinition, + ) + +LOG = logging.getLogger(__name__) + +Properties = TypeVar("Properties") + +PUBLIC_REGISTRY: dict[str, Type[ResourceProvider]] = {} + +PROVIDER_DEFAULTS = {} # TODO: remove this after removing patching in -ext + + +class OperationStatus(Enum): + PENDING = auto() + IN_PROGRESS = auto() + SUCCESS = auto() + FAILED = auto() + + +@dataclass +class ProgressEvent(Generic[Properties]): + status: OperationStatus + resource_model: Optional[Properties] = None + resource_models: Optional[list[Properties]] = None + + message: str = "" + result: Optional[str] = None + error_code: Optional[str] = None # TODO: enum + custom_context: dict = field(default_factory=dict) + + +class Credentials(TypedDict): + accessKeyId: str + secretAccessKey: str + sessionToken: str + + +class ResourceProviderPayloadRequestData(TypedDict): + logicalResourceId: str + resourceProperties: Properties + previousResourceProperties: Optional[Properties] + callerCredentials: Credentials + providerCredentials: Credentials + systemTags: dict[str, str] + previousSystemTags: dict[str, str] + stackTags: dict[str, str] + previousStackTags: dict[str, str] + + +class ResourceProviderPayload(TypedDict): + callbackContext: dict + stackId: str + requestData: ResourceProviderPayloadRequestData + resourceType: str + resourceTypeVersion: str + awsAccountId: str + bearerToken: str + region: str + action: str + + +ResourceProperties = TypeVar("ResourceProperties") + + +def _handler_provide_client_params(event_name: str, params: dict, model: OperationModel, **kwargs): + """ + A botocore hook handler that will try to convert the passed parameters according to the given operation model + """ + return convert_request_kwargs(params, model.input_shape) + + +class ConvertingInternalClientFactory(InternalClientFactory): + def _get_client_post_hook(self, client: BaseClient) -> BaseClient: + """ + Register handlers that modify the passed properties to make them compatible with the API structure + """ + + client.meta.events.register( + "provide-client-params.*.*", handler=_handler_provide_client_params + ) + + return super()._get_client_post_hook(client) + + +_cfn_resource_client_factory = ConvertingInternalClientFactory(use_ssl=config.DISTRIBUTED_MODE) + + +def convert_payload( + stack_name: str, stack_id: str, payload: ResourceProviderPayload +) -> ResourceRequest[Properties]: + client_factory = _cfn_resource_client_factory( + aws_access_key_id=payload["requestData"]["callerCredentials"]["accessKeyId"], + aws_session_token=payload["requestData"]["callerCredentials"]["sessionToken"], + aws_secret_access_key=payload["requestData"]["callerCredentials"]["secretAccessKey"], + region_name=payload["region"], + ) + desired_state = payload["requestData"]["resourceProperties"] + rr = ResourceRequest( + _original_payload=desired_state, + aws_client_factory=client_factory, + request_token=str(uuid.uuid4()), # TODO: not actually a UUID + stack_name=stack_name, + stack_id=stack_id, + account_id=payload["awsAccountId"], + region_name=payload["region"], + desired_state=desired_state, + logical_resource_id=payload["requestData"]["logicalResourceId"], + resource_type=payload["resourceType"], + logger=logging.getLogger("abc"), + custom_context=payload["callbackContext"], + action=payload["action"], + ) + + if previous_properties := payload["requestData"].get("previousResourceProperties"): + rr.previous_state = previous_properties + + return rr + + +@dataclass +class ResourceRequest(Generic[Properties]): + _original_payload: Properties + + aws_client_factory: ServiceLevelClientFactory + request_token: str + stack_name: str + stack_id: str + account_id: str + region_name: str + action: str + + desired_state: Properties + + logical_resource_id: str + resource_type: str + + logger: Logger + + custom_context: dict = field(default_factory=dict) + + previous_state: Optional[Properties] = None + previous_tags: Optional[dict[str, str]] = None + tags: dict[str, str] = field(default_factory=dict) + + +class CloudFormationResourceProviderPlugin(Plugin): + """ + Base class for resource provider plugins. + """ + + namespace = "localstack.cloudformation.resource_providers" + + +class ResourceProvider(Generic[Properties]): + """ + This provides a base class onto which service-specific resource providers are built. + """ + + SCHEMA: dict + + def create(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + raise NotImplementedError + + def update(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + raise NotImplementedError + + def delete(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + raise NotImplementedError + + def read(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + raise NotImplementedError + + def list(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + raise NotImplementedError + + +# legacy helpers +def get_resource_type(resource: dict) -> str: + """this is currently overwritten in PRO to add support for custom resources""" + if isinstance(resource, str): + raise ValueError(f"Invalid argument: {resource}") + try: + resource_type: str = resource["Type"] + + if resource_type.startswith("Custom::"): + return "AWS::CloudFormation::CustomResource" + return resource_type + except Exception: + LOG.warning( + "Failed to retrieve resource type %s", + resource.get("Type"), + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + +def invoke_function( + account_id: str, + region_name: str, + function: Callable, + params: dict, + resource_type: str, + func_details: FuncDetails, + action_name: str, + resource: Any, +) -> Any: + try: + LOG.debug( + 'Request for resource type "%s" in account %s region %s: %s %s', + resource_type, + account_id, + region_name, + func_details["function"], + params, + ) + try: + result = function(**params) + except botocore.exceptions.ParamValidationError as e: + # alternatively we could also use the ParamValidator directly + report = e.kwargs.get("report") + if not report: + raise + + LOG.debug("Converting parameters to allowed types") + LOG.debug("Report: %s", report) + converted_params = fix_boto_parameters_based_on_report(params, report) + LOG.debug("Original parameters: %s", params) + LOG.debug("Converted parameters: %s", converted_params) + + result = function(**converted_params) + except Exception as e: + if action_name == "Remove" and check_not_found_exception(e, resource_type, resource): + return + log_method = LOG.warning + if config.CFN_VERBOSE_ERRORS: + log_method = LOG.exception + log_method("Error calling %s with params: %s for resource: %s", function, params, resource) + raise e + + return result + + +def get_service_name(resource): + res_type = resource["Type"] + parts = res_type.split("::") + if len(parts) == 1: + return None + if "Cognito::IdentityPool" in res_type: + return "cognito-identity" + if res_type.endswith("Cognito::UserPool"): + return "cognito-idp" + if parts[-2] == "Cognito": + return "cognito-idp" + if parts[-2] == "Elasticsearch": + return "es" + if parts[-2] == "OpenSearchService": + return "opensearch" + if parts[-2] == "KinesisFirehose": + return "firehose" + if parts[-2] == "ResourceGroups": + return "resource-groups" + if parts[-2] == "CertificateManager": + return "acm" + if "ElasticLoadBalancing::" in res_type: + return "elb" + if "ElasticLoadBalancingV2::" in res_type: + return "elbv2" + if "ApplicationAutoScaling::" in res_type: + return "application-autoscaling" + if "MSK::" in res_type: + return "kafka" + if "Timestream::" in res_type: + return "timestream-write" + return parts[1].lower() + + +def resolve_resource_parameters( + account_id_: str, + region_name_: str, + stack_name: str, + resource_definition: ResourceDefinition, + resources: dict[str, ResourceDefinition], + resource_id: str, + func_details: FuncDetailsValue, +) -> dict | None: + params = func_details.get("parameters") or ( + lambda account_id, region_name, properties, logical_resource_id, *args, **kwargs: properties + ) + resource_props = resource_definition["Properties"] = resource_definition.get("Properties", {}) + resource_props = dict(resource_props) + resource_state = resource_definition.get(KEY_RESOURCE_STATE, {}) + last_deployed_state = resource_definition.get("_last_deployed_state", {}) + + if callable(params): + # resolve parameter map via custom function + params = params( + account_id_, region_name_, resource_props, resource_id, resource_definition, stack_name + ) + else: + # it could be a list like ['param1', 'param2', {'apiCallParamName': 'cfResourcePropName'}] + if isinstance(params, list): + _params = {} + for param in params: + if isinstance(param, dict): + _params.update(param) + else: + _params[param] = param + params = _params + + params = dict(params) + # TODO(srw): mutably mapping params :( + for param_key, prop_keys in dict(params).items(): + params.pop(param_key, None) + if not isinstance(prop_keys, list): + prop_keys = [prop_keys] + for prop_key in prop_keys: + if callable(prop_key): + prop_value = prop_key( + account_id_, + region_name_, + resource_props, + resource_id, + resource_definition, + stack_name, + ) + else: + prop_value = resource_props.get( + prop_key, + resource_definition.get( + prop_key, + resource_state.get(prop_key, last_deployed_state.get(prop_key)), + ), + ) + if prop_value is not None: + params[param_key] = prop_value + break + + # this is an indicator that we should skip this resource deployment, and return + if params is None: + return + + # FIXME: move this to a single place after template processing is finished + # convert any moto account IDs (123456789012) in ARNs to our format (000000000000) + params = fix_account_id_in_arns(params, account_id_) + # convert data types (e.g., boolean strings to bool) + # TODO: this might not be needed anymore + params = convert_data_types(func_details.get("types", {}), params) + # remove None values, as they usually raise boto3 errors + params = remove_none_values(params) + + return params + + +class NoResourceProvider(Exception): + pass + + +def resolve_json_pointer(resource_props: Properties, primary_id_path: str) -> str: + primary_id_path = primary_id_path.replace("/properties", "") + parts = [p for p in primary_id_path.split("/") if p] + + resolved_part = resource_props.copy() + for i in range(len(parts)): + part = parts[i] + resolved_part = resolved_part.get(part) + if i == len(parts) - 1: + # last part + return resolved_part + + raise Exception(f"Resource properties is missing field: {part}") + + +class ResourceProviderExecutor: + """ + Point of abstraction between our integration with generic base models, and the new providers. + """ + + def __init__( + self, + *, + stack_name: str, + stack_id: str, + ): + self.stack_name = stack_name + self.stack_id = stack_id + + def deploy_loop( + self, + resource_provider: ResourceProvider, + resource: dict, + raw_payload: ResourceProviderPayload, + max_timeout: int = config.CFN_PER_RESOURCE_TIMEOUT, + sleep_time: float = 5, + ) -> ProgressEvent[Properties]: + payload = copy.deepcopy(raw_payload) + + max_iterations = max(ceil(max_timeout / sleep_time), 2) + + for current_iteration in range(max_iterations): + resource_type = get_resource_type({"Type": raw_payload["resourceType"]}) + resource["SpecifiedProperties"] = raw_payload["requestData"]["resourceProperties"] + + try: + event = self.execute_action(resource_provider, payload) + except ClientError: + LOG.error( + "client error invoking '%s' handler for resource '%s' (type '%s')", + raw_payload["action"], + raw_payload["requestData"]["logicalResourceId"], + resource_type, + ) + raise + + match event.status: + case OperationStatus.FAILED: + return event + case OperationStatus.SUCCESS: + if not hasattr(resource_provider, "SCHEMA"): + raise Exception( + "A ResourceProvider should always have a SCHEMA property defined." + ) + resource_type_schema = resource_provider.SCHEMA + physical_resource_id = self.extract_physical_resource_id_from_model_with_schema( + event.resource_model, + raw_payload["resourceType"], + resource_type_schema, + ) + + resource["PhysicalResourceId"] = physical_resource_id + resource["Properties"] = event.resource_model + resource["_last_deployed_state"] = copy.deepcopy(event.resource_model) + return event + case OperationStatus.IN_PROGRESS: + # update the shared state + context = {**payload["callbackContext"], **event.custom_context} + payload["callbackContext"] = context + payload["requestData"]["resourceProperties"] = event.resource_model + resource["Properties"] = event.resource_model + + if current_iteration == 0: + time.sleep(0) + else: + time.sleep(sleep_time) + case OperationStatus.PENDING: + # come back to this resource in another iteration + return event + case invalid_status: + raise ValueError( + f"Invalid OperationStatus ({invalid_status}) returned for resource {raw_payload['requestData']['logicalResourceId']} (type {raw_payload['resourceType']})" + ) + + else: + raise TimeoutError( + f"Resource deployment for resource {raw_payload['requestData']['logicalResourceId']} (type {raw_payload['resourceType']}) timed out." + ) + + def execute_action( + self, resource_provider: ResourceProvider, raw_payload: ResourceProviderPayload + ) -> ProgressEvent[Properties]: + change_type = raw_payload["action"] + request = convert_payload( + stack_name=self.stack_name, stack_id=self.stack_id, payload=raw_payload + ) + + match change_type: + case "Add": + return resource_provider.create(request) + case "Dynamic" | "Modify": + try: + return resource_provider.update(request) + except NotImplementedError: + LOG.warning( + 'Unable to update resource type "%s", id "%s"', + request.resource_type, + request.logical_resource_id, + ) + if request.previous_state is None: + # this is an issue with our update detection. We should never be in this state. + request.action = "Add" + return resource_provider.create(request) + + return ProgressEvent( + status=OperationStatus.SUCCESS, resource_model=request.previous_state + ) + except Exception as e: + # FIXME: this fallback should be removed after fixing updates in general (order/dependenies) + # catch-all for any exception that looks like a not found exception + if check_not_found_exception(e, request.resource_type, request.desired_state): + return ProgressEvent( + status=OperationStatus.SUCCESS, resource_model=request.previous_state + ) + + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model={}, + message=f"Failed to delete resource with id {request.logical_resource_id} of type {request.resource_type}", + ) + case "Remove": + try: + return resource_provider.delete(request) + except Exception as e: + # catch-all for any exception that looks like a not found exception + if check_not_found_exception(e, request.resource_type, request.desired_state): + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model={}, + message=f"Failed to delete resource with id {request.logical_resource_id} of type {request.resource_type}", + ) + case _: + raise NotImplementedError(change_type) # TODO: change error type + + @staticmethod + def try_load_resource_provider(resource_type: str) -> ResourceProvider | None: + # TODO: unify namespace of plugins + + # 1. try to load pro resource provider + # prioritise pro resource providers + if PRO_RESOURCE_PROVIDERS: + try: + plugin = pro_plugin_manager.load(resource_type) + return plugin.factory() + except ValueError: + # could not find a plugin for that name + pass + except Exception: + LOG.warning( + "Failed to load PRO resource type %s as a ResourceProvider.", + resource_type, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + # 2. try to load community resource provider + try: + plugin = plugin_manager.load(resource_type) + return plugin.factory() + except ValueError: + # could not find a plugin for that name + pass + except Exception: + if config.CFN_VERBOSE_ERRORS: + LOG.warning( + "Failed to load community resource type %s as a ResourceProvider.", + resource_type, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + # we could not find the resource provider + return None + + def extract_physical_resource_id_from_model_with_schema( + self, resource_model: Properties, resource_type: str, resource_type_schema: dict + ) -> str: + if resource_type in PHYSICAL_RESOURCE_ID_SPECIAL_CASES: + primary_id_path = PHYSICAL_RESOURCE_ID_SPECIAL_CASES[resource_type] + + if "<" in primary_id_path: + # composite quirk, e.g. something like MyRef|MyName + # try to extract parts + physical_resource_id = primary_id_path + find_results = re.findall("<([^>]+)>", primary_id_path) + for found_part in find_results: + resolved_part = resolve_json_pointer(resource_model, found_part) + physical_resource_id = physical_resource_id.replace( + f"<{found_part}>", resolved_part + ) + else: + physical_resource_id = resolve_json_pointer(resource_model, primary_id_path) + else: + primary_id_paths = resource_type_schema["primaryIdentifier"] + if len(primary_id_paths) > 1: + # TODO: auto-merge. Verify logic here with AWS + physical_resource_id = "-".join( + [resolve_json_pointer(resource_model, pip) for pip in primary_id_paths] + ) + else: + physical_resource_id = resolve_json_pointer(resource_model, primary_id_paths[0]) + + return physical_resource_id + + +plugin_manager = PluginManager(CloudFormationResourceProviderPlugin.namespace) +if PRO_RESOURCE_PROVIDERS: + pro_plugin_manager = PluginManager(CloudFormationResourceProviderPluginExt.namespace) diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/__init__.py b/localstack-core/localstack/services/cloudformation/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro.py new file mode 100644 index 0000000000000..8f17b3d36368e --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro.py @@ -0,0 +1,102 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.services.cloudformation.stores import get_cloudformation_store + + +class CloudFormationMacroProperties(TypedDict): + FunctionName: Optional[str] + Name: Optional[str] + Description: Optional[str] + Id: Optional[str] + LogGroupName: Optional[str] + LogRoleARN: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CloudFormationMacroProvider(ResourceProvider[CloudFormationMacroProperties]): + TYPE = "AWS::CloudFormation::Macro" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CloudFormationMacroProperties], + ) -> ProgressEvent[CloudFormationMacroProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - FunctionName + - Name + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + + # TODO: fix or validate that we want to keep this here. + # AWS::CloudFormation:: resources need special handling since they seem to require access to internal APIs + store = get_cloudformation_store(request.account_id, request.region_name) + store.macros[model["Name"]] = model + model["Id"] = model["Name"] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[CloudFormationMacroProperties], + ) -> ProgressEvent[CloudFormationMacroProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CloudFormationMacroProperties], + ) -> ProgressEvent[CloudFormationMacroProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + + store = get_cloudformation_store(request.account_id, request.region_name) + store.macros.pop(model["Name"], None) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def update( + self, + request: ResourceRequest[CloudFormationMacroProperties], + ) -> ProgressEvent[CloudFormationMacroProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro.schema.json b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro.schema.json new file mode 100644 index 0000000000000..a04056992eb09 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro.schema.json @@ -0,0 +1,38 @@ +{ + "typeName": "AWS::CloudFormation::Macro", + "description": "Resource Type definition for AWS::CloudFormation::Macro", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "FunctionName": { + "type": "string" + }, + "LogGroupName": { + "type": "string" + }, + "LogRoleARN": { + "type": "string" + }, + "Name": { + "type": "string" + } + }, + "required": [ + "FunctionName", + "Name" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro_plugin.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro_plugin.py new file mode 100644 index 0000000000000..9c6572792fc21 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_macro_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class CloudFormationMacroProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CloudFormation::Macro" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.cloudformation.resource_providers.aws_cloudformation_macro import ( + CloudFormationMacroProvider, + ) + + self.factory = CloudFormationMacroProvider diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack.py new file mode 100644 index 0000000000000..b30c629682cc6 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack.py @@ -0,0 +1,220 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class CloudFormationStackProperties(TypedDict): + TemplateURL: Optional[str] + Id: Optional[str] + NotificationARNs: Optional[list[str]] + Parameters: Optional[dict] + Tags: Optional[list[Tag]] + TimeoutInMinutes: Optional[int] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CloudFormationStackProvider(ResourceProvider[CloudFormationStackProperties]): + TYPE = "AWS::CloudFormation::Stack" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CloudFormationStackProperties], + ) -> ProgressEvent[CloudFormationStackProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - TemplateURL + + + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + + # TODO: validations + + if not request.custom_context.get(REPEATED_INVOCATION): + if not model.get("StackName"): + model["StackName"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + create_params = util.select_attributes( + model, + [ + "StackName", + "Parameters", + "NotificationARNs", + "TemplateURL", + "TimeoutInMinutes", + "Tags", + ], + ) + + create_params["Capabilities"] = [ + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM", + "CAPABILITY_AUTO_EXPAND", + ] + + create_params["Parameters"] = [ + { + "ParameterKey": k, + "ParameterValue": str(v).lower() if isinstance(v, bool) else str(v), + } + for k, v in create_params.get("Parameters", {}).items() + ] + + result = request.aws_client_factory.cloudformation.create_stack(**create_params) + model["Id"] = result["StackId"] + + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + stack = request.aws_client_factory.cloudformation.describe_stacks(StackName=model["Id"])[ + "Stacks" + ][0] + match stack["StackStatus"]: + case "CREATE_COMPLETE": + # only store nested stack outputs when we know the deploy has completed + model["Outputs"] = { + o["OutputKey"]: o["OutputValue"] for o in stack.get("Outputs", []) + } + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + case "CREATE_IN_PROGRESS": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + case "CREATE_FAILED": + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + ) + case _: + raise Exception(f"Unexpected status: {stack['StackStatus']}") + + def read( + self, + request: ResourceRequest[CloudFormationStackProperties], + ) -> ProgressEvent[CloudFormationStackProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CloudFormationStackProperties], + ) -> ProgressEvent[CloudFormationStackProperties]: + """ + Delete a resource + """ + + model = request.desired_state + if not request.custom_context.get(REPEATED_INVOCATION): + request.aws_client_factory.cloudformation.delete_stack(StackName=model["Id"]) + + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + try: + stack = request.aws_client_factory.cloudformation.describe_stacks( + StackName=model["Id"] + )["Stacks"][0] + except Exception as e: + if "does not exist" in str(e): + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + raise e + + match stack["StackStatus"]: + case "DELETE_COMPLETE": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + case "DELETE_IN_PROGRESS": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + case "DELETE_FAILED": + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + ) + case _: + raise Exception(f"Unexpected status: {stack['StackStatus']}") + + def update( + self, + request: ResourceRequest[CloudFormationStackProperties], + ) -> ProgressEvent[CloudFormationStackProperties]: + """ + Update a resource + + + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[CloudFormationStackProperties], + ) -> ProgressEvent[CloudFormationStackProperties]: + resources = request.aws_client_factory.cloudformation.describe_stacks() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + CloudFormationStackProperties(Id=resource["StackId"]) + for resource in resources["Stacks"] + ], + ) diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack.schema.json b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack.schema.json new file mode 100644 index 0000000000000..a26835e77ba10 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack.schema.json @@ -0,0 +1,65 @@ +{ + "typeName": "AWS::CloudFormation::Stack", + "description": "Resource Type definition for AWS::CloudFormation::Stack", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "NotificationARNs": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Parameters": { + "type": "object", + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "TemplateURL": { + "type": "string" + }, + "TimeoutInMinutes": { + "type": "integer" + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "TemplateURL" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack_plugin.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack_plugin.py new file mode 100644 index 0000000000000..9dc020a564aa4 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_stack_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class CloudFormationStackProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CloudFormation::Stack" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.cloudformation.resource_providers.aws_cloudformation_stack import ( + CloudFormationStackProvider, + ) + + self.factory = CloudFormationStackProvider diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition.py new file mode 100644 index 0000000000000..051c901e425d9 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition.py @@ -0,0 +1,83 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class CloudFormationWaitConditionProperties(TypedDict): + Count: Optional[int] + Data: Optional[dict] + Handle: Optional[str] + Id: Optional[str] + Timeout: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CloudFormationWaitConditionProvider(ResourceProvider[CloudFormationWaitConditionProperties]): + TYPE = "AWS::CloudFormation::WaitCondition" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CloudFormationWaitConditionProperties], + ) -> ProgressEvent[CloudFormationWaitConditionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Read-only properties: + - /properties/Data + - /properties/Id + + """ + model = request.desired_state + model["Data"] = {} # TODO + model["Id"] = f"{request.stack_id}/{uuid.uuid4()}/{request.logical_resource_id}" + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[CloudFormationWaitConditionProperties], + ) -> ProgressEvent[CloudFormationWaitConditionProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CloudFormationWaitConditionProperties], + ) -> ProgressEvent[CloudFormationWaitConditionProperties]: + """ + Delete a resource + + + """ + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) # NO-OP + + def update( + self, + request: ResourceRequest[CloudFormationWaitConditionProperties], + ) -> ProgressEvent[CloudFormationWaitConditionProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition.schema.json b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition.schema.json new file mode 100644 index 0000000000000..232d5c012e745 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition.schema.json @@ -0,0 +1,29 @@ +{ + "typeName": "AWS::CloudFormation::WaitCondition", + "description": "Resource Type definition for AWS::CloudFormation::WaitCondition", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "Data": { + "type": "object" + }, + "Count": { + "type": "integer" + }, + "Handle": { + "type": "string" + }, + "Timeout": { + "type": "string" + } + }, + "readOnlyProperties": [ + "/properties/Data", + "/properties/Id" + ], + "primaryIdentifier": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition_plugin.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition_plugin.py new file mode 100644 index 0000000000000..bdc8b49fd2e6d --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitcondition_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class CloudFormationWaitConditionProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CloudFormation::WaitCondition" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.cloudformation.resource_providers.aws_cloudformation_waitcondition import ( + CloudFormationWaitConditionProvider, + ) + + self.factory = CloudFormationWaitConditionProvider diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle.py new file mode 100644 index 0000000000000..f2b5237876fe0 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle.py @@ -0,0 +1,94 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class CloudFormationWaitConditionHandleProperties(TypedDict): + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CloudFormationWaitConditionHandleProvider( + ResourceProvider[CloudFormationWaitConditionHandleProperties] +): + TYPE = "AWS::CloudFormation::WaitConditionHandle" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CloudFormationWaitConditionHandleProperties], + ) -> ProgressEvent[CloudFormationWaitConditionHandleProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + + + + + Read-only properties: + - /properties/Id + + + + """ + # TODO: properly test this and fix s3 bucket usage + model = request.desired_state + + s3 = request.aws_client_factory.s3 + region = s3.meta.region_name + + bucket = f"cloudformation-waitcondition-{region}" + waitcondition_url = s3.generate_presigned_url( + "put_object", Params={"Bucket": bucket, "Key": request.stack_id} + ) + model["Id"] = waitcondition_url + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[CloudFormationWaitConditionHandleProperties], + ) -> ProgressEvent[CloudFormationWaitConditionHandleProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CloudFormationWaitConditionHandleProperties], + ) -> ProgressEvent[CloudFormationWaitConditionHandleProperties]: + """ + Delete a resource + + + """ + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[CloudFormationWaitConditionHandleProperties], + ) -> ProgressEvent[CloudFormationWaitConditionHandleProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle.schema.json b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle.schema.json new file mode 100644 index 0000000000000..34c317b900bf4 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle.schema.json @@ -0,0 +1,16 @@ +{ + "typeName": "AWS::CloudFormation::WaitConditionHandle", + "description": "Resource Type definition for AWS::CloudFormation::WaitConditionHandle", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + } + }, + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle_plugin.py b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle_plugin.py new file mode 100644 index 0000000000000..f5888171517ab --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/resource_providers/aws_cloudformation_waitconditionhandle_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class CloudFormationWaitConditionHandleProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CloudFormation::WaitConditionHandle" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.cloudformation.resource_providers.aws_cloudformation_waitconditionhandle import ( + CloudFormationWaitConditionHandleProvider, + ) + + self.factory = CloudFormationWaitConditionHandleProvider diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/CloudformationSchema.zip b/localstack-core/localstack/services/cloudformation/scaffolding/CloudformationSchema.zip new file mode 100644 index 0000000000000..f9c8e2f6dbf4d Binary files /dev/null and b/localstack-core/localstack/services/cloudformation/scaffolding/CloudformationSchema.zip differ diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/__main__.py b/localstack-core/localstack/services/cloudformation/scaffolding/__main__.py new file mode 100644 index 0000000000000..d6eb97f8dbbf1 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/scaffolding/__main__.py @@ -0,0 +1,824 @@ +from __future__ import annotations + +import json +import os +import zipfile +from dataclasses import dataclass +from enum import Enum, auto +from functools import reduce +from pathlib import Path +from typing import Any, Generator, Literal, Optional, TypedDict, TypeVar + +import click +from jinja2 import Environment, FileSystemLoader +from yaml import safe_dump + +from .propgen import generate_ir_for_type + +try: + from rich.console import Console + from rich.syntax import Syntax +except ImportError: + + class Console: + def print(self, text: str): + print("# " + text.replace("[underline]", "").replace("[/underline]", "")) + + def Syntax(text: str, *args, **kwargs) -> str: + return text + + +# increase when any major changes are done to the scaffolding, +# so that we can reason better about previously scaffolded resources in the future +SCAFFOLDING_VERSION = 2 + +# Some services require their names to be re-written as we know them by different names +SERVICE_NAME_MAP = { + "OpenSearchService": "OpenSearch", + "Lambda": "lambda_", +} + + +class Property(TypedDict): + type: Optional[Literal["str"]] + items: Optional[dict] + + +class HandlerDefinition(TypedDict): + permissions: Optional[list[str]] + + +class HandlersDefinition(TypedDict): + create: HandlerDefinition + read: HandlerDefinition + update: HandlerDefinition + delete: HandlerDefinition + list: HandlerDefinition + + +class ResourceSchema(TypedDict): + typeName: str + description: Optional[str] + required: Optional[list[str]] + properties: dict[str, Property] + handlers: HandlersDefinition + + +def resolve_ref(schema: ResourceSchema, target: str) -> dict: + """ + Given a schema {"a": {"b": "c"}} and the ref "#/a/b" return "c" + """ + target_path = filter(None, (elem.strip() for elem in target.lstrip("#").split("/"))) + + T = TypeVar("T") + + def lookup(d: dict[str, T], key: str) -> dict | T: + return d[key] + + return reduce(lookup, target_path, schema) + + +@dataclass +class ResourceName: + full_name: str + namespace: str + service: str + resource: str + python_compatible_service_name: str + + def provider_name(self) -> str: + return f"{self.service}{self.resource}" + + def schema_filename(self) -> str: + return f"{self.namespace.lower()}-{self.service.lower()}-{self.resource.lower()}.json" + + def path_compatible_full_name(self) -> str: + return f"{self.namespace.lower()}_{self.service.lower()}_{self.resource.lower()}" + + @classmethod + def from_name(cls, name: str) -> ResourceName: + parts = name.split("::") + if len(parts) != 3 or parts[0] != "AWS": + raise ValueError(f"Invalid CloudFormation resource name {name}") + + raw_service_name = parts[1].strip() + renamed_service = SERVICE_NAME_MAP.get(raw_service_name, raw_service_name) + + return ResourceName( + full_name=name, + namespace=parts[0], + service=raw_service_name, + python_compatible_service_name=renamed_service, + resource=parts[2].strip(), + ) + + +def get_formatted_template_output( + env: Environment, template_name: str, *render_args, **render_kwargs +) -> str: + template = env.get_template(template_name) + return template.render(*render_args, **render_kwargs) + + +class SchemaProvider: + def __init__(self, zipfile_path: Path): + self.schemas = {} + with zipfile.ZipFile(zipfile_path) as infile: + for filename in infile.namelist(): + with infile.open(filename) as schema_file: + schema = json.load(schema_file) + typename = schema["typeName"] + self.schemas[typename] = schema + + def schema(self, resource_name: ResourceName) -> ResourceSchema: + try: + return self.schemas[resource_name.full_name] + except KeyError as e: + raise click.ClickException( + f"Could not find schema for CloudFormation resource type: {resource_name.full_name}" + ) from e + + +LOCALSTACK_ROOT_DIR = Path(__file__).parent.joinpath("../../../../..").resolve() +LOCALSTACK_PRO_ROOT_DIR = LOCALSTACK_ROOT_DIR.joinpath("../localstack-ext").resolve() +TESTS_ROOT_DIR = LOCALSTACK_ROOT_DIR.joinpath( + "tests/aws/services/cloudformation/resource_providers" +) +TESTS_PRO_ROOT_DIR = LOCALSTACK_PRO_ROOT_DIR.joinpath( + "localstack-pro-core/tests/aws/services/cloudformation/resource_providers" +) + +assert LOCALSTACK_ROOT_DIR.is_dir(), f"{LOCALSTACK_ROOT_DIR} does not exist" +assert LOCALSTACK_PRO_ROOT_DIR.is_dir(), f"{LOCALSTACK_PRO_ROOT_DIR} does not exist" +assert TESTS_ROOT_DIR.is_dir(), f"{TESTS_ROOT_DIR} does not exist" +assert TESTS_PRO_ROOT_DIR.is_dir(), f"{TESTS_PRO_ROOT_DIR} does not exist" + + +def root_dir(pro: bool = False) -> Path: + if pro: + return LOCALSTACK_PRO_ROOT_DIR + else: + return LOCALSTACK_ROOT_DIR + + +def tests_root_dir(pro: bool = False) -> Path: + if pro: + return TESTS_PRO_ROOT_DIR + else: + return TESTS_ROOT_DIR + + +def template_path( + resource_name: ResourceName, + file_type: FileType, + root: Optional[Path] = None, + pro: bool = False, +) -> Path: + """ + Given a resource name and file type, return the path of the template relative to the template root. + """ + match file_type: + case FileType.minimal_template: + stub = "basic.yaml" + case FileType.attribute_template: + stub = "getatt_exploration.yaml" + case FileType.update_without_replacement_template: + stub = "update.yaml" + case FileType.autogenerated_template: + stub = "basic_autogenerated.yaml" + case _: + raise ValueError(f"File type {file_type} is not a template") + + output_path = ( + tests_root_dir(pro) + .joinpath( + f"{resource_name.python_compatible_service_name.lower()}/{resource_name.path_compatible_full_name()}/templates/{stub}" + ) + .resolve() + ) + + if root: + test_path = ( + root_dir(pro) + .joinpath( + f"tests/aws/cloudformation/resource_providers/{resource_name.python_compatible_service_name.lower()}/{resource_name.path_compatible_full_name()}" + ) + .resolve() + ) + + common_root = os.path.relpath(output_path, test_path) + return Path(common_root) + else: + return output_path + + +class FileType(Enum): + # service code + plugin = auto() + provider = auto() + + # test files + integration_test = auto() + getatt_test = auto() + # cloudcontrol_test = auto() + parity_test = auto() + + # templates + attribute_template = auto() + minimal_template = auto() + update_without_replacement_template = auto() + autogenerated_template = auto() + + # schema + schema = auto() + + +class TemplateRenderer: + def __init__(self, schema: ResourceSchema, environment: Environment, pro: bool = False): + self.schema = schema + self.environment = environment + self.pro = pro + + def render( + self, + file_type: FileType, + resource_name: ResourceName, + ) -> str: + # Generated outputs (template, schema) + # templates + if file_type == FileType.attribute_template: + return self.render_attribute_template(resource_name) + elif file_type == FileType.minimal_template: + return self.render_minimal_template(resource_name) + elif file_type == FileType.update_without_replacement_template: + return self.render_update_without_replacement_template(resource_name) + elif file_type == FileType.autogenerated_template: + return self.render_autogenerated_template(resource_name) + # schema + elif file_type == FileType.schema: + return json.dumps(self.schema, indent=2) + + template_mapping = { + FileType.plugin: "plugin_template.py.j2", + FileType.provider: "provider_template.py.j2", + FileType.getatt_test: "test_getatt_template.py.j2", + FileType.integration_test: "test_integration_template.py.j2", + # FileType.cloudcontrol_test: "test_cloudcontrol_template.py.j2", + FileType.parity_test: "test_parity_template.py.j2", + } + kwargs = dict( + name=resource_name.full_name, # AWS::SNS::Topic + resource=resource_name.provider_name(), # SNSTopic + scaffolding_version=f"v{SCAFFOLDING_VERSION}", + ) + # TODO: we might want to segregate each provider in its own directory + # e.g. .../resource_providers/aws_iam_role/test_X.py vs. .../resource_providers/iam/test_X.py + # add extra parameters + tests_output_path = root_dir(self.pro).joinpath( + f"tests/aws/cloudformation/resource_providers/{resource_name.python_compatible_service_name.lower()}/{resource_name.full_name.lower()}" + ) + match file_type: + case FileType.getatt_test: + kwargs["getatt_targets"] = list(self.get_getatt_targets()) + kwargs["service"] = resource_name.service.lower() + kwargs["resource"] = resource_name.resource.lower() + kwargs["template_path"] = str( + template_path(resource_name, FileType.attribute_template, tests_output_path) + ) + case FileType.provider: + property_ir = generate_ir_for_type( + [self.schema], + resource_name.full_name, + provider_prefix=resource_name.provider_name(), + ) + kwargs["provider_properties"] = property_ir + kwargs["required_properties"] = self.schema.get("required") + kwargs["create_only_properties"] = self.schema.get("createOnlyProperties") + kwargs["read_only_properties"] = self.schema.get("readOnlyProperties") + kwargs["primary_identifier"] = self.schema.get("primaryIdentifier") + kwargs["create_permissions"] = ( + self.schema.get("handlers", {}).get("create", {}).get("permissions") + ) + kwargs["delete_permissions"] = ( + self.schema.get("handlers", {}).get("delete", {}).get("permissions") + ) + kwargs["read_permissions"] = ( + self.schema.get("handlers", {}).get("read", {}).get("permissions") + ) + kwargs["update_permissions"] = ( + self.schema.get("handlers", {}).get("update", {}).get("permissions") + ) + kwargs["list_permissions"] = ( + self.schema.get("handlers", {}).get("list", {}).get("permissions") + ) + case FileType.plugin: + kwargs["service"] = resource_name.python_compatible_service_name.lower() + kwargs["lower_resource"] = resource_name.resource.lower() + kwargs["pro"] = self.pro + case FileType.integration_test: + kwargs["black_box_template_path"] = str( + template_path(resource_name, FileType.minimal_template, tests_output_path) + ) + kwargs["update_template_path"] = str( + template_path( + resource_name, + FileType.update_without_replacement_template, + tests_output_path, + ) + ) + kwargs["autogenerated_template_path"] = str( + template_path(resource_name, FileType.autogenerated_template, tests_output_path) + ) + # case FileType.cloudcontrol_test: + case FileType.parity_test: + kwargs["parity_test_filename"] = "test_parity.py" + case _: + raise NotImplementedError(f"Rendering template of type {file_type}") + + return get_formatted_template_output( + self.environment, template_mapping[file_type], **kwargs + ) + + def get_getatt_targets(self) -> Generator[str, None, None]: + for name, defn in self.schema["properties"].items(): + if "type" in defn and defn["type"] in ["string"]: + yield name + + def render_minimal_template(self, resource_name: ResourceName) -> str: + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": f"Template to exercise create and delete operations for {resource_name.full_name}", + "Resources": { + "MyResource": { + "Type": resource_name.full_name, + "Properties": {}, + }, + }, + "Outputs": { + "MyRef": { + "Value": { + "Ref": "MyResource", + }, + }, + }, + } + + return safe_dump(template, sort_keys=False) + + def render_update_without_replacement_template(self, resource_name: ResourceName) -> str: + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": f"Template to exercise updating {resource_name.full_name}", + "Parameters": { + "AttributeValue": { + "Type": "String", + "Description": "Value of property to change to force an update", + }, + }, + "Resources": { + "MyResource": { + "Type": resource_name.full_name, + "Properties": { + "SomeProperty": "!Ref AttributeValue", + }, + }, + }, + "Outputs": { + "MyRef": { + "Value": { + "Ref": "MyResource", + }, + }, + "MyOutput": { + "Value": "# TODO: the value to verify", + }, + }, + } + return safe_dump(template, sort_keys=False) + + def render_autogenerated_template(self, resource_name: ResourceName) -> str: + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": f"Template to exercise updating autogenerated properties of {resource_name.full_name}", + "Resources": { + "MyResource": { + "Type": resource_name.full_name, + }, + }, + "Outputs": { + "MyRef": { + "Value": { + "Ref": "MyResource", + }, + }, + }, + } + return safe_dump(template, sort_keys=False) + + def render_attribute_template(self, resource_name: ResourceName) -> str: + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": f"Template to exercise getting attributes of {resource_name.full_name}", + "Parameters": { + "AttributeName": { + "Type": "String", + "Description": "Name of the attribute to fetch from the resource", + }, + }, + "Resources": { + "MyResource": { + "Type": resource_name.full_name, + "Properties": {}, + }, + }, + "Outputs": self.render_outputs(), + } + + return safe_dump(template, sort_keys=False) + + def required_properties(self) -> dict[str, Property]: + return PropertyRenderer(self.schema).properties() + + def render_outputs(self) -> dict: + """ + Generate an output for each property in the schema + """ + outputs = {} + + # ref + outputs["MyRef"] = {"Value": {"Ref": "MyResource"}} + + # getatt + outputs["MyOutput"] = {"Value": {"Fn::GetAtt": ["MyResource", {"Ref": "AttributeName"}]}} + + return outputs + + +class PropertyRenderer: + def __init__(self, schema: ResourceSchema): + self.schema = schema + + def properties(self) -> dict: + required_properties = self.schema.get("required", []) + + result = {} + for name, defn in self.schema["properties"].items(): + if name not in required_properties: + continue + + value = self.render_property(defn) + result[name] = value + + return result + + def render_property(self, property: Property) -> str | dict | list: + if prop_type := property.get("type"): + if prop_type in {"string"}: + return self._render_basic(prop_type) + elif prop_type == "array": + return [self.render_property(item) for item in property["items"]] + elif oneof := property.get("oneOf"): + return self._render_one_of(oneof) + else: + raise NotImplementedError(property) + + def _render_basic(self, type: str) -> str: + return "CHANGEME" + + def _render_one_of(self, options: list[Property]) -> Any: + return self.render_property(options[0]) + + +class FileWriter: + destination_files: dict[FileType, Path] + + def __init__( + self, resource_name: ResourceName, console: Console, overwrite: bool, pro: bool = False + ): + self.resource_name = resource_name + self.console = console + self.overwrite = overwrite + self.pro = pro + + base_path = ( + ["localstack-pro-core", "localstack", "pro", "core"] + if self.pro + else ["localstack-core", "localstack"] + ) + + self.destination_files = { + FileType.provider: root_dir(self.pro).joinpath( + *base_path, + "services", + self.resource_name.python_compatible_service_name.lower(), + "resource_providers", + f"{self.resource_name.namespace.lower()}_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}.py", + ), + FileType.plugin: root_dir(self.pro).joinpath( + *base_path, + "services", + self.resource_name.python_compatible_service_name.lower(), + "resource_providers", + f"{self.resource_name.namespace.lower()}_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}_plugin.py", + ), + FileType.schema: root_dir(self.pro).joinpath( + *base_path, + "services", + self.resource_name.python_compatible_service_name.lower(), + "resource_providers", + f"aws_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}.schema.json", + ), + FileType.integration_test: tests_root_dir(self.pro).joinpath( + self.resource_name.python_compatible_service_name.lower(), + self.resource_name.path_compatible_full_name(), + "test_basic.py", + ), + FileType.getatt_test: tests_root_dir(self.pro).joinpath( + self.resource_name.python_compatible_service_name.lower(), + self.resource_name.path_compatible_full_name(), + "test_exploration.py", + ), + # FileType.cloudcontrol_test: tests_root_dir(self.pro).joinpath( + # self.resource_name.python_compatible_service_name.lower(), + # f"test_aws_{self.resource_name.service.lower()}_{self.resource_name.resource.lower()}_cloudcontrol.py", + # ), + FileType.parity_test: tests_root_dir(self.pro).joinpath( + self.resource_name.python_compatible_service_name.lower(), + self.resource_name.path_compatible_full_name(), + "test_parity.py", + ), + } + + # output files that are templates + templates = [ + FileType.attribute_template, + FileType.minimal_template, + FileType.update_without_replacement_template, + FileType.autogenerated_template, + ] + for template_type in templates: + self.destination_files[template_type] = template_path(self.resource_name, template_type) + + def write(self, file_type: FileType, contents: str): + file_destination = self.destination_files[file_type] + destination_path = file_destination.parent + destination_path.mkdir(parents=True, exist_ok=True) + + if file_destination.exists(): + should_overwrite = self.confirm_overwrite(file_destination) + if not should_overwrite: + self.console.print(f"Skipping {file_destination}") + return + + match file_type: + # provider + case FileType.provider: + self.ensure_python_init_files(destination_path) + self.write_text(contents, file_destination) + self.console.print(f"Written provider to {file_destination}") + case FileType.plugin: + self.ensure_python_init_files(destination_path) + self.write_text(contents, file_destination) + self.console.print(f"Written plugin to {file_destination}") + + # tests + case FileType.integration_test: + self.ensure_python_init_files(destination_path) + self.write_text(contents, file_destination) + self.console.print(f"Written integration test to {file_destination}") + case FileType.getatt_test: + self.write_text(contents, file_destination) + self.console.print(f"Written getatt tests to {file_destination}") + # case FileType.cloudcontrol_test: + # self.write_text(contents, file_destination) + # self.console.print(f"Written cloudcontrol tests to {file_destination}") + case FileType.parity_test: + self.write_text(contents, file_destination) + self.console.print(f"Written parity tests to {file_destination}") + + # templates + case FileType.attribute_template: + self.write_text(contents, file_destination) + self.console.print(f"Written attribute template to {file_destination}") + case FileType.minimal_template: + self.write_text(contents, file_destination) + self.console.print(f"Written minimal template to {file_destination}") + case FileType.update_without_replacement_template: + self.write_text(contents, file_destination) + self.console.print( + f"Written update without replacement template to {file_destination}" + ) + case FileType.autogenerated_template: + self.write_text(contents, file_destination) + self.console.print( + f"Written autogenerated properties template to {file_destination}" + ) + + # schema + case FileType.schema: + self.write_text(contents, file_destination) + self.console.print(f"Written schema to {file_destination}") + case _: + raise NotImplementedError(f"Writing {file_type}") + + def confirm_overwrite(self, destination_file: Path) -> bool: + """ + If a file we are about to write to exists, overwrite or ignore. + + :return True if file should be (over-)written, False otherwise + """ + return self.overwrite or click.confirm("Destination files already exist, overwrite?") + + @staticmethod + def write_text(contents: str, destination: Path): + with destination.open("wt") as outfile: + print(contents, file=outfile) + + @staticmethod + def ensure_python_init_files(path: Path): + """ + Make sure __init__.py files are created correctly + """ + project_root = path.parent.parent.parent.parent + path_relative_to_root = path.relative_to(project_root) + dir = project_root + for part in path_relative_to_root.parts: + dir = dir / part + test_path = dir.joinpath("__init__.py") + if not test_path.is_file(): + # touch file + with test_path.open("w"): + pass + + +class OutputFactory: + def __init__( + self, + template_renderer: TemplateRenderer, + printer: Console, + writer: FileWriter, + ): + self.template_renderer = template_renderer + self.printer = printer + self.writer = writer + + def get(self, file_type: FileType, resource_name: ResourceName) -> Output: + contents = self.template_renderer.render(file_type, resource_name) + return Output(contents, file_type, self.printer, self.writer, resource_name) + + +class Output: + def __init__( + self, + contents: str, + file_type: FileType, + printer: Console, + writer: FileWriter, + resource_name: ResourceName, + ): + self.contents = contents + self.file_type = file_type + self.printer = printer + self.writer = writer + self.resource_name = resource_name + + def handle(self, should_write: bool = False): + if should_write: + self.write() + else: + self.print() + + def write(self): + self.writer.write(self.file_type, self.contents) + + def print(self): + match self.file_type: + # service code + case FileType.provider: + self.printer.print("\n[underline]Provider template[/underline]\n") + self.printer.print(Syntax(self.contents, "python")) + case FileType.plugin: + self.printer.print("\n[underline]Plugin[/underline]\n") + self.printer.print(Syntax(self.contents, "python")) + # tests + case FileType.integration_test: + self.printer.print("\n[underline]Integration test file[/underline]\n") + self.printer.print(Syntax(self.contents, "python")) + case FileType.getatt_test: + self.printer.print("\n[underline]GetAtt test file[/underline]\n") + self.printer.print(Syntax(self.contents, "python")) + # case FileType.cloudcontrol_test: + # self.printer.print("\n[underline]CloudControl test[/underline]\n") + # self.printer.print(Syntax(self.contents, "python")) + case FileType.parity_test: + self.printer.print("\n[underline]Parity test[/underline]\n") + self.printer.print(Syntax(self.contents, "python")) + + # templates + case FileType.attribute_template: + self.printer.print("\n[underline]Attribute Test Template[/underline]\n") + self.printer.print(Syntax(self.contents, "yaml")) + case FileType.minimal_template: + self.printer.print("\n[underline]Minimal template[/underline]\n") + self.printer.print(Syntax(self.contents, "yaml")) + case FileType.update_without_replacement_template: + self.printer.print("\n[underline]Update test template[/underline]\n") + self.printer.print(Syntax(self.contents, "yaml")) + case FileType.autogenerated_template: + self.printer.print("\n[underline]Autogenerated properties template[/underline]\n") + self.printer.print(Syntax(self.contents, "yaml")) + + # schema + case FileType.schema: + self.printer.print("\n[underline]Schema[/underline]\n") + self.printer.print(Syntax(self.contents, "json")) + case _: + raise NotImplementedError(self.file_type) + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.option( + "-r", + "--resource-type", + required=True, + help="CloudFormation resource type (e.g. 'AWS::SSM::Parameter') to generate", +) +@click.option("-w", "--write/--no-write", default=False) +@click.option("--overwrite", is_flag=True, default=False) +@click.option("-t", "--write-tests/--no-write-tests", default=False) +@click.option("--pro", is_flag=True, default=False) +def generate( + resource_type: str, + write: bool, + write_tests: bool, + overwrite: bool, + pro: bool, +): + console = Console() + console.rule(title=resource_type) + + schema_provider = SchemaProvider( + zipfile_path=Path(__file__).parent.joinpath("CloudformationSchema.zip") + ) + + template_root = Path(__file__).parent.joinpath("templates") + env = Environment( + loader=FileSystemLoader(template_root), + ) + + parts = resource_type.rpartition("::") + if parts[-1] == "*": + # generate all resource types for that service + matching_resources = [x for x in schema_provider.schemas.keys() if x.startswith(parts[0])] + else: + matching_resources = [resource_type] + + for matching_resource in matching_resources: + console.rule(title=matching_resource) + resource_name = ResourceName.from_name(matching_resource) + schema = schema_provider.schema(resource_name) + + template_renderer = TemplateRenderer(schema, env, pro) + writer = FileWriter(resource_name, console, overwrite, pro) + output_factory = OutputFactory(template_renderer, console, writer) # noqa + for file_type in FileType: + if not write_tests and file_type in { + FileType.integration_test, + FileType.getatt_test, + FileType.parity_test, + FileType.minimal_template, + FileType.update_without_replacement_template, + FileType.attribute_template, + FileType.autogenerated_template, + }: + # skip test generation + continue + output_factory.get(file_type, resource_name).handle(should_write=write) + + console.rule(title="Resources & Instructions") + console.print( + "Resource types: https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-types.html" + ) + # TODO: print for every resource + for matching_resource in matching_resources: + resource_name = ResourceName.from_name(matching_resource) + console.print( + # lambda_ should become lambda (re-use the same list we use for generating the models) + f"{matching_resource}: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-{resource_name.service.lower()}-{resource_name.resource.lower()}.html" + ) + console.print("\nWondering where to get started?") + console.print( + "First run `make entrypoints` to make sure your resource provider plugin is actually registered." + ) + console.print( + 'Then start off by finalizing the generated minimal ("basic") template and get it to deploy against AWS.' + ) + + +if __name__ == "__main__": + cli() diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/propgen.py b/localstack-core/localstack/services/cloudformation/scaffolding/propgen.py new file mode 100644 index 0000000000000..6a7e90166b490 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/scaffolding/propgen.py @@ -0,0 +1,227 @@ +""" +Implementation of generating the types for a provider from the schema +""" + +from __future__ import annotations + +import logging +import textwrap +from dataclasses import dataclass +from typing import Optional, TypedDict + +LOG = logging.getLogger(__name__) + + +@dataclass +class Item: + """An Item is a single field definition""" + + name: str + type: str + required: bool + + def __str__(self) -> str: + return f"{self.name}: {self.type}" + + @classmethod + def new(cls, name: str, type: str, required: bool = False) -> Item: + if required: + return cls(name=name, type=type, required=required) + else: + return cls(name=name, type=f"Optional[{type}]", required=required) + + +@dataclass +class PrimitiveStruct: + name: str + primitive_type: str + + def __str__(self) -> str: + return f""" +{self.name} = {self.primitive_type} +""" + + +@dataclass +class Struct: + """A struct represents a single rendered class""" + + name: str + items: list[Item] + + def __str__(self) -> str: + if self.items: + raw_text = "\n".join(map(str, self.sorted_items)) + else: + raw_text = "pass" + formatted_items = textwrap.indent(raw_text, " ") + return f""" +class {self.name}(TypedDict): +{formatted_items} +""" + + @property + def sorted_items(self) -> list[Item]: + required_items = sorted( + [item for item in self.items if item.required], key=lambda item: item.name + ) + optional_items = sorted( + [item for item in self.items if not item.required], key=lambda item: item.name + ) + return required_items + optional_items + + +@dataclass +class IR: + structs: list[Struct] + + def __str__(self) -> str: + """ + Pretty print the IR + """ + return "\n\n".join(map(str, self.structs)) + + +class Schema(TypedDict): + properties: dict + definitions: dict + typeName: str + required: Optional[list[str]] + + +TYPE_MAP = { + "string": "str", + "boolean": "bool", + "integer": "int", + "number": "float", + "object": "dict", + "array": "list", +} + + +class PropertyTypeScaffolding: + resource_type: str + provider_prefix: str + schema: Schema + + structs: list[Struct] + + required_properties: list[str] + + def __init__(self, resource_type: str, provider_prefix: str, schema: Schema): + self.resource_type = resource_type + self.provider_prefix = provider_prefix + self.schema = schema + self.structs = [] + self.required_properties = schema.get("required", []) + + def get_structs(self) -> list[Struct]: + root_struct = Struct(f"{self.provider_prefix}Properties", items=[]) + self._add_struct(root_struct) + + for property_name, property_def in self.schema["properties"].items(): + is_required = property_name in self.required_properties + item = self.property_to_item(property_name, property_def, is_required) + root_struct.items.append(item) + + return self.structs + + def _add_struct(self, struct: Struct): + if struct.name in [s.name for s in self.structs]: + return + else: + self.structs.append(struct) + + def get_ref_definition(self, property_ref: str) -> dict: + property_ref_name = property_ref.lstrip("#").rpartition("/")[-1] + return self.schema["definitions"][property_ref_name] + + def resolve_type_of_property(self, property_def: dict) -> str: + if property_ref := property_def.get("$ref"): + ref_definition = self.get_ref_definition(property_ref) + ref_type = ref_definition.get("type") + if ref_type not in ["object", "array"]: + # in this case we simply flatten it (instead of for example creating a type alias) + resolved_type = TYPE_MAP.get(ref_type) + if resolved_type is None: + LOG.warning( + "Type for %s not found in the TYPE_MAP. Using `Any` as fallback.", ref_type + ) + resolved_type = "Any" + else: + if ref_type == "object": + # the object might only have a pattern defined and no actual properties + if "properties" not in ref_definition: + resolved_type = "dict" + else: + nested_struct = self.ref_to_struct(property_ref) + resolved_type = nested_struct.name + self._add_struct(nested_struct) + elif ref_type == "array": + item_def = ref_definition["items"] + item_type = self.resolve_type_of_property(item_def) + resolved_type = f"list[{item_type}]" + else: + raise Exception(f"Unknown property type encountered: {ref_type}") + else: + match property_type := property_def.get("type"): + # primitives + case "string": + resolved_type = "str" + case "boolean": + resolved_type = "bool" + case "integer": + resolved_type = "int" + case "number": + resolved_type = "float" + # complex objects + case "object": + resolved_type = "dict" # TODO: any cases where we need to continue here? + case "array": + try: + item_type = self.resolve_type_of_property(property_def["items"]) + resolved_type = f"list[{item_type}]" + except RecursionError: + resolved_type = "list[Any]" + case _: + # TODO: allOf, anyOf, patternProperties (?) + # AWS::ApiGateway::RestApi passes a ["object", "string"] here for the "Body" property + # it probably makes sense to assume this behaves the same as a "oneOf" + if one_of := property_def.get("oneOf"): + resolved_type = "|".join([self.resolve_type_of_property(o) for o in one_of]) + elif isinstance(property_type, list): + resolved_type = "|".join([TYPE_MAP[pt] for pt in property_type]) + else: + raise Exception(f"Unknown property type: {property_type}") + return resolved_type + + def property_to_item(self, property_name: str, property_def: dict, required: bool) -> Item: + resolved_type = self.resolve_type_of_property(property_def) + return Item(name=property_name, type=f"Optional[{resolved_type}]", required=required) + + def ref_to_struct(self, property_ref: str) -> Struct: + property_ref_name = property_ref.lstrip("#").rpartition("/")[-1] + resolved_def = self.schema["definitions"][property_ref_name] + nested_struct = Struct(name=property_ref_name, items=[]) + if resolved_properties := resolved_def.get("properties"): + required_props = resolved_def.get("required", []) + for k, v in resolved_properties.items(): + is_required = k in required_props + item = self.property_to_item(k, v, is_required) + nested_struct.items.append(item) + else: + raise Exception("Unknown resource format. Expected properties on object") + + return nested_struct + + +def generate_ir_for_type(schema: list[Schema], type_name: str, provider_prefix: str = "") -> IR: + try: + resource_schema = [every for every in schema if every["typeName"] == type_name][0] + except IndexError: + raise ValueError(f"could not find schema for type {type_name}") + + structs = PropertyTypeScaffolding( + resource_type=type_name, provider_prefix=provider_prefix, schema=resource_schema + ).get_structs() + return IR(structs=structs) diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/templates/plugin_template.py.j2 b/localstack-core/localstack/services/cloudformation/scaffolding/templates/plugin_template.py.j2 new file mode 100644 index 0000000000000..0a9a530cdfccc --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/scaffolding/templates/plugin_template.py.j2 @@ -0,0 +1,22 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ResourceProvider +{%- if pro %} +{%- set base_class = "CloudFormationResourceProviderPluginExt" %} +{%- set root_module = "localstack.pro.core" %} +{%- else %} +{%- set base_class = "CloudFormationResourceProviderPlugin" %} +{%- set root_module = "localstack" %} +{%- endif %} +from {{ root_module }}.services.cloudformation.resource_provider import {{ base_class }} + +class {{ resource }}ProviderPlugin({{ base_class }}): + name = "{{ name }}" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from {{ root_module }}.services.{{ service }}.resource_providers.aws_{{ service }}_{{ lower_resource }} import {{ resource }}Provider + + self.factory = {{ resource }}Provider diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/templates/provider_template.py.j2 b/localstack-core/localstack/services/cloudformation/scaffolding/templates/provider_template.py.j2 new file mode 100644 index 0000000000000..3d52dbd6b7a83 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/scaffolding/templates/provider_template.py.j2 @@ -0,0 +1,138 @@ +# LocalStack Resource Provider Scaffolding {{ scaffolding_version }} +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + +{{ provider_properties }} + + +REPEATED_INVOCATION = "repeated_invocation" + +class {{ resource }}Provider(ResourceProvider[{{ resource }}Properties]): + + TYPE = "{{ name }}" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[{{ resource }}Properties], + ) -> ProgressEvent[{{ resource }}Properties]: + """ + Create a new resource. + + {% if primary_identifier -%} + Primary identifier fields: + {%- for property in primary_identifier %} + - {{ property }} + {%- endfor %} + {%- endif %} + + {% if required_properties -%} + Required properties: + {%- for property in required_properties %} + - {{ property }} + {%- endfor %} + {%- endif %} + + {% if create_only_properties -%} + Create-only properties: + {%- for property in create_only_properties %} + - {{ property }} + {%- endfor %} + {%- endif %} + + {% if read_only_properties -%} + Read-only properties: + {%- for property in read_only_properties %} + - {{ property }} + {%- endfor %} + {%- endif %} + + {% if create_permissions -%} + IAM permissions required: + {%- for permission in create_permissions %} + - {{ permission }} + {%- endfor -%} + {%- endif %} + + """ + model = request.desired_state + + # TODO: validations + + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + # TODO: defaults + # TODO: idempotency + # TODO: actually create the resource + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + # TODO: check the status of the resource + # - if finished, update the model with all fields and return success event: + # return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + # - else + # return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + + raise NotImplementedError + + def read( + self, + request: ResourceRequest[{{ resource }}Properties], + ) -> ProgressEvent[{{ resource }}Properties]: + """ + Fetch resource information + + {% if read_permissions -%} + IAM permissions required: + {%- for permission in read_permissions %} + - {{ permission }} + {%- endfor %} + {%- endif %} + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[{{ resource }}Properties], + ) -> ProgressEvent[{{ resource }}Properties]: + """ + Delete a resource + + {% if delete_permissions -%} + IAM permissions required: + {%- for permission in delete_permissions %} + - {{ permission }} + {%- endfor %} + {%- endif %} + """ + raise NotImplementedError + + def update( + self, + request: ResourceRequest[{{ resource }}Properties], + ) -> ProgressEvent[{{ resource }}Properties]: + """ + Update a resource + + {% if update_permissions -%} + IAM permissions required: + {%- for permission in update_permissions %} + - {{ permission }} + {%- endfor %} + {%- endif %} + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_getatt_template.py.j2 b/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_getatt_template.py.j2 new file mode 100644 index 0000000000000..24f59945903b5 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_getatt_template.py.j2 @@ -0,0 +1,41 @@ +# LocalStack Resource Provider Scaffolding {{ scaffolding_version }} +import os + +import pytest + +from localstack.testing.aws.util import is_aws_cloud + + +RESOURCE_GETATT_TARGETS = {{getatt_targets}} + + +class TestAttributeAccess: + @pytest.mark.parametrize("attribute", RESOURCE_GETATT_TARGETS) + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Exploratory test only") + def test_getatt( + self, + aws_client, + deploy_cfn_template, + attribute, + snapshot, + ): + """ + Use this test to find out which properties support GetAtt access + + Fn::GetAtt documentation: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html + """ + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "{{ template_path }}", + ), + parameters={"AttributeName": attribute}, + ) + snapshot.match("stack_outputs", stack.outputs) + + # check physical resource id + res = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="MyResource" + )["StackResourceDetail"] + snapshot.match("physical_resource_id", res.get("PhysicalResourceId")) diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_integration_template.py.j2 b/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_integration_template.py.j2 new file mode 100644 index 0000000000000..98bd596be3b89 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_integration_template.py.j2 @@ -0,0 +1,93 @@ +# LocalStack Resource Provider Scaffolding {{ scaffolding_version }} +import os + +import pytest +# from botocore.exceptions import ClientError + + +class TestBasicCRD: + + def test_black_box(self, deploy_cfn_template, aws_client, snapshot): + """ + Simple test that + - deploys a stack containing the resource + - verifies that the resource has been created correctly by querying the service directly + - deletes the stack ensuring that the delete operation has been implemented correctly + - verifies that the resource no longer exists by querying the service directly + """ + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "{{ black_box_template_path }}", + ), + ) + snapshot.match("stack-outputs", stack.outputs) + + # TODO: fetch the resource and perform any required validations here + # e.g. + # parameter_name = stack.outputs["MyRef"] + # snapshot.add_transformer(snapshot.transform.regex(parameter_name, "")) + + # res = aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + # - this snapshot also asserts that the value set in the template is correct + # snapshot.match("describe-resource", res) + + # verify that the delete operation works + stack.destroy() + + # TODO: fetch the resource again and assert that it no longer exists + # e.g. + # with pytest.raises(ClientError): + # aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + + def test_autogenerated_values(self, aws_client, deploy_cfn_template, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "{{ autogenerated_template_path }}", + ), + ) + snapshot.match("stack_outputs", stack.outputs) + + # user_name = stack.outputs["MyRef"] + + # verify resource has been correctly deployed with the autogenerated field + # e.g. aws_client.iam.get_user(UserName=user_name) + + # check the auto-generated pattern + # TODO: add a sample of the auto-generated value here for reference, e.g. "TestStack-CustomUser-13AA838" + + +class TestUpdates: + @pytest.mark.skip(reason="TODO") + def test_update_without_replacement(self, deploy_cfn_template, aws_client, snapshot): + """ + Test an UPDATE of a simple property that does not require replacing the entire resource. + Check out the official resource documentation at https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html to see if a property needs replacement + """ + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "{{ update_template_path }}", + ), + parameters={"AttributeValue": "first"}, + ) + + # TODO: implement fetching the resource and performing any required validations here + res = aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + snapshot.match("describe-resource-before-update", res) + + # TODO: update the stack + deploy_cfn_template( + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), + "{{ update_template_path }}", + ), + parameters={"AttributeValue": "second"}, + is_update=True, + ) + + # TODO: check the value has changed + res = aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + snapshot.match("describe-resource-after-update", res) diff --git a/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_parity_template.py.j2 b/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_parity_template.py.j2 new file mode 100644 index 0000000000000..6cf269aa392db --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/scaffolding/templates/test_parity_template.py.j2 @@ -0,0 +1,30 @@ +# ruff: noqa +# LocalStack Resource Provider Scaffolding {{ scaffolding_version }} + +import pytest + + +@pytest.mark.skip(reason="TODO") +class TestParity: + """ + Pro-active parity-focused tests that go into more detailed than the basic test skeleton + + TODO: add more focused detailed tests for updates, different combinations, etc. + Use snapshots here to capture detailed parity with AWS + + Other ideas for tests in here: + - Negative test: invalid combination of properties + - Negative test: missing required properties + """ + + def test_create_with_full_properties(self, aws_client, deploy_cfn_template): + """ A sort of smoke test that simply covers as many properties as possible """ + ... + + + + +@pytest.mark.skip(reason="TODO") +class TestSamples: + """ User-provided samples and other reactively added scenarios (e.g. reported and reproduced GitHub issues) """ + ... diff --git a/localstack-core/localstack/services/cloudformation/service_models.py b/localstack-core/localstack/services/cloudformation/service_models.py new file mode 100644 index 0000000000000..aeadbeb85f305 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/service_models.py @@ -0,0 +1,128 @@ +import logging +from typing import TypedDict + +from localstack.services.cloudformation.deployment_utils import check_not_found_exception + +LOG = logging.getLogger(__name__) + +# dict key used to store the deployment state of a resource +KEY_RESOURCE_STATE = "_state_" + + +class DependencyNotYetSatisfied(Exception): + """Exception indicating that a resource dependency is not (yet) deployed/available.""" + + def __init__(self, resource_ids, message=None): + message = message or "Unresolved dependencies: %s" % resource_ids + super(DependencyNotYetSatisfied, self).__init__(message) + resource_ids = resource_ids if isinstance(resource_ids, list) else [resource_ids] + self.resource_ids = resource_ids + + +class ResourceJson(TypedDict): + Type: str + Properties: dict + + +class GenericBaseModel: + """Abstract base class representing a resource model class in LocalStack. + This class keeps references to a combination of (1) the CF resource + properties (as defined in the template), and (2) the current deployment + state of a resource. + + Concrete subclasses will implement convenience methods to manage resources, + e.g., fetching the latest deployment state, getting the resource name, etc. + """ + + def __init__(self, account_id: str, region_name: str, resource_json: dict, **params): + # self.stack_name = stack_name # TODO: add stack name to params + self.account_id = account_id + self.region_name = region_name + self.resource_json = resource_json + self.resource_type = resource_json["Type"] + # Properties, as defined in the resource template + self.properties = resource_json["Properties"] = resource_json.get("Properties") or {} + # State, as determined from the deployed resource; use a special dict key here to keep + # track of state changes within resource_json (this way we encapsulate all state details + # in `resource_json` and the changes will survive creation of multiple instances of this class) + self.state = resource_json[KEY_RESOURCE_STATE] = resource_json.get(KEY_RESOURCE_STATE) or {} + + # ---------------------- + # ABSTRACT BASE METHODS + # ---------------------- + + def fetch_state(self, stack_name, resources): + """Fetch the latest deployment state of this resource, or return None if not currently deployed (NOTE: THIS IS NOT ALWAYS TRUE).""" + return None + + def update_resource(self, new_resource, stack_name, resources): + """Update the deployment of this resource, using the updated properties (implemented by subclasses).""" + raise NotImplementedError + + def is_updatable(self) -> bool: + return type(self).update_resource != GenericBaseModel.update_resource + + @classmethod + def cloudformation_type(cls): + """Return the CloudFormation resource type name, e.g., "AWS::S3::Bucket" (implemented by subclasses).""" + pass + + @staticmethod + def get_deploy_templates(): + """Return template configurations used to create the final API requests (implemented by subclasses).""" + pass + + # TODO: rework to normal instance method when resources aren't mutated in different place anymore + @staticmethod + def add_defaults(resource, stack_name: str): + """Set any defaults required, including auto-generating names. Must be called before deploying the resource""" + pass + + # --------------------- + # GENERIC UTIL METHODS + # --------------------- + + # TODO: remove + def fetch_and_update_state(self, *args, **kwargs): + if self.physical_resource_id is None: + return None + + try: + state = self.fetch_state(*args, **kwargs) + self.update_state(state) + return state + except Exception as e: + if not check_not_found_exception(e, self.resource_type, self.properties): + LOG.warning( + "Unable to fetch state for resource %s: %s", + self, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + # TODO: remove + def update_state(self, details): + """Update the deployment state of this resource (existing attributes will be overwritten).""" + details = details or {} + self.state.update(details) + + @property + def physical_resource_id(self) -> str | None: + """Return the (cached) physical resource ID.""" + return self.resource_json.get("PhysicalResourceId") + + @property + def logical_resource_id(self) -> str: + """Return the logical resource ID.""" + return self.resource_json["LogicalResourceId"] + + # TODO: rename? make it clearer what props are in comparison with state, properties and resource_json + @property + def props(self) -> dict: + """Return a copy of (1) the resource properties (from the template), combined with + (2) the current deployment state properties of the resource.""" + result = dict(self.properties) + result.update(self.state or {}) + last_state = self.resource_json.get("_last_deployed_state", {}) + result.update(last_state) + return result diff --git a/localstack-core/localstack/services/cloudformation/stores.py b/localstack-core/localstack/services/cloudformation/stores.py new file mode 100644 index 0000000000000..7191f5491b4e1 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/stores.py @@ -0,0 +1,134 @@ +import logging +from typing import Optional + +from localstack.aws.api.cloudformation import StackStatus +from localstack.services.cloudformation.engine.entities import Stack, StackChangeSet, StackSet +from localstack.services.cloudformation.v2.entities import ChangeSet as ChangeSetV2 +from localstack.services.cloudformation.v2.entities import Stack as StackV2 +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + +LOG = logging.getLogger(__name__) + + +class CloudFormationStore(BaseStore): + # maps stack ID to stack details + stacks: dict[str, Stack] = LocalAttribute(default=dict) + stacks_v2: dict[str, StackV2] = LocalAttribute(default=dict) + + change_sets: dict[str, ChangeSetV2] = LocalAttribute(default=dict) + + # maps stack set ID to stack set details + stack_sets: dict[str, StackSet] = LocalAttribute(default=dict) + + # maps macro ID to macros + macros: dict[str, dict] = LocalAttribute(default=dict) + + # exports: dict[str, str] + @property + def exports(self): + exports = [] + output_keys = {} + for stack_id, stack in self.stacks.items(): + for output in stack.resolved_outputs: + export_name = output.get("ExportName") + if not export_name: + continue + if export_name in output_keys: + # TODO: raise exception on stack creation in case of duplicate exports + LOG.warning( + "Found duplicate export name %s in stacks: %s %s", + export_name, + output_keys[export_name], + stack.stack_id, + ) + entry = { + "ExportingStackId": stack.stack_id, + "Name": export_name, + "Value": output["OutputValue"], + } + exports.append(entry) + output_keys[export_name] = stack.stack_id + return exports + + +cloudformation_stores = AccountRegionBundle("cloudformation", CloudFormationStore) + + +def get_cloudformation_store(account_id: str, region_name: str) -> CloudFormationStore: + return cloudformation_stores[account_id][region_name] + + +# TODO: rework / fix usage of this +def find_stack(account_id: str, region_name: str, stack_name: str) -> Stack | None: + # Warning: This function may not return the correct stack if multiple stacks with same name exist. + state = get_cloudformation_store(account_id, region_name) + return ( + [s for s in state.stacks.values() if stack_name in [s.stack_name, s.stack_id]] or [None] + )[0] + + +def find_stack_by_id(account_id: str, region_name: str, stack_id: str) -> Stack | None: + """ + Find the stack by id. + + :param account_id: account of the stack + :param region_name: region of the stack + :param stack_id: stack id + :return: Stack if it is found, None otherwise + """ + state = get_cloudformation_store(account_id, region_name) + for stack in state.stacks.values(): + # there can only be one stack with an id + if stack_id == stack.stack_id: + return stack + return None + + +def find_active_stack_by_name_or_id( + account_id: str, region_name: str, stack_name_or_id: str +) -> Stack | None: + """ + Find the active stack by name. Some cloudformation operations only allow referencing by slack name if the stack is + "active", which we currently interpret as not DELETE_COMPLETE. + + :param account_id: account of the stack + :param region_name: region of the stack + :param stack_name_or_id: stack name or stack id + :return: Stack if it is found, None otherwise + """ + state = get_cloudformation_store(account_id, region_name) + for stack in state.stacks.values(): + # there can only be one stack where this condition is true for each region + # as there can only be one active stack with a given name + if ( + stack_name_or_id in [stack.stack_name, stack.stack_id] + and stack.status != "DELETE_COMPLETE" + ): + return stack + return None + + +def find_change_set( + account_id: str, + region_name: str, + cs_name: str, + stack_name: Optional[str] = None, + active_only: bool = False, +) -> Optional[StackChangeSet]: + store = get_cloudformation_store(account_id, region_name) + for stack in store.stacks.values(): + if active_only and stack.status == StackStatus.DELETE_COMPLETE: + continue + if stack_name in (stack.stack_name, stack.stack_id, None): + for change_set in stack.change_sets: + if cs_name in (change_set.change_set_id, change_set.change_set_name): + return change_set + return None + + +def exports_map(account_id: str, region_name: str): + result = {} + store = get_cloudformation_store(account_id, region_name) + for export in store.exports: + result[export["Name"]] = export + return result diff --git a/localstack-core/localstack/services/cloudformation/v2/__init__.py b/localstack-core/localstack/services/cloudformation/v2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cloudformation/v2/entities.py b/localstack-core/localstack/services/cloudformation/v2/entities.py new file mode 100644 index 0000000000000..0d44ae1276ade --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/v2/entities.py @@ -0,0 +1,249 @@ +from datetime import datetime, timezone +from typing import NotRequired, Optional, TypedDict + +from localstack.aws.api.cloudformation import ( + ChangeSetStatus, + ChangeSetType, + CreateChangeSetInput, + CreateStackInput, + ExecutionStatus, + Output, + Parameter, + ResourceStatus, + StackDriftInformation, + StackDriftStatus, + StackEvent, + StackResource, + StackStatus, + StackStatusReason, +) +from localstack.aws.api.cloudformation import ( + Stack as ApiStack, +) +from localstack.services.cloudformation.engine.entities import ( + StackIdentifier, +) +from localstack.services.cloudformation.engine.v2.change_set_model import ( + UpdateModel, +) +from localstack.utils.aws import arns +from localstack.utils.strings import long_uid, short_uid + + +class ResolvedResource(TypedDict): + Type: str + Properties: dict + + +class Stack: + stack_name: str + parameters: list[Parameter] + change_set_id: str | None + status: StackStatus + status_reason: StackStatusReason | None + stack_id: str + creation_time: datetime + deletion_time: datetime | None + events = list[StackEvent] + + # state after deploy + resolved_parameters: dict[str, str] + resolved_resources: dict[str, ResolvedResource] + resolved_outputs: dict[str, str] + resource_states: dict[str, StackResource] + + def __init__( + self, + account_id: str, + region_name: str, + request_payload: CreateChangeSetInput | CreateStackInput, + template: dict | None = None, + template_body: str | None = None, + ): + self.account_id = account_id + self.region_name = region_name + self.template = template + self.template_body = template_body + self.status = StackStatus.CREATE_IN_PROGRESS + self.status_reason = None + self.change_set_ids = [] + self.creation_time = datetime.now(tz=timezone.utc) + self.deletion_time = None + self.change_set_id = None + + self.stack_name = request_payload["StackName"] + self.parameters = request_payload.get("Parameters", []) + self.stack_id = arns.cloudformation_stack_arn( + self.stack_name, + stack_id=StackIdentifier( + account_id=self.account_id, region=self.region_name, stack_name=self.stack_name + ).generate(tags=request_payload.get("Tags")), + account_id=self.account_id, + region_name=self.region_name, + ) + + # TODO: only kept for v1 compatibility + self.request_payload = request_payload + + # state after deploy + self.resolved_parameters = {} + self.resolved_resources = {} + self.resolved_outputs = {} + self.resource_states = {} + self.events = [] + + def set_stack_status(self, status: StackStatus, reason: StackStatusReason | None = None): + self.status = status + if reason: + self.status_reason = reason + + self._store_event(self.stack_name, self.stack_id, status.value, status_reason=reason) + + def set_resource_status( + self, + *, + logical_resource_id: str, + physical_resource_id: str | None, + resource_type: str, + status: ResourceStatus, + resource_status_reason: str | None = None, + ): + resource_description = StackResource( + StackName=self.stack_name, + StackId=self.stack_id, + LogicalResourceId=logical_resource_id, + PhysicalResourceId=physical_resource_id, + ResourceType=resource_type, + Timestamp=datetime.now(tz=timezone.utc), + ResourceStatus=status, + ResourceStatusReason=resource_status_reason, + ) + + if not resource_status_reason: + resource_description.pop("ResourceStatusReason") + + self.resource_states[logical_resource_id] = resource_description + self._store_event(logical_resource_id, physical_resource_id, status, resource_status_reason) + + def _store_event( + self, + resource_id: str = None, + physical_res_id: str = None, + status: str = "", + status_reason: str = "", + ): + resource_id = resource_id + physical_res_id = physical_res_id + resource_type = ( + self.template.get("Resources", {}) + .get(resource_id, {}) + .get("Type", "AWS::CloudFormation::Stack") + ) + + event: StackEvent = { + "EventId": long_uid(), + "Timestamp": datetime.now(tz=timezone.utc), + "StackId": self.stack_id, + "StackName": self.stack_name, + "LogicalResourceId": resource_id, + "PhysicalResourceId": physical_res_id, + "ResourceStatus": status, + "ResourceType": resource_type, + } + + if status_reason: + event["ResourceStatusReason"] = status_reason + + self.events.insert(0, event) + + def describe_details(self) -> ApiStack: + result = { + "CreationTime": self.creation_time, + "DeletionTime": self.deletion_time, + "StackId": self.stack_id, + "StackName": self.stack_name, + "StackStatus": self.status, + "StackStatusReason": self.status_reason, + # fake values + "DisableRollback": False, + "DriftInformation": StackDriftInformation( + StackDriftStatus=StackDriftStatus.NOT_CHECKED + ), + "EnableTerminationProtection": False, + "LastUpdatedTime": self.creation_time, + "RollbackConfiguration": {}, + "Tags": [], + } + if change_set_id := self.change_set_id: + result["ChangeSetId"] = change_set_id + + if self.resolved_outputs: + describe_outputs = [] + for key, value in self.resolved_outputs.items(): + describe_outputs.append( + Output( + # TODO(parity): Description, ExportName + # TODO(parity): what happens on describe stack when the stack has not been deployed yet? + OutputKey=key, + OutputValue=value, + ) + ) + result["Outputs"] = describe_outputs + return result + + def is_active(self) -> bool: + return self.status != StackStatus.DELETE_COMPLETE + + +class ChangeSetRequestPayload(TypedDict, total=False): + ChangeSetName: str + ChangeSetType: NotRequired[ChangeSetType] + + +class ChangeSet: + change_set_name: str + change_set_id: str + change_set_type: ChangeSetType + update_model: Optional[UpdateModel] + status: ChangeSetStatus + execution_status: ExecutionStatus + creation_time: datetime + + def __init__( + self, + stack: Stack, + request_payload: ChangeSetRequestPayload, + template: dict | None = None, + ): + self.stack = stack + self.template = template + self.status = ChangeSetStatus.CREATE_IN_PROGRESS + self.execution_status = ExecutionStatus.AVAILABLE + self.update_model = None + self.creation_time = datetime.now(tz=timezone.utc) + + self.change_set_name = request_payload["ChangeSetName"] + self.change_set_type = request_payload.get("ChangeSetType", ChangeSetType.UPDATE) + self.change_set_id = arns.cloudformation_change_set_arn( + self.change_set_name, + change_set_id=short_uid(), + account_id=self.stack.account_id, + region_name=self.stack.region_name, + ) + + def set_update_model(self, update_model: UpdateModel) -> None: + self.update_model = update_model + + def set_change_set_status(self, status: ChangeSetStatus): + self.status = status + + def set_execution_status(self, execution_status: ExecutionStatus): + self.execution_status = execution_status + + @property + def account_id(self) -> str: + return self.stack.account_id + + @property + def region_name(self) -> str: + return self.stack.region_name diff --git a/localstack-core/localstack/services/cloudformation/v2/provider.py b/localstack-core/localstack/services/cloudformation/v2/provider.py new file mode 100644 index 0000000000000..5e96a743780b0 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/v2/provider.py @@ -0,0 +1,823 @@ +import copy +import logging +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Optional + +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.cloudformation import ( + Changes, + ChangeSetNameOrId, + ChangeSetNotFoundException, + ChangeSetStatus, + ChangeSetType, + ClientRequestToken, + CreateChangeSetInput, + CreateChangeSetOutput, + CreateStackInput, + CreateStackOutput, + DeletionMode, + DescribeChangeSetOutput, + DescribeStackEventsOutput, + DescribeStackResourcesOutput, + DescribeStacksOutput, + DisableRollback, + ExecuteChangeSetOutput, + ExecutionStatus, + GetTemplateSummaryInput, + GetTemplateSummaryOutput, + IncludePropertyValues, + InvalidChangeSetStatusException, + LogicalResourceId, + NextToken, + Parameter, + PhysicalResourceId, + RetainExceptOnCreate, + RetainResources, + RoleARN, + RollbackConfiguration, + StackName, + StackNameOrId, + StackStatus, + UpdateStackInput, + UpdateStackOutput, +) +from localstack.services.cloudformation import api_utils +from localstack.services.cloudformation.engine import template_preparer +from localstack.services.cloudformation.engine.v2.change_set_model import ( + ChangeSetModel, + ChangeType, + UpdateModel, +) +from localstack.services.cloudformation.engine.v2.change_set_model_describer import ( + ChangeSetModelDescriber, +) +from localstack.services.cloudformation.engine.v2.change_set_model_executor import ( + ChangeSetModelExecutor, +) +from localstack.services.cloudformation.engine.v2.change_set_model_transform import ( + ChangeSetModelTransform, +) +from localstack.services.cloudformation.engine.validations import ValidationError +from localstack.services.cloudformation.provider import ( + ARN_CHANGESET_REGEX, + ARN_STACK_REGEX, + CloudformationProvider, +) +from localstack.services.cloudformation.stores import ( + CloudFormationStore, + get_cloudformation_store, +) +from localstack.services.cloudformation.v2.entities import ChangeSet, Stack +from localstack.utils.threads import start_worker_thread + +LOG = logging.getLogger(__name__) + + +def is_stack_arn(stack_name_or_id: str) -> bool: + return ARN_STACK_REGEX.match(stack_name_or_id) is not None + + +def is_changeset_arn(change_set_name_or_id: str) -> bool: + return ARN_CHANGESET_REGEX.match(change_set_name_or_id) is not None + + +class StackNotFoundError(ValidationError): + def __init__(self, stack_name: str): + super().__init__(f"Stack with id {stack_name} does not exist") + + +def find_stack_v2(state: CloudFormationStore, stack_name: str | None) -> Stack | None: + if stack_name: + if is_stack_arn(stack_name): + return state.stacks_v2[stack_name] + else: + stack_candidates = [] + for stack in state.stacks_v2.values(): + if stack.stack_name == stack_name and stack.status != StackStatus.DELETE_COMPLETE: + stack_candidates.append(stack) + if len(stack_candidates) == 0: + return None + elif len(stack_candidates) > 1: + raise RuntimeError("Programing error, duplicate stacks found") + else: + return stack_candidates[0] + else: + raise NotImplementedError + + +def find_change_set_v2( + state: CloudFormationStore, change_set_name: str, stack_name: str | None = None +) -> ChangeSet | None: + if is_changeset_arn(change_set_name): + return state.change_sets[change_set_name] + else: + if stack_name is not None: + stack = find_stack_v2(state, stack_name) + if not stack: + raise StackNotFoundError(stack_name) + + for change_set_id in stack.change_set_ids: + change_set_candidate = state.change_sets[change_set_id] + if change_set_candidate.change_set_name == change_set_name: + return change_set_candidate + else: + raise NotImplementedError + + +class CloudformationProviderV2(CloudformationProvider): + @staticmethod + def _setup_change_set_model( + change_set: ChangeSet, + before_template: Optional[dict], + after_template: Optional[dict], + before_parameters: Optional[dict], + after_parameters: Optional[dict], + previous_update_model: Optional[UpdateModel], + ): + # Create and preprocess the update graph for this template update. + change_set_model = ChangeSetModel( + before_template=before_template, + after_template=after_template, + before_parameters=before_parameters, + after_parameters=after_parameters, + ) + raw_update_model: UpdateModel = change_set_model.get_update_model() + # If there exists an update model which operated in the 'before' version of this change set, + # port the runtime values computed for the before version into this latest update model. + if previous_update_model: + raw_update_model.before_runtime_cache.clear() + raw_update_model.before_runtime_cache.update(previous_update_model.after_runtime_cache) + change_set.set_update_model(raw_update_model) + + # Apply global transforms. + # TODO: skip this process iff both versions of the template don't specify transform blocks. + change_set_model_transform = ChangeSetModelTransform( + change_set=change_set, + before_parameters=before_parameters, + after_parameters=after_parameters, + before_template=before_template, + after_template=after_template, + ) + transformed_before_template, transformed_after_template = ( + change_set_model_transform.transform() + ) + + # Remodel the update graph after the applying the global transforms. + change_set_model = ChangeSetModel( + before_template=transformed_before_template, + after_template=transformed_after_template, + before_parameters=before_parameters, + after_parameters=after_parameters, + ) + update_model = change_set_model.get_update_model() + # Bring the cache for the previous operations forward in the update graph for this version + # of the templates. This enables downstream update graph visitors to access runtime + # information computed whilst evaluating the previous version of this template, and during + # the transformations. + update_model.before_runtime_cache.update(raw_update_model.before_runtime_cache) + update_model.after_runtime_cache.update(raw_update_model.after_runtime_cache) + change_set.set_update_model(update_model) + + @handler("CreateChangeSet", expand=False) + def create_change_set( + self, context: RequestContext, request: CreateChangeSetInput + ) -> CreateChangeSetOutput: + try: + stack_name = request["StackName"] + except KeyError: + # TODO: proper exception + raise ValidationError("StackName must be specified") + try: + change_set_name = request["ChangeSetName"] + except KeyError: + # TODO: proper exception + raise ValidationError("StackName must be specified") + + state = get_cloudformation_store(context.account_id, context.region) + + change_set_type = request.get("ChangeSetType", "UPDATE") + template_body = request.get("TemplateBody") + # s3 or secretsmanager url + template_url = request.get("TemplateURL") + + # validate and resolve template + if template_body and template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + if not template_body and not template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + template_body = api_utils.extract_template_body(request) + structured_template = template_preparer.parse_template(template_body) + + # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing + # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet) + if is_stack_arn(stack_name): + stack = state.stacks_v2.get(stack_name) + if not stack: + raise ValidationError(f"Stack '{stack_name}' does not exist.") + else: + # stack name specified, so fetch the stack by name + stack_candidates: list[Stack] = [ + s for stack_arn, s in state.stacks_v2.items() if s.stack_name == stack_name + ] + active_stack_candidates = [s for s in stack_candidates if s.is_active()] + + # on a CREATE an empty Stack should be generated if we didn't find an active one + if not active_stack_candidates and change_set_type == ChangeSetType.CREATE: + stack = Stack( + account_id=context.account_id, + region_name=context.region, + request_payload=request, + template=structured_template, + template_body=template_body, + ) + state.stacks_v2[stack.stack_id] = stack + else: + if not active_stack_candidates: + raise ValidationError(f"Stack '{stack_name}' does not exist.") + stack = active_stack_candidates[0] + + if stack.status in [StackStatus.CREATE_COMPLETE, StackStatus.UPDATE_COMPLETE]: + stack.set_stack_status(StackStatus.UPDATE_IN_PROGRESS) + else: + stack.set_stack_status(StackStatus.REVIEW_IN_PROGRESS) + + # TODO: test if rollback status is allowed as well + if ( + change_set_type == ChangeSetType.CREATE + and stack.status != StackStatus.REVIEW_IN_PROGRESS + ): + raise ValidationError( + f"Stack [{stack_name}] already exists and cannot be created again with the changeSet [{change_set_name}]." + ) + + before_parameters: dict[str, Parameter] | None = None + match change_set_type: + case ChangeSetType.UPDATE: + before_parameters = stack.resolved_parameters + # add changeset to existing stack + # old_parameters = { + # k: mask_no_echo(strip_parameter_type(v)) + # for k, v in stack.resolved_parameters.items() + # } + case ChangeSetType.IMPORT: + raise NotImplementedError() # TODO: implement importing resources + case ChangeSetType.CREATE: + pass + case _: + msg = ( + f"1 validation error detected: Value '{change_set_type}' at 'changeSetType' failed to satisfy " + f"constraint: Member must satisfy enum value set: [IMPORT, UPDATE, CREATE] " + ) + raise ValidationError(msg) + + # TODO: reconsider the way parameters are modelled in the update graph process. + # The options might be reduce to using the current style, or passing the extra information + # as a metadata object. The choice should be made considering when the extra information + # is needed for the update graph building, or only looked up in downstream tasks (metadata). + request_parameters = request.get("Parameters", list()) + # TODO: handle parameter defaults and resolution + after_parameters: dict[str, Any] = { + parameter["ParameterKey"]: parameter["ParameterValue"] + for parameter in request_parameters + } + + # TODO: update this logic to always pass the clean template object if one exists. The + # current issue with relaying on stack.template_original is that this appears to have + # its parameters and conditions populated. + before_template = None + if change_set_type == ChangeSetType.UPDATE: + before_template = stack.template + after_template = structured_template + + previous_update_model = None + try: + # FIXME: 'change_set_id' for 'stack' objects is dynamically attributed + if previous_change_set := find_change_set_v2(state, stack.change_set_id): + previous_update_model = previous_change_set.update_model + except Exception: + # No change set available on this stack. + pass + + # create change set for the stack and apply changes + change_set = ChangeSet(stack, request, template=after_template) + self._setup_change_set_model( + change_set=change_set, + before_template=before_template, + after_template=after_template, + before_parameters=before_parameters, + after_parameters=after_parameters, + previous_update_model=previous_update_model, + ) + + change_set.set_change_set_status(ChangeSetStatus.CREATE_COMPLETE) + stack.change_set_id = change_set.change_set_id + stack.change_set_ids.append(change_set.change_set_id) + state.change_sets[change_set.change_set_id] = change_set + + return CreateChangeSetOutput(StackId=stack.stack_id, Id=change_set.change_set_id) + + @handler("ExecuteChangeSet") + def execute_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId | None = None, + client_request_token: ClientRequestToken | None = None, + disable_rollback: DisableRollback | None = None, + retain_except_on_create: RetainExceptOnCreate | None = None, + **kwargs, + ) -> ExecuteChangeSetOutput: + state = get_cloudformation_store(context.account_id, context.region) + + change_set = find_change_set_v2(state, change_set_name, stack_name) + if not change_set: + raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist") + + if change_set.execution_status != ExecutionStatus.AVAILABLE: + LOG.debug("Change set %s not in execution status 'AVAILABLE'", change_set_name) + raise InvalidChangeSetStatusException( + f"ChangeSet [{change_set.change_set_id}] cannot be executed in its current status of [{change_set.status}]" + ) + # LOG.debug( + # 'Executing change set "%s" for stack "%s" with %s resources ...', + # change_set_name, + # stack_name, + # len(change_set.template_resources), + # ) + if not change_set.update_model: + raise RuntimeError("Programming error: no update graph found for change set") + + change_set.set_execution_status(ExecutionStatus.EXECUTE_IN_PROGRESS) + change_set.stack.set_stack_status( + StackStatus.UPDATE_IN_PROGRESS + if change_set.change_set_type == ChangeSetType.UPDATE + else StackStatus.CREATE_IN_PROGRESS + ) + + change_set_executor = ChangeSetModelExecutor( + change_set, + ) + + def _run(*args): + try: + result = change_set_executor.execute() + new_stack_status = StackStatus.UPDATE_COMPLETE + if change_set.change_set_type == ChangeSetType.CREATE: + new_stack_status = StackStatus.CREATE_COMPLETE + change_set.stack.set_stack_status(new_stack_status) + change_set.set_execution_status(ExecutionStatus.EXECUTE_COMPLETE) + change_set.stack.resolved_resources = result.resources + change_set.stack.resolved_parameters = result.parameters + change_set.stack.resolved_outputs = result.outputs + # if the deployment succeeded, update the stack's template representation to that + # which was just deployed + change_set.stack.template = change_set.template + except Exception as e: + LOG.error( + "Execute change set failed: %s", e, exc_info=LOG.isEnabledFor(logging.WARNING) + ) + new_stack_status = StackStatus.UPDATE_FAILED + if change_set.change_set_type == ChangeSetType.CREATE: + new_stack_status = StackStatus.CREATE_FAILED + + change_set.stack.set_stack_status(new_stack_status) + change_set.set_execution_status(ExecutionStatus.EXECUTE_FAILED) + + start_worker_thread(_run) + + return ExecuteChangeSetOutput() + + def _describe_change_set( + self, change_set: ChangeSet, include_property_values: bool + ) -> DescribeChangeSetOutput: + # TODO: The ChangeSetModelDescriber currently matches AWS behavior by listing + # resource changes in the order they appear in the template. However, when + # a resource change is triggered indirectly (e.g., via Ref or GetAtt), the + # dependency's change appears first in the list. + # Snapshot tests using the `capture_update_process` fixture rely on a + # normalizer to account for this ordering. This should be removed in the + # future by enforcing a consistently correct change ordering at the source. + change_set_describer = ChangeSetModelDescriber( + change_set=change_set, include_property_values=include_property_values + ) + changes: Changes = change_set_describer.get_changes() + + result = DescribeChangeSetOutput( + Status=change_set.status, + ChangeSetId=change_set.change_set_id, + ChangeSetName=change_set.change_set_name, + ExecutionStatus=change_set.execution_status, + RollbackConfiguration=RollbackConfiguration(), + StackId=change_set.stack.stack_id, + StackName=change_set.stack.stack_name, + CreationTime=change_set.creation_time, + Parameters=[ + # TODO: add masking support. + Parameter(ParameterKey=key, ParameterValue=value) + for (key, value) in change_set.stack.resolved_parameters.items() + ], + Changes=changes, + ) + return result + + @handler("DescribeChangeSet") + def describe_change_set( + self, + context: RequestContext, + change_set_name: ChangeSetNameOrId, + stack_name: StackNameOrId | None = None, + next_token: NextToken | None = None, + include_property_values: IncludePropertyValues | None = None, + **kwargs, + ) -> DescribeChangeSetOutput: + # TODO add support for include_property_values + # only relevant if change_set_name isn't an ARN + state = get_cloudformation_store(context.account_id, context.region) + change_set = find_change_set_v2(state, change_set_name, stack_name) + if not change_set: + raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist") + result = self._describe_change_set( + change_set=change_set, include_property_values=include_property_values or False + ) + return result + + @handler("CreateStack", expand=False) + def create_stack(self, context: RequestContext, request: CreateStackInput) -> CreateStackOutput: + try: + stack_name = request["StackName"] + except KeyError: + # TODO: proper exception + raise ValidationError("StackName must be specified") + + state = get_cloudformation_store(context.account_id, context.region) + # TODO: copied from create_change_set, consider unifying + template_body = request.get("TemplateBody") + # s3 or secretsmanager url + template_url = request.get("TemplateURL") + + # validate and resolve template + if template_body and template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + if not template_body and not template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + template_body = api_utils.extract_template_body(request) + structured_template = template_preparer.parse_template(template_body) + + stack = Stack( + account_id=context.account_id, + region_name=context.region, + request_payload=request, + template=structured_template, + template_body=template_body, + ) + # TODO: what is the correct initial status? + state.stacks_v2[stack.stack_id] = stack + + # TODO: reconsider the way parameters are modelled in the update graph process. + # The options might be reduce to using the current style, or passing the extra information + # as a metadata object. The choice should be made considering when the extra information + # is needed for the update graph building, or only looked up in downstream tasks (metadata). + request_parameters = request.get("Parameters", list()) + # TODO: handle parameter defaults and resolution + after_parameters: dict[str, Any] = { + parameter["ParameterKey"]: parameter["ParameterValue"] + for parameter in request_parameters + } + after_template = structured_template + + # Create internal change set to execute + change_set = ChangeSet( + stack, + {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE}, + template=after_template, + ) + self._setup_change_set_model( + change_set=change_set, + before_template=None, + after_template=after_template, + before_parameters=None, + after_parameters=after_parameters, + previous_update_model=None, + ) + + # deployment process + stack.set_stack_status(StackStatus.CREATE_IN_PROGRESS) + change_set_executor = ChangeSetModelExecutor(change_set) + + def _run(*args): + try: + result = change_set_executor.execute() + stack.set_stack_status(StackStatus.CREATE_COMPLETE) + stack.resolved_resources = result.resources + stack.resolved_parameters = result.parameters + stack.resolved_outputs = result.outputs + # if the deployment succeeded, update the stack's template representation to that + # which was just deployed + stack.template = change_set.template + except Exception as e: + LOG.error( + "Create Stack set failed: %s", e, exc_info=LOG.isEnabledFor(logging.WARNING) + ) + stack.set_stack_status(StackStatus.CREATE_FAILED) + + start_worker_thread(_run) + + return CreateStackOutput(StackId=stack.stack_id) + + @handler("DescribeStacks") + def describe_stacks( + self, + context: RequestContext, + stack_name: StackName = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeStacksOutput: + state = get_cloudformation_store(context.account_id, context.region) + stack = find_stack_v2(state, stack_name) + if not stack: + raise StackNotFoundError(stack_name) + return DescribeStacksOutput(Stacks=[stack.describe_details()]) + + @handler("DescribeStackResources") + def describe_stack_resources( + self, + context: RequestContext, + stack_name: StackName = None, + logical_resource_id: LogicalResourceId = None, + physical_resource_id: PhysicalResourceId = None, + **kwargs, + ) -> DescribeStackResourcesOutput: + if physical_resource_id and stack_name: + raise ValidationError("Cannot specify both StackName and PhysicalResourceId") + state = get_cloudformation_store(context.account_id, context.region) + stack = find_stack_v2(state, stack_name) + if not stack: + raise StackNotFoundError(stack_name) + # TODO: filter stack by PhysicalResourceId! + statuses = [] + for resource_id, resource_status in stack.resource_states.items(): + if resource_id == logical_resource_id or logical_resource_id is None: + status = copy.deepcopy(resource_status) + status.setdefault("DriftInformation", {"StackResourceDriftStatus": "NOT_CHECKED"}) + statuses.append(status) + return DescribeStackResourcesOutput(StackResources=statuses) + + @handler("DescribeStackEvents") + def describe_stack_events( + self, + context: RequestContext, + stack_name: StackName = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeStackEventsOutput: + state = get_cloudformation_store(context.account_id, context.region) + stack = find_stack_v2(state, stack_name) + if not stack: + raise StackNotFoundError(stack_name) + return DescribeStackEventsOutput(StackEvents=stack.events) + + @handler("GetTemplateSummary", expand=False) + def get_template_summary( + self, + context: RequestContext, + request: GetTemplateSummaryInput, + ) -> GetTemplateSummaryOutput: + state = get_cloudformation_store(context.account_id, context.region) + stack_name = request.get("StackName") + + if stack_name: + stack = find_stack_v2(state, stack_name) + if not stack: + raise StackNotFoundError(stack_name) + template = stack.template + else: + template_body = request.get("TemplateBody") + # s3 or secretsmanager url + template_url = request.get("TemplateURL") + + # validate and resolve template + if template_body and template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + if not template_body and not template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + template_body = api_utils.extract_template_body(request) + template = template_preparer.parse_template(template_body) + + id_summaries = defaultdict(list) + for resource_id, resource in template["Resources"].items(): + res_type = resource["Type"] + id_summaries[res_type].append(resource_id) + + summarized_parameters = [] + for parameter_id, parameter_body in template.get("Parameters", {}).items(): + summarized_parameters.append( + { + "ParameterKey": parameter_id, + "DefaultValue": parameter_body.get("Default"), + "ParameterType": parameter_body["Type"], + "Description": parameter_body.get("Description"), + } + ) + result = GetTemplateSummaryOutput( + Parameters=summarized_parameters, + Metadata=template.get("Metadata"), + ResourceIdentifierSummaries=[ + {"ResourceType": key, "LogicalResourceIds": values} + for key, values in id_summaries.items() + ], + ResourceTypes=list(id_summaries.keys()), + Version=template.get("AWSTemplateFormatVersion", "2010-09-09"), + ) + + return result + + @handler("UpdateStack", expand=False) + def update_stack( + self, + context: RequestContext, + request: UpdateStackInput, + ) -> UpdateStackOutput: + try: + stack_name = request["StackName"] + except KeyError: + # TODO: proper exception + raise ValidationError("StackName must be specified") + state = get_cloudformation_store(context.account_id, context.region) + template_body = request.get("TemplateBody") + # s3 or secretsmanager url + template_url = request.get("TemplateURL") + + # validate and resolve template + if template_body and template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + if not template_body and not template_url: + raise ValidationError( + "Specify exactly one of 'TemplateBody' or 'TemplateUrl'" + ) # TODO: check proper message + + template_body = api_utils.extract_template_body(request) + structured_template = template_preparer.parse_template(template_body) + + # this is intentionally not in a util yet. Let's first see how the different operations deal with these before generalizing + # handle ARN stack_name here (not valid for initial CREATE, since stack doesn't exist yet) + stack: Stack + if is_stack_arn(stack_name): + stack = state.stacks_v2.get(stack_name) + if not stack: + raise ValidationError(f"Stack '{stack_name}' does not exist.") + + else: + # stack name specified, so fetch the stack by name + stack_candidates: list[Stack] = [ + s for stack_arn, s in state.stacks_v2.items() if s.stack_name == stack_name + ] + active_stack_candidates = [ + s for s in stack_candidates if self._stack_status_is_active(s.status) + ] + + if not active_stack_candidates: + raise ValidationError(f"Stack '{stack_name}' does not exist.") + elif len(active_stack_candidates) > 1: + raise RuntimeError("Multiple stacks matched, update matching logic") + stack = active_stack_candidates[0] + + # TODO: proper status modeling + before_parameters = stack.resolved_parameters + # TODO: reconsider the way parameters are modelled in the update graph process. + # The options might be reduce to using the current style, or passing the extra information + # as a metadata object. The choice should be made considering when the extra information + # is needed for the update graph building, or only looked up in downstream tasks (metadata). + request_parameters = request.get("Parameters", list()) + # TODO: handle parameter defaults and resolution + after_parameters: dict[str, Any] = { + parameter["ParameterKey"]: parameter["ParameterValue"] + for parameter in request_parameters + } + before_template = stack.template + after_template = structured_template + + previous_update_model = None + if previous_change_set := find_change_set_v2(state, stack.change_set_id): + previous_update_model = previous_change_set.update_model + + change_set = ChangeSet( + stack, + {"ChangeSetName": f"cs-{stack_name}-create", "ChangeSetType": ChangeSetType.CREATE}, + template=after_template, + ) + self._setup_change_set_model( + change_set=change_set, + before_template=before_template, + after_template=after_template, + before_parameters=before_parameters, + after_parameters=after_parameters, + previous_update_model=previous_update_model, + ) + + # TODO: some changes are only detectable at runtime; consider using + # the ChangeSetModelDescriber, or a new custom visitors, to + # pick-up on runtime changes. + if change_set.update_model.node_template.change_type == ChangeType.UNCHANGED: + raise ValidationError("No updates are to be performed.") + + stack.set_stack_status(StackStatus.UPDATE_IN_PROGRESS) + change_set_executor = ChangeSetModelExecutor(change_set) + + def _run(*args): + try: + result = change_set_executor.execute() + stack.set_stack_status(StackStatus.UPDATE_COMPLETE) + stack.resolved_resources = result.resources + stack.resolved_parameters = result.parameters + stack.resolved_outputs = result.outputs + # if the deployment succeeded, update the stack's template representation to that + # which was just deployed + stack.template = change_set.template + except Exception as e: + LOG.error("Update Stack failed: %s", e, exc_info=LOG.isEnabledFor(logging.WARNING)) + stack.set_stack_status(StackStatus.UPDATE_FAILED) + + start_worker_thread(_run) + + # TODO: stack id + return UpdateStackOutput(StackId=stack.stack_id) + + @handler("DeleteStack") + def delete_stack( + self, + context: RequestContext, + stack_name: StackName, + retain_resources: RetainResources = None, + role_arn: RoleARN = None, + client_request_token: ClientRequestToken = None, + deletion_mode: DeletionMode = None, + **kwargs, + ) -> None: + state = get_cloudformation_store(context.account_id, context.region) + stack = find_stack_v2(state, stack_name) + if not stack: + # aws will silently ignore invalid stack names - we should do the same + return + + # shortcut for stacks which have no deployed resources i.e. where a change set was + # created, but never executed + if stack.status == StackStatus.REVIEW_IN_PROGRESS and not stack.resolved_resources: + stack.set_stack_status(StackStatus.DELETE_COMPLETE) + stack.deletion_time = datetime.now(tz=timezone.utc) + return + + previous_update_model = None + if previous_change_set := find_change_set_v2(state, stack.change_set_id): + previous_update_model = previous_change_set.update_model + + # create a dummy change set + change_set = ChangeSet(stack, {"ChangeSetName": f"delete-stack_{stack.stack_name}"}) # noqa + self._setup_change_set_model( + change_set=change_set, + before_template=stack.template, + after_template=None, + before_parameters=stack.resolved_parameters, + after_parameters=None, + previous_update_model=previous_update_model, + ) + + change_set_executor = ChangeSetModelExecutor(change_set) + + def _run(*args): + try: + stack.set_stack_status(StackStatus.DELETE_IN_PROGRESS) + change_set_executor.execute() + stack.set_stack_status(StackStatus.DELETE_COMPLETE) + stack.deletion_time = datetime.now(tz=timezone.utc) + except Exception as e: + LOG.warning( + "Failed to delete stack '%s': %s", + stack.stack_name, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + stack.set_stack_status(StackStatus.DELETE_FAILED) + + start_worker_thread(_run) diff --git a/localstack-core/localstack/services/cloudformation/v2/utils.py b/localstack-core/localstack/services/cloudformation/v2/utils.py new file mode 100644 index 0000000000000..02a6cbb971a99 --- /dev/null +++ b/localstack-core/localstack/services/cloudformation/v2/utils.py @@ -0,0 +1,5 @@ +from localstack import config + + +def is_v2_engine() -> bool: + return config.SERVICE_PROVIDER_CONFIG.get_provider("cloudformation") == "engine-v2" diff --git a/localstack-core/localstack/services/cloudwatch/__init__.py b/localstack-core/localstack/services/cloudwatch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cloudwatch/alarm_scheduler.py b/localstack-core/localstack/services/cloudwatch/alarm_scheduler.py new file mode 100644 index 0000000000000..2b0675f121450 --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/alarm_scheduler.py @@ -0,0 +1,395 @@ +import json +import logging +import math +import threading +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, List, Optional + +from localstack.aws.api.cloudwatch import MetricAlarm, MetricDataQuery, MetricStat, StateValue +from localstack.aws.connect import connect_to +from localstack.utils.aws import arns, aws_stack +from localstack.utils.scheduler import Scheduler + +if TYPE_CHECKING: + from mypy_boto3_cloudwatch import CloudWatchClient + +LOG = logging.getLogger(__name__) + +# TODO currently not supported, used for anomaly detection models: +# LessThanLowerOrGreaterThanUpperThreshold +# LessThanLowerThreshold +# GreaterThanUpperThreshold +COMPARISON_OPS = { + "GreaterThanOrEqualToThreshold": (lambda value, threshold: value >= threshold), + "GreaterThanThreshold": (lambda value, threshold: value > threshold), + "LessThanThreshold": (lambda value, threshold: value < threshold), + "LessThanOrEqualToThreshold": (lambda value, threshold: value <= threshold), +} + +DEFAULT_REASON = "Alarm Evaluation" +THRESHOLD_CROSSED = "Threshold Crossed" +INSUFFICIENT_DATA = "Insufficient Data" + + +class AlarmScheduler: + def __init__(self) -> None: + """ + Creates a new AlarmScheduler, with a Scheduler, that will be started in a new thread + """ + super().__init__() + self.scheduler = Scheduler() + self.thread = threading.Thread(target=self.scheduler.run, name="cloudwatch-scheduler") + self.thread.start() + self.scheduled_alarms = {} + + def shutdown_scheduler(self) -> None: + """ + Shutsdown the scheduler, must be called before application stops + """ + self.scheduler.close() + self.thread.join(10) + + def schedule_metric_alarm(self, alarm_arn: str) -> None: + """(Re-)schedules the alarm, if the alarm is re-scheduled, the running alarm scheduler will be cancelled before + starting a new one""" + alarm_details = get_metric_alarm_details_for_alarm_arn(alarm_arn) + self.delete_scheduler_for_alarm(alarm_arn) + if not alarm_details: + LOG.warning("Scheduling alarm failed: could not find alarm %s", alarm_arn) + return + + if not self._is_alarm_supported(alarm_details): + LOG.warning( + "Given alarm configuration not yet supported, alarm state will not be evaluated." + ) + return + + period = alarm_details["Period"] + evaluation_periods = alarm_details["EvaluationPeriods"] + schedule_period = evaluation_periods * period + + def on_error(e): + LOG.exception("Error executing scheduled alarm", exc_info=e) + + task = self.scheduler.schedule( + func=calculate_alarm_state, + period=schedule_period, + fixed_rate=True, + args=[alarm_arn], + on_error=on_error, + ) + + self.scheduled_alarms[alarm_arn] = task + + def delete_scheduler_for_alarm(self, alarm_arn: str) -> None: + """ + Deletes the recurring scheduler for an alarm + + :param alarm_arn: the arn of the alarm to be removed + """ + task = self.scheduled_alarms.pop(alarm_arn, None) + if task: + task.cancel() + + def restart_existing_alarms(self) -> None: + """ + Only used re-create persistent state. Reschedules alarms that already exist + """ + for region in aws_stack.get_valid_regions_for_service("cloudwatch"): + client = connect_to(region_name=region).cloudwatch + result = client.describe_alarms() + for metric_alarm in result["MetricAlarms"]: + arn = metric_alarm["AlarmArn"] + self.schedule_metric_alarm(alarm_arn=arn) + + def _is_alarm_supported(self, alarm_details: MetricAlarm) -> bool: + required_parameters = ["Period", "Statistic", "MetricName", "Threshold"] + for param in required_parameters: + if param not in alarm_details: + LOG.debug( + "Currently only simple MetricAlarm are supported. Alarm is missing '%s'. ExtendedStatistic is not yet supported.", + param, + ) + return False + if alarm_details["ComparisonOperator"] not in COMPARISON_OPS: + LOG.debug( + "ComparisonOperator '%s' not yet supported.", + alarm_details["ComparisonOperator"], + ) + return False + return True + + +def get_metric_alarm_details_for_alarm_arn(alarm_arn: str) -> Optional[MetricAlarm]: + alarm_name = arns.extract_resource_from_arn(alarm_arn).split(":", 1)[1] + client = get_cloudwatch_client_for_region_of_alarm(alarm_arn) + metric_alarms = client.describe_alarms(AlarmNames=[alarm_name])["MetricAlarms"] + return metric_alarms[0] if metric_alarms else None + + +def get_cloudwatch_client_for_region_of_alarm(alarm_arn: str) -> "CloudWatchClient": + parsed_arn = arns.parse_arn(alarm_arn) + region = parsed_arn["region"] + access_key_id = parsed_arn["account"] + return connect_to(region_name=region, aws_access_key_id=access_key_id).cloudwatch + + +def generate_metric_query(alarm_details: MetricAlarm) -> MetricDataQuery: + """Creates the dict with the required data for MetricDataQueries when calling client.get_metric_data""" + + metric = { + "MetricName": alarm_details["MetricName"], + } + if alarm_details.get("Namespace"): + metric["Namespace"] = alarm_details["Namespace"] + if alarm_details.get("Dimensions"): + metric["Dimensions"] = alarm_details["Dimensions"] + return MetricDataQuery( + Id=alarm_details["AlarmName"], + MetricStat=MetricStat( + Metric=metric, + Period=alarm_details["Period"], + Stat=alarm_details["Statistic"], + ), + # TODO other fields might be required in the future + ) + + +def is_threshold_exceeded(metric_values: List[float], alarm_details: MetricAlarm) -> bool: + """Evaluates if the threshold is exceeded for the configured alarm and given metric values + + :param metric_values: values to compare against threshold + :param alarm_details: Alarm Description, as returned from describe_alarms + + :return: True if threshold is exceeded, else False + """ + threshold = alarm_details["Threshold"] + comparison_operator = alarm_details["ComparisonOperator"] + treat_missing_data = alarm_details.get("TreatMissingData", "missing") + evaluation_periods = alarm_details.get("EvaluationPeriods") + datapoints_to_alarm = alarm_details.get("DatapointsToAlarm", evaluation_periods) + evaluated_datapoints = [] + for value in metric_values: + if value is None: + if treat_missing_data == "breaching": + evaluated_datapoints.append(True) + elif treat_missing_data == "notBreaching": + evaluated_datapoints.append(False) + # else we can ignore the data + else: + evaluated_datapoints.append(COMPARISON_OPS.get(comparison_operator)(value, threshold)) + + sum_breaching = evaluated_datapoints.count(True) + if sum_breaching >= datapoints_to_alarm: + return True + return False + + +def is_triggering_premature_alarm(metric_values: List[float], alarm_details: MetricAlarm) -> bool: + """ + Checks if a premature alarm should be triggered. + https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html#CloudWatch-alarms-avoiding-premature-transition: + + [...] alarms are designed to always go into ALARM state when the oldest available breaching datapoint during the Evaluation + Periods number of data points is at least as old as the value of Datapoints to Alarm, and all other more recent data + points are breaching or missing. In this case, the alarm goes into ALARM state even if the total number of datapoints + available is lower than M (Datapoints to Alarm). + This alarm logic applies to M out of N alarms as well. + """ + treat_missing_data = alarm_details.get("TreatMissingData", "missing") + if treat_missing_data not in ("missing", "ignore"): + return False + + datapoints_to_alarm = alarm_details.get("DatapointsToAlarm", 1) + if datapoints_to_alarm > 1: + comparison_operator = alarm_details["ComparisonOperator"] + threshold = alarm_details["Threshold"] + oldest_datapoints = metric_values[:-datapoints_to_alarm] + if oldest_datapoints.count(None) == len(oldest_datapoints): + if metric_values[-datapoints_to_alarm] and COMPARISON_OPS.get(comparison_operator)( + metric_values[-datapoints_to_alarm], threshold + ): + values = list(filter(None, metric_values[len(oldest_datapoints) :])) + if all( + COMPARISON_OPS.get(comparison_operator)(value, threshold) for value in values + ): + return True + return False + + +def collect_metric_data(alarm_details: MetricAlarm, client: "CloudWatchClient") -> List[float]: + """ + Collects the metric data for the evaluation interval. + + :param alarm_details: the alarm details as returned by describe_alarms + :param client: the cloudwatch client + :return: list with data points + """ + metric_values = [] + + evaluation_periods = alarm_details["EvaluationPeriods"] + period = alarm_details["Period"] + + # From the docs: "Whenever an alarm evaluates whether to change state, CloudWatch attempts to retrieve a higher number of data + # points than the number specified as Evaluation Periods." + # No other indication, try to calculate a reasonable value: + magic_number = max(math.floor(evaluation_periods / 3), 2) + collected_periods = evaluation_periods + magic_number + + now = datetime.utcnow().replace(tzinfo=timezone.utc) + metric_query = generate_metric_query(alarm_details) + + # get_metric_data needs to be run in a loop, so we also collect empty data points on the right position + for i in range(0, collected_periods): + start_time = now - timedelta(seconds=period) + end_time = now + metric_data = client.get_metric_data( + MetricDataQueries=[metric_query], StartTime=start_time, EndTime=end_time + )["MetricDataResults"][0] + val = metric_data["Values"] + # oldest datapoint should be at the beginning of the list + metric_values.insert(0, val[0] if val else None) + now = start_time + return metric_values + + +def update_alarm_state( + client: "CloudWatchClient", + alarm_name: str, + current_state: str, + desired_state: str, + reason: str = DEFAULT_REASON, + state_reason_data: dict = None, +) -> None: + """Updates the alarm state, if the current_state is different than the desired_state + + :param client: the cloudwatch client + :param alarm_name: the name of the alarm + :param current_state: the state the alarm is currently in + :param desired_state: the state the alarm should have after updating + :param reason: reason why the state is set, will be used to for set_alarm_state + :param state_reason_data: data associated with the state change, optional + """ + if current_state == desired_state: + return + client.set_alarm_state( + AlarmName=alarm_name, + StateValue=desired_state, + StateReason=reason, + StateReasonData=json.dumps(state_reason_data), + ) + + +def calculate_alarm_state(alarm_arn: str) -> None: + """ + Calculates and updates the state of the alarm + + :param alarm_arn: the arn of the alarm to be evaluated + """ + alarm_details = get_metric_alarm_details_for_alarm_arn(alarm_arn) + if not alarm_details: + LOG.warning("Could not find alarm %s", alarm_arn) + return + + client = get_cloudwatch_client_for_region_of_alarm(alarm_arn) + + query_date = datetime.utcnow().strftime(format="%Y-%m-%dT%H:%M:%S+0000") + metric_values = collect_metric_data(alarm_details, client) + + state_reason_data = { + "version": "1.0", + "queryDate": query_date, + "period": alarm_details["Period"], + "recentDatapoints": [v for v in metric_values if v is not None], + "threshold": alarm_details["Threshold"], + } + if alarm_details.get("Statistic"): + state_reason_data["statistic"] = alarm_details["Statistic"] + if alarm_details.get("Unit"): + state_reason_data["unit"] = alarm_details["Unit"] + + alarm_name = alarm_details["AlarmName"] + alarm_state = alarm_details["StateValue"] + treat_missing_data = alarm_details.get("TreatMissingData", "missing") + + empty_datapoints = metric_values.count(None) + if empty_datapoints == len(metric_values): + evaluation_periods = alarm_details["EvaluationPeriods"] + details_msg = ( + f"no datapoints were received for {evaluation_periods} period{'s' if evaluation_periods > 1 else ''} and " + f"{evaluation_periods} missing datapoint{'s were' if evaluation_periods > 1 else ' was'} treated as" + ) + if treat_missing_data == "missing": + update_alarm_state( + client, + alarm_name, + alarm_state, + StateValue.INSUFFICIENT_DATA, + f"{INSUFFICIENT_DATA}: {details_msg} [{treat_missing_data.capitalize()}].", + state_reason_data=state_reason_data, + ) + elif treat_missing_data == "breaching": + update_alarm_state( + client, + alarm_name, + alarm_state, + StateValue.ALARM, + f"{THRESHOLD_CROSSED}: {details_msg} [{treat_missing_data.capitalize()}].", + state_reason_data=state_reason_data, + ) + elif treat_missing_data == "notBreaching": + update_alarm_state( + client, + alarm_name, + alarm_state, + StateValue.OK, + f"{THRESHOLD_CROSSED}: {details_msg} [NonBreaching].", + state_reason_data=state_reason_data, + ) + # 'ignore': keep the same state + return + + if is_triggering_premature_alarm(metric_values, alarm_details): + if treat_missing_data == "missing": + update_alarm_state( + client, + alarm_name, + alarm_state, + StateValue.ALARM, + f"{THRESHOLD_CROSSED}: premature alarm for missing datapoints", + state_reason_data=state_reason_data, + ) + # for 'ignore' the state should be retained + return + + # collect all non-empty datapoints from the evaluation interval + collected_datapoints = [val for val in reversed(metric_values) if val is not None] + + # adding empty data points until amount of data points == "evaluation periods" + evaluation_periods = alarm_details["EvaluationPeriods"] + while len(collected_datapoints) < evaluation_periods and treat_missing_data in ( + "breaching", + "notBreaching", + ): + # breaching/non-breaching datapoints will be evaluated + # ignore/missing are not relevant + collected_datapoints.append(None) + + if is_threshold_exceeded(collected_datapoints, alarm_details): + update_alarm_state( + client, + alarm_name, + alarm_state, + StateValue.ALARM, + THRESHOLD_CROSSED, + state_reason_data=state_reason_data, + ) + else: + update_alarm_state( + client, + alarm_name, + alarm_state, + StateValue.OK, + THRESHOLD_CROSSED, + state_reason_data=state_reason_data, + ) diff --git a/localstack-core/localstack/services/cloudwatch/cloudwatch_database_helper.py b/localstack-core/localstack/services/cloudwatch/cloudwatch_database_helper.py new file mode 100644 index 0000000000000..43383cf2782ad --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/cloudwatch_database_helper.py @@ -0,0 +1,460 @@ +import logging +import os +import sqlite3 +import threading +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from localstack import config +from localstack.aws.api.cloudwatch import MetricData, MetricDataQuery, ScanBy +from localstack.utils.files import mkdir + +LOG = logging.getLogger(__name__) + +STAT_TO_SQLITE_AGGREGATION_FUNC = { + "Sum": "SUM(value)", + "Average": "SUM(value)", # we need to calculate the avg manually as we have also a table with aggregated data + "Minimum": "MIN(value)", + "Maximum": "MAX(value)", + "SampleCount": "Sum(count)", +} + +STAT_TO_SQLITE_COL_NAME_HELPER = { + "Sum": "sum", + "Average": "sum", + "Minimum": "min", + "Maximum": "max", + "SampleCount": "sample_count", +} + + +class CloudwatchDatabase: + DB_NAME = "metrics.db" + CLOUDWATCH_DATA_ROOT: str = os.path.join(config.dirs.data, "cloudwatch") + METRICS_DB: str = os.path.join(CLOUDWATCH_DATA_ROOT, DB_NAME) + METRICS_DB_READ_ONLY: str = f"file:{METRICS_DB}?mode=ro" + TABLE_SINGLE_METRICS = "SINGLE_METRICS" + TABLE_AGGREGATED_METRICS = "AGGREGATED_METRICS" + DATABASE_LOCK: threading.RLock + + def __init__(self): + self.DATABASE_LOCK = threading.RLock() + if os.path.exists(self.METRICS_DB): + LOG.debug("database for metrics already exists (%s)", self.METRICS_DB) + return + + mkdir(self.CLOUDWATCH_DATA_ROOT) + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB) as conn: + cur = conn.cursor() + common_columns = """ + "id" INTEGER, + "account_id" TEXT, + "region" TEXT, + "metric_name" TEXT, + "namespace" TEXT, + "timestamp" NUMERIC, + "dimensions" TEXT, + "unit" TEXT, + "storage_resolution" INTEGER + """ + cur.execute( + f""" + CREATE TABLE "{self.TABLE_SINGLE_METRICS}" ( + {common_columns}, + "value" NUMERIC, + PRIMARY KEY("id") + ); + """ + ) + + cur.execute( + f""" + CREATE TABLE "{self.TABLE_AGGREGATED_METRICS}" ( + {common_columns}, + "sample_count" NUMERIC, + "sum" NUMERIC, + "min" NUMERIC, + "max" NUMERIC, + PRIMARY KEY("id") + ); + """ + ) + # create indexes + cur.executescript( + """ + CREATE INDEX idx_single_metrics_comp ON SINGLE_METRICS (metric_name, namespace); + CREATE INDEX idx_aggregated_metrics_comp ON AGGREGATED_METRICS (metric_name, namespace); + """ + ) + conn.commit() + + def add_metric_data( + self, account_id: str, region: str, namespace: str, metric_data: MetricData + ): + def _get_current_unix_timestamp_utc(): + now = datetime.utcnow().replace(tzinfo=timezone.utc) + return int(now.timestamp()) + + for metric in metric_data: + unix_timestamp = ( + self._convert_timestamp_to_unix(metric.get("Timestamp")) + if metric.get("Timestamp") + else _get_current_unix_timestamp_utc() + ) + + inserts = [] + if metric.get("Value") is not None: + inserts.append({"Value": metric.get("Value"), "TimesToInsert": 1}) + elif metric.get("Values"): + counts = metric.get("Counts", [1] * len(metric.get("Values"))) + inserts = [ + {"Value": value, "TimesToInsert": int(counts[indexValue])} + for indexValue, value in enumerate(metric.get("Values")) + ] + all_data = [] + for insert in inserts: + times_to_insert = insert.get("TimesToInsert") + + data = ( + account_id, + region, + metric.get("MetricName"), + namespace, + unix_timestamp, + self._get_ordered_dimensions_with_separator(metric.get("Dimensions")), + metric.get("Unit"), + metric.get("StorageResolution"), + insert.get("Value"), + ) + all_data.extend([data] * times_to_insert) + + if all_data: + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB) as conn: + cur = conn.cursor() + query = f"INSERT INTO {self.TABLE_SINGLE_METRICS} (account_id, region, metric_name, namespace, timestamp, dimensions, unit, storage_resolution, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + cur.executemany(query, all_data) + conn.commit() + + if statistic_values := metric.get("StatisticValues"): + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB) as conn: + cur = conn.cursor() + cur.execute( + f"""INSERT INTO {self.TABLE_AGGREGATED_METRICS} + ("account_id", "region", "metric_name", "namespace", "timestamp", "dimensions", "unit", "storage_resolution", "sample_count", "sum", "min", "max") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + account_id, + region, + metric.get("MetricName"), + namespace, + unix_timestamp, + self._get_ordered_dimensions_with_separator(metric.get("Dimensions")), + metric.get("Unit"), + metric.get("StorageResolution"), + statistic_values.get("SampleCount"), + statistic_values.get("Sum"), + statistic_values.get("Minimum"), + statistic_values.get("Maximum"), + ), + ) + + conn.commit() + + def get_units_for_metric_data_stat( + self, + account_id: str, + region: str, + start_time: datetime, + end_time: datetime, + metric_name: str, + namespace: str, + ): + # prepare SQL query + start_time_unix = self._convert_timestamp_to_unix(start_time) + end_time_unix = self._convert_timestamp_to_unix(end_time) + + data = ( + account_id, + region, + namespace, + metric_name, + start_time_unix, + end_time_unix, + ) + + sql_query = f""" + SELECT GROUP_CONCAT(unit) AS unit_values + FROM( + SELECT + DISTINCT COALESCE(unit, 'NULL_VALUE') AS unit + FROM ( + SELECT + account_id, region, metric_name, namespace, timestamp, unit + FROM {self.TABLE_SINGLE_METRICS} + UNION ALL + SELECT + account_id, region, metric_name, namespace, timestamp, unit + FROM {self.TABLE_AGGREGATED_METRICS} + ) AS combined + WHERE account_id = ? AND region = ? + AND namespace = ? AND metric_name = ? + AND timestamp >= ? AND timestamp < ? + ) AS subquery + """ + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB_READ_ONLY, uri=True) as conn: + cur = conn.cursor() + cur.execute( + sql_query, + data, + ) + result_row = cur.fetchone() + return result_row[0].split(",") if result_row[0] else ["NULL_VALUE"] + + def get_metric_data_stat( + self, + account_id: str, + region: str, + query: MetricDataQuery, + start_time: datetime, + end_time: datetime, + scan_by: str, + ) -> Dict[str, List]: + metric_stat = query.get("MetricStat") + metric = metric_stat.get("Metric") + period = metric_stat.get("Period") + stat = metric_stat.get("Stat") + dimensions = metric.get("Dimensions", []) + unit = metric_stat.get("Unit") + + # prepare SQL query + start_time_unix = self._convert_timestamp_to_unix(start_time) + end_time_unix = self._convert_timestamp_to_unix(end_time) + + data = ( + account_id, + region, + metric.get("Namespace"), + metric.get("MetricName"), + ) + + dimension_filter = "AND dimensions is null " if not dimensions else "AND dimensions LIKE ? " + if dimensions: + data = data + ( + self._get_ordered_dimensions_with_separator(dimensions, for_search=True), + ) + + unit_filter = "" + if unit: + if unit == "NULL_VALUE": + unit_filter = "AND unit IS NULL" + else: + unit_filter = "AND unit = ? " + data += (unit,) + + sql_query = f""" + SELECT + {STAT_TO_SQLITE_AGGREGATION_FUNC[stat]}, + SUM(count) + FROM ( + SELECT + value, 1 as count, + account_id, region, metric_name, namespace, timestamp, dimensions, unit, storage_resolution + FROM {self.TABLE_SINGLE_METRICS} + UNION ALL + SELECT + {STAT_TO_SQLITE_COL_NAME_HELPER[stat]} as value, sample_count as count, + account_id, region, metric_name, namespace, timestamp, dimensions, unit, storage_resolution + FROM {self.TABLE_AGGREGATED_METRICS} + ) AS combined + WHERE account_id = ? AND region = ? + AND namespace = ? AND metric_name = ? + {dimension_filter} + {unit_filter} + AND timestamp >= ? AND timestamp < ? + ORDER BY timestamp ASC + """ + + timestamps = [] + values = [] + query_params = [] + + # Prepare all the query parameters + while start_time_unix < end_time_unix: + next_start_time = start_time_unix + period + query_params.append(data + (start_time_unix, next_start_time)) + start_time_unix = next_start_time + + all_results = [] + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB_READ_ONLY, uri=True) as conn: + cur = conn.cursor() + batch_size = 500 + for i in range(0, len(query_params), batch_size): + batch = query_params[i : i + batch_size] + cur.execute( + f""" + SELECT * FROM ( + {" UNION ALL ".join(["SELECT * FROM (" + sql_query + ")"] * len(batch))} + ) + """, + sum(batch, ()), # flatten the list of tuples in batch into a single tuple + ) + all_results.extend(cur.fetchall()) + + # Process results outside the lock + for i, result_row in enumerate(all_results): + if result_row[1]: + calculated_result = ( + result_row[0] / result_row[1] if stat == "Average" else result_row[0] + ) + timestamps.append(query_params[i][-2]) # start_time_unix + values.append(calculated_result) + + # The while loop while always give us the timestamps in ascending order as we start with the start_time + # and increase it by the period until we reach the end_time + # If we want the timestamps in descending order we need to reverse the list + if scan_by is None or scan_by == ScanBy.TimestampDescending: + timestamps = timestamps[::-1] + values = values[::-1] + + return { + "timestamps": timestamps, + "values": values, + } + + def list_metrics( + self, + account_id: str, + region: str, + namespace: str, + metric_name: str, + dimensions: list[dict[str, str]], + ) -> dict: + data = (account_id, region) + + namespace_filter = "" + if namespace: + namespace_filter = " AND namespace = ?" + data = data + (namespace,) + + metric_name_filter = "" + if metric_name: + metric_name_filter = " AND metric_name = ?" + data = data + (metric_name,) + + dimension_filter = "" if not dimensions else " AND dimensions LIKE ? " + if dimensions: + data = data + ( + self._get_ordered_dimensions_with_separator(dimensions, for_search=True), + ) + + query = f""" + SELECT DISTINCT metric_name, namespace, dimensions + FROM ( + SELECT metric_name, namespace, dimensions, account_id, region, timestamp + FROM SINGLE_METRICS + UNION + SELECT metric_name, namespace, dimensions, account_id, region, timestamp + FROM AGGREGATED_METRICS + ) AS combined + WHERE account_id = ? AND region = ? + {namespace_filter} + {metric_name_filter} + {dimension_filter} + ORDER BY timestamp DESC + """ + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB_READ_ONLY, uri=True) as conn: + cur = conn.cursor() + + cur.execute( + query, + data, + ) + metrics_result = [ + { + "metric_name": r[0], + "namespace": r[1], + "dimensions": self._restore_dimensions_from_string(r[2]), + } + for r in cur.fetchall() + ] + + return {"metrics": metrics_result} + + def clear_tables(self): + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB) as conn: + cur = conn.cursor() + cur.execute(f"DELETE FROM {self.TABLE_SINGLE_METRICS}") + cur.execute(f"DELETE FROM {self.TABLE_AGGREGATED_METRICS}") + conn.commit() + cur.execute("VACUUM") + conn.commit() + + def _get_ordered_dimensions_with_separator(self, dims: Optional[List[Dict]], for_search=False): + """ + Returns a string with the dimensions in the format "Name=Value\tName=Value\tName=Value" in order to store the metric + with the dimensions in a single column in the database + + :param dims: List of dimensions in the format [{"Name": "name", "Value": "value"}, ...] + :param for_search: If True, the dimensions will be formatted in a way that can be used in a LIKE query to search. Default is False. Example: " %{Name}={Value}% " + :return: String with the dimensions in the format "Name=Value\tName=Value\tName=Value" + """ + if not dims: + return None + dims.sort(key=lambda d: d["Name"]) + dimensions = "" + if not for_search: + for d in dims: + dimensions += f"{d['Name']}={d['Value']}\t" # aws does not allow ascii control characters, we can use it a sa separator + else: + for d in dims: + dimensions += f"%{d.get('Name')}={d.get('Value', '')}%" + + return dimensions + + def _restore_dimensions_from_string(self, dimensions: str): + if not dimensions: + return None + dims = [] + for d in dimensions.split("\t"): + if not d: + continue + name, value = d.split("=") + dims.append({"Name": name, "Value": value}) + + return dims + + def _convert_timestamp_to_unix( + self, timestamp: datetime + ): # TODO verify if this is the standard format, might need to convert + return int(timestamp.timestamp()) + + def get_all_metric_data(self): + with self.DATABASE_LOCK, sqlite3.connect(self.METRICS_DB_READ_ONLY, uri=True) as conn: + cur = conn.cursor() + """ shape for each data entry: + { + "ns": r.namespace, + "n": r.name, + "v": r.value, + "t": r.timestamp, + "d": [{"n": d.name, "v": d.value} for d in r.dimensions], + "account": account-id, # new for v2 + "region": region_name, # new for v2 + } + """ + query = f"SELECT namespace, metric_name, value, timestamp, dimensions, account_id, region from {self.TABLE_SINGLE_METRICS}" + cur.execute(query) + metrics_result = [ + { + "ns": r[0], + "n": r[1], + "v": r[2], + "t": r[3], + "d": r[4], + "account": r[5], + "region": r[6], + } + for r in cur.fetchall() + ] + # TODO add aggregated metrics (was not handled by v1 either) + return metrics_result diff --git a/localstack-core/localstack/services/cloudwatch/models.py b/localstack-core/localstack/services/cloudwatch/models.py new file mode 100644 index 0000000000000..a1246569f4f97 --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/models.py @@ -0,0 +1,109 @@ +import datetime +from datetime import timezone +from typing import Dict, List + +from localstack.aws.api.cloudwatch import CompositeAlarm, DashboardBody, MetricAlarm, StateValue +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossRegionAttribute, + LocalAttribute, +) +from localstack.utils.aws import arns +from localstack.utils.tagging import TaggingService + + +class LocalStackMetricAlarm: + region: str + account_id: str + alarm: MetricAlarm + + def __init__(self, account_id: str, region: str, alarm: MetricAlarm): + self.account_id = account_id + self.region = region + self.alarm = alarm + self.set_default_attributes() + + def set_default_attributes(self): + current_time = datetime.datetime.now(timezone.utc) + self.alarm["AlarmArn"] = arns.cloudwatch_alarm_arn( + self.alarm["AlarmName"], account_id=self.account_id, region_name=self.region + ) + self.alarm["AlarmConfigurationUpdatedTimestamp"] = current_time + self.alarm.setdefault("ActionsEnabled", True) + self.alarm.setdefault("OKActions", []) + self.alarm.setdefault("AlarmActions", []) + self.alarm.setdefault("InsufficientDataActions", []) + self.alarm["StateValue"] = StateValue.INSUFFICIENT_DATA + self.alarm["StateReason"] = "Unchecked: Initial alarm creation" + self.alarm["StateUpdatedTimestamp"] = current_time + self.alarm.setdefault("Dimensions", []) + self.alarm["StateTransitionedTimestamp"] = current_time + + +class LocalStackCompositeAlarm: + region: str + account_id: str + alarm: CompositeAlarm + + def __init__(self, account_id: str, region: str, alarm: CompositeAlarm): + self.account_id = account_id + self.region = region + self.alarm = alarm + self.set_default_attributes() + + def set_default_attributes(self): + current_time = datetime.datetime.now(timezone.utc) + self.alarm["AlarmArn"] = arns.cloudwatch_alarm_arn( + self.alarm["AlarmName"], account_id=self.account_id, region_name=self.region + ) + self.alarm["AlarmConfigurationUpdatedTimestamp"] = current_time + self.alarm.setdefault("ActionsEnabled", True) + self.alarm.setdefault("OKActions", []) + self.alarm.setdefault("AlarmActions", []) + self.alarm.setdefault("InsufficientDataActions", []) + self.alarm["StateValue"] = StateValue.INSUFFICIENT_DATA + self.alarm["StateReason"] = "Unchecked: Initial alarm creation" + self.alarm["StateUpdatedTimestamp"] = current_time + self.alarm["StateTransitionedTimestamp"] = current_time + + +class LocalStackDashboard: + region: str + account_id: str + dashboard_name: str + dashboard_arn: str + dashboard_body: DashboardBody + + def __init__( + self, account_id: str, region: str, dashboard_name: str, dashboard_body: DashboardBody + ): + self.account_id = account_id + self.region = region + self.dashboard_name = dashboard_name + self.dashboard_arn = arns.cloudwatch_dashboard_arn( + self.dashboard_name, account_id=self.account_id, region_name=self.region + ) + self.dashboard_body = dashboard_body + self.last_modified = datetime.datetime.now() + self.size = 225 # TODO: calculate size + + +LocalStackAlarm = LocalStackMetricAlarm | LocalStackCompositeAlarm + + +class CloudWatchStore(BaseStore): + # maps resource ARN to tags + TAGS: TaggingService = CrossRegionAttribute(default=TaggingService) + + # maps resource ARN to alarms + alarms: Dict[str, LocalStackAlarm] = LocalAttribute(default=dict) + + # Contains all the Alarm Histories. Per documentation, an alarm history is retained even if the alarm is deleted, + # making it necessary to save this at store level + histories: List[Dict] = LocalAttribute(default=list) + + dashboards: Dict[str, LocalStackDashboard] = LocalAttribute(default=dict) + + +cloudwatch_stores = AccountRegionBundle("cloudwatch", CloudWatchStore) diff --git a/localstack-core/localstack/services/cloudwatch/provider.py b/localstack-core/localstack/services/cloudwatch/provider.py new file mode 100644 index 0000000000000..42e4b5fe94e58 --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/provider.py @@ -0,0 +1,530 @@ +import json +import logging +import uuid +from typing import Any, Optional +from xml.sax.saxutils import escape + +from moto.cloudwatch import cloudwatch_backends +from moto.cloudwatch.models import CloudWatchBackend, FakeAlarm, MetricDatum + +from localstack.aws.accounts import get_account_id_from_access_key_id +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.cloudwatch import ( + AlarmNames, + AmazonResourceName, + CloudwatchApi, + DescribeAlarmsInput, + DescribeAlarmsOutput, + GetMetricDataInput, + GetMetricDataOutput, + GetMetricStatisticsInput, + GetMetricStatisticsOutput, + ListTagsForResourceOutput, + PutCompositeAlarmInput, + PutMetricAlarmInput, + StateValue, + TagKeyList, + TagList, + TagResourceOutput, + UntagResourceOutput, +) +from localstack.aws.connect import connect_to +from localstack.constants import DEFAULT_AWS_ACCOUNT_ID +from localstack.http import Request +from localstack.services import moto +from localstack.services.cloudwatch.alarm_scheduler import AlarmScheduler +from localstack.services.edge import ROUTER +from localstack.services.plugins import SERVICE_PLUGINS, ServiceLifecycleHook +from localstack.utils.aws import arns +from localstack.utils.aws.arns import extract_account_id_from_arn, lambda_function_name +from localstack.utils.aws.request_context import ( + extract_access_key_id_from_auth_header, + extract_region_from_auth_header, +) +from localstack.utils.patch import patch +from localstack.utils.strings import camel_to_snake_case +from localstack.utils.sync import poll_condition +from localstack.utils.tagging import TaggingService +from localstack.utils.threads import start_worker_thread + +PATH_GET_RAW_METRICS = "/_aws/cloudwatch/metrics/raw" +DEPRECATED_PATH_GET_RAW_METRICS = "/cloudwatch/metrics/raw" +MOTO_INITIAL_UNCHECKED_REASON = "Unchecked: Initial alarm creation" + +LOG = logging.getLogger(__name__) + + +@patch(target=FakeAlarm.update_state) +def update_state(target, self, reason, reason_data, state_value): + if reason_data is None: + reason_data = "" + if self.state_reason == MOTO_INITIAL_UNCHECKED_REASON: + old_state = StateValue.INSUFFICIENT_DATA + else: + old_state = self.state_value + + old_state_reason = self.state_reason + old_state_update_timestamp = self.state_updated_timestamp + target(self, reason, reason_data, state_value) + + # check the state and trigger required actions + if not self.actions_enabled or old_state == self.state_value: + return + if self.state_value == "OK": + actions = self.ok_actions + elif self.state_value == "ALARM": + actions = self.alarm_actions + else: + actions = self.insufficient_data_actions + for action in actions: + data = arns.parse_arn(action) + if data["service"] == "sns": + service = connect_to(region_name=data["region"], aws_access_key_id=data["account"]).sns + subject = f"""{self.state_value}: "{self.name}" in {self.region_name}""" + message = create_message_response_update_state_sns(self, old_state) + service.publish(TopicArn=action, Subject=subject, Message=message) + elif data["service"] == "lambda": + service = connect_to( + region_name=data["region"], aws_access_key_id=data["account"] + ).lambda_ + message = create_message_response_update_state_lambda( + self, old_state, old_state_reason, old_state_update_timestamp + ) + service.invoke(FunctionName=lambda_function_name(action), Payload=message) + else: + # TODO: support other actions + LOG.warning( + "Action for service %s not implemented, action '%s' will not be triggered.", + data["service"], + action, + ) + + +@patch(target=CloudWatchBackend.put_metric_alarm) +def put_metric_alarm( + target, + self, + name: str, + namespace: str, + metric_name: str, + comparison_operator: str, + evaluation_periods: int, + period: int, + threshold: float, + statistic: str, + description: str, + dimensions: list[dict[str, str]], + alarm_actions: list[str], + metric_data_queries: Optional[list[Any]] = None, + datapoints_to_alarm: Optional[int] = None, + extended_statistic: Optional[str] = None, + ok_actions: Optional[list[str]] = None, + insufficient_data_actions: Optional[list[str]] = None, + unit: Optional[str] = None, + actions_enabled: bool = True, + treat_missing_data: Optional[str] = None, + evaluate_low_sample_count_percentile: Optional[str] = None, + threshold_metric_id: Optional[str] = None, + rule: Optional[str] = None, + tags: Optional[list[dict[str, str]]] = None, +) -> FakeAlarm: + if description: + description = escape(description) + return target( + self, + name, + namespace, + metric_name, + comparison_operator, + evaluation_periods, + period, + threshold, + statistic, + description, + dimensions, + alarm_actions, + metric_data_queries, + datapoints_to_alarm, + extended_statistic, + ok_actions, + insufficient_data_actions, + unit, + actions_enabled, + treat_missing_data, + evaluate_low_sample_count_percentile, + threshold_metric_id, + rule, + tags, + ) + + +def create_metric_data_query_from_alarm(alarm: FakeAlarm): + # TODO may need to be adapted for other use cases + # verified return value with a snapshot test + return [ + { + "id": str(uuid.uuid4()), + "metricStat": { + "metric": { + "namespace": alarm.namespace, + "name": alarm.metric_name, + "dimensions": alarm.dimensions or {}, + }, + "period": int(alarm.period), + "stat": alarm.statistic, + }, + "returnData": True, + } + ] + + +def create_message_response_update_state_lambda( + alarm: FakeAlarm, old_state, old_state_reason, old_state_timestamp +): + response = { + "accountId": extract_account_id_from_arn(alarm.alarm_arn), + "alarmArn": alarm.alarm_arn, + "alarmData": { + "alarmName": alarm.name, + "state": { + "value": alarm.state_value, + "reason": alarm.state_reason, + "timestamp": alarm.state_updated_timestamp, + }, + "previousState": { + "value": old_state, + "reason": old_state_reason, + "timestamp": old_state_timestamp, + }, + "configuration": { + "description": alarm.description or "", + "metrics": alarm.metric_data_queries + or create_metric_data_query_from_alarm( + alarm + ), # TODO: add test with metric_data_queries + }, + }, + "time": alarm.state_updated_timestamp, + "region": alarm.region_name, + "source": "aws.cloudwatch", + } + return json.dumps(response) + + +def create_message_response_update_state_sns(alarm, old_state): + response = { + "AWSAccountId": extract_account_id_from_arn(alarm.alarm_arn), + "OldStateValue": old_state, + "AlarmName": alarm.name, + "AlarmDescription": alarm.description or "", + "AlarmConfigurationUpdatedTimestamp": alarm.configuration_updated_timestamp, + "NewStateValue": alarm.state_value, + "NewStateReason": alarm.state_reason, + "StateChangeTime": alarm.state_updated_timestamp, + # the long-name for 'region' should be used - as we don't have it, we use the short name + # which needs to be slightly changed to make snapshot tests work + "Region": alarm.region_name.replace("-", " ").capitalize(), + "AlarmArn": alarm.alarm_arn, + "OKActions": alarm.ok_actions or [], + "AlarmActions": alarm.alarm_actions or [], + "InsufficientDataActions": alarm.insufficient_data_actions or [], + } + + # collect trigger details + details = { + "MetricName": alarm.metric_name or "", + "Namespace": alarm.namespace or "", + "Unit": alarm.unit or None, # testing with AWS revealed this currently returns None + "Period": int(alarm.period) if alarm.period else 0, + "EvaluationPeriods": int(alarm.evaluation_periods) if alarm.evaluation_periods else 0, + "ComparisonOperator": alarm.comparison_operator or "", + "Threshold": float(alarm.threshold) if alarm.threshold else 0.0, + "TreatMissingData": alarm.treat_missing_data or "", + "EvaluateLowSampleCountPercentile": alarm.evaluate_low_sample_count_percentile or "", + } + + # Dimensions not serializable + dimensions = [] + if alarm.dimensions: + for d in alarm.dimensions: + dimensions.append({"value": d.value, "name": d.name}) + + details["Dimensions"] = dimensions or "" + + if alarm.statistic: + details["StatisticType"] = "Statistic" + details["Statistic"] = camel_to_snake_case(alarm.statistic).upper() # AWS returns uppercase + elif alarm.extended_statistic: + details["StatisticType"] = "ExtendedStatistic" + details["ExtendedStatistic"] = alarm.extended_statistic + + response["Trigger"] = details + + return json.dumps(response) + + +class ValidationError(CommonServiceException): + def __init__(self, message: str): + super().__init__("ValidationError", message, 400, True) + + +def _set_alarm_actions(context, alarm_names, enabled): + backend = cloudwatch_backends[context.account_id][context.region] + for name in alarm_names: + alarm = backend.alarms.get(name) + if alarm: + alarm.actions_enabled = enabled + + +def _cleanup_describe_output(alarm): + if "Metrics" in alarm and len(alarm["Metrics"]) == 0: + alarm.pop("Metrics") + reason_data = alarm.get("StateReasonData") + if reason_data is not None and reason_data in ("{}", ""): + alarm.pop("StateReasonData") + if ( + alarm.get("StateReason", "") == MOTO_INITIAL_UNCHECKED_REASON + and alarm.get("StateValue") != StateValue.INSUFFICIENT_DATA + ): + alarm["StateValue"] = StateValue.INSUFFICIENT_DATA + + +class CloudwatchProvider(CloudwatchApi, ServiceLifecycleHook): + """ + Cloudwatch provider. + + LIMITATIONS: + - no alarm rule evaluation + """ + + def __init__(self): + self.tags = TaggingService() + self.alarm_scheduler = None + + def on_after_init(self): + ROUTER.add(PATH_GET_RAW_METRICS, self.get_raw_metrics) + self.start_alarm_scheduler() + + def on_before_state_reset(self): + self.shutdown_alarm_scheduler() + + def on_after_state_reset(self): + self.start_alarm_scheduler() + + def on_before_state_load(self): + self.shutdown_alarm_scheduler() + + def on_after_state_load(self): + self.start_alarm_scheduler() + + def restart_alarms(*args): + poll_condition(lambda: SERVICE_PLUGINS.is_running("cloudwatch")) + self.alarm_scheduler.restart_existing_alarms() + + start_worker_thread(restart_alarms) + + def on_before_stop(self): + self.shutdown_alarm_scheduler() + + def start_alarm_scheduler(self): + if not self.alarm_scheduler: + LOG.debug("starting cloudwatch scheduler") + self.alarm_scheduler = AlarmScheduler() + + def shutdown_alarm_scheduler(self): + LOG.debug("stopping cloudwatch scheduler") + self.alarm_scheduler.shutdown_scheduler() + self.alarm_scheduler = None + + def delete_alarms(self, context: RequestContext, alarm_names: AlarmNames, **kwargs) -> None: + moto.call_moto(context) + for alarm_name in alarm_names: + arn = arns.cloudwatch_alarm_arn(alarm_name, context.account_id, context.region) + self.alarm_scheduler.delete_scheduler_for_alarm(arn) + + def get_raw_metrics(self, request: Request): + region = extract_region_from_auth_header(request.headers) + account_id = ( + get_account_id_from_access_key_id( + extract_access_key_id_from_auth_header(request.headers) + ) + or DEFAULT_AWS_ACCOUNT_ID + ) + backend = cloudwatch_backends[account_id][region] + if backend: + result = [m for m in backend.metric_data if isinstance(m, MetricDatum)] + # TODO handle aggregated metrics as well (MetricAggregatedDatum) + else: + result = [] + + result = [ + { + "ns": r.namespace, + "n": r.name, + "v": r.value, + "t": r.timestamp, + "d": [{"n": d.name, "v": d.value} for d in r.dimensions], + "account": account_id, + "region": region, + } + for r in result + ] + return {"metrics": result} + + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs + ) -> ListTagsForResourceOutput: + tags = self.tags.list_tags_for_resource(resource_arn) + return ListTagsForResourceOutput(Tags=tags.get("Tags", [])) + + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagResourceOutput: + self.tags.untag_resource(resource_arn, tag_keys) + return UntagResourceOutput() + + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: TagList, **kwargs + ) -> TagResourceOutput: + self.tags.tag_resource(resource_arn, tags) + return TagResourceOutput() + + @handler("GetMetricData", expand=False) + def get_metric_data( + self, context: RequestContext, request: GetMetricDataInput + ) -> GetMetricDataOutput: + result = moto.call_moto(context) + # moto currently uses hardcoded label metric_name + stat + # parity tests shows that default is MetricStat, but there might also be a label explicitly set + metric_data_queries = request["MetricDataQueries"] + for i in range(0, len(metric_data_queries)): + metric_query = metric_data_queries[i] + label = metric_query.get("Label") or metric_query.get("MetricStat", {}).get( + "Metric", {} + ).get("MetricName") + if label: + result["MetricDataResults"][i]["Label"] = label + if "Messages" not in result: + # parity tests reveals that an empty messages list is added + result["Messages"] = [] + return result + + @handler("PutMetricAlarm", expand=False) + def put_metric_alarm( + self, + context: RequestContext, + request: PutMetricAlarmInput, + ) -> None: + # missing will be the default, when not set (but it will not explicitly be set) + if request.get("TreatMissingData", "missing") not in [ + "breaching", + "notBreaching", + "ignore", + "missing", + ]: + raise ValidationError( + f"The value {request['TreatMissingData']} is not supported for TreatMissingData parameter. Supported values are [breaching, notBreaching, ignore, missing]." + ) + # do some sanity checks: + if request.get("Period"): + # Valid values are 10, 30, and any multiple of 60. + value = request.get("Period") + if value not in (10, 30): + if value % 60 != 0: + raise ValidationError("Period must be 10, 30 or a multiple of 60") + if request.get("Statistic"): + if request.get("Statistic") not in [ + "SampleCount", + "Average", + "Sum", + "Minimum", + "Maximum", + ]: + raise ValidationError( + f"Value '{request.get('Statistic')}' at 'statistic' failed to satisfy constraint: Member must satisfy enum value set: [Maximum, SampleCount, Sum, Minimum, Average]" + ) + + moto.call_moto(context) + + name = request.get("AlarmName") + arn = arns.cloudwatch_alarm_arn(name, context.account_id, context.region) + self.tags.tag_resource(arn, request.get("Tags")) + self.alarm_scheduler.schedule_metric_alarm(arn) + + @handler("PutCompositeAlarm", expand=False) + def put_composite_alarm( + self, + context: RequestContext, + request: PutCompositeAlarmInput, + ) -> None: + backend = cloudwatch_backends[context.account_id][context.region] + backend.put_metric_alarm( + name=request.get("AlarmName"), + namespace=None, + metric_name=None, + metric_data_queries=None, + comparison_operator=None, + evaluation_periods=None, + datapoints_to_alarm=None, + period=None, + threshold=None, + statistic=None, + extended_statistic=None, + description=request.get("AlarmDescription"), + dimensions=[], + alarm_actions=request.get("AlarmActions", []), + ok_actions=request.get("OKActions", []), + insufficient_data_actions=request.get("InsufficientDataActions", []), + unit=None, + actions_enabled=request.get("ActionsEnabled"), + treat_missing_data=None, + evaluate_low_sample_count_percentile=None, + threshold_metric_id=None, + rule=request.get("AlarmRule"), + tags=request.get("Tags", []), + ) + LOG.warning( + "Composite Alarms configuration is not yet supported, alarm state will not be evaluated" + ) + + @handler("EnableAlarmActions") + def enable_alarm_actions( + self, context: RequestContext, alarm_names: AlarmNames, **kwargs + ) -> None: + _set_alarm_actions(context, alarm_names, enabled=True) + + @handler("DisableAlarmActions") + def disable_alarm_actions( + self, context: RequestContext, alarm_names: AlarmNames, **kwargs + ) -> None: + _set_alarm_actions(context, alarm_names, enabled=False) + + @handler("DescribeAlarms", expand=False) + def describe_alarms( + self, context: RequestContext, request: DescribeAlarmsInput + ) -> DescribeAlarmsOutput: + response = moto.call_moto(context) + + for c in response["CompositeAlarms"]: + _cleanup_describe_output(c) + for m in response["MetricAlarms"]: + _cleanup_describe_output(m) + + return response + + @handler("GetMetricStatistics", expand=False) + def get_metric_statistics( + self, context: RequestContext, request: GetMetricStatisticsInput + ) -> GetMetricStatisticsOutput: + response = moto.call_moto(context) + + # cleanup -> ExtendendStatics is not included in AWS response if it returned empty + for datapoint in response.get("Datapoints"): + if "ExtendedStatistics" in datapoint and not datapoint.get("ExtendedStatistics"): + datapoint.pop("ExtendedStatistics") + + return response diff --git a/localstack-core/localstack/services/cloudwatch/provider_v2.py b/localstack-core/localstack/services/cloudwatch/provider_v2.py new file mode 100644 index 0000000000000..31f737fec9e23 --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/provider_v2.py @@ -0,0 +1,1110 @@ +import datetime +import json +import logging +import re +import threading +import uuid +from datetime import timezone +from typing import List + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.cloudwatch import ( + AccountId, + ActionPrefix, + AlarmName, + AlarmNamePrefix, + AlarmNames, + AlarmTypes, + AmazonResourceName, + CloudwatchApi, + DashboardBody, + DashboardName, + DashboardNamePrefix, + DashboardNames, + Datapoint, + DeleteDashboardsOutput, + DescribeAlarmHistoryOutput, + DescribeAlarmsForMetricOutput, + DescribeAlarmsOutput, + DimensionFilters, + Dimensions, + EntityMetricDataList, + ExtendedStatistic, + ExtendedStatistics, + GetDashboardOutput, + GetMetricDataMaxDatapoints, + GetMetricDataOutput, + GetMetricStatisticsOutput, + HistoryItemType, + IncludeLinkedAccounts, + InvalidParameterCombinationException, + InvalidParameterValueException, + LabelOptions, + ListDashboardsOutput, + ListMetricsOutput, + ListTagsForResourceOutput, + MaxRecords, + MetricData, + MetricDataQueries, + MetricDataQuery, + MetricDataResult, + MetricDataResultMessages, + MetricName, + MetricStat, + Namespace, + NextToken, + Period, + PutCompositeAlarmInput, + PutDashboardOutput, + PutMetricAlarmInput, + RecentlyActive, + ResourceNotFound, + ScanBy, + StandardUnit, + StateReason, + StateReasonData, + StateValue, + Statistic, + Statistics, + StrictEntityValidation, + TagKeyList, + TagList, + TagResourceOutput, + Timestamp, + UntagResourceOutput, +) +from localstack.aws.connect import connect_to +from localstack.http import Request +from localstack.services.cloudwatch.alarm_scheduler import AlarmScheduler +from localstack.services.cloudwatch.cloudwatch_database_helper import CloudwatchDatabase +from localstack.services.cloudwatch.models import ( + CloudWatchStore, + LocalStackAlarm, + LocalStackCompositeAlarm, + LocalStackDashboard, + LocalStackMetricAlarm, + cloudwatch_stores, +) +from localstack.services.edge import ROUTER +from localstack.services.plugins import SERVICE_PLUGINS, ServiceLifecycleHook +from localstack.state import AssetDirectory, StateVisitor +from localstack.utils.aws import arns +from localstack.utils.aws.arns import extract_account_id_from_arn, lambda_function_name +from localstack.utils.collections import PaginatedList +from localstack.utils.json import CustomEncoder as JSONEncoder +from localstack.utils.strings import camel_to_snake_case +from localstack.utils.sync import poll_condition +from localstack.utils.threads import start_worker_thread +from localstack.utils.time import timestamp_millis + +PATH_GET_RAW_METRICS = "/_aws/cloudwatch/metrics/raw" +MOTO_INITIAL_UNCHECKED_REASON = "Unchecked: Initial alarm creation" +LIST_METRICS_MAX_RESULTS = 500 +# If the values in these fields are not the same, their values are added when generating labels +LABEL_DIFFERENTIATORS = ["Stat", "Period"] +HISTORY_VERSION = "1.0" + +LOG = logging.getLogger(__name__) +_STORE_LOCK = threading.RLock() +AWS_MAX_DATAPOINTS_ACCEPTED: int = 1440 + + +class ValidationError(CommonServiceException): + # TODO: check this error against AWS (doesn't exist in the API) + def __init__(self, message: str): + super().__init__("ValidationError", message, 400, True) + + +class InvalidParameterCombination(CommonServiceException): + def __init__(self, message: str): + super().__init__("InvalidParameterCombination", message, 400, True) + + +def _validate_parameters_for_put_metric_data(metric_data: MetricData) -> None: + for index, metric_item in enumerate(metric_data): + indexplusone = index + 1 + if metric_item.get("Value") and metric_item.get("Values"): + raise InvalidParameterCombinationException( + f"The parameters MetricData.member.{indexplusone}.Value and MetricData.member.{indexplusone}.Values are mutually exclusive and you have specified both." + ) + + if metric_item.get("StatisticValues") and metric_item.get("Value"): + raise InvalidParameterCombinationException( + f"The parameters MetricData.member.{indexplusone}.Value and MetricData.member.{indexplusone}.StatisticValues are mutually exclusive and you have specified both." + ) + + if metric_item.get("Values") and metric_item.get("Counts"): + values = metric_item.get("Values") + counts = metric_item.get("Counts") + if len(values) != len(counts): + raise InvalidParameterValueException( + f"The parameters MetricData.member.{indexplusone}.Values and MetricData.member.{indexplusone}.Counts must be of the same size." + ) + + +class CloudwatchProvider(CloudwatchApi, ServiceLifecycleHook): + """ + Cloudwatch provider. + + LIMITATIONS: + - simplified composite alarm rule evaluation: + - only OR operator is supported + - only ALARM expression is supported + - only metric alarms can be included in the rule and they should be referenced by ARN only + """ + + def __init__(self): + self.alarm_scheduler: AlarmScheduler = None + self.store = None + self.cloudwatch_database = CloudwatchDatabase() + + @staticmethod + def get_store(account_id: str, region: str) -> CloudWatchStore: + return cloudwatch_stores[account_id][region] + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(cloudwatch_stores) + visitor.visit(AssetDirectory(self.service, CloudwatchDatabase.CLOUDWATCH_DATA_ROOT)) + + def on_after_init(self): + ROUTER.add(PATH_GET_RAW_METRICS, self.get_raw_metrics) + self.start_alarm_scheduler() + + def on_before_state_reset(self): + self.shutdown_alarm_scheduler() + self.cloudwatch_database.clear_tables() + + def on_after_state_reset(self): + self.cloudwatch_database = CloudwatchDatabase() + self.start_alarm_scheduler() + + def on_before_state_load(self): + self.shutdown_alarm_scheduler() + + def on_after_state_load(self): + self.start_alarm_scheduler() + + def restart_alarms(*args): + poll_condition(lambda: SERVICE_PLUGINS.is_running("cloudwatch")) + self.alarm_scheduler.restart_existing_alarms() + + start_worker_thread(restart_alarms) + + def on_before_stop(self): + self.shutdown_alarm_scheduler() + + def start_alarm_scheduler(self): + if not self.alarm_scheduler: + LOG.debug("starting cloudwatch scheduler") + self.alarm_scheduler = AlarmScheduler() + + def shutdown_alarm_scheduler(self): + LOG.debug("stopping cloudwatch scheduler") + self.alarm_scheduler.shutdown_scheduler() + self.alarm_scheduler = None + + def delete_alarms(self, context: RequestContext, alarm_names: AlarmNames, **kwargs) -> None: + """ + Delete alarms. + """ + with _STORE_LOCK: + for alarm_name in alarm_names: + alarm_arn = arns.cloudwatch_alarm_arn( + alarm_name, account_id=context.account_id, region_name=context.region + ) # obtain alarm ARN from alarm name + self.alarm_scheduler.delete_scheduler_for_alarm(alarm_arn) + store = self.get_store(context.account_id, context.region) + store.alarms.pop(alarm_arn, None) + + def put_metric_data( + self, + context: RequestContext, + namespace: Namespace, + metric_data: MetricData = None, + entity_metric_data: EntityMetricDataList = None, + strict_entity_validation: StrictEntityValidation = None, + **kwargs, + ) -> None: + # TODO add support for entity_metric_data and strict_entity_validation + _validate_parameters_for_put_metric_data(metric_data) + + self.cloudwatch_database.add_metric_data( + context.account_id, context.region, namespace, metric_data + ) + + def get_metric_data( + self, + context: RequestContext, + metric_data_queries: MetricDataQueries, + start_time: Timestamp, + end_time: Timestamp, + next_token: NextToken = None, + scan_by: ScanBy = None, + max_datapoints: GetMetricDataMaxDatapoints = None, + label_options: LabelOptions = None, + **kwargs, + ) -> GetMetricDataOutput: + results: List[MetricDataResult] = [] + limit = max_datapoints or 100_800 + messages: MetricDataResultMessages = [] + nxt = None + label_additions = [] + + for diff in LABEL_DIFFERENTIATORS: + non_unique = [] + for query in metric_data_queries: + non_unique.append(query["MetricStat"][diff]) + if len(set(non_unique)) > 1: + label_additions.append(diff) + + for query in metric_data_queries: + query_result = self.cloudwatch_database.get_metric_data_stat( + account_id=context.account_id, + region=context.region, + query=query, + start_time=start_time, + end_time=end_time, + scan_by=scan_by, + ) + if query_result.get("messages"): + messages.extend(query_result.get("messages")) + + label = query.get("Label") or f"{query['MetricStat']['Metric']['MetricName']}" + # TODO: does this happen even if a label is set in the query? + for label_addition in label_additions: + label = f"{label} {query['MetricStat'][label_addition]}" + + timestamps = query_result.get("timestamps", {}) + values = query_result.get("values", {}) + + # Paginate + timestamp_value_dicts = [ + { + "Timestamp": timestamp, + "Value": value, + } + for timestamp, value in zip(timestamps, values, strict=False) + ] + + pagination = PaginatedList(timestamp_value_dicts) + timestamp_page, nxt = pagination.get_page( + lambda item: item.get("Timestamp"), + next_token=next_token, + page_size=limit, + ) + + timestamps = [item.get("Timestamp") for item in timestamp_page] + values = [item.get("Value") for item in timestamp_page] + + metric_data_result = { + "Id": query.get("Id"), + "Label": label, + "StatusCode": "Complete", + "Timestamps": timestamps, + "Values": values, + } + results.append(MetricDataResult(**metric_data_result)) + + return GetMetricDataOutput(MetricDataResults=results, NextToken=nxt, Messages=messages) + + def set_alarm_state( + self, + context: RequestContext, + alarm_name: AlarmName, + state_value: StateValue, + state_reason: StateReason, + state_reason_data: StateReasonData = None, + **kwargs, + ) -> None: + try: + if state_reason_data: + state_reason_data = json.loads(state_reason_data) + except ValueError: + raise InvalidParameterValueException( + "TODO: check right error message: Json was not correctly formatted" + ) + with _STORE_LOCK: + store = self.get_store(context.account_id, context.region) + alarm = store.alarms.get( + arns.cloudwatch_alarm_arn( + alarm_name, account_id=context.account_id, region_name=context.region + ) + ) + if not alarm: + raise ResourceNotFound() + + old_state = alarm.alarm["StateValue"] + if state_value not in ("OK", "ALARM", "INSUFFICIENT_DATA"): + raise ValidationError( + f"1 validation error detected: Value '{state_value}' at 'stateValue' failed to satisfy constraint: Member must satisfy enum value set: [INSUFFICIENT_DATA, ALARM, OK]" + ) + + old_state_reason = alarm.alarm["StateReason"] + old_state_update_timestamp = alarm.alarm["StateUpdatedTimestamp"] + + if old_state == state_value: + return + + alarm.alarm["StateTransitionedTimestamp"] = datetime.datetime.now(timezone.utc) + # update startDate (=last ALARM date) - should only update when a new alarm is triggered + # the date is only updated if we have a reason-data, which is set by an alarm + if state_reason_data: + state_reason_data["startDate"] = state_reason_data.get("queryDate") + + self._update_state( + context, + alarm, + state_value, + state_reason, + state_reason_data, + ) + + self._evaluate_composite_alarms(context, alarm) + + if not alarm.alarm["ActionsEnabled"]: + return + if state_value == "OK": + actions = alarm.alarm["OKActions"] + elif state_value == "ALARM": + actions = alarm.alarm["AlarmActions"] + else: + actions = alarm.alarm["InsufficientDataActions"] + for action in actions: + data = arns.parse_arn(action) + # test for sns - can this be done in a more generic way? + if data["service"] == "sns": + service = connect_to( + region_name=data["region"], aws_access_key_id=data["account"] + ).sns + subject = f"""{state_value}: "{alarm_name}" in {context.region}""" + message = create_message_response_update_state_sns(alarm, old_state) + service.publish(TopicArn=action, Subject=subject, Message=message) + elif data["service"] == "lambda": + service = connect_to( + region_name=data["region"], aws_access_key_id=data["account"] + ).lambda_ + message = create_message_response_update_state_lambda( + alarm, old_state, old_state_reason, old_state_update_timestamp + ) + service.invoke(FunctionName=lambda_function_name(action), Payload=message) + else: + # TODO: support other actions + LOG.warning( + "Action for service %s not implemented, action '%s' will not be triggered.", + data["service"], + action, + ) + + def get_raw_metrics(self, request: Request): + """this feature was introduced with https://github.com/localstack/localstack/pull/3535 + # in the meantime, it required a valid aws-header so that the account-id/region could be extracted + # with the new implementation, we want to return all data, but add the account-id/region as additional attributes + + # TODO endpoint should be refactored or deprecated at some point + # - result should be paginated + # - include aggregated metrics (but we would also need to change/adapt the shape of "metrics" that we return) + :returns: json {"metrics": [{"ns": "namespace", "n": "metric_name", "v": value, "t": timestamp, + "d": [],"account": account, "region": region}]} + """ + return {"metrics": self.cloudwatch_database.get_all_metric_data() or []} + + @handler("PutMetricAlarm", expand=False) + def put_metric_alarm(self, context: RequestContext, request: PutMetricAlarmInput) -> None: + # missing will be the default, when not set (but it will not explicitly be set) + if request.get("TreatMissingData", "missing") not in [ + "breaching", + "notBreaching", + "ignore", + "missing", + ]: + raise ValidationError( + f"The value {request['TreatMissingData']} is not supported for TreatMissingData parameter. Supported values are [breaching, notBreaching, ignore, missing]." + ) + # do some sanity checks: + if request.get("Period"): + # Valid values are 10, 30, and any multiple of 60. + value = request.get("Period") + if value not in (10, 30): + if value % 60 != 0: + raise ValidationError("Period must be 10, 30 or a multiple of 60") + if request.get("Statistic"): + if request.get("Statistic") not in [ + "SampleCount", + "Average", + "Sum", + "Minimum", + "Maximum", + ]: + raise ValidationError( + f"Value '{request.get('Statistic')}' at 'statistic' failed to satisfy constraint: Member must satisfy enum value set: [Maximum, SampleCount, Sum, Minimum, Average]" + ) + + extended_statistic = request.get("ExtendedStatistic") + if extended_statistic and not extended_statistic.startswith("p"): + raise InvalidParameterValueException( + f"The value {extended_statistic} for parameter ExtendedStatistic is not supported." + ) + evaluate_low_sample_count_percentile = request.get("EvaluateLowSampleCountPercentile") + if evaluate_low_sample_count_percentile and evaluate_low_sample_count_percentile not in ( + "evaluate", + "ignore", + ): + raise ValidationError( + f"Option {evaluate_low_sample_count_percentile} is not supported. " + "Supported options for parameter EvaluateLowSampleCountPercentile are evaluate and ignore." + ) + with _STORE_LOCK: + store = self.get_store(context.account_id, context.region) + metric_alarm = LocalStackMetricAlarm(context.account_id, context.region, {**request}) + alarm_arn = metric_alarm.alarm["AlarmArn"] + store.alarms[alarm_arn] = metric_alarm + self.alarm_scheduler.schedule_metric_alarm(alarm_arn) + + @handler("PutCompositeAlarm", expand=False) + def put_composite_alarm(self, context: RequestContext, request: PutCompositeAlarmInput) -> None: + with _STORE_LOCK: + store = self.get_store(context.account_id, context.region) + composite_alarm = LocalStackCompositeAlarm( + context.account_id, context.region, {**request} + ) + + alarm_rule = composite_alarm.alarm["AlarmRule"] + rule_expression_validation_result = self._validate_alarm_rule_expression(alarm_rule) + [LOG.warning(w) for w in rule_expression_validation_result] + + alarm_arn = composite_alarm.alarm["AlarmArn"] + store.alarms[alarm_arn] = composite_alarm + + def describe_alarms( + self, + context: RequestContext, + alarm_names: AlarmNames = None, + alarm_name_prefix: AlarmNamePrefix = None, + alarm_types: AlarmTypes = None, + children_of_alarm_name: AlarmName = None, + parents_of_alarm_name: AlarmName = None, + state_value: StateValue = None, + action_prefix: ActionPrefix = None, + max_records: MaxRecords = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeAlarmsOutput: + store = self.get_store(context.account_id, context.region) + alarms = list(store.alarms.values()) + if action_prefix: + alarms = [a.alarm for a in alarms if a.alarm["AlarmAction"].startswith(action_prefix)] + elif alarm_name_prefix: + alarms = [a.alarm for a in alarms if a.alarm["AlarmName"].startswith(alarm_name_prefix)] + elif alarm_names: + alarms = [a.alarm for a in alarms if a.alarm["AlarmName"] in alarm_names] + elif state_value: + alarms = [a.alarm for a in alarms if a.alarm["StateValue"] == state_value] + else: + alarms = [a.alarm for a in list(store.alarms.values())] + + # TODO: Pagination + metric_alarms = [a for a in alarms if a.get("AlarmRule") is None] + composite_alarms = [a for a in alarms if a.get("AlarmRule") is not None] + return DescribeAlarmsOutput(CompositeAlarms=composite_alarms, MetricAlarms=metric_alarms) + + def describe_alarms_for_metric( + self, + context: RequestContext, + metric_name: MetricName, + namespace: Namespace, + statistic: Statistic = None, + extended_statistic: ExtendedStatistic = None, + dimensions: Dimensions = None, + period: Period = None, + unit: StandardUnit = None, + **kwargs, + ) -> DescribeAlarmsForMetricOutput: + store = self.get_store(context.account_id, context.region) + alarms = [ + a.alarm + for a in store.alarms.values() + if isinstance(a, LocalStackMetricAlarm) + and a.alarm.get("MetricName") == metric_name + and a.alarm.get("Namespace") == namespace + ] + + if statistic: + alarms = [a for a in alarms if a.get("Statistic") == statistic] + if dimensions: + alarms = [a for a in alarms if a.get("Dimensions") == dimensions] + if period: + alarms = [a for a in alarms if a.get("Period") == period] + if unit: + alarms = [a for a in alarms if a.get("Unit") == unit] + return DescribeAlarmsForMetricOutput(MetricAlarms=alarms) + + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs + ) -> ListTagsForResourceOutput: + store = self.get_store(context.account_id, context.region) + tags = store.TAGS.list_tags_for_resource(resource_arn) + return ListTagsForResourceOutput(Tags=tags.get("Tags", [])) + + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagResourceOutput: + store = self.get_store(context.account_id, context.region) + store.TAGS.untag_resource(resource_arn, tag_keys) + return UntagResourceOutput() + + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: TagList, **kwargs + ) -> TagResourceOutput: + store = self.get_store(context.account_id, context.region) + store.TAGS.tag_resource(resource_arn, tags) + return TagResourceOutput() + + def put_dashboard( + self, + context: RequestContext, + dashboard_name: DashboardName, + dashboard_body: DashboardBody, + **kwargs, + ) -> PutDashboardOutput: + pattern = r"^[a-zA-Z0-9_-]+$" + if not re.match(pattern, dashboard_name): + raise InvalidParameterValueException( + "The value for field DashboardName contains invalid characters. " + "It can only contain alphanumerics, dash (-) and underscore (_).\n" + ) + + store = self.get_store(context.account_id, context.region) + store.dashboards[dashboard_name] = LocalStackDashboard( + context.account_id, context.region, dashboard_name, dashboard_body + ) + return PutDashboardOutput() + + def get_dashboard( + self, context: RequestContext, dashboard_name: DashboardName, **kwargs + ) -> GetDashboardOutput: + store = self.get_store(context.account_id, context.region) + dashboard = store.dashboards.get(dashboard_name) + if not dashboard: + raise InvalidParameterValueException(f"Dashboard {dashboard_name} does not exist.") + + return GetDashboardOutput( + DashboardName=dashboard_name, + DashboardBody=dashboard.dashboard_body, + DashboardArn=dashboard.dashboard_arn, + ) + + def delete_dashboards( + self, context: RequestContext, dashboard_names: DashboardNames, **kwargs + ) -> DeleteDashboardsOutput: + store = self.get_store(context.account_id, context.region) + for dashboard_name in dashboard_names: + store.dashboards.pop(dashboard_name, None) + return DeleteDashboardsOutput() + + def list_dashboards( + self, + context: RequestContext, + dashboard_name_prefix: DashboardNamePrefix = None, + next_token: NextToken = None, + **kwargs, + ) -> ListDashboardsOutput: + store = self.get_store(context.account_id, context.region) + dashboard_names = list(store.dashboards.keys()) + dashboard_names = [ + name for name in dashboard_names if name.startswith(dashboard_name_prefix or "") + ] + + entries = [ + { + "DashboardName": name, + "DashboardArn": store.dashboards[name].dashboard_arn, + "LastModified": store.dashboards[name].last_modified, + "Size": store.dashboards[name].size, + } + for name in dashboard_names + ] + return ListDashboardsOutput( + DashboardEntries=entries, + ) + + def list_metrics( + self, + context: RequestContext, + namespace: Namespace = None, + metric_name: MetricName = None, + dimensions: DimensionFilters = None, + next_token: NextToken = None, + recently_active: RecentlyActive = None, + include_linked_accounts: IncludeLinkedAccounts = None, + owning_account: AccountId = None, + **kwargs, + ) -> ListMetricsOutput: + result = self.cloudwatch_database.list_metrics( + context.account_id, + context.region, + namespace, + metric_name, + dimensions or [], + ) + + metrics = [ + { + "Namespace": metric.get("namespace"), + "MetricName": metric.get("metric_name"), + "Dimensions": metric.get("dimensions"), + } + for metric in result.get("metrics", []) + ] + aliases_list = PaginatedList(metrics) + page, nxt = aliases_list.get_page( + lambda metric: f"{metric.get('Namespace')}-{metric.get('MetricName')}-{metric.get('Dimensions')}", + next_token=next_token, + page_size=LIST_METRICS_MAX_RESULTS, + ) + return ListMetricsOutput(Metrics=page, NextToken=nxt) + + def get_metric_statistics( + self, + context: RequestContext, + namespace: Namespace, + metric_name: MetricName, + start_time: Timestamp, + end_time: Timestamp, + period: Period, + dimensions: Dimensions = None, + statistics: Statistics = None, + extended_statistics: ExtendedStatistics = None, + unit: StandardUnit = None, + **kwargs, + ) -> GetMetricStatisticsOutput: + start_time_unix = int(start_time.timestamp()) + end_time_unix = int(end_time.timestamp()) + + if not start_time_unix < end_time_unix: + raise InvalidParameterValueException( + "The parameter StartTime must be less than the parameter EndTime." + ) + + expected_datapoints = (end_time_unix - start_time_unix) / period + + if expected_datapoints > AWS_MAX_DATAPOINTS_ACCEPTED: + raise InvalidParameterCombination( + f"You have requested up to {int(expected_datapoints)} datapoints, which exceeds the limit of {AWS_MAX_DATAPOINTS_ACCEPTED}. " + f"You may reduce the datapoints requested by increasing Period, or decreasing the time range." + ) + + stat_datapoints = {} + + units = ( + [unit] + if unit + else self.cloudwatch_database.get_units_for_metric_data_stat( + account_id=context.account_id, + region=context.region, + start_time=start_time, + end_time=end_time, + metric_name=metric_name, + namespace=namespace, + ) + ) + + for stat in statistics: + for selected_unit in units: + query_result = self.cloudwatch_database.get_metric_data_stat( + account_id=context.account_id, + region=context.region, + start_time=start_time, + end_time=end_time, + scan_by="TimestampDescending", + query=MetricDataQuery( + MetricStat=MetricStat( + Metric={ + "MetricName": metric_name, + "Namespace": namespace, + "Dimensions": dimensions or [], + }, + Period=period, + Stat=stat, + Unit=selected_unit, + ) + ), + ) + + timestamps = query_result.get("timestamps", []) + values = query_result.get("values", []) + for i, timestamp in enumerate(timestamps): + stat_datapoints.setdefault(selected_unit, {}) + stat_datapoints[selected_unit].setdefault(timestamp, {}) + stat_datapoints[selected_unit][timestamp][stat] = values[i] + stat_datapoints[selected_unit][timestamp]["Unit"] = selected_unit + + datapoints: list[Datapoint] = [] + for selected_unit, results in stat_datapoints.items(): + for timestamp, stats in results.items(): + datapoints.append( + Datapoint( + Timestamp=timestamp, + SampleCount=stats.get("SampleCount"), + Average=stats.get("Average"), + Sum=stats.get("Sum"), + Minimum=stats.get("Minimum"), + Maximum=stats.get("Maximum"), + Unit="None" if selected_unit == "NULL_VALUE" else selected_unit, + ) + ) + + return GetMetricStatisticsOutput(Datapoints=datapoints, Label=metric_name) + + def _update_state( + self, + context: RequestContext, + alarm: LocalStackAlarm, + state_value: str, + state_reason: str, + state_reason_data: dict = None, + ): + old_state = alarm.alarm["StateValue"] + old_state_reason = alarm.alarm["StateReason"] + store = self.get_store(context.account_id, context.region) + current_time = datetime.datetime.now() + # version is not present in state reason data for composite alarm, hence the check + if state_reason_data and isinstance(alarm, LocalStackMetricAlarm): + state_reason_data["version"] = HISTORY_VERSION + history_data = { + "version": HISTORY_VERSION, + "oldState": {"stateValue": old_state, "stateReason": old_state_reason}, + "newState": { + "stateValue": state_value, + "stateReason": state_reason, + "stateReasonData": state_reason_data, + }, + } + store.histories.append( + { + "Timestamp": timestamp_millis(alarm.alarm["StateUpdatedTimestamp"]), + "HistoryItemType": HistoryItemType.StateUpdate, + "AlarmName": alarm.alarm["AlarmName"], + "HistoryData": json.dumps(history_data), + "HistorySummary": f"Alarm updated from {old_state} to {state_value}", + "AlarmType": "MetricAlarm" + if isinstance(alarm, LocalStackMetricAlarm) + else "CompositeAlarm", + } + ) + alarm.alarm["StateValue"] = state_value + alarm.alarm["StateReason"] = state_reason + if state_reason_data: + alarm.alarm["StateReasonData"] = json.dumps(state_reason_data) + alarm.alarm["StateUpdatedTimestamp"] = current_time + + def disable_alarm_actions( + self, context: RequestContext, alarm_names: AlarmNames, **kwargs + ) -> None: + self._set_alarm_actions(context, alarm_names, enabled=False) + + def enable_alarm_actions( + self, context: RequestContext, alarm_names: AlarmNames, **kwargs + ) -> None: + self._set_alarm_actions(context, alarm_names, enabled=True) + + def _set_alarm_actions(self, context, alarm_names, enabled): + store = self.get_store(context.account_id, context.region) + for name in alarm_names: + alarm_arn = arns.cloudwatch_alarm_arn( + name, account_id=context.account_id, region_name=context.region + ) + alarm = store.alarms.get(alarm_arn) + if alarm: + alarm.alarm["ActionsEnabled"] = enabled + + def describe_alarm_history( + self, + context: RequestContext, + alarm_name: AlarmName = None, + alarm_types: AlarmTypes = None, + history_item_type: HistoryItemType = None, + start_date: Timestamp = None, + end_date: Timestamp = None, + max_records: MaxRecords = None, + next_token: NextToken = None, + scan_by: ScanBy = None, + **kwargs, + ) -> DescribeAlarmHistoryOutput: + store = self.get_store(context.account_id, context.region) + history = store.histories + if alarm_name: + history = [h for h in history if h["AlarmName"] == alarm_name] + + def _get_timestamp(input: dict): + if timestamp_string := input.get("Timestamp"): + return datetime.datetime.fromisoformat(timestamp_string) + return None + + if start_date: + history = [h for h in history if (date := _get_timestamp(h)) and date >= start_date] + if end_date: + history = [h for h in history if (date := _get_timestamp(h)) and date <= end_date] + return DescribeAlarmHistoryOutput(AlarmHistoryItems=history) + + def _evaluate_composite_alarms(self, context: RequestContext, triggering_alarm): + # TODO either pass store as a parameter or acquire RLock (with _STORE_LOCK:) + # everything works ok now but better ensure protection of critical section in front of future changes + store = self.get_store(context.account_id, context.region) + alarms = list(store.alarms.values()) + composite_alarms = [a for a in alarms if isinstance(a, LocalStackCompositeAlarm)] + for composite_alarm in composite_alarms: + self._evaluate_composite_alarm(context, composite_alarm, triggering_alarm) + + def _evaluate_composite_alarm(self, context, composite_alarm, triggering_alarm): + store = self.get_store(context.account_id, context.region) + alarm_rule = composite_alarm.alarm["AlarmRule"] + rule_expression_validation = self._validate_alarm_rule_expression(alarm_rule) + if rule_expression_validation: + LOG.warning( + "Alarm rule contains unsupported expressions and will not be evaluated: %s", + rule_expression_validation, + ) + return + new_state_value = StateValue.OK + # assuming that a rule consists only of ALARM evaluations of metric alarms, with OR logic applied + for metric_alarm_arn in self._get_alarm_arns(alarm_rule): + metric_alarm = store.alarms.get(metric_alarm_arn) + if not metric_alarm: + LOG.warning( + "Alarm rule won't be evaluated as there is no alarm with ARN %s", + metric_alarm_arn, + ) + return + if metric_alarm.alarm["StateValue"] == StateValue.ALARM: + triggering_alarm = metric_alarm + new_state_value = StateValue.ALARM + break + old_state_value = composite_alarm.alarm["StateValue"] + if old_state_value == new_state_value: + return + triggering_alarm_arn = triggering_alarm.alarm.get("AlarmArn") + triggering_alarm_state = triggering_alarm.alarm.get("StateValue") + triggering_alarm_state_change_timestamp = triggering_alarm.alarm.get( + "StateTransitionedTimestamp" + ) + state_reason_formatted_timestamp = triggering_alarm_state_change_timestamp.strftime( + "%A %d %B, %Y %H:%M:%S %Z" + ) + state_reason = ( + f"{triggering_alarm_arn} " + f"transitioned to {triggering_alarm_state} " + f"at {state_reason_formatted_timestamp}" + ) + state_reason_data = { + "triggeringAlarms": [ + { + "arn": triggering_alarm_arn, + "state": { + "value": triggering_alarm_state, + "timestamp": timestamp_millis(triggering_alarm_state_change_timestamp), + }, + } + ] + } + self._update_state( + context, composite_alarm, new_state_value, state_reason, state_reason_data + ) + if composite_alarm.alarm["ActionsEnabled"]: + self._run_composite_alarm_actions( + context, composite_alarm, old_state_value, triggering_alarm + ) + + def _validate_alarm_rule_expression(self, alarm_rule): + validation_result = [] + alarms_conditions = [alarm.strip() for alarm in alarm_rule.split("OR")] + for alarm_condition in alarms_conditions: + if not alarm_condition.startswith("ALARM"): + validation_result.append( + f"Unsupported expression in alarm rule condition {alarm_condition}: Only ALARM expression is supported by Localstack as of now" + ) + return validation_result + + def _get_alarm_arns(self, composite_alarm_rule): + # regexp for everything within (" ") + return re.findall(r'\("([^"]*)"\)', composite_alarm_rule) + + def _run_composite_alarm_actions( + self, context, composite_alarm, old_state_value, triggering_alarm + ): + new_state_value = composite_alarm.alarm["StateValue"] + if new_state_value == StateValue.OK: + actions = composite_alarm.alarm["OKActions"] + elif new_state_value == StateValue.ALARM: + actions = composite_alarm.alarm["AlarmActions"] + else: + actions = composite_alarm.alarm["InsufficientDataActions"] + for action in actions: + data = arns.parse_arn(action) + if data["service"] == "sns": + service = connect_to( + region_name=data["region"], aws_access_key_id=data["account"] + ).sns + subject = f"""{new_state_value}: "{composite_alarm.alarm["AlarmName"]}" in {context.region}""" + message = create_message_response_update_composite_alarm_state_sns( + composite_alarm, triggering_alarm, old_state_value + ) + service.publish(TopicArn=action, Subject=subject, Message=message) + else: + # TODO: support other actions + LOG.warning( + "Action for service %s not implemented, action '%s' will not be triggered.", + data["service"], + action, + ) + + +def create_metric_data_query_from_alarm(alarm: LocalStackMetricAlarm): + # TODO may need to be adapted for other use cases + # verified return value with a snapshot test + return [ + { + "id": str(uuid.uuid4()), + "metricStat": { + "metric": { + "namespace": alarm.alarm["Namespace"], + "name": alarm.alarm["MetricName"], + "dimensions": alarm.alarm.get("Dimensions") or {}, + }, + "period": int(alarm.alarm["Period"]), + "stat": alarm.alarm["Statistic"], + }, + "returnData": True, + } + ] + + +def create_message_response_update_state_lambda( + alarm: LocalStackMetricAlarm, old_state, old_state_reason, old_state_timestamp +): + _alarm = alarm.alarm + response = { + "accountId": extract_account_id_from_arn(_alarm["AlarmArn"]), + "alarmArn": _alarm["AlarmArn"], + "alarmData": { + "alarmName": _alarm["AlarmName"], + "state": { + "value": _alarm["StateValue"], + "reason": _alarm["StateReason"], + "timestamp": _alarm["StateUpdatedTimestamp"], + }, + "previousState": { + "value": old_state, + "reason": old_state_reason, + "timestamp": old_state_timestamp, + }, + "configuration": { + "description": _alarm.get("AlarmDescription", ""), + "metrics": _alarm.get( + "Metrics", create_metric_data_query_from_alarm(alarm) + ), # TODO: add test with metric_data_queries + }, + }, + "time": _alarm["StateUpdatedTimestamp"], + "region": alarm.region, + "source": "aws.cloudwatch", + } + return json.dumps(response, cls=JSONEncoder) + + +def create_message_response_update_state_sns(alarm: LocalStackMetricAlarm, old_state: StateValue): + _alarm = alarm.alarm + response = { + "AWSAccountId": alarm.account_id, + "OldStateValue": old_state, + "AlarmName": _alarm["AlarmName"], + "AlarmDescription": _alarm.get("AlarmDescription"), + "AlarmConfigurationUpdatedTimestamp": _alarm["AlarmConfigurationUpdatedTimestamp"], + "NewStateValue": _alarm["StateValue"], + "NewStateReason": _alarm["StateReason"], + "StateChangeTime": _alarm["StateUpdatedTimestamp"], + # the long-name for 'region' should be used - as we don't have it, we use the short name + # which needs to be slightly changed to make snapshot tests work + "Region": alarm.region.replace("-", " ").capitalize(), + "AlarmArn": _alarm["AlarmArn"], + "OKActions": _alarm.get("OKActions", []), + "AlarmActions": _alarm.get("AlarmActions", []), + "InsufficientDataActions": _alarm.get("InsufficientDataActions", []), + } + + # collect trigger details + details = { + "MetricName": _alarm.get("MetricName", ""), + "Namespace": _alarm.get("Namespace", ""), + "Unit": _alarm.get("Unit", None), # testing with AWS revealed this currently returns None + "Period": int(_alarm.get("Period", 0)), + "EvaluationPeriods": int(_alarm.get("EvaluationPeriods", 0)), + "ComparisonOperator": _alarm.get("ComparisonOperator", ""), + "Threshold": float(_alarm.get("Threshold", 0.0)), + "TreatMissingData": _alarm.get("TreatMissingData", ""), + "EvaluateLowSampleCountPercentile": _alarm.get("EvaluateLowSampleCountPercentile", ""), + } + + # Dimensions not serializable + dimensions = [] + alarm_dimensions = _alarm.get("Dimensions", []) + if alarm_dimensions: + for d in _alarm["Dimensions"]: + dimensions.append({"value": d["Value"], "name": d["Name"]}) + details["Dimensions"] = dimensions or "" + + alarm_statistic = _alarm.get("Statistic") + alarm_extended_statistic = _alarm.get("ExtendedStatistic") + + if alarm_statistic: + details["StatisticType"] = "Statistic" + details["Statistic"] = camel_to_snake_case(alarm_statistic).upper() # AWS returns uppercase + elif alarm_extended_statistic: + details["StatisticType"] = "ExtendedStatistic" + details["ExtendedStatistic"] = alarm_extended_statistic + + response["Trigger"] = details + + return json.dumps(response, cls=JSONEncoder) + + +def create_message_response_update_composite_alarm_state_sns( + composite_alarm: LocalStackCompositeAlarm, + triggering_alarm: LocalStackMetricAlarm, + old_state: StateValue, +): + _alarm = composite_alarm.alarm + response = { + "AWSAccountId": composite_alarm.account_id, + "AlarmName": _alarm["AlarmName"], + "AlarmDescription": _alarm.get("AlarmDescription"), + "AlarmRule": _alarm.get("AlarmRule"), + "OldStateValue": old_state, + "NewStateValue": _alarm["StateValue"], + "NewStateReason": _alarm["StateReason"], + "StateChangeTime": _alarm["StateUpdatedTimestamp"], + # the long-name for 'region' should be used - as we don't have it, we use the short name + # which needs to be slightly changed to make snapshot tests work + "Region": composite_alarm.region.replace("-", " ").capitalize(), + "AlarmArn": _alarm["AlarmArn"], + "OKActions": _alarm.get("OKActions", []), + "AlarmActions": _alarm.get("AlarmActions", []), + "InsufficientDataActions": _alarm.get("InsufficientDataActions", []), + } + + triggering_children = [ + { + "Arn": triggering_alarm.alarm.get("AlarmArn"), + "State": { + "Value": triggering_alarm.alarm["StateValue"], + "Timestamp": triggering_alarm.alarm["StateUpdatedTimestamp"], + }, + } + ] + + response["TriggeringChildren"] = triggering_children + + return json.dumps(response, cls=JSONEncoder) diff --git a/localstack-core/localstack/services/cloudwatch/resource_providers/__init__.py b/localstack-core/localstack/services/cloudwatch/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm.py b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm.py new file mode 100644 index 0000000000000..56aa3292de1f4 --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm.py @@ -0,0 +1,194 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class CloudWatchAlarmProperties(TypedDict): + ComparisonOperator: Optional[str] + EvaluationPeriods: Optional[int] + ActionsEnabled: Optional[bool] + AlarmActions: Optional[list[str]] + AlarmDescription: Optional[str] + AlarmName: Optional[str] + Arn: Optional[str] + DatapointsToAlarm: Optional[int] + Dimensions: Optional[list[Dimension]] + EvaluateLowSampleCountPercentile: Optional[str] + ExtendedStatistic: Optional[str] + Id: Optional[str] + InsufficientDataActions: Optional[list[str]] + MetricName: Optional[str] + Metrics: Optional[list[MetricDataQuery]] + Namespace: Optional[str] + OKActions: Optional[list[str]] + Period: Optional[int] + Statistic: Optional[str] + Threshold: Optional[float] + ThresholdMetricId: Optional[str] + TreatMissingData: Optional[str] + Unit: Optional[str] + + +class Dimension(TypedDict): + Name: Optional[str] + Value: Optional[str] + + +class Metric(TypedDict): + Dimensions: Optional[list[Dimension]] + MetricName: Optional[str] + Namespace: Optional[str] + + +class MetricStat(TypedDict): + Metric: Optional[Metric] + Period: Optional[int] + Stat: Optional[str] + Unit: Optional[str] + + +class MetricDataQuery(TypedDict): + Id: Optional[str] + AccountId: Optional[str] + Expression: Optional[str] + Label: Optional[str] + MetricStat: Optional[MetricStat] + Period: Optional[int] + ReturnData: Optional[bool] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CloudWatchAlarmProvider(ResourceProvider[CloudWatchAlarmProperties]): + TYPE = "AWS::CloudWatch::Alarm" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CloudWatchAlarmProperties], + ) -> ProgressEvent[CloudWatchAlarmProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - ComparisonOperator + - EvaluationPeriods + + Create-only properties: + - /properties/AlarmName + + Read-only properties: + - /properties/Id + - /properties/Arn + + + + """ + model = request.desired_state + cloudwatch = request.aws_client_factory.cloudwatch + + if not model.get("AlarmName"): + model["AlarmName"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + create_params = util.select_attributes( + model, + [ + "AlarmName", + "ComparisonOperator", + "EvaluationPeriods", + "Period", + "MetricName", + "Namespace", + "Statistic", + "Threshold", + "ActionsEnabled", + "AlarmActions", + "AlarmDescription", + "DatapointsToAlarm", + "Dimensions", + "EvaluateLowSampleCountPercentile", + "ExtendedStatistic", + "InsufficientDataActions", + "Metrics", + "OKActions", + "ThresholdMetricId", + "TreatMissingData", + "Unit", + ], + ) + + cloudwatch.put_metric_alarm(**create_params) + alarms = cloudwatch.describe_alarms(AlarmNames=[model["AlarmName"]])["MetricAlarms"] + if not alarms: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="Alarm not found", + ) + + alarm = alarms[0] + model["Arn"] = alarm["AlarmArn"] + model["Id"] = alarm["AlarmName"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[CloudWatchAlarmProperties], + ) -> ProgressEvent[CloudWatchAlarmProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CloudWatchAlarmProperties], + ) -> ProgressEvent[CloudWatchAlarmProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + cloud_watch = request.aws_client_factory.cloudwatch + cloud_watch.delete_alarms(AlarmNames=[model["AlarmName"]]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[CloudWatchAlarmProperties], + ) -> ProgressEvent[CloudWatchAlarmProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm.schema.json b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm.schema.json new file mode 100644 index 0000000000000..c30c227e6aff9 --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm.schema.json @@ -0,0 +1,200 @@ +{ + "typeName": "AWS::CloudWatch::Alarm", + "description": "Resource Type definition for AWS::CloudWatch::Alarm", + "additionalProperties": false, + "properties": { + "ThresholdMetricId": { + "type": "string" + }, + "EvaluateLowSampleCountPercentile": { + "type": "string" + }, + "ExtendedStatistic": { + "type": "string" + }, + "ComparisonOperator": { + "type": "string" + }, + "TreatMissingData": { + "type": "string" + }, + "Dimensions": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Dimension" + } + }, + "Period": { + "type": "integer" + }, + "EvaluationPeriods": { + "type": "integer" + }, + "Unit": { + "type": "string" + }, + "Namespace": { + "type": "string" + }, + "OKActions": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "AlarmActions": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "MetricName": { + "type": "string" + }, + "ActionsEnabled": { + "type": "boolean" + }, + "Metrics": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/MetricDataQuery" + } + }, + "AlarmDescription": { + "type": "string" + }, + "AlarmName": { + "type": "string" + }, + "Statistic": { + "type": "string" + }, + "InsufficientDataActions": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "DatapointsToAlarm": { + "type": "integer" + }, + "Threshold": { + "type": "number" + } + }, + "definitions": { + "MetricStat": { + "type": "object", + "additionalProperties": false, + "properties": { + "Period": { + "type": "integer" + }, + "Metric": { + "$ref": "#/definitions/Metric" + }, + "Stat": { + "type": "string" + }, + "Unit": { + "type": "string" + } + }, + "required": [ + "Stat", + "Period", + "Metric" + ] + }, + "Metric": { + "type": "object", + "additionalProperties": false, + "properties": { + "MetricName": { + "type": "string" + }, + "Dimensions": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Dimension" + } + }, + "Namespace": { + "type": "string" + } + } + }, + "Dimension": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Name": { + "type": "string" + } + }, + "required": [ + "Value", + "Name" + ] + }, + "MetricDataQuery": { + "type": "object", + "additionalProperties": false, + "properties": { + "AccountId": { + "type": "string" + }, + "ReturnData": { + "type": "boolean" + }, + "Expression": { + "type": "string" + }, + "Label": { + "type": "string" + }, + "MetricStat": { + "$ref": "#/definitions/MetricStat" + }, + "Period": { + "type": "integer" + }, + "Id": { + "type": "string" + } + }, + "required": [ + "Id" + ] + } + }, + "required": [ + "ComparisonOperator", + "EvaluationPeriods" + ], + "createOnlyProperties": [ + "/properties/AlarmName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/Arn" + ] +} diff --git a/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm_plugin.py b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm_plugin.py new file mode 100644 index 0000000000000..6dfffe39b52a4 --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_alarm_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class CloudWatchAlarmProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CloudWatch::Alarm" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.cloudwatch.resource_providers.aws_cloudwatch_alarm import ( + CloudWatchAlarmProvider, + ) + + self.factory = CloudWatchAlarmProvider diff --git a/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm.py b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm.py new file mode 100644 index 0000000000000..b6ca22b2e9f3f --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm.py @@ -0,0 +1,168 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.strings import str_to_bool + + +class CloudWatchCompositeAlarmProperties(TypedDict): + AlarmRule: Optional[str] + ActionsEnabled: Optional[bool] + ActionsSuppressor: Optional[str] + ActionsSuppressorExtensionPeriod: Optional[int] + ActionsSuppressorWaitPeriod: Optional[int] + AlarmActions: Optional[list[str]] + AlarmDescription: Optional[str] + AlarmName: Optional[str] + Arn: Optional[str] + InsufficientDataActions: Optional[list[str]] + OKActions: Optional[list[str]] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class CloudWatchCompositeAlarmProvider(ResourceProvider[CloudWatchCompositeAlarmProperties]): + TYPE = "AWS::CloudWatch::CompositeAlarm" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[CloudWatchCompositeAlarmProperties], + ) -> ProgressEvent[CloudWatchCompositeAlarmProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/AlarmName + + Required properties: + - AlarmRule + + Create-only properties: + - /properties/AlarmName + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - cloudwatch:DescribeAlarms + - cloudwatch:PutCompositeAlarm + + """ + model = request.desired_state + cloud_watch = request.aws_client_factory.cloudwatch + + params = util.select_attributes( + model, + [ + "AlarmName", + "AlarmRule", + "ActionsEnabled", + "ActionsSuppressor", + "ActionsSuppressorWaitPeriod", + "ActionsSuppressorExtensionPeriod", + "AlarmActions", + "AlarmDescription", + "InsufficientDataActions", + "OKActions", + ], + ) + if not params.get("AlarmName"): + model["AlarmName"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + params["AlarmName"] = model["AlarmName"] + + if "ActionsEnabled" in params: + params["ActionsEnabled"] = str_to_bool(params["ActionsEnabled"]) + + create_params = util.select_attributes( + model, + [ + "AlarmName", + "AlarmRule", + "ActionsEnabled", + "ActionsSuppressor", + "ActionsSuppressorExtensionPeriod", + "ActionsSuppressorWaitPeriod", + "AlarmActions", + "AlarmDescription", + "InsufficientDataActions", + "OKActions", + ], + ) + + cloud_watch.put_composite_alarm(**create_params) + alarms = cloud_watch.describe_alarms( + AlarmNames=[model["AlarmName"]], AlarmTypes=["CompositeAlarm"] + )["CompositeAlarms"] + + if not alarms: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="Composite Alarm not found", + ) + model["Arn"] = alarms[0]["AlarmArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[CloudWatchCompositeAlarmProperties], + ) -> ProgressEvent[CloudWatchCompositeAlarmProperties]: + """ + Fetch resource information + + IAM permissions required: + - cloudwatch:DescribeAlarms + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[CloudWatchCompositeAlarmProperties], + ) -> ProgressEvent[CloudWatchCompositeAlarmProperties]: + """ + Delete a resource + + IAM permissions required: + - cloudwatch:DescribeAlarms + - cloudwatch:DeleteAlarms + """ + model = request.desired_state + cloud_watch = request.aws_client_factory.cloudwatch + cloud_watch.delete_alarms(AlarmNames=[model["AlarmName"]]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[CloudWatchCompositeAlarmProperties], + ) -> ProgressEvent[CloudWatchCompositeAlarmProperties]: + """ + Update a resource + + IAM permissions required: + - cloudwatch:DescribeAlarms + - cloudwatch:PutCompositeAlarm + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm.schema.json b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm.schema.json new file mode 100644 index 0000000000000..36464ecf204be --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm.schema.json @@ -0,0 +1,130 @@ +{ + "typeName": "AWS::CloudWatch::CompositeAlarm", + "description": "The AWS::CloudWatch::CompositeAlarm type specifies an alarm which aggregates the states of other Alarms (Metric or Composite Alarms) as defined by the AlarmRule expression", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-cloudwatch.git", + "properties": { + "Arn": { + "type": "string", + "description": "Amazon Resource Name (ARN) of the alarm", + "minLength": 1, + "maxLength": 1600 + }, + "AlarmName": { + "description": "The name of the Composite Alarm", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "AlarmRule": { + "type": "string", + "description": "Expression which aggregates the state of other Alarms (Metric or Composite Alarms)", + "minLength": 1, + "maxLength": 10240 + }, + "AlarmDescription": { + "type": "string", + "description": "The description of the alarm", + "minLength": 0, + "maxLength": 1024 + }, + "ActionsEnabled": { + "description": "Indicates whether actions should be executed during any changes to the alarm state. The default is TRUE.", + "type": "boolean" + }, + "OKActions": { + "type": "array", + "items": { + "type": "string", + "description": "Amazon Resource Name (ARN) of the action", + "minLength": 1, + "maxLength": 1024 + }, + "description": "The actions to execute when this alarm transitions to the OK state from any other state. Each action is specified as an Amazon Resource Name (ARN).", + "maxItems": 5 + }, + "AlarmActions": { + "type": "array", + "items": { + "type": "string", + "description": "Amazon Resource Name (ARN) of the action", + "minLength": 1, + "maxLength": 1024 + }, + "description": "The list of actions to execute when this alarm transitions into an ALARM state from any other state. Specify each action as an Amazon Resource Name (ARN).", + "maxItems": 5 + }, + "InsufficientDataActions": { + "type": "array", + "items": { + "type": "string", + "description": "Amazon Resource Name (ARN) of the action", + "minLength": 1, + "maxLength": 1024 + }, + "description": "The actions to execute when this alarm transitions to the INSUFFICIENT_DATA state from any other state. Each action is specified as an Amazon Resource Name (ARN).", + "maxItems": 5 + }, + "ActionsSuppressor": { + "description": "Actions will be suppressed if the suppressor alarm is in the ALARM state. ActionsSuppressor can be an AlarmName or an Amazon Resource Name (ARN) from an existing alarm. ", + "type": "string", + "minLength": 1, + "maxLength": 1600 + }, + "ActionsSuppressorWaitPeriod": { + "description": "Actions will be suppressed if ExtensionPeriod is active. The length of time that actions are suppressed is in seconds.", + "type": "integer", + "minimum": 0 + }, + "ActionsSuppressorExtensionPeriod": { + "description": "Actions will be suppressed if WaitPeriod is active. The length of time that actions are suppressed is in seconds.", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "AlarmRule" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "createOnlyProperties": [ + "/properties/AlarmName" + ], + "primaryIdentifier": [ + "/properties/AlarmName" + ], + "additionalProperties": false, + "handlers": { + "create": { + "permissions": [ + "cloudwatch:DescribeAlarms", + "cloudwatch:PutCompositeAlarm" + ] + }, + "read": { + "permissions": [ + "cloudwatch:DescribeAlarms" + ] + }, + "update": { + "permissions": [ + "cloudwatch:DescribeAlarms", + "cloudwatch:PutCompositeAlarm" + ] + }, + "delete": { + "permissions": [ + "cloudwatch:DescribeAlarms", + "cloudwatch:DeleteAlarms" + ] + }, + "list": { + "permissions": [ + "cloudwatch:DescribeAlarms" + ] + } + }, + "tagging": { + "taggable": false + } +} diff --git a/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm_plugin.py b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm_plugin.py new file mode 100644 index 0000000000000..867cebdbfe31d --- /dev/null +++ b/localstack-core/localstack/services/cloudwatch/resource_providers/aws_cloudwatch_compositealarm_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class CloudWatchCompositeAlarmProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::CloudWatch::CompositeAlarm" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.cloudwatch.resource_providers.aws_cloudwatch_compositealarm import ( + CloudWatchCompositeAlarmProvider, + ) + + self.factory = CloudWatchCompositeAlarmProvider diff --git a/localstack-core/localstack/services/configservice/__init__.py b/localstack-core/localstack/services/configservice/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/configservice/provider.py b/localstack-core/localstack/services/configservice/provider.py new file mode 100644 index 0000000000000..3087c6b23e270 --- /dev/null +++ b/localstack-core/localstack/services/configservice/provider.py @@ -0,0 +1,5 @@ +from localstack.aws.api.config import ConfigApi + + +class ConfigProvider(ConfigApi): + pass diff --git a/localstack-core/localstack/services/dynamodb/__init__.py b/localstack-core/localstack/services/dynamodb/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/dynamodb/models.py b/localstack-core/localstack/services/dynamodb/models.py new file mode 100644 index 0000000000000..cc6d7ee2e4939 --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/models.py @@ -0,0 +1,122 @@ +import dataclasses +from typing import TypedDict + +from localstack.aws.api.dynamodb import ( + AttributeMap, + Key, + RegionName, + ReplicaDescription, + StreamViewType, + TableName, + TimeToLiveSpecification, +) +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossRegionAttribute, + LocalAttribute, +) + + +@dataclasses.dataclass +class TableStreamType: + """ + When an item in the table is modified, StreamViewType determines what information is written to the stream for this table. + - KEYS_ONLY - Only the key attributes of the modified item are written to the stream. + - NEW_IMAGE - The entire item, as it appears after it was modified, is written to the stream. + - OLD_IMAGE - The entire item, as it appeared before it was modified, is written to the stream. + - NEW_AND_OLD_IMAGES - Both the new and the old item images of the item are written to the stream. + Special case: + is_kinesis: equivalent to NEW_AND_OLD_IMAGES, can be set at the same time as StreamViewType + """ + + stream_view_type: StreamViewType | None + is_kinesis: bool + + @property + def needs_old_image(self): + return self.is_kinesis or self.stream_view_type in ( + StreamViewType.OLD_IMAGE, + StreamViewType.NEW_AND_OLD_IMAGES, + ) + + @property + def needs_new_image(self): + return self.is_kinesis or self.stream_view_type in ( + StreamViewType.NEW_IMAGE, + StreamViewType.NEW_AND_OLD_IMAGES, + ) + + +class DynamoDbStreamRecord(TypedDict, total=False): + ApproximateCreationDateTime: int + SizeBytes: int + Keys: Key + StreamViewType: StreamViewType | None + OldImage: AttributeMap | None + NewImage: AttributeMap | None + SequenceNumber: int | None + + +class StreamRecord(TypedDict, total=False): + """ + Related to DynamoDB Streams and Kinesis Destinations + This class contains data necessary for both KinesisRecord and DynamoDBStreams record + """ + + eventName: str + eventID: str + eventVersion: str + dynamodb: DynamoDbStreamRecord + awsRegion: str + eventSource: str + + +StreamRecords = list[StreamRecord] + + +class TableRecords(TypedDict): + """ + Container class used to forward events from DynamoDB to DDB Streams and Kinesis destinations. + It contains the records to be forwarded and data about the streams to be forwarded to. + """ + + table_stream_type: TableStreamType + records: StreamRecords + + +# the RecordsMap maps the TableName to TableRecords, allowing forwarding to the destinations +# some DynamoDB calls can modify several tables at once, which is why we need to group those events per table, as each +# table can have different destinations +RecordsMap = dict[TableName, TableRecords] + + +class DynamoDBStore(BaseStore): + # maps global table names to configurations (for the legacy v.2017 tables) + GLOBAL_TABLES: dict[str, dict] = CrossRegionAttribute(default=dict) + + # Maps table name to the region they exist in on DDBLocal (for v.2019 global tables) + TABLE_REGION: dict[TableName, RegionName] = CrossRegionAttribute(default=dict) + + # Maps the table replicas (for v.2019 global tables) + REPLICAS: dict[TableName, dict[RegionName, ReplicaDescription]] = CrossRegionAttribute( + default=dict + ) + + # cache table taggings - maps table ARN to tags dict + TABLE_TAGS: dict[str, dict] = CrossRegionAttribute(default=dict) + + # maps table names to cached table definitions + table_definitions: dict[str, dict] = LocalAttribute(default=dict) + + # maps table names to additional table properties that are not stored upstream (e.g., ReplicaUpdates) + table_properties: dict[str, dict] = LocalAttribute(default=dict) + + # maps table names to TTL specifications + ttl_specifications: dict[str, TimeToLiveSpecification] = LocalAttribute(default=dict) + + # maps backups + backups: dict[str, dict] = LocalAttribute(default=dict) + + +dynamodb_stores = AccountRegionBundle("dynamodb", DynamoDBStore) diff --git a/localstack-core/localstack/services/dynamodb/packages.py b/localstack-core/localstack/services/dynamodb/packages.py new file mode 100644 index 0000000000000..db2ca14c49bf6 --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/packages.py @@ -0,0 +1,105 @@ +import os +from typing import List + +from localstack import config +from localstack.constants import ARTIFACTS_REPO, MAVEN_REPO_URL +from localstack.packages import InstallTarget, Package, PackageInstaller +from localstack.packages.java import java_package +from localstack.utils.archives import ( + download_and_extract_with_retry, + update_jar_manifest, + upgrade_jar_file, +) +from localstack.utils.files import rm_rf, save_file +from localstack.utils.functions import run_safe +from localstack.utils.http import download +from localstack.utils.run import run + +DDB_AGENT_JAR_URL = f"{ARTIFACTS_REPO}/raw/388cd73f45bfd3bcf7ad40aa35499093061c7962/dynamodb-local-patch/target/ddb-local-loader-0.1.jar" +JAVASSIST_JAR_URL = f"{MAVEN_REPO_URL}/org/javassist/javassist/3.30.2-GA/javassist-3.30.2-GA.jar" + +DDBLOCAL_URL = "https://d1ni2b6xgvw0s0.cloudfront.net/v2.x/dynamodb_local_latest.zip" + + +class DynamoDBLocalPackage(Package): + def __init__(self): + super().__init__(name="DynamoDBLocal", default_version="2") + + def _get_installer(self, _) -> PackageInstaller: + return DynamoDBLocalPackageInstaller() + + def get_versions(self) -> List[str]: + return ["2"] + + +class DynamoDBLocalPackageInstaller(PackageInstaller): + def __init__(self): + super().__init__("dynamodb-local", "2") + + # DDBLocal v2 requires JRE 17+ + # See: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.DownloadingAndRunning.html + self.java_version = "21" + + def _prepare_installation(self, target: InstallTarget) -> None: + java_package.get_installer(self.java_version).install(target) + + def get_java_env_vars(self) -> dict[str, str]: + java_home = java_package.get_installer(self.java_version).get_java_home() + path = f"{java_home}/bin:{os.environ['PATH']}" + + return { + "JAVA_HOME": java_home, + "PATH": path, + } + + def _install(self, target: InstallTarget): + # download and extract archive + tmp_archive = os.path.join(config.dirs.cache, f"DynamoDBLocal-{self.version}.zip") + install_dir = self._get_install_dir(target) + + download_and_extract_with_retry(DDBLOCAL_URL, tmp_archive, install_dir) + rm_rf(tmp_archive) + + # Use custom log formatting + log4j2_config = """ + + + + + + + + + + + + """ + log4j2_file = os.path.join(install_dir, "log4j2.xml") + run_safe(lambda: save_file(log4j2_file, log4j2_config)) + run_safe(lambda: run(["zip", "-u", "DynamoDBLocal.jar", "log4j2.xml"], cwd=install_dir)) + + # Add patch that enables 20+ GSIs + ddb_agent_jar_path = self.get_ddb_agent_jar_path() + if not os.path.exists(ddb_agent_jar_path): + download(DDB_AGENT_JAR_URL, ddb_agent_jar_path) + + javassit_jar_path = os.path.join(install_dir, "javassist.jar") + if not os.path.exists(javassit_jar_path): + download(JAVASSIST_JAR_URL, javassit_jar_path) + + # Add javassist in the manifest classpath + update_jar_manifest( + "DynamoDBLocal.jar", install_dir, "Class-Path: .", "Class-Path: javassist.jar ." + ) + + ddb_local_lib_dir = os.path.join(install_dir, "DynamoDBLocal_lib") + upgrade_jar_file(ddb_local_lib_dir, "slf4j-ext-*.jar", "org/slf4j/slf4j-ext:2.0.13") + + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, "DynamoDBLocal.jar") + + def get_ddb_agent_jar_path(self): + return os.path.join(self.get_installed_dir(), "ddb-local-loader-0.1.jar") + + +dynamodblocal_package = DynamoDBLocalPackage() diff --git a/localstack-core/localstack/services/dynamodb/plugins.py b/localstack-core/localstack/services/dynamodb/plugins.py new file mode 100644 index 0000000000000..f5d60a15b914a --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/plugins.py @@ -0,0 +1,8 @@ +from localstack.packages import Package, package + + +@package(name="dynamodb-local") +def dynamodb_local_package() -> Package: + from localstack.services.dynamodb.packages import dynamodblocal_package + + return dynamodblocal_package diff --git a/localstack-core/localstack/services/dynamodb/provider.py b/localstack-core/localstack/services/dynamodb/provider.py new file mode 100644 index 0000000000000..407e6400414ca --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/provider.py @@ -0,0 +1,2271 @@ +import copy +import json +import logging +import os +import random +import re +import threading +import time +import traceback +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from datetime import datetime +from operator import itemgetter +from typing import Dict, List, Optional + +import requests +import werkzeug + +from localstack import config +from localstack.aws import handlers +from localstack.aws.api import ( + CommonServiceException, + RequestContext, + ServiceRequest, + ServiceResponse, + handler, +) +from localstack.aws.api.dynamodb import ( + AttributeMap, + BatchExecuteStatementOutput, + BatchGetItemOutput, + BatchGetRequestMap, + BatchGetResponseMap, + BatchWriteItemInput, + BatchWriteItemOutput, + BatchWriteItemRequestMap, + BillingMode, + ContinuousBackupsDescription, + ContinuousBackupsStatus, + CreateGlobalTableOutput, + CreateTableInput, + CreateTableOutput, + Delete, + DeleteItemInput, + DeleteItemOutput, + DeleteRequest, + DeleteTableOutput, + DescribeContinuousBackupsOutput, + DescribeGlobalTableOutput, + DescribeKinesisStreamingDestinationOutput, + DescribeTableOutput, + DescribeTimeToLiveOutput, + DestinationStatus, + DynamodbApi, + EnableKinesisStreamingConfiguration, + ExecuteStatementInput, + ExecuteStatementOutput, + ExecuteTransactionInput, + ExecuteTransactionOutput, + GetItemInput, + GetItemOutput, + GlobalTableAlreadyExistsException, + GlobalTableNotFoundException, + KinesisStreamingDestinationOutput, + ListGlobalTablesOutput, + ListTablesInputLimit, + ListTablesOutput, + ListTagsOfResourceOutput, + NextTokenString, + PartiQLBatchRequest, + PointInTimeRecoveryDescription, + PointInTimeRecoverySpecification, + PointInTimeRecoveryStatus, + PositiveIntegerObject, + ProvisionedThroughputExceededException, + Put, + PutItemInput, + PutItemOutput, + PutRequest, + QueryInput, + QueryOutput, + RegionName, + ReplicaDescription, + ReplicaList, + ReplicaStatus, + ReplicaUpdateList, + ResourceArnString, + ResourceInUseException, + ResourceNotFoundException, + ReturnConsumedCapacity, + ScanInput, + ScanOutput, + StreamArn, + TableDescription, + TableName, + TagKeyList, + TagList, + TimeToLiveSpecification, + TransactGetItemList, + TransactGetItemsOutput, + TransactWriteItem, + TransactWriteItemList, + TransactWriteItemsInput, + TransactWriteItemsOutput, + Update, + UpdateContinuousBackupsOutput, + UpdateGlobalTableOutput, + UpdateItemInput, + UpdateItemOutput, + UpdateTableInput, + UpdateTableOutput, + UpdateTimeToLiveOutput, + WriteRequest, +) +from localstack.aws.api.dynamodbstreams import StreamStatus +from localstack.aws.connect import connect_to +from localstack.constants import ( + AUTH_CREDENTIAL_REGEX, + AWS_REGION_US_EAST_1, + INTERNAL_AWS_SECRET_ACCESS_KEY, +) +from localstack.http import Request, Response, route +from localstack.services.dynamodb.models import ( + DynamoDBStore, + RecordsMap, + StreamRecord, + StreamRecords, + TableRecords, + TableStreamType, + dynamodb_stores, +) +from localstack.services.dynamodb.server import DynamodbServer +from localstack.services.dynamodb.utils import ( + ItemFinder, + ItemSet, + SchemaExtractor, + de_dynamize_record, + extract_table_name_from_partiql_update, + get_ddb_access_key, + modify_ddblocal_arns, +) +from localstack.services.dynamodbstreams import dynamodbstreams_api +from localstack.services.dynamodbstreams.models import dynamodbstreams_stores +from localstack.services.edge import ROUTER +from localstack.services.plugins import ServiceLifecycleHook +from localstack.state import AssetDirectory, StateVisitor +from localstack.utils.aws import arns +from localstack.utils.aws.arns import ( + extract_account_id_from_arn, + extract_region_from_arn, + get_partition, +) +from localstack.utils.aws.aws_stack import get_valid_regions_for_service +from localstack.utils.aws.request_context import ( + extract_account_id_from_headers, + extract_region_from_headers, +) +from localstack.utils.collections import select_attributes, select_from_typed_dict +from localstack.utils.common import short_uid, to_bytes +from localstack.utils.json import BytesEncoder, canonical_json +from localstack.utils.scheduler import Scheduler +from localstack.utils.strings import long_uid, md5, to_str +from localstack.utils.threads import FuncThread, start_thread + +# set up logger +LOG = logging.getLogger(__name__) + +# action header prefix +ACTION_PREFIX = "DynamoDB_20120810." + +# list of actions subject to throughput limitations +READ_THROTTLED_ACTIONS = [ + "GetItem", + "Query", + "Scan", + "TransactGetItems", + "BatchGetItem", +] +WRITE_THROTTLED_ACTIONS = [ + "PutItem", + "BatchWriteItem", + "UpdateItem", + "DeleteItem", + "TransactWriteItems", +] +THROTTLED_ACTIONS = READ_THROTTLED_ACTIONS + WRITE_THROTTLED_ACTIONS + +MANAGED_KMS_KEYS = {} + + +def dynamodb_table_exists(table_name: str, client=None) -> bool: + client = client or connect_to().dynamodb + paginator = client.get_paginator("list_tables") + pages = paginator.paginate(PaginationConfig={"PageSize": 100}) + table_name = to_str(table_name) + return any(table_name in page["TableNames"] for page in pages) + + +class EventForwarder: + def __init__(self, num_thread: int = 10): + self.executor = ThreadPoolExecutor(num_thread, thread_name_prefix="ddb_stream_fwd") + + def shutdown(self): + self.executor.shutdown(wait=False) + + def forward_to_targets( + self, account_id: str, region_name: str, records_map: RecordsMap, background: bool = True + ) -> None: + if background: + self._submit_records( + account_id=account_id, + region_name=region_name, + records_map=records_map, + ) + else: + self._forward(account_id, region_name, records_map) + + def _submit_records(self, account_id: str, region_name: str, records_map: RecordsMap): + """Required for patching submit with local thread context for EventStudio""" + self.executor.submit( + self._forward, + account_id, + region_name, + records_map, + ) + + def _forward(self, account_id: str, region_name: str, records_map: RecordsMap) -> None: + try: + self.forward_to_kinesis_stream(account_id, region_name, records_map) + except Exception as e: + LOG.debug( + "Error while publishing to Kinesis streams: '%s'", + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + try: + self.forward_to_ddb_stream(account_id, region_name, records_map) + except Exception as e: + LOG.debug( + "Error while publishing to DynamoDB streams, '%s'", + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + @staticmethod + def forward_to_ddb_stream(account_id: str, region_name: str, records_map: RecordsMap) -> None: + dynamodbstreams_api.forward_events(account_id, region_name, records_map) + + @staticmethod + def forward_to_kinesis_stream( + account_id: str, region_name: str, records_map: RecordsMap + ) -> None: + # You can only stream data from DynamoDB to Kinesis Data Streams in the same AWS account and AWS Region as your + # table. + # You can only stream data from a DynamoDB table to one Kinesis data stream. + store = get_store(account_id, region_name) + + for table_name, table_records in records_map.items(): + table_stream_type = table_records["table_stream_type"] + if not table_stream_type.is_kinesis: + continue + + kinesis_records = [] + + table_arn = arns.dynamodb_table_arn(table_name, account_id, region_name) + records = table_records["records"] + table_def = store.table_definitions.get(table_name) or {} + stream_arn = table_def["KinesisDataStreamDestinations"][-1]["StreamArn"] + for record in records: + kinesis_record = dict( + tableName=table_name, + recordFormat="application/json", + userIdentity=None, + **record, + ) + fields_to_remove = {"StreamViewType", "SequenceNumber"} + kinesis_record["dynamodb"] = { + k: v for k, v in record["dynamodb"].items() if k not in fields_to_remove + } + kinesis_record.pop("eventVersion", None) + + hash_keys = list( + filter(lambda key: key["KeyType"] == "HASH", table_def["KeySchema"]) + ) + # TODO: reverse properly how AWS creates the partition key, it seems to be an MD5 hash + kinesis_partition_key = md5(f"{table_name}{hash_keys[0]['AttributeName']}") + + kinesis_records.append( + { + "Data": json.dumps(kinesis_record, cls=BytesEncoder), + "PartitionKey": kinesis_partition_key, + } + ) + + kinesis = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).kinesis.request_metadata(service_principal="dynamodb", source_arn=table_arn) + + kinesis.put_records( + StreamARN=stream_arn, + Records=kinesis_records, + ) + + @classmethod + def is_kinesis_stream_exists(cls, stream_arn): + account_id = extract_account_id_from_arn(stream_arn) + region_name = extract_region_from_arn(stream_arn) + + kinesis = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).kinesis + stream_name_from_arn = stream_arn.split("/", 1)[1] + # check if the stream exists in kinesis for the user + filtered = list( + filter( + lambda stream_name: stream_name == stream_name_from_arn, + kinesis.list_streams()["StreamNames"], + ) + ) + return bool(filtered) + + +class SSEUtils: + """Utils for server-side encryption (SSE)""" + + @classmethod + def get_sse_kms_managed_key(cls, account_id: str, region_name: str): + from localstack.services.kms import provider + + existing_key = MANAGED_KMS_KEYS.get(region_name) + if existing_key: + return existing_key + kms_client = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).kms + key_data = kms_client.create_key( + Description="Default key that protects my DynamoDB data when no other key is defined" + ) + key_id = key_data["KeyMetadata"]["KeyId"] + + provider.set_key_managed(key_id, account_id, region_name) + MANAGED_KMS_KEYS[region_name] = key_id + return key_id + + @classmethod + def get_sse_description(cls, account_id: str, region_name: str, data): + if data.get("Enabled"): + kms_master_key_id = data.get("KMSMasterKeyId") + if not kms_master_key_id: + # this is of course not the actual key for dynamodb, just a better, since existing, mock + kms_master_key_id = cls.get_sse_kms_managed_key(account_id, region_name) + kms_master_key_id = arns.kms_key_arn(kms_master_key_id, account_id, region_name) + return { + "Status": "ENABLED", + "SSEType": "KMS", # no other value is allowed here + "KMSMasterKeyArn": kms_master_key_id, + } + return {} + + +class ValidationException(CommonServiceException): + def __init__(self, message: str): + super().__init__(code="ValidationException", status_code=400, message=message) + + +def get_store(account_id: str, region_name: str) -> DynamoDBStore: + # special case: AWS NoSQL Workbench sends "localhost" as region - replace with proper region here + region_name = DynamoDBProvider.ddb_region_name(region_name) + return dynamodb_stores[account_id][region_name] + + +@contextmanager +def modify_context_region(context: RequestContext, region: str): + """ + Context manager that modifies the region of a `RequestContext`. At the exit, the context is restored to its + original state. + + :param context: the context to modify + :param region: the modified region + :return: a modified `RequestContext` + """ + original_region = context.region + original_authorization = context.request.headers.get("Authorization") + + key = get_ddb_access_key(context.account_id, region) + + context.region = region + context.request.headers["Authorization"] = re.sub( + AUTH_CREDENTIAL_REGEX, + rf"Credential={key}/\2/{region}/\4/", + original_authorization or "", + flags=re.IGNORECASE, + ) + + try: + yield context + except Exception: + raise + finally: + # revert the original context + context.region = original_region + context.request.headers["Authorization"] = original_authorization + + +class DynamoDBDeveloperEndpoints: + """ + Developer endpoints for DynamoDB + DELETE /_aws/dynamodb/expired - delete expired items from tables with TTL enabled; return the number of expired + items deleted + """ + + @route("/_aws/dynamodb/expired", methods=["DELETE"]) + def delete_expired_messages(self, _: Request): + no_expired_items = delete_expired_items() + return {"ExpiredItems": no_expired_items} + + +def delete_expired_items() -> int: + """ + This utility function iterates over all stores, looks for tables with TTL enabled, + scan such tables and delete expired items. + """ + no_expired_items = 0 + for account_id, region_name, state in dynamodb_stores.iter_stores(): + ttl_specs = state.ttl_specifications + client = connect_to(aws_access_key_id=account_id, region_name=region_name).dynamodb + for table_name, ttl_spec in ttl_specs.items(): + if ttl_spec.get("Enabled", False): + attribute_name = ttl_spec.get("AttributeName") + current_time = int(datetime.now().timestamp()) + try: + result = client.scan( + TableName=table_name, + FilterExpression="#ttl <= :threshold", + ExpressionAttributeValues={":threshold": {"N": str(current_time)}}, + ExpressionAttributeNames={"#ttl": attribute_name}, + ) + items_to_delete = result.get("Items", []) + no_expired_items += len(items_to_delete) + table_description = client.describe_table(TableName=table_name) + partition_key, range_key = _get_hash_and_range_key(table_description) + keys_to_delete = [ + {partition_key: item.get(partition_key)} + if range_key is None + else { + partition_key: item.get(partition_key), + range_key: item.get(range_key), + } + for item in items_to_delete + ] + delete_requests = [{"DeleteRequest": {"Key": key}} for key in keys_to_delete] + for i in range(0, len(delete_requests), 25): + batch = delete_requests[i : i + 25] + client.batch_write_item(RequestItems={table_name: batch}) + except Exception as e: + LOG.warning( + "An error occurred when deleting expired items from table %s: %s", + table_name, + e, + ) + return no_expired_items + + +def _get_hash_and_range_key(table_description: DescribeTableOutput) -> [str, str | None]: + key_schema = table_description.get("Table", {}).get("KeySchema", []) + hash_key, range_key = None, None + for key in key_schema: + if key["KeyType"] == "HASH": + hash_key = key["AttributeName"] + if key["KeyType"] == "RANGE": + range_key = key["AttributeName"] + return hash_key, range_key + + +class ExpiredItemsWorker: + """A worker that periodically computes and deletes expired items from DynamoDB tables""" + + def __init__(self) -> None: + super().__init__() + self.scheduler = Scheduler() + self.thread: Optional[FuncThread] = None + self.mutex = threading.RLock() + + def start(self): + with self.mutex: + if self.thread: + return + + self.scheduler = Scheduler() + self.scheduler.schedule( + delete_expired_items, period=60 * 60 + ) # the background process seems slow on AWS + + def _run(*_args): + self.scheduler.run() + + self.thread = start_thread(_run, name="ddb-remove-expired-items") + + def stop(self): + with self.mutex: + if self.scheduler: + self.scheduler.close() + + if self.thread: + self.thread.stop() + + self.thread = None + self.scheduler = None + + +class DynamoDBProvider(DynamodbApi, ServiceLifecycleHook): + server: DynamodbServer + """The instance of the server managing the instance of DynamoDB local""" + + def __init__(self): + self.server = self._new_dynamodb_server() + self._expired_items_worker = ExpiredItemsWorker() + self._router_rules = [] + self._event_forwarder = EventForwarder() + + def on_before_start(self): + self.server.start_dynamodb() + if config.DYNAMODB_REMOVE_EXPIRED_ITEMS: + self._expired_items_worker.start() + self._router_rules = ROUTER.add(DynamoDBDeveloperEndpoints()) + + def on_before_stop(self): + self._expired_items_worker.stop() + ROUTER.remove(self._router_rules) + self._event_forwarder.shutdown() + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(dynamodb_stores) + visitor.visit(dynamodbstreams_stores) + visitor.visit(AssetDirectory(self.service, os.path.join(config.dirs.data, self.service))) + + def on_before_state_reset(self): + self.server.stop_dynamodb() + + def on_before_state_load(self): + self.server.stop_dynamodb() + + def on_after_state_reset(self): + self.server.start_dynamodb() + + @staticmethod + def _new_dynamodb_server() -> DynamodbServer: + return DynamodbServer.get() + + def on_after_state_load(self): + self.server.start_dynamodb() + + def on_after_init(self): + # add response processor specific to ddblocal + handlers.modify_service_response.append(self.service, modify_ddblocal_arns) + + # routes for the shell ui + ROUTER.add( + path="/shell", + endpoint=self.handle_shell_ui_redirect, + methods=["GET"], + ) + ROUTER.add( + path="/shell/", + endpoint=self.handle_shell_ui_request, + ) + + def _forward_request( + self, + context: RequestContext, + region: str | None, + service_request: ServiceRequest | None = None, + ) -> ServiceResponse: + """ + Modify the context region and then forward request to DynamoDB Local. + + This is used for operations impacted by global tables. In LocalStack, a single copy of global table + is kept, and any requests to replicated tables are forwarded to this original table. + """ + if region: + with modify_context_region(context, region): + return self.forward_request(context, service_request=service_request) + return self.forward_request(context, service_request=service_request) + + def forward_request( + self, context: RequestContext, service_request: ServiceRequest = None + ) -> ServiceResponse: + """ + Forward a request to DynamoDB Local. + """ + self.check_provisioned_throughput(context.operation.name) + self.prepare_request_headers( + context.request.headers, account_id=context.account_id, region_name=context.region + ) + return self.server.proxy(context, service_request) + + def get_forward_url(self, account_id: str, region_name: str) -> str: + """Return the URL of the backend DynamoDBLocal server to forward requests to""" + return self.server.url + + def handle_shell_ui_redirect(self, request: werkzeug.Request) -> Response: + headers = {"Refresh": f"0; url={config.external_service_url()}/shell/index.html"} + return Response("", headers=headers) + + def handle_shell_ui_request(self, request: werkzeug.Request, req_path: str) -> Response: + # TODO: "DynamoDB Local Web Shell was deprecated with version 1.16.X and is not available any + # longer from 1.17.X to latest. There are no immediate plans for a new Web Shell to be introduced." + # -> keeping this for now, to allow configuring custom installs; should consider removing it in the future + # https://repost.aws/questions/QUHyIzoEDqQ3iOKlUEp1LPWQ#ANdBm9Nz9TRf6VqR3jZtcA1g + req_path = f"/{req_path}" if not req_path.startswith("/") else req_path + account_id = extract_account_id_from_headers(request.headers) + region_name = extract_region_from_headers(request.headers) + url = f"{self.get_forward_url(account_id, region_name)}/shell{req_path}" + result = requests.request( + method=request.method, url=url, headers=request.headers, data=request.data + ) + return Response(result.content, headers=dict(result.headers), status=result.status_code) + + # + # Table ops + # + + @handler("CreateTable", expand=False) + def create_table( + self, + context: RequestContext, + create_table_input: CreateTableInput, + ) -> CreateTableOutput: + table_name = create_table_input["TableName"] + + # Return this specific error message to keep parity with AWS + if self.table_exists(context.account_id, context.region, table_name): + raise ResourceInUseException(f"Table already exists: {table_name}") + + billing_mode = create_table_input.get("BillingMode") + provisioned_throughput = create_table_input.get("ProvisionedThroughput") + if billing_mode == BillingMode.PAY_PER_REQUEST and provisioned_throughput is not None: + raise ValidationException( + "One or more parameter values were invalid: Neither ReadCapacityUnits nor WriteCapacityUnits can be " + "specified when BillingMode is PAY_PER_REQUEST" + ) + + result = self.forward_request(context) + + table_description = result["TableDescription"] + table_description["TableArn"] = table_arn = self.fix_table_arn( + context.account_id, context.region, table_description["TableArn"] + ) + + backend = get_store(context.account_id, context.region) + backend.table_definitions[table_name] = table_definitions = dict(create_table_input) + backend.TABLE_REGION[table_name] = context.region + + if "TableId" not in table_definitions: + table_definitions["TableId"] = long_uid() + + if "SSESpecification" in table_definitions: + sse_specification = table_definitions.pop("SSESpecification") + table_definitions["SSEDescription"] = SSEUtils.get_sse_description( + context.account_id, context.region, sse_specification + ) + + if table_definitions: + table_content = result.get("Table", {}) + table_content.update(table_definitions) + table_description.update(table_content) + + if "StreamSpecification" in table_definitions: + create_dynamodb_stream( + context.account_id, + context.region, + table_definitions, + table_description.get("LatestStreamLabel"), + ) + + if "TableClass" in table_definitions: + table_class = table_description.pop("TableClass", None) or table_definitions.pop( + "TableClass" + ) + table_description["TableClassSummary"] = {"TableClass": table_class} + + if "GlobalSecondaryIndexes" in table_description: + gsis = copy.deepcopy(table_description["GlobalSecondaryIndexes"]) + # update the different values, as DynamoDB-local v2 has a regression around GSI and does not return anything + # anymore + for gsi in gsis: + index_name = gsi.get("IndexName", "") + gsi.update( + { + "IndexArn": f"{table_arn}/index/{index_name}", + "IndexSizeBytes": 0, + "IndexStatus": "ACTIVE", + "ItemCount": 0, + } + ) + gsi_provisioned_throughput = gsi.setdefault("ProvisionedThroughput", {}) + gsi_provisioned_throughput["NumberOfDecreasesToday"] = 0 + + if billing_mode == BillingMode.PAY_PER_REQUEST: + gsi_provisioned_throughput["ReadCapacityUnits"] = 0 + gsi_provisioned_throughput["WriteCapacityUnits"] = 0 + + table_description["GlobalSecondaryIndexes"] = gsis + + if "ProvisionedThroughput" in table_description: + if "NumberOfDecreasesToday" not in table_description["ProvisionedThroughput"]: + table_description["ProvisionedThroughput"]["NumberOfDecreasesToday"] = 0 + + tags = table_definitions.pop("Tags", []) + if tags: + get_store(context.account_id, context.region).TABLE_TAGS[table_arn] = { + tag["Key"]: tag["Value"] for tag in tags + } + + # remove invalid attributes from result + table_description.pop("Tags", None) + table_description.pop("BillingMode", None) + + return result + + def delete_table( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DeleteTableOutput: + global_table_region = self.get_global_table_region(context, table_name) + + # Limitation note: On AWS, for a replicated table, if the source table is deleted, the replicated tables continue to exist. + # This is not the case for LocalStack, where all replicated tables will also be removed if source is deleted. + + result = self._forward_request(context=context, region=global_table_region) + + table_arn = result.get("TableDescription", {}).get("TableArn") + table_arn = self.fix_table_arn(context.account_id, context.region, table_arn) + dynamodbstreams_api.delete_streams(context.account_id, context.region, table_arn) + + store = get_store(context.account_id, context.region) + store.TABLE_TAGS.pop(table_arn, None) + store.REPLICAS.pop(table_name, None) + + return result + + def describe_table( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeTableOutput: + global_table_region = self.get_global_table_region(context, table_name) + + result = self._forward_request(context=context, region=global_table_region) + table_description: TableDescription = result["Table"] + + # Update table properties from LocalStack stores + if table_props := get_store(context.account_id, context.region).table_properties.get( + table_name + ): + table_description.update(table_props) + + store = get_store(context.account_id, context.region) + + # Update replication details + replicas: Dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {}) + + replica_description_list = [] + + if global_table_region != context.region: + replica_description_list.append( + ReplicaDescription( + RegionName=global_table_region, ReplicaStatus=ReplicaStatus.ACTIVE + ) + ) + + for replica_region, replica_description in replicas.items(): + # The replica in the region being queried must not be returned + if replica_region != context.region: + replica_description_list.append(replica_description) + + if replica_description_list: + table_description.update({"Replicas": replica_description_list}) + + # update only TableId and SSEDescription if present + if table_definitions := store.table_definitions.get(table_name): + for key in ["TableId", "SSEDescription"]: + if table_definitions.get(key): + table_description[key] = table_definitions[key] + if "TableClass" in table_definitions: + table_description["TableClassSummary"] = { + "TableClass": table_definitions["TableClass"] + } + + if "GlobalSecondaryIndexes" in table_description: + for gsi in table_description["GlobalSecondaryIndexes"]: + default_values = { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0, + } + # even if the billing mode is PAY_PER_REQUEST, AWS returns the Read and Write Capacity Units + # Terraform depends on this parity for update operations + gsi["ProvisionedThroughput"] = default_values | gsi.get("ProvisionedThroughput", {}) + + return DescribeTableOutput( + Table=select_from_typed_dict(TableDescription, table_description) + ) + + @handler("UpdateTable", expand=False) + def update_table( + self, context: RequestContext, update_table_input: UpdateTableInput + ) -> UpdateTableOutput: + table_name = update_table_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + try: + result = self._forward_request(context=context, region=global_table_region) + except CommonServiceException as exc: + # DynamoDBLocal refuses to update certain table params and raises. + # But we still need to update this info in LocalStack stores + if not (exc.code == "ValidationException" and exc.message == "Nothing to update"): + raise + + if table_class := update_table_input.get("TableClass"): + table_definitions = get_store( + context.account_id, context.region + ).table_definitions.setdefault(table_name, {}) + table_definitions["TableClass"] = table_class + + if replica_updates := update_table_input.get("ReplicaUpdates"): + store = get_store(context.account_id, global_table_region) + + # Dict with source region to set of replicated regions + replicas: Dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {}) + + for replica_update in replica_updates: + for key, details in replica_update.items(): + # Replicated region + target_region = details.get("RegionName") + + # Check if replicated region is valid + if target_region not in get_valid_regions_for_service("dynamodb"): + raise ValidationException(f"Region {target_region} is not supported") + + match key: + case "Create": + if target_region in replicas: + raise ValidationException( + f"Failed to create a the new replica of table with name: '{table_name}' because one or more replicas already existed as tables." + ) + replicas[target_region] = ReplicaDescription( + RegionName=target_region, + KMSMasterKeyId=details.get("KMSMasterKeyId"), + ProvisionedThroughputOverride=details.get( + "ProvisionedThroughputOverride" + ), + GlobalSecondaryIndexes=details.get("GlobalSecondaryIndexes"), + ReplicaStatus=ReplicaStatus.ACTIVE, + ) + case "Delete": + try: + replicas.pop(target_region) + except KeyError: + raise ValidationException( + "Update global table operation failed because one or more replicas were not part of the global table." + ) + + store.REPLICAS[table_name] = replicas + + # update response content + SchemaExtractor.invalidate_table_schema( + table_name, context.account_id, global_table_region + ) + + schema = SchemaExtractor.get_table_schema( + table_name, context.account_id, global_table_region + ) + + if sse_specification_input := update_table_input.get("SSESpecification"): + # If SSESpecification is changed, update store and return the 'UPDATING' status in the response + table_definition = get_store( + context.account_id, context.region + ).table_definitions.setdefault(table_name, {}) + if not sse_specification_input["Enabled"]: + table_definition.pop("SSEDescription", None) + schema["Table"]["SSEDescription"]["Status"] = "UPDATING" + + return UpdateTableOutput(TableDescription=schema["Table"]) + + SchemaExtractor.invalidate_table_schema(table_name, context.account_id, global_table_region) + + schema = SchemaExtractor.get_table_schema( + table_name, context.account_id, global_table_region + ) + + # TODO: DDB streams must also be created for replicas + if update_table_input.get("StreamSpecification"): + create_dynamodb_stream( + context.account_id, + context.region, + update_table_input, + result["TableDescription"].get("LatestStreamLabel"), + ) + + return UpdateTableOutput(TableDescription=schema["Table"]) + + def list_tables( + self, + context: RequestContext, + exclusive_start_table_name: TableName = None, + limit: ListTablesInputLimit = None, + **kwargs, + ) -> ListTablesOutput: + response = self.forward_request(context) + + # Add replicated tables + replicas = get_store(context.account_id, context.region).REPLICAS + for replicated_table, replications in replicas.items(): + for replica_region, replica_description in replications.items(): + if context.region == replica_region: + response["TableNames"].append(replicated_table) + + return response + + # + # Item ops + # + + @handler("PutItem", expand=False) + def put_item(self, context: RequestContext, put_item_input: PutItemInput) -> PutItemOutput: + table_name = put_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + has_return_values = put_item_input.get("ReturnValues") == "ALL_OLD" + stream_type = get_table_stream_type(context.account_id, context.region, table_name) + + # if the request doesn't ask for ReturnValues and we have stream enabled, we need to modify the request to + # force DDBLocal to return those values + if stream_type and not has_return_values: + service_req = copy.copy(context.service_request) + service_req["ReturnValues"] = "ALL_OLD" + result = self._forward_request( + context=context, region=global_table_region, service_request=service_req + ) + else: + result = self._forward_request(context=context, region=global_table_region) + + # Since this operation makes use of global table region, we need to use the same region for all + # calls made via the inter-service client. This is taken care of by passing the account ID and + # region, e.g. when getting the stream spec + + # Get stream specifications details for the table + if stream_type: + item = put_item_input["Item"] + # prepare record keys + keys = SchemaExtractor.extract_keys( + item=item, + table_name=table_name, + account_id=context.account_id, + region_name=global_table_region, + ) + # because we modified the request, we will always have the ReturnValues if we have streams enabled + if has_return_values: + existing_item = result.get("Attributes") + else: + # remove the ReturnValues if the client didn't ask for it + existing_item = result.pop("Attributes", None) + + if existing_item == item: + return result + + # create record + record = self.get_record_template( + context.region, + ) + record["eventName"] = "INSERT" if not existing_item else "MODIFY" + record["dynamodb"]["Keys"] = keys + record["dynamodb"]["SizeBytes"] = _get_size_bytes(item) + + if stream_type.needs_new_image: + record["dynamodb"]["NewImage"] = item + if stream_type.stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type + if existing_item and stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + + records_map = { + table_name: TableRecords(records=[record], table_stream_type=stream_type) + } + self.forward_stream_records(context.account_id, context.region, records_map) + return result + + @handler("DeleteItem", expand=False) + def delete_item( + self, + context: RequestContext, + delete_item_input: DeleteItemInput, + ) -> DeleteItemOutput: + table_name = delete_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + has_return_values = delete_item_input.get("ReturnValues") == "ALL_OLD" + stream_type = get_table_stream_type(context.account_id, context.region, table_name) + + # if the request doesn't ask for ReturnValues and we have stream enabled, we need to modify the request to + # force DDBLocal to return those values + if stream_type and not has_return_values: + service_req = copy.copy(context.service_request) + service_req["ReturnValues"] = "ALL_OLD" + result = self._forward_request( + context=context, region=global_table_region, service_request=service_req + ) + else: + result = self._forward_request(context=context, region=global_table_region) + + # determine and forward stream record + if stream_type: + # because we modified the request, we will always have the ReturnValues if we have streams enabled + if has_return_values: + existing_item = result.get("Attributes") + else: + # remove the ReturnValues if the client didn't ask for it + existing_item = result.pop("Attributes", None) + + if not existing_item: + return result + + # create record + record = self.get_record_template(context.region) + record["eventName"] = "REMOVE" + record["dynamodb"]["Keys"] = delete_item_input["Key"] + record["dynamodb"]["SizeBytes"] = _get_size_bytes(existing_item) + + if stream_type.stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type + if stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + + records_map = { + table_name: TableRecords(records=[record], table_stream_type=stream_type) + } + self.forward_stream_records(context.account_id, context.region, records_map) + + return result + + @handler("UpdateItem", expand=False) + def update_item( + self, + context: RequestContext, + update_item_input: UpdateItemInput, + ) -> UpdateItemOutput: + # TODO: UpdateItem is harder to use ReturnValues for Streams, because it needs the Before and After images. + table_name = update_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + existing_item = None + stream_type = get_table_stream_type(context.account_id, context.region, table_name) + + # even if we don't need the OldImage, we still need to fetch the existing item to know if the event is INSERT + # or MODIFY (UpdateItem will create the object if it doesn't exist, and you don't use a ConditionExpression) + if stream_type: + existing_item = ItemFinder.find_existing_item( + put_item=update_item_input, + table_name=table_name, + account_id=context.account_id, + region_name=context.region, + endpoint_url=self.server.url, + ) + + result = self._forward_request(context=context, region=global_table_region) + + # construct and forward stream record + if stream_type: + updated_item = ItemFinder.find_existing_item( + put_item=update_item_input, + table_name=table_name, + account_id=context.account_id, + region_name=context.region, + endpoint_url=self.server.url, + ) + if not updated_item or updated_item == existing_item: + return result + + record = self.get_record_template(context.region) + record["eventName"] = "INSERT" if not existing_item else "MODIFY" + record["dynamodb"]["Keys"] = update_item_input["Key"] + record["dynamodb"]["SizeBytes"] = _get_size_bytes(updated_item) + + if stream_type.stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type + if existing_item and stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + if stream_type.needs_new_image: + record["dynamodb"]["NewImage"] = updated_item + + records_map = { + table_name: TableRecords(records=[record], table_stream_type=stream_type) + } + self.forward_stream_records(context.account_id, context.region, records_map) + + return result + + @handler("GetItem", expand=False) + def get_item(self, context: RequestContext, get_item_input: GetItemInput) -> GetItemOutput: + table_name = get_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + result = self._forward_request(context=context, region=global_table_region) + self.fix_consumed_capacity(get_item_input, result) + return result + + # + # Queries + # + + @handler("Query", expand=False) + def query(self, context: RequestContext, query_input: QueryInput) -> QueryOutput: + index_name = query_input.get("IndexName") + if index_name: + if not is_index_query_valid(context.account_id, context.region, query_input): + raise ValidationException( + "One or more parameter values were invalid: Select type ALL_ATTRIBUTES " + "is not supported for global secondary index id-index because its projection " + "type is not ALL", + ) + + table_name = query_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + result = self._forward_request(context=context, region=global_table_region) + self.fix_consumed_capacity(query_input, result) + return result + + @handler("Scan", expand=False) + def scan(self, context: RequestContext, scan_input: ScanInput) -> ScanOutput: + table_name = scan_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + result = self._forward_request(context=context, region=global_table_region) + return result + + # + # Batch ops + # + + @handler("BatchWriteItem", expand=False) + def batch_write_item( + self, + context: RequestContext, + batch_write_item_input: BatchWriteItemInput, + ) -> BatchWriteItemOutput: + # TODO: add global table support + existing_items = {} + existing_items_to_fetch: BatchWriteItemRequestMap = {} + # UnprocessedItems should have the same format as RequestItems + unprocessed_items = {} + request_items = batch_write_item_input["RequestItems"] + + tables_stream_type: dict[TableName, TableStreamType] = {} + + for table_name, items in sorted(request_items.items(), key=itemgetter(0)): + if stream_type := get_table_stream_type(context.account_id, context.region, table_name): + tables_stream_type[table_name] = stream_type + + for request in items: + request: WriteRequest + for key, inner_request in request.items(): + inner_request: PutRequest | DeleteRequest + if self.should_throttle("BatchWriteItem"): + unprocessed_items_for_table = unprocessed_items.setdefault(table_name, []) + unprocessed_items_for_table.append(request) + + elif stream_type: + existing_items_to_fetch_for_table = existing_items_to_fetch.setdefault( + table_name, [] + ) + existing_items_to_fetch_for_table.append(inner_request) + + if existing_items_to_fetch: + existing_items = ItemFinder.find_existing_items( + put_items_per_table=existing_items_to_fetch, + account_id=context.account_id, + region_name=context.region, + endpoint_url=self.server.url, + ) + + try: + result = self.forward_request(context) + except CommonServiceException as e: + # TODO: validate if DynamoDB still raises `One of the required keys was not given a value` + # for now, replace with the schema error validation + if e.message == "One of the required keys was not given a value": + raise ValidationException("The provided key element does not match the schema") + raise e + + # determine and forward stream records + if tables_stream_type: + records_map = self.prepare_batch_write_item_records( + account_id=context.account_id, + region_name=context.region, + tables_stream_type=tables_stream_type, + request_items=request_items, + existing_items=existing_items, + ) + self.forward_stream_records(context.account_id, context.region, records_map) + + # TODO: should unprocessed item which have mutated by `prepare_batch_write_item_records` be returned + for table_name, unprocessed_items_in_table in unprocessed_items.items(): + unprocessed: dict = result["UnprocessedItems"] + result_unprocessed_table = unprocessed.setdefault(table_name, []) + + # add the Unprocessed items to the response + # TODO: check before if the same request has not been Unprocessed by DDB local already? + # those might actually have been processed? shouldn't we remove them from the proxied request? + for request in unprocessed_items_in_table: + result_unprocessed_table.append(request) + + # remove any table entry if it's empty + result["UnprocessedItems"] = {k: v for k, v in unprocessed.items() if v} + + return result + + @handler("BatchGetItem") + def batch_get_item( + self, + context: RequestContext, + request_items: BatchGetRequestMap, + return_consumed_capacity: ReturnConsumedCapacity = None, + **kwargs, + ) -> BatchGetItemOutput: + # TODO: add global table support + return self.forward_request(context) + + # + # Transactions + # + + @handler("TransactWriteItems", expand=False) + def transact_write_items( + self, + context: RequestContext, + transact_write_items_input: TransactWriteItemsInput, + ) -> TransactWriteItemsOutput: + # TODO: add global table support + existing_items = {} + existing_items_to_fetch: dict[str, list[Put | Update | Delete]] = {} + updated_items_to_fetch: dict[str, list[Update]] = {} + transact_items = transact_write_items_input["TransactItems"] + tables_stream_type: dict[TableName, TableStreamType] = {} + no_stream_tables = set() + + for item in transact_items: + item: TransactWriteItem + for key in ["Put", "Update", "Delete"]: + inner_item: Put | Delete | Update = item.get(key) + if inner_item: + table_name = inner_item["TableName"] + # if we've seen the table already and it does not have streams, skip + if table_name in no_stream_tables: + continue + + # if we have not seen the table, fetch its streaming status + if table_name not in tables_stream_type: + if stream_type := get_table_stream_type( + context.account_id, context.region, table_name + ): + tables_stream_type[table_name] = stream_type + else: + # no stream, + no_stream_tables.add(table_name) + continue + + existing_items_to_fetch_for_table = existing_items_to_fetch.setdefault( + table_name, [] + ) + existing_items_to_fetch_for_table.append(inner_item) + if key == "Update": + updated_items_to_fetch_for_table = updated_items_to_fetch.setdefault( + table_name, [] + ) + updated_items_to_fetch_for_table.append(inner_item) + + continue + + if existing_items_to_fetch: + existing_items = ItemFinder.find_existing_items( + put_items_per_table=existing_items_to_fetch, + account_id=context.account_id, + region_name=context.region, + endpoint_url=self.server.url, + ) + + client_token: str | None = transact_write_items_input.get("ClientRequestToken") + + if client_token: + # we sort the payload since identical payload but with different order could cause + # IdempotentParameterMismatchException error if a client token is provided + context.request.data = to_bytes(canonical_json(json.loads(context.request.data))) + + result = self.forward_request(context) + + # determine and forward stream records + if tables_stream_type: + updated_items = ( + ItemFinder.find_existing_items( + put_items_per_table=existing_items_to_fetch, + account_id=context.account_id, + region_name=context.region, + endpoint_url=self.server.url, + ) + if updated_items_to_fetch + else {} + ) + + records_map = self.prepare_transact_write_item_records( + account_id=context.account_id, + region_name=context.region, + transact_items=transact_items, + existing_items=existing_items, + updated_items=updated_items, + tables_stream_type=tables_stream_type, + ) + self.forward_stream_records(context.account_id, context.region, records_map) + + return result + + @handler("TransactGetItems", expand=False) + def transact_get_items( + self, + context: RequestContext, + transact_items: TransactGetItemList, + return_consumed_capacity: ReturnConsumedCapacity = None, + ) -> TransactGetItemsOutput: + return self.forward_request(context) + + @handler("ExecuteTransaction", expand=False) + def execute_transaction( + self, context: RequestContext, execute_transaction_input: ExecuteTransactionInput + ) -> ExecuteTransactionOutput: + result = self.forward_request(context) + return result + + @handler("ExecuteStatement", expand=False) + def execute_statement( + self, + context: RequestContext, + execute_statement_input: ExecuteStatementInput, + ) -> ExecuteStatementOutput: + # TODO: this operation is still really slow with streams enabled + # find a way to make it better, same way as the other operations, by using returnvalues + # see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.update.html + statement = execute_statement_input["Statement"] + # We found out that 'Parameters' can be an empty list when the request comes from the AWS JS client. + if execute_statement_input.get("Parameters", None) == []: # noqa + raise ValidationException( + "1 validation error detected: Value '[]' at 'parameters' failed to satisfy constraint: Member must have length greater than or equal to 1" + ) + table_name = extract_table_name_from_partiql_update(statement) + existing_items = None + stream_type = table_name and get_table_stream_type( + context.account_id, context.region, table_name + ) + if stream_type: + # Note: fetching the entire list of items is hugely inefficient, especially for larger tables + # TODO: find a mechanism to hook into the PartiQL update mechanism of DynamoDB Local directly! + existing_items = ItemFinder.list_existing_items_for_statement( + partiql_statement=statement, + account_id=context.account_id, + region_name=context.region, + endpoint_url=self.server.url, + ) + + result = self.forward_request(context) + + # construct and forward stream record + if stream_type: + records = get_updated_records( + account_id=context.account_id, + region_name=context.region, + table_name=table_name, + existing_items=existing_items, + server_url=self.server.url, + table_stream_type=stream_type, + ) + self.forward_stream_records(context.account_id, context.region, records) + + return result + + # + # Tags + # + + def tag_resource( + self, context: RequestContext, resource_arn: ResourceArnString, tags: TagList, **kwargs + ) -> None: + table_tags = get_store(context.account_id, context.region).TABLE_TAGS + if resource_arn not in table_tags: + table_tags[resource_arn] = {} + table_tags[resource_arn].update({tag["Key"]: tag["Value"] for tag in tags}) + + def untag_resource( + self, + context: RequestContext, + resource_arn: ResourceArnString, + tag_keys: TagKeyList, + **kwargs, + ) -> None: + for tag_key in tag_keys or []: + get_store(context.account_id, context.region).TABLE_TAGS.get(resource_arn, {}).pop( + tag_key, None + ) + + def list_tags_of_resource( + self, + context: RequestContext, + resource_arn: ResourceArnString, + next_token: NextTokenString = None, + **kwargs, + ) -> ListTagsOfResourceOutput: + result = [ + {"Key": k, "Value": v} + for k, v in get_store(context.account_id, context.region) + .TABLE_TAGS.get(resource_arn, {}) + .items() + ] + return ListTagsOfResourceOutput(Tags=result) + + # + # TTLs + # + + def describe_time_to_live( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeTimeToLiveOutput: + if not self.table_exists(context.account_id, context.region, table_name): + raise ResourceNotFoundException( + f"Requested resource not found: Table: {table_name} not found" + ) + + backend = get_store(context.account_id, context.region) + ttl_spec = backend.ttl_specifications.get(table_name) + + result = {"TimeToLiveStatus": "DISABLED"} + if ttl_spec: + if ttl_spec.get("Enabled"): + ttl_status = "ENABLED" + else: + ttl_status = "DISABLED" + result = { + "AttributeName": ttl_spec.get("AttributeName"), + "TimeToLiveStatus": ttl_status, + } + + return DescribeTimeToLiveOutput(TimeToLiveDescription=result) + + def update_time_to_live( + self, + context: RequestContext, + table_name: TableName, + time_to_live_specification: TimeToLiveSpecification, + **kwargs, + ) -> UpdateTimeToLiveOutput: + if not self.table_exists(context.account_id, context.region, table_name): + raise ResourceNotFoundException( + f"Requested resource not found: Table: {table_name} not found" + ) + + # TODO: TTL status is maintained/mocked but no real expiry is happening for items + backend = get_store(context.account_id, context.region) + backend.ttl_specifications[table_name] = time_to_live_specification + return UpdateTimeToLiveOutput(TimeToLiveSpecification=time_to_live_specification) + + # + # Global tables + # + + def create_global_table( + self, + context: RequestContext, + global_table_name: TableName, + replication_group: ReplicaList, + **kwargs, + ) -> CreateGlobalTableOutput: + global_tables: Dict = get_store(context.account_id, context.region).GLOBAL_TABLES + if global_table_name in global_tables: + raise GlobalTableAlreadyExistsException("Global table with this name already exists") + replication_group = [grp.copy() for grp in replication_group or []] + data = {"GlobalTableName": global_table_name, "ReplicationGroup": replication_group} + global_tables[global_table_name] = data + for group in replication_group: + group["ReplicaStatus"] = "ACTIVE" + group["ReplicaStatusDescription"] = "Replica active" + return CreateGlobalTableOutput(GlobalTableDescription=data) + + def describe_global_table( + self, context: RequestContext, global_table_name: TableName, **kwargs + ) -> DescribeGlobalTableOutput: + details = get_store(context.account_id, context.region).GLOBAL_TABLES.get(global_table_name) + if not details: + raise GlobalTableNotFoundException("Global table with this name does not exist") + return DescribeGlobalTableOutput(GlobalTableDescription=details) + + def list_global_tables( + self, + context: RequestContext, + exclusive_start_global_table_name: TableName = None, + limit: PositiveIntegerObject = None, + region_name: RegionName = None, + **kwargs, + ) -> ListGlobalTablesOutput: + # TODO: add paging support + result = [ + select_attributes(tab, ["GlobalTableName", "ReplicationGroup"]) + for tab in get_store(context.account_id, context.region).GLOBAL_TABLES.values() + ] + return ListGlobalTablesOutput(GlobalTables=result) + + def update_global_table( + self, + context: RequestContext, + global_table_name: TableName, + replica_updates: ReplicaUpdateList, + **kwargs, + ) -> UpdateGlobalTableOutput: + details = get_store(context.account_id, context.region).GLOBAL_TABLES.get(global_table_name) + if not details: + raise GlobalTableNotFoundException("Global table with this name does not exist") + for update in replica_updates or []: + repl_group = details["ReplicationGroup"] + # delete existing + delete = update.get("Delete") + if delete: + details["ReplicationGroup"] = [ + g for g in repl_group if g["RegionName"] != delete["RegionName"] + ] + # create new + create = update.get("Create") + if create: + exists = [g for g in repl_group if g["RegionName"] == create["RegionName"]] + if exists: + continue + new_group = { + "RegionName": create["RegionName"], + "ReplicaStatus": "ACTIVE", + "ReplicaStatusDescription": "Replica active", + } + details["ReplicationGroup"].append(new_group) + return UpdateGlobalTableOutput(GlobalTableDescription=details) + + # + # Kinesis Streaming + # + + def enable_kinesis_streaming_destination( + self, + context: RequestContext, + table_name: TableName, + stream_arn: StreamArn, + enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration = None, + **kwargs, + ) -> KinesisStreamingDestinationOutput: + self.ensure_table_exists(context.account_id, context.region, table_name) + + stream = self._event_forwarder.is_kinesis_stream_exists(stream_arn=stream_arn) + if not stream: + raise ValidationException("User does not have a permission to use kinesis stream") + + table_def = get_store(context.account_id, context.region).table_definitions.setdefault( + table_name, {} + ) + + dest_status = table_def.get("KinesisDataStreamDestinationStatus") + if dest_status not in ["DISABLED", "ENABLE_FAILED", None]: + raise ValidationException( + "Table is not in a valid state to enable Kinesis Streaming " + "Destination:EnableKinesisStreamingDestination must be DISABLED or ENABLE_FAILED " + "to perform ENABLE operation." + ) + + table_def["KinesisDataStreamDestinations"] = ( + table_def.get("KinesisDataStreamDestinations") or [] + ) + # remove the stream destination if already present + table_def["KinesisDataStreamDestinations"] = [ + t for t in table_def["KinesisDataStreamDestinations"] if t["StreamArn"] != stream_arn + ] + # append the active stream destination at the end of the list + table_def["KinesisDataStreamDestinations"].append( + { + "DestinationStatus": DestinationStatus.ACTIVE, + "DestinationStatusDescription": "Stream is active", + "StreamArn": stream_arn, + } + ) + table_def["KinesisDataStreamDestinationStatus"] = DestinationStatus.ACTIVE + return KinesisStreamingDestinationOutput( + DestinationStatus=DestinationStatus.ACTIVE, StreamArn=stream_arn, TableName=table_name + ) + + def disable_kinesis_streaming_destination( + self, + context: RequestContext, + table_name: TableName, + stream_arn: StreamArn, + enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration = None, + **kwargs, + ) -> KinesisStreamingDestinationOutput: + self.ensure_table_exists(context.account_id, context.region, table_name) + + stream = self._event_forwarder.is_kinesis_stream_exists(stream_arn=stream_arn) + if not stream: + raise ValidationException( + "User does not have a permission to use kinesis stream", + ) + + table_def = get_store(context.account_id, context.region).table_definitions.setdefault( + table_name, {} + ) + + stream_destinations = table_def.get("KinesisDataStreamDestinations") + if stream_destinations: + if table_def["KinesisDataStreamDestinationStatus"] == DestinationStatus.ACTIVE: + for dest in stream_destinations: + if ( + dest["StreamArn"] == stream_arn + and dest["DestinationStatus"] == DestinationStatus.ACTIVE + ): + dest["DestinationStatus"] = DestinationStatus.DISABLED + dest["DestinationStatusDescription"] = ("Stream is disabled",) + table_def["KinesisDataStreamDestinationStatus"] = DestinationStatus.DISABLED + return KinesisStreamingDestinationOutput( + DestinationStatus=DestinationStatus.DISABLED, + StreamArn=stream_arn, + TableName=table_name, + ) + raise ValidationException( + "Table is not in a valid state to disable Kinesis Streaming Destination:" + "DisableKinesisStreamingDestination must be ACTIVE to perform DISABLE operation." + ) + + def describe_kinesis_streaming_destination( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeKinesisStreamingDestinationOutput: + self.ensure_table_exists(context.account_id, context.region, table_name) + + table_def = ( + get_store(context.account_id, context.region).table_definitions.get(table_name) or {} + ) + + stream_destinations = table_def.get("KinesisDataStreamDestinations") or [] + return DescribeKinesisStreamingDestinationOutput( + KinesisDataStreamDestinations=stream_destinations, TableName=table_name + ) + + # + # Continuous Backups + # + + def describe_continuous_backups( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeContinuousBackupsOutput: + self.get_global_table_region(context, table_name) + store = get_store(context.account_id, context.region) + continuous_backup_description = ( + store.table_properties.get(table_name, {}).get("ContinuousBackupsDescription") + ) or ContinuousBackupsDescription( + ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED, + PointInTimeRecoveryDescription=PointInTimeRecoveryDescription( + PointInTimeRecoveryStatus=PointInTimeRecoveryStatus.DISABLED + ), + ) + + return DescribeContinuousBackupsOutput( + ContinuousBackupsDescription=continuous_backup_description + ) + + def update_continuous_backups( + self, + context: RequestContext, + table_name: TableName, + point_in_time_recovery_specification: PointInTimeRecoverySpecification, + **kwargs, + ) -> UpdateContinuousBackupsOutput: + self.get_global_table_region(context, table_name) + + store = get_store(context.account_id, context.region) + pit_recovery_status = ( + PointInTimeRecoveryStatus.ENABLED + if point_in_time_recovery_specification["PointInTimeRecoveryEnabled"] + else PointInTimeRecoveryStatus.DISABLED + ) + continuous_backup_description = ContinuousBackupsDescription( + ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED, + PointInTimeRecoveryDescription=PointInTimeRecoveryDescription( + PointInTimeRecoveryStatus=pit_recovery_status + ), + ) + table_props = store.table_properties.setdefault(table_name, {}) + table_props["ContinuousBackupsDescription"] = continuous_backup_description + + return UpdateContinuousBackupsOutput( + ContinuousBackupsDescription=continuous_backup_description + ) + + # + # Helpers + # + + @staticmethod + def ddb_region_name(region_name: str) -> str: + """Map `local` or `localhost` region to the us-east-1 region. These values are used by NoSQL Workbench.""" + # TODO: could this be somehow moved into the request handler chain? + if region_name in ("local", "localhost"): + region_name = AWS_REGION_US_EAST_1 + + return region_name + + @staticmethod + def table_exists(account_id: str, region_name: str, table_name: str) -> bool: + region_name = DynamoDBProvider.ddb_region_name(region_name) + + client = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).dynamodb + return dynamodb_table_exists(table_name, client) + + @staticmethod + def ensure_table_exists(account_id: str, region_name: str, table_name: str): + """ + Raise ResourceNotFoundException if the given table does not exist. + + :param account_id: account id + :param region_name: region name + :param table_name: table name + :raise: ResourceNotFoundException if table does not exist in DynamoDB Local + """ + if not DynamoDBProvider.table_exists(account_id, region_name, table_name): + raise ResourceNotFoundException("Cannot do operations on a non-existent table") + + @staticmethod + def get_global_table_region(context: RequestContext, table_name: str) -> str: + """ + Return the table region considering that it might be a replicated table. + + Replication in LocalStack works by keeping a single copy of a table and forwarding + requests to the region where this table exists. + + This method does not check whether the table actually exists in DDBLocal. + + :param context: request context + :param table_name: table name + :return: region + """ + store = get_store(context.account_id, context.region) + + table_region = store.TABLE_REGION.get(table_name) + replicated_at = store.REPLICAS.get(table_name, {}).keys() + + if context.region == table_region or context.region in replicated_at: + return table_region + + return context.region + + @staticmethod + def prepare_request_headers(headers: Dict, account_id: str, region_name: str): + """ + Modify the Credentials field of Authorization header to achieve namespacing in DynamoDBLocal. + """ + region_name = DynamoDBProvider.ddb_region_name(region_name) + key = get_ddb_access_key(account_id, region_name) + + # DynamoDBLocal namespaces based on the value of Credentials + # Since we want to namespace by both account ID and region, use an aggregate key + # We also replace the region to keep compatibility with NoSQL Workbench + headers["Authorization"] = re.sub( + AUTH_CREDENTIAL_REGEX, + rf"Credential={key}/\2/{region_name}/\4/", + headers.get("Authorization") or "", + flags=re.IGNORECASE, + ) + + def fix_consumed_capacity(self, request: Dict, result: Dict): + # make sure we append 'ConsumedCapacity', which is properly + # returned by dynalite, but not by AWS's DynamoDBLocal + table_name = request.get("TableName") + return_cap = request.get("ReturnConsumedCapacity") + if "ConsumedCapacity" not in result and return_cap in ["TOTAL", "INDEXES"]: + request["ConsumedCapacity"] = { + "TableName": table_name, + "CapacityUnits": 5, # TODO hardcoded + "ReadCapacityUnits": 2, + "WriteCapacityUnits": 3, + } + + def fix_table_arn(self, account_id: str, region_name: str, arn: str) -> str: + """ + Set the correct account ID and region in ARNs returned by DynamoDB Local. + """ + partition = get_partition(region_name) + return ( + arn.replace("arn:aws:", f"arn:{partition}:") + .replace(":ddblocal:", f":{region_name}:") + .replace(":000000000000:", f":{account_id}:") + ) + + def prepare_transact_write_item_records( + self, + account_id: str, + region_name: str, + transact_items: TransactWriteItemList, + existing_items: BatchGetResponseMap, + updated_items: BatchGetResponseMap, + tables_stream_type: dict[TableName, TableStreamType], + ) -> RecordsMap: + records_only_map: dict[TableName, StreamRecords] = defaultdict(list) + + for request in transact_items: + record = self.get_record_template(region_name) + match request: + case {"Put": {"TableName": table_name, "Item": new_item}}: + if not (stream_type := tables_stream_type.get(table_name)): + continue + keys = SchemaExtractor.extract_keys( + item=new_item, + table_name=table_name, + account_id=account_id, + region_name=region_name, + ) + existing_item = find_item_for_keys_values_in_batch( + table_name, keys, existing_items + ) + if existing_item == new_item: + continue + + if stream_type.stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type + + record["eventID"] = short_uid() + record["eventName"] = "INSERT" if not existing_item else "MODIFY" + record["dynamodb"]["Keys"] = keys + if stream_type.needs_new_image: + record["dynamodb"]["NewImage"] = new_item + if existing_item and stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + + record_item = de_dynamize_record(new_item) + record["dynamodb"]["SizeBytes"] = _get_size_bytes(record_item) + records_only_map[table_name].append(record) + continue + + case {"Update": {"TableName": table_name, "Key": keys}}: + if not (stream_type := tables_stream_type.get(table_name)): + continue + updated_item = find_item_for_keys_values_in_batch( + table_name, keys, updated_items + ) + if not updated_item: + continue + + existing_item = find_item_for_keys_values_in_batch( + table_name, keys, existing_items + ) + if existing_item == updated_item: + # if the item is the same as the previous version, AWS does not send an event + continue + + if stream_type.stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type + + record["eventID"] = short_uid() + record["eventName"] = "MODIFY" if existing_item else "INSERT" + record["dynamodb"]["Keys"] = keys + + if existing_item and stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + if stream_type.needs_new_image: + record["dynamodb"]["NewImage"] = updated_item + + record["dynamodb"]["SizeBytes"] = _get_size_bytes(updated_item) + records_only_map[table_name].append(record) + continue + + case {"Delete": {"TableName": table_name, "Key": keys}}: + if not (stream_type := tables_stream_type.get(table_name)): + continue + + existing_item = find_item_for_keys_values_in_batch( + table_name, keys, existing_items + ) + if not existing_item: + continue + + if stream_type.stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_type.stream_view_type + + record["eventID"] = short_uid() + record["eventName"] = "REMOVE" + record["dynamodb"]["Keys"] = keys + if stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + record_item = de_dynamize_record(existing_item) + record["dynamodb"]["SizeBytes"] = _get_size_bytes(record_item) + + records_only_map[table_name].append(record) + continue + + records_map = { + table_name: TableRecords( + records=records, table_stream_type=tables_stream_type[table_name] + ) + for table_name, records in records_only_map.items() + } + + return records_map + + def batch_execute_statement( + self, + context: RequestContext, + statements: PartiQLBatchRequest, + return_consumed_capacity: ReturnConsumedCapacity = None, + **kwargs, + ) -> BatchExecuteStatementOutput: + result = self.forward_request(context) + return result + + def prepare_batch_write_item_records( + self, + account_id: str, + region_name: str, + tables_stream_type: dict[TableName, TableStreamType], + request_items: BatchWriteItemRequestMap, + existing_items: BatchGetResponseMap, + ) -> RecordsMap: + records_map: RecordsMap = {} + + # only iterate over tables with streams + for table_name, stream_type in tables_stream_type.items(): + existing_items_for_table_unordered = existing_items.get(table_name, []) + table_records: StreamRecords = [] + + def find_existing_item_for_keys_values(item_keys: dict) -> AttributeMap | None: + """ + This function looks up in the existing items for the provided item keys subset. If present, returns the + full item. + :param item_keys: the request item keys + :return: + """ + keys_items = item_keys.items() + for item in existing_items_for_table_unordered: + if keys_items <= item.items(): + return item + + for write_request in request_items[table_name]: + record = self.get_record_template( + region_name, + stream_view_type=stream_type.stream_view_type, + ) + match write_request: + case {"PutRequest": request}: + keys = SchemaExtractor.extract_keys( + item=request["Item"], + table_name=table_name, + account_id=account_id, + region_name=region_name, + ) + # we need to find if there was an existing item even if we don't need it for `OldImage`, because + # of the `eventName` + existing_item = find_existing_item_for_keys_values(keys) + if existing_item == request["Item"]: + # if the item is the same as the previous version, AWS does not send an event + continue + record["eventID"] = short_uid() + record["dynamodb"]["SizeBytes"] = _get_size_bytes(request["Item"]) + record["eventName"] = "INSERT" if not existing_item else "MODIFY" + record["dynamodb"]["Keys"] = keys + + if stream_type.needs_new_image: + record["dynamodb"]["NewImage"] = request["Item"] + if existing_item and stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + + table_records.append(record) + continue + + case {"DeleteRequest": request}: + keys = request["Key"] + if not (existing_item := find_existing_item_for_keys_values(keys)): + continue + + record["eventID"] = short_uid() + record["eventName"] = "REMOVE" + record["dynamodb"]["Keys"] = keys + if stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = existing_item + record["dynamodb"]["SizeBytes"] = _get_size_bytes(existing_item) + table_records.append(record) + continue + + records_map[table_name] = TableRecords( + records=table_records, table_stream_type=stream_type + ) + + return records_map + + def forward_stream_records( + self, + account_id: str, + region_name: str, + records_map: RecordsMap, + ) -> None: + if not records_map: + return + + self._event_forwarder.forward_to_targets( + account_id, region_name, records_map, background=True + ) + + @staticmethod + def get_record_template(region_name: str, stream_view_type: str | None = None) -> StreamRecord: + record = { + "eventID": short_uid(), + "eventVersion": "1.1", + "dynamodb": { + # expects nearest second rounded down + "ApproximateCreationDateTime": int(time.time()), + "SizeBytes": -1, + }, + "awsRegion": region_name, + "eventSource": "aws:dynamodb", + } + if stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_view_type + + return record + + def check_provisioned_throughput(self, action): + """ + Check rate limiting for an API operation and raise an error if provisioned throughput is exceeded. + """ + if self.should_throttle(action): + message = ( + "The level of configured provisioned throughput for the table was exceeded. " + + "Consider increasing your provisioning level with the UpdateTable API" + ) + raise ProvisionedThroughputExceededException(message) + + def action_should_throttle(self, action, actions): + throttled = [f"{ACTION_PREFIX}{a}" for a in actions] + return (action in throttled) or (action in actions) + + def should_throttle(self, action): + if ( + not config.DYNAMODB_READ_ERROR_PROBABILITY + and not config.DYNAMODB_ERROR_PROBABILITY + and not config.DYNAMODB_WRITE_ERROR_PROBABILITY + ): + # early exit so we don't need to call random() + return False + + rand = random.random() + if rand < config.DYNAMODB_READ_ERROR_PROBABILITY and self.action_should_throttle( + action, READ_THROTTLED_ACTIONS + ): + return True + elif rand < config.DYNAMODB_WRITE_ERROR_PROBABILITY and self.action_should_throttle( + action, WRITE_THROTTLED_ACTIONS + ): + return True + elif rand < config.DYNAMODB_ERROR_PROBABILITY and self.action_should_throttle( + action, THROTTLED_ACTIONS + ): + return True + return False + + +# --- +# Misc. util functions +# --- + + +def _get_size_bytes(item: dict) -> int: + try: + size_bytes = len(json.dumps(item, separators=(",", ":"))) + except TypeError: + size_bytes = len(str(item)) + return size_bytes + + +def get_global_secondary_index(account_id: str, region_name: str, table_name: str, index_name: str): + schema = SchemaExtractor.get_table_schema(table_name, account_id, region_name) + for index in schema["Table"].get("GlobalSecondaryIndexes", []): + if index["IndexName"] == index_name: + return index + raise ResourceNotFoundException("Index not found") + + +def is_local_secondary_index( + account_id: str, region_name: str, table_name: str, index_name: str +) -> bool: + schema = SchemaExtractor.get_table_schema(table_name, account_id, region_name) + for index in schema["Table"].get("LocalSecondaryIndexes", []): + if index["IndexName"] == index_name: + return True + return False + + +def is_index_query_valid(account_id: str, region_name: str, query_data: dict) -> bool: + table_name = to_str(query_data["TableName"]) + index_name = to_str(query_data["IndexName"]) + if is_local_secondary_index(account_id, region_name, table_name, index_name): + return True + index_query_type = query_data.get("Select") + index = get_global_secondary_index(account_id, region_name, table_name, index_name) + index_projection_type = index.get("Projection").get("ProjectionType") + if index_query_type == "ALL_ATTRIBUTES" and index_projection_type != "ALL": + return False + return True + + +def get_table_stream_type( + account_id: str, region_name: str, table_name_or_arn: str +) -> TableStreamType | None: + """ + :param account_id: the account id of the table + :param region_name: the region of the table + :param table_name_or_arn: the table name or ARN + :return: a TableStreamViewType object if the table has streams enabled. If not, return None + """ + if not table_name_or_arn: + return + + table_name = table_name_or_arn.split(":table/")[-1] + + is_kinesis = False + stream_view_type = None + + if table_definition := get_store(account_id, region_name).table_definitions.get(table_name): + if table_definition.get("KinesisDataStreamDestinationStatus") == "ACTIVE": + is_kinesis = True + + table_arn = arns.dynamodb_table_arn(table_name, account_id=account_id, region_name=region_name) + + if ( + stream := dynamodbstreams_api.get_stream_for_table(account_id, region_name, table_arn) + ) and stream["StreamStatus"] in (StreamStatus.ENABLING, StreamStatus.ENABLED): + stream_view_type = stream["StreamViewType"] + + if is_kinesis or stream_view_type: + return TableStreamType(stream_view_type, is_kinesis=is_kinesis) + + +def get_updated_records( + account_id: str, + region_name: str, + table_name: str, + existing_items: List, + server_url: str, + table_stream_type: TableStreamType, +) -> RecordsMap: + """ + Determine the list of record updates, to be sent to a DDB stream after a PartiQL update operation. + + Note: This is currently a fairly expensive operation, as we need to retrieve the list of all items + from the table, and compare the items to the previously available. This is a limitation as + we're currently using the DynamoDB Local backend as a blackbox. In future, we should consider hooking + into the PartiQL query execution inside DynamoDB Local and directly extract the list of updated items. + """ + result = [] + + key_schema = SchemaExtractor.get_key_schema(table_name, account_id, region_name) + before = ItemSet(existing_items, key_schema=key_schema) + all_table_items = ItemFinder.get_all_table_items( + account_id=account_id, + region_name=region_name, + table_name=table_name, + endpoint_url=server_url, + ) + after = ItemSet(all_table_items, key_schema=key_schema) + + def _add_record(item, comparison_set: ItemSet): + matching_item = comparison_set.find_item(item) + if matching_item == item: + return + + # determine event type + if comparison_set == after: + if matching_item: + return + event_name = "REMOVE" + else: + event_name = "INSERT" if not matching_item else "MODIFY" + + old_image = item if event_name == "REMOVE" else matching_item + new_image = matching_item if event_name == "REMOVE" else item + + # prepare record + keys = SchemaExtractor.extract_keys_for_schema(item=item, key_schema=key_schema) + + record = DynamoDBProvider.get_record_template(region_name) + record["eventName"] = event_name + record["dynamodb"]["Keys"] = keys + record["dynamodb"]["SizeBytes"] = _get_size_bytes(item) + + if table_stream_type.stream_view_type: + record["dynamodb"]["StreamViewType"] = table_stream_type.stream_view_type + if table_stream_type.needs_new_image: + record["dynamodb"]["NewImage"] = new_image + if old_image and table_stream_type.needs_old_image: + record["dynamodb"]["OldImage"] = old_image + + result.append(record) + + # loop over items in new item list (find INSERT/MODIFY events) + for item in after.items_list: + _add_record(item, before) + # loop over items in old item list (find REMOVE events) + for item in before.items_list: + _add_record(item, after) + + return {table_name: TableRecords(records=result, table_stream_type=table_stream_type)} + + +def create_dynamodb_stream(account_id: str, region_name: str, data, latest_stream_label): + stream = data["StreamSpecification"] + enabled = stream.get("StreamEnabled") + + if enabled not in [False, "False"]: + table_name = data["TableName"] + view_type = stream["StreamViewType"] + + dynamodbstreams_api.add_dynamodb_stream( + account_id=account_id, + region_name=region_name, + table_name=table_name, + latest_stream_label=latest_stream_label, + view_type=view_type, + enabled=enabled, + ) + + +def dynamodb_get_table_stream_specification(account_id: str, region_name: str, table_name: str): + try: + table_schema = SchemaExtractor.get_table_schema( + table_name, account_id=account_id, region_name=region_name + ) + return table_schema["Table"].get("StreamSpecification") + except Exception as e: + LOG.info( + "Unable to get stream specification for table %s: %s %s", + table_name, + e, + traceback.format_exc(), + ) + raise e + + +def find_item_for_keys_values_in_batch( + table_name: str, item_keys: dict, batch: BatchGetResponseMap +) -> AttributeMap | None: + """ + This function looks up in the existing items for the provided item keys subset. If present, returns the + full item. + :param table_name: the table name for the item + :param item_keys: the request item keys + :param batch: the values in which to look for the item + :return: a DynamoDB Item (AttributeMap) + """ + keys = item_keys.items() + for item in batch.get(table_name, []): + if keys <= item.items(): + return item diff --git a/localstack-core/localstack/services/dynamodb/resource_providers/__init__.py b/localstack-core/localstack/services/dynamodb/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable.py b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable.py new file mode 100644 index 0000000000000..af199a479576c --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable.py @@ -0,0 +1,423 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class DynamoDBGlobalTableProperties(TypedDict): + AttributeDefinitions: Optional[list[AttributeDefinition]] + KeySchema: Optional[list[KeySchema]] + Replicas: Optional[list[ReplicaSpecification]] + Arn: Optional[str] + BillingMode: Optional[str] + GlobalSecondaryIndexes: Optional[list[GlobalSecondaryIndex]] + LocalSecondaryIndexes: Optional[list[LocalSecondaryIndex]] + SSESpecification: Optional[SSESpecification] + StreamArn: Optional[str] + StreamSpecification: Optional[StreamSpecification] + TableId: Optional[str] + TableName: Optional[str] + TimeToLiveSpecification: Optional[TimeToLiveSpecification] + WriteProvisionedThroughputSettings: Optional[WriteProvisionedThroughputSettings] + + +class AttributeDefinition(TypedDict): + AttributeName: Optional[str] + AttributeType: Optional[str] + + +class KeySchema(TypedDict): + AttributeName: Optional[str] + KeyType: Optional[str] + + +class Projection(TypedDict): + NonKeyAttributes: Optional[list[str]] + ProjectionType: Optional[str] + + +class TargetTrackingScalingPolicyConfiguration(TypedDict): + TargetValue: Optional[float] + DisableScaleIn: Optional[bool] + ScaleInCooldown: Optional[int] + ScaleOutCooldown: Optional[int] + + +class CapacityAutoScalingSettings(TypedDict): + MaxCapacity: Optional[int] + MinCapacity: Optional[int] + TargetTrackingScalingPolicyConfiguration: Optional[TargetTrackingScalingPolicyConfiguration] + SeedCapacity: Optional[int] + + +class WriteProvisionedThroughputSettings(TypedDict): + WriteCapacityAutoScalingSettings: Optional[CapacityAutoScalingSettings] + + +class GlobalSecondaryIndex(TypedDict): + IndexName: Optional[str] + KeySchema: Optional[list[KeySchema]] + Projection: Optional[Projection] + WriteProvisionedThroughputSettings: Optional[WriteProvisionedThroughputSettings] + + +class LocalSecondaryIndex(TypedDict): + IndexName: Optional[str] + KeySchema: Optional[list[KeySchema]] + Projection: Optional[Projection] + + +class ContributorInsightsSpecification(TypedDict): + Enabled: Optional[bool] + + +class ReadProvisionedThroughputSettings(TypedDict): + ReadCapacityAutoScalingSettings: Optional[CapacityAutoScalingSettings] + ReadCapacityUnits: Optional[int] + + +class ReplicaGlobalSecondaryIndexSpecification(TypedDict): + IndexName: Optional[str] + ContributorInsightsSpecification: Optional[ContributorInsightsSpecification] + ReadProvisionedThroughputSettings: Optional[ReadProvisionedThroughputSettings] + + +class PointInTimeRecoverySpecification(TypedDict): + PointInTimeRecoveryEnabled: Optional[bool] + + +class ReplicaSSESpecification(TypedDict): + KMSMasterKeyId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class KinesisStreamSpecification(TypedDict): + StreamArn: Optional[str] + + +class ReplicaSpecification(TypedDict): + Region: Optional[str] + ContributorInsightsSpecification: Optional[ContributorInsightsSpecification] + DeletionProtectionEnabled: Optional[bool] + GlobalSecondaryIndexes: Optional[list[ReplicaGlobalSecondaryIndexSpecification]] + KinesisStreamSpecification: Optional[KinesisStreamSpecification] + PointInTimeRecoverySpecification: Optional[PointInTimeRecoverySpecification] + ReadProvisionedThroughputSettings: Optional[ReadProvisionedThroughputSettings] + SSESpecification: Optional[ReplicaSSESpecification] + TableClass: Optional[str] + Tags: Optional[list[Tag]] + + +class SSESpecification(TypedDict): + SSEEnabled: Optional[bool] + SSEType: Optional[str] + + +class StreamSpecification(TypedDict): + StreamViewType: Optional[str] + + +class TimeToLiveSpecification(TypedDict): + Enabled: Optional[bool] + AttributeName: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class DynamoDBGlobalTableProvider(ResourceProvider[DynamoDBGlobalTableProperties]): + TYPE = "AWS::DynamoDB::GlobalTable" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[DynamoDBGlobalTableProperties], + ) -> ProgressEvent[DynamoDBGlobalTableProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/TableName + + Required properties: + - KeySchema + - AttributeDefinitions + - Replicas + + Create-only properties: + - /properties/LocalSecondaryIndexes + - /properties/TableName + - /properties/KeySchema + + Read-only properties: + - /properties/Arn + - /properties/StreamArn + - /properties/TableId + + IAM permissions required: + - dynamodb:CreateTable + - dynamodb:CreateTableReplica + - dynamodb:Describe* + - dynamodb:UpdateTimeToLive + - dynamodb:UpdateContributorInsights + - dynamodb:UpdateContinuousBackups + - dynamodb:ListTagsOfResource + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + - dynamodb:PutItem + - dynamodb:GetItem + - dynamodb:DeleteItem + - dynamodb:BatchWriteItem + - dynamodb:TagResource + - dynamodb:EnableKinesisStreamingDestination + - dynamodb:DisableKinesisStreamingDestination + - dynamodb:DescribeKinesisStreamingDestination + - dynamodb:DescribeTableReplicaAutoScaling + - dynamodb:UpdateTableReplicaAutoScaling + - dynamodb:TagResource + - application-autoscaling:DeleteScalingPolicy + - application-autoscaling:DeleteScheduledAction + - application-autoscaling:DeregisterScalableTarget + - application-autoscaling:Describe* + - application-autoscaling:PutScalingPolicy + - application-autoscaling:PutScheduledAction + - application-autoscaling:RegisterScalableTarget + - kinesis:ListStreams + - kinesis:DescribeStream + - kinesis:PutRecords + - kms:CreateGrant + - kms:Describe* + - kms:Get* + - kms:List* + - kms:RevokeGrant + - cloudwatch:PutMetricData + + """ + model = request.desired_state + + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + + if not model.get("TableName"): + model["TableName"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + create_params = util.select_attributes( + model, + [ + "AttributeDefinitions", + "BillingMode", + "GlobalSecondaryIndexes", + "KeySchema", + "LocalSecondaryIndexes", + "Replicas", + "SSESpecification", + "StreamSpecification", + "TableName", + "WriteProvisionedThroughputSettings", + ], + ) + + replicas = create_params.pop("Replicas", []) + + if sse_specification := create_params.get("SSESpecification"): + # rename bool attribute to fit boto call + sse_specification["Enabled"] = sse_specification.pop("SSEEnabled") + + if stream_spec := model.get("StreamSpecification"): + create_params["StreamSpecification"] = { + "StreamEnabled": True, + **stream_spec, + } + + creation_response = request.aws_client_factory.dynamodb.create_table(**create_params) + model["Arn"] = creation_response["TableDescription"]["TableArn"] + model["TableId"] = creation_response["TableDescription"]["TableId"] + + if creation_response["TableDescription"].get("LatestStreamArn"): + model["StreamArn"] = creation_response["TableDescription"]["LatestStreamArn"] + + replicas_to_create = [] + for replica in replicas: + create = { + "RegionName": replica.get("Region"), + "KMSMasterKeyId": replica.get("KMSMasterKeyId"), + "ProvisionedThroughputOverride": replica.get("ProvisionedThroughputOverride"), + "GlobalSecondaryIndexes": replica.get("GlobalSecondaryIndexes"), + "TableClassOverride": replica.get("TableClassOverride"), + } + + create = {k: v for k, v in create.items() if v is not None} + + replicas_to_create.append({"Create": create}) + + request.aws_client_factory.dynamodb.update_table( + ReplicaUpdates=replicas_to_create, TableName=model["TableName"] + ) + + # add TTL config + if ttl_config := model.get("TimeToLiveSpecification"): + request.aws_client_factory.dynamodb.update_time_to_live( + TableName=model["TableName"], TimeToLiveSpecification=ttl_config + ) + + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + status = request.aws_client_factory.dynamodb.describe_table(TableName=model["TableName"])[ + "Table" + ]["TableStatus"] + if status == "ACTIVE": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + elif status == "CREATING": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + else: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + message=f"Table creation failed with status {status}", + ) + + def read( + self, + request: ResourceRequest[DynamoDBGlobalTableProperties], + ) -> ProgressEvent[DynamoDBGlobalTableProperties]: + """ + Fetch resource information + + IAM permissions required: + - dynamodb:Describe* + - application-autoscaling:Describe* + - cloudwatch:PutMetricData + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[DynamoDBGlobalTableProperties], + ) -> ProgressEvent[DynamoDBGlobalTableProperties]: + """ + Delete a resource + + IAM permissions required: + - dynamodb:Describe* + - application-autoscaling:DeleteScalingPolicy + - application-autoscaling:DeleteScheduledAction + - application-autoscaling:DeregisterScalableTarget + - application-autoscaling:Describe* + - application-autoscaling:PutScalingPolicy + - application-autoscaling:PutScheduledAction + - application-autoscaling:RegisterScalableTarget + """ + + model = request.desired_state + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + request.aws_client_factory.dynamodb.delete_table(TableName=model["TableName"]) + + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + try: + request.aws_client_factory.dynamodb.describe_table(TableName=model["TableName"]) + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + except Exception as ex: + if "ResourceNotFoundException" in str(ex): + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + message=str(ex), + ) + + def update( + self, + request: ResourceRequest[DynamoDBGlobalTableProperties], + ) -> ProgressEvent[DynamoDBGlobalTableProperties]: + """ + Update a resource + + IAM permissions required: + - dynamodb:Describe* + - dynamodb:CreateTableReplica + - dynamodb:UpdateTable + - dynamodb:UpdateTimeToLive + - dynamodb:UpdateContinuousBackups + - dynamodb:UpdateContributorInsights + - dynamodb:ListTagsOfResource + - dynamodb:Query + - dynamodb:Scan + - dynamodb:UpdateItem + - dynamodb:PutItem + - dynamodb:GetItem + - dynamodb:DeleteItem + - dynamodb:BatchWriteItem + - dynamodb:DeleteTable + - dynamodb:DeleteTableReplica + - dynamodb:UpdateItem + - dynamodb:TagResource + - dynamodb:UntagResource + - dynamodb:EnableKinesisStreamingDestination + - dynamodb:DisableKinesisStreamingDestination + - dynamodb:DescribeKinesisStreamingDestination + - dynamodb:DescribeTableReplicaAutoScaling + - dynamodb:UpdateTableReplicaAutoScaling + - application-autoscaling:DeleteScalingPolicy + - application-autoscaling:DeleteScheduledAction + - application-autoscaling:DeregisterScalableTarget + - application-autoscaling:Describe* + - application-autoscaling:PutScalingPolicy + - application-autoscaling:PutScheduledAction + - application-autoscaling:RegisterScalableTarget + - kinesis:ListStreams + - kinesis:DescribeStream + - kinesis:PutRecords + - kms:CreateGrant + - kms:Describe* + - kms:Get* + - kms:List* + - kms:RevokeGrant + - cloudwatch:PutMetricData + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable.schema.json b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable.schema.json new file mode 100644 index 0000000000000..3caa6a203393a --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable.schema.json @@ -0,0 +1,574 @@ +{ + "typeName": "AWS::DynamoDB::GlobalTable", + "description": "Version: None. Resource Type definition for AWS::DynamoDB::GlobalTable", + "additionalProperties": false, + "properties": { + "Arn": { + "type": "string" + }, + "StreamArn": { + "type": "string" + }, + "AttributeDefinitions": { + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/AttributeDefinition" + }, + "minItems": 1 + }, + "BillingMode": { + "type": "string" + }, + "GlobalSecondaryIndexes": { + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/GlobalSecondaryIndex" + } + }, + "KeySchema": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/KeySchema" + }, + "minItems": 1, + "maxItems": 2 + }, + "LocalSecondaryIndexes": { + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/LocalSecondaryIndex" + } + }, + "WriteProvisionedThroughputSettings": { + "$ref": "#/definitions/WriteProvisionedThroughputSettings" + }, + "Replicas": { + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/ReplicaSpecification" + }, + "minItems": 1 + }, + "SSESpecification": { + "$ref": "#/definitions/SSESpecification" + }, + "StreamSpecification": { + "$ref": "#/definitions/StreamSpecification" + }, + "TableName": { + "type": "string" + }, + "TableId": { + "type": "string" + }, + "TimeToLiveSpecification": { + "$ref": "#/definitions/TimeToLiveSpecification" + } + }, + "definitions": { + "StreamSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "StreamViewType": { + "type": "string" + } + }, + "required": [ + "StreamViewType" + ] + }, + "KinesisStreamSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "StreamArn": { + "type": "string" + } + }, + "required": [ + "StreamArn" + ] + }, + "KeySchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeName": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "KeyType": { + "type": "string" + } + }, + "required": [ + "KeyType", + "AttributeName" + ] + }, + "PointInTimeRecoverySpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "PointInTimeRecoveryEnabled": { + "type": "boolean" + } + } + }, + "ReplicaSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "Region": { + "type": "string" + }, + "GlobalSecondaryIndexes": { + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/ReplicaGlobalSecondaryIndexSpecification" + } + }, + "ContributorInsightsSpecification": { + "$ref": "#/definitions/ContributorInsightsSpecification" + }, + "PointInTimeRecoverySpecification": { + "$ref": "#/definitions/PointInTimeRecoverySpecification" + }, + "TableClass": { + "type": "string" + }, + "DeletionProtectionEnabled": { + "type": "boolean" + }, + "SSESpecification": { + "$ref": "#/definitions/ReplicaSSESpecification" + }, + "Tags": { + "type": "array", + "insertionOrder": false, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "ReadProvisionedThroughputSettings": { + "$ref": "#/definitions/ReadProvisionedThroughputSettings" + }, + "KinesisStreamSpecification": { + "$ref": "#/definitions/KinesisStreamSpecification" + } + }, + "required": [ + "Region" + ] + }, + "TimeToLiveSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeName": { + "type": "string" + }, + "Enabled": { + "type": "boolean" + } + }, + "required": [ + "Enabled" + ] + }, + "LocalSecondaryIndex": { + "type": "object", + "additionalProperties": false, + "properties": { + "IndexName": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "KeySchema": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/KeySchema" + }, + "maxItems": 2 + }, + "Projection": { + "$ref": "#/definitions/Projection" + } + }, + "required": [ + "IndexName", + "Projection", + "KeySchema" + ] + }, + "GlobalSecondaryIndex": { + "type": "object", + "additionalProperties": false, + "properties": { + "IndexName": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "KeySchema": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/KeySchema" + }, + "minItems": 1, + "maxItems": 2 + }, + "Projection": { + "$ref": "#/definitions/Projection" + }, + "WriteProvisionedThroughputSettings": { + "$ref": "#/definitions/WriteProvisionedThroughputSettings" + } + }, + "required": [ + "IndexName", + "Projection", + "KeySchema" + ] + }, + "SSESpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "SSEEnabled": { + "type": "boolean" + }, + "SSEType": { + "type": "string" + } + }, + "required": [ + "SSEEnabled" + ] + }, + "ReplicaSSESpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "KMSMasterKeyId": { + "type": "string" + } + }, + "required": [ + "KMSMasterKeyId" + ] + }, + "AttributeDefinition": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeName": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "AttributeType": { + "type": "string" + } + }, + "required": [ + "AttributeName", + "AttributeType" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "Projection": { + "type": "object", + "additionalProperties": false, + "properties": { + "NonKeyAttributes": { + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string" + }, + "maxItems": 20 + }, + "ProjectionType": { + "type": "string" + } + } + }, + "ReplicaGlobalSecondaryIndexSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "IndexName": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "ContributorInsightsSpecification": { + "$ref": "#/definitions/ContributorInsightsSpecification" + }, + "ReadProvisionedThroughputSettings": { + "$ref": "#/definitions/ReadProvisionedThroughputSettings" + } + }, + "required": [ + "IndexName" + ] + }, + "ContributorInsightsSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + } + }, + "required": [ + "Enabled" + ] + }, + "ReadProvisionedThroughputSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "ReadCapacityUnits": { + "type": "integer", + "minimum": 1 + }, + "ReadCapacityAutoScalingSettings": { + "$ref": "#/definitions/CapacityAutoScalingSettings" + } + } + }, + "WriteProvisionedThroughputSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "WriteCapacityAutoScalingSettings": { + "$ref": "#/definitions/CapacityAutoScalingSettings" + } + } + }, + "CapacityAutoScalingSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "MinCapacity": { + "type": "integer", + "minimum": 1 + }, + "MaxCapacity": { + "type": "integer", + "minimum": 1 + }, + "SeedCapacity": { + "type": "integer", + "minimum": 1 + }, + "TargetTrackingScalingPolicyConfiguration": { + "$ref": "#/definitions/TargetTrackingScalingPolicyConfiguration" + } + }, + "required": [ + "MinCapacity", + "MaxCapacity", + "TargetTrackingScalingPolicyConfiguration" + ] + }, + "TargetTrackingScalingPolicyConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "DisableScaleIn": { + "type": "boolean" + }, + "ScaleInCooldown": { + "type": "integer", + "minimum": 0 + }, + "ScaleOutCooldown": { + "type": "integer", + "minimum": 0 + }, + "TargetValue": { + "type": "number", + "format": "double" + } + }, + "required": [ + "TargetValue" + ] + } + }, + "required": [ + "KeySchema", + "AttributeDefinitions", + "Replicas" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/StreamArn", + "/properties/TableId" + ], + "createOnlyProperties": [ + "/properties/LocalSecondaryIndexes", + "/properties/TableName", + "/properties/KeySchema" + ], + "primaryIdentifier": [ + "/properties/TableName" + ], + "additionalIdentifiers": [ + [ + "/properties/Arn" + ], + [ + "/properties/StreamArn" + ] + ], + "handlers": { + "create": { + "permissions": [ + "dynamodb:CreateTable", + "dynamodb:CreateTableReplica", + "dynamodb:Describe*", + "dynamodb:UpdateTimeToLive", + "dynamodb:UpdateContributorInsights", + "dynamodb:UpdateContinuousBackups", + "dynamodb:ListTagsOfResource", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:TagResource", + "dynamodb:EnableKinesisStreamingDestination", + "dynamodb:DisableKinesisStreamingDestination", + "dynamodb:DescribeKinesisStreamingDestination", + "dynamodb:DescribeTableReplicaAutoScaling", + "dynamodb:UpdateTableReplicaAutoScaling", + "dynamodb:TagResource", + "application-autoscaling:DeleteScalingPolicy", + "application-autoscaling:DeleteScheduledAction", + "application-autoscaling:DeregisterScalableTarget", + "application-autoscaling:Describe*", + "application-autoscaling:PutScalingPolicy", + "application-autoscaling:PutScheduledAction", + "application-autoscaling:RegisterScalableTarget", + "kinesis:ListStreams", + "kinesis:DescribeStream", + "kinesis:PutRecords", + "kms:CreateGrant", + "kms:Describe*", + "kms:Get*", + "kms:List*", + "kms:RevokeGrant", + "cloudwatch:PutMetricData" + ] + }, + "read": { + "permissions": [ + "dynamodb:Describe*", + "application-autoscaling:Describe*", + "cloudwatch:PutMetricData" + ] + }, + "update": { + "permissions": [ + "dynamodb:Describe*", + "dynamodb:CreateTableReplica", + "dynamodb:UpdateTable", + "dynamodb:UpdateTimeToLive", + "dynamodb:UpdateContinuousBackups", + "dynamodb:UpdateContributorInsights", + "dynamodb:ListTagsOfResource", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:UpdateItem", + "dynamodb:PutItem", + "dynamodb:GetItem", + "dynamodb:DeleteItem", + "dynamodb:BatchWriteItem", + "dynamodb:DeleteTable", + "dynamodb:DeleteTableReplica", + "dynamodb:UpdateItem", + "dynamodb:TagResource", + "dynamodb:UntagResource", + "dynamodb:EnableKinesisStreamingDestination", + "dynamodb:DisableKinesisStreamingDestination", + "dynamodb:DescribeKinesisStreamingDestination", + "dynamodb:DescribeTableReplicaAutoScaling", + "dynamodb:UpdateTableReplicaAutoScaling", + "application-autoscaling:DeleteScalingPolicy", + "application-autoscaling:DeleteScheduledAction", + "application-autoscaling:DeregisterScalableTarget", + "application-autoscaling:Describe*", + "application-autoscaling:PutScalingPolicy", + "application-autoscaling:PutScheduledAction", + "application-autoscaling:RegisterScalableTarget", + "kinesis:ListStreams", + "kinesis:DescribeStream", + "kinesis:PutRecords", + "kms:CreateGrant", + "kms:Describe*", + "kms:Get*", + "kms:List*", + "kms:RevokeGrant", + "cloudwatch:PutMetricData" + ], + "timeoutInMinutes": 1200 + }, + "delete": { + "permissions": [ + "dynamodb:Describe*", + "application-autoscaling:DeleteScalingPolicy", + "application-autoscaling:DeleteScheduledAction", + "application-autoscaling:DeregisterScalableTarget", + "application-autoscaling:Describe*", + "application-autoscaling:PutScalingPolicy", + "application-autoscaling:PutScheduledAction", + "application-autoscaling:RegisterScalableTarget" + ] + }, + "list": { + "permissions": [ + "dynamodb:ListTables", + "cloudwatch:PutMetricData" + ] + } + } +} diff --git a/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable_plugin.py b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable_plugin.py new file mode 100644 index 0000000000000..8de0265d3d5f1 --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_globaltable_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class DynamoDBGlobalTableProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::DynamoDB::GlobalTable" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.dynamodb.resource_providers.aws_dynamodb_globaltable import ( + DynamoDBGlobalTableProvider, + ) + + self.factory = DynamoDBGlobalTableProvider diff --git a/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table.py b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table.py new file mode 100644 index 0000000000000..469c944cca898 --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table.py @@ -0,0 +1,442 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class DynamoDBTableProperties(TypedDict): + KeySchema: Optional[list[KeySchema] | dict] + Arn: Optional[str] + AttributeDefinitions: Optional[list[AttributeDefinition]] + BillingMode: Optional[str] + ContributorInsightsSpecification: Optional[ContributorInsightsSpecification] + DeletionProtectionEnabled: Optional[bool] + GlobalSecondaryIndexes: Optional[list[GlobalSecondaryIndex]] + ImportSourceSpecification: Optional[ImportSourceSpecification] + KinesisStreamSpecification: Optional[KinesisStreamSpecification] + LocalSecondaryIndexes: Optional[list[LocalSecondaryIndex]] + PointInTimeRecoverySpecification: Optional[PointInTimeRecoverySpecification] + ProvisionedThroughput: Optional[ProvisionedThroughput] + SSESpecification: Optional[SSESpecification] + StreamArn: Optional[str] + StreamSpecification: Optional[StreamSpecification] + TableClass: Optional[str] + TableName: Optional[str] + Tags: Optional[list[Tag]] + TimeToLiveSpecification: Optional[TimeToLiveSpecification] + + +class AttributeDefinition(TypedDict): + AttributeName: Optional[str] + AttributeType: Optional[str] + + +class KeySchema(TypedDict): + AttributeName: Optional[str] + KeyType: Optional[str] + + +class Projection(TypedDict): + NonKeyAttributes: Optional[list[str]] + ProjectionType: Optional[str] + + +class ProvisionedThroughput(TypedDict): + ReadCapacityUnits: Optional[int] + WriteCapacityUnits: Optional[int] + + +class ContributorInsightsSpecification(TypedDict): + Enabled: Optional[bool] + + +class GlobalSecondaryIndex(TypedDict): + IndexName: Optional[str] + KeySchema: Optional[list[KeySchema]] + Projection: Optional[Projection] + ContributorInsightsSpecification: Optional[ContributorInsightsSpecification] + ProvisionedThroughput: Optional[ProvisionedThroughput] + + +class LocalSecondaryIndex(TypedDict): + IndexName: Optional[str] + KeySchema: Optional[list[KeySchema]] + Projection: Optional[Projection] + + +class PointInTimeRecoverySpecification(TypedDict): + PointInTimeRecoveryEnabled: Optional[bool] + + +class SSESpecification(TypedDict): + SSEEnabled: Optional[bool] + KMSMasterKeyId: Optional[str] + SSEType: Optional[str] + + +class StreamSpecification(TypedDict): + StreamViewType: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class TimeToLiveSpecification(TypedDict): + AttributeName: Optional[str] + Enabled: Optional[bool] + + +class KinesisStreamSpecification(TypedDict): + StreamArn: Optional[str] + + +class S3BucketSource(TypedDict): + S3Bucket: Optional[str] + S3BucketOwner: Optional[str] + S3KeyPrefix: Optional[str] + + +class Csv(TypedDict): + Delimiter: Optional[str] + HeaderList: Optional[list[str]] + + +class InputFormatOptions(TypedDict): + Csv: Optional[Csv] + + +class ImportSourceSpecification(TypedDict): + InputFormat: Optional[str] + S3BucketSource: Optional[S3BucketSource] + InputCompressionType: Optional[str] + InputFormatOptions: Optional[InputFormatOptions] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class DynamoDBTableProvider(ResourceProvider[DynamoDBTableProperties]): + TYPE = "AWS::DynamoDB::Table" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[DynamoDBTableProperties], + ) -> ProgressEvent[DynamoDBTableProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/TableName + + Required properties: + - KeySchema + + Create-only properties: + - /properties/TableName + - /properties/ImportSourceSpecification + + Read-only properties: + - /properties/Arn + - /properties/StreamArn + + IAM permissions required: + - dynamodb:CreateTable + - dynamodb:DescribeImport + - dynamodb:DescribeTable + - dynamodb:DescribeTimeToLive + - dynamodb:UpdateTimeToLive + - dynamodb:UpdateContributorInsights + - dynamodb:UpdateContinuousBackups + - dynamodb:DescribeContinuousBackups + - dynamodb:DescribeContributorInsights + - dynamodb:EnableKinesisStreamingDestination + - dynamodb:DisableKinesisStreamingDestination + - dynamodb:DescribeKinesisStreamingDestination + - dynamodb:ImportTable + - dynamodb:ListTagsOfResource + - dynamodb:TagResource + - dynamodb:UpdateTable + - kinesis:DescribeStream + - kinesis:PutRecords + - iam:CreateServiceLinkedRole + - kms:CreateGrant + - kms:Decrypt + - kms:Describe* + - kms:Encrypt + - kms:Get* + - kms:List* + - kms:RevokeGrant + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:DescribeLogGroups + - logs:DescribeLogStreams + - logs:PutLogEvents + - logs:PutRetentionPolicy + - s3:GetObject + - s3:GetObjectMetadata + - s3:ListBucket + + """ + model = request.desired_state + + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + + if not model.get("TableName"): + model["TableName"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + if model.get("ProvisionedThroughput"): + model["ProvisionedThroughput"] = self.get_ddb_provisioned_throughput(model) + + if model.get("GlobalSecondaryIndexes"): + model["GlobalSecondaryIndexes"] = self.get_ddb_global_sec_indexes(model) + + properties = [ + "TableName", + "AttributeDefinitions", + "KeySchema", + "BillingMode", + "ProvisionedThroughput", + "LocalSecondaryIndexes", + "GlobalSecondaryIndexes", + "Tags", + "SSESpecification", + ] + create_params = util.select_attributes(model, properties) + + if sse_specification := create_params.get("SSESpecification"): + # rename bool attribute to fit boto call + sse_specification["Enabled"] = sse_specification.pop("SSEEnabled") + + if stream_spec := model.get("StreamSpecification"): + create_params["StreamSpecification"] = { + "StreamEnabled": True, + **(stream_spec or {}), + } + + response = request.aws_client_factory.dynamodb.create_table(**create_params) + model["Arn"] = response["TableDescription"]["TableArn"] + + if model.get("KinesisStreamSpecification"): + request.aws_client_factory.dynamodb.enable_kinesis_streaming_destination( + **self.get_ddb_kinesis_stream_specification(model) + ) + + # add TTL config + if ttl_config := model.get("TimeToLiveSpecification"): + request.aws_client_factory.dynamodb.update_time_to_live( + TableName=model["TableName"], TimeToLiveSpecification=ttl_config + ) + + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + description = request.aws_client_factory.dynamodb.describe_table( + TableName=model["TableName"] + ) + + if description["Table"]["TableStatus"] != "ACTIVE": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + if model.get("TimeToLiveSpecification"): + request.aws_client_factory.dynamodb.update_time_to_live( + TableName=model["TableName"], + TimeToLiveSpecification=model["TimeToLiveSpecification"], + ) + + if description["Table"].get("LatestStreamArn"): + model["StreamArn"] = description["Table"]["LatestStreamArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[DynamoDBTableProperties], + ) -> ProgressEvent[DynamoDBTableProperties]: + """ + Fetch resource information + + IAM permissions required: + - dynamodb:DescribeTable + - dynamodb:DescribeContinuousBackups + - dynamodb:DescribeContributorInsights + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[DynamoDBTableProperties], + ) -> ProgressEvent[DynamoDBTableProperties]: + """ + Delete a resource + + IAM permissions required: + - dynamodb:DeleteTable + - dynamodb:DescribeTable + """ + model = request.desired_state + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + request.aws_client_factory.dynamodb.delete_table(TableName=model["TableName"]) + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + try: + table_state = request.aws_client_factory.dynamodb.describe_table( + TableName=model["TableName"] + ) + + match table_state["Table"]["TableStatus"]: + case "DELETING": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + case invalid_state: + return ProgressEvent( + status=OperationStatus.FAILED, + message=f"Table deletion failed. Table {model['TableName']} found in state {invalid_state}", # TODO: not validated yet + resource_model={}, + ) + except request.aws_client_factory.dynamodb.exceptions.TableNotFoundException: + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[DynamoDBTableProperties], + ) -> ProgressEvent[DynamoDBTableProperties]: + """ + Update a resource + + IAM permissions required: + - dynamodb:UpdateTable + - dynamodb:DescribeTable + - dynamodb:DescribeTimeToLive + - dynamodb:UpdateTimeToLive + - dynamodb:UpdateContinuousBackups + - dynamodb:UpdateContributorInsights + - dynamodb:DescribeContinuousBackups + - dynamodb:DescribeKinesisStreamingDestination + - dynamodb:ListTagsOfResource + - dynamodb:TagResource + - dynamodb:UntagResource + - dynamodb:DescribeContributorInsights + - dynamodb:EnableKinesisStreamingDestination + - dynamodb:DisableKinesisStreamingDestination + - kinesis:DescribeStream + - kinesis:PutRecords + - iam:CreateServiceLinkedRole + - kms:CreateGrant + - kms:Describe* + - kms:Get* + - kms:List* + - kms:RevokeGrant + """ + raise NotImplementedError + + def get_ddb_provisioned_throughput( + self, + properties: dict, + ) -> dict | None: + # see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html#cfn-dynamodb-table-provisionedthroughput + args = properties.get("ProvisionedThroughput") + if args == "AWS::NoValue": + return None + is_ondemand = properties.get("BillingMode") == "PAY_PER_REQUEST" + # if the BillingMode is set to PAY_PER_REQUEST, you cannot specify ProvisionedThroughput + # if the BillingMode is set to PROVISIONED (default), you have to specify ProvisionedThroughput + + if args is None: + if is_ondemand: + # do not return default value if it's on demand + return + + # return default values if it's not on demand + return { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + } + + if isinstance(args["ReadCapacityUnits"], str): + args["ReadCapacityUnits"] = int(args["ReadCapacityUnits"]) + if isinstance(args["WriteCapacityUnits"], str): + args["WriteCapacityUnits"] = int(args["WriteCapacityUnits"]) + + return args + + def get_ddb_global_sec_indexes( + self, + properties: dict, + ) -> list | None: + args: list = properties.get("GlobalSecondaryIndexes") + is_ondemand = properties.get("BillingMode") == "PAY_PER_REQUEST" + if not args: + return + + for index in args: + # we ignore ContributorInsightsSpecification as not supported yet in DynamoDB and CloudWatch + index.pop("ContributorInsightsSpecification", None) + provisioned_throughput = index.get("ProvisionedThroughput") + if is_ondemand and provisioned_throughput is None: + pass # optional for API calls + elif provisioned_throughput is not None: + # convert types + if isinstance((read_units := provisioned_throughput["ReadCapacityUnits"]), str): + provisioned_throughput["ReadCapacityUnits"] = int(read_units) + if isinstance((write_units := provisioned_throughput["WriteCapacityUnits"]), str): + provisioned_throughput["WriteCapacityUnits"] = int(write_units) + else: + raise Exception("Can't specify ProvisionedThroughput with PAY_PER_REQUEST") + return args + + def get_ddb_kinesis_stream_specification( + self, + properties: dict, + ) -> dict: + args = properties.get("KinesisStreamSpecification") + if args: + args["TableName"] = properties["TableName"] + return args + + def list( + self, + request: ResourceRequest[DynamoDBTableProperties], + ) -> ProgressEvent[DynamoDBTableProperties]: + resources = request.aws_client_factory.dynamodb.list_tables() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + DynamoDBTableProperties(TableName=resource) for resource in resources["TableNames"] + ], + ) diff --git a/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table.schema.json b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table.schema.json new file mode 100644 index 0000000000000..c4dd5ef70eb3d --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table.schema.json @@ -0,0 +1,514 @@ +{ + "typeName": "AWS::DynamoDB::Table", + "description": "Version: None. Resource Type definition for AWS::DynamoDB::Table", + "additionalProperties": false, + "properties": { + "Arn": { + "type": "string" + }, + "StreamArn": { + "type": "string" + }, + "AttributeDefinitions": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/AttributeDefinition" + } + }, + "BillingMode": { + "type": "string" + }, + "DeletionProtectionEnabled": { + "type": "boolean" + }, + "GlobalSecondaryIndexes": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/GlobalSecondaryIndex" + } + }, + "KeySchema": { + "oneOf": [ + { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/KeySchema" + } + }, + { + "type": "object" + } + ] + }, + "LocalSecondaryIndexes": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/LocalSecondaryIndex" + } + }, + "PointInTimeRecoverySpecification": { + "$ref": "#/definitions/PointInTimeRecoverySpecification" + }, + "TableClass": { + "type": "string" + }, + "ProvisionedThroughput": { + "$ref": "#/definitions/ProvisionedThroughput" + }, + "SSESpecification": { + "$ref": "#/definitions/SSESpecification" + }, + "StreamSpecification": { + "$ref": "#/definitions/StreamSpecification" + }, + "TableName": { + "type": "string" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "TimeToLiveSpecification": { + "$ref": "#/definitions/TimeToLiveSpecification" + }, + "ContributorInsightsSpecification": { + "$ref": "#/definitions/ContributorInsightsSpecification" + }, + "KinesisStreamSpecification": { + "$ref": "#/definitions/KinesisStreamSpecification" + }, + "ImportSourceSpecification": { + "$ref": "#/definitions/ImportSourceSpecification" + } + }, + "propertyTransform": { + "/properties/SSESpecification/KMSMasterKeyId": "$join([\"arn:(aws)[-]{0,1}[a-z]{0,2}[-]{0,1}[a-z]{0,3}:kms:[a-z]{2}[-]{1}[a-z]{3,10}[-]{0,1}[a-z]{0,4}[-]{1}[1-4]{1}:[0-9]{12}[:]{1}key\\/\", SSESpecification.KMSMasterKeyId])" + }, + "definitions": { + "StreamSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "StreamViewType": { + "type": "string" + } + }, + "required": [ + "StreamViewType" + ] + }, + "DeprecatedKeySchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "HashKeyElement": { + "$ref": "#/definitions/DeprecatedHashKeyElement" + } + }, + "required": [ + "HashKeyElement" + ] + }, + "DeprecatedHashKeyElement": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeType": { + "type": "string" + }, + "AttributeName": { + "type": "string" + } + }, + "required": [ + "AttributeType", + "AttributeName" + ] + }, + "KeySchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeName": { + "type": "string" + }, + "KeyType": { + "type": "string" + } + }, + "required": [ + "KeyType", + "AttributeName" + ] + }, + "PointInTimeRecoverySpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "PointInTimeRecoveryEnabled": { + "type": "boolean" + } + } + }, + "KinesisStreamSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "StreamArn": { + "type": "string" + } + }, + "required": [ + "StreamArn" + ] + }, + "TimeToLiveSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeName": { + "type": "string" + }, + "Enabled": { + "type": "boolean" + } + }, + "required": [ + "Enabled", + "AttributeName" + ] + }, + "LocalSecondaryIndex": { + "type": "object", + "additionalProperties": false, + "properties": { + "IndexName": { + "type": "string" + }, + "KeySchema": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/KeySchema" + } + }, + "Projection": { + "$ref": "#/definitions/Projection" + } + }, + "required": [ + "IndexName", + "Projection", + "KeySchema" + ] + }, + "GlobalSecondaryIndex": { + "type": "object", + "additionalProperties": false, + "properties": { + "IndexName": { + "type": "string" + }, + "KeySchema": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/KeySchema" + } + }, + "Projection": { + "$ref": "#/definitions/Projection" + }, + "ProvisionedThroughput": { + "$ref": "#/definitions/ProvisionedThroughput" + }, + "ContributorInsightsSpecification": { + "$ref": "#/definitions/ContributorInsightsSpecification" + } + }, + "required": [ + "IndexName", + "Projection", + "KeySchema" + ] + }, + "SSESpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "KMSMasterKeyId": { + "type": "string" + }, + "SSEEnabled": { + "type": "boolean" + }, + "SSEType": { + "type": "string" + } + }, + "required": [ + "SSEEnabled" + ] + }, + "AttributeDefinition": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeName": { + "type": "string" + }, + "AttributeType": { + "type": "string" + } + }, + "required": [ + "AttributeName", + "AttributeType" + ] + }, + "ProvisionedThroughput": { + "type": "object", + "additionalProperties": false, + "properties": { + "ReadCapacityUnits": { + "type": "integer" + }, + "WriteCapacityUnits": { + "type": "integer" + } + }, + "required": [ + "WriteCapacityUnits", + "ReadCapacityUnits" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "Projection": { + "type": "object", + "additionalProperties": false, + "properties": { + "NonKeyAttributes": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "ProjectionType": { + "type": "string" + } + } + }, + "ContributorInsightsSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + } + }, + "required": [ + "Enabled" + ] + }, + "ImportSourceSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "S3BucketSource": { + "$ref": "#/definitions/S3BucketSource" + }, + "InputFormat": { + "type": "string" + }, + "InputFormatOptions": { + "$ref": "#/definitions/InputFormatOptions" + }, + "InputCompressionType": { + "type": "string" + } + }, + "required": [ + "S3BucketSource", + "InputFormat" + ] + }, + "S3BucketSource": { + "type": "object", + "additionalProperties": false, + "properties": { + "S3BucketOwner": { + "type": "string" + }, + "S3Bucket": { + "type": "string" + }, + "S3KeyPrefix": { + "type": "string" + } + }, + "required": [ + "S3Bucket" + ] + }, + "InputFormatOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Csv": { + "$ref": "#/definitions/Csv" + } + } + }, + "Csv": { + "type": "object", + "additionalProperties": false, + "properties": { + "HeaderList": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Delimiter": { + "type": "string" + } + } + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": false, + "tagProperty": "/properties/Tags" + }, + "required": [ + "KeySchema" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/StreamArn" + ], + "createOnlyProperties": [ + "/properties/TableName", + "/properties/ImportSourceSpecification" + ], + "primaryIdentifier": [ + "/properties/TableName" + ], + "writeOnlyProperties": [ + "/properties/ImportSourceSpecification" + ], + "handlers": { + "create": { + "permissions": [ + "dynamodb:CreateTable", + "dynamodb:DescribeImport", + "dynamodb:DescribeTable", + "dynamodb:DescribeTimeToLive", + "dynamodb:UpdateTimeToLive", + "dynamodb:UpdateContributorInsights", + "dynamodb:UpdateContinuousBackups", + "dynamodb:DescribeContinuousBackups", + "dynamodb:DescribeContributorInsights", + "dynamodb:EnableKinesisStreamingDestination", + "dynamodb:DisableKinesisStreamingDestination", + "dynamodb:DescribeKinesisStreamingDestination", + "dynamodb:ImportTable", + "dynamodb:ListTagsOfResource", + "dynamodb:TagResource", + "dynamodb:UpdateTable", + "kinesis:DescribeStream", + "kinesis:PutRecords", + "iam:CreateServiceLinkedRole", + "kms:CreateGrant", + "kms:Decrypt", + "kms:Describe*", + "kms:Encrypt", + "kms:Get*", + "kms:List*", + "kms:RevokeGrant", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "logs:PutLogEvents", + "logs:PutRetentionPolicy", + "s3:GetObject", + "s3:GetObjectMetadata", + "s3:ListBucket" + ], + "timeoutInMinutes": 720 + }, + "read": { + "permissions": [ + "dynamodb:DescribeTable", + "dynamodb:DescribeContinuousBackups", + "dynamodb:DescribeContributorInsights" + ] + }, + "update": { + "permissions": [ + "dynamodb:UpdateTable", + "dynamodb:DescribeTable", + "dynamodb:DescribeTimeToLive", + "dynamodb:UpdateTimeToLive", + "dynamodb:UpdateContinuousBackups", + "dynamodb:UpdateContributorInsights", + "dynamodb:DescribeContinuousBackups", + "dynamodb:DescribeKinesisStreamingDestination", + "dynamodb:ListTagsOfResource", + "dynamodb:TagResource", + "dynamodb:UntagResource", + "dynamodb:DescribeContributorInsights", + "dynamodb:EnableKinesisStreamingDestination", + "dynamodb:DisableKinesisStreamingDestination", + "kinesis:DescribeStream", + "kinesis:PutRecords", + "iam:CreateServiceLinkedRole", + "kms:CreateGrant", + "kms:Describe*", + "kms:Get*", + "kms:List*", + "kms:RevokeGrant" + ], + "timeoutInMinutes": 720 + }, + "delete": { + "permissions": [ + "dynamodb:DeleteTable", + "dynamodb:DescribeTable" + ], + "timeoutInMinutes": 720 + }, + "list": { + "permissions": [ + "dynamodb:ListTables" + ] + } + } +} diff --git a/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table_plugin.py b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table_plugin.py new file mode 100644 index 0000000000000..5f263b9e9d068 --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/resource_providers/aws_dynamodb_table_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class DynamoDBTableProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::DynamoDB::Table" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.dynamodb.resource_providers.aws_dynamodb_table import ( + DynamoDBTableProvider, + ) + + self.factory = DynamoDBTableProvider diff --git a/localstack-core/localstack/services/dynamodb/server.py b/localstack-core/localstack/services/dynamodb/server.py new file mode 100644 index 0000000000000..dba7c321ebbd2 --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/server.py @@ -0,0 +1,220 @@ +import logging +import os +import threading + +from localstack import config +from localstack.aws.connect import connect_externally_to +from localstack.aws.forwarder import AwsRequestProxy +from localstack.config import is_env_true +from localstack.constants import DEFAULT_AWS_ACCOUNT_ID +from localstack.services.dynamodb.packages import dynamodblocal_package +from localstack.utils.common import TMP_THREADS, ShellCommandThread, get_free_tcp_port, mkdir +from localstack.utils.functions import run_safe +from localstack.utils.net import wait_for_port_closed +from localstack.utils.objects import singleton_factory +from localstack.utils.platform import Arch, get_arch +from localstack.utils.run import FuncThread, run +from localstack.utils.serving import Server +from localstack.utils.sync import retry, synchronized + +LOG = logging.getLogger(__name__) +RESTART_LOCK = threading.RLock() + + +def _log_listener(line, **_kwargs): + LOG.debug(line.rstrip()) + + +class DynamodbServer(Server): + db_path: str | None + heap_size: str + + delay_transient_statuses: bool + optimize_db_before_startup: bool + share_db: bool + cors: str | None + + proxy: AwsRequestProxy + + def __init__( + self, + port: int | None = None, + host: str = "localhost", + db_path: str | None = None, + ) -> None: + """ + Creates a DynamoDB server from the local configuration. + + :param port: optional, the port to start the server on (defaults to a random port) + :param host: localhost by default + :param db_path: path to the persistence state files used by the DynamoDB Local process + """ + + port = port or get_free_tcp_port() + super().__init__(port, host) + + self.db_path = ( + f"{config.dirs.data}/dynamodb" if not db_path and config.dirs.data else db_path + ) + + # the DYNAMODB_IN_MEMORY variable takes precedence and will set the DB path to None which forces inMemory=true + if is_env_true("DYNAMODB_IN_MEMORY"): + # note: with DYNAMODB_IN_MEMORY we do not support persistence + self.db_path = None + + if self.db_path: + self.db_path = os.path.abspath(self.db_path) + + self.heap_size = config.DYNAMODB_HEAP_SIZE + self.delay_transient_statuses = is_env_true("DYNAMODB_DELAY_TRANSIENT_STATUSES") + self.optimize_db_before_startup = is_env_true("DYNAMODB_OPTIMIZE_DB_BEFORE_STARTUP") + self.share_db = is_env_true("DYNAMODB_SHARE_DB") + self.cors = os.getenv("DYNAMODB_CORS", None) + self.proxy = AwsRequestProxy(self.url) + + @staticmethod + @singleton_factory + def get() -> "DynamodbServer": + return DynamodbServer(config.DYNAMODB_LOCAL_PORT) + + @synchronized(lock=RESTART_LOCK) + def start_dynamodb(self) -> bool: + """Start the DynamoDB server.""" + + # We want this method to be idempotent. + if self.is_running() and self.is_up(): + return True + + # For the v2 provider, the DynamodbServer has been made a singleton. Yet, the Server abstraction is modelled + # after threading.Thread, where Start -> Stop -> Start is not allowed. This flow happens during state resets. + # The following is a workaround that permits this flow + self._started.clear() + self._stopped.clear() + + # Note: when starting the server, we had a flag for wiping the assets directory before the actual start. + # This behavior was needed in some particular cases: + # - pod load with some assets already lying in the asset folder + # - ... + # The cleaning is now done via the reset endpoint + if self.db_path: + mkdir(self.db_path) + + started = self.start() + self.wait_for_dynamodb() + return started + + @synchronized(lock=RESTART_LOCK) + def stop_dynamodb(self) -> None: + """Stop the DynamoDB server.""" + import psutil + + if self._thread is None: + return + self._thread.auto_restart = False + self.shutdown() + self.join(timeout=10) + try: + wait_for_port_closed(self.port, sleep_time=0.8, retries=10) + except Exception: + LOG.warning( + "DynamoDB server port %s (%s) unexpectedly still open; running processes: %s", + self.port, + self._thread, + run(["ps", "aux"]), + ) + + # attempt to terminate/kill the process manually + server_pid = self._thread.process.pid # noqa + LOG.info("Attempting to kill DynamoDB process %s", server_pid) + process = psutil.Process(server_pid) + run_safe(process.terminate) + run_safe(process.kill) + wait_for_port_closed(self.port, sleep_time=0.5, retries=8) + + @property + def in_memory(self) -> bool: + return self.db_path is None + + @property + def jar_path(self) -> str: + return f"{dynamodblocal_package.get_installed_dir()}/DynamoDBLocal.jar" + + @property + def library_path(self) -> str: + return f"{dynamodblocal_package.get_installed_dir()}/DynamoDBLocal_lib" + + def _get_java_vm_options(self) -> list[str]: + # Workaround for JVM SIGILL crash on Apple Silicon M4 + # See https://bugs.openjdk.org/browse/JDK-8345296 + # To be removed after Java is bumped to 17.0.15+ and 21.0.7+ + return ["-XX:UseSVE=0"] if Arch.arm64 == get_arch() else [] + + def _create_shell_command(self) -> list[str]: + cmd = [ + "java", + *self._get_java_vm_options(), + "-Xmx%s" % self.heap_size, + f"-javaagent:{dynamodblocal_package.get_installer().get_ddb_agent_jar_path()}", + f"-Djava.library.path={self.library_path}", + "-jar", + self.jar_path, + ] + parameters = [] + + parameters.extend(["-port", str(self.port)]) + if self.in_memory: + parameters.append("-inMemory") + if self.db_path: + parameters.extend(["-dbPath", self.db_path]) + if self.delay_transient_statuses: + parameters.extend(["-delayTransientStatuses"]) + if self.optimize_db_before_startup: + parameters.extend(["-optimizeDbBeforeStartup"]) + if self.share_db: + parameters.extend(["-sharedDb"]) + + return cmd + parameters + + def do_start_thread(self) -> FuncThread: + dynamodblocal_installer = dynamodblocal_package.get_installer() + dynamodblocal_installer.install() + + cmd = self._create_shell_command() + env_vars = { + **dynamodblocal_installer.get_java_env_vars(), + "DDB_LOCAL_TELEMETRY": "0", + } + + LOG.debug("Starting DynamoDB Local: %s", cmd) + t = ShellCommandThread( + cmd, + strip_color=True, + log_listener=_log_listener, + auto_restart=True, + name="dynamodb-local", + env_vars=env_vars, + ) + TMP_THREADS.append(t) + t.start() + return t + + def check_dynamodb(self, expect_shutdown: bool = False) -> None: + """Checks if DynamoDB server is up""" + out = None + + try: + self.wait_is_up() + out = connect_externally_to( + endpoint_url=self.url, + aws_access_key_id=DEFAULT_AWS_ACCOUNT_ID, + aws_secret_access_key=DEFAULT_AWS_ACCOUNT_ID, + ).dynamodb.list_tables() + except Exception: + LOG.exception("DynamoDB health check failed") + if expect_shutdown: + assert out is None + else: + assert isinstance(out["TableNames"], list) + + def wait_for_dynamodb(self) -> None: + retry(self.check_dynamodb, sleep=0.4, retries=10) diff --git a/localstack-core/localstack/services/dynamodb/utils.py b/localstack-core/localstack/services/dynamodb/utils.py new file mode 100644 index 0000000000000..4ff065440abec --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/utils.py @@ -0,0 +1,387 @@ +import logging +import re +from binascii import crc32 +from typing import Dict, List, Optional + +from boto3.dynamodb.types import TypeDeserializer, TypeSerializer +from cachetools import TTLCache +from moto.core.exceptions import JsonRESTError + +from localstack.aws.api import RequestContext +from localstack.aws.api.dynamodb import ( + AttributeMap, + BatchGetRequestMap, + BatchGetResponseMap, + Delete, + DeleteRequest, + Put, + PutRequest, + ResourceNotFoundException, + TableName, + Update, +) +from localstack.aws.api.dynamodbstreams import ( + ResourceNotFoundException as DynamoDBStreamsResourceNotFoundException, +) +from localstack.aws.connect import connect_to +from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY +from localstack.http import Response +from localstack.utils.aws.arns import ( + dynamodb_stream_arn, + dynamodb_table_arn, + get_partition, + parse_arn, +) +from localstack.utils.json import canonical_json +from localstack.utils.testutil import list_all_resources + +LOG = logging.getLogger(__name__) + +# cache schema definitions +SCHEMA_CACHE = TTLCache(maxsize=50, ttl=20) + +_ddb_local_arn_pattern = re.compile( + r'("TableArn"|"LatestStreamArn"|"StreamArn"|"ShardIterator"|"IndexArn")\s*:\s*"arn:[a-z-]+:dynamodb:ddblocal:000000000000:([^"]+)"' +) +_ddb_local_region_pattern = re.compile(r'"awsRegion"\s*:\s*"([^"]+)"') +_ddb_local_exception_arn_pattern = re.compile(r'arn:[a-z-]+:dynamodb:ddblocal:000000000000:([^"]+)') + + +def get_ddb_access_key(account_id: str, region_name: str) -> str: + """ + Get the access key to be used while communicating with DynamoDB Local. + + DDBLocal supports namespacing as an undocumented feature. It works based on the value of the `Credentials` + field of the `Authorization` header. We use a concatenated value of account ID and region to achieve + namespacing. + """ + return f"{account_id}{region_name}".replace("-", "") + + +class ItemSet: + """Represents a set of items and provides utils to find individual items in the set""" + + def __init__(self, items: List[Dict], key_schema: List[Dict]): + self.items_list = items + self.key_schema = key_schema + self._build_dict() + + def _build_dict(self): + self.items_dict = {} + for item in self.items_list: + self.items_dict[self._hashable_key(item)] = item + + def _hashable_key(self, item: Dict): + keys = SchemaExtractor.extract_keys_for_schema(item=item, key_schema=self.key_schema) + return canonical_json(keys) + + def find_item(self, item: Dict) -> Optional[Dict]: + key = self._hashable_key(item) + return self.items_dict.get(key) + + +class SchemaExtractor: + @classmethod + def extract_keys( + cls, item: Dict, table_name: str, account_id: str, region_name: str + ) -> Optional[Dict]: + key_schema = cls.get_key_schema(table_name, account_id, region_name) + return cls.extract_keys_for_schema(item, key_schema) + + @classmethod + def extract_keys_for_schema(cls, item: Dict, key_schema: List[Dict]): + result = {} + for key in key_schema: + attr_name = key["AttributeName"] + if attr_name not in item: + raise JsonRESTError( + error_type="ValidationException", + message="One of the required keys was not given a value", + ) + result[attr_name] = item[attr_name] + return result + + @classmethod + def get_key_schema( + cls, table_name: str, account_id: str, region_name: str + ) -> Optional[List[Dict]]: + from localstack.services.dynamodb.provider import get_store + + table_definitions: Dict = get_store( + account_id=account_id, + region_name=region_name, + ).table_definitions + table_def = table_definitions.get(table_name) + if not table_def: + # Try fetching from the backend in case table_definitions has been reset + schema = cls.get_table_schema( + table_name=table_name, account_id=account_id, region_name=region_name + ) + if not schema: + raise ResourceNotFoundException(f"Unknown table: {table_name} not found") + # Save the schema in the cache + table_definitions[table_name] = schema["Table"] + table_def = table_definitions[table_name] + return table_def["KeySchema"] + + @classmethod + def get_table_schema(cls, table_name: str, account_id: str, region_name: str): + key = dynamodb_table_arn( + table_name=table_name, account_id=account_id, region_name=region_name + ) + schema = SCHEMA_CACHE.get(key) + if not schema: + # TODO: consider making in-memory lookup instead of API call + ddb_client = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).dynamodb + try: + schema = ddb_client.describe_table(TableName=table_name) + SCHEMA_CACHE[key] = schema + except Exception as e: + if "ResourceNotFoundException" in str(e): + raise ResourceNotFoundException(f"Unknown table: {table_name}") from e + raise + return schema + + @classmethod + def invalidate_table_schema(cls, table_name: str, account_id: str, region_name: str): + """ + Allow cached table schemas to be invalidated without waiting for the TTL to expire + """ + key = dynamodb_table_arn( + table_name=table_name, account_id=account_id, region_name=region_name + ) + SCHEMA_CACHE.pop(key, None) + + +class ItemFinder: + @staticmethod + def get_ddb_local_client(account_id: str, region_name: str, endpoint_url: str): + ddb_client = connect_to( + aws_access_key_id=get_ddb_access_key(account_id, region_name), + region_name=region_name, + endpoint_url=endpoint_url, + ).dynamodb + return ddb_client + + @staticmethod + def find_existing_item( + put_item: Dict, + table_name: str, + account_id: str, + region_name: str, + endpoint_url: str, + ) -> Optional[AttributeMap]: + from localstack.services.dynamodb.provider import ValidationException + + ddb_client = ItemFinder.get_ddb_local_client(account_id, region_name, endpoint_url) + + search_key = {} + if "Key" in put_item: + search_key = put_item["Key"] + else: + schema = SchemaExtractor.get_table_schema(table_name, account_id, region_name) + schemas = [schema["Table"]["KeySchema"]] + for index in schema["Table"].get("GlobalSecondaryIndexes", []): + # TODO + # schemas.append(index['KeySchema']) + pass + for schema in schemas: + for key in schema: + key_name = key["AttributeName"] + key_value = put_item["Item"].get(key_name) + if not key_value: + raise ValidationException( + "The provided key element does not match the schema" + ) + search_key[key_name] = key_value + if not search_key: + return + + try: + existing_item = ddb_client.get_item(TableName=table_name, Key=search_key) + except ddb_client.exceptions.ClientError as e: + LOG.warning( + "Unable to get item from DynamoDB table '%s': %s", + table_name, + e, + ) + return + + return existing_item.get("Item") + + @staticmethod + def find_existing_items( + put_items_per_table: dict[ + TableName, list[PutRequest | DeleteRequest | Put | Update | Delete] + ], + account_id: str, + region_name: str, + endpoint_url: str, + ) -> BatchGetResponseMap: + from localstack.services.dynamodb.provider import ValidationException + + ddb_client = ItemFinder.get_ddb_local_client(account_id, region_name, endpoint_url) + + get_items_request: BatchGetRequestMap = {} + for table_name, put_item_reqs in put_items_per_table.items(): + table_schema = None + for put_item in put_item_reqs: + search_key = {} + if "Key" in put_item: + search_key = put_item["Key"] + else: + if not table_schema: + table_schema = SchemaExtractor.get_table_schema( + table_name, account_id, region_name + ) + + schemas = [table_schema["Table"]["KeySchema"]] + for index in table_schema["Table"].get("GlobalSecondaryIndexes", []): + # TODO + # schemas.append(index['KeySchema']) + pass + for schema in schemas: + for key in schema: + key_name = key["AttributeName"] + key_value = put_item["Item"].get(key_name) + if not key_value: + raise ValidationException( + "The provided key element does not match the schema" + ) + search_key[key_name] = key_value + if not search_key: + continue + table_keys = get_items_request.setdefault(table_name, {"Keys": []}) + table_keys["Keys"].append(search_key) + + try: + existing_items = ddb_client.batch_get_item(RequestItems=get_items_request) + except ddb_client.exceptions.ClientError as e: + LOG.warning( + "Unable to get items from DynamoDB tables '%s': %s", + list(put_items_per_table.values()), + e, + ) + return {} + + return existing_items.get("Responses", {}) + + @classmethod + def list_existing_items_for_statement( + cls, partiql_statement: str, account_id: str, region_name: str, endpoint_url: str + ) -> List: + table_name = extract_table_name_from_partiql_update(partiql_statement) + if not table_name: + return [] + all_items = cls.get_all_table_items( + account_id=account_id, + region_name=region_name, + table_name=table_name, + endpoint_url=endpoint_url, + ) + return all_items + + @staticmethod + def get_all_table_items( + account_id: str, region_name: str, table_name: str, endpoint_url: str + ) -> List: + ddb_client = ItemFinder.get_ddb_local_client(account_id, region_name, endpoint_url) + dynamodb_kwargs = {"TableName": table_name} + all_items = list_all_resources( + lambda kwargs: ddb_client.scan(**{**kwargs, **dynamodb_kwargs}), + last_token_attr_name="LastEvaluatedKey", + next_token_attr_name="ExclusiveStartKey", + list_attr_name="Items", + ) + return all_items + + +def extract_table_name_from_partiql_update(statement: str) -> Optional[str]: + regex = r"^\s*(UPDATE|INSERT\s+INTO|DELETE\s+FROM)\s+([^\s]+).*" + match = re.match(regex, statement, flags=re.IGNORECASE | re.MULTILINE) + return match and match.group(2) + + +def dynamize_value(value) -> dict: + """ + Take a scalar Python value or dict/list and return a dict consisting of the Amazon DynamoDB type specification and + the value that needs to be sent to Amazon DynamoDB. If the type of the value is not supported, raise a TypeError + """ + return TypeSerializer().serialize(value) + + +def de_dynamize_record(item: dict) -> dict: + """ + Return the given item in DynamoDB format parsed as regular dict object, i.e., convert + something like `{'foo': {'S': 'test'}, 'bar': {'N': 123}}` to `{'foo': 'test', 'bar': 123}`. + Note: This is the reverse operation of `dynamize_value(...)` above. + """ + deserializer = TypeDeserializer() + return {k: deserializer.deserialize(v) for k, v in item.items()} + + +def modify_ddblocal_arns(chain, context: RequestContext, response: Response): + """A service response handler that modifies the dynamodb backend response.""" + if response_content := response.get_data(as_text=True): + partition = get_partition(context.region) + + def _convert_arn(matchobj): + key = matchobj.group(1) + table_name = matchobj.group(2) + return f'{key}: "arn:{partition}:dynamodb:{context.region}:{context.account_id}:{table_name}"' + + # fix the table and latest stream ARNs (DynamoDBLocal hardcodes "ddblocal" as the region) + content_replaced = _ddb_local_arn_pattern.sub( + _convert_arn, + response_content, + ) + if context.service.service_name == "dynamodbstreams": + content_replaced = _ddb_local_region_pattern.sub( + f'"awsRegion": "{context.region}"', content_replaced + ) + if context.service_exception: + content_replaced = _ddb_local_exception_arn_pattern.sub( + rf"arn:{partition}:dynamodb:{context.region}:{context.account_id}:\g<1>", + content_replaced, + ) + + if content_replaced != response_content: + response.data = content_replaced + # make sure the service response is parsed again later + context.service_response = None + + # update x-amz-crc32 header required by some clients + response.headers["x-amz-crc32"] = crc32(response.data) & 0xFFFFFFFF + + +def change_region_in_ddb_stream_arn(arn: str, region: str) -> str: + """ + Modify the ARN or a DynamoDB Stream by changing its region. + We need this logic when dealing with global tables, as we create a stream only in the originating region, and we + need to modify the ARN to mimic the stream of the replica regions. + """ + arn_data = parse_arn(arn) + if arn_data["region"] == region: + return arn + + if arn_data["service"] != "dynamodb": + raise Exception(f"{arn} is not a DynamoDB Streams ARN") + + # Note: a DynamoDB Streams ARN has the following pattern: + # arn:aws:dynamodb:::table//stream/ + resource_splits = arn_data["resource"].split("/") + if len(resource_splits) != 4: + raise DynamoDBStreamsResourceNotFoundException( + f"The format of the '{arn}' ARN is not valid" + ) + + return dynamodb_stream_arn( + table_name=resource_splits[1], + latest_stream_label=resource_splits[-1], + account_id=arn_data["account"], + region_name=region, + ) diff --git a/localstack-core/localstack/services/dynamodb/v2/__init__.py b/localstack-core/localstack/services/dynamodb/v2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/dynamodb/v2/provider.py b/localstack-core/localstack/services/dynamodb/v2/provider.py new file mode 100644 index 0000000000000..f6dee3a68e854 --- /dev/null +++ b/localstack-core/localstack/services/dynamodb/v2/provider.py @@ -0,0 +1,1477 @@ +import copy +import json +import logging +import os +import random +import re +import threading +import time +from contextlib import contextmanager +from datetime import datetime +from operator import itemgetter +from typing import Dict, Optional + +import requests +import werkzeug + +from localstack import config +from localstack.aws import handlers +from localstack.aws.api import ( + CommonServiceException, + RequestContext, + ServiceRequest, + ServiceResponse, + handler, +) +from localstack.aws.api.dynamodb import ( + BatchExecuteStatementOutput, + BatchGetItemOutput, + BatchGetRequestMap, + BatchWriteItemInput, + BatchWriteItemOutput, + BillingMode, + ContinuousBackupsDescription, + ContinuousBackupsStatus, + CreateGlobalTableOutput, + CreateTableInput, + CreateTableOutput, + DeleteItemInput, + DeleteItemOutput, + DeleteRequest, + DeleteTableOutput, + DescribeContinuousBackupsOutput, + DescribeGlobalTableOutput, + DescribeKinesisStreamingDestinationOutput, + DescribeTableOutput, + DescribeTimeToLiveOutput, + DestinationStatus, + DynamodbApi, + EnableKinesisStreamingConfiguration, + ExecuteStatementInput, + ExecuteStatementOutput, + ExecuteTransactionInput, + ExecuteTransactionOutput, + GetItemInput, + GetItemOutput, + GlobalTableAlreadyExistsException, + GlobalTableNotFoundException, + KinesisStreamingDestinationOutput, + ListGlobalTablesOutput, + ListTablesInputLimit, + ListTablesOutput, + ListTagsOfResourceOutput, + NextTokenString, + PartiQLBatchRequest, + PointInTimeRecoveryDescription, + PointInTimeRecoverySpecification, + PointInTimeRecoveryStatus, + PositiveIntegerObject, + ProvisionedThroughputExceededException, + PutItemInput, + PutItemOutput, + PutRequest, + QueryInput, + QueryOutput, + RegionName, + ReplicaDescription, + ReplicaList, + ReplicaStatus, + ReplicaUpdateList, + ResourceArnString, + ResourceInUseException, + ResourceNotFoundException, + ReturnConsumedCapacity, + ScanInput, + ScanOutput, + StreamArn, + TableDescription, + TableName, + TagKeyList, + TagList, + TimeToLiveSpecification, + TransactGetItemList, + TransactGetItemsOutput, + TransactWriteItemsInput, + TransactWriteItemsOutput, + UpdateContinuousBackupsOutput, + UpdateGlobalTableOutput, + UpdateItemInput, + UpdateItemOutput, + UpdateTableInput, + UpdateTableOutput, + UpdateTimeToLiveOutput, + WriteRequest, +) +from localstack.aws.connect import connect_to +from localstack.constants import ( + AUTH_CREDENTIAL_REGEX, + AWS_REGION_US_EAST_1, + INTERNAL_AWS_SECRET_ACCESS_KEY, +) +from localstack.http import Request, Response, route +from localstack.services.dynamodb.models import ( + DynamoDBStore, + StreamRecord, + dynamodb_stores, +) +from localstack.services.dynamodb.server import DynamodbServer +from localstack.services.dynamodb.utils import ( + SchemaExtractor, + get_ddb_access_key, + modify_ddblocal_arns, +) +from localstack.services.dynamodbstreams.models import dynamodbstreams_stores +from localstack.services.edge import ROUTER +from localstack.services.plugins import ServiceLifecycleHook +from localstack.state import AssetDirectory, StateVisitor +from localstack.utils.aws import arns +from localstack.utils.aws.arns import ( + extract_account_id_from_arn, + extract_region_from_arn, + get_partition, +) +from localstack.utils.aws.aws_stack import get_valid_regions_for_service +from localstack.utils.aws.request_context import ( + extract_account_id_from_headers, + extract_region_from_headers, +) +from localstack.utils.collections import select_attributes, select_from_typed_dict +from localstack.utils.common import short_uid, to_bytes +from localstack.utils.json import canonical_json +from localstack.utils.scheduler import Scheduler +from localstack.utils.strings import long_uid, to_str +from localstack.utils.threads import FuncThread, start_thread + +# set up logger +LOG = logging.getLogger(__name__) + +# action header prefix +ACTION_PREFIX = "DynamoDB_20120810." + +# list of actions subject to throughput limitations +READ_THROTTLED_ACTIONS = [ + "GetItem", + "Query", + "Scan", + "TransactGetItems", + "BatchGetItem", +] +WRITE_THROTTLED_ACTIONS = [ + "PutItem", + "BatchWriteItem", + "UpdateItem", + "DeleteItem", + "TransactWriteItems", +] +THROTTLED_ACTIONS = READ_THROTTLED_ACTIONS + WRITE_THROTTLED_ACTIONS + +MANAGED_KMS_KEYS = {} + + +def dynamodb_table_exists(table_name: str, client=None) -> bool: + client = client or connect_to().dynamodb + paginator = client.get_paginator("list_tables") + pages = paginator.paginate(PaginationConfig={"PageSize": 100}) + table_name = to_str(table_name) + return any(table_name in page["TableNames"] for page in pages) + + +class SSEUtils: + """Utils for server-side encryption (SSE)""" + + @classmethod + def get_sse_kms_managed_key(cls, account_id: str, region_name: str): + from localstack.services.kms import provider + + existing_key = MANAGED_KMS_KEYS.get(region_name) + if existing_key: + return existing_key + kms_client = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).kms + key_data = kms_client.create_key( + Description="Default key that protects my DynamoDB data when no other key is defined" + ) + key_id = key_data["KeyMetadata"]["KeyId"] + + provider.set_key_managed(key_id, account_id, region_name) + MANAGED_KMS_KEYS[region_name] = key_id + return key_id + + @classmethod + def get_sse_description(cls, account_id: str, region_name: str, data): + if data.get("Enabled"): + kms_master_key_id = data.get("KMSMasterKeyId") + if not kms_master_key_id: + # this is of course not the actual key for dynamodb, just a better, since existing, mock + kms_master_key_id = cls.get_sse_kms_managed_key(account_id, region_name) + kms_master_key_id = arns.kms_key_arn(kms_master_key_id, account_id, region_name) + return { + "Status": "ENABLED", + "SSEType": "KMS", # no other value is allowed here + "KMSMasterKeyArn": kms_master_key_id, + } + return {} + + +class ValidationException(CommonServiceException): + def __init__(self, message: str): + super().__init__(code="ValidationException", status_code=400, message=message) + + +def get_store(account_id: str, region_name: str) -> DynamoDBStore: + # special case: AWS NoSQL Workbench sends "localhost" as region - replace with proper region here + region_name = DynamoDBProvider.ddb_region_name(region_name) + return dynamodb_stores[account_id][region_name] + + +@contextmanager +def modify_context_region(context: RequestContext, region: str): + """ + Context manager that modifies the region of a `RequestContext`. At the exit, the context is restored to its + original state. + + :param context: the context to modify + :param region: the modified region + :return: a modified `RequestContext` + """ + original_region = context.region + original_authorization = context.request.headers.get("Authorization") + + key = get_ddb_access_key(context.account_id, region) + + context.region = region + context.request.headers["Authorization"] = re.sub( + AUTH_CREDENTIAL_REGEX, + rf"Credential={key}/\2/{region}/\4/", + original_authorization or "", + flags=re.IGNORECASE, + ) + + try: + yield context + except Exception: + raise + finally: + # revert the original context + context.region = original_region + context.request.headers["Authorization"] = original_authorization + + +class DynamoDBDeveloperEndpoints: + """ + Developer endpoints for DynamoDB + DELETE /_aws/dynamodb/expired - delete expired items from tables with TTL enabled; return the number of expired + items deleted + """ + + @route("/_aws/dynamodb/expired", methods=["DELETE"]) + def delete_expired_messages(self, _: Request): + no_expired_items = delete_expired_items() + return {"ExpiredItems": no_expired_items} + + +def delete_expired_items() -> int: + """ + This utility function iterates over all stores, looks for tables with TTL enabled, + scan such tables and delete expired items. + """ + no_expired_items = 0 + for account_id, region_name, state in dynamodb_stores.iter_stores(): + ttl_specs = state.ttl_specifications + client = connect_to(aws_access_key_id=account_id, region_name=region_name).dynamodb + for table_name, ttl_spec in ttl_specs.items(): + if ttl_spec.get("Enabled", False): + attribute_name = ttl_spec.get("AttributeName") + current_time = int(datetime.now().timestamp()) + try: + result = client.scan( + TableName=table_name, + FilterExpression="#ttl <= :threshold", + ExpressionAttributeValues={":threshold": {"N": str(current_time)}}, + ExpressionAttributeNames={"#ttl": attribute_name}, + ) + items_to_delete = result.get("Items", []) + no_expired_items += len(items_to_delete) + table_description = client.describe_table(TableName=table_name) + partition_key, range_key = _get_hash_and_range_key(table_description) + keys_to_delete = [ + {partition_key: item.get(partition_key)} + if range_key is None + else { + partition_key: item.get(partition_key), + range_key: item.get(range_key), + } + for item in items_to_delete + ] + delete_requests = [{"DeleteRequest": {"Key": key}} for key in keys_to_delete] + for i in range(0, len(delete_requests), 25): + batch = delete_requests[i : i + 25] + client.batch_write_item(RequestItems={table_name: batch}) + except Exception as e: + LOG.warning( + "An error occurred when deleting expired items from table %s: %s", + table_name, + e, + ) + return no_expired_items + + +def _get_hash_and_range_key(table_description: DescribeTableOutput) -> [str, str | None]: + key_schema = table_description.get("Table", {}).get("KeySchema", []) + hash_key, range_key = None, None + for key in key_schema: + if key["KeyType"] == "HASH": + hash_key = key["AttributeName"] + if key["KeyType"] == "RANGE": + range_key = key["AttributeName"] + return hash_key, range_key + + +class ExpiredItemsWorker: + """A worker that periodically computes and deletes expired items from DynamoDB tables""" + + def __init__(self) -> None: + super().__init__() + self.scheduler = Scheduler() + self.thread: Optional[FuncThread] = None + self.mutex = threading.RLock() + + def start(self): + with self.mutex: + if self.thread: + return + + self.scheduler = Scheduler() + self.scheduler.schedule( + delete_expired_items, period=60 * 60 + ) # the background process seems slow on AWS + + def _run(*_args): + self.scheduler.run() + + self.thread = start_thread(_run, name="ddb-remove-expired-items") + + def stop(self): + with self.mutex: + if self.scheduler: + self.scheduler.close() + + if self.thread: + self.thread.stop() + + self.thread = None + self.scheduler = None + + +class DynamoDBProvider(DynamodbApi, ServiceLifecycleHook): + server: DynamodbServer + """The instance of the server managing the instance of DynamoDB local""" + + def __init__(self): + self.server = self._new_dynamodb_server() + self._expired_items_worker = ExpiredItemsWorker() + self._router_rules = [] + + def on_before_start(self): + self.server.start_dynamodb() + if config.DYNAMODB_REMOVE_EXPIRED_ITEMS: + self._expired_items_worker.start() + self._router_rules = ROUTER.add(DynamoDBDeveloperEndpoints()) + + def on_before_stop(self): + self._expired_items_worker.stop() + ROUTER.remove(self._router_rules) + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(dynamodb_stores) + visitor.visit(dynamodbstreams_stores) + visitor.visit(AssetDirectory(self.service, os.path.join(config.dirs.data, self.service))) + + def on_before_state_reset(self): + self.server.stop_dynamodb() + + def on_before_state_load(self): + self.server.stop_dynamodb() + + def on_after_state_reset(self): + self.server.start_dynamodb() + + @staticmethod + def _new_dynamodb_server() -> DynamodbServer: + return DynamodbServer.get() + + def on_after_state_load(self): + self.server.start_dynamodb() + + def on_after_init(self): + # add response processor specific to ddblocal + handlers.modify_service_response.append(self.service, modify_ddblocal_arns) + + # routes for the shell ui + ROUTER.add( + path="/shell", + endpoint=self.handle_shell_ui_redirect, + methods=["GET"], + ) + ROUTER.add( + path="/shell/", + endpoint=self.handle_shell_ui_request, + ) + + def _forward_request( + self, + context: RequestContext, + region: str | None, + service_request: ServiceRequest | None = None, + ) -> ServiceResponse: + """ + Modify the context region and then forward request to DynamoDB Local. + + This is used for operations impacted by global tables. In LocalStack, a single copy of global table + is kept, and any requests to replicated tables are forwarded to this original table. + """ + if region: + with modify_context_region(context, region): + return self.forward_request(context, service_request=service_request) + return self.forward_request(context, service_request=service_request) + + def forward_request( + self, context: RequestContext, service_request: ServiceRequest = None + ) -> ServiceResponse: + """ + Forward a request to DynamoDB Local. + """ + self.check_provisioned_throughput(context.operation.name) + self.prepare_request_headers( + context.request.headers, account_id=context.account_id, region_name=context.region + ) + return self.server.proxy(context, service_request) + + def get_forward_url(self, account_id: str, region_name: str) -> str: + """Return the URL of the backend DynamoDBLocal server to forward requests to""" + return self.server.url + + def handle_shell_ui_redirect(self, request: werkzeug.Request) -> Response: + headers = {"Refresh": f"0; url={config.external_service_url()}/shell/index.html"} + return Response("", headers=headers) + + def handle_shell_ui_request(self, request: werkzeug.Request, req_path: str) -> Response: + # TODO: "DynamoDB Local Web Shell was deprecated with version 1.16.X and is not available any + # longer from 1.17.X to latest. There are no immediate plans for a new Web Shell to be introduced." + # -> keeping this for now, to allow configuring custom installs; should consider removing it in the future + # https://repost.aws/questions/QUHyIzoEDqQ3iOKlUEp1LPWQ#ANdBm9Nz9TRf6VqR3jZtcA1g + req_path = f"/{req_path}" if not req_path.startswith("/") else req_path + account_id = extract_account_id_from_headers(request.headers) + region_name = extract_region_from_headers(request.headers) + url = f"{self.get_forward_url(account_id, region_name)}/shell{req_path}" + result = requests.request( + method=request.method, url=url, headers=request.headers, data=request.data + ) + return Response(result.content, headers=dict(result.headers), status=result.status_code) + + # + # Table ops + # + + @handler("CreateTable", expand=False) + def create_table( + self, + context: RequestContext, + create_table_input: CreateTableInput, + ) -> CreateTableOutput: + table_name = create_table_input["TableName"] + + # Return this specific error message to keep parity with AWS + if self.table_exists(context.account_id, context.region, table_name): + raise ResourceInUseException(f"Table already exists: {table_name}") + + billing_mode = create_table_input.get("BillingMode") + provisioned_throughput = create_table_input.get("ProvisionedThroughput") + if billing_mode == BillingMode.PAY_PER_REQUEST and provisioned_throughput is not None: + raise ValidationException( + "One or more parameter values were invalid: Neither ReadCapacityUnits nor WriteCapacityUnits can be " + "specified when BillingMode is PAY_PER_REQUEST" + ) + + result = self.forward_request(context) + + table_description = result["TableDescription"] + table_description["TableArn"] = table_arn = self.fix_table_arn( + context.account_id, context.region, table_description["TableArn"] + ) + + backend = get_store(context.account_id, context.region) + backend.table_definitions[table_name] = table_definitions = dict(create_table_input) + backend.TABLE_REGION[table_name] = context.region + + if "TableId" not in table_definitions: + table_definitions["TableId"] = long_uid() + + if "SSESpecification" in table_definitions: + sse_specification = table_definitions.pop("SSESpecification") + table_definitions["SSEDescription"] = SSEUtils.get_sse_description( + context.account_id, context.region, sse_specification + ) + + if table_definitions: + table_content = result.get("Table", {}) + table_content.update(table_definitions) + table_description.update(table_content) + + if "TableClass" in table_definitions: + table_class = table_description.pop("TableClass", None) or table_definitions.pop( + "TableClass" + ) + table_description["TableClassSummary"] = {"TableClass": table_class} + + if "GlobalSecondaryIndexes" in table_description: + gsis = copy.deepcopy(table_description["GlobalSecondaryIndexes"]) + # update the different values, as DynamoDB-local v2 has a regression around GSI and does not return anything + # anymore + for gsi in gsis: + index_name = gsi.get("IndexName", "") + gsi.update( + { + "IndexArn": f"{table_arn}/index/{index_name}", + "IndexSizeBytes": 0, + "IndexStatus": "ACTIVE", + "ItemCount": 0, + } + ) + gsi_provisioned_throughput = gsi.setdefault("ProvisionedThroughput", {}) + gsi_provisioned_throughput["NumberOfDecreasesToday"] = 0 + + if billing_mode == BillingMode.PAY_PER_REQUEST: + gsi_provisioned_throughput["ReadCapacityUnits"] = 0 + gsi_provisioned_throughput["WriteCapacityUnits"] = 0 + + # table_definitions["GlobalSecondaryIndexes"] = gsis + table_description["GlobalSecondaryIndexes"] = gsis + + if "ProvisionedThroughput" in table_description: + if "NumberOfDecreasesToday" not in table_description["ProvisionedThroughput"]: + table_description["ProvisionedThroughput"]["NumberOfDecreasesToday"] = 0 + + tags = table_definitions.pop("Tags", []) + if tags: + get_store(context.account_id, context.region).TABLE_TAGS[table_arn] = { + tag["Key"]: tag["Value"] for tag in tags + } + + # remove invalid attributes from result + table_description.pop("Tags", None) + table_description.pop("BillingMode", None) + + return result + + def delete_table( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DeleteTableOutput: + global_table_region = self.get_global_table_region(context, table_name) + + # Limitation note: On AWS, for a replicated table, if the source table is deleted, the replicated tables continue to exist. + # This is not the case for LocalStack, where all replicated tables will also be removed if source is deleted. + + result = self._forward_request(context=context, region=global_table_region) + + table_arn = result.get("TableDescription", {}).get("TableArn") + table_arn = self.fix_table_arn(context.account_id, context.region, table_arn) + + store = get_store(context.account_id, context.region) + store.TABLE_TAGS.pop(table_arn, None) + store.REPLICAS.pop(table_name, None) + + return result + + def describe_table( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeTableOutput: + global_table_region = self.get_global_table_region(context, table_name) + + result = self._forward_request(context=context, region=global_table_region) + table_description: TableDescription = result["Table"] + + # Update table properties from LocalStack stores + if table_props := get_store(context.account_id, context.region).table_properties.get( + table_name + ): + table_description.update(table_props) + + store = get_store(context.account_id, context.region) + + # Update replication details + replicas: Dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {}) + + replica_description_list = [] + + if global_table_region != context.region: + replica_description_list.append( + ReplicaDescription( + RegionName=global_table_region, ReplicaStatus=ReplicaStatus.ACTIVE + ) + ) + + for replica_region, replica_description in replicas.items(): + # The replica in the region being queried must not be returned + if replica_region != context.region: + replica_description_list.append(replica_description) + + if replica_description_list: + table_description.update({"Replicas": replica_description_list}) + + # update only TableId and SSEDescription if present + if table_definitions := store.table_definitions.get(table_name): + for key in ["TableId", "SSEDescription"]: + if table_definitions.get(key): + table_description[key] = table_definitions[key] + if "TableClass" in table_definitions: + table_description["TableClassSummary"] = { + "TableClass": table_definitions["TableClass"] + } + + if "GlobalSecondaryIndexes" in table_description: + for gsi in table_description["GlobalSecondaryIndexes"]: + default_values = { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0, + } + # even if the billing mode is PAY_PER_REQUEST, AWS returns the Read and Write Capacity Units + # Terraform depends on this parity for update operations + gsi["ProvisionedThroughput"] = default_values | gsi.get("ProvisionedThroughput", {}) + + return DescribeTableOutput( + Table=select_from_typed_dict(TableDescription, table_description) + ) + + @handler("UpdateTable", expand=False) + def update_table( + self, context: RequestContext, update_table_input: UpdateTableInput + ) -> UpdateTableOutput: + table_name = update_table_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + try: + self._forward_request(context=context, region=global_table_region) + except CommonServiceException as exc: + # DynamoDBLocal refuses to update certain table params and raises. + # But we still need to update this info in LocalStack stores + if not (exc.code == "ValidationException" and exc.message == "Nothing to update"): + raise + + if table_class := update_table_input.get("TableClass"): + table_definitions = get_store( + context.account_id, context.region + ).table_definitions.setdefault(table_name, {}) + table_definitions["TableClass"] = table_class + + if replica_updates := update_table_input.get("ReplicaUpdates"): + store = get_store(context.account_id, global_table_region) + + # Dict with source region to set of replicated regions + replicas: Dict[RegionName, ReplicaDescription] = store.REPLICAS.get(table_name, {}) + + for replica_update in replica_updates: + for key, details in replica_update.items(): + # Replicated region + target_region = details.get("RegionName") + + # Check if replicated region is valid + if target_region not in get_valid_regions_for_service("dynamodb"): + raise ValidationException(f"Region {target_region} is not supported") + + match key: + case "Create": + if target_region in replicas.keys(): + raise ValidationException( + f"Failed to create a the new replica of table with name: '{table_name}' because one or more replicas already existed as tables." + ) + replicas[target_region] = ReplicaDescription( + RegionName=target_region, + KMSMasterKeyId=details.get("KMSMasterKeyId"), + ProvisionedThroughputOverride=details.get( + "ProvisionedThroughputOverride" + ), + GlobalSecondaryIndexes=details.get("GlobalSecondaryIndexes"), + ReplicaStatus=ReplicaStatus.ACTIVE, + ) + case "Delete": + try: + replicas.pop(target_region) + except KeyError: + raise ValidationException( + "Update global table operation failed because one or more replicas were not part of the global table." + ) + + store.REPLICAS[table_name] = replicas + + # update response content + SchemaExtractor.invalidate_table_schema( + table_name, context.account_id, global_table_region + ) + + schema = SchemaExtractor.get_table_schema( + table_name, context.account_id, global_table_region + ) + + if sse_specification_input := update_table_input.get("SSESpecification"): + # If SSESpecification is changed, update store and return the 'UPDATING' status in the response + table_definition = get_store( + context.account_id, context.region + ).table_definitions.setdefault(table_name, {}) + if not sse_specification_input["Enabled"]: + table_definition.pop("SSEDescription", None) + schema["Table"]["SSEDescription"]["Status"] = "UPDATING" + + return UpdateTableOutput(TableDescription=schema["Table"]) + + SchemaExtractor.invalidate_table_schema(table_name, context.account_id, global_table_region) + + schema = SchemaExtractor.get_table_schema( + table_name, context.account_id, global_table_region + ) + + return UpdateTableOutput(TableDescription=schema["Table"]) + + def list_tables( + self, + context: RequestContext, + exclusive_start_table_name: TableName = None, + limit: ListTablesInputLimit = None, + **kwargs, + ) -> ListTablesOutput: + response = self.forward_request(context) + + # Add replicated tables + replicas = get_store(context.account_id, context.region).REPLICAS + for replicated_table, replications in replicas.items(): + for replica_region, replica_description in replications.items(): + if context.region == replica_region: + response["TableNames"].append(replicated_table) + + return response + + # + # Item ops + # + + @handler("PutItem", expand=False) + def put_item(self, context: RequestContext, put_item_input: PutItemInput) -> PutItemOutput: + table_name = put_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + return self._forward_request(context=context, region=global_table_region) + + @handler("DeleteItem", expand=False) + def delete_item( + self, + context: RequestContext, + delete_item_input: DeleteItemInput, + ) -> DeleteItemOutput: + table_name = delete_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + return self._forward_request(context=context, region=global_table_region) + + @handler("UpdateItem", expand=False) + def update_item( + self, + context: RequestContext, + update_item_input: UpdateItemInput, + ) -> UpdateItemOutput: + # TODO: UpdateItem is harder to use ReturnValues for Streams, because it needs the Before and After images. + table_name = update_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + + return self._forward_request(context=context, region=global_table_region) + + @handler("GetItem", expand=False) + def get_item(self, context: RequestContext, get_item_input: GetItemInput) -> GetItemOutput: + table_name = get_item_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + result = self._forward_request(context=context, region=global_table_region) + self.fix_consumed_capacity(get_item_input, result) + return result + + # + # Queries + # + + @handler("Query", expand=False) + def query(self, context: RequestContext, query_input: QueryInput) -> QueryOutput: + index_name = query_input.get("IndexName") + if index_name: + if not is_index_query_valid(context.account_id, context.region, query_input): + raise ValidationException( + "One or more parameter values were invalid: Select type ALL_ATTRIBUTES " + "is not supported for global secondary index id-index because its projection " + "type is not ALL", + ) + + table_name = query_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + result = self._forward_request(context=context, region=global_table_region) + self.fix_consumed_capacity(query_input, result) + return result + + @handler("Scan", expand=False) + def scan(self, context: RequestContext, scan_input: ScanInput) -> ScanOutput: + table_name = scan_input["TableName"] + global_table_region = self.get_global_table_region(context, table_name) + result = self._forward_request(context=context, region=global_table_region) + return result + + # + # Batch ops + # + + @handler("BatchWriteItem", expand=False) + def batch_write_item( + self, + context: RequestContext, + batch_write_item_input: BatchWriteItemInput, + ) -> BatchWriteItemOutput: + # TODO: add global table support + # UnprocessedItems should have the same format as RequestItems + unprocessed_items = {} + request_items = batch_write_item_input["RequestItems"] + + for table_name, items in sorted(request_items.items(), key=itemgetter(0)): + for request in items: + request: WriteRequest + for key, inner_request in request.items(): + inner_request: PutRequest | DeleteRequest + if self.should_throttle("BatchWriteItem"): + unprocessed_items_for_table = unprocessed_items.setdefault(table_name, []) + unprocessed_items_for_table.append(request) + + try: + result = self.forward_request(context) + except CommonServiceException as e: + # TODO: validate if DynamoDB still raises `One of the required keys was not given a value` + # for now, replace with the schema error validation + if e.message == "One of the required keys was not given a value": + raise ValidationException("The provided key element does not match the schema") + raise e + + # TODO: should unprocessed item which have mutated by `prepare_batch_write_item_records` be returned + for table_name, unprocessed_items_in_table in unprocessed_items.items(): + unprocessed: dict = result["UnprocessedItems"] + result_unprocessed_table = unprocessed.setdefault(table_name, []) + + # add the Unprocessed items to the response + # TODO: check before if the same request has not been Unprocessed by DDB local already? + # those might actually have been processed? shouldn't we remove them from the proxied request? + for request in unprocessed_items_in_table: + result_unprocessed_table.append(request) + + # remove any table entry if it's empty + result["UnprocessedItems"] = {k: v for k, v in unprocessed.items() if v} + + return result + + @handler("BatchGetItem") + def batch_get_item( + self, + context: RequestContext, + request_items: BatchGetRequestMap, + return_consumed_capacity: ReturnConsumedCapacity = None, + **kwargs, + ) -> BatchGetItemOutput: + # TODO: add global table support + return self.forward_request(context) + + # + # Transactions + # + + @handler("TransactWriteItems", expand=False) + def transact_write_items( + self, + context: RequestContext, + transact_write_items_input: TransactWriteItemsInput, + ) -> TransactWriteItemsOutput: + # TODO: add global table support + client_token: str | None = transact_write_items_input.get("ClientRequestToken") + + if client_token: + # we sort the payload since identical payload but with different order could cause + # IdempotentParameterMismatchException error if a client token is provided + context.request.data = to_bytes(canonical_json(json.loads(context.request.data))) + + return self.forward_request(context) + + @handler("TransactGetItems", expand=False) + def transact_get_items( + self, + context: RequestContext, + transact_items: TransactGetItemList, + return_consumed_capacity: ReturnConsumedCapacity = None, + ) -> TransactGetItemsOutput: + return self.forward_request(context) + + @handler("ExecuteTransaction", expand=False) + def execute_transaction( + self, context: RequestContext, execute_transaction_input: ExecuteTransactionInput + ) -> ExecuteTransactionOutput: + result = self.forward_request(context) + return result + + @handler("ExecuteStatement", expand=False) + def execute_statement( + self, + context: RequestContext, + execute_statement_input: ExecuteStatementInput, + ) -> ExecuteStatementOutput: + # TODO: this operation is still really slow with streams enabled + # find a way to make it better, same way as the other operations, by using returnvalues + # see https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.update.html + + # We found out that 'Parameters' can be an empty list when the request comes from the AWS JS client. + if execute_statement_input.get("Parameters", None) == []: # noqa + raise ValidationException( + "1 validation error detected: Value '[]' at 'parameters' failed to satisfy constraint: Member must have length greater than or equal to 1" + ) + return self.forward_request(context) + + # + # Tags + # + + def tag_resource( + self, context: RequestContext, resource_arn: ResourceArnString, tags: TagList, **kwargs + ) -> None: + table_tags = get_store(context.account_id, context.region).TABLE_TAGS + if resource_arn not in table_tags: + table_tags[resource_arn] = {} + table_tags[resource_arn].update({tag["Key"]: tag["Value"] for tag in tags}) + + def untag_resource( + self, + context: RequestContext, + resource_arn: ResourceArnString, + tag_keys: TagKeyList, + **kwargs, + ) -> None: + for tag_key in tag_keys or []: + get_store(context.account_id, context.region).TABLE_TAGS.get(resource_arn, {}).pop( + tag_key, None + ) + + def list_tags_of_resource( + self, + context: RequestContext, + resource_arn: ResourceArnString, + next_token: NextTokenString = None, + **kwargs, + ) -> ListTagsOfResourceOutput: + result = [ + {"Key": k, "Value": v} + for k, v in get_store(context.account_id, context.region) + .TABLE_TAGS.get(resource_arn, {}) + .items() + ] + return ListTagsOfResourceOutput(Tags=result) + + # + # TTLs + # + + def describe_time_to_live( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeTimeToLiveOutput: + if not self.table_exists(context.account_id, context.region, table_name): + raise ResourceNotFoundException( + f"Requested resource not found: Table: {table_name} not found" + ) + + backend = get_store(context.account_id, context.region) + ttl_spec = backend.ttl_specifications.get(table_name) + + result = {"TimeToLiveStatus": "DISABLED"} + if ttl_spec: + if ttl_spec.get("Enabled"): + ttl_status = "ENABLED" + else: + ttl_status = "DISABLED" + result = { + "AttributeName": ttl_spec.get("AttributeName"), + "TimeToLiveStatus": ttl_status, + } + + return DescribeTimeToLiveOutput(TimeToLiveDescription=result) + + def update_time_to_live( + self, + context: RequestContext, + table_name: TableName, + time_to_live_specification: TimeToLiveSpecification, + **kwargs, + ) -> UpdateTimeToLiveOutput: + if not self.table_exists(context.account_id, context.region, table_name): + raise ResourceNotFoundException( + f"Requested resource not found: Table: {table_name} not found" + ) + + # TODO: TTL status is maintained/mocked but no real expiry is happening for items + backend = get_store(context.account_id, context.region) + backend.ttl_specifications[table_name] = time_to_live_specification + return UpdateTimeToLiveOutput(TimeToLiveSpecification=time_to_live_specification) + + # + # Global tables + # + + def create_global_table( + self, + context: RequestContext, + global_table_name: TableName, + replication_group: ReplicaList, + **kwargs, + ) -> CreateGlobalTableOutput: + global_tables: Dict = get_store(context.account_id, context.region).GLOBAL_TABLES + if global_table_name in global_tables: + raise GlobalTableAlreadyExistsException("Global table with this name already exists") + replication_group = [grp.copy() for grp in replication_group or []] + data = {"GlobalTableName": global_table_name, "ReplicationGroup": replication_group} + global_tables[global_table_name] = data + for group in replication_group: + group["ReplicaStatus"] = "ACTIVE" + group["ReplicaStatusDescription"] = "Replica active" + return CreateGlobalTableOutput(GlobalTableDescription=data) + + def describe_global_table( + self, context: RequestContext, global_table_name: TableName, **kwargs + ) -> DescribeGlobalTableOutput: + details = get_store(context.account_id, context.region).GLOBAL_TABLES.get(global_table_name) + if not details: + raise GlobalTableNotFoundException("Global table with this name does not exist") + return DescribeGlobalTableOutput(GlobalTableDescription=details) + + def list_global_tables( + self, + context: RequestContext, + exclusive_start_global_table_name: TableName = None, + limit: PositiveIntegerObject = None, + region_name: RegionName = None, + **kwargs, + ) -> ListGlobalTablesOutput: + # TODO: add paging support + result = [ + select_attributes(tab, ["GlobalTableName", "ReplicationGroup"]) + for tab in get_store(context.account_id, context.region).GLOBAL_TABLES.values() + ] + return ListGlobalTablesOutput(GlobalTables=result) + + def update_global_table( + self, + context: RequestContext, + global_table_name: TableName, + replica_updates: ReplicaUpdateList, + **kwargs, + ) -> UpdateGlobalTableOutput: + details = get_store(context.account_id, context.region).GLOBAL_TABLES.get(global_table_name) + if not details: + raise GlobalTableNotFoundException("Global table with this name does not exist") + for update in replica_updates or []: + repl_group = details["ReplicationGroup"] + # delete existing + delete = update.get("Delete") + if delete: + details["ReplicationGroup"] = [ + g for g in repl_group if g["RegionName"] != delete["RegionName"] + ] + # create new + create = update.get("Create") + if create: + exists = [g for g in repl_group if g["RegionName"] == create["RegionName"]] + if exists: + continue + new_group = { + "RegionName": create["RegionName"], + "ReplicaStatus": "ACTIVE", + "ReplicaStatusDescription": "Replica active", + } + details["ReplicationGroup"].append(new_group) + return UpdateGlobalTableOutput(GlobalTableDescription=details) + + # + # Kinesis Streaming + # + + def enable_kinesis_streaming_destination( + self, + context: RequestContext, + table_name: TableName, + stream_arn: StreamArn, + enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration = None, + **kwargs, + ) -> KinesisStreamingDestinationOutput: + self.ensure_table_exists(context.account_id, context.region, table_name) + + if not kinesis_stream_exists(stream_arn=stream_arn): + raise ValidationException("User does not have a permission to use kinesis stream") + + table_def = get_store(context.account_id, context.region).table_definitions.setdefault( + table_name, {} + ) + + dest_status = table_def.get("KinesisDataStreamDestinationStatus") + if dest_status not in ["DISABLED", "ENABLE_FAILED", None]: + raise ValidationException( + "Table is not in a valid state to enable Kinesis Streaming " + "Destination:EnableKinesisStreamingDestination must be DISABLED or ENABLE_FAILED " + "to perform ENABLE operation." + ) + + table_def["KinesisDataStreamDestinations"] = ( + table_def.get("KinesisDataStreamDestinations") or [] + ) + # remove the stream destination if already present + table_def["KinesisDataStreamDestinations"] = [ + t for t in table_def["KinesisDataStreamDestinations"] if t["StreamArn"] != stream_arn + ] + # append the active stream destination at the end of the list + table_def["KinesisDataStreamDestinations"].append( + { + "DestinationStatus": DestinationStatus.ACTIVE, + "DestinationStatusDescription": "Stream is active", + "StreamArn": stream_arn, + } + ) + table_def["KinesisDataStreamDestinationStatus"] = DestinationStatus.ACTIVE + return KinesisStreamingDestinationOutput( + DestinationStatus=DestinationStatus.ACTIVE, StreamArn=stream_arn, TableName=table_name + ) + + def disable_kinesis_streaming_destination( + self, + context: RequestContext, + table_name: TableName, + stream_arn: StreamArn, + enable_kinesis_streaming_configuration: EnableKinesisStreamingConfiguration = None, + **kwargs, + ) -> KinesisStreamingDestinationOutput: + self.ensure_table_exists(context.account_id, context.region, table_name) + if not kinesis_stream_exists(stream_arn): + raise ValidationException( + "User does not have a permission to use kinesis stream", + ) + + table_def = get_store(context.account_id, context.region).table_definitions.setdefault( + table_name, {} + ) + + stream_destinations = table_def.get("KinesisDataStreamDestinations") + if stream_destinations: + if table_def["KinesisDataStreamDestinationStatus"] == DestinationStatus.ACTIVE: + for dest in stream_destinations: + if ( + dest["StreamArn"] == stream_arn + and dest["DestinationStatus"] == DestinationStatus.ACTIVE + ): + dest["DestinationStatus"] = DestinationStatus.DISABLED + dest["DestinationStatusDescription"] = ("Stream is disabled",) + table_def["KinesisDataStreamDestinationStatus"] = DestinationStatus.DISABLED + return KinesisStreamingDestinationOutput( + DestinationStatus=DestinationStatus.DISABLED, + StreamArn=stream_arn, + TableName=table_name, + ) + raise ValidationException( + "Table is not in a valid state to disable Kinesis Streaming Destination:" + "DisableKinesisStreamingDestination must be ACTIVE to perform DISABLE operation." + ) + + def describe_kinesis_streaming_destination( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeKinesisStreamingDestinationOutput: + self.ensure_table_exists(context.account_id, context.region, table_name) + + table_def = ( + get_store(context.account_id, context.region).table_definitions.get(table_name) or {} + ) + + stream_destinations = table_def.get("KinesisDataStreamDestinations") or [] + return DescribeKinesisStreamingDestinationOutput( + KinesisDataStreamDestinations=stream_destinations, TableName=table_name + ) + + # + # Continuous Backups + # + + def describe_continuous_backups( + self, context: RequestContext, table_name: TableName, **kwargs + ) -> DescribeContinuousBackupsOutput: + self.get_global_table_region(context, table_name) + store = get_store(context.account_id, context.region) + continuous_backup_description = ( + store.table_properties.get(table_name, {}).get("ContinuousBackupsDescription") + ) or ContinuousBackupsDescription( + ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED, + PointInTimeRecoveryDescription=PointInTimeRecoveryDescription( + PointInTimeRecoveryStatus=PointInTimeRecoveryStatus.DISABLED + ), + ) + + return DescribeContinuousBackupsOutput( + ContinuousBackupsDescription=continuous_backup_description + ) + + def update_continuous_backups( + self, + context: RequestContext, + table_name: TableName, + point_in_time_recovery_specification: PointInTimeRecoverySpecification, + **kwargs, + ) -> UpdateContinuousBackupsOutput: + self.get_global_table_region(context, table_name) + + store = get_store(context.account_id, context.region) + pit_recovery_status = ( + PointInTimeRecoveryStatus.ENABLED + if point_in_time_recovery_specification["PointInTimeRecoveryEnabled"] + else PointInTimeRecoveryStatus.DISABLED + ) + continuous_backup_description = ContinuousBackupsDescription( + ContinuousBackupsStatus=ContinuousBackupsStatus.ENABLED, + PointInTimeRecoveryDescription=PointInTimeRecoveryDescription( + PointInTimeRecoveryStatus=pit_recovery_status + ), + ) + table_props = store.table_properties.setdefault(table_name, {}) + table_props["ContinuousBackupsDescription"] = continuous_backup_description + + return UpdateContinuousBackupsOutput( + ContinuousBackupsDescription=continuous_backup_description + ) + + # + # Helpers + # + + @staticmethod + def ddb_region_name(region_name: str) -> str: + """Map `local` or `localhost` region to the us-east-1 region. These values are used by NoSQL Workbench.""" + # TODO: could this be somehow moved into the request handler chain? + if region_name in ("local", "localhost"): + region_name = AWS_REGION_US_EAST_1 + + return region_name + + @staticmethod + def table_exists(account_id: str, region_name: str, table_name: str) -> bool: + region_name = DynamoDBProvider.ddb_region_name(region_name) + + client = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).dynamodb + return dynamodb_table_exists(table_name, client) + + @staticmethod + def ensure_table_exists(account_id: str, region_name: str, table_name: str): + """ + Raise ResourceNotFoundException if the given table does not exist. + + :param account_id: account id + :param region_name: region name + :param table_name: table name + :raise: ResourceNotFoundException if table does not exist in DynamoDB Local + """ + if not DynamoDBProvider.table_exists(account_id, region_name, table_name): + raise ResourceNotFoundException("Cannot do operations on a non-existent table") + + @staticmethod + def get_global_table_region(context: RequestContext, table_name: str) -> str: + """ + Return the table region considering that it might be a replicated table. + + Replication in LocalStack works by keeping a single copy of a table and forwarding + requests to the region where this table exists. + + This method does not check whether the table actually exists in DDBLocal. + + :param context: request context + :param table_name: table name + :return: region + """ + store = get_store(context.account_id, context.region) + + table_region = store.TABLE_REGION.get(table_name) + replicated_at = store.REPLICAS.get(table_name, {}).keys() + + if context.region == table_region or context.region in replicated_at: + return table_region + + return context.region + + @staticmethod + def prepare_request_headers(headers: Dict, account_id: str, region_name: str): + """ + Modify the Credentials field of Authorization header to achieve namespacing in DynamoDBLocal. + """ + region_name = DynamoDBProvider.ddb_region_name(region_name) + key = get_ddb_access_key(account_id, region_name) + + # DynamoDBLocal namespaces based on the value of Credentials + # Since we want to namespace by both account ID and region, use an aggregate key + # We also replace the region to keep compatibility with NoSQL Workbench + headers["Authorization"] = re.sub( + AUTH_CREDENTIAL_REGEX, + rf"Credential={key}/\2/{region_name}/\4/", + headers.get("Authorization") or "", + flags=re.IGNORECASE, + ) + + def fix_consumed_capacity(self, request: Dict, result: Dict): + # make sure we append 'ConsumedCapacity', which is properly + # returned by dynalite, but not by AWS's DynamoDBLocal + table_name = request.get("TableName") + return_cap = request.get("ReturnConsumedCapacity") + if "ConsumedCapacity" not in result and return_cap in ["TOTAL", "INDEXES"]: + request["ConsumedCapacity"] = { + "TableName": table_name, + "CapacityUnits": 5, # TODO hardcoded + "ReadCapacityUnits": 2, + "WriteCapacityUnits": 3, + } + + def fix_table_arn(self, account_id: str, region_name: str, arn: str) -> str: + """ + Set the correct account ID and region in ARNs returned by DynamoDB Local. + """ + partition = get_partition(region_name) + return ( + arn.replace("arn:aws:", f"arn:{partition}:") + .replace(":ddblocal:", f":{region_name}:") + .replace(":000000000000:", f":{account_id}:") + ) + + def batch_execute_statement( + self, + context: RequestContext, + statements: PartiQLBatchRequest, + return_consumed_capacity: ReturnConsumedCapacity = None, + **kwargs, + ) -> BatchExecuteStatementOutput: + result = self.forward_request(context) + return result + + @staticmethod + def get_record_template(region_name: str, stream_view_type: str | None = None) -> StreamRecord: + record = { + "eventID": short_uid(), + "eventVersion": "1.1", + "dynamodb": { + # expects nearest second rounded down + "ApproximateCreationDateTime": int(time.time()), + "SizeBytes": -1, + }, + "awsRegion": region_name, + "eventSource": "aws:dynamodb", + } + if stream_view_type: + record["dynamodb"]["StreamViewType"] = stream_view_type + + return record + + def check_provisioned_throughput(self, action): + """ + Check rate limiting for an API operation and raise an error if provisioned throughput is exceeded. + """ + if self.should_throttle(action): + message = ( + "The level of configured provisioned throughput for the table was exceeded. " + + "Consider increasing your provisioning level with the UpdateTable API" + ) + raise ProvisionedThroughputExceededException(message) + + def action_should_throttle(self, action, actions): + throttled = [f"{ACTION_PREFIX}{a}" for a in actions] + return (action in throttled) or (action in actions) + + def should_throttle(self, action): + if ( + not config.DYNAMODB_READ_ERROR_PROBABILITY + and not config.DYNAMODB_ERROR_PROBABILITY + and not config.DYNAMODB_WRITE_ERROR_PROBABILITY + ): + # early exit so we don't need to call random() + return False + + rand = random.random() + if rand < config.DYNAMODB_READ_ERROR_PROBABILITY and self.action_should_throttle( + action, READ_THROTTLED_ACTIONS + ): + return True + elif rand < config.DYNAMODB_WRITE_ERROR_PROBABILITY and self.action_should_throttle( + action, WRITE_THROTTLED_ACTIONS + ): + return True + elif rand < config.DYNAMODB_ERROR_PROBABILITY and self.action_should_throttle( + action, THROTTLED_ACTIONS + ): + return True + return False + + +# --- +# Misc. util functions +# --- + + +def get_global_secondary_index(account_id: str, region_name: str, table_name: str, index_name: str): + schema = SchemaExtractor.get_table_schema(table_name, account_id, region_name) + for index in schema["Table"].get("GlobalSecondaryIndexes", []): + if index["IndexName"] == index_name: + return index + raise ResourceNotFoundException("Index not found") + + +def is_local_secondary_index( + account_id: str, region_name: str, table_name: str, index_name: str +) -> bool: + schema = SchemaExtractor.get_table_schema(table_name, account_id, region_name) + for index in schema["Table"].get("LocalSecondaryIndexes", []): + if index["IndexName"] == index_name: + return True + return False + + +def is_index_query_valid(account_id: str, region_name: str, query_data: dict) -> bool: + table_name = to_str(query_data["TableName"]) + index_name = to_str(query_data["IndexName"]) + if is_local_secondary_index(account_id, region_name, table_name, index_name): + return True + index_query_type = query_data.get("Select") + index = get_global_secondary_index(account_id, region_name, table_name, index_name) + index_projection_type = index.get("Projection").get("ProjectionType") + if index_query_type == "ALL_ATTRIBUTES" and index_projection_type != "ALL": + return False + return True + + +def kinesis_stream_exists(stream_arn): + account_id = extract_account_id_from_arn(stream_arn) + region_name = extract_region_from_arn(stream_arn) + + kinesis = connect_to( + aws_access_key_id=account_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + region_name=region_name, + ).kinesis + stream_name_from_arn = stream_arn.split("/", 1)[1] + # check if the stream exists in kinesis for the user + filtered = list( + filter( + lambda stream_name: stream_name == stream_name_from_arn, + kinesis.list_streams()["StreamNames"], + ) + ) + return bool(filtered) diff --git a/localstack-core/localstack/services/dynamodbstreams/__init__.py b/localstack-core/localstack/services/dynamodbstreams/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/dynamodbstreams/dynamodbstreams_api.py b/localstack-core/localstack/services/dynamodbstreams/dynamodbstreams_api.py new file mode 100644 index 0000000000000..e9164465fdd57 --- /dev/null +++ b/localstack-core/localstack/services/dynamodbstreams/dynamodbstreams_api.py @@ -0,0 +1,235 @@ +import logging +import threading +from typing import TYPE_CHECKING, Dict + +from bson.json_util import dumps + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.api.dynamodbstreams import StreamStatus, StreamViewType, TableName +from localstack.aws.connect import connect_to +from localstack.services.dynamodb.v2.provider import DynamoDBProvider +from localstack.services.dynamodbstreams.models import DynamoDbStreamsStore, dynamodbstreams_stores +from localstack.utils.aws import arns, resources +from localstack.utils.common import now_utc +from localstack.utils.threads import FuncThread + +if TYPE_CHECKING: + from mypy_boto3_kinesis import KinesisClient + +DDB_KINESIS_STREAM_NAME_PREFIX = "__ddb_stream_" + +LOG = logging.getLogger(__name__) + +_SEQUENCE_MTX = threading.RLock() +_SEQUENCE_NUMBER_COUNTER = 1 + + +def get_dynamodbstreams_store(account_id: str, region: str) -> DynamoDbStreamsStore: + return dynamodbstreams_stores[account_id][region] + + +def get_and_increment_sequence_number_counter() -> int: + global _SEQUENCE_NUMBER_COUNTER + with _SEQUENCE_MTX: + cnt = _SEQUENCE_NUMBER_COUNTER + _SEQUENCE_NUMBER_COUNTER += 1 + return cnt + + +def get_kinesis_client(account_id: str, region_name: str) -> "KinesisClient": + # specifically specify endpoint url here to ensure we always hit the local kinesis instance + return connect_to( + aws_access_key_id=account_id, + region_name=region_name, + endpoint_url=config.internal_service_url(), + ).kinesis + + +def add_dynamodb_stream( + account_id: str, + region_name: str, + table_name: str, + latest_stream_label: str | None = None, + view_type: StreamViewType = StreamViewType.NEW_AND_OLD_IMAGES, + enabled: bool = True, +) -> None: + if not enabled: + return + + store = get_dynamodbstreams_store(account_id, region_name) + # create kinesis stream as a backend + stream_name = get_kinesis_stream_name(table_name) + resources.create_kinesis_stream( + get_kinesis_client(account_id, region_name), + stream_name=stream_name, + ) + latest_stream_label = latest_stream_label or "latest" + stream = { + "StreamArn": arns.dynamodb_stream_arn( + table_name=table_name, + latest_stream_label=latest_stream_label, + account_id=account_id, + region_name=region_name, + ), + "TableName": table_name, + "StreamLabel": latest_stream_label, + "StreamStatus": StreamStatus.ENABLING, + "KeySchema": [], + "Shards": [], + "StreamViewType": view_type, + "shards_id_map": {}, + } + store.ddb_streams[table_name] = stream + + +def get_stream_for_table(account_id: str, region_name: str, table_arn: str) -> dict: + store = get_dynamodbstreams_store(account_id, region_name) + table_name = table_name_from_stream_arn(table_arn) + return store.ddb_streams.get(table_name) + + +def _process_forwarded_records( + account_id: str, region_name: str, table_name: TableName, table_records: dict, kinesis +) -> None: + records = table_records["records"] + stream_type = table_records["table_stream_type"] + # if the table does not have a DynamoDB Streams enabled, skip publishing anything + if not stream_type.stream_view_type: + return + + # in this case, Kinesis forces the record to have both OldImage and NewImage, so we need to filter it + # as the settings are different for DDB Streams and Kinesis + if stream_type.is_kinesis and stream_type.stream_view_type != StreamViewType.NEW_AND_OLD_IMAGES: + kinesis_records = [] + + # StreamViewType determines what information is written to the stream for the table + # When an item in the table is inserted, updated or deleted + image_filter = set() + if stream_type.stream_view_type == StreamViewType.KEYS_ONLY: + image_filter = {"OldImage", "NewImage"} + elif stream_type.stream_view_type == StreamViewType.OLD_IMAGE: + image_filter = {"NewImage"} + elif stream_type.stream_view_type == StreamViewType.NEW_IMAGE: + image_filter = {"OldImage"} + + for record in records: + record["dynamodb"] = { + k: v for k, v in record["dynamodb"].items() if k not in image_filter + } + + if "SequenceNumber" not in record["dynamodb"]: + record["dynamodb"]["SequenceNumber"] = str( + get_and_increment_sequence_number_counter() + ) + + kinesis_records.append({"Data": dumps(record), "PartitionKey": "TODO"}) + + else: + kinesis_records = [] + for record in records: + if "SequenceNumber" not in record["dynamodb"]: + # we can mutate the record for SequenceNumber, the Kinesis forwarding takes care of filtering it + record["dynamodb"]["SequenceNumber"] = str( + get_and_increment_sequence_number_counter() + ) + + # simply pass along the records, they already have the right format + kinesis_records.append({"Data": dumps(record), "PartitionKey": "TODO"}) + + stream_name = get_kinesis_stream_name(table_name) + kinesis.put_records( + StreamName=stream_name, + Records=kinesis_records, + ) + + +def forward_events(account_id: str, region_name: str, records_map: dict[TableName, dict]) -> None: + kinesis = get_kinesis_client(account_id, region_name) + + for table_name, table_records in records_map.items(): + _process_forwarded_records(account_id, region_name, table_name, table_records, kinesis) + + +def delete_streams(account_id: str, region_name: str, table_arn: str) -> None: + store = get_dynamodbstreams_store(account_id, region_name) + table_name = table_name_from_table_arn(table_arn) + if store.ddb_streams.pop(table_name, None): + stream_name = get_kinesis_stream_name(table_name) + # stream_arn = stream["StreamArn"] + + # we're basically asynchronously trying to delete the stream, or should we do this "synchronous" with the table + # deletion? + def _delete_stream(*args, **kwargs): + try: + kinesis_client = get_kinesis_client(account_id, region_name) + # needs to be active otherwise we can't delete it + kinesis_client.get_waiter("stream_exists").wait(StreamName=stream_name) + kinesis_client.delete_stream(StreamName=stream_name, EnforceConsumerDeletion=True) + kinesis_client.get_waiter("stream_not_exists").wait(StreamName=stream_name) + except Exception: + LOG.warning( + "Failed to delete underlying kinesis stream for dynamodb table table_arn=%s", + table_arn, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + FuncThread(_delete_stream).start() # fire & forget + + +def get_kinesis_stream_name(table_name: str) -> str: + return DDB_KINESIS_STREAM_NAME_PREFIX + table_name + + +def table_name_from_stream_arn(stream_arn: str) -> str: + return stream_arn.split(":table/", 1)[-1].split("/")[0] + + +def table_name_from_table_arn(table_arn: str) -> str: + return table_name_from_stream_arn(table_arn) + + +def stream_name_from_stream_arn(stream_arn: str) -> str: + table_name = table_name_from_stream_arn(stream_arn) + return get_kinesis_stream_name(table_name) + + +def shard_id(kinesis_shard_id: str) -> str: + timestamp = str(int(now_utc())) + timestamp = f"{timestamp[:-5]}00000000".rjust(20, "0") + kinesis_shard_params = kinesis_shard_id.split("-") + return f"{kinesis_shard_params[0]}-{timestamp}-{kinesis_shard_params[-1][:32]}" + + +def kinesis_shard_id(dynamodbstream_shard_id: str) -> str: + shard_params = dynamodbstream_shard_id.rsplit("-") + return f"{shard_params[0]}-{shard_params[-1]}" + + +def get_shard_id(stream: Dict, kinesis_shard_id: str) -> str: + ddb_stream_shard_id = stream.get("shards_id_map", {}).get(kinesis_shard_id) + if not ddb_stream_shard_id: + ddb_stream_shard_id = shard_id(kinesis_shard_id) + stream["shards_id_map"][kinesis_shard_id] = ddb_stream_shard_id + + return ddb_stream_shard_id + + +def get_original_region( + context: RequestContext, stream_arn: str | None = None, table_name: str | None = None +) -> str: + """ + In DDB Global tables, we forward all the requests to the original region, instead of really replicating the data. + Since each table has a separate stream associated, we need to have a similar forwarding logic for DDB Streams. + To determine the original region, we need the table name, that can be either provided here or determined from the + ARN of the stream. + """ + if not stream_arn and not table_name: + LOG.debug( + "No Stream ARN or table name provided. Returning region '%s' from the request", + context.region, + ) + return context.region + + table_name = table_name or table_name_from_stream_arn(stream_arn) + return DynamoDBProvider.get_global_table_region(context=context, table_name=table_name) diff --git a/localstack-core/localstack/services/dynamodbstreams/models.py b/localstack-core/localstack/services/dynamodbstreams/models.py new file mode 100644 index 0000000000000..a8a6672babf11 --- /dev/null +++ b/localstack-core/localstack/services/dynamodbstreams/models.py @@ -0,0 +1,11 @@ +from typing import Dict + +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + + +class DynamoDbStreamsStore(BaseStore): + # maps table names to DynamoDB stream descriptions + ddb_streams: Dict[str, dict] = LocalAttribute(default=dict) + + +dynamodbstreams_stores = AccountRegionBundle("dynamodbstreams", DynamoDbStreamsStore) diff --git a/localstack-core/localstack/services/dynamodbstreams/provider.py b/localstack-core/localstack/services/dynamodbstreams/provider.py new file mode 100644 index 0000000000000..6c9548bb81ebf --- /dev/null +++ b/localstack-core/localstack/services/dynamodbstreams/provider.py @@ -0,0 +1,195 @@ +import copy +import logging + +from bson.json_util import loads + +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.dynamodbstreams import ( + DescribeStreamOutput, + DynamodbstreamsApi, + ExpiredIteratorException, + GetRecordsInput, + GetRecordsOutput, + GetShardIteratorOutput, + ListStreamsOutput, + PositiveIntegerObject, + ResourceNotFoundException, + SequenceNumber, + ShardId, + ShardIteratorType, + Stream, + StreamArn, + StreamDescription, + StreamStatus, + TableName, +) +from localstack.aws.connect import connect_to +from localstack.services.dynamodb.utils import change_region_in_ddb_stream_arn +from localstack.services.dynamodbstreams.dynamodbstreams_api import ( + get_dynamodbstreams_store, + get_kinesis_client, + get_kinesis_stream_name, + get_original_region, + get_shard_id, + kinesis_shard_id, + stream_name_from_stream_arn, + table_name_from_stream_arn, +) +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.collections import select_from_typed_dict + +LOG = logging.getLogger(__name__) + +STREAM_STATUS_MAP = { + "ACTIVE": StreamStatus.ENABLED, + "CREATING": StreamStatus.ENABLING, + "DELETING": StreamStatus.DISABLING, + "UPDATING": StreamStatus.ENABLING, +} + + +class DynamoDBStreamsProvider(DynamodbstreamsApi, ServiceLifecycleHook): + shard_to_region: dict[str, str] + """Map a shard iterator to the originating region. This is used in case of replica tables, as LocalStack keeps the + data in one region only, redirecting all the requests from replica regions.""" + + def __init__(self): + self.shard_to_region = {} + + def describe_stream( + self, + context: RequestContext, + stream_arn: StreamArn, + limit: PositiveIntegerObject = None, + exclusive_start_shard_id: ShardId = None, + **kwargs, + ) -> DescribeStreamOutput: + og_region = get_original_region(context=context, stream_arn=stream_arn) + store = get_dynamodbstreams_store(context.account_id, og_region) + kinesis = get_kinesis_client(account_id=context.account_id, region_name=og_region) + for stream in store.ddb_streams.values(): + _stream_arn = stream_arn + if context.region != og_region: + _stream_arn = change_region_in_ddb_stream_arn(_stream_arn, og_region) + if stream["StreamArn"] == _stream_arn: + # get stream details + dynamodb = connect_to( + aws_access_key_id=context.account_id, region_name=og_region + ).dynamodb + table_name = table_name_from_stream_arn(stream["StreamArn"]) + stream_name = get_kinesis_stream_name(table_name) + stream_details = kinesis.describe_stream(StreamName=stream_name) + table_details = dynamodb.describe_table(TableName=table_name) + stream["KeySchema"] = table_details["Table"]["KeySchema"] + stream["StreamStatus"] = STREAM_STATUS_MAP.get( + stream_details["StreamDescription"]["StreamStatus"] + ) + + # Replace Kinesis ShardIDs with ones that mimic actual + # DynamoDBStream ShardIDs. + stream_shards = copy.deepcopy(stream_details["StreamDescription"]["Shards"]) + start_index = 0 + for index, shard in enumerate(stream_shards): + shard["ShardId"] = get_shard_id(stream, shard["ShardId"]) + shard.pop("HashKeyRange", None) + # we want to ignore the shards before exclusive_start_shard_id parameters + # we store the index where we encounter then slice the shards + if exclusive_start_shard_id and exclusive_start_shard_id == shard["ShardId"]: + start_index = index + + if exclusive_start_shard_id: + # slicing the resulting shards after the exclusive_start_shard_id parameters + stream_shards = stream_shards[start_index + 1 :] + + stream["Shards"] = stream_shards + stream_description = select_from_typed_dict(StreamDescription, stream) + stream_description["StreamArn"] = _stream_arn + return DescribeStreamOutput(StreamDescription=stream_description) + + raise ResourceNotFoundException( + f"Requested resource not found: Stream: {stream_arn} not found" + ) + + @handler("GetRecords", expand=False) + def get_records(self, context: RequestContext, payload: GetRecordsInput) -> GetRecordsOutput: + _shard_iterator = payload["ShardIterator"] + region_name = context.region + if payload["ShardIterator"] in self.shard_to_region: + region_name = self.shard_to_region[_shard_iterator] + + kinesis = get_kinesis_client(account_id=context.account_id, region_name=region_name) + prefix, _, payload["ShardIterator"] = _shard_iterator.rpartition("|") + try: + kinesis_records = kinesis.get_records(**payload) + except kinesis.exceptions.ExpiredIteratorException: + self.shard_to_region.pop(_shard_iterator, None) + LOG.debug("Shard iterator for underlying kinesis stream expired") + raise ExpiredIteratorException("Shard iterator has expired") + result = { + "Records": [], + "NextShardIterator": f"{prefix}|{kinesis_records.get('NextShardIterator')}", + } + for record in kinesis_records["Records"]: + record_data = loads(record["Data"]) + record_data["dynamodb"]["SequenceNumber"] = record["SequenceNumber"] + result["Records"].append(record_data) + + # Similar as the logic in GetShardIterator, we need to track the originating region when we get the + # NextShardIterator in the results. + if region_name != context.region and "NextShardIterator" in result: + self.shard_to_region[result["NextShardIterator"]] = region_name + return GetRecordsOutput(**result) + + def get_shard_iterator( + self, + context: RequestContext, + stream_arn: StreamArn, + shard_id: ShardId, + shard_iterator_type: ShardIteratorType, + sequence_number: SequenceNumber = None, + **kwargs, + ) -> GetShardIteratorOutput: + stream_name = stream_name_from_stream_arn(stream_arn) + og_region = get_original_region(context=context, stream_arn=stream_arn) + stream_shard_id = kinesis_shard_id(shard_id) + kinesis = get_kinesis_client(account_id=context.account_id, region_name=og_region) + + kwargs = {"StartingSequenceNumber": sequence_number} if sequence_number else {} + result = kinesis.get_shard_iterator( + StreamName=stream_name, + ShardId=stream_shard_id, + ShardIteratorType=shard_iterator_type, + **kwargs, + ) + del result["ResponseMetadata"] + # TODO not quite clear what the |1| exactly denotes, because at AWS it's sometimes other numbers + result["ShardIterator"] = f"{stream_arn}|1|{result['ShardIterator']}" + + # In case of a replica table, we need to keep track of the real region originating the shard iterator. + # This region will be later used in GetRecords to redirect to the originating region, holding the data. + if og_region != context.region: + self.shard_to_region[result["ShardIterator"]] = og_region + return GetShardIteratorOutput(**result) + + def list_streams( + self, + context: RequestContext, + table_name: TableName = None, + limit: PositiveIntegerObject = None, + exclusive_start_stream_arn: StreamArn = None, + **kwargs, + ) -> ListStreamsOutput: + og_region = get_original_region(context=context, table_name=table_name) + store = get_dynamodbstreams_store(context.account_id, og_region) + result = [select_from_typed_dict(Stream, res) for res in store.ddb_streams.values()] + if table_name: + result: list[Stream] = [res for res in result if res["TableName"] == table_name] + # If this is a stream from a table replica, we need to change the region in the stream ARN, as LocalStack + # keeps a stream only in the originating region. + if context.region != og_region: + for stream in result: + stream["StreamArn"] = change_region_in_ddb_stream_arn( + stream["StreamArn"], context.region + ) + + return ListStreamsOutput(Streams=result) diff --git a/localstack-core/localstack/services/dynamodbstreams/v2/__init__.py b/localstack-core/localstack/services/dynamodbstreams/v2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/dynamodbstreams/v2/provider.py b/localstack-core/localstack/services/dynamodbstreams/v2/provider.py new file mode 100644 index 0000000000000..a91fbc592a992 --- /dev/null +++ b/localstack-core/localstack/services/dynamodbstreams/v2/provider.py @@ -0,0 +1,130 @@ +import logging + +from localstack.aws import handlers +from localstack.aws.api import RequestContext, ServiceRequest, ServiceResponse, handler +from localstack.aws.api.dynamodbstreams import ( + DescribeStreamInput, + DescribeStreamOutput, + DynamodbstreamsApi, + GetRecordsInput, + GetRecordsOutput, + GetShardIteratorInput, + GetShardIteratorOutput, + ListStreamsInput, + ListStreamsOutput, +) +from localstack.services.dynamodb.server import DynamodbServer +from localstack.services.dynamodb.utils import modify_ddblocal_arns +from localstack.services.dynamodb.v2.provider import DynamoDBProvider, modify_context_region +from localstack.services.dynamodbstreams.dynamodbstreams_api import get_original_region +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.aws.arns import parse_arn + +LOG = logging.getLogger(__name__) + + +class DynamoDBStreamsProvider(DynamodbstreamsApi, ServiceLifecycleHook): + shard_to_region: dict[str, str] + """Map a shard iterator to the originating region. This is used in case of replica tables, as LocalStack keeps the + data in one region only, redirecting all the requests from replica regions.""" + + def __init__(self): + self.server = DynamodbServer.get() + self.shard_to_region = {} + + def on_after_init(self): + # add response processor specific to ddblocal + handlers.modify_service_response.append(self.service, modify_ddblocal_arns) + + def on_before_start(self): + self.server.start_dynamodb() + + def _forward_request( + self, context: RequestContext, region: str | None, service_request: ServiceRequest + ) -> ServiceResponse: + """ + Modify the context region and then forward request to DynamoDB Local. + + This is used for operations impacted by global tables. In LocalStack, a single copy of global table + is kept, and any requests to replicated tables are forwarded to this original table. + """ + if region: + with modify_context_region(context, region): + return self.forward_request(context, service_request=service_request) + return self.forward_request(context, service_request=service_request) + + def forward_request( + self, context: RequestContext, service_request: ServiceRequest = None + ) -> ServiceResponse: + """ + Forward a request to DynamoDB Local. + """ + DynamoDBProvider.prepare_request_headers( + context.request.headers, account_id=context.account_id, region_name=context.region + ) + return self.server.proxy(context, service_request) + + def modify_stream_arn_for_ddb_local(self, stream_arn: str) -> str: + parsed_arn = parse_arn(stream_arn) + + return f"arn:aws:dynamodb:ddblocal:000000000000:{parsed_arn['resource']}" + + @handler("DescribeStream", expand=False) + def describe_stream( + self, + context: RequestContext, + payload: DescribeStreamInput, + ) -> DescribeStreamOutput: + global_table_region = get_original_region(context=context, stream_arn=payload["StreamArn"]) + request = payload.copy() + request["StreamArn"] = self.modify_stream_arn_for_ddb_local(request.get("StreamArn", "")) + return self._forward_request( + context=context, service_request=request, region=global_table_region + ) + + @handler("GetRecords", expand=False) + def get_records(self, context: RequestContext, payload: GetRecordsInput) -> GetRecordsOutput: + request = payload.copy() + request["ShardIterator"] = self.modify_stream_arn_for_ddb_local( + request.get("ShardIterator", "") + ) + region = self.shard_to_region.pop(request["ShardIterator"], None) + response = self._forward_request(context=context, region=region, service_request=request) + # Similar as the logic in GetShardIterator, we need to track the originating region when we get the + # NextShardIterator in the results. + if ( + region + and region != context.region + and (next_shard := response.get("NextShardIterator")) + ): + self.shard_to_region[next_shard] = region + return response + + @handler("GetShardIterator", expand=False) + def get_shard_iterator( + self, context: RequestContext, payload: GetShardIteratorInput + ) -> GetShardIteratorOutput: + global_table_region = get_original_region(context=context, stream_arn=payload["StreamArn"]) + request = payload.copy() + request["StreamArn"] = self.modify_stream_arn_for_ddb_local(request.get("StreamArn", "")) + response = self._forward_request( + context=context, service_request=request, region=global_table_region + ) + + # In case of a replica table, we need to keep track of the real region originating the shard iterator. + # This region will be later used in GetRecords to redirect to the originating region, holding the data. + if global_table_region != context.region and ( + shard_iterator := response.get("ShardIterator") + ): + self.shard_to_region[shard_iterator] = global_table_region + return response + + @handler("ListStreams", expand=False) + def list_streams(self, context: RequestContext, payload: ListStreamsInput) -> ListStreamsOutput: + global_table_region = get_original_region( + context=context, stream_arn=payload.get("TableName") + ) + # TODO: look into `ExclusiveStartStreamArn` param + return self._forward_request( + context=context, service_request=payload, region=global_table_region + ) diff --git a/localstack-core/localstack/services/ec2/__init__.py b/localstack-core/localstack/services/ec2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ec2/exceptions.py b/localstack-core/localstack/services/ec2/exceptions.py new file mode 100644 index 0000000000000..cb968ba2e6e68 --- /dev/null +++ b/localstack-core/localstack/services/ec2/exceptions.py @@ -0,0 +1,80 @@ +from localstack.aws.api import CommonServiceException + + +class InternalError(CommonServiceException): + def __init__(self, message): + super().__init__( + code="InternalError", + message=message, + ) + + +class IncorrectInstanceStateError(CommonServiceException): + def __init__(self, instance_id): + super().__init__( + code="IncorrectInstanceState", + message=f"The instance '{instance_id}' is not in a state from which it can be started", + ) + + +class InvalidAMIIdError(CommonServiceException): + def __init__(self, ami_id): + super().__init__( + code="InvalidAMIID.NotFound", message=f"The image id '{ami_id}' does not exist" + ) + + +class InvalidInstanceIdError(CommonServiceException): + def __init__(self, instance_id): + super().__init__( + code="InvalidInstanceID.NotFound", + message=f"The instance ID '{instance_id}' does not exist", + ) + + +class MissingParameterError(CommonServiceException): + def __init__(self, parameter): + super().__init__( + code="MissingParameter", + message=f"The request must contain the parameter {parameter}", + ) + + +class InvalidLaunchTemplateNameError(CommonServiceException): + def __init__(self): + super().__init__( + code="InvalidLaunchTemplateName.MalformedException", + message="A launch template name must be between 3 and 128 characters, and may contain letters, numbers, and the following characters: - ( ) . / _.'", + ) + + +class InvalidLaunchTemplateIdError(CommonServiceException): + def __init__(self): + super().__init__( + code="InvalidLaunchTemplateId.VersionNotFound", + message="Could not find launch template version", + ) + + +class InvalidSubnetDuplicateCustomIdError(CommonServiceException): + def __init__(self, custom_id): + super().__init__( + code="InvalidSubnet.DuplicateCustomId", + message=f"Subnet with custom id '{custom_id}' already exists", + ) + + +class InvalidSecurityGroupDuplicateCustomIdError(CommonServiceException): + def __init__(self, custom_id): + super().__init__( + code="InvalidSecurityGroupId.DuplicateCustomId", + message=f"Security group with custom id '{custom_id}' already exists", + ) + + +class InvalidVpcDuplicateCustomIdError(CommonServiceException): + def __init__(self, custom_id): + super().__init__( + code="InvalidVpc.DuplicateCustomId", + message=f"VPC with custom id '{custom_id}' already exists", + ) diff --git a/localstack-core/localstack/services/ec2/models.py b/localstack-core/localstack/services/ec2/models.py new file mode 100644 index 0000000000000..cf2bc854900da --- /dev/null +++ b/localstack-core/localstack/services/ec2/models.py @@ -0,0 +1,27 @@ +from moto.ec2 import ec2_backends +from moto.ec2.models import EC2Backend +from moto.ec2.models.subnets import Subnet + + +def get_ec2_backend(account_id: str, region: str) -> EC2Backend: + return ec2_backends[account_id][region] + + +# +# Pickle patches +# + + +def set_state(self, state): + state["_subnet_ip_generator"] = state["cidr"].hosts() + self.__dict__.update(state) + + +def get_state(self): + state = self.__dict__.copy() + state.pop("_subnet_ip_generator", None) + return state + + +Subnet.__setstate__ = set_state +Subnet.__getstate__ = get_state diff --git a/localstack-core/localstack/services/ec2/patches.py b/localstack-core/localstack/services/ec2/patches.py new file mode 100644 index 0000000000000..d2037015905ef --- /dev/null +++ b/localstack-core/localstack/services/ec2/patches.py @@ -0,0 +1,254 @@ +import logging +from typing import Optional + +from moto.ec2 import models as ec2_models +from moto.utilities.id_generator import Tags + +from localstack.services.ec2.exceptions import ( + InvalidSecurityGroupDuplicateCustomIdError, + InvalidSubnetDuplicateCustomIdError, + InvalidVpcDuplicateCustomIdError, +) +from localstack.utils.id_generator import ( + ExistingIds, + ResourceIdentifier, + localstack_id, +) +from localstack.utils.patch import patch + +LOG = logging.getLogger(__name__) + + +@localstack_id +def generate_vpc_id( + resource_identifier: ResourceIdentifier, + existing_ids: ExistingIds = None, + tags: Tags = None, +) -> str: + # We return an empty string here to differentiate between when a custom ID was used, or when it was randomly generated by `moto`. + return "" + + +@localstack_id +def generate_security_group_id( + resource_identifier: ResourceIdentifier, + existing_ids: ExistingIds = None, + tags: Tags = None, +) -> str: + # We return an empty string here to differentiate between when a custom ID was used, or when it was randomly generated by `moto`. + return "" + + +@localstack_id +def generate_subnet_id( + resource_identifier: ResourceIdentifier, + existing_ids: ExistingIds = None, + tags: Tags = None, +) -> str: + # We return an empty string here to differentiate between when a custom ID was used, or when it was randomly generated by `moto`. + return "" + + +class VpcIdentifier(ResourceIdentifier): + service = "ec2" + resource = "vpc" + + def __init__(self, account_id: str, region: str, cidr_block: str): + super().__init__(account_id, region, name=cidr_block) + + def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str: + return generate_vpc_id( + resource_identifier=self, + existing_ids=existing_ids, + tags=tags, + ) + + +class SecurityGroupIdentifier(ResourceIdentifier): + service = "ec2" + resource = "securitygroup" + + def __init__(self, account_id: str, region: str, vpc_id: str, group_name: str): + super().__init__(account_id, region, name=f"sg-{vpc_id}-{group_name}") + + def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str: + return generate_security_group_id( + resource_identifier=self, existing_ids=existing_ids, tags=tags + ) + + +class SubnetIdentifier(ResourceIdentifier): + service = "ec2" + resource = "subnet" + + def __init__(self, account_id: str, region: str, vpc_id: str, cidr_block: str): + super().__init__(account_id, region, name=f"subnet-{vpc_id}-{cidr_block}") + + def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str: + return generate_subnet_id( + resource_identifier=self, + existing_ids=existing_ids, + tags=tags, + ) + + +def apply_patches(): + @patch(ec2_models.subnets.SubnetBackend.create_subnet) + def ec2_create_subnet( + fn: ec2_models.subnets.SubnetBackend.create_subnet, + self: ec2_models.subnets.SubnetBackend, + *args, + tags: Optional[dict[str, str]] = None, + **kwargs, + ): + # Patch this method so that we can create a subnet with a specific "custom" + # ID. The custom ID that we will use is contained within a special tag. + vpc_id: str = args[0] if len(args) >= 1 else kwargs["vpc_id"] + cidr_block: str = args[1] if len(args) >= 1 else kwargs["cidr_block"] + resource_identifier = SubnetIdentifier( + self.account_id, self.region_name, vpc_id, cidr_block + ) + + # tags has the format: {"subnet": {"Key": ..., "Value": ...}}, but we need + # to pass this to the generate method as {"Key": ..., "Value": ...}. Take + # care not to alter the original tags dict otherwise moto will not be able + # to understand it. + subnet_tags = None + if tags is not None: + subnet_tags = tags.get("subnet", tags) + custom_id = resource_identifier.generate(tags=subnet_tags) + + if custom_id: + # Check if custom id is unique within a given VPC + for az_subnets in self.subnets.values(): + for subnet in az_subnets.values(): + if subnet.vpc_id == vpc_id and subnet.id == custom_id: + raise InvalidSubnetDuplicateCustomIdError(custom_id) + + # Generate subnet with moto library + result: ec2_models.subnets.Subnet = fn(self, *args, tags=tags, **kwargs) + availability_zone = result.availability_zone + + if custom_id: + # Remove the subnet from the default dict and add it back with the custom id + self.subnets[availability_zone].pop(result.id) + old_id = result.id + result.id = custom_id + self.subnets[availability_zone][custom_id] = result + + # Tags are not stored in the Subnet object, but instead stored in a separate + # dict in the EC2 backend, keyed by subnet id. That therefore requires + # updating as well. + if old_id in self.tags: + self.tags[custom_id] = self.tags.pop(old_id) + + # Return the subnet with the patched custom id + return result + + @patch(ec2_models.security_groups.SecurityGroupBackend.create_security_group) + def ec2_create_security_group( + fn: ec2_models.security_groups.SecurityGroupBackend.create_security_group, + self: ec2_models.security_groups.SecurityGroupBackend, + name: str, + *args, + vpc_id: Optional[str] = None, + tags: Optional[dict[str, str]] = None, + force: bool = False, + **kwargs, + ): + vpc_id = vpc_id or self.default_vpc.id + resource_identifier = SecurityGroupIdentifier( + self.account_id, self.region_name, vpc_id, name + ) + custom_id = resource_identifier.generate(tags=tags) + + if not force and self.get_security_group_from_id(custom_id): + raise InvalidSecurityGroupDuplicateCustomIdError(custom_id) + + # Generate security group with moto library + result: ec2_models.security_groups.SecurityGroup = fn( + self, name, *args, vpc_id=vpc_id, tags=tags, force=force, **kwargs + ) + + if custom_id: + # Remove the security group from the default dict and add it back with the custom id + self.groups[result.vpc_id].pop(result.group_id) + old_id = result.group_id + result.group_id = result.id = custom_id + self.groups[result.vpc_id][custom_id] = result + + # Tags are not stored in the Security Group object, but instead are stored in a + # separate dict in the EC2 backend, keyed by id. That therefore requires + # updating as well. + if old_id in self.tags: + self.tags[custom_id] = self.tags.pop(old_id) + + return result + + @patch(ec2_models.vpcs.VPCBackend.create_vpc) + def ec2_create_vpc( + fn: ec2_models.vpcs.VPCBackend.create_vpc, + self: ec2_models.vpcs.VPCBackend, + cidr_block: str, + *args, + tags: Optional[list[dict[str, str]]] = None, + is_default: bool = False, + **kwargs, + ): + resource_identifier = VpcIdentifier(self.account_id, self.region_name, cidr_block) + custom_id = resource_identifier.generate(tags=tags) + + # Check if custom id is unique + if custom_id and custom_id in self.vpcs: + raise InvalidVpcDuplicateCustomIdError(custom_id) + + # Generate VPC with moto library + result: ec2_models.vpcs.VPC = fn( + self, cidr_block, *args, tags=tags, is_default=is_default, **kwargs + ) + vpc_id = result.id + + if custom_id: + # Remove security group associated with unique non-custom VPC ID + default = self.get_security_group_from_name("default", vpc_id=vpc_id) + if not default: + self.delete_security_group( + name="default", + vpc_id=vpc_id, + ) + + # Delete route table if only main route table remains. + for route_table in self.describe_route_tables(filters={"vpc-id": vpc_id}): + self.delete_route_table(route_table.id) # type: ignore[attr-defined] + + # Remove the VPC from the default dict and add it back with the custom id + self.vpcs.pop(vpc_id) + old_id = result.id + result.id = custom_id + self.vpcs[custom_id] = result + + # Tags are not stored in the VPC object, but instead stored in a separate + # dict in the EC2 backend, keyed by VPC id. That therefore requires + # updating as well. + if old_id in self.tags: + self.tags[custom_id] = self.tags.pop(old_id) + + # Create default network ACL, route table, and security group for custom ID VPC + self.create_route_table( + vpc_id=custom_id, + main=True, + ) + self.create_network_acl( + vpc_id=custom_id, + default=True, + ) + # Associate default security group with custom ID VPC + if not default: + self.create_security_group( + name="default", + description="default VPC security group", + vpc_id=custom_id, + is_default=is_default, + ) + + return result diff --git a/localstack-core/localstack/services/ec2/provider.py b/localstack-core/localstack/services/ec2/provider.py new file mode 100644 index 0000000000000..ab52195e4cfa8 --- /dev/null +++ b/localstack-core/localstack/services/ec2/provider.py @@ -0,0 +1,628 @@ +import copy +import json +import logging +import re +from abc import ABC +from datetime import datetime, timezone + +from botocore.parsers import ResponseParserError +from moto.core.utils import camelcase_to_underscores, underscores_to_camelcase +from moto.ec2.exceptions import InvalidVpcEndPointIdError +from moto.ec2.models import ( + EC2Backend, + FlowLogsBackend, + SubnetBackend, + TransitGatewayAttachmentBackend, + VPCBackend, + ec2_backends, +) +from moto.ec2.models.launch_templates import LaunchTemplate as MotoLaunchTemplate +from moto.ec2.models.subnets import Subnet + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.ec2 import ( + AvailabilityZone, + Boolean, + CreateFlowLogsRequest, + CreateFlowLogsResult, + CreateLaunchTemplateRequest, + CreateLaunchTemplateResult, + CreateSubnetRequest, + CreateSubnetResult, + CreateTransitGatewayRequest, + CreateTransitGatewayResult, + CurrencyCodeValues, + DescribeAvailabilityZonesRequest, + DescribeAvailabilityZonesResult, + DescribeReservedInstancesOfferingsRequest, + DescribeReservedInstancesOfferingsResult, + DescribeReservedInstancesRequest, + DescribeReservedInstancesResult, + DescribeSubnetsRequest, + DescribeSubnetsResult, + DescribeTransitGatewaysRequest, + DescribeTransitGatewaysResult, + DescribeVpcEndpointServicesRequest, + DescribeVpcEndpointServicesResult, + DescribeVpcEndpointsRequest, + DescribeVpcEndpointsResult, + DnsOptions, + DnsOptionsSpecification, + DnsRecordIpType, + Ec2Api, + GetSecurityGroupsForVpcRequest, + GetSecurityGroupsForVpcResult, + InstanceType, + IpAddressType, + LaunchTemplate, + ModifyLaunchTemplateRequest, + ModifyLaunchTemplateResult, + ModifySubnetAttributeRequest, + ModifyVpcEndpointResult, + OfferingClassType, + OfferingTypeValues, + PricingDetail, + PurchaseReservedInstancesOfferingRequest, + PurchaseReservedInstancesOfferingResult, + RecurringCharge, + RecurringChargeFrequency, + ReservedInstances, + ReservedInstancesOffering, + ReservedInstanceState, + RevokeSecurityGroupEgressRequest, + RevokeSecurityGroupEgressResult, + RIProductDescription, + SecurityGroupForVpc, + String, + SubnetConfigurationsList, + Tenancy, + UnsuccessfulItem, + UnsuccessfulItemError, + VpcEndpointId, + VpcEndpointRouteTableIdList, + VpcEndpointSecurityGroupIdList, + VpcEndpointSubnetIdList, + scope, +) +from localstack.aws.connect import connect_to +from localstack.services.ec2.exceptions import ( + InvalidLaunchTemplateIdError, + InvalidLaunchTemplateNameError, + MissingParameterError, +) +from localstack.services.ec2.models import get_ec2_backend +from localstack.services.ec2.patches import apply_patches +from localstack.services.moto import call_moto, call_moto_with_request +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.patch import patch +from localstack.utils.strings import first_char_to_upper, long_uid, short_uid + +LOG = logging.getLogger(__name__) + +# additional subnet attributes not yet supported upstream +ADDITIONAL_SUBNET_ATTRS = ("private_dns_name_options_on_launch", "enable_dns64") + + +class Ec2Provider(Ec2Api, ABC, ServiceLifecycleHook): + def on_after_init(self): + apply_patches() + + @handler("DescribeAvailabilityZones", expand=False) + def describe_availability_zones( + self, + context: RequestContext, + describe_availability_zones_request: DescribeAvailabilityZonesRequest, + ) -> DescribeAvailabilityZonesResult: + backend = get_ec2_backend(context.account_id, context.region) + zone_names = describe_availability_zones_request.get("ZoneNames") + zone_ids = describe_availability_zones_request.get("ZoneIds") + if zone_names or zone_ids: + filtered_zones = backend.describe_availability_zones( + zone_names=zone_names, zone_ids=zone_ids + ) + availability_zones = [ + AvailabilityZone( + State="available", + Messages=[], + RegionName=zone.region_name, + ZoneName=zone.name, + ZoneId=zone.zone_id, + ZoneType=zone.zone_type, + ) + for zone in filtered_zones + ] + return DescribeAvailabilityZonesResult(AvailabilityZones=availability_zones) + return call_moto(context) + + @handler("DescribeReservedInstancesOfferings", expand=False) + def describe_reserved_instances_offerings( + self, + context: RequestContext, + describe_reserved_instances_offerings_request: DescribeReservedInstancesOfferingsRequest, + ) -> DescribeReservedInstancesOfferingsResult: + return DescribeReservedInstancesOfferingsResult( + ReservedInstancesOfferings=[ + ReservedInstancesOffering( + AvailabilityZone="eu-central-1a", + Duration=2628000, + FixedPrice=0.0, + InstanceType=InstanceType.t2_small, + ProductDescription=RIProductDescription.Linux_UNIX, + ReservedInstancesOfferingId=long_uid(), + UsagePrice=0.0, + CurrencyCode=CurrencyCodeValues.USD, + InstanceTenancy=Tenancy.default, + Marketplace=True, + PricingDetails=[PricingDetail(Price=0.0, Count=3)], + RecurringCharges=[ + RecurringCharge(Amount=0.25, Frequency=RecurringChargeFrequency.Hourly) + ], + Scope=scope.Availability_Zone, + ) + ] + ) + + @handler("DescribeReservedInstances", expand=False) + def describe_reserved_instances( + self, + context: RequestContext, + describe_reserved_instances_request: DescribeReservedInstancesRequest, + ) -> DescribeReservedInstancesResult: + return DescribeReservedInstancesResult( + ReservedInstances=[ + ReservedInstances( + AvailabilityZone="eu-central-1a", + Duration=2628000, + End=datetime(2016, 6, 30, tzinfo=timezone.utc), + FixedPrice=0.0, + InstanceCount=2, + InstanceType=InstanceType.t2_small, + ProductDescription=RIProductDescription.Linux_UNIX, + ReservedInstancesId=long_uid(), + Start=datetime(2016, 1, 1, tzinfo=timezone.utc), + State=ReservedInstanceState.active, + UsagePrice=0.05, + CurrencyCode=CurrencyCodeValues.USD, + InstanceTenancy=Tenancy.default, + OfferingClass=OfferingClassType.standard, + OfferingType=OfferingTypeValues.Partial_Upfront, + RecurringCharges=[ + RecurringCharge(Amount=0.05, Frequency=RecurringChargeFrequency.Hourly) + ], + Scope=scope.Availability_Zone, + ) + ] + ) + + @handler("PurchaseReservedInstancesOffering", expand=False) + def purchase_reserved_instances_offering( + self, + context: RequestContext, + purchase_reserved_instances_offerings_request: PurchaseReservedInstancesOfferingRequest, + ) -> PurchaseReservedInstancesOfferingResult: + return PurchaseReservedInstancesOfferingResult( + ReservedInstancesId=long_uid(), + ) + + @handler("ModifyVpcEndpoint") + def modify_vpc_endpoint( + self, + context: RequestContext, + vpc_endpoint_id: VpcEndpointId, + dry_run: Boolean = None, + reset_policy: Boolean = None, + policy_document: String = None, + add_route_table_ids: VpcEndpointRouteTableIdList = None, + remove_route_table_ids: VpcEndpointRouteTableIdList = None, + add_subnet_ids: VpcEndpointSubnetIdList = None, + remove_subnet_ids: VpcEndpointSubnetIdList = None, + add_security_group_ids: VpcEndpointSecurityGroupIdList = None, + remove_security_group_ids: VpcEndpointSecurityGroupIdList = None, + ip_address_type: IpAddressType = None, + dns_options: DnsOptionsSpecification = None, + private_dns_enabled: Boolean = None, + subnet_configurations: SubnetConfigurationsList = None, + **kwargs, + ) -> ModifyVpcEndpointResult: + backend = get_ec2_backend(context.account_id, context.region) + + vpc_endpoint = backend.vpc_end_points.get(vpc_endpoint_id) + if not vpc_endpoint: + raise InvalidVpcEndPointIdError(vpc_endpoint_id) + + if policy_document is not None: + vpc_endpoint.policy_document = policy_document + + if add_route_table_ids is not None: + vpc_endpoint.route_table_ids.extend(add_route_table_ids) + + if remove_route_table_ids is not None: + vpc_endpoint.route_table_ids = [ + id_ for id_ in vpc_endpoint.route_table_ids if id_ not in remove_route_table_ids + ] + + if add_subnet_ids is not None: + vpc_endpoint.subnet_ids.extend(add_subnet_ids) + + if remove_subnet_ids is not None: + vpc_endpoint.subnet_ids = [ + id_ for id_ in vpc_endpoint.subnet_ids if id_ not in remove_subnet_ids + ] + + if private_dns_enabled is not None: + vpc_endpoint.private_dns_enabled = private_dns_enabled + + return ModifyVpcEndpointResult(Return=True) + + @handler("ModifySubnetAttribute", expand=False) + def modify_subnet_attribute( + self, context: RequestContext, request: ModifySubnetAttributeRequest + ) -> None: + try: + return call_moto(context) + except Exception as e: + if not isinstance(e, ResponseParserError) and "InvalidParameterValue" not in str(e): + raise + + backend = get_ec2_backend(context.account_id, context.region) + + # fix setting subnet attributes currently not supported upstream + subnet_id = request["SubnetId"] + host_type = request.get("PrivateDnsHostnameTypeOnLaunch") + a_record_on_launch = request.get("EnableResourceNameDnsARecordOnLaunch") + aaaa_record_on_launch = request.get("EnableResourceNameDnsAAAARecordOnLaunch") + enable_dns64 = request.get("EnableDns64") + + if host_type: + attr_name = camelcase_to_underscores("PrivateDnsNameOptionsOnLaunch") + value = {"HostnameType": host_type} + backend.modify_subnet_attribute(subnet_id, attr_name, value) + ## explicitly checking None value as this could contain a False value + if aaaa_record_on_launch is not None: + attr_name = camelcase_to_underscores("PrivateDnsNameOptionsOnLaunch") + value = {"EnableResourceNameDnsAAAARecord": aaaa_record_on_launch["Value"]} + backend.modify_subnet_attribute(subnet_id, attr_name, value) + if a_record_on_launch is not None: + attr_name = camelcase_to_underscores("PrivateDnsNameOptionsOnLaunch") + value = {"EnableResourceNameDnsARecord": a_record_on_launch["Value"]} + backend.modify_subnet_attribute(subnet_id, attr_name, value) + if enable_dns64 is not None: + attr_name = camelcase_to_underscores("EnableDns64") + backend.modify_subnet_attribute(subnet_id, attr_name, enable_dns64["Value"]) + + @handler("CreateSubnet", expand=False) + def create_subnet( + self, context: RequestContext, request: CreateSubnetRequest + ) -> CreateSubnetResult: + response = call_moto(context) + backend = get_ec2_backend(context.account_id, context.region) + subnet_id = response["Subnet"]["SubnetId"] + host_type = request.get("PrivateDnsHostnameTypeOnLaunch", "ip-name") + attr_name = camelcase_to_underscores("PrivateDnsNameOptionsOnLaunch") + value = {"HostnameType": host_type} + backend.modify_subnet_attribute(subnet_id, attr_name, value) + return response + + @handler("RevokeSecurityGroupEgress", expand=False) + def revoke_security_group_egress( + self, + context: RequestContext, + revoke_security_group_egress_request: RevokeSecurityGroupEgressRequest, + ) -> RevokeSecurityGroupEgressResult: + try: + return call_moto(context) + except Exception as e: + if "specified rule does not exist" in str(e): + backend = get_ec2_backend(context.account_id, context.region) + group_id = revoke_security_group_egress_request["GroupId"] + group = backend.get_security_group_by_name_or_id(group_id) + if group and not group.egress_rules: + return RevokeSecurityGroupEgressResult(Return=True) + raise + + @handler("DescribeSubnets", expand=False) + def describe_subnets( + self, + context: RequestContext, + request: DescribeSubnetsRequest, + ) -> DescribeSubnetsResult: + result = call_moto(context) + backend = get_ec2_backend(context.account_id, context.region) + # add additional/missing attributes in subnet responses + for subnet in result.get("Subnets", []): + subnet_obj = backend.subnets[subnet["AvailabilityZone"]].get(subnet["SubnetId"]) + for attr in ADDITIONAL_SUBNET_ATTRS: + if hasattr(subnet_obj, attr): + attr_name = first_char_to_upper(underscores_to_camelcase(attr)) + if attr_name not in subnet: + subnet[attr_name] = getattr(subnet_obj, attr) + return result + + @handler("CreateTransitGateway", expand=False) + def create_transit_gateway( + self, + context: RequestContext, + request: CreateTransitGatewayRequest, + ) -> CreateTransitGatewayResult: + result = call_moto(context) + backend = get_ec2_backend(context.account_id, context.region) + transit_gateway_id = result["TransitGateway"]["TransitGatewayId"] + transit_gateway = backend.transit_gateways.get(transit_gateway_id) + result.get("TransitGateway").get("Options").update(transit_gateway.options) + return result + + @handler("DescribeTransitGateways", expand=False) + def describe_transit_gateways( + self, + context: RequestContext, + request: DescribeTransitGatewaysRequest, + ) -> DescribeTransitGatewaysResult: + result = call_moto(context) + backend = get_ec2_backend(context.account_id, context.region) + for transit_gateway in result.get("TransitGateways", []): + transit_gateway_id = transit_gateway["TransitGatewayId"] + tgw = backend.transit_gateways.get(transit_gateway_id) + transit_gateway["Options"].update(tgw.options) + return result + + @handler("CreateLaunchTemplate", expand=False) + def create_launch_template( + self, + context: RequestContext, + request: CreateLaunchTemplateRequest, + ) -> CreateLaunchTemplateResult: + # parameter validation + if not request["LaunchTemplateData"]: + raise MissingParameterError(parameter="LaunchTemplateData") + + name = request["LaunchTemplateName"] + if len(name) < 3 or len(name) > 128 or not re.fullmatch(r"[a-zA-Z0-9.\-_()/]*", name): + raise InvalidLaunchTemplateNameError() + + return call_moto(context) + + @handler("ModifyLaunchTemplate", expand=False) + def modify_launch_template( + self, + context: RequestContext, + request: ModifyLaunchTemplateRequest, + ) -> ModifyLaunchTemplateResult: + backend = get_ec2_backend(context.account_id, context.region) + template_id = ( + request["LaunchTemplateId"] + or backend.launch_template_name_to_ids[request["LaunchTemplateName"]] + ) + template: MotoLaunchTemplate = backend.launch_templates[template_id] + + # check if defaultVersion exists + if request["DefaultVersion"]: + try: + template.versions[int(request["DefaultVersion"]) - 1] + except IndexError: + raise InvalidLaunchTemplateIdError() + + template.default_version_number = int(request["DefaultVersion"]) + + return ModifyLaunchTemplateResult( + LaunchTemplate=LaunchTemplate( + LaunchTemplateId=template.id, + LaunchTemplateName=template.name, + CreateTime=template.create_time, + DefaultVersionNumber=template.default_version_number, + LatestVersionNumber=template.latest_version_number, + Tags=template.tags, + ) + ) + + @handler("DescribeVpcEndpointServices", expand=False) + def describe_vpc_endpoint_services( + self, + context: RequestContext, + request: DescribeVpcEndpointServicesRequest, + ) -> DescribeVpcEndpointServicesResult: + ep_services = VPCBackend._collect_default_endpoint_services( + account_id=context.account_id, region=context.region + ) + + moto_backend = get_moto_backend(context) + service_names = [s["ServiceName"] for s in ep_services] + execute_api_name = f"com.amazonaws.{context.region}.execute-api" + + if execute_api_name not in service_names: + # ensure that the service entry for execute-api exists + zones = moto_backend.describe_availability_zones() + zones = [zone.name for zone in zones] + private_dns_name = f"*.execute-api.{context.region}.amazonaws.com" + service = { + "ServiceName": execute_api_name, + "ServiceId": f"vpce-svc-{short_uid()}", + "ServiceType": [{"ServiceType": "Interface"}], + "AvailabilityZones": zones, + "Owner": "amazon", + "BaseEndpointDnsNames": [f"execute-api.{context.region}.vpce.amazonaws.com"], + "PrivateDnsName": private_dns_name, + "PrivateDnsNames": [{"PrivateDnsName": private_dns_name}], + "VpcEndpointPolicySupported": True, + "AcceptanceRequired": False, + "ManagesVpcEndpoints": False, + "PrivateDnsNameVerificationState": "verified", + "SupportedIpAddressTypes": ["ipv4"], + } + ep_services.append(service) + + return call_moto(context) + + @handler("DescribeVpcEndpoints", expand=False) + def describe_vpc_endpoints( + self, + context: RequestContext, + request: DescribeVpcEndpointsRequest, + ) -> DescribeVpcEndpointsResult: + result: DescribeVpcEndpointsResult = call_moto(context) + + for endpoint in result.get("VpcEndpoints"): + endpoint.setdefault("DnsOptions", DnsOptions(DnsRecordIpType=DnsRecordIpType.ipv4)) + endpoint.setdefault("IpAddressType", IpAddressType.ipv4) + endpoint.setdefault("RequesterManaged", False) + endpoint.setdefault("RouteTableIds", []) + # AWS parity: Version should not be contained in the policy response + policy = endpoint.get("PolicyDocument") + if policy and '"Version":' in policy: + policy = json.loads(policy) + policy.pop("Version", None) + endpoint["PolicyDocument"] = json.dumps(policy) + + return result + + @handler("CreateFlowLogs", expand=False) + def create_flow_logs( + self, + context: RequestContext, + request: CreateFlowLogsRequest, + **kwargs, + ) -> CreateFlowLogsResult: + if request.get("LogDestination") and request.get("LogGroupName"): + raise CommonServiceException( + code="InvalidParameter", + message="Please only provide LogGroupName or only provide LogDestination.", + ) + if request.get("LogDestinationType") == "s3": + if request.get("LogGroupName"): + raise CommonServiceException( + code="InvalidParameter", + message="LogDestination type must be cloud-watch-logs if LogGroupName is provided.", + ) + elif not (bucket_arn := request.get("LogDestination")): + raise CommonServiceException( + code="InvalidParameter", + message="LogDestination can't be empty if LogGroupName is not provided.", + ) + + # Moto will check in memory whether the bucket exists in Moto itself + # we modify the request to not send a destination, so that the validation does not happen + # we can add the validation ourselves + service_request = copy.deepcopy(request) + service_request["LogDestinationType"] = "__placeholder__" + bucket_name = bucket_arn.split(":", 5)[5].split("/")[0] + # TODO: validate how IAM is enforced? probably with DeliverLogsPermissionArn + s3_client = connect_to().s3 + try: + s3_client.head_bucket(Bucket=bucket_name) + except Exception as e: + LOG.debug( + "An exception occurred when trying to create FlowLogs with S3 destination: %s", + e, + ) + return CreateFlowLogsResult( + FlowLogIds=[], + Unsuccessful=[ + UnsuccessfulItem( + Error=UnsuccessfulItemError( + Code="400", + Message=f"LogDestination: {bucket_name} does not exist", + ), + ResourceId=resource_id, + ) + for resource_id in request.get("ResourceIds", []) + ], + ) + + response: CreateFlowLogsResult = call_moto_with_request(context, service_request) + moto_backend = get_moto_backend(context) + for flow_log_id in response["FlowLogIds"]: + if flow_log := moto_backend.flow_logs.get(flow_log_id): + # just to be sure to not override another value, we only replace if it's the placeholder + flow_log.log_destination_type = flow_log.log_destination_type.replace( + "__placeholder__", "s3" + ) + else: + response = call_moto(context) + + return response + + @handler("GetSecurityGroupsForVpc", expand=False) + def get_security_groups_for_vpc( + self, + context: RequestContext, + get_security_groups_for_vpc_request: GetSecurityGroupsForVpcRequest, + ) -> GetSecurityGroupsForVpcResult: + vpc_id = get_security_groups_for_vpc_request.get("VpcId") + backend = get_ec2_backend(context.account_id, context.region) + filters = {"vpc-id": [vpc_id]} + filtered_sgs = backend.describe_security_groups(filters=filters) + + sgs = [ + SecurityGroupForVpc( + Description=sg.description, + GroupId=sg.id, + GroupName=sg.name, + OwnerId=context.account_id, + PrimaryVpcId=sg.vpc_id, + Tags=[{"Key": tag.get("key"), "Value": tag.get("value")} for tag in sg.get_tags()], + ) + for sg in filtered_sgs + ] + return GetSecurityGroupsForVpcResult(SecurityGroupForVpcs=sgs, NextToken=None) + + +@patch(SubnetBackend.modify_subnet_attribute) +def modify_subnet_attribute(fn, self, subnet_id, attr_name, attr_value): + subnet = self.get_subnet(subnet_id) + if attr_name in ADDITIONAL_SUBNET_ATTRS: + # private dns name options on launch contains dict with keys EnableResourceNameDnsARecord and EnableResourceNameDnsAAAARecord, HostnameType + if attr_name == "private_dns_name_options_on_launch": + if hasattr(subnet, attr_name): + getattr(subnet, attr_name).update(attr_value) + return + else: + setattr(subnet, attr_name, attr_value) + return + setattr(subnet, attr_name, attr_value) + return + return fn(self, subnet_id, attr_name, attr_value) + + +def get_moto_backend(context: RequestContext) -> EC2Backend: + """Get the moto EC2 backend for the given request context""" + return ec2_backends[context.account_id][context.region] + + +@patch(Subnet.get_filter_value) +def get_filter_value(fn, self, filter_name): + if filter_name in ( + "ipv6CidrBlockAssociationSet.associationId", + "ipv6-cidr-block-association.association-id", + ): + return self.ipv6_cidr_block_associations + return fn(self, filter_name) + + +@patch(TransitGatewayAttachmentBackend.delete_transit_gateway_vpc_attachment) +def delete_transit_gateway_vpc_attachment(fn, self, transit_gateway_attachment_id, **kwargs): + transit_gateway_attachment = self.transit_gateway_attachments.get(transit_gateway_attachment_id) + transit_gateway_attachment.state = "deleted" + return transit_gateway_attachment + + +@patch(FlowLogsBackend._validate_request) +def _validate_request( + fn, + self, + log_group_name: str, + log_destination: str, + log_destination_type: str, + max_aggregation_interval: str, + deliver_logs_permission_arn: str, +) -> None: + if not log_destination_type and log_destination: + # this is to fix the S3 destination issue, the validation will occur in the provider + return + + fn( + self, + log_group_name, + log_destination, + log_destination_type, + max_aggregation_interval, + deliver_logs_permission_arn, + ) diff --git a/localstack-core/localstack/services/ec2/resource_providers/__init__.py b/localstack-core/localstack/services/ec2/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions.py new file mode 100644 index 0000000000000..03665a7c45fb6 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions.py @@ -0,0 +1,149 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2DHCPOptionsProperties(TypedDict): + DhcpOptionsId: Optional[str] + DomainName: Optional[str] + DomainNameServers: Optional[list[str]] + NetbiosNameServers: Optional[list[str]] + NetbiosNodeType: Optional[int] + NtpServers: Optional[list[str]] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2DHCPOptionsProvider(ResourceProvider[EC2DHCPOptionsProperties]): + TYPE = "AWS::EC2::DHCPOptions" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2DHCPOptionsProperties], + ) -> ProgressEvent[EC2DHCPOptionsProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/DhcpOptionsId + + + + Create-only properties: + - /properties/NetbiosNameServers + - /properties/NetbiosNodeType + - /properties/NtpServers + - /properties/DomainName + - /properties/DomainNameServers + + Read-only properties: + - /properties/DhcpOptionsId + + IAM permissions required: + - ec2:CreateDhcpOptions + - ec2:DescribeDhcpOptions + - ec2:CreateTags + + """ + model = request.desired_state + + dhcp_configurations = [] + if model.get("DomainName"): + dhcp_configurations.append({"Key": "domain-name", "Values": [model["DomainName"]]}) + if model.get("DomainNameServers"): + dhcp_configurations.append( + {"Key": "domain-name-servers", "Values": model["DomainNameServers"]} + ) + if model.get("NetbiosNameServers"): + dhcp_configurations.append( + {"Key": "netbios-name-servers", "Values": model["NetbiosNameServers"]} + ) + if model.get("NetbiosNodeType"): + dhcp_configurations.append( + {"Key": "netbios-node-type", "Values": [str(model["NetbiosNodeType"])]} + ) + if model.get("NtpServers"): + dhcp_configurations.append({"Key": "ntp-servers", "Values": model["NtpServers"]}) + + create_params = { + "DhcpConfigurations": dhcp_configurations, + } + if model.get("Tags"): + tags = [{"Key": str(tag["Key"]), "Value": str(tag["Value"])} for tag in model["Tags"]] + else: + tags = [] + + default_tags = [ + {"Key": "aws:cloudformation:logical-id", "Value": request.logical_resource_id}, + {"Key": "aws:cloudformation:stack-id", "Value": request.stack_id}, + {"Key": "aws:cloudformation:stack-name", "Value": request.stack_name}, + ] + + create_params["TagSpecifications"] = [ + {"ResourceType": "dhcp-options", "Tags": (tags + default_tags)} + ] + + result = request.aws_client_factory.ec2.create_dhcp_options(**create_params) + model["DhcpOptionsId"] = result["DhcpOptions"]["DhcpOptionsId"] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[EC2DHCPOptionsProperties], + ) -> ProgressEvent[EC2DHCPOptionsProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeDhcpOptions + - ec2:DescribeTags + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2DHCPOptionsProperties], + ) -> ProgressEvent[EC2DHCPOptionsProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteDhcpOptions + - ec2:DeleteTags + """ + model = request.desired_state + request.aws_client_factory.ec2.delete_dhcp_options(DhcpOptionsId=model["DhcpOptionsId"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[EC2DHCPOptionsProperties], + ) -> ProgressEvent[EC2DHCPOptionsProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:CreateTags + - ec2:DescribeDhcpOptions + - ec2:DeleteTags + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions.schema.json new file mode 100644 index 0000000000000..93e8fd3d62171 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions.schema.json @@ -0,0 +1,120 @@ +{ + "typeName": "AWS::EC2::DHCPOptions", + "description": "Resource Type definition for AWS::EC2::DHCPOptions", + "additionalProperties": false, + "properties": { + "DhcpOptionsId": { + "type": "string" + }, + "DomainName": { + "type": "string", + "description": "This value is used to complete unqualified DNS hostnames." + }, + "DomainNameServers": { + "type": "array", + "description": "The IPv4 addresses of up to four domain name servers, or AmazonProvidedDNS.", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "NetbiosNameServers": { + "type": "array", + "description": "The IPv4 addresses of up to four NetBIOS name servers.", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "NetbiosNodeType": { + "type": "integer", + "description": "The NetBIOS node type (1, 2, 4, or 8)." + }, + "NtpServers": { + "type": "array", + "description": "The IPv4 addresses of up to four Network Time Protocol (NTP) servers.", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Tags": { + "type": "array", + "description": "Any tags assigned to the DHCP options set.", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "taggable": true, + "createOnlyProperties": [ + "/properties/NetbiosNameServers", + "/properties/NetbiosNodeType", + "/properties/NtpServers", + "/properties/DomainName", + "/properties/DomainNameServers" + ], + "readOnlyProperties": [ + "/properties/DhcpOptionsId" + ], + "primaryIdentifier": [ + "/properties/DhcpOptionsId" + ], + "handlers": { + "create": { + "permissions": [ + "ec2:CreateDhcpOptions", + "ec2:DescribeDhcpOptions", + "ec2:CreateTags" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeDhcpOptions", + "ec2:DescribeTags" + ] + }, + "update": { + "permissions": [ + "ec2:CreateTags", + "ec2:DescribeDhcpOptions", + "ec2:DeleteTags" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteDhcpOptions", + "ec2:DeleteTags" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeDhcpOptions" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions_plugin.py new file mode 100644 index 0000000000000..c3ac8bb5a5827 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_dhcpoptions_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2DHCPOptionsProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::DHCPOptions" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_dhcpoptions import ( + EC2DHCPOptionsProvider, + ) + + self.factory = EC2DHCPOptionsProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance.py new file mode 100644 index 0000000000000..8c33cde7b2ab8 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance.py @@ -0,0 +1,342 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.strings import to_str + + +class EC2InstanceProperties(TypedDict): + AdditionalInfo: Optional[str] + Affinity: Optional[str] + AvailabilityZone: Optional[str] + BlockDeviceMappings: Optional[list[BlockDeviceMapping]] + CpuOptions: Optional[CpuOptions] + CreditSpecification: Optional[CreditSpecification] + DisableApiTermination: Optional[bool] + EbsOptimized: Optional[bool] + ElasticGpuSpecifications: Optional[list[ElasticGpuSpecification]] + ElasticInferenceAccelerators: Optional[list[ElasticInferenceAccelerator]] + EnclaveOptions: Optional[EnclaveOptions] + HibernationOptions: Optional[HibernationOptions] + HostId: Optional[str] + HostResourceGroupArn: Optional[str] + IamInstanceProfile: Optional[str] + Id: Optional[str] + ImageId: Optional[str] + InstanceInitiatedShutdownBehavior: Optional[str] + InstanceType: Optional[str] + Ipv6AddressCount: Optional[int] + Ipv6Addresses: Optional[list[InstanceIpv6Address]] + KernelId: Optional[str] + KeyName: Optional[str] + LaunchTemplate: Optional[LaunchTemplateSpecification] + LicenseSpecifications: Optional[list[LicenseSpecification]] + Monitoring: Optional[bool] + NetworkInterfaces: Optional[list[NetworkInterface]] + PlacementGroupName: Optional[str] + PrivateDnsName: Optional[str] + PrivateDnsNameOptions: Optional[PrivateDnsNameOptions] + PrivateIp: Optional[str] + PrivateIpAddress: Optional[str] + PropagateTagsToVolumeOnCreation: Optional[bool] + PublicDnsName: Optional[str] + PublicIp: Optional[str] + RamdiskId: Optional[str] + SecurityGroupIds: Optional[list[str]] + SecurityGroups: Optional[list[str]] + SourceDestCheck: Optional[bool] + SsmAssociations: Optional[list[SsmAssociation]] + SubnetId: Optional[str] + Tags: Optional[list[Tag]] + Tenancy: Optional[str] + UserData: Optional[str] + Volumes: Optional[list[Volume]] + + +class Ebs(TypedDict): + DeleteOnTermination: Optional[bool] + Encrypted: Optional[bool] + Iops: Optional[int] + KmsKeyId: Optional[str] + SnapshotId: Optional[str] + VolumeSize: Optional[int] + VolumeType: Optional[str] + + +class BlockDeviceMapping(TypedDict): + DeviceName: Optional[str] + Ebs: Optional[Ebs] + NoDevice: Optional[dict] + VirtualName: Optional[str] + + +class InstanceIpv6Address(TypedDict): + Ipv6Address: Optional[str] + + +class ElasticGpuSpecification(TypedDict): + Type: Optional[str] + + +class ElasticInferenceAccelerator(TypedDict): + Type: Optional[str] + Count: Optional[int] + + +class Volume(TypedDict): + Device: Optional[str] + VolumeId: Optional[str] + + +class LaunchTemplateSpecification(TypedDict): + Version: Optional[str] + LaunchTemplateId: Optional[str] + LaunchTemplateName: Optional[str] + + +class EnclaveOptions(TypedDict): + Enabled: Optional[bool] + + +class PrivateIpAddressSpecification(TypedDict): + Primary: Optional[bool] + PrivateIpAddress: Optional[str] + + +class NetworkInterface(TypedDict): + DeviceIndex: Optional[str] + AssociateCarrierIpAddress: Optional[bool] + AssociatePublicIpAddress: Optional[bool] + DeleteOnTermination: Optional[bool] + Description: Optional[str] + GroupSet: Optional[list[str]] + Ipv6AddressCount: Optional[int] + Ipv6Addresses: Optional[list[InstanceIpv6Address]] + NetworkInterfaceId: Optional[str] + PrivateIpAddress: Optional[str] + PrivateIpAddresses: Optional[list[PrivateIpAddressSpecification]] + SecondaryPrivateIpAddressCount: Optional[int] + SubnetId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class HibernationOptions(TypedDict): + Configured: Optional[bool] + + +class LicenseSpecification(TypedDict): + LicenseConfigurationArn: Optional[str] + + +class CpuOptions(TypedDict): + CoreCount: Optional[int] + ThreadsPerCore: Optional[int] + + +class PrivateDnsNameOptions(TypedDict): + EnableResourceNameDnsAAAARecord: Optional[bool] + EnableResourceNameDnsARecord: Optional[bool] + HostnameType: Optional[str] + + +class AssociationParameter(TypedDict): + Key: Optional[str] + Value: Optional[list[str]] + + +class SsmAssociation(TypedDict): + DocumentName: Optional[str] + AssociationParameters: Optional[list[AssociationParameter]] + + +class CreditSpecification(TypedDict): + CPUCredits: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2InstanceProvider(ResourceProvider[EC2InstanceProperties]): + TYPE = "AWS::EC2::Instance" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2InstanceProperties], + ) -> ProgressEvent[EC2InstanceProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + + + Create-only properties: + - /properties/ElasticGpuSpecifications + - /properties/Ipv6Addresses + - /properties/PlacementGroupName + - /properties/HostResourceGroupArn + - /properties/ImageId + - /properties/CpuOptions + - /properties/PrivateIpAddress + - /properties/ElasticInferenceAccelerators + - /properties/EnclaveOptions + - /properties/HibernationOptions + - /properties/KeyName + - /properties/LicenseSpecifications + - /properties/NetworkInterfaces + - /properties/AvailabilityZone + - /properties/SubnetId + - /properties/LaunchTemplate + - /properties/SecurityGroups + - /properties/Ipv6AddressCount + + Read-only properties: + - /properties/PublicIp + - /properties/Id + - /properties/PublicDnsName + - /properties/PrivateDnsName + - /properties/PrivateIp + + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + # TODO: validations + + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + # TODO: idempotency + params = util.select_attributes( + model, + ["InstanceType", "SecurityGroups", "KeyName", "ImageId", "MaxCount", "MinCount"], + ) + + # This Parameters are not defined in the schema but are required by the API + params["MaxCount"] = 1 + params["MinCount"] = 1 + + if model.get("UserData"): + params["UserData"] = to_str(base64.b64decode(model["UserData"])) + + response = ec2.run_instances(**params) + model["Id"] = response["Instances"][0]["InstanceId"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + response = ec2.describe_instances(InstanceIds=[model["Id"]]) + instance = response["Reservations"][0]["Instances"][0] + if instance["State"]["Name"] != "running": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + model["PrivateIp"] = instance["PrivateIpAddress"] + model["PrivateDnsName"] = instance["PrivateDnsName"] + model["AvailabilityZone"] = instance["Placement"]["AvailabilityZone"] + + # PublicIp is not guaranteed to be returned by the request: + # https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Instance.html#instancepublicip + # it says it is supposed to return an empty string, but trying to add an output with the value will result in + # an error: `Attribute 'PublicIp' does not exist` + if public_ip := instance.get("PublicIpAddress"): + model["PublicIp"] = public_ip + + if public_dns_name := instance.get("PublicDnsName"): + model["PublicDnsName"] = public_dns_name + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2InstanceProperties], + ) -> ProgressEvent[EC2InstanceProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2InstanceProperties], + ) -> ProgressEvent[EC2InstanceProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + ec2.terminate_instances(InstanceIds=[model["Id"]]) + # TODO add checking of ec2 instance state + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2InstanceProperties], + ) -> ProgressEvent[EC2InstanceProperties]: + """ + Update a resource + + + """ + desired_state = request.desired_state + ec2 = request.aws_client_factory.ec2 + + groups = desired_state.get("SecurityGroups", desired_state.get("SecurityGroupIds")) + + kwargs = {} + if groups: + kwargs["Groups"] = groups + ec2.modify_instance_attribute( + InstanceId=desired_state["Id"], + InstanceType={"Value": desired_state["InstanceType"]}, + **kwargs, + ) + + response = ec2.describe_instances(InstanceIds=[desired_state["Id"]]) + instance = response["Reservations"][0]["Instances"][0] + if instance["State"]["Name"] != "running": + return ProgressEvent( + status=OperationStatus.PENDING, + resource_model=desired_state, + custom_context=request.custom_context, + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=desired_state, + custom_context=request.custom_context, + ) diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance.schema.json new file mode 100644 index 0000000000000..85ff4e3fd9d10 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance.schema.json @@ -0,0 +1,540 @@ +{ + "typeName": "AWS::EC2::Instance", + "description": "Resource Type definition for AWS::EC2::Instance", + "additionalProperties": false, + "properties": { + "Tenancy": { + "type": "string" + }, + "SecurityGroups": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "PrivateDnsName": { + "type": "string" + }, + "PrivateIpAddress": { + "type": "string" + }, + "UserData": { + "type": "string" + }, + "BlockDeviceMappings": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/BlockDeviceMapping" + } + }, + "IamInstanceProfile": { + "type": "string" + }, + "Ipv6Addresses": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/InstanceIpv6Address" + } + }, + "KernelId": { + "type": "string" + }, + "SubnetId": { + "type": "string" + }, + "EbsOptimized": { + "type": "boolean" + }, + "PropagateTagsToVolumeOnCreation": { + "type": "boolean" + }, + "ElasticGpuSpecifications": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/ElasticGpuSpecification" + } + }, + "ElasticInferenceAccelerators": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/ElasticInferenceAccelerator" + } + }, + "Volumes": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Volume" + } + }, + "PrivateIp": { + "type": "string" + }, + "Ipv6AddressCount": { + "type": "integer" + }, + "LaunchTemplate": { + "$ref": "#/definitions/LaunchTemplateSpecification" + }, + "EnclaveOptions": { + "$ref": "#/definitions/EnclaveOptions" + }, + "NetworkInterfaces": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/NetworkInterface" + } + }, + "ImageId": { + "type": "string" + }, + "InstanceType": { + "type": "string" + }, + "Monitoring": { + "type": "boolean" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "AdditionalInfo": { + "type": "string" + }, + "HibernationOptions": { + "$ref": "#/definitions/HibernationOptions" + }, + "LicenseSpecifications": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/LicenseSpecification" + } + }, + "PublicIp": { + "type": "string" + }, + "InstanceInitiatedShutdownBehavior": { + "type": "string" + }, + "CpuOptions": { + "$ref": "#/definitions/CpuOptions" + }, + "AvailabilityZone": { + "type": "string" + }, + "PrivateDnsNameOptions": { + "$ref": "#/definitions/PrivateDnsNameOptions" + }, + "HostId": { + "type": "string" + }, + "HostResourceGroupArn": { + "type": "string" + }, + "PublicDnsName": { + "type": "string" + }, + "SecurityGroupIds": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "DisableApiTermination": { + "type": "boolean" + }, + "KeyName": { + "type": "string" + }, + "RamdiskId": { + "type": "string" + }, + "SourceDestCheck": { + "type": "boolean" + }, + "PlacementGroupName": { + "type": "string" + }, + "SsmAssociations": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/SsmAssociation" + } + }, + "Affinity": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "CreditSpecification": { + "$ref": "#/definitions/CreditSpecification" + } + }, + "definitions": { + "LaunchTemplateSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "LaunchTemplateName": { + "type": "string" + }, + "LaunchTemplateId": { + "type": "string" + }, + "Version": { + "type": "string" + } + }, + "required": [ + "Version" + ] + }, + "HibernationOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Configured": { + "type": "boolean" + } + } + }, + "LicenseSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "LicenseConfigurationArn": { + "type": "string" + } + }, + "required": [ + "LicenseConfigurationArn" + ] + }, + "CpuOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "ThreadsPerCore": { + "type": "integer" + }, + "CoreCount": { + "type": "integer" + } + } + }, + "NoDevice": { + "type": "object", + "additionalProperties": false + }, + "InstanceIpv6Address": { + "type": "object", + "additionalProperties": false, + "properties": { + "Ipv6Address": { + "type": "string" + } + }, + "required": [ + "Ipv6Address" + ] + }, + "NetworkInterface": { + "type": "object", + "additionalProperties": false, + "properties": { + "Description": { + "type": "string" + }, + "PrivateIpAddress": { + "type": "string" + }, + "PrivateIpAddresses": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/PrivateIpAddressSpecification" + } + }, + "SecondaryPrivateIpAddressCount": { + "type": "integer" + }, + "DeviceIndex": { + "type": "string" + }, + "GroupSet": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Ipv6Addresses": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/InstanceIpv6Address" + } + }, + "SubnetId": { + "type": "string" + }, + "AssociatePublicIpAddress": { + "type": "boolean" + }, + "NetworkInterfaceId": { + "type": "string" + }, + "AssociateCarrierIpAddress": { + "type": "boolean" + }, + "Ipv6AddressCount": { + "type": "integer" + }, + "DeleteOnTermination": { + "type": "boolean" + } + }, + "required": [ + "DeviceIndex" + ] + }, + "PrivateDnsNameOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "HostnameType": { + "type": "string" + }, + "EnableResourceNameDnsAAAARecord": { + "type": "boolean" + }, + "EnableResourceNameDnsARecord": { + "type": "boolean" + } + } + }, + "ElasticGpuSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "Type": { + "type": "string" + } + }, + "required": [ + "Type" + ] + }, + "ElasticInferenceAccelerator": { + "type": "object", + "additionalProperties": false, + "properties": { + "Type": { + "type": "string" + }, + "Count": { + "type": "integer" + } + }, + "required": [ + "Type" + ] + }, + "SsmAssociation": { + "type": "object", + "additionalProperties": false, + "properties": { + "AssociationParameters": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/AssociationParameter" + } + }, + "DocumentName": { + "type": "string" + } + }, + "required": [ + "DocumentName" + ] + }, + "AssociationParameter": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "PrivateIpAddressSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "PrivateIpAddress": { + "type": "string" + }, + "Primary": { + "type": "boolean" + } + }, + "required": [ + "PrivateIpAddress", + "Primary" + ] + }, + "Volume": { + "type": "object", + "additionalProperties": false, + "properties": { + "VolumeId": { + "type": "string" + }, + "Device": { + "type": "string" + } + }, + "required": [ + "VolumeId", + "Device" + ] + }, + "EnclaveOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + } + } + }, + "Ebs": { + "type": "object", + "additionalProperties": false, + "properties": { + "SnapshotId": { + "type": "string" + }, + "VolumeType": { + "type": "string" + }, + "KmsKeyId": { + "type": "string" + }, + "Encrypted": { + "type": "boolean" + }, + "Iops": { + "type": "integer" + }, + "VolumeSize": { + "type": "integer" + }, + "DeleteOnTermination": { + "type": "boolean" + } + } + }, + "BlockDeviceMapping": { + "type": "object", + "additionalProperties": false, + "properties": { + "NoDevice": { + "$ref": "#/definitions/NoDevice" + }, + "VirtualName": { + "type": "string" + }, + "Ebs": { + "$ref": "#/definitions/Ebs" + }, + "DeviceName": { + "type": "string" + } + }, + "required": [ + "DeviceName" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "CreditSpecification": { + "type": "object", + "additionalProperties": false, + "properties": { + "CPUCredits": { + "type": "string" + } + } + } + }, + "createOnlyProperties": [ + "/properties/ElasticGpuSpecifications", + "/properties/Ipv6Addresses", + "/properties/PlacementGroupName", + "/properties/HostResourceGroupArn", + "/properties/ImageId", + "/properties/CpuOptions", + "/properties/PrivateIpAddress", + "/properties/ElasticInferenceAccelerators", + "/properties/EnclaveOptions", + "/properties/HibernationOptions", + "/properties/KeyName", + "/properties/LicenseSpecifications", + "/properties/NetworkInterfaces", + "/properties/AvailabilityZone", + "/properties/SubnetId", + "/properties/LaunchTemplate", + "/properties/SecurityGroups", + "/properties/Ipv6AddressCount" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/PublicIp", + "/properties/Id", + "/properties/PublicDnsName", + "/properties/PrivateDnsName", + "/properties/PrivateIp" + ] +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance_plugin.py new file mode 100644 index 0000000000000..60f400297a47f --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_instance_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2InstanceProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::Instance" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_instance import EC2InstanceProvider + + self.factory = EC2InstanceProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway.py new file mode 100644 index 0000000000000..1ad0d6981b9c0 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway.py @@ -0,0 +1,116 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2InternetGatewayProperties(TypedDict): + InternetGatewayId: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2InternetGatewayProvider(ResourceProvider[EC2InternetGatewayProperties]): + TYPE = "AWS::EC2::InternetGateway" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2InternetGatewayProperties], + ) -> ProgressEvent[EC2InternetGatewayProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/InternetGatewayId + + Read-only properties: + - /properties/InternetGatewayId + + IAM permissions required: + - ec2:CreateInternetGateway + - ec2:CreateTags + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + tags = [{"ResourceType": "'internet-gateway'", "Tags": model.get("Tags", [])}] + + response = ec2.create_internet_gateway(TagSpecifications=tags) + model["InternetGatewayId"] = response["InternetGateway"]["InternetGatewayId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2InternetGatewayProperties], + ) -> ProgressEvent[EC2InternetGatewayProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeInternetGateways + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2InternetGatewayProperties], + ) -> ProgressEvent[EC2InternetGatewayProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteInternetGateway + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + # detach it first before deleting it + response = ec2.describe_internet_gateways(InternetGatewayIds=[model["InternetGatewayId"]]) + + for gateway in response.get("InternetGateways", []): + for attachment in gateway.get("Attachments", []): + ec2.detach_internet_gateway( + InternetGatewayId=model["InternetGatewayId"], VpcId=attachment["VpcId"] + ) + ec2.delete_internet_gateway(InternetGatewayId=model["InternetGatewayId"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2InternetGatewayProperties], + ) -> ProgressEvent[EC2InternetGatewayProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:DeleteTags + - ec2:CreateTags + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway.schema.json new file mode 100644 index 0000000000000..62fd843a46c3f --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway.schema.json @@ -0,0 +1,78 @@ +{ + "typeName": "AWS::EC2::InternetGateway", + "description": "Resource Type definition for AWS::EC2::InternetGateway", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "additionalProperties": false, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "properties": { + "InternetGatewayId": { + "description": "ID of internet gateway.", + "type": "string" + }, + "Tags": { + "description": "Any tags to assign to the internet gateway.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "taggable": true, + "readOnlyProperties": [ + "/properties/InternetGatewayId" + ], + "primaryIdentifier": [ + "/properties/InternetGatewayId" + ], + "handlers": { + "create": { + "permissions": [ + "ec2:CreateInternetGateway", + "ec2:CreateTags" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeInternetGateways" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteInternetGateway" + ] + }, + "update": { + "permissions": [ + "ec2:DeleteTags", + "ec2:CreateTags" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeInternetGateways" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway_plugin.py new file mode 100644 index 0000000000000..51c889fae01a0 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_internetgateway_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2InternetGatewayProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::InternetGateway" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_internetgateway import ( + EC2InternetGatewayProvider, + ) + + self.factory = EC2InternetGatewayProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair.py new file mode 100644 index 0000000000000..8c03d6bc738b5 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair.py @@ -0,0 +1,148 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2KeyPairProperties(TypedDict): + KeyName: Optional[str] + KeyFingerprint: Optional[str] + KeyFormat: Optional[str] + KeyPairId: Optional[str] + KeyType: Optional[str] + PublicKeyMaterial: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2KeyPairProvider(ResourceProvider[EC2KeyPairProperties]): + TYPE = "AWS::EC2::KeyPair" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2KeyPairProperties], + ) -> ProgressEvent[EC2KeyPairProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/KeyName + + Required properties: + - KeyName + + Create-only properties: + - /properties/KeyName + - /properties/KeyType + - /properties/KeyFormat + - /properties/PublicKeyMaterial + - /properties/Tags + + Read-only properties: + - /properties/KeyPairId + - /properties/KeyFingerprint + + IAM permissions required: + - ec2:CreateKeyPair + - ec2:ImportKeyPair + - ec2:CreateTags + - ssm:PutParameter + + """ + model = request.desired_state + + if "KeyName" not in model: + raise ValueError("Property 'KeyName' is required") + + if public_key_material := model.get("PublicKeyMaterial"): + response = request.aws_client_factory.ec2.import_key_pair( + KeyName=model["KeyName"], + PublicKeyMaterial=public_key_material, + ) + else: + create_params = util.select_attributes( + model, ["KeyName", "KeyType", "KeyFormat", "Tags"] + ) + response = request.aws_client_factory.ec2.create_key_pair(**create_params) + + model["KeyPairId"] = response["KeyPairId"] + model["KeyFingerprint"] = response["KeyFingerprint"] + + request.aws_client_factory.ssm.put_parameter( + Name=f"/ec2/keypair/{model['KeyPairId']}", + Value=model["KeyName"], + Type="String", + Overwrite=True, + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[EC2KeyPairProperties], + ) -> ProgressEvent[EC2KeyPairProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeKeyPairs + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2KeyPairProperties], + ) -> ProgressEvent[EC2KeyPairProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteKeyPair + - ssm:DeleteParameter + - ec2:DescribeKeyPairs + """ + + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + ec2.delete_key_pair(KeyName=model["KeyName"]) + + request.aws_client_factory.ssm.delete_parameter( + Name=f"/ec2/keypair/{model['KeyPairId']}", + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2KeyPairProperties], + ) -> ProgressEvent[EC2KeyPairProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair.schema.json new file mode 100644 index 0000000000000..d5b65ffc19a74 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair.schema.json @@ -0,0 +1,133 @@ +{ + "typeName": "AWS::EC2::KeyPair", + "description": "The AWS::EC2::KeyPair creates an SSH key pair", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false + } + }, + "properties": { + "KeyName": { + "description": "The name of the SSH key pair", + "type": "string" + }, + "KeyType": { + "description": "The crypto-system used to generate a key pair.", + "type": "string", + "default": "rsa", + "enum": [ + "rsa", + "ed25519" + ] + }, + "KeyFormat": { + "description": "The format of the private key", + "type": "string", + "default": "pem", + "enum": [ + "pem", + "ppk" + ] + }, + "PublicKeyMaterial": { + "description": "Plain text public key to import", + "type": "string" + }, + "KeyFingerprint": { + "description": "A short sequence of bytes used for public key verification", + "type": "string" + }, + "KeyPairId": { + "description": "An AWS generated ID for the key pair", + "type": "string" + }, + "Tags": { + "description": "An array of key-value pairs to apply to this resource.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "additionalProperties": false, + "required": [ + "KeyName" + ], + "primaryIdentifier": [ + "/properties/KeyName" + ], + "additionalIdentifiers": [ + [ + "/properties/KeyPairId" + ] + ], + "createOnlyProperties": [ + "/properties/KeyName", + "/properties/KeyType", + "/properties/KeyFormat", + "/properties/PublicKeyMaterial", + "/properties/Tags" + ], + "writeOnlyProperties": [ + "/properties/KeyFormat" + ], + "readOnlyProperties": [ + "/properties/KeyPairId", + "/properties/KeyFingerprint" + ], + "tagging": { + "taggable": true, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "handlers": { + "create": { + "permissions": [ + "ec2:CreateKeyPair", + "ec2:ImportKeyPair", + "ec2:CreateTags", + "ssm:PutParameter" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeKeyPairs" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeKeyPairs" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteKeyPair", + "ssm:DeleteParameter", + "ec2:DescribeKeyPairs" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair_plugin.py new file mode 100644 index 0000000000000..5bb9524b1f667 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_keypair_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2KeyPairProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::KeyPair" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_keypair import EC2KeyPairProvider + + self.factory = EC2KeyPairProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway.py new file mode 100644 index 0000000000000..de03079d89699 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway.py @@ -0,0 +1,183 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2NatGatewayProperties(TypedDict): + SubnetId: Optional[str] + AllocationId: Optional[str] + ConnectivityType: Optional[str] + MaxDrainDurationSeconds: Optional[int] + NatGatewayId: Optional[str] + PrivateIpAddress: Optional[str] + SecondaryAllocationIds: Optional[list[str]] + SecondaryPrivateIpAddressCount: Optional[int] + SecondaryPrivateIpAddresses: Optional[list[str]] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2NatGatewayProvider(ResourceProvider[EC2NatGatewayProperties]): + TYPE = "AWS::EC2::NatGateway" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2NatGatewayProperties], + ) -> ProgressEvent[EC2NatGatewayProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/NatGatewayId + + Required properties: + - SubnetId + + Create-only properties: + - /properties/SubnetId + - /properties/ConnectivityType + - /properties/AllocationId + - /properties/PrivateIpAddress + + Read-only properties: + - /properties/NatGatewayId + + IAM permissions required: + - ec2:CreateNatGateway + - ec2:DescribeNatGateways + - ec2:CreateTags + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + # TODO: validations + # TODO add tests for this resource at the moment, it's not covered + + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + # TODO: defaults + # TODO: idempotency + params = util.select_attributes( + model, + ["SubnetId", "AllocationId"], + ) + + if model.get("Tags"): + tags = [{"ResourceType": "natgateway", "Tags": model.get("Tags")}] + params["TagSpecifications"] = tags + + response = ec2.create_nat_gateway( + SubnetId=model["SubnetId"], AllocationId=model["AllocationId"] + ) + model["NatGatewayId"] = response["NatGateway"]["NatGatewayId"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + response = ec2.describe_nat_gateways(NatGatewayIds=[model["NatGatewayId"]]) + if response["NatGateways"][0]["State"] == "pending": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + # TODO add handling for failed events + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2NatGatewayProperties], + ) -> ProgressEvent[EC2NatGatewayProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeNatGateways + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2NatGatewayProperties], + ) -> ProgressEvent[EC2NatGatewayProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteNatGateway + - ec2:DescribeNatGateways + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + ec2.delete_nat_gateway(NatGatewayId=model["NatGatewayId"]) + + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + is_deleting = False + try: + response = ec2.describe_nat_gateways(NatGatewayIds=[model["NatGatewayId"]]) + is_deleting = response["NatGateways"][0]["State"] == "deleting" + except ec2.exceptions.ClientError: + pass + + if is_deleting: + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2NatGatewayProperties], + ) -> ProgressEvent[EC2NatGatewayProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:DescribeNatGateways + - ec2:CreateTags + - ec2:DeleteTags + - ec2:AssociateNatGatewayAddress + - ec2:DisassociateNatGatewayAddress + - ec2:AssignPrivateNatGatewayAddress + - ec2:UnassignPrivateNatGatewayAddress + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway.schema.json new file mode 100644 index 0000000000000..99f268a2dfc29 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway.schema.json @@ -0,0 +1,131 @@ +{ + "typeName": "AWS::EC2::NatGateway", + "description": "Resource Type definition for AWS::EC2::NatGateway", + "additionalProperties": false, + "properties": { + "SubnetId": { + "type": "string" + }, + "NatGatewayId": { + "type": "string" + }, + "ConnectivityType": { + "type": "string" + }, + "PrivateIpAddress": { + "type": "string" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "AllocationId": { + "type": "string" + }, + "SecondaryAllocationIds": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "type": "string" + } + }, + "SecondaryPrivateIpAddresses": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "type": "string" + } + }, + "SecondaryPrivateIpAddressCount": { + "type": "integer", + "minimum": 1 + }, + "MaxDrainDurationSeconds": { + "type": "integer" + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "SubnetId" + ], + "createOnlyProperties": [ + "/properties/SubnetId", + "/properties/ConnectivityType", + "/properties/AllocationId", + "/properties/PrivateIpAddress" + ], + "primaryIdentifier": [ + "/properties/NatGatewayId" + ], + "readOnlyProperties": [ + "/properties/NatGatewayId" + ], + "writeOnlyProperties": [ + "/properties/MaxDrainDurationSeconds" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true + }, + "handlers": { + "create": { + "permissions": [ + "ec2:CreateNatGateway", + "ec2:DescribeNatGateways", + "ec2:CreateTags" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteNatGateway", + "ec2:DescribeNatGateways" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeNatGateways" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeNatGateways" + ] + }, + "update": { + "permissions": [ + "ec2:DescribeNatGateways", + "ec2:CreateTags", + "ec2:DeleteTags", + "ec2:AssociateNatGatewayAddress", + "ec2:DisassociateNatGatewayAddress", + "ec2:AssignPrivateNatGatewayAddress", + "ec2:UnassignPrivateNatGatewayAddress" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway_plugin.py new file mode 100644 index 0000000000000..e8036702f5e79 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_natgateway_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2NatGatewayProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::NatGateway" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_natgateway import ( + EC2NatGatewayProvider, + ) + + self.factory = EC2NatGatewayProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl.py new file mode 100644 index 0000000000000..47d36951d0068 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl.py @@ -0,0 +1,116 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2NetworkAclProperties(TypedDict): + VpcId: Optional[str] + Id: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2NetworkAclProvider(ResourceProvider[EC2NetworkAclProperties]): + TYPE = "AWS::EC2::NetworkAcl" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2NetworkAclProperties], + ) -> ProgressEvent[EC2NetworkAclProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - VpcId + + Create-only properties: + - /properties/VpcId + + Read-only properties: + - /properties/Id + + IAM permissions required: + - ec2:CreateNetworkAcl + - ec2:DescribeNetworkAcls + + """ + model = request.desired_state + + create_params = { + "VpcId": model["VpcId"], + } + + if model.get("Tags"): + create_params["TagSpecifications"] = [ + { + "ResourceType": "network-acl", + "Tags": [{"Key": tag["Key"], "Value": tag["Value"]} for tag in model["Tags"]], + } + ] + + response = request.aws_client_factory.ec2.create_network_acl(**create_params) + model["Id"] = response["NetworkAcl"]["NetworkAclId"] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[EC2NetworkAclProperties], + ) -> ProgressEvent[EC2NetworkAclProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeNetworkAcls + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2NetworkAclProperties], + ) -> ProgressEvent[EC2NetworkAclProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteNetworkAcl + - ec2:DescribeNetworkAcls + """ + model = request.desired_state + request.aws_client_factory.ec2.delete_network_acl(NetworkAclId=model["Id"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[EC2NetworkAclProperties], + ) -> ProgressEvent[EC2NetworkAclProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:DescribeNetworkAcls + - ec2:DeleteTags + - ec2:CreateTags + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl.schema.json new file mode 100644 index 0000000000000..52bdc7cdca1ca --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl.schema.json @@ -0,0 +1,92 @@ +{ + "typeName": "AWS::EC2::NetworkAcl", + "description": "Resource Type definition for AWS::EC2::NetworkAcl", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", + "additionalProperties": false, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "properties": { + "Id": { + "type": "string" + }, + "Tags": { + "description": "The tags to assign to the network ACL.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "VpcId": { + "description": "The ID of the VPC.", + "type": "string" + } + }, + "required": [ + "VpcId" + ], + "createOnlyProperties": [ + "/properties/VpcId" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "ec2:CreateNetworkAcl", + "ec2:DescribeNetworkAcls" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeNetworkAcls" + ] + }, + "update": { + "permissions": [ + "ec2:DescribeNetworkAcls", + "ec2:DeleteTags", + "ec2:CreateTags" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteNetworkAcl", + "ec2:DescribeNetworkAcls" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeNetworkAcls" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl_plugin.py new file mode 100644 index 0000000000000..0f24a9cd40adc --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_networkacl_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2NetworkAclProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::NetworkAcl" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_networkacl import ( + EC2NetworkAclProvider, + ) + + self.factory = EC2NetworkAclProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist.py new file mode 100644 index 0000000000000..8308fb5bfa990 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist.py @@ -0,0 +1,167 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2PrefixListProperties(TypedDict): + AddressFamily: Optional[str] + MaxEntries: Optional[int] + PrefixListName: Optional[str] + Arn: Optional[str] + Entries: Optional[list[Entry]] + OwnerId: Optional[str] + PrefixListId: Optional[str] + Tags: Optional[list[Tag]] + Version: Optional[int] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class Entry(TypedDict): + Cidr: Optional[str] + Description: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2PrefixListProvider(ResourceProvider[EC2PrefixListProperties]): + TYPE = "AWS::EC2::PrefixList" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2PrefixListProperties], + ) -> ProgressEvent[EC2PrefixListProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/PrefixListId + + Required properties: + - PrefixListName + - MaxEntries + - AddressFamily + + + + Read-only properties: + - /properties/PrefixListId + - /properties/OwnerId + - /properties/Version + - /properties/Arn + + IAM permissions required: + - EC2:CreateManagedPrefixList + - EC2:DescribeManagedPrefixLists + - EC2:CreateTags + + """ + model = request.desired_state + + if not request.custom_context.get(REPEATED_INVOCATION): + create_params = util.select_attributes( + model, ["PrefixListName", "Entries", "MaxEntries", "AddressFamily", "Tags"] + ) + + if "Tags" in create_params: + create_params["TagSpecifications"] = [ + {"ResourceType": "prefix-list", "Tags": create_params.pop("Tags")} + ] + + response = request.aws_client_factory.ec2.create_managed_prefix_list(**create_params) + model["Arn"] = response["PrefixList"]["PrefixListId"] + model["OwnerId"] = response["PrefixList"]["OwnerId"] + model["PrefixListId"] = response["PrefixList"]["PrefixListId"] + model["Version"] = response["PrefixList"]["Version"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + response = request.aws_client_factory.ec2.describe_managed_prefix_lists( + PrefixListIds=[model["PrefixListId"]] + ) + if not response["PrefixLists"]: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + message="Resource not found after creation", + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2PrefixListProperties], + ) -> ProgressEvent[EC2PrefixListProperties]: + """ + Fetch resource information + + IAM permissions required: + - EC2:GetManagedPrefixListEntries + - EC2:DescribeManagedPrefixLists + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2PrefixListProperties], + ) -> ProgressEvent[EC2PrefixListProperties]: + """ + Delete a resource + + IAM permissions required: + - EC2:DeleteManagedPrefixList + - EC2:DescribeManagedPrefixLists + """ + + model = request.previous_state + response = request.aws_client_factory.ec2.describe_managed_prefix_lists( + PrefixListIds=[model["PrefixListId"]] + ) + + if not response["PrefixLists"]: + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + request.aws_client_factory.ec2.delete_managed_prefix_list( + PrefixListId=request.previous_state["PrefixListId"] + ) + return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + + def update( + self, + request: ResourceRequest[EC2PrefixListProperties], + ) -> ProgressEvent[EC2PrefixListProperties]: + """ + Update a resource + + IAM permissions required: + - EC2:DescribeManagedPrefixLists + - EC2:GetManagedPrefixListEntries + - EC2:ModifyManagedPrefixList + - EC2:CreateTags + - EC2:DeleteTags + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist.schema.json new file mode 100644 index 0000000000000..cb27aefee2bd3 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist.schema.json @@ -0,0 +1,152 @@ +{ + "typeName": "AWS::EC2::PrefixList", + "description": "Resource schema of AWS::EC2::PrefixList Type", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "Tag": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "maxLength": 256 + } + }, + "required": [ + "Key" + ], + "additionalProperties": false + }, + "Entry": { + "type": "object", + "properties": { + "Cidr": { + "type": "string", + "minLength": 1, + "maxLength": 46 + }, + "Description": { + "type": "string", + "minLength": 0, + "maxLength": 255 + } + }, + "required": [ + "Cidr" + ], + "additionalProperties": false + } + }, + "properties": { + "PrefixListName": { + "description": "Name of Prefix List.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "PrefixListId": { + "description": "Id of Prefix List.", + "type": "string" + }, + "OwnerId": { + "description": "Owner Id of Prefix List.", + "type": "string" + }, + "AddressFamily": { + "description": "Ip Version of Prefix List.", + "type": "string", + "enum": [ + "IPv4", + "IPv6" + ] + }, + "MaxEntries": { + "description": "Max Entries of Prefix List.", + "type": "integer", + "minimum": 1 + }, + "Version": { + "description": "Version of Prefix List.", + "type": "integer" + }, + "Tags": { + "description": "Tags for Prefix List", + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Entries": { + "description": "Entries of Prefix List.", + "type": "array", + "items": { + "$ref": "#/definitions/Entry" + } + }, + "Arn": { + "description": "The Amazon Resource Name (ARN) of the Prefix List.", + "type": "string" + } + }, + "required": [ + "PrefixListName", + "MaxEntries", + "AddressFamily" + ], + "readOnlyProperties": [ + "/properties/PrefixListId", + "/properties/OwnerId", + "/properties/Version", + "/properties/Arn" + ], + "primaryIdentifier": [ + "/properties/PrefixListId" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true + }, + "handlers": { + "create": { + "permissions": [ + "EC2:CreateManagedPrefixList", + "EC2:DescribeManagedPrefixLists", + "EC2:CreateTags" + ] + }, + "read": { + "permissions": [ + "EC2:GetManagedPrefixListEntries", + "EC2:DescribeManagedPrefixLists" + ] + }, + "update": { + "permissions": [ + "EC2:DescribeManagedPrefixLists", + "EC2:GetManagedPrefixListEntries", + "EC2:ModifyManagedPrefixList", + "EC2:CreateTags", + "EC2:DeleteTags" + ] + }, + "delete": { + "permissions": [ + "EC2:DeleteManagedPrefixList", + "EC2:DescribeManagedPrefixLists" + ] + }, + "list": { + "permissions": [ + "EC2:DescribeManagedPrefixLists", + "EC2:GetManagedPrefixListEntries" + ] + } + }, + "additionalProperties": false +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist_plugin.py new file mode 100644 index 0000000000000..5d8b993d28409 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_prefixlist_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2PrefixListProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::PrefixList" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_prefixlist import ( + EC2PrefixListProvider, + ) + + self.factory = EC2PrefixListProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route.py new file mode 100644 index 0000000000000..c779541d04229 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route.py @@ -0,0 +1,137 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +from moto.ec2.utils import generate_route_id + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2RouteProperties(TypedDict): + RouteTableId: Optional[str] + CarrierGatewayId: Optional[str] + DestinationCidrBlock: Optional[str] + DestinationIpv6CidrBlock: Optional[str] + EgressOnlyInternetGatewayId: Optional[str] + GatewayId: Optional[str] + Id: Optional[str] + InstanceId: Optional[str] + LocalGatewayId: Optional[str] + NatGatewayId: Optional[str] + NetworkInterfaceId: Optional[str] + TransitGatewayId: Optional[str] + VpcEndpointId: Optional[str] + VpcPeeringConnectionId: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2RouteProvider(ResourceProvider[EC2RouteProperties]): + TYPE = "AWS::EC2::Route" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2RouteProperties], + ) -> ProgressEvent[EC2RouteProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - RouteTableId + + Create-only properties: + - /properties/RouteTableId + - /properties/DestinationCidrBlock + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + cidr_block = model.get("DestinationCidrBlock") + ipv6_cidr_block = model.get("DestinationIpv6CidrBlock", "") + + ec2.create_route( + DestinationCidrBlock=cidr_block, + DestinationIpv6CidrBlock=ipv6_cidr_block, + RouteTableId=model["RouteTableId"], + ) + model["Id"] = generate_route_id( + model["RouteTableId"], + cidr_block, + ipv6_cidr_block, + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2RouteProperties], + ) -> ProgressEvent[EC2RouteProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2RouteProperties], + ) -> ProgressEvent[EC2RouteProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + cidr_block = model.get("DestinationCidrBlock") + ipv6_cidr_block = model.get("DestinationIpv6CidrBlock", "") + + try: + ec2.delete_route( + DestinationCidrBlock=cidr_block, + DestinationIpv6CidrBlock=ipv6_cidr_block, + RouteTableId=model["RouteTableId"], + ) + except ec2.exceptions.ClientError: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2RouteProperties], + ) -> ProgressEvent[EC2RouteProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route.schema.json new file mode 100644 index 0000000000000..151c2d115972e --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route.schema.json @@ -0,0 +1,62 @@ +{ + "typeName": "AWS::EC2::Route", + "description": "Resource Type definition for AWS::EC2::Route", + "additionalProperties": false, + "properties": { + "DestinationIpv6CidrBlock": { + "type": "string" + }, + "RouteTableId": { + "type": "string" + }, + "InstanceId": { + "type": "string" + }, + "LocalGatewayId": { + "type": "string" + }, + "CarrierGatewayId": { + "type": "string" + }, + "DestinationCidrBlock": { + "type": "string" + }, + "GatewayId": { + "type": "string" + }, + "NetworkInterfaceId": { + "type": "string" + }, + "VpcEndpointId": { + "type": "string" + }, + "TransitGatewayId": { + "type": "string" + }, + "VpcPeeringConnectionId": { + "type": "string" + }, + "EgressOnlyInternetGatewayId": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "NatGatewayId": { + "type": "string" + } + }, + "required": [ + "RouteTableId" + ], + "createOnlyProperties": [ + "/properties/RouteTableId", + "/properties/DestinationCidrBlock" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route_plugin.py new file mode 100644 index 0000000000000..abd759b08aaca --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_route_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2RouteProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::Route" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_route import EC2RouteProvider + + self.factory = EC2RouteProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable.py new file mode 100644 index 0000000000000..618c3fad99c08 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable.py @@ -0,0 +1,123 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2RouteTableProperties(TypedDict): + VpcId: Optional[str] + RouteTableId: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2RouteTableProvider(ResourceProvider[EC2RouteTableProperties]): + TYPE = "AWS::EC2::RouteTable" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2RouteTableProperties], + ) -> ProgressEvent[EC2RouteTableProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RouteTableId + + Required properties: + - VpcId + + Create-only properties: + - /properties/VpcId + + Read-only properties: + - /properties/RouteTableId + + IAM permissions required: + - ec2:CreateRouteTable + - ec2:CreateTags + - ec2:DescribeRouteTables + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + # TODO: validations + params = util.select_attributes(model, ["VpcId", "Tags"]) + + tags = [{"ResourceType": "route-table", "Tags": params.get("Tags", [])}] + + response = ec2.create_route_table(VpcId=params["VpcId"], TagSpecifications=tags) + model["RouteTableId"] = response["RouteTable"]["RouteTableId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2RouteTableProperties], + ) -> ProgressEvent[EC2RouteTableProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeRouteTables + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2RouteTableProperties], + ) -> ProgressEvent[EC2RouteTableProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DescribeRouteTables + - ec2:DeleteRouteTable + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + try: + ec2.delete_route_table(RouteTableId=model["RouteTableId"]) + except ec2.exceptions.ClientError: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2RouteTableProperties], + ) -> ProgressEvent[EC2RouteTableProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:CreateTags + - ec2:DeleteTags + - ec2:DescribeRouteTables + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable.schema.json new file mode 100644 index 0000000000000..491be25027a62 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable.schema.json @@ -0,0 +1,94 @@ +{ + "typeName": "AWS::EC2::RouteTable", + "description": "Resource Type definition for AWS::EC2::RouteTable", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2", + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "properties": { + "RouteTableId": { + "description": "The route table ID.", + "type": "string" + }, + "Tags": { + "description": "Any tags assigned to the route table.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "VpcId": { + "description": "The ID of the VPC.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "VpcId" + ], + "createOnlyProperties": [ + "/properties/VpcId" + ], + "readOnlyProperties": [ + "/properties/RouteTableId" + ], + "primaryIdentifier": [ + "/properties/RouteTableId" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "ec2:CreateRouteTable", + "ec2:CreateTags", + "ec2:DescribeRouteTables" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeRouteTables" + ] + }, + "update": { + "permissions": [ + "ec2:CreateTags", + "ec2:DeleteTags", + "ec2:DescribeRouteTables" + ] + }, + "delete": { + "permissions": [ + "ec2:DescribeRouteTables", + "ec2:DeleteRouteTable" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeRouteTables" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable_plugin.py new file mode 100644 index 0000000000000..07396c832bf66 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_routetable_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2RouteTableProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::RouteTable" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_routetable import ( + EC2RouteTableProvider, + ) + + self.factory = EC2RouteTableProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup.py new file mode 100644 index 0000000000000..39621b8e5178e --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup.py @@ -0,0 +1,230 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + Properties, + ResourceProvider, + ResourceRequest, +) + + +class EC2SecurityGroupProperties(TypedDict): + GroupDescription: Optional[str] + GroupId: Optional[str] + GroupName: Optional[str] + Id: Optional[str] + SecurityGroupEgress: Optional[list[Egress]] + SecurityGroupIngress: Optional[list[Ingress]] + Tags: Optional[list[Tag]] + VpcId: Optional[str] + + +class Ingress(TypedDict): + IpProtocol: Optional[str] + CidrIp: Optional[str] + CidrIpv6: Optional[str] + Description: Optional[str] + FromPort: Optional[int] + SourcePrefixListId: Optional[str] + SourceSecurityGroupId: Optional[str] + SourceSecurityGroupName: Optional[str] + SourceSecurityGroupOwnerId: Optional[str] + ToPort: Optional[int] + + +class Egress(TypedDict): + IpProtocol: Optional[str] + CidrIp: Optional[str] + CidrIpv6: Optional[str] + Description: Optional[str] + DestinationPrefixListId: Optional[str] + DestinationSecurityGroupId: Optional[str] + FromPort: Optional[int] + ToPort: Optional[int] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +def model_from_description(sg_description: dict) -> dict: + model = { + "Id": sg_description.get("GroupId"), + "GroupId": sg_description.get("GroupId"), + "GroupName": sg_description.get("GroupName"), + "GroupDescription": sg_description.get("Description"), + "SecurityGroupEgress": [], + "SecurityGroupIngress": [], + } + if tags := sg_description.get("Tags"): + model["Tags"] = tags + + for i, egress in enumerate(sg_description.get("IpPermissionsEgress", [])): + for ip_range in egress.get("IpRanges", []): + model["SecurityGroupEgress"].append( + { + "CidrIp": ip_range.get("CidrIp"), + "FromPort": egress.get("FromPort", -1), + "IpProtocol": egress.get("IpProtocol", "-1"), + "ToPort": egress.get("ToPort", -1), + } + ) + + for i, ingress in enumerate(sg_description.get("IpPermissions", [])): + for ip_range in ingress.get("IpRanges", []): + model["SecurityGroupIngress"].append( + { + "CidrIp": ip_range.get("CidrIp"), + "FromPort": ingress.get("FromPort", -1), + "IpProtocol": ingress.get("IpProtocol", "-1"), + "ToPort": ingress.get("ToPort", -1), + } + ) + + model["VpcId"] = sg_description.get("VpcId") + return model + + +class EC2SecurityGroupProvider(ResourceProvider[EC2SecurityGroupProperties]): + TYPE = "AWS::EC2::SecurityGroup" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2SecurityGroupProperties], + ) -> ProgressEvent[EC2SecurityGroupProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - GroupDescription + + Create-only properties: + - /properties/GroupDescription + - /properties/GroupName + - /properties/VpcId + + Read-only properties: + - /properties/Id + - /properties/GroupId + + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + params = {} + + if not model.get("GroupName"): + params["GroupName"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + else: + params["GroupName"] = model["GroupName"] + + if vpc_id := model.get("VpcId"): + params["VpcId"] = vpc_id + + params["Description"] = model.get("GroupDescription", "") + + tags = [ + {"Key": "aws:cloudformation:logical-id", "Value": request.logical_resource_id}, + {"Key": "aws:cloudformation:stack-id", "Value": request.stack_id}, + {"Key": "aws:cloudformation:stack-name", "Value": request.stack_name}, + ] + + if model_tags := model.get("Tags"): + tags += model_tags + + params["TagSpecifications"] = [{"ResourceType": "security-group", "Tags": tags}] + + response = ec2.create_security_group(**params) + model["GroupId"] = response["GroupId"] + + # When you pass the logical ID of this resource to the intrinsic Ref function, + # Ref returns the ID of the security group if you specified the VpcId property. + # Otherwise, it returns the name of the security group. + # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-securitygroup.html#aws-resource-ec2-securitygroup-return-values-ref + if "VpcId" in model: + model["Id"] = response["GroupId"] + else: + model["Id"] = params["GroupName"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2SecurityGroupProperties], + ) -> ProgressEvent[EC2SecurityGroupProperties]: + """ + Fetch resource information + """ + + model = request.desired_state + + security_group = request.aws_client_factory.ec2.describe_security_groups( + GroupIds=[model["Id"]] + )["SecurityGroups"][0] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model_from_description(security_group), + ) + + def list(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + security_groups = request.aws_client_factory.ec2.describe_security_groups()[ + "SecurityGroups" + ] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[{"Id": description["GroupId"]} for description in security_groups], + ) + + def delete( + self, + request: ResourceRequest[EC2SecurityGroupProperties], + ) -> ProgressEvent[EC2SecurityGroupProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + ec2.delete_security_group(GroupId=model["GroupId"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2SecurityGroupProperties], + ) -> ProgressEvent[EC2SecurityGroupProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup.schema.json new file mode 100644 index 0000000000000..5ccdf924ac598 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup.schema.json @@ -0,0 +1,148 @@ +{ + "typeName": "AWS::EC2::SecurityGroup", + "description": "Resource Type definition for AWS::EC2::SecurityGroup", + "additionalProperties": false, + "properties": { + "GroupDescription": { + "type": "string" + }, + "GroupName": { + "type": "string" + }, + "VpcId": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "SecurityGroupIngress": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Ingress" + } + }, + "SecurityGroupEgress": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Egress" + } + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "GroupId": { + "type": "string" + } + }, + "definitions": { + "Ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "CidrIp": { + "type": "string" + }, + "CidrIpv6": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "FromPort": { + "type": "integer" + }, + "SourceSecurityGroupName": { + "type": "string" + }, + "ToPort": { + "type": "integer" + }, + "SourceSecurityGroupOwnerId": { + "type": "string" + }, + "IpProtocol": { + "type": "string" + }, + "SourceSecurityGroupId": { + "type": "string" + }, + "SourcePrefixListId": { + "type": "string" + } + }, + "required": [ + "IpProtocol" + ] + }, + "Egress": { + "type": "object", + "additionalProperties": false, + "properties": { + "CidrIp": { + "type": "string" + }, + "CidrIpv6": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "FromPort": { + "type": "integer" + }, + "ToPort": { + "type": "integer" + }, + "IpProtocol": { + "type": "string" + }, + "DestinationSecurityGroupId": { + "type": "string" + }, + "DestinationPrefixListId": { + "type": "string" + } + }, + "required": [ + "IpProtocol" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "GroupDescription" + ], + "createOnlyProperties": [ + "/properties/GroupDescription", + "/properties/GroupName", + "/properties/VpcId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/GroupId" + ] +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup_plugin.py new file mode 100644 index 0000000000000..176bddb74e703 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_securitygroup_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2SecurityGroupProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::SecurityGroup" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_securitygroup import ( + EC2SecurityGroupProvider, + ) + + self.factory = EC2SecurityGroupProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet.py new file mode 100644 index 0000000000000..e7c82a0d3669c --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet.py @@ -0,0 +1,248 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.strings import str_to_bool + + +class EC2SubnetProperties(TypedDict): + VpcId: Optional[str] + AssignIpv6AddressOnCreation: Optional[bool] + AvailabilityZone: Optional[str] + AvailabilityZoneId: Optional[str] + CidrBlock: Optional[str] + EnableDns64: Optional[bool] + Ipv6CidrBlock: Optional[str] + Ipv6CidrBlocks: Optional[list[str]] + Ipv6Native: Optional[bool] + MapPublicIpOnLaunch: Optional[bool] + NetworkAclAssociationId: Optional[str] + OutpostArn: Optional[str] + PrivateDnsNameOptionsOnLaunch: Optional[dict] + SubnetId: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +def generate_subnet_read_payload( + ec2_client, schema, subnet_ids: Optional[list[str]] = None +) -> list[EC2SubnetProperties]: + kwargs = {} + if subnet_ids: + kwargs["SubnetIds"] = subnet_ids + subnets = ec2_client.describe_subnets(**kwargs)["Subnets"] + + models = [] + for subnet in subnets: + subnet_id = subnet["SubnetId"] + + model = EC2SubnetProperties(**util.select_attributes(subnet, schema)) + + if "Tags" not in model: + model["Tags"] = [] + + if "EnableDns64" not in model: + model["EnableDns64"] = False + + private_dns_name_options = model.setdefault("PrivateDnsNameOptionsOnLaunch", {}) + + if "HostnameType" not in private_dns_name_options: + private_dns_name_options["HostnameType"] = "ip-name" + + optional_bool_attrs = ["EnableResourceNameDnsAAAARecord", "EnableResourceNameDnsARecord"] + for attr in optional_bool_attrs: + if attr not in private_dns_name_options: + private_dns_name_options[attr] = False + + network_acl_associations = ec2_client.describe_network_acls( + Filters=[{"Name": "association.subnet-id", "Values": [subnet_id]}] + ) + model["NetworkAclAssociationId"] = network_acl_associations["NetworkAcls"][0][ + "NetworkAclId" + ] + models.append(model) + + return models + + +class EC2SubnetProvider(ResourceProvider[EC2SubnetProperties]): + TYPE = "AWS::EC2::Subnet" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2SubnetProperties], + ) -> ProgressEvent[EC2SubnetProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/SubnetId + + Required properties: + - VpcId + + Create-only properties: + - /properties/VpcId + - /properties/AvailabilityZone + - /properties/AvailabilityZoneId + - /properties/CidrBlock + - /properties/OutpostArn + - /properties/Ipv6Native + + Read-only properties: + - /properties/NetworkAclAssociationId + - /properties/SubnetId + - /properties/Ipv6CidrBlocks + + IAM permissions required: + - ec2:DescribeSubnets + - ec2:CreateSubnet + - ec2:CreateTags + - ec2:ModifySubnetAttribute + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + params = util.select_attributes( + model, + [ + "AvailabilityZone", + "AvailabilityZoneId", + "CidrBlock", + "Ipv6CidrBlock", + "Ipv6Native", + "OutpostArn", + "VpcId", + ], + ) + if model.get("Tags"): + tags = [{"ResourceType": "subnet", "Tags": model.get("Tags")}] + params["TagSpecifications"] = tags + + response = ec2.create_subnet(**params) + model["SubnetId"] = response["Subnet"]["SubnetId"] + bool_attrs = [ + "AssignIpv6AddressOnCreation", + "EnableDns64", + "MapPublicIpOnLaunch", + ] + custom_attrs = bool_attrs + ["PrivateDnsNameOptionsOnLaunch"] + if not any(attr in model for attr in custom_attrs): + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + # update boolean attributes + for attr in bool_attrs: + if attr in model: + kwargs = {attr: {"Value": str_to_bool(model[attr])}} + ec2.modify_subnet_attribute(SubnetId=model["SubnetId"], **kwargs) + + # determine DNS hostname type on launch + dns_options = model.get("PrivateDnsNameOptionsOnLaunch") + if dns_options: + if isinstance(dns_options, str): + dns_options = json.loads(dns_options) + if dns_options.get("HostnameType"): + ec2.modify_subnet_attribute( + SubnetId=model["SubnetId"], + PrivateDnsHostnameTypeOnLaunch=dns_options.get("HostnameType"), + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2SubnetProperties], + ) -> ProgressEvent[EC2SubnetProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeSubnets + - ec2:DescribeNetworkAcls + """ + models = generate_subnet_read_payload( + ec2_client=request.aws_client_factory.ec2, + schema=self.SCHEMA["properties"], + subnet_ids=[request.desired_state["SubnetId"]], + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=models[0], + custom_context=request.custom_context, + ) + + def delete( + self, + request: ResourceRequest[EC2SubnetProperties], + ) -> ProgressEvent[EC2SubnetProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DescribeSubnets + - ec2:DeleteSubnet + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + ec2.delete_subnet(SubnetId=model["SubnetId"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def update( + self, + request: ResourceRequest[EC2SubnetProperties], + ) -> ProgressEvent[EC2SubnetProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:DescribeSubnets + - ec2:ModifySubnetAttribute + - ec2:CreateTags + - ec2:DeleteTags + - ec2:AssociateSubnetCidrBlock + - ec2:DisassociateSubnetCidrBlock + """ + raise NotImplementedError + + def list( + self, request: ResourceRequest[EC2SubnetProperties] + ) -> ProgressEvent[EC2SubnetProperties]: + """ + List resources + + IAM permissions required: + - ec2:DescribeSubnets + - ec2:DescribeNetworkAcls + """ + models = generate_subnet_read_payload( + request.aws_client_factory.ec2, self.SCHEMA["properties"] + ) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_models=models) diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet.schema.json new file mode 100644 index 0000000000000..806f82f3ed8c7 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet.schema.json @@ -0,0 +1,157 @@ +{ + "typeName": "AWS::EC2::Subnet", + "description": "Resource Type definition for AWS::EC2::Subnet", + "additionalProperties": false, + "properties": { + "AssignIpv6AddressOnCreation": { + "type": "boolean" + }, + "VpcId": { + "type": "string" + }, + "MapPublicIpOnLaunch": { + "type": "boolean" + }, + "NetworkAclAssociationId": { + "type": "string" + }, + "AvailabilityZone": { + "type": "string" + }, + "AvailabilityZoneId": { + "type": "string" + }, + "CidrBlock": { + "type": "string" + }, + "SubnetId": { + "type": "string" + }, + "Ipv6CidrBlocks": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Ipv6CidrBlock": { + "type": "string" + }, + "OutpostArn": { + "type": "string" + }, + "Ipv6Native": { + "type": "boolean" + }, + "EnableDns64": { + "type": "boolean" + }, + "PrivateDnsNameOptionsOnLaunch": { + "type": "object", + "additionalProperties": false, + "properties": { + "HostnameType": { + "type": "string" + }, + "EnableResourceNameDnsARecord": { + "type": "boolean" + }, + "EnableResourceNameDnsAAAARecord": { + "type": "boolean" + } + } + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "VpcId" + ], + "createOnlyProperties": [ + "/properties/VpcId", + "/properties/AvailabilityZone", + "/properties/AvailabilityZoneId", + "/properties/CidrBlock", + "/properties/OutpostArn", + "/properties/Ipv6Native" + ], + "conditionalCreateOnlyProperties": [ + "/properties/Ipv6CidrBlock" + ], + "primaryIdentifier": [ + "/properties/SubnetId" + ], + "readOnlyProperties": [ + "/properties/NetworkAclAssociationId", + "/properties/SubnetId", + "/properties/Ipv6CidrBlocks" + ], + "handlers": { + "create": { + "permissions": [ + "ec2:DescribeSubnets", + "ec2:CreateSubnet", + "ec2:CreateTags", + "ec2:ModifySubnetAttribute" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeSubnets", + "ec2:DescribeNetworkAcls" + ] + }, + "update": { + "permissions": [ + "ec2:DescribeSubnets", + "ec2:ModifySubnetAttribute", + "ec2:CreateTags", + "ec2:DeleteTags", + "ec2:AssociateSubnetCidrBlock", + "ec2:DisassociateSubnetCidrBlock" + ] + }, + "delete": { + "permissions": [ + "ec2:DescribeSubnets", + "ec2:DeleteSubnet" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeSubnets", + "ec2:DescribeNetworkAcls" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet_plugin.py new file mode 100644 index 0000000000000..65349afd2f656 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnet_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2SubnetProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::Subnet" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_subnet import EC2SubnetProvider + + self.factory = EC2SubnetProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation.py new file mode 100644 index 0000000000000..d07bbdcb6665e --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation.py @@ -0,0 +1,142 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2SubnetRouteTableAssociationProperties(TypedDict): + RouteTableId: Optional[str] + SubnetId: Optional[str] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2SubnetRouteTableAssociationProvider( + ResourceProvider[EC2SubnetRouteTableAssociationProperties] +): + TYPE = "AWS::EC2::SubnetRouteTableAssociation" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2SubnetRouteTableAssociationProperties], + ) -> ProgressEvent[EC2SubnetRouteTableAssociationProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - RouteTableId + - SubnetId + + Create-only properties: + - /properties/SubnetId + - /properties/RouteTableId + + Read-only properties: + - /properties/Id + + IAM permissions required: + - ec2:AssociateRouteTable + - ec2:ReplaceRouteTableAssociation + - ec2:DescribeSubnets + - ec2:DescribeRouteTables + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + # TODO: validations + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + # TODO: defaults + # TODO: idempotency + model["Id"] = ec2.associate_route_table( + RouteTableId=model["RouteTableId"], SubnetId=model["SubnetId"] + )["AssociationId"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + # we need to check association status + route_table = ec2.describe_route_tables(RouteTableIds=[model["RouteTableId"]])[ + "RouteTables" + ][0] + for association in route_table["Associations"]: + if association["RouteTableAssociationId"] == model["Id"]: + # if it is showing up here, it's associated + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2SubnetRouteTableAssociationProperties], + ) -> ProgressEvent[EC2SubnetRouteTableAssociationProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeRouteTables + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2SubnetRouteTableAssociationProperties], + ) -> ProgressEvent[EC2SubnetRouteTableAssociationProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DisassociateRouteTable + - ec2:DescribeSubnets + - ec2:DescribeRouteTables + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + # TODO add async + try: + ec2.disassociate_route_table(AssociationId=model["Id"]) + except ec2.exceptions.ClientError: + pass + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2SubnetRouteTableAssociationProperties], + ) -> ProgressEvent[EC2SubnetRouteTableAssociationProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation.schema.json new file mode 100644 index 0000000000000..d0dab1cba2a02 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation.schema.json @@ -0,0 +1,64 @@ +{ + "typeName": "AWS::EC2::SubnetRouteTableAssociation", + "description": "Resource Type definition for AWS::EC2::SubnetRouteTableAssociation", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ec2.git", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "RouteTableId": { + "type": "string" + }, + "SubnetId": { + "type": "string" + } + }, + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "required": [ + "RouteTableId", + "SubnetId" + ], + "createOnlyProperties": [ + "/properties/SubnetId", + "/properties/RouteTableId" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "handlers": { + "create": { + "permissions": [ + "ec2:AssociateRouteTable", + "ec2:ReplaceRouteTableAssociation", + "ec2:DescribeSubnets", + "ec2:DescribeRouteTables" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeRouteTables" + ] + }, + "delete": { + "permissions": [ + "ec2:DisassociateRouteTable", + "ec2:DescribeSubnets", + "ec2:DescribeRouteTables" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeRouteTables" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation_plugin.py new file mode 100644 index 0000000000000..6841f27741847 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_subnetroutetableassociation_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2SubnetRouteTableAssociationProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::SubnetRouteTableAssociation" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_subnetroutetableassociation import ( + EC2SubnetRouteTableAssociationProvider, + ) + + self.factory = EC2SubnetRouteTableAssociationProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway.py new file mode 100644 index 0000000000000..4a4b5825966cc --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway.py @@ -0,0 +1,144 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2TransitGatewayProperties(TypedDict): + AmazonSideAsn: Optional[int] + AssociationDefaultRouteTableId: Optional[str] + AutoAcceptSharedAttachments: Optional[str] + DefaultRouteTableAssociation: Optional[str] + DefaultRouteTablePropagation: Optional[str] + Description: Optional[str] + DnsSupport: Optional[str] + Id: Optional[str] + MulticastSupport: Optional[str] + PropagationDefaultRouteTableId: Optional[str] + Tags: Optional[list[Tag]] + TransitGatewayCidrBlocks: Optional[list[str]] + VpnEcmpSupport: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2TransitGatewayProvider(ResourceProvider[EC2TransitGatewayProperties]): + TYPE = "AWS::EC2::TransitGateway" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2TransitGatewayProperties], + ) -> ProgressEvent[EC2TransitGatewayProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + + + Create-only properties: + - /properties/AmazonSideAsn + - /properties/MulticastSupport + + Read-only properties: + - /properties/Id + + IAM permissions required: + - ec2:CreateTransitGateway + - ec2:CreateTags + + """ + model = request.desired_state + create_params = { + "Options": util.select_attributes( + model, + [ + "AmazonSideAsn", + "AssociationDefaultRouteTableId", + "AutoAcceptSharedAttachments", + "DefaultRouteTableAssociation", + "DefaultRouteTablePropagation", + "DnsSupport", + "MulticastSupport", + "PropagationDefaultRouteTableId", + "TransitGatewayCidrBlocks", + "VpnEcmpSupport", + ], + ) + } + + if model.get("Description"): + create_params["Description"] = model["Description"] + + if model.get("Tags", []): + create_params["TagSpecifications"] = [ + {"ResourceType": "transit-gateway", "Tags": model["Tags"]} + ] + + response = request.aws_client_factory.ec2.create_transit_gateway(**create_params) + model["Id"] = response["TransitGateway"]["TransitGatewayId"] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[EC2TransitGatewayProperties], + ) -> ProgressEvent[EC2TransitGatewayProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeTransitGateways + - ec2:DescribeTags + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2TransitGatewayProperties], + ) -> ProgressEvent[EC2TransitGatewayProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteTransitGateway + - ec2:DeleteTags + """ + model = request.desired_state + request.aws_client_factory.ec2.delete_transit_gateway(TransitGatewayId=model["Id"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[EC2TransitGatewayProperties], + ) -> ProgressEvent[EC2TransitGatewayProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:ModifyTransitGateway + - ec2:DeleteTags + - ec2:CreateTags + - ec2:ModifyTransitGatewayOptions + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway.schema.json new file mode 100644 index 0000000000000..afa8ae6ecd09f --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway.schema.json @@ -0,0 +1,118 @@ +{ + "typeName": "AWS::EC2::TransitGateway", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-transitgateway", + "description": "Resource Type definition for AWS::EC2::TransitGateway", + "additionalProperties": false, + "properties": { + "DefaultRouteTablePropagation": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "AutoAcceptSharedAttachments": { + "type": "string" + }, + "DefaultRouteTableAssociation": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "VpnEcmpSupport": { + "type": "string" + }, + "DnsSupport": { + "type": "string" + }, + "MulticastSupport": { + "type": "string" + }, + "AmazonSideAsn": { + "type": "integer", + "format": "int64" + }, + "TransitGatewayCidrBlocks": { + "type": "array", + "items": { + "type": "string" + } + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "AssociationDefaultRouteTableId": { + "type": "string" + }, + "PropagationDefaultRouteTableId": { + "type": "string" + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "createOnlyProperties": [ + "/properties/AmazonSideAsn", + "/properties/MulticastSupport" + ], + "taggable": true, + "handlers": { + "create": { + "permissions": [ + "ec2:CreateTransitGateway", + "ec2:CreateTags" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeTransitGateways", + "ec2:DescribeTags" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteTransitGateway", + "ec2:DeleteTags" + ] + }, + "update": { + "permissions": [ + "ec2:ModifyTransitGateway", + "ec2:DeleteTags", + "ec2:CreateTags", + "ec2:ModifyTransitGatewayOptions" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeTransitGateways", + "ec2:DescribeTags" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway_plugin.py new file mode 100644 index 0000000000000..eac947d512bd5 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgateway_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2TransitGatewayProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::TransitGateway" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_transitgateway import ( + EC2TransitGatewayProvider, + ) + + self.factory = EC2TransitGatewayProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment.py new file mode 100644 index 0000000000000..59aac3a6a15d4 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment.py @@ -0,0 +1,131 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2TransitGatewayAttachmentProperties(TypedDict): + SubnetIds: Optional[list[str]] + TransitGatewayId: Optional[str] + VpcId: Optional[str] + Id: Optional[str] + Options: Optional[dict] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2TransitGatewayAttachmentProvider(ResourceProvider[EC2TransitGatewayAttachmentProperties]): + TYPE = "AWS::EC2::TransitGatewayAttachment" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2TransitGatewayAttachmentProperties], + ) -> ProgressEvent[EC2TransitGatewayAttachmentProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - VpcId + - SubnetIds + - TransitGatewayId + + Create-only properties: + - /properties/TransitGatewayId + - /properties/VpcId + + Read-only properties: + - /properties/Id + + IAM permissions required: + - ec2:CreateTransitGatewayVpcAttachment + - ec2:CreateTags + + """ + model = request.desired_state + create_params = util.select_attributes( + model, ["SubnetIds", "TransitGatewayId", "VpcId", "Options"] + ) + + if model.get("Tags", []): + create_params["TagSpecifications"] = [ + {"ResourceType": "transit-gateway-attachment", "Tags": model["Tags"]} + ] + + result = request.aws_client_factory.ec2.create_transit_gateway_vpc_attachment( + **create_params + ) + model["Id"] = result["TransitGatewayVpcAttachment"]["TransitGatewayAttachmentId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[EC2TransitGatewayAttachmentProperties], + ) -> ProgressEvent[EC2TransitGatewayAttachmentProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeTransitGatewayAttachments + - ec2:DescribeTransitGatewayVpcAttachments + - ec2:DescribeTags + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2TransitGatewayAttachmentProperties], + ) -> ProgressEvent[EC2TransitGatewayAttachmentProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteTransitGatewayVpcAttachment + - ec2:DeleteTags + """ + model = request.desired_state + request.aws_client_factory.ec2.delete_transit_gateway_vpc_attachment( + TransitGatewayAttachmentId=model["Id"] + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[EC2TransitGatewayAttachmentProperties], + ) -> ProgressEvent[EC2TransitGatewayAttachmentProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:ModifyTransitGatewayVpcAttachment + - ec2:DescribeTransitGatewayVpcAttachments + - ec2:DeleteTags + - ec2:CreateTags + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment.schema.json new file mode 100644 index 0000000000000..075af98c71c9a --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment.schema.json @@ -0,0 +1,128 @@ +{ + "typeName": "AWS::EC2::TransitGatewayAttachment", + "description": "Resource Type definition for AWS::EC2::TransitGatewayAttachment", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-transitgateway", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "TransitGatewayId": { + "type": "string" + }, + "VpcId": { + "type": "string" + }, + "SubnetIds": { + "type": "array", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Tags": { + "type": "array", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Options": { + "description": "The options for the transit gateway vpc attachment.", + "type": "object", + "properties": { + "DnsSupport": { + "description": "Indicates whether to enable DNS Support for Vpc Attachment. Valid Values: enable | disable", + "type": "string" + }, + "Ipv6Support": { + "description": "Indicates whether to enable Ipv6 Support for Vpc Attachment. Valid Values: enable | disable", + "type": "string" + }, + "ApplianceModeSupport": { + "description": "Indicates whether to enable Ipv6 Support for Vpc Attachment. Valid Values: enable | disable", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "VpcId", + "SubnetIds", + "TransitGatewayId" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": false, + "tagProperty": "/properties/Tags" + }, + "createOnlyProperties": [ + "/properties/TransitGatewayId", + "/properties/VpcId" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "handlers": { + "create": { + "permissions": [ + "ec2:CreateTransitGatewayVpcAttachment", + "ec2:CreateTags" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeTransitGatewayAttachments", + "ec2:DescribeTransitGatewayVpcAttachments", + "ec2:DescribeTags" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteTransitGatewayVpcAttachment", + "ec2:DeleteTags" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeTransitGatewayAttachments", + "ec2:DescribeTransitGatewayVpcAttachments", + "ec2:DescribeTags" + ] + }, + "update": { + "permissions": [ + "ec2:ModifyTransitGatewayVpcAttachment", + "ec2:DescribeTransitGatewayVpcAttachments", + "ec2:DeleteTags", + "ec2:CreateTags" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment_plugin.py new file mode 100644 index 0000000000000..7b34a535f56e6 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_transitgatewayattachment_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2TransitGatewayAttachmentProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::TransitGatewayAttachment" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_transitgatewayattachment import ( + EC2TransitGatewayAttachmentProvider, + ) + + self.factory = EC2TransitGatewayAttachmentProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc.py new file mode 100644 index 0000000000000..3244a72b8b863 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc.py @@ -0,0 +1,242 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + +LOG = logging.getLogger(__name__) + + +class EC2VPCProperties(TypedDict): + CidrBlock: Optional[str] + CidrBlockAssociations: Optional[list[str]] + DefaultNetworkAcl: Optional[str] + DefaultSecurityGroup: Optional[str] + EnableDnsHostnames: Optional[bool] + EnableDnsSupport: Optional[bool] + InstanceTenancy: Optional[str] + Ipv4IpamPoolId: Optional[str] + Ipv4NetmaskLength: Optional[int] + Ipv6CidrBlocks: Optional[list[str]] + Tags: Optional[list[Tag]] + VpcId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +def _get_default_security_group_for_vpc(ec2_client, vpc_id: str) -> str: + sgs = ec2_client.describe_security_groups( + Filters=[ + {"Name": "group-name", "Values": ["default"]}, + {"Name": "vpc-id", "Values": [vpc_id]}, + ] + )["SecurityGroups"] + if len(sgs) != 1: + raise Exception(f"There should only be one default group for this VPC ({vpc_id=})") + return sgs[0]["GroupId"] + + +def _get_default_acl_for_vpc(ec2_client, vpc_id: str) -> str: + acls = ec2_client.describe_network_acls( + Filters=[ + {"Name": "default", "Values": ["true"]}, + {"Name": "vpc-id", "Values": [vpc_id]}, + ] + )["NetworkAcls"] + if len(acls) != 1: + raise Exception(f"There should only be one default network ACL for this VPC ({vpc_id=})") + return acls[0]["NetworkAclId"] + + +def generate_vpc_read_payload(ec2_client, vpc_id: str) -> EC2VPCProperties: + vpc = ec2_client.describe_vpcs(VpcIds=[vpc_id])["Vpcs"][0] + + model = EC2VPCProperties( + **util.select_attributes(vpc, EC2VPCProvider.SCHEMA["properties"].keys()) + ) + model["CidrBlockAssociations"] = [ + cba["AssociationId"] for cba in vpc["CidrBlockAssociationSet"] + ] + model["Ipv6CidrBlocks"] = [ + ipv6_ass["Ipv6CidrBlock"] for ipv6_ass in vpc.get("Ipv6CidrBlockAssociationSet", []) + ] + model["DefaultNetworkAcl"] = _get_default_acl_for_vpc(ec2_client, model["VpcId"]) + model["DefaultSecurityGroup"] = _get_default_security_group_for_vpc(ec2_client, model["VpcId"]) + model["EnableDnsHostnames"] = ec2_client.describe_vpc_attribute( + Attribute="enableDnsHostnames", VpcId=vpc_id + )["EnableDnsHostnames"]["Value"] + model["EnableDnsSupport"] = ec2_client.describe_vpc_attribute( + Attribute="enableDnsSupport", VpcId=vpc_id + )["EnableDnsSupport"]["Value"] + + return model + + +class EC2VPCProvider(ResourceProvider[EC2VPCProperties]): + TYPE = "AWS::EC2::VPC" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2VPCProperties], + ) -> ProgressEvent[EC2VPCProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/VpcId + + Create-only properties: + - /properties/CidrBlock + - /properties/Ipv4IpamPoolId + - /properties/Ipv4NetmaskLength + + Read-only properties: + - /properties/CidrBlockAssociations + - /properties/DefaultNetworkAcl + - /properties/DefaultSecurityGroup + - /properties/Ipv6CidrBlocks + - /properties/VpcId + + IAM permissions required: + - ec2:CreateVpc + - ec2:DescribeVpcs + - ec2:ModifyVpcAttribute + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + # TODO: validations + + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + # TODO: defaults + # TODO: idempotency + params = util.select_attributes( + model, + ["CidrBlock", "InstanceTenancy"], + ) + if model.get("Tags"): + tags = [{"ResourceType": "vpc", "Tags": model.get("Tags")}] + params["TagSpecifications"] = tags + + response = ec2.create_vpc(**params) + + request.custom_context[REPEATED_INVOCATION] = True + model = generate_vpc_read_payload(ec2, response["Vpc"]["VpcId"]) + + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + response = ec2.describe_vpcs(VpcIds=[model["VpcId"]])["Vpcs"][0] + if response["State"] == "pending": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2VPCProperties], + ) -> ProgressEvent[EC2VPCProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeVpcs + - ec2:DescribeSecurityGroups + - ec2:DescribeNetworkAcls + - ec2:DescribeVpcAttribute + """ + ec2 = request.aws_client_factory.ec2 + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=generate_vpc_read_payload(ec2, request.desired_state["VpcId"]), + custom_context=request.custom_context, + ) + + def delete( + self, + request: ResourceRequest[EC2VPCProperties], + ) -> ProgressEvent[EC2VPCProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteVpc + - ec2:DescribeVpcs + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + + # remove routes and route tables first + resp = ec2.describe_route_tables( + Filters=[ + {"Name": "vpc-id", "Values": [model["VpcId"]]}, + {"Name": "association.main", "Values": ["false"]}, + ] + ) + for rt in resp["RouteTables"]: + for assoc in rt.get("Associations", []): + # skipping Main association (upstream moto includes default association that cannot be deleted) + if assoc.get("Main"): + continue + ec2.disassociate_route_table(AssociationId=assoc["RouteTableAssociationId"]) + ec2.delete_route_table(RouteTableId=rt["RouteTableId"]) + + # TODO security groups, gateways and other attached resources need to be deleted as well + ec2.delete_vpc(VpcId=model["VpcId"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def update( + self, + request: ResourceRequest[EC2VPCProperties], + ) -> ProgressEvent[EC2VPCProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:CreateTags + - ec2:ModifyVpcAttribute + - ec2:DeleteTags + - ec2:ModifyVpcTenancy + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[EC2VPCProperties], + ) -> ProgressEvent[EC2VPCProperties]: + resources = request.aws_client_factory.ec2.describe_vpcs() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + EC2VPCProperties(VpcId=resource["VpcId"]) for resource in resources["Vpcs"] + ], + ) diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc.schema.json new file mode 100644 index 0000000000000..0f8838c52d008 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc.schema.json @@ -0,0 +1,155 @@ +{ + "typeName": "AWS::EC2::VPC", + "description": "Resource Type definition for AWS::EC2::VPC", + "additionalProperties": false, + "properties": { + "VpcId": { + "type": "string", + "description": "The Id for the model." + }, + "CidrBlock": { + "type": "string", + "description": "The primary IPv4 CIDR block for the VPC." + }, + "CidrBlockAssociations": { + "type": "array", + "description": "A list of IPv4 CIDR block association IDs for the VPC.", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "DefaultNetworkAcl": { + "type": "string", + "insertionOrder": false, + "description": "The default network ACL ID that is associated with the VPC." + }, + "DefaultSecurityGroup": { + "type": "string", + "insertionOrder": false, + "description": "The default security group ID that is associated with the VPC." + }, + "Ipv6CidrBlocks": { + "type": "array", + "description": "A list of IPv6 CIDR blocks that are associated with the VPC.", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "EnableDnsHostnames": { + "type": "boolean", + "description": "Indicates whether the instances launched in the VPC get DNS hostnames. If enabled, instances in the VPC get DNS hostnames; otherwise, they do not. Disabled by default for nondefault VPCs." + }, + "EnableDnsSupport": { + "type": "boolean", + "description": "Indicates whether the DNS resolution is supported for the VPC. If enabled, queries to the Amazon provided DNS server at the 169.254.169.253 IP address, or the reserved IP address at the base of the VPC network range \"plus two\" succeed. If disabled, the Amazon provided DNS service in the VPC that resolves public DNS hostnames to IP addresses is not enabled. Enabled by default." + }, + "InstanceTenancy": { + "type": "string", + "description": "The allowed tenancy of instances launched into the VPC.\n\n\"default\": An instance launched into the VPC runs on shared hardware by default, unless you explicitly specify a different tenancy during instance launch.\n\n\"dedicated\": An instance launched into the VPC is a Dedicated Instance by default, unless you explicitly specify a tenancy of host during instance launch. You cannot specify a tenancy of default during instance launch.\n\nUpdating InstanceTenancy requires no replacement only if you are updating its value from \"dedicated\" to \"default\". Updating InstanceTenancy from \"default\" to \"dedicated\" requires replacement." + }, + "Ipv4IpamPoolId": { + "type": "string", + "description": "The ID of an IPv4 IPAM pool you want to use for allocating this VPC's CIDR" + }, + "Ipv4NetmaskLength": { + "type": "integer", + "description": "The netmask length of the IPv4 CIDR you want to allocate to this VPC from an Amazon VPC IP Address Manager (IPAM) pool" + }, + "Tags": { + "type": "array", + "description": "The tags for the VPC.", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "createOnlyProperties": [ + "/properties/CidrBlock", + "/properties/Ipv4IpamPoolId", + "/properties/Ipv4NetmaskLength" + ], + "conditionalCreateOnlyProperties": [ + "/properties/InstanceTenancy" + ], + "readOnlyProperties": [ + "/properties/CidrBlockAssociations", + "/properties/DefaultNetworkAcl", + "/properties/DefaultSecurityGroup", + "/properties/Ipv6CidrBlocks", + "/properties/VpcId" + ], + "primaryIdentifier": [ + "/properties/VpcId" + ], + "writeOnlyProperties": [ + "/properties/Ipv4IpamPoolId", + "/properties/Ipv4NetmaskLength" + ], + "handlers": { + "create": { + "permissions": [ + "ec2:CreateVpc", + "ec2:DescribeVpcs", + "ec2:ModifyVpcAttribute" + ] + }, + "read": { + "permissions": [ + "ec2:DescribeVpcs", + "ec2:DescribeSecurityGroups", + "ec2:DescribeNetworkAcls", + "ec2:DescribeVpcAttribute" + ] + }, + "update": { + "permissions": [ + "ec2:CreateTags", + "ec2:ModifyVpcAttribute", + "ec2:DeleteTags", + "ec2:ModifyVpcTenancy" + ] + }, + "delete": { + "permissions": [ + "ec2:DeleteVpc", + "ec2:DescribeVpcs" + ] + }, + "list": { + "permissions": [ + "ec2:DescribeVpcs" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc_plugin.py new file mode 100644 index 0000000000000..3f4aea38386f0 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpc_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2VPCProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::VPC" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_vpc import EC2VPCProvider + + self.factory = EC2VPCProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint.py new file mode 100644 index 0000000000000..420efcb8029ee --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint.py @@ -0,0 +1,180 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2VPCEndpointProperties(TypedDict): + ServiceName: Optional[str] + VpcId: Optional[str] + CreationTimestamp: Optional[str] + DnsEntries: Optional[list[str]] + Id: Optional[str] + NetworkInterfaceIds: Optional[list[str]] + PolicyDocument: Optional[str | dict] + PrivateDnsEnabled: Optional[bool] + RouteTableIds: Optional[list[str]] + SecurityGroupIds: Optional[list[str]] + SubnetIds: Optional[list[str]] + VpcEndpointType: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2VPCEndpointProvider(ResourceProvider[EC2VPCEndpointProperties]): + TYPE = "AWS::EC2::VPCEndpoint" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2VPCEndpointProperties], + ) -> ProgressEvent[EC2VPCEndpointProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - VpcId + - ServiceName + + Create-only properties: + - /properties/ServiceName + - /properties/VpcEndpointType + - /properties/VpcId + + Read-only properties: + - /properties/NetworkInterfaceIds + - /properties/CreationTimestamp + - /properties/DnsEntries + - /properties/Id + + IAM permissions required: + - ec2:CreateVpcEndpoint + - ec2:DescribeVpcEndpoints + + """ + model = request.desired_state + create_params = util.select_attributes( + model, + [ + "PolidyDocument", + "PrivateDnsEnabled", + "RouteTablesIds", + "SecurityGroupIds", + "ServiceName", + "SubnetIds", + "VpcEndpointType", + "VpcId", + ], + ) + + if not request.custom_context.get(REPEATED_INVOCATION): + response = request.aws_client_factory.ec2.create_vpc_endpoint(**create_params) + model["Id"] = response["VpcEndpoint"]["VpcEndpointId"] + model["DnsEntries"] = response["VpcEndpoint"]["DnsEntries"] + model["CreationTimestamp"] = response["VpcEndpoint"]["CreationTimestamp"] + model["NetworkInterfaceIds"] = response["VpcEndpoint"]["NetworkInterfaceIds"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + response = request.aws_client_factory.ec2.describe_vpc_endpoints( + VpcEndpointIds=[model["Id"]] + ) + if not response["VpcEndpoints"]: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + message="Resource not found after creation", + ) + + state = response["VpcEndpoints"][0][ + "State" + ].lower() # API specifies capital but lowercase is returned + match state: + case "available": + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + case "pending": + return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + case "pendingacceptance": + return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + case _: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message=f"Invalid state '{state}' for resource", + ) + + def read( + self, + request: ResourceRequest[EC2VPCEndpointProperties], + ) -> ProgressEvent[EC2VPCEndpointProperties]: + """ + Fetch resource information + + IAM permissions required: + - ec2:DescribeVpcEndpoints + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2VPCEndpointProperties], + ) -> ProgressEvent[EC2VPCEndpointProperties]: + """ + Delete a resource + + IAM permissions required: + - ec2:DeleteVpcEndpoints + - ec2:DescribeVpcEndpoints + """ + model = request.previous_state + response = request.aws_client_factory.ec2.describe_vpc_endpoints( + VpcEndpointIds=[model["Id"]] + ) + + if not response["VpcEndpoints"]: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="Resource not found for deletion", + ) + + state = response["VpcEndpoints"][0]["State"].lower() + match state: + case "deleted": + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + case "deleting": + return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + case _: + request.aws_client_factory.ec2.delete_vpc_endpoints(VpcEndpointIds=[model["Id"]]) + return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + + def update( + self, + request: ResourceRequest[EC2VPCEndpointProperties], + ) -> ProgressEvent[EC2VPCEndpointProperties]: + """ + Update a resource + + IAM permissions required: + - ec2:ModifyVpcEndpoint + - ec2:DescribeVpcEndpoints + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint.schema.json new file mode 100644 index 0000000000000..c8dcc84644d4c --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint.schema.json @@ -0,0 +1,140 @@ +{ + "typeName": "AWS::EC2::VPCEndpoint", + "description": "Resource Type definition for AWS::EC2::VPCEndpoint", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "CreationTimestamp": { + "type": "string" + }, + "DnsEntries": { + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "NetworkInterfaceIds": { + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "PolicyDocument": { + "type": [ + "string", + "object" + ], + "description": "A policy to attach to the endpoint that controls access to the service." + }, + "PrivateDnsEnabled": { + "type": "boolean", + "description": "Indicate whether to associate a private hosted zone with the specified VPC." + }, + "RouteTableIds": { + "type": "array", + "description": "One or more route table IDs.", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "SecurityGroupIds": { + "type": "array", + "description": "The ID of one or more security groups to associate with the endpoint network interface.", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "ServiceName": { + "type": "string", + "description": "The service name." + }, + "SubnetIds": { + "type": "array", + "description": "The ID of one or more subnets in which to create an endpoint network interface.", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "VpcEndpointType": { + "type": "string", + "enum": [ + "Interface", + "Gateway", + "GatewayLoadBalancer" + ] + }, + "VpcId": { + "type": "string", + "description": "The ID of the VPC in which the endpoint will be used." + } + }, + "required": [ + "VpcId", + "ServiceName" + ], + "readOnlyProperties": [ + "/properties/NetworkInterfaceIds", + "/properties/CreationTimestamp", + "/properties/DnsEntries", + "/properties/Id" + ], + "createOnlyProperties": [ + "/properties/ServiceName", + "/properties/VpcEndpointType", + "/properties/VpcId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "handlers": { + "create": { + "permissions": [ + "ec2:CreateVpcEndpoint", + "ec2:DescribeVpcEndpoints" + ], + "timeoutInMinutes": 210 + }, + "read": { + "permissions": [ + "ec2:DescribeVpcEndpoints" + ] + }, + "update": { + "permissions": [ + "ec2:ModifyVpcEndpoint", + "ec2:DescribeVpcEndpoints" + ], + "timeoutInMinutes": 210 + }, + "delete": { + "permissions": [ + "ec2:DeleteVpcEndpoints", + "ec2:DescribeVpcEndpoints" + ], + "timeoutInMinutes": 210 + }, + "list": { + "permissions": [ + "ec2:DescribeVpcEndpoints" + ] + } + } +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint_plugin.py new file mode 100644 index 0000000000000..e0e1d228a95de --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcendpoint_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2VPCEndpointProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::VPCEndpoint" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_vpcendpoint import ( + EC2VPCEndpointProvider, + ) + + self.factory = EC2VPCEndpointProvider diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment.py new file mode 100644 index 0000000000000..8f4656e317b7f --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment.py @@ -0,0 +1,116 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EC2VPCGatewayAttachmentProperties(TypedDict): + VpcId: Optional[str] + Id: Optional[str] + InternetGatewayId: Optional[str] + VpnGatewayId: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EC2VPCGatewayAttachmentProvider(ResourceProvider[EC2VPCGatewayAttachmentProperties]): + TYPE = "AWS::EC2::VPCGatewayAttachment" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EC2VPCGatewayAttachmentProperties], + ) -> ProgressEvent[EC2VPCGatewayAttachmentProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - VpcId + + + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + # TODO: validations + if model.get("InternetGatewayId"): + ec2.attach_internet_gateway( + InternetGatewayId=model["InternetGatewayId"], VpcId=model["VpcId"] + ) + else: + ec2.attach_vpn_gateway(VpnGatewayId=model["VpnGatewayId"], VpcId=model["VpcId"]) + + # TODO: idempotency + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EC2VPCGatewayAttachmentProperties], + ) -> ProgressEvent[EC2VPCGatewayAttachmentProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EC2VPCGatewayAttachmentProperties], + ) -> ProgressEvent[EC2VPCGatewayAttachmentProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ec2 = request.aws_client_factory.ec2 + # TODO: validations + try: + if model.get("InternetGatewayId"): + ec2.detach_internet_gateway( + InternetGatewayId=model["InternetGatewayId"], VpcId=model["VpcId"] + ) + else: + ec2.detach_vpn_gateway(VpnGatewayId=model["VpnGatewayId"], VpcId=model["VpcId"]) + except ec2.exceptions.ClientError: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EC2VPCGatewayAttachmentProperties], + ) -> ProgressEvent[EC2VPCGatewayAttachmentProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment.schema.json b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment.schema.json new file mode 100644 index 0000000000000..856548db1f173 --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment.schema.json @@ -0,0 +1,28 @@ +{ + "typeName": "AWS::EC2::VPCGatewayAttachment", + "description": "Resource Type definition for AWS::EC2::VPCGatewayAttachment", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "InternetGatewayId": { + "type": "string" + }, + "VpcId": { + "type": "string" + }, + "VpnGatewayId": { + "type": "string" + } + }, + "required": [ + "VpcId" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "primaryIdentifier": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment_plugin.py b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment_plugin.py new file mode 100644 index 0000000000000..f210fa0ff8c1d --- /dev/null +++ b/localstack-core/localstack/services/ec2/resource_providers/aws_ec2_vpcgatewayattachment_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EC2VPCGatewayAttachmentProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::EC2::VPCGatewayAttachment" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ec2.resource_providers.aws_ec2_vpcgatewayattachment import ( + EC2VPCGatewayAttachmentProvider, + ) + + self.factory = EC2VPCGatewayAttachmentProvider diff --git a/localstack-core/localstack/services/ecr/__init__.py b/localstack-core/localstack/services/ecr/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ecr/resource_providers/__init__.py b/localstack-core/localstack/services/ecr/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository.py b/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository.py new file mode 100644 index 0000000000000..a42735467d146 --- /dev/null +++ b/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository.py @@ -0,0 +1,169 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.constants import AWS_REGION_US_EAST_1, DEFAULT_AWS_ACCOUNT_ID +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.aws import arns + +LOG = logging.getLogger(__name__) + +# simple mock state +default_repos_per_stack = {} + + +class ECRRepositoryProperties(TypedDict): + Arn: Optional[str] + EncryptionConfiguration: Optional[EncryptionConfiguration] + ImageScanningConfiguration: Optional[ImageScanningConfiguration] + ImageTagMutability: Optional[str] + LifecyclePolicy: Optional[LifecyclePolicy] + RepositoryName: Optional[str] + RepositoryPolicyText: Optional[dict | str] + RepositoryUri: Optional[str] + Tags: Optional[list[Tag]] + + +class LifecyclePolicy(TypedDict): + LifecyclePolicyText: Optional[str] + RegistryId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class ImageScanningConfiguration(TypedDict): + ScanOnPush: Optional[bool] + + +class EncryptionConfiguration(TypedDict): + EncryptionType: Optional[str] + KmsKey: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ECRRepositoryProvider(ResourceProvider[ECRRepositoryProperties]): + TYPE = "AWS::ECR::Repository" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ECRRepositoryProperties], + ) -> ProgressEvent[ECRRepositoryProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RepositoryName + + Create-only properties: + - /properties/RepositoryName + - /properties/EncryptionConfiguration + - /properties/EncryptionConfiguration/EncryptionType + - /properties/EncryptionConfiguration/KmsKey + + Read-only properties: + - /properties/Arn + - /properties/RepositoryUri + + IAM permissions required: + - ecr:CreateRepository + - ecr:PutLifecyclePolicy + - ecr:SetRepositoryPolicy + - ecr:TagResource + - kms:DescribeKey + - kms:CreateGrant + - kms:RetireGrant + + """ + model = request.desired_state + + default_repos_per_stack[request.stack_name] = model["RepositoryName"] + LOG.warning( + "Creating a Mock ECR Repository for CloudFormation. This is only intended to be used for allowing a successful CDK bootstrap and does not provision any underlying ECR repository." + ) + model.update( + { + "Arn": arns.ecr_repository_arn( + model["RepositoryName"], DEFAULT_AWS_ACCOUNT_ID, AWS_REGION_US_EAST_1 + ), + "RepositoryUri": "http://localhost:4566", + "ImageTagMutability": "MUTABLE", + "ImageScanningConfiguration": {"scanOnPush": True}, + } + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[ECRRepositoryProperties], + ) -> ProgressEvent[ECRRepositoryProperties]: + """ + Fetch resource information + + IAM permissions required: + - ecr:DescribeRepositories + - ecr:GetLifecyclePolicy + - ecr:GetRepositoryPolicy + - ecr:ListTagsForResource + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ECRRepositoryProperties], + ) -> ProgressEvent[ECRRepositoryProperties]: + """ + Delete a resource + + IAM permissions required: + - ecr:DeleteRepository + - kms:RetireGrant + """ + if default_repos_per_stack.get(request.stack_name): + del default_repos_per_stack[request.stack_name] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=request.desired_state, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[ECRRepositoryProperties], + ) -> ProgressEvent[ECRRepositoryProperties]: + """ + Update a resource + + IAM permissions required: + - ecr:PutLifecyclePolicy + - ecr:SetRepositoryPolicy + - ecr:TagResource + - ecr:UntagResource + - ecr:DeleteLifecyclePolicy + - ecr:DeleteRepositoryPolicy + - ecr:PutImageScanningConfiguration + - ecr:PutImageTagMutability + - kms:DescribeKey + - kms:CreateGrant + - kms:RetireGrant + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository.schema.json b/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository.schema.json new file mode 100644 index 0000000000000..ef4f7c01e3a74 --- /dev/null +++ b/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository.schema.json @@ -0,0 +1,210 @@ +{ + "typeName": "AWS::ECR::Repository", + "description": "The AWS::ECR::Repository resource specifies an Amazon Elastic Container Registry (Amazon ECR) repository, where users can push and pull Docker images. For more information, see https://docs.aws.amazon.com/AmazonECR/latest/userguide/Repositories.html", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ecr.git", + "definitions": { + "LifecyclePolicy": { + "type": "object", + "description": "The LifecyclePolicy property type specifies a lifecycle policy. For information about lifecycle policy syntax, see https://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html", + "properties": { + "LifecyclePolicyText": { + "$ref": "#/definitions/LifecyclePolicyText" + }, + "RegistryId": { + "$ref": "#/definitions/RegistryId" + } + }, + "additionalProperties": false + }, + "LifecyclePolicyText": { + "type": "string", + "description": "The JSON repository policy text to apply to the repository.", + "minLength": 100, + "maxLength": 30720 + }, + "RegistryId": { + "type": "string", + "description": "The AWS account ID associated with the registry that contains the repository. If you do not specify a registry, the default registry is assumed. ", + "minLength": 12, + "maxLength": 12, + "pattern": "^[0-9]{12}$" + }, + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 127 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -. ", + "minLength": 1, + "maxLength": 127 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 1 to 255 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -. ", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "Value", + "Key" + ], + "additionalProperties": false + }, + "ImageScanningConfiguration": { + "type": "object", + "description": "The image scanning configuration for the repository. This setting determines whether images are scanned for known vulnerabilities after being pushed to the repository.", + "properties": { + "ScanOnPush": { + "$ref": "#/definitions/ScanOnPush" + } + }, + "additionalProperties": false + }, + "ScanOnPush": { + "type": "boolean", + "description": "The setting that determines whether images are scanned after being pushed to a repository." + }, + "EncryptionConfiguration": { + "type": "object", + "description": "The encryption configuration for the repository. This determines how the contents of your repository are encrypted at rest.\n\nBy default, when no encryption configuration is set or the AES256 encryption type is used, Amazon ECR uses server-side encryption with Amazon S3-managed encryption keys which encrypts your data at rest using an AES-256 encryption algorithm. This does not require any action on your part.\n\nFor more information, see https://docs.aws.amazon.com/AmazonECR/latest/userguide/encryption-at-rest.html", + "properties": { + "EncryptionType": { + "$ref": "#/definitions/EncryptionType" + }, + "KmsKey": { + "$ref": "#/definitions/KmsKey" + } + }, + "required": [ + "EncryptionType" + ], + "additionalProperties": false + }, + "EncryptionType": { + "type": "string", + "description": "The encryption type to use.", + "enum": [ + "AES256", + "KMS" + ] + }, + "KmsKey": { + "type": "string", + "description": "If you use the KMS encryption type, specify the CMK to use for encryption. The alias, key ID, or full ARN of the CMK can be specified. The key must exist in the same Region as the repository. If no key is specified, the default AWS managed CMK for Amazon ECR will be used.", + "minLength": 1, + "maxLength": 2048 + } + }, + "properties": { + "LifecyclePolicy": { + "$ref": "#/definitions/LifecyclePolicy" + }, + "RepositoryName": { + "type": "string", + "description": "The name to use for the repository. The repository name may be specified on its own (such as nginx-web-app) or it can be prepended with a namespace to group the repository into a category (such as project-a/nginx-web-app). If you don't specify a name, AWS CloudFormation generates a unique physical ID and uses that ID for the repository name. For more information, see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-name.html.", + "minLength": 2, + "maxLength": 256, + "pattern": "^(?=.{2,256}$)((?:[a-z0-9]+(?:[._-][a-z0-9]+)*/)*[a-z0-9]+(?:[._-][a-z0-9]+)*)$" + }, + "RepositoryPolicyText": { + "type": [ + "object", + "string" + ], + "description": "The JSON repository policy text to apply to the repository. For more information, see https://docs.aws.amazon.com/AmazonECR/latest/userguide/RepositoryPolicyExamples.html in the Amazon Elastic Container Registry User Guide. " + }, + "Tags": { + "type": "array", + "maxItems": 50, + "uniqueItems": true, + "insertionOrder": false, + "description": "An array of key-value pairs to apply to this resource.", + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Arn": { + "type": "string" + }, + "RepositoryUri": { + "type": "string" + }, + "ImageTagMutability": { + "type": "string", + "description": "The image tag mutability setting for the repository.", + "enum": [ + "MUTABLE", + "IMMUTABLE" + ] + }, + "ImageScanningConfiguration": { + "$ref": "#/definitions/ImageScanningConfiguration" + }, + "EncryptionConfiguration": { + "$ref": "#/definitions/EncryptionConfiguration" + } + }, + "createOnlyProperties": [ + "/properties/RepositoryName", + "/properties/EncryptionConfiguration", + "/properties/EncryptionConfiguration/EncryptionType", + "/properties/EncryptionConfiguration/KmsKey" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/RepositoryUri" + ], + "primaryIdentifier": [ + "/properties/RepositoryName" + ], + "handlers": { + "create": { + "permissions": [ + "ecr:CreateRepository", + "ecr:PutLifecyclePolicy", + "ecr:SetRepositoryPolicy", + "ecr:TagResource", + "kms:DescribeKey", + "kms:CreateGrant", + "kms:RetireGrant" + ] + }, + "read": { + "permissions": [ + "ecr:DescribeRepositories", + "ecr:GetLifecyclePolicy", + "ecr:GetRepositoryPolicy", + "ecr:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "ecr:PutLifecyclePolicy", + "ecr:SetRepositoryPolicy", + "ecr:TagResource", + "ecr:UntagResource", + "ecr:DeleteLifecyclePolicy", + "ecr:DeleteRepositoryPolicy", + "ecr:PutImageScanningConfiguration", + "ecr:PutImageTagMutability", + "kms:DescribeKey", + "kms:CreateGrant", + "kms:RetireGrant" + ] + }, + "delete": { + "permissions": [ + "ecr:DeleteRepository", + "kms:RetireGrant" + ] + }, + "list": { + "permissions": [ + "ecr:DescribeRepositories" + ] + } + }, + "additionalProperties": false +} diff --git a/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository_plugin.py b/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository_plugin.py new file mode 100644 index 0000000000000..7d7ba440a668d --- /dev/null +++ b/localstack-core/localstack/services/ecr/resource_providers/aws_ecr_repository_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ECRRepositoryProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ECR::Repository" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ecr.resource_providers.aws_ecr_repository import ( + ECRRepositoryProvider, + ) + + self.factory = ECRRepositoryProvider diff --git a/localstack-core/localstack/services/edge.py b/localstack-core/localstack/services/edge.py new file mode 100644 index 0000000000000..5c3ede66b65e5 --- /dev/null +++ b/localstack-core/localstack/services/edge.py @@ -0,0 +1,213 @@ +import argparse +import logging +import shlex +import subprocess +import sys +from typing import List, Optional, TypeVar + +from localstack import config, constants +from localstack.config import HostAndPort +from localstack.constants import ( + LOCALSTACK_ROOT_FOLDER, +) +from localstack.http import Router +from localstack.http.dispatcher import Handler, handler_dispatcher +from localstack.http.router import GreedyPathConverter +from localstack.utils.collections import split_list_by +from localstack.utils.net import get_free_tcp_port +from localstack.utils.run import is_root, run +from localstack.utils.server.tcp_proxy import TCPProxy +from localstack.utils.threads import start_thread + +T = TypeVar("T") + +LOG = logging.getLogger(__name__) + + +ROUTER: Router[Handler] = Router( + dispatcher=handler_dispatcher(), converters={"greedy_path": GreedyPathConverter} +) +"""This special Router is part of the edge proxy. Use the router to inject custom handlers that are handled before +the actual AWS service call is made.""" + + +def do_start_edge( + listen: HostAndPort | List[HostAndPort], use_ssl: bool, asynchronous: bool = False +): + from localstack.aws.serving.edge import serve_gateway + + return serve_gateway(listen, use_ssl, asynchronous) + + +def can_use_sudo(): + try: + run("sudo -n -v", print_error=False) + return True + except Exception: + return False + + +def ensure_can_use_sudo(): + if not is_root() and not can_use_sudo(): + if not sys.stdin.isatty(): + raise IOError("cannot get sudo password from non-tty input") + print("Please enter your sudo password (required to configure local network):") + run("sudo -v", stdin=True) + + +def start_component( + component: str, listen_str: str | None = None, target_address: str | None = None +): + if component == "edge": + return start_edge(listen_str=listen_str) + if component == "proxy": + if target_address is None: + raise ValueError("no target address specified") + + return start_proxy( + listen_str=listen_str, + target_address=HostAndPort.parse( + target_address, + default_host=config.default_ip, + default_port=constants.DEFAULT_PORT_EDGE, + ), + ) + raise Exception("Unexpected component name '%s' received during start up" % component) + + +def start_proxy( + listen_str: str, target_address: HostAndPort, asynchronous: bool = False +) -> TCPProxy: + """ + Starts a TCP proxy to perform a low-level forwarding of incoming requests. + + :param listen_str: address to listen on + :param target_address: target address to proxy requests to + :param asynchronous: False if the function should join the proxy thread and block until it terminates. + :return: created thread executing the proxy + """ + listen_hosts = parse_gateway_listen( + listen_str, + default_host=constants.LOCALHOST_IP, + default_port=constants.DEFAULT_PORT_EDGE, + ) + listen = listen_hosts[0] + return do_start_tcp_proxy(listen, target_address, asynchronous) + + +def do_start_tcp_proxy( + listen: HostAndPort, target_address: HostAndPort, asynchronous: bool = False +) -> TCPProxy: + src = str(listen) + dst = str(target_address) + + LOG.debug("Starting Local TCP Proxy: %s -> %s", src, dst) + proxy = TCPProxy( + target_address=target_address.host, + target_port=target_address.port, + host=listen.host, + port=listen.port, + ) + proxy.start() + if not asynchronous: + proxy.join() + return proxy + + +def start_edge(listen_str: str, use_ssl: bool = True, asynchronous: bool = False): + if listen_str: + listen = parse_gateway_listen( + listen_str, default_host=config.default_ip, default_port=constants.DEFAULT_PORT_EDGE + ) + else: + listen = config.GATEWAY_LISTEN + + if len(listen) == 0: + raise ValueError("no listen addresses provided") + + # separate privileged and unprivileged addresses + unprivileged, privileged = split_list_by(listen, lambda addr: addr.is_unprivileged() or False) + + # if we are root, we can directly bind to privileged ports as well + if is_root(): + unprivileged = unprivileged + privileged + privileged = [] + + # check that we are actually started the gateway server + if not unprivileged: + unprivileged = parse_gateway_listen( + f":{get_free_tcp_port()}", + default_host=config.default_ip, + default_port=constants.DEFAULT_PORT_EDGE, + ) + + # bind the gateway server to unprivileged addresses + edge_thread = do_start_edge(unprivileged, use_ssl=use_ssl, asynchronous=True) + + # start TCP proxies for the remaining addresses + proxy_destination = unprivileged[0] + for address in privileged: + # escalate to root + args = [ + "proxy", + "--gateway-listen", + str(address), + "--target-address", + str(proxy_destination), + ] + run_module_as_sudo( + module="localstack.services.edge", + arguments=args, + asynchronous=True, + ) + + if edge_thread is not None: + edge_thread.join() + + +def run_module_as_sudo( + module: str, arguments: Optional[List[str]] = None, asynchronous=False, env_vars=None +): + # prepare environment + env_vars = env_vars or {} + env_vars["PYTHONPATH"] = f".:{LOCALSTACK_ROOT_FOLDER}" + + # start the process as sudo + python_cmd = sys.executable + cmd = ["sudo", "-n", "--preserve-env", python_cmd, "-m", module] + arguments = arguments or [] + shell_cmd = shlex.join(cmd + arguments) + + # make sure we can run sudo commands + try: + ensure_can_use_sudo() + except Exception as e: + LOG.error("cannot run command as root (%s): %s ", str(e), shell_cmd) + return + + def run_command(*_): + run(shell_cmd, outfile=subprocess.PIPE, print_error=False, env_vars=env_vars) + + LOG.debug("Running command as sudo: %s", shell_cmd) + result = ( + start_thread(run_command, quiet=True, name="sudo-edge") if asynchronous else run_command() + ) + return result + + +def parse_gateway_listen(listen: str, default_host: str, default_port: int) -> List[HostAndPort]: + addresses = [] + for address in listen.split(","): + addresses.append(HostAndPort.parse(address, default_host, default_port)) + return addresses + + +if __name__ == "__main__": + logging.basicConfig() + parser = argparse.ArgumentParser() + parser.add_argument("component") + parser.add_argument("-l", "--gateway-listen", required=False, type=str) + parser.add_argument("-t", "--target-address", required=False, type=str) + args = parser.parse_args() + + start_component(args.component, args.gateway_listen, args.target_address) diff --git a/localstack-core/localstack/services/es/__init__.py b/localstack-core/localstack/services/es/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/es/plugins.py b/localstack-core/localstack/services/es/plugins.py new file mode 100644 index 0000000000000..d14c5f15bd72f --- /dev/null +++ b/localstack-core/localstack/services/es/plugins.py @@ -0,0 +1,8 @@ +from localstack.packages import Package, package + + +@package(name="elasticsearch") +def elasticsearch_package() -> Package: + from localstack.services.opensearch.packages import elasticsearch_package + + return elasticsearch_package diff --git a/localstack-core/localstack/services/es/provider.py b/localstack-core/localstack/services/es/provider.py new file mode 100644 index 0000000000000..4519e417bceaa --- /dev/null +++ b/localstack-core/localstack/services/es/provider.py @@ -0,0 +1,441 @@ +from contextlib import contextmanager +from typing import Dict, Optional, cast + +from botocore.exceptions import ClientError + +from localstack import constants +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.es import ( + ARN, + AccessDeniedException, + AdvancedOptions, + AdvancedSecurityOptionsInput, + AutoTuneOptionsInput, + CognitoOptions, + CompatibleElasticsearchVersionsList, + CompatibleVersionsMap, + ConflictException, + CreateElasticsearchDomainResponse, + DeleteElasticsearchDomainResponse, + DescribeElasticsearchDomainConfigResponse, + DescribeElasticsearchDomainResponse, + DescribeElasticsearchDomainsResponse, + DisabledOperationException, + DomainEndpointOptions, + DomainInfoList, + DomainName, + DomainNameList, + EBSOptions, + ElasticsearchClusterConfig, + ElasticsearchClusterConfigStatus, + ElasticsearchDomainConfig, + ElasticsearchDomainStatus, + ElasticsearchVersionStatus, + ElasticsearchVersionString, + EncryptionAtRestOptions, + EngineType, + EsApi, + GetCompatibleElasticsearchVersionsResponse, + InternalException, + InvalidPaginationTokenException, + InvalidTypeException, + LimitExceededException, + ListDomainNamesResponse, + ListElasticsearchVersionsResponse, + ListTagsResponse, + LogPublishingOptions, + MaxResults, + NextToken, + NodeToNodeEncryptionOptions, + OptionStatus, + PolicyDocument, + ResourceAlreadyExistsException, + ResourceNotFoundException, + SnapshotOptions, + StringList, + TagList, + UpdateElasticsearchDomainConfigRequest, + UpdateElasticsearchDomainConfigResponse, + ValidationException, + VPCOptions, +) +from localstack.aws.api.es import BaseException as EsBaseException +from localstack.aws.api.opensearch import ( + ClusterConfig, + CompatibleVersionsList, + DomainConfig, + DomainStatus, + VersionString, +) +from localstack.aws.connect import connect_to + + +def _version_to_opensearch( + version: Optional[ElasticsearchVersionString], +) -> Optional[VersionString]: + if version is not None: + if version.startswith("OpenSearch_"): + return version + else: + return f"Elasticsearch_{version}" + + +def _version_from_opensearch( + version: Optional[VersionString], +) -> Optional[ElasticsearchVersionString]: + if version is not None: + if version.startswith("Elasticsearch_"): + return version.split("_")[1] + else: + return version + + +def _instancetype_to_opensearch(instance_type: Optional[str]) -> Optional[str]: + if instance_type is not None: + return instance_type.replace("elasticsearch", "search") + + +def _instancetype_from_opensearch(instance_type: Optional[str]) -> Optional[str]: + if instance_type is not None: + return instance_type.replace("search", "elasticsearch") + + +def _clusterconfig_from_opensearch( + cluster_config: Optional[ClusterConfig], +) -> Optional[ElasticsearchClusterConfig]: + if cluster_config is not None: + # Just take the whole typed dict and typecast it to our target type + result = cast(ElasticsearchClusterConfig, cluster_config) + + # Adjust the instance type names + result["InstanceType"] = _instancetype_from_opensearch(cluster_config.get("InstanceType")) + result["DedicatedMasterType"] = _instancetype_from_opensearch( + cluster_config.get("DedicatedMasterType") + ) + result["WarmType"] = _instancetype_from_opensearch(cluster_config.get("WarmType")) + return result + + +def _domainstatus_from_opensearch( + domain_status: Optional[DomainStatus], +) -> Optional[ElasticsearchDomainStatus]: + if domain_status is not None: + # Just take the whole typed dict and typecast it to our target type + result = cast(ElasticsearchDomainStatus, domain_status) + # Only specifically handle keys which are named differently or their values differ (version and clusterconfig) + result["ElasticsearchVersion"] = _version_from_opensearch( + domain_status.get("EngineVersion") + ) + result["ElasticsearchClusterConfig"] = _clusterconfig_from_opensearch( + domain_status.get("ClusterConfig") + ) + result.pop("EngineVersion", None) + result.pop("ClusterConfig", None) + return result + + +def _clusterconfig_to_opensearch( + elasticsearch_cluster_config: Optional[ElasticsearchClusterConfig], +) -> Optional[ClusterConfig]: + if elasticsearch_cluster_config is not None: + result = cast(ClusterConfig, elasticsearch_cluster_config) + if instance_type := result.get("InstanceType"): + result["InstanceType"] = _instancetype_to_opensearch(instance_type) + if dedicated_master_type := result.get("DedicatedMasterType"): + result["DedicatedMasterType"] = _instancetype_to_opensearch(dedicated_master_type) + if warm_type := result.get("WarmType"): + result["WarmType"] = _instancetype_to_opensearch(warm_type) + return result + + +def _domainconfig_from_opensearch( + domain_config: Optional[DomainConfig], +) -> Optional[ElasticsearchDomainConfig]: + if domain_config is not None: + result = cast(ElasticsearchDomainConfig, domain_config) + engine_version = domain_config.get("EngineVersion", {}) + result["ElasticsearchVersion"] = ElasticsearchVersionStatus( + Options=_version_from_opensearch(engine_version.get("Options")), + Status=cast(OptionStatus, engine_version.get("Status")), + ) + cluster_config = domain_config.get("ClusterConfig", {}) + result["ElasticsearchClusterConfig"] = ElasticsearchClusterConfigStatus( + Options=_clusterconfig_from_opensearch(cluster_config.get("Options")), + Status=cluster_config.get("Status"), + ) + result.pop("EngineVersion", None) + result.pop("ClusterConfig", None) + return result + + +def _compatible_version_list_from_opensearch( + compatible_version_list: Optional[CompatibleVersionsList], +) -> Optional[CompatibleElasticsearchVersionsList]: + if compatible_version_list is not None: + return [ + CompatibleVersionsMap( + SourceVersion=_version_from_opensearch(version_map["SourceVersion"]), + TargetVersions=[ + _version_from_opensearch(target_version) + for target_version in version_map["TargetVersions"] + ], + ) + for version_map in compatible_version_list + ] + + +@contextmanager +def exception_mapper(): + """Maps an exception thrown by the OpenSearch client to an exception thrown by the ElasticSearch API.""" + try: + yield + except ClientError as err: + exception_types = { + "AccessDeniedException": AccessDeniedException, + "BaseException": EsBaseException, + "ConflictException": ConflictException, + "DisabledOperationException": DisabledOperationException, + "InternalException": InternalException, + "InvalidPaginationTokenException": InvalidPaginationTokenException, + "InvalidTypeException": InvalidTypeException, + "LimitExceededException": LimitExceededException, + "ResourceAlreadyExistsException": ResourceAlreadyExistsException, + "ResourceNotFoundException": ResourceNotFoundException, + "ValidationException": ValidationException, + } + mapped_exception_type = exception_types.get(err.response["Error"]["Code"], EsBaseException) + raise mapped_exception_type(err.response["Error"]["Message"]) + + +class EsProvider(EsApi): + def create_elasticsearch_domain( + self, + context: RequestContext, + domain_name: DomainName, + elasticsearch_version: ElasticsearchVersionString = None, + elasticsearch_cluster_config: ElasticsearchClusterConfig = None, + ebs_options: EBSOptions = None, + access_policies: PolicyDocument = None, + snapshot_options: SnapshotOptions = None, + vpc_options: VPCOptions = None, + cognito_options: CognitoOptions = None, + encryption_at_rest_options: EncryptionAtRestOptions = None, + node_to_node_encryption_options: NodeToNodeEncryptionOptions = None, + advanced_options: AdvancedOptions = None, + log_publishing_options: LogPublishingOptions = None, + domain_endpoint_options: DomainEndpointOptions = None, + advanced_security_options: AdvancedSecurityOptionsInput = None, + auto_tune_options: AutoTuneOptionsInput = None, + tag_list: TagList = None, + **kwargs, + ) -> CreateElasticsearchDomainResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + # If no version is given, we set our default elasticsearch version + engine_version = ( + _version_to_opensearch(elasticsearch_version) + if elasticsearch_version + else constants.ELASTICSEARCH_DEFAULT_VERSION + ) + kwargs = { + "DomainName": domain_name, + "EngineVersion": engine_version, + "ClusterConfig": _clusterconfig_to_opensearch(elasticsearch_cluster_config), + "EBSOptions": ebs_options, + "AccessPolicies": access_policies, + "SnapshotOptions": snapshot_options, + "VPCOptions": vpc_options, + "CognitoOptions": cognito_options, + "EncryptionAtRestOptions": encryption_at_rest_options, + "NodeToNodeEncryptionOptions": node_to_node_encryption_options, + "AdvancedOptions": advanced_options, + "LogPublishingOptions": log_publishing_options, + "DomainEndpointOptions": domain_endpoint_options, + "AdvancedSecurityOptions": advanced_security_options, + "AutoTuneOptions": auto_tune_options, + "TagList": tag_list, + } + + # Filter the kwargs to not set None values at all (boto doesn't like that) + kwargs = {key: value for key, value in kwargs.items() if value is not None} + + with exception_mapper(): + domain_status = opensearch_client.create_domain(**kwargs)["DomainStatus"] + + status = _domainstatus_from_opensearch(domain_status) + return CreateElasticsearchDomainResponse(DomainStatus=status) + + def delete_elasticsearch_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DeleteElasticsearchDomainResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + with exception_mapper(): + domain_status = opensearch_client.delete_domain( + DomainName=domain_name, + )["DomainStatus"] + + status = _domainstatus_from_opensearch(domain_status) + return DeleteElasticsearchDomainResponse(DomainStatus=status) + + def describe_elasticsearch_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeElasticsearchDomainResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + with exception_mapper(): + opensearch_status = opensearch_client.describe_domain( + DomainName=domain_name, + )["DomainStatus"] + + status = _domainstatus_from_opensearch(opensearch_status) + return DescribeElasticsearchDomainResponse(DomainStatus=status) + + @handler("UpdateElasticsearchDomainConfig", expand=False) + def update_elasticsearch_domain_config( + self, context: RequestContext, payload: UpdateElasticsearchDomainConfigRequest + ) -> UpdateElasticsearchDomainConfigResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + payload: Dict + if "ElasticsearchClusterConfig" in payload: + payload["ClusterConfig"] = payload["ElasticsearchClusterConfig"] + payload["ClusterConfig"]["InstanceType"] = _instancetype_to_opensearch( + payload["ClusterConfig"]["InstanceType"] + ) + payload.pop("ElasticsearchClusterConfig") + + with exception_mapper(): + opensearch_config = opensearch_client.update_domain_config(**payload)["DomainConfig"] + + config = _domainconfig_from_opensearch(opensearch_config) + return UpdateElasticsearchDomainConfigResponse(DomainConfig=config) + + def describe_elasticsearch_domains( + self, context: RequestContext, domain_names: DomainNameList, **kwargs + ) -> DescribeElasticsearchDomainsResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + with exception_mapper(): + opensearch_status_list = opensearch_client.describe_domains( + DomainNames=domain_names, + )["DomainStatusList"] + + status_list = [_domainstatus_from_opensearch(s) for s in opensearch_status_list] + return DescribeElasticsearchDomainsResponse(DomainStatusList=status_list) + + def list_domain_names( + self, context: RequestContext, engine_type: EngineType = None, **kwargs + ) -> ListDomainNamesResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + # Only hand the EngineType param to boto if it's set + kwargs = {} + if engine_type: + kwargs["EngineType"] = engine_type + + with exception_mapper(): + domain_names = opensearch_client.list_domain_names(**kwargs)["DomainNames"] + + return ListDomainNamesResponse(DomainNames=cast(Optional[DomainInfoList], domain_names)) + + def list_elasticsearch_versions( + self, + context: RequestContext, + max_results: MaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> ListElasticsearchVersionsResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + # Construct the arguments as kwargs to not set None values at all (boto doesn't like that) + kwargs = { + key: value + for key, value in {"MaxResults": max_results, "NextToken": next_token}.items() + if value is not None + } + with exception_mapper(): + versions = opensearch_client.list_versions(**kwargs) + + return ListElasticsearchVersionsResponse( + ElasticsearchVersions=[ + _version_from_opensearch(version) for version in versions["Versions"] + ], + NextToken=versions.get(next_token), + ) + + def get_compatible_elasticsearch_versions( + self, context: RequestContext, domain_name: DomainName = None, **kwargs + ) -> GetCompatibleElasticsearchVersionsResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + # Only hand the DomainName param to boto if it's set + kwargs = {} + if domain_name: + kwargs["DomainName"] = domain_name + + with exception_mapper(): + compatible_versions_response = opensearch_client.get_compatible_versions(**kwargs) + + compatible_versions = compatible_versions_response.get("CompatibleVersions") + return GetCompatibleElasticsearchVersionsResponse( + CompatibleElasticsearchVersions=_compatible_version_list_from_opensearch( + compatible_versions + ) + ) + + def describe_elasticsearch_domain_config( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeElasticsearchDomainConfigResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + with exception_mapper(): + domain_config = opensearch_client.describe_domain_config(DomainName=domain_name).get( + "DomainConfig" + ) + + return DescribeElasticsearchDomainConfigResponse( + DomainConfig=_domainconfig_from_opensearch(domain_config) + ) + + def add_tags(self, context: RequestContext, arn: ARN, tag_list: TagList, **kwargs) -> None: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + with exception_mapper(): + opensearch_client.add_tags(ARN=arn, TagList=tag_list) + + def list_tags(self, context: RequestContext, arn: ARN, **kwargs) -> ListTagsResponse: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + with exception_mapper(): + response = opensearch_client.list_tags(ARN=arn) + + return ListTagsResponse(TagList=response.get("TagList")) + + def remove_tags( + self, context: RequestContext, arn: ARN, tag_keys: StringList, **kwargs + ) -> None: + opensearch_client = connect_to( + region_name=context.region, aws_access_key_id=context.account_id + ).opensearch + + with exception_mapper(): + opensearch_client.remove_tags(ARN=arn, TagKeys=tag_keys) diff --git a/localstack-core/localstack/services/events/__init__.py b/localstack-core/localstack/services/events/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/events/analytics.py b/localstack-core/localstack/services/events/analytics.py new file mode 100644 index 0000000000000..8ebe75d8dd5fd --- /dev/null +++ b/localstack-core/localstack/services/events/analytics.py @@ -0,0 +1,16 @@ +from enum import StrEnum + +from localstack.utils.analytics.metrics import LabeledCounter + + +class InvocationStatus(StrEnum): + success = "success" + error = "error" + + +# number of EventBridge rule invocations per target (e.g., aws:lambda) +# - status label can be `success` or `error`, see InvocationStatus +# - service label is the target service name +rule_invocation = LabeledCounter( + namespace="events", name="rule_invocations", labels=["status", "service"] +) diff --git a/localstack-core/localstack/services/events/api_destination.py b/localstack-core/localstack/services/events/api_destination.py new file mode 100644 index 0000000000000..0bb9f097ffb4b --- /dev/null +++ b/localstack-core/localstack/services/events/api_destination.py @@ -0,0 +1,308 @@ +import base64 +import json +import logging +import re + +import requests + +from localstack.aws.api.events import ( + ApiDestinationDescription, + ApiDestinationHttpMethod, + ApiDestinationInvocationRateLimitPerSecond, + ApiDestinationName, + ApiDestinationState, + Arn, + ConnectionArn, + ConnectionAuthorizationType, + ConnectionState, + HttpsEndpoint, + Timestamp, +) +from localstack.aws.connect import connect_to +from localstack.services.events.models import ApiDestination, Connection, ValidationException +from localstack.utils.aws.arns import ( + extract_account_id_from_arn, + extract_region_from_arn, + parse_arn, +) +from localstack.utils.aws.message_forwarding import ( + list_of_parameters_to_object, +) +from localstack.utils.http import add_query_params_to_url +from localstack.utils.strings import to_str + +VALID_AUTH_TYPES = [t.value for t in ConnectionAuthorizationType] +LOG = logging.getLogger(__name__) + + +class APIDestinationService: + def __init__( + self, + name: ApiDestinationName, + region: str, + account_id: str, + connection_arn: ConnectionArn, + connection: Connection, + invocation_endpoint: HttpsEndpoint, + http_method: ApiDestinationHttpMethod, + invocation_rate_limit_per_second: ApiDestinationInvocationRateLimitPerSecond | None, + description: ApiDestinationDescription | None = None, + ): + self.validate_input(name, connection_arn, http_method, invocation_endpoint) + self.connection = connection + state = self._get_state() + + self.api_destination = ApiDestination( + name, + region, + account_id, + connection_arn, + invocation_endpoint, + http_method, + state, + invocation_rate_limit_per_second, + description, + ) + + @classmethod + def restore_from_api_destination_and_connection( + cls, api_destination: ApiDestination, connection: Connection + ): + api_destination_service = cls( + name=api_destination.name, + region=api_destination.region, + account_id=api_destination.account_id, + connection_arn=api_destination.connection_arn, + connection=connection, + invocation_endpoint=api_destination.invocation_endpoint, + http_method=api_destination.http_method, + invocation_rate_limit_per_second=api_destination.invocation_rate_limit_per_second, + ) + api_destination_service.api_destination = api_destination + return api_destination_service + + @property + def arn(self) -> Arn: + return self.api_destination.arn + + @property + def state(self) -> ApiDestinationState: + return self.api_destination.state + + @property + def creation_time(self) -> Timestamp: + return self.api_destination.creation_time + + @property + def last_modified_time(self) -> Timestamp: + return self.api_destination.last_modified_time + + def set_state(self, state: ApiDestinationState) -> None: + if hasattr(self, "api_destination"): + if state == ApiDestinationState.ACTIVE: + state = self._get_state() + self.api_destination.state = state + + def update( + self, + connection, + invocation_endpoint, + http_method, + invocation_rate_limit_per_second, + description, + ): + self.set_state(ApiDestinationState.INACTIVE) + self.connection = connection + self.api_destination.connection_arn = connection.arn + if invocation_endpoint: + self.api_destination.invocation_endpoint = invocation_endpoint + if http_method: + self.api_destination.http_method = http_method + if invocation_rate_limit_per_second: + self.api_destination.invocation_rate_limit_per_second = invocation_rate_limit_per_second + if description: + self.api_destination.description = description + self.api_destination.last_modified_time = Timestamp.now() + self.set_state(ApiDestinationState.ACTIVE) + + def _get_state(self) -> ApiDestinationState: + """Determine ApiDestinationState based on ConnectionState.""" + return ( + ApiDestinationState.ACTIVE + if self.connection.state == ConnectionState.AUTHORIZED + else ApiDestinationState.INACTIVE + ) + + @classmethod + def validate_input( + cls, + name: ApiDestinationName, + connection_arn: ConnectionArn, + http_method: ApiDestinationHttpMethod, + invocation_endpoint: HttpsEndpoint, + ) -> None: + errors = [] + errors.extend(cls._validate_api_destination_name(name)) + errors.extend(cls._validate_connection_arn(connection_arn)) + errors.extend(cls._validate_http_method(http_method)) + errors.extend(cls._validate_invocation_endpoint(invocation_endpoint)) + + if errors: + error_message = ( + f"{len(errors)} validation error{'s' if len(errors) > 1 else ''} detected: " + ) + error_message += "; ".join(errors) + raise ValidationException(error_message) + + @staticmethod + def _validate_api_destination_name(name: str) -> list[str]: + """Validate the API destination name according to AWS rules. Returns a list of validation errors.""" + errors = [] + if not re.match(r"^[\.\-_A-Za-z0-9]+$", name): + errors.append( + f"Value '{name}' at 'name' failed to satisfy constraint: " + "Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+" + ) + if not (1 <= len(name) <= 64): + errors.append( + f"Value '{name}' at 'name' failed to satisfy constraint: " + "Member must have length less than or equal to 64" + ) + return errors + + @staticmethod + def _validate_connection_arn(connection_arn: ConnectionArn) -> list[str]: + errors = [] + if not re.match( + r"^arn:aws([a-z]|\-)*:events:[a-z0-9\-]+:\d{12}:connection/[\.\-_A-Za-z0-9]+/[\-A-Za-z0-9]+$", + connection_arn, + ): + errors.append( + f"Value '{connection_arn}' at 'connectionArn' failed to satisfy constraint: " + "Member must satisfy regular expression pattern: " + "^arn:aws([a-z]|\\-)*:events:([a-z]|\\d|\\-)*:([0-9]{12})?:connection\\/[\\.\\-_A-Za-z0-9]+\\/[\\-A-Za-z0-9]+$" + ) + return errors + + @staticmethod + def _validate_http_method(http_method: ApiDestinationHttpMethod) -> list[str]: + errors = [] + allowed_methods = ["HEAD", "POST", "PATCH", "DELETE", "PUT", "GET", "OPTIONS"] + if http_method not in allowed_methods: + errors.append( + f"Value '{http_method}' at 'httpMethod' failed to satisfy constraint: " + f"Member must satisfy enum value set: [{', '.join(allowed_methods)}]" + ) + return errors + + @staticmethod + def _validate_invocation_endpoint(invocation_endpoint: HttpsEndpoint) -> list[str]: + errors = [] + endpoint_pattern = r"^((%[0-9A-Fa-f]{2}|[-()_.!~*';/?:@&=+$,A-Za-z0-9])+)([).!';/?:,])?$" + if not re.match(endpoint_pattern, invocation_endpoint): + errors.append( + f"Value '{invocation_endpoint}' at 'invocationEndpoint' failed to satisfy constraint: " + "Member must satisfy regular expression pattern: " + "^((%[0-9A-Fa-f]{2}|[-()_.!~*';/?:@&=+$,A-Za-z0-9])+)([).!';/?:,])?$" + ) + return errors + + +ApiDestinationServiceDict = dict[Arn, APIDestinationService] + + +def add_api_destination_authorization(destination, headers, event): + connection_arn = destination.get("ConnectionArn", "") + connection_name = re.search(r"connection\/([a-zA-Z0-9-_]+)\/", connection_arn).group(1) + + account_id = extract_account_id_from_arn(connection_arn) + region = extract_region_from_arn(connection_arn) + + events_client = connect_to(aws_access_key_id=account_id, region_name=region).events + connection_details = events_client.describe_connection(Name=connection_name) + secret_arn = connection_details["SecretArn"] + parsed_arn = parse_arn(secret_arn) + secretsmanager_client = connect_to( + aws_access_key_id=parsed_arn["account"], region_name=parsed_arn["region"] + ).secretsmanager + auth_secret = json.loads( + secretsmanager_client.get_secret_value(SecretId=secret_arn)["SecretString"] + ) + + headers.update(_auth_keys_from_connection(connection_details, auth_secret)) + + auth_parameters = connection_details.get("AuthParameters", {}) + invocation_parameters = auth_parameters.get("InvocationHttpParameters") + + endpoint = destination.get("InvocationEndpoint") + if invocation_parameters: + header_parameters = list_of_parameters_to_object( + invocation_parameters.get("HeaderParameters", []) + ) + headers.update(header_parameters) + + body_parameters = list_of_parameters_to_object( + invocation_parameters.get("BodyParameters", []) + ) + event.update(body_parameters) + + query_parameters = invocation_parameters.get("QueryStringParameters", []) + query_object = list_of_parameters_to_object(query_parameters) + endpoint = add_query_params_to_url(endpoint, query_object) + + return endpoint + + +def _auth_keys_from_connection(connection_details, auth_secret): + headers = {} + + auth_type = connection_details.get("AuthorizationType").upper() + auth_parameters = connection_details.get("AuthParameters") + match auth_type: + case ConnectionAuthorizationType.BASIC: + username = auth_secret.get("username", "") + password = auth_secret.get("password", "") + auth = "Basic " + to_str(base64.b64encode(f"{username}:{password}".encode("ascii"))) + headers.update({"authorization": auth}) + + case ConnectionAuthorizationType.API_KEY: + api_key_name = auth_secret.get("api_key_name", "") + api_key_value = auth_secret.get("api_key_value", "") + headers.update({api_key_name: api_key_value}) + + case ConnectionAuthorizationType.OAUTH_CLIENT_CREDENTIALS: + oauth_parameters = auth_parameters.get("OAuthParameters", {}) + oauth_method = auth_secret.get("http_method") + + oauth_http_parameters = oauth_parameters.get("OAuthHttpParameters", {}) + oauth_endpoint = auth_secret.get("authorization_endpoint", "") + query_object = list_of_parameters_to_object( + oauth_http_parameters.get("QueryStringParameters", []) + ) + oauth_endpoint = add_query_params_to_url(oauth_endpoint, query_object) + + client_id = auth_secret.get("client_id", "") + client_secret = auth_secret.get("client_secret", "") + + oauth_body = list_of_parameters_to_object( + oauth_http_parameters.get("BodyParameters", []) + ) + oauth_body.update({"client_id": client_id, "client_secret": client_secret}) + + oauth_header = list_of_parameters_to_object( + oauth_http_parameters.get("HeaderParameters", []) + ) + oauth_result = requests.request( + method=oauth_method, + url=oauth_endpoint, + data=json.dumps(oauth_body), + headers=oauth_header, + ) + oauth_data = json.loads(oauth_result.text) + + token_type = oauth_data.get("token_type", "") + access_token = oauth_data.get("access_token", "") + auth_header = f"{token_type} {access_token}" + headers.update({"authorization": auth_header}) + + return headers diff --git a/localstack-core/localstack/services/events/archive.py b/localstack-core/localstack/services/events/archive.py new file mode 100644 index 0000000000000..12d7e4601747f --- /dev/null +++ b/localstack-core/localstack/services/events/archive.py @@ -0,0 +1,189 @@ +import json +import logging +from datetime import datetime, timezone +from typing import Self + +from botocore.client import BaseClient + +from localstack.aws.api.events import ( + ArchiveState, + Arn, + EventBusName, + TargetId, + Timestamp, +) +from localstack.aws.connect import connect_to +from localstack.services.events.models import ( + Archive, + ArchiveDescription, + ArchiveName, + EventPattern, + FormattedEvent, + FormattedEventList, + RetentionDays, + RuleName, +) +from localstack.services.events.utils import extract_event_bus_name +from localstack.utils.aws.client_types import ServicePrincipal + +LOG = logging.getLogger(__name__) + + +class ArchiveService: + archive_name: ArchiveName + region: str + account_id: str + event_source_arn: Arn + description: ArchiveDescription + event_pattern: EventPattern + retention_days: RetentionDays + archive: Archive + client: BaseClient + event_bus_name: EventBusName + rule_name: RuleName + target_id: TargetId + + def __init__(self, archive: Archive): + self.archive = archive + self.set_state(ArchiveState.CREATING) + self.set_creation_time() + self.client: BaseClient = self._initialize_client() + self.event_bus_name: EventBusName = extract_event_bus_name(archive.event_source_arn) + self.set_state(ArchiveState.ENABLED) + self.rule_name = f"Events-Archive-{self.archive_name}" + self.target_id = f"Events-Archive-{self.archive_name}" + + @classmethod + def create_archive_service( + cls, + archive_name: ArchiveName, + region: str, + account_id: str, + event_source_arn: Arn, + description: ArchiveDescription, + event_pattern: EventPattern, + retention_days: RetentionDays, + ) -> Self: + return cls( + Archive( + archive_name, + region, + account_id, + event_source_arn, + description, + event_pattern, + retention_days, + ) + ) + + def register_archive_rule_and_targets(self): + self._create_archive_rule() + self._create_archive_target() + + def __getattr__(self, name): + return getattr(self.archive, name) + + @property + def archive_name(self) -> ArchiveName: + return self.archive.name + + @property + def archive_arn(self) -> Arn: + return self.archive.arn + + def set_state(self, state: ArchiveState) -> None: + self.archive.state = state + + def set_creation_time(self) -> None: + self.archive.creation_time = datetime.now(timezone.utc) + + def update( + self, + description: ArchiveDescription, + event_pattern: EventPattern, + retention_days: RetentionDays, + ) -> None: + self.set_state(ArchiveState.UPDATING) + if description is not None: + self.archive.description = description + if event_pattern is not None: + self.archive.event_pattern = event_pattern + if retention_days is not None: + self.archive.retention_days = retention_days + self.set_state(ArchiveState.ENABLED) + + def delete(self) -> None: + self.set_state(ArchiveState.DISABLED) + try: + self.client.remove_targets( + Rule=self.rule_name, EventBusName=self.event_bus_name, Ids=[self.target_id] + ) + except Exception as e: + LOG.debug("Target %s could not be removed, %s", self.target_id, e) + try: + self.client.delete_rule(Name=self.rule_name, EventBusName=self.event_bus_name) + except Exception as e: + LOG.debug("Rule %s could not be deleted, %s", self.rule_name, e) + + def put_events(self, events: FormattedEventList) -> None: + for event in events: + self.archive.events[event["id"]] = event + + def get_events(self, start_time: Timestamp, end_time: Timestamp) -> FormattedEventList: + events_to_replay = self._filter_events_start_end_time(start_time, end_time) + return events_to_replay + + def _initialize_client(self) -> BaseClient: + client_factory = connect_to(aws_access_key_id=self.account_id, region_name=self.region) + client = client_factory.get_client("events") + + service_principal = ServicePrincipal.events + client = client.request_metadata(service_principal=service_principal, source_arn=self.arn) + return client + + def _create_archive_rule( + self, + ): + default_event_pattern = { + "replay-name": [{"exists": False}], + } + if self.event_pattern: + updated_event_pattern = json.loads(self.event_pattern) + updated_event_pattern.update(default_event_pattern) + else: + updated_event_pattern = default_event_pattern + self.client.put_rule( + Name=self.rule_name, + EventBusName=self.event_bus_name, + EventPattern=json.dumps(updated_event_pattern), + ) + + def _create_archive_target( + self, + ): + """Creates a target for the archive rule. The target is required for accessing parameters + from the provider during sending of events to the target but it is not invoked + because events are put to the archive directly to not overload the gateway""" + self.client.put_targets( + Rule=self.rule_name, + EventBusName=self.event_bus_name, + Targets=[{"Id": self.target_id, "Arn": self.arn}], + ) + + def _normalize_datetime(self, dt: datetime) -> datetime: + return dt.replace(second=0, microsecond=0) + + def _filter_events_start_end_time( + self, event_start_time: Timestamp, event_end_time: Timestamp + ) -> list[FormattedEvent]: + events = self.archive.events + event_start_time = self._normalize_datetime(event_start_time) + event_end_time = self._normalize_datetime(event_end_time) + return [ + event + for event in events.values() + if event_start_time <= self._normalize_datetime(event["time"]) <= event_end_time + ] + + +ArchiveServiceDict = dict[Arn, ArchiveService] diff --git a/localstack-core/localstack/services/events/connection.py b/localstack-core/localstack/services/events/connection.py new file mode 100644 index 0000000000000..c2b72a2025328 --- /dev/null +++ b/localstack-core/localstack/services/events/connection.py @@ -0,0 +1,344 @@ +import json +import logging +import re +import uuid +from datetime import datetime, timezone + +from localstack.aws.api.events import ( + Arn, + ConnectionAuthorizationType, + ConnectionDescription, + ConnectionName, + ConnectionState, + ConnectivityResourceParameters, + CreateConnectionAuthRequestParameters, + Timestamp, + UpdateConnectionAuthRequestParameters, +) +from localstack.aws.connect import connect_to +from localstack.services.events.models import Connection, ValidationException + +VALID_AUTH_TYPES = [t.value for t in ConnectionAuthorizationType] +LOG = logging.getLogger(__name__) + + +class ConnectionService: + def __init__( + self, + name: ConnectionName, + region: str, + account_id: str, + authorization_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters, + description: ConnectionDescription | None = None, + invocation_connectivity_parameters: ConnectivityResourceParameters | None = None, + create_secret: bool = True, + ): + self._validate_input(name, authorization_type) + state = self._get_initial_state(authorization_type) + + secret_arn = None + if create_secret: + secret_arn = self.create_connection_secret( + region, account_id, name, authorization_type, auth_parameters + ) + public_auth_parameters = self._get_public_parameters(authorization_type, auth_parameters) + + self.connection = Connection( + name, + region, + account_id, + authorization_type, + public_auth_parameters, + state, + secret_arn, + description, + invocation_connectivity_parameters, + ) + + @classmethod + def restore_from_connection(cls, connection: Connection): + connection_service = cls( + connection.name, + connection.region, + connection.account_id, + connection.authorization_type, + connection.auth_parameters, + create_secret=False, + ) + connection_service.connection = connection + return connection_service + + @property + def arn(self) -> Arn: + return self.connection.arn + + @property + def state(self) -> ConnectionState: + return self.connection.state + + @property + def creation_time(self) -> Timestamp: + return self.connection.creation_time + + @property + def last_modified_time(self) -> Timestamp: + return self.connection.last_modified_time + + @property + def last_authorized_time(self) -> Timestamp: + return self.connection.last_authorized_time + + @property + def secret_arn(self) -> Arn: + return self.connection.secret_arn + + @property + def auth_parameters(self) -> CreateConnectionAuthRequestParameters: + return self.connection.auth_parameters + + def set_state(self, state: ConnectionState) -> None: + if hasattr(self, "connection"): + self.connection.state = state + + def update( + self, + description: ConnectionDescription, + authorization_type: ConnectionAuthorizationType, + auth_parameters: UpdateConnectionAuthRequestParameters, + invocation_connectivity_parameters: ConnectivityResourceParameters | None = None, + ) -> None: + self.set_state(ConnectionState.UPDATING) + if description: + self.connection.description = description + if invocation_connectivity_parameters: + self.connection.invocation_connectivity_parameters = invocation_connectivity_parameters + # Use existing values if not provided in update + if authorization_type: + auth_type = ( + authorization_type.value + if hasattr(authorization_type, "value") + else authorization_type + ) + self._validate_auth_type(auth_type) + else: + auth_type = self.connection.authorization_type + + try: + if self.connection.secret_arn: + self.update_connection_secret( + self.connection.secret_arn, auth_type, auth_parameters + ) + else: + secret_arn = self.create_connection_secret( + self.connection.region, + self.connection.account_id, + self.connection.name, + auth_type, + auth_parameters, + ) + self.connection.secret_arn = secret_arn + self.connection.last_authorized_time = datetime.now(timezone.utc) + + # Set new values + self.connection.authorization_type = auth_type + public_auth_parameters = ( + self._get_public_parameters(authorization_type, auth_parameters) + if auth_parameters + else self.connection.auth_parameters + ) + self.connection.auth_parameters = public_auth_parameters + self.set_state(ConnectionState.AUTHORIZED) + self.connection.last_modified_time = datetime.now(timezone.utc) + + except Exception as error: + LOG.warning( + "Connection with name %s updating failed with errors: %s.", + self.connection.name, + error, + ) + + def delete(self) -> None: + self.set_state(ConnectionState.DELETING) + self.delete_connection_secret(self.connection.secret_arn) + self.set_state(ConnectionState.DELETING) # required for AWS parity + self.connection.last_modified_time = datetime.now(timezone.utc) + + def create_connection_secret( + self, + region: str, + account_id: str, + name: str, + authorization_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters + | UpdateConnectionAuthRequestParameters, + ) -> Arn | None: + self.set_state(ConnectionState.AUTHORIZING) + secretsmanager_client = connect_to( + aws_access_key_id=account_id, region_name=region + ).secretsmanager + secret_value = self._get_secret_value(authorization_type, auth_parameters) + secret_name = f"events!connection/{name}/{str(uuid.uuid4())}" + try: + secret_arn = secretsmanager_client.create_secret( + Name=secret_name, + SecretString=secret_value, + Tags=[{"Key": "BYPASS_SECRET_ID_VALIDATION", "Value": "1"}], + )["ARN"] + self.set_state(ConnectionState.AUTHORIZED) + return secret_arn + except Exception as error: + LOG.warning("Secret with name %s creation failed with errors: %s.", secret_name, error) + + def update_connection_secret( + self, + secret_arn: str, + authorization_type: ConnectionAuthorizationType, + auth_parameters: UpdateConnectionAuthRequestParameters, + ) -> None: + self.set_state(ConnectionState.AUTHORIZING) + secretsmanager_client = connect_to( + aws_access_key_id=self.connection.account_id, region_name=self.connection.region + ).secretsmanager + secret_value = self._get_secret_value(authorization_type, auth_parameters) + try: + secretsmanager_client.update_secret(SecretId=secret_arn, SecretString=secret_value) + self.set_state(ConnectionState.AUTHORIZED) + self.connection.last_authorized_time = datetime.now(timezone.utc) + except Exception as error: + LOG.warning("Secret with id %s updating failed with errors: %s.", secret_arn, error) + + def delete_connection_secret(self, secret_arn: str) -> None: + self.set_state(ConnectionState.DEAUTHORIZING) + secretsmanager_client = connect_to( + aws_access_key_id=self.connection.account_id, region_name=self.connection.region + ).secretsmanager + try: + secretsmanager_client.delete_secret( + SecretId=secret_arn, ForceDeleteWithoutRecovery=True + ) + self.set_state(ConnectionState.DEAUTHORIZED) + except Exception as error: + LOG.warning("Secret with id %s deleting failed with errors: %s.", secret_arn, error) + + def _get_initial_state(self, auth_type: str) -> ConnectionState: + if auth_type == "OAUTH_CLIENT_CREDENTIALS": + return ConnectionState.AUTHORIZING + return ConnectionState.AUTHORIZED + + def _get_secret_value( + self, + authorization_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters + | UpdateConnectionAuthRequestParameters, + ) -> str: + result = {} + match authorization_type: + case ConnectionAuthorizationType.BASIC: + params = auth_parameters.get("BasicAuthParameters", {}) + result = {"username": params.get("Username"), "password": params.get("Password")} + case ConnectionAuthorizationType.API_KEY: + params = auth_parameters.get("ApiKeyAuthParameters", {}) + result = { + "api_key_name": params.get("ApiKeyName"), + "api_key_value": params.get("ApiKeyValue"), + } + case ConnectionAuthorizationType.OAUTH_CLIENT_CREDENTIALS: + params = auth_parameters.get("OAuthParameters", {}) + client_params = params.get("ClientParameters", {}) + result = { + "client_id": client_params.get("ClientID"), + "client_secret": client_params.get("ClientSecret"), + "authorization_endpoint": params.get("AuthorizationEndpoint"), + "http_method": params.get("HttpMethod"), + } + + if "InvocationHttpParameters" in auth_parameters: + result["invocation_http_parameters"] = auth_parameters["InvocationHttpParameters"] + + return json.dumps(result) + + def _get_public_parameters( + self, + auth_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters + | UpdateConnectionAuthRequestParameters, + ) -> CreateConnectionAuthRequestParameters: + """Extract public parameters (without secrets) based on auth type.""" + public_params = {} + + if ( + auth_type == ConnectionAuthorizationType.BASIC + and "BasicAuthParameters" in auth_parameters + ): + public_params["BasicAuthParameters"] = { + "Username": auth_parameters["BasicAuthParameters"]["Username"] + } + + elif ( + auth_type == ConnectionAuthorizationType.API_KEY + and "ApiKeyAuthParameters" in auth_parameters + ): + public_params["ApiKeyAuthParameters"] = { + "ApiKeyName": auth_parameters["ApiKeyAuthParameters"]["ApiKeyName"] + } + + elif ( + auth_type == ConnectionAuthorizationType.OAUTH_CLIENT_CREDENTIALS + and "OAuthParameters" in auth_parameters + ): + oauth_params = auth_parameters["OAuthParameters"] + public_params["OAuthParameters"] = { + "AuthorizationEndpoint": oauth_params["AuthorizationEndpoint"], + "HttpMethod": oauth_params["HttpMethod"], + "ClientParameters": {"ClientID": oauth_params["ClientParameters"]["ClientID"]}, + } + if "OAuthHttpParameters" in oauth_params: + public_params["OAuthParameters"]["OAuthHttpParameters"] = oauth_params.get( + "OAuthHttpParameters" + ) + + if "InvocationHttpParameters" in auth_parameters: + public_params["InvocationHttpParameters"] = auth_parameters["InvocationHttpParameters"] + + return public_params + + def _validate_input( + self, + name: ConnectionName, + authorization_type: ConnectionAuthorizationType, + ) -> None: + errors = [] + errors.extend(self._validate_connection_name(name)) + errors.extend(self._validate_auth_type(authorization_type)) + if errors: + error_message = ( + f"{len(errors)} validation error{'s' if len(errors) > 1 else ''} detected: " + ) + error_message += "; ".join(errors) + raise ValidationException(error_message) + + def _validate_connection_name(self, name: str) -> list[str]: + errors = [] + if not re.match("^[\\.\\-_A-Za-z0-9]+$", name): + errors.append( + f"Value '{name}' at 'name' failed to satisfy constraint: " + "Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+" + ) + if not (1 <= len(name) <= 64): + errors.append( + f"Value '{name}' at 'name' failed to satisfy constraint: " + "Member must have length less than or equal to 64" + ) + return errors + + def _validate_auth_type(self, auth_type: str) -> list[str]: + if auth_type not in VALID_AUTH_TYPES: + return [ + f"Value '{auth_type}' at 'authorizationType' failed to satisfy constraint: " + f"Member must satisfy enum value set: [{', '.join(VALID_AUTH_TYPES)}]" + ] + return [] + + +ConnectionServiceDict = dict[Arn, ConnectionService] diff --git a/localstack-core/localstack/services/events/event_bus.py b/localstack-core/localstack/services/events/event_bus.py new file mode 100644 index 0000000000000..1ea6f332a493b --- /dev/null +++ b/localstack-core/localstack/services/events/event_bus.py @@ -0,0 +1,131 @@ +import json +from datetime import datetime, timezone +from typing import Optional, Self + +from localstack.aws.api.events import ( + Action, + Arn, + Condition, + EventBusName, + Principal, + ResourceNotFoundException, + StatementId, + TagList, +) +from localstack.services.events.models import EventBus, ResourcePolicy, RuleDict, Statement +from localstack.utils.aws.arns import get_partition + + +class EventBusService: + name: EventBusName + region: str + account_id: str + event_source_name: str | None + tags: TagList | None + policy: str | None + event_bus: EventBus + + def __init__(self, event_bus: EventBus): + self.event_bus = event_bus + + @classmethod + def create_event_bus_service( + cls, + name: EventBusName, + region: str, + account_id: str, + event_source_name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[TagList] = None, + policy: Optional[str] = None, + rules: Optional[RuleDict] = None, + ) -> Self: + return cls( + EventBus( + name, + region, + account_id, + event_source_name, + description, + tags, + policy, + rules, + ) + ) + + @property + def arn(self) -> Arn: + return self.event_bus.arn + + def put_permission( + self, + action: Action, + principal: Principal, + statement_id: StatementId, + condition: Condition, + policy: str, + ): + # TODO: cover via test + # if policy and any([action, principal, statement_id, condition]): + # raise ValueError("Combination of policy with other arguments is not allowed") + self.event_bus.last_modified_time = datetime.now(timezone.utc) + if policy: # policy document replaces all existing permissions + policy = json.loads(policy) + parsed_policy = ResourcePolicy(**policy) + self.event_bus.policy = parsed_policy + else: + permission_statement = self._parse_statement( + statement_id, action, principal, self.arn, condition + ) + + if existing_policy := self.event_bus.policy: + if permission_statement["Principal"] == "*": + for statement in existing_policy["Statement"]: + if "*" == statement["Principal"]: + return + existing_policy["Statement"].append(permission_statement) + else: + parsed_policy = ResourcePolicy( + Version="2012-10-17", Statement=[permission_statement] + ) + self.event_bus.policy = parsed_policy + + def revoke_put_events_permission(self, statement_id: str): + policy = self.event_bus.policy + if not policy or not any( + statement.get("Sid") == statement_id for statement in policy["Statement"] + ): + raise ResourceNotFoundException("Statement with the provided id does not exist.") + if policy: + policy["Statement"] = [ + statement + for statement in policy["Statement"] + if statement.get("Sid") != statement_id + ] + self.event_bus.last_modified_time = datetime.now(timezone.utc) + + def _parse_statement( + self, + statement_id: StatementId, + action: Action, + principal: Principal, + resource_arn: Arn, + condition: Condition, + ) -> Statement: + # TODO: cover via test + # if condition and principal != "*": + # raise ValueError("Condition can only be set when principal is '*'") + if principal != "*": + principal = {"AWS": f"arn:{get_partition(self.event_bus.region)}:iam::{principal}:root"} + statement = Statement( + Sid=statement_id, + Effect="Allow", + Principal=principal, + Action=action, + Resource=resource_arn, + Condition=condition, + ) + return statement + + +EventBusServiceDict = dict[Arn, EventBusService] diff --git a/localstack-core/localstack/services/events/event_rule_engine.py b/localstack-core/localstack/services/events/event_rule_engine.py new file mode 100644 index 0000000000000..a1af9a9cdb339 --- /dev/null +++ b/localstack-core/localstack/services/events/event_rule_engine.py @@ -0,0 +1,624 @@ +import ipaddress +import json +import re +import typing as t + +from localstack.aws.api.events import InvalidEventPatternException + + +class EventRuleEngine: + def evaluate_pattern_on_event(self, compiled_event_pattern: dict, event: str | dict): + if isinstance(event, str): + try: + body = json.loads(event) + if not isinstance(body, dict): + return False + except json.JSONDecodeError: + # Event pattern for the message body assume that the message payload is a well-formed JSON object. + return False + else: + body = event + + return self._evaluate_nested_event_pattern_on_dict(compiled_event_pattern, payload=body) + + def _evaluate_nested_event_pattern_on_dict(self, event_pattern, payload: dict) -> bool: + """ + This method evaluates the event pattern against the JSON decoded payload. + Although it's not documented anywhere, AWS allows `.` in the fields name in the event pattern and the payload, + and will evaluate them. However, it's not JSONPath compatible. + See: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-pattern.html#eb-create-pattern-considerations + Example: + Pattern: `{"field1.field2": "value1"}` + This pattern will match both `{"field1.field2": "value1"}` and {"field1: {"field2": "value1"}}`, unlike JSONPath + for which `.` points to a child node. + This might show they are flattening the both dictionaries to a single level for an easier matching without + recursion. + :param event_pattern: a dict, starting at the Event Pattern + :param payload: a dict, starting at the MessageBody + :return: True if the payload respect the event pattern, otherwise False + """ + if not event_pattern: + return True + + # TODO: maybe save/cache the flattened/expanded pattern? + flat_pattern_conditions = self.flatten_pattern(event_pattern) + flat_payloads = self.flatten_payload(payload, flat_pattern_conditions) + + return any( + all( + any( + self._evaluate_condition( + flat_payload.get(key), condition, field_exists=key in flat_payload + ) + for condition in conditions + for flat_payload in flat_payloads + ) + for key, conditions in flat_pattern.items() + ) + for flat_pattern in flat_pattern_conditions + ) + + def _evaluate_condition(self, value, condition, field_exists: bool): + if not isinstance(condition, dict): + return field_exists and value == condition + elif (must_exist := condition.get("exists")) is not None: + # if must_exists is True then field_exists must be True + # if must_exists is False then fields_exists must be False + return must_exist == field_exists + elif (anything_but := condition.get("anything-but")) is not None: + if isinstance(anything_but, dict): + if (not_condition := anything_but.get("prefix")) is not None: + predicate = self._evaluate_prefix + elif (not_condition := anything_but.get("suffix")) is not None: + predicate = self._evaluate_suffix + elif (not_condition := anything_but.get("equals-ignore-case")) is not None: + predicate = self._evaluate_equal_ignore_case + elif (not_condition := anything_but.get("wildcard")) is not None: + predicate = self._evaluate_wildcard + else: + # this should not happen as we validate the EventPattern before + return False + + if isinstance(not_condition, str): + return not predicate(not_condition, value) + elif isinstance(not_condition, list): + return all( + not predicate(sub_condition, value) for sub_condition in not_condition + ) + + elif isinstance(anything_but, list): + return value not in anything_but + else: + return value != anything_but + + elif value is None: + # the remaining conditions require the value to not be None + return False + elif (prefix := condition.get("prefix")) is not None: + if isinstance(prefix, dict): + if (prefix_equal_ignore_case := prefix.get("equals-ignore-case")) is not None: + return self._evaluate_prefix(prefix_equal_ignore_case.lower(), value.lower()) + else: + return self._evaluate_prefix(prefix, value) + + elif (suffix := condition.get("suffix")) is not None: + if isinstance(suffix, dict): + if suffix_equal_ignore_case := suffix.get("equals-ignore-case"): + return self._evaluate_suffix(suffix_equal_ignore_case.lower(), value.lower()) + else: + return self._evaluate_suffix(suffix, value) + + elif (equal_ignore_case := condition.get("equals-ignore-case")) is not None: + return self._evaluate_equal_ignore_case(equal_ignore_case, value) + + # we validated that `numeric` should be a non-empty list when creating the rule, we don't need the None check + elif numeric_condition := condition.get("numeric"): + return self._evaluate_numeric_condition(numeric_condition, value) + + # we also validated the `cidr` that it cannot be empty + elif cidr := condition.get("cidr"): + return self._evaluate_cidr(cidr, value) + + elif (wildcard := condition.get("wildcard")) is not None: + return self._evaluate_wildcard(wildcard, value) + + return False + + @staticmethod + def _evaluate_prefix(condition: str | list, value: str) -> bool: + return value.startswith(condition) + + @staticmethod + def _evaluate_suffix(condition: str | list, value: str) -> bool: + return value.endswith(condition) + + @staticmethod + def _evaluate_equal_ignore_case(condition: str, value: str) -> bool: + return condition.lower() == value.lower() + + @staticmethod + def _evaluate_cidr(condition: str, value: str) -> bool: + try: + ip = ipaddress.ip_address(value) + return ip in ipaddress.ip_network(condition) + except ValueError: + return False + + @staticmethod + def _evaluate_wildcard(condition: str, value: str) -> bool: + return bool(re.match(re.escape(condition).replace("\\*", ".+") + "$", value)) + + @staticmethod + def _evaluate_numeric_condition(conditions: list, value: t.Any) -> bool: + if not isinstance(value, (int, float)): + return False + try: + # try if the value is numeric + value = float(value) + except ValueError: + # the value is not numeric, the condition is False + return False + + for i in range(0, len(conditions), 2): + operator = conditions[i] + operand = float(conditions[i + 1]) + + if operator == "=": + if value != operand: + return False + elif operator == ">": + if value <= operand: + return False + elif operator == "<": + if value >= operand: + return False + elif operator == ">=": + if value < operand: + return False + elif operator == "<=": + if value > operand: + return False + + return True + + @staticmethod + def flatten_pattern(nested_dict: dict) -> list[dict]: + """ + Takes a dictionary as input and will output the dictionary on a single level. + Input: + `{"field1": {"field2": {"field3": "val1", "field4": "val2"}}}` + Output: + `[ + { + "field1.field2.field3": "val1", + "field1.field2.field4": "val2" + } + ]` + Input with $or will create multiple outputs: + `{"$or": [{"field1": "val1"}, {"field2": "val2"}], "field3": "val3"}` + Output: + `[ + {"field1": "val1", "field3": "val3"}, + {"field2": "val2", "field3": "val3"} + ]` + :param nested_dict: a (nested) dictionary + :return: a list of flattened dictionaries with no nested dict or list inside, flattened to a + single level, one list item for every list item encountered + """ + + def _traverse_event_pattern(obj, array=None, parent_key=None) -> list: + if array is None: + array = [{}] + + for key, values in obj.items(): + if key == "$or" and isinstance(values, list) and len(values) > 1: + # $or will create multiple new branches in the array. + # Each current branch will traverse with each choice in $or + array = [ + i + for value in values + for i in _traverse_event_pattern(value, array, parent_key) + ] + else: + # We update the parent key do that {"key1": {"key2": ""}} becomes "key1.key2" + _parent_key = f"{parent_key}.{key}" if parent_key else key + if isinstance(values, dict): + # If the current key has child dict -- key: "key1", child: {"key2": ["val1", val2"]} + # We only update the parent_key and traverse its children with the current branches + array = _traverse_event_pattern(values, array, _parent_key) + else: + # If the current key has no child, this means we found the values to match -- child: ["val1", val2"] + # we update the branches with the parent chain and the values -- {"key1.key2": ["val1, val2"]} + array = [{**item, _parent_key: values} for item in array] + + return array + + return _traverse_event_pattern(nested_dict) + + @staticmethod + def flatten_payload(payload: dict, patterns: list[dict]) -> list[dict]: + """ + Takes a dictionary as input and will output the dictionary on a single level. + The dictionary can have lists containing other dictionaries, and one root level entry will be created for every + item in a list if it corresponds to the entries of the patterns. + Input: + payload: + `{"field1": { + "field2: [ + {"field3: "val1", "field4": "val2"}, + {"field3: "val3", "field4": "val4"}, + } + ]}` + patterns: + `[ + "field1.field2.field3": , + "field1.field2.field4": , + ]` + Output: + `[ + { + "field1.field2.field3": "val1", + "field1.field2.field4": "val2" + }, + { + "field1.field2.field3": "val3", + "field1.field2.field4": "val4" + }, + ]` + :param payload: a (nested) dictionary, the event payload + :param patterns: the flattened patterns from the EventPattern (see flatten_pattern) + :return: flatten_dict: a dictionary with no nested dict inside, flattened to a single level + """ + patterns_keys = {key for keys in patterns for key in keys} + + def _is_key_in_patterns(key: str) -> bool: + return key is None or any(pattern_key.startswith(key) for pattern_key in patterns_keys) + + def _traverse(_object: dict, array=None, parent_key=None) -> list: + if isinstance(_object, dict): + for key, values in _object.items(): + # We update the parent key so that {"key1": {"key2": ""}} becomes "key1.key2" + _parent_key = f"{parent_key}.{key}" if parent_key else key + + # we make sure that we are building only the relevant parts of the payload related to the pattern + # the payload could be very complex, and the pattern only applies to part of it + if _is_key_in_patterns(_parent_key): + array = _traverse(values, array, _parent_key) + + elif isinstance(_object, list): + if not _object: + return array + array = [i for value in _object for i in _traverse(value, array, parent_key)] + else: + array = [{**item, parent_key: _object} for item in array] + return array + + return _traverse(payload, array=[{}], parent_key=None) + + +class EventPatternCompiler: + def __init__(self): + self.error_prefix = "Event pattern is not valid. Reason: " + + def compile_event_pattern(self, event_pattern: str | dict) -> dict[str, t.Any]: + if isinstance(event_pattern, str): + try: + event_pattern = json.loads(event_pattern) + if not isinstance(event_pattern, dict): + raise InvalidEventPatternException( + f"{self.error_prefix}Filter is not an object" + ) + except json.JSONDecodeError: + # this error message is not in parity, as it is tightly coupled to AWS parsing engine + raise InvalidEventPatternException(f"{self.error_prefix}Filter is not valid JSON") + + aggregated_rules, combinations = self.aggregate_rules(event_pattern) + + for rules in aggregated_rules: + for rule in rules: + self._validate_rule(rule) + + return event_pattern + + def aggregate_rules(self, event_pattern: dict[str, t.Any]) -> tuple[list[list[t.Any]], int]: + """ + This method evaluate the event pattern recursively, and returns only a list of lists of rules. + It also calculates the combinations of rules, calculated depending on the nesting of the rules. + Example: + nested_event_pattern = { + "key_a": { + "key_b": { + "key_c": ["value_one", "value_two", "value_three", "value_four"] + } + }, + "key_d": { + "key_e": ["value_one", "value_two", "value_three"] + } + } + This function then iterates on the values of the top level keys of the event pattern: ("key_a", "key_d") + If the iterated value is not a list, it means it is a nested property. If the scope is `MessageBody`, it is + allowed, we call this method on the value, adding a level to the depth to keep track on how deep the key is. + If the value is a list, it means it contains rules: we will append this list of rules in _rules, and + calculate the combinations it adds. + For the example event pattern containing nested properties, we calculate it this way + The first array has four values in a three-level nested key, and the second has three values in a two-level + nested key. 3 x 4 x 2 x 3 = 72 + The return value would be: + [["value_one", "value_two", "value_three", "value_four"], ["value_one", "value_two", "value_three"]] + It allows us to later iterate of the list of rules in an easy way, to verify its conditions only. + + :param event_pattern: a dict, starting at the Event Pattern + :return: a tuple with a list of lists of rules and the calculated number of combinations + """ + + def _inner( + pattern_elements: dict[str, t.Any], depth: int = 1, combinations: int = 1 + ) -> tuple[list[list[t.Any]], int]: + _rules = [] + for key, _value in pattern_elements.items(): + if isinstance(_value, dict): + # From AWS docs: "unlike attribute-based policies, payload-based policies support property nesting." + sub_rules, combinations = _inner( + _value, depth=depth + 1, combinations=combinations + ) + _rules.extend(sub_rules) + elif isinstance(_value, list): + if not _value: + raise InvalidEventPatternException( + f"{self.error_prefix}Empty arrays are not allowed" + ) + + current_combination = 0 + if key == "$or": + for val in _value: + sub_rules, or_combinations = _inner( + val, depth=depth, combinations=combinations + ) + _rules.extend(sub_rules) + current_combination += or_combinations + + combinations = current_combination + else: + _rules.append(_value) + combinations = combinations * len(_value) * depth + else: + raise InvalidEventPatternException( + f'{self.error_prefix}"{key}" must be an object or an array' + ) + + return _rules, combinations + + return _inner(event_pattern) + + def _validate_rule(self, rule: t.Any, from_: str | None = None) -> None: + match rule: + case None | str() | bool(): + return + + case int() | float(): + # TODO: AWS says they support only from -10^9 to 10^9 but seems to accept it, so we just return + # if rule <= -1000000000 or rule >= 1000000000: + # raise "" + return + + case {**kwargs}: + if len(kwargs) != 1: + raise InvalidEventPatternException( + f"{self.error_prefix}Only one key allowed in match expression" + ) + + operator, value = None, None + for k, v in kwargs.items(): + operator, value = k, v + + if operator in ( + "prefix", + "suffix", + ): + if from_ == "anything-but": + if isinstance(value, dict): + raise InvalidEventPatternException( + f"{self.error_prefix}Value of {from_} must be an array or single string/number value." + ) + + if not self._is_str_or_list_of_str(value): + raise InvalidEventPatternException( + f"{self.error_prefix}prefix/suffix match pattern must be a string" + ) + elif not value: + raise InvalidEventPatternException( + f"{self.error_prefix}Null prefix/suffix not allowed" + ) + + elif isinstance(value, dict): + for inner_operator in value.keys(): + if inner_operator != "equals-ignore-case": + raise InvalidEventPatternException( + f"{self.error_prefix}Unsupported anything-but pattern: {inner_operator}" + ) + + elif not isinstance(value, str): + raise InvalidEventPatternException( + f"{self.error_prefix}{operator} match pattern must be a string" + ) + return + + elif operator == "equals-ignore-case": + if from_ == "anything-but": + if not self._is_str_or_list_of_str(value): + raise InvalidEventPatternException( + f"{self.error_prefix}Inside {from_}/{operator} list, number|start|null|boolean is not supported." + ) + elif not isinstance(value, str): + raise InvalidEventPatternException( + f"{self.error_prefix}{operator} match pattern must be a string" + ) + return + + elif operator == "anything-but": + # anything-but can actually contain any kind of simple rule (str, number, and list) + if isinstance(value, list): + for v in value: + self._validate_rule(v) + + return + + # or have a nested `prefix`, `suffix` or `equals-ignore-case` pattern + elif isinstance(value, dict): + for inner_operator in value.keys(): + if inner_operator not in ( + "prefix", + "equals-ignore-case", + "suffix", + "wildcard", + ): + raise InvalidEventPatternException( + f"{self.error_prefix}Unsupported anything-but pattern: {inner_operator}" + ) + + self._validate_rule(value, from_="anything-but") + return + + elif operator == "exists": + if not isinstance(value, bool): + raise InvalidEventPatternException( + f"{self.error_prefix}exists match pattern must be either true or false." + ) + return + + elif operator == "numeric": + self._validate_numeric_condition(value) + + elif operator == "cidr": + self._validate_cidr_condition(value) + + elif operator == "wildcard": + if from_ == "anything-but" and isinstance(value, list): + for v in value: + self._validate_wildcard(v) + else: + self._validate_wildcard(value) + + else: + raise InvalidEventPatternException( + f"{self.error_prefix}Unrecognized match type {operator}" + ) + + case _: + raise InvalidEventPatternException( + f"{self.error_prefix}Match value must be String, number, true, false, or null" + ) + + def _validate_numeric_condition(self, value): + if not isinstance(value, list): + raise InvalidEventPatternException( + f"{self.error_prefix}Value of numeric must be an array." + ) + if not value: + raise InvalidEventPatternException( + f"{self.error_prefix}Invalid member in numeric match: ]" + ) + num_values = value[::-1] + + operator = num_values.pop() + if not isinstance(operator, str): + raise InvalidEventPatternException( + f"{self.error_prefix}Invalid member in numeric match: {operator}" + ) + elif operator not in ("<", "<=", "=", ">", ">="): + raise InvalidEventPatternException( + f"{self.error_prefix}Unrecognized numeric range operator: {operator}" + ) + + value = num_values.pop() if num_values else None + if not isinstance(value, (int, float)): + exc_operator = "equals" if operator == "=" else operator + raise InvalidEventPatternException( + f"{self.error_prefix}Value of {exc_operator} must be numeric" + ) + + if not num_values: + return + + if operator not in (">", ">="): + raise InvalidEventPatternException( + f"{self.error_prefix}Too many elements in numeric expression" + ) + + second_operator = num_values.pop() + if not isinstance(second_operator, str): + raise InvalidEventPatternException( + f"{self.error_prefix}Bad value in numeric range: {second_operator}" + ) + elif second_operator not in ("<", "<="): + raise InvalidEventPatternException( + f"{self.error_prefix}Bad numeric range operator: {second_operator}" + ) + + second_value = num_values.pop() if num_values else None + if not isinstance(second_value, (int, float)): + exc_operator = "equals" if second_operator == "=" else second_operator + raise InvalidEventPatternException( + f"{self.error_prefix}Value of {exc_operator} must be numeric" + ) + + elif second_value <= value: + raise InvalidEventPatternException(f"{self.error_prefix}Bottom must be less than top") + + elif num_values: + raise InvalidEventPatternException( + f"{self.error_prefix}Too many terms in numeric range expression" + ) + + def _validate_wildcard(self, value: t.Any): + if not isinstance(value, str): + raise InvalidEventPatternException( + f"{self.error_prefix}wildcard match pattern must be a string" + ) + # TODO: properly calculate complexity of wildcard + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-pattern-operators.html#eb-filtering-wildcard-matching-complexity + # > calculate complexity of repeating character sequences that occur after a wildcard character + if "**" in value: + raise InvalidEventPatternException( + f"{self.error_prefix}Consecutive wildcard characters at pos {value.index('**') + 1}" + ) + + if value.count("*") > 5: + raise InvalidEventPatternException( + f"{self.error_prefix}Rule is too complex - try using fewer wildcard characters or fewer repeating character sequences after a wildcard character" + ) + + def _validate_cidr_condition(self, value): + if not isinstance(value, str): + # `cidr` returns the prefix error + raise InvalidEventPatternException( + f"{self.error_prefix}prefix match pattern must be a string" + ) + ip_and_mask = value.split("/") + if len(ip_and_mask) != 2: + raise InvalidEventPatternException( + f"{self.error_prefix}Malformed CIDR, one '/' required" + ) + ip_addr, mask = value.split("/") + try: + int(mask) + except ValueError: + raise InvalidEventPatternException( + f"{self.error_prefix}Malformed CIDR, mask bits must be an integer" + ) + try: + ipaddress.ip_network(value) + except ValueError: + raise InvalidEventPatternException( + f"{self.error_prefix}Nonstandard IP address: {ip_addr}" + ) + + @staticmethod + def _is_str_or_list_of_str(value: t.Any) -> bool: + if not isinstance(value, (str, list)): + return False + if isinstance(value, list) and not all(isinstance(v, str) for v in value): + return False + + return True diff --git a/localstack-core/localstack/services/events/models.py b/localstack-core/localstack/services/events/models.py new file mode 100644 index 0000000000000..95e64ece83711 --- /dev/null +++ b/localstack-core/localstack/services/events/models.py @@ -0,0 +1,340 @@ +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Literal, Optional, TypeAlias, TypedDict + +from localstack.aws.api.core import ServiceException +from localstack.aws.api.events import ( + ApiDestinationDescription, + ApiDestinationHttpMethod, + ApiDestinationInvocationRateLimitPerSecond, + ApiDestinationName, + ApiDestinationState, + ArchiveDescription, + ArchiveName, + ArchiveState, + Arn, + ConnectionArn, + ConnectionAuthorizationType, + ConnectionDescription, + ConnectionName, + ConnectionState, + ConnectivityResourceParameters, + CreateConnectionAuthRequestParameters, + CreatedBy, + EventBusName, + EventPattern, + EventResourceList, + EventSourceName, + EventTime, + HttpsEndpoint, + ManagedBy, + ReplayDescription, + ReplayDestination, + ReplayName, + ReplayState, + ReplayStateReason, + RetentionDays, + RoleArn, + RuleDescription, + RuleName, + RuleState, + ScheduleExpression, + TagList, + Target, + TargetId, + Timestamp, +) +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossRegionAttribute, + LocalAttribute, +) +from localstack.utils.aws.arns import ( + event_bus_arn, + events_api_destination_arn, + events_archive_arn, + events_connection_arn, + events_replay_arn, + events_rule_arn, +) +from localstack.utils.strings import short_uid +from localstack.utils.tagging import TaggingService + +TargetDict = dict[TargetId, Target] + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = True + status_code: int = 400 + + +class InvalidEventPatternException(Exception): + reason: str + + def __init__(self, reason=None, message=None) -> None: + self.reason = reason + self.message = message or f"Event pattern is not valid. Reason: {reason}" + + +FormattedEvent = TypedDict( # functional syntax required due to name-name keys + "FormattedEvent", + { + "version": str, + "id": str, + "detail-type": Optional[str], + "source": Optional[EventSourceName], + "account": str, + "time": EventTime, + "region": str, + "resources": Optional[EventResourceList], + "detail": dict[str, str | dict], + "replay-name": Optional[ReplayName], + "event-bus-name": EventBusName, + }, +) + + +FormattedEventDict = dict[str, FormattedEvent] +FormattedEventList = list[FormattedEvent] + +TransformedEvent: TypeAlias = FormattedEvent | dict | str + + +class ResourceType(Enum): + EVENT_BUS = "event_bus" + RULE = "rule" + + +class Condition(TypedDict): + Type: Literal["StringEquals"] + Key: Literal["aws:PrincipalOrgID"] + Value: str + + +class Statement(TypedDict): + Sid: str + Effect: str + Principal: str | dict[str, str] + Action: str + Resource: str + Condition: Condition + + +class ResourcePolicy(TypedDict): + Version: str + Statement: list[Statement] + + +@dataclass +class Rule: + name: RuleName + region: str + account_id: str + schedule_expression: Optional[ScheduleExpression] = None + event_pattern: Optional[EventPattern] = None + state: Optional[RuleState] = None + description: Optional[RuleDescription] = None + role_arn: Optional[RoleArn] = None + tags: TagList = field(default_factory=list) + event_bus_name: EventBusName = "default" + targets: TargetDict = field(default_factory=dict) + managed_by: Optional[ManagedBy] = None # can only be set by AWS services + created_by: CreatedBy = field(init=False) + + def __post_init__(self): + self.created_by = self.account_id + if self.tags is None: + self.tags = [] + if self.targets is None: + self.targets = {} + if self.state is None: + self.state = RuleState.ENABLED + + @property + def arn(self) -> Arn: + return events_rule_arn(self.name, self.account_id, self.region, self.event_bus_name) + + +RuleDict = dict[RuleName, Rule] + + +@dataclass +class Replay: + name: str + region: str + account_id: str + event_source_arn: Arn + destination: ReplayDestination # Event Bus Arn or Rule Arns + event_start_time: Timestamp + event_end_time: Timestamp + description: Optional[ReplayDescription] = None + state: Optional[ReplayState] = None + state_reason: Optional[ReplayStateReason] = None + event_last_replayed_time: Optional[Timestamp] = None + replay_start_time: Optional[Timestamp] = None + replay_end_time: Optional[Timestamp] = None + + @property + def arn(self) -> Arn: + return events_replay_arn(self.name, self.account_id, self.region) + + +ReplayDict = dict[ReplayName, Replay] + + +@dataclass +class Archive: + name: ArchiveName + region: str + account_id: str + event_source_arn: Arn + description: ArchiveDescription = None + event_pattern: EventPattern = None + retention_days: RetentionDays = None + state: ArchiveState = ArchiveState.DISABLED + creation_time: Timestamp = None + size_bytes: int = 0 # TODO how to deal with updating this value? + events: FormattedEventDict = field(default_factory=dict) + + @property + def arn(self) -> Arn: + return events_archive_arn(self.name, self.account_id, self.region) + + @property + def event_count(self) -> int: + return len(self.events) + + +ArchiveDict = dict[ArchiveName, Archive] + + +@dataclass +class EventBus: + name: EventBusName + region: str + account_id: str + event_source_name: Optional[str] = None + description: Optional[str] = None + tags: TagList = field(default_factory=list) + policy: Optional[ResourcePolicy] = None + rules: RuleDict = field(default_factory=dict) + creation_time: Timestamp = field(init=False) + last_modified_time: Timestamp = field(init=False) + + def __post_init__(self): + self.creation_time = datetime.now(timezone.utc) + self.last_modified_time = datetime.now(timezone.utc) + if self.rules is None: + self.rules = {} + if self.tags is None: + self.tags = [] + + @property + def arn(self) -> Arn: + return event_bus_arn(self.name, self.account_id, self.region) + + +EventBusDict = dict[EventBusName, EventBus] + + +@dataclass +class Connection: + name: ConnectionName + region: str + account_id: str + authorization_type: ConnectionAuthorizationType + auth_parameters: CreateConnectionAuthRequestParameters + state: ConnectionState + secret_arn: Arn + description: ConnectionDescription | None = None + invocation_connectivity_parameters: ConnectivityResourceParameters | None = None + creation_time: Timestamp = field(init=False) + last_modified_time: Timestamp = field(init=False) + last_authorized_time: Timestamp = field(init=False) + tags: TagList = field(default_factory=list) + id: str = str(uuid.uuid4()) + + def __post_init__(self): + timestamp_now = datetime.now(timezone.utc) + self.creation_time = timestamp_now + self.last_modified_time = timestamp_now + self.last_authorized_time = timestamp_now + if self.tags is None: + self.tags = [] + + @property + def arn(self) -> Arn: + return events_connection_arn(self.name, self.id, self.account_id, self.region) + + +ConnectionDict = dict[ConnectionName, Connection] + + +@dataclass +class ApiDestination: + name: ApiDestinationName + region: str + account_id: str + connection_arn: ConnectionArn + invocation_endpoint: HttpsEndpoint + http_method: ApiDestinationHttpMethod + state: ApiDestinationState + _invocation_rate_limit_per_second: ApiDestinationInvocationRateLimitPerSecond | None = None + description: ApiDestinationDescription | None = None + creation_time: Timestamp = field(init=False) + last_modified_time: Timestamp = field(init=False) + last_authorized_time: Timestamp = field(init=False) + tags: TagList = field(default_factory=list) + id: str = str(short_uid()) + + def __post_init__(self): + timestamp_now = datetime.now(timezone.utc) + self.creation_time = timestamp_now + self.last_modified_time = timestamp_now + self.last_authorized_time = timestamp_now + if self.tags is None: + self.tags = [] + + @property + def arn(self) -> Arn: + return events_api_destination_arn(self.name, self.id, self.account_id, self.region) + + @property + def invocation_rate_limit_per_second(self) -> int: + return self._invocation_rate_limit_per_second or 300 # Default value + + @invocation_rate_limit_per_second.setter + def invocation_rate_limit_per_second( + self, value: ApiDestinationInvocationRateLimitPerSecond | None + ): + self._invocation_rate_limit_per_second = value + + +ApiDestinationDict = dict[ApiDestinationName, ApiDestination] + + +class EventsStore(BaseStore): + # Map of eventbus names to eventbus objects. The name MUST be unique per account and region (works with AccountRegionBundle) + event_buses: EventBusDict = LocalAttribute(default=dict) + + # Map of archive names to archive objects. The name MUST be unique per account and region (works with AccountRegionBundle) + archives: ArchiveDict = LocalAttribute(default=dict) + + # Map of replay names to replay objects. The name MUST be unique per account and region (works with AccountRegionBundle) + replays: ReplayDict = LocalAttribute(default=dict) + + # Map of connection names to connection objects. + connections: ConnectionDict = LocalAttribute(default=dict) + + # Map of api destination names to api destination objects + api_destinations: ApiDestinationDict = LocalAttribute(default=dict) + + # Maps resource ARN to tags + TAGS: TaggingService = CrossRegionAttribute(default=TaggingService) + + +events_stores = AccountRegionBundle("events", EventsStore) diff --git a/localstack-core/localstack/services/events/provider.py b/localstack-core/localstack/services/events/provider.py new file mode 100644 index 0000000000000..91e95b5100374 --- /dev/null +++ b/localstack-core/localstack/services/events/provider.py @@ -0,0 +1,1984 @@ +import base64 +import json +import logging +import re +from typing import Callable, Optional + +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.config import TagsList +from localstack.aws.api.events import ( + Action, + ApiDestinationDescription, + ApiDestinationHttpMethod, + ApiDestinationInvocationRateLimitPerSecond, + ApiDestinationName, + ApiDestinationResponseList, + ArchiveDescription, + ArchiveName, + ArchiveResponseList, + ArchiveState, + Arn, + Boolean, + CancelReplayResponse, + Condition, + ConnectionArn, + ConnectionAuthorizationType, + ConnectionDescription, + ConnectionName, + ConnectionResponseList, + ConnectionState, + ConnectivityResourceParameters, + CreateApiDestinationResponse, + CreateArchiveResponse, + CreateConnectionAuthRequestParameters, + CreateConnectionResponse, + CreateEventBusResponse, + DeadLetterConfig, + DeleteApiDestinationResponse, + DeleteArchiveResponse, + DeleteConnectionResponse, + DescribeApiDestinationResponse, + DescribeArchiveResponse, + DescribeConnectionResponse, + DescribeEventBusResponse, + DescribeReplayResponse, + DescribeRuleResponse, + EndpointId, + EventBusArn, + EventBusDescription, + EventBusList, + EventBusName, + EventBusNameOrArn, + EventPattern, + EventsApi, + EventSourceName, + HttpsEndpoint, + InternalException, + KmsKeyIdentifier, + LimitMax100, + ListApiDestinationsResponse, + ListArchivesResponse, + ListConnectionsResponse, + ListEventBusesResponse, + ListReplaysResponse, + ListRuleNamesByTargetResponse, + ListRulesResponse, + ListTagsForResourceResponse, + ListTargetsByRuleResponse, + NextToken, + NonPartnerEventBusName, + Principal, + PutEventsRequestEntry, + PutEventsRequestEntryList, + PutEventsResponse, + PutEventsResultEntry, + PutEventsResultEntryList, + PutPartnerEventsRequestEntryList, + PutPartnerEventsResponse, + PutRuleResponse, + PutTargetsResponse, + RemoveTargetsResponse, + ReplayDescription, + ReplayDestination, + ReplayList, + ReplayName, + ReplayState, + ResourceAlreadyExistsException, + ResourceNotFoundException, + RetentionDays, + RoleArn, + RuleDescription, + RuleName, + RuleResponseList, + RuleState, + ScheduleExpression, + StartReplayResponse, + StatementId, + String, + TagKeyList, + TagList, + TagResourceResponse, + Target, + TargetArn, + TargetId, + TargetIdList, + TargetList, + TestEventPatternResponse, + Timestamp, + UntagResourceResponse, + UpdateApiDestinationResponse, + UpdateArchiveResponse, + UpdateConnectionAuthRequestParameters, + UpdateConnectionResponse, +) +from localstack.aws.api.events import ApiDestination as ApiTypeApiDestination +from localstack.aws.api.events import Archive as ApiTypeArchive +from localstack.aws.api.events import Connection as ApiTypeConnection +from localstack.aws.api.events import EventBus as ApiTypeEventBus +from localstack.aws.api.events import Replay as ApiTypeReplay +from localstack.aws.api.events import Rule as ApiTypeRule +from localstack.services.events.api_destination import ( + APIDestinationService, + ApiDestinationServiceDict, +) +from localstack.services.events.archive import ArchiveService, ArchiveServiceDict +from localstack.services.events.connection import ( + ConnectionService, + ConnectionServiceDict, +) +from localstack.services.events.event_bus import EventBusService, EventBusServiceDict +from localstack.services.events.models import ( + ApiDestination, + ApiDestinationDict, + Archive, + ArchiveDict, + Connection, + ConnectionDict, + EventBus, + EventBusDict, + EventsStore, + FormattedEvent, + Replay, + ReplayDict, + ResourceType, + Rule, + RuleDict, + TargetDict, + ValidationException, + events_stores, +) +from localstack.services.events.replay import ReplayService, ReplayServiceDict +from localstack.services.events.rule import RuleService, RuleServiceDict +from localstack.services.events.scheduler import JobScheduler +from localstack.services.events.target import ( + TargetSender, + TargetSenderDict, + TargetSenderFactory, +) +from localstack.services.events.utils import ( + TARGET_ID_PATTERN, + extract_connection_name, + extract_event_bus_name, + extract_region_and_account_id, + format_event, + get_resource_type, + get_trace_header_encoded_region_account, + is_archive_arn, + recursive_remove_none_values_from_dict, +) +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.common import truncate +from localstack.utils.event_matcher import matches_event +from localstack.utils.strings import long_uid +from localstack.utils.time import TIMESTAMP_FORMAT_TZ, timestamp +from localstack.utils.xray.trace_header import TraceHeader + +from .analytics import InvocationStatus, rule_invocation + +LOG = logging.getLogger(__name__) + +ARCHIVE_TARGET_ID_NAME_PATTERN = re.compile(r"^Events-Archive-(?P[a-zA-Z0-9_-]+)$") + + +def decode_next_token(token: NextToken) -> int: + """Decode a pagination token from base64 to integer.""" + return int.from_bytes(base64.b64decode(token), "big") + + +def encode_next_token(token: int) -> NextToken: + """Encode a pagination token to base64 from integer.""" + return base64.b64encode(token.to_bytes(128, "big")).decode("utf-8") + + +def get_filtered_dict(name_prefix: str, input_dict: dict) -> dict: + """Filter dictionary by prefix.""" + return {name: value for name, value in input_dict.items() if name.startswith(name_prefix)} + + +def validate_event(event: PutEventsRequestEntry) -> None | PutEventsResultEntry: + if not event.get("Source"): + return { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter Source is not valid. Reason: Source is a required argument.", + } + elif not event.get("DetailType"): + return { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter DetailType is not valid. Reason: DetailType is a required argument.", + } + elif not event.get("Detail"): + return { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter Detail is not valid. Reason: Detail is a required argument.", + } + elif event.get("Detail") and len(event["Detail"]) >= 262144: + raise ValidationException("Total size of the entries in the request is over the limit.") + elif event.get("Detail"): + try: + json_detail = json.loads(event.get("Detail")) + if isinstance(json_detail, dict): + return + except json.JSONDecodeError: + pass + + return { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed.", + } + + +def check_unique_tags(tags: TagsList) -> None: + unique_tag_keys = {tag["Key"] for tag in tags} + if len(unique_tag_keys) < len(tags): + raise ValidationException("Invalid parameter: Duplicated keys are not allowed.") + + +class EventsProvider(EventsApi, ServiceLifecycleHook): + # api methods are grouped by resource type and sorted in alphabetical order + # functions in each group is sorted alphabetically + def __init__(self): + self._event_bus_services_store: EventBusServiceDict = {} + self._rule_services_store: RuleServiceDict = {} + self._target_sender_store: TargetSenderDict = {} + self._archive_service_store: ArchiveServiceDict = {} + self._replay_service_store: ReplayServiceDict = {} + self._connection_service_store: ConnectionServiceDict = {} + self._api_destination_service_store: ApiDestinationServiceDict = {} + + def on_before_start(self): + JobScheduler.start() + + def on_before_stop(self): + JobScheduler.shutdown() + + ################## + # API Destinations + ################## + @handler("CreateApiDestination") + def create_api_destination( + self, + context: RequestContext, + name: ApiDestinationName, + connection_arn: ConnectionArn, + invocation_endpoint: HttpsEndpoint, + http_method: ApiDestinationHttpMethod, + description: ApiDestinationDescription = None, + invocation_rate_limit_per_second: ApiDestinationInvocationRateLimitPerSecond = None, + **kwargs, + ) -> CreateApiDestinationResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if name in store.api_destinations: + raise ResourceAlreadyExistsException(f"An api-destination '{name}' already exists.") + APIDestinationService.validate_input(name, connection_arn, http_method, invocation_endpoint) + connection_name = extract_connection_name(connection_arn) + connection = self.get_connection(connection_name, store) + api_destination_service = self.create_api_destinations_service( + name, + region, + account_id, + connection_arn, + connection, + invocation_endpoint, + http_method, + invocation_rate_limit_per_second, + description, + ) + store.api_destinations[api_destination_service.api_destination.name] = ( + api_destination_service.api_destination + ) + + response = CreateApiDestinationResponse( + ApiDestinationArn=api_destination_service.arn, + ApiDestinationState=api_destination_service.state, + CreationTime=api_destination_service.creation_time, + LastModifiedTime=api_destination_service.last_modified_time, + ) + return response + + @handler("DescribeApiDestination") + def describe_api_destination( + self, context: RequestContext, name: ApiDestinationName, **kwargs + ) -> DescribeApiDestinationResponse: + store = self.get_store(context.region, context.account_id) + api_destination = self.get_api_destination(name, store) + + response = self._api_destination_to_api_type_api_destination(api_destination) + return response + + @handler("DeleteApiDestination") + def delete_api_destination( + self, context: RequestContext, name: ApiDestinationName, **kwargs + ) -> DeleteApiDestinationResponse: + store = self.get_store(context.region, context.account_id) + if api_destination := self.get_api_destination(name, store): + del self._api_destination_service_store[api_destination.arn] + del store.api_destinations[name] + del store.TAGS[api_destination.arn] + + return DeleteApiDestinationResponse() + + @handler("ListApiDestinations") + def list_api_destinations( + self, + context: RequestContext, + name_prefix: ApiDestinationName = None, + connection_arn: ConnectionArn = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListApiDestinationsResponse: + store = self.get_store(context.region, context.account_id) + api_destinations = ( + get_filtered_dict(name_prefix, store.api_destinations) + if name_prefix + else store.api_destinations + ) + limited_rules, next_token = self._get_limited_dict_and_next_token( + api_destinations, next_token, limit + ) + + response = ListApiDestinationsResponse( + ApiDestinations=list( + self._api_destination_dict_to_api_destination_response_list(limited_rules) + ) + ) + if next_token is not None: + response["NextToken"] = next_token + return response + + @handler("UpdateApiDestination") + def update_api_destination( + self, + context: RequestContext, + name: ApiDestinationName, + description: ApiDestinationDescription = None, + connection_arn: ConnectionArn = None, + invocation_endpoint: HttpsEndpoint = None, + http_method: ApiDestinationHttpMethod = None, + invocation_rate_limit_per_second: ApiDestinationInvocationRateLimitPerSecond = None, + **kwargs, + ) -> UpdateApiDestinationResponse: + store = self.get_store(context.region, context.account_id) + api_destination = self.get_api_destination(name, store) + api_destination_service = self._api_destination_service_store[api_destination.arn] + if connection_arn: + connection_name = extract_connection_name(connection_arn) + connection = self.get_connection(connection_name, store) + else: + connection = api_destination_service.connection + api_destination_service.update( + connection, + invocation_endpoint, + http_method, + invocation_rate_limit_per_second, + description, + ) + + response = UpdateApiDestinationResponse( + ApiDestinationArn=api_destination_service.arn, + ApiDestinationState=api_destination_service.state, + CreationTime=api_destination_service.creation_time, + LastModifiedTime=api_destination_service.last_modified_time, + ) + return response + + ############# + # Connections + ############# + @handler("CreateConnection") + def create_connection( + self, + context: RequestContext, + name: ConnectionName, + authorization_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters, + description: ConnectionDescription = None, + invocation_connectivity_parameters: ConnectivityResourceParameters = None, + kms_key_identifier: KmsKeyIdentifier = None, + **kwargs, + ) -> CreateConnectionResponse: + # TODO add support for kms_key_identifier + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if name in store.connections: + raise ResourceAlreadyExistsException(f"Connection {name} already exists.") + connection_service = self.create_connection_service( + name, + region, + account_id, + authorization_type, + auth_parameters, + description, + invocation_connectivity_parameters, + ) + store.connections[connection_service.connection.name] = connection_service.connection + + response = CreateConnectionResponse( + ConnectionArn=connection_service.arn, + ConnectionState=connection_service.state, + CreationTime=connection_service.creation_time, + LastModifiedTime=connection_service.last_modified_time, + ) + return response + + @handler("DescribeConnection") + def describe_connection( + self, context: RequestContext, name: ConnectionName, **kwargs + ) -> DescribeConnectionResponse: + store = self.get_store(context.region, context.account_id) + connection = self.get_connection(name, store) + + response = self._connection_to_api_type_connection(connection) + return response + + @handler("DeleteConnection") + def delete_connection( + self, context: RequestContext, name: ConnectionName, **kwargs + ) -> DeleteConnectionResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if connection := self.get_connection(name, store): + connection_service = self._connection_service_store.pop(connection.arn) + connection_service.delete() + del store.connections[name] + del store.TAGS[connection.arn] + + response = DeleteConnectionResponse( + ConnectionArn=connection.arn, + ConnectionState=connection.state, + CreationTime=connection.creation_time, + LastModifiedTime=connection.last_modified_time, + LastAuthorizedTime=connection.last_authorized_time, + ) + return response + + @handler("ListConnections") + def list_connections( + self, + context: RequestContext, + name_prefix: ConnectionName = None, + connection_state: ConnectionState = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListConnectionsResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + connections = ( + get_filtered_dict(name_prefix, store.connections) if name_prefix else store.connections + ) + limited_rules, next_token = self._get_limited_dict_and_next_token( + connections, next_token, limit + ) + + response = ListConnectionsResponse( + Connections=list(self._connection_dict_to_connection_response_list(limited_rules)) + ) + if next_token is not None: + response["NextToken"] = next_token + return response + + @handler("UpdateConnection") + def update_connection( + self, + context: RequestContext, + name: ConnectionName, + description: ConnectionDescription = None, + authorization_type: ConnectionAuthorizationType = None, + auth_parameters: UpdateConnectionAuthRequestParameters = None, + invocation_connectivity_parameters: ConnectivityResourceParameters = None, + kms_key_identifier: KmsKeyIdentifier = None, + **kwargs, + ) -> UpdateConnectionResponse: + # TODO add support for kms_key_identifier + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + connection = self.get_connection(name, store) + connection_service = self._connection_service_store[connection.arn] + connection_service.update( + description, authorization_type, auth_parameters, invocation_connectivity_parameters + ) + + response = UpdateConnectionResponse( + ConnectionArn=connection_service.arn, + ConnectionState=connection_service.state, + CreationTime=connection_service.creation_time, + LastModifiedTime=connection_service.last_modified_time, + LastAuthorizedTime=connection_service.last_authorized_time, + ) + return response + + ########## + # EventBus + ########## + + @handler("CreateEventBus") + def create_event_bus( + self, + context: RequestContext, + name: EventBusName, + event_source_name: EventSourceName = None, + description: EventBusDescription = None, + kms_key_identifier: KmsKeyIdentifier = None, + dead_letter_config: DeadLetterConfig = None, + tags: TagList = None, + **kwargs, + ) -> CreateEventBusResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if name in store.event_buses: + raise ResourceAlreadyExistsException(f"Event bus {name} already exists.") + event_bus_service = self.create_event_bus_service( + name, region, account_id, event_source_name, description, tags + ) + store.event_buses[event_bus_service.event_bus.name] = event_bus_service.event_bus + + if tags: + self.tag_resource(context, event_bus_service.arn, tags) + + response = CreateEventBusResponse( + EventBusArn=event_bus_service.arn, + ) + if description := getattr(event_bus_service.event_bus, "description", None): + response["Description"] = description + return response + + @handler("DeleteEventBus") + def delete_event_bus(self, context: RequestContext, name: EventBusName, **kwargs) -> None: + if name == "default": + raise ValidationException("Cannot delete event bus default.") + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + try: + if event_bus := self.get_event_bus(name, store): + del self._event_bus_services_store[event_bus.arn] + if rules := event_bus.rules: + self._delete_rule_services(rules) + del store.event_buses[name] + del store.TAGS[event_bus.arn] + except ResourceNotFoundException as error: + return error + + @handler("DescribeEventBus") + def describe_event_bus( + self, context: RequestContext, name: EventBusNameOrArn = None, **kwargs + ) -> DescribeEventBusResponse: + name = extract_event_bus_name(name) + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus = self.get_event_bus(name, store) + + response = self._event_bus_to_api_type_event_bus(event_bus) + return response + + @handler("ListEventBuses") + def list_event_buses( + self, + context: RequestContext, + name_prefix: EventBusName = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListEventBusesResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_buses = ( + get_filtered_dict(name_prefix, store.event_buses) if name_prefix else store.event_buses + ) + limited_event_buses, next_token = self._get_limited_dict_and_next_token( + event_buses, next_token, limit + ) + + response = ListEventBusesResponse( + EventBuses=self._event_bust_dict_to_event_bus_response_list(limited_event_buses) + ) + if next_token is not None: + response["NextToken"] = next_token + return response + + @handler("PutPermission") + def put_permission( + self, + context: RequestContext, + event_bus_name: NonPartnerEventBusName = None, + action: Action = None, + principal: Principal = None, + statement_id: StatementId = None, + condition: Condition = None, + policy: String = None, + **kwargs, + ) -> None: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus = self.get_event_bus(event_bus_name, store) + event_bus_service = self._event_bus_services_store[event_bus.arn] + event_bus_service.put_permission(action, principal, statement_id, condition, policy) + + @handler("RemovePermission") + def remove_permission( + self, + context: RequestContext, + statement_id: StatementId = None, + remove_all_permissions: Boolean = None, + event_bus_name: NonPartnerEventBusName = None, + **kwargs, + ) -> None: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus = self.get_event_bus(event_bus_name, store) + event_bus_service = self._event_bus_services_store[event_bus.arn] + if remove_all_permissions: + event_bus_service.event_bus.policy = None + return + if not statement_id: + raise ValidationException("Parameter StatementId is required.") + event_bus_service.revoke_put_events_permission(statement_id) + + ####### + # Rules + ####### + @handler("EnableRule") + def enable_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> None: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + rule = self.get_rule(name, event_bus) + rule.state = RuleState.ENABLED + + @handler("DeleteRule") + def delete_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn = None, + force: Boolean = None, + **kwargs, + ) -> None: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + try: + rule = self.get_rule(name, event_bus) + if rule.targets and not force: + raise ValidationException("Rule can't be deleted since it has targets.") + self._delete_rule_services(rule) + del event_bus.rules[name] + del store.TAGS[rule.arn] + except ResourceNotFoundException as error: + return error + + @handler("DescribeRule") + def describe_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> DescribeRuleResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + rule = self.get_rule(name, event_bus) + + response = self._rule_to_api_type_rule(rule) + return response + + @handler("DisableRule") + def disable_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> None: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + rule = self.get_rule(name, event_bus) + rule.state = RuleState.DISABLED + + @handler("ListRules") + def list_rules( + self, + context: RequestContext, + name_prefix: RuleName = None, + event_bus_name: EventBusNameOrArn = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListRulesResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + rules = get_filtered_dict(name_prefix, event_bus.rules) if name_prefix else event_bus.rules + limited_rules, next_token = self._get_limited_dict_and_next_token(rules, next_token, limit) + + response = ListRulesResponse( + Rules=list(self._rule_dict_to_rule_response_list(limited_rules)) + ) + if next_token is not None: + response["NextToken"] = next_token + return response + + @handler("ListRuleNamesByTarget") + def list_rule_names_by_target( + self, + context: RequestContext, + target_arn: TargetArn, + event_bus_name: EventBusNameOrArn = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListRuleNamesByTargetResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + + # Find all rules that have a target with the specified ARN + matching_rule_names = [] + for rule_name, rule in event_bus.rules.items(): + for target_id, target in rule.targets.items(): + if target["Arn"] == target_arn: + matching_rule_names.append(rule_name) + break # Found a match in this rule, no need to check other targets + + limited_rules, next_token = self._get_limited_list_and_next_token( + matching_rule_names, next_token, limit + ) + + response = ListRuleNamesByTargetResponse(RuleNames=limited_rules) + if next_token is not None: + response["NextToken"] = next_token + + return response + + @handler("PutRule") + def put_rule( + self, + context: RequestContext, + name: RuleName, + schedule_expression: ScheduleExpression = None, + event_pattern: EventPattern = None, + state: RuleState = None, + description: RuleDescription = None, + role_arn: RoleArn = None, + tags: TagList = None, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> PutRuleResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + existing_rule = event_bus.rules.get(name) + targets = existing_rule.targets if existing_rule else None + rule_service = self.create_rule_service( + name, + region, + account_id, + schedule_expression, + event_pattern, + state, + description, + role_arn, + tags, + event_bus_name, + targets, + ) + event_bus.rules[name] = rule_service.rule + + if tags: + self.tag_resource(context, rule_service.arn, tags) + + response = PutRuleResponse(RuleArn=rule_service.arn) + return response + + @handler("TestEventPattern") + def test_event_pattern( + self, context: RequestContext, event_pattern: EventPattern, event: str, **kwargs + ) -> TestEventPatternResponse: + """Test event pattern uses EventBridge event pattern matching: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html + """ + try: + json_event = json.loads(event) + except json.JSONDecodeError: + raise ValidationException("Parameter Event is not valid.") + + mandatory_fields = { + "id", + "account", + "source", + "time", + "region", + "detail-type", + } + # https://docs.aws.amazon.com/eventbridge/latest/APIReference/API_TestEventPattern.html + # the documentation says that `resources` is mandatory, but it is not in reality + + if not isinstance(json_event, dict) or not mandatory_fields.issubset(json_event): + raise ValidationException("Parameter Event is not valid.") + + result = matches_event(event_pattern, event) + return TestEventPatternResponse(Result=result) + + ######### + # Targets + ######### + + @handler("ListTargetsByRule") + def list_targets_by_rule( + self, + context: RequestContext, + rule: RuleName, + event_bus_name: EventBusNameOrArn = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListTargetsByRuleResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + rule = self.get_rule(rule, event_bus) + targets = rule.targets + limited_targets, next_token = self._get_limited_dict_and_next_token( + targets, next_token, limit + ) + + response = ListTargetsByRuleResponse(Targets=list(limited_targets.values())) + if next_token is not None: + response["NextToken"] = next_token + return response + + @handler("PutTargets") + def put_targets( + self, + context: RequestContext, + rule: RuleName, + targets: TargetList, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> PutTargetsResponse: + region = context.region + account_id = context.account_id + rule_service = self.get_rule_service(region, account_id, rule, event_bus_name) + failed_entries = rule_service.add_targets(targets) + rule_arn = rule_service.arn + rule_name = rule_service.rule.name + for index, target in enumerate(targets): # TODO only add successful targets + target_id = target["Id"] + if len(target_id) > 64: + raise ValidationException( + rf"1 validation error detected: Value '{target_id}' at 'targets.{index + 1}.member.id' failed to satisfy constraint: Member must have length less than or equal to 64" + ) + if not bool(TARGET_ID_PATTERN.match(target_id)): + raise ValidationException( + rf"1 validation error detected: Value '{target_id}' at 'targets.{index + 1}.member.id' failed to satisfy constraint: Member must satisfy regular expression pattern: [\.\-_A-Za-z0-9]+" + ) + self.create_target_sender(target, rule_arn, rule_name, region, account_id) + + if rule_service.schedule_cron: + schedule_job_function = self._get_scheduled_rule_job_function( + account_id, region, rule_service.rule + ) + rule_service.create_schedule_job(schedule_job_function) + response = PutTargetsResponse( + FailedEntryCount=len(failed_entries), FailedEntries=failed_entries + ) + return response + + @handler("RemoveTargets") + def remove_targets( + self, + context: RequestContext, + rule: RuleName, + ids: TargetIdList, + event_bus_name: EventBusNameOrArn = None, + force: Boolean = None, + **kwargs, + ) -> RemoveTargetsResponse: + region = context.region + account_id = context.account_id + rule_service = self.get_rule_service(region, account_id, rule, event_bus_name) + failed_entries = rule_service.remove_targets(ids) + self._delete_target_sender(ids, rule_service.rule) + + response = RemoveTargetsResponse( + FailedEntryCount=len(failed_entries), FailedEntries=failed_entries + ) + return response + + ######### + # Archive + ######### + @handler("CreateArchive") + def create_archive( + self, + context: RequestContext, + archive_name: ArchiveName, + event_source_arn: EventBusArn, + description: ArchiveDescription = None, + event_pattern: EventPattern = None, + retention_days: RetentionDays = None, + kms_key_identifier: KmsKeyIdentifier = None, + **kwargs, + ) -> CreateArchiveResponse: + # TODO add support for kms_key_identifier + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if archive_name in store.archives: + raise ResourceAlreadyExistsException(f"Archive {archive_name} already exists.") + self._check_event_bus_exists(event_source_arn, store) + archive_service = self.create_archive_service( + archive_name, + region, + account_id, + event_source_arn, + description, + event_pattern, + retention_days, + ) + store.archives[archive_service.archive.name] = archive_service.archive + + response = CreateArchiveResponse( + ArchiveArn=archive_service.arn, + State=archive_service.state, + CreationTime=archive_service.creation_time, + ) + return response + + @handler("DeleteArchive") + def delete_archive( + self, context: RequestContext, archive_name: ArchiveName, **kwargs + ) -> DeleteArchiveResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if archive := self.get_archive(archive_name, store): + try: + archive_service = self._archive_service_store.pop(archive.arn) + archive_service.delete() + del store.archives[archive_name] + except ResourceNotFoundException as error: + return error + + @handler("DescribeArchive") + def describe_archive( + self, context: RequestContext, archive_name: ArchiveName, **kwargs + ) -> DescribeArchiveResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + archive = self.get_archive(archive_name, store) + + response = self._archive_to_describe_archive_response(archive) + return response + + @handler("ListArchives") + def list_archives( + self, + context: RequestContext, + name_prefix: ArchiveName = None, + event_source_arn: Arn = None, + state: ArchiveState = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListArchivesResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if event_source_arn: + self._check_event_bus_exists(event_source_arn, store) + archives = { + key: archive + for key, archive in store.archives.items() + if archive.event_source_arn == event_source_arn + } + elif name_prefix: + archives = get_filtered_dict(name_prefix, store.archives) + else: + archives = store.archives + limited_archives, next_token = self._get_limited_dict_and_next_token( + archives, next_token, limit + ) + + response = ListArchivesResponse( + Archives=list(self._archive_dict_to_archive_response_list(limited_archives)) + ) + if next_token is not None: + response["NextToken"] = next_token + return response + + @handler("UpdateArchive") + def update_archive( + self, + context: RequestContext, + archive_name: ArchiveName, + description: ArchiveDescription = None, + event_pattern: EventPattern = None, + retention_days: RetentionDays = None, + kms_key_identifier: KmsKeyIdentifier = None, + **kwargs, + ) -> UpdateArchiveResponse: + # TODO add support for kms_key_identifier + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + try: + archive = self.get_archive(archive_name, store) + except ResourceNotFoundException: + raise InternalException("Service encountered unexpected problem. Please try again.") + archive_service = self._archive_service_store[archive.arn] + archive_service.update(description, event_pattern, retention_days) + + response = UpdateArchiveResponse( + ArchiveArn=archive_service.arn, + State=archive.state, + # StateReason=archive.state_reason, + CreationTime=archive.creation_time, + ) + return response + + ######## + # Events + ######## + + @handler("PutEvents") + def put_events( + self, + context: RequestContext, + entries: PutEventsRequestEntryList, + endpoint_id: EndpointId = None, + **kwargs, + ) -> PutEventsResponse: + if len(entries) > 10: + formatted_entries = [self._event_to_error_type_event(entry) for entry in entries] + formatted_entries = f"[{', '.join(formatted_entries)}]" + raise ValidationException( + f"1 validation error detected: Value '{formatted_entries}' at 'entries' failed to satisfy constraint: Member must have length less than or equal to 10" + ) + entries, failed_entry_count = self._process_entries(context, entries) + + response = PutEventsResponse( + Entries=entries, + FailedEntryCount=failed_entry_count, + ) + return response + + @handler("PutPartnerEvents") + def put_partner_events( + self, + context: RequestContext, + entries: PutPartnerEventsRequestEntryList, + **kwargs, + ) -> PutPartnerEventsResponse: + raise NotImplementedError + + ######## + # Replay + ######## + + @handler("CancelReplay") + def cancel_replay( + self, context: RequestContext, replay_name: ReplayName, **kwargs + ) -> CancelReplayResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + replay = self.get_replay(replay_name, store) + replay_service = self._replay_service_store[replay.arn] + replay_service.stop() + response = CancelReplayResponse( + ReplayArn=replay_service.arn, + State=replay_service.state, + # StateReason=replay_service.state_reason, + ) + return response + + @handler("DescribeReplay") + def describe_replay( + self, context: RequestContext, replay_name: ReplayName, **kwargs + ) -> DescribeReplayResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + replay = self.get_replay(replay_name, store) + + response = self._replay_to_describe_replay_response(replay) + return response + + @handler("ListReplays") + def list_replays( + self, + context: RequestContext, + name_prefix: ReplayName = None, + state: ReplayState = None, + event_source_arn: Arn = None, + next_token: NextToken = None, + limit: LimitMax100 = None, + **kwargs, + ) -> ListReplaysResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if event_source_arn: + replays = { + key: replay + for key, replay in store.replays.items() + if replay.event_source_arn == event_source_arn + } + elif name_prefix: + replays = get_filtered_dict(name_prefix, store.replays) + else: + replays = store.replays + limited_replays, next_token = self._get_limited_dict_and_next_token( + replays, next_token, limit + ) + + response = ListReplaysResponse( + Replays=list(self._replay_dict_to_replay_response_list(limited_replays)) + ) + if next_token is not None: + response["NextToken"] = next_token + return response + + @handler("StartReplay") + def start_replay( + self, + context: RequestContext, + replay_name: ReplayName, + event_source_arn: Arn, # Archive Arn + event_start_time: Timestamp, + event_end_time: Timestamp, + destination: ReplayDestination, + description: ReplayDescription = None, + **kwargs, + ) -> StartReplayResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + if replay_name in store.replays: + raise ResourceAlreadyExistsException(f"Replay {replay_name} already exists.") + self._validate_replay_time(event_start_time, event_end_time) + if event_source_arn not in self._archive_service_store: + archive_name = event_source_arn.split("/")[-1] + raise ValidationException( + f"Parameter EventSourceArn is not valid. Reason: Archive {archive_name} does not exist." + ) + self._validate_replay_destination(destination, event_source_arn) + replay_service = self.create_replay_service( + replay_name, + region, + account_id, + event_source_arn, + destination, + event_start_time, + event_end_time, + description, + ) + store.replays[replay_service.replay.name] = replay_service.replay + archive_service = self._archive_service_store[event_source_arn] + events_to_replay = archive_service.get_events( + replay_service.event_start_time, replay_service.event_end_time + ) + replay_service.start(events_to_replay) + if events_to_replay: + re_formatted_event_to_replay = replay_service.re_format_events_from_archive( + events_to_replay, replay_name + ) + self._process_entries(context, re_formatted_event_to_replay) + replay_service.finish() + + response = StartReplayResponse( + ReplayArn=replay_service.arn, + State=replay_service.state, + StateReason=replay_service.state_reason, + ReplayStartTime=replay_service.replay_start_time, + ) + return response + + ###### + # Tags + ###### + + @handler("ListTagsForResource") + def list_tags_for_resource( + self, context: RequestContext, resource_arn: Arn, **kwargs + ) -> ListTagsForResourceResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + resource_type = get_resource_type(resource_arn) + self._check_resource_exists(resource_arn, resource_type, store) + tags = store.TAGS.list_tags_for_resource(resource_arn) + return ListTagsForResourceResponse(tags) + + @handler("TagResource") + def tag_resource( + self, context: RequestContext, resource_arn: Arn, tags: TagList, **kwargs + ) -> TagResourceResponse: + # each tag key must be unique + # https://docs.aws.amazon.com/general/latest/gr/aws_tagging.html#tag-best-practices + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + resource_type = get_resource_type(resource_arn) + self._check_resource_exists(resource_arn, resource_type, store) + check_unique_tags(tags) + store.TAGS.tag_resource(resource_arn, tags) + + @handler("UntagResource") + def untag_resource( + self, context: RequestContext, resource_arn: Arn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceResponse: + region = context.region + account_id = context.account_id + store = self.get_store(region, account_id) + resource_type = get_resource_type(resource_arn) + self._check_resource_exists(resource_arn, resource_type, store) + store.TAGS.untag_resource(resource_arn, tag_keys) + + ######### + # Methods + ######### + + def get_store(self, region: str, account_id: str) -> EventsStore: + """Returns the events store for the account and region. + On first call, creates the default event bus for the account region.""" + store = events_stores[account_id][region] + # create default event bus for account region on first call + default_event_bus_name = "default" + if default_event_bus_name not in store.event_buses: + event_bus_service = self.create_event_bus_service( + default_event_bus_name, region, account_id, None, None, None + ) + store.event_buses[event_bus_service.event_bus.name] = event_bus_service.event_bus + return store + + def get_event_bus(self, name: EventBusName, store: EventsStore) -> EventBus: + if event_bus := store.event_buses.get(name): + return event_bus + raise ResourceNotFoundException(f"Event bus {name} does not exist.") + + def get_rule(self, name: RuleName, event_bus: EventBus) -> Rule: + if rule := event_bus.rules.get(name): + return rule + raise ResourceNotFoundException(f"Rule {name} does not exist on EventBus {event_bus.name}.") + + def get_target(self, target_id: TargetId, rule: Rule) -> Target: + if target := rule.targets.get(target_id): + return target + raise ResourceNotFoundException(f"Target {target_id} does not exist on Rule {rule.name}.") + + def get_archive(self, name: ArchiveName, store: EventsStore) -> Archive: + if archive := store.archives.get(name): + return archive + raise ResourceNotFoundException(f"Archive {name} does not exist.") + + def get_replay(self, name: ReplayName, store: EventsStore) -> Replay: + if replay := store.replays.get(name): + return replay + raise ResourceNotFoundException(f"Replay {name} does not exist.") + + def get_connection(self, name: ConnectionName, store: EventsStore) -> Connection: + if connection := store.connections.get(name): + return connection + raise ResourceNotFoundException( + f"Failed to describe the connection(s). Connection '{name}' does not exist." + ) + + def get_api_destination(self, name: ApiDestinationName, store: EventsStore) -> ApiDestination: + if api_destination := store.api_destinations.get(name): + return api_destination + raise ResourceNotFoundException( + f"Failed to describe the api-destination(s). An api-destination '{name}' does not exist." + ) + + def get_rule_service( + self, + region: str, + account_id: str, + rule_name: RuleName, + event_bus_name: EventBusName, + ) -> RuleService: + store = self.get_store(region, account_id) + event_bus_name = extract_event_bus_name(event_bus_name) + event_bus = self.get_event_bus(event_bus_name, store) + rule = self.get_rule(rule_name, event_bus) + return self._rule_services_store[rule.arn] + + def create_event_bus_service( + self, + name: EventBusName, + region: str, + account_id: str, + event_source_name: Optional[EventSourceName], + description: Optional[EventBusDescription], + tags: Optional[TagList], + ) -> EventBusService: + event_bus_service = EventBusService.create_event_bus_service( + name, + region, + account_id, + event_source_name, + description, + tags, + ) + self._event_bus_services_store[event_bus_service.arn] = event_bus_service + return event_bus_service + + def create_rule_service( + self, + name: RuleName, + region: str, + account_id: str, + schedule_expression: Optional[ScheduleExpression], + event_pattern: Optional[EventPattern], + state: Optional[RuleState], + description: Optional[RuleDescription], + role_arn: Optional[RoleArn], + tags: Optional[TagList], + event_bus_name: Optional[EventBusName], + targets: Optional[TargetDict], + ) -> RuleService: + rule_service = RuleService.create_rule_service( + name, + region, + account_id, + schedule_expression, + event_pattern, + state, + description, + role_arn, + tags, + event_bus_name, + targets, + ) + self._rule_services_store[rule_service.arn] = rule_service + return rule_service + + def create_target_sender( + self, target: Target, rule_arn: Arn, rule_name: RuleName, region: str, account_id: str + ) -> TargetSender: + target_sender = TargetSenderFactory( + target, rule_arn, rule_name, region, account_id + ).get_target_sender() + self._target_sender_store[target_sender.unique_id] = target_sender + return target_sender + + def create_archive_service( + self, + archive_name: ArchiveName, + region: str, + account_id: str, + event_source_arn: Arn, + description: ArchiveDescription, + event_pattern: EventPattern, + retention_days: RetentionDays, + ) -> ArchiveService: + archive_service = ArchiveService.create_archive_service( + archive_name, + region, + account_id, + event_source_arn, + description, + event_pattern, + retention_days, + ) + archive_service.register_archive_rule_and_targets() + self._archive_service_store[archive_service.arn] = archive_service + return archive_service + + def create_replay_service( + self, + name: ReplayName, + region: str, + account_id: str, + event_source_arn: Arn, + destination: ReplayDestination, + event_start_time: Timestamp, + event_end_time: Timestamp, + description: ReplayDescription, + ) -> ReplayService: + replay_service = ReplayService( + name, + region, + account_id, + event_source_arn, + destination, + event_start_time, + event_end_time, + description, + ) + self._replay_service_store[replay_service.arn] = replay_service + return replay_service + + def create_connection_service( + self, + name: ConnectionName, + region: str, + account_id: str, + authorization_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters, + description: ConnectionDescription, + invocation_connectivity_parameters: ConnectivityResourceParameters, + ) -> ConnectionService: + connection_service = ConnectionService( + name, + region, + account_id, + authorization_type, + auth_parameters, + description, + invocation_connectivity_parameters, + ) + self._connection_service_store[connection_service.arn] = connection_service + return connection_service + + def create_api_destinations_service( + self, + name: ConnectionName, + region: str, + account_id: str, + connection_arn: ConnectionArn, + connection: Connection, + invocation_endpoint: HttpsEndpoint, + http_method: ApiDestinationHttpMethod, + invocation_rate_limit_per_second: ApiDestinationInvocationRateLimitPerSecond, + description: ApiDestinationDescription, + ) -> APIDestinationService: + api_destination_service = APIDestinationService( + name, + region, + account_id, + connection_arn, + connection, + invocation_endpoint, + http_method, + invocation_rate_limit_per_second, + description, + ) + self._api_destination_service_store[api_destination_service.arn] = api_destination_service + return api_destination_service + + def _delete_connection(self, connection_arn: Arn) -> None: + del self._connection_service_store[connection_arn] + + def _delete_rule_services(self, rules: RuleDict | Rule) -> None: + """ + Delete all rule services associated to the input from the store. + Accepts a single Rule object or a dict of Rule objects as input. + """ + if isinstance(rules, Rule): + rules = {rules.name: rules} + for rule in rules.values(): + del self._rule_services_store[rule.arn] + + def _delete_target_sender(self, ids: TargetIdList, rule) -> None: + for target_id in ids: + if target := rule.targets.get(target_id): + target_unique_id = f"{rule.arn}-{target_id}" + try: + del self._target_sender_store[target_unique_id] + except KeyError: + LOG.error("Error deleting target service %s.", target["Arn"]) + + def _get_limited_dict_and_next_token( + self, input_dict: dict, next_token: NextToken | None, limit: LimitMax100 | None + ) -> tuple[dict, NextToken]: + """Return a slice of the given dictionary starting from next_token with length of limit + and new last index encoded as a next_token for pagination.""" + input_dict_len = len(input_dict) + start_index = decode_next_token(next_token) if next_token is not None else 0 + end_index = start_index + limit if limit is not None else input_dict_len + limited_dict = dict(list(input_dict.items())[start_index:end_index]) + + next_token = ( + encode_next_token(end_index) + # return a next_token (encoded integer of next starting index) if not all items are returned + if end_index < input_dict_len + else None + ) + return limited_dict, next_token + + def _get_limited_list_and_next_token( + self, input_list: list, next_token: NextToken | None, limit: LimitMax100 | None + ) -> tuple[list, NextToken]: + """Return a slice of the given list starting from next_token with length of limit + and new last index encoded as a next_token for pagination.""" + input_list_len = len(input_list) + start_index = decode_next_token(next_token) if next_token is not None else 0 + end_index = start_index + limit if limit is not None else input_list_len + limited_list = input_list[start_index:end_index] + + next_token = ( + encode_next_token(end_index) + # return a next_token (encoded integer of next starting index) if not all items are returned + if end_index < input_list_len + else None + ) + return limited_list, next_token + + def _check_resource_exists( + self, resource_arn: Arn, resource_type: ResourceType, store: EventsStore + ) -> None: + if resource_type == ResourceType.EVENT_BUS: + event_bus_name = extract_event_bus_name(resource_arn) + self.get_event_bus(event_bus_name, store) + if resource_type == ResourceType.RULE: + event_bus_name = extract_event_bus_name(resource_arn) + event_bus = self.get_event_bus(event_bus_name, store) + rule_name = resource_arn.split("/")[-1] + self.get_rule(rule_name, event_bus) + + def _get_scheduled_rule_job_function(self, account_id, region, rule: Rule) -> Callable: + def func(*args, **kwargs): + """Create custom scheduled event and send it to all targets specified by associated rule using respective TargetSender""" + for target in rule.targets.values(): + if custom_input := target.get("Input"): + event = json.loads(custom_input) + else: + event = { + "version": "0", + "id": long_uid(), + "detail-type": "Scheduled Event", + "source": "aws.events", + "account": account_id, + "time": timestamp(format=TIMESTAMP_FORMAT_TZ), + "region": region, + "resources": [rule.arn], + "detail": {}, + } + target_unique_id = f"{rule.arn}-{target['Id']}" + target_sender = self._target_sender_store[target_unique_id] + new_trace_header = ( + TraceHeader().ensure_root_exists() + ) # scheduled events will always start a new trace + try: + target_sender.process_event(event.copy(), trace_header=new_trace_header) + except Exception as e: + LOG.info( + "Unable to send event notification %s to target %s: %s", + truncate(event), + target, + e, + ) + + return func + + def _check_event_bus_exists( + self, event_bus_name_or_arn: EventBusNameOrArn, store: EventsStore + ) -> None: + event_bus_name = extract_event_bus_name(event_bus_name_or_arn) + self.get_event_bus(event_bus_name, store) + + def _validate_replay_time(self, event_start_time: Timestamp, event_end_time: Timestamp) -> None: + if event_end_time <= event_start_time: + raise ValidationException( + "Parameter EventEndTime is not valid. Reason: EventStartTime must be before EventEndTime." + ) + + def _validate_replay_destination( + self, destination: ReplayDestination, event_source_arn: Arn + ) -> None: + archive_service = self._archive_service_store[event_source_arn] + if destination_arn := destination.get("Arn"): + if destination_arn != archive_service.archive.event_source_arn: + if destination_arn in self._event_bus_services_store: + raise ValidationException( + "Parameter Destination.Arn is not valid. Reason: Cross event bus replay is not permitted." + ) + else: + event_bus_name = extract_event_bus_name(destination_arn) + raise ResourceNotFoundException(f"Event bus {event_bus_name} does not exist.") + + # Internal type to API type remappings + + def _event_bust_dict_to_event_bus_response_list( + self, event_buses: EventBusDict + ) -> EventBusList: + """Return a converted dict of EventBus model objects as a list of event buses in API type EventBus format.""" + event_bus_list = [ + self._event_bus_to_api_type_event_bus(event_bus) for event_bus in event_buses.values() + ] + return event_bus_list + + def _event_bus_to_api_type_event_bus(self, event_bus: EventBus) -> ApiTypeEventBus: + event_bus_api_type = { + "Name": event_bus.name, + "Arn": event_bus.arn, + } + if getattr(event_bus, "description", None): + event_bus_api_type["Description"] = event_bus.description + if event_bus.creation_time: + event_bus_api_type["CreationTime"] = event_bus.creation_time + if event_bus.last_modified_time: + event_bus_api_type["LastModifiedTime"] = event_bus.last_modified_time + if event_bus.policy: + event_bus_api_type["Policy"] = json.dumps( + recursive_remove_none_values_from_dict(event_bus.policy) + ) + + return event_bus_api_type + + def _event_to_error_type_event(self, entry: PutEventsRequestEntry) -> str: + detail = ( + json.dumps(json.loads(entry["Detail"]), separators=(", ", ": ")) + if entry.get("Detail") + else "null" + ) + return ( + f"PutEventsRequestEntry(" + f"time={entry.get('Time', 'null')}, " + f"source={entry.get('Source', 'null')}, " + f"resources={entry.get('Resources', 'null')}, " + f"detailType={entry.get('DetailType', 'null')}, " + f"detail={detail}, " + f"eventBusName={entry.get('EventBusName', 'null')}, " + f"traceHeader={entry.get('TraceHeader', 'null')}, " + f"kmsKeyIdentifier={entry.get('kmsKeyIdentifier', 'null')}, " + f"internalMetadata={entry.get('internalMetadata', 'null')}" + f")" + ) + + def _rule_dict_to_rule_response_list(self, rules: RuleDict) -> RuleResponseList: + """Return a converted dict of Rule model objects as a list of rules in API type Rule format.""" + rule_list = [self._rule_to_api_type_rule(rule) for rule in rules.values()] + return rule_list + + def _rule_to_api_type_rule(self, rule: Rule) -> ApiTypeRule: + rule = { + "Name": rule.name, + "Arn": rule.arn, + "EventPattern": rule.event_pattern, + "State": rule.state, + "Description": rule.description, + "ScheduleExpression": rule.schedule_expression, + "RoleArn": rule.role_arn, + "ManagedBy": rule.managed_by, + "EventBusName": rule.event_bus_name, + "CreatedBy": rule.created_by, + } + return {key: value for key, value in rule.items() if value is not None} + + def _archive_dict_to_archive_response_list(self, archives: ArchiveDict) -> ArchiveResponseList: + """Return a converted dict of Archive model objects as a list of archives in API type Archive format.""" + archive_list = [self._archive_to_api_type_archive(archive) for archive in archives.values()] + return archive_list + + def _archive_to_api_type_archive(self, archive: Archive) -> ApiTypeArchive: + archive = { + "ArchiveName": archive.name, + "EventSourceArn": archive.event_source_arn, + "State": archive.state, + # TODO add "StateReason": archive.state_reason, + "RetentionDays": archive.retention_days, + "SizeBytes": archive.size_bytes, + "EventCount": archive.event_count, + "CreationTime": archive.creation_time, + } + return {key: value for key, value in archive.items() if value is not None} + + def _archive_to_describe_archive_response(self, archive: Archive) -> DescribeArchiveResponse: + archive_dict = { + "ArchiveArn": archive.arn, + "ArchiveName": archive.name, + "EventSourceArn": archive.event_source_arn, + "State": archive.state, + # TODO add "StateReason": archive.state_reason, + "RetentionDays": archive.retention_days, + "SizeBytes": archive.size_bytes, + "EventCount": archive.event_count, + "CreationTime": archive.creation_time, + "EventPattern": archive.event_pattern, + "Description": archive.description, + } + return {key: value for key, value in archive_dict.items() if value is not None} + + def _replay_dict_to_replay_response_list(self, replays: ReplayDict) -> ReplayList: + """Return a converted dict of Replay model objects as a list of replays in API type Replay format.""" + replay_list = [self._replay_to_api_type_replay(replay) for replay in replays.values()] + return replay_list + + def _replay_to_api_type_replay(self, replay: Replay) -> ApiTypeReplay: + replay = { + "ReplayName": replay.name, + "EventSourceArn": replay.event_source_arn, + "State": replay.state, + # # "StateReason": replay.state_reason, + "EventStartTime": replay.event_start_time, + "EventEndTime": replay.event_end_time, + "EventLastReplayedTime": replay.event_last_replayed_time, + "ReplayStartTime": replay.replay_start_time, + "ReplayEndTime": replay.replay_end_time, + } + return {key: value for key, value in replay.items() if value is not None} + + def _replay_to_describe_replay_response(self, replay: Replay) -> DescribeReplayResponse: + replay_dict = { + "ReplayName": replay.name, + "ReplayArn": replay.arn, + "Description": replay.description, + "State": replay.state, + # # "StateReason": replay.state_reason, + "EventSourceArn": replay.event_source_arn, + "Destination": replay.destination, + "EventStartTime": replay.event_start_time, + "EventEndTime": replay.event_end_time, + "EventLastReplayedTime": replay.event_last_replayed_time, + "ReplayStartTime": replay.replay_start_time, + "ReplayEndTime": replay.replay_end_time, + } + return {key: value for key, value in replay_dict.items() if value is not None} + + def _connection_to_api_type_connection(self, connection: Connection) -> ApiTypeConnection: + connection = { + "ConnectionArn": connection.arn, + "Name": connection.name, + "ConnectionState": connection.state, + # "StateReason": connection.state_reason, # TODO implement state reason + "AuthorizationType": connection.authorization_type, + "AuthParameters": connection.auth_parameters, + "SecretArn": connection.secret_arn, + "CreationTime": connection.creation_time, + "LastModifiedTime": connection.last_modified_time, + "LastAuthorizedTime": connection.last_authorized_time, + } + return {key: value for key, value in connection.items() if value is not None} + + def _connection_dict_to_connection_response_list( + self, connections: ConnectionDict + ) -> ConnectionResponseList: + """Return a converted dict of Connection model objects as a list of connections in API type Connection format.""" + connection_list = [ + self._connection_to_api_type_connection(connection) + for connection in connections.values() + ] + return connection_list + + def _api_destination_to_api_type_api_destination( + self, api_destination: ApiDestination + ) -> ApiTypeApiDestination: + api_destination = { + "ApiDestinationArn": api_destination.arn, + "Name": api_destination.name, + "ConnectionArn": api_destination.connection_arn, + "ApiDestinationState": api_destination.state, + "InvocationEndpoint": api_destination.invocation_endpoint, + "HttpMethod": api_destination.http_method, + "InvocationRateLimitPerSecond": api_destination.invocation_rate_limit_per_second, + "CreationTime": api_destination.creation_time, + "LastModifiedTime": api_destination.last_modified_time, + "Description": api_destination.description, + } + return {key: value for key, value in api_destination.items() if value is not None} + + def _api_destination_dict_to_api_destination_response_list( + self, api_destinations: ApiDestinationDict + ) -> ApiDestinationResponseList: + """Return a converted dict of ApiDestination model objects as a list of connections in API type ApiDestination format.""" + api_destination_list = [ + self._api_destination_to_api_type_api_destination(api_destination) + for api_destination in api_destinations.values() + ] + return api_destination_list + + def _put_to_archive( + self, + region: str, + account_id: str, + archive_target_id: str, + event: FormattedEvent, + ) -> None: + archive_name = ARCHIVE_TARGET_ID_NAME_PATTERN.match(archive_target_id).group("name") + + store = self.get_store(region, account_id) + archive = self.get_archive(archive_name, store) + archive_service = self._archive_service_store[archive.arn] + archive_service.put_events([event]) + + def _process_entries( + self, context: RequestContext, entries: PutEventsRequestEntryList + ) -> tuple[PutEventsResultEntryList, int]: + """Main method to process events put to an event bus. + Events are validated to contain the proper fields and formatted. + Events are matched against all the rules of the respective event bus. + For matching rules the event is either sent to the respective target, + via the target sender put to the defined archived.""" + processed_entries = [] + failed_entry_count = {"count": 0} + for event in entries: + self._process_entry(event, processed_entries, failed_entry_count, context) + return processed_entries, failed_entry_count["count"] + + def _process_entry( + self, + entry: PutEventsRequestEntry, + processed_entries: PutEventsResultEntryList, + failed_entry_count: dict[str, int], + context: RequestContext, + ) -> None: + event_bus_name_or_arn = entry.get("EventBusName", "default") + event_bus_name = extract_event_bus_name(event_bus_name_or_arn) + if event_failed_validation := validate_event(entry): + processed_entries.append(event_failed_validation) + failed_entry_count["count"] += 1 + LOG.info(json.dumps(event_failed_validation)) + return + + region, account_id = extract_region_and_account_id(event_bus_name_or_arn, context) + + # TODO check interference with x-ray trace header + if encoded_trace_header := get_trace_header_encoded_region_account( + entry, context.region, context.account_id, region, account_id + ): + entry["TraceHeader"] = encoded_trace_header + + event_formatted = format_event(entry, region, account_id, event_bus_name) + store = self.get_store(region, account_id) + + try: + event_bus = self.get_event_bus(event_bus_name, store) + except ResourceNotFoundException: + # ignore events for non-existing event buses but add processed event + processed_entries.append({"EventId": event_formatted["id"]}) + LOG.info( + json.dumps( + { + "ErrorCode": "ResourceNotFoundException at get_event_bus", + "ErrorMessage": f"Event_bus {event_bus_name} does not exist", + } + ) + ) + return + + trace_header = context.trace_context["aws_trace_header"] + + self._proxy_capture_input_event(event_formatted, trace_header, region, account_id) + + # Always add the successful EventId entry, even if target processing might fail + processed_entries.append({"EventId": event_formatted["id"]}) + + if configured_rules := list(event_bus.rules.values()): + for rule in configured_rules: + if rule.schedule_expression: + # we do not want to execute Scheduled Rules on PutEvents + continue + + self._process_rules(rule, region, account_id, event_formatted, trace_header) + else: + LOG.info( + json.dumps( + { + "InfoCode": "InternalInfoEvents at process_rules", + "InfoMessage": f"No rules attached to event_bus: {event_bus_name}", + } + ) + ) + + def _proxy_capture_input_event( + self, event: FormattedEvent, trace_header: TraceHeader, region: str, account_id: str + ) -> None: + # only required for EventStudio to capture input event if no rule is configured + pass + + def _process_rules( + self, + rule: Rule, + region: str, + account_id: str, + event_formatted: FormattedEvent, + trace_header: TraceHeader, + ) -> None: + """Process rules for an event. Note that we no longer handle entries here as AWS returns success regardless of target failures.""" + event_pattern = rule.event_pattern + + if matches_event(event_pattern, event_formatted): + if not rule.targets: + LOG.info( + json.dumps( + { + "InfoCode": "InternalInfoEvents at iterate over targets", + "InfoMessage": f"No target configured for matched rule: {rule}", + } + ) + ) + return + + for target in rule.targets.values(): + target_id = target["Id"] + if is_archive_arn(target["Arn"]): + self._put_to_archive( + region, + account_id, + archive_target_id=target_id, + event=event_formatted, + ) + else: + target_unique_id = f"{rule.arn}-{target_id}" + target_sender = self._target_sender_store[target_unique_id] + try: + target_sender.process_event(event_formatted.copy(), trace_header) + rule_invocation.labels( + status=InvocationStatus.success, + service=target_sender.service, + ).increment() + + except Exception as error: + rule_invocation.labels( + status=InvocationStatus.error, + service=target_sender.service, + ).increment() + # Log the error but don't modify the response + LOG.info( + json.dumps( + { + "ErrorCode": "TargetDeliveryFailure", + "ErrorMessage": f"Failed to deliver to target {target_id}: {str(error)}", + } + ) + ) + else: + LOG.info( + json.dumps( + { + "InfoCode": "InternalInfoEvents at matches_rule", + "InfoMessage": f"No rules matched for formatted event: {event_formatted}", + } + ) + ) diff --git a/localstack-core/localstack/services/events/replay.py b/localstack-core/localstack/services/events/replay.py new file mode 100644 index 0000000000000..7a58fb3534d05 --- /dev/null +++ b/localstack-core/localstack/services/events/replay.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone + +from localstack.aws.api.events import ( + Arn, + PutEventsRequestEntry, + ReplayDescription, + ReplayDestination, + ReplayName, + ReplayState, + Timestamp, +) +from localstack.services.events.models import FormattedEventList, Replay +from localstack.services.events.utils import ( + convert_to_timezone_aware_datetime, + extract_event_bus_name, + re_format_event, +) + + +class ReplayService: + name: ReplayName + region: str + account_id: str + event_source_arn: Arn + destination: ReplayDestination + event_start_time: Timestamp + event_end_time: Timestamp + description: ReplayDescription + replay: Replay + + def __init__( + self, + name: ReplayName, + region: str, + account_id: str, + event_source_arn: Arn, + destination: ReplayDestination, + event_start_time: Timestamp, + event_end_time: Timestamp, + description: ReplayDescription, + ): + event_start_time = convert_to_timezone_aware_datetime(event_start_time) + event_end_time = convert_to_timezone_aware_datetime(event_end_time) + self.replay = Replay( + name, + region, + account_id, + event_source_arn, + destination, + event_start_time, + event_end_time, + description, + ) + self.set_state(ReplayState.STARTING) + + def __getattr__(self, name): + return getattr(self.replay, name) + + def set_state(self, state: ReplayState) -> None: + self.replay.state = state + + def start(self, events: FormattedEventList | None) -> None: + self.set_state(ReplayState.RUNNING) + self.replay.replay_start_time = datetime.now(timezone.utc) + if events: + self._set_event_last_replayed_time(events) + + def finish(self) -> None: + self.set_state(ReplayState.COMPLETED) + self.replay.replay_end_time = datetime.now(timezone.utc) + + def stop(self) -> None: + self.set_state(ReplayState.CANCELLING) + self.replay.event_last_replayed_time = None + self.replay.replay_end_time = None + + def re_format_events_from_archive( + self, events: FormattedEventList, replay_name: ReplayName + ) -> PutEventsRequestEntry: + event_bus_name = extract_event_bus_name( + self.destination["Arn"] + ) # TODO deal with filter arn -> defining rules to replay to + re_formatted_events = [re_format_event(event, event_bus_name) for event in events] + re_formatted_events_from_archive = [ + {**event, "ReplayName": replay_name} for event in re_formatted_events + ] + return re_formatted_events_from_archive + + def _set_event_last_replayed_time(self, events: FormattedEventList) -> None: + latest_event_time = max(event["time"] for event in events) + self.replay.event_last_replayed_time = latest_event_time + + +ReplayServiceDict = dict[ReplayName, ReplayService] diff --git a/localstack-core/localstack/services/events/resource_providers/__init__.py b/localstack-core/localstack/services/events/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination.py b/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination.py new file mode 100644 index 0000000000000..372d45de40dce --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination.py @@ -0,0 +1,115 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EventsApiDestinationProperties(TypedDict): + ConnectionArn: Optional[str] + HttpMethod: Optional[str] + InvocationEndpoint: Optional[str] + Arn: Optional[str] + Description: Optional[str] + InvocationRateLimitPerSecond: Optional[int] + Name: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EventsApiDestinationProvider(ResourceProvider[EventsApiDestinationProperties]): + TYPE = "AWS::Events::ApiDestination" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EventsApiDestinationProperties], + ) -> ProgressEvent[EventsApiDestinationProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Name + + Required properties: + - ConnectionArn + - InvocationEndpoint + - HttpMethod + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - events:CreateApiDestination + - events:DescribeApiDestination + + """ + model = request.desired_state + events = request.aws_client_factory.events + + response = events.create_api_destination(**model) + model["Arn"] = response["ApiDestinationArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EventsApiDestinationProperties], + ) -> ProgressEvent[EventsApiDestinationProperties]: + """ + Fetch resource information + + IAM permissions required: + - events:DescribeApiDestination + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EventsApiDestinationProperties], + ) -> ProgressEvent[EventsApiDestinationProperties]: + """ + Delete a resource + + IAM permissions required: + - events:DeleteApiDestination + - events:DescribeApiDestination + """ + model = request.desired_state + events = request.aws_client_factory.events + + events.delete_api_destination(Name=model["Name"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EventsApiDestinationProperties], + ) -> ProgressEvent[EventsApiDestinationProperties]: + """ + Update a resource + + IAM permissions required: + - events:UpdateApiDestination + - events:DescribeApiDestination + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination.schema.json b/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination.schema.json new file mode 100644 index 0000000000000..f50460b1aea17 --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination.schema.json @@ -0,0 +1,92 @@ +{ + "typeName": "AWS::Events::ApiDestination", + "description": "Resource Type definition for AWS::Events::ApiDestination.", + "properties": { + "Name": { + "description": "Name of the apiDestination.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "Description": { + "type": "string", + "maxLength": 512 + }, + "ConnectionArn": { + "description": "The arn of the connection.", + "type": "string" + }, + "Arn": { + "description": "The arn of the api destination.", + "type": "string" + }, + "InvocationRateLimitPerSecond": { + "type": "integer", + "minimum": 1 + }, + "InvocationEndpoint": { + "description": "Url endpoint to invoke.", + "type": "string" + }, + "HttpMethod": { + "type": "string", + "enum": [ + "GET", + "HEAD", + "POST", + "OPTIONS", + "PUT", + "DELETE", + "PATCH" + ] + } + }, + "additionalProperties": false, + "createOnlyProperties": [ + "/properties/Name" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "required": [ + "ConnectionArn", + "InvocationEndpoint", + "HttpMethod" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "tagging": { + "taggable": false + }, + "handlers": { + "create": { + "permissions": [ + "events:CreateApiDestination", + "events:DescribeApiDestination" + ] + }, + "read": { + "permissions": [ + "events:DescribeApiDestination" + ] + }, + "update": { + "permissions": [ + "events:UpdateApiDestination", + "events:DescribeApiDestination" + ] + }, + "delete": { + "permissions": [ + "events:DeleteApiDestination", + "events:DescribeApiDestination" + ] + }, + "list": { + "permissions": [ + "events:ListApiDestinations" + ] + } + } +} diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination_plugin.py b/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination_plugin.py new file mode 100644 index 0000000000000..0aa7ada08cc50 --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_apidestination_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EventsApiDestinationProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Events::ApiDestination" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.events.resource_providers.aws_events_apidestination import ( + EventsApiDestinationProvider, + ) + + self.factory = EventsApiDestinationProvider diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_connection.py b/localstack-core/localstack/services/events/resource_providers/aws_events_connection.py new file mode 100644 index 0000000000000..a99f8df743aca --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_connection.py @@ -0,0 +1,162 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EventsConnectionProperties(TypedDict): + AuthParameters: Optional[AuthParameters] + AuthorizationType: Optional[str] + Arn: Optional[str] + Description: Optional[str] + Name: Optional[str] + SecretArn: Optional[str] + + +class ApiKeyAuthParameters(TypedDict): + ApiKeyName: Optional[str] + ApiKeyValue: Optional[str] + + +class BasicAuthParameters(TypedDict): + Password: Optional[str] + Username: Optional[str] + + +class ClientParameters(TypedDict): + ClientID: Optional[str] + ClientSecret: Optional[str] + + +class Parameter(TypedDict): + Key: Optional[str] + Value: Optional[str] + IsValueSecret: Optional[bool] + + +class ConnectionHttpParameters(TypedDict): + BodyParameters: Optional[list[Parameter]] + HeaderParameters: Optional[list[Parameter]] + QueryStringParameters: Optional[list[Parameter]] + + +class OAuthParameters(TypedDict): + AuthorizationEndpoint: Optional[str] + ClientParameters: Optional[ClientParameters] + HttpMethod: Optional[str] + OAuthHttpParameters: Optional[ConnectionHttpParameters] + + +class AuthParameters(TypedDict): + ApiKeyAuthParameters: Optional[ApiKeyAuthParameters] + BasicAuthParameters: Optional[BasicAuthParameters] + InvocationHttpParameters: Optional[ConnectionHttpParameters] + OAuthParameters: Optional[OAuthParameters] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EventsConnectionProvider(ResourceProvider[EventsConnectionProperties]): + TYPE = "AWS::Events::Connection" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EventsConnectionProperties], + ) -> ProgressEvent[EventsConnectionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Name + + Required properties: + - AuthorizationType + - AuthParameters + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Arn + - /properties/SecretArn + + IAM permissions required: + - events:CreateConnection + - secretsmanager:CreateSecret + - secretsmanager:GetSecretValue + - secretsmanager:PutSecretValue + - iam:CreateServiceLinkedRole + + """ + model = request.desired_state + events = request.aws_client_factory.events + + response = events.create_connection(**model) + model["Arn"] = response["ConnectionArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EventsConnectionProperties], + ) -> ProgressEvent[EventsConnectionProperties]: + """ + Fetch resource information + + IAM permissions required: + - events:DescribeConnection + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EventsConnectionProperties], + ) -> ProgressEvent[EventsConnectionProperties]: + """ + Delete a resource + + IAM permissions required: + - events:DeleteConnection + """ + model = request.desired_state + events = request.aws_client_factory.events + + events.delete_connection(Name=model["Name"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EventsConnectionProperties], + ) -> ProgressEvent[EventsConnectionProperties]: + """ + Update a resource + + IAM permissions required: + - events:UpdateConnection + - events:DescribeConnection + - secretsmanager:CreateSecret + - secretsmanager:UpdateSecret + - secretsmanager:GetSecretValue + - secretsmanager:PutSecretValue + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_connection.schema.json b/localstack-core/localstack/services/events/resource_providers/aws_events_connection.schema.json new file mode 100644 index 0000000000000..efc8539e82273 --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_connection.schema.json @@ -0,0 +1,251 @@ +{ + "typeName": "AWS::Events::Connection", + "description": "Resource Type definition for AWS::Events::Connection.", + "definitions": { + "AuthParameters": { + "type": "object", + "minProperties": 1, + "maxProperties": 2, + "properties": { + "ApiKeyAuthParameters": { + "$ref": "#/definitions/ApiKeyAuthParameters" + }, + "BasicAuthParameters": { + "$ref": "#/definitions/BasicAuthParameters" + }, + "OAuthParameters": { + "$ref": "#/definitions/OAuthParameters" + }, + "InvocationHttpParameters": { + "$ref": "#/definitions/ConnectionHttpParameters" + } + }, + "oneOf": [ + { + "required": [ + "BasicAuthParameters" + ] + }, + { + "required": [ + "OAuthParameters" + ] + }, + { + "required": [ + "ApiKeyAuthParameters" + ] + } + ], + "additionalProperties": false + }, + "BasicAuthParameters": { + "type": "object", + "properties": { + "Username": { + "type": "string" + }, + "Password": { + "type": "string" + } + }, + "required": [ + "Username", + "Password" + ], + "additionalProperties": false + }, + "OAuthParameters": { + "type": "object", + "properties": { + "ClientParameters": { + "$ref": "#/definitions/ClientParameters" + }, + "AuthorizationEndpoint": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "HttpMethod": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT" + ] + }, + "OAuthHttpParameters": { + "$ref": "#/definitions/ConnectionHttpParameters" + } + }, + "required": [ + "ClientParameters", + "AuthorizationEndpoint", + "HttpMethod" + ], + "additionalProperties": false + }, + "ApiKeyAuthParameters": { + "type": "object", + "properties": { + "ApiKeyName": { + "type": "string" + }, + "ApiKeyValue": { + "type": "string" + } + }, + "required": [ + "ApiKeyName", + "ApiKeyValue" + ], + "additionalProperties": false + }, + "ClientParameters": { + "type": "object", + "properties": { + "ClientID": { + "type": "string" + }, + "ClientSecret": { + "type": "string" + } + }, + "required": [ + "ClientID", + "ClientSecret" + ], + "additionalProperties": false + }, + "ConnectionHttpParameters": { + "type": "object", + "properties": { + "HeaderParameters": { + "type": "array", + "items": { + "$ref": "#/definitions/Parameter" + } + }, + "QueryStringParameters": { + "type": "array", + "items": { + "$ref": "#/definitions/Parameter" + } + }, + "BodyParameters": { + "type": "array", + "items": { + "$ref": "#/definitions/Parameter" + } + } + }, + "additionalProperties": false + }, + "Parameter": { + "type": "object", + "properties": { + "Key": { + "type": "string" + }, + "Value": { + "type": "string" + }, + "IsValueSecret": { + "type": "boolean", + "default": true + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false + } + }, + "properties": { + "Name": { + "description": "Name of the connection.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "Arn": { + "description": "The arn of the connection resource.", + "type": "string" + }, + "SecretArn": { + "description": "The arn of the secrets manager secret created in the customer account.", + "type": "string" + }, + "Description": { + "description": "Description of the connection.", + "type": "string", + "maxLength": 512 + }, + "AuthorizationType": { + "type": "string", + "enum": [ + "API_KEY", + "BASIC", + "OAUTH_CLIENT_CREDENTIALS" + ] + }, + "AuthParameters": { + "$ref": "#/definitions/AuthParameters" + } + }, + "additionalProperties": false, + "required": [ + "AuthorizationType", + "AuthParameters" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/SecretArn" + ], + "writeOnlyProperties": [ + "/properties/AuthParameters" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "handlers": { + "create": { + "permissions": [ + "events:CreateConnection", + "secretsmanager:CreateSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "iam:CreateServiceLinkedRole" + ] + }, + "read": { + "permissions": [ + "events:DescribeConnection" + ] + }, + "update": { + "permissions": [ + "events:UpdateConnection", + "events:DescribeConnection", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue" + ] + }, + "delete": { + "permissions": [ + "events:DeleteConnection" + ] + }, + "list": { + "permissions": [ + "events:ListConnections" + ] + } + } +} diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_connection_plugin.py b/localstack-core/localstack/services/events/resource_providers/aws_events_connection_plugin.py new file mode 100644 index 0000000000000..c8b16c6c961a1 --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_connection_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EventsConnectionProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Events::Connection" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.events.resource_providers.aws_events_connection import ( + EventsConnectionProvider, + ) + + self.factory = EventsConnectionProvider diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus.py b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus.py new file mode 100644 index 0000000000000..5929d42f7252b --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus.py @@ -0,0 +1,126 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class EventsEventBusProperties(TypedDict): + Name: Optional[str] + Arn: Optional[str] + EventSourceName: Optional[str] + Id: Optional[str] + Policy: Optional[str] + Tags: Optional[list[TagEntry]] + + +class TagEntry(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EventsEventBusProvider(ResourceProvider[EventsEventBusProperties]): + TYPE = "AWS::Events::EventBus" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EventsEventBusProperties], + ) -> ProgressEvent[EventsEventBusProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - Name + + Create-only properties: + - /properties/Name + - /properties/EventSourceName + + Read-only properties: + - /properties/Id + - /properties/Policy + - /properties/Arn + + """ + model = request.desired_state + events = request.aws_client_factory.events + + response = events.create_event_bus(Name=model["Name"]) + model["Arn"] = response["EventBusArn"] + model["Id"] = model["Name"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EventsEventBusProperties], + ) -> ProgressEvent[EventsEventBusProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EventsEventBusProperties], + ) -> ProgressEvent[EventsEventBusProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + events = request.aws_client_factory.events + + events.delete_event_bus(Name=model["Name"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EventsEventBusProperties], + ) -> ProgressEvent[EventsEventBusProperties]: + """ + Update a resource + + + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[EventsEventBusProperties], + ) -> ProgressEvent[EventsEventBusProperties]: + resources = request.aws_client_factory.events.list_event_buses() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + EventsEventBusProperties(Name=resource["Name"]) + for resource in resources["EventBuses"] + ], + ) diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus.schema.json b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus.schema.json new file mode 100644 index 0000000000000..eb5d780188a5f --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus.schema.json @@ -0,0 +1,62 @@ +{ + "typeName": "AWS::Events::EventBus", + "description": "Resource Type definition for AWS::Events::EventBus", + "additionalProperties": false, + "properties": { + "Policy": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "EventSourceName": { + "type": "string" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/TagEntry" + } + }, + "Name": { + "type": "string" + } + }, + "definitions": { + "TagEntry": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "Name" + ], + "createOnlyProperties": [ + "/properties/Name", + "/properties/EventSourceName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/Policy", + "/properties/Arn" + ] +} diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus_plugin.py b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus_plugin.py new file mode 100644 index 0000000000000..25f94f1940bb2 --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbus_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EventsEventBusProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Events::EventBus" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.events.resource_providers.aws_events_eventbus import ( + EventsEventBusProvider, + ) + + self.factory = EventsEventBusProvider diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy.py b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy.py new file mode 100644 index 0000000000000..9da54ceeff6bf --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy.py @@ -0,0 +1,155 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +from botocore.exceptions import ClientError + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.strings import short_uid + + +class EventsEventBusPolicyProperties(TypedDict): + StatementId: Optional[str] + Action: Optional[str] + Condition: Optional[Condition] + EventBusName: Optional[str] + Id: Optional[str] + Principal: Optional[str] + Statement: Optional[dict] + + +class Condition(TypedDict): + Key: Optional[str] + Type: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class EventsEventBusPolicyProvider(ResourceProvider[EventsEventBusPolicyProperties]): + TYPE = "AWS::Events::EventBusPolicy" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EventsEventBusPolicyProperties], + ) -> ProgressEvent[EventsEventBusPolicyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - StatementId + + Create-only properties: + - /properties/EventBusName + - /properties/StatementId + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + events = request.aws_client_factory.events + + model["Id"] = f"EventBusPolicy-{short_uid()}" + + # either this field is set or all other fields (Action, Principal, etc.) + statement = model.get("Statement") + optional_params = {"EventBusName": model.get("EventBusName")} + + if statement: + policy = { + "Version": "2012-10-17", + "Statement": [{"Sid": model["StatementId"], **statement}], + } + events.put_permission(Policy=json.dumps(policy), **optional_params) + else: + if model.get("Condition"): + optional_params.update({"Condition": model.get("Condition")}) + + events.put_permission( + StatementId=model["StatementId"], + Action=model["Action"], + Principal=model["Principal"], + **optional_params, + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EventsEventBusPolicyProperties], + ) -> ProgressEvent[EventsEventBusPolicyProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EventsEventBusPolicyProperties], + ) -> ProgressEvent[EventsEventBusPolicyProperties]: + """ + Delete a resource + + """ + model = request.desired_state + events = request.aws_client_factory.events + + statement_id = model["StatementId"] + event_bus_name = model.get("EventBusName") + + params = {"StatementId": statement_id, "RemoveAllPermissions": False} + + if event_bus_name: + params["EventBusName"] = event_bus_name + + # We are using try/except since at the moment + # CFN doesn't properly resolve dependency between resources + # so this resource could be deleted if parent resource was deleted first + + try: + events.remove_permission(**params) + except ClientError as err: + is_resource_not_found = err.response["Error"]["Code"] == "ResourceNotFoundException" + + if not is_resource_not_found: + raise + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EventsEventBusPolicyProperties], + ) -> ProgressEvent[EventsEventBusPolicyProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy.schema.json b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy.schema.json new file mode 100644 index 0000000000000..99bd136ddbdcd --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy.schema.json @@ -0,0 +1,58 @@ +{ + "typeName": "AWS::Events::EventBusPolicy", + "description": "Resource Type definition for AWS::Events::EventBusPolicy", + "additionalProperties": false, + "properties": { + "EventBusName": { + "type": "string" + }, + "Condition": { + "$ref": "#/definitions/Condition" + }, + "Action": { + "type": "string" + }, + "StatementId": { + "type": "string" + }, + "Statement": { + "type": "object" + }, + "Id": { + "type": "string" + }, + "Principal": { + "type": "string" + } + }, + "definitions": { + "Condition": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Type": { + "type": "string" + }, + "Key": { + "type": "string" + } + } + } + }, + "required": [ + "StatementId" + ], + "createOnlyProperties": [ + "/properties/EventBusName", + "/properties/StatementId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy_plugin.py b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy_plugin.py new file mode 100644 index 0000000000000..5368348690773 --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_eventbuspolicy_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EventsEventBusPolicyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Events::EventBusPolicy" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.events.resource_providers.aws_events_eventbuspolicy import ( + EventsEventBusPolicyProvider, + ) + + self.factory = EventsEventBusPolicyProvider diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_rule.py b/localstack-core/localstack/services/events/resource_providers/aws_events_rule.py new file mode 100644 index 0000000000000..a10d23360a41c --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_rule.py @@ -0,0 +1,323 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils import common + + +class EventsRuleProperties(TypedDict): + Arn: Optional[str] + Description: Optional[str] + EventBusName: Optional[str] + EventPattern: Optional[dict] + Id: Optional[str] + Name: Optional[str] + RoleArn: Optional[str] + ScheduleExpression: Optional[str] + State: Optional[str] + Targets: Optional[list[Target]] + + +class HttpParameters(TypedDict): + HeaderParameters: Optional[dict] + PathParameterValues: Optional[list[str]] + QueryStringParameters: Optional[dict] + + +class DeadLetterConfig(TypedDict): + Arn: Optional[str] + + +class RunCommandTarget(TypedDict): + Key: Optional[str] + Values: Optional[list[str]] + + +class RunCommandParameters(TypedDict): + RunCommandTargets: Optional[list[RunCommandTarget]] + + +class InputTransformer(TypedDict): + InputTemplate: Optional[str] + InputPathsMap: Optional[dict] + + +class KinesisParameters(TypedDict): + PartitionKeyPath: Optional[str] + + +class RedshiftDataParameters(TypedDict): + Database: Optional[str] + Sql: Optional[str] + DbUser: Optional[str] + SecretManagerArn: Optional[str] + StatementName: Optional[str] + WithEvent: Optional[bool] + + +class SqsParameters(TypedDict): + MessageGroupId: Optional[str] + + +class PlacementConstraint(TypedDict): + Expression: Optional[str] + Type: Optional[str] + + +class PlacementStrategy(TypedDict): + Field: Optional[str] + Type: Optional[str] + + +class CapacityProviderStrategyItem(TypedDict): + CapacityProvider: Optional[str] + Base: Optional[int] + Weight: Optional[int] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class AwsVpcConfiguration(TypedDict): + Subnets: Optional[list[str]] + AssignPublicIp: Optional[str] + SecurityGroups: Optional[list[str]] + + +class NetworkConfiguration(TypedDict): + AwsVpcConfiguration: Optional[AwsVpcConfiguration] + + +class EcsParameters(TypedDict): + TaskDefinitionArn: Optional[str] + CapacityProviderStrategy: Optional[list[CapacityProviderStrategyItem]] + EnableECSManagedTags: Optional[bool] + EnableExecuteCommand: Optional[bool] + Group: Optional[str] + LaunchType: Optional[str] + NetworkConfiguration: Optional[NetworkConfiguration] + PlacementConstraints: Optional[list[PlacementConstraint]] + PlacementStrategies: Optional[list[PlacementStrategy]] + PlatformVersion: Optional[str] + PropagateTags: Optional[str] + ReferenceId: Optional[str] + TagList: Optional[list[Tag]] + TaskCount: Optional[int] + + +class BatchRetryStrategy(TypedDict): + Attempts: Optional[int] + + +class BatchArrayProperties(TypedDict): + Size: Optional[int] + + +class BatchParameters(TypedDict): + JobDefinition: Optional[str] + JobName: Optional[str] + ArrayProperties: Optional[BatchArrayProperties] + RetryStrategy: Optional[BatchRetryStrategy] + + +class SageMakerPipelineParameter(TypedDict): + Name: Optional[str] + Value: Optional[str] + + +class SageMakerPipelineParameters(TypedDict): + PipelineParameterList: Optional[list[SageMakerPipelineParameter]] + + +class RetryPolicy(TypedDict): + MaximumEventAgeInSeconds: Optional[int] + MaximumRetryAttempts: Optional[int] + + +class Target(TypedDict): + Arn: Optional[str] + Id: Optional[str] + BatchParameters: Optional[BatchParameters] + DeadLetterConfig: Optional[DeadLetterConfig] + EcsParameters: Optional[EcsParameters] + HttpParameters: Optional[HttpParameters] + Input: Optional[str] + InputPath: Optional[str] + InputTransformer: Optional[InputTransformer] + KinesisParameters: Optional[KinesisParameters] + RedshiftDataParameters: Optional[RedshiftDataParameters] + RetryPolicy: Optional[RetryPolicy] + RoleArn: Optional[str] + RunCommandParameters: Optional[RunCommandParameters] + SageMakerPipelineParameters: Optional[SageMakerPipelineParameters] + SqsParameters: Optional[SqsParameters] + + +REPEATED_INVOCATION = "repeated_invocation" + +MATCHING_OPERATIONS = [ + "prefix", + "cidr", + "exists", + "suffix", + "anything-but", + "numeric", + "equals-ignore-case", + "wildcard", +] + + +def extract_rule_name(rule_id: str) -> str: + return rule_id.rsplit("|", maxsplit=1)[-1] + + +class EventsRuleProvider(ResourceProvider[EventsRuleProperties]): + TYPE = "AWS::Events::Rule" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[EventsRuleProperties], + ) -> ProgressEvent[EventsRuleProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Create-only properties: + - /properties/Name + - /properties/EventBusName + + Read-only properties: + - /properties/Id + - /properties/Arn + + + """ + model = request.desired_state + events = request.aws_client_factory.events + + name = model.get("Name") + if not name: + name = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + if event_bus_name := model.get("EventBusName"): + model["Id"] = "|".join( + [ + event_bus_name, + name, + ] + ) + else: + model["Id"] = name + + attrs = [ + "ScheduleExpression", + "EventPattern", + "State", + "Description", + "Name", + "EventBusName", + ] + + params = util.select_attributes(model, attrs) + + def wrap_in_lists(o, **kwargs): + if isinstance(o, dict): + for k, v in o.items(): + if not isinstance(v, (dict, list)) and k not in MATCHING_OPERATIONS: + o[k] = [v] + return o + + pattern = params.get("EventPattern") + if isinstance(pattern, dict): + wrapped = common.recurse_object(pattern, wrap_in_lists) + params["EventPattern"] = json.dumps(wrapped) + + params["Name"] = name + result = events.put_rule(**params) + model["Arn"] = result["RuleArn"] + + # put targets + event_bus_name = model.get("EventBusName") + targets = model.get("Targets") or [] + + if targets: + put_targets_kwargs = {"Rule": extract_rule_name(model["Id"]), "Targets": targets} + if event_bus_name: + put_targets_kwargs["EventBusName"] = event_bus_name + + put_targets_kwargs = util.convert_request_kwargs( + put_targets_kwargs, + events.meta.service_model.operation_model("PutTargets").input_shape, + ) + + events.put_targets(**put_targets_kwargs) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[EventsRuleProperties], + ) -> ProgressEvent[EventsRuleProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[EventsRuleProperties], + ) -> ProgressEvent[EventsRuleProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + events = request.aws_client_factory.events + + rule_name = extract_rule_name(model["Id"]) + targets = events.list_targets_by_rule(Rule=rule_name)["Targets"] + target_ids = [tgt["Id"] for tgt in targets] + if targets: + events.remove_targets(Rule=rule_name, Ids=target_ids, Force=True) + events.delete_rule(Name=rule_name) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[EventsRuleProperties], + ) -> ProgressEvent[EventsRuleProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_rule.schema.json b/localstack-core/localstack/services/events/resource_providers/aws_events_rule.schema.json new file mode 100644 index 0000000000000..c3a3601ff7b49 --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_rule.schema.json @@ -0,0 +1,495 @@ +{ + "typeName": "AWS::Events::Rule", + "description": "Resource Type definition for AWS::Events::Rule", + "additionalProperties": false, + "properties": { + "EventBusName": { + "type": "string" + }, + "EventPattern": { + "type": "object" + }, + "ScheduleExpression": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "State": { + "type": "string" + }, + "Targets": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Target" + } + }, + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "RoleArn": { + "type": "string" + }, + "Name": { + "type": "string" + } + }, + "definitions": { + "CapacityProviderStrategyItem": { + "type": "object", + "additionalProperties": false, + "properties": { + "Base": { + "type": "integer" + }, + "Weight": { + "type": "integer" + }, + "CapacityProvider": { + "type": "string" + } + }, + "required": [ + "CapacityProvider" + ] + }, + "HttpParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "PathParameterValues": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "HeaderParameters": { + "type": "object", + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "QueryStringParameters": { + "type": "object", + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + } + } + }, + "DeadLetterConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "Arn": { + "type": "string" + } + } + }, + "RunCommandParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "RunCommandTargets": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/RunCommandTarget" + } + } + }, + "required": [ + "RunCommandTargets" + ] + }, + "PlacementStrategy": { + "type": "object", + "additionalProperties": false, + "properties": { + "Field": { + "type": "string" + }, + "Type": { + "type": "string" + } + } + }, + "InputTransformer": { + "type": "object", + "additionalProperties": false, + "properties": { + "InputTemplate": { + "type": "string" + }, + "InputPathsMap": { + "type": "object", + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + } + }, + "required": [ + "InputTemplate" + ] + }, + "KinesisParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "PartitionKeyPath": { + "type": "string" + } + }, + "required": [ + "PartitionKeyPath" + ] + }, + "BatchRetryStrategy": { + "type": "object", + "additionalProperties": false, + "properties": { + "Attempts": { + "type": "integer" + } + } + }, + "RedshiftDataParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "StatementName": { + "type": "string" + }, + "Database": { + "type": "string" + }, + "SecretManagerArn": { + "type": "string" + }, + "DbUser": { + "type": "string" + }, + "Sql": { + "type": "string" + }, + "WithEvent": { + "type": "boolean" + } + }, + "required": [ + "Database", + "Sql" + ] + }, + "Target": { + "type": "object", + "additionalProperties": false, + "properties": { + "InputPath": { + "type": "string" + }, + "HttpParameters": { + "$ref": "#/definitions/HttpParameters" + }, + "DeadLetterConfig": { + "$ref": "#/definitions/DeadLetterConfig" + }, + "RunCommandParameters": { + "$ref": "#/definitions/RunCommandParameters" + }, + "InputTransformer": { + "$ref": "#/definitions/InputTransformer" + }, + "KinesisParameters": { + "$ref": "#/definitions/KinesisParameters" + }, + "RoleArn": { + "type": "string" + }, + "RedshiftDataParameters": { + "$ref": "#/definitions/RedshiftDataParameters" + }, + "Input": { + "type": "string" + }, + "SqsParameters": { + "$ref": "#/definitions/SqsParameters" + }, + "EcsParameters": { + "$ref": "#/definitions/EcsParameters" + }, + "BatchParameters": { + "$ref": "#/definitions/BatchParameters" + }, + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "SageMakerPipelineParameters": { + "$ref": "#/definitions/SageMakerPipelineParameters" + }, + "RetryPolicy": { + "$ref": "#/definitions/RetryPolicy" + } + }, + "required": [ + "Id", + "Arn" + ] + }, + "PlacementConstraint": { + "type": "object", + "additionalProperties": false, + "properties": { + "Expression": { + "type": "string" + }, + "Type": { + "type": "string" + } + } + }, + "AwsVpcConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "SecurityGroups": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Subnets": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "AssignPublicIp": { + "type": "string" + } + }, + "required": [ + "Subnets" + ] + }, + "SqsParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "MessageGroupId": { + "type": "string" + } + }, + "required": [ + "MessageGroupId" + ] + }, + "RunCommandTarget": { + "type": "object", + "additionalProperties": false, + "properties": { + "Values": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Values", + "Key" + ] + }, + "EcsParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "PlatformVersion": { + "type": "string" + }, + "Group": { + "type": "string" + }, + "EnableECSManagedTags": { + "type": "boolean" + }, + "EnableExecuteCommand": { + "type": "boolean" + }, + "PlacementConstraints": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/PlacementConstraint" + } + }, + "PropagateTags": { + "type": "string" + }, + "TaskCount": { + "type": "integer" + }, + "PlacementStrategies": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/PlacementStrategy" + } + }, + "CapacityProviderStrategy": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/CapacityProviderStrategyItem" + } + }, + "LaunchType": { + "type": "string" + }, + "ReferenceId": { + "type": "string" + }, + "TagList": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "NetworkConfiguration": { + "$ref": "#/definitions/NetworkConfiguration" + }, + "TaskDefinitionArn": { + "type": "string" + } + }, + "required": [ + "TaskDefinitionArn" + ] + }, + "BatchParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "JobName": { + "type": "string" + }, + "RetryStrategy": { + "$ref": "#/definitions/BatchRetryStrategy" + }, + "ArrayProperties": { + "$ref": "#/definitions/BatchArrayProperties" + }, + "JobDefinition": { + "type": "string" + } + }, + "required": [ + "JobName", + "JobDefinition" + ] + }, + "NetworkConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "AwsVpcConfiguration": { + "$ref": "#/definitions/AwsVpcConfiguration" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + } + }, + "SageMakerPipelineParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "PipelineParameterList": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/SageMakerPipelineParameter" + } + } + } + }, + "RetryPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "MaximumEventAgeInSeconds": { + "type": "integer" + }, + "MaximumRetryAttempts": { + "type": "integer" + } + } + }, + "BatchArrayProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "Size": { + "type": "integer" + } + } + }, + "SageMakerPipelineParameter": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Name": { + "type": "string" + } + }, + "required": [ + "Value", + "Name" + ] + } + }, + "createOnlyProperties": [ + "/properties/Name", + "/properties/EventBusName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/Arn" + ] +} diff --git a/localstack-core/localstack/services/events/resource_providers/aws_events_rule_plugin.py b/localstack-core/localstack/services/events/resource_providers/aws_events_rule_plugin.py new file mode 100644 index 0000000000000..3fa01b6717fdc --- /dev/null +++ b/localstack-core/localstack/services/events/resource_providers/aws_events_rule_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class EventsRuleProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Events::Rule" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.events.resource_providers.aws_events_rule import EventsRuleProvider + + self.factory = EventsRuleProvider diff --git a/localstack-core/localstack/services/events/rule.py b/localstack-core/localstack/services/events/rule.py new file mode 100644 index 0000000000000..576cfc36e781c --- /dev/null +++ b/localstack-core/localstack/services/events/rule.py @@ -0,0 +1,245 @@ +import re +from typing import Callable, Optional + +from localstack.aws.api.events import ( + Arn, + EventBusName, + EventPattern, + LimitExceededException, + ManagedBy, + PutTargetsResultEntryList, + RemoveTargetsResultEntryList, + RoleArn, + RuleDescription, + RuleName, + RuleState, + ScheduleExpression, + TagList, + Target, + TargetIdList, + TargetList, +) +from localstack.services.events.models import Rule, TargetDict, ValidationException +from localstack.services.events.scheduler import JobScheduler, convert_schedule_to_cron + +TARGET_ID_REGEX = re.compile(r"^[\.\-_A-Za-z0-9]+$") +TARGET_ARN_REGEX = re.compile(r"arn:[\d\w:\-/]*") +CRON_REGEX = ( # borrowed from https://regex101.com/r/I80Eu0/1 + r"^(?:cron[(](?:(?:(?:[0-5]?[0-9])|[*])(?:(?:[-](?:(?:[0-5]?[0-9])|[*]))|(?:[/][0-9]+))?" + r"(?:[,](?:(?:[0-5]?[0-9])|[*])(?:(?:[-](?:(?:[0-5]?[0-9])|[*]))|(?:[/][0-9]+))?)*)[ ]+" + r"(?:(?:(?:[0-2]?[0-9])|[*])(?:(?:[-](?:(?:[0-2]?[0-9])|[*]))|(?:[/][0-9]+))?" + r"(?:[,](?:(?:[0-2]?[0-9])|[*])(?:(?:[-](?:(?:[0-2]?[0-9])|[*]))|(?:[/][0-9]+))?)*)[ ]+" + r"(?:(?:[?][ ]+(?:(?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|[*])" + r"(?:(?:[-](?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|[*])(?:[/][0-9]+)?)|" + r"(?:[/][0-9]+))?(?:[,](?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|[*])" + r"(?:(?:[-](?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|[*])(?:[/][0-9]+)?)|" + r"(?:[/][0-9]+))?)*)[ ]+(?:(?:(?:[1-7]|(?:SUN|MON|TUE|WED|THU|FRI|SAT))[#][0-5])|" + r"(?:(?:(?:(?:[1-7]|(?:SUN|MON|TUE|WED|THU|FRI|SAT))L?)|[L*])(?:(?:[-](?:(?:(?:[1-7]|" + r"(?:SUN|MON|TUE|WED|THU|FRI|SAT))L?)|[L*]))|(?:[/][0-9]+))?(?:[,](?:(?:(?:[1-7]|" + r"(?:SUN|MON|TUE|WED|THU|FRI|SAT))L?)|[L*])(?:(?:[-](?:(?:(?:[1-7]|(?:SUN|MON|TUE|WED|THU|FRI|SAT))L?)|" + r"[L*]))|(?:[/][0-9]+))?)*)))|(?:(?:(?:(?:(?:[1-3]?[0-9])W?)|LW|[L*])(?:(?:[-](?:(?:(?:[1-3]?[0-9])W?)|" + r"LW|[L*]))|(?:[/][0-9]+))?(?:[,](?:(?:(?:[1-3]?[0-9])W?)|LW|[L*])(?:(?:[-](?:(?:(?:[1-3]?[0-9])W?)|" + r"LW|[L*]))|(?:[/][0-9]+))?)*)[ ]+(?:(?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|" + r"[*])(?:(?:[-](?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|[*])(?:[/][0-9]+)?)|" + r"(?:[/][0-9]+))?(?:[,](?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|[*])" + r"(?:(?:[-](?:(?:[1]?[0-9])|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)|[*])(?:[/][0-9]+)?)|" + r"(?:[/][0-9]+))?)*)[ ]+[?]))[ ]+(?:(?:(?:[12][0-9]{3})|[*])(?:(?:[-](?:(?:[12][0-9]{3})|[*]))|" + r"(?:[/][0-9]+))?(?:[,](?:(?:[12][0-9]{3})|[*])(?:(?:[-](?:(?:[12][0-9]{3})|[*]))|(?:[/][0-9]+))?)*)[)])$" +) +RULE_SCHEDULE_CRON_REGEX = re.compile(CRON_REGEX) +RULE_SCHEDULE_RATE_REGEX = re.compile(r"^rate\(\d*\s(minute|minutes|hour|hours|day|days)\)") + + +class RuleService: + name: RuleName + region: str + account_id: str + schedule_expression: ScheduleExpression | None + event_pattern: EventPattern | None + description: RuleDescription | None + role_arn: Arn | None + tags: TagList | None + event_bus_name: EventBusName | None + targets: TargetDict | None + managed_by: ManagedBy + rule: Rule + + def __init__(self, rule: Rule): + self.rule = rule + if rule.schedule_expression: + self.schedule_cron = self._get_schedule_cron(rule.schedule_expression) + else: + self.schedule_cron = None + + @classmethod + def create_rule_service( + cls, + name: RuleName, + region: Optional[str] = None, + account_id: Optional[str] = None, + schedule_expression: Optional[ScheduleExpression] = None, + event_pattern: Optional[EventPattern] = None, + state: Optional[RuleState] = None, + description: Optional[RuleDescription] = None, + role_arn: Optional[RoleArn] = None, + tags: Optional[TagList] = None, + event_bus_name: Optional[EventBusName] = None, + targets: Optional[TargetDict] = None, + managed_by: Optional[ManagedBy] = None, + ): + cls._validate_input(event_pattern, schedule_expression, event_bus_name) + # required to keep data and functionality separate for persistence + return cls( + Rule( + name, + region, + account_id, + schedule_expression, + event_pattern, + state, + description, + role_arn, + tags, + event_bus_name, + targets, + managed_by, + ) + ) + + @property + def arn(self) -> Arn: + return self.rule.arn + + @property + def state(self) -> RuleState: + return self.rule.state + + def enable(self) -> None: + self.rule.state = RuleState.ENABLED + + def disable(self) -> None: + self.rule.state = RuleState.DISABLED + + def add_targets(self, targets: TargetList) -> PutTargetsResultEntryList: + failed_entries = self.validate_targets_input(targets) + for target in targets: + target_id = target["Id"] + if target_id not in self.rule.targets and self._check_target_limit_reached(): + raise LimitExceededException( + "The requested resource exceeds the maximum number allowed." + ) + target = Target(**target) + self.rule.targets[target_id] = target + return failed_entries + + def remove_targets( + self, target_ids: TargetIdList, force: bool = False + ) -> RemoveTargetsResultEntryList: + delete_errors = [] + for target_id in target_ids: + if target_id in self.rule.targets: + if self.rule.managed_by and not force: + delete_errors.append( + { + "TargetId": target_id, + "ErrorCode": "ManagedRuleException", + "ErrorMessage": f"Rule '{self.rule.name}' is managed by an AWS service can only be modified if force is True.", + } + ) + else: + del self.rule.targets[target_id] + else: + delete_errors.append( + { + "TargetId": target_id, + "ErrorCode": "ResourceNotFoundException", + "ErrorMessage": f"Rule '{self.rule.name}' does not have a target with the Id '{target_id}'.", + } + ) + return delete_errors + + def create_schedule_job(self, schedule_job_sender_func: Callable) -> None: + cron = self.schedule_cron + state = self.rule.state != "DISABLED" + self.job_id = JobScheduler.instance().add_job(schedule_job_sender_func, cron, state) + + def validate_targets_input(self, targets: TargetList) -> PutTargetsResultEntryList: + validation_errors = [] + for index, target in enumerate(targets): + id = target.get("Id") + arn = target.get("Arn", "") + if not TARGET_ID_REGEX.match(id): + validation_errors.append( + { + "TargetId": id, + "ErrorCode": "ValidationException", + "ErrorMessage": f"Value '{id}' at 'targets.{index + 1}.member.id' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+", + } + ) + + if len(id) > 64: + validation_errors.append( + { + "TargetId": id, + "ErrorCode": "ValidationException", + "ErrorMessage": f"Value '{id}' at 'targets.{index + 1}.member.id' failed to satisfy constraint: Member must have length less than or equal to 64", + } + ) + + if not TARGET_ARN_REGEX.match(arn): + validation_errors.append( + { + "TargetId": id, + "ErrorCode": "ValidationException", + "ErrorMessage": f"Parameter {arn} is not valid. Reason: Provided Arn is not in correct format.", + } + ) + + if ":sqs:" in arn and arn.endswith(".fifo") and not target.get("SqsParameters"): + validation_errors.append( + { + "TargetId": id, + "ErrorCode": "ValidationException", + "ErrorMessage": f"Parameter(s) SqsParameters must be specified for target: {id}.", + } + ) + + return validation_errors + + @classmethod + def _validate_input( + cls, + event_pattern: Optional[EventPattern], + schedule_expression: Optional[ScheduleExpression], + event_bus_name: Optional[EventBusName] = "default", + ) -> None: + if not event_pattern and not schedule_expression: + raise ValidationException( + "Parameter(s) EventPattern or ScheduleExpression must be specified." + ) + + if schedule_expression: + if event_bus_name != "default": + raise ValidationException( + "ScheduleExpression is supported only on the default event bus." + ) + if not ( + RULE_SCHEDULE_CRON_REGEX.match(schedule_expression) + or RULE_SCHEDULE_RATE_REGEX.match(schedule_expression) + ): + raise ValidationException("Parameter ScheduleExpression is not valid.") + + def _check_target_limit_reached(self) -> bool: + if len(self.rule.targets) >= 5: + return True + return False + + def _get_schedule_cron(self, schedule_expression: ScheduleExpression) -> str: + try: + cron = convert_schedule_to_cron(schedule_expression) + return cron + except ValueError as e: + raise ValidationException("Parameter ScheduleExpression is not valid.") from e + + +RuleServiceDict = dict[Arn, RuleService] diff --git a/localstack-core/localstack/services/events/scheduler.py b/localstack-core/localstack/services/events/scheduler.py new file mode 100644 index 0000000000000..c71833f402d0b --- /dev/null +++ b/localstack-core/localstack/services/events/scheduler.py @@ -0,0 +1,136 @@ +import logging +import re +import threading + +from crontab import CronTab + +from localstack.utils.common import short_uid +from localstack.utils.run import FuncThread + +LOG = logging.getLogger(__name__) + +CRON_REGEX = re.compile(r"\s*cron\s*\(([^\)]*)\)\s*") +RATE_REGEX = re.compile(r"\s*rate\s*\(([^\)]*)\)\s*") + + +def convert_schedule_to_cron(schedule): + """Convert Events schedule like "cron(0 20 * * ? *)" or "rate(5 minutes)" """ + cron_match = CRON_REGEX.match(schedule) + if cron_match: + return cron_match.group(1) + + rate_match = RATE_REGEX.match(schedule) + if rate_match: + rate = rate_match.group(1) + rate_value, rate_unit = re.split(r"\s+", rate.strip()) + rate_value = int(rate_value) + + if rate_value < 1: + raise ValueError("Rate value must be larger than 0") + # see https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-rate-expressions.html + if rate_value == 1 and rate_unit.endswith("s"): + raise ValueError("If the value is equal to 1, then the unit must be singular") + if rate_value > 1 and not rate_unit.endswith("s"): + raise ValueError("If the value is greater than 1, the unit must be plural") + + if "minute" in rate_unit: + return f"*/{rate_value} * * * *" + if "hour" in rate_unit: + return f"0 */{rate_value} * * *" + if "day" in rate_unit: + return f"0 0 */{rate_value} * *" + + # TODO: cover via test + # raise ValueError(f"Unable to parse events schedule expression: {schedule}") + + return schedule + + +class Job: + def __init__(self, job_func, schedule, enabled): + self.job_func = job_func + self.schedule = schedule + self.job_id = short_uid() + self.is_enabled = enabled + + def run(self): + try: + if self.should_run_now() and self.is_enabled: + self.do_run() + except Exception as e: + LOG.debug("Unable to run scheduled function %s: %s", self.job_func, e) + + def should_run_now(self): + schedule = CronTab(self.schedule) + delay_secs = schedule.next( + default_utc=True + ) # utc default time format for rule schedule cron + # TODO fix execute on exact cron time + return delay_secs is not None and delay_secs < 60 + + def do_run(self): + FuncThread(self.job_func, name="events-job-run").start() + + +class JobScheduler: + _instance = None + + def __init__(self): + # TODO: introduce RLock for mutating jobs list + self.jobs = [] + self.thread = None + self._stop_event = threading.Event() + + def add_job(self, job_func, schedule, enabled=True): + job = Job(job_func, schedule, enabled=enabled) + self.jobs.append(job) + return job.job_id + + def get_job(self, job_id) -> Job | None: + for job in self.jobs: + if job.job_id == job_id: + return job + return None + + def disable_job(self, job_id): + for job in self.jobs: + if job.job_id == job_id: + job.is_enabled = False + break + + def cancel_job(self, job_id): + self.jobs = [job for job in self.jobs if job.job_id != job_id] + + def loop(self, *args): + while not self._stop_event.is_set(): + try: + for job in list(self.jobs): + job.run() + except Exception: + pass + # This is a simple heuristic to cause the loop to run approximately every minute + # TODO: we should keep track of jobs execution times, to avoid duplicate executions + self._stop_event.wait(timeout=59.9) + + def start_loop(self): + self.thread = FuncThread(self.loop, name="events-jobscheduler-loop") + self.thread.start() + + @classmethod + def instance(cls): + if not cls._instance: + cls._instance = JobScheduler() + return cls._instance + + @classmethod + def start(cls): + instance = cls.instance() + if not instance.thread: + instance.start_loop() + return instance + + @classmethod + def shutdown(cls): + instance = cls.instance() + if instance.thread: + instance._stop_event.set() diff --git a/localstack-core/localstack/services/events/target.py b/localstack-core/localstack/services/events/target.py new file mode 100644 index 0000000000000..fe18ce999412c --- /dev/null +++ b/localstack-core/localstack/services/events/target.py @@ -0,0 +1,749 @@ +import datetime +import json +import logging +import re +import uuid +from abc import ABC, abstractmethod +from typing import Any, Dict, Set, Type +from urllib.parse import urlencode + +import requests +from botocore.client import BaseClient + +from localstack import config +from localstack.aws.api.events import ( + Arn, + InputTransformer, + RuleName, + Target, + TargetInputPath, +) +from localstack.aws.connect import connect_to +from localstack.services.events.api_destination import add_api_destination_authorization +from localstack.services.events.models import ( + FormattedEvent, + TransformedEvent, + ValidationException, +) +from localstack.services.events.utils import ( + event_time_to_time_string, + get_trace_header_encoded_region_account, + is_nested_in_string, + to_json_str, +) +from localstack.utils import collections +from localstack.utils.aws.arns import ( + extract_account_id_from_arn, + extract_region_from_arn, + extract_service_from_arn, + firehose_name, + parse_arn, + sqs_queue_url_for_arn, +) +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.aws.message_forwarding import ( + add_target_http_parameters, +) +from localstack.utils.json import extract_jsonpath +from localstack.utils.strings import to_bytes +from localstack.utils.time import now_utc +from localstack.utils.xray.trace_header import TraceHeader + +LOG = logging.getLogger(__name__) + +# https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-transform-target-input.html#eb-transform-input-predefined +AWS_PREDEFINED_PLACEHOLDERS_STRING_VALUES = { + "aws.events.rule-arn", + "aws.events.rule-name", + "aws.events.event.ingestion-time", +} +AWS_PREDEFINED_PLACEHOLDERS_JSON_VALUES = {"aws.events.event", "aws.events.event.json"} + +PREDEFINED_PLACEHOLDERS: Set[str] = AWS_PREDEFINED_PLACEHOLDERS_STRING_VALUES.union( + AWS_PREDEFINED_PLACEHOLDERS_JSON_VALUES +) + +TRANSFORMER_PLACEHOLDER_PATTERN = re.compile(r"<(.*?)>") +TRACE_HEADER_KEY = "X-Amzn-Trace-Id" + + +def transform_event_with_target_input_path( + input_path: TargetInputPath, event: FormattedEvent +) -> TransformedEvent: + formatted_event = extract_jsonpath(event, input_path) + return formatted_event + + +def get_template_replacements( + input_transformer: InputTransformer, event: FormattedEvent +) -> dict[str, Any]: + """Extracts values from the event using the input paths map keys and places them in the input template dict.""" + template_replacements = {} + transformer_path_map = input_transformer.get("InputPathsMap", {}) + for placeholder, transformer_path in transformer_path_map.items(): + if placeholder in PREDEFINED_PLACEHOLDERS: + continue + value = extract_jsonpath(event, transformer_path) + if not value: + value = "" # default value is empty string + template_replacements[placeholder] = value + return template_replacements + + +def replace_template_placeholders( + template: str, replacements: dict[str, Any], is_json_template: bool +) -> TransformedEvent: + """Replace placeholders defined by in the template with the values from the replacements dict. + Can handle single template string or template dict.""" + + def replace_placeholder(match): + key = match.group(1) + value = replacements.get(key, "") # handle non defined placeholders + if isinstance(value, datetime.datetime): + return event_time_to_time_string(value) + if isinstance(value, dict): + json_str = to_json_str(value).replace('\\"', '"') + if is_json_template: + return json_str + return json_str.replace('"', "") + if isinstance(value, list): + if is_json_template: + return json.dumps(value) + return f"[{','.join(value)}]" + if is_nested_in_string(template, match): + return value + if is_json_template: + return json.dumps(value) + return value + + formatted_template = TRANSFORMER_PLACEHOLDER_PATTERN.sub(replace_placeholder, template).replace( + "\\n", "\n" + ) + + if is_json_template: + try: + loaded_json_template = json.loads(formatted_template) + return loaded_json_template + except json.JSONDecodeError: + LOG.info( + json.dumps( + { + "InfoCode": "InternalInfoEvents at transform_event", + "InfoMessage": f"Replaced template is not valid json: {formatted_template}", + } + ) + ) + else: + return formatted_template[1:-1] + + +class TargetSender(ABC): + target: Target + rule_arn: Arn + rule_name: RuleName + service: str + + region: str # region of the event bus + account_id: str # region of the event bus + target_region: str + target_account_id: str + _client: BaseClient | None + + def __init__( + self, + target: Target, + rule_arn: Arn, + rule_name: RuleName, + service: str, + region: str, + account_id: str, + ): + self.target = target + self.rule_arn = rule_arn + self.rule_name = rule_name + self.service = service + self.region = region + self.account_id = account_id + + self.target_region = extract_region_from_arn(self.target["Arn"]) + self.target_account_id = extract_account_id_from_arn(self.target["Arn"]) + + self._validate_input(target) + self._client: BaseClient | None = None + + @property + def arn(self): + return self.target["Arn"] + + @property + def target_id(self): + return self.target["Id"] + + @property + def unique_id(self): + """Necessary to distinguish between targets with the same ARN but for different rules. + The unique_id is a combination of the rule ARN and the Target Id. + This is necessary since input path and input transformer can be different for the same target ARN, + attached to different rules.""" + return f"{self.rule_arn}-{self.target_id}" + + @property + def client(self): + """Lazy initialization of internal botoclient factory.""" + if self._client is None: + self._client = self._initialize_client() + return self._client + + @abstractmethod + def send_event(self, event: FormattedEvent | TransformedEvent, trace_header: TraceHeader): + pass + + def process_event(self, event: FormattedEvent, trace_header: TraceHeader): + """Processes the event and send it to the target.""" + if input_ := self.target.get("Input"): + event = json.loads(input_) + if isinstance(event, dict): + event.pop("event-bus-name", None) + if not input_: + if input_path := self.target.get("InputPath"): + event = transform_event_with_target_input_path(input_path, event) + if input_transformer := self.target.get("InputTransformer"): + event = self.transform_event_with_target_input_transformer(input_transformer, event) + if event: + self.send_event(event, trace_header) + else: + LOG.info("No event to send to target %s", self.target.get("Id")) + + def transform_event_with_target_input_transformer( + self, input_transformer: InputTransformer, event: FormattedEvent + ) -> TransformedEvent: + input_template = input_transformer["InputTemplate"] + template_replacements = get_template_replacements(input_transformer, event) + predefined_template_replacements = self._get_predefined_template_replacements(event) + template_replacements.update(predefined_template_replacements) + + is_json_template = input_template.strip().startswith(("{")) + populated_template = replace_template_placeholders( + input_template, template_replacements, is_json_template + ) + + return populated_template + + def _validate_input(self, target: Target): + """Provide a default implementation extended for each target based on specifications.""" + # TODO add For Lambda and Amazon SNS resources, EventBridge relies on resource-based policies. + if "InputPath" in target and "InputTransformer" in target: + raise ValidationException( + f"Only one of Input, InputPath, or InputTransformer must be provided for target {target.get('Id')}." + ) + if input_transformer := target.get("InputTransformer"): + self._validate_input_transformer(input_transformer) + + def _initialize_client(self) -> BaseClient: + """Initializes internal boto client. + If a role from a target is provided, the client will be initialized with the assumed role. + If no role is provided, the client will be initialized with the account ID and region. + In both cases event bridge is requested as service principal""" + service_principal = ServicePrincipal.events + role_arn = self.target.get("RoleArn") + if role_arn: # required for cross account + # assumed role sessions expire after 6 hours in AWS, currently no expiration in LocalStack + client_factory = connect_to.with_assumed_role( + role_arn=role_arn, + service_principal=service_principal, + region_name=self.region, + ) + else: + client_factory = connect_to(aws_access_key_id=self.account_id, region_name=self.region) + client = client_factory.get_client(self.service) + client = client.request_metadata( + service_principal=service_principal, source_arn=self.rule_arn + ) + self._register_client_hooks(client) + return client + + def _validate_input_transformer(self, input_transformer: InputTransformer): + # TODO: cover via test + # if "InputTemplate" not in input_transformer: + # raise ValueError("InputTemplate is required for InputTransformer") + input_template = input_transformer["InputTemplate"] + input_paths_map = input_transformer.get("InputPathsMap", {}) + placeholders = TRANSFORMER_PLACEHOLDER_PATTERN.findall(input_template) + for placeholder in placeholders: + if placeholder not in input_paths_map and placeholder not in PREDEFINED_PLACEHOLDERS: + raise ValidationException( + f"InputTemplate for target {self.target.get('Id')} contains invalid placeholder {placeholder}." + ) + + def _get_predefined_template_replacements(self, event: FormattedEvent) -> dict[str, Any]: + """Extracts predefined values from the event.""" + predefined_template_replacements = {} + predefined_template_replacements["aws.events.rule-arn"] = self.rule_arn + predefined_template_replacements["aws.events.rule-name"] = self.rule_name + predefined_template_replacements["aws.events.event.ingestion-time"] = event["time"] + predefined_template_replacements["aws.events.event"] = { + "detailType" if k == "detail-type" else k: v # detail-type is is returned as detailType + for k, v in event.items() + if k != "detail" # detail is not part of .event placeholder + } + predefined_template_replacements["aws.events.event.json"] = event + + return predefined_template_replacements + + def _register_client_hooks(self, client: BaseClient): + """Register client hooks to inject trace header into requests.""" + + def handle_extract_params(params, context, **kwargs): + trace_header = params.pop("TraceHeader", None) + if trace_header is None: + return + context[TRACE_HEADER_KEY] = trace_header.to_header_str() + + def handle_inject_headers(params, context, **kwargs): + if trace_header_str := context.pop(TRACE_HEADER_KEY, None): + params["headers"][TRACE_HEADER_KEY] = trace_header_str + + client.meta.events.register( + f"provide-client-params.{self.service}.*", handle_extract_params + ) + client.meta.events.register(f"before-call.{self.service}.*", handle_inject_headers) + + +TargetSenderDict = dict[str, TargetSender] # rule_arn-target_id as global unique id + +# Target Senders are ordered alphabetically by service name + + +class ApiGatewayTargetSender(TargetSender): + """ + ApiGatewayTargetSender is a TargetSender that sends events to an API Gateway target. + """ + + PROHIBITED_HEADERS = [ + "authorization", + "connection", + "content-encoding", + "content-length", + "host", + "max-forwards", + "te", + "transfer-encoding", + "trailer", + "upgrade", + "via", + "www-authenticate", + "x-forwarded-for", + ] # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-api-gateway-target.html + + ALLOWED_HTTP_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + + def send_event(self, event, trace_header): + # Parse the ARN to extract api_id, stage_name, http_method, and resource path + # Example ARN: arn:{partition}:execute-api:{region}:{account_id}:{api_id}/{stage_name}/{method}/{resource_path} + arn_parts = parse_arn(self.target["Arn"]) + api_gateway_info = arn_parts["resource"] # e.g., 'myapi/dev/POST/pets/*/*' + api_gateway_info_parts = api_gateway_info.split("/") + + api_id = api_gateway_info_parts[0] + stage_name = api_gateway_info_parts[1] + http_method = api_gateway_info_parts[2].upper() + resource_path_parts = api_gateway_info_parts[3:] # may contain wildcards + + if http_method not in self.ALLOWED_HTTP_METHODS: + LOG.error("Unsupported HTTP method: %s", http_method) + return + + # Replace wildcards in resource path with PathParameterValues + path_params_values = self.target.get("HttpParameters", {}).get("PathParameterValues", []) + resource_path_segments = [] + path_param_index = 0 + for part in resource_path_parts: + if part == "*": + if path_param_index < len(path_params_values): + resource_path_segments.append(path_params_values[path_param_index]) + path_param_index += 1 + else: + # Use empty string if no path parameter is provided + resource_path_segments.append("") + else: + resource_path_segments.append(part) + resource_path = "/".join(resource_path_segments) + + # Ensure resource path starts and ends with '/' + resource_path = f"/{resource_path.strip('/')}/" + + # Construct query string parameters + query_params = self.target.get("HttpParameters", {}).get("QueryStringParameters", {}) + query_string = urlencode(query_params) if query_params else "" + + # Construct headers + headers = self.target.get("HttpParameters", {}).get("HeaderParameters", {}) + headers = {k: v for k, v in headers.items() if k.lower() not in self.PROHIBITED_HEADERS} + # Add Host header to ensure proper routing in LocalStack + + host = f"{api_id}.execute-api.localhost.localstack.cloud" + headers["Host"] = host + + # Ensure Content-Type is set + headers.setdefault("Content-Type", "application/json") + + # Construct the full URL + resource_path = f"/{resource_path.strip('/')}/" + + # Construct the full URL using urljoin + from urllib.parse import urljoin + + base_url = config.internal_service_url() + base_path = f"/{stage_name}" + full_path = urljoin(base_path + "/", resource_path.lstrip("/")) + url = urljoin(base_url + "/", full_path.lstrip("/")) + + if query_string: + url += f"?{query_string}" + + # Serialize the event, converting datetime objects to strings + event_json = json.dumps(event, default=str) + + # Add trace header + headers[TRACE_HEADER_KEY] = trace_header.to_header_str() + + # Send the HTTP request + response = requests.request( + method=http_method, url=url, headers=headers, data=event_json, timeout=5 + ) + if not response.ok: + LOG.warning( + "API Gateway target invocation failed with status code %s, response: %s", + response.status_code, + response.text, + ) + + def _validate_input(self, target: Target): + super()._validate_input(target) + # TODO: cover via test + # if not collections.get_safe(target, "$.RoleArn"): + # raise ValueError("RoleArn is required for ApiGateway target") + + def _get_predefined_template_replacements(self, event: Dict[str, Any]) -> Dict[str, Any]: + """Extracts predefined values from the event.""" + predefined_template_replacements = {} + predefined_template_replacements["aws.events.rule-arn"] = self.rule_arn + predefined_template_replacements["aws.events.rule-name"] = self.rule_name + predefined_template_replacements["aws.events.event.ingestion-time"] = event.get("time", "") + predefined_template_replacements["aws.events.event"] = { + "detailType" if k == "detail-type" else k: v for k, v in event.items() if k != "detail" + } + predefined_template_replacements["aws.events.event.json"] = event + + return predefined_template_replacements + + +class AppSyncTargetSender(TargetSender): + def send_event(self, event, trace_header): + raise NotImplementedError("AppSync target is not yet implemented") + + +class BatchTargetSender(TargetSender): + def send_event(self, event, trace_header): + raise NotImplementedError("Batch target is not yet implemented") + + def _validate_input(self, target: Target): + # TODO: cover via test and fix (only required if we have BatchParameters) + # if not collections.get_safe(target, "$.BatchParameters.JobDefinition"): + # raise ValueError("BatchParameters.JobDefinition is required for Batch target") + # if not collections.get_safe(target, "$.BatchParameters.JobName"): + # raise ValueError("BatchParameters.JobName is required for Batch target") + pass + + +class ECSTargetSender(TargetSender): + def send_event(self, event, trace_header): + raise NotImplementedError("ECS target is a pro feature, please use LocalStack Pro") + + def _validate_input(self, target: Target): + super()._validate_input(target) + # TODO: cover via test + # if not collections.get_safe(target, "$.EcsParameters.TaskDefinitionArn"): + # raise ValueError("EcsParameters.TaskDefinitionArn is required for ECS target") + + +class EventsTargetSender(TargetSender): + def send_event(self, event, trace_header): + # TODO add validation and tests for eventbridge to eventbridge requires Detail, DetailType, and Source + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/events/client/put_events.html + source = self._get_source(event) + detail_type = self._get_detail_type(event) + detail = event.get("detail", event) + resources = self._get_resources(event) + entries = [ + { + "EventBusName": self.target["Arn"], # use arn for target account and region + "Source": source, + "DetailType": detail_type, + "Detail": json.dumps(detail), + "Resources": resources, + } + ] + if encoded_original_id := get_trace_header_encoded_region_account( + event, self.region, self.account_id, self.target_region, self.target_account_id + ): + entries[0]["TraceHeader"] = encoded_original_id + + self.client.put_events(Entries=entries, TraceHeader=trace_header) + + def _get_source(self, event: FormattedEvent | TransformedEvent) -> str: + if isinstance(event, dict) and (source := event.get("source")): + return source + else: + return self.service or "" + + def _get_detail_type(self, event: FormattedEvent | TransformedEvent) -> str: + if isinstance(event, dict) and (detail_type := event.get("detail-type")): + return detail_type + else: + return "" + + def _get_resources(self, event: FormattedEvent | TransformedEvent) -> list[str]: + if isinstance(event, dict) and (resources := event.get("resources")): + return resources + else: + return [] + + +class EventsApiDestinationTargetSender(TargetSender): + def send_event(self, event, trace_header): + """Send an event to an EventBridge API destination + See https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-api-destinations.html""" + target_arn = self.target["Arn"] + target_region = extract_region_from_arn(target_arn) + target_account_id = extract_account_id_from_arn(target_arn) + api_destination_name = target_arn.split(":")[-1].split("/")[1] + + events_client = connect_to( + aws_access_key_id=target_account_id, region_name=target_region + ).events + destination = events_client.describe_api_destination(Name=api_destination_name) + + # get destination endpoint details + method = destination.get("HttpMethod", "GET") + endpoint = destination.get("InvocationEndpoint") + state = destination.get("ApiDestinationState") or "ACTIVE" + + LOG.debug( + 'Calling EventBridge API destination (state "%s"): %s %s', state, method, endpoint + ) + headers = { + # default headers AWS sends with every api destination call + "User-Agent": "Amazon/EventBridge/ApiDestinations", + "Content-Type": "application/json; charset=utf-8", + "Range": "bytes=0-1048575", + "Accept-Encoding": "gzip,deflate", + "Connection": "close", + } + + endpoint = add_api_destination_authorization(destination, headers, event) + if http_parameters := self.target.get("HttpParameters"): + endpoint = add_target_http_parameters(http_parameters, endpoint, headers, event) + + # add trace header + headers[TRACE_HEADER_KEY] = trace_header.to_header_str() + + result = requests.request( + method=method, url=endpoint, data=json.dumps(event or {}), headers=headers + ) + if result.status_code >= 400: + LOG.debug( + "Received code %s forwarding events: %s %s", result.status_code, method, endpoint + ) + if result.status_code == 429 or 500 <= result.status_code <= 600: + pass # TODO: retry logic (only retry on 429 and 5xx response status) + + +class FirehoseTargetSender(TargetSender): + def send_event(self, event, trace_header): + delivery_stream_name = firehose_name(self.target["Arn"]) + + self.client.put_record( + DeliveryStreamName=delivery_stream_name, + Record={"Data": to_bytes(to_json_str(event))}, + ) + + +class KinesisTargetSender(TargetSender): + def send_event(self, event, trace_header): + partition_key_path = collections.get_safe( + self.target, + "$.KinesisParameters.PartitionKeyPath", + default_value="$.id", + ) + stream_name = self.target["Arn"].split("/")[-1] + partition_key = collections.get_safe(event, partition_key_path, event["id"]) + + self.client.put_record( + StreamName=stream_name, + Data=to_bytes(to_json_str(event)), + PartitionKey=partition_key, + ) + + def _validate_input(self, target: Target): + super()._validate_input(target) + # TODO: cover via tests + # if not collections.get_safe(target, "$.RoleArn"): + # raise ValueError("RoleArn is required for Kinesis target") + # if not collections.get_safe(target, "$.KinesisParameters.PartitionKeyPath"): + # raise ValueError("KinesisParameters.PartitionKeyPath is required for Kinesis target") + + +class LambdaTargetSender(TargetSender): + def send_event(self, event, trace_header): + self.client.invoke( + FunctionName=self.target["Arn"], + Payload=to_bytes(to_json_str(event)), + InvocationType="Event", + TraceHeader=trace_header, + ) + + +class LogsTargetSender(TargetSender): + def send_event(self, event, trace_header): + log_group_name = self.target["Arn"].split(":")[6] + log_stream_name = str(uuid.uuid4()) # Unique log stream name + + self.client.create_log_stream(logGroupName=log_group_name, logStreamName=log_stream_name) + self.client.put_log_events( + logGroupName=log_group_name, + logStreamName=log_stream_name, + logEvents=[ + { + "timestamp": now_utc(millis=True), + "message": to_json_str(event), + } + ], + ) + + +class RedshiftTargetSender(TargetSender): + def send_event(self, event, trace_header): + raise NotImplementedError("Redshift target is not yet implemented") + + def _validate_input(self, target: Target): + super()._validate_input(target) + # TODO: cover via test + # if not collections.get_safe(target, "$.RedshiftDataParameters.Database"): + # raise ValueError("RedshiftDataParameters.Database is required for Redshift target") + + +class SagemakerTargetSender(TargetSender): + def send_event(self, event, trace_header): + raise NotImplementedError("Sagemaker target is not yet implemented") + + +class SnsTargetSender(TargetSender): + def send_event(self, event, trace_header): + self.client.publish(TopicArn=self.target["Arn"], Message=to_json_str(event)) + + +class SqsTargetSender(TargetSender): + def send_event(self, event, trace_header): + queue_url = sqs_queue_url_for_arn(self.target["Arn"]) + msg_group_id = self.target.get("SqsParameters", {}).get("MessageGroupId", None) + kwargs = {"MessageGroupId": msg_group_id} if msg_group_id else {} + + self.client.send_message( + QueueUrl=queue_url, + MessageBody=to_json_str(event), + **kwargs, + ) + + +class StatesTargetSender(TargetSender): + """Step Functions Target Sender""" + + def send_event(self, event, trace_header): + self.service = "stepfunctions" + + self.client.start_execution( + stateMachineArn=self.target["Arn"], name=event["id"], input=to_json_str(event) + ) + + def _validate_input(self, target: Target): + super()._validate_input(target) + # TODO: cover via test + # if not collections.get_safe(target, "$.RoleArn"): + # raise ValueError("RoleArn is required for StepFunctions target") + + +class SystemsManagerSender(TargetSender): + """EC2 Run Command Target Sender""" + + def send_event(self, event, trace_header): + raise NotImplementedError("Systems Manager target is not yet implemented") + + def _validate_input(self, target: Target): + super()._validate_input(target) + # TODO: cover via test + # if not collections.get_safe(target, "$.RoleArn"): + # raise ValueError( + # "RoleArn is required for SystemManager target to invoke a EC2 run command" + # ) + # if not collections.get_safe(target, "$.RunCommandParameters.RunCommandTargets"): + # raise ValueError( + # "RunCommandParameters.RunCommandTargets is required for Systems Manager target" + # ) + + +class TargetSenderFactory: + # supported targets: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-targets.html + target: Target + rule_arn: Arn + rule_name: RuleName + region: str + account_id: str + + target_map = { + "apigateway": ApiGatewayTargetSender, + "appsync": AppSyncTargetSender, + "batch": BatchTargetSender, + "ecs": ECSTargetSender, + "events": EventsTargetSender, + "events_api_destination": EventsApiDestinationTargetSender, + "firehose": FirehoseTargetSender, + "kinesis": KinesisTargetSender, + "lambda": LambdaTargetSender, + "logs": LogsTargetSender, + "redshift": RedshiftTargetSender, + "sns": SnsTargetSender, + "sqs": SqsTargetSender, + "sagemaker": SagemakerTargetSender, + "ssm": SystemsManagerSender, + "states": StatesTargetSender, + "execute-api": ApiGatewayTargetSender, + # TODO custom endpoints via http target + } + + def __init__( + self, target: Target, rule_arn: Arn, rule_name: RuleName, region: str, account_id: str + ): + self.target = target + self.rule_arn = rule_arn + self.rule_name = rule_name + self.region = region + self.account_id = account_id + + @classmethod + def register_target_sender(cls, service_name: str, sender_class: Type[TargetSender]): + cls.target_map[service_name] = sender_class + + def get_target_sender(self) -> TargetSender: + target_arn = self.target["Arn"] + service = extract_service_from_arn(target_arn) + if ":api-destination/" in target_arn or ":destination/" in target_arn: + service = "events_api_destination" + if service in self.target_map: + target_sender_class = self.target_map[service] + else: + raise Exception(f"Unsupported target for Service: {service}") + target_sender = target_sender_class( + self.target, self.rule_arn, self.rule_name, service, self.region, self.account_id + ) + return target_sender diff --git a/localstack-core/localstack/services/events/utils.py b/localstack-core/localstack/services/events/utils.py new file mode 100644 index 0000000000000..5ac8e835b136f --- /dev/null +++ b/localstack-core/localstack/services/events/utils.py @@ -0,0 +1,296 @@ +import json +import logging +import re +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from botocore.utils import ArnParser + +from localstack.aws.api import RequestContext +from localstack.aws.api.events import ( + ArchiveName, + Arn, + ConnectionArn, + ConnectionName, + EventBusName, + EventBusNameOrArn, + EventTime, + PutEventsRequestEntry, + RuleArn, + Timestamp, +) +from localstack.services.events.models import ( + FormattedEvent, + ResourceType, + TransformedEvent, + ValidationException, +) +from localstack.utils.aws.arns import ARN_PARTITION_REGEX, parse_arn +from localstack.utils.strings import long_uid + +LOG = logging.getLogger(__name__) + +RULE_ARN_CUSTOM_EVENT_BUS_PATTERN = re.compile( + rf"{ARN_PARTITION_REGEX}:events:[a-z0-9-]+:\d{{12}}:rule/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$" +) + +RULE_ARN_ARCHIVE_PATTERN = re.compile( + rf"{ARN_PARTITION_REGEX}:events:[a-z0-9-]+:\d{{12}}:archive/[a-zA-Z0-9_-]+$" +) +ARCHIVE_NAME_ARN_PATTERN = re.compile( + rf"{ARN_PARTITION_REGEX}:events:[a-z0-9-]+:\d{{12}}:archive/(?P.+)$" +) +CONNECTION_NAME_ARN_PATTERN = re.compile( + rf"{ARN_PARTITION_REGEX}:events:[a-z0-9-]+:\d{{12}}:connection/(?P[^/]+)/(?P[^/]+)$" +) + +TARGET_ID_PATTERN = re.compile(r"[\.\-_A-Za-z0-9]+") + + +class EventJSONEncoder(json.JSONEncoder): + """This json encoder is used to serialize datetime object + of a eventbridge event to time strings.""" + + def default(self, obj): + if isinstance(obj, datetime): + return event_time_to_time_string(obj) + return super().default(obj) + + +def to_json_str(obj: Any, separators: Optional[tuple[str, str]] = (",", ":")) -> str: + return json.dumps(obj, cls=EventJSONEncoder, separators=separators) + + +def extract_region_and_account_id( + name_or_arn: EventBusNameOrArn, context: RequestContext +) -> tuple[str, str]: + """Returns the region and account id from the arn, + or falls back on the region and account id of the context""" + account_id = None + region = None + if ArnParser.is_arn(name_or_arn): + parsed_arn = parse_arn(name_or_arn) + region = parsed_arn.get("region") + account_id = parsed_arn.get("account") + if not account_id or not region: + region = context.get("region") + account_id = context.get("account_id") + return region, account_id + + +def extract_event_bus_name( + resource_arn_or_name: EventBusNameOrArn | RuleArn | None, +) -> EventBusName: + """Return the event bus name. Input can be either an event bus name or ARN.""" + if not resource_arn_or_name: + return "default" + if not re.match(f"{ARN_PARTITION_REGEX}:events", resource_arn_or_name): + return resource_arn_or_name + resource_type = get_resource_type(resource_arn_or_name) + if resource_type == ResourceType.EVENT_BUS: + return resource_arn_or_name.split("/")[-1] + if resource_type == ResourceType.RULE: + if bool(RULE_ARN_CUSTOM_EVENT_BUS_PATTERN.match(resource_arn_or_name)): + return resource_arn_or_name.split("rule/", 1)[1].split("/", 1)[0] + return "default" + + +def extract_connection_name( + connection_arn: ConnectionArn, +) -> ConnectionName: + match = CONNECTION_NAME_ARN_PATTERN.match(connection_arn) + if not match: + raise ValidationException( + f"Parameter {connection_arn} is not valid. Reason: Provided Arn is not in correct format." + ) + return match.group("name") + + +def extract_archive_name(arn: Arn) -> ArchiveName: + match = ARCHIVE_NAME_ARN_PATTERN.match(arn) + if not match: + raise ValidationException( + f"Parameter {arn} is not valid. Reason: Provided Arn is not in correct format." + ) + return match.group("name") + + +def is_archive_arn(arn: Arn) -> bool: + return bool(RULE_ARN_ARCHIVE_PATTERN.match(arn)) + + +def get_resource_type(arn: Arn) -> ResourceType: + parsed_arn = parse_arn(arn) + resource_type = parsed_arn["resource"].split("/", 1)[0] + if resource_type == "event-bus": + return ResourceType.EVENT_BUS + if resource_type == "rule": + return ResourceType.RULE + raise ValidationException( + f"Parameter {arn} is not valid. Reason: Provided Arn is not in correct format." + ) + + +def get_event_time(event: PutEventsRequestEntry) -> EventTime: + event_time = datetime.now(timezone.utc) + if event_timestamp := event.get("Time"): + try: + # use time from event if provided + event_time = event_timestamp.replace(tzinfo=timezone.utc) + except ValueError: + # use current time if event time is invalid + LOG.debug( + "Could not parse the `Time` parameter, falling back to current time for the following Event: '%s'", + event, + ) + return event_time + + +def event_time_to_time_string(event_time: EventTime) -> str: + return event_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def convert_to_timezone_aware_datetime( + timestamp: Timestamp, +) -> Timestamp: + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) + return timestamp + + +def recursive_remove_none_values_from_dict(d: Dict[str, Any]) -> Dict[str, Any]: + """ + Recursively removes keys with non values from a dictionary. + """ + if not isinstance(d, dict): + return d + + clean_dict = {} + for key, value in d.items(): + if value is None: + continue + if isinstance(value, list): + nested_list = [recursive_remove_none_values_from_dict(item) for item in value] + nested_list = [item for item in nested_list if item] + if nested_list: + clean_dict[key] = nested_list + elif isinstance(value, dict): + nested_dict = recursive_remove_none_values_from_dict(value) + if nested_dict: + clean_dict[key] = nested_dict + else: + clean_dict[key] = value + return clean_dict + + +def format_event( + event: PutEventsRequestEntry, region: str, account_id: str, event_bus_name: EventBusName +) -> FormattedEvent: + # See https://docs.aws.amazon.com/AmazonS3/latest/userguide/ev-events.html + # region_name and account_id of original event is preserved fro cross-region event bus communication + trace_header = event.get("TraceHeader") + message = {} + if trace_header: + try: + message = json.loads(trace_header) + except json.JSONDecodeError: + pass + message_id = message.get("original_id", str(long_uid())) + region = message.get("original_region", region) + account_id = message.get("original_account", account_id) + # Format the datetime to ISO-8601 string + event_time = get_event_time(event) + formatted_time = event_time_to_time_string(event_time) + + formatted_event = { + "version": "0", + "id": message_id, + "detail-type": event.get("DetailType"), + "source": event.get("Source"), + "account": account_id, + "time": formatted_time, + "region": region, + "resources": event.get("Resources", []), + "detail": json.loads(event.get("Detail", "{}")), + "event-bus-name": event_bus_name, # current workaround for EventStudio extension + } + if replay_name := event.get("ReplayName"): + formatted_event["replay-name"] = replay_name # required for replay from archive + + return formatted_event + + +def re_format_event(event: FormattedEvent, event_bus_name: EventBusName) -> PutEventsRequestEntry: + """Transforms the event to the original event structure.""" + re_formatted_event = { + "Source": event["source"], + "DetailType": event[ + "detail-type" + ], # detail_type automatically interpreted as detail-type in typedict + "Detail": json.dumps(event["detail"]), + "Time": event["time"], + } + if event.get("resources"): + re_formatted_event["Resources"] = event["resources"] + if event_bus_name: + re_formatted_event["EventBusName"] = event_bus_name + if event.get("replay-name"): + re_formatted_event["ReplayName"] = event["replay_name"] + return re_formatted_event + + +def get_trace_header_encoded_region_account( + event: PutEventsRequestEntry | FormattedEvent | TransformedEvent, + source_region: str, + source_account_id: str, + target_region: str, + target_account_id: str, +) -> str | None: + """Encode the original region and account_id for cross-region and cross-account + event bus communication in the trace header. For event bus to event bus communication + in a different account the event id is preserved. This is not the case if the region differs.""" + if event.get("TraceHeader"): + return None + if source_region != target_region and source_account_id != target_account_id: + return json.dumps( + { + "original_region": source_region, + "original_account": source_account_id, + } + ) + if source_region != target_region: + return json.dumps({"original_region": source_region}) + if source_account_id != target_account_id: + if original_id := event.get("id"): + return json.dumps({"original_id": original_id, "original_account": source_account_id}) + else: + return json.dumps({"original_account": source_account_id}) + + +def is_nested_in_string(template: str, match: re.Match[str]) -> bool: + """ + Determines if a match (string) is within quotes in the given template. + + Examples: + True for "users-service/users/" # nested within larger string + True for "" # simple quoted placeholder + True for "Hello " # nested within larger string + False for {"id": } # not in quotes at all + """ + start = match.start() + end = match.end() + + left_quote = template.rfind('"', 0, start) + right_quote = template.find('"', end) + next_comma = template.find(",", end) + next_brace = template.find("}", end) + + # If no right quote, or if comma/brace comes before right quote, not nested + if ( + right_quote == -1 + or (next_comma != -1 and next_comma < right_quote) + or (next_brace != -1 and next_brace < right_quote) + ): + return False + + return left_quote != -1 diff --git a/localstack-core/localstack/services/events/v1/__init__.py b/localstack-core/localstack/services/events/v1/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/events/v1/models.py b/localstack-core/localstack/services/events/v1/models.py new file mode 100644 index 0000000000000..4096215c82499 --- /dev/null +++ b/localstack-core/localstack/services/events/v1/models.py @@ -0,0 +1,11 @@ +from typing import Dict + +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + + +class EventsStore(BaseStore): + # maps rule name to job_id + rule_scheduled_jobs: Dict[str, str] = LocalAttribute(default=dict) + + +events_stores = AccountRegionBundle("events", EventsStore) diff --git a/localstack-core/localstack/services/events/v1/provider.py b/localstack-core/localstack/services/events/v1/provider.py new file mode 100644 index 0000000000000..9e3da8e447f6a --- /dev/null +++ b/localstack-core/localstack/services/events/v1/provider.py @@ -0,0 +1,536 @@ +import datetime +import json +import logging +import os +import re +import time +from typing import Any, Dict, Optional + +from moto.events import events_backends +from moto.events.responses import EventsHandler as MotoEventsHandler +from werkzeug import Request +from werkzeug.exceptions import NotFound + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.api.core import CommonServiceException, ServiceException +from localstack.aws.api.events import ( + Boolean, + ConnectionAuthorizationType, + ConnectionDescription, + ConnectionName, + ConnectivityResourceParameters, + CreateConnectionAuthRequestParameters, + CreateConnectionResponse, + EventBusNameOrArn, + EventPattern, + EventsApi, + KmsKeyIdentifier, + PutRuleResponse, + PutTargetsResponse, + RoleArn, + RuleDescription, + RuleName, + RuleState, + ScheduleExpression, + String, + TagList, + TargetList, + TestEventPatternResponse, +) +from localstack.constants import APPLICATION_AMZ_JSON_1_1 +from localstack.http import route +from localstack.services.edge import ROUTER +from localstack.services.events.scheduler import JobScheduler +from localstack.services.events.v1.models import EventsStore, events_stores +from localstack.services.moto import call_moto +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.aws.arns import event_bus_arn, parse_arn +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.aws.message_forwarding import send_event_to_target +from localstack.utils.collections import pick_attributes +from localstack.utils.common import TMP_FILES, mkdir, save_file, truncate +from localstack.utils.event_matcher import matches_event +from localstack.utils.json import extract_jsonpath +from localstack.utils.strings import long_uid, short_uid +from localstack.utils.time import TIMESTAMP_FORMAT_TZ, timestamp + +LOG = logging.getLogger(__name__) + +# list of events used to run assertions during integration testing (not exposed to the user) +TEST_EVENTS_CACHE = [] +EVENTS_TMP_DIR = "cw_events" +DEFAULT_EVENT_BUS_NAME = "default" +CONNECTION_NAME_PATTERN = re.compile("^[\\.\\-_A-Za-z0-9]+$") + + +class ValidationException(ServiceException): + code: str = "ValidationException" + sender_fault: bool = True + status_code: int = 400 + + +class EventsProvider(EventsApi, ServiceLifecycleHook): + def __init__(self): + apply_patches() + + def on_after_init(self): + ROUTER.add(self.trigger_scheduled_rule) + + def on_before_start(self): + JobScheduler.start() + + def on_before_stop(self): + JobScheduler.shutdown() + + @route("/_aws/events/rules//trigger") + def trigger_scheduled_rule(self, request: Request, rule_arn: str): + """Developer endpoint to trigger a scheduled rule.""" + arn_data = parse_arn(rule_arn) + account_id = arn_data["account"] + region = arn_data["region"] + rule_name = arn_data["resource"].split("/", maxsplit=1)[-1] + + job_id = events_stores[account_id][region].rule_scheduled_jobs.get(rule_name) + if not job_id: + raise NotFound() + job = JobScheduler().instance().get_job(job_id) + if not job: + raise NotFound() + + # TODO: once job scheduler is refactored, we can update the deadline of the task instead of running + # it here + job.run() + + @staticmethod + def get_store(context: RequestContext) -> EventsStore: + return events_stores[context.account_id][context.region] + + def test_event_pattern( + self, context: RequestContext, event_pattern: EventPattern, event: String, **kwargs + ) -> TestEventPatternResponse: + """Test event pattern uses EventBridge event pattern matching: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html + """ + result = matches_event(event_pattern, event) + return TestEventPatternResponse(Result=result) + + @staticmethod + def get_scheduled_rule_func( + store: EventsStore, + rule_name: RuleName, + event_bus_name_or_arn: Optional[EventBusNameOrArn] = None, + ): + def func(*args, **kwargs): + account_id = store._account_id + region = store._region_name + moto_backend = events_backends[account_id][region] + event_bus_name = get_event_bus_name(event_bus_name_or_arn) + event_bus = moto_backend.event_buses[event_bus_name] + rule = event_bus.rules.get(rule_name) + if not rule: + LOG.info("Unable to find rule `%s` for event bus `%s`", rule_name, event_bus_name) + return + if rule.targets: + LOG.debug( + "Notifying %s targets in response to triggered Events rule %s", + len(rule.targets), + rule_name, + ) + + default_event = { + "version": "0", + "id": long_uid(), + "detail-type": "Scheduled Event", + "source": "aws.events", + "account": account_id, + "time": timestamp(format=TIMESTAMP_FORMAT_TZ), + "region": region, + "resources": [rule.arn], + "detail": {}, + } + + for target in rule.targets: + arn = target.get("Arn") + + if input_ := target.get("Input"): + event = json.loads(input_) + else: + event = default_event + if target.get("InputPath"): + event = filter_event_with_target_input_path(target, event) + if input_transformer := target.get("InputTransformer"): + event = process_event_with_input_transformer(input_transformer, event) + + attr = pick_attributes(target, ["$.SqsParameters", "$.KinesisParameters"]) + + try: + send_event_to_target( + arn, + event, + target_attributes=attr, + role=target.get("RoleArn"), + target=target, + source_arn=rule.arn, + source_service=ServicePrincipal.events, + ) + except Exception as e: + LOG.info( + "Unable to send event notification %s to target %s: %s", + truncate(event), + target, + e, + ) + + return func + + @staticmethod + def convert_schedule_to_cron(schedule): + """Convert Events schedule like "cron(0 20 * * ? *)" or "rate(5 minutes)" """ + cron_regex = r"\s*cron\s*\(([^\)]*)\)\s*" + if re.match(cron_regex, schedule): + cron = re.sub(cron_regex, r"\1", schedule) + return cron + rate_regex = r"\s*rate\s*\(([^\)]*)\)\s*" + if re.match(rate_regex, schedule): + rate = re.sub(rate_regex, r"\1", schedule) + value, unit = re.split(r"\s+", rate.strip()) + + value = int(value) + if value < 1: + raise ValueError("Rate value must be larger than 0") + # see https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-rate-expressions.html + if value == 1 and unit.endswith("s"): + raise ValueError("If the value is equal to 1, then the unit must be singular") + if value > 1 and not unit.endswith("s"): + raise ValueError("If the value is greater than 1, the unit must be plural") + + if "minute" in unit: + return "*/%s * * * *" % value + if "hour" in unit: + return "0 */%s * * *" % value + if "day" in unit: + return "0 0 */%s * *" % value + raise ValueError("Unable to parse events schedule expression: %s" % schedule) + return schedule + + @staticmethod + def put_rule_job_scheduler( + store: EventsStore, + name: Optional[RuleName], + state: Optional[RuleState], + schedule_expression: Optional[ScheduleExpression], + event_bus_name_or_arn: Optional[EventBusNameOrArn] = None, + ): + if not schedule_expression: + return + + try: + cron = EventsProvider.convert_schedule_to_cron(schedule_expression) + except ValueError as e: + LOG.error("Error parsing schedule expression: %s", e) + raise ValidationException("Parameter ScheduleExpression is not valid.") from e + + job_func = EventsProvider.get_scheduled_rule_func( + store, name, event_bus_name_or_arn=event_bus_name_or_arn + ) + LOG.debug("Adding new scheduled Events rule with cron schedule %s", cron) + + enabled = state != "DISABLED" + job_id = JobScheduler.instance().add_job(job_func, cron, enabled) + rule_scheduled_jobs = store.rule_scheduled_jobs + rule_scheduled_jobs[name] = job_id + + def put_rule( + self, + context: RequestContext, + name: RuleName, + schedule_expression: ScheduleExpression = None, + event_pattern: EventPattern = None, + state: RuleState = None, + description: RuleDescription = None, + role_arn: RoleArn = None, + tags: TagList = None, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> PutRuleResponse: + store = self.get_store(context) + self.put_rule_job_scheduler( + store, name, state, schedule_expression, event_bus_name_or_arn=event_bus_name + ) + return call_moto(context) + + def delete_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn = None, + force: Boolean = None, + **kwargs, + ) -> None: + rule_scheduled_jobs = self.get_store(context).rule_scheduled_jobs + job_id = rule_scheduled_jobs.get(name) + if job_id: + LOG.debug("Removing scheduled Events: %s | job_id: %s", name, job_id) + JobScheduler.instance().cancel_job(job_id=job_id) + call_moto(context) + + def disable_rule( + self, + context: RequestContext, + name: RuleName, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> None: + rule_scheduled_jobs = self.get_store(context).rule_scheduled_jobs + job_id = rule_scheduled_jobs.get(name) + if job_id: + LOG.debug("Disabling Rule: %s | job_id: %s", name, job_id) + JobScheduler.instance().disable_job(job_id=job_id) + call_moto(context) + + def create_connection( + self, + context: RequestContext, + name: ConnectionName, + authorization_type: ConnectionAuthorizationType, + auth_parameters: CreateConnectionAuthRequestParameters, + description: ConnectionDescription = None, + invocation_connectivity_parameters: ConnectivityResourceParameters = None, + kms_key_identifier: KmsKeyIdentifier = None, + **kwargs, + ) -> CreateConnectionResponse: + # TODO add support for kms_key_identifier + errors = [] + + if not CONNECTION_NAME_PATTERN.match(name): + error = f"{name} at 'name' failed to satisfy: Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+" + errors.append(error) + + if len(name) > 64: + error = f"{name} at 'name' failed to satisfy: Member must have length less than or equal to 64" + errors.append(error) + + if authorization_type not in ["BASIC", "API_KEY", "OAUTH_CLIENT_CREDENTIALS"]: + error = f"{authorization_type} at 'authorizationType' failed to satisfy: Member must satisfy enum value set: [BASIC, OAUTH_CLIENT_CREDENTIALS, API_KEY]" + errors.append(error) + + if len(errors) > 0: + error_description = "; ".join(errors) + error_plural = "errors" if len(errors) > 1 else "error" + errors_amount = len(errors) + message = f"{errors_amount} validation {error_plural} detected: {error_description}" + raise CommonServiceException(message=message, code="ValidationException") + + return call_moto(context) + + def put_targets( + self, + context: RequestContext, + rule: RuleName, + targets: TargetList, + event_bus_name: EventBusNameOrArn = None, + **kwargs, + ) -> PutTargetsResponse: + validation_errors = [] + + id_regex = re.compile(r"^[\.\-_A-Za-z0-9]+$") + for index, target in enumerate(targets): + id = target.get("Id") + if not id_regex.match(id): + validation_errors.append( + f"Value '{id}' at 'targets.{index + 1}.member.id' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+" + ) + + if len(id) > 64: + validation_errors.append( + f"Value '{id}' at 'targets.{index + 1}.member.id' failed to satisfy constraint: Member must have length less than or equal to 64" + ) + + if validation_errors: + errors_message = "; ".join(validation_errors) + message = f"{len(validation_errors)} validation {'errors' if len(validation_errors) > 1 else 'error'} detected: {errors_message}" + raise CommonServiceException(message=message, code="ValidationException") + + return call_moto(context) + + +def _get_events_tmp_dir(): + return os.path.join(config.dirs.tmp, EVENTS_TMP_DIR) + + +def _create_and_register_temp_dir(): + tmp_dir = _get_events_tmp_dir() + if not os.path.exists(tmp_dir): + mkdir(tmp_dir) + TMP_FILES.append(tmp_dir) + return tmp_dir + + +def _dump_events_to_files(events_with_added_uuid): + try: + _create_and_register_temp_dir() + current_time_millis = int(round(time.time() * 1000)) + for event in events_with_added_uuid: + target = os.path.join( + _get_events_tmp_dir(), + "%s_%s" % (current_time_millis, event["uuid"]), + ) + save_file(target, json.dumps(event["event"])) + except Exception as e: + LOG.info("Unable to dump events to tmp dir %s: %s", _get_events_tmp_dir(), e) + + +def filter_event_based_on_event_format( + self, rule_name: str, event_bus_name: str, event: dict[str, Any] +): + rule_information = self.events_backend.describe_rule( + rule_name, event_bus_arn(event_bus_name, self.current_account, self.region) + ) + + if not rule_information: + LOG.info('Unable to find rule "%s" in backend: %s', rule_name, rule_information) + return False + if rule_information.event_pattern._pattern: + event_pattern = rule_information.event_pattern._pattern + if not matches_event(event_pattern, event): + return False + return True + + +def filter_event_with_target_input_path(target: Dict, event: Dict) -> Dict: + input_path = target.get("InputPath") + if input_path: + event = extract_jsonpath(event, input_path) + return event + + +def process_event_with_input_transformer(input_transformer: Dict, event: Dict) -> Dict: + """ + Process the event with the input transformer of the target event, + by replacing the message with the populated InputTemplate. + docs.aws.amazon.com/eventbridge/latest/userguide/eb-transform-target-input.html + """ + try: + input_paths = input_transformer["InputPathsMap"] + input_template = input_transformer["InputTemplate"] + except KeyError as e: + LOG.error("%s key does not exist in input_transformer.", e) + raise e + for key, path in input_paths.items(): + value = extract_jsonpath(event, path) + if not value: + value = "" + input_template = input_template.replace(f"<{key}>", value) + templated_event = re.sub('"', "", input_template) + return templated_event + + +def process_events(event: Dict, targets: list[Dict]): + for target in targets: + arn = target["Arn"] + changed_event = filter_event_with_target_input_path(target, event) + if input_transformer := target.get("InputTransformer"): + changed_event = process_event_with_input_transformer(input_transformer, changed_event) + if target.get("Input"): + changed_event = json.loads(target.get("Input")) + try: + send_event_to_target( + arn, + changed_event, + pick_attributes(target, ["$.SqsParameters", "$.KinesisParameters"]), + role=target.get("RoleArn"), + target=target, + source_service=ServicePrincipal.events, + source_arn=target.get("RuleArn"), + ) + except Exception as e: + LOG.info( + "Unable to send event notification %s to target %s: %s", + truncate(event), + target, + e, + ) + + +def get_event_bus_name(event_bus_name_or_arn: Optional[EventBusNameOrArn] = None) -> str: + event_bus_name_or_arn = event_bus_name_or_arn or DEFAULT_EVENT_BUS_NAME + return event_bus_name_or_arn.split("/")[-1] + + +# specific logic for put_events which forwards matching events to target listeners +def events_handler_put_events(self): + entries = self._get_param("Entries") + + # keep track of events for local integration testing + if config.is_local_test_mode(): + TEST_EVENTS_CACHE.extend(entries) + + events = [{"event": event, "uuid": str(long_uid())} for event in entries] + + _dump_events_to_files(events) + + for event_envelope in events: + event = event_envelope["event"] + event_bus_name = get_event_bus_name(event.get("EventBusName")) + event_bus = self.events_backend.event_buses.get(event_bus_name) + if not event_bus: + continue + + matching_rules = [ + r + for r in event_bus.rules.values() + if r.event_bus_name == event_bus_name and not r.scheduled_expression + ] + if not matching_rules: + continue + + event_time = datetime.datetime.utcnow() + if event_timestamp := event.get("Time"): + try: + # if provided, use the time from event + event_time = datetime.datetime.utcfromtimestamp(event_timestamp) + except ValueError: + # if we can't parse it, pass and keep using `utcnow` + LOG.debug( + "Could not parse the `Time` parameter, falling back to `utcnow` for the following Event: '%s'", + event, + ) + + # See https://docs.aws.amazon.com/AmazonS3/latest/userguide/ev-events.html + formatted_event = { + "version": "0", + "id": event_envelope["uuid"], + "detail-type": event.get("DetailType"), + "source": event.get("Source"), + "account": self.current_account, + "time": event_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + "region": self.region, + "resources": event.get("Resources", []), + "detail": json.loads(event.get("Detail", "{}")), + } + + targets = [] + for rule in matching_rules: + if filter_event_based_on_event_format(self, rule.name, event_bus_name, formatted_event): + rule_targets, _ = self.events_backend.list_targets_by_rule( + rule.name, event_bus_arn(event_bus_name, self.current_account, self.region) + ) + targets.extend([{"RuleArn": rule.arn} | target for target in rule_targets]) + # process event + process_events(formatted_event, targets) + + content = { + "FailedEntryCount": 0, # TODO: dynamically set proper value when refactoring + "Entries": [{"EventId": event["uuid"]} for event in events], + } + + self.response_headers.update( + {"Content-Type": APPLICATION_AMZ_JSON_1_1, "x-amzn-RequestId": short_uid()} + ) + + return json.dumps(content), self.response_headers + + +def apply_patches(): + MotoEventsHandler.put_events = events_handler_put_events diff --git a/localstack-core/localstack/services/firehose/__init__.py b/localstack-core/localstack/services/firehose/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/firehose/mappers.py b/localstack-core/localstack/services/firehose/mappers.py new file mode 100644 index 0000000000000..f262db136020a --- /dev/null +++ b/localstack-core/localstack/services/firehose/mappers.py @@ -0,0 +1,172 @@ +from datetime import datetime +from typing import cast + +from localstack.aws.api.firehose import ( + AmazonopensearchserviceDestinationConfiguration, + AmazonopensearchserviceDestinationDescription, + AmazonopensearchserviceDestinationUpdate, + ElasticsearchDestinationConfiguration, + ElasticsearchDestinationDescription, + ElasticsearchDestinationUpdate, + ExtendedS3DestinationConfiguration, + ExtendedS3DestinationDescription, + ExtendedS3DestinationUpdate, + HttpEndpointDestinationConfiguration, + HttpEndpointDestinationDescription, + HttpEndpointDestinationUpdate, + KinesisStreamSourceConfiguration, + KinesisStreamSourceDescription, + RedshiftDestinationConfiguration, + RedshiftDestinationDescription, + S3DestinationConfiguration, + S3DestinationDescription, + S3DestinationUpdate, + SourceDescription, + VpcConfigurationDescription, +) + + +def convert_es_config_to_desc( + configuration: ElasticsearchDestinationConfiguration, +) -> ElasticsearchDestinationDescription: + if configuration is not None: + # Just take the whole typed dict and typecast it to our target type + result = cast(ElasticsearchDestinationDescription, configuration) + # Only specifically handle keys which are named differently or their values differ (version and clusterconfig) + result["S3DestinationDescription"] = convert_s3_config_to_desc( + configuration["S3Configuration"] + ) + if "VpcConfiguration" in configuration: + result["VpcConfigurationDescription"] = cast( + VpcConfigurationDescription, configuration["VpcConfiguration"] + ) + result.pop("S3Configuration", None) + result.pop("VpcConfiguration", None) + return result + + +def convert_es_update_to_desc( + update: ElasticsearchDestinationUpdate, +) -> ElasticsearchDestinationDescription: + if update is not None: + # Just take the whole typed dict and typecast it to our target type + result = cast(ElasticsearchDestinationDescription, update) + # Only specifically handle keys which are named differently or their values differ (version and clusterconfig) + if "S3Update" in update: + result["S3DestinationDescription"] = cast(S3DestinationDescription, update["S3Update"]) + result.pop("S3Update", None) + return result + + +def convert_opensearch_config_to_desc( + configuration: AmazonopensearchserviceDestinationConfiguration, +) -> AmazonopensearchserviceDestinationDescription: + if configuration is not None: + # Just take the whole typed dict and typecast it to our target type + result = cast(AmazonopensearchserviceDestinationDescription, configuration) + # Only specifically handle keys which are named differently or their values differ (version and clusterconfig) + if "S3Configuration" in configuration: + result["S3DestinationDescription"] = convert_s3_config_to_desc( + configuration["S3Configuration"] + ) + if "VpcConfiguration" in configuration: + result["VpcConfigurationDescription"] = cast( + VpcConfigurationDescription, configuration["VpcConfiguration"] + ) + result.pop("S3Configuration", None) + result.pop("VpcConfiguration", None) + return result + + +def convert_opensearch_update_to_desc( + update: AmazonopensearchserviceDestinationUpdate, +) -> AmazonopensearchserviceDestinationDescription: + if update is not None: + # Just take the whole typed dict and typecast it to our target type + result = cast(AmazonopensearchserviceDestinationDescription, update) + # Only specifically handle keys which are named differently or their values differ (version and clusterconfig) + if "S3Update" in update: + result["S3DestinationDescription"] = cast(S3DestinationDescription, update["S3Update"]) + result.pop("S3Update", None) + return result + + +def convert_s3_config_to_desc( + configuration: S3DestinationConfiguration, +) -> S3DestinationDescription: + if configuration: + return cast(S3DestinationDescription, configuration) + + +def convert_s3_update_to_desc(update: S3DestinationUpdate) -> S3DestinationDescription: + if update: + return cast(S3DestinationDescription, update) + + +def convert_extended_s3_config_to_desc( + configuration: ExtendedS3DestinationConfiguration, +) -> ExtendedS3DestinationDescription: + if configuration: + result = cast(ExtendedS3DestinationDescription, configuration) + if "S3BackupConfiguration" in configuration: + result["S3BackupDescription"] = convert_s3_config_to_desc( + configuration["S3BackupConfiguration"] + ) + result.pop("S3BackupConfiguration", None) + return result + + +def convert_extended_s3_update_to_desc( + update: ExtendedS3DestinationUpdate, +) -> ExtendedS3DestinationDescription: + if update: + result = cast(ExtendedS3DestinationDescription, update) + if "S3BackupUpdate" in update: + result["S3BackupDescription"] = convert_s3_update_to_desc(update["S3BackupUpdate"]) + result.pop("S3BackupUpdate", None) + return result + + +def convert_http_config_to_desc( + configuration: HttpEndpointDestinationConfiguration, +) -> HttpEndpointDestinationDescription: + if configuration: + result = cast(HttpEndpointDestinationDescription, configuration) + if "S3Configuration" in configuration: + result["S3DestinationDescription"] = convert_s3_config_to_desc( + configuration["S3Configuration"] + ) + result.pop("S3Configuration", None) + return result + + +def convert_http_update_to_desc( + update: HttpEndpointDestinationUpdate, +) -> HttpEndpointDestinationDescription: + if update: + result = cast(HttpEndpointDestinationDescription, update) + if "S3Update" in update: + result["S3DestinationDescription"] = convert_s3_update_to_desc(update["S3Update"]) + result.pop("S3Update", None) + return result + + +def convert_source_config_to_desc( + configuration: KinesisStreamSourceConfiguration, +) -> SourceDescription: + if configuration: + result = cast(KinesisStreamSourceDescription, configuration) + result["DeliveryStartTimestamp"] = datetime.now() + return SourceDescription(KinesisStreamSourceDescription=result) + + +def convert_redshift_config_to_desc( + configuration: RedshiftDestinationConfiguration, +) -> RedshiftDestinationDescription: + if configuration is not None: + result = cast(RedshiftDestinationDescription, configuration) + result["S3DestinationDescription"] = convert_s3_config_to_desc( + configuration["S3Configuration"] + ) + result.pop("S3Configuration", None) + return result diff --git a/localstack-core/localstack/services/firehose/models.py b/localstack-core/localstack/services/firehose/models.py new file mode 100644 index 0000000000000..ef2e395ef9229 --- /dev/null +++ b/localstack-core/localstack/services/firehose/models.py @@ -0,0 +1,21 @@ +from typing import Dict + +from localstack.aws.api.firehose import DeliveryStreamDescription +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossRegionAttribute, + LocalAttribute, +) +from localstack.utils.tagging import TaggingService + + +class FirehoseStore(BaseStore): + # maps delivery stream names to DeliveryStreamDescription + delivery_streams: Dict[str, DeliveryStreamDescription] = LocalAttribute(default=dict) + + # static tagging service instance + TAGS = CrossRegionAttribute(default=TaggingService) + + +firehose_stores = AccountRegionBundle("firehose", FirehoseStore) diff --git a/localstack-core/localstack/services/firehose/provider.py b/localstack-core/localstack/services/firehose/provider.py new file mode 100644 index 0000000000000..18142ae80d88b --- /dev/null +++ b/localstack-core/localstack/services/firehose/provider.py @@ -0,0 +1,970 @@ +import base64 +import functools +import json +import logging +import os +import re +import threading +import time +import uuid +from datetime import datetime +from typing import Dict, List +from urllib.parse import urlparse + +import requests + +from localstack.aws.api import RequestContext +from localstack.aws.api.firehose import ( + AmazonOpenSearchServerlessDestinationConfiguration, + AmazonOpenSearchServerlessDestinationUpdate, + AmazonopensearchserviceDestinationConfiguration, + AmazonopensearchserviceDestinationDescription, + AmazonopensearchserviceDestinationUpdate, + BooleanObject, + CreateDeliveryStreamOutput, + DatabaseSourceConfiguration, + DeleteDeliveryStreamOutput, + DeliveryStreamDescription, + DeliveryStreamEncryptionConfigurationInput, + DeliveryStreamName, + DeliveryStreamStatus, + DeliveryStreamType, + DeliveryStreamVersionId, + DescribeDeliveryStreamInputLimit, + DescribeDeliveryStreamOutput, + DestinationDescription, + DestinationDescriptionList, + DestinationId, + DirectPutSourceConfiguration, + ElasticsearchDestinationConfiguration, + ElasticsearchDestinationDescription, + ElasticsearchDestinationUpdate, + ElasticsearchS3BackupMode, + ExtendedS3DestinationConfiguration, + ExtendedS3DestinationUpdate, + FirehoseApi, + HttpEndpointDestinationConfiguration, + HttpEndpointDestinationUpdate, + IcebergDestinationConfiguration, + IcebergDestinationUpdate, + InvalidArgumentException, + KinesisStreamSourceConfiguration, + ListDeliveryStreamsInputLimit, + ListDeliveryStreamsOutput, + ListTagsForDeliveryStreamInputLimit, + ListTagsForDeliveryStreamOutput, + ListTagsForDeliveryStreamOutputTagList, + MSKSourceConfiguration, + PutRecordBatchOutput, + PutRecordBatchRequestEntryList, + PutRecordBatchResponseEntry, + PutRecordOutput, + Record, + RedshiftDestinationConfiguration, + RedshiftDestinationDescription, + RedshiftDestinationUpdate, + ResourceInUseException, + ResourceNotFoundException, + S3DestinationConfiguration, + S3DestinationDescription, + S3DestinationUpdate, + SnowflakeDestinationConfiguration, + SnowflakeDestinationUpdate, + SplunkDestinationConfiguration, + SplunkDestinationUpdate, + TagDeliveryStreamInputTagList, + TagDeliveryStreamOutput, + TagKey, + TagKeyList, + UntagDeliveryStreamOutput, + UpdateDestinationOutput, +) +from localstack.aws.connect import connect_to +from localstack.services.firehose.mappers import ( + convert_es_config_to_desc, + convert_es_update_to_desc, + convert_extended_s3_config_to_desc, + convert_extended_s3_update_to_desc, + convert_http_config_to_desc, + convert_http_update_to_desc, + convert_opensearch_config_to_desc, + convert_opensearch_update_to_desc, + convert_redshift_config_to_desc, + convert_s3_config_to_desc, + convert_s3_update_to_desc, + convert_source_config_to_desc, +) +from localstack.services.firehose.models import FirehoseStore, firehose_stores +from localstack.utils.aws.arns import ( + extract_account_id_from_arn, + extract_region_from_arn, + firehose_stream_arn, + opensearch_domain_name, + s3_bucket_name, +) +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.collections import select_from_typed_dict +from localstack.utils.common import ( + TIMESTAMP_FORMAT_MICROS, + first_char_to_lower, + keys_to_lower, + now_utc, + short_uid, + timestamp, + to_bytes, + to_str, + truncate, +) +from localstack.utils.kinesis import kinesis_connector +from localstack.utils.kinesis.kinesis_connector import KinesisProcessorThread +from localstack.utils.run import run_for_max_seconds + +LOG = logging.getLogger(__name__) + +# global sequence number counter for Firehose records (these are very large long values in AWS) +SEQUENCE_NUMBER = 49546986683135544286507457936321625675700192471156785154 +SEQUENCE_NUMBER_MUTEX = threading.RLock() + + +def next_sequence_number() -> int: + """Increase and return the next global sequence number.""" + global SEQUENCE_NUMBER + with SEQUENCE_NUMBER_MUTEX: + SEQUENCE_NUMBER += 1 + return SEQUENCE_NUMBER + + +def _get_description_or_raise_not_found( + context, delivery_stream_name: str +) -> DeliveryStreamDescription: + store = FirehoseProvider.get_store(context.account_id, context.region) + delivery_stream_description = store.delivery_streams.get(delivery_stream_name) + if not delivery_stream_description: + raise ResourceNotFoundException( + f"Firehose {delivery_stream_name} under account {context.account_id} not found." + ) + return delivery_stream_description + + +def get_opensearch_endpoint(domain_arn: str) -> str: + """ + Get an OpenSearch cluster endpoint by describing the cluster associated with the domain_arn + :param domain_arn: ARN of the cluster. + :returns: cluster endpoint + :raises: ValueError if the domain_arn is malformed + """ + account_id = extract_account_id_from_arn(domain_arn) + region_name = extract_region_from_arn(domain_arn) + if region_name is None: + raise ValueError("unable to parse region from opensearch domain ARN") + opensearch_client = connect_to(aws_access_key_id=account_id, region_name=region_name).opensearch + domain_name = opensearch_domain_name(domain_arn) + info = opensearch_client.describe_domain(DomainName=domain_name) + base_domain = info["DomainStatus"]["Endpoint"] + # Add the URL scheme "http" if it's not set yet. https might not be enabled for all instances + # f.e. when the endpoint strategy is PORT or there is a custom opensearch/elasticsearch instance + endpoint = base_domain if base_domain.startswith("http") else f"http://{base_domain}" + return endpoint + + +def get_search_db_connection(endpoint: str, region_name: str): + """ + Get a connection to an ElasticSearch or OpenSearch DB + :param endpoint: cluster endpoint + :param region_name: cluster region e.g. us-east-1 + """ + from opensearchpy import OpenSearch, RequestsHttpConnection + from requests_aws4auth import AWS4Auth + + verify_certs = False + use_ssl = False + # use ssl? + if "https://" in endpoint: + use_ssl = True + # TODO remove this condition once ssl certs are available for .es.localhost.localstack.cloud domains + endpoint_netloc = urlparse(endpoint).netloc + if not re.match(r"^.*(localhost(\.localstack\.cloud)?)(:\d+)?$", endpoint_netloc): + verify_certs = True + + LOG.debug("Creating ES client with endpoint %s", endpoint) + if "AWS_ACCESS_KEY_ID" in os.environ and "AWS_SECRET_ACCESS_KEY" in os.environ: + access_key = os.environ.get("AWS_ACCESS_KEY_ID") + secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY") + session_token = os.environ.get("AWS_SESSION_TOKEN") + awsauth = AWS4Auth(access_key, secret_key, region_name, "es", session_token=session_token) + connection_class = RequestsHttpConnection + return OpenSearch( + hosts=[endpoint], + verify_certs=verify_certs, + use_ssl=use_ssl, + connection_class=connection_class, + http_auth=awsauth, + ) + return OpenSearch(hosts=[endpoint], verify_certs=verify_certs, use_ssl=use_ssl) + + +def _drop_keys_in_destination_descriptions_not_in_output_types( + destinations: list, +) -> list[dict]: + """For supported destinations, drops the keys in the description not defined in the respective destination description return type""" + for destination in destinations: + if amazon_open_search_service_destination_description := destination.get( + "AmazonopensearchserviceDestinationDescription" + ): + destination["AmazonopensearchserviceDestinationDescription"] = select_from_typed_dict( + AmazonopensearchserviceDestinationDescription, + amazon_open_search_service_destination_description, + filter=True, + ) + if elasticsearch_destination_description := destination.get( + "ElasticsearchDestinationDescription" + ): + destination["ElasticsearchDestinationDescription"] = select_from_typed_dict( + ElasticsearchDestinationDescription, + elasticsearch_destination_description, + filter=True, + ) + if http_endpoint_destination_description := destination.get( + "HttpEndpointDestinationDescription" + ): + destination["HttpEndpointDestinationDescription"] = select_from_typed_dict( + HttpEndpointDestinationConfiguration, + http_endpoint_destination_description, + filter=True, + ) + if redshift_destination_description := destination.get("RedshiftDestinationDescription"): + destination["RedshiftDestinationDescription"] = select_from_typed_dict( + RedshiftDestinationDescription, + redshift_destination_description, + filter=True, + ) + if s3_destination_description := destination.get("S3DestinationDescription"): + destination["S3DestinationDescription"] = select_from_typed_dict( + S3DestinationDescription, s3_destination_description, filter=True + ) + + return destinations + + +class FirehoseProvider(FirehoseApi): + # maps a delivery_stream_arn to its kinesis thread; the arn encodes account id and region + kinesis_listeners: dict[str, KinesisProcessorThread] + + def __init__(self) -> None: + super().__init__() + self.kinesis_listeners = {} + + @staticmethod + def get_store(account_id: str, region_name: str) -> FirehoseStore: + return firehose_stores[account_id][region_name] + + def create_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + delivery_stream_type: DeliveryStreamType = None, + direct_put_source_configuration: DirectPutSourceConfiguration = None, + kinesis_stream_source_configuration: KinesisStreamSourceConfiguration = None, + delivery_stream_encryption_configuration_input: DeliveryStreamEncryptionConfigurationInput = None, + s3_destination_configuration: S3DestinationConfiguration = None, + extended_s3_destination_configuration: ExtendedS3DestinationConfiguration = None, + redshift_destination_configuration: RedshiftDestinationConfiguration = None, + elasticsearch_destination_configuration: ElasticsearchDestinationConfiguration = None, + amazonopensearchservice_destination_configuration: AmazonopensearchserviceDestinationConfiguration = None, + splunk_destination_configuration: SplunkDestinationConfiguration = None, + http_endpoint_destination_configuration: HttpEndpointDestinationConfiguration = None, + tags: TagDeliveryStreamInputTagList = None, + amazon_open_search_serverless_destination_configuration: AmazonOpenSearchServerlessDestinationConfiguration = None, + msk_source_configuration: MSKSourceConfiguration = None, + snowflake_destination_configuration: SnowflakeDestinationConfiguration = None, + iceberg_destination_configuration: IcebergDestinationConfiguration = None, + database_source_configuration: DatabaseSourceConfiguration = None, + **kwargs, + ) -> CreateDeliveryStreamOutput: + # TODO add support for database_source_configuration and direct_put_source_configuration + store = self.get_store(context.account_id, context.region) + delivery_stream_type = delivery_stream_type or DeliveryStreamType.DirectPut + + delivery_stream_arn = firehose_stream_arn( + stream_name=delivery_stream_name, + account_id=context.account_id, + region_name=context.region, + ) + + if delivery_stream_name in store.delivery_streams.keys(): + raise ResourceInUseException( + f"Firehose {delivery_stream_name} under accountId {context.account_id} already exists" + ) + + destinations: DestinationDescriptionList = [] + if elasticsearch_destination_configuration: + destinations.append( + DestinationDescription( + DestinationId=short_uid(), + ElasticsearchDestinationDescription=convert_es_config_to_desc( + elasticsearch_destination_configuration + ), + ) + ) + if amazonopensearchservice_destination_configuration: + db_description = convert_opensearch_config_to_desc( + amazonopensearchservice_destination_configuration + ) + destinations.append( + DestinationDescription( + DestinationId=short_uid(), + AmazonopensearchserviceDestinationDescription=db_description, + ) + ) + if s3_destination_configuration or extended_s3_destination_configuration: + destinations.append( + DestinationDescription( + DestinationId=short_uid(), + S3DestinationDescription=convert_s3_config_to_desc( + s3_destination_configuration + ), + ExtendedS3DestinationDescription=convert_extended_s3_config_to_desc( + extended_s3_destination_configuration + ), + ) + ) + if http_endpoint_destination_configuration: + destinations.append( + DestinationDescription( + DestinationId=short_uid(), + HttpEndpointDestinationDescription=convert_http_config_to_desc( + http_endpoint_destination_configuration + ), + ) + ) + if splunk_destination_configuration: + LOG.warning( + "Delivery stream contains a splunk destination (which is currently not supported)." + ) + if redshift_destination_configuration: + destinations.append( + DestinationDescription( + DestinationId=short_uid(), + RedshiftDestinationDescription=convert_redshift_config_to_desc( + redshift_destination_configuration + ), + ) + ) + if amazon_open_search_serverless_destination_configuration: + LOG.warning( + "Delivery stream contains a opensearch serverless destination (which is currently not supported)." + ) + + stream = DeliveryStreamDescription( + DeliveryStreamName=delivery_stream_name, + DeliveryStreamARN=delivery_stream_arn, + DeliveryStreamStatus=DeliveryStreamStatus.ACTIVE, + DeliveryStreamType=delivery_stream_type, + HasMoreDestinations=False, + VersionId="1", + CreateTimestamp=datetime.now(), + Destinations=destinations, + Source=convert_source_config_to_desc(kinesis_stream_source_configuration), + ) + delivery_stream_arn = stream["DeliveryStreamARN"] + + if delivery_stream_type == DeliveryStreamType.KinesisStreamAsSource: + if not kinesis_stream_source_configuration: + raise InvalidArgumentException("Missing delivery stream configuration") + kinesis_stream_arn = kinesis_stream_source_configuration["KinesisStreamARN"] + kinesis_stream_name = kinesis_stream_arn.split(":stream/")[1] + + def _startup(): + stream["DeliveryStreamStatus"] = DeliveryStreamStatus.CREATING + try: + listener_function = functools.partial( + self._process_records, + context.account_id, + context.region, + delivery_stream_name, + ) + process = kinesis_connector.listen_to_kinesis( + stream_name=kinesis_stream_name, + account_id=context.account_id, + region_name=context.region, + listener_func=listener_function, + wait_until_started=True, + ddb_lease_table_suffix=f"-firehose-{delivery_stream_name}", + ) + + self.kinesis_listeners[delivery_stream_arn] = process + stream["DeliveryStreamStatus"] = DeliveryStreamStatus.ACTIVE + except Exception as e: + LOG.warning( + "Unable to create Firehose delivery stream %s: %s", + delivery_stream_name, + e, + ) + stream["DeliveryStreamStatus"] = DeliveryStreamStatus.CREATING_FAILED + + run_for_max_seconds(25, _startup) + + store.TAGS.tag_resource(delivery_stream_arn, tags) + store.delivery_streams[delivery_stream_name] = stream + + return CreateDeliveryStreamOutput(DeliveryStreamARN=stream["DeliveryStreamARN"]) + + def delete_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + allow_force_delete: BooleanObject = None, + **kwargs, + ) -> DeleteDeliveryStreamOutput: + store = self.get_store(context.account_id, context.region) + delivery_stream_description = store.delivery_streams.pop(delivery_stream_name, {}) + if not delivery_stream_description: + raise ResourceNotFoundException( + f"Firehose {delivery_stream_name} under account {context.account_id} not found." + ) + + delivery_stream_arn = firehose_stream_arn( + stream_name=delivery_stream_name, + account_id=context.account_id, + region_name=context.region, + ) + if kinesis_process := self.kinesis_listeners.pop(delivery_stream_arn, None): + LOG.debug("Stopping kinesis listener for %s", delivery_stream_name) + kinesis_process.stop() + + return DeleteDeliveryStreamOutput() + + def describe_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + limit: DescribeDeliveryStreamInputLimit = None, + exclusive_start_destination_id: DestinationId = None, + **kwargs, + ) -> DescribeDeliveryStreamOutput: + delivery_stream_description = _get_description_or_raise_not_found( + context, delivery_stream_name + ) + if destinations := delivery_stream_description.get("Destinations"): + delivery_stream_description["Destinations"] = ( + _drop_keys_in_destination_descriptions_not_in_output_types(destinations) + ) + + return DescribeDeliveryStreamOutput(DeliveryStreamDescription=delivery_stream_description) + + def list_delivery_streams( + self, + context: RequestContext, + limit: ListDeliveryStreamsInputLimit = None, + delivery_stream_type: DeliveryStreamType = None, + exclusive_start_delivery_stream_name: DeliveryStreamName = None, + **kwargs, + ) -> ListDeliveryStreamsOutput: + store = self.get_store(context.account_id, context.region) + delivery_stream_names = [] + for name, stream in store.delivery_streams.items(): + delivery_stream_names.append(stream["DeliveryStreamName"]) + return ListDeliveryStreamsOutput( + DeliveryStreamNames=delivery_stream_names, HasMoreDeliveryStreams=False + ) + + def put_record( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + record: Record, + **kwargs, + ) -> PutRecordOutput: + record = self._reencode_record(record) + return self._put_record(context.account_id, context.region, delivery_stream_name, record) + + def put_record_batch( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + records: PutRecordBatchRequestEntryList, + **kwargs, + ) -> PutRecordBatchOutput: + records = self._reencode_records(records) + return PutRecordBatchOutput( + FailedPutCount=0, + RequestResponses=self._put_records( + context.account_id, context.region, delivery_stream_name, records + ), + ) + + def tag_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + tags: TagDeliveryStreamInputTagList, + **kwargs, + ) -> TagDeliveryStreamOutput: + store = self.get_store(context.account_id, context.region) + delivery_stream_description = _get_description_or_raise_not_found( + context, delivery_stream_name + ) + store.TAGS.tag_resource(delivery_stream_description["DeliveryStreamARN"], tags) + return ListTagsForDeliveryStreamOutput() + + def list_tags_for_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + exclusive_start_tag_key: TagKey = None, + limit: ListTagsForDeliveryStreamInputLimit = None, + **kwargs, + ) -> ListTagsForDeliveryStreamOutput: + store = self.get_store(context.account_id, context.region) + delivery_stream_description = _get_description_or_raise_not_found( + context, delivery_stream_name + ) + # The tagging service returns a dictionary with the given root name + tags = store.TAGS.list_tags_for_resource( + arn=delivery_stream_description["DeliveryStreamARN"], root_name="root" + ) + # Extract the actual list of tags for the typed response + tag_list: ListTagsForDeliveryStreamOutputTagList = tags["root"] + return ListTagsForDeliveryStreamOutput(Tags=tag_list, HasMoreTags=False) + + def untag_delivery_stream( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagDeliveryStreamOutput: + store = self.get_store(context.account_id, context.region) + delivery_stream_description = _get_description_or_raise_not_found( + context, delivery_stream_name + ) + # The tagging service returns a dictionary with the given root name + store.TAGS.untag_resource( + arn=delivery_stream_description["DeliveryStreamARN"], tag_names=tag_keys + ) + return UntagDeliveryStreamOutput() + + def update_destination( + self, + context: RequestContext, + delivery_stream_name: DeliveryStreamName, + current_delivery_stream_version_id: DeliveryStreamVersionId, + destination_id: DestinationId, + s3_destination_update: S3DestinationUpdate = None, + extended_s3_destination_update: ExtendedS3DestinationUpdate = None, + redshift_destination_update: RedshiftDestinationUpdate = None, + elasticsearch_destination_update: ElasticsearchDestinationUpdate = None, + amazonopensearchservice_destination_update: AmazonopensearchserviceDestinationUpdate = None, + splunk_destination_update: SplunkDestinationUpdate = None, + http_endpoint_destination_update: HttpEndpointDestinationUpdate = None, + amazon_open_search_serverless_destination_update: AmazonOpenSearchServerlessDestinationUpdate = None, + snowflake_destination_update: SnowflakeDestinationUpdate = None, + iceberg_destination_update: IcebergDestinationUpdate = None, + **kwargs, + ) -> UpdateDestinationOutput: + delivery_stream_description = _get_description_or_raise_not_found( + context, delivery_stream_name + ) + destinations = delivery_stream_description["Destinations"] + try: + destination = next(filter(lambda d: d["DestinationId"] == destination_id, destinations)) + except StopIteration: + destination = DestinationDescription(DestinationId=destination_id) + delivery_stream_description["Destinations"].append(destination) + + if elasticsearch_destination_update: + destination["ElasticsearchDestinationDescription"] = convert_es_update_to_desc( + elasticsearch_destination_update + ) + + if amazonopensearchservice_destination_update: + destination["AmazonopensearchserviceDestinationDescription"] = ( + convert_opensearch_update_to_desc(amazonopensearchservice_destination_update) + ) + + if s3_destination_update: + destination["S3DestinationDescription"] = convert_s3_update_to_desc( + s3_destination_update + ) + + if extended_s3_destination_update: + destination["ExtendedS3DestinationDescription"] = convert_extended_s3_update_to_desc( + extended_s3_destination_update + ) + + if http_endpoint_destination_update: + destination["HttpEndpointDestinationDescription"] = convert_http_update_to_desc( + http_endpoint_destination_update + ) + # TODO: add feature update redshift destination + + return UpdateDestinationOutput() + + def _reencode_record(self, record: Record) -> Record: + """ + The ASF decodes the record's data automatically. But most of the service integrations (kinesis, lambda, http) + are working with the base64 encoded data. + """ + if "Data" in record: + record["Data"] = base64.b64encode(record["Data"]) + return record + + def _reencode_records(self, records: List[Record]) -> List[Record]: + return [self._reencode_record(r) for r in records] + + def _process_records( + self, + account_id: str, + region_name: str, + fh_d_stream: str, + records: List[Record], + ): + """Process the given records from the underlying Kinesis stream""" + return self._put_records(account_id, region_name, fh_d_stream, records) + + def _put_record( + self, + account_id: str, + region_name: str, + delivery_stream_name: str, + record: Record, + ) -> PutRecordOutput: + """Put a record to the firehose stream from a PutRecord API call""" + result = self._put_records(account_id, region_name, delivery_stream_name, [record]) + return PutRecordOutput(RecordId=result[0]["RecordId"]) + + def _put_records( + self, + account_id: str, + region_name: str, + delivery_stream_name: str, + unprocessed_records: List[Record], + ) -> List[PutRecordBatchResponseEntry]: + """Put a list of records to the firehose stream - either directly from a PutRecord API call, or + received from an underlying Kinesis stream (if 'KinesisStreamAsSource' is configured)""" + store = self.get_store(account_id, region_name) + delivery_stream_description = store.delivery_streams.get(delivery_stream_name) + if not delivery_stream_description: + raise ResourceNotFoundException( + f"Firehose {delivery_stream_name} under account {account_id} not found." + ) + + # preprocess records, add any missing attributes + self._add_missing_record_attributes(unprocessed_records) + + for destination in delivery_stream_description.get("Destinations", []): + # apply processing steps to incoming items + proc_config = {} + for child in destination.values(): + proc_config = ( + isinstance(child, dict) and child.get("ProcessingConfiguration") or proc_config + ) + records = list(unprocessed_records) + if proc_config.get("Enabled") is not False: + for processor in proc_config.get("Processors", []): + # TODO: run processors asynchronously, to avoid request timeouts on PutRecord API calls + records = self._preprocess_records(processor, records) + + if "ElasticsearchDestinationDescription" in destination: + self._put_to_search_db( + "ElasticSearch", + destination["ElasticsearchDestinationDescription"], + delivery_stream_name, + records, + unprocessed_records, + region_name, + ) + if "AmazonopensearchserviceDestinationDescription" in destination: + self._put_to_search_db( + "OpenSearch", + destination["AmazonopensearchserviceDestinationDescription"], + delivery_stream_name, + records, + unprocessed_records, + region_name, + ) + if "S3DestinationDescription" in destination: + s3_dest_desc = ( + destination["S3DestinationDescription"] + or destination["ExtendedS3DestinationDescription"] + ) + self._put_records_to_s3_bucket(delivery_stream_name, records, s3_dest_desc) + if "HttpEndpointDestinationDescription" in destination: + http_dest = destination["HttpEndpointDestinationDescription"] + end_point = http_dest["EndpointConfiguration"] + url = end_point["Url"] + record_to_send = { + "requestId": str(uuid.uuid4()), + "timestamp": (int(time.time())), + "records": [], + } + for record in records: + data = record.get("Data") or record.get("data") + record_to_send["records"].append({"data": to_str(data)}) + headers = { + "Content-Type": "application/json", + } + try: + requests.post(url, json=record_to_send, headers=headers) + except Exception as e: + LOG.exception("Unable to put Firehose records to HTTP endpoint %s.", url) + raise e + if "RedshiftDestinationDescription" in destination: + s3_dest_desc = destination["RedshiftDestinationDescription"][ + "S3DestinationDescription" + ] + self._put_records_to_s3_bucket(delivery_stream_name, records, s3_dest_desc) + + redshift_dest_desc = destination["RedshiftDestinationDescription"] + self._put_to_redshift(records, redshift_dest_desc) + return [ + PutRecordBatchResponseEntry(RecordId=str(uuid.uuid4())) for _ in unprocessed_records + ] + + def _put_to_search_db( + self, + db_flavor, + db_description, + delivery_stream_name, + records, + unprocessed_records, + region_name, + ): + """ + sends Firehose records to an ElasticSearch or Opensearch database + """ + search_db_index = db_description["IndexName"] + domain_arn = db_description.get("DomainARN") + cluster_endpoint = db_description.get("ClusterEndpoint") + if cluster_endpoint is None: + cluster_endpoint = get_opensearch_endpoint(domain_arn) + + db_connection = get_search_db_connection(cluster_endpoint, region_name) + + if db_description.get("S3BackupMode") == ElasticsearchS3BackupMode.AllDocuments: + s3_dest_desc = db_description.get("S3DestinationDescription") + if s3_dest_desc: + try: + self._put_records_to_s3_bucket( + stream_name=delivery_stream_name, + records=unprocessed_records, + s3_destination_description=s3_dest_desc, + ) + except Exception as e: + LOG.warning("Unable to backup unprocessed records to S3. Error: %s", e) + else: + LOG.warning("Passed S3BackupMode without S3Configuration. Cannot backup...") + elif db_description.get("S3BackupMode") == ElasticsearchS3BackupMode.FailedDocumentsOnly: + # TODO support FailedDocumentsOnly as well + LOG.warning("S3BackupMode FailedDocumentsOnly is set but currently not supported.") + for record in records: + obj_id = uuid.uuid4() + + data = "{}" + # DirectPut + if "Data" in record: + data = base64.b64decode(record["Data"]) + # KinesisAsSource + elif "data" in record: + data = base64.b64decode(record["data"]) + + try: + body = json.loads(data) + except Exception as e: + LOG.warning("%s only allows json input data!", db_flavor) + raise e + + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + "Publishing to %s destination. Data: %s", + db_flavor, + truncate(data, max_length=300), + ) + try: + db_connection.create(index=search_db_index, id=obj_id, body=body) + except Exception as e: + LOG.exception("Unable to put record to stream %s.", delivery_stream_name) + raise e + + def _add_missing_record_attributes(self, records: List[Dict]) -> None: + def _get_entry(obj, key): + return obj.get(key) or obj.get(first_char_to_lower(key)) + + for record in records: + if not _get_entry(record, "ApproximateArrivalTimestamp"): + record["ApproximateArrivalTimestamp"] = int(now_utc(millis=True)) + if not _get_entry(record, "KinesisRecordMetadata"): + record["kinesisRecordMetadata"] = { + "shardId": "shardId-000000000000", + # not really documented what AWS is using internally - simply using a random UUID here + "partitionKey": str(uuid.uuid4()), + "approximateArrivalTimestamp": timestamp( + float(_get_entry(record, "ApproximateArrivalTimestamp")) / 1000, + format=TIMESTAMP_FORMAT_MICROS, + ), + "sequenceNumber": next_sequence_number(), + "subsequenceNumber": "", + } + + def _preprocess_records(self, processor: Dict, records: List[Record]) -> List[Dict]: + """Preprocess the list of records by calling the given processor (e.g., Lamnda function).""" + proc_type = processor.get("Type") + parameters = processor.get("Parameters", []) + parameters = {p["ParameterName"]: p["ParameterValue"] for p in parameters} + if proc_type == "Lambda": + lambda_arn = parameters.get("LambdaArn") + # TODO: add support for other parameters, e.g., NumberOfRetries, BufferSizeInMBs, BufferIntervalInSeconds, ... + records = keys_to_lower(records) + # Convert the record data to string (for json serialization) + for record in records: + if "data" in record: + record["data"] = to_str(record["data"]) + if "Data" in record: + record["Data"] = to_str(record["Data"]) + event = {"records": records} + event = to_bytes(json.dumps(event)) + + account_id = extract_account_id_from_arn(lambda_arn) + region_name = extract_region_from_arn(lambda_arn) + client = connect_to(aws_access_key_id=account_id, region_name=region_name).lambda_ + + response = client.invoke(FunctionName=lambda_arn, Payload=event) + result = json.load(response["Payload"]) + records = result.get("records", []) if result else [] + else: + LOG.warning("Unsupported Firehose processor type '%s'", proc_type) + return records + + def _put_records_to_s3_bucket( + self, + stream_name: str, + records: List[Dict], + s3_destination_description: S3DestinationDescription, + ): + bucket = s3_bucket_name(s3_destination_description["BucketARN"]) + prefix = s3_destination_description.get("Prefix", "") + file_extension = s3_destination_description.get("FileExtension", "") + + if role_arn := s3_destination_description.get("RoleARN"): + factory = connect_to.with_assumed_role( + role_arn=role_arn, service_principal=ServicePrincipal.firehose + ) + else: + factory = connect_to() + s3 = factory.s3.request_metadata( + source_arn=stream_name, service_principal=ServicePrincipal.firehose + ) + batched_data = b"".join([base64.b64decode(r.get("Data") or r.get("data")) for r in records]) + + obj_path = self._get_s3_object_path(stream_name, prefix, file_extension) + try: + LOG.debug("Publishing to S3 destination: %s. Data: %s", bucket, batched_data) + s3.put_object(Bucket=bucket, Key=obj_path, Body=batched_data) + except Exception as e: + LOG.exception( + "Unable to put records %s to s3 bucket.", + records, + ) + raise e + + def _get_s3_object_path(self, stream_name, prefix, file_extension): + # See https://aws.amazon.com/kinesis/data-firehose/faqs/#Data_delivery + # Path prefix pattern: myApp/YYYY/MM/DD/HH/ + # Object name pattern: DeliveryStreamName-DeliveryStreamVersion-YYYY-MM-DD-HH-MM-SS-RandomString + if not prefix.endswith("/") and prefix != "": + prefix = prefix + "/" + pattern = "{pre}%Y/%m/%d/%H/{name}-%Y-%m-%d-%H-%M-%S-{rand}" + path = pattern.format(pre=prefix, name=stream_name, rand=str(uuid.uuid4())) + path = timestamp(format=path) + + if file_extension: + path += file_extension + + return path + + def _put_to_redshift( + self, + records: List[Dict], + redshift_destination_description: RedshiftDestinationDescription, + ): + jdbcurl = redshift_destination_description.get("ClusterJDBCURL") + cluster_id = self._get_cluster_id_from_jdbc_url(jdbcurl) + db_name = jdbcurl.split("/")[-1] + table_name = redshift_destination_description.get("CopyCommand").get("DataTableName") + + rows_to_insert = [self._prepare_records_for_redshift(record) for record in records] + columns_placeholder_str = self._extract_columns(records[0]) + sql_insert_statement = f"INSERT INTO {table_name} VALUES ({columns_placeholder_str})" + + execute_statement = { + "Sql": sql_insert_statement, + "Database": db_name, + "ClusterIdentifier": cluster_id, # cluster_identifier in cluster create + } + + role_arn = redshift_destination_description.get("RoleARN") + account_id = extract_account_id_from_arn(role_arn) + region_name = self._get_region_from_jdbc_url(jdbcurl) + redshift_data = connect_to( + aws_access_key_id=account_id, region_name=region_name + ).redshift_data + + for row_to_insert in rows_to_insert: # redsift_data only allows single row inserts + try: + LOG.debug( + "Publishing to Redshift destination: %s. Data: %s", + jdbcurl, + row_to_insert, + ) + redshift_data.execute_statement(Parameters=row_to_insert, **execute_statement) + except Exception as e: + LOG.exception( + "Unable to put records %s to redshift cluster.", + row_to_insert, + ) + raise e + + def _get_cluster_id_from_jdbc_url(self, jdbc_url: str) -> str: + pattern = r"://(.*?)\." + match = re.search(pattern, jdbc_url) + if match: + return match.group(1) + else: + raise ValueError(f"Unable to extract cluster id from jdbc url: {jdbc_url}") + + def _get_region_from_jdbc_url(self, jdbc_url: str) -> str | None: + match = re.search(r"://(?:[^.]+\.){2}([^.]+)\.", jdbc_url) + if match: + return match.group(1) + else: + LOG.debug("Cannot extract region from JDBC url '%s'", jdbc_url) + return None + + def _decode_record(self, record: Dict) -> Dict: + data = base64.b64decode(record.get("Data") or record.get("data")) + data = to_str(data) + data = json.loads(data) + return data + + def _prepare_records_for_redshift(self, record: Dict) -> List[Dict]: + data = self._decode_record(record) + + parameters = [] + for key, value in data.items(): + if isinstance(value, str): + value = value.replace("\t", " ") + value = value.replace("\n", " ") + elif value is None: + value = "NULL" + else: + value = str(value) + parameters.append({"name": key, "value": value}) + # required to work with execute_statement in community (moto) and ext (localstack native) + + return parameters + + def _extract_columns(self, record: Dict) -> str: + data = self._decode_record(record) + placeholders = [f":{key}" for key in data] + placeholder_str = ", ".join(placeholders) + return placeholder_str diff --git a/localstack-core/localstack/services/iam/__init__.py b/localstack-core/localstack/services/iam/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/iam/iam_patches.py b/localstack-core/localstack/services/iam/iam_patches.py new file mode 100644 index 0000000000000..bec31419c3c8f --- /dev/null +++ b/localstack-core/localstack/services/iam/iam_patches.py @@ -0,0 +1,164 @@ +import threading +from typing import Dict, List, Optional + +from moto.iam.models import ( + AccessKey, + AWSManagedPolicy, + IAMBackend, + InlinePolicy, + Policy, + User, +) +from moto.iam.models import Role as MotoRole +from moto.iam.policy_validation import VALID_STATEMENT_ELEMENTS + +from localstack import config +from localstack.constants import TAG_KEY_CUSTOM_ID +from localstack.utils.patch import patch + +ADDITIONAL_MANAGED_POLICIES = { + "AWSLambdaExecute": { + "Arn": "arn:aws:iam::aws:policy/AWSLambdaExecute", + "Path": "/", + "CreateDate": "2017-10-20T17:23:10+00:00", + "DefaultVersionId": "v4", + "Document": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["logs:*"], + "Resource": "arn:aws:logs:*:*:*", + }, + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject"], + "Resource": "arn:aws:s3:::*", + }, + ], + }, + "UpdateDate": "2019-05-20T18:22:18+00:00", + } +} + +IAM_PATCHED = False +IAM_PATCH_LOCK = threading.RLock() + + +def apply_iam_patches(): + global IAM_PATCHED + + # prevent patching multiple times, as this is called from both STS and IAM (for now) + with IAM_PATCH_LOCK: + if IAM_PATCHED: + return + + IAM_PATCHED = True + + # support service linked roles + moto_role_og_arn_prop = MotoRole.arn + + @property + def moto_role_arn(self): + return getattr(self, "service_linked_role_arn", None) or moto_role_og_arn_prop.__get__(self) + + MotoRole.arn = moto_role_arn + + # Add missing managed polices + # TODO this might not be necessary + @patch(IAMBackend._init_aws_policies) + def _init_aws_policies_extended(_init_aws_policies, self): + loaded_policies = _init_aws_policies(self) + loaded_policies.extend( + [ + AWSManagedPolicy.from_data(name, self.account_id, self.region_name, d) + for name, d in ADDITIONAL_MANAGED_POLICIES.items() + ] + ) + return loaded_policies + + if "Principal" not in VALID_STATEMENT_ELEMENTS: + VALID_STATEMENT_ELEMENTS.append("Principal") + + # patch policy __init__ to set document as attribute + + @patch(Policy.__init__) + def policy__init__( + fn, + self, + name, + account_id, + region, + default_version_id=None, + description=None, + document=None, + **kwargs, + ): + fn(self, name, account_id, region, default_version_id, description, document, **kwargs) + self.document = document + if "tags" in kwargs and TAG_KEY_CUSTOM_ID in kwargs["tags"]: + self.id = kwargs["tags"][TAG_KEY_CUSTOM_ID]["Value"] + + @patch(IAMBackend.create_role) + def iam_backend_create_role( + fn, + self, + role_name: str, + assume_role_policy_document: str, + path: str, + permissions_boundary: Optional[str], + description: str, + tags: List[Dict[str, str]], + max_session_duration: Optional[str], + linked_service: Optional[str] = None, + ): + role = fn( + self, + role_name, + assume_role_policy_document, + path, + permissions_boundary, + description, + tags, + max_session_duration, + linked_service, + ) + new_id_tag = [tag for tag in (tags or []) if tag["Key"] == TAG_KEY_CUSTOM_ID] + if new_id_tag: + new_id = new_id_tag[0]["Value"] + old_id = role.id + role.id = new_id + self.roles[new_id] = self.roles.pop(old_id) + return role + + @patch(InlinePolicy.unapply_policy) + def inline_policy_unapply_policy(fn, self, backend): + try: + fn(self, backend) + except Exception: + # Actually role can be deleted before policy being deleted in cloudformation + pass + + @patch(AccessKey.__init__) + def access_key__init__( + fn, + self, + user_name: Optional[str], + prefix: str, + account_id: str, + status: str = "Active", + **kwargs, + ): + if not config.PARITY_AWS_ACCESS_KEY_ID: + prefix = "L" + prefix[1:] + fn(self, user_name, prefix, account_id, status, **kwargs) + + @patch(User.__init__) + def user__init__( + fn, + self, + *args, + **kwargs, + ): + fn(self, *args, **kwargs) + self.service_specific_credentials = [] diff --git a/localstack-core/localstack/services/iam/provider.py b/localstack-core/localstack/services/iam/provider.py new file mode 100644 index 0000000000000..d5e1af505867d --- /dev/null +++ b/localstack-core/localstack/services/iam/provider.py @@ -0,0 +1,777 @@ +import inspect +import json +import logging +import random +import re +import string +import uuid +from datetime import datetime +from typing import Any, Dict, List, TypeVar +from urllib.parse import quote + +from moto.iam.models import ( + IAMBackend, + filter_items_with_path_prefix, + iam_backends, +) +from moto.iam.models import Role as MotoRole +from moto.iam.models import User as MotoUser +from moto.iam.utils import generate_access_key_id_from_account_id + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.iam import ( + ActionNameListType, + ActionNameType, + AttachedPermissionsBoundary, + ContextEntryListType, + CreateRoleRequest, + CreateRoleResponse, + CreateServiceLinkedRoleResponse, + CreateServiceSpecificCredentialResponse, + CreateUserResponse, + DeleteConflictException, + DeleteServiceLinkedRoleResponse, + DeletionTaskIdType, + DeletionTaskStatusType, + EvaluationResult, + GetServiceLinkedRoleDeletionStatusResponse, + GetUserResponse, + IamApi, + InvalidInputException, + ListInstanceProfileTagsResponse, + ListRolesResponse, + ListServiceSpecificCredentialsResponse, + MalformedPolicyDocumentException, + NoSuchEntityException, + PolicyEvaluationDecisionType, + ResetServiceSpecificCredentialResponse, + ResourceHandlingOptionType, + ResourceNameListType, + ResourceNameType, + Role, + ServiceSpecificCredential, + ServiceSpecificCredentialMetadata, + SimulatePolicyResponse, + SimulationPolicyListType, + User, + allUsers, + arnType, + credentialAgeDays, + customSuffixType, + existingUserNameType, + groupNameType, + instanceProfileNameType, + markerType, + maxItemsType, + pathPrefixType, + pathType, + policyDocumentType, + roleDescriptionType, + roleNameType, + serviceName, + serviceSpecificCredentialId, + statusType, + tagKeyListType, + tagListType, + userNameType, +) +from localstack.aws.connect import connect_to +from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY +from localstack.services.iam.iam_patches import apply_iam_patches +from localstack.services.iam.resources.service_linked_roles import SERVICE_LINKED_ROLES +from localstack.services.moto import call_moto +from localstack.utils.aws.request_context import extract_access_key_id_from_auth_header + +LOG = logging.getLogger(__name__) + +SERVICE_LINKED_ROLE_PATH_PREFIX = "/aws-service-role" + +POLICY_ARN_REGEX = re.compile(r"arn:[^:]+:iam::(?:\d{12}|aws):policy/.*") + +CREDENTIAL_ID_REGEX = re.compile(r"^\w+$") + +T = TypeVar("T") + + +class ValidationError(CommonServiceException): + def __init__(self, message: str): + super().__init__("ValidationError", message, 400, True) + + +class ValidationListError(ValidationError): + def __init__(self, validation_errors: list[str]): + message = f"{len(validation_errors)} validation error{'s' if len(validation_errors) > 1 else ''} detected: {'; '.join(validation_errors)}" + super().__init__(message) + + +def get_iam_backend(context: RequestContext) -> IAMBackend: + return iam_backends[context.account_id][context.partition] + + +def get_policies_from_principal(backend: IAMBackend, principal_arn: str) -> list[dict]: + policies = [] + if ":role" in principal_arn: + role_name = principal_arn.split("/")[-1] + + policies.append(backend.get_role(role_name=role_name).assume_role_policy_document) + + policy_names = backend.list_role_policies(role_name=role_name) + policies.extend( + [ + backend.get_role_policy(role_name=role_name, policy_name=policy_name)[1] + for policy_name in policy_names + ] + ) + + attached_policies, _ = backend.list_attached_role_policies(role_name=role_name) + policies.extend([policy.document for policy in attached_policies]) + + if ":group" in principal_arn: + print(principal_arn) + group_name = principal_arn.split("/")[-1] + policy_names = backend.list_group_policies(group_name=group_name) + policies.extend( + [ + backend.get_group_policy(group_name=group_name, policy_name=policy_name)[1] + for policy_name in policy_names + ] + ) + + attached_policies, _ = backend.list_attached_group_policies(group_name=group_name) + policies.extend([policy.document for policy in attached_policies]) + + if ":user" in principal_arn: + print(principal_arn) + user_name = principal_arn.split("/")[-1] + policy_names = backend.list_user_policies(user_name=user_name) + policies.extend( + [ + backend.get_user_policy(user_name=user_name, policy_name=policy_name)[1] + for policy_name in policy_names + ] + ) + + attached_policies, _ = backend.list_attached_user_policies(user_name=user_name) + policies.extend([policy.document for policy in attached_policies]) + + return policies + + +class IamProvider(IamApi): + def __init__(self): + apply_iam_patches() + + @handler("CreateRole", expand=False) + def create_role( + self, context: RequestContext, request: CreateRoleRequest + ) -> CreateRoleResponse: + try: + json.loads(request["AssumeRolePolicyDocument"]) + except json.JSONDecodeError: + raise MalformedPolicyDocumentException("This policy contains invalid Json") + result = call_moto(context) + + if not request.get("MaxSessionDuration") and result["Role"].get("MaxSessionDuration"): + result["Role"].pop("MaxSessionDuration") + + if "RoleLastUsed" in result["Role"] and not result["Role"]["RoleLastUsed"]: + # not part of the AWS response if it's empty + # FIXME: RoleLastUsed did not seem well supported when this check was added + result["Role"].pop("RoleLastUsed") + + return result + + @staticmethod + def build_evaluation_result( + action_name: ActionNameType, resource_name: ResourceNameType, policy_statements: List[Dict] + ) -> EvaluationResult: + eval_res = EvaluationResult() + eval_res["EvalActionName"] = action_name + eval_res["EvalResourceName"] = resource_name + eval_res["EvalDecision"] = PolicyEvaluationDecisionType.explicitDeny + for statement in policy_statements: + # TODO Implement evaluation logic here + if ( + action_name in statement["Action"] + and resource_name in statement["Resource"] + and statement["Effect"] == "Allow" + ): + eval_res["EvalDecision"] = PolicyEvaluationDecisionType.allowed + eval_res["MatchedStatements"] = [] # TODO: add support for statement compilation. + return eval_res + + def simulate_principal_policy( + self, + context: RequestContext, + policy_source_arn: arnType, + action_names: ActionNameListType, + policy_input_list: SimulationPolicyListType = None, + permissions_boundary_policy_input_list: SimulationPolicyListType = None, + resource_arns: ResourceNameListType = None, + resource_policy: policyDocumentType = None, + resource_owner: ResourceNameType = None, + caller_arn: ResourceNameType = None, + context_entries: ContextEntryListType = None, + resource_handling_option: ResourceHandlingOptionType = None, + max_items: maxItemsType = None, + marker: markerType = None, + **kwargs, + ) -> SimulatePolicyResponse: + backend = get_iam_backend(context) + + policies = get_policies_from_principal(backend, policy_source_arn) + + def _get_statements_from_policy_list(policies: list[str]): + statements = [] + for policy_str in policies: + policy_dict = json.loads(policy_str) + if isinstance(policy_dict["Statement"], list): + statements.extend(policy_dict["Statement"]) + else: + statements.append(policy_dict["Statement"]) + return statements + + policy_statements = _get_statements_from_policy_list(policies) + + evaluations = [ + self.build_evaluation_result(action_name, resource_arn, policy_statements) + for action_name in action_names + for resource_arn in resource_arns + ] + + response = SimulatePolicyResponse() + response["IsTruncated"] = False + response["EvaluationResults"] = evaluations + return response + + def delete_policy(self, context: RequestContext, policy_arn: arnType, **kwargs) -> None: + backend = get_iam_backend(context) + if backend.managed_policies.get(policy_arn): + backend.managed_policies.pop(policy_arn, None) + else: + raise NoSuchEntityException("Policy {0} was not found.".format(policy_arn)) + + def detach_role_policy( + self, context: RequestContext, role_name: roleNameType, policy_arn: arnType, **kwargs + ) -> None: + backend = get_iam_backend(context) + try: + role = backend.get_role(role_name) + policy = role.managed_policies[policy_arn] + policy.detach_from(role) + except KeyError: + raise NoSuchEntityException("Policy {0} was not found.".format(policy_arn)) + + @staticmethod + def moto_role_to_role_type(moto_role: MotoRole) -> Role: + role = Role() + role["Path"] = moto_role.path + role["RoleName"] = moto_role.name + role["RoleId"] = moto_role.id + role["Arn"] = moto_role.arn + role["CreateDate"] = moto_role.create_date + if moto_role.assume_role_policy_document: + role["AssumeRolePolicyDocument"] = moto_role.assume_role_policy_document + if moto_role.description: + role["Description"] = moto_role.description + if moto_role.max_session_duration: + role["MaxSessionDuration"] = moto_role.max_session_duration + if moto_role.permissions_boundary: + role["PermissionsBoundary"] = moto_role.permissions_boundary + if moto_role.tags: + role["Tags"] = moto_role.tags + # role["RoleLastUsed"]: # TODO: add support + return role + + def list_roles( + self, + context: RequestContext, + path_prefix: pathPrefixType = None, + marker: markerType = None, + max_items: maxItemsType = None, + **kwargs, + ) -> ListRolesResponse: + backend = get_iam_backend(context) + moto_roles = backend.roles.values() + if path_prefix: + moto_roles = filter_items_with_path_prefix(path_prefix, moto_roles) + moto_roles = sorted(moto_roles, key=lambda role: role.id) + + response_roles = [] + for moto_role in moto_roles: + response_role = self.moto_role_to_role_type(moto_role) + # Permission boundary and Tags should not be a part of the response + response_role.pop("PermissionsBoundary", None) + response_role.pop("Tags", None) + response_roles.append(response_role) + if path_prefix: # TODO: this is consistent with the patch it migrates, but should add tests for this. + response_role["AssumeRolePolicyDocument"] = quote( + json.dumps(moto_role.assume_role_policy_document or {}) + ) + + return ListRolesResponse(Roles=response_roles, IsTruncated=False) + + def update_group( + self, + context: RequestContext, + group_name: groupNameType, + new_path: pathType = None, + new_group_name: groupNameType = None, + **kwargs, + ) -> None: + new_group_name = new_group_name or group_name + backend = get_iam_backend(context) + group = backend.get_group(group_name) + group.path = new_path + group.name = new_group_name + backend.groups[new_group_name] = backend.groups.pop(group_name) + + def list_instance_profile_tags( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + marker: markerType = None, + max_items: maxItemsType = None, + **kwargs, + ) -> ListInstanceProfileTagsResponse: + backend = get_iam_backend(context) + profile = backend.get_instance_profile(instance_profile_name) + response = ListInstanceProfileTagsResponse() + response["Tags"] = profile.tags + return response + + def tag_instance_profile( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + tags: tagListType, + **kwargs, + ) -> None: + backend = get_iam_backend(context) + profile = backend.get_instance_profile(instance_profile_name) + new_keys = [tag["Key"] for tag in tags] + updated_tags = [tag for tag in profile.tags if tag["Key"] not in new_keys] + updated_tags.extend(tags) + profile.tags = updated_tags + + def untag_instance_profile( + self, + context: RequestContext, + instance_profile_name: instanceProfileNameType, + tag_keys: tagKeyListType, + **kwargs, + ) -> None: + backend = get_iam_backend(context) + profile = backend.get_instance_profile(instance_profile_name) + profile.tags = [tag for tag in profile.tags if tag["Key"] not in tag_keys] + + def create_service_linked_role( + self, + context: RequestContext, + aws_service_name: groupNameType, + description: roleDescriptionType = None, + custom_suffix: customSuffixType = None, + **kwargs, + ) -> CreateServiceLinkedRoleResponse: + policy_doc = json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": aws_service_name}, + "Action": "sts:AssumeRole", + } + ], + } + ) + service_role_data = SERVICE_LINKED_ROLES.get(aws_service_name) + + path = f"{SERVICE_LINKED_ROLE_PATH_PREFIX}/{aws_service_name}/" + if service_role_data: + if custom_suffix and not service_role_data["suffix_allowed"]: + raise InvalidInputException(f"Custom suffix is not allowed for {aws_service_name}") + role_name = service_role_data.get("role_name") + attached_policies = service_role_data["attached_policies"] + else: + role_name = f"AWSServiceRoleFor{aws_service_name.split('.')[0].capitalize()}" + attached_policies = [] + if custom_suffix: + role_name = f"{role_name}_{custom_suffix}" + backend = get_iam_backend(context) + + # check for role duplicates + for role in backend.roles.values(): + if role.name == role_name: + raise InvalidInputException( + f"Service role name {role_name} has been taken in this account, please try a different suffix." + ) + + role = backend.create_role( + role_name=role_name, + assume_role_policy_document=policy_doc, + path=path, + permissions_boundary="", + description=description, + tags={}, + max_session_duration=3600, + linked_service=aws_service_name, + ) + # attach policies + for policy in attached_policies: + try: + backend.attach_role_policy(policy, role_name) + except Exception as e: + LOG.warning( + "Policy %s for service linked role %s does not exist: %s", + policy, + aws_service_name, + e, + ) + + res_role = self.moto_role_to_role_type(role) + return CreateServiceLinkedRoleResponse(Role=res_role) + + def delete_service_linked_role( + self, context: RequestContext, role_name: roleNameType, **kwargs + ) -> DeleteServiceLinkedRoleResponse: + backend = get_iam_backend(context) + role = backend.get_role(role_name=role_name) + role.managed_policies.clear() + backend.delete_role(role_name) + return DeleteServiceLinkedRoleResponse( + DeletionTaskId=f"task{role.path}{role.name}/{uuid.uuid4()}" + ) + + def get_service_linked_role_deletion_status( + self, context: RequestContext, deletion_task_id: DeletionTaskIdType, **kwargs + ) -> GetServiceLinkedRoleDeletionStatusResponse: + # TODO: check if task id is valid + return GetServiceLinkedRoleDeletionStatusResponse(Status=DeletionTaskStatusType.SUCCEEDED) + + def put_user_permissions_boundary( + self, + context: RequestContext, + user_name: userNameType, + permissions_boundary: arnType, + **kwargs, + ) -> None: + if user := get_iam_backend(context).users.get(user_name): + user.permissions_boundary = permissions_boundary + else: + raise NoSuchEntityException() + + def delete_user_permissions_boundary( + self, context: RequestContext, user_name: userNameType, **kwargs + ) -> None: + if user := get_iam_backend(context).users.get(user_name): + if hasattr(user, "permissions_boundary"): + delattr(user, "permissions_boundary") + else: + raise NoSuchEntityException() + + def create_user( + self, + context: RequestContext, + user_name: userNameType, + path: pathType = None, + permissions_boundary: arnType = None, + tags: tagListType = None, + **kwargs, + ) -> CreateUserResponse: + response = call_moto(context=context) + user = get_iam_backend(context).get_user(user_name) + if permissions_boundary: + user.permissions_boundary = permissions_boundary + response["User"]["PermissionsBoundary"] = AttachedPermissionsBoundary( + PermissionsBoundaryArn=permissions_boundary, + PermissionsBoundaryType="Policy", + ) + return response + + def get_user( + self, context: RequestContext, user_name: existingUserNameType = None, **kwargs + ) -> GetUserResponse: + response = call_moto(context=context) + moto_user_name = response["User"]["UserName"] + moto_user = get_iam_backend(context).users.get(moto_user_name) + # if the user does not exist or is no user + if not moto_user and not user_name: + access_key_id = extract_access_key_id_from_auth_header(context.request.headers) + sts_client = connect_to( + region_name=context.region, + aws_access_key_id=access_key_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + ).sts + caller_identity = sts_client.get_caller_identity() + caller_arn = caller_identity["Arn"] + if caller_arn.endswith(":root"): + return GetUserResponse( + User=User( + UserId=context.account_id, + Arn=caller_arn, + CreateDate=datetime.now(), + PasswordLastUsed=datetime.now(), + ) + ) + else: + raise CommonServiceException( + "ValidationError", + "Must specify userName when calling with non-User credentials", + ) + + if hasattr(moto_user, "permissions_boundary") and moto_user.permissions_boundary: + response["User"]["PermissionsBoundary"] = AttachedPermissionsBoundary( + PermissionsBoundaryArn=moto_user.permissions_boundary, + PermissionsBoundaryType="Policy", + ) + + return response + + def delete_user( + self, context: RequestContext, user_name: existingUserNameType, **kwargs + ) -> None: + moto_user = get_iam_backend(context).users.get(user_name) + if moto_user and moto_user.service_specific_credentials: + LOG.info( + "Cannot delete user '%s' because service specific credentials are still present.", + user_name, + ) + raise DeleteConflictException( + "Cannot delete entity, must remove referenced objects first." + ) + return call_moto(context=context) + + def attach_role_policy( + self, context: RequestContext, role_name: roleNameType, policy_arn: arnType, **kwargs + ) -> None: + if not POLICY_ARN_REGEX.match(policy_arn): + raise ValidationError("Invalid ARN: Could not be parsed!") + return call_moto(context=context) + + def attach_user_policy( + self, context: RequestContext, user_name: userNameType, policy_arn: arnType, **kwargs + ) -> None: + if not POLICY_ARN_REGEX.match(policy_arn): + raise ValidationError("Invalid ARN: Could not be parsed!") + return call_moto(context=context) + + # ------------------------------ Service specific credentials ------------------------------ # + + def _get_user_or_raise_error(self, user_name: str, context: RequestContext) -> MotoUser: + """ + Return the moto user from the store, or raise the proper exception if no user can be found. + + :param user_name: Username to find + :param context: Request context + :return: A moto user object + """ + moto_user = get_iam_backend(context).users.get(user_name) + if not moto_user: + raise NoSuchEntityException(f"The user with name {user_name} cannot be found.") + return moto_user + + def _validate_service_name(self, service_name: str) -> None: + """ + Validate if the service provided is supported. + + :param service_name: Service name to check + """ + if service_name not in ["codecommit.amazonaws.com", "cassandra.amazonaws.com"]: + raise NoSuchEntityException( + f"No such service {service_name} is supported for Service Specific Credentials" + ) + + def _validate_credential_id(self, credential_id: str) -> None: + """ + Validate if the credential id is correctly formed. + + :param credential_id: Credential ID to check + """ + if not CREDENTIAL_ID_REGEX.match(credential_id): + raise ValidationListError( + [ + "Value at 'serviceSpecificCredentialId' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w]+" + ] + ) + + def _generate_service_password(self): + """ + Generate a new service password for a service specific credential. + + :return: 60 letter password ending in `=` + """ + password_charset = string.ascii_letters + string.digits + "+/" + # password always ends in = for some reason - but it is not base64 + return "".join(random.choices(password_charset, k=59)) + "=" + + def _generate_credential_id(self, context: RequestContext): + """ + Generate a credential ID. + Credentials have a similar structure as access key ids, and also contain the account id encoded in them. + Example: `ACCAQAAAAAAAPBAFQJI5W` for account `000000000000` + + :param context: Request context (to extract account id) + :return: New credential id. + """ + return generate_access_key_id_from_account_id( + context.account_id, prefix="ACCA", total_length=21 + ) + + def _new_service_specific_credential( + self, user_name: str, service_name: str, context: RequestContext + ) -> ServiceSpecificCredential: + """ + Create a new service specific credential for the given username and service. + + :param user_name: Username the credential will be assigned to. + :param service_name: Service the credential will be used for. + :param context: Request context, used to extract the account id. + :return: New ServiceSpecificCredential + """ + password = self._generate_service_password() + credential_id = self._generate_credential_id(context) + return ServiceSpecificCredential( + CreateDate=datetime.now(), + ServiceName=service_name, + ServiceUserName=f"{user_name}-at-{context.account_id}", + ServicePassword=password, + ServiceSpecificCredentialId=credential_id, + UserName=user_name, + Status=statusType.Active, + ) + + def _find_credential_in_user_by_id( + self, user_name: str, credential_id: str, context: RequestContext + ) -> ServiceSpecificCredential: + """ + Find a credential by a given username and id. + Raises errors if the user or credential is not found. + + :param user_name: Username of the user the credential is assigned to. + :param credential_id: Credential ID to check + :param context: Request context (used to determine account and region) + :return: Service specific credential + """ + moto_user = self._get_user_or_raise_error(user_name, context) + self._validate_credential_id(credential_id) + matching_credentials = [ + cred + for cred in moto_user.service_specific_credentials + if cred["ServiceSpecificCredentialId"] == credential_id + ] + if not matching_credentials: + raise NoSuchEntityException(f"No such credential {credential_id} exists") + return matching_credentials[0] + + def _validate_status(self, status: str): + """ + Validate if the status has an accepted value. + Raises a ValidationError if the status is invalid. + + :param status: Status to check + """ + try: + statusType(status) + except ValueError: + raise ValidationListError( + [ + "Value at 'status' failed to satisfy constraint: Member must satisfy enum value set" + ] + ) + + def build_dict_with_only_defined_keys( + self, data: dict[str, Any], typed_dict_type: type[T] + ) -> T: + """ + Builds a dict with only the defined keys from a given typed dict. + Filtering is only present on the first level. + + :param data: Dict to filter. + :param typed_dict_type: TypedDict subtype containing the attributes allowed to be present in the return value + :return: shallow copy of the data only containing the keys defined on typed_dict_type + """ + key_set = inspect.get_annotations(typed_dict_type).keys() + return {k: v for k, v in data.items() if k in key_set} + + def create_service_specific_credential( + self, + context: RequestContext, + user_name: userNameType, + service_name: serviceName, + credential_age_days: credentialAgeDays | None = None, + **kwargs, + ) -> CreateServiceSpecificCredentialResponse: + # TODO add support for credential_age_days + moto_user = self._get_user_or_raise_error(user_name, context) + self._validate_service_name(service_name) + credential = self._new_service_specific_credential(user_name, service_name, context) + moto_user.service_specific_credentials.append(credential) + return CreateServiceSpecificCredentialResponse(ServiceSpecificCredential=credential) + + def list_service_specific_credentials( + self, + context: RequestContext, + user_name: userNameType | None = None, + service_name: serviceName | None = None, + all_users: allUsers | None = None, + marker: markerType | None = None, + max_items: maxItemsType | None = None, + **kwargs, + ) -> ListServiceSpecificCredentialsResponse: + # TODO add support for all_users, marker, max_items + moto_user = self._get_user_or_raise_error(user_name, context) + self._validate_service_name(service_name) + result = [ + self.build_dict_with_only_defined_keys(creds, ServiceSpecificCredentialMetadata) + for creds in moto_user.service_specific_credentials + if creds["ServiceName"] == service_name + ] + return ListServiceSpecificCredentialsResponse(ServiceSpecificCredentials=result) + + def update_service_specific_credential( + self, + context: RequestContext, + service_specific_credential_id: serviceSpecificCredentialId, + status: statusType, + user_name: userNameType = None, + **kwargs, + ) -> None: + self._validate_status(status) + + credential = self._find_credential_in_user_by_id( + user_name, service_specific_credential_id, context + ) + credential["Status"] = status + + def reset_service_specific_credential( + self, + context: RequestContext, + service_specific_credential_id: serviceSpecificCredentialId, + user_name: userNameType = None, + **kwargs, + ) -> ResetServiceSpecificCredentialResponse: + credential = self._find_credential_in_user_by_id( + user_name, service_specific_credential_id, context + ) + credential["ServicePassword"] = self._generate_service_password() + return ResetServiceSpecificCredentialResponse(ServiceSpecificCredential=credential) + + def delete_service_specific_credential( + self, + context: RequestContext, + service_specific_credential_id: serviceSpecificCredentialId, + user_name: userNameType = None, + **kwargs, + ) -> None: + moto_user = self._get_user_or_raise_error(user_name, context) + credentials = self._find_credential_in_user_by_id( + user_name, service_specific_credential_id, context + ) + try: + moto_user.service_specific_credentials.remove(credentials) + # just in case of race conditions + except ValueError: + raise NoSuchEntityException( + f"No such credential {service_specific_credential_id} exists" + ) diff --git a/localstack-core/localstack/services/iam/resource_providers/__init__.py b/localstack-core/localstack/services/iam/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey.py new file mode 100644 index 0000000000000..a945e5af67a47 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey.py @@ -0,0 +1,116 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMAccessKeyProperties(TypedDict): + UserName: Optional[str] + Id: Optional[str] + SecretAccessKey: Optional[str] + Serial: Optional[int] + Status: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMAccessKeyProvider(ResourceProvider[IAMAccessKeyProperties]): + TYPE = "AWS::IAM::AccessKey" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMAccessKeyProperties], + ) -> ProgressEvent[IAMAccessKeyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - UserName + + Create-only properties: + - /properties/UserName + - /properties/Serial + + Read-only properties: + - /properties/SecretAccessKey + - /properties/Id + + """ + # TODO: what alues can model['Serial'] take on initial create? + model = request.desired_state + iam_client = request.aws_client_factory.iam + + access_key = iam_client.create_access_key(UserName=model["UserName"]) + model["SecretAccessKey"] = access_key["AccessKey"]["SecretAccessKey"] + model["Id"] = access_key["AccessKey"]["AccessKeyId"] + + if model.get("Status") == "Inactive": + # can be "Active" or "Inactive" + # by default the created access key has Status "Active", but if user set Inactive this needs to be adjusted + iam_client.update_access_key( + AccessKeyId=model["Id"], UserName=model["UserName"], Status=model["Status"] + ) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[IAMAccessKeyProperties], + ) -> ProgressEvent[IAMAccessKeyProperties]: + """ + Fetch resource information + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMAccessKeyProperties], + ) -> ProgressEvent[IAMAccessKeyProperties]: + """ + Delete a resource + """ + iam_client = request.aws_client_factory.iam + model = request.previous_state + iam_client.delete_access_key(AccessKeyId=model["Id"], UserName=model["UserName"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[IAMAccessKeyProperties], + ) -> ProgressEvent[IAMAccessKeyProperties]: + """ + Update a resource + """ + iam_client = request.aws_client_factory.iam + + # FIXME: replacement should be handled in engine before here + user_name_changed = request.desired_state["UserName"] != request.previous_state["UserName"] + serial_changed = request.desired_state["Serial"] != request.previous_state["Serial"] + if user_name_changed or serial_changed: + # recreate the key + self.delete(request) + create_event = self.create(request) + return create_event + + iam_client.update_access_key( + AccessKeyId=request.previous_state["Id"], + UserName=request.previous_state["UserName"], + Status=request.desired_state["Status"], + ) + old_model = request.previous_state + old_model["Status"] = request.desired_state["Status"] + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=old_model) diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey.schema.json new file mode 100644 index 0000000000000..4925db7a9d608 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey.schema.json @@ -0,0 +1,36 @@ +{ + "typeName": "AWS::IAM::AccessKey", + "description": "Resource Type definition for AWS::IAM::AccessKey", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "SecretAccessKey": { + "type": "string" + }, + "Serial": { + "type": "integer" + }, + "Status": { + "type": "string" + }, + "UserName": { + "type": "string" + } + }, + "required": [ + "UserName" + ], + "readOnlyProperties": [ + "/properties/SecretAccessKey", + "/properties/Id" + ], + "createOnlyProperties": [ + "/properties/UserName", + "/properties/Serial" + ], + "primaryIdentifier": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey_plugin.py new file mode 100644 index 0000000000000..a54ee6f94b3db --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_accesskey_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMAccessKeyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::AccessKey" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_accesskey import ( + IAMAccessKeyProvider, + ) + + self.factory = IAMAccessKeyProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_group.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_group.py new file mode 100644 index 0000000000000..69c2b15ab1bfe --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_group.py @@ -0,0 +1,152 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMGroupProperties(TypedDict): + Arn: Optional[str] + GroupName: Optional[str] + Id: Optional[str] + ManagedPolicyArns: Optional[list[str]] + Path: Optional[str] + Policies: Optional[list[Policy]] + + +class Policy(TypedDict): + PolicyDocument: Optional[dict] + PolicyName: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMGroupProvider(ResourceProvider[IAMGroupProperties]): + TYPE = "AWS::IAM::Group" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMGroupProperties], + ) -> ProgressEvent[IAMGroupProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Create-only properties: + - /properties/GroupName + + Read-only properties: + - /properties/Arn + - /properties/Id + """ + model = request.desired_state + iam_client = request.aws_client_factory.iam + + group_name = model.get("GroupName") + if not group_name: + group_name = util.generate_default_name(request.stack_name, request.logical_resource_id) + model["GroupName"] = group_name + + create_group_result = iam_client.create_group( + **util.select_attributes(model, ["GroupName", "Path"]) + ) + model["Id"] = create_group_result["Group"][ + "GroupName" + ] # a bit weird that this is not the GroupId + model["Arn"] = create_group_result["Group"]["Arn"] + + for managed_policy in model.get("ManagedPolicyArns", []): + iam_client.attach_group_policy(GroupName=group_name, PolicyArn=managed_policy) + + for inline_policy in model.get("Policies", []): + doc = json.dumps(inline_policy.get("PolicyDocument")) + iam_client.put_group_policy( + GroupName=group_name, + PolicyName=inline_policy.get("PolicyName"), + PolicyDocument=doc, + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[IAMGroupProperties], + ) -> ProgressEvent[IAMGroupProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMGroupProperties], + ) -> ProgressEvent[IAMGroupProperties]: + """ + Delete a resource + """ + model = request.desired_state + iam_client = request.aws_client_factory.iam + + # first we need to detach and delete any attached policies + for managed_policy in model.get("ManagedPolicyArns", []): + iam_client.detach_group_policy(GroupName=model["GroupName"], PolicyArn=managed_policy) + + for inline_policy in model.get("Policies", []): + iam_client.delete_group_policy( + GroupName=model["GroupName"], + PolicyName=inline_policy.get("PolicyName"), + ) + + # now we can delete the actual group + iam_client.delete_group(GroupName=model["GroupName"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[IAMGroupProperties], + ) -> ProgressEvent[IAMGroupProperties]: + """ + Update a resource + """ + # TODO: note: while the resource implemented "update_resource" previously, it didn't actually work + # so leaving it out here for now + # iam.update_group( + # GroupName=props.get("GroupName"), + # NewPath=props.get("NewPath") or "", + # NewGroupName=props.get("NewGroupName") or "", + # ) + raise NotImplementedError + + def list( + self, + request: ResourceRequest[IAMGroupProperties], + ) -> ProgressEvent[IAMGroupProperties]: + resources = request.aws_client_factory.iam.list_groups() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + IAMGroupProperties(Id=resource["GroupName"]) for resource in resources["Groups"] + ], + ) diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_group.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_group.schema.json new file mode 100644 index 0000000000000..e31b0e5594b3f --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_group.schema.json @@ -0,0 +1,61 @@ +{ + "typeName": "AWS::IAM::Group", + "description": "Resource Type definition for AWS::IAM::Group", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "GroupName": { + "type": "string" + }, + "ManagedPolicyArns": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Path": { + "type": "string" + }, + "Policies": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Policy" + } + } + }, + "definitions": { + "Policy": { + "type": "object", + "additionalProperties": false, + "properties": { + "PolicyDocument": { + "type": "object" + }, + "PolicyName": { + "type": "string" + } + }, + "required": [ + "PolicyDocument", + "PolicyName" + ] + } + }, + "readOnlyProperties": [ + "/properties/Arn", + "/properties/Id" + ], + "createOnlyProperties": [ + "/properties/GroupName" + ], + "primaryIdentifier": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_group_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_group_plugin.py new file mode 100644 index 0000000000000..24af55af719b1 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_group_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMGroupProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::Group" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_group import IAMGroupProvider + + self.factory = IAMGroupProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile.py new file mode 100644 index 0000000000000..b65f5f079d0ff --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile.py @@ -0,0 +1,136 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMInstanceProfileProperties(TypedDict): + Roles: Optional[list[str]] + Arn: Optional[str] + InstanceProfileName: Optional[str] + Path: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMInstanceProfileProvider(ResourceProvider[IAMInstanceProfileProperties]): + TYPE = "AWS::IAM::InstanceProfile" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMInstanceProfileProperties], + ) -> ProgressEvent[IAMInstanceProfileProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/InstanceProfileName + + Required properties: + - Roles + + Create-only properties: + - /properties/InstanceProfileName + - /properties/Path + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - iam:CreateInstanceProfile + - iam:PassRole + - iam:AddRoleToInstanceProfile + - iam:GetInstanceProfile + + """ + model = request.desired_state + iam = request.aws_client_factory.iam + + # defaults + role_name = model.get("InstanceProfileName") + if not role_name: + role_name = util.generate_default_name(request.stack_name, request.logical_resource_id) + model["InstanceProfileName"] = role_name + + response = iam.create_instance_profile( + **util.select_attributes( + model, + [ + "InstanceProfileName", + "Path", + ], + ), + ) + for role_name in model.get("Roles", []): + iam.add_role_to_instance_profile( + InstanceProfileName=model["InstanceProfileName"], RoleName=role_name + ) + model["Arn"] = response["InstanceProfile"]["Arn"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[IAMInstanceProfileProperties], + ) -> ProgressEvent[IAMInstanceProfileProperties]: + """ + Fetch resource information + + IAM permissions required: + - iam:GetInstanceProfile + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMInstanceProfileProperties], + ) -> ProgressEvent[IAMInstanceProfileProperties]: + """ + Delete a resource + + IAM permissions required: + - iam:GetInstanceProfile + - iam:RemoveRoleFromInstanceProfile + - iam:DeleteInstanceProfile + """ + iam = request.aws_client_factory.iam + instance_profile = iam.get_instance_profile( + InstanceProfileName=request.previous_state["InstanceProfileName"] + ) + for role in instance_profile["InstanceProfile"]["Roles"]: + iam.remove_role_from_instance_profile( + InstanceProfileName=request.previous_state["InstanceProfileName"], + RoleName=role["RoleName"], + ) + iam.delete_instance_profile( + InstanceProfileName=request.previous_state["InstanceProfileName"] + ) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[IAMInstanceProfileProperties], + ) -> ProgressEvent[IAMInstanceProfileProperties]: + """ + Update a resource + + IAM permissions required: + - iam:PassRole + - iam:RemoveRoleFromInstanceProfile + - iam:AddRoleToInstanceProfile + - iam:GetInstanceProfile + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile.schema.json new file mode 100644 index 0000000000000..f04a6751c1691 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile.schema.json @@ -0,0 +1,77 @@ +{ + "typeName": "AWS::IAM::InstanceProfile", + "description": "Resource Type definition for AWS::IAM::InstanceProfile", + "additionalProperties": false, + "properties": { + "Path": { + "type": "string", + "description": "The path to the instance profile." + }, + "Roles": { + "type": "array", + "description": "The name of the role to associate with the instance profile. Only one role can be assigned to an EC2 instance at a time, and all applications on the instance share the same role and permissions.", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "InstanceProfileName": { + "type": "string", + "description": "The name of the instance profile to create." + }, + "Arn": { + "type": "string", + "description": "The Amazon Resource Name (ARN) of the instance profile." + } + }, + "taggable": false, + "required": [ + "Roles" + ], + "createOnlyProperties": [ + "/properties/InstanceProfileName", + "/properties/Path" + ], + "primaryIdentifier": [ + "/properties/InstanceProfileName" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "handlers": { + "create": { + "permissions": [ + "iam:CreateInstanceProfile", + "iam:PassRole", + "iam:AddRoleToInstanceProfile", + "iam:GetInstanceProfile" + ] + }, + "read": { + "permissions": [ + "iam:GetInstanceProfile" + ] + }, + "update": { + "permissions": [ + "iam:PassRole", + "iam:RemoveRoleFromInstanceProfile", + "iam:AddRoleToInstanceProfile", + "iam:GetInstanceProfile" + ] + }, + "delete": { + "permissions": [ + "iam:GetInstanceProfile", + "iam:RemoveRoleFromInstanceProfile", + "iam:DeleteInstanceProfile" + ] + }, + "list": { + "permissions": [ + "iam:ListInstanceProfiles" + ] + } + } +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile_plugin.py new file mode 100644 index 0000000000000..875b729a55323 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_instanceprofile_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMInstanceProfileProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::InstanceProfile" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_instanceprofile import ( + IAMInstanceProfileProvider, + ) + + self.factory = IAMInstanceProfileProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy.py new file mode 100644 index 0000000000000..0bca0e5a02169 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy.py @@ -0,0 +1,117 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMManagedPolicyProperties(TypedDict): + PolicyDocument: Optional[dict] + Description: Optional[str] + Groups: Optional[list[str]] + Id: Optional[str] + ManagedPolicyName: Optional[str] + Path: Optional[str] + Roles: Optional[list[str]] + Users: Optional[list[str]] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMManagedPolicyProvider(ResourceProvider[IAMManagedPolicyProperties]): + TYPE = "AWS::IAM::ManagedPolicy" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMManagedPolicyProperties], + ) -> ProgressEvent[IAMManagedPolicyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - PolicyDocument + + Create-only properties: + - /properties/ManagedPolicyName + - /properties/Description + - /properties/Path + + Read-only properties: + - /properties/Id + + """ + model = request.desired_state + iam_client = request.aws_client_factory.iam + group_name = model.get("ManagedPolicyName") + if not group_name: + group_name = util.generate_default_name(request.stack_name, request.logical_resource_id) + model["ManagedPolicyName"] = group_name + + policy_doc = json.dumps(util.remove_none_values(model["PolicyDocument"])) + policy = iam_client.create_policy( + PolicyName=model["ManagedPolicyName"], PolicyDocument=policy_doc + ) + model["Id"] = policy["Policy"]["Arn"] + policy_arn = policy["Policy"]["Arn"] + for role in model.get("Roles", []): + iam_client.attach_role_policy(RoleName=role, PolicyArn=policy_arn) + for user in model.get("Users", []): + iam_client.attach_user_policy(UserName=user, PolicyArn=policy_arn) + for group in model.get("Groups", []): + iam_client.attach_group_policy(GroupName=group, PolicyArn=policy_arn) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[IAMManagedPolicyProperties], + ) -> ProgressEvent[IAMManagedPolicyProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMManagedPolicyProperties], + ) -> ProgressEvent[IAMManagedPolicyProperties]: + """ + Delete a resource + """ + iam_client = request.aws_client_factory.iam + model = request.previous_state + + for role in model.get("Roles", []): + iam_client.detach_role_policy(RoleName=role, PolicyArn=model["Id"]) + for user in model.get("Users", []): + iam_client.detach_user_policy(UserName=user, PolicyArn=model["Id"]) + for group in model.get("Groups", []): + iam_client.detach_group_policy(GroupName=group, PolicyArn=model["Id"]) + + iam_client.delete_policy(PolicyArn=model["Id"]) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def update( + self, + request: ResourceRequest[IAMManagedPolicyProperties], + ) -> ProgressEvent[IAMManagedPolicyProperties]: + """ + Update a resource + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy.schema.json new file mode 100644 index 0000000000000..da6d25ca321bf --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy.schema.json @@ -0,0 +1,57 @@ +{ + "typeName": "AWS::IAM::ManagedPolicy", + "description": "Resource Type definition for AWS::IAM::ManagedPolicy", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "Groups": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "ManagedPolicyName": { + "type": "string" + }, + "Path": { + "type": "string" + }, + "PolicyDocument": { + "type": "object" + }, + "Roles": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Users": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + } + }, + "required": [ + "PolicyDocument" + ], + "createOnlyProperties": [ + "/properties/ManagedPolicyName", + "/properties/Description", + "/properties/Path" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy_plugin.py new file mode 100644 index 0000000000000..d33ce61ef26b5 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_managedpolicy_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMManagedPolicyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::ManagedPolicy" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_managedpolicy import ( + IAMManagedPolicyProvider, + ) + + self.factory = IAMManagedPolicyProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy.py new file mode 100644 index 0000000000000..97fdb19341b57 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy.py @@ -0,0 +1,143 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +import random +import string +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMPolicyProperties(TypedDict): + PolicyDocument: Optional[dict] + PolicyName: Optional[str] + Groups: Optional[list[str]] + Id: Optional[str] + Roles: Optional[list[str]] + Users: Optional[list[str]] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMPolicyProvider(ResourceProvider[IAMPolicyProperties]): + TYPE = "AWS::IAM::Policy" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMPolicyProperties], + ) -> ProgressEvent[IAMPolicyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - PolicyDocument + - PolicyName + + Read-only properties: + - /properties/Id + + """ + model = request.desired_state + iam_client = request.aws_client_factory.iam + + policy_doc = json.dumps(util.remove_none_values(model["PolicyDocument"])) + policy_name = model["PolicyName"] + + if not any([model.get("Roles"), model.get("Users"), model.get("Groups")]): + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model={}, + error_code="InvalidRequest", + message="At least one of [Groups,Roles,Users] must be non-empty.", + ) + + for role in model.get("Roles", []): + iam_client.put_role_policy( + RoleName=role, PolicyName=policy_name, PolicyDocument=policy_doc + ) + for user in model.get("Users", []): + iam_client.put_user_policy( + UserName=user, PolicyName=policy_name, PolicyDocument=policy_doc + ) + for group in model.get("Groups", []): + iam_client.put_group_policy( + GroupName=group, PolicyName=policy_name, PolicyDocument=policy_doc + ) + + # the physical resource ID here has a bit of a weird format + # e.g. 'stack-fnSe-1OKWZIBB89193' where fnSe are the first 4 characters of the LogicalResourceId (or name?) + suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=13)) + model["Id"] = f"stack-{model.get('PolicyName', '')[:4]}-{suffix}" + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[IAMPolicyProperties], + ) -> ProgressEvent[IAMPolicyProperties]: + """ + Fetch resource information + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMPolicyProperties], + ) -> ProgressEvent[IAMPolicyProperties]: + """ + Delete a resource + """ + iam = request.aws_client_factory.iam + + model = request.previous_state + policy_name = request.previous_state["PolicyName"] + for role in model.get("Roles", []): + iam.delete_role_policy(RoleName=role, PolicyName=policy_name) + for user in model.get("Users", []): + iam.delete_user_policy(UserName=user, PolicyName=policy_name) + for group in model.get("Groups", []): + iam.delete_group_policy(GroupName=group, PolicyName=policy_name) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[IAMPolicyProperties], + ) -> ProgressEvent[IAMPolicyProperties]: + """ + Update a resource + """ + iam_client = request.aws_client_factory.iam + model = request.desired_state + # FIXME: this wasn't properly implemented before as well, still needs to be rewritten + policy_doc = json.dumps(util.remove_none_values(model["PolicyDocument"])) + policy_name = model["PolicyName"] + + for role in model.get("Roles", []): + iam_client.put_role_policy( + RoleName=role, PolicyName=policy_name, PolicyDocument=policy_doc + ) + for user in model.get("Users", []): + iam_client.put_user_policy( + UserName=user, PolicyName=policy_name, PolicyDocument=policy_doc + ) + for group in model.get("Groups", []): + iam_client.put_group_policy( + GroupName=group, PolicyName=policy_name, PolicyDocument=policy_doc + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={**request.previous_state, **request.desired_state}, + ) diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy.schema.json new file mode 100644 index 0000000000000..1b6a5fb438e4b --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy.schema.json @@ -0,0 +1,47 @@ +{ + "typeName": "AWS::IAM::Policy", + "description": "Resource Type definition for AWS::IAM::Policy", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "Groups": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "PolicyDocument": { + "type": "object" + }, + "PolicyName": { + "type": "string" + }, + "Roles": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Users": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + } + }, + "required": [ + "PolicyDocument", + "PolicyName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy_plugin.py new file mode 100644 index 0000000000000..a3fdd7e9c9dc3 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_policy_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMPolicyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::Policy" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_policy import IAMPolicyProvider + + self.factory = IAMPolicyProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_role.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_role.py new file mode 100644 index 0000000000000..f3687337e332d --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_role.py @@ -0,0 +1,269 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.functions import call_safe + + +class IAMRoleProperties(TypedDict): + AssumeRolePolicyDocument: Optional[dict | str] + Arn: Optional[str] + Description: Optional[str] + ManagedPolicyArns: Optional[list[str]] + MaxSessionDuration: Optional[int] + Path: Optional[str] + PermissionsBoundary: Optional[str] + Policies: Optional[list[Policy]] + RoleId: Optional[str] + RoleName: Optional[str] + Tags: Optional[list[Tag]] + + +class Policy(TypedDict): + PolicyDocument: Optional[str | dict] + PolicyName: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + +IAM_POLICY_VERSION = "2012-10-17" + + +class IAMRoleProvider(ResourceProvider[IAMRoleProperties]): + TYPE = "AWS::IAM::Role" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMRoleProperties], + ) -> ProgressEvent[IAMRoleProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/RoleName + + Required properties: + - AssumeRolePolicyDocument + + Create-only properties: + - /properties/Path + - /properties/RoleName + + Read-only properties: + - /properties/Arn + - /properties/RoleId + + IAM permissions required: + - iam:CreateRole + - iam:PutRolePolicy + - iam:AttachRolePolicy + - iam:GetRolePolicy <- not in use right now + + """ + model = request.desired_state + iam = request.aws_client_factory.iam + + # defaults + role_name = model.get("RoleName") + if not role_name: + role_name = util.generate_default_name(request.stack_name, request.logical_resource_id) + model["RoleName"] = role_name + + create_role_response = iam.create_role( + **{ + k: v + for k, v in model.items() + if k not in ["ManagedPolicyArns", "Policies", "AssumeRolePolicyDocument"] + }, + AssumeRolePolicyDocument=json.dumps(model["AssumeRolePolicyDocument"]), + ) + + # attach managed policies + policy_arns = model.get("ManagedPolicyArns", []) + for arn in policy_arns: + iam.attach_role_policy(RoleName=role_name, PolicyArn=arn) + + # add inline policies + inline_policies = model.get("Policies", []) + for policy in inline_policies: + if not isinstance(policy, dict): + request.logger.info( + 'Invalid format of policy for IAM role "%s": %s', + model.get("RoleName"), + policy, + ) + continue + pol_name = policy.get("PolicyName") + + # get policy document - make sure we're resolving references in the policy doc + doc = dict(policy["PolicyDocument"]) + doc = util.remove_none_values(doc) + + doc["Version"] = doc.get("Version") or IAM_POLICY_VERSION + statements = doc["Statement"] + statements = statements if isinstance(statements, list) else [statements] + for statement in statements: + if isinstance(statement.get("Resource"), list): + # filter out empty resource strings + statement["Resource"] = [r for r in statement["Resource"] if r] + doc = json.dumps(doc) + iam.put_role_policy( + RoleName=model["RoleName"], + PolicyName=pol_name, + PolicyDocument=doc, + ) + model["Arn"] = create_role_response["Role"]["Arn"] + model["RoleId"] = create_role_response["Role"]["RoleId"] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[IAMRoleProperties], + ) -> ProgressEvent[IAMRoleProperties]: + """ + Fetch resource information + + IAM permissions required: + - iam:GetRole + - iam:ListAttachedRolePolicies + - iam:ListRolePolicies + - iam:GetRolePolicy + """ + role_name = request.desired_state["RoleName"] + get_role = request.aws_client_factory.iam.get_role(RoleName=role_name) + + model = {**get_role["Role"]} + model.pop("CreateDate") + model.pop("RoleLastUsed") + + list_managed_policies = request.aws_client_factory.iam.list_attached_role_policies( + RoleName=role_name + ) + model["ManagedPolicyArns"] = [ + policy["PolicyArn"] for policy in list_managed_policies["AttachedPolicies"] + ] + model["Policies"] = [] + + policies = request.aws_client_factory.iam.list_role_policies(RoleName=role_name) + for policy_name in policies["PolicyNames"]: + policy = request.aws_client_factory.iam.get_role_policy( + RoleName=role_name, PolicyName=policy_name + ) + policy.pop("ResponseMetadata") + policy.pop("RoleName") + model["Policies"].append(policy) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def delete( + self, + request: ResourceRequest[IAMRoleProperties], + ) -> ProgressEvent[IAMRoleProperties]: + """ + Delete a resource + + IAM permissions required: + - iam:DeleteRole + - iam:DetachRolePolicy + - iam:DeleteRolePolicy + - iam:GetRole + - iam:ListAttachedRolePolicies + - iam:ListRolePolicies + """ + iam_client = request.aws_client_factory.iam + role_name = request.previous_state["RoleName"] + + # detach managed policies + for policy in iam_client.list_attached_role_policies(RoleName=role_name).get( + "AttachedPolicies", [] + ): + call_safe( + iam_client.detach_role_policy, + kwargs={"RoleName": role_name, "PolicyArn": policy["PolicyArn"]}, + ) + + # delete inline policies + for inline_policy_name in iam_client.list_role_policies(RoleName=role_name).get( + "PolicyNames", [] + ): + call_safe( + iam_client.delete_role_policy, + kwargs={"RoleName": role_name, "PolicyName": inline_policy_name}, + ) + + iam_client.delete_role(RoleName=role_name) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[IAMRoleProperties], + ) -> ProgressEvent[IAMRoleProperties]: + """ + Update a resource + + IAM permissions required: + - iam:UpdateRole + - iam:UpdateRoleDescription + - iam:UpdateAssumeRolePolicy + - iam:DetachRolePolicy + - iam:AttachRolePolicy + - iam:DeleteRolePermissionsBoundary + - iam:PutRolePermissionsBoundary + - iam:DeleteRolePolicy + - iam:PutRolePolicy + - iam:TagRole + - iam:UntagRole + """ + props = request.desired_state + _states = request.previous_state + + # note that we're using permissions that are not technically allowed here due to the currently broken change detection + props_policy = props.get("AssumeRolePolicyDocument") + # technically a change to the role name shouldn't even get here since it implies a replacement, not an in-place update + # for now we just go with it though + # determine if the previous name was autogenerated or not + new_role_name = props.get("RoleName") + name_changed = new_role_name and new_role_name != _states["RoleName"] + + # new_role_name = props.get("RoleName", _states.get("RoleName")) + policy_changed = props_policy and props_policy != _states.get( + "AssumeRolePolicyDocument", "" + ) + managed_policy_arns_changed = props.get("ManagedPolicyArns", []) != _states.get( + "ManagedPolicyArns", [] + ) + if name_changed or policy_changed or managed_policy_arns_changed: + # TODO: do a proper update instead of replacement + self.delete(request) + return self.create(request) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=request.previous_state) + # raise Exception("why was a change even detected?") + + def list( + self, + request: ResourceRequest[IAMRoleProperties], + ) -> ProgressEvent[IAMRoleProperties]: + resources = request.aws_client_factory.iam.list_roles() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + IAMRoleProperties(RoleName=resource["RoleName"]) for resource in resources["Roles"] + ], + ) diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_role.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_role.schema.json new file mode 100644 index 0000000000000..a7b8a4489cc59 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_role.schema.json @@ -0,0 +1,183 @@ +{ + "typeName": "AWS::IAM::Role", + "$schema": "https://raw.githubusercontent.com/aws-cloudformation/cloudformation-resource-schema/master/src/main/resources/schema/provider.definition.schema.v1.json", + "description": "Resource Type definition for AWS::IAM::Role", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-iam.git", + "definitions": { + "Policy": { + "description": "The inline policy document that is embedded in the specified IAM role.", + "type": "object", + "additionalProperties": false, + "properties": { + "PolicyDocument": { + "description": "The policy document.", + "type": [ + "string", + "object" + ] + }, + "PolicyName": { + "description": "The friendly name (not ARN) identifying the policy.", + "type": "string" + } + }, + "required": [ + "PolicyName", + "PolicyDocument" + ] + }, + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -." + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -." + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false + } + }, + "properties": { + "Arn": { + "description": "The Amazon Resource Name (ARN) for the role.", + "type": "string" + }, + "AssumeRolePolicyDocument": { + "description": "The trust policy that is associated with this role.", + "type": [ + "object", + "string" + ] + }, + "Description": { + "description": "A description of the role that you provide.", + "type": "string" + }, + "ManagedPolicyArns": { + "description": "A list of Amazon Resource Names (ARNs) of the IAM managed policies that you want to attach to the role. ", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "type": "string" + } + }, + "MaxSessionDuration": { + "description": "The maximum session duration (in seconds) that you want to set for the specified role. If you do not specify a value for this setting, the default maximum of one hour is applied. This setting can have a value from 1 hour to 12 hours. ", + "type": "integer" + }, + "Path": { + "description": "The path to the role.", + "type": "string" + }, + "PermissionsBoundary": { + "description": "The ARN of the policy used to set the permissions boundary for the role.", + "type": "string" + }, + "Policies": { + "description": "Adds or updates an inline policy document that is embedded in the specified IAM role. ", + "type": "array", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Policy" + } + }, + "RoleId": { + "description": "The stable and unique string identifying the role.", + "type": "string" + }, + "RoleName": { + "description": "A name for the IAM role, up to 64 characters in length.", + "type": "string" + }, + "Tags": { + "description": "A list of tags that are attached to the role.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "additionalProperties": false, + "required": [ + "AssumeRolePolicyDocument" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/RoleId" + ], + "createOnlyProperties": [ + "/properties/Path", + "/properties/RoleName" + ], + "primaryIdentifier": [ + "/properties/RoleName" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": false, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "iam:CreateRole", + "iam:PutRolePolicy", + "iam:AttachRolePolicy", + "iam:GetRolePolicy" + ] + }, + "read": { + "permissions": [ + "iam:GetRole", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:GetRolePolicy" + ] + }, + "update": { + "permissions": [ + "iam:UpdateRole", + "iam:UpdateRoleDescription", + "iam:UpdateAssumeRolePolicy", + "iam:DetachRolePolicy", + "iam:AttachRolePolicy", + "iam:DeleteRolePermissionsBoundary", + "iam:PutRolePermissionsBoundary", + "iam:DeleteRolePolicy", + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole" + ] + }, + "delete": { + "permissions": [ + "iam:DeleteRole", + "iam:DetachRolePolicy", + "iam:DeleteRolePolicy", + "iam:GetRole", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies" + ] + }, + "list": { + "permissions": [ + "iam:ListRoles" + ] + } + } +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_role_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_role_plugin.py new file mode 100644 index 0000000000000..d6c7059f611eb --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_role_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMRoleProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::Role" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_role import IAMRoleProvider + + self.factory = IAMRoleProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate.py new file mode 100644 index 0000000000000..233f9554efcc0 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate.py @@ -0,0 +1,133 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMServerCertificateProperties(TypedDict): + Arn: Optional[str] + CertificateBody: Optional[str] + CertificateChain: Optional[str] + Path: Optional[str] + PrivateKey: Optional[str] + ServerCertificateName: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMServerCertificateProvider(ResourceProvider[IAMServerCertificateProperties]): + TYPE = "AWS::IAM::ServerCertificate" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMServerCertificateProperties], + ) -> ProgressEvent[IAMServerCertificateProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/ServerCertificateName + + + + Create-only properties: + - /properties/ServerCertificateName + - /properties/PrivateKey + - /properties/CertificateBody + - /properties/CertificateChain + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - iam:UploadServerCertificate + - iam:GetServerCertificate + + """ + model = request.desired_state + if not model.get("ServerCertificateName"): + model["ServerCertificateName"] = util.generate_default_name_without_stack( + request.logical_resource_id + ) + + create_params = util.select_attributes( + model, + [ + "ServerCertificateName", + "PrivateKey", + "CertificateBody", + "CertificateChain", + "Path", + "Tags", + ], + ) + + # Create the resource + certificate = request.aws_client_factory.iam.upload_server_certificate(**create_params) + model["Arn"] = certificate["ServerCertificateMetadata"]["Arn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[IAMServerCertificateProperties], + ) -> ProgressEvent[IAMServerCertificateProperties]: + """ + Fetch resource information + + IAM permissions required: + - iam:GetServerCertificate + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMServerCertificateProperties], + ) -> ProgressEvent[IAMServerCertificateProperties]: + """ + Delete a resource + + IAM permissions required: + - iam:DeleteServerCertificate + """ + model = request.desired_state + request.aws_client_factory.iam.delete_server_certificate( + ServerCertificateName=model["ServerCertificateName"] + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + ) + + def update( + self, + request: ResourceRequest[IAMServerCertificateProperties], + ) -> ProgressEvent[IAMServerCertificateProperties]: + """ + Update a resource + + IAM permissions required: + - iam:TagServerCertificate + - iam:UntagServerCertificate + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate.schema.json new file mode 100644 index 0000000000000..b0af6c74c2da9 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate.schema.json @@ -0,0 +1,129 @@ +{ + "typeName": "AWS::IAM::ServerCertificate", + "description": "Resource Type definition for AWS::IAM::ServerCertificate", + "additionalProperties": false, + "properties": { + "CertificateBody": { + "minLength": 1, + "maxLength": 16384, + "pattern": "[\\u0009\\u000A\\u000D\\u0020-\\u00FF]+", + "type": "string" + }, + "CertificateChain": { + "minLength": 1, + "maxLength": 2097152, + "pattern": "[\\u0009\\u000A\\u000D\\u0020-\\u00FF]+", + "type": "string" + }, + "ServerCertificateName": { + "minLength": 1, + "maxLength": 128, + "pattern": "[\\w+=,.@-]+", + "type": "string" + }, + "Path": { + "minLength": 1, + "maxLength": 512, + "pattern": "(\\u002F)|(\\u002F[\\u0021-\\u007F]+\\u002F)", + "type": "string" + }, + "PrivateKey": { + "minLength": 1, + "maxLength": 16384, + "pattern": "[\\u0009\\u000A\\u000D\\u0020-\\u00FF]+", + "type": "string" + }, + "Arn": { + "description": "Amazon Resource Name (ARN) of the server certificate", + "minLength": 1, + "maxLength": 1600, + "type": "string" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "Key": { + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "createOnlyProperties": [ + "/properties/ServerCertificateName", + "/properties/PrivateKey", + "/properties/CertificateBody", + "/properties/CertificateChain" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "writeOnlyProperties": [ + "/properties/PrivateKey", + "/properties/CertificateBody", + "/properties/CertificateChain" + ], + "primaryIdentifier": [ + "/properties/ServerCertificateName" + ], + "handlers": { + "create": { + "permissions": [ + "iam:UploadServerCertificate", + "iam:GetServerCertificate" + ] + }, + "read": { + "permissions": [ + "iam:GetServerCertificate" + ] + }, + "update": { + "permissions": [ + "iam:TagServerCertificate", + "iam:UntagServerCertificate" + ] + }, + "delete": { + "permissions": [ + "iam:DeleteServerCertificate" + ] + }, + "list": { + "permissions": [ + "iam:ListServerCertificates", + "iam:GetServerCertificate" + ] + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": false + } +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate_plugin.py new file mode 100644 index 0000000000000..13723bd73ce2b --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servercertificate_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMServerCertificateProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::ServerCertificate" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_servercertificate import ( + IAMServerCertificateProvider, + ) + + self.factory = IAMServerCertificateProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole.py new file mode 100644 index 0000000000000..2437966df10e7 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole.py @@ -0,0 +1,95 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMServiceLinkedRoleProperties(TypedDict): + AWSServiceName: Optional[str] + CustomSuffix: Optional[str] + Description: Optional[str] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMServiceLinkedRoleProvider(ResourceProvider[IAMServiceLinkedRoleProperties]): + TYPE = "AWS::IAM::ServiceLinkedRole" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMServiceLinkedRoleProperties], + ) -> ProgressEvent[IAMServiceLinkedRoleProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - AWSServiceName + + Create-only properties: + - /properties/CustomSuffix + - /properties/AWSServiceName + + Read-only properties: + - /properties/Id + + """ + model = request.desired_state + response = request.aws_client_factory.iam.create_service_linked_role(**model) + model["Id"] = response["Role"]["RoleName"] # TODO + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[IAMServiceLinkedRoleProperties], + ) -> ProgressEvent[IAMServiceLinkedRoleProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMServiceLinkedRoleProperties], + ) -> ProgressEvent[IAMServiceLinkedRoleProperties]: + """ + Delete a resource + """ + request.aws_client_factory.iam.delete_service_linked_role( + RoleName=request.previous_state["Id"] + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[IAMServiceLinkedRoleProperties], + ) -> ProgressEvent[IAMServiceLinkedRoleProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole.schema.json new file mode 100644 index 0000000000000..4472358b498b1 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole.schema.json @@ -0,0 +1,32 @@ +{ + "typeName": "AWS::IAM::ServiceLinkedRole", + "description": "Resource Type definition for AWS::IAM::ServiceLinkedRole", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "CustomSuffix": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "AWSServiceName": { + "type": "string" + } + }, + "required": [ + "AWSServiceName" + ], + "createOnlyProperties": [ + "/properties/CustomSuffix", + "/properties/AWSServiceName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole_plugin.py new file mode 100644 index 0000000000000..e81cc105f85c1 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_servicelinkedrole_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMServiceLinkedRoleProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::ServiceLinkedRole" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_servicelinkedrole import ( + IAMServiceLinkedRoleProvider, + ) + + self.factory = IAMServiceLinkedRoleProvider diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_user.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_user.py new file mode 100644 index 0000000000000..8600522013b39 --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_user.py @@ -0,0 +1,158 @@ +# LocalStack Resource Provider Scaffolding v1 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class IAMUserProperties(TypedDict): + Arn: Optional[str] + Groups: Optional[list[str]] + Id: Optional[str] + LoginProfile: Optional[LoginProfile] + ManagedPolicyArns: Optional[list[str]] + Path: Optional[str] + PermissionsBoundary: Optional[str] + Policies: Optional[list[Policy]] + Tags: Optional[list[Tag]] + UserName: Optional[str] + + +class Policy(TypedDict): + PolicyDocument: Optional[dict] + PolicyName: Optional[str] + + +class LoginProfile(TypedDict): + Password: Optional[str] + PasswordResetRequired: Optional[bool] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class IAMUserProvider(ResourceProvider[IAMUserProperties]): + TYPE = "AWS::IAM::User" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[IAMUserProperties], + ) -> ProgressEvent[IAMUserProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Create-only properties: + - /properties/UserName + + Read-only properties: + - /properties/Id + - /properties/Arn + """ + model = request.desired_state + iam_client = request.aws_client_factory.iam + # TODO: validations + # TODO: idempotency + + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + + # Set defaults + if not model.get("UserName"): + model["UserName"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + # actually create the resource + # note: technically we could make this synchronous, but for the sake of this being an example it is intentionally "asynchronous" and returns IN_PROGRESS + + # this example uses a helper utility, check out the module for more helpful utilities and add your own! + iam_client.create_user( + **util.select_attributes(model, ["UserName", "Path", "PermissionsBoundary", "Tags"]) + ) + + # alternatively you can also just do: + # iam_client.create_user( + # UserName=model["UserName"], + # Path=model["Path"], + # PermissionsBoundary=model["PermissionsBoundary"], + # Tags=model["Tags"], + # ) + + # this kind of logic below was previously done in either a result_handler or a custom "_post_create" function + for group in model.get("Groups", []): + iam_client.add_user_to_group(GroupName=group, UserName=model["UserName"]) + + for policy_arn in model.get("ManagedPolicyArns", []): + iam_client.attach_user_policy(UserName=model["UserName"], PolicyArn=policy_arn) + + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + get_response = iam_client.get_user(UserName=model["UserName"]) + model["Id"] = get_response["User"]["UserName"] # this is the ref / physical resource id + model["Arn"] = get_response["User"]["Arn"] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[IAMUserProperties], + ) -> ProgressEvent[IAMUserProperties]: + """ + Fetch resource information + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[IAMUserProperties], + ) -> ProgressEvent[IAMUserProperties]: + """ + Delete a resource + """ + iam_client = request.aws_client_factory.iam + iam_client.delete_user(UserName=request.desired_state["Id"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=request.previous_state) + + def update( + self, + request: ResourceRequest[IAMUserProperties], + ) -> ProgressEvent[IAMUserProperties]: + """ + Update a resource + """ + # return ProgressEvent(OperationStatus.SUCCESS, request.desired_state) + raise NotImplementedError + + def list( + self, + request: ResourceRequest[IAMUserProperties], + ) -> ProgressEvent[IAMUserProperties]: + resources = request.aws_client_factory.iam.list_users() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + IAMUserProperties(Id=resource["UserName"]) for resource in resources["Users"] + ], + ) diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_user.schema.json b/localstack-core/localstack/services/iam/resource_providers/aws_iam_user.schema.json new file mode 100644 index 0000000000000..aabdb1c81ddbf --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_user.schema.json @@ -0,0 +1,112 @@ +{ + "typeName": "AWS::IAM::User", + "description": "Resource Type definition for AWS::IAM::User", + "additionalProperties": false, + "properties": { + "Path": { + "type": "string" + }, + "ManagedPolicyArns": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "Policies": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Policy" + } + }, + "UserName": { + "type": "string" + }, + "Groups": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "LoginProfile": { + "$ref": "#/definitions/LoginProfile" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "PermissionsBoundary": { + "type": "string" + } + }, + "definitions": { + "Policy": { + "type": "object", + "additionalProperties": false, + "properties": { + "PolicyDocument": { + "type": "object" + }, + "PolicyName": { + "type": "string" + } + }, + "required": [ + "PolicyName", + "PolicyDocument" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "LoginProfile": { + "type": "object", + "additionalProperties": false, + "properties": { + "PasswordResetRequired": { + "type": "boolean" + }, + "Password": { + "type": "string" + } + }, + "required": [ + "Password" + ] + } + }, + "createOnlyProperties": [ + "/properties/UserName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/Arn" + ] +} diff --git a/localstack-core/localstack/services/iam/resource_providers/aws_iam_user_plugin.py b/localstack-core/localstack/services/iam/resource_providers/aws_iam_user_plugin.py new file mode 100644 index 0000000000000..60acd8fc1493c --- /dev/null +++ b/localstack-core/localstack/services/iam/resource_providers/aws_iam_user_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class IAMUserProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::IAM::User" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.iam.resource_providers.aws_iam_user import IAMUserProvider + + self.factory = IAMUserProvider diff --git a/localstack-core/localstack/services/iam/resources/service_linked_roles.py b/localstack-core/localstack/services/iam/resources/service_linked_roles.py new file mode 100644 index 0000000000000..679ec393dcffa --- /dev/null +++ b/localstack-core/localstack/services/iam/resources/service_linked_roles.py @@ -0,0 +1,550 @@ +SERVICE_LINKED_ROLES = { + "accountdiscovery.ssm.amazonaws.com": { + "service": "accountdiscovery.ssm.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonSSM_AccountDiscovery", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSSystemsManagerAccountDiscoveryServicePolicy" + ], + "suffix_allowed": False, + }, + "acm.amazonaws.com": { + "service": "acm.amazonaws.com", + "role_name": "AWSServiceRoleForCertificateManager", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/CertificateManagerServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "appmesh.amazonaws.com": { + "service": "appmesh.amazonaws.com", + "role_name": "AWSServiceRoleForAppMesh", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSAppMeshServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "autoscaling-plans.amazonaws.com": { + "service": "autoscaling-plans.amazonaws.com", + "role_name": "AWSServiceRoleForAutoScalingPlans_EC2AutoScaling", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSAutoScalingPlansEC2AutoScalingPolicy" + ], + "suffix_allowed": False, + }, + "autoscaling.amazonaws.com": { + "service": "autoscaling.amazonaws.com", + "role_name": "AWSServiceRoleForAutoScaling", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AutoScalingServiceRolePolicy" + ], + "suffix_allowed": True, + }, + "backup.amazonaws.com": { + "service": "backup.amazonaws.com", + "role_name": "AWSServiceRoleForBackup", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSBackupServiceLinkedRolePolicyForBackup" + ], + "suffix_allowed": False, + }, + "batch.amazonaws.com": { + "service": "batch.amazonaws.com", + "role_name": "AWSServiceRoleForBatch", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/BatchServiceRolePolicy"], + "suffix_allowed": False, + }, + "cassandra.application-autoscaling.amazonaws.com": { + "service": "cassandra.application-autoscaling.amazonaws.com", + "role_name": "AWSServiceRoleForApplicationAutoScaling_CassandraTable", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSApplicationAutoscalingCassandraTablePolicy" + ], + "suffix_allowed": False, + }, + "cks.kms.amazonaws.com": { + "service": "cks.kms.amazonaws.com", + "role_name": "AWSServiceRoleForKeyManagementServiceCustomKeyStores", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSKeyManagementServiceCustomKeyStoresServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "cloudtrail.amazonaws.com": { + "service": "cloudtrail.amazonaws.com", + "role_name": "AWSServiceRoleForCloudTrail", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/CloudTrailServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "codestar-notifications.amazonaws.com": { + "service": "codestar-notifications.amazonaws.com", + "role_name": "AWSServiceRoleForCodeStarNotifications", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSCodeStarNotificationsServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "config.amazonaws.com": { + "service": "config.amazonaws.com", + "role_name": "AWSServiceRoleForConfig", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSConfigServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "connect.amazonaws.com": { + "service": "connect.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonConnect", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonConnectServiceLinkedRolePolicy" + ], + "suffix_allowed": True, + }, + "dms-fleet-advisor.amazonaws.com": { + "service": "dms-fleet-advisor.amazonaws.com", + "role_name": "AWSServiceRoleForDMSFleetAdvisor", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSDMSFleetAdvisorServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "dms.amazonaws.com": { + "service": "dms.amazonaws.com", + "role_name": "AWSServiceRoleForDMSServerless", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSDMSServerlessServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "docdb-elastic.amazonaws.com": { + "service": "docdb-elastic.amazonaws.com", + "role_name": "AWSServiceRoleForDocDB-Elastic", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonDocDB-ElasticServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ec2-instance-connect.amazonaws.com": { + "service": "ec2-instance-connect.amazonaws.com", + "role_name": "AWSServiceRoleForEc2InstanceConnect", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/Ec2InstanceConnectEndpoint" + ], + "suffix_allowed": False, + }, + "ec2.application-autoscaling.amazonaws.com": { + "service": "ec2.application-autoscaling.amazonaws.com", + "role_name": "AWSServiceRoleForApplicationAutoScaling_EC2SpotFleetRequest", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSApplicationAutoscalingEC2SpotFleetRequestPolicy" + ], + "suffix_allowed": False, + }, + "ecr.amazonaws.com": { + "service": "ecr.amazonaws.com", + "role_name": "AWSServiceRoleForECRTemplate", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/ECRTemplateServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ecs.amazonaws.com": { + "service": "ecs.amazonaws.com", + "role_name": "AWSServiceRoleForECS", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonECSServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "eks-connector.amazonaws.com": { + "service": "eks-connector.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonEKSConnector", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonEKSConnectorServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "eks-fargate.amazonaws.com": { + "service": "eks-fargate.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonEKSForFargate", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonEKSForFargateServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "eks-nodegroup.amazonaws.com": { + "service": "eks-nodegroup.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonEKSNodegroup", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSServiceRoleForAmazonEKSNodegroup" + ], + "suffix_allowed": False, + }, + "eks.amazonaws.com": { + "service": "eks.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonEKS", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonEKSServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "elasticache.amazonaws.com": { + "service": "elasticache.amazonaws.com", + "role_name": "AWSServiceRoleForElastiCache", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/ElastiCacheServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "elasticbeanstalk.amazonaws.com": { + "service": "elasticbeanstalk.amazonaws.com", + "role_name": "AWSServiceRoleForElasticBeanstalk", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSElasticBeanstalkServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "elasticfilesystem.amazonaws.com": { + "service": "elasticfilesystem.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonElasticFileSystem", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonElasticFileSystemServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "elasticloadbalancing.amazonaws.com": { + "service": "elasticloadbalancing.amazonaws.com", + "role_name": "AWSServiceRoleForElasticLoadBalancing", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSElasticLoadBalancingServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "email.cognito-idp.amazonaws.com": { + "service": "email.cognito-idp.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonCognitoIdpEmailService", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonCognitoIdpEmailServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "emr-containers.amazonaws.com": { + "service": "emr-containers.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonEMRContainers", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonEMRContainersServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "emrwal.amazonaws.com": { + "service": "emrwal.amazonaws.com", + "role_name": "AWSServiceRoleForEMRWAL", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/EMRDescribeClusterPolicyForEMRWAL" + ], + "suffix_allowed": False, + }, + "fis.amazonaws.com": { + "service": "fis.amazonaws.com", + "role_name": "AWSServiceRoleForFIS", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonFISServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "grafana.amazonaws.com": { + "service": "grafana.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonGrafana", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonGrafanaServiceLinkedRolePolicy" + ], + "suffix_allowed": False, + }, + "imagebuilder.amazonaws.com": { + "service": "imagebuilder.amazonaws.com", + "role_name": "AWSServiceRoleForImageBuilder", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSServiceRoleForImageBuilder" + ], + "suffix_allowed": False, + }, + "iotmanagedintegrations.amazonaws.com": { + "service": "iotmanagedintegrations.amazonaws.com", + "role_name": "AWSServiceRoleForIoTManagedIntegrations", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSIoTManagedIntegrationsRolePolicy" + ], + "suffix_allowed": False, + }, + "kafka.amazonaws.com": { + "service": "kafka.amazonaws.com", + "role_name": "AWSServiceRoleForKafka", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/KafkaServiceRolePolicy"], + "suffix_allowed": False, + }, + "kafkaconnect.amazonaws.com": { + "service": "kafkaconnect.amazonaws.com", + "role_name": "AWSServiceRoleForKafkaConnect", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/KafkaConnectServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "lakeformation.amazonaws.com": { + "service": "lakeformation.amazonaws.com", + "role_name": "AWSServiceRoleForLakeFormationDataAccess", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/LakeFormationDataAccessServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "lex.amazonaws.com": { + "service": "lex.amazonaws.com", + "role_name": "AWSServiceRoleForLexBots", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/AmazonLexBotPolicy"], + "suffix_allowed": False, + }, + "lexv2.amazonaws.com": { + "service": "lexv2.amazonaws.com", + "role_name": "AWSServiceRoleForLexV2Bots", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/AmazonLexV2BotPolicy"], + "suffix_allowed": True, + }, + "lightsail.amazonaws.com": { + "service": "lightsail.amazonaws.com", + "role_name": "AWSServiceRoleForLightsail", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/LightsailExportAccess"], + "suffix_allowed": False, + }, + "m2.amazonaws.com": { + "service": "m2.amazonaws.com", + "role_name": "AWSServiceRoleForAWSM2", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/AWSM2ServicePolicy"], + "suffix_allowed": False, + }, + "memorydb.amazonaws.com": { + "service": "memorydb.amazonaws.com", + "role_name": "AWSServiceRoleForMemoryDB", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/MemoryDBServiceRolePolicy"], + "suffix_allowed": False, + }, + "mq.amazonaws.com": { + "service": "mq.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonMQ", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/AmazonMQServiceRolePolicy"], + "suffix_allowed": False, + }, + "mrk.kms.amazonaws.com": { + "service": "mrk.kms.amazonaws.com", + "role_name": "AWSServiceRoleForKeyManagementServiceMultiRegionKeys", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSKeyManagementServiceMultiRegionKeysServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "notifications.amazonaws.com": { + "service": "notifications.amazonaws.com", + "role_name": "AWSServiceRoleForAwsUserNotifications", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSUserNotificationsServiceLinkedRolePolicy" + ], + "suffix_allowed": False, + }, + "observability.aoss.amazonaws.com": { + "service": "observability.aoss.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonOpenSearchServerless", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonOpenSearchServerlessServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "opensearchservice.amazonaws.com": { + "service": "opensearchservice.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonOpenSearchService", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonOpenSearchServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ops.apigateway.amazonaws.com": { + "service": "ops.apigateway.amazonaws.com", + "role_name": "AWSServiceRoleForAPIGateway", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/APIGatewayServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ops.emr-serverless.amazonaws.com": { + "service": "ops.emr-serverless.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonEMRServerless", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonEMRServerlessServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "opsdatasync.ssm.amazonaws.com": { + "service": "opsdatasync.ssm.amazonaws.com", + "role_name": "AWSServiceRoleForSystemsManagerOpsDataSync", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSSystemsManagerOpsDataSyncServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "opsinsights.ssm.amazonaws.com": { + "service": "opsinsights.ssm.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonSSM_OpsInsights", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSSSMOpsInsightsServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "pullthroughcache.ecr.amazonaws.com": { + "service": "pullthroughcache.ecr.amazonaws.com", + "role_name": "AWSServiceRoleForECRPullThroughCache", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSECRPullThroughCache_ServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ram.amazonaws.com": { + "service": "ram.amazonaws.com", + "role_name": "AWSServiceRoleForResourceAccessManager", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSResourceAccessManagerServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "rds.amazonaws.com": { + "service": "rds.amazonaws.com", + "role_name": "AWSServiceRoleForRDS", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonRDSServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "redshift.amazonaws.com": { + "service": "redshift.amazonaws.com", + "role_name": "AWSServiceRoleForRedshift", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonRedshiftServiceLinkedRolePolicy" + ], + "suffix_allowed": False, + }, + "replication.cassandra.amazonaws.com": { + "service": "replication.cassandra.amazonaws.com", + "role_name": "AWSServiceRoleForKeyspacesReplication", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/KeyspacesReplicationServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "replication.ecr.amazonaws.com": { + "service": "replication.ecr.amazonaws.com", + "role_name": "AWSServiceRoleForECRReplication", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/ECRReplicationServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "repository.sync.codeconnections.amazonaws.com": { + "service": "repository.sync.codeconnections.amazonaws.com", + "role_name": "AWSServiceRoleForGitSync", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSGitSyncServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "resource-explorer-2.amazonaws.com": { + "service": "resource-explorer-2.amazonaws.com", + "role_name": "AWSServiceRoleForResourceExplorer", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSResourceExplorerServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "rolesanywhere.amazonaws.com": { + "service": "rolesanywhere.amazonaws.com", + "role_name": "AWSServiceRoleForRolesAnywhere", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSRolesAnywhereServicePolicy" + ], + "suffix_allowed": False, + }, + "s3-outposts.amazonaws.com": { + "service": "s3-outposts.amazonaws.com", + "role_name": "AWSServiceRoleForS3OnOutposts", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSS3OnOutpostsServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ses.amazonaws.com": { + "service": "ses.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonSES", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonSESServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "shield.amazonaws.com": { + "service": "shield.amazonaws.com", + "role_name": "AWSServiceRoleForAWSShield", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSShieldServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ssm-incidents.amazonaws.com": { + "service": "ssm-incidents.amazonaws.com", + "role_name": "AWSServiceRoleForIncidentManager", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSIncidentManagerServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "ssm-quicksetup.amazonaws.com": { + "service": "ssm-quicksetup.amazonaws.com", + "role_name": "AWSServiceRoleForSSMQuickSetup", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/SSMQuickSetupRolePolicy"], + "suffix_allowed": False, + }, + "ssm.amazonaws.com": { + "service": "ssm.amazonaws.com", + "role_name": "AWSServiceRoleForAmazonSSM", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AmazonSSMServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "sso.amazonaws.com": { + "service": "sso.amazonaws.com", + "role_name": "AWSServiceRoleForSSO", + "attached_policies": ["arn:aws:iam::aws:policy/aws-service-role/AWSSSOServiceRolePolicy"], + "suffix_allowed": False, + }, + "vpcorigin.cloudfront.amazonaws.com": { + "service": "vpcorigin.cloudfront.amazonaws.com", + "role_name": "AWSServiceRoleForCloudFrontVPCOrigin", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/AWSCloudFrontVPCOriginServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "waf.amazonaws.com": { + "service": "waf.amazonaws.com", + "role_name": "AWSServiceRoleForWAFLogging", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/WAFLoggingServiceRolePolicy" + ], + "suffix_allowed": False, + }, + "wafv2.amazonaws.com": { + "service": "wafv2.amazonaws.com", + "role_name": "AWSServiceRoleForWAFV2Logging", + "attached_policies": [ + "arn:aws:iam::aws:policy/aws-service-role/WAFV2LoggingServiceRolePolicy" + ], + "suffix_allowed": False, + }, +} diff --git a/localstack-core/localstack/services/internal.py b/localstack-core/localstack/services/internal.py new file mode 100644 index 0000000000000..85c4de12ff351 --- /dev/null +++ b/localstack-core/localstack/services/internal.py @@ -0,0 +1,344 @@ +"""Module for localstack internal resources, such as health, graph, or _localstack/cloudformation/deploy.""" + +import logging +import os +import re +import time +from collections import defaultdict +from datetime import datetime + +from plux import PluginManager +from werkzeug.exceptions import NotFound + +from localstack import config, constants +from localstack.deprecations import deprecated_endpoint +from localstack.http import Request, Resource, Response, Router +from localstack.http.dispatcher import handler_dispatcher +from localstack.runtime.legacy import signal_supervisor_restart +from localstack.utils.analytics.metadata import ( + get_client_metadata, + get_localstack_edition, + is_license_activated, +) +from localstack.utils.collections import merge_recursive +from localstack.utils.functions import call_safe +from localstack.utils.numbers import is_number +from localstack.utils.objects import singleton_factory + +LOG = logging.getLogger(__name__) + +HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"] + + +class DeprecatedResource: + """ + Resource class which wraps a given resource in the deprecated_endpoint (i.e. logs deprecation warnings on every + invocation). + """ + + def __init__(self, resource, previous_path: str, deprecation_version: str, new_path: str): + for http_method in HTTP_METHODS: + fn_name = f"on_{http_method.lower()}" + fn = getattr(resource, fn_name, None) + if fn: + wrapped = deprecated_endpoint( + fn, + previous_path=previous_path, + deprecation_version=deprecation_version, + new_path=new_path, + ) + setattr(self, fn_name, wrapped) + + +class HealthResource: + """ + Resource for the LocalStack /health endpoint. It provides access to the service states and other components of + localstack. We support arbitrary data to be put into the health state to support things like the + run_startup_scripts function in docker-entrypoint.sh which sets the status of the init scripts feature. + """ + + def __init__(self, service_manager) -> None: + super().__init__() + self.service_manager = service_manager + self.state = {} + + def on_post(self, request: Request): + data = request.get_json(True, True) + if not data: + return Response("invalid request", 400) + + # backdoor API to support restarting the instance + if data.get("action") == "restart": + signal_supervisor_restart() + elif data.get("action") == "kill": + from localstack.runtime import get_current_runtime + + get_current_runtime().exit(0) + + return Response("ok", 200) + + def on_get(self, request: Request): + path = request.path + + reload = "reload" in path + + # get service state + if reload: + self.service_manager.check_all() + services = { + service: state.value for service, state in self.service_manager.get_states().items() + } + + # build state dict from internal state and merge into it the service states + result = dict(self.state) + result = merge_recursive({"services": services}, result) + result["edition"] = get_localstack_edition() + result["version"] = constants.VERSION + return result + + def on_head(self, request: Request): + return Response("ok", 200) + + def on_put(self, request: Request): + data = request.get_json(True, True) or {} + + # keys like "features:initScripts" should be interpreted as ['features']['initScripts'] + state = defaultdict(dict) + for k, v in data.items(): + if ":" in k: + path = k.split(":") + else: + path = [k] + + d = state + for p in path[:-1]: + d = state[p] + d[path[-1]] = v + + self.state = merge_recursive(state, self.state, overwrite=True) + return {"status": "OK"} + + +class InfoResource: + """ + Resource that is exposed to /_localstack/info and used to get generalized information about the current + localstack instance. + """ + + def on_get(self, request): + return self.get_info_data() + + @staticmethod + def get_info_data() -> dict: + client_metadata = get_client_metadata() + uptime = int(time.time() - config.load_start_time) + + return { + "version": client_metadata.version, + "edition": get_localstack_edition(), + "is_license_activated": is_license_activated(), + "session_id": client_metadata.session_id, + "machine_id": client_metadata.machine_id, + "system": client_metadata.system, + "is_docker": client_metadata.is_docker, + "server_time_utc": datetime.utcnow().isoformat(timespec="seconds"), + "uptime": uptime, + } + + +class UsageResource: + def on_get(self, request): + from localstack.utils import diagnose + + return call_safe(diagnose.get_usage) or {} + + +class DiagnoseResource: + def on_get(self, request): + from localstack.utils import diagnose + + return { + "version": { + "image-version": call_safe(diagnose.get_docker_image_details), + "localstack-version": call_safe(diagnose.get_localstack_version), + "host": { + "kernel": call_safe(diagnose.get_host_kernel_version), + }, + }, + "info": call_safe(InfoResource.get_info_data), + "services": call_safe(diagnose.get_service_stats), + "config": call_safe(diagnose.get_localstack_config), + "docker-inspect": call_safe(diagnose.inspect_main_container), + "docker-dependent-image-hashes": call_safe(diagnose.get_important_image_hashes), + "file-tree": call_safe(diagnose.get_file_tree), + "important-endpoints": call_safe(diagnose.resolve_endpoints), + "logs": call_safe(diagnose.get_localstack_logs), + "usage": call_safe(diagnose.get_usage), + } + + +class PluginsResource: + """ + Resource to list information about plux plugins. + """ + + plugin_managers: list[PluginManager] = [] + + def __init__(self): + # defer imports here to lazy-load code + from localstack.runtime import hooks, init + from localstack.services.plugins import SERVICE_PLUGINS + + # service providers + PluginsResource.plugin_managers.append(SERVICE_PLUGINS.plugin_manager) + # init script runners + PluginsResource.plugin_managers.append(init.init_script_manager().runner_manager) + # init hooks + PluginsResource.plugin_managers.append(hooks.configure_localstack_container.manager) + PluginsResource.plugin_managers.append(hooks.prepare_host.manager) + PluginsResource.plugin_managers.append(hooks.on_infra_ready.manager) + PluginsResource.plugin_managers.append(hooks.on_infra_start.manager) + PluginsResource.plugin_managers.append(hooks.on_infra_shutdown.manager) + + def on_get(self, request): + return { + manager.namespace: [ + self._get_plugin_details(manager, name) for name in manager.list_names() + ] + for manager in self.plugin_managers + } + + def _get_plugin_details(self, manager: PluginManager, plugin_name: str) -> dict: + container = manager.get_container(plugin_name) + + details = { + "name": plugin_name, + "is_initialized": container.is_init, + "is_loaded": container.is_loaded, + } + + # optionally add requires_license information if the plugin provides it + requires_license = None + if container.plugin: + try: + requires_license = container.plugin.requires_license + except AttributeError: + pass + if requires_license is not None: + details["requires_license"] = requires_license + + return details + + +class InitScriptsResource: + def on_get(self, request): + from localstack.runtime.init import init_script_manager + + manager = init_script_manager() + + return { + "completed": { + stage.name: completed for stage, completed in manager.stage_completed.items() + }, + "scripts": [ + { + "stage": script.stage.name, + "name": os.path.basename(script.path), + "state": script.state.name, + } + for scripts in manager.scripts.values() + for script in scripts + ], + } + + +class InitScriptsStageResource: + def on_get(self, request, stage: str): + from localstack.runtime.init import Stage, init_script_manager + + manager = init_script_manager() + + try: + stage = Stage[stage.upper()] + except KeyError as e: + raise NotFound(f"no such stage {stage}") from e + + return { + "completed": manager.stage_completed.get(stage), + "scripts": [ + { + "stage": script.stage.name, + "name": os.path.basename(script.path), + "state": script.state.name, + } + for script in manager.scripts.get(stage) + ], + } + + +class ConfigResource: + def on_get(self, request): + from localstack.utils import diagnose + + return call_safe(diagnose.get_localstack_config) + + def on_post(self, request: Request): + from localstack.utils.config_listener import update_config_variable + + data = request.get_json(force=True) + variable = data.get("variable", "") + if not re.match(r"^[_a-zA-Z0-9]+$", variable): + return Response("{}", mimetype="application/json", status=400) + new_value = data.get("value") + if is_number(new_value): + new_value = float(new_value) + update_config_variable(variable, new_value) + value = getattr(config, variable, None) + return { + "variable": variable, + "value": value, + } + + +class LocalstackResources(Router): + """ + Router for localstack-internal HTTP resources. + """ + + def __init__(self): + super().__init__(dispatcher=handler_dispatcher()) + self.add_default_routes() + # TODO: load routes as plugins + + def add_default_routes(self): + from localstack.services.plugins import SERVICE_PLUGINS + + health_resource = HealthResource(SERVICE_PLUGINS) + self.add(Resource("/_localstack/health", health_resource)) + self.add(Resource("/_localstack/info", InfoResource())) + self.add(Resource("/_localstack/plugins", PluginsResource())) + self.add(Resource("/_localstack/init", InitScriptsResource())) + self.add(Resource("/_localstack/init/", InitScriptsStageResource())) + + if config.ENABLE_CONFIG_UPDATES: + LOG.warning( + "Enabling config endpoint, " + "please be aware that this can expose sensitive information via your network." + ) + self.add(Resource("/_localstack/config", ConfigResource())) + + if config.DEBUG: + LOG.warning( + "Enabling diagnose endpoint, " + "please be aware that this can expose sensitive information via your network." + ) + self.add(Resource("/_localstack/diagnose", DiagnoseResource())) + self.add(Resource("/_localstack/usage", UsageResource())) + + +@singleton_factory +def get_internal_apis() -> LocalstackResources: + """ + Get the LocalstackResources singleton. + """ + return LocalstackResources() diff --git a/localstack-core/localstack/services/kinesis/__init__.py b/localstack-core/localstack/services/kinesis/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/kinesis/kinesis_mock_server.py b/localstack-core/localstack/services/kinesis/kinesis_mock_server.py new file mode 100644 index 0000000000000..b9ce394e1415d --- /dev/null +++ b/localstack-core/localstack/services/kinesis/kinesis_mock_server.py @@ -0,0 +1,233 @@ +import logging +import os +import threading +from abc import abstractmethod +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from localstack import config +from localstack.services.kinesis.packages import ( + KinesisMockEngine, + kinesismock_package, + kinesismock_scala_package, +) +from localstack.utils.common import TMP_THREADS, ShellCommandThread, get_free_tcp_port, mkdir +from localstack.utils.run import FuncThread +from localstack.utils.serving import Server + +LOG = logging.getLogger(__name__) + + +class KinesisMockServer(Server): + """ + Server abstraction for controlling Kinesis Mock in a separate thread + """ + + def __init__( + self, + port: int, + exe_path: Path, + latency: str, + account_id: str, + host: str = "localhost", + log_level: str = "INFO", + data_dir: Optional[str] = None, + ) -> None: + self._account_id = account_id + self._latency = latency + self._data_dir = data_dir + self._data_filename = f"{self._account_id}.json" + self._exe_path = exe_path + self._log_level = log_level + super().__init__(port, host) + + def do_start_thread(self) -> FuncThread: + cmd, env_vars = self._create_shell_command() + LOG.debug("starting kinesis process %s with env vars %s", cmd, env_vars) + t = ShellCommandThread( + cmd, + strip_color=True, + env_vars=env_vars, + log_listener=self._log_listener, + auto_restart=True, + name="kinesis-mock", + ) + TMP_THREADS.append(t) + t.start() + return t + + @property + def _environment_variables(self) -> Dict: + env_vars = { + "KINESIS_MOCK_PLAIN_PORT": self.port, + # Each kinesis-mock instance listens to two ports - secure and insecure. + # LocalStack uses only one - the insecure one. Block the secure port to avoid conflicts. + "KINESIS_MOCK_TLS_PORT": get_free_tcp_port(), + "SHARD_LIMIT": config.KINESIS_SHARD_LIMIT, + "ON_DEMAND_STREAM_COUNT_LIMIT": config.KINESIS_ON_DEMAND_STREAM_COUNT_LIMIT, + "AWS_ACCOUNT_ID": self._account_id, + } + + latency_params = [ + "CREATE_STREAM_DURATION", + "DELETE_STREAM_DURATION", + "REGISTER_STREAM_CONSUMER_DURATION", + "START_STREAM_ENCRYPTION_DURATION", + "STOP_STREAM_ENCRYPTION_DURATION", + "DEREGISTER_STREAM_CONSUMER_DURATION", + "MERGE_SHARDS_DURATION", + "SPLIT_SHARD_DURATION", + "UPDATE_SHARD_COUNT_DURATION", + "UPDATE_STREAM_MODE_DURATION", + ] + for param in latency_params: + env_vars[param] = self._latency + + if self._data_dir and config.KINESIS_PERSISTENCE: + env_vars["SHOULD_PERSIST_DATA"] = "true" + env_vars["PERSIST_PATH"] = self._data_dir + env_vars["PERSIST_FILE_NAME"] = self._data_filename + env_vars["PERSIST_INTERVAL"] = config.KINESIS_MOCK_PERSIST_INTERVAL + + env_vars["LOG_LEVEL"] = self._log_level + + return env_vars + + @abstractmethod + def _create_shell_command(self) -> Tuple[List, Dict]: + """ + Helper method for creating kinesis mock invocation command + :return: returns a tuple containing the command list and a dictionary with the environment variables + """ + pass + + def _log_listener(self, line, **_kwargs): + LOG.info(line.rstrip()) + + +class KinesisMockScalaServer(KinesisMockServer): + def _create_shell_command(self) -> Tuple[List, Dict]: + cmd = ["java", "-jar", *self._get_java_vm_options(), str(self._exe_path)] + return cmd, self._environment_variables + + @property + def _environment_variables(self) -> Dict: + default_env_vars = super()._environment_variables + kinesis_mock_installer = kinesismock_scala_package.get_installer() + return { + **default_env_vars, + **kinesis_mock_installer.get_java_env_vars(), + } + + def _get_java_vm_options(self) -> list[str]: + return [ + f"-Xms{config.KINESIS_MOCK_INITIAL_HEAP_SIZE}", + f"-Xmx{config.KINESIS_MOCK_MAXIMUM_HEAP_SIZE}", + "-XX:MaxGCPauseMillis=500", + "-XX:+ExitOnOutOfMemoryError", + ] + + +class KinesisMockNodeServer(KinesisMockServer): + @property + def _environment_variables(self) -> Dict: + node_env_vars = { + # Use the `server.json` packaged next to the main.js + "KINESIS_MOCK_CERT_PATH": str((self._exe_path.parent / "server.json").absolute()), + } + + default_env_vars = super()._environment_variables + return {**node_env_vars, **default_env_vars} + + def _create_shell_command(self) -> Tuple[List, Dict]: + cmd = ["node", self._exe_path] + return cmd, self._environment_variables + + +class KinesisServerManager: + default_startup_timeout = 60 + + def __init__(self): + self._lock = threading.RLock() + self._servers: dict[str, KinesisMockServer] = {} + + def get_server_for_account(self, account_id: str) -> KinesisMockServer: + if account_id in self._servers: + return self._servers[account_id] + + with self._lock: + if account_id in self._servers: + return self._servers[account_id] + + LOG.info("Creating kinesis backend for account %s", account_id) + self._servers[account_id] = self._create_kinesis_mock_server(account_id) + self._servers[account_id].start() + if not self._servers[account_id].wait_is_up(timeout=self.default_startup_timeout): + raise TimeoutError("gave up waiting for kinesis backend to start up") + return self._servers[account_id] + + def shutdown_all(self): + with self._lock: + while self._servers: + account_id, server = self._servers.popitem() + LOG.info("Shutting down kinesis backend for account %s", account_id) + server.shutdown() + + def _create_kinesis_mock_server(self, account_id: str) -> KinesisMockServer: + """ + Creates a new Kinesis Mock server instance. Installs Kinesis Mock on the host first if necessary. + Introspects on the host config to determine server configuration: + config.dirs.data -> if set, the server runs with persistence using the path to store data + config.LS_LOG -> configure kinesis mock log level (defaults to INFO) + config.KINESIS_LATENCY -> configure stream latency (in milliseconds) + """ + port = get_free_tcp_port() + + # kinesis-mock stores state in json files .json, so we can dump everything into `kinesis/` + persist_path = os.path.join(config.dirs.data, "kinesis") + mkdir(persist_path) + if config.KINESIS_MOCK_LOG_LEVEL: + log_level = config.KINESIS_MOCK_LOG_LEVEL.upper() + elif config.LS_LOG: + ls_log_level = config.LS_LOG.upper() + if ls_log_level == "WARNING": + log_level = "WARN" + elif ls_log_level == "TRACE-INTERNAL": + log_level = "TRACE" + elif ls_log_level not in ("ERROR", "WARN", "INFO", "DEBUG", "TRACE"): + # to protect from cases where the log level will be rejected from kinesis-mock + log_level = "INFO" + else: + log_level = ls_log_level + else: + log_level = "INFO" + latency = config.KINESIS_LATENCY + "ms" + + # Install the Scala Kinesis Mock build if specified in KINESIS_MOCK_PROVIDER_ENGINE + if KinesisMockEngine(config.KINESIS_MOCK_PROVIDER_ENGINE) == KinesisMockEngine.SCALA: + kinesismock_scala_package.install() + kinesis_mock_path = Path( + kinesismock_scala_package.get_installer().get_executable_path() + ) + + return KinesisMockScalaServer( + port=port, + exe_path=kinesis_mock_path, + log_level=log_level, + latency=latency, + data_dir=persist_path, + account_id=account_id, + ) + + # Otherwise, install the NodeJS version (default) + kinesismock_package.install() + kinesis_mock_path = Path(kinesismock_package.get_installer().get_executable_path()) + + return KinesisMockNodeServer( + port=port, + exe_path=kinesis_mock_path, + log_level=log_level, + latency=latency, + data_dir=persist_path, + account_id=account_id, + ) diff --git a/localstack-core/localstack/services/kinesis/models.py b/localstack-core/localstack/services/kinesis/models.py new file mode 100644 index 0000000000000..3247ac060fbb0 --- /dev/null +++ b/localstack-core/localstack/services/kinesis/models.py @@ -0,0 +1,18 @@ +from collections import defaultdict +from typing import Dict, List, Set + +from localstack.aws.api.kinesis import ConsumerDescription, MetricsName, StreamName +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + + +class KinesisStore(BaseStore): + # list of stream consumer details + stream_consumers: List[ConsumerDescription] = LocalAttribute(default=list) + + # maps stream name to list of enhanced monitoring metrics + enhanced_metrics: Dict[StreamName, Set[MetricsName]] = LocalAttribute( + default=lambda: defaultdict(set) + ) + + +kinesis_stores = AccountRegionBundle("kinesis", KinesisStore) diff --git a/localstack-core/localstack/services/kinesis/packages.py b/localstack-core/localstack/services/kinesis/packages.py new file mode 100644 index 0000000000000..1d64bb4194b63 --- /dev/null +++ b/localstack-core/localstack/services/kinesis/packages.py @@ -0,0 +1,82 @@ +import os +from enum import StrEnum +from functools import lru_cache +from typing import Any, List + +from localstack.packages import InstallTarget, Package +from localstack.packages.core import GitHubReleaseInstaller, NodePackageInstaller +from localstack.packages.java import JavaInstallerMixin, java_package + +_KINESIS_MOCK_VERSION = os.environ.get("KINESIS_MOCK_VERSION") or "0.4.12" + + +class KinesisMockEngine(StrEnum): + NODE = "node" + SCALA = "scala" + + @classmethod + def _missing_(cls, value: str | Any) -> str: + # default to 'node' if invalid enum + if not isinstance(value, str): + return cls(cls.NODE) + return cls.__members__.get(value.upper(), cls.NODE) + + +class KinesisMockNodePackageInstaller(NodePackageInstaller): + def __init__(self, version: str): + super().__init__(package_name="kinesis-local", version=version) + + +class KinesisMockScalaPackageInstaller(JavaInstallerMixin, GitHubReleaseInstaller): + def __init__(self, version: str = _KINESIS_MOCK_VERSION): + super().__init__( + name="kinesis-local", tag=f"v{version}", github_slug="etspaceman/kinesis-mock" + ) + + # Kinesis Mock requires JRE 21+ + self.java_version = "21" + + def _get_github_asset_name(self) -> str: + return "kinesis-mock.jar" + + def _prepare_installation(self, target: InstallTarget) -> None: + java_package.get_installer(self.java_version).install(target) + + def get_java_home(self) -> str | None: + """Override to use the specific Java version""" + return java_package.get_installer(self.java_version).get_java_home() + + +class KinesisMockScalaPackage(Package[KinesisMockScalaPackageInstaller]): + def __init__( + self, + default_version: str = _KINESIS_MOCK_VERSION, + ): + super().__init__(name="Kinesis Mock", default_version=default_version) + + @lru_cache + def _get_installer(self, version: str) -> KinesisMockScalaPackageInstaller: + return KinesisMockScalaPackageInstaller(version) + + def get_versions(self) -> List[str]: + return [_KINESIS_MOCK_VERSION] # Only supported on v0.4.12+ + + +class KinesisMockNodePackage(Package[KinesisMockNodePackageInstaller]): + def __init__( + self, + default_version: str = _KINESIS_MOCK_VERSION, + ): + super().__init__(name="Kinesis Mock", default_version=default_version) + + @lru_cache + def _get_installer(self, version: str) -> KinesisMockNodePackageInstaller: + return KinesisMockNodePackageInstaller(version) + + def get_versions(self) -> List[str]: + return [_KINESIS_MOCK_VERSION] + + +# leave as 'kinesismock_package' for backwards compatability +kinesismock_package = KinesisMockNodePackage() +kinesismock_scala_package = KinesisMockScalaPackage() diff --git a/localstack-core/localstack/services/kinesis/plugins.py b/localstack-core/localstack/services/kinesis/plugins.py new file mode 100644 index 0000000000000..75249c9a2d904 --- /dev/null +++ b/localstack-core/localstack/services/kinesis/plugins.py @@ -0,0 +1,16 @@ +import localstack.config as config +from localstack.packages import Package, package + + +@package(name="kinesis-mock") +def kinesismock_package() -> Package: + from localstack.services.kinesis.packages import ( + KinesisMockEngine, + kinesismock_package, + kinesismock_scala_package, + ) + + if KinesisMockEngine(config.KINESIS_MOCK_PROVIDER_ENGINE) == KinesisMockEngine.SCALA: + return kinesismock_scala_package + + return kinesismock_package diff --git a/localstack-core/localstack/services/kinesis/provider.py b/localstack-core/localstack/services/kinesis/provider.py new file mode 100644 index 0000000000000..7f080e35fc122 --- /dev/null +++ b/localstack-core/localstack/services/kinesis/provider.py @@ -0,0 +1,185 @@ +import logging +import os +import time +from random import random + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.api.kinesis import ( + ConsumerARN, + Data, + HashKey, + KinesisApi, + PartitionKey, + ProvisionedThroughputExceededException, + PutRecordOutput, + PutRecordsOutput, + PutRecordsRequestEntryList, + PutRecordsResultEntry, + SequenceNumber, + ShardId, + StartingPosition, + StreamARN, + StreamName, + SubscribeToShardEvent, + SubscribeToShardEventStream, + SubscribeToShardOutput, +) +from localstack.aws.connect import connect_to +from localstack.constants import LOCALHOST +from localstack.services.kinesis.kinesis_mock_server import KinesisServerManager +from localstack.services.kinesis.models import KinesisStore, kinesis_stores +from localstack.services.plugins import ServiceLifecycleHook +from localstack.state import AssetDirectory, StateVisitor +from localstack.utils.aws import arns +from localstack.utils.aws.arns import extract_account_id_from_arn, extract_region_from_arn +from localstack.utils.time import now_utc + +LOG = logging.getLogger(__name__) +MAX_SUBSCRIPTION_SECONDS = 300 +SERVER_STARTUP_TIMEOUT = 120 + + +def find_stream_for_consumer(consumer_arn): + account_id = extract_account_id_from_arn(consumer_arn) + region_name = extract_region_from_arn(consumer_arn) + kinesis = connect_to(aws_access_key_id=account_id, region_name=region_name).kinesis + for stream_name in kinesis.list_streams()["StreamNames"]: + stream_arn = arns.kinesis_stream_arn(stream_name, account_id, region_name) + for cons in kinesis.list_stream_consumers(StreamARN=stream_arn)["Consumers"]: + if cons["ConsumerARN"] == consumer_arn: + return stream_name + raise Exception("Unable to find stream for stream consumer %s" % consumer_arn) + + +class KinesisProvider(KinesisApi, ServiceLifecycleHook): + server_manager: KinesisServerManager + + def __init__(self): + self.server_manager = KinesisServerManager() + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(kinesis_stores) + visitor.visit(AssetDirectory(self.service, os.path.join(config.dirs.data, "kinesis"))) + + def on_before_state_load(self): + # no need to restart servers, since that happens lazily in `server_manager.get_server_for_account`. + self.server_manager.shutdown_all() + + def on_before_state_reset(self): + self.server_manager.shutdown_all() + + def on_before_stop(self): + self.server_manager.shutdown_all() + + def get_forward_url(self, account_id: str, region_name: str) -> str: + """Return the URL of the backend Kinesis server to forward requests to""" + server = self.server_manager.get_server_for_account(account_id) + return f"http://{LOCALHOST}:{server.port}" + + @staticmethod + def get_store(account_id: str, region_name: str) -> KinesisStore: + return kinesis_stores[account_id][region_name] + + def subscribe_to_shard( + self, + context: RequestContext, + consumer_arn: ConsumerARN, + shard_id: ShardId, + starting_position: StartingPosition, + **kwargs, + ) -> SubscribeToShardOutput: + kinesis = connect_to( + aws_access_key_id=context.account_id, region_name=context.region + ).kinesis + stream_name = find_stream_for_consumer(consumer_arn) + iter_type = starting_position["Type"] + kwargs = {} + starting_sequence_number = starting_position.get("SequenceNumber") or "0" + if iter_type in ["AT_SEQUENCE_NUMBER", "AFTER_SEQUENCE_NUMBER"]: + kwargs["StartingSequenceNumber"] = starting_sequence_number + elif iter_type in ["AT_TIMESTAMP"]: + # or value is just an example timestamp from aws docs + timestamp = starting_position.get("Timestamp") or 1459799926.480 + kwargs["Timestamp"] = timestamp + initial_shard_iterator = kinesis.get_shard_iterator( + StreamName=stream_name, ShardId=shard_id, ShardIteratorType=iter_type, **kwargs + )["ShardIterator"] + + def event_generator(): + shard_iterator = initial_shard_iterator + last_sequence_number = starting_sequence_number + + maximum_duration_subscription_timestamp = now_utc() + MAX_SUBSCRIPTION_SECONDS + + while now_utc() < maximum_duration_subscription_timestamp: + try: + result = kinesis.get_records(ShardIterator=shard_iterator) + except Exception as e: + if "ResourceNotFoundException" in str(e): + LOG.debug( + 'Kinesis stream "%s" has been deleted, closing shard subscriber', + stream_name, + ) + return + raise + shard_iterator = result.get("NextShardIterator") + records = result.get("Records", []) + if not records: + # On AWS there is *at least* 1 event every 5 seconds + # but this is not possible in this structure. + # In order to avoid a 5-second blocking call, we make the compromise of 3 seconds. + time.sleep(3) + + yield SubscribeToShardEventStream( + SubscribeToShardEvent=SubscribeToShardEvent( + Records=records, + ContinuationSequenceNumber=str(last_sequence_number), + MillisBehindLatest=0, + ChildShards=[], + ) + ) + + return SubscribeToShardOutput(EventStream=event_generator()) + + def put_record( + self, + context: RequestContext, + data: Data, + partition_key: PartitionKey, + stream_name: StreamName = None, + explicit_hash_key: HashKey = None, + sequence_number_for_ordering: SequenceNumber = None, + stream_arn: StreamARN = None, + **kwargs, + ) -> PutRecordOutput: + # TODO: Ensure use of `stream_arn` works. Currently kinesis-mock only works with ctx request account ID and region + if random() < config.KINESIS_ERROR_PROBABILITY: + raise ProvisionedThroughputExceededException( + "Rate exceeded for shard X in stream Y under account Z." + ) + # If "we were lucky" and the error probability didn't hit, we raise a NotImplementedError in order to + # trigger the fallback to kinesis-mock + raise NotImplementedError + + def put_records( + self, + context: RequestContext, + records: PutRecordsRequestEntryList, + stream_name: StreamName = None, + stream_arn: StreamARN = None, + **kwargs, + ) -> PutRecordsOutput: + # TODO: Ensure use of `stream_arn` works. Currently kinesis-mock only works with ctx request account ID and region + if random() < config.KINESIS_ERROR_PROBABILITY: + records_count = len(records) if records is not None else 0 + records = [ + PutRecordsResultEntry( + ErrorCode="ProvisionedThroughputExceededException", + ErrorMessage="Rate exceeded for shard X in stream Y under account Z.", + ) + ] * records_count + return PutRecordsOutput(FailedRecordCount=1, Records=records) + # If "we were lucky" and the error probability didn't hit, we raise a NotImplementedError in order to + # trigger the fallback to kinesis-mock + raise NotImplementedError diff --git a/localstack-core/localstack/services/kinesis/resource_providers/__init__.py b/localstack-core/localstack/services/kinesis/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream.py b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream.py new file mode 100644 index 0000000000000..28d231d666484 --- /dev/null +++ b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream.py @@ -0,0 +1,181 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class KinesisStreamProperties(TypedDict): + Arn: Optional[str] + Name: Optional[str] + RetentionPeriodHours: Optional[int] + ShardCount: Optional[int] + StreamEncryption: Optional[StreamEncryption] + StreamModeDetails: Optional[StreamModeDetails] + Tags: Optional[list[Tag]] + + +class StreamModeDetails(TypedDict): + StreamMode: Optional[str] + + +class StreamEncryption(TypedDict): + EncryptionType: Optional[str] + KeyId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class KinesisStreamProvider(ResourceProvider[KinesisStreamProperties]): + TYPE = "AWS::Kinesis::Stream" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[KinesisStreamProperties], + ) -> ProgressEvent[KinesisStreamProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Name + + + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - kinesis:EnableEnhancedMonitoring + - kinesis:DescribeStreamSummary + - kinesis:CreateStream + - kinesis:IncreaseStreamRetentionPeriod + - kinesis:StartStreamEncryption + - kinesis:AddTagsToStream + - kinesis:ListTagsForStream + + """ + model = request.desired_state + kinesis = request.aws_client_factory.kinesis + if not request.custom_context.get(REPEATED_INVOCATION): + if not model.get("Name"): + model["Name"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + if not model.get("ShardCount"): + model["ShardCount"] = 1 + + if not model.get("StreamModeDetails"): + model["StreamModeDetails"] = StreamModeDetails(StreamMode="ON_DEMAND") + + kinesis.create_stream( + StreamName=model["Name"], + ShardCount=model["ShardCount"], + StreamModeDetails=model["StreamModeDetails"], + ) + + stream_data = kinesis.describe_stream(StreamName=model["Name"])["StreamDescription"] + model["Arn"] = stream_data["StreamARN"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + stream_data = kinesis.describe_stream(StreamARN=model["Arn"])["StreamDescription"] + if stream_data["StreamStatus"] != "ACTIVE": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[KinesisStreamProperties], + ) -> ProgressEvent[KinesisStreamProperties]: + """ + Fetch resource information + + IAM permissions required: + - kinesis:DescribeStreamSummary + - kinesis:ListTagsForStream + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[KinesisStreamProperties], + ) -> ProgressEvent[KinesisStreamProperties]: + """ + Delete a resource + + IAM permissions required: + - kinesis:DescribeStreamSummary + - kinesis:DeleteStream + - kinesis:RemoveTagsFromStream + """ + model = request.previous_state + client = request.aws_client_factory.kinesis + + if not request.custom_context.get(REPEATED_INVOCATION): + client.delete_stream(StreamARN=model["Arn"], EnforceConsumerDeletion=True) + request.custom_context[REPEATED_INVOCATION] = True + + try: + client.describe_stream(StreamARN=model["Arn"]) + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + ) + except client.exceptions.ResourceNotFoundException: + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[KinesisStreamProperties], + ) -> ProgressEvent[KinesisStreamProperties]: + """ + Update a resource + + IAM permissions required: + - kinesis:EnableEnhancedMonitoring + - kinesis:DisableEnhancedMonitoring + - kinesis:DescribeStreamSummary + - kinesis:UpdateShardCount + - kinesis:UpdateStreamMode + - kinesis:IncreaseStreamRetentionPeriod + - kinesis:DecreaseStreamRetentionPeriod + - kinesis:StartStreamEncryption + - kinesis:StopStreamEncryption + - kinesis:AddTagsToStream + - kinesis:RemoveTagsFromStream + - kinesis:ListTagsForStream + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream.schema.json b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream.schema.json new file mode 100644 index 0000000000000..69b6d10cfd89d --- /dev/null +++ b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream.schema.json @@ -0,0 +1,173 @@ +{ + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-kinesis.git", + "handlers": { + "read": { + "permissions": [ + "kinesis:DescribeStreamSummary", + "kinesis:ListTagsForStream" + ] + }, + "create": { + "permissions": [ + "kinesis:EnableEnhancedMonitoring", + "kinesis:DescribeStreamSummary", + "kinesis:CreateStream", + "kinesis:IncreaseStreamRetentionPeriod", + "kinesis:StartStreamEncryption", + "kinesis:AddTagsToStream", + "kinesis:ListTagsForStream" + ] + }, + "update": { + "permissions": [ + "kinesis:EnableEnhancedMonitoring", + "kinesis:DisableEnhancedMonitoring", + "kinesis:DescribeStreamSummary", + "kinesis:UpdateShardCount", + "kinesis:UpdateStreamMode", + "kinesis:IncreaseStreamRetentionPeriod", + "kinesis:DecreaseStreamRetentionPeriod", + "kinesis:StartStreamEncryption", + "kinesis:StopStreamEncryption", + "kinesis:AddTagsToStream", + "kinesis:RemoveTagsFromStream", + "kinesis:ListTagsForStream" + ], + "timeoutInMinutes": 240 + }, + "list": { + "permissions": [ + "kinesis:ListStreams" + ] + }, + "delete": { + "permissions": [ + "kinesis:DescribeStreamSummary", + "kinesis:DeleteStream", + "kinesis:RemoveTagsFromStream" + ] + } + }, + "typeName": "AWS::Kinesis::Stream", + "readOnlyProperties": [ + "/properties/Arn" + ], + "description": "Resource Type definition for AWS::Kinesis::Stream", + "createOnlyProperties": [ + "/properties/Name" + ], + "additionalProperties": false, + "primaryIdentifier": [ + "/properties/Name" + ], + "definitions": { + "StreamModeDetails": { + "description": "When specified, enables or updates the mode of stream. Default is PROVISIONED.", + "additionalProperties": false, + "type": "object", + "properties": { + "StreamMode": { + "description": "The mode of the stream", + "type": "string", + "enum": [ + "ON_DEMAND", + "PROVISIONED" + ] + } + }, + "required": [ + "StreamMode" + ] + }, + "StreamEncryption": { + "description": "When specified, enables or updates server-side encryption using an AWS KMS key for a specified stream. Removing this property from your stack template and updating your stack disables encryption.", + "additionalProperties": false, + "type": "object", + "properties": { + "EncryptionType": { + "description": "The encryption type to use. The only valid value is KMS. ", + "type": "string", + "enum": [ + "KMS" + ] + }, + "KeyId": { + "minLength": 1, + "description": "The GUID for the customer-managed AWS KMS key to use for encryption. This value can be a globally unique identifier, a fully specified Amazon Resource Name (ARN) to either an alias or a key, or an alias name prefixed by \"alias/\".You can also use a master key owned by Kinesis Data Streams by specifying the alias aws/kinesis.", + "type": "string", + "maxLength": 2048 + } + }, + "required": [ + "EncryptionType", + "KeyId" + ] + }, + "Tag": { + "description": "An arbitrary set of tags (key-value pairs) to associate with the Kinesis stream.", + "additionalProperties": false, + "type": "object", + "properties": { + "Value": { + "minLength": 0, + "description": "The value for the tag. You can specify a value that is 0 to 255 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "type": "string", + "maxLength": 255 + }, + "Key": { + "minLength": 1, + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "type": "string", + "maxLength": 128 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, + "properties": { + "StreamModeDetails": { + "default": { + "StreamMode": "PROVISIONED" + }, + "description": "The mode in which the stream is running.", + "$ref": "#/definitions/StreamModeDetails" + }, + "StreamEncryption": { + "description": "When specified, enables or updates server-side encryption using an AWS KMS key for a specified stream.", + "$ref": "#/definitions/StreamEncryption" + }, + "Arn": { + "description": "The Amazon resource name (ARN) of the Kinesis stream", + "type": "string" + }, + "RetentionPeriodHours": { + "description": "The number of hours for the data records that are stored in shards to remain accessible.", + "type": "integer", + "minimum": 24 + }, + "Tags": { + "uniqueItems": false, + "description": "An arbitrary set of tags (key\u2013value pairs) to associate with the Kinesis stream.", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Name": { + "minLength": 1, + "pattern": "^[a-zA-Z0-9_.-]+$", + "description": "The name of the Kinesis stream.", + "type": "string", + "maxLength": 128 + }, + "ShardCount": { + "description": "The number of shards that the stream uses. Required when StreamMode = PROVISIONED is passed.", + "type": "integer", + "minimum": 1 + } + } +} diff --git a/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream_plugin.py b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream_plugin.py new file mode 100644 index 0000000000000..d7e834e7bb0bf --- /dev/null +++ b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_stream_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class KinesisStreamProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Kinesis::Stream" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.kinesis.resource_providers.aws_kinesis_stream import ( + KinesisStreamProvider, + ) + + self.factory = KinesisStreamProvider diff --git a/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer.py b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer.py new file mode 100644 index 0000000000000..3f0faee08ffda --- /dev/null +++ b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer.py @@ -0,0 +1,131 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class KinesisStreamConsumerProperties(TypedDict): + ConsumerName: Optional[str] + StreamARN: Optional[str] + ConsumerARN: Optional[str] + ConsumerCreationTimestamp: Optional[str] + ConsumerStatus: Optional[str] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class KinesisStreamConsumerProvider(ResourceProvider[KinesisStreamConsumerProperties]): + TYPE = "AWS::Kinesis::StreamConsumer" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[KinesisStreamConsumerProperties], + ) -> ProgressEvent[KinesisStreamConsumerProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - ConsumerName + - StreamARN + + Create-only properties: + - /properties/ConsumerName + - /properties/StreamARN + + Read-only properties: + - /properties/ConsumerStatus + - /properties/ConsumerARN + - /properties/ConsumerCreationTimestamp + - /properties/Id + + + + """ + model = request.desired_state + kinesis = request.aws_client_factory.kinesis + + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + # TODO: idempotency + + response = kinesis.register_stream_consumer( + StreamARN=model["StreamARN"], ConsumerName=model["ConsumerName"] + ) + model["ConsumerARN"] = response["Consumer"]["ConsumerARN"] + model["ConsumerStatus"] = response["Consumer"]["ConsumerStatus"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + response = kinesis.describe_stream_consumer(ConsumerARN=model["ConsumerARN"]) + model["ConsumerStatus"] = response["ConsumerDescription"]["ConsumerStatus"] + if model["ConsumerStatus"] == "CREATING": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[KinesisStreamConsumerProperties], + ) -> ProgressEvent[KinesisStreamConsumerProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[KinesisStreamConsumerProperties], + ) -> ProgressEvent[KinesisStreamConsumerProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + kinesis = request.aws_client_factory.kinesis + kinesis.deregister_stream_consumer(ConsumerARN=model["ConsumerARN"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[KinesisStreamConsumerProperties], + ) -> ProgressEvent[KinesisStreamConsumerProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer.schema.json b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer.schema.json new file mode 100644 index 0000000000000..635fb10017540 --- /dev/null +++ b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer.schema.json @@ -0,0 +1,42 @@ +{ + "typeName": "AWS::Kinesis::StreamConsumer", + "description": "Resource Type definition for AWS::Kinesis::StreamConsumer", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "ConsumerCreationTimestamp": { + "type": "string" + }, + "ConsumerName": { + "type": "string" + }, + "ConsumerARN": { + "type": "string" + }, + "ConsumerStatus": { + "type": "string" + }, + "StreamARN": { + "type": "string" + } + }, + "required": [ + "ConsumerName", + "StreamARN" + ], + "readOnlyProperties": [ + "/properties/ConsumerStatus", + "/properties/ConsumerARN", + "/properties/ConsumerCreationTimestamp", + "/properties/Id" + ], + "createOnlyProperties": [ + "/properties/ConsumerName", + "/properties/StreamARN" + ], + "primaryIdentifier": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer_plugin.py b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer_plugin.py new file mode 100644 index 0000000000000..b1f2cab38423d --- /dev/null +++ b/localstack-core/localstack/services/kinesis/resource_providers/aws_kinesis_streamconsumer_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class KinesisStreamConsumerProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Kinesis::StreamConsumer" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.kinesis.resource_providers.aws_kinesis_streamconsumer import ( + KinesisStreamConsumerProvider, + ) + + self.factory = KinesisStreamConsumerProvider diff --git a/localstack-core/localstack/services/kinesisfirehose/__init__.py b/localstack-core/localstack/services/kinesisfirehose/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/kinesisfirehose/resource_providers/__init__.py b/localstack-core/localstack/services/kinesisfirehose/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream.py b/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream.py new file mode 100644 index 0000000000000..6764a783667f0 --- /dev/null +++ b/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream.py @@ -0,0 +1,496 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class KinesisFirehoseDeliveryStreamProperties(TypedDict): + AmazonOpenSearchServerlessDestinationConfiguration: Optional[ + AmazonOpenSearchServerlessDestinationConfiguration + ] + AmazonopensearchserviceDestinationConfiguration: Optional[ + AmazonopensearchserviceDestinationConfiguration + ] + Arn: Optional[str] + DeliveryStreamEncryptionConfigurationInput: Optional[DeliveryStreamEncryptionConfigurationInput] + DeliveryStreamName: Optional[str] + DeliveryStreamType: Optional[str] + ElasticsearchDestinationConfiguration: Optional[ElasticsearchDestinationConfiguration] + ExtendedS3DestinationConfiguration: Optional[ExtendedS3DestinationConfiguration] + HttpEndpointDestinationConfiguration: Optional[HttpEndpointDestinationConfiguration] + KinesisStreamSourceConfiguration: Optional[KinesisStreamSourceConfiguration] + RedshiftDestinationConfiguration: Optional[RedshiftDestinationConfiguration] + S3DestinationConfiguration: Optional[S3DestinationConfiguration] + SplunkDestinationConfiguration: Optional[SplunkDestinationConfiguration] + Tags: Optional[list[Tag]] + + +class DeliveryStreamEncryptionConfigurationInput(TypedDict): + KeyType: Optional[str] + KeyARN: Optional[str] + + +class ElasticsearchBufferingHints(TypedDict): + IntervalInSeconds: Optional[int] + SizeInMBs: Optional[int] + + +class CloudWatchLoggingOptions(TypedDict): + Enabled: Optional[bool] + LogGroupName: Optional[str] + LogStreamName: Optional[str] + + +class ProcessorParameter(TypedDict): + ParameterName: Optional[str] + ParameterValue: Optional[str] + + +class Processor(TypedDict): + Type: Optional[str] + Parameters: Optional[list[ProcessorParameter]] + + +class ProcessingConfiguration(TypedDict): + Enabled: Optional[bool] + Processors: Optional[list[Processor]] + + +class ElasticsearchRetryOptions(TypedDict): + DurationInSeconds: Optional[int] + + +class BufferingHints(TypedDict): + IntervalInSeconds: Optional[int] + SizeInMBs: Optional[int] + + +class KMSEncryptionConfig(TypedDict): + AWSKMSKeyARN: Optional[str] + + +class EncryptionConfiguration(TypedDict): + KMSEncryptionConfig: Optional[KMSEncryptionConfig] + NoEncryptionConfig: Optional[str] + + +class S3DestinationConfiguration(TypedDict): + BucketARN: Optional[str] + RoleARN: Optional[str] + BufferingHints: Optional[BufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + CompressionFormat: Optional[str] + EncryptionConfiguration: Optional[EncryptionConfiguration] + ErrorOutputPrefix: Optional[str] + Prefix: Optional[str] + + +class VpcConfiguration(TypedDict): + RoleARN: Optional[str] + SecurityGroupIds: Optional[list[str]] + SubnetIds: Optional[list[str]] + + +class DocumentIdOptions(TypedDict): + DefaultDocumentIdFormat: Optional[str] + + +class ElasticsearchDestinationConfiguration(TypedDict): + IndexName: Optional[str] + RoleARN: Optional[str] + S3Configuration: Optional[S3DestinationConfiguration] + BufferingHints: Optional[ElasticsearchBufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ClusterEndpoint: Optional[str] + DocumentIdOptions: Optional[DocumentIdOptions] + DomainARN: Optional[str] + IndexRotationPeriod: Optional[str] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RetryOptions: Optional[ElasticsearchRetryOptions] + S3BackupMode: Optional[str] + TypeName: Optional[str] + VpcConfiguration: Optional[VpcConfiguration] + + +class AmazonopensearchserviceBufferingHints(TypedDict): + IntervalInSeconds: Optional[int] + SizeInMBs: Optional[int] + + +class AmazonopensearchserviceRetryOptions(TypedDict): + DurationInSeconds: Optional[int] + + +class AmazonopensearchserviceDestinationConfiguration(TypedDict): + IndexName: Optional[str] + RoleARN: Optional[str] + S3Configuration: Optional[S3DestinationConfiguration] + BufferingHints: Optional[AmazonopensearchserviceBufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ClusterEndpoint: Optional[str] + DocumentIdOptions: Optional[DocumentIdOptions] + DomainARN: Optional[str] + IndexRotationPeriod: Optional[str] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RetryOptions: Optional[AmazonopensearchserviceRetryOptions] + S3BackupMode: Optional[str] + TypeName: Optional[str] + VpcConfiguration: Optional[VpcConfiguration] + + +class AmazonOpenSearchServerlessBufferingHints(TypedDict): + IntervalInSeconds: Optional[int] + SizeInMBs: Optional[int] + + +class AmazonOpenSearchServerlessRetryOptions(TypedDict): + DurationInSeconds: Optional[int] + + +class AmazonOpenSearchServerlessDestinationConfiguration(TypedDict): + IndexName: Optional[str] + RoleARN: Optional[str] + S3Configuration: Optional[S3DestinationConfiguration] + BufferingHints: Optional[AmazonOpenSearchServerlessBufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + CollectionEndpoint: Optional[str] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RetryOptions: Optional[AmazonOpenSearchServerlessRetryOptions] + S3BackupMode: Optional[str] + VpcConfiguration: Optional[VpcConfiguration] + + +class HiveJsonSerDe(TypedDict): + TimestampFormats: Optional[list[str]] + + +class OpenXJsonSerDe(TypedDict): + CaseInsensitive: Optional[bool] + ColumnToJsonKeyMappings: Optional[dict] + ConvertDotsInJsonKeysToUnderscores: Optional[bool] + + +class Deserializer(TypedDict): + HiveJsonSerDe: Optional[HiveJsonSerDe] + OpenXJsonSerDe: Optional[OpenXJsonSerDe] + + +class InputFormatConfiguration(TypedDict): + Deserializer: Optional[Deserializer] + + +class OrcSerDe(TypedDict): + BlockSizeBytes: Optional[int] + BloomFilterColumns: Optional[list[str]] + BloomFilterFalsePositiveProbability: Optional[float] + Compression: Optional[str] + DictionaryKeyThreshold: Optional[float] + EnablePadding: Optional[bool] + FormatVersion: Optional[str] + PaddingTolerance: Optional[float] + RowIndexStride: Optional[int] + StripeSizeBytes: Optional[int] + + +class ParquetSerDe(TypedDict): + BlockSizeBytes: Optional[int] + Compression: Optional[str] + EnableDictionaryCompression: Optional[bool] + MaxPaddingBytes: Optional[int] + PageSizeBytes: Optional[int] + WriterVersion: Optional[str] + + +class Serializer(TypedDict): + OrcSerDe: Optional[OrcSerDe] + ParquetSerDe: Optional[ParquetSerDe] + + +class OutputFormatConfiguration(TypedDict): + Serializer: Optional[Serializer] + + +class SchemaConfiguration(TypedDict): + CatalogId: Optional[str] + DatabaseName: Optional[str] + Region: Optional[str] + RoleARN: Optional[str] + TableName: Optional[str] + VersionId: Optional[str] + + +class DataFormatConversionConfiguration(TypedDict): + Enabled: Optional[bool] + InputFormatConfiguration: Optional[InputFormatConfiguration] + OutputFormatConfiguration: Optional[OutputFormatConfiguration] + SchemaConfiguration: Optional[SchemaConfiguration] + + +class RetryOptions(TypedDict): + DurationInSeconds: Optional[int] + + +class DynamicPartitioningConfiguration(TypedDict): + Enabled: Optional[bool] + RetryOptions: Optional[RetryOptions] + + +class ExtendedS3DestinationConfiguration(TypedDict): + BucketARN: Optional[str] + RoleARN: Optional[str] + BufferingHints: Optional[BufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + CompressionFormat: Optional[str] + DataFormatConversionConfiguration: Optional[DataFormatConversionConfiguration] + DynamicPartitioningConfiguration: Optional[DynamicPartitioningConfiguration] + EncryptionConfiguration: Optional[EncryptionConfiguration] + ErrorOutputPrefix: Optional[str] + Prefix: Optional[str] + ProcessingConfiguration: Optional[ProcessingConfiguration] + S3BackupConfiguration: Optional[S3DestinationConfiguration] + S3BackupMode: Optional[str] + + +class KinesisStreamSourceConfiguration(TypedDict): + KinesisStreamARN: Optional[str] + RoleARN: Optional[str] + + +class CopyCommand(TypedDict): + DataTableName: Optional[str] + CopyOptions: Optional[str] + DataTableColumns: Optional[str] + + +class RedshiftRetryOptions(TypedDict): + DurationInSeconds: Optional[int] + + +class RedshiftDestinationConfiguration(TypedDict): + ClusterJDBCURL: Optional[str] + CopyCommand: Optional[CopyCommand] + Password: Optional[str] + RoleARN: Optional[str] + S3Configuration: Optional[S3DestinationConfiguration] + Username: Optional[str] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RetryOptions: Optional[RedshiftRetryOptions] + S3BackupConfiguration: Optional[S3DestinationConfiguration] + S3BackupMode: Optional[str] + + +class SplunkRetryOptions(TypedDict): + DurationInSeconds: Optional[int] + + +class SplunkDestinationConfiguration(TypedDict): + HECEndpoint: Optional[str] + HECEndpointType: Optional[str] + HECToken: Optional[str] + S3Configuration: Optional[S3DestinationConfiguration] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + HECAcknowledgmentTimeoutInSeconds: Optional[int] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RetryOptions: Optional[SplunkRetryOptions] + S3BackupMode: Optional[str] + + +class HttpEndpointConfiguration(TypedDict): + Url: Optional[str] + AccessKey: Optional[str] + Name: Optional[str] + + +class HttpEndpointCommonAttribute(TypedDict): + AttributeName: Optional[str] + AttributeValue: Optional[str] + + +class HttpEndpointRequestConfiguration(TypedDict): + CommonAttributes: Optional[list[HttpEndpointCommonAttribute]] + ContentEncoding: Optional[str] + + +class HttpEndpointDestinationConfiguration(TypedDict): + EndpointConfiguration: Optional[HttpEndpointConfiguration] + S3Configuration: Optional[S3DestinationConfiguration] + BufferingHints: Optional[BufferingHints] + CloudWatchLoggingOptions: Optional[CloudWatchLoggingOptions] + ProcessingConfiguration: Optional[ProcessingConfiguration] + RequestConfiguration: Optional[HttpEndpointRequestConfiguration] + RetryOptions: Optional[RetryOptions] + RoleARN: Optional[str] + S3BackupMode: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class KinesisFirehoseDeliveryStreamProvider( + ResourceProvider[KinesisFirehoseDeliveryStreamProperties] +): + TYPE = "AWS::KinesisFirehose::DeliveryStream" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[KinesisFirehoseDeliveryStreamProperties], + ) -> ProgressEvent[KinesisFirehoseDeliveryStreamProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/DeliveryStreamName + + + + Create-only properties: + - /properties/DeliveryStreamName + - /properties/DeliveryStreamType + - /properties/ElasticsearchDestinationConfiguration/VpcConfiguration + - /properties/AmazonopensearchserviceDestinationConfiguration/VpcConfiguration + - /properties/AmazonOpenSearchServerlessDestinationConfiguration/VpcConfiguration + - /properties/KinesisStreamSourceConfiguration + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - firehose:CreateDeliveryStream + - firehose:DescribeDeliveryStream + - iam:GetRole + - iam:PassRole + - kms:CreateGrant + - kms:DescribeKey + + """ + model = request.desired_state + firehose = request.aws_client_factory.firehose + parameters = [ + "DeliveryStreamName", + "DeliveryStreamType", + "S3DestinationConfiguration", + "ElasticsearchDestinationConfiguration", + "AmazonopensearchserviceDestinationConfiguration", + "DeliveryStreamEncryptionConfigurationInput", + "ExtendedS3DestinationConfiguration", + "HttpEndpointDestinationConfiguration", + "KinesisStreamSourceConfiguration", + "RedshiftDestinationConfiguration", + "SplunkDestinationConfiguration", + "Tags", + ] + attrs = util.select_attributes(model, params=parameters) + if not attrs.get("DeliveryStreamName"): + attrs["DeliveryStreamName"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + if not request.custom_context.get(REPEATED_INVOCATION): + response = firehose.create_delivery_stream(**attrs) + # TODO: defaults + # TODO: idempotency + model["Arn"] = response["DeliveryStreamARN"] + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + # TODO add handler for CREATE FAILED state + stream = firehose.describe_delivery_stream(DeliveryStreamName=model["DeliveryStreamName"]) + if stream["DeliveryStreamDescription"]["DeliveryStreamStatus"] != "ACTIVE": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[KinesisFirehoseDeliveryStreamProperties], + ) -> ProgressEvent[KinesisFirehoseDeliveryStreamProperties]: + """ + Fetch resource information + + IAM permissions required: + - firehose:DescribeDeliveryStream + - firehose:ListTagsForDeliveryStream + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[KinesisFirehoseDeliveryStreamProperties], + ) -> ProgressEvent[KinesisFirehoseDeliveryStreamProperties]: + """ + Delete a resource + + IAM permissions required: + - firehose:DeleteDeliveryStream + - firehose:DescribeDeliveryStream + - kms:RevokeGrant + - kms:DescribeKey + """ + model = request.desired_state + firehose = request.aws_client_factory.firehose + try: + stream = firehose.describe_delivery_stream( + DeliveryStreamName=model["DeliveryStreamName"] + ) + except request.aws_client_factory.firehose.exceptions.ResourceNotFoundException: + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + if stream["DeliveryStreamDescription"]["DeliveryStreamStatus"] != "DELETING": + firehose.delete_delivery_stream(DeliveryStreamName=model["DeliveryStreamName"]) + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[KinesisFirehoseDeliveryStreamProperties], + ) -> ProgressEvent[KinesisFirehoseDeliveryStreamProperties]: + """ + Update a resource + + IAM permissions required: + - firehose:UpdateDestination + - firehose:DescribeDeliveryStream + - firehose:StartDeliveryStreamEncryption + - firehose:StopDeliveryStreamEncryption + - firehose:ListTagsForDeliveryStream + - firehose:TagDeliveryStream + - firehose:UntagDeliveryStream + - kms:CreateGrant + - kms:RevokeGrant + - kms:DescribeKey + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream.schema.json b/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream.schema.json new file mode 100644 index 0000000000000..939b5c7bd35d2 --- /dev/null +++ b/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream.schema.json @@ -0,0 +1,1205 @@ +{ + "typeName": "AWS::KinesisFirehose::DeliveryStream", + "description": "Resource Type definition for AWS::KinesisFirehose::DeliveryStream", + "additionalProperties": false, + "properties": { + "Arn": { + "type": "string" + }, + "DeliveryStreamEncryptionConfigurationInput": { + "$ref": "#/definitions/DeliveryStreamEncryptionConfigurationInput" + }, + "DeliveryStreamName": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "[a-zA-Z0-9._-]+" + }, + "DeliveryStreamType": { + "type": "string", + "enum": [ + "DirectPut", + "KinesisStreamAsSource" + ] + }, + "ElasticsearchDestinationConfiguration": { + "$ref": "#/definitions/ElasticsearchDestinationConfiguration" + }, + "AmazonopensearchserviceDestinationConfiguration": { + "$ref": "#/definitions/AmazonopensearchserviceDestinationConfiguration" + }, + "AmazonOpenSearchServerlessDestinationConfiguration": { + "$ref": "#/definitions/AmazonOpenSearchServerlessDestinationConfiguration" + }, + "ExtendedS3DestinationConfiguration": { + "$ref": "#/definitions/ExtendedS3DestinationConfiguration" + }, + "KinesisStreamSourceConfiguration": { + "$ref": "#/definitions/KinesisStreamSourceConfiguration" + }, + "RedshiftDestinationConfiguration": { + "$ref": "#/definitions/RedshiftDestinationConfiguration" + }, + "S3DestinationConfiguration": { + "$ref": "#/definitions/S3DestinationConfiguration" + }, + "SplunkDestinationConfiguration": { + "$ref": "#/definitions/SplunkDestinationConfiguration" + }, + "HttpEndpointDestinationConfiguration": { + "$ref": "#/definitions/HttpEndpointDestinationConfiguration" + }, + "Tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + }, + "minItems": 1, + "maxItems": 50 + } + }, + "definitions": { + "DeliveryStreamEncryptionConfigurationInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "KeyARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "KeyType": { + "type": "string", + "enum": [ + "AWS_OWNED_CMK", + "CUSTOMER_MANAGED_CMK" + ] + } + }, + "required": [ + "KeyType" + ] + }, + "SplunkDestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "HECAcknowledgmentTimeoutInSeconds": { + "type": "integer", + "minimum": 180, + "maximum": 600 + }, + "HECEndpoint": { + "type": "string", + "minLength": 0, + "maxLength": 2048 + }, + "HECEndpointType": { + "type": "string", + "enum": [ + "Raw", + "Event" + ] + }, + "HECToken": { + "type": "string", + "minLength": 0, + "maxLength": 2048 + }, + "ProcessingConfiguration": { + "$ref": "#/definitions/ProcessingConfiguration" + }, + "RetryOptions": { + "$ref": "#/definitions/SplunkRetryOptions" + }, + "S3BackupMode": { + "type": "string" + }, + "S3Configuration": { + "$ref": "#/definitions/S3DestinationConfiguration" + } + }, + "required": [ + "HECEndpoint", + "S3Configuration", + "HECToken", + "HECEndpointType" + ] + }, + "HttpEndpointDestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "EndpointConfiguration": { + "$ref": "#/definitions/HttpEndpointConfiguration" + }, + "RequestConfiguration": { + "$ref": "#/definitions/HttpEndpointRequestConfiguration" + }, + "BufferingHints": { + "$ref": "#/definitions/BufferingHints" + }, + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "ProcessingConfiguration": { + "$ref": "#/definitions/ProcessingConfiguration" + }, + "RetryOptions": { + "$ref": "#/definitions/RetryOptions" + }, + "S3BackupMode": { + "type": "string" + }, + "S3Configuration": { + "$ref": "#/definitions/S3DestinationConfiguration" + } + }, + "required": [ + "EndpointConfiguration", + "S3Configuration" + ] + }, + "KinesisStreamSourceConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "KinesisStreamARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + } + }, + "required": [ + "RoleARN", + "KinesisStreamARN" + ] + }, + "VpcConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "SubnetIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "minItems": 1, + "maxItems": 16 + }, + "SecurityGroupIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "minItems": 1, + "maxItems": 5 + } + }, + "required": [ + "RoleARN", + "SubnetIds", + "SecurityGroupIds" + ] + }, + "DocumentIdOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "DefaultDocumentIdFormat": { + "type": "string", + "enum": [ + "FIREHOSE_DEFAULT", + "NO_DOCUMENT_ID" + ] + } + }, + "required": [ + "DefaultDocumentIdFormat" + ] + }, + "ExtendedS3DestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "BucketARN": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "arn:.*" + }, + "BufferingHints": { + "$ref": "#/definitions/BufferingHints" + }, + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "CompressionFormat": { + "type": "string", + "enum": [ + "UNCOMPRESSED", + "GZIP", + "ZIP", + "Snappy", + "HADOOP_SNAPPY" + ] + }, + "DataFormatConversionConfiguration": { + "$ref": "#/definitions/DataFormatConversionConfiguration" + }, + "DynamicPartitioningConfiguration": { + "$ref": "#/definitions/DynamicPartitioningConfiguration" + }, + "EncryptionConfiguration": { + "$ref": "#/definitions/EncryptionConfiguration" + }, + "ErrorOutputPrefix": { + "type": "string", + "minLength": 0, + "maxLength": 1024 + }, + "Prefix": { + "type": "string", + "minLength": 0, + "maxLength": 1024 + }, + "ProcessingConfiguration": { + "$ref": "#/definitions/ProcessingConfiguration" + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "S3BackupConfiguration": { + "$ref": "#/definitions/S3DestinationConfiguration" + }, + "S3BackupMode": { + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + } + }, + "required": [ + "BucketARN", + "RoleARN" + ] + }, + "S3DestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "BucketARN": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "arn:.*" + }, + "BufferingHints": { + "$ref": "#/definitions/BufferingHints" + }, + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "CompressionFormat": { + "type": "string", + "enum": [ + "UNCOMPRESSED", + "GZIP", + "ZIP", + "Snappy", + "HADOOP_SNAPPY" + ] + }, + "EncryptionConfiguration": { + "$ref": "#/definitions/EncryptionConfiguration" + }, + "ErrorOutputPrefix": { + "type": "string", + "minLength": 0, + "maxLength": 1024 + }, + "Prefix": { + "type": "string", + "minLength": 0, + "maxLength": 1024 + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + } + }, + "required": [ + "BucketARN", + "RoleARN" + ] + }, + "RedshiftDestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "ClusterJDBCURL": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "CopyCommand": { + "$ref": "#/definitions/CopyCommand" + }, + "Password": { + "type": "string", + "minLength": 6, + "maxLength": 512 + }, + "ProcessingConfiguration": { + "$ref": "#/definitions/ProcessingConfiguration" + }, + "RetryOptions": { + "$ref": "#/definitions/RedshiftRetryOptions" + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "S3BackupConfiguration": { + "$ref": "#/definitions/S3DestinationConfiguration" + }, + "S3BackupMode": { + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + }, + "S3Configuration": { + "$ref": "#/definitions/S3DestinationConfiguration" + }, + "Username": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "S3Configuration", + "Username", + "ClusterJDBCURL", + "CopyCommand", + "RoleARN", + "Password" + ] + }, + "ElasticsearchDestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "BufferingHints": { + "$ref": "#/definitions/ElasticsearchBufferingHints" + }, + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "DomainARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "IndexName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "IndexRotationPeriod": { + "type": "string", + "enum": [ + "NoRotation", + "OneHour", + "OneDay", + "OneWeek", + "OneMonth" + ] + }, + "ProcessingConfiguration": { + "$ref": "#/definitions/ProcessingConfiguration" + }, + "RetryOptions": { + "$ref": "#/definitions/ElasticsearchRetryOptions" + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "S3BackupMode": { + "type": "string", + "enum": [ + "FailedDocumentsOnly", + "AllDocuments" + ] + }, + "S3Configuration": { + "$ref": "#/definitions/S3DestinationConfiguration" + }, + "ClusterEndpoint": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "https:.*" + }, + "TypeName": { + "type": "string", + "minLength": 0, + "maxLength": 100 + }, + "VpcConfiguration": { + "$ref": "#/definitions/VpcConfiguration" + }, + "DocumentIdOptions": { + "$ref": "#/definitions/DocumentIdOptions" + } + }, + "required": [ + "IndexName", + "S3Configuration", + "RoleARN" + ] + }, + "AmazonopensearchserviceDestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "BufferingHints": { + "$ref": "#/definitions/AmazonopensearchserviceBufferingHints" + }, + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "DomainARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "IndexName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "IndexRotationPeriod": { + "type": "string", + "enum": [ + "NoRotation", + "OneHour", + "OneDay", + "OneWeek", + "OneMonth" + ] + }, + "ProcessingConfiguration": { + "$ref": "#/definitions/ProcessingConfiguration" + }, + "RetryOptions": { + "$ref": "#/definitions/AmazonopensearchserviceRetryOptions" + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "S3BackupMode": { + "type": "string", + "enum": [ + "FailedDocumentsOnly", + "AllDocuments" + ] + }, + "S3Configuration": { + "$ref": "#/definitions/S3DestinationConfiguration" + }, + "ClusterEndpoint": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "https:.*" + }, + "TypeName": { + "type": "string", + "minLength": 0, + "maxLength": 100 + }, + "VpcConfiguration": { + "$ref": "#/definitions/VpcConfiguration" + }, + "DocumentIdOptions": { + "$ref": "#/definitions/DocumentIdOptions" + } + }, + "required": [ + "IndexName", + "S3Configuration", + "RoleARN" + ] + }, + "AmazonOpenSearchServerlessDestinationConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "BufferingHints": { + "$ref": "#/definitions/AmazonOpenSearchServerlessBufferingHints" + }, + "CloudWatchLoggingOptions": { + "$ref": "#/definitions/CloudWatchLoggingOptions" + }, + "IndexName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "ProcessingConfiguration": { + "$ref": "#/definitions/ProcessingConfiguration" + }, + "RetryOptions": { + "$ref": "#/definitions/AmazonOpenSearchServerlessRetryOptions" + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "S3BackupMode": { + "type": "string", + "enum": [ + "FailedDocumentsOnly", + "AllDocuments" + ] + }, + "S3Configuration": { + "$ref": "#/definitions/S3DestinationConfiguration" + }, + "CollectionEndpoint": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "https:.*" + }, + "VpcConfiguration": { + "$ref": "#/definitions/VpcConfiguration" + } + }, + "required": [ + "IndexName", + "S3Configuration", + "RoleARN" + ] + }, + "BufferingHints": { + "type": "object", + "additionalProperties": false, + "properties": { + "IntervalInSeconds": { + "type": "integer" + }, + "SizeInMBs": { + "type": "integer" + } + } + }, + "ProcessingConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "Processors": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Processor" + } + } + } + }, + "SplunkRetryOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "DurationInSeconds": { + "type": "integer" + } + } + }, + "ElasticsearchRetryOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "DurationInSeconds": { + "type": "integer" + } + } + }, + "AmazonopensearchserviceRetryOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "DurationInSeconds": { + "type": "integer" + } + } + }, + "AmazonOpenSearchServerlessRetryOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "DurationInSeconds": { + "type": "integer" + } + } + }, + "RedshiftRetryOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "DurationInSeconds": { + "type": "integer" + } + } + }, + "RetryOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "DurationInSeconds": { + "type": "integer" + } + } + }, + "DataFormatConversionConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "InputFormatConfiguration": { + "$ref": "#/definitions/InputFormatConfiguration" + }, + "OutputFormatConfiguration": { + "$ref": "#/definitions/OutputFormatConfiguration" + }, + "SchemaConfiguration": { + "$ref": "#/definitions/SchemaConfiguration" + } + } + }, + "DynamicPartitioningConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "RetryOptions": { + "$ref": "#/definitions/RetryOptions" + } + } + }, + "CopyCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "CopyOptions": { + "type": "string", + "minLength": 0, + "maxLength": 204800 + }, + "DataTableColumns": { + "type": "string", + "minLength": 0, + "maxLength": 204800 + }, + "DataTableName": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "DataTableName" + ] + }, + "EncryptionConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "KMSEncryptionConfig": { + "$ref": "#/definitions/KMSEncryptionConfig" + }, + "NoEncryptionConfig": { + "type": "string", + "enum": [ + "NoEncryption" + ] + } + } + }, + "ElasticsearchBufferingHints": { + "type": "object", + "additionalProperties": false, + "properties": { + "IntervalInSeconds": { + "type": "integer" + }, + "SizeInMBs": { + "type": "integer" + } + } + }, + "AmazonopensearchserviceBufferingHints": { + "type": "object", + "additionalProperties": false, + "properties": { + "IntervalInSeconds": { + "type": "integer" + }, + "SizeInMBs": { + "type": "integer" + } + } + }, + "AmazonOpenSearchServerlessBufferingHints": { + "type": "object", + "additionalProperties": false, + "properties": { + "IntervalInSeconds": { + "type": "integer" + }, + "SizeInMBs": { + "type": "integer" + } + } + }, + "CloudWatchLoggingOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "LogGroupName": { + "type": "string" + }, + "LogStreamName": { + "type": "string" + } + } + }, + "OutputFormatConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Serializer": { + "$ref": "#/definitions/Serializer" + } + } + }, + "Processor": { + "type": "object", + "additionalProperties": false, + "properties": { + "Parameters": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/ProcessorParameter" + } + }, + "Type": { + "type": "string", + "enum": [ + "RecordDeAggregation", + "Lambda", + "MetadataExtraction", + "AppendDelimiterToRecord" + ] + } + }, + "required": [ + "Type" + ] + }, + "KMSEncryptionConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "AWSKMSKeyARN": { + "type": "string" + } + }, + "required": [ + "AWSKMSKeyARN" + ] + }, + "InputFormatConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Deserializer": { + "$ref": "#/definitions/Deserializer" + } + } + }, + "SchemaConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "CatalogId": { + "type": "string" + }, + "DatabaseName": { + "type": "string" + }, + "Region": { + "type": "string" + }, + "RoleARN": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "arn:.*" + }, + "TableName": { + "type": "string" + }, + "VersionId": { + "type": "string" + } + } + }, + "Serializer": { + "type": "object", + "additionalProperties": false, + "properties": { + "OrcSerDe": { + "$ref": "#/definitions/OrcSerDe" + }, + "ParquetSerDe": { + "$ref": "#/definitions/ParquetSerDe" + } + } + }, + "ProcessorParameter": { + "type": "object", + "additionalProperties": false, + "properties": { + "ParameterName": { + "type": "string" + }, + "ParameterValue": { + "type": "string" + } + }, + "required": [ + "ParameterValue", + "ParameterName" + ] + }, + "Deserializer": { + "type": "object", + "additionalProperties": false, + "properties": { + "HiveJsonSerDe": { + "$ref": "#/definitions/HiveJsonSerDe" + }, + "OpenXJsonSerDe": { + "$ref": "#/definitions/OpenXJsonSerDe" + } + } + }, + "HiveJsonSerDe": { + "type": "object", + "additionalProperties": false, + "properties": { + "TimestampFormats": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "OrcSerDe": { + "type": "object", + "additionalProperties": false, + "properties": { + "BlockSizeBytes": { + "type": "integer" + }, + "BloomFilterColumns": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "BloomFilterFalsePositiveProbability": { + "type": "number" + }, + "Compression": { + "type": "string" + }, + "DictionaryKeyThreshold": { + "type": "number" + }, + "EnablePadding": { + "type": "boolean" + }, + "FormatVersion": { + "type": "string" + }, + "PaddingTolerance": { + "type": "number" + }, + "RowIndexStride": { + "type": "integer" + }, + "StripeSizeBytes": { + "type": "integer" + } + } + }, + "ParquetSerDe": { + "type": "object", + "additionalProperties": false, + "properties": { + "BlockSizeBytes": { + "type": "integer" + }, + "Compression": { + "type": "string" + }, + "EnableDictionaryCompression": { + "type": "boolean" + }, + "MaxPaddingBytes": { + "type": "integer" + }, + "PageSizeBytes": { + "type": "integer" + }, + "WriterVersion": { + "type": "string" + } + } + }, + "OpenXJsonSerDe": { + "type": "object", + "additionalProperties": false, + "properties": { + "CaseInsensitive": { + "type": "boolean" + }, + "ColumnToJsonKeyMappings": { + "type": "object", + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "ConvertDotsInJsonKeysToUnderscores": { + "type": "boolean" + } + } + }, + "HttpEndpointRequestConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "ContentEncoding": { + "type": "string", + "enum": [ + "NONE", + "GZIP" + ] + }, + "CommonAttributes": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/HttpEndpointCommonAttribute" + }, + "minItems": 0, + "maxItems": 50 + } + } + }, + "HttpEndpointCommonAttribute": { + "type": "object", + "additionalProperties": false, + "properties": { + "AttributeName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "AttributeValue": { + "type": "string", + "minLength": 0, + "maxLength": 1024 + } + }, + "required": [ + "AttributeName", + "AttributeValue" + ] + }, + "HttpEndpointConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Url": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "AccessKey": { + "type": "string", + "minLength": 0, + "maxLength": 4096 + }, + "Name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "Url" + ] + }, + "Tag": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "pattern": "^(?!aws:)[\\p{L}\\p{Z}\\p{N}_.:\\/=+\\-@%]*$", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "pattern": "^[\\p{L}\\p{Z}\\p{N}_.:\\/=+\\-@%]*$", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Key" + ] + } + }, + "handlers": { + "create": { + "permissions": [ + "firehose:CreateDeliveryStream", + "firehose:DescribeDeliveryStream", + "iam:GetRole", + "iam:PassRole", + "kms:CreateGrant", + "kms:DescribeKey" + ] + }, + "read": { + "permissions": [ + "firehose:DescribeDeliveryStream", + "firehose:ListTagsForDeliveryStream" + ] + }, + "update": { + "permissions": [ + "firehose:UpdateDestination", + "firehose:DescribeDeliveryStream", + "firehose:StartDeliveryStreamEncryption", + "firehose:StopDeliveryStreamEncryption", + "firehose:ListTagsForDeliveryStream", + "firehose:TagDeliveryStream", + "firehose:UntagDeliveryStream", + "kms:CreateGrant", + "kms:RevokeGrant", + "kms:DescribeKey" + ] + }, + "delete": { + "permissions": [ + "firehose:DeleteDeliveryStream", + "firehose:DescribeDeliveryStream", + "kms:RevokeGrant", + "kms:DescribeKey" + ] + }, + "list": { + "permissions": [ + "firehose:ListDeliveryStreams" + ] + } + }, + "readOnlyProperties": [ + "/properties/Arn" + ], + "createOnlyProperties": [ + "/properties/DeliveryStreamName", + "/properties/DeliveryStreamType", + "/properties/ElasticsearchDestinationConfiguration/VpcConfiguration", + "/properties/AmazonopensearchserviceDestinationConfiguration/VpcConfiguration", + "/properties/AmazonOpenSearchServerlessDestinationConfiguration/VpcConfiguration", + "/properties/KinesisStreamSourceConfiguration" + ], + "primaryIdentifier": [ + "/properties/DeliveryStreamName" + ] +} diff --git a/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream_plugin.py b/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream_plugin.py new file mode 100644 index 0000000000000..772007e6ce18d --- /dev/null +++ b/localstack-core/localstack/services/kinesisfirehose/resource_providers/aws_kinesisfirehose_deliverystream_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class KinesisFirehoseDeliveryStreamProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::KinesisFirehose::DeliveryStream" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.kinesisfirehose.resource_providers.aws_kinesisfirehose_deliverystream import ( + KinesisFirehoseDeliveryStreamProvider, + ) + + self.factory = KinesisFirehoseDeliveryStreamProvider diff --git a/localstack-core/localstack/services/kms/__init__.py b/localstack-core/localstack/services/kms/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/kms/exceptions.py b/localstack-core/localstack/services/kms/exceptions.py new file mode 100644 index 0000000000000..ad157c5d85c4a --- /dev/null +++ b/localstack-core/localstack/services/kms/exceptions.py @@ -0,0 +1,16 @@ +from localstack.aws.api import CommonServiceException + + +class ValidationException(CommonServiceException): + def __init__(self, message: str): + super().__init__("ValidationException", message, 400, True) + + +class AccessDeniedException(CommonServiceException): + def __init__(self, message: str): + super().__init__("AccessDeniedException", message, 400, True) + + +class TagException(CommonServiceException): + def __init__(self, message=None): + super().__init__("TagException", status_code=400, message=message) diff --git a/localstack-core/localstack/services/kms/models.py b/localstack-core/localstack/services/kms/models.py new file mode 100644 index 0000000000000..3479e309d4903 --- /dev/null +++ b/localstack-core/localstack/services/kms/models.py @@ -0,0 +1,851 @@ +import base64 +import datetime +import io +import json +import logging +import os +import random +import re +import struct +import uuid +from collections import namedtuple +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +from cryptography.exceptions import InvalidSignature, InvalidTag, UnsupportedAlgorithm +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, hmac +from cryptography.hazmat.primitives import serialization as crypto_serialization +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa, utils +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey +from cryptography.hazmat.primitives.asymmetric.padding import PSS, PKCS1v15 +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.asymmetric.utils import Prehashed +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.serialization import load_der_public_key + +from localstack.aws.api.kms import ( + CreateAliasRequest, + CreateGrantRequest, + CreateKeyRequest, + EncryptionContextType, + InvalidCiphertextException, + InvalidKeyUsageException, + KeyMetadata, + KeySpec, + KeyState, + KeyUsageType, + KMSInvalidMacException, + KMSInvalidSignatureException, + LimitExceededException, + MacAlgorithmSpec, + MessageType, + MultiRegionConfiguration, + MultiRegionKey, + MultiRegionKeyType, + OriginType, + ReplicateKeyRequest, + SigningAlgorithmSpec, + TagList, + UnsupportedOperationException, +) +from localstack.constants import TAG_KEY_CUSTOM_ID +from localstack.services.kms.exceptions import TagException, ValidationException +from localstack.services.kms.utils import is_valid_key_arn, validate_tag +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute +from localstack.utils.aws.arns import get_partition, kms_alias_arn, kms_key_arn +from localstack.utils.crypto import decrypt, encrypt +from localstack.utils.strings import long_uid, to_bytes, to_str + +LOG = logging.getLogger(__name__) + +PATTERN_UUID = re.compile( + r"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" +) +MULTI_REGION_PATTERN = re.compile(r"^mrk-[a-fA-F0-9]{32}$") + +SYMMETRIC_DEFAULT_MATERIAL_LENGTH = 32 + +RSA_CRYPTO_KEY_LENGTHS = { + "RSA_2048": 2048, + "RSA_3072": 3072, + "RSA_4096": 4096, +} + +ECC_CURVES = { + "ECC_NIST_P256": ec.SECP256R1(), + "ECC_NIST_P384": ec.SECP384R1(), + "ECC_NIST_P521": ec.SECP521R1(), + "ECC_SECG_P256K1": ec.SECP256K1(), +} + +HMAC_RANGE_KEY_LENGTHS = { + "HMAC_224": (28, 64), + "HMAC_256": (32, 64), + "HMAC_384": (48, 128), + "HMAC_512": (64, 128), +} + +ON_DEMAND_ROTATION_LIMIT = 10 +KEY_ID_LEN = 36 +# Moto uses IV_LEN of 12, as it is fine for GCM encryption mode, but we use CBC, so have to set it to 16. +IV_LEN = 16 +TAG_LEN = 16 +CIPHERTEXT_HEADER_FORMAT = ">{key_id_len}s{iv_len}s{tag_len}s".format( + key_id_len=KEY_ID_LEN, iv_len=IV_LEN, tag_len=TAG_LEN +) +HEADER_LEN = KEY_ID_LEN + IV_LEN + TAG_LEN +Ciphertext = namedtuple("Ciphertext", ("key_id", "iv", "ciphertext", "tag")) + +RESERVED_ALIASES = [ + "alias/aws/acm", + "alias/aws/dynamodb", + "alias/aws/ebs", + "alias/aws/elasticfilesystem", + "alias/aws/es", + "alias/aws/glue", + "alias/aws/kinesisvideo", + "alias/aws/lambda", + "alias/aws/rds", + "alias/aws/redshift", + "alias/aws/s3", + "alias/aws/secretsmanager", + "alias/aws/ssm", + "alias/aws/xray", +] + +# list of key names that should be skipped when serializing the encryption context +IGNORED_CONTEXT_KEYS = ["aws-crypto-public-key"] + +# special tag name to allow specifying a custom key material for created keys +TAG_KEY_CUSTOM_KEY_MATERIAL = "_custom_key_material_" + + +def _serialize_ciphertext_blob(ciphertext: Ciphertext) -> bytes: + header = struct.pack( + CIPHERTEXT_HEADER_FORMAT, + ciphertext.key_id.encode("utf-8"), + ciphertext.iv, + ciphertext.tag, + ) + return header + ciphertext.ciphertext + + +def deserialize_ciphertext_blob(ciphertext_blob: bytes) -> Ciphertext: + header = ciphertext_blob[:HEADER_LEN] + ciphertext = ciphertext_blob[HEADER_LEN:] + key_id, iv, tag = struct.unpack(CIPHERTEXT_HEADER_FORMAT, header) + return Ciphertext(key_id=key_id.decode("utf-8"), iv=iv, ciphertext=ciphertext, tag=tag) + + +def _serialize_encryption_context(encryption_context: Optional[EncryptionContextType]) -> bytes: + if encryption_context: + aad = io.BytesIO() + for key, value in sorted(encryption_context.items(), key=lambda x: x[0]): + # remove the reserved key-value pair from additional authentication data + if key not in IGNORED_CONTEXT_KEYS: + aad.write(key.encode("utf-8")) + aad.write(value.encode("utf-8")) + return aad.getvalue() + else: + return b"" + + +# Confusion alert! +# In KMS, there are two things that can be called "keys": +# 1. A cryptographic key, i.e. a string of characters, a private/public/symmetrical key for cryptographic encoding +# and decoding etc. It is modeled here by KmsCryptoKey class. +# 2. An AWS object that stores both a cryptographic key and some relevant metadata, e.g. creation time, a unique ID, +# some state. It is modeled by KmsKey class. +# +# While KmsKeys always contain KmsCryptoKeys, sometimes KmsCryptoKeys exist without corresponding KmsKeys, +# e.g. GenerateDataKeyPair API call returns contents of a new KmsCryptoKey that is not associated with any KmsKey, +# but is partially encrypted by some pre-existing KmsKey. + + +class KmsCryptoKey: + """ + KmsCryptoKeys used to model both of the two cases where AWS generates keys: + 1. Keys that are created to be used inside of AWS. For such a key, its key material / private key are not to + leave AWS unencrypted. If they have to leave AWS, a different KmsCryptoKey is used to encrypt the data first. + 2. Keys that AWS creates for customers for some external use. Such a key might be returned to a customer with its + key material or public key unencrypted - see KMS GenerateDataKey / GenerateDataKeyPair. But such a key is not stored + by AWS and is not used by AWS. + """ + + public_key: Optional[bytes] + private_key: Optional[bytes] + key_material: bytes + key_spec: str + + @staticmethod + def assert_valid(key_spec: str): + """ + Validates that the given ``key_spec`` is supported in the current context. + + :param key_spec: The key specification to validate. + :type key_spec: str + :raises ValidationException: If ``key_spec`` is not a known valid spec. + :raises UnsupportedOperationException: If ``key_spec`` is entirely unsupported. + """ + + def raise_validation(): + raise ValidationException( + f"1 validation error detected: Value '{key_spec}' at 'keySpec' " + f"failed to satisfy constraint: Member must satisfy enum value set: " + f"[RSA_2048, ECC_NIST_P384, ECC_NIST_P256, ECC_NIST_P521, HMAC_384, RSA_3072, " + f"ECC_SECG_P256K1, RSA_4096, SYMMETRIC_DEFAULT, HMAC_256, HMAC_224, HMAC_512]" + ) + + if key_spec == "SYMMETRIC_DEFAULT": + return + + if key_spec.startswith("RSA"): + if key_spec not in RSA_CRYPTO_KEY_LENGTHS: + raise_validation() + return + + if key_spec.startswith("ECC"): + if key_spec not in ECC_CURVES: + raise_validation() + return + + if key_spec.startswith("HMAC"): + if key_spec not in HMAC_RANGE_KEY_LENGTHS: + raise_validation() + return + + raise UnsupportedOperationException(f"KeySpec {key_spec} is not supported") + + def __init__(self, key_spec: str, key_material: Optional[bytes] = None): + self.private_key = None + self.public_key = None + # Technically, key_material, being a symmetric encryption key, is only relevant for + # key_spec == SYMMETRIC_DEFAULT. + # But LocalStack uses symmetric encryption with this key_material even for other specs. Asymmetric keys are + # generated, but are not actually used for encryption. Signing is different. + self.key_material = key_material or os.urandom(SYMMETRIC_DEFAULT_MATERIAL_LENGTH) + self.key_spec = key_spec + + KmsCryptoKey.assert_valid(key_spec) + + if key_spec == "SYMMETRIC_DEFAULT": + return + + if key_spec.startswith("RSA"): + key_size = RSA_CRYPTO_KEY_LENGTHS.get(key_spec) + key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) + elif key_spec.startswith("ECC"): + curve = ECC_CURVES.get(key_spec) + if key_material: + key = crypto_serialization.load_der_private_key(key_material, password=None) + else: + key = ec.generate_private_key(curve) + elif key_spec.startswith("HMAC"): + minimum_length, maximum_length = HMAC_RANGE_KEY_LENGTHS.get(key_spec) + self.key_material = key_material or os.urandom( + random.randint(minimum_length, maximum_length) + ) + return + + self._serialize_key(key) + + def load_key_material(self, material: bytes): + if self.key_spec == "SYMMETRIC_DEFAULT": + self.key_material = material + else: + key = crypto_serialization.load_der_private_key(material, password=None) + self._serialize_key(key) + + def _serialize_key(self, key: ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey): + self.public_key = key.public_key().public_bytes( + crypto_serialization.Encoding.DER, + crypto_serialization.PublicFormat.SubjectPublicKeyInfo, + ) + self.private_key = key.private_bytes( + crypto_serialization.Encoding.DER, + crypto_serialization.PrivateFormat.PKCS8, + crypto_serialization.NoEncryption(), + ) + + @property + def key(self) -> RSAPrivateKey | EllipticCurvePrivateKey: + return crypto_serialization.load_der_private_key( + self.private_key, + password=None, + backend=default_backend(), + ) + + +class KmsKey: + metadata: KeyMetadata + crypto_key: KmsCryptoKey + tags: Dict[str, str] + policy: str + is_key_rotation_enabled: bool + rotation_period_in_days: int + next_rotation_date: datetime.datetime + previous_keys = [str] + + def __init__( + self, + create_key_request: CreateKeyRequest = None, + account_id: str = None, + region: str = None, + ): + create_key_request = create_key_request or CreateKeyRequest() + self.previous_keys = [] + + # Please keep in mind that tags of a key could be present in the request, they are not a part of metadata. At + # least in the sense of DescribeKey not returning them with the rest of the metadata. Instead, tags are more + # like aliases: + # https://docs.aws.amazon.com/kms/latest/APIReference/API_DescribeKey.html + # "DescribeKey does not return the following information: ... Tags on the KMS key." + self.tags = {} + self.add_tags(create_key_request.get("Tags")) + # Same goes for the policy. It is in the request, but not in the metadata. + self.policy = create_key_request.get("Policy") or self._get_default_key_policy( + account_id, region + ) + # https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html + # "Automatic key rotation is disabled by default on customer managed keys but authorized users can enable and + # disable it." + self.is_key_rotation_enabled = False + + self._populate_metadata(create_key_request, account_id, region) + custom_key_material = None + if TAG_KEY_CUSTOM_KEY_MATERIAL in self.tags: + # check if the _custom_key_material_ tag is specified, to use a custom key material for this key + custom_key_material = base64.b64decode(self.tags[TAG_KEY_CUSTOM_KEY_MATERIAL]) + # remove the _custom_key_material_ tag from the tags to not readily expose the custom key material + del self.tags[TAG_KEY_CUSTOM_KEY_MATERIAL] + self.crypto_key = KmsCryptoKey(self.metadata.get("KeySpec"), custom_key_material) + self.rotation_period_in_days = 365 + self.next_rotation_date = None + + def calculate_and_set_arn(self, account_id, region): + self.metadata["Arn"] = kms_key_arn(self.metadata.get("KeyId"), account_id, region) + + def generate_mac(self, msg: bytes, mac_algorithm: MacAlgorithmSpec) -> bytes: + h = self._get_hmac_context(mac_algorithm) + h.update(msg) + return h.finalize() + + def verify_mac(self, msg: bytes, mac: bytes, mac_algorithm: MacAlgorithmSpec) -> bool: + h = self._get_hmac_context(mac_algorithm) + h.update(msg) + try: + h.verify(mac) + return True + except InvalidSignature: + raise KMSInvalidMacException() + + # Encrypt is a method of KmsKey and not of KmsCryptoKey only because it requires KeyId, and KmsCryptoKeys do not + # hold KeyIds. Maybe it would be possible to remodel this better. + def encrypt(self, plaintext: bytes, encryption_context: EncryptionContextType = None) -> bytes: + iv = os.urandom(IV_LEN) + aad = _serialize_encryption_context(encryption_context=encryption_context) + ciphertext, tag = encrypt(self.crypto_key.key_material, plaintext, iv, aad) + return _serialize_ciphertext_blob( + ciphertext=Ciphertext( + key_id=self.metadata.get("KeyId"), iv=iv, ciphertext=ciphertext, tag=tag + ) + ) + + # The ciphertext has to be deserialized before this call. + def decrypt( + self, ciphertext: Ciphertext, encryption_context: EncryptionContextType = None + ) -> bytes: + aad = _serialize_encryption_context(encryption_context=encryption_context) + keys_to_try = [self.crypto_key.key_material] + self.previous_keys + + for key in keys_to_try: + try: + return decrypt(key, ciphertext.ciphertext, ciphertext.iv, ciphertext.tag, aad) + except (InvalidTag, InvalidSignature): + continue + + raise InvalidCiphertextException() + + def decrypt_rsa(self, encrypted: bytes) -> bytes: + private_key = crypto_serialization.load_der_private_key( + self.crypto_key.private_key, password=None, backend=default_backend() + ) + decrypted = private_key.decrypt( + encrypted, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + return decrypted + + def sign( + self, data: bytes, message_type: MessageType, signing_algorithm: SigningAlgorithmSpec + ) -> bytes: + hasher, wrapped_hasher = self._construct_sign_verify_hasher(signing_algorithm, message_type) + try: + if signing_algorithm.startswith("ECDSA"): + return self.crypto_key.key.sign(data, ec.ECDSA(wrapped_hasher)) + else: + padding = self._construct_sign_verify_padding(signing_algorithm, hasher) + return self.crypto_key.key.sign(data, padding, wrapped_hasher) + except ValueError as exc: + raise ValidationException(str(exc)) + + def verify( + self, + data: bytes, + message_type: MessageType, + signing_algorithm: SigningAlgorithmSpec, + signature: bytes, + ) -> bool: + hasher, wrapped_hasher = self._construct_sign_verify_hasher(signing_algorithm, message_type) + try: + if signing_algorithm.startswith("ECDSA"): + self.crypto_key.key.public_key().verify(signature, data, ec.ECDSA(wrapped_hasher)) + else: + padding = self._construct_sign_verify_padding(signing_algorithm, hasher) + self.crypto_key.key.public_key().verify(signature, data, padding, wrapped_hasher) + return True + except ValueError as exc: + raise ValidationException(str(exc)) + except InvalidSignature: + # AWS itself raises this exception without any additional message. + raise KMSInvalidSignatureException() + + def derive_shared_secret(self, public_key: bytes) -> bytes: + key_spec = self.metadata.get("KeySpec") + match key_spec: + case KeySpec.ECC_NIST_P256 | KeySpec.ECC_SECG_P256K1: + algorithm = hashes.SHA256() + case KeySpec.ECC_NIST_P384: + algorithm = hashes.SHA384() + case KeySpec.ECC_NIST_P521: + algorithm = hashes.SHA512() + case _: + raise InvalidKeyUsageException( + f"{self.metadata['Arn']} key usage is {self.metadata['KeyUsage']} which is not valid for DeriveSharedSecret." + ) + + # Deserialize public key from DER encoded data to EllipticCurvePublicKey. + try: + pub_key = load_der_public_key(public_key) + except (UnsupportedAlgorithm, ValueError): + raise ValidationException("") + shared_secret = self.crypto_key.key.exchange(ec.ECDH(), pub_key) + # Perform shared secret derivation. + return HKDF( + algorithm=algorithm, + salt=None, + info=b"", + length=algorithm.digest_size, + backend=default_backend(), + ).derive(shared_secret) + + # This method gets called when a key is replicated to another region. It's meant to populate the required metadata + # fields in a new replica key. + def replicate_metadata( + self, replicate_key_request: ReplicateKeyRequest, account_id: str, replica_region: str + ) -> None: + self.metadata["Description"] = replicate_key_request.get("Description") or "" + primary_key_arn = self.metadata["Arn"] + # Multi region keys have the same key ID for all replicas, but ARNs differ, as they include actual regions of + # replicas. + self.calculate_and_set_arn(account_id, replica_region) + + current_replica_keys = self.metadata.get("MultiRegionConfiguration", {}).get( + "ReplicaKeys", [] + ) + current_replica_keys.append(MultiRegionKey(Arn=self.metadata["Arn"], Region=replica_region)) + primary_key_region = ( + self.metadata.get("MultiRegionConfiguration", {}).get("PrimaryKey", {}).get("Region") + ) + + self.metadata["MultiRegionConfiguration"] = MultiRegionConfiguration( + MultiRegionKeyType=MultiRegionKeyType.REPLICA, + PrimaryKey=MultiRegionKey( + Arn=primary_key_arn, + Region=primary_key_region, + ), + ReplicaKeys=current_replica_keys, + ) + + def _get_hmac_context(self, mac_algorithm: MacAlgorithmSpec) -> hmac.HMAC: + if mac_algorithm == "HMAC_SHA_224": + h = hmac.HMAC(self.crypto_key.key_material, hashes.SHA224()) + elif mac_algorithm == "HMAC_SHA_256": + h = hmac.HMAC(self.crypto_key.key_material, hashes.SHA256()) + elif mac_algorithm == "HMAC_SHA_384": + h = hmac.HMAC(self.crypto_key.key_material, hashes.SHA384()) + elif mac_algorithm == "HMAC_SHA_512": + h = hmac.HMAC(self.crypto_key.key_material, hashes.SHA512()) + else: + raise ValidationException( + f"1 validation error detected: Value '{mac_algorithm}' at 'macAlgorithm' " + f"failed to satisfy constraint: Member must satisfy enum value set: " + f"[HMAC_SHA_384, HMAC_SHA_256, HMAC_SHA_224, HMAC_SHA_512]" + ) + return h + + def _construct_sign_verify_hasher( + self, signing_algorithm: SigningAlgorithmSpec, message_type: MessageType + ) -> ( + Prehashed | hashes.SHA256 | hashes.SHA384 | hashes.SHA512, + Prehashed | hashes.SHA256 | hashes.SHA384 | hashes.SHA512, + ): + if "SHA_256" in signing_algorithm: + hasher = hashes.SHA256() + elif "SHA_384" in signing_algorithm: + hasher = hashes.SHA384() + elif "SHA_512" in signing_algorithm: + hasher = hashes.SHA512() + else: + raise ValidationException( + f"Unsupported hash type in SigningAlgorithm '{signing_algorithm}'" + ) + + wrapped_hasher = hasher + if message_type == MessageType.DIGEST: + wrapped_hasher = utils.Prehashed(hasher) + return hasher, wrapped_hasher + + def _construct_sign_verify_padding( + self, + signing_algorithm: SigningAlgorithmSpec, + hasher: Prehashed | hashes.SHA256 | hashes.SHA384 | hashes.SHA512, + ) -> PKCS1v15 | PSS: + if signing_algorithm.startswith("RSA"): + if "PKCS" in signing_algorithm: + return padding.PKCS1v15() + elif "PSS" in signing_algorithm: + return padding.PSS(mgf=padding.MGF1(hasher), salt_length=padding.PSS.DIGEST_LENGTH) + else: + LOG.warning("Unsupported padding in SigningAlgorithm '%s'", signing_algorithm) + + # Not a comment, rather some possibly relevant links for the future. + # https://docs.aws.amazon.com/kms/latest/developerguide/asymm-create-key.html + # "You cannot create an elliptic curve key pair for encryption and decryption." + # https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#asymmetric-keys-concept + # "You can create asymmetric KMS keys that represent RSA key pairs for public key encryption or signing and + # verification, or elliptic curve key pairs for signing and verification." + # + # A useful link with a cheat-sheet of what operations are supported by what types of keys: + # https://docs.aws.amazon.com/kms/latest/developerguide/symm-asymm-compare.html + # + # https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#data-keys + # "AWS KMS generates the data key. Then it encrypts a copy of the data key under a symmetric encryption KMS key that + # you specify." + # + # Data keys are symmetric, data key pairs are asymmetric. + def _populate_metadata( + self, create_key_request: CreateKeyRequest, account_id: str, region: str + ) -> None: + self.metadata = KeyMetadata() + # Metadata fields coming from a creation request + # + # We do not include tags into the metadata. Tags might be present in a key creation request, but our metadata + # only contains data displayed by DescribeKey. And tags are not there: + # https://docs.aws.amazon.com/kms/latest/APIReference/API_DescribeKey.html + # "DescribeKey does not return the following information: ... Tags on the KMS key." + + self.metadata["Description"] = create_key_request.get("Description") or "" + self.metadata["MultiRegion"] = create_key_request.get("MultiRegion") or False + self.metadata["Origin"] = create_key_request.get("Origin") or "AWS_KMS" + # https://docs.aws.amazon.com/kms/latest/APIReference/API_CreateKey.html#KMS-CreateKey-request-CustomerMasterKeySpec + # CustomerMasterKeySpec has been deprecated, still used for compatibility. Is replaced by KeySpec. + # The meaning is the same, just the name differs. + self.metadata["KeySpec"] = ( + create_key_request.get("KeySpec") + or create_key_request.get("CustomerMasterKeySpec") + or "SYMMETRIC_DEFAULT" + ) + self.metadata["CustomerMasterKeySpec"] = self.metadata.get("KeySpec") + self.metadata["KeyUsage"] = self._get_key_usage( + create_key_request.get("KeyUsage"), self.metadata.get("KeySpec") + ) + + # Metadata fields AWS introduces automatically + self.metadata["AWSAccountId"] = account_id + self.metadata["CreationDate"] = datetime.datetime.now() + self.metadata["Enabled"] = create_key_request.get("Origin") != OriginType.EXTERNAL + self.metadata["KeyManager"] = "CUSTOMER" + self.metadata["KeyState"] = ( + KeyState.Enabled + if create_key_request.get("Origin") != OriginType.EXTERNAL + else KeyState.PendingImport + ) + + if TAG_KEY_CUSTOM_ID in self.tags: + # check if the _custom_id_ tag is specified, to set a user-defined KeyId for this key + self.metadata["KeyId"] = self.tags[TAG_KEY_CUSTOM_ID].strip() + elif self.metadata.get("MultiRegion"): + # https://docs.aws.amazon.com/kms/latest/developerguide/multi-region-keys-overview.html + # "Notice that multi-Region keys have a distinctive key ID that begins with mrk-. You can use the mrk- prefix to + # identify MRKs programmatically." + # The ID for MultiRegion keys also do not have dashes. + self.metadata["KeyId"] = "mrk-" + str(uuid.uuid4().hex) + else: + self.metadata["KeyId"] = str(uuid.uuid4()) + self.calculate_and_set_arn(account_id, region) + + self._populate_encryption_algorithms( + self.metadata.get("KeyUsage"), self.metadata.get("KeySpec") + ) + self._populate_signing_algorithms( + self.metadata.get("KeyUsage"), self.metadata.get("KeySpec") + ) + self._populate_mac_algorithms(self.metadata.get("KeyUsage"), self.metadata.get("KeySpec")) + + if self.metadata["MultiRegion"]: + self.metadata["MultiRegionConfiguration"] = MultiRegionConfiguration( + MultiRegionKeyType=MultiRegionKeyType.PRIMARY, + PrimaryKey=MultiRegionKey(Arn=self.metadata["Arn"], Region=region), + ReplicaKeys=[], + ) + + def add_tags(self, tags: TagList) -> None: + # Just in case we get None from somewhere. + if not tags: + return + + unique_tag_keys = {tag["TagKey"] for tag in tags} + if len(unique_tag_keys) < len(tags): + raise TagException("Duplicate tag keys") + + if len(tags) > 50: + raise TagException("Too many tags") + + # Do not care if we overwrite an existing tag: + # https://docs.aws.amazon.com/kms/latest/APIReference/API_TagResource.html + # "To edit a tag, specify an existing tag key and a new tag value." + for i, tag in enumerate(tags, start=1): + validate_tag(i, tag) + self.tags[tag.get("TagKey")] = tag.get("TagValue") + + def schedule_key_deletion(self, pending_window_in_days: int) -> None: + self.metadata["Enabled"] = False + # TODO For MultiRegion keys, the status of replicas get set to "PendingDeletion", while the primary key + # becomes "PendingReplicaDeletion". Here we just set all keys to "PendingDeletion", as we do not have any + # notion of a primary key in LocalStack. Might be useful to improve it. + # https://docs.aws.amazon.com/kms/latest/developerguide/multi-region-keys-delete.html#primary-delete + self.metadata["KeyState"] = "PendingDeletion" + self.metadata["DeletionDate"] = datetime.datetime.now() + datetime.timedelta( + days=pending_window_in_days + ) + + def _update_key_rotation_date(self) -> None: + if not self.next_rotation_date or self.next_rotation_date < datetime.datetime.now(): + self.next_rotation_date = datetime.datetime.now() + datetime.timedelta( + days=self.rotation_period_in_days + ) + + # An example of how the whole policy should look like: + # https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-overview.html + # The default statement is here: + # https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-default.html#key-policy-default-allow-root-enable-iam + def _get_default_key_policy(self, account_id: str, region: str) -> str: + return json.dumps( + { + "Version": "2012-10-17", + "Id": "key-default-1", + "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": {"AWS": f"arn:{get_partition(region)}:iam::{account_id}:root"}, + "Action": "kms:*", + "Resource": "*", + } + ], + } + ) + + def _populate_encryption_algorithms(self, key_usage: str, key_spec: str) -> None: + # The two main usages for KMS keys are encryption/decryption and signing/verification. + # Doesn't make sense to populate fields related to encryption/decryption unless the key is created with that + # goal in mind. + if key_usage != "ENCRYPT_DECRYPT": + return + if key_spec == "SYMMETRIC_DEFAULT": + self.metadata["EncryptionAlgorithms"] = ["SYMMETRIC_DEFAULT"] + else: + self.metadata["EncryptionAlgorithms"] = ["RSAES_OAEP_SHA_1", "RSAES_OAEP_SHA_256"] + + def _populate_signing_algorithms(self, key_usage: str, key_spec: str) -> None: + # The two main usages for KMS keys are encryption/decryption and signing/verification. + # Doesn't make sense to populate fields related to signing/verification unless the key is created with that + # goal in mind. + if key_usage != "SIGN_VERIFY": + return + if key_spec in ["ECC_NIST_P256", "ECC_SECG_P256K1"]: + self.metadata["SigningAlgorithms"] = ["ECDSA_SHA_256"] + elif key_spec == "ECC_NIST_P384": + self.metadata["SigningAlgorithms"] = ["ECDSA_SHA_384"] + elif key_spec == "ECC_NIST_P521": + self.metadata["SigningAlgorithms"] = ["ECDSA_SHA_512"] + else: + self.metadata["SigningAlgorithms"] = [ + "RSASSA_PKCS1_V1_5_SHA_256", + "RSASSA_PKCS1_V1_5_SHA_384", + "RSASSA_PKCS1_V1_5_SHA_512", + "RSASSA_PSS_SHA_256", + "RSASSA_PSS_SHA_384", + "RSASSA_PSS_SHA_512", + ] + + def _populate_mac_algorithms(self, key_usage: str, key_spec: str) -> None: + if key_usage != "GENERATE_VERIFY_MAC": + return + if key_spec == "HMAC_224": + self.metadata["MacAlgorithms"] = ["HMAC_SHA_224"] + elif key_spec == "HMAC_256": + self.metadata["MacAlgorithms"] = ["HMAC_SHA_256"] + elif key_spec == "HMAC_384": + self.metadata["MacAlgorithms"] = ["HMAC_SHA_384"] + elif key_spec == "HMAC_512": + self.metadata["MacAlgorithms"] = ["HMAC_SHA_512"] + + def _get_key_usage(self, request_key_usage: str, key_spec: str) -> str: + if key_spec in HMAC_RANGE_KEY_LENGTHS: + if request_key_usage is None: + raise ValidationException( + "You must specify a KeyUsage value for all KMS keys except for symmetric encryption keys." + ) + elif request_key_usage != KeyUsageType.GENERATE_VERIFY_MAC: + raise ValidationException( + f"1 validation error detected: Value '{request_key_usage}' at 'keyUsage' " + f"failed to satisfy constraint: Member must satisfy enum value set: " + f"[ENCRYPT_DECRYPT, SIGN_VERIFY, GENERATE_VERIFY_MAC]" + ) + else: + return KeyUsageType.GENERATE_VERIFY_MAC + elif request_key_usage == KeyUsageType.KEY_AGREEMENT: + if key_spec not in [ + KeySpec.ECC_NIST_P256, + KeySpec.ECC_NIST_P384, + KeySpec.ECC_NIST_P521, + KeySpec.ECC_SECG_P256K1, + KeySpec.SM2, + ]: + raise ValidationException( + f"KeyUsage {request_key_usage} is not compatible with KeySpec {key_spec}" + ) + else: + return request_key_usage + else: + return request_key_usage or "ENCRYPT_DECRYPT" + + def rotate_key_on_demand(self): + if len(self.previous_keys) >= ON_DEMAND_ROTATION_LIMIT: + raise LimitExceededException( + f"The on-demand rotations limit has been reached for the given keyId. " + f"No more on-demand rotations can be performed for this key: {self.metadata['Arn']}" + ) + self.previous_keys.append(self.crypto_key.key_material) + self.crypto_key = KmsCryptoKey(KeySpec.SYMMETRIC_DEFAULT) + + +class KmsGrant: + # AWS documentation doesn't seem to mention any metadata object for grants like it does mention KeyMetadata for + # keys. But, based on our understanding of AWS documentation for CreateGrant, ListGrants operations etc, + # AWS has some set of fields for grants like it has for keys. So we are going to call them `metadata` here for + # consistency. + metadata: Dict + # Tokens are not a part of metadata, as their use is more limited and specific than for the rest of the + # metadata: https://docs.aws.amazon.com/kms/latest/developerguide/grant-manage.html#using-grant-token + # Tokens are used to refer to a grant in a short period right after the grant gets created. Normally it might + # take KMS up to 5 minutes to make a new grant available. In that time window referring to a grant by its + # GrantId might not work, so tokens are supposed to be used. The tokens could possibly be used even + # afterwards. But since the only way to get a token is through a CreateGrant operation (see below), the chances + # of someone storing a token and using it later are slim. + # + # https://docs.aws.amazon.com/kms/latest/developerguide/grants.html#grant_token + # "CreateGrant is the only operation that returns a grant token. You cannot get a grant token from any other + # AWS KMS operation or from the CloudTrail log event for the CreateGrant operation. The ListGrants and + # ListRetirableGrants operations return the grant ID, but not a grant token." + # + # Usually a grant might have multiple unique tokens. But here we just model it with a single token for + # simplicity. + token: str + + def __init__(self, create_grant_request: CreateGrantRequest, account_id: str, region_name: str): + self.metadata = dict(create_grant_request) + + if is_valid_key_arn(self.metadata["KeyId"]): + self.metadata["KeyArn"] = self.metadata["KeyId"] + else: + self.metadata["KeyArn"] = kms_key_arn(self.metadata["KeyId"], account_id, region_name) + + self.metadata["GrantId"] = long_uid() + self.metadata["CreationDate"] = datetime.datetime.now() + # https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantListEntry.html + # "If a name was provided in the CreateGrant request, that name is returned. Otherwise this value is null." + # According to the examples in AWS docs + # https://docs.aws.amazon.com/kms/latest/APIReference/API_ListGrants.html#API_ListGrants_Examples + # The Name field is present with just an empty string value. + self.metadata.setdefault("Name", "") + + # Encode account ID and region in grant token. + # This way the grant can be located when being retired by grant principal. + # The token consists of account ID, region name and a UUID concatenated with ':' and encoded with base64 + decoded_token = account_id + ":" + region_name + ":" + long_uid() + self.token = to_str(base64.b64encode(to_bytes(decoded_token))) + + +class KmsAlias: + # Like with grants (see comment for KmsGrant), there is no mention of some specific object modeling metadata + # for KMS aliases. But there is data that is some metadata, so we model it in a way similar to KeyMetadata for keys. + metadata: Dict + + def __init__( + self, + create_alias_request: CreateAliasRequest = None, + account_id: str = None, + region: str = None, + ): + create_alias_request = create_alias_request or CreateAliasRequest() + self.metadata = {} + self.metadata["AliasName"] = create_alias_request.get("AliasName") + self.metadata["TargetKeyId"] = create_alias_request.get("TargetKeyId") + self.update_date_of_last_update() + self.metadata["CreationDate"] = self.metadata["LastUpdateDate"] + self.metadata["AliasArn"] = kms_alias_arn(self.metadata["AliasName"], account_id, region) + + def update_date_of_last_update(self): + self.metadata["LastUpdateDate"] = datetime.datetime.now() + + +@dataclass +class KeyImportState: + key_id: str + import_token: str + wrapping_algo: str + key: KmsKey + + +class KmsStore(BaseStore): + # maps key ids to keys + keys: Dict[str, KmsKey] = LocalAttribute(default=dict) + + # According to AWS documentation on grants https://docs.aws.amazon.com/kms/latest/APIReference/API_RetireGrant.html + # "Cross-account use: Yes. You can retire a grant on a KMS key in a different AWS account." + + # maps grant ids to grants + grants: Dict[str, KmsGrant] = LocalAttribute(default=dict) + + # maps from (grant names (used for idempotency), key id) to grant ids + grant_names: Dict[Tuple[str, str], str] = LocalAttribute(default=dict) + + # maps grant tokens to grant ids + grant_tokens: Dict[str, str] = LocalAttribute(default=dict) + + # maps key alias names to aliases + aliases: Dict[str, KmsAlias] = LocalAttribute(default=dict) + + # maps import tokens to import data + imports: Dict[str, KeyImportState] = LocalAttribute(default=dict) + + +kms_stores = AccountRegionBundle("kms", KmsStore) diff --git a/localstack-core/localstack/services/kms/provider.py b/localstack-core/localstack/services/kms/provider.py new file mode 100644 index 0000000000000..a243e1d7fcea6 --- /dev/null +++ b/localstack-core/localstack/services/kms/provider.py @@ -0,0 +1,1622 @@ +import base64 +import copy +import datetime +import logging +import os +from typing import Dict, Tuple + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.kms import ( + AlgorithmSpec, + AlreadyExistsException, + BackingKeyIdType, + CancelKeyDeletionRequest, + CancelKeyDeletionResponse, + CiphertextType, + CreateAliasRequest, + CreateGrantRequest, + CreateGrantResponse, + CreateKeyRequest, + CreateKeyResponse, + DataKeyPairSpec, + DateType, + DecryptResponse, + DeleteAliasRequest, + DeleteImportedKeyMaterialResponse, + DeriveSharedSecretResponse, + DescribeKeyRequest, + DescribeKeyResponse, + DisabledException, + DisableKeyRequest, + DisableKeyRotationRequest, + EnableKeyRequest, + EnableKeyRotationRequest, + EncryptionAlgorithmSpec, + EncryptionContextType, + EncryptResponse, + ExpirationModelType, + GenerateDataKeyPairResponse, + GenerateDataKeyPairWithoutPlaintextResponse, + GenerateDataKeyRequest, + GenerateDataKeyResponse, + GenerateDataKeyWithoutPlaintextRequest, + GenerateDataKeyWithoutPlaintextResponse, + GenerateMacRequest, + GenerateMacResponse, + GenerateRandomRequest, + GenerateRandomResponse, + GetKeyPolicyRequest, + GetKeyPolicyResponse, + GetKeyRotationStatusRequest, + GetKeyRotationStatusResponse, + GetParametersForImportResponse, + GetPublicKeyResponse, + GrantIdType, + GrantTokenList, + GrantTokenType, + ImportKeyMaterialResponse, + ImportType, + IncorrectKeyException, + InvalidCiphertextException, + InvalidGrantIdException, + InvalidKeyUsageException, + KeyAgreementAlgorithmSpec, + KeyIdType, + KeyMaterialDescriptionType, + KeySpec, + KeyState, + KeyUsageType, + KmsApi, + KMSInvalidStateException, + LimitType, + ListAliasesResponse, + ListGrantsRequest, + ListGrantsResponse, + ListKeyPoliciesRequest, + ListKeyPoliciesResponse, + ListKeysRequest, + ListKeysResponse, + ListResourceTagsRequest, + ListResourceTagsResponse, + MacAlgorithmSpec, + MarkerType, + MultiRegionKey, + NotFoundException, + NullableBooleanType, + OriginType, + PlaintextType, + PrincipalIdType, + PublicKeyType, + PutKeyPolicyRequest, + RecipientInfo, + ReEncryptResponse, + ReplicateKeyRequest, + ReplicateKeyResponse, + RotateKeyOnDemandRequest, + RotateKeyOnDemandResponse, + ScheduleKeyDeletionRequest, + ScheduleKeyDeletionResponse, + SignRequest, + SignResponse, + TagResourceRequest, + UnsupportedOperationException, + UntagResourceRequest, + UpdateAliasRequest, + UpdateKeyDescriptionRequest, + VerifyMacRequest, + VerifyMacResponse, + VerifyRequest, + VerifyResponse, + WrappingKeySpec, +) +from localstack.services.kms.exceptions import ValidationException +from localstack.services.kms.models import ( + MULTI_REGION_PATTERN, + PATTERN_UUID, + RESERVED_ALIASES, + KeyImportState, + KmsAlias, + KmsCryptoKey, + KmsGrant, + KmsKey, + KmsStore, + deserialize_ciphertext_blob, + kms_stores, +) +from localstack.services.kms.utils import ( + execute_dry_run_capable, + is_valid_key_arn, + parse_key_arn, + validate_alias_name, +) +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.aws.arns import get_partition, kms_alias_arn, parse_arn +from localstack.utils.collections import PaginatedList +from localstack.utils.common import select_attributes +from localstack.utils.strings import short_uid, to_bytes, to_str + +LOG = logging.getLogger(__name__) + +# valid operations +VALID_OPERATIONS = [ + "CreateKey", + "Decrypt", + "Encrypt", + "GenerateDataKey", + "GenerateDataKeyWithoutPlaintext", + "ReEncryptFrom", + "ReEncryptTo", + "Sign", + "Verify", + "GetPublicKey", + "CreateGrant", + "RetireGrant", + "DescribeKey", + "GenerateDataKeyPair", + "GenerateDataKeyPairWithoutPlaintext", +] + + +class ValidationError(CommonServiceException): + """General validation error type (defined in the AWS docs, but not part of the botocore spec)""" + + def __init__(self, message=None): + super().__init__("ValidationError", message=message) + + +# For all operations constraints for states of keys are based on +# https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html +class KmsProvider(KmsApi, ServiceLifecycleHook): + """ + The LocalStack Key Management Service (KMS) provider. + + Cross-account access is supported by following operations where key ID belonging + to another account can be used with the key ARN. + - CreateGrant + - DescribeKey + - GetKeyRotationStatus + - GetPublicKey + - ListGrants + - RetireGrant + - RevokeGrant + - Decrypt + - Encrypt + - GenerateDataKey + - GenerateDataKeyPair + - GenerateDataKeyPairWithoutPlaintext + - GenerateDataKeyWithoutPlaintext + - GenerateMac + - ReEncrypt + - Sign + - Verify + - VerifyMac + """ + + # + # Helpers + # + + @staticmethod + def _get_store(account_id: str, region_name: str) -> KmsStore: + return kms_stores[account_id][region_name] + + @staticmethod + def _create_kms_alias(account_id: str, region_name: str, request: CreateAliasRequest): + store = kms_stores[account_id][region_name] + alias = KmsAlias(request, account_id, region_name) + alias_name = request.get("AliasName") + store.aliases[alias_name] = alias + + @staticmethod + def _create_kms_key( + account_id: str, region_name: str, request: CreateKeyRequest = None + ) -> KmsKey: + store = kms_stores[account_id][region_name] + key = KmsKey(request, account_id, region_name) + key_id = key.metadata["KeyId"] + store.keys[key_id] = key + return key + + @staticmethod + def _get_key_id_from_any_id(account_id: str, region_name: str, some_id: str) -> str: + """ + Resolve a KMS key ID by using one of the following identifiers: + - key ID + - key ARN + - key alias + - key alias ARN + """ + alias_name = None + key_id = None + key_arn = None + + if some_id.startswith("arn:"): + if ":alias/" in some_id: + alias_arn = some_id + alias_name = "alias/" + alias_arn.split(":alias/")[1] + elif ":key/" in some_id: + key_arn = some_id + key_id = key_arn.split(":key/")[1] + parsed_arn = parse_arn(key_arn) + if parsed_arn["region"] != region_name: + raise NotFoundException(f"Invalid arn {parsed_arn['region']}") + else: + raise ValueError( + f"Supplied value of {some_id} is an ARN, but neither of a KMS key nor of a KMS key " + f"alias" + ) + elif some_id.startswith("alias/"): + alias_name = some_id + else: + key_id = some_id + + store = kms_stores[account_id][region_name] + + if alias_name: + KmsProvider._create_alias_if_reserved_and_not_exists( + account_id, + region_name, + alias_name, + ) + if alias_name not in store.aliases: + raise NotFoundException(f"Unable to find KMS alias with name {alias_name}") + key_id = store.aliases[alias_name].metadata["TargetKeyId"] + + # regular KeyId are UUID, and MultiRegion keys starts with 'mrk-' and 32 hex chars + if not PATTERN_UUID.match(key_id) and not MULTI_REGION_PATTERN.match(key_id): + raise NotFoundException(f"Invalid keyId '{key_id}'") + + if key_id not in store.keys: + if not key_arn: + key_arn = ( + f"arn:{get_partition(region_name)}:kms:{region_name}:{account_id}:key/{key_id}" + ) + raise NotFoundException(f"Key '{key_arn}' does not exist") + + return key_id + + @staticmethod + def _create_alias_if_reserved_and_not_exists( + account_id: str, region_name: str, alias_name: str + ): + store = kms_stores[account_id][region_name] + if alias_name not in RESERVED_ALIASES or alias_name in store.aliases: + return + create_key_request = {} + key_id = KmsProvider._create_kms_key( + account_id, + region_name, + create_key_request, + ).metadata.get("KeyId") + create_alias_request = CreateAliasRequest(AliasName=alias_name, TargetKeyId=key_id) + KmsProvider._create_kms_alias(account_id, region_name, create_alias_request) + + # While in AWS keys have more than Enabled, Disabled and PendingDeletion states, we currently only model these 3 + # in LocalStack, so this function is limited to them. + # + # The current default values are based on most of the operations working in AWS with enabled keys, but failing with + # disabled and those pending deletion. + # + # If we decide to use the other states as well, we might want to come up with a better key state validation per + # operation. Can consult this page for what states are supported by various operations: + # https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html + @staticmethod + def _get_kms_key( + account_id: str, + region_name: str, + any_type_of_key_id: str, + any_key_state_allowed: bool = False, + enabled_key_allowed: bool = True, + disabled_key_allowed: bool = False, + pending_deletion_key_allowed: bool = False, + ) -> KmsKey: + store = kms_stores[account_id][region_name] + + if any_key_state_allowed: + enabled_key_allowed = True + disabled_key_allowed = True + pending_deletion_key_allowed = True + if not (enabled_key_allowed or disabled_key_allowed or pending_deletion_key_allowed): + raise ValueError("A key is requested, but all possible key states are prohibited") + + key_id = KmsProvider._get_key_id_from_any_id(account_id, region_name, any_type_of_key_id) + key = store.keys[key_id] + + if not disabled_key_allowed and key.metadata.get("KeyState") == "Disabled": + raise DisabledException(f"{key.metadata.get('Arn')} is disabled.") + if not pending_deletion_key_allowed and key.metadata.get("KeyState") == "PendingDeletion": + raise KMSInvalidStateException(f"{key.metadata.get('Arn')} is pending deletion.") + if not enabled_key_allowed and key.metadata.get("KeyState") == "Enabled": + raise KMSInvalidStateException( + f"{key.metadata.get('Arn')} is enabled, but the operation doesn't support " + f"such a state" + ) + return store.keys[key_id] + + @staticmethod + def _get_kms_alias(account_id: str, region_name: str, alias_name_or_arn: str) -> KmsAlias: + store = kms_stores[account_id][region_name] + + if not alias_name_or_arn.startswith("arn:"): + alias_name = alias_name_or_arn + else: + if ":alias/" not in alias_name_or_arn: + raise ValidationException(f"{alias_name_or_arn} is not a valid alias ARN") + alias_name = "alias/" + alias_name_or_arn.split(":alias/")[1] + + validate_alias_name(alias_name) + + if alias_name not in store.aliases: + alias_arn = kms_alias_arn(alias_name, account_id, region_name) + # AWS itself uses AliasArn instead of AliasName in this exception. + raise NotFoundException(f"Alias {alias_arn} is not found.") + + return store.aliases.get(alias_name) + + @staticmethod + def _parse_key_id(key_id_or_arn: str, context: RequestContext) -> Tuple[str, str, str]: + """ + Return locator attributes (account ID, region_name, key ID) of a given KMS key. + + If an ARN is provided, this is extracted from it. Otherwise, context data is used. + + :param key_id_or_arn: KMS key ID or ARN + :param context: request context + :return: Tuple of account ID, region name and key ID + """ + if is_valid_key_arn(key_id_or_arn): + account_id, region_name, key_id = parse_key_arn(key_id_or_arn) + if region_name != context.region: + raise NotFoundException(f"Invalid arn {region_name}") + return account_id, region_name, key_id + + return context.account_id, context.region, key_id_or_arn + + @staticmethod + def _is_rsa_spec(key_spec: str) -> bool: + return key_spec in [KeySpec.RSA_2048, KeySpec.RSA_3072, KeySpec.RSA_4096] + + # + # Operation Handlers + # + + @handler("CreateKey", expand=False) + def create_key( + self, + context: RequestContext, + request: CreateKeyRequest = None, + ) -> CreateKeyResponse: + key = self._create_kms_key(context.account_id, context.region, request) + return CreateKeyResponse(KeyMetadata=key.metadata) + + @handler("ScheduleKeyDeletion", expand=False) + def schedule_key_deletion( + self, context: RequestContext, request: ScheduleKeyDeletionRequest + ) -> ScheduleKeyDeletionResponse: + pending_window = int(request.get("PendingWindowInDays", 30)) + if pending_window < 7 or pending_window > 30: + raise ValidationException( + f"PendingWindowInDays should be between 7 and 30, but it is {pending_window}" + ) + key = self._get_kms_key( + context.account_id, + context.region, + request.get("KeyId"), + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key.schedule_key_deletion(pending_window) + attrs = ["DeletionDate", "KeyId", "KeyState"] + result = select_attributes(key.metadata, attrs) + result["PendingWindowInDays"] = pending_window + return ScheduleKeyDeletionResponse(**result) + + @handler("CancelKeyDeletion", expand=False) + def cancel_key_deletion( + self, context: RequestContext, request: CancelKeyDeletionRequest + ) -> CancelKeyDeletionResponse: + key = self._get_kms_key( + context.account_id, + context.region, + request.get("KeyId"), + enabled_key_allowed=False, + pending_deletion_key_allowed=True, + ) + key.metadata["KeyState"] = KeyState.Disabled + key.metadata["DeletionDate"] = None + # https://docs.aws.amazon.com/kms/latest/APIReference/API_CancelKeyDeletion.html#API_CancelKeyDeletion_ResponseElements + # "The Amazon Resource Name (key ARN) of the KMS key whose deletion is canceled." + return CancelKeyDeletionResponse(KeyId=key.metadata.get("Arn")) + + @handler("DisableKey", expand=False) + def disable_key(self, context: RequestContext, request: DisableKeyRequest) -> None: + # Technically, AWS allows DisableKey for keys that are already disabled. + key = self._get_kms_key( + context.account_id, + context.region, + request.get("KeyId"), + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key.metadata["KeyState"] = KeyState.Disabled + key.metadata["Enabled"] = False + + @handler("EnableKey", expand=False) + def enable_key(self, context: RequestContext, request: EnableKeyRequest) -> None: + key = self._get_kms_key( + context.account_id, + context.region, + request.get("KeyId"), + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key.metadata["KeyState"] = KeyState.Enabled + key.metadata["Enabled"] = True + + @handler("ListKeys", expand=False) + def list_keys(self, context: RequestContext, request: ListKeysRequest) -> ListKeysResponse: + # https://docs.aws.amazon.com/kms/latest/APIReference/API_ListKeys.html#API_ListKeys_ResponseSyntax + # Out of whole KeyMetadata only two fields are present in the response. + keys_list = PaginatedList( + [ + {"KeyId": key.metadata["KeyId"], "KeyArn": key.metadata["Arn"]} + for key in self._get_store(context.account_id, context.region).keys.values() + ] + ) + # https://docs.aws.amazon.com/kms/latest/APIReference/API_ListKeys.html#API_ListKeys_RequestParameters + # Regarding the default value of Limit: "If you do not include a value, it defaults to 100." + page, next_token = keys_list.get_page( + lambda key_data: key_data.get("KeyId"), + next_token=request.get("Marker"), + page_size=request.get("Limit", 100), + ) + kwargs = {"NextMarker": next_token, "Truncated": True} if next_token else {} + return ListKeysResponse(Keys=page, **kwargs) + + @handler("DescribeKey", expand=False) + def describe_key( + self, context: RequestContext, request: DescribeKeyRequest + ) -> DescribeKeyResponse: + account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(account_id, region_name, key_id, any_key_state_allowed=True) + return DescribeKeyResponse(KeyMetadata=key.metadata) + + @handler("ReplicateKey", expand=False) + def replicate_key( + self, context: RequestContext, request: ReplicateKeyRequest + ) -> ReplicateKeyResponse: + account_id = context.account_id + key = self._get_kms_key(account_id, context.region, request.get("KeyId")) + key_id = key.metadata.get("KeyId") + if not key.metadata.get("MultiRegion"): + raise UnsupportedOperationException( + f"Unable to replicate a non-MultiRegion key {key_id}" + ) + replica_region = request.get("ReplicaRegion") + replicate_to_store = kms_stores[account_id][replica_region] + if key_id in replicate_to_store.keys: + raise AlreadyExistsException( + f"Unable to replicate key {key_id} to region {replica_region}, as the key " + f"already exist there" + ) + replica_key = copy.deepcopy(key) + replica_key.replicate_metadata(request, account_id, replica_region) + replicate_to_store.keys[key_id] = replica_key + + self.update_primary_key_with_replica_keys(key, replica_key, replica_region) + + return ReplicateKeyResponse(ReplicaKeyMetadata=replica_key.metadata) + + @staticmethod + # Adds new multi region replica key to the primary key's metadata. + def update_primary_key_with_replica_keys(key: KmsKey, replica_key: KmsKey, region: str): + key.metadata["MultiRegionConfiguration"]["ReplicaKeys"].append( + MultiRegionKey( + Arn=replica_key.metadata["Arn"], + Region=region, + ) + ) + + @handler("UpdateKeyDescription", expand=False) + def update_key_description( + self, context: RequestContext, request: UpdateKeyDescriptionRequest + ) -> None: + key = self._get_kms_key( + context.account_id, + context.region, + request.get("KeyId"), + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key.metadata["Description"] = request.get("Description") + + @handler("CreateGrant", expand=False) + def create_grant( + self, context: RequestContext, request: CreateGrantRequest + ) -> CreateGrantResponse: + key_account_id, key_region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(key_account_id, key_region_name, key_id) + + # KeyId can potentially hold one of multiple different types of key identifiers. Here we find a key no + # matter which type of id is used. + key_id = key.metadata.get("KeyId") + request["KeyId"] = key_id + self._validate_grant_request(request) + grant_name = request.get("Name") + + store = self._get_store(context.account_id, context.region) + if grant_name and (grant_name, key_id) in store.grant_names: + grant = store.grants[store.grant_names[(grant_name, key_id)]] + else: + grant = KmsGrant(request, context.account_id, context.region) + grant_id = grant.metadata["GrantId"] + store.grants[grant_id] = grant + if grant_name: + store.grant_names[(grant_name, key_id)] = grant_id + store.grant_tokens[grant.token] = grant_id + + # At the moment we do not support multiple GrantTokens for grant creation request. Instead, we always use + # the same token. For the reference, AWS documentation says: + # https://docs.aws.amazon.com/kms/latest/APIReference/API_CreateGrant.html#API_CreateGrant_RequestParameters + # "The returned grant token is unique with every CreateGrant request, even when a duplicate GrantId is + # returned". "A duplicate GrantId" refers to the idempotency of grant creation requests - if a request has + # "Name" field, and if such name already belongs to a previously created grant, no new grant gets created + # and the existing grant with the name is returned. + return CreateGrantResponse(GrantId=grant.metadata["GrantId"], GrantToken=grant.token) + + @handler("ListGrants", expand=False) + def list_grants( + self, context: RequestContext, request: ListGrantsRequest + ) -> ListGrantsResponse: + if not request.get("KeyId"): + raise ValidationError("Required input parameter KeyId not specified") + key_account_id, key_region_name, _ = self._parse_key_id(request["KeyId"], context) + # KeyId can potentially hold one of multiple different types of key identifiers. Here we find a key no + # matter which type of id is used. + key = self._get_kms_key( + key_account_id, key_region_name, request.get("KeyId"), any_key_state_allowed=True + ) + key_id = key.metadata.get("KeyId") + + store = self._get_store(context.account_id, context.region) + grant_id = request.get("GrantId") + if grant_id: + if grant_id not in store.grants: + raise InvalidGrantIdException() + return ListGrantsResponse(Grants=[store.grants[grant_id].metadata]) + + matching_grants = [] + grantee_principal = request.get("GranteePrincipal") + for grant in store.grants.values(): + # KeyId is a mandatory field of ListGrants request, so is going to be present. + _, _, grant_key_id = parse_key_arn(grant.metadata["KeyArn"]) + if grant_key_id != key_id: + continue + # GranteePrincipal is a mandatory field for CreateGrant, should be in grants. But it is an optional field + # for ListGrants, so might not be there. + if grantee_principal and grant.metadata["GranteePrincipal"] != grantee_principal: + continue + matching_grants.append(grant.metadata) + + grants_list = PaginatedList(matching_grants) + page, next_token = grants_list.get_page( + lambda grant_data: grant_data.get("GrantId"), + next_token=request.get("Marker"), + page_size=request.get("Limit", 50), + ) + kwargs = {"NextMarker": next_token, "Truncated": True} if next_token else {} + + return ListGrantsResponse(Grants=page, **kwargs) + + @staticmethod + def _delete_grant(store: KmsStore, grant_id: str, key_id: str): + grant = store.grants[grant_id] + + _, _, grant_key_id = parse_key_arn(grant.metadata.get("KeyArn")) + if key_id != grant_key_id: + raise ValidationError(f"Invalid KeyId={key_id} specified for grant {grant_id}") + + store.grant_tokens.pop(grant.token) + store.grant_names.pop((grant.metadata.get("Name"), key_id), None) + store.grants.pop(grant_id) + + def revoke_grant( + self, + context: RequestContext, + key_id: KeyIdType, + grant_id: GrantIdType, + dry_run: NullableBooleanType = None, + **kwargs, + ) -> None: + # TODO add support for "dry_run" + key_account_id, key_region_name, key_id = self._parse_key_id(key_id, context) + key = self._get_kms_key(key_account_id, key_region_name, key_id, any_key_state_allowed=True) + key_id = key.metadata.get("KeyId") + + store = self._get_store(context.account_id, context.region) + + if grant_id not in store.grants: + raise InvalidGrantIdException() + + self._delete_grant(store, grant_id, key_id) + + def retire_grant( + self, + context: RequestContext, + grant_token: GrantTokenType = None, + key_id: KeyIdType = None, + grant_id: GrantIdType = None, + dry_run: NullableBooleanType = None, + **kwargs, + ) -> None: + # TODO add support for "dry_run" + if not grant_token and (not grant_id or not key_id): + raise ValidationException("Grant token OR (grant ID, key ID) must be specified") + + if grant_token: + decoded_token = to_str(base64.b64decode(grant_token)) + grant_account_id, grant_region_name, _ = decoded_token.split(":") + grant_store = self._get_store(grant_account_id, grant_region_name) + + if grant_token not in grant_store.grant_tokens: + raise NotFoundException(f"Unable to find grant token {grant_token}") + + grant_id = grant_store.grant_tokens[grant_token] + else: + grant_store = self._get_store(context.account_id, context.region) + + if key_id: + key_account_id, key_region_name, key_id = self._parse_key_id(key_id, context) + key = self._get_kms_key( + key_account_id, key_region_name, key_id, any_key_state_allowed=True + ) + key_id = key.metadata.get("KeyId") + else: + _, _, key_id = parse_key_arn(grant_store.grants[grant_id].metadata.get("KeyArn")) + + self._delete_grant(grant_store, grant_id, key_id) + + def list_retirable_grants( + self, + context: RequestContext, + retiring_principal: PrincipalIdType, + limit: LimitType = None, + marker: MarkerType = None, + **kwargs, + ) -> ListGrantsResponse: + if not retiring_principal: + raise ValidationError("Required input parameter 'RetiringPrincipal' not specified") + + matching_grants = [ + grant.metadata + for grant in self._get_store(context.account_id, context.region).grants.values() + if grant.metadata.get("RetiringPrincipal") == retiring_principal + ] + grants_list = PaginatedList(matching_grants) + limit = limit or 50 + page, next_token = grants_list.get_page( + lambda grant_data: grant_data.get("GrantId"), + next_token=marker, + page_size=limit, + ) + kwargs = {"NextMarker": next_token, "Truncated": True} if next_token else {} + + return ListGrantsResponse(Grants=page, **kwargs) + + def get_public_key( + self, + context: RequestContext, + key_id: KeyIdType, + grant_tokens: GrantTokenList = None, + **kwargs, + ) -> GetPublicKeyResponse: + # According to https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html, GetPublicKey is supposed + # to fail for disabled keys. But it actually doesn't fail in AWS. + account_id, region_name, key_id = self._parse_key_id(key_id, context) + key = self._get_kms_key( + account_id, + region_name, + key_id, + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + attrs = [ + "KeySpec", + "KeyUsage", + "EncryptionAlgorithms", + "SigningAlgorithms", + ] + result = select_attributes(key.metadata, attrs) + result["PublicKey"] = key.crypto_key.public_key + result["KeyId"] = key.metadata["Arn"] + return GetPublicKeyResponse(**result) + + def _generate_data_key_pair( + self, + context: RequestContext, + key_id: str, + key_pair_spec: str, + encryption_context: EncryptionContextType = None, + dry_run: NullableBooleanType = None, + ): + account_id, region_name, key_id = self._parse_key_id(key_id, context) + key = self._get_kms_key(account_id, region_name, key_id) + self._validate_key_for_encryption_decryption(context, key) + KmsCryptoKey.assert_valid(key_pair_spec) + return execute_dry_run_capable( + self._build_data_key_pair_response, dry_run, key, key_pair_spec, encryption_context + ) + + def _build_data_key_pair_response( + self, key: KmsKey, key_pair_spec: str, encryption_context: EncryptionContextType = None + ): + crypto_key = KmsCryptoKey(key_pair_spec) + + return { + "KeyId": key.metadata["Arn"], + "KeyPairSpec": key_pair_spec, + "PrivateKeyCiphertextBlob": key.encrypt(crypto_key.private_key, encryption_context), + "PrivateKeyPlaintext": crypto_key.private_key, + "PublicKey": crypto_key.public_key, + } + + @handler("GenerateDataKeyPair") + def generate_data_key_pair( + self, + context: RequestContext, + key_id: KeyIdType, + key_pair_spec: DataKeyPairSpec, + encryption_context: EncryptionContextType = None, + grant_tokens: GrantTokenList = None, + recipient: RecipientInfo = None, + dry_run: NullableBooleanType = None, + **kwargs, + ) -> GenerateDataKeyPairResponse: + result = self._generate_data_key_pair( + context, key_id, key_pair_spec, encryption_context, dry_run + ) + return GenerateDataKeyPairResponse(**result) + + @handler("GenerateRandom", expand=False) + def generate_random( + self, context: RequestContext, request: GenerateRandomRequest + ) -> GenerateRandomResponse: + number_of_bytes = request.get("NumberOfBytes") + if number_of_bytes is None: + raise ValidationException("NumberOfBytes is required.") + if number_of_bytes > 1024: + raise ValidationException( + f"1 validation error detected: Value '{number_of_bytes}' at 'numberOfBytes' failed " + "to satisfy constraint: Member must have value less than or equal to 1024" + ) + if number_of_bytes < 1: + raise ValidationException( + f"1 validation error detected: Value '{number_of_bytes}' at 'numberOfBytes' failed " + "to satisfy constraint: Member must have value greater than or equal to 1" + ) + + byte_string = os.urandom(number_of_bytes) + + return GenerateRandomResponse(Plaintext=byte_string) + + @handler("GenerateDataKeyPairWithoutPlaintext") + def generate_data_key_pair_without_plaintext( + self, + context: RequestContext, + key_id: KeyIdType, + key_pair_spec: DataKeyPairSpec, + encryption_context: EncryptionContextType = None, + grant_tokens: GrantTokenList = None, + dry_run: NullableBooleanType = None, + **kwargs, + ) -> GenerateDataKeyPairWithoutPlaintextResponse: + result = self._generate_data_key_pair( + context, key_id, key_pair_spec, encryption_context, dry_run + ) + result.pop("PrivateKeyPlaintext") + return GenerateDataKeyPairResponse(**result) + + # We currently act on neither on KeySpec setting (which is different from and holds values different then + # KeySpec for CreateKey) nor on NumberOfBytes. Instead, we generate a key with a key length that is "standard" in + # LocalStack. + # + def _generate_data_key( + self, context: RequestContext, key_id: str, encryption_context: EncryptionContextType = None + ): + account_id, region_name, key_id = self._parse_key_id(key_id, context) + key = self._get_kms_key(account_id, region_name, key_id) + # TODO Should also have a validation for the key being a symmetric one. + self._validate_key_for_encryption_decryption(context, key) + crypto_key = KmsCryptoKey("SYMMETRIC_DEFAULT") + return { + "KeyId": key.metadata["Arn"], + "Plaintext": crypto_key.key_material, + "CiphertextBlob": key.encrypt(crypto_key.key_material, encryption_context), + } + + @handler("GenerateDataKey", expand=False) + def generate_data_key( + self, context: RequestContext, request: GenerateDataKeyRequest + ) -> GenerateDataKeyResponse: + result = self._generate_data_key( + context, request.get("KeyId"), request.get("EncryptionContext") + ) + return GenerateDataKeyResponse(**result) + + @handler("GenerateDataKeyWithoutPlaintext", expand=False) + def generate_data_key_without_plaintext( + self, context: RequestContext, request: GenerateDataKeyWithoutPlaintextRequest + ) -> GenerateDataKeyWithoutPlaintextResponse: + result = self._generate_data_key( + context, request.get("KeyId"), request.get("EncryptionContext") + ) + result.pop("Plaintext") + return GenerateDataKeyWithoutPlaintextResponse(**result) + + @handler("GenerateMac", expand=False) + def generate_mac( + self, + context: RequestContext, + request: GenerateMacRequest, + ) -> GenerateMacResponse: + msg = request.get("Message") + self._validate_mac_msg_length(msg) + + account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(account_id, region_name, key_id) + + self._validate_key_for_generate_verify_mac(context, key) + + algorithm = request.get("MacAlgorithm") + self._validate_mac_algorithm(key, algorithm) + + mac = key.generate_mac(msg, algorithm) + + return GenerateMacResponse(Mac=mac, MacAlgorithm=algorithm, KeyId=key.metadata.get("Arn")) + + @handler("VerifyMac", expand=False) + def verify_mac( + self, + context: RequestContext, + request: VerifyMacRequest, + ) -> VerifyMacResponse: + msg = request.get("Message") + self._validate_mac_msg_length(msg) + + account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(account_id, region_name, key_id) + + self._validate_key_for_generate_verify_mac(context, key) + + algorithm = request.get("MacAlgorithm") + self._validate_mac_algorithm(key, algorithm) + + mac_valid = key.verify_mac(msg, request.get("Mac"), algorithm) + + return VerifyMacResponse( + KeyId=key.metadata.get("Arn"), MacValid=mac_valid, MacAlgorithm=algorithm + ) + + @handler("Sign", expand=False) + def sign(self, context: RequestContext, request: SignRequest) -> SignResponse: + account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(account_id, region_name, key_id) + + self._validate_key_for_sign_verify(context, key) + + # TODO Add constraints on KeySpec / SigningAlgorithm pairs: + # https://docs.aws.amazon.com/kms/latest/developerguide/asymmetric-key-specs.html#key-spec-ecc + + signing_algorithm = request.get("SigningAlgorithm") + signature = key.sign(request.get("Message"), request.get("MessageType"), signing_algorithm) + + result = { + "KeyId": key.metadata["Arn"], + "Signature": signature, + "SigningAlgorithm": signing_algorithm, + } + return SignResponse(**result) + + # Currently LocalStack only calculates SHA256 digests no matter what the signing algorithm is. + @handler("Verify", expand=False) + def verify(self, context: RequestContext, request: VerifyRequest) -> VerifyResponse: + account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(account_id, region_name, key_id) + + self._validate_key_for_sign_verify(context, key) + + signing_algorithm = request.get("SigningAlgorithm") + is_signature_valid = key.verify( + request.get("Message"), + request.get("MessageType"), + signing_algorithm, + request.get("Signature"), + ) + + result = { + "KeyId": key.metadata["Arn"], + "SignatureValid": is_signature_valid, + "SigningAlgorithm": signing_algorithm, + } + return VerifyResponse(**result) + + def re_encrypt( + self, + context: RequestContext, + ciphertext_blob: CiphertextType, + destination_key_id: KeyIdType, + source_encryption_context: EncryptionContextType = None, + source_key_id: KeyIdType = None, + destination_encryption_context: EncryptionContextType = None, + source_encryption_algorithm: EncryptionAlgorithmSpec = None, + destination_encryption_algorithm: EncryptionAlgorithmSpec = None, + grant_tokens: GrantTokenList = None, + dry_run: NullableBooleanType = None, + **kwargs, + ) -> ReEncryptResponse: + # TODO: when implementing, ensure cross-account support for source_key_id and destination_key_id + # Parse and fetch source Key + account_id, region_name, source_key_id = self._parse_key_id(source_key_id, context) + source_key = self._get_kms_key(account_id, region_name, source_key_id) + # Decrypt using source key + decrypt_response = self.decrypt( + context=context, + ciphertext_blob=ciphertext_blob, + encryption_context=source_encryption_context, + encryption_algorithm=source_encryption_algorithm, + key_id=source_key_id, + grant_tokens=grant_tokens, + ) + # Parse and fetch destination key + account_id, region_name, destination_key_id = self._parse_key_id( + destination_key_id, context + ) + destination_key = self._get_kms_key(account_id, region_name, destination_key_id) + # Encrypt using destination key + encrypt_response = self.encrypt( + context=context, + encryption_context=destination_encryption_context, + key_id=destination_key_id, + plaintext=decrypt_response["Plaintext"], + grant_tokens=grant_tokens, + dry_run=dry_run, + ) + return ReEncryptResponse( + CiphertextBlob=encrypt_response["CiphertextBlob"], + SourceKeyId=source_key.metadata.get("Arn"), + KeyId=destination_key.metadata.get("Arn"), + SourceEncryptionAlgorithm=source_encryption_algorithm, + DestinationEncryptionAlgorithm=destination_encryption_algorithm, + ) + + def encrypt( + self, + context: RequestContext, + key_id: KeyIdType, + plaintext: PlaintextType, + encryption_context: EncryptionContextType = None, + grant_tokens: GrantTokenList = None, + encryption_algorithm: EncryptionAlgorithmSpec = None, + dry_run: NullableBooleanType = None, + **kwargs, + ) -> EncryptResponse: + # TODO add support for "dry_run" + account_id, region_name, key_id = self._parse_key_id(key_id, context) + key = self._get_kms_key(account_id, region_name, key_id) + self._validate_plaintext_length(plaintext) + self._validate_plaintext_key_type_based(plaintext, key, encryption_algorithm) + self._validate_key_for_encryption_decryption(context, key) + self._validate_key_state_not_pending_import(key) + + ciphertext_blob = key.encrypt(plaintext, encryption_context) + # For compatibility, we return EncryptionAlgorithm values expected from AWS. But LocalStack currently always + # encrypts with symmetric encryption no matter the key settings. + return EncryptResponse( + CiphertextBlob=ciphertext_blob, + KeyId=key.metadata.get("Arn"), + EncryptionAlgorithm=encryption_algorithm, + ) + + # TODO We currently do not even check encryption_context, while moto does. Should add the corresponding logic later. + def decrypt( + self, + context: RequestContext, + ciphertext_blob: CiphertextType, + encryption_context: EncryptionContextType = None, + grant_tokens: GrantTokenList = None, + key_id: KeyIdType = None, + encryption_algorithm: EncryptionAlgorithmSpec = None, + recipient: RecipientInfo = None, + dry_run: NullableBooleanType = None, + **kwargs, + ) -> DecryptResponse: + # In AWS, key_id is only supplied for data encrypted with an asymmetrical algorithm. For symmetrical + # encryption, key_id is taken from the encrypted data itself. + # Since LocalStack doesn't currently do asymmetrical encryption, there is a question of modeling here: we + # currently expect data to be only encrypted with symmetric encryption, so having key_id inside. It might not + # always be what customers expect. + if key_id: + account_id, region_name, key_id = self._parse_key_id(key_id, context) + try: + ciphertext = deserialize_ciphertext_blob(ciphertext_blob=ciphertext_blob) + except Exception: + ciphertext = None + pass + else: + try: + ciphertext = deserialize_ciphertext_blob(ciphertext_blob=ciphertext_blob) + account_id, region_name, key_id = self._parse_key_id(ciphertext.key_id, context) + except Exception: + raise InvalidCiphertextException( + "LocalStack is unable to deserialize the ciphertext blob. Perhaps the " + "blob didn't come from LocalStack" + ) + + key = self._get_kms_key(account_id, region_name, key_id) + if ciphertext and key.metadata["KeyId"] != ciphertext.key_id: + raise IncorrectKeyException( + "The key ID in the request does not identify a CMK that can perform this operation." + ) + + self._validate_key_for_encryption_decryption(context, key) + self._validate_key_state_not_pending_import(key) + + try: + # TODO: Extend the implementation to handle additional encryption/decryption scenarios + # beyond the current support for offline encryption and online decryption using RSA keys if key id exists in + # parameters, where `ciphertext_blob` will not be deserializable. + if self._is_rsa_spec(key.crypto_key.key_spec) and not ciphertext: + plaintext = key.decrypt_rsa(ciphertext_blob) + else: + plaintext = key.decrypt(ciphertext, encryption_context) + except InvalidTag: + raise InvalidCiphertextException() + # For compatibility, we return EncryptionAlgorithm values expected from AWS. But LocalStack currently always + # encrypts with symmetric encryption no matter the key settings. + # + # We return a key ARN instead of KeyId despite the name of the parameter, as this is what AWS does and states + # in its docs. + # TODO add support for "recipient" + # https://docs.aws.amazon.com/kms/latest/APIReference/API_Decrypt.html#API_Decrypt_RequestSyntax + # TODO add support for "dry_run" + return DecryptResponse( + KeyId=key.metadata.get("Arn"), + Plaintext=plaintext, + EncryptionAlgorithm=encryption_algorithm, + ) + + def get_parameters_for_import( + self, + context: RequestContext, + key_id: KeyIdType, + wrapping_algorithm: AlgorithmSpec, + wrapping_key_spec: WrappingKeySpec, + **kwargs, + ) -> GetParametersForImportResponse: + store = self._get_store(context.account_id, context.region) + # KeyId can potentially hold one of multiple different types of key identifiers. get_key finds a key no + # matter which type of id is used. + key_to_import_material_to = self._get_kms_key( + context.account_id, + context.region, + key_id, + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key_arn = key_to_import_material_to.metadata["Arn"] + key_origin = key_to_import_material_to.metadata.get("Origin") + + if key_origin != "EXTERNAL": + raise UnsupportedOperationException( + f"{key_arn} origin is {key_origin} which is not valid for this operation." + ) + + key_id = key_to_import_material_to.metadata["KeyId"] + + key = KmsKey(CreateKeyRequest(KeySpec=wrapping_key_spec)) + import_token = short_uid() + import_state = KeyImportState( + key_id=key_id, import_token=import_token, wrapping_algo=wrapping_algorithm, key=key + ) + store.imports[import_token] = import_state + # https://docs.aws.amazon.com/kms/latest/APIReference/API_GetParametersForImport.html + # "To import key material, you must use the public key and import token from the same response. These items + # are valid for 24 hours." + expiry_date = datetime.datetime.now() + datetime.timedelta(days=100) + return GetParametersForImportResponse( + KeyId=key_to_import_material_to.metadata["Arn"], + ImportToken=to_bytes(import_state.import_token), + PublicKey=import_state.key.crypto_key.public_key, + ParametersValidTo=expiry_date, + ) + + def import_key_material( + self, + context: RequestContext, + key_id: KeyIdType, + import_token: CiphertextType, + encrypted_key_material: CiphertextType, + valid_to: DateType | None = None, + expiration_model: ExpirationModelType | None = None, + import_type: ImportType | None = None, + key_material_description: KeyMaterialDescriptionType | None = None, + key_material_id: BackingKeyIdType | None = None, + **kwargs, + ) -> ImportKeyMaterialResponse: + store = self._get_store(context.account_id, context.region) + import_token = to_str(import_token) + import_state = store.imports.get(import_token) + if not import_state: + raise NotFoundException(f"Unable to find key import token '{import_token}'") + key_to_import_material_to = self._get_kms_key( + context.account_id, + context.region, + key_id, + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + + if import_state.wrapping_algo == AlgorithmSpec.RSAES_PKCS1_V1_5: + decrypt_padding = padding.PKCS1v15() + elif import_state.wrapping_algo == AlgorithmSpec.RSAES_OAEP_SHA_1: + decrypt_padding = padding.OAEP(padding.MGF1(hashes.SHA1()), hashes.SHA1(), None) + elif import_state.wrapping_algo == AlgorithmSpec.RSAES_OAEP_SHA_256: + decrypt_padding = padding.OAEP(padding.MGF1(hashes.SHA256()), hashes.SHA256(), None) + else: + raise KMSInvalidStateException( + f"Unsupported padding, requested wrapping algorithm:'{import_state.wrapping_algo}'" + ) + + # TODO check if there was already a key imported for this kms key + # if so, it has to be identical. We cannot change keys by reimporting after deletion/expiry + key_material = import_state.key.crypto_key.key.decrypt( + encrypted_key_material, decrypt_padding + ) + if expiration_model: + key_to_import_material_to.metadata["ExpirationModel"] = expiration_model + else: + key_to_import_material_to.metadata["ExpirationModel"] = ( + ExpirationModelType.KEY_MATERIAL_EXPIRES + ) + if ( + key_to_import_material_to.metadata["ExpirationModel"] + == ExpirationModelType.KEY_MATERIAL_EXPIRES + and not valid_to + ): + raise ValidationException( + "A validTo date must be set if the ExpirationModel is KEY_MATERIAL_EXPIRES" + ) + # TODO actually set validTo and make the key expire + key_to_import_material_to.metadata["Enabled"] = True + key_to_import_material_to.metadata["KeyState"] = KeyState.Enabled + key_to_import_material_to.crypto_key.load_key_material(key_material) + + return ImportKeyMaterialResponse() + + def delete_imported_key_material( + self, + context: RequestContext, + key_id: KeyIdType, + key_material_id: BackingKeyIdType | None = None, + **kwargs, + ) -> DeleteImportedKeyMaterialResponse: + # TODO add support for key_material_id + key = self._get_kms_key( + context.account_id, + context.region, + key_id, + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key.crypto_key.key_material = None + key.metadata["Enabled"] = False + key.metadata["KeyState"] = KeyState.PendingImport + key.metadata.pop("ExpirationModel", None) + + # TODO populate DeleteImportedKeyMaterialResponse + return DeleteImportedKeyMaterialResponse() + + @handler("CreateAlias", expand=False) + def create_alias(self, context: RequestContext, request: CreateAliasRequest) -> None: + store = self._get_store(context.account_id, context.region) + alias_name = request["AliasName"] + validate_alias_name(alias_name) + if alias_name in store.aliases: + alias_arn = store.aliases.get(alias_name).metadata["AliasArn"] + # AWS itself uses AliasArn instead of AliasName in this exception. + raise AlreadyExistsException(f"An alias with the name {alias_arn} already exists") + # KeyId can potentially hold one of multiple different types of key identifiers. Here we find a key no + # matter which type of id is used. + key = self._get_kms_key( + context.account_id, + context.region, + request.get("TargetKeyId"), + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + request["TargetKeyId"] = key.metadata.get("KeyId") + self._create_kms_alias(context.account_id, context.region, request) + + @handler("DeleteAlias", expand=False) + def delete_alias(self, context: RequestContext, request: DeleteAliasRequest) -> None: + # We do not check the state of the key, as, according to AWS docs, all key states, that are possible in + # LocalStack, are supported by this operation. + store = self._get_store(context.account_id, context.region) + alias_name = request["AliasName"] + if alias_name not in store.aliases: + alias_arn = kms_alias_arn(request["AliasName"], context.account_id, context.region) + # AWS itself uses AliasArn instead of AliasName in this exception. + raise NotFoundException(f"Alias {alias_arn} is not found") + store.aliases.pop(alias_name, None) + + @handler("UpdateAlias", expand=False) + def update_alias(self, context: RequestContext, request: UpdateAliasRequest) -> None: + # https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html + # "If the source KMS key is pending deletion, the command succeeds. If the destination KMS key is pending + # deletion, the command fails with error: KMSInvalidStateException : is pending deletion." + # Also disabled keys are accepted for this operation (see the table on that page). + # + # As such, we do not care about the state of the source key, but check the destination one. + + alias_name = request["AliasName"] + # This API, per AWS docs, accepts only names, not ARNs. + validate_alias_name(alias_name) + alias = self._get_kms_alias(context.account_id, context.region, alias_name) + key_id = request["TargetKeyId"] + # Don't care about the key itself, just want to validate its state. + self._get_kms_key( + context.account_id, + context.region, + key_id, + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + alias.metadata["TargetKeyId"] = key_id + alias.update_date_of_last_update() + + @handler("ListAliases") + def list_aliases( + self, + context: RequestContext, + key_id: KeyIdType = None, + limit: LimitType = None, + marker: MarkerType = None, + **kwargs, + ) -> ListAliasesResponse: + store = self._get_store(context.account_id, context.region) + if key_id: + # KeyId can potentially hold one of multiple different types of key identifiers. Here we find a key no + # matter which type of id is used. + key = self._get_kms_key( + context.account_id, context.region, key_id, any_key_state_allowed=True + ) + key_id = key.metadata.get("KeyId") + + matching_aliases = [] + for alias in store.aliases.values(): + if key_id and alias.metadata["TargetKeyId"] != key_id: + continue + matching_aliases.append(alias.metadata) + aliases_list = PaginatedList(matching_aliases) + limit = limit or 100 + page, next_token = aliases_list.get_page( + lambda alias_metadata: alias_metadata.get("AliasName"), + next_token=marker, + page_size=limit, + ) + kwargs = {"NextMarker": next_token, "Truncated": True} if next_token else {} + return ListAliasesResponse(Aliases=page, **kwargs) + + @handler("GetKeyRotationStatus", expand=False) + def get_key_rotation_status( + self, context: RequestContext, request: GetKeyRotationStatusRequest + ) -> GetKeyRotationStatusResponse: + # https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html + # "If the KMS key has imported key material or is in a custom key store: UnsupportedOperationException." + # We do not model that here, though. + account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(account_id, region_name, key_id, any_key_state_allowed=True) + + response = GetKeyRotationStatusResponse( + KeyId=key_id, + KeyRotationEnabled=key.is_key_rotation_enabled, + NextRotationDate=key.next_rotation_date, + ) + if key.is_key_rotation_enabled: + response["RotationPeriodInDays"] = key.rotation_period_in_days + + return response + + @handler("DisableKeyRotation", expand=False) + def disable_key_rotation( + self, context: RequestContext, request: DisableKeyRotationRequest + ) -> None: + # https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html + # "If the KMS key has imported key material or is in a custom key store: UnsupportedOperationException." + # We do not model that here, though. + key = self._get_kms_key(context.account_id, context.region, request.get("KeyId")) + key.is_key_rotation_enabled = False + + @handler("EnableKeyRotation", expand=False) + def enable_key_rotation( + self, context: RequestContext, request: EnableKeyRotationRequest + ) -> None: + # https://docs.aws.amazon.com/kms/latest/developerguide/key-state.html + # "If the KMS key has imported key material or is in a custom key store: UnsupportedOperationException." + # We do not model that here, though. + key = self._get_kms_key(context.account_id, context.region, request.get("KeyId")) + key.is_key_rotation_enabled = True + if request.get("RotationPeriodInDays"): + key.rotation_period_in_days = request.get("RotationPeriodInDays") + key._update_key_rotation_date() + + @handler("ListKeyPolicies", expand=False) + def list_key_policies( + self, context: RequestContext, request: ListKeyPoliciesRequest + ) -> ListKeyPoliciesResponse: + # We just care if the key exists. The response, by AWS specifications, is the same for all keys, as the only + # supported policy is "default": + # https://docs.aws.amazon.com/kms/latest/APIReference/API_ListKeyPolicies.html#API_ListKeyPolicies_ResponseElements + self._get_kms_key( + context.account_id, context.region, request.get("KeyId"), any_key_state_allowed=True + ) + return ListKeyPoliciesResponse(PolicyNames=["default"], Truncated=False) + + @handler("PutKeyPolicy", expand=False) + def put_key_policy(self, context: RequestContext, request: PutKeyPolicyRequest) -> None: + key = self._get_kms_key( + context.account_id, context.region, request.get("KeyId"), any_key_state_allowed=True + ) + if request.get("PolicyName") != "default": + raise UnsupportedOperationException("Only default policy is supported") + key.policy = request.get("Policy") + + @handler("GetKeyPolicy", expand=False) + def get_key_policy( + self, context: RequestContext, request: GetKeyPolicyRequest + ) -> GetKeyPolicyResponse: + key = self._get_kms_key( + context.account_id, context.region, request.get("KeyId"), any_key_state_allowed=True + ) + if request.get("PolicyName") != "default": + raise NotFoundException("No such policy exists") + return GetKeyPolicyResponse(Policy=key.policy) + + @handler("ListResourceTags", expand=False) + def list_resource_tags( + self, context: RequestContext, request: ListResourceTagsRequest + ) -> ListResourceTagsResponse: + key = self._get_kms_key( + context.account_id, context.region, request.get("KeyId"), any_key_state_allowed=True + ) + keys_list = PaginatedList( + [{"TagKey": tag_key, "TagValue": tag_value} for tag_key, tag_value in key.tags.items()] + ) + page, next_token = keys_list.get_page( + lambda tag: tag.get("TagKey"), + next_token=request.get("Marker"), + page_size=request.get("Limit", 50), + ) + kwargs = {"NextMarker": next_token, "Truncated": True} if next_token else {} + return ListResourceTagsResponse(Tags=page, **kwargs) + + @handler("RotateKeyOnDemand", expand=False) + # TODO: return the key rotations in the ListKeyRotations operation + def rotate_key_on_demand( + self, context: RequestContext, request: RotateKeyOnDemandRequest + ) -> RotateKeyOnDemandResponse: + account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context) + key = self._get_kms_key(account_id, region_name, key_id) + + if key.metadata["KeySpec"] != KeySpec.SYMMETRIC_DEFAULT: + raise UnsupportedOperationException() + if key.metadata["Origin"] == OriginType.EXTERNAL: + raise UnsupportedOperationException( + f"{key.metadata['Arn']} origin is EXTERNAL which is not valid for this operation." + ) + + key.rotate_key_on_demand() + + return RotateKeyOnDemandResponse( + KeyId=key_id, + ) + + @handler("TagResource", expand=False) + def tag_resource(self, context: RequestContext, request: TagResourceRequest) -> None: + key = self._get_kms_key( + context.account_id, + context.region, + request.get("KeyId"), + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key.add_tags(request.get("Tags")) + + @handler("UntagResource", expand=False) + def untag_resource(self, context: RequestContext, request: UntagResourceRequest) -> None: + key = self._get_kms_key( + context.account_id, + context.region, + request.get("KeyId"), + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + if not request.get("TagKeys"): + return + for tag_key in request.get("TagKeys"): + # AWS doesn't seem to mind removal of a non-existent tag, so we do not raise any exception. + key.tags.pop(tag_key, None) + + def derive_shared_secret( + self, + context: RequestContext, + key_id: KeyIdType, + key_agreement_algorithm: KeyAgreementAlgorithmSpec, + public_key: PublicKeyType, + grant_tokens: GrantTokenList = None, + dry_run: NullableBooleanType = None, + recipient: RecipientInfo = None, + **kwargs, + ) -> DeriveSharedSecretResponse: + key = self._get_kms_key( + context.account_id, + context.region, + key_id, + enabled_key_allowed=True, + disabled_key_allowed=True, + ) + key_usage = key.metadata.get("KeyUsage") + key_origin = key.metadata.get("Origin") + + if key_usage != KeyUsageType.KEY_AGREEMENT: + raise InvalidKeyUsageException( + f"{key.metadata['Arn']} key usage is {key_usage} which is not valid for {context.operation.name}." + ) + + if key_agreement_algorithm != KeyAgreementAlgorithmSpec.ECDH: + raise ValidationException( + f"1 validation error detected: Value '{key_agreement_algorithm}' at 'keyAgreementAlgorithm' " + f"failed to satisfy constraint: Member must satisfy enum value set: [ECDH]" + ) + + # TODO: Verify the actual error raised + if key_origin not in [OriginType.AWS_KMS, OriginType.EXTERNAL]: + raise ValueError(f"Key origin: {key_origin} is not valid for {context.operation.name}.") + + shared_secret = key.derive_shared_secret(public_key) + return DeriveSharedSecretResponse( + KeyId=key_id, + SharedSecret=shared_secret, + KeyAgreementAlgorithm=key_agreement_algorithm, + KeyOrigin=key_origin, + ) + + def _validate_key_state_not_pending_import(self, key: KmsKey): + if key.metadata["KeyState"] == KeyState.PendingImport: + raise KMSInvalidStateException(f"{key.metadata['Arn']} is pending import.") + + def _validate_key_for_encryption_decryption(self, context: RequestContext, key: KmsKey): + key_usage = key.metadata["KeyUsage"] + if key_usage != "ENCRYPT_DECRYPT": + raise InvalidKeyUsageException( + f"{key.metadata['Arn']} key usage is {key_usage} which is not valid for {context.operation.name}." + ) + + def _validate_key_for_sign_verify(self, context: RequestContext, key: KmsKey): + key_usage = key.metadata["KeyUsage"] + if key_usage != "SIGN_VERIFY": + raise InvalidKeyUsageException( + f"{key.metadata['Arn']} key usage is {key_usage} which is not valid for {context.operation.name}." + ) + + def _validate_key_for_generate_verify_mac(self, context: RequestContext, key: KmsKey): + key_usage = key.metadata["KeyUsage"] + if key_usage != "GENERATE_VERIFY_MAC": + raise InvalidKeyUsageException( + f"{key.metadata['Arn']} key usage is {key_usage} which is not valid for {context.operation.name}." + ) + + def _validate_mac_msg_length(self, msg: bytes): + if len(msg) > 4096: + raise ValidationException( + "1 validation error detected: Value at 'message' failed to satisfy constraint: " + "Member must have length less than or equal to 4096" + ) + + def _validate_mac_algorithm(self, key: KmsKey, algorithm: str): + if not hasattr(MacAlgorithmSpec, algorithm): + raise ValidationException( + f"1 validation error detected: Value '{algorithm}' at 'macAlgorithm' " + f"failed to satisfy constraint: Member must satisfy enum value set: " + f"[HMAC_SHA_384, HMAC_SHA_256, HMAC_SHA_224, HMAC_SHA_512]" + ) + + key_spec = key.metadata["KeySpec"] + if x := algorithm.split("_"): + if len(x) == 3 and x[0] + "_" + x[2] != key_spec: + raise InvalidKeyUsageException( + f"Algorithm {algorithm} is incompatible with key spec {key_spec}." + ) + + def _validate_plaintext_length(self, plaintext: bytes): + if len(plaintext) > 4096: + raise ValidationException( + "1 validation error detected: Value at 'plaintext' failed to satisfy constraint: " + "Member must have length less than or equal to 4096" + ) + + def _validate_grant_request(self, data: Dict): + if "KeyId" not in data or "GranteePrincipal" not in data or "Operations" not in data: + raise ValidationError("Grant ID, key ID and grantee principal must be specified") + + for operation in data["Operations"]: + if operation not in VALID_OPERATIONS: + raise ValidationError( + f"Value {['Operations']} at 'operations' failed to satisfy constraint: Member must satisfy" + f" constraint: [Member must satisfy enum value set: {VALID_OPERATIONS}]" + ) + + def _validate_plaintext_key_type_based( + self, + plaintext: PlaintextType, + key: KmsKey, + encryption_algorithm: EncryptionAlgorithmSpec = None, + ): + # max size values extracted from AWS boto3 documentation + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/kms/client/encrypt.html + max_size_bytes = 4096 # max allowed size + if ( + key.metadata["KeySpec"] == KeySpec.RSA_2048 + and encryption_algorithm == EncryptionAlgorithmSpec.RSAES_OAEP_SHA_1 + ): + max_size_bytes = 214 + elif ( + key.metadata["KeySpec"] == KeySpec.RSA_2048 + and encryption_algorithm == EncryptionAlgorithmSpec.RSAES_OAEP_SHA_256 + ): + max_size_bytes = 190 + elif ( + key.metadata["KeySpec"] == KeySpec.RSA_3072 + and encryption_algorithm == EncryptionAlgorithmSpec.RSAES_OAEP_SHA_1 + ): + max_size_bytes = 342 + elif ( + key.metadata["KeySpec"] == KeySpec.RSA_3072 + and encryption_algorithm == EncryptionAlgorithmSpec.RSAES_OAEP_SHA_256 + ): + max_size_bytes = 318 + elif ( + key.metadata["KeySpec"] == KeySpec.RSA_4096 + and encryption_algorithm == EncryptionAlgorithmSpec.RSAES_OAEP_SHA_1 + ): + max_size_bytes = 470 + elif ( + key.metadata["KeySpec"] == KeySpec.RSA_4096 + and encryption_algorithm == EncryptionAlgorithmSpec.RSAES_OAEP_SHA_256 + ): + max_size_bytes = 446 + + if len(plaintext) > max_size_bytes: + raise ValidationException( + f"Algorithm {encryption_algorithm} and key spec {key.metadata['KeySpec']} cannot encrypt data larger than {max_size_bytes} bytes." + ) + + +# --------------- +# UTIL FUNCTIONS +# --------------- + +# Different AWS services have some internal integrations with KMS. Some create keys, that are used to encrypt/decrypt +# customer's data. Such keys can't be created from outside for security reasons. So AWS services use some internal +# APIs to do that. Functions here are supposed to be used by other LocalStack services to have similar integrations +# with KMS in LocalStack. As such, they are supposed to be proper APIs (as in error and security handling), +# just with more features. + + +def set_key_managed(key_id: str, account_id: str, region_name: str) -> None: + key = KmsProvider._get_kms_key(account_id, region_name, key_id) + key.metadata["KeyManager"] = "AWS" diff --git a/localstack-core/localstack/services/kms/resource_providers/__init__.py b/localstack-core/localstack/services/kms/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias.py b/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias.py new file mode 100644 index 0000000000000..81ecef65ca520 --- /dev/null +++ b/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias.py @@ -0,0 +1,105 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class KMSAliasProperties(TypedDict): + AliasName: Optional[str] + TargetKeyId: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class KMSAliasProvider(ResourceProvider[KMSAliasProperties]): + TYPE = "AWS::KMS::Alias" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[KMSAliasProperties], + ) -> ProgressEvent[KMSAliasProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/AliasName + + Required properties: + - AliasName + - TargetKeyId + + Create-only properties: + - /properties/AliasName + + + + IAM permissions required: + - kms:CreateAlias + + """ + model = request.desired_state + kms = request.aws_client_factory.kms + + kms.create_alias(AliasName=model["AliasName"], TargetKeyId=model["TargetKeyId"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[KMSAliasProperties], + ) -> ProgressEvent[KMSAliasProperties]: + """ + Fetch resource information + + IAM permissions required: + - kms:ListAliases + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[KMSAliasProperties], + ) -> ProgressEvent[KMSAliasProperties]: + """ + Delete a resource + + IAM permissions required: + - kms:DeleteAlias + """ + model = request.desired_state + kms = request.aws_client_factory.kms + + kms.delete_alias(AliasName=model["AliasName"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[KMSAliasProperties], + ) -> ProgressEvent[KMSAliasProperties]: + """ + Update a resource + + IAM permissions required: + - kms:UpdateAlias + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias.schema.json b/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias.schema.json new file mode 100644 index 0000000000000..e3eb5a1591f1d --- /dev/null +++ b/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias.schema.json @@ -0,0 +1,61 @@ +{ + "typeName": "AWS::KMS::Alias", + "description": "The AWS::KMS::Alias resource specifies a display name for an AWS KMS key in AWS Key Management Service (AWS KMS). You can use an alias to identify an AWS KMS key in cryptographic operations.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "properties": { + "AliasName": { + "description": "Specifies the alias name. This value must begin with alias/ followed by a name, such as alias/ExampleAlias. The alias name cannot begin with alias/aws/. The alias/aws/ prefix is reserved for AWS managed keys.", + "type": "string", + "pattern": "^(alias/)[a-zA-Z0-9:/_-]+$", + "minLength": 1, + "maxLength": 256 + }, + "TargetKeyId": { + "description": "Identifies the AWS KMS key to which the alias refers. Specify the key ID or the Amazon Resource Name (ARN) of the AWS KMS key. You cannot specify another alias. For help finding the key ID and ARN, see Finding the Key ID and ARN in the AWS Key Management Service Developer Guide.", + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "additionalProperties": false, + "required": [ + "AliasName", + "TargetKeyId" + ], + "createOnlyProperties": [ + "/properties/AliasName" + ], + "primaryIdentifier": [ + "/properties/AliasName" + ], + "tagging": { + "taggable": false + }, + "handlers": { + "create": { + "permissions": [ + "kms:CreateAlias" + ] + }, + "read": { + "permissions": [ + "kms:ListAliases" + ] + }, + "update": { + "permissions": [ + "kms:UpdateAlias" + ] + }, + "delete": { + "permissions": [ + "kms:DeleteAlias" + ] + }, + "list": { + "permissions": [ + "kms:ListAliases" + ] + } + } +} diff --git a/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias_plugin.py b/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias_plugin.py new file mode 100644 index 0000000000000..172d4915576ce --- /dev/null +++ b/localstack-core/localstack/services/kms/resource_providers/aws_kms_alias_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class KMSAliasProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::KMS::Alias" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.kms.resource_providers.aws_kms_alias import KMSAliasProvider + + self.factory = KMSAliasProvider diff --git a/localstack-core/localstack/services/kms/resource_providers/aws_kms_key.py b/localstack-core/localstack/services/kms/resource_providers/aws_kms_key.py new file mode 100644 index 0000000000000..6228292ed2953 --- /dev/null +++ b/localstack-core/localstack/services/kms/resource_providers/aws_kms_key.py @@ -0,0 +1,190 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class KMSKeyProperties(TypedDict): + KeyPolicy: Optional[dict | str] + Arn: Optional[str] + Description: Optional[str] + EnableKeyRotation: Optional[bool] + Enabled: Optional[bool] + KeyId: Optional[str] + KeySpec: Optional[str] + KeyUsage: Optional[str] + MultiRegion: Optional[bool] + PendingWindowInDays: Optional[int] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class KMSKeyProvider(ResourceProvider[KMSKeyProperties]): + TYPE = "AWS::KMS::Key" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[KMSKeyProperties], + ) -> ProgressEvent[KMSKeyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/KeyId + + Required properties: + - KeyPolicy + + + + Read-only properties: + - /properties/Arn + - /properties/KeyId + + IAM permissions required: + - kms:CreateKey + - kms:EnableKeyRotation + - kms:DisableKey + - kms:TagResource + + """ + model = request.desired_state + kms = request.aws_client_factory.kms + + params = util.select_attributes(model, ["Description", "KeySpec", "KeyUsage"]) + + if model.get("KeyPolicy"): + params["Policy"] = json.dumps(model["KeyPolicy"]) + + if model.get("Tags"): + params["Tags"] = [ + {"TagKey": tag["Key"], "TagValue": tag["Value"]} for tag in model.get("Tags", []) + ] + response = kms.create_key(**params) + model["KeyId"] = response["KeyMetadata"]["KeyId"] + model["Arn"] = response["KeyMetadata"]["Arn"] + + # key is created but some fields map to separate api calls + if model.get("EnableKeyRotation", False): + kms.enable_key_rotation(KeyId=model["KeyId"]) + else: + kms.disable_key_rotation(KeyId=model["KeyId"]) + + if model.get("Enabled", True): + kms.enable_key(KeyId=model["KeyId"]) + else: + kms.disable_key(KeyId=model["KeyId"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[KMSKeyProperties], + ) -> ProgressEvent[KMSKeyProperties]: + """ + Fetch resource information + + IAM permissions required: + - kms:DescribeKey + - kms:GetKeyPolicy + - kms:GetKeyRotationStatus + - kms:ListResourceTags + """ + kms = request.aws_client_factory.kms + key_id = request.desired_state["KeyId"] + + key = kms.describe_key(KeyId=key_id) + + policy = kms.get_key_policy(KeyId=key_id, PolicyName="default") + rotation_status = kms.get_key_rotation_status(KeyId=key_id) + tags = kms.list_resource_tags(KeyId=key_id) + + model = util.select_attributes(key["KeyMetadata"], self.SCHEMA["properties"]) + model["KeyPolicy"] = json.loads(policy["Policy"]) + model["EnableKeyRotation"] = rotation_status["KeyRotationEnabled"] + # Super consistent api... KMS api does return TagKey/TagValue, but the CC api transforms it to Key/Value + # It migth be worth noting if there are more apis for which CC does it again + model["Tags"] = [{"Key": tag["TagKey"], "Value": tag["TagValue"]} for tag in tags["Tags"]] + + if "Origin" not in model: + model["Origin"] = "AWS_KMS" + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def delete( + self, + request: ResourceRequest[KMSKeyProperties], + ) -> ProgressEvent[KMSKeyProperties]: + """ + Delete a resource + + IAM permissions required: + - kms:DescribeKey + - kms:ScheduleKeyDeletion + """ + model = request.desired_state + kms = request.aws_client_factory.kms + + kms.schedule_key_deletion(KeyId=model["KeyId"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[KMSKeyProperties], + ) -> ProgressEvent[KMSKeyProperties]: + """ + Update a resource + + IAM permissions required: + - kms:DescribeKey + - kms:DisableKey + - kms:DisableKeyRotation + - kms:EnableKey + - kms:EnableKeyRotation + - kms:PutKeyPolicy + - kms:TagResource + - kms:UntagResource + - kms:UpdateKeyDescription + """ + raise NotImplementedError + + def list(self, request: ResourceRequest[KMSKeyProperties]) -> ProgressEvent[KMSKeyProperties]: + """ + List a resource + + IAM permissions required: + - kms:ListKeys + - kms:DescribeKey + """ + kms = request.aws_client_factory.kms + + response = kms.list_keys(Limit=10) + models = [{"KeyId": key["KeyId"]} for key in response["Keys"]] + return ProgressEvent(status=OperationStatus.SUCCESS, resource_models=models) diff --git a/localstack-core/localstack/services/kms/resource_providers/aws_kms_key.schema.json b/localstack-core/localstack/services/kms/resource_providers/aws_kms_key.schema.json new file mode 100644 index 0000000000000..782d35fa134ac --- /dev/null +++ b/localstack-core/localstack/services/kms/resource_providers/aws_kms_key.schema.json @@ -0,0 +1,172 @@ +{ + "typeName": "AWS::KMS::Key", + "description": "The AWS::KMS::Key resource specifies an AWS KMS key in AWS Key Management Service (AWS KMS). Authorized users can use the AWS KMS key to encrypt and decrypt small amounts of data (up to 4096 bytes), but they are more commonly used to generate data keys. You can also use AWS KMS keys to encrypt data stored in AWS services that are integrated with AWS KMS or within their applications.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-kms", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 0, + "maxLength": 256 + } + }, + "additionalProperties": false, + "required": [ + "Key", + "Value" + ] + } + }, + "properties": { + "Description": { + "description": "A description of the AWS KMS key. Use a description that helps you to distinguish this AWS KMS key from others in the account, such as its intended use.", + "type": "string", + "minLength": 0, + "maxLength": 8192 + }, + "Enabled": { + "description": "Specifies whether the AWS KMS key is enabled. Disabled AWS KMS keys cannot be used in cryptographic operations.", + "type": "boolean" + }, + "EnableKeyRotation": { + "description": "Enables automatic rotation of the key material for the specified AWS KMS key. By default, automation key rotation is not enabled.", + "type": "boolean" + }, + "KeyPolicy": { + "description": "The key policy that authorizes use of the AWS KMS key. The key policy must observe the following rules.", + "type": [ + "object", + "string" + ] + }, + "KeyUsage": { + "description": "Determines the cryptographic operations for which you can use the AWS KMS key. The default value is ENCRYPT_DECRYPT. This property is required only for asymmetric AWS KMS keys. You can't change the KeyUsage value after the AWS KMS key is created.", + "type": "string", + "default": "ENCRYPT_DECRYPT", + "enum": [ + "ENCRYPT_DECRYPT", + "SIGN_VERIFY", + "GENERATE_VERIFY_MAC" + ] + }, + "KeySpec": { + "description": "Specifies the type of AWS KMS key to create. The default value is SYMMETRIC_DEFAULT. This property is required only for asymmetric AWS KMS keys. You can't change the KeySpec value after the AWS KMS key is created.", + "type": "string", + "default": "SYMMETRIC_DEFAULT", + "enum": [ + "SYMMETRIC_DEFAULT", + "RSA_2048", + "RSA_3072", + "RSA_4096", + "ECC_NIST_P256", + "ECC_NIST_P384", + "ECC_NIST_P521", + "ECC_SECG_P256K1", + "HMAC_224", + "HMAC_256", + "HMAC_384", + "HMAC_512", + "SM2" + ] + }, + "MultiRegion": { + "description": "Specifies whether the AWS KMS key should be Multi-Region. You can't change the MultiRegion value after the AWS KMS key is created.", + "type": "boolean", + "default": false + }, + "PendingWindowInDays": { + "description": "Specifies the number of days in the waiting period before AWS KMS deletes an AWS KMS key that has been removed from a CloudFormation stack. Enter a value between 7 and 30 days. The default value is 30 days.", + "type": "integer", + "minimum": 7, + "maximum": 30 + }, + "Tags": { + "description": "An array of key-value pairs to apply to this resource.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Arn": { + "type": "string" + }, + "KeyId": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "KeyPolicy" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/KeyId" + ], + "primaryIdentifier": [ + "/properties/KeyId" + ], + "writeOnlyProperties": [ + "/properties/PendingWindowInDays" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": false + }, + "handlers": { + "create": { + "permissions": [ + "kms:CreateKey", + "kms:EnableKeyRotation", + "kms:DisableKey", + "kms:TagResource" + ] + }, + "read": { + "permissions": [ + "kms:DescribeKey", + "kms:GetKeyPolicy", + "kms:GetKeyRotationStatus", + "kms:ListResourceTags" + ] + }, + "update": { + "permissions": [ + "kms:DescribeKey", + "kms:DisableKey", + "kms:DisableKeyRotation", + "kms:EnableKey", + "kms:EnableKeyRotation", + "kms:PutKeyPolicy", + "kms:TagResource", + "kms:UntagResource", + "kms:UpdateKeyDescription" + ] + }, + "delete": { + "permissions": [ + "kms:DescribeKey", + "kms:ScheduleKeyDeletion" + ] + }, + "list": { + "permissions": [ + "kms:ListKeys", + "kms:DescribeKey" + ] + } + } +} diff --git a/localstack-core/localstack/services/kms/resource_providers/aws_kms_key_plugin.py b/localstack-core/localstack/services/kms/resource_providers/aws_kms_key_plugin.py new file mode 100644 index 0000000000000..a03c3c714af8c --- /dev/null +++ b/localstack-core/localstack/services/kms/resource_providers/aws_kms_key_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class KMSKeyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::KMS::Key" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.kms.resource_providers.aws_kms_key import KMSKeyProvider + + self.factory = KMSKeyProvider diff --git a/localstack-core/localstack/services/kms/utils.py b/localstack-core/localstack/services/kms/utils.py new file mode 100644 index 0000000000000..ae9ff4580caa1 --- /dev/null +++ b/localstack-core/localstack/services/kms/utils.py @@ -0,0 +1,87 @@ +import re +from typing import Callable, Tuple, TypeVar + +from localstack.aws.api.kms import DryRunOperationException, Tag, TagException +from localstack.services.kms.exceptions import ValidationException +from localstack.utils.aws.arns import ARN_PARTITION_REGEX + +T = TypeVar("T") + +KMS_KEY_ARN_PATTERN = re.compile( + rf"{ARN_PARTITION_REGEX}:kms:(?P[^:]+):(?P\d{{12}}):key\/(?P[^:]+)$" +) + + +def get_hash_algorithm(signing_algorithm: str) -> str: + """ + Return the hashing algorithm for a given signing algorithm. + eg. "RSASSA_PSS_SHA_512" -> "SHA_512" + """ + return "_".join(signing_algorithm.rsplit(sep="_", maxsplit=-2)[-2:]) + + +def parse_key_arn(key_arn: str) -> Tuple[str, str, str]: + """ + Parse a valid KMS key arn into its constituents. + + :param key_arn: KMS key ARN + :return: Tuple of account ID, region name and key ID + """ + return KMS_KEY_ARN_PATTERN.match(key_arn).group("account_id", "region_name", "key_id") + + +def is_valid_key_arn(key_arn: str) -> bool: + """ + Check if a given string is a valid KMS key ARN. + """ + return KMS_KEY_ARN_PATTERN.match(key_arn) is not None + + +def validate_alias_name(alias_name: str) -> None: + if not alias_name.startswith("alias/"): + raise ValidationException( + 'Alias must start with the prefix "alias/". Please see ' + "https://docs.aws.amazon.com/kms/latest/developerguide/kms-alias.html" + ) + + +def validate_tag(tag_position: int, tag: Tag) -> None: + tag_key = tag.get("TagKey") + tag_value = tag.get("TagValue") + + if len(tag_key) > 128: + raise ValidationException( + f"1 validation error detected: Value '{tag_key}' at 'tags.{tag_position}.member.tagKey' failed to satisfy constraint: Member must have length less than or equal to 128" + ) + if len(tag_value) > 256: + raise ValidationException( + f"1 validation error detected: Value '{tag_value}' at 'tags.{tag_position}.member.tagValue' failed to satisfy constraint: Member must have length less than or equal to 256" + ) + + if tag_key.lower().startswith("aws:"): + raise TagException("Tags beginning with aws: are reserved") + + +def execute_dry_run_capable(func: Callable[..., T], dry_run: bool, *args, **kwargs) -> T: + """ + Executes a function unless dry run mode is enabled. + + If ``dry_run`` is ``True``, the function is not executed and a + ``DryRunOperationException`` is raised. Otherwise, the provided + function is called with the given positional and keyword arguments. + + :param func: The function to be executed. + :type func: Callable[..., T] + :param dry_run: Flag indicating whether the execution is a dry run. + :type dry_run: bool + :param args: Positional arguments to pass to the function. + :param kwargs: Keyword arguments to pass to the function. + :returns: The result of the function call if ``dry_run`` is ``False``. + :rtype: T + :raises DryRunOperationException: If ``dry_run`` is ``True``. + """ + if dry_run: + raise DryRunOperationException( + "The request would have succeeded, but the DryRun option is set." + ) + return func(*args, **kwargs) diff --git a/localstack-core/localstack/services/lambda_/__init__.py b/localstack-core/localstack/services/lambda_/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/lambda_/analytics.py b/localstack-core/localstack/services/lambda_/analytics.py new file mode 100644 index 0000000000000..ff4a1ae6f516c --- /dev/null +++ b/localstack-core/localstack/services/lambda_/analytics.py @@ -0,0 +1,53 @@ +from enum import StrEnum + +from localstack.utils.analytics.metrics import LabeledCounter + +NAMESPACE = "lambda" + +hotreload_counter = LabeledCounter(namespace=NAMESPACE, name="hotreload", labels=["operation"]) + +function_counter = LabeledCounter( + namespace=NAMESPACE, + name="function", + labels=[ + "operation", + "status", + "runtime", + "package_type", + # only for operation "invoke" + "invocation_type", + ], +) + + +class FunctionOperation(StrEnum): + invoke = "invoke" + create = "create" + + +class FunctionStatus(StrEnum): + success = "success" + zero_reserved_concurrency_error = "zero_reserved_concurrency_error" + event_age_exceeded_error = "event_age_exceeded_error" + throttle_error = "throttle_error" + system_error = "system_error" + unhandled_state_error = "unhandled_state_error" + failed_state_error = "failed_state_error" + pending_state_error = "pending_state_error" + invalid_payload_error = "invalid_payload_error" + invocation_error = "invocation_error" + + +esm_counter = LabeledCounter(namespace=NAMESPACE, name="esm", labels=["source", "status"]) + + +class EsmExecutionStatus(StrEnum): + success = "success" + partial_batch_failure_error = "partial_batch_failure_error" + target_invocation_error = "target_invocation_error" + unhandled_error = "unhandled_error" + source_poller_error = "source_poller_error" + # TODO: Add tracking for filter error. Options: + # a) raise filter exception and track it in the esm_worker + # b) somehow add tracking in the individual pollers + filter_error = "filter_error" diff --git a/localstack-core/localstack/services/lambda_/api_utils.py b/localstack-core/localstack/services/lambda_/api_utils.py new file mode 100644 index 0000000000000..bc573c5e019f6 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/api_utils.py @@ -0,0 +1,764 @@ +"""Utilities related to Lambda API operations such as ARN handling, validations, and output formatting. +Everything related to behavior or implicit functionality goes into `lambda_utils.py`. +""" + +import datetime +import random +import re +import string +from typing import TYPE_CHECKING, Any, Optional, Tuple + +from localstack.aws.api import CommonServiceException, RequestContext +from localstack.aws.api import lambda_ as api_spec +from localstack.aws.api.lambda_ import ( + AliasConfiguration, + Architecture, + DeadLetterConfig, + EnvironmentResponse, + EphemeralStorage, + FunctionConfiguration, + FunctionUrlAuthType, + ImageConfig, + ImageConfigResponse, + InvalidParameterValueException, + LayerVersionContentOutput, + PublishLayerVersionResponse, + ResourceNotFoundException, + TracingConfig, + VpcConfigResponse, +) +from localstack.services.lambda_.invocation import AccessDeniedException +from localstack.services.lambda_.runtimes import ALL_RUNTIMES, VALID_LAYER_RUNTIMES, VALID_RUNTIMES +from localstack.utils.aws.arns import ARN_PARTITION_REGEX, get_partition +from localstack.utils.collections import merge_recursive + +if TYPE_CHECKING: + from localstack.services.lambda_.invocation.lambda_models import ( + CodeSigningConfig, + Function, + FunctionUrlConfig, + FunctionVersion, + LayerVersion, + VersionAlias, + ) + from localstack.services.lambda_.invocation.models import LambdaStore + + +# Pattern for a full (both with and without qualifier) lambda function ARN +FULL_FN_ARN_PATTERN = re.compile( + rf"{ARN_PARTITION_REGEX}:lambda:(?P[^:]+):(?P\d{{12}}):function:(?P[^:]+)(:(?P.*))?$" +) + +# Pattern for a full (both with and without qualifier) lambda layer ARN +# TODO: It looks like they added `|(arn:[a-zA-Z0-9-]+:lambda:::awslayer:[a-zA-Z0-9-_]+` in 2024-11 +LAYER_VERSION_ARN_PATTERN = re.compile( + rf"{ARN_PARTITION_REGEX}:lambda:(?P[^:]+):(?P\d{{12}}):layer:(?P[^:]+)(:(?P\d+))?$" +) + + +# Pattern for a valid destination arn +DESTINATION_ARN_PATTERN = re.compile( + r"^$|arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)" +) + +AWS_FUNCTION_NAME_REGEX = re.compile( + "^(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?$" +) + +# Pattern for extracting various attributes from a full or partial ARN or just a function name. +FUNCTION_NAME_REGEX = re.compile( + r"(arn:(aws[a-zA-Z-]*):lambda:)?((?P[a-z]{2}(-gov)?-[a-z]+-\d{1}):)?(?:(?P\d{12}):)?(function:)?(?P[a-zA-Z0-9-_\.]+)(:(?P\$LATEST|[a-zA-Z0-9-_]+))?" +) # also length 1-170 incl. +# Pattern for a lambda function handler +HANDLER_REGEX = re.compile(r"[^\s]+") +# Pattern for a valid kms key +KMS_KEY_ARN_REGEX = re.compile(r"(arn:(aws[a-zA-Z-]*)?:[a-z0-9-.]+:.*)|()") +# Pattern for a valid IAM role assumed by a lambda function +ROLE_REGEX = re.compile(r"arn:(aws[a-zA-Z-]*)?:iam::\d{12}:role/?[a-zA-Z_0-9+=,.@\-_/]+") +# Pattern for a valid AWS account +AWS_ACCOUNT_REGEX = re.compile(r"\d{12}") +# Pattern for a signing job arn +SIGNING_JOB_ARN_REGEX = re.compile( + r"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)" +) +# Pattern for a signing profiler version arn +SIGNING_PROFILE_VERSION_ARN_REGEX = re.compile( + r"arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}(-gov)?-[a-z]+-\d{1})?:(\d{12})?:(.*)" +) +# Combined pattern for alias and version based on AWS error using "(|[a-zA-Z0-9$_-]+)" +QUALIFIER_REGEX = re.compile(r"(^[a-zA-Z0-9$_-]+$)") +# Pattern for a version qualifier +VERSION_REGEX = re.compile(r"^[0-9]+$") +# Pattern for an alias qualifier +# Rules: https://docs.aws.amazon.com/lambda/latest/dg/API_CreateAlias.html#SSS-CreateAlias-request-Name +# The original regex from AWS misses ^ and $ in the second regex, which allowed for partial substring matches +ALIAS_REGEX = re.compile(r"(?!^[0-9]+$)(^[a-zA-Z0-9-_]+$)") +# Permission statement id +STATEMENT_ID_REGEX = re.compile(r"^[a-zA-Z0-9-_]+$") +# Pattern for a valid SubnetId +SUBNET_ID_REGEX = re.compile(r"^subnet-[0-9a-z]*$") + + +URL_CHAR_SET = string.ascii_lowercase + string.digits +# Date format as returned by the lambda service +LAMBDA_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f+0000" + +# An unordered list of all Lambda CPU architectures supported by LocalStack. +ARCHITECTURES = [Architecture.arm64, Architecture.x86_64] + +# ARN pattern returned in validation exception messages. +# Some excpetions from AWS return a '\.' in the function name regex +# pattern therefore we can sub this value in when appropriate. +ARN_NAME_PATTERN_VALIDATION_TEMPLATE = "(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{{2}}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{{1}}:)?(\\d{{12}}:)?(function:)?([a-zA-Z0-9-_{0}]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + +# AWS response when invalid ARNs are used in Tag operations. +TAGGABLE_RESOURCE_ARN_PATTERN = "arn:(aws[a-zA-Z-]*):lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:(function:[a-zA-Z0-9-_]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?|layer:([a-zA-Z0-9-_]+)|code-signing-config:csc-[a-z0-9]{17}|event-source-mapping:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" + + +def validate_function_name(function_name_or_arn: str, operation_type: str): + function_name, *_ = function_locators_from_arn(function_name_or_arn) + arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format("") + max_length = 170 + + match operation_type: + case "GetFunction" | "Invoke": + arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format(r"\.") + case "CreateFunction" if function_name == function_name_or_arn: # only a function name + max_length = 64 + case "CreateFunction" | "DeleteFunction": + max_length = 140 + + validations = [] + if len(function_name_or_arn) > max_length: + constraint = f"Member must have length less than or equal to {max_length}" + validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}" + validations.append(validation_msg) + + if not AWS_FUNCTION_NAME_REGEX.match(function_name_or_arn) or not function_name: + constraint = f"Member must satisfy regular expression pattern: {arn_name_pattern}" + validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}" + validations.append(validation_msg) + + return validations + + +def validate_qualifier(qualifier: str): + validations = [] + + if len(qualifier) > 128: + constraint = "Member must have length less than or equal to 128" + validation_msg = ( + f"Value '{qualifier}' at 'qualifier' failed to satisfy constraint: {constraint}" + ) + validations.append(validation_msg) + + if not QUALIFIER_REGEX.match(qualifier): + constraint = "Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)" + validation_msg = ( + f"Value '{qualifier}' at 'qualifier' failed to satisfy constraint: {constraint}" + ) + validations.append(validation_msg) + + return validations + + +def construct_validation_exception_message(validation_errors): + if validation_errors: + return f"{len(validation_errors)} validation error{'s' if len(validation_errors) > 1 else ''} detected: {'; '.join(validation_errors)}" + + return None + + +def map_function_url_config(model: "FunctionUrlConfig") -> api_spec.FunctionUrlConfig: + return api_spec.FunctionUrlConfig( + FunctionUrl=model.url, + FunctionArn=model.function_arn, + CreationTime=model.creation_time, + LastModifiedTime=model.last_modified_time, + Cors=model.cors, + AuthType=model.auth_type, + InvokeMode=model.invoke_mode, + ) + + +def map_csc(model: "CodeSigningConfig") -> api_spec.CodeSigningConfig: + return api_spec.CodeSigningConfig( + CodeSigningConfigId=model.csc_id, + CodeSigningConfigArn=model.arn, + Description=model.description, + AllowedPublishers=model.allowed_publishers, + CodeSigningPolicies=model.policies, + LastModified=model.last_modified, + ) + + +def get_config_for_url(store: "LambdaStore", url_id: str) -> "Optional[FunctionUrlConfig]": + """ + Get a config object when resolving a URL + + :param store: Lambda Store + :param url_id: unique url ID (prefixed domain when calling the function) + :return: FunctionUrlConfig that belongs to this ID + + # TODO: quite inefficient: optimize + """ + for fn_name, fn in store.functions.items(): + for qualifier, fn_url_config in fn.function_url_configs.items(): + if fn_url_config.url_id == url_id: + return fn_url_config + return None + + +def is_qualifier_expression(qualifier: str) -> bool: + """Checks if a given qualifier is a syntactically accepted expression. + It is not necessarily a valid alias or version. + + :param qualifier: Qualifier to check + :return True if syntactically accepted qualifier expression, false otherwise + """ + return bool(QUALIFIER_REGEX.match(qualifier)) + + +def qualifier_is_version(qualifier: str) -> bool: + """ + Checks if a given qualifier represents a version + + :param qualifier: Qualifier to check + :return: True if it matches a version, false otherwise + """ + return bool(VERSION_REGEX.match(qualifier)) + + +def qualifier_is_alias(qualifier: str) -> bool: + """ + Checks if a given qualifier represents an alias + + :param qualifier: Qualifier to check + :return: True if it matches an alias, false otherwise + """ + return bool(ALIAS_REGEX.match(qualifier)) + + +def get_function_name(function_arn_or_name: str, context: RequestContext) -> str: + """ + Return function name from a given arn. + Will check if the context region matches the arn region in the arn, if an arn is provided. + + :param function_arn_or_name: Function arn or only name + :return: function name + """ + name, _ = get_name_and_qualifier(function_arn_or_name, qualifier=None, context=context) + return name + + +def function_locators_from_arn(arn: str) -> tuple[str | None, str | None, str | None, str | None]: + """ + Takes a full or partial arn, or a name + + :param arn: Given arn (or name) + :return: tuple with (name, qualifier, account, region). Qualifier and region are none if missing + """ + + if matched := FUNCTION_NAME_REGEX.match(arn): + name = matched.group("name") + qualifier = matched.group("qualifier") + account = matched.group("account") + region = matched.group("region") + return (name, qualifier, account, region) + + return None, None, None, None + + +def get_account_and_region(function_arn_or_name: str, context: RequestContext) -> Tuple[str, str]: + """ + Takes a full ARN, partial ARN or a name. Returns account ID and region from ARN if available, else + falls back to context account ID and region. + + Lambda allows cross-account access. This function should be used to resolve the correct Store based on the ARN. + """ + _, _, account_id, region = function_locators_from_arn(function_arn_or_name) + return account_id or context.account_id, region or context.region + + +def get_name_and_qualifier( + function_arn_or_name: str, qualifier: str | None, context: RequestContext +) -> tuple[str, str | None]: + """ + Takes a full or partial arn, or a name and a qualifier. + + :param function_arn_or_name: Given arn (or name) + :param qualifier: A qualifier for the function (or None) + :param context: Request context + :return: tuple with (name, qualifier). Qualifier is none if missing + :raises: `ResourceNotFoundException` when the context's region differs from the ARN's region + :raises: `AccessDeniedException` when the context's account ID differs from the ARN's account ID + :raises: `ValidationExcpetion` when a function ARN/name or qualifier fails validation checks + :raises: `InvalidParameterValueException` when a qualified arn is provided and the qualifier does not match (but is given) + """ + function_name, arn_qualifier, account, region = function_locators_from_arn(function_arn_or_name) + operation_type = context.operation.name + + if operation_type not in _supported_resource_based_operations: + if account and account != context.account_id: + raise AccessDeniedException(None) + + # TODO: should this only run if operation type is unsupported? + if region and region != context.region: + raise ResourceNotFoundException( + f"Functions from '{region}' are not reachable in this region ('{context.region}')", + Type="User", + ) + + validation_errors = [] + if function_arn_or_name: + validation_errors.extend(validate_function_name(function_arn_or_name, operation_type)) + + if qualifier: + validation_errors.extend(validate_qualifier(qualifier)) + + is_only_function_name = function_arn_or_name == function_name + if validation_errors: + message = construct_validation_exception_message(validation_errors) + # Edge-case where the error type is not ValidationException + if ( + operation_type == "CreateFunction" + and is_only_function_name + and arn_qualifier is None + and region is None + ): # just name OR partial + raise InvalidParameterValueException(message=message, Type="User") + raise CommonServiceException(message=message, code="ValidationException") + + if qualifier and arn_qualifier and arn_qualifier != qualifier: + raise InvalidParameterValueException( + "The derived qualifier from the function name does not match the specified qualifier.", + Type="User", + ) + + qualifier = qualifier or arn_qualifier + return function_name, qualifier + + +def build_statement( + partition: str, + resource_arn: str, + statement_id: str, + action: str, + principal: str, + source_arn: Optional[str] = None, + source_account: Optional[str] = None, + principal_org_id: Optional[str] = None, + event_source_token: Optional[str] = None, + auth_type: Optional[FunctionUrlAuthType] = None, +) -> dict[str, Any]: + statement = { + "Sid": statement_id, + "Effect": "Allow", + "Action": action, + "Resource": resource_arn, + } + + # See AWS service principals for comprehensive docs: + # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html + # TODO: validate against actual list of IAM-supported AWS services (e.g., lambda.amazonaws.com) + if principal.endswith(".amazonaws.com"): + statement["Principal"] = {"Service": principal} + elif is_aws_account(principal): + statement["Principal"] = {"AWS": f"arn:{partition}:iam::{principal}:root"} + # TODO: potentially validate against IAM? + elif re.match(f"{ARN_PARTITION_REGEX}:iam:", principal): + statement["Principal"] = {"AWS": principal} + elif principal == "*": + statement["Principal"] = principal + # TODO: unclear whether above matching is complete? + else: + raise InvalidParameterValueException( + "The provided principal was invalid. Please check the principal and try again.", + Type="User", + ) + + condition = dict() + if auth_type: + update = {"StringEquals": {"lambda:FunctionUrlAuthType": auth_type}} + condition = merge_recursive(condition, update) + + if principal_org_id: + update = {"StringEquals": {"aws:PrincipalOrgID": principal_org_id}} + condition = merge_recursive(condition, update) + + if source_account: + update = {"StringEquals": {"AWS:SourceAccount": source_account}} + condition = merge_recursive(condition, update) + + if event_source_token: + update = {"StringEquals": {"lambda:EventSourceToken": event_source_token}} + condition = merge_recursive(condition, update) + + if source_arn: + update = {"ArnLike": {"AWS:SourceArn": source_arn}} + condition = merge_recursive(condition, update) + + if condition: + statement["Condition"] = condition + + return statement + + +def generate_random_url_id() -> str: + """ + 32 characters [0-9a-z] url ID + """ + + return "".join(random.choices(URL_CHAR_SET, k=32)) + + +def unqualified_lambda_arn(function_name: str, account: str, region: str): + """ + Generate an unqualified lambda arn + + :param function_name: Function name (not an arn!) + :param account: Account ID + :param region: Region + :return: Unqualified lambda arn + """ + return f"arn:{get_partition(region)}:lambda:{region}:{account}:function:{function_name}" + + +def qualified_lambda_arn( + function_name: str, qualifier: Optional[str], account: str, region: str +) -> str: + """ + Generate a qualified lambda arn + + :param function_name: Function name (not an arn!) + :param qualifier: qualifier (will be set to $LATEST if not present) + :param account: Account ID + :param region: Region + :return: Qualified lambda arn + """ + qualifier = qualifier or "$LATEST" + return f"{unqualified_lambda_arn(function_name=function_name, account=account, region=region)}:{qualifier}" + + +def lambda_arn(function_name: str, qualifier: Optional[str], account: str, region: str) -> str: + """ + Return the lambda arn for the given parameters, with a qualifier if supplied, without otherwise + + :param function_name: Function name + :param qualifier: Qualifier. May be left out, then the returning arn does not have one either + :param account: Account ID + :param region: Region of the Lambda + :return: Lambda Arn with or without qualifier + """ + if qualifier: + return qualified_lambda_arn( + function_name=function_name, qualifier=qualifier, account=account, region=region + ) + else: + return unqualified_lambda_arn(function_name=function_name, account=account, region=region) + + +def is_role_arn(role_arn: str) -> bool: + """ + Returns true if the provided string is a role arn, false otherwise + + :param role_arn: Potential role arn + :return: Boolean indicating if input is a role arn + """ + return bool(ROLE_REGEX.match(role_arn)) + + +def is_aws_account(aws_account: str) -> bool: + """ + Returns true if the provided string is an AWS account, false otherwise + + :param role_arn: Potential AWS account + :return: Boolean indicating if input is an AWS account + """ + return bool(AWS_ACCOUNT_REGEX.match(aws_account)) + + +def format_lambda_date(date_to_format: datetime.datetime) -> str: + """Format a given datetime to a string generated with the lambda date format""" + return date_to_format.strftime(LAMBDA_DATE_FORMAT) + + +def generate_lambda_date() -> str: + """Get the current date as string generated with the lambda date format""" + return format_lambda_date(datetime.datetime.now()) + + +def map_update_status_config(version: "FunctionVersion") -> dict[str, str]: + """Map version model to dict output""" + result = {} + if version.config.last_update: + if version.config.last_update.status: + result["LastUpdateStatus"] = version.config.last_update.status + if version.config.last_update.code: + result["LastUpdateStatusReasonCode"] = version.config.last_update.code + if version.config.last_update.reason: + result["LastUpdateStatusReason"] = version.config.last_update.reason + return result + + +def map_state_config(version: "FunctionVersion") -> dict[str, str]: + """Map version state to dict output""" + result = {} + if version_state := version.config.state: + if version_state.state: + result["State"] = version_state.state + if version_state.reason: + result["StateReason"] = version_state.reason + if version_state.code: + result["StateReasonCode"] = version_state.code + return result + + +def map_config_out( + version: "FunctionVersion", + return_qualified_arn: bool = False, + return_update_status: bool = True, + alias_name: str | None = None, +) -> FunctionConfiguration: + """map function version to function configuration""" + + # handle optional entries that shouldn't be rendered at all if not present + optional_kwargs = {} + if return_update_status: + optional_kwargs.update(map_update_status_config(version)) + optional_kwargs.update(map_state_config(version)) + + if version.config.architectures: + optional_kwargs["Architectures"] = version.config.architectures + + if version.config.dead_letter_arn: + optional_kwargs["DeadLetterConfig"] = DeadLetterConfig( + TargetArn=version.config.dead_letter_arn + ) + + if version.config.vpc_config: + optional_kwargs["VpcConfig"] = VpcConfigResponse( + VpcId=version.config.vpc_config.vpc_id, + SubnetIds=version.config.vpc_config.subnet_ids, + SecurityGroupIds=version.config.vpc_config.security_group_ids, + ) + + if version.config.environment is not None: + optional_kwargs["Environment"] = EnvironmentResponse( + Variables=version.config.environment + ) # TODO: Errors key? + + if version.config.layers: + optional_kwargs["Layers"] = [ + {"Arn": layer.layer_version_arn, "CodeSize": layer.code.code_size} + for layer in version.config.layers + ] + if version.config.image_config: + image_config = ImageConfig() + if version.config.image_config.command: + image_config["Command"] = version.config.image_config.command + if version.config.image_config.entrypoint: + image_config["EntryPoint"] = version.config.image_config.entrypoint + if version.config.image_config.working_directory: + image_config["WorkingDirectory"] = version.config.image_config.working_directory + if image_config: + optional_kwargs["ImageConfigResponse"] = ImageConfigResponse(ImageConfig=image_config) + if version.config.code: + optional_kwargs["CodeSize"] = version.config.code.code_size + optional_kwargs["CodeSha256"] = version.config.code.code_sha256 + elif version.config.image: + optional_kwargs["CodeSize"] = 0 + optional_kwargs["CodeSha256"] = version.config.image.code_sha256 + + # output for an alias qualifier is completely the same except for the returned ARN + if alias_name: + function_arn = f"{':'.join(version.id.qualified_arn().split(':')[:-1])}:{alias_name}" + else: + function_arn = ( + version.id.qualified_arn() if return_qualified_arn else version.id.unqualified_arn() + ) + + func_conf = FunctionConfiguration( + RevisionId=version.config.revision_id, + FunctionName=version.id.function_name, + FunctionArn=function_arn, + LastModified=version.config.last_modified, + Version=version.id.qualifier, + Description=version.config.description, + Role=version.config.role, + Timeout=version.config.timeout, + Runtime=version.config.runtime, + Handler=version.config.handler, + MemorySize=version.config.memory_size, + PackageType=version.config.package_type, + TracingConfig=TracingConfig(Mode=version.config.tracing_config_mode), + EphemeralStorage=EphemeralStorage(Size=version.config.ephemeral_storage.size), + SnapStart=version.config.snap_start, + RuntimeVersionConfig=version.config.runtime_version_config, + LoggingConfig=version.config.logging_config, + **optional_kwargs, + ) + return func_conf + + +def map_to_list_response(config: FunctionConfiguration) -> FunctionConfiguration: + """remove values not usually presented in list operations from function config output""" + shallow_copy = config.copy() + for k in [ + "State", + "StateReason", + "StateReasonCode", + "LastUpdateStatus", + "LastUpdateStatusReason", + "LastUpdateStatusReasonCode", + "RuntimeVersionConfig", + ]: + shallow_copy.pop(k, None) + return shallow_copy + + +def map_alias_out(alias: "VersionAlias", function: "Function") -> AliasConfiguration: + """map alias model to alias configuration output""" + alias_arn = f"{function.latest().id.unqualified_arn()}:{alias.name}" + optional_kwargs = {} + if alias.routing_configuration: + optional_kwargs |= { + "RoutingConfig": { + "AdditionalVersionWeights": alias.routing_configuration.version_weights + } + } + return AliasConfiguration( + AliasArn=alias_arn, + Description=alias.description, + FunctionVersion=alias.function_version, + Name=alias.name, + RevisionId=alias.revision_id, + **optional_kwargs, + ) + + +def validate_and_set_batch_size(service: str, batch_size: Optional[int] = None) -> int: + min_batch_size = 1 + + BATCH_SIZE_RANGES = { + "kafka": (100, 10_000), + "kinesis": (100, 10_000), + "dynamodb": (100, 10_000), + "sqs-fifo": (10, 10), + "sqs": (10, 10_000), + "mq": (100, 10_000), + } + svc_range = BATCH_SIZE_RANGES.get(service) + + if svc_range: + default_batch_size, max_batch_size = svc_range + + if batch_size is None: + batch_size = default_batch_size + + if batch_size < min_batch_size or batch_size > max_batch_size: + raise InvalidParameterValueException("out of bounds todo", Type="User") # TODO: test + + return batch_size + + +def map_layer_out(layer_version: "LayerVersion") -> PublishLayerVersionResponse: + return PublishLayerVersionResponse( + Content=LayerVersionContentOutput( + Location=layer_version.code.generate_presigned_url(), + CodeSha256=layer_version.code.code_sha256, + CodeSize=layer_version.code.code_size, + # SigningProfileVersionArn="", # same as in function configuration + # SigningJobArn="" # same as in function configuration + ), + LicenseInfo=layer_version.license_info, + Description=layer_version.description, + CompatibleArchitectures=layer_version.compatible_architectures, + CompatibleRuntimes=layer_version.compatible_runtimes, + CreatedDate=layer_version.created, + LayerArn=layer_version.layer_arn, + LayerVersionArn=layer_version.layer_version_arn, + Version=layer_version.version, + ) + + +def layer_arn(layer_name: str, account: str, region: str): + return f"arn:{get_partition(region)}:lambda:{region}:{account}:layer:{layer_name}" + + +def layer_version_arn(layer_name: str, account: str, region: str, version: str): + return f"arn:{get_partition(region)}:lambda:{region}:{account}:layer:{layer_name}:{version}" + + +def parse_layer_arn(layer_version_arn: str) -> Tuple[str, str, str, str]: + return LAYER_VERSION_ARN_PATTERN.match(layer_version_arn).group( + "region_name", "account_id", "layer_name", "layer_version" + ) + + +def validate_layer_runtime(compatible_runtime: str) -> str | None: + if compatible_runtime is not None and compatible_runtime not in ALL_RUNTIMES: + return f"Value '{compatible_runtime}' at 'compatibleRuntime' failed to satisfy constraint: Member must satisfy enum value set: {VALID_LAYER_RUNTIMES}" + return None + + +def validate_layer_architecture(compatible_architecture: str) -> str | None: + if compatible_architecture is not None and compatible_architecture not in ARCHITECTURES: + return f"Value '{compatible_architecture}' at 'compatibleArchitecture' failed to satisfy constraint: Member must satisfy enum value set: [x86_64, arm64]" + return None + + +def validate_layer_runtimes_and_architectures( + compatible_runtimes: list[str], compatible_architectures: list[str] +): + validations = [] + + if compatible_runtimes and set(compatible_runtimes).difference(ALL_RUNTIMES): + constraint = f"Member must satisfy enum value set: {VALID_RUNTIMES}" + validation_msg = f"Value '[{', '.join([s for s in compatible_runtimes])}]' at 'compatibleRuntimes' failed to satisfy constraint: {constraint}" + validations.append(validation_msg) + + if compatible_architectures and set(compatible_architectures).difference(ARCHITECTURES): + constraint = "[Member must satisfy enum value set: [x86_64, arm64]]" + validation_msg = f"Value '[{', '.join([s for s in compatible_architectures])}]' at 'compatibleArchitectures' failed to satisfy constraint: Member must satisfy constraint: {constraint}" + validations.append(validation_msg) + + return validations + + +def is_layer_arn(layer_name: str) -> bool: + return LAYER_VERSION_ARN_PATTERN.match(layer_name) is not None + + +# See Lambda API actions that support resource-based IAM policies +# https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html#permissions-resource-api +_supported_resource_based_operations = { + "CreateAlias", + "DeleteAlias", + "DeleteFunction", + "DeleteFunctionConcurrency", + "DeleteFunctionEventInvokeConfig", + "DeleteProvisionedConcurrencyConfig", + "GetAlias", + "GetFunction", + "GetFunctionConcurrency", + "GetFunctionConfiguration", + "GetFunctionEventInvokeConfig", + "GetPolicy", + "GetProvisionedConcurrencyConfig", + "Invoke", + "ListAliases", + "ListFunctionEventInvokeConfigs", + "ListProvisionedConcurrencyConfigs", + "ListTags", + "ListVersionsByFunction", + "PublishVersion", + "PutFunctionConcurrency", + "PutFunctionEventInvokeConfig", + "PutProvisionedConcurrencyConfig", + "TagResource", + "UntagResource", + "UpdateAlias", + "UpdateFunctionCode", + "UpdateFunctionEventInvokeConfig", +} diff --git a/localstack-core/localstack/services/lambda_/custom_endpoints.py b/localstack-core/localstack/services/lambda_/custom_endpoints.py new file mode 100644 index 0000000000000..b8267f1e7d06b --- /dev/null +++ b/localstack-core/localstack/services/lambda_/custom_endpoints.py @@ -0,0 +1,49 @@ +import urllib.parse +from typing import List, TypedDict + +from rolo import Request, route + +from localstack.aws.api.lambda_ import Runtime +from localstack.http import Response +from localstack.services.lambda_.packages import get_runtime_client_path +from localstack.services.lambda_.runtimes import ( + ALL_RUNTIMES, + DEPRECATED_RUNTIMES, + SUPPORTED_RUNTIMES, +) + + +class LambdaRuntimesResponse(TypedDict, total=False): + Runtimes: List[Runtime] + + +class LambdaCustomEndpoints: + @route("/_aws/lambda/runtimes", methods=["GET"]) + def runtimes(self, request: Request) -> LambdaRuntimesResponse: + """This metadata endpoint needs to be loaded before the Lambda provider. + It can be used by the Webapp to query supported Lambda runtimes of an unknown LocalStack version.""" + query_params = urllib.parse.parse_qs(request.environ["QUERY_STRING"]) + # Query parameter values are all lists. Example: { "filter": ["all"] } + filter_params = query_params.get("filter", []) + runtimes = set() + if "all" in filter_params: + runtimes.update(ALL_RUNTIMES) + if "deprecated" in filter_params: + runtimes.update(DEPRECATED_RUNTIMES) + # By default (i.e., without any filter param), we return the supported runtimes because that is most useful. + if "supported" in filter_params or len(runtimes) == 0: + runtimes.update(SUPPORTED_RUNTIMES) + + return LambdaRuntimesResponse(Runtimes=list(runtimes)) + + @route("/_aws/lambda/init", methods=["GET"]) + def init(self, request: Request) -> Response: + """ + This internal endpoint exposes the init binary over an http API + :param request: The HTTP request object. + :return: Response containing the init binary. + """ + runtime_client_path = get_runtime_client_path() / "var" / "rapid" / "init" + runtime_init_binary = runtime_client_path.read_bytes() + + return Response(runtime_init_binary, mimetype="application/octet-stream") diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/__init__.py b/localstack-core/localstack/services/lambda_/event_source_mapping/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/esm_config_factory.py b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_config_factory.py new file mode 100644 index 0000000000000..aea1aeb33bb65 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_config_factory.py @@ -0,0 +1,118 @@ +import datetime + +from localstack.aws.api.lambda_ import ( + CreateEventSourceMappingRequest, + DestinationConfig, + EventSourceMappingConfiguration, + EventSourcePosition, + RequestContext, +) +from localstack.services.lambda_ import hooks as lambda_hooks +from localstack.services.lambda_.event_source_mapping.esm_worker import EsmState, EsmStateReason +from localstack.services.lambda_.event_source_mapping.pipe_utils import ( + get_standardized_service_name, +) +from localstack.utils.aws.arns import lambda_event_source_mapping_arn, parse_arn +from localstack.utils.collections import merge_recursive +from localstack.utils.strings import long_uid + + +class EsmConfigFactory: + request: CreateEventSourceMappingRequest + context: RequestContext + function_arn: str + + def __init__( + self, request: CreateEventSourceMappingRequest, context: RequestContext, function_arn: str + ): + self.request = request + self.function_arn = function_arn + self.context = context + + def get_esm_config(self) -> EventSourceMappingConfiguration: + """Creates an Event Source Mapping (ESM) configuration based on a create ESM request. + * CreateEventSourceMapping API: https://docs.aws.amazon.com/lambda/latest/api/API_CreateEventSourceMapping.html + * CreatePipe API: https://docs.aws.amazon.com/eventbridge/latest/pipes-reference/API_CreatePipe.html + The CreatePipe API covers largely the same parameters, but is better structured using hierarchical parameters. + """ + service = "" + if source_arn := self.request.get("EventSourceArn"): + parsed_arn = parse_arn(source_arn) + service = get_standardized_service_name(parsed_arn["service"]) + + uuid = long_uid() + + default_source_parameters = {} + default_source_parameters["UUID"] = uuid + default_source_parameters["EventSourceMappingArn"] = lambda_event_source_mapping_arn( + uuid, self.context.account_id, self.context.region + ) + default_source_parameters["StateTransitionReason"] = EsmStateReason.USER_ACTION + + if service == "sqs": + default_source_parameters["BatchSize"] = 10 + default_source_parameters["MaximumBatchingWindowInSeconds"] = 0 + default_source_parameters["StateTransitionReason"] = EsmStateReason.USER_INITIATED + elif service == "kinesis": + # TODO: test all defaults + default_source_parameters["BatchSize"] = 100 + default_source_parameters["DestinationConfig"] = DestinationConfig(OnFailure={}) + default_source_parameters["BisectBatchOnFunctionError"] = False + default_source_parameters["MaximumBatchingWindowInSeconds"] = 0 + default_source_parameters["MaximumRecordAgeInSeconds"] = -1 + default_source_parameters["MaximumRetryAttempts"] = -1 + default_source_parameters["ParallelizationFactor"] = 1 + default_source_parameters["StartingPosition"] = EventSourcePosition.TRIM_HORIZON + default_source_parameters["TumblingWindowInSeconds"] = 0 + default_source_parameters["LastProcessingResult"] = EsmStateReason.NO_RECORDS_PROCESSED + elif service == "dynamodbstreams": + # TODO: test all defaults + default_source_parameters["BatchSize"] = 100 + default_source_parameters["DestinationConfig"] = DestinationConfig(OnFailure={}) + default_source_parameters["BisectBatchOnFunctionError"] = False + default_source_parameters["MaximumBatchingWindowInSeconds"] = 0 + default_source_parameters["MaximumRecordAgeInSeconds"] = -1 + default_source_parameters["MaximumRetryAttempts"] = -1 + default_source_parameters["ParallelizationFactor"] = 1 + default_source_parameters["StartingPosition"] = EventSourcePosition.TRIM_HORIZON + default_source_parameters["TumblingWindowInSeconds"] = 0 + default_source_parameters["LastProcessingResult"] = EsmStateReason.NO_RECORDS_PROCESSED + else: + lambda_hooks.set_event_source_config_defaults.run( + default_source_parameters, self.request, service + ) + + if not default_source_parameters: + raise Exception( + f"Default Lambda Event Source Mapping parameters not implemented for service {service}. req={self.request} dict={default_source_parameters}" + ) + + # TODO: test whether merging actually happens recursively. Examples: + # a) What happens if only one of the parameters for DocumentDBEventSourceConfig change? + # b) Does a change of AmazonManagedKafkaEventSourceConfig.ConsumerGroupId affect flat parameters such as BatchSize and MaximumBatchingWindowInSeconds)? + # c) Are FilterCriteria.Filters merged or replaced upon update? + # TODO: can we ignore extra parameters from the request (e.g., Kinesis params for SQS source)? + derived_source_parameters = merge_recursive(default_source_parameters, self.request) + + # TODO What happens when FunctionResponseTypes value or target service is invalid? + if service in ["sqs", "kinesis", "dynamodbstreams"]: + derived_source_parameters["FunctionResponseTypes"] = derived_source_parameters.get( + "FunctionResponseTypes", [] + ) + + state = EsmState.CREATING if self.request.get("Enabled", True) else EsmState.DISABLED + esm_config = EventSourceMappingConfiguration( + **derived_source_parameters, + FunctionArn=self.function_arn, + # TODO: last modified => does state transition affect this? + LastModified=datetime.datetime.now(), + State=state, + # TODO: complete missing fields + ) + # TODO: check whether we need to remove any more fields that are present in the request but should not be in the + # esm_config + esm_config.pop("Enabled", "") + esm_config.pop("FunctionName", "") + if not esm_config.get("FilterCriteria", {}).get("Filters", []): + esm_config.pop("FilterCriteria", "") + return esm_config diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/esm_event_processor.py b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_event_processor.py new file mode 100644 index 0000000000000..b2e85a04ea26c --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_event_processor.py @@ -0,0 +1,178 @@ +import json +import logging +import uuid + +from localstack.aws.api.pipes import LogLevel +from localstack.services.lambda_.analytics import EsmExecutionStatus, esm_counter +from localstack.services.lambda_.event_source_mapping.event_processor import ( + BatchFailureError, + EventProcessor, + PartialBatchFailureError, +) +from localstack.services.lambda_.event_source_mapping.pipe_loggers.pipe_logger import PipeLogger +from localstack.services.lambda_.event_source_mapping.pipe_utils import to_json_str +from localstack.services.lambda_.event_source_mapping.senders.sender import ( + PartialFailureSenderError, + Sender, + SenderError, +) + +LOG = logging.getLogger(__name__) + + +class EsmEventProcessor(EventProcessor): + sender: Sender + logger: PipeLogger + + def __init__(self, sender, logger): + self.sender = sender + self.logger = logger + + def process_events_batch(self, input_events: list[dict] | dict) -> None: + # analytics + if isinstance(input_events, list) and input_events: + first_event = input_events[0] + elif input_events: + first_event = input_events + else: + first_event = {} + event_source = first_event.get("eventSource") + + execution_id = uuid.uuid4() + # Create a copy of the original input events + events = input_events.copy() + try: + self.logger.set_fields(executionId=str(execution_id)) + self.logger.log( + messageType="ExecutionStarted", + logLevel=LogLevel.INFO, + payload=to_json_str(events), + ) + # An execution is only triggered upon successful polling. Therefore, `PollingStageStarted` never occurs. + self.logger.log( + messageType="PollingStageSucceeded", + logLevel=LogLevel.TRACE, + ) + # Target Stage + self.process_target_stage(events) + self.logger.log( + messageType="ExecutionSucceeded", + logLevel=LogLevel.INFO, + ) + esm_counter.labels(source=event_source, status=EsmExecutionStatus.success).increment() + except PartialFailureSenderError as e: + self.logger.log( + messageType="ExecutionFailed", + logLevel=LogLevel.ERROR, + error=e.error, + ) + esm_counter.labels( + source=event_source, status=EsmExecutionStatus.partial_batch_failure_error + ).increment() + # TODO: check whether partial batch item failures is enabled by default or need to be explicitly enabled + # using --function-response-types "ReportBatchItemFailures" + # https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html + raise PartialBatchFailureError( + partial_failure_payload=e.partial_failure_payload, error=e.error + ) from e + except SenderError as e: + self.logger.log( + messageType="ExecutionFailed", + logLevel=LogLevel.ERROR, + error=e.error, + ) + esm_counter.labels( + source=event_source, status=EsmExecutionStatus.target_invocation_error + ).increment() + raise BatchFailureError(error=e.error) from e + except Exception as e: + LOG.error( + "Unhandled exception while processing Lambda event source mapping (ESM) events %s for ESM with execution id %s", + events, + execution_id, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + esm_counter.labels( + source=event_source, status=EsmExecutionStatus.unhandled_error + ).increment() + raise e + + def process_target_stage(self, events: list[dict]) -> None: + try: + self.logger.log( + messageType="TargetStageEntered", + logLevel=LogLevel.INFO, + ) + # 2) Deliver to target in batches + try: + self.logger.log( + messageType="TargetInvocationStarted", + logLevel=LogLevel.TRACE, + ) + # TODO: handle and log target invocation + stage skipped (when no records present) + payload = self.sender.send_events(events) + if payload: + # TODO: test unserializable content (e.g., byte strings) + payload = json.dumps(payload) + else: + payload = "" + self.logger.log( + messageType="TargetInvocationSucceeded", + logLevel=LogLevel.TRACE, + ) + except PartialFailureSenderError as e: + self.logger.log( + messageType="TargetInvocationPartiallyFailed", + logLevel=LogLevel.ERROR, + error=e.error, + ) + raise e + except SenderError as e: + self.logger.log( + messageType="TargetInvocationFailed", + logLevel=LogLevel.ERROR, + error=e.error, + ) + raise e + self.logger.log( + messageType="TargetStageSucceeded", + logLevel=LogLevel.INFO, + payload=payload, + ) + except PartialFailureSenderError as e: + self.logger.log( + messageType="TargetStagePartiallyFailed", + logLevel=LogLevel.ERROR, + error=e.error, + ) + raise e + except SenderError as e: + self.logger.log( + messageType="TargetStageFailed", + logLevel=LogLevel.ERROR, + error=e.error, + ) + raise e + + def generate_event_failure_context(self, abort_condition: str, **kwargs) -> dict: + error_payload: dict = kwargs.get("error") + if not error_payload: + return {} + # TODO: Should 'requestContext' and 'responseContext' be defined as models? + # TODO: Allow for generating failure context where there is no responseContext i.e + # if a RecordAgeExceeded condition is triggered. + context = { + "requestContext": { + "requestId": error_payload.get("requestId"), + "functionArn": self.sender.target_arn, # get the target ARN from the sender (always LambdaSender) + "condition": abort_condition, + "approximateInvokeCount": kwargs.get("attempts_count"), + }, + "responseContext": { + "statusCode": error_payload.get("httpStatusCode"), + "executedVersion": error_payload.get("executedVersion"), + "functionError": error_payload.get("functionError"), + }, + } + + return context diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/esm_worker.py b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_worker.py new file mode 100644 index 0000000000000..05f38bcf5ddbf --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_worker.py @@ -0,0 +1,233 @@ +import logging +import threading +from enum import StrEnum + +from localstack.aws.api.lambda_ import ( + EventSourceMappingConfiguration, +) +from localstack.config import ( + LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_EMPTY_POLL_SEC, + LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_ERROR_SEC, + LAMBDA_EVENT_SOURCE_MAPPING_POLL_INTERVAL_SEC, +) +from localstack.services.lambda_.analytics import EsmExecutionStatus, esm_counter +from localstack.services.lambda_.event_source_mapping.pollers.poller import ( + EmptyPollResultsException, + Poller, +) +from localstack.services.lambda_.invocation.models import LambdaStore, lambda_stores +from localstack.services.lambda_.provider_utils import get_function_version_from_arn +from localstack.utils.aws.arns import parse_arn +from localstack.utils.backoff import ExponentialBackoff +from localstack.utils.threads import FuncThread + +LOG = logging.getLogger(__name__) + + +class EsmState(StrEnum): + # https://docs.aws.amazon.com/lambda/latest/api/API_CreateEventSourceMapping.html#lambda-CreateEventSourceMapping-response-State + CREATING = "Creating" + ENABLING = "Enabling" + ENABLED = "Enabled" + DISABLING = "Disabling" + DISABLED = "Disabled" + UPDATING = "Updating" + DELETING = "Deleting" + + +class EsmStateReason(StrEnum): + # Used for Kinesis and DynamoDB + USER_ACTION = "User action" + # Used for SQS + USER_INITIATED = "USER_INITIATED" + NO_RECORDS_PROCESSED = "No records processed" + # TODO: add others? + + +class EsmWorker: + esm_config: EventSourceMappingConfiguration + enabled: bool + current_state: EsmState + state_transition_reason: EsmStateReason + # Either USER_ACTION or USER_INITIATED (SQS) depending on the event source + user_state_reason: EsmStateReason + # TODO: test + last_processing_result: str + + poller: Poller + + _state: LambdaStore + _state_lock: threading.RLock + _shutdown_event: threading.Event + _poller_thread: FuncThread | None + + def __init__( + self, + esm_config: EventSourceMappingConfiguration, + poller: Poller, + enabled: bool = True, + user_state_reason: EsmStateReason = EsmStateReason.USER_ACTION, + ): + self.esm_config = esm_config + self.enabled = enabled + self.current_state = EsmState.CREATING + self.user_state_reason = user_state_reason + self.state_transition_reason = self.user_state_reason + + self.poller = poller + + # TODO: implement lifecycle locking + self._state_lock = threading.RLock() + self._shutdown_event = threading.Event() + self._poller_thread = None + + function_version = get_function_version_from_arn(self.esm_config["FunctionArn"]) + self._state = lambda_stores[function_version.id.account][function_version.id.region] + + # HACK: Flag used to check if a graceful shutdown was triggered. + self._graceful_shutdown_triggered = False + + @property + def uuid(self) -> str: + return self.esm_config["UUID"] + + def stop_for_shutdown(self): + # Signal the worker's poller_loop thread to gracefully shutdown + # TODO: Once ESM state is de-coupled from lambda store, re-think this approach. + self._shutdown_event.set() + self._graceful_shutdown_triggered = True + + def create(self): + if self.enabled: + with self._state_lock: + self.current_state = EsmState.CREATING + self.state_transition_reason = self.user_state_reason + self.start() + else: + # TODO: validate with tests + with self._state_lock: + self.current_state = EsmState.DISABLED + self.state_transition_reason = self.user_state_reason + self.update_esm_state_in_store(EsmState.DISABLED) + + def start(self): + with self._state_lock: + self.enabled = True + # CREATING state takes precedence over ENABLING + if self.current_state != EsmState.CREATING: + self.current_state = EsmState.ENABLING + self.state_transition_reason = self.user_state_reason + # Reset the shutdown event such that we don't stop immediately after a restart + self._shutdown_event.clear() + self._poller_thread = FuncThread( + self.poller_loop, + name=f"event-source-mapping-poller-{self.uuid}", + ) + self._poller_thread.start() + + def stop(self): + with self._state_lock: + self.enabled = False + self.current_state = EsmState.DISABLING + self.update_esm_state_in_store(EsmState.DISABLING) + self.state_transition_reason = self.user_state_reason + self._shutdown_event.set() + + def delete(self): + with self._state_lock: + self.current_state = EsmState.DELETING + self.update_esm_state_in_store(EsmState.DELETING) + self.state_transition_reason = self.user_state_reason + self._shutdown_event.set() + + def poller_loop(self, *args, **kwargs): + with self._state_lock: + self.current_state = EsmState.ENABLED + self.update_esm_state_in_store(EsmState.ENABLED) + self.state_transition_reason = self.user_state_reason + + error_boff = ExponentialBackoff( + initial_interval=2, max_interval=LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_ERROR_SEC + ) + empty_boff = ExponentialBackoff( + initial_interval=1, + max_interval=LAMBDA_EVENT_SOURCE_MAPPING_MAX_BACKOFF_ON_EMPTY_POLL_SEC, + ) + + poll_interval_duration = LAMBDA_EVENT_SOURCE_MAPPING_POLL_INTERVAL_SEC + + while not self._shutdown_event.is_set(): + try: + # TODO: update state transition reason? + self.poller.poll_events() + + # If no exception encountered, reset the backoff + error_boff.reset() + empty_boff.reset() + + # Set the poll frequency back to the default + poll_interval_duration = LAMBDA_EVENT_SOURCE_MAPPING_POLL_INTERVAL_SEC + except EmptyPollResultsException as miss_ex: + # If the event source is empty, backoff + poll_interval_duration = empty_boff.next_backoff() + LOG.debug( + "The event source %s is empty. Backing off for %.2f seconds until next request.", + miss_ex.source_arn, + poll_interval_duration, + ) + except Exception as e: + LOG.error( + "Error while polling messages for event source %s: %s", + self.esm_config.get("EventSourceArn") + or self.esm_config.get("SelfManagedEventSource"), + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + event_source = parse_arn(self.esm_config.get("EventSourceArn")).get("service") + esm_counter.labels( + source=event_source, status=EsmExecutionStatus.source_poller_error + ).increment() + # Wait some time between retries to avoid running into the problem right again + poll_interval_duration = error_boff.next_backoff() + finally: + self._shutdown_event.wait(poll_interval_duration) + + # Optionally closes internal components of Poller. This is a no-op for unimplemented pollers. + self.poller.close() + + try: + # Update state in store after async stop or delete + if self.enabled and self.current_state == EsmState.DELETING: + # TODO: we also need to remove the ESM worker reference from the Lambda provider to esm_worker + # TODO: proper locking for store updates + self.delete_esm_in_store() + elif not self.enabled and self.current_state == EsmState.DISABLING: + with self._state_lock: + self.current_state = EsmState.DISABLED + self.state_transition_reason = self.user_state_reason + self.update_esm_state_in_store(EsmState.DISABLED) + elif not self._graceful_shutdown_triggered: + # HACK: If we reach this state and a graceful shutdown was not triggered, log a warning to indicate + # an unexpected state. + LOG.warning( + "Invalid state %s for event source mapping %s.", + self.current_state, + self.esm_config["UUID"], + ) + except Exception as e: + LOG.warning( + "Failed to update state %s for event source mapping %s. Exception: %s ", + self.current_state, + self.esm_config["UUID"], + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + def delete_esm_in_store(self): + self._state.event_source_mappings.pop(self.esm_config["UUID"], None) + + # TODO: how can we handle async state updates better? Async deletion or disabling needs to update the model state. + def update_esm_state_in_store(self, new_state: EsmState): + esm_update = {"State": new_state} + # TODO: add proper locking for store updates + self._state.event_source_mappings[self.esm_config["UUID"]].update(esm_update) diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/esm_worker_factory.py b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_worker_factory.py new file mode 100644 index 0000000000000..0bf30dfb15d79 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/esm_worker_factory.py @@ -0,0 +1,241 @@ +from typing import Callable + +import botocore.config + +from localstack.aws.api.lambda_ import ( + EventSourceMappingConfiguration, + FunctionResponseType, +) +from localstack.aws.api.pipes import ( + DynamoDBStreamStartPosition, + KinesisStreamStartPosition, + PipeSourceDynamoDBStreamParameters, + PipeSourceKinesisStreamParameters, + PipeSourceParameters, + PipeSourceSqsQueueParameters, + PipeTargetInvocationType, + PipeTargetLambdaFunctionParameters, + PipeTargetParameters, +) +from localstack.services.lambda_ import hooks as lambda_hooks +from localstack.services.lambda_.event_source_mapping.esm_event_processor import ( + EsmEventProcessor, +) +from localstack.services.lambda_.event_source_mapping.esm_worker import EsmStateReason, EsmWorker +from localstack.services.lambda_.event_source_mapping.pipe_loggers.noops_pipe_logger import ( + NoOpsPipeLogger, +) +from localstack.services.lambda_.event_source_mapping.pipe_utils import ( + get_internal_client, + get_standardized_service_name, +) +from localstack.services.lambda_.event_source_mapping.pollers.dynamodb_poller import DynamoDBPoller +from localstack.services.lambda_.event_source_mapping.pollers.kinesis_poller import KinesisPoller +from localstack.services.lambda_.event_source_mapping.pollers.poller import Poller +from localstack.services.lambda_.event_source_mapping.pollers.sqs_poller import ( + DEFAULT_MAX_WAIT_TIME_SECONDS, + SqsPoller, +) +from localstack.services.lambda_.event_source_mapping.senders.lambda_sender import LambdaSender +from localstack.utils.aws.arns import parse_arn +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.lambda_debug_mode.lambda_debug_mode import ( + DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS, + is_lambda_debug_mode, +) + + +class PollerHolder: + """Holds a `Callable` function `create_poller_fn` used to create a Poller. Useful when creating Pollers downstream via hooks.""" + + create_poller_fn: Callable[..., Poller] | None = None + + +class EsmWorkerFactory: + esm_config: EventSourceMappingConfiguration + function_role_arn: str + enabled: bool + + def __init__(self, esm_config, function_role, enabled): + self.esm_config = esm_config + self.function_role_arn = function_role + self.enabled = enabled + + def get_esm_worker(self) -> EsmWorker: + # Sender (always Lambda) + function_arn = self.esm_config["FunctionArn"] + + if is_lambda_debug_mode(): + timeout_seconds = DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS + else: + # 900s is the maximum amount of time a Lambda can run for. + lambda_max_timeout_seconds = 900 + invoke_timeout_buffer_seconds = 5 + timeout_seconds = lambda_max_timeout_seconds + invoke_timeout_buffer_seconds + + lambda_client = get_internal_client( + arn=function_arn, # Only the function_arn is necessary since the Lambda should be able to invoke itself + client_config=botocore.config.Config( + retries={ + "total_max_attempts": 1 + }, # Disable retries, to prevent re-invoking the Lambda + read_timeout=timeout_seconds, + tcp_keepalive=True, + ), + ) + sender = LambdaSender( + target_arn=function_arn, + target_parameters=PipeTargetParameters( + LambdaFunctionParameters=PipeTargetLambdaFunctionParameters( + InvocationType=PipeTargetInvocationType.REQUEST_RESPONSE + ) + ), + target_client=lambda_client, + payload_dict=True, # TODO: This should be handled better since not all payloads in ESM are in the form { "Records" : List[Dict]} + report_batch_item_failures=self.esm_config.get("FunctionResponseTypes") + == [FunctionResponseType.ReportBatchItemFailures], + ) + + # Logger + logger = NoOpsPipeLogger() + + # Event Source Mapping processor + esm_processor = EsmEventProcessor(sender=sender, logger=logger) + + # Poller + source_service = "" + source_client = None + source_arn = self.esm_config.get("EventSourceArn", "") + if source_arn: + parsed_source_arn = parse_arn(source_arn) + source_service = get_standardized_service_name(parsed_source_arn["service"]) + source_client = get_internal_client( + arn=source_arn, + role_arn=self.function_role_arn, + service_principal=ServicePrincipal.lambda_, + source_arn=self.esm_config["FunctionArn"], + client_config=botocore.config.Config( + retries={"total_max_attempts": 1}, # Disable retries + read_timeout=max( + self.esm_config.get( + "MaximumBatchingWindowInSeconds", DEFAULT_MAX_WAIT_TIME_SECONDS + ), + 60, + ) + + 5, # Extend read timeout (with 5s buffer) for long-polling + # Setting tcp_keepalive to true allows the boto client to keep + # a long-running TCP connection when making calls to the gateway. + # This ensures long-poll calls do not prematurely have their socket + # connection marked as stale if no data is transferred for a given + # period of time hence preventing premature drops or resets of the + # connection. + # See https://aws.amazon.com/blogs/networking-and-content-delivery/implementing-long-running-tcp-connections-within-vpc-networking/ + tcp_keepalive=True, + ), + ) + + filter_criteria = self.esm_config.get("FilterCriteria", {"Filters": []}) + user_state_reason = EsmStateReason.USER_ACTION + if source_service == "sqs": + user_state_reason = EsmStateReason.USER_INITIATED + source_parameters = PipeSourceParameters( + FilterCriteria=filter_criteria, + SqsQueueParameters=PipeSourceSqsQueueParameters( + BatchSize=self.esm_config["BatchSize"], + MaximumBatchingWindowInSeconds=self.esm_config[ + "MaximumBatchingWindowInSeconds" + ], + ), + ) + poller = SqsPoller( + source_arn=source_arn, + source_parameters=source_parameters, + source_client=source_client, + processor=esm_processor, + ) + elif source_service == "kinesis": + # TODO: map all supported ESM to Pipe parameters + optional_params = {} + dead_letter_config_arn = ( + self.esm_config.get("DestinationConfig", {}).get("OnFailure", {}).get("Destination") + ) + if dead_letter_config_arn: + optional_params["DeadLetterConfig"] = {"Arn": dead_letter_config_arn} + source_parameters = PipeSourceParameters( + FilterCriteria=filter_criteria, + KinesisStreamParameters=PipeSourceKinesisStreamParameters( + StartingPosition=KinesisStreamStartPosition[ + self.esm_config["StartingPosition"] + ], + BatchSize=self.esm_config["BatchSize"], + MaximumBatchingWindowInSeconds=self.esm_config[ + "MaximumBatchingWindowInSeconds" + ], + MaximumRetryAttempts=self.esm_config["MaximumRetryAttempts"], + MaximumRecordAgeInSeconds=self.esm_config["MaximumRecordAgeInSeconds"], + **optional_params, + ), + ) + poller = KinesisPoller( + esm_uuid=self.esm_config["UUID"], + source_arn=source_arn, + source_parameters=source_parameters, + source_client=source_client, + processor=esm_processor, + invoke_identity_arn=self.function_role_arn, + kinesis_namespace=True, + ) + elif source_service == "dynamodbstreams": + # TODO: map all supported ESM to Pipe parameters + optional_params = {} + dead_letter_config_arn = ( + self.esm_config.get("DestinationConfig", {}).get("OnFailure", {}).get("Destination") + ) + if dead_letter_config_arn: + optional_params["DeadLetterConfig"] = {"Arn": dead_letter_config_arn} + source_parameters = PipeSourceParameters( + FilterCriteria=filter_criteria, + DynamoDBStreamParameters=PipeSourceDynamoDBStreamParameters( + StartingPosition=DynamoDBStreamStartPosition[ + self.esm_config["StartingPosition"] + ], + BatchSize=self.esm_config["BatchSize"], + MaximumBatchingWindowInSeconds=self.esm_config[ + "MaximumBatchingWindowInSeconds" + ], + MaximumRetryAttempts=self.esm_config["MaximumRetryAttempts"], + MaximumRecordAgeInSeconds=self.esm_config["MaximumRecordAgeInSeconds"], + **optional_params, + ), + ) + poller = DynamoDBPoller( + esm_uuid=self.esm_config["UUID"], + source_arn=source_arn, + source_parameters=source_parameters, + source_client=source_client, + processor=esm_processor, + ) + else: + poller_holder = PollerHolder() + lambda_hooks.create_event_source_poller.run( + poller_holder, source_service, self.esm_config + ) + + if not poller_holder.create_poller_fn: + raise Exception( + f"Unsupported event source mapping source service {source_service}. Please upvote or create a feature request." + ) + + poller: Poller = poller_holder.create_poller_fn( + arn=source_arn, + client=source_client, + processor=esm_processor, + ) + + esm_worker = EsmWorker( + self.esm_config, + poller=poller, + enabled=self.enabled, + user_state_reason=user_state_reason, + ) + return esm_worker diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/event_processor.py b/localstack-core/localstack/services/lambda_/event_source_mapping/event_processor.py new file mode 100644 index 0000000000000..cccd02e843aec --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/event_processor.py @@ -0,0 +1,73 @@ +from abc import ABC, abstractmethod +from typing import TypedDict + + +class EventProcessorError(Exception): + pass + + +class PipeInternalError(EventProcessorError): + """Errors caused by an internal event processor implementation such as Pipes or Lambda ESM. + Examples: connection error to target service, transient availability issue, implementation error + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-error-troubleshooting.html#eb-pipes-error-invoke + """ + + pass + + +class CustomerInvocationError(EventProcessorError): + """Errors caused by customers due to configuration or code errors. + Examples: insufficient permissions, logic error in synchronously invoked Lambda target. + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-error-troubleshooting.html#eb-pipes-error-invoke + """ + + pass + + +class BatchFailureError(EventProcessorError): + """The entire batch failed.""" + + def __init__(self, error=None) -> None: + self.error = error + + +class PartialFailurePayload(TypedDict, total=False): + """Following the partial failure payload structure defined by AWS: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-batching-concurrency.html + Special cases: https://repost.aws/knowledge-center/lambda-sqs-report-batch-item-failures + """ + + batchItemFailures: list[dict[str, str]] + + +class PartialBatchFailureError(EventProcessorError): + """A part of the batch failed.""" + + def __init__( + self, + partial_failure_payload: PartialFailurePayload | None = None, + error=None, + ) -> None: + self.error = error + self.partial_failure_payload = partial_failure_payload + + +class EventProcessor(ABC): + """Interface for event processors such as Event Source Mapping or Pipes that process batches of events.""" + + @abstractmethod + def process_events_batch(self, input_events: list[dict]) -> None: + """Processes a batch of `input_events`. + Throws an error upon full or partial batch failure. + """ + + @abstractmethod + def generate_event_failure_context(self, abort_condition: str, **kwargs) -> dict: + """ + Generates a context object for a failed event processing invocation. + + This method is used to create a standardized failure context for both + event source mapping and pipes processing scenarios. The resulting + context will be passed to a Dead Letter Queue (DLQ). + """ + pass diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/noops_event_processor.py b/localstack-core/localstack/services/lambda_/event_source_mapping/noops_event_processor.py new file mode 100644 index 0000000000000..88d89fa41d026 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/noops_event_processor.py @@ -0,0 +1,11 @@ +import logging + +from localstack.services.lambda_.event_source_mapping.event_processor import EventProcessor + +LOG = logging.getLogger(__name__) + + +class NoOpsEventProcessor(EventProcessor): + def process_events_batch(self, input_events: list[dict]) -> None: + """Intentionally do nothing""" + LOG.debug("Process input events %s using NoOpsEventProcessor", input_events) diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/__init__.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/noops_pipe_logger.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/noops_pipe_logger.py new file mode 100644 index 0000000000000..4743ef9a7339b --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/noops_pipe_logger.py @@ -0,0 +1,14 @@ +from localstack.services.lambda_.event_source_mapping.pipe_loggers.pipe_logger import PipeLogger + + +class NoOpsPipeLogger(PipeLogger): + def __init__(self): + super().__init__(log_configuration={}) + + def log_msg(self, message: dict) -> None: + # intentionally logs nothing + pass + + def log(self, logLevel: str, **kwargs): + # intentionally logs nothing + pass diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/pipe_logger.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/pipe_logger.py new file mode 100644 index 0000000000000..1dda8cd7a25f4 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_loggers/pipe_logger.py @@ -0,0 +1,113 @@ +import logging +import time +from abc import ABC, abstractmethod + +from localstack.aws.api.pipes import IncludeExecutionDataOption, LogLevel + +LOG = logging.getLogger(__name__) + + +class PipeLogger(ABC): + """Logger interface designed for EventBridge pipes logging: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-logs.html + """ + + log_configuration: dict + extra_fields: dict + + def __init__(self, log_configuration): + self.log_configuration = log_configuration + self.extra_fields = {} + + @abstractmethod + def log_msg(self, message: dict) -> None: + pass + + @property + def include_execution_data(self) -> list[str] | None: + return self.log_configuration.get("IncludeExecutionData") + + def set_fields(self, **kwargs): + self.extra_fields.update(kwargs) + + def log(self, logLevel: str, **kwargs): + if self.is_enabled_for(logLevel): + message = { + **self.extra_fields, + "timestamp": int(time.time() * 1000), + "logLevel": logLevel, + **kwargs, + } + filtered_message = self.filter_message(message) + LOG.debug(filtered_message) + self.log_msg(filtered_message) + + def is_enabled_for(self, level: str): + return log_levels().index(level) <= log_levels().index(self.get_effective_level()) + + def get_effective_level(self): + return self.log_configuration["Level"] + + def filter_message(self, message: dict) -> dict: + """ + Filters a message payload to ensure it is formatted correcly for EventBridge Pipes Logging (see [AWS docs example](https://aws.amazon.com/blogs/compute/introducing-logging-support-for-amazon-eventbridge-pipes/)): + ```python + { + "resourceArn": str, + "timestamp": str, + "executionId": str, + "messageType": str, + "logLevel": str, + "error": { + "message": str, + "httpStatusCode": int, + "awsService": str, + "requestId": str, + "exceptionType": str, + "resourceArn": str + }, # Optional + "awsRequest": str, # Optional + "awsResponse": str # Optional + } + ``` + """ + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-logs.html#eb-pipes-logs-execution-data + execution_data_fields = { + "payload", + "awsRequest", + "awsResponse", + } + fields_to_include = { + "resourceArn", + "timestamp", + "executionId", + "messageType", + "logLevel", + } + error_fields_to_include = { + "message", + "httpStatusCode", + "awsService", + "requestId", + "exceptionType", + "resourceArn", + } + + if self.include_execution_data == [IncludeExecutionDataOption.ALL]: + fields_to_include.update(execution_data_fields) + + filtered_message = { + key: value for key, value in message.items() if key in fields_to_include + } + + if error := message.get("error"): + filtered_error = { + key: value for key, value in error.items() if key in error_fields_to_include + } + filtered_message["error"] = filtered_error + + return filtered_message + + +def log_levels(): + return [LogLevel.OFF, LogLevel.ERROR, LogLevel.INFO, LogLevel.TRACE] diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_utils.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_utils.py new file mode 100644 index 0000000000000..644e99c264035 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pipe_utils.py @@ -0,0 +1,77 @@ +import json +from datetime import datetime, timezone + +import botocore +from botocore.client import BaseClient + +from localstack.aws.connect import connect_to +from localstack.utils.aws.arns import parse_arn +from localstack.utils.json import BytesEncoder + + +def get_internal_client( + arn: str, + client_config: botocore.config.Config = None, + role_arn: str = None, + service_principal: str = None, + source_arn: str = None, + service: str = None, + session_name: str = None, +) -> BaseClient: + """Return a botocore client for a given arn. Supports: + * assume role if `role_arn` is provided + * request metadata if `source_arn` is provided + """ + parsed_arn = parse_arn(arn) + parsed_arn["service"] = get_standardized_service_name(parsed_arn["service"]) + service = service or parsed_arn["service"] + if role_arn: + client = connect_to.with_assumed_role( + role_arn=role_arn, + service_principal=service_principal, + session_name=session_name, + region_name=parsed_arn["region"], + config=client_config, + ).get_client(service) + else: + client = connect_to( + aws_access_key_id=parsed_arn["account"], + region_name=parsed_arn["region"], + config=client_config, + ).get_client(service) + + if source_arn: + client = client.request_metadata(source_arn=source_arn, service_principal=service_principal) + + return client + + +def get_standardized_service_name(service_name: str) -> str: + """Convert ARN service namespace to standardized service name used for boto clients.""" + if service_name == "states": + return "stepfunctions" + elif service_name == "dynamodb": + return "dynamodbstreams" + else: + return service_name + + +def get_current_time() -> datetime: + return datetime.now(tz=timezone.utc) + + +def get_datetime_from_timestamp(timestamp: float) -> datetime: + return datetime.utcfromtimestamp(timestamp) + # TODO: fixed deprecated API (timestamp snapshots fail with the below) + # return datetime.fromtimestamp(timestamp, tz=timezone.utc) + + +def to_json_str(obj: any) -> str: + """Custom JSON encoding for events with potentially unserializable fields (e.g., byte string). + JSON encoders in LocalStack: + * localstack.utils.json.CustomEncoder + * localstack.utils.json.BytesEncoder + * localstack.services.events.utils.EventJSONEncoder + * localstack.services.stepfunctions.asl.utils.encoding._DateTimeEncoder + """ + return json.dumps(obj, cls=BytesEncoder) diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/__init__.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/dynamodb_poller.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/dynamodb_poller.py new file mode 100644 index 0000000000000..d8b1af71b1b71 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/dynamodb_poller.py @@ -0,0 +1,119 @@ +import logging +from datetime import datetime + +from botocore.client import BaseClient + +from localstack.aws.api.dynamodbstreams import StreamStatus +from localstack.services.lambda_.event_source_mapping.event_processor import ( + EventProcessor, +) +from localstack.services.lambda_.event_source_mapping.pipe_utils import get_current_time +from localstack.services.lambda_.event_source_mapping.pollers.stream_poller import StreamPoller + +LOG = logging.getLogger(__name__) + + +class DynamoDBPoller(StreamPoller): + def __init__( + self, + source_arn: str, + source_parameters: dict | None = None, + source_client: BaseClient | None = None, + processor: EventProcessor | None = None, + partner_resource_arn: str | None = None, + esm_uuid: str | None = None, + shards: dict[str, str] | None = None, + ): + super().__init__( + source_arn, + source_parameters, + source_client, + processor, + esm_uuid=esm_uuid, + partner_resource_arn=partner_resource_arn, + shards=shards, + ) + + @property + def stream_parameters(self) -> dict: + return self.source_parameters["DynamoDBStreamParameters"] + + def initialize_shards(self): + # TODO: update upon re-sharding, maybe using a cache and call every time?! + stream_info = self.source_client.describe_stream(StreamArn=self.source_arn) + stream_status = stream_info["StreamDescription"]["StreamStatus"] + if stream_status != StreamStatus.ENABLED: + LOG.warning( + "DynamoDB stream %s is not enabled. Current status: %s", + self.source_arn, + stream_status, + ) + return {} + + # NOTICE: re-sharding might require updating this periodically (unknown how Pipes does it!?) + # Mapping of shard id => shard iterator + shards = {} + for shard in stream_info["StreamDescription"]["Shards"]: + shard_id = shard["ShardId"] + starting_position = self.stream_parameters["StartingPosition"] + kwargs = {} + get_shard_iterator_response = self.source_client.get_shard_iterator( + StreamArn=self.source_arn, + ShardId=shard_id, + ShardIteratorType=starting_position, + **kwargs, + ) + shards[shard_id] = get_shard_iterator_response["ShardIterator"] + + LOG.debug("Event source %s has %d shards.", self.source_arn, len(self.shards)) + return shards + + def stream_arn_param(self) -> dict: + # Not supported for GetRecords: + # https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_streams_GetRecords.html + return {} + + def event_source(self) -> str: + return "aws:dynamodb" + + def extra_metadata(self) -> dict: + return { + "eventVersion": "1.1", + } + + def transform_into_events(self, records: list[dict], shard_id) -> list[dict]: + events = [] + for record in records: + # TODO: consolidate with DynamoDB event source listener: + # localstack.services.lambda_.event_source_listeners.dynamodb_event_source_listener.DynamoDBEventSourceListener._create_lambda_event_payload + dynamodb = record["dynamodb"] + + if creation_time := dynamodb.get("ApproximateCreationDateTime"): + # Float conversion validated by TestDynamoDBEventSourceMapping.test_dynamodb_event_filter + dynamodb["ApproximateCreationDateTime"] = float(creation_time.timestamp()) + event = { + # TODO: add this metadata after filtering (these are based on the original record!) + # This requires some design adjustment because the eventId and eventName depend on the record. + "eventID": record["eventID"], + "eventName": record["eventName"], + # record content + "dynamodb": dynamodb, + } + events.append(event) + return events + + def failure_payload_details_field_name(self) -> str: + return "DDBStreamBatchInfo" + + def get_approximate_arrival_time(self, record: dict) -> float: + # TODO: validate whether the default should be now + # Optional according to AWS docs: + # https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_streams_StreamRecord.html + # TODO: parse float properly if present from ApproximateCreationDateTime -> now works, compare via debug! + return record["dynamodb"].get("todo", get_current_time().timestamp()) + + def format_datetime(self, time: datetime) -> str: + return f"{time.isoformat(timespec='seconds')}Z" + + def get_sequence_number(self, record: dict) -> str: + return record["dynamodb"]["SequenceNumber"] diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/kinesis_poller.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/kinesis_poller.py new file mode 100644 index 0000000000000..defe87a6a6dee --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/kinesis_poller.py @@ -0,0 +1,212 @@ +import base64 +import json +import logging +from copy import deepcopy +from datetime import datetime + +from botocore.client import BaseClient + +from localstack.aws.api.kinesis import StreamStatus +from localstack.aws.api.pipes import ( + KinesisStreamStartPosition, +) +from localstack.services.lambda_.event_source_mapping.event_processor import ( + EventProcessor, +) +from localstack.services.lambda_.event_source_mapping.pollers.stream_poller import StreamPoller +from localstack.utils.strings import to_str + +LOG = logging.getLogger(__name__) + + +class KinesisPoller(StreamPoller): + # The role ARN of the processor (e.g., role ARN of the Pipe) + invoke_identity_arn: str | None + # Flag to enable nested kinesis namespace when formatting events to support the nested `kinesis` field structure + # used for Lambda ESM: https://docs.aws.amazon.com/lambda/latest/dg/with-kinesis.html#services-kinesis-event-example + # EventBridge Pipes uses no nesting: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-kinesis.html + kinesis_namespace: bool + + def __init__( + self, + source_arn: str, + source_parameters: dict | None = None, + source_client: BaseClient | None = None, + processor: EventProcessor | None = None, + partner_resource_arn: str | None = None, + invoke_identity_arn: str | None = None, + kinesis_namespace: bool = False, + esm_uuid: str | None = None, + shards: dict[str, str] | None = None, + ): + super().__init__( + source_arn, + source_parameters, + source_client, + processor, + esm_uuid=esm_uuid, + partner_resource_arn=partner_resource_arn, + shards=shards, + ) + self.invoke_identity_arn = invoke_identity_arn + self.kinesis_namespace = kinesis_namespace + + @property + def stream_parameters(self) -> dict: + return self.source_parameters["KinesisStreamParameters"] + + def initialize_shards(self) -> dict[str, str]: + # TODO: cache this and update/re-try upon failures + stream_info = self.source_client.describe_stream(StreamARN=self.source_arn) + stream_status = stream_info["StreamDescription"]["StreamStatus"] + if stream_status != StreamStatus.ACTIVE: + LOG.warning( + "Stream %s is not active. Current status: %s", + self.source_arn, + stream_status, + ) + return {} + + # NOTICE: re-sharding might require updating this periodically (unknown how Pipes does it!?) + # Mapping of shard id => shard iterator + shards = {} + for shard in stream_info["StreamDescription"]["Shards"]: + shard_id = shard["ShardId"] + starting_position = self.stream_parameters["StartingPosition"] + kwargs = {} + # TODO: test StartingPosition=AT_TIMESTAMP (only supported for Kinesis!) + if starting_position == KinesisStreamStartPosition.AT_TIMESTAMP: + kwargs["StartingSequenceNumber"] = self.stream_parameters[ + "StartingPositionTimestamp" + ] + get_shard_iterator_response = self.source_client.get_shard_iterator( + StreamARN=self.source_arn, + ShardId=shard_id, + ShardIteratorType=starting_position, + **kwargs, + ) + shards[shard_id] = get_shard_iterator_response["ShardIterator"] + + LOG.debug("Event source %s has %d shards.", self.source_arn, len(self.shards)) + return shards + + def stream_arn_param(self) -> dict: + return {"StreamARN": self.source_arn} + + def event_source(self) -> str: + return "aws:kinesis" + + def extra_metadata(self) -> dict: + return { + "eventVersion": "1.0", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": self.invoke_identity_arn, + } + + def transform_into_events(self, records: list[dict], shard_id) -> list[dict]: + events = [] + for record in records: + # TODO: consolidate with Kinesis event source listener: + # localstack.services.lambda_.event_source_listeners.kinesis_event_source_listener.KinesisEventSourceListener._create_lambda_event_payload + # check `encryptionType` leading to serialization errors by Dotnet Lambdas + sequence_number = record["SequenceNumber"] + event = { + # TODO: add this metadata after filtering. + # This requires some design adjustment because the sequence number depends on the record. + "eventID": f"{shard_id}:{sequence_number}", + } + kinesis_fields = { + "kinesisSchemaVersion": "1.0", + "partitionKey": record["PartitionKey"], + "sequenceNumber": sequence_number, + # TODO: implement heuristic based on content type: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html#pipes-filter-sqs + # boto3 automatically decodes records in get_records(), so we must re-encode + "data": to_str(base64.b64encode(record["Data"])), + "approximateArrivalTimestamp": record["ApproximateArrivalTimestamp"].timestamp(), + } + if self.kinesis_namespace: + event["kinesis"] = kinesis_fields + else: + event.update(kinesis_fields) + events.append(event) + return events + + def failure_payload_details_field_name(self) -> str: + return "KinesisBatchInfo" + + def get_approximate_arrival_time(self, record: dict) -> float: + if self.kinesis_namespace: + return record["kinesis"]["approximateArrivalTimestamp"] + else: + return record["approximateArrivalTimestamp"] + + def format_datetime(self, time: datetime) -> str: + return f"{time.isoformat(timespec='milliseconds')}Z" + + def get_sequence_number(self, record: dict) -> str: + if self.kinesis_namespace: + return record["kinesis"]["sequenceNumber"] + else: + return record["sequenceNumber"] + + def pre_filter(self, events: list[dict]) -> list[dict]: + # TODO: test what happens with a mixture of data and non-data filters? + if has_data_filter_criteria_parsed(self.filter_patterns): + parsed_events = [] + for event in events: + raw_data = self.get_data(event) + try: + data = self.parse_data(raw_data) + # TODO: test "data" key remapping + # Filtering remaps "kinesis.data" in ESM to "data (idempotent for Pipes using "data" directly) + # ESM: https://docs.aws.amazon.com/lambda/latest/dg/with-kinesis-filtering.html + # Pipes: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-kinesis.html + # Pipes filtering: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html + parsed_event = deepcopy(event) + parsed_event["data"] = data + + parsed_events.append(parsed_event) + except json.JSONDecodeError: + LOG.warning( + "Unable to convert event data '%s' to json... Record will be dropped.", + raw_data, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + return parsed_events + else: + return events + + def post_filter(self, events: list[dict]) -> list[dict]: + if has_data_filter_criteria_parsed(self.filter_patterns): + # convert them back (HACK for fixing parity with v1 and getting regression tests passing) + for event in events: + parsed_data = event.pop("data") + encoded_data = self.encode_data(parsed_data) + self.set_data(event, encoded_data) + return events + + def get_data(self, event: dict) -> str: + if self.kinesis_namespace: + return event["kinesis"]["data"] + else: + return event["data"] + + def set_data(self, event: dict, data: bytes) -> None: + if self.kinesis_namespace: + event["kinesis"]["data"] = data + else: + event["data"] = data + + def parse_data(self, raw_data: str) -> dict | str: + decoded_data = base64.b64decode(raw_data) + return json.loads(decoded_data) + + def encode_data(self, parsed_data: dict) -> str: + return base64.b64encode(json.dumps(parsed_data).encode()).decode() + + +def has_data_filter_criteria_parsed(parsed_filters: list[dict]) -> bool: + for filter in parsed_filters: + if "data" in filter: + return True + return False diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/poller.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/poller.py new file mode 100644 index 0000000000000..3f8fdd88f0305 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/poller.py @@ -0,0 +1,208 @@ +import json +import logging +from abc import ABC, abstractmethod +from typing import Any + +from botocore.client import BaseClient + +from localstack.aws.api.pipes import PipeStateReason +from localstack.services.lambda_.event_source_mapping.event_processor import EventProcessor +from localstack.services.lambda_.event_source_mapping.noops_event_processor import ( + NoOpsEventProcessor, +) +from localstack.services.lambda_.event_source_mapping.pipe_utils import get_internal_client +from localstack.utils.aws.arns import parse_arn +from localstack.utils.event_matcher import matches_event + + +class EmptyPollResultsException(Exception): + service: str + source_arn: str + + def __init__(self, service: str = "", source_arn: str = ""): + self.service = service + self.source_arn = source_arn + + +class PipeStateReasonValues(PipeStateReason): + USER_INITIATED = "USER_INITIATED" + NO_RECORDS_PROCESSED = "No records processed" + # TODO: add others (e.g., failure) + + +LOG = logging.getLogger(__name__) + + +class Poller(ABC): + source_arn: str | None + aws_region: str | None + source_parameters: dict + filter_patterns: list[dict[str, Any]] + source_client: BaseClient + + # Target processor (e.g., Pipe, EventSourceMapping) + processor: EventProcessor + + def __init__( + self, + source_arn: str | None = None, + source_parameters: dict | None = None, + source_client: BaseClient | None = None, + processor: EventProcessor | None = None, + ): + # TODO: handle pollers without an ARN (e.g., Apache Kafka) + if source_arn: + self.source_arn = source_arn + self.aws_region = parse_arn(source_arn)["region"] + self.source_client = source_client or get_internal_client(source_arn) + + self.source_parameters = source_parameters or {} + filters = self.source_parameters.get("FilterCriteria", {}).get("Filters", []) + self.filter_patterns = [json.loads(event_filter["Pattern"]) for event_filter in filters] + + # Target processor + self.processor = processor or NoOpsEventProcessor() + + @abstractmethod + def event_source(self) -> str: + """Return the event source metadata (e.g., aws:sqs)""" + pass + + # TODO: create an abstract fetch_records method that all children should implement. This will unify how poller's internally retreive data from an event + # source and make for much easier error handling. + @abstractmethod + def poll_events(self) -> None: + """Poll events polled from the event source and matching at least one filter criteria and invoke the target processor.""" + pass + + def close(self) -> None: + """Closes a target poller alongside all associated internal polling/consuming clients. + Only implemented for supported pollers. Therefore, the default implementation is empty.""" + pass + + def send_events_to_dlq(self, events, context) -> None: + """Send failed events to a DLQ configured on the source. + Only implemented for supported pollers. Therefore, the default implementation is empty.""" + pass + + def filter_events(self, events: list[dict]) -> list[dict]: + """Filter events using the EventBridge event patterns: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html""" + if len(self.filter_patterns) == 0: + return events + + filtered_events = [] + for event in events: + # TODO: add try/catch with default discard and error log for extra resilience + if any(matches_event(pattern, event) for pattern in self.filter_patterns): + filtered_events.append(event) + return filtered_events + + def add_source_metadata(self, events: list[dict], extra_metadata=None) -> list[dict]: + """Add event source metadata to each event for eventSource, eventSourceARN, and awsRegion. + This metadata is added after filtering: https://repost.aws/knowledge-center/eventbridge-filter-events-with-pipes + See "The following fields can't be used in event patterns": + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html + """ + for event in events: + event["eventSourceARN"] = self.source_arn + event["eventSource"] = self.event_source() + event["awsRegion"] = self.aws_region + event.update(self.extra_metadata()) + return events + + def extra_metadata(self) -> dict: + """Default implementation that subclasses can override to customize""" + return {} + + +def has_batch_item_failures( + result: dict | str | None, valid_item_ids: set[str] | None = None +) -> bool: + """Returns False if no batch item failures are present and True otherwise (i.e., including parse exceptions).""" + # TODO: validate correct behavior upon exceptions + try: + failed_items_ids = parse_batch_item_failures(result, valid_item_ids) + return len(failed_items_ids) > 0 + except (KeyError, ValueError): + return True + + +def get_batch_item_failures( + result: dict | str | None, valid_item_ids: set[str] | None = None +) -> list[str] | None: + """ + Returns a list of failed batch item IDs. If an empty list is returned, then the batch should be considered as a complete success. + + If `None` is returned, the batch should be considered a complete failure. + """ + try: + failed_items_ids = parse_batch_item_failures(result, valid_item_ids) + return failed_items_ids + except (KeyError, ValueError): + return None + + +def parse_batch_item_failures( + result: dict | str | None, valid_item_ids: set[str] | None = None +) -> list[str]: + """ + Parses a partial batch failure response, that looks like this: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-batching-concurrency.html + + { + "batchItemFailures": [ + { + "itemIdentifier": "id2" + }, + { + "itemIdentifier": "id4" + } + ] + } + + If the response returns an empty list, then the batch should be considered as a complete success. If an exception + is raised, the batch should be considered a complete failure. + + Pipes partial batch failure: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-batching-concurrency.html + Lambda ESM with SQS: https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html + Special cases: https://repost.aws/knowledge-center/lambda-sqs-report-batch-item-failures + Kinesis: https://docs.aws.amazon.com/lambda/latest/dg/services-kinesis-batchfailurereporting.html + + :param result: the process status (e.g., invocation result from Lambda) + :param valid_item_ids: the set of valid item ids in the batch + :raises KeyError: if the itemIdentifier value is missing or not in the batch + :raises Exception: any other exception related to parsing (e.g., JSON parser error) + :return: a list of item IDs that failed + """ + if not result: + return [] + + if isinstance(result, dict): + partial_batch_failure = result + else: + partial_batch_failure = json.loads(result) + + if not partial_batch_failure: + return [] + + batch_item_failures = partial_batch_failure.get("batchItemFailures") + + if not batch_item_failures: + return [] + + failed_items = [] + for item in batch_item_failures: + if "itemIdentifier" not in item: + raise KeyError(f"missing itemIdentifier in batchItemFailure record {item}") + + item_identifier = item["itemIdentifier"] + if not item_identifier: + raise ValueError("itemIdentifier cannot be empty or null") + + # Optionally validate whether the item_identifier is part of the batch + if valid_item_ids and item_identifier not in valid_item_ids: + raise KeyError(f"itemIdentifier '{item_identifier}' not in the batch") + + failed_items.append(item_identifier) + + return failed_items diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/sqs_poller.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/sqs_poller.py new file mode 100644 index 0000000000000..d39805dce9113 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/sqs_poller.py @@ -0,0 +1,342 @@ +import functools +import json +import logging +from collections import defaultdict +from functools import cached_property + +from botocore.client import BaseClient + +from localstack.aws.api.pipes import PipeSourceSqsQueueParameters +from localstack.aws.api.sqs import MessageSystemAttributeName +from localstack.aws.connect import connect_to +from localstack.services.lambda_.event_source_mapping.event_processor import ( + EventProcessor, + PartialBatchFailureError, +) +from localstack.services.lambda_.event_source_mapping.pollers.poller import ( + EmptyPollResultsException, + Poller, + parse_batch_item_failures, +) +from localstack.services.lambda_.event_source_mapping.senders.sender_utils import ( + batched, +) +from localstack.services.sqs.constants import ( + HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT, + HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS, +) +from localstack.utils.aws.arns import parse_arn +from localstack.utils.strings import first_char_to_lower + +LOG = logging.getLogger(__name__) + +DEFAULT_MAX_RECEIVE_COUNT = 10 +# See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-short-and-long-polling.html +DEFAULT_MAX_WAIT_TIME_SECONDS = 20 + + +class SqsPoller(Poller): + queue_url: str + + batch_size: int + maximum_batching_window: int + + def __init__( + self, + source_arn: str, + source_parameters: dict | None = None, + source_client: BaseClient | None = None, + processor: EventProcessor | None = None, + ): + super().__init__(source_arn, source_parameters, source_client, processor) + self.queue_url = get_queue_url(self.source_arn) + + self.batch_size = self.sqs_queue_parameters.get("BatchSize", DEFAULT_MAX_RECEIVE_COUNT) + # HACK: When the MaximumBatchingWindowInSeconds is not set, just default to short-polling. + # While set in ESM (via the config factory) setting this param as a default in Pipes causes + # parity issues with a retrieved config since no default value is returned. + self.maximum_batching_window = self.sqs_queue_parameters.get( + "MaximumBatchingWindowInSeconds", 0 + ) + + self._register_client_hooks() + + @property + def sqs_queue_parameters(self) -> PipeSourceSqsQueueParameters: + # TODO: De-couple Poller configuration params from ESM/Pipes specific config (i.e PipeSourceSqsQueueParameters) + return self.source_parameters["SqsQueueParameters"] + + @cached_property + def is_fifo_queue(self) -> bool: + # Alternative heuristic: self.queue_url.endswith(".fifo"), but we need the call to get_queue_attributes for IAM + return self.get_queue_attributes().get("FifoQueue", "false").lower() == "true" + + def _register_client_hooks(self): + event_system = self.source_client.meta.events + + def handle_message_count_override(params, context, **kwargs): + requested_count = params.pop("sqs_override_max_message_count", None) + if not requested_count or requested_count <= DEFAULT_MAX_RECEIVE_COUNT: + return + + context[HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT] = str(requested_count) + + def handle_message_wait_time_seconds_override(params, context, **kwargs): + requested_wait = params.pop("sqs_override_wait_time_seconds", None) + if not requested_wait or requested_wait <= DEFAULT_MAX_WAIT_TIME_SECONDS: + return + + context[HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS] = str(requested_wait) + + def handle_inject_headers(params, context, **kwargs): + if override_message_count := context.pop( + HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT, None + ): + params["headers"][HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT] = ( + override_message_count + ) + + if override_wait_time := context.pop( + HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS, None + ): + params["headers"][HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS] = ( + override_wait_time + ) + + event_system.register( + "provide-client-params.sqs.ReceiveMessage", handle_message_count_override + ) + event_system.register( + "provide-client-params.sqs.ReceiveMessage", handle_message_wait_time_seconds_override + ) + # Since we delete SQS messages after processing, this allows us to remove up to 10K entries at a time. + event_system.register( + "provide-client-params.sqs.DeleteMessageBatch", handle_message_count_override + ) + + event_system.register("before-call.sqs.ReceiveMessage", handle_inject_headers) + event_system.register("before-call.sqs.DeleteMessageBatch", handle_inject_headers) + + def get_queue_attributes(self) -> dict: + """The API call to sqs:GetQueueAttributes is required for IAM policy streamsing.""" + get_queue_attributes_response = self.source_client.get_queue_attributes( + QueueUrl=self.queue_url, + AttributeNames=["FifoQueue"], + ) + return get_queue_attributes_response.get("Attributes", {}) + + def event_source(self) -> str: + return "aws:sqs" + + def poll_events(self) -> None: + # In order to improve performance, we've adopted long-polling for the SQS poll operation `ReceiveMessage` [1]. + # * Our LS-internal optimizations leverage custom boto-headers to set larger batch sizes and longer wait times than what the AWS API allows [2]. + # * Higher batch collection durations and no. of records retrieved per request mean fewer calls to the LocalStack gateway [3] when polling an event-source [4]. + # * LocalStack shutdown works because the LocalStack gateway shuts down and terminates the open connection. + # * Provider lifecycle hooks have been added to ensure blocking long-poll calls are gracefully interrupted and returned. + # + # Pros (+) / Cons (-): + # + Alleviates pressure on the gateway since each `ReceiveMessage` call only returns once we reach the desired `BatchSize` or the `WaitTimeSeconds` elapses. + # + Matches the AWS behavior also using long-polling + # - Blocks a LocalStack gateway thread (default 1k) for every open connection, which could lead to resource contention if used at scale. + # + # Refs / Notes: + # [1] Amazon SQS short and long polling: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-short-and-long-polling.html + # [2] PR (2025-02): https://github.com/localstack/localstack/pull/12002 + # [3] Note: Under high volumes of requests, the LocalStack gateway becomes a major performance bottleneck. + # [4] ESM blog mentioning long-polling: https://aws.amazon.com/de/blogs/aws/aws-lambda-adds-amazon-simple-queue-service-to-supported-event-sources/ + + # TODO: Handle exceptions differently i.e QueueNotExist or ConnectionFailed should retry with backoff + response = self.source_client.receive_message( + QueueUrl=self.queue_url, + MaxNumberOfMessages=min(self.batch_size, DEFAULT_MAX_RECEIVE_COUNT), + WaitTimeSeconds=min(self.maximum_batching_window, DEFAULT_MAX_WAIT_TIME_SECONDS), + MessageAttributeNames=["All"], + MessageSystemAttributeNames=[MessageSystemAttributeName.All], + # Override how many messages we can receive per call + sqs_override_max_message_count=self.batch_size, + # Override how long to wait until batching conditions are met + sqs_override_wait_time_seconds=self.maximum_batching_window, + ) + + messages = response.get("Messages", []) + if not messages: + raise EmptyPollResultsException(service="sqs", source_arn=self.source_arn) + + LOG.debug("Polled %d events from %s", len(messages), self.source_arn) + # TODO: implement invocation payload size quota + # NOTE: Split up a batch into mini-batches of up to 2.5K records each. This is to prevent exceeding the 6MB size-limit + # imposed on payloads sent to a Lambda as well as LocalStack Lambdas failing to handle large payloads efficiently. + # See https://docs.aws.amazon.com/lambda/latest/dg/invocation-eventsourcemapping.html#invocation-eventsourcemapping-batching + for message_batch in batched(messages, 2500): + if len(message_batch) < len(messages): + LOG.debug( + "Splitting events from %s into mini-batch (%d/%d)", + self.source_arn, + len(message_batch), + len(messages), + ) + try: + if self.is_fifo_queue: + # TODO: think about starvation behavior because once failing message could block other groups + fifo_groups = split_by_message_group_id(message_batch) + for fifo_group_messages in fifo_groups.values(): + self.handle_messages(fifo_group_messages) + else: + self.handle_messages(message_batch) + + # TODO: unify exception handling across pollers: should we catch and raise? + except Exception as e: + # TODO: improve error messages (produce same failure and design better error messages) + LOG.warning( + "Polling or batch processing failed: %s", + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + def handle_messages(self, messages): + polled_events = transform_into_events(messages) + # Filtering: matching vs. discarded (i.e., not matching filter criteria) + # TODO: implement format detection behavior (e.g., for JSON body): + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html#pipes-filter-sqs + # Check whether we need poller-specific filter-preprocessing here without modifying the actual event! + # convert to json for filtering (HACK for fixing parity with v1 and getting regression tests passing) + for event in polled_events: + try: + event["body"] = json.loads(event["body"]) + except json.JSONDecodeError: + LOG.debug( + "Unable to convert event body '%s' to json... Event might be dropped.", + event["body"], + ) + matching_events = self.filter_events(polled_events) + # convert them back (HACK for fixing parity with v1 and getting regression tests passing) + for event in matching_events: + event["body"] = ( + json.dumps(event["body"]) if not isinstance(event["body"], str) else event["body"] + ) + + all_message_ids = {message["MessageId"] for message in messages} + matching_message_ids = {event["messageId"] for event in matching_events} + discarded_message_ids = all_message_ids.difference(matching_message_ids) + # Delete discarded events immediately: + # https://lucvandonkersgoed.com/2022/01/20/the-9-ways-an-sqs-message-can-be-deleted/#7-event-source-mappings-with-filters + self.delete_messages(messages, discarded_message_ids) + + # Don't trigger upon empty events + if len(matching_events) == 0: + return + # Enrich events with metadata after filtering + enriched_events = self.add_source_metadata(matching_events) + + # Invoke the processor (e.g., Pipe, ESM) and handle partial batch failures + try: + self.processor.process_events_batch(enriched_events) + successful_message_ids = all_message_ids + except PartialBatchFailureError as e: + failed_message_ids = parse_batch_item_failures( + e.partial_failure_payload, matching_message_ids + ) + successful_message_ids = matching_message_ids.difference(failed_message_ids) + + # Only delete messages that are processed successfully as described here: + # https://docs.aws.amazon.com/en_gb/lambda/latest/dg/with-sqs.html + # When Lambda reads a batch, the messages stay in the queue but are hidden for the length of the queue's + # visibility timeout. If your function successfully processes the batch, Lambda deletes the messages + # from the queue. By default, if your function encounters an error while processing a batch, + # all messages in that batch become visible in the queue again. For this reason, your function code must + # be able to process the same message multiple times without unintended side effects. + # Troubleshooting: https://repost.aws/knowledge-center/lambda-sqs-report-batch-item-failures + # For FIFO queues, AWS also deletes successfully sent messages. Therefore, the AWS docs recommends: + # "If you're using this feature with a FIFO queue, your function should stop processing messages after the first + # failure and return all failed and unprocessed messages in batchItemFailures. This helps preserve the ordering + # of messages in your queue." + # Following this recommendation could result in the unsolved side effect that valid messages are continuously + # placed in the same batch as failing messages: + # * https://stackoverflow.com/questions/78694079/how-to-stop-fifo-sqs-messages-from-being-placed-in-a-batch-with-failing-messages + # * https://stackoverflow.com/questions/76912394/can-i-report-only-messages-from-failing-group-id-in-reportbatchitemfailures-resp + + # TODO: Test blocking failure behavior for FIFO queues to guarantee strict ordering + # -> might require some checkpointing or retry control on the poller side?! + # The poller should only proceed processing FIFO queues after having retried failing messages: + # "If your pipe returns an error, the pipe attempts all retries on the affected messages before EventBridge + # receives additional messages from the same group." + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-sqs.html + self.delete_messages(messages, successful_message_ids) + + def delete_messages(self, messages: list[dict], message_ids_to_delete: set): + """Delete SQS `messages` from the source queue that match a MessageId within `message_ids_to_delete`""" + # TODO: unclear how (partial) failures for deleting are handled, retry or fail batch? Hard to test against AWS + if len(message_ids_to_delete) > 0: + entries = [ + {"Id": str(count), "ReceiptHandle": message["ReceiptHandle"]} + for count, message in enumerate(messages) + if message["MessageId"] in message_ids_to_delete + ] + + self.source_client.delete_message_batch( + QueueUrl=self.queue_url, + Entries=entries, + # Override how many messages can be deleted at once + sqs_override_max_message_count=self.batch_size, + ) + + +def split_by_message_group_id(messages) -> defaultdict[str, list[dict]]: + """Splitting SQS messages by MessageGroupId to ensure strict ordering for FIFO queues""" + fifo_groups = defaultdict(list) + for message in messages: + message_group_id = message["Attributes"]["MessageGroupId"] + fifo_groups[message_group_id].append(message) + return fifo_groups + + +def transform_into_events(messages: list[dict]) -> list[dict]: + events = [] + for message in messages: + # TODO: consolidate with SQS event source listener: + # localstack.services.lambda_.event_source_listeners.sqs_event_source_listener.SQSEventSourceListener._send_event_to_lambda + message_attrs = message_attributes_to_lower(message.get("MessageAttributes")) + event = { + # Original SQS message attributes + "messageId": message["MessageId"], + "receiptHandle": message["ReceiptHandle"], + # TODO: test with empty body + # TODO: implement heuristic based on content type: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html#pipes-filter-sqs + "body": message.get("Body", "MessageBody"), + "attributes": message.get("Attributes", {}), + "messageAttributes": message_attrs, + # TODO: test with empty body + "md5OfBody": message.get("MD5OfBody") or message.get("MD5OfMessageBody"), + } + # TODO: test Pipe with message attributes (only covered by Lambda ESM SQS test so far) + if md5_of_message_attributes := message.get("MD5OfMessageAttributes"): + event["md5OfMessageAttributes"] = md5_of_message_attributes + events.append(event) + return events + + +@functools.cache +def get_queue_url(queue_arn: str) -> str: + parsed_arn = parse_arn(queue_arn) + + queue_name = parsed_arn["resource"] + account_id = parsed_arn["account"] + region = parsed_arn["region"] + + sqs_client = connect_to(region_name=region).sqs + queue_url = sqs_client.get_queue_url(QueueName=queue_name, QueueOwnerAWSAccountId=account_id)[ + "QueueUrl" + ] + return queue_url + + +def message_attributes_to_lower(message_attrs): + """Convert message attribute details (first characters) to lower case (e.g., stringValue, dataType).""" + message_attrs = message_attrs or {} + for _, attr in message_attrs.items(): + if not isinstance(attr, dict): + continue + for key, value in dict(attr).items(): + attr[first_char_to_lower(key)] = attr.pop(key) + return message_attrs diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/stream_poller.py b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/stream_poller.py new file mode 100644 index 0000000000000..07ef9a7d9cca5 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/pollers/stream_poller.py @@ -0,0 +1,521 @@ +import json +import logging +import threading +from abc import abstractmethod +from bisect import bisect_left +from collections import defaultdict +from datetime import datetime +from typing import Iterator + +from botocore.client import BaseClient +from botocore.exceptions import ClientError + +from localstack.aws.api.pipes import ( + OnPartialBatchItemFailureStreams, +) +from localstack.services.lambda_.event_source_mapping.event_processor import ( + BatchFailureError, + CustomerInvocationError, + EventProcessor, + PartialBatchFailureError, + PipeInternalError, +) +from localstack.services.lambda_.event_source_mapping.pipe_utils import ( + get_current_time, + get_datetime_from_timestamp, + get_internal_client, +) +from localstack.services.lambda_.event_source_mapping.pollers.poller import ( + EmptyPollResultsException, + Poller, + get_batch_item_failures, +) +from localstack.services.lambda_.event_source_mapping.pollers.sqs_poller import get_queue_url +from localstack.services.lambda_.event_source_mapping.senders.sender_utils import ( + batched, +) +from localstack.utils.aws.arns import parse_arn, s3_bucket_name +from localstack.utils.backoff import ExponentialBackoff +from localstack.utils.batch_policy import Batcher +from localstack.utils.strings import long_uid + +LOG = logging.getLogger(__name__) + + +# TODO: fix this poller to support resharding +# https://docs.aws.amazon.com/streams/latest/dev/kinesis-using-sdk-java-resharding.html +class StreamPoller(Poller): + # Mapping of shard id => shard iterator + # TODO: This mapping approach needs to be re-worked to instead store last processed sequence number. + shards: dict[str, str] + # Iterator for round-robin polling from different shards because a batch cannot contain events from different shards + # This is a workaround for not handling shards in parallel. + iterator_over_shards: Iterator[tuple[str, str]] | None + # ESM UUID is needed in failure processing to form s3 failure destination object key + esm_uuid: str | None + + # The ARN of the processor (e.g., Pipe ARN) + partner_resource_arn: str | None + + # Used for backing-off between retries and breaking the retry loop + _is_shutdown: threading.Event + + # Collects and flushes a batch of records based on a batching policy + shard_batcher: dict[str, Batcher[dict]] + + def __init__( + self, + source_arn: str, + source_parameters: dict | None = None, + source_client: BaseClient | None = None, + processor: EventProcessor | None = None, + partner_resource_arn: str | None = None, + esm_uuid: str | None = None, + shards: dict[str, str] | None = None, + ): + super().__init__(source_arn, source_parameters, source_client, processor) + self.partner_resource_arn = partner_resource_arn + self.esm_uuid = esm_uuid + self.shards = shards if shards is not None else {} + self.iterator_over_shards = None + + self._is_shutdown = threading.Event() + + self.shard_batcher = defaultdict( + lambda: Batcher( + max_count=self.stream_parameters.get("BatchSize", 100), + max_window=self.stream_parameters.get("MaximumBatchingWindowInSeconds", 0), + ) + ) + + @abstractmethod + def transform_into_events(self, records: list[dict], shard_id) -> list[dict]: + pass + + @property + @abstractmethod + def stream_parameters(self) -> dict: + pass + + @abstractmethod + def initialize_shards(self) -> dict[str, str]: + """Returns a shard dict mapping from shard id -> shard iterator + The implementations for Kinesis and DynamoDB are similar but differ in various ways: + * Kinesis uses "StreamARN" and DynamoDB uses "StreamArn" as source parameter + * Kinesis uses "StreamStatus.ACTIVE" and DynamoDB uses "StreamStatus.ENABLED" + * Only Kinesis supports the additional StartingPosition called "AT_TIMESTAMP" using "StartingPositionTimestamp" + """ + pass + + @abstractmethod + def stream_arn_param(self) -> dict: + """Returns a dict of the correct key/value pair for the stream arn used in GetRecords. + Either StreamARN for Kinesis or {} for DynamoDB (unsupported)""" + pass + + @abstractmethod + def failure_payload_details_field_name(self) -> str: + pass + + @abstractmethod + def get_approximate_arrival_time(self, record: dict) -> float: + pass + + @abstractmethod + def format_datetime(self, time: datetime) -> str: + """Formats a datetime in the correct format for DynamoDB (with ms) or Kinesis (without ms)""" + pass + + @abstractmethod + def get_sequence_number(self, record: dict) -> str: + pass + + def close(self): + self._is_shutdown.set() + + def pre_filter(self, events: list[dict]) -> list[dict]: + return events + + def post_filter(self, events: list[dict]) -> list[dict]: + return events + + def poll_events(self): + """Generalized poller for streams such as Kinesis or DynamoDB + Examples of Kinesis consumers: + * StackOverflow: https://stackoverflow.com/a/22403036/6875981 + * AWS Sample: https://github.com/aws-samples/kinesis-poster-worker/blob/master/worker.py + Examples of DynamoDB consumers: + * Blogpost: https://www.tecracer.com/blog/2022/05/getting-a-near-real-time-view-of-a-dynamodb-stream-with-python.html + """ + # TODO: consider potential shard iterator timeout after 300 seconds (likely not relevant with short-polling): + # https://docs.aws.amazon.com/streams/latest/dev/troubleshooting-consumers.html#shard-iterator-expires-unexpectedly + # Does this happen if no records are received for 300 seconds? + if not self.shards: + self.shards = self.initialize_shards() + + if not self.shards: + LOG.debug("No shards found for %s.", self.source_arn) + raise EmptyPollResultsException(service=self.event_source(), source_arn=self.source_arn) + else: + # Remove all shard batchers without corresponding shards + for shard_id in self.shard_batcher.keys() - self.shards.keys(): + self.shard_batcher.pop(shard_id, None) + + # TODO: improve efficiency because this currently limits the throughput to at most batch size per poll interval + # Handle shards round-robin. Re-initialize current shard iterator once all shards are handled. + if self.iterator_over_shards is None: + self.iterator_over_shards = iter(self.shards.items()) + + current_shard_tuple = next(self.iterator_over_shards, None) + if not current_shard_tuple: + self.iterator_over_shards = iter(self.shards.items()) + current_shard_tuple = next(self.iterator_over_shards, None) + + # TODO Better handling when shards are initialised and the iterator returns nothing + if not current_shard_tuple: + raise PipeInternalError( + "Failed to retrieve any shards for stream polling despite initialization." + ) + + try: + self.poll_events_from_shard(*current_shard_tuple) + except PipeInternalError: + # TODO: standardize logging + # Ignore and wait for the next polling interval, which will do retry + pass + + def poll_events_from_shard(self, shard_id: str, shard_iterator: str): + get_records_response = self.get_records(shard_iterator) + records: list[dict] = get_records_response.get("Records", []) + if not (next_shard_iterator := get_records_response.get("NextShardIterator")): + # If the next shard iterator is None, we can assume the shard is closed or + # has expired on the DynamoDB Local server, hence we should re-initialize. + self.shards = self.initialize_shards() + else: + # We should always be storing the next_shard_iterator value, otherwise we risk an iterator expiring + # and all records being re-processed. + self.shards[shard_id] = next_shard_iterator + + # We cannot reliably back-off when no records found since an iterator + # may have to move multiple times until records are returned. + # See https://docs.aws.amazon.com/streams/latest/dev/troubleshooting-consumers.html#getrecords-returns-empty + # However, we still need to check if batcher should be triggered due to time-based batching. + should_flush = self.shard_batcher[shard_id].add(records) + if not should_flush: + return + + # Retrieve and drain all events in batcher + collected_records = self.shard_batcher[shard_id].flush() + # If there is overflow (i.e 1k BatchSize and 1.2K returned in flush), further split up the batch. + for batch in batched(collected_records, self.stream_parameters.get("BatchSize")): + # This could potentially lead to data loss if forward_events_to_target raises an exception after a flush + # which would otherwise be solved with checkpointing. + # TODO: Implement checkpointing, leasing, etc. from https://docs.aws.amazon.com/streams/latest/dev/kcl-concepts.html + self.forward_events_to_target(shard_id, batch) + + def forward_events_to_target(self, shard_id, records): + polled_events = self.transform_into_events(records, shard_id) + abort_condition = None + # TODO: implement format detection behavior (e.g., for JSON body): + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html + # Check whether we need poller-specific filter-preprocessing here without modifying the actual event! + # convert to json for filtering (HACK for fixing parity with v1 and getting regression tests passing) + # localstack.services.lambda_.event_source_listeners.kinesis_event_source_listener.KinesisEventSourceListener._filter_records + # TODO: explore better abstraction for the entire filtering, including the set_data and get_data remapping + # We need better clarify which transformations happen before and after filtering -> fix missing test coverage + parsed_events = self.pre_filter(polled_events) + # TODO: advance iterator past matching events! + # We need to checkpoint the sequence number for each shard and then advance the shard iterator using + # GetShardIterator with a given sequence number + # https://docs.aws.amazon.com/kinesis/latest/APIReference/API_GetShardIterator.html + # Failing to do so kinda blocks the stream resulting in very high latency. + matching_events = self.filter_events(parsed_events) + matching_events_post_filter = self.post_filter(matching_events) + + # TODO: implement MaximumBatchingWindowInSeconds flush condition (before or after filter?) + # Don't trigger upon empty events + if len(matching_events_post_filter) == 0: + return + + events = self.add_source_metadata(matching_events_post_filter) + LOG.debug("Polled %d events from %s in shard %s", len(events), self.source_arn, shard_id) + # -> This could be tested by setting a high retry number, using a long pipe execution, and a relatively + # short record expiration age at the source. Check what happens if the record expires at the source. + # A potential implementation could use checkpointing based on the iterator position (within shard scope) + # TODO: handle partial batch failure (see poller.py:parse_batch_item_failures) + # TODO: think about how to avoid starvation of other shards if one shard runs into infinite retries + attempts = 0 + discarded_events_for_dlq = [] + error_payload = {} + + max_retries = self.stream_parameters.get("MaximumRetryAttempts", -1) + max_record_age = max( + self.stream_parameters.get("MaximumRecordAgeInSeconds", -1), 0 + ) # Disable check if -1 + # NOTE: max_retries == 0 means exponential backoff is disabled + boff = ExponentialBackoff(max_retries=max_retries) + while not abort_condition and events and not self._is_shutdown.is_set(): + if self.max_retries_exceeded(attempts): + abort_condition = "RetryAttemptsExhausted" + break + + if max_record_age: + events, expired_events = self.bisect_events_by_record_age(max_record_age, events) + if expired_events: + discarded_events_for_dlq.extend(expired_events) + continue + + try: + if attempts > 0: + # TODO: Should we always backoff (with jitter) before processing since we may not want multiple pollers + # all starting up and polling simultaneously + # For example: 500 persisted ESMs starting up and requesting concurrently could flood gateway + self._is_shutdown.wait(boff.next_backoff()) + + self.processor.process_events_batch(events) + boff.reset() + # We may need to send on data to a DLQ so break the processing loop and proceed if invocation successful. + break + except PartialBatchFailureError as ex: + # TODO: add tests for partial batch failure scenarios + if ( + self.stream_parameters.get("OnPartialBatchItemFailure") + == OnPartialBatchItemFailureStreams.AUTOMATIC_BISECT + ): + # TODO: implement and test splitting batches in half until batch size 1 + # https://docs.aws.amazon.com/eventbridge/latest/pipes-reference/API_PipeSourceKinesisStreamParameters.html + LOG.warning( + "AUTOMATIC_BISECT upon partial batch item failure is not yet implemented. Retrying the entire batch." + ) + error_payload = ex.error + + # Extract all sequence numbers from events in batch. This allows us to fail the whole batch if + # an unknown itemidentifier is returned. + batch_sequence_numbers = { + self.get_sequence_number(event) for event in matching_events + } + + # If the batchItemFailures array contains multiple items, Lambda uses the record with the lowest sequence number as the checkpoint. + # Lambda then retries all records starting from that checkpoint. + failed_sequence_ids: list[int] | None = get_batch_item_failures( + ex.partial_failure_payload, batch_sequence_numbers + ) + + # If None is returned, consider the entire batch a failure. + if failed_sequence_ids is None: + continue + + # This shouldn't be possible since a PartialBatchFailureError was raised + if len(failed_sequence_ids) == 0: + assert failed_sequence_ids, ( + "Invalid state encountered: PartialBatchFailureError raised but no batch item failures found." + ) + + lowest_sequence_id: str = min(failed_sequence_ids, key=int) + + # Discard all successful events and re-process from sequence number of failed event + _, events = self.bisect_events(lowest_sequence_id, events) + except BatchFailureError as ex: + error_payload = ex.error + + # FIXME partner_resource_arn is not defined in ESM + LOG.debug( + "Attempt %d failed while processing %s with events: %s", + attempts, + self.partner_resource_arn or self.source_arn, + events, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + except Exception: + # FIXME partner_resource_arn is not defined in ESM + LOG.error( + "Attempt %d failed with unexpected error while processing %s with events: %s", + attempts, + self.partner_resource_arn or self.source_arn, + events, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + finally: + # Retry polling until the record expires at the source + attempts += 1 + + if discarded_events_for_dlq: + abort_condition = "RecordAgeExceeded" + error_payload = {} + events = discarded_events_for_dlq + + # Send failed events to potential DLQ + if abort_condition: + failure_context = self.processor.generate_event_failure_context( + abort_condition=abort_condition, + error=error_payload, + attempts_count=attempts, + partner_resource_arn=self.partner_resource_arn, + ) + self.send_events_to_dlq(shard_id, events, context=failure_context) + + def get_records(self, shard_iterator: str) -> dict: + """Returns a GetRecordsOutput from the GetRecords endpoint of streaming services such as Kinesis or DynamoDB""" + try: + get_records_response = self.source_client.get_records( + # TODO: add test for cross-account scenario + # Differs for Kinesis and DynamoDB but required for cross-account scenario + **self.stream_arn_param(), + ShardIterator=shard_iterator, + Limit=self.stream_parameters["BatchSize"], + ) + return get_records_response + # TODO: test iterator expired with conditional error scenario (requires failure destinations) + except self.source_client.exceptions.ExpiredIteratorException as e: + LOG.debug( + "Shard iterator %s expired for stream %s, re-initializing shards", + shard_iterator, + self.source_arn, + ) + # TODO: test TRIM_HORIZON and AT_TIMESTAMP scenarios for this case. We don't want to start from scratch and + # might need to think about checkpointing here. + self.shards = self.initialize_shards() + raise PipeInternalError from e + except ClientError as e: + if "AccessDeniedException" in str(e): + LOG.warning( + "Insufficient permissions to get records from stream %s: %s", + self.source_arn, + e, + ) + raise CustomerInvocationError from e + elif "ResourceNotFoundException" in str(e): + # FIXME: The 'Invalid ShardId in ShardIterator' error is returned by DynamoDB-local. Unsure when/why this is returned. + if "Invalid ShardId in ShardIterator" in str(e): + LOG.warning( + "Invalid ShardId in ShardIterator for %s. Re-initializing shards.", + self.source_arn, + ) + self.shards = self.initialize_shards() + else: + LOG.warning( + "Source stream %s does not exist: %s", + self.source_arn, + e, + ) + raise CustomerInvocationError from e + elif "TrimmedDataAccessException" in str(e): + LOG.debug( + "Attempted to iterate over trimmed record or expired shard iterator %s for stream %s, re-initializing shards", + shard_iterator, + self.source_arn, + ) + self.shards = self.initialize_shards() + else: + LOG.debug("ClientError during get_records for stream %s: %s", self.source_arn, e) + raise PipeInternalError from e + + def send_events_to_dlq(self, shard_id, events, context) -> None: + dlq_arn = self.stream_parameters.get("DeadLetterConfig", {}).get("Arn") + if dlq_arn: + failure_timstamp = get_current_time() + dlq_event = self.create_dlq_event(shard_id, events, context, failure_timstamp) + # Send DLQ event to DLQ target + parsed_arn = parse_arn(dlq_arn) + service = parsed_arn["service"] + # TODO: use a sender instance here, likely inject via DI into poller (what if it updates?) + if service == "sqs": + # TODO: inject and cache SQS client using proper IAM role (supports cross-account operations) + sqs_client = get_internal_client(dlq_arn) + # TODO: check if the DLQ exists + dlq_url = get_queue_url(dlq_arn) + # TODO: validate no FIFO queue because they are unsupported + sqs_client.send_message(QueueUrl=dlq_url, MessageBody=json.dumps(dlq_event)) + elif service == "sns": + sns_client = get_internal_client(dlq_arn) + sns_client.publish(TopicArn=dlq_arn, Message=json.dumps(dlq_event)) + elif service == "s3": + s3_client = get_internal_client(dlq_arn) + dlq_event_with_payload = { + **dlq_event, + "payload": { + "Records": events, + }, + } + s3_client.put_object( + Bucket=s3_bucket_name(dlq_arn), + Key=get_failure_s3_object_key(self.esm_uuid, shard_id, failure_timstamp), + Body=json.dumps(dlq_event_with_payload), + ) + else: + LOG.warning("Unsupported DLQ service %s", service) + + def create_dlq_event( + self, shard_id: str, events: list[dict], context: dict, failure_timestamp: datetime + ) -> dict: + first_record = events[0] + first_record_arrival = get_datetime_from_timestamp( + self.get_approximate_arrival_time(first_record) + ) + + last_record = events[-1] + last_record_arrival = get_datetime_from_timestamp( + self.get_approximate_arrival_time(last_record) + ) + return { + **context, + self.failure_payload_details_field_name(): { + "approximateArrivalOfFirstRecord": self.format_datetime(first_record_arrival), + "approximateArrivalOfLastRecord": self.format_datetime(last_record_arrival), + "batchSize": len(events), + "endSequenceNumber": self.get_sequence_number(last_record), + "shardId": shard_id, + "startSequenceNumber": self.get_sequence_number(first_record), + "streamArn": self.source_arn, + }, + "timestamp": failure_timestamp.isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ), + "version": "1.0", + } + + def max_retries_exceeded(self, attempts: int) -> bool: + maximum_retry_attempts = self.stream_parameters.get("MaximumRetryAttempts", -1) + # Infinite retries until the source expires + if maximum_retry_attempts == -1: + return False + return attempts > maximum_retry_attempts + + def bisect_events( + self, sequence_number: str, events: list[dict] + ) -> tuple[list[dict], list[dict]]: + """Splits list of events in two, where a sequence number equals a passed parameter `sequence_number`. + This is used for: + - `ReportBatchItemFailures`: Discarding events in a batch following a failure when is set. + - `BisectBatchOnFunctionError`: Used to split a failed batch in two when doing a retry (not implemented).""" + for i, event in enumerate(events): + if self.get_sequence_number(event) == sequence_number: + return events[:i], events[i:] + + return events, [] + + def bisect_events_by_record_age( + self, maximum_record_age: int, events: list[dict] + ) -> tuple[list[dict], list[dict]]: + """Splits events into [valid_events], [expired_events] based on record age. + Where: + - Events with age < maximum_record_age are valid. + - Events with age >= maximum_record_age are expired.""" + cutoff_timestamp = get_current_time().timestamp() - maximum_record_age + index = bisect_left(events, cutoff_timestamp, key=self.get_approximate_arrival_time) + return events[index:], events[:index] + + +def get_failure_s3_object_key(esm_uuid: str, shard_id: str, failure_datetime: datetime) -> str: + """ + From https://docs.aws.amazon.com/lambda/latest/dg/kinesis-on-failure-destination.html: + + The S3 object containing the invocation record uses the following naming convention: + aws/lambda///YYYY/MM/DD/YYYY-MM-DDTHH.MM.SS- + + :return: Key for s3 object that invocation failure record will be put to + """ + timestamp = failure_datetime.strftime("%Y-%m-%dT%H.%M.%S") + year_month_day = failure_datetime.strftime("%Y/%m/%d") + random_uuid = long_uid() + return f"aws/lambda/{esm_uuid}/{shard_id}/{year_month_day}/{timestamp}-{random_uuid}" diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/senders/__init__.py b/localstack-core/localstack/services/lambda_/event_source_mapping/senders/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/senders/lambda_sender.py b/localstack-core/localstack/services/lambda_/event_source_mapping/senders/lambda_sender.py new file mode 100644 index 0000000000000..71911f545a600 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/senders/lambda_sender.py @@ -0,0 +1,112 @@ +import json +import logging + +from localstack.aws.api.lambda_ import InvocationType +from localstack.aws.api.pipes import PipeTargetInvocationType +from localstack.services.lambda_.api_utils import function_locators_from_arn +from localstack.services.lambda_.event_source_mapping.pipe_utils import to_json_str +from localstack.services.lambda_.event_source_mapping.pollers.poller import has_batch_item_failures +from localstack.services.lambda_.event_source_mapping.senders.sender import ( + PartialFailureSenderError, + Sender, + SenderError, +) + +LOG = logging.getLogger(__name__) + + +class LambdaSender(Sender): + # Flag to enable the payload dict using the "Records" key used for Lambda event source mapping + payload_dict: bool + + # Flag to enable partial successes/failures when processing batched events through a Lambda event source mapping + report_batch_item_failures: bool + + def __init__( + self, + target_arn, + target_parameters=None, + target_client=None, + payload_dict=False, + report_batch_item_failures=False, + ): + super().__init__(target_arn, target_parameters, target_client) + self.payload_dict = payload_dict + self.report_batch_item_failures = report_batch_item_failures + + def event_target(self) -> str: + return "aws:lambda" + + def send_events(self, events: list[dict] | dict) -> dict: + if self.payload_dict: + events = {"Records": events} + # TODO: test qualified + unqualified Lambda invoke + # According to Pipe trace logs, the internal awsRequest contains a qualifier, even if "null" + _, qualifier, _, _ = function_locators_from_arn(self.target_arn) + optional_qualifier = {} + if qualifier is not None: + optional_qualifier["Qualifier"] = qualifier + invocation_type = InvocationType.RequestResponse + if ( + self.target_parameters.get("LambdaFunctionParameters", {}).get("InvocationType") + == PipeTargetInvocationType.FIRE_AND_FORGET + ): + invocation_type = InvocationType.Event + + # TODO: test special payloads (e.g., None, str, empty str, bytes) + # see "to_bytes(json.dumps(payload or {}, cls=BytesEncoder))" in legacy invoke adapter + # localstack.services.lambda_.event_source_listeners.adapters.EventSourceAsfAdapter.invoke_with_statuscode + invoke_result = self.target_client.invoke( + FunctionName=self.target_arn, + Payload=to_json_str(events), + InvocationType=invocation_type, + **optional_qualifier, + ) + + try: + payload = json.load(invoke_result["Payload"]) + except json.JSONDecodeError: + payload = None + LOG.debug( + "Payload from Lambda invocation '%s' is invalid json. Setting this to 'None'", + invoke_result["Payload"], + ) + + if function_error := invoke_result.get("FunctionError"): + LOG.debug( + "Pipe target function %s failed with FunctionError %s. Payload: %s", + self.target_arn, + function_error, + payload, + ) + error = { + "message": f"Target {self.target_arn} encountered an error while processing event(s).", + "httpStatusCode": invoke_result["StatusCode"], + "awsService": "lambda", + "requestId": invoke_result["ResponseMetadata"]["RequestId"], + # TODO: fix hardcoded value by figuring out what other exception types exist + "exceptionType": "BadRequest", # Currently only used in Pipes + "resourceArn": self.target_arn, + "functionError": function_error, + "executedVersion": invoke_result.get("ExecutedVersion", "$LATEST"), + } + raise SenderError( + f"Error during sending events {events} due to FunctionError {function_error}.", + error=error, + ) + + # The payload can contain the key "batchItemFailures" with a list of partial batch failures: + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-batching-concurrency.html + if self.report_batch_item_failures and has_batch_item_failures(payload): + error = { + "message": "Target invocation failed partially.", + "httpStatusCode": invoke_result["StatusCode"], + "awsService": "lambda", + "requestId": invoke_result["ResponseMetadata"]["RequestId"], + "exceptionType": "BadRequest", + "resourceArn": self.target_arn, + "executedVersion": invoke_result.get("ExecutedVersion", "$LATEST"), + } + raise PartialFailureSenderError(error=error, partial_failure_payload=payload) + + return payload diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/senders/sender.py b/localstack-core/localstack/services/lambda_/event_source_mapping/senders/sender.py new file mode 100644 index 0000000000000..58196bc3d6b02 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/senders/sender.py @@ -0,0 +1,50 @@ +from abc import abstractmethod + +from botocore.client import BaseClient + +from localstack.services.lambda_.event_source_mapping.pipe_utils import get_internal_client + + +class SenderError(Exception): + def __init__(self, message=None, error=None) -> None: + self.message = message or "Error during sending events" + self.error = error + + +class PartialFailureSenderError(SenderError): + def __init__(self, message=None, error=None, partial_failure_payload=None) -> None: + self.message = message or "Target invocation failed partially." + self.error = error + # Following the partial failure payload structure: + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-batching-concurrency.html + self.partial_failure_payload = partial_failure_payload + + +class Sender: + target_arn: str + target_parameters: dict + target_client: BaseClient + + def __init__( + self, + target_arn: str, + target_parameters: dict | None = None, + target_client: BaseClient | None = None, + ) -> None: + self.target_arn = target_arn + self.target_parameters = target_parameters or {} + self.target_client = target_client or get_internal_client(target_arn) + + # TODO: Can an event also be of type `bytes`? + @abstractmethod + def send_events(self, events: list[dict | str]) -> dict | None: + """Send the given `events` to the target. + Returns an optional payload with a list of "batchItemFailures" if only part of the batch succeeds. + """ + pass + + @abstractmethod + def event_target(self) -> str: + """Return the event target metadata (e.g., aws:sqs) + Format analogous to event_source of pollers""" + pass diff --git a/localstack-core/localstack/services/lambda_/event_source_mapping/senders/sender_utils.py b/localstack-core/localstack/services/lambda_/event_source_mapping/senders/sender_utils.py new file mode 100644 index 0000000000000..ab1180adbdd1d --- /dev/null +++ b/localstack-core/localstack/services/lambda_/event_source_mapping/senders/sender_utils.py @@ -0,0 +1,43 @@ +import sys +from itertools import islice +from typing import Any, Iterable, Iterator + + +def batched(iterable, n): + # TODO: replace this method with native version when supporting Python 3.12 + # https://docs.python.org/3.12/library/itertools.html#itertools.batched + # batched('ABCDEFG', 3) --> ABC DEF G + if n < 1: + raise ValueError("n must be at least one") + it = iter(iterable) + while batch := tuple(islice(it, n)): + yield batch + + +def batched_by_size(iterable: Iterable[Any], max_bytes) -> Iterator[tuple[Any, ...]]: + """ + Generate batches from iterable where the total size of each batch in bytes does not exceed `max_bytes`. + """ + if max_bytes < 1: + raise ValueError("max_bytes must be at least one") + + it = iter(iterable) + while True: + batch = [] + current_size = 0 + try: + while current_size < max_bytes: + item = next(it) + item_size = sys.getsizeof(item) + if current_size + item_size > max_bytes: + # If adding this item exceeds max_bytes, push it back onto the iterator and stop this batch + it = iter([item] + list(it)) + break + batch.append(item) + current_size += item_size + except StopIteration: + pass + + if not batch: + break + yield tuple(batch) diff --git a/localstack-core/localstack/services/lambda_/hooks.py b/localstack-core/localstack/services/lambda_/hooks.py new file mode 100644 index 0000000000000..16195ae538bca --- /dev/null +++ b/localstack-core/localstack/services/lambda_/hooks.py @@ -0,0 +1,20 @@ +"""Definition of Plux extension points (i.e., hooks) for Lambda.""" + +from localstack.runtime.hooks import hook_spec + +HOOKS_LAMBDA_START_DOCKER_EXECUTOR = "localstack.hooks.lambda_start_docker_executor" +HOOKS_LAMBDA_PREPARE_DOCKER_EXECUTOR = "localstack.hooks.lambda_prepare_docker_executors" +HOOKS_LAMBDA_INJECT_LAYER_FETCHER = "localstack.hooks.lambda_inject_layer_fetcher" +HOOKS_LAMBDA_PREBUILD_ENVIRONMENT_IMAGE = "localstack.hooks.lambda_prebuild_environment_image" +HOOKS_LAMBDA_CREATE_EVENT_SOURCE_POLLER = "localstack.hooks.lambda_create_event_source_poller" +HOOKS_LAMBDA_SET_EVENT_SOURCE_CONFIG_DEFAULTS = ( + "localstack.hooks.lambda_set_event_source_config_defaults" +) + + +start_docker_executor = hook_spec(HOOKS_LAMBDA_START_DOCKER_EXECUTOR) +prepare_docker_executor = hook_spec(HOOKS_LAMBDA_PREPARE_DOCKER_EXECUTOR) +inject_layer_fetcher = hook_spec(HOOKS_LAMBDA_INJECT_LAYER_FETCHER) +prebuild_environment_image = hook_spec(HOOKS_LAMBDA_PREBUILD_ENVIRONMENT_IMAGE) +create_event_source_poller = hook_spec(HOOKS_LAMBDA_CREATE_EVENT_SOURCE_POLLER) +set_event_source_config_defaults = hook_spec(HOOKS_LAMBDA_SET_EVENT_SOURCE_CONFIG_DEFAULTS) diff --git a/localstack-core/localstack/services/lambda_/invocation/__init__.py b/localstack-core/localstack/services/lambda_/invocation/__init__.py new file mode 100644 index 0000000000000..a08cbe30f494a --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/__init__.py @@ -0,0 +1,7 @@ +from localstack.aws.api.lambda_ import ServiceException + + +class AccessDeniedException(ServiceException): + code: str = "AccessDeniedException" + sender_fault: bool = True + status_code: int = 403 diff --git a/localstack-core/localstack/services/lambda_/invocation/assignment.py b/localstack-core/localstack/services/lambda_/invocation/assignment.py new file mode 100644 index 0000000000000..39f4d04383e26 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/assignment.py @@ -0,0 +1,191 @@ +import contextlib +import logging +from collections import defaultdict +from concurrent.futures import Future, ThreadPoolExecutor +from typing import ContextManager + +from localstack.services.lambda_.invocation.execution_environment import ( + EnvironmentStartupTimeoutException, + ExecutionEnvironment, + InvalidStatusException, +) +from localstack.services.lambda_.invocation.executor_endpoint import StatusErrorException +from localstack.services.lambda_.invocation.lambda_models import ( + FunctionVersion, + InitializationType, + OtherServiceEndpoint, +) +from localstack.utils.lambda_debug_mode.lambda_debug_mode import ( + is_lambda_debug_enabled_for, + is_lambda_debug_timeout_enabled_for, +) + +LOG = logging.getLogger(__name__) + + +class AssignmentException(Exception): + pass + + +class AssignmentService(OtherServiceEndpoint): + """ + scope: LocalStack global + """ + + # function_version manager id => runtime_environment_id => runtime_environment + environments: dict[str, dict[str, ExecutionEnvironment]] + + # Global pool for spawning and killing provisioned Lambda runtime environments + provisioning_pool: ThreadPoolExecutor + + def __init__(self): + self.environments = defaultdict(dict) + self.provisioning_pool = ThreadPoolExecutor(thread_name_prefix="lambda-provisioning-pool") + + @contextlib.contextmanager + def get_environment( + self, + version_manager_id: str, + function_version: FunctionVersion, + provisioning_type: InitializationType, + ) -> ContextManager[ExecutionEnvironment]: + applicable_envs = ( + env + for env in self.environments[version_manager_id].values() + if env.initialization_type == provisioning_type + ) + execution_environment = None + for environment in applicable_envs: + try: + environment.reserve() + execution_environment = environment + break + except InvalidStatusException: + pass + + if execution_environment is None: + if provisioning_type == "provisioned-concurrency": + raise AssignmentException( + "No provisioned concurrency environment available despite lease." + ) + elif provisioning_type == "on-demand": + execution_environment = self.start_environment(version_manager_id, function_version) + self.environments[version_manager_id][execution_environment.id] = ( + execution_environment + ) + execution_environment.reserve() + else: + raise ValueError(f"Invalid provisioning type {provisioning_type}") + + try: + yield execution_environment + if is_lambda_debug_timeout_enabled_for(lambda_arn=function_version.qualified_arn): + self.stop_environment(execution_environment) + else: + execution_environment.release() + except InvalidStatusException as invalid_e: + LOG.error("InvalidStatusException: %s", invalid_e) + except Exception as e: + LOG.error( + "Failed invocation <%s>: %s", type(e), e, exc_info=LOG.isEnabledFor(logging.DEBUG) + ) + self.stop_environment(execution_environment) + raise e + + def start_environment( + self, version_manager_id: str, function_version: FunctionVersion + ) -> ExecutionEnvironment: + LOG.debug("Starting new environment") + execution_environment = ExecutionEnvironment( + function_version=function_version, + initialization_type="on-demand", + on_timeout=self.on_timeout, + version_manager_id=version_manager_id, + ) + try: + execution_environment.start() + except StatusErrorException: + raise + except EnvironmentStartupTimeoutException: + raise + except Exception as e: + message = f"Could not start new environment: {type(e).__name__}:{e}" + raise AssignmentException(message) from e + return execution_environment + + def on_timeout(self, version_manager_id: str, environment_id: str) -> None: + """Callback for deleting environment after function times out""" + del self.environments[version_manager_id][environment_id] + + def stop_environment(self, environment: ExecutionEnvironment) -> None: + version_manager_id = environment.version_manager_id + try: + environment.stop() + self.environments.get(version_manager_id).pop(environment.id) + except Exception as e: + LOG.debug( + "Error while stopping environment for lambda %s, manager id %s, environment: %s, error: %s", + environment.function_version.qualified_arn, + version_manager_id, + environment.id, + e, + ) + + def stop_environments_for_version(self, version_manager_id: str): + # We have to materialize the list before iterating due to concurrency + environments_to_stop = list(self.environments.get(version_manager_id, {}).values()) + for env in environments_to_stop: + self.stop_environment(env) + + def scale_provisioned_concurrency( + self, + version_manager_id: str, + function_version: FunctionVersion, + target_provisioned_environments: int, + ) -> list[Future[None]]: + # Enforce a single environment per lambda version if this is a target + # of an active Lambda Debug Mode. + qualified_lambda_version_arn = function_version.qualified_arn + if ( + is_lambda_debug_enabled_for(qualified_lambda_version_arn) + and target_provisioned_environments > 0 + ): + LOG.warning( + "Environments for '%s' enforced to '1' by Lambda Debug Mode, " + "configurations will continue to report the set value '%s'", + qualified_lambda_version_arn, + target_provisioned_environments, + ) + target_provisioned_environments = 1 + + current_provisioned_environments = [ + e + for e in self.environments[version_manager_id].values() + if e.initialization_type == "provisioned-concurrency" + ] + # TODO: refine scaling loop to re-use existing environments instead of re-creating all + # current_provisioned_environments_count = len(current_provisioned_environments) + # diff = target_provisioned_environments - current_provisioned_environments_count + + # TODO: handle case where no provisioned environment is available during scaling + # Most simple scaling implementation for now: + futures = [] + # 1) Re-create new target + for _ in range(target_provisioned_environments): + execution_environment = ExecutionEnvironment( + function_version=function_version, + initialization_type="provisioned-concurrency", + on_timeout=self.on_timeout, + version_manager_id=version_manager_id, + ) + self.environments[version_manager_id][execution_environment.id] = execution_environment + futures.append(self.provisioning_pool.submit(execution_environment.start)) + # 2) Kill all existing + for env in current_provisioned_environments: + # TODO: think about concurrent updates while deleting a function + futures.append(self.provisioning_pool.submit(self.stop_environment, env)) + + return futures + + def stop(self): + self.provisioning_pool.shutdown(cancel_futures=True) diff --git a/localstack-core/localstack/services/lambda_/invocation/counting_service.py b/localstack-core/localstack/services/lambda_/invocation/counting_service.py new file mode 100644 index 0000000000000..3c7024288a305 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/counting_service.py @@ -0,0 +1,266 @@ +import contextlib +import logging +from collections import defaultdict +from threading import RLock + +from localstack import config +from localstack.aws.api.lambda_ import TooManyRequestsException +from localstack.services.lambda_.invocation.lambda_models import ( + Function, + FunctionVersion, + InitializationType, +) +from localstack.services.lambda_.invocation.models import lambda_stores +from localstack.utils.lambda_debug_mode.lambda_debug_mode import ( + is_lambda_debug_enabled_for, +) + +LOG = logging.getLogger(__name__) + + +class ConcurrencyTracker: + """Keeps track of the number of concurrent executions per lock scope (e.g., per function or function version). + The lock scope depends on the provisioning type (i.e., on-demand or provisioned): + * on-demand concurrency per function: unqualified arn ending with my-function + * provisioned concurrency per function version: qualified arn ending with my-function:1 + """ + + # Lock scope => concurrent executions counter + concurrent_executions: dict[str, int] + # Lock for safely updating the concurrent executions counter + lock: RLock + + def __init__(self): + self.concurrent_executions = defaultdict(int) + self.lock = RLock() + + def increment(self, scope: str) -> None: + self.concurrent_executions[scope] += 1 + + def atomic_decrement(self, scope: str): + with self.lock: + self.decrement(scope) + + def decrement(self, scope: str) -> None: + self.concurrent_executions[scope] -= 1 + + +def calculate_provisioned_concurrency_sum(function: Function) -> int: + """Returns the total provisioned concurrency for a given function, including all versions.""" + provisioned_concurrency_sum_for_fn = sum( + [ + provisioned_configs.provisioned_concurrent_executions + for provisioned_configs in function.provisioned_concurrency_configs.values() + ] + ) + return provisioned_concurrency_sum_for_fn + + +class CountingService: + """ + The CountingService enforces quota limits per region and account in get_invocation_lease() + for every Lambda invocation. It uses separate ConcurrencyTrackers for on-demand and provisioned concurrency + to keep track of the number of concurrent invocations. + + Concurrency limits are per region and account: + https://repost.aws/knowledge-center/lambda-concurrency-limit-increase + https://docs.aws.amazon.com/lambda/latest/dg/lambda-concurrency.htm + https://docs.aws.amazon.com/lambda/latest/dg/monitoring-concurrency.html + """ + + # (account, region) => ConcurrencyTracker (unqualified arn) => concurrent executions + on_demand_concurrency_trackers: dict[(str, str), ConcurrencyTracker] + # Lock for safely initializing new on-demand concurrency trackers + on_demand_init_lock: RLock + + # (account, region) => ConcurrencyTracker (qualified arn) => concurrent executions + provisioned_concurrency_trackers: dict[(str, str), ConcurrencyTracker] + # Lock for safely initializing new provisioned concurrency trackers + provisioned_concurrency_init_lock: RLock + + def __init__(self): + self.on_demand_concurrency_trackers = {} + self.on_demand_init_lock = RLock() + self.provisioned_concurrency_trackers = {} + self.provisioned_concurrency_init_lock = RLock() + + @contextlib.contextmanager + def get_invocation_lease( + self, function: Function | None, function_version: FunctionVersion + ) -> InitializationType: + """An invocation lease reserves the right to schedule an invocation. + The returned lease type can either be on-demand or provisioned. + Scheduling preference: + 1) Check for free provisioned concurrency => provisioned + 2) Check for reserved concurrency => on-demand + 3) Check for unreserved concurrency => on-demand + + HACK: We allow the function to be None for Lambda@Edge to skip provisioned and reserved concurrency. + """ + account = function_version.id.account + region = function_version.id.region + scope_tuple = (account, region) + on_demand_tracker = self.on_demand_concurrency_trackers.get(scope_tuple) + # Double-checked locking pattern to initialize an on-demand concurrency tracker if it does not exist + if not on_demand_tracker: + with self.on_demand_init_lock: + on_demand_tracker = self.on_demand_concurrency_trackers.get(scope_tuple) + if not on_demand_tracker: + on_demand_tracker = self.on_demand_concurrency_trackers[scope_tuple] = ( + ConcurrencyTracker() + ) + + provisioned_tracker = self.provisioned_concurrency_trackers.get(scope_tuple) + # Double-checked locking pattern to initialize a provisioned concurrency tracker if it does not exist + if not provisioned_tracker: + with self.provisioned_concurrency_init_lock: + provisioned_tracker = self.provisioned_concurrency_trackers.get(scope_tuple) + if not provisioned_tracker: + provisioned_tracker = self.provisioned_concurrency_trackers[scope_tuple] = ( + ConcurrencyTracker() + ) + + # TODO: check that we don't give a lease while updating provisioned concurrency + # Potential challenge if an update happens in between reserving the lease here and actually assigning + # * Increase provisioned: It could happen that we give a lease for provisioned-concurrency although + # brand new provisioned environments are not yet initialized. + # * Decrease provisioned: It could happen that we have running invocations that should still be counted + # against the limit but they are not because we already updated the concurrency config to fewer envs. + + unqualified_function_arn = function_version.id.unqualified_arn() + qualified_arn = function_version.id.qualified_arn() + + # Enforce one lease per ARN if the global flag is set + if is_lambda_debug_enabled_for(qualified_arn): + with provisioned_tracker.lock, on_demand_tracker.lock: + on_demand_executions: int = on_demand_tracker.concurrent_executions[ + unqualified_function_arn + ] + provisioned_executions = provisioned_tracker.concurrent_executions[qualified_arn] + if on_demand_executions or provisioned_executions: + LOG.warning( + "Concurrent lambda invocations disabled for '%s' by Lambda Debug Mode", + qualified_arn, + ) + raise TooManyRequestsException( + "Rate Exceeded.", + Reason="SingleLeaseEnforcement", + Type="User", + ) + + lease_type = None + # HACK: skip reserved and provisioned concurrency if function not available (e.g., in Lambda@Edge) + if function is not None: + with provisioned_tracker.lock: + # 1) Check for free provisioned concurrency + provisioned_concurrency_config = function.provisioned_concurrency_configs.get( + function_version.id.qualifier + ) + if not provisioned_concurrency_config: + # check if any aliases point to the current version, and check the provisioned concurrency config + # for them. There can be only one config for a version, not matter if defined on the alias or version itself. + for alias in function.aliases.values(): + if alias.function_version == function_version.id.qualifier: + provisioned_concurrency_config = ( + function.provisioned_concurrency_configs.get(alias.name) + ) + break + if provisioned_concurrency_config: + available_provisioned_concurrency = ( + provisioned_concurrency_config.provisioned_concurrent_executions + - provisioned_tracker.concurrent_executions[qualified_arn] + ) + if available_provisioned_concurrency > 0: + provisioned_tracker.increment(qualified_arn) + lease_type = "provisioned-concurrency" + + if not lease_type: + with on_demand_tracker.lock: + # 2) If reserved concurrency is set AND no provisioned concurrency available: + # => Check if enough reserved concurrency is available for the specific function. + # HACK: skip reserved if function not available (e.g., in Lambda@Edge) + if function and function.reserved_concurrent_executions is not None: + on_demand_running_invocation_count = on_demand_tracker.concurrent_executions[ + unqualified_function_arn + ] + available_reserved_concurrency = ( + function.reserved_concurrent_executions + - calculate_provisioned_concurrency_sum(function) + - on_demand_running_invocation_count + ) + if available_reserved_concurrency > 0: + on_demand_tracker.increment(unqualified_function_arn) + lease_type = "on-demand" + else: + extras = { + "available_reserved_concurrency": available_reserved_concurrency, + "reserved_concurrent_executions": function.reserved_concurrent_executions, + "provisioned_concurrency_sum": calculate_provisioned_concurrency_sum( + function + ), + "on_demand_running_invocation_count": on_demand_running_invocation_count, + } + LOG.debug("Insufficient reserved concurrency available: %s", extras) + raise TooManyRequestsException( + "Rate Exceeded.", + Reason="ReservedFunctionConcurrentInvocationLimitExceeded", + Type="User", + ) + # 3) If no reserved concurrency is set AND no provisioned concurrency available. + # => Check the entire state within the scope of account and region. + else: + # TODO: Consider a dedicated counter for unavailable concurrency with locks for updates on + # reserved and provisioned concurrency if this is too slow + # The total concurrency allocated or used (i.e., unavailable concurrency) per account and region + total_used_concurrency = 0 + store = lambda_stores[account][region] + for fn in store.functions.values(): + if fn.reserved_concurrent_executions is not None: + total_used_concurrency += fn.reserved_concurrent_executions + else: + fn_provisioned_concurrency = calculate_provisioned_concurrency_sum(fn) + total_used_concurrency += fn_provisioned_concurrency + fn_on_demand_concurrent_executions = ( + on_demand_tracker.concurrent_executions[ + fn.latest().id.unqualified_arn() + ] + ) + total_used_concurrency += fn_on_demand_concurrent_executions + + available_unreserved_concurrency = ( + config.LAMBDA_LIMITS_CONCURRENT_EXECUTIONS - total_used_concurrency + ) + if available_unreserved_concurrency > 0: + on_demand_tracker.increment(unqualified_function_arn) + lease_type = "on-demand" + else: + if available_unreserved_concurrency < 0: + LOG.error( + "Invalid function concurrency state detected for function: %s | available unreserved concurrency: %d", + unqualified_function_arn, + available_unreserved_concurrency, + ) + extras = { + "available_unreserved_concurrency": available_unreserved_concurrency, + "lambda_limits_concurrent_executions": config.LAMBDA_LIMITS_CONCURRENT_EXECUTIONS, + "total_used_concurrency": total_used_concurrency, + } + LOG.debug("Insufficient unreserved concurrency available: %s", extras) + raise TooManyRequestsException( + "Rate Exceeded.", + Reason="ReservedFunctionConcurrentInvocationLimitExceeded", + Type="User", + ) + try: + yield lease_type + finally: + if lease_type == "provisioned-concurrency": + provisioned_tracker.atomic_decrement(qualified_arn) + elif lease_type == "on-demand": + on_demand_tracker.atomic_decrement(unqualified_function_arn) + else: + LOG.error( + "Invalid lease type detected for function: %s: %s", + unqualified_function_arn, + lease_type, + ) diff --git a/localstack-core/localstack/services/lambda_/invocation/docker_runtime_executor.py b/localstack-core/localstack/services/lambda_/invocation/docker_runtime_executor.py new file mode 100644 index 0000000000000..c67f39addb414 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/docker_runtime_executor.py @@ -0,0 +1,514 @@ +import dataclasses +import json +import logging +import shutil +import tempfile +import threading +from collections import defaultdict +from pathlib import Path +from typing import Callable, Dict, Literal, Optional + +from localstack import config +from localstack.aws.api.lambda_ import Architecture, PackageType, Runtime +from localstack.dns import server as dns_server +from localstack.services.lambda_ import hooks as lambda_hooks +from localstack.services.lambda_.invocation.executor_endpoint import ( + INVOCATION_PORT, + ExecutorEndpoint, +) +from localstack.services.lambda_.invocation.lambda_models import FunctionVersion +from localstack.services.lambda_.invocation.runtime_executor import ( + ChmodPath, + LambdaPrebuildContext, + LambdaRuntimeException, + RuntimeExecutor, +) +from localstack.services.lambda_.lambda_utils import HINT_LOG +from localstack.services.lambda_.networking import ( + get_all_container_networks_for_lambda, + get_main_endpoint_from_container, +) +from localstack.services.lambda_.packages import get_runtime_client_path +from localstack.services.lambda_.runtimes import IMAGE_MAPPING +from localstack.utils.container_networking import get_main_container_name +from localstack.utils.container_utils.container_client import ( + BindMount, + ContainerConfiguration, + DockerNotAvailable, + DockerPlatform, + NoSuchContainer, + NoSuchImage, + PortMappings, + VolumeMappings, +) +from localstack.utils.docker_utils import DOCKER_CLIENT as CONTAINER_CLIENT +from localstack.utils.files import chmod_r, rm_rf +from localstack.utils.lambda_debug_mode.lambda_debug_mode import lambda_debug_port_for +from localstack.utils.net import get_free_tcp_port +from localstack.utils.strings import short_uid, truncate + +LOG = logging.getLogger(__name__) + +IMAGE_PREFIX = "public.ecr.aws/lambda/" +# IMAGE_PREFIX = "amazon/aws-lambda-" + +RAPID_ENTRYPOINT = "/var/rapid/init" + +InitializationType = Literal["on-demand", "provisioned-concurrency"] + +LAMBDA_DOCKERFILE = """FROM {base_img} +COPY init {rapid_entrypoint} +COPY code/ /var/task +""" + +PULLED_IMAGES: set[(str, DockerPlatform)] = set() +PULL_LOCKS: dict[(str, DockerPlatform), threading.RLock] = defaultdict(threading.RLock) + +HOT_RELOADING_ENV_VARIABLE = "LOCALSTACK_HOT_RELOADING_PATHS" + + +"""Map AWS Lambda architecture to Docker platform flags. Example: arm64 => linux/arm64""" +ARCHITECTURE_PLATFORM_MAPPING: dict[Architecture, DockerPlatform] = { + Architecture.x86_64: DockerPlatform.linux_amd64, + Architecture.arm64: DockerPlatform.linux_arm64, +} + + +def docker_platform(lambda_architecture: Architecture) -> DockerPlatform | None: + """ + Convert an AWS Lambda architecture into a Docker platform flag. Examples: + * docker_platform("x86_64") == "linux/amd64" + * docker_platform("arm64") == "linux/arm64" + + :param lambda_architecture: the instruction set that the function supports + :return: Docker platform in the format ``os[/arch[/variant]]`` or None if configured to ignore the architecture + """ + if config.LAMBDA_IGNORE_ARCHITECTURE: + return None + return ARCHITECTURE_PLATFORM_MAPPING[lambda_architecture] + + +def get_image_name_for_function(function_version: FunctionVersion) -> str: + return f"localstack/prebuild-lambda-{function_version.id.qualified_arn().replace(':', '_').replace('$', '_').lower()}" + + +def get_default_image_for_runtime(runtime: Runtime) -> str: + postfix = IMAGE_MAPPING.get(runtime) + if not postfix: + raise ValueError(f"Unsupported runtime {runtime}!") + return f"{IMAGE_PREFIX}{postfix}" + + +def _ensure_runtime_image_present(image: str, platform: DockerPlatform) -> None: + # Pull image for a given platform upon function creation such that invocations do not time out. + if (image, platform) in PULLED_IMAGES: + return + # use a lock to avoid concurrent pulling of the same image + with PULL_LOCKS[(image, platform)]: + if (image, platform) in PULLED_IMAGES: + return + try: + CONTAINER_CLIENT.pull_image(image, platform) + PULLED_IMAGES.add((image, platform)) + except NoSuchImage as e: + LOG.debug("Unable to pull image %s for runtime executor preparation.", image) + raise e + except DockerNotAvailable as e: + HINT_LOG.error( + "Failed to pull Docker image because Docker is not available in the LocalStack container " + "but required to run Lambda functions. Please add the Docker volume mount " + '"/var/run/docker.sock:/var/run/docker.sock" to your LocalStack startup. ' + "https://docs.localstack.cloud/user-guide/aws/lambda/#docker-not-available" + ) + raise e + + +class RuntimeImageResolver: + """ + Resolves Lambda runtimes to corresponding docker images + The default behavior resolves based on a prefix (including the repository) and a suffix (per runtime). + + This can be customized via the LAMBDA_RUNTIME_IMAGE_MAPPING config in 2 distinct ways: + + Option A: use a pattern string for the config variable that includes the "" string + e.g. "myrepo/lambda:-custom" would resolve the runtime "python3.9" to "myrepo/lambda:python3.9-custom" + + Option B: use a JSON dict string for the config variable, mapping the runtime to the full image name & tag + e.g. {"python3.9": "myrepo/lambda:python3.9-custom", "python3.8": "myotherrepo/pylambda:3.8"} + + Note that with Option B this will only apply to the runtimes included in the dict. + All other (non-included) runtimes will fall back to the default behavior. + """ + + _mapping: dict[Runtime, str] + _default_resolve_fn: Callable[[Runtime], str] + + def __init__( + self, default_resolve_fn: Callable[[Runtime], str] = get_default_image_for_runtime + ): + self._mapping = dict() + self._default_resolve_fn = default_resolve_fn + + def _resolve(self, runtime: Runtime, custom_image_mapping: str = "") -> str: + if runtime not in IMAGE_MAPPING: + raise ValueError(f"Unsupported runtime {runtime}") + + if not custom_image_mapping: + return self._default_resolve_fn(runtime) + + # Option A (pattern string that includes to replace) + if "" in custom_image_mapping: + return custom_image_mapping.replace("", runtime) + + # Option B (json dict mapping with fallback) + try: + mapping: dict = json.loads(custom_image_mapping) + # at this point we're loading the whole dict to avoid parsing multiple times + for k, v in mapping.items(): + if k not in IMAGE_MAPPING: + raise ValueError( + f"Unsupported runtime ({runtime}) provided in LAMBDA_RUNTIME_IMAGE_MAPPING" + ) + self._mapping[k] = v + + if runtime in self._mapping: + return self._mapping[runtime] + + # fall back to default behavior if the runtime was not present in the custom config + return self._default_resolve_fn(runtime) + + except Exception: + LOG.error( + "Failed to load config from LAMBDA_RUNTIME_IMAGE_MAPPING=%s", + custom_image_mapping, + ) + raise # TODO: validate config at start and prevent startup + + def get_image_for_runtime(self, runtime: Runtime) -> str: + if runtime not in self._mapping: + resolved_image = self._resolve(runtime, config.LAMBDA_RUNTIME_IMAGE_MAPPING) + self._mapping[runtime] = resolved_image + + return self._mapping[runtime] + + +resolver = RuntimeImageResolver() + + +def prepare_image(function_version: FunctionVersion, platform: DockerPlatform) -> None: + if not function_version.config.runtime: + raise NotImplementedError( + "Custom images are currently not supported with image prebuilding" + ) + + # create dockerfile + docker_file = LAMBDA_DOCKERFILE.format( + base_img=resolver.get_image_for_runtime(function_version.config.runtime), + rapid_entrypoint=RAPID_ENTRYPOINT, + ) + + code_path = function_version.config.code.get_unzipped_code_location() + context_path = Path( + f"{tempfile.gettempdir()}/lambda/prebuild_tmp/{function_version.id.function_name}-{short_uid()}" + ) + context_path.mkdir(parents=True) + prebuild_context = LambdaPrebuildContext( + docker_file_content=docker_file, + context_path=context_path, + function_version=function_version, + ) + lambda_hooks.prebuild_environment_image.run(prebuild_context) + LOG.debug( + "Prebuilding image for function %s from context %s and Dockerfile %s", + function_version.qualified_arn, + str(prebuild_context.context_path), + prebuild_context.docker_file_content, + ) + # save dockerfile + docker_file_path = prebuild_context.context_path / "Dockerfile" + with docker_file_path.open(mode="w") as f: + f.write(prebuild_context.docker_file_content) + + # copy init file + init_destination_path = prebuild_context.context_path / "init" + src_init = f"{get_runtime_client_path()}/var/rapid/init" + shutil.copy(src_init, init_destination_path) + init_destination_path.chmod(0o755) + + # copy function code + context_code_path = prebuild_context.context_path / "code" + shutil.copytree( + f"{str(code_path)}/", + str(context_code_path), + dirs_exist_ok=True, + ) + # if layers are present, permissions should be 0755 + if prebuild_context.function_version.config.layers: + chmod_r(str(context_code_path), 0o755) + + try: + image_name = get_image_name_for_function(function_version) + CONTAINER_CLIENT.build_image( + dockerfile_path=str(docker_file_path), + image_name=image_name, + platform=platform, + ) + except Exception as e: + if LOG.isEnabledFor(logging.DEBUG): + LOG.exception( + "Error while building prebuilt lambda image for '%s'", + function_version.qualified_arn, + ) + else: + LOG.error( + "Error while building prebuilt lambda image for '%s', Error: %s", + function_version.qualified_arn, + e, + ) + finally: + rm_rf(str(prebuild_context.context_path)) + + +@dataclasses.dataclass +class LambdaContainerConfiguration(ContainerConfiguration): + copy_folders: list[tuple[str, str]] = dataclasses.field(default_factory=list) + + +class DockerRuntimeExecutor(RuntimeExecutor): + ip: Optional[str] + executor_endpoint: Optional[ExecutorEndpoint] + container_name: str + + def __init__(self, id: str, function_version: FunctionVersion) -> None: + super(DockerRuntimeExecutor, self).__init__(id=id, function_version=function_version) + self.ip = None + self.executor_endpoint = ExecutorEndpoint(self.id) + self.container_name = self._generate_container_name() + LOG.debug("Assigning container name of %s to executor %s", self.container_name, self.id) + + def get_image(self) -> str: + if not self.function_version.config.runtime: + raise NotImplementedError("Container images are a Pro feature.") + return ( + get_image_name_for_function(self.function_version) + if config.LAMBDA_PREBUILD_IMAGES + else resolver.get_image_for_runtime(self.function_version.config.runtime) + ) + + def _generate_container_name(self): + """ + Format -lambda-- + TODO: make the format configurable + """ + container_name = "-".join( + [ + get_main_container_name() or "localstack", + "lambda", + self.function_version.id.function_name.lower(), + ] + ).replace("_", "-") + return f"{container_name}-{self.id}" + + def start(self, env_vars: dict[str, str]) -> None: + self.executor_endpoint.start() + main_network, *additional_networks = self._get_networks_for_executor() + container_config = LambdaContainerConfiguration( + image_name=None, + name=self.container_name, + env_vars=env_vars, + network=main_network, + entrypoint=RAPID_ENTRYPOINT, + platform=docker_platform(self.function_version.config.architectures[0]), + additional_flags=config.LAMBDA_DOCKER_FLAGS, + ) + debug_port = lambda_debug_port_for(self.function_version.qualified_arn) + if debug_port is not None: + container_config.ports.add(debug_port, debug_port) + + if self.function_version.config.package_type == PackageType.Zip: + if self.function_version.config.code.is_hot_reloading(): + container_config.env_vars[HOT_RELOADING_ENV_VARIABLE] = "/var/task" + if container_config.volumes is None: + container_config.volumes = VolumeMappings() + container_config.volumes.add( + BindMount( + str(self.function_version.config.code.get_unzipped_code_location()), + "/var/task", + read_only=True, + ) + ) + else: + container_config.copy_folders.append( + ( + f"{str(self.function_version.config.code.get_unzipped_code_location())}/.", + "/var/task", + ) + ) + + # always chmod /tmp to 700 + chmod_paths = [ChmodPath(path="/tmp", mode="0700")] + + # set the dns server of the lambda container to the LocalStack container IP + # the dns server will automatically respond with the right target for transparent endpoint injection + if config.LAMBDA_DOCKER_DNS: + # Don't overwrite DNS container config if it is already set (e.g., using LAMBDA_DOCKER_DNS) + LOG.warning( + "Container DNS overridden to %s, connection to names pointing to LocalStack, like 'localhost.localstack.cloud' will need additional configuration.", + config.LAMBDA_DOCKER_DNS, + ) + container_config.dns = config.LAMBDA_DOCKER_DNS + else: + if dns_server.is_server_running(): + # Set the container DNS to LocalStack to resolve localhost.localstack.cloud and + # enable transparent endpoint injection (Pro image only). + container_config.dns = self.get_endpoint_from_executor() + + lambda_hooks.start_docker_executor.run(container_config, self.function_version) + + if not container_config.image_name: + container_config.image_name = self.get_image() + if config.LAMBDA_DEV_PORT_EXPOSE: + self.executor_endpoint.container_port = get_free_tcp_port() + if container_config.ports is None: + container_config.ports = PortMappings() + container_config.ports.add(self.executor_endpoint.container_port, INVOCATION_PORT) + + if config.LAMBDA_INIT_DEBUG: + container_config.entrypoint = "/debug-bootstrap.sh" + if not container_config.ports: + container_config.ports = PortMappings() + container_config.ports.add(config.LAMBDA_INIT_DELVE_PORT, config.LAMBDA_INIT_DELVE_PORT) + + if ( + self.function_version.config.layers + and not config.LAMBDA_PREBUILD_IMAGES + and self.function_version.config.package_type == PackageType.Zip + ): + # avoid chmod on mounted code paths + hot_reloading_env = container_config.env_vars.get(HOT_RELOADING_ENV_VARIABLE, "") + if "/opt" not in hot_reloading_env: + chmod_paths.append(ChmodPath(path="/opt", mode="0755")) + if "/var/task" not in hot_reloading_env: + chmod_paths.append(ChmodPath(path="/var/task", mode="0755")) + container_config.env_vars["LOCALSTACK_CHMOD_PATHS"] = json.dumps(chmod_paths) + + CONTAINER_CLIENT.create_container_from_config(container_config) + if ( + not config.LAMBDA_PREBUILD_IMAGES + or self.function_version.config.package_type != PackageType.Zip + ): + CONTAINER_CLIENT.copy_into_container( + self.container_name, f"{str(get_runtime_client_path())}/.", "/" + ) + # tiny bit inefficient since we actually overwrite the init, but otherwise the path might not exist + if config.LAMBDA_INIT_BIN_PATH: + CONTAINER_CLIENT.copy_into_container( + self.container_name, config.LAMBDA_INIT_BIN_PATH, "/var/rapid/init" + ) + if config.LAMBDA_INIT_DEBUG: + CONTAINER_CLIENT.copy_into_container( + self.container_name, config.LAMBDA_INIT_DELVE_PATH, "/var/rapid/dlv" + ) + CONTAINER_CLIENT.copy_into_container( + self.container_name, config.LAMBDA_INIT_BOOTSTRAP_PATH, "/debug-bootstrap.sh" + ) + + if not config.LAMBDA_PREBUILD_IMAGES: + # copy_folders should be empty here if package type is not zip + for source, target in container_config.copy_folders: + CONTAINER_CLIENT.copy_into_container(self.container_name, source, target) + + if additional_networks: + for additional_network in additional_networks: + CONTAINER_CLIENT.connect_container_to_network( + additional_network, self.container_name + ) + + CONTAINER_CLIENT.start_container(self.container_name) + # still using main network as main entrypoint + self.ip = CONTAINER_CLIENT.get_container_ipv4_for_network( + container_name_or_id=self.container_name, container_network=main_network + ) + if config.LAMBDA_DEV_PORT_EXPOSE: + self.ip = "127.0.0.1" + self.executor_endpoint.container_address = self.ip + + self.executor_endpoint.wait_for_startup() + + def stop(self) -> None: + CONTAINER_CLIENT.stop_container(container_name=self.container_name, timeout=5) + if config.LAMBDA_REMOVE_CONTAINERS: + CONTAINER_CLIENT.remove_container(container_name=self.container_name) + try: + self.executor_endpoint.shutdown() + except Exception as e: + LOG.debug( + "Error while stopping executor endpoint for lambda %s, error: %s", + self.function_version.qualified_arn, + e, + ) + + def get_address(self) -> str: + if not self.ip: + raise LambdaRuntimeException(f"IP address of executor '{self.id}' unknown") + return self.ip + + def get_endpoint_from_executor(self) -> str: + return get_main_endpoint_from_container() + + def _get_networks_for_executor(self) -> list[str]: + return get_all_container_networks_for_lambda() + + def invoke(self, payload: Dict[str, str]): + LOG.debug( + "Sending invoke-payload '%s' to executor '%s'", + truncate(json.dumps(payload), config.LAMBDA_TRUNCATE_STDOUT), + self.id, + ) + return self.executor_endpoint.invoke(payload) + + def get_logs(self) -> str: + try: + return CONTAINER_CLIENT.get_container_logs(container_name_or_id=self.container_name) + except NoSuchContainer: + return "Container was not created" + + @classmethod + def prepare_version(cls, function_version: FunctionVersion) -> None: + lambda_hooks.prepare_docker_executor.run(function_version) + # Trigger the installation of the Lambda runtime-init binary before invocation and + # cache the result to save time upon every invocation. + get_runtime_client_path() + if function_version.config.code: + function_version.config.code.prepare_for_execution() + image_name = resolver.get_image_for_runtime(function_version.config.runtime) + platform = docker_platform(function_version.config.architectures[0]) + _ensure_runtime_image_present(image_name, platform) + if config.LAMBDA_PREBUILD_IMAGES: + prepare_image(function_version, platform) + + @classmethod + def cleanup_version(cls, function_version: FunctionVersion) -> None: + if config.LAMBDA_PREBUILD_IMAGES: + # TODO re-enable image cleanup. + # Enabling it currently deletes image after updates as well + # It also creates issues when cleanup is concurrently with build + # probably due to intermediate layers being deleted + # image_name = get_image_name_for_function(function_version) + # LOG.debug("Removing image %s after version deletion", image_name) + # CONTAINER_CLIENT.remove_image(image_name) + pass + + def get_runtime_endpoint(self) -> str: + return f"http://{self.get_endpoint_from_executor()}:{config.GATEWAY_LISTEN[0].port}{self.executor_endpoint.get_endpoint_prefix()}" + + @classmethod + def validate_environment(cls) -> bool: + if not CONTAINER_CLIENT.has_docker(): + LOG.warning( + "WARNING: Docker not available in the LocalStack container but required to run Lambda " + 'functions. Please add the Docker volume mount "/var/run/docker.sock:/var/run/docker.sock" to your ' + "LocalStack startup. https://docs.localstack.cloud/user-guide/aws/lambda/#docker-not-available" + ) + return False + return True diff --git a/localstack-core/localstack/services/lambda_/invocation/event_manager.py b/localstack-core/localstack/services/lambda_/invocation/event_manager.py new file mode 100644 index 0000000000000..a433460543b7b --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/event_manager.py @@ -0,0 +1,572 @@ +import base64 +import dataclasses +import json +import logging +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from math import ceil + +from botocore.config import Config + +from localstack import config +from localstack.aws.api.lambda_ import InvocationType, TooManyRequestsException +from localstack.services.lambda_.analytics import ( + FunctionOperation, + FunctionStatus, + function_counter, +) +from localstack.services.lambda_.invocation.internal_sqs_queue import get_fake_sqs_client +from localstack.services.lambda_.invocation.lambda_models import ( + EventInvokeConfig, + FunctionVersion, + Invocation, + InvocationResult, +) +from localstack.services.lambda_.invocation.version_manager import LambdaVersionManager +from localstack.utils.aws import dead_letter_queue +from localstack.utils.aws.message_forwarding import send_event_to_target +from localstack.utils.strings import md5, to_str +from localstack.utils.threads import FuncThread +from localstack.utils.time import timestamp_millis +from localstack.utils.xray.trace_header import TraceHeader + +LOG = logging.getLogger(__name__) + + +def get_sqs_client(function_version: FunctionVersion, client_config=None): + return get_fake_sqs_client() + + +# TODO: remove once DLQ handling is refactored following the removal of the legacy lambda provider +class LegacyInvocationException(Exception): + def __init__(self, message, log_output=None, result=None): + super(LegacyInvocationException, self).__init__(message) + self.log_output = log_output + self.result = result + + +@dataclasses.dataclass +class SQSInvocation: + invocation: Invocation + retries: int = 0 + exception_retries: int = 0 + + def encode(self) -> str: + # Encode TraceHeader as string + aws_trace_header = self.invocation.trace_context.get("aws_trace_header") + aws_trace_header_str = aws_trace_header.to_header_str() + self.invocation.trace_context["aws_trace_header"] = aws_trace_header_str + return json.dumps( + { + "payload": to_str(base64.b64encode(self.invocation.payload)), + "invoked_arn": self.invocation.invoked_arn, + "client_context": self.invocation.client_context, + "invocation_type": self.invocation.invocation_type, + "invoke_time": self.invocation.invoke_time.isoformat(), + # = invocation_id + "request_id": self.invocation.request_id, + "retries": self.retries, + "exception_retries": self.exception_retries, + "trace_context": self.invocation.trace_context, + } + ) + + @classmethod + def decode(cls, message: str) -> "SQSInvocation": + invocation_dict = json.loads(message) + invocation = Invocation( + payload=base64.b64decode(invocation_dict["payload"]), + invoked_arn=invocation_dict["invoked_arn"], + client_context=invocation_dict["client_context"], + invocation_type=invocation_dict["invocation_type"], + invoke_time=datetime.fromisoformat(invocation_dict["invoke_time"]), + request_id=invocation_dict["request_id"], + trace_context=invocation_dict.get("trace_context"), + ) + # Decode TraceHeader + aws_trace_header_str = invocation_dict.get("trace_context", {}).get("aws_trace_header") + invocation_dict["trace_context"]["aws_trace_header"] = TraceHeader.from_header_str( + aws_trace_header_str + ) + return cls( + invocation=invocation, + retries=invocation_dict["retries"], + exception_retries=invocation_dict["exception_retries"], + ) + + +def has_enough_time_for_retry( + sqs_invocation: SQSInvocation, event_invoke_config: EventInvokeConfig +) -> bool: + time_passed = datetime.now() - sqs_invocation.invocation.invoke_time + delay_queue_invoke_seconds = ( + sqs_invocation.retries + 1 + ) * config.LAMBDA_RETRY_BASE_DELAY_SECONDS + # 6 hours is the default based on these AWS sources: + # https://repost.aws/questions/QUd214DdOQRkKWr7D8IuSMIw/why-is-aws-lambda-eventinvokeconfig-s-limit-for-maximumretryattempts-2 + # https://aws.amazon.com/blogs/compute/introducing-new-asynchronous-invocation-metrics-for-aws-lambda/ + # https://aws.amazon.com/about-aws/whats-new/2019/11/aws-lambda-supports-max-retry-attempts-event-age-asynchronous-invocations/ + maximum_event_age_in_seconds = 6 * 60 * 60 + if event_invoke_config and event_invoke_config.maximum_event_age_in_seconds is not None: + maximum_event_age_in_seconds = event_invoke_config.maximum_event_age_in_seconds + return ( + maximum_event_age_in_seconds + and ceil(time_passed.total_seconds()) + delay_queue_invoke_seconds + <= maximum_event_age_in_seconds + ) + + +# TODO: optimize this client configuration. Do we need to consider client caching here? +CLIENT_CONFIG = Config( + connect_timeout=5, + read_timeout=10, + retries={"max_attempts": 0}, +) + + +class Poller: + version_manager: LambdaVersionManager + event_queue_url: str + _shutdown_event: threading.Event + invoker_pool: ThreadPoolExecutor + + def __init__(self, version_manager: LambdaVersionManager, event_queue_url: str): + self.version_manager = version_manager + self.event_queue_url = event_queue_url + self._shutdown_event = threading.Event() + function_id = self.version_manager.function_version.id + # TODO: think about scaling, test it, make it configurable?! + self.invoker_pool = ThreadPoolExecutor( + thread_name_prefix=f"lambda-invoker-{function_id.function_name}:{function_id.qualifier}" + ) + + def run(self, *args, **kwargs): + sqs_client = get_sqs_client( + self.version_manager.function_version, client_config=CLIENT_CONFIG + ) + function_timeout = self.version_manager.function_version.config.timeout + while not self._shutdown_event.is_set(): + try: + response = sqs_client.receive_message( + QueueUrl=self.event_queue_url, + # TODO: consider replacing with short polling instead of long polling to prevent keeping connections open + # however, we had some serious performance issues when tried out, so those have to be investigated first + WaitTimeSeconds=2, + # Related: SQS event source mapping batches up to 10 messages: + # https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html + MaxNumberOfMessages=10, + VisibilityTimeout=function_timeout + 60, + ) + if not response.get("Messages"): + continue + LOG.debug("[%s] Got %d messages", self.event_queue_url, len(response["Messages"])) + # Guard against shutdown event arriving while polling SQS for messages + if not self._shutdown_event.is_set(): + for message in response["Messages"]: + # NOTE: queueing within the thread pool executor could lead to double executions + # due to the visibility timeout + self.invoker_pool.submit(self.handle_message, message) + + except Exception as e: + # TODO: if the gateway shuts down before the shutdown event even is set, + # we might still get an error message + # after shutdown of LS, we might expectedly get errors, if other components shut down. + # In any case, after the event manager is shut down, we do not need to spam error logs in case + # some resource is already missing + if self._shutdown_event.is_set(): + return + LOG.error( + "Error while polling lambda events for function %s: %s", + self.version_manager.function_version.qualified_arn, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + # some time between retries to avoid running into the problem right again + time.sleep(1) + + def stop(self): + LOG.debug( + "Stopping event poller %s %s", + self.version_manager.function_version.qualified_arn, + id(self), + ) + self._shutdown_event.set() + self.invoker_pool.shutdown(cancel_futures=True, wait=False) + + def handle_message(self, message: dict) -> None: + failure_cause = None + qualifier = self.version_manager.function_version.id.qualifier + event_invoke_config = self.version_manager.function.event_invoke_configs.get(qualifier) + runtime = None + status = None + try: + sqs_invocation = SQSInvocation.decode(message["Body"]) + invocation = sqs_invocation.invocation + try: + invocation_result = self.version_manager.invoke(invocation=invocation) + function_config = self.version_manager.function_version.config + function_counter.labels( + operation=FunctionOperation.invoke, + runtime=function_config.runtime or "n/a", + status=FunctionStatus.success, + invocation_type=InvocationType.Event, + package_type=function_config.package_type, + ).increment() + except Exception as e: + # Reserved concurrency == 0 + if self.version_manager.function.reserved_concurrent_executions == 0: + failure_cause = "ZeroReservedConcurrency" + status = FunctionStatus.zero_reserved_concurrency_error + # Maximum event age expired (lookahead for next retry) + elif not has_enough_time_for_retry(sqs_invocation, event_invoke_config): + failure_cause = "EventAgeExceeded" + status = FunctionStatus.event_age_exceeded_error + if failure_cause: + invocation_result = InvocationResult( + is_error=True, request_id=invocation.request_id, payload=None, logs=None + ) + self.process_failure_destination( + sqs_invocation, invocation_result, event_invoke_config, failure_cause + ) + self.process_dead_letter_queue(sqs_invocation, invocation_result) + return + # 3) Otherwise, retry without increasing counter + status = self.process_throttles_and_system_errors(sqs_invocation, e) + return + finally: + sqs_client = get_sqs_client(self.version_manager.function_version) + sqs_client.delete_message( + QueueUrl=self.event_queue_url, ReceiptHandle=message["ReceiptHandle"] + ) + # status MUST be set before returning + package_type = self.version_manager.function_version.config.package_type + function_counter.labels( + operation=FunctionOperation.invoke, + runtime=runtime or "n/a", + status=status, + invocation_type=InvocationType.Event, + package_type=package_type, + ).increment() + + # Good summary blogpost: https://haithai91.medium.com/aws-lambdas-retry-behaviors-edff90e1cf1b + # Asynchronous invocation handling: https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html + # https://aws.amazon.com/blogs/compute/introducing-new-asynchronous-invocation-metrics-for-aws-lambda/ + max_retry_attempts = 2 + if event_invoke_config and event_invoke_config.maximum_retry_attempts is not None: + max_retry_attempts = event_invoke_config.maximum_retry_attempts + + # An invocation error either leads to a terminal failure or to a scheduled retry + if invocation_result.is_error: # invocation error + failure_cause = None + # Reserved concurrency == 0 + if self.version_manager.function.reserved_concurrent_executions == 0: + failure_cause = "ZeroReservedConcurrency" + # Maximum retries exhausted + elif sqs_invocation.retries >= max_retry_attempts: + failure_cause = "RetriesExhausted" + # TODO: test what happens if max event age expired before it gets scheduled the first time?! + # Maximum event age expired (lookahead for next retry) + elif not has_enough_time_for_retry(sqs_invocation, event_invoke_config): + failure_cause = "EventAgeExceeded" + + if failure_cause: # handle failure destination and DLQ + self.process_failure_destination( + sqs_invocation, invocation_result, event_invoke_config, failure_cause + ) + self.process_dead_letter_queue(sqs_invocation, invocation_result) + return + else: # schedule retry + sqs_invocation.retries += 1 + # Assumption: We assume that the internal exception retries counter is reset after + # an invocation that does not throw an exception + sqs_invocation.exception_retries = 0 + # LAMBDA_RETRY_BASE_DELAY_SECONDS has a limit of 300s because the maximum SQS DelaySeconds + # is 15 minutes (900s) and the maximum retry count is 3. SQS quota for "Message timer": + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html + delay_seconds = sqs_invocation.retries * config.LAMBDA_RETRY_BASE_DELAY_SECONDS + # TODO: max SQS message size limit could break parity with AWS because + # our SQSInvocation contains additional fields! 256kb is max for both Lambda payload + SQS + # TODO: write test with max SQS message size + sqs_client.send_message( + QueueUrl=self.event_queue_url, + MessageBody=sqs_invocation.encode(), + DelaySeconds=delay_seconds, + ) + return + else: # invocation success + self.process_success_destination( + sqs_invocation, invocation_result, event_invoke_config + ) + except Exception as e: + LOG.error( + "Error handling lambda invoke %s", e, exc_info=LOG.isEnabledFor(logging.DEBUG) + ) + + def process_throttles_and_system_errors( + self, sqs_invocation: SQSInvocation, error: Exception + ) -> str: + # If the function doesn't have enough concurrency available to process all events, additional + # requests are throttled. For throttling errors (429) and system errors (500-series), Lambda returns + # the event to the queue and attempts to run the function again for up to 6 hours. The retry interval + # increases exponentially from 1 second after the first attempt to a maximum of 5 minutes. If the + # queue contains many entries, Lambda increases the retry interval and reduces the rate at which it + # reads events from the queue. Source: + # https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html + # Difference depending on error cause: + # https://aws.amazon.com/blogs/compute/introducing-new-asynchronous-invocation-metrics-for-aws-lambda/ + # Troubleshooting 500 errors: + # https://repost.aws/knowledge-center/lambda-troubleshoot-invoke-error-502-500 + if isinstance(error, TooManyRequestsException): # Throttles 429 + LOG.debug("Throttled lambda %s: %s", self.version_manager.function_arn, error) + status = FunctionStatus.throttle_error + else: # System errors 5xx + LOG.debug( + "Service exception in lambda %s: %s", self.version_manager.function_arn, error + ) + status = FunctionStatus.system_error + maximum_exception_retry_delay_seconds = 5 * 60 + delay_seconds = min( + 2**sqs_invocation.exception_retries, maximum_exception_retry_delay_seconds + ) + # TODO: calculate delay seconds into max event age handling + sqs_client = get_sqs_client(self.version_manager.function_version) + sqs_client.send_message( + QueueUrl=self.event_queue_url, + MessageBody=sqs_invocation.encode(), + DelaySeconds=delay_seconds, + ) + return status + + def process_success_destination( + self, + sqs_invocation: SQSInvocation, + invocation_result: InvocationResult, + event_invoke_config: EventInvokeConfig | None, + ) -> None: + if event_invoke_config is None: + return + success_destination = event_invoke_config.destination_config.get("OnSuccess", {}).get( + "Destination" + ) + if success_destination is None: + return + LOG.debug("Handling success destination for %s", self.version_manager.function_arn) + + original_payload = sqs_invocation.invocation.payload + destination_payload = { + "version": "1.0", + "timestamp": timestamp_millis(), + "requestContext": { + "requestId": invocation_result.request_id, + "functionArn": self.version_manager.function_version.qualified_arn, + "condition": "Success", + "approximateInvokeCount": sqs_invocation.retries + 1, + }, + "requestPayload": json.loads(to_str(original_payload)), + "responseContext": { + "statusCode": 200, + "executedVersion": self.version_manager.function_version.id.qualifier, + }, + "responsePayload": json.loads(to_str(invocation_result.payload or {})), + } + + target_arn = event_invoke_config.destination_config["OnSuccess"]["Destination"] + try: + send_event_to_target( + target_arn=target_arn, + event=destination_payload, + role=self.version_manager.function_version.config.role, + source_arn=self.version_manager.function_version.id.unqualified_arn(), + source_service="lambda", + events_source="lambda", + events_detail_type="Lambda Function Invocation Result - Success", + ) + except Exception as e: + LOG.warning("Error sending invocation result to %s: %s", target_arn, e) + + def process_failure_destination( + self, + sqs_invocation: SQSInvocation, + invocation_result: InvocationResult, + event_invoke_config: EventInvokeConfig | None, + failure_cause: str, + ): + if event_invoke_config is None: + return + failure_destination = event_invoke_config.destination_config.get("OnFailure", {}).get( + "Destination" + ) + if failure_destination is None: + return + LOG.debug("Handling failure destination for %s", self.version_manager.function_arn) + + original_payload = sqs_invocation.invocation.payload + if failure_cause == "ZeroReservedConcurrency": + approximate_invoke_count = sqs_invocation.retries + else: + approximate_invoke_count = sqs_invocation.retries + 1 + destination_payload = { + "version": "1.0", + "timestamp": timestamp_millis(), + "requestContext": { + "requestId": invocation_result.request_id, + "functionArn": self.version_manager.function_version.qualified_arn, + "condition": failure_cause, + "approximateInvokeCount": approximate_invoke_count, + }, + "requestPayload": json.loads(to_str(original_payload)), + } + if failure_cause != "ZeroReservedConcurrency": + destination_payload["responseContext"] = { + "statusCode": 200, + "executedVersion": self.version_manager.function_version.id.qualifier, + "functionError": "Unhandled", + } + destination_payload["responsePayload"] = json.loads(to_str(invocation_result.payload)) + + target_arn = event_invoke_config.destination_config["OnFailure"]["Destination"] + try: + send_event_to_target( + target_arn=target_arn, + event=destination_payload, + role=self.version_manager.function_version.config.role, + source_arn=self.version_manager.function_version.id.unqualified_arn(), + source_service="lambda", + events_source="lambda", + events_detail_type="Lambda Function Invocation Result - Failure", + ) + except Exception as e: + LOG.warning("Error sending invocation result to %s: %s", target_arn, e) + + def process_dead_letter_queue( + self, + sqs_invocation: SQSInvocation, + invocation_result: InvocationResult, + ): + LOG.debug("Handling dead letter queue for %s", self.version_manager.function_arn) + try: + dead_letter_queue._send_to_dead_letter_queue( + source_arn=self.version_manager.function_arn, + dlq_arn=self.version_manager.function_version.config.dead_letter_arn, + event=json.loads(to_str(sqs_invocation.invocation.payload)), + # TODO: Refactor DLQ handling by removing the invocation exception from the legacy lambda provider + # TODO: Check message. Possibly remove because it is not used in the DLQ message?! + error=LegacyInvocationException( + message="hi", result=to_str(invocation_result.payload) + ), + role=self.version_manager.function_version.config.role, + ) + except Exception as e: + LOG.warning( + "Error sending invocation result to DLQ %s: %s", + self.version_manager.function_version.config.dead_letter_arn, + e, + ) + + +class LambdaEventManager: + version_manager: LambdaVersionManager + poller: Poller | None + poller_thread: FuncThread | None + event_queue_url: str | None + lifecycle_lock: threading.RLock + stopped: threading.Event + + def __init__(self, version_manager: LambdaVersionManager): + self.version_manager = version_manager + self.poller = None + self.poller_thread = None + self.event_queue_url = None + self.lifecycle_lock = threading.RLock() + self.stopped = threading.Event() + + def enqueue_event(self, invocation: Invocation) -> None: + message_body = SQSInvocation(invocation).encode() + sqs_client = get_sqs_client(self.version_manager.function_version) + try: + sqs_client.send_message(QueueUrl=self.event_queue_url, MessageBody=message_body) + except Exception: + LOG.error( + "Failed to enqueue Lambda event into queue %s. Invocation: request_id=%s, invoked_arn=%s", + self.event_queue_url, + invocation.request_id, + invocation.invoked_arn, + ) + raise + + def start(self) -> None: + LOG.debug( + "Starting event manager %s id %s", + self.version_manager.function_version.id.qualified_arn(), + id(self), + ) + with self.lifecycle_lock: + if self.stopped.is_set(): + LOG.debug("Event manager already stopped before started.") + return + sqs_client = get_sqs_client(self.version_manager.function_version) + function_id = self.version_manager.function_version.id + # Truncate function name to ensure queue name limit of max 80 characters + function_name_short = function_id.function_name[:47] + # The instance id MUST be unique to the function and a given LocalStack instance + queue_namespace = ( + f"{function_id.qualified_arn()}-{self.version_manager.function.instance_id}" + ) + queue_name = f"{function_name_short}-{md5(queue_namespace)}" + create_queue_response = sqs_client.create_queue(QueueName=queue_name) + self.event_queue_url = create_queue_response["QueueUrl"] + # We don't need to purge the queue for persistence or cloud pods because the instance id is MUST be unique + + self.poller = Poller(self.version_manager, self.event_queue_url) + self.poller_thread = FuncThread( + self.poller.run, + name=f"lambda-poller-{function_id.function_name}:{function_id.qualifier}", + ) + self.poller_thread.start() + + def stop_for_update(self) -> None: + LOG.debug( + "Stopping event manager but keep queue %s id %s", + self.version_manager.function_version.qualified_arn, + id(self), + ) + with self.lifecycle_lock: + if self.stopped.is_set(): + LOG.debug("Event manager already stopped!") + return + self.stopped.set() + if self.poller: + self.poller.stop() + self.poller_thread.join(timeout=3) + LOG.debug("Waited for poller thread %s", self.poller_thread) + if self.poller_thread.is_alive(): + LOG.error("Poller did not shutdown %s", self.poller_thread) + self.poller = None + + def stop(self) -> None: + LOG.debug( + "Stopping event manager %s: %s id %s", + self.version_manager.function_version.qualified_arn, + self.poller, + id(self), + ) + with self.lifecycle_lock: + if self.stopped.is_set(): + LOG.debug("Event manager already stopped!") + return + self.stopped.set() + if self.poller: + self.poller.stop() + self.poller_thread.join(timeout=3) + LOG.debug("Waited for poller thread %s", self.poller_thread) + if self.poller_thread.is_alive(): + LOG.error("Poller did not shutdown %s", self.poller_thread) + self.poller = None + if self.event_queue_url: + sqs_client = get_sqs_client( + self.version_manager.function_version, client_config=CLIENT_CONFIG + ) + sqs_client.delete_queue(QueueUrl=self.event_queue_url) + self.event_queue_url = None diff --git a/localstack-core/localstack/services/lambda_/invocation/execution_environment.py b/localstack-core/localstack/services/lambda_/invocation/execution_environment.py new file mode 100644 index 0000000000000..139ec4d877fbe --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/execution_environment.py @@ -0,0 +1,405 @@ +import logging +import random +import string +import time +from datetime import date, datetime +from enum import Enum, auto +from threading import RLock, Timer +from typing import Callable, Dict, Optional + +from localstack import config +from localstack.aws.connect import connect_to +from localstack.services.lambda_.invocation.lambda_models import ( + Credentials, + FunctionVersion, + InitializationType, + Invocation, + InvocationResult, +) +from localstack.services.lambda_.invocation.runtime_executor import ( + RuntimeExecutor, + get_runtime_executor, +) +from localstack.utils.lambda_debug_mode.lambda_debug_mode import ( + DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS, + is_lambda_debug_timeout_enabled_for, +) +from localstack.utils.strings import to_str +from localstack.utils.xray.trace_header import TraceHeader + +STARTUP_TIMEOUT_SEC = config.LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT +HEX_CHARS = [str(num) for num in range(10)] + ["a", "b", "c", "d", "e", "f"] + +LOG = logging.getLogger(__name__) + + +class RuntimeStatus(Enum): + INACTIVE = auto() + STARTING = auto() + READY = auto() + INVOKING = auto() + STARTUP_FAILED = auto() + STARTUP_TIMED_OUT = auto() + STOPPED = auto() + TIMING_OUT = auto() + + +class InvalidStatusException(Exception): + def __init__(self, message: str): + super().__init__(message) + + +class EnvironmentStartupTimeoutException(Exception): + def __init__(self, message: str): + super().__init__(message) + + +def generate_runtime_id() -> str: + return "".join(random.choices(string.hexdigits[:16], k=32)).lower() + + +# TODO: add status callback +class ExecutionEnvironment: + runtime_executor: RuntimeExecutor + status_lock: RLock + status: RuntimeStatus + initialization_type: InitializationType + last_returned: datetime + startup_timer: Optional[Timer] + keepalive_timer: Optional[Timer] + on_timeout: Callable[[str, str], None] + + def __init__( + self, + function_version: FunctionVersion, + initialization_type: InitializationType, + on_timeout: Callable[[str, str], None], + version_manager_id: str, + ): + self.id = generate_runtime_id() + self.status = RuntimeStatus.INACTIVE + # Lock for updating the runtime status + self.status_lock = RLock() + self.function_version = function_version + self.initialization_type = initialization_type + self.runtime_executor = get_runtime_executor()(self.id, function_version) + self.last_returned = datetime.min + self.startup_timer = None + self.keepalive_timer = Timer(0, lambda *args, **kwargs: None) + self.on_timeout = on_timeout + self.version_manager_id = version_manager_id + + def get_log_group_name(self) -> str: + return f"/aws/lambda/{self.function_version.id.function_name}" + + def get_log_stream_name(self) -> str: + return f"{date.today():%Y/%m/%d}/[{self.function_version.id.qualifier}]{self.id}" + + def get_environment_variables(self) -> Dict[str, str]: + """ + Returns the environment variable set for the runtime container + :return: Dict of environment variables + """ + credentials = self.get_credentials() + env_vars = { + # 1) Public AWS defined runtime environment variables (in same order): + # https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html + # a) Reserved environment variables + # _HANDLER conditionally added below + # TODO: _X_AMZN_TRACE_ID + "AWS_DEFAULT_REGION": self.function_version.id.region, + "AWS_REGION": self.function_version.id.region, + # AWS_EXECUTION_ENV conditionally added below + "AWS_LAMBDA_FUNCTION_NAME": self.function_version.id.function_name, + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": self.function_version.config.memory_size, + "AWS_LAMBDA_FUNCTION_VERSION": self.function_version.id.qualifier, + "AWS_LAMBDA_INITIALIZATION_TYPE": self.initialization_type, + "AWS_LAMBDA_LOG_GROUP_NAME": self.get_log_group_name(), + "AWS_LAMBDA_LOG_STREAM_NAME": self.get_log_stream_name(), + # Access IDs for role + "AWS_ACCESS_KEY_ID": credentials["AccessKeyId"], + "AWS_SECRET_ACCESS_KEY": credentials["SecretAccessKey"], + "AWS_SESSION_TOKEN": credentials["SessionToken"], + # AWS_LAMBDA_RUNTIME_API is set in the runtime interface emulator (RIE) + "LAMBDA_TASK_ROOT": "/var/task", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + # b) Unreserved environment variables + # LANG + # LD_LIBRARY_PATH + # NODE_PATH + # PYTHONPATH + # GEM_PATH + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + # TODO: allow configuration of xray address + "AWS_XRAY_DAEMON_ADDRESS": "127.0.0.1:2000", + # not 100% sure who sets these two + # extensions are not supposed to have them in their envs => TODO: test if init removes them + "_AWS_XRAY_DAEMON_PORT": "2000", + "_AWS_XRAY_DAEMON_ADDRESS": "127.0.0.1", + # AWS_LAMBDA_DOTNET_PREJIT + "TZ": ":UTC", + # 2) Public AWS RIE interface: https://github.com/aws/aws-lambda-runtime-interface-emulator + "AWS_LAMBDA_FUNCTION_TIMEOUT": self._get_execution_timeout_seconds(), + # 3) Public LocalStack endpoint + "LOCALSTACK_HOSTNAME": self.runtime_executor.get_endpoint_from_executor(), + "EDGE_PORT": str(config.GATEWAY_LISTEN[0].port), + # AWS_ENDPOINT_URL conditionally added below + # 4) Internal LocalStack runtime API + "LOCALSTACK_RUNTIME_ID": self.id, + "LOCALSTACK_RUNTIME_ENDPOINT": self.runtime_executor.get_runtime_endpoint(), + # 5) Account of the function (necessary for extensions API) + "LOCALSTACK_FUNCTION_ACCOUNT_ID": self.function_version.id.account, + # used by the init to spawn the x-ray daemon + # LOCALSTACK_USER conditionally added below + } + # Conditionally added environment variables + if not config.LAMBDA_DISABLE_AWS_ENDPOINT_URL: + env_vars["AWS_ENDPOINT_URL"] = ( + f"http://{self.runtime_executor.get_endpoint_from_executor()}:{config.GATEWAY_LISTEN[0].port}" + ) + # config.handler is None for image lambdas and will be populated at runtime (e.g., by RIE) + if self.function_version.config.handler: + env_vars["_HANDLER"] = self.function_version.config.handler + # Will be overridden by the runtime itself unless it is a provided runtime + if self.function_version.config.runtime: + env_vars["AWS_EXECUTION_ENV"] = "AWS_Lambda_rapid" + if self.function_version.config.environment: + env_vars.update(self.function_version.config.environment) + if config.LAMBDA_INIT_DEBUG: + # Disable dropping privileges because it breaks debugging + env_vars["LOCALSTACK_USER"] = "root" + # Forcefully overwrite the user might break debugging! + if config.LAMBDA_INIT_USER is not None: + env_vars["LOCALSTACK_USER"] = config.LAMBDA_INIT_USER + if config.LS_LOG in config.TRACE_LOG_LEVELS: + env_vars["LOCALSTACK_INIT_LOG_LEVEL"] = "info" + if config.LAMBDA_INIT_POST_INVOKE_WAIT_MS: + env_vars["LOCALSTACK_POST_INVOKE_WAIT_MS"] = int(config.LAMBDA_INIT_POST_INVOKE_WAIT_MS) + if config.LAMBDA_LIMITS_MAX_FUNCTION_PAYLOAD_SIZE_BYTES: + env_vars["LOCALSTACK_MAX_PAYLOAD_SIZE"] = int( + config.LAMBDA_LIMITS_MAX_FUNCTION_PAYLOAD_SIZE_BYTES + ) + return env_vars + + # Lifecycle methods + def start(self) -> None: + """ + Starting the runtime environment + """ + with self.status_lock: + if self.status != RuntimeStatus.INACTIVE: + raise InvalidStatusException( + f"Execution environment {self.id} can only be started when inactive. Current status: {self.status}" + ) + self.status = RuntimeStatus.STARTING + + startup_time_seconds: int = self._get_startup_timeout_seconds() + self.startup_timer = Timer(startup_time_seconds, self.timed_out) + self.startup_timer.start() + + try: + time_before = time.perf_counter() + self.runtime_executor.start(self.get_environment_variables()) + LOG.debug( + "Start of execution environment %s for function %s took %0.2fms", + self.id, + self.function_version.qualified_arn, + (time.perf_counter() - time_before) * 1000, + ) + + with self.status_lock: + self.status = RuntimeStatus.READY + # TODO: Distinguish between expected errors (e.g., timeout, cancellation due to deletion update) and + # other unexpected exceptions. Improve control flow after implementing error reporting in Go init. + except Exception as e: + if self.status == RuntimeStatus.STARTUP_TIMED_OUT: + raise EnvironmentStartupTimeoutException( + "Execution environment timed out during startup." + ) from e + else: + LOG.warning( + "Failed to start execution environment %s: %s", + self.id, + e, + ) + self.errored() + raise + finally: + if self.startup_timer: + self.startup_timer.cancel() + self.startup_timer = None + + def stop(self) -> None: + """ + Stopping the runtime environment + """ + with self.status_lock: + if self.status in [RuntimeStatus.INACTIVE, RuntimeStatus.STOPPED]: + raise InvalidStatusException( + f"Execution environment {self.id} cannot be stopped when inactive or already stopped." + f" Current status: {self.status}" + ) + self.status = RuntimeStatus.STOPPED + self.runtime_executor.stop() + self.keepalive_timer.cancel() + + # Status methods + def release(self) -> None: + self.last_returned = datetime.now() + with self.status_lock: + if self.status != RuntimeStatus.INVOKING: + raise InvalidStatusException( + f"Execution environment {self.id} can only be set to status ready while running." + f" Current status: {self.status}" + ) + self.status = RuntimeStatus.READY + + if self.initialization_type == "on-demand": + self.keepalive_timer = Timer(config.LAMBDA_KEEPALIVE_MS / 1000, self.keepalive_passed) + self.keepalive_timer.start() + + def reserve(self) -> None: + with self.status_lock: + if self.status != RuntimeStatus.READY: + raise InvalidStatusException( + f"Execution environment {self.id} can only be reserved if ready. " + f" Current status: {self.status}" + ) + self.status = RuntimeStatus.INVOKING + + self.keepalive_timer.cancel() + + def keepalive_passed(self) -> None: + LOG.debug( + "Execution environment %s for function %s has not received any invocations in a while. Stopping.", + self.id, + self.function_version.qualified_arn, + ) + # The stop() method allows to interrupt invocations (on purpose), which might cancel running invocations + # which we should not do when the keepalive timer passed. + # The new TIMING_OUT state prevents this race condition + with self.status_lock: + if self.status != RuntimeStatus.READY: + LOG.debug( + "Keepalive timer passed, but current runtime status is %s. Aborting keepalive stop.", + self.status, + ) + return + self.status = RuntimeStatus.TIMING_OUT + self.stop() + # Notify assignment service via callback to remove from environments list + self.on_timeout(self.version_manager_id, self.id) + + def timed_out(self) -> None: + """Handle status updates if the startup of an execution environment times out. + Invoked asynchronously by the startup timer in a separate thread.""" + # TODO: De-emphasize the error part after fixing control flow and tests for test_lambda_runtime_exit + LOG.warning( + "Execution environment %s for function %s timed out during startup." + " Check for errors during the startup of your Lambda function and" + " consider increasing the startup timeout via LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT.", + self.id, + self.function_version.qualified_arn, + ) + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + "Logs from the execution environment %s after startup timeout:\n%s", + self.id, + self.get_prefixed_logs(), + ) + with self.status_lock: + if self.status != RuntimeStatus.STARTING: + raise InvalidStatusException( + f"Execution environment {self.id} can only time out while starting. Current status: {self.status}" + ) + self.status = RuntimeStatus.STARTUP_TIMED_OUT + try: + self.runtime_executor.stop() + except Exception as e: + LOG.debug("Unable to shutdown execution environment %s after timeout: %s", self.id, e) + + def errored(self) -> None: + """Handle status updates if the startup of an execution environment fails. + Invoked synchronously when an unexpected error occurs during startup.""" + LOG.warning( + "Execution environment %s for function %s failed during startup." + " Check for errors during the startup of your Lambda function.", + self.id, + self.function_version.qualified_arn, + ) + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + "Logs from the execution environment %s after startup error:\n%s", + self.id, + self.get_prefixed_logs(), + ) + with self.status_lock: + if self.status != RuntimeStatus.STARTING: + raise InvalidStatusException( + f"Execution environment {self.id} can only error while starting. Current status: {self.status}" + ) + self.status = RuntimeStatus.STARTUP_FAILED + try: + self.runtime_executor.stop() + except Exception as e: + LOG.debug("Unable to shutdown execution environment %s after error: %s", self.id, e) + + def get_prefixed_logs(self) -> str: + """Returns prefixed lambda containers logs""" + logs = self.runtime_executor.get_logs() + prefix = f"[lambda {self.id}] " + prefixed_logs = logs.replace("\n", f"\n{prefix}") + return f"{prefix}{prefixed_logs}" + + def invoke(self, invocation: Invocation) -> InvocationResult: + assert self.status == RuntimeStatus.INVOKING + # Async/event invokes might miss an aws_trace_header, then we need to create a new root trace id. + aws_trace_header = ( + invocation.trace_context.get("aws_trace_header") or TraceHeader().ensure_root_exists() + ) + # The Lambda RIE requires a full tracing header including Root, Parent, and Samples. Otherwise, tracing fails + # with the warning "Subsegment ## handler discarded due to Lambda worker still initializing" + aws_trace_header.ensure_sampled_exists() + # TODO: replace this random parent id with actual parent segment created within the Lambda provider using X-Ray + aws_trace_header.ensure_parent_exists() + # TODO: test and implement Active and PassThrough tracing and sampling decisions. + # TODO: implement Lambda lineage: https://docs.aws.amazon.com/lambda/latest/dg/invocation-recursion.html + invoke_payload = { + "invoke-id": invocation.request_id, # TODO: rename to request-id (requires change in lambda-init) + "invoked-function-arn": invocation.invoked_arn, + "payload": to_str(invocation.payload), + "trace-id": aws_trace_header.to_header_str(), + } + return self.runtime_executor.invoke(payload=invoke_payload) + + def get_credentials(self) -> Credentials: + sts_client = connect_to(region_name=self.function_version.id.region).sts.request_metadata( + service_principal="lambda" + ) + role_session_name = self.function_version.id.function_name + + # To handle single character function names #9016 + if len(role_session_name) == 1: + role_session_name += "@lambda_function" + # TODO we should probably set a maximum alive duration for environments, due to the session expiration + return sts_client.assume_role( + RoleArn=self.function_version.config.role, + RoleSessionName=role_session_name, + DurationSeconds=43200, + )["Credentials"] + + def _get_execution_timeout_seconds(self) -> int: + # Returns the timeout value in seconds to be enforced during the execution of the + # lambda function. This is the configured value or the DEBUG MODE default if this + # is enabled. + if is_lambda_debug_timeout_enabled_for(self.function_version.qualified_arn): + return DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS + return self.function_version.config.timeout + + def _get_startup_timeout_seconds(self) -> int: + # Returns the timeout value in seconds to be enforced during lambda container startups. + # This is the value defined through LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT or the LAMBDA + # DEBUG MODE default if this is enabled. + if is_lambda_debug_timeout_enabled_for(self.function_version.qualified_arn): + return DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS + return STARTUP_TIMEOUT_SEC diff --git a/localstack-core/localstack/services/lambda_/invocation/executor_endpoint.py b/localstack-core/localstack/services/lambda_/invocation/executor_endpoint.py new file mode 100644 index 0000000000000..eea6e0c77ebaa --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/executor_endpoint.py @@ -0,0 +1,282 @@ +import abc +import logging +import time +from concurrent.futures import CancelledError, Future +from http import HTTPStatus +from typing import Any, Dict, Optional + +import requests +from werkzeug import Request + +from localstack.http import Response, route +from localstack.services.edge import ROUTER +from localstack.services.lambda_.invocation.lambda_models import InvocationResult +from localstack.utils.backoff import ExponentialBackoff +from localstack.utils.lambda_debug_mode.lambda_debug_mode import ( + DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS, + is_lambda_debug_mode, +) +from localstack.utils.objects import singleton_factory +from localstack.utils.strings import to_str + +LOG = logging.getLogger(__name__) +INVOCATION_PORT = 9563 + +NAMESPACE = "/_localstack_lambda" + + +class InvokeSendError(Exception): + def __init__(self, message): + super().__init__(message) + + +class StatusErrorException(Exception): + payload: bytes + + def __init__(self, message, payload: bytes): + super().__init__(message) + self.payload = payload + + +class ShutdownDuringStartup(Exception): + def __init__(self, message): + super().__init__(message) + + +class Endpoint(abc.ABC): + @abc.abstractmethod + def invocation_response(self, request: Request, req_id: str) -> Response: + pass + + @abc.abstractmethod + def invocation_error(self, request: Request, req_id: str) -> Response: + pass + + @abc.abstractmethod + def invocation_logs(self, request: Request, invoke_id: str) -> Response: + pass + + @abc.abstractmethod + def status_ready(self, request: Request, executor_id: str) -> Response: + pass + + @abc.abstractmethod + def status_error(self, request: Request, executor_id: str) -> Response: + pass + + +class ExecutorRouter: + endpoints: dict[str, Endpoint] + + def __init__(self): + self.endpoints = {} + + def register_endpoint(self, executor_id: str, endpoint: Endpoint): + self.endpoints[executor_id] = endpoint + + def unregister_endpoint(self, executor_id: str): + self.endpoints.pop(executor_id) + + @route(f"{NAMESPACE}//invocations//response", methods=["POST"]) + def invocation_response(self, request: Request, executor_id: str, req_id: str) -> Response: + endpoint = self.endpoints[executor_id] + return endpoint.invocation_response(request, req_id) + + @route(f"{NAMESPACE}//invocations//error", methods=["POST"]) + def invocation_error(self, request: Request, executor_id: str, req_id: str) -> Response: + endpoint = self.endpoints[executor_id] + return endpoint.invocation_error(request, req_id) + + @route(f"{NAMESPACE}//invocations//logs", methods=["POST"]) + def invocation_logs(self, request: Request, executor_id: str, invoke_id: str) -> Response: + endpoint = self.endpoints[executor_id] + return endpoint.invocation_logs(request, invoke_id) + + @route(f"{NAMESPACE}//status//ready", methods=["POST"]) + def status_ready(self, request: Request, env_id: str, executor_id: str) -> Response: + endpoint = self.endpoints[executor_id] + return endpoint.status_ready(request, executor_id) + + @route(f"{NAMESPACE}//status//error", methods=["POST"]) + def status_error(self, request: Request, env_id: str, executor_id: str) -> Response: + endpoint = self.endpoints[executor_id] + return endpoint.status_error(request, executor_id) + + +@singleton_factory +def executor_router(): + router = ExecutorRouter() + ROUTER.add(router) + return router + + +class ExecutorEndpoint(Endpoint): + container_address: str + container_port: int + executor_id: str + startup_future: Future[bool] | None + invocation_future: Future[InvocationResult] | None + logs: str | None + + def __init__( + self, + executor_id: str, + container_address: Optional[str] = None, + container_port: Optional[int] = INVOCATION_PORT, + ) -> None: + self.container_address = container_address + self.container_port = container_port + self.executor_id = executor_id + self.startup_future = None + self.invocation_future = None + self.logs = None + + def invocation_response(self, request: Request, req_id: str) -> Response: + result = InvocationResult(req_id, request.data, is_error=False, logs=self.logs) + self.invocation_future.set_result(result) + return Response(status=HTTPStatus.ACCEPTED) + + def invocation_error(self, request: Request, req_id: str) -> Response: + result = InvocationResult(req_id, request.data, is_error=True, logs=self.logs) + self.invocation_future.set_result(result) + return Response(status=HTTPStatus.ACCEPTED) + + def invocation_logs(self, request: Request, invoke_id: str) -> Response: + logs = request.json + if isinstance(logs, Dict): + self.logs = logs["logs"] + else: + LOG.error("Invalid logs from init! Logs: %s", logs) + return Response(status=HTTPStatus.ACCEPTED) + + def status_ready(self, request: Request, executor_id: str) -> Response: + self.startup_future.set_result(True) + return Response(status=HTTPStatus.ACCEPTED) + + def status_error(self, request: Request, executor_id: str) -> Response: + LOG.warning("Execution environment startup failed: %s", to_str(request.data)) + # TODO: debug Lambda runtime init to not send `runtime/init/error` twice + if self.startup_future.done(): + return Response(status=HTTPStatus.BAD_REQUEST) + self.startup_future.set_exception( + StatusErrorException("Environment startup failed", payload=request.data) + ) + return Response(status=HTTPStatus.ACCEPTED) + + def start(self) -> None: + executor_router().register_endpoint(self.executor_id, self) + self.startup_future = Future() + + def wait_for_startup(self): + try: + self.startup_future.result() + except CancelledError as e: + # Only happens if we shutdown the container during execution environment startup + # Daniel: potential problem if we have a shutdown while we start the container (e.g., timeout) but wait_for_startup is not yet called + raise ShutdownDuringStartup( + "Executor environment shutdown during container startup" + ) from e + + def get_endpoint_prefix(self): + return f"{NAMESPACE}/{self.executor_id}" + + def shutdown(self) -> None: + executor_router().unregister_endpoint(self.executor_id) + self.startup_future.cancel() + if self.invocation_future: + self.invocation_future.cancel() + + def invoke(self, payload: Dict[str, str]) -> InvocationResult: + self.invocation_future = Future() + self.logs = None + if not self.container_address: + raise ValueError("Container address not set, but got an invoke.") + invocation_url = f"http://{self.container_address}:{self.container_port}/invoke" + # disable proxies for internal requests + proxies = {"http": "", "https": ""} + response = self._perform_invoke( + invocation_url=invocation_url, proxies=proxies, payload=payload + ) + if not response.ok: + raise InvokeSendError( + f"Error while sending invocation {payload} to {invocation_url}. Error Code: {response.status_code}" + ) + + # Set a reference future awaiting limit to ensure this process eventually ends, + # with timeout errors being handled by the lambda evaluator. + # The following logic selects which maximum waiting time to consider depending + # on whether the application is being debugged or not. + # Note that if timeouts are enforced for the lambda function invoked at this endpoint + # (this is needs to be configured in the Lambda Debug Mode Config file), the lambda + # function will continue to enforce the expected timeouts. + if is_lambda_debug_mode(): + # The value is set to a default high value to ensure eventual termination. + timeout_seconds = DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS + else: + # Do not wait longer for an invoke than the maximum lambda timeout plus a buffer + lambda_max_timeout_seconds = 900 + invoke_timeout_buffer_seconds = 5 + timeout_seconds = lambda_max_timeout_seconds + invoke_timeout_buffer_seconds + return self.invocation_future.result(timeout=timeout_seconds) + + @staticmethod + def _perform_invoke( + invocation_url: str, + proxies: dict[str, str], + payload: dict[str, Any], + ) -> requests.Response: + """ + Dispatches a Lambda invocation request to the specified container endpoint, with automatic + retries in case of connection errors, using exponential backoff. + + The first attempt is made immediately. If it fails, exponential backoff is applied with + retry intervals starting at 100ms, doubling each time for up to 5 total retries. + + Parameters: + invocation_url (str): The full URL of the container's invocation endpoint. + proxies (dict[str, str]): Proxy settings to be used for the HTTP request. + payload (dict[str, Any]): The JSON payload to send to the container. + + Returns: + Response: The successful HTTP response from the container. + + Raises: + requests.exceptions.ConnectionError: If all retry attempts fail to connect. + """ + backoff = None + last_exception = None + max_retry_on_connection_error = 5 + + for attempt_count in range(max_retry_on_connection_error + 1): # 1 initial + n retries + try: + response = requests.post(url=invocation_url, json=payload, proxies=proxies) + return response + except requests.exceptions.ConnectionError as connection_error: + last_exception = connection_error + + if backoff is None: + LOG.debug( + "Initial connection attempt failed: %s. Starting backoff retries.", + connection_error, + ) + backoff = ExponentialBackoff( + max_retries=max_retry_on_connection_error, + initial_interval=0.1, + multiplier=2.0, + randomization_factor=0.0, + max_interval=1, + max_time_elapsed=-1, + ) + + delay = backoff.next_backoff() + if delay > 0: + LOG.debug( + "Connection error on invoke attempt #%d: %s. Retrying in %.2f seconds", + attempt_count, + connection_error, + delay, + ) + time.sleep(delay) + + LOG.debug("Connection error after all attempts exhausted: %s", last_exception) + raise last_exception diff --git a/localstack-core/localstack/services/lambda_/invocation/internal_sqs_queue.py b/localstack-core/localstack/services/lambda_/invocation/internal_sqs_queue.py new file mode 100644 index 0000000000000..41da58b681701 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/internal_sqs_queue.py @@ -0,0 +1,215 @@ +import logging +import threading +from typing import Iterable + +from localstack import config +from localstack.aws.api.sqs import ( + AttributeNameList, + CreateQueueResult, + GetQueueAttributesResult, + Message, + MessageAttributeNameList, + MessageBodyAttributeMap, + MessageBodySystemAttributeMap, + MessageSystemAttributeName, + NullableInteger, + QueueAttributeMap, + ReceiveMessageResult, + SendMessageResult, + String, + TagMap, +) +from localstack.services.sqs.models import SqsQueue, StandardQueue +from localstack.services.sqs.provider import ( + QueueUpdateWorker, + _create_message_attribute_hash, + to_sqs_api_message, +) +from localstack.services.sqs.utils import generate_message_id +from localstack.utils.objects import singleton_factory +from localstack.utils.strings import md5 +from localstack.utils.time import now + +LOG = logging.getLogger(__name__) + + +class EventQueueUpdateWorker(QueueUpdateWorker): + """ + Regularly re-queues inflight and delayed messages whose visibility timeout has expired or delay deadline has been + reached. + """ + + def __init__(self) -> None: + super().__init__() + self.queues = [] + + def add_queue(self, queue: SqsQueue): + self.queues.append(queue) + + def remove_queue(self, queue: SqsQueue): + self.queues.remove(queue) + + def iter_queues(self) -> Iterable[SqsQueue]: + return iter(self.queues) + + +class QueueManager: + queues: dict[str, StandardQueue] + queue_lock: threading.RLock + queue_update_worker: EventQueueUpdateWorker + + def __init__(self): + self.queues = {} + # lock for handling queue lifecycle and avoiding duplicates + self.queue_lock = threading.RLock() + self.queue_update_worker = EventQueueUpdateWorker() + + def start(self): + self.queue_update_worker.start() + + def stop(self): + self.queue_update_worker.stop() + + def get_queue(self, queue_name: str): + if queue_name not in self.queues: + raise ValueError("Queue not available") + return self.queues[queue_name] + + def create_queue(self, queue_name: str) -> SqsQueue: + """ + Creates a queue. + :param queue_name: Queue name, has to be unique + :return: Queue Object + """ + with self.queue_lock: + if queue_name in self.queues: + return self.queues[queue_name] + + queue = StandardQueue( + name=queue_name, + region="us-east-1", + account_id=config.INTERNAL_RESOURCE_ACCOUNT, + ) + self.queues[queue_name] = queue + self.queue_update_worker.add_queue(queue) + return queue + + def delete_queue(self, queue_name: str) -> None: + with self.queue_lock: + if queue_name not in self.queues: + raise ValueError(f"Queue '{queue_name}' not available") + + queue = self.queues.pop(queue_name) + self.queue_update_worker.remove_queue(queue) + + +class FakeSqsClient: + def __init__(self, queue_manager: QueueManager): + self.queue_manager = queue_manager + + def create_queue( + self, QueueName: String, Attributes: QueueAttributeMap = None, tags: TagMap = None + ) -> CreateQueueResult: + self.queue_manager.create_queue(queue_name=QueueName) + return {"QueueUrl": QueueName} + + def delete_queue(self, QueueUrl: String) -> None: + self.queue_manager.delete_queue(queue_name=QueueUrl) + + def get_queue_attributes( + self, QueueUrl: String, AttributeNames: AttributeNameList = None + ) -> GetQueueAttributesResult: + queue = self.queue_manager.get_queue(queue_name=QueueUrl) + result = queue.get_queue_attributes(AttributeNames) + return {"Attributes": result} + + def purge_queue(self, QueueUrl: String) -> None: + queue = self.queue_manager.get_queue(queue_name=QueueUrl) + queue.clear() + + def receive_message( + self, + QueueUrl: String, + AttributeNames: AttributeNameList = None, + MessageAttributeNames: MessageAttributeNameList = None, + MaxNumberOfMessages: NullableInteger = None, + VisibilityTimeout: NullableInteger = None, + WaitTimeSeconds: NullableInteger = None, + ReceiveRequestAttemptId: String = None, + ) -> ReceiveMessageResult: + queue = self.queue_manager.get_queue(queue_name=QueueUrl) + num = MaxNumberOfMessages or 1 + result = queue.receive( + num_messages=num, + visibility_timeout=VisibilityTimeout, + wait_time_seconds=WaitTimeSeconds, + ) + + messages = [] + for i, standard_message in enumerate(result.successful): + message = to_sqs_api_message(standard_message, AttributeNames, MessageAttributeNames) + message["ReceiptHandle"] = result.receipt_handles[i] + messages.append(message) + + return {"Messages": messages if messages else None} + + def delete_message(self, QueueUrl: String, ReceiptHandle: String) -> None: + queue = self.queue_manager.get_queue(queue_name=QueueUrl) + queue.remove(ReceiptHandle) + + def _create_message_attributes( + self, + message_system_attributes: MessageBodySystemAttributeMap = None, + ) -> dict[str, str]: + result = { + MessageSystemAttributeName.SenderId: config.INTERNAL_RESOURCE_ACCOUNT, # not the account ID in AWS + MessageSystemAttributeName.SentTimestamp: str(now(millis=True)), + } + + if message_system_attributes is not None: + for attr in message_system_attributes: + result[attr] = message_system_attributes[attr]["StringValue"] + + return result + + def send_message( + self, + QueueUrl: String, + MessageBody: String, + DelaySeconds: NullableInteger = None, + MessageAttributes: MessageBodyAttributeMap = None, + MessageSystemAttributes: MessageBodySystemAttributeMap = None, + MessageDeduplicationId: String = None, + MessageGroupId: String = None, + ) -> SendMessageResult: + queue = self.queue_manager.get_queue(queue_name=QueueUrl) + + message = Message( + MessageId=generate_message_id(), + MD5OfBody=md5(MessageBody), + Body=MessageBody, + Attributes=self._create_message_attributes(MessageSystemAttributes), + MD5OfMessageAttributes=_create_message_attribute_hash(MessageAttributes), + MessageAttributes=MessageAttributes, + ) + queue_item = queue.put( + message=message, + message_deduplication_id=MessageDeduplicationId, + message_group_id=MessageGroupId, + delay_seconds=int(DelaySeconds) if DelaySeconds is not None else None, + ) + message = queue_item.message + return { + "MessageId": message["MessageId"], + "MD5OfMessageBody": message["MD5OfBody"], + "MD5OfMessageAttributes": message.get("MD5OfMessageAttributes"), + "SequenceNumber": queue_item.sequence_number, + "MD5OfMessageSystemAttributes": _create_message_attribute_hash(MessageSystemAttributes), + } + + +@singleton_factory +def get_fake_sqs_client(): + queue_manager = QueueManager() + queue_manager.start() + return FakeSqsClient(queue_manager) diff --git a/localstack-core/localstack/services/lambda_/invocation/lambda_models.py b/localstack-core/localstack/services/lambda_/invocation/lambda_models.py new file mode 100644 index 0000000000000..0ce171cff6cc6 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/lambda_models.py @@ -0,0 +1,650 @@ +"""Lambda models for internal use and persistence. +The LambdaProviderPro in localstack-pro imports this model and configures persistence. +The actual function code is stored in S3 (see S3Code). +""" + +import dataclasses +import logging +import os.path +import shutil +import tempfile +import threading +from abc import ABCMeta, abstractmethod +from datetime import datetime +from pathlib import Path +from typing import IO, Dict, Literal, Optional, TypedDict + +from botocore.exceptions import ClientError + +from localstack import config +from localstack.aws.api import CommonServiceException +from localstack.aws.api.lambda_ import ( + AllowedPublishers, + Architecture, + CodeSigningPolicies, + Cors, + DestinationConfig, + FunctionUrlAuthType, + InvocationType, + InvokeMode, + LastUpdateStatus, + LoggingConfig, + PackageType, + ProvisionedConcurrencyStatusEnum, + RecursiveLoop, + Runtime, + RuntimeVersionConfig, + SnapStartResponse, + State, + StateReasonCode, + TracingMode, +) +from localstack.aws.connect import connect_to +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.services.lambda_.api_utils import qualified_lambda_arn, unqualified_lambda_arn +from localstack.utils.archives import unzip +from localstack.utils.strings import long_uid, short_uid + +LOG = logging.getLogger(__name__) + + +# TODO: maybe we should make this more "transient" by always initializing to Pending and *not* persisting it? +@dataclasses.dataclass(frozen=True) +class VersionState: + state: State + code: Optional[StateReasonCode] = None + reason: Optional[str] = None + + +@dataclasses.dataclass +class Invocation: + payload: bytes + invoked_arn: str + client_context: str | None + invocation_type: InvocationType + invoke_time: datetime + # = invocation_id + request_id: str + trace_context: dict + + +InitializationType = Literal["on-demand", "provisioned-concurrency"] + + +class ArchiveCode(metaclass=ABCMeta): + @abstractmethod + def generate_presigned_url(self, endpoint_url: str | None = None): + """ + Generates a presigned url pointing to the code archive + """ + pass + + @abstractmethod + def is_hot_reloading(self): + """ + Whether this code archive is for hot reloading. + This means it should mount the location from the host, and should instruct the runtimes to listen for changes + + :return: True if this object represents hot reloading, False otherwise + """ + pass + + @abstractmethod + def get_unzipped_code_location(self): + """ + Get the location of the unzipped archive on disk + """ + pass + + @abstractmethod + def prepare_for_execution(self): + """ + Unzips the code archive to the proper destination on disk, if not already present + """ + pass + + @abstractmethod + def destroy_cached(self): + """ + Destroys the code object on disk, if it was saved on disk before + """ + pass + + @abstractmethod + def destroy(self): + """ + Deletes the code object from S3 and the unzipped version from disk + """ + pass + + +@dataclasses.dataclass(frozen=True) +class S3Code(ArchiveCode): + """ + Objects representing a code archive stored in an internal S3 bucket. + + S3 Store: + Code archives represented by this method are stored in a bucket awslambda-{region_name}-tasks, + (e.g. awslambda-us-east-1-tasks), when correctly created using create_lambda_archive. + The "awslambda" prefix matches the behavior at real AWS. + + This class will then provide different properties / methods to be operated on the stored code, + like the ability to create presigned-urls, checking the code hash etc. + + A call to destroy() of this class will delete the code object from both the S3 store and the local cache + Unzipped Cache: + After a call to prepare_for_execution, an unzipped version of the represented code will be stored on disk, + ready to mount/copy. + + It will be present at the location returned by get_unzipped_code_location, + namely /tmp/lambda/{bucket_name}/{id}/code + + The cache on disk will be deleted after a call to destroy_cached (or destroy) + """ + + id: str + account_id: str + s3_bucket: str + s3_key: str + s3_object_version: str | None + code_sha256: str + code_size: int + _disk_lock: threading.RLock = dataclasses.field(default_factory=threading.RLock) + + def _download_archive_to_file(self, target_file: IO) -> None: + """ + Download the code archive into a given file + + :param target_file: File the code archive should be downloaded into (IO object) + """ + s3_client = connect_to( + region_name=AWS_REGION_US_EAST_1, + aws_access_key_id=config.INTERNAL_RESOURCE_ACCOUNT, + ).s3 + extra_args = {"VersionId": self.s3_object_version} if self.s3_object_version else {} + s3_client.download_fileobj( + Bucket=self.s3_bucket, Key=self.s3_key, Fileobj=target_file, ExtraArgs=extra_args + ) + target_file.flush() + + def generate_presigned_url(self, endpoint_url: str | None = None) -> str: + """ + Generates a presigned url pointing to the code archive + """ + s3_client = connect_to( + region_name=AWS_REGION_US_EAST_1, + aws_access_key_id=config.INTERNAL_RESOURCE_ACCOUNT, + endpoint_url=endpoint_url, + ).s3 + params = {"Bucket": self.s3_bucket, "Key": self.s3_key} + if self.s3_object_version: + params["VersionId"] = self.s3_object_version + return s3_client.generate_presigned_url("get_object", Params=params) + + def is_hot_reloading(self) -> bool: + """ + Whether this code archive is hot reloading + + :return: True if it must it represents hot reloading, False otherwise + """ + return False + + def get_unzipped_code_location(self) -> Path: + """ + Get the location of the unzipped archive on disk + """ + return Path(f"{tempfile.gettempdir()}/lambda/{self.s3_bucket}/{self.id}/code") + + def prepare_for_execution(self) -> None: + """ + Unzips the code archive to the proper destination on disk, if not already present + """ + target_path = self.get_unzipped_code_location() + with self._disk_lock: + if target_path.exists(): + return + LOG.debug("Saving code %s to disk", self.id) + target_path.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile() as file: + self._download_archive_to_file(file) + unzip(file.name, str(target_path)) + + def destroy_cached(self) -> None: + """ + Destroys the code object on disk, if it was saved on disk before + """ + # delete parent folder to delete the whole code location + code_path = self.get_unzipped_code_location().parent + if not code_path.exists(): + return + try: + shutil.rmtree(code_path) + except OSError as e: + LOG.debug( + "Could not cleanup function code path %s due to error %s while deleting file %s", + code_path, + e.strerror, + e.filename, + ) + + def destroy(self) -> None: + """ + Deletes the code object from S3 and the unzipped version from disk + """ + LOG.debug("Final code destruction for %s", self.id) + self.destroy_cached() + s3_client = connect_to( + region_name=AWS_REGION_US_EAST_1, + aws_access_key_id=config.INTERNAL_RESOURCE_ACCOUNT, + ).s3 + kwargs = {"VersionId": self.s3_object_version} if self.s3_object_version else {} + try: + s3_client.delete_object(Bucket=self.s3_bucket, Key=self.s3_key, **kwargs) + except ClientError as e: + LOG.debug( + "Cannot delete lambda archive %s in bucket %s: %s", self.s3_key, self.s3_bucket, e + ) + + +@dataclasses.dataclass(frozen=True) +class HotReloadingCode(ArchiveCode): + """ + Objects representing code which is mounted from a given directory from the host, for hot reloading + """ + + host_path: str + code_sha256: str = "hot-reloading-hash-not-available" + code_size: int = 0 + + def generate_presigned_url(self, endpoint_url: str | None = None) -> str: + return f"Code location: {self.host_path}" + + def get_unzipped_code_location(self) -> Path: + path = os.path.expandvars(self.host_path) + return Path(path) + + def is_hot_reloading(self) -> bool: + """ + Whether this code archive is for hot reloading. + This means it should mount the location from the host, and should instruct the runtimes to listen for changes + + :return: True if it represents hot reloading, False otherwise + """ + return True + + def prepare_for_execution(self) -> None: + pass + + def destroy_cached(self) -> None: + """ + Destroys the code object on disk, if it was saved on disk before + """ + pass + + def destroy(self) -> None: + """ + Deletes the code object from S3 and the unzipped version from disk + """ + pass + + +@dataclasses.dataclass(frozen=True) +class ImageCode: + image_uri: str + repository_type: str + code_sha256: str + + @property + def resolved_image_uri(self): + return f"{self.image_uri.rpartition(':')[0]}@sha256:{self.code_sha256}" + + +@dataclasses.dataclass +class DeadLetterConfig: + target_arn: str + + +@dataclasses.dataclass +class FileSystemConfig: + arn: str + local_mount_path: str + + +@dataclasses.dataclass(frozen=True) +class ImageConfig: + working_directory: str + command: list[str] = dataclasses.field(default_factory=list) + entrypoint: list[str] = dataclasses.field(default_factory=list) + + +@dataclasses.dataclass +class VpcConfig: + vpc_id: str + security_group_ids: list[str] = dataclasses.field(default_factory=list) + subnet_ids: list[str] = dataclasses.field(default_factory=list) + + +@dataclasses.dataclass(frozen=True) +class UpdateStatus: + status: LastUpdateStatus | None + code: str | None = None # TODO: probably not a string + reason: str | None = None + + +@dataclasses.dataclass +class LambdaEphemeralStorage: + size: int + + +@dataclasses.dataclass +class FunctionUrlConfig: + """ + * HTTP(s) + * You can apply function URLs to any function alias, or to the $LATEST unpublished function version. You can't add a function URL to any other function version. + * Once you create a function URL, its URL endpoint never changes + """ + + function_arn: str # fully qualified ARN + function_name: str # resolved name + cors: Cors + url_id: str # Custom URL (via tag), or generated unique subdomain id e.g. pfn5bdb2dl5mzkbn6eb2oi3xfe0nthdn + url: str # full URL (e.g. "https://pfn5bdb2dl5mzkbn6eb2oi3xfe0nthdn.lambda-url.eu-west-3.on.aws/") + auth_type: FunctionUrlAuthType + creation_time: str # time + last_modified_time: Optional[str] = ( + None # TODO: check if this is the creation time when initially creating + ) + function_qualifier: Optional[str] = "$LATEST" # only $LATEST or alias name + invoke_mode: Optional[InvokeMode] = None + + +@dataclasses.dataclass +class ProvisionedConcurrencyConfiguration: + provisioned_concurrent_executions: int + last_modified: str # date + + +@dataclasses.dataclass +class ProvisionedConcurrencyState: + """transient items""" + + allocated: int = 0 + available: int = 0 + status: ProvisionedConcurrencyStatusEnum = dataclasses.field( + default=ProvisionedConcurrencyStatusEnum.IN_PROGRESS + ) + status_reason: Optional[str] = None + + +@dataclasses.dataclass +class AliasRoutingConfig: + version_weights: Dict[str, float] + + +@dataclasses.dataclass(frozen=True) +class VersionIdentifier: + function_name: str + qualifier: str + region: str + account: str + + def qualified_arn(self): + return qualified_lambda_arn( + function_name=self.function_name, + qualifier=self.qualifier, + region=self.region, + account=self.account, + ) + + def unqualified_arn(self): + return unqualified_lambda_arn( + function_name=self.function_name, + region=self.region, + account=self.account, + ) + + +@dataclasses.dataclass(frozen=True) +class VersionAlias: + function_version: str + name: str + description: str | None + routing_configuration: AliasRoutingConfig | None = None + revision_id: str = dataclasses.field(init=False, default_factory=long_uid) + + +@dataclasses.dataclass +class ResourcePolicy: + Version: str + Id: str + Statement: list[dict] + + +@dataclasses.dataclass +class FunctionResourcePolicy: + policy: ResourcePolicy + + +@dataclasses.dataclass +class EventInvokeConfig: + function_name: str + qualifier: str + + last_modified: Optional[str] = dataclasses.field(compare=False) + destination_config: Optional[DestinationConfig] = None + maximum_retry_attempts: Optional[int] = None + maximum_event_age_in_seconds: Optional[int] = None + + +# Result Models +@dataclasses.dataclass +class InvocationResult: + request_id: str + payload: bytes | None + is_error: bool + logs: str | None + executed_version: str | None = None + + +@dataclasses.dataclass +class InvocationLogs: + request_id: str + logs: str + + +class Credentials(TypedDict): + AccessKeyId: str + SecretAccessKey: str + SessionToken: str + Expiration: datetime + + +class OtherServiceEndpoint: + def status_ready(self, executor_id: str) -> None: + """ + Processes a status ready report by RAPID + :param executor_id: Executor ID this ready report is for + """ + raise NotImplementedError() + + def status_error(self, executor_id: str) -> None: + """ + Processes a status error report by RAPID + :param executor_id: Executor ID this error report is for + """ + raise NotImplementedError() + + +@dataclasses.dataclass(frozen=True) +class CodeSigningConfig: + csc_id: str + arn: str + + allowed_publishers: AllowedPublishers + policies: CodeSigningPolicies + last_modified: str + description: Optional[str] = None + + +@dataclasses.dataclass +class LayerPolicyStatement: + sid: str + action: str + principal: str + organization_id: Optional[str] + + +@dataclasses.dataclass +class LayerPolicy: + revision_id: str = dataclasses.field(init=False, default_factory=long_uid) + id: str = "default" # static + version: str = "2012-10-17" # static + statements: dict[str, LayerPolicyStatement] = dataclasses.field( + default_factory=dict + ) # statement ID => statement + + +@dataclasses.dataclass +class LayerVersion: + layer_version_arn: str + layer_arn: str + + version: int + code: ArchiveCode + license_info: str + compatible_runtimes: list[Runtime] + compatible_architectures: list[Architecture] + created: str # date + description: str = "" + + policy: LayerPolicy = None + + +@dataclasses.dataclass +class Layer: + arn: str + next_version: int = 1 + next_version_lock: threading.RLock = dataclasses.field(default_factory=threading.RLock) + layer_versions: dict[str, LayerVersion] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass(frozen=True) +class VersionFunctionConfiguration: + # fields + description: str + role: str + timeout: int + runtime: Runtime + memory_size: int + handler: str + package_type: PackageType + environment: dict[str, str] + architectures: list[Architecture] + # internal revision is updated when runtime restart is necessary + internal_revision: str + ephemeral_storage: LambdaEphemeralStorage + snap_start: SnapStartResponse + + tracing_config_mode: TracingMode + code: ArchiveCode + last_modified: str # ISO string + state: VersionState + + image: Optional[ImageCode] = None + image_config: Optional[ImageConfig] = None + runtime_version_config: Optional[RuntimeVersionConfig] = None + last_update: Optional[UpdateStatus] = None + revision_id: str = dataclasses.field(init=False, default_factory=long_uid) + layers: list[LayerVersion] = dataclasses.field(default_factory=list) + + dead_letter_arn: Optional[str] = None + + # kms_key_arn: str + # file_system_configs: FileSystemConfig + vpc_config: Optional[VpcConfig] = None + + logging_config: LoggingConfig = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass(frozen=True) +class FunctionVersion: + id: VersionIdentifier + config: VersionFunctionConfiguration + + @property + def qualified_arn(self) -> str: + return self.id.qualified_arn() + + +@dataclasses.dataclass +class Function: + function_name: str + code_signing_config_arn: Optional[str] = None + aliases: dict[str, VersionAlias] = dataclasses.field(default_factory=dict) + versions: dict[str, FunctionVersion] = dataclasses.field(default_factory=dict) + function_url_configs: dict[str, FunctionUrlConfig] = dataclasses.field( + default_factory=dict + ) # key is $LATEST, version, or alias + permissions: dict[str, FunctionResourcePolicy] = dataclasses.field( + default_factory=dict + ) # key is $LATEST, version or alias + event_invoke_configs: dict[str, EventInvokeConfig] = dataclasses.field( + default_factory=dict + ) # key is $LATEST(?), version or alias + reserved_concurrent_executions: Optional[int] = None + recursive_loop: RecursiveLoop = RecursiveLoop.Terminate + provisioned_concurrency_configs: dict[str, ProvisionedConcurrencyConfiguration] = ( + dataclasses.field(default_factory=dict) + ) + + lock: threading.RLock = dataclasses.field(default_factory=threading.RLock) + next_version: int = 1 + + def latest(self) -> FunctionVersion: + return self.versions["$LATEST"] + + # HACK to model a volatile variable that should be ignored for persistence + def __post_init__(self): + # Identifier unique to this function and LocalStack instance. + # A LocalStack restart or persistence load should create a new instance id. + # Used for retaining invoke queues across version updates for $LATEST, but separate unrelated instances. + self.instance_id = short_uid() + + def __getstate__(self): + """Ignore certain volatile fields for pickling. + # https://docs.python.org/3/library/pickle.html#handling-stateful-objects + """ + # Copy the object's state from self.__dict__ which contains + # all our instance attributes. Always use the dict.copy() + # method to avoid modifying the original state. + state = self.__dict__.copy() + # Remove the volatile entries. + del state["instance_id"] + return state + + def __setstate__(self, state): + # Inject persistent state + self.__dict__.update(state) + # Create new instance id + self.__post_init__() + + +class ValidationException(CommonServiceException): + def __init__(self, message: str): + super().__init__(code="ValidationException", status_code=400, message=message) + + +class RequestEntityTooLargeException(CommonServiceException): + def __init__(self, message: str): + super().__init__(code="RequestEntityTooLargeException", status_code=413, message=message) + + +# note: we might at some point want to generalize these limits across all services and fetch them from there + + +@dataclasses.dataclass +class AccountSettings: + total_code_size: int = config.LAMBDA_LIMITS_TOTAL_CODE_SIZE + code_size_zipped: int = config.LAMBDA_LIMITS_CODE_SIZE_ZIPPED + code_size_unzipped: int = config.LAMBDA_LIMITS_CODE_SIZE_UNZIPPED + concurrent_executions: int = config.LAMBDA_LIMITS_CONCURRENT_EXECUTIONS diff --git a/localstack-core/localstack/services/lambda_/invocation/lambda_service.py b/localstack-core/localstack/services/lambda_/invocation/lambda_service.py new file mode 100644 index 0000000000000..837d766444c5d --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/lambda_service.py @@ -0,0 +1,680 @@ +import base64 +import concurrent.futures +import dataclasses +import io +import logging +import os.path +import random +import uuid +from concurrent.futures import Executor, Future, ThreadPoolExecutor +from datetime import datetime +from hashlib import sha256 +from pathlib import PurePosixPath, PureWindowsPath +from threading import RLock +from typing import TYPE_CHECKING, Optional + +from localstack import config +from localstack.aws.api.lambda_ import ( + InvalidParameterValueException, + InvalidRequestContentException, + InvocationType, + LastUpdateStatus, + ResourceConflictException, + ResourceNotFoundException, + State, +) +from localstack.aws.connect import connect_to +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.services.lambda_.analytics import ( + FunctionOperation, + FunctionStatus, + function_counter, + hotreload_counter, +) +from localstack.services.lambda_.api_utils import ( + lambda_arn, + qualified_lambda_arn, + qualifier_is_alias, +) +from localstack.services.lambda_.invocation.assignment import AssignmentService +from localstack.services.lambda_.invocation.counting_service import CountingService +from localstack.services.lambda_.invocation.event_manager import LambdaEventManager +from localstack.services.lambda_.invocation.lambda_models import ( + ArchiveCode, + Function, + FunctionVersion, + HotReloadingCode, + ImageCode, + Invocation, + InvocationResult, + S3Code, + UpdateStatus, + VersionAlias, + VersionState, +) +from localstack.services.lambda_.invocation.models import lambda_stores +from localstack.services.lambda_.invocation.version_manager import LambdaVersionManager +from localstack.services.lambda_.lambda_utils import HINT_LOG +from localstack.utils.archives import get_unzipped_size, is_zip_file +from localstack.utils.container_utils.container_client import ContainerException +from localstack.utils.docker_utils import DOCKER_CLIENT as CONTAINER_CLIENT +from localstack.utils.strings import short_uid, to_str + +if TYPE_CHECKING: + from mypy_boto3_s3 import S3Client + +LOG = logging.getLogger(__name__) + +LAMBDA_DEFAULT_TIMEOUT_SECONDS = 3 +LAMBDA_DEFAULT_MEMORY_SIZE = 128 + + +class LambdaService: + # mapping from qualified ARN to version manager + lambda_running_versions: dict[str, LambdaVersionManager] + lambda_starting_versions: dict[str, LambdaVersionManager] + # mapping from qualified ARN to event manager + event_managers = dict[str, LambdaEventManager] + lambda_version_manager_lock: RLock + task_executor: Executor + + assignment_service: AssignmentService + counting_service: CountingService + + def __init__(self) -> None: + self.lambda_running_versions = {} + self.lambda_starting_versions = {} + self.event_managers = {} + self.lambda_version_manager_lock = RLock() + self.task_executor = ThreadPoolExecutor(thread_name_prefix="lambda-service-task") + self.assignment_service = AssignmentService() + self.counting_service = CountingService() + + def stop(self) -> None: + """ + Stop the whole lambda service + """ + shutdown_futures = [] + for event_manager in self.event_managers.values(): + shutdown_futures.append(self.task_executor.submit(event_manager.stop)) + # TODO: switch shutdown order? yes, shutdown starting versions before the running versions would make more sense + for version_manager in self.lambda_running_versions.values(): + shutdown_futures.append(self.task_executor.submit(version_manager.stop)) + for version_manager in self.lambda_starting_versions.values(): + shutdown_futures.append(self.task_executor.submit(version_manager.stop)) + shutdown_futures.append( + self.task_executor.submit( + version_manager.function_version.config.code.destroy_cached + ) + ) + _, not_done = concurrent.futures.wait(shutdown_futures, timeout=5) + if not_done: + LOG.debug("Shutdown not complete, missing threads: %s", not_done) + self.task_executor.shutdown(cancel_futures=True) + self.assignment_service.stop() + + def stop_version(self, qualified_arn: str) -> None: + """ + Stops a specific lambda service version + :param qualified_arn: Qualified arn for the version to stop + """ + LOG.debug("Stopping version %s", qualified_arn) + event_manager = self.event_managers.pop(qualified_arn, None) + if not event_manager: + LOG.debug("Could not find event manager to stop for function %s...", qualified_arn) + else: + self.task_executor.submit(event_manager.stop) + version_manager = self.lambda_running_versions.pop( + qualified_arn, self.lambda_starting_versions.pop(qualified_arn, None) + ) + if not version_manager: + raise ValueError(f"Unable to find version manager for {qualified_arn}") + self.task_executor.submit(version_manager.stop) + + def get_lambda_version_manager(self, function_arn: str) -> LambdaVersionManager: + """ + Get the lambda version for the given arn + :param function_arn: qualified arn for the lambda version + :return: LambdaVersionManager for the arn + """ + version_manager = self.lambda_running_versions.get(function_arn) + if not version_manager: + raise ValueError(f"Could not find version '{function_arn}'. Is it created?") + + return version_manager + + def get_lambda_event_manager(self, function_arn: str) -> LambdaEventManager: + """ + Get the lambda event manager for the given arn + :param function_arn: qualified arn for the lambda version + :return: LambdaEventManager for the arn + """ + event_manager = self.event_managers.get(function_arn) + if not event_manager: + raise ValueError(f"Could not find event manager '{function_arn}'. Is it created?") + + return event_manager + + def _start_lambda_version(self, version_manager: LambdaVersionManager) -> None: + new_state = version_manager.start() + self.update_version_state( + function_version=version_manager.function_version, new_state=new_state + ) + + def create_function_version(self, function_version: FunctionVersion) -> Future[None]: + """ + Creates a new function version (manager), and puts it in the startup dict + + :param function_version: Function Version to create + """ + with self.lambda_version_manager_lock: + qualified_arn = function_version.id.qualified_arn() + version_manager = self.lambda_starting_versions.get(qualified_arn) + if version_manager: + raise ResourceConflictException( + f"The operation cannot be performed at this time. An update is in progress for resource: {function_version.id.unqualified_arn()}", + Type="User", + ) + state = lambda_stores[function_version.id.account][function_version.id.region] + fn = state.functions.get(function_version.id.function_name) + version_manager = LambdaVersionManager( + function_arn=qualified_arn, + function_version=function_version, + function=fn, + counting_service=self.counting_service, + assignment_service=self.assignment_service, + ) + self.lambda_starting_versions[qualified_arn] = version_manager + return self.task_executor.submit(self._start_lambda_version, version_manager) + + def publish_version(self, function_version: FunctionVersion): + """ + Synchronously create a function version (manager) + Should only be called on publishing new versions, which basically clone an existing one. + The new version needs to be added to the lambda store before invoking this. + After successful completion of this method, the lambda version stored will be modified to be active, with a new revision id. + It will then be active for execution, and should be retrieved again from the store before returning the data over the API. + + :param function_version: Function Version to create + """ + with self.lambda_version_manager_lock: + qualified_arn = function_version.id.qualified_arn() + version_manager = self.lambda_starting_versions.get(qualified_arn) + if version_manager: + raise Exception( + "Version '%s' already starting up and in state %s", + qualified_arn, + version_manager.state, + ) + state = lambda_stores[function_version.id.account][function_version.id.region] + fn = state.functions.get(function_version.id.function_name) + version_manager = LambdaVersionManager( + function_arn=qualified_arn, + function_version=function_version, + function=fn, + counting_service=self.counting_service, + assignment_service=self.assignment_service, + ) + self.lambda_starting_versions[qualified_arn] = version_manager + self._start_lambda_version(version_manager) + + # Commands + def invoke( + self, + function_name: str, + qualifier: str, + region: str, + account_id: str, + invocation_type: InvocationType | None, + client_context: str | None, + request_id: str, + payload: bytes | None, + trace_context: dict | None = None, + ) -> InvocationResult | None: + """ + Invokes a specific version of a lambda + + :param request_id: context request ID + :param function_name: Function name + :param qualifier: Function version qualifier + :param region: Region of the function + :param account_id: Account id of the function + :param invocation_type: Invocation Type + :param client_context: Client Context, if applicable + :param trace_context: tracing information such as X-Ray header + :param payload: Invocation payload + :return: The invocation result + """ + # NOTE: consider making the trace_context mandatory once we update all usages (should be easier after v4.0) + trace_context = trace_context or {} + # Invoked arn (for lambda context) does not have qualifier if not supplied + invoked_arn = lambda_arn( + function_name=function_name, + qualifier=qualifier, + account=account_id, + region=region, + ) + qualifier = qualifier or "$LATEST" + state = lambda_stores[account_id][region] + function = state.functions.get(function_name) + + if function is None: + raise ResourceNotFoundException(f"Function not found: {invoked_arn}", Type="User") + + if qualifier_is_alias(qualifier): + alias = function.aliases.get(qualifier) + if not alias: + raise ResourceNotFoundException(f"Function not found: {invoked_arn}", Type="User") + version_qualifier = alias.function_version + if alias.routing_configuration: + version, probability = next( + iter(alias.routing_configuration.version_weights.items()) + ) + if random.random() < probability: + version_qualifier = version + else: + version_qualifier = qualifier + + # Need the qualified arn to exactly get the target lambda + qualified_arn = qualified_lambda_arn(function_name, version_qualifier, account_id, region) + version = function.versions.get(version_qualifier) + runtime = version.config.runtime or "n/a" + package_type = version.config.package_type + try: + version_manager = self.get_lambda_version_manager(qualified_arn) + event_manager = self.get_lambda_event_manager(qualified_arn) + except ValueError as e: + state = version and version.config.state.state + if state == State.Failed: + status = FunctionStatus.failed_state_error + HINT_LOG.error( + f"Failed to create the runtime executor for the function {function_name}. " + "Please ensure that Docker is available in the LocalStack container by adding the volume mount " + '"/var/run/docker.sock:/var/run/docker.sock" to your LocalStack startup. ' + "Check out https://docs.localstack.cloud/user-guide/aws/lambda/#docker-not-available" + ) + elif state == State.Pending: + status = FunctionStatus.pending_state_error + HINT_LOG.warning( + "Lambda functions are created and updated asynchronously in the new lambda provider like in AWS. " + f"Before invoking {function_name}, please wait until the function transitioned from the state " + "Pending to Active using: " + f'"awslocal lambda wait function-active-v2 --function-name {function_name}" ' + "Check out https://docs.localstack.cloud/user-guide/aws/lambda/#function-in-pending-state" + ) + else: + status = FunctionStatus.unhandled_state_error + LOG.error("Unexpected state %s for Lambda function %s", state, function_name) + function_counter.labels( + operation=FunctionOperation.invoke, + runtime=runtime, + status=status, + invocation_type=invocation_type, + package_type=package_type, + ).increment() + raise ResourceConflictException( + f"The operation cannot be performed at this time. The function is currently in the following state: {state}" + ) from e + # empty payloads have to work as well + if payload is None: + payload = b"{}" + else: + # detect invalid payloads early before creating an execution environment + try: + to_str(payload) + except Exception as e: + function_counter.labels( + operation=FunctionOperation.invoke, + runtime=runtime, + status=FunctionStatus.invalid_payload_error, + invocation_type=invocation_type, + package_type=package_type, + ).increment() + # MAYBE: improve parity of detailed exception message (quite cumbersome) + raise InvalidRequestContentException( + f"Could not parse request body into json: Could not parse payload into json: {e}", + Type="User", + ) + if invocation_type is None: + invocation_type = InvocationType.RequestResponse + if invocation_type == InvocationType.DryRun: + return None + # TODO payload verification An error occurred (InvalidRequestContentException) when calling the Invoke operation: Could not parse request body into json: Could not parse payload into json: Unexpected character (''' (code 39)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false') + # at [Source: (byte[])"'test'"; line: 1, column: 2] + # + if invocation_type == InvocationType.Event: + return event_manager.enqueue_event( + invocation=Invocation( + payload=payload, + invoked_arn=invoked_arn, + client_context=client_context, + invocation_type=invocation_type, + invoke_time=datetime.now(), + request_id=request_id, + trace_context=trace_context, + ) + ) + + invocation_result = version_manager.invoke( + invocation=Invocation( + payload=payload, + invoked_arn=invoked_arn, + client_context=client_context, + invocation_type=invocation_type, + invoke_time=datetime.now(), + request_id=request_id, + trace_context=trace_context, + ) + ) + status = ( + FunctionStatus.invocation_error + if invocation_result.is_error + else FunctionStatus.success + ) + function_counter.labels( + operation=FunctionOperation.invoke, + runtime=runtime, + status=status, + invocation_type=invocation_type, + package_type=package_type, + ).increment() + return invocation_result + + def update_version(self, new_version: FunctionVersion) -> Future[None]: + """ + Updates a given version. Will perform a rollover, so the old version will be active until the new one is ready + to be invoked + + :param new_version: New version (with the same qualifier as an older one) + """ + if new_version.qualified_arn not in self.lambda_running_versions: + raise ValueError( + f"Version {new_version.qualified_arn} cannot be updated if an old one is not running" + ) + + return self.create_function_version(function_version=new_version) + + def update_version_state( + self, function_version: FunctionVersion, new_state: VersionState + ) -> None: + """ + Update the version state for the given function version. + + This will perform a rollover to the given function if the new state is active and there is a previously + running version registered. The old version will be shutdown and its code deleted. + + If the new state is failed, it will abort the update and mark it as failed. + If an older version is still running, it will keep running. + + :param function_version: Version reporting the state + :param new_state: New state + """ + function_arn = function_version.qualified_arn + try: + old_version = None + old_event_manager = None + with self.lambda_version_manager_lock: + new_version_manager = self.lambda_starting_versions.pop(function_arn) + if not new_version_manager: + raise ValueError( + f"Version {function_arn} reporting state {new_state.state} does exist in the starting versions." + ) + if new_state.state == State.Active: + old_version = self.lambda_running_versions.get(function_arn, None) + old_event_manager = self.event_managers.get(function_arn, None) + self.lambda_running_versions[function_arn] = new_version_manager + self.event_managers[function_arn] = LambdaEventManager( + version_manager=new_version_manager + ) + self.event_managers[function_arn].start() + update_status = UpdateStatus(status=LastUpdateStatus.Successful) + elif new_state.state == State.Failed: + update_status = UpdateStatus(status=LastUpdateStatus.Failed) + self.task_executor.submit(new_version_manager.stop) + else: + # TODO what to do if state pending or inactive is supported? + self.task_executor.submit(new_version_manager.stop) + LOG.error( + "State %s for version %s should not have been reported. New version will be stopped.", + new_state, + function_arn, + ) + return + + # TODO is it necessary to get the version again? Should be locked for modification anyway + # Without updating the new state, the function would not change to active, last_update would be missing, and + # the revision id would not be updated. + state = lambda_stores[function_version.id.account][function_version.id.region] + # FIXME this will fail if the function is deleted during this code lines here + function = state.functions.get(function_version.id.function_name) + if old_event_manager: + self.task_executor.submit(old_event_manager.stop_for_update) + if old_version: + # if there is an old version, we assume it is an update, and stop the old one + self.task_executor.submit(old_version.stop) + if function: + self.task_executor.submit( + destroy_code_if_not_used, old_version.function_version.config.code, function + ) + if not function: + LOG.debug("Function %s was deleted during status update", function_arn) + return + current_version = function.versions[function_version.id.qualifier] + new_version_manager.state = new_state + new_version_state = dataclasses.replace( + current_version, + config=dataclasses.replace( + current_version.config, state=new_state, last_update=update_status + ), + ) + state.functions[function_version.id.function_name].versions[ + function_version.id.qualifier + ] = new_version_state + + except Exception: + LOG.exception("Failed to update function version for arn %s", function_arn) + + def update_alias(self, old_alias: VersionAlias, new_alias: VersionAlias, function: Function): + # if pointer changed, need to restart provisioned + provisioned_concurrency_config = function.provisioned_concurrency_configs.get( + old_alias.name + ) + if ( + old_alias.function_version != new_alias.function_version + and provisioned_concurrency_config is not None + ): + LOG.warning("Deprovisioning") + fn_version_old = function.versions.get(old_alias.function_version) + vm_old = self.get_lambda_version_manager(function_arn=fn_version_old.qualified_arn) + fn_version_new = function.versions.get(new_alias.function_version) + vm_new = self.get_lambda_version_manager(function_arn=fn_version_new.qualified_arn) + + # TODO: we might need to pull provisioned concurrency state a bit more out of the version manager for get_provisioned_concurrency_config + # TODO: make this fully async + vm_old.update_provisioned_concurrency_config(0).result(timeout=4) # sync + vm_new.update_provisioned_concurrency_config( + provisioned_concurrency_config.provisioned_concurrent_executions + ) # async again + + def can_assume_role(self, role_arn: str, region: str) -> bool: + """ + Checks whether lambda can assume the given role. + This _should_ only fail if IAM enforcement is enabled. + + :param role_arn: Role to assume + :return: True if the role can be assumed by lambda, false otherwise + """ + sts_client = connect_to(region_name=region).sts.request_metadata(service_principal="lambda") + try: + sts_client.assume_role( + RoleArn=role_arn, + RoleSessionName=f"test-assume-{short_uid()}", + DurationSeconds=900, + ) + return True + except Exception as e: + LOG.debug("Cannot assume role %s: %s", role_arn, e) + return False + + +# TODO: Move helper functions out of lambda_service into a separate module + + +def is_code_used(code: S3Code, function: Function) -> bool: + """ + Check if given code is still used in some version of the function + + :param code: Code object + :param function: function to check + :return: bool whether code is used in another version of the function + """ + with function.lock: + return any(code == version.config.code for version in function.versions.values()) + + +def destroy_code_if_not_used(code: S3Code, function: Function) -> None: + """ + Destroy the given code if it is not used in some version of the function + Do nothing otherwise + + :param code: Code object + :param function: Function the code belongs too + """ + with function.lock: + if not is_code_used(code, function): + code.destroy() + + +def store_lambda_archive( + archive_file: bytes, function_name: str, region_name: str, account_id: str +) -> S3Code: + """ + Stores the given lambda archive in an internal s3 bucket. + Also checks if zipfile matches the specifications + + :param archive_file: Archive file to store + :param function_name: function name the archive should be stored for + :param region_name: region name the archive should be stored for + :param account_id: account id the archive should be stored for + :return: S3 Code object representing the archive stored in S3 + """ + # check if zip file + if not is_zip_file(archive_file): + raise InvalidParameterValueException( + "Could not unzip uploaded file. Please check your file, then try to upload again.", + Type="User", + ) + # check unzipped size + unzipped_size = get_unzipped_size(zip_file=io.BytesIO(archive_file)) + if unzipped_size >= config.LAMBDA_LIMITS_CODE_SIZE_UNZIPPED: + raise InvalidParameterValueException( + f"Unzipped size must be smaller than {config.LAMBDA_LIMITS_CODE_SIZE_UNZIPPED} bytes", + Type="User", + ) + # store all buckets in us-east-1 for now + s3_client = connect_to( + region_name=AWS_REGION_US_EAST_1, aws_access_key_id=config.INTERNAL_RESOURCE_ACCOUNT + ).s3 + bucket_name = f"awslambda-{region_name}-tasks" + # s3 create bucket is idempotent in us-east-1 + s3_client.create_bucket(Bucket=bucket_name) + code_id = f"{function_name}-{uuid.uuid4()}" + key = f"snapshots/{account_id}/{code_id}" + s3_client.upload_fileobj(Fileobj=io.BytesIO(archive_file), Bucket=bucket_name, Key=key) + code_sha256 = to_str(base64.b64encode(sha256(archive_file).digest())) + return S3Code( + id=code_id, + account_id=account_id, + s3_bucket=bucket_name, + s3_key=key, + s3_object_version=None, + code_sha256=code_sha256, + code_size=len(archive_file), + ) + + +def assert_hot_reloading_path_absolute(path: str) -> None: + """ + Check whether a given path, after environment variable substitution, is an absolute path. + Accepts either posix or windows paths, with environment placeholders. + Example placeholders: $ENV_VAR, ${ENV_VAR} + + :param path: Posix or windows path, potentially containing environment variable placeholders. + Example: `$ENV_VAR/lambda/src` with `ENV_VAR=/home/user/test-repo` set. + """ + # expand variables in path before checking for an absolute path + expanded_path = os.path.expandvars(path) + if ( + not PurePosixPath(expanded_path).is_absolute() + and not PureWindowsPath(expanded_path).is_absolute() + ): + raise InvalidParameterValueException( + f"When using hot reloading, the archive key has to be an absolute path! Your archive key: {path}", + ) + + +def create_hot_reloading_code(path: str) -> HotReloadingCode: + assert_hot_reloading_path_absolute(path) + return HotReloadingCode(host_path=path) + + +def store_s3_bucket_archive( + archive_bucket: str, + archive_key: str, + archive_version: Optional[str], + function_name: str, + region_name: str, + account_id: str, +) -> ArchiveCode: + """ + Takes the lambda archive stored in the given bucket and stores it in an internal s3 bucket + + :param archive_bucket: Bucket the archive is stored in + :param archive_key: Key the archive is stored under + :param archive_version: Version of the archive object in the bucket + :param function_name: function name the archive should be stored for + :param region_name: region name the archive should be stored for + :param account_id: account id the archive should be stored for + :return: S3 Code object representing the archive stored in S3 + """ + if archive_bucket == config.BUCKET_MARKER_LOCAL: + hotreload_counter.labels(operation="create").increment() + return create_hot_reloading_code(path=archive_key) + s3_client: "S3Client" = connect_to().s3 + kwargs = {"VersionId": archive_version} if archive_version else {} + archive_file = s3_client.get_object(Bucket=archive_bucket, Key=archive_key, **kwargs)[ + "Body" + ].read() + return store_lambda_archive( + archive_file, function_name=function_name, region_name=region_name, account_id=account_id + ) + + +def create_image_code(image_uri: str) -> ImageCode: + """ + Creates an image code by inspecting the provided image + + :param image_uri: Image URI of the image to inspect + :return: Image code object + """ + code_sha256 = "" + if CONTAINER_CLIENT.has_docker(): + try: + CONTAINER_CLIENT.pull_image(docker_image=image_uri) + except ContainerException: + LOG.debug("Cannot pull image %s. Maybe only available locally?", image_uri) + try: + code_sha256 = CONTAINER_CLIENT.inspect_image(image_name=image_uri)["RepoDigests"][ + 0 + ].rpartition(":")[2] + except Exception as e: + LOG.debug( + "Cannot inspect image %s. Is this image and/or docker available: %s", image_uri, e + ) + else: + LOG.warning( + "Unable to get image hash for image %s - no docker socket available." + "Image hash returned by Lambda will not be correct.", + image_uri, + ) + return ImageCode(image_uri=image_uri, code_sha256=code_sha256, repository_type="ECR") diff --git a/localstack-core/localstack/services/lambda_/invocation/logs.py b/localstack-core/localstack/services/lambda_/invocation/logs.py new file mode 100644 index 0000000000000..2ff2ab35d951b --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/logs.py @@ -0,0 +1,108 @@ +import dataclasses +import logging +import threading +import time +from queue import Queue +from typing import Optional, Union + +from localstack.aws.connect import connect_to +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.threads import FuncThread + +LOG = logging.getLogger(__name__) + + +class ShutdownPill: + pass + + +QUEUE_SHUTDOWN = ShutdownPill() + + +@dataclasses.dataclass(frozen=True) +class LogItem: + log_group: str + log_stream: str + logs: str + + +class LogHandler: + log_queue: "Queue[Union[LogItem, ShutdownPill]]" + role_arn: str + _thread: Optional[FuncThread] + _shutdown_event: threading.Event + + def __init__(self, role_arn: str, region: str) -> None: + self.role_arn = role_arn + self.region = region + self.log_queue = Queue() + self._shutdown_event = threading.Event() + self._thread = None + + def run_log_loop(self, *args, **kwargs) -> None: + logs_client = connect_to.with_assumed_role( + region_name=self.region, + role_arn=self.role_arn, + service_principal=ServicePrincipal.lambda_, + ).logs + while not self._shutdown_event.is_set(): + log_item = self.log_queue.get() + if log_item is QUEUE_SHUTDOWN: + return + # we need to split by newline - but keep the newlines in the strings + # strips empty lines, as they are not accepted by cloudwatch + logs = [line + "\n" for line in log_item.logs.split("\n") if line] + # until we have a better way to have timestamps, log events have the same time for a single invocation + log_events = [ + {"timestamp": int(time.time() * 1000), "message": log_line} for log_line in logs + ] + try: + try: + logs_client.put_log_events( + logGroupName=log_item.log_group, + logStreamName=log_item.log_stream, + logEvents=log_events, + ) + except logs_client.exceptions.ResourceNotFoundException: + # create new log group + try: + logs_client.create_log_group(logGroupName=log_item.log_group) + except logs_client.exceptions.ResourceAlreadyExistsException: + pass + logs_client.create_log_stream( + logGroupName=log_item.log_group, logStreamName=log_item.log_stream + ) + logs_client.put_log_events( + logGroupName=log_item.log_group, + logStreamName=log_item.log_stream, + logEvents=log_events, + ) + except Exception as e: + LOG.warning( + "Error saving logs to group %s in region %s: %s", + log_item.log_group, + self.region, + e, + ) + + def start_subscriber(self) -> None: + if not is_api_enabled("logs"): + LOG.debug("Service 'logs' is disabled, not storing any logs for lambda executions") + return + self._thread = FuncThread(self.run_log_loop, name="log_handler") + self._thread.start() + + def add_logs(self, log_item: LogItem) -> None: + if not is_api_enabled("logs"): + return + self.log_queue.put(log_item) + + def stop(self) -> None: + self._shutdown_event.set() + if self._thread: + self.log_queue.put(QUEUE_SHUTDOWN) + self._thread.join(timeout=2) + if self._thread.is_alive(): + LOG.error("Could not stop log subscriber in time") + self._thread = None diff --git a/localstack-core/localstack/services/lambda_/invocation/metrics.py b/localstack-core/localstack/services/lambda_/invocation/metrics.py new file mode 100644 index 0000000000000..b9fcefa89f44b --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/metrics.py @@ -0,0 +1,38 @@ +import logging + +from localstack.utils.cloudwatch.cloudwatch_util import publish_lambda_metric + +LOG = logging.getLogger(__name__) + + +def record_cw_metric_invocation(function_name: str, account_id: str, region_name: str): + try: + publish_lambda_metric( + "Invocations", + 1, + {"func_name": function_name}, + region_name=region_name, + account_id=account_id, + ) + except Exception as e: + LOG.debug("Failed to send CloudWatch metric for Lambda invocation: %s", e) + + +def record_cw_metric_error(function_name: str, account_id: str, region_name: str): + try: + publish_lambda_metric( + "Invocations", + 1, + {"func_name": function_name}, + region_name=region_name, + account_id=account_id, + ) + publish_lambda_metric( + "Errors", + 1, + {"func_name": function_name}, + account_id=account_id, + region_name=region_name, + ) + except Exception as e: + LOG.debug("Failed to send CloudWatch metric for Lambda invocation error: %s", e) diff --git a/localstack-core/localstack/services/lambda_/invocation/models.py b/localstack-core/localstack/services/lambda_/invocation/models.py new file mode 100644 index 0000000000000..bc0eef5e7ebf0 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/models.py @@ -0,0 +1,24 @@ +from localstack.aws.api.lambda_ import EventSourceMappingConfiguration +from localstack.services.lambda_.invocation.lambda_models import CodeSigningConfig, Function, Layer +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute +from localstack.utils.tagging import TaggingService + + +class LambdaStore(BaseStore): + # maps function names to the respective Function + functions: dict[str, Function] = LocalAttribute(default=dict) + + # maps EventSourceMapping UUIDs to the respective EventSourceMapping + event_source_mappings: dict[str, EventSourceMappingConfiguration] = LocalAttribute(default=dict) + + # maps CodeSigningConfig ARNs to the respective CodeSigningConfig + code_signing_configs: dict[str, CodeSigningConfig] = LocalAttribute(default=dict) + + # maps layer names to Layers + layers: dict[str, Layer] = LocalAttribute(default=dict) + + # maps resource ARNs for EventSourceMappings and CodeSigningConfiguration to tags + TAGS = LocalAttribute(default=TaggingService) + + +lambda_stores = AccountRegionBundle("lambda", LambdaStore) diff --git a/localstack-core/localstack/services/lambda_/invocation/plugins.py b/localstack-core/localstack/services/lambda_/invocation/plugins.py new file mode 100644 index 0000000000000..0941b4118a957 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/plugins.py @@ -0,0 +1,16 @@ +from plux import Plugin + + +class RuntimeExecutorPlugin(Plugin): + namespace = "localstack.lambda.runtime_executor" + + +class DockerRuntimeExecutorPlugin(RuntimeExecutorPlugin): + name = "docker" + + def load(self, *args, **kwargs): + from localstack.services.lambda_.invocation.docker_runtime_executor import ( + DockerRuntimeExecutor, + ) + + return DockerRuntimeExecutor diff --git a/localstack-core/localstack/services/lambda_/invocation/runtime_executor.py b/localstack-core/localstack/services/lambda_/invocation/runtime_executor.py new file mode 100644 index 0000000000000..93ed5cc600532 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/runtime_executor.py @@ -0,0 +1,149 @@ +import dataclasses +import logging +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Type, TypedDict + +from plux import PluginManager + +from localstack import config +from localstack.services.lambda_.invocation.lambda_models import FunctionVersion, InvocationResult +from localstack.services.lambda_.invocation.plugins import RuntimeExecutorPlugin + +LOG = logging.getLogger(__name__) + + +class RuntimeExecutor(ABC): + id: str + function_version: FunctionVersion + + def __init__( + self, + id: str, + function_version: FunctionVersion, + ) -> None: + """ + Runtime executor class responsible for executing a runtime in specific environment + + :param id: ID string of the runtime executor + :param function_version: Function version to be executed + """ + self.id = id + self.function_version = function_version + + @abstractmethod + def start(self, env_vars: dict[str, str]) -> None: + """ + Start the runtime executor with the given environment variables + + :param env_vars: + """ + pass + + @abstractmethod + def stop(self) -> None: + """ + Stop the runtime executor + """ + pass + + @abstractmethod + def get_address(self) -> str: + """ + Get the address the runtime executor is available at for the LocalStack container. + + :return: IP address or hostname of the execution environment + """ + pass + + @abstractmethod + def get_endpoint_from_executor(self) -> str: + """ + Get the address of LocalStack the runtime execution environment can communicate with LocalStack + + :return: IP address or hostname of LocalStack (from the view of the execution environment) + """ + pass + + @abstractmethod + def get_runtime_endpoint(self) -> str: + """ + Gets the callback url of our executor endpoint + + :return: Base url of the callback, e.g. "http://123.123.123.123:4566/_localstack_lambda/ID1234" without trailing slash + """ + pass + + @abstractmethod + def invoke(self, payload: dict[str, str]) -> InvocationResult: + """ + Send an invocation to the execution environment + + :param payload: Invocation payload + """ + pass + + @abstractmethod + def get_logs(self) -> str: + """Get all logs of a given execution environment""" + pass + + @classmethod + @abstractmethod + def prepare_version(cls, function_version: FunctionVersion) -> None: + """ + Prepare a given function version to be executed. + Includes all the preparation work necessary for execution, short of starting anything + + :param function_version: Function version to prepare + """ + pass + + @classmethod + @abstractmethod + def cleanup_version(cls, function_version: FunctionVersion): + """ + Cleanup the version preparation for the given version. + Should cleanup preparation steps taken by prepare_version + :param function_version: + """ + pass + + @classmethod + def validate_environment(cls) -> bool: + """Validates the setup of the environment and provides an opportunity to log warnings. + Returns False if an invalid environment is detected and True otherwise.""" + return True + + +class LambdaRuntimeException(Exception): + def __init__(self, message: str): + super().__init__(message) + + +@dataclasses.dataclass +class LambdaPrebuildContext: + docker_file_content: str + context_path: Path + function_version: FunctionVersion + + +class ChmodPath(TypedDict): + path: str + mode: str + + +EXECUTOR_PLUGIN_MANAGER: PluginManager[Type[RuntimeExecutor]] = PluginManager( + RuntimeExecutorPlugin.namespace +) + + +def get_runtime_executor() -> Type[RuntimeExecutor]: + plugin_name = config.LAMBDA_RUNTIME_EXECUTOR or "docker" + if not EXECUTOR_PLUGIN_MANAGER.exists(plugin_name): + LOG.warning( + 'Invalid specified plugin name %s. Falling back to "docker" runtime executor', + plugin_name, + ) + plugin_name = "docker" + return EXECUTOR_PLUGIN_MANAGER.load(plugin_name).load() diff --git a/localstack-core/localstack/services/lambda_/invocation/version_manager.py b/localstack-core/localstack/services/lambda_/invocation/version_manager.py new file mode 100644 index 0000000000000..e53049dc82754 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/invocation/version_manager.py @@ -0,0 +1,274 @@ +import concurrent.futures +import logging +import threading +import time +from concurrent.futures import Future + +from localstack import config +from localstack.aws.api.lambda_ import ( + ProvisionedConcurrencyStatusEnum, + ServiceException, + State, + StateReasonCode, +) +from localstack.services.lambda_.invocation.assignment import AssignmentService +from localstack.services.lambda_.invocation.counting_service import CountingService +from localstack.services.lambda_.invocation.execution_environment import ExecutionEnvironment +from localstack.services.lambda_.invocation.executor_endpoint import StatusErrorException +from localstack.services.lambda_.invocation.lambda_models import ( + Function, + FunctionVersion, + Invocation, + InvocationResult, + ProvisionedConcurrencyState, + VersionState, +) +from localstack.services.lambda_.invocation.logs import LogHandler, LogItem +from localstack.services.lambda_.invocation.metrics import ( + record_cw_metric_error, + record_cw_metric_invocation, +) +from localstack.services.lambda_.invocation.runtime_executor import get_runtime_executor +from localstack.utils.strings import long_uid, truncate +from localstack.utils.threads import FuncThread, start_thread + +LOG = logging.getLogger(__name__) + + +class LambdaVersionManager: + # arn this Lambda Version manager manages + function_arn: str + function_version: FunctionVersion + function: Function + + # Scale provisioned concurrency up and down + provisioning_thread: FuncThread | None + # Additional guard to prevent scheduling invocation on version during shutdown + shutdown_event: threading.Event + + state: VersionState | None + provisioned_state: ProvisionedConcurrencyState | None # TODO: remove? + log_handler: LogHandler + counting_service: CountingService + assignment_service: AssignmentService + + def __init__( + self, + function_arn: str, + function_version: FunctionVersion, + # HACK allowing None for Lambda@Edge; only used in invoke for get_invocation_lease + function: Function | None, + counting_service: CountingService, + assignment_service: AssignmentService, + ): + self.id = long_uid() + self.function_arn = function_arn + self.function_version = function_version + self.function = function + self.counting_service = counting_service + self.assignment_service = assignment_service + self.log_handler = LogHandler(function_version.config.role, function_version.id.region) + + # async + self.provisioning_thread = None + self.shutdown_event = threading.Event() + + # async state + self.provisioned_state = None + self.provisioned_state_lock = threading.RLock() + # https://aws.amazon.com/blogs/compute/coming-soon-expansion-of-aws-lambda-states-to-all-functions/ + self.state = VersionState(state=State.Pending) + + def start(self) -> VersionState: + try: + self.log_handler.start_subscriber() + time_before = time.perf_counter() + get_runtime_executor().prepare_version(self.function_version) # TODO: make pluggable? + LOG.debug( + "Version preparation of function %s took %0.2fms", + self.function_version.qualified_arn, + (time.perf_counter() - time_before) * 1000, + ) + + # code and reason not set for success scenario because only failed states provide this field: + # https://docs.aws.amazon.com/lambda/latest/dg/API_GetFunctionConfiguration.html#SSS-GetFunctionConfiguration-response-LastUpdateStatusReasonCode + self.state = VersionState(state=State.Active) + LOG.debug( + "Changing Lambda %s (id %s) to active", + self.function_arn, + self.function_version.config.internal_revision, + ) + except Exception as e: + self.state = VersionState( + state=State.Failed, + code=StateReasonCode.InternalError, + reason=f"Error while creating lambda: {e}", + ) + LOG.debug( + "Changing Lambda %s (id %s) to failed. Reason: %s", + self.function_arn, + self.function_version.config.internal_revision, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + return self.state + + def stop(self) -> None: + LOG.debug("Stopping lambda version '%s'", self.function_arn) + self.state = VersionState( + state=State.Inactive, code=StateReasonCode.Idle, reason="Shutting down" + ) + self.shutdown_event.set() + self.log_handler.stop() + self.assignment_service.stop_environments_for_version(self.id) + get_runtime_executor().cleanup_version(self.function_version) # TODO: make pluggable? + + def update_provisioned_concurrency_config( + self, provisioned_concurrent_executions: int + ) -> Future[None]: + """ + TODO: implement update while in progress (see test_provisioned_concurrency test) + TODO: loop until diff == 0 and retry to remove/add diff environments + TODO: alias routing & allocated (i.e., the status while updating provisioned concurrency) + TODO: ProvisionedConcurrencyStatusEnum.FAILED + TODO: status reason + + :param provisioned_concurrent_executions: set to 0 to stop all provisioned environments + """ + with self.provisioned_state_lock: + # LocalStack limitation: cannot update provisioned concurrency while another update is in progress + if ( + self.provisioned_state + and self.provisioned_state.status == ProvisionedConcurrencyStatusEnum.IN_PROGRESS + ): + raise ServiceException( + "Updating provisioned concurrency configuration while IN_PROGRESS is not supported yet." + ) + + if not self.provisioned_state: + self.provisioned_state = ProvisionedConcurrencyState() + + def scale_environments(*args, **kwargs) -> None: + futures = self.assignment_service.scale_provisioned_concurrency( + self.id, self.function_version, provisioned_concurrent_executions + ) + + concurrent.futures.wait(futures) + + with self.provisioned_state_lock: + if provisioned_concurrent_executions == 0: + self.provisioned_state = None + else: + self.provisioned_state.available = provisioned_concurrent_executions + self.provisioned_state.allocated = provisioned_concurrent_executions + self.provisioned_state.status = ProvisionedConcurrencyStatusEnum.READY + + self.provisioning_thread = start_thread(scale_environments) + return self.provisioning_thread.result_future + + # Extract environment handling + + def invoke(self, *, invocation: Invocation) -> InvocationResult: + """ + synchronous invoke entrypoint + + 0. check counter, get lease + 1. try to get an inactive (no active invoke) environment + 2.(allgood) send invoke to environment + 3. wait for invocation result + 4. return invocation result & release lease + + 2.(nogood) fail fast fail hard + + """ + LOG.debug( + "Got an invocation for function %s with request_id %s", + self.function_arn, + invocation.request_id, + ) + if self.shutdown_event.is_set(): + message = f"Got an invocation with request_id {invocation.request_id} for a version shutting down" + LOG.warning(message) + raise ServiceException(message) + + with self.counting_service.get_invocation_lease( + self.function, self.function_version + ) as provisioning_type: + # TODO: potential race condition when changing provisioned concurrency after getting the lease but before + # getting an environment + try: + # Blocks and potentially creates a new execution environment for this invocation + with self.assignment_service.get_environment( + self.id, self.function_version, provisioning_type + ) as execution_env: + invocation_result = execution_env.invoke(invocation) + invocation_result.executed_version = self.function_version.id.qualifier + self.store_logs( + invocation_result=invocation_result, execution_env=execution_env + ) + except StatusErrorException as e: + invocation_result = InvocationResult( + request_id="", + payload=e.payload, + is_error=True, + logs="", + executed_version=self.function_version.id.qualifier, + ) + + function_id = self.function_version.id + # Record CloudWatch metrics in separate threads + # MAYBE reuse threads rather than starting new threads upon every invocation + if invocation_result.is_error: + start_thread( + lambda *args, **kwargs: record_cw_metric_error( + function_name=function_id.function_name, + account_id=function_id.account, + region_name=function_id.region, + ), + name=f"record-cloudwatch-metric-error-{function_id.function_name}:{function_id.qualifier}", + ) + else: + start_thread( + lambda *args, **kwargs: record_cw_metric_invocation( + function_name=function_id.function_name, + account_id=function_id.account, + region_name=function_id.region, + ), + name=f"record-cloudwatch-metric-{function_id.function_name}:{function_id.qualifier}", + ) + # TODO: consider using the same prefix logging as in error case for execution environment. + # possibly as separate named logger. + if invocation_result.logs is not None: + LOG.debug("Got logs for invocation '%s'", invocation.request_id) + for log_line in invocation_result.logs.splitlines(): + LOG.debug( + "[%s-%s] %s", + function_id.function_name, + invocation.request_id, + truncate(log_line, config.LAMBDA_TRUNCATE_STDOUT), + ) + else: + LOG.warning( + "[%s] Error while printing logs for function '%s': Received no logs from environment.", + invocation.request_id, + function_id.function_name, + ) + return invocation_result + + def store_logs( + self, invocation_result: InvocationResult, execution_env: ExecutionEnvironment + ) -> None: + if invocation_result.logs: + log_item = LogItem( + execution_env.get_log_group_name(), + execution_env.get_log_stream_name(), + invocation_result.logs, + ) + self.log_handler.add_logs(log_item) + else: + LOG.warning( + "Received no logs from invocation with id %s for lambda %s. Execution environment logs: \n%s", + invocation_result.request_id, + self.function_arn, + execution_env.get_prefixed_logs(), + ) diff --git a/localstack-core/localstack/services/lambda_/lambda_utils.py b/localstack-core/localstack/services/lambda_/lambda_utils.py new file mode 100644 index 0000000000000..e66eab9812e58 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/lambda_utils.py @@ -0,0 +1,46 @@ +"""Lambda utilities for behavior and implicit functionality. +Everything related to API operations goes into `api_utils.py`. +""" + +import logging +import os + +from localstack.aws.api.lambda_ import Runtime + +# Custom logger for proactive advice +HINT_LOG = logging.getLogger("localstack.services.lambda_.hints") + + +def get_handler_file_from_name(handler_name: str, runtime: str = None): + # Previously used DEFAULT_LAMBDA_RUNTIME here but that is only relevant for testing and this helper is still used in + # a CloudFormation model in localstack.services.cloudformation.models.lambda_.LambdaFunction.get_lambda_code_param + runtime = runtime or Runtime.python3_12 + + # TODO: consider using localstack/testing/aws/lambda_utils.py:RUNTIMES_AGGREGATED for testing or moving the constant + # RUNTIMES_AGGREGATED to LocalStack core if this helper remains relevant within CloudFormation. + if runtime.startswith(Runtime.provided): + return "bootstrap" + if runtime.startswith("nodejs"): + return format_name_to_path(handler_name, ".", ".js") + if runtime.startswith(Runtime.go1_x): + return handler_name + if runtime.startswith("dotnet"): + return format_name_to_path(handler_name, ":", ".dll") + if runtime.startswith("ruby"): + return format_name_to_path(handler_name, ".", ".rb") + + return format_name_to_path(handler_name, ".", ".py") + + +def format_name_to_path(handler_name: str, delimiter: str, extension: str): + file_path = handler_name.rpartition(delimiter)[0] + if delimiter == ":": + file_path = file_path.split(delimiter)[0] + + if os.path.sep not in file_path: + file_path = file_path.replace(".", os.path.sep) + + if file_path.startswith(f".{os.path.sep}"): + file_path = file_path[2:] + + return f"{file_path}{extension}" diff --git a/localstack-core/localstack/services/lambda_/layerfetcher/__init__.py b/localstack-core/localstack/services/lambda_/layerfetcher/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/lambda_/layerfetcher/layer_fetcher.py b/localstack-core/localstack/services/lambda_/layerfetcher/layer_fetcher.py new file mode 100644 index 0000000000000..4b4c67da860e7 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/layerfetcher/layer_fetcher.py @@ -0,0 +1,15 @@ +from abc import abstractmethod + +from localstack.services.lambda_.invocation.lambda_models import Layer + + +class LayerFetcher: + @abstractmethod + def fetch_layer(self, layer_version_arn: str) -> Layer | None: + """Fetches a shared Lambda layer for a given layer_version_arn + + :param layer_version_arn: The layer arn including its version to be fetched. Example: + "arn:aws:lambda:us-east-1:770693421928:layer:Klayers-p39-PyYAML:1" + :return: A Lambda layer model if layer could be fetched, None otherwise (e.g., not available or accessible) + """ + pass diff --git a/localstack-core/localstack/services/lambda_/networking.py b/localstack-core/localstack/services/lambda_/networking.py new file mode 100644 index 0000000000000..0f47926d79475 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/networking.py @@ -0,0 +1,29 @@ +from localstack import config +from localstack.utils.container_networking import ( + get_endpoint_for_network, + get_main_container_network, +) + +# IP address of main Docker container (lazily initialized) +DOCKER_MAIN_CONTAINER_IP = None +LAMBDA_CONTAINER_NETWORK = None + + +def get_main_endpoint_from_container() -> str: + if config.HOSTNAME_FROM_LAMBDA: + return config.HOSTNAME_FROM_LAMBDA + return get_endpoint_for_network(network=get_main_container_network_for_lambda()) + + +def get_main_container_network_for_lambda() -> str: + global LAMBDA_CONTAINER_NETWORK + if config.LAMBDA_DOCKER_NETWORK: + return config.LAMBDA_DOCKER_NETWORK.split(",")[0] + return get_main_container_network() + + +def get_all_container_networks_for_lambda() -> list[str]: + global LAMBDA_CONTAINER_NETWORK + if config.LAMBDA_DOCKER_NETWORK: + return config.LAMBDA_DOCKER_NETWORK.split(",") + return [get_main_container_network()] diff --git a/localstack-core/localstack/services/lambda_/packages.py b/localstack-core/localstack/services/lambda_/packages.py new file mode 100644 index 0000000000000..0600b4310ae30 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/packages.py @@ -0,0 +1,100 @@ +"""Package installers for external Lambda dependencies.""" + +import os +import stat +from functools import cache +from pathlib import Path +from typing import List + +from localstack import config +from localstack.packages import DownloadInstaller, InstallTarget, Package, PackageInstaller +from localstack.utils.platform import get_arch + +"""Customized LocalStack version of the AWS Lambda Runtime Interface Emulator (RIE). +https://github.com/localstack/lambda-runtime-init/blob/localstack/README-LOCALSTACK.md +""" +LAMBDA_RUNTIME_DEFAULT_VERSION = "v0.1.34-pre" +LAMBDA_RUNTIME_VERSION = config.LAMBDA_INIT_RELEASE_VERSION or LAMBDA_RUNTIME_DEFAULT_VERSION +LAMBDA_RUNTIME_INIT_URL = "https://github.com/localstack/lambda-runtime-init/releases/download/{version}/aws-lambda-rie-{arch}" + +"""Unmaintained Java utilities and JUnit integration for LocalStack released to Maven Central. +https://github.com/localstack/localstack-java-utils +We recommend the Testcontainers LocalStack Java module as an alternative: +https://java.testcontainers.org/modules/localstack/ +""" +LOCALSTACK_MAVEN_VERSION = "0.2.21" +MAVEN_REPO_URL = "https://repo1.maven.org/maven2" +URL_LOCALSTACK_FAT_JAR = ( + "{mvn_repo}/cloud/localstack/localstack-utils/{ver}/localstack-utils-{ver}-fat.jar" +) + + +class LambdaRuntimePackage(Package): + """Golang binary containing the lambda-runtime-init.""" + + def __init__(self, default_version: str = LAMBDA_RUNTIME_VERSION): + super().__init__(name="Lambda", default_version=default_version) + + def get_versions(self) -> List[str]: + return [LAMBDA_RUNTIME_VERSION] + + def _get_installer(self, version: str) -> PackageInstaller: + return LambdaRuntimePackageInstaller(name="lambda-runtime", version=version) + + +class LambdaRuntimePackageInstaller(DownloadInstaller): + """Installer for the lambda-runtime-init Golang binary.""" + + # TODO: Architecture should ideally be configurable in the installer for proper cross-architecture support. + # We currently hope the native binary works within emulated containers. + def _get_arch(self): + arch = get_arch() + return "x86_64" if arch == "amd64" else arch + + def _get_download_url(self) -> str: + arch = self._get_arch() + return LAMBDA_RUNTIME_INIT_URL.format(version=self.version, arch=arch) + + def _get_install_dir(self, target: InstallTarget) -> str: + install_dir = super()._get_install_dir(target) + arch = self._get_arch() + return os.path.join(install_dir, arch) + + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, "var", "rapid", "init") + + def _install(self, target: InstallTarget) -> None: + super()._install(target) + install_location = self.get_executable_path() + st = os.stat(install_location) + os.chmod(install_location, mode=st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +# TODO: replace usage in LocalStack tests with locally built Java jar and remove this unmaintained dependency. +class LambdaJavaPackage(Package): + def __init__(self): + super().__init__("LambdaJavaLibs", "0.2.22") + + def get_versions(self) -> List[str]: + return ["0.2.22", "0.2.21"] + + def _get_installer(self, version: str) -> PackageInstaller: + return LambdaJavaPackageInstaller("lambda-java-libs", version) + + +class LambdaJavaPackageInstaller(DownloadInstaller): + def _get_download_url(self) -> str: + return URL_LOCALSTACK_FAT_JAR.format(ver=self.version, mvn_repo=MAVEN_REPO_URL) + + +lambda_runtime_package = LambdaRuntimePackage() +lambda_java_libs_package = LambdaJavaPackage() + + +# TODO: handle architecture-specific installer and caching because we currently assume that the lambda-runtime-init +# Golang binary is cross-architecture compatible. +@cache +def get_runtime_client_path() -> Path: + installer = lambda_runtime_package.get_installer() + installer.install() + return Path(installer.get_installed_dir()) diff --git a/localstack-core/localstack/services/lambda_/plugins.py b/localstack-core/localstack/services/lambda_/plugins.py new file mode 100644 index 0000000000000..646dc170fb9b8 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/plugins.py @@ -0,0 +1,46 @@ +import logging + +from werkzeug.routing import Rule + +from localstack.config import LAMBDA_DOCKER_NETWORK +from localstack.packages import Package, package +from localstack.runtime import hooks +from localstack.services.edge import ROUTER +from localstack.services.lambda_.custom_endpoints import LambdaCustomEndpoints + +LOG = logging.getLogger(__name__) + +CUSTOM_ROUTER_RULES: list[Rule] = [] + + +@package(name="lambda-runtime") +def lambda_runtime_package() -> Package: + from localstack.services.lambda_.packages import lambda_runtime_package + + return lambda_runtime_package + + +@package(name="lambda-java-libs") +def lambda_java_libs() -> Package: + from localstack.services.lambda_.packages import lambda_java_libs_package + + return lambda_java_libs_package + + +@hooks.on_infra_start() +def validate_configuration() -> None: + if LAMBDA_DOCKER_NETWORK == "host": + LOG.warning( + "The configuration LAMBDA_DOCKER_NETWORK=host is currently not supported with the new lambda provider." + ) + + +@hooks.on_infra_start() +def register_custom_endpoints() -> None: + global CUSTOM_ROUTER_RULES + CUSTOM_ROUTER_RULES = ROUTER.add(LambdaCustomEndpoints()) + + +@hooks.on_infra_shutdown() +def remove_custom_endpoints() -> None: + ROUTER.remove(CUSTOM_ROUTER_RULES) diff --git a/localstack-core/localstack/services/lambda_/provider.py b/localstack-core/localstack/services/lambda_/provider.py new file mode 100644 index 0000000000000..516b931723293 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/provider.py @@ -0,0 +1,4297 @@ +import base64 +import dataclasses +import datetime +import itertools +import json +import logging +import re +import threading +import time +from typing import IO, Any, Optional, Tuple + +from botocore.exceptions import ClientError + +from localstack import config +from localstack.aws.api import RequestContext, ServiceException, handler +from localstack.aws.api.lambda_ import ( + AccountLimit, + AccountUsage, + AddLayerVersionPermissionResponse, + AddPermissionRequest, + AddPermissionResponse, + Alias, + AliasConfiguration, + AliasRoutingConfiguration, + AllowedPublishers, + Architecture, + Arn, + Blob, + BlobStream, + CodeSigningConfigArn, + CodeSigningConfigNotFoundException, + CodeSigningPolicies, + CompatibleArchitectures, + CompatibleRuntimes, + Concurrency, + Cors, + CreateCodeSigningConfigResponse, + CreateEventSourceMappingRequest, + CreateFunctionRequest, + CreateFunctionUrlConfigResponse, + DeleteCodeSigningConfigResponse, + Description, + DestinationConfig, + EventSourceMappingConfiguration, + FunctionCodeLocation, + FunctionConfiguration, + FunctionEventInvokeConfig, + FunctionName, + FunctionUrlAuthType, + FunctionUrlQualifier, + GetAccountSettingsResponse, + GetCodeSigningConfigResponse, + GetFunctionCodeSigningConfigResponse, + GetFunctionConcurrencyResponse, + GetFunctionRecursionConfigResponse, + GetFunctionResponse, + GetFunctionUrlConfigResponse, + GetLayerVersionPolicyResponse, + GetLayerVersionResponse, + GetPolicyResponse, + GetProvisionedConcurrencyConfigResponse, + InvalidParameterValueException, + InvocationResponse, + InvocationType, + InvokeAsyncResponse, + InvokeMode, + LambdaApi, + LastUpdateStatus, + LayerName, + LayerPermissionAllowedAction, + LayerPermissionAllowedPrincipal, + LayersListItem, + LayerVersionArn, + LayerVersionContentInput, + LayerVersionNumber, + LicenseInfo, + ListAliasesResponse, + ListCodeSigningConfigsResponse, + ListEventSourceMappingsResponse, + ListFunctionEventInvokeConfigsResponse, + ListFunctionsByCodeSigningConfigResponse, + ListFunctionsResponse, + ListFunctionUrlConfigsResponse, + ListLayersResponse, + ListLayerVersionsResponse, + ListProvisionedConcurrencyConfigsResponse, + ListTagsResponse, + ListVersionsByFunctionResponse, + LogFormat, + LoggingConfig, + LogType, + MasterRegion, + MaxFunctionEventInvokeConfigListItems, + MaximumEventAgeInSeconds, + MaximumRetryAttempts, + MaxItems, + MaxLayerListItems, + MaxListItems, + MaxProvisionedConcurrencyConfigListItems, + NamespacedFunctionName, + NamespacedStatementId, + OnFailure, + OnSuccess, + OrganizationId, + PackageType, + PositiveInteger, + PreconditionFailedException, + ProvisionedConcurrencyConfigListItem, + ProvisionedConcurrencyConfigNotFoundException, + ProvisionedConcurrencyStatusEnum, + PublishLayerVersionResponse, + PutFunctionCodeSigningConfigResponse, + PutFunctionRecursionConfigResponse, + PutProvisionedConcurrencyConfigResponse, + Qualifier, + RecursiveLoop, + ReservedConcurrentExecutions, + ResourceConflictException, + ResourceNotFoundException, + Runtime, + RuntimeVersionConfig, + SnapStart, + SnapStartApplyOn, + SnapStartOptimizationStatus, + SnapStartResponse, + State, + StatementId, + StateReasonCode, + String, + TaggableResource, + TagKeyList, + Tags, + TracingMode, + UnqualifiedFunctionName, + UpdateCodeSigningConfigResponse, + UpdateEventSourceMappingRequest, + UpdateFunctionCodeRequest, + UpdateFunctionConfigurationRequest, + UpdateFunctionUrlConfigResponse, + Version, +) +from localstack.aws.api.lambda_ import FunctionVersion as FunctionVersionApi +from localstack.aws.api.lambda_ import ServiceException as LambdaServiceException +from localstack.aws.api.pipes import ( + DynamoDBStreamStartPosition, + KinesisStreamStartPosition, +) +from localstack.aws.connect import connect_to +from localstack.aws.spec import load_service +from localstack.services.edge import ROUTER +from localstack.services.lambda_ import api_utils +from localstack.services.lambda_ import hooks as lambda_hooks +from localstack.services.lambda_.analytics import ( + FunctionOperation, + FunctionStatus, + function_counter, +) +from localstack.services.lambda_.api_utils import ( + ARCHITECTURES, + STATEMENT_ID_REGEX, + SUBNET_ID_REGEX, + function_locators_from_arn, +) +from localstack.services.lambda_.event_source_mapping.esm_config_factory import ( + EsmConfigFactory, +) +from localstack.services.lambda_.event_source_mapping.esm_worker import ( + EsmState, + EsmWorker, +) +from localstack.services.lambda_.event_source_mapping.esm_worker_factory import ( + EsmWorkerFactory, +) +from localstack.services.lambda_.event_source_mapping.pipe_utils import get_internal_client +from localstack.services.lambda_.invocation import AccessDeniedException +from localstack.services.lambda_.invocation.execution_environment import ( + EnvironmentStartupTimeoutException, +) +from localstack.services.lambda_.invocation.lambda_models import ( + AliasRoutingConfig, + CodeSigningConfig, + EventInvokeConfig, + Function, + FunctionResourcePolicy, + FunctionUrlConfig, + FunctionVersion, + ImageConfig, + LambdaEphemeralStorage, + Layer, + LayerPolicy, + LayerPolicyStatement, + LayerVersion, + ProvisionedConcurrencyConfiguration, + RequestEntityTooLargeException, + ResourcePolicy, + UpdateStatus, + ValidationException, + VersionAlias, + VersionFunctionConfiguration, + VersionIdentifier, + VersionState, + VpcConfig, +) +from localstack.services.lambda_.invocation.lambda_service import ( + LambdaService, + create_image_code, + destroy_code_if_not_used, + lambda_stores, + store_lambda_archive, + store_s3_bucket_archive, +) +from localstack.services.lambda_.invocation.models import LambdaStore +from localstack.services.lambda_.invocation.runtime_executor import get_runtime_executor +from localstack.services.lambda_.lambda_utils import HINT_LOG +from localstack.services.lambda_.layerfetcher.layer_fetcher import LayerFetcher +from localstack.services.lambda_.provider_utils import ( + LambdaLayerVersionIdentifier, + get_function_version, + get_function_version_from_arn, +) +from localstack.services.lambda_.runtimes import ( + ALL_RUNTIMES, + DEPRECATED_RUNTIMES, + DEPRECATED_RUNTIMES_UPGRADES, + RUNTIMES_AGGREGATED, + SNAP_START_SUPPORTED_RUNTIMES, + VALID_RUNTIMES, +) +from localstack.services.lambda_.urlrouter import FunctionUrlRouter +from localstack.services.plugins import ServiceLifecycleHook +from localstack.state import StateVisitor +from localstack.utils.aws.arns import ( + ArnData, + extract_resource_from_arn, + extract_service_from_arn, + get_partition, + lambda_event_source_mapping_arn, + parse_arn, +) +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.collections import PaginatedList +from localstack.utils.event_matcher import validate_event_pattern +from localstack.utils.lambda_debug_mode.lambda_debug_mode_session import LambdaDebugModeSession +from localstack.utils.strings import get_random_hex, short_uid, to_bytes, to_str +from localstack.utils.sync import poll_condition +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +LAMBDA_DEFAULT_TIMEOUT = 3 +LAMBDA_DEFAULT_MEMORY_SIZE = 128 + +LAMBDA_TAG_LIMIT_PER_RESOURCE = 50 +LAMBDA_LAYERS_LIMIT_PER_FUNCTION = 5 + +TAG_KEY_CUSTOM_URL = "_custom_id_" +# Requirements (from RFC3986 & co): not longer than 63, first char must be +# alpha, then alphanumeric or hyphen, except cannot start or end with hyphen +TAG_KEY_CUSTOM_URL_VALIDATOR = re.compile(r"^[A-Za-z]([A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$") + + +class LambdaProvider(LambdaApi, ServiceLifecycleHook): + lambda_service: LambdaService + create_fn_lock: threading.RLock + create_layer_lock: threading.RLock + router: FunctionUrlRouter + esm_workers: dict[str, EsmWorker] + layer_fetcher: LayerFetcher | None + + def __init__(self) -> None: + self.lambda_service = LambdaService() + self.create_fn_lock = threading.RLock() + self.create_layer_lock = threading.RLock() + self.router = FunctionUrlRouter(ROUTER, self.lambda_service) + self.esm_workers = {} + self.layer_fetcher = None + lambda_hooks.inject_layer_fetcher.run(self) + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(lambda_stores) + + def on_before_start(self): + # Attempt to start the Lambda Debug Mode session object. + try: + lambda_debug_mode_session = LambdaDebugModeSession.get() + lambda_debug_mode_session.ensure_running() + except Exception as ex: + LOG.error( + "Unexpected error encountered when attempting to initialise Lambda Debug Mode '%s'.", + ex, + ) + + def on_before_state_reset(self): + self.lambda_service.stop() + + def on_after_state_reset(self): + self.router.lambda_service = self.lambda_service = LambdaService() + + def on_before_state_load(self): + self.lambda_service.stop() + + def on_after_state_load(self): + self.lambda_service = LambdaService() + self.router.lambda_service = self.lambda_service + + for account_id, account_bundle in lambda_stores.items(): + for region_name, state in account_bundle.items(): + for fn in state.functions.values(): + for fn_version in fn.versions.values(): + # restore the "Pending" state for every function version and start it + try: + new_state = VersionState( + state=State.Pending, + code=StateReasonCode.Creating, + reason="The function is being created.", + ) + new_config = dataclasses.replace(fn_version.config, state=new_state) + new_version = dataclasses.replace(fn_version, config=new_config) + fn.versions[fn_version.id.qualifier] = new_version + self.lambda_service.create_function_version(fn_version).result( + timeout=5 + ) + except Exception: + LOG.warning( + "Failed to restore function version %s", + fn_version.id.qualified_arn(), + exc_info=True, + ) + # restore provisioned concurrency per function considering both versions and aliases + for ( + provisioned_qualifier, + provisioned_config, + ) in fn.provisioned_concurrency_configs.items(): + fn_arn = None + try: + if api_utils.qualifier_is_alias(provisioned_qualifier): + alias = fn.aliases.get(provisioned_qualifier) + resolved_version = fn.versions.get(alias.function_version) + fn_arn = resolved_version.id.qualified_arn() + elif api_utils.qualifier_is_version(provisioned_qualifier): + fn_version = fn.versions.get(provisioned_qualifier) + fn_arn = fn_version.id.qualified_arn() + else: + raise InvalidParameterValueException( + "Invalid qualifier type:" + " Qualifier can only be an alias or a version for provisioned concurrency." + ) + + manager = self.lambda_service.get_lambda_version_manager(fn_arn) + manager.update_provisioned_concurrency_config( + provisioned_config.provisioned_concurrent_executions + ) + except Exception: + LOG.warning( + "Failed to restore provisioned concurrency %s for function %s", + provisioned_config, + fn_arn, + exc_info=True, + ) + + for esm in state.event_source_mappings.values(): + # Restores event source workers + function_arn = esm.get("FunctionArn") + + # TODO: How do we know the event source is up? + # A basic poll to see if the mapped Lambda function is active/failed + if not poll_condition( + lambda: get_function_version_from_arn(function_arn).config.state.state + in [State.Active, State.Failed], + timeout=10, + ): + LOG.warning( + "Creating ESM for Lambda that is not in running state: %s", + function_arn, + ) + + function_version = get_function_version_from_arn(function_arn) + function_role = function_version.config.role + + is_esm_enabled = esm.get("State", EsmState.DISABLED) not in ( + EsmState.DISABLED, + EsmState.DISABLING, + ) + esm_worker = EsmWorkerFactory( + esm, function_role, is_esm_enabled + ).get_esm_worker() + + # Note: a worker is created in the DISABLED state if not enabled + esm_worker.create() + # TODO: assigning the esm_worker to the dict only works after .create(). Could it cause a race + # condition if we get a shutdown here and have a worker thread spawned but not accounted for? + self.esm_workers[esm_worker.uuid] = esm_worker + + def on_after_init(self): + self.router.register_routes() + get_runtime_executor().validate_environment() + + def on_before_stop(self) -> None: + for esm_worker in self.esm_workers.values(): + esm_worker.stop_for_shutdown() + + # TODO: should probably unregister routes? + self.lambda_service.stop() + # Attempt to signal to the Lambda Debug Mode session object to stop. + try: + lambda_debug_mode_session = LambdaDebugModeSession.get() + lambda_debug_mode_session.signal_stop() + except Exception as ex: + LOG.error( + "Unexpected error encountered when attempting to signal Lambda Debug Mode to stop '%s'.", + ex, + ) + + @staticmethod + def _get_function(function_name: str, account_id: str, region: str) -> Function: + state = lambda_stores[account_id][region] + function = state.functions.get(function_name) + if not function: + arn = api_utils.unqualified_lambda_arn( + function_name=function_name, + account=account_id, + region=region, + ) + raise ResourceNotFoundException( + f"Function not found: {arn}", + Type="User", + ) + return function + + @staticmethod + def _get_esm(uuid: str, account_id: str, region: str) -> EventSourceMappingConfiguration: + state = lambda_stores[account_id][region] + esm = state.event_source_mappings.get(uuid) + if not esm: + arn = lambda_event_source_mapping_arn(uuid, account_id, region) + raise ResourceNotFoundException( + f"Event source mapping not found: {arn}", + Type="User", + ) + return esm + + @staticmethod + def _validate_qualifier_expression(qualifier: str) -> None: + if error_messages := api_utils.validate_qualifier(qualifier): + raise ValidationException( + message=api_utils.construct_validation_exception_message(error_messages) + ) + + @staticmethod + def _resolve_fn_qualifier(resolved_fn: Function, qualifier: str | None) -> tuple[str, str]: + """Attempts to resolve a given qualifier and returns a qualifier that exists or + raises an appropriate ResourceNotFoundException. + + :param resolved_fn: The resolved lambda function + :param qualifier: The qualifier to be resolved or None + :return: Tuple of (resolved qualifier, function arn either qualified or unqualified)""" + function_name = resolved_fn.function_name + # assuming function versions need to live in the same account and region + account_id = resolved_fn.latest().id.account + region = resolved_fn.latest().id.region + fn_arn = api_utils.unqualified_lambda_arn(function_name, account_id, region) + if qualifier is not None: + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + if api_utils.qualifier_is_alias(qualifier): + if qualifier not in resolved_fn.aliases: + raise ResourceNotFoundException(f"Cannot find alias arn: {fn_arn}", Type="User") + elif api_utils.qualifier_is_version(qualifier) or qualifier == "$LATEST": + if qualifier not in resolved_fn.versions: + raise ResourceNotFoundException(f"Function not found: {fn_arn}", Type="User") + else: + # matches qualifier pattern but invalid alias or version + raise ResourceNotFoundException(f"Function not found: {fn_arn}", Type="User") + resolved_qualifier = qualifier or "$LATEST" + return resolved_qualifier, fn_arn + + @staticmethod + def _function_revision_id(resolved_fn: Function, resolved_qualifier: str) -> str: + if api_utils.qualifier_is_alias(resolved_qualifier): + return resolved_fn.aliases[resolved_qualifier].revision_id + # Assumes that a non-alias is a version + else: + return resolved_fn.versions[resolved_qualifier].config.revision_id + + def _resolve_vpc_id(self, account_id: str, region_name: str, subnet_id: str) -> str: + ec2_client = connect_to(aws_access_key_id=account_id, region_name=region_name).ec2 + try: + return ec2_client.describe_subnets(SubnetIds=[subnet_id])["Subnets"][0]["VpcId"] + except ec2_client.exceptions.ClientError as e: + code = e.response["Error"]["Code"] + message = e.response["Error"]["Message"] + raise InvalidParameterValueException( + f"Error occurred while DescribeSubnets. EC2 Error Code: {code}. EC2 Error Message: {message}", + Type="User", + ) + + def _build_vpc_config( + self, + account_id: str, + region_name: str, + vpc_config: Optional[dict] = None, + ) -> VpcConfig | None: + if not vpc_config or not is_api_enabled("ec2"): + return None + + subnet_ids = vpc_config.get("SubnetIds", []) + if subnet_ids is not None and len(subnet_ids) == 0: + return VpcConfig(vpc_id="", security_group_ids=[], subnet_ids=[]) + + subnet_id = subnet_ids[0] + if not bool(SUBNET_ID_REGEX.match(subnet_id)): + raise ValidationException( + f"1 validation error detected: Value '[{subnet_id}]' at 'vpcConfig.subnetIds' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 1024, Member must have length greater than or equal to 0, Member must satisfy regular expression pattern: ^subnet-[0-9a-z]*$]" + ) + + return VpcConfig( + vpc_id=self._resolve_vpc_id(account_id, region_name, subnet_id), + security_group_ids=vpc_config.get("SecurityGroupIds", []), + subnet_ids=subnet_ids, + ) + + def _create_version_model( + self, + function_name: str, + region: str, + account_id: str, + description: str | None = None, + revision_id: str | None = None, + code_sha256: str | None = None, + ) -> tuple[FunctionVersion, bool]: + """ + Release a new version to the model if all restrictions are met. + Restrictions: + - CodeSha256, if provided, must equal the current latest version code hash + - RevisionId, if provided, must equal the current latest version revision id + - Some changes have been done to the latest version since last publish + Will return a tuple of the version, and whether the version was published (True) or the latest available version was taken (False). + This can happen if the latest version has not been changed since the last version publish, in this case the last version will be returned. + + :param function_name: Function name to be published + :param region: Region of the function + :param account_id: Account of the function + :param description: new description of the version (will be the description of the function if missing) + :param revision_id: Revision id, function will raise error if it does not match latest revision id + :param code_sha256: Code sha256, function will raise error if it does not match latest code hash + :return: Tuple of (published version, whether version was released or last released version returned, since nothing changed) + """ + current_latest_version = get_function_version( + function_name=function_name, qualifier="$LATEST", account_id=account_id, region=region + ) + if revision_id and current_latest_version.config.revision_id != revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + Type="User", + ) + + # check if code hashes match if they are specified + current_hash = ( + current_latest_version.config.code.code_sha256 + if current_latest_version.config.package_type == PackageType.Zip + else current_latest_version.config.image.code_sha256 + ) + # if the code is a zip package and hot reloaded (hot reloading is currently only supported for zip packagetypes) + # we cannot enforce the codesha256 check + is_hot_reloaded_zip_package = ( + current_latest_version.config.package_type == PackageType.Zip + and current_latest_version.config.code.is_hot_reloading() + ) + if code_sha256 and current_hash != code_sha256 and not is_hot_reloaded_zip_package: + raise InvalidParameterValueException( + f"CodeSHA256 ({code_sha256}) is different from current CodeSHA256 in $LATEST ({current_hash}). Please try again with the CodeSHA256 in $LATEST.", + Type="User", + ) + + state = lambda_stores[account_id][region] + function = state.functions.get(function_name) + changes = {} + if description is not None: + changes["description"] = description + # TODO copy environment instead of restarting one, get rid of all the "Pending"s + + with function.lock: + if function.next_version > 1 and ( + prev_version := function.versions.get(str(function.next_version - 1)) + ): + if ( + prev_version.config.internal_revision + == current_latest_version.config.internal_revision + ): + return prev_version, False + # TODO check if there was a change since last version + next_version = str(function.next_version) + function.next_version += 1 + new_id = VersionIdentifier( + function_name=function_name, + qualifier=next_version, + region=region, + account=account_id, + ) + apply_on = current_latest_version.config.snap_start["ApplyOn"] + optimization_status = SnapStartOptimizationStatus.Off + if apply_on == SnapStartApplyOn.PublishedVersions: + optimization_status = SnapStartOptimizationStatus.On + snap_start = SnapStartResponse( + ApplyOn=apply_on, + OptimizationStatus=optimization_status, + ) + new_version = dataclasses.replace( + current_latest_version, + config=dataclasses.replace( + current_latest_version.config, + last_update=None, # versions never have a last update status + state=VersionState( + state=State.Pending, + code=StateReasonCode.Creating, + reason="The function is being created.", + ), + snap_start=snap_start, + **changes, + ), + id=new_id, + ) + function.versions[next_version] = new_version + return new_version, True + + def _publish_version_from_existing_version( + self, + function_name: str, + region: str, + account_id: str, + description: str | None = None, + revision_id: str | None = None, + code_sha256: str | None = None, + ) -> FunctionVersion: + """ + Publish version from an existing, already initialized LATEST + + :param function_name: Function name + :param region: region + :param account_id: account id + :param description: description + :param revision_id: revision id (check if current version matches) + :param code_sha256: code sha (check if current code matches) + :return: new version + """ + new_version, changed = self._create_version_model( + function_name=function_name, + region=region, + account_id=account_id, + description=description, + revision_id=revision_id, + code_sha256=code_sha256, + ) + if not changed: + return new_version + self.lambda_service.publish_version(new_version) + state = lambda_stores[account_id][region] + function = state.functions.get(function_name) + # TODO: re-evaluate data model to prevent this dirty hack just for bumping the revision id + latest_version = function.versions["$LATEST"] + function.versions["$LATEST"] = dataclasses.replace( + latest_version, config=dataclasses.replace(latest_version.config) + ) + return function.versions.get(new_version.id.qualifier) + + def _publish_version_with_changes( + self, + function_name: str, + region: str, + account_id: str, + description: str | None = None, + revision_id: str | None = None, + code_sha256: str | None = None, + ) -> FunctionVersion: + """ + Publish version together with a new latest version (publish on create / update) + + :param function_name: Function name + :param region: region + :param account_id: account id + :param description: description + :param revision_id: revision id (check if current version matches) + :param code_sha256: code sha (check if current code matches) + :return: new version + """ + new_version, changed = self._create_version_model( + function_name=function_name, + region=region, + account_id=account_id, + description=description, + revision_id=revision_id, + code_sha256=code_sha256, + ) + if not changed: + return new_version + self.lambda_service.create_function_version(new_version) + return new_version + + @staticmethod + def _verify_env_variables(env_vars: dict[str, str]): + dumped_env_vars = json.dumps(env_vars, separators=(",", ":")) + if ( + len(dumped_env_vars.encode("utf-8")) + > config.LAMBDA_LIMITS_MAX_FUNCTION_ENVVAR_SIZE_BYTES + ): + raise InvalidParameterValueException( + f"Lambda was unable to configure your environment variables because the environment variables you have provided exceeded the 4KB limit. String measured: {dumped_env_vars}", + Type="User", + ) + + @staticmethod + def _validate_snapstart(snap_start: SnapStart, runtime: Runtime): + apply_on = snap_start.get("ApplyOn") + if apply_on not in [ + SnapStartApplyOn.PublishedVersions, + SnapStartApplyOn.None_, + ]: + raise ValidationException( + f"1 validation error detected: Value '{apply_on}' at 'snapStart.applyOn' failed to satisfy constraint: Member must satisfy enum value set: [PublishedVersions, None]" + ) + + if runtime not in SNAP_START_SUPPORTED_RUNTIMES: + raise InvalidParameterValueException( + f"{runtime} is not supported for SnapStart enabled functions.", Type="User" + ) + + def _validate_layers(self, new_layers: list[str], region: str, account_id: str): + if len(new_layers) > LAMBDA_LAYERS_LIMIT_PER_FUNCTION: + raise InvalidParameterValueException( + "Cannot reference more than 5 layers.", Type="User" + ) + + visited_layers = dict() + for layer_version_arn in new_layers: + ( + layer_region, + layer_account_id, + layer_name, + layer_version_str, + ) = api_utils.parse_layer_arn(layer_version_arn) + if layer_version_str is None: + raise ValidationException( + f"1 validation error detected: Value '[{layer_version_arn}]'" + + r" at 'layers' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 140, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: (arn:[a-zA-Z0-9-]+:lambda:[a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\d{1}:\d{12}:layer:[a-zA-Z0-9-_]+:[0-9]+)|(arn:[a-zA-Z0-9-]+:lambda:::awslayer:[a-zA-Z0-9-_]+), Member must not be null]", + ) + + state = lambda_stores[layer_account_id][layer_region] + layer = state.layers.get(layer_name) + layer_version = None + if layer is not None: + layer_version = layer.layer_versions.get(layer_version_str) + if layer_account_id == account_id: + if region and layer_region != region: + raise InvalidParameterValueException( + f"Layers are not in the same region as the function. " + f"Layers are expected to be in region {region}.", + Type="User", + ) + if layer is None or layer.layer_versions.get(layer_version_str) is None: + raise InvalidParameterValueException( + f"Layer version {layer_version_arn} does not exist.", Type="User" + ) + else: # External layer from other account + # TODO: validate IAM layer policy here, allowing access by default for now and only checking region + if region and layer_region != region: + # TODO: detect user or role from context when IAM users are implemented + user = "user/localstack-testing" + raise AccessDeniedException( + f"User: arn:{get_partition(region)}:iam::{account_id}:{user} is not authorized to perform: lambda:GetLayerVersion on resource: {layer_version_arn} because no resource-based policy allows the lambda:GetLayerVersion action" + ) + if layer is None or layer_version is None: + # Limitation: cannot fetch external layers when using the same account id as the target layer + # because we do not want to trigger the layer fetcher for every non-existing layer. + if self.layer_fetcher is None: + raise NotImplementedError( + "Fetching shared layers from AWS is a pro feature." + ) + + layer = self.layer_fetcher.fetch_layer(layer_version_arn) + if layer is None: + # TODO: detect user or role from context when IAM users are implemented + user = "user/localstack-testing" + raise AccessDeniedException( + f"User: arn:{get_partition(region)}:iam::{account_id}:{user} is not authorized to perform: lambda:GetLayerVersion on resource: {layer_version_arn} because no resource-based policy allows the lambda:GetLayerVersion action" + ) + + # Distinguish between new layer and new layer version + if layer_version is None: + # Create whole layer from scratch + state.layers[layer_name] = layer + else: + # Create layer version if another version of the same layer already exists + state.layers[layer_name].layer_versions[layer_version_str] = ( + layer.layer_versions.get(layer_version_str) + ) + + # only the first two matches in the array are considered for the error message + layer_arn = ":".join(layer_version_arn.split(":")[:-1]) + if layer_arn in visited_layers: + conflict_layer_version_arn = visited_layers[layer_arn] + raise InvalidParameterValueException( + f"Two different versions of the same layer are not allowed to be referenced in the same function. {conflict_layer_version_arn} and {layer_version_arn} are versions of the same layer.", + Type="User", + ) + visited_layers[layer_arn] = layer_version_arn + + @staticmethod + def map_layers(new_layers: list[str]) -> list[LayerVersion]: + layers = [] + for layer_version_arn in new_layers: + region_name, account_id, layer_name, layer_version = api_utils.parse_layer_arn( + layer_version_arn + ) + layer = lambda_stores[account_id][region_name].layers.get(layer_name) + layer_version = layer.layer_versions.get(layer_version) + layers.append(layer_version) + return layers + + def get_function_recursion_config( + self, + context: RequestContext, + function_name: UnqualifiedFunctionName, + **kwargs, + ) -> GetFunctionRecursionConfigResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + fn = self._get_function(function_name=function_name, region=region, account_id=account_id) + return GetFunctionRecursionConfigResponse(RecursiveLoop=fn.recursive_loop) + + def put_function_recursion_config( + self, + context: RequestContext, + function_name: UnqualifiedFunctionName, + recursive_loop: RecursiveLoop, + **kwargs, + ) -> PutFunctionRecursionConfigResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + + fn = self._get_function(function_name=function_name, region=region, account_id=account_id) + + allowed_values = list(RecursiveLoop.__members__.values()) + if recursive_loop not in allowed_values: + raise ValidationException( + f"1 validation error detected: Value '{recursive_loop}' at 'recursiveLoop' failed to satisfy constraint: " + f"Member must satisfy enum value set: [Terminate, Allow]" + ) + + fn.recursive_loop = recursive_loop + return PutFunctionRecursionConfigResponse(RecursiveLoop=fn.recursive_loop) + + @handler(operation="CreateFunction", expand=False) + def create_function( + self, + context: RequestContext, + request: CreateFunctionRequest, + ) -> FunctionConfiguration: + context_region = context.region + context_account_id = context.account_id + + zip_file = request.get("Code", {}).get("ZipFile") + if zip_file and len(zip_file) > config.LAMBDA_LIMITS_CODE_SIZE_ZIPPED: + raise RequestEntityTooLargeException( + f"Zipped size must be smaller than {config.LAMBDA_LIMITS_CODE_SIZE_ZIPPED} bytes" + ) + + if context.request.content_length > config.LAMBDA_LIMITS_CREATE_FUNCTION_REQUEST_SIZE: + raise RequestEntityTooLargeException( + f"Request must be smaller than {config.LAMBDA_LIMITS_CREATE_FUNCTION_REQUEST_SIZE} bytes for the CreateFunction operation" + ) + + if architectures := request.get("Architectures"): + if len(architectures) != 1: + raise ValidationException( + f"1 validation error detected: Value '[{', '.join(architectures)}]' at 'architectures' failed to " + f"satisfy constraint: Member must have length less than or equal to 1", + ) + if architectures[0] not in ARCHITECTURES: + raise ValidationException( + f"1 validation error detected: Value '[{', '.join(architectures)}]' at 'architectures' failed to " + f"satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: " + f"[x86_64, arm64], Member must not be null]", + ) + + if env_vars := request.get("Environment", {}).get("Variables"): + self._verify_env_variables(env_vars) + + if layers := request.get("Layers", []): + self._validate_layers(layers, region=context_region, account_id=context_account_id) + + if not api_utils.is_role_arn(request.get("Role")): + raise ValidationException( + f"1 validation error detected: Value '{request.get('Role')}'" + + " at 'role' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*)?:iam::\\d{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+" + ) + if not self.lambda_service.can_assume_role(request.get("Role"), context.region): + raise InvalidParameterValueException( + "The role defined for the function cannot be assumed by Lambda.", Type="User" + ) + package_type = request.get("PackageType", PackageType.Zip) + runtime = request.get("Runtime") + self._validate_runtime(package_type, runtime) + + request_function_name = request.get("FunctionName") + + function_name, *_ = api_utils.get_name_and_qualifier( + function_arn_or_name=request_function_name, + qualifier=None, + context=context, + ) + + if runtime in DEPRECATED_RUNTIMES: + LOG.warning( + "The Lambda runtime %s} is deprecated. " + "Please upgrade the runtime for the function %s: " + "https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html", + runtime, + function_name, + ) + if snap_start := request.get("SnapStart"): + self._validate_snapstart(snap_start, runtime) + state = lambda_stores[context_account_id][context_region] + + with self.create_fn_lock: + if function_name in state.functions: + raise ResourceConflictException(f"Function already exist: {function_name}") + fn = Function(function_name=function_name) + arn = VersionIdentifier( + function_name=function_name, + qualifier="$LATEST", + region=context_region, + account=context_account_id, + ) + # save function code to s3 + code = None + image = None + image_config = None + runtime_version_config = RuntimeVersionConfig( + # Limitation: the runtime id (presumably sha256 of image) is currently hardcoded + # Potential implementation: provide (cached) sha256 hash of used Docker image + RuntimeVersionArn=f"arn:{context.partition}:lambda:{context_region}::runtime:8eeff65f6809a3ce81507fe733fe09b835899b99481ba22fd75b5a7338290ec1" + ) + request_code = request.get("Code") + if package_type == PackageType.Zip: + # TODO verify if correct combination of code is set + if zip_file := request_code.get("ZipFile"): + code = store_lambda_archive( + archive_file=zip_file, + function_name=function_name, + region_name=context_region, + account_id=context_account_id, + ) + elif s3_bucket := request_code.get("S3Bucket"): + s3_key = request_code["S3Key"] + s3_object_version = request_code.get("S3ObjectVersion") + code = store_s3_bucket_archive( + archive_bucket=s3_bucket, + archive_key=s3_key, + archive_version=s3_object_version, + function_name=function_name, + region_name=context_region, + account_id=context_account_id, + ) + else: + raise LambdaServiceException("Gotta have s3 bucket or zip file") + elif package_type == PackageType.Image: + image = request_code.get("ImageUri") + if not image: + raise LambdaServiceException("Gotta have an image when package type is image") + image = create_image_code(image_uri=image) + + image_config_req = request.get("ImageConfig", {}) + image_config = ImageConfig( + command=image_config_req.get("Command"), + entrypoint=image_config_req.get("EntryPoint"), + working_directory=image_config_req.get("WorkingDirectory"), + ) + # Runtime management controls are not available when providing a custom image + runtime_version_config = None + if "LoggingConfig" in request: + logging_config = request["LoggingConfig"] + LOG.warning( + "Advanced Lambda Logging Configuration is currently mocked " + "and will not impact the logging behavior. " + "Please create a feature request if needed." + ) + + # when switching to JSON, app and system level log is auto set to INFO + if logging_config.get("LogFormat", None) == LogFormat.JSON: + logging_config = { + "ApplicationLogLevel": "INFO", + "SystemLogLevel": "INFO", + "LogGroup": f"/aws/lambda/{function_name}", + } | logging_config + else: + logging_config = ( + LoggingConfig( + LogFormat=LogFormat.Text, LogGroup=f"/aws/lambda/{function_name}" + ) + | logging_config + ) + + else: + logging_config = LoggingConfig( + LogFormat=LogFormat.Text, LogGroup=f"/aws/lambda/{function_name}" + ) + + version = FunctionVersion( + id=arn, + config=VersionFunctionConfiguration( + last_modified=api_utils.format_lambda_date(datetime.datetime.now()), + description=request.get("Description", ""), + role=request["Role"], + timeout=request.get("Timeout", LAMBDA_DEFAULT_TIMEOUT), + runtime=request.get("Runtime"), + memory_size=request.get("MemorySize", LAMBDA_DEFAULT_MEMORY_SIZE), + handler=request.get("Handler"), + package_type=package_type, + environment=env_vars, + architectures=request.get("Architectures") or [Architecture.x86_64], + tracing_config_mode=request.get("TracingConfig", {}).get( + "Mode", TracingMode.PassThrough + ), + image=image, + image_config=image_config, + code=code, + layers=self.map_layers(layers), + internal_revision=short_uid(), + ephemeral_storage=LambdaEphemeralStorage( + size=request.get("EphemeralStorage", {}).get("Size", 512) + ), + snap_start=SnapStartResponse( + ApplyOn=request.get("SnapStart", {}).get("ApplyOn", SnapStartApplyOn.None_), + OptimizationStatus=SnapStartOptimizationStatus.Off, + ), + runtime_version_config=runtime_version_config, + dead_letter_arn=request.get("DeadLetterConfig", {}).get("TargetArn"), + vpc_config=self._build_vpc_config( + context_account_id, context_region, request.get("VpcConfig") + ), + state=VersionState( + state=State.Pending, + code=StateReasonCode.Creating, + reason="The function is being created.", + ), + logging_config=logging_config, + ), + ) + fn.versions["$LATEST"] = version + state.functions[function_name] = fn + function_counter.labels( + operation=FunctionOperation.create, + runtime=runtime or "n/a", + status=FunctionStatus.success, + invocation_type="n/a", + package_type=package_type, + ) + self.lambda_service.create_function_version(version) + + if tags := request.get("Tags"): + # This will check whether the function exists. + self._store_tags(arn.unqualified_arn(), tags) + + if request.get("Publish"): + version = self._publish_version_with_changes( + function_name=function_name, region=context_region, account_id=context_account_id + ) + + if config.LAMBDA_SYNCHRONOUS_CREATE: + # block via retrying until "terminal" condition reached before returning + if not poll_condition( + lambda: get_function_version( + function_name, version.id.qualifier, version.id.account, version.id.region + ).config.state.state + in [State.Active, State.Failed], + timeout=10, + ): + LOG.warning( + "LAMBDA_SYNCHRONOUS_CREATE is active, but waiting for %s reached timeout.", + function_name, + ) + + return api_utils.map_config_out( + version, return_qualified_arn=False, return_update_status=False + ) + + def _validate_runtime(self, package_type, runtime): + runtimes = ALL_RUNTIMES + if config.LAMBDA_RUNTIME_VALIDATION: + runtimes = list(itertools.chain(RUNTIMES_AGGREGATED.values())) + + if package_type == PackageType.Zip and runtime not in runtimes: + # deprecated runtimes have different error + if runtime in DEPRECATED_RUNTIMES: + HINT_LOG.info( + "Set env variable LAMBDA_RUNTIME_VALIDATION to 0" + " in order to allow usage of deprecated runtimes" + ) + self._check_for_recomended_migration_target(runtime) + + raise InvalidParameterValueException( + f"Value {runtime} at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: {VALID_RUNTIMES} or be a valid ARN", + Type="User", + ) + + def _check_for_recomended_migration_target(self, deprecated_runtime): + # AWS offers recommended runtime for migration for "newly" deprecated runtimes + # in order to preserve parity with error messages we need the code bellow + latest_runtime = DEPRECATED_RUNTIMES_UPGRADES.get(deprecated_runtime) + + if latest_runtime is not None: + LOG.debug( + "The Lambda runtime %s is deprecated. Please upgrade to a supported Lambda runtime such as %s.", + deprecated_runtime, + latest_runtime, + ) + raise InvalidParameterValueException( + f"The runtime parameter of {deprecated_runtime} is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + Type="User", + ) + + @handler(operation="UpdateFunctionConfiguration", expand=False) + def update_function_configuration( + self, context: RequestContext, request: UpdateFunctionConfigurationRequest + ) -> FunctionConfiguration: + """updates the $LATEST version of the function""" + function_name = request.get("FunctionName") + + # in case we got ARN or partial ARN + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier(function_name, None, context) + state = lambda_stores[account_id][region] + + if function_name not in state.functions: + raise ResourceNotFoundException( + f"Function not found: {api_utils.unqualified_lambda_arn(function_name=function_name, region=region, account=account_id)}", + Type="User", + ) + function = state.functions[function_name] + + # TODO: lock modification of latest version + # TODO: notify service for changes relevant to re-provisioning of $LATEST + latest_version = function.latest() + latest_version_config = latest_version.config + + revision_id = request.get("RevisionId") + if revision_id and revision_id != latest_version.config.revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. " + "Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + Type="User", + ) + + replace_kwargs = {} + if "EphemeralStorage" in request: + replace_kwargs["ephemeral_storage"] = LambdaEphemeralStorage( + request.get("EphemeralStorage", {}).get("Size", 512) + ) # TODO: do defaults here apply as well? + + if "Role" in request: + if not api_utils.is_role_arn(request["Role"]): + raise ValidationException( + f"1 validation error detected: Value '{request.get('Role')}'" + + " at 'role' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*)?:iam::\\d{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+" + ) + replace_kwargs["role"] = request["Role"] + + if "Description" in request: + replace_kwargs["description"] = request["Description"] + + if "Timeout" in request: + replace_kwargs["timeout"] = request["Timeout"] + + if "MemorySize" in request: + replace_kwargs["memory_size"] = request["MemorySize"] + + if "DeadLetterConfig" in request: + replace_kwargs["dead_letter_arn"] = request.get("DeadLetterConfig", {}).get("TargetArn") + + if vpc_config := request.get("VpcConfig"): + replace_kwargs["vpc_config"] = self._build_vpc_config(account_id, region, vpc_config) + + if "Handler" in request: + replace_kwargs["handler"] = request["Handler"] + + if "Runtime" in request: + runtime = request["Runtime"] + + if runtime not in ALL_RUNTIMES: + raise InvalidParameterValueException( + f"Value {runtime} at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: {VALID_RUNTIMES} or be a valid ARN", + Type="User", + ) + if runtime in DEPRECATED_RUNTIMES: + LOG.warning( + "The Lambda runtime %s is deprecated. " + "Please upgrade the runtime for the function %s: " + "https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html", + runtime, + function_name, + ) + replace_kwargs["runtime"] = request["Runtime"] + + if snap_start := request.get("SnapStart"): + runtime = replace_kwargs.get("runtime") or latest_version_config.runtime + self._validate_snapstart(snap_start, runtime) + replace_kwargs["snap_start"] = SnapStartResponse( + ApplyOn=snap_start.get("ApplyOn", SnapStartApplyOn.None_), + OptimizationStatus=SnapStartOptimizationStatus.Off, + ) + + if "Environment" in request: + if env_vars := request.get("Environment", {}).get("Variables", {}): + self._verify_env_variables(env_vars) + replace_kwargs["environment"] = env_vars + + if "Layers" in request: + new_layers = request["Layers"] + if new_layers: + self._validate_layers(new_layers, region=region, account_id=account_id) + replace_kwargs["layers"] = self.map_layers(new_layers) + + if "ImageConfig" in request: + new_image_config = request["ImageConfig"] + replace_kwargs["image_config"] = ImageConfig( + command=new_image_config.get("Command"), + entrypoint=new_image_config.get("EntryPoint"), + working_directory=new_image_config.get("WorkingDirectory"), + ) + + if "LoggingConfig" in request: + logging_config = request["LoggingConfig"] + LOG.warning( + "Advanced Lambda Logging Configuration is currently mocked " + "and will not impact the logging behavior. " + "Please create a feature request if needed." + ) + + # when switching to JSON, app and system level log is auto set to INFO + if logging_config.get("LogFormat", None) == LogFormat.JSON: + logging_config = { + "ApplicationLogLevel": "INFO", + "SystemLogLevel": "INFO", + } | logging_config + + last_config = latest_version_config.logging_config + + # add partial update + new_logging_config = last_config | logging_config + + # in case we switched from JSON to Text we need to remove LogLevel keys + if ( + new_logging_config.get("LogFormat") == LogFormat.Text + and last_config.get("LogFormat") == LogFormat.JSON + ): + new_logging_config.pop("ApplicationLogLevel", None) + new_logging_config.pop("SystemLogLevel", None) + + replace_kwargs["logging_config"] = new_logging_config + + if "TracingConfig" in request: + new_mode = request.get("TracingConfig", {}).get("Mode") + if new_mode: + replace_kwargs["tracing_config_mode"] = new_mode + + new_latest_version = dataclasses.replace( + latest_version, + config=dataclasses.replace( + latest_version_config, + last_modified=api_utils.generate_lambda_date(), + internal_revision=short_uid(), + last_update=UpdateStatus( + status=LastUpdateStatus.InProgress, + code="Creating", + reason="The function is being created.", + ), + **replace_kwargs, + ), + ) + function.versions["$LATEST"] = new_latest_version # TODO: notify + self.lambda_service.update_version(new_version=new_latest_version) + + return api_utils.map_config_out(new_latest_version) + + @handler(operation="UpdateFunctionCode", expand=False) + def update_function_code( + self, context: RequestContext, request: UpdateFunctionCodeRequest + ) -> FunctionConfiguration: + """updates the $LATEST version of the function""" + # only supports normal zip packaging atm + # if request.get("Publish"): + # self.lambda_service.create_function_version() + + function_name = request.get("FunctionName") + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier(function_name, None, context) + + store = lambda_stores[account_id][region] + if function_name not in store.functions: + raise ResourceNotFoundException( + f"Function not found: {api_utils.unqualified_lambda_arn(function_name=function_name, region=region, account=account_id)}", + Type="User", + ) + function = store.functions[function_name] + + revision_id = request.get("RevisionId") + if revision_id and revision_id != function.latest().config.revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. " + "Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + Type="User", + ) + + # TODO verify if correct combination of code is set + image = None + if ( + request.get("ZipFile") or request.get("S3Bucket") + ) and function.latest().config.package_type == PackageType.Image: + raise InvalidParameterValueException( + "Please provide ImageUri when updating a function with packageType Image.", + Type="User", + ) + elif request.get("ImageUri") and function.latest().config.package_type == PackageType.Zip: + raise InvalidParameterValueException( + "Please don't provide ImageUri when updating a function with packageType Zip.", + Type="User", + ) + + if zip_file := request.get("ZipFile"): + code = store_lambda_archive( + archive_file=zip_file, + function_name=function_name, + region_name=region, + account_id=account_id, + ) + elif s3_bucket := request.get("S3Bucket"): + s3_key = request["S3Key"] + s3_object_version = request.get("S3ObjectVersion") + code = store_s3_bucket_archive( + archive_bucket=s3_bucket, + archive_key=s3_key, + archive_version=s3_object_version, + function_name=function_name, + region_name=region, + account_id=account_id, + ) + elif image := request.get("ImageUri"): + code = None + image = create_image_code(image_uri=image) + else: + raise LambdaServiceException("Gotta have s3 bucket or zip file or image") + + old_function_version = function.versions.get("$LATEST") + replace_kwargs = {"code": code} if code else {"image": image} + + if architectures := request.get("Architectures"): + if len(architectures) != 1: + raise ValidationException( + f"1 validation error detected: Value '[{', '.join(architectures)}]' at 'architectures' failed to " + f"satisfy constraint: Member must have length less than or equal to 1", + ) + # An empty list of architectures is also forbidden. Further exceptions are tested here for create_function: + # tests.aws.services.lambda_.test_lambda_api.TestLambdaFunction.test_create_lambda_exceptions + if architectures[0] not in ARCHITECTURES: + raise ValidationException( + f"1 validation error detected: Value '[{', '.join(architectures)}]' at 'architectures' failed to " + f"satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: " + f"[x86_64, arm64], Member must not be null]", + ) + replace_kwargs["architectures"] = architectures + + config = dataclasses.replace( + old_function_version.config, + internal_revision=short_uid(), + last_modified=api_utils.generate_lambda_date(), + last_update=UpdateStatus( + status=LastUpdateStatus.InProgress, + code="Creating", + reason="The function is being created.", + ), + **replace_kwargs, + ) + function_version = dataclasses.replace(old_function_version, config=config) + function.versions["$LATEST"] = function_version + + self.lambda_service.update_version(new_version=function_version) + if request.get("Publish"): + function_version = self._publish_version_with_changes( + function_name=function_name, region=region, account_id=account_id + ) + return api_utils.map_config_out( + function_version, return_qualified_arn=bool(request.get("Publish")) + ) + + # TODO: does deleting the latest published version affect the next versions number? + # TODO: what happens when we call this with a qualifier and a fully qualified ARN? (+ conflicts?) + # TODO: test different ARN patterns (shorthand ARN?) + # TODO: test deleting across regions? + # TODO: test mismatch between context region and region in ARN + # TODO: test qualifier $LATEST, alias-name and version + def delete_function( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier = None, + **kwargs, + ) -> None: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + + if qualifier and api_utils.qualifier_is_alias(qualifier): + raise InvalidParameterValueException( + "Deletion of aliases is not currently supported.", + Type="User", + ) + + store = lambda_stores[account_id][region] + if qualifier == "$LATEST": + raise InvalidParameterValueException( + "$LATEST version cannot be deleted without deleting the function.", Type="User" + ) + + if function_name not in store.functions: + e = ResourceNotFoundException( + f"Function not found: {api_utils.unqualified_lambda_arn(function_name=function_name, region=region, account=account_id)}", + Type="User", + ) + raise e + function = store.functions.get(function_name) + + if qualifier: + # delete a version of the function + version = function.versions.pop(qualifier, None) + if version: + self.lambda_service.stop_version(version.id.qualified_arn()) + destroy_code_if_not_used(code=version.config.code, function=function) + else: + # delete the whole function + # TODO: introduce locking for safe deletion: We could create a new version at the API layer before + # the old version gets cleaned up in the internal lambda service. + function = store.functions.pop(function_name) + for version in function.versions.values(): + self.lambda_service.stop_version(qualified_arn=version.id.qualified_arn()) + # we can safely destroy the code here + if version.config.code: + version.config.code.destroy() + + def list_functions( + self, + context: RequestContext, + master_region: MasterRegion = None, # (only relevant for lambda@edge) + function_version: FunctionVersionApi = None, + marker: String = None, + max_items: MaxListItems = None, + **kwargs, + ) -> ListFunctionsResponse: + state = lambda_stores[context.account_id][context.region] + + if function_version and function_version != FunctionVersionApi.ALL: + raise ValidationException( + f"1 validation error detected: Value '{function_version}'" + + " at 'functionVersion' failed to satisfy constraint: Member must satisfy enum value set: [ALL]" + ) + + if function_version == FunctionVersionApi.ALL: + # include all versions for all function + versions = [v for f in state.functions.values() for v in f.versions.values()] + return_qualified_arn = True + else: + versions = [f.latest() for f in state.functions.values()] + return_qualified_arn = False + + versions = [ + api_utils.map_to_list_response( + api_utils.map_config_out(fc, return_qualified_arn=return_qualified_arn) + ) + for fc in versions + ] + versions = PaginatedList(versions) + page, token = versions.get_page( + lambda version: version["FunctionArn"], + marker, + max_items, + ) + return ListFunctionsResponse(Functions=page, NextMarker=token) + + def get_function( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + qualifier: Qualifier = None, + **kwargs, + ) -> GetFunctionResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + + fn = lambda_stores[account_id][region].functions.get(function_name) + if fn is None: + if qualifier is None: + raise ResourceNotFoundException( + f"Function not found: {api_utils.unqualified_lambda_arn(function_name, account_id, region)}", + Type="User", + ) + else: + raise ResourceNotFoundException( + f"Function not found: {api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region)}", + Type="User", + ) + alias_name = None + if qualifier and api_utils.qualifier_is_alias(qualifier): + if qualifier not in fn.aliases: + alias_arn = api_utils.qualified_lambda_arn( + function_name, qualifier, account_id, region + ) + raise ResourceNotFoundException(f"Function not found: {alias_arn}", Type="User") + alias_name = qualifier + qualifier = fn.aliases[alias_name].function_version + + version = get_function_version( + function_name=function_name, + qualifier=qualifier, + account_id=account_id, + region=region, + ) + tags = self._get_tags(api_utils.unqualified_lambda_arn(function_name, account_id, region)) + additional_fields = {} + if tags: + additional_fields["Tags"] = tags + code_location = None + if code := version.config.code: + code_location = FunctionCodeLocation( + Location=code.generate_presigned_url(), RepositoryType="S3" + ) + elif image := version.config.image: + code_location = FunctionCodeLocation( + ImageUri=image.image_uri, + RepositoryType=image.repository_type, + ResolvedImageUri=image.resolved_image_uri, + ) + concurrency = None + if fn.reserved_concurrent_executions: + concurrency = Concurrency( + ReservedConcurrentExecutions=fn.reserved_concurrent_executions + ) + + return GetFunctionResponse( + Configuration=api_utils.map_config_out( + version, return_qualified_arn=bool(qualifier), alias_name=alias_name + ), + Code=code_location, # TODO + Concurrency=concurrency, + **additional_fields, + ) + + def get_function_configuration( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + qualifier: Qualifier = None, + **kwargs, + ) -> FunctionConfiguration: + account_id, region = api_utils.get_account_and_region(function_name, context) + # CAVE: THIS RETURN VALUE IS *NOT* THE SAME AS IN get_function (!) but seems to be only configuration part? + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + version = get_function_version( + function_name=function_name, + qualifier=qualifier, + account_id=account_id, + region=region, + ) + return api_utils.map_config_out(version, return_qualified_arn=bool(qualifier)) + + def invoke( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + invocation_type: InvocationType = None, + log_type: LogType = None, + client_context: String = None, + payload: IO[Blob] = None, + qualifier: Qualifier = None, + **kwargs, + ) -> InvocationResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + + time_before = time.perf_counter() + try: + invocation_result = self.lambda_service.invoke( + function_name=function_name, + qualifier=qualifier, + region=region, + account_id=account_id, + invocation_type=invocation_type, + client_context=client_context, + request_id=context.request_id, + trace_context=context.trace_context, + payload=payload.read() if payload else None, + ) + except ServiceException: + raise + except EnvironmentStartupTimeoutException as e: + raise LambdaServiceException( + f"[{context.request_id}] Timeout while starting up lambda environment for function {function_name}:{qualifier}" + ) from e + except Exception as e: + LOG.error( + "[%s] Error while invoking lambda %s", + context.request_id, + function_name, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + raise LambdaServiceException( + f"[{context.request_id}] Internal error while executing lambda {function_name}:{qualifier}. Caused by {type(e).__name__}: {e}" + ) from e + + if invocation_type == InvocationType.Event: + # This happens when invocation type is event + return InvocationResponse(StatusCode=202) + if invocation_type == InvocationType.DryRun: + # This happens when invocation type is dryrun + return InvocationResponse(StatusCode=204) + LOG.debug("Lambda invocation duration: %0.2fms", (time.perf_counter() - time_before) * 1000) + + response = InvocationResponse( + StatusCode=200, + Payload=invocation_result.payload, + ExecutedVersion=invocation_result.executed_version, + ) + + if invocation_result.is_error: + response["FunctionError"] = "Unhandled" + + if log_type == LogType.Tail: + response["LogResult"] = to_str( + base64.b64encode(to_bytes(invocation_result.logs)[-4096:]) + ) + + return response + + # Version operations + def publish_version( + self, + context: RequestContext, + function_name: FunctionName, + code_sha256: String = None, + description: Description = None, + revision_id: String = None, + **kwargs, + ) -> FunctionConfiguration: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + new_version = self._publish_version_from_existing_version( + function_name=function_name, + description=description, + account_id=account_id, + region=region, + revision_id=revision_id, + code_sha256=code_sha256, + ) + return api_utils.map_config_out(new_version, return_qualified_arn=True) + + def list_versions_by_function( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + marker: String = None, + max_items: MaxListItems = None, + **kwargs, + ) -> ListVersionsByFunctionResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + function = self._get_function( + function_name=function_name, region=region, account_id=account_id + ) + versions = [ + api_utils.map_to_list_response( + api_utils.map_config_out(version=version, return_qualified_arn=True) + ) + for version in function.versions.values() + ] + items = PaginatedList(versions) + page, token = items.get_page( + lambda item: item, + marker, + max_items, + ) + return ListVersionsByFunctionResponse(Versions=page, NextMarker=token) + + # Alias + + def _create_routing_config_model( + self, routing_config_dict: dict[str, float], function_version: FunctionVersion + ): + if len(routing_config_dict) > 1: + raise InvalidParameterValueException( + "Number of items in AdditionalVersionWeights cannot be greater than 1", + Type="User", + ) + # should be exactly one item here, still iterating, might be supported in the future + for key, value in routing_config_dict.items(): + if value < 0.0 or value >= 1.0: + raise ValidationException( + f"1 validation error detected: Value '{{{key}={value}}}' at 'routingConfig.additionalVersionWeights' failed to satisfy constraint: Map value must satisfy constraint: [Member must have value less than or equal to 1.0, Member must have value greater than or equal to 0.0, Member must not be null]" + ) + if key == function_version.id.qualifier: + raise InvalidParameterValueException( + f"Invalid function version {function_version.id.qualifier}. Function version {function_version.id.qualifier} is already included in routing configuration.", + Type="User", + ) + # check if version target is latest, then no routing config is allowed + if function_version.id.qualifier == "$LATEST": + raise InvalidParameterValueException( + "$LATEST is not supported for an alias pointing to more than 1 version" + ) + if not api_utils.qualifier_is_version(key): + raise ValidationException( + f"1 validation error detected: Value '{{{key}={value}}}' at 'routingConfig.additionalVersionWeights' failed to satisfy constraint: Map keys must satisfy constraint: [Member must have length less than or equal to 1024, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [0-9]+, Member must not be null]" + ) + + # checking if the version in the config exists + get_function_version( + function_name=function_version.id.function_name, + qualifier=key, + region=function_version.id.region, + account_id=function_version.id.account, + ) + return AliasRoutingConfig(version_weights=routing_config_dict) + + def create_alias( + self, + context: RequestContext, + function_name: FunctionName, + name: Alias, + function_version: Version, + description: Description = None, + routing_config: AliasRoutingConfiguration = None, + **kwargs, + ) -> AliasConfiguration: + if not api_utils.qualifier_is_alias(name): + raise ValidationException( + f"1 validation error detected: Value '{name}' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: (?!^[0-9]+$)([a-zA-Z0-9-_]+)" + ) + + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + target_version = get_function_version( + function_name=function_name, + qualifier=function_version, + region=region, + account_id=account_id, + ) + function = self._get_function( + function_name=function_name, region=region, account_id=account_id + ) + # description is always present, if not specified it's an empty string + description = description or "" + with function.lock: + if existing_alias := function.aliases.get(name): + raise ResourceConflictException( + f"Alias already exists: {api_utils.map_alias_out(alias=existing_alias, function=function)['AliasArn']}", + Type="User", + ) + # checking if the version exists + routing_configuration = None + if routing_config and ( + routing_config_dict := routing_config.get("AdditionalVersionWeights") + ): + routing_configuration = self._create_routing_config_model( + routing_config_dict, target_version + ) + + alias = VersionAlias( + name=name, + function_version=function_version, + description=description, + routing_configuration=routing_configuration, + ) + function.aliases[name] = alias + return api_utils.map_alias_out(alias=alias, function=function) + + def list_aliases( + self, + context: RequestContext, + function_name: FunctionName, + function_version: Version = None, + marker: String = None, + max_items: MaxListItems = None, + **kwargs, + ) -> ListAliasesResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + function = self._get_function( + function_name=function_name, region=region, account_id=account_id + ) + aliases = [ + api_utils.map_alias_out(alias, function) + for alias in function.aliases.values() + if function_version is None or alias.function_version == function_version + ] + + aliases = PaginatedList(aliases) + page, token = aliases.get_page( + lambda alias: alias["AliasArn"], + marker, + max_items, + ) + + return ListAliasesResponse(Aliases=page, NextMarker=token) + + def delete_alias( + self, context: RequestContext, function_name: FunctionName, name: Alias, **kwargs + ) -> None: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + function = self._get_function( + function_name=function_name, region=region, account_id=account_id + ) + version_alias = function.aliases.pop(name, None) + + # cleanup related resources + if name in function.provisioned_concurrency_configs: + function.provisioned_concurrency_configs.pop(name) + + # TODO: Allow for deactivating/unregistering specific Lambda URLs + if version_alias and name in function.function_url_configs: + url_config = function.function_url_configs.pop(name) + LOG.debug( + "Stopping aliased Lambda Function URL %s for %s", + url_config.url, + url_config.function_name, + ) + + def get_alias( + self, context: RequestContext, function_name: FunctionName, name: Alias, **kwargs + ) -> AliasConfiguration: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + function = self._get_function( + function_name=function_name, region=region, account_id=account_id + ) + if not (alias := function.aliases.get(name)): + raise ResourceNotFoundException( + f"Cannot find alias arn: {api_utils.qualified_lambda_arn(function_name=function_name, qualifier=name, region=region, account=account_id)}", + Type="User", + ) + return api_utils.map_alias_out(alias=alias, function=function) + + def update_alias( + self, + context: RequestContext, + function_name: FunctionName, + name: Alias, + function_version: Version = None, + description: Description = None, + routing_config: AliasRoutingConfiguration = None, + revision_id: String = None, + **kwargs, + ) -> AliasConfiguration: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + function = self._get_function( + function_name=function_name, region=region, account_id=account_id + ) + if not (alias := function.aliases.get(name)): + fn_arn = api_utils.qualified_lambda_arn(function_name, name, account_id, region) + raise ResourceNotFoundException( + f"Alias not found: {fn_arn}", + Type="User", + ) + if revision_id and alias.revision_id != revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. " + "Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + Type="User", + ) + changes = {} + if function_version is not None: + changes |= {"function_version": function_version} + if description is not None: + changes |= {"description": description} + if routing_config is not None: + # if it is an empty dict or AdditionalVersionWeights is empty, set routing config to None + new_routing_config = None + if routing_config_dict := routing_config.get("AdditionalVersionWeights"): + new_routing_config = self._create_routing_config_model(routing_config_dict) + changes |= {"routing_configuration": new_routing_config} + # even if no changes are done, we have to update revision id for some reason + old_alias = alias + alias = dataclasses.replace(alias, **changes) + function.aliases[name] = alias + + # TODO: signal lambda service that pointer potentially changed + self.lambda_service.update_alias(old_alias=old_alias, new_alias=alias, function=function) + + return api_utils.map_alias_out(alias=alias, function=function) + + # ======================================= + # ======= EVENT SOURCE MAPPINGS ========= + # ======================================= + def check_service_resource_exists( + self, service: str, resource_arn: str, function_arn: str, function_role_arn: str + ): + """ + Check if the service resource exists and if the function has access to it. + + Raises: + InvalidParameterValueException: If the service resource does not exist or the function does not have access to it. + """ + arn = parse_arn(resource_arn) + source_client = get_internal_client( + arn=resource_arn, + role_arn=function_role_arn, + service_principal=ServicePrincipal.lambda_, + source_arn=function_arn, + ) + if service in ["sqs", "sqs-fifo"]: + try: + # AWS uses `GetQueueAttributes` internally to verify the queue existence, but we need the `QueueUrl` + # which is not given directly. We build out a dummy `QueueUrl` which can be parsed by SQS to return + # the right value + queue_name = arn["resource"].split("/")[-1] + queue_url = f"http://sqs.{arn['region']}.domain/{arn['account']}/{queue_name}" + source_client.get_queue_attributes(QueueUrl=queue_url) + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code == "AWS.SimpleQueueService.NonExistentQueue": + raise InvalidParameterValueException( + f"Error occurred while ReceiveMessage. SQS Error Code: {error_code}. SQS Error Message: {e.response['Error']['Message']}", + Type="User", + ) + raise e + elif service in ["kinesis"]: + try: + source_client.describe_stream(StreamARN=resource_arn) + except ClientError as e: + if e.response["Error"]["Code"] == "ResourceNotFoundException": + raise InvalidParameterValueException( + f"Stream not found: {resource_arn}", + Type="User", + ) + raise e + elif service in ["dynamodb"]: + try: + source_client.describe_stream(StreamArn=resource_arn) + except ClientError as e: + if e.response["Error"]["Code"] == "ResourceNotFoundException": + raise InvalidParameterValueException( + f"Stream not found: {resource_arn}", + Type="User", + ) + raise e + + @handler("CreateEventSourceMapping", expand=False) + def create_event_source_mapping( + self, + context: RequestContext, + request: CreateEventSourceMappingRequest, + ) -> EventSourceMappingConfiguration: + return self.create_event_source_mapping_v2(context, request) + + def create_event_source_mapping_v2( + self, + context: RequestContext, + request: CreateEventSourceMappingRequest, + ) -> EventSourceMappingConfiguration: + # Validations + function_arn, function_name, state, function_version, function_role = ( + self.validate_event_source_mapping(context, request) + ) + + esm_config = EsmConfigFactory(request, context, function_arn).get_esm_config() + + # Copy esm_config to avoid a race condition with potential async update in the store + state.event_source_mappings[esm_config["UUID"]] = esm_config.copy() + enabled = request.get("Enabled", True) + # TODO: check for potential async race condition update -> think about locking + esm_worker = EsmWorkerFactory(esm_config, function_role, enabled).get_esm_worker() + self.esm_workers[esm_worker.uuid] = esm_worker + # TODO: check StateTransitionReason, LastModified, LastProcessingResult (concurrent updates requires locking!) + if tags := request.get("Tags"): + self._store_tags(esm_config.get("EventSourceMappingArn"), tags) + esm_worker.create() + return esm_config + + def validate_event_source_mapping(self, context, request): + # TODO: test whether stream ARNs are valid sources for Pipes or ESM or whether only DynamoDB table ARNs work + # TODO: Validate MaxRecordAgeInSeconds (i.e cannot subceed 60s but can be -1) and MaxRetryAttempts parameters. + # See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-lambda-eventsourcemapping.html#cfn-lambda-eventsourcemapping-maximumrecordageinseconds + is_create_esm_request = context.operation.name == self.create_event_source_mapping.operation + + if destination_config := request.get("DestinationConfig"): + if "OnSuccess" in destination_config: + raise InvalidParameterValueException( + "Unsupported DestinationConfig parameter for given event source mapping type.", + Type="User", + ) + + service = None + if "SelfManagedEventSource" in request: + service = "kafka" + if "SourceAccessConfigurations" not in request: + raise InvalidParameterValueException( + "Required 'sourceAccessConfigurations' parameter is missing.", Type="User" + ) + if service is None and "EventSourceArn" not in request: + raise InvalidParameterValueException("Unrecognized event source.", Type="User") + if service is None: + service = extract_service_from_arn(request["EventSourceArn"]) + + batch_size = api_utils.validate_and_set_batch_size(service, request.get("BatchSize")) + if service in ["dynamodb", "kinesis"]: + starting_position = request.get("StartingPosition") + if not starting_position: + raise InvalidParameterValueException( + "1 validation error detected: Value null at 'startingPosition' failed to satisfy constraint: Member must not be null.", + Type="User", + ) + + if starting_position not in KinesisStreamStartPosition.__members__: + raise ValidationException( + f"1 validation error detected: Value '{starting_position}' at 'startingPosition' failed to satisfy constraint: Member must satisfy enum value set: [LATEST, AT_TIMESTAMP, TRIM_HORIZON]" + ) + # AT_TIMESTAMP is not allowed for DynamoDB Streams + elif ( + service == "dynamodb" + and starting_position not in DynamoDBStreamStartPosition.__members__ + ): + raise InvalidParameterValueException( + f"Unsupported starting position for arn type: {request['EventSourceArn']}", + Type="User", + ) + + if service in ["sqs", "sqs-fifo"]: + if batch_size > 10 and request.get("MaximumBatchingWindowInSeconds", 0) == 0: + raise InvalidParameterValueException( + "Maximum batch window in seconds must be greater than 0 if maximum batch size is greater than 10", + Type="User", + ) + + if (filter_criteria := request.get("FilterCriteria")) is not None: + for filter_ in filter_criteria.get("Filters", []): + pattern_str = filter_.get("Pattern") + if not pattern_str or not isinstance(pattern_str, str): + raise InvalidParameterValueException( + "Invalid filter pattern definition.", Type="User" + ) + + if not validate_event_pattern(pattern_str): + raise InvalidParameterValueException( + "Invalid filter pattern definition.", Type="User" + ) + + # Can either have a FunctionName (i.e CreateEventSourceMapping request) or + # an internal EventSourceMappingConfiguration representation + request_function_name = request.get("FunctionName") or request.get("FunctionArn") + # can be either a partial arn or a full arn for the version/alias + function_name, qualifier, account, region = function_locators_from_arn( + request_function_name + ) + # TODO: validate `context.region` vs. `region(request["FunctionName"])` vs. `region(request["EventSourceArn"])` + account = account or context.account_id + region = region or context.region + state = lambda_stores[account][region] + fn = state.functions.get(function_name) + if not fn: + raise InvalidParameterValueException("Function does not exist", Type="User") + + if qualifier: + # make sure the function version/alias exists + if api_utils.qualifier_is_alias(qualifier): + fn_alias = fn.aliases.get(qualifier) + if not fn_alias: + raise Exception("unknown alias") # TODO: cover via test + elif api_utils.qualifier_is_version(qualifier): + fn_version = fn.versions.get(qualifier) + if not fn_version: + raise Exception("unknown version") # TODO: cover via test + elif qualifier == "$LATEST": + pass + else: + raise Exception("invalid functionname") # TODO: cover via test + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account, region) + + else: + fn_arn = api_utils.unqualified_lambda_arn(function_name, account, region) + + function_version = get_function_version_from_arn(fn_arn) + function_role = function_version.config.role + + if source_arn := request.get("EventSourceArn"): + self.check_service_resource_exists(service, source_arn, fn_arn, function_role) + # Check we are validating a CreateEventSourceMapping request + if is_create_esm_request: + + def _get_mapping_sources(mapping: dict[str, Any]) -> list[str]: + if event_source_arn := mapping.get("EventSourceArn"): + return [event_source_arn] + return ( + mapping.get("SelfManagedEventSource", {}) + .get("Endpoints", {}) + .get("KAFKA_BOOTSTRAP_SERVERS", []) + ) + + # check for event source duplicates + # TODO: currently validated for sqs, kinesis, and dynamodb + service_id = load_service(service).service_id + for uuid, mapping in state.event_source_mappings.items(): + mapping_sources = _get_mapping_sources(mapping) + request_sources = _get_mapping_sources(request) + if mapping["FunctionArn"] == fn_arn and ( + set(mapping_sources).intersection(request_sources) + ): + if service == "sqs": + # *shakes fist at SQS* + raise ResourceConflictException( + f'An event source mapping with {service_id} arn (" {mapping["EventSourceArn"]} ") ' + f'and function (" {function_name} ") already exists. Please update or delete the ' + f"existing mapping with UUID {uuid}", + Type="User", + ) + elif service == "kafka": + if set(mapping["Topics"]).intersection(request["Topics"]): + raise ResourceConflictException( + f'An event source mapping with event source ("{",".join(request_sources)}"), ' + f'function ("{fn_arn}"), ' + f'topics ("{",".join(request["Topics"])}") already exists. Please update or delete the ' + f"existing mapping with UUID {uuid}", + Type="User", + ) + else: + raise ResourceConflictException( + f'The event source arn (" {mapping["EventSourceArn"]} ") and function ' + f'(" {function_name} ") provided mapping already exists. Please update or delete the ' + f"existing mapping with UUID {uuid}", + Type="User", + ) + return fn_arn, function_name, state, function_version, function_role + + @handler("UpdateEventSourceMapping", expand=False) + def update_event_source_mapping( + self, + context: RequestContext, + request: UpdateEventSourceMappingRequest, + ) -> EventSourceMappingConfiguration: + return self.update_event_source_mapping_v2(context, request) + + def update_event_source_mapping_v2( + self, + context: RequestContext, + request: UpdateEventSourceMappingRequest, + ) -> EventSourceMappingConfiguration: + # TODO: test and implement this properly (quite complex with many validations and limitations!) + LOG.warning( + "Updating Lambda Event Source Mapping is in experimental state and not yet fully tested." + ) + state = lambda_stores[context.account_id][context.region] + request_data = {**request} + uuid = request_data.pop("UUID", None) + if not uuid: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + old_event_source_mapping = state.event_source_mappings.get(uuid) + esm_worker = self.esm_workers.get(uuid) + if old_event_source_mapping is None or esm_worker is None: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) # TODO: test? + + # normalize values to overwrite + event_source_mapping = old_event_source_mapping | request_data + + temp_params = {} # values only set for the returned response, not saved internally (e.g. transient state) + + # Validate the newly updated ESM object. We ignore the output here since we only care whether an Exception is raised. + function_arn, _, _, function_version, function_role = self.validate_event_source_mapping( + context, event_source_mapping + ) + + # remove the FunctionName field + event_source_mapping.pop("FunctionName", None) + + if function_arn: + event_source_mapping["FunctionArn"] = function_arn + + # Only apply update if the desired state differs + enabled = request.get("Enabled") + if enabled is not None: + if enabled and old_event_source_mapping["State"] != EsmState.ENABLED: + event_source_mapping["State"] = EsmState.ENABLING + # TODO: What happens when trying to update during an update or failed state?! + elif not enabled and old_event_source_mapping["State"] == EsmState.ENABLED: + event_source_mapping["State"] = EsmState.DISABLING + else: + event_source_mapping["State"] = EsmState.UPDATING + + # To ensure parity, certain responses need to be immediately returned + temp_params["State"] = event_source_mapping["State"] + + state.event_source_mappings[uuid] = event_source_mapping + + # TODO: Currently, we re-create the entire ESM worker. Look into approach with better performance. + worker_factory = EsmWorkerFactory( + event_source_mapping, function_role, request.get("Enabled", esm_worker.enabled) + ) + + # Get a new ESM worker object but do not active it, since the factory holds all logic for creating new worker from configuration. + updated_esm_worker = worker_factory.get_esm_worker() + self.esm_workers[uuid] = updated_esm_worker + + # We should stop() the worker since the delete() will remove the ESM from the state mapping. + esm_worker.stop() + # This will either create an EsmWorker in the CREATING state if enabled. Otherwise, the DISABLING state is set. + updated_esm_worker.create() + + return {**event_source_mapping, **temp_params} + + def delete_event_source_mapping( + self, context: RequestContext, uuid: String, **kwargs + ) -> EventSourceMappingConfiguration: + state = lambda_stores[context.account_id][context.region] + event_source_mapping = state.event_source_mappings.get(uuid) + if not event_source_mapping: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + esm = state.event_source_mappings[uuid] + # TODO: add proper locking + esm_worker = self.esm_workers.pop(uuid, None) + # Asynchronous delete in v2 + if not esm_worker: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + esm_worker.delete() + return {**esm, "State": EsmState.DELETING} + + def get_event_source_mapping( + self, context: RequestContext, uuid: String, **kwargs + ) -> EventSourceMappingConfiguration: + state = lambda_stores[context.account_id][context.region] + event_source_mapping = state.event_source_mappings.get(uuid) + if not event_source_mapping: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + esm_worker = self.esm_workers.get(uuid) + if not esm_worker: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + event_source_mapping["State"] = esm_worker.current_state + event_source_mapping["StateTransitionReason"] = esm_worker.state_transition_reason + return event_source_mapping + + def list_event_source_mappings( + self, + context: RequestContext, + event_source_arn: Arn = None, + function_name: FunctionName = None, + marker: String = None, + max_items: MaxListItems = None, + **kwargs, + ) -> ListEventSourceMappingsResponse: + state = lambda_stores[context.account_id][context.region] + + esms = state.event_source_mappings.values() + # TODO: update and test State and StateTransitionReason for ESM v2 + + if event_source_arn: # TODO: validate pattern + esms = [e for e in esms if e.get("EventSourceArn") == event_source_arn] + + if function_name: + esms = [e for e in esms if function_name in e["FunctionArn"]] + + esms = PaginatedList(esms) + page, token = esms.get_page( + lambda x: x["UUID"], + marker, + max_items, + ) + return ListEventSourceMappingsResponse(EventSourceMappings=page, NextMarker=token) + + def get_source_type_from_request(self, request: dict[str, Any]) -> str: + if event_source_arn := request.get("EventSourceArn", ""): + service = extract_service_from_arn(event_source_arn) + if service == "sqs" and "fifo" in event_source_arn: + service = "sqs-fifo" + return service + elif request.get("SelfManagedEventSource"): + return "kafka" + + # ======================================= + # ============ FUNCTION URLS ============ + # ======================================= + + @staticmethod + def _validate_qualifier(qualifier: str) -> None: + if qualifier == "$LATEST" or (qualifier and api_utils.qualifier_is_version(qualifier)): + raise ValidationException( + f"1 validation error detected: Value '{qualifier}' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + ) + + @staticmethod + def _validate_invoke_mode(invoke_mode: str) -> None: + if invoke_mode and invoke_mode not in [InvokeMode.BUFFERED, InvokeMode.RESPONSE_STREAM]: + raise ValidationException( + f"1 validation error detected: Value '{invoke_mode}' at 'invokeMode' failed to satisfy constraint: Member must satisfy enum value set: [RESPONSE_STREAM, BUFFERED]" + ) + if invoke_mode == InvokeMode.RESPONSE_STREAM: + # TODO should we actually fail for setting RESPONSE_STREAM? + # It should trigger InvokeWithResponseStream which is not implemented + LOG.warning( + "The invokeMode 'RESPONSE_STREAM' is not yet supported on LocalStack. The property is only mocked, the execution will still be 'BUFFERED'" + ) + + # TODO: what happens if function state is not active? + def create_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + auth_type: FunctionUrlAuthType, + qualifier: FunctionUrlQualifier = None, + cors: Cors = None, + invoke_mode: InvokeMode = None, + **kwargs, + ) -> CreateFunctionUrlConfigResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + state = lambda_stores[account_id][region] + self._validate_qualifier(qualifier) + self._validate_invoke_mode(invoke_mode) + + fn = state.functions.get(function_name) + if fn is None: + raise ResourceNotFoundException("Function does not exist", Type="User") + + url_config = fn.function_url_configs.get(qualifier or "$LATEST") + if url_config: + raise ResourceConflictException( + f"Failed to create function url config for [functionArn = {url_config.function_arn}]. Error message: FunctionUrlConfig exists for this Lambda function", + Type="User", + ) + + if qualifier and qualifier != "$LATEST" and qualifier not in fn.aliases: + raise ResourceNotFoundException("Function does not exist", Type="User") + + normalized_qualifier = qualifier or "$LATEST" + + function_arn = ( + api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + if qualifier + else api_utils.unqualified_lambda_arn(function_name, account_id, region) + ) + + custom_id: str | None = None + + tags = self._get_tags(api_utils.unqualified_lambda_arn(function_name, account_id, region)) + if TAG_KEY_CUSTOM_URL in tags: + # Note: I really wanted to add verification here that the + # url_id is unique, so we could surface that to the user ASAP. + # However, it seems like that information isn't available yet, + # since (as far as I can tell) we call + # self.router.register_routes() once, in a single shot, for all + # of the routes -- and we need to verify that it's unique not + # just for this particular lambda function, but for the entire + # lambda provider. Therefore... that idea proved non-trivial! + custom_id_tag_value = ( + f"{tags[TAG_KEY_CUSTOM_URL]}-{qualifier}" if qualifier else tags[TAG_KEY_CUSTOM_URL] + ) + if TAG_KEY_CUSTOM_URL_VALIDATOR.match(custom_id_tag_value): + custom_id = custom_id_tag_value + + else: + # Note: we're logging here instead of raising to prioritize + # strict parity with AWS over the localstack-only custom_id + LOG.warning( + "Invalid custom ID tag value for lambda URL (%s=%s). " + "Replaced with default (random id)", + TAG_KEY_CUSTOM_URL, + custom_id_tag_value, + ) + + # The url_id is the subdomain used for the URL we're creating. This + # is either created randomly (as in AWS), or can be passed as a tag + # to the lambda itself (localstack-only). + url_id: str + if custom_id is None: + url_id = api_utils.generate_random_url_id() + else: + url_id = custom_id + + host_definition = localstack_host(custom_port=config.GATEWAY_LISTEN[0].port) + fn.function_url_configs[normalized_qualifier] = FunctionUrlConfig( + function_arn=function_arn, + function_name=function_name, + cors=cors, + url_id=url_id, + url=f"http://{url_id}.lambda-url.{context.region}.{host_definition.host_and_port()}/", # TODO: https support + auth_type=auth_type, + creation_time=api_utils.generate_lambda_date(), + last_modified_time=api_utils.generate_lambda_date(), + invoke_mode=invoke_mode, + ) + + # persist and start URL + # TODO: implement URL invoke + api_url_config = api_utils.map_function_url_config( + fn.function_url_configs[normalized_qualifier] + ) + + return CreateFunctionUrlConfigResponse( + FunctionUrl=api_url_config["FunctionUrl"], + FunctionArn=api_url_config["FunctionArn"], + AuthType=api_url_config["AuthType"], + Cors=api_url_config["Cors"], + CreationTime=api_url_config["CreationTime"], + InvokeMode=api_url_config["InvokeMode"], + ) + + def get_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: FunctionUrlQualifier = None, + **kwargs, + ) -> GetFunctionUrlConfigResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + + fn_name, qualifier = api_utils.get_name_and_qualifier(function_name, qualifier, context) + + self._validate_qualifier(qualifier) + + resolved_fn = state.functions.get(fn_name) + if not resolved_fn: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + qualifier = qualifier or "$LATEST" + url_config = resolved_fn.function_url_configs.get(qualifier) + if not url_config: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + return api_utils.map_function_url_config(url_config) + + def update_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: FunctionUrlQualifier = None, + auth_type: FunctionUrlAuthType = None, + cors: Cors = None, + invoke_mode: InvokeMode = None, + **kwargs, + ) -> UpdateFunctionUrlConfigResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + self._validate_qualifier(qualifier) + self._validate_invoke_mode(invoke_mode) + + fn = state.functions.get(function_name) + if not fn: + raise ResourceNotFoundException("Function does not exist", Type="User") + + normalized_qualifier = qualifier or "$LATEST" + + if ( + api_utils.qualifier_is_alias(normalized_qualifier) + and normalized_qualifier not in fn.aliases + ): + raise ResourceNotFoundException("Function does not exist", Type="User") + + url_config = fn.function_url_configs.get(normalized_qualifier) + if not url_config: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + changes = { + "last_modified_time": api_utils.generate_lambda_date(), + **({"cors": cors} if cors is not None else {}), + **({"auth_type": auth_type} if auth_type is not None else {}), + } + + if invoke_mode: + changes["invoke_mode"] = invoke_mode + + new_url_config = dataclasses.replace(url_config, **changes) + fn.function_url_configs[normalized_qualifier] = new_url_config + + return UpdateFunctionUrlConfigResponse( + FunctionUrl=new_url_config.url, + FunctionArn=new_url_config.function_arn, + AuthType=new_url_config.auth_type, + Cors=new_url_config.cors, + CreationTime=new_url_config.creation_time, + LastModifiedTime=new_url_config.last_modified_time, + InvokeMode=new_url_config.invoke_mode, + ) + + def delete_function_url_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: FunctionUrlQualifier = None, + **kwargs, + ) -> None: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + self._validate_qualifier(qualifier) + + resolved_fn = state.functions.get(function_name) + if not resolved_fn: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + qualifier = qualifier or "$LATEST" + url_config = resolved_fn.function_url_configs.get(qualifier) + if not url_config: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + del resolved_fn.function_url_configs[qualifier] + + def list_function_url_configs( + self, + context: RequestContext, + function_name: FunctionName, + marker: String = None, + max_items: MaxItems = None, + **kwargs, + ) -> ListFunctionUrlConfigsResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + + fn_name = api_utils.get_function_name(function_name, context) + resolved_fn = state.functions.get(fn_name) + if not resolved_fn: + raise ResourceNotFoundException("Function does not exist", Type="User") + + url_configs = [ + api_utils.map_function_url_config(fn_conf) + for fn_conf in resolved_fn.function_url_configs.values() + ] + url_configs = PaginatedList(url_configs) + page, token = url_configs.get_page( + lambda url_config: url_config["FunctionArn"], + marker, + max_items, + ) + url_configs = page + return ListFunctionUrlConfigsResponse(FunctionUrlConfigs=url_configs, NextMarker=token) + + # ======================================= + # ============ Permissions ============ + # ======================================= + + @handler("AddPermission", expand=False) + def add_permission( + self, + context: RequestContext, + request: AddPermissionRequest, + ) -> AddPermissionResponse: + function_name, qualifier = api_utils.get_name_and_qualifier( + request.get("FunctionName"), request.get("Qualifier"), context + ) + + # validate qualifier + if qualifier is not None: + self._validate_qualifier_expression(qualifier) + if qualifier == "$LATEST": + raise InvalidParameterValueException( + "We currently do not support adding policies for $LATEST.", Type="User" + ) + account_id, region = api_utils.get_account_and_region(request.get("FunctionName"), context) + + resolved_fn = self._get_function(function_name, account_id, region) + resolved_qualifier, fn_arn = self._resolve_fn_qualifier(resolved_fn, qualifier) + + revision_id = request.get("RevisionId") + if revision_id: + fn_revision_id = self._function_revision_id(resolved_fn, resolved_qualifier) + if revision_id != fn_revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. " + "Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + Type="User", + ) + + request_sid = request["StatementId"] + if not bool(STATEMENT_ID_REGEX.match(request_sid)): + raise ValidationException( + f"1 validation error detected: Value '{request_sid}' at 'statementId' failed to satisfy constraint: Member must satisfy regular expression pattern: ([a-zA-Z0-9-_]+)" + ) + # check for an already existing policy and any conflicts in existing statements + existing_policy = resolved_fn.permissions.get(resolved_qualifier) + if existing_policy: + if request_sid in [s["Sid"] for s in existing_policy.policy.Statement]: + # uniqueness scope: statement id needs to be unique per qualified function ($LATEST, version, or alias) + # Counterexample: the same sid can exist within $LATEST, version, and alias + raise ResourceConflictException( + f"The statement id ({request_sid}) provided already exists. Please provide a new statement id, or remove the existing statement.", + Type="User", + ) + + permission_statement = api_utils.build_statement( + partition=context.partition, + resource_arn=fn_arn, + statement_id=request["StatementId"], + action=request["Action"], + principal=request["Principal"], + source_arn=request.get("SourceArn"), + source_account=request.get("SourceAccount"), + principal_org_id=request.get("PrincipalOrgID"), + event_source_token=request.get("EventSourceToken"), + auth_type=request.get("FunctionUrlAuthType"), + ) + new_policy = existing_policy + if not existing_policy: + new_policy = FunctionResourcePolicy( + policy=ResourcePolicy(Version="2012-10-17", Id="default", Statement=[]) + ) + new_policy.policy.Statement.append(permission_statement) + if not existing_policy: + resolved_fn.permissions[resolved_qualifier] = new_policy + + # Update revision id of alias or version + # TODO: re-evaluate data model to prevent this dirty hack just for bumping the revision id + # TODO: does that need a `with function.lock` for atomic updates of the policy + revision_id? + if api_utils.qualifier_is_alias(resolved_qualifier): + resolved_alias = resolved_fn.aliases[resolved_qualifier] + resolved_fn.aliases[resolved_qualifier] = dataclasses.replace(resolved_alias) + # Assumes that a non-alias is a version + else: + resolved_version = resolved_fn.versions[resolved_qualifier] + resolved_fn.versions[resolved_qualifier] = dataclasses.replace( + resolved_version, config=dataclasses.replace(resolved_version.config) + ) + return AddPermissionResponse(Statement=json.dumps(permission_statement)) + + def remove_permission( + self, + context: RequestContext, + function_name: FunctionName, + statement_id: NamespacedStatementId, + qualifier: Qualifier = None, + revision_id: String = None, + **kwargs, + ) -> None: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + if qualifier is not None: + self._validate_qualifier_expression(qualifier) + + state = lambda_stores[account_id][region] + resolved_fn = state.functions.get(function_name) + if resolved_fn is None: + fn_arn = api_utils.unqualified_lambda_arn(function_name, account_id, region) + raise ResourceNotFoundException(f"No policy found for: {fn_arn}", Type="User") + + resolved_qualifier, _ = self._resolve_fn_qualifier(resolved_fn, qualifier) + function_permission = resolved_fn.permissions.get(resolved_qualifier) + if not function_permission: + raise ResourceNotFoundException( + "No policy is associated with the given resource.", Type="User" + ) + + # try to find statement in policy and delete it + statement = None + for s in function_permission.policy.Statement: + if s["Sid"] == statement_id: + statement = s + break + + if not statement: + raise ResourceNotFoundException( + f"Statement {statement_id} is not found in resource policy.", Type="User" + ) + fn_revision_id = self._function_revision_id(resolved_fn, resolved_qualifier) + if revision_id and revision_id != fn_revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. " + "Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + Type="User", + ) + function_permission.policy.Statement.remove(statement) + + # Update revision id for alias or version + # TODO: re-evaluate data model to prevent this dirty hack just for bumping the revision id + # TODO: does that need a `with function.lock` for atomic updates of the policy + revision_id? + if api_utils.qualifier_is_alias(resolved_qualifier): + resolved_alias = resolved_fn.aliases[resolved_qualifier] + resolved_fn.aliases[resolved_qualifier] = dataclasses.replace(resolved_alias) + # Assumes that a non-alias is a version + else: + resolved_version = resolved_fn.versions[resolved_qualifier] + resolved_fn.versions[resolved_qualifier] = dataclasses.replace( + resolved_version, config=dataclasses.replace(resolved_version.config) + ) + + # remove the policy as a whole when there's no statement left in it + if len(function_permission.policy.Statement) == 0: + del resolved_fn.permissions[resolved_qualifier] + + def get_policy( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + qualifier: Qualifier = None, + **kwargs, + ) -> GetPolicyResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + + if qualifier is not None: + self._validate_qualifier_expression(qualifier) + + resolved_fn = self._get_function(function_name, account_id, region) + + resolved_qualifier = qualifier or "$LATEST" + function_permission = resolved_fn.permissions.get(resolved_qualifier) + if not function_permission: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + fn_revision_id = None + if api_utils.qualifier_is_alias(resolved_qualifier): + resolved_alias = resolved_fn.aliases[resolved_qualifier] + fn_revision_id = resolved_alias.revision_id + # Assumes that a non-alias is a version + else: + resolved_version = resolved_fn.versions[resolved_qualifier] + fn_revision_id = resolved_version.config.revision_id + + return GetPolicyResponse( + Policy=json.dumps(dataclasses.asdict(function_permission.policy)), + RevisionId=fn_revision_id, + ) + + # ======================================= + # ======== Code signing config ======== + # ======================================= + + def create_code_signing_config( + self, + context: RequestContext, + allowed_publishers: AllowedPublishers, + description: Description = None, + code_signing_policies: CodeSigningPolicies = None, + tags: Tags = None, + **kwargs, + ) -> CreateCodeSigningConfigResponse: + account = context.account_id + region = context.region + + state = lambda_stores[account][region] + # TODO: can there be duplicates? + csc_id = f"csc-{get_random_hex(17)}" # e.g. 'csc-077c33b4c19e26036' + csc_arn = f"arn:{context.partition}:lambda:{region}:{account}:code-signing-config:{csc_id}" + csc = CodeSigningConfig( + csc_id=csc_id, + arn=csc_arn, + allowed_publishers=allowed_publishers, + policies=code_signing_policies, + last_modified=api_utils.generate_lambda_date(), + description=description, + ) + state.code_signing_configs[csc_arn] = csc + return CreateCodeSigningConfigResponse(CodeSigningConfig=api_utils.map_csc(csc)) + + def put_function_code_signing_config( + self, + context: RequestContext, + code_signing_config_arn: CodeSigningConfigArn, + function_name: FunctionName, + **kwargs, + ) -> PutFunctionCodeSigningConfigResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + function_name = api_utils.get_function_name(function_name, context) + + csc = state.code_signing_configs.get(code_signing_config_arn) + if not csc: + raise CodeSigningConfigNotFoundException( + f"The code signing configuration cannot be found. Check that the provided configuration is not deleted: {code_signing_config_arn}.", + Type="User", + ) + + fn = state.functions.get(function_name) + fn_arn = api_utils.unqualified_lambda_arn(function_name, account_id, region) + if not fn: + raise ResourceNotFoundException(f"Function not found: {fn_arn}", Type="User") + + fn.code_signing_config_arn = code_signing_config_arn + return PutFunctionCodeSigningConfigResponse( + CodeSigningConfigArn=code_signing_config_arn, FunctionName=function_name + ) + + def update_code_signing_config( + self, + context: RequestContext, + code_signing_config_arn: CodeSigningConfigArn, + description: Description = None, + allowed_publishers: AllowedPublishers = None, + code_signing_policies: CodeSigningPolicies = None, + **kwargs, + ) -> UpdateCodeSigningConfigResponse: + state = lambda_stores[context.account_id][context.region] + csc = state.code_signing_configs.get(code_signing_config_arn) + if not csc: + raise ResourceNotFoundException( + f"The Lambda code signing configuration {code_signing_config_arn} can not be found." + ) + + changes = { + **( + {"allowed_publishers": allowed_publishers} if allowed_publishers is not None else {} + ), + **({"policies": code_signing_policies} if code_signing_policies is not None else {}), + **({"description": description} if description is not None else {}), + } + new_csc = dataclasses.replace( + csc, last_modified=api_utils.generate_lambda_date(), **changes + ) + state.code_signing_configs[code_signing_config_arn] = new_csc + + return UpdateCodeSigningConfigResponse(CodeSigningConfig=api_utils.map_csc(new_csc)) + + def get_code_signing_config( + self, context: RequestContext, code_signing_config_arn: CodeSigningConfigArn, **kwargs + ) -> GetCodeSigningConfigResponse: + state = lambda_stores[context.account_id][context.region] + csc = state.code_signing_configs.get(code_signing_config_arn) + if not csc: + raise ResourceNotFoundException( + f"The Lambda code signing configuration {code_signing_config_arn} can not be found." + ) + + return GetCodeSigningConfigResponse(CodeSigningConfig=api_utils.map_csc(csc)) + + def get_function_code_signing_config( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> GetFunctionCodeSigningConfigResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + function_name = api_utils.get_function_name(function_name, context) + fn = state.functions.get(function_name) + fn_arn = api_utils.unqualified_lambda_arn(function_name, account_id, region) + if not fn: + raise ResourceNotFoundException(f"Function not found: {fn_arn}", Type="User") + + if fn.code_signing_config_arn: + return GetFunctionCodeSigningConfigResponse( + CodeSigningConfigArn=fn.code_signing_config_arn, FunctionName=function_name + ) + + return GetFunctionCodeSigningConfigResponse() + + def delete_function_code_signing_config( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> None: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + function_name = api_utils.get_function_name(function_name, context) + fn = state.functions.get(function_name) + fn_arn = api_utils.unqualified_lambda_arn(function_name, account_id, region) + if not fn: + raise ResourceNotFoundException(f"Function not found: {fn_arn}", Type="User") + + fn.code_signing_config_arn = None + + def delete_code_signing_config( + self, context: RequestContext, code_signing_config_arn: CodeSigningConfigArn, **kwargs + ) -> DeleteCodeSigningConfigResponse: + state = lambda_stores[context.account_id][context.region] + + csc = state.code_signing_configs.get(code_signing_config_arn) + if not csc: + raise ResourceNotFoundException( + f"The Lambda code signing configuration {code_signing_config_arn} can not be found." + ) + + del state.code_signing_configs[code_signing_config_arn] + + return DeleteCodeSigningConfigResponse() + + def list_code_signing_configs( + self, + context: RequestContext, + marker: String = None, + max_items: MaxListItems = None, + **kwargs, + ) -> ListCodeSigningConfigsResponse: + state = lambda_stores[context.account_id][context.region] + + cscs = [api_utils.map_csc(csc) for csc in state.code_signing_configs.values()] + cscs = PaginatedList(cscs) + page, token = cscs.get_page( + lambda csc: csc["CodeSigningConfigId"], + marker, + max_items, + ) + return ListCodeSigningConfigsResponse(CodeSigningConfigs=page, NextMarker=token) + + def list_functions_by_code_signing_config( + self, + context: RequestContext, + code_signing_config_arn: CodeSigningConfigArn, + marker: String = None, + max_items: MaxListItems = None, + **kwargs, + ) -> ListFunctionsByCodeSigningConfigResponse: + account = context.account_id + region = context.region + + state = lambda_stores[account][region] + + if code_signing_config_arn not in state.code_signing_configs: + raise ResourceNotFoundException( + f"The Lambda code signing configuration {code_signing_config_arn} can not be found." + ) + + fn_arns = [ + api_utils.unqualified_lambda_arn(fn.function_name, account, region) + for fn in state.functions.values() + if fn.code_signing_config_arn == code_signing_config_arn + ] + + cscs = PaginatedList(fn_arns) + page, token = cscs.get_page( + lambda x: x, + marker, + max_items, + ) + return ListFunctionsByCodeSigningConfigResponse(FunctionArns=page, NextMarker=token) + + # ======================================= + # ========= Account Settings ========= + # ======================================= + + # CAVE: these settings & usages are *per* region! + # Lambda quotas: https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html + def get_account_settings(self, context: RequestContext, **kwargs) -> GetAccountSettingsResponse: + state = lambda_stores[context.account_id][context.region] + + fn_count = 0 + code_size_sum = 0 + reserved_concurrency_sum = 0 + for fn in state.functions.values(): + fn_count += 1 + for fn_version in fn.versions.values(): + # Image-based Lambdas do not have a code attribute and count against the ECR quotas instead + if fn_version.config.package_type == PackageType.Zip: + code_size_sum += fn_version.config.code.code_size + if fn.reserved_concurrent_executions is not None: + reserved_concurrency_sum += fn.reserved_concurrent_executions + for c in fn.provisioned_concurrency_configs.values(): + reserved_concurrency_sum += c.provisioned_concurrent_executions + for layer in state.layers.values(): + for layer_version in layer.layer_versions.values(): + code_size_sum += layer_version.code.code_size + return GetAccountSettingsResponse( + AccountLimit=AccountLimit( + TotalCodeSize=config.LAMBDA_LIMITS_TOTAL_CODE_SIZE, + CodeSizeZipped=config.LAMBDA_LIMITS_CODE_SIZE_ZIPPED, + CodeSizeUnzipped=config.LAMBDA_LIMITS_CODE_SIZE_UNZIPPED, + ConcurrentExecutions=config.LAMBDA_LIMITS_CONCURRENT_EXECUTIONS, + UnreservedConcurrentExecutions=config.LAMBDA_LIMITS_CONCURRENT_EXECUTIONS + - reserved_concurrency_sum, + ), + AccountUsage=AccountUsage( + TotalCodeSize=code_size_sum, + FunctionCount=fn_count, + ), + ) + + # ======================================= + # == Provisioned Concurrency Config == + # ======================================= + + def _get_provisioned_config( + self, context: RequestContext, function_name: str, qualifier: str + ) -> ProvisionedConcurrencyConfiguration | None: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + function_name = api_utils.get_function_name(function_name, context) + fn = state.functions.get(function_name) + if api_utils.qualifier_is_alias(qualifier): + fn_alias = None + if fn: + fn_alias = fn.aliases.get(qualifier) + if fn_alias is None: + raise ResourceNotFoundException( + f"Cannot find alias arn: {api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region)}", + Type="User", + ) + elif api_utils.qualifier_is_version(qualifier): + fn_version = None + if fn: + fn_version = fn.versions.get(qualifier) + if fn_version is None: + raise ResourceNotFoundException( + f"Function not found: {api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region)}", + Type="User", + ) + + return fn.provisioned_concurrency_configs.get(qualifier) + + def put_provisioned_concurrency_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier, + provisioned_concurrent_executions: PositiveInteger, + **kwargs, + ) -> PutProvisionedConcurrencyConfigResponse: + if provisioned_concurrent_executions <= 0: + raise ValidationException( + f"1 validation error detected: Value '{provisioned_concurrent_executions}' at 'provisionedConcurrentExecutions' failed to satisfy constraint: Member must have value greater than or equal to 1" + ) + + if qualifier == "$LATEST": + raise InvalidParameterValueException( + "Provisioned Concurrency Configs cannot be applied to unpublished function versions.", + Type="User", + ) + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + state = lambda_stores[account_id][region] + fn = state.functions.get(function_name) + + provisioned_config = self._get_provisioned_config(context, function_name, qualifier) + + if provisioned_config: # TODO: merge? + # TODO: add a test for partial updates (if possible) + LOG.warning( + "Partial update of provisioned concurrency config is currently not supported." + ) + + other_provisioned_sum = sum( + [ + provisioned_configs.provisioned_concurrent_executions + for provisioned_qualifier, provisioned_configs in fn.provisioned_concurrency_configs.items() + if provisioned_qualifier != qualifier + ] + ) + + if ( + fn.reserved_concurrent_executions is not None + and fn.reserved_concurrent_executions + < other_provisioned_sum + provisioned_concurrent_executions + ): + raise InvalidParameterValueException( + "Requested Provisioned Concurrency should not be greater than the reservedConcurrentExecution for function", + Type="User", + ) + + if provisioned_concurrent_executions > config.LAMBDA_LIMITS_CONCURRENT_EXECUTIONS: + raise InvalidParameterValueException( + f"Specified ConcurrentExecutions for function is greater than account's unreserved concurrency" + f" [{config.LAMBDA_LIMITS_CONCURRENT_EXECUTIONS}]." + ) + + settings = self.get_account_settings(context) + unreserved_concurrent_executions = settings["AccountLimit"][ + "UnreservedConcurrentExecutions" + ] + if ( + unreserved_concurrent_executions - provisioned_concurrent_executions + < config.LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY + ): + raise InvalidParameterValueException( + f"Specified ConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below" + f" its minimum value of [{config.LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY}]." + ) + + provisioned_config = ProvisionedConcurrencyConfiguration( + provisioned_concurrent_executions, api_utils.generate_lambda_date() + ) + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + + if api_utils.qualifier_is_alias(qualifier): + alias = fn.aliases.get(qualifier) + resolved_version = fn.versions.get(alias.function_version) + + if ( + resolved_version + and fn.provisioned_concurrency_configs.get(alias.function_version) is not None + ): + raise ResourceConflictException( + "Alias can't be used for Provisioned Concurrency configuration on an already Provisioned version", + Type="User", + ) + fn_arn = resolved_version.id.qualified_arn() + elif api_utils.qualifier_is_version(qualifier): + fn_version = fn.versions.get(qualifier) + + # TODO: might be useful other places, utilize + pointing_aliases = [] + for alias in fn.aliases.values(): + if ( + alias.function_version == qualifier + and fn.provisioned_concurrency_configs.get(alias.name) is not None + ): + pointing_aliases.append(alias.name) + if pointing_aliases: + raise ResourceConflictException( + "Version is pointed by a Provisioned Concurrency alias", Type="User" + ) + + fn_arn = fn_version.id.qualified_arn() + + manager = self.lambda_service.get_lambda_version_manager(fn_arn) + + fn.provisioned_concurrency_configs[qualifier] = provisioned_config + + manager.update_provisioned_concurrency_config( + provisioned_config.provisioned_concurrent_executions + ) + + return PutProvisionedConcurrencyConfigResponse( + RequestedProvisionedConcurrentExecutions=provisioned_config.provisioned_concurrent_executions, + AvailableProvisionedConcurrentExecutions=0, + AllocatedProvisionedConcurrentExecutions=0, + Status=ProvisionedConcurrencyStatusEnum.IN_PROGRESS, + # StatusReason=manager.provisioned_state.status_reason, + LastModified=provisioned_config.last_modified, # TODO: does change with configuration or also with state changes? + ) + + def get_provisioned_concurrency_config( + self, context: RequestContext, function_name: FunctionName, qualifier: Qualifier, **kwargs + ) -> GetProvisionedConcurrencyConfigResponse: + if qualifier == "$LATEST": + raise InvalidParameterValueException( + "The function resource provided must be an alias or a published version.", + Type="User", + ) + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + + provisioned_config = self._get_provisioned_config(context, function_name, qualifier) + if not provisioned_config: + raise ProvisionedConcurrencyConfigNotFoundException( + "No Provisioned Concurrency Config found for this function", Type="User" + ) + + # TODO: make this compatible with alias pointer migration on update + if api_utils.qualifier_is_alias(qualifier): + state = lambda_stores[account_id][region] + fn = state.functions.get(function_name) + alias = fn.aliases.get(qualifier) + fn_arn = api_utils.qualified_lambda_arn( + function_name, alias.function_version, account_id, region + ) + else: + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + + ver_manager = self.lambda_service.get_lambda_version_manager(fn_arn) + + return GetProvisionedConcurrencyConfigResponse( + RequestedProvisionedConcurrentExecutions=provisioned_config.provisioned_concurrent_executions, + LastModified=provisioned_config.last_modified, + AvailableProvisionedConcurrentExecutions=ver_manager.provisioned_state.available, + AllocatedProvisionedConcurrentExecutions=ver_manager.provisioned_state.allocated, + Status=ver_manager.provisioned_state.status, + StatusReason=ver_manager.provisioned_state.status_reason, + ) + + def list_provisioned_concurrency_configs( + self, + context: RequestContext, + function_name: FunctionName, + marker: String = None, + max_items: MaxProvisionedConcurrencyConfigListItems = None, + **kwargs, + ) -> ListProvisionedConcurrencyConfigsResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + + function_name = api_utils.get_function_name(function_name, context) + fn = state.functions.get(function_name) + if fn is None: + raise ResourceNotFoundException( + f"Function not found: {api_utils.unqualified_lambda_arn(function_name, account_id, region)}", + Type="User", + ) + + configs = [] + for qualifier, pc_config in fn.provisioned_concurrency_configs.items(): + if api_utils.qualifier_is_alias(qualifier): + alias = fn.aliases.get(qualifier) + fn_arn = api_utils.qualified_lambda_arn( + function_name, alias.function_version, account_id, region + ) + else: + fn_arn = api_utils.qualified_lambda_arn( + function_name, qualifier, account_id, region + ) + + manager = self.lambda_service.get_lambda_version_manager(fn_arn) + + configs.append( + ProvisionedConcurrencyConfigListItem( + FunctionArn=api_utils.qualified_lambda_arn( + function_name, qualifier, account_id, region + ), + RequestedProvisionedConcurrentExecutions=pc_config.provisioned_concurrent_executions, + AvailableProvisionedConcurrentExecutions=manager.provisioned_state.available, + AllocatedProvisionedConcurrentExecutions=manager.provisioned_state.allocated, + Status=manager.provisioned_state.status, + StatusReason=manager.provisioned_state.status_reason, + LastModified=pc_config.last_modified, + ) + ) + + provisioned_concurrency_configs = configs + provisioned_concurrency_configs = PaginatedList(provisioned_concurrency_configs) + page, token = provisioned_concurrency_configs.get_page( + lambda x: x, + marker, + max_items, + ) + return ListProvisionedConcurrencyConfigsResponse( + ProvisionedConcurrencyConfigs=page, NextMarker=token + ) + + def delete_provisioned_concurrency_config( + self, context: RequestContext, function_name: FunctionName, qualifier: Qualifier, **kwargs + ) -> None: + if qualifier == "$LATEST": + raise InvalidParameterValueException( + "The function resource provided must be an alias or a published version.", + Type="User", + ) + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + state = lambda_stores[account_id][region] + fn = state.functions.get(function_name) + + provisioned_config = self._get_provisioned_config(context, function_name, qualifier) + # delete is idempotent and doesn't actually care about the provisioned concurrency config not existing + if provisioned_config: + fn.provisioned_concurrency_configs.pop(qualifier) + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + manager = self.lambda_service.get_lambda_version_manager(fn_arn) + manager.update_provisioned_concurrency_config(0) + + # ======================================= + # ======= Event Invoke Config ======== + # ======================================= + + # "1 validation error detected: Value 'arn:aws:_-/!lambda::111111111111:function:' at 'destinationConfig.onFailure.destination' failed to satisfy constraint: Member must satisfy regular expression pattern: ^$|arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\\-])+:([a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\\d{1})?:(\\d{12})?:(.*)" + # "1 validation error detected: Value 'arn:aws:_-/!lambda::111111111111:function:' at 'destinationConfig.onFailure.destination' failed to satisfy constraint: Member must satisfy regular expression pattern: ^$|arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\\-])+:([a-z]2((-gov)|(-iso(b?)))?-[a-z]+-\\d1)?:(\\d12)?:(.*)" ... (expected β†’ actual) + + def _validate_destination_config( + self, store: LambdaStore, function_name: str, destination_config: DestinationConfig + ): + def _validate_destination_arn(destination_arn) -> bool: + if not api_utils.DESTINATION_ARN_PATTERN.match(destination_arn): + # technically we shouldn't handle this in the provider + raise ValidationException( + "1 validation error detected: Value '" + + destination_arn + + r"' at 'destinationConfig.onFailure.destination' failed to satisfy constraint: Member must satisfy regular expression pattern: ^$|arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\-])+:([a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\d{1})?:(\d{12})?:(.*)" + ) + + match destination_arn.split(":")[2]: + case "lambda": + fn_parts = api_utils.FULL_FN_ARN_PATTERN.search(destination_arn).groupdict() + if fn_parts: + # check if it exists + fn = store.functions.get(fn_parts["function_name"]) + if not fn: + raise InvalidParameterValueException( + f"The destination ARN {destination_arn} is invalid.", Type="User" + ) + if fn_parts["function_name"] == function_name: + raise InvalidParameterValueException( + "You can't specify the function as a destination for itself.", + Type="User", + ) + case "sns" | "sqs" | "events": + pass + case _: + return False + return True + + validation_err = False + + failure_destination = destination_config.get("OnFailure", {}).get("Destination") + if failure_destination: + validation_err = validation_err or not _validate_destination_arn(failure_destination) + + success_destination = destination_config.get("OnSuccess", {}).get("Destination") + if success_destination: + validation_err = validation_err or not _validate_destination_arn(success_destination) + + if validation_err: + on_success_part = ( + f"OnSuccess(destination={success_destination})" if success_destination else "null" + ) + on_failure_part = ( + f"OnFailure(destination={failure_destination})" if failure_destination else "null" + ) + raise InvalidParameterValueException( + f"The provided destination config DestinationConfig(onSuccess={on_success_part}, onFailure={on_failure_part}) is invalid.", + Type="User", + ) + + def put_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier = None, + maximum_retry_attempts: MaximumRetryAttempts = None, + maximum_event_age_in_seconds: MaximumEventAgeInSeconds = None, + destination_config: DestinationConfig = None, + **kwargs, + ) -> FunctionEventInvokeConfig: + """ + Destination ARNs can be: + * SQS arn + * SNS arn + * Lambda arn + * EventBridge arn + + Differences between put_ and update_: + * put overwrites any existing config + * update allows changes only single values while keeping the rest of existing ones + * update fails on non-existing configs + + Differences between destination and DLQ + * "However, a dead-letter queue is part of a function's version-specific configuration, so it is locked in when you publish a version." + * "On-failure destinations also support additional targets and include details about the function's response in the invocation record." + + """ + if ( + maximum_event_age_in_seconds is None + and maximum_retry_attempts is None + and destination_config is None + ): + raise InvalidParameterValueException( + "You must specify at least one of error handling or destination setting.", + Type="User", + ) + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + fn = state.functions.get(function_name) + if not fn or (qualifier and not (qualifier in fn.aliases or qualifier in fn.versions)): + raise ResourceNotFoundException("The function doesn't exist.", Type="User") + + qualifier = qualifier or "$LATEST" + + # validate and normalize destination config + if destination_config: + self._validate_destination_config(state, function_name, destination_config) + + destination_config = DestinationConfig( + OnSuccess=OnSuccess( + Destination=(destination_config or {}).get("OnSuccess", {}).get("Destination") + ), + OnFailure=OnFailure( + Destination=(destination_config or {}).get("OnFailure", {}).get("Destination") + ), + ) + + config = EventInvokeConfig( + function_name=function_name, + qualifier=qualifier, + maximum_event_age_in_seconds=maximum_event_age_in_seconds, + maximum_retry_attempts=maximum_retry_attempts, + last_modified=api_utils.generate_lambda_date(), + destination_config=destination_config, + ) + fn.event_invoke_configs[qualifier] = config + + return FunctionEventInvokeConfig( + LastModified=datetime.datetime.strptime( + config.last_modified, api_utils.LAMBDA_DATE_FORMAT + ), + FunctionArn=api_utils.qualified_lambda_arn( + function_name, qualifier or "$LATEST", account_id, region + ), + DestinationConfig=destination_config, + MaximumEventAgeInSeconds=maximum_event_age_in_seconds, + MaximumRetryAttempts=maximum_retry_attempts, + ) + + def get_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier = None, + **kwargs, + ) -> FunctionEventInvokeConfig: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + + qualifier = qualifier or "$LATEST" + fn = state.functions.get(function_name) + if not fn: + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + raise ResourceNotFoundException( + f"The function {fn_arn} doesn't have an EventInvokeConfig", Type="User" + ) + + config = fn.event_invoke_configs.get(qualifier) + if not config: + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + raise ResourceNotFoundException( + f"The function {fn_arn} doesn't have an EventInvokeConfig", Type="User" + ) + + return FunctionEventInvokeConfig( + LastModified=datetime.datetime.strptime( + config.last_modified, api_utils.LAMBDA_DATE_FORMAT + ), + FunctionArn=api_utils.qualified_lambda_arn( + function_name, qualifier, account_id, region + ), + DestinationConfig=config.destination_config, + MaximumEventAgeInSeconds=config.maximum_event_age_in_seconds, + MaximumRetryAttempts=config.maximum_retry_attempts, + ) + + def list_function_event_invoke_configs( + self, + context: RequestContext, + function_name: FunctionName, + marker: String = None, + max_items: MaxFunctionEventInvokeConfigListItems = None, + **kwargs, + ) -> ListFunctionEventInvokeConfigsResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + fn = state.functions.get(function_name) + if not fn: + raise ResourceNotFoundException("The function doesn't exist.", Type="User") + + event_invoke_configs = [ + FunctionEventInvokeConfig( + LastModified=c.last_modified, + FunctionArn=api_utils.qualified_lambda_arn( + function_name, c.qualifier, account_id, region + ), + MaximumEventAgeInSeconds=c.maximum_event_age_in_seconds, + MaximumRetryAttempts=c.maximum_retry_attempts, + DestinationConfig=c.destination_config, + ) + for c in fn.event_invoke_configs.values() + ] + + event_invoke_configs = PaginatedList(event_invoke_configs) + page, token = event_invoke_configs.get_page( + lambda x: x["FunctionArn"], + marker, + max_items, + ) + return ListFunctionEventInvokeConfigsResponse( + FunctionEventInvokeConfigs=page, NextMarker=token + ) + + def delete_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier = None, + **kwargs, + ) -> None: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + state = lambda_stores[account_id][region] + fn = state.functions.get(function_name) + resolved_qualifier = qualifier or "$LATEST" + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + if not fn: + raise ResourceNotFoundException( + f"The function {fn_arn} doesn't have an EventInvokeConfig", Type="User" + ) + + config = fn.event_invoke_configs.get(resolved_qualifier) + if not config: + raise ResourceNotFoundException( + f"The function {fn_arn} doesn't have an EventInvokeConfig", Type="User" + ) + + del fn.event_invoke_configs[resolved_qualifier] + + def update_function_event_invoke_config( + self, + context: RequestContext, + function_name: FunctionName, + qualifier: Qualifier = None, + maximum_retry_attempts: MaximumRetryAttempts = None, + maximum_event_age_in_seconds: MaximumEventAgeInSeconds = None, + destination_config: DestinationConfig = None, + **kwargs, + ) -> FunctionEventInvokeConfig: + # like put but only update single fields via replace + account_id, region = api_utils.get_account_and_region(function_name, context) + state = lambda_stores[account_id][region] + function_name, qualifier = api_utils.get_name_and_qualifier( + function_name, qualifier, context + ) + + if ( + maximum_event_age_in_seconds is None + and maximum_retry_attempts is None + and destination_config is None + ): + raise InvalidParameterValueException( + "You must specify at least one of error handling or destination setting.", + Type="User", + ) + + fn = state.functions.get(function_name) + if not fn or (qualifier and not (qualifier in fn.aliases or qualifier in fn.versions)): + raise ResourceNotFoundException("The function doesn't exist.", Type="User") + + qualifier = qualifier or "$LATEST" + + config = fn.event_invoke_configs.get(qualifier) + if not config: + fn_arn = api_utils.qualified_lambda_arn(function_name, qualifier, account_id, region) + raise ResourceNotFoundException( + f"The function {fn_arn} doesn't have an EventInvokeConfig", Type="User" + ) + + if destination_config: + self._validate_destination_config(state, function_name, destination_config) + + optional_kwargs = { + k: v + for k, v in { + "destination_config": destination_config, + "maximum_retry_attempts": maximum_retry_attempts, + "maximum_event_age_in_seconds": maximum_event_age_in_seconds, + }.items() + if v is not None + } + + new_config = dataclasses.replace( + config, last_modified=api_utils.generate_lambda_date(), **optional_kwargs + ) + fn.event_invoke_configs[qualifier] = new_config + + return FunctionEventInvokeConfig( + LastModified=datetime.datetime.strptime( + new_config.last_modified, api_utils.LAMBDA_DATE_FORMAT + ), + FunctionArn=api_utils.qualified_lambda_arn( + function_name, qualifier or "$LATEST", account_id, region + ), + DestinationConfig=new_config.destination_config, + MaximumEventAgeInSeconds=new_config.maximum_event_age_in_seconds, + MaximumRetryAttempts=new_config.maximum_retry_attempts, + ) + + # ======================================= + # ====== Layer & Layer Versions ======= + # ======================================= + + @staticmethod + def _resolve_layer( + layer_name_or_arn: str, context: RequestContext + ) -> Tuple[str, str, str, Optional[str]]: + """ + Return locator attributes for a given Lambda layer. + + :param layer_name_or_arn: Layer name or ARN + :param context: Request context + :return: Tuple of region, account ID, layer name, layer version + """ + if api_utils.is_layer_arn(layer_name_or_arn): + return api_utils.parse_layer_arn(layer_name_or_arn) + + return context.region, context.account_id, layer_name_or_arn, None + + def publish_layer_version( + self, + context: RequestContext, + layer_name: LayerName, + content: LayerVersionContentInput, + description: Description = None, + compatible_runtimes: CompatibleRuntimes = None, + license_info: LicenseInfo = None, + compatible_architectures: CompatibleArchitectures = None, + **kwargs, + ) -> PublishLayerVersionResponse: + """ + On first use of a LayerName a new layer is created and for each subsequent call with the same LayerName a new version is created. + Note that there are no $LATEST versions with layers! + + """ + account = context.account_id + region = context.region + + validation_errors = api_utils.validate_layer_runtimes_and_architectures( + compatible_runtimes, compatible_architectures + ) + if validation_errors: + raise ValidationException( + f"{len(validation_errors)} validation error{'s' if len(validation_errors) > 1 else ''} detected: {'; '.join(validation_errors)}" + ) + + state = lambda_stores[account][region] + with self.create_layer_lock: + if layer_name not in state.layers: + # we don't have a version so create new layer object + # lock is required to avoid creating two v1 objects for the same name + layer = Layer( + arn=api_utils.layer_arn(layer_name=layer_name, account=account, region=region) + ) + state.layers[layer_name] = layer + + layer = state.layers[layer_name] + with layer.next_version_lock: + next_version = LambdaLayerVersionIdentifier( + account_id=account, region=region, layer_name=layer_name + ).generate(next_version=layer.next_version) + # When creating a layer with user defined layer version, it is possible that we + # create layer versions out of order. + # ie. a user could replicate layer v2 then layer v1. It is important to always keep the maximum possible + # value for next layer to avoid overwriting existing versions + if layer.next_version <= next_version: + # We don't need to update layer.next_version if the created version is lower than the "next in line" + layer.next_version = max(next_version, layer.next_version) + 1 + + # creating a new layer + if content.get("ZipFile"): + code = store_lambda_archive( + archive_file=content["ZipFile"], + function_name=layer_name, + region_name=region, + account_id=account, + ) + else: + code = store_s3_bucket_archive( + archive_bucket=content["S3Bucket"], + archive_key=content["S3Key"], + archive_version=content.get("S3ObjectVersion"), + function_name=layer_name, + region_name=region, + account_id=account, + ) + + new_layer_version = LayerVersion( + layer_version_arn=api_utils.layer_version_arn( + layer_name=layer_name, + account=account, + region=region, + version=str(next_version), + ), + layer_arn=layer.arn, + version=next_version, + description=description or "", + license_info=license_info, + compatible_runtimes=compatible_runtimes, + compatible_architectures=compatible_architectures, + created=api_utils.generate_lambda_date(), + code=code, + ) + + layer.layer_versions[str(next_version)] = new_layer_version + + return api_utils.map_layer_out(new_layer_version) + + def get_layer_version( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + **kwargs, + ) -> GetLayerVersionResponse: + # TODO: handle layer_name as an ARN + + region_name, account_id, layer_name, _ = LambdaProvider._resolve_layer(layer_name, context) + state = lambda_stores[account_id][region_name] + + layer = state.layers.get(layer_name) + if version_number < 1: + raise InvalidParameterValueException("Layer Version Cannot be less than 1", Type="User") + if layer is None: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + layer_version = layer.layer_versions.get(str(version_number)) + if layer_version is None: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + return api_utils.map_layer_out(layer_version) + + def get_layer_version_by_arn( + self, context: RequestContext, arn: LayerVersionArn, **kwargs + ) -> GetLayerVersionResponse: + region_name, account_id, layer_name, layer_version = LambdaProvider._resolve_layer( + arn, context + ) + + if not layer_version: + raise ValidationException( + f"1 validation error detected: Value '{arn}' at 'arn' failed to satisfy constraint: Member must satisfy regular expression pattern: " + + "(arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:layer:[a-zA-Z0-9-_]+:[0-9]+)|(arn:[a-zA-Z0-9-]+:lambda:::awslayer:[a-zA-Z0-9-_]+)" + ) + + store = lambda_stores[account_id][region_name] + if not (layers := store.layers.get(layer_name)): + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + layer_version = layers.layer_versions.get(layer_version) + + if not layer_version: + raise ResourceNotFoundException( + "The resource you requested does not exist.", Type="User" + ) + + return api_utils.map_layer_out(layer_version) + + def list_layers( + self, + context: RequestContext, + compatible_runtime: Runtime = None, + marker: String = None, + max_items: MaxLayerListItems = None, + compatible_architecture: Architecture = None, + **kwargs, + ) -> ListLayersResponse: + validation_errors = [] + + validation_error_arch = api_utils.validate_layer_architecture(compatible_architecture) + if validation_error_arch: + validation_errors.append(validation_error_arch) + + validation_error_runtime = api_utils.validate_layer_runtime(compatible_runtime) + if validation_error_runtime: + validation_errors.append(validation_error_runtime) + + if validation_errors: + raise ValidationException( + f"{len(validation_errors)} validation error{'s' if len(validation_errors) > 1 else ''} detected: {';'.join(validation_errors)}" + ) + # TODO: handle filter: compatible_runtime + # TODO: handle filter: compatible_architecture + + state = lambda_stores[context.account_id][context.region] + layers = state.layers + + # TODO: test how filters behave together with only returning layers here? Does it return the latest "matching" layer, i.e. does it ignore later layer versions that don't match? + + responses: list[LayersListItem] = [] + for layer_name, layer in layers.items(): + # fetch latest version + layer_versions = list(layer.layer_versions.values()) + sorted(layer_versions, key=lambda x: x.version) + latest_layer_version = layer_versions[-1] + responses.append( + LayersListItem( + LayerName=layer_name, + LayerArn=layer.arn, + LatestMatchingVersion=api_utils.map_layer_out(latest_layer_version), + ) + ) + + responses = PaginatedList(responses) + page, token = responses.get_page( + lambda version: version, + marker, + max_items, + ) + + return ListLayersResponse(NextMarker=token, Layers=page) + + def list_layer_versions( + self, + context: RequestContext, + layer_name: LayerName, + compatible_runtime: Runtime = None, + marker: String = None, + max_items: MaxLayerListItems = None, + compatible_architecture: Architecture = None, + **kwargs, + ) -> ListLayerVersionsResponse: + validation_errors = api_utils.validate_layer_runtimes_and_architectures( + [compatible_runtime] if compatible_runtime else [], + [compatible_architecture] if compatible_architecture else [], + ) + if validation_errors: + raise ValidationException( + f"{len(validation_errors)} validation error{'s' if len(validation_errors) > 1 else ''} detected: {';'.join(validation_errors)}" + ) + + region_name, account_id, layer_name, layer_version = LambdaProvider._resolve_layer( + layer_name, context + ) + state = lambda_stores[account_id][region_name] + + # TODO: Test & handle filter: compatible_runtime + # TODO: Test & handle filter: compatible_architecture + all_layer_versions = [] + layer = state.layers.get(layer_name) + if layer is not None: + for layer_version in layer.layer_versions.values(): + all_layer_versions.append(api_utils.map_layer_out(layer_version)) + + all_layer_versions.sort(key=lambda x: x["Version"], reverse=True) + all_layer_versions = PaginatedList(all_layer_versions) + page, token = all_layer_versions.get_page( + lambda version: version["LayerVersionArn"], + marker, + max_items, + ) + return ListLayerVersionsResponse(NextMarker=token, LayerVersions=page) + + def delete_layer_version( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + **kwargs, + ) -> None: + if version_number < 1: + raise InvalidParameterValueException("Layer Version Cannot be less than 1", Type="User") + + region_name, account_id, layer_name, layer_version = LambdaProvider._resolve_layer( + layer_name, context + ) + + store = lambda_stores[account_id][region_name] + layer = store.layers.get(layer_name, {}) + if layer: + layer.layer_versions.pop(str(version_number), None) + + # ======================================= + # ===== Layer Version Permissions ===== + # ======================================= + # TODO: lock updates that change revision IDs + + def add_layer_version_permission( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + statement_id: StatementId, + action: LayerPermissionAllowedAction, + principal: LayerPermissionAllowedPrincipal, + organization_id: OrganizationId = None, + revision_id: String = None, + **kwargs, + ) -> AddLayerVersionPermissionResponse: + # `layer_name` can either be layer name or ARN. It is used to generate error messages. + # `layer_n` contains the layer name. + region_name, account_id, layer_n, _ = LambdaProvider._resolve_layer(layer_name, context) + + if action != "lambda:GetLayerVersion": + raise ValidationException( + f"1 validation error detected: Value '{action}' at 'action' failed to satisfy constraint: Member must satisfy regular expression pattern: lambda:GetLayerVersion" + ) + + store = lambda_stores[account_id][region_name] + layer = store.layers.get(layer_n) + + layer_version_arn = api_utils.layer_version_arn( + layer_name, account_id, region_name, str(version_number) + ) + + if layer is None: + raise ResourceNotFoundException( + f"Layer version {layer_version_arn} does not exist.", Type="User" + ) + layer_version = layer.layer_versions.get(str(version_number)) + if layer_version is None: + raise ResourceNotFoundException( + f"Layer version {layer_version_arn} does not exist.", Type="User" + ) + # do we have a policy? if not set one + if layer_version.policy is None: + layer_version.policy = LayerPolicy() + + if statement_id in layer_version.policy.statements: + raise ResourceConflictException( + f"The statement id ({statement_id}) provided already exists. Please provide a new statement id, or remove the existing statement.", + Type="User", + ) + + if revision_id and layer_version.policy.revision_id != revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. " + "Call the GetLayerPolicy API to retrieve the latest Revision Id", + Type="User", + ) + + statement = LayerPolicyStatement( + sid=statement_id, action=action, principal=principal, organization_id=organization_id + ) + + old_statements = layer_version.policy.statements + layer_version.policy = dataclasses.replace( + layer_version.policy, statements={**old_statements, statement_id: statement} + ) + + return AddLayerVersionPermissionResponse( + Statement=json.dumps( + { + "Sid": statement.sid, + "Effect": "Allow", + "Principal": statement.principal, + "Action": statement.action, + "Resource": layer_version.layer_version_arn, + } + ), + RevisionId=layer_version.policy.revision_id, + ) + + def remove_layer_version_permission( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + statement_id: StatementId, + revision_id: String = None, + **kwargs, + ) -> None: + # `layer_name` can either be layer name or ARN. It is used to generate error messages. + # `layer_n` contains the layer name. + region_name, account_id, layer_n, layer_version = LambdaProvider._resolve_layer( + layer_name, context + ) + + layer_version_arn = api_utils.layer_version_arn( + layer_name, account_id, region_name, str(version_number) + ) + + state = lambda_stores[account_id][region_name] + layer = state.layers.get(layer_n) + if layer is None: + raise ResourceNotFoundException( + f"Layer version {layer_version_arn} does not exist.", Type="User" + ) + layer_version = layer.layer_versions.get(str(version_number)) + if layer_version is None: + raise ResourceNotFoundException( + f"Layer version {layer_version_arn} does not exist.", Type="User" + ) + + if revision_id and layer_version.policy.revision_id != revision_id: + raise PreconditionFailedException( + "The Revision Id provided does not match the latest Revision Id. " + "Call the GetLayerPolicy API to retrieve the latest Revision Id", + Type="User", + ) + + if statement_id not in layer_version.policy.statements: + raise ResourceNotFoundException( + f"Statement {statement_id} is not found in resource policy.", Type="User" + ) + + old_statements = layer_version.policy.statements + layer_version.policy = dataclasses.replace( + layer_version.policy, + statements={k: v for k, v in old_statements.items() if k != statement_id}, + ) + + def get_layer_version_policy( + self, + context: RequestContext, + layer_name: LayerName, + version_number: LayerVersionNumber, + **kwargs, + ) -> GetLayerVersionPolicyResponse: + # `layer_name` can either be layer name or ARN. It is used to generate error messages. + # `layer_n` contains the layer name. + region_name, account_id, layer_n, _ = LambdaProvider._resolve_layer(layer_name, context) + + layer_version_arn = api_utils.layer_version_arn( + layer_name, account_id, region_name, str(version_number) + ) + + store = lambda_stores[account_id][region_name] + layer = store.layers.get(layer_n) + + if layer is None: + raise ResourceNotFoundException( + f"Layer version {layer_version_arn} does not exist.", Type="User" + ) + + layer_version = layer.layer_versions.get(str(version_number)) + if layer_version is None: + raise ResourceNotFoundException( + f"Layer version {layer_version_arn} does not exist.", Type="User" + ) + + if layer_version.policy is None: + raise ResourceNotFoundException( + "No policy is associated with the given resource.", Type="User" + ) + + return GetLayerVersionPolicyResponse( + Policy=json.dumps( + { + "Version": layer_version.policy.version, + "Id": layer_version.policy.id, + "Statement": [ + { + "Sid": ps.sid, + "Effect": "Allow", + "Principal": ps.principal, + "Action": ps.action, + "Resource": layer_version.layer_version_arn, + } + for ps in layer_version.policy.statements.values() + ], + } + ), + RevisionId=layer_version.policy.revision_id, + ) + + # ======================================= + # ======= Function Concurrency ======== + # ======================================= + # (Reserved) function concurrency is scoped to the whole function + + def get_function_concurrency( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> GetFunctionConcurrencyResponse: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name = api_utils.get_function_name(function_name, context) + fn = self._get_function(function_name=function_name, region=region, account_id=account_id) + return GetFunctionConcurrencyResponse( + ReservedConcurrentExecutions=fn.reserved_concurrent_executions + ) + + def put_function_concurrency( + self, + context: RequestContext, + function_name: FunctionName, + reserved_concurrent_executions: ReservedConcurrentExecutions, + **kwargs, + ) -> Concurrency: + account_id, region = api_utils.get_account_and_region(function_name, context) + + function_name, qualifier = api_utils.get_name_and_qualifier(function_name, None, context) + if qualifier: + raise InvalidParameterValueException( + "This operation is permitted on Lambda functions only. Aliases and versions do not support this operation. Please specify either a function name or an unqualified function ARN.", + Type="User", + ) + + store = lambda_stores[account_id][region] + fn = store.functions.get(function_name) + if not fn: + fn_arn = api_utils.qualified_lambda_arn( + function_name, + qualifier="$LATEST", + account=account_id, + region=region, + ) + raise ResourceNotFoundException(f"Function not found: {fn_arn}", Type="User") + + settings = self.get_account_settings(context) + unreserved_concurrent_executions = settings["AccountLimit"][ + "UnreservedConcurrentExecutions" + ] + + # The existing reserved concurrent executions for the same function are already deduced in + # unreserved_concurrent_executions but must not count because the new one will replace the existing one. + # Joel tested this behavior manually against AWS (2023-11-28). + existing_reserved_concurrent_executions = ( + fn.reserved_concurrent_executions if fn.reserved_concurrent_executions else 0 + ) + if ( + unreserved_concurrent_executions + - reserved_concurrent_executions + + existing_reserved_concurrent_executions + ) < config.LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY: + raise InvalidParameterValueException( + f"Specified ReservedConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of [{config.LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY}]." + ) + + total_provisioned_concurrency = sum( + [ + provisioned_configs.provisioned_concurrent_executions + for provisioned_configs in fn.provisioned_concurrency_configs.values() + ] + ) + if total_provisioned_concurrency > reserved_concurrent_executions: + raise InvalidParameterValueException( + f" ReservedConcurrentExecutions {reserved_concurrent_executions} should not be lower than function's total provisioned concurrency [{total_provisioned_concurrency}]." + ) + + fn.reserved_concurrent_executions = reserved_concurrent_executions + + return Concurrency(ReservedConcurrentExecutions=fn.reserved_concurrent_executions) + + def delete_function_concurrency( + self, context: RequestContext, function_name: FunctionName, **kwargs + ) -> None: + account_id, region = api_utils.get_account_and_region(function_name, context) + function_name, qualifier = api_utils.get_name_and_qualifier(function_name, None, context) + store = lambda_stores[account_id][region] + fn = store.functions.get(function_name) + fn.reserved_concurrent_executions = None + + # ======================================= + # =============== TAGS =============== + # ======================================= + # only Function, Event Source Mapping, and Code Signing Config (not currently supported by LocalStack) ARNs an are available for tagging in AWS + + def _get_tags(self, resource: TaggableResource) -> dict[str, str]: + state = self.fetch_lambda_store_for_tagging(resource) + lambda_adapted_tags = { + tag["Key"]: tag["Value"] + for tag in state.TAGS.list_tags_for_resource(resource).get("Tags") + } + return lambda_adapted_tags + + def _store_tags(self, resource: TaggableResource, tags: dict[str, str]): + state = self.fetch_lambda_store_for_tagging(resource) + if len(state.TAGS.tags.get(resource, {}) | tags) > LAMBDA_TAG_LIMIT_PER_RESOURCE: + raise InvalidParameterValueException( + "Number of tags exceeds resource tag limit.", Type="User" + ) + + tag_svc_adapted_tags = [{"Key": key, "Value": value} for key, value in tags.items()] + state.TAGS.tag_resource(resource, tag_svc_adapted_tags) + + def fetch_lambda_store_for_tagging(self, resource: TaggableResource) -> LambdaStore: + """ + Takes a resource ARN for a TaggableResource (Lambda Function, Event Source Mapping, or Code Signing Config) and returns a corresponding + LambdaStore for its region and account. + + In addition, this function validates that the ARN is a valid TaggableResource type, and that the TaggableResource exists. + + Raises: + ValidationException: If the resource ARN is not a full ARN for a TaggableResource. + ResourceNotFoundException: If the specified resource does not exist. + InvalidParameterValueException: If the resource ARN is a qualified Lambda Function. + """ + + def _raise_validation_exception(): + raise ValidationException( + f"1 validation error detected: Value '{resource}' at 'resource' failed to satisfy constraint: Member must satisfy regular expression pattern: {api_utils.TAGGABLE_RESOURCE_ARN_PATTERN}" + ) + + # Check whether the ARN we have been passed is correctly formatted + parsed_resource_arn: ArnData = None + try: + parsed_resource_arn = parse_arn(resource) + except Exception: + _raise_validation_exception() + + # TODO: Should we be checking whether this is a full ARN? + region, account_id, resource_type = map( + parsed_resource_arn.get, ("region", "account", "resource") + ) + + if not all((region, account_id, resource_type)): + _raise_validation_exception() + + if not (parts := resource_type.split(":")): + _raise_validation_exception() + + resource_type, resource_identifier, *qualifier = parts + if resource_type not in {"event-source-mapping", "code-signing-config", "function"}: + _raise_validation_exception() + + if qualifier: + if resource_type == "function": + raise InvalidParameterValueException( + "Tags on function aliases and versions are not supported. Please specify a function ARN.", + Type="User", + ) + _raise_validation_exception() + + match resource_type: + case "event-source-mapping": + self._get_esm(resource_identifier, account_id, region) + case "code-signing-config": + raise NotImplementedError("Resource tagging on CSC not yet implemented.") + case "function": + self._get_function( + function_name=resource_identifier, account_id=account_id, region=region + ) + + # If no exceptions are raised, assume ARN and referenced resource is valid for tag operations + return lambda_stores[account_id][region] + + def tag_resource( + self, context: RequestContext, resource: TaggableResource, tags: Tags, **kwargs + ) -> None: + if not tags: + raise InvalidParameterValueException( + "An error occurred and the request cannot be processed.", Type="User" + ) + self._store_tags(resource, tags) + + if (resource_id := extract_resource_from_arn(resource)) and resource_id.startswith( + "function" + ): + name, _, account, region = function_locators_from_arn(resource) + function = self._get_function(name, account, region) + with function.lock: + # dirty hack for changed revision id, should reevaluate model to prevent this: + latest_version = function.versions["$LATEST"] + function.versions["$LATEST"] = dataclasses.replace( + latest_version, config=dataclasses.replace(latest_version.config) + ) + + def list_tags( + self, context: RequestContext, resource: TaggableResource, **kwargs + ) -> ListTagsResponse: + tags = self._get_tags(resource) + return ListTagsResponse(Tags=tags) + + def untag_resource( + self, context: RequestContext, resource: TaggableResource, tag_keys: TagKeyList, **kwargs + ) -> None: + if not tag_keys: + raise ValidationException( + "1 validation error detected: Value null at 'tagKeys' failed to satisfy constraint: Member must not be null" + ) # should probably be generalized a bit + + state = self.fetch_lambda_store_for_tagging(resource) + state.TAGS.untag_resource(resource, tag_keys) + + if (resource_id := extract_resource_from_arn(resource)) and resource_id.startswith( + "function" + ): + name, _, account, region = function_locators_from_arn(resource) + function = self._get_function(name, account, region) + # TODO: Potential race condition + with function.lock: + # dirty hack for changed revision id, should reevaluate model to prevent this: + latest_version = function.versions["$LATEST"] + function.versions["$LATEST"] = dataclasses.replace( + latest_version, config=dataclasses.replace(latest_version.config) + ) + + # ======================================= + # ======= LEGACY / DEPRECATED ======== + # ======================================= + + def invoke_async( + self, + context: RequestContext, + function_name: NamespacedFunctionName, + invoke_args: IO[BlobStream], + **kwargs, + ) -> InvokeAsyncResponse: + """LEGACY API endpoint. Even AWS heavily discourages its usage.""" + raise NotImplementedError diff --git a/localstack-core/localstack/services/lambda_/provider_utils.py b/localstack-core/localstack/services/lambda_/provider_utils.py new file mode 100644 index 0000000000000..4c0c4e7e1bc8b --- /dev/null +++ b/localstack-core/localstack/services/lambda_/provider_utils.py @@ -0,0 +1,92 @@ +from typing import TYPE_CHECKING + +from localstack.aws.api.lambda_ import ResourceNotFoundException +from localstack.services.lambda_.api_utils import ( + function_locators_from_arn, + lambda_arn, + qualified_lambda_arn, + qualifier_is_alias, + unqualified_lambda_arn, +) +from localstack.services.lambda_.invocation.models import lambda_stores +from localstack.utils.id_generator import ExistingIds, ResourceIdentifier, Tags, localstack_id + +if TYPE_CHECKING: + from localstack.services.lambda_.invocation.lambda_models import ( + FunctionVersion, + ) + + +def get_function_version_from_arn(function_arn: str) -> "FunctionVersion": + function_name, qualifier, account_id, region = function_locators_from_arn(function_arn) + fn = lambda_stores[account_id][region].functions.get(function_name) + if fn is None: + if qualifier is None: + raise ResourceNotFoundException( + f"Function not found: {unqualified_lambda_arn(function_name, account_id, region)}", + Type="User", + ) + else: + raise ResourceNotFoundException( + f"Function not found: {qualified_lambda_arn(function_name, qualifier, account_id, region)}", + Type="User", + ) + if qualifier and qualifier_is_alias(qualifier): + if qualifier not in fn.aliases: + alias_arn = qualified_lambda_arn(function_name, qualifier, account_id, region) + raise ResourceNotFoundException(f"Function not found: {alias_arn}", Type="User") + alias_name = qualifier + qualifier = fn.aliases[alias_name].function_version + + version = get_function_version( + function_name=function_name, + qualifier=qualifier, + account_id=account_id, + region=region, + ) + return version + + +def get_function_version( + function_name: str, qualifier: str | None, account_id: str, region: str +) -> "FunctionVersion": + state = lambda_stores[account_id][region] + function = state.functions.get(function_name) + qualifier_or_latest = qualifier or "$LATEST" + version = function and function.versions.get(qualifier_or_latest) + if not function or not version: + arn = lambda_arn( + function_name=function_name, + qualifier=qualifier, + account=account_id, + region=region, + ) + raise ResourceNotFoundException( + f"Function not found: {arn}", + Type="User", + ) + # TODO what if version is missing? + return version + + +class LambdaLayerVersionIdentifier(ResourceIdentifier): + service = "lambda" + resource = "layer-version" + + def __init__(self, account_id: str, region: str, layer_name: str): + super(LambdaLayerVersionIdentifier, self).__init__(account_id, region, layer_name) + + def generate( + self, existing_ids: ExistingIds = None, tags: Tags = None, next_version: int = None + ) -> int: + return int(generate_layer_version(self, next_version=next_version)) + + +@localstack_id +def generate_layer_version( + resource_identifier: ResourceIdentifier, + existing_ids: ExistingIds = None, + tags: Tags = None, + next_version: int = 0, +): + return next_version diff --git a/localstack-core/localstack/services/lambda_/resource_providers/__init__.py b/localstack-core/localstack/services/lambda_/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig.py new file mode 100644 index 0000000000000..8a23156e4ab13 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig.py @@ -0,0 +1,118 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaCodeSigningConfigProperties(TypedDict): + AllowedPublishers: Optional[AllowedPublishers] + CodeSigningConfigArn: Optional[str] + CodeSigningConfigId: Optional[str] + CodeSigningPolicies: Optional[CodeSigningPolicies] + Description: Optional[str] + + +class AllowedPublishers(TypedDict): + SigningProfileVersionArns: Optional[list[str]] + + +class CodeSigningPolicies(TypedDict): + UntrustedArtifactOnDeployment: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaCodeSigningConfigProvider(ResourceProvider[LambdaCodeSigningConfigProperties]): + TYPE = "AWS::Lambda::CodeSigningConfig" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaCodeSigningConfigProperties], + ) -> ProgressEvent[LambdaCodeSigningConfigProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/CodeSigningConfigArn + + Required properties: + - AllowedPublishers + + + + Read-only properties: + - /properties/CodeSigningConfigId + - /properties/CodeSigningConfigArn + + IAM permissions required: + - lambda:CreateCodeSigningConfig + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + response = lambda_client.create_code_signing_config(**model) + model["CodeSigningConfigArn"] = response["CodeSigningConfig"]["CodeSigningConfigArn"] + model["CodeSigningConfigId"] = response["CodeSigningConfig"]["CodeSigningConfigId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LambdaCodeSigningConfigProperties], + ) -> ProgressEvent[LambdaCodeSigningConfigProperties]: + """ + Fetch resource information + + IAM permissions required: + - lambda:GetCodeSigningConfig + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaCodeSigningConfigProperties], + ) -> ProgressEvent[LambdaCodeSigningConfigProperties]: + """ + Delete a resource + + IAM permissions required: + - lambda:DeleteCodeSigningConfig + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + lambda_client.delete_code_signing_config(CodeSigningConfigArn=model["CodeSigningConfigArn"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaCodeSigningConfigProperties], + ) -> ProgressEvent[LambdaCodeSigningConfigProperties]: + """ + Update a resource + + IAM permissions required: + - lambda:UpdateCodeSigningConfig + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig.schema.json new file mode 100644 index 0000000000000..75c28a58fa3b1 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig.schema.json @@ -0,0 +1,111 @@ +{ + "typeName": "AWS::Lambda::CodeSigningConfig", + "description": "Resource Type definition for AWS::Lambda::CodeSigningConfig.", + "additionalProperties": false, + "properties": { + "Description": { + "description": "A description of the CodeSigningConfig", + "type": "string", + "minLength": 0, + "maxLength": 256 + }, + "AllowedPublishers": { + "description": "When the CodeSigningConfig is later on attached to a function, the function code will be expected to be signed by profiles from this list", + "$ref": "#/definitions/AllowedPublishers" + }, + "CodeSigningPolicies": { + "description": "Policies to control how to act if a signature is invalid", + "$ref": "#/definitions/CodeSigningPolicies" + }, + "CodeSigningConfigId": { + "description": "A unique identifier for CodeSigningConfig resource", + "type": "string", + "pattern": "csc-[a-zA-Z0-9-_\\.]{17}" + }, + "CodeSigningConfigArn": { + "description": "A unique Arn for CodeSigningConfig resource", + "type": "string", + "pattern": "arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\\d{1}:\\d{12}:code-signing-config:csc-[a-z0-9]{17}" + } + }, + "definitions": { + "AllowedPublishers": { + "type": "object", + "description": "When the CodeSigningConfig is later on attached to a function, the function code will be expected to be signed by profiles from this list", + "additionalProperties": false, + "properties": { + "SigningProfileVersionArns": { + "type": "array", + "description": "List of Signing profile version Arns", + "minItems": 1, + "maxItems": 20, + "items": { + "type": "string", + "pattern": "arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\\-])+:([a-z]{2}(-gov)?-[a-z]+-\\d{1})?:(\\d{12})?:(.*)", + "minLength": 12, + "maxLength": 1024 + } + } + }, + "required": [ + "SigningProfileVersionArns" + ] + }, + "CodeSigningPolicies": { + "type": "object", + "description": "Policies to control how to act if a signature is invalid", + "additionalProperties": false, + "properties": { + "UntrustedArtifactOnDeployment": { + "type": "string", + "description": "Indicates how Lambda operations involve updating the code artifact will operate. Default to Warn if not provided", + "default": "Warn", + "enum": [ + "Warn", + "Enforce" + ] + } + }, + "required": [ + "UntrustedArtifactOnDeployment" + ] + } + }, + "required": [ + "AllowedPublishers" + ], + "readOnlyProperties": [ + "/properties/CodeSigningConfigId", + "/properties/CodeSigningConfigArn" + ], + "primaryIdentifier": [ + "/properties/CodeSigningConfigArn" + ], + "handlers": { + "create": { + "permissions": [ + "lambda:CreateCodeSigningConfig" + ] + }, + "read": { + "permissions": [ + "lambda:GetCodeSigningConfig" + ] + }, + "update": { + "permissions": [ + "lambda:UpdateCodeSigningConfig" + ] + }, + "delete": { + "permissions": [ + "lambda:DeleteCodeSigningConfig" + ] + }, + "list": { + "permissions": [ + "lambda:ListCodeSigningConfigs" + ] + } + } +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig_plugin.py new file mode 100644 index 0000000000000..b165c1253e910 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_codesigningconfig_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaCodeSigningConfigProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::CodeSigningConfig" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_codesigningconfig import ( + LambdaCodeSigningConfigProvider, + ) + + self.factory = LambdaCodeSigningConfigProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig.py new file mode 100644 index 0000000000000..60dec55595e95 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig.py @@ -0,0 +1,124 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaEventInvokeConfigProperties(TypedDict): + FunctionName: Optional[str] + Qualifier: Optional[str] + DestinationConfig: Optional[DestinationConfig] + Id: Optional[str] + MaximumEventAgeInSeconds: Optional[int] + MaximumRetryAttempts: Optional[int] + + +class OnSuccess(TypedDict): + Destination: Optional[str] + + +class OnFailure(TypedDict): + Destination: Optional[str] + + +class DestinationConfig(TypedDict): + OnFailure: Optional[OnFailure] + OnSuccess: Optional[OnSuccess] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaEventInvokeConfigProvider(ResourceProvider[LambdaEventInvokeConfigProperties]): + TYPE = "AWS::Lambda::EventInvokeConfig" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaEventInvokeConfigProperties], + ) -> ProgressEvent[LambdaEventInvokeConfigProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - FunctionName + - Qualifier + + Create-only properties: + - /properties/FunctionName + - /properties/Qualifier + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + lambda_client.put_function_event_invoke_config(**model) + model["Id"] = str(uuid.uuid4()) # TODO: not actually a UUIDv4 + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LambdaEventInvokeConfigProperties], + ) -> ProgressEvent[LambdaEventInvokeConfigProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaEventInvokeConfigProperties], + ) -> ProgressEvent[LambdaEventInvokeConfigProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + lambda_client.delete_function_event_invoke_config( + FunctionName=model["FunctionName"], Qualifier=model["Qualifier"] + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaEventInvokeConfigProperties], + ) -> ProgressEvent[LambdaEventInvokeConfigProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig.schema.json new file mode 100644 index 0000000000000..f188bcbfdaca4 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig.schema.json @@ -0,0 +1,77 @@ +{ + "typeName": "AWS::Lambda::EventInvokeConfig", + "description": "Resource Type definition for AWS::Lambda::EventInvokeConfig", + "additionalProperties": false, + "properties": { + "FunctionName": { + "type": "string" + }, + "MaximumRetryAttempts": { + "type": "integer" + }, + "Qualifier": { + "type": "string" + }, + "DestinationConfig": { + "$ref": "#/definitions/DestinationConfig" + }, + "Id": { + "type": "string" + }, + "MaximumEventAgeInSeconds": { + "type": "integer" + } + }, + "definitions": { + "DestinationConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "OnSuccess": { + "$ref": "#/definitions/OnSuccess" + }, + "OnFailure": { + "$ref": "#/definitions/OnFailure" + } + } + }, + "OnSuccess": { + "type": "object", + "additionalProperties": false, + "properties": { + "Destination": { + "type": "string" + } + }, + "required": [ + "Destination" + ] + }, + "OnFailure": { + "type": "object", + "additionalProperties": false, + "properties": { + "Destination": { + "type": "string" + } + }, + "required": [ + "Destination" + ] + } + }, + "required": [ + "FunctionName", + "Qualifier" + ], + "createOnlyProperties": [ + "/properties/FunctionName", + "/properties/Qualifier" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig_plugin.py new file mode 100644 index 0000000000000..6ebda8450ef65 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventinvokeconfig_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaEventInvokeConfigProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::EventInvokeConfig" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_eventinvokeconfig import ( + LambdaEventInvokeConfigProvider, + ) + + self.factory = LambdaEventInvokeConfigProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping.py new file mode 100644 index 0000000000000..1f82478526dd8 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping.py @@ -0,0 +1,224 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaEventSourceMappingProperties(TypedDict): + FunctionName: Optional[str] + AmazonManagedKafkaEventSourceConfig: Optional[AmazonManagedKafkaEventSourceConfig] + BatchSize: Optional[int] + BisectBatchOnFunctionError: Optional[bool] + DestinationConfig: Optional[DestinationConfig] + DocumentDBEventSourceConfig: Optional[DocumentDBEventSourceConfig] + Enabled: Optional[bool] + EventSourceArn: Optional[str] + FilterCriteria: Optional[FilterCriteria] + FunctionResponseTypes: Optional[list[str]] + Id: Optional[str] + MaximumBatchingWindowInSeconds: Optional[int] + MaximumRecordAgeInSeconds: Optional[int] + MaximumRetryAttempts: Optional[int] + ParallelizationFactor: Optional[int] + Queues: Optional[list[str]] + ScalingConfig: Optional[ScalingConfig] + SelfManagedEventSource: Optional[SelfManagedEventSource] + SelfManagedKafkaEventSourceConfig: Optional[SelfManagedKafkaEventSourceConfig] + SourceAccessConfigurations: Optional[list[SourceAccessConfiguration]] + StartingPosition: Optional[str] + StartingPositionTimestamp: Optional[float] + Topics: Optional[list[str]] + TumblingWindowInSeconds: Optional[int] + + +class OnFailure(TypedDict): + Destination: Optional[str] + + +class DestinationConfig(TypedDict): + OnFailure: Optional[OnFailure] + + +class Filter(TypedDict): + Pattern: Optional[str] + + +class FilterCriteria(TypedDict): + Filters: Optional[list[Filter]] + + +class SourceAccessConfiguration(TypedDict): + Type: Optional[str] + URI: Optional[str] + + +class Endpoints(TypedDict): + KafkaBootstrapServers: Optional[list[str]] + + +class SelfManagedEventSource(TypedDict): + Endpoints: Optional[Endpoints] + + +class AmazonManagedKafkaEventSourceConfig(TypedDict): + ConsumerGroupId: Optional[str] + + +class SelfManagedKafkaEventSourceConfig(TypedDict): + ConsumerGroupId: Optional[str] + + +class ScalingConfig(TypedDict): + MaximumConcurrency: Optional[int] + + +class DocumentDBEventSourceConfig(TypedDict): + CollectionName: Optional[str] + DatabaseName: Optional[str] + FullDocument: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaEventSourceMappingProvider(ResourceProvider[LambdaEventSourceMappingProperties]): + TYPE = "AWS::Lambda::EventSourceMapping" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaEventSourceMappingProperties], + ) -> ProgressEvent[LambdaEventSourceMappingProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - FunctionName + + Create-only properties: + - /properties/EventSourceArn + - /properties/StartingPosition + - /properties/StartingPositionTimestamp + - /properties/SelfManagedEventSource + - /properties/AmazonManagedKafkaEventSourceConfig + - /properties/SelfManagedKafkaEventSourceConfig + + Read-only properties: + - /properties/Id + + IAM permissions required: + - lambda:CreateEventSourceMapping + - lambda:GetEventSourceMapping + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + params = copy.deepcopy(model) + if tags := params.get("Tags"): + transformed_tags = {} + for tag_definition in tags: + transformed_tags[tag_definition["Key"]] = tag_definition["Value"] + params["Tags"] = transformed_tags + + response = lambda_client.create_event_source_mapping(**params) + model["Id"] = response["UUID"] + model["EventSourceMappingArn"] = response["EventSourceMappingArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LambdaEventSourceMappingProperties], + ) -> ProgressEvent[LambdaEventSourceMappingProperties]: + """ + Fetch resource information + + IAM permissions required: + - lambda:GetEventSourceMapping + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaEventSourceMappingProperties], + ) -> ProgressEvent[LambdaEventSourceMappingProperties]: + """ + Delete a resource + + IAM permissions required: + - lambda:DeleteEventSourceMapping + - lambda:GetEventSourceMapping + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + lambda_client.delete_event_source_mapping(UUID=model["Id"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaEventSourceMappingProperties], + ) -> ProgressEvent[LambdaEventSourceMappingProperties]: + """ + Update a resource + + IAM permissions required: + - lambda:UpdateEventSourceMapping + - lambda:GetEventSourceMapping + """ + current_state = request.previous_state + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + params = util.select_attributes( + model=model, + params=[ + "FunctionName", + "Enabled", + "BatchSize", + "FilterCriteria", + "MaximumBatchingWindowInSeconds", + "DestinationConfig", + "MaximumRecordAgeInSeconds", + "BisectBatchOnFunctionError", + "MaximumRetryAttempts", + "ParallelizationFactor", + "SourceAccessConfigurations", + "TumblingWindowInSeconds", + "FunctionResponseTypes", + "ScalingConfig", + "DocumentDBEventSourceConfig", + ], + ) + lambda_client.update_event_source_mapping(UUID=current_state["Id"], **params) + + model["Id"] = current_state["Id"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping.schema.json new file mode 100644 index 0000000000000..2071c3dad1d10 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping.schema.json @@ -0,0 +1,398 @@ +{ + "typeName": "AWS::Lambda::EventSourceMapping", + "description": "Resource Type definition for AWS::Lambda::EventSourceMapping", + "additionalProperties": false, + "properties": { + "Id": { + "description": "Event Source Mapping Identifier UUID.", + "type": "string", + "pattern": "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}", + "minLength": 36, + "maxLength": 36 + }, + "BatchSize": { + "description": "The maximum number of items to retrieve in a single batch.", + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "BisectBatchOnFunctionError": { + "description": "(Streams) If the function returns an error, split the batch in two and retry.", + "type": "boolean" + }, + "DestinationConfig": { + "description": "(Streams) An Amazon SQS queue or Amazon SNS topic destination for discarded records.", + "$ref": "#/definitions/DestinationConfig" + }, + "Enabled": { + "description": "Disables the event source mapping to pause polling and invocation.", + "type": "boolean" + }, + "EventSourceArn": { + "description": "The Amazon Resource Name (ARN) of the event source.", + "type": "string", + "pattern": "arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\\-])+:([a-z]{2}(-gov)?-[a-z]+-\\d{1})?:(\\d{12})?:(.*)", + "minLength": 12, + "maxLength": 1024 + }, + "FilterCriteria": { + "description": "The filter criteria to control event filtering.", + "$ref": "#/definitions/FilterCriteria" + }, + "FunctionName": { + "description": "The name of the Lambda function.", + "type": "string", + "pattern": "(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}(-gov)?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?", + "minLength": 1, + "maxLength": 140 + }, + "MaximumBatchingWindowInSeconds": { + "description": "(Streams) The maximum amount of time to gather records before invoking the function, in seconds.", + "type": "integer", + "minimum": 0, + "maximum": 300 + }, + "MaximumRecordAgeInSeconds": { + "description": "(Streams) The maximum age of a record that Lambda sends to a function for processing.", + "type": "integer", + "minimum": -1, + "maximum": 604800 + }, + "MaximumRetryAttempts": { + "description": "(Streams) The maximum number of times to retry when the function returns an error.", + "type": "integer", + "minimum": -1, + "maximum": 10000 + }, + "ParallelizationFactor": { + "description": "(Streams) The number of batches to process from each shard concurrently.", + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "StartingPosition": { + "description": "The position in a stream from which to start reading. Required for Amazon Kinesis and Amazon DynamoDB Streams sources.", + "type": "string", + "pattern": "(LATEST|TRIM_HORIZON|AT_TIMESTAMP)+", + "minLength": 6, + "maxLength": 12 + }, + "StartingPositionTimestamp": { + "description": "With StartingPosition set to AT_TIMESTAMP, the time from which to start reading, in Unix time seconds.", + "type": "number" + }, + "Topics": { + "description": "(Kafka) A list of Kafka topics.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[^.]([a-zA-Z0-9\\-_.]+)", + "minLength": 1, + "maxLength": 249 + }, + "minItems": 1, + "maxItems": 1 + }, + "Queues": { + "description": "(ActiveMQ) A list of ActiveMQ queues.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "[\\s\\S]*", + "minLength": 1, + "maxLength": 1000 + }, + "minItems": 1, + "maxItems": 1 + }, + "SourceAccessConfigurations": { + "description": "A list of SourceAccessConfiguration.", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/SourceAccessConfiguration" + }, + "minItems": 1, + "maxItems": 22 + }, + "TumblingWindowInSeconds": { + "description": "(Streams) Tumbling window (non-overlapping time window) duration to perform aggregations.", + "type": "integer", + "minimum": 0, + "maximum": 900 + }, + "FunctionResponseTypes": { + "description": "(Streams) A list of response types supported by the function.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ReportBatchItemFailures" + ] + }, + "minLength": 0, + "maxLength": 1 + }, + "SelfManagedEventSource": { + "description": "Self-managed event source endpoints.", + "$ref": "#/definitions/SelfManagedEventSource" + }, + "AmazonManagedKafkaEventSourceConfig": { + "description": "Specific configuration settings for an MSK event source.", + "$ref": "#/definitions/AmazonManagedKafkaEventSourceConfig" + }, + "SelfManagedKafkaEventSourceConfig": { + "description": "Specific configuration settings for a Self-Managed Apache Kafka event source.", + "$ref": "#/definitions/SelfManagedKafkaEventSourceConfig" + }, + "ScalingConfig": { + "description": "The scaling configuration for the event source.", + "$ref": "#/definitions/ScalingConfig" + }, + "DocumentDBEventSourceConfig": { + "description": "Document db event source config.", + "$ref": "#/definitions/DocumentDBEventSourceConfig" + } + }, + "definitions": { + "DestinationConfig": { + "type": "object", + "additionalProperties": false, + "description": "(Streams) An Amazon SQS queue or Amazon SNS topic destination for discarded records.", + "properties": { + "OnFailure": { + "description": "The destination configuration for failed invocations.", + "$ref": "#/definitions/OnFailure" + } + } + }, + "FilterCriteria": { + "type": "object", + "description": "The filter criteria to control event filtering.", + "additionalProperties": false, + "properties": { + "Filters": { + "description": "List of filters of this FilterCriteria", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Filter" + }, + "minItems": 1, + "maxItems": 20 + } + } + }, + "Filter": { + "type": "object", + "description": "The filter object that defines parameters for ESM filtering.", + "additionalProperties": false, + "properties": { + "Pattern": { + "type": "string", + "description": "The filter pattern that defines which events should be passed for invocations.", + "pattern": ".*", + "minLength": 0, + "maxLength": 4096 + } + } + }, + "OnFailure": { + "type": "object", + "description": "A destination for events that failed processing.", + "additionalProperties": false, + "properties": { + "Destination": { + "description": "The Amazon Resource Name (ARN) of the destination resource.", + "type": "string", + "pattern": "arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\\-])+:([a-z]{2}(-gov)?-[a-z]+-\\d{1})?:(\\d{12})?:(.*)", + "minLength": 12, + "maxLength": 1024 + } + } + }, + "SourceAccessConfiguration": { + "type": "object", + "additionalProperties": false, + "description": "The configuration used by AWS Lambda to access event source", + "properties": { + "Type": { + "description": "The type of source access configuration.", + "enum": [ + "BASIC_AUTH", + "VPC_SUBNET", + "VPC_SECURITY_GROUP", + "SASL_SCRAM_512_AUTH", + "SASL_SCRAM_256_AUTH", + "VIRTUAL_HOST", + "CLIENT_CERTIFICATE_TLS_AUTH", + "SERVER_ROOT_CA_CERTIFICATE" + ], + "type": "string" + }, + "URI": { + "description": "The URI for the source access configuration resource.", + "type": "string", + "pattern": "[a-zA-Z0-9-\\/*:_+=.@-]*", + "minLength": 1, + "maxLength": 200 + } + } + }, + "SelfManagedEventSource": { + "type": "object", + "additionalProperties": false, + "description": "The configuration used by AWS Lambda to access a self-managed event source.", + "properties": { + "Endpoints": { + "description": "The endpoints for a self-managed event source.", + "$ref": "#/definitions/Endpoints" + } + } + }, + "Endpoints": { + "type": "object", + "additionalProperties": false, + "description": "The endpoints used by AWS Lambda to access a self-managed event source.", + "properties": { + "KafkaBootstrapServers": { + "type": "array", + "description": "A list of Kafka server endpoints.", + "uniqueItems": true, + "items": { + "type": "string", + "description": "The URL of a Kafka server.", + "pattern": "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9]):[0-9]{1,5}", + "minLength": 1, + "maxLength": 300 + }, + "minItems": 1, + "maxItems": 10 + } + } + }, + "ConsumerGroupId": { + "description": "The identifier for the Kafka Consumer Group to join.", + "type": "string", + "pattern": "[a-zA-Z0-9-\\/*:_+=.@-]*", + "minLength": 1, + "maxLength": 200 + }, + "AmazonManagedKafkaEventSourceConfig": { + "description": "Specific configuration settings for an MSK event source.", + "type": "object", + "additionalProperties": false, + "properties": { + "ConsumerGroupId": { + "description": "The identifier for the Kafka Consumer Group to join.", + "$ref": "#/definitions/ConsumerGroupId" + } + } + }, + "SelfManagedKafkaEventSourceConfig": { + "description": "Specific configuration settings for a Self-Managed Apache Kafka event source.", + "type": "object", + "additionalProperties": false, + "properties": { + "ConsumerGroupId": { + "description": "The identifier for the Kafka Consumer Group to join.", + "$ref": "#/definitions/ConsumerGroupId" + } + } + }, + "MaximumConcurrency": { + "description": "The maximum number of concurrent functions that an event source can invoke.", + "type": "integer", + "minimum": 2, + "maximum": 1000 + }, + "ScalingConfig": { + "description": "The scaling configuration for the event source.", + "type": "object", + "additionalProperties": false, + "properties": { + "MaximumConcurrency": { + "description": "The maximum number of concurrent functions that the event source can invoke.", + "$ref": "#/definitions/MaximumConcurrency" + } + } + }, + "DocumentDBEventSourceConfig": { + "description": "Document db event source config.", + "type": "object", + "additionalProperties": false, + "properties": { + "DatabaseName": { + "description": "The database name to connect to.", + "type": "string", + "minLength": 1, + "maxLength": 63 + }, + "CollectionName": { + "description": "The collection name to connect to.", + "type": "string", + "minLength": 1, + "maxLength": 57 + }, + "FullDocument": { + "description": "Include full document in change stream response. The default option will only send the changes made to documents to Lambda. If you want the complete document sent to Lambda, set this to UpdateLookup.", + "type": "string", + "enum": [ + "UpdateLookup", + "Default" + ] + } + } + } + }, + "required": [ + "FunctionName" + ], + "createOnlyProperties": [ + "/properties/EventSourceArn", + "/properties/StartingPosition", + "/properties/StartingPositionTimestamp", + "/properties/SelfManagedEventSource", + "/properties/AmazonManagedKafkaEventSourceConfig", + "/properties/SelfManagedKafkaEventSourceConfig" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "handlers": { + "create": { + "permissions": [ + "lambda:CreateEventSourceMapping", + "lambda:GetEventSourceMapping" + ] + }, + "delete": { + "permissions": [ + "lambda:DeleteEventSourceMapping", + "lambda:GetEventSourceMapping" + ] + }, + "list": { + "permissions": [ + "lambda:ListEventSourceMappings" + ] + }, + "read": { + "permissions": [ + "lambda:GetEventSourceMapping" + ] + }, + "update": { + "permissions": [ + "lambda:UpdateEventSourceMapping", + "lambda:GetEventSourceMapping" + ] + } + } +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping_plugin.py new file mode 100644 index 0000000000000..f4dd5b69a5423 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_eventsourcemapping_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaEventSourceMappingProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::EventSourceMapping" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_eventsourcemapping import ( + LambdaEventSourceMappingProvider, + ) + + self.factory = LambdaEventSourceMappingProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_function.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_function.py new file mode 100644 index 0000000000000..bbcc61e335934 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_function.py @@ -0,0 +1,585 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.services.lambda_.lambda_utils import get_handler_file_from_name +from localstack.utils.archives import is_zip_file +from localstack.utils.files import mkdir, new_tmp_dir, rm_rf, save_file +from localstack.utils.strings import is_base64, to_bytes +from localstack.utils.testutil import create_zip_file + + +class LambdaFunctionProperties(TypedDict): + Code: Optional[Code] + Role: Optional[str] + Architectures: Optional[list[str]] + Arn: Optional[str] + CodeSigningConfigArn: Optional[str] + DeadLetterConfig: Optional[DeadLetterConfig] + Description: Optional[str] + Environment: Optional[Environment] + EphemeralStorage: Optional[EphemeralStorage] + FileSystemConfigs: Optional[list[FileSystemConfig]] + FunctionName: Optional[str] + Handler: Optional[str] + ImageConfig: Optional[ImageConfig] + KmsKeyArn: Optional[str] + Layers: Optional[list[str]] + MemorySize: Optional[int] + PackageType: Optional[str] + ReservedConcurrentExecutions: Optional[int] + Runtime: Optional[str] + RuntimeManagementConfig: Optional[RuntimeManagementConfig] + SnapStart: Optional[SnapStart] + SnapStartResponse: Optional[SnapStartResponse] + Tags: Optional[list[Tag]] + Timeout: Optional[int] + TracingConfig: Optional[TracingConfig] + VpcConfig: Optional[VpcConfig] + + +class TracingConfig(TypedDict): + Mode: Optional[str] + + +class VpcConfig(TypedDict): + SecurityGroupIds: Optional[list[str]] + SubnetIds: Optional[list[str]] + + +class RuntimeManagementConfig(TypedDict): + UpdateRuntimeOn: Optional[str] + RuntimeVersionArn: Optional[str] + + +class SnapStart(TypedDict): + ApplyOn: Optional[str] + + +class FileSystemConfig(TypedDict): + Arn: Optional[str] + LocalMountPath: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class ImageConfig(TypedDict): + Command: Optional[list[str]] + EntryPoint: Optional[list[str]] + WorkingDirectory: Optional[str] + + +class DeadLetterConfig(TypedDict): + TargetArn: Optional[str] + + +class SnapStartResponse(TypedDict): + ApplyOn: Optional[str] + OptimizationStatus: Optional[str] + + +class Code(TypedDict): + ImageUri: Optional[str] + S3Bucket: Optional[str] + S3Key: Optional[str] + S3ObjectVersion: Optional[str] + ZipFile: Optional[str] + + +class LoggingConfig(TypedDict): + ApplicationLogLevel: Optional[str] + LogFormat: Optional[str] + LogGroup: Optional[str] + SystemLogLevel: Optional[str] + + +class Environment(TypedDict): + Variables: Optional[dict] + + +class EphemeralStorage(TypedDict): + Size: Optional[int] + + +REPEATED_INVOCATION = "repeated_invocation" + +# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html +PYTHON_CFN_RESPONSE_CONTENT = """ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +from __future__ import print_function +import urllib3 +import json + +SUCCESS = "SUCCESS" +FAILED = "FAILED" + +http = urllib3.PoolManager() + + +def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None): + responseUrl = event['ResponseURL'] + + print(responseUrl) + + responseBody = { + 'Status' : responseStatus, + 'Reason' : reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name), + 'PhysicalResourceId' : physicalResourceId or context.log_stream_name, + 'StackId' : event['StackId'], + 'RequestId' : event['RequestId'], + 'LogicalResourceId' : event['LogicalResourceId'], + 'NoEcho' : noEcho, + 'Data' : responseData + } + + json_responseBody = json.dumps(responseBody) + + print("Response body:") + print(json_responseBody) + + headers = { + 'content-type' : '', + 'content-length' : str(len(json_responseBody)) + } + + try: + response = http.request('PUT', responseUrl, headers=headers, body=json_responseBody) + print("Status code:", response.status) + + + except Exception as e: + + print("send(..) failed executing http.request(..):", e) +""" + +# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html +NODEJS_CFN_RESPONSE_CONTENT = r""" +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 + +exports.SUCCESS = "SUCCESS"; +exports.FAILED = "FAILED"; + +exports.send = function(event, context, responseStatus, responseData, physicalResourceId, noEcho) { + + var responseBody = JSON.stringify({ + Status: responseStatus, + Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName, + PhysicalResourceId: physicalResourceId || context.logStreamName, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + NoEcho: noEcho || false, + Data: responseData + }); + + console.log("Response body:\n", responseBody); + + var https = require("https"); + var url = require("url"); + + var parsedUrl = url.parse(event.ResponseURL); + var options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port, // Modified line: LS uses port 4566 for https; hard coded 443 causes error + path: parsedUrl.path, + method: "PUT", + headers: { + "content-type": "", + "content-length": responseBody.length + } + }; + + var request = https.request(options, function(response) { + console.log("Status code: " + parseInt(response.statusCode)); + context.done(); + }); + + request.on("error", function(error) { + console.log("send(..) failed executing https.request(..): " + error); + context.done(); + }); + + request.write(responseBody); + request.end(); +} +""" + + +def _runtime_supports_inline_code(runtime: str) -> bool: + return runtime.startswith("python") or runtime.startswith("node") + + +def _get_lambda_code_param( + properties: LambdaFunctionProperties, + _include_arch=False, +): + # code here is mostly taken directly from legacy implementation + code = properties.get("Code", {}).copy() + + # TODO: verify only one of "ImageUri" | "S3Bucket" | "ZipFile" is set + zip_file = code.get("ZipFile") + if zip_file and not _runtime_supports_inline_code(properties["Runtime"]): + raise Exception( + f"Runtime {properties['Runtime']} doesn't support inlining code via the 'ZipFile' property." + ) # TODO: message not validated + if zip_file and not is_base64(zip_file) and not is_zip_file(to_bytes(zip_file)): + tmp_dir = new_tmp_dir() + try: + handler_file = get_handler_file_from_name( + properties["Handler"], runtime=properties["Runtime"] + ) + tmp_file = os.path.join(tmp_dir, handler_file) + save_file(tmp_file, zip_file) + + # CloudFormation only includes cfn-response libs if an import is detected + # TODO: add snapshots for this behavior + # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html + if properties["Runtime"].lower().startswith("node") and ( + "require('cfn-response')" in zip_file or 'require("cfn-response")' in zip_file + ): + # the check if cfn-response is used is pretty simplistic and apparently based on simple string matching + # having the import commented out will also lead to cfn-response.js being injected + # this is added under both cfn-response.js and node_modules/cfn-response.js + cfn_response_mod_dir = os.path.join(tmp_dir, "node_modules") + mkdir(cfn_response_mod_dir) + save_file( + os.path.join(cfn_response_mod_dir, "cfn-response.js"), + NODEJS_CFN_RESPONSE_CONTENT, + ) + save_file(os.path.join(tmp_dir, "cfn-response.js"), NODEJS_CFN_RESPONSE_CONTENT) + elif ( + properties["Runtime"].lower().startswith("python") + and "import cfnresponse" in zip_file + ): + save_file(os.path.join(tmp_dir, "cfnresponse.py"), PYTHON_CFN_RESPONSE_CONTENT) + + # create zip file + zip_file = create_zip_file(tmp_dir, get_content=True) + code["ZipFile"] = zip_file + finally: + rm_rf(tmp_dir) + if _include_arch and "Architectures" in properties: + code["Architectures"] = properties.get("Architectures") + return code + + +def _transform_function_to_model(function): + model_properties = [ + "MemorySize", + "Description", + "TracingConfig", + "Timeout", + "Handler", + "SnapStartResponse", + "Role", + "FileSystemConfigs", + "FunctionName", + "Runtime", + "PackageType", + "LoggingConfig", + "Environment", + "Arn", + "EphemeralStorage", + "Architectures", + ] + response_model = util.select_attributes(function, model_properties) + response_model["Arn"] = function["FunctionArn"] + return response_model + + +class LambdaFunctionProvider(ResourceProvider[LambdaFunctionProperties]): + TYPE = "AWS::Lambda::Function" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaFunctionProperties], + ) -> ProgressEvent[LambdaFunctionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/FunctionName + + Required properties: + - Code + - Role + + Create-only properties: + - /properties/FunctionName + + Read-only properties: + - /properties/Arn + - /properties/SnapStartResponse + - /properties/SnapStartResponse/ApplyOn + - /properties/SnapStartResponse/OptimizationStatus + + IAM permissions required: + - lambda:CreateFunction + - lambda:GetFunction + - lambda:PutFunctionConcurrency + - iam:PassRole + - s3:GetObject + - s3:GetObjectVersion + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeVpcs + - elasticfilesystem:DescribeMountTargets + - kms:CreateGrant + - kms:Decrypt + - kms:Encrypt + - kms:GenerateDataKey + - lambda:GetCodeSigningConfig + - lambda:GetFunctionCodeSigningConfig + - lambda:GetLayerVersion + - lambda:GetRuntimeManagementConfig + - lambda:PutRuntimeManagementConfig + - lambda:TagResource + - lambda:GetPolicy + - lambda:AddPermission + - lambda:RemovePermission + - lambda:GetResourcePolicy + - lambda:PutResourcePolicy + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + + name = model.get("FunctionName") + if not name: + name = util.generate_default_name(request.stack_name, request.logical_resource_id) + model["FunctionName"] = name + + kwargs = util.select_attributes( + model, + [ + "Architectures", + "DeadLetterConfig", + "Description", + "FunctionName", + "Handler", + "ImageConfig", + "PackageType", + "Layers", + "MemorySize", + "Runtime", + "Role", + "Timeout", + "TracingConfig", + "VpcConfig", + "LoggingConfig", + ], + ) + if "Timeout" in kwargs: + kwargs["Timeout"] = int(kwargs["Timeout"]) + if "MemorySize" in kwargs: + kwargs["MemorySize"] = int(kwargs["MemorySize"]) + if model_tags := model.get("Tags"): + tags = {} + for tag in model_tags: + tags[tag["Key"]] = tag["Value"] + kwargs["Tags"] = tags + + # botocore/data/lambda/2015-03-31/service-2.json:1161 (EnvironmentVariableValue) + # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-environment.html + if "Environment" in model: + environment_variables = model["Environment"].get("Variables", {}) + kwargs["Environment"] = { + "Variables": {k: str(v) for k, v in environment_variables.items()} + } + + kwargs["Code"] = _get_lambda_code_param(model) + create_response = lambda_client.create_function(**kwargs) + model["Arn"] = create_response["FunctionArn"] + + get_fn_response = lambda_client.get_function(FunctionName=model["Arn"]) + match get_fn_response["Configuration"]["State"]: + case "Pending": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + case "Active": + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + case "Inactive": + # This might happen when setting LAMBDA_KEEPALIVE_MS=0 + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + case "Failed": + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + error_code=get_fn_response["Configuration"].get("StateReasonCode", "unknown"), + message=get_fn_response["Configuration"].get("StateReason", "unknown"), + ) + case unknown_state: # invalid state, should technically never happen + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + error_code="InternalException", + message=f"Invalid state returned: {unknown_state}", + ) + + def read( + self, + request: ResourceRequest[LambdaFunctionProperties], + ) -> ProgressEvent[LambdaFunctionProperties]: + """ + Fetch resource information + + IAM permissions required: + - lambda:GetFunction + - lambda:GetFunctionCodeSigningConfig + """ + function_name = request.desired_state["FunctionName"] + lambda_client = request.aws_client_factory.lambda_ + get_fn_response = lambda_client.get_function(FunctionName=function_name) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=_transform_function_to_model(get_fn_response["Configuration"]), + ) + + def delete( + self, + request: ResourceRequest[LambdaFunctionProperties], + ) -> ProgressEvent[LambdaFunctionProperties]: + """ + Delete a resource + + IAM permissions required: + - lambda:DeleteFunction + - ec2:DescribeNetworkInterfaces + """ + try: + lambda_client = request.aws_client_factory.lambda_ + lambda_client.delete_function(FunctionName=request.previous_state["FunctionName"]) + except request.aws_client_factory.lambda_.exceptions.ResourceNotFoundException: + pass + # any other exception will be propagated + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[LambdaFunctionProperties], + ) -> ProgressEvent[LambdaFunctionProperties]: + """ + Update a resource + + IAM permissions required: + - lambda:DeleteFunctionConcurrency + - lambda:GetFunction + - lambda:PutFunctionConcurrency + - lambda:ListTags + - lambda:TagResource + - lambda:UntagResource + - lambda:UpdateFunctionConfiguration + - lambda:UpdateFunctionCode + - iam:PassRole + - s3:GetObject + - s3:GetObjectVersion + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeVpcs + - elasticfilesystem:DescribeMountTargets + - kms:CreateGrant + - kms:Decrypt + - kms:GenerateDataKey + - lambda:GetRuntimeManagementConfig + - lambda:PutRuntimeManagementConfig + - lambda:PutFunctionCodeSigningConfig + - lambda:DeleteFunctionCodeSigningConfig + - lambda:GetCodeSigningConfig + - lambda:GetFunctionCodeSigningConfig + - lambda:GetPolicy + - lambda:AddPermission + - lambda:RemovePermission + - lambda:GetResourcePolicy + - lambda:PutResourcePolicy + - lambda:DeleteResourcePolicy + """ + client = request.aws_client_factory.lambda_ + + # TODO: handle defaults properly + old_name = request.previous_state["FunctionName"] + new_name = request.desired_state.get("FunctionName") + if new_name and old_name != new_name: + # replacement (!) => shouldn't be handled here but in the engine + self.delete(request) + return self.create(request) + + config_keys = [ + "Description", + "DeadLetterConfig", + "Environment", + "Handler", + "ImageConfig", + "Layers", + "MemorySize", + "Role", + "Runtime", + "Timeout", + "TracingConfig", + "VpcConfig", + "LoggingConfig", + ] + update_config_props = util.select_attributes(request.desired_state, config_keys) + function_name = request.previous_state["FunctionName"] + update_config_props["FunctionName"] = function_name + + if "Timeout" in update_config_props: + update_config_props["Timeout"] = int(update_config_props["Timeout"]) + if "MemorySize" in update_config_props: + update_config_props["MemorySize"] = int(update_config_props["MemorySize"]) + if "Code" in request.desired_state: + code = request.desired_state["Code"] or {} + if not code.get("ZipFile"): + request.logger.debug( + 'Updating code for Lambda "%s" from location: %s', function_name, code + ) + code = _get_lambda_code_param( + request.desired_state, + _include_arch=True, + ) + client.update_function_code(FunctionName=function_name, **code) + client.get_waiter("function_updated_v2").wait(FunctionName=function_name) + if "Environment" in update_config_props: + environment_variables = update_config_props["Environment"].get("Variables", {}) + update_config_props["Environment"]["Variables"] = { + k: str(v) for k, v in environment_variables.items() + } + client.update_function_configuration(**update_config_props) + client.get_waiter("function_updated_v2").wait(FunctionName=function_name) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={**request.previous_state, **request.desired_state}, + ) + + def list( + self, + request: ResourceRequest[LambdaFunctionProperties], + ) -> ProgressEvent[LambdaFunctionProperties]: + functions = request.aws_client_factory.lambda_.list_functions() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[_transform_function_to_model(fn) for fn in functions["Functions"]], + ) diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_function.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_function.schema.json new file mode 100644 index 0000000000000..b1d128047b150 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_function.schema.json @@ -0,0 +1,566 @@ +{ + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "tagProperty": "/properties/Tags", + "cloudFormationSystemTags": true + }, + "handlers": { + "read": { + "permissions": [ + "lambda:GetFunction", + "lambda:GetFunctionCodeSigningConfig" + ] + }, + "create": { + "permissions": [ + "lambda:CreateFunction", + "lambda:GetFunction", + "lambda:PutFunctionConcurrency", + "iam:PassRole", + "s3:GetObject", + "s3:GetObjectVersion", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "elasticfilesystem:DescribeMountTargets", + "kms:CreateGrant", + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey", + "lambda:GetCodeSigningConfig", + "lambda:GetFunctionCodeSigningConfig", + "lambda:GetLayerVersion", + "lambda:GetRuntimeManagementConfig", + "lambda:PutRuntimeManagementConfig", + "lambda:TagResource", + "lambda:GetPolicy", + "lambda:AddPermission", + "lambda:RemovePermission", + "lambda:GetResourcePolicy", + "lambda:PutResourcePolicy" + ] + }, + "update": { + "permissions": [ + "lambda:DeleteFunctionConcurrency", + "lambda:GetFunction", + "lambda:PutFunctionConcurrency", + "lambda:ListTags", + "lambda:TagResource", + "lambda:UntagResource", + "lambda:UpdateFunctionConfiguration", + "lambda:UpdateFunctionCode", + "iam:PassRole", + "s3:GetObject", + "s3:GetObjectVersion", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "elasticfilesystem:DescribeMountTargets", + "kms:CreateGrant", + "kms:Decrypt", + "kms:GenerateDataKey", + "lambda:GetRuntimeManagementConfig", + "lambda:PutRuntimeManagementConfig", + "lambda:PutFunctionCodeSigningConfig", + "lambda:DeleteFunctionCodeSigningConfig", + "lambda:GetCodeSigningConfig", + "lambda:GetFunctionCodeSigningConfig", + "lambda:GetPolicy", + "lambda:AddPermission", + "lambda:RemovePermission", + "lambda:GetResourcePolicy", + "lambda:PutResourcePolicy", + "lambda:DeleteResourcePolicy" + ] + }, + "list": { + "permissions": [ + "lambda:ListFunctions" + ] + }, + "delete": { + "permissions": [ + "lambda:DeleteFunction", + "ec2:DescribeNetworkInterfaces" + ] + } + }, + "typeName": "AWS::Lambda::Function", + "readOnlyProperties": [ + "/properties/SnapStartResponse", + "/properties/SnapStartResponse/ApplyOn", + "/properties/SnapStartResponse/OptimizationStatus", + "/properties/Arn" + ], + "description": "Resource Type definition for AWS::Lambda::Function in region", + "writeOnlyProperties": [ + "/properties/SnapStart", + "/properties/SnapStart/ApplyOn", + "/properties/Code", + "/properties/Code/ImageUri", + "/properties/Code/S3Bucket", + "/properties/Code/S3Key", + "/properties/Code/S3ObjectVersion", + "/properties/Code/ZipFile" + ], + "createOnlyProperties": [ + "/properties/FunctionName" + ], + "additionalProperties": false, + "primaryIdentifier": [ + "/properties/FunctionName" + ], + "definitions": { + "ImageConfig": { + "additionalProperties": false, + "type": "object", + "properties": { + "WorkingDirectory": { + "description": "WorkingDirectory.", + "type": "string" + }, + "Command": { + "maxItems": 1500, + "uniqueItems": true, + "description": "Command.", + "type": "array", + "items": { + "type": "string" + } + }, + "EntryPoint": { + "maxItems": 1500, + "uniqueItems": true, + "description": "EntryPoint.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TracingConfig": { + "description": "The function's AWS X-Ray tracing configuration. To sample and record incoming requests, set Mode to Active.", + "additionalProperties": false, + "type": "object", + "properties": { + "Mode": { + "description": "The tracing mode.", + "type": "string", + "enum": [ + "Active", + "PassThrough" + ] + } + } + }, + "VpcConfig": { + "description": "The VPC security groups and subnets that are attached to a Lambda function. When you connect a function to a VPC, Lambda creates an elastic network interface for each combination of security group and subnet in the function's VPC configuration. The function can only access resources and the internet through that VPC.", + "additionalProperties": false, + "type": "object", + "properties": { + "Ipv6AllowedForDualStack": { + "description": "A boolean indicating whether IPv6 protocols will be allowed for dual stack subnets", + "type": "boolean" + }, + "SecurityGroupIds": { + "maxItems": 5, + "uniqueItems": false, + "description": "A list of VPC security groups IDs.", + "type": "array", + "items": { + "type": "string" + } + }, + "SubnetIds": { + "maxItems": 16, + "uniqueItems": false, + "description": "A list of VPC subnet IDs.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "DeadLetterConfig": { + "description": "The dead-letter queue for failed asynchronous invocations.", + "additionalProperties": false, + "type": "object", + "properties": { + "TargetArn": { + "pattern": "^(arn:(aws[a-zA-Z-]*)?:[a-z0-9-.]+:.*)|()$", + "description": "The Amazon Resource Name (ARN) of an Amazon SQS queue or Amazon SNS topic.", + "type": "string" + } + } + }, + "RuntimeManagementConfig": { + "additionalProperties": false, + "type": "object", + "properties": { + "UpdateRuntimeOn": { + "description": "Trigger for runtime update", + "type": "string", + "enum": [ + "Auto", + "FunctionUpdate", + "Manual" + ] + }, + "RuntimeVersionArn": { + "description": "Unique identifier for a runtime version arn", + "type": "string" + } + }, + "required": [ + "UpdateRuntimeOn" + ] + }, + "SnapStart": { + "description": "The function's SnapStart setting. When set to PublishedVersions, Lambda creates a snapshot of the execution environment when you publish a function version.", + "additionalProperties": false, + "type": "object", + "properties": { + "ApplyOn": { + "description": "Applying SnapStart setting on function resource type.", + "type": "string", + "enum": [ + "PublishedVersions", + "None" + ] + } + }, + "required": [ + "ApplyOn" + ] + }, + "SnapStartResponse": { + "description": "The function's SnapStart Response. When set to PublishedVersions, Lambda creates a snapshot of the execution environment when you publish a function version.", + "additionalProperties": false, + "type": "object", + "properties": { + "OptimizationStatus": { + "description": "Indicates whether SnapStart is activated for the specified function version.", + "type": "string", + "enum": [ + "On", + "Off" + ] + }, + "ApplyOn": { + "description": "Applying SnapStart setting on function resource type.", + "type": "string", + "enum": [ + "PublishedVersions", + "None" + ] + } + } + }, + "Code": { + "additionalProperties": false, + "type": "object", + "properties": { + "S3ObjectVersion": { + "minLength": 1, + "description": "For versioned objects, the version of the deployment package object to use.", + "type": "string", + "maxLength": 1024 + }, + "S3Bucket": { + "minLength": 3, + "pattern": "^[0-9A-Za-z\\.\\-_]*(? ProgressEvent[LambdaLayerVersionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/LayerVersionArn + + Required properties: + - Content + + Create-only properties: + - /properties/CompatibleRuntimes + - /properties/LicenseInfo + - /properties/CompatibleArchitectures + - /properties/LayerName + - /properties/Description + - /properties/Content + + Read-only properties: + - /properties/LayerVersionArn + + IAM permissions required: + - lambda:PublishLayerVersion + - s3:GetObject + - s3:GetObjectVersion + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + if not model.get("LayerName"): + model["LayerName"] = f"layer-{short_uid()}" + response = lambda_client.publish_layer_version(**model) + model["LayerVersionArn"] = response["LayerVersionArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LambdaLayerVersionProperties], + ) -> ProgressEvent[LambdaLayerVersionProperties]: + """ + Fetch resource information + + IAM permissions required: + - lambda:GetLayerVersion + """ + lambda_client = request.aws_client_factory.lambda_ + layer_version_arn = request.desired_state.get("LayerVersionArn") + + try: + _, _, layer_name, version = parse_layer_arn(layer_version_arn) + except AttributeError as e: + LOG.info( + "Invalid Arn: '%s', %s", + layer_version_arn, + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + return ProgressEvent( + status=OperationStatus.FAILED, + message="Caught unexpected syntax violation. Consider using ARN.fromString().", + error_code="InternalFailure", + ) + + if not version: + return ProgressEvent( + status=OperationStatus.FAILED, + message="Invalid request provided: Layer Version ARN contains invalid layer name or version", + error_code="InvalidRequest", + ) + + try: + response = lambda_client.get_layer_version_by_arn(Arn=layer_version_arn) + except lambda_client.exceptions.ResourceNotFoundException as e: + return ProgressEvent( + status=OperationStatus.FAILED, + message="The resource you requested does not exist. " + f"(Service: Lambda, Status Code: 404, Request ID: {e.response['ResponseMetadata']['RequestId']})", + error_code="NotFound", + ) + layer = util.select_attributes( + response, + [ + "CompatibleRuntimes", + "Description", + "LayerVersionArn", + "CompatibleArchitectures", + ], + ) + layer.setdefault("CompatibleRuntimes", []) + layer.setdefault("CompatibleArchitectures", []) + layer.setdefault("LayerName", layer_name) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=layer, + custom_context=request.custom_context, + ) + + def delete( + self, + request: ResourceRequest[LambdaLayerVersionProperties], + ) -> ProgressEvent[LambdaLayerVersionProperties]: + """ + Delete a resource + + IAM permissions required: + - lambda:GetLayerVersion + - lambda:DeleteLayerVersion + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + version = int(model["LayerVersionArn"].split(":")[-1]) + + lambda_client.delete_layer_version(LayerName=model["LayerName"], VersionNumber=version) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaLayerVersionProperties], + ) -> ProgressEvent[LambdaLayerVersionProperties]: + """ + Update a resource + + + """ + raise NotImplementedError + + def list(self, request: ResourceRequest[Properties]) -> ProgressEvent[Properties]: + """ + List resources + + IAM permissions required: + - lambda:ListLayerVersions + """ + + lambda_client = request.aws_client_factory.lambda_ + + lambda_layer = request.desired_state.get("LayerName") + if not lambda_layer: + return ProgressEvent( + status=OperationStatus.FAILED, + message="Layer Name cannot be empty", + error_code="InvalidRequest", + ) + + layer_versions = lambda_client.list_layer_versions(LayerName=lambda_layer) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + LambdaLayerVersionProperties(LayerVersionArn=layer_version["LayerVersionArn"]) + for layer_version in layer_versions["LayerVersions"] + ], + custom_context=request.custom_context, + ) diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.schema.json new file mode 100644 index 0000000000000..7bc8e494ecd93 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion.schema.json @@ -0,0 +1,120 @@ +{ + "typeName": "AWS::Lambda::LayerVersion", + "description": "Resource Type definition for AWS::Lambda::LayerVersion", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-lambda.git", + "definitions": { + "Content": { + "type": "object", + "additionalProperties": false, + "properties": { + "S3ObjectVersion": { + "description": "For versioned objects, the version of the layer archive object to use.", + "type": "string" + }, + "S3Bucket": { + "description": "The Amazon S3 bucket of the layer archive.", + "type": "string" + }, + "S3Key": { + "description": "The Amazon S3 key of the layer archive.", + "type": "string" + } + }, + "required": [ + "S3Bucket", + "S3Key" + ] + } + }, + "properties": { + "CompatibleRuntimes": { + "description": "A list of compatible function runtimes. Used for filtering with ListLayers and ListLayerVersions.", + "type": "array", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "LicenseInfo": { + "description": "The layer's software license.", + "type": "string" + }, + "Description": { + "description": "The description of the version.", + "type": "string" + }, + "LayerName": { + "description": "The name or Amazon Resource Name (ARN) of the layer.", + "type": "string" + }, + "Content": { + "description": "The function layer archive.", + "$ref": "#/definitions/Content" + }, + "LayerVersionArn": { + "type": "string" + }, + "CompatibleArchitectures": { + "description": "A list of compatible instruction set architectures.", + "type": "array", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "Content" + ], + "createOnlyProperties": [ + "/properties/CompatibleRuntimes", + "/properties/LicenseInfo", + "/properties/CompatibleArchitectures", + "/properties/LayerName", + "/properties/Description", + "/properties/Content" + ], + "readOnlyProperties": [ + "/properties/LayerVersionArn" + ], + "writeOnlyProperties": [ + "/properties/Content" + ], + "primaryIdentifier": [ + "/properties/LayerVersionArn" + ], + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "handlers": { + "create": { + "permissions": [ + "lambda:PublishLayerVersion", + "s3:GetObject", + "s3:GetObjectVersion" + ] + }, + "read": { + "permissions": [ + "lambda:GetLayerVersion" + ] + }, + "delete": { + "permissions": [ + "lambda:GetLayerVersion", + "lambda:DeleteLayerVersion" + ] + }, + "list": { + "permissions": [ + "lambda:ListLayerVersions" + ] + } + } +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion_plugin.py new file mode 100644 index 0000000000000..7ebe11a9c7647 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversion_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaLayerVersionProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::LayerVersion" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_layerversion import ( + LambdaLayerVersionProvider, + ) + + self.factory = LambdaLayerVersionProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission.py new file mode 100644 index 0000000000000..e6622141a165c --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission.py @@ -0,0 +1,134 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaLayerVersionPermissionProperties(TypedDict): + Action: Optional[str] + LayerVersionArn: Optional[str] + Principal: Optional[str] + Id: Optional[str] + OrganizationId: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaLayerVersionPermissionProvider( + ResourceProvider[LambdaLayerVersionPermissionProperties] +): + TYPE = "AWS::Lambda::LayerVersionPermission" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaLayerVersionPermissionProperties], + ) -> ProgressEvent[LambdaLayerVersionPermissionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - LayerVersionArn + - Action + - Principal + + Create-only properties: + - /properties/OrganizationId + - /properties/Principal + - /properties/Action + - /properties/LayerVersionArn + + Read-only properties: + - /properties/Id + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + model["Id"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + layer_name, version_number = self.layer_name_and_version(model) + + params = util.select_attributes(model, ["Action", "Principal"]) + params["StatementId"] = model["Id"].split("-")[-1] + params["LayerName"] = layer_name + params["VersionNumber"] = version_number + + lambda_client.add_layer_version_permission(**params) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + @staticmethod + def layer_name_and_version(params): + layer_arn = params.get("LayerVersionArn", "") + parts = layer_arn.split(":") + layer_name = parts[6] if ":" in layer_arn else layer_arn + version_number = int(parts[7] if len(parts) > 7 else 1) # TODO fetch latest version number + return layer_name, version_number + + def read( + self, + request: ResourceRequest[LambdaLayerVersionPermissionProperties], + ) -> ProgressEvent[LambdaLayerVersionPermissionProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaLayerVersionPermissionProperties], + ) -> ProgressEvent[LambdaLayerVersionPermissionProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + layer_name, version_number = self.layer_name_and_version(model) + params = { + "StatementId": model["Id"].split("-")[-1], + "LayerName": layer_name, + "VersionNumber": version_number, + } + + lambda_client.remove_layer_version_permission(**params) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaLayerVersionPermissionProperties], + ) -> ProgressEvent[LambdaLayerVersionPermissionProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission.schema.json new file mode 100644 index 0000000000000..423d497ab5e36 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission.schema.json @@ -0,0 +1,39 @@ +{ + "typeName": "AWS::Lambda::LayerVersionPermission", + "description": "Resource Type definition for AWS::Lambda::LayerVersionPermission", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "Action": { + "type": "string" + }, + "LayerVersionArn": { + "type": "string" + }, + "OrganizationId": { + "type": "string" + }, + "Principal": { + "type": "string" + } + }, + "required": [ + "LayerVersionArn", + "Action", + "Principal" + ], + "createOnlyProperties": [ + "/properties/OrganizationId", + "/properties/Principal", + "/properties/Action", + "/properties/LayerVersionArn" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission_plugin.py new file mode 100644 index 0000000000000..339765439b293 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_layerversionpermission_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaLayerVersionPermissionProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::LayerVersionPermission" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_layerversionpermission import ( + LambdaLayerVersionPermissionProvider, + ) + + self.factory = LambdaLayerVersionPermissionProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission.py new file mode 100644 index 0000000000000..315e5a015502e --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission.py @@ -0,0 +1,155 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaPermissionProperties(TypedDict): + Action: Optional[str] + FunctionName: Optional[str] + Principal: Optional[str] + EventSourceToken: Optional[str] + FunctionUrlAuthType: Optional[str] + Id: Optional[str] + PrincipalOrgID: Optional[str] + SourceAccount: Optional[str] + SourceArn: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaPermissionProvider(ResourceProvider[LambdaPermissionProperties]): + TYPE = "AWS::Lambda::Permission" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaPermissionProperties], + ) -> ProgressEvent[LambdaPermissionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - FunctionName + - Action + - Principal + + Create-only properties: + - /properties/SourceAccount + - /properties/FunctionUrlAuthType + - /properties/SourceArn + - /properties/Principal + - /properties/Action + - /properties/FunctionName + - /properties/EventSourceToken + - /properties/PrincipalOrgID + + Read-only properties: + - /properties/Id + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + params = util.select_attributes( + model=model, params=["FunctionName", "Action", "Principal", "SourceArn"] + ) + + params["StatementId"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + response = lambda_client.add_permission(**params) + + parsed_statement = json.loads(response["Statement"]) + model["Id"] = parsed_statement["Sid"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LambdaPermissionProperties], + ) -> ProgressEvent[LambdaPermissionProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaPermissionProperties], + ) -> ProgressEvent[LambdaPermissionProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + try: + lambda_client.remove_permission( + FunctionName=model.get("FunctionName"), StatementId=model["Id"] + ) + except lambda_client.exceptions.ResourceNotFoundException: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaPermissionProperties], + ) -> ProgressEvent[LambdaPermissionProperties]: + """ + Update a resource + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + if not model.get("Id"): + model["Id"] = request.previous_state["Id"] + + params = util.select_attributes( + model=model, params=["FunctionName", "Action", "Principal", "SourceArn"] + ) + + try: + lambda_client.remove_permission( + FunctionName=model.get("FunctionName"), StatementId=model["Id"] + ) + except lambda_client.exceptions.ResourceNotFoundException: + pass + + lambda_client.add_permission(StatementId=model["Id"], **params) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission.schema.json new file mode 100644 index 0000000000000..15f7f30168b41 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission.schema.json @@ -0,0 +1,55 @@ +{ + "typeName": "AWS::Lambda::Permission", + "description": "Resource Type definition for AWS::Lambda::Permission", + "additionalProperties": false, + "properties": { + "FunctionName": { + "type": "string" + }, + "Action": { + "type": "string" + }, + "EventSourceToken": { + "type": "string" + }, + "FunctionUrlAuthType": { + "type": "string" + }, + "SourceArn": { + "type": "string" + }, + "SourceAccount": { + "type": "string" + }, + "PrincipalOrgID": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "Principal": { + "type": "string" + } + }, + "required": [ + "FunctionName", + "Action", + "Principal" + ], + "createOnlyProperties": [ + "/properties/SourceAccount", + "/properties/FunctionUrlAuthType", + "/properties/SourceArn", + "/properties/Principal", + "/properties/Action", + "/properties/FunctionName", + "/properties/EventSourceToken", + "/properties/PrincipalOrgID" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission_plugin.py new file mode 100644 index 0000000000000..4a06f49c62ee4 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_permission_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaPermissionProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::Permission" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_permission import ( + LambdaPermissionProvider, + ) + + self.factory = LambdaPermissionProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url.py new file mode 100644 index 0000000000000..c9b157dd26a89 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url.py @@ -0,0 +1,131 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaUrlProperties(TypedDict): + AuthType: Optional[str] + TargetFunctionArn: Optional[str] + Cors: Optional[Cors] + FunctionArn: Optional[str] + FunctionUrl: Optional[str] + InvokeMode: Optional[str] + Qualifier: Optional[str] + + +class Cors(TypedDict): + AllowCredentials: Optional[bool] + AllowHeaders: Optional[list[str]] + AllowMethods: Optional[list[str]] + AllowOrigins: Optional[list[str]] + ExposeHeaders: Optional[list[str]] + MaxAge: Optional[int] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaUrlProvider(ResourceProvider[LambdaUrlProperties]): + TYPE = "AWS::Lambda::Url" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaUrlProperties], + ) -> ProgressEvent[LambdaUrlProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/FunctionArn + + Required properties: + - TargetFunctionArn + - AuthType + + Create-only properties: + - /properties/TargetFunctionArn + - /properties/Qualifier + + Read-only properties: + - /properties/FunctionUrl + - /properties/FunctionArn + + IAM permissions required: + - lambda:CreateFunctionUrlConfig + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + params = util.select_attributes(model, ["Qualifier", "Cors", "AuthType"]) + params["FunctionName"] = model["TargetFunctionArn"] + + response = lambda_client.create_function_url_config(**params) + + model["FunctionArn"] = response["FunctionArn"] + model["FunctionUrl"] = response["FunctionUrl"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LambdaUrlProperties], + ) -> ProgressEvent[LambdaUrlProperties]: + """ + Fetch resource information + + IAM permissions required: + - lambda:GetFunctionUrlConfig + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaUrlProperties], + ) -> ProgressEvent[LambdaUrlProperties]: + """ + Delete a resource + + IAM permissions required: + - lambda:DeleteFunctionUrlConfig + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + params = {"FunctionName": model["TargetFunctionArn"]} + + if qualifier := model.get("Qualifier"): + params["Qualifier"] = qualifier + + lambda_client.delete_function_url_config(**params) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaUrlProperties], + ) -> ProgressEvent[LambdaUrlProperties]: + """ + Update a resource + + IAM permissions required: + - lambda:UpdateFunctionUrlConfig + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url.schema.json new file mode 100644 index 0000000000000..de715b7e1506b --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url.schema.json @@ -0,0 +1,180 @@ +{ + "typeName": "AWS::Lambda::Url", + "description": "Resource Type definition for AWS::Lambda::Url", + "additionalProperties": false, + "tagging": { + "taggable": false + }, + "properties": { + "TargetFunctionArn": { + "description": "The Amazon Resource Name (ARN) of the function associated with the Function URL.", + "type": "string", + "pattern": "^(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:((?!\\d+)[0-9a-zA-Z-_]+))?$" + }, + "Qualifier": { + "description": "The alias qualifier for the target function. If TargetFunctionArn is unqualified then Qualifier must be passed.", + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "((?!^[0-9]+$)([a-zA-Z0-9-_]+))" + }, + "AuthType": { + "description": "Can be either AWS_IAM if the requests are authorized via IAM, or NONE if no authorization is configured on the Function URL.", + "type": "string", + "enum": [ + "AWS_IAM", + "NONE" + ] + }, + "InvokeMode": { + "description": "The invocation mode for the function\u2019s URL. Set to BUFFERED if you want to buffer responses before returning them to the client. Set to RESPONSE_STREAM if you want to stream responses, allowing faster time to first byte and larger response payload sizes. If not set, defaults to BUFFERED.", + "type": "string", + "enum": [ + "BUFFERED", + "RESPONSE_STREAM" + ] + }, + "FunctionArn": { + "description": "The full Amazon Resource Name (ARN) of the function associated with the Function URL.", + "type": "string", + "pattern": "^(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:((?!\\d+)[0-9a-zA-Z-_]+))?$" + }, + "FunctionUrl": { + "description": "The generated url for this resource.", + "type": "string" + }, + "Cors": { + "$ref": "#/definitions/Cors" + } + }, + "definitions": { + "AllowHeaders": { + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "type": "array", + "minItems": 1, + "maxItems": 100, + "insertionOrder": true + }, + "AllowMethods": { + "items": { + "type": "string", + "enum": [ + "GET", + "PUT", + "HEAD", + "POST", + "PATCH", + "DELETE", + "*" + ] + }, + "type": "array", + "minItems": 1, + "maxItems": 6, + "insertionOrder": true + }, + "AllowOrigins": { + "items": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "type": "array", + "minItems": 1, + "maxItems": 100, + "insertionOrder": true + }, + "ExposeHeaders": { + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "type": "array", + "minItems": 1, + "maxItems": 100, + "insertionOrder": true + }, + "Cors": { + "additionalProperties": false, + "properties": { + "AllowCredentials": { + "description": "Specifies whether credentials are included in the CORS request.", + "type": "boolean" + }, + "AllowHeaders": { + "description": "Represents a collection of allowed headers.", + "$ref": "#/definitions/AllowHeaders" + }, + "AllowMethods": { + "description": "Represents a collection of allowed HTTP methods.", + "$ref": "#/definitions/AllowMethods" + }, + "AllowOrigins": { + "description": "Represents a collection of allowed origins.", + "$ref": "#/definitions/AllowOrigins" + }, + "ExposeHeaders": { + "description": "Represents a collection of exposed headers.", + "$ref": "#/definitions/ExposeHeaders" + }, + "MaxAge": { + "type": "integer", + "minimum": 0, + "maximum": 86400 + } + }, + "type": "object" + } + }, + "required": [ + "TargetFunctionArn", + "AuthType" + ], + "createOnlyProperties": [ + "/properties/TargetFunctionArn", + "/properties/Qualifier" + ], + "readOnlyProperties": [ + "/properties/FunctionUrl", + "/properties/FunctionArn" + ], + "writeOnlyProperties": [ + "/properties/TargetFunctionArn", + "/properties/Qualifier" + ], + "primaryIdentifier": [ + "/properties/FunctionArn" + ], + "handlers": { + "create": { + "permissions": [ + "lambda:CreateFunctionUrlConfig" + ] + }, + "read": { + "permissions": [ + "lambda:GetFunctionUrlConfig" + ] + }, + "update": { + "permissions": [ + "lambda:UpdateFunctionUrlConfig" + ] + }, + "list": { + "permissions": [ + "lambda:ListFunctionUrlConfigs" + ] + }, + "delete": { + "permissions": [ + "lambda:DeleteFunctionUrlConfig" + ] + } + } +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url_plugin.py new file mode 100644 index 0000000000000..afa2d4a2b92b5 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_url_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaUrlProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::Url" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_url import LambdaUrlProvider + + self.factory = LambdaUrlProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.py new file mode 100644 index 0000000000000..adc04756a59c5 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.py @@ -0,0 +1,171 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaVersionProperties(TypedDict): + FunctionName: Optional[str] + CodeSha256: Optional[str] + Description: Optional[str] + Id: Optional[str] + ProvisionedConcurrencyConfig: Optional[ProvisionedConcurrencyConfiguration] + Version: Optional[str] + + +class ProvisionedConcurrencyConfiguration(TypedDict): + ProvisionedConcurrentExecutions: Optional[int] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaVersionProvider(ResourceProvider[LambdaVersionProperties]): + TYPE = "AWS::Lambda::Version" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaVersionProperties], + ) -> ProgressEvent[LambdaVersionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - FunctionName + + Create-only properties: + - /properties/FunctionName + + Read-only properties: + - /properties/Id + - /properties/Version + + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + ctx = request.custom_context + + params = util.select_attributes(model, ["FunctionName", "CodeSha256", "Description"]) + + if not ctx.get(REPEATED_INVOCATION): + response = lambda_client.publish_version(**params) + model["Version"] = response["Version"] + model["Id"] = response["FunctionArn"] + if model.get("ProvisionedConcurrencyConfig"): + lambda_client.put_provisioned_concurrency_config( + FunctionName=model["FunctionName"], + Qualifier=model["Version"], + ProvisionedConcurrentExecutions=model["ProvisionedConcurrencyConfig"][ + "ProvisionedConcurrentExecutions" + ], + ) + ctx[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + if model.get("ProvisionedConcurrencyConfig"): + # Assumption: Ready provisioned concurrency implies the function version is ready + provisioned_concurrency_config = lambda_client.get_provisioned_concurrency_config( + FunctionName=model["FunctionName"], + Qualifier=model["Version"], + ) + if provisioned_concurrency_config["Status"] == "IN_PROGRESS": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + elif provisioned_concurrency_config["Status"] == "READY": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + else: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="", + error_code="VersionStateFailure", # TODO: not parity tested + ) + else: + version = lambda_client.get_function(FunctionName=model["Id"]) + if version["Configuration"]["State"] == "Pending": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + elif version["Configuration"]["State"] == "Active": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + else: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="", + error_code="VersionStateFailure", # TODO: not parity tested + ) + + def read( + self, + request: ResourceRequest[LambdaVersionProperties], + ) -> ProgressEvent[LambdaVersionProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaVersionProperties], + ) -> ProgressEvent[LambdaVersionProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + lambda_client = request.aws_client_factory.lambda_ + + # without qualifier entire function is deleted instead of just version + # provisioned concurrency is automatically deleted upon deleting a function or function version + lambda_client.delete_function(FunctionName=model["Id"], Qualifier=model["Version"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LambdaVersionProperties], + ) -> ProgressEvent[LambdaVersionProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.schema.json new file mode 100644 index 0000000000000..f4a0320af6231 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version.schema.json @@ -0,0 +1,52 @@ +{ + "typeName": "AWS::Lambda::Version", + "description": "Resource Type definition for AWS::Lambda::Version", + "additionalProperties": false, + "properties": { + "FunctionName": { + "type": "string" + }, + "ProvisionedConcurrencyConfig": { + "$ref": "#/definitions/ProvisionedConcurrencyConfiguration" + }, + "Description": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "CodeSha256": { + "type": "string" + }, + "Version": { + "type": "string" + } + }, + "definitions": { + "ProvisionedConcurrencyConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "ProvisionedConcurrentExecutions": { + "type": "integer" + } + }, + "required": [ + "ProvisionedConcurrentExecutions" + ] + } + }, + "required": [ + "FunctionName" + ], + "createOnlyProperties": [ + "/properties/FunctionName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/Version" + ] +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version_plugin.py new file mode 100644 index 0000000000000..ad6e92edb426d --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/aws_lambda_version_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaVersionProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::Version" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.aws_lambda_version import ( + LambdaVersionProvider, + ) + + self.factory = LambdaVersionProvider diff --git a/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.py b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.py new file mode 100644 index 0000000000000..044eeed162845 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.py @@ -0,0 +1,174 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LambdaAliasProperties(TypedDict): + FunctionName: Optional[str] + FunctionVersion: Optional[str] + Name: Optional[str] + Description: Optional[str] + Id: Optional[str] + ProvisionedConcurrencyConfig: Optional[ProvisionedConcurrencyConfiguration] + RoutingConfig: Optional[AliasRoutingConfiguration] + + +class ProvisionedConcurrencyConfiguration(TypedDict): + ProvisionedConcurrentExecutions: Optional[int] + + +class VersionWeight(TypedDict): + FunctionVersion: Optional[str] + FunctionWeight: Optional[float] + + +class AliasRoutingConfiguration(TypedDict): + AdditionalVersionWeights: Optional[list[VersionWeight]] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LambdaAliasProvider(ResourceProvider[LambdaAliasProperties]): + TYPE = "AWS::Lambda::Alias" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LambdaAliasProperties], + ) -> ProgressEvent[LambdaAliasProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - FunctionName + - FunctionVersion + - Name + + Create-only properties: + - /properties/Name + - /properties/FunctionName + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + lambda_ = request.aws_client_factory.lambda_ + + create_params = util.select_attributes( + model, ["FunctionName", "FunctionVersion", "Name", "Description", "RoutingConfig"] + ) + + ctx = request.custom_context + if not ctx.get(REPEATED_INVOCATION): + result = lambda_.create_alias(**create_params) + model["Id"] = result["AliasArn"] + ctx[REPEATED_INVOCATION] = True + + if model.get("ProvisionedConcurrencyConfig"): + lambda_.put_provisioned_concurrency_config( + FunctionName=model["FunctionName"], + Qualifier=model["Name"], + ProvisionedConcurrentExecutions=model["ProvisionedConcurrencyConfig"][ + "ProvisionedConcurrentExecutions" + ], + ) + + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=ctx, + ) + + if ctx.get(REPEATED_INVOCATION) and model.get("ProvisionedConcurrencyConfig"): + # get provisioned config status + result = lambda_.get_provisioned_concurrency_config( + FunctionName=model["FunctionName"], + Qualifier=model["Name"], + ) + if result["Status"] == "IN_PROGRESS": + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + ) + elif result["Status"] == "READY": + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + else: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + message="", + error_code="VersionStateFailure", # TODO: not parity tested + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[LambdaAliasProperties], + ) -> ProgressEvent[LambdaAliasProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LambdaAliasProperties], + ) -> ProgressEvent[LambdaAliasProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + lambda_ = request.aws_client_factory.lambda_ + + try: + # provisioned concurrency is automatically deleted upon deleting a function alias + lambda_.delete_alias( + FunctionName=model["FunctionName"], + Name=model["Name"], + ) + except lambda_.exceptions.ResourceNotFoundException: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=request.previous_state, + ) + + def update( + self, + request: ResourceRequest[LambdaAliasProperties], + ) -> ProgressEvent[LambdaAliasProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.schema.json b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.schema.json new file mode 100644 index 0000000000000..05686e6432be3 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias.schema.json @@ -0,0 +1,89 @@ +{ + "typeName": "AWS::Lambda::Alias", + "description": "Resource Type definition for AWS::Lambda::Alias", + "additionalProperties": false, + "properties": { + "FunctionName": { + "type": "string" + }, + "ProvisionedConcurrencyConfig": { + "$ref": "#/definitions/ProvisionedConcurrencyConfiguration" + }, + "Description": { + "type": "string" + }, + "FunctionVersion": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "RoutingConfig": { + "$ref": "#/definitions/AliasRoutingConfiguration" + }, + "Name": { + "type": "string" + } + }, + "definitions": { + "ProvisionedConcurrencyConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "ProvisionedConcurrentExecutions": { + "type": "integer" + } + }, + "required": [ + "ProvisionedConcurrentExecutions" + ] + }, + "VersionWeight": { + "type": "object", + "additionalProperties": false, + "properties": { + "FunctionWeight": { + "type": "number" + }, + "FunctionVersion": { + "type": "string" + } + }, + "required": [ + "FunctionVersion", + "FunctionWeight" + ] + }, + "AliasRoutingConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "AdditionalVersionWeights": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/definitions/VersionWeight" + } + } + }, + "required": [ + "AdditionalVersionWeights" + ] + } + }, + "required": [ + "FunctionName", + "FunctionVersion", + "Name" + ], + "createOnlyProperties": [ + "/properties/Name", + "/properties/FunctionName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias_plugin.py b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias_plugin.py new file mode 100644 index 0000000000000..406b05deddd45 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/resource_providers/lambda_alias_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LambdaAliasProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Lambda::Alias" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.lambda_.resource_providers.lambda_alias import LambdaAliasProvider + + self.factory = LambdaAliasProvider diff --git a/localstack-core/localstack/services/lambda_/runtimes.py b/localstack-core/localstack/services/lambda_/runtimes.py new file mode 100644 index 0000000000000..3fa96216257f6 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/runtimes.py @@ -0,0 +1,168 @@ +"""This Lambda Runtimes reference defines everything around Lambda runtimes to facilitate adding new runtimes.""" + +from typing import Optional + +from localstack.aws.api.lambda_ import Runtime + +# LocalStack Lambda runtimes support policy +# We support all Lambda runtimes currently actively supported at AWS. +# Further, we aim to provide best-effort support for deprecated runtimes at least until function updates are blocked, +# ideally a bit longer to help users migrate their Lambda runtimes. However, we do not actively test them anymore. + +# HOWTO add a new Lambda runtime: +# 1. Update botocore and generate the Lambda API stubs using `python3 -m localstack.aws.scaffold upgrade` +# => This usually happens automatically through the Github Action "Update ASF APIs" +# 2. Add the new runtime to these variables below: +# a) `IMAGE_MAPPING` +# b) `RUNTIMES_AGGREGATED` +# c) `SNAP_START_SUPPORTED_RUNTIMES` if supported (currently only new Java runtimes) +# 3. Re-create snapshots for Lambda tests with the marker @markers.lambda_runtime_update +# => Filter the tests using pytest -m lambda_runtime_update (i.e., additional arguments in PyCharm) +# Depending on the runtime, `test_lambda_runtimes.py` might require further snapshot updates. +# 4. Add the new runtime to these variables below: +# a) `VALID_RUNTIMES` matching the order of the snapshots +# b) `VALID_LAYER_RUNTIMES` matching the order of the snapshots +# 5. Run the unit test to check the runtime setup: +# tests.unit.services.lambda_.test_api_utils.TestApiUtils.test_check_runtime +# 6. Review special tests including: +# a) [ext] tests.aws.services.lambda_.test_lambda_endpoint_injection +# 7. Before merging, run the ext integration tests to cover transparent endpoint injection testing. +# 8. Add the new runtime to the K8 image build: https://github.com/localstack/lambda-images +# 9. Inform the web team to update the resource browser (consider offering an endpoint in the future) + +# Mapping from a) AWS Lambda runtime identifier => b) official AWS image on Amazon ECR Public +# a) AWS Lambda runtimes: https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html +# b) Amazon ECR Lambda images: https://gallery.ecr.aws/lambda +# => Synchronize the order with the "Supported runtimes" under "AWS Lambda runtimes" (a) +# => Add comments for deprecated runtimes using => => +IMAGE_MAPPING: dict[Runtime, str] = { + Runtime.nodejs22_x: "nodejs:22", + Runtime.nodejs20_x: "nodejs:20", + Runtime.nodejs18_x: "nodejs:18", + Runtime.nodejs16_x: "nodejs:16", + Runtime.nodejs14_x: "nodejs:14", # deprecated Dec 4, 2023 => Jan 9, 2024 => Feb 8, 2024 + Runtime.nodejs12_x: "nodejs:12", # deprecated Mar 31, 2023 => Mar 31, 2023 => Apr 30, 2023 + Runtime.python3_13: "python:3.13", + Runtime.python3_12: "python:3.12", + Runtime.python3_11: "python:3.11", + Runtime.python3_10: "python:3.10", + Runtime.python3_9: "python:3.9", + Runtime.python3_8: "python:3.8", + Runtime.python3_7: "python:3.7", # deprecated Dec 4, 2023 => Jan 9, 2024 => Feb 8, 2024 + Runtime.java21: "java:21", + Runtime.java17: "java:17", + Runtime.java11: "java:11", + Runtime.java8_al2: "java:8.al2", + Runtime.java8: "java:8", # deprecated Jan 8, 2024 => Feb 8, 2024 => Mar 12, 2024 + Runtime.dotnet8: "dotnet:8", + # dotnet7 (container only) + Runtime.dotnet6: "dotnet:6", + Runtime.dotnetcore3_1: "dotnet:core3.1", # deprecated Apr 3, 2023 => Apr 3, 2023 => May 3, 2023 + Runtime.go1_x: "go:1", # deprecated Jan 8, 2024 => Feb 8, 2024 => Mar 12, 2024 + Runtime.ruby3_4: "ruby:3.4", + Runtime.ruby3_3: "ruby:3.3", + Runtime.ruby3_2: "ruby:3.2", + Runtime.ruby2_7: "ruby:2.7", # deprecated Dec 7, 2023 => Jan 9, 2024 => Feb 8, 2024 + Runtime.provided_al2023: "provided:al2023", + Runtime.provided_al2: "provided:al2", + Runtime.provided: "provided:alami", # deprecated Jan 8, 2024 => Feb 8, 2024 => Mar 12, 2024 +} + + +# A list of all deprecated Lambda runtimes, with upgrade recommendations +# ideally ordered by deprecation date (following the AWS docs). +# LocalStack can still provide best-effort support. + +# TODO: Consider removing these as AWS is not using them anymore and they likely get outdated. +# We currently use them in LocalStack logs as bonus recommendation (DevX). +# When updating the recommendation, +# please regenerate all tests with @markers.lambda_runtime_update +DEPRECATED_RUNTIMES_UPGRADES: dict[Runtime, Optional[Runtime]] = { + # deprecated Jan 8, 2024 => Feb 8, 2024 => Mar 12, 2024 + Runtime.java8: Runtime.java21, + # deprecated Jan 8, 2024 => Feb 8, 2024 => Mar 12, 2024 + Runtime.go1_x: Runtime.provided_al2023, + # deprecated Jan 8, 2024 => Feb 8, 2024 => Mar 12, 2024 + Runtime.provided: Runtime.provided_al2023, + # deprecated Dec 7, 2023 => Jan 9, 2024 => Feb 8, 2024 + Runtime.ruby2_7: Runtime.ruby3_2, + # deprecated Dec 4, 2023 => Jan 9, 2024 => Feb 8, 2024 + Runtime.nodejs14_x: Runtime.nodejs20_x, + # deprecated Dec 4, 2023 => Jan 9, 2024 => Feb 8, 2024 + Runtime.python3_7: Runtime.python3_12, + # deprecated Apr 3, 2023 => Apr 3, 2023 => May 3, 2023 + Runtime.dotnetcore3_1: Runtime.dotnet6, + # deprecated Mar 31, 2023 => Mar 31, 2023 => Apr 30, 2023 + Runtime.nodejs12_x: Runtime.nodejs18_x, +} + + +DEPRECATED_RUNTIMES: list[Runtime] = list(DEPRECATED_RUNTIMES_UPGRADES.keys()) + +# An unordered list of all AWS-supported runtimes. +SUPPORTED_RUNTIMES: list[Runtime] = list(set(IMAGE_MAPPING.keys()) - set(DEPRECATED_RUNTIMES)) + +# A temporary list of missing runtimes not yet supported in LocalStack. Used for modular updates. +MISSING_RUNTIMES = [] + +# An unordered list of all Lambda runtimes supported by LocalStack. +ALL_RUNTIMES: list[Runtime] = list(IMAGE_MAPPING.keys()) + +# Grouped supported runtimes by language for testing. Moved here from `lambda_utils` for easier runtime updates. +# => Remove deprecated runtimes from this testing list +RUNTIMES_AGGREGATED = { + "nodejs": [ + Runtime.nodejs22_x, + Runtime.nodejs20_x, + Runtime.nodejs18_x, + Runtime.nodejs16_x, + ], + "python": [ + Runtime.python3_13, + Runtime.python3_12, + Runtime.python3_11, + Runtime.python3_10, + Runtime.python3_9, + Runtime.python3_8, + ], + "java": [ + Runtime.java21, + Runtime.java17, + Runtime.java11, + Runtime.java8_al2, + ], + "ruby": [ + Runtime.ruby3_2, + Runtime.ruby3_3, + Runtime.ruby3_4, + ], + "dotnet": [ + Runtime.dotnet6, + Runtime.dotnet8, + ], + "provided": [ + Runtime.provided_al2023, + Runtime.provided_al2, + ], +} + +# An unordered list of all tested runtimes listed in `RUNTIMES_AGGREGATED` +TESTED_RUNTIMES: list[Runtime] = [ + runtime for runtime_group in RUNTIMES_AGGREGATED.values() for runtime in runtime_group +] + +# An unordered list of snapstart-enabled runtimes. Related to snapshots in test_snapstart_exceptions +# https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html +SNAP_START_SUPPORTED_RUNTIMES = [ + Runtime.java11, + Runtime.java17, + Runtime.java21, + Runtime.python3_12, + Runtime.python3_13, + Runtime.dotnet8, +] + +# An ordered list of all Lambda runtimes considered valid by AWS. Matching snapshots in test_create_lambda_exceptions +VALID_RUNTIMES: str = "[nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9]" +# An ordered list of all Lambda runtimes for layers considered valid by AWS. Matching snapshots in test_layer_exceptions +VALID_LAYER_RUNTIMES: str = "[ruby2.6, dotnetcore1.0, python3.7, nodejs8.10, nasa, ruby2.7, python2.7-greengrass, dotnetcore2.0, python3.8, java21, dotnet6, dotnetcore2.1, python3.9, java11, nodejs6.10, provided, dotnetcore3.1, dotnet8, java25, java17, nodejs, nodejs4.3, java8.al2, go1.x, dotnet10, nodejs20.x, go1.9, byol, nodejs10.x, provided.al2023, nodejs22.x, python3.10, java8, nodejs12.x, python3.11, nodejs24.x, nodejs8.x, python3.12, nodejs14.x, nodejs8.9, python3.13, python3.14, nodejs16.x, provided.al2, nodejs4.3-edge, nodejs18.x, ruby3.2, python3.4, ruby3.3, ruby3.4, ruby2.5, python3.6, python2.7]" diff --git a/localstack-core/localstack/services/lambda_/urlrouter.py b/localstack-core/localstack/services/lambda_/urlrouter.py new file mode 100644 index 0000000000000..992909d0e57a2 --- /dev/null +++ b/localstack-core/localstack/services/lambda_/urlrouter.py @@ -0,0 +1,224 @@ +"""Routing for Lambda function URLs: https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html""" + +import base64 +import json +import logging +from datetime import datetime +from http import HTTPStatus + +from rolo.request import restore_payload + +from localstack.aws.api.lambda_ import InvocationType +from localstack.aws.protocol.serializer import gen_amzn_requestid +from localstack.http import Request, Response, Router +from localstack.http.dispatcher import Handler +from localstack.services.lambda_.api_utils import FULL_FN_ARN_PATTERN +from localstack.services.lambda_.invocation.lambda_models import InvocationResult +from localstack.services.lambda_.invocation.lambda_service import LambdaService +from localstack.services.lambda_.invocation.models import lambda_stores +from localstack.utils.aws.request_context import AWS_REGION_REGEX +from localstack.utils.strings import long_uid, to_bytes, to_str +from localstack.utils.time import TIMESTAMP_READABLE_FORMAT, mktime, timestamp +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + + +class FunctionUrlRouter: + router: Router[Handler] + lambda_service: LambdaService + + def __init__(self, router: Router[Handler], lambda_service: LambdaService): + self.router = router + self.registered = False + self.lambda_service = lambda_service + + def register_routes(self) -> None: + if self.registered: + LOG.debug("Skipped Lambda URL route registration (routes already registered).") + return + self.registered = True + + LOG.debug("Registering parameterized Lambda routes.") + + self.router.add( + "/", + host=f".lambda-url..", + endpoint=self.handle_lambda_url_invocation, + defaults={"path": ""}, + ) + self.router.add( + "/", + host=f".lambda-url..", + endpoint=self.handle_lambda_url_invocation, + ) + + def handle_lambda_url_invocation( + self, + request: Request, + api_id: str, + region: str, + **url_params: str, + ) -> Response: + response = Response() + response.mimetype = "application/json" + + lambda_url_config = None + + for account_id in lambda_stores.keys(): + store = lambda_stores[account_id][region] + for fn in store.functions.values(): + for url_config in fn.function_url_configs.values(): + # AWS tags are case sensitive, but domains are not. + # So we normalize them here to maximize both AWS and RFC + # conformance + if url_config.url_id.lower() == api_id.lower(): + lambda_url_config = url_config + + # TODO: check if errors are different when the URL has existed previously + if lambda_url_config is None: + LOG.info("Lambda URL %s does not exist", request.url) + response.data = '{"Message":null}' + response.status = 403 + response.headers["x-amzn-ErrorType"] = "AccessDeniedException" + # TODO: x-amzn-requestid + return response + + event = event_for_lambda_url(api_id, request) + + match = FULL_FN_ARN_PATTERN.search(lambda_url_config.function_arn).groupdict() + + result = self.lambda_service.invoke( + function_name=match.get("function_name"), + qualifier=match.get("qualifier"), + account_id=match.get("account_id"), + region=match.get("region_name"), + invocation_type=InvocationType.RequestResponse, + client_context="{}", # TODO: test + payload=to_bytes(json.dumps(event)), + request_id=gen_amzn_requestid(), + ) + if result.is_error: + response = Response("Internal Server Error", HTTPStatus.BAD_GATEWAY) + else: + response = lambda_result_to_response(result) + return response + + +def event_for_lambda_url(api_id: str, request: Request) -> dict: + partitioned_uri = request.full_path.partition("?") + raw_path = partitioned_uri[0] + raw_query_string = partitioned_uri[2] + + query_string_parameters = {k: ",".join(request.args.getlist(k)) for k in request.args.keys()} + + now = datetime.utcnow() + readable = timestamp(time=now, format=TIMESTAMP_READABLE_FORMAT) + if not any(char in readable for char in ["+", "-"]): + readable += "+0000" + + data = restore_payload(request) + headers = request.headers + source_ip = headers.get("Remote-Addr", "") + request_context = { + "accountId": "anonymous", + "apiId": api_id, + "domainName": headers.get("Host", ""), + "domainPrefix": api_id, + "http": { + "method": request.method, + "path": raw_path, + "protocol": "HTTP/1.1", + "sourceIp": source_ip, + "userAgent": headers.get("User-Agent", ""), + }, + "requestId": long_uid(), + "routeKey": "$default", + "stage": "$default", + "time": readable, + "timeEpoch": mktime(ts=now, millis=True), + } + + content_type = headers.get("Content-Type", "").lower() + content_type_is_text = any(text_type in content_type for text_type in ["text", "json", "xml"]) + + is_base64_encoded = not (data.isascii() and content_type_is_text) if data else False + body = base64.b64encode(data).decode() if is_base64_encoded else data + if isinstance(body, bytes): + body = to_str(body) + + ignored_headers = ["connection", "x-localstack-tgt-api", "x-localstack-request-url"] + event_headers = {k.lower(): v for k, v in headers.items() if k.lower() not in ignored_headers} + + event_headers.update( + { + "x-amzn-tls-cipher-suite": "ECDHE-RSA-AES128-GCM-SHA256", + "x-amzn-tls-version": "TLSv1.2", + "x-forwarded-proto": "http", + "x-forwarded-for": source_ip, + "x-forwarded-port": str(localstack_host().port), + } + ) + + event = { + "version": "2.0", + "routeKey": "$default", + "rawPath": raw_path, + "rawQueryString": raw_query_string, + "headers": event_headers, + "queryStringParameters": query_string_parameters, + "requestContext": request_context, + "body": body, + "isBase64Encoded": is_base64_encoded, + } + + if not data: + event.pop("body") + + return event + + +def lambda_result_to_response(result: InvocationResult): + response = Response() + + # Set default headers + response.headers.update( + { + "Content-Type": "application/json", + "Connection": "keep-alive", + "x-amzn-requestid": result.request_id, + "x-amzn-trace-id": long_uid(), # TODO: get the proper trace id here + } + ) + + original_payload = to_str(result.payload) + parsed_result = json.loads(original_payload) + + # patch to fix whitespaces + # TODO: check if this is a downstream issue of invocation result serialization + original_payload = json.dumps(parsed_result, separators=(",", ":")) + + if isinstance(parsed_result, str): + # a string is a special case here and is returned as-is + response.data = parsed_result + elif isinstance(parsed_result, dict): + # if it's a dict it might be a proper response + if isinstance(parsed_result.get("headers"), dict): + response.headers.update(parsed_result.get("headers")) + if "statusCode" in parsed_result: + response.status_code = int(parsed_result["statusCode"]) + if "body" not in parsed_result: + # TODO: test if providing a status code but no body actually works + response.data = original_payload + elif isinstance(parsed_result.get("body"), dict): + response.data = json.dumps(parsed_result.get("body")) + elif parsed_result.get("isBase64Encoded", False): + body_bytes = to_bytes(to_str(parsed_result.get("body", ""))) + decoded_body_bytes = base64.b64decode(body_bytes) + response.data = decoded_body_bytes + else: + response.data = parsed_result.get("body") + else: + response.data = original_payload + + return response diff --git a/localstack-core/localstack/services/logs/__init__.py b/localstack-core/localstack/services/logs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/logs/models.py b/localstack-core/localstack/services/logs/models.py new file mode 100644 index 0000000000000..5e2ba973cab93 --- /dev/null +++ b/localstack-core/localstack/services/logs/models.py @@ -0,0 +1,18 @@ +from typing import Dict + +from moto.logs.models import LogsBackend as MotoLogsBackend +from moto.logs.models import logs_backends as moto_logs_backend + +from localstack.services.stores import AccountRegionBundle, BaseStore, CrossRegionAttribute + + +def get_moto_logs_backend(account_id: str, region_name: str) -> MotoLogsBackend: + return moto_logs_backend[account_id][region_name] + + +class LogsStore(BaseStore): + # maps resource ARN to tags + TAGS: Dict[str, Dict[str, str]] = CrossRegionAttribute(default=dict) + + +logs_stores = AccountRegionBundle("logs", LogsStore) diff --git a/localstack-core/localstack/services/logs/provider.py b/localstack-core/localstack/services/logs/provider.py new file mode 100644 index 0000000000000..2ded5f5d31f0d --- /dev/null +++ b/localstack-core/localstack/services/logs/provider.py @@ -0,0 +1,467 @@ +import base64 +import copy +import io +import json +import logging +from gzip import GzipFile +from typing import Callable, Dict + +from moto.core.utils import unix_time_millis +from moto.logs.models import LogEvent, LogsBackend +from moto.logs.models import LogGroup as MotoLogGroup +from moto.logs.models import LogStream as MotoLogStream + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.logs import ( + AmazonResourceName, + DescribeLogGroupsRequest, + DescribeLogGroupsResponse, + DescribeLogStreamsRequest, + DescribeLogStreamsResponse, + Entity, + InputLogEvents, + InvalidParameterException, + KmsKeyId, + ListTagsForResourceResponse, + ListTagsLogGroupResponse, + LogGroupClass, + LogGroupName, + LogsApi, + LogStreamName, + PutLogEventsResponse, + ResourceNotFoundException, + SequenceToken, + TagKeyList, + TagList, + Tags, +) +from localstack.aws.connect import connect_to +from localstack.services import moto +from localstack.services.logs.models import get_moto_logs_backend, logs_stores +from localstack.services.moto import call_moto +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.aws import arns +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.common import is_number +from localstack.utils.patch import patch + +LOG = logging.getLogger(__name__) + + +class LogsProvider(LogsApi, ServiceLifecycleHook): + def __init__(self): + super().__init__() + self.cw_client = connect_to().cloudwatch + + def put_log_events( + self, + context: RequestContext, + log_group_name: LogGroupName, + log_stream_name: LogStreamName, + log_events: InputLogEvents, + sequence_token: SequenceToken = None, + entity: Entity = None, + **kwargs, + ) -> PutLogEventsResponse: + logs_backend = get_moto_logs_backend(context.account_id, context.region) + metric_filters = logs_backend.filters.metric_filters if is_api_enabled("cloudwatch") else [] + for metric_filter in metric_filters: + pattern = metric_filter.get("filterPattern", "") + transformations = metric_filter.get("metricTransformations", []) + matches = get_pattern_matcher(pattern) + for log_event in log_events: + if matches(pattern, log_event): + for tf in transformations: + value = tf.get("metricValue") or "1" + if "$size" in value: + LOG.info( + "Expression not yet supported for log filter metricValue", value + ) + value = float(value) if is_number(value) else 1 + data = [{"MetricName": tf["metricName"], "Value": value}] + try: + client = connect_to( + aws_access_key_id=context.account_id, region_name=context.region + ).cloudwatch + client.put_metric_data(Namespace=tf["metricNamespace"], MetricData=data) + except Exception as e: + LOG.info( + "Unable to put metric data for matching CloudWatch log events", e + ) + return call_moto(context) + + @handler("DescribeLogGroups", expand=False) + def describe_log_groups( + self, context: RequestContext, request: DescribeLogGroupsRequest + ) -> DescribeLogGroupsResponse: + region_backend = get_moto_logs_backend(context.account_id, context.region) + + prefix: str = request.get("logGroupNamePrefix", "") + pattern: str = request.get("logGroupNamePattern", "") + + if pattern and prefix: + raise InvalidParameterException( + "LogGroup name prefix and LogGroup name pattern are mutually exclusive parameters." + ) + + copy_groups = copy.deepcopy(region_backend.groups) + + groups = [ + group.to_describe_dict() + for name, group in copy_groups.items() + if not (prefix or pattern) + or (prefix and name.startswith(prefix)) + or (pattern and pattern in name) + ] + + groups = sorted(groups, key=lambda x: x["logGroupName"]) + return DescribeLogGroupsResponse(logGroups=groups) + + @handler("DescribeLogStreams", expand=False) + def describe_log_streams( + self, context: RequestContext, request: DescribeLogStreamsRequest + ) -> DescribeLogStreamsResponse: + log_group_name: str = request.get("logGroupName") + log_group_identifier: str = request.get("logGroupIdentifier") + + if log_group_identifier and log_group_name: + raise CommonServiceException( + "ValidationException", + "LogGroup name and LogGroup ARN are mutually exclusive parameters.", + ) + request_copy = copy.deepcopy(request) + if log_group_identifier: + request_copy.pop("logGroupIdentifier") + # identifier can be arn or name + request_copy["logGroupName"] = log_group_identifier.split(":")[-1] + + return moto.call_moto_with_request(context, request_copy) + + def create_log_group( + self, + context: RequestContext, + log_group_name: LogGroupName, + kms_key_id: KmsKeyId = None, + tags: Tags = None, + log_group_class: LogGroupClass = None, + **kwargs, + ) -> None: + call_moto(context) + if tags: + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + store.TAGS.setdefault(resource_arn, {}).update(tags) + + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs + ) -> ListTagsForResourceResponse: + self._check_resource_arn_tagging(resource_arn) + store = logs_stores[context.account_id][context.region] + tags = store.TAGS.get(resource_arn, {}) + return ListTagsForResourceResponse(tags=tags) + + def list_tags_log_group( + self, context: RequestContext, log_group_name: LogGroupName, **kwargs + ) -> ListTagsLogGroupResponse: + # deprecated implementation, new one: list_tags_for_resource + self._verify_log_group_exists( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + tags = store.TAGS.get(resource_arn, {}) + return ListTagsLogGroupResponse(tags=tags) + + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> None: + self._check_resource_arn_tagging(resource_arn) + store = logs_stores[context.account_id][context.region] + tags_stored = store.TAGS.get(resource_arn, {}) + for tag in tag_keys: + tags_stored.pop(tag, None) + + def untag_log_group( + self, context: RequestContext, log_group_name: LogGroupName, tags: TagList, **kwargs + ) -> None: + # deprecated implementation -> new one: untag_resource + self._verify_log_group_exists( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + tags_stored = store.TAGS.get(resource_arn, {}) + for tag in tags: + tags_stored.pop(tag, None) + + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: Tags, **kwargs + ) -> None: + self._check_resource_arn_tagging(resource_arn) + store = logs_stores[context.account_id][context.region] + store.TAGS.get(resource_arn, {}).update(tags or {}) + + def tag_log_group( + self, context: RequestContext, log_group_name: LogGroupName, tags: Tags, **kwargs + ) -> None: + # deprecated implementation -> new one: tag_resource + self._verify_log_group_exists( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + store.TAGS.get(resource_arn, {}).update(tags or {}) + + def _verify_log_group_exists(self, group_name: LogGroupName, account_id: str, region_name: str): + store = get_moto_logs_backend(account_id, region_name) + if group_name not in store.groups: + raise ResourceNotFoundException() + + def _check_resource_arn_tagging(self, resource_arn): + service = arns.extract_service_from_arn(resource_arn) + region = arns.extract_region_from_arn(resource_arn) + account = arns.extract_account_id_from_arn(resource_arn) + + # AWS currently only supports tagging for Log Group and Destinations + # LS: we only verify if log group exists, and create tags for other resources + if service.lower().startswith("log-group:"): + self._verify_log_group_exists( + service.split(":")[-1], account_id=account, region_name=region + ) + + +def get_pattern_matcher(pattern: str) -> Callable[[str, Dict], bool]: + """Returns a pattern matcher. Can be patched by plugins to return a more sophisticated pattern matcher.""" + return lambda _pattern, _log_event: True + + +@patch(LogsBackend.put_subscription_filter) +def moto_put_subscription_filter(fn, self, *args, **kwargs): + log_group_name = args[0] + filter_name = args[1] + filter_pattern = args[2] + destination_arn = args[3] + role_arn = args[4] + + log_group = self.groups.get(log_group_name) + log_group_arn = arns.log_group_arn(log_group_name, self.account_id, self.region_name) + + if not log_group: + raise ResourceNotFoundException("The specified log group does not exist.") + + arn_data = arns.parse_arn(destination_arn) + + if role_arn: + factory = connect_to.with_assumed_role( + role_arn=role_arn, + service_principal=ServicePrincipal.logs, + region_name=arn_data["region"], + ) + else: + factory = connect_to(aws_access_key_id=arn_data["account"], region_name=arn_data["region"]) + + if ":lambda:" in destination_arn: + client = factory.lambda_.request_metadata( + source_arn=log_group_arn, service_principal=ServicePrincipal.logs + ) + try: + client.get_function(FunctionName=destination_arn) + except Exception: + raise InvalidParameterException( + "destinationArn for vendor lambda cannot be used with roleArn" + ) + + elif ":kinesis:" in destination_arn: + client = factory.kinesis.request_metadata( + source_arn=log_group_arn, service_principal=ServicePrincipal.logs + ) + stream_name = arns.kinesis_stream_name(destination_arn) + try: + # Kinesis-Local DescribeStream does not support StreamArn param, so use StreamName instead + client.describe_stream(StreamName=stream_name) + except Exception: + raise InvalidParameterException( + "Could not deliver message to specified Kinesis stream. " + "Ensure that the Kinesis stream exists and is ACTIVE." + ) + + elif ":firehose:" in destination_arn: + client = factory.firehose.request_metadata( + source_arn=log_group_arn, service_principal=ServicePrincipal.logs + ) + firehose_name = arns.firehose_name(destination_arn) + try: + client.describe_delivery_stream(DeliveryStreamName=firehose_name) + except Exception: + raise InvalidParameterException( + "Could not deliver message to specified Firehose stream. " + "Ensure that the Firehose stream exists and is ACTIVE." + ) + + else: + raise InvalidParameterException( + f"PutSubscriptionFilter operation cannot work with destinationArn for vendor {arn_data['service']}" + ) + + if filter_pattern: + for stream in log_group.streams.values(): + stream.filter_pattern = filter_pattern + + log_group.put_subscription_filter(filter_name, filter_pattern, destination_arn, role_arn) + + +@patch(MotoLogStream.put_log_events, pass_target=False) +def moto_put_log_events(self: "MotoLogStream", log_events): + # TODO: call/patch upstream method here, instead of duplicating the code! + self.last_ingestion_time = int(unix_time_millis()) + self.stored_bytes += sum([len(log_event["message"]) for log_event in log_events]) + events = [LogEvent(self.last_ingestion_time, log_event) for log_event in log_events] + self.events += events + self.upload_sequence_token += 1 + + # apply filter_pattern -> only forward what matches the pattern + for subscription_filter in self.log_group.subscription_filters.values(): + if subscription_filter.filter_pattern: + # TODO only patched in pro + matches = get_pattern_matcher(subscription_filter.filter_pattern) + events = [ + LogEvent(self.last_ingestion_time, event) + for event in log_events + if matches(subscription_filter.filter_pattern, event) + ] + + if events and subscription_filter.destination_arn: + destination_arn = subscription_filter.destination_arn + log_events = [ + { + "id": str(event.event_id), + "timestamp": event.timestamp, + "message": event.message, + } + for event in events + ] + + data = { + "messageType": "DATA_MESSAGE", + "owner": self.account_id, # AWS Account ID of the originating log data + "logGroup": self.log_group.name, + "logStream": self.log_stream_name, + "subscriptionFilters": [subscription_filter.name], + "logEvents": log_events, + } + + output = io.BytesIO() + with GzipFile(fileobj=output, mode="w") as f: + f.write(json.dumps(data, separators=(",", ":")).encode("utf-8")) + payload_gz_encoded = output.getvalue() + event = {"awslogs": {"data": base64.b64encode(output.getvalue()).decode("utf-8")}} + + log_group_arn = arns.log_group_arn(self.log_group.name, self.account_id, self.region) + arn_data = arns.parse_arn(destination_arn) + + if subscription_filter.role_arn: + factory = connect_to.with_assumed_role( + role_arn=subscription_filter.role_arn, + service_principal=ServicePrincipal.logs, + region_name=arn_data["region"], + ) + else: + factory = connect_to( + aws_access_key_id=arn_data["account"], region_name=arn_data["region"] + ) + + if ":lambda:" in destination_arn: + client = factory.lambda_.request_metadata( + source_arn=log_group_arn, service_principal=ServicePrincipal.logs + ) + client.invoke(FunctionName=destination_arn, Payload=json.dumps(event)) + + if ":kinesis:" in destination_arn: + client = factory.kinesis.request_metadata( + source_arn=log_group_arn, service_principal=ServicePrincipal.logs + ) + stream_name = arns.kinesis_stream_name(destination_arn) + client.put_record( + StreamName=stream_name, + Data=payload_gz_encoded, + PartitionKey=self.log_group.name, + ) + + if ":firehose:" in destination_arn: + client = factory.firehose.request_metadata( + source_arn=log_group_arn, service_principal=ServicePrincipal.logs + ) + firehose_name = arns.firehose_name(destination_arn) + client.put_record( + DeliveryStreamName=firehose_name, + Record={"Data": payload_gz_encoded}, + ) + + return "{:056d}".format(self.upload_sequence_token) + + +@patch(MotoLogStream.filter_log_events) +def moto_filter_log_events( + filter_log_events, self, start_time, end_time, filter_pattern, *args, **kwargs +): + # moto currently raises an exception if filter_patterns is None, so we skip it + events = filter_log_events( + self, *args, start_time=start_time, end_time=end_time, filter_pattern=None, **kwargs + ) + + if not filter_pattern: + return events + + matches = get_pattern_matcher(filter_pattern) + return [event for event in events if matches(filter_pattern, event)] + + +@patch(MotoLogGroup.create_log_stream) +def moto_create_log_stream(target, self, log_stream_name): + target(self, log_stream_name) + stream = self.streams[log_stream_name] + filters = self.describe_subscription_filters() + stream.filter_pattern = filters[0]["filterPattern"] if filters else None + + +@patch(MotoLogGroup.to_describe_dict) +def moto_to_describe_dict(target, self): + # reported race condition in https://github.com/localstack/localstack/issues/8011 + # making copy of "streams" dict here to avoid issues while summing up storedBytes + copy_streams = copy.deepcopy(self.streams) + # parity tests shows that the arn ends with ":*" + arn = self.arn if self.arn.endswith(":*") else f"{self.arn}:*" + log_group = { + "arn": arn, + "creationTime": self.creation_time, + "logGroupName": self.name, + "metricFilterCount": 0, + "storedBytes": sum(s.stored_bytes for s in copy_streams.values()), + } + if self.retention_in_days: + log_group["retentionInDays"] = self.retention_in_days + if self.kms_key_id: + log_group["kmsKeyId"] = self.kms_key_id + return log_group + + +@patch(MotoLogGroup.get_log_events) +def moto_get_log_events( + target, self, log_stream_name, start_time, end_time, limit, next_token, start_from_head +): + if log_stream_name not in self.streams: + raise ResourceNotFoundException("The specified log stream does not exist.") + return target(self, log_stream_name, start_time, end_time, limit, next_token, start_from_head) diff --git a/localstack-core/localstack/services/logs/resource_providers/__init__.py b/localstack-core/localstack/services/logs/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup.py b/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup.py new file mode 100644 index 0000000000000..6dd6b66190bf3 --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup.py @@ -0,0 +1,144 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LogsLogGroupProperties(TypedDict): + Arn: Optional[str] + DataProtectionPolicy: Optional[dict] + KmsKeyId: Optional[str] + LogGroupName: Optional[str] + RetentionInDays: Optional[int] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LogsLogGroupProvider(ResourceProvider[LogsLogGroupProperties]): + TYPE = "AWS::Logs::LogGroup" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LogsLogGroupProperties], + ) -> ProgressEvent[LogsLogGroupProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/LogGroupName + + Create-only properties: + - /properties/LogGroupName + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - logs:DescribeLogGroups + - logs:CreateLogGroup + - logs:PutRetentionPolicy + - logs:TagLogGroup + - logs:GetDataProtectionPolicy + - logs:PutDataProtectionPolicy + - logs:CreateLogDelivery + - s3:REST.PUT.OBJECT + - firehose:TagDeliveryStream + - logs:PutResourcePolicy + - logs:DescribeResourcePolicies + + """ + model = request.desired_state + logs = request.aws_client_factory.logs + + if not model.get("LogGroupName"): + model["LogGroupName"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + logs.create_log_group(logGroupName=model["LogGroupName"]) + + describe_result = logs.describe_log_groups(logGroupNamePrefix=model["LogGroupName"]) + model["Arn"] = describe_result["logGroups"][0]["arn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LogsLogGroupProperties], + ) -> ProgressEvent[LogsLogGroupProperties]: + """ + Fetch resource information + + IAM permissions required: + - logs:DescribeLogGroups + - logs:ListTagsLogGroup + - logs:GetDataProtectionPolicy + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LogsLogGroupProperties], + ) -> ProgressEvent[LogsLogGroupProperties]: + """ + Delete a resource + + IAM permissions required: + - logs:DescribeLogGroups + - logs:DeleteLogGroup + - logs:DeleteDataProtectionPolicy + """ + model = request.desired_state + logs = request.aws_client_factory.logs + + logs.delete_log_group(logGroupName=model["LogGroupName"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LogsLogGroupProperties], + ) -> ProgressEvent[LogsLogGroupProperties]: + """ + Update a resource + + IAM permissions required: + - logs:DescribeLogGroups + - logs:AssociateKmsKey + - logs:DisassociateKmsKey + - logs:PutRetentionPolicy + - logs:DeleteRetentionPolicy + - logs:TagLogGroup + - logs:UntagLogGroup + - logs:GetDataProtectionPolicy + - logs:PutDataProtectionPolicy + - logs:CreateLogDelivery + - s3:REST.PUT.OBJECT + - firehose:TagDeliveryStream + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup.schema.json b/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup.schema.json new file mode 100644 index 0000000000000..5e0c8f041671b --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup.schema.json @@ -0,0 +1,168 @@ +{ + "typeName": "AWS::Logs::LogGroup", + "description": "Resource schema for AWS::Logs::LogGroup", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-logs.git", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., :, /, =, +, - and @.", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., :, /, =, +, - and @.", + "minLength": 0, + "maxLength": 256 + } + }, + "required": [ + "Key", + "Value" + ] + } + }, + "properties": { + "LogGroupName": { + "description": "The name of the log group. If you don't specify a name, AWS CloudFormation generates a unique ID for the log group.", + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[.\\-_/#A-Za-z0-9]{1,512}\\Z" + }, + "KmsKeyId": { + "description": "The Amazon Resource Name (ARN) of the CMK to use when encrypting log data.", + "type": "string", + "maxLength": 256, + "pattern": "^arn:[a-z0-9-]+:kms:[a-z0-9-]+:\\d{12}:(key|alias)/.+\\Z" + }, + "DataProtectionPolicy": { + "description": "The body of the policy document you want to use for this topic.\n\nYou can only add one policy per topic.\n\nThe policy must be in JSON string format.\n\nLength Constraints: Maximum length of 30720", + "type": "object" + }, + "RetentionInDays": { + "description": "The number of days to retain the log events in the specified log group. Possible values are: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, and 3653.", + "type": "integer", + "enum": [ + 1, + 3, + 5, + 7, + 14, + 30, + 60, + 90, + 120, + 150, + 180, + 365, + 400, + 545, + 731, + 1096, + 1827, + 2192, + 2557, + 2922, + 3288, + 3653 + ] + }, + "Tags": { + "description": "An array of key-value pairs to apply to this resource.", + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Arn": { + "description": "The CloudWatch log group ARN.", + "type": "string" + } + }, + "handlers": { + "create": { + "permissions": [ + "logs:DescribeLogGroups", + "logs:CreateLogGroup", + "logs:PutRetentionPolicy", + "logs:TagLogGroup", + "logs:GetDataProtectionPolicy", + "logs:PutDataProtectionPolicy", + "logs:CreateLogDelivery", + "s3:REST.PUT.OBJECT", + "firehose:TagDeliveryStream", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies" + ] + }, + "read": { + "permissions": [ + "logs:DescribeLogGroups", + "logs:ListTagsLogGroup", + "logs:GetDataProtectionPolicy" + ] + }, + "update": { + "permissions": [ + "logs:DescribeLogGroups", + "logs:AssociateKmsKey", + "logs:DisassociateKmsKey", + "logs:PutRetentionPolicy", + "logs:DeleteRetentionPolicy", + "logs:TagLogGroup", + "logs:UntagLogGroup", + "logs:GetDataProtectionPolicy", + "logs:PutDataProtectionPolicy", + "logs:CreateLogDelivery", + "s3:REST.PUT.OBJECT", + "firehose:TagDeliveryStream" + ] + }, + "delete": { + "permissions": [ + "logs:DescribeLogGroups", + "logs:DeleteLogGroup", + "logs:DeleteDataProtectionPolicy" + ] + }, + "list": { + "permissions": [ + "logs:DescribeLogGroups", + "logs:ListTagsLogGroup" + ], + "handlerSchema": { + "properties": { + "LogGroupName": { + "$ref": "resource-schema.json#/properties/LogGroupName" + } + }, + "required": [] + } + } + }, + "createOnlyProperties": [ + "/properties/LogGroupName" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "primaryIdentifier": [ + "/properties/LogGroupName" + ], + "additionalProperties": false, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + } +} diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup_plugin.py b/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup_plugin.py new file mode 100644 index 0000000000000..5dd8087a87561 --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_loggroup_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LogsLogGroupProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Logs::LogGroup" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.logs.resource_providers.aws_logs_loggroup import ( + LogsLogGroupProvider, + ) + + self.factory = LogsLogGroupProvider diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream.py b/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream.py new file mode 100644 index 0000000000000..4cb21339b6e77 --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream.py @@ -0,0 +1,108 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LogsLogStreamProperties(TypedDict): + LogGroupName: Optional[str] + Id: Optional[str] + LogStreamName: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LogsLogStreamProvider(ResourceProvider[LogsLogStreamProperties]): + TYPE = "AWS::Logs::LogStream" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LogsLogStreamProperties], + ) -> ProgressEvent[LogsLogStreamProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - LogGroupName + + Create-only properties: + - /properties/LogGroupName + - /properties/LogStreamName + + Read-only properties: + - /properties/Id + + """ + model = request.desired_state + logs = request.aws_client_factory.logs + + if not model.get("LogStreamName"): + model["LogStreamName"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + logs.create_log_stream( + logGroupName=model["LogGroupName"], logStreamName=model["LogStreamName"] + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LogsLogStreamProperties], + ) -> ProgressEvent[LogsLogStreamProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LogsLogStreamProperties], + ) -> ProgressEvent[LogsLogStreamProperties]: + """ + Delete a resource + """ + model = request.desired_state + logs = request.aws_client_factory.logs + + logs.delete_log_stream( + logGroupName=model["LogGroupName"], logStreamName=model["LogStreamName"] + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LogsLogStreamProperties], + ) -> ProgressEvent[LogsLogStreamProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream.schema.json b/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream.schema.json new file mode 100644 index 0000000000000..1f42acd0f2f4f --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream.schema.json @@ -0,0 +1,29 @@ +{ + "typeName": "AWS::Logs::LogStream", + "description": "Resource Type definition for AWS::Logs::LogStream", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "LogGroupName": { + "type": "string" + }, + "LogStreamName": { + "type": "string" + } + }, + "required": [ + "LogGroupName" + ], + "createOnlyProperties": [ + "/properties/LogGroupName", + "/properties/LogStreamName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream_plugin.py b/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream_plugin.py new file mode 100644 index 0000000000000..578e23c4ae628 --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_logstream_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LogsLogStreamProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Logs::LogStream" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.logs.resource_providers.aws_logs_logstream import ( + LogsLogStreamProvider, + ) + + self.factory = LogsLogStreamProvider diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter.py b/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter.py new file mode 100644 index 0000000000000..26f204e52e78e --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter.py @@ -0,0 +1,123 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class LogsSubscriptionFilterProperties(TypedDict): + DestinationArn: Optional[str] + FilterPattern: Optional[str] + LogGroupName: Optional[str] + Distribution: Optional[str] + FilterName: Optional[str] + RoleArn: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class LogsSubscriptionFilterProvider(ResourceProvider[LogsSubscriptionFilterProperties]): + TYPE = "AWS::Logs::SubscriptionFilter" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[LogsSubscriptionFilterProperties], + ) -> ProgressEvent[LogsSubscriptionFilterProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/FilterName + - /properties/LogGroupName + + Required properties: + - DestinationArn + - FilterPattern + - LogGroupName + + Create-only properties: + - /properties/FilterName + - /properties/LogGroupName + + + + IAM permissions required: + - iam:PassRole + - logs:PutSubscriptionFilter + - logs:DescribeSubscriptionFilters + + """ + model = request.desired_state + logs = request.aws_client_factory.logs + + logs.put_subscription_filter( + logGroupName=model["LogGroupName"], + filterName=model["LogGroupName"], + filterPattern=model["FilterPattern"], + destinationArn=model["DestinationArn"], + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[LogsSubscriptionFilterProperties], + ) -> ProgressEvent[LogsSubscriptionFilterProperties]: + """ + Fetch resource information + + IAM permissions required: + - logs:DescribeSubscriptionFilters + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[LogsSubscriptionFilterProperties], + ) -> ProgressEvent[LogsSubscriptionFilterProperties]: + """ + Delete a resource + + IAM permissions required: + - logs:DeleteSubscriptionFilter + """ + model = request.desired_state + logs = request.aws_client_factory.logs + + logs.delete_subscription_filter( + logGroupName=model["LogGroupName"], + filterName=model["LogGroupName"], + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[LogsSubscriptionFilterProperties], + ) -> ProgressEvent[LogsSubscriptionFilterProperties]: + """ + Update a resource + + IAM permissions required: + - logs:PutSubscriptionFilter + - logs:DescribeSubscriptionFilters + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter.schema.json b/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter.schema.json new file mode 100644 index 0000000000000..2198cbc7aca94 --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter.schema.json @@ -0,0 +1,97 @@ +{ + "typeName": "AWS::Logs::SubscriptionFilter", + "$schema": "https://raw.githubusercontent.com/aws-cloudformation/cloudformation-cli/master/src/rpdk/core/data/schema/provider.definition.schema.v1.json", + "description": "Subscription filters allow you to subscribe to a real-time stream of log events and have them delivered to a specific destination.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-logs", + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "replacementStrategy": "delete_then_create", + "properties": { + "FilterName": { + "description": "The name of the filter generated by resource.", + "type": "string" + }, + "DestinationArn": { + "description": "The Amazon Resource Name (ARN) of the destination.", + "type": "string" + }, + "FilterPattern": { + "description": "The filtering expressions that restrict what gets delivered to the destination AWS resource.", + "type": "string" + }, + "LogGroupName": { + "description": "Existing log group that you want to associate with this filter.", + "type": "string" + }, + "RoleArn": { + "description": "The ARN of an IAM role that grants CloudWatch Logs permissions to deliver ingested log events to the destination stream. You don't need to provide the ARN when you are working with a logical destination for cross-account delivery.", + "type": "string" + }, + "Distribution": { + "description": "The method used to distribute log data to the destination. By default, log data is grouped by log stream, but the grouping can be set to random for a more even distribution. This property is only applicable when the destination is an Amazon Kinesis stream.", + "type": "string", + "enum": [ + "Random", + "ByLogStream" + ] + } + }, + "handlers": { + "create": { + "permissions": [ + "iam:PassRole", + "logs:PutSubscriptionFilter", + "logs:DescribeSubscriptionFilters" + ] + }, + "read": { + "permissions": [ + "logs:DescribeSubscriptionFilters" + ] + }, + "update": { + "permissions": [ + "logs:PutSubscriptionFilter", + "logs:DescribeSubscriptionFilters" + ] + }, + "delete": { + "permissions": [ + "logs:DeleteSubscriptionFilter" + ] + }, + "list": { + "permissions": [ + "logs:DescribeSubscriptionFilters" + ], + "handlerSchema": { + "properties": { + "LogGroupName": { + "$ref": "resource-schema.json#/properties/LogGroupName" + } + }, + "required": [ + "LogGroupName" + ] + } + } + }, + "required": [ + "DestinationArn", + "FilterPattern", + "LogGroupName" + ], + "createOnlyProperties": [ + "/properties/FilterName", + "/properties/LogGroupName" + ], + "primaryIdentifier": [ + "/properties/FilterName", + "/properties/LogGroupName" + ], + "additionalProperties": false +} diff --git a/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter_plugin.py b/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter_plugin.py new file mode 100644 index 0000000000000..def55ff386045 --- /dev/null +++ b/localstack-core/localstack/services/logs/resource_providers/aws_logs_subscriptionfilter_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class LogsSubscriptionFilterProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Logs::SubscriptionFilter" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.logs.resource_providers.aws_logs_subscriptionfilter import ( + LogsSubscriptionFilterProvider, + ) + + self.factory = LogsSubscriptionFilterProvider diff --git a/localstack-core/localstack/services/moto.py b/localstack-core/localstack/services/moto.py new file mode 100644 index 0000000000000..c98989c39a967 --- /dev/null +++ b/localstack-core/localstack/services/moto.py @@ -0,0 +1,211 @@ +""" +This module provides tools to call moto using moto and botocore internals without going through the moto HTTP server. +""" + +import copy +import sys +from functools import lru_cache +from typing import Callable, Optional, Union + +import moto.backends as moto_backends +from moto.core.base_backend import BackendDict +from moto.core.exceptions import RESTError +from rolo.router import RegexConverter +from werkzeug.exceptions import NotFound +from werkzeug.routing import Map, Rule + +from localstack import constants +from localstack.aws.api import ( + CommonServiceException, + RequestContext, + ServiceRequest, + ServiceResponse, +) +from localstack.aws.forwarder import ( + ForwardingFallbackDispatcher, + create_aws_request_context, + dispatch_to_backend, +) +from localstack.aws.skeleton import DispatchTable +from localstack.constants import DEFAULT_AWS_ACCOUNT_ID +from localstack.constants import VERSION as LOCALSTACK_VERSION +from localstack.http import Response +from localstack.http.request import Request, get_full_raw_path, get_raw_current_url + +MotoDispatcher = Callable[[Request, str, dict], Response] + +user_agent = f"Localstack/{LOCALSTACK_VERSION} Python/{sys.version.split(' ')[0]}" + + +def call_moto(context: RequestContext, include_response_metadata=False) -> ServiceResponse: + """ + Call moto with the given request context and receive a parsed ServiceResponse. + + :param context: the request context + :param include_response_metadata: whether to include botocore's "ResponseMetadata" attribute + :return: a serialized AWS ServiceResponse (same as boto3 would return) + """ + return dispatch_to_backend(context, dispatch_to_moto, include_response_metadata) + + +def call_moto_with_request( + context: RequestContext, service_request: ServiceRequest +) -> ServiceResponse: + """ + Like `call_moto`, but you can pass a modified version of the service request before calling moto. The caveat is + that a new HTTP request has to be created. The service_request is serialized into a new RequestContext object, + and headers from the old request are merged into the new one. + + :param context: the original request context + :param service_request: the dictionary containing the service request parameters + :return: an ASF ServiceResponse (same as a service provider would return) + """ + local_context = create_aws_request_context( + service_name=context.service.service_name, + action=context.operation.name, + parameters=service_request, + region=context.region, + ) + # we keep the headers from the original request, but override them with the ones created from the `service_request` + headers = copy.deepcopy(context.request.headers) + headers.update(local_context.request.headers) + local_context.request.headers = headers + + return call_moto(local_context) + + +def _proxy_moto( + context: RequestContext, request: ServiceRequest +) -> Optional[Union[ServiceResponse, Response]]: + """ + Wraps `call_moto` such that the interface is compliant with a ServiceRequestHandler. + + :param context: the request context + :param service_request: currently not being used, added to satisfy ServiceRequestHandler contract + :return: the Response from moto + """ + return call_moto(context) + + +def MotoFallbackDispatcher(provider: object) -> DispatchTable: + """ + Wraps a provider with a moto fallthrough mechanism. It does by creating a new DispatchTable from the original + provider, and wrapping each method with a fallthrough method that calls ``request`` if the original provider + raises a ``NotImplementedError``. + + :param provider: the ASF provider + :return: a modified DispatchTable + """ + return ForwardingFallbackDispatcher(provider, _proxy_moto) + + +def dispatch_to_moto(context: RequestContext) -> Response: + """ + Internal method to dispatch the request to moto without changing moto's dispatcher output. + :param context: the request context + :return: the response from moto + """ + service = context.service + request = context.request + + # Werkzeug might have an issue (to be determined where the responsibility lies) with proxied requests where the + # HTTP location is a full URI and not only a path. + # We need to use the full_raw_url as moto does some path decoding (in S3 for example) + full_raw_path = get_full_raw_path(request) + # remove the query string from the full path to do the matching of the request + raw_path_only = full_raw_path.split("?")[0] + # this is where we skip the HTTP roundtrip between the moto server and the boto client + dispatch = get_dispatcher(service.service_name, raw_path_only) + try: + raw_url = get_raw_current_url( + request.scheme, request.host, request.root_path, full_raw_path + ) + response = dispatch(request, raw_url, request.headers) + if not response: + # some operations are only partially implemented by moto + # e.g. the request will be resolved, but then the request method is not handled + # it will return None in that case, e.g. for: apigateway TestInvokeAuthorizer + UpdateGatewayResponse + raise NotImplementedError + status, headers, content = response + if isinstance(content, str) and len(content) == 0: + # moto often returns an empty string to indicate an empty body. + # use None instead to ensure that body-related headers aren't overwritten when creating the response object. + content = None + return Response(content, status, headers) + except RESTError as e: + raise CommonServiceException(e.error_type, e.message, status_code=e.code) from e + + +def get_dispatcher(service: str, path: str) -> MotoDispatcher: + url_map = get_moto_routing_table(service) + + if len(url_map._rules) == 1: + # in most cases, there will only be one dispatch method in the list of urls, so no need to do matching + rule = next(url_map.iter_rules()) + return rule.endpoint + + matcher = url_map.bind(constants.LOCALHOST) + try: + endpoint, _ = matcher.match(path_info=path) + except NotFound as e: + raise NotImplementedError( + f"No moto route for service {service} on path {path} found." + ) from e + return endpoint + + +@lru_cache() +def get_moto_routing_table(service: str) -> Map: + """Cached version of load_moto_routing_table.""" + return load_moto_routing_table(service) + + +def load_moto_routing_table(service: str) -> Map: + """ + Creates from moto service url_paths a werkzeug URL rule map that can be used to locate moto methods to dispatch + requests to. + + :param service: the service to get the map for. + :return: a new Map object + """ + # code from moto.moto_server.werkzeug_app.create_backend_app + backend_dict = moto_backends.get_backend(service) + # Get an instance of this backend. + # We'll only use this backend to resolve the URL's, so the exact region/account_id is irrelevant + if isinstance(backend_dict, BackendDict): + if "us-east-1" in backend_dict[DEFAULT_AWS_ACCOUNT_ID]: + backend = backend_dict[DEFAULT_AWS_ACCOUNT_ID]["us-east-1"] + else: + backend = backend_dict[DEFAULT_AWS_ACCOUNT_ID]["global"] + else: + backend = backend_dict["global"] + + url_map = Map() + url_map.converters["regex"] = _PartIsolatingRegexConverter + + for url_path, handler in backend.flask_paths.items(): + # Some URL patterns in moto have optional trailing slashes, for example the route53 pattern: + # r"{0}/(?P[\d_-]+)/hostedzone/(?P[^/]+)/rrset/?$". + # However, they don't actually seem to work. Routing only works because moto disables strict_slashes check + # for the URL Map. So we also disable it here explicitly. + strict_slashes = False + + # Rule endpoints are annotated as string types in werkzeug, but they don't have to be. + endpoint = handler + + url_map.add(Rule(url_path, endpoint=endpoint, strict_slashes=strict_slashes)) + + return url_map + + +class _PartIsolatingRegexConverter(RegexConverter): + """ + Werkzeug converter with disabled path isolation. + This converter is equivalent to moto.moto_server.utilities.RegexConverter. + It is necessary to be duplicated here to avoid a transitive import of flask. + """ + + part_isolating = False + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) diff --git a/localstack-core/localstack/services/opensearch/__init__.py b/localstack-core/localstack/services/opensearch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/opensearch/cluster.py b/localstack-core/localstack/services/opensearch/cluster.py new file mode 100644 index 0000000000000..cae1916c90b09 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/cluster.py @@ -0,0 +1,722 @@ +import dataclasses +import logging +import os +import threading +from typing import Dict, List, NamedTuple, Optional, Tuple +from urllib.parse import urlparse + +import requests +from werkzeug.routing import Rule + +from localstack import config, constants +from localstack.aws.api.opensearch import ( + AdvancedSecurityOptionsInput, + EngineType, + ValidationException, +) +from localstack.http.client import SimpleRequestsClient +from localstack.http.proxy import ProxyHandler +from localstack.services.edge import ROUTER +from localstack.services.opensearch import versions +from localstack.services.opensearch.packages import elasticsearch_package, opensearch_package +from localstack.utils.aws.arns import parse_arn +from localstack.utils.common import ( + ShellCommandThread, + chmod_r, + get_free_tcp_port, + is_root, + mkdir, + rm_rf, +) +from localstack.utils.run import FuncThread +from localstack.utils.serving import Server +from localstack.utils.sync import poll_condition +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) +INTERNAL_USER_AUTH = ("localstack-internal", "localstack-internal") +DEFAULT_BACKEND_HOST = "127.0.0.1" + +CommandSettings = Dict[str, str] + + +class Directories(NamedTuple): + install: str + tmp: str + mods: str + data: str + backup: str + + +def get_cluster_health_status( + url: str, auth: Tuple[str, str] | None, host: str | None = None +) -> Optional[str]: + """ + Queries the health endpoint of OpenSearch/Elasticsearch and returns either the status ('green', 'yellow', + ...) or None if the response returned a non-200 response. + Authentication needs to be set in case the security plugin is enabled. + """ + headers = {} + if host: + headers["Host"] = host + resp = requests.get(url + "/_cluster/health", headers=headers, verify=False, auth=auth) + + if resp and resp.ok: + opensearch_status = resp.json() + opensearch_status = opensearch_status["status"] + return opensearch_status + + return None + + +def init_directories(dirs: Directories): + """Makes sure the directories exist and have the necessary permissions.""" + LOG.debug("initializing cluster directories %s", dirs) + chmod_r(dirs.install, 0o777) + + if not config.dirs.data or not dirs.data.startswith(config.dirs.data): + # only clear previous data if it's not in DATA_DIR + rm_rf(dirs.data) + + rm_rf(dirs.tmp) + mkdir(dirs.tmp) + chmod_r(dirs.tmp, 0o777) + + mkdir(dirs.data) + chmod_r(dirs.data, 0o777) + + mkdir(dirs.backup) + chmod_r(dirs.backup, 0o777) + + # clear potentially existing lock files (which cause problems since ES 7.10) + for d, _, files in os.walk(dirs.data, True): + for f in files: + if f.endswith(".lock"): + rm_rf(os.path.join(d, f)) + + +def resolve_directories(version: str, cluster_path: str, data_root: str = None) -> Directories: + """ + Determines directories to find the opensearch binary as well as where to store the instance data. + + :param version: the full OpenSearch/Elasticsearch version (to resolve the install dir) + :param cluster_path: the path between data_root and the actual data directories + :param data_root: the root of the data dir (will be resolved to TMP_PATH or DATA_DIR by default) + :returns: a Directories data structure + """ + # where to find cluster binary and the modules + engine_type, install_version = versions.get_install_type_and_version(version) + install_dir = opensearch_package.get_installed_dir(version) + + modules_dir = os.path.join(install_dir, "modules") + + if not data_root: + data_root = config.dirs.data or config.dirs.tmp + + if engine_type == EngineType.OpenSearch: + data_path = os.path.join(data_root, "opensearch", cluster_path) + else: + data_path = os.path.join(data_root, "elasticsearch", cluster_path) + + tmp_dir = os.path.join(data_path, "tmp") + data_dir = os.path.join(data_path, "data") + backup_dir = os.path.join(data_path, "backup") + + return Directories(install_dir, tmp_dir, modules_dir, data_dir, backup_dir) + + +def build_cluster_run_command(cluster_bin: str, settings: CommandSettings) -> List[str]: + """ + Takes the command settings dict and builds the actual command (which can then be executed as a shell command). + + :param cluster_bin: path to the OpenSearch/Elasticsearch binary (including the binary) + :param settings: dictionary where each item will be set as a command arguments + :return: list of strings for the command with the settings to be executed as a shell command + """ + cmd_settings = [f"-E {k}={v}" for k, v in settings.items()] + return [cluster_bin] + cmd_settings + + +class CustomEndpoint: + """ + Encapsulates a custom endpoint (combines CustomEndpoint and CustomEndpointEnabled within the DomainEndpointOptions + of the cluster, i.e. combines two fields from the AWS OpenSearch service model). + """ + + enabled: bool + endpoint: str + + def __init__(self, enabled: bool, endpoint: str) -> None: + """ + :param enabled: true if the custom endpoint is enabled (refers to DomainEndpointOptions#CustomEndpointEnabled) + :param endpoint: defines the endpoint (i.e. the URL - refers to DomainEndpointOptions#CustomEndpoint) + """ + self.enabled = enabled + self.endpoint = endpoint + + if self.endpoint: + self.url = urlparse(endpoint) + else: + self.url = None + + +@dataclasses.dataclass +class SecurityOptions: + """DTO which encapsulates the currently supported security options.""" + + enabled: bool + master_username: str | None + master_password: str | None + + @property + def auth(self) -> Tuple[str, str] | None: + """Returns an auth tuple which can be used for HTTP requests or None, if disabled.""" + return None if not self.enabled else (self.master_username, self.master_password) + + @staticmethod + def from_input( + advanced_security_options: Optional[AdvancedSecurityOptionsInput], + ) -> "SecurityOptions": + """ + Parses the given AdvancedSecurityOptionsInput, performs some validation, and returns the parsed SecurityOptions. + If unsupported settings are used, the SecurityOptions are disabled and a warning is logged. + + :param advanced_security_options: of the domain which will be created + :return: parsed SecurityOptions + :raises: ValidationException in case the given AdvancedSecurityOptions are invalid + """ + if advanced_security_options is None: + return SecurityOptions(enabled=False, master_username=None, master_password=None) + if not advanced_security_options.get("InternalUserDatabaseEnabled", False): + LOG.warning( + "AdvancedSecurityOptions are set, but InternalUserDatabase is disabled. Disabling security options." + ) + return SecurityOptions(enabled=False, master_username=None, master_password=None) + + master_username = advanced_security_options.get("MasterUserOptions", {}).get( + "MasterUserName", None + ) + master_password = advanced_security_options.get("MasterUserOptions", {}).get( + "MasterUserPassword", None + ) + if not master_username and not master_password: + raise ValidationException( + "You must provide a master username and password when the internal user database is enabled." + ) + if not master_username or not master_password: + raise ValidationException("You must provide a master username and password together.") + + return SecurityOptions( + enabled=advanced_security_options["Enabled"] or False, + master_username=master_username, + master_password=master_password, + ) + + +def register_cluster( + host: str, path: str, forward_url: str, custom_endpoint: CustomEndpoint +) -> List[Rule]: + """ + Registers routes for a cluster at the edge router. + Depending on which endpoint strategy is employed, and if a custom endpoint is enabled, different routes are + registered. + This method is tightly coupled with `cluster_manager.build_cluster_endpoint`, which already creates the + endpoint URL according to the configuration used here. + + :param host: hostname of the inbound address without scheme or port + :param path: path of the inbound address + :param forward_url: whole address for outgoing traffic (including the protocol) + :param custom_endpoint: Object that stores a custom address and if its enabled. + If a custom_endpoint is set AND enabled, the specified address takes precedence + over any strategy currently active, and overwrites any host/path combination. + :return: a list of generated router rules, which can be used for removal + """ + # custom backends overwrite the usual forward_url + forward_url = config.OPENSEARCH_CUSTOM_BACKEND or forward_url + + # if the opensearch security plugin is enabled, only TLS connections are allowed, but the cert cannot be verified + client = SimpleRequestsClient() + client.session.verify = False + endpoint = ProxyHandler(forward_url, client) + + rules = [] + strategy = config.OPENSEARCH_ENDPOINT_STRATEGY + # custom endpoints override any endpoint strategy + if custom_endpoint and custom_endpoint.enabled: + LOG.debug("Registering route from %s%s to %s", host, path, endpoint.proxy.forward_base_url) + assert not (host == localstack_host().host and (not path or path == "/")), ( + "trying to register an illegal catch all route" + ) + rules.append( + ROUTER.add( + path=path, + endpoint=endpoint, + host=f"{host}", + ) + ) + rules.append( + ROUTER.add( + f"{path}/", + endpoint=endpoint, + host=f"{host}", + ) + ) + elif strategy == "domain": + LOG.debug("Registering route from %s to %s", host, endpoint.proxy.forward_base_url) + assert not host == localstack_host().host, "trying to register an illegal catch all route" + rules.append( + ROUTER.add( + "/", + endpoint=endpoint, + host=f"{host}", + ) + ) + rules.append( + ROUTER.add( + "/", + endpoint=endpoint, + host=f"{host}", + ) + ) + elif strategy == "path": + LOG.debug("Registering route from %s to %s", path, endpoint.proxy.forward_base_url) + assert path and not path == "/", "trying to register an illegal catch all route" + rules.append(ROUTER.add(path, endpoint=endpoint)) + rules.append(ROUTER.add(f"{path}/", endpoint=endpoint)) + + elif strategy != "port": + LOG.warning("Attempted to register route for cluster with invalid strategy '%s'", strategy) + + return rules + + +class OpensearchCluster(Server): + """Manages an OpenSearch cluster which is installed and operated by LocalStack.""" + + def __init__( + self, + port: int, + arn: str, + host: str = "localhost", + version: str = None, + security_options: SecurityOptions = None, + ) -> None: + super().__init__(port, host) + self._version = version or self.default_version + self.arn = arn + self.security_options = security_options + self.is_security_enabled = self.security_options and self.security_options.enabled + self.auth = security_options.auth if self.is_security_enabled else None + + parsed_arn = parse_arn(arn) + self.account_id = parsed_arn["account"] + self.region_name = parsed_arn["region"] + + @property + def default_version(self) -> str: + return constants.OPENSEARCH_DEFAULT_VERSION + + @property + def version(self) -> str: + return self._version + + @property + def install_version(self) -> str: + _, install_version = versions.get_install_type_and_version(self._version) + return install_version + + @property + def protocol(self): + # if the security plugin is enabled, the cluster rejects unencrypted requests + return "https" if self.is_security_enabled else "http" + + @property + def bin_name(self) -> str: + return "opensearch" + + @property + def os_user(self): + return constants.OS_USER_OPENSEARCH + + def health(self) -> Optional[str]: + return get_cluster_health_status(self.url, auth=self.auth) + + def do_start_thread(self) -> FuncThread: + self._ensure_installed() + directories = resolve_directories(version=self.version, cluster_path=self.arn) + init_directories(directories) + + cmd = self._create_run_command(directories=directories) + cmd = " ".join(cmd) + + if is_root() and self.os_user: + # run the opensearch process as a non-root user (when running in docker) + cmd = f"su {self.os_user} -c '{cmd}'" + + env_vars = self._create_env_vars(directories) + + LOG.info("starting %s: %s with env %s", self.bin_name, cmd, env_vars) + t = ShellCommandThread( + cmd, + env_vars=env_vars, + strip_color=True, + log_listener=self._log_listener, + name="opensearch-cluster", + ) + t.start() + + # FIXME this approach should be handled differently + # - we need to perform some API requests after the server is up, but before the Server instance becomes healthy + # - this should be implemented in the Cluster or Server implementation + # wait for the cluster to be up and running and perform the post-startup setup + threading.Thread( + target=self._post_start_setup, + daemon=True, + ).start() + + return t + + def _post_start_setup(self): + if not self.is_security_enabled: + # post start setup not necessary + return + + # the health check for the cluster uses the master user auth (which will be created here). + # check for the health using the startup internal user auth here. + def wait_for_cluster_with_internal_creds() -> bool: + try: + return get_cluster_health_status(self.url, auth=INTERNAL_USER_AUTH) is not None + except Exception: + # we can get (raised) connection exceptions when the cluster is not yet accepting requests + return False + + poll_condition(wait_for_cluster_with_internal_creds) + + # create the master user + user = { + "password": self.security_options.master_password, + "opendistro_security_roles": ["all_access"], + } + response = requests.put( + f"{self.url}/_plugins/_security/api/internalusers/{self.security_options.master_username}", + json=user, + auth=INTERNAL_USER_AUTH, + verify=False, + ) + # after it's created the actual domain check (using these credentials) will report healthy + if not response.ok: + LOG.error( + "Setting up master user failed with status code %d! Shutting down!", + response.status_code, + ) + self.shutdown() + + def _ensure_installed(self): + opensearch_package.install(self.version) + + def _base_settings(self, dirs) -> CommandSettings: + settings = { + "http.port": self.port, + "http.publish_port": self.port, + "transport.port": "0", + "network.host": self.host, + "http.compression": "true", + "path.data": f'"{dirs.data}"', + "path.repo": f'"{dirs.backup}"', + "discovery.type": "single-node", + } + + if os.path.exists(os.path.join(dirs.mods, "x-pack-ml")): + settings["xpack.ml.enabled"] = "false" + + if not self.is_security_enabled: + settings["plugins.security.disabled"] = "true" + else: + # enable the security plugin in the settings + settings["plugins.security.disabled"] = "false" + # certs are set up during the package installation + settings["plugins.security.ssl.transport.pemkey_filepath"] = "cert.key" + settings["plugins.security.ssl.transport.pemcert_filepath"] = "cert.crt" + settings["plugins.security.ssl.transport.pemtrustedcas_filepath"] = "cert.crt" + settings["plugins.security.ssl.transport.enforce_hostname_verification"] = "false" + settings["plugins.security.ssl.http.enabled"] = "true" + settings["plugins.security.ssl.http.pemkey_filepath"] = "cert.key" + settings["plugins.security.ssl.http.pemcert_filepath"] = "cert.crt" + settings["plugins.security.ssl.http.pemtrustedcas_filepath"] = "cert.crt" + settings["plugins.security.allow_default_init_securityindex"] = "true" + settings["plugins.security.restapi.roles_enabled"] = ( + "all_access,security_rest_api_access" + ) + + return settings + + def _create_run_command( + self, directories: Directories, additional_settings: Optional[CommandSettings] = None + ) -> List[str]: + # delete opensearch data that may be cached locally from a previous test run + bin_path = os.path.join(directories.install, "bin", self.bin_name) + + settings = self._base_settings(directories) + + if additional_settings: + settings.update(additional_settings) + + cmd = build_cluster_run_command(bin_path, settings) + return cmd + + def _create_env_vars(self, directories: Directories) -> Dict: + env_vars = { + "JAVA_HOME": os.path.join(directories.install, "jdk"), + "OPENSEARCH_JAVA_OPTS": os.environ.get("OPENSEARCH_JAVA_OPTS", "-Xms200m -Xmx600m"), + "OPENSEARCH_TMPDIR": directories.tmp, + } + + # if the "opensearch-knn" plugin exists and has a "lib" directory, add it to the LD_LIBRARY_PATH + # see https://forum.opensearch.org/t/issue-with-opensearch-knn/12633 + knn_lib_dir = os.path.join(directories.install, "plugins", "opensearch-knn", "lib") + if os.path.isdir(knn_lib_dir): + prefix = ( + f"{os.environ.get('LD_LIBRARY_PATH')}:" if "LD_LIBRARY_PATH" in os.environ else "" + ) + env_vars["LD_LIBRARY_PATH"] = prefix + f"{knn_lib_dir}" + + return env_vars + + def _log_listener(self, line, **_kwargs): + # logging the port before each line to be able to connect logs to specific instances + LOG.info("[%s] %s", self.port, line.rstrip()) + + +class EndpointProxy: + def __init__(self, base_url: str, forward_url: str, custom_endpoint: CustomEndpoint) -> None: + super().__init__() + self.base_url = base_url + self.forward_url = forward_url + self.custom_endpoint = custom_endpoint + self.routing_rules = None + + def register(self): + _url = urlparse(self.base_url) + self.routing_rules = register_cluster( + host=_url.hostname, + path=_url.path, + forward_url=self.forward_url, + custom_endpoint=self.custom_endpoint, + ) + + def unregister(self): + ROUTER.remove(self.routing_rules) + self.routing_rules.clear() + + +class FakeEndpointProxyServer(Server): + """ + Makes an EndpointProxy behave like a Server. You can use this to create transparent + multiplexing behavior. + """ + + endpoint: EndpointProxy + + def __init__(self, endpoint: EndpointProxy) -> None: + self.endpoint = endpoint + self._shutdown_event = threading.Event() + + self._url = urlparse(self.endpoint.base_url) + super().__init__(self._url.port, self._url.hostname) + + @property + def url(self): + return self._url.geturl() + + def do_run(self): + self.endpoint.register() + try: + self._shutdown_event.wait() + finally: + self.endpoint.unregister() + + def do_shutdown(self): + self._shutdown_event.set() + + +class EdgeProxiedOpensearchCluster(Server): + """ + Opensearch-backed Server that can be routed through the edge proxy using an UrlMatchingForwarder to forward + requests to the backend cluster. + """ + + def __init__( + self, + url: str, + arn: str, + custom_endpoint: CustomEndpoint, + version: str = None, + security_options: SecurityOptions = None, + ) -> None: + self._url = urlparse(url) + + super().__init__( + host=self._url.hostname, + port=self._url.port, + ) + self.custom_endpoint = custom_endpoint + self._version = version or self.default_version + self.security_options = security_options + self.is_security_enabled = self.security_options and self.security_options.enabled + self.auth = security_options.auth if self.is_security_enabled else None + self.arn = arn + + self.cluster = None + self.cluster_port = None + self.proxy = None + + parsed_arn = parse_arn(arn) + self.account_id = parsed_arn["account"] + self.region_name = parsed_arn["region"] + + @property + def version(self): + return self._version + + @property + def default_version(self): + return constants.OPENSEARCH_DEFAULT_VERSION + + @property + def url(self) -> str: + return self._url.geturl() + + def is_up(self): + # check service lifecycle + if not self.cluster: + return False + + if not self.cluster.is_up(): + return False + + return super().is_up() + + def health(self): + """calls the health endpoint of cluster through the proxy, making sure implicitly that both are running""" + + # The user may have customised `LOCALSTACK_HOST`, so we need to rewrite the health + # check endpoint to always make a request against localhost.localstack.cloud (since we + # are always running in the same container), but in order to match the registered HTTP + # route, we must set the host header to the original URL used by this cluster. + url = self.url.replace(config.LOCALSTACK_HOST.host, constants.LOCALHOST_HOSTNAME) + url = url.replace(str(config.LOCALSTACK_HOST.port), str(config.GATEWAY_LISTEN[0].port)) + host = self._url.hostname + return get_cluster_health_status(url, self.auth, host=host) + + def _backend_cluster(self) -> OpensearchCluster: + return OpensearchCluster( + port=self.cluster_port, + host=DEFAULT_BACKEND_HOST, + arn=self.arn, + version=self.version, + security_options=self.security_options, + ) + + def do_run(self): + self.cluster_port = get_free_tcp_port() + self.cluster = self._backend_cluster() + self.cluster.start() + + self.proxy = EndpointProxy(self.url, self.cluster.url, self.custom_endpoint) + LOG.info("registering an endpoint proxy for %s => %s", self.url, self.cluster.url) + self.proxy.register() + + self.cluster.wait_is_up() + LOG.info("cluster on %s is ready", self.cluster.url) + + return self.cluster.join() + + def do_shutdown(self): + if self.proxy: + self.proxy.unregister() + if self.cluster: + self.cluster.shutdown() + + +class ElasticsearchCluster(OpensearchCluster): + def __init__( + self, + port: int, + arn: str, + host: str = "localhost", + version: str = None, + security_options: SecurityOptions = None, + ) -> None: + if security_options and security_options.enabled: + LOG.warning( + "Advanced security options are enabled, but are not supported for ElasticSearch." + ) + security_options = None + super().__init__( + port=port, arn=arn, host=host, version=version, security_options=security_options + ) + + @property + def default_version(self) -> str: + return constants.ELASTICSEARCH_DEFAULT_VERSION + + @property + def bin_name(self) -> str: + return "elasticsearch" + + @property + def os_user(self): + return constants.OS_USER_OPENSEARCH + + def _ensure_installed(self): + elasticsearch_package.install(self.version) + + def _base_settings(self, dirs) -> CommandSettings: + settings = { + "http.port": self.port, + "http.publish_port": self.port, + "network.host": self.host, + "http.compression": "false", + "path.data": f'"{dirs.data}"', + "path.repo": f'"{dirs.backup}"', + } + + # This config option was renamed between 6.7 and 6.8, yet not documented as a breaking change + # See https://github.com/elastic/elasticsearch/blob/f220abaf/build-tools/src/main/java/org/elasticsearch/gradle/testclusters/ElasticsearchNode.java#L1349-L1353 + if self.version.startswith("Elasticsearch_5.") or ( + self.version.startswith("Elasticsearch_6.") and self.version != "Elasticsearch_6.8" + ): + settings["transport.tcp.port"] = "0" + else: + settings["transport.port"] = "0" + + # `discovery.type` had a different meaning in 5.x + if not self.version.startswith("Elasticsearch_5."): + settings["discovery.type"] = "single-node" + + if os.path.exists(os.path.join(dirs.mods, "x-pack-ml")): + settings["xpack.ml.enabled"] = "false" + + return settings + + def _create_env_vars(self, directories: Directories) -> Dict: + return { + **elasticsearch_package.get_installer(self.version).get_java_env_vars(), + "ES_JAVA_OPTS": os.environ.get("ES_JAVA_OPTS", "-Xms200m -Xmx600m"), + "ES_TMPDIR": directories.tmp, + } + + +class EdgeProxiedElasticsearchCluster(EdgeProxiedOpensearchCluster): + @property + def default_version(self): + return constants.ELASTICSEARCH_DEFAULT_VERSION + + def _backend_cluster(self) -> OpensearchCluster: + return ElasticsearchCluster( + port=self.cluster_port, + host=DEFAULT_BACKEND_HOST, + arn=self.arn, + version=self.version, + security_options=self.security_options, + ) diff --git a/localstack-core/localstack/services/opensearch/cluster_manager.py b/localstack-core/localstack/services/opensearch/cluster_manager.py new file mode 100644 index 0000000000000..8a286daf661fc --- /dev/null +++ b/localstack-core/localstack/services/opensearch/cluster_manager.py @@ -0,0 +1,443 @@ +import dataclasses +import logging +import threading +from typing import Dict, Optional + +from botocore.utils import ArnParser + +from localstack import config +from localstack.aws.api.opensearch import DomainEndpointOptions, EngineType +from localstack.constants import LOCALHOST +from localstack.services.opensearch import versions +from localstack.services.opensearch.cluster import ( + CustomEndpoint, + EdgeProxiedElasticsearchCluster, + EdgeProxiedOpensearchCluster, + ElasticsearchCluster, + EndpointProxy, + FakeEndpointProxyServer, + OpensearchCluster, + SecurityOptions, +) +from localstack.utils.aws.arns import get_partition +from localstack.utils.common import ( + PortNotAvailableException, + call_safe, + external_service_ports, + get_free_tcp_port, + start_thread, +) +from localstack.utils.serving import Server +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + + +def create_cluster_manager() -> "ClusterManager": + """Creates the cluster manager according to the configuration.""" + + # If we have an external cluster, we always use the CustomBackendManager. + if config.OPENSEARCH_CUSTOM_BACKEND: + return CustomBackendManager() + + # If we are using a localstack-managed multi-cluster-setup, we use the MultiClusterManager. + if config.OPENSEARCH_MULTI_CLUSTER: + return MultiClusterManager() + else: + # Otherwise, we use a single cluster + if config.OPENSEARCH_ENDPOINT_STRATEGY != "port": + # and multiplex domains with the MultiplexingClusterManager. + return MultiplexingClusterManager() + else: + # with a single port. + return SingletonClusterManager() + + +@dataclasses.dataclass +class DomainKey: + """Uniquely identifies an OpenSearch/Elasticsearch domain.""" + + domain_name: str + region: str + account: str + + @property + def arn(self): + return f"arn:{get_partition(self.region)}:es:{self.region}:{self.account}:domain/{self.domain_name}" + + @staticmethod + def from_arn(arn: str) -> "DomainKey": + parsed = ArnParser().parse_arn(arn) + if parsed["service"] != "es": + raise ValueError("not an opensearch/es arn: %s", arn) + + return DomainKey( + domain_name=parsed["resource"][7:], # strip 'domain/' + region=parsed["region"], + account=parsed["account"], + ) + + +def build_cluster_endpoint( + domain_key: DomainKey, + custom_endpoint: Optional[CustomEndpoint] = None, + engine_type: EngineType = EngineType.OpenSearch, + preferred_port: Optional[int] = None, +) -> str: + """ + Builds the cluster endpoint from and optional custom_endpoint and the localstack opensearch config. Example + values: + + - my-domain.us-east-1.opensearch.localhost.localstack.cloud:4566 (endpoint strategy = domain (default)) + - localhost:4566/us-east-1/my-domain (endpoint strategy = path) + - localhost:[port-from-range] (endpoint strategy = port (or deprecated 'off')) + - my.domain:443/foo (arbitrary endpoints (technically not allowed by AWS, but there are no rules in localstack)) + + If preferred_port is not None, it is tried to reserve the given port. If the port is already bound, another port + will be used. + """ + # If we have a CustomEndpoint, we directly take its endpoint. + if custom_endpoint and custom_endpoint.enabled: + return custom_endpoint.endpoint + + # different endpoints based on engine type + engine_domain = "opensearch" if engine_type == EngineType.OpenSearch else "es" + + # Otherwise, the endpoint is either routed through the edge proxy via a sub-path (localhost:4566/opensearch/...) + if config.OPENSEARCH_ENDPOINT_STRATEGY == "port": + if preferred_port is not None: + try: + # if the preferred port is given, we explicitly try to reserve it + assigned_port = external_service_ports.reserve_port(preferred_port) + except PortNotAvailableException: + LOG.warning( + "Preferred port %s is not available, trying to reserve another port.", + preferred_port, + ) + assigned_port = external_service_ports.reserve_port() + else: + assigned_port = external_service_ports.reserve_port() + + host_definition = localstack_host(custom_port=assigned_port) + return host_definition.host_and_port() + if config.OPENSEARCH_ENDPOINT_STRATEGY == "path": + host_definition = localstack_host() + return f"{host_definition.host_and_port()}/{engine_domain}/{domain_key.region}/{domain_key.domain_name}" + + # or through a subdomain (domain-name.region.opensearch.localhost.localstack.cloud) + host_definition = localstack_host() + return f"{domain_key.domain_name}.{domain_key.region}.{engine_domain}.{host_definition.host_and_port()}" + + +def determine_custom_endpoint( + domain_endpoint_options: DomainEndpointOptions, +) -> Optional[CustomEndpoint]: + if not domain_endpoint_options: + return + + custom_endpoint = domain_endpoint_options.get("CustomEndpoint") + enabled = domain_endpoint_options.get("CustomEndpointEnabled", False) + + if not custom_endpoint: + # No custom endpoint to determine + return + + return CustomEndpoint(enabled, custom_endpoint) + + +class ClusterManager: + clusters: Dict[str, Server] + + def __init__(self) -> None: + self.clusters = {} + + def create( + self, + arn: str, + version: str, + endpoint_options: Optional[DomainEndpointOptions] = None, + security_options: Optional[SecurityOptions] = None, + preferred_port: Optional[int] = None, + ) -> Server: + """ + Creates a new cluster. + + :param arn: of the cluster to create + :param version: of the cluster to start (string including the EngineType) + :param endpoint_options: DomainEndpointOptions (may contain information about a custom endpoint url) + :param security_options: SecurityOptions (may contain info on the security plugin config) + :param preferred_port: port which should be preferred (only if OPENSEARCH_ENDPOINT_STRATEGY == "port") + :return: None + """ + + # determine custom domain endpoint + custom_endpoint = determine_custom_endpoint(endpoint_options) + + # determine engine type + engine_type = versions.get_engine_type(version) + + # build final endpoint and cluster url + endpoint = build_cluster_endpoint( + DomainKey.from_arn(arn), custom_endpoint, engine_type, preferred_port + ) + url = f"http://{endpoint}" if "://" not in endpoint else endpoint + + # call abstract cluster factory + cluster = self._create_cluster(arn, url, version, custom_endpoint, security_options) + cluster.start() + + # save cluster into registry and return + self.clusters[arn] = cluster + return cluster + + def get(self, arn: str) -> Optional[Server]: + return self.clusters.get(arn) + + def remove(self, arn: str): + if arn in self.clusters: + cluster = self.clusters.pop(arn) + if cluster: + LOG.debug("shutting down cluster arn %s (%s)", arn, cluster.url) + cluster.shutdown() + + def is_up(self, arn: str) -> bool: + cluster = self.get(arn) + return cluster.is_up() if cluster else False + + def _create_cluster( + self, + arn: str, + url: str, + version: str, + custom_endpoint: CustomEndpoint, + security_options: SecurityOptions, + ) -> Server: + """ + Abstract cluster factory. + + :param version: the full prefixed version, e.g. "OpenSearch_1.0" or "Elasticsearch_7.10" + """ + raise NotImplementedError + + def shutdown_all(self): + while self.clusters: + domain, cluster = self.clusters.popitem() + call_safe(cluster.shutdown) + + +class ClusterEndpoint(FakeEndpointProxyServer): + """ + An endpoint that points to a cluster, and behaves like a Server. + """ + + def __init__(self, cluster: Server, endpoint: EndpointProxy) -> None: + super().__init__(endpoint) + self.cluster = cluster + + def health(self): + return super().health() and self.cluster.health() + + def do_shutdown(self): + super(FakeEndpointProxyServer, self).do_shutdown() + self.cluster.shutdown() + + +def _get_port_from_url(url: str) -> int: + return int(url.split(":")[2]) + + +class MultiplexingClusterManager(ClusterManager): + """ + Multiplexes multiple endpoints to a single backend cluster (not managed by LocalStack). + Using this, we lie to the client about the opensearch domain version. + It only works with a single endpoint. + + Assumes the config: + - OPENSEARCH_MULTI_CLUSTER = False + - OPENSEARCH_ENDPOINT_STRATEGY = domain / path + """ + + cluster: Optional[Server] + endpoints: Dict[str, ClusterEndpoint] + + def __init__(self) -> None: + super().__init__() + self.cluster = None + self.endpoints = {} + self.mutex = threading.RLock() + + def _create_cluster( + self, + arn: str, + url: str, + version: str, + custom_endpoint: CustomEndpoint, + security_options: SecurityOptions, + ) -> Server: + with self.mutex: + if not self.cluster: + engine_type = versions.get_engine_type(version) + # startup routine for the singleton cluster instance + if engine_type == EngineType.OpenSearch: + self.cluster = OpensearchCluster( + port=get_free_tcp_port(), arn=arn, security_options=security_options + ) + else: + self.cluster = ElasticsearchCluster( + port=get_free_tcp_port(), arn=arn, security_options=security_options + ) + + def _start_async(*_): + LOG.info("starting %s on %s", type(self.cluster), self.cluster.url) + self.cluster.start() # start may block during install + + start_thread(_start_async, name="opensearch-multiplex") + cluster_endpoint = ClusterEndpoint( + self.cluster, EndpointProxy(url, self.cluster.url, custom_endpoint=custom_endpoint) + ) + self.clusters[arn] = cluster_endpoint + return cluster_endpoint + + def remove(self, arn: str): + super().remove(arn) # removes the fake server + + if not self.endpoints: + # if there are no endpoints left, remove the cluster + with self.mutex: + if not self.cluster: + return + + LOG.debug("shutting down multiplexed cluster for %s: %s", arn, self.cluster.url) + self.cluster.shutdown() + self.cluster = None + + +class MultiClusterManager(ClusterManager): + """ + Manages one cluster and endpoint per domain. + """ + + def _create_cluster( + self, + arn: str, + url: str, + version: str, + custom_endpoint: CustomEndpoint, + security_options: SecurityOptions, + ) -> Server: + engine_type = versions.get_engine_type(version) + if config.OPENSEARCH_ENDPOINT_STRATEGY != "port": + if engine_type == EngineType.OpenSearch: + return EdgeProxiedOpensearchCluster( + url=url, + arn=arn, + version=version, + custom_endpoint=custom_endpoint, + security_options=security_options, + ) + else: + return EdgeProxiedElasticsearchCluster( + url=url, + arn=arn, + version=version, + custom_endpoint=custom_endpoint, + security_options=security_options, + ) + else: + port = _get_port_from_url(url) + if engine_type == EngineType.OpenSearch: + return OpensearchCluster( + port=port, host=config.GATEWAY_LISTEN[0].host, arn=arn, version=version + ) + else: + return ElasticsearchCluster( + port=port, host=config.GATEWAY_LISTEN[0].host, arn=arn, version=version + ) + + +class SingletonClusterManager(ClusterManager): + """ + Manages a single cluster and always returns that cluster. Using this, we lie to the client about the + elasticsearch domain version. The first call to create_domain with a specific version will create the cluster + with that version. Subsequent calls will believe they created a cluster with the version they specified. It keeps + the cluster running until the last domain was removed. It only works with a single endpoint. + Assumes the config: + - ES_ENDPOINT_STRATEGY == "port" + - ES_MULTI_CLUSTER == False + """ + + cluster: Optional[Server] + + def __init__(self) -> None: + super().__init__() + self.server = None + self.mutex = threading.RLock() + self.cluster = None + + def create( + self, + arn: str, + version: str, + endpoint_options: Optional[DomainEndpointOptions] = None, + security_options: Optional[SecurityOptions] = None, + preferred_port: int = None, + ) -> Server: + with self.mutex: + return super().create(arn, version, endpoint_options, security_options, preferred_port) + + def _create_cluster( + self, + arn: str, + url: str, + version: str, + custom_endpoint: CustomEndpoint, + security_options: SecurityOptions, + ) -> Server: + if not self.cluster: + port = _get_port_from_url(url) + engine_type = versions.get_engine_type(version) + if engine_type == EngineType.OpenSearch: + self.cluster = OpensearchCluster( + port=port, + host=config.GATEWAY_LISTEN[0].host, + version=version, + arn=arn, + security_options=security_options, + ) + else: + self.cluster = ElasticsearchCluster( + port=port, + host=LOCALHOST, + version=version, + arn=arn, + security_options=security_options, + ) + + return self.cluster + + def remove(self, arn: str): + with self.mutex: + try: + cluster = self.clusters.pop(arn) + except KeyError: + return + + LOG.debug("removing cluster for %s, %s remaining", arn, len(self.clusters)) + if not self.clusters: + # shutdown the cluster if it is + cluster.shutdown() + self.cluster = None + + +class CustomBackendManager(ClusterManager): + def _create_cluster( + self, + arn: str, + url: str, + version: str, + custom_endpoint: CustomEndpoint, + security_options: SecurityOptions, + ) -> Server: + return FakeEndpointProxyServer( + EndpointProxy(url, config.OPENSEARCH_CUSTOM_BACKEND, custom_endpoint) + ) diff --git a/localstack-core/localstack/services/opensearch/models.py b/localstack-core/localstack/services/opensearch/models.py new file mode 100644 index 0000000000000..5748ac7639ec3 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/models.py @@ -0,0 +1,21 @@ +from typing import Dict + +from localstack.aws.api.opensearch import DomainStatus +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossRegionAttribute, + LocalAttribute, +) +from localstack.utils.tagging import TaggingService + + +class OpenSearchStore(BaseStore): + # storage for domain resources (access should be protected with the _domain_mutex) + opensearch_domains: Dict[str, DomainStatus] = LocalAttribute(default=dict) + + # static tagging service instance + TAGS = CrossRegionAttribute(default=TaggingService) + + +opensearch_stores = AccountRegionBundle("opensearch", OpenSearchStore) diff --git a/localstack-core/localstack/services/opensearch/packages.py b/localstack-core/localstack/services/opensearch/packages.py new file mode 100644 index 0000000000000..35a7fd933ea91 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/packages.py @@ -0,0 +1,371 @@ +import glob +import logging +import os +import re +import shutil +import textwrap +import threading +from typing import List + +import semver + +from localstack import config +from localstack.constants import ( + ELASTICSEARCH_DEFAULT_VERSION, + ELASTICSEARCH_DELETE_MODULES, + ELASTICSEARCH_PLUGIN_LIST, + OPENSEARCH_DEFAULT_VERSION, + OPENSEARCH_PLUGIN_LIST, +) +from localstack.packages import InstallTarget, Package, PackageInstaller +from localstack.packages.java import java_package +from localstack.services.opensearch import versions +from localstack.utils.archives import download_and_extract_with_retry +from localstack.utils.files import chmod_r, load_file, mkdir, rm_rf, save_file +from localstack.utils.java import ( + java_system_properties_proxy, + java_system_properties_ssl, + system_properties_to_cli_args, +) +from localstack.utils.run import run +from localstack.utils.ssl import create_ssl_cert, install_predefined_cert_if_available +from localstack.utils.sync import SynchronizedDefaultDict, retry + +LOG = logging.getLogger(__name__) + + +_OPENSEARCH_INSTALL_LOCKS = SynchronizedDefaultDict(threading.RLock) + + +class OpensearchPackage(Package): + def __init__(self, default_version: str = OPENSEARCH_DEFAULT_VERSION): + super().__init__(name="OpenSearch", default_version=default_version) + + def _get_installer(self, version: str) -> PackageInstaller: + if version in versions._prefixed_elasticsearch_install_versions: + if version.startswith("Elasticsearch_5.") or version.startswith("Elasticsearch_6."): + return ElasticsearchLegacyPackageInstaller(version) + return ElasticsearchPackageInstaller(version) + else: + return OpensearchPackageInstaller(version) + + def get_versions(self) -> List[str]: + return list(versions.install_versions.keys()) + + +class OpensearchPackageInstaller(PackageInstaller): + def __init__(self, version: str): + super().__init__("opensearch", version) + + def _install(self, target: InstallTarget): + # locally import to avoid having a dependency on ASF when starting the CLI + from localstack.aws.api.opensearch import EngineType + from localstack.services.opensearch import versions + + version = self._get_opensearch_install_version() + install_dir = self._get_install_dir(target) + with _OPENSEARCH_INSTALL_LOCKS[version]: + if not os.path.exists(install_dir): + opensearch_url = versions.get_download_url(version, EngineType.OpenSearch) + install_dir_parent = os.path.dirname(install_dir) + mkdir(install_dir_parent) + # download and extract archive + tmp_archive = os.path.join( + config.dirs.cache, f"localstack.{os.path.basename(opensearch_url)}" + ) + download_and_extract_with_retry(opensearch_url, tmp_archive, install_dir_parent) + opensearch_dir = glob.glob(os.path.join(install_dir_parent, "opensearch*")) + if not opensearch_dir: + raise Exception(f"Unable to find OpenSearch folder in {install_dir_parent}") + shutil.move(opensearch_dir[0], install_dir) + + for dir_name in ("data", "logs", "modules", "plugins", "config/scripts"): + dir_path = os.path.join(install_dir, dir_name) + mkdir(dir_path) + chmod_r(dir_path, 0o777) + + parsed_version = semver.VersionInfo.parse(version) + + # setup security based on the version + self._setup_security(install_dir, parsed_version) + + # install other default plugins for opensearch 1.1+ + # https://forum.opensearch.org/t/ingest-attachment-cannot-be-installed/6494/12 + if parsed_version >= "1.1.0": + # Determine network configuration to use for plugin downloads + sys_props = { + **java_system_properties_proxy(), + **java_system_properties_ssl( + os.path.join(install_dir, "jdk", "bin", "keytool"), + {"JAVA_HOME": os.path.join(install_dir, "jdk")}, + ), + } + java_opts = system_properties_to_cli_args(sys_props) + + for plugin in OPENSEARCH_PLUGIN_LIST: + plugin_binary = os.path.join(install_dir, "bin", "opensearch-plugin") + plugin_dir = os.path.join(install_dir, "plugins", plugin) + if not os.path.exists(plugin_dir): + LOG.info("Installing OpenSearch plugin %s", plugin) + + def try_install(): + output = run( + [plugin_binary, "install", "-b", plugin], + env_vars={"OPENSEARCH_JAVA_OPTS": " ".join(java_opts)}, + ) + LOG.debug("Plugin installation output: %s", output) + + # We're occasionally seeing javax.net.ssl.SSLHandshakeException -> add download retries + download_attempts = 3 + try: + retry(try_install, retries=download_attempts - 1, sleep=2) + except Exception: + LOG.warning( + "Unable to download OpenSearch plugin '%s' after %s attempts", + plugin, + download_attempts, + ) + if not os.environ.get("IGNORE_OS_DOWNLOAD_ERRORS"): + raise + + def _setup_security(self, install_dir: str, parsed_version: semver.VersionInfo): + """ + Prepares the usage of the SecurityPlugin for the different versions of OpenSearch. + :param install_dir: root installation directory for OpenSearch which should be configured + :param parsed_version: parsed semantic version of the OpenSearch installation which should be configured + """ + # create & copy SSL certs to opensearch config dir + install_predefined_cert_if_available() + config_path = os.path.join(install_dir, "config") + _, cert_file_name, key_file_name = create_ssl_cert() + shutil.copyfile(cert_file_name, os.path.join(config_path, "cert.crt")) + shutil.copyfile(key_file_name, os.path.join(config_path, "cert.key")) + + # configure the default roles, roles_mappings, and internal_users + if parsed_version >= "2.0.0": + # with version 2 of opensearch and the security plugin, the config moved to the root config folder + security_config_folder = os.path.join(install_dir, "config", "opensearch-security") + else: + security_config_folder = os.path.join( + install_dir, "plugins", "opensearch-security", "securityconfig" + ) + + # no non-default roles (not even the demo roles) should be set up + roles_path = os.path.join(security_config_folder, "roles.yml") + save_file( + file=roles_path, + permissions=0o666, + content=textwrap.dedent( + """\ + _meta: + type: "roles" + config_version: 2 + """ + ), + ) + + # create the internal user which allows localstack to manage the running instance + internal_users_path = os.path.join(security_config_folder, "internal_users.yml") + save_file( + file=internal_users_path, + permissions=0o666, + content=textwrap.dedent( + """\ + _meta: + type: "internalusers" + config_version: 2 + + # Define your internal users here + localstack-internal: + hash: "$2y$12$ZvpKLI2nsdGj1ResAmlLne7ki5o45XpBppyg9nXF2RLNfmwjbFY22" + reserved: true + hidden: true + backend_roles: [] + attributes: {} + opendistro_security_roles: [] + static: false + """ + ), + ) + + # define the necessary roles mappings for the internal user + roles_mapping_path = os.path.join(security_config_folder, "roles_mapping.yml") + save_file( + file=roles_mapping_path, + permissions=0o666, + content=textwrap.dedent( + """\ + _meta: + type: "rolesmapping" + config_version: 2 + + security_manager: + hosts: [] + users: + - localstack-internal + reserved: false + hidden: false + backend_roles: [] + and_backend_roles: [] + + all_access: + hosts: [] + users: + - localstack-internal + reserved: false + hidden: false + backend_roles: [] + and_backend_roles: [] + """ + ), + ) + + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, "bin", "opensearch") + + def _get_opensearch_install_version(self) -> str: + from localstack.services.opensearch import versions + + if config.SKIP_INFRA_DOWNLOADS: + self.version = OPENSEARCH_DEFAULT_VERSION + + return versions.get_install_version(self.version) + + +class ElasticsearchPackageInstaller(PackageInstaller): + def __init__(self, version: str): + super().__init__("elasticsearch", version) + + def get_java_env_vars(self) -> dict[str, str]: + install_dir = self.get_installed_dir() + return { + "JAVA_HOME": os.path.join(install_dir, "jdk"), + } + + def _install(self, target: InstallTarget): + # locally import to avoid having a dependency on ASF when starting the CLI + from localstack.aws.api.opensearch import EngineType + from localstack.services.opensearch import versions + + version = self.get_elasticsearch_install_version() + install_dir = self._get_install_dir(target) + installed_executable = os.path.join(install_dir, "bin", "elasticsearch") + if not os.path.exists(installed_executable): + es_url = versions.get_download_url(version, EngineType.Elasticsearch) + install_dir_parent = os.path.dirname(install_dir) + mkdir(install_dir_parent) + # download and extract archive + tmp_archive = os.path.join(config.dirs.cache, f"localstack.{os.path.basename(es_url)}") + download_and_extract_with_retry(es_url, tmp_archive, install_dir_parent) + elasticsearch_dir = glob.glob(os.path.join(install_dir_parent, "elasticsearch*")) + if not elasticsearch_dir: + raise Exception(f"Unable to find Elasticsearch folder in {install_dir_parent}") + shutil.move(elasticsearch_dir[0], install_dir) + + for dir_name in ("data", "logs", "modules", "plugins", "config/scripts"): + dir_path = os.path.join(install_dir, dir_name) + mkdir(dir_path) + chmod_r(dir_path, 0o777) + + # Determine network configuration to use for plugin downloads + sys_props = { + **java_system_properties_proxy(), + **java_system_properties_ssl( + os.path.join(install_dir, "jdk", "bin", "keytool"), + self.get_java_env_vars(), + ), + } + java_opts = system_properties_to_cli_args(sys_props) + + # install default plugins + for plugin in ELASTICSEARCH_PLUGIN_LIST: + plugin_binary = os.path.join(install_dir, "bin", "elasticsearch-plugin") + plugin_dir = os.path.join(install_dir, "plugins", plugin) + if not os.path.exists(plugin_dir): + LOG.info("Installing Elasticsearch plugin %s", plugin) + + def try_install(): + output = run( + [plugin_binary, "install", "-b", plugin], + env_vars={"ES_JAVA_OPTS": " ".join(java_opts)}, + ) + LOG.debug("Plugin installation output: %s", output) + + # We're occasionally seeing javax.net.ssl.SSLHandshakeException -> add download retries + download_attempts = 3 + try: + retry(try_install, retries=download_attempts - 1, sleep=2) + except Exception: + LOG.warning( + "Unable to download Elasticsearch plugin '%s' after %s attempts", + plugin, + download_attempts, + ) + if not os.environ.get("IGNORE_ES_DOWNLOAD_ERRORS"): + raise + + # delete some plugins to free up space + for plugin in ELASTICSEARCH_DELETE_MODULES: + module_dir = os.path.join(install_dir, "modules", plugin) + rm_rf(module_dir) + + # disable x-pack-ml plugin (not working on Alpine) + xpack_dir = os.path.join(install_dir, "modules", "x-pack-ml", "platform") + rm_rf(xpack_dir) + + # patch JVM options file - replace hardcoded heap size settings + jvm_options_file = os.path.join(install_dir, "config", "jvm.options") + if os.path.exists(jvm_options_file): + jvm_options = load_file(jvm_options_file) + jvm_options_replaced = re.sub( + r"(^-Xm[sx][a-zA-Z0-9.]+$)", r"# \1", jvm_options, flags=re.MULTILINE + ) + if jvm_options != jvm_options_replaced: + save_file(jvm_options_file, jvm_options_replaced) + + # patch JVM options file - replace hardcoded heap size settings + jvm_options_file = os.path.join(install_dir, "config", "jvm.options") + if os.path.exists(jvm_options_file): + jvm_options = load_file(jvm_options_file) + jvm_options_replaced = re.sub( + r"(^-Xm[sx][a-zA-Z0-9.]+$)", r"# \1", jvm_options, flags=re.MULTILINE + ) + if jvm_options != jvm_options_replaced: + save_file(jvm_options_file, jvm_options_replaced) + + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, "bin", "elasticsearch") + + def get_elasticsearch_install_version(self) -> str: + from localstack.services.opensearch import versions + + if config.SKIP_INFRA_DOWNLOADS: + return ELASTICSEARCH_DEFAULT_VERSION + + return versions.get_install_version(self.version) + + +class ElasticsearchLegacyPackageInstaller(ElasticsearchPackageInstaller): + """ + Specialised package installer for ElasticSearch 5.x and 6.x + + It installs Java during setup because these releases of ES do not have a bundled JDK. + This should be removed after these versions are dropped in line with AWS EOL, scheduled for Nov 2026. + https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version + """ + + # ES 5.x and 6.x require Java 8 + # See: https://www.elastic.co/guide/en/elasticsearch/reference/6.0/zip-targz.html + JAVA_VERSION = "8" + + def _prepare_installation(self, target: InstallTarget) -> None: + java_package.get_installer(self.JAVA_VERSION).install(target) + + def get_java_env_vars(self) -> dict[str, str]: + return { + "JAVA_HOME": java_package.get_installer(self.JAVA_VERSION).get_java_home(), + } + + +opensearch_package = OpensearchPackage(default_version=OPENSEARCH_DEFAULT_VERSION) +elasticsearch_package = OpensearchPackage(default_version=ELASTICSEARCH_DEFAULT_VERSION) diff --git a/localstack-core/localstack/services/opensearch/plugins.py b/localstack-core/localstack/services/opensearch/plugins.py new file mode 100644 index 0000000000000..9643e81efe56b --- /dev/null +++ b/localstack-core/localstack/services/opensearch/plugins.py @@ -0,0 +1,8 @@ +from localstack.packages import Package, package + + +@package(name="opensearch") +def opensearch_package() -> Package: + from localstack.services.opensearch.packages import opensearch_package + + return opensearch_package diff --git a/localstack-core/localstack/services/opensearch/provider.py b/localstack-core/localstack/services/opensearch/provider.py new file mode 100644 index 0000000000000..e33554c347ad8 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/provider.py @@ -0,0 +1,688 @@ +import logging +import os +import re +import threading +from copy import deepcopy +from datetime import datetime, timezone +from random import randint +from typing import Dict, Optional +from urllib.parse import urlparse + +from localstack import config +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.opensearch import ( + ARN, + AccessPoliciesStatus, + AdvancedOptionsStatus, + AdvancedSecurityOptions, + AdvancedSecurityOptionsStatus, + AutoTuneDesiredState, + AutoTuneOptions, + AutoTuneOptionsOutput, + AutoTuneOptionsStatus, + AutoTuneState, + AutoTuneStatus, + ClusterConfig, + ClusterConfigStatus, + CognitoOptions, + CognitoOptionsStatus, + ColdStorageOptions, + CreateDomainRequest, + CreateDomainResponse, + DeleteDomainResponse, + DeploymentStatus, + DescribeDomainConfigResponse, + DescribeDomainResponse, + DescribeDomainsResponse, + DomainConfig, + DomainEndpointOptions, + DomainEndpointOptionsStatus, + DomainInfo, + DomainName, + DomainNameList, + DomainProcessingStatusType, + DomainStatus, + EBSOptions, + EBSOptionsStatus, + EncryptionAtRestOptions, + EncryptionAtRestOptionsStatus, + EngineType, + GetCompatibleVersionsResponse, + ListDomainNamesResponse, + ListTagsResponse, + ListVersionsResponse, + LogPublishingOptionsStatus, + MaxResults, + NextToken, + NodeToNodeEncryptionOptions, + NodeToNodeEncryptionOptionsStatus, + OpensearchApi, + OpenSearchPartitionInstanceType, + OptionState, + OptionStatus, + ResourceAlreadyExistsException, + ResourceNotFoundException, + RollbackOnDisable, + ServiceSoftwareOptions, + SnapshotOptions, + SnapshotOptionsStatus, + StringList, + TagList, + TLSSecurityPolicy, + UpdateDomainConfigRequest, + UpdateDomainConfigResponse, + ValidationException, + VersionStatus, + VolumeType, + VPCDerivedInfoStatus, +) +from localstack.constants import OPENSEARCH_DEFAULT_VERSION +from localstack.services.opensearch import versions +from localstack.services.opensearch.cluster import SecurityOptions +from localstack.services.opensearch.cluster_manager import ( + ClusterManager, + DomainKey, + create_cluster_manager, +) +from localstack.services.opensearch.models import OpenSearchStore, opensearch_stores +from localstack.services.plugins import ServiceLifecycleHook +from localstack.state import AssetDirectory, StateVisitor +from localstack.utils.aws.arns import parse_arn +from localstack.utils.collections import PaginatedList, remove_none_values_from_dict +from localstack.utils.serving import Server +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +# The singleton for the ClusterManager instance. +# The singleton is implemented this way only to be able to overwrite its value during tests. +__CLUSTER_MANAGER = None + +# mutex for modifying domains +_domain_mutex = threading.RLock() + +DEFAULT_OPENSEARCH_CLUSTER_CONFIG = ClusterConfig( + InstanceType=OpenSearchPartitionInstanceType.m3_medium_search, + InstanceCount=1, + DedicatedMasterEnabled=True, + ZoneAwarenessEnabled=False, + DedicatedMasterType=OpenSearchPartitionInstanceType.m3_medium_search, + DedicatedMasterCount=1, +) + +DEFAULT_OPENSEARCH_DOMAIN_ENDPOINT_OPTIONS = DomainEndpointOptions( + EnforceHTTPS=False, + TLSSecurityPolicy=TLSSecurityPolicy.Policy_Min_TLS_1_0_2019_07, + CustomEndpointEnabled=False, +) + + +def cluster_manager() -> ClusterManager: + global __CLUSTER_MANAGER + if __CLUSTER_MANAGER is None: + __CLUSTER_MANAGER = create_cluster_manager() + return __CLUSTER_MANAGER + + +def _run_cluster_startup_monitor(cluster: Server, domain_name: str, region: str): + LOG.debug("running cluster startup monitor for cluster %s", cluster) + + # wait until the cluster is started + # NOTE: does not work when DNS rebind protection is active for localhost.localstack.cloud + is_up = cluster.wait_is_up() + + LOG.debug("cluster state polling for %s returned! status = %s", domain_name, is_up) + with _domain_mutex: + store = OpensearchProvider.get_store(cluster.account_id, cluster.region_name) + status = store.opensearch_domains.get(domain_name) + if status is not None: + status["Processing"] = False + status["DomainProcessingStatus"] = DomainProcessingStatusType.Active + + +def create_cluster( + domain_key: DomainKey, + engine_version: str, + domain_endpoint_options: Optional[DomainEndpointOptions], + security_options: Optional[SecurityOptions], + preferred_port: Optional[int] = None, +): + """ + Uses the ClusterManager to create a new cluster for the given domain key. NOT thread safe, needs to be called + around _domain_mutex. + If the preferred_port is given, this port will be preferred (if OPENSEARCH_ENDPOINT_STRATEGY == "port"). + """ + store = opensearch_stores[domain_key.account][domain_key.region] + + manager = cluster_manager() + engine_version = engine_version or OPENSEARCH_DEFAULT_VERSION + cluster = manager.create( + arn=domain_key.arn, + version=engine_version, + endpoint_options=domain_endpoint_options, + security_options=security_options, + preferred_port=preferred_port, + ) + + # FIXME: in AWS, the Endpoint is set once the cluster is running, not before (like here), but our tests and + # in particular cloudformation currently relies on the assumption that it is set when the domain is created. + status = store.opensearch_domains[domain_key.domain_name] + # Replacing only 0.0.0.0 here as usage of this bind address mostly means running in docker which is used locally + # If another bind address is used we want to keep it in the endpoint as this is a conscious user decision to + # access from another device on the network. + status["Endpoint"] = cluster.url.split("://")[-1].replace("0.0.0.0", localstack_host().host) + status["EngineVersion"] = engine_version + status["DomainProcessingStatus"] = DomainProcessingStatusType.Creating + + if cluster.is_up(): + status["Processing"] = False + status["DomainProcessingStatus"] = DomainProcessingStatusType.Active + else: + # run a background thread that will update all domains that use this cluster to set + # the cluster state once it is started, or the CLUSTER_STARTUP_TIMEOUT is reached + threading.Thread( + target=_run_cluster_startup_monitor, + args=(cluster, domain_key.domain_name, domain_key.region), + daemon=True, + ).start() + + +def _remove_cluster(domain_key: DomainKey): + parsed_arn = parse_arn(domain_key.arn) + store = OpensearchProvider.get_store(parsed_arn["account"], parsed_arn["region"]) + cluster_manager().remove(domain_key.arn) + del store.opensearch_domains[domain_key.domain_name] + + +def get_domain_config(domain_key) -> DomainConfig: + status = get_domain_status(domain_key) + return _status_to_config(status) + + +def _status_to_config(status: DomainStatus) -> DomainConfig: + cluster_cfg = status.get("ClusterConfig") or {} + default_cfg = DEFAULT_OPENSEARCH_CLUSTER_CONFIG + config_status = get_domain_config_status() + return DomainConfig( + AccessPolicies=AccessPoliciesStatus( + Options=status.get("AccessPolicies", ""), + Status=config_status, + ), + AdvancedOptions=AdvancedOptionsStatus( + Options={ + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true", + }, + Status=config_status, + ), + EBSOptions=EBSOptionsStatus( + Options=EBSOptions( + EBSEnabled=True, + VolumeSize=100, + VolumeType=VolumeType.gp2, + ), + Status=config_status, + ), + ClusterConfig=ClusterConfigStatus( + Options=ClusterConfig( + DedicatedMasterCount=cluster_cfg.get( + "DedicatedMasterCount", default_cfg["DedicatedMasterCount"] + ), + DedicatedMasterEnabled=cluster_cfg.get( + "DedicatedMasterEnabled", default_cfg["DedicatedMasterEnabled"] + ), + DedicatedMasterType=cluster_cfg.get( + "DedicatedMasterType", default_cfg["DedicatedMasterType"] + ), + InstanceCount=cluster_cfg.get("InstanceCount", default_cfg["InstanceCount"]), + InstanceType=cluster_cfg.get("InstanceType", default_cfg["InstanceType"]), + ZoneAwarenessEnabled=cluster_cfg.get( + "ZoneAwarenessEnabled", default_cfg["ZoneAwarenessEnabled"] + ), + ), + Status=config_status, + ), + CognitoOptions=CognitoOptionsStatus( + Options=CognitoOptions(Enabled=False), Status=config_status + ), + EngineVersion=VersionStatus(Options=status.get("EngineVersion"), Status=config_status), + EncryptionAtRestOptions=EncryptionAtRestOptionsStatus( + Options=EncryptionAtRestOptions(Enabled=False), + Status=config_status, + ), + LogPublishingOptions=LogPublishingOptionsStatus( + Options={}, + Status=config_status, + ), + SnapshotOptions=SnapshotOptionsStatus( + Options=SnapshotOptions(AutomatedSnapshotStartHour=randint(0, 23)), + Status=config_status, + ), + VPCOptions=VPCDerivedInfoStatus( + Options={}, + Status=config_status, + ), + DomainEndpointOptions=DomainEndpointOptionsStatus( + Options=status.get("DomainEndpointOptions", {}), + Status=config_status, + ), + NodeToNodeEncryptionOptions=NodeToNodeEncryptionOptionsStatus( + Options=NodeToNodeEncryptionOptions(Enabled=False), + Status=config_status, + ), + AdvancedSecurityOptions=AdvancedSecurityOptionsStatus( + Options=status.get("AdvancedSecurityOptions", {}), Status=config_status + ), + AutoTuneOptions=AutoTuneOptionsStatus( + Options=AutoTuneOptions( + DesiredState=AutoTuneDesiredState.ENABLED, + RollbackOnDisable=RollbackOnDisable.NO_ROLLBACK, + MaintenanceSchedules=[], + ), + Status=AutoTuneStatus( + CreationDate=config_status.get("CreationDate"), + UpdateDate=config_status.get("UpdateDate"), + UpdateVersion=config_status.get("UpdateVersion"), + State=AutoTuneState.ENABLED, + PendingDeletion=config_status.get("PendingDeletion"), + ), + ), + ) + + +def get_domain_config_status() -> OptionStatus: + return OptionStatus( + CreationDate=datetime.now(), + PendingDeletion=False, + State=OptionState.Active, + UpdateDate=datetime.now(), + UpdateVersion=randint(1, 100), + ) + + +def get_domain_status( + domain_key: DomainKey, deleted=False, request: CreateDomainRequest | None = None +) -> DomainStatus: + parsed_arn = parse_arn(domain_key.arn) + store = OpensearchProvider.get_store(parsed_arn["account"], parsed_arn["region"]) + stored_status: DomainStatus = ( + store.opensearch_domains.get(domain_key.domain_name) or DomainStatus() + ) + cluster_cfg = stored_status.get("ClusterConfig") or {} + default_cfg = DEFAULT_OPENSEARCH_CLUSTER_CONFIG + if request: + stored_status = deepcopy(stored_status) + stored_status.update(request) + default_cfg.update(request.get("ClusterConfig", {})) + + domain_processing_status = stored_status.get("DomainProcessingStatus", None) + processing = stored_status.get("Processing", True) + if deleted: + domain_processing_status = DomainProcessingStatusType.Deleting + processing = True + + new_status = DomainStatus( + ARN=domain_key.arn, + Created=True, + Deleted=deleted, + DomainProcessingStatus=domain_processing_status, + Processing=processing, + DomainId=f"{domain_key.account}/{domain_key.domain_name}", + DomainName=domain_key.domain_name, + ClusterConfig=ClusterConfig( + DedicatedMasterCount=cluster_cfg.get( + "DedicatedMasterCount", default_cfg["DedicatedMasterCount"] + ), + DedicatedMasterEnabled=cluster_cfg.get( + "DedicatedMasterEnabled", default_cfg["DedicatedMasterEnabled"] + ), + DedicatedMasterType=cluster_cfg.get( + "DedicatedMasterType", default_cfg["DedicatedMasterType"] + ), + InstanceCount=cluster_cfg.get("InstanceCount", default_cfg["InstanceCount"]), + InstanceType=cluster_cfg.get("InstanceType", default_cfg["InstanceType"]), + ZoneAwarenessEnabled=cluster_cfg.get( + "ZoneAwarenessEnabled", default_cfg["ZoneAwarenessEnabled"] + ), + WarmEnabled=False, + ColdStorageOptions=ColdStorageOptions(Enabled=False), + ), + EngineVersion=stored_status.get("EngineVersion") or OPENSEARCH_DEFAULT_VERSION, + Endpoint=stored_status.get("Endpoint", None), + EBSOptions=stored_status.get("EBSOptions") + or EBSOptions(EBSEnabled=True, VolumeType=VolumeType.gp2, VolumeSize=10, Iops=0), + CognitoOptions=CognitoOptions(Enabled=False), + UpgradeProcessing=False, + AccessPolicies=stored_status.get("AccessPolicies", ""), + SnapshotOptions=SnapshotOptions(AutomatedSnapshotStartHour=0), + EncryptionAtRestOptions=EncryptionAtRestOptions(Enabled=False), + NodeToNodeEncryptionOptions=NodeToNodeEncryptionOptions(Enabled=False), + AdvancedOptions={ + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true", + **stored_status.get("AdvancedOptions", {}), + }, + ServiceSoftwareOptions=ServiceSoftwareOptions( + CurrentVersion="", + NewVersion="", + UpdateAvailable=False, + Cancellable=False, + UpdateStatus=DeploymentStatus.COMPLETED, + Description="There is no software update available for this domain.", + AutomatedUpdateDate=datetime.fromtimestamp(0, tz=timezone.utc), + OptionalDeployment=True, + ), + DomainEndpointOptions=stored_status.get("DomainEndpointOptions") + or DEFAULT_OPENSEARCH_DOMAIN_ENDPOINT_OPTIONS, + AdvancedSecurityOptions=AdvancedSecurityOptions( + Enabled=False, InternalUserDatabaseEnabled=False + ), + AutoTuneOptions=AutoTuneOptionsOutput(State=AutoTuneState.ENABLE_IN_PROGRESS), + ) + return new_status + + +def _ensure_domain_exists(arn: ARN) -> None: + """ + Checks if the domain for the given ARN exists. Otherwise, a ValidationException is raised. + + :param arn: ARN string to lookup the domain for + :return: None if the domain exists, otherwise raises an exception + :raises: ValidationException if the domain for the given ARN cannot be found + """ + parsed_arn = parse_arn(arn) + store = OpensearchProvider.get_store(parsed_arn["account"], parsed_arn["region"]) + domain_key = DomainKey.from_arn(arn) + domain_status = store.opensearch_domains.get(domain_key.domain_name) + if domain_status is None: + raise ValidationException("Invalid ARN. Domain not found.") + + +def _update_domain_config_request_to_status(request: UpdateDomainConfigRequest) -> DomainStatus: + request: Dict + request.pop("DryRun", None) + request.pop("DomainName", None) + return request + + +_domain_name_pattern = re.compile(r"[a-z][a-z0-9\\-]{3,28}") + + +def is_valid_domain_name(name: str) -> bool: + return True if _domain_name_pattern.match(name) else False + + +def validate_endpoint_options(endpoint_options: DomainEndpointOptions): + custom_endpoint = endpoint_options.get("CustomEndpoint", "") + custom_endpoint_enabled = endpoint_options.get("CustomEndpointEnabled", False) + + if custom_endpoint and not custom_endpoint_enabled: + raise ValidationException( + "CustomEndpointEnabled flag should be set in order to use CustomEndpoint." + ) + if custom_endpoint_enabled and not custom_endpoint: + raise ValidationException( + "Please provide CustomEndpoint field to create a custom endpoint." + ) + + +class OpensearchProvider(OpensearchApi, ServiceLifecycleHook): + @staticmethod + def get_store(account_id: str, region_name: str) -> OpenSearchStore: + return opensearch_stores[account_id][region_name] + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(opensearch_stores) + visitor.visit(AssetDirectory(self.service, os.path.join(config.dirs.data, "opensearch"))) + visitor.visit(AssetDirectory(self.service, os.path.join(config.dirs.data, "elasticsearch"))) + + def on_after_state_load(self): + """Starts clusters whose metadata has been restored.""" + for account_id, region, store in opensearch_stores.iter_stores(): + for domain_name, domain_status in store.opensearch_domains.items(): + domain_key = DomainKey(domain_name, region, account_id) + if cluster_manager().get(domain_key.arn): + # cluster already restored in previous call to on_after_state_load + continue + + LOG.info("Restoring domain %s in region %s.", domain_name, region) + try: + preferred_port = None + if config.OPENSEARCH_ENDPOINT_STRATEGY == "port": + # try to parse the previous port to re-use it for the re-created cluster + if "Endpoint" in domain_status: + preferred_port = urlparse(f"http://{domain_status['Endpoint']}").port + + engine_version = domain_status.get("EngineVersion") + domain_endpoint_options = domain_status.get("DomainEndpointOptions", {}) + security_options = SecurityOptions.from_input( + domain_status.get("AdvancedSecurityOptions") + ) + + create_cluster( + domain_key=domain_key, + engine_version=engine_version, + domain_endpoint_options=domain_endpoint_options, + security_options=security_options, + preferred_port=preferred_port, + ) + except Exception: + LOG.exception( + "Could not restore domain %s in region %s.", + domain_name, + region, + ) + + def on_before_state_reset(self): + self._stop_clusters() + + def on_before_stop(self): + self._stop_clusters() + + def _stop_clusters(self): + for account_id, region, store in opensearch_stores.iter_stores(): + for domain_name in store.opensearch_domains.keys(): + cluster_manager().remove(DomainKey(domain_name, region, account_id).arn) + + @handler("CreateDomain", expand=False) + def create_domain( + self, context: RequestContext, request: CreateDomainRequest + ) -> CreateDomainResponse: + store = self.get_store(context.account_id, context.region) + + if not (domain_name := request.get("DomainName")) or not is_valid_domain_name(domain_name): + # TODO: this should use the server-side validation framework at some point. + raise ValidationException( + "Member must satisfy regular expression pattern: [a-z][a-z0-9\\-]+" + ) + + if domain_endpoint_options := request.get("DomainEndpointOptions", {}): + validate_endpoint_options(domain_endpoint_options) + + with _domain_mutex: + if domain_name in store.opensearch_domains: + raise ResourceAlreadyExistsException( + f"domain {domain_name} already exists in region {context.region}" + ) + domain_key = DomainKey( + domain_name=domain_name, + region=context.region, + account=context.account_id, + ) + security_options = SecurityOptions.from_input(request.get("AdvancedSecurityOptions")) + + # "create" domain data + store.opensearch_domains[domain_name] = get_domain_status(domain_key, request=request) + if domain_endpoint_options: + store.opensearch_domains[domain_name]["DomainEndpointOptions"] = ( + DEFAULT_OPENSEARCH_DOMAIN_ENDPOINT_OPTIONS | domain_endpoint_options + ) + + # lazy-init the cluster (sets the Endpoint and Processing flag of the domain status) + # TODO handle additional parameters (cluster config,...) + create_cluster( + domain_key, request.get("EngineVersion"), domain_endpoint_options, security_options + ) + + # set the tags + self.add_tags(context, domain_key.arn, request.get("TagList")) + + # get the (updated) status + status = get_domain_status(domain_key) + + return CreateDomainResponse(DomainStatus=status) + + def delete_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DeleteDomainResponse: + domain_key = DomainKey( + domain_name=domain_name, + region=context.region, + account=context.account_id, + ) + store = self.get_store(context.account_id, context.region) + with _domain_mutex: + if domain_name not in store.opensearch_domains: + raise ResourceNotFoundException(f"Domain not found: {domain_name}") + + status = get_domain_status(domain_key, deleted=True) + _remove_cluster(domain_key) + + return DeleteDomainResponse(DomainStatus=status) + + def describe_domain( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeDomainResponse: + store = self.get_store(context.account_id, context.region) + domain_key = DomainKey( + domain_name=domain_name, + region=context.region, + account=context.account_id, + ) + with _domain_mutex: + if domain_name not in store.opensearch_domains: + raise ResourceNotFoundException(f"Domain not found: {domain_name}") + + status = get_domain_status(domain_key) + return DescribeDomainResponse(DomainStatus=status) + + @handler("UpdateDomainConfig", expand=False) + def update_domain_config( + self, context: RequestContext, payload: UpdateDomainConfigRequest + ) -> UpdateDomainConfigResponse: + domain_key = DomainKey( + domain_name=payload["DomainName"], + region=context.region, + account=context.account_id, + ) + store = self.get_store(context.account_id, context.region) + with _domain_mutex: + domain_status = store.opensearch_domains.get(domain_key.domain_name, None) + if domain_status is None: + raise ResourceNotFoundException(f"Domain not found: {domain_key.domain_name}") + + status_update: Dict = _update_domain_config_request_to_status(payload) + domain_status.update(status_update) + + return UpdateDomainConfigResponse(DomainConfig=_status_to_config(domain_status)) + + def describe_domains( + self, context: RequestContext, domain_names: DomainNameList, **kwargs + ) -> DescribeDomainsResponse: + status_list = [] + with _domain_mutex: + for domain_name in domain_names: + try: + domain_status = self.describe_domain(context, domain_name)["DomainStatus"] + status_list.append(domain_status) + except ResourceNotFoundException: + # ResourceNotFoundExceptions are ignored, we just look for the next domain. + # If no domain can be found, the result will just be empty. + pass + return DescribeDomainsResponse(DomainStatusList=status_list) + + def list_domain_names( + self, context: RequestContext, engine_type: EngineType = None, **kwargs + ) -> ListDomainNamesResponse: + store = self.get_store(context.account_id, context.region) + domain_names = [ + DomainInfo( + DomainName=DomainName(domain_name), + EngineType=versions.get_engine_type(domain["EngineVersion"]), + ) + for domain_name, domain in store.opensearch_domains.items() + if engine_type is None + or versions.get_engine_type(domain["EngineVersion"]) == engine_type + ] + return ListDomainNamesResponse(DomainNames=domain_names) + + def list_versions( + self, + context: RequestContext, + max_results: MaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> ListVersionsResponse: + version_list = PaginatedList(versions.install_versions.keys()) + page, nxt = version_list.get_page( + lambda x: x, + next_token=next_token, + page_size=max_results, + ) + response = ListVersionsResponse(Versions=page, NextToken=nxt) + return remove_none_values_from_dict(response) + + def get_compatible_versions( + self, context: RequestContext, domain_name: DomainName = None, **kwargs + ) -> GetCompatibleVersionsResponse: + version_filter = None + if domain_name: + store = self.get_store(context.account_id, context.region) + with _domain_mutex: + domain = store.opensearch_domains.get(domain_name) + if not domain: + raise ResourceNotFoundException(f"Domain not found: {domain_name}") + version_filter = domain.get("EngineVersion") + compatible_versions = list(versions.compatible_versions) + if version_filter is not None: + compatible_versions = [ + comp + for comp in versions.compatible_versions + if comp["SourceVersion"] == version_filter + ] + return GetCompatibleVersionsResponse(CompatibleVersions=compatible_versions) + + def describe_domain_config( + self, context: RequestContext, domain_name: DomainName, **kwargs + ) -> DescribeDomainConfigResponse: + domain_key = DomainKey( + domain_name=domain_name, + region=context.region, + account=context.account_id, + ) + store = self.get_store(context.account_id, context.region) + with _domain_mutex: + if domain_name not in store.opensearch_domains: + raise ResourceNotFoundException(f"Domain not found: {domain_name}") + domain_config = get_domain_config(domain_key) + return DescribeDomainConfigResponse(DomainConfig=domain_config) + + def add_tags(self, context: RequestContext, arn: ARN, tag_list: TagList, **kwargs) -> None: + _ensure_domain_exists(arn) + self.get_store(context.account_id, context.region).TAGS.tag_resource(arn, tag_list) + + def list_tags(self, context: RequestContext, arn: ARN, **kwargs) -> ListTagsResponse: + _ensure_domain_exists(arn) + + # The tagging service returns a dictionary with the given root name + store = self.get_store(context.account_id, context.region) + tags = store.TAGS.list_tags_for_resource(arn=arn, root_name="root") + # Extract the actual list of tags for the typed response + tag_list: TagList = tags["root"] + return ListTagsResponse(TagList=tag_list) + + def remove_tags( + self, context: RequestContext, arn: ARN, tag_keys: StringList, **kwargs + ) -> None: + _ensure_domain_exists(arn) + self.get_store(context.account_id, context.region).TAGS.untag_resource(arn, tag_keys) diff --git a/localstack-core/localstack/services/opensearch/resource_providers/__init__.py b/localstack-core/localstack/services/opensearch/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain.py b/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain.py new file mode 100644 index 0000000000000..4de950b722ce9 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain.py @@ -0,0 +1,227 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.aws.api.es import CreateElasticsearchDomainRequest +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.collections import convert_to_typed_dict + + +class ElasticsearchDomainProperties(TypedDict): + AccessPolicies: Optional[dict] + AdvancedOptions: Optional[dict] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsInput] + Arn: Optional[str] + CognitoOptions: Optional[CognitoOptions] + DomainArn: Optional[str] + DomainEndpoint: Optional[str] + DomainEndpointOptions: Optional[DomainEndpointOptions] + DomainName: Optional[str] + EBSOptions: Optional[EBSOptions] + ElasticsearchClusterConfig: Optional[ElasticsearchClusterConfig] + ElasticsearchVersion: Optional[str] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + Id: Optional[str] + LogPublishingOptions: Optional[dict] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + SnapshotOptions: Optional[SnapshotOptions] + Tags: Optional[list[Tag]] + VPCOptions: Optional[VPCOptions] + + +class ZoneAwarenessConfig(TypedDict): + AvailabilityZoneCount: Optional[int] + + +class ColdStorageOptions(TypedDict): + Enabled: Optional[bool] + + +class ElasticsearchClusterConfig(TypedDict): + ColdStorageOptions: Optional[ColdStorageOptions] + DedicatedMasterCount: Optional[int] + DedicatedMasterEnabled: Optional[bool] + DedicatedMasterType: Optional[str] + InstanceCount: Optional[int] + InstanceType: Optional[str] + WarmCount: Optional[int] + WarmEnabled: Optional[bool] + WarmType: Optional[str] + ZoneAwarenessConfig: Optional[ZoneAwarenessConfig] + ZoneAwarenessEnabled: Optional[bool] + + +class SnapshotOptions(TypedDict): + AutomatedSnapshotStartHour: Optional[int] + + +class VPCOptions(TypedDict): + SecurityGroupIds: Optional[list[str]] + SubnetIds: Optional[list[str]] + + +class NodeToNodeEncryptionOptions(TypedDict): + Enabled: Optional[bool] + + +class DomainEndpointOptions(TypedDict): + CustomEndpoint: Optional[str] + CustomEndpointCertificateArn: Optional[str] + CustomEndpointEnabled: Optional[bool] + EnforceHTTPS: Optional[bool] + TLSSecurityPolicy: Optional[str] + + +class CognitoOptions(TypedDict): + Enabled: Optional[bool] + IdentityPoolId: Optional[str] + RoleArn: Optional[str] + UserPoolId: Optional[str] + + +class MasterUserOptions(TypedDict): + MasterUserARN: Optional[str] + MasterUserName: Optional[str] + MasterUserPassword: Optional[str] + + +class AdvancedSecurityOptionsInput(TypedDict): + AnonymousAuthEnabled: Optional[bool] + Enabled: Optional[bool] + InternalUserDatabaseEnabled: Optional[bool] + MasterUserOptions: Optional[MasterUserOptions] + + +class EBSOptions(TypedDict): + EBSEnabled: Optional[bool] + Iops: Optional[int] + VolumeSize: Optional[int] + VolumeType: Optional[str] + + +class EncryptionAtRestOptions(TypedDict): + Enabled: Optional[bool] + KmsKeyId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ElasticsearchDomainProvider(ResourceProvider[ElasticsearchDomainProperties]): + TYPE = "AWS::Elasticsearch::Domain" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ElasticsearchDomainProperties], + ) -> ProgressEvent[ElasticsearchDomainProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + + + Create-only properties: + - /properties/DomainName + + Read-only properties: + - /properties/Id + - /properties/DomainArn + - /properties/DomainEndpoint + - /properties/Arn + + + + """ + model = request.desired_state + + client = request.aws_client_factory.es + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + request.custom_context[REPEATED_INVOCATION] = True + + # defaults + domain_name = model.get("DomainName") + if not domain_name: + model["DomainName"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + params = copy.deepcopy(model) + params = convert_to_typed_dict(CreateElasticsearchDomainRequest, params) + params = util.remove_none_values(params) + cluster_config = params.get("ElasticsearchClusterConfig") + if isinstance(cluster_config, dict): + # set defaults required for boto3 calls + cluster_config.setdefault("DedicatedMasterType", "m3.medium.elasticsearch") + cluster_config.setdefault("WarmType", "ultrawarm1.medium.elasticsearch") + + client.create_elasticsearch_domain(**params) + + domain = client.describe_elasticsearch_domain(DomainName=model["DomainName"]) + if domain["DomainStatus"]["Created"]: + # set data + model["Arn"] = domain["DomainStatus"]["ARN"] + model["Id"] = model["DomainName"] + model["DomainArn"] = domain["DomainStatus"]["ARN"] + model["DomainEndpoint"] = domain["DomainStatus"].get("Endpoint") + + if tags := model.get("Tags", []): + client.add_tags(ARN=model["Arn"], TagList=tags) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + else: + return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + + def read( + self, + request: ResourceRequest[ElasticsearchDomainProperties], + ) -> ProgressEvent[ElasticsearchDomainProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ElasticsearchDomainProperties], + ) -> ProgressEvent[ElasticsearchDomainProperties]: + """ + Delete a resource + + + """ + client = request.aws_client_factory.es + # TODO the delete is currently synchronous; + # if this changes, we should also reflect the OperationStatus here + client.delete_elasticsearch_domain(DomainName=request.previous_state["DomainName"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[ElasticsearchDomainProperties], + ) -> ProgressEvent[ElasticsearchDomainProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain.schema.json b/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain.schema.json new file mode 100644 index 0000000000000..691137e956431 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain.schema.json @@ -0,0 +1,317 @@ +{ + "typeName": "AWS::Elasticsearch::Domain", + "description": "Resource Type definition for AWS::Elasticsearch::Domain", + "additionalProperties": false, + "properties": { + "ElasticsearchClusterConfig": { + "$ref": "#/definitions/ElasticsearchClusterConfig" + }, + "DomainName": { + "type": "string" + }, + "ElasticsearchVersion": { + "type": "string" + }, + "LogPublishingOptions": { + "type": "object", + "patternProperties": { + "[a-zA-Z0-9]+": { + "$ref": "#/definitions/LogPublishingOption" + } + } + }, + "SnapshotOptions": { + "$ref": "#/definitions/SnapshotOptions" + }, + "VPCOptions": { + "$ref": "#/definitions/VPCOptions" + }, + "NodeToNodeEncryptionOptions": { + "$ref": "#/definitions/NodeToNodeEncryptionOptions" + }, + "AccessPolicies": { + "type": "object" + }, + "DomainEndpointOptions": { + "$ref": "#/definitions/DomainEndpointOptions" + }, + "DomainArn": { + "type": "string" + }, + "CognitoOptions": { + "$ref": "#/definitions/CognitoOptions" + }, + "AdvancedOptions": { + "type": "object", + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "AdvancedSecurityOptions": { + "$ref": "#/definitions/AdvancedSecurityOptionsInput" + }, + "DomainEndpoint": { + "type": "string" + }, + "EBSOptions": { + "$ref": "#/definitions/EBSOptions" + }, + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "EncryptionAtRestOptions": { + "$ref": "#/definitions/EncryptionAtRestOptions" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "LogPublishingOption": { + "type": "object", + "additionalProperties": false, + "properties": { + "CloudWatchLogsLogGroupArn": { + "type": "string" + }, + "Enabled": { + "type": "boolean" + } + } + }, + "ElasticsearchClusterConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "InstanceCount": { + "type": "integer" + }, + "WarmEnabled": { + "type": "boolean" + }, + "WarmCount": { + "type": "integer" + }, + "DedicatedMasterEnabled": { + "type": "boolean" + }, + "ZoneAwarenessConfig": { + "$ref": "#/definitions/ZoneAwarenessConfig" + }, + "ColdStorageOptions": { + "$ref": "#/definitions/ColdStorageOptions" + }, + "DedicatedMasterCount": { + "type": "integer" + }, + "InstanceType": { + "type": "string" + }, + "WarmType": { + "type": "string" + }, + "ZoneAwarenessEnabled": { + "type": "boolean" + }, + "DedicatedMasterType": { + "type": "string" + } + } + }, + "VPCOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "SecurityGroupIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "SubnetIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "SnapshotOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "AutomatedSnapshotStartHour": { + "type": "integer" + } + } + }, + "ZoneAwarenessConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "AvailabilityZoneCount": { + "type": "integer" + } + } + }, + "NodeToNodeEncryptionOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + } + } + }, + "ColdStorageOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + } + } + }, + "DomainEndpointOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "CustomEndpointCertificateArn": { + "type": "string" + }, + "CustomEndpointEnabled": { + "type": "boolean" + }, + "EnforceHTTPS": { + "type": "boolean" + }, + "CustomEndpoint": { + "type": "string" + }, + "TLSSecurityPolicy": { + "type": "string" + } + } + }, + "CognitoOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "IdentityPoolId": { + "type": "string" + }, + "UserPoolId": { + "type": "string" + }, + "RoleArn": { + "type": "string" + } + } + }, + "EBSOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "EBSEnabled": { + "type": "boolean" + }, + "VolumeType": { + "type": "string" + }, + "Iops": { + "type": "integer" + }, + "VolumeSize": { + "type": "integer" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "EncryptionAtRestOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "KmsKeyId": { + "type": "string" + }, + "Enabled": { + "type": "boolean" + } + } + }, + "MasterUserOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "MasterUserPassword": { + "type": "string" + }, + "MasterUserName": { + "type": "string" + }, + "MasterUserARN": { + "type": "string" + } + } + }, + "AdvancedSecurityOptionsInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "MasterUserOptions": { + "$ref": "#/definitions/MasterUserOptions" + }, + "AnonymousAuthEnabled": { + "type": "boolean" + }, + "InternalUserDatabaseEnabled": { + "type": "boolean" + } + } + } + }, + "createOnlyProperties": [ + "/properties/DomainName" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/DomainArn", + "/properties/DomainEndpoint", + "/properties/Arn" + ] +} diff --git a/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain_plugin.py b/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain_plugin.py new file mode 100644 index 0000000000000..c5f22fa0b816e --- /dev/null +++ b/localstack-core/localstack/services/opensearch/resource_providers/aws_elasticsearch_domain_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ElasticsearchDomainProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Elasticsearch::Domain" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.opensearch.resource_providers.aws_elasticsearch_domain import ( + ElasticsearchDomainProvider, + ) + + self.factory = ElasticsearchDomainProvider diff --git a/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain.py b/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain.py new file mode 100644 index 0000000000000..96b8c60ec0b2b --- /dev/null +++ b/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain.py @@ -0,0 +1,312 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class OpenSearchServiceDomainProperties(TypedDict): + AccessPolicies: Optional[dict] + AdvancedOptions: Optional[dict] + AdvancedSecurityOptions: Optional[AdvancedSecurityOptionsInput] + Arn: Optional[str] + ClusterConfig: Optional[ClusterConfig] + CognitoOptions: Optional[CognitoOptions] + DomainArn: Optional[str] + DomainEndpoint: Optional[str] + DomainEndpointOptions: Optional[DomainEndpointOptions] + DomainEndpoints: Optional[dict] + DomainName: Optional[str] + EBSOptions: Optional[EBSOptions] + EncryptionAtRestOptions: Optional[EncryptionAtRestOptions] + EngineVersion: Optional[str] + Id: Optional[str] + LogPublishingOptions: Optional[dict] + NodeToNodeEncryptionOptions: Optional[NodeToNodeEncryptionOptions] + OffPeakWindowOptions: Optional[OffPeakWindowOptions] + ServiceSoftwareOptions: Optional[ServiceSoftwareOptions] + SnapshotOptions: Optional[SnapshotOptions] + SoftwareUpdateOptions: Optional[SoftwareUpdateOptions] + Tags: Optional[list[Tag]] + VPCOptions: Optional[VPCOptions] + + +class ZoneAwarenessConfig(TypedDict): + AvailabilityZoneCount: Optional[int] + + +class ClusterConfig(TypedDict): + DedicatedMasterCount: Optional[int] + DedicatedMasterEnabled: Optional[bool] + DedicatedMasterType: Optional[str] + InstanceCount: Optional[int] + InstanceType: Optional[str] + WarmCount: Optional[int] + WarmEnabled: Optional[bool] + WarmType: Optional[str] + ZoneAwarenessConfig: Optional[ZoneAwarenessConfig] + ZoneAwarenessEnabled: Optional[bool] + + +class SnapshotOptions(TypedDict): + AutomatedSnapshotStartHour: Optional[int] + + +class VPCOptions(TypedDict): + SecurityGroupIds: Optional[list[str]] + SubnetIds: Optional[list[str]] + + +class NodeToNodeEncryptionOptions(TypedDict): + Enabled: Optional[bool] + + +class DomainEndpointOptions(TypedDict): + CustomEndpoint: Optional[str] + CustomEndpointCertificateArn: Optional[str] + CustomEndpointEnabled: Optional[bool] + EnforceHTTPS: Optional[bool] + TLSSecurityPolicy: Optional[str] + + +class CognitoOptions(TypedDict): + Enabled: Optional[bool] + IdentityPoolId: Optional[str] + RoleArn: Optional[str] + UserPoolId: Optional[str] + + +class MasterUserOptions(TypedDict): + MasterUserARN: Optional[str] + MasterUserName: Optional[str] + MasterUserPassword: Optional[str] + + +class Idp(TypedDict): + EntityId: Optional[str] + MetadataContent: Optional[str] + + +class SAMLOptions(TypedDict): + Enabled: Optional[bool] + Idp: Optional[Idp] + MasterBackendRole: Optional[str] + MasterUserName: Optional[str] + RolesKey: Optional[str] + SessionTimeoutMinutes: Optional[int] + SubjectKey: Optional[str] + + +class AdvancedSecurityOptionsInput(TypedDict): + AnonymousAuthDisableDate: Optional[str] + AnonymousAuthEnabled: Optional[bool] + Enabled: Optional[bool] + InternalUserDatabaseEnabled: Optional[bool] + MasterUserOptions: Optional[MasterUserOptions] + SAMLOptions: Optional[SAMLOptions] + + +class EBSOptions(TypedDict): + EBSEnabled: Optional[bool] + Iops: Optional[int] + Throughput: Optional[int] + VolumeSize: Optional[int] + VolumeType: Optional[str] + + +class EncryptionAtRestOptions(TypedDict): + Enabled: Optional[bool] + KmsKeyId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class ServiceSoftwareOptions(TypedDict): + AutomatedUpdateDate: Optional[str] + Cancellable: Optional[bool] + CurrentVersion: Optional[str] + Description: Optional[str] + NewVersion: Optional[str] + OptionalDeployment: Optional[bool] + UpdateAvailable: Optional[bool] + UpdateStatus: Optional[str] + + +class WindowStartTime(TypedDict): + Hours: Optional[int] + Minutes: Optional[int] + + +class OffPeakWindow(TypedDict): + WindowStartTime: Optional[WindowStartTime] + + +class OffPeakWindowOptions(TypedDict): + Enabled: Optional[bool] + OffPeakWindow: Optional[OffPeakWindow] + + +class SoftwareUpdateOptions(TypedDict): + AutoSoftwareUpdateEnabled: Optional[bool] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class OpenSearchServiceDomainProvider(ResourceProvider[OpenSearchServiceDomainProperties]): + TYPE = "AWS::OpenSearchService::Domain" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[OpenSearchServiceDomainProperties], + ) -> ProgressEvent[OpenSearchServiceDomainProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/DomainName + + + + Create-only properties: + - /properties/DomainName + + Read-only properties: + - /properties/Id + - /properties/Arn + - /properties/DomainArn + - /properties/DomainEndpoint + - /properties/DomainEndpoints + - /properties/ServiceSoftwareOptions + - /properties/AdvancedSecurityOptions/AnonymousAuthDisableDate + + IAM permissions required: + - es:CreateDomain + - es:DescribeDomain + - es:AddTags + - es:ListTags + + """ + model = request.desired_state + opensearch_client = request.aws_client_factory.opensearch + if not request.custom_context.get(REPEATED_INVOCATION): + # resource is not ready + # this is the first time this callback is invoked + request.custom_context[REPEATED_INVOCATION] = True + + # defaults + domain_name = model.get("DomainName") + if not domain_name: + domain_name = util.generate_default_name( + request.stack_name, request.logical_resource_id + ).lower()[0:28] + model["DomainName"] = domain_name + + properties = util.remove_none_values(model) + cluster_config = properties.get("ClusterConfig") + if isinstance(cluster_config, dict): + # set defaults required for boto3 calls + cluster_config.setdefault("DedicatedMasterType", "m3.medium.search") + cluster_config.setdefault("WarmType", "ultrawarm1.medium.search") + + for key in ["DedicatedMasterCount", "InstanceCount", "WarmCount"]: + if key in cluster_config and isinstance(cluster_config[key], str): + cluster_config[key] = int(cluster_config[key]) + + if properties.get("AccessPolicies"): + properties["AccessPolicies"] = json.dumps(properties["AccessPolicies"]) + + if ebs_options := properties.get("EBSOptions"): + for key in ["Iops", "Throughput", "VolumeSize"]: + if key in ebs_options and isinstance(ebs_options[key], str): + ebs_options[key] = int(ebs_options[key]) + + create_kwargs = {**util.deselect_attributes(properties, ["Tags"])} + if tags := properties.get("Tags"): + create_kwargs["TagList"] = tags + opensearch_client.create_domain(**create_kwargs) + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + opensearch_domain = opensearch_client.describe_domain(DomainName=model["DomainName"]) + if opensearch_domain["DomainStatus"]["Processing"] is False: + # set data + model["Arn"] = opensearch_domain["DomainStatus"]["ARN"] + model["Id"] = opensearch_domain["DomainStatus"]["DomainId"] + model["DomainArn"] = opensearch_domain["DomainStatus"]["ARN"] + model["DomainEndpoint"] = opensearch_domain["DomainStatus"].get("Endpoint") + model["DomainEndpoints"] = opensearch_domain["DomainStatus"].get("Endpoints") + model["ServiceSoftwareOptions"] = opensearch_domain["DomainStatus"].get( + "ServiceSoftwareOptions" + ) + model.setdefault("AdvancedSecurityOptions", {})["AnonymousAuthDisableDate"] = ( + opensearch_domain["DomainStatus"] + .get("AdvancedSecurityOptions", {}) + .get("AnonymousAuthDisableDate") + ) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + else: + return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + + def read( + self, + request: ResourceRequest[OpenSearchServiceDomainProperties], + ) -> ProgressEvent[OpenSearchServiceDomainProperties]: + """ + Fetch resource information + + IAM permissions required: + - es:DescribeDomain + - es:ListTags + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[OpenSearchServiceDomainProperties], + ) -> ProgressEvent[OpenSearchServiceDomainProperties]: + """ + Delete a resource + + IAM permissions required: + - es:DeleteDomain + - es:DescribeDomain + """ + opensearch_client = request.aws_client_factory.opensearch + # TODO the delete is currently synchronous; + # if this changes, we should also reflect the OperationStatus here + opensearch_client.delete_domain(DomainName=request.previous_state["DomainName"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[OpenSearchServiceDomainProperties], + ) -> ProgressEvent[OpenSearchServiceDomainProperties]: + """ + Update a resource + + IAM permissions required: + - es:UpdateDomain + - es:UpgradeDomain + - es:DescribeDomain + - es:AddTags + - es:RemoveTags + - es:ListTags + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain.schema.json b/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain.schema.json new file mode 100644 index 0000000000000..1e75c1642c1ae --- /dev/null +++ b/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain.schema.json @@ -0,0 +1,511 @@ +{ + "typeName": "AWS::OpenSearchService::Domain", + "description": "An example resource schema demonstrating some basic constructs and validation rules.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "ZoneAwarenessConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "AvailabilityZoneCount": { + "type": "integer" + } + } + }, + "ClusterConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "InstanceCount": { + "type": "integer" + }, + "WarmEnabled": { + "type": "boolean" + }, + "WarmCount": { + "type": "integer" + }, + "DedicatedMasterEnabled": { + "type": "boolean" + }, + "ZoneAwarenessConfig": { + "$ref": "#/definitions/ZoneAwarenessConfig" + }, + "DedicatedMasterCount": { + "type": "integer" + }, + "InstanceType": { + "type": "string" + }, + "WarmType": { + "type": "string" + }, + "ZoneAwarenessEnabled": { + "type": "boolean" + }, + "DedicatedMasterType": { + "type": "string" + } + } + }, + "LogPublishingOption": { + "type": "object", + "additionalProperties": false, + "properties": { + "CloudWatchLogsLogGroupArn": { + "type": "string" + }, + "Enabled": { + "type": "boolean" + } + } + }, + "SnapshotOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "AutomatedSnapshotStartHour": { + "type": "integer" + } + } + }, + "VPCOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "SecurityGroupIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "SubnetIds": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + } + }, + "NodeToNodeEncryptionOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + } + } + }, + "DomainEndpointOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "CustomEndpointCertificateArn": { + "type": "string" + }, + "CustomEndpointEnabled": { + "type": "boolean" + }, + "EnforceHTTPS": { + "type": "boolean" + }, + "CustomEndpoint": { + "type": "string" + }, + "TLSSecurityPolicy": { + "type": "string" + } + } + }, + "CognitoOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "IdentityPoolId": { + "type": "string" + }, + "UserPoolId": { + "type": "string" + }, + "RoleArn": { + "type": "string" + } + } + }, + "MasterUserOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "MasterUserPassword": { + "type": "string" + }, + "MasterUserName": { + "type": "string" + }, + "MasterUserARN": { + "type": "string" + } + } + }, + "Idp": { + "type": "object", + "additionalProperties": false, + "properties": { + "MetadataContent": { + "type": "string", + "maxLength": 20480, + "minLength": 1 + }, + "EntityId": { + "type": "string" + } + }, + "required": [ + "MetadataContent", + "EntityId" + ] + }, + "SAMLOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "Idp": { + "$ref": "#/definitions/Idp" + }, + "MasterUserName": { + "type": "string" + }, + "MasterBackendRole": { + "type": "string" + }, + "SubjectKey": { + "type": "string" + }, + "RolesKey": { + "type": "string" + }, + "SessionTimeoutMinutes": { + "type": "integer" + } + } + }, + "AdvancedSecurityOptionsInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "MasterUserOptions": { + "$ref": "#/definitions/MasterUserOptions" + }, + "InternalUserDatabaseEnabled": { + "type": "boolean" + }, + "AnonymousAuthEnabled": { + "type": "boolean" + }, + "SAMLOptions": { + "$ref": "#/definitions/SAMLOptions" + }, + "AnonymousAuthDisableDate": { + "type": "string" + } + } + }, + "EBSOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "EBSEnabled": { + "type": "boolean" + }, + "VolumeType": { + "type": "string" + }, + "Iops": { + "type": "integer" + }, + "VolumeSize": { + "type": "integer" + }, + "Throughput": { + "type": "integer" + } + } + }, + "EncryptionAtRestOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "KmsKeyId": { + "type": "string" + }, + "Enabled": { + "type": "boolean" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "description": "The key of the tag.", + "type": "string", + "minLength": 0, + "maxLength": 256 + }, + "Key": { + "description": "The value of the tag.", + "type": "string", + "minLength": 0, + "maxLength": 128 + } + }, + "required": [ + "Value", + "Key" + ] + }, + "ServiceSoftwareOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "CurrentVersion": { + "type": "string" + }, + "NewVersion": { + "type": "string" + }, + "UpdateAvailable": { + "type": "boolean" + }, + "Cancellable": { + "type": "boolean" + }, + "UpdateStatus": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "AutomatedUpdateDate": { + "type": "string" + }, + "OptionalDeployment": { + "type": "boolean" + } + } + }, + "WindowStartTime": { + "type": "object", + "additionalProperties": false, + "properties": { + "Hours": { + "type": "integer", + "minimum": 0, + "maximum": 23 + }, + "Minutes": { + "type": "integer", + "minimum": 0, + "maximum": 59 + } + }, + "required": [ + "Hours", + "Minutes" + ] + }, + "OffPeakWindow": { + "type": "object", + "additionalProperties": false, + "properties": { + "WindowStartTime": { + "$ref": "#/definitions/WindowStartTime" + } + } + }, + "OffPeakWindowOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + }, + "OffPeakWindow": { + "$ref": "#/definitions/OffPeakWindow" + } + } + }, + "SoftwareUpdateOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "AutoSoftwareUpdateEnabled": { + "type": "boolean" + } + } + } + }, + "properties": { + "ClusterConfig": { + "$ref": "#/definitions/ClusterConfig" + }, + "DomainName": { + "type": "string" + }, + "AccessPolicies": { + "type": "object" + }, + "EngineVersion": { + "type": "string" + }, + "AdvancedOptions": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "type": "string" + } + } + }, + "LogPublishingOptions": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "[a-zA-Z0-9]+": { + "$ref": "#/definitions/LogPublishingOption" + } + } + }, + "SnapshotOptions": { + "$ref": "#/definitions/SnapshotOptions" + }, + "VPCOptions": { + "$ref": "#/definitions/VPCOptions" + }, + "NodeToNodeEncryptionOptions": { + "$ref": "#/definitions/NodeToNodeEncryptionOptions" + }, + "DomainEndpointOptions": { + "$ref": "#/definitions/DomainEndpointOptions" + }, + "CognitoOptions": { + "$ref": "#/definitions/CognitoOptions" + }, + "AdvancedSecurityOptions": { + "$ref": "#/definitions/AdvancedSecurityOptionsInput" + }, + "DomainEndpoint": { + "type": "string" + }, + "DomainEndpoints": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.*$": { + "type": "string" + } + } + }, + "EBSOptions": { + "$ref": "#/definitions/EBSOptions" + }, + "Id": { + "type": "string" + }, + "Arn": { + "type": "string" + }, + "DomainArn": { + "type": "string" + }, + "EncryptionAtRestOptions": { + "$ref": "#/definitions/EncryptionAtRestOptions" + }, + "Tags": { + "description": "An arbitrary set of tags (key-value pairs) for this Domain.", + "items": { + "$ref": "#/definitions/Tag" + }, + "type": "array", + "uniqueItems": true + }, + "ServiceSoftwareOptions": { + "$ref": "#/definitions/ServiceSoftwareOptions" + }, + "OffPeakWindowOptions": { + "$ref": "#/definitions/OffPeakWindowOptions" + }, + "SoftwareUpdateOptions": { + "$ref": "#/definitions/SoftwareUpdateOptions" + } + }, + "additionalProperties": false, + "createOnlyProperties": [ + "/properties/DomainName" + ], + "conditionalCreateOnlyProperties": [ + "/properties/EncryptionAtRestOptions/properties", + "/properties/AdvancedSecurityOptions/properties/Enabled" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/Arn", + "/properties/DomainArn", + "/properties/DomainEndpoint", + "/properties/DomainEndpoints", + "/properties/ServiceSoftwareOptions", + "/properties/AdvancedSecurityOptions/AnonymousAuthDisableDate" + ], + "writeOnlyProperties": [ + "/properties/AdvancedSecurityOptions/MasterUserOptions", + "/properties/AdvancedSecurityOptions/SAMLOptions/MasterUserName", + "/properties/AdvancedSecurityOptions/SAMLOptions/MasterBackendRole" + ], + "primaryIdentifier": [ + "/properties/DomainName" + ], + "handlers": { + "create": { + "permissions": [ + "es:CreateDomain", + "es:DescribeDomain", + "es:AddTags", + "es:ListTags" + ] + }, + "read": { + "permissions": [ + "es:DescribeDomain", + "es:ListTags" + ] + }, + "update": { + "permissions": [ + "es:UpdateDomain", + "es:UpgradeDomain", + "es:DescribeDomain", + "es:AddTags", + "es:RemoveTags", + "es:ListTags" + ] + }, + "delete": { + "permissions": [ + "es:DeleteDomain", + "es:DescribeDomain" + ] + } + } +} diff --git a/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain_plugin.py b/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain_plugin.py new file mode 100644 index 0000000000000..029076b1aefa8 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/resource_providers/aws_opensearchservice_domain_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class OpenSearchServiceDomainProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::OpenSearchService::Domain" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.opensearch.resource_providers.aws_opensearchservice_domain import ( + OpenSearchServiceDomainProvider, + ) + + self.factory = OpenSearchServiceDomainProvider diff --git a/localstack-core/localstack/services/opensearch/versions.py b/localstack-core/localstack/services/opensearch/versions.py new file mode 100644 index 0000000000000..205b9b33d5202 --- /dev/null +++ b/localstack-core/localstack/services/opensearch/versions.py @@ -0,0 +1,360 @@ +""" +Functions for querying opensearch versions and getting download URLs. This script is also runnable to generate +the latest install_versions from the github repository tags. Run:: + + python -m localstack.services.opensearch.versions + +""" + +from typing import Dict + +import semver + +from localstack.aws.api.opensearch import CompatibleVersionsMap, EngineType +from localstack.utils.common import get_arch + +# Internal representation of the OpenSearch versions (without the "OpenSearch_" prefix) +_opensearch_install_versions = { + "2.13": "2.13.0", + "2.11": "2.11.1", + "2.9": "2.9.0", + "2.7": "2.7.0", + "2.5": "2.5.0", + "2.3": "2.3.0", + "1.3": "1.3.12", + "1.2": "1.2.4", + "1.1": "1.1.0", + "1.0": "1.0.0", +} +# Internal representation of the Elasticsearch versions (without the "Elasticsearch_" prefix) +_elasticsearch_install_versions = { + "7.10": "7.10.0", + "7.9": "7.9.3", + "7.8": "7.8.1", + "7.7": "7.7.1", + "7.4": "7.4.2", + "7.1": "7.1.1", + "6.8": "6.8.20", + "6.7": "6.7.2", + "6.5": "6.5.4", + "6.4": "6.4.3", + "6.3": "6.3.2", + "6.2": "6.2.4", + "6.0": "6.0.1", + "5.6": "5.6.16", + "5.5": "5.5.3", + "5.3": "5.3.3", + "5.1": "5.1.2", +} +# prefixed versions +_prefixed_opensearch_install_versions = { + f"OpenSearch_{key}": value for key, value in _opensearch_install_versions.items() +} +_prefixed_elasticsearch_install_versions = { + f"Elasticsearch_{key}": value for key, value in _elasticsearch_install_versions.items() +} +install_versions = { + **_prefixed_opensearch_install_versions, + **_prefixed_elasticsearch_install_versions, +} + +# List of compatible versions (using the external representations) +compatible_versions = [ + CompatibleVersionsMap( + SourceVersion="Elasticsearch_5.1", + TargetVersions=["Elasticsearch_5.6"], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_5.3", + TargetVersions=["Elasticsearch_5.6"], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_5.5", + TargetVersions=["Elasticsearch_5.6"], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_5.6", + TargetVersions=[ + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_6.0", + TargetVersions=[ + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_6.2", + TargetVersions=[ + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_6.3", + TargetVersions=[ + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_6.4", + TargetVersions=["Elasticsearch_6.5", "Elasticsearch_6.7", "Elasticsearch_6.8"], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_6.5", + TargetVersions=["Elasticsearch_6.7", "Elasticsearch_6.8"], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_6.7", + TargetVersions=["Elasticsearch_6.8"], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_6.8", + TargetVersions=[ + "Elasticsearch_7.1", + "Elasticsearch_7.4", + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_7.1", + TargetVersions=[ + "Elasticsearch_7.4", + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_7.4", + TargetVersions=[ + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_7.7", + TargetVersions=[ + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_7.8", + TargetVersions=[ + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_7.9", + TargetVersions=[ + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + ), + CompatibleVersionsMap( + SourceVersion="Elasticsearch_7.10", + TargetVersions=["OpenSearch_1.0", "OpenSearch_1.1", "OpenSearch_1.2", "OpenSearch_1.3"], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_1.0", + TargetVersions=["OpenSearch_1.1", "OpenSearch_1.2", "OpenSearch_1.3"], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_1.1", + TargetVersions=["OpenSearch_1.2", "OpenSearch_1.3"], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_1.2", + TargetVersions=["OpenSearch_1.3"], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_1.3", + TargetVersions=[ + "OpenSearch_2.3", + "OpenSearch_2.5", + "OpenSearch_2.7", + "OpenSearch_2.9", + "OpenSearch_2.11", + "OpenSearch_2.13", + ], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_2.3", + TargetVersions=[ + "OpenSearch_2.5", + "OpenSearch_2.7", + "OpenSearch_2.9", + "OpenSearch_2.11", + "OpenSearch_2.13", + ], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_2.5", + TargetVersions=["OpenSearch_2.7", "OpenSearch_2.9", "OpenSearch_2.11", "OpenSearch_2.13"], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_2.7", + TargetVersions=["OpenSearch_2.9", "OpenSearch_2.11", "OpenSearch_2.13"], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_2.9", + TargetVersions=["OpenSearch_2.11", "OpenSearch_2.13"], + ), + CompatibleVersionsMap( + SourceVersion="OpenSearch_2.11", + TargetVersions=["OpenSearch_2.13"], + ), +] + + +def get_install_type_and_version(version: str) -> (EngineType, str): + engine_type = EngineType(version.split("_")[0]) + + if version not in install_versions: + raise ValueError(f"unknown version {version}") + + return engine_type, install_versions[version] + + +def get_engine_type(version: str) -> EngineType: + return EngineType(version.split("_")[0]) + + +def get_install_version(version: str) -> str: + if version not in install_versions: + raise ValueError(f"unknown version {version}") + + return install_versions[version] + + +def _opensearch_url(install_version: semver.VersionInfo) -> str: + arch = "x64" if get_arch() == "amd64" else "arm64" + version = str(install_version) + return ( + f"https://artifacts.opensearch.org/releases/bundle/opensearch/" + f"{version}/opensearch-{version}-linux-{arch}.tar.gz" + ) + + +def _es_url(install_version: semver.VersionInfo) -> str: + arch = "x86_64" if get_arch() == "amd64" else "aarch64" + version = str(install_version) + repo = "https://artifacts.elastic.co/downloads/elasticsearch" + if install_version.major <= 6: + return f"{repo}/elasticsearch-{version}.tar.gz" + + return f"{repo}/elasticsearch-{version}-linux-{arch}.tar.gz" + + +def get_download_url(install_version: str, engine_type: EngineType) -> str: + install_version = semver.VersionInfo.parse(install_version) + if engine_type == EngineType.OpenSearch: + return _opensearch_url(install_version) + elif engine_type == EngineType.Elasticsearch: + return _es_url(install_version) + + +def fetch_latest_versions() -> Dict[str, str]: # pragma: no cover + """ + Fetches from the opensearch git repository tags the latest patch versions for a minor version and returns a + dictionary where the key corresponds to the minor version, and the value to the patch version. Run this once in a + while and update the ``install_versions`` constant in this file. + + Example:: + + { + '1.0': '1.0.0', + '1.1': '1.1.0', + '1.2': '1.2.2' + } + + When updating the versions, make sure to not add versions which are currently not yet supported by AWS. + + :returns: a version dictionary + """ + from collections import defaultdict + + import requests + + versions = [] + + i = 0 + while True: + tags_raw = requests.get( + f"https://api.github.com/repos/opensearch-project/OpenSearch/tags?per_page=100&page={i}" + ) + tags = tags_raw.json() + i += 1 + if not tags: + break + versions.extend([tag["name"].lstrip("v") for tag in tags]) + + sem_versions = [] + + for v in versions: + try: + sem_version = semver.VersionInfo.parse(v) + if not sem_version.prerelease: + sem_versions.append(sem_version) + except ValueError: + pass + + minor = defaultdict(list) + + for ver in sem_versions: + minor[f"{ver.major}.{ver.minor}"].append(ver) + + return {k: str(max(versions)) for k, versions in minor.items()} + + +if __name__ == "__main__": # pragma: no cover + from pprint import pprint + + pprint(fetch_latest_versions(), sort_dicts=False) diff --git a/localstack-core/localstack/services/plugins.py b/localstack-core/localstack/services/plugins.py new file mode 100644 index 0000000000000..fbd75a53f0ca7 --- /dev/null +++ b/localstack-core/localstack/services/plugins.py @@ -0,0 +1,710 @@ +import abc +import functools +import logging +import threading +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from enum import Enum +from typing import Callable, Dict, List, Optional, Protocol, Tuple + +from plux import Plugin, PluginLifecycleListener, PluginManager, PluginSpec + +from localstack import config +from localstack.aws.skeleton import DispatchTable, Skeleton +from localstack.aws.spec import load_service +from localstack.config import ServiceProviderConfig +from localstack.runtime import hooks +from localstack.state import StateLifecycleHook, StateVisitable, StateVisitor +from localstack.utils.bootstrap import get_enabled_apis, is_api_enabled, log_duration +from localstack.utils.functions import call_safe +from localstack.utils.sync import SynchronizedDefaultDict, poll_condition + +# set up logger +LOG = logging.getLogger(__name__) + +# namespace for AWS provider plugins +PLUGIN_NAMESPACE = "localstack.aws.provider" + +_default = object() # sentinel object indicating a default value + + +# ----------------- +# PLUGIN UTILITIES +# ----------------- + + +class ServiceException(Exception): + pass + + +class ServiceDisabled(ServiceException): + pass + + +class ServiceStateException(ServiceException): + pass + + +class ServiceLifecycleHook(StateLifecycleHook): + def on_after_init(self): + pass + + def on_before_start(self): + pass + + def on_before_stop(self): + pass + + def on_exception(self): + pass + + +class ServiceProvider(Protocol): + service: str + + +class Service: + """ + FIXME: this has become frankenstein's monster, and it has to go. once we've rid ourselves of the legacy edge + proxy, we can get rid of the ``listener`` concept. we should then do one iteration over all the + ``start_dynamodb``, ``start_``, ``check_``, etc. methods, to make all of those integral part + of the service provider. the assumption that every service provider starts a backend server is outdated, and then + we can get rid of ``start``, and ``check``. + """ + + def __init__( + self, + name, + start=_default, + check=None, + skeleton=None, + active=False, + stop=None, + lifecycle_hook: ServiceLifecycleHook = None, + ): + self.plugin_name = name + self.start_function = start + self.skeleton = skeleton + self.check_function = check + self.default_active = active + self.stop_function = stop + self.lifecycle_hook = lifecycle_hook or ServiceLifecycleHook() + self._provider = None + call_safe(self.lifecycle_hook.on_after_init) + + def start(self, asynchronous): + call_safe(self.lifecycle_hook.on_before_start) + + if not self.start_function: + return + + if self.start_function is _default: + return + + kwargs = {"asynchronous": asynchronous} + if self.skeleton: + kwargs["update_listener"] = self.skeleton + return self.start_function(**kwargs) + + def stop(self): + call_safe(self.lifecycle_hook.on_before_stop) + if not self.stop_function: + return + return self.stop_function() + + def check(self, expect_shutdown=False, print_error=False): + if not self.check_function: + return + return self.check_function(expect_shutdown=expect_shutdown, print_error=print_error) + + def name(self): + return self.plugin_name + + def is_enabled(self): + return is_api_enabled(self.plugin_name) + + def accept_state_visitor(self, visitor: StateVisitor): + """ + Passes the StateVisitor to the ASF provider if it is set and implements the StateVisitable. Otherwise, it uses + the ReflectionStateLocator to visit the service state. + + :param visitor: the visitor + """ + if self._provider and isinstance(self._provider, StateVisitable): + self._provider.accept_state_visitor(visitor) + return + + from localstack.state.inspect import ReflectionStateLocator + + ReflectionStateLocator(service=self.name()).accept_state_visitor(visitor) + + @staticmethod + def for_provider( + provider: ServiceProvider, + dispatch_table_factory: Callable[[ServiceProvider], DispatchTable] = None, + service_lifecycle_hook: ServiceLifecycleHook = None, + ) -> "Service": + """ + Factory method for creating services for providers. This method hides a bunch of legacy code and + band-aids/adapters to make persistence visitors work, while providing compatibility with the legacy edge proxy. + + :param provider: the service provider, i.e., the implementation of the generated ASF service API. + :param dispatch_table_factory: a `MotoFallbackDispatcher` or something similar that uses the provider to + create a dispatch table. this one's a bit clumsy. + :param service_lifecycle_hook: if left empty, the factory checks whether the provider is a ServiceLifecycleHook. + :return: a service instance + """ + # determine the service_lifecycle_hook + if service_lifecycle_hook is None: + if isinstance(provider, ServiceLifecycleHook): + service_lifecycle_hook = provider + + # determine the delegate for injecting into the skeleton + delegate = dispatch_table_factory(provider) if dispatch_table_factory else provider + service = Service( + name=provider.service, + skeleton=Skeleton(load_service(provider.service), delegate), + lifecycle_hook=service_lifecycle_hook, + ) + service._provider = provider + + return service + + +class ServiceState(Enum): + UNKNOWN = "unknown" + AVAILABLE = "available" + DISABLED = "disabled" + STARTING = "starting" + RUNNING = "running" + STOPPING = "stopping" + STOPPED = "stopped" + ERROR = "error" + + +class ServiceContainer: + """ + Holds a service, its state, and exposes lifecycle methods of the service. + """ + + service: Service + state: ServiceState + lock: threading.RLock + errors: List[Exception] + + def __init__(self, service: Service, state=ServiceState.UNKNOWN): + self.service = service + self.state = state + self.lock = threading.RLock() + self.errors = [] + + def get(self) -> Service: + return self.service + + def start(self) -> bool: + try: + self.state = ServiceState.STARTING + self.service.start(asynchronous=True) + except Exception as e: + self.state = ServiceState.ERROR + self.errors.append(e) + LOG.error("error while starting service %s: %s", self.service.name(), e) + return False + return self.check() + + def check(self) -> bool: + try: + self.service.check(print_error=True) + self.state = ServiceState.RUNNING + return True + except Exception as e: + self.state = ServiceState.ERROR + self.errors.append(e) + LOG.error("error while checking service %s: %s", self.service.name(), e) + return False + + def stop(self): + try: + self.state = ServiceState.STOPPING + self.service.stop() + self.state = ServiceState.STOPPED + except Exception as e: + self.state = ServiceState.ERROR + self.errors.append(e) + + +class ServiceManager: + def __init__(self) -> None: + super().__init__() + self._services: Dict[str, ServiceContainer] = {} + self._mutex = threading.RLock() + + def get_service_container(self, name: str) -> Optional[ServiceContainer]: + return self._services.get(name) + + def get_service(self, name: str) -> Optional[Service]: + container = self.get_service_container(name) + return container.service if container else None + + def add_service(self, service: Service) -> bool: + state = ServiceState.AVAILABLE if service.is_enabled() else ServiceState.DISABLED + self._services[service.name()] = ServiceContainer(service, state) + + return True + + def list_available(self) -> List[str]: + return list(self._services.keys()) + + def exists(self, name: str) -> bool: + return name in self._services + + def is_running(self, name: str) -> bool: + return self.get_state(name) == ServiceState.RUNNING + + def check(self, name: str) -> bool: + if self.get_state(name) in [ServiceState.RUNNING, ServiceState.ERROR]: + return self.get_service_container(name).check() + + def check_all(self): + return any(self.check(service_name) for service_name in self.list_available()) + + def get_state(self, name: str) -> Optional[ServiceState]: + container = self.get_service_container(name) + return container.state if container else None + + def get_states(self) -> Dict[str, ServiceState]: + return {name: self.get_state(name) for name in self.list_available()} + + @log_duration() + def require(self, name: str) -> Service: + """ + High level function that always returns a running service, or raises an error. If the service is in a state + that it could be transitioned into a running state, then invoking this function will attempt that transition, + e.g., by starting the service if it is available. + """ + container = self.get_service_container(name) + + if not container: + raise ValueError("no such service %s" % name) + + if container.state == ServiceState.STARTING: + if not poll_condition(lambda: container.state != ServiceState.STARTING, timeout=30): + raise TimeoutError("gave up waiting for service %s to start" % name) + + if container.state == ServiceState.STOPPING: + if not poll_condition(lambda: container.state == ServiceState.STOPPED, timeout=30): + raise TimeoutError("gave up waiting for service %s to stop" % name) + + with container.lock: + if container.state == ServiceState.DISABLED: + raise ServiceDisabled("service %s is disabled" % name) + + if container.state == ServiceState.RUNNING: + return container.service + + if container.state == ServiceState.ERROR: + # raise any capture error + raise container.errors[-1] + + if container.state == ServiceState.AVAILABLE or container.state == ServiceState.STOPPED: + if container.start(): + return container.service + else: + raise container.errors[-1] + + raise ServiceStateException( + "service %s is not ready (%s) and could not be started" % (name, container.state) + ) + + # legacy map compatibility + + def items(self): + return { + container.service.name(): container.service for container in self._services.values() + }.items() + + def keys(self): + return self._services.keys() + + def values(self): + return [container.service for container in self._services.values()] + + def get(self, key): + return self.get_service(key) + + def __iter__(self): + return self._services + + +class ServicePlugin(Plugin): + service: Service + api: str + + @abc.abstractmethod + def create_service(self) -> Service: + raise NotImplementedError + + def load(self): + self.service = self.create_service() + return self.service + + +class ServicePluginAdapter(ServicePlugin): + def __init__( + self, + api: str, + create_service: Callable[[], Service], + should_load: Callable[[], bool] = None, + ) -> None: + super().__init__() + self.api = api + self._create_service = create_service + self._should_load = should_load + + def should_load(self) -> bool: + if self._should_load: + return self._should_load() + return True + + def create_service(self) -> Service: + return self._create_service() + + +def aws_provider(api: str = None, name="default", should_load: Callable[[], bool] = None): + """ + Decorator for marking methods that create a Service instance as a ServicePlugin. Methods marked with this + decorator are discoverable as a PluginSpec within the namespace "localstack.aws.provider", with the name + ":". If api is not explicitly specified, then the method name is used as api name. + """ + + def wrapper(fn): + # sugar for being able to name the function like the api + _api = api or fn.__name__ + + # this causes the plugin framework into pointing the entrypoint to the original function rather than the + # nested factory function + @functools.wraps(fn) + def factory() -> ServicePluginAdapter: + return ServicePluginAdapter(api=_api, should_load=should_load, create_service=fn) + + return PluginSpec(PLUGIN_NAMESPACE, f"{_api}:{name}", factory=factory) + + return wrapper + + +class ServicePluginErrorCollector(PluginLifecycleListener): + """ + A PluginLifecycleListener that collects errors related to service plugins. + """ + + errors: Dict[Tuple[str, str], Exception] # keys are: (api, provider) + + def __init__(self, errors: Dict[str, Exception] = None) -> None: + super().__init__() + self.errors = errors or {} + + def get_key(self, plugin_name) -> Tuple[str, str]: + # the convention is :, currently we don't really expose the provider + # TODO: faulty plugin names would break this + return tuple(plugin_name.split(":", maxsplit=1)) + + def on_resolve_exception(self, namespace: str, entrypoint, exception: Exception): + self.errors[self.get_key(entrypoint.name)] = exception + + def on_init_exception(self, plugin_spec: PluginSpec, exception: Exception): + self.errors[self.get_key(plugin_spec.name)] = exception + + def on_load_exception(self, plugin_spec: PluginSpec, plugin: Plugin, exception: Exception): + self.errors[self.get_key(plugin_spec.name)] = exception + + def has_errors(self, api: str, provider: str = None) -> bool: + for e_api, e_provider in self.errors.keys(): + if api == e_api: + if not provider: + return True + else: + return e_provider == provider + + return False + + +class ServicePluginManager(ServiceManager): + plugin_manager: PluginManager[ServicePlugin] + plugin_errors: ServicePluginErrorCollector + + def __init__( + self, + plugin_manager: PluginManager[ServicePlugin] = None, + provider_config: ServiceProviderConfig = None, + ) -> None: + super().__init__() + self.plugin_errors = ServicePluginErrorCollector() + self.plugin_manager = plugin_manager or PluginManager( + PLUGIN_NAMESPACE, listener=self.plugin_errors + ) + self._api_provider_specs = None + self.provider_config = provider_config or config.SERVICE_PROVIDER_CONFIG + + # locks used to make sure plugin loading is thread safe - will be cleared after single use + self._plugin_load_locks: Dict[str, threading.RLock] = SynchronizedDefaultDict( + threading.RLock + ) + + def get_active_provider(self, service: str) -> str: + """ + Get configured provider for a given service + + :param service: Service name + :return: configured provider + """ + return self.provider_config.get_provider(service) + + def get_default_provider(self) -> str: + """ + Get the default provider + + :return: default provider + """ + return self.provider_config.default_value + + # TODO make the abstraction clearer, to provide better information if service is available versus discoverable + # especially important when considering pro services + def list_available(self) -> List[str]: + """ + List all available services, which have an available, configured provider + + :return: List of service names + """ + return [ + service + for service, providers in self.api_provider_specs.items() + if self.get_active_provider(service) in providers + ] + + def _get_loaded_service_containers( + self, services: Optional[List[str]] = None + ) -> List[ServiceContainer]: + """ + Returns all the available service containers. + :param services: the list of services to restrict the search to. If empty or NULL then service containers for + all available services are queried. + :return: a list of all the available service containers. + """ + services = services or self.list_available() + return [ + c for s in services if (c := super(ServicePluginManager, self).get_service_container(s)) + ] + + def list_loaded_services(self) -> List[str]: + """ + Lists all the services which have a provider that has been initialized + + :return: a list of service names + """ + return [ + service_container.service.name() + for service_container in self._get_loaded_service_containers() + ] + + def list_active_services(self) -> List[str]: + """ + Lists all services that have an initialised provider and are currently running. + + :return: the list of active service names. + """ + return [ + service_container.service.name() + for service_container in self._get_loaded_service_containers() + if service_container.state == ServiceState.RUNNING + ] + + def exists(self, name: str) -> bool: + return name in self.list_available() + + def get_state(self, name: str) -> Optional[ServiceState]: + if name in self._services: + # ServiceContainer exists, which means the plugin has been loaded + return super().get_state(name) + + if not self.exists(name): + # there's definitely no service with this name + return None + + # if a PluginSpec exists, then we can get the container and check whether there was an error loading the plugin + provider = self.get_active_provider(name) + if self.plugin_errors.has_errors(name, provider): + return ServiceState.ERROR + + return ServiceState.AVAILABLE if is_api_enabled(name) else ServiceState.DISABLED + + def get_service_container(self, name: str) -> Optional[ServiceContainer]: + if container := self._services.get(name): + return container + + if not self.exists(name): + return None + + load_lock = self._plugin_load_locks[name] + with load_lock: + # check once again to avoid race conditions + if container := self._services.get(name): + return container + + # this is where we start lazy loading. we now know the PluginSpec for the API exists, + # but the ServiceContainer has not been created. + # this control path will be executed once per service + plugin = self._load_service_plugin(name) + if not plugin or not plugin.service: + return None + + with self._mutex: + super().add_service(plugin.service) + + del self._plugin_load_locks[name] # we only needed the service lock once + + return self._services.get(name) + + @property + def api_provider_specs(self) -> Dict[str, List[str]]: + """ + Returns all provider names within the service plugin namespace and parses their name according to the convention, + that is ":". The result is a dictionary that maps api => List[str (name of a provider)]. + """ + if self._api_provider_specs is not None: + return self._api_provider_specs + + with self._mutex: + if self._api_provider_specs is None: + self._api_provider_specs = self._resolve_api_provider_specs() + return self._api_provider_specs + + @log_duration() + def _load_service_plugin(self, name: str) -> Optional[ServicePlugin]: + providers = self.api_provider_specs.get(name) + if not providers: + # no providers for this api + return None + + preferred_provider = self.get_active_provider(name) + if preferred_provider in providers: + provider = preferred_provider + else: + default = self.get_default_provider() + LOG.warning( + "Configured provider (%s) does not exist for service (%s). Available options are: %s. " + "Falling back to default provider '%s'. This can impact the availability of Pro functionality, " + "please fix this configuration issue as soon as possible.", + preferred_provider, + name, + providers, + default, + ) + provider = default + + plugin_name = f"{name}:{provider}" + plugin = self.plugin_manager.load(plugin_name) + plugin.name = plugin_name + + return plugin + + @log_duration() + def _resolve_api_provider_specs(self) -> Dict[str, List[str]]: + result = defaultdict(list) + + for spec in self.plugin_manager.list_plugin_specs(): + api, provider = spec.name.split( + ":" + ) # TODO: error handling, faulty plugins could break the runtime + result[api].append(provider) + + return result + + def apis_with_provider(self, provider: str) -> List[str]: + """ + Lists all apis where a given provider exists for. + :param provider: Name of the provider + :return: List of apis the given provider provides + """ + apis = [] + for api, providers in self.api_provider_specs.items(): + if provider in providers: + apis.append(api) + return apis + + def _stop_services(self, service_containers: List[ServiceContainer]) -> None: + """ + Atomically attempts to stop all given 'ServiceState.STARTING' and 'ServiceState.RUNNING' services. + :param service_containers: the list of service containers to be stopped. + """ + target_service_states = {ServiceState.STARTING, ServiceState.RUNNING} + with self._mutex: + for service_container in service_containers: + if service_container.state in target_service_states: + service_container.stop() + + def stop_services(self, services: List[str] = None): + """ + Stops services for this service manager, if they are currently active. + Will not stop services not already started or in and error state. + + :param services: Service names to stop. If not provided, all services for this manager will be stopped. + """ + target_service_containers = self._get_loaded_service_containers(services=services) + self._stop_services(target_service_containers) + + def stop_all_services(self) -> None: + """ + Stops all services for this service manager, if they are currently active. + Will not stop services not already started or in and error state. + """ + target_service_containers = self._get_loaded_service_containers() + self._stop_services(target_service_containers) + + +# map of service plugins, mapping from service name to plugin details +SERVICE_PLUGINS: ServicePluginManager = ServicePluginManager() + + +# ----------------------------- +# INFRASTRUCTURE HEALTH CHECKS +# ----------------------------- + + +def wait_for_infra_shutdown(): + apis = get_enabled_apis() + + names = [name for name, plugin in SERVICE_PLUGINS.items() if name in apis] + + def check(name): + check_service_health(api=name, expect_shutdown=True) + LOG.debug("[shutdown] api %s has shut down", name) + + # no special significance to 10 workers, seems like a reasonable number given the number of services we have + with ThreadPoolExecutor(max_workers=10) as executor: + executor.map(check, names) + + +def check_service_health(api, expect_shutdown=False): + status = SERVICE_PLUGINS.check(api) + if status == expect_shutdown: + if not expect_shutdown: + LOG.warning('Service "%s" not yet available, retrying...', api) + else: + LOG.warning('Service "%s" still shutting down, retrying...', api) + raise Exception("Service check failed for api: %s" % api) + + +@hooks.on_infra_start(should_load=lambda: config.EAGER_SERVICE_LOADING) +def eager_load_services(): + from localstack.utils.bootstrap import get_preloaded_services + + preloaded_apis = get_preloaded_services() + LOG.debug("Eager loading services: %s", sorted(preloaded_apis)) + + for api in preloaded_apis: + try: + SERVICE_PLUGINS.require(api) + except ServiceDisabled as e: + LOG.debug("%s", e) + except Exception: + LOG.exception("could not load service plugin %s", api) diff --git a/localstack-core/localstack/services/providers.py b/localstack-core/localstack/services/providers.py new file mode 100644 index 0000000000000..2a09121d430f1 --- /dev/null +++ b/localstack-core/localstack/services/providers.py @@ -0,0 +1,434 @@ +from localstack.aws.forwarder import HttpFallbackDispatcher +from localstack.services.plugins import ( + Service, + aws_provider, +) + + +@aws_provider() +def acm(): + from localstack.services.acm.provider import AcmProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = AcmProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def apigateway(): + from localstack.services.apigateway.next_gen.provider import ApigatewayNextGenProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = ApigatewayNextGenProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider(api="apigateway", name="next_gen") +def apigateway_next_gen(): + from localstack.services.apigateway.next_gen.provider import ApigatewayNextGenProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = ApigatewayNextGenProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider(api="apigateway", name="legacy") +def apigateway_legacy(): + from localstack.services.apigateway.legacy.provider import ApigatewayProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = ApigatewayProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def cloudformation(): + from localstack.services.cloudformation.provider import CloudformationProvider + + provider = CloudformationProvider() + return Service.for_provider(provider) + + +@aws_provider(api="cloudformation", name="engine-v2") +def cloudformation_v2(): + from localstack.services.cloudformation.v2.provider import CloudformationProviderV2 + + provider = CloudformationProviderV2() + return Service.for_provider(provider) + + +@aws_provider(api="config") +def awsconfig(): + from localstack.services.configservice.provider import ConfigProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = ConfigProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider(api="cloudwatch", name="default") +def cloudwatch(): + from localstack.services.cloudwatch.provider_v2 import CloudwatchProvider + + provider = CloudwatchProvider() + return Service.for_provider(provider) + + +@aws_provider(api="cloudwatch", name="v1") +def cloudwatch_v1(): + from localstack.services.cloudwatch.provider import CloudwatchProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = CloudwatchProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider(api="cloudwatch", name="v2") +def cloudwatch_v2(): + from localstack.services.cloudwatch.provider_v2 import CloudwatchProvider + + provider = CloudwatchProvider() + return Service.for_provider(provider) + + +@aws_provider() +def dynamodb(): + from localstack.services.dynamodb.provider import DynamoDBProvider + + provider = DynamoDBProvider() + return Service.for_provider( + provider, + dispatch_table_factory=lambda _provider: HttpFallbackDispatcher( + _provider, _provider.get_forward_url + ), + ) + + +@aws_provider(api="dynamodbstreams", name="v2") +def dynamodbstreams_v2(): + from localstack.services.dynamodbstreams.v2.provider import DynamoDBStreamsProvider + + provider = DynamoDBStreamsProvider() + return Service.for_provider(provider) + + +@aws_provider(api="dynamodb", name="v2") +def dynamodb_v2(): + from localstack.services.dynamodb.v2.provider import DynamoDBProvider + + provider = DynamoDBProvider() + return Service.for_provider( + provider, + dispatch_table_factory=lambda _provider: HttpFallbackDispatcher( + _provider, _provider.get_forward_url + ), + ) + + +@aws_provider() +def dynamodbstreams(): + from localstack.services.dynamodbstreams.provider import DynamoDBStreamsProvider + + provider = DynamoDBStreamsProvider() + return Service.for_provider(provider) + + +@aws_provider() +def ec2(): + from localstack.services.ec2.provider import Ec2Provider + from localstack.services.moto import MotoFallbackDispatcher + + provider = Ec2Provider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def es(): + from localstack.services.es.provider import EsProvider + + provider = EsProvider() + return Service.for_provider(provider) + + +@aws_provider() +def firehose(): + from localstack.services.firehose.provider import FirehoseProvider + + provider = FirehoseProvider() + return Service.for_provider(provider) + + +@aws_provider() +def iam(): + from localstack.services.iam.provider import IamProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = IamProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def sts(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.sts.provider import StsProvider + + provider = StsProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def kinesis(): + from localstack.services.kinesis.provider import KinesisProvider + + provider = KinesisProvider() + return Service.for_provider( + provider, + dispatch_table_factory=lambda _provider: HttpFallbackDispatcher( + _provider, _provider.get_forward_url + ), + ) + + +@aws_provider() +def kms(): + from localstack.services.kms.provider import KmsProvider + + provider = KmsProvider() + return Service.for_provider(provider) + + +@aws_provider(api="lambda") +def lambda_(): + from localstack.services.lambda_.provider import LambdaProvider + + provider = LambdaProvider() + return Service.for_provider(provider) + + +@aws_provider(api="lambda", name="asf") +def lambda_asf(): + from localstack.services.lambda_.provider import LambdaProvider + + provider = LambdaProvider() + return Service.for_provider(provider) + + +@aws_provider(api="lambda", name="v2") +def lambda_v2(): + from localstack.services.lambda_.provider import LambdaProvider + + provider = LambdaProvider() + return Service.for_provider(provider) + + +@aws_provider() +def logs(): + from localstack.services.logs.provider import LogsProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = LogsProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def opensearch(): + from localstack.services.opensearch.provider import OpensearchProvider + + provider = OpensearchProvider() + return Service.for_provider(provider) + + +@aws_provider() +def redshift(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.redshift.provider import RedshiftProvider + + provider = RedshiftProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def route53(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.route53.provider import Route53Provider + + provider = Route53Provider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def route53resolver(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.route53resolver.provider import Route53ResolverProvider + + provider = Route53ResolverProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def s3(): + from localstack.services.s3.provider import S3Provider + + provider = S3Provider() + return Service.for_provider(provider) + + +@aws_provider() +def s3control(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.s3control.provider import S3ControlProvider + + provider = S3ControlProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def scheduler(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.scheduler.provider import SchedulerProvider + + provider = SchedulerProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def secretsmanager(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.secretsmanager.provider import SecretsmanagerProvider + + provider = SecretsmanagerProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def ses(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.ses.provider import SesProvider + + provider = SesProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def sns(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.sns.provider import SnsProvider + + provider = SnsProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def sqs(): + from localstack.services.sqs.provider import SqsProvider + + provider = SqsProvider() + return Service.for_provider(provider) + + +@aws_provider() +def ssm(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.ssm.provider import SsmProvider + + provider = SsmProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider(api="events", name="default") +def events(): + from localstack.services.events.provider import EventsProvider + + provider = EventsProvider() + return Service.for_provider(provider) + + +@aws_provider(api="events", name="v2") +def events_v2(): + from localstack.services.events.provider import EventsProvider + + provider = EventsProvider() + return Service.for_provider(provider) + + +@aws_provider(api="events", name="v1") +def events_v1(): + from localstack.services.events.v1.provider import EventsProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = EventsProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider(api="events", name="legacy") +def events_legacy(): + from localstack.services.events.v1.provider import EventsProvider + from localstack.services.moto import MotoFallbackDispatcher + + provider = EventsProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def stepfunctions(): + from localstack.services.stepfunctions.provider import StepFunctionsProvider + + provider = StepFunctionsProvider() + return Service.for_provider(provider) + + +# TODO: remove with 4.1.0 to allow smooth deprecation path for users that have v2 set manually +@aws_provider(api="stepfunctions", name="v2") +def stepfunctions_v2(): + # provider for people still manually using `v2` + from localstack.services.stepfunctions.provider import StepFunctionsProvider + + provider = StepFunctionsProvider() + return Service.for_provider(provider) + + +@aws_provider() +def swf(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.swf.provider import SWFProvider + + provider = SWFProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def resourcegroupstaggingapi(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.resourcegroupstaggingapi.provider import ( + ResourcegroupstaggingapiProvider, + ) + + provider = ResourcegroupstaggingapiProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider(api="resource-groups") +def resource_groups(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.resource_groups.provider import ResourceGroupsProvider + + provider = ResourceGroupsProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def support(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.support.provider import SupportProvider + + provider = SupportProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) + + +@aws_provider() +def transcribe(): + from localstack.services.moto import MotoFallbackDispatcher + from localstack.services.transcribe.provider import TranscribeProvider + + provider = TranscribeProvider() + return Service.for_provider(provider, dispatch_table_factory=MotoFallbackDispatcher) diff --git a/localstack-core/localstack/services/redshift/__init__.py b/localstack-core/localstack/services/redshift/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/redshift/provider.py b/localstack-core/localstack/services/redshift/provider.py new file mode 100644 index 0000000000000..4f432e3a1aef5 --- /dev/null +++ b/localstack-core/localstack/services/redshift/provider.py @@ -0,0 +1,57 @@ +import os + +from moto.redshift import responses as redshift_responses +from moto.redshift.models import redshift_backends + +from localstack import config +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.redshift import ( + ClusterSecurityGroupMessage, + DescribeClusterSecurityGroupsMessage, + RedshiftApi, +) +from localstack.services.moto import call_moto +from localstack.state import AssetDirectory, StateVisitor +from localstack.utils.common import recurse_object +from localstack.utils.patch import patch + + +@patch(redshift_responses.itemize) +def itemize(fn, data, parent_key=None, *args, **kwargs): + # TODO: potentially add additional required tags here! + list_parent_tags = ["ClusterSubnetGroups"] + + def fix_keys(o, **kwargs): + if isinstance(o, dict): + for k, v in o.items(): + if k in list_parent_tags: + if isinstance(v, dict) and "item" in v: + v[k[:-1]] = v.pop("item") + return o + + result = fn(data, *args, **kwargs) + recurse_object(result, fix_keys) + return result + + +class RedshiftProvider(RedshiftApi): + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(redshift_backends) + visitor.visit(AssetDirectory(self.service, os.path.join(config.dirs.data, "redshift"))) + + @handler("DescribeClusterSecurityGroups", expand=False) + def describe_cluster_security_groups( + self, + context: RequestContext, + request: DescribeClusterSecurityGroupsMessage, + ) -> ClusterSecurityGroupMessage: + result = call_moto(context) + backend = redshift_backends[context.account_id][context.region] + for group in result.get("ClusterSecurityGroups", []): + if group.get("IPRanges"): + continue + sgroup = backend.security_groups.get(group["ClusterSecurityGroupName"]) + group["IPRanges"] = [ + {"Status": "authorized", "CIDRIP": ip} for ip in sgroup.ingress_rules + ] + return result diff --git a/localstack-core/localstack/services/redshift/resource_providers/__init__.py b/localstack-core/localstack/services/redshift/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster.py b/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster.py new file mode 100644 index 0000000000000..629a7ca7a5b2e --- /dev/null +++ b/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster.py @@ -0,0 +1,262 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class RedshiftClusterProperties(TypedDict): + ClusterType: Optional[str] + DBName: Optional[str] + MasterUserPassword: Optional[str] + MasterUsername: Optional[str] + NodeType: Optional[str] + AllowVersionUpgrade: Optional[bool] + AquaConfigurationStatus: Optional[str] + AutomatedSnapshotRetentionPeriod: Optional[int] + AvailabilityZone: Optional[str] + AvailabilityZoneRelocation: Optional[bool] + AvailabilityZoneRelocationStatus: Optional[str] + Classic: Optional[bool] + ClusterIdentifier: Optional[str] + ClusterParameterGroupName: Optional[str] + ClusterSecurityGroups: Optional[list[str]] + ClusterSubnetGroupName: Optional[str] + ClusterVersion: Optional[str] + DeferMaintenance: Optional[bool] + DeferMaintenanceDuration: Optional[int] + DeferMaintenanceEndTime: Optional[str] + DeferMaintenanceIdentifier: Optional[str] + DeferMaintenanceStartTime: Optional[str] + DestinationRegion: Optional[str] + ElasticIp: Optional[str] + Encrypted: Optional[bool] + Endpoint: Optional[Endpoint] + EnhancedVpcRouting: Optional[bool] + HsmClientCertificateIdentifier: Optional[str] + HsmConfigurationIdentifier: Optional[str] + IamRoles: Optional[list[str]] + Id: Optional[str] + KmsKeyId: Optional[str] + LoggingProperties: Optional[LoggingProperties] + MaintenanceTrackName: Optional[str] + ManualSnapshotRetentionPeriod: Optional[int] + NumberOfNodes: Optional[int] + OwnerAccount: Optional[str] + Port: Optional[int] + PreferredMaintenanceWindow: Optional[str] + PubliclyAccessible: Optional[bool] + ResourceAction: Optional[str] + RevisionTarget: Optional[str] + RotateEncryptionKey: Optional[bool] + SnapshotClusterIdentifier: Optional[str] + SnapshotCopyGrantName: Optional[str] + SnapshotCopyManual: Optional[bool] + SnapshotCopyRetentionPeriod: Optional[int] + SnapshotIdentifier: Optional[str] + Tags: Optional[list[Tag]] + VpcSecurityGroupIds: Optional[list[str]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class LoggingProperties(TypedDict): + BucketName: Optional[str] + S3KeyPrefix: Optional[str] + + +class Endpoint(TypedDict): + Address: Optional[str] + Port: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class RedshiftClusterProvider(ResourceProvider[RedshiftClusterProperties]): + TYPE = "AWS::Redshift::Cluster" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[RedshiftClusterProperties], + ) -> ProgressEvent[RedshiftClusterProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/ClusterIdentifier + + Required properties: + - MasterUserPassword + - NodeType + - MasterUsername + - DBName + - ClusterType + + Create-only properties: + - /properties/ClusterIdentifier + - /properties/OwnerAccount + - /properties/SnapshotIdentifier + - /properties/DBName + - /properties/SnapshotClusterIdentifier + - /properties/ClusterSubnetGroupName + - /properties/MasterUsername + + Read-only properties: + - /properties/Id + - /properties/DeferMaintenanceIdentifier + - /properties/Endpoint/Port + - /properties/Endpoint/Address + + IAM permissions required: + - redshift:DescribeClusters + - redshift:CreateCluster + - redshift:RestoreFromClusterSnapshot + - redshift:EnableLogging + + """ + model = request.desired_state + redshift = request.aws_client_factory.redshift + + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + + if not model.get("ClusterIdentifier"): + model["ClusterIdentifier"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + result = redshift.create_cluster(**model) + model["Id"] = result["Cluster"]["ClusterIdentifier"] + + try: + cluster = redshift.describe_clusters(ClusterIdentifier=model["ClusterIdentifier"])[ + "Clusters" + ][0] + match cluster["ClusterStatus"]: + case "available": + model.setdefault("Endpoint", {}) + model["Endpoint"]["Address"] = cluster["Endpoint"]["Address"] + model["Endpoint"]["Port"] = str(cluster["Endpoint"]["Port"]) + # getting "Attribute 'DeferMaintenanceIdentifier' does not exist." on AWS + # model["DeferMaintenanceIdentifier"] = "?" + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + case failed_state: + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + message=f"Cluster in failed state: {failed_state}", + ) + + except redshift.exceptions.ClusterNotFoundFault: + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[RedshiftClusterProperties], + ) -> ProgressEvent[RedshiftClusterProperties]: + """ + Fetch resource information + + IAM permissions required: + - redshift:DescribeClusters + - redshift:DescribeLoggingStatus + - redshift:DescribeSnapshotCopyGrant + - redshift:DescribeClusterDbRevisions + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[RedshiftClusterProperties], + ) -> ProgressEvent[RedshiftClusterProperties]: + """ + Delete a resource + + IAM permissions required: + - redshift:DescribeClusters + - redshift:DeleteCluster + """ + model = request.desired_state + redshift = request.aws_client_factory.redshift + + if not request.custom_context.get(REPEATED_INVOCATION): + request.custom_context[REPEATED_INVOCATION] = True + redshift.delete_cluster(ClusterIdentifier=model["ClusterIdentifier"]) + + try: + cluster = redshift.describe_clusters(ClusterIdentifier=model["ClusterIdentifier"])[ + "Clusters" + ][0] + match cluster["ClusterStatus"]: + case "creating" | "modifying": + return ProgressEvent( + status=OperationStatus.FAILED, + resource_model=model, + custom_context=request.custom_context, + message=f"Redshift cluster in unexpected status: {cluster['ClusterStatus']}", + ) + case _: + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + except redshift.exceptions.ClusterNotFoundFault: + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[RedshiftClusterProperties], + ) -> ProgressEvent[RedshiftClusterProperties]: + """ + Update a resource + + IAM permissions required: + - redshift:DescribeClusters + - redshift:ModifyCluster + - redshift:ModifyClusterIamRoles + - redshift:EnableLogging + - redshift:CreateTags + - redshift:DeleteTags + - redshift:DisableLogging + - redshift:RebootCluster + - redshift:EnableSnapshotCopy + - redshift:DisableSnapshotCopy + - redshift:ModifySnapshotCopyRetentionPeriod + - redshift:ModifyAquaConfiguration + - redshift:ResizeCluster + - redshift:ModifyClusterMaintenance + - redshift:DescribeClusterDbRevisions + - redshift:ModifyClusterDbRevisions + - redshift:PauseCluster + - redshift:ResumeCluster + - redshift:RotateEncryptionKey + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster.schema.json b/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster.schema.json new file mode 100644 index 0000000000000..89feee84e0f67 --- /dev/null +++ b/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster.schema.json @@ -0,0 +1,367 @@ +{ + "typeName": "AWS::Redshift::Cluster", + "description": "An example resource schema demonstrating some basic constructs and validation rules.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "Tag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 127 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 1, + "maxLength": 127 + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 1 to 255 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "minLength": 1, + "maxLength": 255 + } + }, + "required": [ + "Value", + "Key" + ] + }, + "LoggingProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "BucketName": { + "type": "string" + }, + "S3KeyPrefix": { + "type": "string" + } + }, + "required": [ + "BucketName" + ] + }, + "Endpoint": { + "type": "object", + "additionalProperties": false, + "properties": { + "Port": { + "type": "string" + }, + "Address": { + "type": "string" + } + } + } + }, + "properties": { + "ClusterIdentifier": { + "description": "A unique identifier for the cluster. You use this identifier to refer to the cluster for any subsequent cluster operations such as deleting or modifying. All alphabetical characters must be lower case, no hypens at the end, no two consecutive hyphens. Cluster name should be unique for all clusters within an AWS account", + "type": "string", + "maxLength": 63 + }, + "MasterUsername": { + "description": "The user name associated with the master user account for the cluster that is being created. The user name can't be PUBLIC and first character must be a letter.", + "type": "string", + "maxLength": 128 + }, + "MasterUserPassword": { + "description": "The password associated with the master user account for the cluster that is being created. Password must be between 8 and 64 characters in length, should have at least one uppercase letter.Must contain at least one lowercase letter.Must contain one number.Can be any printable ASCII character.", + "type": "string", + "maxLength": 64 + }, + "NodeType": { + "description": "The node type to be provisioned for the cluster.Valid Values: ds2.xlarge | ds2.8xlarge | dc1.large | dc1.8xlarge | dc2.large | dc2.8xlarge | ra3.4xlarge | ra3.16xlarge", + "type": "string" + }, + "AllowVersionUpgrade": { + "description": "Major version upgrades can be applied during the maintenance window to the Amazon Redshift engine that is running on the cluster. Default value is True", + "type": "boolean" + }, + "AutomatedSnapshotRetentionPeriod": { + "description": "The number of days that automated snapshots are retained. If the value is 0, automated snapshots are disabled. Default value is 1", + "type": "integer" + }, + "AvailabilityZone": { + "description": "The EC2 Availability Zone (AZ) in which you want Amazon Redshift to provision the cluster. Default: A random, system-chosen Availability Zone in the region that is specified by the endpoint", + "type": "string" + }, + "ClusterParameterGroupName": { + "description": "The name of the parameter group to be associated with this cluster.", + "type": "string", + "maxLength": 255 + }, + "ClusterType": { + "description": "The type of the cluster. When cluster type is specified as single-node, the NumberOfNodes parameter is not required and if multi-node, the NumberOfNodes parameter is required", + "type": "string" + }, + "ClusterVersion": { + "description": "The version of the Amazon Redshift engine software that you want to deploy on the cluster.The version selected runs on all the nodes in the cluster.", + "type": "string" + }, + "ClusterSubnetGroupName": { + "description": "The name of a cluster subnet group to be associated with this cluster.", + "type": "string" + }, + "DBName": { + "description": "The name of the first database to be created when the cluster is created. To create additional databases after the cluster is created, connect to the cluster with a SQL client and use SQL commands to create a database.", + "type": "string" + }, + "ElasticIp": { + "description": "The Elastic IP (EIP) address for the cluster.", + "type": "string" + }, + "Encrypted": { + "description": "If true, the data in the cluster is encrypted at rest.", + "type": "boolean" + }, + "HsmClientCertificateIdentifier": { + "description": "Specifies the name of the HSM client certificate the Amazon Redshift cluster uses to retrieve the data encryption keys stored in an HSM", + "type": "string" + }, + "HsmConfigurationIdentifier": { + "description": "Specifies the name of the HSM configuration that contains the information the Amazon Redshift cluster can use to retrieve and store keys in an HSM.", + "type": "string" + }, + "KmsKeyId": { + "description": "The AWS Key Management Service (KMS) key ID of the encryption key that you want to use to encrypt data in the cluster.", + "type": "string" + }, + "NumberOfNodes": { + "description": "The number of compute nodes in the cluster. This parameter is required when the ClusterType parameter is specified as multi-node.", + "type": "integer" + }, + "Port": { + "description": "The port number on which the cluster accepts incoming connections. The cluster is accessible only via the JDBC and ODBC connection strings", + "type": "integer" + }, + "PreferredMaintenanceWindow": { + "description": "The weekly time range (in UTC) during which automated cluster maintenance can occur.", + "type": "string" + }, + "PubliclyAccessible": { + "description": "If true, the cluster can be accessed from a public network.", + "type": "boolean" + }, + "ClusterSecurityGroups": { + "description": "A list of security groups to be associated with this cluster.", + "type": "array", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "IamRoles": { + "description": "A list of AWS Identity and Access Management (IAM) roles that can be used by the cluster to access other AWS services. You must supply the IAM roles in their Amazon Resource Name (ARN) format. You can supply up to 50 IAM roles in a single request", + "type": "array", + "insertionOrder": false, + "maxItems": 50, + "items": { + "type": "string" + } + }, + "Tags": { + "description": "The list of tags for the cluster parameter group.", + "type": "array", + "insertionOrder": false, + "maxItems": 50, + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "VpcSecurityGroupIds": { + "description": "A list of Virtual Private Cloud (VPC) security groups to be associated with the cluster.", + "type": "array", + "insertionOrder": false, + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "SnapshotClusterIdentifier": { + "description": "The name of the cluster the source snapshot was created from. This parameter is required if your IAM user has a policy containing a snapshot resource element that specifies anything other than * for the cluster name.", + "type": "string" + }, + "SnapshotIdentifier": { + "description": "The name of the snapshot from which to create the new cluster. This parameter isn't case sensitive.", + "type": "string" + }, + "Id": { + "type": "string" + }, + "OwnerAccount": { + "type": "string" + }, + "LoggingProperties": { + "$ref": "#/definitions/LoggingProperties" + }, + "Endpoint": { + "$ref": "#/definitions/Endpoint" + }, + "DestinationRegion": { + "description": "The destination AWS Region that you want to copy snapshots to. Constraints: Must be the name of a valid AWS Region. For more information, see Regions and Endpoints in the Amazon Web Services [https://docs.aws.amazon.com/general/latest/gr/rande.html#redshift_region] General Reference", + "type": "string" + }, + "SnapshotCopyRetentionPeriod": { + "description": "The number of days to retain automated snapshots in the destination region after they are copied from the source region. \n\n Default is 7. \n\n Constraints: Must be at least 1 and no more than 35.", + "type": "integer" + }, + "SnapshotCopyGrantName": { + "description": "The name of the snapshot copy grant to use when snapshots of an AWS KMS-encrypted cluster are copied to the destination region.", + "type": "string" + }, + "ManualSnapshotRetentionPeriod": { + "description": "The number of days to retain newly copied snapshots in the destination AWS Region after they are copied from the source AWS Region. If the value is -1, the manual snapshot is retained indefinitely.\n\nThe value must be either -1 or an integer between 1 and 3,653.", + "type": "integer" + }, + "SnapshotCopyManual": { + "description": "Indicates whether to apply the snapshot retention period to newly copied manual snapshots instead of automated snapshots.", + "type": "boolean" + }, + "AvailabilityZoneRelocation": { + "description": "The option to enable relocation for an Amazon Redshift cluster between Availability Zones after the cluster modification is complete.", + "type": "boolean" + }, + "AvailabilityZoneRelocationStatus": { + "description": "The availability zone relocation status of the cluster", + "type": "string" + }, + "AquaConfigurationStatus": { + "description": "The value represents how the cluster is configured to use AQUA (Advanced Query Accelerator) after the cluster is restored. Possible values include the following.\n\nenabled - Use AQUA if it is available for the current Region and Amazon Redshift node type.\ndisabled - Don't use AQUA.\nauto - Amazon Redshift determines whether to use AQUA.\n", + "type": "string" + }, + "Classic": { + "description": "A boolean value indicating whether the resize operation is using the classic resize process. If you don't provide this parameter or set the value to false , the resize type is elastic.", + "type": "boolean" + }, + "EnhancedVpcRouting": { + "description": "An option that specifies whether to create the cluster with enhanced VPC routing enabled. To create a cluster that uses enhanced VPC routing, the cluster must be in a VPC. For more information, see Enhanced VPC Routing in the Amazon Redshift Cluster Management Guide.\n\nIf this option is true , enhanced VPC routing is enabled.\n\nDefault: false", + "type": "boolean" + }, + "MaintenanceTrackName": { + "description": "The name for the maintenance track that you want to assign for the cluster. This name change is asynchronous. The new track name stays in the PendingModifiedValues for the cluster until the next maintenance window. When the maintenance track changes, the cluster is switched to the latest cluster release available for the maintenance track. At this point, the maintenance track name is applied.", + "type": "string" + }, + "DeferMaintenance": { + "description": "A boolean indicating whether to enable the deferred maintenance window.", + "type": "boolean" + }, + "DeferMaintenanceIdentifier": { + "description": "A unique identifier for the deferred maintenance window.", + "type": "string" + }, + "DeferMaintenanceStartTime": { + "description": "A timestamp indicating the start time for the deferred maintenance window.", + "type": "string" + }, + "DeferMaintenanceEndTime": { + "description": "A timestamp indicating end time for the deferred maintenance window. If you specify an end time, you can't specify a duration.", + "type": "string" + }, + "DeferMaintenanceDuration": { + "description": "An integer indicating the duration of the maintenance window in days. If you specify a duration, you can't specify an end time. The duration must be 45 days or less.", + "type": "integer" + }, + "RevisionTarget": { + "description": "The identifier of the database revision. You can retrieve this value from the response to the DescribeClusterDbRevisions request.", + "type": "string" + }, + "ResourceAction": { + "description": "The Redshift operation to be performed. Resource Action supports pause-cluster, resume-cluster APIs", + "type": "string" + }, + "RotateEncryptionKey": { + "description": "A boolean indicating if we want to rotate Encryption Keys.", + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "MasterUserPassword", + "NodeType", + "MasterUsername", + "DBName", + "ClusterType" + ], + "primaryIdentifier": [ + "/properties/ClusterIdentifier" + ], + "readOnlyProperties": [ + "/properties/Id", + "/properties/DeferMaintenanceIdentifier", + "/properties/Endpoint/Port", + "/properties/Endpoint/Address" + ], + "createOnlyProperties": [ + "/properties/ClusterIdentifier", + "/properties/OwnerAccount", + "/properties/SnapshotIdentifier", + "/properties/DBName", + "/properties/SnapshotClusterIdentifier", + "/properties/ClusterSubnetGroupName", + "/properties/MasterUsername" + ], + "writeOnlyProperties": [ + "/properties/MasterUserPassword" + ], + "tagging": { + "taggable": true + }, + "handlers": { + "create": { + "permissions": [ + "redshift:DescribeClusters", + "redshift:CreateCluster", + "redshift:RestoreFromClusterSnapshot", + "redshift:EnableLogging" + ], + "timeoutInMinutes": 2160 + }, + "read": { + "permissions": [ + "redshift:DescribeClusters", + "redshift:DescribeLoggingStatus", + "redshift:DescribeSnapshotCopyGrant", + "redshift:DescribeClusterDbRevisions" + ] + }, + "update": { + "permissions": [ + "redshift:DescribeClusters", + "redshift:ModifyCluster", + "redshift:ModifyClusterIamRoles", + "redshift:EnableLogging", + "redshift:CreateTags", + "redshift:DeleteTags", + "redshift:DisableLogging", + "redshift:RebootCluster", + "redshift:EnableSnapshotCopy", + "redshift:DisableSnapshotCopy", + "redshift:ModifySnapshotCopyRetentionPeriod", + "redshift:ModifyAquaConfiguration", + "redshift:ResizeCluster", + "redshift:ModifyClusterMaintenance", + "redshift:DescribeClusterDbRevisions", + "redshift:ModifyClusterDbRevisions", + "redshift:PauseCluster", + "redshift:ResumeCluster", + "redshift:RotateEncryptionKey" + ], + "timeoutInMinutes": 2160 + }, + "delete": { + "permissions": [ + "redshift:DescribeClusters", + "redshift:DeleteCluster" + ], + "timeoutInMinutes": 2160 + }, + "list": { + "permissions": [ + "redshift:DescribeClusters" + ] + } + } +} diff --git a/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster_plugin.py b/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster_plugin.py new file mode 100644 index 0000000000000..742fa8c2c1c39 --- /dev/null +++ b/localstack-core/localstack/services/redshift/resource_providers/aws_redshift_cluster_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class RedshiftClusterProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Redshift::Cluster" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.redshift.resource_providers.aws_redshift_cluster import ( + RedshiftClusterProvider, + ) + + self.factory = RedshiftClusterProvider diff --git a/localstack-core/localstack/services/resource_groups/__init__.py b/localstack-core/localstack/services/resource_groups/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/resource_groups/provider.py b/localstack-core/localstack/services/resource_groups/provider.py new file mode 100644 index 0000000000000..647dbadbae1e3 --- /dev/null +++ b/localstack-core/localstack/services/resource_groups/provider.py @@ -0,0 +1,5 @@ +from localstack.aws.api.resource_groups import ResourceGroupsApi + + +class ResourceGroupsProvider(ResourceGroupsApi): + pass diff --git a/localstack-core/localstack/services/resource_groups/resource_providers/__init__.py b/localstack-core/localstack/services/resource_groups/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group.py b/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group.py new file mode 100644 index 0000000000000..0105de3b2233f --- /dev/null +++ b/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group.py @@ -0,0 +1,172 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class ResourceGroupsGroupProperties(TypedDict): + Name: Optional[str] + Arn: Optional[str] + Configuration: Optional[list[ConfigurationItem]] + Description: Optional[str] + ResourceQuery: Optional[ResourceQuery] + Resources: Optional[list[str]] + Tags: Optional[list[Tag]] + + +class TagFilter(TypedDict): + Key: Optional[str] + Values: Optional[list[str]] + + +class Query(TypedDict): + ResourceTypeFilters: Optional[list[str]] + StackIdentifier: Optional[str] + TagFilters: Optional[list[TagFilter]] + + +class ResourceQuery(TypedDict): + Query: Optional[Query] + Type: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class ConfigurationParameter(TypedDict): + Name: Optional[str] + Values: Optional[list[str]] + + +class ConfigurationItem(TypedDict): + Parameters: Optional[list[ConfigurationParameter]] + Type: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class ResourceGroupsGroupProvider(ResourceProvider[ResourceGroupsGroupProperties]): + TYPE = "AWS::ResourceGroups::Group" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[ResourceGroupsGroupProperties], + ) -> ProgressEvent[ResourceGroupsGroupProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Name + + Required properties: + - Name + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - resource-groups:CreateGroup + - resource-groups:Tag + - cloudformation:DescribeStacks + - cloudformation:ListStackResources + - resource-groups:ListGroupResources + - resource-groups:GroupResources + + """ + model = request.desired_state + client = request.aws_client_factory.resource_groups + + if not model.get("Name"): + raise ValueError("Name is a required property") + + # Default query + resource_query = model.get("ResourceQuery", {}) + if ( + not resource_query.get("Query") + and resource_query.get("Type") == "CLOUDFORMATION_STACK_1_0" + ): + resource_query["Query"] = json.dumps( + {"ResourceTypeFilters": ["AWS::AllSupported"], "StackIdentifier": request.stack_id} + ) + + params = util.select_attributes( + model, ["Name", "Description", "ResourceQuery", "Configuration"] + ) + + if tags := model.get("Tags"): + params["Tags"] = util.transform_list_to_dict(tags) + + result = client.create_group(**params) + model["Arn"] = result["Group"]["GroupArn"] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def read( + self, + request: ResourceRequest[ResourceGroupsGroupProperties], + ) -> ProgressEvent[ResourceGroupsGroupProperties]: + """ + Fetch resource information + + IAM permissions required: + - resource-groups:GetGroup + - resource-groups:GetGroupQuery + - resource-groups:GetTags + - resource-groups:GetGroupConfiguration + - resource-groups:ListGroupResources + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[ResourceGroupsGroupProperties], + ) -> ProgressEvent[ResourceGroupsGroupProperties]: + """ + Delete a resource + + IAM permissions required: + - resource-groups:DeleteGroup + - resource-groups:UnGroupResources + """ + client = request.aws_client_factory.resource_groups + client.delete_group(GroupName=request.desired_state["Name"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[ResourceGroupsGroupProperties], + ) -> ProgressEvent[ResourceGroupsGroupProperties]: + """ + Update a resource + + IAM permissions required: + - resource-groups:UpdateGroup + - resource-groups:GetTags + - resource-groups:GetGroupQuery + - resource-groups:UpdateGroupQuery + - resource-groups:Tag + - resource-groups:Untag + - resource-groups:PutGroupConfiguration + - resource-groups:GetGroupConfiguration + - resource-groups:ListGroupResources + - resource-groups:GroupResources + - resource-groups:UnGroupResources + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group.schema.json b/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group.schema.json new file mode 100644 index 0000000000000..1902baa33a0cc --- /dev/null +++ b/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group.schema.json @@ -0,0 +1,209 @@ +{ + "typeName": "AWS::ResourceGroups::Group", + "description": "Schema for ResourceGroups::Group", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": { + "ResourceQuery": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "enum": [ + "TAG_FILTERS_1_0", + "CLOUDFORMATION_STACK_1_0" + ] + }, + "Query": { + "$ref": "#/definitions/Query" + } + }, + "additionalProperties": false + }, + "Query": { + "type": "object", + "properties": { + "ResourceTypeFilters": { + "type": "array", + "items": { + "type": "string" + } + }, + "StackIdentifier": { + "type": "string" + }, + "TagFilters": { + "type": "array", + "items": { + "$ref": "#/definitions/TagFilter" + } + } + }, + "additionalProperties": false + }, + "TagFilter": { + "type": "object", + "properties": { + "Key": { + "type": "string" + }, + "Values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "Tag": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "pattern": "^(?!aws:).+" + }, + "Value": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Configuration": { + "type": "array", + "items": { + "$ref": "#/definitions/ConfigurationItem" + } + }, + "ConfigurationItem": { + "type": "object", + "properties": { + "Type": { + "type": "string" + }, + "Parameters": { + "type": "array", + "items": { + "$ref": "#/definitions/ConfigurationParameter" + } + } + }, + "additionalProperties": false + }, + "ConfigurationParameter": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "properties": { + "Name": { + "description": "The name of the resource group", + "type": "string", + "maxLength": 128 + }, + "Description": { + "description": "The description of the resource group", + "type": "string", + "maxLength": 512 + }, + "ResourceQuery": { + "$ref": "#/definitions/ResourceQuery" + }, + "Tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Arn": { + "description": "The Resource Group ARN.", + "type": "string" + }, + "Configuration": { + "$ref": "#/definitions/Configuration" + }, + "Resources": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "Name" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "resource-groups:CreateGroup", + "resource-groups:Tag", + "cloudformation:DescribeStacks", + "cloudformation:ListStackResources", + "resource-groups:ListGroupResources", + "resource-groups:GroupResources" + ] + }, + "read": { + "permissions": [ + "resource-groups:GetGroup", + "resource-groups:GetGroupQuery", + "resource-groups:GetTags", + "resource-groups:GetGroupConfiguration", + "resource-groups:ListGroupResources" + ] + }, + "update": { + "permissions": [ + "resource-groups:UpdateGroup", + "resource-groups:GetTags", + "resource-groups:GetGroupQuery", + "resource-groups:UpdateGroupQuery", + "resource-groups:Tag", + "resource-groups:Untag", + "resource-groups:PutGroupConfiguration", + "resource-groups:GetGroupConfiguration", + "resource-groups:ListGroupResources", + "resource-groups:GroupResources", + "resource-groups:UnGroupResources" + ] + }, + "delete": { + "permissions": [ + "resource-groups:DeleteGroup", + "resource-groups:UnGroupResources" + ] + }, + "list": { + "permissions": [ + "resource-groups:ListGroups" + ] + } + } +} diff --git a/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group_plugin.py b/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group_plugin.py new file mode 100644 index 0000000000000..99e589abd9722 --- /dev/null +++ b/localstack-core/localstack/services/resource_groups/resource_providers/aws_resourcegroups_group_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class ResourceGroupsGroupProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::ResourceGroups::Group" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.resource_groups.resource_providers.aws_resourcegroups_group import ( + ResourceGroupsGroupProvider, + ) + + self.factory = ResourceGroupsGroupProvider diff --git a/localstack-core/localstack/services/resourcegroupstaggingapi/__init__.py b/localstack-core/localstack/services/resourcegroupstaggingapi/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/resourcegroupstaggingapi/provider.py b/localstack-core/localstack/services/resourcegroupstaggingapi/provider.py new file mode 100644 index 0000000000000..a9535da68eaae --- /dev/null +++ b/localstack-core/localstack/services/resourcegroupstaggingapi/provider.py @@ -0,0 +1,7 @@ +from abc import ABC + +from localstack.aws.api.resourcegroupstaggingapi import ResourcegroupstaggingapiApi + + +class ResourcegroupstaggingapiProvider(ResourcegroupstaggingapiApi, ABC): + pass diff --git a/localstack-core/localstack/services/route53/__init__.py b/localstack-core/localstack/services/route53/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/route53/models.py b/localstack-core/localstack/services/route53/models.py new file mode 100644 index 0000000000000..9bfd65e612d33 --- /dev/null +++ b/localstack-core/localstack/services/route53/models.py @@ -0,0 +1,12 @@ +from typing import Dict + +from localstack.aws.api.route53 import DelegationSet +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + + +class Route53Store(BaseStore): + # maps delegation set ID to reusable delegation set details + reusable_delegation_sets: Dict[str, DelegationSet] = LocalAttribute(default=dict) + + +route53_stores = AccountRegionBundle("route53", Route53Store) diff --git a/localstack-core/localstack/services/route53/provider.py b/localstack-core/localstack/services/route53/provider.py new file mode 100644 index 0000000000000..cdd3650adf274 --- /dev/null +++ b/localstack-core/localstack/services/route53/provider.py @@ -0,0 +1,123 @@ +from datetime import datetime +from typing import Optional + +import moto.route53.models as route53_models +from botocore.exceptions import ClientError +from moto.route53.models import route53_backends + +from localstack.aws.api import RequestContext +from localstack.aws.api.route53 import ( + VPC, + ChangeInfo, + ChangeStatus, + CreateHostedZoneResponse, + DeleteHealthCheckResponse, + DNSName, + GetChangeResponse, + GetHealthCheckResponse, + HealthCheck, + HealthCheckId, + HostedZoneConfig, + InvalidVPCId, + Nonce, + NoSuchHealthCheck, + ResourceId, + Route53Api, +) +from localstack.aws.connect import connect_to +from localstack.services.moto import call_moto +from localstack.services.plugins import ServiceLifecycleHook + + +class Route53Provider(Route53Api, ServiceLifecycleHook): + def create_hosted_zone( + self, + context: RequestContext, + name: DNSName, + caller_reference: Nonce, + vpc: VPC = None, + hosted_zone_config: HostedZoneConfig = None, + delegation_set_id: ResourceId = None, + **kwargs, + ) -> CreateHostedZoneResponse: + # private hosted zones cannot be created in a VPC that does not exist + # check that the VPC exists + if vpc: + vpc_id = vpc.get("VPCId") + vpc_region = vpc.get("VPCRegion") + if not vpc_id or not vpc_region: + raise Exception( + "VPCId and VPCRegion must be specified when creating a private hosted zone" + ) + try: + connect_to( + aws_access_key_id=context.account_id, region_name=vpc_region + ).ec2.describe_vpcs(VpcIds=[vpc_id]) + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "InvalidVpcID.NotFound": + raise InvalidVPCId("The VPC ID is invalid.", sender_fault=True) from e + raise e + + response = call_moto(context) + + # moto does not populate the VPC struct of the response if creating a private hosted zone + if ( + hosted_zone_config + and hosted_zone_config.get("PrivateZone", False) + and "VPC" in response + and vpc + ): + response["VPC"]["VPCId"] = response["VPC"]["VPCId"] or vpc.get("VPCId", "") + response["VPC"]["VPCRegion"] = response["VPC"]["VPCRegion"] or vpc.get("VPCRegion", "") + + return response + + def get_change(self, context: RequestContext, id: ResourceId, **kwargs) -> GetChangeResponse: + change_info = ChangeInfo(Id=id, Status=ChangeStatus.INSYNC, SubmittedAt=datetime.now()) + return GetChangeResponse(ChangeInfo=change_info) + + def get_health_check( + self, context: RequestContext, health_check_id: HealthCheckId, **kwargs + ) -> GetHealthCheckResponse: + health_check: Optional[route53_models.HealthCheck] = route53_backends[context.account_id][ + context.partition + ].health_checks.get(health_check_id, None) + if not health_check: + raise NoSuchHealthCheck( + f"No health check exists with the specified ID {health_check_id}" + ) + health_check_config = { + "Disabled": health_check.disabled, + "EnableSNI": health_check.enable_sni, + "FailureThreshold": health_check.failure_threshold, + "FullyQualifiedDomainName": health_check.fqdn, + "HealthThreshold": health_check.health_threshold, + "Inverted": health_check.inverted, + "IPAddress": health_check.ip_address, + "MeasureLatency": health_check.measure_latency, + "Port": health_check.port, + "RequestInterval": health_check.request_interval, + "ResourcePath": health_check.resource_path, + "Type": health_check.type_, + } + return GetHealthCheckResponse( + HealthCheck=HealthCheck( + Id=health_check.id, + CallerReference=health_check.caller_reference, + HealthCheckConfig=health_check_config, + ) + ) + + def delete_health_check( + self, context: RequestContext, health_check_id: HealthCheckId, **kwargs + ) -> DeleteHealthCheckResponse: + if ( + health_check_id + not in route53_backends[context.account_id][context.partition].health_checks + ): + raise NoSuchHealthCheck( + f"No health check exists with the specified ID {health_check_id}" + ) + + route53_backends[context.account_id][context.partition].delete_health_check(health_check_id) + return {} diff --git a/localstack-core/localstack/services/route53/resource_providers/__init__.py b/localstack-core/localstack/services/route53/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck.py b/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck.py new file mode 100644 index 0000000000000..ddd156b7e638f --- /dev/null +++ b/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck.py @@ -0,0 +1,118 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class Route53HealthCheckProperties(TypedDict): + HealthCheckConfig: Optional[dict] + HealthCheckId: Optional[str] + HealthCheckTags: Optional[list[HealthCheckTag]] + + +class HealthCheckTag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class Route53HealthCheckProvider(ResourceProvider[Route53HealthCheckProperties]): + TYPE = "AWS::Route53::HealthCheck" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[Route53HealthCheckProperties], + ) -> ProgressEvent[Route53HealthCheckProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/HealthCheckId + + Required properties: + - HealthCheckConfig + + Create-only properties: + - /properties/HealthCheckConfig/Type + - /properties/HealthCheckConfig/MeasureLatency + - /properties/HealthCheckConfig/RequestInterval + + Read-only properties: + - /properties/HealthCheckId + + IAM permissions required: + - route53:CreateHealthCheck + - route53:ChangeTagsForResource + - cloudwatch:DescribeAlarms + - route53-recovery-control-config:DescribeRoutingControl + + """ + model = request.desired_state + create_params = util.select_attributes(model, ["HealthCheckConfig", "CallerReference"]) + if not create_params.get("CallerReference"): + create_params["CallerReference"] = util.generate_default_name_without_stack( + request.logical_resource_id + ) + result = request.aws_client_factory.route53.create_health_check(**create_params) + model["HealthCheckId"] = result["HealthCheck"]["Id"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[Route53HealthCheckProperties], + ) -> ProgressEvent[Route53HealthCheckProperties]: + """ + Fetch resource information + + IAM permissions required: + - route53:GetHealthCheck + - route53:ListTagsForResource + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[Route53HealthCheckProperties], + ) -> ProgressEvent[Route53HealthCheckProperties]: + """ + Delete a resource + + IAM permissions required: + - route53:DeleteHealthCheck + """ + model = request.desired_state + request.aws_client_factory.route53.delete_health_check(HealthCheckId=model["HealthCheckId"]) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[Route53HealthCheckProperties], + ) -> ProgressEvent[Route53HealthCheckProperties]: + """ + Update a resource + + IAM permissions required: + - route53:UpdateHealthCheck + - route53:ChangeTagsForResource + - route53:ListTagsForResource + - cloudwatch:DescribeAlarms + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck.schema.json b/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck.schema.json new file mode 100644 index 0000000000000..2033fc1ca1a15 --- /dev/null +++ b/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck.schema.json @@ -0,0 +1,215 @@ +{ + "typeName": "AWS::Route53::HealthCheck", + "description": "Resource schema for AWS::Route53::HealthCheck.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-route53.git", + "definitions": { + "AlarmIdentifier": { + "description": "A complex type that identifies the CloudWatch alarm that you want Amazon Route 53 health checkers to use to determine whether the specified health check is healthy.", + "type": "object", + "additionalProperties": false, + "properties": { + "Name": { + "description": "The name of the CloudWatch alarm that you want Amazon Route 53 health checkers to use to determine whether this health check is healthy.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "Region": { + "description": "For the CloudWatch alarm that you want Route 53 health checkers to use to determine whether this health check is healthy, the region that the alarm was created in.", + "type": "string" + } + }, + "required": [ + "Name", + "Region" + ] + }, + "HealthCheckTag": { + "description": "A key-value pair to associate with a resource.", + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag.", + "maxLength": 128 + }, + "Value": { + "type": "string", + "description": "The value for the tag.", + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "properties": { + "HealthCheckId": { + "type": "string" + }, + "HealthCheckConfig": { + "description": "A complex type that contains information about the health check.", + "type": "object", + "properties": { + "AlarmIdentifier": { + "$ref": "#/definitions/AlarmIdentifier" + }, + "ChildHealthChecks": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 256, + "insertionOrder": false + }, + "EnableSNI": { + "type": "boolean" + }, + "FailureThreshold": { + "type": "integer", + "minimum": 1, + "maximum": 10 + }, + "FullyQualifiedDomainName": { + "type": "string", + "maxLength": 255 + }, + "HealthThreshold": { + "type": "integer", + "minimum": 0, + "maximum": 256 + }, + "InsufficientDataHealthStatus": { + "type": "string", + "enum": [ + "Healthy", + "LastKnownStatus", + "Unhealthy" + ] + }, + "Inverted": { + "type": "boolean" + }, + "IPAddress": { + "type": "string", + "maxLength": 45, + "pattern": "^((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))$|^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$" + }, + "MeasureLatency": { + "type": "boolean" + }, + "Port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "Regions": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 64, + "insertionOrder": false + }, + "RequestInterval": { + "type": "integer", + "minimum": 10, + "maximum": 30 + }, + "ResourcePath": { + "type": "string", + "maxLength": 255 + }, + "SearchString": { + "type": "string", + "maxLength": 255 + }, + "RoutingControlArn": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "Type": { + "type": "string", + "enum": [ + "CALCULATED", + "CLOUDWATCH_METRIC", + "HTTP", + "HTTP_STR_MATCH", + "HTTPS", + "HTTPS_STR_MATCH", + "TCP", + "RECOVERY_CONTROL" + ] + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + }, + "HealthCheckTags": { + "type": "array", + "uniqueItems": true, + "insertionOrder": false, + "description": "An array of key-value pairs to apply to this resource.", + "items": { + "$ref": "#/definitions/HealthCheckTag" + } + } + }, + "additionalProperties": false, + "required": [ + "HealthCheckConfig" + ], + "createOnlyProperties": [ + "/properties/HealthCheckConfig/Type", + "/properties/HealthCheckConfig/MeasureLatency", + "/properties/HealthCheckConfig/RequestInterval" + ], + "readOnlyProperties": [ + "/properties/HealthCheckId" + ], + "primaryIdentifier": [ + "/properties/HealthCheckId" + ], + "handlers": { + "create": { + "permissions": [ + "route53:CreateHealthCheck", + "route53:ChangeTagsForResource", + "cloudwatch:DescribeAlarms", + "route53-recovery-control-config:DescribeRoutingControl" + ] + }, + "read": { + "permissions": [ + "route53:GetHealthCheck", + "route53:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "route53:UpdateHealthCheck", + "route53:ChangeTagsForResource", + "route53:ListTagsForResource", + "cloudwatch:DescribeAlarms" + ] + }, + "delete": { + "permissions": [ + "route53:DeleteHealthCheck" + ] + }, + "list": { + "permissions": [ + "route53:ListHealthChecks", + "route53:ListTagsForResource" + ] + } + }, + "taggable": true +} diff --git a/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck_plugin.py b/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck_plugin.py new file mode 100644 index 0000000000000..7a8e244561cf8 --- /dev/null +++ b/localstack-core/localstack/services/route53/resource_providers/aws_route53_healthcheck_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class Route53HealthCheckProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Route53::HealthCheck" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.route53.resource_providers.aws_route53_healthcheck import ( + Route53HealthCheckProvider, + ) + + self.factory = Route53HealthCheckProvider diff --git a/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset.py b/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset.py new file mode 100644 index 0000000000000..c3d0e3866e14c --- /dev/null +++ b/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset.py @@ -0,0 +1,213 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Optional, TypedDict + +if TYPE_CHECKING: + from mypy_boto3_route53 import Route53Client + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class Route53RecordSetProperties(TypedDict): + Name: Optional[str] + Type: Optional[str] + AliasTarget: Optional[AliasTarget] + CidrRoutingConfig: Optional[CidrRoutingConfig] + Comment: Optional[str] + Failover: Optional[str] + GeoLocation: Optional[GeoLocation] + HealthCheckId: Optional[str] + HostedZoneId: Optional[str] + HostedZoneName: Optional[str] + Id: Optional[str] + MultiValueAnswer: Optional[bool] + Region: Optional[str] + ResourceRecords: Optional[list[str]] + SetIdentifier: Optional[str] + TTL: Optional[str] + Weight: Optional[int] + + +class AliasTarget(TypedDict): + DNSName: Optional[str] + HostedZoneId: Optional[str] + EvaluateTargetHealth: Optional[bool] + + +class CidrRoutingConfig(TypedDict): + CollectionId: Optional[str] + LocationName: Optional[str] + + +class GeoLocation(TypedDict): + ContinentCode: Optional[str] + CountryCode: Optional[str] + SubdivisionCode: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class Route53RecordSetProvider(ResourceProvider[Route53RecordSetProperties]): + TYPE = "AWS::Route53::RecordSet" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[Route53RecordSetProperties], + ) -> ProgressEvent[Route53RecordSetProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - Type + - Name + + Create-only properties: + - /properties/HostedZoneName + - /properties/Name + - /properties/HostedZoneId + + Read-only properties: + - /properties/Id + """ + model = request.desired_state + route53 = request.aws_client_factory.route53 + + if not model.get("HostedZoneId"): + # if only name was provided for hosted zone + hosted_zone_name = model.get("HostedZoneName") + hosted_zone_id = self.get_hosted_zone_id_from_name(hosted_zone_name, route53) + model["HostedZoneId"] = hosted_zone_id + + attr_names = [ + "Name", + "Type", + "SetIdentifier", + "Weight", + "Region", + "GeoLocation", + "Failover", + "MultiValueAnswer", + "TTL", + "ResourceRecords", + "AliasTarget", + "HealthCheckId", + ] + attrs = util.select_attributes(model, attr_names) + + if "AliasTarget" in attrs: + # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-route53-recordset-aliastarget.html + if "EvaluateTargetHealth" not in attrs["AliasTarget"]: + attrs["AliasTarget"]["EvaluateTargetHealth"] = False + else: + # TODO: CNAME & SOA only allow 1 record type. should we check that here? + attrs["ResourceRecords"] = [{"Value": record} for record in attrs["ResourceRecords"]] + + if "TTL" in attrs: + if isinstance(attrs["TTL"], str): + attrs["TTL"] = int(attrs["TTL"]) + + route53.change_resource_record_sets( + HostedZoneId=model["HostedZoneId"], + ChangeBatch={ + "Changes": [ + { + "Action": "UPSERT", + "ResourceRecordSet": attrs, + }, + ] + }, + ) + # TODO: not 100% sure this behaves the same between alias and non-alias records + model["Id"] = model["Name"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def get_hosted_zone_id_from_name(self, hosted_zone_name: str, client: "Route53Client"): + if not hosted_zone_name: + raise Exception("Either HostedZoneId or HostedZoneName must be present.") + + zones = client.list_hosted_zones_by_name(DNSName=hosted_zone_name)["HostedZones"] + if len(zones) != 1: + raise Exception(f"Ambiguous HostedZoneName {hosted_zone_name} provided.") + + hosted_zone_id = zones[0]["Id"] + return hosted_zone_id + + def read( + self, + request: ResourceRequest[Route53RecordSetProperties], + ) -> ProgressEvent[Route53RecordSetProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[Route53RecordSetProperties], + ) -> ProgressEvent[Route53RecordSetProperties]: + """ + Delete a resource + + + """ + model = request.previous_state + route53 = request.aws_client_factory.route53 + rrset_kwargs = { + "Name": model["Name"], + "Type": model["Type"], + } + + if "AliasTarget" in model: + rrset_kwargs["AliasTarget"] = model["AliasTarget"] + if "ResourceRecords" in model: + rrset_kwargs["ResourceRecords"] = [ + {"Value": record} for record in model["ResourceRecords"] + ] + if "TTL" in model: + rrset_kwargs["TTL"] = int(model["TTL"]) + + route53.change_resource_record_sets( + HostedZoneId=model["HostedZoneId"], + ChangeBatch={ + "Changes": [ + { + "Action": "DELETE", + "ResourceRecordSet": rrset_kwargs, + }, + ] + }, + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def update( + self, + request: ResourceRequest[Route53RecordSetProperties], + ) -> ProgressEvent[Route53RecordSetProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset.schema.json b/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset.schema.json new file mode 100644 index 0000000000000..300099a7f978c --- /dev/null +++ b/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset.schema.json @@ -0,0 +1,129 @@ +{ + "typeName": "AWS::Route53::RecordSet", + "description": "Resource Type definition for AWS::Route53::RecordSet", + "additionalProperties": false, + "properties": { + "HealthCheckId": { + "type": "string" + }, + "AliasTarget": { + "$ref": "#/definitions/AliasTarget" + }, + "Comment": { + "type": "string" + }, + "HostedZoneName": { + "type": "string" + }, + "ResourceRecords": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "HostedZoneId": { + "type": "string" + }, + "SetIdentifier": { + "type": "string" + }, + "TTL": { + "type": "string" + }, + "Weight": { + "type": "integer" + }, + "Name": { + "type": "string" + }, + "Type": { + "type": "string" + }, + "CidrRoutingConfig": { + "$ref": "#/definitions/CidrRoutingConfig" + }, + "Failover": { + "type": "string" + }, + "Region": { + "type": "string" + }, + "GeoLocation": { + "$ref": "#/definitions/GeoLocation" + }, + "Id": { + "type": "string" + }, + "MultiValueAnswer": { + "type": "boolean" + } + }, + "definitions": { + "AliasTarget": { + "type": "object", + "additionalProperties": false, + "properties": { + "DNSName": { + "type": "string" + }, + "HostedZoneId": { + "type": "string" + }, + "EvaluateTargetHealth": { + "type": "boolean" + } + }, + "required": [ + "HostedZoneId", + "DNSName" + ] + }, + "CidrRoutingConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "CollectionId": { + "type": "string" + }, + "LocationName": { + "type": "string" + } + }, + "required": [ + "CollectionId", + "LocationName" + ] + }, + "GeoLocation": { + "type": "object", + "additionalProperties": false, + "properties": { + "ContinentCode": { + "type": "string" + }, + "CountryCode": { + "type": "string" + }, + "SubdivisionCode": { + "type": "string" + } + } + } + }, + "required": [ + "Type", + "Name" + ], + "createOnlyProperties": [ + "/properties/HostedZoneName", + "/properties/Name", + "/properties/HostedZoneId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset_plugin.py b/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset_plugin.py new file mode 100644 index 0000000000000..bdecf6996b78d --- /dev/null +++ b/localstack-core/localstack/services/route53/resource_providers/aws_route53_recordset_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class Route53RecordSetProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Route53::RecordSet" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.route53.resource_providers.aws_route53_recordset import ( + Route53RecordSetProvider, + ) + + self.factory = Route53RecordSetProvider diff --git a/localstack-core/localstack/services/route53resolver/__init__.py b/localstack-core/localstack/services/route53resolver/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/route53resolver/models.py b/localstack-core/localstack/services/route53resolver/models.py new file mode 100644 index 0000000000000..57c5436bab568 --- /dev/null +++ b/localstack-core/localstack/services/route53resolver/models.py @@ -0,0 +1,180 @@ +from typing import Dict + +import localstack.services.route53resolver.utils +from localstack.aws.api.route53resolver import ( + FirewallConfig, + FirewallDomainList, + FirewallDomains, + FirewallRule, + FirewallRuleGroup, + FirewallRuleGroupAssociation, + ResolverQueryLogConfig, + ResolverQueryLogConfigAssociation, + ResolverQueryLogConfigStatus, + ResourceNotFoundException, +) +from localstack.services.route53resolver.utils import get_firewall_config_id, validate_vpc +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + + +class Route53ResolverStore(BaseStore): + firewall_configs: Dict[str, FirewallConfig] = LocalAttribute(default=dict) + firewall_domain_lists: Dict[str, FirewallDomainList] = LocalAttribute(default=dict) + firewall_domains: Dict[str, FirewallDomains] = LocalAttribute(default=dict) + firewall_rules: Dict[str, FirewallRule] = LocalAttribute(default=dict) + firewall_rule_groups: Dict[str, FirewallRuleGroup] = LocalAttribute(default=dict) + firewall_rule_group_associations: Dict[str, FirewallRuleGroupAssociation] = LocalAttribute( + default=dict + ) + resolver_query_log_configs: Dict[str, ResolverQueryLogConfig] = LocalAttribute(default=dict) + resolver_query_log_config_associations: Dict[str, ResolverQueryLogConfigAssociation] = ( + LocalAttribute(default=dict) + ) + + def get_firewall_rule_group(self, id): + """returns firewall rule group with the given id if it exists""" + + firewall_rule_group = self.firewall_rule_groups.get(id) + if not firewall_rule_group: + raise ResourceNotFoundException( + f"Can't find the resource with ID '{id}'. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + return firewall_rule_group + + def delete_firewall_rule_group(self, id): + """deletes the firewall rule group with the given id""" + # if firewall_rule_groups doesn't exist it will throw an error + + firewall_rule_group = self.get_firewall_rule_group(id) + self.firewall_rule_groups.pop(id) + return firewall_rule_group + + def get_firewall_rule_group_association(self, id): + """returns firewall rule group association with the given id if it exists""" + + firewall_rule_group_association = self.firewall_rule_group_associations.get(id) + if not firewall_rule_group_association: + raise ResourceNotFoundException( + f"[RSLVR-02025] Can't find the resource with ID '{id}'. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + return self.firewall_rule_group_associations.get(id) + + def delete_firewall_rule_group_association(self, id): + """deletes the firewall rule group association with the given id""" + # if firewall_rule_group_associations doesn't exist it will throw an error + + firewall_rule_group_associations = self.get_firewall_rule_group_association(id) + self.firewall_rule_group_associations.pop(id) + return firewall_rule_group_associations + + def get_firewall_domain(self, d): + """returns firewall domain with the given id if it exists""" + # firewall_domain can return none + + firewall_domain = self.firewall_domains.get(id) + return firewall_domain + + def get_firewall_domain_list(self, id): + """returns firewall domain list with the given id if it exists""" + + firewall_domain_list = self.firewall_domain_lists.get(id) + if not firewall_domain_list: + raise ResourceNotFoundException( + f"Can't find the resource with ID '{id}'. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + return firewall_domain_list + + def delete_firewall_domain_list(self, id): + """deletes the firewall domain list with the given id""" + # if firewall_domain_lists doesn't exist it will throw an error + + firewall_domain_list = self.get_firewall_domain_list(id) + self.firewall_domain_lists.pop(id) + return firewall_domain_list + + def get_firewall_rule(self, firewall_rule_group_id, firewall_domain_list_id): + """returns firewall rule with the given id if it exists""" + + firewall_rule = self.firewall_rules.get(firewall_rule_group_id, {}).get( + firewall_domain_list_id + ) + if not firewall_rule: + raise ResourceNotFoundException( + f"Can't find the resource with ID '{firewall_rule_group_id}'. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + return firewall_rule + + def delete_firewall_rule(self, firewall_rule_group_id, firewall_domain_list_id): + """deletes the firewall rule with the given id""" + # if firewall_rules doesn't exist it will throw an error + + firewall_rule = self.get_firewall_rule(firewall_rule_group_id, firewall_domain_list_id) + self.firewall_rules.get(firewall_rule_group_id, {}).pop(firewall_domain_list_id) + return firewall_rule + + def get_resolver_query_log_config(self, id): + """returns resolver query log config with the given id if it exists""" + + resolver_query_log_config = self.resolver_query_log_configs.get(id) + if not resolver_query_log_config: + raise ResourceNotFoundException( + f"[RSLVR-01601] The specified query logging configuration doesn't exist. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + return resolver_query_log_config + + def delete_resolver_query_log_config(self, id): + """deletes the resolver query log config with the given id""" + + self.get_resolver_query_log_config(id) + resolver_query_log_config = self.resolver_query_log_configs.pop(id) + resolver_query_log_config["Status"] = ResolverQueryLogConfigStatus.DELETING + return resolver_query_log_config + + def get_resolver_query_log_config_associations(self, id): + """returns resolver query log config association with the given id if it exists""" + + resolver_query_log_config_association = self.resolver_query_log_config_associations.get(id) + if not resolver_query_log_config_association: + raise ResourceNotFoundException( + f"[RSLVR-01601] The specified query logging configuration doesn't exist. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + return resolver_query_log_config_association + + def delete_resolver_query_log_config_associations( + self, resolver_query_log_config_id, resource_id + ): + """deletes the resolver query log config association with the given id and vpc id""" + + association_id = None + for association in self.resolver_query_log_config_associations.values(): + if not ( + association.get("ResolverQueryLogConfigId") == resolver_query_log_config_id + and association.get("ResourceId") == resource_id + ): + raise ResourceNotFoundException( + f"[RSLVR-01602] The specified query logging configuration association doesn't exist. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + association["Status"] = "DELETING" + association_id = association.get("Id") + return self.resolver_query_log_config_associations.pop(association_id) + + def get_or_create_firewall_config(self, resource_id: str, region: str, account_id: str): + """returns the firewall config with the given id if it exists or creates a new one""" + + validate_vpc(resource_id, region, account_id) + firewall_config: FirewallConfig + if self.firewall_configs.get(resource_id): + firewall_config = self.firewall_configs[resource_id] + else: + id = get_firewall_config_id() + firewall_config = FirewallConfig( + Id=id, + ResourceId=resource_id, + OwnerId=account_id, + FirewallFailOpen="DISABLED", + ) + self.firewall_configs[resource_id] = firewall_config + return firewall_config + + +route53resolver_stores = AccountRegionBundle("route53resolver", Route53ResolverStore) diff --git a/localstack-core/localstack/services/route53resolver/provider.py b/localstack-core/localstack/services/route53resolver/provider.py new file mode 100644 index 0000000000000..e002748d9aa17 --- /dev/null +++ b/localstack-core/localstack/services/route53resolver/provider.py @@ -0,0 +1,844 @@ +from datetime import datetime, timezone + +from moto.route53resolver.models import Route53ResolverBackend as MotoRoute53ResolverBackend +from moto.route53resolver.models import route53resolver_backends + +import localstack.services.route53resolver.utils +from localstack.aws.api import RequestContext +from localstack.aws.api.route53resolver import ( + Action, + AssociateFirewallRuleGroupResponse, + AssociateResolverQueryLogConfigResponse, + BlockOverrideDnsType, + BlockOverrideDomain, + BlockOverrideTtl, + BlockResponse, + ConfidenceThreshold, + CreateFirewallDomainListResponse, + CreateFirewallRuleGroupResponse, + CreateFirewallRuleResponse, + CreateResolverEndpointResponse, + CreateResolverQueryLogConfigResponse, + CreatorRequestId, + DeleteFirewallDomainListResponse, + DeleteFirewallRuleGroupResponse, + DeleteFirewallRuleResponse, + DeleteResolverQueryLogConfigResponse, + DestinationArn, + DisassociateFirewallRuleGroupResponse, + DisassociateResolverQueryLogConfigResponse, + DnsThreatProtection, + Filters, + FirewallConfig, + FirewallDomainList, + FirewallDomainListMetadata, + FirewallDomainName, + FirewallDomainRedirectionAction, + FirewallDomains, + FirewallDomainUpdateOperation, + FirewallFailOpenStatus, + FirewallRule, + FirewallRuleGroup, + FirewallRuleGroupAssociation, + FirewallRuleGroupMetadata, + GetFirewallConfigResponse, + GetFirewallDomainListResponse, + GetFirewallRuleGroupAssociationResponse, + GetFirewallRuleGroupResponse, + GetResolverQueryLogConfigAssociationResponse, + GetResolverQueryLogConfigResponse, + InvalidParameterException, + InvalidRequestException, + IpAddressesRequest, + ListDomainMaxResults, + ListFirewallConfigsMaxResult, + ListFirewallConfigsResponse, + ListFirewallDomainListsResponse, + ListFirewallDomainsResponse, + ListFirewallRuleGroupsResponse, + ListFirewallRulesResponse, + ListResolverQueryLogConfigAssociationsResponse, + ListResolverQueryLogConfigsResponse, + MaxResults, + MutationProtectionStatus, + Name, + NextToken, + OutpostArn, + OutpostInstanceType, + Priority, + ProtocolList, + Qtype, + ResolverEndpointDirection, + ResolverEndpointType, + ResolverQueryLogConfig, + ResolverQueryLogConfigAssociation, + ResolverQueryLogConfigName, + ResolverQueryLogConfigStatus, + ResourceId, + ResourceNotFoundException, + Route53ResolverApi, + SecurityGroupIds, + SortByKey, + SortOrder, + TagList, + UpdateFirewallConfigResponse, + UpdateFirewallDomainsResponse, + UpdateFirewallRuleGroupAssociationResponse, + UpdateFirewallRuleResponse, + ValidationException, +) +from localstack.services.ec2.models import get_ec2_backend +from localstack.services.moto import call_moto +from localstack.services.route53resolver.models import Route53ResolverStore, route53resolver_stores +from localstack.services.route53resolver.utils import ( + get_resolver_query_log_config_id, + get_route53_resolver_firewall_domain_list_id, + get_route53_resolver_firewall_rule_group_association_id, + get_route53_resolver_firewall_rule_group_id, + get_route53_resolver_query_log_config_association_id, + validate_destination_arn, + validate_mutation_protection, + validate_priority, +) +from localstack.utils.aws import arns +from localstack.utils.aws.arns import extract_account_id_from_arn, extract_region_from_arn +from localstack.utils.collections import select_from_typed_dict +from localstack.utils.patch import patch + + +class Route53ResolverProvider(Route53ResolverApi): + @staticmethod + def get_store(account_id: str, region: str) -> Route53ResolverStore: + return route53resolver_stores[account_id][region] + + def create_firewall_rule_group( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + name: Name, + tags: TagList = None, + **kwargs, + ) -> CreateFirewallRuleGroupResponse: + """Create a Firewall Rule Group.""" + store = self.get_store(context.account_id, context.region) + firewall_rule_group_id = get_route53_resolver_firewall_rule_group_id() + arn = arns.route53_resolver_firewall_rule_group_arn( + firewall_rule_group_id, context.account_id, context.region + ) + firewall_rule_group = FirewallRuleGroup( + Id=firewall_rule_group_id, + Arn=arn, + Name=name, + RuleCount=0, + Status="COMPLETE", + OwnerId=context.account_id, + ShareStatus="NOT_SHARED", + StatusMessage="Created Firewall Rule Group", + CreatorRequestId=creator_request_id, + CreationTime=datetime.now(timezone.utc).isoformat(), + ModificationTime=datetime.now(timezone.utc).isoformat(), + ) + store.firewall_rule_groups[firewall_rule_group_id] = firewall_rule_group + store.firewall_rules[firewall_rule_group_id] = {} + route53resolver_backends[context.account_id][context.region].tagger.tag_resource( + arn, tags or [] + ) + return CreateFirewallRuleGroupResponse(FirewallRuleGroup=firewall_rule_group) + + def delete_firewall_rule_group( + self, context: RequestContext, firewall_rule_group_id: ResourceId, **kwargs + ) -> DeleteFirewallRuleGroupResponse: + """Delete a Firewall Rule Group.""" + store = self.get_store(context.account_id, context.region) + firewall_rule_group: FirewallRuleGroup = store.delete_firewall_rule_group( + firewall_rule_group_id + ) + return DeleteFirewallRuleGroupResponse(FirewallRuleGroup=firewall_rule_group) + + def get_firewall_rule_group( + self, context: RequestContext, firewall_rule_group_id: ResourceId, **kwargs + ) -> GetFirewallRuleGroupResponse: + """Get the details of a Firewall Rule Group.""" + store = self.get_store(context.account_id, context.region) + firewall_rule_group: FirewallRuleGroup = store.get_firewall_rule_group( + firewall_rule_group_id + ) + return GetFirewallRuleGroupResponse(FirewallRuleGroup=firewall_rule_group) + + def list_firewall_rule_groups( + self, + context: RequestContext, + max_results: MaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> ListFirewallRuleGroupsResponse: + """List Firewall Rule Groups.""" + store = self.get_store(context.account_id, context.region) + firewall_rule_groups = [] + for firewall_rule_group in store.firewall_rule_groups.values(): + firewall_rule_groups.append( + select_from_typed_dict(FirewallRuleGroupMetadata, firewall_rule_group) + ) + return ListFirewallRuleGroupsResponse(FirewallRuleGroups=firewall_rule_groups) + + def create_firewall_domain_list( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + name: Name, + tags: TagList = None, + **kwargs, + ) -> CreateFirewallDomainListResponse: + """Create a Firewall Domain List.""" + store = self.get_store(context.account_id, context.region) + id = get_route53_resolver_firewall_domain_list_id() + arn = arns.route53_resolver_firewall_domain_list_arn(id, context.account_id, context.region) + firewall_domain_list = FirewallDomainList( + Id=id, + Arn=arn, + Name=name, + DomainCount=0, + Status="COMPLETE", + StatusMessage="Created Firewall Domain List", + ManagedOwnerName=context.account_id, + CreatorRequestId=creator_request_id, + CreationTime=datetime.now(timezone.utc).isoformat(), + ModificationTime=datetime.now(timezone.utc).isoformat(), + ) + store.firewall_domain_lists[id] = firewall_domain_list + route53resolver_backends[context.account_id][context.region].tagger.tag_resource( + arn, tags or [] + ) + return CreateFirewallDomainListResponse(FirewallDomainList=firewall_domain_list) + + def delete_firewall_domain_list( + self, context: RequestContext, firewall_domain_list_id: ResourceId, **kwargs + ) -> DeleteFirewallDomainListResponse: + """Delete a Firewall Domain List.""" + store = self.get_store(context.account_id, context.region) + firewall_domain_list: FirewallDomainList = store.delete_firewall_domain_list( + firewall_domain_list_id + ) + return DeleteFirewallDomainListResponse(FirewallDomainList=firewall_domain_list) + + def get_firewall_domain_list( + self, context: RequestContext, firewall_domain_list_id: ResourceId, **kwargs + ) -> GetFirewallDomainListResponse: + """Get the details of a Firewall Domain List.""" + store = self.get_store(context.account_id, context.region) + firewall_domain_list: FirewallDomainList = store.get_firewall_domain_list( + firewall_domain_list_id + ) + return GetFirewallDomainListResponse(FirewallDomainList=firewall_domain_list) + + def list_firewall_domain_lists( + self, + context: RequestContext, + max_results: MaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> ListFirewallDomainListsResponse: + """List all Firewall Domain Lists.""" + store = self.get_store(context.account_id, context.region) + firewall_domain_lists = [] + for firewall_domain_list in store.firewall_domain_lists.values(): + firewall_domain_lists.append( + select_from_typed_dict(FirewallDomainListMetadata, firewall_domain_list) + ) + return ListFirewallDomainListsResponse(FirewallDomainLists=firewall_domain_lists) + + def update_firewall_domains( + self, + context: RequestContext, + firewall_domain_list_id: ResourceId, + operation: FirewallDomainUpdateOperation, + domains: FirewallDomains, + **kwargs, + ) -> UpdateFirewallDomainsResponse: + """Update the domains in a Firewall Domain List.""" + store = self.get_store(context.account_id, context.region) + + firewall_domain_list: FirewallDomainList = store.get_firewall_domain_list( + firewall_domain_list_id + ) + firewall_domains = store.get_firewall_domain(firewall_domain_list_id) + + if operation == FirewallDomainUpdateOperation.ADD: + if not firewall_domains: + store.firewall_domains[firewall_domain_list_id] = domains + else: + store.firewall_domains[firewall_domain_list_id].append(domains) + + if operation == FirewallDomainUpdateOperation.REMOVE: + if firewall_domains: + for domain in domains: + if domain in firewall_domains: + firewall_domains.remove(domain) + else: + raise ValidationException( + f"[RSLVR-02502] The following domains don't exist in the DNS Firewall domain list '{firewall_domain_list_id}'. You can't delete a domain that isn't in a domain list. Example unknown domain: '{domain}'. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + + if operation == FirewallDomainUpdateOperation.REPLACE: + store.firewall_domains[firewall_domain_list_id] = domains + + firewall_domain_list["StatusMessage"] = "Finished domain list update" + firewall_domain_list["ModificationTime"] = datetime.now(timezone.utc).isoformat() + return UpdateFirewallDomainsResponse( + Id=firewall_domain_list.get("Id"), + Name=firewall_domain_list.get("Name"), + Status=firewall_domain_list.get("Status"), + StatusMessage=firewall_domain_list.get("StatusMessage"), + ) + + def list_firewall_domains( + self, + context: RequestContext, + firewall_domain_list_id: ResourceId, + max_results: ListDomainMaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> ListFirewallDomainsResponse: + """List the domains in a DNS Firewall domain list.""" + store = self.get_store(context.account_id, context.region) + firewall_domains: FirewallDomains[FirewallDomainName] = [] + if store.firewall_domains.get(firewall_domain_list_id): + for firewall_domain in store.firewall_domains.get(firewall_domain_list_id): + firewall_domains.append(FirewallDomainName(firewall_domain)) + return ListFirewallDomainsResponse(Domains=firewall_domains) + + def create_firewall_rule( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + firewall_rule_group_id: ResourceId, + priority: Priority, + action: Action, + name: Name, + firewall_domain_list_id: ResourceId = None, + block_response: BlockResponse = None, + block_override_domain: BlockOverrideDomain = None, + block_override_dns_type: BlockOverrideDnsType = None, + block_override_ttl: BlockOverrideTtl = None, + firewall_domain_redirection_action: FirewallDomainRedirectionAction = None, + qtype: Qtype = None, + dns_threat_protection: DnsThreatProtection = None, + confidence_threshold: ConfidenceThreshold = None, + **kwargs, + ) -> CreateFirewallRuleResponse: + """Create a new firewall rule""" + # TODO add support for firewall_domain_list_id, dns_threat_protection, and confidence_threshold + store = self.get_store(context.account_id, context.region) + firewall_rule = FirewallRule( + FirewallRuleGroupId=firewall_rule_group_id, + FirewallDomainListId=firewall_domain_list_id, + Name=name, + Priority=priority, + Action=action, + BlockResponse=block_response, + BlockOverrideDomain=block_override_domain, + BlockOverrideDnsType=block_override_dns_type, + BlockOverrideTtl=block_override_ttl, + CreatorRequestId=creator_request_id, + CreationTime=datetime.now(timezone.utc).isoformat(), + ModificationTime=datetime.now(timezone.utc).isoformat(), + FirewallDomainRedirectionAction=firewall_domain_redirection_action, + Qtype=qtype, + ) + if firewall_rule_group_id in store.firewall_rules: + store.firewall_rules[firewall_rule_group_id][firewall_domain_list_id] = firewall_rule + # TODO: handle missing firewall-rule-group-id + return CreateFirewallRuleResponse(FirewallRule=firewall_rule) + + def delete_firewall_rule( + self, + context: RequestContext, + firewall_rule_group_id: ResourceId, + firewall_domain_list_id: ResourceId = None, + firewall_threat_protection_id: ResourceId = None, + qtype: Qtype = None, + **kwargs, + ) -> DeleteFirewallRuleResponse: + """Delete a firewall rule""" + store = self.get_store(context.account_id, context.region) + firewall_rule: FirewallRule = store.delete_firewall_rule( + firewall_rule_group_id, firewall_domain_list_id + ) + return DeleteFirewallRuleResponse( + FirewallRule=firewall_rule, + ) + + def list_firewall_rules( + self, + context: RequestContext, + firewall_rule_group_id: ResourceId, + priority: Priority = None, + action: Action = None, + max_results: MaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> ListFirewallRulesResponse: + """List firewall rules in a firewall rule group. + + Rules will be filtered by priority and action if values for these params are provided. + + Raises: + ResourceNotFound: If a firewall group by the provided id does not exist. + """ + store = self.get_store(context.account_id, context.region) + firewall_rule_group = store.firewall_rules.get(firewall_rule_group_id) + if firewall_rule_group is None: + raise ResourceNotFoundException( + f"Can't find the resource with ID '{firewall_rule_group_id}'. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + + firewall_rules = [ + FirewallRule(rule) + for rule in firewall_rule_group.values() + if (action is None or action == rule["Action"]) + and (priority is None or priority == rule["Priority"]) + ] + + # TODO: implement max_results filtering and next_token handling + return ListFirewallRulesResponse(FirewallRules=firewall_rules) + + def update_firewall_rule( + self, + context: RequestContext, + firewall_rule_group_id: ResourceId, + firewall_domain_list_id: ResourceId = None, + firewall_threat_protection_id: ResourceId = None, + priority: Priority = None, + action: Action = None, + block_response: BlockResponse = None, + block_override_domain: BlockOverrideDomain = None, + block_override_dns_type: BlockOverrideDnsType = None, + block_override_ttl: BlockOverrideTtl = None, + name: Name = None, + firewall_domain_redirection_action: FirewallDomainRedirectionAction = None, + qtype: Qtype = None, + dns_threat_protection: DnsThreatProtection = None, + confidence_threshold: ConfidenceThreshold = None, + **kwargs, + ) -> UpdateFirewallRuleResponse: + """Updates a firewall rule""" + store = self.get_store(context.account_id, context.region) + firewall_rule: FirewallRule = store.get_firewall_rule( + firewall_rule_group_id, firewall_domain_list_id + ) + + if priority: + firewall_rule["Priority"] = priority + if action: + firewall_rule["Action"] = action + if block_response: + firewall_rule["BlockResponse"] = block_response + if block_override_domain: + firewall_rule["BlockOverrideDomain"] = block_override_domain + if block_override_dns_type: + firewall_rule["BlockOverrideDnsType"] = block_override_dns_type + if block_override_ttl: + firewall_rule["BlockOverrideTtl"] = block_override_ttl + if name: + firewall_rule["Name"] = name + if firewall_domain_redirection_action: + firewall_rule["FirewallDomainRedirectionAction"] = firewall_domain_redirection_action + if qtype: + firewall_rule["Qtype"] = qtype + return UpdateFirewallRuleResponse( + FirewallRule=firewall_rule, + ) + + def associate_firewall_rule_group( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + firewall_rule_group_id: ResourceId, + vpc_id: ResourceId, + priority: Priority, + name: Name, + mutation_protection: MutationProtectionStatus = None, + tags: TagList = None, + **kwargs, + ) -> AssociateFirewallRuleGroupResponse: + """Associate a firewall rule group with a VPC.""" + store = self.get_store(context.account_id, context.region) + validate_priority(priority=priority) + validate_mutation_protection(mutation_protection=mutation_protection) + + for firewall_rule_group_association in store.firewall_rule_group_associations.values(): + if ( + firewall_rule_group_association.get("VpcId") == vpc_id + and firewall_rule_group_association.get("FirewallRuleGroupId") + == firewall_rule_group_id + ): + raise ValidationException( + f"[RSLVR-02302] This DNS Firewall rule group can't be associated to a VPC: '{vpc_id}'. It is already associated to VPC '{firewall_rule_group_id}'. Try again with another VPC or DNS Firewall rule group. Trace Id: '{localstack.services.route53resolver.utils.get_trace_id()}'" + ) + + id = get_route53_resolver_firewall_rule_group_association_id() + arn = arns.route53_resolver_firewall_rule_group_associations_arn( + id, context.account_id, context.region + ) + + firewall_rule_group_association = FirewallRuleGroupAssociation( + Id=id, + Arn=arn, + FirewallRuleGroupId=firewall_rule_group_id, + VpcId=vpc_id, + Name=name, + Priority=priority, + MutationProtection=mutation_protection or "DISABLED", + Status="COMPLETE", + StatusMessage="Creating Firewall Rule Group Association", + CreatorRequestId=creator_request_id, + CreationTime=datetime.now(timezone.utc).isoformat(), + ModificationTime=datetime.now(timezone.utc).isoformat(), + ) + store.firewall_rule_group_associations[id] = firewall_rule_group_association + route53resolver_backends[context.account_id][context.region].tagger.tag_resource( + arn, tags or [] + ) + return AssociateFirewallRuleGroupResponse( + FirewallRuleGroupAssociation=firewall_rule_group_association + ) + + def disassociate_firewall_rule_group( + self, context: RequestContext, firewall_rule_group_association_id: ResourceId, **kwargs + ) -> DisassociateFirewallRuleGroupResponse: + """Disassociate a DNS Firewall rule group from a VPC.""" + store = self.get_store(context.account_id, context.region) + firewall_rule_group_association: FirewallRuleGroupAssociation = ( + store.delete_firewall_rule_group_association(firewall_rule_group_association_id) + ) + return DisassociateFirewallRuleGroupResponse( + FirewallRuleGroupAssociation=firewall_rule_group_association + ) + + def get_firewall_rule_group_association( + self, context: RequestContext, firewall_rule_group_association_id: ResourceId, **kwargs + ) -> GetFirewallRuleGroupAssociationResponse: + """Returns the Firewall Rule Group Association that you specified.""" + store = self.get_store(context.account_id, context.region) + firewall_rule_group_association: FirewallRuleGroupAssociation = ( + store.get_firewall_rule_group_association(firewall_rule_group_association_id) + ) + return GetFirewallRuleGroupAssociationResponse( + FirewallRuleGroupAssociation=firewall_rule_group_association + ) + + def update_firewall_rule_group_association( + self, + context: RequestContext, + firewall_rule_group_association_id: ResourceId, + priority: Priority = None, + mutation_protection: MutationProtectionStatus = None, + name: Name = None, + **kwargs, + ) -> UpdateFirewallRuleGroupAssociationResponse: + """Updates the specified Firewall Rule Group Association.""" + store = self.get_store(context.account_id, context.region) + validate_priority(priority=priority) + validate_mutation_protection(mutation_protection=mutation_protection) + + firewall_rule_group_association: FirewallRuleGroupAssociation = ( + store.get_firewall_rule_group_association(firewall_rule_group_association_id) + ) + + if priority: + firewall_rule_group_association["Priority"] = priority + if mutation_protection: + firewall_rule_group_association["MutationProtection"] = mutation_protection + if name: + firewall_rule_group_association["Name"] = name + + return UpdateFirewallRuleGroupAssociationResponse( + FirewallRuleGroupAssociation=firewall_rule_group_association + ) + + def create_resolver_query_log_config( + self, + context: RequestContext, + name: ResolverQueryLogConfigName, + destination_arn: DestinationArn, + creator_request_id: CreatorRequestId, + tags: TagList = None, + **kwargs, + ) -> CreateResolverQueryLogConfigResponse: + store = self.get_store(context.account_id, context.region) + validate_destination_arn(destination_arn) + id = get_resolver_query_log_config_id() + arn = arns.route53_resolver_query_log_config_arn(id, context.account_id, context.region) + resolver_query_log_config: ResolverQueryLogConfig = ResolverQueryLogConfig( + Id=id, + Arn=arn, + Name=name, + AssociationCount=0, + Status="CREATED", + OwnerId=context.account_id, + ShareStatus="NOT_SHARED", + DestinationArn=destination_arn, + CreatorRequestId=creator_request_id, + CreationTime=datetime.now(timezone.utc).isoformat(), + ) + store.resolver_query_log_configs[id] = resolver_query_log_config + route53resolver_backends[context.account_id][context.region].tagger.tag_resource( + arn, tags or [] + ) + return CreateResolverQueryLogConfigResponse( + ResolverQueryLogConfig=resolver_query_log_config + ) + + def create_resolver_endpoint( + self, + context: RequestContext, + creator_request_id: CreatorRequestId, + security_group_ids: SecurityGroupIds, + direction: ResolverEndpointDirection, + ip_addresses: IpAddressesRequest, + name: Name = None, + outpost_arn: OutpostArn = None, + preferred_instance_type: OutpostInstanceType = None, + tags: TagList = None, + resolver_endpoint_type: ResolverEndpointType = None, + protocols: ProtocolList = None, + **kwargs, + ) -> CreateResolverEndpointResponse: + create_resolver_endpoint_resp = call_moto(context) + create_resolver_endpoint_resp["ResolverEndpoint"]["Status"] = ( + ResolverQueryLogConfigStatus.CREATING + ) + return create_resolver_endpoint_resp + + def get_resolver_query_log_config( + self, context: RequestContext, resolver_query_log_config_id: ResourceId, **kwargs + ) -> GetResolverQueryLogConfigResponse: + store = self.get_store(context.account_id, context.region) + resolver_query_log_config: ResolverQueryLogConfig = store.get_resolver_query_log_config( + resolver_query_log_config_id + ) + return GetResolverQueryLogConfigResponse(ResolverQueryLogConfig=resolver_query_log_config) + + def delete_resolver_query_log_config( + self, context: RequestContext, resolver_query_log_config_id: ResourceId, **kwargs + ) -> DeleteResolverQueryLogConfigResponse: + store = self.get_store(context.account_id, context.region) + resolver_query_log_config: ResolverQueryLogConfig = store.delete_resolver_query_log_config( + resolver_query_log_config_id + ) + return DeleteResolverQueryLogConfigResponse( + ResolverQueryLogConfig=resolver_query_log_config + ) + + def list_resolver_query_log_configs( + self, + context: RequestContext, + max_results: MaxResults = None, + next_token: NextToken = None, + filters: Filters = None, + sort_by: SortByKey = None, + sort_order: SortOrder = None, + **kwargs, + ) -> ListResolverQueryLogConfigsResponse: + store = self.get_store(context.account_id, context.region) + resolver_query_log_configs = [] + for resolver_query_log_config in store.resolver_query_log_configs.values(): + resolver_query_log_configs.append(ResolverQueryLogConfig(resolver_query_log_config)) + return ListResolverQueryLogConfigsResponse( + ResolverQueryLogConfigs=resolver_query_log_configs, + TotalCount=len(resolver_query_log_configs), + ) + + def associate_resolver_query_log_config( + self, + context: RequestContext, + resolver_query_log_config_id: ResourceId, + resource_id: ResourceId, + **kwargs, + ) -> AssociateResolverQueryLogConfigResponse: + store = self.get_store(context.account_id, context.region) + id = get_route53_resolver_query_log_config_association_id() + + resolver_query_log_config_association: ResolverQueryLogConfigAssociation = ( + ResolverQueryLogConfigAssociation( + Id=id, + ResolverQueryLogConfigId=resolver_query_log_config_id, + ResourceId=resource_id, + Status="ACTIVE", + Error="NONE", + ErrorMessage="", + CreationTime=datetime.now(timezone.utc).isoformat(), + ) + ) + + store.resolver_query_log_config_associations[id] = resolver_query_log_config_association + + return AssociateResolverQueryLogConfigResponse( + ResolverQueryLogConfigAssociation=resolver_query_log_config_association + ) + + def disassociate_resolver_query_log_config( + self, + context: RequestContext, + resolver_query_log_config_id: ResourceId, + resource_id: ResourceId, + **kwargs, + ) -> DisassociateResolverQueryLogConfigResponse: + store = self.get_store(context.account_id, context.region) + resolver_query_log_config_association = store.delete_resolver_query_log_config_associations( + resolver_query_log_config_id, resource_id + ) + + return DisassociateResolverQueryLogConfigResponse( + ResolverQueryLogConfigAssociation=resolver_query_log_config_association + ) + + def get_resolver_query_log_config_association( + self, + context: RequestContext, + resolver_query_log_config_association_id: ResourceId, + **kwargs, + ) -> GetResolverQueryLogConfigAssociationResponse: + store = self.get_store(context.account_id, context.region) + resolver_query_log_config_association: ResolverQueryLogConfigAssociation = ( + store.get_resolver_query_log_config_associations( + resolver_query_log_config_association_id + ) + ) + return GetResolverQueryLogConfigAssociationResponse( + ResolverQueryLogConfigAssociation=resolver_query_log_config_association + ) + + def list_resolver_query_log_config_associations( + self, + context: RequestContext, + max_results: MaxResults = None, + next_token: NextToken = None, + filters: Filters = None, + sort_by: SortByKey = None, + sort_order: SortOrder = None, + **kwargs, + ) -> ListResolverQueryLogConfigAssociationsResponse: + store = self.get_store(context.account_id, context.region) + resolver_query_log_config_associations = [] + for ( + resolver_query_log_config_association + ) in store.resolver_query_log_config_associations.values(): + resolver_query_log_config_associations.append( + ResolverQueryLogConfigAssociation(resolver_query_log_config_association) + ) + return ListResolverQueryLogConfigAssociationsResponse( + TotalCount=len(resolver_query_log_config_associations), + ResolverQueryLogConfigAssociations=resolver_query_log_config_associations, + ) + + def get_firewall_config( + self, context: RequestContext, resource_id: ResourceId, **kwargs + ) -> GetFirewallConfigResponse: + store = self.get_store(context.account_id, context.region) + firewall_config = store.get_or_create_firewall_config( + resource_id, context.region, context.account_id + ) + return GetFirewallConfigResponse(FirewallConfig=firewall_config) + + def list_firewall_configs( + self, + context: RequestContext, + max_results: ListFirewallConfigsMaxResult = None, + next_token: NextToken = None, + **kwargs, + ) -> ListFirewallConfigsResponse: + store = self.get_store(context.account_id, context.region) + firewall_configs = [] + backend = get_ec2_backend(context.account_id, context.region) + for vpc in backend.vpcs: + if vpc not in store.firewall_configs: + store.get_or_create_firewall_config(vpc, context.region, context.account_id) + for firewall_config in store.firewall_configs.values(): + firewall_configs.append(select_from_typed_dict(FirewallConfig, firewall_config)) + return ListFirewallConfigsResponse(FirewallConfigs=firewall_configs) + + def update_firewall_config( + self, + context: RequestContext, + resource_id: ResourceId, + firewall_fail_open: FirewallFailOpenStatus, + **kwargs, + ) -> UpdateFirewallConfigResponse: + store = self.get_store(context.account_id, context.region) + backend = get_ec2_backend(context.account_id, context.region) + for resource_id in backend.vpcs: + if resource_id not in store.firewall_configs: + firewall_config = store.get_or_create_firewall_config( + resource_id, context.region, context.account_id + ) + firewall_config["FirewallFailOpen"] = firewall_fail_open + else: + firewall_config = store.firewall_configs[resource_id] + firewall_config["FirewallFailOpen"] = firewall_fail_open + return UpdateFirewallConfigResponse(FirewallConfig=firewall_config) + + +@patch(MotoRoute53ResolverBackend._matched_arn) +def Route53ResolverBackend_matched_arn(fn, self, resource_arn): + """Given ARN, raise exception if there is no corresponding resource.""" + account_id = extract_account_id_from_arn(resource_arn) + region_name = extract_region_from_arn(resource_arn) + store = Route53ResolverProvider.get_store(account_id, region_name) + + for firewall_rule_group in store.firewall_rule_groups.values(): + if firewall_rule_group.get("Arn") == resource_arn: + return + for firewall_domain_list in store.firewall_domain_lists.values(): + if firewall_domain_list.get("Arn") == resource_arn: + return + for firewall_rule_group_association in store.firewall_rule_group_associations.values(): + if firewall_rule_group_association.get("Arn") == resource_arn: + return + for resolver_query_log_config in store.resolver_query_log_configs.values(): + if resolver_query_log_config.get("Arn") == resource_arn: + return + fn(self, resource_arn) + + +@patch(MotoRoute53ResolverBackend.disassociate_resolver_rule) +def moto_disassociate_resolver_rule(fn, self, resolver_rule_id, vpc_id): + if resolver_rule_id not in self.resolver_rules: + raise ResourceNotFoundException( + f'[RSLVR-00703] Resolver rule with ID "{resolver_rule_id}" does not exist.' + ) + return fn(self, resolver_rule_id, vpc_id) + + +@patch(MotoRoute53ResolverBackend.create_resolver_endpoint) +def moto_create_resolver_endpoint(fn, self, *args, **kwargs): + for group_id in kwargs.get("security_group_ids"): + if not group_id.startswith("sg-"): + raise InvalidParameterException( + f'[RSLVR-00408] Malformed security group ID: "Invalid id: "{group_id}" ' + f'(expecting "sg-...")".' + ) + return fn(self, *args, **kwargs) + + +@patch(MotoRoute53ResolverBackend.delete_resolver_rule) +def moto_delete_resolver_endpoint(fn, self, resolver_rule_id): + if resolver_rule_id not in self.resolver_rules: + raise ResourceNotFoundException( + f'[RSLVR-00703] Resolver rule with ID "{resolver_rule_id}" does not exist.' + ) + return fn(self, resolver_rule_id) + + +@patch(MotoRoute53ResolverBackend.create_resolver_rule) +def moto_create_resolver_rule(fn, self, *args, **kwargs): + direction = [ + x.direction + for x in self.resolver_endpoints.values() + if x.id == kwargs.get("resolver_endpoint_id") + ] + if direction and direction[0] == ResolverEndpointDirection.INBOUND: + raise InvalidRequestException( + "[RSLVR-00700] Resolver rules can only be associated to OUTBOUND resolver endpoints." + ) + return fn(self, *args, **kwargs) diff --git a/localstack-core/localstack/services/route53resolver/utils.py b/localstack-core/localstack/services/route53resolver/utils.py new file mode 100644 index 0000000000000..bcc7357ae5a31 --- /dev/null +++ b/localstack-core/localstack/services/route53resolver/utils.py @@ -0,0 +1,68 @@ +import re + +from localstack.aws.api.route53resolver import ResourceNotFoundException, ValidationException +from localstack.services.ec2.models import get_ec2_backend +from localstack.utils.aws.arns import ARN_PARTITION_REGEX +from localstack.utils.strings import get_random_hex + + +def get_route53_resolver_firewall_rule_group_id(): + return f"rslvr-frg-{get_random_hex(17)}" + + +def get_route53_resolver_firewall_domain_list_id(): + return f"rslvr-fdl-{get_random_hex(17)}" + + +def get_route53_resolver_firewall_rule_group_association_id(): + return f"rslvr-frgassoc-{get_random_hex(17)}" + + +def get_resolver_query_log_config_id(): + return f"rslvr-rqlc-{get_random_hex(17)}" + + +def get_route53_resolver_query_log_config_association_id(): + return f"rslvr-qlcassoc-{get_random_hex(17)}" + + +def get_firewall_config_id(): + return f"rslvr-fc-{get_random_hex(17)}" + + +def validate_priority(priority): + # value of priority can be null in case of update + if priority: + if priority not in range(100, 9900): + raise ValidationException( + f"[RSLVR-02017] The priority value you provided is reserved. Provide a number between '100' and '9900'. Trace Id: '{get_trace_id()}'" + ) + + +def validate_mutation_protection(mutation_protection): + if mutation_protection: + if mutation_protection not in ["ENABLED", "DISABLED"]: + raise ValidationException( + f"[RSLVR-02018] The mutation protection value you provided is reserved. Provide a value of 'ENABLED' or 'DISABLED'. Trace Id: '{get_trace_id()}'" + ) + + +def validate_destination_arn(destination_arn): + arn_pattern = rf"{ARN_PARTITION_REGEX}:(kinesis|logs|s3):?(.*)" + if not re.match(arn_pattern, destination_arn): + raise ResourceNotFoundException( + f"[RSLVR-01014] An Amazon Resource Name (ARN) for the destination is required. Trace Id: '{get_trace_id()}'" + ) + + +def validate_vpc(vpc_id: str, region: str, account_id: str): + backend = get_ec2_backend(account_id, region) + + if vpc_id not in backend.vpcs: + raise ValidationException( + f"[RSLVR-02025] Can't find the resource with ID : '{vpc_id}'. Trace Id: '{get_trace_id()}'" + ) + + +def get_trace_id(): + return f"1-{get_random_hex(8)}-{get_random_hex(24)}" diff --git a/localstack-core/localstack/services/s3/__init__.py b/localstack-core/localstack/services/s3/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/s3/checksums.py b/localstack-core/localstack/services/s3/checksums.py new file mode 100644 index 0000000000000..a3cc9ae0f8f77 --- /dev/null +++ b/localstack-core/localstack/services/s3/checksums.py @@ -0,0 +1,169 @@ +# Code ported/inspired from https://github.com/aliyun/aliyun-oss-python-sdk/blob/master/oss2/crc64_combine.py +# This code implements checksum combinations: the ability to get the full checksum of an object with the checksums of +# its parts. +import sys + +_CRC64NVME_POLYNOMIAL = 0xAD93D23594C93659 +_CRC32_POLYNOMIAL = 0x104C11DB7 +_CRC32C_POLYNOMIAL = 0x1EDC6F41 +_CRC64_XOR_OUT = 0xFFFFFFFFFFFFFFFF +_CRC32_XOR_OUT = 0xFFFFFFFF +_GF2_DIM_64 = 64 +_GF2_DIM_32 = 32 + + +def gf2_matrix_square(square, mat): + for n in range(len(mat)): + square[n] = gf2_matrix_times(mat, mat[n]) + + +def gf2_matrix_times(mat, vec): + summary = 0 + mat_index = 0 + + while vec: + if vec & 1: + summary ^= mat[mat_index] + + vec >>= 1 + mat_index += 1 + + return summary + + +def _combine( + poly: int, + size_bits: int, + init_crc: int, + rev: bool, + xor_out: int, + crc1: int, + crc2: int, + len2: int, +) -> bytes: + if len2 == 0: + return _encode_to_bytes(crc1, size_bits) + + even = [0] * size_bits + odd = [0] * size_bits + + crc1 ^= init_crc ^ xor_out + + if rev: + # put operator for one zero bit in odd + odd[0] = poly # CRC-64 polynomial + row = 1 + for n in range(1, size_bits): + odd[n] = row + row <<= 1 + else: + row = 2 + for n in range(0, size_bits - 1): + odd[n] = row + row <<= 1 + odd[size_bits - 1] = poly + + gf2_matrix_square(even, odd) + + gf2_matrix_square(odd, even) + + while True: + gf2_matrix_square(even, odd) + if len2 & 1: + crc1 = gf2_matrix_times(even, crc1) + len2 >>= 1 + if len2 == 0: + break + + gf2_matrix_square(odd, even) + if len2 & 1: + crc1 = gf2_matrix_times(odd, crc1) + len2 >>= 1 + + if len2 == 0: + break + + crc1 ^= crc2 + + return _encode_to_bytes(crc1, size_bits) + + +def _encode_to_bytes(crc: int, size_bits: int) -> bytes: + if size_bits == 64: + return crc.to_bytes(8, byteorder="big") + elif size_bits == 32: + return crc.to_bytes(4, byteorder="big") + else: + raise ValueError("size_bites must be 32 or 64") + + +def _bitrev(x: int, n: int): + # Bit reverse the input value. + x = int(x) + y = 0 + for i in range(n): + y = (y << 1) | (x & 1) + x = x >> 1 + if ((1 << n) - 1) <= sys.maxsize: + return int(y) + return y + + +def _verify_params(size_bits: int, init_crc: int, xor_out: int): + """ + The following function validates the parameters of the CRC, namely, poly, and initial/final XOR values. + It returns the size of the CRC (in bits), and "sanitized" initial/final XOR values. + """ + mask = (1 << size_bits) - 1 + + # Adjust the initial CRC to the correct data type (unsigned value). + init_crc = int(init_crc) & mask + if mask <= sys.maxsize: + init_crc = int(init_crc) + + # Similar for XOR-out value. + xor_out = int(xor_out) & mask + if mask <= sys.maxsize: + xor_out = int(xor_out) + + return size_bits, init_crc, xor_out + + +def create_combine_function(poly: int, size_bits: int, init_crc=~0, rev=True, xor_out=0): + """ + The function returns the proper function depending on the checksum algorithm wanted. + Example, for the CRC64NVME function, you need to pass the proper polynomial, its size (64), and the proper XOR_OUT + (taken for the botocore/httpchecksums.py file). + :param poly: the CRC polynomial used (each algorithm has its own, for ex. CRC32C is called Castagnioli) + :param size_bits: the size of the algorithm, 32 for CRC32 and 64 for CRC64 + :param init_crc: the init_crc, always 0 in our case + :param rev: reversing the polynomial, true in our case as well + :param xor_out: value used to initialize the register as we don't specify init_crc + :return: + """ + size_bits, init_crc, xor_out = _verify_params(size_bits, init_crc, xor_out) + + mask = (1 << size_bits) - 1 + if rev: + poly = _bitrev(poly & mask, size_bits) + else: + poly = poly & mask + + def combine_func(crc1: bytes | int, crc2: bytes | int, len2: int): + if isinstance(crc1, bytes): + crc1 = int.from_bytes(crc1, byteorder="big") + if isinstance(crc2, bytes): + crc2 = int.from_bytes(crc2, byteorder="big") + return _combine(poly, size_bits, init_crc ^ xor_out, rev, xor_out, crc1, crc2, len2) + + return combine_func + + +combine_crc64_nvme = create_combine_function( + _CRC64NVME_POLYNOMIAL, 64, init_crc=0, xor_out=_CRC64_XOR_OUT +) +combine_crc32 = create_combine_function(_CRC32_POLYNOMIAL, 32, init_crc=0, xor_out=_CRC32_XOR_OUT) +combine_crc32c = create_combine_function(_CRC32C_POLYNOMIAL, 32, init_crc=0, xor_out=_CRC32_XOR_OUT) + + +__all__ = ["combine_crc32", "combine_crc32c", "combine_crc64_nvme"] diff --git a/localstack-core/localstack/services/s3/codec.py b/localstack-core/localstack/services/s3/codec.py new file mode 100644 index 0000000000000..9d1b3167ccda8 --- /dev/null +++ b/localstack-core/localstack/services/s3/codec.py @@ -0,0 +1,126 @@ +import io +from typing import IO, Any, Optional + + +class AwsChunkedDecoder(io.RawIOBase): + """ + This helper class takes a IO[bytes] stream, and decodes it on the fly, so that S3 can directly access the stream + without worrying about implementation details of `aws-chunked`. + It needs to expose the trailing headers, which will be available once the stream is fully read. + You can also directly pass the S3 Object, so the stream would set the checksum value once it's done. + See `aws-chunked` format here: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html + """ + + def readable(self): + return True + + def __init__( + self, stream: IO[bytes], decoded_content_length: int, s3_object: Optional[Any] = None + ): + self._stream = stream + + self._decoded_length = decoded_content_length # Length of the encoded object + self._new_chunk = True + self._end_chunk = False + self._trailing_set = False + self._chunk_size = 0 + self._trailing_headers = {} + self.s3_object = s3_object + + @property + def trailing_headers(self): + if not self._trailing_set: + raise AttributeError( + "The stream has not been fully read yet, the trailing headers are not available." + ) + return self._trailing_headers + + def seekable(self): + return self._stream.seekable() + + def readinto(self, b): + with memoryview(b) as view, view.cast("B") as byte_view: + data = self.read(len(byte_view)) + byte_view[: len(data)] = data + return len(data) + + def read(self, size=-1): + """ + Read from the underlying stream, and return at most `size` decoded bytes. + If a chunk is smaller than `size`, we will return less than asked, but we will always return data if there + are chunks left + :param size: amount to read, please note that it can return less than asked + :return: bytes from the underlying stream + """ + if size < 0: + return self.readall() + + if not size: + return b"" + + if self._end_chunk: + # if it's the end of a chunk we need to strip the newline at the end of the chunk + # before jumping to the new one + self._strip_chunk_new_lines() + self._new_chunk = True + self._end_chunk = False + + if self._new_chunk: + # If the _new_chunk flag is set, we have to jump to the next chunk, if there's one + self._get_next_chunk_length() + self._new_chunk = False + + if self._chunk_size == 0 and self._decoded_length <= 0: + # If the next chunk is 0, and we decoded everything, try to get the trailing headers + self._get_trailing_headers() + if self.s3_object: + self._set_checksum_value() + return b"" + + # take the minimum account between the requested size, and the left chunk size + # (to not over read from the chunk) + amount = min(self._chunk_size, size) + data = self._stream.read(amount) + + if data == b"": + raise EOFError("Encoded file ended before the end-of-stream marker was reached") + + read = len(data) + self._chunk_size -= read + if self._chunk_size <= 0: + self._end_chunk = True + + self._decoded_length -= read + + return data + + def _strip_chunk_new_lines(self): + self._stream.read(2) + + def _get_next_chunk_length(self): + line = self._stream.readline() + chunk_length = int(line.split(b";")[0], 16) + self._chunk_size = chunk_length + + def _get_trailing_headers(self): + """ + Once the stream content is read, we try to parse the trailing headers. + """ + # try to get all trailing headers until the end of the stream + while line := self._stream.readline(): + if trailing_header := line.strip(): + header_key, header_value = trailing_header.decode("utf-8").split(":", maxsplit=1) + self._trailing_headers[header_key.lower()] = header_value.strip() + self._trailing_set = True + + def _set_checksum_value(self): + """ + If an S3 object was passed, we check the presence of the `checksum_algorithm` field, so that we can properly + get the right checksum header value, and set it directly to the object. It allows us to transparently access + the provided checksum value by the client in the S3 logic. + """ + if checksum_algorithm := getattr(self.s3_object, "checksum_algorithm", None): + if checksum_value := self._trailing_headers.get( + f"x-amz-checksum-{checksum_algorithm.lower()}" + ): + self.s3_object.checksum_value = checksum_value diff --git a/localstack-core/localstack/services/s3/constants.py b/localstack-core/localstack/services/s3/constants.py new file mode 100644 index 0000000000000..510494d048d47 --- /dev/null +++ b/localstack-core/localstack/services/s3/constants.py @@ -0,0 +1,124 @@ +from localstack.aws.api.s3 import ( + ChecksumAlgorithm, + Grantee, + Permission, + PublicAccessBlockConfiguration, + ServerSideEncryption, + ServerSideEncryptionByDefault, + ServerSideEncryptionRule, + StorageClass, +) +from localstack.aws.api.s3 import Type as GranteeType + +S3_VIRTUAL_HOST_FORWARDED_HEADER = "x-s3-vhost-forwarded-for" + +S3_UPLOAD_PART_MIN_SIZE = 5242880 +""" +This is minimum size allowed by S3 when uploading more than one part for a Multipart Upload, except for the last part +""" + +# These 2 values have been the historical hardcoded values for S3 credentials if needing to validate S3 pre-signed URLs +DEFAULT_PRE_SIGNED_ACCESS_KEY_ID = "test" +DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY = "test" + +AUTHENTICATED_USERS_ACL_GROUP = "http://acs.amazonaws.com/groups/global/AuthenticatedUsers" +ALL_USERS_ACL_GROUP = "http://acs.amazonaws.com/groups/global/AllUsers" +LOG_DELIVERY_ACL_GROUP = "http://acs.amazonaws.com/groups/s3/LogDelivery" + +VALID_ACL_PREDEFINED_GROUPS = { + AUTHENTICATED_USERS_ACL_GROUP, + ALL_USERS_ACL_GROUP, + LOG_DELIVERY_ACL_GROUP, +} + +VALID_GRANTEE_PERMISSIONS = { + Permission.FULL_CONTROL, + Permission.READ, + Permission.READ_ACP, + Permission.WRITE, + Permission.WRITE_ACP, +} + +VALID_STORAGE_CLASSES = [ + StorageClass.STANDARD, + StorageClass.STANDARD_IA, + StorageClass.GLACIER, + StorageClass.GLACIER_IR, + StorageClass.REDUCED_REDUNDANCY, + StorageClass.ONEZONE_IA, + StorageClass.INTELLIGENT_TIERING, + StorageClass.DEEP_ARCHIVE, +] + +ARCHIVES_STORAGE_CLASSES = [ + StorageClass.GLACIER, + StorageClass.DEEP_ARCHIVE, +] + +CHECKSUM_ALGORITHMS: list[ChecksumAlgorithm] = [ + ChecksumAlgorithm.SHA1, + ChecksumAlgorithm.SHA256, + ChecksumAlgorithm.CRC32, + ChecksumAlgorithm.CRC32C, + ChecksumAlgorithm.CRC64NVME, +] + +# response header overrides the client may request +ALLOWED_HEADER_OVERRIDES = { + "ResponseContentType": "ContentType", + "ResponseContentLanguage": "ContentLanguage", + "ResponseExpires": "Expires", + "ResponseCacheControl": "CacheControl", + "ResponseContentDisposition": "ContentDisposition", + "ResponseContentEncoding": "ContentEncoding", +} + +# Whether to enable S3 bucket policy enforcement in moto - currently disabled, as some recent CDK versions +# are creating bucket policies that enforce aws:SecureTransport, which makes the CDK deployment fail. +# TODO: potentially look into making configurable +ENABLE_MOTO_BUCKET_POLICY_ENFORCEMENT = False + + +SYSTEM_METADATA_SETTABLE_HEADERS = [ + "CacheControl", + "ContentDisposition", + "ContentEncoding", + "ContentLanguage", + "ContentType", +] + +# params are required in presigned url +SIGNATURE_V2_PARAMS = ["Signature", "Expires", "AWSAccessKeyId"] + +SIGNATURE_V4_PARAMS = [ + "X-Amz-Algorithm", + "X-Amz-Credential", + "X-Amz-Date", + "X-Amz-Expires", + "X-Amz-SignedHeaders", + "X-Amz-Signature", +] + +# The chunk size to use when iterating over and writing to S3 streams. +# chosen as middle ground between memory usage and amount of iterations over the S3 object body +# This is AWS recommended size when uploading chunks +# https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html +S3_CHUNK_SIZE = 65536 + +DEFAULT_BUCKET_ENCRYPTION = ServerSideEncryptionRule( + ApplyServerSideEncryptionByDefault=ServerSideEncryptionByDefault( + SSEAlgorithm=ServerSideEncryption.AES256, + ), + BucketKeyEnabled=False, +) + +DEFAULT_PUBLIC_BLOCK_ACCESS = PublicAccessBlockConfiguration( + BlockPublicAcls=True, + BlockPublicPolicy=True, + RestrictPublicBuckets=True, + IgnorePublicAcls=True, +) + +AUTHENTICATED_USERS_ACL_GRANTEE = Grantee(URI=AUTHENTICATED_USERS_ACL_GROUP, Type=GranteeType.Group) +ALL_USERS_ACL_GRANTEE = Grantee(URI=ALL_USERS_ACL_GROUP, Type=GranteeType.Group) +LOG_DELIVERY_ACL_GRANTEE = Grantee(URI=LOG_DELIVERY_ACL_GROUP, Type=GranteeType.Group) diff --git a/localstack-core/localstack/services/s3/cors.py b/localstack-core/localstack/services/s3/cors.py new file mode 100644 index 0000000000000..9051f3679c6ca --- /dev/null +++ b/localstack-core/localstack/services/s3/cors.py @@ -0,0 +1,312 @@ +import logging +import re +from typing import Optional, Protocol, Tuple + +from werkzeug.datastructures import Headers + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.api.s3 import ( + AccessForbidden, + BadRequest, + CORSConfiguration, + CORSRule, + CORSRules, +) +from localstack.aws.chain import Handler, HandlerChain + +# TODO: refactor those to expose the needed methods +from localstack.aws.handlers.cors import CorsEnforcer, CorsResponseEnricher +from localstack.aws.protocol.op_router import RestServiceOperationRouter +from localstack.aws.spec import get_service_catalog +from localstack.config import S3_VIRTUAL_HOSTNAME +from localstack.http import Request, Response +from localstack.services.s3.utils import S3_VIRTUAL_HOSTNAME_REGEX + +# TODO: add more logging statements +LOG = logging.getLogger(__name__) + +_s3_virtual_host_regex = re.compile(S3_VIRTUAL_HOSTNAME_REGEX) +FAKE_HOST_ID = "9Gjjt1m+cjU4OPvX9O9/8RuvnG41MRb/18Oux2o5H5MY7ISNTlXN+Dz9IG62/ILVxhAGI0qyPfg=" + +# TODO: refactor those to expose the needed methods maybe in another way that both can import +add_default_headers = CorsResponseEnricher.add_cors_headers +is_origin_allowed_default = CorsEnforcer.is_cors_origin_allowed + + +class BucketCorsIndex(Protocol): + @property + def cors(self) -> dict[str, CORSConfiguration]: + raise NotImplementedError + + @property + def buckets(self) -> set[str]: + raise NotImplementedError + + def invalidate(self): + raise NotImplementedError + + +class S3CorsHandler(Handler): + bucket_cors_index: BucketCorsIndex + + def __init__(self, bucket_cors_index: BucketCorsIndex): + self.bucket_cors_index = bucket_cors_index + self._service = get_service_catalog().get("s3") + self._s3_op_router = RestServiceOperationRouter(self._service) + + def __call__(self, chain: HandlerChain, context: RequestContext, response: Response): + self.handle_cors(chain, context, response) + + def pre_parse_s3_request(self, request: Request) -> Tuple[bool, Optional[str]]: + """ + Parse the request to try to determine if it's directed towards S3. It tries to match on host, then check + if the targeted bucket exists. If we could not determine it was a s3 request from the host, but the first + element in the path contains an existing bucket, we can think it's S3. + :param request: Request from the context + :return: is_s3, whether we're certain it's a s3 request, and bucket_name if the bucket exists + """ + is_s3: bool + bucket_name: str + + path = request.path + host = request.host + + # first, try to figure out best-effort whether the request is an s3 request + if host.startswith(S3_VIRTUAL_HOSTNAME): + is_s3 = True + bucket_name = path.split("/")[1] + # try to extract the bucket from the hostname (the "in" check is a minor optimization) + elif ".s3" in host and (match := _s3_virtual_host_regex.match(host)): + is_s3 = True + bucket_name = match.group("bucket") + # otherwise we're not sure, and whether it's s3 depends on whether the bucket exists. check later + else: + is_s3 = False + bucket_name = path.split("/")[1] + + existing_buckets = self.bucket_cors_index.buckets + if bucket_name not in existing_buckets: + return is_s3, None + + return True, bucket_name + + def handle_cors(self, chain: HandlerChain, context: RequestContext, response: Response): + """ + Handle CORS for S3 requests. S3 CORS rules can be configured. + https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html + https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html + """ + request = context.request + is_s3, bucket_name = self.pre_parse_s3_request(context.request) + + if not is_s3: + # continue the chain, let the default CORS handler take care of the request + return + + # set the service so that the regular CORS enforcer knows it needs to ignore this request + # we always want to set the service early, because the `ContentDecoder` is very early in the chain and + # depends on S3 + context.service = self._service + + if config.DISABLE_CUSTOM_CORS_S3: + # we do not apply S3 specific headers if this config flag is set + return + + is_options_request = request.method == "OPTIONS" + + def stop_options_chain(): + """ + Stops the chain to avoid the OPTIONS request being parsed. The request is ready to be returned to the + client. We also need to add specific headers normally added by the serializer for regular requests. + """ + request_id = context.request_id + response.headers["x-amz-request-id"] = request_id + response.headers["x-amz-id-2"] = ( + f"MzRISOwyjmnup{request_id}7/JypPGXLh0OVFGcJaaO3KW/hRAqKOpIEEp" + ) + + response.set_response(b"") + response.headers.pop("Content-Type", None) + chain.stop() + + # check the presence of the Origin header. If not there, it means the request is not concerned about CORS + if not (origin := request.headers.get("Origin")): + if is_options_request: + context.operation = self._get_op_from_request(request) + raise BadRequest( + "Insufficient information. Origin request header needed.", HostId=FAKE_HOST_ID + ) + else: + # If the header is missing, Amazon S3 doesn't treat the request as a cross-origin request, + # and doesn't send CORS response headers in the response. + return + + is_origin_allowed_by_default = is_origin_allowed_default(request.headers) + + # The bucket does not exist or does have CORS configured + # might apply default LS CORS or raise AWS specific errors + if not bucket_name or bucket_name not in self.bucket_cors_index.cors: + # if the origin is allowed by localstack per default, adds default LS CORS headers + if is_origin_allowed_by_default: + add_default_headers( + response_headers=response.headers, request_headers=request.headers + ) + if is_options_request: + stop_options_chain() + return + # if the origin is not allowed, raise a specific S3 options in case of OPTIONS + # if it's a regular request, simply return without adding CORS + else: + if is_options_request: + if not bucket_name: + message = "CORSResponse: Bucket not found" + else: + message = "CORSResponse: CORS is not enabled for this bucket." + + context.operation = self._get_op_from_request(request) + raise AccessForbidden( + message, + HostId=FAKE_HOST_ID, + Method=request.headers.get("Access-Control-Request-Method", "OPTIONS"), + ResourceType="BUCKET", + ) + + # we return without adding any CORS headers, we could even block the request with 403 here + return + + rules = self.bucket_cors_index.cors[bucket_name]["CORSRules"] + + if not (rule := self.match_rules(request, rules)): + if is_options_request: + context.operation = self._get_op_from_request(request) + raise AccessForbidden( + "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + HostId=FAKE_HOST_ID, + Method=request.headers.get("Access-Control-Request-Method"), + ResourceType="OBJECT", + ) + + if is_options_request: + stop_options_chain() + return + + is_wildcard = "*" in rule["AllowedOrigins"] + # this is contrary to CORS specs. The Access-Control-Allow-Origin should always return the request Origin + response.headers["Access-Control-Allow-Origin"] = origin if not is_wildcard else "*" + if not is_wildcard: + response.headers["Access-Control-Allow-Credentials"] = "true" + + response.headers["Vary"] = ( + "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" + ) + + response.headers["Access-Control-Allow-Methods"] = ", ".join(rule["AllowedMethods"]) + + if requested_headers := request.headers.get("Access-Control-Request-Headers"): + # if the rule matched, it means all Requested Headers are allowed + requested_headers_formatted = [ + header.strip().lower() for header in requested_headers.split(",") + ] + response.headers["Access-Control-Allow-Headers"] = ", ".join( + requested_headers_formatted + ) + + if expose_headers := rule.get("ExposeHeaders"): + response.headers["Access-Control-Expose-Headers"] = ", ".join(expose_headers) + + if max_age := rule.get("MaxAgeSeconds"): + response.headers["Access-Control-Max-Age"] = str(max_age) + + if is_options_request: + stop_options_chain() + + def invalidate_cache(self): + self.bucket_cors_index.invalidate() + + def match_rules(self, request: Request, rules: CORSRules) -> Optional[CORSRule]: + """ + Try to match the request to the bucket rules. How to match rules: + - The request's Origin header must match AllowedOrigin elements. + - The request method (for example, GET, PUT, HEAD, and so on) or the Access-Control-Request-Method + header in case of a pre-flight OPTIONS request must be one of the AllowedMethod elements. + - Every header specified in the Access-Control-Request-Headers request header of a pre-flight request + must match an AllowedHeader element. + :param request: RequestContext: + :param rules: CORSRules: the bucket CORS rules + :return: return a CORSRule if it finds a match, or None + """ + headers = request.headers + method = request.method + for rule in rules: + if matched_rule := self._match_rule(rule, method, headers): + return matched_rule + + @staticmethod + def _match_rule(rule: CORSRule, method: str, headers: Headers) -> Optional[CORSRule]: + """ + Check if the request method and headers matches the given CORS rule. + :param rule: CORSRule: a CORS Rule from the bucket + :param method: HTTP method of the request + :param headers: Headers of the request + :return: CORSRule if the rule match, or None + """ + # AWS treats any method as an OPTIONS if it has the specific OPTIONS CORS headers + request_method = headers.get("Access-Control-Request-Method") or method + origin = headers.get("Origin") + if request_method not in rule["AllowedMethods"]: + return + + if "*" not in rule["AllowedOrigins"] and not any( + # Escapes any characters that needs escaping and replaces * with .+ + # Transforms http://*.localhost:1234 to http://.+\\.localhost:1234 + re.match(re.escape(allowed_origin).replace("\\*", ".+") + "$", origin) + for allowed_origin in rule["AllowedOrigins"] + ): + return + + if request_headers := headers.get("Access-Control-Request-Headers"): + if not (allowed_headers := rule.get("AllowedHeaders")): + return + + lower_case_allowed_headers = {header.lower() for header in allowed_headers} + if "*" not in allowed_headers and not all( + header.strip() in lower_case_allowed_headers + for header in request_headers.lower().split(",") + ): + return + + return rule + + def _get_op_from_request(self, request: Request): + try: + op, _ = self._s3_op_router.match(request) + return op + except Exception: + # if we can't parse the request, just set GetObject + return self._service.operation_model("GetObject") + + +def s3_cors_request_handler(chain: HandlerChain, context: RequestContext, response: Response): + """ + Handler to add default CORS headers to S3 operations not concerned with CORS configuration + """ + # if DISABLE_CUSTOM_CORS_S3 is true, the default CORS handling will take place, so we won't need to do it here + if config.DISABLE_CUSTOM_CORS_S3: + return + + if not context.service or context.service.service_name != "s3": + return + + if not context.operation or context.operation.name not in ("ListBuckets", "CreateBucket"): + return + + if not config.DISABLE_CORS_CHECKS and not is_origin_allowed_default(context.request.headers): + LOG.info( + "Blocked CORS request from forbidden origin %s", + context.request.headers.get("origin") or context.request.headers.get("referer"), + ) + response.status_code = 403 + chain.terminate() + + add_default_headers(response_headers=response.headers, request_headers=context.request.headers) diff --git a/localstack-core/localstack/services/s3/exceptions.py b/localstack-core/localstack/services/s3/exceptions.py new file mode 100644 index 0000000000000..382631de91a50 --- /dev/null +++ b/localstack-core/localstack/services/s3/exceptions.py @@ -0,0 +1,53 @@ +from localstack.aws.api import CommonServiceException + + +class MalformedXML(CommonServiceException): + def __init__(self, message=None): + if not message: + message = "The XML you provided was not well-formed or did not validate against our published schema" + super().__init__("MalformedXML", status_code=400, message=message) + + +class MalformedACLError(CommonServiceException): + def __init__(self, message=None): + super().__init__("MalformedACLError", status_code=400, message=message) + + +class InvalidRequest(CommonServiceException): + def __init__(self, message=None): + super().__init__("InvalidRequest", status_code=400, message=message) + + +class UnexpectedContent(CommonServiceException): + def __init__(self, message=None): + super().__init__("UnexpectedContent", status_code=400, message=message) + + +class NoSuchConfiguration(CommonServiceException): + def __init__(self, message=None): + super().__init__("NoSuchConfiguration", status_code=404, message=message) + + +class InvalidBucketState(CommonServiceException): + def __init__(self, message=None): + super().__init__("InvalidBucketState", status_code=409, message=message) + + +class NoSuchObjectLockConfiguration(CommonServiceException): + def __init__(self, message=None): + super().__init__("NoSuchObjectLockConfiguration", status_code=404, message=message) + + +class MalformedPolicy(CommonServiceException): + def __init__(self, message=None): + super().__init__("MalformedPolicy", status_code=400, message=message) + + +class InvalidBucketOwnerAWSAccountID(CommonServiceException): + def __init__(self, message=None) -> None: + super().__init__("InvalidBucketOwnerAWSAccountID", status_code=400, message=message) + + +class TooManyConfigurations(CommonServiceException): + def __init__(self, message=None) -> None: + super().__init__("TooManyConfigurations", status_code=400, message=message) diff --git a/localstack-core/localstack/services/s3/models.py b/localstack-core/localstack/services/s3/models.py new file mode 100644 index 0000000000000..6246d394dad33 --- /dev/null +++ b/localstack-core/localstack/services/s3/models.py @@ -0,0 +1,807 @@ +import base64 +import hashlib +import logging +from collections import defaultdict +from datetime import datetime +from secrets import token_urlsafe +from typing import Literal, NamedTuple, Optional, Union +from zoneinfo import ZoneInfo + +from localstack.aws.api import CommonServiceException +from localstack.aws.api.s3 import ( + AccessControlPolicy, + AccountId, + AnalyticsConfiguration, + AnalyticsId, + BadDigest, + BucketAccelerateStatus, + BucketKeyEnabled, + BucketName, + BucketRegion, + BucketVersioningStatus, + ChecksumAlgorithm, + ChecksumType, + CompletedPartList, + CORSConfiguration, + DefaultRetention, + EntityTooSmall, + ETag, + Expiration, + IntelligentTieringConfiguration, + IntelligentTieringId, + InvalidArgument, + InvalidPart, + InventoryConfiguration, + InventoryId, + LifecycleRules, + LoggingEnabled, + Metadata, + MethodNotAllowed, + MetricsConfiguration, + MetricsId, + MultipartUploadId, + NoSuchKey, + NoSuchVersion, + NotificationConfiguration, + ObjectKey, + ObjectLockLegalHoldStatus, + ObjectLockMode, + ObjectLockRetainUntilDate, + ObjectLockRetentionMode, + ObjectOwnership, + ObjectStorageClass, + ObjectVersionId, + Owner, + Part, + PartNumber, + Payer, + Policy, + PublicAccessBlockConfiguration, + ReplicationConfiguration, + Restore, + ServerSideEncryption, + ServerSideEncryptionRule, + Size, + SSECustomerKeyMD5, + SSEKMSKeyId, + StorageClass, + TransitionDefaultMinimumObjectSize, + WebsiteConfiguration, + WebsiteRedirectLocation, +) +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.services.s3.constants import ( + DEFAULT_BUCKET_ENCRYPTION, + DEFAULT_PUBLIC_BLOCK_ACCESS, + S3_UPLOAD_PART_MIN_SIZE, +) +from localstack.services.s3.exceptions import InvalidRequest +from localstack.services.s3.utils import CombinedCrcHash, get_s3_checksum, rfc_1123_datetime +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossAccountAttribute, + CrossRegionAttribute, + LocalAttribute, +) +from localstack.utils.aws import arns +from localstack.utils.tagging import TaggingService + +LOG = logging.getLogger(__name__) + +_gmt_zone_info = ZoneInfo("GMT") + + +class InternalObjectPart(Part): + _position: int + + +# note: not really a need to use a dataclass here, as it has a lot of fields, but only a few are set at creation +class S3Bucket: + name: BucketName + bucket_account_id: AccountId + bucket_region: BucketRegion + creation_date: datetime + multiparts: dict[MultipartUploadId, "S3Multipart"] + objects: Union["KeyStore", "VersionedKeyStore"] + versioning_status: BucketVersioningStatus | None + lifecycle_rules: Optional[LifecycleRules] + transition_default_minimum_object_size: Optional[TransitionDefaultMinimumObjectSize] + policy: Optional[Policy] + website_configuration: Optional[WebsiteConfiguration] + acl: AccessControlPolicy + cors_rules: Optional[CORSConfiguration] + logging: LoggingEnabled + notification_configuration: NotificationConfiguration + payer: Payer + encryption_rule: Optional[ServerSideEncryptionRule] + public_access_block: Optional[PublicAccessBlockConfiguration] + accelerate_status: Optional[BucketAccelerateStatus] + object_lock_enabled: bool + object_ownership: ObjectOwnership + intelligent_tiering_configurations: dict[IntelligentTieringId, IntelligentTieringConfiguration] + analytics_configurations: dict[AnalyticsId, AnalyticsConfiguration] + inventory_configurations: dict[InventoryId, InventoryConfiguration] + metric_configurations: dict[MetricsId, MetricsConfiguration] + object_lock_default_retention: Optional[DefaultRetention] + replication: ReplicationConfiguration + owner: Owner + + # set all buckets parameters here + def __init__( + self, + name: BucketName, + account_id: AccountId, + bucket_region: BucketRegion, + owner: Owner, + acl: AccessControlPolicy = None, + object_ownership: ObjectOwnership = None, + object_lock_enabled_for_bucket: bool = None, + ): + self.name = name + self.bucket_account_id = account_id + self.bucket_region = bucket_region + # If ObjectLock is enabled, it forces the bucket to be versioned as well + self.versioning_status = None if not object_lock_enabled_for_bucket else "Enabled" + self.objects = KeyStore() if not object_lock_enabled_for_bucket else VersionedKeyStore() + self.object_ownership = object_ownership or ObjectOwnership.BucketOwnerEnforced + self.object_lock_enabled = object_lock_enabled_for_bucket + self.encryption_rule = DEFAULT_BUCKET_ENCRYPTION + self.creation_date = datetime.now(tz=_gmt_zone_info) + self.payer = Payer.BucketOwner + self.public_access_block = DEFAULT_PUBLIC_BLOCK_ACCESS + self.multiparts = {} + self.notification_configuration = {} + self.logging = {} + self.cors_rules = None + self.lifecycle_rules = None + self.transition_default_minimum_object_size = None + self.website_configuration = None + self.policy = None + self.accelerate_status = None + self.intelligent_tiering_configurations = {} + self.analytics_configurations = {} + self.inventory_configurations = {} + self.metric_configurations = {} + self.object_lock_default_retention = {} + self.replication = None + self.acl = acl + # see https://docs.aws.amazon.com/AmazonS3/latest/API/API_Owner.html + self.owner = owner + self.bucket_arn = arns.s3_bucket_arn(self.name, region=bucket_region) + + def get_object( + self, + key: ObjectKey, + version_id: ObjectVersionId = None, + http_method: Literal["GET", "PUT", "HEAD", "DELETE"] = "GET", + ) -> "S3Object": + """ + :param key: the Object Key + :param version_id: optional, the versionId of the object + :param http_method: the HTTP method of the original call. This is necessary for the exception if the bucket is + versioned or suspended + see: https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeleteMarker.html + :return: the S3Object from the bucket + :raises NoSuchKey if the object key does not exist at all, or if the object is a DeleteMarker + :raises MethodNotAllowed if the object is a DeleteMarker and the operation is not allowed against it + """ + + if self.versioning_status is None: + if version_id and version_id != "null": + raise InvalidArgument( + "Invalid version id specified", + ArgumentName="versionId", + ArgumentValue=version_id, + ) + + s3_object = self.objects.get(key) + + if not s3_object: + raise NoSuchKey("The specified key does not exist.", Key=key) + + else: + self.objects: VersionedKeyStore + if version_id: + s3_object_version = self.objects.get(key, version_id) + if not s3_object_version: + raise NoSuchVersion( + "The specified version does not exist.", + Key=key, + VersionId=version_id, + ) + elif isinstance(s3_object_version, S3DeleteMarker): + if http_method == "HEAD": + raise CommonServiceException( + code="405", + message="Method Not Allowed", + status_code=405, + ) + + raise MethodNotAllowed( + "The specified method is not allowed against this resource.", + Method=http_method, + ResourceType="DeleteMarker", + DeleteMarker=True, + Allow="DELETE", + VersionId=s3_object_version.version_id, + ) + return s3_object_version + + s3_object = self.objects.get(key) + + if not s3_object: + raise NoSuchKey("The specified key does not exist.", Key=key) + + elif isinstance(s3_object, S3DeleteMarker): + if http_method not in ("HEAD", "GET"): + raise MethodNotAllowed( + "The specified method is not allowed against this resource.", + Method=http_method, + ResourceType="DeleteMarker", + DeleteMarker=True, + Allow="DELETE", + VersionId=s3_object.version_id, + ) + + raise NoSuchKey( + "The specified key does not exist.", + Key=key, + DeleteMarker=True, + VersionId=s3_object.version_id, + ) + + return s3_object + + +class S3Object: + key: ObjectKey + version_id: Optional[ObjectVersionId] + bucket: BucketName + owner: Optional[Owner] + size: Optional[Size] + etag: Optional[ETag] + user_metadata: Metadata + system_metadata: Metadata + last_modified: datetime + expires: Optional[datetime] + expiration: Optional[Expiration] # right now, this is stored in the provider cache + storage_class: StorageClass | ObjectStorageClass + encryption: Optional[ServerSideEncryption] # inherit bucket + kms_key_id: Optional[SSEKMSKeyId] # inherit bucket + bucket_key_enabled: Optional[bool] # inherit bucket + sse_key_hash: Optional[SSECustomerKeyMD5] + checksum_algorithm: ChecksumAlgorithm + checksum_value: str + checksum_type: ChecksumType + lock_mode: Optional[ObjectLockMode | ObjectLockRetentionMode] + lock_legal_status: Optional[ObjectLockLegalHoldStatus] + lock_until: Optional[datetime] + website_redirect_location: Optional[WebsiteRedirectLocation] + acl: Optional[AccessControlPolicy] + is_current: bool + parts: Optional[dict[int, InternalObjectPart]] + restore: Optional[Restore] + internal_last_modified: int + + def __init__( + self, + key: ObjectKey, + etag: Optional[ETag] = None, + size: Optional[int] = None, + version_id: Optional[ObjectVersionId] = None, + user_metadata: Optional[Metadata] = None, + system_metadata: Optional[Metadata] = None, + storage_class: StorageClass = StorageClass.STANDARD, + expires: Optional[datetime] = None, + expiration: Optional[Expiration] = None, + checksum_algorithm: Optional[ChecksumAlgorithm] = None, + checksum_value: Optional[str] = None, + checksum_type: Optional[ChecksumType] = ChecksumType.FULL_OBJECT, + encryption: Optional[ServerSideEncryption] = None, + kms_key_id: Optional[SSEKMSKeyId] = None, + sse_key_hash: Optional[SSECustomerKeyMD5] = None, + bucket_key_enabled: bool = False, + lock_mode: Optional[ObjectLockMode | ObjectLockRetentionMode] = None, + lock_legal_status: Optional[ObjectLockLegalHoldStatus] = None, + lock_until: Optional[datetime] = None, + website_redirect_location: Optional[WebsiteRedirectLocation] = None, + acl: Optional[AccessControlPolicy] = None, # TODO + owner: Optional[Owner] = None, + ): + self.key = key + self.user_metadata = ( + {k.lower(): v for k, v in user_metadata.items()} if user_metadata else {} + ) + self.system_metadata = system_metadata or {} + self.version_id = version_id + self.storage_class = storage_class or StorageClass.STANDARD + self.etag = etag + self.size = size + self.expires = expires + self.checksum_algorithm = checksum_algorithm or ChecksumAlgorithm.CRC64NVME + self.checksum_value = checksum_value + self.checksum_type = checksum_type + self.encryption = encryption + self.kms_key_id = kms_key_id + self.bucket_key_enabled = bucket_key_enabled + self.sse_key_hash = sse_key_hash + self.lock_mode = lock_mode + self.lock_legal_status = lock_legal_status + self.lock_until = lock_until + self.acl = acl + self.expiration = expiration + self.website_redirect_location = website_redirect_location + self.is_current = True + self.last_modified = datetime.now(tz=_gmt_zone_info) + self.parts = {} + self.restore = None + self.owner = owner + self.internal_last_modified = 0 + + def get_system_metadata_fields(self) -> dict: + headers = { + "LastModified": self.last_modified_rfc1123, + "ContentLength": str(self.size), + "ETag": self.quoted_etag, + } + if self.expires: + headers["Expires"] = self.expires_rfc1123 + + for metadata_key, metadata_value in self.system_metadata.items(): + headers[metadata_key] = metadata_value + + if self.storage_class != StorageClass.STANDARD: + headers["StorageClass"] = self.storage_class + + return headers + + @property + def last_modified_rfc1123(self) -> str: + # TODO: verify if we need them with proper snapshot testing, for now it's copied from moto + # Different datetime formats depending on how the key is obtained + # https://github.com/boto/boto/issues/466 + return rfc_1123_datetime(self.last_modified) + + @property + def expires_rfc1123(self) -> str: + return rfc_1123_datetime(self.expires) + + @property + def quoted_etag(self) -> str: + return f'"{self.etag}"' + + def is_locked(self, bypass_governance: bool = False) -> bool: + if self.lock_legal_status == "ON": + return True + + if bypass_governance and self.lock_mode == ObjectLockMode.GOVERNANCE: + return False + + if self.lock_until: + return self.lock_until > datetime.now(tz=_gmt_zone_info) + + return False + + +# TODO: could use dataclass, validate after models are set +class S3DeleteMarker: + key: ObjectKey + version_id: str + last_modified: datetime + is_current: bool + + def __init__(self, key: ObjectKey, version_id: ObjectVersionId): + self.key = key + self.version_id = version_id + self.last_modified = datetime.now(tz=_gmt_zone_info) + self.is_current = True + + @staticmethod + def is_locked(*args, **kwargs) -> bool: + # an S3DeleteMarker cannot be lock protected + return False + + +# TODO: could use dataclass, validate after models are set +class S3Part: + part_number: PartNumber + etag: Optional[ETag] + last_modified: datetime + size: Optional[int] + checksum_algorithm: Optional[ChecksumAlgorithm] + checksum_value: Optional[str] + + def __init__( + self, + part_number: PartNumber, + size: int = None, + etag: ETag = None, + checksum_algorithm: Optional[ChecksumAlgorithm] = None, + checksum_value: Optional[str] = None, + ): + self.last_modified = datetime.now(tz=_gmt_zone_info) + self.part_number = part_number + self.size = size + self.etag = etag + self.checksum_algorithm = checksum_algorithm + self.checksum_value = checksum_value + + @property + def quoted_etag(self) -> str: + return f'"{self.etag}"' + + +class S3Multipart: + parts: dict[PartNumber, S3Part] + object: S3Object + upload_id: MultipartUploadId + checksum_value: Optional[str] + checksum_type: Optional[ChecksumType] + checksum_algorithm: ChecksumAlgorithm + initiated: datetime + precondition: bool + + def __init__( + self, + key: ObjectKey, + storage_class: StorageClass | ObjectStorageClass = StorageClass.STANDARD, + expires: Optional[datetime] = None, + expiration: Optional[datetime] = None, # come from lifecycle + checksum_algorithm: Optional[ChecksumAlgorithm] = None, + checksum_type: Optional[ChecksumType] = None, + encryption: Optional[ServerSideEncryption] = None, # inherit bucket + kms_key_id: Optional[SSEKMSKeyId] = None, # inherit bucket + bucket_key_enabled: bool = False, # inherit bucket + sse_key_hash: Optional[SSECustomerKeyMD5] = None, + lock_mode: Optional[ObjectLockMode] = None, + lock_legal_status: Optional[ObjectLockLegalHoldStatus] = None, + lock_until: Optional[datetime] = None, + website_redirect_location: Optional[WebsiteRedirectLocation] = None, + acl: Optional[AccessControlPolicy] = None, # TODO + user_metadata: Optional[Metadata] = None, + system_metadata: Optional[Metadata] = None, + initiator: Optional[Owner] = None, + tagging: Optional[dict[str, str]] = None, + owner: Optional[Owner] = None, + precondition: Optional[bool] = None, + ): + self.id = token_urlsafe(96) # MultipartUploadId is 128 characters long + self.initiated = datetime.now(tz=_gmt_zone_info) + self.parts = {} + self.initiator = initiator + self.tagging = tagging + self.checksum_value = None + self.checksum_type = checksum_type + self.checksum_algorithm = checksum_algorithm + self.precondition = precondition + self.object = S3Object( + key=key, + user_metadata=user_metadata, + system_metadata=system_metadata, + storage_class=storage_class or StorageClass.STANDARD, + expires=expires, + expiration=expiration, + checksum_algorithm=checksum_algorithm, + checksum_type=checksum_type, + encryption=encryption, + kms_key_id=kms_key_id, + bucket_key_enabled=bucket_key_enabled, + sse_key_hash=sse_key_hash, + lock_mode=lock_mode, + lock_legal_status=lock_legal_status, + lock_until=lock_until, + website_redirect_location=website_redirect_location, + acl=acl, + owner=owner, + ) + + def complete_multipart( + self, parts: CompletedPartList, mpu_size: int = None, validation_checksum: str = None + ): + last_part_index = len(parts) - 1 + object_etag = hashlib.md5(usedforsecurity=False) + has_checksum = self.checksum_algorithm is not None + checksum_hash = None + checksum_key = None + if has_checksum: + checksum_key = f"Checksum{self.checksum_algorithm.upper()}" + if self.checksum_type == ChecksumType.COMPOSITE: + checksum_hash = get_s3_checksum(self.checksum_algorithm) + else: + checksum_hash = CombinedCrcHash(self.checksum_algorithm) + + pos = 0 + parts_map: dict[int, InternalObjectPart] = {} + for index, part in enumerate(parts): + part_number = part["PartNumber"] + part_etag = part["ETag"].strip('"') + + s3_part = self.parts.get(part_number) + if ( + not s3_part + or s3_part.etag != part_etag + or (not has_checksum and any(k.startswith("Checksum") for k in part)) + ): + raise InvalidPart( + "One or more of the specified parts could not be found. " + "The part may not have been uploaded, " + "or the specified entity tag may not match the part's entity tag.", + ETag=part_etag, + PartNumber=part_number, + UploadId=self.id, + ) + + if has_checksum: + if not (part_checksum := part.get(checksum_key)): + if self.checksum_type == ChecksumType.COMPOSITE: + # weird case, they still try to validate a different checksum type than the multipart + for field in part: + if field.startswith("Checksum"): + algo = field.removeprefix("Checksum").lower() + raise BadDigest( + f"The {algo} you specified for part {part_number} did not match what we received." + ) + + raise InvalidRequest( + f"The upload was created using a {self.checksum_algorithm.lower()} checksum. " + f"The complete request must include the checksum for each part. " + f"It was missing for part {part_number} in the request." + ) + elif part_checksum != s3_part.checksum_value: + raise InvalidPart( + "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + ETag=part_etag, + PartNumber=part_number, + UploadId=self.id, + ) + + part_checksum_value = base64.b64decode(s3_part.checksum_value) + if self.checksum_type == ChecksumType.COMPOSITE: + checksum_hash.update(part_checksum_value) + else: + checksum_hash.combine(part_checksum_value, s3_part.size) + + elif any(k.startswith("Checksum") for k in part): + raise InvalidPart( + "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + ETag=part_etag, + PartNumber=part_number, + UploadId=self.id, + ) + + if index != last_part_index and s3_part.size < S3_UPLOAD_PART_MIN_SIZE: + raise EntityTooSmall( + "Your proposed upload is smaller than the minimum allowed size", + ETag=part_etag, + PartNumber=part_number, + MinSizeAllowed=S3_UPLOAD_PART_MIN_SIZE, + ProposedSize=s3_part.size, + ) + + object_etag.update(bytes.fromhex(s3_part.etag)) + # keep track of the parts size, as it can be queried afterward on the object as a Range + internal_part = InternalObjectPart( + _position=pos, + Size=s3_part.size, + ETag=s3_part.etag, + PartNumber=s3_part.part_number, + ) + if has_checksum and self.checksum_type == ChecksumType.COMPOSITE: + internal_part[checksum_key] = s3_part.checksum_value + + parts_map[part_number] = internal_part + pos += s3_part.size + + if mpu_size and mpu_size != pos: + raise InvalidRequest( + f"The provided 'x-amz-mp-object-size' header value {mpu_size} " + f"does not match what was computed: {pos}" + ) + + if has_checksum: + checksum_value = base64.b64encode(checksum_hash.digest()).decode() + if self.checksum_type == ChecksumType.COMPOSITE: + checksum_value = f"{checksum_value}-{len(parts)}" + + elif self.checksum_type == ChecksumType.FULL_OBJECT: + if validation_checksum and validation_checksum != checksum_value: + raise BadDigest( + f"The {self.object.checksum_algorithm.lower()} you specified did not match the calculated checksum." + ) + + self.checksum_value = checksum_value + self.object.checksum_value = checksum_value + + multipart_etag = f"{object_etag.hexdigest()}-{len(parts)}" + self.object.etag = multipart_etag + self.object.parts = parts_map + + +class KeyStore: + """ + Object representing an S3 Un-versioned Bucket's Key Store. An object is mapped by a key, and you can simply + retrieve the object from that key. + """ + + def __init__(self): + self._store = {} + + def get(self, object_key: ObjectKey) -> S3Object | None: + return self._store.get(object_key) + + def set(self, object_key: ObjectKey, s3_object: S3Object): + self._store[object_key] = s3_object + + def pop(self, object_key: ObjectKey, default=None) -> S3Object | None: + return self._store.pop(object_key, default) + + def values(self, *_, **__) -> list[S3Object | S3DeleteMarker]: + # we create a shallow copy with dict to avoid size changed during iteration + return [value for value in dict(self._store).values()] + + def is_empty(self) -> bool: + return not self._store + + def __contains__(self, item): + return item in self._store + + +class VersionedKeyStore: + """ + Object representing an S3 Versioned Bucket's Key Store. An object is mapped by a key, and adding an object to the + same key will create a new version of it. When deleting the object, a S3DeleteMarker is created and put on top + of the version stack, to signal the object has been "deleted". + This object allows easy retrieval and saving of new object versions. + See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/versioning-workflows.html + """ + + def __init__(self): + self._store = defaultdict(dict) + + @classmethod + def from_key_store(cls, keystore: KeyStore) -> "VersionedKeyStore": + new_versioned_keystore = cls() + for s3_object in keystore.values(): + # TODO: maybe do the object mutation inside the provider instead? but would need to iterate twice + # or do this whole operation inside the provider instead, when actually working on versioning + s3_object.version_id = "null" + new_versioned_keystore.set(object_key=s3_object.key, s3_object=s3_object) + + return new_versioned_keystore + + def get( + self, object_key: ObjectKey, version_id: ObjectVersionId = None + ) -> S3Object | S3DeleteMarker | None: + """ + :param object_key: the key of the Object we need to retrieve + :param version_id: Optional, if not specified, return the current version (last one inserted) + :return: an S3Object or S3DeleteMarker + """ + if not version_id and (versions := self._store.get(object_key)): + for version_id in reversed(versions): + return versions.get(version_id) + + return self._store.get(object_key, {}).get(version_id) + + def set(self, object_key: ObjectKey, s3_object: S3Object | S3DeleteMarker): + """ + Set an S3 object, using its already set VersionId. + If the bucket versioning is `Enabled`, then we're just inserting a new Version. + If the bucket versioning is `Suspended`, the current object version will be set to `null`, so if setting a new + object at the same key, we will override it at the `null` versionId entry. + :param object_key: the key of the Object we are setting + :param s3_object: the S3 object or S3DeleteMarker to set + :return: None + """ + existing_s3_object = self.get(object_key) + if existing_s3_object: + existing_s3_object.is_current = False + + self._store[object_key][s3_object.version_id] = s3_object + + def pop( + self, object_key: ObjectKey, version_id: ObjectVersionId = None, default=None + ) -> S3Object | S3DeleteMarker | None: + versions = self._store.get(object_key) + if not versions: + return None + + object_version = versions.pop(version_id, default) + if not versions: + self._store.pop(object_key) + else: + existing_s3_object = self.get(object_key) + existing_s3_object.is_current = True + + return object_version + + def values(self, with_versions: bool = False) -> list[S3Object | S3DeleteMarker]: + if with_versions: + # we create a shallow copy with dict to avoid size changed during iteration + return [ + object_version + for values in dict(self._store).values() + for object_version in dict(values).values() + ] + + # if `with_versions` is False, then we need to return only the current version if it's not a DeleteMarker + objects = [] + for object_key, versions in dict(self._store).items(): + # we're getting the last set object in the versions dictionary + for version_id in reversed(versions): + current_object = versions[version_id] + if isinstance(current_object, S3DeleteMarker): + break + + objects.append(versions[version_id]) + break + + return objects + + def is_empty(self) -> bool: + return not self._store + + def __contains__(self, item): + return item in self._store + + +class S3Store(BaseStore): + buckets: dict[BucketName, S3Bucket] = CrossRegionAttribute(default=dict) + global_bucket_map: dict[BucketName, AccountId] = CrossAccountAttribute(default=dict) + aws_managed_kms_key_id: SSEKMSKeyId = LocalAttribute(default=str) + + # static tagging service instance + TAGS: TaggingService = CrossAccountAttribute(default=TaggingService) + + +class BucketCorsIndex: + def __init__(self): + self._cors_index_cache = None + self._bucket_index_cache = None + + @property + def cors(self) -> dict[str, CORSConfiguration]: + if self._cors_index_cache is None: + self._bucket_index_cache, self._cors_index_cache = self._build_index() + return self._cors_index_cache + + @property + def buckets(self) -> set[str]: + if self._bucket_index_cache is None: + self._bucket_index_cache, self._cors_index_cache = self._build_index() + return self._bucket_index_cache + + def invalidate(self): + self._cors_index_cache = None + self._bucket_index_cache = None + + @staticmethod + def _build_index() -> tuple[set[BucketName], dict[BucketName, CORSConfiguration]]: + buckets = set() + cors_index = {} + # we create a shallow copy with dict to avoid size changed during iteration, as the store could have new account + # or region create from any other requests + for account_id, regions in dict(s3_stores).items(): + for bucket_name, bucket in dict(regions[AWS_REGION_US_EAST_1].buckets).items(): + bucket: S3Bucket + buckets.add(bucket_name) + if bucket.cors_rules is not None: + cors_index[bucket_name] = bucket.cors_rules + + return buckets, cors_index + + +class EncryptionParameters(NamedTuple): + encryption: ServerSideEncryption + kms_key_id: SSEKMSKeyId + bucket_key_enabled: BucketKeyEnabled + + +class ObjectLockParameters(NamedTuple): + lock_until: ObjectLockRetainUntilDate + lock_legal_status: ObjectLockLegalHoldStatus + lock_mode: ObjectLockMode | ObjectLockRetentionMode + + +s3_stores = AccountRegionBundle[S3Store]("s3", S3Store) diff --git a/localstack-core/localstack/services/s3/notifications.py b/localstack-core/localstack/services/s3/notifications.py new file mode 100644 index 0000000000000..48ece2ab9e788 --- /dev/null +++ b/localstack-core/localstack/services/s3/notifications.py @@ -0,0 +1,786 @@ +from __future__ import annotations + +import datetime +import json +import logging +import re +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple, TypedDict, Union +from urllib.parse import quote + +from botocore.exceptions import ClientError + +from localstack.aws.api import RequestContext +from localstack.aws.api.events import PutEventsRequestEntry +from localstack.aws.api.lambda_ import InvocationType +from localstack.aws.api.s3 import ( + AccountId, + BucketName, + BucketRegion, + Event, + EventBridgeConfiguration, + EventList, + LambdaFunctionArn, + LambdaFunctionConfiguration, + NotificationConfiguration, + NotificationConfigurationFilter, + NotificationId, + ObjectKey, + QueueArn, + QueueConfiguration, + StorageClass, + TopicArn, + TopicConfiguration, +) +from localstack.aws.connect import connect_to +from localstack.services.s3.models import S3Bucket, S3DeleteMarker, S3Object +from localstack.services.s3.utils import _create_invalid_argument_exc +from localstack.utils.aws import arns +from localstack.utils.aws.arns import ARN_PARTITION_REGEX, get_partition, parse_arn, s3_bucket_arn +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.strings import short_uid +from localstack.utils.time import parse_timestamp, timestamp_millis + +LOG = logging.getLogger(__name__) + +EVENT_OPERATION_MAP = { + "PutObject": Event.s3_ObjectCreated_Put, + "CopyObject": Event.s3_ObjectCreated_Copy, + "CompleteMultipartUpload": Event.s3_ObjectCreated_CompleteMultipartUpload, + "PostObject": Event.s3_ObjectCreated_Post, + "PutObjectTagging": Event.s3_ObjectTagging_Put, + "DeleteObjectTagging": Event.s3_ObjectTagging_Delete, + "DeleteObject": Event.s3_ObjectRemoved_Delete, + "DeleteObjects": Event.s3_ObjectRemoved_Delete, + "PutObjectAcl": Event.s3_ObjectAcl_Put, + "RestoreObject": Event.s3_ObjectRestore_Post, +} + +HEADER_AMZN_XRAY = "X-Amzn-Trace-Id" + + +class S3NotificationContent(TypedDict): + s3SchemaVersion: str + configurationId: NotificationId + bucket: Dict[str, str] # todo + object: Dict[str, Union[str, int]] # todo + + +class EventRecord(TypedDict): + eventVersion: str + eventSource: str + awsRegion: str + eventTime: str + eventName: str + userIdentity: Dict[str, str] + requestParameters: Dict[str, str] + responseElements: Dict[str, str] + s3: S3NotificationContent + + +class Notification(TypedDict): + Records: List[EventRecord] + + +@dataclass +class S3EventNotificationContext: + request_id: str + event_type: str + event_time: datetime.datetime + account_id: str + region: str + bucket_name: BucketName + key_name: ObjectKey + xray: str + bucket_location: BucketRegion + bucket_account_id: AccountId + caller: AccountId + key_size: int + key_etag: str + key_version_id: str + key_expiry: datetime.datetime + key_storage_class: Optional[StorageClass] + + @classmethod + def from_request_context_native( + cls, + request_context: RequestContext, + s3_bucket: S3Bucket, + s3_object: S3Object | S3DeleteMarker, + ) -> "S3EventNotificationContext": + """ + Create an S3EventNotificationContext from a RequestContext. + The key is not always present in the request context depending on the event type. In that case, we can use + a provided one. + :param request_context: RequestContext + :param s3_bucket: S3Bucket + :param s3_object: S3Object passed directly to the context + :return: S3EventNotificationContext + """ + bucket_name = request_context.service_request["Bucket"] + event_type = EVENT_OPERATION_MAP.get(request_context.operation.wire_name, "") + + if isinstance(s3_object, S3DeleteMarker): + # AWS sets the etag of a DeleteMarker to the etag of an empty object + etag = "d41d8cd98f00b204e9800998ecf8427e" + key_size = 0 + key_expiry = None + storage_class = "" + else: + etag = s3_object.etag.strip('"') + key_size = s3_object.size + key_expiry = s3_object.expires + storage_class = s3_object.storage_class + + return cls( + request_id=request_context.request_id, + event_type=event_type, + event_time=datetime.datetime.now(), + account_id=request_context.account_id, + region=request_context.region, + caller=request_context.account_id, # TODO: use it for `userIdentity` + bucket_name=bucket_name, + bucket_location=s3_bucket.bucket_region, + bucket_account_id=s3_bucket.bucket_account_id, # TODO: use it for bucket owner identity + key_name=quote(s3_object.key), + key_etag=etag, + key_size=key_size, + key_expiry=key_expiry, + key_storage_class=storage_class, + key_version_id=s3_object.version_id + if s3_bucket.versioning_status + else None, # todo: check this? + xray=request_context.request.headers.get(HEADER_AMZN_XRAY), + ) + + +@dataclass +class BucketVerificationContext: + """ + Context object for data required for sending a `s3:TestEvent` like message. + """ + + request_id: str + bucket_name: str + region: str + configuration: Dict + skip_destination_validation: bool + + +def _matching_event(events: EventList, event_name: str) -> bool: + """ + See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-how-to-event-types-and-destinations.html + Checks if the event is part of the NotificationConfiguration, and returns if the event should be notified for + this configuration + :param events: the list of events of the NotificationConfiguration + :param event_name: the event type, like s3:ObjectCreated:* or s3:ObjectRemoved:Delete + :return: boolean indicating if the event should be sent to the notifiers + """ + if event_name in events: + return True + wildcard_pattern = f"{event_name[0 : event_name.rindex(':')]}:*" + return wildcard_pattern in events + + +def _matching_filter( + notification_filter: Optional[NotificationConfigurationFilter], key_name: str +) -> bool: + """ + See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-how-to-filtering.html + S3 allows filtering notification events with rules about the key name. + If the key does not have a filter rule, or if it matches the rule, then returns that the event should be sent + :param notification_filter: the Filter structure from NotificationConfiguration + :param key_name: the key name of the key concerned by the event + :return: boolean indicating if the key name matches the rules and the event should be sent + """ + # TODO: implement wildcard filtering + if not notification_filter or not notification_filter.get("Key", {}).get("FilterRules"): + return True + filter_rules = notification_filter.get("Key").get("FilterRules") + for rule in filter_rules: + name = rule.get("Name", "").lower() + value = rule.get("Value", "") + if name == "prefix" and not key_name.startswith(value): + return False + elif name == "suffix" and not key_name.endswith(value): + return False + + return True + + +class BaseNotifier: + service_name: str + + def notify(self, ctx: S3EventNotificationContext, config: Dict): + raise NotImplementedError + + @staticmethod + def should_notify(ctx: S3EventNotificationContext, config: Dict) -> bool: + """ + Helper method checking if the event should be notified to the configured notifiers from the configuration + :param ctx: S3EventNotificationContext + :param config: the notification config + :return: if the notifier should send the event or not + """ + return _matching_event(config["Events"], ctx.event_type) and _matching_filter( + config.get("Filter"), ctx.key_name + ) + + @staticmethod + def _get_arn_value_and_name(notifier_configuration: Dict) -> Tuple[str, str]: + raise NotImplementedError + + def validate_configuration_for_notifier( + self, + configurations: List[Dict], + skip_destination_validation: bool, + context: RequestContext, + bucket_name: str, + ): + for configuration in configurations: + self._validate_notification( + BucketVerificationContext( + configuration=configuration, + bucket_name=bucket_name, + region=context.region, + request_id=context.request_id, + skip_destination_validation=skip_destination_validation, + ) + ) + + def _verify_target(self, target_arn: str, verification_ctx: BucketVerificationContext) -> None: + raise NotImplementedError + + def _validate_notification(self, verification_ctx: BucketVerificationContext): + """ + Validates the notification configuration + - setting default ID if not provided + - validate the arn pattern + - validating the Rule names (and normalizing to capitalized) + - check if the filter value is not empty + :param verification_ctx: the verification context containing necessary data for validation + :raises InvalidArgument if the rule is not valid, infos in ArgumentName and ArgumentValue members + :return: + """ + configuration = verification_ctx.configuration + # id's can be set the request, but need to be auto-generated if they are not provided + if not configuration.get("Id"): + configuration["Id"] = short_uid() + + arn, argument_name = self._get_arn_value_and_name(configuration) + + if not re.match(f"{ARN_PARTITION_REGEX}:{self.service_name}:", arn): + raise _create_invalid_argument_exc( + "The ARN could not be parsed", name=argument_name, value=arn + ) + if not verification_ctx.skip_destination_validation: + self._verify_target(arn, verification_ctx) + + if filter_rules := configuration.get("Filter", {}).get("Key", {}).get("FilterRules"): + for rule in filter_rules: + rule["Name"] = rule["Name"].capitalize() + if rule["Name"] not in ["Suffix", "Prefix"]: + raise _create_invalid_argument_exc( + "filter rule name must be either prefix or suffix", + rule["Name"], + rule["Value"], + ) + if not rule["Value"]: + raise _create_invalid_argument_exc( + "filter value cannot be empty", rule["Name"], rule["Value"] + ) + + @staticmethod + def _get_test_payload(verification_ctx: BucketVerificationContext): + return { + "Service": "Amazon S3", + "Event": "s3:TestEvent", + "Time": timestamp_millis(), + "Bucket": verification_ctx.bucket_name, + "RequestId": verification_ctx.request_id, + "HostId": "eftixk72aD6Ap51TnqcoF8eFidJG9Z/2", + } + + @staticmethod + def _get_event_payload( + ctx: S3EventNotificationContext, config_id: NotificationId + ) -> Notification: + # Based on: http://docs.aws.amazon.com/AmazonS3/latest/dev/notification-content-structure.html + # TODO: think about caching or generating the payload only once, because only the config_id changes + # except if it is EventBridge. Check that. + partition = get_partition(ctx.region) + record = EventRecord( + eventVersion="2.1", + eventSource="aws:s3", + awsRegion=ctx.bucket_location, + eventTime=timestamp_millis(ctx.event_time), + eventName=ctx.event_type.removeprefix("s3:"), + userIdentity={"principalId": "AIDAJDPLRKLG7UEXAMPLE"}, # TODO: use the real one? + requestParameters={ + "sourceIPAddress": "127.0.0.1" + }, # TODO sourceIPAddress was previously extracted from headers ("X-Forwarded-For") + responseElements={ + "x-amz-request-id": short_uid(), + # todo this one is tricky, as it's generated by the response serializer... + "x-amz-id-2": "eftixk72aD6Ap51TnqcoF8eFidJG9Z/2", # Amazon S3 host that processed the request + }, + s3=S3NotificationContent( + s3SchemaVersion="1.0", + configurationId=config_id, + bucket={ + "name": ctx.bucket_name, + "ownerIdentity": { + "principalId": "A3NL1KOZZKExample" + }, # TODO: use proper principal? + "arn": f"arn:{partition}:s3:::{ctx.bucket_name}", + }, + object={ + "key": ctx.key_name, + "sequencer": "0055AED6DCD90281E5", + }, + ), + ) + + if ctx.key_version_id and ctx.key_version_id != "null": + # object version if bucket is versioning-enabled, otherwise null + record["s3"]["object"]["versionId"] = ctx.key_version_id + + event_type = ctx.event_type.lower() + if any(e in event_type for e in ("created", "restore")): + record["s3"]["object"]["eTag"] = ctx.key_etag + # if we created a DeleteMarker, AWS does not set the `size` field + if "deletemarker" not in event_type: + record["s3"]["object"]["size"] = ctx.key_size + + if "ObjectTagging" in ctx.event_type or "ObjectAcl" in ctx.event_type: + record["eventVersion"] = "2.3" + record["s3"]["object"]["eTag"] = ctx.key_etag + record["s3"]["object"].pop("sequencer") + + if "objectrestore:completed" in event_type: + record["glacierEventData"] = { + "restoreEventData": { + "lifecycleRestorationExpiryTime": timestamp_millis(ctx.key_expiry), + "lifecycleRestoreStorageClass": ctx.key_storage_class, + } + } + record["userIdentity"]["principalId"] = ( + "AmazonCustomer:A3NL1KOZZKExample" # TODO: use proper principal? + ) + # a bit hacky, it is to ensure the eventTime is a bit after the `Post` event, as its instant in LS + # the best would be to delay the publishing of the event + event_time = parse_timestamp(record["eventTime"]) + datetime.timedelta(milliseconds=500) + record["eventTime"] = timestamp_millis(event_time) + + return {"Records": [record]} + + +class SqsNotifier(BaseNotifier): + service_name = "sqs" + + @staticmethod + def _get_arn_value_and_name(queue_configuration: QueueConfiguration) -> Tuple[QueueArn, str]: + return queue_configuration.get("QueueArn", ""), "QueueArn" + + def _verify_target(self, target_arn: str, verification_ctx: BucketVerificationContext) -> None: + if not is_api_enabled("sqs"): + LOG.warning( + "Service 'sqs' is not enabled: skipping validation of the following destination: '%s' " + "Please check your 'SERVICES' configuration variable.", + target_arn, + ) + return + + arn_data = parse_arn(target_arn) + sqs_client = connect_to( + aws_access_key_id=arn_data["account"], region_name=arn_data["region"] + ).sqs + # test if the destination exists (done on AWS side, no permission required) + try: + queue_url = sqs_client.get_queue_url( + QueueName=arn_data["resource"], QueueOwnerAWSAccountId=arn_data["account"] + )["QueueUrl"] + except ClientError: + LOG.exception("Could not validate the notification destination %s", target_arn) + raise _create_invalid_argument_exc( + "Unable to validate the following destination configurations", + name=target_arn, + value="The destination queue does not exist", + ) + # send test event with the request metadata for permissions + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-how-to-event-types-and-destinations.html#supported-notification-event-types + sqs_client = connect_to(region_name=arn_data["region"]).sqs.request_metadata( + source_arn=s3_bucket_arn(verification_ctx.bucket_name, region=verification_ctx.region), + service_principal=ServicePrincipal.s3, + ) + test_payload = self._get_test_payload(verification_ctx) + try: + sqs_client.send_message(QueueUrl=queue_url, MessageBody=json.dumps(test_payload)) + except ClientError as e: + LOG.error( + 'Unable to send test notification for S3 bucket "%s" to SQS queue "%s"', + verification_ctx.bucket_name, + target_arn, + ) + raise _create_invalid_argument_exc( + "Unable to validate the following destination configurations", + name=target_arn, + value="Permissions on the destination queue do not allow S3 to publish notifications from this bucket", + ) from e + + def notify(self, ctx: S3EventNotificationContext, config: QueueConfiguration): + event_payload = self._get_event_payload(ctx, config.get("Id")) + message = json.dumps(event_payload) + queue_arn = config["QueueArn"] + + parsed_arn = parse_arn(queue_arn) + sqs_client = connect_to(region_name=parsed_arn["region"]).sqs.request_metadata( + source_arn=s3_bucket_arn(ctx.bucket_name, region=ctx.region), + service_principal=ServicePrincipal.s3, + ) + try: + queue_url = arns.sqs_queue_url_for_arn(queue_arn) + system_attributes = {} + if ctx.xray: + system_attributes["AWSTraceHeader"] = { + "DataType": "String", + "StringValue": ctx.xray, + } + sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=message, + MessageSystemAttributes=system_attributes, + ) + except Exception: + LOG.exception( + 'Unable to send notification for S3 bucket "%s" to SQS queue "%s"', + ctx.bucket_name, + parsed_arn["resource"], + ) + + +class SnsNotifier(BaseNotifier): + service_name = "sns" + + @staticmethod + def _get_arn_value_and_name(topic_configuration: TopicConfiguration) -> [TopicArn, str]: + return topic_configuration.get("TopicArn", ""), "TopicArn" + + def _verify_target(self, target_arn: str, verification_ctx: BucketVerificationContext) -> None: + if not is_api_enabled("sns"): + LOG.warning( + "Service 'sns' is not enabled: skipping validation of the following destination: '%s' " + "Please check your 'SERVICES' configuration variable.", + target_arn, + ) + return + arn_data = parse_arn(target_arn) + sns_client = connect_to( + aws_access_key_id=arn_data["account"], region_name=arn_data["region"] + ).sns + try: + sns_client.get_topic_attributes(TopicArn=target_arn) + except ClientError: + raise _create_invalid_argument_exc( + "Unable to validate the following destination configurations", + name=target_arn, + value="The destination topic does not exist", + ) + + sns_client = connect_to(region_name=arn_data["region"]).sns.request_metadata( + source_arn=s3_bucket_arn(verification_ctx.bucket_name, region=verification_ctx.region), + service_principal=ServicePrincipal.s3, + ) + test_payload = self._get_test_payload(verification_ctx) + try: + sns_client.publish( + TopicArn=target_arn, + Message=json.dumps(test_payload), + Subject="Amazon S3 Notification", + ) + except ClientError as e: + LOG.error( + 'Unable to send test notification for S3 bucket "%s" to SNS topic "%s"', + verification_ctx.bucket_name, + target_arn, + ) + raise _create_invalid_argument_exc( + "Unable to validate the following destination configurations", + name=target_arn, + value="Permissions on the destination topic do not allow S3 to publish notifications from this bucket", + ) from e + + def notify(self, ctx: S3EventNotificationContext, config: TopicConfiguration): + LOG.debug( + "Task received by a worker for notification to %s for bucket %s, key %s, action %s", + self.service_name, + ctx.bucket_name, + ctx.key_name, + ctx.event_type, + ) + event_payload = self._get_event_payload(ctx, config.get("Id")) + message = json.dumps(event_payload) + topic_arn = config["TopicArn"] + + arn_data = parse_arn(topic_arn) + sns_client = connect_to(region_name=arn_data["region"]).sns.request_metadata( + source_arn=s3_bucket_arn(ctx.bucket_name, region=ctx.region), + service_principal=ServicePrincipal.s3, + ) + try: + sns_client.publish( + TopicArn=topic_arn, + Message=message, + Subject="Amazon S3 Notification", + ) + except Exception: + LOG.exception( + 'Unable to send notification for S3 bucket "%s" to SNS topic "%s"', + ctx.bucket_name, + topic_arn, + ) + + +class LambdaNotifier(BaseNotifier): + service_name = "lambda" + + @staticmethod + def _get_arn_value_and_name( + lambda_configuration: LambdaFunctionConfiguration, + ) -> Tuple[LambdaFunctionArn, str]: + return lambda_configuration.get("LambdaFunctionArn", ""), "LambdaFunctionArn" + + def _verify_target(self, target_arn: str, verification_ctx: BucketVerificationContext) -> None: + if not is_api_enabled("lambda"): + LOG.warning( + "Service 'lambda' is not enabled: skipping validation of the following destination: '%s' " + "Please check your 'SERVICES' configuration variable.", + target_arn, + ) + return + arn_data = parse_arn(arn=target_arn) + lambda_client = connect_to( + aws_access_key_id=arn_data["account"], region_name=arn_data["region"] + ).lambda_ + try: + lambda_client.get_function(FunctionName=target_arn) + except ClientError: + raise _create_invalid_argument_exc( + "Unable to validate the following destination configurations", + name=target_arn, + value="The destination Lambda does not exist", + ) + lambda_client = connect_to(region_name=arn_data["region"]).lambda_.request_metadata( + source_arn=s3_bucket_arn(verification_ctx.bucket_name, region=verification_ctx.region), + service_principal=ServicePrincipal.s3, + ) + try: + lambda_client.invoke(FunctionName=target_arn, InvocationType=InvocationType.DryRun) + except ClientError as e: + raise _create_invalid_argument_exc( + "Unable to validate the following destination configurations", + name=f"{target_arn}, null", + value=f"Not authorized to invoke function [{target_arn}]", + ) from e + + def notify(self, ctx: S3EventNotificationContext, config: LambdaFunctionConfiguration): + event_payload = self._get_event_payload(ctx, config.get("Id")) + payload = json.dumps(event_payload) + lambda_arn = config["LambdaFunctionArn"] + + arn_data = parse_arn(lambda_arn) + + lambda_client = connect_to(region_name=arn_data["region"]).lambda_.request_metadata( + source_arn=s3_bucket_arn(ctx.bucket_name, region=ctx.region), + service_principal=ServicePrincipal.s3, + ) + + try: + lambda_client.invoke( + FunctionName=lambda_arn, + InvocationType="Event", + Payload=payload, + ) + except Exception: + LOG.exception( + 'Unable to send notification for S3 bucket "%s" to Lambda function "%s".', + ctx.bucket_name, + lambda_arn, + ) + + +class EventBridgeNotifier(BaseNotifier): + service_name = "events" + + @staticmethod + def _get_event_payload( + ctx: S3EventNotificationContext, config_id: NotificationId = None + ) -> PutEventsRequestEntry: + # see https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventBridge.html + # see also https://docs.aws.amazon.com/AmazonS3/latest/userguide/ev-events.html + partition = get_partition(ctx.region) + entry: PutEventsRequestEntry = { + "Source": "aws.s3", + "Resources": [f"arn:{partition}:s3:::{ctx.bucket_name}"], + "Time": ctx.event_time, + } + + if ctx.xray: + entry["TraceHeader"] = ctx.xray + + event_details = { + "version": "0", + "bucket": {"name": ctx.bucket_name}, + "object": { + "key": ctx.key_name, + "size": ctx.key_size, + "etag": ctx.key_etag, + "sequencer": "0062E99A88DC407460", + }, + "request-id": ctx.request_id, + "requester": "074255357339", + "source-ip-address": "127.0.0.1", + # TODO previously headers.get("X-Forwarded-For", "127.0.0.1").split(",")[0] + } + if ctx.key_version_id and ctx.key_version_id != "null": + event_details["object"]["version-id"] = ctx.key_version_id + + if "ObjectCreated" in ctx.event_type: + entry["DetailType"] = "Object Created" + event_type = ctx.event_type + event_action = event_type[event_type.rindex(":") + 1 :] + if event_action in ["Put", "Post", "Copy"]: + event_type = f"{event_action}Object" + # TODO: what about CompleteMultiformUpload?? + event_details["reason"] = event_type + + elif "ObjectRemoved" in ctx.event_type: + entry["DetailType"] = "Object Deleted" + event_details["reason"] = "DeleteObject" + if "DeleteMarkerCreated" in ctx.event_type: + delete_type = "Delete Marker Created" + else: + delete_type = "Permanently Deleted" + event_details["object"].pop("etag") + + event_details["deletion-type"] = delete_type + event_details["object"].pop("size") + + elif "ObjectTagging" in ctx.event_type: + entry["DetailType"] = ( + "Object Tags Added" if "Put" in ctx.event_type else "Object Tags Deleted" + ) + + elif "ObjectAcl" in ctx.event_type: + entry["DetailType"] = "Object ACL Updated" + event_details["object"].pop("size") + event_details["object"].pop("sequencer") + + elif "ObjectRestore" in ctx.event_type: + entry["DetailType"] = ( + "Object Restore Initiated" + if "Post" in ctx.event_type + else "Object Restore Completed" + ) + event_details["source-storage-class"] = ctx.key_storage_class + event_details["object"].pop("sequencer", None) + if ctx.event_type.endswith("Completed"): + event_details["restore-expiry-time"] = timestamp_millis(ctx.key_expiry) + event_details.pop("source-ip-address", None) + # a bit hacky, it is to ensure the eventTime is a bit after the `Post` event, as its instant in LS + # the best would be to delay the publishing of the event. We need at least 1s as it's the precision + # of the event + entry["Time"] = entry["Time"] + datetime.timedelta(seconds=1) + + entry["Detail"] = json.dumps(event_details) + return entry + + @staticmethod + def should_notify(ctx: S3EventNotificationContext, config: Dict) -> bool: + # Events are always passed to EventBridge, you can route the event in EventBridge + # See https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventBridge.html + return True + + def validate_configuration_for_notifier( + self, + configurations: List[Dict], + skip_destination_validation: bool, + context: RequestContext, + bucket_name: str, + ): + # There are no configuration for EventBridge, simply passing an empty dict will enable notifications + return + + def _verify_target(self, target_arn: str, verification_ctx: BucketVerificationContext) -> None: + # There are no target for EventBridge + return + + def notify(self, ctx: S3EventNotificationContext, config: EventBridgeConfiguration): + # does not require permissions + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/ev-permissions.html + # the account_id should be the bucket owner + # - account β€” The 12-digit AWS account ID of the bucket owner. + events_client = connect_to( + aws_access_key_id=ctx.bucket_account_id, region_name=ctx.bucket_location + ).events + entry = self._get_event_payload(ctx) + try: + events_client.put_events(Entries=[entry]) + except Exception: + LOG.exception( + 'Unable to send notification for S3 bucket "%s" to EventBridge', ctx.bucket_name + ) + + +class NotificationDispatcher: + notifiers = { + "QueueConfigurations": SqsNotifier(), + "TopicConfigurations": SnsNotifier(), + "LambdaFunctionConfigurations": LambdaNotifier(), + "EventBridgeConfiguration": EventBridgeNotifier(), + } + + def __init__(self, num_thread: int = 3): + self.executor = ThreadPoolExecutor(num_thread, thread_name_prefix="s3_ev") + + def shutdown(self): + self.executor.shutdown(wait=False) + + def send_notifications( + self, ctx: S3EventNotificationContext, notification_config: NotificationConfiguration + ): + for configuration_key, configurations in notification_config.items(): + notifier = self.notifiers[configuration_key] + if not is_api_enabled(notifier.service_name): + LOG.warning( + "Service '%s' is not enabled: skip sending notification. " + "Please check your 'SERVICES' configuration variable.", + notifier.service_name, + ) + continue + # there is not really a configuration for EventBridge, it is an empty dict + configurations = ( + configurations if isinstance(configurations, list) else [configurations] + ) + for config in configurations: + if notifier.should_notify(ctx, config): # we check before sending it to the thread + LOG.debug("Submitting task to the executor for notifier %s", notifier) + self._submit_notification(notifier, ctx, config) + + def _submit_notification(self, notifier, ctx, config): + "Required for patching submit with local thread context for EventStudio" + self.executor.submit(notifier.notify, ctx, config) + + def verify_configuration( + self, + notification_configurations: NotificationConfiguration, + skip_destination_validation, + context: RequestContext, + bucket_name: str, + ): + for notifier_type, notification_configuration in notification_configurations.items(): + self.notifiers[notifier_type].validate_configuration_for_notifier( + notification_configuration, skip_destination_validation, context, bucket_name + ) diff --git a/localstack-core/localstack/services/s3/presigned_url.py b/localstack-core/localstack/services/s3/presigned_url.py new file mode 100644 index 0000000000000..e696e82e2c2dc --- /dev/null +++ b/localstack-core/localstack/services/s3/presigned_url.py @@ -0,0 +1,943 @@ +import base64 +import copy +import datetime +import json +import logging +import re +import time +from collections import namedtuple +from functools import cache, cached_property +from typing import Mapping, Optional, TypedDict +from urllib import parse as urlparse + +from botocore.auth import HmacV1QueryAuth, S3SigV4QueryAuth +from botocore.awsrequest import AWSRequest, create_request_object +from botocore.compat import HTTPHeaders, urlsplit +from botocore.credentials import Credentials, ReadOnlyCredentials +from botocore.exceptions import NoCredentialsError +from botocore.model import ServiceModel +from botocore.utils import percent_encode_sequence +from werkzeug.datastructures import Headers, ImmutableMultiDict + +from localstack import config +from localstack.aws.accounts import get_account_id_from_access_key_id +from localstack.aws.api import CommonServiceException, RequestContext +from localstack.aws.api.s3 import ( + AccessDenied, + AuthorizationQueryParametersError, + EntityTooLarge, + EntityTooSmall, + InvalidArgument, + InvalidBucketName, + SignatureDoesNotMatch, +) +from localstack.aws.chain import HandlerChain +from localstack.aws.protocol.op_router import RestServiceOperationRouter +from localstack.aws.spec import get_service_catalog +from localstack.http import Request, Response +from localstack.http.request import get_raw_path +from localstack.services.s3.constants import ( + DEFAULT_PRE_SIGNED_ACCESS_KEY_ID, + DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY, + SIGNATURE_V2_PARAMS, + SIGNATURE_V4_PARAMS, +) +from localstack.services.s3.utils import ( + S3_VIRTUAL_HOST_FORWARDED_HEADER, + _create_invalid_argument_exc, + capitalize_header_name_from_snake_case, + extract_bucket_name_and_key_from_headers_and_path, + forwarded_from_virtual_host_addressed_request, + is_bucket_name_valid, + is_presigned_url_request, + uses_host_addressing, +) +from localstack.utils.aws.arns import get_partition +from localstack.utils.strings import to_bytes + +LOG = logging.getLogger(__name__) + + +SIGNATURE_V2_POST_FIELDS = [ + "signature", + "awsaccesskeyid", +] + +SIGNATURE_V4_POST_FIELDS = [ + "x-amz-signature", + "x-amz-algorithm", + "x-amz-credential", + "x-amz-date", +] + +# Boto3 has some issues with some headers that it disregards and does not validate or adds to the signature +# we need to manually define them +# see https://github.com/boto/boto3/issues/4367 +SIGNATURE_V4_BOTO_IGNORED_PARAMS = [ + "if-none-match", + "if-match", +] + +# headers to blacklist from request_dict.signed_headers +BLACKLISTED_HEADERS = ["X-Amz-Security-Token"] + +IGNORED_SIGV4_HEADERS = [ + "x-amz-content-sha256", +] + +FAKE_HOST_ID = "9Gjjt1m+cjU4OPvX9O9/8RuvnG41MRb/18Oux2o5H5MY7ISNTlXN+Dz9IG62/ILVxhAGI0qyPfg=" + +HOST_COMBINATION_REGEX = r"^(.*)(:[\d]{0,6})" +PORT_REPLACEMENT = [":80", ":443", f":{config.GATEWAY_LISTEN[0].port}", ""] + +# STS policy expiration date format +POLICY_EXPIRATION_FORMAT1 = "%Y-%m-%dT%H:%M:%SZ" +POLICY_EXPIRATION_FORMAT2 = "%Y-%m-%dT%H:%M:%S.%fZ" + +PreSignedCredentials = namedtuple( + "PreSignedCredentials", ["access_key_id", "secret_access_key", "security_token"] +) + + +class NotValidSigV4SignatureContext(TypedDict): + signature_provided: str + string_to_sign: str + canonical_request: str + + +FindSigV4Result = tuple["S3SigV4SignatureContext", Optional[NotValidSigV4SignatureContext]] + + +class HmacV1QueryAuthValidation(HmacV1QueryAuth): + """ + Override _get_date for signature calculation, to use received date instead of adding a fixed Expired time + """ + + post_signature_headers = [ + header.lower() + for header in SIGNATURE_V2_PARAMS + BLACKLISTED_HEADERS + HmacV1QueryAuth.QSAOfInterest + ] + QSAOfInterest_low = [qs.lower() for qs in HmacV1QueryAuth.QSAOfInterest] + + def _get_date(self): + return str(int(self._expires)) # noqa + + def get_signature(self, method, split, headers: HTTPHeaders, expires=None, auth_path=None): + if self.credentials.token: + del headers["x-amz-security-token"] + headers["x-amz-security-token"] = self.credentials.token + string_to_sign = self.canonical_string(method, split, headers, auth_path=auth_path) + return self.sign_string(string_to_sign), string_to_sign + + +class S3SigV4QueryAuthValidation(S3SigV4QueryAuth): + """ + Override the timestamp for signature calculation, to use received timestamp instead of adding a fixed Expired time + """ + + def add_auth(self, request) -> tuple[bytes, str, str]: + if self.credentials is None: # noqa + raise NoCredentialsError() + canonical_request = self.canonical_request(request) + string_to_sign = self.string_to_sign(request, canonical_request) + signature = self.signature(string_to_sign, request) + + return signature, canonical_request, string_to_sign + + +# we are taking advantages of the fact that non-attached members are not returned +# those exceptions are polymorphic, they can have multiple shapes under the same name + + +def create_signature_does_not_match_sig_v2( + request_signature: str, string_to_sign: str, access_key_id: str +) -> SignatureDoesNotMatch: + ex = SignatureDoesNotMatch( + "The request signature we calculated does not match the signature you provided. Check your key and signing method." + ) + ex.AWSAccessKeyId = access_key_id + ex.HostId = FAKE_HOST_ID + ex.SignatureProvided = request_signature + ex.StringToSign = string_to_sign + ex.StringToSignBytes = to_bytes(string_to_sign).hex(sep=" ", bytes_per_sep=2).upper() + return ex + + +def create_signature_does_not_match_sig_v4( + not_valid_sig_v4: NotValidSigV4SignatureContext, access_key_id: str +) -> SignatureDoesNotMatch: + ex = create_signature_does_not_match_sig_v2( + request_signature=not_valid_sig_v4["signature_provided"], + string_to_sign=not_valid_sig_v4["string_to_sign"], + access_key_id=access_key_id, + ) + ex.CanonicalRequest = not_valid_sig_v4["canonical_request"] + ex.CanonicalRequestBytes = to_bytes(ex.CanonicalRequest).hex(sep=" ", bytes_per_sep=2).upper() + return ex + + +class S3PreSignedURLRequestHandler: + @cached_property + def _service(self) -> ServiceModel: + return get_service_catalog().get("s3") + + @cached_property + def _s3_op_router(self) -> RestServiceOperationRouter: + return RestServiceOperationRouter(self._service) + + def __call__(self, _: HandlerChain, context: RequestContext, __: Response): + """ + Handler to validate S3 pre-signed URL. Checks the validity of the request signature, and raises an error if + `S3_SKIP_SIGNATURE_VALIDATION` is set to False + """ + if not context.service or context.service.service_name != "s3": + return + try: + if not is_presigned_url_request(context): + # validate headers, as some can raise ValueError in Moto + _validate_headers_for_moto(context.request.headers) + return + # will raise exceptions if the url is not valid, except if S3_SKIP_SIGNATURE_VALIDATION is True + # will still try to validate it and log if there's an error + + # We save the query args as a set to save time for lookup in validation + query_arg_set = set(context.request.args) + + if is_valid_sig_v2(query_arg_set): + validate_presigned_url_s3(context) + + elif is_valid_sig_v4(query_arg_set): + validate_presigned_url_s3v4(context) + + _validate_headers_for_moto(context.request.headers) + LOG.debug("Valid presign url.") + # TODO: set the Authorization with the data from the pre-signed query string + + except Exception: + # as we are raising before the ServiceRequestParser, we need to + context.service = self._service + context.operation = self._get_op_from_request(context.request) + raise + + def _get_op_from_request(self, request: Request): + try: + op, _ = self._s3_op_router.match(request) + return op + except Exception: + # if we can't parse the request, just set GetObject + return self._service.operation_model("GetObject") + + +def get_credentials_from_parameters(parameters: dict, region: str) -> PreSignedCredentials: + """ + Extract and retrieves the credentials from the passed signed requests parameters (can be from the query string or + the form for POST requests) + :param parameters: + :return: + """ + # This is V2 signature AccessKeyId + if not (access_key_id := parameters.get("AWSAccessKeyId")): + # If not present, then it is a V4 signature (casing differs between QS parameters and form) + credential_value = parameters.get( + "X-Amz-Credential", parameters.get("x-amz-credential", "") + ).split("/") + if credential_value: + access_key_id = credential_value[0] + + if not access_key_id: + # fallback to the hardcoded value + access_key_id = DEFAULT_PRE_SIGNED_ACCESS_KEY_ID + + if not (secret_access_key := get_secret_access_key_from_access_key_id(access_key_id, region)): + # if we could not retrieve the secret access key, it means the access key was not registered in LocalStack, + # fallback to hardcoded necessary secret access key + secret_access_key = DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY + + security_token = parameters.get("X-Amz-Security-Token", None) + return PreSignedCredentials(access_key_id, secret_access_key, security_token) + + +@cache +def get_secret_access_key_from_access_key_id(access_key_id: str, region: str) -> Optional[str]: + """ + We need to retrieve the internal secret access key in order to also sign the request on our side to validate it + For now, we need to access Moto internals, as they are no public APIs to retrieve it for obvious reasons. + If the AccessKey is not registered, use the default `test` value that was historically used for pre-signed URLs, in + order to support default use cases + :param access_key_id: the provided AccessKeyID in the Credentials parameter + :param region: the region from the credentials + :return: the linked secret_access_key to the access_key + """ + try: + from moto.iam.models import AccessKey, iam_backends + except ImportError: + return + + account_id = get_account_id_from_access_key_id(access_key_id) + moto_access_key: AccessKey = iam_backends[account_id][get_partition(region)].access_keys.get( + access_key_id + ) + if not moto_access_key: + return + + return moto_access_key.secret_access_key + + +def is_expired(expiry_datetime: datetime.datetime): + now_datetime = datetime.datetime.now(tz=expiry_datetime.tzinfo) + return now_datetime > expiry_datetime + + +def is_valid_sig_v2(query_args: set) -> bool: + """ + :param query_args: a Set representing the query parameters from the presign URL request + :raises AccessDenied: if the query contains parts of the required parameters but not all + :return: True if the request is a valid SigV2 request, or False if no parameters are found to be related to SigV2 + """ + if any(p in query_args for p in SIGNATURE_V2_PARAMS): + if not all(p in query_args for p in SIGNATURE_V2_PARAMS): + LOG.info("Presign signature calculation failed") + raise AccessDenied( + "Query-string authentication requires the Signature, Expires and AWSAccessKeyId parameters", + HostId=FAKE_HOST_ID, + ) + + return True + return False + + +def is_valid_sig_v4(query_args: set) -> bool: + """ + :param query_args: a Set representing the query parameters from the presign URL request + :raises AuthorizationQueryParametersError: if the query contains parts of the required parameters but not all + :return: True if the request is a valid SigV4 request, or False if no parameters are found to be related to SigV4 + """ + if any(p in query_args for p in SIGNATURE_V4_PARAMS): + if not all(p in query_args for p in SIGNATURE_V4_PARAMS): + LOG.info("Presign signature calculation failed") + raise AuthorizationQueryParametersError( + "Query-string authentication version 4 requires the X-Amz-Algorithm, X-Amz-Credential, X-Amz-Signature, X-Amz-Date, X-Amz-SignedHeaders, and X-Amz-Expires parameters.", + HostId=FAKE_HOST_ID, + ) + + return True + return False + + +def validate_presigned_url_s3(context: RequestContext) -> None: + """ + Validate the presigned URL signed with SigV2. + :param context: RequestContext + """ + query_parameters = context.request.args + method = context.request.method + credentials = get_credentials_from_parameters(query_parameters, "us-east-1") + signing_credentials = Credentials( + access_key=credentials.access_key_id, + secret_key=credentials.secret_access_key, + token=credentials.security_token, + ) + try: + expires = int(query_parameters["Expires"]) + except (ValueError, TypeError): + # TODO: test this in AWS?? + raise SignatureDoesNotMatch("Expires error?") + + # Checking whether the url is expired or not + if expires < time.time(): + if config.S3_SKIP_SIGNATURE_VALIDATION: + LOG.warning( + "Signature is expired, but not raising an error, as S3_SKIP_SIGNATURE_VALIDATION=1" + ) + else: + raise AccessDenied( + "Request has expired", HostId=FAKE_HOST_ID, Expires=expires, ServerTime=time.time() + ) + + auth_signer = HmacV1QueryAuthValidation(credentials=signing_credentials, expires=expires) + + split_url, headers = _reverse_inject_signature_hmac_v1_query(context.request) + + signature, string_to_sign = auth_signer.get_signature( + method, split_url, headers, auth_path=None + ) + # after passing through the virtual host to path proxy, the signature is parsed and `+` are replaced by space + req_signature = context.request.args.get("Signature").replace(" ", "+") + + if not signature == req_signature: + if config.S3_SKIP_SIGNATURE_VALIDATION: + LOG.warning( + "Signatures do not match, but not raising an error, as S3_SKIP_SIGNATURE_VALIDATION=1" + ) + else: + ex: SignatureDoesNotMatch = create_signature_does_not_match_sig_v2( + request_signature=req_signature, + string_to_sign=string_to_sign, + access_key_id=credentials.access_key_id, + ) + raise ex + + add_headers_to_original_request(context, headers) + + +def _reverse_inject_signature_hmac_v1_query( + request: Request, +) -> tuple[urlparse.SplitResult, HTTPHeaders]: + """ + Reverses what does HmacV1QueryAuth._inject_signature while injecting the signature in the request. + Transforms the query string parameters in headers to recalculate the signature + see botocore.auth.HmacV1QueryAuth._inject_signature + :param request: the original request + :return: tuple of a split result from the reversed request, and the reversed headers + """ + new_headers = {} + new_query_string_dict = {} + + for header, value in request.args.items(): + header_low = header.lower() + if header_low not in HmacV1QueryAuthValidation.post_signature_headers: + new_headers[header] = value + elif header_low in HmacV1QueryAuthValidation.QSAOfInterest_low: + new_query_string_dict[header] = value + + # there should not be any headers here. If there are, it means they have been added by the client + # We should verify them, they will fail the signature except if they were part of the original request + for header, value in request.headers.items(): + header_low = header.lower() + if header_low.startswith("x-amz-") or header_low in ["content-type", "date", "content-md5"]: + new_headers[header_low] = value + + # rebuild the query string + new_query_string = percent_encode_sequence(new_query_string_dict) + + if bucket_name := uses_host_addressing(request.headers): + # if the request is host addressed, we need to remove the bucket from the host and set it in the path + path = f"/{bucket_name}{request.path}" + host = request.host.removeprefix(f"{bucket_name}.") + else: + path = request.path + host = request.host + + # we need to URL encode the path, as the key needs to be urlencoded for the signature to match + encoded_path = urlparse.quote(path) + + reversed_url = f"{request.scheme}://{host}{encoded_path}?{new_query_string}" + + reversed_headers = HTTPHeaders() + for key, value in new_headers.items(): + reversed_headers[key] = value + + return urlsplit(reversed_url), reversed_headers + + +def validate_presigned_url_s3v4(context: RequestContext) -> None: + """ + Validate the presigned URL signed with SigV4. + :param context: RequestContext + :return: + """ + + sigv4_context, exception = _find_valid_signature_through_ports(context) + add_headers_to_original_request(context, sigv4_context.headers_in_qs) + + if sigv4_context.missing_signed_headers: + if config.S3_SKIP_SIGNATURE_VALIDATION: + LOG.warning( + "There were headers present in the request which were not signed (%s), " + "but not raising an error, as S3_SKIP_SIGNATURE_VALIDATION=1", + ", ".join(sigv4_context.missing_signed_headers), + ) + else: + raise AccessDenied( + "There were headers present in the request which were not signed", + HostId=FAKE_HOST_ID, + HeadersNotSigned=", ".join(sigv4_context.missing_signed_headers), + ) + + if exception: + if config.S3_SKIP_SIGNATURE_VALIDATION: + LOG.warning( + "Signatures do not match, but not raising an error, as S3_SKIP_SIGNATURE_VALIDATION=1" + ) + else: + ex: SignatureDoesNotMatch = create_signature_does_not_match_sig_v4( + exception, sigv4_context.credentials.access_key_id + ) + raise ex + + # Checking whether the url is expired or not + query_parameters = context.request.args + # TODO: should maybe try/except here -> create auth params validation before checking signature, above!! + x_amz_date = datetime.datetime.strptime(query_parameters["X-Amz-Date"], "%Y%m%dT%H%M%SZ") + x_amz_expires = int(query_parameters["X-Amz-Expires"]) + x_amz_expires_dt = datetime.timedelta(seconds=x_amz_expires) + expiration_time = x_amz_date + x_amz_expires_dt + expiration_time = expiration_time.replace(tzinfo=datetime.timezone.utc) + + if is_expired(expiration_time): + if config.S3_SKIP_SIGNATURE_VALIDATION: + LOG.warning( + "Signature is expired, but not raising an error, as S3_SKIP_SIGNATURE_VALIDATION=1" + ) + else: + raise AccessDenied( + "Request has expired", + HostId=FAKE_HOST_ID, + Expires=expiration_time.timestamp(), + ServerTime=time.time(), + X_Amz_Expires=x_amz_expires, + ) + + +def _find_valid_signature_through_ports(context: RequestContext) -> FindSigV4Result: + """ + Tries to validate the signature of the received request. If it fails, it will iterate through known LocalStack + ports to try to find a match (the host is used for the calculation). + If it fails to find a valid match, it will return NotValidSigV4Signature context data + :param context: + :return: FindSigV4Result: contains a tuple with the signature if found, or NotValidSigV4Signature context + """ + request_sig = context.request.args["X-Amz-Signature"] + + sigv4_context = S3SigV4SignatureContext(context=context) + # get the port of the request + match = re.match(HOST_COMBINATION_REGEX, sigv4_context.host) + request_port = match.group(2) if match else None + + # get the signature from the request + signature, canonical_request, string_to_sign = sigv4_context.get_signature_data() + if signature == request_sig: + return sigv4_context, None + + # if the signature does not match, save the data for the exception + exception_context = NotValidSigV4SignatureContext( + signature_provided=request_sig, + string_to_sign=string_to_sign, + canonical_request=canonical_request, + ) + + # we try to iterate through possible ports, to match the signature + for port in PORT_REPLACEMENT: + if request_port: + # the request has already been tested before the loop, skip + if request_port == port: + continue + sigv4_context.update_host_port(new_host_port=port, original_host_port=request_port) + + else: + sigv4_context.update_host_port(new_host_port=port) + + # we ignore the additional data because we want the exception raised to match the original request + signature, _, _ = sigv4_context.get_signature_data() + if signature == request_sig: + return sigv4_context, None + + # Return the exception data from the original request after trying to loop through ports + return sigv4_context, exception_context + + +class S3SigV4SignatureContext: + def __init__(self, context: RequestContext): + self.request = context.request + self._query_parameters = context.request.args + self._headers = context.request.headers + self._bucket, _ = extract_bucket_name_and_key_from_headers_and_path( + context.request.headers, get_raw_path(context.request) + ) + self._bucket = urlparse.unquote(self._bucket) + self._request_method = context.request.method + self.missing_signed_headers = [] + + region = self._get_region_from_x_amz_credential(self._query_parameters["X-Amz-Credential"]) + credentials = get_credentials_from_parameters(self._query_parameters, region) + signing_credentials = ReadOnlyCredentials( + credentials.access_key_id, + credentials.secret_access_key, + credentials.security_token, + ) + self.credentials = credentials + expires = int(self._query_parameters["X-Amz-Expires"]) + self.signature_date = self._query_parameters["X-Amz-Date"] + + self.signer = S3SigV4QueryAuthValidation(signing_credentials, "s3", region, expires=expires) + sig_headers, qs, headers_in_qs = self._get_signed_headers_and_filtered_query_string() + self.signed_headers = sig_headers + self.request_query_string = qs + self.headers_in_qs = headers_in_qs | sig_headers + self.headers_in_qs["Authorization"] = self._get_authorization_header_from_qs( + self._query_parameters + ) + + if forwarded_from_virtual_host_addressed_request(self._headers): + # FIXME: maybe move this so it happens earlier in the chain when using virtual host? + if not is_bucket_name_valid(self._bucket): + raise InvalidBucketName(BucketName=self._bucket) + netloc = self._headers.get(S3_VIRTUAL_HOST_FORWARDED_HEADER) + self.host = netloc + self._original_host = netloc + self.signed_headers["host"] = netloc + # the request comes from the Virtual Host router, we need to remove the bucket from the path + splitted_path = self.request.path.split("/", maxsplit=2) + self.path = f"/{splitted_path[-1]}" + + else: + netloc = urlparse.urlparse(self.request.url).netloc + self.host = netloc + self._original_host = netloc + if (host_addressed := uses_host_addressing(self._headers)) and not is_bucket_name_valid( + self._bucket + ): + raise InvalidBucketName(BucketName=self._bucket) + + if not host_addressed and not self.request.path.startswith(f"/{self._bucket}"): + # if in path style, check that the path starts with the bucket + # our path has been sanitized, we should use the un-sanitized one + splitted_path = self.request.path.split("/", maxsplit=2) + self.path = f"/{self._bucket}/{splitted_path[-1]}" + else: + self.path = self.request.path + + # we need to URL encode the path, as the key needs to be urlencoded for the signature to match + self.path = urlparse.quote(self.path) + self.aws_request = self._get_aws_request() + + def update_host_port(self, new_host_port: str, original_host_port: str = None): + """ + Update the host port of the context with the provided one, format `:{port}` + :param new_host_port: + :param original_host_port: + :return: + """ + if original_host_port: + updated_netloc = self._original_host.replace(original_host_port, new_host_port) + else: + updated_netloc = f"{self._original_host}{new_host_port}" + self.host = updated_netloc + self.signed_headers["host"] = updated_netloc + self.aws_request = self._get_aws_request() + + @property + def request_url(self) -> str: + return f"{self.request.scheme}://{self.host}{self.path}?{self.request_query_string}" + + def get_signature_data(self) -> tuple[bytes, str, str]: + """ + Uses the signer to return the signature and the data used to calculate it + :return: signature, canonical_request and string_to_sign + """ + return self.signer.add_auth(self.aws_request) + + def _get_signed_headers_and_filtered_query_string( + self, + ) -> tuple[dict[str, str], str, dict[str, str]]: + """ + Transforms the original headers and query parameters to the headers and query string used to sign the + original request. + Allows us to recreate the original request, and also retrieve query string parameters that should be headers + :raises AccessDenied if the request contains headers that were not in X-Amz-SignedHeaders and started with x-amz + :return: the headers used to sign the request and the query string without X-Amz-Signature, and the query string + parameters which should be put back in the headers + """ + headers = copy.copy(self._headers) + # set automatically by the handler chain, we don't want that + headers.pop("Authorization", None) + signed_headers = self._query_parameters.get("X-Amz-SignedHeaders") + + new_query_args = {} + query_args_to_headers = {} + for qs_parameter, qs_value in self._query_parameters.items(): + # skip the signature + if qs_parameter == "X-Amz-Signature": + continue + + qs_param_low = qs_parameter.lower() + if ( + qs_parameter not in SIGNATURE_V4_PARAMS + and ( + qs_param_low.startswith("x-amz-") + or qs_param_low in SIGNATURE_V4_BOTO_IGNORED_PARAMS + ) + and qs_param_low not in headers + ): + if qs_param_low in signed_headers: + # AWS JS SDK does not behave like boto, and will add some parameters as query string when signing + # when boto would not. this difference in behaviour would lead to pre-signed URLs generated by the + # JS SDK to be invalid for the boto signer. + # This fixes the behaviour by manually adding the parameter to the headers like boto would, if the + # SDK put them in the SignedHeaders + # this is especially valid for headers starting with x-amz-server-side-encryption, treated + # specially in the old JS SDK v2 + headers.add(qs_param_low, qs_value) + else: + # The JS SDK is adding the `x-amz-checksum-crc32` header to query parameters, even though it cannot + # know in advance the actual checksum. Those are ignored by AWS, if they're not put in the + # SignedHeaders + if not qs_param_low.startswith("x-amz-checksum-"): + query_args_to_headers[qs_param_low] = qs_value + + new_query_args[qs_parameter] = qs_value + + signature_headers = {} + for header, value in headers.items(): + header_low = header.lower() + if header_low.startswith("x-amz-") and header_low not in signed_headers.lower(): + if header_low in IGNORED_SIGV4_HEADERS: + continue + self.missing_signed_headers.append(header_low) + if header_low in signed_headers: + signature_headers[header_low] = value + + new_query_string = percent_encode_sequence(new_query_args) + return signature_headers, new_query_string, query_args_to_headers + + def _get_aws_request(self) -> AWSRequest: + """ + Creates and returns the AWSRequest needed for S3SigV4QueryAuth signer + :return: AWSRequest + """ + request_dict = { + "method": self._request_method, + "url": self.request_url, + "body": b"", + "headers": self.signed_headers, + "context": { + "is_presign_request": True, + "use_global_endpoint": True, + "signing": {"bucket": self._bucket}, + "timestamp": self.signature_date, + }, + } + return create_request_object(request_dict) + + @staticmethod + def _get_region_from_x_amz_credential(credential: str) -> str: + if not (split_creds := credential.split("/")) or len(split_creds) != 5: + raise AuthorizationQueryParametersError( + 'Error parsing the X-Amz-Credential parameter; the Credential is mal-formed; expecting "/YYYYMMDD/REGION/SERVICE/aws4_request".', + HostId=FAKE_HOST_ID, + ) + + return split_creds[2] + + @staticmethod + def _get_authorization_header_from_qs(parameters: dict) -> str: + # See https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html + # Recreating the Authorization header from the query string parameters of a pre-signed request + authorization_keys = ["X-Amz-Credential", "X-Amz-SignedHeaders", "X-Amz-Signature"] + values = [ + f"{param.removeprefix('X-Amz-')}={parameters[param]}" for param in authorization_keys + ] + + authorization = f"{parameters['X-Amz-Algorithm']}{','.join(values)}" + return authorization + + +def add_headers_to_original_request(context: RequestContext, headers: Mapping[str, str]): + for header, value in headers.items(): + context.request.headers.add(header, value) + + +def _validate_headers_for_moto(headers: Headers) -> None: + """ + The headers can contain values that do not have the right type, and it will throw Exception when passed to Moto + Validate them before it get passed + :param headers: request headers + """ + if headers.get("x-amz-content-sha256", None) == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD": + # this is sign that this is a SigV4 request, with payload encoded + # we do not support payload encoding yet + # moto parses it to an int, it would raise a 500 + content_length = headers.get("x-amz-decoded-content-length") + if not content_length: + raise SignatureDoesNotMatch('"X-Amz-Decoded-Content-Length" header is missing') + try: + int(content_length) + except ValueError: + raise SignatureDoesNotMatch('Wrong "X-Amz-Decoded-Content-Length" header') + + +def validate_post_policy( + request_form: ImmutableMultiDict, additional_policy_metadata: dict +) -> None: + """ + Validate the pre-signed POST with its policy contained + For now, only validates its expiration + SigV2: https://docs.aws.amazon.com/AmazonS3/latest/userguide/HTTPPOSTExamples.html + SigV4: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-authentication-HTTPPOST.html + + :param request_form: the form data contained in the pre-signed POST request + :param additional_policy_metadata: additional metadata needed to validate the policy (bucket name, object size) + :raises AccessDenied, SignatureDoesNotMatch + :return: None + """ + if not request_form.get("key"): + ex: InvalidArgument = _create_invalid_argument_exc( + message="Bucket POST must contain a field named 'key'. If it is specified, please check the order of the fields.", + name="key", + value="", + host_id=FAKE_HOST_ID, + ) + raise ex + + form_dict = {k.lower(): v for k, v in request_form.items()} + + policy = form_dict.get("policy") + if not policy: + # A POST request needs a policy except if the bucket is publicly writable + return + + # TODO: this does validation of fields only for now + is_v4 = _is_match_with_signature_fields(form_dict, SIGNATURE_V4_POST_FIELDS) + is_v2 = _is_match_with_signature_fields(form_dict, SIGNATURE_V2_POST_FIELDS) + + if not is_v2 and not is_v4: + ex: AccessDenied = AccessDenied("Access Denied") + ex.HostId = FAKE_HOST_ID + raise ex + + try: + policy_decoded = json.loads(base64.b64decode(policy).decode("utf-8")) + except ValueError: + # this means the policy has been tampered with + signature = form_dict.get("signature") if is_v2 else form_dict.get("x-amz-signature") + credentials = get_credentials_from_parameters(request_form, "us-east-1") + ex: SignatureDoesNotMatch = create_signature_does_not_match_sig_v2( + request_signature=signature, + string_to_sign=policy, + access_key_id=credentials.access_key_id, + ) + raise ex + + if expiration := policy_decoded.get("expiration"): + if is_expired(_parse_policy_expiration_date(expiration)): + ex: AccessDenied = AccessDenied("Invalid according to Policy: Policy expired.") + ex.HostId = FAKE_HOST_ID + raise ex + + # TODO: validate the signature + + # See https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html + # for the list of conditions and what matching they support + # TODO: + # 1. only support the kind of matching the field supports: `success_action_status` does not support `starts-with` + # matching + # 2. if there are fields that are not defined in the policy, we should reject it + + # Special case for LEGACY_V2: do not validate the conditions. Remove this check once we remove legacy_v2 + if not additional_policy_metadata: + return + + conditions = policy_decoded.get("conditions", []) + for condition in conditions: + if not _verify_condition(condition, form_dict, additional_policy_metadata): + str_condition = str(condition).replace("'", '"') + raise AccessDenied( + f"Invalid according to Policy: Policy Condition failed: {str_condition}", + HostId=FAKE_HOST_ID, + ) + + +def _verify_condition(condition: list | dict, form: dict, additional_policy_metadata: dict) -> bool: + if isinstance(condition, dict) and len(condition) > 1: + raise CommonServiceException( + code="InvalidPolicyDocument", + message="Invalid Policy: Invalid Simple-Condition: Simple-Conditions must have exactly one property specified.", + ) + + match condition: + case {**kwargs}: + # this is the most performant to check for a dict with only one key + # alternative version is `key, val = next(iter(dict))` + for key, val in kwargs.items(): + k = key.lower() + if k == "bucket": + return additional_policy_metadata.get("bucket") == val + else: + return form.get(k) == val + + case ["eq", key, value]: + k = key.lower() + if k == "$bucket": + return additional_policy_metadata.get("bucket") == value + + return k.startswith("$") and form.get(k.lstrip("$")) == value + + case ["starts-with", key, value]: + # You can set the `starts-with` value to an empty string to accept anything + return key.startswith("$") and ( + not value or form.get(key.lstrip("$").lower(), "").startswith(value) + ) + + case ["content-length-range", start, end]: + size = additional_policy_metadata.get("content_length", 0) + try: + start, end = int(start), int(end) + except ValueError: + return False + + if size < start: + raise EntityTooSmall( + "Your proposed upload is smaller than the minimum allowed size", + ProposedSize=size, + MinSizeAllowed=start, + ) + elif size > end: + raise EntityTooLarge( + "Your proposed upload exceeds the maximum allowed size", + ProposedSize=size, + MaxSizeAllowed=end, + HostId=FAKE_HOST_ID, + ) + else: + return True + + +def _parse_policy_expiration_date(expiration_string: str) -> datetime.datetime: + """ + Parses the Policy Expiration datetime string + :param expiration_string: a policy expiration string, can be of 2 format: `2007-12-01T12:00:00.000Z` or + `2007-12-01T12:00:00Z` + :return: a datetime object representing the expiration datetime + """ + try: + dt = datetime.datetime.strptime(expiration_string, POLICY_EXPIRATION_FORMAT1) + except Exception: + dt = datetime.datetime.strptime(expiration_string, POLICY_EXPIRATION_FORMAT2) + + # both date formats assume a UTC timezone ('Z' suffix), but it's not parsed as tzinfo into the datetime object + dt = dt.replace(tzinfo=datetime.timezone.utc) + return dt + + +def _is_match_with_signature_fields( + request_form: dict[str, str], signature_fields: list[str] +) -> bool: + """ + Checks if the form contains at least one of the required fields passed in `signature_fields` + If it contains at least one field, validates it contains all of them or raises InvalidArgument + :param request_form: ImmutableMultiDict: the pre-signed POST request form + :param signature_fields: the field we want to validate against + :raises InvalidArgument + :return: False if none of the fields are present, or True if it does + """ + if any(p in request_form for p in signature_fields): + for p in signature_fields: + if p not in request_form: + LOG.info("POST pre-sign missing fields") + argument_name = ( + capitalize_header_name_from_snake_case(p) if "-" in p else p.capitalize() + ) + # AWSAccessKeyId is a special case + if argument_name == "Awsaccesskeyid": + argument_name = "AWSAccessKeyId" + + ex: InvalidArgument = _create_invalid_argument_exc( + message=f"Bucket POST must contain a field named '{argument_name}'. If it is specified, please check the order of the fields.", + name=argument_name, + value="", + host_id=FAKE_HOST_ID, + ) + raise ex + + return True + return False diff --git a/localstack-core/localstack/services/s3/provider.py b/localstack-core/localstack/services/s3/provider.py new file mode 100644 index 0000000000000..0c6b41e5974f0 --- /dev/null +++ b/localstack-core/localstack/services/s3/provider.py @@ -0,0 +1,4959 @@ +import base64 +import copy +import datetime +import json +import logging +import re +from collections import defaultdict +from inspect import signature +from io import BytesIO +from operator import itemgetter +from typing import IO, Optional, Union +from urllib import parse as urlparse +from zoneinfo import ZoneInfo + +from localstack import config +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.s3 import ( + MFA, + AbortMultipartUploadOutput, + AccelerateConfiguration, + AccessControlPolicy, + AccessDenied, + AccountId, + AnalyticsConfiguration, + AnalyticsId, + BadDigest, + Body, + Bucket, + BucketAlreadyExists, + BucketAlreadyOwnedByYou, + BucketCannedACL, + BucketLifecycleConfiguration, + BucketLoggingStatus, + BucketName, + BucketNotEmpty, + BucketRegion, + BucketVersioningStatus, + BypassGovernanceRetention, + ChecksumAlgorithm, + ChecksumCRC32, + ChecksumCRC32C, + ChecksumCRC64NVME, + ChecksumSHA1, + ChecksumSHA256, + ChecksumType, + CommonPrefix, + CompletedMultipartUpload, + CompleteMultipartUploadOutput, + ConditionalRequestConflict, + ConfirmRemoveSelfBucketAccess, + ContentMD5, + CopyObjectOutput, + CopyObjectRequest, + CopyObjectResult, + CopyPartResult, + CORSConfiguration, + CreateBucketOutput, + CreateBucketRequest, + CreateMultipartUploadOutput, + CreateMultipartUploadRequest, + CrossLocationLoggingProhibitted, + Delete, + DeletedObject, + DeleteMarkerEntry, + DeleteObjectOutput, + DeleteObjectsOutput, + DeleteObjectTaggingOutput, + Delimiter, + EncodingType, + Error, + Expiration, + FetchOwner, + GetBucketAccelerateConfigurationOutput, + GetBucketAclOutput, + GetBucketAnalyticsConfigurationOutput, + GetBucketCorsOutput, + GetBucketEncryptionOutput, + GetBucketIntelligentTieringConfigurationOutput, + GetBucketInventoryConfigurationOutput, + GetBucketLifecycleConfigurationOutput, + GetBucketLocationOutput, + GetBucketLoggingOutput, + GetBucketMetricsConfigurationOutput, + GetBucketOwnershipControlsOutput, + GetBucketPolicyOutput, + GetBucketPolicyStatusOutput, + GetBucketReplicationOutput, + GetBucketRequestPaymentOutput, + GetBucketTaggingOutput, + GetBucketVersioningOutput, + GetBucketWebsiteOutput, + GetObjectAclOutput, + GetObjectAttributesOutput, + GetObjectAttributesParts, + GetObjectAttributesRequest, + GetObjectLegalHoldOutput, + GetObjectLockConfigurationOutput, + GetObjectOutput, + GetObjectRequest, + GetObjectRetentionOutput, + GetObjectTaggingOutput, + GetObjectTorrentOutput, + GetPublicAccessBlockOutput, + HeadBucketOutput, + HeadObjectOutput, + HeadObjectRequest, + IfMatch, + IfMatchInitiatedTime, + IfMatchLastModifiedTime, + IfMatchSize, + IfNoneMatch, + IntelligentTieringConfiguration, + IntelligentTieringId, + InvalidArgument, + InvalidBucketName, + InvalidDigest, + InvalidLocationConstraint, + InvalidObjectState, + InvalidPartNumber, + InvalidPartOrder, + InvalidStorageClass, + InvalidTargetBucketForLogging, + InventoryConfiguration, + InventoryId, + KeyMarker, + LifecycleRules, + ListBucketAnalyticsConfigurationsOutput, + ListBucketIntelligentTieringConfigurationsOutput, + ListBucketInventoryConfigurationsOutput, + ListBucketMetricsConfigurationsOutput, + ListBucketsOutput, + ListMultipartUploadsOutput, + ListObjectsOutput, + ListObjectsV2Output, + ListObjectVersionsOutput, + ListPartsOutput, + Marker, + MaxBuckets, + MaxKeys, + MaxParts, + MaxUploads, + MethodNotAllowed, + MetricsConfiguration, + MetricsId, + MissingSecurityHeader, + MpuObjectSize, + MultipartUpload, + MultipartUploadId, + NoSuchBucket, + NoSuchBucketPolicy, + NoSuchCORSConfiguration, + NoSuchKey, + NoSuchLifecycleConfiguration, + NoSuchPublicAccessBlockConfiguration, + NoSuchTagSet, + NoSuchUpload, + NoSuchWebsiteConfiguration, + NotificationConfiguration, + Object, + ObjectIdentifier, + ObjectKey, + ObjectLockConfiguration, + ObjectLockConfigurationNotFoundError, + ObjectLockEnabled, + ObjectLockLegalHold, + ObjectLockMode, + ObjectLockRetention, + ObjectLockToken, + ObjectOwnership, + ObjectPart, + ObjectVersion, + ObjectVersionId, + ObjectVersionStorageClass, + OptionalObjectAttributesList, + Owner, + OwnershipControls, + OwnershipControlsNotFoundError, + Part, + PartNumber, + PartNumberMarker, + Policy, + PostResponse, + PreconditionFailed, + Prefix, + PublicAccessBlockConfiguration, + PutBucketAclRequest, + PutBucketLifecycleConfigurationOutput, + PutObjectAclOutput, + PutObjectAclRequest, + PutObjectLegalHoldOutput, + PutObjectLockConfigurationOutput, + PutObjectOutput, + PutObjectRequest, + PutObjectRetentionOutput, + PutObjectTaggingOutput, + ReplicationConfiguration, + ReplicationConfigurationNotFoundError, + RequestPayer, + RequestPaymentConfiguration, + RestoreObjectOutput, + RestoreRequest, + S3Api, + ServerSideEncryption, + ServerSideEncryptionConfiguration, + SkipValidation, + SSECustomerAlgorithm, + SSECustomerKey, + SSECustomerKeyMD5, + StartAfter, + StorageClass, + Tagging, + Token, + TransitionDefaultMinimumObjectSize, + UploadIdMarker, + UploadPartCopyOutput, + UploadPartCopyRequest, + UploadPartOutput, + UploadPartRequest, + VersionIdMarker, + VersioningConfiguration, + WebsiteConfiguration, +) +from localstack.aws.api.s3 import NotImplemented as NotImplementedException +from localstack.aws.handlers import ( + modify_service_response, + preprocess_request, + serve_custom_service_request_handlers, +) +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.services.edge import ROUTER +from localstack.services.plugins import ServiceLifecycleHook +from localstack.services.s3.codec import AwsChunkedDecoder +from localstack.services.s3.constants import ( + ALLOWED_HEADER_OVERRIDES, + ARCHIVES_STORAGE_CLASSES, + CHECKSUM_ALGORITHMS, + DEFAULT_BUCKET_ENCRYPTION, +) +from localstack.services.s3.cors import S3CorsHandler, s3_cors_request_handler +from localstack.services.s3.exceptions import ( + InvalidBucketOwnerAWSAccountID, + InvalidBucketState, + InvalidRequest, + MalformedPolicy, + MalformedXML, + NoSuchConfiguration, + NoSuchObjectLockConfiguration, + TooManyConfigurations, + UnexpectedContent, +) +from localstack.services.s3.models import ( + BucketCorsIndex, + EncryptionParameters, + ObjectLockParameters, + S3Bucket, + S3DeleteMarker, + S3Multipart, + S3Object, + S3Part, + S3Store, + VersionedKeyStore, + s3_stores, +) +from localstack.services.s3.notifications import NotificationDispatcher, S3EventNotificationContext +from localstack.services.s3.presigned_url import validate_post_policy +from localstack.services.s3.storage.core import LimitedIterableStream, S3ObjectStore +from localstack.services.s3.storage.ephemeral import EphemeralS3ObjectStore +from localstack.services.s3.utils import ( + ObjectRange, + add_expiration_days_to_datetime, + base_64_content_md5_to_etag, + create_redirect_for_post_request, + create_s3_kms_managed_key_for_region, + etag_to_base_64_content_md5, + extract_bucket_key_version_id_from_copy_source, + generate_safe_version_id, + get_canned_acl, + get_class_attrs_from_spec_class, + get_failed_precondition_copy_source, + get_full_default_bucket_location, + get_kms_key_arn, + get_lifecycle_rule_from_object, + get_owner_for_account_id, + get_permission_from_header, + get_retention_from_now, + get_s3_checksum_algorithm_from_request, + get_s3_checksum_algorithm_from_trailing_headers, + get_system_metadata_from_request, + get_unique_key_id, + is_bucket_name_valid, + is_version_older_than_other, + parse_copy_source_range_header, + parse_post_object_tagging_xml, + parse_range_header, + parse_tagging_header, + s3_response_handler, + serialize_expiration_header, + str_to_rfc_1123_datetime, + validate_dict_fields, + validate_failed_precondition, + validate_kms_key_id, + validate_tag_set, +) +from localstack.services.s3.validation import ( + parse_grants_in_headers, + validate_acl_acp, + validate_bucket_analytics_configuration, + validate_bucket_intelligent_tiering_configuration, + validate_canned_acl, + validate_checksum_value, + validate_cors_configuration, + validate_inventory_configuration, + validate_lifecycle_configuration, + validate_object_key, + validate_sse_c, + validate_website_configuration, +) +from localstack.services.s3.website_hosting import register_website_hosting_routes +from localstack.state import AssetDirectory, StateVisitor +from localstack.utils.aws.arns import s3_bucket_name +from localstack.utils.collections import select_from_typed_dict +from localstack.utils.strings import short_uid, to_bytes, to_str + +LOG = logging.getLogger(__name__) + +STORAGE_CLASSES = get_class_attrs_from_spec_class(StorageClass) +SSE_ALGORITHMS = get_class_attrs_from_spec_class(ServerSideEncryption) +OBJECT_OWNERSHIPS = get_class_attrs_from_spec_class(ObjectOwnership) +OBJECT_LOCK_MODES = get_class_attrs_from_spec_class(ObjectLockMode) + +DEFAULT_S3_TMP_DIR = "/tmp/localstack-s3-storage" + + +class S3Provider(S3Api, ServiceLifecycleHook): + def __init__(self, storage_backend: S3ObjectStore = None) -> None: + super().__init__() + self._storage_backend = storage_backend or EphemeralS3ObjectStore(DEFAULT_S3_TMP_DIR) + self._notification_dispatcher = NotificationDispatcher() + self._cors_handler = S3CorsHandler(BucketCorsIndex()) + + # runtime cache of Lifecycle Expiration headers, as they need to be calculated everytime we fetch an object + # in case the rules have changed + self._expiration_cache: dict[BucketName, dict[ObjectKey, Expiration]] = defaultdict(dict) + + def on_after_init(self): + preprocess_request.append(self._cors_handler) + serve_custom_service_request_handlers.append(s3_cors_request_handler) + modify_service_response.append(self.service, s3_response_handler) + register_website_hosting_routes(router=ROUTER) + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(s3_stores) + visitor.visit(AssetDirectory(self.service, self._storage_backend.root_directory)) + + def on_before_state_save(self): + self._storage_backend.flush() + + def on_after_state_reset(self): + self._cors_handler.invalidate_cache() + + def on_after_state_load(self): + self._cors_handler.invalidate_cache() + + def on_before_stop(self): + self._notification_dispatcher.shutdown() + self._storage_backend.close() + + def _notify( + self, + context: RequestContext, + s3_bucket: S3Bucket, + s3_object: S3Object | S3DeleteMarker = None, + s3_notif_ctx: S3EventNotificationContext = None, + ): + """ + :param context: the RequestContext, to retrieve more information about the incoming notification + :param s3_bucket: the S3Bucket object + :param s3_object: the S3Object object if S3EventNotificationContext is not given + :param s3_notif_ctx: S3EventNotificationContext, in case we need specific data only available in the API call + :return: + """ + if s3_bucket.notification_configuration: + if not s3_notif_ctx: + s3_notif_ctx = S3EventNotificationContext.from_request_context_native( + context, + s3_bucket=s3_bucket, + s3_object=s3_object, + ) + + self._notification_dispatcher.send_notifications( + s3_notif_ctx, s3_bucket.notification_configuration + ) + + def _verify_notification_configuration( + self, + notification_configuration: NotificationConfiguration, + skip_destination_validation: SkipValidation, + context: RequestContext, + bucket_name: str, + ): + self._notification_dispatcher.verify_configuration( + notification_configuration, skip_destination_validation, context, bucket_name + ) + + def _get_expiration_header( + self, + lifecycle_rules: LifecycleRules, + bucket: BucketName, + s3_object: S3Object, + object_tags: dict[str, str], + ) -> Expiration: + """ + This method will check if the key matches a Lifecycle filter, and return the serializer header if that's + the case. We're caching it because it can change depending on the set rules on the bucket. + We can't use `lru_cache` as the parameters needs to be hashable + :param lifecycle_rules: the bucket LifecycleRules + :param s3_object: S3Object + :param object_tags: the object tags + :return: the Expiration header if there's a rule matching + """ + if cached_exp := self._expiration_cache.get(bucket, {}).get(s3_object.key): + return cached_exp + + if lifecycle_rule := get_lifecycle_rule_from_object( + lifecycle_rules, s3_object.key, s3_object.size, object_tags + ): + expiration_header = serialize_expiration_header( + lifecycle_rule["ID"], + lifecycle_rule["Expiration"], + s3_object.last_modified, + ) + self._expiration_cache[bucket][s3_object.key] = expiration_header + return expiration_header + + def _get_cross_account_bucket( + self, + context: RequestContext, + bucket_name: BucketName, + *, + expected_bucket_owner: AccountId = None, + ) -> tuple[S3Store, S3Bucket]: + if expected_bucket_owner and not re.fullmatch(r"\w{12}", expected_bucket_owner): + raise InvalidBucketOwnerAWSAccountID( + f"The value of the expected bucket owner parameter must be an AWS Account ID... [{expected_bucket_owner}]", + ) + + store = self.get_store(context.account_id, context.region) + if not (s3_bucket := store.buckets.get(bucket_name)): + if not (account_id := store.global_bucket_map.get(bucket_name)): + raise NoSuchBucket("The specified bucket does not exist", BucketName=bucket_name) + + store = self.get_store(account_id, context.region) + if not (s3_bucket := store.buckets.get(bucket_name)): + raise NoSuchBucket("The specified bucket does not exist", BucketName=bucket_name) + + if expected_bucket_owner and s3_bucket.bucket_account_id != expected_bucket_owner: + raise AccessDenied("Access Denied") + + return store, s3_bucket + + @staticmethod + def get_store(account_id: str, region_name: str) -> S3Store: + # Use default account id for external access? would need an anonymous one + return s3_stores[account_id][region_name] + + @handler("CreateBucket", expand=False) + def create_bucket( + self, + context: RequestContext, + request: CreateBucketRequest, + ) -> CreateBucketOutput: + bucket_name = request["Bucket"] + + if not is_bucket_name_valid(bucket_name): + raise InvalidBucketName("The specified bucket is not valid.", BucketName=bucket_name) + + # the XML parser returns an empty dict if the body contains the following: + # + # but it also returns an empty dict if the body is fully empty. We need to differentiate the 2 cases by checking + # if the body is empty or not + if context.request.data and ( + (create_bucket_configuration := request.get("CreateBucketConfiguration")) is not None + ): + if not (bucket_region := create_bucket_configuration.get("LocationConstraint")): + raise MalformedXML() + + if context.region == AWS_REGION_US_EAST_1: + if bucket_region == "us-east-1": + raise InvalidLocationConstraint( + "The specified location-constraint is not valid", + LocationConstraint=bucket_region, + ) + elif context.region != bucket_region: + raise CommonServiceException( + code="IllegalLocationConstraintException", + message=f"The {bucket_region} location constraint is incompatible for the region specific endpoint this request was sent to.", + ) + else: + bucket_region = AWS_REGION_US_EAST_1 + if context.region != bucket_region: + raise CommonServiceException( + code="IllegalLocationConstraintException", + message="The unspecified location constraint is incompatible for the region specific endpoint this request was sent to.", + ) + + store = self.get_store(context.account_id, bucket_region) + + if bucket_name in store.global_bucket_map: + existing_bucket_owner = store.global_bucket_map[bucket_name] + if existing_bucket_owner != context.account_id: + raise BucketAlreadyExists() + + # if the existing bucket has the same owner, the behaviour will depend on the region + if bucket_region != "us-east-1": + raise BucketAlreadyOwnedByYou( + "Your previous request to create the named bucket succeeded and you already own it.", + BucketName=bucket_name, + ) + else: + # CreateBucket is idempotent in us-east-1 + return CreateBucketOutput(Location=f"/{bucket_name}") + + if ( + object_ownership := request.get("ObjectOwnership") + ) is not None and object_ownership not in OBJECT_OWNERSHIPS: + raise InvalidArgument( + f"Invalid x-amz-object-ownership header: {object_ownership}", + ArgumentName="x-amz-object-ownership", + ) + # see https://docs.aws.amazon.com/AmazonS3/latest/API/API_Owner.html + owner = get_owner_for_account_id(context.account_id) + acl = get_access_control_policy_for_new_resource_request(request, owner=owner) + s3_bucket = S3Bucket( + name=bucket_name, + account_id=context.account_id, + bucket_region=bucket_region, + owner=owner, + acl=acl, + object_ownership=request.get("ObjectOwnership"), + object_lock_enabled_for_bucket=request.get("ObjectLockEnabledForBucket"), + ) + + store.buckets[bucket_name] = s3_bucket + store.global_bucket_map[bucket_name] = s3_bucket.bucket_account_id + self._cors_handler.invalidate_cache() + self._storage_backend.create_bucket(bucket_name) + + # Location is always contained in response -> full url for LocationConstraint outside us-east-1 + location = ( + f"/{bucket_name}" + if bucket_region == "us-east-1" + else get_full_default_bucket_location(bucket_name) + ) + response = CreateBucketOutput(Location=location) + return response + + def delete_bucket( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + # the bucket still contains objects + if not s3_bucket.objects.is_empty(): + message = "The bucket you tried to delete is not empty" + if s3_bucket.versioning_status: + message += ". You must delete all versions in the bucket." + raise BucketNotEmpty( + message, + BucketName=bucket, + ) + + store.buckets.pop(bucket) + store.global_bucket_map.pop(bucket) + self._cors_handler.invalidate_cache() + self._expiration_cache.pop(bucket, None) + # clean up the storage backend + self._storage_backend.delete_bucket(bucket) + + def list_buckets( + self, + context: RequestContext, + max_buckets: MaxBuckets = None, + continuation_token: Token = None, + prefix: Prefix = None, + bucket_region: BucketRegion = None, + **kwargs, + ) -> ListBucketsOutput: + owner = get_owner_for_account_id(context.account_id) + store = self.get_store(context.account_id, context.region) + + decoded_continuation_token = ( + to_str(base64.urlsafe_b64decode(continuation_token.encode())) + if continuation_token + else None + ) + + count = 0 + buckets: list[Bucket] = [] + next_continuation_token = None + + # Comparing strings with case sensitivity since AWS is case-sensitive + for bucket in sorted(store.buckets.values(), key=lambda r: r.name): + if continuation_token and bucket.name < decoded_continuation_token: + continue + + if prefix and not bucket.name.startswith(prefix): + continue + + if bucket_region and not bucket.bucket_region == bucket_region: + continue + + if max_buckets and count >= max_buckets: + next_continuation_token = to_str(base64.urlsafe_b64encode(bucket.name.encode())) + break + + output_bucket = Bucket( + Name=bucket.name, + CreationDate=bucket.creation_date, + BucketRegion=bucket.bucket_region, + ) + buckets.append(output_bucket) + count += 1 + + return ListBucketsOutput( + Owner=owner, Buckets=buckets, Prefix=prefix, ContinuationToken=next_continuation_token + ) + + def head_bucket( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> HeadBucketOutput: + store = self.get_store(context.account_id, context.region) + if not (s3_bucket := store.buckets.get(bucket)): + if not (account_id := store.global_bucket_map.get(bucket)): + # just to return the 404 error message + raise NoSuchBucket() + + store = self.get_store(account_id, context.region) + if not (s3_bucket := store.buckets.get(bucket)): + # just to return the 404 error message + raise NoSuchBucket() + + # TODO: this call is also used to check if the user has access/authorization for the bucket + # it can return 403 + return HeadBucketOutput(BucketRegion=s3_bucket.bucket_region) + + def get_bucket_location( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketLocationOutput: + """ + When implementing the ASF provider, this operation is implemented because: + - The spec defines a root element GetBucketLocationOutput containing a LocationConstraint member, where + S3 actually just returns the LocationConstraint on the root level (only operation so far that we know of). + - We circumvent the root level element here by patching the spec such that this operation returns a + single "payload" (the XML body response), which causes the serializer to directly take the payload element. + - The above "hack" causes the fix in the serializer to not be picked up here as we're passing the XML body as + the payload, which is why we need to manually do this here by manipulating the string. + Botocore implements this hack for parsing the response in `botocore.handlers.py#parse_get_bucket_location` + """ + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + location_constraint = ( + '\n' + '{{location}}' + ) + + location = s3_bucket.bucket_region if s3_bucket.bucket_region != "us-east-1" else "" + location_constraint = location_constraint.replace("{{location}}", location) + + response = GetBucketLocationOutput(LocationConstraint=location_constraint) + return response + + @handler("PutObject", expand=False) + def put_object( + self, + context: RequestContext, + request: PutObjectRequest, + ) -> PutObjectOutput: + # TODO: validate order of validation + # TODO: still need to handle following parameters + # request_payer: RequestPayer = None, + bucket_name = request["Bucket"] + key = request["Key"] + store, s3_bucket = self._get_cross_account_bucket(context, bucket_name) + + if (storage_class := request.get("StorageClass")) is not None and ( + storage_class not in STORAGE_CLASSES or storage_class == StorageClass.OUTPOSTS + ): + raise InvalidStorageClass( + "The storage class you specified is not valid", StorageClassRequested=storage_class + ) + + if not config.S3_SKIP_KMS_KEY_VALIDATION and (sse_kms_key_id := request.get("SSEKMSKeyId")): + validate_kms_key_id(sse_kms_key_id, s3_bucket) + + validate_object_key(key) + + if_match = request.get("IfMatch") + if (if_none_match := request.get("IfNoneMatch")) and if_match: + raise NotImplementedException( + "A header you provided implies functionality that is not implemented", + Header="If-Match,If-None-Match", + additionalMessage="Multiple conditional request headers present in the request", + ) + + elif (if_none_match and if_none_match != "*") or (if_match and if_match == "*"): + header_name = "If-None-Match" if if_none_match else "If-Match" + raise NotImplementedException( + "A header you provided implies functionality that is not implemented", + Header=header_name, + additionalMessage=f"We don't accept the provided value of {header_name} header for this API", + ) + + system_metadata = get_system_metadata_from_request(request) + if not system_metadata.get("ContentType"): + system_metadata["ContentType"] = "binary/octet-stream" + + version_id = generate_version_id(s3_bucket.versioning_status) + + etag_content_md5 = "" + if content_md5 := request.get("ContentMD5"): + # assert that the received ContentMD5 is a properly b64 encoded value that fits a MD5 hash length + etag_content_md5 = base_64_content_md5_to_etag(content_md5) + if not etag_content_md5: + raise InvalidDigest( + "The Content-MD5 you specified was invalid.", + Content_MD5=content_md5, + ) + + checksum_algorithm = get_s3_checksum_algorithm_from_request(request) + checksum_value = ( + request.get(f"Checksum{checksum_algorithm.upper()}") if checksum_algorithm else None + ) + + # TODO: we're not encrypting the object with the provided key for now + sse_c_key_md5 = request.get("SSECustomerKeyMD5") + validate_sse_c( + algorithm=request.get("SSECustomerAlgorithm"), + encryption_key=request.get("SSECustomerKey"), + encryption_key_md5=sse_c_key_md5, + server_side_encryption=request.get("ServerSideEncryption"), + ) + + encryption_parameters = get_encryption_parameters_from_request_and_bucket( + request, + s3_bucket, + store, + ) + + lock_parameters = get_object_lock_parameters_from_bucket_and_request(request, s3_bucket) + + acl = get_access_control_policy_for_new_resource_request(request, owner=s3_bucket.owner) + + if tagging := request.get("Tagging"): + tagging = parse_tagging_header(tagging) + + s3_object = S3Object( + key=key, + version_id=version_id, + storage_class=storage_class, + expires=request.get("Expires"), + user_metadata=request.get("Metadata"), + system_metadata=system_metadata, + checksum_algorithm=checksum_algorithm, + checksum_value=checksum_value, + encryption=encryption_parameters.encryption, + kms_key_id=encryption_parameters.kms_key_id, + bucket_key_enabled=encryption_parameters.bucket_key_enabled, + sse_key_hash=sse_c_key_md5, + lock_mode=lock_parameters.lock_mode, + lock_legal_status=lock_parameters.lock_legal_status, + lock_until=lock_parameters.lock_until, + website_redirect_location=request.get("WebsiteRedirectLocation"), + acl=acl, + owner=s3_bucket.owner, # TODO: for now we only have one owner, but it can depends on Bucket settings + ) + + body = request.get("Body") + # check if chunked request + headers = context.request.headers + is_aws_chunked = headers.get("x-amz-content-sha256", "").startswith( + "STREAMING-" + ) or "aws-chunked" in headers.get("content-encoding", "") + if is_aws_chunked: + checksum_algorithm = ( + checksum_algorithm + or get_s3_checksum_algorithm_from_trailing_headers(headers.get("x-amz-trailer", "")) + ) + if checksum_algorithm: + s3_object.checksum_algorithm = checksum_algorithm + + decoded_content_length = int(headers.get("x-amz-decoded-content-length", 0)) + body = AwsChunkedDecoder(body, decoded_content_length, s3_object=s3_object) + + # S3 removes the `aws-chunked` value from ContentEncoding + if content_encoding := s3_object.system_metadata.pop("ContentEncoding", None): + encodings = [enc for enc in content_encoding.split(",") if enc != "aws-chunked"] + if encodings: + s3_object.system_metadata["ContentEncoding"] = ",".join(encodings) + + with self._storage_backend.open(bucket_name, s3_object, mode="w") as s3_stored_object: + # as we are inside the lock here, if multiple concurrent requests happen for the same object, it's the first + # one to finish to succeed, and subsequent will raise exceptions. Once the first write finishes, we're + # opening the lock and other requests can check this condition + if if_none_match and object_exists_for_precondition_write(s3_bucket, key): + raise PreconditionFailed( + "At least one of the pre-conditions you specified did not hold", + Condition="If-None-Match", + ) + + elif if_match: + verify_object_equality_precondition_write(s3_bucket, key, if_match) + + s3_stored_object.write(body) + + if s3_object.checksum_algorithm: + if not s3_object.checksum_value: + s3_object.checksum_value = s3_stored_object.checksum + elif not validate_checksum_value(s3_object.checksum_value, checksum_algorithm): + self._storage_backend.remove(bucket_name, s3_object) + raise InvalidRequest( + f"Value for x-amz-checksum-{s3_object.checksum_algorithm.lower()} header is invalid." + ) + elif s3_object.checksum_value != s3_stored_object.checksum: + self._storage_backend.remove(bucket_name, s3_object) + raise BadDigest( + f"The {checksum_algorithm.upper()} you specified did not match the calculated checksum." + ) + + # TODO: handle ContentMD5 and ChecksumAlgorithm in a handler for all requests except requests with a + # streaming body. We can use the specs to verify which operations needs to have the checksum validated + if content_md5: + calculated_md5 = etag_to_base_64_content_md5(s3_stored_object.etag) + if calculated_md5 != content_md5: + self._storage_backend.remove(bucket_name, s3_object) + raise BadDigest( + "The Content-MD5 you specified did not match what we received.", + ExpectedDigest=etag_content_md5, + CalculatedDigest=calculated_md5, + ) + + s3_bucket.objects.set(key, s3_object) + + # in case we are overriding an object, delete the tags entry + key_id = get_unique_key_id(bucket_name, key, version_id) + store.TAGS.tags.pop(key_id, None) + if tagging: + store.TAGS.tags[key_id] = tagging + + # RequestCharged: Optional[RequestCharged] # TODO + response = PutObjectOutput( + ETag=s3_object.quoted_etag, + ) + if s3_bucket.versioning_status == "Enabled": + response["VersionId"] = s3_object.version_id + + if s3_object.checksum_algorithm: + response[f"Checksum{s3_object.checksum_algorithm}"] = s3_object.checksum_value + response["ChecksumType"] = getattr(s3_object, "checksum_type", ChecksumType.FULL_OBJECT) + + if s3_bucket.lifecycle_rules: + if expiration_header := self._get_expiration_header( + s3_bucket.lifecycle_rules, + bucket_name, + s3_object, + store.TAGS.tags.get(key_id, {}), + ): + # TODO: we either apply the lifecycle to existing objects when we set the new rules, or we need to + # apply them everytime we get/head an object + response["Expiration"] = expiration_header + + add_encryption_to_response(response, s3_object=s3_object) + if sse_c_key_md5: + response["SSECustomerAlgorithm"] = "AES256" + response["SSECustomerKeyMD5"] = sse_c_key_md5 + + self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object) + + return response + + @handler("GetObject", expand=False) + def get_object( + self, + context: RequestContext, + request: GetObjectRequest, + ) -> GetObjectOutput: + # TODO: missing handling parameters: + # request_payer: RequestPayer = None, + # expected_bucket_owner: AccountId = None, + + bucket_name = request["Bucket"] + object_key = request["Key"] + version_id = request.get("VersionId") + store, s3_bucket = self._get_cross_account_bucket(context, bucket_name) + + s3_object = s3_bucket.get_object( + key=object_key, + version_id=version_id, + http_method="GET", + ) + if s3_object.expires and s3_object.expires < datetime.datetime.now( + tz=s3_object.expires.tzinfo + ): + # TODO: old behaviour was deleting key instantly if expired. AWS cleans up only once a day generally + # you can still HeadObject on it and you get the expiry time until scheduled deletion + kwargs = {"Key": object_key} + if version_id: + kwargs["VersionId"] = version_id + raise NoSuchKey("The specified key does not exist.", **kwargs) + + if s3_object.storage_class in ARCHIVES_STORAGE_CLASSES and not s3_object.restore: + raise InvalidObjectState( + "The operation is not valid for the object's storage class", + StorageClass=s3_object.storage_class, + ) + + if not config.S3_SKIP_KMS_KEY_VALIDATION and s3_object.kms_key_id: + validate_kms_key_id(kms_key=s3_object.kms_key_id, bucket=s3_bucket) + + sse_c_key_md5 = request.get("SSECustomerKeyMD5") + # we're using getattr access because when restoring, the field might not exist + # TODO: cleanup at next major release + if sse_key_hash := getattr(s3_object, "sse_key_hash", None): + if sse_key_hash and not sse_c_key_md5: + raise InvalidRequest( + "The object was stored using a form of Server Side Encryption. " + "The correct parameters must be provided to retrieve the object." + ) + elif sse_key_hash != sse_c_key_md5: + raise AccessDenied( + "Requests specifying Server Side Encryption with Customer provided keys must provide the correct secret key." + ) + + validate_sse_c( + algorithm=request.get("SSECustomerAlgorithm"), + encryption_key=request.get("SSECustomerKey"), + encryption_key_md5=sse_c_key_md5, + ) + + validate_failed_precondition(request, s3_object.last_modified, s3_object.etag) + + range_header = request.get("Range") + part_number = request.get("PartNumber") + if range_header and part_number: + raise InvalidRequest("Cannot specify both Range header and partNumber query parameter") + range_data = None + if range_header: + range_data = parse_range_header(range_header, s3_object.size) + elif part_number: + range_data = get_part_range(s3_object, part_number) + + # we deliberately do not call `.close()` on the s3_stored_object to keep the read lock acquired. When passing + # the object to Werkzeug, the handler will call `.close()` after finishing iterating over `__iter__`. + # this can however lead to deadlocks if an exception happens between the call and returning the object. + # Be careful into adding validation between this call and `return` of `S3Provider.get_object` + s3_stored_object = self._storage_backend.open(bucket_name, s3_object, mode="r") + + # this is a hacky way to verify the object hasn't been modified between `s3_object = s3_bucket.get_object` + # and the storage backend call. If it has been modified, now that we're in the read lock, we can safely fetch + # the object again + if s3_stored_object.last_modified != s3_object.internal_last_modified: + s3_object = s3_bucket.get_object( + key=object_key, + version_id=version_id, + http_method="GET", + ) + + response = GetObjectOutput( + AcceptRanges="bytes", + **s3_object.get_system_metadata_fields(), + ) + if s3_object.user_metadata: + response["Metadata"] = s3_object.user_metadata + + if s3_object.parts and request.get("PartNumber"): + response["PartsCount"] = len(s3_object.parts) + + if s3_object.version_id: + response["VersionId"] = s3_object.version_id + + if s3_object.website_redirect_location: + response["WebsiteRedirectLocation"] = s3_object.website_redirect_location + + if s3_object.restore: + response["Restore"] = s3_object.restore + + checksum_value = None + checksum_type = None + if checksum_algorithm := s3_object.checksum_algorithm: + if (request.get("ChecksumMode") or "").upper() == "ENABLED": + checksum_value = s3_object.checksum_value + checksum_type = getattr(s3_object, "checksum_type", ChecksumType.FULL_OBJECT) + + if range_data: + s3_stored_object.seek(range_data.begin) + response["Body"] = LimitedIterableStream( + s3_stored_object, max_length=range_data.content_length + ) + response["ContentRange"] = range_data.content_range + response["ContentLength"] = range_data.content_length + response["StatusCode"] = 206 + if checksum_value: + if s3_object.parts and part_number and checksum_type == ChecksumType.COMPOSITE: + part_data = s3_object.parts[part_number] + checksum_key = f"Checksum{checksum_algorithm.upper()}" + response[checksum_key] = part_data.get(checksum_key) + response["ChecksumType"] = ChecksumType.COMPOSITE + + # it means either the range header means the whole object, or that a multipart upload with `FULL_OBJECT` + # only had one part + elif range_data.content_length == s3_object.size: + response[f"Checksum{checksum_algorithm.upper()}"] = checksum_value + response["ChecksumType"] = checksum_type + else: + response["Body"] = s3_stored_object + if checksum_value: + response[f"Checksum{checksum_algorithm.upper()}"] = checksum_value + response["ChecksumType"] = checksum_type + + add_encryption_to_response(response, s3_object=s3_object) + + if object_tags := store.TAGS.tags.get( + get_unique_key_id(bucket_name, object_key, version_id) + ): + response["TagCount"] = len(object_tags) + + if s3_object.is_current and s3_bucket.lifecycle_rules: + if expiration_header := self._get_expiration_header( + s3_bucket.lifecycle_rules, + bucket_name, + s3_object, + object_tags, + ): + # TODO: we either apply the lifecycle to existing objects when we set the new rules, or we need to + # apply them everytime we get/head an object + response["Expiration"] = expiration_header + + # TODO: missing returned fields + # RequestCharged: Optional[RequestCharged] + # ReplicationStatus: Optional[ReplicationStatus] + + if s3_object.lock_mode: + response["ObjectLockMode"] = s3_object.lock_mode + if s3_object.lock_until: + response["ObjectLockRetainUntilDate"] = s3_object.lock_until + if s3_object.lock_legal_status: + response["ObjectLockLegalHoldStatus"] = s3_object.lock_legal_status + + if sse_c_key_md5: + response["SSECustomerAlgorithm"] = "AES256" + response["SSECustomerKeyMD5"] = sse_c_key_md5 + + for request_param, response_param in ALLOWED_HEADER_OVERRIDES.items(): + if request_param_value := request.get(request_param): + response[response_param] = request_param_value + + return response + + @handler("HeadObject", expand=False) + def head_object( + self, + context: RequestContext, + request: HeadObjectRequest, + ) -> HeadObjectOutput: + bucket_name = request["Bucket"] + object_key = request["Key"] + version_id = request.get("VersionId") + store, s3_bucket = self._get_cross_account_bucket(context, bucket_name) + + s3_object = s3_bucket.get_object( + key=object_key, + version_id=version_id, + http_method="HEAD", + ) + + validate_failed_precondition(request, s3_object.last_modified, s3_object.etag) + + sse_c_key_md5 = request.get("SSECustomerKeyMD5") + if s3_object.sse_key_hash: + if not sse_c_key_md5: + raise InvalidRequest( + "The object was stored using a form of Server Side Encryption. " + "The correct parameters must be provided to retrieve the object." + ) + elif s3_object.sse_key_hash != sse_c_key_md5: + raise AccessDenied( + "Requests specifying Server Side Encryption with Customer provided keys must provide the correct secret key." + ) + + validate_sse_c( + algorithm=request.get("SSECustomerAlgorithm"), + encryption_key=request.get("SSECustomerKey"), + encryption_key_md5=sse_c_key_md5, + ) + + response = HeadObjectOutput( + AcceptRanges="bytes", + **s3_object.get_system_metadata_fields(), + ) + if s3_object.user_metadata: + response["Metadata"] = s3_object.user_metadata + + checksum_value = None + checksum_type = None + if checksum_algorithm := s3_object.checksum_algorithm: + if (request.get("ChecksumMode") or "").upper() == "ENABLED": + checksum_value = s3_object.checksum_value + checksum_type = getattr(s3_object, "checksum_type", ChecksumType.FULL_OBJECT) + + if s3_object.parts and request.get("PartNumber"): + response["PartsCount"] = len(s3_object.parts) + + if s3_object.version_id: + response["VersionId"] = s3_object.version_id + + if s3_object.website_redirect_location: + response["WebsiteRedirectLocation"] = s3_object.website_redirect_location + + if s3_object.restore: + response["Restore"] = s3_object.restore + + range_header = request.get("Range") + part_number = request.get("PartNumber") + if range_header and part_number: + raise InvalidRequest("Cannot specify both Range header and partNumber query parameter") + range_data = None + if range_header: + range_data = parse_range_header(range_header, s3_object.size) + elif part_number: + range_data = get_part_range(s3_object, part_number) + + if range_data: + response["ContentLength"] = range_data.content_length + response["ContentRange"] = range_data.content_range + response["StatusCode"] = 206 + if checksum_value: + if s3_object.parts and part_number and checksum_type == ChecksumType.COMPOSITE: + part_data = s3_object.parts[part_number] + checksum_key = f"Checksum{checksum_algorithm.upper()}" + response[checksum_key] = part_data.get(checksum_key) + response["ChecksumType"] = ChecksumType.COMPOSITE + + # it means either the range header means the whole object, or that a multipart upload with `FULL_OBJECT` + # only had one part + elif range_data.content_length == s3_object.size: + response[f"Checksum{checksum_algorithm.upper()}"] = checksum_value + response["ChecksumType"] = checksum_type + elif checksum_value: + response[f"Checksum{checksum_algorithm.upper()}"] = checksum_value + response["ChecksumType"] = checksum_type + + add_encryption_to_response(response, s3_object=s3_object) + + # if you specify the VersionId, AWS won't return the Expiration header, even if that's the current version + if not version_id and s3_bucket.lifecycle_rules: + object_tags = store.TAGS.tags.get( + get_unique_key_id(bucket_name, object_key, s3_object.version_id) + ) + if expiration_header := self._get_expiration_header( + s3_bucket.lifecycle_rules, + bucket_name, + s3_object, + object_tags, + ): + # TODO: we either apply the lifecycle to existing objects when we set the new rules, or we need to + # apply them everytime we get/head an object + response["Expiration"] = expiration_header + + if s3_object.lock_mode: + response["ObjectLockMode"] = s3_object.lock_mode + if s3_object.lock_until: + response["ObjectLockRetainUntilDate"] = s3_object.lock_until + if s3_object.lock_legal_status: + response["ObjectLockLegalHoldStatus"] = s3_object.lock_legal_status + + if sse_c_key_md5: + response["SSECustomerAlgorithm"] = "AES256" + response["SSECustomerKeyMD5"] = sse_c_key_md5 + + # TODO: missing return fields: + # ArchiveStatus: Optional[ArchiveStatus] + # RequestCharged: Optional[RequestCharged] + # ReplicationStatus: Optional[ReplicationStatus] + + return response + + def delete_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + mfa: MFA = None, + version_id: ObjectVersionId = None, + request_payer: RequestPayer = None, + bypass_governance_retention: BypassGovernanceRetention = None, + expected_bucket_owner: AccountId = None, + if_match: IfMatch = None, + if_match_last_modified_time: IfMatchLastModifiedTime = None, + if_match_size: IfMatchSize = None, + **kwargs, + ) -> DeleteObjectOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if bypass_governance_retention is not None and not s3_bucket.object_lock_enabled: + raise InvalidArgument( + "x-amz-bypass-governance-retention is only applicable to Object Lock enabled buckets.", + ArgumentName="x-amz-bypass-governance-retention", + ) + + # TODO: this is only supported for Directory Buckets + non_supported_precondition = None + if if_match: + non_supported_precondition = "If-Match" + if if_match_size: + non_supported_precondition = "x-amz-if-match-size" + if if_match_last_modified_time: + non_supported_precondition = "x-amz-if-match-last-modified-time" + if non_supported_precondition: + LOG.warning( + "DeleteObject Preconditions is only supported for Directory Buckets. " + "LocalStack does not support Directory Buckets yet." + ) + raise NotImplementedException( + "A header you provided implies functionality that is not implemented", + Header=non_supported_precondition, + ) + + if s3_bucket.versioning_status is None: + if version_id and version_id != "null": + raise InvalidArgument( + "Invalid version id specified", + ArgumentName="versionId", + ArgumentValue=version_id, + ) + + found_object = s3_bucket.objects.pop(key, None) + # TODO: RequestCharged + if found_object: + self._storage_backend.remove(bucket, found_object) + self._notify(context, s3_bucket=s3_bucket, s3_object=found_object) + store.TAGS.tags.pop(get_unique_key_id(bucket, key, version_id), None) + + return DeleteObjectOutput() + + if not version_id: + delete_marker_id = generate_version_id(s3_bucket.versioning_status) + delete_marker = S3DeleteMarker(key=key, version_id=delete_marker_id) + s3_bucket.objects.set(key, delete_marker) + s3_notif_ctx = S3EventNotificationContext.from_request_context_native( + context, + s3_bucket=s3_bucket, + s3_object=delete_marker, + ) + s3_notif_ctx.event_type = f"{s3_notif_ctx.event_type}MarkerCreated" + self._notify(context, s3_bucket=s3_bucket, s3_notif_ctx=s3_notif_ctx) + + return DeleteObjectOutput(VersionId=delete_marker.version_id, DeleteMarker=True) + + if key not in s3_bucket.objects: + return DeleteObjectOutput() + + if not (s3_object := s3_bucket.objects.get(key, version_id)): + raise InvalidArgument( + "Invalid version id specified", + ArgumentName="versionId", + ArgumentValue=version_id, + ) + + if s3_object.is_locked(bypass_governance_retention): + raise AccessDenied("Access Denied because object protected by object lock.") + + s3_bucket.objects.pop(object_key=key, version_id=version_id) + response = DeleteObjectOutput(VersionId=s3_object.version_id) + + if isinstance(s3_object, S3DeleteMarker): + response["DeleteMarker"] = True + else: + self._storage_backend.remove(bucket, s3_object) + store.TAGS.tags.pop(get_unique_key_id(bucket, key, version_id), None) + self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object) + + return response + + def delete_objects( + self, + context: RequestContext, + bucket: BucketName, + delete: Delete, + mfa: MFA = None, + request_payer: RequestPayer = None, + bypass_governance_retention: BypassGovernanceRetention = None, + expected_bucket_owner: AccountId = None, + checksum_algorithm: ChecksumAlgorithm = None, + **kwargs, + ) -> DeleteObjectsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if bypass_governance_retention is not None and not s3_bucket.object_lock_enabled: + raise InvalidArgument( + "x-amz-bypass-governance-retention is only applicable to Object Lock enabled buckets.", + ArgumentName="x-amz-bypass-governance-retention", + ) + + objects: list[ObjectIdentifier] = delete.get("Objects") + if not objects: + raise MalformedXML() + + # TODO: max 1000 delete at once? test against AWS? + + quiet = delete.get("Quiet", False) + deleted = [] + errors = [] + + to_remove = [] + for to_delete_object in objects: + object_key = to_delete_object.get("Key") + version_id = to_delete_object.get("VersionId") + if s3_bucket.versioning_status is None: + if version_id and version_id != "null": + errors.append( + Error( + Code="NoSuchVersion", + Key=object_key, + Message="The specified version does not exist.", + VersionId=version_id, + ) + ) + continue + + found_object = s3_bucket.objects.pop(object_key, None) + if found_object: + to_remove.append(found_object) + self._notify(context, s3_bucket=s3_bucket, s3_object=found_object) + store.TAGS.tags.pop(get_unique_key_id(bucket, object_key, version_id), None) + # small hack to not create a fake object for nothing + elif s3_bucket.notification_configuration: + # DeleteObjects is a bit weird, even if the object didn't exist, S3 will trigger a notification + # for a non-existing object being deleted + self._notify( + context, s3_bucket=s3_bucket, s3_object=S3Object(key=object_key, etag="") + ) + + if not quiet: + deleted.append(DeletedObject(Key=object_key)) + + continue + + if not version_id: + delete_marker_id = generate_version_id(s3_bucket.versioning_status) + delete_marker = S3DeleteMarker(key=object_key, version_id=delete_marker_id) + s3_bucket.objects.set(object_key, delete_marker) + s3_notif_ctx = S3EventNotificationContext.from_request_context_native( + context, + s3_bucket=s3_bucket, + s3_object=delete_marker, + ) + s3_notif_ctx.event_type = f"{s3_notif_ctx.event_type}MarkerCreated" + self._notify(context, s3_bucket=s3_bucket, s3_notif_ctx=s3_notif_ctx) + + if not quiet: + deleted.append( + DeletedObject( + DeleteMarker=True, + DeleteMarkerVersionId=delete_marker_id, + Key=object_key, + ) + ) + continue + + if not ( + found_object := s3_bucket.objects.get(object_key=object_key, version_id=version_id) + ): + errors.append( + Error( + Code="NoSuchVersion", + Key=object_key, + Message="The specified version does not exist.", + VersionId=version_id, + ) + ) + continue + + if found_object.is_locked(bypass_governance_retention): + errors.append( + Error( + Code="AccessDenied", + Key=object_key, + Message="Access Denied because object protected by object lock.", + VersionId=version_id, + ) + ) + continue + + s3_bucket.objects.pop(object_key=object_key, version_id=version_id) + if not quiet: + deleted_object = DeletedObject( + Key=object_key, + VersionId=version_id, + ) + if isinstance(found_object, S3DeleteMarker): + deleted_object["DeleteMarker"] = True + deleted_object["DeleteMarkerVersionId"] = found_object.version_id + + deleted.append(deleted_object) + + if isinstance(found_object, S3Object): + to_remove.append(found_object) + + self._notify(context, s3_bucket=s3_bucket, s3_object=found_object) + store.TAGS.tags.pop(get_unique_key_id(bucket, object_key, version_id), None) + + # TODO: request charged + self._storage_backend.remove(bucket, to_remove) + response: DeleteObjectsOutput = {} + # AWS validated: the list of Deleted objects is unordered, multiple identical calls can return different results + if errors: + response["Errors"] = errors + if not quiet: + response["Deleted"] = deleted + + return response + + @handler("CopyObject", expand=False) + def copy_object( + self, + context: RequestContext, + request: CopyObjectRequest, + ) -> CopyObjectOutput: + # request_payer: RequestPayer = None, # TODO: + dest_bucket = request["Bucket"] + dest_key = request["Key"] + validate_object_key(dest_key) + store, dest_s3_bucket = self._get_cross_account_bucket(context, dest_bucket) + + src_bucket, src_key, src_version_id = extract_bucket_key_version_id_from_copy_source( + request.get("CopySource") + ) + _, src_s3_bucket = self._get_cross_account_bucket(context, src_bucket) + + if not config.S3_SKIP_KMS_KEY_VALIDATION and (sse_kms_key_id := request.get("SSEKMSKeyId")): + validate_kms_key_id(sse_kms_key_id, dest_s3_bucket) + + # if the object is a delete marker, get_object will raise NotFound if no versionId, like AWS + try: + src_s3_object = src_s3_bucket.get_object(key=src_key, version_id=src_version_id) + except MethodNotAllowed: + raise InvalidRequest( + "The source of a copy request may not specifically refer to a delete marker by version id." + ) + + if src_s3_object.storage_class in ARCHIVES_STORAGE_CLASSES and not src_s3_object.restore: + raise InvalidObjectState( + "Operation is not valid for the source object's storage class", + StorageClass=src_s3_object.storage_class, + ) + + if failed_condition := get_failed_precondition_copy_source( + request, src_s3_object.last_modified, src_s3_object.etag + ): + raise PreconditionFailed( + "At least one of the pre-conditions you specified did not hold", + Condition=failed_condition, + ) + + source_sse_c_key_md5 = request.get("CopySourceSSECustomerKeyMD5") + if src_s3_object.sse_key_hash: + if not source_sse_c_key_md5: + raise InvalidRequest( + "The object was stored using a form of Server Side Encryption. " + "The correct parameters must be provided to retrieve the object." + ) + elif src_s3_object.sse_key_hash != source_sse_c_key_md5: + raise AccessDenied("Access Denied") + + validate_sse_c( + algorithm=request.get("CopySourceSSECustomerAlgorithm"), + encryption_key=request.get("CopySourceSSECustomerKey"), + encryption_key_md5=source_sse_c_key_md5, + ) + + target_sse_c_key_md5 = request.get("SSECustomerKeyMD5") + server_side_encryption = request.get("ServerSideEncryption") + # validate target SSE-C parameters + validate_sse_c( + algorithm=request.get("SSECustomerAlgorithm"), + encryption_key=request.get("SSECustomerKey"), + encryption_key_md5=target_sse_c_key_md5, + server_side_encryption=server_side_encryption, + ) + + # TODO validate order of validation + storage_class = request.get("StorageClass") + metadata_directive = request.get("MetadataDirective") + website_redirect_location = request.get("WebsiteRedirectLocation") + # we need to check for identity of the object, to see if the default one has been changed + is_default_encryption = ( + dest_s3_bucket.encryption_rule is DEFAULT_BUCKET_ENCRYPTION + and src_s3_object.encryption == "AES256" + ) + if ( + src_bucket == dest_bucket + and src_key == dest_key + and not any( + ( + storage_class, + server_side_encryption, + target_sse_c_key_md5, + metadata_directive == "REPLACE", + website_redirect_location, + dest_s3_bucket.encryption_rule + and not is_default_encryption, # S3 will allow copy in place if the bucket has encryption configured + src_s3_object.restore, + ) + ) + ): + raise InvalidRequest( + "This copy request is illegal because it is trying to copy an object to itself without changing the " + "object's metadata, storage class, website redirect location or encryption attributes." + ) + + if tagging := request.get("Tagging"): + tagging = parse_tagging_header(tagging) + + if metadata_directive == "REPLACE": + user_metadata = request.get("Metadata") + system_metadata = get_system_metadata_from_request(request) + if not system_metadata.get("ContentType"): + system_metadata["ContentType"] = "binary/octet-stream" + else: + user_metadata = src_s3_object.user_metadata + system_metadata = src_s3_object.system_metadata + + dest_version_id = generate_version_id(dest_s3_bucket.versioning_status) + + encryption_parameters = get_encryption_parameters_from_request_and_bucket( + request, + dest_s3_bucket, + store, + ) + lock_parameters = get_object_lock_parameters_from_bucket_and_request( + request, dest_s3_bucket + ) + + acl = get_access_control_policy_for_new_resource_request( + request, owner=dest_s3_bucket.owner + ) + checksum_algorithm = request.get("ChecksumAlgorithm") + + s3_object = S3Object( + key=dest_key, + size=src_s3_object.size, + version_id=dest_version_id, + storage_class=storage_class, + expires=request.get("Expires"), + user_metadata=user_metadata, + system_metadata=system_metadata, + checksum_algorithm=checksum_algorithm or src_s3_object.checksum_algorithm, + encryption=encryption_parameters.encryption, + kms_key_id=encryption_parameters.kms_key_id, + bucket_key_enabled=request.get( + "BucketKeyEnabled" + ), # CopyObject does not inherit from the bucket here + sse_key_hash=target_sse_c_key_md5, + lock_mode=lock_parameters.lock_mode, + lock_legal_status=lock_parameters.lock_legal_status, + lock_until=lock_parameters.lock_until, + website_redirect_location=website_redirect_location, + expiration=None, # TODO, from lifecycle + acl=acl, + owner=dest_s3_bucket.owner, + ) + + with self._storage_backend.copy( + src_bucket=src_bucket, + src_object=src_s3_object, + dest_bucket=dest_bucket, + dest_object=s3_object, + ) as s3_stored_object: + s3_object.checksum_value = s3_stored_object.checksum or src_s3_object.checksum_value + s3_object.etag = s3_stored_object.etag or src_s3_object.etag + + dest_s3_bucket.objects.set(dest_key, s3_object) + + dest_key_id = get_unique_key_id(dest_bucket, dest_key, dest_version_id) + + if (request.get("TaggingDirective")) == "REPLACE": + store.TAGS.tags[dest_key_id] = tagging or {} + else: + src_key_id = get_unique_key_id(src_bucket, src_key, src_s3_object.version_id) + src_tags = store.TAGS.tags.get(src_key_id, {}) + store.TAGS.tags[dest_key_id] = copy.copy(src_tags) + + copy_object_result = CopyObjectResult( + ETag=s3_object.quoted_etag, + LastModified=s3_object.last_modified, + ) + if s3_object.checksum_algorithm: + copy_object_result[f"Checksum{s3_object.checksum_algorithm.upper()}"] = ( + s3_object.checksum_value + ) + + response = CopyObjectOutput( + CopyObjectResult=copy_object_result, + ) + + if s3_object.version_id: + response["VersionId"] = s3_object.version_id + + if s3_object.expiration: + response["Expiration"] = s3_object.expiration # TODO: properly parse the datetime + + add_encryption_to_response(response, s3_object=s3_object) + if target_sse_c_key_md5: + response["SSECustomerAlgorithm"] = "AES256" + response["SSECustomerKeyMD5"] = target_sse_c_key_md5 + + if ( + src_s3_bucket.versioning_status + and src_s3_object.version_id + and src_s3_object.version_id != "null" + ): + response["CopySourceVersionId"] = src_s3_object.version_id + + # RequestCharged: Optional[RequestCharged] # TODO + self._notify(context, s3_bucket=dest_s3_bucket, s3_object=s3_object) + + return response + + def list_objects( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter = None, + encoding_type: EncodingType = None, + marker: Marker = None, + max_keys: MaxKeys = None, + prefix: Prefix = None, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + optional_object_attributes: OptionalObjectAttributesList = None, + **kwargs, + ) -> ListObjectsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + common_prefixes = set() + count = 0 + is_truncated = False + next_key_marker = None + max_keys = max_keys or 1000 + prefix = prefix or "" + delimiter = delimiter or "" + if encoding_type: + prefix = urlparse.quote(prefix) + delimiter = urlparse.quote(delimiter) + + s3_objects: list[Object] = [] + + all_keys = sorted(s3_bucket.objects.values(), key=lambda r: r.key) + last_key = all_keys[-1] if all_keys else None + + # sort by key + for s3_object in all_keys: + key = urlparse.quote(s3_object.key) if encoding_type else s3_object.key + # skip all keys that alphabetically come before key_marker + if marker: + if key <= marker: + continue + + # Filter for keys that start with prefix + if prefix and not key.startswith(prefix): + continue + + # see ListObjectsV2 for the logic comments (shared logic here) + prefix_including_delimiter = None + if delimiter and delimiter in (key_no_prefix := key.removeprefix(prefix)): + pre_delimiter, _, _ = key_no_prefix.partition(delimiter) + prefix_including_delimiter = f"{prefix}{pre_delimiter}{delimiter}" + + if prefix_including_delimiter in common_prefixes or ( + marker and marker.startswith(prefix_including_delimiter) + ): + continue + + if prefix_including_delimiter: + common_prefixes.add(prefix_including_delimiter) + else: + # TODO: add RestoreStatus if present + object_data = Object( + Key=key, + ETag=s3_object.quoted_etag, + Owner=s3_bucket.owner, # TODO: verify reality + Size=s3_object.size, + LastModified=s3_object.last_modified, + StorageClass=s3_object.storage_class, + ) + + if s3_object.checksum_algorithm: + object_data["ChecksumAlgorithm"] = [s3_object.checksum_algorithm] + object_data["ChecksumType"] = getattr( + s3_object, "checksum_type", ChecksumType.FULL_OBJECT + ) + + s3_objects.append(object_data) + + # we just added a CommonPrefix or an Object, increase the counter + count += 1 + if count >= max_keys and last_key.key != s3_object.key: + is_truncated = True + if prefix_including_delimiter: + next_key_marker = prefix_including_delimiter + elif s3_objects: + next_key_marker = s3_objects[-1]["Key"] + break + + common_prefixes = [CommonPrefix(Prefix=prefix) for prefix in sorted(common_prefixes)] + + response = ListObjectsOutput( + IsTruncated=is_truncated, + Name=bucket, + MaxKeys=max_keys, + Prefix=prefix or "", + Marker=marker or "", + ) + if s3_objects: + response["Contents"] = s3_objects + if encoding_type: + response["EncodingType"] = EncodingType.url + if delimiter: + response["Delimiter"] = delimiter + if common_prefixes: + response["CommonPrefixes"] = common_prefixes + if delimiter and next_key_marker: + response["NextMarker"] = next_key_marker + if s3_bucket.bucket_region != "us-east-1": + response["BucketRegion"] = s3_bucket.bucket_region + + # RequestCharged: Optional[RequestCharged] # TODO + return response + + def list_objects_v2( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter = None, + encoding_type: EncodingType = None, + max_keys: MaxKeys = None, + prefix: Prefix = None, + continuation_token: Token = None, + fetch_owner: FetchOwner = None, + start_after: StartAfter = None, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + optional_object_attributes: OptionalObjectAttributesList = None, + **kwargs, + ) -> ListObjectsV2Output: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if continuation_token == "": + raise InvalidArgument( + "The continuation token provided is incorrect", + ArgumentName="continuation-token", + ) + + common_prefixes = set() + count = 0 + is_truncated = False + next_continuation_token = None + max_keys = max_keys or 1000 + prefix = prefix or "" + delimiter = delimiter or "" + if encoding_type: + prefix = urlparse.quote(prefix) + delimiter = urlparse.quote(delimiter) + decoded_continuation_token = ( + to_str(base64.urlsafe_b64decode(continuation_token.encode())) + if continuation_token + else None + ) + + s3_objects: list[Object] = [] + + # sort by key + for s3_object in sorted(s3_bucket.objects.values(), key=lambda r: r.key): + key = urlparse.quote(s3_object.key) if encoding_type else s3_object.key + + # skip all keys that alphabetically come before continuation_token + if continuation_token: + if key < decoded_continuation_token: + continue + + elif start_after: + if key <= start_after: + continue + + # Filter for keys that start with prefix + if prefix and not key.startswith(prefix): + continue + + # separate keys that contain the same string between the prefix and the first occurrence of the delimiter + prefix_including_delimiter = None + if delimiter and delimiter in (key_no_prefix := key.removeprefix(prefix)): + pre_delimiter, _, _ = key_no_prefix.partition(delimiter) + prefix_including_delimiter = f"{prefix}{pre_delimiter}{delimiter}" + + # if the CommonPrefix is already in the CommonPrefixes, it doesn't count towards MaxKey, we can skip + # the entry without increasing the counter. We need to iterate over all of these entries before + # returning the next continuation marker, to properly start at the next key after this CommonPrefix + if prefix_including_delimiter in common_prefixes: + continue + + # After skipping all entries, verify we're not over the MaxKeys before adding a new entry + if count >= max_keys: + is_truncated = True + next_continuation_token = to_str(base64.urlsafe_b64encode(s3_object.key.encode())) + break + + # if we found a new CommonPrefix, add it to the CommonPrefixes + # else, it means it's a new Object, add it to the Contents + if prefix_including_delimiter: + common_prefixes.add(prefix_including_delimiter) + else: + # TODO: add RestoreStatus if present + object_data = Object( + Key=key, + ETag=s3_object.quoted_etag, + Size=s3_object.size, + LastModified=s3_object.last_modified, + StorageClass=s3_object.storage_class, + ) + + if fetch_owner: + object_data["Owner"] = s3_bucket.owner + + if s3_object.checksum_algorithm: + object_data["ChecksumAlgorithm"] = [s3_object.checksum_algorithm] + object_data["ChecksumType"] = getattr( + s3_object, "checksum_type", ChecksumType.FULL_OBJECT + ) + + s3_objects.append(object_data) + + # we just added either a CommonPrefix or an Object to the List, increase the counter by one + count += 1 + + common_prefixes = [CommonPrefix(Prefix=prefix) for prefix in sorted(common_prefixes)] + + response = ListObjectsV2Output( + IsTruncated=is_truncated, + Name=bucket, + MaxKeys=max_keys, + Prefix=prefix or "", + KeyCount=count, + ) + if s3_objects: + response["Contents"] = s3_objects + if encoding_type: + response["EncodingType"] = EncodingType.url + if delimiter: + response["Delimiter"] = delimiter + if common_prefixes: + response["CommonPrefixes"] = common_prefixes + if next_continuation_token: + response["NextContinuationToken"] = next_continuation_token + + if continuation_token: + response["ContinuationToken"] = continuation_token + elif start_after: + response["StartAfter"] = start_after + + if s3_bucket.bucket_region != "us-east-1": + response["BucketRegion"] = s3_bucket.bucket_region + + # RequestCharged: Optional[RequestCharged] # TODO + return response + + def list_object_versions( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter = None, + encoding_type: EncodingType = None, + key_marker: KeyMarker = None, + max_keys: MaxKeys = None, + prefix: Prefix = None, + version_id_marker: VersionIdMarker = None, + expected_bucket_owner: AccountId = None, + request_payer: RequestPayer = None, + optional_object_attributes: OptionalObjectAttributesList = None, + **kwargs, + ) -> ListObjectVersionsOutput: + if version_id_marker and not key_marker: + raise InvalidArgument( + "A version-id marker cannot be specified without a key marker.", + ArgumentName="version-id-marker", + ArgumentValue=version_id_marker, + ) + + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + common_prefixes = set() + count = 0 + is_truncated = False + next_key_marker = None + next_version_id_marker = None + max_keys = max_keys or 1000 + prefix = prefix or "" + delimiter = delimiter or "" + if encoding_type: + prefix = urlparse.quote(prefix) + delimiter = urlparse.quote(delimiter) + version_key_marker_found = False + + object_versions: list[ObjectVersion] = [] + delete_markers: list[DeleteMarkerEntry] = [] + + all_versions = s3_bucket.objects.values(with_versions=True) + # sort by key, and last-modified-date, to get the last version first + all_versions.sort(key=lambda r: (r.key, -r.last_modified.timestamp())) + last_version = all_versions[-1] if all_versions else None + + for version in all_versions: + key = urlparse.quote(version.key) if encoding_type else version.key + # skip all keys that alphabetically come before key_marker + if key_marker: + if key < key_marker: + continue + elif key == key_marker: + if not version_id_marker: + continue + # as the keys are ordered by time, once we found the key marker, we can return the next one + if version.version_id == version_id_marker: + version_key_marker_found = True + continue + + # it is possible that the version_id_marker related object has been deleted, in that case, start + # as soon as the next version id is older than the version id marker (meaning this version was + # next after the now-deleted version) + elif is_version_older_than_other(version.version_id, version_id_marker): + version_key_marker_found = True + + elif not version_key_marker_found: + # as long as we have not passed the version_key_marker, skip the versions + continue + + # Filter for keys that start with prefix + if prefix and not key.startswith(prefix): + continue + + # see ListObjectsV2 for the logic comments (shared logic here) + prefix_including_delimiter = None + if delimiter and delimiter in (key_no_prefix := key.removeprefix(prefix)): + pre_delimiter, _, _ = key_no_prefix.partition(delimiter) + prefix_including_delimiter = f"{prefix}{pre_delimiter}{delimiter}" + + if prefix_including_delimiter in common_prefixes or ( + key_marker and key_marker.startswith(prefix_including_delimiter) + ): + continue + + if prefix_including_delimiter: + common_prefixes.add(prefix_including_delimiter) + + elif isinstance(version, S3DeleteMarker): + delete_marker = DeleteMarkerEntry( + Key=key, + Owner=s3_bucket.owner, + VersionId=version.version_id, + IsLatest=version.is_current, + LastModified=version.last_modified, + ) + delete_markers.append(delete_marker) + else: + # TODO: add RestoreStatus if present + object_version = ObjectVersion( + Key=key, + ETag=version.quoted_etag, + Owner=s3_bucket.owner, # TODO: verify reality + Size=version.size, + VersionId=version.version_id or "null", + LastModified=version.last_modified, + IsLatest=version.is_current, + # TODO: verify this, are other class possible? + # StorageClass=version.storage_class, + StorageClass=ObjectVersionStorageClass.STANDARD, + ) + + if version.checksum_algorithm: + object_version["ChecksumAlgorithm"] = [version.checksum_algorithm] + object_version["ChecksumType"] = getattr( + version, "checksum_type", ChecksumType.FULL_OBJECT + ) + + object_versions.append(object_version) + + # we just added a CommonPrefix, an Object or a DeleteMarker, increase the counter + count += 1 + if count >= max_keys and last_version.version_id != version.version_id: + is_truncated = True + if prefix_including_delimiter: + next_key_marker = prefix_including_delimiter + else: + next_key_marker = version.key + next_version_id_marker = version.version_id + break + + common_prefixes = [CommonPrefix(Prefix=prefix) for prefix in sorted(common_prefixes)] + + response = ListObjectVersionsOutput( + IsTruncated=is_truncated, + Name=bucket, + MaxKeys=max_keys, + Prefix=prefix, + KeyMarker=key_marker or "", + VersionIdMarker=version_id_marker or "", + ) + if object_versions: + response["Versions"] = object_versions + if encoding_type: + response["EncodingType"] = EncodingType.url + if delete_markers: + response["DeleteMarkers"] = delete_markers + if delimiter: + response["Delimiter"] = delimiter + if common_prefixes: + response["CommonPrefixes"] = common_prefixes + if next_key_marker: + response["NextKeyMarker"] = next_key_marker + if next_version_id_marker: + response["NextVersionIdMarker"] = next_version_id_marker + + # RequestCharged: Optional[RequestCharged] # TODO + return response + + @handler("GetObjectAttributes", expand=False) + def get_object_attributes( + self, + context: RequestContext, + request: GetObjectAttributesRequest, + ) -> GetObjectAttributesOutput: + bucket_name = request["Bucket"] + object_key = request["Key"] + store, s3_bucket = self._get_cross_account_bucket(context, bucket_name) + + s3_object = s3_bucket.get_object( + key=object_key, + version_id=request.get("VersionId"), + http_method="GET", + ) + + sse_c_key_md5 = request.get("SSECustomerKeyMD5") + if s3_object.sse_key_hash: + if not sse_c_key_md5: + raise InvalidRequest( + "The object was stored using a form of Server Side Encryption. " + "The correct parameters must be provided to retrieve the object." + ) + elif s3_object.sse_key_hash != sse_c_key_md5: + raise AccessDenied("Access Denied") + + validate_sse_c( + algorithm=request.get("SSECustomerAlgorithm"), + encryption_key=request.get("SSECustomerKey"), + encryption_key_md5=sse_c_key_md5, + ) + + object_attrs = request.get("ObjectAttributes", []) + response = GetObjectAttributesOutput() + object_checksum_type = getattr(s3_object, "checksum_type", ChecksumType.FULL_OBJECT) + if "ETag" in object_attrs: + response["ETag"] = s3_object.etag + if "StorageClass" in object_attrs: + response["StorageClass"] = s3_object.storage_class + if "ObjectSize" in object_attrs: + response["ObjectSize"] = s3_object.size + if "Checksum" in object_attrs and (checksum_algorithm := s3_object.checksum_algorithm): + if s3_object.parts: + checksum_value = s3_object.checksum_value.split("-")[0] + else: + checksum_value = s3_object.checksum_value + response["Checksum"] = { + f"Checksum{checksum_algorithm.upper()}": checksum_value, + "ChecksumType": object_checksum_type, + } + + response["LastModified"] = s3_object.last_modified + + if s3_bucket.versioning_status: + response["VersionId"] = s3_object.version_id + + if "ObjectParts" in object_attrs and s3_object.parts: + if object_checksum_type == ChecksumType.FULL_OBJECT: + response["ObjectParts"] = GetObjectAttributesParts( + TotalPartsCount=len(s3_object.parts) + ) + else: + # this is basically a simplified `ListParts` call on the object, only returned when the checksum type is + # COMPOSITE + count = 0 + is_truncated = False + part_number_marker = request.get("PartNumberMarker") or 0 + max_parts = request.get("MaxParts") or 1000 + + parts = [] + all_parts = sorted(s3_object.parts.items()) + last_part_number, last_part = all_parts[-1] + + # TODO: remove this backward compatibility hack needed for state created with <= 4.5 + # the parts would only be a tuple and would not store the proper state for 4.5 and earlier, so we need + # to return early + if isinstance(last_part, tuple): + response["ObjectParts"] = GetObjectAttributesParts( + TotalPartsCount=len(s3_object.parts) + ) + return response + + for part_number, part in all_parts: + if part_number <= part_number_marker: + continue + part_item = select_from_typed_dict(ObjectPart, part) + + parts.append(part_item) + count += 1 + + if count >= max_parts and part["PartNumber"] != last_part_number: + is_truncated = True + break + + object_parts = GetObjectAttributesParts( + TotalPartsCount=len(s3_object.parts), + IsTruncated=is_truncated, + MaxParts=max_parts, + PartNumberMarker=part_number_marker, + NextPartNumberMarker=0, + ) + if parts: + object_parts["Parts"] = parts + object_parts["NextPartNumberMarker"] = parts[-1]["PartNumber"] + + response["ObjectParts"] = object_parts + + return response + + def restore_object( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId = None, + restore_request: RestoreRequest = None, + request_payer: RequestPayer = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> RestoreObjectOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_object = s3_bucket.get_object( + key=key, + version_id=version_id, + http_method="GET", # TODO: verify http method + ) + if s3_object.storage_class not in ARCHIVES_STORAGE_CLASSES: + raise InvalidObjectState(StorageClass=s3_object.storage_class) + + # TODO: moto was only supported "Days" parameters from RestoreRequest, and was ignoring the others + # will only implement only the same functionality for now + + # if a request was already done and the object was available, and we're updating it, set the status code to 200 + status_code = 200 if s3_object.restore else 202 + restore_days = restore_request.get("Days") + if not restore_days: + LOG.debug("LocalStack does not support restore SELECT requests yet.") + return RestoreObjectOutput() + + restore_expiration_date = add_expiration_days_to_datetime( + datetime.datetime.now(datetime.UTC), restore_days + ) + # TODO: add a way to transition from ongoing-request=true to false? for now it is instant + s3_object.restore = f'ongoing-request="false", expiry-date="{restore_expiration_date}"' + + s3_notif_ctx_initiated = S3EventNotificationContext.from_request_context_native( + context, + s3_bucket=s3_bucket, + s3_object=s3_object, + ) + self._notify(context, s3_bucket=s3_bucket, s3_notif_ctx=s3_notif_ctx_initiated) + # But because it's instant in LocalStack, we can directly send the Completed notification as well + # We just need to copy the context so that we don't mutate the first context while it could be sent + # And modify its event type from `ObjectRestore:Post` to `ObjectRestore:Completed` + s3_notif_ctx_completed = copy.copy(s3_notif_ctx_initiated) + s3_notif_ctx_completed.event_type = s3_notif_ctx_completed.event_type.replace( + "Post", "Completed" + ) + self._notify(context, s3_bucket=s3_bucket, s3_notif_ctx=s3_notif_ctx_completed) + + # TODO: request charged + return RestoreObjectOutput(StatusCode=status_code) + + @handler("CreateMultipartUpload", expand=False) + def create_multipart_upload( + self, + context: RequestContext, + request: CreateMultipartUploadRequest, + ) -> CreateMultipartUploadOutput: + # TODO: handle missing parameters: + # request_payer: RequestPayer = None, + bucket_name = request["Bucket"] + store, s3_bucket = self._get_cross_account_bucket(context, bucket_name) + + if (storage_class := request.get("StorageClass")) is not None and ( + storage_class not in STORAGE_CLASSES or storage_class == StorageClass.OUTPOSTS + ): + raise InvalidStorageClass( + "The storage class you specified is not valid", StorageClassRequested=storage_class + ) + + if not config.S3_SKIP_KMS_KEY_VALIDATION and (sse_kms_key_id := request.get("SSEKMSKeyId")): + validate_kms_key_id(sse_kms_key_id, s3_bucket) + + if tagging := request.get("Tagging"): + tagging = parse_tagging_header(tagging_header=tagging) + + key = request["Key"] + + system_metadata = get_system_metadata_from_request(request) + if not system_metadata.get("ContentType"): + system_metadata["ContentType"] = "binary/octet-stream" + + checksum_algorithm = request.get("ChecksumAlgorithm") + if checksum_algorithm and checksum_algorithm not in CHECKSUM_ALGORITHMS: + raise InvalidRequest( + "Checksum algorithm provided is unsupported. Please try again with any of the valid types: [CRC32, CRC32C, SHA1, SHA256]" + ) + + if not (checksum_type := request.get("ChecksumType")) and checksum_algorithm: + if checksum_algorithm == ChecksumAlgorithm.CRC64NVME: + checksum_type = ChecksumType.FULL_OBJECT + else: + checksum_type = ChecksumType.COMPOSITE + elif checksum_type and not checksum_algorithm: + raise InvalidRequest( + "The x-amz-checksum-type header can only be used with the x-amz-checksum-algorithm header." + ) + + if ( + checksum_type == ChecksumType.COMPOSITE + and checksum_algorithm == ChecksumAlgorithm.CRC64NVME + ): + raise InvalidRequest( + "The COMPOSITE checksum type cannot be used with the crc64nvme checksum algorithm." + ) + elif checksum_type == ChecksumType.FULL_OBJECT and checksum_algorithm.upper().startswith( + "SHA" + ): + raise InvalidRequest( + f"The FULL_OBJECT checksum type cannot be used with the {checksum_algorithm.lower()} checksum algorithm." + ) + + # TODO: we're not encrypting the object with the provided key for now + sse_c_key_md5 = request.get("SSECustomerKeyMD5") + validate_sse_c( + algorithm=request.get("SSECustomerAlgorithm"), + encryption_key=request.get("SSECustomerKey"), + encryption_key_md5=sse_c_key_md5, + server_side_encryption=request.get("ServerSideEncryption"), + ) + + encryption_parameters = get_encryption_parameters_from_request_and_bucket( + request, + s3_bucket, + store, + ) + lock_parameters = get_object_lock_parameters_from_bucket_and_request(request, s3_bucket) + + acl = get_access_control_policy_for_new_resource_request(request, owner=s3_bucket.owner) + + # validate encryption values + s3_multipart = S3Multipart( + key=key, + storage_class=storage_class, + expires=request.get("Expires"), + user_metadata=request.get("Metadata"), + system_metadata=system_metadata, + checksum_algorithm=checksum_algorithm, + checksum_type=checksum_type, + encryption=encryption_parameters.encryption, + kms_key_id=encryption_parameters.kms_key_id, + bucket_key_enabled=encryption_parameters.bucket_key_enabled, + sse_key_hash=sse_c_key_md5, + lock_mode=lock_parameters.lock_mode, + lock_legal_status=lock_parameters.lock_legal_status, + lock_until=lock_parameters.lock_until, + website_redirect_location=request.get("WebsiteRedirectLocation"), + expiration=None, # TODO, from lifecycle, or should it be updated with config? + acl=acl, + initiator=get_owner_for_account_id(context.account_id), + tagging=tagging, + owner=s3_bucket.owner, + precondition=object_exists_for_precondition_write(s3_bucket, key), + ) + # it seems if there is SSE-C on the multipart, AWS S3 will override the default Checksum behavior (but not on + # PutObject) + if sse_c_key_md5: + s3_multipart.object.checksum_algorithm = None + + s3_bucket.multiparts[s3_multipart.id] = s3_multipart + + response = CreateMultipartUploadOutput( + Bucket=bucket_name, Key=key, UploadId=s3_multipart.id + ) + + if checksum_algorithm: + response["ChecksumAlgorithm"] = checksum_algorithm + response["ChecksumType"] = checksum_type + + add_encryption_to_response(response, s3_object=s3_multipart.object) + if sse_c_key_md5: + response["SSECustomerAlgorithm"] = "AES256" + response["SSECustomerKeyMD5"] = sse_c_key_md5 + + # TODO: missing response fields we're not currently supporting + # - AbortDate: lifecycle related,not currently supported, todo + # - AbortRuleId: lifecycle related, not currently supported, todo + # - RequestCharged: todo + + return response + + @handler("UploadPart", expand=False) + def upload_part( + self, + context: RequestContext, + request: UploadPartRequest, + ) -> UploadPartOutput: + # TODO: missing following parameters: + # content_length: ContentLength = None, ->validate? + # content_md5: ContentMD5 = None, -> validate? + # request_payer: RequestPayer = None, + bucket_name = request["Bucket"] + store, s3_bucket = self._get_cross_account_bucket(context, bucket_name) + + upload_id = request.get("UploadId") + if not ( + s3_multipart := s3_bucket.multiparts.get(upload_id) + ) or s3_multipart.object.key != request.get("Key"): + raise NoSuchUpload( + "The specified upload does not exist. " + "The upload ID may be invalid, or the upload may have been aborted or completed.", + UploadId=upload_id, + ) + elif (part_number := request.get("PartNumber", 0)) < 1 or part_number > 10000: + raise InvalidArgument( + "Part number must be an integer between 1 and 10000, inclusive", + ArgumentName="partNumber", + ArgumentValue=part_number, + ) + + if content_md5 := request.get("ContentMD5"): + # assert that the received ContentMD5 is a properly b64 encoded value that fits a MD5 hash length + if not base_64_content_md5_to_etag(content_md5): + raise InvalidDigest( + "The Content-MD5 you specified was invalid.", + Content_MD5=content_md5, + ) + + checksum_algorithm = get_s3_checksum_algorithm_from_request(request) + checksum_value = ( + request.get(f"Checksum{checksum_algorithm.upper()}") if checksum_algorithm else None + ) + + # TODO: we're not encrypting the object with the provided key for now + sse_c_key_md5 = request.get("SSECustomerKeyMD5") + validate_sse_c( + algorithm=request.get("SSECustomerAlgorithm"), + encryption_key=request.get("SSECustomerKey"), + encryption_key_md5=sse_c_key_md5, + ) + + if (s3_multipart.object.sse_key_hash and not sse_c_key_md5) or ( + sse_c_key_md5 and not s3_multipart.object.sse_key_hash + ): + raise InvalidRequest( + "The multipart upload initiate requested encryption. " + "Subsequent part requests must include the appropriate encryption parameters." + ) + elif ( + s3_multipart.object.sse_key_hash + and sse_c_key_md5 + and s3_multipart.object.sse_key_hash != sse_c_key_md5 + ): + raise InvalidRequest( + "The provided encryption parameters did not match the ones used originally." + ) + + s3_part = S3Part( + part_number=part_number, + checksum_algorithm=checksum_algorithm, + checksum_value=checksum_value, + ) + body = request.get("Body") + headers = context.request.headers + is_aws_chunked = headers.get("x-amz-content-sha256", "").startswith( + "STREAMING-" + ) or "aws-chunked" in headers.get("content-encoding", "") + # check if chunked request + if is_aws_chunked: + checksum_algorithm = ( + checksum_algorithm + or get_s3_checksum_algorithm_from_trailing_headers(headers.get("x-amz-trailer", "")) + ) + if checksum_algorithm: + s3_part.checksum_algorithm = checksum_algorithm + + decoded_content_length = int(headers.get("x-amz-decoded-content-length", 0)) + body = AwsChunkedDecoder(body, decoded_content_length, s3_part) + + if ( + s3_multipart.checksum_algorithm + and s3_part.checksum_algorithm != s3_multipart.checksum_algorithm + ): + error_req_checksum = checksum_algorithm.lower() if checksum_algorithm else "null" + error_mp_checksum = ( + s3_multipart.object.checksum_algorithm.lower() + if s3_multipart.object.checksum_algorithm + else "null" + ) + if not error_mp_checksum == "null": + raise InvalidRequest( + f"Checksum Type mismatch occurred, expected checksum Type: {error_mp_checksum}, actual checksum Type: {error_req_checksum}" + ) + + stored_multipart = self._storage_backend.get_multipart(bucket_name, s3_multipart) + with stored_multipart.open(s3_part, mode="w") as stored_s3_part: + try: + stored_s3_part.write(body) + except Exception: + stored_multipart.remove_part(s3_part) + raise + + if checksum_algorithm: + if not validate_checksum_value(s3_part.checksum_value, checksum_algorithm): + stored_multipart.remove_part(s3_part) + raise InvalidRequest( + f"Value for x-amz-checksum-{s3_part.checksum_algorithm.lower()} header is invalid." + ) + elif s3_part.checksum_value != stored_s3_part.checksum: + stored_multipart.remove_part(s3_part) + raise BadDigest( + f"The {checksum_algorithm.upper()} you specified did not match the calculated checksum." + ) + + if content_md5: + calculated_md5 = etag_to_base_64_content_md5(s3_part.etag) + if calculated_md5 != content_md5: + stored_multipart.remove_part(s3_part) + raise BadDigest( + "The Content-MD5 you specified did not match what we received.", + ExpectedDigest=content_md5, + CalculatedDigest=calculated_md5, + ) + + s3_multipart.parts[part_number] = s3_part + + response = UploadPartOutput( + ETag=s3_part.quoted_etag, + ) + + add_encryption_to_response(response, s3_object=s3_multipart.object) + if sse_c_key_md5: + response["SSECustomerAlgorithm"] = "AES256" + response["SSECustomerKeyMD5"] = sse_c_key_md5 + + if s3_part.checksum_algorithm: + response[f"Checksum{s3_part.checksum_algorithm.upper()}"] = s3_part.checksum_value + + # TODO: RequestCharged: Optional[RequestCharged] + return response + + @handler("UploadPartCopy", expand=False) + def upload_part_copy( + self, + context: RequestContext, + request: UploadPartCopyRequest, + ) -> UploadPartCopyOutput: + # TODO: handle following parameters: + # CopySourceIfMatch: Optional[CopySourceIfMatch] + # CopySourceIfModifiedSince: Optional[CopySourceIfModifiedSince] + # CopySourceIfNoneMatch: Optional[CopySourceIfNoneMatch] + # CopySourceIfUnmodifiedSince: Optional[CopySourceIfUnmodifiedSince] + # SSECustomerAlgorithm: Optional[SSECustomerAlgorithm] + # SSECustomerKey: Optional[SSECustomerKey] + # SSECustomerKeyMD5: Optional[SSECustomerKeyMD5] + # CopySourceSSECustomerAlgorithm: Optional[CopySourceSSECustomerAlgorithm] + # CopySourceSSECustomerKey: Optional[CopySourceSSECustomerKey] + # CopySourceSSECustomerKeyMD5: Optional[CopySourceSSECustomerKeyMD5] + # RequestPayer: Optional[RequestPayer] + # ExpectedBucketOwner: Optional[AccountId] + # ExpectedSourceBucketOwner: Optional[AccountId] + dest_bucket = request["Bucket"] + dest_key = request["Key"] + store = self.get_store(context.account_id, context.region) + # TODO: validate cross-account UploadPartCopy + if not (dest_s3_bucket := store.buckets.get(dest_bucket)): + raise NoSuchBucket("The specified bucket does not exist", BucketName=dest_bucket) + + src_bucket, src_key, src_version_id = extract_bucket_key_version_id_from_copy_source( + request.get("CopySource") + ) + + if not (src_s3_bucket := store.buckets.get(src_bucket)): + raise NoSuchBucket("The specified bucket does not exist", BucketName=src_bucket) + + # if the object is a delete marker, get_object will raise NotFound if no versionId, like AWS + try: + src_s3_object = src_s3_bucket.get_object(key=src_key, version_id=src_version_id) + except MethodNotAllowed: + raise InvalidRequest( + "The source of a copy request may not specifically refer to a delete marker by version id." + ) + + if src_s3_object.storage_class in ARCHIVES_STORAGE_CLASSES and not src_s3_object.restore: + raise InvalidObjectState( + "Operation is not valid for the source object's storage class", + StorageClass=src_s3_object.storage_class, + ) + + upload_id = request.get("UploadId") + if ( + not (s3_multipart := dest_s3_bucket.multiparts.get(upload_id)) + or s3_multipart.object.key != dest_key + ): + raise NoSuchUpload( + "The specified upload does not exist. " + "The upload ID may be invalid, or the upload may have been aborted or completed.", + UploadId=upload_id, + ) + + elif (part_number := request.get("PartNumber", 0)) < 1 or part_number > 10000: + raise InvalidArgument( + "Part number must be an integer between 1 and 10000, inclusive", + ArgumentName="partNumber", + ArgumentValue=part_number, + ) + + source_range = request.get("CopySourceRange") + # TODO implement copy source IF + + range_data: Optional[ObjectRange] = None + if source_range: + range_data = parse_copy_source_range_header(source_range, src_s3_object.size) + + s3_part = S3Part(part_number=part_number) + if s3_multipart.checksum_algorithm: + s3_part.checksum_algorithm = s3_multipart.checksum_algorithm + + stored_multipart = self._storage_backend.get_multipart(dest_bucket, s3_multipart) + stored_multipart.copy_from_object(s3_part, src_bucket, src_s3_object, range_data) + + s3_multipart.parts[part_number] = s3_part + + # TODO: return those fields + # RequestCharged: Optional[RequestCharged] + + result = CopyPartResult( + ETag=s3_part.quoted_etag, + LastModified=s3_part.last_modified, + ) + + response = UploadPartCopyOutput( + CopyPartResult=result, + ) + + if src_s3_bucket.versioning_status and src_s3_object.version_id: + response["CopySourceVersionId"] = src_s3_object.version_id + + if s3_part.checksum_algorithm: + result[f"Checksum{s3_part.checksum_algorithm.upper()}"] = s3_part.checksum_value + + add_encryption_to_response(response, s3_object=s3_multipart.object) + + return response + + def complete_multipart_upload( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + upload_id: MultipartUploadId, + multipart_upload: CompletedMultipartUpload = None, + checksum_crc32: ChecksumCRC32 = None, + checksum_crc32_c: ChecksumCRC32C = None, + checksum_crc64_nvme: ChecksumCRC64NVME = None, + checksum_sha1: ChecksumSHA1 = None, + checksum_sha256: ChecksumSHA256 = None, + checksum_type: ChecksumType = None, + mpu_object_size: MpuObjectSize = None, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + if_match: IfMatch = None, + if_none_match: IfNoneMatch = None, + sse_customer_algorithm: SSECustomerAlgorithm = None, + sse_customer_key: SSECustomerKey = None, + sse_customer_key_md5: SSECustomerKeyMD5 = None, + **kwargs, + ) -> CompleteMultipartUploadOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if ( + not (s3_multipart := s3_bucket.multiparts.get(upload_id)) + or s3_multipart.object.key != key + ): + raise NoSuchUpload( + "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + UploadId=upload_id, + ) + + if if_none_match and if_match: + raise NotImplementedException( + "A header you provided implies functionality that is not implemented", + Header="If-Match,If-None-Match", + additionalMessage="Multiple conditional request headers present in the request", + ) + + elif if_none_match: + if if_none_match != "*": + raise NotImplementedException( + "A header you provided implies functionality that is not implemented", + Header="If-None-Match", + additionalMessage="We don't accept the provided value of If-None-Match header for this API", + ) + if object_exists_for_precondition_write(s3_bucket, key): + raise PreconditionFailed( + "At least one of the pre-conditions you specified did not hold", + Condition="If-None-Match", + ) + elif s3_multipart.precondition: + raise ConditionalRequestConflict( + "The conditional request cannot succeed due to a conflicting operation against this resource.", + Condition="If-None-Match", + Key=key, + ) + + elif if_match: + if if_match == "*": + raise NotImplementedException( + "A header you provided implies functionality that is not implemented", + Header="If-None-Match", + additionalMessage="We don't accept the provided value of If-None-Match header for this API", + ) + verify_object_equality_precondition_write( + s3_bucket, key, if_match, initiated=s3_multipart.initiated + ) + + parts = multipart_upload.get("Parts", []) + if not parts: + raise InvalidRequest("You must specify at least one part") + + parts_numbers = [part.get("PartNumber") for part in parts] + # TODO: it seems that with new S3 data integrity, sorting might not be mandatory depending on checksum type + # see https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html + # sorted is very fast (fastest) if the list is already sorted, which should be the case + if sorted(parts_numbers) != parts_numbers: + raise InvalidPartOrder( + "The list of parts was not in ascending order. Parts must be ordered by part number.", + UploadId=upload_id, + ) + + mpu_checksum_algorithm = s3_multipart.checksum_algorithm + mpu_checksum_type = getattr(s3_multipart, "checksum_type", None) + + if checksum_type and checksum_type != mpu_checksum_type: + raise InvalidRequest( + f"The upload was created using the {mpu_checksum_type or 'null'} checksum mode. " + f"The complete request must use the same checksum mode." + ) + + # generate the versionId before completing, in case the bucket versioning status has changed between + # creation and completion? AWS validate this + version_id = generate_version_id(s3_bucket.versioning_status) + s3_multipart.object.version_id = version_id + + # we're inspecting the signature of `complete_multipart`, in case the multipart has been restored from + # persistence. if we do not have a new version, do not validate those parameters + # TODO: remove for next major version (minor?) + if signature(s3_multipart.complete_multipart).parameters.get("mpu_size"): + checksum_algorithm = mpu_checksum_algorithm.lower() if mpu_checksum_algorithm else None + checksum_map = { + "crc32": checksum_crc32, + "crc32c": checksum_crc32_c, + "crc64nvme": checksum_crc64_nvme, + "sha1": checksum_sha1, + "sha256": checksum_sha256, + } + checksum_value = checksum_map.get(checksum_algorithm) + s3_multipart.complete_multipart( + parts, mpu_size=mpu_object_size, validation_checksum=checksum_value + ) + if mpu_checksum_algorithm and ( + ( + checksum_value + and mpu_checksum_type == ChecksumType.FULL_OBJECT + and not checksum_type + ) + or any( + checksum_value + for checksum_type, checksum_value in checksum_map.items() + if checksum_type != checksum_algorithm + ) + ): + # this is not ideal, but this validation comes last... after the validation of individual parts + s3_multipart.object.parts.clear() + raise BadDigest( + f"The {mpu_checksum_algorithm.lower()} you specified did not match the calculated checksum." + ) + else: + s3_multipart.complete_multipart(parts) + + stored_multipart = self._storage_backend.get_multipart(bucket, s3_multipart) + stored_multipart.complete_multipart( + [s3_multipart.parts.get(part_number) for part_number in parts_numbers] + ) + if not s3_multipart.checksum_algorithm and s3_multipart.object.checksum_algorithm: + with self._storage_backend.open( + bucket, s3_multipart.object, mode="r" + ) as s3_stored_object: + s3_multipart.object.checksum_value = s3_stored_object.checksum + s3_multipart.object.checksum_type = ChecksumType.FULL_OBJECT + + s3_object = s3_multipart.object + + s3_bucket.objects.set(key, s3_object) + + # remove the multipart now that it's complete + self._storage_backend.remove_multipart(bucket, s3_multipart) + s3_bucket.multiparts.pop(s3_multipart.id, None) + + key_id = get_unique_key_id(bucket, key, version_id) + store.TAGS.tags.pop(key_id, None) + if s3_multipart.tagging: + store.TAGS.tags[key_id] = s3_multipart.tagging + + # RequestCharged: Optional[RequestCharged] TODO + + response = CompleteMultipartUploadOutput( + Bucket=bucket, + Key=key, + ETag=s3_object.quoted_etag, + Location=f"{get_full_default_bucket_location(bucket)}{key}", + ) + + if s3_object.version_id: + response["VersionId"] = s3_object.version_id + + # it seems AWS is not returning checksum related fields if the object has KMS encryption Β―\_(ツ)_/Β― + # but it still generates them, and they can be retrieved with regular GetObject and such operations + if s3_object.checksum_algorithm and not s3_object.kms_key_id: + response[f"Checksum{s3_object.checksum_algorithm.upper()}"] = s3_object.checksum_value + response["ChecksumType"] = s3_object.checksum_type + + if s3_object.expiration: + response["Expiration"] = s3_object.expiration # TODO: properly parse the datetime + + add_encryption_to_response(response, s3_object=s3_object) + + self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object) + + return response + + def abort_multipart_upload( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + upload_id: MultipartUploadId, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + if_match_initiated_time: IfMatchInitiatedTime = None, + **kwargs, + ) -> AbortMultipartUploadOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if ( + not (s3_multipart := s3_bucket.multiparts.get(upload_id)) + or s3_multipart.object.key != key + ): + raise NoSuchUpload( + "The specified upload does not exist. " + "The upload ID may be invalid, or the upload may have been aborted or completed.", + UploadId=upload_id, + ) + s3_bucket.multiparts.pop(upload_id, None) + + self._storage_backend.remove_multipart(bucket, s3_multipart) + response = AbortMultipartUploadOutput() + # TODO: requestCharged + return response + + def list_parts( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + upload_id: MultipartUploadId, + max_parts: MaxParts = None, + part_number_marker: PartNumberMarker = None, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + sse_customer_algorithm: SSECustomerAlgorithm = None, + sse_customer_key: SSECustomerKey = None, + sse_customer_key_md5: SSECustomerKeyMD5 = None, + **kwargs, + ) -> ListPartsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if ( + not (s3_multipart := s3_bucket.multiparts.get(upload_id)) + or s3_multipart.object.key != key + ): + raise NoSuchUpload( + "The specified upload does not exist. " + "The upload ID may be invalid, or the upload may have been aborted or completed.", + UploadId=upload_id, + ) + + count = 0 + is_truncated = False + part_number_marker = part_number_marker or 0 + max_parts = max_parts or 1000 + + parts = [] + all_parts = sorted(s3_multipart.parts.items()) + last_part_number = all_parts[-1][0] if all_parts else None + for part_number, part in all_parts: + if part_number <= part_number_marker: + continue + part_item = Part( + ETag=part.quoted_etag, + LastModified=part.last_modified, + PartNumber=part_number, + Size=part.size, + ) + if s3_multipart.checksum_algorithm and part.checksum_algorithm: + part_item[f"Checksum{part.checksum_algorithm.upper()}"] = part.checksum_value + + parts.append(part_item) + count += 1 + + if count >= max_parts and part.part_number != last_part_number: + is_truncated = True + break + + response = ListPartsOutput( + Bucket=bucket, + Key=key, + UploadId=upload_id, + Initiator=s3_multipart.initiator, + Owner=s3_multipart.initiator, + StorageClass=s3_multipart.object.storage_class, + IsTruncated=is_truncated, + MaxParts=max_parts, + PartNumberMarker=0, + NextPartNumberMarker=0, + ) + if parts: + response["Parts"] = parts + last_part = parts[-1]["PartNumber"] + response["NextPartNumberMarker"] = last_part + + if part_number_marker: + response["PartNumberMarker"] = part_number_marker + if s3_multipart.checksum_algorithm: + response["ChecksumAlgorithm"] = s3_multipart.object.checksum_algorithm + response["ChecksumType"] = getattr(s3_multipart, "checksum_type", None) + + # AbortDate: Optional[AbortDate] TODO: lifecycle + # AbortRuleId: Optional[AbortRuleId] TODO: lifecycle + # RequestCharged: Optional[RequestCharged] + + return response + + def list_multipart_uploads( + self, + context: RequestContext, + bucket: BucketName, + delimiter: Delimiter = None, + encoding_type: EncodingType = None, + key_marker: KeyMarker = None, + max_uploads: MaxUploads = None, + prefix: Prefix = None, + upload_id_marker: UploadIdMarker = None, + expected_bucket_owner: AccountId = None, + request_payer: RequestPayer = None, + **kwargs, + ) -> ListMultipartUploadsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + common_prefixes = set() + count = 0 + is_truncated = False + max_uploads = max_uploads or 1000 + prefix = prefix or "" + delimiter = delimiter or "" + if encoding_type: + prefix = urlparse.quote(prefix) + delimiter = urlparse.quote(delimiter) + upload_id_marker_found = False + + if key_marker and upload_id_marker: + multipart = s3_bucket.multiparts.get(upload_id_marker) + if multipart: + key = ( + urlparse.quote(multipart.object.key) if encoding_type else multipart.object.key + ) + else: + # set key to None so it fails if the multipart is not Found + key = None + + if key_marker != key: + raise InvalidArgument( + "Invalid uploadId marker", + ArgumentName="upload-id-marker", + ArgumentValue=upload_id_marker, + ) + + uploads = [] + # sort by key and initiated + all_multiparts = sorted( + s3_bucket.multiparts.values(), key=lambda r: (r.object.key, r.initiated.timestamp()) + ) + last_multipart = all_multiparts[-1] if all_multiparts else None + + for multipart in all_multiparts: + key = urlparse.quote(multipart.object.key) if encoding_type else multipart.object.key + # skip all keys that are different than key_marker + if key_marker: + if key < key_marker: + continue + elif key == key_marker: + if not upload_id_marker: + continue + # as the keys are ordered by time, once we found the key marker, we can return the next one + if multipart.id == upload_id_marker: + upload_id_marker_found = True + continue + elif not upload_id_marker_found: + # as long as we have not passed the version_key_marker, skip the versions + continue + + # Filter for keys that start with prefix + if prefix and not key.startswith(prefix): + continue + + # see ListObjectsV2 for the logic comments (shared logic here) + prefix_including_delimiter = None + if delimiter and delimiter in (key_no_prefix := key.removeprefix(prefix)): + pre_delimiter, _, _ = key_no_prefix.partition(delimiter) + prefix_including_delimiter = f"{prefix}{pre_delimiter}{delimiter}" + + if prefix_including_delimiter in common_prefixes or ( + key_marker and key_marker.startswith(prefix_including_delimiter) + ): + continue + + if prefix_including_delimiter: + common_prefixes.add(prefix_including_delimiter) + else: + multipart_upload = MultipartUpload( + UploadId=multipart.id, + Key=multipart.object.key, + Initiated=multipart.initiated, + StorageClass=multipart.object.storage_class, + Owner=multipart.initiator, # TODO: check the difference + Initiator=multipart.initiator, + ) + if multipart.checksum_algorithm: + multipart_upload["ChecksumAlgorithm"] = multipart.checksum_algorithm + multipart_upload["ChecksumType"] = getattr(multipart, "checksum_type", None) + + uploads.append(multipart_upload) + + count += 1 + if count >= max_uploads and last_multipart.id != multipart.id: + is_truncated = True + break + + common_prefixes = [CommonPrefix(Prefix=prefix) for prefix in sorted(common_prefixes)] + + response = ListMultipartUploadsOutput( + Bucket=bucket, + IsTruncated=is_truncated, + MaxUploads=max_uploads or 1000, + KeyMarker=key_marker or "", + UploadIdMarker=upload_id_marker or "" if key_marker else "", + NextKeyMarker="", + NextUploadIdMarker="", + ) + if uploads: + response["Uploads"] = uploads + last_upload = uploads[-1] + response["NextKeyMarker"] = last_upload["Key"] + response["NextUploadIdMarker"] = last_upload["UploadId"] + if delimiter: + response["Delimiter"] = delimiter + if prefix: + response["Prefix"] = prefix + if encoding_type: + response["EncodingType"] = EncodingType.url + if common_prefixes: + response["CommonPrefixes"] = common_prefixes + + return response + + def put_bucket_versioning( + self, + context: RequestContext, + bucket: BucketName, + versioning_configuration: VersioningConfiguration, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + mfa: MFA = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + if not (versioning_status := versioning_configuration.get("Status")): + raise CommonServiceException( + code="IllegalVersioningConfigurationException", + message="The Versioning element must be specified", + ) + + if versioning_status not in ("Enabled", "Suspended"): + raise MalformedXML() + + if s3_bucket.object_lock_enabled and versioning_status == "Suspended": + raise InvalidBucketState( + "An Object Lock configuration is present on this bucket, so the versioning state cannot be changed." + ) + + if not s3_bucket.versioning_status: + s3_bucket.objects = VersionedKeyStore.from_key_store(s3_bucket.objects) + + s3_bucket.versioning_status = versioning_status + + def get_bucket_versioning( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketVersioningOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.versioning_status: + return GetBucketVersioningOutput() + + return GetBucketVersioningOutput(Status=s3_bucket.versioning_status) + + def get_bucket_encryption( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketEncryptionOutput: + # AWS now encrypts bucket by default with AES256, see: + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-bucket-encryption.html + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.encryption_rule: + return GetBucketEncryptionOutput() + + return GetBucketEncryptionOutput( + ServerSideEncryptionConfiguration={"Rules": [s3_bucket.encryption_rule]} + ) + + def put_bucket_encryption( + self, + context: RequestContext, + bucket: BucketName, + server_side_encryption_configuration: ServerSideEncryptionConfiguration, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not (rules := server_side_encryption_configuration.get("Rules")): + raise MalformedXML() + + if len(rules) != 1 or not ( + encryption := rules[0].get("ApplyServerSideEncryptionByDefault") + ): + raise MalformedXML() + + if not (sse_algorithm := encryption.get("SSEAlgorithm")): + raise MalformedXML() + + if sse_algorithm not in SSE_ALGORITHMS: + raise MalformedXML() + + if sse_algorithm != ServerSideEncryption.aws_kms and "KMSMasterKeyID" in encryption: + raise InvalidArgument( + "a KMSMasterKeyID is not applicable if the default sse algorithm is not aws:kms or aws:kms:dsse", + ArgumentName="ApplyServerSideEncryptionByDefault", + ) + # elif master_kms_key := encryption.get("KMSMasterKeyID"): + # TODO: validate KMS key? not currently done in moto + # You can pass either the KeyId or the KeyArn. If cross-account, it has to be the ARN. + # It's always saved as the ARN in the bucket configuration. + # kms_key_arn = get_kms_key_arn(master_kms_key, s3_bucket.bucket_account_id) + # encryption["KMSMasterKeyID"] = master_kms_key + + s3_bucket.encryption_rule = rules[0] + + def delete_bucket_encryption( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_bucket.encryption_rule = None + + def put_bucket_notification_configuration( + self, + context: RequestContext, + bucket: BucketName, + notification_configuration: NotificationConfiguration, + expected_bucket_owner: AccountId = None, + skip_destination_validation: SkipValidation = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + self._verify_notification_configuration( + notification_configuration, skip_destination_validation, context, bucket + ) + s3_bucket.notification_configuration = notification_configuration + + def get_bucket_notification_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> NotificationConfiguration: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + return s3_bucket.notification_configuration or NotificationConfiguration() + + def put_bucket_tagging( + self, + context: RequestContext, + bucket: BucketName, + tagging: Tagging, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if "TagSet" not in tagging: + raise MalformedXML() + + validate_tag_set(tagging["TagSet"], type_set="bucket") + + # remove the previous tags before setting the new ones, it overwrites the whole TagSet + store.TAGS.tags.pop(s3_bucket.bucket_arn, None) + store.TAGS.tag_resource(s3_bucket.bucket_arn, tags=tagging["TagSet"]) + + def get_bucket_tagging( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketTaggingOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + tag_set = store.TAGS.list_tags_for_resource(s3_bucket.bucket_arn, root_name="Tags")["Tags"] + if not tag_set: + raise NoSuchTagSet( + "The TagSet does not exist", + BucketName=bucket, + ) + + return GetBucketTaggingOutput(TagSet=tag_set) + + def delete_bucket_tagging( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + store.TAGS.tags.pop(s3_bucket.bucket_arn, None) + + def put_object_tagging( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + tagging: Tagging, + version_id: ObjectVersionId = None, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + request_payer: RequestPayer = None, + **kwargs, + ) -> PutObjectTaggingOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_object = s3_bucket.get_object(key=key, version_id=version_id, http_method="PUT") + + if "TagSet" not in tagging: + raise MalformedXML() + + validate_tag_set(tagging["TagSet"], type_set="object") + + key_id = get_unique_key_id(bucket, key, s3_object.version_id) + # remove the previous tags before setting the new ones, it overwrites the whole TagSet + store.TAGS.tags.pop(key_id, None) + store.TAGS.tag_resource(key_id, tags=tagging["TagSet"]) + response = PutObjectTaggingOutput() + if s3_object.version_id: + response["VersionId"] = s3_object.version_id + + self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object) + + return response + + def get_object_tagging( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId = None, + expected_bucket_owner: AccountId = None, + request_payer: RequestPayer = None, + **kwargs, + ) -> GetObjectTaggingOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + try: + s3_object = s3_bucket.get_object(key=key, version_id=version_id) + except NoSuchKey as e: + # it seems GetObjectTagging does not work like all other operations, so we need to raise a different + # exception. As we already need to catch it because of the format of the Key, it is not worth to modify the + # `S3Bucket.get_object` signature for one operation. + if s3_bucket.versioning_status and ( + s3_object_version := s3_bucket.objects.get(key, version_id) + ): + raise MethodNotAllowed( + "The specified method is not allowed against this resource.", + Method="GET", + ResourceType="DeleteMarker", + DeleteMarker=True, + Allow="DELETE", + VersionId=s3_object_version.version_id, + ) + + # There a weird AWS validated bug in S3: the returned key contains the bucket name as well + # follow AWS on this one + e.Key = f"{bucket}/{key}" + raise e + + tag_set = store.TAGS.list_tags_for_resource( + get_unique_key_id(bucket, key, s3_object.version_id) + )["Tags"] + response = GetObjectTaggingOutput(TagSet=tag_set) + if s3_object.version_id: + response["VersionId"] = s3_object.version_id + + return response + + def delete_object_tagging( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> DeleteObjectTaggingOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_object = s3_bucket.get_object(key=key, version_id=version_id, http_method="DELETE") + + store.TAGS.tags.pop(get_unique_key_id(bucket, key, version_id), None) + response = DeleteObjectTaggingOutput() + if s3_object.version_id: + response["VersionId"] = s3_object.version_id + + self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object) + + return response + + def put_bucket_cors( + self, + context: RequestContext, + bucket: BucketName, + cors_configuration: CORSConfiguration, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + validate_cors_configuration(cors_configuration) + s3_bucket.cors_rules = cors_configuration + self._cors_handler.invalidate_cache() + + def get_bucket_cors( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketCorsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.cors_rules: + raise NoSuchCORSConfiguration( + "The CORS configuration does not exist", + BucketName=bucket, + ) + return GetBucketCorsOutput(CORSRules=s3_bucket.cors_rules["CORSRules"]) + + def delete_bucket_cors( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if s3_bucket.cors_rules: + self._cors_handler.invalidate_cache() + s3_bucket.cors_rules = None + + def get_bucket_lifecycle_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketLifecycleConfigurationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.lifecycle_rules: + raise NoSuchLifecycleConfiguration( + "The lifecycle configuration does not exist", + BucketName=bucket, + ) + + return GetBucketLifecycleConfigurationOutput( + Rules=s3_bucket.lifecycle_rules, + # TODO: remove for next major version, safe access to new attribute + TransitionDefaultMinimumObjectSize=getattr( + s3_bucket, + "transition_default_minimum_object_size", + TransitionDefaultMinimumObjectSize.all_storage_classes_128K, + ), + ) + + def put_bucket_lifecycle_configuration( + self, + context: RequestContext, + bucket: BucketName, + checksum_algorithm: ChecksumAlgorithm = None, + lifecycle_configuration: BucketLifecycleConfiguration = None, + expected_bucket_owner: AccountId = None, + transition_default_minimum_object_size: TransitionDefaultMinimumObjectSize = None, + **kwargs, + ) -> PutBucketLifecycleConfigurationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + transition_min_obj_size = ( + transition_default_minimum_object_size + or TransitionDefaultMinimumObjectSize.all_storage_classes_128K + ) + + if transition_min_obj_size not in ( + TransitionDefaultMinimumObjectSize.all_storage_classes_128K, + TransitionDefaultMinimumObjectSize.varies_by_storage_class, + ): + raise InvalidRequest( + f"Invalid TransitionDefaultMinimumObjectSize found: {transition_min_obj_size}" + ) + + validate_lifecycle_configuration(lifecycle_configuration) + # TODO: we either apply the lifecycle to existing objects when we set the new rules, or we need to apply them + # everytime we get/head an object + # for now, we keep a cache and get it everytime we fetch an object + s3_bucket.lifecycle_rules = lifecycle_configuration["Rules"] + s3_bucket.transition_default_minimum_object_size = transition_min_obj_size + self._expiration_cache[bucket].clear() + return PutBucketLifecycleConfigurationOutput( + TransitionDefaultMinimumObjectSize=transition_min_obj_size + ) + + def delete_bucket_lifecycle( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_bucket.lifecycle_rules = None + self._expiration_cache[bucket].clear() + + def put_bucket_analytics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: AnalyticsId, + analytics_configuration: AnalyticsConfiguration, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + validate_bucket_analytics_configuration( + id=id, analytics_configuration=analytics_configuration + ) + + s3_bucket.analytics_configurations[id] = analytics_configuration + + def get_bucket_analytics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: AnalyticsId, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketAnalyticsConfigurationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not (analytic_config := s3_bucket.analytics_configurations.get(id)): + raise NoSuchConfiguration("The specified configuration does not exist.") + + return GetBucketAnalyticsConfigurationOutput(AnalyticsConfiguration=analytic_config) + + def list_bucket_analytics_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> ListBucketAnalyticsConfigurationsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + return ListBucketAnalyticsConfigurationsOutput( + IsTruncated=False, + AnalyticsConfigurationList=sorted( + s3_bucket.analytics_configurations.values(), + key=itemgetter("Id"), + ), + ) + + def delete_bucket_analytics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: AnalyticsId, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.analytics_configurations.pop(id, None): + raise NoSuchConfiguration("The specified configuration does not exist.") + + def put_bucket_intelligent_tiering_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: IntelligentTieringId, + intelligent_tiering_configuration: IntelligentTieringConfiguration, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + # TODO add support for expected_bucket_owner + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + validate_bucket_intelligent_tiering_configuration(id, intelligent_tiering_configuration) + + s3_bucket.intelligent_tiering_configurations[id] = intelligent_tiering_configuration + + def get_bucket_intelligent_tiering_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: IntelligentTieringId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> GetBucketIntelligentTieringConfigurationOutput: + # TODO add support for expected_bucket_owner + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not (itier_config := s3_bucket.intelligent_tiering_configurations.get(id)): + raise NoSuchConfiguration("The specified configuration does not exist.") + + return GetBucketIntelligentTieringConfigurationOutput( + IntelligentTieringConfiguration=itier_config + ) + + def delete_bucket_intelligent_tiering_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: IntelligentTieringId, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> None: + # TODO add support for expected_bucket_owner + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.intelligent_tiering_configurations.pop(id, None): + raise NoSuchConfiguration("The specified configuration does not exist.") + + def list_bucket_intelligent_tiering_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token | None = None, + expected_bucket_owner: AccountId | None = None, + **kwargs, + ) -> ListBucketIntelligentTieringConfigurationsOutput: + # TODO add support for expected_bucket_owner + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + return ListBucketIntelligentTieringConfigurationsOutput( + IsTruncated=False, + IntelligentTieringConfigurationList=sorted( + s3_bucket.intelligent_tiering_configurations.values(), + key=itemgetter("Id"), + ), + ) + + def put_bucket_inventory_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: InventoryId, + inventory_configuration: InventoryConfiguration, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + validate_inventory_configuration( + config_id=id, inventory_configuration=inventory_configuration + ) + s3_bucket.inventory_configurations[id] = inventory_configuration + + def get_bucket_inventory_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: InventoryId, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketInventoryConfigurationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not (inv_config := s3_bucket.inventory_configurations.get(id)): + raise NoSuchConfiguration("The specified configuration does not exist.") + return GetBucketInventoryConfigurationOutput(InventoryConfiguration=inv_config) + + def list_bucket_inventory_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> ListBucketInventoryConfigurationsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + return ListBucketInventoryConfigurationsOutput( + IsTruncated=False, + InventoryConfigurationList=sorted( + s3_bucket.inventory_configurations.values(), key=itemgetter("Id") + ), + ) + + def delete_bucket_inventory_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: InventoryId, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.inventory_configurations.pop(id, None): + raise NoSuchConfiguration("The specified configuration does not exist.") + + def get_bucket_website( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketWebsiteOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.website_configuration: + raise NoSuchWebsiteConfiguration( + "The specified bucket does not have a website configuration", + BucketName=bucket, + ) + return s3_bucket.website_configuration + + def put_bucket_website( + self, + context: RequestContext, + bucket: BucketName, + website_configuration: WebsiteConfiguration, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + validate_website_configuration(website_configuration) + s3_bucket.website_configuration = website_configuration + + def delete_bucket_website( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + # does not raise error if the bucket did not have a config, will simply return + s3_bucket.website_configuration = None + + def get_object_lock_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetObjectLockConfigurationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + if not s3_bucket.object_lock_enabled: + raise ObjectLockConfigurationNotFoundError( + "Object Lock configuration does not exist for this bucket", + BucketName=bucket, + ) + + response = GetObjectLockConfigurationOutput( + ObjectLockConfiguration=ObjectLockConfiguration( + ObjectLockEnabled=ObjectLockEnabled.Enabled + ) + ) + if s3_bucket.object_lock_default_retention: + response["ObjectLockConfiguration"]["Rule"] = { + "DefaultRetention": s3_bucket.object_lock_default_retention + } + + return response + + def put_object_lock_configuration( + self, + context: RequestContext, + bucket: BucketName, + object_lock_configuration: ObjectLockConfiguration = None, + request_payer: RequestPayer = None, + token: ObjectLockToken = None, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> PutObjectLockConfigurationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + if s3_bucket.versioning_status != "Enabled": + raise InvalidBucketState( + "Versioning must be 'Enabled' on the bucket to apply a Object Lock configuration" + ) + + if ( + not object_lock_configuration + or object_lock_configuration.get("ObjectLockEnabled") != "Enabled" + ): + raise MalformedXML() + + if "Rule" not in object_lock_configuration: + s3_bucket.object_lock_default_retention = None + if not s3_bucket.object_lock_enabled: + s3_bucket.object_lock_enabled = True + + return PutObjectLockConfigurationOutput() + elif not (rule := object_lock_configuration["Rule"]) or not ( + default_retention := rule.get("DefaultRetention") + ): + raise MalformedXML() + + if "Mode" not in default_retention or ( + ("Days" in default_retention and "Years" in default_retention) + or ("Days" not in default_retention and "Years" not in default_retention) + ): + raise MalformedXML() + + if default_retention["Mode"] not in OBJECT_LOCK_MODES: + raise MalformedXML() + + s3_bucket.object_lock_default_retention = default_retention + if not s3_bucket.object_lock_enabled: + s3_bucket.object_lock_enabled = True + + return PutObjectLockConfigurationOutput() + + def get_object_legal_hold( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId = None, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetObjectLegalHoldOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + if not s3_bucket.object_lock_enabled: + raise InvalidRequest("Bucket is missing Object Lock Configuration") + + s3_object = s3_bucket.get_object( + key=key, + version_id=version_id, + http_method="GET", + ) + if not s3_object.lock_legal_status: + raise NoSuchObjectLockConfiguration( + "The specified object does not have a ObjectLock configuration" + ) + + return GetObjectLegalHoldOutput( + LegalHold=ObjectLockLegalHold(Status=s3_object.lock_legal_status) + ) + + def put_object_legal_hold( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + legal_hold: ObjectLockLegalHold = None, + request_payer: RequestPayer = None, + version_id: ObjectVersionId = None, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> PutObjectLegalHoldOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not legal_hold: + raise MalformedXML() + + if not s3_bucket.object_lock_enabled: + raise InvalidRequest("Bucket is missing Object Lock Configuration") + + s3_object = s3_bucket.get_object( + key=key, + version_id=version_id, + http_method="PUT", + ) + # TODO: check casing + if not (status := legal_hold.get("Status")) or status not in ("ON", "OFF"): + raise MalformedXML() + + s3_object.lock_legal_status = status + + # TODO: return RequestCharged + return PutObjectRetentionOutput() + + def get_object_retention( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId = None, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetObjectRetentionOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + if not s3_bucket.object_lock_enabled: + raise InvalidRequest("Bucket is missing Object Lock Configuration") + + s3_object = s3_bucket.get_object( + key=key, + version_id=version_id, + http_method="GET", + ) + if not s3_object.lock_mode: + raise NoSuchObjectLockConfiguration( + "The specified object does not have a ObjectLock configuration" + ) + + return GetObjectRetentionOutput( + Retention=ObjectLockRetention( + Mode=s3_object.lock_mode, + RetainUntilDate=s3_object.lock_until, + ) + ) + + def put_object_retention( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + retention: ObjectLockRetention = None, + request_payer: RequestPayer = None, + version_id: ObjectVersionId = None, + bypass_governance_retention: BypassGovernanceRetention = None, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> PutObjectRetentionOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + if not s3_bucket.object_lock_enabled: + raise InvalidRequest("Bucket is missing Object Lock Configuration") + + s3_object = s3_bucket.get_object( + key=key, + version_id=version_id, + http_method="PUT", + ) + + if retention and ( + not validate_dict_fields(retention, required_fields={"Mode", "RetainUntilDate"}) + or retention["Mode"] not in OBJECT_LOCK_MODES + ): + raise MalformedXML() + + if retention and retention["RetainUntilDate"] < datetime.datetime.now(datetime.UTC): + # weirdly, this date is format as following: Tue Dec 31 16:00:00 PST 2019 + # it contains the timezone as PST, even if you target a bucket in Europe or Asia + pst_datetime = retention["RetainUntilDate"].astimezone(tz=ZoneInfo("US/Pacific")) + raise InvalidArgument( + "The retain until date must be in the future!", + ArgumentName="RetainUntilDate", + ArgumentValue=pst_datetime.strftime("%a %b %d %H:%M:%S %Z %Y"), + ) + + is_request_reducing_locking = ( + not retention + or (s3_object.lock_until and s3_object.lock_until > retention["RetainUntilDate"]) + or ( + retention["Mode"] == ObjectLockMode.GOVERNANCE + and s3_object.lock_mode == ObjectLockMode.COMPLIANCE + ) + ) + if is_request_reducing_locking and ( + s3_object.lock_mode == ObjectLockMode.COMPLIANCE + or ( + s3_object.lock_mode == ObjectLockMode.GOVERNANCE and not bypass_governance_retention + ) + ): + raise AccessDenied("Access Denied because object protected by object lock.") + + s3_object.lock_mode = retention["Mode"] if retention else None + s3_object.lock_until = retention["RetainUntilDate"] if retention else None + + # TODO: return RequestCharged + return PutObjectRetentionOutput() + + def put_bucket_request_payment( + self, + context: RequestContext, + bucket: BucketName, + request_payment_configuration: RequestPaymentConfiguration, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + # TODO: this currently only mock the operation, but its actual effect is not emulated + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + payer = request_payment_configuration.get("Payer") + if payer not in ["Requester", "BucketOwner"]: + raise MalformedXML() + + s3_bucket.payer = payer + + def get_bucket_request_payment( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketRequestPaymentOutput: + # TODO: this currently only mock the operation, but its actual effect is not emulated + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + return GetBucketRequestPaymentOutput(Payer=s3_bucket.payer) + + def get_bucket_ownership_controls( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketOwnershipControlsOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.object_ownership: + raise OwnershipControlsNotFoundError( + "The bucket ownership controls were not found", + BucketName=bucket, + ) + + return GetBucketOwnershipControlsOutput( + OwnershipControls={"Rules": [{"ObjectOwnership": s3_bucket.object_ownership}]} + ) + + def put_bucket_ownership_controls( + self, + context: RequestContext, + bucket: BucketName, + ownership_controls: OwnershipControls, + content_md5: ContentMD5 | None = None, + expected_bucket_owner: AccountId | None = None, + checksum_algorithm: ChecksumAlgorithm | None = None, + **kwargs, + ) -> None: + # TODO: this currently only mock the operation, but its actual effect is not emulated + # it for example almost forbid ACL usage when set to BucketOwnerEnforced + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not (rules := ownership_controls.get("Rules")) or len(rules) > 1: + raise MalformedXML() + + rule = rules[0] + if (object_ownership := rule.get("ObjectOwnership")) not in OBJECT_OWNERSHIPS: + raise MalformedXML() + + s3_bucket.object_ownership = object_ownership + + def delete_bucket_ownership_controls( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_bucket.object_ownership = None + + def get_public_access_block( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetPublicAccessBlockOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.public_access_block: + raise NoSuchPublicAccessBlockConfiguration( + "The public access block configuration was not found", BucketName=bucket + ) + + return GetPublicAccessBlockOutput( + PublicAccessBlockConfiguration=s3_bucket.public_access_block + ) + + def put_public_access_block( + self, + context: RequestContext, + bucket: BucketName, + public_access_block_configuration: PublicAccessBlockConfiguration, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + # TODO: this currently only mock the operation, but its actual effect is not emulated + # as we do not enforce ACL directly. Also, this should take the most restrictive between S3Control and the + # bucket configuration. See s3control + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + public_access_block_fields = { + "BlockPublicAcls", + "BlockPublicPolicy", + "IgnorePublicAcls", + "RestrictPublicBuckets", + } + if not validate_dict_fields( + public_access_block_configuration, + required_fields=set(), + optional_fields=public_access_block_fields, + ): + raise MalformedXML() + + for field in public_access_block_fields: + if public_access_block_configuration.get(field) is None: + public_access_block_configuration[field] = False + + s3_bucket.public_access_block = public_access_block_configuration + + def delete_public_access_block( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_bucket.public_access_block = None + + def get_bucket_policy( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketPolicyOutput: + store, s3_bucket = self._get_cross_account_bucket( + context, bucket, expected_bucket_owner=expected_bucket_owner + ) + if not s3_bucket.policy: + raise NoSuchBucketPolicy( + "The bucket policy does not exist", + BucketName=bucket, + ) + return GetBucketPolicyOutput(Policy=s3_bucket.policy) + + def put_bucket_policy( + self, + context: RequestContext, + bucket: BucketName, + policy: Policy, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + confirm_remove_self_bucket_access: ConfirmRemoveSelfBucketAccess = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket( + context, bucket, expected_bucket_owner=expected_bucket_owner + ) + + if not policy or policy[0] != "{": + raise MalformedPolicy("Policies must be valid JSON and the first byte must be '{'") + try: + json_policy = json.loads(policy) + if not json_policy: + # TODO: add more validation around the policy? + raise MalformedPolicy("Missing required field Statement") + except ValueError: + raise MalformedPolicy("Policies must be valid JSON and the first byte must be '{'") + + s3_bucket.policy = policy + + def delete_bucket_policy( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket( + context, bucket, expected_bucket_owner=expected_bucket_owner + ) + + s3_bucket.policy = None + + def get_bucket_accelerate_configuration( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + request_payer: RequestPayer = None, + **kwargs, + ) -> GetBucketAccelerateConfigurationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + response = GetBucketAccelerateConfigurationOutput() + if s3_bucket.accelerate_status: + response["Status"] = s3_bucket.accelerate_status + + return response + + def put_bucket_accelerate_configuration( + self, + context: RequestContext, + bucket: BucketName, + accelerate_configuration: AccelerateConfiguration, + expected_bucket_owner: AccountId = None, + checksum_algorithm: ChecksumAlgorithm = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if "." in bucket: + raise InvalidRequest( + "S3 Transfer Acceleration is not supported for buckets with periods (.) in their names" + ) + + if not (status := accelerate_configuration.get("Status")) or status not in ( + "Enabled", + "Suspended", + ): + raise MalformedXML() + + s3_bucket.accelerate_status = status + + def put_bucket_logging( + self, + context: RequestContext, + bucket: BucketName, + bucket_logging_status: BucketLoggingStatus, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not (logging_config := bucket_logging_status.get("LoggingEnabled")): + s3_bucket.logging = {} + return + + # the target bucket must be in the same account + if not (target_bucket_name := logging_config.get("TargetBucket")): + raise MalformedXML() + + if not logging_config.get("TargetPrefix"): + logging_config["TargetPrefix"] = "" + + # TODO: validate Grants + + if not (target_s3_bucket := store.buckets.get(target_bucket_name)): + raise InvalidTargetBucketForLogging( + "The target bucket for logging does not exist", + TargetBucket=target_bucket_name, + ) + + source_bucket_region = s3_bucket.bucket_region + if target_s3_bucket.bucket_region != source_bucket_region: + raise ( + CrossLocationLoggingProhibitted( + "Cross S3 location logging not allowed. ", + TargetBucketLocation=target_s3_bucket.bucket_region, + ) + if source_bucket_region == AWS_REGION_US_EAST_1 + else CrossLocationLoggingProhibitted( + "Cross S3 location logging not allowed. ", + SourceBucketLocation=source_bucket_region, + TargetBucketLocation=target_s3_bucket.bucket_region, + ) + ) + + s3_bucket.logging = logging_config + + def get_bucket_logging( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketLoggingOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.logging: + return GetBucketLoggingOutput() + + return GetBucketLoggingOutput(LoggingEnabled=s3_bucket.logging) + + def put_bucket_replication( + self, + context: RequestContext, + bucket: BucketName, + replication_configuration: ReplicationConfiguration, + content_md5: ContentMD5 = None, + checksum_algorithm: ChecksumAlgorithm = None, + token: ObjectLockToken = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + if not s3_bucket.versioning_status == BucketVersioningStatus.Enabled: + raise InvalidRequest( + "Versioning must be 'Enabled' on the bucket to apply a replication configuration" + ) + + if not (rules := replication_configuration.get("Rules")): + raise MalformedXML() + + for rule in rules: + if "ID" not in rule: + rule["ID"] = short_uid() + + dest_bucket_arn = rule.get("Destination", {}).get("Bucket") + dest_bucket_name = s3_bucket_name(dest_bucket_arn) + if ( + not (dest_s3_bucket := store.buckets.get(dest_bucket_name)) + or not dest_s3_bucket.versioning_status == BucketVersioningStatus.Enabled + ): + # according to AWS testing the same exception is raised if the bucket does not exist + # or if versioning was disabled + raise InvalidRequest("Destination bucket must have versioning enabled.") + + # TODO more validation on input + s3_bucket.replication = replication_configuration + + def get_bucket_replication( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketReplicationOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + if not s3_bucket.replication: + raise ReplicationConfigurationNotFoundError( + "The replication configuration was not found", + BucketName=bucket, + ) + + return GetBucketReplicationOutput(ReplicationConfiguration=s3_bucket.replication) + + def delete_bucket_replication( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_bucket.replication = None + + @handler("PutBucketAcl", expand=False) + def put_bucket_acl( + self, + context: RequestContext, + request: PutBucketAclRequest, + ) -> None: + bucket = request["Bucket"] + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + acp = get_access_control_policy_from_acl_request( + request=request, owner=s3_bucket.owner, request_body=context.request.data + ) + s3_bucket.acl = acp + + def get_bucket_acl( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketAclOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + return GetBucketAclOutput(Owner=s3_bucket.acl["Owner"], Grants=s3_bucket.acl["Grants"]) + + @handler("PutObjectAcl", expand=False) + def put_object_acl( + self, + context: RequestContext, + request: PutObjectAclRequest, + ) -> PutObjectAclOutput: + bucket = request["Bucket"] + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_object = s3_bucket.get_object( + key=request["Key"], + version_id=request.get("VersionId"), + http_method="PUT", + ) + acp = get_access_control_policy_from_acl_request( + request=request, owner=s3_object.owner, request_body=context.request.data + ) + previous_acl = s3_object.acl + s3_object.acl = acp + + if previous_acl != acp: + self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object) + + # TODO: RequestCharged + return PutObjectAclOutput() + + def get_object_acl( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + version_id: ObjectVersionId = None, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetObjectAclOutput: + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + s3_object = s3_bucket.get_object( + key=key, + version_id=version_id, + ) + # TODO: RequestCharged + return GetObjectAclOutput(Owner=s3_object.acl["Owner"], Grants=s3_object.acl["Grants"]) + + def get_bucket_policy_status( + self, + context: RequestContext, + bucket: BucketName, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketPolicyStatusOutput: + raise NotImplementedError + + def get_object_torrent( + self, + context: RequestContext, + bucket: BucketName, + key: ObjectKey, + request_payer: RequestPayer = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetObjectTorrentOutput: + raise NotImplementedError + + def post_object( + self, context: RequestContext, bucket: BucketName, body: IO[Body] = None, **kwargs + ) -> PostResponse: + if "multipart/form-data" not in context.request.headers.get("Content-Type", ""): + raise PreconditionFailed( + "At least one of the pre-conditions you specified did not hold", + Condition="Bucket POST must be of the enclosure-type multipart/form-data", + ) + # see https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html + # TODO: signature validation is not implemented for pre-signed POST + # policy validation is not implemented either, except expiration and mandatory fields + # This operation is the only one using form for storing the request data. We will have to do some manual + # parsing here, as no specs are present for this, as no client directly implements this operation. + store, s3_bucket = self._get_cross_account_bucket(context, bucket) + + form = context.request.form + object_key = context.request.form.get("key") + + if "file" in form: + # in AWS, you can pass the file content as a string in the form field and not as a file object + file_data = to_bytes(form["file"]) + object_content_length = len(file_data) + stream = BytesIO(file_data) + else: + # this is the default behaviour + fileobj = context.request.files["file"] + stream = fileobj.stream + # stream is a SpooledTemporaryFile, so we can seek the stream to know its length, necessary for policy + # validation + original_pos = stream.tell() + object_content_length = stream.seek(0, 2) + # reset the stream and put it back at its original position + stream.seek(original_pos, 0) + + if "${filename}" in object_key: + # TODO: ${filename} is actually usable in all form fields + # See https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/PresignedPost.html + # > The string ${filename} is automatically replaced with the name of the file provided by the user and + # is recognized by all form fields. + object_key = object_key.replace("${filename}", fileobj.filename) + + # TODO: see if we need to pass additional metadata not contained in the policy from the table under + # https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html#sigv4-PolicyConditions + additional_policy_metadata = { + "bucket": bucket, + "content_length": object_content_length, + } + validate_post_policy(form, additional_policy_metadata) + + if canned_acl := form.get("acl"): + validate_canned_acl(canned_acl) + acp = get_canned_acl(canned_acl, owner=s3_bucket.owner) + else: + acp = get_canned_acl(BucketCannedACL.private, owner=s3_bucket.owner) + + post_system_settable_headers = [ + "Cache-Control", + "Content-Type", + "Content-Disposition", + "Content-Encoding", + ] + system_metadata = {} + for system_metadata_field in post_system_settable_headers: + if field_value := form.get(system_metadata_field): + system_metadata[system_metadata_field.replace("-", "")] = field_value + + if not system_metadata.get("ContentType"): + system_metadata["ContentType"] = "binary/octet-stream" + + user_metadata = { + field.removeprefix("x-amz-meta-").lower(): form.get(field) + for field in form + if field.startswith("x-amz-meta-") + } + + if tagging := form.get("tagging"): + # this is weird, as it's direct XML in the form, we need to parse it directly + tagging = parse_post_object_tagging_xml(tagging) + + if (storage_class := form.get("x-amz-storage-class")) is not None and ( + storage_class not in STORAGE_CLASSES or storage_class == StorageClass.OUTPOSTS + ): + raise InvalidStorageClass( + "The storage class you specified is not valid", StorageClassRequested=storage_class + ) + + encryption_request = { + "ServerSideEncryption": form.get("x-amz-server-side-encryption"), + "SSEKMSKeyId": form.get("x-amz-server-side-encryption-aws-kms-key-id"), + "BucketKeyEnabled": form.get("x-amz-server-side-encryption-bucket-key-enabled"), + } + + encryption_parameters = get_encryption_parameters_from_request_and_bucket( + encryption_request, + s3_bucket, + store, + ) + + checksum_algorithm = form.get("x-amz-checksum-algorithm") + checksum_value = ( + form.get(f"x-amz-checksum-{checksum_algorithm.lower()}") if checksum_algorithm else None + ) + expires = ( + str_to_rfc_1123_datetime(expires_str) if (expires_str := form.get("Expires")) else None + ) + + version_id = generate_version_id(s3_bucket.versioning_status) + + s3_object = S3Object( + key=object_key, + version_id=version_id, + storage_class=storage_class, + expires=expires, + user_metadata=user_metadata, + system_metadata=system_metadata, + checksum_algorithm=checksum_algorithm, + checksum_value=checksum_value, + encryption=encryption_parameters.encryption, + kms_key_id=encryption_parameters.kms_key_id, + bucket_key_enabled=encryption_parameters.bucket_key_enabled, + website_redirect_location=form.get("x-amz-website-redirect-location"), + acl=acp, + owner=s3_bucket.owner, # TODO: for now we only have one owner, but it can depends on Bucket settings + ) + + with self._storage_backend.open(bucket, s3_object, mode="w") as s3_stored_object: + s3_stored_object.write(stream) + + if not s3_object.checksum_value: + s3_object.checksum_value = s3_stored_object.checksum + + elif checksum_algorithm and s3_object.checksum_value != s3_stored_object.checksum: + self._storage_backend.remove(bucket, s3_object) + raise InvalidRequest( + f"Value for x-amz-checksum-{checksum_algorithm.lower()} header is invalid." + ) + + s3_bucket.objects.set(object_key, s3_object) + + # in case we are overriding an object, delete the tags entry + key_id = get_unique_key_id(bucket, object_key, version_id) + store.TAGS.tags.pop(key_id, None) + if tagging: + store.TAGS.tags[key_id] = tagging + + response = PostResponse() + # hacky way to set the etag in the headers as well: two locations for one value + response["ETagHeader"] = s3_object.quoted_etag + + if redirect := form.get("success_action_redirect"): + # we need to create the redirect, as the parser could not return the moto-calculated one + try: + redirect = create_redirect_for_post_request( + base_redirect=redirect, + bucket=bucket, + object_key=object_key, + etag=s3_object.quoted_etag, + ) + response["LocationHeader"] = redirect + response["StatusCode"] = 303 + except ValueError: + # If S3 cannot interpret the URL, it acts as if the field is not present. + response["StatusCode"] = form.get("success_action_status", 204) + + elif status_code := form.get("success_action_status"): + response["StatusCode"] = status_code + else: + response["StatusCode"] = 204 + + response["LocationHeader"] = response.get( + "LocationHeader", f"{get_full_default_bucket_location(bucket)}{object_key}" + ) + + if s3_bucket.versioning_status == "Enabled": + response["VersionId"] = s3_object.version_id + + if s3_object.checksum_algorithm: + response[f"Checksum{s3_object.checksum_algorithm.upper()}"] = s3_object.checksum_value + response["ChecksumType"] = ChecksumType.FULL_OBJECT + + if s3_bucket.lifecycle_rules: + if expiration_header := self._get_expiration_header( + s3_bucket.lifecycle_rules, + bucket, + s3_object, + store.TAGS.tags.get(key_id, {}), + ): + # TODO: we either apply the lifecycle to existing objects when we set the new rules, or we need to + # apply them everytime we get/head an object + response["Expiration"] = expiration_header + + add_encryption_to_response(response, s3_object=s3_object) + + self._notify(context, s3_bucket=s3_bucket, s3_object=s3_object) + + if response["StatusCode"] == "201": + # if the StatusCode is 201, S3 returns an XML body with additional information + response["ETag"] = s3_object.quoted_etag + response["Bucket"] = bucket + response["Key"] = object_key + response["Location"] = response["LocationHeader"] + + return response + + def put_bucket_metrics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: MetricsId, + metrics_configuration: MetricsConfiguration, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + """ + Update or add a new metrics configuration. If the provided `id` already exists, its associated configuration + will be overwritten. The total number of metric configurations is limited to 1000. If this limit is exceeded, + an error is raised unless the `is` already exists. + + :param context: The request context. + :param bucket: The name of the bucket associated with the metrics configuration. + :param id: Identifies the metrics configuration being added or updated. + :param metrics_configuration: A new or updated configuration associated with the given metrics identifier. + :param expected_bucket_owner: The expected account ID of the bucket owner. + :return: None + :raises TooManyConfigurations: If the total number of metrics configurations exceeds 1000 AND the provided + `metrics_id` does not already exist. + """ + store, s3_bucket = self._get_cross_account_bucket( + context, bucket, expected_bucket_owner=expected_bucket_owner + ) + + if ( + len(s3_bucket.metric_configurations) >= 1000 + and id not in s3_bucket.metric_configurations + ): + raise TooManyConfigurations("Too many metrics configurations") + s3_bucket.metric_configurations[id] = metrics_configuration + + def get_bucket_metrics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: MetricsId, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> GetBucketMetricsConfigurationOutput: + """ + Retrieve the metrics configuration associated with a given metrics identifier. + + :param context: The request context. + :param bucket: The name of the bucket associated with the metrics configuration. + :param id: The unique identifier of the metrics configuration to retrieve. + :param expected_bucket_owner: The expected account ID of the bucket owner. + :return: The metrics configuration associated with the given metrics identifier. + :raises NoSuchConfiguration: If the provided metrics configuration does not exist. + """ + store, s3_bucket = self._get_cross_account_bucket( + context, bucket, expected_bucket_owner=expected_bucket_owner + ) + + metric_config = s3_bucket.metric_configurations.get(id) + if not metric_config: + raise NoSuchConfiguration("The specified configuration does not exist.") + return GetBucketMetricsConfigurationOutput(MetricsConfiguration=metric_config) + + def list_bucket_metrics_configurations( + self, + context: RequestContext, + bucket: BucketName, + continuation_token: Token = None, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> ListBucketMetricsConfigurationsOutput: + """ + Lists the metric configurations available, allowing for pagination using a continuation token to retrieve more + results. + + :param context: The request context. + :param bucket: The name of the bucket associated with the metrics configuration. + :param continuation_token: An optional continuation token to retrieve the next set of results in case there are + more results than the default limit. Provided as a base64-encoded string value. + :param expected_bucket_owner: The expected account ID of the bucket owner. + :return: A list of metric configurations and an optional continuation token for fetching subsequent data, if + applicable. + """ + store, s3_bucket = self._get_cross_account_bucket( + context, bucket, expected_bucket_owner=expected_bucket_owner + ) + + metrics_configurations: list[MetricsConfiguration] = [] + next_continuation_token = None + + decoded_continuation_token = ( + to_str(base64.urlsafe_b64decode(continuation_token.encode())) + if continuation_token + else None + ) + + for metric in sorted(s3_bucket.metric_configurations.values(), key=lambda r: r["Id"]): + if continuation_token and metric["Id"] < decoded_continuation_token: + continue + + if len(metrics_configurations) >= 100: + next_continuation_token = to_str(base64.urlsafe_b64encode(metric["Id"].encode())) + break + + metrics_configurations.append(metric) + + return ListBucketMetricsConfigurationsOutput( + IsTruncated=next_continuation_token is not None, + ContinuationToken=continuation_token, + NextContinuationToken=next_continuation_token, + MetricsConfigurationList=metrics_configurations, + ) + + def delete_bucket_metrics_configuration( + self, + context: RequestContext, + bucket: BucketName, + id: MetricsId, + expected_bucket_owner: AccountId = None, + **kwargs, + ) -> None: + """ + Removes a specific metrics configuration identified by its metrics ID. + + :param context: The request context. + :param bucket: The name of the bucket associated with the metrics configuration. + :param id: The unique identifier of the metrics configuration to delete. + :param expected_bucket_owner: The expected account ID of the bucket owner. + :return: None + :raises NoSuchConfiguration: If the provided metrics configuration does not exist. + """ + store, s3_bucket = self._get_cross_account_bucket( + context, bucket, expected_bucket_owner=expected_bucket_owner + ) + + deleted_config = s3_bucket.metric_configurations.pop(id, None) + if not deleted_config: + raise NoSuchConfiguration("The specified configuration does not exist.") + + +def generate_version_id(bucket_versioning_status: str) -> str | None: + if not bucket_versioning_status: + return None + elif bucket_versioning_status.lower() == "enabled": + return generate_safe_version_id() + else: + return "null" + + +def add_encryption_to_response(response: dict, s3_object: S3Object): + if encryption := s3_object.encryption: + response["ServerSideEncryption"] = encryption + if encryption == ServerSideEncryption.aws_kms: + response["SSEKMSKeyId"] = s3_object.kms_key_id + if s3_object.bucket_key_enabled: + response["BucketKeyEnabled"] = s3_object.bucket_key_enabled + + +def get_encryption_parameters_from_request_and_bucket( + request: PutObjectRequest | CopyObjectRequest | CreateMultipartUploadRequest, + s3_bucket: S3Bucket, + store: S3Store, +) -> EncryptionParameters: + if request.get("SSECustomerKey"): + # we return early, because ServerSideEncryption does not apply if the request has SSE-C + return EncryptionParameters(None, None, False) + + encryption = request.get("ServerSideEncryption") + kms_key_id = request.get("SSEKMSKeyId") + bucket_key_enabled = request.get("BucketKeyEnabled") + if s3_bucket.encryption_rule: + bucket_key_enabled = bucket_key_enabled or s3_bucket.encryption_rule.get("BucketKeyEnabled") + encryption = ( + encryption + or s3_bucket.encryption_rule["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"] + ) + if encryption == ServerSideEncryption.aws_kms: + key_id = kms_key_id or s3_bucket.encryption_rule[ + "ApplyServerSideEncryptionByDefault" + ].get("KMSMasterKeyID") + kms_key_id = get_kms_key_arn( + key_id, s3_bucket.bucket_account_id, s3_bucket.bucket_region + ) + if not kms_key_id: + # if not key is provided, AWS will use an AWS managed KMS key + # create it if it doesn't already exist, and save it in the store per region + if not store.aws_managed_kms_key_id: + managed_kms_key_id = create_s3_kms_managed_key_for_region( + s3_bucket.bucket_account_id, s3_bucket.bucket_region + ) + store.aws_managed_kms_key_id = managed_kms_key_id + + kms_key_id = store.aws_managed_kms_key_id + + return EncryptionParameters(encryption, kms_key_id, bucket_key_enabled) + + +def get_object_lock_parameters_from_bucket_and_request( + request: PutObjectRequest | CopyObjectRequest | CreateMultipartUploadRequest, + s3_bucket: S3Bucket, +): + lock_mode = request.get("ObjectLockMode") + lock_legal_status = request.get("ObjectLockLegalHoldStatus") + lock_until = request.get("ObjectLockRetainUntilDate") + + if lock_mode and not lock_until: + raise InvalidArgument( + "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied", + ArgumentName="x-amz-object-lock-retain-until-date", + ) + elif not lock_mode and lock_until: + raise InvalidArgument( + "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied", + ArgumentName="x-amz-object-lock-mode", + ) + + if lock_mode and lock_mode not in OBJECT_LOCK_MODES: + raise InvalidArgument( + "Unknown wormMode directive.", + ArgumentName="x-amz-object-lock-mode", + ArgumentValue=lock_mode, + ) + + if (default_retention := s3_bucket.object_lock_default_retention) and not lock_mode: + lock_mode = default_retention["Mode"] + lock_until = get_retention_from_now( + days=default_retention.get("Days"), + years=default_retention.get("Years"), + ) + + return ObjectLockParameters(lock_until, lock_legal_status, lock_mode) + + +def get_part_range(s3_object: S3Object, part_number: PartNumber) -> ObjectRange: + """ + Calculate the range value from a part Number for an S3 Object + :param s3_object: S3Object + :param part_number: the wanted part from the S3Object + :return: an ObjectRange used to return only a slice of an Object + """ + if not s3_object.parts: + if part_number > 1: + raise InvalidPartNumber( + "The requested partnumber is not satisfiable", + PartNumberRequested=part_number, + ActualPartCount=1, + ) + return ObjectRange( + begin=0, + end=s3_object.size - 1, + content_length=s3_object.size, + content_range=f"bytes 0-{s3_object.size - 1}/{s3_object.size}", + ) + elif not (part_data := s3_object.parts.get(part_number)): + raise InvalidPartNumber( + "The requested partnumber is not satisfiable", + PartNumberRequested=part_number, + ActualPartCount=len(s3_object.parts), + ) + + # TODO: remove for next major version 5.0, compatibility for <= 4.5 + if isinstance(part_data, tuple): + begin, part_length = part_data + else: + begin = part_data["_position"] + part_length = part_data["Size"] + + end = begin + part_length - 1 + return ObjectRange( + begin=begin, + end=end, + content_length=part_length, + content_range=f"bytes {begin}-{end}/{s3_object.size}", + ) + + +def get_acl_headers_from_request( + request: Union[ + PutObjectRequest, + CreateMultipartUploadRequest, + CopyObjectRequest, + CreateBucketRequest, + PutBucketAclRequest, + PutObjectAclRequest, + ], +) -> list[tuple[str, str]]: + permission_keys = [ + "GrantFullControl", + "GrantRead", + "GrantReadACP", + "GrantWrite", + "GrantWriteACP", + ] + acl_headers = [ + (permission, grant_header) + for permission in permission_keys + if (grant_header := request.get(permission)) + ] + return acl_headers + + +def get_access_control_policy_from_acl_request( + request: Union[PutBucketAclRequest, PutObjectAclRequest], + owner: Owner, + request_body: bytes, +) -> AccessControlPolicy: + canned_acl = request.get("ACL") + acl_headers = get_acl_headers_from_request(request) + + # FIXME: this is very dirty, but the parser does not differentiate between an empty body and an empty XML node + # errors are different depending on that data, so we need to access the context. Modifying the parser for this + # use case seems dangerous + is_acp_in_body = request_body + + if not (canned_acl or acl_headers or is_acp_in_body): + raise MissingSecurityHeader( + "Your request was missing a required header", MissingHeaderName="x-amz-acl" + ) + + elif canned_acl and acl_headers: + raise InvalidRequest("Specifying both Canned ACLs and Header Grants is not allowed") + + elif (canned_acl or acl_headers) and is_acp_in_body: + raise UnexpectedContent("This request does not support content") + + if canned_acl: + validate_canned_acl(canned_acl) + acp = get_canned_acl(canned_acl, owner=owner) + + elif acl_headers: + grants = [] + for permission, grantees_values in acl_headers: + permission = get_permission_from_header(permission) + partial_grants = parse_grants_in_headers(permission, grantees_values) + grants.extend(partial_grants) + + acp = AccessControlPolicy(Owner=owner, Grants=grants) + else: + acp = request.get("AccessControlPolicy") + validate_acl_acp(acp) + if ( + owner.get("DisplayName") + and acp["Grants"] + and "DisplayName" not in acp["Grants"][0]["Grantee"] + ): + acp["Grants"][0]["Grantee"]["DisplayName"] = owner["DisplayName"] + + return acp + + +def get_access_control_policy_for_new_resource_request( + request: Union[ + PutObjectRequest, CreateMultipartUploadRequest, CopyObjectRequest, CreateBucketRequest + ], + owner: Owner, +) -> AccessControlPolicy: + # TODO: this is basic ACL, not taking into account Bucket settings. Revisit once we really implement ACLs. + canned_acl = request.get("ACL") + acl_headers = get_acl_headers_from_request(request) + + if not (canned_acl or acl_headers): + return get_canned_acl(BucketCannedACL.private, owner=owner) + + elif canned_acl and acl_headers: + raise InvalidRequest("Specifying both Canned ACLs and Header Grants is not allowed") + + if canned_acl: + validate_canned_acl(canned_acl) + return get_canned_acl(canned_acl, owner=owner) + + grants = [] + for permission, grantees_values in acl_headers: + permission = get_permission_from_header(permission) + partial_grants = parse_grants_in_headers(permission, grantees_values) + grants.extend(partial_grants) + + return AccessControlPolicy(Owner=owner, Grants=grants) + + +def object_exists_for_precondition_write(s3_bucket: S3Bucket, key: ObjectKey) -> bool: + return (existing := s3_bucket.objects.get(key)) and not isinstance(existing, S3DeleteMarker) + + +def verify_object_equality_precondition_write( + s3_bucket: S3Bucket, + key: ObjectKey, + etag: str, + initiated: datetime.datetime | None = None, +) -> None: + existing = s3_bucket.objects.get(key) + if not existing or isinstance(existing, S3DeleteMarker): + raise NoSuchKey("The specified key does not exist.", Key=key) + + if not existing.etag == etag.strip('"'): + raise PreconditionFailed( + "At least one of the pre-conditions you specified did not hold", + Condition="If-Match", + ) + + if initiated and initiated < existing.last_modified: + raise ConditionalRequestConflict( + "The conditional request cannot succeed due to a conflicting operation against this resource.", + Condition="If-Match", + Key=key, + ) diff --git a/localstack-core/localstack/services/s3/resource_providers/__init__.py b/localstack-core/localstack/services/s3/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket.py b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket.py new file mode 100644 index 0000000000000..de1573274b2b8 --- /dev/null +++ b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket.py @@ -0,0 +1,733 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import re +from pathlib import Path +from typing import Optional, TypedDict + +from botocore.exceptions import ClientError + +import localstack.services.cloudformation.provider_utils as util +from localstack.config import S3_STATIC_WEBSITE_HOSTNAME, S3_VIRTUAL_HOSTNAME +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.services.s3.utils import normalize_bucket_name +from localstack.utils.aws import arns +from localstack.utils.testutil import delete_all_s3_objects +from localstack.utils.urls import localstack_host + + +class S3BucketProperties(TypedDict): + AccelerateConfiguration: Optional[AccelerateConfiguration] + AccessControl: Optional[str] + AnalyticsConfigurations: Optional[list[AnalyticsConfiguration]] + Arn: Optional[str] + BucketEncryption: Optional[BucketEncryption] + BucketName: Optional[str] + CorsConfiguration: Optional[CorsConfiguration] + DomainName: Optional[str] + DualStackDomainName: Optional[str] + IntelligentTieringConfigurations: Optional[list[IntelligentTieringConfiguration]] + InventoryConfigurations: Optional[list[InventoryConfiguration]] + LifecycleConfiguration: Optional[LifecycleConfiguration] + LoggingConfiguration: Optional[LoggingConfiguration] + MetricsConfigurations: Optional[list[MetricsConfiguration]] + NotificationConfiguration: Optional[NotificationConfiguration] + ObjectLockConfiguration: Optional[ObjectLockConfiguration] + ObjectLockEnabled: Optional[bool] + OwnershipControls: Optional[OwnershipControls] + PublicAccessBlockConfiguration: Optional[PublicAccessBlockConfiguration] + RegionalDomainName: Optional[str] + ReplicationConfiguration: Optional[ReplicationConfiguration] + Tags: Optional[list[Tag]] + VersioningConfiguration: Optional[VersioningConfiguration] + WebsiteConfiguration: Optional[WebsiteConfiguration] + WebsiteURL: Optional[str] + + +class AccelerateConfiguration(TypedDict): + AccelerationStatus: Optional[str] + + +class TagFilter(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class Destination(TypedDict): + BucketArn: Optional[str] + Format: Optional[str] + BucketAccountId: Optional[str] + Prefix: Optional[str] + + +class DataExport(TypedDict): + Destination: Optional[Destination] + OutputSchemaVersion: Optional[str] + + +class StorageClassAnalysis(TypedDict): + DataExport: Optional[DataExport] + + +class AnalyticsConfiguration(TypedDict): + Id: Optional[str] + StorageClassAnalysis: Optional[StorageClassAnalysis] + Prefix: Optional[str] + TagFilters: Optional[list[TagFilter]] + + +class ServerSideEncryptionByDefault(TypedDict): + SSEAlgorithm: Optional[str] + KMSMasterKeyID: Optional[str] + + +class ServerSideEncryptionRule(TypedDict): + BucketKeyEnabled: Optional[bool] + ServerSideEncryptionByDefault: Optional[ServerSideEncryptionByDefault] + + +class BucketEncryption(TypedDict): + ServerSideEncryptionConfiguration: Optional[list[ServerSideEncryptionRule]] + + +class CorsRule(TypedDict): + AllowedMethods: Optional[list[str]] + AllowedOrigins: Optional[list[str]] + AllowedHeaders: Optional[list[str]] + ExposedHeaders: Optional[list[str]] + Id: Optional[str] + MaxAge: Optional[int] + + +class CorsConfiguration(TypedDict): + CorsRules: Optional[list[CorsRule]] + + +class Tiering(TypedDict): + AccessTier: Optional[str] + Days: Optional[int] + + +class IntelligentTieringConfiguration(TypedDict): + Id: Optional[str] + Status: Optional[str] + Tierings: Optional[list[Tiering]] + Prefix: Optional[str] + TagFilters: Optional[list[TagFilter]] + + +class InventoryConfiguration(TypedDict): + Destination: Optional[Destination] + Enabled: Optional[bool] + Id: Optional[str] + IncludedObjectVersions: Optional[str] + ScheduleFrequency: Optional[str] + OptionalFields: Optional[list[str]] + Prefix: Optional[str] + + +class AbortIncompleteMultipartUpload(TypedDict): + DaysAfterInitiation: Optional[int] + + +class NoncurrentVersionExpiration(TypedDict): + NoncurrentDays: Optional[int] + NewerNoncurrentVersions: Optional[int] + + +class NoncurrentVersionTransition(TypedDict): + StorageClass: Optional[str] + TransitionInDays: Optional[int] + NewerNoncurrentVersions: Optional[int] + + +class Transition(TypedDict): + StorageClass: Optional[str] + TransitionDate: Optional[str] + TransitionInDays: Optional[int] + + +class Rule(TypedDict): + Status: Optional[str] + AbortIncompleteMultipartUpload: Optional[AbortIncompleteMultipartUpload] + ExpirationDate: Optional[str] + ExpirationInDays: Optional[int] + ExpiredObjectDeleteMarker: Optional[bool] + Id: Optional[str] + NoncurrentVersionExpiration: Optional[NoncurrentVersionExpiration] + NoncurrentVersionExpirationInDays: Optional[int] + NoncurrentVersionTransition: Optional[NoncurrentVersionTransition] + NoncurrentVersionTransitions: Optional[list[NoncurrentVersionTransition]] + ObjectSizeGreaterThan: Optional[str] + ObjectSizeLessThan: Optional[str] + Prefix: Optional[str] + TagFilters: Optional[list[TagFilter]] + Transition: Optional[Transition] + Transitions: Optional[list[Transition]] + + +class LifecycleConfiguration(TypedDict): + Rules: Optional[list[Rule]] + + +class LoggingConfiguration(TypedDict): + DestinationBucketName: Optional[str] + LogFilePrefix: Optional[str] + + +class MetricsConfiguration(TypedDict): + Id: Optional[str] + AccessPointArn: Optional[str] + Prefix: Optional[str] + TagFilters: Optional[list[TagFilter]] + + +class EventBridgeConfiguration(TypedDict): + EventBridgeEnabled: Optional[bool] + + +class FilterRule(TypedDict): + Name: Optional[str] + Value: Optional[str] + + +class S3KeyFilter(TypedDict): + Rules: Optional[list[FilterRule]] + + +class NotificationFilter(TypedDict): + S3Key: Optional[S3KeyFilter] + + +class LambdaConfiguration(TypedDict): + Event: Optional[str] + Function: Optional[str] + Filter: Optional[NotificationFilter] + + +class QueueConfiguration(TypedDict): + Event: Optional[str] + Queue: Optional[str] + Filter: Optional[NotificationFilter] + + +class TopicConfiguration(TypedDict): + Event: Optional[str] + Topic: Optional[str] + Filter: Optional[NotificationFilter] + + +class NotificationConfiguration(TypedDict): + EventBridgeConfiguration: Optional[EventBridgeConfiguration] + LambdaConfigurations: Optional[list[LambdaConfiguration]] + QueueConfigurations: Optional[list[QueueConfiguration]] + TopicConfigurations: Optional[list[TopicConfiguration]] + + +class DefaultRetention(TypedDict): + Days: Optional[int] + Mode: Optional[str] + Years: Optional[int] + + +class ObjectLockRule(TypedDict): + DefaultRetention: Optional[DefaultRetention] + + +class ObjectLockConfiguration(TypedDict): + ObjectLockEnabled: Optional[str] + Rule: Optional[ObjectLockRule] + + +class OwnershipControlsRule(TypedDict): + ObjectOwnership: Optional[str] + + +class OwnershipControls(TypedDict): + Rules: Optional[list[OwnershipControlsRule]] + + +class PublicAccessBlockConfiguration(TypedDict): + BlockPublicAcls: Optional[bool] + BlockPublicPolicy: Optional[bool] + IgnorePublicAcls: Optional[bool] + RestrictPublicBuckets: Optional[bool] + + +class DeleteMarkerReplication(TypedDict): + Status: Optional[str] + + +class AccessControlTranslation(TypedDict): + Owner: Optional[str] + + +class EncryptionConfiguration(TypedDict): + ReplicaKmsKeyID: Optional[str] + + +class ReplicationTimeValue(TypedDict): + Minutes: Optional[int] + + +class Metrics(TypedDict): + Status: Optional[str] + EventThreshold: Optional[ReplicationTimeValue] + + +class ReplicationTime(TypedDict): + Status: Optional[str] + Time: Optional[ReplicationTimeValue] + + +class ReplicationDestination(TypedDict): + Bucket: Optional[str] + AccessControlTranslation: Optional[AccessControlTranslation] + Account: Optional[str] + EncryptionConfiguration: Optional[EncryptionConfiguration] + Metrics: Optional[Metrics] + ReplicationTime: Optional[ReplicationTime] + StorageClass: Optional[str] + + +class ReplicationRuleAndOperator(TypedDict): + Prefix: Optional[str] + TagFilters: Optional[list[TagFilter]] + + +class ReplicationRuleFilter(TypedDict): + And: Optional[ReplicationRuleAndOperator] + Prefix: Optional[str] + TagFilter: Optional[TagFilter] + + +class ReplicaModifications(TypedDict): + Status: Optional[str] + + +class SseKmsEncryptedObjects(TypedDict): + Status: Optional[str] + + +class SourceSelectionCriteria(TypedDict): + ReplicaModifications: Optional[ReplicaModifications] + SseKmsEncryptedObjects: Optional[SseKmsEncryptedObjects] + + +class ReplicationRule(TypedDict): + Destination: Optional[ReplicationDestination] + Status: Optional[str] + DeleteMarkerReplication: Optional[DeleteMarkerReplication] + Filter: Optional[ReplicationRuleFilter] + Id: Optional[str] + Prefix: Optional[str] + Priority: Optional[int] + SourceSelectionCriteria: Optional[SourceSelectionCriteria] + + +class ReplicationConfiguration(TypedDict): + Role: Optional[str] + Rules: Optional[list[ReplicationRule]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +class VersioningConfiguration(TypedDict): + Status: Optional[str] + + +class RedirectRule(TypedDict): + HostName: Optional[str] + HttpRedirectCode: Optional[str] + Protocol: Optional[str] + ReplaceKeyPrefixWith: Optional[str] + ReplaceKeyWith: Optional[str] + + +class RoutingRuleCondition(TypedDict): + HttpErrorCodeReturnedEquals: Optional[str] + KeyPrefixEquals: Optional[str] + + +class RoutingRule(TypedDict): + RedirectRule: Optional[RedirectRule] + RoutingRuleCondition: Optional[RoutingRuleCondition] + + +class RedirectAllRequestsTo(TypedDict): + HostName: Optional[str] + Protocol: Optional[str] + + +class WebsiteConfiguration(TypedDict): + ErrorDocument: Optional[str] + IndexDocument: Optional[str] + RedirectAllRequestsTo: Optional[RedirectAllRequestsTo] + RoutingRules: Optional[list[RoutingRule]] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class S3BucketProvider(ResourceProvider[S3BucketProperties]): + TYPE = "AWS::S3::Bucket" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[S3BucketProperties], + ) -> ProgressEvent[S3BucketProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/BucketName + + + Create-only properties: + - /properties/BucketName + - /properties/ObjectLockEnabled + + Read-only properties: + - /properties/Arn + - /properties/DomainName + - /properties/DualStackDomainName + - /properties/RegionalDomainName + - /properties/WebsiteURL + + IAM permissions required: + - s3:CreateBucket + - s3:PutBucketTagging + - s3:PutAnalyticsConfiguration + - s3:PutEncryptionConfiguration + - s3:PutBucketCORS + - s3:PutInventoryConfiguration + - s3:PutLifecycleConfiguration + - s3:PutMetricsConfiguration + - s3:PutBucketNotification + - s3:PutBucketReplication + - s3:PutBucketWebsite + - s3:PutAccelerateConfiguration + - s3:PutBucketPublicAccessBlock + - s3:PutReplicationConfiguration + - s3:PutObjectAcl + - s3:PutBucketObjectLockConfiguration + - s3:GetBucketAcl + - s3:ListBucket + - iam:PassRole + - s3:DeleteObject + - s3:PutBucketLogging + - s3:PutBucketVersioning + - s3:PutObjectLockConfiguration + - s3:PutBucketOwnershipControls + - s3:PutBucketIntelligentTieringConfiguration + + """ + model = request.desired_state + s3_client = request.aws_client_factory.s3 + + if not model.get("BucketName"): + model["BucketName"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + model["BucketName"] = normalize_bucket_name(model["BucketName"]) + + self._create_bucket_if_does_not_exist(model, request.region_name, s3_client) + + self._setup_post_creation_attributes(model, request.region_name) + + if put_config := self._get_s3_bucket_notification_config(model): + s3_client.put_bucket_notification_configuration(**put_config) + + if version_conf := model.get("VersioningConfiguration"): + # from the documentation, it seems that `Status` is a required parameter + s3_client.put_bucket_versioning( + Bucket=model["BucketName"], + VersioningConfiguration={ + "Status": version_conf.get("Status", "Suspended"), + }, + ) + + if cors_configuration := self._transform_cfn_cors(model.get("CorsConfiguration")): + s3_client.put_bucket_cors( + Bucket=model["BucketName"], + CORSConfiguration=cors_configuration, + ) + + if object_lock_configuration := model.get("ObjectLockConfiguration"): + s3_client.put_object_lock_configuration( + Bucket=model["BucketName"], + ObjectLockConfiguration=object_lock_configuration, + ) + + if tags := model.get("Tags"): + s3_client.put_bucket_tagging(Bucket=model["BucketName"], Tagging={"TagSet": tags}) + + if website_config := self._transform_website_configuration( + model.get("WebsiteConfiguration") + ): + s3_client.put_bucket_website( + Bucket=model["BucketName"], + WebsiteConfiguration=website_config, + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def _transform_website_configuration(self, website_configuration: dict) -> dict: + if not website_configuration: + return {} + output = {} + if index := website_configuration.get("IndexDocument"): + output["IndexDocument"] = {"Suffix": index} + if error := website_configuration.get("ErrorDocument"): + output["ErrorDocument"] = {"Key": error} + if redirect_all := website_configuration.get("RedirectAllRequestsTo"): + output["RedirectAllRequestsTo"] = redirect_all + + for r in website_configuration.get("RoutingRules", []): + rule = {} + if condition := r.get("RoutingRuleCondition"): + rule["Condition"] = condition + if redirect := r.get("RedirectRule"): + rule["Redirect"] = redirect + output.setdefault("RoutingRules", []).append(rule) + + return output + + def _transform_cfn_cors(self, cors_config): + # See https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_CORSRule.html + # only AllowedMethods and AllowedOrigins are required + if not cors_config: + return {} + + cors_rules = [] + for cfn_rule in cors_config.get("CorsRules", []): + rule = { + "AllowedOrigins": cfn_rule.get("AllowedOrigins"), + "AllowedMethods": cfn_rule.get("AllowedMethods"), + } + # we should not pass those to PutBucketCors if they are None, as S3 will provide default values and + # does not accept None + if (allowed_headers := cfn_rule.get("AllowedHeaders")) is not None: + rule["AllowedHeaders"] = allowed_headers + + if (allowed_headers := cfn_rule.get("ExposedHeaders")) is not None: + rule["ExposeHeaders"] = allowed_headers + + if (allowed_headers := cfn_rule.get("MaxAge")) is not None: + rule["MaxAgeSeconds"] = allowed_headers + + if (allowed_headers := cfn_rule.get("Id")) is not None: + rule["ID"] = allowed_headers + + cors_rules.append(rule) + + return {"CORSRules": cors_rules} + + def _get_s3_bucket_notification_config( + self, + properties: dict, + ) -> dict | None: + notif_config = properties.get("NotificationConfiguration") + if not notif_config: + return None + + lambda_configs = [] + queue_configs = [] + topic_configs = [] + + attr_tuples = ( + ( + "LambdaConfigurations", + lambda_configs, + "LambdaFunctionArn", + "Function", + ), + ("QueueConfigurations", queue_configs, "QueueArn", "Queue"), + ("TopicConfigurations", topic_configs, "TopicArn", "Topic"), + ) + + # prepare lambda/queue/topic notification configs + for attrs in attr_tuples: + for notif_cfg in notif_config.get(attrs[0]) or []: + filter_rules = notif_cfg.get("Filter", {}).get("S3Key", {}).get("Rules") + entry = { + attrs[2]: notif_cfg[attrs[3]], + "Events": [notif_cfg["Event"]], + } + if filter_rules: + entry["Filter"] = {"Key": {"FilterRules": filter_rules}} + attrs[1].append(entry) + + # construct final result + result = { + "Bucket": properties.get("BucketName"), + "NotificationConfiguration": { + "LambdaFunctionConfigurations": lambda_configs, + "QueueConfigurations": queue_configs, + "TopicConfigurations": topic_configs, + }, + } + if notif_config.get("EventBridgeConfiguration", {}).get("EventBridgeEnabled"): + result["NotificationConfiguration"]["EventBridgeConfiguration"] = {} + + return result + + def _setup_post_creation_attributes(self, model, region: str): + model["Arn"] = arns.s3_bucket_arn(model["BucketName"], region=region) + domain_name = f"{model['BucketName']}.{S3_VIRTUAL_HOSTNAME}" + model["DomainName"] = domain_name + model["RegionalDomainName"] = domain_name + # by default (parity) s3 website only supports http + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html + # "Amazon S3 website endpoints do not support HTTPS. If you want to use HTTPS, + # you can use Amazon CloudFront [...]" + model["WebsiteURL"] = ( + f"http://{model['BucketName']}.{S3_STATIC_WEBSITE_HOSTNAME}:{localstack_host().port}" + ) + # resource["Properties"]["DualStackDomainName"] = ? + + def _create_bucket_if_does_not_exist(self, model, region_name, s3_client): + try: + s3_client.head_bucket(Bucket=model["BucketName"]) + except ClientError as e: + if e.response["ResponseMetadata"]["HTTPStatusCode"] != 404: + return + + params = { + "Bucket": model["BucketName"], + "ACL": self._convert_acl_cf_to_s3(model.get("AccessControl", "PublicRead")), + } + + if model.get("ObjectLockEnabled"): + params["ObjectLockEnabledForBucket"] = True + + if region_name != "us-east-1": + params["CreateBucketConfiguration"] = { + "LocationConstraint": region_name, + } + + s3_client.create_bucket(**params) + + def _convert_acl_cf_to_s3(self, acl): + """Convert a CloudFormation ACL string (e.g., 'PublicRead') to an S3 ACL string (e.g., 'public-read')""" + return re.sub("(? ProgressEvent[S3BucketProperties]: + """ + Fetch resource information + + IAM permissions required: + - s3:GetAccelerateConfiguration + - s3:GetLifecycleConfiguration + - s3:GetBucketPublicAccessBlock + - s3:GetAnalyticsConfiguration + - s3:GetBucketCORS + - s3:GetEncryptionConfiguration + - s3:GetInventoryConfiguration + - s3:GetBucketLogging + - s3:GetMetricsConfiguration + - s3:GetBucketNotification + - s3:GetBucketVersioning + - s3:GetReplicationConfiguration + - S3:GetBucketWebsite + - s3:GetBucketPublicAccessBlock + - s3:GetBucketObjectLockConfiguration + - s3:GetBucketTagging + - s3:GetBucketOwnershipControls + - s3:GetIntelligentTieringConfiguration + - s3:ListBucket + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[S3BucketProperties], + ) -> ProgressEvent[S3BucketProperties]: + """ + Delete a resource + + IAM permissions required: + - s3:DeleteBucket + """ + model = request.desired_state + s3_client = request.aws_client_factory.s3 + + # TODO: divergence from how AWS deals with bucket deletes (should throw an error) + try: + delete_all_s3_objects(s3_client, model["BucketName"]) + except s3_client.exceptions.ClientError as e: + if "NoSuchBucket" not in str(e): + raise + + s3_client.delete_bucket(Bucket=model["BucketName"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[S3BucketProperties], + ) -> ProgressEvent[S3BucketProperties]: + """ + Update a resource + + IAM permissions required: + - s3:PutBucketAcl + - s3:PutBucketTagging + - s3:PutAnalyticsConfiguration + - s3:PutEncryptionConfiguration + - s3:PutBucketCORS + - s3:PutInventoryConfiguration + - s3:PutLifecycleConfiguration + - s3:PutMetricsConfiguration + - s3:PutBucketNotification + - s3:PutBucketReplication + - s3:PutBucketWebsite + - s3:PutAccelerateConfiguration + - s3:PutBucketPublicAccessBlock + - s3:PutReplicationConfiguration + - s3:PutBucketOwnershipControls + - s3:PutBucketIntelligentTieringConfiguration + - s3:DeleteBucketWebsite + - s3:PutBucketLogging + - s3:PutBucketVersioning + - s3:PutObjectLockConfiguration + - s3:DeleteBucketAnalyticsConfiguration + - s3:DeleteBucketCors + - s3:DeleteBucketMetricsConfiguration + - s3:DeleteBucketEncryption + - s3:DeleteBucketLifecycle + - s3:DeleteBucketReplication + - iam:PassRole + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[S3BucketProperties], + ) -> ProgressEvent[S3BucketProperties]: + buckets = request.aws_client_factory.s3.list_buckets() + final_buckets = [] + for bucket in buckets["Buckets"]: + final_buckets.append(S3BucketProperties(BucketName=bucket["Name"])) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_models=final_buckets) diff --git a/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket.schema.json b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket.schema.json new file mode 100644 index 0000000000000..88c84aad148f3 --- /dev/null +++ b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket.schema.json @@ -0,0 +1,1611 @@ +{ + "typeName": "AWS::S3::Bucket", + "description": "Resource Type definition for AWS::S3::Bucket", + "additionalProperties": false, + "properties": { + "AccelerateConfiguration": { + "$ref": "#/definitions/AccelerateConfiguration", + "description": "Configuration for the transfer acceleration state." + }, + "AccessControl": { + "description": "A canned access control list (ACL) that grants predefined permissions to the bucket.", + "enum": [ + "AuthenticatedRead", + "AwsExecRead", + "BucketOwnerFullControl", + "BucketOwnerRead", + "LogDeliveryWrite", + "Private", + "PublicRead", + "PublicReadWrite" + ], + "type": "string" + }, + "AnalyticsConfigurations": { + "description": "The configuration and any analyses for the analytics filter of an Amazon S3 bucket.", + "items": { + "$ref": "#/definitions/AnalyticsConfiguration" + }, + "type": "array", + "uniqueItems": true, + "insertionOrder": true + }, + "BucketEncryption": { + "$ref": "#/definitions/BucketEncryption" + }, + "BucketName": { + "description": "A name for the bucket. If you don't specify a name, AWS CloudFormation generates a unique physical ID and uses that ID for the bucket name.", + "maxLength": 63, + "minLength": 3, + "pattern": "^[a-z0-9][a-z0-9//.//-]*[a-z0-9]$", + "type": "string" + }, + "CorsConfiguration": { + "$ref": "#/definitions/CorsConfiguration", + "description": "Rules that define cross-origin resource sharing of objects in this bucket." + }, + "IntelligentTieringConfigurations": { + "description": "Specifies the S3 Intelligent-Tiering configuration for an Amazon S3 bucket.", + "items": { + "$ref": "#/definitions/IntelligentTieringConfiguration" + }, + "type": "array", + "uniqueItems": true, + "insertionOrder": true + }, + "InventoryConfigurations": { + "description": "The inventory configuration for an Amazon S3 bucket.", + "items": { + "$ref": "#/definitions/InventoryConfiguration" + }, + "type": "array", + "uniqueItems": true, + "insertionOrder": true + }, + "LifecycleConfiguration": { + "$ref": "#/definitions/LifecycleConfiguration", + "description": "Rules that define how Amazon S3 manages objects during their lifetime." + }, + "LoggingConfiguration": { + "$ref": "#/definitions/LoggingConfiguration", + "description": "Settings that define where logs are stored." + }, + "MetricsConfigurations": { + "description": "Settings that define a metrics configuration for the CloudWatch request metrics from the bucket.", + "items": { + "$ref": "#/definitions/MetricsConfiguration" + }, + "type": "array", + "uniqueItems": true, + "insertionOrder": true + }, + "NotificationConfiguration": { + "$ref": "#/definitions/NotificationConfiguration", + "description": "Configuration that defines how Amazon S3 handles bucket notifications." + }, + "ObjectLockConfiguration": { + "$ref": "#/definitions/ObjectLockConfiguration", + "description": "Places an Object Lock configuration on the specified bucket." + }, + "ObjectLockEnabled": { + "description": "Indicates whether this bucket has an Object Lock configuration enabled.", + "type": "boolean" + }, + "OwnershipControls": { + "description": "Specifies the container element for object ownership rules.", + "$ref": "#/definitions/OwnershipControls" + }, + "PublicAccessBlockConfiguration": { + "$ref": "#/definitions/PublicAccessBlockConfiguration" + }, + "ReplicationConfiguration": { + "$ref": "#/definitions/ReplicationConfiguration", + "description": "Configuration for replicating objects in an S3 bucket." + }, + "Tags": { + "description": "An arbitrary set of tags (key-value pairs) for this S3 bucket.", + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + }, + "type": "array" + }, + "VersioningConfiguration": { + "$ref": "#/definitions/VersioningConfiguration" + }, + "WebsiteConfiguration": { + "$ref": "#/definitions/WebsiteConfiguration" + }, + "Arn": { + "$ref": "#/definitions/Arn", + "description": "The Amazon Resource Name (ARN) of the specified bucket.", + "examples": [ + "arn:aws:s3:::mybucket" + ] + }, + "DomainName": { + "description": "The IPv4 DNS name of the specified bucket.", + "examples": [ + "mystack-mybucket-kdwwxmddtr2g.s3.amazonaws.com" + ], + "type": "string" + }, + "DualStackDomainName": { + "description": "The IPv6 DNS name of the specified bucket. For more information about dual-stack endpoints, see [Using Amazon S3 Dual-Stack Endpoints](https://docs.aws.amazon.com/AmazonS3/latest/dev/dual-stack-endpoints.html).", + "examples": [ + "mystack-mybucket-kdwwxmddtr2g.s3.dualstack.us-east-2.amazonaws.com" + ], + "type": "string" + }, + "RegionalDomainName": { + "description": "Returns the regional domain name of the specified bucket.", + "examples": [ + "mystack-mybucket-kdwwxmddtr2g.s3.us-east-2.amazonaws.com" + ], + "type": "string" + }, + "WebsiteURL": { + "description": "The Amazon S3 website endpoint for the specified bucket.", + "examples": [ + "Example (IPv4): http://mystack-mybucket-kdwwxmddtr2g.s3-website-us-east-2.amazonaws.com/", + "Example (IPv6): http://mystack-mybucket-kdwwxmddtr2g.s3.dualstack.us-east-2.amazonaws.com/" + ], + "format": "uri", + "type": "string" + } + }, + "definitions": { + "TagFilter": { + "description": "Tags to use to identify a subset of objects for an Amazon S3 bucket.", + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "Destination": { + "description": "Specifies information about where to publish analysis or configuration results for an Amazon S3 bucket and S3 Replication Time Control (S3 RTC).", + "type": "object", + "additionalProperties": false, + "properties": { + "BucketArn": { + "description": "The Amazon Resource Name (ARN) of the bucket to which data is exported.", + "type": "string" + }, + "BucketAccountId": { + "description": "The account ID that owns the destination S3 bucket. ", + "type": "string" + }, + "Format": { + "description": "Specifies the file format used when exporting data to Amazon S3.", + "type": "string", + "enum": [ + "CSV", + "ORC", + "Parquet" + ] + }, + "Prefix": { + "description": "The prefix to use when exporting data. The prefix is prepended to all results.", + "type": "string" + } + }, + "required": [ + "BucketArn", + "Format" + ] + }, + "AccelerateConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "AccelerationStatus": { + "description": "Configures the transfer acceleration state for an Amazon S3 bucket.", + "type": "string", + "enum": [ + "Enabled", + "Suspended" + ] + } + }, + "required": [ + "AccelerationStatus" + ] + }, + "AnalyticsConfiguration": { + "description": "Specifies the configuration and any analyses for the analytics filter of an Amazon S3 bucket.", + "type": "object", + "additionalProperties": false, + "properties": { + "TagFilters": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/TagFilter" + } + }, + "StorageClassAnalysis": { + "$ref": "#/definitions/StorageClassAnalysis" + }, + "Id": { + "description": "The ID that identifies the analytics configuration.", + "type": "string" + }, + "Prefix": { + "description": "The prefix that an object must have to be included in the analytics results.", + "type": "string" + } + }, + "required": [ + "StorageClassAnalysis", + "Id" + ] + }, + "StorageClassAnalysis": { + "description": "Specifies data related to access patterns to be collected and made available to analyze the tradeoffs between different storage classes for an Amazon S3 bucket.", + "type": "object", + "additionalProperties": false, + "properties": { + "DataExport": { + "$ref": "#/definitions/DataExport" + } + } + }, + "DataExport": { + "description": "Specifies how data related to the storage class analysis for an Amazon S3 bucket should be exported.", + "type": "object", + "additionalProperties": false, + "properties": { + "Destination": { + "$ref": "#/definitions/Destination" + }, + "OutputSchemaVersion": { + "description": "The version of the output schema to use when exporting data.", + "type": "string", + "const": "V_1" + } + }, + "required": [ + "Destination", + "OutputSchemaVersion" + ] + }, + "BucketEncryption": { + "description": "Specifies default encryption for a bucket using server-side encryption with either Amazon S3-managed keys (SSE-S3) or AWS KMS-managed keys (SSE-KMS).", + "type": "object", + "additionalProperties": false, + "properties": { + "ServerSideEncryptionConfiguration": { + "description": "Specifies the default server-side-encryption configuration.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/ServerSideEncryptionRule" + } + } + }, + "required": [ + "ServerSideEncryptionConfiguration" + ] + }, + "ServerSideEncryptionRule": { + "description": "Specifies the default server-side encryption configuration.", + "type": "object", + "additionalProperties": false, + "properties": { + "BucketKeyEnabled": { + "description": "Specifies whether Amazon S3 should use an S3 Bucket Key with server-side encryption using KMS (SSE-KMS) for new objects in the bucket. Existing objects are not affected. Setting the BucketKeyEnabled element to true causes Amazon S3 to use an S3 Bucket Key. By default, S3 Bucket Key is not enabled.", + "type": "boolean" + }, + "ServerSideEncryptionByDefault": { + "$ref": "#/definitions/ServerSideEncryptionByDefault" + } + } + }, + "ServerSideEncryptionByDefault": { + "description": "Specifies the default server-side encryption to apply to new objects in the bucket. If a PUT Object request doesn't specify any server-side encryption, this default encryption will be applied.", + "type": "object", + "properties": { + "KMSMasterKeyID": { + "description": "\"KMSMasterKeyID\" can only be used when you set the value of SSEAlgorithm as aws:kms.", + "type": "string" + }, + "SSEAlgorithm": { + "type": "string", + "enum": [ + "aws:kms", + "AES256" + ] + } + }, + "additionalProperties": false, + "required": [ + "SSEAlgorithm" + ] + }, + "CorsConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "CorsRules": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/CorsRule", + "maxLength": 100 + } + } + }, + "required": [ + "CorsRules" + ] + }, + "CorsRule": { + "type": "object", + "description": "A set of origins and methods (cross-origin access that you want to allow). You can add up to 100 rules to the configuration.", + "additionalProperties": false, + "properties": { + "AllowedHeaders": { + "description": "Headers that are specified in the Access-Control-Request-Headers header.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "type": "string" + } + }, + "AllowedMethods": { + "description": "An HTTP method that you allow the origin to execute.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "type": "string", + "enum": [ + "GET", + "PUT", + "HEAD", + "POST", + "DELETE" + ] + } + }, + "AllowedOrigins": { + "description": "One or more origins you want customers to be able to access the bucket from.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "type": "string" + } + }, + "ExposedHeaders": { + "description": "One or more headers in the response that you want customers to be able to access from their applications (for example, from a JavaScript XMLHttpRequest object).", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "type": "string" + } + }, + "Id": { + "description": "A unique identifier for this rule.", + "type": "string", + "maxLength": 255 + }, + "MaxAge": { + "description": "The time in seconds that your browser is to cache the preflight response for the specified resource.", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "AllowedMethods", + "AllowedOrigins" + ] + }, + "IntelligentTieringConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Id": { + "description": "The ID used to identify the S3 Intelligent-Tiering configuration.", + "type": "string" + }, + "Prefix": { + "description": "An object key name prefix that identifies the subset of objects to which the rule applies.", + "type": "string" + }, + "Status": { + "description": "Specifies the status of the configuration.", + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + }, + "TagFilters": { + "description": "A container for a key-value pair.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/TagFilter" + } + }, + "Tierings": { + "description": "Specifies a list of S3 Intelligent-Tiering storage class tiers in the configuration. At least one tier must be defined in the list. At most, you can specify two tiers in the list, one for each available AccessTier: ARCHIVE_ACCESS and DEEP_ARCHIVE_ACCESS.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/Tiering" + } + } + }, + "required": [ + "Id", + "Status", + "Tierings" + ] + }, + "Tiering": { + "type": "object", + "additionalProperties": false, + "properties": { + "AccessTier": { + "description": "S3 Intelligent-Tiering access tier. See Storage class for automatically optimizing frequently and infrequently accessed objects for a list of access tiers in the S3 Intelligent-Tiering storage class.", + "type": "string", + "enum": [ + "ARCHIVE_ACCESS", + "DEEP_ARCHIVE_ACCESS" + ] + }, + "Days": { + "description": "The number of consecutive days of no access after which an object will be eligible to be transitioned to the corresponding tier. The minimum number of days specified for Archive Access tier must be at least 90 days and Deep Archive Access tier must be at least 180 days. The maximum can be up to 2 years (730 days).", + "type": "integer" + } + }, + "required": [ + "AccessTier", + "Days" + ] + }, + "InventoryConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Destination": { + "$ref": "#/definitions/Destination" + }, + "Enabled": { + "description": "Specifies whether the inventory is enabled or disabled.", + "type": "boolean" + }, + "Id": { + "description": "The ID used to identify the inventory configuration.", + "type": "string" + }, + "IncludedObjectVersions": { + "description": "Object versions to include in the inventory list.", + "type": "string", + "enum": [ + "All", + "Current" + ] + }, + "OptionalFields": { + "description": "Contains the optional fields that are included in the inventory results.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "type": "string", + "enum": [ + "Size", + "LastModifiedDate", + "StorageClass", + "ETag", + "IsMultipartUploaded", + "ReplicationStatus", + "EncryptionStatus", + "ObjectLockRetainUntilDate", + "ObjectLockMode", + "ObjectLockLegalHoldStatus", + "IntelligentTieringAccessTier", + "BucketKeyStatus" + ] + } + }, + "Prefix": { + "description": "The prefix that is prepended to all inventory results.", + "type": "string" + }, + "ScheduleFrequency": { + "description": "Specifies the schedule for generating inventory results.", + "type": "string", + "enum": [ + "Daily", + "Weekly" + ] + } + }, + "required": [ + "Destination", + "Enabled", + "Id", + "IncludedObjectVersions", + "ScheduleFrequency" + ] + }, + "LifecycleConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Rules": { + "description": "A lifecycle rule for individual objects in an Amazon S3 bucket.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/Rule" + } + } + }, + "required": [ + "Rules" + ] + }, + "Rule": { + "type": "object", + "description": "You must specify at least one of the following properties: AbortIncompleteMultipartUpload, ExpirationDate, ExpirationInDays, NoncurrentVersionExpirationInDays, NoncurrentVersionTransition, NoncurrentVersionTransitions, Transition, or Transitions.", + "additionalProperties": false, + "properties": { + "AbortIncompleteMultipartUpload": { + "$ref": "#/definitions/AbortIncompleteMultipartUpload" + }, + "ExpirationDate": { + "$ref": "#/definitions/iso8601UTC" + }, + "ExpirationInDays": { + "type": "integer" + }, + "ExpiredObjectDeleteMarker": { + "type": "boolean" + }, + "Id": { + "type": "string", + "maxLength": 255 + }, + "NoncurrentVersionExpirationInDays": { + "type": "integer" + }, + "NoncurrentVersionExpiration": { + "$ref": "#/definitions/NoncurrentVersionExpiration" + }, + "NoncurrentVersionTransition": { + "$ref": "#/definitions/NoncurrentVersionTransition" + }, + "NoncurrentVersionTransitions": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/NoncurrentVersionTransition" + } + }, + "Prefix": { + "type": "string" + }, + "Status": { + "type": "string", + "enum": [ + "Enabled", + "Disabled" + ] + }, + "TagFilters": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/TagFilter" + } + }, + "ObjectSizeGreaterThan": { + "type": "string", + "maxLength": 20, + "pattern": "[0-9]+" + }, + "ObjectSizeLessThan": { + "type": "string", + "maxLength": 20, + "pattern": "[0-9]+" + }, + "Transition": { + "$ref": "#/definitions/Transition" + }, + "Transitions": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/Transition" + } + } + }, + "required": [ + "Status" + ] + }, + "AbortIncompleteMultipartUpload": { + "description": "Specifies the days since the initiation of an incomplete multipart upload that Amazon S3 will wait before permanently removing all parts of the upload.", + "type": "object", + "additionalProperties": false, + "properties": { + "DaysAfterInitiation": { + "description": "Specifies the number of days after which Amazon S3 aborts an incomplete multipart upload.", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "DaysAfterInitiation" + ] + }, + "iso8601UTC": { + "description": "The date value in ISO 8601 format. The timezone is always UTC. (YYYY-MM-DDThh:mm:ssZ)", + "type": "string", + "pattern": "^([0-2]\\d{3})-(0[0-9]|1[0-2])-([0-2]\\d|3[01])T([01]\\d|2[0-4]):([0-5]\\d):([0-6]\\d)((\\.\\d{3})?)Z$" + }, + "NoncurrentVersionExpiration": { + "type": "object", + "description": "Container for the expiration rule that describes when noncurrent objects are expired. If your bucket is versioning-enabled (or versioning is suspended), you can set this action to request that Amazon S3 expire noncurrent object versions at a specific period in the object's lifetime", + "additionalProperties": false, + "properties": { + "NoncurrentDays": { + "description": "Specified the number of days an object is noncurrent before Amazon S3 can perform the associated action", + "type": "integer" + }, + "NewerNoncurrentVersions": { + "description": "Specified the number of newer noncurrent and current versions that must exists before performing the associated action", + "type": "integer" + } + }, + "required": [ + "NoncurrentDays" + ] + }, + "NoncurrentVersionTransition": { + "type": "object", + "description": "Container for the transition rule that describes when noncurrent objects transition to the STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, GLACIER_IR, GLACIER, or DEEP_ARCHIVE storage class. If your bucket is versioning-enabled (or versioning is suspended), you can set this action to request that Amazon S3 transition noncurrent object versions to the STANDARD_IA, ONEZONE_IA, INTELLIGENT_TIERING, GLACIER_IR, GLACIER, or DEEP_ARCHIVE storage class at a specific period in the object's lifetime.", + "additionalProperties": false, + "properties": { + "StorageClass": { + "description": "The class of storage used to store the object.", + "type": "string", + "enum": [ + "DEEP_ARCHIVE", + "GLACIER", + "Glacier", + "GLACIER_IR", + "INTELLIGENT_TIERING", + "ONEZONE_IA", + "STANDARD_IA" + ] + }, + "TransitionInDays": { + "description": "Specifies the number of days an object is noncurrent before Amazon S3 can perform the associated action.", + "type": "integer" + }, + "NewerNoncurrentVersions": { + "description": "Specified the number of newer noncurrent and current versions that must exists before performing the associated action", + "type": "integer" + } + }, + "required": [ + "StorageClass", + "TransitionInDays" + ] + }, + "Transition": { + "type": "object", + "properties": { + "StorageClass": { + "type": "string", + "enum": [ + "DEEP_ARCHIVE", + "GLACIER", + "Glacier", + "GLACIER_IR", + "INTELLIGENT_TIERING", + "ONEZONE_IA", + "STANDARD_IA" + ] + }, + "TransitionDate": { + "$ref": "#/definitions/iso8601UTC" + }, + "TransitionInDays": { + "type": "integer" + } + }, + "additionalProperties": false, + "description": "You must specify at least one of \"TransitionDate\" and \"TransitionInDays\"", + "required": [ + "StorageClass" + ] + }, + "LoggingConfiguration": { + "type": "object", + "properties": { + "DestinationBucketName": { + "type": "string", + "description": "The name of an Amazon S3 bucket where Amazon S3 store server access log files. You can store log files in any bucket that you own. By default, logs are stored in the bucket where the LoggingConfiguration property is defined." + }, + "LogFilePrefix": { + "type": "string" + } + }, + "additionalProperties": false + }, + "MetricsConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "AccessPointArn": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "Prefix": { + "type": "string" + }, + "TagFilters": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/TagFilter" + } + } + }, + "required": [ + "Id" + ] + }, + "NotificationConfiguration": { + "description": "Describes the notification configuration for an Amazon S3 bucket.", + "type": "object", + "additionalProperties": false, + "properties": { + "EventBridgeConfiguration": { + "$ref": "#/definitions/EventBridgeConfiguration" + }, + "LambdaConfigurations": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/LambdaConfiguration" + } + }, + "QueueConfigurations": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/QueueConfiguration" + } + }, + "TopicConfigurations": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/TopicConfiguration" + } + } + } + }, + "EventBridgeConfiguration": { + "type": "object", + "description": "Describes the Amazon EventBridge notification configuration for an Amazon S3 bucket.", + "additionalProperties": false, + "properties": { + "EventBridgeEnabled": { + "description": "Specifies whether to send notifications to Amazon EventBridge when events occur in an Amazon S3 bucket.", + "type": "boolean", + "default": "true" + } + }, + "required": [ + "EventBridgeEnabled" + ] + }, + "LambdaConfiguration": { + "type": "object", + "description": "Describes the AWS Lambda functions to invoke and the events for which to invoke them.", + "additionalProperties": false, + "properties": { + "Event": { + "description": "The Amazon S3 bucket event for which to invoke the AWS Lambda function.", + "type": "string" + }, + "Filter": { + "description": "The filtering rules that determine which objects invoke the AWS Lambda function.", + "$ref": "#/definitions/NotificationFilter" + }, + "Function": { + "description": "The Amazon Resource Name (ARN) of the AWS Lambda function that Amazon S3 invokes when the specified event type occurs.", + "type": "string" + } + }, + "required": [ + "Function", + "Event" + ] + }, + "QueueConfiguration": { + "type": "object", + "description": "The Amazon Simple Queue Service queues to publish messages to and the events for which to publish messages.", + "additionalProperties": false, + "properties": { + "Event": { + "description": "The Amazon S3 bucket event about which you want to publish messages to Amazon SQS.", + "type": "string" + }, + "Filter": { + "description": "The filtering rules that determine which objects trigger notifications.", + "$ref": "#/definitions/NotificationFilter" + }, + "Queue": { + "description": "The Amazon Resource Name (ARN) of the Amazon SQS queue to which Amazon S3 publishes a message when it detects events of the specified type.", + "type": "string" + } + }, + "required": [ + "Event", + "Queue" + ] + }, + "TopicConfiguration": { + "type": "object", + "description": "The topic to which notifications are sent and the events for which notifications are generated.", + "additionalProperties": false, + "properties": { + "Event": { + "description": "The Amazon S3 bucket event about which to send notifications.", + "type": "string" + }, + "Filter": { + "description": "The filtering rules that determine for which objects to send notifications.", + "$ref": "#/definitions/NotificationFilter" + }, + "Topic": { + "description": "The Amazon Resource Name (ARN) of the Amazon SNS topic to which Amazon S3 publishes a message when it detects events of the specified type.", + "type": "string" + } + }, + "required": [ + "Event", + "Topic" + ] + }, + "NotificationFilter": { + "type": "object", + "description": "Specifies object key name filtering rules.", + "additionalProperties": false, + "properties": { + "S3Key": { + "$ref": "#/definitions/S3KeyFilter" + } + }, + "required": [ + "S3Key" + ] + }, + "S3KeyFilter": { + "type": "object", + "description": "A container for object key name prefix and suffix filtering rules.", + "additionalProperties": false, + "properties": { + "Rules": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/FilterRule" + } + } + }, + "required": [ + "Rules" + ] + }, + "FilterRule": { + "type": "object", + "description": "Specifies the Amazon S3 object key name to filter on and whether to filter on the suffix or prefix of the key name.", + "additionalProperties": false, + "properties": { + "Name": { + "type": "string", + "maxLength": 1024 + }, + "Value": { + "type": "string" + } + }, + "required": [ + "Value", + "Name" + ] + }, + "ObjectLockConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "ObjectLockEnabled": { + "type": "string", + "const": "Enabled" + }, + "Rule": { + "$ref": "#/definitions/ObjectLockRule" + } + } + }, + "ObjectLockRule": { + "type": "object", + "description": "The Object Lock rule in place for the specified object.", + "additionalProperties": false, + "properties": { + "DefaultRetention": { + "$ref": "#/definitions/DefaultRetention" + } + } + }, + "DefaultRetention": { + "type": "object", + "description": "The default retention period that you want to apply to new objects placed in the specified bucket.", + "additionalProperties": false, + "properties": { + "Years": { + "type": "integer" + }, + "Days": { + "type": "integer" + }, + "Mode": { + "type": "string", + "enum": [ + "COMPLIANCE", + "GOVERNANCE" + ] + } + } + }, + "OwnershipControls": { + "type": "object", + "additionalProperties": false, + "properties": { + "Rules": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/OwnershipControlsRule" + } + } + }, + "required": [ + "Rules" + ] + }, + "OwnershipControlsRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "ObjectOwnership": { + "description": "Specifies an object ownership rule.", + "type": "string", + "enum": [ + "ObjectWriter", + "BucketOwnerPreferred", + "BucketOwnerEnforced" + ] + } + } + }, + "PublicAccessBlockConfiguration": { + "description": "Configuration that defines how Amazon S3 handles public access.", + "type": "object", + "additionalProperties": false, + "properties": { + "BlockPublicAcls": { + "type": "boolean", + "description": "Specifies whether Amazon S3 should block public access control lists (ACLs) for this bucket and objects in this bucket. Setting this element to TRUE causes the following behavior:\n- PUT Bucket acl and PUT Object acl calls fail if the specified ACL is public.\n - PUT Object calls fail if the request includes a public ACL.\nEnabling this setting doesn't affect existing policies or ACLs." + }, + "BlockPublicPolicy": { + "type": "boolean", + "description": "Specifies whether Amazon S3 should block public bucket policies for this bucket. Setting this element to TRUE causes Amazon S3 to reject calls to PUT Bucket policy if the specified bucket policy allows public access.\nEnabling this setting doesn't affect existing bucket policies." + }, + "IgnorePublicAcls": { + "type": "boolean", + "description": "Specifies whether Amazon S3 should ignore public ACLs for this bucket and objects in this bucket. Setting this element to TRUE causes Amazon S3 to ignore all public ACLs on this bucket and objects in this bucket.\nEnabling this setting doesn't affect the persistence of any existing ACLs and doesn't prevent new public ACLs from being set." + }, + "RestrictPublicBuckets": { + "type": "boolean", + "description": "Specifies whether Amazon S3 should restrict public bucket policies for this bucket. Setting this element to TRUE restricts access to this bucket to only AWS services and authorized users within this account if the bucket has a public policy.\nEnabling this setting doesn't affect previously stored bucket policies, except that public and cross-account access within any public bucket policy, including non-public delegation to specific accounts, is blocked." + } + } + }, + "ReplicationConfiguration": { + "type": "object", + "description": "A container for replication rules. You can add up to 1,000 rules. The maximum size of a replication configuration is 2 MB.", + "additionalProperties": false, + "properties": { + "Role": { + "description": "The Amazon Resource Name (ARN) of the AWS Identity and Access Management (IAM) role that Amazon S3 assumes when replicating objects.", + "type": "string" + }, + "Rules": { + "description": "A container for one or more replication rules.", + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/ReplicationRule", + "maxLength": 1000, + "minLength": 1 + } + } + }, + "required": [ + "Role", + "Rules" + ] + }, + "ReplicationRule": { + "type": "object", + "description": "Specifies which Amazon S3 objects to replicate and where to store the replicas.", + "additionalProperties": false, + "properties": { + "DeleteMarkerReplication": { + "$ref": "#/definitions/DeleteMarkerReplication" + }, + "Destination": { + "$ref": "#/definitions/ReplicationDestination" + }, + "Filter": { + "$ref": "#/definitions/ReplicationRuleFilter" + }, + "Id": { + "description": "A unique identifier for the rule.", + "type": "string", + "maxLength": 255 + }, + "Prefix": { + "description": "An object key name prefix that identifies the object or objects to which the rule applies.", + "type": "string", + "maxLength": 1024 + }, + "Priority": { + "type": "integer" + }, + "SourceSelectionCriteria": { + "$ref": "#/definitions/SourceSelectionCriteria" + }, + "Status": { + "description": "Specifies whether the rule is enabled.", + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + } + }, + "required": [ + "Destination", + "Status" + ] + }, + "DeleteMarkerReplication": { + "type": "object", + "additionalProperties": false, + "properties": { + "Status": { + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + } + } + }, + "ReplicationDestination": { + "type": "object", + "description": "Specifies which Amazon S3 bucket to store replicated objects in and their storage class.", + "additionalProperties": false, + "properties": { + "AccessControlTranslation": { + "$ref": "#/definitions/AccessControlTranslation" + }, + "Account": { + "type": "string" + }, + "Bucket": { + "type": "string" + }, + "EncryptionConfiguration": { + "$ref": "#/definitions/EncryptionConfiguration" + }, + "Metrics": { + "$ref": "#/definitions/Metrics" + }, + "ReplicationTime": { + "$ref": "#/definitions/ReplicationTime" + }, + "StorageClass": { + "description": "The storage class to use when replicating objects, such as S3 Standard or reduced redundancy.", + "type": "string", + "enum": [ + "DEEP_ARCHIVE", + "GLACIER", + "GLACIER_IR", + "INTELLIGENT_TIERING", + "ONEZONE_IA", + "REDUCED_REDUNDANCY", + "STANDARD", + "STANDARD_IA" + ] + } + }, + "required": [ + "Bucket" + ] + }, + "AccessControlTranslation": { + "type": "object", + "description": "Specify this only in a cross-account scenario (where source and destination bucket owners are not the same), and you want to change replica ownership to the AWS account that owns the destination bucket. If this is not specified in the replication configuration, the replicas are owned by same AWS account that owns the source object.", + "additionalProperties": false, + "properties": { + "Owner": { + "type": "string", + "const": "Destination" + } + }, + "required": [ + "Owner" + ] + }, + "EncryptionConfiguration": { + "type": "object", + "description": "Specifies encryption-related information for an Amazon S3 bucket that is a destination for replicated objects.", + "additionalProperties": false, + "properties": { + "ReplicaKmsKeyID": { + "description": "Specifies the ID (Key ARN or Alias ARN) of the customer managed customer master key (CMK) stored in AWS Key Management Service (KMS) for the destination bucket.", + "type": "string" + } + }, + "required": [ + "ReplicaKmsKeyID" + ] + }, + "Metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "EventThreshold": { + "$ref": "#/definitions/ReplicationTimeValue" + }, + "Status": { + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + } + }, + "required": [ + "Status" + ] + }, + "ReplicationTimeValue": { + "type": "object", + "additionalProperties": false, + "properties": { + "Minutes": { + "type": "integer" + } + }, + "required": [ + "Minutes" + ] + }, + "ReplicationTime": { + "type": "object", + "additionalProperties": false, + "properties": { + "Status": { + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + }, + "Time": { + "$ref": "#/definitions/ReplicationTimeValue" + } + }, + "required": [ + "Status", + "Time" + ] + }, + "ReplicationRuleFilter": { + "type": "object", + "additionalProperties": false, + "properties": { + "And": { + "$ref": "#/definitions/ReplicationRuleAndOperator" + }, + "Prefix": { + "type": "string" + }, + "TagFilter": { + "$ref": "#/definitions/TagFilter" + } + } + }, + "ReplicationRuleAndOperator": { + "type": "object", + "additionalProperties": false, + "properties": { + "Prefix": { + "type": "string" + }, + "TagFilters": { + "type": "array", + "uniqueItems": true, + "insertionOrder": true, + "items": { + "$ref": "#/definitions/TagFilter" + } + } + } + }, + "SourceSelectionCriteria": { + "description": "A container that describes additional filters for identifying the source objects that you want to replicate.", + "type": "object", + "additionalProperties": false, + "properties": { + "ReplicaModifications": { + "description": "A filter that you can specify for selection for modifications on replicas.", + "$ref": "#/definitions/ReplicaModifications" + }, + "SseKmsEncryptedObjects": { + "description": "A container for filter information for the selection of Amazon S3 objects encrypted with AWS KMS.", + "$ref": "#/definitions/SseKmsEncryptedObjects" + } + } + }, + "ReplicaModifications": { + "type": "object", + "additionalProperties": false, + "properties": { + "Status": { + "description": "Specifies whether Amazon S3 replicates modifications on replicas.", + "type": "string", + "enum": [ + "Enabled", + "Disabled" + ] + } + }, + "required": [ + "Status" + ] + }, + "SseKmsEncryptedObjects": { + "type": "object", + "description": "A container for filter information for the selection of S3 objects encrypted with AWS KMS.", + "additionalProperties": false, + "properties": { + "Status": { + "description": "Specifies whether Amazon S3 replicates objects created with server-side encryption using a customer master key (CMK) stored in AWS Key Management Service.", + "type": "string", + "enum": [ + "Disabled", + "Enabled" + ] + } + }, + "required": [ + "Status" + ] + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "Value", + "Key" + ] + }, + "VersioningConfiguration": { + "description": "Describes the versioning state of an Amazon S3 bucket.", + "type": "object", + "additionalProperties": false, + "properties": { + "Status": { + "description": "The versioning state of the bucket.", + "type": "string", + "default": "Suspended", + "enum": [ + "Enabled", + "Suspended" + ] + } + }, + "required": [ + "Status" + ] + }, + "WebsiteConfiguration": { + "type": "object", + "description": "Specifies website configuration parameters for an Amazon S3 bucket.", + "additionalProperties": false, + "properties": { + "ErrorDocument": { + "description": "The name of the error document for the website.", + "type": "string" + }, + "IndexDocument": { + "description": "The name of the index document for the website.", + "type": "string" + }, + "RoutingRules": { + "type": "array", + "insertionOrder": true, + "items": { + "$ref": "#/definitions/RoutingRule" + } + }, + "RedirectAllRequestsTo": { + "$ref": "#/definitions/RedirectAllRequestsTo" + } + } + }, + "RoutingRule": { + "description": "Specifies the redirect behavior and when a redirect is applied.", + "type": "object", + "additionalProperties": false, + "properties": { + "RedirectRule": { + "description": "Container for redirect information. You can redirect requests to another host, to another page, or with another protocol. In the event of an error, you can specify a different error code to return.", + "$ref": "#/definitions/RedirectRule" + }, + "RoutingRuleCondition": { + "$ref": "#/definitions/RoutingRuleCondition" + } + }, + "required": [ + "RedirectRule" + ] + }, + "RedirectRule": { + "type": "object", + "description": "Specifies how requests are redirected. In the event of an error, you can specify a different error code to return.", + "additionalProperties": false, + "properties": { + "HostName": { + "description": "The host name to use in the redirect request.", + "type": "string" + }, + "HttpRedirectCode": { + "description": "The HTTP redirect code to use on the response. Not required if one of the siblings is present.", + "type": "string" + }, + "Protocol": { + "description": "Protocol to use when redirecting requests. The default is the protocol that is used in the original request.", + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "ReplaceKeyPrefixWith": { + "description": "The object key prefix to use in the redirect request.", + "type": "string" + }, + "ReplaceKeyWith": { + "description": "The specific object key to use in the redirect request.d", + "type": "string" + } + } + }, + "RoutingRuleCondition": { + "description": "A container for describing a condition that must be met for the specified redirect to apply.You must specify at least one of HttpErrorCodeReturnedEquals and KeyPrefixEquals", + "type": "object", + "additionalProperties": false, + "properties": { + "KeyPrefixEquals": { + "description": "The object key name prefix when the redirect is applied.", + "type": "string" + }, + "HttpErrorCodeReturnedEquals": { + "description": "The HTTP error code when the redirect is applied. ", + "type": "string" + } + } + }, + "RedirectAllRequestsTo": { + "description": "Specifies the redirect behavior of all requests to a website endpoint of an Amazon S3 bucket.", + "type": "object", + "additionalProperties": false, + "properties": { + "HostName": { + "description": "Name of the host where requests are redirected.", + "type": "string" + }, + "Protocol": { + "description": "Protocol to use when redirecting requests. The default is the protocol that is used in the original request.", + "type": "string", + "enum": [ + "http", + "https" + ] + } + }, + "required": [ + "HostName" + ] + }, + "Arn": { + "description": "the Amazon Resource Name (ARN) of the specified bucket.", + "type": "string" + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "createOnlyProperties": [ + "/properties/BucketName", + "/properties/ObjectLockEnabled" + ], + "primaryIdentifier": [ + "/properties/BucketName" + ], + "readOnlyProperties": [ + "/properties/Arn", + "/properties/DomainName", + "/properties/DualStackDomainName", + "/properties/RegionalDomainName", + "/properties/WebsiteURL" + ], + "handlers": { + "create": { + "permissions": [ + "s3:CreateBucket", + "s3:PutBucketTagging", + "s3:PutAnalyticsConfiguration", + "s3:PutEncryptionConfiguration", + "s3:PutBucketCORS", + "s3:PutInventoryConfiguration", + "s3:PutLifecycleConfiguration", + "s3:PutMetricsConfiguration", + "s3:PutBucketNotification", + "s3:PutBucketReplication", + "s3:PutBucketWebsite", + "s3:PutAccelerateConfiguration", + "s3:PutBucketPublicAccessBlock", + "s3:PutReplicationConfiguration", + "s3:PutObjectAcl", + "s3:PutBucketObjectLockConfiguration", + "s3:GetBucketAcl", + "s3:ListBucket", + "iam:PassRole", + "s3:DeleteObject", + "s3:PutBucketLogging", + "s3:PutBucketVersioning", + "s3:PutObjectLockConfiguration", + "s3:PutBucketOwnershipControls", + "s3:PutBucketIntelligentTieringConfiguration" + ] + }, + "read": { + "permissions": [ + "s3:GetAccelerateConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetBucketPublicAccessBlock", + "s3:GetAnalyticsConfiguration", + "s3:GetBucketCORS", + "s3:GetEncryptionConfiguration", + "s3:GetInventoryConfiguration", + "s3:GetBucketLogging", + "s3:GetMetricsConfiguration", + "s3:GetBucketNotification", + "s3:GetBucketVersioning", + "s3:GetReplicationConfiguration", + "S3:GetBucketWebsite", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketTagging", + "s3:GetBucketOwnershipControls", + "s3:GetIntelligentTieringConfiguration", + "s3:ListBucket" + ] + }, + "update": { + "permissions": [ + "s3:PutBucketAcl", + "s3:PutBucketTagging", + "s3:PutAnalyticsConfiguration", + "s3:PutEncryptionConfiguration", + "s3:PutBucketCORS", + "s3:PutInventoryConfiguration", + "s3:PutLifecycleConfiguration", + "s3:PutMetricsConfiguration", + "s3:PutBucketNotification", + "s3:PutBucketReplication", + "s3:PutBucketWebsite", + "s3:PutAccelerateConfiguration", + "s3:PutBucketPublicAccessBlock", + "s3:PutReplicationConfiguration", + "s3:PutBucketOwnershipControls", + "s3:PutBucketIntelligentTieringConfiguration", + "s3:DeleteBucketWebsite", + "s3:PutBucketLogging", + "s3:PutBucketVersioning", + "s3:PutObjectLockConfiguration", + "s3:DeleteBucketAnalyticsConfiguration", + "s3:DeleteBucketCors", + "s3:DeleteBucketMetricsConfiguration", + "s3:DeleteBucketEncryption", + "s3:DeleteBucketLifecycle", + "s3:DeleteBucketReplication", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "s3:DeleteBucket" + ] + }, + "list": { + "permissions": [ + "s3:ListAllMyBuckets" + ] + } + } +} diff --git a/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket_plugin.py b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket_plugin.py new file mode 100644 index 0000000000000..d79e772ca7a65 --- /dev/null +++ b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucket_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class S3BucketProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::S3::Bucket" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.s3.resource_providers.aws_s3_bucket import S3BucketProvider + + self.factory = S3BucketProvider diff --git a/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy.py b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy.py new file mode 100644 index 0000000000000..78c5db3544efa --- /dev/null +++ b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy.py @@ -0,0 +1,110 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.json import canonical_json +from localstack.utils.strings import md5 + + +class S3BucketPolicyProperties(TypedDict): + Bucket: Optional[str] + PolicyDocument: Optional[dict] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class S3BucketPolicyProvider(ResourceProvider[S3BucketPolicyProperties]): + TYPE = "AWS::S3::BucketPolicy" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[S3BucketPolicyProperties], + ) -> ProgressEvent[S3BucketPolicyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - Bucket + - PolicyDocument + + Create-only properties: + - /properties/Bucket + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + s3 = request.aws_client_factory.s3 + + s3.put_bucket_policy(Bucket=model["Bucket"], Policy=json.dumps(model["PolicyDocument"])) + model["Id"] = md5(canonical_json(model["PolicyDocument"])) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[S3BucketPolicyProperties], + ) -> ProgressEvent[S3BucketPolicyProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[S3BucketPolicyProperties], + ) -> ProgressEvent[S3BucketPolicyProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + s3 = request.aws_client_factory.s3 + + try: + s3.delete_bucket_policy(Bucket=model["Bucket"]) + except s3.exceptions.NoSuchBucket: + pass + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[S3BucketPolicyProperties], + ) -> ProgressEvent[S3BucketPolicyProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy.schema.json b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy.schema.json new file mode 100644 index 0000000000000..c0e5cca4493da --- /dev/null +++ b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy.schema.json @@ -0,0 +1,29 @@ +{ + "typeName": "AWS::S3::BucketPolicy", + "description": "Resource Type definition for AWS::S3::BucketPolicy", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "Bucket": { + "type": "string" + }, + "PolicyDocument": { + "type": "object" + } + }, + "required": [ + "Bucket", + "PolicyDocument" + ], + "createOnlyProperties": [ + "/properties/Bucket" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy_plugin.py b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy_plugin.py new file mode 100644 index 0000000000000..1589f69b38ad6 --- /dev/null +++ b/localstack-core/localstack/services/s3/resource_providers/aws_s3_bucketpolicy_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class S3BucketPolicyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::S3::BucketPolicy" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.s3.resource_providers.aws_s3_bucketpolicy import ( + S3BucketPolicyProvider, + ) + + self.factory = S3BucketPolicyProvider diff --git a/localstack-core/localstack/services/s3/storage/__init__.py b/localstack-core/localstack/services/s3/storage/__init__.py new file mode 100644 index 0000000000000..1e75dd858f811 --- /dev/null +++ b/localstack-core/localstack/services/s3/storage/__init__.py @@ -0,0 +1,15 @@ +from .core import ( + LimitedIterableStream, + LimitedStream, + S3ObjectStore, + S3StoredMultipart, + S3StoredObject, +) + +__all__ = [ + "LimitedIterableStream", + "LimitedStream", + "S3ObjectStore", + "S3StoredMultipart", + "S3StoredObject", +] diff --git a/localstack-core/localstack/services/s3/storage/core.py b/localstack-core/localstack/services/s3/storage/core.py new file mode 100644 index 0000000000000..d925f3cfc2b7e --- /dev/null +++ b/localstack-core/localstack/services/s3/storage/core.py @@ -0,0 +1,267 @@ +import abc +from io import RawIOBase +from typing import IO, Iterable, Iterator, Literal, Optional + +from localstack.aws.api.s3 import BucketName, PartNumber +from localstack.services.s3.models import S3Multipart, S3Object, S3Part +from localstack.services.s3.utils import ObjectRange + + +class LimitedIterableStream(Iterable[bytes]): + """ + This can limit an Iterable which can return any number of bytes at each iteration, to return a max_length total + amount of bytes + """ + + def __init__(self, iterable: Iterable[bytes], max_length: int): + self.iterable = iterable + self.max_length = max_length + + def __iter__(self): + for chunk in self.iterable: + read = len(chunk) + if self.max_length - read >= 0: + self.max_length -= read + yield chunk + elif self.max_length == 0: + break + else: + yield chunk[: self.max_length] + break + + return + + def close(self): + if hasattr(self.iterable, "close"): + self.iterable.close() + + +class LimitedStream(RawIOBase): + """ + This utility class allows to return a range from the underlying stream representing an S3 Object. + """ + + def __init__(self, base_stream: IO[bytes] | "S3StoredObject", range_data: ObjectRange): + super().__init__() + self.file = base_stream + self._pos = range_data.begin + self._max_length = range_data.content_length + + def read(self, s: int = -1) -> bytes | None: + if s is None or s < 0: + amount = self._max_length + else: + amount = min(self._max_length, s) + + self.file.seek(self._pos) + data = self.file.read(amount) + + if not data: + return b"" + read_amount = len(data) + self._max_length -= read_amount + self._pos += read_amount + + return data + + +class S3StoredObject(abc.ABC, Iterable[bytes]): + """ + This abstract class represents the underlying stored data of an S3 object. Its API mimics one of a typical object + returned by `open`, while allowing easy usage from an S3 perspective. + """ + + s3_object: S3Object + + def __init__(self, s3_object: S3Object | S3Part, mode: Literal["r", "w"] = "r"): + self.s3_object = s3_object + self._mode = mode + self.closed = False + + @abc.abstractmethod + def close(self): + pass + + @abc.abstractmethod + def write(self, s: IO[bytes] | "S3StoredObject") -> int: + pass + + @abc.abstractmethod + def append(self, part: "S3StoredObject") -> int: + pass + + @abc.abstractmethod + def read(self, s: int = -1) -> bytes | None: + pass + + @abc.abstractmethod + def seek(self, offset: int, whence: int = 0) -> int: + pass + + def truncate(self, size: int = None) -> int: + pass + + @property + @abc.abstractmethod + def last_modified(self) -> int: + pass + + @property + @abc.abstractmethod + def checksum(self) -> Optional[str]: + if not self.s3_object.checksum_algorithm: + return None + + @property + @abc.abstractmethod + def etag(self) -> str: + pass + + @abc.abstractmethod + def __iter__(self) -> Iterator[bytes]: + pass + + def __enter__(self): + """Context management protocol. Returns self (an instance of S3StoredObject).""" + if self.closed: + raise ValueError("I/O operation on closed S3 Object.") + return self + + def __exit__(self, *args): + """Context management protocol. Calls close()""" + self.close() + + +class S3StoredMultipart(abc.ABC): + """ + This abstract class represents the collection of stored data of an S3 Multipart Upload. It will collect parts, + represented as S3StoredObject, and can at some point be assembled into a single S3StoredObject. + """ + + parts: dict[PartNumber, S3StoredObject] + s3_multipart: S3Multipart + _s3_store: "S3ObjectStore" + + def __init__(self, s3_store: "S3ObjectStore", bucket: BucketName, s3_multipart: S3Multipart): + self.s3_multipart = s3_multipart + self.bucket = bucket + self._s3_store = s3_store + self.parts = {} + + @abc.abstractmethod + def open(self, s3_part: S3Part, mode: Literal["r", "w"] = "r") -> S3StoredObject: + pass + + @abc.abstractmethod + def remove_part(self, s3_part: S3Part): + pass + + @abc.abstractmethod + def complete_multipart(self, parts: list[PartNumber]) -> None: + pass + + @abc.abstractmethod + def close(self): + pass + + @abc.abstractmethod + def copy_from_object( + self, + s3_part: S3Part, + src_bucket: BucketName, + src_s3_object: S3Object, + range_data: Optional[ObjectRange], + ) -> None: + pass + + +class S3ObjectStore(abc.ABC): + """ + This abstract class is the entrypoint of accessing the storage of S3 data. You can easily open and remove S3 Objects + as well as directly retrieving a StoredS3Multipart to directly interact with it. + """ + + @abc.abstractmethod + def open( + self, bucket: BucketName, s3_object: S3Object, mode: Literal["r", "w"] = "r" + ) -> S3StoredObject: + pass + + @abc.abstractmethod + def remove(self, bucket: BucketName, s3_object: S3Object | list[S3Object]): + pass + + @abc.abstractmethod + def copy( + self, + src_bucket: BucketName, + src_object: S3Object, + dest_bucket: BucketName, + dest_object: S3Object, + ) -> S3StoredObject: + pass + + @abc.abstractmethod + def get_multipart(self, bucket: BucketName, upload_id: S3Multipart) -> S3StoredMultipart: + pass + + @abc.abstractmethod + def remove_multipart(self, bucket: BucketName, s3_multipart: S3Multipart): + pass + + def create_bucket(self, bucket: BucketName): + pass + + def delete_bucket(self, bucket: BucketName): + pass + + def flush(self): + """ + Calling `flush()` should force the `S3ObjectStore` to dump its state to disk, depending on the implementation. + """ + pass + + def close(self): + """ + Closing the `S3ObjectStore` allows freeing resources up (like file descriptors for example) when stopping the + linked provider. + """ + pass + + def reset(self): + """ + Resetting the `S3ObjectStore` will delete all the contained resources. + """ + pass + + +def should_copy_in_place( + src_bucket: BucketName, + src_object: S3Object, + dest_bucket: BucketName, + dest_object: S3Object, +) -> bool: + """ + Helper method to determine if we should use the same underlying fileobject to avoid copying in place for no gain. + :param src_bucket: the source bucket + :param src_object: the source S3Object + :param dest_bucket: the destination bucket + :param dest_object: the destination S3Object + :return: if + """ + if src_bucket != dest_bucket: + return False + + if src_object.key != dest_object.key: + return False + + # if the objects are versioned, we should not copy in place: the new destination + # object will be a new version of the source object, with a different version id (both can be fetched) + if _is_object_versioned(src_object) or _is_object_versioned(dest_object): + return False + + return True + + +def _is_object_versioned(s3_object: S3Object) -> bool: + return s3_object.version_id and s3_object.version_id != "null" diff --git a/localstack-core/localstack/services/s3/storage/ephemeral.py b/localstack-core/localstack/services/s3/storage/ephemeral.py new file mode 100644 index 0000000000000..64fc3440d7996 --- /dev/null +++ b/localstack-core/localstack/services/s3/storage/ephemeral.py @@ -0,0 +1,525 @@ +import base64 +import hashlib +import os +import threading +import time +from collections import defaultdict +from io import BytesIO, UnsupportedOperation +from shutil import rmtree +from tempfile import SpooledTemporaryFile, mkdtemp +from threading import RLock +from typing import IO, Iterator, Literal, Optional, TypedDict + +from readerwriterlock import rwlock + +from localstack.aws.api.s3 import BucketName, MultipartUploadId, PartNumber +from localstack.services.s3.constants import S3_CHUNK_SIZE +from localstack.services.s3.models import S3Multipart, S3Object, S3Part +from localstack.services.s3.utils import ChecksumHash, ObjectRange, get_s3_checksum +from localstack.utils.files import mkdir + +from .core import ( + LimitedStream, + S3ObjectStore, + S3StoredMultipart, + S3StoredObject, + should_copy_in_place, +) + +# max file size for S3 objects kept in memory (500 KB by default) +# TODO: make it configurable +S3_MAX_FILE_SIZE_BYTES = 512 * 1024 + + +class LockedFileMixin: + """Mixin with 2 locks: one lock used to lock an underlying stream position between seek and read, and a readwrite""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # this lock allows us to make `seek` and `read` operation as an atomic one, without an external reader + # modifying the internal position of the stream + self.position_lock = RLock() + # these locks are for the read/write lock issues. No writer should modify the object while a reader is + # currently iterating over it. + # see: + self.readwrite_lock = rwlock.RWLockWrite() + self.internal_last_modified = 0 + + +class LockedSpooledTemporaryFile(LockedFileMixin, SpooledTemporaryFile): + """Creates a SpooledTemporaryFile with locks""" + + def seekable(self) -> bool: + return True + + +class EphemeralS3StoredObject(S3StoredObject): + """ + An Ephemeral S3StoredObject, using LockedSpooledTemporaryFile as its underlying file object. + """ + + file: LockedSpooledTemporaryFile + size: int + _pos: int + etag: Optional[str] + checksum_hash: Optional[ChecksumHash] + _checksum: Optional[str] + _lock: rwlock.Lockable + + def __init__( + self, + s3_object: S3Object | S3Part, + file: LockedSpooledTemporaryFile, + mode: Literal["r", "w"] = "r", + ): + super().__init__(s3_object=s3_object, mode=mode) + self.file = file + self.size = 0 + self._etag = None + self.checksum_hash = None + self._checksum = None + self._pos = 0 + self._lock = ( + self.file.readwrite_lock.gen_wlock() + if mode == "w" + else self.file.readwrite_lock.gen_rlock() + ) + self._lock.acquire() + + def read(self, s: int = -1) -> bytes | None: + """Read at most `s` bytes from the underlying fileobject, and keeps the internal position""" + with self.file.position_lock: + self.file.seek(self._pos) + data = self.file.read(s) + + if not data: + return b"" + + read = len(data) + self._pos += read + + return data + + def seek(self, offset: int, whence: int = 0) -> int: + """ + Set the position of the stream at `offset`, starting depending on `whence`. + :param offset: offset from the position depending on the whence + :param whence: can be 0, 1 or 2 - 0 meaning beginning of stream, 1 current position and 2 end of the stream + :return: the position after seeking, from beginning of the stream + """ + with self.file.position_lock: + self._pos = self.file.seek(offset, whence) + + return self._pos + + def truncate(self, size: int = None) -> int: + """ + Resize the stream to the given size in bytes (or the current position if size is not specified). + The current stream position isn’t changed. This resizing can extend or reduce the current file size. + :param size: size to resize the stream to, or position if not given + :return: the new file size + """ + if self._mode != "w": + raise UnsupportedOperation("S3 object is not in write mode") + + with self.file.position_lock: + truncate = self.file.truncate(size) + self.s3_object.internal_last_modified = self.file.internal_last_modified = ( + time.time_ns() + ) + return truncate + + def write(self, stream: IO[bytes] | "EphemeralS3StoredObject" | LimitedStream) -> int: + """ + Read from the `stream` parameter into the underlying fileobject. This will truncate the fileobject before + writing, effectively copying the stream into the fileobject. While iterating, it will also calculate the MD5 + hash of the stream, and its checksum value if the S3Object has a checksum algorithm set. + This method can directly take an EphemeralS3StoredObject as input, and will use its own locking system to + prevent concurrent write access while iterating over the stream input. + :param stream: can be a regular IO[bytes] or an EphemeralS3StoredObject + :return: number of bytes written + """ + if self._mode != "w": + raise UnsupportedOperation("S3 object is not in write mode") + + if stream is None: + stream = BytesIO() + + if self.s3_object.checksum_algorithm: + self.checksum_hash = get_s3_checksum(self.s3_object.checksum_algorithm) + + file = self.file + with file.position_lock: + file.seek(0) + file.truncate() + + etag = hashlib.md5(usedforsecurity=False) + + while data := stream.read(S3_CHUNK_SIZE): + file.write(data) + etag.update(data) + if self.checksum_hash: + self.checksum_hash.update(data) + + etag = etag.hexdigest() + self.size = self.s3_object.size = file.tell() + self._etag = self.s3_object.etag = etag + self.s3_object.internal_last_modified = self.file.internal_last_modified = ( + time.time_ns() + ) + + self._pos = file.seek(0) + + return self.size + + def append(self, part: "EphemeralS3StoredObject") -> int: + """ + Append the EphemeralS3StoredObject data into the underlying fileobject. Used with Multipart Upload to + assemble parts into the final S3StoredObject. + :param part: EphemeralS3StoredObject + :return: number of written bytes + """ + if self._mode != "w": + raise UnsupportedOperation("S3 object is not in write mode") + + read = 0 + while data := part.read(S3_CHUNK_SIZE): + self.file.write(data) + read += len(data) + + self.size += read + self.s3_object.size = self.size + self.s3_object.internal_last_modified = self.file.internal_last_modified = time.time_ns() + return read + + def close(self): + """We only release the lock, because closing the underlying file object will delete it""" + self._lock.release() + self.closed = True + + @property + def last_modified(self) -> int: + return self.file.internal_last_modified + + @property + def checksum(self) -> Optional[str]: + """ + Return the object checksum base64 encoded, if the S3Object has a checksum algorithm. + If the checksum hasn't been calculated, this method will iterate over the file again to recalculate it. + :return: + """ + if not self.s3_object.checksum_algorithm: + return + if not self.checksum_hash: + # we didn't write or yet calculated the checksum, so calculate with what is in the underlying file + self.checksum_hash = get_s3_checksum(self.s3_object.checksum_algorithm) + original_pos = self._pos + self._pos = 0 + while data := self.read(S3_CHUNK_SIZE): + self.checksum_hash.update(data) + + self._pos = original_pos + + if not self._checksum: + self._checksum = base64.b64encode(self.checksum_hash.digest()).decode() + + return self._checksum + + @property + def etag(self) -> str: + if not self._etag: + etag = hashlib.md5(usedforsecurity=False) + original_pos = self._pos + self._pos = 0 + while data := self.read(S3_CHUNK_SIZE): + etag.update(data) + self._pos = original_pos + self._etag = etag.hexdigest() + + return self._etag + + def __iter__(self) -> Iterator[bytes]: + """ + This is mostly used as convenience to directly pass this object to a Werkzeug response object, hiding the + iteration locking logic. + The caller needs to call `close()` once it is done to release the lock. + :return: + """ + while data := self.read(S3_CHUNK_SIZE): + if not data: + return b"" + + yield data + + +class EphemeralS3StoredMultipart(S3StoredMultipart): + upload_dir: str + _s3_store: "EphemeralS3ObjectStore" + parts: dict[PartNumber, LockedSpooledTemporaryFile] + + def __init__( + self, + s3_store: "EphemeralS3ObjectStore", + bucket: BucketName, + s3_multipart: S3Multipart, + upload_dir: str, + ): + super().__init__(s3_store=s3_store, bucket=bucket, s3_multipart=s3_multipart) + self.upload_dir = upload_dir + + def open(self, s3_part: S3Part, mode: Literal["r", "w"] = "r") -> EphemeralS3StoredObject: + """ + Returns an EphemeralS3StoredObject for an S3Part, allowing direct access to the object. This will add a part + into the Multipart collection. We can directly store the EphemeralS3Stored Object in the collection, as S3Part + cannot be accessed/read directly from the API. + :param s3_part: S3Part object + :param mode: opening mode, "read" or "write" + :return: EphemeralS3StoredObject, most often to directly `write` into it. + """ + if not (file := self.parts.get(s3_part.part_number)): + file = LockedSpooledTemporaryFile(dir=self.upload_dir, max_size=S3_MAX_FILE_SIZE_BYTES) + self.parts[s3_part.part_number] = file + + return EphemeralS3StoredObject(s3_part, file, mode=mode) + + def remove_part(self, s3_part: S3Part): + """ + Remove a part from the Multipart collection. + :param s3_part: S3Part + :return: + """ + stored_part_file = self.parts.pop(s3_part.part_number, None) + if stored_part_file: + stored_part_file.close() + + def complete_multipart(self, parts: list[S3Part]) -> None: + """ + Takes a list of parts numbers, and will iterate over it to assemble all parts together into a single + EphemeralS3StoredObject containing all those parts. + :param parts: list of PartNumber + :return: the resulting EphemeralS3StoredObject + """ + with self._s3_store.open( + self.bucket, self.s3_multipart.object, mode="w" + ) as s3_stored_object: + # reset the file to overwrite + s3_stored_object.seek(0) + s3_stored_object.truncate() + for s3_part in parts: + with self.open(s3_part, mode="r") as stored_part: + s3_stored_object.append(stored_part) + + def close(self): + """ + Iterates over all parts of the collection to close them and clean them up. Closing a part will delete it. + :return: + """ + for stored_part_file in self.parts.values(): + stored_part_file.close() + self.parts.clear() + + def copy_from_object( + self, + s3_part: S3Part, + src_bucket: BucketName, + src_s3_object: S3Object, + range_data: Optional[ObjectRange], + ) -> None: + """ + Create and add an EphemeralS3StoredObject to the Multipart collection, with an S3Object as input. This will + take a slice of the S3Object to create a part. + :param s3_part: the part which will contain the S3 Object slice + :param src_bucket: the bucket where the source S3Object resides + :param src_s3_object: the source S3Object + :param range_data: the range data from which the S3Part will copy its data. + :return: the EphemeralS3StoredObject representing the stored part + """ + with ( + self._s3_store.open(src_bucket, src_s3_object, mode="r") as src_stored_object, + self.open(s3_part, mode="w") as stored_part, + ): + if not range_data: + stored_part.write(src_stored_object) + else: + object_slice = LimitedStream(src_stored_object, range_data=range_data) + stored_part.write(object_slice) + + if s3_part.checksum_algorithm: + s3_part.checksum_value = stored_part.checksum + + +class BucketTemporaryFileSystem(TypedDict): + keys: dict[str, LockedSpooledTemporaryFile] + multiparts: dict[MultipartUploadId, EphemeralS3StoredMultipart] + + +class EphemeralS3ObjectStore(S3ObjectStore): + """ + This simulates a filesystem where S3 will store its assets + The structure is the following: + / + keys/ + β”œβ”€ -> fileobj + β”œβ”€ -> fileobj + multiparts/ + β”œβ”€ / + β”‚ β”œβ”€ -> fileobj + β”‚ β”œβ”€ -> fileobj + """ + + root_directory: str + + def __init__(self, root_directory: str = None): + self._filesystem: dict[BucketName, BucketTemporaryFileSystem] = defaultdict( + lambda: {"keys": {}, "multiparts": {}} + ) + # namespace the EphemeralS3ObjectStore artifacts under a single root directory, under gettempdir() if not + # provided + if not root_directory: + root_directory = mkdtemp() + + self.root_directory = root_directory + self._lock_multipart_create = threading.RLock() + + def open( + self, bucket: BucketName, s3_object: S3Object, mode: Literal["r", "w"] = "r" + ) -> EphemeralS3StoredObject: + """ + Returns a EphemeralS3StoredObject from an S3Object, a wrapper around an underlying fileobject underneath, + exposing higher level actions for the provider to interact with. This allows the provider to store data for an + S3Object. + :param bucket: the S3Object bucket + :param s3_object: an S3Object + :param mode: read or write mode for the object to open + :return: EphemeralS3StoredObject + """ + key = self._key_from_s3_object(s3_object) + if not (file := self._get_object_file(bucket, key)): + bucket_tmp_dir = os.path.join(self.root_directory, bucket) + file = LockedSpooledTemporaryFile(dir=bucket_tmp_dir, max_size=S3_MAX_FILE_SIZE_BYTES) + self._filesystem[bucket]["keys"][key] = file + + return EphemeralS3StoredObject(s3_object=s3_object, file=file, mode=mode) + + def remove(self, bucket: BucketName, s3_object: S3Object | list[S3Object]): + """ + Remove the underlying data of an S3Object. + :param bucket: the S3Object bucket + :param s3_object: S3Object to remove. This can also take a list of S3Objects + :return: + """ + if not isinstance(s3_object, list): + s3_object = [s3_object] + + if keys := self._filesystem.get(bucket, {}).get("keys", {}): + for obj in s3_object: + key = self._key_from_s3_object(obj) + file = keys.pop(key, None) + if file: + file.close() + + def copy( + self, + src_bucket: BucketName, + src_object: S3Object, + dest_bucket: BucketName, + dest_object: S3Object, + ) -> EphemeralS3StoredObject: + """ + Copy an S3Object into another one. This will copy the underlying data inside another. + :param src_bucket: the source bucket + :param src_object: the source S3Object + :param dest_bucket: the destination bucket + :param dest_object: the destination S3Object + :return: the destination EphemeralS3StoredObject + """ + # If this is an in-place copy, directly return the EphemeralS3StoredObject of the destination S3Object, no need + # to copy the underlying data except if we are in a versioned bucket. + if should_copy_in_place(src_bucket, src_object, dest_bucket, dest_object): + return self.open(dest_bucket, dest_object, mode="r") + + with self.open(src_bucket, src_object, mode="r") as src_stored_object: + dest_stored_object = self.open(dest_bucket, dest_object, mode="w") + dest_stored_object.write(src_stored_object) + + return dest_stored_object + + def get_multipart( + self, bucket: BucketName, s3_multipart: S3Multipart + ) -> EphemeralS3StoredMultipart: + # We need to lock this block, because we could have concurrent requests trying to access the same multipart + # and both creating it at the same time, returning 2 different entities and overriding one + with self._lock_multipart_create: + if not (multipart := self._get_multipart(bucket, s3_multipart.id)): + upload_dir = self._create_upload_directory(bucket, s3_multipart.id) + multipart = EphemeralS3StoredMultipart(self, bucket, s3_multipart, upload_dir) + self._filesystem[bucket]["multiparts"][s3_multipart.id] = multipart + + return multipart + + def remove_multipart(self, bucket: BucketName, s3_multipart: S3Multipart): + if multiparts := self._filesystem.get(bucket, {}).get("multiparts", {}): + if multipart := multiparts.pop(s3_multipart.id, None): + multipart.close() + self._delete_upload_directory(bucket, s3_multipart.id) + + def create_bucket(self, bucket: BucketName): + mkdir(os.path.join(self.root_directory, bucket)) + + def delete_bucket(self, bucket: BucketName): + if self._filesystem.pop(bucket, None): + rmtree(os.path.join(self.root_directory, bucket)) + + def close(self): + """ + Close the Store and clean up all underlying objects. This will effectively remove all data from the filesystem + and memory. + :return: + """ + for bucket in self._filesystem.values(): + if keys := bucket.get("keys"): + for file in keys.values(): + file.close() + keys.clear() + + if multiparts := bucket.get("multiparts"): + for multipart in multiparts.values(): + multipart.close() + multiparts.clear() + + def reset(self): + self.close() + + @staticmethod + def _key_from_s3_object(s3_object: S3Object) -> str: + return str(hash(f"{s3_object.key}?{s3_object.version_id or 'null'}")) + + def _get_object_file(self, bucket: BucketName, key: str) -> LockedSpooledTemporaryFile | None: + return self._filesystem.get(bucket, {}).get("keys", {}).get(key) + + def _get_multipart(self, bucket: BucketName, upload_key: str) -> S3StoredMultipart | None: + return self._filesystem.get(bucket, {}).get("multiparts", {}).get(upload_key) + + def _create_upload_directory( + self, bucket_name: BucketName, upload_id: MultipartUploadId + ) -> str: + """ + Create a temporary directory inside a bucket, representing a multipart upload, holding its parts + :param bucket_name: the bucket where the multipart upload resides + :param upload_id: the multipart upload id + :return: the full part of the upload, where the parts will live + """ + upload_tmp_dir = os.path.join(self.root_directory, bucket_name, upload_id) + mkdir(upload_tmp_dir) + return upload_tmp_dir + + def _delete_upload_directory(self, bucket_name: BucketName, upload_id: MultipartUploadId): + """ + Delete the temporary directory representing a multipart upload + :param bucket_name: the multipart upload bucket + :param upload_id: the multipart upload id + :return: + """ + upload_tmp_dir = os.path.join(self.root_directory, bucket_name, upload_id) + if upload_tmp_dir: + rmtree(upload_tmp_dir, ignore_errors=True) diff --git a/localstack-core/localstack/services/s3/utils.py b/localstack-core/localstack/services/s3/utils.py new file mode 100644 index 0000000000000..8592de4712594 --- /dev/null +++ b/localstack-core/localstack/services/s3/utils.py @@ -0,0 +1,1066 @@ +import base64 +import codecs +import datetime +import hashlib +import itertools +import logging +import re +import time +import zlib +from enum import StrEnum +from secrets import token_bytes +from typing import Any, Dict, Literal, NamedTuple, Optional, Protocol, Tuple, Union +from urllib import parse as urlparser +from zoneinfo import ZoneInfo + +import xmltodict +from botocore.exceptions import ClientError +from botocore.utils import InvalidArnException + +from localstack import config, constants +from localstack.aws.api import CommonServiceException, RequestContext +from localstack.aws.api.s3 import ( + AccessControlPolicy, + BucketCannedACL, + BucketName, + ChecksumAlgorithm, + ContentMD5, + CopyObjectRequest, + CopySource, + ETag, + GetObjectRequest, + Grant, + Grantee, + HeadObjectRequest, + InvalidArgument, + InvalidRange, + InvalidTag, + LifecycleExpiration, + LifecycleRule, + LifecycleRules, + Metadata, + ObjectCannedACL, + ObjectKey, + ObjectSize, + ObjectVersionId, + Owner, + Permission, + PreconditionFailed, + PutObjectRequest, + SSEKMSKeyId, + TaggingHeader, + TagSet, + UploadPartRequest, +) +from localstack.aws.api.s3 import Type as GranteeType +from localstack.aws.chain import HandlerChain +from localstack.aws.connect import connect_to +from localstack.http import Response +from localstack.services.s3 import checksums +from localstack.services.s3.constants import ( + ALL_USERS_ACL_GRANTEE, + AUTHENTICATED_USERS_ACL_GRANTEE, + CHECKSUM_ALGORITHMS, + LOG_DELIVERY_ACL_GRANTEE, + S3_VIRTUAL_HOST_FORWARDED_HEADER, + SIGNATURE_V2_PARAMS, + SIGNATURE_V4_PARAMS, + SYSTEM_METADATA_SETTABLE_HEADERS, +) +from localstack.services.s3.exceptions import InvalidRequest, MalformedXML +from localstack.utils.aws import arns +from localstack.utils.aws.arns import parse_arn +from localstack.utils.objects import singleton_factory +from localstack.utils.strings import ( + is_base64, + to_bytes, + to_str, +) +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +BUCKET_NAME_REGEX = ( + r"(?=^.{3,63}$)(?!^(\d+\.)+\d+$)" + + r"(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)" +) + +TAG_REGEX = re.compile(r"^[\w\s.:/=+\-@]*$") + + +S3_VIRTUAL_HOSTNAME_REGEX = ( + r"(?P.*).s3.(?P(?:us-gov|us|ap|ca|cn|eu|sa)-[a-z]+-\d)?.*" +) + +_s3_virtual_host_regex = re.compile(S3_VIRTUAL_HOSTNAME_REGEX) + + +RFC1123 = "%a, %d %b %Y %H:%M:%S GMT" +_gmt_zone_info = ZoneInfo("GMT") + + +def s3_response_handler(chain: HandlerChain, context: RequestContext, response: Response): + """ + This response handler is taking care of removing certain headers from S3 responses. + We cannot handle this in the serializer, because the serializer handler calls `Response.update_from`, which does + not allow you to remove headers, only add them. + This handler can delete headers from the response. + """ + # some requests, for example coming frome extensions, are flagged as S3 requests. This check confirms that it is + # indeed truly an S3 request by checking if it parsed properly as an S3 operation + if not context.service_operation: + return + + # if AWS returns 204, it will not return a body, Content-Length and Content-Type + # the web server is already taking care of deleting the body, but it's more explicit to remove it here + if response.status_code == 204: + response.data = b"" + response.headers.pop("Content-Type", None) + response.headers.pop("Content-Length", None) + + elif ( + response.status_code == 200 + and context.request.method == "PUT" + and response.headers.get("Content-Length") in (0, None) + ): + # AWS does not return a Content-Type if the Content-Length is 0 + response.headers.pop("Content-Type", None) + + +def get_owner_for_account_id(account_id: str): + """ + This method returns the S3 Owner from the account id. for now, this is hardcoded as it was in moto, but we can then + extend it to return different values depending on the account ID + See https://docs.aws.amazon.com/AmazonS3/latest/API/API_Owner.html + :param account_id: the owner account id + :return: the Owner object containing the DisplayName and owner ID + """ + return Owner( + DisplayName="webfile", # only in certain regions, see above + ID="75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a", + ) + + +def extract_bucket_key_version_id_from_copy_source( + copy_source: CopySource, +) -> tuple[BucketName, ObjectKey, Optional[ObjectVersionId]]: + """ + Utility to parse bucket name, object key and optionally its versionId. It accepts the CopySource format: + - ?versionId=, used for example in CopySource for CopyObject + :param copy_source: the S3 CopySource to parse + :return: parsed BucketName, ObjectKey and optionally VersionId + """ + copy_source_parsed = urlparser.urlparse(copy_source) + # we need to manually replace `+` character with a space character before URL decoding, because different languages + # don't encode their URL the same way (%20 vs +), and Python doesn't unquote + into a space char + parsed_path = urlparser.unquote(copy_source_parsed.path.replace("+", " ")).lstrip("/") + + if "/" not in parsed_path: + raise InvalidArgument( + "Invalid copy source object key", + ArgumentName="x-amz-copy-source", + ArgumentValue="x-amz-copy-source", + ) + src_bucket, src_key = parsed_path.split("/", 1) + src_version_id = urlparser.parse_qs(copy_source_parsed.query).get("versionId", [None])[0] + + return src_bucket, src_key, src_version_id + + +class ChecksumHash(Protocol): + """ + This Protocol allows proper typing for different kind of hash used by S3 (hashlib.shaX, zlib.crc32 from + S3CRC32Checksum, and botocore CrtCrc32cChecksum). + """ + + def digest(self) -> bytes: ... + + def update(self, value: bytes): ... + + +def get_s3_checksum_algorithm_from_request( + request: PutObjectRequest | UploadPartRequest, +) -> ChecksumAlgorithm | None: + checksum_algorithm: list[ChecksumAlgorithm] = [ + algo for algo in CHECKSUM_ALGORITHMS if request.get(f"Checksum{algo}") + ] + if not checksum_algorithm: + return None + + if len(checksum_algorithm) > 1: + raise InvalidRequest( + "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed." + ) + + return checksum_algorithm[0] + + +def get_s3_checksum_algorithm_from_trailing_headers( + trailing_headers: str, +) -> ChecksumAlgorithm | None: + checksum_algorithm: list[ChecksumAlgorithm] = [ + algo for algo in CHECKSUM_ALGORITHMS if f"x-amz-checksum-{algo.lower()}" in trailing_headers + ] + if not checksum_algorithm: + return None + + if len(checksum_algorithm) > 1: + raise InvalidRequest( + "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed." + ) + + return checksum_algorithm[0] + + +def get_s3_checksum(algorithm) -> ChecksumHash: + match algorithm: + case ChecksumAlgorithm.CRC32: + return S3CRC32Checksum() + + case ChecksumAlgorithm.CRC32C: + from botocore.httpchecksum import CrtCrc32cChecksum + + return CrtCrc32cChecksum() + + case ChecksumAlgorithm.CRC64NVME: + from botocore.httpchecksum import CrtCrc64NvmeChecksum + + return CrtCrc64NvmeChecksum() + + case ChecksumAlgorithm.SHA1: + return hashlib.sha1(usedforsecurity=False) + + case ChecksumAlgorithm.SHA256: + return hashlib.sha256(usedforsecurity=False) + + case _: + # TODO: check proper error? for now validated client side, need to check server response + raise InvalidRequest("The value specified in the x-amz-trailer header is not supported") + + +class S3CRC32Checksum: + """Implements a unified way of using zlib.crc32 compatible with hashlib.sha and botocore CrtCrc32cChecksum""" + + __slots__ = ["checksum"] + + def __init__(self): + self.checksum = zlib.crc32(b"") + + def update(self, value: bytes): + self.checksum = zlib.crc32(value, self.checksum) + + def digest(self) -> bytes: + return self.checksum.to_bytes(4, "big") + + +class CombinedCrcHash: + def __init__(self, checksum_type: ChecksumAlgorithm): + match checksum_type: + case ChecksumAlgorithm.CRC32: + func = checksums.combine_crc32 + case ChecksumAlgorithm.CRC32C: + func = checksums.combine_crc32c + case ChecksumAlgorithm.CRC64NVME: + func = checksums.combine_crc64_nvme + case _: + raise ValueError("You cannot combine SHA based checksums") + + self.combine_function = func + self.checksum = b"" + + def combine(self, value: bytes, object_len: int): + if not self.checksum: + self.checksum = value + return + + self.checksum = self.combine_function(self.checksum, value, object_len) + + def digest(self): + return self.checksum + + +class ObjectRange(NamedTuple): + """ + NamedTuple representing a parsed Range header with the requested S3 object size + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range + """ + + content_range: str # the original Range header + content_length: int # the full requested object size + begin: int # the start of range + end: int # the end of the end + + +def parse_range_header(range_header: str, object_size: int) -> ObjectRange | None: + """ + Takes a Range header, and returns a dataclass containing the necessary information to return only a slice of an + S3 object. If the range header is invalid, we return None so that the request is treated as a regular request. + :param range_header: a Range header + :param object_size: the requested S3 object total size + :return: ObjectRange or None if the Range header is invalid + """ + last = object_size - 1 + try: + _, rspec = range_header.split("=") + except ValueError: + return None + if "," in rspec: + return None + + try: + begin, end = [int(i) if i else None for i in rspec.split("-")] + except ValueError: + # if we can't parse the Range header, S3 just treat the request as a non-range request + return None + + if (begin is None and end == 0) or (begin is not None and begin > last): + raise InvalidRange( + "The requested range is not satisfiable", + ActualObjectSize=str(object_size), + RangeRequested=range_header, + ) + + if begin is not None: # byte range + end = last if end is None else min(end, last) + elif end is not None: # suffix byte range + begin = object_size - min(end, object_size) + end = last + else: + # Treat as non-range request + return None + + if begin > min(end, last): + # Treat as non-range request if after the logic is applied + return None + + return ObjectRange( + content_range=f"bytes {begin}-{end}/{object_size}", + content_length=end - begin + 1, + begin=begin, + end=end, + ) + + +def parse_copy_source_range_header(copy_source_range: str, object_size: int) -> ObjectRange: + """ + Takes a CopySourceRange parameter, and returns a dataclass containing the necessary information to return only a slice of an + S3 object. The validation is much stricter than `parse_range_header` + :param copy_source_range: a CopySourceRange parameter for UploadCopyPart + :param object_size: the requested S3 object total size + :raises InvalidArgument if the CopySourceRanger parameter does not follow validation + :return: ObjectRange + """ + last = object_size - 1 + try: + _, rspec = copy_source_range.split("=") + except ValueError: + raise InvalidArgument( + "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy", + ArgumentName="x-amz-copy-source-range", + ArgumentValue=copy_source_range, + ) + if "," in rspec: + raise InvalidArgument( + "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy", + ArgumentName="x-amz-copy-source-range", + ArgumentValue=copy_source_range, + ) + + try: + begin, end = [int(i) if i else None for i in rspec.split("-")] + except ValueError: + # if we can't parse the Range header, S3 just treat the request as a non-range request + raise InvalidArgument( + "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy", + ArgumentName="x-amz-copy-source-range", + ArgumentValue=copy_source_range, + ) + + if begin is None or end is None or begin > end: + raise InvalidArgument( + "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy", + ArgumentName="x-amz-copy-source-range", + ArgumentValue=copy_source_range, + ) + + if begin > last: + # Treat as non-range request if after the logic is applied + raise InvalidRequest( + "The specified copy range is invalid for the source object size", + ) + elif end > last: + raise InvalidArgument( + f"Range specified is not valid for source object of size: {object_size}", + ArgumentName="x-amz-copy-source-range", + ArgumentValue=copy_source_range, + ) + + return ObjectRange( + content_range=f"bytes {begin}-{end}/{object_size}", + content_length=end - begin + 1, + begin=begin, + end=end, + ) + + +def get_full_default_bucket_location(bucket_name: BucketName) -> str: + host_definition = localstack_host() + if host_definition.host != constants.LOCALHOST_HOSTNAME: + # the user has customised their LocalStack hostname, and may not support subdomains. + # Return the location in path form. + return f"{config.get_protocol()}://{host_definition.host_and_port()}/{bucket_name}/" + else: + return f"{config.get_protocol()}://{bucket_name}.s3.{host_definition.host_and_port()}/" + + +def etag_to_base_64_content_md5(etag: ETag) -> str: + """ + Convert an ETag, representing a MD5 hexdigest (might be quoted), to its base64 encoded representation + :param etag: an ETag, might be quoted + :return: the base64 value + """ + # get the bytes digest from the hexdigest + byte_digest = codecs.decode(to_bytes(etag.strip('"')), "hex") + return to_str(base64.b64encode(byte_digest)) + + +def base_64_content_md5_to_etag(content_md5: ContentMD5) -> str | None: + """ + Convert a ContentMD5 header, representing a base64 encoded representation of a MD5 binary digest to its ETag value, + hex encoded + :param content_md5: a ContentMD5 header, base64 encoded + :return: the ETag value, hex coded MD5 digest, or None if the input is not valid b64 or the representation of a MD5 + hash + """ + if not is_base64(content_md5): + return None + # get the hexdigest from the bytes digest + byte_digest = base64.b64decode(content_md5) + hex_digest = to_str(codecs.encode(byte_digest, "hex")) + if len(hex_digest) != 32: + return None + + return hex_digest + + +def is_presigned_url_request(context: RequestContext) -> bool: + """ + Detects pre-signed URL from query string parameters + Return True if any kind of presigned URL query string parameter is encountered + :param context: the request context from the handler chain + """ + # Detecting pre-sign url and checking signature + query_parameters = context.request.args + return any(p in query_parameters for p in SIGNATURE_V2_PARAMS) or any( + p in query_parameters for p in SIGNATURE_V4_PARAMS + ) + + +def is_bucket_name_valid(bucket_name: str) -> bool: + """ + ref. https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html + """ + return True if re.match(BUCKET_NAME_REGEX, bucket_name) else False + + +def get_permission_header_name(permission: Permission) -> str: + return f"x-amz-grant-{permission.replace('_', '-').lower()}" + + +def get_permission_from_header(capitalized_field: str) -> Permission: + headers_parts = [part.upper() for part in re.split(r"([A-Z][a-z]+)", capitalized_field) if part] + return "_".join(headers_parts[1:]) + + +def is_valid_canonical_id(canonical_id: str) -> bool: + """ + Validate that the string is a hex string with 64 char + """ + try: + return int(canonical_id, 16) and len(canonical_id) == 64 + except ValueError: + return False + + +def uses_host_addressing(headers: Dict[str, str]) -> str | None: + """ + Determines if the request is targeting S3 with virtual host addressing + :param headers: the request headers + :return: if the request targets S3 with virtual host addressing, returns the bucket name else None + """ + host = headers.get("host", "") + + # try to extract the bucket from the hostname (the "in" check is a minor optimization, as the regex is very greedy) + if ".s3." in host and ( + (match := _s3_virtual_host_regex.match(host)) and (bucket_name := match.group("bucket")) + ): + return bucket_name + + +def get_class_attrs_from_spec_class(spec_class: type[StrEnum]) -> set[str]: + return {str(spec) for spec in spec_class} + + +def get_system_metadata_from_request(request: dict) -> Metadata: + metadata: Metadata = {} + + for system_metadata_field in SYSTEM_METADATA_SETTABLE_HEADERS: + if field_value := request.get(system_metadata_field): + metadata[system_metadata_field] = field_value + + return metadata + + +def forwarded_from_virtual_host_addressed_request(headers: dict[str, str]) -> bool: + """ + Determines if the request was forwarded from a v-host addressing style into a path one + """ + # we can assume that the host header we are receiving here is actually the header we originally received + # from the client (because the edge service is forwarding the request in memory) + return S3_VIRTUAL_HOST_FORWARDED_HEADER in headers + + +def extract_bucket_name_and_key_from_headers_and_path( + headers: dict[str, str], path: str +) -> tuple[Optional[str], Optional[str]]: + """ + Extract the bucket name and the object key from a request headers and path. This works with both virtual host + and path style requests. + :param headers: the request headers, used to get the Host + :param path: the request path + :return: if found, the bucket name and object key + """ + bucket_name = None + object_key = None + host = headers.get("host", "") + if ".s3" in host: + vhost_match = _s3_virtual_host_regex.match(host) + if vhost_match and vhost_match.group("bucket"): + bucket_name = vhost_match.group("bucket") or None + split = path.split("/", maxsplit=1) + if len(split) > 1 and split[1]: + object_key = split[1] + else: + path_without_params = path.partition("?")[0] + split = path_without_params.split("/", maxsplit=2) + bucket_name = split[1] or None + if len(split) > 2: + object_key = split[2] + + return bucket_name, object_key + + +def normalize_bucket_name(bucket_name): + bucket_name = bucket_name or "" + bucket_name = bucket_name.lower() + return bucket_name + + +def get_bucket_and_key_from_s3_uri(s3_uri: str) -> Tuple[str, str]: + """ + Extracts the bucket name and key from s3 uri + """ + output_bucket, _, output_key = s3_uri.removeprefix("s3://").partition("/") + return output_bucket, output_key + + +def get_bucket_and_key_from_presign_url(presign_url: str) -> Tuple[str, str]: + """ + Extracts the bucket name and key from s3 presign url + """ + parsed_url = urlparser.urlparse(presign_url) + bucket = parsed_url.path.split("/")[1] + key = "/".join(parsed_url.path.split("/")[2:]).split("?")[0] + return bucket, key + + +def _create_invalid_argument_exc( + message: Union[str, None], name: str, value: str, host_id: str = None +) -> InvalidArgument: + ex = InvalidArgument(message) + ex.ArgumentName = name + ex.ArgumentValue = value + if host_id: + ex.HostId = host_id + return ex + + +def capitalize_header_name_from_snake_case(header_name: str) -> str: + return "-".join([part.capitalize() for part in header_name.split("-")]) + + +def get_kms_key_arn(kms_key: str, account_id: str, bucket_region: str) -> Optional[str]: + """ + In S3, the KMS key can be passed as a KeyId or a KeyArn. This method allows to always get the KeyArn from either. + It can also validate if the key is in the same region, and raise an exception. + :param kms_key: the KMS key id or ARN + :param account_id: the bucket account id + :param bucket_region: the bucket region + :raise KMS.NotFoundException if the key is not in the same region + :return: the key ARN if found and enabled + """ + if not kms_key: + return None + try: + parsed_arn = parse_arn(kms_key) + key_region = parsed_arn["region"] + # the KMS key should be in the same region as the bucket, we can raise an exception without calling KMS + if bucket_region and key_region != bucket_region: + raise CommonServiceException( + code="KMS.NotFoundException", message=f"Invalid arn {key_region}" + ) + + except InvalidArnException: + # if it fails, the passed ID is a UUID with no region data + key_id = kms_key + # recreate the ARN manually with the bucket region and bucket owner + # if the KMS key is cross-account, user should provide an ARN and not a KeyId + kms_key = arns.kms_key_arn(key_id=key_id, account_id=account_id, region_name=bucket_region) + + return kms_key + + +# TODO: replace Any by a replacement for S3Bucket, some kind of defined type? +def validate_kms_key_id(kms_key: str, bucket: Any) -> None: + """ + Validate that the KMS key used to encrypt the object is valid + :param kms_key: the KMS key id or ARN + :param bucket: the targeted bucket + :raise KMS.DisabledException if the key is disabled + :raise KMS.NotFoundException if the key is not in the same region or does not exist + :return: the key ARN if found and enabled + """ + if hasattr(bucket, "region_name"): + bucket_region = bucket.region_name + else: + bucket_region = bucket.bucket_region + + if hasattr(bucket, "account_id"): + bucket_account_id = bucket.account_id + else: + bucket_account_id = bucket.bucket_account_id + + kms_key_arn = get_kms_key_arn(kms_key, bucket_account_id, bucket_region) + + # the KMS key should be in the same region as the bucket, create the client in the bucket region + kms_client = connect_to(region_name=bucket_region).kms + try: + key = kms_client.describe_key(KeyId=kms_key_arn) + if not key["KeyMetadata"]["Enabled"]: + if key["KeyMetadata"]["KeyState"] == "PendingDeletion": + raise CommonServiceException( + code="KMS.KMSInvalidStateException", + message=f"{key['KeyMetadata']['Arn']} is pending deletion.", + ) + raise CommonServiceException( + code="KMS.DisabledException", message=f"{key['KeyMetadata']['Arn']} is disabled." + ) + + except ClientError as e: + if e.response["Error"]["Code"] == "NotFoundException": + raise CommonServiceException( + code="KMS.NotFoundException", message=e.response["Error"]["Message"] + ) + raise + + +def create_s3_kms_managed_key_for_region(account_id: str, region_name: str) -> SSEKMSKeyId: + kms_client = connect_to(aws_access_key_id=account_id, region_name=region_name).kms + key = kms_client.create_key( + Description="Default key that protects my S3 objects when no other key is defined" + ) + + return key["KeyMetadata"]["Arn"] + + +def rfc_1123_datetime(src: datetime.datetime) -> str: + return src.strftime(RFC1123) + + +def str_to_rfc_1123_datetime(value: str) -> datetime.datetime: + return datetime.datetime.strptime(value, RFC1123).replace(tzinfo=_gmt_zone_info) + + +def add_expiration_days_to_datetime(user_datatime: datetime.datetime, exp_days: int) -> str: + """ + This adds expiration days to a datetime, rounding to the next day at midnight UTC. + :param user_datatime: datetime object + :param exp_days: provided days + :return: return a datetime object, rounded to midnight, in string formatted to rfc_1123 + """ + rounded_datetime = user_datatime.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + datetime.timedelta(days=exp_days + 1) + + return rfc_1123_datetime(rounded_datetime) + + +def serialize_expiration_header( + rule_id: str, lifecycle_exp: LifecycleExpiration, last_modified: datetime.datetime +): + if exp_days := lifecycle_exp.get("Days"): + # AWS round to the next day at midnight UTC + exp_date = add_expiration_days_to_datetime(last_modified, exp_days) + else: + exp_date = rfc_1123_datetime(lifecycle_exp["Date"]) + + return f'expiry-date="{exp_date}", rule-id="{rule_id}"' + + +def get_lifecycle_rule_from_object( + lifecycle_conf_rules: LifecycleRules, + object_key: ObjectKey, + size: ObjectSize, + object_tags: dict[str, str], +) -> LifecycleRule: + for rule in lifecycle_conf_rules: + if not (expiration := rule.get("Expiration")) or "ExpiredObjectDeleteMarker" in expiration: + continue + + if not (rule_filter := rule.get("Filter")): + return rule + + if and_rules := rule_filter.get("And"): + if all( + _match_lifecycle_filter(key, value, object_key, size, object_tags) + for key, value in and_rules.items() + ): + return rule + + if any( + _match_lifecycle_filter(key, value, object_key, size, object_tags) + for key, value in rule_filter.items() + ): + # after validation, we can only one of `Prefix`, `Tag`, `ObjectSizeGreaterThan` or `ObjectSizeLessThan` in + # the dict. Instead of manually checking, we can iterate of the only key and try to match it + return rule + + +def _match_lifecycle_filter( + filter_key: str, + filter_value: str | int | dict[str, str], + object_key: ObjectKey, + size: ObjectSize, + object_tags: dict[str, str], +): + match filter_key: + case "Prefix": + return object_key.startswith(filter_value) + case "Tag": + return object_tags and object_tags.get(filter_value.get("Key")) == filter_value.get( + "Value" + ) + case "ObjectSizeGreaterThan": + return size > filter_value + case "ObjectSizeLessThan": + return size < filter_value + case "Tags": # this is inside the `And` field + return object_tags and all( + object_tags.get(tag.get("Key")) == tag.get("Value") for tag in filter_value + ) + + +def parse_expiration_header( + expiration_header: str, +) -> tuple[Optional[datetime.datetime], Optional[str]]: + try: + header_values = dict( + (p.strip('"') for p in v.split("=")) for v in expiration_header.split('", ') + ) + expiration_date = str_to_rfc_1123_datetime(header_values["expiry-date"]) + return expiration_date, header_values["rule-id"] + + except (IndexError, ValueError, KeyError): + return None, None + + +def validate_dict_fields(data: dict, required_fields: set, optional_fields: set = None): + """ + Validate whether the `data` dict contains at least the required fields and not more than the union of the required + and optional fields + TODO: we could pass the TypedDict to also use its required/optional properties, but it could be sensitive to + mistake/changes in the specs and not always right + :param data: the dict we want to validate + :param required_fields: a set containing the required fields + :param optional_fields: a set containing the optional fields + :return: bool, whether the dict is valid or not + """ + if optional_fields is None: + optional_fields = set() + return (set_fields := set(data)) >= required_fields and set_fields <= ( + required_fields | optional_fields + ) + + +def parse_tagging_header(tagging_header: TaggingHeader) -> dict: + try: + parsed_tags = urlparser.parse_qs(tagging_header, keep_blank_values=True) + tags: dict[str, str] = {} + for key, val in parsed_tags.items(): + if len(val) != 1 or not TAG_REGEX.match(key) or not TAG_REGEX.match(val[0]): + raise InvalidArgument( + "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates.", + ArgumentName="x-amz-tagging", + ArgumentValue=tagging_header, + ) + elif key.startswith("aws:"): + raise + tags[key] = val[0] + return tags + + except ValueError: + raise InvalidArgument( + "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates.", + ArgumentName="x-amz-tagging", + ArgumentValue=tagging_header, + ) + + +def validate_tag_set(tag_set: TagSet, type_set: Literal["bucket", "object"] = "bucket"): + keys = set() + for tag in tag_set: + if set(tag) != {"Key", "Value"}: + raise MalformedXML() + + key = tag["Key"] + if key in keys: + raise InvalidTag( + "Cannot provide multiple Tags with the same key", + TagKey=key, + ) + + if key.startswith("aws:"): + if type_set == "bucket": + message = "System tags cannot be added/updated by requester" + else: + message = "Your TagKey cannot be prefixed with aws:" + raise InvalidTag( + message, + TagKey=key, + ) + + if not TAG_REGEX.match(key): + raise InvalidTag( + "The TagKey you have provided is invalid", + TagKey=key, + ) + elif not TAG_REGEX.match(tag["Value"]): + raise InvalidTag( + "The TagValue you have provided is invalid", TagKey=key, TagValue=tag["Value"] + ) + + keys.add(key) + + +def get_unique_key_id( + bucket: BucketName, object_key: ObjectKey, version_id: ObjectVersionId +) -> str: + return f"{bucket}/{object_key}/{version_id or 'null'}" + + +def get_retention_from_now(days: int = None, years: int = None) -> datetime.datetime: + """ + This calculates a retention date from now, adding days or years to it + :param days: provided days + :param years: provided years, exclusive with days + :return: return a datetime object + """ + if not days and not years: + raise ValueError("Either 'days' or 'years' needs to be provided") + now = datetime.datetime.now(tz=_gmt_zone_info) + if days: + retention = now + datetime.timedelta(days=days) + else: + retention = now.replace(year=now.year + years) + + return retention + + +def get_failed_precondition_copy_source( + request: CopyObjectRequest, last_modified: datetime.datetime, etag: ETag +) -> Optional[str]: + """ + Validate if the source object LastModified and ETag matches a precondition, and if it does, return the failed + precondition + # see https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html + :param request: the CopyObjectRequest + :param last_modified: source object LastModified + :param etag: source object ETag + :return str: the failed precondition to raise + """ + if (cs_if_match := request.get("CopySourceIfMatch")) and etag.strip('"') != cs_if_match.strip( + '"' + ): + return "x-amz-copy-source-If-Match" + + elif ( + cs_if_unmodified_since := request.get("CopySourceIfUnmodifiedSince") + ) and last_modified > cs_if_unmodified_since: + return "x-amz-copy-source-If-Unmodified-Since" + + elif (cs_if_none_match := request.get("CopySourceIfNoneMatch")) and etag.strip( + '"' + ) == cs_if_none_match.strip('"'): + return "x-amz-copy-source-If-None-Match" + + elif ( + cs_if_modified_since := request.get("CopySourceIfModifiedSince") + ) and last_modified < cs_if_modified_since < datetime.datetime.now(tz=_gmt_zone_info): + return "x-amz-copy-source-If-Modified-Since" + + +def validate_failed_precondition( + request: GetObjectRequest | HeadObjectRequest, last_modified: datetime.datetime, etag: ETag +) -> None: + """ + Validate if the object LastModified and ETag matches a precondition, and if it does, return the failed + precondition + :param request: the GetObjectRequest or HeadObjectRequest + :param last_modified: S3 object LastModified + :param etag: S3 object ETag + :raises PreconditionFailed + :raises NotModified, 304 with an empty body + """ + precondition_failed = None + # last_modified needs to be rounded to a second so that strict equality can be enforced from a RFC1123 header + last_modified = last_modified.replace(microsecond=0) + if (if_match := request.get("IfMatch")) and etag != if_match.strip('"'): + precondition_failed = "If-Match" + + elif ( + if_unmodified_since := request.get("IfUnmodifiedSince") + ) and last_modified > if_unmodified_since: + precondition_failed = "If-Unmodified-Since" + + if precondition_failed: + raise PreconditionFailed( + "At least one of the pre-conditions you specified did not hold", + Condition=precondition_failed, + ) + + if ((if_none_match := request.get("IfNoneMatch")) and etag == if_none_match.strip('"')) or ( + (if_modified_since := request.get("IfModifiedSince")) + and last_modified <= if_modified_since < datetime.datetime.now(tz=_gmt_zone_info) + ): + raise CommonServiceException( + message="Not Modified", + code="NotModified", + status_code=304, + ) + + +def get_canned_acl( + canned_acl: BucketCannedACL | ObjectCannedACL, owner: Owner +) -> AccessControlPolicy: + """ + Return the proper Owner and Grants from a CannedACL + See https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl + :param canned_acl: an S3 CannedACL + :param owner: the current owner of the bucket or object + :return: an AccessControlPolicy containing the Grants and Owner + """ + owner_grantee = Grantee(**owner, Type=GranteeType.CanonicalUser) + grants = [Grant(Grantee=owner_grantee, Permission=Permission.FULL_CONTROL)] + + match canned_acl: + case ObjectCannedACL.private: + pass # no other permissions + case ObjectCannedACL.public_read: + grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.READ)) + + case ObjectCannedACL.public_read_write: + grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.READ)) + grants.append(Grant(Grantee=ALL_USERS_ACL_GRANTEE, Permission=Permission.WRITE)) + case ObjectCannedACL.authenticated_read: + grants.append( + Grant(Grantee=AUTHENTICATED_USERS_ACL_GRANTEE, Permission=Permission.READ) + ) + case ObjectCannedACL.bucket_owner_read: + pass # TODO: bucket owner ACL + case ObjectCannedACL.bucket_owner_full_control: + pass # TODO: bucket owner ACL + case ObjectCannedACL.aws_exec_read: + pass # TODO: bucket owner, EC2 Read + case BucketCannedACL.log_delivery_write: + grants.append(Grant(Grantee=LOG_DELIVERY_ACL_GRANTEE, Permission=Permission.READ_ACP)) + grants.append(Grant(Grantee=LOG_DELIVERY_ACL_GRANTEE, Permission=Permission.WRITE)) + + return AccessControlPolicy(Owner=owner, Grants=grants) + + +def create_redirect_for_post_request( + base_redirect: str, bucket: BucketName, object_key: ObjectKey, etag: ETag +): + """ + POST requests can redirect if successful. It will take the URL provided and append query string parameters + (key, bucket and ETag). It needs to be a full URL. + :param base_redirect: the URL provided for redirection + :param bucket: bucket name + :param object_key: object key + :param etag: key ETag + :return: the URL provided with the new appended query string parameters + """ + parts = urlparser.urlparse(base_redirect) + if not parts.netloc: + raise ValueError("The provided URL is not valid") + queryargs = urlparser.parse_qs(parts.query) + queryargs["key"] = [object_key] + queryargs["bucket"] = [bucket] + queryargs["etag"] = [etag] + redirect_queryargs = urlparser.urlencode(queryargs, doseq=True) + newparts = ( + parts.scheme, + parts.netloc, + parts.path, + parts.params, + redirect_queryargs, + parts.fragment, + ) + return urlparser.urlunparse(newparts) + + +def parse_post_object_tagging_xml(tagging: str) -> Optional[dict]: + try: + tag_set = {} + tags = xmltodict.parse(tagging) + xml_tags = tags.get("Tagging", {}).get("TagSet", {}).get("Tag", []) + if not xml_tags: + # if the Tagging does not respect the schema, just return + return + if not isinstance(xml_tags, list): + xml_tags = [xml_tags] + for tag in xml_tags: + tag_set[tag["Key"]] = tag["Value"] + + return tag_set + + except Exception: + raise MalformedXML() + + +def generate_safe_version_id() -> str: + """ + Generate a safe version id for XML rendering. + VersionId cannot have `-` in it, as it fails in XML + Combine an ever-increasing part in the 8 first characters, and a random element. + We need the sequence part in order to properly implement pagination around ListObjectVersions. + By prefixing the version-id with a global increasing number, we can sort the versions + :return: an S3 VersionId containing a timestamp part in the first 8 characters + """ + tok = next(global_version_id_sequence()).to_bytes(length=6) + token_bytes(18) + return base64.b64encode(tok, altchars=b"._").rstrip(b"=").decode("ascii") + + +@singleton_factory +def global_version_id_sequence(): + start = int(time.time() * 1000) + # itertools.count is thread safe over the GIL since its getAndIncrement operation is a single python bytecode op + return itertools.count(start) + + +def is_version_older_than_other(version_id: str, other: str): + """ + Compare the sequence part of a VersionId against the sequence part of a VersionIdMarker. Used for pagination + See `generate_safe_version_id` + """ + return base64.b64decode(version_id, altchars=b"._") < base64.b64decode(other, altchars=b"._") diff --git a/localstack-core/localstack/services/s3/validation.py b/localstack-core/localstack/services/s3/validation.py new file mode 100644 index 0000000000000..884b9f6cd11ba --- /dev/null +++ b/localstack-core/localstack/services/s3/validation.py @@ -0,0 +1,508 @@ +import base64 +import datetime +import hashlib +from zoneinfo import ZoneInfo + +from botocore.utils import InvalidArnException + +from localstack.aws.api import CommonServiceException +from localstack.aws.api.s3 import ( + AccessControlPolicy, + AnalyticsConfiguration, + AnalyticsId, + BucketCannedACL, + BucketLifecycleConfiguration, + BucketName, + ChecksumAlgorithm, + CORSConfiguration, + Grant, + Grantee, + Grants, + IntelligentTieringConfiguration, + IntelligentTieringId, + InvalidArgument, + InvalidBucketName, + InvalidEncryptionAlgorithmError, + InventoryConfiguration, + InventoryId, + KeyTooLongError, + ObjectCannedACL, + Permission, + ServerSideEncryption, + SSECustomerAlgorithm, + SSECustomerKey, + SSECustomerKeyMD5, + WebsiteConfiguration, +) +from localstack.aws.api.s3 import Type as GranteeType +from localstack.services.s3 import constants as s3_constants +from localstack.services.s3.exceptions import InvalidRequest, MalformedACLError, MalformedXML +from localstack.services.s3.utils import ( + _create_invalid_argument_exc, + get_class_attrs_from_spec_class, + get_permission_header_name, + is_bucket_name_valid, + is_valid_canonical_id, + validate_dict_fields, +) +from localstack.utils.aws import arns +from localstack.utils.strings import to_bytes + +# https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl +# bucket-owner-read + bucket-owner-full-control are allowed, but ignored for buckets +VALID_CANNED_ACLS = get_class_attrs_from_spec_class( + BucketCannedACL +) | get_class_attrs_from_spec_class(ObjectCannedACL) + + +def validate_bucket_analytics_configuration( + id: AnalyticsId, analytics_configuration: AnalyticsConfiguration +) -> None: + if id != analytics_configuration.get("Id"): + raise MalformedXML( + "The XML you provided was not well-formed or did not validate against our published schema" + ) + + +def validate_bucket_intelligent_tiering_configuration( + id: IntelligentTieringId, intelligent_tiering_configuration: IntelligentTieringConfiguration +) -> None: + if id != intelligent_tiering_configuration.get("Id"): + raise MalformedXML( + "The XML you provided was not well-formed or did not validate against our published schema" + ) + + +def validate_bucket_name(bucket: BucketName) -> None: + """ + Validate s3 bucket name based on the documentation + ref. https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html + """ + if not is_bucket_name_valid(bucket_name=bucket): + raise InvalidBucketName("The specified bucket is not valid.", BucketName=bucket) + + +def validate_canned_acl(canned_acl: str) -> None: + """ + Validate the canned ACL value, or raise an Exception + """ + if canned_acl and canned_acl not in VALID_CANNED_ACLS: + ex = _create_invalid_argument_exc(None, "x-amz-acl", canned_acl) + raise ex + + +def parse_grants_in_headers(permission: Permission, grantees: str) -> Grants: + splitted_grantees = [grantee.strip() for grantee in grantees.split(",")] + grants = [] + for seralized_grantee in splitted_grantees: + grantee_type, grantee_id = seralized_grantee.split("=") + grantee_id = grantee_id.strip('"') + if grantee_type not in ("uri", "id", "emailAddress"): + ex = _create_invalid_argument_exc( + "Argument format not recognized", + get_permission_header_name(permission), + seralized_grantee, + ) + raise ex + elif grantee_type == "uri": + if grantee_id not in s3_constants.VALID_ACL_PREDEFINED_GROUPS: + ex = _create_invalid_argument_exc("Invalid group uri", "uri", grantee_id) + raise ex + grantee = Grantee( + Type=GranteeType.Group, + URI=grantee_id, + ) + + elif grantee_type == "id": + if not is_valid_canonical_id(grantee_id): + ex = _create_invalid_argument_exc("Invalid id", "id", grantee_id) + raise ex + grantee = Grantee( + Type=GranteeType.CanonicalUser, + ID=grantee_id, + DisplayName="webfile", # TODO: only in certain regions + ) + + else: + # TODO: check validation here + grantee = Grantee( + Type=GranteeType.AmazonCustomerByEmail, + EmailAddress=grantee_id, + ) + grants.append(Grant(Permission=permission, Grantee=grantee)) + + return grants + + +def validate_acl_acp(acp: AccessControlPolicy) -> None: + if acp is None or "Owner" not in acp or "Grants" not in acp: + raise MalformedACLError( + "The XML you provided was not well-formed or did not validate against our published schema" + ) + + if not is_valid_canonical_id(owner_id := acp["Owner"].get("ID", "")): + ex = _create_invalid_argument_exc("Invalid id", "CanonicalUser/ID", owner_id) + raise ex + + for grant in acp["Grants"]: + if grant.get("Permission") not in s3_constants.VALID_GRANTEE_PERMISSIONS: + raise MalformedACLError( + "The XML you provided was not well-formed or did not validate against our published schema" + ) + + grantee = grant.get("Grantee", {}) + grant_type = grantee.get("Type") + if grant_type not in ( + GranteeType.Group, + GranteeType.CanonicalUser, + GranteeType.AmazonCustomerByEmail, + ): + raise MalformedACLError( + "The XML you provided was not well-formed or did not validate against our published schema" + ) + elif ( + grant_type == GranteeType.Group + and (grant_uri := grantee.get("URI", "")) + not in s3_constants.VALID_ACL_PREDEFINED_GROUPS + ): + ex = _create_invalid_argument_exc("Invalid group uri", "Group/URI", grant_uri) + raise ex + + elif grant_type == GranteeType.AmazonCustomerByEmail: + # TODO: add validation here + continue + + elif grant_type == GranteeType.CanonicalUser and not is_valid_canonical_id( + (grantee_id := grantee.get("ID", "")) + ): + ex = _create_invalid_argument_exc("Invalid id", "CanonicalUser/ID", grantee_id) + raise ex + + +def validate_lifecycle_configuration(lifecycle_conf: BucketLifecycleConfiguration) -> None: + """ + Validate the Lifecycle configuration following AWS docs + See https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.html + https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketLifecycleConfiguration.html + :param lifecycle_conf: the bucket lifecycle configuration given by the client + :raises MalformedXML: when the file doesn't follow the basic structure/required fields + :raises InvalidArgument: if the `Date` passed for the Expiration is not at Midnight GMT + :raises InvalidRequest: if there are duplicate tags keys in `Tags` field + :return: None + """ + # we only add the `Expiration` header, we don't delete objects yet + # We don't really expire or transition objects + # TODO: transition not supported not validated, as we don't use it yet + if not lifecycle_conf: + return + + for rule in lifecycle_conf.get("Rules", []): + if any(req_key not in rule for req_key in ("ID", "Filter", "Status")): + raise MalformedXML() + if (non_current_exp := rule.get("NoncurrentVersionExpiration")) is not None: + if all( + req_key not in non_current_exp + for req_key in ("NewerNoncurrentVersions", "NoncurrentDays") + ): + raise MalformedXML() + + if rule_filter := rule.get("Filter"): + if len(rule_filter) > 1: + raise MalformedXML() + + if (expiration := rule.get("Expiration", {})) and "ExpiredObjectDeleteMarker" in expiration: + if len(expiration) > 1: + raise MalformedXML() + + if exp_date := (expiration.get("Date")): + if exp_date.timetz() != datetime.time( + hour=0, minute=0, second=0, microsecond=0, tzinfo=ZoneInfo("GMT") + ): + raise InvalidArgument( + "'Date' must be at midnight GMT", + ArgumentName="Date", + ArgumentValue=exp_date.astimezone(), # use the locale timezone, that's what AWS does (returns PST?) + ) + + if tags := (rule_filter.get("And", {}).get("Tags")): + tag_keys = set() + for tag in tags: + if (tag_key := tag.get("Key")) in tag_keys: + raise InvalidRequest("Duplicate Tag Keys are not allowed.") + tag_keys.add(tag_key) + + +def validate_website_configuration(website_config: WebsiteConfiguration) -> None: + """ + Validate the website configuration following AWS docs + See https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketWebsite.html + :param website_config: + :raises + :return: None + """ + if redirect_all_req := website_config.get("RedirectAllRequestsTo", {}): + if len(website_config) > 1: + ex = _create_invalid_argument_exc( + message="RedirectAllRequestsTo cannot be provided in conjunction with other Routing Rules.", + name="RedirectAllRequestsTo", + value="not null", + ) + raise ex + if "HostName" not in redirect_all_req: + raise MalformedXML() + + if (protocol := redirect_all_req.get("Protocol")) and protocol not in ("http", "https"): + raise InvalidRequest( + "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically." + ) + + return + + # required + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_IndexDocument.html + if not (index_configuration := website_config.get("IndexDocument")): + ex = _create_invalid_argument_exc( + message="A value for IndexDocument Suffix must be provided if RedirectAllRequestsTo is empty", + name="IndexDocument", + value="null", + ) + raise ex + + if not (index_suffix := index_configuration.get("Suffix")) or "/" in index_suffix: + ex = _create_invalid_argument_exc( + message="The IndexDocument Suffix is not well formed", + name="IndexDocument", + value=index_suffix or None, + ) + raise ex + + if "ErrorDocument" in website_config and not website_config.get("ErrorDocument", {}).get("Key"): + raise MalformedXML() + + if "RoutingRules" in website_config: + routing_rules = website_config.get("RoutingRules", []) + if len(routing_rules) == 0: + raise MalformedXML() + if len(routing_rules) > 50: + raise ValueError("Too many routing rules") # TODO: correct exception + for routing_rule in routing_rules: + redirect = routing_rule.get("Redirect", {}) + # todo: this does not raise an error? check what GetWebsiteConfig returns? empty field? + # if not (redirect := routing_rule.get("Redirect")): + # raise "Something" + + if "ReplaceKeyPrefixWith" in redirect and "ReplaceKeyWith" in redirect: + raise InvalidRequest( + "You can only define ReplaceKeyPrefix or ReplaceKey but not both." + ) + + if "Condition" in routing_rule and not routing_rule.get("Condition", {}): + raise InvalidRequest( + "Condition cannot be empty. To redirect all requests without a condition, the condition element shouldn't be present." + ) + + if (protocol := redirect.get("Protocol")) and protocol not in ("http", "https"): + raise InvalidRequest( + "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically." + ) + + +def validate_inventory_configuration( + config_id: InventoryId, inventory_configuration: InventoryConfiguration +): + """ + Validate the Inventory Configuration following AWS docs + Validation order is XML then `Id` then S3DestinationBucket + https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketInventoryConfiguration.html + https://docs.aws.amazon.com/AmazonS3/latest/userguide/storage-inventory.html + :param config_id: the passed Id parameter passed to the provider method + :param inventory_configuration: InventoryConfiguration + :raises MalformedXML: when the file doesn't follow the basic structure/required fields + :raises IdMismatch: if the `Id` parameter is different from the `Id` field from the configuration + :raises InvalidS3DestinationBucket: if S3 bucket is not provided as an ARN + :return: None + """ + required_root_fields = {"Destination", "Id", "IncludedObjectVersions", "IsEnabled", "Schedule"} + optional_root_fields = {"Filter", "OptionalFields"} + + if not validate_dict_fields( + inventory_configuration, required_root_fields, optional_root_fields + ): + raise MalformedXML() + + required_s3_bucket_dest_fields = {"Bucket", "Format"} + optional_s3_bucket_dest_fields = {"AccountId", "Encryption", "Prefix"} + + if not ( + s3_bucket_destination := inventory_configuration["Destination"].get("S3BucketDestination") + ) or not validate_dict_fields( + s3_bucket_destination, required_s3_bucket_dest_fields, optional_s3_bucket_dest_fields + ): + raise MalformedXML() + + if inventory_configuration["Destination"]["S3BucketDestination"]["Format"] not in ( + "CSV", + "ORC", + "Parquet", + ): + raise MalformedXML() + + if not (frequency := inventory_configuration["Schedule"].get("Frequency")) or frequency not in ( + "Daily", + "Weekly", + ): + raise MalformedXML() + + if inventory_configuration["IncludedObjectVersions"] not in ("All", "Current"): + raise MalformedXML() + + possible_optional_fields = { + "Size", + "LastModifiedDate", + "StorageClass", + "ETag", + "IsMultipartUploaded", + "ReplicationStatus", + "EncryptionStatus", + "ObjectLockRetainUntilDate", + "ObjectLockMode", + "ObjectLockLegalHoldStatus", + "IntelligentTieringAccessTier", + "BucketKeyStatus", + "ChecksumAlgorithm", + } + if (opt_fields := inventory_configuration.get("OptionalFields")) and set( + opt_fields + ) - possible_optional_fields: + raise MalformedXML() + + if inventory_configuration.get("Id") != config_id: + raise CommonServiceException( + code="IdMismatch", message="Document ID does not match the specified configuration ID." + ) + + bucket_arn = inventory_configuration["Destination"]["S3BucketDestination"]["Bucket"] + try: + arns.parse_arn(bucket_arn) + except InvalidArnException: + raise CommonServiceException( + code="InvalidS3DestinationBucket", message="Invalid bucket ARN." + ) + + +def validate_cors_configuration(cors_configuration: CORSConfiguration): + rules = cors_configuration["CORSRules"] + + if not rules or len(rules) > 100: + raise MalformedXML() + + required_rule_fields = {"AllowedMethods", "AllowedOrigins"} + optional_rule_fields = {"AllowedHeaders", "ExposeHeaders", "MaxAgeSeconds", "ID"} + + for rule in rules: + if not validate_dict_fields(rule, required_rule_fields, optional_rule_fields): + raise MalformedXML() + + for method in rule["AllowedMethods"]: + if method not in ("GET", "PUT", "HEAD", "POST", "DELETE"): + raise InvalidRequest( + f"Found unsupported HTTP method in CORS config. Unsupported method is {method}" + ) + + +def validate_object_key(object_key: str) -> None: + """ + ref. https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html + + """ + if (len_key := len(to_bytes(object_key, encoding="UTF-8"))) > 1024: + raise KeyTooLongError( + "Your key is too long", + MaxSizeAllowed="1024", + Size=str(len_key), + ) + + +def validate_sse_c( + algorithm: SSECustomerAlgorithm, + encryption_key: SSECustomerKey, + encryption_key_md5: SSECustomerKeyMD5, + server_side_encryption: ServerSideEncryption = None, +): + """ + This method validates the SSE Customer parameters for different requests. + :param algorithm: the SSECustomerAlgorithm parameter of the incoming Request, can only be AES256 + :param encryption_key: the SSECustomerKey of the incoming Request, represent the base64 encoded encryption key + :param encryption_key_md5: the SSECustomerKeyMD5 of the request, represents the base64 encoded MD5 hash of the + encryption key + :param server_side_encryption: when the incoming request is a "write" request (PutObject, CopyObject, + CreateMultipartUpload), the user can specify the encryption. Customer encryption and AWS SSE can't both be set. + :raises: InvalidArgument if the request is invalid + :raises: InvalidEncryptionAlgorithmError if the given algorithm is different from AES256 + """ + if not encryption_key and not algorithm: + return + elif server_side_encryption: + raise InvalidArgument( + "Server Side Encryption with Customer provided key is incompatible with the encryption method specified", + ArgumentName="x-amz-server-side-encryption", + ArgumentValue=server_side_encryption, + ) + + if encryption_key and not algorithm: + raise InvalidArgument( + "Requests specifying Server Side Encryption with Customer provided keys must provide a valid encryption algorithm.", + ArgumentName="x-amz-server-side-encryption", + ArgumentValue="null", + ) + elif not encryption_key and algorithm: + raise InvalidArgument( + "Requests specifying Server Side Encryption with Customer provided keys must provide an appropriate secret key.", + ArgumentName="x-amz-server-side-encryption", + ArgumentValue="null", + ) + + if algorithm != "AES256": + raise InvalidEncryptionAlgorithmError( + "The Encryption request you specified is not valid. Supported value: AES256.", + ArgumentName="x-amz-server-side-encryption", + ArgumentValue=algorithm, + ) + + sse_customer_key = base64.b64decode(encryption_key) + if len(sse_customer_key) != 32: + raise InvalidArgument( + "The secret key was invalid for the specified algorithm.", + ArgumentName="x-amz-server-side-encryption", + ArgumentValue="null", + ) + + sse_customer_key_md5 = base64.b64encode(hashlib.md5(sse_customer_key).digest()).decode("utf-8") + if sse_customer_key_md5 != encryption_key_md5: + raise InvalidArgument( + "The calculated MD5 hash of the key did not match the hash that was provided.", + # weirdly, the argument name is wrong, it should be `x-amz-server-side-encryption-customer-key-MD5` + ArgumentName="x-amz-server-side-encryption", + ArgumentValue="null", + ) + + +def validate_checksum_value(checksum_value: str, checksum_algorithm: ChecksumAlgorithm) -> bool: + try: + checksum = base64.b64decode(checksum_value) + except Exception: + return False + + match checksum_algorithm: + case ChecksumAlgorithm.CRC32 | ChecksumAlgorithm.CRC32C: + valid_length = 4 + case ChecksumAlgorithm.CRC64NVME: + valid_length = 8 + case ChecksumAlgorithm.SHA1: + valid_length = 20 + case ChecksumAlgorithm.SHA256: + valid_length = 32 + case _: + valid_length = 0 + + return len(checksum) == valid_length diff --git a/localstack-core/localstack/services/s3/website_hosting.py b/localstack-core/localstack/services/s3/website_hosting.py new file mode 100644 index 0000000000000..141dc4e935105 --- /dev/null +++ b/localstack-core/localstack/services/s3/website_hosting.py @@ -0,0 +1,409 @@ +import logging +import re +from functools import wraps +from typing import Callable, Dict, Optional, Union +from urllib.parse import urlparse + +from werkzeug.datastructures import Headers + +from localstack.aws.api.s3 import ( + BucketName, + ErrorDocument, + GetObjectOutput, + NoSuchKey, + NoSuchWebsiteConfiguration, + ObjectKey, + RoutingRule, + RoutingRules, +) +from localstack.aws.connect import connect_to +from localstack.aws.protocol.serializer import gen_amzn_requestid +from localstack.http import Request, Response, Router +from localstack.http.dispatcher import Handler + +LOG = logging.getLogger(__name__) + +STATIC_WEBSITE_HOST_REGEX = '.s3-website.' + +_leading_whitespace_re = re.compile("(^[ \t]*)(?:[ \t\n])", re.MULTILINE) + + +class NoSuchKeyFromErrorDocument(NoSuchKey): + code: str = "NoSuchKey" + sender_fault: bool = False + status_code: int = 404 + Key: Optional[ObjectKey] + ErrorDocumentKey: Optional[ObjectKey] + + +class S3WebsiteHostingHandler: + def __init__(self): + # TODO: once we implement ACLs, maybe revisit the way we use the client/verify the bucket/object's ACL + self.s3_client = connect_to().s3 + + def __call__( + self, + request: Request, + bucket_name: str, + domain: str = None, + path: str = None, + ) -> Response: + """ + Tries to serve the key, and if an Exception is encountered, returns a generic response + This will allow to easily extend it to 403 exceptions + :param request: router Request object + :param bucket_name: str, bucket name + :param domain: str, domain name + :param path: the path of the request + :return: Response object + """ + if request.method != "GET": + return Response( + _create_405_error_string(request.method, request_id=gen_amzn_requestid()), + status=405, + ) + + try: + return self._serve_object(request, bucket_name, path) + + except (NoSuchKeyFromErrorDocument, NoSuchWebsiteConfiguration) as e: + resource_name = e.Key if hasattr(e, "Key") else e.BucketName + response_body = _create_404_error_string( + code=e.code, + message=e.message, + resource_name=resource_name, + request_id=gen_amzn_requestid(), + from_error_document=getattr(e, "ErrorDocumentKey", None), + ) + return Response(response_body, status=e.status_code) + + except self.s3_client.exceptions.ClientError as e: + error = e.response["Error"] + if error["Code"] not in ("NoSuchKey", "NoSuchBucket", "NoSuchWebsiteConfiguration"): + raise + + resource_name = error.get("Key", error.get("BucketName")) + response_body = _create_404_error_string( + code=error["Code"], + message=error["Message"], + resource_name=resource_name, + request_id=gen_amzn_requestid(), + from_error_document=getattr(e, "ErrorDocumentKey", None), + ) + return Response(response_body, status=e.response["ResponseMetadata"]["HTTPStatusCode"]) + + except Exception: + LOG.exception( + "Exception encountered while trying to serve s3-website at %s", request.url + ) + return Response(_create_500_error_string(), status=500) + + def _serve_object( + self, request: Request, bucket_name: BucketName, path: str = None + ) -> Response: + """ + Serves the S3 Object as a website handler. It will match routing rules set in the configuration first, + and redirect the request if necessary. They are specific case for handling configured index, see the docs: + https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html + https://docs.aws.amazon.com/AmazonS3/latest/userguide/CustomErrorDocSupport.html + https://docs.aws.amazon.com/AmazonS3/latest/userguide/how-to-page-redirect.html + :param request: Request object received by the router + :param bucket_name: bucket name contained in the host name + :param path: path of the request, corresponds to the S3 Object key + :return: Response object, either the Object, a redirection or an error + """ + + website_config = self.s3_client.get_bucket_website(Bucket=bucket_name) + headers = {} + + redirection = website_config.get("RedirectAllRequestsTo") + if redirection: + parsed_url = urlparse(request.url) + redirect_to = request.url.replace(parsed_url.netloc, redirection["HostName"]) + if protocol := redirection.get("Protocol"): + redirect_to = redirect_to.replace(parsed_url.scheme, protocol) + + headers["Location"] = redirect_to + return Response("", status=301, headers=headers) + + object_key = path + routing_rules = website_config.get("RoutingRules") + # checks for prefix rules, before trying to get the key + if ( + object_key + and routing_rules + and (rule := self._find_matching_rule(routing_rules, object_key=object_key)) + ): + redirect_response = self._get_redirect_from_routing_rule(request, rule) + return redirect_response + + # if the URL ends with a trailing slash, try getting the index first + is_folder = request.url[-1] == "/" + if ( + not object_key or is_folder + ): # the path automatically remove the trailing slash, even with strict_slashes=False + index_key = website_config["IndexDocument"]["Suffix"] + object_key = f"{object_key}{index_key}" if object_key else index_key + + try: + s3_object = self.s3_client.get_object(Bucket=bucket_name, Key=object_key) + except self.s3_client.exceptions.NoSuchKey: + if not is_folder: + # try appending the index suffix in case we're accessing a "folder" without a trailing slash + index_key = website_config["IndexDocument"]["Suffix"] + try: + self.s3_client.head_object(Bucket=bucket_name, Key=f"{object_key}/{index_key}") + return Response("", status=302, headers={"Location": f"/{object_key}/"}) + except self.s3_client.exceptions.ClientError: + pass + + # checks for error code (and prefix) rules, after trying to get the key + if routing_rules and ( + rule := self._find_matching_rule( + routing_rules, object_key=object_key, error_code=404 + ) + ): + redirect_response = self._get_redirect_from_routing_rule(request, rule) + return redirect_response + + # tries to get the error document, otherwise raises NoSuchKey + if error_document := website_config.get("ErrorDocument"): + return self._return_error_document( + error_document=error_document, + bucket=bucket_name, + missing_key=object_key, + ) + else: + # If not ErrorDocument is configured, raise NoSuchKey + raise + + if website_redirect_location := s3_object.get("WebsiteRedirectLocation"): + headers["Location"] = website_redirect_location + return Response("", status=301, headers=headers) + + if self._check_if_headers(request.headers, s3_object=s3_object): + return Response("", status=304) + + headers = self._get_response_headers_from_object(s3_object) + return Response(s3_object["Body"], headers=headers) + + def _return_error_document( + self, + error_document: ErrorDocument, + bucket: BucketName, + missing_key: ObjectKey, + ) -> Response: + """ + Try to retrieve the configured ErrorDocument and return the response with its body + https://docs.aws.amazon.com/AmazonS3/latest/userguide/CustomErrorDocSupport.html + :param error_document: the ErrorDocument from the bucket WebsiteConfiguration + :param bucket: the bucket name + :param missing_key: the missing key not found in the bucket + :return: a Response, either a redirection or containing the Body of the ErrorDocument + :raises NoSuchKeyFromErrorDocument if the ErrorDocument is not found + """ + headers = {} + error_key = error_document["Key"] + try: + s3_object = self.s3_client.get_object(Bucket=bucket, Key=error_key) + # if the key is found, return the key, or if that key has a redirect, return a redirect + + if website_redirect_location := s3_object.get("WebsiteRedirectLocation"): + headers["Location"] = website_redirect_location + return Response("", status=301, headers=headers) + + headers = self._get_response_headers_from_object(s3_object) + return Response(s3_object["Body"], status=404, headers=headers) + + except self.s3_client.exceptions.NoSuchKey: + raise NoSuchKeyFromErrorDocument( + "The specified key does not exist.", + Key=missing_key, + ErrorDocumentKey=error_key, + ) + + @staticmethod + def _get_response_headers_from_object(get_object_response: GetObjectOutput) -> Dict[str, str]: + """ + Only return some headers from the S3 Object + :param get_object_response: the response from S3.GetObject + :return: headers from the object to be part of the response + """ + response_headers = {} + if content_type := get_object_response.get("ContentType"): + response_headers["Content-Type"] = content_type + if etag := get_object_response.get("ETag"): + response_headers["etag"] = etag + + return response_headers + + @staticmethod + def _check_if_headers(headers: Headers, s3_object: GetObjectOutput) -> bool: + # TODO: add other conditions here If-Modified-Since, etc etc + etag = s3_object.get("ETag") + # last_modified = s3_object.get("LastModified") # TODO + if "if-none-match" in headers and etag and etag in headers["if-none-match"]: + return True + + @staticmethod + def _find_matching_rule( + routing_rules: RoutingRules, object_key: ObjectKey, error_code: int = None + ) -> Union[RoutingRule, None]: + """ + Iterate over the routing rules set in the configuration, and return the first that match the key name and/or the + error code (in the 4XX range). + :param routing_rules: RoutingRules part of WebsiteConfiguration + :param object_key: ObjectKey + :param error_code: error code of the Response in the 4XX range + :return: a RoutingRule if matched, or None + """ + # TODO: we could separate rules depending in they have the HttpErrorCodeReturnedEquals field + # we would not try to match on them early, no need to iterate on them + # and iterate them over only if an exception is encountered + for rule in routing_rules: + if condition := rule.get("Condition"): + prefix = condition.get("KeyPrefixEquals") + return_http_code = condition.get("HttpErrorCodeReturnedEquals") + # if both prefix matching and http error matching conditions are set + if prefix and return_http_code: + if object_key.startswith(prefix) and error_code == int(return_http_code): + return rule + else: + # it must either match both or it does not apply + continue + # only prefix is set, but this should have been matched before the error + elif prefix and object_key.startswith(prefix): + return rule + elif return_http_code and error_code == int(return_http_code): + return rule + + else: + # if no Condition is set, the redirect is applied to all requests + return rule + + @staticmethod + def _get_redirect_from_routing_rule(request: Request, routing_rule: RoutingRule) -> Response: + """ + Return a redirect Response object created with the different parameters set in the RoutingRule + :param request: the original Request object received from the router + :param routing_rule: a RoutingRule from the WebsiteConfiguration + :return: a redirect Response + """ + parsed_url = urlparse(request.url) + redirect_to = request.url + redirect = routing_rule["Redirect"] + if host_name := redirect.get("HostName"): + redirect_to = redirect_to.replace(parsed_url.netloc, host_name) + if protocol := redirect.get("Protocol"): + redirect_to = redirect_to.replace(parsed_url.scheme, protocol) + if redirect_to_key := redirect.get("ReplaceKeyWith"): + redirect_to = redirect_to.replace(parsed_url.path, f"/{redirect_to_key}") + elif "ReplaceKeyPrefixWith" in redirect: # the value might be empty and it's a valid config + matched_prefix = routing_rule["Condition"].get("KeyPrefixEquals", "") + redirect_to = redirect_to.replace( + matched_prefix, redirect.get("ReplaceKeyPrefixWith"), 1 + ) + + return Response( + "", headers={"Location": redirect_to}, status=redirect.get("HttpRedirectCode", 301) + ) + + +def register_website_hosting_routes( + router: Router[Handler], handler: S3WebsiteHostingHandler = None +): + """ + Registers the S3 website hosting handler into the given router. + :param handler: an S3WebsiteHosting handler + :param router: the router to add the handlers into. + """ + handler = handler or S3WebsiteHostingHandler() + router.add( + path="/", + host=STATIC_WEBSITE_HOST_REGEX, + endpoint=handler, + ) + router.add( + path="/", + host=STATIC_WEBSITE_HOST_REGEX, + endpoint=handler, + ) + + +def _flatten_html_response(fn: Callable[[...], str]): + @wraps(fn) + def wrapper(*args, **kwargs) -> str: + r = fn(*args, **kwargs) + # remove leading whitespace + return re.sub(_leading_whitespace_re, "", r) + + return wrapper + + +@_flatten_html_response +def _create_404_error_string( + code: str, message: str, resource_name: str, request_id: str, from_error_document: str = None +) -> str: + # TODO: the nested error could be permission related + # permission are not enforced currently + resource_key = "Key" if "Key" in code else "BucketName" + return f""" + 404 Not Found + +

404 Not Found

+
    +
  • Code: {code}
  • +
  • Message: {message}
  • +
  • {resource_key}: {resource_name}
  • +
  • RequestId: {request_id}
  • +
  • HostId: h6t23Wl2Ndijztq+COn9kvx32omFVRLLtwk36D6+2/CIYSey+Uox6kBxRgcnAASsgnGwctU6zzU=
  • +
+ {_create_nested_404_error_string(from_error_document)} +
+ + +""" + + +def _create_nested_404_error_string(error_document_key: str) -> str: + if not error_document_key: + return "" + return f"""

An Error Occurred While Attempting to Retrieve a Custom Error Document

+
    +
  • Code: NoSuchKey
  • +
  • Message: The specified key does not exist.
  • +
  • Key: {error_document_key}
  • +
+ """ + + +@_flatten_html_response +def _create_405_error_string(method: str, request_id: str) -> str: + return f""" + 405 Method Not Allowed + +

405 Method Not Allowed

+
    +
  • Code: MethodNotAllowed
  • +
  • Message: The specified method is not allowed against this resource.
  • +
  • Method: {method.upper()}
  • +
  • ResourceType: OBJECT
  • +
  • RequestId: {request_id}
  • +
  • HostId: h6t23Wl2Ndijztq+COn9kvx32omFVRLLtwk36D6+2/CIYSey+Uox6kBxRgcnAASsgnGwctU6zzU=
  • +
+
+ + +""" + + +@_flatten_html_response +def _create_500_error_string() -> str: + return """ + 500 Service Error + +

500 Service Error

+
+ + + """ diff --git a/localstack-core/localstack/services/s3control/__init__.py b/localstack-core/localstack/services/s3control/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/s3control/provider.py b/localstack-core/localstack/services/s3control/provider.py new file mode 100644 index 0000000000000..f4057b0adc0bc --- /dev/null +++ b/localstack-core/localstack/services/s3control/provider.py @@ -0,0 +1,5 @@ +from localstack.aws.api.s3control import S3ControlApi + + +class S3ControlProvider(S3ControlApi): + pass diff --git a/localstack-core/localstack/services/scheduler/__init__.py b/localstack-core/localstack/services/scheduler/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/scheduler/models.py b/localstack-core/localstack/services/scheduler/models.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/scheduler/provider.py b/localstack-core/localstack/services/scheduler/provider.py new file mode 100644 index 0000000000000..63177c01fda30 --- /dev/null +++ b/localstack-core/localstack/services/scheduler/provider.py @@ -0,0 +1,36 @@ +import logging +import re + +from moto.scheduler.models import EventBridgeSchedulerBackend + +from localstack.aws.api.scheduler import SchedulerApi, ValidationException +from localstack.services.events.rule import RULE_SCHEDULE_CRON_REGEX, RULE_SCHEDULE_RATE_REGEX +from localstack.services.plugins import ServiceLifecycleHook +from localstack.utils.patch import patch + +LOG = logging.getLogger(__name__) + +AT_REGEX = ( + r"^at[(](19|20)\d\d-(0[1-9]|1[012])-([012]\d|3[01])T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)[)]$" +) +RULE_SCHEDULE_AT_REGEX = re.compile(AT_REGEX) + + +class SchedulerProvider(SchedulerApi, ServiceLifecycleHook): + pass + + +def _validate_schedule_expression(schedule_expression: str) -> None: + if not ( + RULE_SCHEDULE_CRON_REGEX.match(schedule_expression) + or RULE_SCHEDULE_RATE_REGEX.match(schedule_expression) + or RULE_SCHEDULE_AT_REGEX.match(schedule_expression) + ): + raise ValidationException(f"Invalid Schedule Expression {schedule_expression}.") + + +@patch(EventBridgeSchedulerBackend.create_schedule) +def create_schedule(fn, self, **kwargs): + if schedule_expression := kwargs.get("schedule_expression"): + _validate_schedule_expression(schedule_expression) + return fn(self, **kwargs) diff --git a/localstack-core/localstack/services/scheduler/resource_providers/__init__.py b/localstack-core/localstack/services/scheduler/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule.py b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule.py new file mode 100644 index 0000000000000..adfc5316062ab --- /dev/null +++ b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule.py @@ -0,0 +1,229 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SchedulerScheduleProperties(TypedDict): + FlexibleTimeWindow: Optional[FlexibleTimeWindow] + ScheduleExpression: Optional[str] + Target: Optional[Target] + Arn: Optional[str] + Description: Optional[str] + EndDate: Optional[str] + GroupName: Optional[str] + KmsKeyArn: Optional[str] + Name: Optional[str] + ScheduleExpressionTimezone: Optional[str] + StartDate: Optional[str] + State: Optional[str] + + +class FlexibleTimeWindow(TypedDict): + Mode: Optional[str] + MaximumWindowInMinutes: Optional[float] + + +class DeadLetterConfig(TypedDict): + Arn: Optional[str] + + +class RetryPolicy(TypedDict): + MaximumEventAgeInSeconds: Optional[float] + MaximumRetryAttempts: Optional[float] + + +class AwsVpcConfiguration(TypedDict): + Subnets: Optional[list[str]] + AssignPublicIp: Optional[str] + SecurityGroups: Optional[list[str]] + + +class NetworkConfiguration(TypedDict): + AwsvpcConfiguration: Optional[AwsVpcConfiguration] + + +class CapacityProviderStrategyItem(TypedDict): + CapacityProvider: Optional[str] + Base: Optional[float] + Weight: Optional[float] + + +class PlacementConstraint(TypedDict): + Expression: Optional[str] + Type: Optional[str] + + +class PlacementStrategy(TypedDict): + Field: Optional[str] + Type: Optional[str] + + +class EcsParameters(TypedDict): + TaskDefinitionArn: Optional[str] + CapacityProviderStrategy: Optional[list[CapacityProviderStrategyItem]] + EnableECSManagedTags: Optional[bool] + EnableExecuteCommand: Optional[bool] + Group: Optional[str] + LaunchType: Optional[str] + NetworkConfiguration: Optional[NetworkConfiguration] + PlacementConstraints: Optional[list[PlacementConstraint]] + PlacementStrategy: Optional[list[PlacementStrategy]] + PlatformVersion: Optional[str] + PropagateTags: Optional[str] + ReferenceId: Optional[str] + Tags: Optional[list[dict]] + TaskCount: Optional[float] + + +class EventBridgeParameters(TypedDict): + DetailType: Optional[str] + Source: Optional[str] + + +class KinesisParameters(TypedDict): + PartitionKey: Optional[str] + + +class SageMakerPipelineParameter(TypedDict): + Name: Optional[str] + Value: Optional[str] + + +class SageMakerPipelineParameters(TypedDict): + PipelineParameterList: Optional[list[SageMakerPipelineParameter]] + + +class SqsParameters(TypedDict): + MessageGroupId: Optional[str] + + +class Target(TypedDict): + Arn: Optional[str] + RoleArn: Optional[str] + DeadLetterConfig: Optional[DeadLetterConfig] + EcsParameters: Optional[EcsParameters] + EventBridgeParameters: Optional[EventBridgeParameters] + Input: Optional[str] + KinesisParameters: Optional[KinesisParameters] + RetryPolicy: Optional[RetryPolicy] + SageMakerPipelineParameters: Optional[SageMakerPipelineParameters] + SqsParameters: Optional[SqsParameters] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SchedulerScheduleProvider(ResourceProvider[SchedulerScheduleProperties]): + TYPE = "AWS::Scheduler::Schedule" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SchedulerScheduleProperties], + ) -> ProgressEvent[SchedulerScheduleProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Name + + Required properties: + - FlexibleTimeWindow + - ScheduleExpression + - Target + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - scheduler:CreateSchedule + - scheduler:GetSchedule + - iam:PassRole + + """ + model = request.desired_state + + if not model.get("Name"): + model["Name"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + create_params = util.select_attributes( + model, + [ + "Description", + "EndDate", + "FlexibleTimeWindow", + "GroupName", + "KmsKeyArn", + "Name", + "ScheduleExpression", + "ScheduleExpressionTimezone", + "StartDate", + "State", + "Target", + ], + ) + + result = request.aws_client_factory.scheduler.create_schedule(**create_params) + model["Arn"] = result["ScheduleArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[SchedulerScheduleProperties], + ) -> ProgressEvent[SchedulerScheduleProperties]: + """ + Fetch resource information + + IAM permissions required: + - scheduler:GetSchedule + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SchedulerScheduleProperties], + ) -> ProgressEvent[SchedulerScheduleProperties]: + """ + Delete a resource + + IAM permissions required: + - scheduler:DeleteSchedule + - scheduler:GetSchedule + """ + + delete_params = util.select_attributes(request.desired_state, ["Name", "GroupName"]) + request.aws_client_factory.scheduler.delete_schedule(**delete_params) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[SchedulerScheduleProperties], + ) -> ProgressEvent[SchedulerScheduleProperties]: + """ + Update a resource + + IAM permissions required: + - scheduler:UpdateSchedule + - scheduler:GetSchedule + - iam:PassRole + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule.schema.json b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule.schema.json new file mode 100644 index 0000000000000..6ea351d62add3 --- /dev/null +++ b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule.schema.json @@ -0,0 +1,591 @@ +{ + "typeName": "AWS::Scheduler::Schedule", + "description": "Definition of AWS::Scheduler::Schedule Resource Type", + "definitions": { + "AssignPublicIp": { + "type": "string", + "description": "Specifies whether the task's elastic network interface receives a public IP address. You can specify ENABLED only when LaunchType in EcsParameters is set to FARGATE.", + "enum": [ + "ENABLED", + "DISABLED" + ] + }, + "AwsVpcConfiguration": { + "type": "object", + "description": "This structure specifies the VPC subnets and security groups for the task, and whether a public IP address is to be used. This structure is relevant only for ECS tasks that use the awsvpc network mode.", + "properties": { + "Subnets": { + "type": "array", + "items": { + "type": "string", + "maxLength": 1000, + "minLength": 1, + "description": "Specifies the subnet associated with the task." + }, + "maxItems": 16, + "minItems": 1, + "description": "Specifies the subnets associated with the task. These subnets must all be in the same VPC. You can specify as many as 16 subnets.", + "insertionOrder": false + }, + "SecurityGroups": { + "type": "array", + "items": { + "type": "string", + "maxLength": 1000, + "minLength": 1, + "description": "Specifies the security group associated with the task." + }, + "maxItems": 5, + "minItems": 1, + "description": "Specifies the security groups associated with the task. These security groups must all be in the same VPC. You can specify as many as five security groups. If you do not specify a security group, the default security group for the VPC is used.", + "insertionOrder": false + }, + "AssignPublicIp": { + "$ref": "#/definitions/AssignPublicIp" + } + }, + "required": [ + "Subnets" + ], + "additionalProperties": false + }, + "CapacityProviderStrategyItem": { + "type": "object", + "description": "The details of a capacity provider strategy.", + "properties": { + "CapacityProvider": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "description": "The short name of the capacity provider." + }, + "Weight": { + "type": "number", + "default": 0, + "maximum": 1000, + "minimum": 0, + "description": "The weight value designates the relative percentage of the total number of tasks launched that should use the specified capacity provider. The weight value is taken into consideration after the base value, if defined, is satisfied." + }, + "Base": { + "type": "number", + "default": 0, + "maximum": 100000, + "minimum": 0, + "description": "The base value designates how many tasks, at a minimum, to run on the specified capacity provider. Only one capacity provider in a capacity provider strategy can have a base defined. If no value is specified, the default value of 0 is used." + } + }, + "required": [ + "CapacityProvider" + ], + "additionalProperties": false + }, + "DeadLetterConfig": { + "type": "object", + "description": "A DeadLetterConfig object that contains information about a dead-letter queue configuration.", + "properties": { + "Arn": { + "type": "string", + "maxLength": 1600, + "minLength": 1, + "pattern": "^arn:aws(-[a-z]+)?:sqs:[a-z0-9\\-]+:\\d{12}:[a-zA-Z0-9\\-_]+$", + "description": "The ARN of the SQS queue specified as the target for the dead-letter queue." + } + }, + "additionalProperties": false + }, + "EcsParameters": { + "type": "object", + "description": "The custom parameters to be used when the target is an Amazon ECS task.", + "properties": { + "TaskDefinitionArn": { + "type": "string", + "maxLength": 1600, + "minLength": 1, + "description": "The ARN of the task definition to use if the event target is an Amazon ECS task." + }, + "TaskCount": { + "type": "number", + "maximum": 10, + "minimum": 1, + "description": "The number of tasks to create based on TaskDefinition. The default is 1." + }, + "LaunchType": { + "$ref": "#/definitions/LaunchType" + }, + "NetworkConfiguration": { + "$ref": "#/definitions/NetworkConfiguration" + }, + "PlatformVersion": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "description": "Specifies the platform version for the task. Specify only the numeric portion of the platform version, such as 1.1.0." + }, + "Group": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "description": "Specifies an ECS task group for the task. The maximum length is 255 characters." + }, + "CapacityProviderStrategy": { + "type": "array", + "items": { + "$ref": "#/definitions/CapacityProviderStrategyItem" + }, + "maxItems": 6, + "description": "The capacity provider strategy to use for the task.", + "insertionOrder": false + }, + "EnableECSManagedTags": { + "type": "boolean", + "description": "Specifies whether to enable Amazon ECS managed tags for the task. For more information, see Tagging Your Amazon ECS Resources in the Amazon Elastic Container Service Developer Guide." + }, + "EnableExecuteCommand": { + "type": "boolean", + "description": "Whether or not to enable the execute command functionality for the containers in this task. If true, this enables execute command functionality on all containers in the task." + }, + "PlacementConstraints": { + "type": "array", + "items": { + "$ref": "#/definitions/PlacementConstraint" + }, + "maxItems": 10, + "description": "An array of placement constraint objects to use for the task. You can specify up to 10 constraints per task (including constraints in the task definition and those specified at runtime).", + "insertionOrder": false + }, + "PlacementStrategy": { + "type": "array", + "items": { + "$ref": "#/definitions/PlacementStrategy" + }, + "maxItems": 5, + "description": "The placement strategy objects to use for the task. You can specify a maximum of five strategy rules per task.", + "insertionOrder": false + }, + "PropagateTags": { + "$ref": "#/definitions/PropagateTags" + }, + "ReferenceId": { + "type": "string", + "maxLength": 1024, + "description": "The reference ID to use for the task." + }, + "Tags": { + "type": "array", + "items": { + "$ref": "#/definitions/TagMap" + }, + "maxItems": 50, + "minItems": 0, + "description": "The metadata that you apply to the task to help you categorize and organize them. Each tag consists of a key and an optional value, both of which you define. To learn more, see RunTask in the Amazon ECS API Reference.", + "insertionOrder": false + } + }, + "required": [ + "TaskDefinitionArn" + ], + "additionalProperties": false + }, + "EventBridgeParameters": { + "type": "object", + "description": "EventBridge PutEvent predefined target type.", + "properties": { + "DetailType": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "description": "Free-form string, with a maximum of 128 characters, used to decide what fields to expect in the event detail." + }, + "Source": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^(?=[/\\.\\-_A-Za-z0-9]+)((?!aws\\.).*)|(\\$(\\.[\\w_-]+(\\[(\\d+|\\*)\\])*)*)$", + "description": "The source of the event." + } + }, + "required": [ + "DetailType", + "Source" + ], + "additionalProperties": false + }, + "FlexibleTimeWindow": { + "type": "object", + "description": "Flexible time window allows configuration of a window within which a schedule can be invoked", + "properties": { + "Mode": { + "$ref": "#/definitions/FlexibleTimeWindowMode" + }, + "MaximumWindowInMinutes": { + "type": "number", + "maximum": 1440, + "minimum": 1, + "description": "The maximum time window during which a schedule can be invoked." + } + }, + "required": [ + "Mode" + ], + "additionalProperties": false + }, + "FlexibleTimeWindowMode": { + "type": "string", + "description": "Determines whether the schedule is executed within a flexible time window.", + "enum": [ + "OFF", + "FLEXIBLE" + ] + }, + "KinesisParameters": { + "type": "object", + "description": "The custom parameter you can use to control the shard to which EventBridge Scheduler sends the event.", + "properties": { + "PartitionKey": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "description": "The custom parameter used as the Kinesis partition key. For more information, see Amazon Kinesis Streams Key Concepts in the Amazon Kinesis Streams Developer Guide." + } + }, + "required": [ + "PartitionKey" + ], + "additionalProperties": false + }, + "LaunchType": { + "type": "string", + "description": "Specifies the launch type on which your task is running. The launch type that you specify here must match one of the launch type (compatibilities) of the target task. The FARGATE value is supported only in the Regions where AWS Fargate with Amazon ECS is supported. For more information, see AWS Fargate on Amazon ECS in the Amazon Elastic Container Service Developer Guide.", + "enum": [ + "EC2", + "FARGATE", + "EXTERNAL" + ] + }, + "NetworkConfiguration": { + "type": "object", + "description": "This structure specifies the network configuration for an ECS task.", + "properties": { + "AwsvpcConfiguration": { + "$ref": "#/definitions/AwsVpcConfiguration" + } + }, + "additionalProperties": false + }, + "PlacementConstraint": { + "type": "object", + "description": "An object representing a constraint on task placement.", + "properties": { + "Type": { + "$ref": "#/definitions/PlacementConstraintType" + }, + "Expression": { + "type": "string", + "maxLength": 2000, + "description": "A cluster query language expression to apply to the constraint. You cannot specify an expression if the constraint type is distinctInstance. To learn more, see Cluster Query Language in the Amazon Elastic Container Service Developer Guide." + } + }, + "additionalProperties": false + }, + "PlacementConstraintType": { + "type": "string", + "description": "The type of constraint. Use distinctInstance to ensure that each task in a particular group is running on a different container instance. Use memberOf to restrict the selection to a group of valid candidates.", + "enum": [ + "distinctInstance", + "memberOf" + ] + }, + "PlacementStrategy": { + "type": "object", + "description": "The task placement strategy for a task or service.", + "properties": { + "Type": { + "$ref": "#/definitions/PlacementStrategyType" + }, + "Field": { + "type": "string", + "maxLength": 255, + "description": "The field to apply the placement strategy against. For the spread placement strategy, valid values are instanceId (or host, which has the same effect), or any platform or custom attribute that is applied to a container instance, such as attribute:ecs.availability-zone. For the binpack placement strategy, valid values are cpu and memory. For the random placement strategy, this field is not used." + } + }, + "additionalProperties": false + }, + "PlacementStrategyType": { + "type": "string", + "description": "The type of placement strategy. The random placement strategy randomly places tasks on available candidates. The spread placement strategy spreads placement across available candidates evenly based on the field parameter. The binpack strategy places tasks on available candidates that have the least available amount of the resource that is specified with the field parameter. For example, if you binpack on memory, a task is placed on the instance with the least amount of remaining memory (but still enough to run the task).", + "enum": [ + "random", + "spread", + "binpack" + ] + }, + "PropagateTags": { + "type": "string", + "description": "Specifies whether to propagate the tags from the task definition to the task. If no value is specified, the tags are not propagated. Tags can only be propagated to the task during task creation. To add tags to a task after task creation, use the TagResource API action.", + "enum": [ + "TASK_DEFINITION" + ] + }, + "RetryPolicy": { + "type": "object", + "description": "A RetryPolicy object that includes information about the retry policy settings.", + "properties": { + "MaximumEventAgeInSeconds": { + "type": "number", + "maximum": 86400, + "minimum": 60, + "description": "The maximum amount of time, in seconds, to continue to make retry attempts." + }, + "MaximumRetryAttempts": { + "type": "number", + "maximum": 185, + "minimum": 0, + "description": "The maximum number of retry attempts to make before the request fails. Retry attempts with exponential backoff continue until either the maximum number of attempts is made or until the duration of the MaximumEventAgeInSeconds is reached." + } + }, + "additionalProperties": false + }, + "SageMakerPipelineParameter": { + "type": "object", + "description": "Name/Value pair of a parameter to start execution of a SageMaker Model Building Pipeline.", + "properties": { + "Name": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[A-Za-z0-9\\-_]*$", + "description": "Name of parameter to start execution of a SageMaker Model Building Pipeline." + }, + "Value": { + "type": "string", + "maxLength": 1024, + "minLength": 1, + "description": "Value of parameter to start execution of a SageMaker Model Building Pipeline." + } + }, + "required": [ + "Name", + "Value" + ], + "additionalProperties": false + }, + "SageMakerPipelineParameters": { + "type": "object", + "description": "These are custom parameters to use when the target is a SageMaker Model Building Pipeline that starts based on AWS EventBridge Scheduler schedules.", + "properties": { + "PipelineParameterList": { + "type": "array", + "items": { + "$ref": "#/definitions/SageMakerPipelineParameter" + }, + "maxItems": 200, + "minItems": 0, + "description": "List of Parameter names and values for SageMaker Model Building Pipeline execution.", + "insertionOrder": false + } + }, + "additionalProperties": false + }, + "ScheduleState": { + "type": "string", + "description": "Specifies whether the schedule is enabled or disabled.", + "enum": [ + "ENABLED", + "DISABLED" + ] + }, + "SqsParameters": { + "type": "object", + "description": "Contains the message group ID to use when the target is a FIFO queue. If you specify an SQS FIFO queue as a target, the queue must have content-based deduplication enabled.", + "properties": { + "MessageGroupId": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "description": "The FIFO message group ID to use as the target." + } + }, + "additionalProperties": false + }, + "TagMap": { + "type": "object", + "patternProperties": { + ".+": { + "type": "string", + "maxLength": 256, + "minLength": 1 + } + }, + "additionalProperties": false + }, + "Target": { + "type": "object", + "description": "The schedule target.", + "properties": { + "Arn": { + "type": "string", + "maxLength": 1600, + "minLength": 1, + "description": "The Amazon Resource Name (ARN) of the target." + }, + "RoleArn": { + "type": "string", + "maxLength": 1600, + "minLength": 1, + "pattern": "^arn:aws(-[a-z]+)?:iam::\\d{12}:role\\/[\\w+=,.@\\/-]+$", + "description": "The Amazon Resource Name (ARN) of the IAM role to be used for this target when the schedule is triggered." + }, + "DeadLetterConfig": { + "$ref": "#/definitions/DeadLetterConfig" + }, + "RetryPolicy": { + "$ref": "#/definitions/RetryPolicy" + }, + "Input": { + "type": "string", + "minLength": 1, + "description": "The text, or well-formed JSON, passed to the target. If you are configuring a templated Lambda, AWS Step Functions, or Amazon EventBridge target, the input must be a well-formed JSON. For all other target types, a JSON is not required. If you do not specify anything for this field, EventBridge Scheduler delivers a default notification to the target." + }, + "EcsParameters": { + "$ref": "#/definitions/EcsParameters" + }, + "EventBridgeParameters": { + "$ref": "#/definitions/EventBridgeParameters" + }, + "KinesisParameters": { + "$ref": "#/definitions/KinesisParameters" + }, + "SageMakerPipelineParameters": { + "$ref": "#/definitions/SageMakerPipelineParameters" + }, + "SqsParameters": { + "$ref": "#/definitions/SqsParameters" + } + }, + "required": [ + "Arn", + "RoleArn" + ], + "additionalProperties": false + } + }, + "properties": { + "Arn": { + "type": "string", + "maxLength": 1224, + "minLength": 1, + "pattern": "^arn:aws(-[a-z]+)?:scheduler:[a-z0-9\\-]+:\\d{12}:schedule\\/[0-9a-zA-Z-_.]+\\/[0-9a-zA-Z-_.]+$", + "description": "The Amazon Resource Name (ARN) of the schedule." + }, + "Description": { + "type": "string", + "maxLength": 512, + "minLength": 0, + "description": "The description of the schedule." + }, + "EndDate": { + "type": "string", + "description": "The date, in UTC, before which the schedule can invoke its target. Depending on the schedule's recurrence expression, invocations might stop on, or before, the EndDate you specify.", + "format": "date-time" + }, + "FlexibleTimeWindow": { + "$ref": "#/definitions/FlexibleTimeWindow" + }, + "GroupName": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[0-9a-zA-Z-_.]+$", + "description": "The name of the schedule group to associate with this schedule. If you omit this, the default schedule group is used." + }, + "KmsKeyArn": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "pattern": "^arn:aws(-[a-z]+)?:kms:[a-z0-9\\-]+:\\d{12}:(key|alias)\\/[0-9a-zA-Z-_]*$", + "description": "The ARN for a KMS Key that will be used to encrypt customer data." + }, + "Name": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[0-9a-zA-Z-_.]+$" + }, + "ScheduleExpression": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "description": "The scheduling expression." + }, + "ScheduleExpressionTimezone": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "description": "The timezone in which the scheduling expression is evaluated." + }, + "StartDate": { + "type": "string", + "description": "The date, in UTC, after which the schedule can begin invoking its target. Depending on the schedule's recurrence expression, invocations might occur on, or after, the StartDate you specify.", + "format": "date-time" + }, + "State": { + "$ref": "#/definitions/ScheduleState" + }, + "Target": { + "$ref": "#/definitions/Target" + } + }, + "required": [ + "FlexibleTimeWindow", + "ScheduleExpression", + "Target" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "handlers": { + "create": { + "permissions": [ + "scheduler:CreateSchedule", + "scheduler:GetSchedule", + "iam:PassRole" + ] + }, + "read": { + "permissions": [ + "scheduler:GetSchedule" + ] + }, + "update": { + "permissions": [ + "scheduler:UpdateSchedule", + "scheduler:GetSchedule", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "scheduler:DeleteSchedule", + "scheduler:GetSchedule" + ] + }, + "list": { + "permissions": [ + "scheduler:ListSchedules" + ] + } + }, + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "additionalProperties": false +} diff --git a/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule_plugin.py b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule_plugin.py new file mode 100644 index 0000000000000..b5fc742b5377b --- /dev/null +++ b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedule_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SchedulerScheduleProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Scheduler::Schedule" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.scheduler.resource_providers.aws_scheduler_schedule import ( + SchedulerScheduleProvider, + ) + + self.factory = SchedulerScheduleProvider diff --git a/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup.py b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup.py new file mode 100644 index 0000000000000..913ce73707551 --- /dev/null +++ b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup.py @@ -0,0 +1,123 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SchedulerScheduleGroupProperties(TypedDict): + Arn: Optional[str] + CreationDate: Optional[str] + LastModificationDate: Optional[str] + Name: Optional[str] + State: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SchedulerScheduleGroupProvider(ResourceProvider[SchedulerScheduleGroupProperties]): + TYPE = "AWS::Scheduler::ScheduleGroup" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SchedulerScheduleGroupProperties], + ) -> ProgressEvent[SchedulerScheduleGroupProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Name + + + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Arn + - /properties/CreationDate + - /properties/LastModificationDate + - /properties/State + + IAM permissions required: + - scheduler:CreateScheduleGroup + - scheduler:GetScheduleGroup + - scheduler:ListTagsForResource + + """ + model = request.desired_state + + if not model.get("Name"): + model["Name"] = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + + create_params = util.select_attributes(model, ("Name", "Tags")) + + result = request.aws_client_factory.scheduler.create_schedule_group(**create_params) + model["Arn"] = result["ScheduleGroupArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + ) + + def read( + self, + request: ResourceRequest[SchedulerScheduleGroupProperties], + ) -> ProgressEvent[SchedulerScheduleGroupProperties]: + """ + Fetch resource information + + IAM permissions required: + - scheduler:GetScheduleGroup + - scheduler:ListTagsForResource + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SchedulerScheduleGroupProperties], + ) -> ProgressEvent[SchedulerScheduleGroupProperties]: + """ + Delete a resource + + IAM permissions required: + - scheduler:DeleteScheduleGroup + - scheduler:GetScheduleGroup + - scheduler:DeleteSchedule + """ + model = request.desired_state + request.aws_client_factory.scheduler.delete_schedule_group(Name=model["Name"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[SchedulerScheduleGroupProperties], + ) -> ProgressEvent[SchedulerScheduleGroupProperties]: + """ + Update a resource + + IAM permissions required: + - scheduler:TagResource + - scheduler:UntagResource + - scheduler:ListTagsForResource + - scheduler:GetScheduleGroup + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup.schema.json b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup.schema.json new file mode 100644 index 0000000000000..50e5a3fe4f275 --- /dev/null +++ b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup.schema.json @@ -0,0 +1,130 @@ +{ + "typeName": "AWS::Scheduler::ScheduleGroup", + "description": "Definition of AWS::Scheduler::ScheduleGroup Resource Type", + "definitions": { + "ScheduleGroupState": { + "type": "string", + "description": "Specifies the state of the schedule group.", + "enum": [ + "ACTIVE", + "DELETING" + ] + }, + "Tag": { + "type": "object", + "description": "Tag to associate with the resource.", + "properties": { + "Key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "description": "Key for the tag" + }, + "Value": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "description": "Value for the tag" + } + }, + "required": [ + "Key", + "Value" + ], + "additionalProperties": false + } + }, + "properties": { + "Arn": { + "type": "string", + "maxLength": 1224, + "minLength": 1, + "pattern": "^arn:aws(-[a-z]+)?:scheduler:[a-z0-9\\-]+:\\d{12}:schedule-group\\/[0-9a-zA-Z-_.]+$", + "description": "The Amazon Resource Name (ARN) of the schedule group." + }, + "CreationDate": { + "type": "string", + "description": "The time at which the schedule group was created.", + "format": "date-time" + }, + "LastModificationDate": { + "type": "string", + "description": "The time at which the schedule group was last modified.", + "format": "date-time" + }, + "Name": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[0-9a-zA-Z-_.]+$" + }, + "State": { + "$ref": "#/definitions/ScheduleGroupState" + }, + "Tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + }, + "maxItems": 200, + "minItems": 0, + "description": "The list of tags to associate with the schedule group.", + "insertionOrder": false + } + }, + "readOnlyProperties": [ + "/properties/Arn", + "/properties/CreationDate", + "/properties/LastModificationDate", + "/properties/State" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "handlers": { + "create": { + "permissions": [ + "scheduler:CreateScheduleGroup", + "scheduler:GetScheduleGroup", + "scheduler:ListTagsForResource" + ] + }, + "read": { + "permissions": [ + "scheduler:GetScheduleGroup", + "scheduler:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "scheduler:TagResource", + "scheduler:UntagResource", + "scheduler:ListTagsForResource", + "scheduler:GetScheduleGroup" + ] + }, + "delete": { + "permissions": [ + "scheduler:DeleteScheduleGroup", + "scheduler:GetScheduleGroup", + "scheduler:DeleteSchedule" + ] + }, + "list": { + "permissions": [ + "scheduler:ListScheduleGroups" + ] + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "additionalProperties": false +} diff --git a/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup_plugin.py b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup_plugin.py new file mode 100644 index 0000000000000..2f76e843976f7 --- /dev/null +++ b/localstack-core/localstack/services/scheduler/resource_providers/aws_scheduler_schedulegroup_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SchedulerScheduleGroupProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::Scheduler::ScheduleGroup" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.scheduler.resource_providers.aws_scheduler_schedulegroup import ( + SchedulerScheduleGroupProvider, + ) + + self.factory = SchedulerScheduleGroupProvider diff --git a/localstack-core/localstack/services/secretsmanager/__init__.py b/localstack-core/localstack/services/secretsmanager/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/secretsmanager/provider.py b/localstack-core/localstack/services/secretsmanager/provider.py new file mode 100644 index 0000000000000..5838732f2c4b0 --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/provider.py @@ -0,0 +1,948 @@ +from __future__ import annotations + +import base64 +import json +import logging +import re +import time +from typing import Any, Final, Optional, Union + +import moto.secretsmanager.exceptions as moto_exception +from botocore.utils import InvalidArnException +from moto.iam.policy_validation import IAMPolicyDocumentValidator +from moto.secretsmanager import secretsmanager_backends +from moto.secretsmanager.models import FakeSecret, SecretsManagerBackend +from moto.secretsmanager.responses import SecretsManagerResponse + +from localstack.aws.api import CommonServiceException, RequestContext, handler +from localstack.aws.api.secretsmanager import ( + CancelRotateSecretRequest, + CancelRotateSecretResponse, + CreateSecretRequest, + CreateSecretResponse, + DeleteResourcePolicyRequest, + DeleteResourcePolicyResponse, + DeleteSecretRequest, + DeleteSecretResponse, + DescribeSecretRequest, + DescribeSecretResponse, + GetResourcePolicyRequest, + GetResourcePolicyResponse, + GetSecretValueRequest, + GetSecretValueResponse, + InvalidParameterException, + InvalidRequestException, + ListSecretVersionIdsRequest, + ListSecretVersionIdsResponse, + NameType, + PutResourcePolicyRequest, + PutResourcePolicyResponse, + PutSecretValueRequest, + PutSecretValueResponse, + RemoveRegionsFromReplicationRequest, + RemoveRegionsFromReplicationResponse, + ReplicateSecretToRegionsRequest, + ReplicateSecretToRegionsResponse, + ResourceExistsException, + ResourceNotFoundException, + RestoreSecretRequest, + RestoreSecretResponse, + RotateSecretRequest, + RotateSecretResponse, + SecretIdType, + SecretsmanagerApi, + SecretVersionsListEntry, + StopReplicationToReplicaRequest, + StopReplicationToReplicaResponse, + TagResourceRequest, + UntagResourceRequest, + UpdateSecretRequest, + UpdateSecretResponse, + UpdateSecretVersionStageRequest, + UpdateSecretVersionStageResponse, + ValidateResourcePolicyRequest, + ValidateResourcePolicyResponse, +) +from localstack.aws.connect import connect_to +from localstack.services.moto import call_moto +from localstack.utils.aws import arns +from localstack.utils.patch import patch +from localstack.utils.time import today_no_time + +# Constants. +AWSPREVIOUS: Final[str] = "AWSPREVIOUS" +AWSPENDING: Final[str] = "AWSPENDING" +AWSCURRENT: Final[str] = "AWSCURRENT" +# The maximum number of outdated versions that can be stored in the secret. +# see: https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_PutSecretValue.html +MAX_OUTDATED_SECRET_VERSIONS: Final[int] = 100 +# +# Error Messages. +AWS_INVALID_REQUEST_MESSAGE_CREATE_WITH_SCHEDULED_DELETION: Final[str] = ( + "You can't create this secret because a secret with this name is already scheduled for deletion." +) + +LOG = logging.getLogger(__name__) + + +class ValidationException(CommonServiceException): + def __init__(self, message: str): + super().__init__("ValidationException", message, 400, True) + + +class SecretNotFoundException(CommonServiceException): + def __init__(self): + super().__init__( + "ResourceNotFoundException", + "Secrets Manager can't find the specified secret.", + 400, + True, + ) + + +class SecretsmanagerProvider(SecretsmanagerApi): + def __init__(self): + super().__init__() + apply_patches() + + @staticmethod + def get_moto_backend_for_resource( + name_or_arn: str, context: RequestContext + ) -> SecretsManagerBackend: + try: + arn_data = arns.parse_arn(name_or_arn) + backend = secretsmanager_backends[arn_data["account"]][arn_data["region"]] + except InvalidArnException: + backend = secretsmanager_backends[context.account_id][context.region] + return backend + + @staticmethod + def _raise_if_default_kms_key( + secret_id: str, request: RequestContext, backend: SecretsManagerBackend + ): + try: + secret = backend.describe_secret(secret_id) + except moto_exception.SecretNotFoundException: + raise ResourceNotFoundException("Secrets Manager can't find the specified secret.") + if secret.kms_key_id is None and request.account_id != secret.account_id: + raise InvalidRequestException( + "You can't access a secret from a different AWS account if you encrypt the secret with the default KMS service key." + ) + + @staticmethod + def _validate_secret_id(secret_id: SecretIdType) -> bool: + # The secret name can contain ASCII letters, numbers, and the following characters: /_+=.@- + return bool(re.match(r"^[A-Za-z0-9/_+=.@-]+\Z", secret_id)) + + @staticmethod + def _raise_if_invalid_secret_id(secret_id: Union[SecretIdType, NameType]): + # Patches moto's implementation for which secret_ids are not validated, by raising a ValidationException. + # Skips this check if the secret_id provided appears to be an arn (starting with 'arn:'). + if not re.match( + r"^arn:", secret_id + ): # Check if it appears to be an arn: so to skip secret_id check: delegate parsing of arn to handlers. + if not SecretsmanagerProvider._validate_secret_id(secret_id): + raise ValidationException( + "Invalid name. Must be a valid name containing alphanumeric " + "characters, or any of the following: -/_+=.@!" + ) + + @staticmethod + def _raise_if_missing_client_req_token( + request: Union[ + CreateSecretRequest, + PutSecretValueRequest, + RotateSecretRequest, + UpdateSecretRequest, + ], + ): + if "ClientRequestToken" not in request: + raise InvalidRequestException( + "You must provide a ClientRequestToken value. We recommend a UUID-type value." + ) + + @handler("CancelRotateSecret", expand=False) + def cancel_rotate_secret( + self, context: RequestContext, request: CancelRotateSecretRequest + ) -> CancelRotateSecretResponse: + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + @handler("CreateSecret", expand=False) + def create_secret( + self, context: RequestContext, request: CreateSecretRequest + ) -> CreateSecretResponse: + self._raise_if_missing_client_req_token(request) + # Some providers need to create keys which are not usually creatable by users + if not any( + tag_entry["Key"] == "BYPASS_SECRET_ID_VALIDATION" + for tag_entry in request.get("Tags", []) + ): + self._raise_if_invalid_secret_id(request["Name"]) + else: + request["Tags"] = [ + tag_entry + for tag_entry in request.get("Tags", []) + if tag_entry["Key"] != "BYPASS_SECRET_ID_VALIDATION" + ] + + return call_moto(context, request) + + @handler("DeleteResourcePolicy", expand=False) + def delete_resource_policy( + self, context: RequestContext, request: DeleteResourcePolicyRequest + ) -> DeleteResourcePolicyResponse: + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + @handler("DeleteSecret", expand=False) + def delete_secret( + self, context: RequestContext, request: DeleteSecretRequest + ) -> DeleteSecretResponse: + secret_id: str = request["SecretId"] + self._raise_if_invalid_secret_id(secret_id) + recovery_window_in_days: Optional[int] = request.get("RecoveryWindowInDays") + force_delete_without_recovery: Optional[bool] = request.get("ForceDeleteWithoutRecovery") + + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + try: + arn, name, deletion_date = backend.delete_secret( + secret_id=secret_id, + recovery_window_in_days=recovery_window_in_days, + force_delete_without_recovery=force_delete_without_recovery, + ) + except moto_exception.InvalidParameterException as e: + raise InvalidParameterException(str(e)) + except moto_exception.InvalidRequestException: + raise InvalidRequestException( + "You tried to perform the operation on a secret that's currently marked deleted." + ) + except moto_exception.SecretNotFoundException: + raise SecretNotFoundException() + return DeleteSecretResponse(ARN=arn, Name=name, DeletionDate=deletion_date) + + @handler("DescribeSecret", expand=False) + def describe_secret( + self, context: RequestContext, request: DescribeSecretRequest + ) -> DescribeSecretResponse: + secret_id: str = request["SecretId"] + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + try: + secret = backend.describe_secret(secret_id) + except moto_exception.SecretNotFoundException: + raise ResourceNotFoundException("Secrets Manager can't find the specified secret.") + return DescribeSecretResponse(**secret.to_dict()) + + @handler("GetResourcePolicy", expand=False) + def get_resource_policy( + self, context: RequestContext, request: GetResourcePolicyRequest + ) -> GetResourcePolicyResponse: + secret_id = request["SecretId"] + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + policy = backend.get_resource_policy(secret_id) + return GetResourcePolicyResponse(**json.loads(policy)) + + @handler("GetSecretValue", expand=False) + def get_secret_value( + self, context: RequestContext, request: GetSecretValueRequest + ) -> GetSecretValueResponse: + secret_id = request.get("SecretId") + version_id = request.get("VersionId") + version_stage = request.get("VersionStage") + if not version_id and not version_stage: + version_stage = "AWSCURRENT" + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + self._raise_if_default_kms_key(secret_id, context, backend) + try: + response = backend.get_secret_value(secret_id, version_id, version_stage) + response = decode_secret_binary_from_response(response) + except moto_exception.SecretNotFoundException: + raise ResourceNotFoundException( + f"Secrets Manager can't find the specified secret value for staging label: {version_stage}" + ) + except moto_exception.ResourceNotFoundException: + error_message = ( + f"VersionId: {version_id}" if version_id else f"staging label: {version_stage}" + ) + raise ResourceNotFoundException( + f"Secrets Manager can't find the specified secret value for {error_message}" + ) + except moto_exception.SecretStageVersionMismatchException: + raise InvalidRequestException( + "You provided a VersionStage that is not associated to the provided VersionId." + ) + except moto_exception.SecretHasNoValueException: + raise ResourceNotFoundException( + f"Secrets Manager can't find the specified secret value for staging label: {version_stage}" + ) + except moto_exception.InvalidRequestException: + raise InvalidRequestException( + "You can't perform this operation on the secret because it was marked for deletion." + ) + return GetSecretValueResponse(**response) + + @handler("ListSecretVersionIds", expand=False) + def list_secret_version_ids( + self, context: RequestContext, request: ListSecretVersionIdsRequest + ) -> ListSecretVersionIdsResponse: + secret_id = request["SecretId"] + include_deprecated = request.get("IncludeDeprecated", False) + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + secrets = backend.list_secret_version_ids(secret_id, include_deprecated=include_deprecated) + return ListSecretVersionIdsResponse(**json.loads(secrets)) + + @handler("PutResourcePolicy", expand=False) + def put_resource_policy( + self, context: RequestContext, request: PutResourcePolicyRequest + ) -> PutResourcePolicyResponse: + secret_id = request["SecretId"] + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + arn, name = backend.put_resource_policy(secret_id, request["ResourcePolicy"]) + return PutResourcePolicyResponse(ARN=arn, Name=name) + + @handler("PutSecretValue", expand=False) + def put_secret_value( + self, context: RequestContext, request: PutSecretValueRequest + ) -> PutSecretValueResponse: + secret_id = request["SecretId"] + self._raise_if_invalid_secret_id(secret_id) + self._raise_if_missing_client_req_token(request) + client_req_token = request.get("ClientRequestToken") + secret_string = request.get("SecretString") + secret_binary = request.get("SecretBinary") + if not secret_binary and not secret_string: + raise InvalidRequestException("You must provide either SecretString or SecretBinary.") + + version_stages = request.get("VersionStages", ["AWSCURRENT"]) + if not isinstance(version_stages, list): + version_stages = [version_stages] + + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + self._raise_if_default_kms_key(secret_id, context, backend) + + response = backend.put_secret_value( + secret_id=secret_id, + secret_binary=secret_binary, + secret_string=secret_string, + version_stages=version_stages, + client_request_token=client_req_token, + ) + return PutSecretValueResponse(**json.loads(response)) + + @handler("RemoveRegionsFromReplication", expand=False) + def remove_regions_from_replication( + self, context: RequestContext, request: RemoveRegionsFromReplicationRequest + ) -> RemoveRegionsFromReplicationResponse: + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + @handler("ReplicateSecretToRegions", expand=False) + def replicate_secret_to_regions( + self, context: RequestContext, request: ReplicateSecretToRegionsRequest + ) -> ReplicateSecretToRegionsResponse: + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + @handler("RestoreSecret", expand=False) + def restore_secret( + self, context: RequestContext, request: RestoreSecretRequest + ) -> RestoreSecretResponse: + secret_id = request["SecretId"] + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + try: + arn, name = backend.restore_secret(secret_id) + except moto_exception.SecretNotFoundException: + raise ResourceNotFoundException("Secrets Manager can't find the specified secret.") + return RestoreSecretResponse(ARN=arn, Name=name) + + @handler("RotateSecret", expand=False) + def rotate_secret( + self, context: RequestContext, request: RotateSecretRequest + ) -> RotateSecretResponse: + self._raise_if_missing_client_req_token(request) + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + @handler("StopReplicationToReplica", expand=False) + def stop_replication_to_replica( + self, context: RequestContext, request: StopReplicationToReplicaRequest + ) -> StopReplicationToReplicaResponse: + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + @handler("TagResource", expand=False) + def tag_resource(self, context: RequestContext, request: TagResourceRequest) -> None: + secret_id = request["SecretId"] + tags = request["Tags"] + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + backend.tag_resource(secret_id, tags) + + @handler("UntagResource", expand=False) + def untag_resource(self, context: RequestContext, request: UntagResourceRequest) -> None: + secret_id = request["SecretId"] + tag_keys = request.get("TagKeys") + self._raise_if_invalid_secret_id(secret_id) + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + backend.untag_resource(secret_id=secret_id, tag_keys=tag_keys) + + @handler("UpdateSecret", expand=False) + def update_secret( + self, context: RequestContext, request: UpdateSecretRequest + ) -> UpdateSecretResponse: + # if we're modifying the value of the secret, ClientRequestToken is required + secret_id = request["SecretId"] + secret_string = request.get("SecretString") + secret_binary = request.get("SecretBinary") + description = request.get("Description") + kms_key_id = request.get("KmsKeyId") + client_req_token = request.get("ClientRequestToken") + self._raise_if_invalid_secret_id(secret_id) + self._raise_if_missing_client_req_token(request) + + backend = SecretsmanagerProvider.get_moto_backend_for_resource(secret_id, context) + try: + secret = backend.update_secret( + secret_id, + description=description, + secret_string=secret_string, + secret_binary=secret_binary, + client_request_token=client_req_token, + kms_key_id=kms_key_id, + ) + except moto_exception.SecretNotFoundException: + raise ResourceNotFoundException("Secrets Manager can't find the specified secret.") + except moto_exception.OperationNotPermittedOnReplica: + raise InvalidRequestException( + "Operation not permitted on a replica secret. Call must be made in primary secret's region." + ) + except moto_exception.InvalidRequestException: + raise InvalidRequestException( + "An error occurred (InvalidRequestException) when calling the UpdateSecret operation: " + "You can't perform this operation on the secret because it was marked for deletion." + ) + return UpdateSecretResponse(**json.loads(secret)) + + @handler("UpdateSecretVersionStage", expand=False) + def update_secret_version_stage( + self, context: RequestContext, request: UpdateSecretVersionStageRequest + ) -> UpdateSecretVersionStageResponse: + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + @handler("ValidateResourcePolicy", expand=False) + def validate_resource_policy( + self, context: RequestContext, request: ValidateResourcePolicyRequest + ) -> ValidateResourcePolicyResponse: + self._raise_if_invalid_secret_id(request["SecretId"]) + return call_moto(context, request) + + +@patch(FakeSecret.__init__) +def fake_secret__init__(fn, self, *args, **kwargs): + fn(self, *args, **kwargs) + + # Fix time not including millis. + time_now = time.time() + if kwargs.get("last_changed_date", None): + self.last_changed_date = time_now + if kwargs.get("created_date", None): + self.created_date = time_now + + # The last date that the secret value was retrieved. + # This value does not include the time. + # This field is omitted if the secret has never been retrieved. + self.last_accessed_date = None + # Results in RotationEnabled being returned only if rotation was ever overwritten, + # in which case this field is non-null, but an integer. + self.auto_rotate_after_days = None + self.rotation_lambda_arn = None + + +@patch(FakeSecret.update) +def fake_secret_update( + fn, self, description=None, tags=None, kms_key_id=None, last_changed_date=None +): + fn(self, description, tags, kms_key_id, last_changed_date) + if last_changed_date is not None: + self.last_changed_date = round(time.time(), 3) + + +@patch(SecretsManagerBackend.get_secret_value) +def moto_smb_get_secret_value(fn, self, secret_id, version_id, version_stage): + res = fn(self, secret_id, version_id, version_stage) + + secret = self.secrets[secret_id] + + # Patch: update last accessed date on get. + secret.last_accessed_date = today_no_time() + + # Patch: update version's last accessed date. + secret_version = secret.versions.get(version_id or secret.default_version_id) + if secret_version: + secret_version["last_accessed_date"] = secret.last_accessed_date + + return res + + +@patch(SecretsManagerBackend.create_secret) +def moto_smb_create_secret(fn, self, name, *args, **kwargs): + # Creating a secret with a SecretId equal to one that is scheduled for + # deletion should raise an 'InvalidRequestException'. + secret: Optional[FakeSecret] = self.secrets.get(name) + if secret is not None and secret.deleted_date is not None: + raise InvalidRequestException(AWS_INVALID_REQUEST_MESSAGE_CREATE_WITH_SCHEDULED_DELETION) + + if name in self.secrets: + raise ResourceExistsException( + f"The operation failed because the secret {name} already exists." + ) + + return fn(self, name, *args, **kwargs) + + +@patch(SecretsManagerBackend.list_secret_version_ids) +def moto_smb_list_secret_version_ids( + _, self, secret_id: str, include_deprecated: bool, *args, **kwargs +): + if secret_id not in self.secrets: + raise SecretNotFoundException() + + if self.secrets[secret_id].is_deleted(): + raise InvalidRequestException( + "An error occurred (InvalidRequestException) when calling the UpdateSecret operation: " + "You can't perform this operation on the secret because it was marked for deletion." + ) + + secret = self.secrets[secret_id] + + # Patch: output format, report exact createdate instead of current time. + versions: list[SecretVersionsListEntry] = list() + for version_id, version in secret.versions.items(): + version_stages = version["version_stages"] + # Patch: include deprecated versions if include_deprecated is True. + # version_stages is empty if the version is deprecated. + # see: https://docs.aws.amazon.com/secretsmanager/latest/userguide/getting-started.html#term_version + if len(version_stages) > 0 or include_deprecated: + entry = SecretVersionsListEntry( + CreatedDate=version["createdate"], + VersionId=version_id, + ) + + if version_stages: + entry["VersionStages"] = version_stages + + # Patch: bind LastAccessedDate if one exists for this version. + last_accessed_date = version.get("last_accessed_date") + if last_accessed_date: + entry["LastAccessedDate"] = last_accessed_date + + versions.append(entry) + + # Patch: sort versions by date. + versions.sort(key=lambda v: v["CreatedDate"], reverse=True) + + response = ListSecretVersionIdsResponse(ARN=secret.arn, Name=secret.name, Versions=versions) + + return json.dumps(response) + + +@patch(FakeSecret.to_dict) +def fake_secret_to_dict(fn, self): + res_dict = fn(self) + if self.last_accessed_date: + res_dict["LastAccessedDate"] = self.last_accessed_date + if not self.description and "Description" in res_dict: + del res_dict["Description"] + if not self.rotation_enabled and "RotationEnabled" in res_dict: + del res_dict["RotationEnabled"] + if self.auto_rotate_after_days is None and "RotationRules" in res_dict: + del res_dict["RotationRules"] + if self.tags is None and "Tags" in res_dict: + del res_dict["Tags"] + for null_field in [key for key, value in res_dict.items() if value is None]: + del res_dict[null_field] + return res_dict + + +@patch(SecretsManagerBackend.update_secret) +def backend_update_secret( + fn, + self, + secret_id, + description=None, + **kwargs, +): + if secret_id not in self.secrets: + raise SecretNotFoundException() + + if self.secrets[secret_id].is_deleted(): + raise InvalidRequestException( + "An error occurred (InvalidRequestException) when calling the UpdateSecret operation: " + "You can't perform this operation on the secret because it was marked for deletion." + ) + + secret = self.secrets[secret_id] + version_id_t0 = secret.default_version_id + + requires_new_version: bool = any( + [kwargs.get("kms_key_id"), kwargs.get("secret_binary"), kwargs.get("secret_string")] + ) + if requires_new_version: + fn(self, secret_id, **kwargs) + + if description is not None: + secret.description = description + + version_id_t1 = secret.default_version_id + + resp: UpdateSecretResponse = UpdateSecretResponse() + resp["ARN"] = secret.arn + resp["Name"] = secret.name + + if version_id_t0 != version_id_t1: + resp["VersionId"] = version_id_t1 + + return json.dumps(resp) + + +@patch(SecretsManagerResponse.update_secret, pass_target=False) +def response_update_secret(self): + secret_id = self._get_param("SecretId") + description = self._get_param("Description") + secret_string = self._get_param("SecretString") + secret_binary = self._get_param("SecretBinary") + client_request_token = self._get_param("ClientRequestToken") + kms_key_id = self._get_param("KmsKeyId") + return self.backend.update_secret( + secret_id=secret_id, + description=description, + secret_string=secret_string, + secret_binary=secret_binary, + client_request_token=client_request_token, + kms_key_id=kms_key_id, + ) + + +@patch(SecretsManagerBackend.update_secret_version_stage) +def backend_update_secret_version_stage( + fn, self, secret_id, version_stage, remove_from_version_id, move_to_version_id +): + fn(self, secret_id, version_stage, remove_from_version_id, move_to_version_id) + + secret = self.secrets[secret_id] + + # Patch: default version is the new AWSCURRENT version + if version_stage == AWSCURRENT: + secret.default_version_id = move_to_version_id + + versions_no_stages = [] + for version_id, version in secret.versions.items(): + version_stages = version["version_stages"] + + # moto appends a new AWSPREVIOUS label to the version AWSCURRENT was removed from, + # but it does not remove the old AWSPREVIOUS label. + # Patch: ensure only one AWSPREVIOUS tagged version is in the pool. + if ( + version_stage == AWSCURRENT + and version_id != remove_from_version_id + and AWSPREVIOUS in version_stages + ): + version_stages.remove(AWSPREVIOUS) + + if not version_stages: + versions_no_stages.append(version_id) + + # Patch: remove secret versions with no version stages. + for version_no_stages in versions_no_stages: + del secret.versions[version_no_stages] + + return secret.arn, secret.name + + +@patch(FakeSecret.reset_default_version) +def fake_secret_reset_default_version(fn, self, secret_version, version_id): + fn(self, secret_version, version_id) + + # Remove versions with no version stages, if max limit of outdated versions is exceeded. + versions_no_stages: list[str] = [ + version_id for version_id, version in self.versions.items() if not version["version_stages"] + ] + versions_to_delete: list[str] = [] + + # Patch: remove outdated versions if the max deprecated versions limit is exceeded. + if len(versions_no_stages) >= MAX_OUTDATED_SECRET_VERSIONS: + versions_to_delete = versions_no_stages[ + : len(versions_no_stages) - MAX_OUTDATED_SECRET_VERSIONS + ] + + for version_to_delete in versions_to_delete: + del self.versions[version_to_delete] + + +@patch(FakeSecret.remove_version_stages_from_old_versions) +def fake_secret_remove_version_stages_from_old_versions(fn, self, version_stages): + fn(self, version_stages) + # Remove versions with no version stages. + versions_no_stages = [ + version_id for version_id, version in self.versions.items() if not version["version_stages"] + ] + for version_no_stages in versions_no_stages: + del self.versions[version_no_stages] + + +# Moto does not support rotate_immediately as an API parameter while the AWS API does +@patch(SecretsManagerResponse.rotate_secret, pass_target=False) +def rotate_secret(self) -> str: + client_request_token = self._get_param("ClientRequestToken") + rotation_lambda_arn = self._get_param("RotationLambdaARN") + rotation_rules = self._get_param("RotationRules") + rotate_immediately = self._get_param("RotateImmediately") + secret_id = self._get_param("SecretId") + return self.backend.rotate_secret( + secret_id=secret_id, + client_request_token=client_request_token, + rotation_lambda_arn=rotation_lambda_arn, + rotation_rules=rotation_rules, + rotate_immediately=True if rotate_immediately is None else rotate_immediately, + ) + + +@patch(SecretsManagerBackend.rotate_secret) +def backend_rotate_secret( + _, + self, + secret_id, + client_request_token=None, + rotation_lambda_arn=None, + rotation_rules=None, + rotate_immediately=True, +): + rotation_days = "AutomaticallyAfterDays" + + if not self._is_valid_identifier(secret_id): + raise SecretNotFoundException() + + secret = self.secrets[secret_id] + if secret.is_deleted(): + raise InvalidRequestException( + "An error occurred (InvalidRequestException) when calling the RotateSecret operation: You tried to \ + perform the operation on a secret that's currently marked deleted." + ) + # Resolve rotation_lambda_arn and fallback to previous value if its missing + # from the current request + rotation_lambda_arn = rotation_lambda_arn or secret.rotation_lambda_arn + if not rotation_lambda_arn: + raise InvalidRequestException( + "No Lambda rotation function ARN is associated with this secret." + ) + + if rotation_lambda_arn: + if len(rotation_lambda_arn) > 2048: + msg = "RotationLambdaARN must <= 2048 characters long." + raise InvalidParameterException(msg) + + # In case rotation_period is not provided, resolve auto_rotate_after_days + # and fallback to previous value if its missing from the current request. + rotation_period = secret.auto_rotate_after_days or 0 + if rotation_rules: + if rotation_days in rotation_rules: + rotation_period = rotation_rules[rotation_days] + if rotation_period < 1 or rotation_period > 1000: + msg = "RotationRules.AutomaticallyAfterDays must be within 1-1000." + raise InvalidParameterException(msg) + + try: + lm_client = connect_to(region_name=self.region_name).lambda_ + lm_client.get_function(FunctionName=rotation_lambda_arn) + except Exception: + raise ResourceNotFoundException("Lambda does not exist or could not be accessed") + + # The rotation function must end with the versions of the secret in + # one of two states: + # + # - The AWSPENDING and AWSCURRENT staging labels are attached to the + # same version of the secret, or + # - The AWSPENDING staging label is not attached to any version of the secret. + # + # If the AWSPENDING staging label is present but not attached to the same + # version as AWSCURRENT then any later invocation of RotateSecret assumes + # that a previous rotation request is still in progress and returns an error. + try: + pending_version = None + version = next( + version + for version in secret.versions.values() + if AWSPENDING in version["version_stages"] + ) + if AWSCURRENT not in version["version_stages"]: + msg = "Previous rotation request is still in progress." + # Delay exception, so we can trigger lambda again + pending_version = [InvalidRequestException(msg), version] + + except StopIteration: + # Pending is not present in any version + pass + + secret.rotation_lambda_arn = rotation_lambda_arn + secret.auto_rotate_after_days = rotation_period + if secret.auto_rotate_after_days > 0: + wait_interval_s = int(rotation_period) * 86400 + secret.next_rotation_date = int(time.time()) + wait_interval_s + secret.rotation_enabled = True + secret.rotation_requested = True + + if rotate_immediately: + if not pending_version: + # Begin the rotation process for the given secret by invoking the lambda function. + # + # We add the new secret version as "pending". The previous version remains + # as "current" for now. Once we've passed the new secret through the lambda + # rotation function (if provided) we can then update the status to "current". + new_version_id = self._from_client_request_token(client_request_token) + + # An initial dummy secret value is necessary otherwise moto is not adding the new + # secret version. + self._add_secret( + secret_id, + "dummy_password", + description=secret.description, + tags=secret.tags, + version_id=new_version_id, + version_stages=[AWSPENDING], + ) + + # AWS secret rotation function templates have checks on existing values so we remove + # the dummy value to force the lambda to generate a new one. + del secret.versions[new_version_id]["secret_string"] + else: + new_version_id = pending_version.pop()["version_id"] + + try: + for step in ["create", "set", "test", "finish"]: + resp = lm_client.invoke( + FunctionName=rotation_lambda_arn, + Payload=json.dumps( + { + "Step": step + "Secret", + "SecretId": secret.name, + "ClientRequestToken": new_version_id, + } + ), + ) + if resp.get("FunctionError"): + data = json.loads(resp.get("Payload").read()) + raise Exception(data.get("errorType")) + except Exception as e: + LOG.debug("An exception (%s) has occurred in %s", str(e), rotation_lambda_arn) + if pending_version: + raise pending_version.pop() + # Fall through if there is no previously pending version so we'll "stuck" with a new + # secret version in AWSPENDING state. + secret.last_rotation_date = int(time.time()) + return secret.to_short_dict(version_id=new_version_id) + + +@patch(moto_exception.SecretNotFoundException.__init__) +def moto_secret_not_found_exception_init(fn, self): + fn(self) + self.code = 400 + + +@patch(FakeSecret._form_version_ids_to_stages, pass_target=False) +def _form_version_ids_to_stages_modal(self): + version_id_to_stages: dict[str, list] = {} + for key, value in self.versions.items(): + # Patch: include version_stages in the response only if it is not empty. + if len(value["version_stages"]) > 0: + version_id_to_stages[key] = value["version_stages"] + return version_id_to_stages + + +# patching resource policy in moto +def get_resource_policy_model(self, secret_id): + if self._is_valid_identifier(secret_id): + result = { + "ARN": self.secrets[secret_id].arn, + "Name": self.secrets[secret_id].secret_id, + } + policy = getattr(self.secrets[secret_id], "policy", None) + if policy: + result["ResourcePolicy"] = policy + return json.dumps(result) + else: + raise SecretNotFoundException() + + +def get_resource_policy_response(self): + secret_id = self._get_param("SecretId") + return self.backend.get_resource_policy(secret_id=secret_id) + + +def decode_secret_binary_from_response(response: dict[str, Any]): + if "SecretBinary" in response: + response["SecretBinary"] = base64.b64decode(response["SecretBinary"]) + + return response + + +def delete_resource_policy_model(self, secret_id): + if self._is_valid_identifier(secret_id): + self.secrets[secret_id].policy = None + return json.dumps( + { + "ARN": self.secrets[secret_id].arn, + "Name": self.secrets[secret_id].secret_id, + } + ) + else: + raise SecretNotFoundException() + + +def delete_resource_policy_response(self): + secret_id = self._get_param("SecretId") + return self.backend.delete_resource_policy(secret_id=secret_id) + + +def put_resource_policy_model(self, secret_id, resource_policy): + policy_validator = IAMPolicyDocumentValidator(resource_policy) + policy_validator._validate_top_elements() + policy_validator._validate_version_syntax() + if self._is_valid_identifier(secret_id): + self.secrets[secret_id].policy = resource_policy + return json.dumps( + { + "ARN": self.secrets[secret_id].arn, + "Name": self.secrets[secret_id].secret_id, + } + ) + else: + raise SecretNotFoundException() + + +def put_resource_policy_response(self): + secret_id = self._get_param("SecretId") + resource_policy = self._get_param("ResourcePolicy") + return self.backend.put_resource_policy( + secret_id=secret_id, resource_policy=json.loads(resource_policy) + ) + + +def apply_patches(): + SecretsManagerBackend.get_resource_policy = get_resource_policy_model + SecretsManagerResponse.get_resource_policy = get_resource_policy_response + + if not hasattr(SecretsManagerBackend, "delete_resource_policy"): + SecretsManagerBackend.delete_resource_policy = delete_resource_policy_model + if not hasattr(SecretsManagerResponse, "delete_resource_policy"): + SecretsManagerResponse.delete_resource_policy = delete_resource_policy_response + if not hasattr(SecretsManagerBackend, "put_resource_policy"): + SecretsManagerBackend.put_resource_policy = put_resource_policy_model + if not hasattr(SecretsManagerResponse, "put_resource_policy"): + SecretsManagerResponse.put_resource_policy = put_resource_policy_response diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/__init__.py b/localstack-core/localstack/services/secretsmanager/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy.py new file mode 100644 index 0000000000000..53784023f67f5 --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy.py @@ -0,0 +1,112 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SecretsManagerResourcePolicyProperties(TypedDict): + ResourcePolicy: Optional[dict] + SecretId: Optional[str] + BlockPublicPolicy: Optional[bool] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SecretsManagerResourcePolicyProvider( + ResourceProvider[SecretsManagerResourcePolicyProperties] +): + TYPE = "AWS::SecretsManager::ResourcePolicy" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SecretsManagerResourcePolicyProperties], + ) -> ProgressEvent[SecretsManagerResourcePolicyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - SecretId + - ResourcePolicy + + Create-only properties: + - /properties/SecretId + + Read-only properties: + - /properties/Id + + """ + model = request.desired_state + secret_manager = request.aws_client_factory.secretsmanager + + params = { + "SecretId": model["SecretId"], + "ResourcePolicy": json.dumps(model["ResourcePolicy"]), + "BlockPublicPolicy": model.get("BlockPublicPolicy") or True, + } + response = secret_manager.put_resource_policy(**params) + + model["Id"] = response["ARN"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SecretsManagerResourcePolicyProperties], + ) -> ProgressEvent[SecretsManagerResourcePolicyProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SecretsManagerResourcePolicyProperties], + ) -> ProgressEvent[SecretsManagerResourcePolicyProperties]: + """ + Delete a resource + + """ + model = request.desired_state + secret_manager = request.aws_client_factory.secretsmanager + + response = secret_manager.delete_resource_policy(SecretId=model["SecretId"]) + + model["Id"] = response["ARN"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SecretsManagerResourcePolicyProperties], + ) -> ProgressEvent[SecretsManagerResourcePolicyProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy.schema.json b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy.schema.json new file mode 100644 index 0000000000000..cb829fc66c01d --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy.schema.json @@ -0,0 +1,32 @@ +{ + "typeName": "AWS::SecretsManager::ResourcePolicy", + "description": "Resource Type definition for AWS::SecretsManager::ResourcePolicy", + "additionalProperties": false, + "properties": { + "ResourcePolicy": { + "type": "object" + }, + "Id": { + "type": "string" + }, + "BlockPublicPolicy": { + "type": "boolean" + }, + "SecretId": { + "type": "string" + } + }, + "required": [ + "SecretId", + "ResourcePolicy" + ], + "createOnlyProperties": [ + "/properties/SecretId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy_plugin.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy_plugin.py new file mode 100644 index 0000000000000..1571bbfd89afc --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_resourcepolicy_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SecretsManagerResourcePolicyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SecretsManager::ResourcePolicy" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.secretsmanager.resource_providers.aws_secretsmanager_resourcepolicy import ( + SecretsManagerResourcePolicyProvider, + ) + + self.factory = SecretsManagerResourcePolicyProvider diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule.py new file mode 100644 index 0000000000000..b838450d24a1d --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule.py @@ -0,0 +1,125 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SecretsManagerRotationScheduleProperties(TypedDict): + SecretId: Optional[str] + HostedRotationLambda: Optional[HostedRotationLambda] + Id: Optional[str] + RotateImmediatelyOnUpdate: Optional[bool] + RotationLambdaARN: Optional[str] + RotationRules: Optional[RotationRules] + + +class RotationRules(TypedDict): + AutomaticallyAfterDays: Optional[int] + Duration: Optional[str] + ScheduleExpression: Optional[str] + + +class HostedRotationLambda(TypedDict): + RotationType: Optional[str] + ExcludeCharacters: Optional[str] + KmsKeyArn: Optional[str] + MasterSecretArn: Optional[str] + MasterSecretKmsKeyArn: Optional[str] + RotationLambdaName: Optional[str] + Runtime: Optional[str] + SuperuserSecretArn: Optional[str] + SuperuserSecretKmsKeyArn: Optional[str] + VpcSecurityGroupIds: Optional[str] + VpcSubnetIds: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SecretsManagerRotationScheduleProvider( + ResourceProvider[SecretsManagerRotationScheduleProperties] +): + TYPE = "AWS::SecretsManager::RotationSchedule" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SecretsManagerRotationScheduleProperties], + ) -> ProgressEvent[SecretsManagerRotationScheduleProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - SecretId + + Create-only properties: + - /properties/SecretId + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + if not model.get("Id"): + model["Id"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SecretsManagerRotationScheduleProperties], + ) -> ProgressEvent[SecretsManagerRotationScheduleProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SecretsManagerRotationScheduleProperties], + ) -> ProgressEvent[SecretsManagerRotationScheduleProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SecretsManagerRotationScheduleProperties], + ) -> ProgressEvent[SecretsManagerRotationScheduleProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule.schema.json b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule.schema.json new file mode 100644 index 0000000000000..a99cc063f1106 --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule.schema.json @@ -0,0 +1,96 @@ +{ + "typeName": "AWS::SecretsManager::RotationSchedule", + "description": "Resource Type definition for AWS::SecretsManager::RotationSchedule", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "RotationLambdaARN": { + "type": "string" + }, + "RotationRules": { + "$ref": "#/definitions/RotationRules" + }, + "RotateImmediatelyOnUpdate": { + "type": "boolean" + }, + "SecretId": { + "type": "string" + }, + "HostedRotationLambda": { + "$ref": "#/definitions/HostedRotationLambda" + } + }, + "definitions": { + "HostedRotationLambda": { + "type": "object", + "additionalProperties": false, + "properties": { + "Runtime": { + "type": "string" + }, + "RotationType": { + "type": "string" + }, + "RotationLambdaName": { + "type": "string" + }, + "KmsKeyArn": { + "type": "string" + }, + "MasterSecretArn": { + "type": "string" + }, + "VpcSecurityGroupIds": { + "type": "string" + }, + "ExcludeCharacters": { + "type": "string" + }, + "MasterSecretKmsKeyArn": { + "type": "string" + }, + "SuperuserSecretArn": { + "type": "string" + }, + "SuperuserSecretKmsKeyArn": { + "type": "string" + }, + "VpcSubnetIds": { + "type": "string" + } + }, + "required": [ + "RotationType" + ] + }, + "RotationRules": { + "type": "object", + "additionalProperties": false, + "properties": { + "ScheduleExpression": { + "type": "string" + }, + "Duration": { + "type": "string" + }, + "AutomaticallyAfterDays": { + "type": "integer" + } + } + } + }, + "required": [ + "SecretId" + ], + "createOnlyProperties": [ + "/properties/SecretId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule_plugin.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule_plugin.py new file mode 100644 index 0000000000000..dd680bd788d1f --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_rotationschedule_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SecretsManagerRotationScheduleProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SecretsManager::RotationSchedule" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.secretsmanager.resource_providers.aws_secretsmanager_rotationschedule import ( + SecretsManagerRotationScheduleProvider, + ) + + self.factory = SecretsManagerRotationScheduleProvider diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret.py new file mode 100644 index 0000000000000..d53dbd2e9aefe --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret.py @@ -0,0 +1,272 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +import logging +import random +import string +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + +LOG = logging.getLogger(__name__) + + +class SecretsManagerSecretProperties(TypedDict): + Description: Optional[str] + GenerateSecretString: Optional[GenerateSecretString] + Id: Optional[str] + KmsKeyId: Optional[str] + Name: Optional[str] + ReplicaRegions: Optional[list[ReplicaRegion]] + SecretString: Optional[str] + Tags: Optional[list[Tag]] + + +class GenerateSecretString(TypedDict): + ExcludeCharacters: Optional[str] + ExcludeLowercase: Optional[bool] + ExcludeNumbers: Optional[bool] + ExcludePunctuation: Optional[bool] + ExcludeUppercase: Optional[bool] + GenerateStringKey: Optional[str] + IncludeSpace: Optional[bool] + PasswordLength: Optional[int] + RequireEachIncludedType: Optional[bool] + SecretStringTemplate: Optional[str] + + +class ReplicaRegion(TypedDict): + Region: Optional[str] + KmsKeyId: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SecretsManagerSecretProvider(ResourceProvider[SecretsManagerSecretProperties]): + TYPE = "AWS::SecretsManager::Secret" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SecretsManagerSecretProperties], + ) -> ProgressEvent[SecretsManagerSecretProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Id + + IAM permissions required: + - secretsmanager:DescribeSecret + - secretsmanager:GetRandomPassword + - secretsmanager:CreateSecret + - secretsmanager:TagResource + + """ + model = request.desired_state + secrets_manager = request.aws_client_factory.secretsmanager + + if not model.get("Name"): + # not actually correct. Given the LogicalResourceId "MySecret", + # an example for the generated name would be "MySecret-krxoxgcznYdq-sQNsqO" + model["Name"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + attributes = ["Name", "Description", "KmsKeyId", "SecretString", "Tags"] + params = util.select_attributes(model, attributes) + + """ + From CFn Docs: + If you omit both GenerateSecretString and SecretString, you create an empty secret. + When you make a change to this property, a new secret version is created. + CDK wil generate empty dict in which case we also need to generate SecretString + """ + + gen_secret = model.get("GenerateSecretString") + if gen_secret is not None: + secret_value = self._get_secret_value(gen_secret) + template = gen_secret.get("SecretStringTemplate") + if template: + secret_value = self._modify_secret_template(template, secret_value, gen_secret) + params["SecretString"] = secret_value + + response = secrets_manager.create_secret(**params) + model["Id"] = response["ARN"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def _get_secret_value(self, gen_secret): + excl_lower = gen_secret.get("ExcludeLowercase") + excl_upper = gen_secret.get("ExcludeUppercase") + excl_chars = gen_secret.get("ExcludeCharacters") or "" + excl_numbers = gen_secret.get("ExcludeNumbers") + excl_punct = gen_secret.get("ExcludePunctuation") + incl_spaces = gen_secret.get("IncludeSpace") + length = gen_secret.get("PasswordLength") or 32 + req_each = gen_secret.get("RequireEachIncludedType") + return self.generate_secret_value( + length=length, + excl_lower=excl_lower, + excl_upper=excl_upper, + excl_punct=excl_punct, + incl_spaces=incl_spaces, + excl_chars=excl_chars, + excl_numbers=excl_numbers, + req_each=req_each, + ) + + def _modify_secret_template(self, template, secret_value, gen_secret): + gen_key = gen_secret.get("GenerateStringKey") or "secret" + template = json.loads(template) + template[gen_key] = secret_value + return json.dumps(template) + + def generate_secret_value( + self, + length: int, + excl_lower: bool, + excl_upper: bool, + excl_chars: str, + excl_numbers: bool, + excl_punct: bool, + incl_spaces: bool, + req_each: bool, + ) -> str: + """WARN: This is NOT a secure way to generate secrets - use only for testing and not in production use cases!""" + + # TODO: add a couple of unit tests for this function ... + + punctuation = r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~" + alphabet = "" + if not excl_punct: + alphabet += punctuation + if not excl_upper: + alphabet += string.ascii_uppercase + if not excl_lower: + alphabet += string.ascii_lowercase + if not excl_numbers: + alphabet += "".join([str(i) for i in list(range(10))]) + if incl_spaces: + alphabet += " " + if req_each: + LOG.info("Secret generation option 'RequireEachIncludedType' not yet supported") + + for char in excl_chars: + alphabet = alphabet.replace(char, "") + + result = [alphabet[random.randrange(len(alphabet))] for _ in range(length)] + result = "".join(result) + return result + + def read( + self, + request: ResourceRequest[SecretsManagerSecretProperties], + ) -> ProgressEvent[SecretsManagerSecretProperties]: + """ + Fetch resource information + + IAM permissions required: + - secretsmanager:DescribeSecret + - secretsmanager:GetSecretValue + """ + secretsmanager = request.aws_client_factory.secretsmanager + secret_id = request.desired_state["Id"] + + secret = secretsmanager.describe_secret(SecretId=secret_id) + model = SecretsManagerSecretProperties( + **util.select_attributes(secret, self.SCHEMA["properties"]) + ) + model["Id"] = secret["ARN"] + + if "Tags" not in model: + model["Tags"] = [] + + model["ReplicaRegions"] = [ + {"KmsKeyId": replication_region["KmsKeyId"], "Region": replication_region["Region"]} + for replication_region in secret.get("ReplicationStatus", []) + ] + if "ReplicaRegions" not in model: + model["ReplicaRegions"] = [] + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + + def delete( + self, + request: ResourceRequest[SecretsManagerSecretProperties], + ) -> ProgressEvent[SecretsManagerSecretProperties]: + """ + Delete a resource + + IAM permissions required: + - secretsmanager:DeleteSecret + - secretsmanager:DescribeSecret + - secretsmanager:RemoveRegionsFromReplication + """ + model = request.desired_state + secrets_manager = request.aws_client_factory.secretsmanager + + secrets_manager.delete_secret(SecretId=model["Name"], ForceDeleteWithoutRecovery=True) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SecretsManagerSecretProperties], + ) -> ProgressEvent[SecretsManagerSecretProperties]: + """ + Update a resource + + IAM permissions required: + - secretsmanager:UpdateSecret + - secretsmanager:TagResource + - secretsmanager:UntagResource + - secretsmanager:GetRandomPassword + - secretsmanager:GetSecretValue + - secretsmanager:ReplicateSecretToRegions + - secretsmanager:RemoveRegionsFromReplication + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[SecretsManagerSecretProperties], + ) -> ProgressEvent[SecretsManagerSecretProperties]: + resources = request.aws_client_factory.secretsmanager.list_secrets() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + SecretsManagerSecretProperties(Id=resource["Name"]) + for resource in resources["SecretList"] + ], + ) diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret.schema.json b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret.schema.json new file mode 100644 index 0000000000000..408bb14bcdfd1 --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret.schema.json @@ -0,0 +1,195 @@ +{ + "typeName": "AWS::SecretsManager::Secret", + "$schema": "https://schema.cloudformation.us-east-1.amazonaws.com/provider.definition.schema.v1.json", + "description": "Resource Type definition for AWS::SecretsManager::Secret", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-secretsmanager.git", + "additionalProperties": false, + "properties": { + "Description": { + "type": "string", + "description": "(Optional) Specifies a user-provided description of the secret." + }, + "KmsKeyId": { + "type": "string", + "description": "(Optional) Specifies the ARN, Key ID, or alias of the AWS KMS customer master key (CMK) used to encrypt the SecretString." + }, + "SecretString": { + "type": "string", + "description": "(Optional) Specifies text data that you want to encrypt and store in this new version of the secret." + }, + "GenerateSecretString": { + "$ref": "#/definitions/GenerateSecretString", + "description": "(Optional) Specifies text data that you want to encrypt and store in this new version of the secret." + }, + "ReplicaRegions": { + "type": "array", + "description": "(Optional) A list of ReplicaRegion objects. The ReplicaRegion type consists of a Region (required) and the KmsKeyId which can be an ARN, Key ID, or Alias.", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/ReplicaRegion" + } + }, + "Id": { + "type": "string", + "description": "secret Id, the Arn of the resource." + }, + "Tags": { + "type": "array", + "description": "The list of user-defined tags associated with the secret. Use tags to manage your AWS resources. For additional information about tags, see TagResource.", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Name": { + "type": "string", + "description": "The friendly name of the secret. You can use forward slashes in the name to represent a path hierarchy." + } + }, + "definitions": { + "GenerateSecretString": { + "type": "object", + "additionalProperties": false, + "properties": { + "ExcludeUppercase": { + "type": "boolean", + "description": "Specifies that the generated password should not include uppercase letters. The default behavior is False, and the generated password can include uppercase letters. " + }, + "RequireEachIncludedType": { + "type": "boolean", + "description": "Specifies whether the generated password must include at least one of every allowed character type. By default, Secrets Manager enables this parameter, and the generated password includes at least one of every character type." + }, + "IncludeSpace": { + "type": "boolean", + "description": "Specifies that the generated password can include the space character. By default, Secrets Manager disables this parameter, and the generated password doesn't include space" + }, + "ExcludeCharacters": { + "type": "string", + "description": "A string that excludes characters in the generated password. By default, all characters from the included sets can be used. The string can be a minimum length of 0 characters and a maximum length of 7168 characters. " + }, + "GenerateStringKey": { + "type": "string", + "description": "The JSON key name used to add the generated password to the JSON structure specified by the SecretStringTemplate parameter. If you specify this parameter, then you must also specify SecretStringTemplate. " + }, + "PasswordLength": { + "type": "integer", + "description": "The desired length of the generated password. The default value if you do not include this parameter is 32 characters. " + }, + "ExcludePunctuation": { + "type": "boolean", + "description": "Specifies that the generated password should not include punctuation characters. The default if you do not include this switch parameter is that punctuation characters can be included. " + }, + "ExcludeLowercase": { + "type": "boolean", + "description": "Specifies the generated password should not include lowercase letters. By default, ecrets Manager disables this parameter, and the generated password can include lowercase False, and the generated password can include lowercase letters." + }, + "SecretStringTemplate": { + "type": "string", + "description": "A properly structured JSON string that the generated password can be added to. If you specify this parameter, then you must also specify GenerateStringKey." + }, + "ExcludeNumbers": { + "type": "boolean", + "description": "Specifies that the generated password should exclude digits. By default, Secrets Manager does not enable the parameter, False, and the generated password can include digits." + } + } + }, + "ReplicaRegion": { + "type": "object", + "description": "A custom type that specifies a Region and the KmsKeyId for a replica secret.", + "additionalProperties": false, + "properties": { + "KmsKeyId": { + "type": "string", + "description": "The ARN, key ID, or alias of the KMS key to encrypt the secret. If you don't include this field, Secrets Manager uses aws/secretsmanager." + }, + "Region": { + "type": "string", + "description": "(Optional) A string that represents a Region, for example \"us-east-1\"." + } + }, + "required": [ + "Region" + ] + }, + "Tag": { + "type": "object", + "description": "A list of tags to attach to the secret. Each tag is a key and value pair of strings in a JSON text string.", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string", + "description": "The key name of the tag. You can specify a value that's 1 to 128 Unicode characters in length and can't be prefixed with aws." + }, + "Key": { + "type": "string", + "description": "The value for the tag. You can specify a value that's 1 to 256 characters in length." + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "createOnlyProperties": [ + "/properties/Name" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "writeOnlyProperties": [ + "/properties/SecretString", + "/properties/GenerateSecretString" + ], + "handlers": { + "create": { + "permissions": [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetRandomPassword", + "secretsmanager:CreateSecret", + "secretsmanager:TagResource" + ] + }, + "delete": { + "permissions": [ + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:RemoveRegionsFromReplication" + ] + }, + "list": { + "permissions": [ + "secretsmanager:ListSecrets" + ] + }, + "read": { + "permissions": [ + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue" + ] + }, + "update": { + "permissions": [ + "secretsmanager:UpdateSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:GetRandomPassword", + "secretsmanager:GetSecretValue", + "secretsmanager:ReplicateSecretToRegions", + "secretsmanager:RemoveRegionsFromReplication" + ] + } + } +} diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret_plugin.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret_plugin.py new file mode 100644 index 0000000000000..4c85279d0d81f --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secret_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SecretsManagerSecretProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SecretsManager::Secret" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.secretsmanager.resource_providers.aws_secretsmanager_secret import ( + SecretsManagerSecretProvider, + ) + + self.factory = SecretsManagerSecretProvider diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment.py new file mode 100644 index 0000000000000..27f8682c0a51f --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment.py @@ -0,0 +1,104 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SecretsManagerSecretTargetAttachmentProperties(TypedDict): + SecretId: Optional[str] + TargetId: Optional[str] + TargetType: Optional[str] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SecretsManagerSecretTargetAttachmentProvider( + ResourceProvider[SecretsManagerSecretTargetAttachmentProperties] +): + TYPE = "AWS::SecretsManager::SecretTargetAttachment" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SecretsManagerSecretTargetAttachmentProperties], + ) -> ProgressEvent[SecretsManagerSecretTargetAttachmentProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - TargetType + - TargetId + - SecretId + + + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + if not model.get("Id"): + model["Id"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SecretsManagerSecretTargetAttachmentProperties], + ) -> ProgressEvent[SecretsManagerSecretTargetAttachmentProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SecretsManagerSecretTargetAttachmentProperties], + ) -> ProgressEvent[SecretsManagerSecretTargetAttachmentProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SecretsManagerSecretTargetAttachmentProperties], + ) -> ProgressEvent[SecretsManagerSecretTargetAttachmentProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment.schema.json b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment.schema.json new file mode 100644 index 0000000000000..38e1b18fcdc07 --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment.schema.json @@ -0,0 +1,30 @@ +{ + "typeName": "AWS::SecretsManager::SecretTargetAttachment", + "description": "Resource Type definition for AWS::SecretsManager::SecretTargetAttachment", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "SecretId": { + "type": "string" + }, + "TargetType": { + "type": "string" + }, + "TargetId": { + "type": "string" + } + }, + "required": [ + "TargetType", + "TargetId", + "SecretId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment_plugin.py b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment_plugin.py new file mode 100644 index 0000000000000..f84e773ee3faf --- /dev/null +++ b/localstack-core/localstack/services/secretsmanager/resource_providers/aws_secretsmanager_secrettargetattachment_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SecretsManagerSecretTargetAttachmentProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SecretsManager::SecretTargetAttachment" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.secretsmanager.resource_providers.aws_secretsmanager_secrettargetattachment import ( + SecretsManagerSecretTargetAttachmentProvider, + ) + + self.factory = SecretsManagerSecretTargetAttachmentProvider diff --git a/localstack-core/localstack/services/ses/__init__.py b/localstack-core/localstack/services/ses/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ses/models.py b/localstack-core/localstack/services/ses/models.py new file mode 100644 index 0000000000000..2560f872410da --- /dev/null +++ b/localstack-core/localstack/services/ses/models.py @@ -0,0 +1,21 @@ +from typing import TypedDict + +from localstack.aws.api.ses import Address, Destination, Subject, TemplateData, TemplateName + + +class SentEmailBody(TypedDict): + html_part: str | None + text_part: str + + +class SentEmail(TypedDict): + Id: str + Region: str + Timestamp: str + Destination: Destination + RawData: str + Source: Address + Subject: Subject + Template: TemplateName + TemplateData: TemplateData + Body: SentEmailBody diff --git a/localstack-core/localstack/services/ses/provider.py b/localstack-core/localstack/services/ses/provider.py new file mode 100644 index 0000000000000..ca87c457c5818 --- /dev/null +++ b/localstack-core/localstack/services/ses/provider.py @@ -0,0 +1,647 @@ +import dataclasses +import json +import logging +import os +import re +from collections import defaultdict +from datetime import date, datetime, time, timezone +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from botocore.exceptions import ClientError +from moto.ses import ses_backends +from moto.ses.models import SESBackend + +from localstack import config +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.core import CommonServiceException +from localstack.aws.api.ses import ( + Address, + AddressList, + AmazonResourceName, + CloneReceiptRuleSetResponse, + ConfigurationSetDoesNotExistException, + ConfigurationSetName, + CreateConfigurationSetEventDestinationResponse, + DeleteConfigurationSetEventDestinationResponse, + DeleteConfigurationSetResponse, + DeleteTemplateResponse, + Destination, + EventDestination, + EventDestinationDoesNotExistException, + EventDestinationName, + GetIdentityVerificationAttributesResponse, + IdentityList, + IdentityVerificationAttributes, + InvalidSNSDestinationException, + ListTemplatesResponse, + MaxItems, + Message, + MessageId, + MessageRejected, + MessageTagList, + NextToken, + RawMessage, + ReceiptRuleSetName, + SendEmailResponse, + SendRawEmailResponse, + SendTemplatedEmailResponse, + SesApi, + TemplateData, + TemplateName, + VerificationAttributes, + VerificationStatus, +) +from localstack.aws.connect import connect_to +from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY +from localstack.http import Resource, Response +from localstack.services.moto import call_moto +from localstack.services.plugins import ServiceLifecycleHook +from localstack.services.ses.models import SentEmail, SentEmailBody +from localstack.utils.aws import arns +from localstack.utils.files import mkdir +from localstack.utils.strings import long_uid, to_str +from localstack.utils.time import timestamp, timestamp_millis + +if TYPE_CHECKING: + from mypy_boto3_sns import SNSClient + +LOGGER = logging.getLogger(__name__) + +# Keep record of all sent emails +# These can be retrieved via a service endpoint +EMAILS: Dict[MessageId, Dict[str, Any]] = {} + +# Endpoint to access all the sent emails +# (relative to LocalStack internal HTTP resources base endpoint) +EMAILS_ENDPOINT = "/_aws/ses" + +_EMAILS_ENDPOINT_REGISTERED = False + +REGEX_TAG_NAME = r"^[A-Za-z0-9_-]*$" +REGEX_TAG_VALUE = r"^[A-Za-z0-9_\-.@]*$" + +ALLOWED_TAG_LEN = 255 + + +def save_for_retrospection(sent_email: SentEmail): + """ + Save a message for retrospection. + + The contents of the email is saved to filesystem. It can also be accessed via a service endpoint. + """ + message_id = sent_email["Id"] + ses_dir = os.path.join(config.dirs.data or config.dirs.tmp, "ses") + mkdir(ses_dir) + + path = os.path.join(ses_dir, message_id + ".json") + + if not sent_email.get("Timestamp"): + sent_email["Timestamp"] = timestamp() + + EMAILS[message_id] = sent_email + + def _serialize(obj): + """JSON serializer for timestamps.""" + if isinstance(obj, (datetime, date, time)): + return obj.isoformat() + return obj.__dict__ + + with open(path, "w") as f: + f.write(json.dumps(sent_email, default=_serialize)) + + LOGGER.debug("Email saved at: %s", path) + + +def recipients_from_destination(destination: Destination) -> List[str]: + """Get list of recipient email addresses from a Destination object.""" + return ( + destination.get("ToAddresses", []) + + destination.get("CcAddresses", []) + + destination.get("BccAddresses", []) + ) + + +def get_ses_backend(context: RequestContext) -> SESBackend: + return ses_backends[context.account_id][context.region] + + +class SesServiceApiResource: + """Provides a REST API for retrospective access to emails sent via SES. + + This is registered as a LocalStack internal HTTP resource. + + This endpoint accepts: + - GET param `id`: filter for `id` field in SES message + - GET param `email`: filter for `source` field in SES message, when `id` filter is specified then filters on both + """ + + def on_get(self, request): + filter_id = request.args.get("id") + filter_source = request.args.get("email") + messages = [] + + for msg in EMAILS.values(): + if filter_id in (msg.get("Id"), None, ""): + if filter_source in (msg.get("Source"), None, ""): + messages.append(msg) + + return { + "messages": messages, + } + + def on_delete(self, request): + filter_id = request.args.get("id") + if filter_id is not None: + del EMAILS[filter_id] + else: + EMAILS.clear() + return Response(status=204) + + +def register_ses_api_resource(): + """Register the email retrospection endpoint as an internal LocalStack endpoint.""" + # Use a global to indicate whether the resource has already been registered + # This is cheaper than iterating over the registered routes in the Router object + global _EMAILS_ENDPOINT_REGISTERED + + if not _EMAILS_ENDPOINT_REGISTERED: + from localstack.services.edge import ROUTER + + ROUTER.add(Resource(EMAILS_ENDPOINT, SesServiceApiResource())) + _EMAILS_ENDPOINT_REGISTERED = True + + +class SesProvider(SesApi, ServiceLifecycleHook): + # + # Lifecycle Hooks + # + + def on_after_init(self): + # Allow sent emails to be retrieved from the SES emails endpoint + register_ses_api_resource() + + # + # Helpers + # + + def get_source_from_raw(self, raw_data: str) -> Optional[str]: + """Given a raw representation of email, return the source/from field.""" + entities = raw_data.split("\n") + for entity in entities: + if "From:" in entity: + return entity.replace("From:", "").strip() + return None + + # + # Implementations for SES operations + # + + @handler("CreateConfigurationSetEventDestination") + def create_configuration_set_event_destination( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + event_destination: EventDestination, + **kwargs, + ) -> CreateConfigurationSetEventDestinationResponse: + # send SES test event if an SNS topic is attached + sns_topic_arn = event_destination.get("SNSDestination", {}).get("TopicARN") + if sns_topic_arn is not None: + emitter = SNSEmitter(context) + emitter.emit_create_configuration_set_event_destination_test_message(sns_topic_arn) + + # only register the event destiation if emitting the message worked + try: + result = call_moto(context) + except CommonServiceException as e: + if e.code == "ConfigurationSetDoesNotExist": + raise ConfigurationSetDoesNotExistException( + f"Configuration set <{configuration_set_name}> does not exist." + ) + else: + raise + + return result + + @handler("DeleteConfigurationSet") + def delete_configuration_set( + self, context: RequestContext, configuration_set_name: ConfigurationSetName, **kwargs + ) -> DeleteConfigurationSetResponse: + # not implemented in moto + # TODO: contribute upstream? + backend = get_ses_backend(context) + try: + backend.config_sets.pop(configuration_set_name) + except KeyError: + raise ConfigurationSetDoesNotExistException( + f"Configuration set <{configuration_set_name}> does not exist." + ) + + return DeleteConfigurationSetResponse() + + @handler("DeleteConfigurationSetEventDestination") + def delete_configuration_set_event_destination( + self, + context: RequestContext, + configuration_set_name: ConfigurationSetName, + event_destination_name: EventDestinationName, + **kwargs, + ) -> DeleteConfigurationSetEventDestinationResponse: + # not implemented in moto + # TODO: contribute upstream? + backend = get_ses_backend(context) + + # the configuration set must exist + if configuration_set_name not in backend.config_sets: + raise ConfigurationSetDoesNotExistException( + f"Configuration set <{configuration_set_name}> does not exist." + ) + + # the event destination must exist + if configuration_set_name not in backend.config_set_event_destination: + raise EventDestinationDoesNotExistException( + f"No EventDestination found for {configuration_set_name}" + ) + + if event_destination_name in backend.event_destinations: + backend.event_destinations.pop(event_destination_name) + else: + # FIXME: inconsistent state + LOGGER.warning("inconsistent state encountered in ses backend") + + backend.config_set_event_destination.pop(configuration_set_name) + + return DeleteConfigurationSetEventDestinationResponse() + + @handler("ListTemplates") + def list_templates( + self, + context: RequestContext, + next_token: NextToken = None, + max_items: MaxItems = None, + **kwargs, + ) -> ListTemplatesResponse: + backend = get_ses_backend(context) + for template in backend.list_templates(): + if isinstance(template["Timestamp"], (date, datetime)): + template["Timestamp"] = timestamp_millis(template["Timestamp"]) + return call_moto(context) + + @handler("DeleteTemplate") + def delete_template( + self, context: RequestContext, template_name: TemplateName, **kwargs + ) -> DeleteTemplateResponse: + backend = get_ses_backend(context) + if template_name in backend.templates: + del backend.templates[template_name] + return DeleteTemplateResponse() + + @handler("GetIdentityVerificationAttributes") + def get_identity_verification_attributes( + self, context: RequestContext, identities: IdentityList, **kwargs + ) -> GetIdentityVerificationAttributesResponse: + attributes: VerificationAttributes = {} + + for identity in identities: + if "@" in identity: + attributes[identity] = IdentityVerificationAttributes( + VerificationStatus=VerificationStatus.Success, + ) + else: + attributes[identity] = IdentityVerificationAttributes( + VerificationStatus=VerificationStatus.Success, + VerificationToken=long_uid(), + ) + + return GetIdentityVerificationAttributesResponse( + VerificationAttributes=attributes, + ) + + @handler("SendEmail") + def send_email( + self, + context: RequestContext, + source: Address, + destination: Destination, + message: Message, + reply_to_addresses: AddressList = None, + return_path: Address = None, + source_arn: AmazonResourceName = None, + return_path_arn: AmazonResourceName = None, + tags: MessageTagList = None, + configuration_set_name: ConfigurationSetName = None, + **kwargs, + ) -> SendEmailResponse: + if tags: + for tag in tags: + tag_name = tag.get("Name", "") + tag_value = tag.get("Value", "") + if tag_name == "": + raise InvalidParameterValue("The tag name must be specified.") + if tag_value == "": + raise InvalidParameterValue("The tag value must be specified.") + if len(tag_name) > 255: + raise InvalidParameterValue("Tag name cannot exceed 255 characters.") + # The `ses:` prefix is for a special case and disregarded for validation + # see https://docs.aws.amazon.com/ses/latest/dg/monitor-using-event-publishing.html#event-publishing-fine-grained-feedback + if not re.match(REGEX_TAG_NAME, tag_name.removeprefix("ses:")): + raise InvalidParameterValue( + f"Invalid tag name <{tag_name}>: only alphanumeric ASCII characters, '_', '-' are allowed.", + ) + if len(tag_value) > 255: + raise InvalidParameterValue("Tag value cannot exceed 255 characters.") + if not re.match(REGEX_TAG_VALUE, tag_value): + raise InvalidParameterValue( + f"Invalid tag value <{tag_value}>: only alphanumeric ASCII characters, '_', '-' , '.', '@' are allowed.", + ) + + response = call_moto(context) + + backend = get_ses_backend(context) + emitter = SNSEmitter(context) + recipients = recipients_from_destination(destination) + + for event_destination in backend.config_set_event_destination.values(): + if not event_destination["Enabled"]: + continue + + sns_destination_arn = event_destination.get("SNSDestination") + if not sns_destination_arn: + continue + + payload = SNSPayload( + message_id=response["MessageId"], + sender_email=source, + destination_addresses=recipients, + tags=tags, + ) + emitter.emit_send_event(payload, sns_destination_arn) + emitter.emit_delivery_event(payload, sns_destination_arn) + + text_part = message["Body"].get("Text", {}).get("Data") + html_part = message["Body"].get("Html", {}).get("Data") + + save_for_retrospection( + SentEmail( + Id=response["MessageId"], + Region=context.region, + Destination=destination, + Source=source, + Subject=message["Subject"].get("Data"), + Body=SentEmailBody(text_part=text_part, html_part=html_part), + ) + ) + + return response + + @handler("SendTemplatedEmail") + def send_templated_email( + self, + context: RequestContext, + source: Address, + destination: Destination, + template: TemplateName, + template_data: TemplateData, + reply_to_addresses: AddressList = None, + return_path: Address = None, + source_arn: AmazonResourceName = None, + return_path_arn: AmazonResourceName = None, + tags: MessageTagList = None, + configuration_set_name: ConfigurationSetName = None, + template_arn: AmazonResourceName = None, + **kwargs, + ) -> SendTemplatedEmailResponse: + response = call_moto(context) + + backend = get_ses_backend(context) + emitter = SNSEmitter(context) + recipients = recipients_from_destination(destination) + + for event_destination in backend.config_set_event_destination.values(): + if not event_destination["Enabled"]: + continue + + sns_destination_arn = event_destination.get("SNSDestination") + if not sns_destination_arn: + continue + + payload = SNSPayload( + message_id=response["MessageId"], + sender_email=source, + destination_addresses=recipients, + tags=tags, + ) + emitter.emit_send_event(payload, sns_destination_arn, emit_source_arn=False) + emitter.emit_delivery_event(payload, sns_destination_arn) + + save_for_retrospection( + SentEmail( + Id=response["MessageId"], + Region=context.region, + Source=source, + Template=template, + TemplateData=template_data, + Destination=destination, + ) + ) + + return response + + @handler("SendRawEmail") + def send_raw_email( + self, + context: RequestContext, + raw_message: RawMessage, + source: Address = None, + destinations: AddressList = None, + from_arn: AmazonResourceName = None, + source_arn: AmazonResourceName = None, + return_path_arn: AmazonResourceName = None, + tags: MessageTagList = None, + configuration_set_name: ConfigurationSetName = None, + **kwargs, + ) -> SendRawEmailResponse: + raw_data = to_str(raw_message["Data"]) + + if source is None or not source.strip(): + LOGGER.debug("Raw email:\n%s\nEOT", raw_data) + + source = self.get_source_from_raw(raw_data) + if not source: + LOGGER.warning("Source not specified. Rejecting message.") + raise MessageRejected() + + # TODO: On AWS, `destinations` is ignored if the `To` field is set in the raw email. + destinations = destinations or [] + + backend = get_ses_backend(context) + message = backend.send_raw_email(source, destinations, raw_data) + + emitter = SNSEmitter(context) + for event_destination in backend.config_set_event_destination.values(): + if not event_destination["Enabled"]: + continue + + sns_destination_arn = event_destination.get("SNSDestination") + if not sns_destination_arn: + continue + + payload = SNSPayload( + message_id=message.id, + sender_email=source, + destination_addresses=destinations, + tags=tags, + ) + emitter.emit_send_event(payload, sns_destination_arn) + emitter.emit_delivery_event(payload, sns_destination_arn) + + save_for_retrospection( + SentEmail( + Id=message.id, + Region=context.region, + Source=source or message.source, + RawData=raw_data, + ) + ) + + return SendRawEmailResponse(MessageId=message.id) + + @handler("CloneReceiptRuleSet") + def clone_receipt_rule_set( + self, + context: RequestContext, + rule_set_name: ReceiptRuleSetName, + original_rule_set_name: ReceiptRuleSetName, + **kwargs, + ) -> CloneReceiptRuleSetResponse: + backend = get_ses_backend(context) + + backend.create_receipt_rule_set(rule_set_name) + original_rule_set = backend.describe_receipt_rule_set(original_rule_set_name) + + for rule in original_rule_set: + backend.create_receipt_rule(rule_set_name, rule) + + return CloneReceiptRuleSetResponse() + + +@dataclasses.dataclass(frozen=True) +class SNSPayload: + message_id: str + sender_email: Address + destination_addresses: AddressList + tags: Optional[MessageTagList] + + +class SNSEmitter: + def __init__( + self, + context: RequestContext, + ): + self.context = context + + def emit_create_configuration_set_event_destination_test_message( + self, sns_topic_arn: str + ) -> None: + client = self._client_for_topic(sns_topic_arn) + # topic must exist + try: + client.get_topic_attributes(TopicArn=sns_topic_arn) + except ClientError as exc: + if "NotFound" in exc.response["Error"]["Code"]: + raise InvalidSNSDestinationException(f"SNS topic <{sns_topic_arn}> not found.") + raise + + client.publish( + TopicArn=sns_topic_arn, + Message="Successfully validated SNS topic for Amazon SES event publishing.", + ) + + def emit_send_event( + self, payload: SNSPayload, sns_topic_arn: str, emit_source_arn: bool = True + ): + now = datetime.now(tz=timezone.utc) + + tags = defaultdict(list) + for every in payload.tags or []: + tags[every["Name"]].append(every["Value"]) + + event_payload = { + "eventType": "Send", + "mail": { + "timestamp": now.isoformat(), + "source": payload.sender_email, + "sendingAccountId": self.context.account_id, + "destination": payload.destination_addresses, + "messageId": payload.message_id, + "tags": tags, + }, + "send": {}, + } + + if emit_source_arn: + event_payload["mail"]["sourceArn"] = ( + f"arn:{self.context.partition}:ses:{self.context.region}:{self.context.account_id}:identity/{payload.sender_email}" + ) + + client = self._client_for_topic(sns_topic_arn) + try: + client.publish( + TopicArn=sns_topic_arn, + Message=json.dumps(event_payload), + Subject="Amazon SES Email Event Notification", + ) + except ClientError: + LOGGER.exception("sending SNS message") + + def emit_delivery_event(self, payload: SNSPayload, sns_topic_arn: str): + now = datetime.now(tz=timezone.utc) + + tags = defaultdict(list) + for every in payload.tags or []: + tags[every["Name"]].append(every["Value"]) + + event_payload = { + "eventType": "Delivery", + "mail": { + "timestamp": now.isoformat(), + "source": payload.sender_email, + "sourceArn": f"arn:{self.context.partition}:ses:{self.context.region}:{self.context.account_id}:identity/{payload.sender_email}", + "sendingAccountId": self.context.account_id, + "destination": payload.destination_addresses, + "messageId": payload.message_id, + "tags": tags, + }, + "delivery": { + "recipients": payload.destination_addresses, + "timestamp": now.isoformat(), + }, + } + client = self._client_for_topic(sns_topic_arn) + try: + client.publish( + TopicArn=sns_topic_arn, + Message=json.dumps(event_payload), + Subject="Amazon SES Email Event Notification", + ) + except ClientError: + LOGGER.exception("sending SNS message") + + @staticmethod + def _client_for_topic(topic_arn: str) -> "SNSClient": + arn_parameters = arns.parse_arn(topic_arn) + region = arn_parameters["region"] + access_key_id = arn_parameters["account"] + + return connect_to( + region_name=region, + aws_access_key_id=access_key_id, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + ).sns + + +class InvalidParameterValue(CommonServiceException): + def __init__(self, message=None): + super().__init__( + "InvalidParameterValue", status_code=400, message=message, sender_fault=True + ) diff --git a/localstack-core/localstack/services/ses/resource_providers/__init__.py b/localstack-core/localstack/services/ses/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity.py b/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity.py new file mode 100644 index 0000000000000..5baeb44cd6a82 --- /dev/null +++ b/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity.py @@ -0,0 +1,166 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SESEmailIdentityProperties(TypedDict): + EmailIdentity: Optional[str] + ConfigurationSetAttributes: Optional[ConfigurationSetAttributes] + DkimAttributes: Optional[DkimAttributes] + DkimDNSTokenName1: Optional[str] + DkimDNSTokenName2: Optional[str] + DkimDNSTokenName3: Optional[str] + DkimDNSTokenValue1: Optional[str] + DkimDNSTokenValue2: Optional[str] + DkimDNSTokenValue3: Optional[str] + DkimSigningAttributes: Optional[DkimSigningAttributes] + FeedbackAttributes: Optional[FeedbackAttributes] + MailFromAttributes: Optional[MailFromAttributes] + + +class ConfigurationSetAttributes(TypedDict): + ConfigurationSetName: Optional[str] + + +class DkimSigningAttributes(TypedDict): + DomainSigningPrivateKey: Optional[str] + DomainSigningSelector: Optional[str] + NextSigningKeyLength: Optional[str] + + +class DkimAttributes(TypedDict): + SigningEnabled: Optional[bool] + + +class MailFromAttributes(TypedDict): + BehaviorOnMxFailure: Optional[str] + MailFromDomain: Optional[str] + + +class FeedbackAttributes(TypedDict): + EmailForwardingEnabled: Optional[bool] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SESEmailIdentityProvider(ResourceProvider[SESEmailIdentityProperties]): + TYPE = "AWS::SES::EmailIdentity" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SESEmailIdentityProperties], + ) -> ProgressEvent[SESEmailIdentityProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/EmailIdentity + + Required properties: + - EmailIdentity + + Create-only properties: + - /properties/EmailIdentity + + Read-only properties: + - /properties/DkimDNSTokenName1 + - /properties/DkimDNSTokenName2 + - /properties/DkimDNSTokenName3 + - /properties/DkimDNSTokenValue1 + - /properties/DkimDNSTokenValue2 + - /properties/DkimDNSTokenValue3 + + IAM permissions required: + - ses:CreateEmailIdentity + - ses:PutEmailIdentityMailFromAttributes + - ses:PutEmailIdentityFeedbackAttributes + - ses:PutEmailIdentityDkimAttributes + - ses:GetEmailIdentity + + """ + model = request.desired_state + + # TODO: validations + + if not request.custom_context.get(REPEATED_INVOCATION): + # this is the first time this callback is invoked + # TODO: defaults + # TODO: idempotency + # TODO: actually create the resource + request.custom_context[REPEATED_INVOCATION] = True + return ProgressEvent( + status=OperationStatus.IN_PROGRESS, + resource_model=model, + custom_context=request.custom_context, + ) + + # TODO: check the status of the resource + # - if finished, update the model with all fields and return success event: + # return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=model) + # - else + # return ProgressEvent(status=OperationStatus.IN_PROGRESS, resource_model=model) + + raise NotImplementedError + + def read( + self, + request: ResourceRequest[SESEmailIdentityProperties], + ) -> ProgressEvent[SESEmailIdentityProperties]: + """ + Fetch resource information + + IAM permissions required: + - ses:GetEmailIdentity + """ + raise NotImplementedError + + def list( + self, + request: ResourceRequest[SESEmailIdentityProperties], + ) -> ProgressEvent[SESEmailIdentityProperties]: + response = request.aws_client_factory.ses.list_identities()["Identities"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[SESEmailIdentityProperties(EmailIdentity=every) for every in response], + ) + + def delete( + self, + request: ResourceRequest[SESEmailIdentityProperties], + ) -> ProgressEvent[SESEmailIdentityProperties]: + """ + Delete a resource + + IAM permissions required: + - ses:DeleteEmailIdentity + """ + raise NotImplementedError + + def update( + self, + request: ResourceRequest[SESEmailIdentityProperties], + ) -> ProgressEvent[SESEmailIdentityProperties]: + """ + Update a resource + + IAM permissions required: + - ses:PutEmailIdentityMailFromAttributes + - ses:PutEmailIdentityFeedbackAttributes + - ses:PutEmailIdentityConfigurationSetAttributes + - ses:PutEmailIdentityDkimSigningAttributes + - ses:PutEmailIdentityDkimAttributes + - ses:GetEmailIdentity + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity.schema.json b/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity.schema.json new file mode 100644 index 0000000000000..8d952ff03a1a9 --- /dev/null +++ b/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity.schema.json @@ -0,0 +1,173 @@ +{ + "typeName": "AWS::SES::EmailIdentity", + "description": "Resource Type definition for AWS::SES::EmailIdentity", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-ses.git", + "additionalProperties": false, + "properties": { + "EmailIdentity": { + "type": "string", + "description": "The email address or domain to verify." + }, + "ConfigurationSetAttributes": { + "$ref": "#/definitions/ConfigurationSetAttributes" + }, + "DkimSigningAttributes": { + "$ref": "#/definitions/DkimSigningAttributes" + }, + "DkimAttributes": { + "$ref": "#/definitions/DkimAttributes" + }, + "MailFromAttributes": { + "$ref": "#/definitions/MailFromAttributes" + }, + "FeedbackAttributes": { + "$ref": "#/definitions/FeedbackAttributes" + }, + "DkimDNSTokenName1": { + "type": "string" + }, + "DkimDNSTokenName2": { + "type": "string" + }, + "DkimDNSTokenName3": { + "type": "string" + }, + "DkimDNSTokenValue1": { + "type": "string" + }, + "DkimDNSTokenValue2": { + "type": "string" + }, + "DkimDNSTokenValue3": { + "type": "string" + } + }, + "definitions": { + "DkimSigningAttributes": { + "type": "object", + "additionalProperties": false, + "description": "If your request includes this object, Amazon SES configures the identity to use Bring Your Own DKIM (BYODKIM) for DKIM authentication purposes, or, configures the key length to be used for Easy DKIM.", + "properties": { + "DomainSigningSelector": { + "type": "string", + "description": "[Bring Your Own DKIM] A string that's used to identify a public key in the DNS configuration for a domain." + }, + "DomainSigningPrivateKey": { + "type": "string", + "description": "[Bring Your Own DKIM] A private key that's used to generate a DKIM signature. The private key must use 1024 or 2048-bit RSA encryption, and must be encoded using base64 encoding." + }, + "NextSigningKeyLength": { + "type": "string", + "description": "[Easy DKIM] The key length of the future DKIM key pair to be generated. This can be changed at most once per day.", + "pattern": "RSA_1024_BIT|RSA_2048_BIT" + } + } + }, + "ConfigurationSetAttributes": { + "type": "object", + "additionalProperties": false, + "description": "Used to associate a configuration set with an email identity.", + "properties": { + "ConfigurationSetName": { + "type": "string", + "description": "The configuration set to use by default when sending from this identity. Note that any configuration set defined in the email sending request takes precedence." + } + } + }, + "DkimAttributes": { + "type": "object", + "additionalProperties": false, + "description": "Used to enable or disable DKIM authentication for an email identity.", + "properties": { + "SigningEnabled": { + "type": "boolean", + "description": "Sets the DKIM signing configuration for the identity. When you set this value true, then the messages that are sent from the identity are signed using DKIM. If you set this value to false, your messages are sent without DKIM signing." + } + } + }, + "MailFromAttributes": { + "type": "object", + "additionalProperties": false, + "description": "Used to enable or disable the custom Mail-From domain configuration for an email identity.", + "properties": { + "MailFromDomain": { + "type": "string", + "description": "The custom MAIL FROM domain that you want the verified identity to use" + }, + "BehaviorOnMxFailure": { + "type": "string", + "description": "The action to take if the required MX record isn't found when you send an email. When you set this value to UseDefaultValue , the mail is sent using amazonses.com as the MAIL FROM domain. When you set this value to RejectMessage , the Amazon SES API v2 returns a MailFromDomainNotVerified error, and doesn't attempt to deliver the email.", + "pattern": "USE_DEFAULT_VALUE|REJECT_MESSAGE" + } + } + }, + "FeedbackAttributes": { + "type": "object", + "additionalProperties": false, + "description": "Used to enable or disable feedback forwarding for an identity.", + "properties": { + "EmailForwardingEnabled": { + "type": "boolean", + "description": "If the value is true, you receive email notifications when bounce or complaint events occur" + } + } + } + }, + "required": [ + "EmailIdentity" + ], + "readOnlyProperties": [ + "/properties/DkimDNSTokenName1", + "/properties/DkimDNSTokenName2", + "/properties/DkimDNSTokenName3", + "/properties/DkimDNSTokenValue1", + "/properties/DkimDNSTokenValue2", + "/properties/DkimDNSTokenValue3" + ], + "createOnlyProperties": [ + "/properties/EmailIdentity" + ], + "primaryIdentifier": [ + "/properties/EmailIdentity" + ], + "writeOnlyProperties": [ + "/properties/DkimSigningAttributes/DomainSigningSelector", + "/properties/DkimSigningAttributes/DomainSigningPrivateKey" + ], + "handlers": { + "create": { + "permissions": [ + "ses:CreateEmailIdentity", + "ses:PutEmailIdentityMailFromAttributes", + "ses:PutEmailIdentityFeedbackAttributes", + "ses:PutEmailIdentityDkimAttributes", + "ses:GetEmailIdentity" + ] + }, + "read": { + "permissions": [ + "ses:GetEmailIdentity" + ] + }, + "update": { + "permissions": [ + "ses:PutEmailIdentityMailFromAttributes", + "ses:PutEmailIdentityFeedbackAttributes", + "ses:PutEmailIdentityConfigurationSetAttributes", + "ses:PutEmailIdentityDkimSigningAttributes", + "ses:PutEmailIdentityDkimAttributes", + "ses:GetEmailIdentity" + ] + }, + "delete": { + "permissions": [ + "ses:DeleteEmailIdentity" + ] + }, + "list": { + "permissions": [ + "ses:ListEmailIdentities" + ] + } + } +} diff --git a/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity_plugin.py b/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity_plugin.py new file mode 100644 index 0000000000000..ca75f6be6c340 --- /dev/null +++ b/localstack-core/localstack/services/ses/resource_providers/aws_ses_emailidentity_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SESEmailIdentityProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SES::EmailIdentity" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ses.resource_providers.aws_ses_emailidentity import ( + SESEmailIdentityProvider, + ) + + self.factory = SESEmailIdentityProvider diff --git a/localstack-core/localstack/services/sns/__init__.py b/localstack-core/localstack/services/sns/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/sns/analytics.py b/localstack-core/localstack/services/sns/analytics.py new file mode 100644 index 0000000000000..426c5403bae6b --- /dev/null +++ b/localstack-core/localstack/services/sns/analytics.py @@ -0,0 +1,11 @@ +""" +Usage analytics for SNS internal endpoints +""" + +from localstack.utils.analytics.metrics import LabeledCounter + +# number of times SNS internal endpoint per resource types +# (e.g. PlatformMessage invoked 10x times, SMSMessage invoked 3x times, SubscriptionToken...) +internal_api_calls = LabeledCounter( + namespace="sns", name="internal_api_call", labels=["resource_type"] +) diff --git a/localstack-core/localstack/services/sns/certificate.py b/localstack-core/localstack/services/sns/certificate.py new file mode 100644 index 0000000000000..8e7ad5fc21803 --- /dev/null +++ b/localstack-core/localstack/services/sns/certificate.py @@ -0,0 +1,54 @@ +from datetime import datetime, timedelta + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + +SNS_SERVER_PRIVATE_KEY: RSAPrivateKey = rsa.generate_private_key( + public_exponent=65537, key_size=2048 +) + +SNS_SERVER_CERT_ISSUER = x509.Name( + [ + x509.NameAttribute(x509.NameOID.COUNTRY_NAME, "CH"), + x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, "LocalStack"), + x509.NameAttribute( + x509.NameOID.COMMON_NAME, "LocalStack TEST SNS Root Certificate Authority" + ), + ] +) + +SNS_SERVER_CERT: str = ( + ( + x509.CertificateBuilder() + .subject_name(SNS_SERVER_CERT_ISSUER) + .issuer_name(SNS_SERVER_CERT_ISSUER) + .public_key(SNS_SERVER_PRIVATE_KEY.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=365)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + crl_sign=True, + key_cert_sign=True, + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(SNS_SERVER_PRIVATE_KEY.public_key()), + critical=False, + ) + .sign(SNS_SERVER_PRIVATE_KEY, hashes.SHA256()) + ) + .public_bytes(serialization.Encoding.PEM) + .decode("utf-8") +) diff --git a/localstack-core/localstack/services/sns/constants.py b/localstack-core/localstack/services/sns/constants.py new file mode 100644 index 0000000000000..04b5f05293818 --- /dev/null +++ b/localstack-core/localstack/services/sns/constants.py @@ -0,0 +1,41 @@ +import re +from string import ascii_letters, digits + +SNS_PROTOCOLS = [ + "http", + "https", + "email", + "email-json", + "sms", + "sqs", + "application", + "lambda", + "firehose", +] + +VALID_SUBSCRIPTION_ATTR_NAME = [ + "DeliveryPolicy", + "FilterPolicy", + "FilterPolicyScope", + "RawMessageDelivery", + "RedrivePolicy", + "SubscriptionRoleArn", +] + +MSG_ATTR_NAME_REGEX = re.compile(r"^(?!\.)(?!.*\.$)(?!.*\.\.)[a-zA-Z0-9_\-.]+$") +ATTR_TYPE_REGEX = re.compile(r"^(String|Number|Binary)\..+$") +VALID_MSG_ATTR_NAME_CHARS = set(ascii_letters + digits + "." + "-" + "_") + + +GCM_URL = "https://fcm.googleapis.com/fcm/send" + +# Endpoint to access all the PlatformEndpoint sent Messages +PLATFORM_ENDPOINT_MSGS_ENDPOINT = "/_aws/sns/platform-endpoint-messages" +SMS_MSGS_ENDPOINT = "/_aws/sns/sms-messages" +SUBSCRIPTION_TOKENS_ENDPOINT = "/_aws/sns/subscription-tokens" + +# we add hex chars to respect the format of AWS with certificate ID, hardcoded for now +# we could parametrize the certificate ID in the future +SNS_CERT_ENDPOINT = "/_aws/sns/SimpleNotificationService-6c6f63616c737461636b69736e696365.pem" + +DUMMY_SUBSCRIPTION_PRINCIPAL = "arn:{partition}:iam::{account_id}:user/DummySNSPrincipal" diff --git a/localstack-core/localstack/services/sns/executor.py b/localstack-core/localstack/services/sns/executor.py new file mode 100644 index 0000000000000..ce4f8850d6e3e --- /dev/null +++ b/localstack-core/localstack/services/sns/executor.py @@ -0,0 +1,114 @@ +import itertools +import logging +import os +import queue +import threading + +LOG = logging.getLogger(__name__) + + +def _worker(work_queue: queue.Queue): + try: + while True: + work_item = work_queue.get(block=True) + if work_item is None: + return + work_item.run() + # delete reference to the work item to avoid it being in memory until the next blocking `queue.get` call returns + del work_item + + except Exception: + LOG.exception("Exception in worker") + + +class _WorkItem: + def __init__(self, fn, args, kwargs): + self.fn = fn + self.args = args + self.kwargs = kwargs + + def run(self): + try: + self.fn(*self.args, **self.kwargs) + except Exception: + LOG.exception("Unhandled Exception in while running %s", self.fn.__name__) + + +class TopicPartitionedThreadPoolExecutor: + """ + This topic partition the work between workers based on Topics. + It guarantees that each Topic only has one worker assigned, and thus that the tasks will be executed sequentially. + + Loosely based on ThreadPoolExecutor for stdlib, but does not return Future as SNS does not need it (fire&forget) + Could be extended if needed to fit other needs. + + Currently, we do not re-balance between workers if some of them have more load. This could be investigated. + """ + + # Used to assign unique thread names when thread_name_prefix is not supplied. + _counter = itertools.count().__next__ + + def __init__(self, max_workers: int = None, thread_name_prefix: str = ""): + if max_workers is None: + max_workers = min(32, (os.cpu_count() or 1) + 4) + if max_workers <= 0: + raise ValueError("max_workers must be greater than 0") + + self._max_workers = max_workers + self._thread_name_prefix = ( + thread_name_prefix or f"TopicThreadPoolExecutor-{self._counter()}" + ) + + # for now, the pool isn't fair and is not redistributed depending on load + self._pool = {} + self._shutdown = False + self._lock = threading.Lock() + self._threads = set() + self._work_queues = [] + self._cycle = itertools.cycle(range(max_workers)) + + def _add_worker(self): + work_queue = queue.SimpleQueue() + self._work_queues.append(work_queue) + thread_name = f"{self._thread_name_prefix}_{len(self._threads)}" + t = threading.Thread(name=thread_name, target=_worker, args=(work_queue,)) + t.daemon = True + t.start() + self._threads.add(t) + + def _get_work_queue(self, topic: str) -> queue.SimpleQueue: + if not (work_queue := self._pool.get(topic)): + if len(self._threads) < self._max_workers: + self._add_worker() + + # we cycle through the possible indexes for a work queue, in order to distribute the load across + # once we get to the max amount of worker, the cycle will start back at 0 + index = next(self._cycle) + work_queue = self._work_queues[index] + + # TODO: the pool is not cleaned up at the moment, think about the clean-up interface + self._pool[topic] = work_queue + return work_queue + + def submit(self, fn, topic, /, *args, **kwargs) -> None: + with self._lock: + work_queue = self._get_work_queue(topic) + + if self._shutdown: + raise RuntimeError("cannot schedule new futures after shutdown") + + w = _WorkItem(fn, args, kwargs) + work_queue.put(w) + + def shutdown(self, wait=True): + with self._lock: + self._shutdown = True + + # Send a wake-up to prevent threads calling + # _work_queue.get(block=True) from permanently blocking. + for work_queue in self._work_queues: + work_queue.put(None) + + if wait: + for t in self._threads: + t.join() diff --git a/localstack-core/localstack/services/sns/filter.py b/localstack-core/localstack/services/sns/filter.py new file mode 100644 index 0000000000000..1a61fcab10552 --- /dev/null +++ b/localstack-core/localstack/services/sns/filter.py @@ -0,0 +1,538 @@ +import ipaddress +import json +import typing as t + +from localstack.aws.api.sns import InvalidParameterException + + +class SubscriptionFilter: + def check_filter_policy_on_message_attributes( + self, filter_policy: dict, message_attributes: dict + ): + if not filter_policy: + return True + + flat_policy_conditions = self.flatten_policy(filter_policy) + + return any( + all( + self._evaluate_filter_policy_conditions_on_attribute( + conditions, + message_attributes.get(criteria), + field_exists=criteria in message_attributes, + ) + for criteria, conditions in flat_policy.items() + ) + for flat_policy in flat_policy_conditions + ) + + def check_filter_policy_on_message_body(self, filter_policy: dict, message_body: str): + try: + body = json.loads(message_body) + if not isinstance(body, dict): + return False + except json.JSONDecodeError: + # Filter policies for the message body assume that the message payload is a well-formed JSON object. + # See https://docs.aws.amazon.com/sns/latest/dg/sns-message-filtering.html + return False + + return self._evaluate_nested_filter_policy_on_dict(filter_policy, payload=body) + + def _evaluate_nested_filter_policy_on_dict(self, filter_policy, payload: dict) -> bool: + """ + This method evaluates the filter policy against the JSON decoded payload. + Although it's not documented anywhere, AWS allows `.` in the fields name in the filter policy and the payload, + and will evaluate them. However, it's not JSONPath compatible: + Example: + Policy: `{"field1.field2": "value1"}` + This policy will match both `{"field1.field2": "value1"}` and {"field1: {"field2": "value1"}}`, unlike JSONPath + for which `.` points to a child node. + This might show they are flattening the both dictionaries to a single level for an easier matching without + recursion. + :param filter_policy: a dict, starting at the FilterPolicy + :param payload: a dict, starting at the MessageBody + :return: True if the payload respect the filter policy, otherwise False + """ + if not filter_policy: + return True + + # TODO: maybe save/cache the flattened/expanded policy? + flat_policy_conditions = self.flatten_policy(filter_policy) + flat_payloads = self.flatten_payload(payload, flat_policy_conditions) + + return any( + all( + any( + self._evaluate_condition( + flat_payload.get(key), condition, field_exists=key in flat_payload + ) + for condition in conditions + for flat_payload in flat_payloads + ) + for key, conditions in flat_policy.items() + ) + for flat_policy in flat_policy_conditions + ) + + def _evaluate_filter_policy_conditions_on_attribute( + self, conditions, attribute, field_exists: bool + ): + if not isinstance(conditions, list): + conditions = [conditions] + + tpe = attribute.get("DataType") or attribute.get("Type") if attribute else None + val = attribute.get("StringValue") or attribute.get("Value") if attribute else None + if attribute is not None and tpe == "String.Array": + try: + values = json.loads(val) + except ValueError: + return False + for value in values: + for condition in conditions: + if self._evaluate_condition(value, condition, field_exists): + return True + else: + for condition in conditions: + value = val or None + if self._evaluate_condition(value, condition, field_exists): + return True + + return False + + def _evaluate_condition(self, value, condition, field_exists: bool): + if not isinstance(condition, dict): + return field_exists and value == condition + elif (must_exist := condition.get("exists")) is not None: + # if must_exists is True then field_exists must be True + # if must_exists is False then fields_exists must be False + return must_exist == field_exists + elif value is None: + # the remaining conditions require the value to not be None + return False + elif anything_but := condition.get("anything-but"): + if isinstance(anything_but, dict): + not_prefix = anything_but.get("prefix") + return not value.startswith(not_prefix) + elif isinstance(anything_but, list): + return value not in anything_but + else: + return value != anything_but + elif prefix := condition.get("prefix"): + return value.startswith(prefix) + elif suffix := condition.get("suffix"): + return value.endswith(suffix) + elif equal_ignore_case := condition.get("equals-ignore-case"): + return equal_ignore_case.lower() == value.lower() + elif numeric_condition := condition.get("numeric"): + return self._evaluate_numeric_condition(numeric_condition, value) + elif cidr := condition.get("cidr"): + try: + ip = ipaddress.ip_address(value) + return ip in ipaddress.ip_network(cidr) + except ValueError: + return False + + return False + + @staticmethod + def _evaluate_numeric_condition(conditions, value): + try: + # try if the value is numeric + value = float(value) + except ValueError: + # the value is not numeric, the condition is False + return False + + for i in range(0, len(conditions), 2): + operator = conditions[i] + operand = float(conditions[i + 1]) + + if operator == "=": + if value != operand: + return False + elif operator == ">": + if value <= operand: + return False + elif operator == "<": + if value >= operand: + return False + elif operator == ">=": + if value < operand: + return False + elif operator == "<=": + if value > operand: + return False + + return True + + @staticmethod + def flatten_policy(nested_dict: dict) -> list[dict]: + """ + Takes a dictionary as input and will output the dictionary on a single level. + Input: + `{"field1": {"field2": {"field3": "val1", "field4": "val2"}}}` + Output: + `[ + { + "field1.field2.field3": "val1", + "field1.field2.field4": "val2" + } + ]` + Input with $or will create multiple outputs: + `{"$or": [{"field1": "val1"}, {"field2": "val2"}], "field3": "val3"}` + Output: + `[ + {"field1": "val1", "field3": "val3"}, + {"field2": "val2", "field3": "val3"} + ]` + :param nested_dict: a (nested) dictionary + :return: a list of flattened dictionaries with no nested dict or list inside, flattened to a + single level, one list item for every list item encountered + """ + + def _traverse_policy(obj, array=None, parent_key=None) -> list: + if array is None: + array = [{}] + + for key, values in obj.items(): + if key == "$or" and isinstance(values, list) and len(values) > 1: + # $or will create multiple new branches in the array. + # Each current branch will traverse with each choice in $or + array = [ + i for value in values for i in _traverse_policy(value, array, parent_key) + ] + else: + # We update the parent key do that {"key1": {"key2": ""}} becomes "key1.key2" + _parent_key = f"{parent_key}.{key}" if parent_key else key + if isinstance(values, dict): + # If the current key has child dict -- key: "key1", child: {"key2": ["val1", val2"]} + # We only update the parent_key and traverse its children with the current branches + array = _traverse_policy(values, array, _parent_key) + else: + # If the current key has no child, this means we found the values to match -- child: ["val1", val2"] + # we update the branches with the parent chain and the values -- {"key1.key2": ["val1, val2"]} + array = [{**item, _parent_key: values} for item in array] + + return array + + return _traverse_policy(nested_dict) + + @staticmethod + def flatten_payload(payload: dict, policy_conditions: list[dict]) -> list[dict]: + """ + Takes a dictionary as input and will output the dictionary on a single level. + The dictionary can have lists containing other dictionaries, and one root level entry will be created for every + item in a list if it corresponds to the entries of the policy conditions. + Input: + payload: + `{"field1": { + "field2: [ + {"field3": "val1", "field4": "val2"}, + {"field3": "val3", "field4": "val4"} + } + ]}` + policy_conditions: + `[ + "field1.field2.field3": , + "field1.field2.field4": , + ]` + Output: + `[ + { + "field1.field2.field3": "val1", + "field1.field2.field4": "val2" + }, + { + "field1.field2.field3": "val3", + "field1.field2.field4": "val4" + } + ]` + :param payload: a (nested) dictionary + :return: flatten_dict: a dictionary with no nested dict inside, flattened to a single level + """ + policy_keys = {key for keys in policy_conditions for key in keys} + + def _is_key_in_policy(key: str) -> bool: + return key is None or any(policy_key.startswith(key) for policy_key in policy_keys) + + def _traverse(_object: dict, array=None, parent_key=None) -> list: + if isinstance(_object, dict): + for key, values in _object.items(): + # We update the parent key so that {"key1": {"key2": ""}} becomes "key1.key2" + _parent_key = f"{parent_key}.{key}" if parent_key else key + + # we make sure that we are building only the relevant parts of the payload related to the policy + # the payload could be very complex, and the policy only applies to part of it + if _is_key_in_policy(_parent_key): + array = _traverse(values, array, _parent_key) + + elif isinstance(_object, list): + if not _object: + return array + array = [i for value in _object for i in _traverse(value, array, parent_key)] + else: + array = [{**item, parent_key: _object} for item in array] + + return array + + return _traverse(payload, array=[{}], parent_key=None) + + +class FilterPolicyValidator: + def __init__(self, scope: str, is_subscribe_call: bool): + self.scope = scope + self.error_prefix = ( + "Invalid parameter: Attributes Reason: " if is_subscribe_call else "Invalid parameter: " + ) + + def validate_filter_policy(self, filter_policy: dict[str, t.Any]): + # # A filter policy can have a maximum of five attribute names. For a nested policy, only parent keys are counted. + if len(filter_policy.values()) > 5: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Filter policy can not have more than 5 keys" + ) + + aggregated_rules, combinations = self.aggregate_rules(filter_policy) + # For the complexity of the filter policy, the total combination of values must not exceed 150. + # https://docs.aws.amazon.com/sns/latest/dg/subscription-filter-policy-constraints.html + if combinations > 150: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Filter policy is too complex" + ) + + for rules in aggregated_rules: + for rule in rules: + self._validate_rule(rule) + + def aggregate_rules(self, filter_policy: dict[str, t.Any]) -> tuple[list[list[t.Any]], int]: + """ + This method evaluate the filter policy recursively, and returns only a list of lists of rules. + It also calculates the combinations of rules, calculated depending on the nesting of the rules. + Example: + nested_filter_policy = { + "key_a": { + "key_b": { + "key_c": ["value_one", "value_two", "value_three", "value_four"] + } + }, + "key_d": { + "key_e": ["value_one", "value_two", "value_three"] + } + } + This function then iterates on the values of the top level keys of the filter policy: ("key_a", "key_d") + If the iterated value is not a list, it means it is a nested property. If the scope is `MessageBody`, it is + allowed, we call this method on the value, adding a level to the depth to keep track on how deep the key is. + If the value is a list, it means it contains rules: we will append this list of rules in _rules, and + calculate the combinations it adds. + For the example filter policy containing nested properties, we calculate it this way + The first array has four values in a three-level nested key, and the second has three values in a two-level + nested key. 3 x 4 x 2 x 3 = 72 + The return value would be: + [["value_one", "value_two", "value_three", "value_four"], ["value_one", "value_two", "value_three"]] + It allows us to later iterate of the list of rules in an easy way, to verify its conditions only. + + :param filter_policy: a dict, starting at the FilterPolicy + :return: a tuple with a list of lists of rules and the calculated number of combinations + """ + + def _inner( + policy_elements: dict[str, t.Any], depth: int = 1, combinations: int = 1 + ) -> tuple[list[list[t.Any]], int]: + _rules = [] + for key, _value in policy_elements.items(): + if isinstance(_value, dict): + # From AWS docs: "unlike attribute-based policies, payload-based policies support property nesting." + sub_rules, combinations = _inner( + _value, depth=depth + 1, combinations=combinations + ) + _rules.extend(sub_rules) + elif isinstance(_value, list): + if not _value: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Empty arrays are not allowed" + ) + + current_combination = 0 + if key == "$or": + for val in _value: + sub_rules, or_combinations = _inner( + val, depth=depth, combinations=combinations + ) + _rules.extend(sub_rules) + current_combination += or_combinations + + combinations = current_combination + else: + _rules.append(_value) + combinations = combinations * len(_value) * depth + else: + raise InvalidParameterException( + f'{self.error_prefix}FilterPolicy: "{key}" must be an object or an array' + ) + + if self.scope == "MessageAttributes" and depth > 1: + raise InvalidParameterException( + f"{self.error_prefix}Filter policy scope MessageAttributes does not support nested filter policy" + ) + + return _rules, combinations + + return _inner(filter_policy) + + def _validate_rule(self, rule: t.Any) -> None: + match rule: + case None | str() | bool(): + return + + case int() | float(): + # TODO: AWS says they support only from -10^9 to 10^9 but seems to accept it, so we just return + # if rule <= -1000000000 or rule >= 1000000000: + # raise "" + return + + case {**kwargs}: + if len(kwargs) != 1: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Only one key allowed in match expression" + ) + + operator, value = None, None + for k, v in kwargs.items(): + operator, value = k, v + + if operator in ( + "equals-ignore-case", + "prefix", + "suffix", + ): + if not isinstance(value, str): + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: {operator} match pattern must be a string" + ) + return + + elif operator == "anything-but": + # anything-but can actually contain any kind of simple rule (str, number, and list) + if isinstance(value, list): + for v in value: + self._validate_rule(v) + + return + + # or have a nested `prefix` pattern + elif isinstance(value, dict): + for inner_operator in value.keys(): + if inner_operator != "prefix": + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Unsupported anything-but pattern: {inner_operator}" + ) + + self._validate_rule(value) + return + + elif operator == "exists": + if not isinstance(value, bool): + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: exists match pattern must be either true or false." + ) + return + + elif operator == "numeric": + self._validate_numeric_condition(value) + + elif operator == "cidr": + self._validate_cidr_condition(value) + + else: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Unrecognized match type {operator}" + ) + + case _: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Match value must be String, number, true, false, or null" + ) + + def _validate_cidr_condition(self, value): + if not isinstance(value, str): + # `cidr` returns the prefix error + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: prefix match pattern must be a string" + ) + splitted = value.split("/") + if len(splitted) != 2: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Malformed CIDR, one '/' required" + ) + ip_addr, mask = value.split("/") + try: + int(mask) + except ValueError: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Malformed CIDR, mask bits must be an integer" + ) + try: + ipaddress.ip_network(value) + except ValueError: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Nonstandard IP address: {ip_addr}" + ) + + def _validate_numeric_condition(self, value): + if not value: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Invalid member in numeric match: ]" + ) + num_values = value[::-1] + + operator = num_values.pop() + if not isinstance(operator, str): + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Invalid member in numeric match: {operator}" + ) + elif operator not in ("<", "<=", "=", ">", ">="): + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Unrecognized numeric range operator: {operator}" + ) + + value = num_values.pop() if num_values else None + if not isinstance(value, (int, float)): + exc_operator = "equals" if operator == "=" else operator + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Value of {exc_operator} must be numeric" + ) + + if not num_values: + return + + if operator not in (">", ">="): + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Too many elements in numeric expression" + ) + + second_operator = num_values.pop() + if not isinstance(second_operator, str): + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Bad value in numeric range: {second_operator}" + ) + elif second_operator not in ("<", "<="): + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Bad numeric range operator: {second_operator}" + ) + + second_value = num_values.pop() if num_values else None + if not isinstance(second_value, (int, float)): + exc_operator = "equals" if second_operator == "=" else second_operator + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Value of {exc_operator} must be numeric" + ) + + elif second_value <= value: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Bottom must be less than top" + ) + + elif num_values: + raise InvalidParameterException( + f"{self.error_prefix}FilterPolicy: Too many terms in numeric range expression" + ) diff --git a/localstack-core/localstack/services/sns/models.py b/localstack-core/localstack/services/sns/models.py new file mode 100644 index 0000000000000..a4e660e243207 --- /dev/null +++ b/localstack-core/localstack/services/sns/models.py @@ -0,0 +1,185 @@ +import itertools +import time +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Dict, List, Literal, Optional, TypedDict, Union + +from localstack.aws.api.sns import ( + MessageAttributeMap, + PublishBatchRequestEntry, + subscriptionARN, + topicARN, +) +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute +from localstack.utils.aws.arns import parse_arn +from localstack.utils.objects import singleton_factory +from localstack.utils.strings import long_uid + +SnsProtocols = Literal[ + "http", "https", "email", "email-json", "sms", "sqs", "application", "lambda", "firehose" +] + +SnsApplicationPlatforms = Literal[ + "APNS", "APNS_SANDBOX", "ADM", "FCM", "Baidu", "GCM", "MPNS", "WNS" +] + +SnsMessageProtocols = Literal[SnsProtocols, SnsApplicationPlatforms] + + +def create_default_sns_topic_policy(topic_arn: str) -> dict: + """ + Creates the default SNS topic policy for the given topic ARN. + + :param topic_arn: The topic arn + :return: A policy document + """ + return { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish", + ], + "Resource": topic_arn, + "Condition": {"StringEquals": {"AWS:SourceOwner": parse_arn(topic_arn)["account"]}}, + } + ], + } + + +@singleton_factory +def global_sns_message_sequence(): + # creates a 20-digit number used as the start for the global sequence, adds 100 for it to be different from SQS's + # mostly for testing purpose, both global sequence would be initialized at the same and be identical + start = int(time.time() + 100) << 33 + # itertools.count is thread safe over the GIL since its getAndIncrement operation is a single python bytecode op + return itertools.count(start) + + +def get_next_sequence_number(): + return next(global_sns_message_sequence()) + + +class SnsMessageType(StrEnum): + Notification = "Notification" + SubscriptionConfirmation = "SubscriptionConfirmation" + UnsubscribeConfirmation = "UnsubscribeConfirmation" + + +@dataclass +class SnsMessage: + type: SnsMessageType + message: Union[ + str, Dict + ] # can be Dict if after being JSON decoded for validation if structure is `json` + message_attributes: Optional[MessageAttributeMap] = None + message_structure: Optional[str] = None + subject: Optional[str] = None + message_deduplication_id: Optional[str] = None + message_group_id: Optional[str] = None + token: Optional[str] = None + message_id: str = field(default_factory=long_uid) + is_fifo: Optional[bool] = False + sequencer_number: Optional[str] = None + + def __post_init__(self): + if self.message_attributes is None: + self.message_attributes = {} + if self.is_fifo: + self.sequencer_number = str(get_next_sequence_number()) + + def message_content(self, protocol: SnsMessageProtocols) -> str: + """ + Helper function to retrieve the message content for the right protocol if the StructureMessage is `json` + See https://docs.aws.amazon.com/sns/latest/dg/sns-send-custom-platform-specific-payloads-mobile-devices.html + https://docs.aws.amazon.com/sns/latest/dg/example_sns_Publish_section.html + :param protocol: + :return: message content as string + """ + if self.message_structure == "json": + return self.message.get(protocol, self.message.get("default")) + + return self.message + + @classmethod + def from_batch_entry(cls, entry: PublishBatchRequestEntry, is_fifo=False) -> "SnsMessage": + return cls( + type=SnsMessageType.Notification, + message=entry["Message"], + subject=entry.get("Subject"), + message_structure=entry.get("MessageStructure"), + message_attributes=entry.get("MessageAttributes"), + message_deduplication_id=entry.get("MessageDeduplicationId"), + message_group_id=entry.get("MessageGroupId"), + is_fifo=is_fifo, + ) + + +class SnsSubscription(TypedDict, total=False): + """ + In SNS, Subscription can be represented with only TopicArn, Endpoint, Protocol, SubscriptionArn and Owner, for + example in ListSubscriptions. However, when getting a subscription with GetSubscriptionAttributes, it will return + the Subscription object merged with its own attributes. + This represents this merged object, for internal use and in GetSubscriptionAttributes + https://docs.aws.amazon.com/cli/latest/reference/sns/get-subscription-attributes.html + """ + + TopicArn: topicARN + Endpoint: str + Protocol: SnsProtocols + SubscriptionArn: subscriptionARN + PendingConfirmation: Literal["true", "false"] + Owner: Optional[str] + SubscriptionPrincipal: Optional[str] + FilterPolicy: Optional[str] + FilterPolicyScope: Literal["MessageAttributes", "MessageBody"] + RawMessageDelivery: Literal["true", "false"] + ConfirmationWasAuthenticated: Literal["true", "false"] + SubscriptionRoleArn: Optional[str] + DeliveryPolicy: Optional[str] + + +class SnsStore(BaseStore): + # maps topic ARN to subscriptions ARN + topic_subscriptions: Dict[str, List[str]] = LocalAttribute(default=dict) + + # maps subscription ARN to SnsSubscription + subscriptions: Dict[str, SnsSubscription] = LocalAttribute(default=dict) + + # maps confirmation token to subscription ARN + subscription_tokens: Dict[str, str] = LocalAttribute(default=dict) + + # maps topic ARN to list of tags + sns_tags: Dict[str, List[Dict]] = LocalAttribute(default=dict) + + # cache of topic ARN to platform endpoint messages (used primarily for testing) + platform_endpoint_messages: Dict[str, List[Dict]] = LocalAttribute(default=dict) + + # list of sent SMS messages + sms_messages: List[Dict] = LocalAttribute(default=list) + + # filter policy are stored as JSON string in subscriptions, store the decoded result Dict + subscription_filter_policy: Dict[subscriptionARN, Dict] = LocalAttribute(default=dict) + + def get_topic_subscriptions(self, topic_arn: str) -> List[SnsSubscription]: + topic_subscriptions = self.topic_subscriptions.get(topic_arn, []) + subscriptions = [ + subscription + for subscription_arn in topic_subscriptions + if (subscription := self.subscriptions.get(subscription_arn)) + ] + return subscriptions + + +sns_stores = AccountRegionBundle("sns", SnsStore) diff --git a/localstack-core/localstack/services/sns/provider.py b/localstack-core/localstack/services/sns/provider.py new file mode 100644 index 0000000000000..e5d166ef3c72c --- /dev/null +++ b/localstack-core/localstack/services/sns/provider.py @@ -0,0 +1,1359 @@ +import base64 +import copy +import functools +import json +import logging +from typing import Dict, List +from uuid import uuid4 + +from botocore.utils import InvalidArnException +from moto.core.utils import camelcase_to_pascal, underscores_to_camelcase +from moto.sns import sns_backends +from moto.sns.models import MAXIMUM_MESSAGE_LENGTH, SNSBackend, Topic +from moto.sns.utils import is_e164 + +from localstack.aws.api import CommonServiceException, RequestContext +from localstack.aws.api.sns import ( + AmazonResourceName, + BatchEntryIdsNotDistinctException, + ConfirmSubscriptionResponse, + CreateEndpointResponse, + CreatePlatformApplicationResponse, + CreateTopicResponse, + EndpointDisabledException, + GetSubscriptionAttributesResponse, + GetTopicAttributesResponse, + InvalidParameterException, + InvalidParameterValueException, + ListSubscriptionsByTopicResponse, + ListSubscriptionsResponse, + ListTagsForResourceResponse, + MapStringToString, + MessageAttributeMap, + NotFoundException, + PublishBatchRequestEntryList, + PublishBatchResponse, + PublishBatchResultEntry, + PublishResponse, + SnsApi, + String, + SubscribeResponse, + Subscription, + SubscriptionAttributesMap, + TagKeyList, + TagList, + TagResourceResponse, + TooManyEntriesInBatchRequestException, + TopicAttributesMap, + UntagResourceResponse, + attributeName, + attributeValue, + authenticateOnUnsubscribe, + boolean, + messageStructure, + nextToken, + subscriptionARN, + topicARN, + topicName, +) +from localstack.constants import AWS_REGION_US_EAST_1, DEFAULT_AWS_ACCOUNT_ID +from localstack.http import Request, Response, Router, route +from localstack.services.edge import ROUTER +from localstack.services.moto import call_moto +from localstack.services.plugins import ServiceLifecycleHook +from localstack.services.sns import constants as sns_constants +from localstack.services.sns.certificate import SNS_SERVER_CERT +from localstack.services.sns.filter import FilterPolicyValidator +from localstack.services.sns.models import ( + SnsMessage, + SnsMessageType, + SnsStore, + SnsSubscription, + sns_stores, +) +from localstack.services.sns.publisher import ( + PublishDispatcher, + SnsBatchPublishContext, + SnsPublishContext, +) +from localstack.utils.aws.arns import ( + ArnData, + extract_account_id_from_arn, + extract_region_from_arn, + get_partition, + parse_arn, +) +from localstack.utils.collections import PaginatedList, select_from_typed_dict +from localstack.utils.strings import short_uid, to_bytes, to_str + +from .analytics import internal_api_calls + +# set up logger +LOG = logging.getLogger(__name__) + + +class SnsProvider(SnsApi, ServiceLifecycleHook): + """ + Provider class for AWS Simple Notification Service. + + AWS supports following operations in a cross-account setup: + - GetTopicAttributes + - SetTopicAttributes + - AddPermission + - RemovePermission + - Publish + - Subscribe + - ListSubscriptionByTopic + - DeleteTopic + """ + + @route(sns_constants.SNS_CERT_ENDPOINT, methods=["GET"]) + def get_signature_cert_pem_file(self, request: Request): + # see http://sns-public-resources.s3.amazonaws.com/SNS_Message_Signing_Release_Note_Jan_25_2011.pdf + # see https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html + return Response(self._signature_cert_pem, 200) + + def __init__(self) -> None: + super().__init__() + self._publisher = PublishDispatcher() + self._signature_cert_pem: str = SNS_SERVER_CERT + + def on_before_stop(self): + self._publisher.shutdown() + + def on_after_init(self): + # Allow sent platform endpoint messages to be retrieved from the SNS endpoint + register_sns_api_resource(ROUTER) + # add the route to serve the certificate used to validate message signatures + ROUTER.add(self.get_signature_cert_pem_file) + + @staticmethod + def get_store(account_id: str, region_name: str) -> SnsStore: + return sns_stores[account_id][region_name] + + @staticmethod + def get_moto_backend(account_id: str, region_name: str) -> SNSBackend: + return sns_backends[account_id][region_name] + + @staticmethod + def _get_topic(arn: str, context: RequestContext) -> Topic: + """ + :param arn: the Topic ARN + :param context: the RequestContext of the request + :param multiregion: if the request can fetch the topic across regions or not (ex. Publish cannot publish to a + topic in a different region than the request) + :return: the Moto model Topic + """ + arn_data = parse_and_validate_topic_arn(arn) + if context.region != arn_data["region"]: + raise InvalidParameterException("Invalid parameter: TopicArn") + + try: + return sns_backends[arn_data["account"]][context.region].topics[arn] + except KeyError: + raise NotFoundException("Topic does not exist") + + def get_topic_attributes( + self, context: RequestContext, topic_arn: topicARN, **kwargs + ) -> GetTopicAttributesResponse: + # get the Topic from moto manually first, because Moto does not handle well the case where the ARN is malformed + # (raises ValueError: not enough values to unpack (expected 6, got 1)) + moto_topic_model = self._get_topic(topic_arn, context) + moto_response: GetTopicAttributesResponse = call_moto(context) + # TODO: fix some attributes by moto, see snapshot + # DeliveryPolicy + # EffectiveDeliveryPolicy + # Policy.Statement..Action -> SNS:Receive is added by moto but not returned in AWS + # TODO: very hacky way to get the attributes we need instead of a moto patch + # see the attributes we need: https://docs.aws.amazon.com/sns/latest/dg/sns-topic-attributes.html + # would need more work to have the proper format out of moto, maybe extract the model to our store + attributes = moto_response["Attributes"] + for attr in vars(moto_topic_model): + if "_feedback" in attr: + key = camelcase_to_pascal(underscores_to_camelcase(attr)) + attributes[key] = getattr(moto_topic_model, attr) + elif attr == "signature_version": + attributes["SignatureVersion"] = moto_topic_model.signature_version + elif attr == "archive_policy": + attributes["ArchivePolicy"] = moto_topic_model.archive_policy + + return moto_response + + def set_topic_attributes( + self, + context: RequestContext, + topic_arn: topicARN, + attribute_name: attributeName, + attribute_value: attributeValue | None = None, + **kwargs, + ) -> None: + # validate the topic first + self._get_topic(topic_arn, context) + call_moto(context) + + def publish_batch( + self, + context: RequestContext, + topic_arn: topicARN, + publish_batch_request_entries: PublishBatchRequestEntryList, + **kwargs, + ) -> PublishBatchResponse: + if len(publish_batch_request_entries) > 10: + raise TooManyEntriesInBatchRequestException( + "The batch request contains more entries than permissible." + ) + + parsed_arn = parse_and_validate_topic_arn(topic_arn) + store = self.get_store(account_id=parsed_arn["account"], region_name=context.region) + moto_topic = self._get_topic(topic_arn, context) + + ids = [entry["Id"] for entry in publish_batch_request_entries] + if len(set(ids)) != len(publish_batch_request_entries): + raise BatchEntryIdsNotDistinctException( + "Two or more batch entries in the request have the same Id." + ) + + response: PublishBatchResponse = {"Successful": [], "Failed": []} + + # TODO: write AWS validated tests with FilterPolicy and batching + # TODO: find a scenario where we can fail to send a message synchronously to be able to report it + # right now, it seems that AWS fails the whole publish if something is wrong in the format of 1 message + + total_batch_size = 0 + message_contexts = [] + for entry_index, entry in enumerate(publish_batch_request_entries, start=1): + message_payload = entry.get("Message") + message_attributes = entry.get("MessageAttributes", {}) + if message_attributes: + # if a message contains non-valid message attributes + # will fail for the first non-valid message encountered, and raise ParameterValueInvalid + validate_message_attributes(message_attributes, position=entry_index) + + total_batch_size += get_total_publish_size(message_payload, message_attributes) + + # TODO: WRITE AWS VALIDATED + if entry.get("MessageStructure") == "json": + try: + message = json.loads(message_payload) + # Keys in the JSON object that correspond to supported transport protocols must have + # simple JSON string values. + # Non-string values will cause the key to be ignored. + message = { + key: field for key, field in message.items() if isinstance(field, str) + } + if "default" not in message: + raise InvalidParameterException( + "Invalid parameter: Message Structure - No default entry in JSON message body" + ) + entry["Message"] = message # noqa + except json.JSONDecodeError: + raise InvalidParameterException( + "Invalid parameter: Message Structure - JSON message body failed to parse" + ) + + if is_fifo := (".fifo" in topic_arn): + if not all("MessageGroupId" in entry for entry in publish_batch_request_entries): + raise InvalidParameterException( + "Invalid parameter: The MessageGroupId parameter is required for FIFO topics" + ) + if moto_topic.content_based_deduplication == "false": + if not all( + "MessageDeduplicationId" in entry for entry in publish_batch_request_entries + ): + raise InvalidParameterException( + "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + ) + + msg_ctx = SnsMessage.from_batch_entry(entry, is_fifo=is_fifo) + message_contexts.append(msg_ctx) + success = PublishBatchResultEntry( + Id=entry["Id"], + MessageId=msg_ctx.message_id, + ) + if is_fifo: + success["SequenceNumber"] = msg_ctx.sequencer_number + response["Successful"].append(success) + + if total_batch_size > MAXIMUM_MESSAGE_LENGTH: + raise CommonServiceException( + code="BatchRequestTooLong", + message="The length of all the messages put together is more than the limit.", + sender_fault=True, + ) + + publish_ctx = SnsBatchPublishContext( + messages=message_contexts, + store=store, + request_headers=context.request.headers, + topic_attributes=vars(moto_topic), + ) + self._publisher.publish_batch_to_topic(publish_ctx, topic_arn) + + return response + + def set_subscription_attributes( + self, + context: RequestContext, + subscription_arn: subscriptionARN, + attribute_name: attributeName, + attribute_value: attributeValue = None, + **kwargs, + ) -> None: + store = self.get_store(account_id=context.account_id, region_name=context.region) + sub = store.subscriptions.get(subscription_arn) + if not sub: + raise NotFoundException("Subscription does not exist") + + validate_subscription_attribute( + attribute_name=attribute_name, + attribute_value=attribute_value, + topic_arn=sub["TopicArn"], + endpoint=sub["Endpoint"], + ) + if attribute_name == "RawMessageDelivery": + attribute_value = attribute_value.lower() + + elif attribute_name == "FilterPolicy": + filter_policy = json.loads(attribute_value) if attribute_value else None + if filter_policy: + validator = FilterPolicyValidator( + scope=sub.get("FilterPolicyScope", "MessageAttributes"), + is_subscribe_call=False, + ) + validator.validate_filter_policy(filter_policy) + + store.subscription_filter_policy[subscription_arn] = filter_policy + + sub[attribute_name] = attribute_value + + def confirm_subscription( + self, + context: RequestContext, + topic_arn: topicARN, + token: String, + authenticate_on_unsubscribe: authenticateOnUnsubscribe = None, + **kwargs, + ) -> ConfirmSubscriptionResponse: + # TODO: validate format on the token (seems to be 288 hex chars) + # this request can come from any http client, it might not be signed (we would need to implement + # `authenticate_on_unsubscribe` to force a signing client to do this request. + # so, the region and account_id might not be in the request. Use the ones from the topic_arn + try: + parsed_arn = parse_arn(topic_arn) + except InvalidArnException: + raise InvalidParameterException("Invalid parameter: Topic") + + store = self.get_store(account_id=parsed_arn["account"], region_name=parsed_arn["region"]) + + # it seems SNS is able to know what the region of the topic should be, even though a wrong topic is accepted + if parsed_arn["region"] != get_region_from_subscription_token(token): + raise InvalidParameterException("Invalid parameter: Topic") + + subscription_arn = store.subscription_tokens.get(token) + if not subscription_arn: + raise InvalidParameterException("Invalid parameter: Token") + + subscription = store.subscriptions.get(subscription_arn) + if not subscription: + # subscription could have been deleted in the meantime + raise InvalidParameterException("Invalid parameter: Token") + + # ConfirmSubscription is idempotent + if subscription.get("PendingConfirmation") == "false": + return ConfirmSubscriptionResponse(SubscriptionArn=subscription_arn) + + subscription["PendingConfirmation"] = "false" + subscription["ConfirmationWasAuthenticated"] = "true" + + return ConfirmSubscriptionResponse(SubscriptionArn=subscription_arn) + + def untag_resource( + self, + context: RequestContext, + resource_arn: AmazonResourceName, + tag_keys: TagKeyList, + **kwargs, + ) -> UntagResourceResponse: + call_moto(context) + # TODO: probably get the account_id and region from the `resource_arn` + store = self.get_store(context.account_id, context.region) + existing_tags = store.sns_tags.setdefault(resource_arn, []) + store.sns_tags[resource_arn] = [t for t in existing_tags if t["Key"] not in tag_keys] + return UntagResourceResponse() + + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs + ) -> ListTagsForResourceResponse: + # TODO: probably get the account_id and region from the `resource_arn` + store = self.get_store(context.account_id, context.region) + tags = store.sns_tags.setdefault(resource_arn, []) + return ListTagsForResourceResponse(Tags=tags) + + def create_platform_application( + self, + context: RequestContext, + name: String, + platform: String, + attributes: MapStringToString, + **kwargs, + ) -> CreatePlatformApplicationResponse: + # TODO: validate platform + # see https://docs.aws.amazon.com/cli/latest/reference/sns/create-platform-application.html + # list of possible values: ADM, Baidu, APNS, APNS_SANDBOX, GCM, MPNS, WNS + # each platform has a specific way to handle credentials + # this can also be used for dispatching message to the right platform + return call_moto(context) + + def create_platform_endpoint( + self, + context: RequestContext, + platform_application_arn: String, + token: String, + custom_user_data: String = None, + attributes: MapStringToString = None, + **kwargs, + ) -> CreateEndpointResponse: + # TODO: support mobile app events + # see https://docs.aws.amazon.com/sns/latest/dg/application-event-notifications.html + return call_moto(context) + + def unsubscribe( + self, context: RequestContext, subscription_arn: subscriptionARN, **kwargs + ) -> None: + count = len(subscription_arn.split(":")) + try: + parsed_arn = parse_arn(subscription_arn) + except InvalidArnException: + # TODO: check for invalid SubscriptionGUID + raise InvalidParameterException( + f"Invalid parameter: SubscriptionArn Reason: An ARN must have at least 6 elements, not {count}" + ) + + account_id = parsed_arn["account"] + region_name = parsed_arn["region"] + + store = self.get_store(account_id=account_id, region_name=region_name) + if count == 6 and subscription_arn not in store.subscriptions: + raise InvalidParameterException("Invalid parameter: SubscriptionId") + + moto_sns_backend = self.get_moto_backend(account_id, region_name) + moto_sns_backend.unsubscribe(subscription_arn) + + # pop the subscription at the end, to avoid race condition by iterating over the topic subscriptions + subscription = store.subscriptions.get(subscription_arn) + + if not subscription: + # unsubscribe is idempotent, so unsubscribing from a non-existing topic does nothing + return + + if subscription["Protocol"] in ["http", "https"]: + # TODO: actually validate this (re)subscribe behaviour somehow (localhost.run?) + # we might need to save the sub token in the store + # TODO: AWS only sends the UnsubscribeConfirmation if the call is unauthenticated or the requester is not + # the owner + subscription_token = encode_subscription_token_with_region(region=context.region) + message_ctx = SnsMessage( + type=SnsMessageType.UnsubscribeConfirmation, + token=subscription_token, + message=f"You have chosen to deactivate subscription {subscription_arn}.\nTo cancel this operation and restore the subscription, visit the SubscribeURL included in this message.", + ) + moto_topic = moto_sns_backend.topics.get(subscription["TopicArn"]) + publish_ctx = SnsPublishContext( + message=message_ctx, + store=store, + request_headers=context.request.headers, + topic_attributes=vars(moto_topic), + ) + self._publisher.publish_to_topic_subscriber( + publish_ctx, + topic_arn=subscription["TopicArn"], + subscription_arn=subscription_arn, + ) + + store.topic_subscriptions[subscription["TopicArn"]].remove(subscription_arn) + store.subscription_filter_policy.pop(subscription_arn, None) + store.subscriptions.pop(subscription_arn, None) + + def get_subscription_attributes( + self, context: RequestContext, subscription_arn: subscriptionARN, **kwargs + ) -> GetSubscriptionAttributesResponse: + store = self.get_store(account_id=context.account_id, region_name=context.region) + sub = store.subscriptions.get(subscription_arn) + if not sub: + raise NotFoundException("Subscription does not exist") + removed_attrs = ["sqs_queue_url"] + if "FilterPolicyScope" in sub and not sub.get("FilterPolicy"): + removed_attrs.append("FilterPolicyScope") + removed_attrs.append("FilterPolicy") + elif "FilterPolicy" in sub and "FilterPolicyScope" not in sub: + sub["FilterPolicyScope"] = "MessageAttributes" + + attributes = {k: v for k, v in sub.items() if k not in removed_attrs} + return GetSubscriptionAttributesResponse(Attributes=attributes) + + def list_subscriptions( + self, context: RequestContext, next_token: nextToken = None, **kwargs + ) -> ListSubscriptionsResponse: + store = self.get_store(context.account_id, context.region) + subscriptions = [ + select_from_typed_dict(Subscription, sub) for sub in list(store.subscriptions.values()) + ] + paginated_subscriptions = PaginatedList(subscriptions) + page, next_token = paginated_subscriptions.get_page( + token_generator=lambda x: get_next_page_token_from_arn(x["SubscriptionArn"]), + page_size=100, + next_token=next_token, + ) + + response = ListSubscriptionsResponse(Subscriptions=page) + if next_token: + response["NextToken"] = next_token + return response + + def list_subscriptions_by_topic( + self, context: RequestContext, topic_arn: topicARN, next_token: nextToken = None, **kwargs + ) -> ListSubscriptionsByTopicResponse: + self._get_topic(topic_arn, context) + parsed_topic_arn = parse_and_validate_topic_arn(topic_arn) + store = self.get_store(parsed_topic_arn["account"], parsed_topic_arn["region"]) + sns_subscriptions = store.get_topic_subscriptions(topic_arn) + subscriptions = [select_from_typed_dict(Subscription, sub) for sub in sns_subscriptions] + + paginated_subscriptions = PaginatedList(subscriptions) + page, next_token = paginated_subscriptions.get_page( + token_generator=lambda x: get_next_page_token_from_arn(x["SubscriptionArn"]), + page_size=100, + next_token=next_token, + ) + + response = ListSubscriptionsResponse(Subscriptions=page) + if next_token: + response["NextToken"] = next_token + return response + + def publish( + self, + context: RequestContext, + message: String, + topic_arn: topicARN = None, + target_arn: String = None, + phone_number: String = None, + subject: String = None, + message_structure: messageStructure = None, + message_attributes: MessageAttributeMap = None, + message_deduplication_id: String = None, + message_group_id: String = None, + **kwargs, + ) -> PublishResponse: + if subject == "": + raise InvalidParameterException("Invalid parameter: Subject") + if not message or all(not m for m in message): + raise InvalidParameterException("Invalid parameter: Empty message") + + # TODO: check for topic + target + phone number at the same time? + # TODO: more validation on phone, it might be opted out? + if phone_number and not is_e164(phone_number): + raise InvalidParameterException( + f"Invalid parameter: PhoneNumber Reason: {phone_number} is not valid to publish to" + ) + + if message_attributes: + validate_message_attributes(message_attributes) + + if get_total_publish_size(message, message_attributes) > MAXIMUM_MESSAGE_LENGTH: + raise InvalidParameterException("Invalid parameter: Message too long") + + # for compatibility reasons, AWS allows users to use either TargetArn or TopicArn for publishing to a topic + # use any of them for topic validation + topic_or_target_arn = topic_arn or target_arn + topic_model = None + + if is_fifo := (topic_or_target_arn and ".fifo" in topic_or_target_arn): + if not message_group_id: + raise InvalidParameterException( + "Invalid parameter: The MessageGroupId parameter is required for FIFO topics", + ) + topic_model = self._get_topic(topic_or_target_arn, context) + if topic_model.content_based_deduplication == "false": + if not message_deduplication_id: + raise InvalidParameterException( + "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + ) + elif message_deduplication_id: + # this is the first one to raise if both are set while the topic is not fifo + raise InvalidParameterException( + "Invalid parameter: MessageDeduplicationId Reason: The request includes MessageDeduplicationId parameter that is not valid for this topic type" + ) + elif message_group_id: + raise InvalidParameterException( + "Invalid parameter: MessageGroupId Reason: The request includes MessageGroupId parameter that is not valid for this topic type" + ) + is_endpoint_publish = target_arn and ":endpoint/" in target_arn + if message_structure == "json": + try: + message = json.loads(message) + # Keys in the JSON object that correspond to supported transport protocols must have + # simple JSON string values. + # Non-string values will cause the key to be ignored. + message = {key: field for key, field in message.items() if isinstance(field, str)} + # TODO: check no default key for direct TargetArn endpoint publish, need credentials + # see example: https://docs.aws.amazon.com/sns/latest/dg/sns-send-custom-platform-specific-payloads-mobile-devices.html + if "default" not in message and not is_endpoint_publish: + raise InvalidParameterException( + "Invalid parameter: Message Structure - No default entry in JSON message body" + ) + except json.JSONDecodeError: + raise InvalidParameterException( + "Invalid parameter: Message Structure - JSON message body failed to parse" + ) + + if not phone_number: + # use the account to get the store from the TopicArn (you can only publish in the same region as the topic) + parsed_arn = parse_and_validate_topic_arn(topic_or_target_arn) + store = self.get_store(account_id=parsed_arn["account"], region_name=context.region) + moto_sns_backend = self.get_moto_backend(parsed_arn["account"], context.region) + if is_endpoint_publish: + if not (platform_endpoint := moto_sns_backend.platform_endpoints.get(target_arn)): + raise InvalidParameterException( + "Invalid parameter: TargetArn Reason: No endpoint found for the target arn specified" + ) + elif not platform_endpoint.enabled: + raise EndpointDisabledException("Endpoint is disabled") + else: + topic_model = self._get_topic(topic_or_target_arn, context) + else: + # use the store from the request context + store = self.get_store(account_id=context.account_id, region_name=context.region) + + message_ctx = SnsMessage( + type=SnsMessageType.Notification, + message=message, + message_attributes=message_attributes, + message_deduplication_id=message_deduplication_id, + message_group_id=message_group_id, + message_structure=message_structure, + subject=subject, + is_fifo=is_fifo, + ) + publish_ctx = SnsPublishContext( + message=message_ctx, store=store, request_headers=context.request.headers + ) + + if is_endpoint_publish: + self._publisher.publish_to_application_endpoint( + ctx=publish_ctx, endpoint_arn=target_arn + ) + elif phone_number: + self._publisher.publish_to_phone_number(ctx=publish_ctx, phone_number=phone_number) + else: + # beware if the subscription is FIFO, the order might not be guaranteed. + # 2 quick call to this method in succession might not be executed in order in the executor? + # TODO: test how this behaves in a FIFO context with a lot of threads. + publish_ctx.topic_attributes |= vars(topic_model) + self._publisher.publish_to_topic(publish_ctx, topic_or_target_arn) + + if is_fifo: + return PublishResponse( + MessageId=message_ctx.message_id, SequenceNumber=message_ctx.sequencer_number + ) + + return PublishResponse(MessageId=message_ctx.message_id) + + def subscribe( + self, + context: RequestContext, + topic_arn: topicARN, + protocol: String, + endpoint: String = None, + attributes: SubscriptionAttributesMap = None, + return_subscription_arn: boolean = None, + **kwargs, + ) -> SubscribeResponse: + # TODO: check validation ordering + parsed_topic_arn = parse_and_validate_topic_arn(topic_arn) + if context.region != parsed_topic_arn["region"]: + raise InvalidParameterException("Invalid parameter: TopicArn") + + store = self.get_store(account_id=parsed_topic_arn["account"], region_name=context.region) + + if topic_arn not in store.topic_subscriptions: + raise NotFoundException("Topic does not exist") + + if not endpoint: + # TODO: check AWS behaviour (because endpoint is optional) + raise NotFoundException("Endpoint not specified in subscription") + if protocol not in sns_constants.SNS_PROTOCOLS: + raise InvalidParameterException( + f"Invalid parameter: Amazon SNS does not support this protocol string: {protocol}" + ) + elif protocol in ["http", "https"] and not endpoint.startswith(f"{protocol}://"): + raise InvalidParameterException( + "Invalid parameter: Endpoint must match the specified protocol" + ) + elif protocol == "sms" and not is_e164(endpoint): + raise InvalidParameterException(f"Invalid SMS endpoint: {endpoint}") + + elif protocol == "sqs": + try: + parse_arn(endpoint) + except InvalidArnException: + raise InvalidParameterException("Invalid parameter: SQS endpoint ARN") + + elif protocol == "application": + # TODO: this is taken from moto, validate it + moto_backend = self.get_moto_backend( + account_id=parsed_topic_arn["account"], region_name=context.region + ) + if endpoint not in moto_backend.platform_endpoints: + raise NotFoundException("Endpoint does not exist") + + if ".fifo" in endpoint and ".fifo" not in topic_arn: + raise InvalidParameterException( + "Invalid parameter: Invalid parameter: Endpoint Reason: FIFO SQS Queues can not be subscribed to standard SNS topics" + ) + + sub_attributes = copy.deepcopy(attributes) if attributes else None + if sub_attributes: + for attr_name, attr_value in sub_attributes.items(): + validate_subscription_attribute( + attribute_name=attr_name, + attribute_value=attr_value, + topic_arn=topic_arn, + endpoint=endpoint, + is_subscribe_call=True, + ) + if raw_msg_delivery := sub_attributes.get("RawMessageDelivery"): + sub_attributes["RawMessageDelivery"] = raw_msg_delivery.lower() + + # An endpoint may only be subscribed to a topic once. Subsequent + # subscribe calls do nothing (subscribe is idempotent), except if its attributes are different. + for existing_topic_subscription in store.topic_subscriptions.get(topic_arn, []): + sub = store.subscriptions.get(existing_topic_subscription, {}) + if sub.get("Endpoint") == endpoint: + if sub_attributes: + # validate the subscription attributes aren't different + for attr in sns_constants.VALID_SUBSCRIPTION_ATTR_NAME: + # if a new attribute is present and different from an existent one, raise + if (new_attr := sub_attributes.get(attr)) and sub.get(attr) != new_attr: + raise InvalidParameterException( + "Invalid parameter: Attributes Reason: Subscription already exists with different attributes" + ) + + return SubscribeResponse(SubscriptionArn=sub["SubscriptionArn"]) + + principal = sns_constants.DUMMY_SUBSCRIPTION_PRINCIPAL.format( + partition=get_partition(context.region), account_id=context.account_id + ) + subscription_arn = create_subscription_arn(topic_arn) + subscription = SnsSubscription( + # http://docs.aws.amazon.com/cli/latest/reference/sns/get-subscription-attributes.html + TopicArn=topic_arn, + Endpoint=endpoint, + Protocol=protocol, + SubscriptionArn=subscription_arn, + PendingConfirmation="true", + Owner=context.account_id, + RawMessageDelivery="false", # default value, will be overridden if set + FilterPolicyScope="MessageAttributes", # default value, will be overridden if set + SubscriptionPrincipal=principal, # dummy value, could be fetched with a call to STS? + ) + if sub_attributes: + subscription.update(sub_attributes) + if "FilterPolicy" in sub_attributes: + filter_policy = ( + json.loads(sub_attributes["FilterPolicy"]) + if sub_attributes["FilterPolicy"] + else None + ) + if filter_policy: + validator = FilterPolicyValidator( + scope=subscription.get("FilterPolicyScope", "MessageAttributes"), + is_subscribe_call=True, + ) + validator.validate_filter_policy(filter_policy) + + store.subscription_filter_policy[subscription_arn] = filter_policy + + store.subscriptions[subscription_arn] = subscription + + topic_subscriptions = store.topic_subscriptions.setdefault(topic_arn, []) + topic_subscriptions.append(subscription_arn) + + # store the token and subscription arn + # TODO: the token is a 288 hex char string + subscription_token = encode_subscription_token_with_region(region=context.region) + store.subscription_tokens[subscription_token] = subscription_arn + + response_subscription_arn = subscription_arn + # Send out confirmation message for HTTP(S), fix for https://github.com/localstack/localstack/issues/881 + if protocol in ["http", "https"]: + message_ctx = SnsMessage( + type=SnsMessageType.SubscriptionConfirmation, + token=subscription_token, + message=f"You have chosen to subscribe to the topic {topic_arn}.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + ) + publish_ctx = SnsPublishContext( + message=message_ctx, + store=store, + request_headers=context.request.headers, + topic_attributes=vars(self._get_topic(topic_arn, context)), + ) + self._publisher.publish_to_topic_subscriber( + ctx=publish_ctx, + topic_arn=topic_arn, + subscription_arn=subscription_arn, + ) + if not return_subscription_arn: + response_subscription_arn = "pending confirmation" + + elif protocol not in ["email", "email-json"]: + # Only HTTP(S) and email subscriptions are not auto validated + # Except if the endpoint and the topic are not in the same AWS account, then you'd need to manually confirm + # the subscription with the token + # TODO: revisit for multi-account + # TODO: test with AWS for email & email-json confirmation message + # we need to add the following check: + # if parsed_topic_arn["account"] == endpoint account (depending on the type, SQS, lambda, parse the arn) + subscription["PendingConfirmation"] = "false" + subscription["ConfirmationWasAuthenticated"] = "true" + + return SubscribeResponse(SubscriptionArn=response_subscription_arn) + + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: TagList, **kwargs + ) -> TagResourceResponse: + # each tag key must be unique + # https://docs.aws.amazon.com/general/latest/gr/aws_tagging.html#tag-best-practices + unique_tag_keys = {tag["Key"] for tag in tags} + if len(unique_tag_keys) < len(tags): + raise InvalidParameterException("Invalid parameter: Duplicated keys are not allowed.") + + call_moto(context) + store = self.get_store(context.account_id, context.region) + existing_tags = store.sns_tags.get(resource_arn, []) + + def existing_tag_index(_item): + for idx, tag in enumerate(existing_tags): + if _item["Key"] == tag["Key"]: + return idx + return None + + for item in tags: + existing_index = existing_tag_index(item) + if existing_index is None: + existing_tags.append(item) + else: + existing_tags[existing_index] = item + + store.sns_tags[resource_arn] = existing_tags + return TagResourceResponse() + + def delete_topic(self, context: RequestContext, topic_arn: topicARN, **kwargs) -> None: + parsed_arn = parse_and_validate_topic_arn(topic_arn) + if context.region != parsed_arn["region"]: + raise InvalidParameterException("Invalid parameter: TopicArn") + + call_moto(context) + store = self.get_store(account_id=parsed_arn["account"], region_name=context.region) + topic_subscriptions = store.topic_subscriptions.pop(topic_arn, []) + for topic_sub in topic_subscriptions: + store.subscriptions.pop(topic_sub, None) + + store.sns_tags.pop(topic_arn, None) + + def create_topic( + self, + context: RequestContext, + name: topicName, + attributes: TopicAttributesMap = None, + tags: TagList = None, + data_protection_policy: attributeValue = None, + **kwargs, + ) -> CreateTopicResponse: + moto_response = call_moto(context) + store = self.get_store(account_id=context.account_id, region_name=context.region) + topic_arn = moto_response["TopicArn"] + tag_resource_success = extract_tags(topic_arn, tags, True, store) + if not tag_resource_success: + raise InvalidParameterException( + "Invalid parameter: Tags Reason: Topic already exists with different tags" + ) + if tags: + self.tag_resource(context=context, resource_arn=topic_arn, tags=tags) + store.topic_subscriptions.setdefault(topic_arn, []) + return CreateTopicResponse(TopicArn=topic_arn) + + +def is_raw_message_delivery(susbcriber): + return susbcriber.get("RawMessageDelivery") in ("true", True, "True") + + +def validate_subscription_attribute( + attribute_name: str, + attribute_value: str, + topic_arn: str, + endpoint: str, + is_subscribe_call: bool = False, +) -> None: + """ + Validate the subscription attribute to be set. See: + https://docs.aws.amazon.com/sns/latest/api/API_SetSubscriptionAttributes.html + :param attribute_name: the subscription attribute name, must be in VALID_SUBSCRIPTION_ATTR_NAME + :param attribute_value: the subscription attribute value + :param topic_arn: the topic_arn of the subscription, needed to know if it is FIFO + :param endpoint: the subscription endpoint (like an SQS queue ARN) + :param is_subscribe_call: the error message is different if called from Subscribe or SetSubscriptionAttributes + :raises InvalidParameterException + :return: + """ + error_prefix = ( + "Invalid parameter: Attributes Reason: " if is_subscribe_call else "Invalid parameter: " + ) + if attribute_name not in sns_constants.VALID_SUBSCRIPTION_ATTR_NAME: + raise InvalidParameterException(f"{error_prefix}AttributeName") + + if attribute_name == "FilterPolicy": + try: + json.loads(attribute_value or "{}") + except json.JSONDecodeError: + raise InvalidParameterException(f"{error_prefix}FilterPolicy: failed to parse JSON.") + elif attribute_name == "FilterPolicyScope": + if attribute_value not in ("MessageAttributes", "MessageBody"): + raise InvalidParameterException( + f"{error_prefix}FilterPolicyScope: Invalid value [{attribute_value}]. " + f"Please use either MessageBody or MessageAttributes" + ) + elif attribute_name == "RawMessageDelivery": + # TODO: only for SQS and https(s) subs, + firehose + if attribute_value.lower() not in ("true", "false"): + raise InvalidParameterException( + f"{error_prefix}RawMessageDelivery: Invalid value [{attribute_value}]. " + f"Must be true or false." + ) + + elif attribute_name == "RedrivePolicy": + try: + dlq_target_arn = json.loads(attribute_value).get("deadLetterTargetArn", "") + except json.JSONDecodeError: + raise InvalidParameterException(f"{error_prefix}RedrivePolicy: failed to parse JSON.") + try: + parsed_arn = parse_arn(dlq_target_arn) + except InvalidArnException: + raise InvalidParameterException( + f"{error_prefix}RedrivePolicy: deadLetterTargetArn is an invalid arn" + ) + + if topic_arn.endswith(".fifo"): + if endpoint.endswith(".fifo") and ( + not parsed_arn["resource"].endswith(".fifo") or "sqs" not in parsed_arn["service"] + ): + raise InvalidParameterException( + f"{error_prefix}RedrivePolicy: must use a FIFO queue as DLQ for a FIFO Subscription to a FIFO Topic." + ) + + +def validate_message_attributes( + message_attributes: MessageAttributeMap, position: int | None = None +) -> None: + """ + Validate the message attributes, and raises an exception if those do not follow AWS validation + See: https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html + Regex from: https://stackoverflow.com/questions/40718851/regex-that-does-not-allow-consecutive-dots + :param message_attributes: the message attributes map for the message + :param position: given to give the Batch Entry position if coming from `publishBatch` + :raises: InvalidParameterValueException + :return: None + """ + for attr_name, attr in message_attributes.items(): + if len(attr_name) > 256: + raise InvalidParameterValueException( + "Length of message attribute name must be less than 256 bytes." + ) + validate_message_attribute_name(attr_name) + # `DataType` is a required field for MessageAttributeValue + if (data_type := attr.get("DataType")) is None: + if position: + at = f"publishBatchRequestEntries.{position}.member.messageAttributes.{attr_name}.member.dataType" + else: + at = f"messageAttributes.{attr_name}.member.dataType" + + raise CommonServiceException( + code="ValidationError", + message=f"1 validation error detected: Value null at '{at}' failed to satisfy constraint: Member must not be null", + sender_fault=True, + ) + + if data_type not in ( + "String", + "Number", + "Binary", + ) and not sns_constants.ATTR_TYPE_REGEX.match(data_type): + raise InvalidParameterValueException( + f"The message attribute '{attr_name}' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String." + ) + if not any(attr_value.endswith("Value") for attr_value in attr): + raise InvalidParameterValueException( + f"The message attribute '{attr_name}' must contain non-empty message attribute value for message attribute type '{data_type}'." + ) + + value_key_data_type = "Binary" if data_type.startswith("Binary") else "String" + value_key = f"{value_key_data_type}Value" + if value_key not in attr: + raise InvalidParameterValueException( + f"The message attribute '{attr_name}' with type '{data_type}' must use field '{value_key_data_type}'." + ) + elif not attr[value_key]: + raise InvalidParameterValueException( + f"The message attribute '{attr_name}' must contain non-empty message attribute value for message attribute type '{data_type}'.", + ) + + +def validate_message_attribute_name(name: str) -> None: + """ + Validate the message attribute name with the specification of AWS. + The message attribute name can contain the following characters: A-Z, a-z, 0-9, underscore(_), hyphen(-), and period (.). The name must not start or end with a period, and it should not have successive periods. + :param name: message attribute name + :raises InvalidParameterValueException: if the name does not conform to the spec + """ + if not sns_constants.MSG_ATTR_NAME_REGEX.match(name): + # find the proper exception + if name[0] == ".": + raise InvalidParameterValueException( + "Invalid message attribute name starting with character '.' was found." + ) + elif name[-1] == ".": + raise InvalidParameterValueException( + "Invalid message attribute name ending with character '.' was found." + ) + + for idx, char in enumerate(name): + if char not in sns_constants.VALID_MSG_ATTR_NAME_CHARS: + # change prefix from 0x to #x, without capitalizing the x + hex_char = "#x" + hex(ord(char)).upper()[2:] + raise InvalidParameterValueException( + f"Invalid non-alphanumeric character '{hex_char}' was found in the message attribute name. Can only include alphanumeric characters, hyphens, underscores, or dots." + ) + # even if we go negative index, it will be covered by starting/ending with dot + if char == "." and name[idx - 1] == ".": + raise InvalidParameterValueException( + "Message attribute name can not have successive '.' character." + ) + + +def extract_tags( + topic_arn: str, tags: TagList, is_create_topic_request: bool, store: SnsStore +) -> bool: + existing_tags = list(store.sns_tags.get(topic_arn, [])) + # if this is none there is nothing to check + if topic_arn in store.topic_subscriptions: + if tags is None: + tags = [] + for tag in tags: + # this means topic already created with empty tags and when we try to create it + # again with other tag value then it should fail according to aws documentation. + if is_create_topic_request and existing_tags is not None and tag not in existing_tags: + return False + return True + + +def parse_and_validate_topic_arn(topic_arn: str | None) -> ArnData: + topic_arn = topic_arn or "" + try: + return parse_arn(topic_arn) + except InvalidArnException: + count = len(topic_arn.split(":")) + raise InvalidParameterException( + f"Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not {count}" + ) + + +def create_subscription_arn(topic_arn: str) -> str: + # This is the format of a Subscription ARN + # arn:aws:sns:us-west-2:123456789012:my-topic:8a21d249-4329-4871-acc6-7be709c6ea7f + return f"{topic_arn}:{uuid4()}" + + +def encode_subscription_token_with_region(region: str) -> str: + """ + Create a 64 characters Subscription Token with the region encoded + :param region: + :return: a subscription token with the region encoded + """ + return ((region.encode() + b"/").hex() + short_uid() * 8)[:64] + + +def get_region_from_subscription_token(token: str) -> str: + """ + Try to decode and return the region from a subscription token + :param token: + :return: the region if able to decode it + :raises: InvalidParameterException if the token is invalid + """ + try: + region = token.split("2f", maxsplit=1)[0] + return bytes.fromhex(region).decode("utf-8") + except (IndexError, ValueError, TypeError, UnicodeDecodeError): + raise InvalidParameterException("Invalid parameter: Token") + + +def get_next_page_token_from_arn(resource_arn: str) -> str: + return to_str(base64.b64encode(to_bytes(resource_arn))) + + +def _get_byte_size(payload: str | bytes) -> int: + # Calculate the real length of the byte object if the object is a string + return len(to_bytes(payload)) + + +def get_total_publish_size( + message_body: str, message_attributes: MessageAttributeMap | None +) -> int: + size = _get_byte_size(message_body) + if message_attributes: + # https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html + # All parts of the message attribute, including name, type, and value, are included in the message size + # restriction, which is 256 KB. + # iterate over the Keys and Attributes, adding the length of the Key to the length of all Attributes values + # (DataType and StringValue or BinaryValue) + size += sum( + _get_byte_size(key) + sum(_get_byte_size(attr_value) for attr_value in attr.values()) + for key, attr in message_attributes.items() + ) + + return size + + +def register_sns_api_resource(router: Router): + """Register the retrospection endpoints as internal LocalStack endpoints.""" + router.add(SNSServicePlatformEndpointMessagesApiResource()) + router.add(SNSServiceSMSMessagesApiResource()) + router.add(SNSServiceSubscriptionTokenApiResource()) + + +def _format_messages(sent_messages: List[Dict[str, str]], validated_keys: List[str]): + """ + This method format the messages to be more readable and undo the format change that was needed for Moto + Should be removed once we refactor SNS. + """ + formatted_messages = [] + for sent_message in sent_messages: + msg = { + key: json.dumps(value) + if key == "Message" and sent_message.get("MessageStructure") == "json" + else value + for key, value in sent_message.items() + if key in validated_keys + } + formatted_messages.append(msg) + + return formatted_messages + + +class SNSInternalResource: + resource_type: str + """Base class with helper to properly track usage of internal endpoints""" + + def count_usage(self): + internal_api_calls.labels(resource_type=self.resource_type).increment() + + +def count_usage(f): + @functools.wraps(f) + def _wrapper(self, *args, **kwargs): + self.count_usage() + return f(self, *args, **kwargs) + + return _wrapper + + +class SNSServicePlatformEndpointMessagesApiResource(SNSInternalResource): + resource_type = "platform-endpoint-message" + """Provides a REST API for retrospective access to platform endpoint messages sent via SNS. + + This is registered as a LocalStack internal HTTP resource. + + This endpoint accepts: + - GET param `accountId`: selector for AWS account. If not specified, return fallback `000000000000` test ID + - GET param `region`: selector for AWS `region`. If not specified, return default "us-east-1" + - GET param `endpointArn`: filter for `endpointArn` resource in SNS + - DELETE param `accountId`: selector for AWS account + - DELETE param `region`: will delete saved messages for `region` + - DELETE param `endpointArn`: will delete saved messages for `endpointArn` + """ + + _PAYLOAD_FIELDS = [ + "TargetArn", + "TopicArn", + "Message", + "MessageAttributes", + "MessageStructure", + "Subject", + "MessageId", + ] + + @route(sns_constants.PLATFORM_ENDPOINT_MSGS_ENDPOINT, methods=["GET"]) + @count_usage + def on_get(self, request: Request): + filter_endpoint_arn = request.args.get("endpointArn") + account_id = ( + request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID) + if not filter_endpoint_arn + else extract_account_id_from_arn(filter_endpoint_arn) + ) + region = ( + request.args.get("region", AWS_REGION_US_EAST_1) + if not filter_endpoint_arn + else extract_region_from_arn(filter_endpoint_arn) + ) + store: SnsStore = sns_stores[account_id][region] + if filter_endpoint_arn: + messages = store.platform_endpoint_messages.get(filter_endpoint_arn, []) + messages = _format_messages(messages, self._PAYLOAD_FIELDS) + return { + "platform_endpoint_messages": {filter_endpoint_arn: messages}, + "region": region, + } + + platform_endpoint_messages = { + endpoint_arn: _format_messages(messages, self._PAYLOAD_FIELDS) + for endpoint_arn, messages in store.platform_endpoint_messages.items() + } + return { + "platform_endpoint_messages": platform_endpoint_messages, + "region": region, + } + + @route(sns_constants.PLATFORM_ENDPOINT_MSGS_ENDPOINT, methods=["DELETE"]) + @count_usage + def on_delete(self, request: Request) -> Response: + filter_endpoint_arn = request.args.get("endpointArn") + account_id = ( + request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID) + if not filter_endpoint_arn + else extract_account_id_from_arn(filter_endpoint_arn) + ) + region = ( + request.args.get("region", AWS_REGION_US_EAST_1) + if not filter_endpoint_arn + else extract_region_from_arn(filter_endpoint_arn) + ) + store: SnsStore = sns_stores[account_id][region] + if filter_endpoint_arn: + store.platform_endpoint_messages.pop(filter_endpoint_arn, None) + return Response("", status=204) + + store.platform_endpoint_messages.clear() + return Response("", status=204) + + +class SNSServiceSMSMessagesApiResource(SNSInternalResource): + resource_type = "sms-message" + """Provides a REST API for retrospective access to SMS messages sent via SNS. + + This is registered as a LocalStack internal HTTP resource. + + This endpoint accepts: + - GET param `accountId`: selector for AWS account. If not specified, return fallback `000000000000` test ID + - GET param `region`: selector for AWS `region`. If not specified, return default "us-east-1" + - GET param `phoneNumber`: filter for `phoneNumber` resource in SNS + """ + + _PAYLOAD_FIELDS = [ + "PhoneNumber", + "TopicArn", + "SubscriptionArn", + "MessageId", + "Message", + "MessageAttributes", + "MessageStructure", + "Subject", + ] + + @route(sns_constants.SMS_MSGS_ENDPOINT, methods=["GET"]) + @count_usage + def on_get(self, request: Request): + account_id = request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID) + region = request.args.get("region", AWS_REGION_US_EAST_1) + filter_phone_number = request.args.get("phoneNumber") + store: SnsStore = sns_stores[account_id][region] + if filter_phone_number: + messages = [ + m for m in store.sms_messages if m.get("PhoneNumber") == filter_phone_number + ] + messages = _format_messages(messages, self._PAYLOAD_FIELDS) + return { + "sms_messages": {filter_phone_number: messages}, + "region": region, + } + + sms_messages = {} + + for m in _format_messages(store.sms_messages, self._PAYLOAD_FIELDS): + sms_messages.setdefault(m.get("PhoneNumber"), []).append(m) + + return { + "sms_messages": sms_messages, + "region": region, + } + + @route(sns_constants.SMS_MSGS_ENDPOINT, methods=["DELETE"]) + @count_usage + def on_delete(self, request: Request) -> Response: + account_id = request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID) + region = request.args.get("region", AWS_REGION_US_EAST_1) + filter_phone_number = request.args.get("phoneNumber") + store: SnsStore = sns_stores[account_id][region] + if filter_phone_number: + store.sms_messages = [ + m for m in store.sms_messages if m.get("PhoneNumber") != filter_phone_number + ] + return Response("", status=204) + + store.sms_messages.clear() + return Response("", status=204) + + +class SNSServiceSubscriptionTokenApiResource(SNSInternalResource): + resource_type = "subscription-token" + """Provides a REST API for retrospective access to Subscription Confirmation Tokens to confirm subscriptions. + Those are not sent for email, and sometimes inaccessible when working with external HTTPS endpoint which won't be + able to reach your local host. + + This is registered as a LocalStack internal HTTP resource. + + This endpoint has the following parameter: + - GET `subscription_arn`: `subscriptionArn`resource in SNS for which you want the SubscriptionToken + """ + + @route(f"{sns_constants.SUBSCRIPTION_TOKENS_ENDPOINT}/", methods=["GET"]) + @count_usage + def on_get(self, _request: Request, subscription_arn: str): + try: + parsed_arn = parse_arn(subscription_arn) + except InvalidArnException: + response = Response("", 400) + response.set_json( + { + "error": "The provided SubscriptionARN is invalid", + "subscription_arn": subscription_arn, + } + ) + return response + + store: SnsStore = sns_stores[parsed_arn["account"]][parsed_arn["region"]] + + for token, sub_arn in store.subscription_tokens.items(): + if sub_arn == subscription_arn: + return { + "subscription_token": token, + "subscription_arn": subscription_arn, + } + + response = Response("", 404) + response.set_json( + { + "error": "The provided SubscriptionARN is not found", + "subscription_arn": subscription_arn, + } + ) + return response diff --git a/localstack-core/localstack/services/sns/publisher.py b/localstack-core/localstack/services/sns/publisher.py new file mode 100644 index 0000000000000..9510885f51431 --- /dev/null +++ b/localstack-core/localstack/services/sns/publisher.py @@ -0,0 +1,1357 @@ +import abc +import base64 +import copy +import datetime +import hashlib +import json +import logging +import time +import traceback +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Dict, List, Tuple, Union + +import requests +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding + +from localstack import config +from localstack.aws.api.lambda_ import InvocationType +from localstack.aws.api.sns import MessageAttributeMap +from localstack.aws.connect import connect_to +from localstack.config import external_service_url +from localstack.services.sns import constants as sns_constants +from localstack.services.sns.certificate import SNS_SERVER_PRIVATE_KEY +from localstack.services.sns.executor import TopicPartitionedThreadPoolExecutor +from localstack.services.sns.filter import SubscriptionFilter +from localstack.services.sns.models import ( + SnsApplicationPlatforms, + SnsMessage, + SnsMessageType, + SnsStore, + SnsSubscription, +) +from localstack.utils.aws.arns import ( + PARTITION_NAMES, + extract_account_id_from_arn, + extract_region_from_arn, + extract_resource_from_arn, + parse_arn, + sqs_queue_url_for_arn, +) +from localstack.utils.aws.aws_responses import create_sqs_system_attributes +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.aws.dead_letter_queue import sns_error_to_dead_letter_queue +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.cloudwatch.cloudwatch_util import store_cloudwatch_logs +from localstack.utils.objects import not_none_or +from localstack.utils.strings import long_uid, md5, to_bytes, to_str +from localstack.utils.time import timestamp_millis + +LOG = logging.getLogger(__name__) + + +@dataclass +class SnsPublishContext: + message: SnsMessage + store: SnsStore + request_headers: dict[str, str] + topic_attributes: dict[str, str] = field(default_factory=dict) + + +@dataclass +class SnsBatchPublishContext: + messages: List[SnsMessage] + store: SnsStore + request_headers: Dict[str, str] + topic_attributes: dict[str, str] = field(default_factory=dict) + + +class TopicPublisher(abc.ABC): + """ + The TopicPublisher is responsible for publishing SNS messages to a topic's subscription. + This is the base class implementing the basic logic. + Each subclass will need to implement `_publish` using the subscription's protocol logic and client. + Subclasses can override `prepare_message` if the format of the message is different. + """ + + def publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + """ + This function wraps the underlying call to the actual publishing. This allows us to catch any uncaught + exception and log it properly. This method is passed to the ThreadPoolExecutor, which would swallow the + exception. This is a convenient way of doing it, but not something the abstract class should take care. + Discussion here: https://github.com/localstack/localstack/pull/7267#discussion_r1056873437 + # TODO: move this out of the base class + :param context: the SnsPublishContext created by the caller, containing the necessary data to publish the + message + :param subscriber: the subscription data + :return: + """ + try: + self._publish(context=context, subscriber=subscriber) + except Exception: + LOG.exception( + "An internal error occurred while trying to send the SNS message %s", + context.message, + ) + return + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + """ + Base method for publishing the message. It is up to the child class to implement its way to publish the message + :param context: the SnsPublishContext created by the caller, containing the necessary data to publish the + message + :param subscriber: the subscription data + :return: + """ + raise NotImplementedError + + def prepare_message( + self, + message_context: SnsMessage, + subscriber: SnsSubscription, + topic_attributes: dict[str, str] = None, + ) -> str: + """ + Returns the message formatted in the base SNS message format. The base SNS message format is shared amongst + SQS, HTTP(S), email-json and Firehose. + See https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html + :param message_context: the SnsMessage containing the message data + :param subscriber: the SNS subscription + :param topic_attributes: the SNS Topic attributes + :return: formatted SNS message body in a JSON string + """ + return create_sns_message_body(message_context, subscriber, topic_attributes) + + +class EndpointPublisher(abc.ABC): + """ + The EndpointPublisher is responsible for publishing SNS messages directly to an endpoint. + SNS allows directly publishing to phone numbers and application endpoints. + This is the base class implementing the basic logic. + Each subclass will need to implement `_publish` and `prepare_message `using the subscription's protocol logic + and client. + """ + + def publish(self, context: SnsPublishContext, endpoint: str): + """ + This function wraps the underlying call to the actual publishing. This allows us to catch any uncaught + exception and log it properly. This method is passed to the ThreadPoolExecutor, which would swallow the + exception. This is a convenient way of doing it, but not something the abstract class should take care. + Discussion here: https://github.com/localstack/localstack/pull/7267#discussion_r1056873437 + # TODO: move this out of the base class + :param context: the SnsPublishContext created by the caller, containing the necessary data to publish the + message + :param endpoint: the endpoint where the message should be published + :return: + """ + try: + self._publish(context=context, endpoint=endpoint) + except Exception: + LOG.exception( + "An internal error occurred while trying to send the SNS message %s", + context.message, + ) + return + + def _publish(self, context: SnsPublishContext, endpoint: str): + """ + Base method for publishing the message. It is up to the child class to implement its way to publish the message + :param context: the SnsPublishContext created by the caller, containing the necessary data to publish the + message + :param endpoint: the endpoint where the message should be published + :return: + """ + raise NotImplementedError + + def prepare_message(self, message_context: SnsMessage, endpoint: str) -> str: + """ + Base method to format the message. It is up to the child class to implement it. + :param message_context: the SnsMessage containing the message data + :param endpoint: the endpoint where the message should be published + :return: the formatted message + """ + raise NotImplementedError + + +class LambdaTopicPublisher(TopicPublisher): + """ + The Lambda publisher is responsible for invoking a subscribed lambda function to process the SNS message using + `Lambda.invoke` with the formatted message as Payload. + See: https://docs.aws.amazon.com/lambda/latest/dg/with-sns.html + """ + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + try: + region = extract_region_from_arn(subscriber["Endpoint"]) + lambda_client = connect_to(region_name=region).lambda_.request_metadata( + source_arn=subscriber["TopicArn"], service_principal="sns" + ) + event = self.prepare_message(context.message, subscriber, context.topic_attributes) + inv_result = lambda_client.invoke( + FunctionName=subscriber["Endpoint"], + Payload=to_bytes(event), + InvocationType=InvocationType.Event, + ) + status_code = inv_result.get("StatusCode") + payload = inv_result.get("Payload") + if payload: + delivery = { + "statusCode": status_code, + "providerResponse": json.dumps( + {"lambdaRequestId": inv_result["ResponseMetadata"]["RequestId"]} + ), + } + store_delivery_log( + context.message, + subscriber, + success=True, + topic_attributes=context.topic_attributes, + delivery=delivery, + ) + + except Exception as exc: + LOG.info( + "Unable to run Lambda function on SNS message: %s %s", exc, traceback.format_exc() + ) + store_delivery_log( + context.message, + subscriber, + success=False, + topic_attributes=context.topic_attributes, + ) + message_body = create_sns_message_body( + message_context=context.message, + subscriber=subscriber, + topic_attributes=context.topic_attributes, + ) + sns_error_to_dead_letter_queue(subscriber, message_body, str(exc)) + + def prepare_message( + self, + message_context: SnsMessage, + subscriber: SnsSubscription, + topic_attributes: dict[str, str] = None, + ) -> str: + """ + You can see Lambda SNS Event format here: https://docs.aws.amazon.com/lambda/latest/dg/with-sns.html + :param message_context: the SnsMessage containing the message data + :param subscriber: the SNS subscription + :return: an SNS message body formatted as a lambda Event in a JSON string + """ + external_url = get_cert_base_url() + unsubscribe_url = create_unsubscribe_url(external_url, subscriber["SubscriptionArn"]) + message_attributes = prepare_message_attributes(message_context.message_attributes) + + event_payload = { + "Type": message_context.type or SnsMessageType.Notification, + "MessageId": message_context.message_id, + "Subject": message_context.subject, + "TopicArn": subscriber["TopicArn"], + "Message": message_context.message_content(subscriber["Protocol"]), + "Timestamp": timestamp_millis(), + "UnsubscribeUrl": unsubscribe_url, + "MessageAttributes": message_attributes, + } + + signature_version = ( + topic_attributes.get("signature_version", "1") if topic_attributes else "1" + ) + canonical_string = compute_canonical_string(event_payload, message_context.type) + signature = get_message_signature(canonical_string, signature_version=signature_version) + + event_payload.update( + { + # this is a bug on AWS side, it is always returned a 1, but it should be actual version of the topic + "SignatureVersion": "1", + "Signature": signature, + "SigningCertUrl": f"{external_url}{sns_constants.SNS_CERT_ENDPOINT}", + } + ) + event = { + "Records": [ + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": subscriber["SubscriptionArn"], + "Sns": event_payload, + } + ] + } + return json.dumps(event) + + +class SqsTopicPublisher(TopicPublisher): + """ + The SQS publisher is responsible for publishing the SNS message to a subscribed SQS queue using `SQS.send_message`. + For integrations and the format of message, see: + https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html + """ + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + message_context = context.message + try: + message_body = self.prepare_message( + message_context, subscriber, topic_attributes=context.topic_attributes + ) + kwargs = self.get_sqs_kwargs(msg_context=message_context, subscriber=subscriber) + except Exception: + LOG.exception("An internal error occurred while trying to format the message for SQS") + return + try: + queue_url: str = sqs_queue_url_for_arn(subscriber["Endpoint"]) + region = extract_region_from_arn(subscriber["Endpoint"]) + sqs_client = connect_to(region_name=region).sqs.request_metadata( + source_arn=subscriber["TopicArn"], service_principal="sns" + ) + sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=message_body, + MessageSystemAttributes=create_sqs_system_attributes(context.request_headers), + **kwargs, + ) + store_delivery_log( + message_context, subscriber, success=True, topic_attributes=context.topic_attributes + ) + except Exception as exc: + LOG.info("Unable to forward SNS message to SQS: %s %s", exc, traceback.format_exc()) + store_delivery_log( + message_context, + subscriber, + success=False, + topic_attributes=context.topic_attributes, + ) + sns_error_to_dead_letter_queue(subscriber, message_body, str(exc), **kwargs) + if "NonExistentQueue" in str(exc): + LOG.debug("The SQS queue endpoint does not exist anymore") + # todo: if the queue got deleted, even if we recreate a queue with the same name/url + # AWS won't send to it anymore. Would need to unsub/resub. + # We should mark this subscription as "broken" + + @staticmethod + def get_sqs_kwargs(msg_context: SnsMessage, subscriber: SnsSubscription): + kwargs = {} + if is_raw_message_delivery(subscriber) and msg_context.message_attributes: + kwargs["MessageAttributes"] = msg_context.message_attributes + + # SNS now allows regular non-fifo subscriptions to FIFO topics. Validate that the subscription target is fifo + # before passing the FIFO-only parameters + if subscriber["Endpoint"].endswith(".fifo"): + if msg_context.message_group_id: + kwargs["MessageGroupId"] = msg_context.message_group_id + if msg_context.message_deduplication_id: + kwargs["MessageDeduplicationId"] = msg_context.message_deduplication_id + elif subscriber["TopicArn"].endswith(".fifo"): + # Amazon SNS uses the message body provided to generate a unique hash value to use as the deduplication + # ID for each message, so you don't need to set a deduplication ID when you send each message. + # https://docs.aws.amazon.com/sns/latest/dg/fifo-message-dedup.html + content = msg_context.message_content("sqs") + kwargs["MessageDeduplicationId"] = hashlib.sha256( + content.encode("utf-8") + ).hexdigest() + + # TODO: for message deduplication, we are using the underlying features of the SQS queue + # however, SQS queue only deduplicate at the Queue level, where the SNS topic deduplicate on the topic level + # we will need to implement this + return kwargs + + +class SqsBatchTopicPublisher(SqsTopicPublisher): + """ + The SQS Batch publisher is responsible for publishing batched SNS messages to a subscribed SQS queue using + `SQS.send_message_batch`. This allows to make use of SQS batching capabilities. + See https://docs.aws.amazon.com/sns/latest/dg/sns-batch-api-actions.html + https://docs.aws.amazon.com/sns/latest/api/API_PublishBatch.html + https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessageBatch.html + """ + + def _publish(self, context: SnsBatchPublishContext, subscriber: SnsSubscription): + entries = [] + sqs_system_attrs = create_sqs_system_attributes(context.request_headers) + # TODO: check ID, SNS rules are not the same as SQS, so maybe generate the entries ID + failure_map = {} + for index, message_ctx in enumerate(context.messages): + message_body = self.prepare_message( + message_ctx, subscriber, topic_attributes=context.topic_attributes + ) + sqs_kwargs = self.get_sqs_kwargs(message_ctx, subscriber) + entry = {"Id": f"sns-batch-{index}", "MessageBody": message_body, **sqs_kwargs} + # in case of failure + failure_map[entry["Id"]] = { + "context": message_ctx, + "entry": entry, + } + + if sqs_system_attrs: + entry["MessageSystemAttributes"] = sqs_system_attrs + + entries.append(entry) + + try: + queue_url = sqs_queue_url_for_arn(subscriber["Endpoint"]) + + account_id = extract_account_id_from_arn(subscriber["Endpoint"]) + region = extract_region_from_arn(subscriber["Endpoint"]) + + sqs_client = connect_to( + aws_access_key_id=account_id, region_name=region + ).sqs.request_metadata(source_arn=subscriber["TopicArn"], service_principal="sns") + response = sqs_client.send_message_batch(QueueUrl=queue_url, Entries=entries) + + for message_ctx in context.messages: + store_delivery_log( + message_ctx, subscriber, success=True, topic_attributes=context.topic_attributes + ) + + if failed_messages := response.get("Failed"): + for failed_msg in failed_messages: + failure_data = failure_map.get(failed_msg["Id"]) + LOG.info( + "Unable to forward SNS message to SQS: %s %s", + failed_msg["Code"], + failed_msg["Message"], + ) + store_delivery_log( + failure_data["context"], + subscriber, + success=False, + topic_attributes=context.topic_attributes, + ) + kwargs = {} + if msg_attrs := failure_data["entry"].get("MessageAttributes"): + kwargs["MessageAttributes"] = msg_attrs + + if msg_group_id := failure_data["context"].get("MessageGroupId"): + kwargs["MessageGroupId"] = msg_group_id + + if msg_dedup_id := failure_data["context"].get("MessageDeduplicationId"): + kwargs["MessageDeduplicationId"] = msg_dedup_id + + sns_error_to_dead_letter_queue( + sns_subscriber=subscriber, + message=failure_data["entry"]["MessageBody"], + error=failed_msg["Code"], + **kwargs, + ) + + except Exception as exc: + LOG.info("Unable to forward SNS message to SQS: %s %s", exc, traceback.format_exc()) + for message_ctx in context.messages: + store_delivery_log( + message_ctx, + subscriber, + success=False, + topic_attributes=context.topic_attributes, + ) + msg_body = self.prepare_message( + message_ctx, subscriber, topic_attributes=context.topic_attributes + ) + kwargs = self.get_sqs_kwargs(message_ctx, subscriber) + + sns_error_to_dead_letter_queue( + subscriber, + msg_body, + str(exc), + **kwargs, + ) + if "NonExistentQueue" in str(exc): + LOG.debug("The SQS queue endpoint does not exist anymore") + # todo: if the queue got deleted, even if we recreate a queue with the same name/url + # AWS won't send to it anymore. Would need to unsub/resub. + # We should mark this subscription as "broken" + + +class HttpTopicPublisher(TopicPublisher): + """ + The HTTP(S) publisher is responsible for publishing the SNS message to an external HTTP(S) endpoint which subscribed + to the topic. It will create an HTTP POST request to be sent to the endpoint. + See https://docs.aws.amazon.com/sns/latest/dg/sns-http-https-endpoint-as-subscriber.html + """ + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + message_context = context.message + message_body = self.prepare_message( + message_context, subscriber, topic_attributes=context.topic_attributes + ) + try: + message_headers = { + "Content-Type": "text/plain; charset=UTF-8", + "Accept-Encoding": "gzip,deflate", + "User-Agent": "Amazon Simple Notification Service Agent", + # AWS headers according to + # https://docs.aws.amazon.com/sns/latest/dg/sns-message-and-json-formats.html#http-header + "x-amz-sns-message-type": message_context.type, + "x-amz-sns-message-id": message_context.message_id, + "x-amz-sns-topic-arn": subscriber["TopicArn"], + } + if message_context.type != SnsMessageType.SubscriptionConfirmation: + # while testing, never had those from AWS but the docs above states it should be there + message_headers["x-amz-sns-subscription-arn"] = subscriber["SubscriptionArn"] + + # When raw message delivery is enabled, x-amz-sns-rawdelivery needs to be set to 'true' + # indicating that the message has been published without JSON formatting. + # https://docs.aws.amazon.com/sns/latest/dg/sns-large-payload-raw-message-delivery.html + if message_context.type == SnsMessageType.Notification: + if is_raw_message_delivery(subscriber): + message_headers["x-amz-sns-rawdelivery"] = "true" + if content_type := self._get_content_type(subscriber, context.topic_attributes): + message_headers["Content-Type"] = content_type + + response = requests.post( + subscriber["Endpoint"], + headers=message_headers, + data=message_body, + verify=False, + ) + + delivery = { + "statusCode": response.status_code, + "providerResponse": response.content.decode("utf-8"), + } + store_delivery_log( + message_context, + subscriber, + success=True, + delivery=delivery, + topic_attributes=context.topic_attributes, + ) + + response.raise_for_status() + except Exception as exc: + LOG.info( + "Received error on sending SNS message, putting to DLQ (if configured): %s", exc + ) + store_delivery_log( + message_context, + subscriber, + success=False, + topic_attributes=context.topic_attributes, + ) + # AWS doesn't send to the DLQ if there's an error trying to deliver a UnsubscribeConfirmation msg + if message_context.type != SnsMessageType.UnsubscribeConfirmation: + sns_error_to_dead_letter_queue(subscriber, message_body, str(exc)) + + @staticmethod + def _get_content_type(subscriber: SnsSubscription, topic_attributes: dict) -> str | None: + # TODO: we need to load the DeliveryPolicy every time if there's one, we should probably save the loaded + # policy on the subscription and dumps it when requested instead + # to be much faster, once the logic is implemented in moto, we would only need to fetch EffectiveDeliveryPolicy, + # which would already have the value from the topic + if json_sub_delivery_policy := subscriber.get("DeliveryPolicy"): + sub_delivery_policy = json.loads(json_sub_delivery_policy) + if sub_content_type := sub_delivery_policy.get("requestPolicy", {}).get( + "headerContentType" + ): + return sub_content_type + + if json_topic_delivery_policy := topic_attributes.get("delivery_policy"): + topic_delivery_policy = json.loads(json_topic_delivery_policy) + if not ( + topic_content_type := topic_delivery_policy.get(subscriber["Protocol"].lower()) + ): + return + if content_type := topic_content_type.get("defaultRequestPolicy", {}).get( + "headerContentType" + ): + return content_type + + +class EmailJsonTopicPublisher(TopicPublisher): + """ + The email-json publisher is responsible for publishing the SNS message to a subscribed email address. + The format of the message will be JSON-encoded, and "is meant for applications to programmatically process emails". + There is not a lot of AWS documentation on SNS emails. + See https://docs.aws.amazon.com/sns/latest/dg/sns-email-notifications.html + But it is mentioned several times in the SNS FAQ (especially in #Transports section): + https://aws.amazon.com/sns/faqs/ + """ + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + account_id = extract_account_id_from_arn(subscriber["Endpoint"]) + region = extract_region_from_arn(subscriber["Endpoint"]) + ses_client = connect_to(aws_access_key_id=account_id, region_name=region).ses + if endpoint := subscriber.get("Endpoint"): + # TODO: legacy value, replace by a more sane value in the future + # no-reply@sns-localstack.cloud or similar + sender = config.SNS_SES_SENDER_ADDRESS or "admin@localstack.com" + ses_client.verify_email_address(EmailAddress=endpoint) + ses_client.verify_email_address(EmailAddress=sender) + message_body = self.prepare_message( + context.message, subscriber, topic_attributes=context.topic_attributes + ) + ses_client.send_email( + Source=sender, + Message={ + "Body": {"Text": {"Data": message_body}}, + "Subject": {"Data": "SNS-Subscriber-Endpoint"}, + }, + Destination={"ToAddresses": [endpoint]}, + ) + + +class EmailTopicPublisher(EmailJsonTopicPublisher): + """ + The email publisher is responsible for publishing the SNS message to a subscribed email address. + The format of the message will be text-based, and "is meant for end-users/consumers and notifications are regular, + text-based messages which are easily readable." + See https://docs.aws.amazon.com/sns/latest/dg/sns-email-notifications.html + """ + + def prepare_message( + self, + message_context: SnsMessage, + subscriber: SnsSubscription, + topic_attributes: dict[str, str] = None, + ) -> str: + return message_context.message_content(subscriber["Protocol"]) + + +class ApplicationTopicPublisher(TopicPublisher): + """ + The application publisher is responsible for publishing the SNS message to a subscribed SNS application endpoint. + The SNS application endpoint represents a mobile app and device. + The application endpoint can be of different types, represented in `SnsApplicationPlatforms`. + This is not directly implemented yet in LocalStack, we save the message to be retrieved later from an internal + endpoint. + The `LEGACY_SNS_GCM_PUBLISHING` flag allows direct publishing to the GCM platform, with some caveats: + - It always publishes if the platform is GCM, and raises an exception if the credentials are wrong. + - the Platform Application should be validated before and not while publishing + See https://docs.aws.amazon.com/sns/latest/dg/sns-mobile-application-as-subscriber.html + """ + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + endpoint_arn = subscriber["Endpoint"] + message = self.prepare_message( + context.message, subscriber, topic_attributes=context.topic_attributes + ) + cache = context.store.platform_endpoint_messages.setdefault(endpoint_arn, []) + cache.append(message) + + if ( + config.LEGACY_SNS_GCM_PUBLISHING + and get_platform_type_from_endpoint_arn(endpoint_arn) == "GCM" + ): + self._legacy_publish_to_gcm(context, endpoint_arn) + + # TODO: rewrite the platform application publishing logic + # will need to validate credentials when creating platform app earlier, need thorough testing + + store_delivery_log( + context.message, subscriber, success=True, topic_attributes=context.topic_attributes + ) + + def prepare_message( + self, + message_context: SnsMessage, + subscriber: SnsSubscription, + topic_attributes: dict[str, str] = None, + ) -> dict[str, str]: + endpoint_arn = subscriber["Endpoint"] + platform_type = get_platform_type_from_endpoint_arn(endpoint_arn) + return { + "TargetArn": endpoint_arn, + "TopicArn": subscriber["TopicArn"], + "SubscriptionArn": subscriber["SubscriptionArn"], + "Message": message_context.message_content(protocol=platform_type), + "MessageAttributes": message_context.message_attributes, + "MessageStructure": message_context.message_structure, + "Subject": message_context.subject, + } + + @staticmethod + def _legacy_publish_to_gcm(context: SnsPublishContext, endpoint: str): + application_attributes, endpoint_attributes = get_attributes_for_application_endpoint( + endpoint + ) + send_message_to_gcm( + context=context, + app_attributes=application_attributes, + endpoint_attributes=endpoint_attributes, + ) + + +class SmsTopicPublisher(TopicPublisher): + """ + The SMS publisher is responsible for publishing the SNS message to a subscribed phone number. + This is not directly implemented yet in LocalStack, we only save the message. + # TODO: create an internal endpoint to retrieve SMS. + """ + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + event = self.prepare_message( + context.message, subscriber, topic_attributes=context.topic_attributes + ) + context.store.sms_messages.append(event) + LOG.info( + "Delivering SMS message to %s: %s from topic: %s", + event["PhoneNumber"], + event["Message"], + event["TopicArn"], + ) + + # MOCK DATA + delivery = { + "phoneCarrier": "Mock Carrier", + "mnc": 270, + "priceInUSD": 0.00645, + "smsType": "Transactional", + "mcc": 310, + "providerResponse": "Message has been accepted by phone carrier", + "dwellTimeMsUntilDeviceAck": 200, + } + store_delivery_log(context.message, subscriber, success=True, delivery=delivery) + + def prepare_message( + self, + message_context: SnsMessage, + subscriber: SnsSubscription, + topic_attributes: dict[str, str] = None, + ) -> dict: + return { + "PhoneNumber": subscriber["Endpoint"], + "TopicArn": subscriber["TopicArn"], + "SubscriptionArn": subscriber["SubscriptionArn"], + "MessageId": message_context.message_id, + "Message": message_context.message_content(protocol=subscriber["Protocol"]), + "MessageAttributes": message_context.message_attributes, + "MessageStructure": message_context.message_structure, + "Subject": message_context.subject, + } + + +class FirehoseTopicPublisher(TopicPublisher): + """ + The Firehose publisher is responsible for publishing the SNS message to a subscribed Firehose delivery stream. + This allows you to "fan out Amazon SNS notifications to Amazon Simple Storage Service (Amazon S3), Amazon Redshift, + Amazon OpenSearch Service (OpenSearch Service), and to third-party service providers." + See https://docs.aws.amazon.com/sns/latest/dg/sns-firehose-as-subscriber.html + """ + + def _publish(self, context: SnsPublishContext, subscriber: SnsSubscription): + message_body = self.prepare_message( + context.message, subscriber, topic_attributes=context.topic_attributes + ) + try: + region = extract_region_from_arn(subscriber["Endpoint"]) + if role_arn := subscriber.get("SubscriptionRoleArn"): + factory = connect_to.with_assumed_role( + role_arn=role_arn, service_principal=ServicePrincipal.sns, region_name=region + ) + else: + account_id = extract_account_id_from_arn(subscriber["Endpoint"]) + factory = connect_to(aws_access_key_id=account_id, region_name=region) + firehose_client = factory.firehose.request_metadata( + source_arn=subscriber["TopicArn"], service_principal=ServicePrincipal.sns + ) + endpoint = subscriber["Endpoint"] + if endpoint: + delivery_stream = extract_resource_from_arn(endpoint).split("/")[1] + firehose_client.put_record( + DeliveryStreamName=delivery_stream, Record={"Data": to_bytes(message_body)} + ) + store_delivery_log( + context.message, + subscriber, + success=True, + topic_attributes=context.topic_attributes, + ) + except Exception as exc: + LOG.info( + "Received error on sending SNS message, putting to DLQ (if configured): %s", exc + ) + # TODO: check delivery log + # TODO check DLQ? + + +class SmsPhoneNumberPublisher(EndpointPublisher): + """ + The SMS publisher is responsible for publishing the SNS message directly to a phone number. + This is not directly implemented yet in LocalStack, we only save the message. + """ + + def _publish(self, context: SnsPublishContext, endpoint: str): + event = self.prepare_message(context.message, endpoint) + context.store.sms_messages.append(event) + LOG.info( + "Delivering SMS message to %s: %s", + event["PhoneNumber"], + event["Message"], + ) + + # TODO: check about delivery logs for individual call, need a real AWS test + # hard to know the format + + def prepare_message(self, message_context: SnsMessage, endpoint: str) -> dict: + return { + "PhoneNumber": endpoint, + "TopicArn": None, + "SubscriptionArn": None, + "MessageId": message_context.message_id, + "Message": message_context.message_content(protocol="sms"), + "MessageAttributes": message_context.message_attributes, + "MessageStructure": message_context.message_structure, + "Subject": message_context.subject, + } + + +class ApplicationEndpointPublisher(EndpointPublisher): + """ + The application publisher is responsible for publishing the SNS message directly to a registered SNS application + endpoint, without it being subscribed to a topic. + See `ApplicationTopicPublisher` for more information about Application Endpoint publishing. + """ + + def _publish(self, context: SnsPublishContext, endpoint: str): + message = self.prepare_message(context.message, endpoint) + cache = context.store.platform_endpoint_messages.setdefault(endpoint, []) + cache.append(message) + + if ( + config.LEGACY_SNS_GCM_PUBLISHING + and get_platform_type_from_endpoint_arn(endpoint) == "GCM" + ): + self._legacy_publish_to_gcm(context, endpoint) + + # TODO: rewrite the platform application publishing logic + # will need to validate credentials when creating platform app earlier, need thorough testing + + # TODO: see about delivery log for individual endpoint message, need credentials for testing + # store_delivery_log(subscriber, context, success=True) + + def prepare_message(self, message_context: SnsMessage, endpoint: str) -> Union[str, Dict]: + platform_type = get_platform_type_from_endpoint_arn(endpoint) + return { + "TargetArn": endpoint, + "TopicArn": "", + "SubscriptionArn": "", + "Message": message_context.message_content(protocol=platform_type), + "MessageAttributes": message_context.message_attributes, + "MessageStructure": message_context.message_structure, + "Subject": message_context.subject, + "MessageId": message_context.message_id, + } + + @staticmethod + def _legacy_publish_to_gcm(context: SnsPublishContext, endpoint: str): + application_attributes, endpoint_attributes = get_attributes_for_application_endpoint( + endpoint + ) + send_message_to_gcm( + context=context, + app_attributes=application_attributes, + endpoint_attributes=endpoint_attributes, + ) + + +def get_platform_type_from_endpoint_arn(endpoint_arn: str) -> SnsApplicationPlatforms: + return endpoint_arn.rsplit("/", maxsplit=3)[1] # noqa + + +def get_application_platform_arn_from_endpoint_arn(endpoint_arn: str) -> str: + """ + Retrieve the application_platform information from the endpoint_arn to build the application platform ARN + The format of the endpoint is: + `arn:aws:sns:{region}:{account_id}:endpoint/{platform_type}/{application_name}/{endpoint_id}` + :param endpoint_arn: str + :return: application_platform_arn: str + """ + parsed_arn = parse_arn(endpoint_arn) + + _, platform_type, app_name, _ = parsed_arn["resource"].split("/") + base_arn = f"arn:aws:sns:{parsed_arn['region']}:{parsed_arn['account']}" + return f"{base_arn}:app/{platform_type}/{app_name}" + + +def get_attributes_for_application_endpoint(endpoint_arn: str) -> Tuple[Dict, Dict]: + """ + Retrieve the attributes necessary to send a message directly to the platform (credentials and token) + :param endpoint_arn: + :return: + """ + account_id = extract_account_id_from_arn(endpoint_arn) + region_name = extract_region_from_arn(endpoint_arn) + + sns_client = connect_to(aws_access_key_id=account_id, region_name=region_name).sns + + # TODO: we should access this from the moto store directly + endpoint_attributes = sns_client.get_endpoint_attributes(EndpointArn=endpoint_arn) + + app_platform_arn = get_application_platform_arn_from_endpoint_arn(endpoint_arn) + app = sns_client.get_platform_application_attributes(PlatformApplicationArn=app_platform_arn) + + return app.get("Attributes", {}), endpoint_attributes.get("Attributes", {}) + + +def send_message_to_gcm( + context: SnsPublishContext, app_attributes: Dict[str, str], endpoint_attributes: Dict[str, str] +) -> None: + """ + Send the message directly to GCM, with the credentials used when creating the PlatformApplication and the Endpoint + :param context: SnsPublishContext + :param app_attributes: ApplicationPlatform attributes, contains PlatformCredential for GCM + :param endpoint_attributes: Endpoint attributes, contains Token that represent the mobile endpoint + :return: + """ + server_key = app_attributes.get("PlatformCredential", "") + token = endpoint_attributes.get("Token", "") + # message is supposed to be a JSON string to GCM + json_message = context.message.message_content("GCM") + data = json.loads(json_message) + + data["to"] = token + headers = {"Authorization": f"key={server_key}", "Content-type": "application/json"} + + response = requests.post( + sns_constants.GCM_URL, + headers=headers, + data=json.dumps(data), + ) + if response.status_code != 200: + LOG.warning( + "Platform GCM returned response %s with content %s", + response.status_code, + response.content, + ) + + +def compute_canonical_string(message: dict, notification_type: str) -> str: + """ + The notification message signature is computed using the SHA1withRSA algorithm on a "canonical string" – a UTF-8 + string which observes certain conventions including the sort order of included fields. (Please note that any + deviation in the construction of the message string described below such as excluding a field, including an extra + space or changing sort order will result in a different validation signature which will not match the pre-computed + message signature.) + See https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html + """ + # create the canonical string + if notification_type == SnsMessageType.Notification: + fields = ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"] + elif notification_type in ( + SnsMessageType.SubscriptionConfirmation, + SnsMessageType.UnsubscribeConfirmation, + ): + fields = ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"] + else: + return "" + + # create the canonical string + string_to_sign = "".join([f"{f}\n{message[f]}\n" for f in fields if f in message]) + return string_to_sign + + +def get_message_signature(canonical_string: str, signature_version: str) -> str: + chosen_hash = hashes.SHA256() if signature_version == "2" else hashes.SHA1() + message_signature = SNS_SERVER_PRIVATE_KEY.sign( + to_bytes(canonical_string), + padding=padding.PKCS1v15(), + algorithm=chosen_hash, + ) + # base64 encode the signature + encoded_signature = base64.b64encode(message_signature) + return to_str(encoded_signature) + + +def create_sns_message_body( + message_context: SnsMessage, + subscriber: SnsSubscription, + topic_attributes: dict[str, str] = None, +) -> str: + message_type = message_context.type or "Notification" + protocol = subscriber["Protocol"] + message_content = message_context.message_content(protocol) + + if message_type == "Notification" and is_raw_message_delivery(subscriber): + return message_content + + external_url = get_cert_base_url() + + data = { + "Type": message_type, + "MessageId": message_context.message_id, + "TopicArn": subscriber["TopicArn"], + "Message": message_content, + "Timestamp": timestamp_millis(), + } + + if message_type == SnsMessageType.Notification: + unsubscribe_url = create_unsubscribe_url(external_url, subscriber["SubscriptionArn"]) + data["UnsubscribeURL"] = unsubscribe_url + + elif message_type in ( + SnsMessageType.SubscriptionConfirmation, + SnsMessageType.UnsubscribeConfirmation, + ): + data["Token"] = message_context.token + data["SubscribeURL"] = create_subscribe_url( + external_url, subscriber["TopicArn"], message_context.token + ) + + if message_context.subject: + data["Subject"] = message_context.subject + + if message_context.message_attributes: + data["MessageAttributes"] = prepare_message_attributes(message_context.message_attributes) + + # FIFO topics do not add the signature in the message + if not subscriber.get("TopicArn", "").endswith(".fifo"): + signature_version = ( + topic_attributes.get("signature_version", "1") if topic_attributes else "1" + ) + canonical_string = compute_canonical_string(data, message_type) + signature = get_message_signature(canonical_string, signature_version=signature_version) + data.update( + { + "SignatureVersion": signature_version, + "Signature": signature, + "SigningCertURL": f"{external_url}{sns_constants.SNS_CERT_ENDPOINT}", + } + ) + else: + data["SequenceNumber"] = message_context.sequencer_number + + return json.dumps(data) + + +def prepare_message_attributes( + message_attributes: MessageAttributeMap, +) -> Dict[str, Dict[str, str]]: + attributes = {} + if not message_attributes: + return attributes + # TODO: Number type is not supported for Lambda subscriptions, passed as String + # do conversion here + for attr_name, attr in message_attributes.items(): + data_type = attr["DataType"] + if data_type.startswith("Binary"): + # binary payload in base64 encoded by AWS, UTF-8 for JSON + # https://docs.aws.amazon.com/sns/latest/api/API_MessageAttributeValue.html + val = base64.b64encode(attr["BinaryValue"]).decode() + else: + val = attr.get("StringValue") + + attributes[attr_name] = { + "Type": data_type, + "Value": val, + } + return attributes + + +def is_raw_message_delivery(subscriber: SnsSubscription) -> bool: + return subscriber.get("RawMessageDelivery") in ("true", True, "True") + + +def is_fifo_topic(subscriber: SnsSubscription) -> bool: + return subscriber.get("TopicArn", "").endswith(".fifo") + + +def store_delivery_log( + message_context: SnsMessage, + subscriber: SnsSubscription, + success: bool, + topic_attributes: dict[str, str] = None, + delivery: dict = None, +): + """ + Store the delivery logs in CloudWatch, configured as TopicAttributes + See: https://docs.aws.amazon.com/sns/latest/dg/sns-topic-attributes.html#msg-status-sdk + + TODO: for Application, you can also configure Platform attributes: + See:https://docs.aws.amazon.com/sns/latest/dg/sns-msg-status.html + """ + # TODO: effectively use `SuccessFeedbackSampleRate` to sample delivery logs + # TODO: validate format of `delivery` for each Publisher + # map Protocol to TopicAttribute + available_delivery_logs_services = { + "http", + "https", + "firehose", + "lambda", + "application", + "sqs", + } + # SMS is a special case: https://docs.aws.amazon.com/sns/latest/dg/sms_stats_cloudwatch.html + # seems like you need to configure on the Console, leave it on by default now in LocalStack + protocol = subscriber.get("Protocol") + + if protocol != "sms": + if protocol not in available_delivery_logs_services or not topic_attributes: + # this service does not have DeliveryLogs feature, return + return + + # TODO: for now, those attributes are stored as attributes of the moto Topic model in snake case + # see to work this in our store instead + role_type = "success" if success else "failure" + topic_attribute = f"{protocol}_{role_type}_feedback_role_arn" + + # check if topic has the right attribute and a role, otherwise return + # TODO: on purpose not using walrus operator to show that we get the RoleArn here for CloudWatch + role_arn = topic_attributes.get(topic_attribute) + if not role_arn: + return + + if not is_api_enabled("logs"): + LOG.warning( + "Service 'logs' is not enabled: skip storing SNS delivery logs. " + "Please check your 'SERVICES' configuration variable." + ) + return + + log_group_name = subscriber.get("TopicArn", "") + for partition in PARTITION_NAMES: + log_group_name = log_group_name.replace(f"arn:{partition}:", "") + log_group_name = log_group_name.replace(":", "/") + log_stream_name = long_uid() + invocation_time = int(time.time() * 1000) + + delivery = not_none_or(delivery, {}) + delivery["deliveryId"] = long_uid() + delivery["destination"] = subscriber.get("Endpoint", "") + delivery["dwellTimeMs"] = 200 + if not success: + delivery["attemps"] = 1 + + if (protocol := subscriber["Protocol"]) == "application": + protocol = get_platform_type_from_endpoint_arn(subscriber["Endpoint"]) + + message = message_context.message_content(protocol) + delivery_log = { + "notification": { + "messageMD5Sum": md5(message), + "messageId": message_context.message_id, + "topicArn": subscriber.get("TopicArn"), + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f%z"), + }, + "delivery": delivery, + "status": "SUCCESS" if success else "FAILURE", + } + + log_output = json.dumps(delivery_log) + + # TODO: use the account/region from the role in the TopicAttribute instead, this is what AWS uses + account_id = extract_account_id_from_arn(subscriber["TopicArn"]) + region_name = extract_region_from_arn(subscriber["TopicArn"]) + logs_client = connect_to(aws_access_key_id=account_id, region_name=region_name).logs + + return store_cloudwatch_logs( + logs_client, log_group_name, log_stream_name, log_output, invocation_time + ) + + +def get_cert_base_url() -> str: + if config.SNS_CERT_URL_HOST: + return f"https://{config.SNS_CERT_URL_HOST}" + + return external_service_url().rstrip("/") + + +def create_subscribe_url(external_url, topic_arn, subscription_token): + return f"{external_url}/?Action=ConfirmSubscription&TopicArn={topic_arn}&Token={subscription_token}" + + +def create_unsubscribe_url(external_url, subscription_arn): + return f"{external_url}/?Action=Unsubscribe&SubscriptionArn={subscription_arn}" + + +class PublishDispatcher: + """ + The PublishDispatcher is responsible for dispatching the publishing of SNS messages asynchronously to worker + threads via a `ThreadPoolExecutor`, depending on the SNS subscriber protocol and filter policy. + """ + + topic_notifiers = { + "http": HttpTopicPublisher(), + "https": HttpTopicPublisher(), + "email": EmailTopicPublisher(), + "email-json": EmailJsonTopicPublisher(), + "sms": SmsTopicPublisher(), + "sqs": SqsTopicPublisher(), + "application": ApplicationTopicPublisher(), + "lambda": LambdaTopicPublisher(), + "firehose": FirehoseTopicPublisher(), + } + batch_topic_notifiers = {"sqs": SqsBatchTopicPublisher()} + sms_notifier = SmsPhoneNumberPublisher() + application_notifier = ApplicationEndpointPublisher() + + subscription_filter = SubscriptionFilter() + + def __init__(self, num_thread: int = 10): + self.executor = ThreadPoolExecutor(num_thread, thread_name_prefix="sns_pub") + self.topic_partitioned_executor = TopicPartitionedThreadPoolExecutor( + max_workers=num_thread, thread_name_prefix="sns_pub_fifo" + ) + + def shutdown(self): + self.executor.shutdown(wait=False) + self.topic_partitioned_executor.shutdown(wait=False) + + def _should_publish( + self, + subscription_filter_policy: dict[str, dict], + message_ctx: SnsMessage, + subscriber: SnsSubscription, + ): + """ + Validate that the message should be relayed to the subscriber, depending on the filter policy and the + subscription status + """ + # FIXME: for now, send to email even if not confirmed, as we do not send the token to confirm to email + # subscriptions + if ( + not subscriber["PendingConfirmation"] == "false" + and "email" not in subscriber["Protocol"] + ): + return + + subscriber_arn = subscriber["SubscriptionArn"] + filter_policy = subscription_filter_policy.get(subscriber_arn) + if not filter_policy: + return True + # default value is `MessageAttributes` + match subscriber.get("FilterPolicyScope", "MessageAttributes"): + case "MessageAttributes": + return self.subscription_filter.check_filter_policy_on_message_attributes( + filter_policy=filter_policy, message_attributes=message_ctx.message_attributes + ) + case "MessageBody": + return self.subscription_filter.check_filter_policy_on_message_body( + filter_policy=filter_policy, + message_body=message_ctx.message_content(subscriber["Protocol"]), + ) + + def publish_to_topic(self, ctx: SnsPublishContext, topic_arn: str) -> None: + subscriptions = ctx.store.get_topic_subscriptions(topic_arn) + for subscriber in subscriptions: + if self._should_publish(ctx.store.subscription_filter_policy, ctx.message, subscriber): + notifier = self.topic_notifiers[subscriber["Protocol"]] + LOG.debug( + "Topic '%s' publishing '%s' to subscribed '%s' with protocol '%s' (subscription '%s')", + topic_arn, + ctx.message.message_id, + subscriber.get("Endpoint"), + subscriber["Protocol"], + subscriber["SubscriptionArn"], + ) + self._submit_notification(notifier, ctx, subscriber) + + def publish_batch_to_topic(self, ctx: SnsBatchPublishContext, topic_arn: str) -> None: + subscriptions = ctx.store.get_topic_subscriptions(topic_arn) + for subscriber in subscriptions: + protocol = subscriber["Protocol"] + notifier = self.batch_topic_notifiers.get(protocol) + # does the notifier supports batching natively? for now, only SQS supports it + if notifier: + subscriber_ctx = ctx + messages_amount_before_filtering = len(ctx.messages) + filtered_messages = [ + message + for message in ctx.messages + if self._should_publish( + ctx.store.subscription_filter_policy, message, subscriber + ) + ] + if not filtered_messages: + LOG.debug( + "No messages match filter policy, not publishing batch from topic '%s' to subscription '%s'", + topic_arn, + subscriber["SubscriptionArn"], + ) + continue + + messages_amount = len(filtered_messages) + if messages_amount != messages_amount_before_filtering: + LOG.debug( + "After applying subscription filter, %s out of %s message(s) to be sent to '%s'", + messages_amount, + messages_amount_before_filtering, + subscriber["SubscriptionArn"], + ) + # We need to copy the context to not overwrite the messages after filtering messages, otherwise we + # would filter on the same context for different subscribers + subscriber_ctx = copy.copy(ctx) + subscriber_ctx.messages = filtered_messages + + LOG.debug( + "Topic '%s' batch publishing %s messages to subscribed '%s' with protocol '%s' (subscription '%s')", + topic_arn, + messages_amount, + subscriber.get("Endpoint"), + subscriber["Protocol"], + subscriber["SubscriptionArn"], + ) + self._submit_notification(notifier, subscriber_ctx, subscriber) + else: + # if no batch support, fall back to sending them sequentially + notifier = self.topic_notifiers[subscriber["Protocol"]] + for message in ctx.messages: + if self._should_publish( + ctx.store.subscription_filter_policy, message, subscriber + ): + individual_ctx = SnsPublishContext( + message=message, store=ctx.store, request_headers=ctx.request_headers + ) + LOG.debug( + "Topic '%s' batch publishing '%s' to subscribed '%s' with protocol '%s' (subscription '%s')", + topic_arn, + individual_ctx.message.message_id, + subscriber.get("Endpoint"), + subscriber["Protocol"], + subscriber["SubscriptionArn"], + ) + self._submit_notification(notifier, individual_ctx, subscriber) + + def _submit_notification( + self, notifier, ctx: SnsPublishContext | SnsBatchPublishContext, subscriber: SnsSubscription + ): + if (topic_arn := subscriber.get("TopicArn", "")).endswith(".fifo"): + # TODO: we still need to implement Message deduplication on the topic level with `should_publish` for FIFO + self.topic_partitioned_executor.submit( + notifier.publish, topic_arn, context=ctx, subscriber=subscriber + ) + else: + self.executor.submit(notifier.publish, context=ctx, subscriber=subscriber) + + def publish_to_phone_number(self, ctx: SnsPublishContext, phone_number: str) -> None: + LOG.debug( + "Publishing '%s' to phone number '%s' with protocol 'sms'", + ctx.message.message_id, + phone_number, + ) + self.executor.submit(self.sms_notifier.publish, context=ctx, endpoint=phone_number) + + def publish_to_application_endpoint(self, ctx: SnsPublishContext, endpoint_arn: str) -> None: + LOG.debug( + "Publishing '%s' to application endpoint '%s'", + ctx.message.message_id, + endpoint_arn, + ) + self.executor.submit(self.application_notifier.publish, context=ctx, endpoint=endpoint_arn) + + def publish_to_topic_subscriber( + self, ctx: SnsPublishContext, topic_arn: str, subscription_arn: str + ) -> None: + """ + This allows us to publish specific HTTP(S) messages specific to those endpoints, namely + `SubscriptionConfirmation` and `UnsubscribeConfirmation`. Those are "topic" messages in shape, but are sent + only to the endpoint subscribing or unsubscribing. + This is only used internally. + Note: might be needed for multi account SQS and Lambda `SubscriptionConfirmation` + :param ctx: SnsPublishContext + :param topic_arn: the topic of the subscriber + :param subscription_arn: the ARN of the subscriber + :return: None + """ + subscriber = ctx.store.subscriptions.get(subscription_arn) + if not subscriber: + return + notifier = self.topic_notifiers[subscriber["Protocol"]] + LOG.debug( + "Topic '%s' publishing '%s' to subscribed '%s' with protocol '%s' (Id='%s', Subscription='%s')", + topic_arn, + ctx.message.type, + subscription_arn, + subscriber["Protocol"], + ctx.message.message_id, + subscriber.get("Endpoint"), + ) + self.executor.submit(notifier.publish, context=ctx, subscriber=subscriber) diff --git a/localstack-core/localstack/services/sns/resource_providers/__init__.py b/localstack-core/localstack/services/sns/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py new file mode 100644 index 0000000000000..650df889dff02 --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py @@ -0,0 +1,178 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack import config +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.services.cloudformation.resource_provider import ( + ConvertingInternalClientFactory, + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SNSSubscriptionProperties(TypedDict): + Protocol: Optional[str] + TopicArn: Optional[str] + DeliveryPolicy: Optional[dict] + Endpoint: Optional[str] + FilterPolicy: Optional[dict] + FilterPolicyScope: Optional[str] + Id: Optional[str] + RawMessageDelivery: Optional[bool] + RedrivePolicy: Optional[dict] + Region: Optional[str] + ReplayPolicy: Optional[dict] + SubscriptionRoleArn: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SNSSubscriptionProvider(ResourceProvider[SNSSubscriptionProperties]): + TYPE = "AWS::SNS::Subscription" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SNSSubscriptionProperties], + ) -> ProgressEvent[SNSSubscriptionProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - TopicArn + - Protocol + + Create-only properties: + - /properties/Endpoint + - /properties/Protocol + - /properties/TopicArn + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + sns = self._get_client(request).sns + + params = util.select_attributes(model=model, params=["TopicArn", "Protocol", "Endpoint"]) + + attrs = [ + "DeliveryPolicy", + "FilterPolicy", + "FilterPolicyScope", + "RawMessageDelivery", + "RedrivePolicy", + ] + attributes = {a: self.attr_val(model[a]) for a in attrs if a in model} + + if attributes: + params["Attributes"] = attributes + + result = sns.subscribe(**params) + model["Id"] = result["SubscriptionArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SNSSubscriptionProperties], + ) -> ProgressEvent[SNSSubscriptionProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SNSSubscriptionProperties], + ) -> ProgressEvent[SNSSubscriptionProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + sns = request.aws_client_factory.sns + + sns.unsubscribe(SubscriptionArn=model["Id"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SNSSubscriptionProperties], + ) -> ProgressEvent[SNSSubscriptionProperties]: + """ + Update a resource + + """ + model = request.desired_state + model["Id"] = request.previous_state["Id"] + sns = self._get_client(request).sns + + attrs = [ + "DeliveryPolicy", + "FilterPolicy", + "FilterPolicyScope", + "RawMessageDelivery", + "RedrivePolicy", + ] + for a in attrs: + if a in model: + sns.set_subscription_attributes( + SubscriptionArn=model["Id"], + AttributeName=a, + AttributeValue=self.attr_val(model[a]), + ) + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + @staticmethod + def attr_val(val): + return json.dumps(val) if isinstance(val, dict) else str(val) + + @staticmethod + def _get_client( + request: ResourceRequest[SNSSubscriptionProperties], + ) -> ServiceLevelClientFactory: + model = request.desired_state + if subscription_region := model.get("Region"): + # FIXME: this is hacky, maybe we should have access to the original parameters for the `aws_client_factory` + # as we now need to manually use them + # Not all internal CloudFormation requests will be directed to the same region and account + # maybe we could need to expose a proper client factory where we can override some parameters like the + # Region + factory = ConvertingInternalClientFactory(use_ssl=config.DISTRIBUTED_MODE) + client_params = dict(request.aws_client_factory._client_creation_params) + client_params["region_name"] = subscription_region + service_factory = factory(**client_params) + else: + service_factory = request.aws_client_factory + + return service_factory diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.schema.json b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.schema.json new file mode 100644 index 0000000000000..e4c5a9e883b5b --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.schema.json @@ -0,0 +1,58 @@ +{ + "typeName": "AWS::SNS::Subscription", + "description": "Resource Type definition for AWS::SNS::Subscription", + "additionalProperties": false, + "properties": { + "ReplayPolicy": { + "type": "object" + }, + "RawMessageDelivery": { + "type": "boolean" + }, + "Endpoint": { + "type": "string" + }, + "FilterPolicy": { + "type": "object" + }, + "TopicArn": { + "type": "string" + }, + "RedrivePolicy": { + "type": "object" + }, + "DeliveryPolicy": { + "type": "object" + }, + "Region": { + "type": "string" + }, + "SubscriptionRoleArn": { + "type": "string" + }, + "FilterPolicyScope": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "Protocol": { + "type": "string" + } + }, + "required": [ + "TopicArn", + "Protocol" + ], + "createOnlyProperties": [ + "/properties/Endpoint", + "/properties/Protocol", + "/properties/TopicArn" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription_plugin.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription_plugin.py new file mode 100644 index 0000000000000..01e23a1f30aed --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SNSSubscriptionProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SNS::Subscription" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.sns.resource_providers.aws_sns_subscription import ( + SNSSubscriptionProvider, + ) + + self.factory = SNSSubscriptionProvider diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic.py new file mode 100644 index 0000000000000..7bc6720fd63f5 --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic.py @@ -0,0 +1,321 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.strings import canonicalize_bool_to_str, short_uid + + +class SNSTopicProperties(TypedDict): + ContentBasedDeduplication: Optional[bool] + DataProtectionPolicy: Optional[dict] + DisplayName: Optional[str] + FifoTopic: Optional[bool] + KmsMasterKeyId: Optional[str] + SignatureVersion: Optional[str] + Subscription: Optional[list[Subscription]] + Tags: Optional[list[Tag]] + TopicArn: Optional[str] + TopicName: Optional[str] + TracingConfig: Optional[str] + + +class Subscription(TypedDict): + Endpoint: Optional[str] + Protocol: Optional[str] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SNSTopicProvider(ResourceProvider[SNSTopicProperties]): + TYPE = "AWS::SNS::Topic" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SNSTopicProperties], + ) -> ProgressEvent[SNSTopicProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/TopicArn + + + + Create-only properties: + - /properties/TopicName + - /properties/FifoTopic + + Read-only properties: + - /properties/TopicArn + + IAM permissions required: + - sns:CreateTopic + - sns:TagResource + - sns:Subscribe + - sns:GetTopicAttributes + - sns:PutDataProtectionPolicy + + """ + model = request.desired_state + sns = request.aws_client_factory.sns + + attributes = { + k: v + for k, v in model.items() + if v is not None + if k not in ["TopicName", "Subscription", "Tags"] + } + if (fifo_topic := attributes.get("FifoTopic")) is not None: + attributes["FifoTopic"] = canonicalize_bool_to_str(fifo_topic) + + if archive_policy := attributes.get("ArchivePolicy"): + archive_policy["MessageRetentionPeriod"] = str(archive_policy["MessageRetentionPeriod"]) + attributes["ArchivePolicy"] = json.dumps(archive_policy) + + if (content_based_dedup := attributes.get("ContentBasedDeduplication")) is not None: + attributes["ContentBasedDeduplication"] = canonicalize_bool_to_str(content_based_dedup) + + # Default name + if model.get("TopicName") is None: + model["TopicName"] = ( + f"topic-{short_uid()}.fifo" if fifo_topic else f"topic-{short_uid()}" + ) + + create_sns_response = sns.create_topic(Name=model["TopicName"], Attributes=attributes) + model["TopicArn"] = create_sns_response["TopicArn"] + + # now we add subscriptions if they exists + for subscription in model.get("Subscription", []): + sns.subscribe( + TopicArn=model["TopicArn"], + Protocol=subscription["Protocol"], + Endpoint=subscription["Endpoint"], + ) + if tags := model.get("Tags"): + sns.tag_resource(ResourceArn=model["TopicArn"], Tags=tags) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SNSTopicProperties], + ) -> ProgressEvent[SNSTopicProperties]: + """ + Fetch resource information + + IAM permissions required: + - sns:GetTopicAttributes + - sns:ListTagsForResource + - sns:ListSubscriptionsByTopic + - sns:GetDataProtectionPolicy + """ + model = request.desired_state + topic_arn = model["TopicArn"] + + describe_res = request.aws_client_factory.sns.get_topic_attributes(TopicArn=topic_arn)[ + "Attributes" + ] + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=describe_res) + + def delete( + self, + request: ResourceRequest[SNSTopicProperties], + ) -> ProgressEvent[SNSTopicProperties]: + """ + Delete a resource + + IAM permissions required: + - sns:DeleteTopic + """ + # FIXME: This appears to incorrectly assume TopicArn would be provided. + model = request.desired_state + sns = request.aws_client_factory.sns + sns.delete_topic(TopicArn=model["TopicArn"]) + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model={}) + + def update( + self, + request: ResourceRequest[SNSTopicProperties], + ) -> ProgressEvent[SNSTopicProperties]: + """ + Update a resource + + IAM permissions required: + - sns:SetTopicAttributes + - sns:TagResource + - sns:UntagResource + - sns:Subscribe + - sns:Unsubscribe + - sns:GetTopicAttributes + - sns:ListTagsForResource + - sns:ListSubscriptionsByTopic + - sns:GetDataProtectionPolicy + - sns:PutDataProtectionPolicy + - sns:CreateTopic (Not in the original spec) + - sns:DeleteTopic (Not in the original spec) + """ + desired_state = request.desired_state + previous_state = request.previous_state + sns = request.aws_client_factory.sns + + current_topic_arn = previous_state.get("TopicArn") + if not current_topic_arn: + raise ValueError("TopicArn not found in previous_state") + + # Check if TopicName has changed (requires recreation) + desired_topic_name = desired_state.get("TopicName") + previous_topic_name = previous_state.get("TopicName") + + if not previous_topic_name: + raise ValueError("Previous topic name is not present.") + + if desired_topic_name != previous_topic_name: + # TopicName changed - need to create new topic and delete old one + + # First, get current subscriptions and tags to preserve them + try: + current_subscriptions = sns.list_subscriptions_by_topic( + TopicArn=current_topic_arn + ).get("Subscriptions", []) + current_tags_response = sns.list_tags_for_resource(ResourceArn=current_topic_arn) + current_tags = current_tags_response.get("Tags", []) + except Exception: + # If we can't get current state, proceed without preserving subscriptions/tags + current_subscriptions = [] + current_tags = [] + + create_result = self.create(request) + if create_result.status != OperationStatus.SUCCESS: + return create_result + + new_topic_arn = create_result.resource_model["TopicArn"] + + # Preserve existing subscriptions on new topic + for subscription in current_subscriptions: + if subscription.get("Protocol") and subscription.get("Endpoint"): + try: + sns.subscribe( + TopicArn=new_topic_arn, + Protocol=subscription["Protocol"], + Endpoint=subscription["Endpoint"], + ) + except Exception: + # Continue if subscription fails + pass + + # Preserve existing tags if not overridden by new tags + if current_tags: + new_tags = desired_state.get("Tags", []) + new_tag_keys = {tag["Key"] for tag in new_tags} + + # Add current tags that aren't being overridden + tags_to_preserve = [tag for tag in current_tags if tag["Key"] not in new_tag_keys] + if tags_to_preserve: + try: + sns.tag_resource(ResourceArn=new_topic_arn, Tags=tags_to_preserve) + except Exception: + # Continue if tagging fails + pass + + # Delete old topic + try: + delete_request = ResourceRequest( + _original_payload=previous_state, + aws_client_factory=request.aws_client_factory, + request_token=request.request_token, + stack_name=request.stack_name, + stack_id=request.stack_id, + account_id=request.account_id, + region_name=request.region_name, + desired_state=request.previous_state, + logical_resource_id=request.logical_resource_id, + resource_type=request.logical_resource_id, + logger=request.logger, + custom_context=request.custom_context, + action=request.action, + ) + self.delete(delete_request) + except Exception: + # Continue even if delete fails - new topic is created + pass + + desired_state["TopicArn"] = new_topic_arn + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=desired_state, + custom_context=request.custom_context, + ) + + # Normal update path - TopicName hasn't changed + desired_state["TopicArn"] = current_topic_arn + + if desired_state.get("DisplayName") != previous_state.get("DisplayName"): + display_name = desired_state.get("DisplayName") + if display_name is not None: + sns.set_topic_attributes( + TopicArn=current_topic_arn, + AttributeName="DisplayName", + AttributeValue=display_name, + ) + + desired_tags = desired_state.get("Tags", []) + previous_tags = previous_state.get("Tags", []) + + desired_tags_dict = {tag["Key"]: tag["Value"] for tag in desired_tags} + previous_tags_dict = {tag["Key"]: tag["Value"] for tag in previous_tags} + + tags_to_add = [] + for key, value in desired_tags_dict.items(): + if key not in previous_tags_dict or previous_tags_dict[key] != value: + tags_to_add.append({"Key": key, "Value": value}) + + tags_to_remove = [] + for key in previous_tags_dict: + if key not in desired_tags_dict: + tags_to_remove.append(key) + + if tags_to_add: + sns.tag_resource(ResourceArn=current_topic_arn, Tags=tags_to_add) + + if tags_to_remove: + sns.untag_resource(ResourceArn=current_topic_arn, TagKeys=tags_to_remove) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=desired_state, + custom_context=request.custom_context, + ) + + def list( + self, + request: ResourceRequest[SNSTopicProperties], + ) -> ProgressEvent[SNSTopicProperties]: + resources = request.aws_client_factory.sns.list_topics() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + SNSTopicProperties(TopicArn=topic["TopicArn"]) for topic in resources["Topics"] + ], + ) diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic.schema.json b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic.schema.json new file mode 100644 index 0000000000000..222634c5503a3 --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic.schema.json @@ -0,0 +1,156 @@ +{ + "typeName": "AWS::SNS::Topic", + "description": "Resource Type definition for AWS::SNS::Topic", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-sns", + "additionalProperties": false, + "properties": { + "DisplayName": { + "description": "The display name to use for an Amazon SNS topic with SMS subscriptions.", + "type": "string" + }, + "KmsMasterKeyId": { + "description": "The ID of an AWS-managed customer master key (CMK) for Amazon SNS or a custom CMK. For more information, see Key Terms. For more examples, see KeyId in the AWS Key Management Service API Reference.\n\nThis property applies only to [server-side-encryption](https://docs.aws.amazon.com/sns/latest/dg/sns-server-side-encryption.html).", + "type": "string" + }, + "DataProtectionPolicy": { + "description": "The body of the policy document you want to use for this topic.\n\nYou can only add one policy per topic.\n\nThe policy must be in JSON string format.\n\nLength Constraints: Maximum length of 30720", + "type": "object" + }, + "Subscription": { + "description": "The SNS subscriptions (endpoints) for this topic.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Subscription" + } + }, + "FifoTopic": { + "description": "Set to true to create a FIFO topic.", + "type": "boolean" + }, + "ContentBasedDeduplication": { + "description": "Enables content-based deduplication for FIFO topics. By default, ContentBasedDeduplication is set to false. If you create a FIFO topic and this attribute is false, you must specify a value for the MessageDeduplicationId parameter for the Publish action.\n\nWhen you set ContentBasedDeduplication to true, Amazon SNS uses a SHA-256 hash to generate the MessageDeduplicationId using the body of the message (but not the attributes of the message).\n\n(Optional) To override the generated value, you can specify a value for the the MessageDeduplicationId parameter for the Publish action.\n\n", + "type": "boolean" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "TopicName": { + "description": "The name of the topic you want to create. Topic names must include only uppercase and lowercase ASCII letters, numbers, underscores, and hyphens, and must be between 1 and 256 characters long. FIFO topic names must end with .fifo.\n\nIf you don't specify a name, AWS CloudFormation generates a unique physical ID and uses that ID for the topic name. For more information, see Name Type.", + "type": "string" + }, + "TopicArn": { + "type": "string" + }, + "SignatureVersion": { + "description": "Version of the Amazon SNS signature used. If the SignatureVersion is 1, Signature is a Base64-encoded SHA1withRSA signature of the Message, MessageId, Type, Timestamp, and TopicArn values. If the SignatureVersion is 2, Signature is a Base64-encoded SHA256withRSA signature of the Message, MessageId, Type, Timestamp, and TopicArn values.", + "type": "string" + }, + "TracingConfig": { + "description": "Tracing mode of an Amazon SNS topic. By default TracingConfig is set to PassThrough, and the topic passes through the tracing header it receives from an SNS publisher to its subscriptions. If set to Active, SNS will vend X-Ray segment data to topic owner account if the sampled flag in the tracing header is true. Only supported on standard topics.", + "type": "string" + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "type": "string", + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, `_`, `.`, `/`, `=`, `+`, and `-`." + }, + "Value": { + "type": "string", + "description": "The value for the tag. You can specify a value that is 0 to 256 characters in length." + } + }, + "required": [ + "Value", + "Key" + ] + }, + "Subscription": { + "type": "object", + "additionalProperties": false, + "properties": { + "Endpoint": { + "type": "string" + }, + "Protocol": { + "type": "string" + } + }, + "required": [ + "Endpoint", + "Protocol" + ] + } + }, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "createOnlyProperties": [ + "/properties/TopicName", + "/properties/FifoTopic" + ], + "primaryIdentifier": [ + "/properties/TopicArn" + ], + "readOnlyProperties": [ + "/properties/TopicArn" + ], + "handlers": { + "create": { + "permissions": [ + "sns:CreateTopic", + "sns:TagResource", + "sns:Subscribe", + "sns:GetTopicAttributes", + "sns:PutDataProtectionPolicy" + ] + }, + "read": { + "permissions": [ + "sns:GetTopicAttributes", + "sns:ListTagsForResource", + "sns:ListSubscriptionsByTopic", + "sns:GetDataProtectionPolicy" + ] + }, + "update": { + "permissions": [ + "sns:SetTopicAttributes", + "sns:TagResource", + "sns:UntagResource", + "sns:Subscribe", + "sns:Unsubscribe", + "sns:GetTopicAttributes", + "sns:ListTagsForResource", + "sns:ListSubscriptionsByTopic", + "sns:GetDataProtectionPolicy", + "sns:PutDataProtectionPolicy" + ] + }, + "delete": { + "permissions": [ + "sns:DeleteTopic" + ] + }, + "list": { + "permissions": [ + "sns:ListTopics" + ] + } + } +} diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic_plugin.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic_plugin.py new file mode 100644 index 0000000000000..de6a26a9482c5 --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topic_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SNSTopicProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SNS::Topic" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.sns.resource_providers.aws_sns_topic import SNSTopicProvider + + self.factory = SNSTopicProvider diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy.py new file mode 100644 index 0000000000000..412a22a150a96 --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy.py @@ -0,0 +1,125 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +from botocore.exceptions import ClientError + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.services.sns.models import create_default_sns_topic_policy + + +class SNSTopicPolicyProperties(TypedDict): + PolicyDocument: Optional[dict | str] + Topics: Optional[list[str]] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SNSTopicPolicyProvider(ResourceProvider[SNSTopicPolicyProperties]): + TYPE = "AWS::SNS::TopicPolicy" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SNSTopicPolicyProperties], + ) -> ProgressEvent[SNSTopicPolicyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - PolicyDocument + - Topics + + Read-only properties: + - /properties/Id + + IAM permissions required: + - sns:SetTopicAttributes + + """ + model = request.desired_state + sns_client = request.aws_client_factory.sns + + policy = json.dumps(model["PolicyDocument"]) + for topic_arn in model["Topics"]: + sns_client.set_topic_attributes( + TopicArn=topic_arn, AttributeName="Policy", AttributeValue=policy + ) + + model["Id"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SNSTopicPolicyProperties], + ) -> ProgressEvent[SNSTopicPolicyProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SNSTopicPolicyProperties], + ) -> ProgressEvent[SNSTopicPolicyProperties]: + """ + Delete a resource + + IAM permissions required: + - sns:SetTopicAttributes + """ + model = request.desired_state + sns = request.aws_client_factory.sns + + for topic_arn in model["Topics"]: + try: + sns.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="Policy", + AttributeValue=json.dumps(create_default_sns_topic_policy(topic_arn)), + ) + + except ClientError as err: + if "NotFound" not in err.response["Error"]["Code"]: + raise + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SNSTopicPolicyProperties], + ) -> ProgressEvent[SNSTopicPolicyProperties]: + """ + Update a resource + + IAM permissions required: + - sns:SetTopicAttributes + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy.schema.json b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy.schema.json new file mode 100644 index 0000000000000..64cb3d845ce17 --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy.schema.json @@ -0,0 +1,61 @@ +{ + "typeName": "AWS::SNS::TopicPolicy", + "description": "Schema for AWS::SNS::TopicPolicy", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-sns.git", + "additionalProperties": false, + "properties": { + "Id": { + "description": "The provider-assigned unique ID for this managed resource.", + "type": "string" + }, + "PolicyDocument": { + "description": "A policy document that contains permissions to add to the specified SNS topics.", + "type": [ + "object", + "string" + ] + }, + "Topics": { + "description": "The Amazon Resource Names (ARN) of the topics to which you want to add the policy. You can use the [Ref](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-ref.html)` function to specify an [AWS::SNS::Topic](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-sns-topic.html) resource.", + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "type": "string" + } + } + }, + "tagging": { + "taggable": false, + "tagOnCreate": false, + "tagUpdatable": false, + "cloudFormationSystemTags": false + }, + "required": [ + "PolicyDocument", + "Topics" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ], + "handlers": { + "create": { + "permissions": [ + "sns:SetTopicAttributes" + ] + }, + "update": { + "permissions": [ + "sns:SetTopicAttributes" + ] + }, + "delete": { + "permissions": [ + "sns:SetTopicAttributes" + ] + } + } +} diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy_plugin.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy_plugin.py new file mode 100644 index 0000000000000..9fbe0afe2d7e4 --- /dev/null +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_topicpolicy_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SNSTopicPolicyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SNS::TopicPolicy" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.sns.resource_providers.aws_sns_topicpolicy import ( + SNSTopicPolicyProvider, + ) + + self.factory = SNSTopicPolicyProvider diff --git a/localstack-core/localstack/services/sqs/__init__.py b/localstack-core/localstack/services/sqs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/sqs/constants.py b/localstack-core/localstack/services/sqs/constants.py new file mode 100644 index 0000000000000..0cdc49b8eccdb --- /dev/null +++ b/localstack-core/localstack/services/sqs/constants.py @@ -0,0 +1,56 @@ +# Valid unicode values: #x9 | #xA | #xD | #x20 to #xD7FF | #xE000 to #xFFFD | #x10000 to #x10FFFF +# https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html +from localstack.aws.api.sqs import QueueAttributeName + +MSG_CONTENT_REGEX = "^[\u0009\u000a\u000d\u0020-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]*$" + +# https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-message-metadata.html +# While not documented, umlauts seem to be allowed +ATTR_NAME_CHAR_REGEX = "^[\u00c0-\u017fa-zA-Z0-9_.-]*$" +ATTR_NAME_PREFIX_SUFFIX_REGEX = r"^(?!(aws\.|amazon\.|\.)).*(??@[\\]^_`{|}~-]*$" + +DEDUPLICATION_INTERVAL_IN_SEC = 5 * 60 + +# When you delete a queue, you must wait at least 60 seconds before creating a queue with the same name. +# see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_DeleteQueue.html +RECENTLY_DELETED_TIMEOUT = 60 + +# the default maximum message size in SQS +DEFAULT_MAXIMUM_MESSAGE_SIZE = 262144 +INTERNAL_QUEUE_ATTRIBUTES = [ + # these attributes cannot be changed by set_queue_attributes and should + # therefore be ignored when comparing queue attributes for create_queue + # 'FifoQueue' is handled on a per_queue basis + QueueAttributeName.ApproximateNumberOfMessages, + QueueAttributeName.ApproximateNumberOfMessagesDelayed, + QueueAttributeName.ApproximateNumberOfMessagesNotVisible, + QueueAttributeName.CreatedTimestamp, + QueueAttributeName.LastModifiedTimestamp, + QueueAttributeName.QueueArn, +] + +INVALID_STANDARD_QUEUE_ATTRIBUTES = [ + QueueAttributeName.FifoQueue, + QueueAttributeName.ContentBasedDeduplication, + *INTERNAL_QUEUE_ATTRIBUTES, +] + +# URL regexes for various endpoint strategies +STANDARD_STRATEGY_URL_REGEX = r"sqs.(?P[a-z0-9-]{1,})\.[^:]+:\d{4,5}\/(?P\d{12})\/(?P[a-zA-Z0-9_-]+(.fifo)?)$" +DOMAIN_STRATEGY_URL_REGEX = r"((?P[a-z0-9-]{1,})\.)?queue\.[^:]+:\d{4,5}\/(?P\d{12})\/(?P[a-zA-Z0-9_-]+(.fifo)?)$" +PATH_STRATEGY_URL_REGEX = r"[^:]+:\d{4,5}\/queue\/(?P[a-z0-9-]{1,})\/(?P\d{12})\/(?P[a-zA-Z0-9_-]+(.fifo)?)$" +LEGACY_STRATEGY_URL_REGEX = ( + r"[^:]+:\d{4,5}\/(?P\d{12})\/(?P[a-zA-Z0-9_-]+(.fifo)?)$" +) + +# HTTP headers used to override internal SQS ReceiveMessage +HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT = "x-localstack-sqs-override-message-count" +HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS = "x-localstack-sqs-override-wait-time-seconds" + +# response includes a default maximum of 1,000 results +MAX_RESULT_LIMIT = 1000 + +# SQS string seed value for uuid generation +SQS_UUID_STRING_SEED = "123e4567-e89b-12d3-a456-426614174000" diff --git a/localstack-core/localstack/services/sqs/exceptions.py b/localstack-core/localstack/services/sqs/exceptions.py new file mode 100644 index 0000000000000..4f256648cc145 --- /dev/null +++ b/localstack-core/localstack/services/sqs/exceptions.py @@ -0,0 +1,16 @@ +from localstack.aws.api import CommonServiceException + + +class InvalidParameterValueException(CommonServiceException): + def __init__(self, message): + super().__init__("InvalidParameterValueException", message, 400, True) + + +class InvalidAttributeValue(CommonServiceException): + def __init__(self, message): + super().__init__("InvalidAttributeValue", message, 400, True) + + +class MissingRequiredParameterException(CommonServiceException): + def __init__(self, message): + super().__init__("MissingRequiredParameterException", message, 400, True) diff --git a/localstack-core/localstack/services/sqs/models.py b/localstack-core/localstack/services/sqs/models.py new file mode 100644 index 0000000000000..8e7352bd28172 --- /dev/null +++ b/localstack-core/localstack/services/sqs/models.py @@ -0,0 +1,1331 @@ +import hashlib +import heapq +import inspect +import json +import logging +import re +import threading +import time +from datetime import datetime +from queue import Empty +from typing import Dict, Optional, Set + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.api.sqs import ( + AttributeNameList, + InvalidAttributeName, + Message, + MessageSystemAttributeName, + QueueAttributeMap, + QueueAttributeName, + ReceiptHandleIsInvalid, + TagMap, +) +from localstack.services.sqs import constants as sqs_constants +from localstack.services.sqs.exceptions import ( + InvalidAttributeValue, + InvalidParameterValueException, + MissingRequiredParameterException, +) +from localstack.services.sqs.queue import InterruptiblePriorityQueue, InterruptibleQueue +from localstack.services.sqs.utils import ( + encode_move_task_handle, + encode_receipt_handle, + extract_receipt_handle_info, + global_message_sequence, + guess_endpoint_strategy_and_host, + is_message_deduplication_id_required, +) +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute +from localstack.utils.aws.arns import get_partition +from localstack.utils.strings import long_uid +from localstack.utils.time import now +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +ReceiptHandle = str + + +class SqsMessage: + message: Message + created: float + visibility_timeout: int + receive_count: int + delay_seconds: Optional[int] + receipt_handles: Set[str] + last_received: Optional[float] + first_received: Optional[float] + visibility_deadline: Optional[float] + deleted: bool + priority: float + message_deduplication_id: str + message_group_id: str + sequence_number: str + + def __init__( + self, + priority: float, + message: Message, + message_deduplication_id: str = None, + message_group_id: str = None, + sequence_number: str = None, + ) -> None: + self.created = time.time() + self.message = message + self.receive_count = 0 + self.receipt_handles = set() + + self.delay_seconds = None + self.last_received = None + self.first_received = None + self.visibility_deadline = None + self.deleted = False + self.priority = priority + self.sequence_number = sequence_number + + attributes = {} + if message_group_id is not None: + attributes["MessageGroupId"] = message_group_id + if message_deduplication_id is not None: + attributes["MessageDeduplicationId"] = message_deduplication_id + if sequence_number is not None: + attributes["SequenceNumber"] = sequence_number + + if self.message.get("Attributes"): + self.message["Attributes"].update(attributes) + else: + self.message["Attributes"] = attributes + + # set attribute default values if not set + self.message["Attributes"].setdefault( + MessageSystemAttributeName.ApproximateReceiveCount, "0" + ) + + @property + def message_group_id(self) -> Optional[str]: + return self.message["Attributes"].get(MessageSystemAttributeName.MessageGroupId) + + @property + def message_deduplication_id(self) -> Optional[str]: + return self.message["Attributes"].get(MessageSystemAttributeName.MessageDeduplicationId) + + @property + def dead_letter_queue_source_arn(self) -> Optional[str]: + return self.message["Attributes"].get(MessageSystemAttributeName.DeadLetterQueueSourceArn) + + @property + def message_id(self): + return self.message["MessageId"] + + def increment_approximate_receive_count(self): + """ + Increment the message system attribute ``ApproximateReceiveCount``. + """ + # TODO: need better handling of system attributes + cnt = int( + self.message["Attributes"].get(MessageSystemAttributeName.ApproximateReceiveCount, "0") + ) + cnt += 1 + self.message["Attributes"][MessageSystemAttributeName.ApproximateReceiveCount] = str(cnt) + + def set_last_received(self, timestamp: float): + """ + Sets the last received timestamp of the message to the given value, and updates the visibility deadline + accordingly. + + :param timestamp: the last time the message was received + """ + self.last_received = timestamp + self.visibility_deadline = timestamp + self.visibility_timeout + + def update_visibility_timeout(self, timeout: int): + """ + Sets the visibility timeout of the message to the given value, and updates the visibility deadline accordingly. + + :param timeout: the timeout value in seconds + """ + self.visibility_timeout = timeout + self.visibility_deadline = time.time() + timeout + + @property + def is_visible(self) -> bool: + """ + Returns false if the message has a visibility deadline that is in the future. + + :return: whether the message is visibile or not. + """ + if self.visibility_deadline is None: + return True + if time.time() >= self.visibility_deadline: + return True + + return False + + @property + def is_delayed(self) -> bool: + if self.delay_seconds is None: + return False + return time.time() <= self.created + self.delay_seconds + + def __gt__(self, other): + return self.priority > other.priority + + def __ge__(self, other): + return self.priority >= other.priority + + def __lt__(self, other): + return self.priority < other.priority + + def __le__(self, other): + return self.priority <= other.priority + + def __eq__(self, other): + return self.message_id == other.message_id + + def __hash__(self): + return self.message_id.__hash__() + + def __repr__(self): + return f"SqsMessage(id={self.message_id},group={self.message_group_id})" + + +class ReceiveMessageResult: + """ + Object to communicate the result of a "receive messages" operation between the SqsProvider and + the underlying datastructure holding the messages. + """ + + successful: list[SqsMessage] + """The messages that were successfully received from the queue""" + + receipt_handles: list[str] + """The array index position in ``successful`` and ``receipt_handles`` need to be the same (this + assumption is needed when assembling the result in `SqsProvider.receive_message`)""" + + dead_letter_messages: list[SqsMessage] + """All messages that were received more than maxReceiveCount in the redrive policy (if any)""" + + def __init__(self): + self.successful = [] + self.receipt_handles = [] + self.dead_letter_messages = [] + + +class MessageMoveTaskStatus(str): + CREATED = "CREATED" # not validated, for internal use + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + CANCELLING = "CANCELLING" + CANCELLED = "CANCELLED" + FAILED = "FAILED" + + +class MessageMoveTask: + """ + A task created by the ``StartMessageMoveTask`` operation. + """ + + # configurable fields + source_arn: str + """The arn of the DLQ the messages are currently in.""" + destination_arn: str | None = None + """If the DestinationArn is not specified, the original source arn will be used as target.""" + max_number_of_messages_per_second: int | None = None + + # dynamic fields + task_id: str + status: str = MessageMoveTaskStatus.CREATED + started_timestamp: datetime | None = None + approximate_number_of_messages_moved: int | None = None + approximate_number_of_messages_to_move: int | None = None + failure_reason: str | None = None + + cancel_event: threading.Event + + def __init__( + self, source_arn: str, destination_arn: str, max_number_of_messages_per_second: int = None + ): + self.task_id = long_uid() + self.source_arn = source_arn + self.destination_arn = destination_arn + self.max_number_of_messages_per_second = max_number_of_messages_per_second + self.cancel_event = threading.Event() + + def mark_started(self): + self.started_timestamp = datetime.utcnow() + self.status = MessageMoveTaskStatus.RUNNING + self.cancel_event.clear() + + @property + def task_handle(self) -> str: + return encode_move_task_handle(self.task_id, self.source_arn) + + +class SqsQueue: + name: str + region: str + account_id: str + + attributes: QueueAttributeMap + tags: TagMap + + purge_in_progress: bool + purge_timestamp: Optional[float] + + delayed: Set[SqsMessage] + inflight: Set[SqsMessage] + receipts: Dict[str, SqsMessage] + + def __init__(self, name: str, region: str, account_id: str, attributes=None, tags=None) -> None: + self.name = name + self.region = region + self.account_id = account_id + + self._assert_queue_name(name) + self.tags = tags or {} + + self.delayed = set() + self.inflight = set() + self.receipts = {} + + self.attributes = self.default_attributes() + if attributes: + self.validate_queue_attributes(attributes) + self.attributes.update(attributes) + + self.purge_in_progress = False + self.purge_timestamp = None + + self.permissions = set() + self.mutex = threading.RLock() + + def shutdown(self): + pass + + def default_attributes(self) -> QueueAttributeMap: + return { + QueueAttributeName.ApproximateNumberOfMessages: lambda: str( + self.approx_number_of_messages + ), + QueueAttributeName.ApproximateNumberOfMessagesNotVisible: lambda: str( + self.approx_number_of_messages_not_visible + ), + QueueAttributeName.ApproximateNumberOfMessagesDelayed: lambda: str( + self.approx_number_of_messages_delayed + ), + QueueAttributeName.CreatedTimestamp: str(now()), + QueueAttributeName.DelaySeconds: "0", + QueueAttributeName.LastModifiedTimestamp: str(now()), + QueueAttributeName.MaximumMessageSize: str(sqs_constants.DEFAULT_MAXIMUM_MESSAGE_SIZE), + QueueAttributeName.MessageRetentionPeriod: "345600", + QueueAttributeName.QueueArn: self.arn, + QueueAttributeName.ReceiveMessageWaitTimeSeconds: "0", + QueueAttributeName.VisibilityTimeout: "30", + QueueAttributeName.SqsManagedSseEnabled: "true", + } + + def update_delay_seconds(self, value: int): + """ + For standard queues, the per-queue delay setting is not retroactiveβ€”changing the setting doesn't affect the + delay of messages already in the queue. For FIFO queues, the per-queue delay setting is retroactiveβ€”changing + the setting affects the delay of messages already in the queue. + + https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-delay-queues.html + + :param value: the number of seconds + """ + self.attributes[QueueAttributeName.DelaySeconds] = str(value) + + def update_last_modified(self, timestamp: int = None): + if timestamp is None: + timestamp = now() + + self.attributes[QueueAttributeName.LastModifiedTimestamp] = str(timestamp) + + @property + def arn(self) -> str: + return f"arn:{get_partition(self.region)}:sqs:{self.region}:{self.account_id}:{self.name}" + + def url(self, context: RequestContext) -> str: + """Return queue URL which depending on the endpoint strategy returns e.g.: + * (standard) http://sqs.eu-west-1.localhost.localstack.cloud:4566/000000000000/myqueue + * (domain) http://eu-west-1.queue.localhost.localstack.cloud:4566/000000000000/myqueue + * (path) http://localhost.localstack.cloud:4566/queue/eu-central-1/000000000000/myqueue + * otherwise: http://localhost.localstack.cloud:4566/000000000000/myqueue + """ + + scheme = config.get_protocol() # TODO: should probably change to context.request.scheme + host_definition = localstack_host() + host_and_port = host_definition.host_and_port() + + endpoint_strategy = config.SQS_ENDPOINT_STRATEGY + + if endpoint_strategy == "dynamic": + scheme = context.request.scheme + # determine the endpoint strategy that should be used, and determine the host dynamically + endpoint_strategy, host_and_port = guess_endpoint_strategy_and_host( + context.request.host + ) + + if endpoint_strategy == "standard": + # Region is always part of the queue URL + # sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/my-queue + scheme = context.request.scheme + host_url = f"{scheme}://sqs.{self.region}.{host_and_port}" + elif endpoint_strategy == "domain": + # Legacy style + # queue.localhost.localstack.cloud:4566/000000000000/my-queue (us-east-1) + # or us-east-2.queue.localhost.localstack.cloud:4566/000000000000/my-queue + region = "" if self.region == "us-east-1" else self.region + "." + host_url = f"{scheme}://{region}queue.{host_and_port}" + elif endpoint_strategy == "path": + # https?://localhost:4566/queue/us-east-1/00000000000/my-queue (us-east-1) + host_url = f"{scheme}://{host_and_port}/queue/{self.region}" + else: + host_url = f"{scheme}://{host_and_port}" + + return "{host}/{account_id}/{name}".format( + host=host_url.rstrip("/"), + account_id=self.account_id, + name=self.name, + ) + + @property + def redrive_policy(self) -> Optional[dict]: + if policy_document := self.attributes.get(QueueAttributeName.RedrivePolicy): + return json.loads(policy_document) + return None + + @property + def max_receive_count(self) -> Optional[int]: + """ + Returns the maxReceiveCount attribute of the redrive policy. If no redrive policy is set, then it + returns None. + """ + if redrive_policy := self.redrive_policy: + return int(redrive_policy["maxReceiveCount"]) + return None + + @property + def visibility_timeout(self) -> int: + return int(self.attributes[QueueAttributeName.VisibilityTimeout]) + + @property + def delay_seconds(self) -> int: + return int(self.attributes[QueueAttributeName.DelaySeconds]) + + @property + def wait_time_seconds(self) -> int: + return int(self.attributes[QueueAttributeName.ReceiveMessageWaitTimeSeconds]) + + @property + def message_retention_period(self) -> int: + """ + ``MessageRetentionPeriod`` -- the length of time, in seconds, for which Amazon SQS retains a message. Valid + values: An integer representing seconds, from 60 (1 minute) to 1,209,600 (14 days). Default: 345,600 (4 days). + """ + return int(self.attributes[QueueAttributeName.MessageRetentionPeriod]) + + @property + def maximum_message_size(self) -> int: + return int(self.attributes[QueueAttributeName.MaximumMessageSize]) + + @property + def approx_number_of_messages(self) -> int: + raise NotImplementedError + + @property + def approx_number_of_messages_not_visible(self) -> int: + return len(self.inflight) + + @property + def approx_number_of_messages_delayed(self) -> int: + return len(self.delayed) + + def validate_receipt_handle(self, receipt_handle: str): + if self.arn != extract_receipt_handle_info(receipt_handle).queue_arn: + raise ReceiptHandleIsInvalid( + f'The input receipt handle "{receipt_handle}" is not a valid receipt handle.' + ) + + def update_visibility_timeout(self, receipt_handle: str, visibility_timeout: int): + with self.mutex: + self.validate_receipt_handle(receipt_handle) + + if receipt_handle not in self.receipts: + raise InvalidParameterValueException( + f"Value {receipt_handle} for parameter ReceiptHandle is invalid. Reason: Message does not exist " + f"or is not available for visibility timeout change." + ) + + standard_message = self.receipts[receipt_handle] + + if standard_message not in self.inflight: + return + + standard_message.update_visibility_timeout(visibility_timeout) + + if visibility_timeout == 0: + LOG.info( + "terminating the visibility timeout of %s", + standard_message.message["MessageId"], + ) + # Terminating the visibility timeout for a message + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html#terminating-message-visibility-timeout + self.inflight.remove(standard_message) + self._put_message(standard_message) + + def remove(self, receipt_handle: str): + with self.mutex: + self.validate_receipt_handle(receipt_handle) + + if receipt_handle not in self.receipts: + LOG.debug( + "no in-flight message found for receipt handle %s in queue %s", + receipt_handle, + self.arn, + ) + return + + standard_message = self.receipts[receipt_handle] + self._pre_delete_checks(standard_message, receipt_handle) + standard_message.deleted = True + LOG.debug( + "deleting message %s from queue %s", + standard_message.message["MessageId"], + self.arn, + ) + + # remove all handles associated with this message + for handle in standard_message.receipt_handles: + del self.receipts[handle] + standard_message.receipt_handles.clear() + + self._on_remove_message(standard_message) + + def _on_remove_message(self, message: SqsMessage): + """Hook for queue-specific logic executed when a message is removed.""" + pass + + def put( + self, + message: Message, + visibility_timeout: int = None, + message_deduplication_id: str = None, + message_group_id: str = None, + delay_seconds: int = None, + ) -> SqsMessage: + raise NotImplementedError + + def receive( + self, + num_messages: int = 1, + wait_time_seconds: int = None, + visibility_timeout: int = None, + *, + poll_empty_queue: bool = False, + ) -> ReceiveMessageResult: + """ + Receive ``num_messages`` from the queue, and wait at max ``wait_time_seconds``. If a visibility + timeout is given, also change the visibility timeout of all received messages accordingly. + + :param num_messages: the number of messages you want to get from the underlying queue + :param wait_time_seconds: the number of seconds you want to wait + :param visibility_timeout: an optional new visibility timeout + :param poll_empty_queue: whether to keep polling an empty queue until the duration ``wait_time_seconds`` has elapsed + :return: a ReceiveMessageResult object that contains the result of the operation + """ + raise NotImplementedError + + def clear(self): + """ + Calls clear on all internal datastructures that hold messages and data related to them. + """ + with self.mutex: + self.inflight.clear() + self.delayed.clear() + self.receipts.clear() + + def _put_message(self, message: SqsMessage): + """Low-level put operation to put messages into a queue and modify visibilities accordingly.""" + raise NotImplementedError + + def create_receipt_handle(self, message: SqsMessage) -> str: + return encode_receipt_handle(self.arn, message) + + def requeue_inflight_messages(self): + if not self.inflight: + return + + with self.mutex: + messages = [message for message in self.inflight if message.is_visible] + for standard_message in messages: + LOG.debug( + "re-queueing inflight messages %s into queue %s", + standard_message, + self.arn, + ) + self.inflight.remove(standard_message) + self._put_message(standard_message) + + def enqueue_delayed_messages(self): + if not self.delayed: + return + + with self.mutex: + messages = [message for message in self.delayed if not message.is_delayed] + for standard_message in messages: + LOG.debug( + "enqueueing delayed messages %s into queue %s", + standard_message.message["MessageId"], + self.arn, + ) + self.delayed.remove(standard_message) + self._put_message(standard_message) + + def remove_expired_messages(self): + """ + Removes messages from the queue whose retention period has expired. + """ + raise NotImplementedError + + def _assert_queue_name(self, name): + if not re.match(r"^[a-zA-Z0-9_-]{1,80}$", name): + raise InvalidParameterValueException( + "Can only include alphanumeric characters, hyphens, or underscores. 1 to 80 in length" + ) + + def validate_queue_attributes(self, attributes): + pass + + def add_permission(self, label: str, actions: list[str], account_ids: list[str]) -> None: + """ + Create / append to a policy for usage with the add_permission api call + + :param actions: List of actions to be included in the policy, without the SQS: prefix + :param account_ids: List of account ids to be included in the policy + :param label: Permission label + """ + statement = { + "Sid": label, + "Effect": "Allow", + "Principal": { + "AWS": [ + f"arn:{get_partition(self.region)}:iam::{account_id}:root" + for account_id in account_ids + ] + if len(account_ids) > 1 + else f"arn:{get_partition(self.region)}:iam::{account_ids[0]}:root" + }, + "Action": [f"SQS:{action}" for action in actions] + if len(actions) > 1 + else f"SQS:{actions[0]}", + "Resource": self.arn, + } + if policy := self.attributes.get(QueueAttributeName.Policy): + policy = json.loads(policy) + policy.setdefault("Statement", []) + else: + policy = { + "Version": "2008-10-17", + "Id": f"{self.arn}/SQSDefaultPolicy", + "Statement": [], + } + policy.setdefault("Statement", []) + existing_statement_ids = [statement.get("Sid") for statement in policy["Statement"]] + if label in existing_statement_ids: + raise InvalidParameterValueException( + f"Value {label} for parameter Label is invalid. Reason: Already exists." + ) + policy["Statement"].append(statement) + self.attributes[QueueAttributeName.Policy] = json.dumps(policy) + + def remove_permission(self, label: str) -> None: + """ + Delete a policy statement for usage of the remove_permission call + + :param label: Permission label + """ + if policy := self.attributes.get(QueueAttributeName.Policy): + policy = json.loads(policy) + # this should not be necessary, but we can upload custom policies, so it's better to be safe + policy.setdefault("Statement", []) + else: + policy = { + "Version": "2008-10-17", + "Id": f"{self.arn}/SQSDefaultPolicy", + "Statement": [], + } + existing_statement_ids = [statement.get("Sid") for statement in policy["Statement"]] + if label not in existing_statement_ids: + raise InvalidParameterValueException( + f"Value {label} for parameter Label is invalid. Reason: can't find label." + ) + policy["Statement"] = [ + statement for statement in policy["Statement"] if statement.get("Sid") != label + ] + if policy["Statement"]: + self.attributes[QueueAttributeName.Policy] = json.dumps(policy) + else: + del self.attributes[QueueAttributeName.Policy] + + def get_queue_attributes(self, attribute_names: AttributeNameList = None) -> dict[str, str]: + if not attribute_names: + return {} + + if QueueAttributeName.All in attribute_names: + attribute_names = self.attributes.keys() + + result: Dict[QueueAttributeName, str] = {} + + for attr in attribute_names: + try: + getattr(QueueAttributeName, attr) + except AttributeError: + raise InvalidAttributeName(f"Unknown Attribute {attr}.") + + value = self.attributes.get(attr) + if callable(value): + func = value + value = func() + if value is not None: + result[attr] = value + elif value == "False" or value == "True": + result[attr] = value.lower() + elif value is not None: + result[attr] = value + return result + + @staticmethod + def remove_expired_messages_from_heap( + heap: list[SqsMessage], message_retention_period: int + ) -> list[SqsMessage]: + """ + Removes from the given heap of SqsMessages all messages that have expired in the context of the current time + and the given message retention period. The method manipulates the heap but retains the heap property. + + :param heap: an array satisfying the heap property + :param message_retention_period: the message retention period to use in relation to the current time + :return: a list of expired messages that have been removed from the heap + """ + th = time.time() - message_retention_period + + expired = [] + while heap: + # here we're leveraging the heap property "that a[0] is always its smallest element" + # and the assumption that message.created == message.priority + message = heap[0] + if th < message.created: + break + # remove the expired element + expired.append(message) + heapq.heappop(heap) + + return expired + + def _pre_delete_checks(self, standard_message: SqsMessage, receipt_handle: str) -> None: + """ + Runs any potential checks if a message that has been successfully identified via a receipt handle + is indeed supposed to be deleted. + For example, a receipt handle that has expired might not lead to deletion. + + :param standard_message: The message to be deleted + :param receipt_handle: The handle associated with the message + :return: None. Potential violations raise errors. + """ + pass + + +class StandardQueue(SqsQueue): + visible: InterruptiblePriorityQueue[SqsMessage] + inflight: Set[SqsMessage] + + def __init__(self, name: str, region: str, account_id: str, attributes=None, tags=None) -> None: + super().__init__(name, region, account_id, attributes, tags) + self.visible = InterruptiblePriorityQueue() + + def clear(self): + with self.mutex: + super().clear() + self.visible.queue.clear() + + @property + def approx_number_of_messages(self): + return self.visible.qsize() + + def shutdown(self): + self.visible.shutdown() + + def put( + self, + message: Message, + visibility_timeout: int = None, + message_deduplication_id: str = None, + message_group_id: str = None, + delay_seconds: int = None, + ): + if message_deduplication_id: + raise InvalidParameterValueException( + f"Value {message_deduplication_id} for parameter MessageDeduplicationId is invalid. Reason: The " + f"request includes a parameter that is not valid for this queue type." + ) + if isinstance(message_group_id, str): + raise InvalidParameterValueException( + f"Value {message_group_id} for parameter MessageGroupId is invalid. Reason: The request include " + f"parameter that is not valid for this queue type." + ) + + standard_message = SqsMessage(time.time(), message) + + if visibility_timeout is not None: + standard_message.visibility_timeout = visibility_timeout + else: + # use the attribute from the queue + standard_message.visibility_timeout = self.visibility_timeout + + if delay_seconds is not None: + standard_message.delay_seconds = delay_seconds + else: + standard_message.delay_seconds = self.delay_seconds + + if standard_message.is_delayed: + self.delayed.add(standard_message) + else: + self._put_message(standard_message) + + return standard_message + + def _put_message(self, message: SqsMessage): + self.visible.put_nowait(message) + + def remove_expired_messages(self): + with self.mutex: + messages = self.remove_expired_messages_from_heap( + self.visible.queue, self.message_retention_period + ) + + for message in messages: + LOG.debug("Removed expired message %s from queue %s", message.message_id, self.arn) + + def receive( + self, + num_messages: int = 1, + wait_time_seconds: int = None, + visibility_timeout: int = None, + *, + poll_empty_queue: bool = False, + ) -> ReceiveMessageResult: + result = ReceiveMessageResult() + + max_receive_count = self.max_receive_count + visibility_timeout = ( + self.visibility_timeout if visibility_timeout is None else visibility_timeout + ) + + block = True if wait_time_seconds else False + timeout = wait_time_seconds or 0 + start = time.time() + + # collect messages + while True: + try: + message = self.visible.get(block=block, timeout=timeout) + except Empty: + break + # setting block to false guarantees that, if we've already waited before, we don't wait the + # full time again in the next iteration if max_number_of_messages is set but there are no more + # messages in the queue. see https://github.com/localstack/localstack/issues/5824 + if not poll_empty_queue: + block = False + + timeout -= time.time() - start + if timeout < 0: + timeout = 0 + + if message.deleted: + # filter messages that were deleted with an expired receipt handle after they have been + # re-queued. this can only happen due to a race with `remove`. + continue + + # update message attributes + message.receive_count += 1 + message.update_visibility_timeout(visibility_timeout) + message.set_last_received(time.time()) + if message.first_received is None: + message.first_received = message.last_received + + LOG.debug("de-queued message %s from %s", message, self.arn) + if max_receive_count and message.receive_count > max_receive_count: + # the message needs to move to the DLQ + LOG.debug( + "message %s has been received %d times, marking it for DLQ", + message, + message.receive_count, + ) + result.dead_letter_messages.append(message) + else: + result.successful.append(message) + message.increment_approximate_receive_count() + + # now we can return + if len(result.successful) == num_messages: + break + + # now process the successful result messages: create receipt handles and manage visibility. + for message in result.successful: + # manage receipt handle + receipt_handle = self.create_receipt_handle(message) + message.receipt_handles.add(receipt_handle) + self.receipts[receipt_handle] = message + result.receipt_handles.append(receipt_handle) + + # manage message visibility + if message.visibility_timeout == 0: + self.visible.put_nowait(message) + else: + self.inflight.add(message) + + return result + + def _on_remove_message(self, message: SqsMessage): + try: + self.inflight.remove(message) + except KeyError: + # this likely means the message was removed with an expired receipt handle unfortunately this + # means we need to scan the queue for the element and remove it from there, and then re-heapify + # the queue + try: + self.visible.queue.remove(message) + heapq.heapify(self.visible.queue) + except ValueError: + # this may happen if the message no longer exists because it was removed earlier + pass + + def validate_queue_attributes(self, attributes): + valid = [ + k[1] + for k in inspect.getmembers( + QueueAttributeName, lambda x: isinstance(x, str) and not x.startswith("__") + ) + if k[1] not in sqs_constants.INVALID_STANDARD_QUEUE_ATTRIBUTES + ] + + for k in attributes.keys(): + if k in [QueueAttributeName.FifoThroughputLimit, QueueAttributeName.DeduplicationScope]: + raise InvalidAttributeName( + f"You can specify the {k} only when FifoQueue is set to true." + ) + if k not in valid: + raise InvalidAttributeName(f"Unknown Attribute {k}.") + + +class MessageGroup: + message_group_id: str + messages: list[SqsMessage] + + def __init__(self, message_group_id: str): + self.message_group_id = message_group_id + self.messages = [] + + def empty(self) -> bool: + return not self.messages + + def size(self) -> int: + return len(self.messages) + + def pop(self) -> SqsMessage: + return heapq.heappop(self.messages) + + def push(self, message: SqsMessage): + heapq.heappush(self.messages, message) + + def __eq__(self, other): + return self.message_group_id == other.message_group_id + + def __hash__(self): + return self.message_group_id.__hash__() + + def __repr__(self): + return f"MessageGroup(id={self.message_group_id}, size={len(self.messages)})" + + +class FifoQueue(SqsQueue): + """ + A FIFO queue behaves differently than a default queue. Most behavior has to be implemented separately. + + See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues.html + + TODO: raise exceptions when trying to remove a message with an expired receipt handle + """ + + deduplication: Dict[str, SqsMessage] + message_groups: dict[str, MessageGroup] + inflight_groups: set[MessageGroup] + message_group_queue: InterruptibleQueue + deduplication_scope: str + + def __init__(self, name: str, region: str, account_id: str, attributes=None, tags=None) -> None: + super().__init__(name, region, account_id, attributes, tags) + self.deduplication = {} + + self.message_groups = {} + self.inflight_groups = set() + self.message_group_queue = InterruptibleQueue() + + # SQS does not seem to change the deduplication behaviour of fifo queues if you + # change to/from 'queue'/'messageGroup' scope after creation -> we need to set this on creation + self.deduplication_scope = self.attributes[QueueAttributeName.DeduplicationScope] + + @property + def approx_number_of_messages(self): + n = 0 + for message_group in self.message_groups.values(): + n += len(message_group.messages) + return n + + def shutdown(self): + self.message_group_queue.shutdown() + + def get_message_group(self, message_group_id: str) -> MessageGroup: + """ + Thread safe lazy factory for MessageGroup objects. + + :param message_group_id: the message group ID + :return: a new or existing MessageGroup object + """ + with self.mutex: + if message_group_id not in self.message_groups: + self.message_groups[message_group_id] = MessageGroup(message_group_id) + + return self.message_groups.get(message_group_id) + + def default_attributes(self) -> QueueAttributeMap: + return { + **super().default_attributes(), + QueueAttributeName.ContentBasedDeduplication: "false", + QueueAttributeName.DeduplicationScope: "queue", + QueueAttributeName.FifoThroughputLimit: "perQueue", + } + + def update_delay_seconds(self, value: int): + super(FifoQueue, self).update_delay_seconds(value) + for message in self.delayed: + message.delay_seconds = value + + def _pre_delete_checks(self, message: SqsMessage, receipt_handle: str) -> None: + _, _, _, last_received = extract_receipt_handle_info(receipt_handle) + if time.time() - float(last_received) > message.visibility_timeout: + raise InvalidParameterValueException( + f"Value {receipt_handle} for parameter ReceiptHandle is invalid. Reason: The receipt handle has expired." + ) + + def remove(self, receipt_handle: str): + self.validate_receipt_handle(receipt_handle) + + super().remove(receipt_handle) + + def put( + self, + message: Message, + visibility_timeout: int = None, + message_deduplication_id: str = None, + message_group_id: str = None, + delay_seconds: int = None, + ): + if delay_seconds: + # in fifo queues, delay is only applied on queue level. However, explicitly setting delay_seconds=0 is valid + raise InvalidParameterValueException( + f"Value {delay_seconds} for parameter DelaySeconds is invalid. Reason: The request include parameter " + f"that is not valid for this queue type." + ) + + if not message_group_id: + raise MissingRequiredParameterException( + "The request must contain the parameter MessageGroupId." + ) + dedup_id = message_deduplication_id + content_based_deduplication = not is_message_deduplication_id_required(self) + if not dedup_id and content_based_deduplication: + dedup_id = hashlib.sha256(message.get("Body").encode("utf-8")).hexdigest() + if not dedup_id: + raise InvalidParameterValueException( + "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly" + ) + + fifo_message = SqsMessage( + time.time(), + message, + message_deduplication_id=dedup_id, + message_group_id=message_group_id, + sequence_number=str(self.next_sequence_number()), + ) + if visibility_timeout is not None: + fifo_message.visibility_timeout = visibility_timeout + else: + # use the attribute from the queue + fifo_message.visibility_timeout = self.visibility_timeout + + if delay_seconds is not None: + fifo_message.delay_seconds = delay_seconds + else: + fifo_message.delay_seconds = self.delay_seconds + + original_message = self.deduplication.get(dedup_id) + if ( + original_message + and original_message.priority + sqs_constants.DEDUPLICATION_INTERVAL_IN_SEC + > fifo_message.priority + # account for deduplication scope required for (but not restricted to) high-throughput-mode + and ( + not self.deduplication_scope == "messageGroup" + or fifo_message.message_group_id == original_message.message_group_id + ) + ): + message["MessageId"] = original_message.message["MessageId"] + else: + if fifo_message.is_delayed: + self.delayed.add(fifo_message) + else: + self._put_message(fifo_message) + + self.deduplication[dedup_id] = fifo_message + + return fifo_message + + def _put_message(self, message: SqsMessage): + """Once a message becomes visible in a FIFO queue, its message group also becomes visible.""" + message_group = self.get_message_group(message.message_group_id) + + with self.mutex: + previously_empty = message_group.empty() + # put the message into the group + message_group.push(message) + + # new messages should not make groups visible that are currently inflight + if message.receive_count < 1 and message_group in self.inflight_groups: + return + # if an older message becomes visible again in the queue, that message's group becomes visible also. + if message_group in self.inflight_groups: + self.inflight_groups.remove(message_group) + self.message_group_queue.put_nowait(message_group) + # if the group was previously empty, it was not yet added back to the queue + elif previously_empty: + self.message_group_queue.put_nowait(message_group) + + def remove_expired_messages(self): + with self.mutex: + retention_period = self.message_retention_period + for message_group in self.message_groups.values(): + messages = self.remove_expired_messages_from_heap( + message_group.messages, retention_period + ) + + for message in messages: + LOG.debug( + "Removed expired message %s from message group %s in queue %s", + message.message_id, + message.message_group_id, + self.arn, + ) + + def receive( + self, + num_messages: int = 1, + wait_time_seconds: int = None, + visibility_timeout: int = None, + *, + poll_empty_queue: bool = False, + ) -> ReceiveMessageResult: + """ + Receive logic for FIFO queues is different from standard queues. See + https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues-understanding-logic.html. + + When receiving messages from a FIFO queue with multiple message group IDs, SQS first attempts to + return as many messages with the same message group ID as possible. This allows other consumers to + process messages with a different message group ID. When you receive a message with a message group + ID, no more messages for the same message group ID are returned unless you delete the message, or it + becomes visible. + """ + result = ReceiveMessageResult() + + max_receive_count = self.max_receive_count + visibility_timeout = ( + self.visibility_timeout if visibility_timeout is None else visibility_timeout + ) + + block = True if wait_time_seconds else False + timeout = wait_time_seconds or 0 + start = time.time() + + received_groups: Set[MessageGroup] = set() + + # collect messages over potentially multiple groups + while True: + try: + group: MessageGroup = self.message_group_queue.get(block=block, timeout=timeout) + except Empty: + break + + if group.empty(): + # this can be the case if all messages in the group are still invisible or + # if all messages of a group have been processed. + # TODO: it should be blocking until at least one message is in the queue, but we don't + # want to block the group + # TODO: check behavior in case it happens if all messages were removed from a group due to message + # retention period. + timeout -= time.time() - start + if timeout < 0: + timeout = 0 + continue + + self.inflight_groups.add(group) + + received_groups.add(group) + + if not poll_empty_queue: + block = False + + # we lock the queue while accessing the groups to not get into races with re-queueing/deleting + with self.mutex: + # collect messages from the group until a continue/break condition is met + while True: + try: + message = group.pop() + except IndexError: + break + + if message.deleted: + # this means the message was deleted with a receipt handle after its visibility + # timeout expired and the messages was re-queued in the meantime. + continue + + # update message attributes + message.receive_count += 1 + message.update_visibility_timeout(visibility_timeout) + message.set_last_received(time.time()) + if message.first_received is None: + message.first_received = message.last_received + + LOG.debug("de-queued message %s from fifo queue %s", message, self.arn) + if max_receive_count and message.receive_count > max_receive_count: + # the message needs to move to the DLQ + LOG.debug( + "message %s has been received %d times, marking it for DLQ", + message, + message.receive_count, + ) + result.dead_letter_messages.append(message) + else: + result.successful.append(message) + message.increment_approximate_receive_count() + + # now we can break the inner loop + if len(result.successful) == num_messages: + break + + # but we also need to check the condition to return from the outer loop + if len(result.successful) == num_messages: + break + + # now process the successful result messages: create receipt handles and manage visibility. + # we use the mutex again because we are modifying the group + with self.mutex: + for message in result.successful: + # manage receipt handle + receipt_handle = self.create_receipt_handle(message) + message.receipt_handles.add(receipt_handle) + self.receipts[receipt_handle] = message + result.receipt_handles.append(receipt_handle) + + # manage message visibility + if message.visibility_timeout == 0: + self._put_message(message) + else: + self.inflight.add(message) + + return result + + def _on_remove_message(self, message: SqsMessage): + # if a message is deleted from the queue, the message's group can become visible again + message_group = self.get_message_group(message.message_group_id) + + with self.mutex: + try: + self.inflight.remove(message) + except KeyError: + # in FIFO queues, this should not happen, as expired receipt handles cannot be used to + # delete a message. + pass + self.update_message_group_visibility(message_group) + + def update_message_group_visibility(self, message_group: MessageGroup): + """ + Check if the passed message group should be made visible again + """ + + with self.mutex: + if message_group in self.inflight_groups: + # it becomes visible again only if there are no other in flight messages in that group + for message in self.inflight: + if message.message_group_id == message_group.message_group_id: + return + + self.inflight_groups.remove(message_group) + if not message_group.empty(): + self.message_group_queue.put_nowait(message_group) + + def _assert_queue_name(self, name): + if not name.endswith(".fifo"): + raise InvalidParameterValueException( + "The name of a FIFO queue can only include alphanumeric characters, hyphens, or underscores, " + "must end with .fifo suffix and be 1 to 80 in length" + ) + # The .fifo suffix counts towards the 80-character queue name quota. + queue_name = name[:-5] + "_fifo" + super()._assert_queue_name(queue_name) + + def validate_queue_attributes(self, attributes): + valid = [ + k[1] + for k in inspect.getmembers(QueueAttributeName) + if k not in sqs_constants.INTERNAL_QUEUE_ATTRIBUTES + ] + for k in attributes.keys(): + if k not in valid: + raise InvalidAttributeName(f"Unknown Attribute {k}.") + # Special Cases + fifo = attributes.get(QueueAttributeName.FifoQueue) + if fifo and fifo.lower() != "true": + raise InvalidAttributeValue( + "Invalid value for the parameter FifoQueue. Reason: Modifying queue type is not supported." + ) + + def next_sequence_number(self): + return next(global_message_sequence()) + + def clear(self): + with self.mutex: + super().clear() + self.message_groups.clear() + self.inflight_groups.clear() + self.message_group_queue.queue.clear() + self.deduplication.clear() + + +class SqsStore(BaseStore): + queues: Dict[str, SqsQueue] = LocalAttribute(default=dict) + + deleted: Dict[str, float] = LocalAttribute(default=dict) + + move_tasks: Dict[str, MessageMoveTask] = LocalAttribute(default=dict) + """Maps task IDs to their ``MoveMessageTask`` object. Task IDs can be found by decoding a task handle.""" + + def expire_deleted(self): + for k in list(self.deleted.keys()): + if self.deleted[k] <= (time.time() - sqs_constants.RECENTLY_DELETED_TIMEOUT): + del self.deleted[k] + + +sqs_stores = AccountRegionBundle("sqs", SqsStore) diff --git a/localstack-core/localstack/services/sqs/provider.py b/localstack-core/localstack/services/sqs/provider.py new file mode 100644 index 0000000000000..0dfcc41a047d2 --- /dev/null +++ b/localstack-core/localstack/services/sqs/provider.py @@ -0,0 +1,1955 @@ +import copy +import hashlib +import json +import logging +import re +import threading +import time +from concurrent.futures.thread import ThreadPoolExecutor +from itertools import islice +from typing import Dict, Iterable, List, Literal, Optional, Tuple + +from botocore.utils import InvalidArnException +from moto.sqs.models import BINARY_TYPE_FIELD_INDEX, STRING_TYPE_FIELD_INDEX +from moto.sqs.models import Message as MotoMessage +from werkzeug import Request as WerkzeugRequest + +from localstack import config +from localstack.aws.api import CommonServiceException, RequestContext, ServiceException +from localstack.aws.api.sqs import ( + ActionNameList, + AttributeNameList, + AWSAccountIdList, + BatchEntryIdsNotDistinct, + BatchRequestTooLong, + BatchResultErrorEntry, + BoxedInteger, + CancelMessageMoveTaskResult, + ChangeMessageVisibilityBatchRequestEntryList, + ChangeMessageVisibilityBatchResult, + CreateQueueResult, + DeleteMessageBatchRequestEntryList, + DeleteMessageBatchResult, + DeleteMessageBatchResultEntry, + EmptyBatchRequest, + GetQueueAttributesResult, + GetQueueUrlResult, + InvalidAttributeName, + InvalidBatchEntryId, + InvalidMessageContents, + ListDeadLetterSourceQueuesResult, + ListMessageMoveTasksResult, + ListMessageMoveTasksResultEntry, + ListQueuesResult, + ListQueueTagsResult, + Message, + MessageAttributeNameList, + MessageBodyAttributeMap, + MessageBodySystemAttributeMap, + MessageSystemAttributeList, + MessageSystemAttributeName, + NullableInteger, + PurgeQueueInProgress, + QueueAttributeMap, + QueueAttributeName, + QueueDeletedRecently, + QueueDoesNotExist, + QueueNameExists, + ReceiveMessageResult, + ResourceNotFoundException, + SendMessageBatchRequestEntryList, + SendMessageBatchResult, + SendMessageBatchResultEntry, + SendMessageResult, + SqsApi, + StartMessageMoveTaskResult, + String, + TagKeyList, + TagMap, + Token, + TooManyEntriesInBatchRequest, +) +from localstack.aws.protocol.parser import create_parser +from localstack.aws.protocol.serializer import aws_response_serializer +from localstack.aws.spec import load_service +from localstack.config import SQS_DISABLE_MAX_NUMBER_OF_MESSAGE_LIMIT +from localstack.http import Request, route +from localstack.services.edge import ROUTER +from localstack.services.plugins import ServiceLifecycleHook +from localstack.services.sqs import constants as sqs_constants +from localstack.services.sqs import query_api +from localstack.services.sqs.constants import ( + HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT, + HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS, + MAX_RESULT_LIMIT, +) +from localstack.services.sqs.exceptions import ( + InvalidParameterValueException, + MissingRequiredParameterException, +) +from localstack.services.sqs.models import ( + FifoQueue, + MessageMoveTask, + MessageMoveTaskStatus, + SqsMessage, + SqsQueue, + SqsStore, + StandardQueue, + sqs_stores, +) +from localstack.services.sqs.utils import ( + decode_move_task_handle, + generate_message_id, + is_fifo_queue, + is_message_deduplication_id_required, + parse_queue_url, +) +from localstack.services.stores import AccountRegionBundle +from localstack.utils.aws.arns import parse_arn +from localstack.utils.aws.request_context import extract_region_from_headers +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.cloudwatch.cloudwatch_util import ( + SqsMetricBatchData, + publish_sqs_metric, + publish_sqs_metric_batch, +) +from localstack.utils.collections import PaginatedList +from localstack.utils.run import FuncThread +from localstack.utils.scheduler import Scheduler +from localstack.utils.strings import md5, token_generator +from localstack.utils.threads import start_thread +from localstack.utils.time import now + +LOG = logging.getLogger(__name__) + +MAX_NUMBER_OF_MESSAGES = 10 +_STORE_LOCK = threading.RLock() + + +class InvalidAddress(ServiceException): + code = "InvalidAddress" + message = "The address https://queue.amazonaws.com/ is not valid for this endpoint." + sender_fault = True + status_code = 404 + + +def assert_queue_name(queue_name: str, fifo: bool = False): + if queue_name.endswith(".fifo"): + if not fifo: + # Standard queues with .fifo suffix are not allowed + raise InvalidParameterValueException( + "Can only include alphanumeric characters, hyphens, or underscores. 1 to 80 in length" + ) + # The .fifo suffix counts towards the 80-character queue name quota. + queue_name = queue_name[:-5] + "_fifo" + + # slashes are actually not allowed, but we've allowed it explicitly in localstack + if not re.match(r"^[a-zA-Z0-9/_-]{1,80}$", queue_name): + raise InvalidParameterValueException( + "Can only include alphanumeric characters, hyphens, or underscores. 1 to 80 in length" + ) + + +def check_message_min_size(message_body: str): + if _message_body_size(message_body) == 0: + raise MissingRequiredParameterException( + "The request must contain the parameter MessageBody." + ) + + +def check_message_max_size( + message_body: str, message_attributes: MessageBodyAttributeMap, max_message_size: int +): + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html + error = "One or more parameters are invalid. " + error += f"Reason: Message must be shorter than {max_message_size} bytes." + if ( + _message_body_size(message_body) + _message_attributes_size(message_attributes) + > max_message_size + ): + raise InvalidParameterValueException(error) + + +def _message_body_size(body: str): + return _bytesize(body) + + +def _message_attributes_size(attributes: MessageBodyAttributeMap): + if not attributes: + return 0 + message_attributes_keys_size = sum(_bytesize(k) for k in attributes.keys()) + message_attributes_values_size = sum( + sum(_bytesize(v) for v in attr.values()) for attr in attributes.values() + ) + return message_attributes_keys_size + message_attributes_values_size + + +def _bytesize(value: str | bytes): + # must encode as utf8 to get correct bytes with len + return len(value.encode("utf8")) if isinstance(value, str) else len(value) + + +def check_message_content(message_body: str): + error = "Invalid characters found. Valid unicode characters are #x9 | #xA | #xD | #x20 to #xD7FF | #xE000 to #xFFFD | #x10000 to #x10FFFF" + + if not re.match(sqs_constants.MSG_CONTENT_REGEX, message_body): + raise InvalidMessageContents(error) + + +class CloudwatchDispatcher: + """ + Dispatches SQS metrics for specific api-calls using a ThreadPool + """ + + def __init__(self, num_thread: int = 3): + self.executor = ThreadPoolExecutor( + num_thread, thread_name_prefix="sqs-metrics-cloudwatch-dispatcher" + ) + self.running = True + + def shutdown(self): + self.executor.shutdown(wait=False, cancel_futures=True) + self.running = False + + def dispatch_sqs_metric( + self, + account_id: str, + region: str, + queue_name: str, + metric: str, + value: float = 1, + unit: str = "Count", + ): + """ + Publishes a metric to Cloudwatch using a Threadpool + :param account_id The account id that should be used for Cloudwatch client + :param region The region that should be used for Cloudwatch client + :param queue_name The name of the queue that the metric belongs to + :param metric The name of the metric + :param value The value for that metric, default 1 + :param unit The unit for the value, default "Count" + """ + if not self.running: + return + + self.executor.submit( + publish_sqs_metric, + account_id=account_id, + region=region, + queue_name=queue_name, + metric=metric, + value=value, + unit=unit, + ) + + def dispatch_metric_message_sent(self, queue: SqsQueue, message_body_size: int): + """ + Sends metric 'NumberOfMessagesSent' and 'SentMessageSize' to Cloudwatch + :param queue The Queue for which the metric will be send + :param message_body_size the size of the message in bytes + """ + self.dispatch_sqs_metric( + account_id=queue.account_id, + region=queue.region, + queue_name=queue.name, + metric="NumberOfMessagesSent", + ) + self.dispatch_sqs_metric( + account_id=queue.account_id, + region=queue.region, + queue_name=queue.name, + metric="SentMessageSize", + value=message_body_size, + unit="Bytes", + ) + + def dispatch_metric_message_deleted(self, queue: SqsQueue, deleted: int = 1): + """ + Sends metric 'NumberOfMessagesDeleted' to Cloudwatch + :param queue The Queue for which the metric will be sent + :param deleted The number of messages that were successfully deleted, default: 1 + """ + self.dispatch_sqs_metric( + account_id=queue.account_id, + region=queue.region, + queue_name=queue.name, + metric="NumberOfMessagesDeleted", + value=deleted, + ) + + def dispatch_metric_received(self, queue: SqsQueue, received: int): + """ + Sends metric 'NumberOfMessagesReceived' (if received > 0), or 'NumberOfEmptyReceives' to Cloudwatch + :param queue The Queue for which the metric will be send + :param received The number of messages that have been received + """ + if received > 0: + self.dispatch_sqs_metric( + account_id=queue.account_id, + region=queue.region, + queue_name=queue.name, + metric="NumberOfMessagesReceived", + value=received, + ) + else: + self.dispatch_sqs_metric( + account_id=queue.account_id, + region=queue.region, + queue_name=queue.name, + metric="NumberOfEmptyReceives", + ) + + +class CloudwatchPublishWorker: + """ + Regularly publish metrics data about approximate messages to Cloudwatch. + Includes: ApproximateNumberOfMessagesVisible, ApproximateNumberOfMessagesNotVisible + and ApproximateNumberOfMessagesDelayed + """ + + def __init__(self) -> None: + super().__init__() + self.scheduler = Scheduler() + self.thread: Optional[FuncThread] = None + + def publish_approximate_cloudwatch_metrics(self): + """Publishes the metrics for ApproximateNumberOfMessagesVisible, ApproximateNumberOfMessagesNotVisible + and ApproximateNumberOfMessagesDelayed to CloudWatch""" + # TODO ApproximateAgeOfOldestMessage is missing + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-available-cloudwatch-metrics.html + + for account_id, region, store in sqs_stores.iter_stores(): + start = 0 + # we can include up to 1000 metric queries for one put-metric-data call + # and we currently include 3 metrics per queue + batch_size = 300 + + while start < len(store.queues): + batch_data = [] + # Process the current batch + for queue in islice(store.queues.values(), start, start + batch_size): + batch_data.append( + SqsMetricBatchData( + QueueName=queue.name, + MetricName="ApproximateNumberOfMessagesVisible", + Value=queue.approx_number_of_messages, + ) + ) + batch_data.append( + SqsMetricBatchData( + QueueName=queue.name, + MetricName="ApproximateNumberOfMessagesNotVisible", + Value=queue.approx_number_of_messages_not_visible, + ) + ) + batch_data.append( + SqsMetricBatchData( + QueueName=queue.name, + MetricName="ApproximateNumberOfMessagesDelayed", + Value=queue.approx_number_of_messages_delayed, + ) + ) + + publish_sqs_metric_batch( + account_id=account_id, region=region, sqs_metric_batch_data=batch_data + ) + # Update for the next batch + start += batch_size + + def start(self): + if self.thread: + return + + self.scheduler = Scheduler() + self.scheduler.schedule( + self.publish_approximate_cloudwatch_metrics, + period=config.SQS_CLOUDWATCH_METRICS_REPORT_INTERVAL, + ) + + def _run(*_args): + self.scheduler.run() + + self.thread = start_thread(_run, name="sqs-approx-metrics-cloudwatch-publisher") + + def stop(self): + if self.scheduler: + self.scheduler.close() + + if self.thread: + self.thread.stop() + + self.thread = None + self.scheduler = None + + +class QueueUpdateWorker: + """ + Regularly re-queues inflight and delayed messages whose visibility timeout has expired or delay deadline has been + reached. + """ + + def __init__(self) -> None: + super().__init__() + self.scheduler = Scheduler() + self.thread: Optional[FuncThread] = None + self.mutex = threading.RLock() + + def iter_queues(self) -> Iterable[SqsQueue]: + for account_id, region, store in sqs_stores.iter_stores(): + for queue in store.queues.values(): + yield queue + + def do_update_all_queues(self): + for queue in self.iter_queues(): + try: + queue.requeue_inflight_messages() + except Exception: + LOG.exception("error re-queueing inflight messages") + + try: + queue.enqueue_delayed_messages() + except Exception: + LOG.exception("error enqueueing delayed messages") + + if config.SQS_ENABLE_MESSAGE_RETENTION_PERIOD: + try: + queue.remove_expired_messages() + except Exception: + LOG.exception("error removing expired messages") + + def start(self): + with self.mutex: + if self.thread: + return + + self.scheduler = Scheduler() + self.scheduler.schedule(self.do_update_all_queues, period=1) + + def _run(*_args): + self.scheduler.run() + + self.thread = start_thread(_run, name="sqs-queue-update-worker") + + def stop(self): + with self.mutex: + if self.scheduler: + self.scheduler.close() + + if self.thread: + self.thread.stop() + + self.thread = None + self.scheduler = None + + +class MessageMoveTaskManager: + """ + Manages and runs MessageMoveTasks. + + TODO: we should check how AWS really moves messages internally: do they use the API? + it's hard to know how AWS really does moving of messages. there are a number of things we could do + to understand it better, including creating a DLQ chain and letting move tasks fail to see whether + move tasks cause message consuming and create receipt handles. for now, we're doing a middle-layer + transactional move, foregoing the API layer but using receipt handles and transactions. + + TODO: restoring move tasks from persistence doesn't work, may be a fringe case though + + TODO: re-drive into multiple original source queues + """ + + def __init__(self, stores: AccountRegionBundle[SqsStore] = None) -> None: + self.stores = stores or sqs_stores + self.mutex = threading.RLock() + self.move_tasks: dict[str, MessageMoveTask] = dict() + self.executor = ThreadPoolExecutor(max_workers=100, thread_name_prefix="sqs-move-message") + + def submit(self, move_task: MessageMoveTask): + with self.mutex: + try: + source_queue = self._get_queue_by_arn(move_task.source_arn) + move_task.approximate_number_of_messages_to_move = ( + source_queue.approx_number_of_messages + ) + move_task.approximate_number_of_messages_moved = 0 + move_task.mark_started() + self.move_tasks[move_task.task_id] = move_task + self.executor.submit(self._run, move_task) + except Exception as e: + self._fail_task(move_task, e) + raise + + def cancel(self, move_task: MessageMoveTask): + with self.mutex: + move_task.status = MessageMoveTaskStatus.CANCELLING + move_task.cancel_event.set() + + def close(self): + with self.mutex: + for move_task in self.move_tasks.values(): + move_task.cancel_event.set() + + self.executor.shutdown(wait=False, cancel_futures=True) + + def _run(self, move_task: MessageMoveTask): + try: + if move_task.destination_arn: + LOG.info( + "Move task started %s (%s -> %s)", + move_task.task_id, + move_task.source_arn, + move_task.destination_arn, + ) + else: + LOG.info( + "Move task started %s (%s -> original sources)", + move_task.task_id, + move_task.source_arn, + ) + + while not move_task.cancel_event.is_set(): + # look up queues for every message in case they are removed + source_queue = self._get_queue_by_arn(move_task.source_arn) + + receive_result = source_queue.receive(num_messages=1, visibility_timeout=1) + + if receive_result.dead_letter_messages: + raise NotImplementedError("Cannot deal with DLQ chains in move tasks") + + if not receive_result.successful: + # queue empty, task done + break + + message = receive_result.successful[0] + receipt_handle = receive_result.receipt_handles[0] + + if move_task.destination_arn: + target_queue = self._get_queue_by_arn(move_task.destination_arn) + else: + # we assume that dead_letter_source_arn is set since the message comes from a DLQ + target_queue = self._get_queue_by_arn(message.dead_letter_queue_source_arn) + + target_queue.put( + message=message.message, + message_group_id=message.message_group_id, + message_deduplication_id=message.message_deduplication_id, + ) + source_queue.remove(receipt_handle) + move_task.approximate_number_of_messages_moved += 1 + + if rate := move_task.max_number_of_messages_per_second: + move_task.cancel_event.wait(timeout=(1 / rate)) + + except Exception as e: + self._fail_task(move_task, e) + else: + if move_task.cancel_event.is_set(): + LOG.info("Move task cancelled %s", move_task.task_id) + move_task.status = MessageMoveTaskStatus.CANCELLED + else: + LOG.info("Move task completed successfully %s", move_task.task_id) + move_task.status = MessageMoveTaskStatus.COMPLETED + + def _get_queue_by_arn(self, queue_arn: str) -> SqsQueue: + arn = parse_arn(queue_arn) + return SqsProvider._require_queue(arn["account"], arn["region"], arn["resource"]) + + def _fail_task(self, task: MessageMoveTask, reason: Exception): + """ + Marks a given task as failed due to the given reason. + + :param task: the task to mark as failed + :param reason: the failure reason + """ + LOG.info( + "Exception occurred during move task %s: %s", + task.task_id, + reason, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + task.status = MessageMoveTaskStatus.FAILED + if isinstance(reason, ServiceException): + task.failure_reason = reason.code + else: + task.failure_reason = reason.__class__.__name__ + + +def check_attributes(message_attributes: MessageBodyAttributeMap): + if not message_attributes: + return + for attribute_name in message_attributes: + if len(attribute_name) >= 256: + raise InvalidParameterValueException( + "Message (user) attribute names must be shorter than 256 Bytes" + ) + if not re.match(sqs_constants.ATTR_NAME_CHAR_REGEX, attribute_name.lower()): + raise InvalidParameterValueException( + "Message (user) attributes name can only contain upper and lower score characters, digits, periods, " + "hyphens and underscores. " + ) + if not re.match(sqs_constants.ATTR_NAME_PREFIX_SUFFIX_REGEX, attribute_name.lower()): + raise InvalidParameterValueException( + "You can't use message attribute names beginning with 'AWS.' or 'Amazon.'. " + "These strings are reserved for internal use. Additionally, they cannot start or end with '.'." + ) + + attribute = message_attributes[attribute_name] + attribute_type = attribute.get("DataType") + if not attribute_type: + raise InvalidParameterValueException("Missing required parameter DataType") + if not re.match(sqs_constants.ATTR_TYPE_REGEX, attribute_type): + raise InvalidParameterValueException( + f"Type for parameter MessageAttributes.Attribute_name.DataType must be prefixed" + f'with "String", "Binary", or "Number", but was: {attribute_type}' + ) + if len(attribute_type) >= 256: + raise InvalidParameterValueException( + "Message (user) attribute types must be shorter than 256 Bytes" + ) + + if attribute_type == "String": + try: + attribute_value = attribute.get("StringValue") + + if not attribute_value: + raise InvalidParameterValueException( + f"Message (user) attribute '{attribute_name}' must contain a non-empty value of type 'String'." + ) + + check_message_content(attribute_value) + except InvalidMessageContents as e: + # AWS throws a different exception here + raise InvalidParameterValueException(e.args[0]) + + +def check_fifo_id(fifo_id, parameter): + if not fifo_id: + return + if len(fifo_id) > 128: + raise InvalidParameterValueException( + f"Value {fifo_id} for parameter {parameter} is invalid. Reason: {parameter} can only include alphanumeric and punctuation characters. 1 to 128 in length." + ) + if not re.match(sqs_constants.FIFO_MSG_REGEX, fifo_id): + raise InvalidParameterValueException( + "Invalid characters found. Deduplication ID and group ID can only contain" + "alphanumeric characters as well as TODO" + ) + + +def get_sqs_protocol(request: Request) -> Literal["query", "json"]: + content_type = request.headers.get("Content-Type") + return "json" if content_type == "application/x-amz-json-1.0" else "query" + + +def sqs_auto_protocol_aws_response_serializer(service_name: str, operation: str): + def _decorate(fn): + def _proxy(*args, **kwargs): + # extract request from function invocation (decorator can be used for methods as well as for functions). + if len(args) > 0 and isinstance(args[0], WerkzeugRequest): + # function + request = args[0] + elif len(args) > 1 and isinstance(args[1], WerkzeugRequest): + # method (arg[0] == self) + request = args[1] + elif "request" in kwargs: + request = kwargs["request"] + else: + raise ValueError(f"could not find Request in signature of function {fn}") + + protocol = get_sqs_protocol(request) + return aws_response_serializer(service_name, operation, protocol)(fn)(*args, **kwargs) + + return _proxy + + return _decorate + + +class SqsDeveloperEndpoints: + """ + A set of SQS developer tool endpoints: + + - ``/_aws/sqs/messages``: list SQS messages without side effects, compatible with ``ReceiveMessage``. + """ + + def __init__(self, stores=None): + self.stores = stores or sqs_stores + + @route("/_aws/sqs/messages", methods=["GET", "POST"]) + @sqs_auto_protocol_aws_response_serializer("sqs", "ReceiveMessage") + def list_messages(self, request: Request) -> ReceiveMessageResult: + """ + This endpoint expects a ``QueueUrl`` request parameter (either as query arg or form parameter), similar to + the ``ReceiveMessage`` operation. It will parse the Queue URL generated by one of the SQS endpoint strategies. + """ + + if "x-amz-" in request.mimetype or "x-www-form-urlencoded" in request.mimetype: + # only parse the request using a parser if it comes from an AWS client + protocol = get_sqs_protocol(request) + operation, service_request = create_parser( + load_service("sqs", protocol=protocol) + ).parse(request) + if operation.name != "ReceiveMessage": + raise CommonServiceException( + "InvalidRequest", "This endpoint only accepts ReceiveMessage calls" + ) + else: + service_request = dict(request.values) + + if not service_request.get("QueueUrl"): + raise QueueDoesNotExist() + + try: + account_id, region, queue_name = parse_queue_url(service_request.get("QueueUrl")) + except ValueError: + LOG.exception( + "Error while parsing Queue URL from request values: %s", service_request.get + ) + raise InvalidAddress() + + if not region: + region = extract_region_from_headers(request.headers) + + return self._get_and_serialize_messages(request, region, account_id, queue_name) + + @route("/_aws/sqs/messages///") + @sqs_auto_protocol_aws_response_serializer("sqs", "ReceiveMessage") + def list_messages_for_queue_url( + self, request: Request, region: str, account_id: str, queue_name: str + ) -> ReceiveMessageResult: + """ + This endpoint extracts the region, account_id, and queue_name directly from the URL rather than requiring the + QueueUrl as parameter. + """ + return self._get_and_serialize_messages(request, region, account_id, queue_name) + + def _get_and_serialize_messages( + self, + request: Request, + region: str, + account_id: str, + queue_name: str, + ) -> ReceiveMessageResult: + show_invisible = request.values.get("ShowInvisible", "").lower() in ["true", "1"] + show_delayed = request.values.get("ShowDelayed", "").lower() in ["true", "1"] + + try: + store = SqsProvider.get_store(account_id, region) + queue = store.queues[queue_name] + except KeyError: + LOG.info( + "no queue named %s in region %s and account %s", queue_name, region, account_id + ) + raise QueueDoesNotExist() + + messages = self._collect_messages( + queue, show_invisible=show_invisible, show_delayed=show_delayed + ) + + return ReceiveMessageResult(Messages=messages) + + def _collect_messages( + self, queue: SqsQueue, show_invisible: bool = False, show_delayed: bool = False + ) -> List[Message]: + """ + Retrieves from a given SqsQueue all visible messages without causing any side effects (not setting any + receive timestamps, receive counts, or visibility state). + + :param queue: the queue + :param show_invisible: show invisible messages as well + :param show_delayed: show delayed messages as well + :return: a list of messages + """ + receipt_handle = "SQS/BACKDOOR/ACCESS" # dummy receipt handle + + sqs_messages: List[SqsMessage] = [] + + if show_invisible: + sqs_messages.extend(queue.inflight) + + if isinstance(queue, StandardQueue): + sqs_messages.extend(queue.visible.queue) + elif isinstance(queue, FifoQueue): + for message_group in queue.message_groups.values(): + for sqs_message in message_group.messages: + sqs_messages.append(sqs_message) + else: + raise ValueError(f"unknown queue type {type(queue)}") + + if show_delayed: + sqs_messages.extend(queue.delayed) + + messages = [] + + for sqs_message in sqs_messages: + message: Message = to_sqs_api_message(sqs_message, [QueueAttributeName.All], ["All"]) + # these are all non-standard fields so we squelch the linter + if show_invisible: + message["Attributes"]["IsVisible"] = str(sqs_message.is_visible).lower() # noqa + if show_delayed: + message["Attributes"]["IsDelayed"] = str(sqs_message.is_delayed).lower() # noqa + messages.append(message) + message["ReceiptHandle"] = receipt_handle + + return messages + + +class SqsProvider(SqsApi, ServiceLifecycleHook): + """ + LocalStack SQS Provider. + + LIMITATIONS: + - Pagination of results (NextToken) + - Delivery guarantees + - The region is not encoded in the queue URL + + CROSS-ACCOUNT ACCESS: + LocalStack permits cross-account access for all operations. However, AWS + disallows the same for following operations: + - AddPermission + - CreateQueue + - DeleteQueue + - ListQueues + - ListQueueTags + - RemovePermission + - SetQueueAttributes + - TagQueue + - UntagQueue + """ + + queues: Dict[str, SqsQueue] + + def __init__(self) -> None: + super().__init__() + self._queue_update_worker = QueueUpdateWorker() + self._message_move_task_manager = MessageMoveTaskManager() + self._router_rules = [] + self._init_cloudwatch_metrics_reporting() + + @staticmethod + def get_store(account_id: str, region: str) -> SqsStore: + return sqs_stores[account_id][region] + + def on_before_start(self): + query_api.register(ROUTER) + self._router_rules = ROUTER.add(SqsDeveloperEndpoints()) + self._queue_update_worker.start() + self._start_cloudwatch_metrics_reporting() + + def on_before_stop(self): + ROUTER.remove(self._router_rules) + + self._queue_update_worker.stop() + self._message_move_task_manager.close() + for _, _, store in sqs_stores.iter_stores(): + for queue in store.queues.values(): + queue.shutdown() + + self._stop_cloudwatch_metrics_reporting() + + @staticmethod + def _require_queue( + account_id: str, region_name: str, name: str, is_query: bool = False + ) -> SqsQueue: + """ + Returns the queue for the given name, or raises QueueDoesNotExist if it does not exist. + + :param: context: the request context + :param name: the name to look for + :param is_query: whether the request is using query protocol (error message is different) + :returns: the queue + :raises QueueDoesNotExist: if the queue does not exist + """ + store = SqsProvider.get_store(account_id, region_name) + with _STORE_LOCK: + if name not in store.queues: + if is_query: + message = "The specified queue does not exist for this wsdl version." + else: + message = "The specified queue does not exist." + raise QueueDoesNotExist(message) + + return store.queues[name] + + def _require_queue_by_arn(self, context: RequestContext, queue_arn: str) -> SqsQueue: + arn = parse_arn(queue_arn) + return self._require_queue( + arn["account"], + arn["region"], + arn["resource"], + is_query=context.service.protocol == "query", + ) + + def _resolve_queue( + self, + context: RequestContext, + queue_name: Optional[str] = None, + queue_url: Optional[str] = None, + ) -> SqsQueue: + """ + Determines the name of the queue from available information (request context, queue URL) to return the respective queue, + or raises QueueDoesNotExist if it does not exist. + + :param context: the request context, used for getting region and account_id, and optionally the queue_url + :param queue_name: the queue name (if this is set, then this will be used for the key) + :param queue_url: the queue url (if name is not set, this will be used to determine the queue name) + :returns: the queue + :raises QueueDoesNotExist: if the queue does not exist + """ + account_id, region_name, name = resolve_queue_location(context, queue_name, queue_url) + is_query = context.service.protocol == "query" + return self._require_queue( + account_id, region_name or context.region, name, is_query=is_query + ) + + def create_queue( + self, + context: RequestContext, + queue_name: String, + attributes: QueueAttributeMap = None, + tags: TagMap = None, + **kwargs, + ) -> CreateQueueResult: + fifo = attributes and ( + attributes.get(QueueAttributeName.FifoQueue, "false").lower() == "true" + ) + + # Special Case TODO: why is an emtpy policy passed at all? same in set_queue_attributes + if attributes and attributes.get(QueueAttributeName.Policy) == "": + del attributes[QueueAttributeName.Policy] + + store = self.get_store(context.account_id, context.region) + + with _STORE_LOCK: + if queue_name in store.queues: + queue = store.queues[queue_name] + + if attributes: + # if attributes are set, then we check whether the existing attributes match the passed ones + queue.validate_queue_attributes(attributes) + for k, v in attributes.items(): + if queue.attributes.get(k) != v: + LOG.debug( + "queue attribute values %s for queue %s do not match %s (existing) != %s (new)", + k, + queue_name, + queue.attributes.get(k), + v, + ) + raise QueueNameExists( + f"A queue already exists with the same name and a different value for attribute {k}" + ) + + return CreateQueueResult(QueueUrl=queue.url(context)) + + if config.SQS_DELAY_RECENTLY_DELETED: + deleted = store.deleted.get(queue_name) + if deleted and deleted > (time.time() - sqs_constants.RECENTLY_DELETED_TIMEOUT): + raise QueueDeletedRecently( + "You must wait 60 seconds after deleting a queue before you can create " + "another with the same name." + ) + store.expire_deleted() + + # create the appropriate queue + if fifo: + queue = FifoQueue(queue_name, context.region, context.account_id, attributes, tags) + else: + queue = StandardQueue( + queue_name, context.region, context.account_id, attributes, tags + ) + + LOG.debug("creating queue key=%s attributes=%s tags=%s", queue_name, attributes, tags) + + store.queues[queue_name] = queue + + return CreateQueueResult(QueueUrl=queue.url(context)) + + def get_queue_url( + self, + context: RequestContext, + queue_name: String, + queue_owner_aws_account_id: String = None, + **kwargs, + ) -> GetQueueUrlResult: + queue = self._require_queue( + queue_owner_aws_account_id or context.account_id, + context.region, + queue_name, + is_query=context.service.protocol == "query", + ) + + return GetQueueUrlResult(QueueUrl=queue.url(context)) + + def list_queues( + self, + context: RequestContext, + queue_name_prefix: String = None, + next_token: Token = None, + max_results: BoxedInteger = None, + **kwargs, + ) -> ListQueuesResult: + store = self.get_store(context.account_id, context.region) + + if queue_name_prefix: + urls = [ + queue.url(context) + for queue in store.queues.values() + if queue.name.startswith(queue_name_prefix) + ] + else: + urls = [queue.url(context) for queue in store.queues.values()] + + paginated_list = PaginatedList(urls) + + page_size = max_results if max_results else MAX_RESULT_LIMIT + paginated_urls, next_token = paginated_list.get_page( + token_generator=token_generator, next_token=next_token, page_size=page_size + ) + + if len(urls) == 0: + return ListQueuesResult() + + return ListQueuesResult(QueueUrls=paginated_urls, NextToken=next_token) + + def change_message_visibility( + self, + context: RequestContext, + queue_url: String, + receipt_handle: String, + visibility_timeout: NullableInteger, + **kwargs, + ) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + queue.update_visibility_timeout(receipt_handle, visibility_timeout) + + def change_message_visibility_batch( + self, + context: RequestContext, + queue_url: String, + entries: ChangeMessageVisibilityBatchRequestEntryList, + **kwargs, + ) -> ChangeMessageVisibilityBatchResult: + queue = self._resolve_queue(context, queue_url=queue_url) + + self._assert_batch(entries) + + successful = [] + failed = [] + + with queue.mutex: + for entry in entries: + try: + queue.update_visibility_timeout( + entry["ReceiptHandle"], entry["VisibilityTimeout"] + ) + successful.append({"Id": entry["Id"]}) + except Exception as e: + failed.append( + BatchResultErrorEntry( + Id=entry["Id"], + SenderFault=False, + Code=e.__class__.__name__, + Message=str(e), + ) + ) + + return ChangeMessageVisibilityBatchResult( + Successful=successful, + Failed=failed, + ) + + def delete_queue(self, context: RequestContext, queue_url: String, **kwargs) -> None: + account_id, region, name = parse_queue_url(queue_url) + if region is None: + region = context.region + + if account_id != context.account_id: + LOG.warning( + "Attempting a cross-account DeleteQueue operation (account from context: %s, account from queue url: %s, which is not allowed in AWS", + account_id, + context.account_id, + ) + + with _STORE_LOCK: + store = self.get_store(account_id, region) + queue = self._resolve_queue(context, queue_url=queue_url) + LOG.debug( + "deleting queue name=%s, region=%s, account=%s", + queue.name, + queue.region, + queue.account_id, + ) + # Trigger a shutdown prior to removing the queue resource + store.queues[queue.name].shutdown() + del store.queues[queue.name] + store.deleted[queue.name] = time.time() + + def get_queue_attributes( + self, + context: RequestContext, + queue_url: String, + attribute_names: AttributeNameList = None, + **kwargs, + ) -> GetQueueAttributesResult: + queue = self._resolve_queue(context, queue_url=queue_url) + result = queue.get_queue_attributes(attribute_names=attribute_names) + + return GetQueueAttributesResult(Attributes=(result if result else None)) + + def send_message( + self, + context: RequestContext, + queue_url: String, + message_body: String, + delay_seconds: NullableInteger = None, + message_attributes: MessageBodyAttributeMap = None, + message_system_attributes: MessageBodySystemAttributeMap = None, + message_deduplication_id: String = None, + message_group_id: String = None, + **kwargs, + ) -> SendMessageResult: + queue = self._resolve_queue(context, queue_url=queue_url) + + queue_item = self._put_message( + queue, + context, + message_body, + delay_seconds, + message_attributes, + message_system_attributes, + message_deduplication_id, + message_group_id, + ) + message = queue_item.message + return SendMessageResult( + MessageId=message["MessageId"], + MD5OfMessageBody=message["MD5OfBody"], + MD5OfMessageAttributes=message.get("MD5OfMessageAttributes"), + SequenceNumber=queue_item.sequence_number, + MD5OfMessageSystemAttributes=_create_message_attribute_hash(message_system_attributes), + ) + + def send_message_batch( + self, + context: RequestContext, + queue_url: String, + entries: SendMessageBatchRequestEntryList, + **kwargs, + ) -> SendMessageBatchResult: + queue = self._resolve_queue(context, queue_url=queue_url) + + self._assert_batch( + entries, + require_fifo_queue_params=is_fifo_queue(queue), + require_message_deduplication_id=is_message_deduplication_id_required(queue), + ) + # check the total batch size first and raise BatchRequestTooLong id > DEFAULT_MAXIMUM_MESSAGE_SIZE. + # This is checked before any messages in the batch are sent. Raising the exception here should + # cause error response, rather than batching error results and returning + self._assert_valid_batch_size(entries, sqs_constants.DEFAULT_MAXIMUM_MESSAGE_SIZE) + + successful = [] + failed = [] + + with queue.mutex: + for entry in entries: + try: + queue_item = self._put_message( + queue, + context, + message_body=entry.get("MessageBody"), + delay_seconds=entry.get("DelaySeconds"), + message_attributes=entry.get("MessageAttributes"), + message_system_attributes=entry.get("MessageSystemAttributes"), + message_deduplication_id=entry.get("MessageDeduplicationId"), + message_group_id=entry.get("MessageGroupId"), + ) + message = queue_item.message + + successful.append( + SendMessageBatchResultEntry( + Id=entry["Id"], + MessageId=message.get("MessageId"), + MD5OfMessageBody=message.get("MD5OfBody"), + MD5OfMessageAttributes=message.get("MD5OfMessageAttributes"), + MD5OfMessageSystemAttributes=_create_message_attribute_hash( + message.get("message_system_attributes") + ), + SequenceNumber=queue_item.sequence_number, + ) + ) + except ServiceException as e: + failed.append( + BatchResultErrorEntry( + Id=entry["Id"], + SenderFault=e.sender_fault, + Code=e.code, + Message=e.message, + ) + ) + except Exception as e: + failed.append( + BatchResultErrorEntry( + Id=entry["Id"], + SenderFault=False, + Code=e.__class__.__name__, + Message=str(e), + ) + ) + + return SendMessageBatchResult( + Successful=(successful if successful else None), + Failed=(failed if failed else None), + ) + + def _put_message( + self, + queue: SqsQueue, + context: RequestContext, + message_body: String, + delay_seconds: NullableInteger = None, + message_attributes: MessageBodyAttributeMap = None, + message_system_attributes: MessageBodySystemAttributeMap = None, + message_deduplication_id: String = None, + message_group_id: String = None, + ) -> SqsMessage: + check_message_min_size(message_body) + check_message_max_size(message_body, message_attributes, queue.maximum_message_size) + check_message_content(message_body) + check_attributes(message_attributes) + check_attributes(message_system_attributes) + check_fifo_id(message_deduplication_id, "MessageDeduplicationId") + check_fifo_id(message_group_id, "MessageGroupId") + + message = Message( + MessageId=generate_message_id(), + MD5OfBody=md5(message_body), + Body=message_body, + Attributes=self._create_message_attributes(context, message_system_attributes), + MD5OfMessageAttributes=_create_message_attribute_hash(message_attributes), + MessageAttributes=message_attributes, + ) + if self._cloudwatch_dispatcher: + self._cloudwatch_dispatcher.dispatch_metric_message_sent( + queue=queue, message_body_size=len(message_body.encode("utf-8")) + ) + + return queue.put( + message=message, + message_deduplication_id=message_deduplication_id, + message_group_id=message_group_id, + delay_seconds=int(delay_seconds) if delay_seconds is not None else None, + ) + + def receive_message( + self, + context: RequestContext, + queue_url: String, + attribute_names: AttributeNameList = None, + message_system_attribute_names: MessageSystemAttributeList = None, + message_attribute_names: MessageAttributeNameList = None, + max_number_of_messages: NullableInteger = None, + visibility_timeout: NullableInteger = None, + wait_time_seconds: NullableInteger = None, + receive_request_attempt_id: String = None, + **kwargs, + ) -> ReceiveMessageResult: + # TODO add support for message_system_attribute_names + queue = self._resolve_queue(context, queue_url=queue_url) + + poll_empty_queue = False + if override := extract_wait_time_seconds_from_headers(context): + wait_time_seconds = override + poll_empty_queue = True + elif wait_time_seconds is None: + wait_time_seconds = queue.wait_time_seconds + elif wait_time_seconds < 0 or wait_time_seconds > 20: + raise InvalidParameterValueException( + f"Value {wait_time_seconds} for parameter WaitTimeSeconds is invalid. " + f"Reason: Must be >= 0 and <= 20, if provided." + ) + num = max_number_of_messages or 1 + + # override receive count with value from custom header + if override := extract_message_count_from_headers(context): + num = override + elif num == -1: + # backdoor to get all messages + num = queue.approx_number_of_messages + elif ( + num < 1 or num > MAX_NUMBER_OF_MESSAGES + ) and not SQS_DISABLE_MAX_NUMBER_OF_MESSAGE_LIMIT: + raise InvalidParameterValueException( + f"Value {num} for parameter MaxNumberOfMessages is invalid. " + f"Reason: Must be between 1 and 10, if provided." + ) + + # we chose to always return the maximum possible number of messages, even though AWS will typically return + # fewer messages than requested on small queues. at some point we could maybe change this to randomly sample + # between 1 and max_number_of_messages. + # see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_ReceiveMessage.html + result = queue.receive( + num, wait_time_seconds, visibility_timeout, poll_empty_queue=poll_empty_queue + ) + + # process dead letter messages + if result.dead_letter_messages: + dead_letter_target_arn = queue.redrive_policy["deadLetterTargetArn"] + dl_queue = self._require_queue_by_arn(context, dead_letter_target_arn) + # TODO: does this need to be atomic? + for standard_message in result.dead_letter_messages: + message = standard_message.message + message["Attributes"][MessageSystemAttributeName.DeadLetterQueueSourceArn] = ( + queue.arn + ) + dl_queue.put( + message=message, + message_deduplication_id=standard_message.message_deduplication_id, + message_group_id=standard_message.message_group_id, + ) + + if isinstance(queue, FifoQueue): + message_group = queue.get_message_group(standard_message.message_group_id) + queue.update_message_group_visibility(message_group) + + # prepare result + messages = [] + message_system_attribute_names = message_system_attribute_names or attribute_names + for i, standard_message in enumerate(result.successful): + message = to_sqs_api_message( + standard_message, message_system_attribute_names, message_attribute_names + ) + message["ReceiptHandle"] = result.receipt_handles[i] + messages.append(message) + + if self._cloudwatch_dispatcher: + self._cloudwatch_dispatcher.dispatch_metric_received(queue, received=len(messages)) + + # TODO: how does receiving behave if the queue was deleted in the meantime? + return ReceiveMessageResult(Messages=(messages if messages else None)) + + def list_dead_letter_source_queues( + self, + context: RequestContext, + queue_url: String, + next_token: Token = None, + max_results: BoxedInteger = None, + **kwargs, + ) -> ListDeadLetterSourceQueuesResult: + urls = [] + store = self.get_store(context.account_id, context.region) + dead_letter_queue = self._resolve_queue(context, queue_url=queue_url) + for queue in store.queues.values(): + if policy := queue.redrive_policy: + if policy.get("deadLetterTargetArn") == dead_letter_queue.arn: + urls.append(queue.url(context)) + return ListDeadLetterSourceQueuesResult(queueUrls=urls) + + def delete_message( + self, context: RequestContext, queue_url: String, receipt_handle: String, **kwargs + ) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + queue.remove(receipt_handle) + if self._cloudwatch_dispatcher: + self._cloudwatch_dispatcher.dispatch_metric_message_deleted(queue) + + def delete_message_batch( + self, + context: RequestContext, + queue_url: String, + entries: DeleteMessageBatchRequestEntryList, + **kwargs, + ) -> DeleteMessageBatchResult: + queue = self._resolve_queue(context, queue_url=queue_url) + override = extract_message_count_from_headers(context) + self._assert_batch(entries, max_messages_override=override) + self._assert_valid_message_ids(entries) + + successful = [] + failed = [] + + with queue.mutex: + for entry in entries: + try: + queue.remove(entry["ReceiptHandle"]) + successful.append(DeleteMessageBatchResultEntry(Id=entry["Id"])) + except Exception as e: + failed.append( + BatchResultErrorEntry( + Id=entry["Id"], + SenderFault=False, + Code=e.__class__.__name__, + Message=str(e), + ) + ) + if self._cloudwatch_dispatcher: + self._cloudwatch_dispatcher.dispatch_metric_message_deleted( + queue, deleted=len(successful) + ) + + return DeleteMessageBatchResult( + Successful=successful, + Failed=failed, + ) + + def purge_queue(self, context: RequestContext, queue_url: String, **kwargs) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + + with queue.mutex: + if config.SQS_DELAY_PURGE_RETRY: + if queue.purge_timestamp and (queue.purge_timestamp + 60) > time.time(): + raise PurgeQueueInProgress( + f"Only one PurgeQueue operation on {queue.name} is allowed every 60 seconds.", + status_code=403, + ) + queue.purge_timestamp = time.time() + queue.clear() + + def set_queue_attributes( + self, context: RequestContext, queue_url: String, attributes: QueueAttributeMap, **kwargs + ) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + + if not attributes: + return + + queue.validate_queue_attributes(attributes) + + for k, v in attributes.items(): + if k in sqs_constants.INTERNAL_QUEUE_ATTRIBUTES: + raise InvalidAttributeName(f"Unknown Attribute {k}.") + queue.attributes[k] = v + + # Special cases + if queue.attributes.get(QueueAttributeName.Policy) == "": + del queue.attributes[QueueAttributeName.Policy] + + redrive_policy = queue.attributes.get(QueueAttributeName.RedrivePolicy) + if redrive_policy == "": + del queue.attributes[QueueAttributeName.RedrivePolicy] + return + + if redrive_policy: + _redrive_policy = json.loads(redrive_policy) + dl_target_arn = _redrive_policy.get("deadLetterTargetArn") + max_receive_count = _redrive_policy.get("maxReceiveCount") + # TODO: use the actual AWS responses + if not dl_target_arn: + raise InvalidParameterValueException( + "The required parameter 'deadLetterTargetArn' is missing" + ) + if max_receive_count is None: + raise InvalidParameterValueException( + "The required parameter 'maxReceiveCount' is missing" + ) + try: + max_receive_count = int(max_receive_count) + valid_count = 1 <= max_receive_count <= 1000 + except ValueError: + valid_count = False + if not valid_count: + raise InvalidParameterValueException( + f"Value {redrive_policy} for parameter RedrivePolicy is invalid. Reason: Invalid value for " + f"maxReceiveCount: {max_receive_count}, valid values are from 1 to 1000 both inclusive." + ) + + def list_message_move_tasks( + self, + context: RequestContext, + source_arn: String, + max_results: NullableInteger = None, + **kwargs, + ) -> ListMessageMoveTasksResult: + try: + self._require_queue_by_arn(context, source_arn) + except InvalidArnException: + raise InvalidParameterValueException( + "You must use this format to specify the SourceArn: arn:::::" + ) + except QueueDoesNotExist: + raise ResourceNotFoundException( + "The resource that you specified for the SourceArn parameter doesn't exist." + ) + + # get move tasks for queue and sort them by most-recent + store = self.get_store(context.account_id, context.region) + tasks = [ + move_task + for move_task in store.move_tasks.values() + if move_task.source_arn == source_arn + and move_task.status != MessageMoveTaskStatus.CREATED + ] + tasks.sort(key=lambda t: t.started_timestamp, reverse=True) + + # convert to result list + n = max_results or 1 + return ListMessageMoveTasksResult( + Results=[self._to_message_move_task_entry(task) for task in tasks[:n]] + ) + + def _to_message_move_task_entry( + self, entity: MessageMoveTask + ) -> ListMessageMoveTasksResultEntry: + """ + Converts a ``MoveMessageTask`` entity into a ``ListMessageMoveTasksResultEntry`` API concept. + + :param entity: the entity to convert + :return: the typed dict for use in the AWS API + """ + entry = ListMessageMoveTasksResultEntry( + SourceArn=entity.source_arn, + DestinationArn=entity.destination_arn, + Status=entity.status, + ) + + if entity.status == "RUNNING": + entry["TaskHandle"] = entity.task_handle + if entity.started_timestamp is not None: + entry["StartedTimestamp"] = int(entity.started_timestamp.timestamp() * 1000) + if entity.max_number_of_messages_per_second is not None: + entry["MaxNumberOfMessagesPerSecond"] = entity.max_number_of_messages_per_second + if entity.approximate_number_of_messages_to_move is not None: + entry["ApproximateNumberOfMessagesToMove"] = ( + entity.approximate_number_of_messages_to_move + ) + if entity.approximate_number_of_messages_moved is not None: + entry["ApproximateNumberOfMessagesMoved"] = entity.approximate_number_of_messages_moved + if entity.failure_reason is not None: + entry["FailureReason"] = entity.failure_reason + + return entry + + def start_message_move_task( + self, + context: RequestContext, + source_arn: String, + destination_arn: String = None, + max_number_of_messages_per_second: NullableInteger = None, + **kwargs, + ) -> StartMessageMoveTaskResult: + try: + self._require_queue_by_arn(context, source_arn) + except QueueDoesNotExist as e: + raise ResourceNotFoundException( + "The resource that you specified for the SourceArn parameter doesn't exist.", + status_code=404, + ) from e + + # check that the source queue is the dlq of some other queue + is_dlq = False + for _, _, store in sqs_stores.iter_stores(): + for queue in store.queues.values(): + if not queue.redrive_policy: + continue + if queue.redrive_policy.get("deadLetterTargetArn") == source_arn: + is_dlq = True + break + if is_dlq: + break + if not is_dlq: + raise InvalidParameterValueException( + "Source queue must be configured as a Dead Letter Queue." + ) + + # If destination_arn is left blank, the messages will be redriven back to their respective original + # source queues. + if destination_arn: + try: + self._require_queue_by_arn(context, destination_arn) + except QueueDoesNotExist as e: + raise ResourceNotFoundException( + "The resource that you specified for the DestinationArn parameter doesn't exist.", + status_code=404, + ) from e + + # check that only one active task exists + with self._message_move_task_manager.mutex: + store = self.get_store(context.account_id, context.region) + tasks = [ + task + for task in store.move_tasks.values() + if task.source_arn == source_arn + and task.status + in [ + MessageMoveTaskStatus.CREATED, + MessageMoveTaskStatus.RUNNING, + MessageMoveTaskStatus.CANCELLING, + ] + ] + if len(tasks) > 0: + raise InvalidParameterValueException( + "There is already a task running. Only one active task is allowed for a source queue " + "arn at a given time." + ) + + task = MessageMoveTask( + source_arn, + destination_arn, + max_number_of_messages_per_second, + ) + store.move_tasks[task.task_id] = task + + self._message_move_task_manager.submit(task) + + return StartMessageMoveTaskResult(TaskHandle=task.task_handle) + + def cancel_message_move_task( + self, context: RequestContext, task_handle: String, **kwargs + ) -> CancelMessageMoveTaskResult: + try: + task_id, source_arn = decode_move_task_handle(task_handle) + except ValueError as e: + raise InvalidParameterValueException( + "Value for parameter TaskHandle is invalid." + ) from e + + try: + self._require_queue_by_arn(context, source_arn) + except QueueDoesNotExist as e: + raise ResourceNotFoundException( + "The resource that you specified for the SourceArn parameter doesn't exist.", + status_code=404, + ) from e + + store = self.get_store(context.account_id, context.region) + try: + move_task = store.move_tasks[task_id] + except KeyError: + raise ResourceNotFoundException("Task does not exist.", status_code=404) + + # TODO: what happens if move tasks are already cancelled? + + self._message_move_task_manager.cancel(move_task) + + return CancelMessageMoveTaskResult( + ApproximateNumberOfMessagesMoved=move_task.approximate_number_of_messages_moved, + ) + + def tag_queue(self, context: RequestContext, queue_url: String, tags: TagMap, **kwargs) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + + if not tags: + return + + for k, v in tags.items(): + queue.tags[k] = v + + def list_queue_tags( + self, context: RequestContext, queue_url: String, **kwargs + ) -> ListQueueTagsResult: + queue = self._resolve_queue(context, queue_url=queue_url) + return ListQueueTagsResult(Tags=(queue.tags if queue.tags else None)) + + def untag_queue( + self, context: RequestContext, queue_url: String, tag_keys: TagKeyList, **kwargs + ) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + + for k in tag_keys: + if k in queue.tags: + del queue.tags[k] + + def add_permission( + self, + context: RequestContext, + queue_url: String, + label: String, + aws_account_ids: AWSAccountIdList, + actions: ActionNameList, + **kwargs, + ) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + + self._validate_actions(actions) + + queue.add_permission(label=label, actions=actions, account_ids=aws_account_ids) + + def remove_permission( + self, context: RequestContext, queue_url: String, label: String, **kwargs + ) -> None: + queue = self._resolve_queue(context, queue_url=queue_url) + + queue.remove_permission(label=label) + + def _create_message_attributes( + self, + context: RequestContext, + message_system_attributes: MessageBodySystemAttributeMap = None, + ) -> Dict[MessageSystemAttributeName, str]: + result: Dict[MessageSystemAttributeName, str] = { + MessageSystemAttributeName.SenderId: context.account_id, # not the account ID in AWS + MessageSystemAttributeName.SentTimestamp: str(now(millis=True)), + } + # we are not using the `context.trace_context` here as it is automatically populated + # AWS only adds the `AWSTraceHeader` attribute if the header is explicitly present + # TODO: check maybe with X-Ray Active mode? + if "X-Amzn-Trace-Id" in context.request.headers: + result[MessageSystemAttributeName.AWSTraceHeader] = str( + context.request.headers["X-Amzn-Trace-Id"] + ) + + if message_system_attributes is not None: + for attr in message_system_attributes: + result[attr] = message_system_attributes[attr]["StringValue"] + + return result + + def _validate_actions(self, actions: ActionNameList): + service = load_service(service=self.service, version=self.version) + # FIXME: this is a bit of a heuristic as it will also include actions like "ListQueues" which is not + # associated with an action on a queue + valid = list(service.operation_names) + valid.append("*") + + for action in actions: + if action not in valid: + raise InvalidParameterValueException( + f"Value SQS:{action} for parameter ActionName is invalid. Reason: Please refer to the appropriate " + "WSDL for a list of valid actions. " + ) + + def _assert_batch( + self, + batch: List, + *, + require_fifo_queue_params: bool = False, + require_message_deduplication_id: bool = False, + max_messages_override: int | None = None, + ) -> None: + if not batch: + raise EmptyBatchRequest + + max_messages_per_batch = max_messages_override or MAX_NUMBER_OF_MESSAGES + if batch and (no_entries := len(batch)) > max_messages_per_batch: + raise TooManyEntriesInBatchRequest( + f"Maximum number of entries per request are {max_messages_per_batch}. You have sent {no_entries}." + ) + visited = set() + for entry in batch: + entry_id = entry["Id"] + if not re.search(r"^[\w-]+$", entry_id) or len(entry_id) > 80: + raise InvalidBatchEntryId( + "A batch entry id can only contain alphanumeric characters, hyphens and underscores. " + "It can be at most 80 letters long." + ) + if require_message_deduplication_id and not entry.get("MessageDeduplicationId"): + raise InvalidParameterValueException( + "The queue should either have ContentBasedDeduplication enabled or " + "MessageDeduplicationId provided explicitly" + ) + if require_fifo_queue_params and not entry.get("MessageGroupId"): + raise InvalidParameterValueException( + "The request must contain the parameter MessageGroupId." + ) + if entry_id in visited: + raise BatchEntryIdsNotDistinct() + else: + visited.add(entry_id) + + def _assert_valid_batch_size(self, batch: List, max_message_size: int): + batch_message_size = sum( + _message_body_size(entry.get("MessageBody")) + + _message_attributes_size(entry.get("MessageAttributes")) + for entry in batch + ) + if batch_message_size > max_message_size: + error = f"Batch requests cannot be longer than {max_message_size} bytes." + error += f" You have sent {batch_message_size} bytes." + raise BatchRequestTooLong(error) + + def _assert_valid_message_ids(self, batch: List): + batch_id_regex = r"^[\w-]{1,80}$" + for message in batch: + if not re.match(batch_id_regex, message.get("Id", "")): + raise InvalidBatchEntryId( + "A batch entry id can only contain alphanumeric characters, " + "hyphens and underscores. It can be at most 80 letters long." + ) + + def _init_cloudwatch_metrics_reporting(self): + self.cloudwatch_disabled: bool = ( + config.SQS_DISABLE_CLOUDWATCH_METRICS or not is_api_enabled("cloudwatch") + ) + + self._cloudwatch_publish_worker = ( + None if self.cloudwatch_disabled else CloudwatchPublishWorker() + ) + self._cloudwatch_dispatcher = None if self.cloudwatch_disabled else CloudwatchDispatcher() + + def _start_cloudwatch_metrics_reporting(self): + if not self.cloudwatch_disabled: + self._cloudwatch_publish_worker.start() + + def _stop_cloudwatch_metrics_reporting(self): + if not self.cloudwatch_disabled: + self._cloudwatch_publish_worker.stop() + self._cloudwatch_dispatcher.shutdown() + + +# Method from moto's attribute_md5 of moto/sqs/models.py, separated from the Message Object +def _create_message_attribute_hash(message_attributes) -> Optional[str]: + # To avoid the need to check for dict conformity everytime we invoke this function + if not isinstance(message_attributes, dict): + return + hash = hashlib.md5() + + for attrName in sorted(message_attributes.keys()): + attr_value = message_attributes[attrName] + # Encode name + MotoMessage.update_binary_length_and_value(hash, MotoMessage.utf8(attrName)) + # Encode data type + MotoMessage.update_binary_length_and_value(hash, MotoMessage.utf8(attr_value["DataType"])) + # Encode transport type and value + if attr_value.get("StringValue"): + hash.update(bytearray([STRING_TYPE_FIELD_INDEX])) + MotoMessage.update_binary_length_and_value( + hash, MotoMessage.utf8(attr_value.get("StringValue")) + ) + elif attr_value.get("BinaryValue"): + hash.update(bytearray([BINARY_TYPE_FIELD_INDEX])) + decoded_binary_value = attr_value.get("BinaryValue") + MotoMessage.update_binary_length_and_value(hash, decoded_binary_value) + # string_list_value, binary_list_value type is not implemented, reserved for the future use. + # See https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_MessageAttributeValue.html + return hash.hexdigest() + + +def resolve_queue_location( + context: RequestContext, queue_name: Optional[str] = None, queue_url: Optional[str] = None +) -> Tuple[str, Optional[str], str]: + """ + Resolves a queue location from the given information. + + :param context: the request context, used for getting region and account_id, and optionally the queue_url + :param queue_name: the queue name (if this is set, then this will be used for the key) + :param queue_url: the queue url (if name is not set, this will be used to determine the queue name) + :return: tuple of account id, region and queue_name + """ + if not queue_name: + try: + if queue_url: + return parse_queue_url(queue_url) + else: + return parse_queue_url(context.request.base_url) + except ValueError: + # should work if queue name is passed in QueueUrl + return context.account_id, context.region, queue_url + + return context.account_id, context.region, queue_name + + +def to_sqs_api_message( + standard_message: SqsMessage, + attribute_names: AttributeNameList = None, + message_attribute_names: MessageAttributeNameList = None, +) -> Message: + """ + Utility function to convert an SQS message from LocalStack's internal representation to the AWS API + concept 'Message', which is the format returned by the ``ReceiveMessage`` operation. + + :param standard_message: A LocalStack SQS message + :param attribute_names: the attribute name list to filter + :param message_attribute_names: the message attribute names to filter + :return: a copy of the original Message with updated message attributes and MD5 attribute hash sums + """ + # prepare message for receiver + message = copy.deepcopy(standard_message.message) + + # update system attributes of the message copy + message["Attributes"][MessageSystemAttributeName.ApproximateFirstReceiveTimestamp] = str( + int((standard_message.first_received or 0) * 1000) + ) + + # filter attributes for receiver + message_filter_attributes(message, attribute_names) + message_filter_message_attributes(message, message_attribute_names) + if message.get("MessageAttributes"): + message["MD5OfMessageAttributes"] = _create_message_attribute_hash( + message["MessageAttributes"] + ) + else: + # delete the value that was computed when creating the message + message.pop("MD5OfMessageAttributes", None) + return message + + +def message_filter_attributes(message: Message, names: Optional[AttributeNameList]): + """ + Utility function filter from the given message (in-place) the system attributes from the given list. It will + apply all rules according to: + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sqs.html#SQS.Client.receive_message. + + :param message: The message to filter (it will be modified) + :param names: the attributes names/filters + """ + if "Attributes" not in message: + return + + if not names: + del message["Attributes"] + return + + if QueueAttributeName.All in names: + return + + for k in list(message["Attributes"].keys()): + if k not in names: + del message["Attributes"][k] + + +def message_filter_message_attributes(message: Message, names: Optional[MessageAttributeNameList]): + """ + Utility function filter from the given message (in-place) the message attributes from the given list. It will + apply all rules according to: + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sqs.html#SQS.Client.receive_message. + + :param message: The message to filter (it will be modified) + :param names: the attributes names/filters (can be 'All', '.*', '*' or prefix filters like 'Foo.*') + """ + if not message.get("MessageAttributes"): + return + + if not names: + del message["MessageAttributes"] + return + + if "All" in names or ".*" in names or "*" in names: + return + + attributes = message["MessageAttributes"] + matched = [] + + keys = [name for name in names if ".*" not in name] + prefixes = [name.split(".*")[0] for name in names if ".*" in name] + + # match prefix filters + for k in attributes: + if k in keys: + matched.append(k) + continue + + for prefix in prefixes: + if k.startswith(prefix): + matched.append(k) + break + if matched: + message["MessageAttributes"] = {k: attributes[k] for k in matched} + else: + message.pop("MessageAttributes") + + +def extract_message_count_from_headers(context: RequestContext) -> int | None: + if override := context.request.headers.get( + HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT, default=None, type=int + ): + return override + + return None + + +def extract_wait_time_seconds_from_headers(context: RequestContext) -> int | None: + if override := context.request.headers.get( + HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS, default=None, type=int + ): + return override + + return None diff --git a/localstack-core/localstack/services/sqs/query_api.py b/localstack-core/localstack/services/sqs/query_api.py new file mode 100644 index 0000000000000..6d5a33ee4bd5d --- /dev/null +++ b/localstack-core/localstack/services/sqs/query_api.py @@ -0,0 +1,226 @@ +"""The SQS Query API allows using Queue URLs as endpoints for operations on that queue. See: +https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-making-api-requests.html. This is a +generic implementation that creates from Query API requests the respective AWS requests, and uses an aws_stack client +to make the request.""" + +import logging +from typing import Dict, Optional, Tuple +from urllib.parse import urlencode + +from botocore.exceptions import ClientError +from botocore.model import OperationModel +from werkzeug.datastructures import Headers +from werkzeug.exceptions import NotFound + +from localstack.aws.api import CommonServiceException +from localstack.aws.connect import connect_to +from localstack.aws.protocol.parser import OperationNotFoundParserError, create_parser +from localstack.aws.protocol.serializer import create_serializer +from localstack.aws.protocol.validate import MissingRequiredField, validate_request +from localstack.aws.spec import load_service +from localstack.constants import ( + AWS_REGION_US_EAST_1, + INTERNAL_AWS_ACCESS_KEY_ID, + INTERNAL_AWS_SECRET_ACCESS_KEY, +) +from localstack.http import Request, Response, Router, route +from localstack.http.dispatcher import Handler +from localstack.services.sqs.exceptions import MissingRequiredParameterException +from localstack.utils.aws.request_context import ( + extract_access_key_id_from_auth_header, + extract_region_from_headers, +) +from localstack.utils.strings import long_uid + +LOG = logging.getLogger(__name__) + +service = load_service("sqs-query") +parser = create_parser(service) +serializer = create_serializer(service) + + +@route( + '//', + host='sqs.', + methods=["POST", "GET"], +) +def standard_strategy_handler( + request: Request, + account_id: str, + queue_name: str, + region: str = None, + domain: str = None, + port: int = None, +): + """ + Handler for modern-style endpoints which always have the region encoded. + See https://docs.aws.amazon.com/general/latest/gr/sqs-service.html + """ + return handle_request(request, region.rstrip(".")) + + +@route( + '/queue///', + methods=["POST", "GET"], +) +def path_strategy_handler(request: Request, region, account_id: str, queue_name: str): + return handle_request(request, region) + + +@route( + '//', + host='queue.', + methods=["POST", "GET"], +) +def domain_strategy_handler( + request: Request, + account_id: str, + queue_name: str, + region: str = None, + domain: str = None, + port: int = None, +): + """Uses the endpoint host to extract the region. See: + https://docs.aws.amazon.com/general/latest/gr/sqs-service.html""" + if not region: + region = AWS_REGION_US_EAST_1 + else: + region = region.rstrip(".") + + return handle_request(request, region) + + +@route( + '//', + methods=["POST", "GET"], +) +def legacy_handler(request: Request, account_id: str, queue_name: str) -> Response: + # previously, Queue URLs were created as http://localhost:4566/000000000000/my-queue-name. Because the region is + # ambiguous in this request, we fall back to the region that the request is coming from (this is not how AWS + # behaves though). + if "X-Amz-Credential" in request.args: + region = request.args["X-Amz-Credential"].split("/")[2] + else: + region = extract_region_from_headers(request.headers) + + LOG.debug( + "Region of queue URL %s is ambiguous, got region %s from request", request.url, region + ) + + return handle_request(request, region) + + +def register(router: Router[Handler]): + """ + Registers the query API handlers into the given router. There are four routes, one for each SQS_ENDPOINT_STRATEGY. + + :param router: the router to add the handlers into. + """ + router.add(standard_strategy_handler) + router.add(path_strategy_handler) + router.add(domain_strategy_handler) + router.add(legacy_handler) + + +class UnknownOperationException(Exception): + pass + + +class InvalidAction(CommonServiceException): + def __init__(self, action: str): + super().__init__( + "InvalidAction", + f"The action {action} is not valid for this endpoint.", + 400, + sender_fault=True, + ) + + +class BotoException(CommonServiceException): + def __init__(self, boto_response): + error = boto_response["Error"] + super().__init__( + code=error.get("Code", "UnknownError"), + status_code=boto_response["ResponseMetadata"]["HTTPStatusCode"], + message=error.get("Message", ""), + sender_fault=error.get("Type", "Sender") == "Sender", + ) + + +def handle_request(request: Request, region: str) -> Response: + # some SDK (PHP) still send requests to the Queue URL even though the JSON spec does not allow it in the + # documentation. If the request is `json`, raise `NotFound` so that we continue the handler chain and the provider + # can handle the request + if request.headers.get("Content-Type", "").lower() == "application/x-amz-json-1.0": + raise NotFound + + request_id = long_uid() + + try: + response, operation = try_call_sqs(request, region) + del response["ResponseMetadata"] + return serializer.serialize_to_response(response, operation, request.headers, request_id) + except UnknownOperationException: + return Response("", 404) + except CommonServiceException as e: + # use a dummy operation for the serialization to work + op = service.operation_model(service.operation_names[0]) + return serializer.serialize_error_to_response(e, op, request.headers, request_id) + except Exception as e: + LOG.exception("exception") + op = service.operation_model(service.operation_names[0]) + return serializer.serialize_error_to_response( + CommonServiceException( + "InternalError", f"An internal error occurred: {e}", status_code=500 + ), + op, + request.headers, + request_id, + ) + + +def try_call_sqs(request: Request, region: str) -> Tuple[Dict, OperationModel]: + action = request.values.get("Action") + if not action: + raise UnknownOperationException() + + if action in ["ListQueues", "CreateQueue"]: + raise InvalidAction(action) + + # prepare aws request for the SQS query protocol (POST request with action url-encoded in the body) + params = {"QueueUrl": request.base_url} + # if a QueueUrl is already set in the body, it should overwrite the one in the URL. this behavior is validated + # against AWS (see TestSqsQueryApi) + params.update(request.values) + body = urlencode(params) + + try: + headers = Headers(request.headers) + headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8" + operation, service_request = parser.parse(Request("POST", "/", headers=headers, body=body)) + validate_request(operation, service_request).raise_first() + except OperationNotFoundParserError: + raise InvalidAction(action) + except MissingRequiredField as e: + raise MissingRequiredParameterException( + f"The request must contain the parameter {e.required_name}." + ) + + # Extract from auth header to allow cross-account operations + # TODO: permissions encoded in URL as AUTHPARAMS cannot be accounted for in this method, which is not a big + # problem yet since we generally don't enforce permissions. + account_id: Optional[str] = extract_access_key_id_from_auth_header(headers) + + client = connect_to( + region_name=region, + aws_access_key_id=account_id or INTERNAL_AWS_ACCESS_KEY_ID, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + ).sqs_query + + try: + # using the layer below boto3.client("sqs").(...) to make the call + boto_response = client._make_api_call(operation.name, service_request) + except ClientError as e: + raise BotoException(e.response) from e + + return boto_response, operation diff --git a/localstack-core/localstack/services/sqs/queue.py b/localstack-core/localstack/services/sqs/queue.py new file mode 100644 index 0000000000000..dc3b5e8d88f70 --- /dev/null +++ b/localstack-core/localstack/services/sqs/queue.py @@ -0,0 +1,50 @@ +import time +from queue import Empty, PriorityQueue, Queue + + +class InterruptibleQueue(Queue): + # is_shutdown is used to check whether we have triggered a shutdown of the Queue + is_shutdown: bool + + def __init__(self, maxsize=0): + super().__init__(maxsize) + self.is_shutdown = False + + def get(self, block=True, timeout=None): + with self.not_empty: + if self.is_shutdown: + raise Empty + if not block: + if not self._qsize(): + raise Empty + elif timeout is None: + while not self._qsize() and not self.is_shutdown: # additional shutdown check + self.not_empty.wait() + elif timeout < 0: + raise ValueError("'timeout' must be a non-negative number") + else: + endtime = time.time() + timeout + while not self._qsize() and not self.is_shutdown: # additional shutdown check + remaining = endtime - time.time() + if remaining <= 0.0: + raise Empty + self.not_empty.wait(remaining) + if self.is_shutdown: # additional shutdown check + raise Empty + item = self._get() + self.not_full.notify() + return item + + def shutdown(self): + """ + `shutdown` signals to stop all current and future `Queue.get` calls from executing. + + This is helpful for exiting otherwise blocking calls early. + """ + with self.not_empty: + self.is_shutdown = True + self.not_empty.notify_all() + + +class InterruptiblePriorityQueue(PriorityQueue, InterruptibleQueue): + pass diff --git a/localstack-core/localstack/services/sqs/resource_providers/__init__.py b/localstack-core/localstack/services/sqs/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue.py b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue.py new file mode 100644 index 0000000000000..52b39da351d96 --- /dev/null +++ b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue.py @@ -0,0 +1,263 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SQSQueueProperties(TypedDict): + Arn: Optional[str] + ContentBasedDeduplication: Optional[bool] + DeduplicationScope: Optional[str] + DelaySeconds: Optional[int] + FifoQueue: Optional[bool] + FifoThroughputLimit: Optional[str] + KmsDataKeyReusePeriodSeconds: Optional[int] + KmsMasterKeyId: Optional[str] + MaximumMessageSize: Optional[int] + MessageRetentionPeriod: Optional[int] + QueueName: Optional[str] + QueueUrl: Optional[str] + ReceiveMessageWaitTimeSeconds: Optional[int] + RedriveAllowPolicy: Optional[dict | str] + RedrivePolicy: Optional[dict | str] + SqsManagedSseEnabled: Optional[bool] + Tags: Optional[list[Tag]] + VisibilityTimeout: Optional[int] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + +_queue_attribute_list = [ + "ContentBasedDeduplication", + "DeduplicationScope", + "DelaySeconds", + "FifoQueue", + "FifoThroughputLimit", + "KmsDataKeyReusePeriodSeconds", + "KmsMasterKeyId", + "MaximumMessageSize", + "MessageRetentionPeriod", + "ReceiveMessageWaitTimeSeconds", + "RedriveAllowPolicy", + "RedrivePolicy", + "SqsManagedSseEnabled", + "VisibilityTimeout", +] + + +class SQSQueueProvider(ResourceProvider[SQSQueueProperties]): + TYPE = "AWS::SQS::Queue" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SQSQueueProperties], + ) -> ProgressEvent[SQSQueueProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/QueueUrl + + + + Create-only properties: + - /properties/FifoQueue + - /properties/QueueName + + Read-only properties: + - /properties/QueueUrl + - /properties/Arn + + IAM permissions required: + - sqs:CreateQueue + - sqs:GetQueueUrl + - sqs:GetQueueAttributes + - sqs:ListQueueTags + - sqs:TagQueue + + """ + # TODO: validations + model = request.desired_state + sqs = request.aws_client_factory.sqs + + if model.get("FifoQueue", False): + model["FifoQueue"] = model["FifoQueue"] + + queue_name = model.get("QueueName") + if not queue_name: + # TODO: verify patterns here + if model.get("FifoQueue"): + queue_name = util.generate_default_name( + request.stack_name, request.logical_resource_id + )[:-5] + queue_name = f"{queue_name}.fifo" + else: + queue_name = util.generate_default_name( + request.stack_name, request.logical_resource_id + ) + model["QueueName"] = queue_name + + attributes = self._compile_sqs_queue_attributes(model) + result = request.aws_client_factory.sqs.create_queue( + QueueName=model["QueueName"], + Attributes=attributes, + tags={t["Key"]: t["Value"] for t in model.get("Tags", [])}, + ) + + # set read-only properties + model["QueueUrl"] = result["QueueUrl"] + model["Arn"] = sqs.get_queue_attributes( + QueueUrl=result["QueueUrl"], AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SQSQueueProperties], + ) -> ProgressEvent[SQSQueueProperties]: + """ + Fetch resource information + + IAM permissions required: + - sqs:GetQueueAttributes + - sqs:ListQueueTags + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SQSQueueProperties], + ) -> ProgressEvent[SQSQueueProperties]: + """ + Delete a resource + + IAM permissions required: + - sqs:DeleteQueue + - sqs:GetQueueAttributes + """ + sqs = request.aws_client_factory.sqs + try: + queue_url = sqs.get_queue_url(QueueName=request.previous_state["QueueName"])["QueueUrl"] + sqs.delete_queue(QueueUrl=queue_url) + + except sqs.exceptions.QueueDoesNotExist: + return ProgressEvent( + status=OperationStatus.SUCCESS, resource_model=request.desired_state + ) + + return ProgressEvent(status=OperationStatus.SUCCESS, resource_model=request.desired_state) + + def update( + self, + request: ResourceRequest[SQSQueueProperties], + ) -> ProgressEvent[SQSQueueProperties]: + """ + Update a resource + + IAM permissions required: + - sqs:SetQueueAttributes + - sqs:GetQueueAttributes + - sqs:ListQueueTags + - sqs:TagQueue + - sqs:UntagQueue + """ + sqs = request.aws_client_factory.sqs + model = request.desired_state + + assert request.previous_state is not None + + should_replace = ( + request.desired_state.get("QueueName", request.previous_state["QueueName"]) + != request.previous_state["QueueName"] + ) or ( + request.desired_state.get("FifoQueue", request.previous_state.get("FifoQueue")) + != request.previous_state.get("FifoQueue") + ) + + if not should_replace: + return ProgressEvent(OperationStatus.SUCCESS, resource_model=request.previous_state) + + # TODO: copied from the create handler, extract? + if model.get("FifoQueue"): + queue_name = util.generate_default_name( + request.stack_name, request.logical_resource_id + )[:-5] + queue_name = f"{queue_name}.fifo" + else: + queue_name = util.generate_default_name(request.stack_name, request.logical_resource_id) + + # replacement (TODO: find out if we should handle this in the provider or outside of it) + # delete old queue + sqs.delete_queue(QueueUrl=request.previous_state["QueueUrl"]) + # create new queue (TODO: re-use create logic to make this more robust, e.g. for + # auto-generated queue names) + model["QueueUrl"] = sqs.create_queue(QueueName=queue_name)["QueueUrl"] + model["Arn"] = sqs.get_queue_attributes( + QueueUrl=model["QueueUrl"], AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + return ProgressEvent(OperationStatus.SUCCESS, resource_model=model) + + def _compile_sqs_queue_attributes(self, properties: SQSQueueProperties) -> dict[str, str]: + """ + SQS is really awkward in how the ``CreateQueue`` operation expects arguments. Most of a Queue's + attributes are passed as a string values in the "Attributes" dictionary. So we need to compile this + dictionary here. + + :param properties: the properties passed from cloudformation + :return: a mapping used for the ``Attributes`` argument of the `CreateQueue` call. + """ + result = {} + + for k in _queue_attribute_list: + v = properties.get(k) + + if v is None: + continue + elif isinstance(v, str): + pass + elif isinstance(v, bool): + v = str(v).lower() + elif isinstance(v, dict): + # RedrivePolicy and RedriveAllowPolicy + v = json.dumps(v) + elif isinstance(v, int): + v = str(v) + else: + raise TypeError(f"cannot convert attribute {k}, unhandled type {type(v)}") + + result[k] = v + + return result + + def list( + self, + request: ResourceRequest[SQSQueueProperties], + ) -> ProgressEvent[SQSQueueProperties]: + resources = request.aws_client_factory.sqs.list_queues() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + SQSQueueProperties(QueueUrl=url) for url in resources.get("QueueUrls", []) + ], + ) diff --git a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue.schema.json b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue.schema.json new file mode 100644 index 0000000000000..0756d0bfb2b07 --- /dev/null +++ b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue.schema.json @@ -0,0 +1,166 @@ +{ + "typeName": "AWS::SQS::Queue", + "description": "Resource Type definition for AWS::SQS::Queue", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-sqs.git", + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Key": { + "description": "The key name of the tag. You can specify a value that is 1 to 128 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "type": "string" + }, + "Value": { + "description": "The value for the tag. You can specify a value that is 0 to 256 Unicode characters in length and cannot be prefixed with aws:. You can use any of the following characters: the set of Unicode letters, digits, whitespace, _, ., /, =, +, and -.", + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "properties": { + "QueueUrl": { + "type": "string", + "description": "URL of the source queue." + }, + "Arn": { + "type": "string", + "description": "Amazon Resource Name (ARN) of the queue." + }, + "ContentBasedDeduplication": { + "type": "boolean", + "description": "For first-in-first-out (FIFO) queues, specifies whether to enable content-based deduplication. During the deduplication interval, Amazon SQS treats messages that are sent with identical content as duplicates and delivers only one copy of the message." + }, + "DeduplicationScope": { + "description": "Specifies whether message deduplication occurs at the message group or queue level. Valid values are messageGroup and queue.", + "type": "string" + }, + "DelaySeconds": { + "type": "integer", + "description": "The time in seconds for which the delivery of all messages in the queue is delayed. You can specify an integer value of 0 to 900 (15 minutes). The default value is 0." + }, + "FifoQueue": { + "type": "boolean", + "description": "If set to true, creates a FIFO queue. If you don't specify this property, Amazon SQS creates a standard queue." + }, + "FifoThroughputLimit": { + "description": "Specifies whether the FIFO queue throughput quota applies to the entire queue or per message group. Valid values are perQueue and perMessageGroupId. The perMessageGroupId value is allowed only when the value for DeduplicationScope is messageGroup.", + "type": "string" + }, + "KmsDataKeyReusePeriodSeconds": { + "type": "integer", + "description": "The length of time in seconds for which Amazon SQS can reuse a data key to encrypt or decrypt messages before calling AWS KMS again. The value must be an integer between 60 (1 minute) and 86,400 (24 hours). The default is 300 (5 minutes)." + }, + "KmsMasterKeyId": { + "type": "string", + "description": "The ID of an AWS managed customer master key (CMK) for Amazon SQS or a custom CMK. To use the AWS managed CMK for Amazon SQS, specify the (default) alias alias/aws/sqs." + }, + "SqsManagedSseEnabled": { + "type": "boolean", + "description": "Enables server-side queue encryption using SQS owned encryption keys. Only one server-side encryption option is supported per queue (e.g. SSE-KMS or SSE-SQS )." + }, + "MaximumMessageSize": { + "type": "integer", + "description": "The limit of how many bytes that a message can contain before Amazon SQS rejects it. You can specify an integer value from 1,024 bytes (1 KiB) to 262,144 bytes (256 KiB). The default value is 262,144 (256 KiB)." + }, + "MessageRetentionPeriod": { + "type": "integer", + "description": "The number of seconds that Amazon SQS retains a message. You can specify an integer value from 60 seconds (1 minute) to 1,209,600 seconds (14 days). The default value is 345,600 seconds (4 days)." + }, + "QueueName": { + "type": "string", + "description": "A name for the queue. To create a FIFO queue, the name of your FIFO queue must end with the .fifo suffix." + }, + "ReceiveMessageWaitTimeSeconds": { + "type": "integer", + "description": "Specifies the duration, in seconds, that the ReceiveMessage action call waits until a message is in the queue in order to include it in the response, rather than returning an empty response if a message isn't yet available. You can specify an integer from 1 to 20. Short polling is used as the default or when you specify 0 for this property." + }, + "RedriveAllowPolicy": { + "type": [ + "object", + "string" + ], + "description": "The string that includes the parameters for the permissions for the dead-letter queue redrive permission and which source queues can specify dead-letter queues as a JSON object." + }, + "RedrivePolicy": { + "type": [ + "object", + "string" + ], + "description": "A string that includes the parameters for the dead-letter queue functionality (redrive policy) of the source queue." + }, + "Tags": { + "type": "array", + "description": "The tags that you attach to this queue.", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "VisibilityTimeout": { + "type": "integer", + "description": "The length of time during which a message will be unavailable after a message is delivered from the queue. This blocks other components from receiving the same message and gives the initial component time to process and delete the message from the queue. Values must be from 0 to 43,200 seconds (12 hours). If you don't specify a value, AWS CloudFormation uses the default value of 30 seconds." + } + }, + "additionalProperties": false, + "readOnlyProperties": [ + "/properties/QueueUrl", + "/properties/Arn" + ], + "primaryIdentifier": [ + "/properties/QueueUrl" + ], + "createOnlyProperties": [ + "/properties/FifoQueue", + "/properties/QueueName" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "handlers": { + "create": { + "permissions": [ + "sqs:CreateQueue", + "sqs:GetQueueUrl", + "sqs:GetQueueAttributes", + "sqs:ListQueueTags", + "sqs:TagQueue" + ] + }, + "read": { + "permissions": [ + "sqs:GetQueueAttributes", + "sqs:ListQueueTags" + ] + }, + "update": { + "permissions": [ + "sqs:SetQueueAttributes", + "sqs:GetQueueAttributes", + "sqs:ListQueueTags", + "sqs:TagQueue", + "sqs:UntagQueue" + ] + }, + "delete": { + "permissions": [ + "sqs:DeleteQueue", + "sqs:GetQueueAttributes" + ] + }, + "list": { + "permissions": [ + "sqs:ListQueues" + ] + } + } +} diff --git a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue_plugin.py b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue_plugin.py new file mode 100644 index 0000000000000..45c892bc5dade --- /dev/null +++ b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queue_plugin.py @@ -0,0 +1,18 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SQSQueueProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SQS::Queue" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.sqs.resource_providers.aws_sqs_queue import SQSQueueProvider + + self.factory = SQSQueueProvider diff --git a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py new file mode 100644 index 0000000000000..cc7bdecfa9254 --- /dev/null +++ b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.py @@ -0,0 +1,110 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SQSQueuePolicyProperties(TypedDict): + PolicyDocument: Optional[dict] + Queues: Optional[list[str]] + Id: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SQSQueuePolicyProvider(ResourceProvider[SQSQueuePolicyProperties]): + TYPE = "AWS::SQS::QueuePolicy" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SQSQueuePolicyProperties], + ) -> ProgressEvent[SQSQueuePolicyProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - PolicyDocument + - Queues + + Read-only properties: + - /properties/Id + + """ + model = request.desired_state + sqs = request.aws_client_factory.sqs + for queue in model.get("Queues", []): + policy = json.dumps(model["PolicyDocument"]) + sqs.set_queue_attributes(QueueUrl=queue, Attributes={"Policy": policy}) + + physical_resource_id = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + model["Id"] = physical_resource_id + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SQSQueuePolicyProperties], + ) -> ProgressEvent[SQSQueuePolicyProperties]: + """ + Fetch resource information + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SQSQueuePolicyProperties], + ) -> ProgressEvent[SQSQueuePolicyProperties]: + """ + Delete a resource + """ + sqs = request.aws_client_factory.sqs + for queue in request.previous_state["Queues"]: + try: + sqs.set_queue_attributes(QueueUrl=queue, Attributes={"Policy": ""}) + + except sqs.exceptions.QueueDoesNotExist: + return ProgressEvent(status=OperationStatus.FAILED, resource_model={}) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model={}, + ) + + def update( + self, + request: ResourceRequest[SQSQueuePolicyProperties], + ) -> ProgressEvent[SQSQueuePolicyProperties]: + """ + Update a resource + """ + model = request.desired_state + sqs = request.aws_client_factory.sqs + for queue in model.get("Queues", []): + policy = json.dumps(model["PolicyDocument"]) + sqs.set_queue_attributes(QueueUrl=queue, Attributes={"Policy": policy}) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=request.desired_state, + ) diff --git a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.schema.json b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.schema.json new file mode 100644 index 0000000000000..654910643709d --- /dev/null +++ b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy.schema.json @@ -0,0 +1,30 @@ +{ + "typeName": "AWS::SQS::QueuePolicy", + "description": "Resource Type definition for AWS::SQS::QueuePolicy", + "additionalProperties": false, + "properties": { + "Id": { + "type": "string" + }, + "PolicyDocument": { + "type": "object" + }, + "Queues": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + } + }, + "required": [ + "PolicyDocument", + "Queues" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy_plugin.py b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy_plugin.py new file mode 100644 index 0000000000000..fc6ce346cf5e3 --- /dev/null +++ b/localstack-core/localstack/services/sqs/resource_providers/aws_sqs_queuepolicy_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SQSQueuePolicyProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SQS::QueuePolicy" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.sqs.resource_providers.aws_sqs_queuepolicy import ( + SQSQueuePolicyProvider, + ) + + self.factory = SQSQueuePolicyProvider diff --git a/localstack-core/localstack/services/sqs/utils.py b/localstack-core/localstack/services/sqs/utils.py new file mode 100644 index 0000000000000..a280128ad7b66 --- /dev/null +++ b/localstack-core/localstack/services/sqs/utils.py @@ -0,0 +1,186 @@ +import base64 +import itertools +import json +import re +import time +from typing import Literal, NamedTuple, Optional, Tuple +from urllib.parse import urlparse + +from localstack.aws.api.sqs import QueueAttributeName, ReceiptHandleIsInvalid +from localstack.services.sqs.constants import ( + DOMAIN_STRATEGY_URL_REGEX, + LEGACY_STRATEGY_URL_REGEX, + PATH_STRATEGY_URL_REGEX, + STANDARD_STRATEGY_URL_REGEX, +) +from localstack.utils.aws.arns import parse_arn +from localstack.utils.objects import singleton_factory +from localstack.utils.strings import base64_decode, long_uid, to_bytes, to_str + +STANDARD_ENDPOINT = re.compile(STANDARD_STRATEGY_URL_REGEX) +DOMAIN_ENDPOINT = re.compile(DOMAIN_STRATEGY_URL_REGEX) +PATH_ENDPOINT = re.compile(PATH_STRATEGY_URL_REGEX) +LEGACY_ENDPOINT = re.compile(LEGACY_STRATEGY_URL_REGEX) + + +def is_sqs_queue_url(url: str) -> bool: + return any( + [ + STANDARD_ENDPOINT.search(url), + DOMAIN_ENDPOINT.search(url), + PATH_ENDPOINT.search(url), + LEGACY_ENDPOINT.search(url), + ] + ) + + +def guess_endpoint_strategy_and_host( + host: str, +) -> Tuple[Literal["standard", "domain", "path"], str]: + """ + This method is used for the dynamic endpoint strategy. It heuristically determines a tuple where the first + element is the endpoint strategy, and the second is the part of the host after the endpoint prefix and region. + For instance: + + * ``sqs.us-east-1.localhost.localstack.cloud`` -> ``standard, localhost.localstack.cloud`` + * ``queue.localhost.localstack.cloud:4566`` -> ``domain, localhost.localstack.cloud:4566`` + * ``us-east-2.queue.amazonaws.com`` -> ``domain, amazonaws.com`` + * ``localhost:4566`` -> ``path, localhost:443`` + * ``amazonaws.com`` -> ``path, amazonaws.com`` + + :param host: the original host in the request + :return: endpoint strategy, host segment + """ + components = host.split(".") + + if host.startswith("sqs."): + return "standard", ".".join(components[2:]) + + if host.startswith("queue."): + return "domain", ".".join(components[1:]) + + if len(components) > 2 and components[1] == "queue": + return "domain", ".".join(components[2:]) + + return "path", host + + +def is_message_deduplication_id_required(queue): + content_based_deduplication_disabled = ( + "false" + == (queue.attributes.get(QueueAttributeName.ContentBasedDeduplication, "false")).lower() + ) + return is_fifo_queue(queue) and content_based_deduplication_disabled + + +def is_fifo_queue(queue): + return "true" == queue.attributes.get(QueueAttributeName.FifoQueue, "false").lower() + + +def parse_queue_url(queue_url: str) -> Tuple[str, Optional[str], str]: + """ + Parses an SQS Queue URL and returns a triple of account_id, region and queue_name. + + :param queue_url: the queue URL + :return: account_id, region (may be None), queue_name + """ + url = urlparse(queue_url.rstrip("/")) + path_parts = url.path.lstrip("/").split("/") + domain_parts = url.netloc.split(".") + + if len(path_parts) != 2 and len(path_parts) != 4: + raise ValueError(f"Not a valid queue URL: {queue_url}") + + account_id, queue_name = path_parts[-2:] + + if len(path_parts) == 4: + if path_parts[0] != "queue": + raise ValueError(f"Not a valid queue URL: {queue_url}") + # SQS_ENDPOINT_STRATEGY == "path" + region = path_parts[1] + elif url.netloc.startswith("sqs."): + # SQS_ENDPOINT_STRATEGY == "standard" + region = domain_parts[1] + elif ".queue." in url.netloc: + if domain_parts[1] != "queue": + # .queue. should be on second position after the region + raise ValueError(f"Not a valid queue URL: {queue_url}") + # SQS_ENDPOINT_STRATEGY == "domain" + region = domain_parts[0] + elif url.netloc.startswith("queue"): + # SQS_ENDPOINT_STRATEGY == "domain" (with default region) + region = "us-east-1" + else: + region = None + + return account_id, region, queue_name + + +class ReceiptHandleInformation(NamedTuple): + identifier: str + queue_arn: str + message_id: str + last_received: str + + +def extract_receipt_handle_info(receipt_handle: str) -> ReceiptHandleInformation: + try: + handle = base64.b64decode(receipt_handle).decode("utf-8") + parts = handle.split(" ") + if len(parts) != 4: + raise ValueError(f'The input receipt handle "{receipt_handle}" is incomplete.') + parse_arn(parts[1]) + return ReceiptHandleInformation(*parts) + except (IndexError, ValueError) as e: + raise ReceiptHandleIsInvalid( + f'The input receipt handle "{receipt_handle}" is not a valid receipt handle.' + ) from e + + +def encode_receipt_handle(queue_arn, message) -> str: + # http://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/ImportantIdentifiers.html#ImportantIdentifiers-receipt-handles + # encode the queue arn in the receipt handle, so we can later check if it belongs to the queue + # but also add some randomness s.t. the generated receipt handles look like the ones from AWS + handle = f"{long_uid()} {queue_arn} {message.message.get('MessageId')} {message.last_received}" + encoded = base64.b64encode(handle.encode("utf-8")) + return encoded.decode("utf-8") + + +def encode_move_task_handle(task_id: str, source_arn: str) -> str: + """ + Move task handles are base64 encoded JSON dictionaries containing the task id and the source arn. + + :param task_id: the move task id + :param source_arn: the source queue arn + :return: a string of a base64 encoded json doc + """ + doc = f'{{"taskId":"{task_id}","sourceArn":"{source_arn}"}}' + return to_str(base64.b64encode(to_bytes(doc))) + + +def decode_move_task_handle(handle: str | bytes) -> tuple[str, str]: + """ + Inverse operation of ``encode_move_task_handle``. + + :param handle: the base64 encoded task handle + :return: a tuple of task_id and source_arn + :raises ValueError: if the handle is not encoded correctly or does not contain the necessary fields + """ + doc = json.loads(base64_decode(handle)) + if "taskId" not in doc: + raise ValueError("taskId not found in handle") + if "sourceArn" not in doc: + raise ValueError("sourceArn not found in handle") + return doc["taskId"], doc["sourceArn"] + + +@singleton_factory +def global_message_sequence(): + # creates a 20-digit number used as the start for the global sequence + start = int(time.time()) << 33 + # itertools.count is thread safe over the GIL since its getAndIncrement operation is a single python bytecode op + return itertools.count(start) + + +def generate_message_id(): + return long_uid() diff --git a/localstack-core/localstack/services/ssm/__init__.py b/localstack-core/localstack/services/ssm/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ssm/provider.py b/localstack-core/localstack/services/ssm/provider.py new file mode 100644 index 0000000000000..7787daa091383 --- /dev/null +++ b/localstack-core/localstack/services/ssm/provider.py @@ -0,0 +1,447 @@ +import copy +import json +import logging +import time +from abc import ABC +from typing import Dict, Optional + +from localstack.aws.api import CommonServiceException, RequestContext +from localstack.aws.api.ssm import ( + AlarmConfiguration, + BaselineDescription, + BaselineId, + BaselineName, + Boolean, + ClientToken, + CreateMaintenanceWindowResult, + CreatePatchBaselineResult, + DeleteMaintenanceWindowResult, + DeleteParameterResult, + DeletePatchBaselineResult, + DeregisterTargetFromMaintenanceWindowResult, + DeregisterTaskFromMaintenanceWindowResult, + DescribeMaintenanceWindowsResult, + DescribeMaintenanceWindowTargetsResult, + DescribeMaintenanceWindowTasksResult, + DescribePatchBaselinesResult, + GetParameterResult, + GetParametersResult, + LabelParameterVersionResult, + LoggingInfo, + MaintenanceWindowAllowUnassociatedTargets, + MaintenanceWindowCutoff, + MaintenanceWindowDescription, + MaintenanceWindowDurationHours, + MaintenanceWindowFilterList, + MaintenanceWindowId, + MaintenanceWindowMaxResults, + MaintenanceWindowName, + MaintenanceWindowOffset, + MaintenanceWindowResourceType, + MaintenanceWindowSchedule, + MaintenanceWindowStringDateTime, + MaintenanceWindowTargetId, + MaintenanceWindowTaskArn, + MaintenanceWindowTaskCutoffBehavior, + MaintenanceWindowTaskId, + MaintenanceWindowTaskInvocationParameters, + MaintenanceWindowTaskParameters, + MaintenanceWindowTaskPriority, + MaintenanceWindowTaskType, + MaintenanceWindowTimezone, + MaxConcurrency, + MaxErrors, + NextToken, + OperatingSystem, + OwnerInformation, + ParameterLabelList, + ParameterName, + ParameterNameList, + PatchAction, + PatchBaselineMaxResults, + PatchComplianceLevel, + PatchComplianceStatus, + PatchFilterGroup, + PatchIdList, + PatchOrchestratorFilterList, + PatchRuleGroup, + PatchSourceList, + PSParameterName, + PSParameterVersion, + PutParameterRequest, + PutParameterResult, + RegisterTargetWithMaintenanceWindowResult, + RegisterTaskWithMaintenanceWindowResult, + ServiceRole, + SsmApi, + TagList, + Targets, +) +from localstack.aws.connect import connect_to +from localstack.services.moto import call_moto, call_moto_with_request +from localstack.utils.aws.arns import extract_resource_from_arn, is_arn +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.collections import remove_attributes +from localstack.utils.objects import keys_to_lower + +LOG = logging.getLogger(__name__) + +PARAM_PREFIX_SECRETSMANAGER = "/aws/reference/secretsmanager" + + +class ValidationException(CommonServiceException): + def __init__(self, message=None): + super().__init__("ValidationException", message=message, sender_fault=True) + + +class InvalidParameterNameException(ValidationException): + def __init__(self): + msg = ( + 'Parameter name: can\'t be prefixed with "ssm" (case-insensitive). ' + "If formed as a path, it can consist of sub-paths divided by slash symbol; " + "each sub-path can be formed as a mix of letters, numbers and the following 3 symbols .-_" + ) + super().__init__(msg) + + +# TODO: check if _normalize_name(..) calls are still required here +class SsmProvider(SsmApi, ABC): + def get_parameters( + self, + context: RequestContext, + names: ParameterNameList, + with_decryption: Boolean = None, + **kwargs, + ) -> GetParametersResult: + if SsmProvider._has_secrets(names): + return SsmProvider._get_params_and_secrets(context.account_id, context.region, names) + + norm_names = [SsmProvider._normalize_name(name, validate=True) for name in names] + request = {"Names": norm_names, "WithDecryption": bool(with_decryption)} + res = call_moto_with_request(context, request) + + if not res.get("InvalidParameters"): + # note: simplifying assumption for now - only de-normalizing names if no invalid params were given + for i in range(len(res["Parameters"])): + self._denormalize_param_name_in_response(res["Parameters"][i], names[i]) + + return GetParametersResult(**res) + + def put_parameter( + self, context: RequestContext, request: PutParameterRequest, **kwargs + ) -> PutParameterResult: + name = request["Name"] + nname = SsmProvider._normalize_name(name) + if name != nname: + request.update({"Name": nname}) + moto_res = call_moto_with_request(context, request) + else: + moto_res = call_moto(context) + SsmProvider._notify_event_subscribers(context.account_id, context.region, nname, "Create") + return PutParameterResult(**moto_res) + + def get_parameter( + self, + context: RequestContext, + name: PSParameterName, + with_decryption: Boolean = None, + **kwargs, + ) -> GetParameterResult: + result = None + + norm_name = self._normalize_name(name, validate=True) + details = norm_name.split("/") + if len(details) > 4: + service = details[3] + if service == "secretsmanager": + resource_name = "/".join(details[4:]) + result = SsmProvider._get_secrets_information( + context.account_id, context.region, norm_name, resource_name + ) + + if not result: + result = call_moto_with_request( + context, {"Name": norm_name, "WithDecryption": bool(with_decryption)} + ) + + self._denormalize_param_name_in_response(result["Parameter"], name) + + return GetParameterResult(**result) + + def delete_parameter( + self, context: RequestContext, name: PSParameterName, **kwargs + ) -> DeleteParameterResult: + SsmProvider._notify_event_subscribers(context.account_id, context.region, name, "Delete") + call_moto(context) # Return type is an emtpy type. + return DeleteParameterResult() + + def label_parameter_version( + self, + context: RequestContext, + name: PSParameterName, + labels: ParameterLabelList, + parameter_version: PSParameterVersion = None, + **kwargs, + ) -> LabelParameterVersionResult: + SsmProvider._notify_event_subscribers( + context.account_id, context.region, name, "LabelParameterVersion" + ) + return LabelParameterVersionResult(**call_moto(context)) + + def create_patch_baseline( + self, + context: RequestContext, + name: BaselineName, + operating_system: OperatingSystem = None, + global_filters: PatchFilterGroup = None, + approval_rules: PatchRuleGroup = None, + approved_patches: PatchIdList = None, + approved_patches_compliance_level: PatchComplianceLevel = None, + approved_patches_enable_non_security: Boolean = None, + rejected_patches: PatchIdList = None, + rejected_patches_action: PatchAction = None, + description: BaselineDescription = None, + sources: PatchSourceList = None, + available_security_updates_compliance_status: PatchComplianceStatus = None, + client_token: ClientToken = None, + tags: TagList = None, + **kwargs, + ) -> CreatePatchBaselineResult: + return CreatePatchBaselineResult(**call_moto(context)) + + def delete_patch_baseline( + self, context: RequestContext, baseline_id: BaselineId, **kwargs + ) -> DeletePatchBaselineResult: + return DeletePatchBaselineResult(**call_moto(context)) + + def describe_patch_baselines( + self, + context: RequestContext, + filters: PatchOrchestratorFilterList = None, + max_results: PatchBaselineMaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribePatchBaselinesResult: + return DescribePatchBaselinesResult(**call_moto(context)) + + def register_target_with_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + resource_type: MaintenanceWindowResourceType, + targets: Targets, + owner_information: OwnerInformation = None, + name: MaintenanceWindowName = None, + description: MaintenanceWindowDescription = None, + client_token: ClientToken = None, + **kwargs, + ) -> RegisterTargetWithMaintenanceWindowResult: + return RegisterTargetWithMaintenanceWindowResult(**call_moto(context)) + + def deregister_target_from_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + window_target_id: MaintenanceWindowTargetId, + safe: Boolean = None, + **kwargs, + ) -> DeregisterTargetFromMaintenanceWindowResult: + return DeregisterTargetFromMaintenanceWindowResult(**call_moto(context)) + + def describe_maintenance_window_targets( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + filters: MaintenanceWindowFilterList = None, + max_results: MaintenanceWindowMaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeMaintenanceWindowTargetsResult: + return DescribeMaintenanceWindowTargetsResult(**call_moto(context)) + + def create_maintenance_window( + self, + context: RequestContext, + name: MaintenanceWindowName, + schedule: MaintenanceWindowSchedule, + duration: MaintenanceWindowDurationHours, + cutoff: MaintenanceWindowCutoff, + allow_unassociated_targets: MaintenanceWindowAllowUnassociatedTargets, + description: MaintenanceWindowDescription = None, + start_date: MaintenanceWindowStringDateTime = None, + end_date: MaintenanceWindowStringDateTime = None, + schedule_timezone: MaintenanceWindowTimezone = None, + schedule_offset: MaintenanceWindowOffset = None, + client_token: ClientToken = None, + tags: TagList = None, + **kwargs, + ) -> CreateMaintenanceWindowResult: + return CreateMaintenanceWindowResult(**call_moto(context)) + + def delete_maintenance_window( + self, context: RequestContext, window_id: MaintenanceWindowId, **kwargs + ) -> DeleteMaintenanceWindowResult: + return DeleteMaintenanceWindowResult(**call_moto(context)) + + def describe_maintenance_windows( + self, + context: RequestContext, + filters: MaintenanceWindowFilterList = None, + max_results: MaintenanceWindowMaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeMaintenanceWindowsResult: + return DescribeMaintenanceWindowsResult(**call_moto(context)) + + def register_task_with_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + task_arn: MaintenanceWindowTaskArn, + task_type: MaintenanceWindowTaskType, + targets: Targets = None, + service_role_arn: ServiceRole = None, + task_parameters: MaintenanceWindowTaskParameters = None, + task_invocation_parameters: MaintenanceWindowTaskInvocationParameters = None, + priority: MaintenanceWindowTaskPriority = None, + max_concurrency: MaxConcurrency = None, + max_errors: MaxErrors = None, + logging_info: LoggingInfo = None, + name: MaintenanceWindowName = None, + description: MaintenanceWindowDescription = None, + client_token: ClientToken = None, + cutoff_behavior: MaintenanceWindowTaskCutoffBehavior = None, + alarm_configuration: AlarmConfiguration = None, + **kwargs, + ) -> RegisterTaskWithMaintenanceWindowResult: + return RegisterTaskWithMaintenanceWindowResult(**call_moto(context)) + + def deregister_task_from_maintenance_window( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + window_task_id: MaintenanceWindowTaskId, + **kwargs, + ) -> DeregisterTaskFromMaintenanceWindowResult: + return DeregisterTaskFromMaintenanceWindowResult(**call_moto(context)) + + def describe_maintenance_window_tasks( + self, + context: RequestContext, + window_id: MaintenanceWindowId, + filters: MaintenanceWindowFilterList = None, + max_results: MaintenanceWindowMaxResults = None, + next_token: NextToken = None, + **kwargs, + ) -> DescribeMaintenanceWindowTasksResult: + return DescribeMaintenanceWindowTasksResult(**call_moto(context)) + + # utility methods below + + @staticmethod + def _denormalize_param_name_in_response(param_result: Dict, param_name: str): + result_name = param_result["Name"] + if result_name != param_name and result_name.lstrip("/") == param_name.lstrip("/"): + param_result["Name"] = param_name + + @staticmethod + def _has_secrets(names: ParameterNameList) -> Boolean: + maybe_secret = next( + filter(lambda n: n.startswith(PARAM_PREFIX_SECRETSMANAGER), names), None + ) + return maybe_secret is not None + + @staticmethod + def _normalize_name(param_name: ParameterName, validate=False) -> ParameterName: + if is_arn(param_name): + resource_name = extract_resource_from_arn(param_name).replace("parameter/", "") + # if the parameter name is only the root path we want to look up without the leading slash. + # Otherwise, we add the leading slash + if "/" in resource_name: + resource_name = f"/{resource_name}" + return resource_name + + if validate: + if "//" in param_name or ("/" in param_name and not param_name.startswith("/")): + raise InvalidParameterNameException() + param_name = param_name.strip("/") + param_name = param_name.replace("//", "/") + if "/" in param_name: + param_name = "/%s" % param_name + return param_name + + @staticmethod + def _get_secrets_information( + account_id: str, region_name: str, name: ParameterName, resource_name: str + ) -> Optional[GetParameterResult]: + client = connect_to(aws_access_key_id=account_id, region_name=region_name).secretsmanager + try: + secret_info = client.get_secret_value(SecretId=resource_name) + secret_info.pop("ResponseMetadata", None) + created_date_timestamp = time.mktime(secret_info["CreatedDate"].timetuple()) + secret_info["CreatedDate"] = created_date_timestamp + secret_info_lower = keys_to_lower( + remove_attributes(copy.deepcopy(secret_info), ["ARN"]) + ) + secret_info_lower["ARN"] = secret_info["ARN"] + result = { + "Parameter": { + "SourceResult": json.dumps(secret_info_lower, default=str), + "Name": name, + "Value": secret_info.get("SecretString"), + "Type": "SecureString", + "LastModifiedDate": created_date_timestamp, + } + } + return GetParameterResult(**result) + except client.exceptions.ResourceNotFoundException: + return None + + @staticmethod + def _get_params_and_secrets( + account_id: str, region_name: str, names: ParameterNameList + ) -> GetParametersResult: + ssm_client = connect_to(aws_access_key_id=account_id, region_name=region_name).ssm + result = {"Parameters": [], "InvalidParameters": []} + + for name in names: + if name.startswith(PARAM_PREFIX_SECRETSMANAGER): + secret = SsmProvider._get_secrets_information( + account_id, region_name, name, name[len(PARAM_PREFIX_SECRETSMANAGER) + 1 :] + ) + if secret is not None: + secret = secret["Parameter"] + result["Parameters"].append(secret) + else: + result["InvalidParameters"].append(name) + else: + try: + param = ssm_client.get_parameter(Name=name) + param["Parameter"]["LastModifiedDate"] = time.mktime( + param["Parameter"]["LastModifiedDate"].timetuple() + ) + result["Parameters"].append(param["Parameter"]) + except ssm_client.exceptions.ParameterNotFound: + result["InvalidParameters"].append(name) + + return GetParametersResult(**result) + + @staticmethod + def _notify_event_subscribers( + account_id: str, region_name: str, name: ParameterName, operation: str + ): + if not is_api_enabled("events"): + LOG.warning( + "Service 'events' is not enabled: skip emitting SSM event. " + "Please check your 'SERVICES' configuration variable." + ) + return + """Publish an EventBridge event to notify subscribers of changes.""" + events = connect_to(aws_access_key_id=account_id, region_name=region_name).events + detail = {"name": name, "operation": operation} + event = { + "Source": "aws.ssm", + "Detail": json.dumps(detail), + "DetailType": "Parameter Store Change", + } + events.put_events(Entries=[event]) diff --git a/localstack-core/localstack/services/ssm/resource_providers/__init__.py b/localstack-core/localstack/services/ssm/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow.py new file mode 100644 index 0000000000000..974a6b0676242 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow.py @@ -0,0 +1,137 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SSMMaintenanceWindowProperties(TypedDict): + AllowUnassociatedTargets: Optional[bool] + Cutoff: Optional[int] + Duration: Optional[int] + Name: Optional[str] + Schedule: Optional[str] + Description: Optional[str] + EndDate: Optional[str] + Id: Optional[str] + ScheduleOffset: Optional[int] + ScheduleTimezone: Optional[str] + StartDate: Optional[str] + Tags: Optional[list[Tag]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SSMMaintenanceWindowProvider(ResourceProvider[SSMMaintenanceWindowProperties]): + TYPE = "AWS::SSM::MaintenanceWindow" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SSMMaintenanceWindowProperties], + ) -> ProgressEvent[SSMMaintenanceWindowProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - AllowUnassociatedTargets + - Cutoff + - Schedule + - Duration + - Name + + + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + ssm_client = request.aws_client_factory.ssm + + params = util.select_attributes( + model, + [ + "AllowUnassociatedTargets", + "Cutoff", + "Duration", + "Name", + "Schedule", + "ScheduleOffset", + "ScheduleTimezone", + "StartDate", + "EndDate", + "Description", + "Tags", + ], + ) + + response = ssm_client.create_maintenance_window(**params) + model["Id"] = response["WindowId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SSMMaintenanceWindowProperties], + ) -> ProgressEvent[SSMMaintenanceWindowProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SSMMaintenanceWindowProperties], + ) -> ProgressEvent[SSMMaintenanceWindowProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ssm_client = request.aws_client_factory.ssm + + ssm_client.delete_maintenance_window(WindowId=model["Id"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SSMMaintenanceWindowProperties], + ) -> ProgressEvent[SSMMaintenanceWindowProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow.schema.json b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow.schema.json new file mode 100644 index 0000000000000..f4cd1289e18d9 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow.schema.json @@ -0,0 +1,78 @@ +{ + "typeName": "AWS::SSM::MaintenanceWindow", + "description": "Resource Type definition for AWS::SSM::MaintenanceWindow", + "additionalProperties": false, + "properties": { + "StartDate": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "AllowUnassociatedTargets": { + "type": "boolean" + }, + "Cutoff": { + "type": "integer" + }, + "Schedule": { + "type": "string" + }, + "Duration": { + "type": "integer" + }, + "ScheduleOffset": { + "type": "integer" + }, + "Id": { + "type": "string" + }, + "EndDate": { + "type": "string" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "Name": { + "type": "string" + }, + "ScheduleTimezone": { + "type": "string" + } + }, + "definitions": { + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + } + }, + "required": [ + "AllowUnassociatedTargets", + "Cutoff", + "Schedule", + "Duration", + "Name" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow_plugin.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow_plugin.py new file mode 100644 index 0000000000000..c7f5ef1c2e50a --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindow_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SSMMaintenanceWindowProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SSM::MaintenanceWindow" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ssm.resource_providers.aws_ssm_maintenancewindow import ( + SSMMaintenanceWindowProvider, + ) + + self.factory = SSMMaintenanceWindowProvider diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget.py new file mode 100644 index 0000000000000..a6f8ef6029dbf --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget.py @@ -0,0 +1,128 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SSMMaintenanceWindowTargetProperties(TypedDict): + ResourceType: Optional[str] + Targets: Optional[list[Targets]] + WindowId: Optional[str] + Description: Optional[str] + Id: Optional[str] + Name: Optional[str] + OwnerInformation: Optional[str] + + +class Targets(TypedDict): + Key: Optional[str] + Values: Optional[list[str]] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SSMMaintenanceWindowTargetProvider(ResourceProvider[SSMMaintenanceWindowTargetProperties]): + TYPE = "AWS::SSM::MaintenanceWindowTarget" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SSMMaintenanceWindowTargetProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTargetProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - WindowId + - ResourceType + - Targets + + Create-only properties: + - /properties/WindowId + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + params = util.select_attributes( + model=model, + params=[ + "Description", + "Name", + "OwnerInformation", + "ResourceType", + "Targets", + "WindowId", + ], + ) + + response = ssm.register_target_with_maintenance_window(**params) + model["Id"] = response["WindowTargetId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SSMMaintenanceWindowTargetProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTargetProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SSMMaintenanceWindowTargetProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTargetProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + ssm.deregister_target_from_maintenance_window( + WindowId=model["WindowId"], WindowTargetId=model["Id"] + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SSMMaintenanceWindowTargetProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTargetProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget.schema.json b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget.schema.json new file mode 100644 index 0000000000000..524e83e7f7134 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget.schema.json @@ -0,0 +1,68 @@ +{ + "typeName": "AWS::SSM::MaintenanceWindowTarget", + "description": "Resource Type definition for AWS::SSM::MaintenanceWindowTarget", + "additionalProperties": false, + "properties": { + "OwnerInformation": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "WindowId": { + "type": "string" + }, + "ResourceType": { + "type": "string" + }, + "Targets": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Targets" + } + }, + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + } + }, + "definitions": { + "Targets": { + "type": "object", + "additionalProperties": false, + "properties": { + "Values": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Values", + "Key" + ] + } + }, + "required": [ + "WindowId", + "ResourceType", + "Targets" + ], + "createOnlyProperties": [ + "/properties/WindowId" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget_plugin.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget_plugin.py new file mode 100644 index 0000000000000..c16b5208eff20 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtarget_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SSMMaintenanceWindowTargetProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SSM::MaintenanceWindowTarget" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ssm.resource_providers.aws_ssm_maintenancewindowtarget import ( + SSMMaintenanceWindowTargetProvider, + ) + + self.factory = SSMMaintenanceWindowTargetProvider diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask.py new file mode 100644 index 0000000000000..01b2f165a9aaa --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask.py @@ -0,0 +1,208 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SSMMaintenanceWindowTaskProperties(TypedDict): + Priority: Optional[int] + TaskArn: Optional[str] + TaskType: Optional[str] + WindowId: Optional[str] + CutoffBehavior: Optional[str] + Description: Optional[str] + Id: Optional[str] + LoggingInfo: Optional[LoggingInfo] + MaxConcurrency: Optional[str] + MaxErrors: Optional[str] + Name: Optional[str] + ServiceRoleArn: Optional[str] + Targets: Optional[list[Target]] + TaskInvocationParameters: Optional[TaskInvocationParameters] + TaskParameters: Optional[dict] + + +class Target(TypedDict): + Key: Optional[str] + Values: Optional[list[str]] + + +class MaintenanceWindowStepFunctionsParameters(TypedDict): + Input: Optional[str] + Name: Optional[str] + + +class CloudWatchOutputConfig(TypedDict): + CloudWatchLogGroupName: Optional[str] + CloudWatchOutputEnabled: Optional[bool] + + +class NotificationConfig(TypedDict): + NotificationArn: Optional[str] + NotificationEvents: Optional[list[str]] + NotificationType: Optional[str] + + +class MaintenanceWindowRunCommandParameters(TypedDict): + CloudWatchOutputConfig: Optional[CloudWatchOutputConfig] + Comment: Optional[str] + DocumentHash: Optional[str] + DocumentHashType: Optional[str] + DocumentVersion: Optional[str] + NotificationConfig: Optional[NotificationConfig] + OutputS3BucketName: Optional[str] + OutputS3KeyPrefix: Optional[str] + Parameters: Optional[dict] + ServiceRoleArn: Optional[str] + TimeoutSeconds: Optional[int] + + +class MaintenanceWindowLambdaParameters(TypedDict): + ClientContext: Optional[str] + Payload: Optional[str] + Qualifier: Optional[str] + + +class MaintenanceWindowAutomationParameters(TypedDict): + DocumentVersion: Optional[str] + Parameters: Optional[dict] + + +class TaskInvocationParameters(TypedDict): + MaintenanceWindowAutomationParameters: Optional[MaintenanceWindowAutomationParameters] + MaintenanceWindowLambdaParameters: Optional[MaintenanceWindowLambdaParameters] + MaintenanceWindowRunCommandParameters: Optional[MaintenanceWindowRunCommandParameters] + MaintenanceWindowStepFunctionsParameters: Optional[MaintenanceWindowStepFunctionsParameters] + + +class LoggingInfo(TypedDict): + Region: Optional[str] + S3Bucket: Optional[str] + S3Prefix: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SSMMaintenanceWindowTaskProvider(ResourceProvider[SSMMaintenanceWindowTaskProperties]): + TYPE = "AWS::SSM::MaintenanceWindowTask" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SSMMaintenanceWindowTaskProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTaskProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - WindowId + - Priority + - TaskType + - TaskArn + + Create-only properties: + - /properties/WindowId + - /properties/TaskType + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + params = util.select_attributes( + model=model, + params=[ + "Description", + "Name", + "OwnerInformation", + "Priority", + "ServiceRoleArn", + "Targets", + "TaskArn", + "TaskParameters", + "TaskType", + "WindowId", + ], + ) + + if invocation_params := model.get("TaskInvocationParameters"): + task_type_map = { + "MaintenanceWindowAutomationParameters": "Automation", + "MaintenanceWindowLambdaParameters": "Lambda", + "MaintenanceWindowRunCommandParameters": "RunCommand", + "MaintenanceWindowStepFunctionsParameters": "StepFunctions", + } + params["TaskInvocationParameters"] = { + task_type_map[k]: v for k, v in invocation_params.items() + } + + response = ssm.register_task_with_maintenance_window(**params) + + model["Id"] = response["WindowTaskId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SSMMaintenanceWindowTaskProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTaskProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SSMMaintenanceWindowTaskProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTaskProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + ssm.deregister_task_from_maintenance_window( + WindowId=model["WindowId"], WindowTaskId=model["Id"] + ) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SSMMaintenanceWindowTaskProperties], + ) -> ProgressEvent[SSMMaintenanceWindowTaskProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask.schema.json b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask.schema.json new file mode 100644 index 0000000000000..344e3e5b83ae5 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask.schema.json @@ -0,0 +1,243 @@ +{ + "typeName": "AWS::SSM::MaintenanceWindowTask", + "description": "Resource Type definition for AWS::SSM::MaintenanceWindowTask", + "additionalProperties": false, + "properties": { + "MaxErrors": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "ServiceRoleArn": { + "type": "string" + }, + "Priority": { + "type": "integer" + }, + "MaxConcurrency": { + "type": "string" + }, + "Targets": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Target" + } + }, + "Name": { + "type": "string" + }, + "TaskArn": { + "type": "string" + }, + "TaskInvocationParameters": { + "$ref": "#/definitions/TaskInvocationParameters" + }, + "WindowId": { + "type": "string" + }, + "TaskParameters": { + "type": "object" + }, + "TaskType": { + "type": "string" + }, + "CutoffBehavior": { + "type": "string" + }, + "Id": { + "type": "string" + }, + "LoggingInfo": { + "$ref": "#/definitions/LoggingInfo" + } + }, + "definitions": { + "TaskInvocationParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "MaintenanceWindowStepFunctionsParameters": { + "$ref": "#/definitions/MaintenanceWindowStepFunctionsParameters" + }, + "MaintenanceWindowRunCommandParameters": { + "$ref": "#/definitions/MaintenanceWindowRunCommandParameters" + }, + "MaintenanceWindowLambdaParameters": { + "$ref": "#/definitions/MaintenanceWindowLambdaParameters" + }, + "MaintenanceWindowAutomationParameters": { + "$ref": "#/definitions/MaintenanceWindowAutomationParameters" + } + } + }, + "Target": { + "type": "object", + "additionalProperties": false, + "properties": { + "Values": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Values", + "Key" + ] + }, + "CloudWatchOutputConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "CloudWatchOutputEnabled": { + "type": "boolean" + }, + "CloudWatchLogGroupName": { + "type": "string" + } + } + }, + "MaintenanceWindowRunCommandParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "TimeoutSeconds": { + "type": "integer" + }, + "Comment": { + "type": "string" + }, + "OutputS3KeyPrefix": { + "type": "string" + }, + "Parameters": { + "type": "object" + }, + "CloudWatchOutputConfig": { + "$ref": "#/definitions/CloudWatchOutputConfig" + }, + "DocumentHashType": { + "type": "string" + }, + "ServiceRoleArn": { + "type": "string" + }, + "NotificationConfig": { + "$ref": "#/definitions/NotificationConfig" + }, + "DocumentVersion": { + "type": "string" + }, + "OutputS3BucketName": { + "type": "string" + }, + "DocumentHash": { + "type": "string" + } + } + }, + "MaintenanceWindowAutomationParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "Parameters": { + "type": "object" + }, + "DocumentVersion": { + "type": "string" + } + } + }, + "NotificationConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "NotificationEvents": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "NotificationArn": { + "type": "string" + }, + "NotificationType": { + "type": "string" + } + }, + "required": [ + "NotificationArn" + ] + }, + "MaintenanceWindowStepFunctionsParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "Input": { + "type": "string" + }, + "Name": { + "type": "string" + } + } + }, + "LoggingInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "Region": { + "type": "string" + }, + "S3Prefix": { + "type": "string" + }, + "S3Bucket": { + "type": "string" + } + }, + "required": [ + "S3Bucket", + "Region" + ] + }, + "MaintenanceWindowLambdaParameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "Qualifier": { + "type": "string" + }, + "Payload": { + "type": "string" + }, + "ClientContext": { + "type": "string" + } + } + } + }, + "required": [ + "WindowId", + "Priority", + "TaskType", + "TaskArn" + ], + "createOnlyProperties": [ + "/properties/WindowId", + "/properties/TaskType" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask_plugin.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask_plugin.py new file mode 100644 index 0000000000000..494b10f07bd48 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_maintenancewindowtask_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SSMMaintenanceWindowTaskProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SSM::MaintenanceWindowTask" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ssm.resource_providers.aws_ssm_maintenancewindowtask import ( + SSMMaintenanceWindowTaskProvider, + ) + + self.factory = SSMMaintenanceWindowTaskProvider diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter.py new file mode 100644 index 0000000000000..95ea2ecb4d214 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter.py @@ -0,0 +1,226 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SSMParameterProperties(TypedDict): + Type: Optional[str] + Value: Optional[str] + AllowedPattern: Optional[str] + DataType: Optional[str] + Description: Optional[str] + Name: Optional[str] + Policies: Optional[str] + Tags: Optional[dict] + Tier: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SSMParameterProvider(ResourceProvider[SSMParameterProperties]): + TYPE = "AWS::SSM::Parameter" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SSMParameterProperties], + ) -> ProgressEvent[SSMParameterProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Name + + Required properties: + - Value + - Type + + Create-only properties: + - /properties/Name + + + + IAM permissions required: + - ssm:PutParameter + - ssm:AddTagsToResource + - ssm:GetParameters + + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + if not model.get("Name"): + model["Name"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + params = util.select_attributes( + model=model, + params=[ + "Name", + "Type", + "Value", + "Description", + "AllowedPattern", + "Policies", + "Tier", + ], + ) + if "Value" in params: + params["Value"] = str(params["Value"]) + + if tags := model.get("Tags"): + formatted_tags = [] + for key, value in tags.items(): + formatted_tags.append({"Key": key, "Value": value}) + + params["Tags"] = formatted_tags + + ssm.put_parameter(**params) + + return self.read(request) + + def read( + self, + request: ResourceRequest[SSMParameterProperties], + ) -> ProgressEvent[SSMParameterProperties]: + """ + Fetch resource information + + IAM permissions required: + - ssm:GetParameters + """ + ssm = request.aws_client_factory.ssm + parameter_name = request.desired_state.get("Name") + try: + resource = ssm.get_parameter(Name=parameter_name, WithDecryption=False) + except ssm.exceptions.ParameterNotFound: + return ProgressEvent( + status=OperationStatus.FAILED, + message=f"Resource of type '{self.TYPE}' with identifier '{parameter_name}' was not found.", + error_code="NotFound", + ) + + parameter = util.select_attributes(resource["Parameter"], params=self.SCHEMA["properties"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=parameter, + custom_context=request.custom_context, + ) + + def delete( + self, + request: ResourceRequest[SSMParameterProperties], + ) -> ProgressEvent[SSMParameterProperties]: + """ + Delete a resource + + IAM permissions required: + - ssm:DeleteParameter + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + ssm.delete_parameter(Name=model["Name"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SSMParameterProperties], + ) -> ProgressEvent[SSMParameterProperties]: + """ + Update a resource + + IAM permissions required: + - ssm:PutParameter + - ssm:AddTagsToResource + - ssm:RemoveTagsFromResource + - ssm:GetParameters + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + if not model.get("Name"): + model["Name"] = request.previous_state["Name"] + parameters_to_select = [ + "AllowedPattern", + "DataType", + "Description", + "Name", + "Policies", + "Tags", + "Tier", + "Type", + "Value", + ] + update_config_props = util.select_attributes(model, parameters_to_select) + + # tag handling + new_tags = update_config_props.pop("Tags", {}) + if new_tags: + self.update_tags(ssm, model, new_tags) + + ssm.put_parameter(Overwrite=True, Tags=[], **update_config_props) + + return self.read(request) + + def update_tags(self, ssm, model, new_tags): + current_tags = ssm.list_tags_for_resource( + ResourceType="Parameter", ResourceId=model["Name"] + )["TagList"] + current_tags = {tag["Key"]: tag["Value"] for tag in current_tags} + + new_tag_keys = set(new_tags.keys()) + old_tag_keys = set(current_tags.keys()) + potentially_modified_tag_keys = new_tag_keys.intersection(old_tag_keys) + tag_keys_to_add = new_tag_keys.difference(old_tag_keys) + tag_keys_to_remove = old_tag_keys.difference(new_tag_keys) + + for tag_key in potentially_modified_tag_keys: + if new_tags[tag_key] != current_tags[tag_key]: + tag_keys_to_add.add(tag_key) + + if tag_keys_to_add: + ssm.add_tags_to_resource( + ResourceType="Parameter", + ResourceId=model["Name"], + Tags=[ + {"Key": tag_key, "Value": tag_value} + for tag_key, tag_value in new_tags.items() + if tag_key in tag_keys_to_add + ], + ) + + if tag_keys_to_remove: + ssm.remove_tags_from_resource( + ResourceType="Parameter", ResourceId=model["Name"], TagKeys=tag_keys_to_remove + ) + + def list( + self, + request: ResourceRequest[SSMParameterProperties], + ) -> ProgressEvent[SSMParameterProperties]: + resources = request.aws_client_factory.ssm.describe_parameters() + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + SSMParameterProperties(Name=resource["Name"]) + for resource in resources["Parameters"] + ], + ) diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter.schema.json b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter.schema.json new file mode 100644 index 0000000000000..9d3e47882fd3d --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter.schema.json @@ -0,0 +1,121 @@ +{ + "typeName": "AWS::SSM::Parameter", + "description": "Resource Type definition for AWS::SSM::Parameter", + "additionalProperties": false, + "properties": { + "Type": { + "type": "string", + "description": "The type of the parameter.", + "enum": [ + "String", + "StringList", + "SecureString" + ] + }, + "Value": { + "type": "string", + "description": "The value associated with the parameter." + }, + "Description": { + "type": "string", + "description": "The information about the parameter." + }, + "Policies": { + "type": "string", + "description": "The policies attached to the parameter." + }, + "AllowedPattern": { + "type": "string", + "description": "The regular expression used to validate the parameter value." + }, + "Tier": { + "type": "string", + "description": "The corresponding tier of the parameter.", + "enum": [ + "Standard", + "Advanced", + "Intelligent-Tiering" + ] + }, + "Tags": { + "type": "object", + "description": "A key-value pair to associate with a resource.", + "patternProperties": { + "^([\\p{L}\\p{Z}\\p{N}_.:/=+\\-@]*)$": { + "type": "string" + } + }, + "additionalProperties": false + }, + "DataType": { + "type": "string", + "description": "The corresponding DataType of the parameter.", + "enum": [ + "text", + "aws:ec2:image" + ] + }, + "Name": { + "type": "string", + "description": "The name of the parameter." + } + }, + "required": [ + "Value", + "Type" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "createOnlyProperties": [ + "/properties/Name" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "writeOnlyProperties": [ + "/properties/Tags", + "/properties/Description", + "/properties/Tier", + "/properties/AllowedPattern", + "/properties/Policies" + ], + "handlers": { + "create": { + "permissions": [ + "ssm:PutParameter", + "ssm:AddTagsToResource", + "ssm:GetParameters" + ], + "timeoutInMinutes": 5 + }, + "read": { + "permissions": [ + "ssm:GetParameters" + ] + }, + "update": { + "permissions": [ + "ssm:PutParameter", + "ssm:AddTagsToResource", + "ssm:RemoveTagsFromResource", + "ssm:GetParameters" + ], + "timeoutInMinutes": 5 + }, + "delete": { + "permissions": [ + "ssm:DeleteParameter" + ] + }, + "list": { + "permissions": [ + "ssm:DescribeParameters" + ] + } + } +} diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter_plugin.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter_plugin.py new file mode 100644 index 0000000000000..e75f657f22100 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_parameter_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SSMParameterProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SSM::Parameter" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ssm.resource_providers.aws_ssm_parameter import ( + SSMParameterProvider, + ) + + self.factory = SSMParameterProvider diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline.py new file mode 100644 index 0000000000000..7c3623c981eee --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline.py @@ -0,0 +1,165 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class SSMPatchBaselineProperties(TypedDict): + Name: Optional[str] + ApprovalRules: Optional[RuleGroup] + ApprovedPatches: Optional[list[str]] + ApprovedPatchesComplianceLevel: Optional[str] + ApprovedPatchesEnableNonSecurity: Optional[bool] + Description: Optional[str] + GlobalFilters: Optional[PatchFilterGroup] + Id: Optional[str] + OperatingSystem: Optional[str] + PatchGroups: Optional[list[str]] + RejectedPatches: Optional[list[str]] + RejectedPatchesAction: Optional[str] + Sources: Optional[list[PatchSource]] + Tags: Optional[list[Tag]] + + +class PatchFilter(TypedDict): + Key: Optional[str] + Values: Optional[list[str]] + + +class PatchFilterGroup(TypedDict): + PatchFilters: Optional[list[PatchFilter]] + + +class Rule(TypedDict): + ApproveAfterDays: Optional[int] + ApproveUntilDate: Optional[dict] + ComplianceLevel: Optional[str] + EnableNonSecurity: Optional[bool] + PatchFilterGroup: Optional[PatchFilterGroup] + + +class RuleGroup(TypedDict): + PatchRules: Optional[list[Rule]] + + +class PatchSource(TypedDict): + Configuration: Optional[str] + Name: Optional[str] + Products: Optional[list[str]] + + +class Tag(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class SSMPatchBaselineProvider(ResourceProvider[SSMPatchBaselineProperties]): + TYPE = "AWS::SSM::PatchBaseline" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[SSMPatchBaselineProperties], + ) -> ProgressEvent[SSMPatchBaselineProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Id + + Required properties: + - Name + + Create-only properties: + - /properties/OperatingSystem + + Read-only properties: + - /properties/Id + + + + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + params = util.select_attributes( + model=model, + params=[ + "OperatingSystem", + "Name", + "GlobalFilters", + "ApprovalRules", + "ApprovedPatches", + "ApprovedPatchesComplianceLevel", + "ApprovedPatchesEnableNonSecurity", + "RejectedPatches", + "RejectedPatchesAction", + "Description", + "Sources", + "ClientToken", + "Tags", + ], + ) + + response = ssm.create_patch_baseline(**params) + model["Id"] = response["BaselineId"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[SSMPatchBaselineProperties], + ) -> ProgressEvent[SSMPatchBaselineProperties]: + """ + Fetch resource information + + + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[SSMPatchBaselineProperties], + ) -> ProgressEvent[SSMPatchBaselineProperties]: + """ + Delete a resource + + + """ + model = request.desired_state + ssm = request.aws_client_factory.ssm + + ssm.delete_patch_baseline(BaselineId=model["Id"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[SSMPatchBaselineProperties], + ) -> ProgressEvent[SSMPatchBaselineProperties]: + """ + Update a resource + + + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline.schema.json b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline.schema.json new file mode 100644 index 0000000000000..84db05c4f432c --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline.schema.json @@ -0,0 +1,185 @@ +{ + "typeName": "AWS::SSM::PatchBaseline", + "description": "Resource Type definition for AWS::SSM::PatchBaseline", + "additionalProperties": false, + "properties": { + "OperatingSystem": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "ApprovalRules": { + "$ref": "#/definitions/RuleGroup" + }, + "Sources": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/PatchSource" + } + }, + "Name": { + "type": "string" + }, + "RejectedPatches": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "ApprovedPatches": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "RejectedPatchesAction": { + "type": "string" + }, + "PatchGroups": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "ApprovedPatchesComplianceLevel": { + "type": "string" + }, + "ApprovedPatchesEnableNonSecurity": { + "type": "boolean" + }, + "Id": { + "type": "string" + }, + "GlobalFilters": { + "$ref": "#/definitions/PatchFilterGroup" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Tag" + } + } + }, + "definitions": { + "PatchFilterGroup": { + "type": "object", + "additionalProperties": false, + "properties": { + "PatchFilters": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/PatchFilter" + } + } + } + }, + "PatchFilter": { + "type": "object", + "additionalProperties": false, + "properties": { + "Values": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Key": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "properties": { + "Value": { + "type": "string" + }, + "Key": { + "type": "string" + } + }, + "required": [ + "Value", + "Key" + ] + }, + "Rule": { + "type": "object", + "additionalProperties": false, + "properties": { + "ApproveUntilDate": { + "$ref": "#/definitions/PatchStringDate" + }, + "ApproveAfterDays": { + "type": "integer" + }, + "EnableNonSecurity": { + "type": "boolean" + }, + "ComplianceLevel": { + "type": "string" + }, + "PatchFilterGroup": { + "$ref": "#/definitions/PatchFilterGroup" + } + } + }, + "PatchStringDate": { + "type": "object", + "additionalProperties": false + }, + "PatchSource": { + "type": "object", + "additionalProperties": false, + "properties": { + "Products": { + "type": "array", + "uniqueItems": false, + "items": { + "type": "string" + } + }, + "Configuration": { + "type": "string" + }, + "Name": { + "type": "string" + } + } + }, + "RuleGroup": { + "type": "object", + "additionalProperties": false, + "properties": { + "PatchRules": { + "type": "array", + "uniqueItems": false, + "items": { + "$ref": "#/definitions/Rule" + } + } + } + } + }, + "required": [ + "Name" + ], + "createOnlyProperties": [ + "/properties/OperatingSystem" + ], + "primaryIdentifier": [ + "/properties/Id" + ], + "readOnlyProperties": [ + "/properties/Id" + ] +} diff --git a/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline_plugin.py b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline_plugin.py new file mode 100644 index 0000000000000..3991ae2eec102 --- /dev/null +++ b/localstack-core/localstack/services/ssm/resource_providers/aws_ssm_patchbaseline_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class SSMPatchBaselineProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::SSM::PatchBaseline" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.ssm.resource_providers.aws_ssm_patchbaseline import ( + SSMPatchBaselineProvider, + ) + + self.factory = SSMPatchBaselineProvider diff --git a/localstack-core/localstack/services/stepfunctions/__init__.py b/localstack-core/localstack/services/stepfunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/analytics.py b/localstack-core/localstack/services/stepfunctions/analytics.py new file mode 100644 index 0000000000000..c96b2c140af13 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/analytics.py @@ -0,0 +1,12 @@ +""" +Usage reporting for StepFunctions service +""" + +from localstack.utils.analytics.metrics import LabeledCounter + +# Initialize a counter to record the usage of language features for each state machine. +language_features_counter = LabeledCounter( + namespace="stepfunctions", + name="language_features_used", + labels=["query_language", "uses_variables"], +) diff --git a/localstack-core/localstack/services/stepfunctions/asl/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/.gitignore b/localstack-core/localstack/services/stepfunctions/asl/antlr/.gitignore new file mode 100644 index 0000000000000..ade3e916efb0c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/.gitignore @@ -0,0 +1,4 @@ +.antlr +/.antlr* +*.tokens +*.interp diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLIntrinsicLexer.g4 b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLIntrinsicLexer.g4 new file mode 100644 index 0000000000000..437122207065f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLIntrinsicLexer.g4 @@ -0,0 +1,62 @@ +// $antlr-format alignTrailingComments true, columnLimit 150, maxEmptyLinesToKeep 1, reflowComments false, useTab false +// $antlr-format allowShortRulesOnASingleLine true, allowShortBlocksOnASingleLine true, minEmptyLines 0, alignSemicolons ownLine +// $antlr-format alignColons trailing, singleLineOverrulesHangingColon true, alignLexerCommands true, alignLabels true, alignTrailers true + +lexer grammar ASLIntrinsicLexer; + +CONTEXT_PATH_STRING: DOLLAR DOLLAR JSON_PATH_BODY; + +JSON_PATH_STRING: DOLLAR JSON_PATH_BODY; + +STRING_VARIABLE: DOLLAR IDENTIFIER JSON_PATH_BODY; + +// TODO: JSONPath body composition may need strenghening to support features such as filtering conditions. +fragment JSON_PATH_BODY: JSON_PATH_BRACK? (DOT IDENTIFIER? JSON_PATH_BRACK?)*; + +fragment JSON_PATH_BRACK: '[' (JSON_PATH_BRACK | ~[\]])* ']'; + +DOLLAR : '$'; +LPAREN : '('; +RPAREN : ')'; +COMMA : ','; +DOT : '.'; + +TRUE : 'true'; +FALSE : 'false'; + +States : 'States'; +Format : 'Format'; +StringToJson : 'StringToJson'; +JsonToString : 'JsonToString'; +Array : 'Array'; +ArrayPartition : 'ArrayPartition'; +ArrayContains : 'ArrayContains'; +ArrayRange : 'ArrayRange'; +ArrayGetItem : 'ArrayGetItem'; +ArrayLength : 'ArrayLength'; +ArrayUnique : 'ArrayUnique'; +Base64Encode : 'Base64Encode'; +Base64Decode : 'Base64Decode'; +Hash : 'Hash'; +JsonMerge : 'JsonMerge'; +MathRandom : 'MathRandom'; +MathAdd : 'MathAdd'; +StringSplit : 'StringSplit'; +UUID : 'UUID'; + +STRING: '\'' (ESC | SAFECODEPOINT)*? '\''; + +fragment ESC : '\\' (UNICODE | .); +fragment UNICODE : 'u' HEX HEX HEX HEX; +fragment HEX : [0-9a-fA-F]; +fragment SAFECODEPOINT : ~ ['\\\u0000-\u001F]; + +INT: '-'? ('0' | [1-9] [0-9]*); + +NUMBER: '-'? INT ('.' [0-9]+)? EXP?; + +fragment EXP: [Ee] [+\-]? INT; + +IDENTIFIER: ([0-9a-zA-Z_] | UNICODE)+; + +WS: [ \t\n]+ -> skip; \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLIntrinsicParser.g4 b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLIntrinsicParser.g4 new file mode 100644 index 0000000000000..be0cac2a9379d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLIntrinsicParser.g4 @@ -0,0 +1,47 @@ +// $antlr-format alignTrailingComments true, columnLimit 150, maxEmptyLinesToKeep 1, reflowComments false, useTab false +// $antlr-format allowShortRulesOnASingleLine true, allowShortBlocksOnASingleLine true, minEmptyLines 0, alignSemicolons ownLine +// $antlr-format alignColons trailing, singleLineOverrulesHangingColon true, alignLexerCommands true, alignLabels true, alignTrailers true + +parser grammar ASLIntrinsicParser; + +options { + tokenVocab = ASLIntrinsicLexer; +} + +func_decl: states_func_decl EOF; + +states_func_decl: States DOT state_fun_name func_arg_list; + +state_fun_name: + Format + | StringToJson + | JsonToString + | Array + | ArrayPartition + | ArrayContains + | ArrayRange + | ArrayGetItem + | ArrayLength + | ArrayUnique + | Base64Encode + | Base64Decode + | Hash + | JsonMerge + | MathRandom + | MathAdd + | StringSplit + | UUID +; + +func_arg_list: LPAREN func_arg (COMMA func_arg)* RPAREN | LPAREN RPAREN; + +func_arg: + STRING # func_arg_string + | INT # func_arg_int + | NUMBER # func_arg_float + | (TRUE | FALSE) # func_arg_bool + | CONTEXT_PATH_STRING # func_arg_context_path + | JSON_PATH_STRING # func_arg_json_path + | STRING_VARIABLE # func_arg_var + | states_func_decl # func_arg_func_decl +; \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLLexer.g4 b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLLexer.g4 new file mode 100644 index 0000000000000..aa79ba245f380 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLLexer.g4 @@ -0,0 +1,357 @@ +// $antlr-format alignTrailingComments true, columnLimit 150, maxEmptyLinesToKeep 1, reflowComments false, useTab false +// $antlr-format allowShortRulesOnASingleLine true, allowShortBlocksOnASingleLine true, minEmptyLines 0, alignSemicolons ownLine +// $antlr-format alignColons trailing, singleLineOverrulesHangingColon true, alignLexerCommands true, alignLabels true, alignTrailers true + +lexer grammar ASLLexer; + +// Symbols. +COMMA: ','; + +COLON: ':'; + +LBRACK: '['; + +RBRACK: ']'; + +LBRACE: '{'; + +RBRACE: '}'; + +// Literals. +TRUE: 'true'; + +FALSE: 'false'; + +NULL: 'null'; + +// Keywords. +COMMENT: '"Comment"'; + +STATES: '"States"'; + +STARTAT: '"StartAt"'; + +NEXTSTATE: '"NextState"'; + +VERSION: '"Version"'; + +TYPE: '"Type"'; + +TASK: '"Task"'; + +CHOICE: '"Choice"'; + +FAIL: '"Fail"'; + +SUCCEED: '"Succeed"'; + +PASS: '"Pass"'; + +WAIT: '"Wait"'; + +PARALLEL: '"Parallel"'; + +MAP: '"Map"'; + +CHOICES: '"Choices"'; + +CONDITION: '"Condition"'; + +VARIABLE: '"Variable"'; + +DEFAULT: '"Default"'; + +BRANCHES: '"Branches"'; + +AND: '"And"'; + +BOOLEANEQUALS: '"BooleanEquals"'; + +BOOLEANQUALSPATH: '"BooleanEqualsPath"'; + +ISBOOLEAN: '"IsBoolean"'; + +ISNULL: '"IsNull"'; + +ISNUMERIC: '"IsNumeric"'; + +ISPRESENT: '"IsPresent"'; + +ISSTRING: '"IsString"'; + +ISTIMESTAMP: '"IsTimestamp"'; + +NOT: '"Not"'; + +NUMERICEQUALS: '"NumericEquals"'; + +NUMERICEQUALSPATH: '"NumericEqualsPath"'; + +NUMERICGREATERTHAN: '"NumericGreaterThan"'; + +NUMERICGREATERTHANPATH: '"NumericGreaterThanPath"'; + +NUMERICGREATERTHANEQUALS: '"NumericGreaterThanEquals"'; + +NUMERICGREATERTHANEQUALSPATH: '"NumericGreaterThanEqualsPath"'; + +NUMERICLESSTHAN: '"NumericLessThan"'; + +NUMERICLESSTHANPATH: '"NumericLessThanPath"'; + +NUMERICLESSTHANEQUALS: '"NumericLessThanEquals"'; + +NUMERICLESSTHANEQUALSPATH: '"NumericLessThanEqualsPath"'; + +OR: '"Or"'; + +STRINGEQUALS: '"StringEquals"'; + +STRINGEQUALSPATH: '"StringEqualsPath"'; + +STRINGGREATERTHAN: '"StringGreaterThan"'; + +STRINGGREATERTHANPATH: '"StringGreaterThanPath"'; + +STRINGGREATERTHANEQUALS: '"StringGreaterThanEquals"'; + +STRINGGREATERTHANEQUALSPATH: '"StringGreaterThanEqualsPath"'; + +STRINGLESSTHAN: '"StringLessThan"'; + +STRINGLESSTHANPATH: '"StringLessThanPath"'; + +STRINGLESSTHANEQUALS: '"StringLessThanEquals"'; + +STRINGLESSTHANEQUALSPATH: '"StringLessThanEqualsPath"'; + +STRINGMATCHES: '"StringMatches"'; + +TIMESTAMPEQUALS: '"TimestampEquals"'; + +TIMESTAMPEQUALSPATH: '"TimestampEqualsPath"'; + +TIMESTAMPGREATERTHAN: '"TimestampGreaterThan"'; + +TIMESTAMPGREATERTHANPATH: '"TimestampGreaterThanPath"'; + +TIMESTAMPGREATERTHANEQUALS: '"TimestampGreaterThanEquals"'; + +TIMESTAMPGREATERTHANEQUALSPATH: '"TimestampGreaterThanEqualsPath"'; + +TIMESTAMPLESSTHAN: '"TimestampLessThan"'; + +TIMESTAMPLESSTHANPATH: '"TimestampLessThanPath"'; + +TIMESTAMPLESSTHANEQUALS: '"TimestampLessThanEquals"'; + +TIMESTAMPLESSTHANEQUALSPATH: '"TimestampLessThanEqualsPath"'; + +SECONDSPATH: '"SecondsPath"'; + +SECONDS: '"Seconds"'; + +TIMESTAMPPATH: '"TimestampPath"'; + +TIMESTAMP: '"Timestamp"'; + +TIMEOUTSECONDS: '"TimeoutSeconds"'; + +TIMEOUTSECONDSPATH: '"TimeoutSecondsPath"'; + +HEARTBEATSECONDS: '"HeartbeatSeconds"'; + +HEARTBEATSECONDSPATH: '"HeartbeatSecondsPath"'; + +PROCESSORCONFIG: '"ProcessorConfig"'; + +MODE: '"Mode"'; + +INLINE: '"INLINE"'; + +DISTRIBUTED: '"DISTRIBUTED"'; + +EXECUTIONTYPE: '"ExecutionType"'; + +STANDARD: '"STANDARD"'; + +ITEMPROCESSOR: '"ItemProcessor"'; + +ITERATOR: '"Iterator"'; + +ITEMSELECTOR: '"ItemSelector"'; + +MAXCONCURRENCYPATH: '"MaxConcurrencyPath"'; + +MAXCONCURRENCY: '"MaxConcurrency"'; + +RESOURCE: '"Resource"'; + +INPUTPATH: '"InputPath"'; + +OUTPUTPATH: '"OutputPath"'; + +ITEMS: '"Items"'; + +ITEMSPATH: '"ItemsPath"'; + +RESULTPATH: '"ResultPath"'; + +RESULT: '"Result"'; + +PARAMETERS: '"Parameters"'; + +CREDENTIALS: '"Credentials"'; + +ROLEARN: '"RoleArn"'; + +ROLEARNPATH: '"RoleArn.$"'; + +RESULTSELECTOR: '"ResultSelector"'; + +ITEMREADER: '"ItemReader"'; + +READERCONFIG: '"ReaderConfig"'; + +INPUTTYPE: '"InputType"'; + +CSVHEADERLOCATION: '"CSVHeaderLocation"'; + +CSVHEADERS: '"CSVHeaders"'; + +MAXITEMS: '"MaxItems"'; + +MAXITEMSPATH: '"MaxItemsPath"'; + +TOLERATEDFAILURECOUNT: '"ToleratedFailureCount"'; + +TOLERATEDFAILURECOUNTPATH: '"ToleratedFailureCountPath"'; + +TOLERATEDFAILUREPERCENTAGE: '"ToleratedFailurePercentage"'; + +TOLERATEDFAILUREPERCENTAGEPATH: '"ToleratedFailurePercentagePath"'; + +LABEL: '"Label"'; + +RESULTWRITER: '"ResultWriter"'; + +NEXT: '"Next"'; + +END: '"End"'; + +CAUSE: '"Cause"'; + +CAUSEPATH: '"CausePath"'; + +ERROR: '"Error"'; + +ERRORPATH: '"ErrorPath"'; + +// Retry. +RETRY: '"Retry"'; + +ERROREQUALS: '"ErrorEquals"'; + +INTERVALSECONDS: '"IntervalSeconds"'; + +MAXATTEMPTS: '"MaxAttempts"'; + +BACKOFFRATE: '"BackoffRate"'; + +MAXDELAYSECONDS: '"MaxDelaySeconds"'; + +JITTERSTRATEGY: '"JitterStrategy"'; + +FULL: '"FULL"'; + +NONE: '"NONE"'; + +// Catch. +CATCH: '"Catch"'; + +// Query Language. +QUERYLANGUAGE: '"QueryLanguage"'; + +JSONPATH: '"JSONPath"'; + +JSONATA: '"JSONata"'; + +// Assign. +ASSIGN: '"Assign"'; + +// Output. +OUTPUT: '"Output"'; + +// Arguments. +ARGUMENTS: '"Arguments"'; + +// ErrorNames +ERRORNAMEStatesALL: '"States.ALL"'; + +ERRORNAMEStatesDataLimitExceeded: '"States.DataLimitExceeded"'; + +ERRORNAMEStatesHeartbeatTimeout: '"States.HeartbeatTimeout"'; + +ERRORNAMEStatesTimeout: '"States.Timeout"'; + +ERRORNAMEStatesTaskFailed: '"States.TaskFailed"'; + +ERRORNAMEStatesPermissions: '"States.Permissions"'; + +ERRORNAMEStatesResultPathMatchFailure: '"States.ResultPathMatchFailure"'; + +ERRORNAMEStatesParameterPathFailure: '"States.ParameterPathFailure"'; + +ERRORNAMEStatesBranchFailed: '"States.BranchFailed"'; + +ERRORNAMEStatesNoChoiceMatched: '"States.NoChoiceMatched"'; + +ERRORNAMEStatesIntrinsicFailure: '"States.IntrinsicFailure"'; + +ERRORNAMEStatesExceedToleratedFailureThreshold: '"States.ExceedToleratedFailureThreshold"'; + +ERRORNAMEStatesItemReaderFailed: '"States.ItemReaderFailed"'; + +ERRORNAMEStatesResultWriterFailed: '"States.ResultWriterFailed"'; + +ERRORNAMEStatesQueryEvaluationError: '"States.QueryEvaluationError"'; + +// Read-only: +ERRORNAMEStatesRuntime: '"States.Runtime"'; + +// Strings. +STRINGDOLLAR: '"' (ESC | SAFECODEPOINT)* '.$"'; + +STRINGPATHCONTEXTOBJ: '"$$' (ESC | SAFECODEPOINT)* '"'; + +STRINGPATH: '"$"' | '"$' ('.' | '[') (ESC | SAFECODEPOINT)* '"'; + +STRINGVAR: '"$' [a-zA-Z_] (ESC | SAFECODEPOINT)* '"'; + +STRINGINTRINSICFUNC: '"States.' (ESC | SAFECODEPOINT)+ '(' (ESC | SAFECODEPOINT)* ')"'; + +STRINGJSONATA: LJSONATA (ESC | SAFECODEPOINT)* RJSONATA; + +STRING: '"' (ESC | SAFECODEPOINT)* '"'; + +fragment ESC: '\\' (["\\/bfnrt] | UNICODE); + +fragment UNICODE: 'u' HEX HEX HEX HEX; + +fragment HEX: [0-9a-fA-F]; + +fragment SAFECODEPOINT: ~ ["\\\u0000-\u001F]; + +fragment LJSONATA: '"{%'; + +fragment RJSONATA: '%}"'; + +// Numbers. +INT: '0' | [1-9] [0-9]*; + +NUMBER: '-'? INT ('.' [0-9]+)? EXP?; + +fragment EXP: [Ee] [+\-]? INT; + +// Whitespace. +WS: [ \t\n\r]+ -> skip; \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLParser.g4 b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLParser.g4 new file mode 100644 index 0000000000000..a8868ea341269 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/ASLParser.g4 @@ -0,0 +1,576 @@ +// $antlr-format alignTrailingComments true, columnLimit 150, maxEmptyLinesToKeep 1, reflowComments false, useTab false +// $antlr-format allowShortRulesOnASingleLine true, allowShortBlocksOnASingleLine true, minEmptyLines 0, alignSemicolons ownLine +// $antlr-format alignColons trailing, singleLineOverrulesHangingColon true, alignLexerCommands true, alignLabels true, alignTrailers true + +parser grammar ASLParser; + +options { + tokenVocab = ASLLexer; +} + +state_machine: program_decl EOF; + +program_decl: LBRACE top_layer_stmt (COMMA top_layer_stmt)* RBRACE; + +top_layer_stmt: + comment_decl + | version_decl + | query_language_decl + | startat_decl + | states_decl + | timeout_seconds_decl +; + +startat_decl: STARTAT COLON string_literal; + +comment_decl: COMMENT COLON string_literal; + +version_decl: VERSION COLON string_literal; + +query_language_decl: QUERYLANGUAGE COLON (JSONPATH | JSONATA); + +state_stmt: + comment_decl + | query_language_decl + | type_decl + | input_path_decl + | resource_decl + | next_decl + | result_decl + | result_path_decl + | output_path_decl + | end_decl + | default_decl + | choices_decl + | error_decl + | cause_decl + | seconds_decl + | timestamp_decl + | items_decl + | items_path_decl + | item_processor_decl + | iterator_decl + | item_selector_decl + | item_reader_decl + | max_concurrency_decl + | timeout_seconds_decl + | heartbeat_seconds_decl + | branches_decl + | parameters_decl + | retry_decl + | catch_decl + | result_selector_decl + | tolerated_failure_count_decl + | tolerated_failure_percentage_decl + | label_decl + | result_writer_decl + | assign_decl + | arguments_decl + | output_decl + | credentials_decl +; + +states_decl: STATES COLON LBRACE state_decl (COMMA state_decl)* RBRACE; + +state_decl: string_literal COLON state_decl_body; + +state_decl_body: LBRACE state_stmt (COMMA state_stmt)* RBRACE; + +type_decl: TYPE COLON state_type; + +next_decl: NEXT COLON string_literal; + +resource_decl: RESOURCE COLON string_literal; + +input_path_decl: INPUTPATH COLON (NULL | string_sampler); + +result_decl: RESULT COLON json_value_decl; + +result_path_decl: RESULTPATH COLON (NULL | string_jsonpath); + +output_path_decl: OUTPUTPATH COLON (NULL | string_sampler); + +end_decl: END COLON (TRUE | FALSE); + +default_decl: DEFAULT COLON string_literal; + +error_decl: + ERROR COLON (string_jsonata | string_literal) # error + | ERRORPATH COLON string_expression_simple # error_path +; + +cause_decl: + CAUSE COLON (string_jsonata | string_literal) # cause + | CAUSEPATH COLON string_expression_simple # cause_path +; + +seconds_decl: + SECONDS COLON string_jsonata # seconds_jsonata + | SECONDS COLON INT # seconds_int + | SECONDSPATH COLON string_sampler # seconds_path +; + +timestamp_decl: + TIMESTAMP COLON (string_jsonata | string_literal) # timestamp + | TIMESTAMPPATH COLON string_sampler # timestamp_path +; + +items_decl: + ITEMS COLON jsonata_template_value_array # items_array + | ITEMS COLON string_jsonata # items_jsonata +; + +items_path_decl: ITEMSPATH COLON string_sampler; + +max_concurrency_decl: + MAXCONCURRENCY COLON string_jsonata # max_concurrency_jsonata + | MAXCONCURRENCY COLON INT # max_concurrency_int + | MAXCONCURRENCYPATH COLON string_sampler # max_concurrency_path +; + +parameters_decl: PARAMETERS COLON payload_tmpl_decl; + +credentials_decl: CREDENTIALS COLON LBRACE role_arn_decl RBRACE; + +role_arn_decl: + ROLEARN COLON (string_jsonata | string_literal) # role_arn + | ROLEARNPATH COLON string_expression_simple # role_path +; + +timeout_seconds_decl: + TIMEOUTSECONDS COLON string_jsonata # timeout_seconds_jsonata + | TIMEOUTSECONDS COLON INT # timeout_seconds_int + | TIMEOUTSECONDSPATH COLON string_sampler # timeout_seconds_path +; + +heartbeat_seconds_decl: + HEARTBEATSECONDS COLON string_jsonata # heartbeat_seconds_jsonata + | HEARTBEATSECONDS COLON INT # heartbeat_seconds_int + | HEARTBEATSECONDSPATH COLON string_sampler # heartbeat_seconds_path +; + +payload_tmpl_decl: LBRACE payload_binding (COMMA payload_binding)* RBRACE | LBRACE RBRACE; + +payload_binding: + STRINGDOLLAR COLON string_expression_simple # payload_binding_sample + | string_literal COLON payload_value_decl # payload_binding_value +; + +payload_arr_decl: LBRACK payload_value_decl (COMMA payload_value_decl)* RBRACK | LBRACK RBRACK; + +payload_value_decl: payload_arr_decl | payload_tmpl_decl | payload_value_lit; + +payload_value_lit: + NUMBER # payload_value_float + | INT # payload_value_int + | (TRUE | FALSE) # payload_value_bool + | NULL # payload_value_null + | string_literal # payload_value_str +; + +assign_decl: ASSIGN COLON assign_decl_body; + +assign_decl_body: LBRACE RBRACE | LBRACE assign_decl_binding (COMMA assign_decl_binding)* RBRACE; + +assign_decl_binding: assign_template_binding; + +assign_template_value_object: + LBRACE RBRACE + | LBRACE assign_template_binding (COMMA assign_template_binding)* RBRACE +; + +assign_template_binding: + STRINGDOLLAR COLON string_expression_simple # assign_template_binding_string_expression_simple + | string_literal COLON assign_template_value # assign_template_binding_value +; + +assign_template_value: + assign_template_value_object + | assign_template_value_array + | assign_template_value_terminal +; + +assign_template_value_array: + LBRACK RBRACK + | LBRACK assign_template_value (COMMA assign_template_value)* RBRACK +; + +assign_template_value_terminal: + NUMBER # assign_template_value_terminal_float + | INT # assign_template_value_terminal_int + | (TRUE | FALSE) # assign_template_value_terminal_bool + | NULL # assign_template_value_terminal_null + | string_jsonata # assign_template_value_terminal_string_jsonata + | string_literal # assign_template_value_terminal_string_literal +; + +arguments_decl: + ARGUMENTS COLON jsonata_template_value_object # arguments_jsonata_template_value_object + | ARGUMENTS COLON string_jsonata # arguments_string_jsonata +; + +output_decl: OUTPUT COLON jsonata_template_value; + +jsonata_template_value_object: + LBRACE RBRACE + | LBRACE jsonata_template_binding (COMMA jsonata_template_binding)* RBRACE +; + +jsonata_template_binding: string_literal COLON jsonata_template_value; + +jsonata_template_value: + jsonata_template_value_object + | jsonata_template_value_array + | jsonata_template_value_terminal +; + +jsonata_template_value_array: + LBRACK RBRACK + | LBRACK jsonata_template_value (COMMA jsonata_template_value)* RBRACK +; + +jsonata_template_value_terminal: + NUMBER # jsonata_template_value_terminal_float + | INT # jsonata_template_value_terminal_int + | (TRUE | FALSE) # jsonata_template_value_terminal_bool + | NULL # jsonata_template_value_terminal_null + | string_jsonata # jsonata_template_value_terminal_string_jsonata + | string_literal # jsonata_template_value_terminal_string_literal +; + +result_selector_decl: RESULTSELECTOR COLON payload_tmpl_decl; + +state_type: TASK | PASS | CHOICE | FAIL | SUCCEED | WAIT | MAP | PARALLEL; + +choices_decl: CHOICES COLON LBRACK choice_rule (COMMA choice_rule)* RBRACK; + +choice_rule: + LBRACE comparison_variable_stmt (COMMA comparison_variable_stmt)+ RBRACE # choice_rule_comparison_variable + | LBRACE comparison_composite_stmt (COMMA comparison_composite_stmt)* RBRACE # choice_rule_comparison_composite +; + +comparison_variable_stmt: + variable_decl + | comparison_func + | next_decl + | assign_decl + | output_decl + | comment_decl +; + +comparison_composite_stmt: comparison_composite | next_decl | assign_decl | comment_decl; + +comparison_composite: + choice_operator COLON (choice_rule | LBRACK choice_rule (COMMA choice_rule)* RBRACK) +; // TODO: this allows for Next definitions in nested choice_rules, is this supported at parse time? + +variable_decl: VARIABLE COLON string_sampler; + +comparison_func: + CONDITION COLON (TRUE | FALSE) # condition_lit + | CONDITION COLON string_jsonata # condition_string_jsonata + | comparison_op COLON string_variable_sample # comparison_func_string_variable_sample + | comparison_op COLON json_value_decl # comparison_func_value +; + +branches_decl: BRANCHES COLON LBRACK program_decl (COMMA program_decl)* RBRACK; + +item_processor_decl: + ITEMPROCESSOR COLON LBRACE item_processor_item (COMMA item_processor_item)* RBRACE +; + +item_processor_item: processor_config_decl | startat_decl | states_decl | comment_decl; + +processor_config_decl: + PROCESSORCONFIG COLON LBRACE processor_config_field (COMMA processor_config_field)* RBRACE +; + +processor_config_field: mode_decl | execution_decl; + +mode_decl: MODE COLON mode_type; + +mode_type: INLINE | DISTRIBUTED; + +execution_decl: EXECUTIONTYPE COLON execution_type; + +execution_type: STANDARD; + +iterator_decl: ITERATOR COLON LBRACE iterator_decl_item (COMMA iterator_decl_item)* RBRACE; + +iterator_decl_item: startat_decl | states_decl | comment_decl | processor_config_decl; + +item_selector_decl: ITEMSELECTOR COLON assign_template_value_object; + +item_reader_decl: ITEMREADER COLON LBRACE items_reader_field (COMMA items_reader_field)* RBRACE; + +items_reader_field: resource_decl | reader_config_decl | parameters_decl | arguments_decl; + +reader_config_decl: + READERCONFIG COLON LBRACE reader_config_field (COMMA reader_config_field)* RBRACE +; + +reader_config_field: + input_type_decl + | csv_header_location_decl + | csv_headers_decl + | max_items_decl +; + +input_type_decl: INPUTTYPE COLON string_literal; + +csv_header_location_decl: CSVHEADERLOCATION COLON string_literal; + +csv_headers_decl: + CSVHEADERS COLON LBRACK string_literal (COMMA string_literal)* RBRACK +; // TODO: are empty "CSVHeaders" list values supported? + +max_items_decl: + MAXITEMS COLON string_jsonata # max_items_string_jsonata + | MAXITEMS COLON INT # max_items_int + | MAXITEMSPATH COLON string_sampler # max_items_path +; + +tolerated_failure_count_decl: + TOLERATEDFAILURECOUNT COLON string_jsonata # tolerated_failure_count_string_jsonata + | TOLERATEDFAILURECOUNT COLON INT # tolerated_failure_count_int + | TOLERATEDFAILURECOUNTPATH COLON string_sampler # tolerated_failure_count_path +; + +tolerated_failure_percentage_decl: + TOLERATEDFAILUREPERCENTAGE COLON string_jsonata # tolerated_failure_percentage_string_jsonata + | TOLERATEDFAILUREPERCENTAGE COLON NUMBER # tolerated_failure_percentage_number + | TOLERATEDFAILUREPERCENTAGEPATH COLON string_sampler # tolerated_failure_percentage_path +; + +label_decl: LABEL COLON string_literal; + +result_writer_decl: + RESULTWRITER COLON LBRACE result_writer_field (COMMA result_writer_field)* RBRACE +; + +result_writer_field: resource_decl | parameters_decl; + +retry_decl: RETRY COLON LBRACK (retrier_decl (COMMA retrier_decl)*)? RBRACK; + +retrier_decl: LBRACE retrier_stmt (COMMA retrier_stmt)* RBRACE; + +retrier_stmt: + error_equals_decl + | interval_seconds_decl + | max_attempts_decl + | backoff_rate_decl + | max_delay_seconds_decl + | jitter_strategy_decl + | comment_decl +; + +error_equals_decl: ERROREQUALS COLON LBRACK error_name (COMMA error_name)* RBRACK; + +interval_seconds_decl: INTERVALSECONDS COLON INT; + +max_attempts_decl: MAXATTEMPTS COLON INT; + +backoff_rate_decl: BACKOFFRATE COLON (INT | NUMBER); + +max_delay_seconds_decl: MAXDELAYSECONDS COLON INT; + +jitter_strategy_decl: JITTERSTRATEGY COLON (FULL | NONE); + +catch_decl: CATCH COLON LBRACK (catcher_decl (COMMA catcher_decl)*)? RBRACK; + +catcher_decl: LBRACE catcher_stmt (COMMA catcher_stmt)* RBRACE; + +catcher_stmt: + error_equals_decl + | result_path_decl + | next_decl + | assign_decl + | output_decl + | comment_decl +; + +comparison_op: + BOOLEANEQUALS + | BOOLEANQUALSPATH + | ISBOOLEAN + | ISNULL + | ISNUMERIC + | ISPRESENT + | ISSTRING + | ISTIMESTAMP + | NUMERICEQUALS + | NUMERICEQUALSPATH + | NUMERICGREATERTHAN + | NUMERICGREATERTHANPATH + | NUMERICGREATERTHANEQUALS + | NUMERICGREATERTHANEQUALSPATH + | NUMERICLESSTHAN + | NUMERICLESSTHANPATH + | NUMERICLESSTHANEQUALS + | NUMERICLESSTHANEQUALSPATH + | STRINGEQUALS + | STRINGEQUALSPATH + | STRINGGREATERTHAN + | STRINGGREATERTHANPATH + | STRINGGREATERTHANEQUALS + | STRINGGREATERTHANEQUALSPATH + | STRINGLESSTHAN + | STRINGLESSTHANPATH + | STRINGLESSTHANEQUALS + | STRINGLESSTHANEQUALSPATH + | STRINGMATCHES + | TIMESTAMPEQUALS + | TIMESTAMPEQUALSPATH + | TIMESTAMPGREATERTHAN + | TIMESTAMPGREATERTHANPATH + | TIMESTAMPGREATERTHANEQUALS + | TIMESTAMPGREATERTHANEQUALSPATH + | TIMESTAMPLESSTHAN + | TIMESTAMPLESSTHANPATH + | TIMESTAMPLESSTHANEQUALS + | TIMESTAMPLESSTHANEQUALSPATH +; + +choice_operator: NOT | AND | OR; + +states_error_name: + ERRORNAMEStatesALL + | ERRORNAMEStatesDataLimitExceeded + | ERRORNAMEStatesHeartbeatTimeout + | ERRORNAMEStatesTimeout + | ERRORNAMEStatesTaskFailed + | ERRORNAMEStatesPermissions + | ERRORNAMEStatesResultPathMatchFailure + | ERRORNAMEStatesParameterPathFailure + | ERRORNAMEStatesBranchFailed + | ERRORNAMEStatesNoChoiceMatched + | ERRORNAMEStatesIntrinsicFailure + | ERRORNAMEStatesExceedToleratedFailureThreshold + | ERRORNAMEStatesItemReaderFailed + | ERRORNAMEStatesResultWriterFailed + | ERRORNAMEStatesRuntime + | ERRORNAMEStatesQueryEvaluationError +; + +error_name: states_error_name | string_literal; + +json_obj_decl: LBRACE json_binding (COMMA json_binding)* RBRACE | LBRACE RBRACE; + +json_binding: string_literal COLON json_value_decl; + +json_arr_decl: LBRACK json_value_decl (COMMA json_value_decl)* RBRACK | LBRACK RBRACK; + +json_value_decl: + NUMBER + | INT + | TRUE + | FALSE + | NULL + | json_binding + | json_arr_decl + | json_obj_decl + | string_literal +; + +string_sampler : string_jsonpath | string_context_path | string_variable_sample; +string_expression_simple : string_sampler | string_intrinsic_function; +string_expression : string_expression_simple | string_jsonata; + +string_jsonpath : STRINGPATH; +string_context_path : STRINGPATHCONTEXTOBJ; +string_variable_sample : STRINGVAR; +string_intrinsic_function : STRINGINTRINSICFUNC; +string_jsonata : STRINGJSONATA; +string_literal: + STRING + | STRINGDOLLAR + | soft_string_keyword + | comparison_op + | choice_operator + | states_error_name + | string_expression +; + +soft_string_keyword: + QUERYLANGUAGE + | ASSIGN + | ARGUMENTS + | OUTPUT + | COMMENT + | STATES + | STARTAT + | NEXTSTATE + | TYPE + | TASK + | CHOICE + | FAIL + | SUCCEED + | PASS + | WAIT + | PARALLEL + | MAP + | CHOICES + | CONDITION + | VARIABLE + | DEFAULT + | BRANCHES + | SECONDSPATH + | SECONDS + | TIMESTAMPPATH + | TIMESTAMP + | TIMEOUTSECONDS + | TIMEOUTSECONDSPATH + | HEARTBEATSECONDS + | HEARTBEATSECONDSPATH + | PROCESSORCONFIG + | MODE + | INLINE + | DISTRIBUTED + | EXECUTIONTYPE + | STANDARD + | ITEMS + | ITEMPROCESSOR + | ITERATOR + | ITEMSELECTOR + | MAXCONCURRENCY + | MAXCONCURRENCYPATH + | RESOURCE + | INPUTPATH + | OUTPUTPATH + | ITEMSPATH + | RESULTPATH + | RESULT + | PARAMETERS + | CREDENTIALS + | ROLEARN + | ROLEARNPATH + | RESULTSELECTOR + | ITEMREADER + | READERCONFIG + | INPUTTYPE + | CSVHEADERLOCATION + | CSVHEADERS + | MAXITEMS + | MAXITEMSPATH + | TOLERATEDFAILURECOUNT + | TOLERATEDFAILURECOUNTPATH + | TOLERATEDFAILUREPERCENTAGE + | TOLERATEDFAILUREPERCENTAGEPATH + | LABEL + | RESULTWRITER + | NEXT + | END + | CAUSE + | ERROR + | RETRY + | ERROREQUALS + | INTERVALSECONDS + | MAXATTEMPTS + | BACKOFFRATE + | MAXDELAYSECONDS + | JITTERSTRATEGY + | FULL + | NONE + | CATCH + | VERSION +; \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/Makefile b/localstack-core/localstack/services/stepfunctions/asl/antlr/Makefile new file mode 100644 index 0000000000000..c74eba7c02dfb --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/Makefile @@ -0,0 +1,39 @@ +# Define default ANTLR4 tool dump directory. +ANTLR4_DIR = .antlr + +# Define the default input and output directory for ANTLR4 grammars. +ANTLR4_SRC_DIR = . +ANTLR4_TARGET_DIR = $(ANTLR4_SRC_DIR)/runtime +ANTLR4_GRAMMAR_FILES = $(wildcard $(ANTLR4_SRC_DIR)/*.g4) + +# Define the default ANTLR4 version and jar file. +ANTLR4_VERSION ?= 4.13.2 +ANTLR4_JAR ?= $(ANTLR4_DIR)/antlr-$(ANTLR4_VERSION)-complete.jar + +# Define the download path for ANTLR4 parser generator. +ANTLR4_URL = https://www.antlr.org/download/antlr-$(ANTLR4_VERSION)-complete.jar + +# Define the default ANTLR4 run command and options. +RUN_ANTLR4 = java -jar $(ANTLR4_JAR) -Dlanguage=Python3 -visitor + +install: ## Install the dependencies for compiling the ANTLR4 project. + @npm i -g --save-dev antlr-format@2.1.5 + @mkdir -p $(ANTLR4_DIR) + @curl -o $(ANTLR4_JAR) $(ANTLR4_URL) + +build: $(ANTLR4_GRAMMAR_FILES) ## Build the ANTLR4 project. + @echo "Compiling grammar files in $(ANTLR_SRC_DIR)" + @mkdir -p $(ANTLR4_TARGET_DIR) + @for grammar in $^ ; do \ + echo "Processing $$grammar..."; \ + $(RUN_ANTLR4) $$grammar -o $(ANTLR4_TARGET_DIR) -Xexact-output-dir; \ + done + +format: + @antlr-format *.g4 + +clean: ## Clean up the ANTLR4 project directory. + rm -rf $(ANTLR4_TARGET_DIR) + rm -rf $(ANTLR4_DIR) + +.PHONY: install build format clean diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicLexer.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicLexer.py new file mode 100644 index 0000000000000..cef42738dc801 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicLexer.py @@ -0,0 +1,243 @@ +# Generated from ASLIntrinsicLexer.g4 by ANTLR 4.13.2 +from antlr4 import * +from io import StringIO +import sys +if sys.version_info[1] > 5: + from typing import TextIO +else: + from typing.io import TextIO + + +def serializedATN(): + return [ + 4,0,34,412,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5, + 2,6,7,6,2,7,7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12,2, + 13,7,13,2,14,7,14,2,15,7,15,2,16,7,16,2,17,7,17,2,18,7,18,2,19,7, + 19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,2,24,7,24,2,25,7,25,2, + 26,7,26,2,27,7,27,2,28,7,28,2,29,7,29,2,30,7,30,2,31,7,31,2,32,7, + 32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,2,37,7,37,2,38,7,38,2, + 39,7,39,2,40,7,40,1,0,1,0,1,0,1,0,1,1,1,1,1,1,1,2,1,2,1,2,1,2,1, + 3,3,3,96,8,3,1,3,1,3,3,3,100,8,3,1,3,3,3,103,8,3,5,3,105,8,3,10, + 3,12,3,108,9,3,1,4,1,4,1,4,5,4,113,8,4,10,4,12,4,116,9,4,1,4,1,4, + 1,5,1,5,1,6,1,6,1,7,1,7,1,8,1,8,1,9,1,9,1,10,1,10,1,10,1,10,1,10, + 1,11,1,11,1,11,1,11,1,11,1,11,1,12,1,12,1,12,1,12,1,12,1,12,1,12, + 1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14, + 1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,15,1,15,1,15,1,15,1,15,1,15, + 1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,16,1,16,1,16,1,16,1,16,1,16, + 1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17, + 1,17,1,17,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1,18,1,18, + 1,18,1,18,1,18,1,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19,1,19, + 1,19,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,20, + 1,20,1,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21,1,21, + 1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,23, + 1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,24, + 1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,25, + 1,25,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,1,26,1,26,1,26,1,26, + 1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,28, + 1,28,1,28,1,28,1,28,1,28,1,28,1,28,1,29,1,29,1,29,1,29,1,29,1,29, + 1,29,1,29,1,29,1,29,1,29,1,29,1,30,1,30,1,30,1,30,1,30,1,31,1,31, + 1,31,5,31,344,8,31,10,31,12,31,347,9,31,1,31,1,31,1,32,1,32,1,32, + 3,32,354,8,32,1,33,1,33,1,33,1,33,1,33,1,33,1,34,1,34,1,35,1,35, + 1,36,3,36,367,8,36,1,36,1,36,1,36,5,36,372,8,36,10,36,12,36,375, + 9,36,3,36,377,8,36,1,37,3,37,380,8,37,1,37,1,37,1,37,4,37,385,8, + 37,11,37,12,37,386,3,37,389,8,37,1,37,3,37,392,8,37,1,38,1,38,3, + 38,396,8,38,1,38,1,38,1,39,1,39,4,39,402,8,39,11,39,12,39,403,1, + 40,4,40,407,8,40,11,40,12,40,408,1,40,1,40,1,345,0,41,1,1,3,2,5, + 3,7,0,9,0,11,4,13,5,15,6,17,7,19,8,21,9,23,10,25,11,27,12,29,13, + 31,14,33,15,35,16,37,17,39,18,41,19,43,20,45,21,47,22,49,23,51,24, + 53,25,55,26,57,27,59,28,61,29,63,30,65,0,67,0,69,0,71,0,73,31,75, + 32,77,0,79,33,81,34,1,0,9,1,0,93,93,3,0,48,57,65,70,97,102,3,0,0, + 31,39,39,92,92,1,0,49,57,1,0,48,57,2,0,69,69,101,101,2,0,43,43,45, + 45,4,0,48,57,65,90,95,95,97,122,2,0,9,10,32,32,424,0,1,1,0,0,0,0, + 3,1,0,0,0,0,5,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,0,17, + 1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27, + 1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37, + 1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,0,0,0,47, + 1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,55,1,0,0,0,0,57, + 1,0,0,0,0,59,1,0,0,0,0,61,1,0,0,0,0,63,1,0,0,0,0,73,1,0,0,0,0,75, + 1,0,0,0,0,79,1,0,0,0,0,81,1,0,0,0,1,83,1,0,0,0,3,87,1,0,0,0,5,90, + 1,0,0,0,7,95,1,0,0,0,9,109,1,0,0,0,11,119,1,0,0,0,13,121,1,0,0,0, + 15,123,1,0,0,0,17,125,1,0,0,0,19,127,1,0,0,0,21,129,1,0,0,0,23,134, + 1,0,0,0,25,140,1,0,0,0,27,147,1,0,0,0,29,154,1,0,0,0,31,167,1,0, + 0,0,33,180,1,0,0,0,35,186,1,0,0,0,37,201,1,0,0,0,39,215,1,0,0,0, + 41,226,1,0,0,0,43,239,1,0,0,0,45,251,1,0,0,0,47,263,1,0,0,0,49,276, + 1,0,0,0,51,289,1,0,0,0,53,294,1,0,0,0,55,304,1,0,0,0,57,315,1,0, + 0,0,59,323,1,0,0,0,61,335,1,0,0,0,63,340,1,0,0,0,65,350,1,0,0,0, + 67,355,1,0,0,0,69,361,1,0,0,0,71,363,1,0,0,0,73,366,1,0,0,0,75,379, + 1,0,0,0,77,393,1,0,0,0,79,401,1,0,0,0,81,406,1,0,0,0,83,84,3,11, + 5,0,84,85,3,11,5,0,85,86,3,7,3,0,86,2,1,0,0,0,87,88,3,11,5,0,88, + 89,3,7,3,0,89,4,1,0,0,0,90,91,3,11,5,0,91,92,3,79,39,0,92,93,3,7, + 3,0,93,6,1,0,0,0,94,96,3,9,4,0,95,94,1,0,0,0,95,96,1,0,0,0,96,106, + 1,0,0,0,97,99,3,19,9,0,98,100,3,79,39,0,99,98,1,0,0,0,99,100,1,0, + 0,0,100,102,1,0,0,0,101,103,3,9,4,0,102,101,1,0,0,0,102,103,1,0, + 0,0,103,105,1,0,0,0,104,97,1,0,0,0,105,108,1,0,0,0,106,104,1,0,0, + 0,106,107,1,0,0,0,107,8,1,0,0,0,108,106,1,0,0,0,109,114,5,91,0,0, + 110,113,3,9,4,0,111,113,8,0,0,0,112,110,1,0,0,0,112,111,1,0,0,0, + 113,116,1,0,0,0,114,112,1,0,0,0,114,115,1,0,0,0,115,117,1,0,0,0, + 116,114,1,0,0,0,117,118,5,93,0,0,118,10,1,0,0,0,119,120,5,36,0,0, + 120,12,1,0,0,0,121,122,5,40,0,0,122,14,1,0,0,0,123,124,5,41,0,0, + 124,16,1,0,0,0,125,126,5,44,0,0,126,18,1,0,0,0,127,128,5,46,0,0, + 128,20,1,0,0,0,129,130,5,116,0,0,130,131,5,114,0,0,131,132,5,117, + 0,0,132,133,5,101,0,0,133,22,1,0,0,0,134,135,5,102,0,0,135,136,5, + 97,0,0,136,137,5,108,0,0,137,138,5,115,0,0,138,139,5,101,0,0,139, + 24,1,0,0,0,140,141,5,83,0,0,141,142,5,116,0,0,142,143,5,97,0,0,143, + 144,5,116,0,0,144,145,5,101,0,0,145,146,5,115,0,0,146,26,1,0,0,0, + 147,148,5,70,0,0,148,149,5,111,0,0,149,150,5,114,0,0,150,151,5,109, + 0,0,151,152,5,97,0,0,152,153,5,116,0,0,153,28,1,0,0,0,154,155,5, + 83,0,0,155,156,5,116,0,0,156,157,5,114,0,0,157,158,5,105,0,0,158, + 159,5,110,0,0,159,160,5,103,0,0,160,161,5,84,0,0,161,162,5,111,0, + 0,162,163,5,74,0,0,163,164,5,115,0,0,164,165,5,111,0,0,165,166,5, + 110,0,0,166,30,1,0,0,0,167,168,5,74,0,0,168,169,5,115,0,0,169,170, + 5,111,0,0,170,171,5,110,0,0,171,172,5,84,0,0,172,173,5,111,0,0,173, + 174,5,83,0,0,174,175,5,116,0,0,175,176,5,114,0,0,176,177,5,105,0, + 0,177,178,5,110,0,0,178,179,5,103,0,0,179,32,1,0,0,0,180,181,5,65, + 0,0,181,182,5,114,0,0,182,183,5,114,0,0,183,184,5,97,0,0,184,185, + 5,121,0,0,185,34,1,0,0,0,186,187,5,65,0,0,187,188,5,114,0,0,188, + 189,5,114,0,0,189,190,5,97,0,0,190,191,5,121,0,0,191,192,5,80,0, + 0,192,193,5,97,0,0,193,194,5,114,0,0,194,195,5,116,0,0,195,196,5, + 105,0,0,196,197,5,116,0,0,197,198,5,105,0,0,198,199,5,111,0,0,199, + 200,5,110,0,0,200,36,1,0,0,0,201,202,5,65,0,0,202,203,5,114,0,0, + 203,204,5,114,0,0,204,205,5,97,0,0,205,206,5,121,0,0,206,207,5,67, + 0,0,207,208,5,111,0,0,208,209,5,110,0,0,209,210,5,116,0,0,210,211, + 5,97,0,0,211,212,5,105,0,0,212,213,5,110,0,0,213,214,5,115,0,0,214, + 38,1,0,0,0,215,216,5,65,0,0,216,217,5,114,0,0,217,218,5,114,0,0, + 218,219,5,97,0,0,219,220,5,121,0,0,220,221,5,82,0,0,221,222,5,97, + 0,0,222,223,5,110,0,0,223,224,5,103,0,0,224,225,5,101,0,0,225,40, + 1,0,0,0,226,227,5,65,0,0,227,228,5,114,0,0,228,229,5,114,0,0,229, + 230,5,97,0,0,230,231,5,121,0,0,231,232,5,71,0,0,232,233,5,101,0, + 0,233,234,5,116,0,0,234,235,5,73,0,0,235,236,5,116,0,0,236,237,5, + 101,0,0,237,238,5,109,0,0,238,42,1,0,0,0,239,240,5,65,0,0,240,241, + 5,114,0,0,241,242,5,114,0,0,242,243,5,97,0,0,243,244,5,121,0,0,244, + 245,5,76,0,0,245,246,5,101,0,0,246,247,5,110,0,0,247,248,5,103,0, + 0,248,249,5,116,0,0,249,250,5,104,0,0,250,44,1,0,0,0,251,252,5,65, + 0,0,252,253,5,114,0,0,253,254,5,114,0,0,254,255,5,97,0,0,255,256, + 5,121,0,0,256,257,5,85,0,0,257,258,5,110,0,0,258,259,5,105,0,0,259, + 260,5,113,0,0,260,261,5,117,0,0,261,262,5,101,0,0,262,46,1,0,0,0, + 263,264,5,66,0,0,264,265,5,97,0,0,265,266,5,115,0,0,266,267,5,101, + 0,0,267,268,5,54,0,0,268,269,5,52,0,0,269,270,5,69,0,0,270,271,5, + 110,0,0,271,272,5,99,0,0,272,273,5,111,0,0,273,274,5,100,0,0,274, + 275,5,101,0,0,275,48,1,0,0,0,276,277,5,66,0,0,277,278,5,97,0,0,278, + 279,5,115,0,0,279,280,5,101,0,0,280,281,5,54,0,0,281,282,5,52,0, + 0,282,283,5,68,0,0,283,284,5,101,0,0,284,285,5,99,0,0,285,286,5, + 111,0,0,286,287,5,100,0,0,287,288,5,101,0,0,288,50,1,0,0,0,289,290, + 5,72,0,0,290,291,5,97,0,0,291,292,5,115,0,0,292,293,5,104,0,0,293, + 52,1,0,0,0,294,295,5,74,0,0,295,296,5,115,0,0,296,297,5,111,0,0, + 297,298,5,110,0,0,298,299,5,77,0,0,299,300,5,101,0,0,300,301,5,114, + 0,0,301,302,5,103,0,0,302,303,5,101,0,0,303,54,1,0,0,0,304,305,5, + 77,0,0,305,306,5,97,0,0,306,307,5,116,0,0,307,308,5,104,0,0,308, + 309,5,82,0,0,309,310,5,97,0,0,310,311,5,110,0,0,311,312,5,100,0, + 0,312,313,5,111,0,0,313,314,5,109,0,0,314,56,1,0,0,0,315,316,5,77, + 0,0,316,317,5,97,0,0,317,318,5,116,0,0,318,319,5,104,0,0,319,320, + 5,65,0,0,320,321,5,100,0,0,321,322,5,100,0,0,322,58,1,0,0,0,323, + 324,5,83,0,0,324,325,5,116,0,0,325,326,5,114,0,0,326,327,5,105,0, + 0,327,328,5,110,0,0,328,329,5,103,0,0,329,330,5,83,0,0,330,331,5, + 112,0,0,331,332,5,108,0,0,332,333,5,105,0,0,333,334,5,116,0,0,334, + 60,1,0,0,0,335,336,5,85,0,0,336,337,5,85,0,0,337,338,5,73,0,0,338, + 339,5,68,0,0,339,62,1,0,0,0,340,345,5,39,0,0,341,344,3,65,32,0,342, + 344,3,71,35,0,343,341,1,0,0,0,343,342,1,0,0,0,344,347,1,0,0,0,345, + 346,1,0,0,0,345,343,1,0,0,0,346,348,1,0,0,0,347,345,1,0,0,0,348, + 349,5,39,0,0,349,64,1,0,0,0,350,353,5,92,0,0,351,354,3,67,33,0,352, + 354,9,0,0,0,353,351,1,0,0,0,353,352,1,0,0,0,354,66,1,0,0,0,355,356, + 5,117,0,0,356,357,3,69,34,0,357,358,3,69,34,0,358,359,3,69,34,0, + 359,360,3,69,34,0,360,68,1,0,0,0,361,362,7,1,0,0,362,70,1,0,0,0, + 363,364,8,2,0,0,364,72,1,0,0,0,365,367,5,45,0,0,366,365,1,0,0,0, + 366,367,1,0,0,0,367,376,1,0,0,0,368,377,5,48,0,0,369,373,7,3,0,0, + 370,372,7,4,0,0,371,370,1,0,0,0,372,375,1,0,0,0,373,371,1,0,0,0, + 373,374,1,0,0,0,374,377,1,0,0,0,375,373,1,0,0,0,376,368,1,0,0,0, + 376,369,1,0,0,0,377,74,1,0,0,0,378,380,5,45,0,0,379,378,1,0,0,0, + 379,380,1,0,0,0,380,381,1,0,0,0,381,388,3,73,36,0,382,384,5,46,0, + 0,383,385,7,4,0,0,384,383,1,0,0,0,385,386,1,0,0,0,386,384,1,0,0, + 0,386,387,1,0,0,0,387,389,1,0,0,0,388,382,1,0,0,0,388,389,1,0,0, + 0,389,391,1,0,0,0,390,392,3,77,38,0,391,390,1,0,0,0,391,392,1,0, + 0,0,392,76,1,0,0,0,393,395,7,5,0,0,394,396,7,6,0,0,395,394,1,0,0, + 0,395,396,1,0,0,0,396,397,1,0,0,0,397,398,3,73,36,0,398,78,1,0,0, + 0,399,402,7,7,0,0,400,402,3,67,33,0,401,399,1,0,0,0,401,400,1,0, + 0,0,402,403,1,0,0,0,403,401,1,0,0,0,403,404,1,0,0,0,404,80,1,0,0, + 0,405,407,7,8,0,0,406,405,1,0,0,0,407,408,1,0,0,0,408,406,1,0,0, + 0,408,409,1,0,0,0,409,410,1,0,0,0,410,411,6,40,0,0,411,82,1,0,0, + 0,21,0,95,99,102,106,112,114,343,345,353,366,373,376,379,386,388, + 391,395,401,403,408,1,6,0,0 + ] + +class ASLIntrinsicLexer(Lexer): + + atn = ATNDeserializer().deserialize(serializedATN()) + + decisionsToDFA = [ DFA(ds, i) for i, ds in enumerate(atn.decisionToState) ] + + CONTEXT_PATH_STRING = 1 + JSON_PATH_STRING = 2 + STRING_VARIABLE = 3 + DOLLAR = 4 + LPAREN = 5 + RPAREN = 6 + COMMA = 7 + DOT = 8 + TRUE = 9 + FALSE = 10 + States = 11 + Format = 12 + StringToJson = 13 + JsonToString = 14 + Array = 15 + ArrayPartition = 16 + ArrayContains = 17 + ArrayRange = 18 + ArrayGetItem = 19 + ArrayLength = 20 + ArrayUnique = 21 + Base64Encode = 22 + Base64Decode = 23 + Hash = 24 + JsonMerge = 25 + MathRandom = 26 + MathAdd = 27 + StringSplit = 28 + UUID = 29 + STRING = 30 + INT = 31 + NUMBER = 32 + IDENTIFIER = 33 + WS = 34 + + channelNames = [ u"DEFAULT_TOKEN_CHANNEL", u"HIDDEN" ] + + modeNames = [ "DEFAULT_MODE" ] + + literalNames = [ "", + "'$'", "'('", "')'", "','", "'.'", "'true'", "'false'", "'States'", + "'Format'", "'StringToJson'", "'JsonToString'", "'Array'", "'ArrayPartition'", + "'ArrayContains'", "'ArrayRange'", "'ArrayGetItem'", "'ArrayLength'", + "'ArrayUnique'", "'Base64Encode'", "'Base64Decode'", "'Hash'", + "'JsonMerge'", "'MathRandom'", "'MathAdd'", "'StringSplit'", + "'UUID'" ] + + symbolicNames = [ "", + "CONTEXT_PATH_STRING", "JSON_PATH_STRING", "STRING_VARIABLE", + "DOLLAR", "LPAREN", "RPAREN", "COMMA", "DOT", "TRUE", "FALSE", + "States", "Format", "StringToJson", "JsonToString", "Array", + "ArrayPartition", "ArrayContains", "ArrayRange", "ArrayGetItem", + "ArrayLength", "ArrayUnique", "Base64Encode", "Base64Decode", + "Hash", "JsonMerge", "MathRandom", "MathAdd", "StringSplit", + "UUID", "STRING", "INT", "NUMBER", "IDENTIFIER", "WS" ] + + ruleNames = [ "CONTEXT_PATH_STRING", "JSON_PATH_STRING", "STRING_VARIABLE", + "JSON_PATH_BODY", "JSON_PATH_BRACK", "DOLLAR", "LPAREN", + "RPAREN", "COMMA", "DOT", "TRUE", "FALSE", "States", "Format", + "StringToJson", "JsonToString", "Array", "ArrayPartition", + "ArrayContains", "ArrayRange", "ArrayGetItem", "ArrayLength", + "ArrayUnique", "Base64Encode", "Base64Decode", "Hash", + "JsonMerge", "MathRandom", "MathAdd", "StringSplit", "UUID", + "STRING", "ESC", "UNICODE", "HEX", "SAFECODEPOINT", "INT", + "NUMBER", "EXP", "IDENTIFIER", "WS" ] + + grammarFileName = "ASLIntrinsicLexer.g4" + + def __init__(self, input=None, output:TextIO = sys.stdout): + super().__init__(input, output) + self.checkVersion("4.13.2") + self._interp = LexerATNSimulator(self, self.atn, self.decisionsToDFA, PredictionContextCache()) + self._actions = None + self._predicates = None + + diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParser.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParser.py new file mode 100644 index 0000000000000..13a9cebf3cb7a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParser.py @@ -0,0 +1,716 @@ +# Generated from ASLIntrinsicParser.g4 by ANTLR 4.13.2 +# encoding: utf-8 +from antlr4 import * +from io import StringIO +import sys +if sys.version_info[1] > 5: + from typing import TextIO +else: + from typing.io import TextIO + +def serializedATN(): + return [ + 4,1,34,46,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,1,0,1,0,1,0,1, + 1,1,1,1,1,1,1,1,1,1,2,1,2,1,3,1,3,1,3,1,3,5,3,25,8,3,10,3,12,3,28, + 9,3,1,3,1,3,1,3,1,3,3,3,34,8,3,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,3, + 4,44,8,4,1,4,0,0,5,0,2,4,6,8,0,2,1,0,12,29,1,0,9,10,49,0,10,1,0, + 0,0,2,13,1,0,0,0,4,18,1,0,0,0,6,33,1,0,0,0,8,43,1,0,0,0,10,11,3, + 2,1,0,11,12,5,0,0,1,12,1,1,0,0,0,13,14,5,11,0,0,14,15,5,8,0,0,15, + 16,3,4,2,0,16,17,3,6,3,0,17,3,1,0,0,0,18,19,7,0,0,0,19,5,1,0,0,0, + 20,21,5,5,0,0,21,26,3,8,4,0,22,23,5,7,0,0,23,25,3,8,4,0,24,22,1, + 0,0,0,25,28,1,0,0,0,26,24,1,0,0,0,26,27,1,0,0,0,27,29,1,0,0,0,28, + 26,1,0,0,0,29,30,5,6,0,0,30,34,1,0,0,0,31,32,5,5,0,0,32,34,5,6,0, + 0,33,20,1,0,0,0,33,31,1,0,0,0,34,7,1,0,0,0,35,44,5,30,0,0,36,44, + 5,31,0,0,37,44,5,32,0,0,38,44,7,1,0,0,39,44,5,1,0,0,40,44,5,2,0, + 0,41,44,5,3,0,0,42,44,3,2,1,0,43,35,1,0,0,0,43,36,1,0,0,0,43,37, + 1,0,0,0,43,38,1,0,0,0,43,39,1,0,0,0,43,40,1,0,0,0,43,41,1,0,0,0, + 43,42,1,0,0,0,44,9,1,0,0,0,3,26,33,43 + ] + +class ASLIntrinsicParser ( Parser ): + + grammarFileName = "ASLIntrinsicParser.g4" + + atn = ATNDeserializer().deserialize(serializedATN()) + + decisionsToDFA = [ DFA(ds, i) for i, ds in enumerate(atn.decisionToState) ] + + sharedContextCache = PredictionContextCache() + + literalNames = [ "", "", "", "", + "'$'", "'('", "')'", "','", "'.'", "'true'", "'false'", + "'States'", "'Format'", "'StringToJson'", "'JsonToString'", + "'Array'", "'ArrayPartition'", "'ArrayContains'", "'ArrayRange'", + "'ArrayGetItem'", "'ArrayLength'", "'ArrayUnique'", + "'Base64Encode'", "'Base64Decode'", "'Hash'", "'JsonMerge'", + "'MathRandom'", "'MathAdd'", "'StringSplit'", "'UUID'" ] + + symbolicNames = [ "", "CONTEXT_PATH_STRING", "JSON_PATH_STRING", + "STRING_VARIABLE", "DOLLAR", "LPAREN", "RPAREN", "COMMA", + "DOT", "TRUE", "FALSE", "States", "Format", "StringToJson", + "JsonToString", "Array", "ArrayPartition", "ArrayContains", + "ArrayRange", "ArrayGetItem", "ArrayLength", "ArrayUnique", + "Base64Encode", "Base64Decode", "Hash", "JsonMerge", + "MathRandom", "MathAdd", "StringSplit", "UUID", "STRING", + "INT", "NUMBER", "IDENTIFIER", "WS" ] + + RULE_func_decl = 0 + RULE_states_func_decl = 1 + RULE_state_fun_name = 2 + RULE_func_arg_list = 3 + RULE_func_arg = 4 + + ruleNames = [ "func_decl", "states_func_decl", "state_fun_name", "func_arg_list", + "func_arg" ] + + EOF = Token.EOF + CONTEXT_PATH_STRING=1 + JSON_PATH_STRING=2 + STRING_VARIABLE=3 + DOLLAR=4 + LPAREN=5 + RPAREN=6 + COMMA=7 + DOT=8 + TRUE=9 + FALSE=10 + States=11 + Format=12 + StringToJson=13 + JsonToString=14 + Array=15 + ArrayPartition=16 + ArrayContains=17 + ArrayRange=18 + ArrayGetItem=19 + ArrayLength=20 + ArrayUnique=21 + Base64Encode=22 + Base64Decode=23 + Hash=24 + JsonMerge=25 + MathRandom=26 + MathAdd=27 + StringSplit=28 + UUID=29 + STRING=30 + INT=31 + NUMBER=32 + IDENTIFIER=33 + WS=34 + + def __init__(self, input:TokenStream, output:TextIO = sys.stdout): + super().__init__(input, output) + self.checkVersion("4.13.2") + self._interp = ParserATNSimulator(self, self.atn, self.decisionsToDFA, self.sharedContextCache) + self._predicates = None + + + + + class Func_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def states_func_decl(self): + return self.getTypedRuleContext(ASLIntrinsicParser.States_func_declContext,0) + + + def EOF(self): + return self.getToken(ASLIntrinsicParser.EOF, 0) + + def getRuleIndex(self): + return ASLIntrinsicParser.RULE_func_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_decl" ): + listener.enterFunc_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_decl" ): + listener.exitFunc_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_decl" ): + return visitor.visitFunc_decl(self) + else: + return visitor.visitChildren(self) + + + + + def func_decl(self): + + localctx = ASLIntrinsicParser.Func_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 0, self.RULE_func_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 10 + self.states_func_decl() + self.state = 11 + self.match(ASLIntrinsicParser.EOF) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class States_func_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def States(self): + return self.getToken(ASLIntrinsicParser.States, 0) + + def DOT(self): + return self.getToken(ASLIntrinsicParser.DOT, 0) + + def state_fun_name(self): + return self.getTypedRuleContext(ASLIntrinsicParser.State_fun_nameContext,0) + + + def func_arg_list(self): + return self.getTypedRuleContext(ASLIntrinsicParser.Func_arg_listContext,0) + + + def getRuleIndex(self): + return ASLIntrinsicParser.RULE_states_func_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterStates_func_decl" ): + listener.enterStates_func_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitStates_func_decl" ): + listener.exitStates_func_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitStates_func_decl" ): + return visitor.visitStates_func_decl(self) + else: + return visitor.visitChildren(self) + + + + + def states_func_decl(self): + + localctx = ASLIntrinsicParser.States_func_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 2, self.RULE_states_func_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 13 + self.match(ASLIntrinsicParser.States) + self.state = 14 + self.match(ASLIntrinsicParser.DOT) + self.state = 15 + self.state_fun_name() + self.state = 16 + self.func_arg_list() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class State_fun_nameContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def Format(self): + return self.getToken(ASLIntrinsicParser.Format, 0) + + def StringToJson(self): + return self.getToken(ASLIntrinsicParser.StringToJson, 0) + + def JsonToString(self): + return self.getToken(ASLIntrinsicParser.JsonToString, 0) + + def Array(self): + return self.getToken(ASLIntrinsicParser.Array, 0) + + def ArrayPartition(self): + return self.getToken(ASLIntrinsicParser.ArrayPartition, 0) + + def ArrayContains(self): + return self.getToken(ASLIntrinsicParser.ArrayContains, 0) + + def ArrayRange(self): + return self.getToken(ASLIntrinsicParser.ArrayRange, 0) + + def ArrayGetItem(self): + return self.getToken(ASLIntrinsicParser.ArrayGetItem, 0) + + def ArrayLength(self): + return self.getToken(ASLIntrinsicParser.ArrayLength, 0) + + def ArrayUnique(self): + return self.getToken(ASLIntrinsicParser.ArrayUnique, 0) + + def Base64Encode(self): + return self.getToken(ASLIntrinsicParser.Base64Encode, 0) + + def Base64Decode(self): + return self.getToken(ASLIntrinsicParser.Base64Decode, 0) + + def Hash(self): + return self.getToken(ASLIntrinsicParser.Hash, 0) + + def JsonMerge(self): + return self.getToken(ASLIntrinsicParser.JsonMerge, 0) + + def MathRandom(self): + return self.getToken(ASLIntrinsicParser.MathRandom, 0) + + def MathAdd(self): + return self.getToken(ASLIntrinsicParser.MathAdd, 0) + + def StringSplit(self): + return self.getToken(ASLIntrinsicParser.StringSplit, 0) + + def UUID(self): + return self.getToken(ASLIntrinsicParser.UUID, 0) + + def getRuleIndex(self): + return ASLIntrinsicParser.RULE_state_fun_name + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterState_fun_name" ): + listener.enterState_fun_name(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitState_fun_name" ): + listener.exitState_fun_name(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitState_fun_name" ): + return visitor.visitState_fun_name(self) + else: + return visitor.visitChildren(self) + + + + + def state_fun_name(self): + + localctx = ASLIntrinsicParser.State_fun_nameContext(self, self._ctx, self.state) + self.enterRule(localctx, 4, self.RULE_state_fun_name) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 18 + _la = self._input.LA(1) + if not((((_la) & ~0x3f) == 0 and ((1 << _la) & 1073737728) != 0)): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Func_arg_listContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LPAREN(self): + return self.getToken(ASLIntrinsicParser.LPAREN, 0) + + def func_arg(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLIntrinsicParser.Func_argContext) + else: + return self.getTypedRuleContext(ASLIntrinsicParser.Func_argContext,i) + + + def RPAREN(self): + return self.getToken(ASLIntrinsicParser.RPAREN, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLIntrinsicParser.COMMA) + else: + return self.getToken(ASLIntrinsicParser.COMMA, i) + + def getRuleIndex(self): + return ASLIntrinsicParser.RULE_func_arg_list + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_list" ): + listener.enterFunc_arg_list(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_list" ): + listener.exitFunc_arg_list(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_list" ): + return visitor.visitFunc_arg_list(self) + else: + return visitor.visitChildren(self) + + + + + def func_arg_list(self): + + localctx = ASLIntrinsicParser.Func_arg_listContext(self, self._ctx, self.state) + self.enterRule(localctx, 6, self.RULE_func_arg_list) + self._la = 0 # Token type + try: + self.state = 33 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,1,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 20 + self.match(ASLIntrinsicParser.LPAREN) + self.state = 21 + self.func_arg() + self.state = 26 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==7: + self.state = 22 + self.match(ASLIntrinsicParser.COMMA) + self.state = 23 + self.func_arg() + self.state = 28 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 29 + self.match(ASLIntrinsicParser.RPAREN) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 31 + self.match(ASLIntrinsicParser.LPAREN) + self.state = 32 + self.match(ASLIntrinsicParser.RPAREN) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Func_argContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLIntrinsicParser.RULE_func_arg + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Func_arg_context_pathContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def CONTEXT_PATH_STRING(self): + return self.getToken(ASLIntrinsicParser.CONTEXT_PATH_STRING, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_context_path" ): + listener.enterFunc_arg_context_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_context_path" ): + listener.exitFunc_arg_context_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_context_path" ): + return visitor.visitFunc_arg_context_path(self) + else: + return visitor.visitChildren(self) + + + class Func_arg_floatContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def NUMBER(self): + return self.getToken(ASLIntrinsicParser.NUMBER, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_float" ): + listener.enterFunc_arg_float(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_float" ): + listener.exitFunc_arg_float(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_float" ): + return visitor.visitFunc_arg_float(self) + else: + return visitor.visitChildren(self) + + + class Func_arg_varContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def STRING_VARIABLE(self): + return self.getToken(ASLIntrinsicParser.STRING_VARIABLE, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_var" ): + listener.enterFunc_arg_var(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_var" ): + listener.exitFunc_arg_var(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_var" ): + return visitor.visitFunc_arg_var(self) + else: + return visitor.visitChildren(self) + + + class Func_arg_func_declContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def states_func_decl(self): + return self.getTypedRuleContext(ASLIntrinsicParser.States_func_declContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_func_decl" ): + listener.enterFunc_arg_func_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_func_decl" ): + listener.exitFunc_arg_func_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_func_decl" ): + return visitor.visitFunc_arg_func_decl(self) + else: + return visitor.visitChildren(self) + + + class Func_arg_intContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def INT(self): + return self.getToken(ASLIntrinsicParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_int" ): + listener.enterFunc_arg_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_int" ): + listener.exitFunc_arg_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_int" ): + return visitor.visitFunc_arg_int(self) + else: + return visitor.visitChildren(self) + + + class Func_arg_boolContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def TRUE(self): + return self.getToken(ASLIntrinsicParser.TRUE, 0) + def FALSE(self): + return self.getToken(ASLIntrinsicParser.FALSE, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_bool" ): + listener.enterFunc_arg_bool(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_bool" ): + listener.exitFunc_arg_bool(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_bool" ): + return visitor.visitFunc_arg_bool(self) + else: + return visitor.visitChildren(self) + + + class Func_arg_stringContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def STRING(self): + return self.getToken(ASLIntrinsicParser.STRING, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_string" ): + listener.enterFunc_arg_string(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_string" ): + listener.exitFunc_arg_string(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_string" ): + return visitor.visitFunc_arg_string(self) + else: + return visitor.visitChildren(self) + + + class Func_arg_json_pathContext(Func_argContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLIntrinsicParser.Func_argContext + super().__init__(parser) + self.copyFrom(ctx) + + def JSON_PATH_STRING(self): + return self.getToken(ASLIntrinsicParser.JSON_PATH_STRING, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterFunc_arg_json_path" ): + listener.enterFunc_arg_json_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitFunc_arg_json_path" ): + listener.exitFunc_arg_json_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitFunc_arg_json_path" ): + return visitor.visitFunc_arg_json_path(self) + else: + return visitor.visitChildren(self) + + + + def func_arg(self): + + localctx = ASLIntrinsicParser.Func_argContext(self, self._ctx, self.state) + self.enterRule(localctx, 8, self.RULE_func_arg) + self._la = 0 # Token type + try: + self.state = 43 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [30]: + localctx = ASLIntrinsicParser.Func_arg_stringContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 35 + self.match(ASLIntrinsicParser.STRING) + pass + elif token in [31]: + localctx = ASLIntrinsicParser.Func_arg_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 36 + self.match(ASLIntrinsicParser.INT) + pass + elif token in [32]: + localctx = ASLIntrinsicParser.Func_arg_floatContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 37 + self.match(ASLIntrinsicParser.NUMBER) + pass + elif token in [9, 10]: + localctx = ASLIntrinsicParser.Func_arg_boolContext(self, localctx) + self.enterOuterAlt(localctx, 4) + self.state = 38 + _la = self._input.LA(1) + if not(_la==9 or _la==10): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + pass + elif token in [1]: + localctx = ASLIntrinsicParser.Func_arg_context_pathContext(self, localctx) + self.enterOuterAlt(localctx, 5) + self.state = 39 + self.match(ASLIntrinsicParser.CONTEXT_PATH_STRING) + pass + elif token in [2]: + localctx = ASLIntrinsicParser.Func_arg_json_pathContext(self, localctx) + self.enterOuterAlt(localctx, 6) + self.state = 40 + self.match(ASLIntrinsicParser.JSON_PATH_STRING) + pass + elif token in [3]: + localctx = ASLIntrinsicParser.Func_arg_varContext(self, localctx) + self.enterOuterAlt(localctx, 7) + self.state = 41 + self.match(ASLIntrinsicParser.STRING_VARIABLE) + pass + elif token in [11]: + localctx = ASLIntrinsicParser.Func_arg_func_declContext(self, localctx) + self.enterOuterAlt(localctx, 8) + self.state = 42 + self.states_func_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + + + diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParserListener.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParserListener.py new file mode 100644 index 0000000000000..80d2a8868036e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParserListener.py @@ -0,0 +1,120 @@ +# Generated from ASLIntrinsicParser.g4 by ANTLR 4.13.2 +from antlr4 import * +if "." in __name__: + from .ASLIntrinsicParser import ASLIntrinsicParser +else: + from ASLIntrinsicParser import ASLIntrinsicParser + +# This class defines a complete listener for a parse tree produced by ASLIntrinsicParser. +class ASLIntrinsicParserListener(ParseTreeListener): + + # Enter a parse tree produced by ASLIntrinsicParser#func_decl. + def enterFunc_decl(self, ctx:ASLIntrinsicParser.Func_declContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_decl. + def exitFunc_decl(self, ctx:ASLIntrinsicParser.Func_declContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#states_func_decl. + def enterStates_func_decl(self, ctx:ASLIntrinsicParser.States_func_declContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#states_func_decl. + def exitStates_func_decl(self, ctx:ASLIntrinsicParser.States_func_declContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#state_fun_name. + def enterState_fun_name(self, ctx:ASLIntrinsicParser.State_fun_nameContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#state_fun_name. + def exitState_fun_name(self, ctx:ASLIntrinsicParser.State_fun_nameContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_list. + def enterFunc_arg_list(self, ctx:ASLIntrinsicParser.Func_arg_listContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_list. + def exitFunc_arg_list(self, ctx:ASLIntrinsicParser.Func_arg_listContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_string. + def enterFunc_arg_string(self, ctx:ASLIntrinsicParser.Func_arg_stringContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_string. + def exitFunc_arg_string(self, ctx:ASLIntrinsicParser.Func_arg_stringContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_int. + def enterFunc_arg_int(self, ctx:ASLIntrinsicParser.Func_arg_intContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_int. + def exitFunc_arg_int(self, ctx:ASLIntrinsicParser.Func_arg_intContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_float. + def enterFunc_arg_float(self, ctx:ASLIntrinsicParser.Func_arg_floatContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_float. + def exitFunc_arg_float(self, ctx:ASLIntrinsicParser.Func_arg_floatContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_bool. + def enterFunc_arg_bool(self, ctx:ASLIntrinsicParser.Func_arg_boolContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_bool. + def exitFunc_arg_bool(self, ctx:ASLIntrinsicParser.Func_arg_boolContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_context_path. + def enterFunc_arg_context_path(self, ctx:ASLIntrinsicParser.Func_arg_context_pathContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_context_path. + def exitFunc_arg_context_path(self, ctx:ASLIntrinsicParser.Func_arg_context_pathContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_json_path. + def enterFunc_arg_json_path(self, ctx:ASLIntrinsicParser.Func_arg_json_pathContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_json_path. + def exitFunc_arg_json_path(self, ctx:ASLIntrinsicParser.Func_arg_json_pathContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_var. + def enterFunc_arg_var(self, ctx:ASLIntrinsicParser.Func_arg_varContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_var. + def exitFunc_arg_var(self, ctx:ASLIntrinsicParser.Func_arg_varContext): + pass + + + # Enter a parse tree produced by ASLIntrinsicParser#func_arg_func_decl. + def enterFunc_arg_func_decl(self, ctx:ASLIntrinsicParser.Func_arg_func_declContext): + pass + + # Exit a parse tree produced by ASLIntrinsicParser#func_arg_func_decl. + def exitFunc_arg_func_decl(self, ctx:ASLIntrinsicParser.Func_arg_func_declContext): + pass + + + +del ASLIntrinsicParser \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParserVisitor.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParserVisitor.py new file mode 100644 index 0000000000000..aaff82cbb9778 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLIntrinsicParserVisitor.py @@ -0,0 +1,73 @@ +# Generated from ASLIntrinsicParser.g4 by ANTLR 4.13.2 +from antlr4 import * +if "." in __name__: + from .ASLIntrinsicParser import ASLIntrinsicParser +else: + from ASLIntrinsicParser import ASLIntrinsicParser + +# This class defines a complete generic visitor for a parse tree produced by ASLIntrinsicParser. + +class ASLIntrinsicParserVisitor(ParseTreeVisitor): + + # Visit a parse tree produced by ASLIntrinsicParser#func_decl. + def visitFunc_decl(self, ctx:ASLIntrinsicParser.Func_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#states_func_decl. + def visitStates_func_decl(self, ctx:ASLIntrinsicParser.States_func_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#state_fun_name. + def visitState_fun_name(self, ctx:ASLIntrinsicParser.State_fun_nameContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_list. + def visitFunc_arg_list(self, ctx:ASLIntrinsicParser.Func_arg_listContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_string. + def visitFunc_arg_string(self, ctx:ASLIntrinsicParser.Func_arg_stringContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_int. + def visitFunc_arg_int(self, ctx:ASLIntrinsicParser.Func_arg_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_float. + def visitFunc_arg_float(self, ctx:ASLIntrinsicParser.Func_arg_floatContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_bool. + def visitFunc_arg_bool(self, ctx:ASLIntrinsicParser.Func_arg_boolContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_context_path. + def visitFunc_arg_context_path(self, ctx:ASLIntrinsicParser.Func_arg_context_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_json_path. + def visitFunc_arg_json_path(self, ctx:ASLIntrinsicParser.Func_arg_json_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_var. + def visitFunc_arg_var(self, ctx:ASLIntrinsicParser.Func_arg_varContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLIntrinsicParser#func_arg_func_decl. + def visitFunc_arg_func_decl(self, ctx:ASLIntrinsicParser.Func_arg_func_declContext): + return self.visitChildren(ctx) + + + +del ASLIntrinsicParser \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLLexer.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLLexer.py new file mode 100644 index 0000000000000..578ffc75320f7 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLLexer.py @@ -0,0 +1,1413 @@ +# Generated from ASLLexer.g4 by ANTLR 4.13.2 +from antlr4 import * +from io import StringIO +import sys +if sys.version_info[1] > 5: + from typing import TextIO +else: + from typing.io import TextIO + + +def serializedATN(): + return [ + 4,0,162,2863,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7, + 5,2,6,7,6,2,7,7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12, + 2,13,7,13,2,14,7,14,2,15,7,15,2,16,7,16,2,17,7,17,2,18,7,18,2,19, + 7,19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,2,24,7,24,2,25,7,25, + 2,26,7,26,2,27,7,27,2,28,7,28,2,29,7,29,2,30,7,30,2,31,7,31,2,32, + 7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,2,37,7,37,2,38,7,38, + 2,39,7,39,2,40,7,40,2,41,7,41,2,42,7,42,2,43,7,43,2,44,7,44,2,45, + 7,45,2,46,7,46,2,47,7,47,2,48,7,48,2,49,7,49,2,50,7,50,2,51,7,51, + 2,52,7,52,2,53,7,53,2,54,7,54,2,55,7,55,2,56,7,56,2,57,7,57,2,58, + 7,58,2,59,7,59,2,60,7,60,2,61,7,61,2,62,7,62,2,63,7,63,2,64,7,64, + 2,65,7,65,2,66,7,66,2,67,7,67,2,68,7,68,2,69,7,69,2,70,7,70,2,71, + 7,71,2,72,7,72,2,73,7,73,2,74,7,74,2,75,7,75,2,76,7,76,2,77,7,77, + 2,78,7,78,2,79,7,79,2,80,7,80,2,81,7,81,2,82,7,82,2,83,7,83,2,84, + 7,84,2,85,7,85,2,86,7,86,2,87,7,87,2,88,7,88,2,89,7,89,2,90,7,90, + 2,91,7,91,2,92,7,92,2,93,7,93,2,94,7,94,2,95,7,95,2,96,7,96,2,97, + 7,97,2,98,7,98,2,99,7,99,2,100,7,100,2,101,7,101,2,102,7,102,2,103, + 7,103,2,104,7,104,2,105,7,105,2,106,7,106,2,107,7,107,2,108,7,108, + 2,109,7,109,2,110,7,110,2,111,7,111,2,112,7,112,2,113,7,113,2,114, + 7,114,2,115,7,115,2,116,7,116,2,117,7,117,2,118,7,118,2,119,7,119, + 2,120,7,120,2,121,7,121,2,122,7,122,2,123,7,123,2,124,7,124,2,125, + 7,125,2,126,7,126,2,127,7,127,2,128,7,128,2,129,7,129,2,130,7,130, + 2,131,7,131,2,132,7,132,2,133,7,133,2,134,7,134,2,135,7,135,2,136, + 7,136,2,137,7,137,2,138,7,138,2,139,7,139,2,140,7,140,2,141,7,141, + 2,142,7,142,2,143,7,143,2,144,7,144,2,145,7,145,2,146,7,146,2,147, + 7,147,2,148,7,148,2,149,7,149,2,150,7,150,2,151,7,151,2,152,7,152, + 2,153,7,153,2,154,7,154,2,155,7,155,2,156,7,156,2,157,7,157,2,158, + 7,158,2,159,7,159,2,160,7,160,2,161,7,161,2,162,7,162,2,163,7,163, + 2,164,7,164,2,165,7,165,2,166,7,166,2,167,7,167,2,168,7,168,1,0, + 1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,6,1,6,1,6,1,6,1,6, + 1,7,1,7,1,7,1,7,1,7,1,7,1,8,1,8,1,8,1,8,1,8,1,9,1,9,1,9,1,9,1,9, + 1,9,1,9,1,9,1,9,1,9,1,10,1,10,1,10,1,10,1,10,1,10,1,10,1,10,1,10, + 1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,11,1,12,1,12,1,12, + 1,12,1,12,1,12,1,12,1,12,1,12,1,12,1,12,1,12,1,13,1,13,1,13,1,13, + 1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,14, + 1,15,1,15,1,15,1,15,1,15,1,15,1,15,1,16,1,16,1,16,1,16,1,16,1,16, + 1,16,1,16,1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,18,1,18,1,18, + 1,18,1,18,1,18,1,18,1,18,1,18,1,18,1,19,1,19,1,19,1,19,1,19,1,19, + 1,19,1,20,1,20,1,20,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,21,1,21, + 1,21,1,21,1,21,1,21,1,21,1,21,1,22,1,22,1,22,1,22,1,22,1,22,1,23, + 1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,23,1,24,1,24,1,24,1,24, + 1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,24,1,25,1,25,1,25,1,25,1,25, + 1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,1,26,1,26, + 1,26,1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27, + 1,27,1,28,1,28,1,28,1,28,1,28,1,28,1,29,1,29,1,29,1,29,1,29,1,29, + 1,29,1,29,1,29,1,29,1,29,1,29,1,29,1,29,1,29,1,29,1,30,1,30,1,30, + 1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30, + 1,30,1,30,1,30,1,30,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31, + 1,31,1,31,1,31,1,32,1,32,1,32,1,32,1,32,1,32,1,32,1,32,1,32,1,33, + 1,33,1,33,1,33,1,33,1,33,1,33,1,33,1,33,1,33,1,33,1,33,1,34,1,34, + 1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,34,1,35,1,35,1,35, + 1,35,1,35,1,35,1,35,1,35,1,35,1,35,1,35,1,36,1,36,1,36,1,36,1,36, + 1,36,1,36,1,36,1,36,1,36,1,36,1,36,1,36,1,36,1,37,1,37,1,37,1,37, + 1,37,1,37,1,38,1,38,1,38,1,38,1,38,1,38,1,38,1,38,1,38,1,38,1,38, + 1,38,1,38,1,38,1,38,1,38,1,39,1,39,1,39,1,39,1,39,1,39,1,39,1,39, + 1,39,1,39,1,39,1,39,1,39,1,39,1,39,1,39,1,39,1,39,1,39,1,39,1,40, + 1,40,1,40,1,40,1,40,1,40,1,40,1,40,1,40,1,40,1,40,1,40,1,40,1,40, + 1,40,1,40,1,40,1,40,1,40,1,40,1,40,1,41,1,41,1,41,1,41,1,41,1,41, + 1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41, + 1,41,1,41,1,41,1,41,1,41,1,41,1,42,1,42,1,42,1,42,1,42,1,42,1,42, + 1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,42, + 1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,43,1,43,1,43,1,43,1,43,1,43, + 1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43, + 1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,44, + 1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44,1,44, + 1,44,1,44,1,44,1,44,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45, + 1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,45, + 1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46, + 1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,46,1,47,1,47, + 1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47, + 1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47,1,47, + 1,48,1,48,1,48,1,48,1,48,1,49,1,49,1,49,1,49,1,49,1,49,1,49,1,49, + 1,49,1,49,1,49,1,49,1,49,1,49,1,49,1,50,1,50,1,50,1,50,1,50,1,50, + 1,50,1,50,1,50,1,50,1,50,1,50,1,50,1,50,1,50,1,50,1,50,1,50,1,50, + 1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51, + 1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,52,1,52,1,52,1,52,1,52,1,52, + 1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52, + 1,52,1,52,1,52,1,52,1,52,1,53,1,53,1,53,1,53,1,53,1,53,1,53,1,53, + 1,53,1,53,1,53,1,53,1,53,1,53,1,53,1,53,1,53,1,53,1,53,1,53,1,53, + 1,53,1,53,1,53,1,53,1,53,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54, + 1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54, + 1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,55,1,55,1,55,1,55, + 1,55,1,55,1,55,1,55,1,55,1,55,1,55,1,55,1,55,1,55,1,55,1,55,1,55, + 1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56, + 1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,57,1,57,1,57,1,57,1,57, + 1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57, + 1,57,1,57,1,57,1,57,1,57,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58, + 1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58, + 1,58,1,58,1,58,1,58,1,58,1,58,1,59,1,59,1,59,1,59,1,59,1,59,1,59, + 1,59,1,59,1,59,1,59,1,59,1,59,1,59,1,59,1,59,1,60,1,60,1,60,1,60, + 1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60, + 1,60,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61, + 1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,62,1,62,1,62, + 1,62,1,62,1,62,1,62,1,62,1,62,1,62,1,62,1,62,1,62,1,62,1,62,1,62, + 1,62,1,62,1,62,1,62,1,62,1,62,1,62,1,63,1,63,1,63,1,63,1,63,1,63, + 1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,63, + 1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,64,1,64,1,64,1,64,1,64, + 1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64, + 1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,64,1,65,1,65, + 1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65, + 1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65, + 1,65,1,65,1,65,1,65,1,65,1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,66, + 1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,67, + 1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67, + 1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,67,1,68,1,68,1,68, + 1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68, + 1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,69,1,69,1,69, + 1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69, + 1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,69, + 1,69,1,70,1,70,1,70,1,70,1,70,1,70,1,70,1,70,1,70,1,70,1,70,1,70, + 1,70,1,70,1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,72, + 1,72,1,72,1,72,1,72,1,72,1,72,1,72,1,72,1,72,1,72,1,72,1,72,1,72, + 1,72,1,72,1,73,1,73,1,73,1,73,1,73,1,73,1,73,1,73,1,73,1,73,1,73, + 1,73,1,74,1,74,1,74,1,74,1,74,1,74,1,74,1,74,1,74,1,74,1,74,1,74, + 1,74,1,74,1,74,1,74,1,74,1,75,1,75,1,75,1,75,1,75,1,75,1,75,1,75, + 1,75,1,75,1,75,1,75,1,75,1,75,1,75,1,75,1,75,1,75,1,75,1,75,1,75, + 1,76,1,76,1,76,1,76,1,76,1,76,1,76,1,76,1,76,1,76,1,76,1,76,1,76, + 1,76,1,76,1,76,1,76,1,76,1,76,1,77,1,77,1,77,1,77,1,77,1,77,1,77, + 1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77, + 1,77,1,77,1,77,1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,78, + 1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,79,1,79,1,79,1,79,1,79, + 1,79,1,79,1,80,1,80,1,80,1,80,1,80,1,80,1,80,1,80,1,80,1,81,1,81, + 1,81,1,81,1,81,1,81,1,81,1,81,1,81,1,81,1,81,1,81,1,81,1,81,1,82, + 1,82,1,82,1,82,1,82,1,82,1,82,1,82,1,82,1,82,1,82,1,82,1,82,1,82, + 1,82,1,82,1,83,1,83,1,83,1,83,1,83,1,83,1,83,1,83,1,83,1,83,1,83, + 1,84,1,84,1,84,1,84,1,84,1,84,1,84,1,84,1,84,1,84,1,84,1,84,1,84, + 1,84,1,84,1,84,1,85,1,85,1,85,1,85,1,85,1,85,1,85,1,85,1,85,1,85, + 1,85,1,86,1,86,1,86,1,86,1,86,1,86,1,86,1,86,1,86,1,86,1,86,1,86, + 1,86,1,86,1,86,1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,87, + 1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,87,1,88,1,88, + 1,88,1,88,1,88,1,88,1,88,1,88,1,88,1,88,1,88,1,88,1,88,1,88,1,88, + 1,88,1,88,1,89,1,89,1,89,1,89,1,89,1,89,1,89,1,89,1,89,1,89,1,89, + 1,90,1,90,1,90,1,90,1,90,1,90,1,90,1,90,1,90,1,90,1,90,1,90,1,91, + 1,91,1,91,1,91,1,91,1,91,1,91,1,91,1,91,1,91,1,91,1,91,1,91,1,92, + 1,92,1,92,1,92,1,92,1,92,1,92,1,92,1,93,1,93,1,93,1,93,1,93,1,93, + 1,93,1,93,1,93,1,93,1,93,1,93,1,94,1,94,1,94,1,94,1,94,1,94,1,94, + 1,94,1,94,1,94,1,94,1,94,1,94,1,95,1,95,1,95,1,95,1,95,1,95,1,95, + 1,95,1,95,1,96,1,96,1,96,1,96,1,96,1,96,1,96,1,96,1,96,1,96,1,96, + 1,96,1,96,1,97,1,97,1,97,1,97,1,97,1,97,1,97,1,97,1,97,1,97,1,97, + 1,97,1,97,1,97,1,98,1,98,1,98,1,98,1,98,1,98,1,98,1,98,1,98,1,98, + 1,99,1,99,1,99,1,99,1,99,1,99,1,99,1,99,1,99,1,99,1,99,1,99,1,100, + 1,100,1,100,1,100,1,100,1,100,1,100,1,100,1,100,1,100,1,100,1,100, + 1,100,1,100,1,100,1,100,1,100,1,101,1,101,1,101,1,101,1,101,1,101, + 1,101,1,101,1,101,1,101,1,101,1,101,1,101,1,102,1,102,1,102,1,102, + 1,102,1,102,1,102,1,102,1,102,1,102,1,102,1,102,1,102,1,102,1,102, + 1,103,1,103,1,103,1,103,1,103,1,103,1,103,1,103,1,103,1,103,1,103, + 1,103,1,104,1,104,1,104,1,104,1,104,1,104,1,104,1,104,1,104,1,104, + 1,104,1,104,1,104,1,104,1,104,1,104,1,104,1,104,1,104,1,104,1,105, + 1,105,1,105,1,105,1,105,1,105,1,105,1,105,1,105,1,105,1,105,1,105, + 1,105,1,106,1,106,1,106,1,106,1,106,1,106,1,106,1,106,1,106,1,106, + 1,106,1,107,1,107,1,107,1,107,1,107,1,107,1,107,1,107,1,107,1,107, + 1,107,1,107,1,107,1,107,1,107,1,108,1,108,1,108,1,108,1,108,1,108, + 1,108,1,108,1,108,1,108,1,108,1,108,1,108,1,108,1,108,1,108,1,108, + 1,108,1,108,1,108,1,108,1,108,1,108,1,108,1,109,1,109,1,109,1,109, + 1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109, + 1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109,1,109, + 1,109,1,109,1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110, + 1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110, + 1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,110,1,111,1,111, + 1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111, + 1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111, + 1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,111,1,112,1,112, + 1,112,1,112,1,112,1,112,1,112,1,112,1,113,1,113,1,113,1,113,1,113, + 1,113,1,113,1,113,1,113,1,113,1,113,1,113,1,113,1,113,1,113,1,114, + 1,114,1,114,1,114,1,114,1,114,1,114,1,115,1,115,1,115,1,115,1,115, + 1,115,1,116,1,116,1,116,1,116,1,116,1,116,1,116,1,116,1,117,1,117, + 1,117,1,117,1,117,1,117,1,117,1,117,1,117,1,117,1,117,1,117,1,118, + 1,118,1,118,1,118,1,118,1,118,1,118,1,118,1,119,1,119,1,119,1,119, + 1,119,1,119,1,119,1,119,1,119,1,119,1,119,1,119,1,120,1,120,1,120, + 1,120,1,120,1,120,1,120,1,120,1,121,1,121,1,121,1,121,1,121,1,121, + 1,121,1,121,1,121,1,121,1,121,1,121,1,121,1,121,1,122,1,122,1,122, + 1,122,1,122,1,122,1,122,1,122,1,122,1,122,1,122,1,122,1,122,1,122, + 1,122,1,122,1,122,1,122,1,123,1,123,1,123,1,123,1,123,1,123,1,123, + 1,123,1,123,1,123,1,123,1,123,1,123,1,123,1,124,1,124,1,124,1,124, + 1,124,1,124,1,124,1,124,1,124,1,124,1,124,1,124,1,124,1,124,1,125, + 1,125,1,125,1,125,1,125,1,125,1,125,1,125,1,125,1,125,1,125,1,125, + 1,125,1,125,1,125,1,125,1,125,1,125,1,126,1,126,1,126,1,126,1,126, + 1,126,1,126,1,126,1,126,1,126,1,126,1,126,1,126,1,126,1,126,1,126, + 1,126,1,127,1,127,1,127,1,127,1,127,1,127,1,127,1,128,1,128,1,128, + 1,128,1,128,1,128,1,128,1,129,1,129,1,129,1,129,1,129,1,129,1,129, + 1,129,1,130,1,130,1,130,1,130,1,130,1,130,1,130,1,130,1,130,1,130, + 1,130,1,130,1,130,1,130,1,130,1,130,1,131,1,131,1,131,1,131,1,131, + 1,131,1,131,1,131,1,131,1,131,1,131,1,132,1,132,1,132,1,132,1,132, + 1,132,1,132,1,132,1,132,1,132,1,133,1,133,1,133,1,133,1,133,1,133, + 1,133,1,133,1,133,1,134,1,134,1,134,1,134,1,134,1,134,1,134,1,134, + 1,134,1,135,1,135,1,135,1,135,1,135,1,135,1,135,1,135,1,135,1,135, + 1,135,1,135,1,136,1,136,1,136,1,136,1,136,1,136,1,136,1,136,1,136, + 1,136,1,136,1,136,1,136,1,137,1,137,1,137,1,137,1,137,1,137,1,137, + 1,137,1,137,1,137,1,137,1,137,1,137,1,137,1,137,1,137,1,137,1,137, + 1,137,1,137,1,137,1,137,1,137,1,137,1,137,1,137,1,137,1,138,1,138, + 1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138, + 1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138,1,138, + 1,138,1,138,1,139,1,139,1,139,1,139,1,139,1,139,1,139,1,139,1,139, + 1,139,1,139,1,139,1,139,1,139,1,139,1,139,1,139,1,140,1,140,1,140, + 1,140,1,140,1,140,1,140,1,140,1,140,1,140,1,140,1,140,1,140,1,140, + 1,140,1,140,1,140,1,140,1,140,1,140,1,141,1,141,1,141,1,141,1,141, + 1,141,1,141,1,141,1,141,1,141,1,141,1,141,1,141,1,141,1,141,1,141, + 1,141,1,141,1,141,1,141,1,141,1,142,1,142,1,142,1,142,1,142,1,142, + 1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142, + 1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142,1,142, + 1,142,1,142,1,142,1,142,1,143,1,143,1,143,1,143,1,143,1,143,1,143, + 1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143, + 1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143,1,143, + 1,143,1,144,1,144,1,144,1,144,1,144,1,144,1,144,1,144,1,144,1,144, + 1,144,1,144,1,144,1,144,1,144,1,144,1,144,1,144,1,144,1,144,1,144, + 1,144,1,145,1,145,1,145,1,145,1,145,1,145,1,145,1,145,1,145,1,145, + 1,145,1,145,1,145,1,145,1,145,1,145,1,145,1,145,1,145,1,145,1,145, + 1,145,1,145,1,145,1,145,1,146,1,146,1,146,1,146,1,146,1,146,1,146, + 1,146,1,146,1,146,1,146,1,146,1,146,1,146,1,146,1,146,1,146,1,146, + 1,146,1,146,1,146,1,146,1,146,1,146,1,146,1,146,1,147,1,147,1,147, + 1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147, + 1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147, + 1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147,1,147, + 1,147,1,147,1,147,1,147,1,147,1,148,1,148,1,148,1,148,1,148,1,148, + 1,148,1,148,1,148,1,148,1,148,1,148,1,148,1,148,1,148,1,148,1,148, + 1,148,1,148,1,148,1,148,1,148,1,148,1,148,1,148,1,148,1,149,1,149, + 1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149, + 1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149,1,149, + 1,149,1,149,1,149,1,149,1,150,1,150,1,150,1,150,1,150,1,150,1,150, + 1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150, + 1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150,1,150, + 1,150,1,151,1,151,1,151,1,151,1,151,1,151,1,151,1,151,1,151,1,151, + 1,151,1,151,1,151,1,151,1,151,1,151,1,151,1,152,1,152,1,152,5,152, + 2705,8,152,10,152,12,152,2708,9,152,1,152,1,152,1,152,1,152,1,153, + 1,153,1,153,1,153,1,153,1,153,5,153,2720,8,153,10,153,12,153,2723, + 9,153,1,153,1,153,1,154,1,154,1,154,1,154,1,154,1,154,1,154,1,154, + 1,154,5,154,2736,8,154,10,154,12,154,2739,9,154,1,154,3,154,2742, + 8,154,1,155,1,155,1,155,1,155,1,155,1,155,5,155,2750,8,155,10,155, + 12,155,2753,9,155,1,155,1,155,1,156,1,156,1,156,1,156,1,156,1,156, + 1,156,1,156,1,156,1,156,1,156,4,156,2768,8,156,11,156,12,156,2769, + 1,156,1,156,1,156,5,156,2775,8,156,10,156,12,156,2778,9,156,1,156, + 1,156,1,156,1,157,1,157,1,157,5,157,2786,8,157,10,157,12,157,2789, + 9,157,1,157,1,157,1,158,1,158,1,158,5,158,2796,8,158,10,158,12,158, + 2799,9,158,1,158,1,158,1,159,1,159,1,159,3,159,2806,8,159,1,160, + 1,160,1,160,1,160,1,160,1,160,1,161,1,161,1,162,1,162,1,163,1,163, + 1,163,1,163,1,164,1,164,1,164,1,164,1,165,1,165,1,165,5,165,2829, + 8,165,10,165,12,165,2832,9,165,3,165,2834,8,165,1,166,3,166,2837, + 8,166,1,166,1,166,1,166,4,166,2842,8,166,11,166,12,166,2843,3,166, + 2846,8,166,1,166,3,166,2849,8,166,1,167,1,167,3,167,2853,8,167,1, + 167,1,167,1,168,4,168,2858,8,168,11,168,12,168,2859,1,168,1,168, + 0,0,169,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12, + 25,13,27,14,29,15,31,16,33,17,35,18,37,19,39,20,41,21,43,22,45,23, + 47,24,49,25,51,26,53,27,55,28,57,29,59,30,61,31,63,32,65,33,67,34, + 69,35,71,36,73,37,75,38,77,39,79,40,81,41,83,42,85,43,87,44,89,45, + 91,46,93,47,95,48,97,49,99,50,101,51,103,52,105,53,107,54,109,55, + 111,56,113,57,115,58,117,59,119,60,121,61,123,62,125,63,127,64,129, + 65,131,66,133,67,135,68,137,69,139,70,141,71,143,72,145,73,147,74, + 149,75,151,76,153,77,155,78,157,79,159,80,161,81,163,82,165,83,167, + 84,169,85,171,86,173,87,175,88,177,89,179,90,181,91,183,92,185,93, + 187,94,189,95,191,96,193,97,195,98,197,99,199,100,201,101,203,102, + 205,103,207,104,209,105,211,106,213,107,215,108,217,109,219,110, + 221,111,223,112,225,113,227,114,229,115,231,116,233,117,235,118, + 237,119,239,120,241,121,243,122,245,123,247,124,249,125,251,126, + 253,127,255,128,257,129,259,130,261,131,263,132,265,133,267,134, + 269,135,271,136,273,137,275,138,277,139,279,140,281,141,283,142, + 285,143,287,144,289,145,291,146,293,147,295,148,297,149,299,150, + 301,151,303,152,305,153,307,154,309,155,311,156,313,157,315,158, + 317,159,319,0,321,0,323,0,325,0,327,0,329,0,331,160,333,161,335, + 0,337,162,1,0,10,2,0,46,46,91,91,3,0,65,90,95,95,97,122,8,0,34,34, + 47,47,92,92,98,98,102,102,110,110,114,114,116,116,3,0,48,57,65,70, + 97,102,3,0,0,31,34,34,92,92,1,0,49,57,1,0,48,57,2,0,69,69,101,101, + 2,0,43,43,45,45,3,0,9,10,13,13,32,32,2881,0,1,1,0,0,0,0,3,1,0,0, + 0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0, + 0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0, + 0,25,1,0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0, + 0,35,1,0,0,0,0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0, + 0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0,0, + 0,55,1,0,0,0,0,57,1,0,0,0,0,59,1,0,0,0,0,61,1,0,0,0,0,63,1,0,0,0, + 0,65,1,0,0,0,0,67,1,0,0,0,0,69,1,0,0,0,0,71,1,0,0,0,0,73,1,0,0,0, + 0,75,1,0,0,0,0,77,1,0,0,0,0,79,1,0,0,0,0,81,1,0,0,0,0,83,1,0,0,0, + 0,85,1,0,0,0,0,87,1,0,0,0,0,89,1,0,0,0,0,91,1,0,0,0,0,93,1,0,0,0, + 0,95,1,0,0,0,0,97,1,0,0,0,0,99,1,0,0,0,0,101,1,0,0,0,0,103,1,0,0, + 0,0,105,1,0,0,0,0,107,1,0,0,0,0,109,1,0,0,0,0,111,1,0,0,0,0,113, + 1,0,0,0,0,115,1,0,0,0,0,117,1,0,0,0,0,119,1,0,0,0,0,121,1,0,0,0, + 0,123,1,0,0,0,0,125,1,0,0,0,0,127,1,0,0,0,0,129,1,0,0,0,0,131,1, + 0,0,0,0,133,1,0,0,0,0,135,1,0,0,0,0,137,1,0,0,0,0,139,1,0,0,0,0, + 141,1,0,0,0,0,143,1,0,0,0,0,145,1,0,0,0,0,147,1,0,0,0,0,149,1,0, + 0,0,0,151,1,0,0,0,0,153,1,0,0,0,0,155,1,0,0,0,0,157,1,0,0,0,0,159, + 1,0,0,0,0,161,1,0,0,0,0,163,1,0,0,0,0,165,1,0,0,0,0,167,1,0,0,0, + 0,169,1,0,0,0,0,171,1,0,0,0,0,173,1,0,0,0,0,175,1,0,0,0,0,177,1, + 0,0,0,0,179,1,0,0,0,0,181,1,0,0,0,0,183,1,0,0,0,0,185,1,0,0,0,0, + 187,1,0,0,0,0,189,1,0,0,0,0,191,1,0,0,0,0,193,1,0,0,0,0,195,1,0, + 0,0,0,197,1,0,0,0,0,199,1,0,0,0,0,201,1,0,0,0,0,203,1,0,0,0,0,205, + 1,0,0,0,0,207,1,0,0,0,0,209,1,0,0,0,0,211,1,0,0,0,0,213,1,0,0,0, + 0,215,1,0,0,0,0,217,1,0,0,0,0,219,1,0,0,0,0,221,1,0,0,0,0,223,1, + 0,0,0,0,225,1,0,0,0,0,227,1,0,0,0,0,229,1,0,0,0,0,231,1,0,0,0,0, + 233,1,0,0,0,0,235,1,0,0,0,0,237,1,0,0,0,0,239,1,0,0,0,0,241,1,0, + 0,0,0,243,1,0,0,0,0,245,1,0,0,0,0,247,1,0,0,0,0,249,1,0,0,0,0,251, + 1,0,0,0,0,253,1,0,0,0,0,255,1,0,0,0,0,257,1,0,0,0,0,259,1,0,0,0, + 0,261,1,0,0,0,0,263,1,0,0,0,0,265,1,0,0,0,0,267,1,0,0,0,0,269,1, + 0,0,0,0,271,1,0,0,0,0,273,1,0,0,0,0,275,1,0,0,0,0,277,1,0,0,0,0, + 279,1,0,0,0,0,281,1,0,0,0,0,283,1,0,0,0,0,285,1,0,0,0,0,287,1,0, + 0,0,0,289,1,0,0,0,0,291,1,0,0,0,0,293,1,0,0,0,0,295,1,0,0,0,0,297, + 1,0,0,0,0,299,1,0,0,0,0,301,1,0,0,0,0,303,1,0,0,0,0,305,1,0,0,0, + 0,307,1,0,0,0,0,309,1,0,0,0,0,311,1,0,0,0,0,313,1,0,0,0,0,315,1, + 0,0,0,0,317,1,0,0,0,0,331,1,0,0,0,0,333,1,0,0,0,0,337,1,0,0,0,1, + 339,1,0,0,0,3,341,1,0,0,0,5,343,1,0,0,0,7,345,1,0,0,0,9,347,1,0, + 0,0,11,349,1,0,0,0,13,351,1,0,0,0,15,356,1,0,0,0,17,362,1,0,0,0, + 19,367,1,0,0,0,21,377,1,0,0,0,23,386,1,0,0,0,25,396,1,0,0,0,27,408, + 1,0,0,0,29,418,1,0,0,0,31,425,1,0,0,0,33,432,1,0,0,0,35,441,1,0, + 0,0,37,448,1,0,0,0,39,458,1,0,0,0,41,465,1,0,0,0,43,472,1,0,0,0, + 45,483,1,0,0,0,47,489,1,0,0,0,49,499,1,0,0,0,51,511,1,0,0,0,53,522, + 1,0,0,0,55,532,1,0,0,0,57,543,1,0,0,0,59,549,1,0,0,0,61,565,1,0, + 0,0,63,585,1,0,0,0,65,597,1,0,0,0,67,606,1,0,0,0,69,618,1,0,0,0, + 71,630,1,0,0,0,73,641,1,0,0,0,75,655,1,0,0,0,77,661,1,0,0,0,79,677, + 1,0,0,0,81,697,1,0,0,0,83,718,1,0,0,0,85,743,1,0,0,0,87,770,1,0, + 0,0,89,801,1,0,0,0,91,819,1,0,0,0,93,841,1,0,0,0,95,865,1,0,0,0, + 97,893,1,0,0,0,99,898,1,0,0,0,101,913,1,0,0,0,103,932,1,0,0,0,105, + 952,1,0,0,0,107,976,1,0,0,0,109,1002,1,0,0,0,111,1032,1,0,0,0,113, + 1049,1,0,0,0,115,1070,1,0,0,0,117,1093,1,0,0,0,119,1120,1,0,0,0, + 121,1136,1,0,0,0,123,1154,1,0,0,0,125,1176,1,0,0,0,127,1199,1,0, + 0,0,129,1226,1,0,0,0,131,1255,1,0,0,0,133,1288,1,0,0,0,135,1308, + 1,0,0,0,137,1332,1,0,0,0,139,1358,1,0,0,0,141,1388,1,0,0,0,143,1402, + 1,0,0,0,145,1412,1,0,0,0,147,1428,1,0,0,0,149,1440,1,0,0,0,151,1457, + 1,0,0,0,153,1478,1,0,0,0,155,1497,1,0,0,0,157,1520,1,0,0,0,159,1538, + 1,0,0,0,161,1545,1,0,0,0,163,1554,1,0,0,0,165,1568,1,0,0,0,167,1584, + 1,0,0,0,169,1595,1,0,0,0,171,1611,1,0,0,0,173,1622,1,0,0,0,175,1637, + 1,0,0,0,177,1658,1,0,0,0,179,1675,1,0,0,0,181,1686,1,0,0,0,183,1698, + 1,0,0,0,185,1711,1,0,0,0,187,1719,1,0,0,0,189,1731,1,0,0,0,191,1744, + 1,0,0,0,193,1753,1,0,0,0,195,1766,1,0,0,0,197,1780,1,0,0,0,199,1790, + 1,0,0,0,201,1802,1,0,0,0,203,1819,1,0,0,0,205,1832,1,0,0,0,207,1847, + 1,0,0,0,209,1859,1,0,0,0,211,1879,1,0,0,0,213,1892,1,0,0,0,215,1903, + 1,0,0,0,217,1918,1,0,0,0,219,1942,1,0,0,0,221,1970,1,0,0,0,223,1999, + 1,0,0,0,225,2032,1,0,0,0,227,2040,1,0,0,0,229,2055,1,0,0,0,231,2062, + 1,0,0,0,233,2068,1,0,0,0,235,2076,1,0,0,0,237,2088,1,0,0,0,239,2096, + 1,0,0,0,241,2108,1,0,0,0,243,2116,1,0,0,0,245,2130,1,0,0,0,247,2148, + 1,0,0,0,249,2162,1,0,0,0,251,2176,1,0,0,0,253,2194,1,0,0,0,255,2211, + 1,0,0,0,257,2218,1,0,0,0,259,2225,1,0,0,0,261,2233,1,0,0,0,263,2249, + 1,0,0,0,265,2260,1,0,0,0,267,2270,1,0,0,0,269,2279,1,0,0,0,271,2288, + 1,0,0,0,273,2300,1,0,0,0,275,2313,1,0,0,0,277,2340,1,0,0,0,279,2366, + 1,0,0,0,281,2383,1,0,0,0,283,2403,1,0,0,0,285,2424,1,0,0,0,287,2456, + 1,0,0,0,289,2486,1,0,0,0,291,2508,1,0,0,0,293,2533,1,0,0,0,295,2559, + 1,0,0,0,297,2600,1,0,0,0,299,2626,1,0,0,0,301,2654,1,0,0,0,303,2684, + 1,0,0,0,305,2701,1,0,0,0,307,2713,1,0,0,0,309,2741,1,0,0,0,311,2743, + 1,0,0,0,313,2756,1,0,0,0,315,2782,1,0,0,0,317,2792,1,0,0,0,319,2802, + 1,0,0,0,321,2807,1,0,0,0,323,2813,1,0,0,0,325,2815,1,0,0,0,327,2817, + 1,0,0,0,329,2821,1,0,0,0,331,2833,1,0,0,0,333,2836,1,0,0,0,335,2850, + 1,0,0,0,337,2857,1,0,0,0,339,340,5,44,0,0,340,2,1,0,0,0,341,342, + 5,58,0,0,342,4,1,0,0,0,343,344,5,91,0,0,344,6,1,0,0,0,345,346,5, + 93,0,0,346,8,1,0,0,0,347,348,5,123,0,0,348,10,1,0,0,0,349,350,5, + 125,0,0,350,12,1,0,0,0,351,352,5,116,0,0,352,353,5,114,0,0,353,354, + 5,117,0,0,354,355,5,101,0,0,355,14,1,0,0,0,356,357,5,102,0,0,357, + 358,5,97,0,0,358,359,5,108,0,0,359,360,5,115,0,0,360,361,5,101,0, + 0,361,16,1,0,0,0,362,363,5,110,0,0,363,364,5,117,0,0,364,365,5,108, + 0,0,365,366,5,108,0,0,366,18,1,0,0,0,367,368,5,34,0,0,368,369,5, + 67,0,0,369,370,5,111,0,0,370,371,5,109,0,0,371,372,5,109,0,0,372, + 373,5,101,0,0,373,374,5,110,0,0,374,375,5,116,0,0,375,376,5,34,0, + 0,376,20,1,0,0,0,377,378,5,34,0,0,378,379,5,83,0,0,379,380,5,116, + 0,0,380,381,5,97,0,0,381,382,5,116,0,0,382,383,5,101,0,0,383,384, + 5,115,0,0,384,385,5,34,0,0,385,22,1,0,0,0,386,387,5,34,0,0,387,388, + 5,83,0,0,388,389,5,116,0,0,389,390,5,97,0,0,390,391,5,114,0,0,391, + 392,5,116,0,0,392,393,5,65,0,0,393,394,5,116,0,0,394,395,5,34,0, + 0,395,24,1,0,0,0,396,397,5,34,0,0,397,398,5,78,0,0,398,399,5,101, + 0,0,399,400,5,120,0,0,400,401,5,116,0,0,401,402,5,83,0,0,402,403, + 5,116,0,0,403,404,5,97,0,0,404,405,5,116,0,0,405,406,5,101,0,0,406, + 407,5,34,0,0,407,26,1,0,0,0,408,409,5,34,0,0,409,410,5,86,0,0,410, + 411,5,101,0,0,411,412,5,114,0,0,412,413,5,115,0,0,413,414,5,105, + 0,0,414,415,5,111,0,0,415,416,5,110,0,0,416,417,5,34,0,0,417,28, + 1,0,0,0,418,419,5,34,0,0,419,420,5,84,0,0,420,421,5,121,0,0,421, + 422,5,112,0,0,422,423,5,101,0,0,423,424,5,34,0,0,424,30,1,0,0,0, + 425,426,5,34,0,0,426,427,5,84,0,0,427,428,5,97,0,0,428,429,5,115, + 0,0,429,430,5,107,0,0,430,431,5,34,0,0,431,32,1,0,0,0,432,433,5, + 34,0,0,433,434,5,67,0,0,434,435,5,104,0,0,435,436,5,111,0,0,436, + 437,5,105,0,0,437,438,5,99,0,0,438,439,5,101,0,0,439,440,5,34,0, + 0,440,34,1,0,0,0,441,442,5,34,0,0,442,443,5,70,0,0,443,444,5,97, + 0,0,444,445,5,105,0,0,445,446,5,108,0,0,446,447,5,34,0,0,447,36, + 1,0,0,0,448,449,5,34,0,0,449,450,5,83,0,0,450,451,5,117,0,0,451, + 452,5,99,0,0,452,453,5,99,0,0,453,454,5,101,0,0,454,455,5,101,0, + 0,455,456,5,100,0,0,456,457,5,34,0,0,457,38,1,0,0,0,458,459,5,34, + 0,0,459,460,5,80,0,0,460,461,5,97,0,0,461,462,5,115,0,0,462,463, + 5,115,0,0,463,464,5,34,0,0,464,40,1,0,0,0,465,466,5,34,0,0,466,467, + 5,87,0,0,467,468,5,97,0,0,468,469,5,105,0,0,469,470,5,116,0,0,470, + 471,5,34,0,0,471,42,1,0,0,0,472,473,5,34,0,0,473,474,5,80,0,0,474, + 475,5,97,0,0,475,476,5,114,0,0,476,477,5,97,0,0,477,478,5,108,0, + 0,478,479,5,108,0,0,479,480,5,101,0,0,480,481,5,108,0,0,481,482, + 5,34,0,0,482,44,1,0,0,0,483,484,5,34,0,0,484,485,5,77,0,0,485,486, + 5,97,0,0,486,487,5,112,0,0,487,488,5,34,0,0,488,46,1,0,0,0,489,490, + 5,34,0,0,490,491,5,67,0,0,491,492,5,104,0,0,492,493,5,111,0,0,493, + 494,5,105,0,0,494,495,5,99,0,0,495,496,5,101,0,0,496,497,5,115,0, + 0,497,498,5,34,0,0,498,48,1,0,0,0,499,500,5,34,0,0,500,501,5,67, + 0,0,501,502,5,111,0,0,502,503,5,110,0,0,503,504,5,100,0,0,504,505, + 5,105,0,0,505,506,5,116,0,0,506,507,5,105,0,0,507,508,5,111,0,0, + 508,509,5,110,0,0,509,510,5,34,0,0,510,50,1,0,0,0,511,512,5,34,0, + 0,512,513,5,86,0,0,513,514,5,97,0,0,514,515,5,114,0,0,515,516,5, + 105,0,0,516,517,5,97,0,0,517,518,5,98,0,0,518,519,5,108,0,0,519, + 520,5,101,0,0,520,521,5,34,0,0,521,52,1,0,0,0,522,523,5,34,0,0,523, + 524,5,68,0,0,524,525,5,101,0,0,525,526,5,102,0,0,526,527,5,97,0, + 0,527,528,5,117,0,0,528,529,5,108,0,0,529,530,5,116,0,0,530,531, + 5,34,0,0,531,54,1,0,0,0,532,533,5,34,0,0,533,534,5,66,0,0,534,535, + 5,114,0,0,535,536,5,97,0,0,536,537,5,110,0,0,537,538,5,99,0,0,538, + 539,5,104,0,0,539,540,5,101,0,0,540,541,5,115,0,0,541,542,5,34,0, + 0,542,56,1,0,0,0,543,544,5,34,0,0,544,545,5,65,0,0,545,546,5,110, + 0,0,546,547,5,100,0,0,547,548,5,34,0,0,548,58,1,0,0,0,549,550,5, + 34,0,0,550,551,5,66,0,0,551,552,5,111,0,0,552,553,5,111,0,0,553, + 554,5,108,0,0,554,555,5,101,0,0,555,556,5,97,0,0,556,557,5,110,0, + 0,557,558,5,69,0,0,558,559,5,113,0,0,559,560,5,117,0,0,560,561,5, + 97,0,0,561,562,5,108,0,0,562,563,5,115,0,0,563,564,5,34,0,0,564, + 60,1,0,0,0,565,566,5,34,0,0,566,567,5,66,0,0,567,568,5,111,0,0,568, + 569,5,111,0,0,569,570,5,108,0,0,570,571,5,101,0,0,571,572,5,97,0, + 0,572,573,5,110,0,0,573,574,5,69,0,0,574,575,5,113,0,0,575,576,5, + 117,0,0,576,577,5,97,0,0,577,578,5,108,0,0,578,579,5,115,0,0,579, + 580,5,80,0,0,580,581,5,97,0,0,581,582,5,116,0,0,582,583,5,104,0, + 0,583,584,5,34,0,0,584,62,1,0,0,0,585,586,5,34,0,0,586,587,5,73, + 0,0,587,588,5,115,0,0,588,589,5,66,0,0,589,590,5,111,0,0,590,591, + 5,111,0,0,591,592,5,108,0,0,592,593,5,101,0,0,593,594,5,97,0,0,594, + 595,5,110,0,0,595,596,5,34,0,0,596,64,1,0,0,0,597,598,5,34,0,0,598, + 599,5,73,0,0,599,600,5,115,0,0,600,601,5,78,0,0,601,602,5,117,0, + 0,602,603,5,108,0,0,603,604,5,108,0,0,604,605,5,34,0,0,605,66,1, + 0,0,0,606,607,5,34,0,0,607,608,5,73,0,0,608,609,5,115,0,0,609,610, + 5,78,0,0,610,611,5,117,0,0,611,612,5,109,0,0,612,613,5,101,0,0,613, + 614,5,114,0,0,614,615,5,105,0,0,615,616,5,99,0,0,616,617,5,34,0, + 0,617,68,1,0,0,0,618,619,5,34,0,0,619,620,5,73,0,0,620,621,5,115, + 0,0,621,622,5,80,0,0,622,623,5,114,0,0,623,624,5,101,0,0,624,625, + 5,115,0,0,625,626,5,101,0,0,626,627,5,110,0,0,627,628,5,116,0,0, + 628,629,5,34,0,0,629,70,1,0,0,0,630,631,5,34,0,0,631,632,5,73,0, + 0,632,633,5,115,0,0,633,634,5,83,0,0,634,635,5,116,0,0,635,636,5, + 114,0,0,636,637,5,105,0,0,637,638,5,110,0,0,638,639,5,103,0,0,639, + 640,5,34,0,0,640,72,1,0,0,0,641,642,5,34,0,0,642,643,5,73,0,0,643, + 644,5,115,0,0,644,645,5,84,0,0,645,646,5,105,0,0,646,647,5,109,0, + 0,647,648,5,101,0,0,648,649,5,115,0,0,649,650,5,116,0,0,650,651, + 5,97,0,0,651,652,5,109,0,0,652,653,5,112,0,0,653,654,5,34,0,0,654, + 74,1,0,0,0,655,656,5,34,0,0,656,657,5,78,0,0,657,658,5,111,0,0,658, + 659,5,116,0,0,659,660,5,34,0,0,660,76,1,0,0,0,661,662,5,34,0,0,662, + 663,5,78,0,0,663,664,5,117,0,0,664,665,5,109,0,0,665,666,5,101,0, + 0,666,667,5,114,0,0,667,668,5,105,0,0,668,669,5,99,0,0,669,670,5, + 69,0,0,670,671,5,113,0,0,671,672,5,117,0,0,672,673,5,97,0,0,673, + 674,5,108,0,0,674,675,5,115,0,0,675,676,5,34,0,0,676,78,1,0,0,0, + 677,678,5,34,0,0,678,679,5,78,0,0,679,680,5,117,0,0,680,681,5,109, + 0,0,681,682,5,101,0,0,682,683,5,114,0,0,683,684,5,105,0,0,684,685, + 5,99,0,0,685,686,5,69,0,0,686,687,5,113,0,0,687,688,5,117,0,0,688, + 689,5,97,0,0,689,690,5,108,0,0,690,691,5,115,0,0,691,692,5,80,0, + 0,692,693,5,97,0,0,693,694,5,116,0,0,694,695,5,104,0,0,695,696,5, + 34,0,0,696,80,1,0,0,0,697,698,5,34,0,0,698,699,5,78,0,0,699,700, + 5,117,0,0,700,701,5,109,0,0,701,702,5,101,0,0,702,703,5,114,0,0, + 703,704,5,105,0,0,704,705,5,99,0,0,705,706,5,71,0,0,706,707,5,114, + 0,0,707,708,5,101,0,0,708,709,5,97,0,0,709,710,5,116,0,0,710,711, + 5,101,0,0,711,712,5,114,0,0,712,713,5,84,0,0,713,714,5,104,0,0,714, + 715,5,97,0,0,715,716,5,110,0,0,716,717,5,34,0,0,717,82,1,0,0,0,718, + 719,5,34,0,0,719,720,5,78,0,0,720,721,5,117,0,0,721,722,5,109,0, + 0,722,723,5,101,0,0,723,724,5,114,0,0,724,725,5,105,0,0,725,726, + 5,99,0,0,726,727,5,71,0,0,727,728,5,114,0,0,728,729,5,101,0,0,729, + 730,5,97,0,0,730,731,5,116,0,0,731,732,5,101,0,0,732,733,5,114,0, + 0,733,734,5,84,0,0,734,735,5,104,0,0,735,736,5,97,0,0,736,737,5, + 110,0,0,737,738,5,80,0,0,738,739,5,97,0,0,739,740,5,116,0,0,740, + 741,5,104,0,0,741,742,5,34,0,0,742,84,1,0,0,0,743,744,5,34,0,0,744, + 745,5,78,0,0,745,746,5,117,0,0,746,747,5,109,0,0,747,748,5,101,0, + 0,748,749,5,114,0,0,749,750,5,105,0,0,750,751,5,99,0,0,751,752,5, + 71,0,0,752,753,5,114,0,0,753,754,5,101,0,0,754,755,5,97,0,0,755, + 756,5,116,0,0,756,757,5,101,0,0,757,758,5,114,0,0,758,759,5,84,0, + 0,759,760,5,104,0,0,760,761,5,97,0,0,761,762,5,110,0,0,762,763,5, + 69,0,0,763,764,5,113,0,0,764,765,5,117,0,0,765,766,5,97,0,0,766, + 767,5,108,0,0,767,768,5,115,0,0,768,769,5,34,0,0,769,86,1,0,0,0, + 770,771,5,34,0,0,771,772,5,78,0,0,772,773,5,117,0,0,773,774,5,109, + 0,0,774,775,5,101,0,0,775,776,5,114,0,0,776,777,5,105,0,0,777,778, + 5,99,0,0,778,779,5,71,0,0,779,780,5,114,0,0,780,781,5,101,0,0,781, + 782,5,97,0,0,782,783,5,116,0,0,783,784,5,101,0,0,784,785,5,114,0, + 0,785,786,5,84,0,0,786,787,5,104,0,0,787,788,5,97,0,0,788,789,5, + 110,0,0,789,790,5,69,0,0,790,791,5,113,0,0,791,792,5,117,0,0,792, + 793,5,97,0,0,793,794,5,108,0,0,794,795,5,115,0,0,795,796,5,80,0, + 0,796,797,5,97,0,0,797,798,5,116,0,0,798,799,5,104,0,0,799,800,5, + 34,0,0,800,88,1,0,0,0,801,802,5,34,0,0,802,803,5,78,0,0,803,804, + 5,117,0,0,804,805,5,109,0,0,805,806,5,101,0,0,806,807,5,114,0,0, + 807,808,5,105,0,0,808,809,5,99,0,0,809,810,5,76,0,0,810,811,5,101, + 0,0,811,812,5,115,0,0,812,813,5,115,0,0,813,814,5,84,0,0,814,815, + 5,104,0,0,815,816,5,97,0,0,816,817,5,110,0,0,817,818,5,34,0,0,818, + 90,1,0,0,0,819,820,5,34,0,0,820,821,5,78,0,0,821,822,5,117,0,0,822, + 823,5,109,0,0,823,824,5,101,0,0,824,825,5,114,0,0,825,826,5,105, + 0,0,826,827,5,99,0,0,827,828,5,76,0,0,828,829,5,101,0,0,829,830, + 5,115,0,0,830,831,5,115,0,0,831,832,5,84,0,0,832,833,5,104,0,0,833, + 834,5,97,0,0,834,835,5,110,0,0,835,836,5,80,0,0,836,837,5,97,0,0, + 837,838,5,116,0,0,838,839,5,104,0,0,839,840,5,34,0,0,840,92,1,0, + 0,0,841,842,5,34,0,0,842,843,5,78,0,0,843,844,5,117,0,0,844,845, + 5,109,0,0,845,846,5,101,0,0,846,847,5,114,0,0,847,848,5,105,0,0, + 848,849,5,99,0,0,849,850,5,76,0,0,850,851,5,101,0,0,851,852,5,115, + 0,0,852,853,5,115,0,0,853,854,5,84,0,0,854,855,5,104,0,0,855,856, + 5,97,0,0,856,857,5,110,0,0,857,858,5,69,0,0,858,859,5,113,0,0,859, + 860,5,117,0,0,860,861,5,97,0,0,861,862,5,108,0,0,862,863,5,115,0, + 0,863,864,5,34,0,0,864,94,1,0,0,0,865,866,5,34,0,0,866,867,5,78, + 0,0,867,868,5,117,0,0,868,869,5,109,0,0,869,870,5,101,0,0,870,871, + 5,114,0,0,871,872,5,105,0,0,872,873,5,99,0,0,873,874,5,76,0,0,874, + 875,5,101,0,0,875,876,5,115,0,0,876,877,5,115,0,0,877,878,5,84,0, + 0,878,879,5,104,0,0,879,880,5,97,0,0,880,881,5,110,0,0,881,882,5, + 69,0,0,882,883,5,113,0,0,883,884,5,117,0,0,884,885,5,97,0,0,885, + 886,5,108,0,0,886,887,5,115,0,0,887,888,5,80,0,0,888,889,5,97,0, + 0,889,890,5,116,0,0,890,891,5,104,0,0,891,892,5,34,0,0,892,96,1, + 0,0,0,893,894,5,34,0,0,894,895,5,79,0,0,895,896,5,114,0,0,896,897, + 5,34,0,0,897,98,1,0,0,0,898,899,5,34,0,0,899,900,5,83,0,0,900,901, + 5,116,0,0,901,902,5,114,0,0,902,903,5,105,0,0,903,904,5,110,0,0, + 904,905,5,103,0,0,905,906,5,69,0,0,906,907,5,113,0,0,907,908,5,117, + 0,0,908,909,5,97,0,0,909,910,5,108,0,0,910,911,5,115,0,0,911,912, + 5,34,0,0,912,100,1,0,0,0,913,914,5,34,0,0,914,915,5,83,0,0,915,916, + 5,116,0,0,916,917,5,114,0,0,917,918,5,105,0,0,918,919,5,110,0,0, + 919,920,5,103,0,0,920,921,5,69,0,0,921,922,5,113,0,0,922,923,5,117, + 0,0,923,924,5,97,0,0,924,925,5,108,0,0,925,926,5,115,0,0,926,927, + 5,80,0,0,927,928,5,97,0,0,928,929,5,116,0,0,929,930,5,104,0,0,930, + 931,5,34,0,0,931,102,1,0,0,0,932,933,5,34,0,0,933,934,5,83,0,0,934, + 935,5,116,0,0,935,936,5,114,0,0,936,937,5,105,0,0,937,938,5,110, + 0,0,938,939,5,103,0,0,939,940,5,71,0,0,940,941,5,114,0,0,941,942, + 5,101,0,0,942,943,5,97,0,0,943,944,5,116,0,0,944,945,5,101,0,0,945, + 946,5,114,0,0,946,947,5,84,0,0,947,948,5,104,0,0,948,949,5,97,0, + 0,949,950,5,110,0,0,950,951,5,34,0,0,951,104,1,0,0,0,952,953,5,34, + 0,0,953,954,5,83,0,0,954,955,5,116,0,0,955,956,5,114,0,0,956,957, + 5,105,0,0,957,958,5,110,0,0,958,959,5,103,0,0,959,960,5,71,0,0,960, + 961,5,114,0,0,961,962,5,101,0,0,962,963,5,97,0,0,963,964,5,116,0, + 0,964,965,5,101,0,0,965,966,5,114,0,0,966,967,5,84,0,0,967,968,5, + 104,0,0,968,969,5,97,0,0,969,970,5,110,0,0,970,971,5,80,0,0,971, + 972,5,97,0,0,972,973,5,116,0,0,973,974,5,104,0,0,974,975,5,34,0, + 0,975,106,1,0,0,0,976,977,5,34,0,0,977,978,5,83,0,0,978,979,5,116, + 0,0,979,980,5,114,0,0,980,981,5,105,0,0,981,982,5,110,0,0,982,983, + 5,103,0,0,983,984,5,71,0,0,984,985,5,114,0,0,985,986,5,101,0,0,986, + 987,5,97,0,0,987,988,5,116,0,0,988,989,5,101,0,0,989,990,5,114,0, + 0,990,991,5,84,0,0,991,992,5,104,0,0,992,993,5,97,0,0,993,994,5, + 110,0,0,994,995,5,69,0,0,995,996,5,113,0,0,996,997,5,117,0,0,997, + 998,5,97,0,0,998,999,5,108,0,0,999,1000,5,115,0,0,1000,1001,5,34, + 0,0,1001,108,1,0,0,0,1002,1003,5,34,0,0,1003,1004,5,83,0,0,1004, + 1005,5,116,0,0,1005,1006,5,114,0,0,1006,1007,5,105,0,0,1007,1008, + 5,110,0,0,1008,1009,5,103,0,0,1009,1010,5,71,0,0,1010,1011,5,114, + 0,0,1011,1012,5,101,0,0,1012,1013,5,97,0,0,1013,1014,5,116,0,0,1014, + 1015,5,101,0,0,1015,1016,5,114,0,0,1016,1017,5,84,0,0,1017,1018, + 5,104,0,0,1018,1019,5,97,0,0,1019,1020,5,110,0,0,1020,1021,5,69, + 0,0,1021,1022,5,113,0,0,1022,1023,5,117,0,0,1023,1024,5,97,0,0,1024, + 1025,5,108,0,0,1025,1026,5,115,0,0,1026,1027,5,80,0,0,1027,1028, + 5,97,0,0,1028,1029,5,116,0,0,1029,1030,5,104,0,0,1030,1031,5,34, + 0,0,1031,110,1,0,0,0,1032,1033,5,34,0,0,1033,1034,5,83,0,0,1034, + 1035,5,116,0,0,1035,1036,5,114,0,0,1036,1037,5,105,0,0,1037,1038, + 5,110,0,0,1038,1039,5,103,0,0,1039,1040,5,76,0,0,1040,1041,5,101, + 0,0,1041,1042,5,115,0,0,1042,1043,5,115,0,0,1043,1044,5,84,0,0,1044, + 1045,5,104,0,0,1045,1046,5,97,0,0,1046,1047,5,110,0,0,1047,1048, + 5,34,0,0,1048,112,1,0,0,0,1049,1050,5,34,0,0,1050,1051,5,83,0,0, + 1051,1052,5,116,0,0,1052,1053,5,114,0,0,1053,1054,5,105,0,0,1054, + 1055,5,110,0,0,1055,1056,5,103,0,0,1056,1057,5,76,0,0,1057,1058, + 5,101,0,0,1058,1059,5,115,0,0,1059,1060,5,115,0,0,1060,1061,5,84, + 0,0,1061,1062,5,104,0,0,1062,1063,5,97,0,0,1063,1064,5,110,0,0,1064, + 1065,5,80,0,0,1065,1066,5,97,0,0,1066,1067,5,116,0,0,1067,1068,5, + 104,0,0,1068,1069,5,34,0,0,1069,114,1,0,0,0,1070,1071,5,34,0,0,1071, + 1072,5,83,0,0,1072,1073,5,116,0,0,1073,1074,5,114,0,0,1074,1075, + 5,105,0,0,1075,1076,5,110,0,0,1076,1077,5,103,0,0,1077,1078,5,76, + 0,0,1078,1079,5,101,0,0,1079,1080,5,115,0,0,1080,1081,5,115,0,0, + 1081,1082,5,84,0,0,1082,1083,5,104,0,0,1083,1084,5,97,0,0,1084,1085, + 5,110,0,0,1085,1086,5,69,0,0,1086,1087,5,113,0,0,1087,1088,5,117, + 0,0,1088,1089,5,97,0,0,1089,1090,5,108,0,0,1090,1091,5,115,0,0,1091, + 1092,5,34,0,0,1092,116,1,0,0,0,1093,1094,5,34,0,0,1094,1095,5,83, + 0,0,1095,1096,5,116,0,0,1096,1097,5,114,0,0,1097,1098,5,105,0,0, + 1098,1099,5,110,0,0,1099,1100,5,103,0,0,1100,1101,5,76,0,0,1101, + 1102,5,101,0,0,1102,1103,5,115,0,0,1103,1104,5,115,0,0,1104,1105, + 5,84,0,0,1105,1106,5,104,0,0,1106,1107,5,97,0,0,1107,1108,5,110, + 0,0,1108,1109,5,69,0,0,1109,1110,5,113,0,0,1110,1111,5,117,0,0,1111, + 1112,5,97,0,0,1112,1113,5,108,0,0,1113,1114,5,115,0,0,1114,1115, + 5,80,0,0,1115,1116,5,97,0,0,1116,1117,5,116,0,0,1117,1118,5,104, + 0,0,1118,1119,5,34,0,0,1119,118,1,0,0,0,1120,1121,5,34,0,0,1121, + 1122,5,83,0,0,1122,1123,5,116,0,0,1123,1124,5,114,0,0,1124,1125, + 5,105,0,0,1125,1126,5,110,0,0,1126,1127,5,103,0,0,1127,1128,5,77, + 0,0,1128,1129,5,97,0,0,1129,1130,5,116,0,0,1130,1131,5,99,0,0,1131, + 1132,5,104,0,0,1132,1133,5,101,0,0,1133,1134,5,115,0,0,1134,1135, + 5,34,0,0,1135,120,1,0,0,0,1136,1137,5,34,0,0,1137,1138,5,84,0,0, + 1138,1139,5,105,0,0,1139,1140,5,109,0,0,1140,1141,5,101,0,0,1141, + 1142,5,115,0,0,1142,1143,5,116,0,0,1143,1144,5,97,0,0,1144,1145, + 5,109,0,0,1145,1146,5,112,0,0,1146,1147,5,69,0,0,1147,1148,5,113, + 0,0,1148,1149,5,117,0,0,1149,1150,5,97,0,0,1150,1151,5,108,0,0,1151, + 1152,5,115,0,0,1152,1153,5,34,0,0,1153,122,1,0,0,0,1154,1155,5,34, + 0,0,1155,1156,5,84,0,0,1156,1157,5,105,0,0,1157,1158,5,109,0,0,1158, + 1159,5,101,0,0,1159,1160,5,115,0,0,1160,1161,5,116,0,0,1161,1162, + 5,97,0,0,1162,1163,5,109,0,0,1163,1164,5,112,0,0,1164,1165,5,69, + 0,0,1165,1166,5,113,0,0,1166,1167,5,117,0,0,1167,1168,5,97,0,0,1168, + 1169,5,108,0,0,1169,1170,5,115,0,0,1170,1171,5,80,0,0,1171,1172, + 5,97,0,0,1172,1173,5,116,0,0,1173,1174,5,104,0,0,1174,1175,5,34, + 0,0,1175,124,1,0,0,0,1176,1177,5,34,0,0,1177,1178,5,84,0,0,1178, + 1179,5,105,0,0,1179,1180,5,109,0,0,1180,1181,5,101,0,0,1181,1182, + 5,115,0,0,1182,1183,5,116,0,0,1183,1184,5,97,0,0,1184,1185,5,109, + 0,0,1185,1186,5,112,0,0,1186,1187,5,71,0,0,1187,1188,5,114,0,0,1188, + 1189,5,101,0,0,1189,1190,5,97,0,0,1190,1191,5,116,0,0,1191,1192, + 5,101,0,0,1192,1193,5,114,0,0,1193,1194,5,84,0,0,1194,1195,5,104, + 0,0,1195,1196,5,97,0,0,1196,1197,5,110,0,0,1197,1198,5,34,0,0,1198, + 126,1,0,0,0,1199,1200,5,34,0,0,1200,1201,5,84,0,0,1201,1202,5,105, + 0,0,1202,1203,5,109,0,0,1203,1204,5,101,0,0,1204,1205,5,115,0,0, + 1205,1206,5,116,0,0,1206,1207,5,97,0,0,1207,1208,5,109,0,0,1208, + 1209,5,112,0,0,1209,1210,5,71,0,0,1210,1211,5,114,0,0,1211,1212, + 5,101,0,0,1212,1213,5,97,0,0,1213,1214,5,116,0,0,1214,1215,5,101, + 0,0,1215,1216,5,114,0,0,1216,1217,5,84,0,0,1217,1218,5,104,0,0,1218, + 1219,5,97,0,0,1219,1220,5,110,0,0,1220,1221,5,80,0,0,1221,1222,5, + 97,0,0,1222,1223,5,116,0,0,1223,1224,5,104,0,0,1224,1225,5,34,0, + 0,1225,128,1,0,0,0,1226,1227,5,34,0,0,1227,1228,5,84,0,0,1228,1229, + 5,105,0,0,1229,1230,5,109,0,0,1230,1231,5,101,0,0,1231,1232,5,115, + 0,0,1232,1233,5,116,0,0,1233,1234,5,97,0,0,1234,1235,5,109,0,0,1235, + 1236,5,112,0,0,1236,1237,5,71,0,0,1237,1238,5,114,0,0,1238,1239, + 5,101,0,0,1239,1240,5,97,0,0,1240,1241,5,116,0,0,1241,1242,5,101, + 0,0,1242,1243,5,114,0,0,1243,1244,5,84,0,0,1244,1245,5,104,0,0,1245, + 1246,5,97,0,0,1246,1247,5,110,0,0,1247,1248,5,69,0,0,1248,1249,5, + 113,0,0,1249,1250,5,117,0,0,1250,1251,5,97,0,0,1251,1252,5,108,0, + 0,1252,1253,5,115,0,0,1253,1254,5,34,0,0,1254,130,1,0,0,0,1255,1256, + 5,34,0,0,1256,1257,5,84,0,0,1257,1258,5,105,0,0,1258,1259,5,109, + 0,0,1259,1260,5,101,0,0,1260,1261,5,115,0,0,1261,1262,5,116,0,0, + 1262,1263,5,97,0,0,1263,1264,5,109,0,0,1264,1265,5,112,0,0,1265, + 1266,5,71,0,0,1266,1267,5,114,0,0,1267,1268,5,101,0,0,1268,1269, + 5,97,0,0,1269,1270,5,116,0,0,1270,1271,5,101,0,0,1271,1272,5,114, + 0,0,1272,1273,5,84,0,0,1273,1274,5,104,0,0,1274,1275,5,97,0,0,1275, + 1276,5,110,0,0,1276,1277,5,69,0,0,1277,1278,5,113,0,0,1278,1279, + 5,117,0,0,1279,1280,5,97,0,0,1280,1281,5,108,0,0,1281,1282,5,115, + 0,0,1282,1283,5,80,0,0,1283,1284,5,97,0,0,1284,1285,5,116,0,0,1285, + 1286,5,104,0,0,1286,1287,5,34,0,0,1287,132,1,0,0,0,1288,1289,5,34, + 0,0,1289,1290,5,84,0,0,1290,1291,5,105,0,0,1291,1292,5,109,0,0,1292, + 1293,5,101,0,0,1293,1294,5,115,0,0,1294,1295,5,116,0,0,1295,1296, + 5,97,0,0,1296,1297,5,109,0,0,1297,1298,5,112,0,0,1298,1299,5,76, + 0,0,1299,1300,5,101,0,0,1300,1301,5,115,0,0,1301,1302,5,115,0,0, + 1302,1303,5,84,0,0,1303,1304,5,104,0,0,1304,1305,5,97,0,0,1305,1306, + 5,110,0,0,1306,1307,5,34,0,0,1307,134,1,0,0,0,1308,1309,5,34,0,0, + 1309,1310,5,84,0,0,1310,1311,5,105,0,0,1311,1312,5,109,0,0,1312, + 1313,5,101,0,0,1313,1314,5,115,0,0,1314,1315,5,116,0,0,1315,1316, + 5,97,0,0,1316,1317,5,109,0,0,1317,1318,5,112,0,0,1318,1319,5,76, + 0,0,1319,1320,5,101,0,0,1320,1321,5,115,0,0,1321,1322,5,115,0,0, + 1322,1323,5,84,0,0,1323,1324,5,104,0,0,1324,1325,5,97,0,0,1325,1326, + 5,110,0,0,1326,1327,5,80,0,0,1327,1328,5,97,0,0,1328,1329,5,116, + 0,0,1329,1330,5,104,0,0,1330,1331,5,34,0,0,1331,136,1,0,0,0,1332, + 1333,5,34,0,0,1333,1334,5,84,0,0,1334,1335,5,105,0,0,1335,1336,5, + 109,0,0,1336,1337,5,101,0,0,1337,1338,5,115,0,0,1338,1339,5,116, + 0,0,1339,1340,5,97,0,0,1340,1341,5,109,0,0,1341,1342,5,112,0,0,1342, + 1343,5,76,0,0,1343,1344,5,101,0,0,1344,1345,5,115,0,0,1345,1346, + 5,115,0,0,1346,1347,5,84,0,0,1347,1348,5,104,0,0,1348,1349,5,97, + 0,0,1349,1350,5,110,0,0,1350,1351,5,69,0,0,1351,1352,5,113,0,0,1352, + 1353,5,117,0,0,1353,1354,5,97,0,0,1354,1355,5,108,0,0,1355,1356, + 5,115,0,0,1356,1357,5,34,0,0,1357,138,1,0,0,0,1358,1359,5,34,0,0, + 1359,1360,5,84,0,0,1360,1361,5,105,0,0,1361,1362,5,109,0,0,1362, + 1363,5,101,0,0,1363,1364,5,115,0,0,1364,1365,5,116,0,0,1365,1366, + 5,97,0,0,1366,1367,5,109,0,0,1367,1368,5,112,0,0,1368,1369,5,76, + 0,0,1369,1370,5,101,0,0,1370,1371,5,115,0,0,1371,1372,5,115,0,0, + 1372,1373,5,84,0,0,1373,1374,5,104,0,0,1374,1375,5,97,0,0,1375,1376, + 5,110,0,0,1376,1377,5,69,0,0,1377,1378,5,113,0,0,1378,1379,5,117, + 0,0,1379,1380,5,97,0,0,1380,1381,5,108,0,0,1381,1382,5,115,0,0,1382, + 1383,5,80,0,0,1383,1384,5,97,0,0,1384,1385,5,116,0,0,1385,1386,5, + 104,0,0,1386,1387,5,34,0,0,1387,140,1,0,0,0,1388,1389,5,34,0,0,1389, + 1390,5,83,0,0,1390,1391,5,101,0,0,1391,1392,5,99,0,0,1392,1393,5, + 111,0,0,1393,1394,5,110,0,0,1394,1395,5,100,0,0,1395,1396,5,115, + 0,0,1396,1397,5,80,0,0,1397,1398,5,97,0,0,1398,1399,5,116,0,0,1399, + 1400,5,104,0,0,1400,1401,5,34,0,0,1401,142,1,0,0,0,1402,1403,5,34, + 0,0,1403,1404,5,83,0,0,1404,1405,5,101,0,0,1405,1406,5,99,0,0,1406, + 1407,5,111,0,0,1407,1408,5,110,0,0,1408,1409,5,100,0,0,1409,1410, + 5,115,0,0,1410,1411,5,34,0,0,1411,144,1,0,0,0,1412,1413,5,34,0,0, + 1413,1414,5,84,0,0,1414,1415,5,105,0,0,1415,1416,5,109,0,0,1416, + 1417,5,101,0,0,1417,1418,5,115,0,0,1418,1419,5,116,0,0,1419,1420, + 5,97,0,0,1420,1421,5,109,0,0,1421,1422,5,112,0,0,1422,1423,5,80, + 0,0,1423,1424,5,97,0,0,1424,1425,5,116,0,0,1425,1426,5,104,0,0,1426, + 1427,5,34,0,0,1427,146,1,0,0,0,1428,1429,5,34,0,0,1429,1430,5,84, + 0,0,1430,1431,5,105,0,0,1431,1432,5,109,0,0,1432,1433,5,101,0,0, + 1433,1434,5,115,0,0,1434,1435,5,116,0,0,1435,1436,5,97,0,0,1436, + 1437,5,109,0,0,1437,1438,5,112,0,0,1438,1439,5,34,0,0,1439,148,1, + 0,0,0,1440,1441,5,34,0,0,1441,1442,5,84,0,0,1442,1443,5,105,0,0, + 1443,1444,5,109,0,0,1444,1445,5,101,0,0,1445,1446,5,111,0,0,1446, + 1447,5,117,0,0,1447,1448,5,116,0,0,1448,1449,5,83,0,0,1449,1450, + 5,101,0,0,1450,1451,5,99,0,0,1451,1452,5,111,0,0,1452,1453,5,110, + 0,0,1453,1454,5,100,0,0,1454,1455,5,115,0,0,1455,1456,5,34,0,0,1456, + 150,1,0,0,0,1457,1458,5,34,0,0,1458,1459,5,84,0,0,1459,1460,5,105, + 0,0,1460,1461,5,109,0,0,1461,1462,5,101,0,0,1462,1463,5,111,0,0, + 1463,1464,5,117,0,0,1464,1465,5,116,0,0,1465,1466,5,83,0,0,1466, + 1467,5,101,0,0,1467,1468,5,99,0,0,1468,1469,5,111,0,0,1469,1470, + 5,110,0,0,1470,1471,5,100,0,0,1471,1472,5,115,0,0,1472,1473,5,80, + 0,0,1473,1474,5,97,0,0,1474,1475,5,116,0,0,1475,1476,5,104,0,0,1476, + 1477,5,34,0,0,1477,152,1,0,0,0,1478,1479,5,34,0,0,1479,1480,5,72, + 0,0,1480,1481,5,101,0,0,1481,1482,5,97,0,0,1482,1483,5,114,0,0,1483, + 1484,5,116,0,0,1484,1485,5,98,0,0,1485,1486,5,101,0,0,1486,1487, + 5,97,0,0,1487,1488,5,116,0,0,1488,1489,5,83,0,0,1489,1490,5,101, + 0,0,1490,1491,5,99,0,0,1491,1492,5,111,0,0,1492,1493,5,110,0,0,1493, + 1494,5,100,0,0,1494,1495,5,115,0,0,1495,1496,5,34,0,0,1496,154,1, + 0,0,0,1497,1498,5,34,0,0,1498,1499,5,72,0,0,1499,1500,5,101,0,0, + 1500,1501,5,97,0,0,1501,1502,5,114,0,0,1502,1503,5,116,0,0,1503, + 1504,5,98,0,0,1504,1505,5,101,0,0,1505,1506,5,97,0,0,1506,1507,5, + 116,0,0,1507,1508,5,83,0,0,1508,1509,5,101,0,0,1509,1510,5,99,0, + 0,1510,1511,5,111,0,0,1511,1512,5,110,0,0,1512,1513,5,100,0,0,1513, + 1514,5,115,0,0,1514,1515,5,80,0,0,1515,1516,5,97,0,0,1516,1517,5, + 116,0,0,1517,1518,5,104,0,0,1518,1519,5,34,0,0,1519,156,1,0,0,0, + 1520,1521,5,34,0,0,1521,1522,5,80,0,0,1522,1523,5,114,0,0,1523,1524, + 5,111,0,0,1524,1525,5,99,0,0,1525,1526,5,101,0,0,1526,1527,5,115, + 0,0,1527,1528,5,115,0,0,1528,1529,5,111,0,0,1529,1530,5,114,0,0, + 1530,1531,5,67,0,0,1531,1532,5,111,0,0,1532,1533,5,110,0,0,1533, + 1534,5,102,0,0,1534,1535,5,105,0,0,1535,1536,5,103,0,0,1536,1537, + 5,34,0,0,1537,158,1,0,0,0,1538,1539,5,34,0,0,1539,1540,5,77,0,0, + 1540,1541,5,111,0,0,1541,1542,5,100,0,0,1542,1543,5,101,0,0,1543, + 1544,5,34,0,0,1544,160,1,0,0,0,1545,1546,5,34,0,0,1546,1547,5,73, + 0,0,1547,1548,5,78,0,0,1548,1549,5,76,0,0,1549,1550,5,73,0,0,1550, + 1551,5,78,0,0,1551,1552,5,69,0,0,1552,1553,5,34,0,0,1553,162,1,0, + 0,0,1554,1555,5,34,0,0,1555,1556,5,68,0,0,1556,1557,5,73,0,0,1557, + 1558,5,83,0,0,1558,1559,5,84,0,0,1559,1560,5,82,0,0,1560,1561,5, + 73,0,0,1561,1562,5,66,0,0,1562,1563,5,85,0,0,1563,1564,5,84,0,0, + 1564,1565,5,69,0,0,1565,1566,5,68,0,0,1566,1567,5,34,0,0,1567,164, + 1,0,0,0,1568,1569,5,34,0,0,1569,1570,5,69,0,0,1570,1571,5,120,0, + 0,1571,1572,5,101,0,0,1572,1573,5,99,0,0,1573,1574,5,117,0,0,1574, + 1575,5,116,0,0,1575,1576,5,105,0,0,1576,1577,5,111,0,0,1577,1578, + 5,110,0,0,1578,1579,5,84,0,0,1579,1580,5,121,0,0,1580,1581,5,112, + 0,0,1581,1582,5,101,0,0,1582,1583,5,34,0,0,1583,166,1,0,0,0,1584, + 1585,5,34,0,0,1585,1586,5,83,0,0,1586,1587,5,84,0,0,1587,1588,5, + 65,0,0,1588,1589,5,78,0,0,1589,1590,5,68,0,0,1590,1591,5,65,0,0, + 1591,1592,5,82,0,0,1592,1593,5,68,0,0,1593,1594,5,34,0,0,1594,168, + 1,0,0,0,1595,1596,5,34,0,0,1596,1597,5,73,0,0,1597,1598,5,116,0, + 0,1598,1599,5,101,0,0,1599,1600,5,109,0,0,1600,1601,5,80,0,0,1601, + 1602,5,114,0,0,1602,1603,5,111,0,0,1603,1604,5,99,0,0,1604,1605, + 5,101,0,0,1605,1606,5,115,0,0,1606,1607,5,115,0,0,1607,1608,5,111, + 0,0,1608,1609,5,114,0,0,1609,1610,5,34,0,0,1610,170,1,0,0,0,1611, + 1612,5,34,0,0,1612,1613,5,73,0,0,1613,1614,5,116,0,0,1614,1615,5, + 101,0,0,1615,1616,5,114,0,0,1616,1617,5,97,0,0,1617,1618,5,116,0, + 0,1618,1619,5,111,0,0,1619,1620,5,114,0,0,1620,1621,5,34,0,0,1621, + 172,1,0,0,0,1622,1623,5,34,0,0,1623,1624,5,73,0,0,1624,1625,5,116, + 0,0,1625,1626,5,101,0,0,1626,1627,5,109,0,0,1627,1628,5,83,0,0,1628, + 1629,5,101,0,0,1629,1630,5,108,0,0,1630,1631,5,101,0,0,1631,1632, + 5,99,0,0,1632,1633,5,116,0,0,1633,1634,5,111,0,0,1634,1635,5,114, + 0,0,1635,1636,5,34,0,0,1636,174,1,0,0,0,1637,1638,5,34,0,0,1638, + 1639,5,77,0,0,1639,1640,5,97,0,0,1640,1641,5,120,0,0,1641,1642,5, + 67,0,0,1642,1643,5,111,0,0,1643,1644,5,110,0,0,1644,1645,5,99,0, + 0,1645,1646,5,117,0,0,1646,1647,5,114,0,0,1647,1648,5,114,0,0,1648, + 1649,5,101,0,0,1649,1650,5,110,0,0,1650,1651,5,99,0,0,1651,1652, + 5,121,0,0,1652,1653,5,80,0,0,1653,1654,5,97,0,0,1654,1655,5,116, + 0,0,1655,1656,5,104,0,0,1656,1657,5,34,0,0,1657,176,1,0,0,0,1658, + 1659,5,34,0,0,1659,1660,5,77,0,0,1660,1661,5,97,0,0,1661,1662,5, + 120,0,0,1662,1663,5,67,0,0,1663,1664,5,111,0,0,1664,1665,5,110,0, + 0,1665,1666,5,99,0,0,1666,1667,5,117,0,0,1667,1668,5,114,0,0,1668, + 1669,5,114,0,0,1669,1670,5,101,0,0,1670,1671,5,110,0,0,1671,1672, + 5,99,0,0,1672,1673,5,121,0,0,1673,1674,5,34,0,0,1674,178,1,0,0,0, + 1675,1676,5,34,0,0,1676,1677,5,82,0,0,1677,1678,5,101,0,0,1678,1679, + 5,115,0,0,1679,1680,5,111,0,0,1680,1681,5,117,0,0,1681,1682,5,114, + 0,0,1682,1683,5,99,0,0,1683,1684,5,101,0,0,1684,1685,5,34,0,0,1685, + 180,1,0,0,0,1686,1687,5,34,0,0,1687,1688,5,73,0,0,1688,1689,5,110, + 0,0,1689,1690,5,112,0,0,1690,1691,5,117,0,0,1691,1692,5,116,0,0, + 1692,1693,5,80,0,0,1693,1694,5,97,0,0,1694,1695,5,116,0,0,1695,1696, + 5,104,0,0,1696,1697,5,34,0,0,1697,182,1,0,0,0,1698,1699,5,34,0,0, + 1699,1700,5,79,0,0,1700,1701,5,117,0,0,1701,1702,5,116,0,0,1702, + 1703,5,112,0,0,1703,1704,5,117,0,0,1704,1705,5,116,0,0,1705,1706, + 5,80,0,0,1706,1707,5,97,0,0,1707,1708,5,116,0,0,1708,1709,5,104, + 0,0,1709,1710,5,34,0,0,1710,184,1,0,0,0,1711,1712,5,34,0,0,1712, + 1713,5,73,0,0,1713,1714,5,116,0,0,1714,1715,5,101,0,0,1715,1716, + 5,109,0,0,1716,1717,5,115,0,0,1717,1718,5,34,0,0,1718,186,1,0,0, + 0,1719,1720,5,34,0,0,1720,1721,5,73,0,0,1721,1722,5,116,0,0,1722, + 1723,5,101,0,0,1723,1724,5,109,0,0,1724,1725,5,115,0,0,1725,1726, + 5,80,0,0,1726,1727,5,97,0,0,1727,1728,5,116,0,0,1728,1729,5,104, + 0,0,1729,1730,5,34,0,0,1730,188,1,0,0,0,1731,1732,5,34,0,0,1732, + 1733,5,82,0,0,1733,1734,5,101,0,0,1734,1735,5,115,0,0,1735,1736, + 5,117,0,0,1736,1737,5,108,0,0,1737,1738,5,116,0,0,1738,1739,5,80, + 0,0,1739,1740,5,97,0,0,1740,1741,5,116,0,0,1741,1742,5,104,0,0,1742, + 1743,5,34,0,0,1743,190,1,0,0,0,1744,1745,5,34,0,0,1745,1746,5,82, + 0,0,1746,1747,5,101,0,0,1747,1748,5,115,0,0,1748,1749,5,117,0,0, + 1749,1750,5,108,0,0,1750,1751,5,116,0,0,1751,1752,5,34,0,0,1752, + 192,1,0,0,0,1753,1754,5,34,0,0,1754,1755,5,80,0,0,1755,1756,5,97, + 0,0,1756,1757,5,114,0,0,1757,1758,5,97,0,0,1758,1759,5,109,0,0,1759, + 1760,5,101,0,0,1760,1761,5,116,0,0,1761,1762,5,101,0,0,1762,1763, + 5,114,0,0,1763,1764,5,115,0,0,1764,1765,5,34,0,0,1765,194,1,0,0, + 0,1766,1767,5,34,0,0,1767,1768,5,67,0,0,1768,1769,5,114,0,0,1769, + 1770,5,101,0,0,1770,1771,5,100,0,0,1771,1772,5,101,0,0,1772,1773, + 5,110,0,0,1773,1774,5,116,0,0,1774,1775,5,105,0,0,1775,1776,5,97, + 0,0,1776,1777,5,108,0,0,1777,1778,5,115,0,0,1778,1779,5,34,0,0,1779, + 196,1,0,0,0,1780,1781,5,34,0,0,1781,1782,5,82,0,0,1782,1783,5,111, + 0,0,1783,1784,5,108,0,0,1784,1785,5,101,0,0,1785,1786,5,65,0,0,1786, + 1787,5,114,0,0,1787,1788,5,110,0,0,1788,1789,5,34,0,0,1789,198,1, + 0,0,0,1790,1791,5,34,0,0,1791,1792,5,82,0,0,1792,1793,5,111,0,0, + 1793,1794,5,108,0,0,1794,1795,5,101,0,0,1795,1796,5,65,0,0,1796, + 1797,5,114,0,0,1797,1798,5,110,0,0,1798,1799,5,46,0,0,1799,1800, + 5,36,0,0,1800,1801,5,34,0,0,1801,200,1,0,0,0,1802,1803,5,34,0,0, + 1803,1804,5,82,0,0,1804,1805,5,101,0,0,1805,1806,5,115,0,0,1806, + 1807,5,117,0,0,1807,1808,5,108,0,0,1808,1809,5,116,0,0,1809,1810, + 5,83,0,0,1810,1811,5,101,0,0,1811,1812,5,108,0,0,1812,1813,5,101, + 0,0,1813,1814,5,99,0,0,1814,1815,5,116,0,0,1815,1816,5,111,0,0,1816, + 1817,5,114,0,0,1817,1818,5,34,0,0,1818,202,1,0,0,0,1819,1820,5,34, + 0,0,1820,1821,5,73,0,0,1821,1822,5,116,0,0,1822,1823,5,101,0,0,1823, + 1824,5,109,0,0,1824,1825,5,82,0,0,1825,1826,5,101,0,0,1826,1827, + 5,97,0,0,1827,1828,5,100,0,0,1828,1829,5,101,0,0,1829,1830,5,114, + 0,0,1830,1831,5,34,0,0,1831,204,1,0,0,0,1832,1833,5,34,0,0,1833, + 1834,5,82,0,0,1834,1835,5,101,0,0,1835,1836,5,97,0,0,1836,1837,5, + 100,0,0,1837,1838,5,101,0,0,1838,1839,5,114,0,0,1839,1840,5,67,0, + 0,1840,1841,5,111,0,0,1841,1842,5,110,0,0,1842,1843,5,102,0,0,1843, + 1844,5,105,0,0,1844,1845,5,103,0,0,1845,1846,5,34,0,0,1846,206,1, + 0,0,0,1847,1848,5,34,0,0,1848,1849,5,73,0,0,1849,1850,5,110,0,0, + 1850,1851,5,112,0,0,1851,1852,5,117,0,0,1852,1853,5,116,0,0,1853, + 1854,5,84,0,0,1854,1855,5,121,0,0,1855,1856,5,112,0,0,1856,1857, + 5,101,0,0,1857,1858,5,34,0,0,1858,208,1,0,0,0,1859,1860,5,34,0,0, + 1860,1861,5,67,0,0,1861,1862,5,83,0,0,1862,1863,5,86,0,0,1863,1864, + 5,72,0,0,1864,1865,5,101,0,0,1865,1866,5,97,0,0,1866,1867,5,100, + 0,0,1867,1868,5,101,0,0,1868,1869,5,114,0,0,1869,1870,5,76,0,0,1870, + 1871,5,111,0,0,1871,1872,5,99,0,0,1872,1873,5,97,0,0,1873,1874,5, + 116,0,0,1874,1875,5,105,0,0,1875,1876,5,111,0,0,1876,1877,5,110, + 0,0,1877,1878,5,34,0,0,1878,210,1,0,0,0,1879,1880,5,34,0,0,1880, + 1881,5,67,0,0,1881,1882,5,83,0,0,1882,1883,5,86,0,0,1883,1884,5, + 72,0,0,1884,1885,5,101,0,0,1885,1886,5,97,0,0,1886,1887,5,100,0, + 0,1887,1888,5,101,0,0,1888,1889,5,114,0,0,1889,1890,5,115,0,0,1890, + 1891,5,34,0,0,1891,212,1,0,0,0,1892,1893,5,34,0,0,1893,1894,5,77, + 0,0,1894,1895,5,97,0,0,1895,1896,5,120,0,0,1896,1897,5,73,0,0,1897, + 1898,5,116,0,0,1898,1899,5,101,0,0,1899,1900,5,109,0,0,1900,1901, + 5,115,0,0,1901,1902,5,34,0,0,1902,214,1,0,0,0,1903,1904,5,34,0,0, + 1904,1905,5,77,0,0,1905,1906,5,97,0,0,1906,1907,5,120,0,0,1907,1908, + 5,73,0,0,1908,1909,5,116,0,0,1909,1910,5,101,0,0,1910,1911,5,109, + 0,0,1911,1912,5,115,0,0,1912,1913,5,80,0,0,1913,1914,5,97,0,0,1914, + 1915,5,116,0,0,1915,1916,5,104,0,0,1916,1917,5,34,0,0,1917,216,1, + 0,0,0,1918,1919,5,34,0,0,1919,1920,5,84,0,0,1920,1921,5,111,0,0, + 1921,1922,5,108,0,0,1922,1923,5,101,0,0,1923,1924,5,114,0,0,1924, + 1925,5,97,0,0,1925,1926,5,116,0,0,1926,1927,5,101,0,0,1927,1928, + 5,100,0,0,1928,1929,5,70,0,0,1929,1930,5,97,0,0,1930,1931,5,105, + 0,0,1931,1932,5,108,0,0,1932,1933,5,117,0,0,1933,1934,5,114,0,0, + 1934,1935,5,101,0,0,1935,1936,5,67,0,0,1936,1937,5,111,0,0,1937, + 1938,5,117,0,0,1938,1939,5,110,0,0,1939,1940,5,116,0,0,1940,1941, + 5,34,0,0,1941,218,1,0,0,0,1942,1943,5,34,0,0,1943,1944,5,84,0,0, + 1944,1945,5,111,0,0,1945,1946,5,108,0,0,1946,1947,5,101,0,0,1947, + 1948,5,114,0,0,1948,1949,5,97,0,0,1949,1950,5,116,0,0,1950,1951, + 5,101,0,0,1951,1952,5,100,0,0,1952,1953,5,70,0,0,1953,1954,5,97, + 0,0,1954,1955,5,105,0,0,1955,1956,5,108,0,0,1956,1957,5,117,0,0, + 1957,1958,5,114,0,0,1958,1959,5,101,0,0,1959,1960,5,67,0,0,1960, + 1961,5,111,0,0,1961,1962,5,117,0,0,1962,1963,5,110,0,0,1963,1964, + 5,116,0,0,1964,1965,5,80,0,0,1965,1966,5,97,0,0,1966,1967,5,116, + 0,0,1967,1968,5,104,0,0,1968,1969,5,34,0,0,1969,220,1,0,0,0,1970, + 1971,5,34,0,0,1971,1972,5,84,0,0,1972,1973,5,111,0,0,1973,1974,5, + 108,0,0,1974,1975,5,101,0,0,1975,1976,5,114,0,0,1976,1977,5,97,0, + 0,1977,1978,5,116,0,0,1978,1979,5,101,0,0,1979,1980,5,100,0,0,1980, + 1981,5,70,0,0,1981,1982,5,97,0,0,1982,1983,5,105,0,0,1983,1984,5, + 108,0,0,1984,1985,5,117,0,0,1985,1986,5,114,0,0,1986,1987,5,101, + 0,0,1987,1988,5,80,0,0,1988,1989,5,101,0,0,1989,1990,5,114,0,0,1990, + 1991,5,99,0,0,1991,1992,5,101,0,0,1992,1993,5,110,0,0,1993,1994, + 5,116,0,0,1994,1995,5,97,0,0,1995,1996,5,103,0,0,1996,1997,5,101, + 0,0,1997,1998,5,34,0,0,1998,222,1,0,0,0,1999,2000,5,34,0,0,2000, + 2001,5,84,0,0,2001,2002,5,111,0,0,2002,2003,5,108,0,0,2003,2004, + 5,101,0,0,2004,2005,5,114,0,0,2005,2006,5,97,0,0,2006,2007,5,116, + 0,0,2007,2008,5,101,0,0,2008,2009,5,100,0,0,2009,2010,5,70,0,0,2010, + 2011,5,97,0,0,2011,2012,5,105,0,0,2012,2013,5,108,0,0,2013,2014, + 5,117,0,0,2014,2015,5,114,0,0,2015,2016,5,101,0,0,2016,2017,5,80, + 0,0,2017,2018,5,101,0,0,2018,2019,5,114,0,0,2019,2020,5,99,0,0,2020, + 2021,5,101,0,0,2021,2022,5,110,0,0,2022,2023,5,116,0,0,2023,2024, + 5,97,0,0,2024,2025,5,103,0,0,2025,2026,5,101,0,0,2026,2027,5,80, + 0,0,2027,2028,5,97,0,0,2028,2029,5,116,0,0,2029,2030,5,104,0,0,2030, + 2031,5,34,0,0,2031,224,1,0,0,0,2032,2033,5,34,0,0,2033,2034,5,76, + 0,0,2034,2035,5,97,0,0,2035,2036,5,98,0,0,2036,2037,5,101,0,0,2037, + 2038,5,108,0,0,2038,2039,5,34,0,0,2039,226,1,0,0,0,2040,2041,5,34, + 0,0,2041,2042,5,82,0,0,2042,2043,5,101,0,0,2043,2044,5,115,0,0,2044, + 2045,5,117,0,0,2045,2046,5,108,0,0,2046,2047,5,116,0,0,2047,2048, + 5,87,0,0,2048,2049,5,114,0,0,2049,2050,5,105,0,0,2050,2051,5,116, + 0,0,2051,2052,5,101,0,0,2052,2053,5,114,0,0,2053,2054,5,34,0,0,2054, + 228,1,0,0,0,2055,2056,5,34,0,0,2056,2057,5,78,0,0,2057,2058,5,101, + 0,0,2058,2059,5,120,0,0,2059,2060,5,116,0,0,2060,2061,5,34,0,0,2061, + 230,1,0,0,0,2062,2063,5,34,0,0,2063,2064,5,69,0,0,2064,2065,5,110, + 0,0,2065,2066,5,100,0,0,2066,2067,5,34,0,0,2067,232,1,0,0,0,2068, + 2069,5,34,0,0,2069,2070,5,67,0,0,2070,2071,5,97,0,0,2071,2072,5, + 117,0,0,2072,2073,5,115,0,0,2073,2074,5,101,0,0,2074,2075,5,34,0, + 0,2075,234,1,0,0,0,2076,2077,5,34,0,0,2077,2078,5,67,0,0,2078,2079, + 5,97,0,0,2079,2080,5,117,0,0,2080,2081,5,115,0,0,2081,2082,5,101, + 0,0,2082,2083,5,80,0,0,2083,2084,5,97,0,0,2084,2085,5,116,0,0,2085, + 2086,5,104,0,0,2086,2087,5,34,0,0,2087,236,1,0,0,0,2088,2089,5,34, + 0,0,2089,2090,5,69,0,0,2090,2091,5,114,0,0,2091,2092,5,114,0,0,2092, + 2093,5,111,0,0,2093,2094,5,114,0,0,2094,2095,5,34,0,0,2095,238,1, + 0,0,0,2096,2097,5,34,0,0,2097,2098,5,69,0,0,2098,2099,5,114,0,0, + 2099,2100,5,114,0,0,2100,2101,5,111,0,0,2101,2102,5,114,0,0,2102, + 2103,5,80,0,0,2103,2104,5,97,0,0,2104,2105,5,116,0,0,2105,2106,5, + 104,0,0,2106,2107,5,34,0,0,2107,240,1,0,0,0,2108,2109,5,34,0,0,2109, + 2110,5,82,0,0,2110,2111,5,101,0,0,2111,2112,5,116,0,0,2112,2113, + 5,114,0,0,2113,2114,5,121,0,0,2114,2115,5,34,0,0,2115,242,1,0,0, + 0,2116,2117,5,34,0,0,2117,2118,5,69,0,0,2118,2119,5,114,0,0,2119, + 2120,5,114,0,0,2120,2121,5,111,0,0,2121,2122,5,114,0,0,2122,2123, + 5,69,0,0,2123,2124,5,113,0,0,2124,2125,5,117,0,0,2125,2126,5,97, + 0,0,2126,2127,5,108,0,0,2127,2128,5,115,0,0,2128,2129,5,34,0,0,2129, + 244,1,0,0,0,2130,2131,5,34,0,0,2131,2132,5,73,0,0,2132,2133,5,110, + 0,0,2133,2134,5,116,0,0,2134,2135,5,101,0,0,2135,2136,5,114,0,0, + 2136,2137,5,118,0,0,2137,2138,5,97,0,0,2138,2139,5,108,0,0,2139, + 2140,5,83,0,0,2140,2141,5,101,0,0,2141,2142,5,99,0,0,2142,2143,5, + 111,0,0,2143,2144,5,110,0,0,2144,2145,5,100,0,0,2145,2146,5,115, + 0,0,2146,2147,5,34,0,0,2147,246,1,0,0,0,2148,2149,5,34,0,0,2149, + 2150,5,77,0,0,2150,2151,5,97,0,0,2151,2152,5,120,0,0,2152,2153,5, + 65,0,0,2153,2154,5,116,0,0,2154,2155,5,116,0,0,2155,2156,5,101,0, + 0,2156,2157,5,109,0,0,2157,2158,5,112,0,0,2158,2159,5,116,0,0,2159, + 2160,5,115,0,0,2160,2161,5,34,0,0,2161,248,1,0,0,0,2162,2163,5,34, + 0,0,2163,2164,5,66,0,0,2164,2165,5,97,0,0,2165,2166,5,99,0,0,2166, + 2167,5,107,0,0,2167,2168,5,111,0,0,2168,2169,5,102,0,0,2169,2170, + 5,102,0,0,2170,2171,5,82,0,0,2171,2172,5,97,0,0,2172,2173,5,116, + 0,0,2173,2174,5,101,0,0,2174,2175,5,34,0,0,2175,250,1,0,0,0,2176, + 2177,5,34,0,0,2177,2178,5,77,0,0,2178,2179,5,97,0,0,2179,2180,5, + 120,0,0,2180,2181,5,68,0,0,2181,2182,5,101,0,0,2182,2183,5,108,0, + 0,2183,2184,5,97,0,0,2184,2185,5,121,0,0,2185,2186,5,83,0,0,2186, + 2187,5,101,0,0,2187,2188,5,99,0,0,2188,2189,5,111,0,0,2189,2190, + 5,110,0,0,2190,2191,5,100,0,0,2191,2192,5,115,0,0,2192,2193,5,34, + 0,0,2193,252,1,0,0,0,2194,2195,5,34,0,0,2195,2196,5,74,0,0,2196, + 2197,5,105,0,0,2197,2198,5,116,0,0,2198,2199,5,116,0,0,2199,2200, + 5,101,0,0,2200,2201,5,114,0,0,2201,2202,5,83,0,0,2202,2203,5,116, + 0,0,2203,2204,5,114,0,0,2204,2205,5,97,0,0,2205,2206,5,116,0,0,2206, + 2207,5,101,0,0,2207,2208,5,103,0,0,2208,2209,5,121,0,0,2209,2210, + 5,34,0,0,2210,254,1,0,0,0,2211,2212,5,34,0,0,2212,2213,5,70,0,0, + 2213,2214,5,85,0,0,2214,2215,5,76,0,0,2215,2216,5,76,0,0,2216,2217, + 5,34,0,0,2217,256,1,0,0,0,2218,2219,5,34,0,0,2219,2220,5,78,0,0, + 2220,2221,5,79,0,0,2221,2222,5,78,0,0,2222,2223,5,69,0,0,2223,2224, + 5,34,0,0,2224,258,1,0,0,0,2225,2226,5,34,0,0,2226,2227,5,67,0,0, + 2227,2228,5,97,0,0,2228,2229,5,116,0,0,2229,2230,5,99,0,0,2230,2231, + 5,104,0,0,2231,2232,5,34,0,0,2232,260,1,0,0,0,2233,2234,5,34,0,0, + 2234,2235,5,81,0,0,2235,2236,5,117,0,0,2236,2237,5,101,0,0,2237, + 2238,5,114,0,0,2238,2239,5,121,0,0,2239,2240,5,76,0,0,2240,2241, + 5,97,0,0,2241,2242,5,110,0,0,2242,2243,5,103,0,0,2243,2244,5,117, + 0,0,2244,2245,5,97,0,0,2245,2246,5,103,0,0,2246,2247,5,101,0,0,2247, + 2248,5,34,0,0,2248,262,1,0,0,0,2249,2250,5,34,0,0,2250,2251,5,74, + 0,0,2251,2252,5,83,0,0,2252,2253,5,79,0,0,2253,2254,5,78,0,0,2254, + 2255,5,80,0,0,2255,2256,5,97,0,0,2256,2257,5,116,0,0,2257,2258,5, + 104,0,0,2258,2259,5,34,0,0,2259,264,1,0,0,0,2260,2261,5,34,0,0,2261, + 2262,5,74,0,0,2262,2263,5,83,0,0,2263,2264,5,79,0,0,2264,2265,5, + 78,0,0,2265,2266,5,97,0,0,2266,2267,5,116,0,0,2267,2268,5,97,0,0, + 2268,2269,5,34,0,0,2269,266,1,0,0,0,2270,2271,5,34,0,0,2271,2272, + 5,65,0,0,2272,2273,5,115,0,0,2273,2274,5,115,0,0,2274,2275,5,105, + 0,0,2275,2276,5,103,0,0,2276,2277,5,110,0,0,2277,2278,5,34,0,0,2278, + 268,1,0,0,0,2279,2280,5,34,0,0,2280,2281,5,79,0,0,2281,2282,5,117, + 0,0,2282,2283,5,116,0,0,2283,2284,5,112,0,0,2284,2285,5,117,0,0, + 2285,2286,5,116,0,0,2286,2287,5,34,0,0,2287,270,1,0,0,0,2288,2289, + 5,34,0,0,2289,2290,5,65,0,0,2290,2291,5,114,0,0,2291,2292,5,103, + 0,0,2292,2293,5,117,0,0,2293,2294,5,109,0,0,2294,2295,5,101,0,0, + 2295,2296,5,110,0,0,2296,2297,5,116,0,0,2297,2298,5,115,0,0,2298, + 2299,5,34,0,0,2299,272,1,0,0,0,2300,2301,5,34,0,0,2301,2302,5,83, + 0,0,2302,2303,5,116,0,0,2303,2304,5,97,0,0,2304,2305,5,116,0,0,2305, + 2306,5,101,0,0,2306,2307,5,115,0,0,2307,2308,5,46,0,0,2308,2309, + 5,65,0,0,2309,2310,5,76,0,0,2310,2311,5,76,0,0,2311,2312,5,34,0, + 0,2312,274,1,0,0,0,2313,2314,5,34,0,0,2314,2315,5,83,0,0,2315,2316, + 5,116,0,0,2316,2317,5,97,0,0,2317,2318,5,116,0,0,2318,2319,5,101, + 0,0,2319,2320,5,115,0,0,2320,2321,5,46,0,0,2321,2322,5,68,0,0,2322, + 2323,5,97,0,0,2323,2324,5,116,0,0,2324,2325,5,97,0,0,2325,2326,5, + 76,0,0,2326,2327,5,105,0,0,2327,2328,5,109,0,0,2328,2329,5,105,0, + 0,2329,2330,5,116,0,0,2330,2331,5,69,0,0,2331,2332,5,120,0,0,2332, + 2333,5,99,0,0,2333,2334,5,101,0,0,2334,2335,5,101,0,0,2335,2336, + 5,100,0,0,2336,2337,5,101,0,0,2337,2338,5,100,0,0,2338,2339,5,34, + 0,0,2339,276,1,0,0,0,2340,2341,5,34,0,0,2341,2342,5,83,0,0,2342, + 2343,5,116,0,0,2343,2344,5,97,0,0,2344,2345,5,116,0,0,2345,2346, + 5,101,0,0,2346,2347,5,115,0,0,2347,2348,5,46,0,0,2348,2349,5,72, + 0,0,2349,2350,5,101,0,0,2350,2351,5,97,0,0,2351,2352,5,114,0,0,2352, + 2353,5,116,0,0,2353,2354,5,98,0,0,2354,2355,5,101,0,0,2355,2356, + 5,97,0,0,2356,2357,5,116,0,0,2357,2358,5,84,0,0,2358,2359,5,105, + 0,0,2359,2360,5,109,0,0,2360,2361,5,101,0,0,2361,2362,5,111,0,0, + 2362,2363,5,117,0,0,2363,2364,5,116,0,0,2364,2365,5,34,0,0,2365, + 278,1,0,0,0,2366,2367,5,34,0,0,2367,2368,5,83,0,0,2368,2369,5,116, + 0,0,2369,2370,5,97,0,0,2370,2371,5,116,0,0,2371,2372,5,101,0,0,2372, + 2373,5,115,0,0,2373,2374,5,46,0,0,2374,2375,5,84,0,0,2375,2376,5, + 105,0,0,2376,2377,5,109,0,0,2377,2378,5,101,0,0,2378,2379,5,111, + 0,0,2379,2380,5,117,0,0,2380,2381,5,116,0,0,2381,2382,5,34,0,0,2382, + 280,1,0,0,0,2383,2384,5,34,0,0,2384,2385,5,83,0,0,2385,2386,5,116, + 0,0,2386,2387,5,97,0,0,2387,2388,5,116,0,0,2388,2389,5,101,0,0,2389, + 2390,5,115,0,0,2390,2391,5,46,0,0,2391,2392,5,84,0,0,2392,2393,5, + 97,0,0,2393,2394,5,115,0,0,2394,2395,5,107,0,0,2395,2396,5,70,0, + 0,2396,2397,5,97,0,0,2397,2398,5,105,0,0,2398,2399,5,108,0,0,2399, + 2400,5,101,0,0,2400,2401,5,100,0,0,2401,2402,5,34,0,0,2402,282,1, + 0,0,0,2403,2404,5,34,0,0,2404,2405,5,83,0,0,2405,2406,5,116,0,0, + 2406,2407,5,97,0,0,2407,2408,5,116,0,0,2408,2409,5,101,0,0,2409, + 2410,5,115,0,0,2410,2411,5,46,0,0,2411,2412,5,80,0,0,2412,2413,5, + 101,0,0,2413,2414,5,114,0,0,2414,2415,5,109,0,0,2415,2416,5,105, + 0,0,2416,2417,5,115,0,0,2417,2418,5,115,0,0,2418,2419,5,105,0,0, + 2419,2420,5,111,0,0,2420,2421,5,110,0,0,2421,2422,5,115,0,0,2422, + 2423,5,34,0,0,2423,284,1,0,0,0,2424,2425,5,34,0,0,2425,2426,5,83, + 0,0,2426,2427,5,116,0,0,2427,2428,5,97,0,0,2428,2429,5,116,0,0,2429, + 2430,5,101,0,0,2430,2431,5,115,0,0,2431,2432,5,46,0,0,2432,2433, + 5,82,0,0,2433,2434,5,101,0,0,2434,2435,5,115,0,0,2435,2436,5,117, + 0,0,2436,2437,5,108,0,0,2437,2438,5,116,0,0,2438,2439,5,80,0,0,2439, + 2440,5,97,0,0,2440,2441,5,116,0,0,2441,2442,5,104,0,0,2442,2443, + 5,77,0,0,2443,2444,5,97,0,0,2444,2445,5,116,0,0,2445,2446,5,99,0, + 0,2446,2447,5,104,0,0,2447,2448,5,70,0,0,2448,2449,5,97,0,0,2449, + 2450,5,105,0,0,2450,2451,5,108,0,0,2451,2452,5,117,0,0,2452,2453, + 5,114,0,0,2453,2454,5,101,0,0,2454,2455,5,34,0,0,2455,286,1,0,0, + 0,2456,2457,5,34,0,0,2457,2458,5,83,0,0,2458,2459,5,116,0,0,2459, + 2460,5,97,0,0,2460,2461,5,116,0,0,2461,2462,5,101,0,0,2462,2463, + 5,115,0,0,2463,2464,5,46,0,0,2464,2465,5,80,0,0,2465,2466,5,97,0, + 0,2466,2467,5,114,0,0,2467,2468,5,97,0,0,2468,2469,5,109,0,0,2469, + 2470,5,101,0,0,2470,2471,5,116,0,0,2471,2472,5,101,0,0,2472,2473, + 5,114,0,0,2473,2474,5,80,0,0,2474,2475,5,97,0,0,2475,2476,5,116, + 0,0,2476,2477,5,104,0,0,2477,2478,5,70,0,0,2478,2479,5,97,0,0,2479, + 2480,5,105,0,0,2480,2481,5,108,0,0,2481,2482,5,117,0,0,2482,2483, + 5,114,0,0,2483,2484,5,101,0,0,2484,2485,5,34,0,0,2485,288,1,0,0, + 0,2486,2487,5,34,0,0,2487,2488,5,83,0,0,2488,2489,5,116,0,0,2489, + 2490,5,97,0,0,2490,2491,5,116,0,0,2491,2492,5,101,0,0,2492,2493, + 5,115,0,0,2493,2494,5,46,0,0,2494,2495,5,66,0,0,2495,2496,5,114, + 0,0,2496,2497,5,97,0,0,2497,2498,5,110,0,0,2498,2499,5,99,0,0,2499, + 2500,5,104,0,0,2500,2501,5,70,0,0,2501,2502,5,97,0,0,2502,2503,5, + 105,0,0,2503,2504,5,108,0,0,2504,2505,5,101,0,0,2505,2506,5,100, + 0,0,2506,2507,5,34,0,0,2507,290,1,0,0,0,2508,2509,5,34,0,0,2509, + 2510,5,83,0,0,2510,2511,5,116,0,0,2511,2512,5,97,0,0,2512,2513,5, + 116,0,0,2513,2514,5,101,0,0,2514,2515,5,115,0,0,2515,2516,5,46,0, + 0,2516,2517,5,78,0,0,2517,2518,5,111,0,0,2518,2519,5,67,0,0,2519, + 2520,5,104,0,0,2520,2521,5,111,0,0,2521,2522,5,105,0,0,2522,2523, + 5,99,0,0,2523,2524,5,101,0,0,2524,2525,5,77,0,0,2525,2526,5,97,0, + 0,2526,2527,5,116,0,0,2527,2528,5,99,0,0,2528,2529,5,104,0,0,2529, + 2530,5,101,0,0,2530,2531,5,100,0,0,2531,2532,5,34,0,0,2532,292,1, + 0,0,0,2533,2534,5,34,0,0,2534,2535,5,83,0,0,2535,2536,5,116,0,0, + 2536,2537,5,97,0,0,2537,2538,5,116,0,0,2538,2539,5,101,0,0,2539, + 2540,5,115,0,0,2540,2541,5,46,0,0,2541,2542,5,73,0,0,2542,2543,5, + 110,0,0,2543,2544,5,116,0,0,2544,2545,5,114,0,0,2545,2546,5,105, + 0,0,2546,2547,5,110,0,0,2547,2548,5,115,0,0,2548,2549,5,105,0,0, + 2549,2550,5,99,0,0,2550,2551,5,70,0,0,2551,2552,5,97,0,0,2552,2553, + 5,105,0,0,2553,2554,5,108,0,0,2554,2555,5,117,0,0,2555,2556,5,114, + 0,0,2556,2557,5,101,0,0,2557,2558,5,34,0,0,2558,294,1,0,0,0,2559, + 2560,5,34,0,0,2560,2561,5,83,0,0,2561,2562,5,116,0,0,2562,2563,5, + 97,0,0,2563,2564,5,116,0,0,2564,2565,5,101,0,0,2565,2566,5,115,0, + 0,2566,2567,5,46,0,0,2567,2568,5,69,0,0,2568,2569,5,120,0,0,2569, + 2570,5,99,0,0,2570,2571,5,101,0,0,2571,2572,5,101,0,0,2572,2573, + 5,100,0,0,2573,2574,5,84,0,0,2574,2575,5,111,0,0,2575,2576,5,108, + 0,0,2576,2577,5,101,0,0,2577,2578,5,114,0,0,2578,2579,5,97,0,0,2579, + 2580,5,116,0,0,2580,2581,5,101,0,0,2581,2582,5,100,0,0,2582,2583, + 5,70,0,0,2583,2584,5,97,0,0,2584,2585,5,105,0,0,2585,2586,5,108, + 0,0,2586,2587,5,117,0,0,2587,2588,5,114,0,0,2588,2589,5,101,0,0, + 2589,2590,5,84,0,0,2590,2591,5,104,0,0,2591,2592,5,114,0,0,2592, + 2593,5,101,0,0,2593,2594,5,115,0,0,2594,2595,5,104,0,0,2595,2596, + 5,111,0,0,2596,2597,5,108,0,0,2597,2598,5,100,0,0,2598,2599,5,34, + 0,0,2599,296,1,0,0,0,2600,2601,5,34,0,0,2601,2602,5,83,0,0,2602, + 2603,5,116,0,0,2603,2604,5,97,0,0,2604,2605,5,116,0,0,2605,2606, + 5,101,0,0,2606,2607,5,115,0,0,2607,2608,5,46,0,0,2608,2609,5,73, + 0,0,2609,2610,5,116,0,0,2610,2611,5,101,0,0,2611,2612,5,109,0,0, + 2612,2613,5,82,0,0,2613,2614,5,101,0,0,2614,2615,5,97,0,0,2615,2616, + 5,100,0,0,2616,2617,5,101,0,0,2617,2618,5,114,0,0,2618,2619,5,70, + 0,0,2619,2620,5,97,0,0,2620,2621,5,105,0,0,2621,2622,5,108,0,0,2622, + 2623,5,101,0,0,2623,2624,5,100,0,0,2624,2625,5,34,0,0,2625,298,1, + 0,0,0,2626,2627,5,34,0,0,2627,2628,5,83,0,0,2628,2629,5,116,0,0, + 2629,2630,5,97,0,0,2630,2631,5,116,0,0,2631,2632,5,101,0,0,2632, + 2633,5,115,0,0,2633,2634,5,46,0,0,2634,2635,5,82,0,0,2635,2636,5, + 101,0,0,2636,2637,5,115,0,0,2637,2638,5,117,0,0,2638,2639,5,108, + 0,0,2639,2640,5,116,0,0,2640,2641,5,87,0,0,2641,2642,5,114,0,0,2642, + 2643,5,105,0,0,2643,2644,5,116,0,0,2644,2645,5,101,0,0,2645,2646, + 5,114,0,0,2646,2647,5,70,0,0,2647,2648,5,97,0,0,2648,2649,5,105, + 0,0,2649,2650,5,108,0,0,2650,2651,5,101,0,0,2651,2652,5,100,0,0, + 2652,2653,5,34,0,0,2653,300,1,0,0,0,2654,2655,5,34,0,0,2655,2656, + 5,83,0,0,2656,2657,5,116,0,0,2657,2658,5,97,0,0,2658,2659,5,116, + 0,0,2659,2660,5,101,0,0,2660,2661,5,115,0,0,2661,2662,5,46,0,0,2662, + 2663,5,81,0,0,2663,2664,5,117,0,0,2664,2665,5,101,0,0,2665,2666, + 5,114,0,0,2666,2667,5,121,0,0,2667,2668,5,69,0,0,2668,2669,5,118, + 0,0,2669,2670,5,97,0,0,2670,2671,5,108,0,0,2671,2672,5,117,0,0,2672, + 2673,5,97,0,0,2673,2674,5,116,0,0,2674,2675,5,105,0,0,2675,2676, + 5,111,0,0,2676,2677,5,110,0,0,2677,2678,5,69,0,0,2678,2679,5,114, + 0,0,2679,2680,5,114,0,0,2680,2681,5,111,0,0,2681,2682,5,114,0,0, + 2682,2683,5,34,0,0,2683,302,1,0,0,0,2684,2685,5,34,0,0,2685,2686, + 5,83,0,0,2686,2687,5,116,0,0,2687,2688,5,97,0,0,2688,2689,5,116, + 0,0,2689,2690,5,101,0,0,2690,2691,5,115,0,0,2691,2692,5,46,0,0,2692, + 2693,5,82,0,0,2693,2694,5,117,0,0,2694,2695,5,110,0,0,2695,2696, + 5,116,0,0,2696,2697,5,105,0,0,2697,2698,5,109,0,0,2698,2699,5,101, + 0,0,2699,2700,5,34,0,0,2700,304,1,0,0,0,2701,2706,5,34,0,0,2702, + 2705,3,319,159,0,2703,2705,3,325,162,0,2704,2702,1,0,0,0,2704,2703, + 1,0,0,0,2705,2708,1,0,0,0,2706,2704,1,0,0,0,2706,2707,1,0,0,0,2707, + 2709,1,0,0,0,2708,2706,1,0,0,0,2709,2710,5,46,0,0,2710,2711,5,36, + 0,0,2711,2712,5,34,0,0,2712,306,1,0,0,0,2713,2714,5,34,0,0,2714, + 2715,5,36,0,0,2715,2716,5,36,0,0,2716,2721,1,0,0,0,2717,2720,3,319, + 159,0,2718,2720,3,325,162,0,2719,2717,1,0,0,0,2719,2718,1,0,0,0, + 2720,2723,1,0,0,0,2721,2719,1,0,0,0,2721,2722,1,0,0,0,2722,2724, + 1,0,0,0,2723,2721,1,0,0,0,2724,2725,5,34,0,0,2725,308,1,0,0,0,2726, + 2727,5,34,0,0,2727,2728,5,36,0,0,2728,2742,5,34,0,0,2729,2730,5, + 34,0,0,2730,2731,5,36,0,0,2731,2732,1,0,0,0,2732,2737,7,0,0,0,2733, + 2736,3,319,159,0,2734,2736,3,325,162,0,2735,2733,1,0,0,0,2735,2734, + 1,0,0,0,2736,2739,1,0,0,0,2737,2735,1,0,0,0,2737,2738,1,0,0,0,2738, + 2740,1,0,0,0,2739,2737,1,0,0,0,2740,2742,5,34,0,0,2741,2726,1,0, + 0,0,2741,2729,1,0,0,0,2742,310,1,0,0,0,2743,2744,5,34,0,0,2744,2745, + 5,36,0,0,2745,2746,1,0,0,0,2746,2751,7,1,0,0,2747,2750,3,319,159, + 0,2748,2750,3,325,162,0,2749,2747,1,0,0,0,2749,2748,1,0,0,0,2750, + 2753,1,0,0,0,2751,2749,1,0,0,0,2751,2752,1,0,0,0,2752,2754,1,0,0, + 0,2753,2751,1,0,0,0,2754,2755,5,34,0,0,2755,312,1,0,0,0,2756,2757, + 5,34,0,0,2757,2758,5,83,0,0,2758,2759,5,116,0,0,2759,2760,5,97,0, + 0,2760,2761,5,116,0,0,2761,2762,5,101,0,0,2762,2763,5,115,0,0,2763, + 2764,5,46,0,0,2764,2767,1,0,0,0,2765,2768,3,319,159,0,2766,2768, + 3,325,162,0,2767,2765,1,0,0,0,2767,2766,1,0,0,0,2768,2769,1,0,0, + 0,2769,2767,1,0,0,0,2769,2770,1,0,0,0,2770,2771,1,0,0,0,2771,2776, + 5,40,0,0,2772,2775,3,319,159,0,2773,2775,3,325,162,0,2774,2772,1, + 0,0,0,2774,2773,1,0,0,0,2775,2778,1,0,0,0,2776,2774,1,0,0,0,2776, + 2777,1,0,0,0,2777,2779,1,0,0,0,2778,2776,1,0,0,0,2779,2780,5,41, + 0,0,2780,2781,5,34,0,0,2781,314,1,0,0,0,2782,2787,3,327,163,0,2783, + 2786,3,319,159,0,2784,2786,3,325,162,0,2785,2783,1,0,0,0,2785,2784, + 1,0,0,0,2786,2789,1,0,0,0,2787,2785,1,0,0,0,2787,2788,1,0,0,0,2788, + 2790,1,0,0,0,2789,2787,1,0,0,0,2790,2791,3,329,164,0,2791,316,1, + 0,0,0,2792,2797,5,34,0,0,2793,2796,3,319,159,0,2794,2796,3,325,162, + 0,2795,2793,1,0,0,0,2795,2794,1,0,0,0,2796,2799,1,0,0,0,2797,2795, + 1,0,0,0,2797,2798,1,0,0,0,2798,2800,1,0,0,0,2799,2797,1,0,0,0,2800, + 2801,5,34,0,0,2801,318,1,0,0,0,2802,2805,5,92,0,0,2803,2806,7,2, + 0,0,2804,2806,3,321,160,0,2805,2803,1,0,0,0,2805,2804,1,0,0,0,2806, + 320,1,0,0,0,2807,2808,5,117,0,0,2808,2809,3,323,161,0,2809,2810, + 3,323,161,0,2810,2811,3,323,161,0,2811,2812,3,323,161,0,2812,322, + 1,0,0,0,2813,2814,7,3,0,0,2814,324,1,0,0,0,2815,2816,8,4,0,0,2816, + 326,1,0,0,0,2817,2818,5,34,0,0,2818,2819,5,123,0,0,2819,2820,5,37, + 0,0,2820,328,1,0,0,0,2821,2822,5,37,0,0,2822,2823,5,125,0,0,2823, + 2824,5,34,0,0,2824,330,1,0,0,0,2825,2834,5,48,0,0,2826,2830,7,5, + 0,0,2827,2829,7,6,0,0,2828,2827,1,0,0,0,2829,2832,1,0,0,0,2830,2828, + 1,0,0,0,2830,2831,1,0,0,0,2831,2834,1,0,0,0,2832,2830,1,0,0,0,2833, + 2825,1,0,0,0,2833,2826,1,0,0,0,2834,332,1,0,0,0,2835,2837,5,45,0, + 0,2836,2835,1,0,0,0,2836,2837,1,0,0,0,2837,2838,1,0,0,0,2838,2845, + 3,331,165,0,2839,2841,5,46,0,0,2840,2842,7,6,0,0,2841,2840,1,0,0, + 0,2842,2843,1,0,0,0,2843,2841,1,0,0,0,2843,2844,1,0,0,0,2844,2846, + 1,0,0,0,2845,2839,1,0,0,0,2845,2846,1,0,0,0,2846,2848,1,0,0,0,2847, + 2849,3,335,167,0,2848,2847,1,0,0,0,2848,2849,1,0,0,0,2849,334,1, + 0,0,0,2850,2852,7,7,0,0,2851,2853,7,8,0,0,2852,2851,1,0,0,0,2852, + 2853,1,0,0,0,2853,2854,1,0,0,0,2854,2855,3,331,165,0,2855,336,1, + 0,0,0,2856,2858,7,9,0,0,2857,2856,1,0,0,0,2858,2859,1,0,0,0,2859, + 2857,1,0,0,0,2859,2860,1,0,0,0,2860,2861,1,0,0,0,2861,2862,6,168, + 0,0,2862,338,1,0,0,0,27,0,2704,2706,2719,2721,2735,2737,2741,2749, + 2751,2767,2769,2774,2776,2785,2787,2795,2797,2805,2830,2833,2836, + 2843,2845,2848,2852,2859,1,6,0,0 + ] + +class ASLLexer(Lexer): + + atn = ATNDeserializer().deserialize(serializedATN()) + + decisionsToDFA = [ DFA(ds, i) for i, ds in enumerate(atn.decisionToState) ] + + COMMA = 1 + COLON = 2 + LBRACK = 3 + RBRACK = 4 + LBRACE = 5 + RBRACE = 6 + TRUE = 7 + FALSE = 8 + NULL = 9 + COMMENT = 10 + STATES = 11 + STARTAT = 12 + NEXTSTATE = 13 + VERSION = 14 + TYPE = 15 + TASK = 16 + CHOICE = 17 + FAIL = 18 + SUCCEED = 19 + PASS = 20 + WAIT = 21 + PARALLEL = 22 + MAP = 23 + CHOICES = 24 + CONDITION = 25 + VARIABLE = 26 + DEFAULT = 27 + BRANCHES = 28 + AND = 29 + BOOLEANEQUALS = 30 + BOOLEANQUALSPATH = 31 + ISBOOLEAN = 32 + ISNULL = 33 + ISNUMERIC = 34 + ISPRESENT = 35 + ISSTRING = 36 + ISTIMESTAMP = 37 + NOT = 38 + NUMERICEQUALS = 39 + NUMERICEQUALSPATH = 40 + NUMERICGREATERTHAN = 41 + NUMERICGREATERTHANPATH = 42 + NUMERICGREATERTHANEQUALS = 43 + NUMERICGREATERTHANEQUALSPATH = 44 + NUMERICLESSTHAN = 45 + NUMERICLESSTHANPATH = 46 + NUMERICLESSTHANEQUALS = 47 + NUMERICLESSTHANEQUALSPATH = 48 + OR = 49 + STRINGEQUALS = 50 + STRINGEQUALSPATH = 51 + STRINGGREATERTHAN = 52 + STRINGGREATERTHANPATH = 53 + STRINGGREATERTHANEQUALS = 54 + STRINGGREATERTHANEQUALSPATH = 55 + STRINGLESSTHAN = 56 + STRINGLESSTHANPATH = 57 + STRINGLESSTHANEQUALS = 58 + STRINGLESSTHANEQUALSPATH = 59 + STRINGMATCHES = 60 + TIMESTAMPEQUALS = 61 + TIMESTAMPEQUALSPATH = 62 + TIMESTAMPGREATERTHAN = 63 + TIMESTAMPGREATERTHANPATH = 64 + TIMESTAMPGREATERTHANEQUALS = 65 + TIMESTAMPGREATERTHANEQUALSPATH = 66 + TIMESTAMPLESSTHAN = 67 + TIMESTAMPLESSTHANPATH = 68 + TIMESTAMPLESSTHANEQUALS = 69 + TIMESTAMPLESSTHANEQUALSPATH = 70 + SECONDSPATH = 71 + SECONDS = 72 + TIMESTAMPPATH = 73 + TIMESTAMP = 74 + TIMEOUTSECONDS = 75 + TIMEOUTSECONDSPATH = 76 + HEARTBEATSECONDS = 77 + HEARTBEATSECONDSPATH = 78 + PROCESSORCONFIG = 79 + MODE = 80 + INLINE = 81 + DISTRIBUTED = 82 + EXECUTIONTYPE = 83 + STANDARD = 84 + ITEMPROCESSOR = 85 + ITERATOR = 86 + ITEMSELECTOR = 87 + MAXCONCURRENCYPATH = 88 + MAXCONCURRENCY = 89 + RESOURCE = 90 + INPUTPATH = 91 + OUTPUTPATH = 92 + ITEMS = 93 + ITEMSPATH = 94 + RESULTPATH = 95 + RESULT = 96 + PARAMETERS = 97 + CREDENTIALS = 98 + ROLEARN = 99 + ROLEARNPATH = 100 + RESULTSELECTOR = 101 + ITEMREADER = 102 + READERCONFIG = 103 + INPUTTYPE = 104 + CSVHEADERLOCATION = 105 + CSVHEADERS = 106 + MAXITEMS = 107 + MAXITEMSPATH = 108 + TOLERATEDFAILURECOUNT = 109 + TOLERATEDFAILURECOUNTPATH = 110 + TOLERATEDFAILUREPERCENTAGE = 111 + TOLERATEDFAILUREPERCENTAGEPATH = 112 + LABEL = 113 + RESULTWRITER = 114 + NEXT = 115 + END = 116 + CAUSE = 117 + CAUSEPATH = 118 + ERROR = 119 + ERRORPATH = 120 + RETRY = 121 + ERROREQUALS = 122 + INTERVALSECONDS = 123 + MAXATTEMPTS = 124 + BACKOFFRATE = 125 + MAXDELAYSECONDS = 126 + JITTERSTRATEGY = 127 + FULL = 128 + NONE = 129 + CATCH = 130 + QUERYLANGUAGE = 131 + JSONPATH = 132 + JSONATA = 133 + ASSIGN = 134 + OUTPUT = 135 + ARGUMENTS = 136 + ERRORNAMEStatesALL = 137 + ERRORNAMEStatesDataLimitExceeded = 138 + ERRORNAMEStatesHeartbeatTimeout = 139 + ERRORNAMEStatesTimeout = 140 + ERRORNAMEStatesTaskFailed = 141 + ERRORNAMEStatesPermissions = 142 + ERRORNAMEStatesResultPathMatchFailure = 143 + ERRORNAMEStatesParameterPathFailure = 144 + ERRORNAMEStatesBranchFailed = 145 + ERRORNAMEStatesNoChoiceMatched = 146 + ERRORNAMEStatesIntrinsicFailure = 147 + ERRORNAMEStatesExceedToleratedFailureThreshold = 148 + ERRORNAMEStatesItemReaderFailed = 149 + ERRORNAMEStatesResultWriterFailed = 150 + ERRORNAMEStatesQueryEvaluationError = 151 + ERRORNAMEStatesRuntime = 152 + STRINGDOLLAR = 153 + STRINGPATHCONTEXTOBJ = 154 + STRINGPATH = 155 + STRINGVAR = 156 + STRINGINTRINSICFUNC = 157 + STRINGJSONATA = 158 + STRING = 159 + INT = 160 + NUMBER = 161 + WS = 162 + + channelNames = [ u"DEFAULT_TOKEN_CHANNEL", u"HIDDEN" ] + + modeNames = [ "DEFAULT_MODE" ] + + literalNames = [ "", + "','", "':'", "'['", "']'", "'{'", "'}'", "'true'", "'false'", + "'null'", "'\"Comment\"'", "'\"States\"'", "'\"StartAt\"'", + "'\"NextState\"'", "'\"Version\"'", "'\"Type\"'", "'\"Task\"'", + "'\"Choice\"'", "'\"Fail\"'", "'\"Succeed\"'", "'\"Pass\"'", + "'\"Wait\"'", "'\"Parallel\"'", "'\"Map\"'", "'\"Choices\"'", + "'\"Condition\"'", "'\"Variable\"'", "'\"Default\"'", "'\"Branches\"'", + "'\"And\"'", "'\"BooleanEquals\"'", "'\"BooleanEqualsPath\"'", + "'\"IsBoolean\"'", "'\"IsNull\"'", "'\"IsNumeric\"'", "'\"IsPresent\"'", + "'\"IsString\"'", "'\"IsTimestamp\"'", "'\"Not\"'", "'\"NumericEquals\"'", + "'\"NumericEqualsPath\"'", "'\"NumericGreaterThan\"'", "'\"NumericGreaterThanPath\"'", + "'\"NumericGreaterThanEquals\"'", "'\"NumericGreaterThanEqualsPath\"'", + "'\"NumericLessThan\"'", "'\"NumericLessThanPath\"'", "'\"NumericLessThanEquals\"'", + "'\"NumericLessThanEqualsPath\"'", "'\"Or\"'", "'\"StringEquals\"'", + "'\"StringEqualsPath\"'", "'\"StringGreaterThan\"'", "'\"StringGreaterThanPath\"'", + "'\"StringGreaterThanEquals\"'", "'\"StringGreaterThanEqualsPath\"'", + "'\"StringLessThan\"'", "'\"StringLessThanPath\"'", "'\"StringLessThanEquals\"'", + "'\"StringLessThanEqualsPath\"'", "'\"StringMatches\"'", "'\"TimestampEquals\"'", + "'\"TimestampEqualsPath\"'", "'\"TimestampGreaterThan\"'", "'\"TimestampGreaterThanPath\"'", + "'\"TimestampGreaterThanEquals\"'", "'\"TimestampGreaterThanEqualsPath\"'", + "'\"TimestampLessThan\"'", "'\"TimestampLessThanPath\"'", "'\"TimestampLessThanEquals\"'", + "'\"TimestampLessThanEqualsPath\"'", "'\"SecondsPath\"'", "'\"Seconds\"'", + "'\"TimestampPath\"'", "'\"Timestamp\"'", "'\"TimeoutSeconds\"'", + "'\"TimeoutSecondsPath\"'", "'\"HeartbeatSeconds\"'", "'\"HeartbeatSecondsPath\"'", + "'\"ProcessorConfig\"'", "'\"Mode\"'", "'\"INLINE\"'", "'\"DISTRIBUTED\"'", + "'\"ExecutionType\"'", "'\"STANDARD\"'", "'\"ItemProcessor\"'", + "'\"Iterator\"'", "'\"ItemSelector\"'", "'\"MaxConcurrencyPath\"'", + "'\"MaxConcurrency\"'", "'\"Resource\"'", "'\"InputPath\"'", + "'\"OutputPath\"'", "'\"Items\"'", "'\"ItemsPath\"'", "'\"ResultPath\"'", + "'\"Result\"'", "'\"Parameters\"'", "'\"Credentials\"'", "'\"RoleArn\"'", + "'\"RoleArn.$\"'", "'\"ResultSelector\"'", "'\"ItemReader\"'", + "'\"ReaderConfig\"'", "'\"InputType\"'", "'\"CSVHeaderLocation\"'", + "'\"CSVHeaders\"'", "'\"MaxItems\"'", "'\"MaxItemsPath\"'", + "'\"ToleratedFailureCount\"'", "'\"ToleratedFailureCountPath\"'", + "'\"ToleratedFailurePercentage\"'", "'\"ToleratedFailurePercentagePath\"'", + "'\"Label\"'", "'\"ResultWriter\"'", "'\"Next\"'", "'\"End\"'", + "'\"Cause\"'", "'\"CausePath\"'", "'\"Error\"'", "'\"ErrorPath\"'", + "'\"Retry\"'", "'\"ErrorEquals\"'", "'\"IntervalSeconds\"'", + "'\"MaxAttempts\"'", "'\"BackoffRate\"'", "'\"MaxDelaySeconds\"'", + "'\"JitterStrategy\"'", "'\"FULL\"'", "'\"NONE\"'", "'\"Catch\"'", + "'\"QueryLanguage\"'", "'\"JSONPath\"'", "'\"JSONata\"'", "'\"Assign\"'", + "'\"Output\"'", "'\"Arguments\"'", "'\"States.ALL\"'", "'\"States.DataLimitExceeded\"'", + "'\"States.HeartbeatTimeout\"'", "'\"States.Timeout\"'", "'\"States.TaskFailed\"'", + "'\"States.Permissions\"'", "'\"States.ResultPathMatchFailure\"'", + "'\"States.ParameterPathFailure\"'", "'\"States.BranchFailed\"'", + "'\"States.NoChoiceMatched\"'", "'\"States.IntrinsicFailure\"'", + "'\"States.ExceedToleratedFailureThreshold\"'", "'\"States.ItemReaderFailed\"'", + "'\"States.ResultWriterFailed\"'", "'\"States.QueryEvaluationError\"'", + "'\"States.Runtime\"'" ] + + symbolicNames = [ "", + "COMMA", "COLON", "LBRACK", "RBRACK", "LBRACE", "RBRACE", "TRUE", + "FALSE", "NULL", "COMMENT", "STATES", "STARTAT", "NEXTSTATE", + "VERSION", "TYPE", "TASK", "CHOICE", "FAIL", "SUCCEED", "PASS", + "WAIT", "PARALLEL", "MAP", "CHOICES", "CONDITION", "VARIABLE", + "DEFAULT", "BRANCHES", "AND", "BOOLEANEQUALS", "BOOLEANQUALSPATH", + "ISBOOLEAN", "ISNULL", "ISNUMERIC", "ISPRESENT", "ISSTRING", + "ISTIMESTAMP", "NOT", "NUMERICEQUALS", "NUMERICEQUALSPATH", + "NUMERICGREATERTHAN", "NUMERICGREATERTHANPATH", "NUMERICGREATERTHANEQUALS", + "NUMERICGREATERTHANEQUALSPATH", "NUMERICLESSTHAN", "NUMERICLESSTHANPATH", + "NUMERICLESSTHANEQUALS", "NUMERICLESSTHANEQUALSPATH", "OR", + "STRINGEQUALS", "STRINGEQUALSPATH", "STRINGGREATERTHAN", "STRINGGREATERTHANPATH", + "STRINGGREATERTHANEQUALS", "STRINGGREATERTHANEQUALSPATH", "STRINGLESSTHAN", + "STRINGLESSTHANPATH", "STRINGLESSTHANEQUALS", "STRINGLESSTHANEQUALSPATH", + "STRINGMATCHES", "TIMESTAMPEQUALS", "TIMESTAMPEQUALSPATH", "TIMESTAMPGREATERTHAN", + "TIMESTAMPGREATERTHANPATH", "TIMESTAMPGREATERTHANEQUALS", "TIMESTAMPGREATERTHANEQUALSPATH", + "TIMESTAMPLESSTHAN", "TIMESTAMPLESSTHANPATH", "TIMESTAMPLESSTHANEQUALS", + "TIMESTAMPLESSTHANEQUALSPATH", "SECONDSPATH", "SECONDS", "TIMESTAMPPATH", + "TIMESTAMP", "TIMEOUTSECONDS", "TIMEOUTSECONDSPATH", "HEARTBEATSECONDS", + "HEARTBEATSECONDSPATH", "PROCESSORCONFIG", "MODE", "INLINE", + "DISTRIBUTED", "EXECUTIONTYPE", "STANDARD", "ITEMPROCESSOR", + "ITERATOR", "ITEMSELECTOR", "MAXCONCURRENCYPATH", "MAXCONCURRENCY", + "RESOURCE", "INPUTPATH", "OUTPUTPATH", "ITEMS", "ITEMSPATH", + "RESULTPATH", "RESULT", "PARAMETERS", "CREDENTIALS", "ROLEARN", + "ROLEARNPATH", "RESULTSELECTOR", "ITEMREADER", "READERCONFIG", + "INPUTTYPE", "CSVHEADERLOCATION", "CSVHEADERS", "MAXITEMS", + "MAXITEMSPATH", "TOLERATEDFAILURECOUNT", "TOLERATEDFAILURECOUNTPATH", + "TOLERATEDFAILUREPERCENTAGE", "TOLERATEDFAILUREPERCENTAGEPATH", + "LABEL", "RESULTWRITER", "NEXT", "END", "CAUSE", "CAUSEPATH", + "ERROR", "ERRORPATH", "RETRY", "ERROREQUALS", "INTERVALSECONDS", + "MAXATTEMPTS", "BACKOFFRATE", "MAXDELAYSECONDS", "JITTERSTRATEGY", + "FULL", "NONE", "CATCH", "QUERYLANGUAGE", "JSONPATH", "JSONATA", + "ASSIGN", "OUTPUT", "ARGUMENTS", "ERRORNAMEStatesALL", "ERRORNAMEStatesDataLimitExceeded", + "ERRORNAMEStatesHeartbeatTimeout", "ERRORNAMEStatesTimeout", + "ERRORNAMEStatesTaskFailed", "ERRORNAMEStatesPermissions", "ERRORNAMEStatesResultPathMatchFailure", + "ERRORNAMEStatesParameterPathFailure", "ERRORNAMEStatesBranchFailed", + "ERRORNAMEStatesNoChoiceMatched", "ERRORNAMEStatesIntrinsicFailure", + "ERRORNAMEStatesExceedToleratedFailureThreshold", "ERRORNAMEStatesItemReaderFailed", + "ERRORNAMEStatesResultWriterFailed", "ERRORNAMEStatesQueryEvaluationError", + "ERRORNAMEStatesRuntime", "STRINGDOLLAR", "STRINGPATHCONTEXTOBJ", + "STRINGPATH", "STRINGVAR", "STRINGINTRINSICFUNC", "STRINGJSONATA", + "STRING", "INT", "NUMBER", "WS" ] + + ruleNames = [ "COMMA", "COLON", "LBRACK", "RBRACK", "LBRACE", "RBRACE", + "TRUE", "FALSE", "NULL", "COMMENT", "STATES", "STARTAT", + "NEXTSTATE", "VERSION", "TYPE", "TASK", "CHOICE", "FAIL", + "SUCCEED", "PASS", "WAIT", "PARALLEL", "MAP", "CHOICES", + "CONDITION", "VARIABLE", "DEFAULT", "BRANCHES", "AND", + "BOOLEANEQUALS", "BOOLEANQUALSPATH", "ISBOOLEAN", "ISNULL", + "ISNUMERIC", "ISPRESENT", "ISSTRING", "ISTIMESTAMP", "NOT", + "NUMERICEQUALS", "NUMERICEQUALSPATH", "NUMERICGREATERTHAN", + "NUMERICGREATERTHANPATH", "NUMERICGREATERTHANEQUALS", + "NUMERICGREATERTHANEQUALSPATH", "NUMERICLESSTHAN", "NUMERICLESSTHANPATH", + "NUMERICLESSTHANEQUALS", "NUMERICLESSTHANEQUALSPATH", + "OR", "STRINGEQUALS", "STRINGEQUALSPATH", "STRINGGREATERTHAN", + "STRINGGREATERTHANPATH", "STRINGGREATERTHANEQUALS", "STRINGGREATERTHANEQUALSPATH", + "STRINGLESSTHAN", "STRINGLESSTHANPATH", "STRINGLESSTHANEQUALS", + "STRINGLESSTHANEQUALSPATH", "STRINGMATCHES", "TIMESTAMPEQUALS", + "TIMESTAMPEQUALSPATH", "TIMESTAMPGREATERTHAN", "TIMESTAMPGREATERTHANPATH", + "TIMESTAMPGREATERTHANEQUALS", "TIMESTAMPGREATERTHANEQUALSPATH", + "TIMESTAMPLESSTHAN", "TIMESTAMPLESSTHANPATH", "TIMESTAMPLESSTHANEQUALS", + "TIMESTAMPLESSTHANEQUALSPATH", "SECONDSPATH", "SECONDS", + "TIMESTAMPPATH", "TIMESTAMP", "TIMEOUTSECONDS", "TIMEOUTSECONDSPATH", + "HEARTBEATSECONDS", "HEARTBEATSECONDSPATH", "PROCESSORCONFIG", + "MODE", "INLINE", "DISTRIBUTED", "EXECUTIONTYPE", "STANDARD", + "ITEMPROCESSOR", "ITERATOR", "ITEMSELECTOR", "MAXCONCURRENCYPATH", + "MAXCONCURRENCY", "RESOURCE", "INPUTPATH", "OUTPUTPATH", + "ITEMS", "ITEMSPATH", "RESULTPATH", "RESULT", "PARAMETERS", + "CREDENTIALS", "ROLEARN", "ROLEARNPATH", "RESULTSELECTOR", + "ITEMREADER", "READERCONFIG", "INPUTTYPE", "CSVHEADERLOCATION", + "CSVHEADERS", "MAXITEMS", "MAXITEMSPATH", "TOLERATEDFAILURECOUNT", + "TOLERATEDFAILURECOUNTPATH", "TOLERATEDFAILUREPERCENTAGE", + "TOLERATEDFAILUREPERCENTAGEPATH", "LABEL", "RESULTWRITER", + "NEXT", "END", "CAUSE", "CAUSEPATH", "ERROR", "ERRORPATH", + "RETRY", "ERROREQUALS", "INTERVALSECONDS", "MAXATTEMPTS", + "BACKOFFRATE", "MAXDELAYSECONDS", "JITTERSTRATEGY", "FULL", + "NONE", "CATCH", "QUERYLANGUAGE", "JSONPATH", "JSONATA", + "ASSIGN", "OUTPUT", "ARGUMENTS", "ERRORNAMEStatesALL", + "ERRORNAMEStatesDataLimitExceeded", "ERRORNAMEStatesHeartbeatTimeout", + "ERRORNAMEStatesTimeout", "ERRORNAMEStatesTaskFailed", + "ERRORNAMEStatesPermissions", "ERRORNAMEStatesResultPathMatchFailure", + "ERRORNAMEStatesParameterPathFailure", "ERRORNAMEStatesBranchFailed", + "ERRORNAMEStatesNoChoiceMatched", "ERRORNAMEStatesIntrinsicFailure", + "ERRORNAMEStatesExceedToleratedFailureThreshold", "ERRORNAMEStatesItemReaderFailed", + "ERRORNAMEStatesResultWriterFailed", "ERRORNAMEStatesQueryEvaluationError", + "ERRORNAMEStatesRuntime", "STRINGDOLLAR", "STRINGPATHCONTEXTOBJ", + "STRINGPATH", "STRINGVAR", "STRINGINTRINSICFUNC", "STRINGJSONATA", + "STRING", "ESC", "UNICODE", "HEX", "SAFECODEPOINT", "LJSONATA", + "RJSONATA", "INT", "NUMBER", "EXP", "WS" ] + + grammarFileName = "ASLLexer.g4" + + def __init__(self, input=None, output:TextIO = sys.stdout): + super().__init__(input, output) + self.checkVersion("4.13.2") + self._interp = LexerATNSimulator(self, self.atn, self.decisionsToDFA, PredictionContextCache()) + self._actions = None + self._predicates = None + + diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParser.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParser.py new file mode 100644 index 0000000000000..aeeb665fbc1c9 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParser.py @@ -0,0 +1,11717 @@ +# Generated from ASLParser.g4 by ANTLR 4.13.2 +# encoding: utf-8 +from antlr4 import * +from io import StringIO +import sys +if sys.version_info[1] > 5: + from typing import TextIO +else: + from typing.io import TextIO + +def serializedATN(): + return [ + 4,1,162,1154,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6, + 7,6,2,7,7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7, + 13,2,14,7,14,2,15,7,15,2,16,7,16,2,17,7,17,2,18,7,18,2,19,7,19,2, + 20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,2,24,7,24,2,25,7,25,2,26,7, + 26,2,27,7,27,2,28,7,28,2,29,7,29,2,30,7,30,2,31,7,31,2,32,7,32,2, + 33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,2,37,7,37,2,38,7,38,2,39,7, + 39,2,40,7,40,2,41,7,41,2,42,7,42,2,43,7,43,2,44,7,44,2,45,7,45,2, + 46,7,46,2,47,7,47,2,48,7,48,2,49,7,49,2,50,7,50,2,51,7,51,2,52,7, + 52,2,53,7,53,2,54,7,54,2,55,7,55,2,56,7,56,2,57,7,57,2,58,7,58,2, + 59,7,59,2,60,7,60,2,61,7,61,2,62,7,62,2,63,7,63,2,64,7,64,2,65,7, + 65,2,66,7,66,2,67,7,67,2,68,7,68,2,69,7,69,2,70,7,70,2,71,7,71,2, + 72,7,72,2,73,7,73,2,74,7,74,2,75,7,75,2,76,7,76,2,77,7,77,2,78,7, + 78,2,79,7,79,2,80,7,80,2,81,7,81,2,82,7,82,2,83,7,83,2,84,7,84,2, + 85,7,85,2,86,7,86,2,87,7,87,2,88,7,88,2,89,7,89,2,90,7,90,2,91,7, + 91,2,92,7,92,2,93,7,93,2,94,7,94,2,95,7,95,2,96,7,96,2,97,7,97,2, + 98,7,98,2,99,7,99,2,100,7,100,2,101,7,101,2,102,7,102,2,103,7,103, + 2,104,7,104,2,105,7,105,2,106,7,106,2,107,7,107,2,108,7,108,2,109, + 7,109,2,110,7,110,2,111,7,111,2,112,7,112,2,113,7,113,2,114,7,114, + 2,115,7,115,1,0,1,0,1,0,1,1,1,1,1,1,1,1,5,1,240,8,1,10,1,12,1,243, + 9,1,1,1,1,1,1,2,1,2,1,2,1,2,1,2,1,2,3,2,253,8,2,1,3,1,3,1,3,1,3, + 1,4,1,4,1,4,1,4,1,5,1,5,1,5,1,5,1,6,1,6,1,6,1,6,1,7,1,7,1,7,1,7, + 1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7, + 1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7, + 1,7,1,7,3,7,309,8,7,1,8,1,8,1,8,1,8,1,8,1,8,5,8,317,8,8,10,8,12, + 8,320,9,8,1,8,1,8,1,9,1,9,1,9,1,9,1,10,1,10,1,10,1,10,5,10,332,8, + 10,10,10,12,10,335,9,10,1,10,1,10,1,11,1,11,1,11,1,11,1,12,1,12, + 1,12,1,12,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,3,14,355,8,14, + 1,15,1,15,1,15,1,15,1,16,1,16,1,16,1,16,3,16,365,8,16,1,17,1,17, + 1,17,1,17,3,17,371,8,17,1,18,1,18,1,18,1,18,1,19,1,19,1,19,1,19, + 1,20,1,20,1,20,1,20,3,20,385,8,20,1,20,1,20,1,20,3,20,390,8,20,1, + 21,1,21,1,21,1,21,3,21,396,8,21,1,21,1,21,1,21,3,21,401,8,21,1,22, + 1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,3,22,412,8,22,1,23,1,23, + 1,23,1,23,3,23,418,8,23,1,23,1,23,1,23,3,23,423,8,23,1,24,1,24,1, + 24,1,24,1,24,1,24,3,24,431,8,24,1,25,1,25,1,25,1,25,1,26,1,26,1, + 26,1,26,1,26,1,26,1,26,1,26,1,26,3,26,446,8,26,1,27,1,27,1,27,1, + 27,1,28,1,28,1,28,1,28,1,28,1,28,1,29,1,29,1,29,1,29,3,29,462,8, + 29,1,29,1,29,1,29,3,29,467,8,29,1,30,1,30,1,30,1,30,1,30,1,30,1, + 30,1,30,1,30,3,30,478,8,30,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1, + 31,1,31,3,31,489,8,31,1,32,1,32,1,32,1,32,5,32,495,8,32,10,32,12, + 32,498,9,32,1,32,1,32,1,32,1,32,3,32,504,8,32,1,33,1,33,1,33,1,33, + 1,33,1,33,1,33,3,33,513,8,33,1,34,1,34,1,34,1,34,5,34,519,8,34,10, + 34,12,34,522,9,34,1,34,1,34,1,34,1,34,3,34,528,8,34,1,35,1,35,1, + 35,3,35,533,8,35,1,36,1,36,1,36,1,36,1,36,3,36,540,8,36,1,37,1,37, + 1,37,1,37,1,38,1,38,1,38,1,38,1,38,1,38,5,38,552,8,38,10,38,12,38, + 555,9,38,1,38,1,38,3,38,559,8,38,1,39,1,39,1,40,1,40,1,40,1,40,1, + 40,1,40,5,40,569,8,40,10,40,12,40,572,9,40,1,40,1,40,3,40,576,8, + 40,1,41,1,41,1,41,1,41,1,41,1,41,1,41,3,41,585,8,41,1,42,1,42,1, + 42,3,42,590,8,42,1,43,1,43,1,43,1,43,1,43,1,43,5,43,598,8,43,10, + 43,12,43,601,9,43,1,43,1,43,3,43,605,8,43,1,44,1,44,1,44,1,44,1, + 44,1,44,3,44,613,8,44,1,45,1,45,1,45,1,45,1,45,1,45,3,45,621,8,45, + 1,46,1,46,1,46,1,46,1,47,1,47,1,47,1,47,1,47,1,47,5,47,633,8,47, + 10,47,12,47,636,9,47,1,47,1,47,3,47,640,8,47,1,48,1,48,1,48,1,48, + 1,49,1,49,1,49,3,49,649,8,49,1,50,1,50,1,50,1,50,1,50,1,50,5,50, + 657,8,50,10,50,12,50,660,9,50,1,50,1,50,3,50,664,8,50,1,51,1,51, + 1,51,1,51,1,51,1,51,3,51,672,8,51,1,52,1,52,1,52,1,52,1,53,1,53, + 1,54,1,54,1,54,1,54,1,54,1,54,5,54,686,8,54,10,54,12,54,689,9,54, + 1,54,1,54,1,55,1,55,1,55,1,55,4,55,697,8,55,11,55,12,55,698,1,55, + 1,55,1,55,1,55,1,55,1,55,5,55,707,8,55,10,55,12,55,710,9,55,1,55, + 1,55,3,55,714,8,55,1,56,1,56,1,56,1,56,1,56,1,56,3,56,722,8,56,1, + 57,1,57,1,57,1,57,3,57,728,8,57,1,58,1,58,1,58,1,58,1,58,1,58,1, + 58,5,58,737,8,58,10,58,12,58,740,9,58,1,58,1,58,3,58,744,8,58,1, + 59,1,59,1,59,1,59,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1,60,1, + 60,1,60,1,60,1,60,1,60,3,60,764,8,60,1,61,1,61,1,61,1,61,1,61,1, + 61,5,61,772,8,61,10,61,12,61,775,9,61,1,61,1,61,1,62,1,62,1,62,1, + 62,1,62,1,62,5,62,785,8,62,10,62,12,62,788,9,62,1,62,1,62,1,63,1, + 63,1,63,1,63,3,63,796,8,63,1,64,1,64,1,64,1,64,1,64,1,64,5,64,804, + 8,64,10,64,12,64,807,9,64,1,64,1,64,1,65,1,65,3,65,813,8,65,1,66, + 1,66,1,66,1,66,1,67,1,67,1,68,1,68,1,68,1,68,1,69,1,69,1,70,1,70, + 1,70,1,70,1,70,1,70,5,70,833,8,70,10,70,12,70,836,9,70,1,70,1,70, + 1,71,1,71,1,71,1,71,3,71,844,8,71,1,72,1,72,1,72,1,72,1,73,1,73, + 1,73,1,73,1,73,1,73,5,73,856,8,73,10,73,12,73,859,9,73,1,73,1,73, + 1,74,1,74,1,74,1,74,3,74,867,8,74,1,75,1,75,1,75,1,75,1,75,1,75, + 5,75,875,8,75,10,75,12,75,878,9,75,1,75,1,75,1,76,1,76,1,76,1,76, + 3,76,886,8,76,1,77,1,77,1,77,1,77,1,78,1,78,1,78,1,78,1,79,1,79, + 1,79,1,79,1,79,1,79,5,79,902,8,79,10,79,12,79,905,9,79,1,79,1,79, + 1,80,1,80,1,80,1,80,1,80,1,80,1,80,1,80,1,80,3,80,918,8,80,1,81, + 1,81,1,81,1,81,1,81,1,81,1,81,1,81,1,81,3,81,929,8,81,1,82,1,82, + 1,82,1,82,1,82,1,82,1,82,1,82,1,82,3,82,940,8,82,1,83,1,83,1,83, + 1,83,1,84,1,84,1,84,1,84,1,84,1,84,5,84,952,8,84,10,84,12,84,955, + 9,84,1,84,1,84,1,85,1,85,3,85,961,8,85,1,86,1,86,1,86,1,86,1,86, + 1,86,5,86,969,8,86,10,86,12,86,972,9,86,3,86,974,8,86,1,86,1,86, + 1,87,1,87,1,87,1,87,5,87,982,8,87,10,87,12,87,985,9,87,1,87,1,87, + 1,88,1,88,1,88,1,88,1,88,1,88,1,88,3,88,996,8,88,1,89,1,89,1,89, + 1,89,1,89,1,89,5,89,1004,8,89,10,89,12,89,1007,9,89,1,89,1,89,1, + 90,1,90,1,90,1,90,1,91,1,91,1,91,1,91,1,92,1,92,1,92,1,92,1,93,1, + 93,1,93,1,93,1,94,1,94,1,94,1,94,1,95,1,95,1,95,1,95,1,95,1,95,5, + 95,1037,8,95,10,95,12,95,1040,9,95,3,95,1042,8,95,1,95,1,95,1,96, + 1,96,1,96,1,96,5,96,1050,8,96,10,96,12,96,1053,9,96,1,96,1,96,1, + 97,1,97,1,97,1,97,1,97,1,97,3,97,1063,8,97,1,98,1,98,1,99,1,99,1, + 100,1,100,1,101,1,101,3,101,1073,8,101,1,102,1,102,1,102,1,102,5, + 102,1079,8,102,10,102,12,102,1082,9,102,1,102,1,102,1,102,1,102, + 3,102,1088,8,102,1,103,1,103,1,103,1,103,1,104,1,104,1,104,1,104, + 5,104,1098,8,104,10,104,12,104,1101,9,104,1,104,1,104,1,104,1,104, + 3,104,1107,8,104,1,105,1,105,1,105,1,105,1,105,1,105,1,105,1,105, + 1,105,3,105,1118,8,105,1,106,1,106,1,106,3,106,1123,8,106,1,107, + 1,107,3,107,1127,8,107,1,108,1,108,3,108,1131,8,108,1,109,1,109, + 1,110,1,110,1,111,1,111,1,112,1,112,1,113,1,113,1,114,1,114,1,114, + 1,114,1,114,1,114,1,114,3,114,1150,8,114,1,115,1,115,1,115,0,0,116, + 0,2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44, + 46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88, + 90,92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124, + 126,128,130,132,134,136,138,140,142,144,146,148,150,152,154,156, + 158,160,162,164,166,168,170,172,174,176,178,180,182,184,186,188, + 190,192,194,196,198,200,202,204,206,208,210,212,214,216,218,220, + 222,224,226,228,230,0,10,1,0,132,133,1,0,7,8,1,0,16,23,1,0,81,82, + 1,0,160,161,1,0,128,129,3,0,30,37,39,48,50,70,3,0,29,29,38,38,49, + 49,1,0,137,152,5,0,10,28,71,117,119,119,121,131,134,136,1225,0,232, + 1,0,0,0,2,235,1,0,0,0,4,252,1,0,0,0,6,254,1,0,0,0,8,258,1,0,0,0, + 10,262,1,0,0,0,12,266,1,0,0,0,14,308,1,0,0,0,16,310,1,0,0,0,18,323, + 1,0,0,0,20,327,1,0,0,0,22,338,1,0,0,0,24,342,1,0,0,0,26,346,1,0, + 0,0,28,350,1,0,0,0,30,356,1,0,0,0,32,360,1,0,0,0,34,366,1,0,0,0, + 36,372,1,0,0,0,38,376,1,0,0,0,40,389,1,0,0,0,42,400,1,0,0,0,44,411, + 1,0,0,0,46,422,1,0,0,0,48,430,1,0,0,0,50,432,1,0,0,0,52,445,1,0, + 0,0,54,447,1,0,0,0,56,451,1,0,0,0,58,466,1,0,0,0,60,477,1,0,0,0, + 62,488,1,0,0,0,64,503,1,0,0,0,66,512,1,0,0,0,68,527,1,0,0,0,70,532, + 1,0,0,0,72,539,1,0,0,0,74,541,1,0,0,0,76,558,1,0,0,0,78,560,1,0, + 0,0,80,575,1,0,0,0,82,584,1,0,0,0,84,589,1,0,0,0,86,604,1,0,0,0, + 88,612,1,0,0,0,90,620,1,0,0,0,92,622,1,0,0,0,94,639,1,0,0,0,96,641, + 1,0,0,0,98,648,1,0,0,0,100,663,1,0,0,0,102,671,1,0,0,0,104,673,1, + 0,0,0,106,677,1,0,0,0,108,679,1,0,0,0,110,713,1,0,0,0,112,721,1, + 0,0,0,114,727,1,0,0,0,116,729,1,0,0,0,118,745,1,0,0,0,120,763,1, + 0,0,0,122,765,1,0,0,0,124,778,1,0,0,0,126,795,1,0,0,0,128,797,1, + 0,0,0,130,812,1,0,0,0,132,814,1,0,0,0,134,818,1,0,0,0,136,820,1, + 0,0,0,138,824,1,0,0,0,140,826,1,0,0,0,142,843,1,0,0,0,144,845,1, + 0,0,0,146,849,1,0,0,0,148,866,1,0,0,0,150,868,1,0,0,0,152,885,1, + 0,0,0,154,887,1,0,0,0,156,891,1,0,0,0,158,895,1,0,0,0,160,917,1, + 0,0,0,162,928,1,0,0,0,164,939,1,0,0,0,166,941,1,0,0,0,168,945,1, + 0,0,0,170,960,1,0,0,0,172,962,1,0,0,0,174,977,1,0,0,0,176,995,1, + 0,0,0,178,997,1,0,0,0,180,1010,1,0,0,0,182,1014,1,0,0,0,184,1018, + 1,0,0,0,186,1022,1,0,0,0,188,1026,1,0,0,0,190,1030,1,0,0,0,192,1045, + 1,0,0,0,194,1062,1,0,0,0,196,1064,1,0,0,0,198,1066,1,0,0,0,200,1068, + 1,0,0,0,202,1072,1,0,0,0,204,1087,1,0,0,0,206,1089,1,0,0,0,208,1106, + 1,0,0,0,210,1117,1,0,0,0,212,1122,1,0,0,0,214,1126,1,0,0,0,216,1130, + 1,0,0,0,218,1132,1,0,0,0,220,1134,1,0,0,0,222,1136,1,0,0,0,224,1138, + 1,0,0,0,226,1140,1,0,0,0,228,1149,1,0,0,0,230,1151,1,0,0,0,232,233, + 3,2,1,0,233,234,5,0,0,1,234,1,1,0,0,0,235,236,5,5,0,0,236,241,3, + 4,2,0,237,238,5,1,0,0,238,240,3,4,2,0,239,237,1,0,0,0,240,243,1, + 0,0,0,241,239,1,0,0,0,241,242,1,0,0,0,242,244,1,0,0,0,243,241,1, + 0,0,0,244,245,5,6,0,0,245,3,1,0,0,0,246,253,3,8,4,0,247,253,3,10, + 5,0,248,253,3,12,6,0,249,253,3,6,3,0,250,253,3,16,8,0,251,253,3, + 60,30,0,252,246,1,0,0,0,252,247,1,0,0,0,252,248,1,0,0,0,252,249, + 1,0,0,0,252,250,1,0,0,0,252,251,1,0,0,0,253,5,1,0,0,0,254,255,5, + 12,0,0,255,256,5,2,0,0,256,257,3,228,114,0,257,7,1,0,0,0,258,259, + 5,10,0,0,259,260,5,2,0,0,260,261,3,228,114,0,261,9,1,0,0,0,262,263, + 5,14,0,0,263,264,5,2,0,0,264,265,3,228,114,0,265,11,1,0,0,0,266, + 267,5,131,0,0,267,268,5,2,0,0,268,269,7,0,0,0,269,13,1,0,0,0,270, + 309,3,8,4,0,271,309,3,12,6,0,272,309,3,22,11,0,273,309,3,28,14,0, + 274,309,3,26,13,0,275,309,3,24,12,0,276,309,3,30,15,0,277,309,3, + 32,16,0,278,309,3,34,17,0,279,309,3,36,18,0,280,309,3,38,19,0,281, + 309,3,108,54,0,282,309,3,40,20,0,283,309,3,42,21,0,284,309,3,44, + 22,0,285,309,3,46,23,0,286,309,3,48,24,0,287,309,3,50,25,0,288,309, + 3,124,62,0,289,309,3,140,70,0,290,309,3,144,72,0,291,309,3,146,73, + 0,292,309,3,52,26,0,293,309,3,60,30,0,294,309,3,62,31,0,295,309, + 3,122,61,0,296,309,3,54,27,0,297,309,3,172,86,0,298,309,3,190,95, + 0,299,309,3,104,52,0,300,309,3,162,81,0,301,309,3,164,82,0,302,309, + 3,166,83,0,303,309,3,168,84,0,304,309,3,74,37,0,305,309,3,90,45, + 0,306,309,3,92,46,0,307,309,3,56,28,0,308,270,1,0,0,0,308,271,1, + 0,0,0,308,272,1,0,0,0,308,273,1,0,0,0,308,274,1,0,0,0,308,275,1, + 0,0,0,308,276,1,0,0,0,308,277,1,0,0,0,308,278,1,0,0,0,308,279,1, + 0,0,0,308,280,1,0,0,0,308,281,1,0,0,0,308,282,1,0,0,0,308,283,1, + 0,0,0,308,284,1,0,0,0,308,285,1,0,0,0,308,286,1,0,0,0,308,287,1, + 0,0,0,308,288,1,0,0,0,308,289,1,0,0,0,308,290,1,0,0,0,308,291,1, + 0,0,0,308,292,1,0,0,0,308,293,1,0,0,0,308,294,1,0,0,0,308,295,1, + 0,0,0,308,296,1,0,0,0,308,297,1,0,0,0,308,298,1,0,0,0,308,299,1, + 0,0,0,308,300,1,0,0,0,308,301,1,0,0,0,308,302,1,0,0,0,308,303,1, + 0,0,0,308,304,1,0,0,0,308,305,1,0,0,0,308,306,1,0,0,0,308,307,1, + 0,0,0,309,15,1,0,0,0,310,311,5,11,0,0,311,312,5,2,0,0,312,313,5, + 5,0,0,313,318,3,18,9,0,314,315,5,1,0,0,315,317,3,18,9,0,316,314, + 1,0,0,0,317,320,1,0,0,0,318,316,1,0,0,0,318,319,1,0,0,0,319,321, + 1,0,0,0,320,318,1,0,0,0,321,322,5,6,0,0,322,17,1,0,0,0,323,324,3, + 228,114,0,324,325,5,2,0,0,325,326,3,20,10,0,326,19,1,0,0,0,327,328, + 5,5,0,0,328,333,3,14,7,0,329,330,5,1,0,0,330,332,3,14,7,0,331,329, + 1,0,0,0,332,335,1,0,0,0,333,331,1,0,0,0,333,334,1,0,0,0,334,336, + 1,0,0,0,335,333,1,0,0,0,336,337,5,6,0,0,337,21,1,0,0,0,338,339,5, + 15,0,0,339,340,5,2,0,0,340,341,3,106,53,0,341,23,1,0,0,0,342,343, + 5,115,0,0,343,344,5,2,0,0,344,345,3,228,114,0,345,25,1,0,0,0,346, + 347,5,90,0,0,347,348,5,2,0,0,348,349,3,228,114,0,349,27,1,0,0,0, + 350,351,5,91,0,0,351,354,5,2,0,0,352,355,5,9,0,0,353,355,3,212,106, + 0,354,352,1,0,0,0,354,353,1,0,0,0,355,29,1,0,0,0,356,357,5,96,0, + 0,357,358,5,2,0,0,358,359,3,210,105,0,359,31,1,0,0,0,360,361,5,95, + 0,0,361,364,5,2,0,0,362,365,5,9,0,0,363,365,3,218,109,0,364,362, + 1,0,0,0,364,363,1,0,0,0,365,33,1,0,0,0,366,367,5,92,0,0,367,370, + 5,2,0,0,368,371,5,9,0,0,369,371,3,212,106,0,370,368,1,0,0,0,370, + 369,1,0,0,0,371,35,1,0,0,0,372,373,5,116,0,0,373,374,5,2,0,0,374, + 375,7,1,0,0,375,37,1,0,0,0,376,377,5,27,0,0,377,378,5,2,0,0,378, + 379,3,228,114,0,379,39,1,0,0,0,380,381,5,119,0,0,381,384,5,2,0,0, + 382,385,3,226,113,0,383,385,3,228,114,0,384,382,1,0,0,0,384,383, + 1,0,0,0,385,390,1,0,0,0,386,387,5,120,0,0,387,388,5,2,0,0,388,390, + 3,214,107,0,389,380,1,0,0,0,389,386,1,0,0,0,390,41,1,0,0,0,391,392, + 5,117,0,0,392,395,5,2,0,0,393,396,3,226,113,0,394,396,3,228,114, + 0,395,393,1,0,0,0,395,394,1,0,0,0,396,401,1,0,0,0,397,398,5,118, + 0,0,398,399,5,2,0,0,399,401,3,214,107,0,400,391,1,0,0,0,400,397, + 1,0,0,0,401,43,1,0,0,0,402,403,5,72,0,0,403,404,5,2,0,0,404,412, + 3,226,113,0,405,406,5,72,0,0,406,407,5,2,0,0,407,412,5,160,0,0,408, + 409,5,71,0,0,409,410,5,2,0,0,410,412,3,212,106,0,411,402,1,0,0,0, + 411,405,1,0,0,0,411,408,1,0,0,0,412,45,1,0,0,0,413,414,5,74,0,0, + 414,417,5,2,0,0,415,418,3,226,113,0,416,418,3,228,114,0,417,415, + 1,0,0,0,417,416,1,0,0,0,418,423,1,0,0,0,419,420,5,73,0,0,420,421, + 5,2,0,0,421,423,3,212,106,0,422,413,1,0,0,0,422,419,1,0,0,0,423, + 47,1,0,0,0,424,425,5,93,0,0,425,426,5,2,0,0,426,431,3,100,50,0,427, + 428,5,93,0,0,428,429,5,2,0,0,429,431,3,226,113,0,430,424,1,0,0,0, + 430,427,1,0,0,0,431,49,1,0,0,0,432,433,5,94,0,0,433,434,5,2,0,0, + 434,435,3,212,106,0,435,51,1,0,0,0,436,437,5,89,0,0,437,438,5,2, + 0,0,438,446,3,226,113,0,439,440,5,89,0,0,440,441,5,2,0,0,441,446, + 5,160,0,0,442,443,5,88,0,0,443,444,5,2,0,0,444,446,3,212,106,0,445, + 436,1,0,0,0,445,439,1,0,0,0,445,442,1,0,0,0,446,53,1,0,0,0,447,448, + 5,97,0,0,448,449,5,2,0,0,449,450,3,64,32,0,450,55,1,0,0,0,451,452, + 5,98,0,0,452,453,5,2,0,0,453,454,5,5,0,0,454,455,3,58,29,0,455,456, + 5,6,0,0,456,57,1,0,0,0,457,458,5,99,0,0,458,461,5,2,0,0,459,462, + 3,226,113,0,460,462,3,228,114,0,461,459,1,0,0,0,461,460,1,0,0,0, + 462,467,1,0,0,0,463,464,5,100,0,0,464,465,5,2,0,0,465,467,3,214, + 107,0,466,457,1,0,0,0,466,463,1,0,0,0,467,59,1,0,0,0,468,469,5,75, + 0,0,469,470,5,2,0,0,470,478,3,226,113,0,471,472,5,75,0,0,472,473, + 5,2,0,0,473,478,5,160,0,0,474,475,5,76,0,0,475,476,5,2,0,0,476,478, + 3,212,106,0,477,468,1,0,0,0,477,471,1,0,0,0,477,474,1,0,0,0,478, + 61,1,0,0,0,479,480,5,77,0,0,480,481,5,2,0,0,481,489,3,226,113,0, + 482,483,5,77,0,0,483,484,5,2,0,0,484,489,5,160,0,0,485,486,5,78, + 0,0,486,487,5,2,0,0,487,489,3,212,106,0,488,479,1,0,0,0,488,482, + 1,0,0,0,488,485,1,0,0,0,489,63,1,0,0,0,490,491,5,5,0,0,491,496,3, + 66,33,0,492,493,5,1,0,0,493,495,3,66,33,0,494,492,1,0,0,0,495,498, + 1,0,0,0,496,494,1,0,0,0,496,497,1,0,0,0,497,499,1,0,0,0,498,496, + 1,0,0,0,499,500,5,6,0,0,500,504,1,0,0,0,501,502,5,5,0,0,502,504, + 5,6,0,0,503,490,1,0,0,0,503,501,1,0,0,0,504,65,1,0,0,0,505,506,5, + 153,0,0,506,507,5,2,0,0,507,513,3,214,107,0,508,509,3,228,114,0, + 509,510,5,2,0,0,510,511,3,70,35,0,511,513,1,0,0,0,512,505,1,0,0, + 0,512,508,1,0,0,0,513,67,1,0,0,0,514,515,5,3,0,0,515,520,3,70,35, + 0,516,517,5,1,0,0,517,519,3,70,35,0,518,516,1,0,0,0,519,522,1,0, + 0,0,520,518,1,0,0,0,520,521,1,0,0,0,521,523,1,0,0,0,522,520,1,0, + 0,0,523,524,5,4,0,0,524,528,1,0,0,0,525,526,5,3,0,0,526,528,5,4, + 0,0,527,514,1,0,0,0,527,525,1,0,0,0,528,69,1,0,0,0,529,533,3,68, + 34,0,530,533,3,64,32,0,531,533,3,72,36,0,532,529,1,0,0,0,532,530, + 1,0,0,0,532,531,1,0,0,0,533,71,1,0,0,0,534,540,5,161,0,0,535,540, + 5,160,0,0,536,540,7,1,0,0,537,540,5,9,0,0,538,540,3,228,114,0,539, + 534,1,0,0,0,539,535,1,0,0,0,539,536,1,0,0,0,539,537,1,0,0,0,539, + 538,1,0,0,0,540,73,1,0,0,0,541,542,5,134,0,0,542,543,5,2,0,0,543, + 544,3,76,38,0,544,75,1,0,0,0,545,546,5,5,0,0,546,559,5,6,0,0,547, + 548,5,5,0,0,548,553,3,78,39,0,549,550,5,1,0,0,550,552,3,78,39,0, + 551,549,1,0,0,0,552,555,1,0,0,0,553,551,1,0,0,0,553,554,1,0,0,0, + 554,556,1,0,0,0,555,553,1,0,0,0,556,557,5,6,0,0,557,559,1,0,0,0, + 558,545,1,0,0,0,558,547,1,0,0,0,559,77,1,0,0,0,560,561,3,82,41,0, + 561,79,1,0,0,0,562,563,5,5,0,0,563,576,5,6,0,0,564,565,5,5,0,0,565, + 570,3,82,41,0,566,567,5,1,0,0,567,569,3,82,41,0,568,566,1,0,0,0, + 569,572,1,0,0,0,570,568,1,0,0,0,570,571,1,0,0,0,571,573,1,0,0,0, + 572,570,1,0,0,0,573,574,5,6,0,0,574,576,1,0,0,0,575,562,1,0,0,0, + 575,564,1,0,0,0,576,81,1,0,0,0,577,578,5,153,0,0,578,579,5,2,0,0, + 579,585,3,214,107,0,580,581,3,228,114,0,581,582,5,2,0,0,582,583, + 3,84,42,0,583,585,1,0,0,0,584,577,1,0,0,0,584,580,1,0,0,0,585,83, + 1,0,0,0,586,590,3,80,40,0,587,590,3,86,43,0,588,590,3,88,44,0,589, + 586,1,0,0,0,589,587,1,0,0,0,589,588,1,0,0,0,590,85,1,0,0,0,591,592, + 5,3,0,0,592,605,5,4,0,0,593,594,5,3,0,0,594,599,3,84,42,0,595,596, + 5,1,0,0,596,598,3,84,42,0,597,595,1,0,0,0,598,601,1,0,0,0,599,597, + 1,0,0,0,599,600,1,0,0,0,600,602,1,0,0,0,601,599,1,0,0,0,602,603, + 5,4,0,0,603,605,1,0,0,0,604,591,1,0,0,0,604,593,1,0,0,0,605,87,1, + 0,0,0,606,613,5,161,0,0,607,613,5,160,0,0,608,613,7,1,0,0,609,613, + 5,9,0,0,610,613,3,226,113,0,611,613,3,228,114,0,612,606,1,0,0,0, + 612,607,1,0,0,0,612,608,1,0,0,0,612,609,1,0,0,0,612,610,1,0,0,0, + 612,611,1,0,0,0,613,89,1,0,0,0,614,615,5,136,0,0,615,616,5,2,0,0, + 616,621,3,94,47,0,617,618,5,136,0,0,618,619,5,2,0,0,619,621,3,226, + 113,0,620,614,1,0,0,0,620,617,1,0,0,0,621,91,1,0,0,0,622,623,5,135, + 0,0,623,624,5,2,0,0,624,625,3,98,49,0,625,93,1,0,0,0,626,627,5,5, + 0,0,627,640,5,6,0,0,628,629,5,5,0,0,629,634,3,96,48,0,630,631,5, + 1,0,0,631,633,3,96,48,0,632,630,1,0,0,0,633,636,1,0,0,0,634,632, + 1,0,0,0,634,635,1,0,0,0,635,637,1,0,0,0,636,634,1,0,0,0,637,638, + 5,6,0,0,638,640,1,0,0,0,639,626,1,0,0,0,639,628,1,0,0,0,640,95,1, + 0,0,0,641,642,3,228,114,0,642,643,5,2,0,0,643,644,3,98,49,0,644, + 97,1,0,0,0,645,649,3,94,47,0,646,649,3,100,50,0,647,649,3,102,51, + 0,648,645,1,0,0,0,648,646,1,0,0,0,648,647,1,0,0,0,649,99,1,0,0,0, + 650,651,5,3,0,0,651,664,5,4,0,0,652,653,5,3,0,0,653,658,3,98,49, + 0,654,655,5,1,0,0,655,657,3,98,49,0,656,654,1,0,0,0,657,660,1,0, + 0,0,658,656,1,0,0,0,658,659,1,0,0,0,659,661,1,0,0,0,660,658,1,0, + 0,0,661,662,5,4,0,0,662,664,1,0,0,0,663,650,1,0,0,0,663,652,1,0, + 0,0,664,101,1,0,0,0,665,672,5,161,0,0,666,672,5,160,0,0,667,672, + 7,1,0,0,668,672,5,9,0,0,669,672,3,226,113,0,670,672,3,228,114,0, + 671,665,1,0,0,0,671,666,1,0,0,0,671,667,1,0,0,0,671,668,1,0,0,0, + 671,669,1,0,0,0,671,670,1,0,0,0,672,103,1,0,0,0,673,674,5,101,0, + 0,674,675,5,2,0,0,675,676,3,64,32,0,676,105,1,0,0,0,677,678,7,2, + 0,0,678,107,1,0,0,0,679,680,5,24,0,0,680,681,5,2,0,0,681,682,5,3, + 0,0,682,687,3,110,55,0,683,684,5,1,0,0,684,686,3,110,55,0,685,683, + 1,0,0,0,686,689,1,0,0,0,687,685,1,0,0,0,687,688,1,0,0,0,688,690, + 1,0,0,0,689,687,1,0,0,0,690,691,5,4,0,0,691,109,1,0,0,0,692,693, + 5,5,0,0,693,696,3,112,56,0,694,695,5,1,0,0,695,697,3,112,56,0,696, + 694,1,0,0,0,697,698,1,0,0,0,698,696,1,0,0,0,698,699,1,0,0,0,699, + 700,1,0,0,0,700,701,5,6,0,0,701,714,1,0,0,0,702,703,5,5,0,0,703, + 708,3,114,57,0,704,705,5,1,0,0,705,707,3,114,57,0,706,704,1,0,0, + 0,707,710,1,0,0,0,708,706,1,0,0,0,708,709,1,0,0,0,709,711,1,0,0, + 0,710,708,1,0,0,0,711,712,5,6,0,0,712,714,1,0,0,0,713,692,1,0,0, + 0,713,702,1,0,0,0,714,111,1,0,0,0,715,722,3,118,59,0,716,722,3,120, + 60,0,717,722,3,24,12,0,718,722,3,74,37,0,719,722,3,92,46,0,720,722, + 3,8,4,0,721,715,1,0,0,0,721,716,1,0,0,0,721,717,1,0,0,0,721,718, + 1,0,0,0,721,719,1,0,0,0,721,720,1,0,0,0,722,113,1,0,0,0,723,728, + 3,116,58,0,724,728,3,24,12,0,725,728,3,74,37,0,726,728,3,8,4,0,727, + 723,1,0,0,0,727,724,1,0,0,0,727,725,1,0,0,0,727,726,1,0,0,0,728, + 115,1,0,0,0,729,730,3,198,99,0,730,743,5,2,0,0,731,744,3,110,55, + 0,732,733,5,3,0,0,733,738,3,110,55,0,734,735,5,1,0,0,735,737,3,110, + 55,0,736,734,1,0,0,0,737,740,1,0,0,0,738,736,1,0,0,0,738,739,1,0, + 0,0,739,741,1,0,0,0,740,738,1,0,0,0,741,742,5,4,0,0,742,744,1,0, + 0,0,743,731,1,0,0,0,743,732,1,0,0,0,744,117,1,0,0,0,745,746,5,26, + 0,0,746,747,5,2,0,0,747,748,3,212,106,0,748,119,1,0,0,0,749,750, + 5,25,0,0,750,751,5,2,0,0,751,764,7,1,0,0,752,753,5,25,0,0,753,754, + 5,2,0,0,754,764,3,226,113,0,755,756,3,196,98,0,756,757,5,2,0,0,757, + 758,3,222,111,0,758,764,1,0,0,0,759,760,3,196,98,0,760,761,5,2,0, + 0,761,762,3,210,105,0,762,764,1,0,0,0,763,749,1,0,0,0,763,752,1, + 0,0,0,763,755,1,0,0,0,763,759,1,0,0,0,764,121,1,0,0,0,765,766,5, + 28,0,0,766,767,5,2,0,0,767,768,5,3,0,0,768,773,3,2,1,0,769,770,5, + 1,0,0,770,772,3,2,1,0,771,769,1,0,0,0,772,775,1,0,0,0,773,771,1, + 0,0,0,773,774,1,0,0,0,774,776,1,0,0,0,775,773,1,0,0,0,776,777,5, + 4,0,0,777,123,1,0,0,0,778,779,5,85,0,0,779,780,5,2,0,0,780,781,5, + 5,0,0,781,786,3,126,63,0,782,783,5,1,0,0,783,785,3,126,63,0,784, + 782,1,0,0,0,785,788,1,0,0,0,786,784,1,0,0,0,786,787,1,0,0,0,787, + 789,1,0,0,0,788,786,1,0,0,0,789,790,5,6,0,0,790,125,1,0,0,0,791, + 796,3,128,64,0,792,796,3,6,3,0,793,796,3,16,8,0,794,796,3,8,4,0, + 795,791,1,0,0,0,795,792,1,0,0,0,795,793,1,0,0,0,795,794,1,0,0,0, + 796,127,1,0,0,0,797,798,5,79,0,0,798,799,5,2,0,0,799,800,5,5,0,0, + 800,805,3,130,65,0,801,802,5,1,0,0,802,804,3,130,65,0,803,801,1, + 0,0,0,804,807,1,0,0,0,805,803,1,0,0,0,805,806,1,0,0,0,806,808,1, + 0,0,0,807,805,1,0,0,0,808,809,5,6,0,0,809,129,1,0,0,0,810,813,3, + 132,66,0,811,813,3,136,68,0,812,810,1,0,0,0,812,811,1,0,0,0,813, + 131,1,0,0,0,814,815,5,80,0,0,815,816,5,2,0,0,816,817,3,134,67,0, + 817,133,1,0,0,0,818,819,7,3,0,0,819,135,1,0,0,0,820,821,5,83,0,0, + 821,822,5,2,0,0,822,823,3,138,69,0,823,137,1,0,0,0,824,825,5,84, + 0,0,825,139,1,0,0,0,826,827,5,86,0,0,827,828,5,2,0,0,828,829,5,5, + 0,0,829,834,3,142,71,0,830,831,5,1,0,0,831,833,3,142,71,0,832,830, + 1,0,0,0,833,836,1,0,0,0,834,832,1,0,0,0,834,835,1,0,0,0,835,837, + 1,0,0,0,836,834,1,0,0,0,837,838,5,6,0,0,838,141,1,0,0,0,839,844, + 3,6,3,0,840,844,3,16,8,0,841,844,3,8,4,0,842,844,3,128,64,0,843, + 839,1,0,0,0,843,840,1,0,0,0,843,841,1,0,0,0,843,842,1,0,0,0,844, + 143,1,0,0,0,845,846,5,87,0,0,846,847,5,2,0,0,847,848,3,80,40,0,848, + 145,1,0,0,0,849,850,5,102,0,0,850,851,5,2,0,0,851,852,5,5,0,0,852, + 857,3,148,74,0,853,854,5,1,0,0,854,856,3,148,74,0,855,853,1,0,0, + 0,856,859,1,0,0,0,857,855,1,0,0,0,857,858,1,0,0,0,858,860,1,0,0, + 0,859,857,1,0,0,0,860,861,5,6,0,0,861,147,1,0,0,0,862,867,3,26,13, + 0,863,867,3,150,75,0,864,867,3,54,27,0,865,867,3,90,45,0,866,862, + 1,0,0,0,866,863,1,0,0,0,866,864,1,0,0,0,866,865,1,0,0,0,867,149, + 1,0,0,0,868,869,5,103,0,0,869,870,5,2,0,0,870,871,5,5,0,0,871,876, + 3,152,76,0,872,873,5,1,0,0,873,875,3,152,76,0,874,872,1,0,0,0,875, + 878,1,0,0,0,876,874,1,0,0,0,876,877,1,0,0,0,877,879,1,0,0,0,878, + 876,1,0,0,0,879,880,5,6,0,0,880,151,1,0,0,0,881,886,3,154,77,0,882, + 886,3,156,78,0,883,886,3,158,79,0,884,886,3,160,80,0,885,881,1,0, + 0,0,885,882,1,0,0,0,885,883,1,0,0,0,885,884,1,0,0,0,886,153,1,0, + 0,0,887,888,5,104,0,0,888,889,5,2,0,0,889,890,3,228,114,0,890,155, + 1,0,0,0,891,892,5,105,0,0,892,893,5,2,0,0,893,894,3,228,114,0,894, + 157,1,0,0,0,895,896,5,106,0,0,896,897,5,2,0,0,897,898,5,3,0,0,898, + 903,3,228,114,0,899,900,5,1,0,0,900,902,3,228,114,0,901,899,1,0, + 0,0,902,905,1,0,0,0,903,901,1,0,0,0,903,904,1,0,0,0,904,906,1,0, + 0,0,905,903,1,0,0,0,906,907,5,4,0,0,907,159,1,0,0,0,908,909,5,107, + 0,0,909,910,5,2,0,0,910,918,3,226,113,0,911,912,5,107,0,0,912,913, + 5,2,0,0,913,918,5,160,0,0,914,915,5,108,0,0,915,916,5,2,0,0,916, + 918,3,212,106,0,917,908,1,0,0,0,917,911,1,0,0,0,917,914,1,0,0,0, + 918,161,1,0,0,0,919,920,5,109,0,0,920,921,5,2,0,0,921,929,3,226, + 113,0,922,923,5,109,0,0,923,924,5,2,0,0,924,929,5,160,0,0,925,926, + 5,110,0,0,926,927,5,2,0,0,927,929,3,212,106,0,928,919,1,0,0,0,928, + 922,1,0,0,0,928,925,1,0,0,0,929,163,1,0,0,0,930,931,5,111,0,0,931, + 932,5,2,0,0,932,940,3,226,113,0,933,934,5,111,0,0,934,935,5,2,0, + 0,935,940,5,161,0,0,936,937,5,112,0,0,937,938,5,2,0,0,938,940,3, + 212,106,0,939,930,1,0,0,0,939,933,1,0,0,0,939,936,1,0,0,0,940,165, + 1,0,0,0,941,942,5,113,0,0,942,943,5,2,0,0,943,944,3,228,114,0,944, + 167,1,0,0,0,945,946,5,114,0,0,946,947,5,2,0,0,947,948,5,5,0,0,948, + 953,3,170,85,0,949,950,5,1,0,0,950,952,3,170,85,0,951,949,1,0,0, + 0,952,955,1,0,0,0,953,951,1,0,0,0,953,954,1,0,0,0,954,956,1,0,0, + 0,955,953,1,0,0,0,956,957,5,6,0,0,957,169,1,0,0,0,958,961,3,26,13, + 0,959,961,3,54,27,0,960,958,1,0,0,0,960,959,1,0,0,0,961,171,1,0, + 0,0,962,963,5,121,0,0,963,964,5,2,0,0,964,973,5,3,0,0,965,970,3, + 174,87,0,966,967,5,1,0,0,967,969,3,174,87,0,968,966,1,0,0,0,969, + 972,1,0,0,0,970,968,1,0,0,0,970,971,1,0,0,0,971,974,1,0,0,0,972, + 970,1,0,0,0,973,965,1,0,0,0,973,974,1,0,0,0,974,975,1,0,0,0,975, + 976,5,4,0,0,976,173,1,0,0,0,977,978,5,5,0,0,978,983,3,176,88,0,979, + 980,5,1,0,0,980,982,3,176,88,0,981,979,1,0,0,0,982,985,1,0,0,0,983, + 981,1,0,0,0,983,984,1,0,0,0,984,986,1,0,0,0,985,983,1,0,0,0,986, + 987,5,6,0,0,987,175,1,0,0,0,988,996,3,178,89,0,989,996,3,180,90, + 0,990,996,3,182,91,0,991,996,3,184,92,0,992,996,3,186,93,0,993,996, + 3,188,94,0,994,996,3,8,4,0,995,988,1,0,0,0,995,989,1,0,0,0,995,990, + 1,0,0,0,995,991,1,0,0,0,995,992,1,0,0,0,995,993,1,0,0,0,995,994, + 1,0,0,0,996,177,1,0,0,0,997,998,5,122,0,0,998,999,5,2,0,0,999,1000, + 5,3,0,0,1000,1005,3,202,101,0,1001,1002,5,1,0,0,1002,1004,3,202, + 101,0,1003,1001,1,0,0,0,1004,1007,1,0,0,0,1005,1003,1,0,0,0,1005, + 1006,1,0,0,0,1006,1008,1,0,0,0,1007,1005,1,0,0,0,1008,1009,5,4,0, + 0,1009,179,1,0,0,0,1010,1011,5,123,0,0,1011,1012,5,2,0,0,1012,1013, + 5,160,0,0,1013,181,1,0,0,0,1014,1015,5,124,0,0,1015,1016,5,2,0,0, + 1016,1017,5,160,0,0,1017,183,1,0,0,0,1018,1019,5,125,0,0,1019,1020, + 5,2,0,0,1020,1021,7,4,0,0,1021,185,1,0,0,0,1022,1023,5,126,0,0,1023, + 1024,5,2,0,0,1024,1025,5,160,0,0,1025,187,1,0,0,0,1026,1027,5,127, + 0,0,1027,1028,5,2,0,0,1028,1029,7,5,0,0,1029,189,1,0,0,0,1030,1031, + 5,130,0,0,1031,1032,5,2,0,0,1032,1041,5,3,0,0,1033,1038,3,192,96, + 0,1034,1035,5,1,0,0,1035,1037,3,192,96,0,1036,1034,1,0,0,0,1037, + 1040,1,0,0,0,1038,1036,1,0,0,0,1038,1039,1,0,0,0,1039,1042,1,0,0, + 0,1040,1038,1,0,0,0,1041,1033,1,0,0,0,1041,1042,1,0,0,0,1042,1043, + 1,0,0,0,1043,1044,5,4,0,0,1044,191,1,0,0,0,1045,1046,5,5,0,0,1046, + 1051,3,194,97,0,1047,1048,5,1,0,0,1048,1050,3,194,97,0,1049,1047, + 1,0,0,0,1050,1053,1,0,0,0,1051,1049,1,0,0,0,1051,1052,1,0,0,0,1052, + 1054,1,0,0,0,1053,1051,1,0,0,0,1054,1055,5,6,0,0,1055,193,1,0,0, + 0,1056,1063,3,178,89,0,1057,1063,3,32,16,0,1058,1063,3,24,12,0,1059, + 1063,3,74,37,0,1060,1063,3,92,46,0,1061,1063,3,8,4,0,1062,1056,1, + 0,0,0,1062,1057,1,0,0,0,1062,1058,1,0,0,0,1062,1059,1,0,0,0,1062, + 1060,1,0,0,0,1062,1061,1,0,0,0,1063,195,1,0,0,0,1064,1065,7,6,0, + 0,1065,197,1,0,0,0,1066,1067,7,7,0,0,1067,199,1,0,0,0,1068,1069, + 7,8,0,0,1069,201,1,0,0,0,1070,1073,3,200,100,0,1071,1073,3,228,114, + 0,1072,1070,1,0,0,0,1072,1071,1,0,0,0,1073,203,1,0,0,0,1074,1075, + 5,5,0,0,1075,1080,3,206,103,0,1076,1077,5,1,0,0,1077,1079,3,206, + 103,0,1078,1076,1,0,0,0,1079,1082,1,0,0,0,1080,1078,1,0,0,0,1080, + 1081,1,0,0,0,1081,1083,1,0,0,0,1082,1080,1,0,0,0,1083,1084,5,6,0, + 0,1084,1088,1,0,0,0,1085,1086,5,5,0,0,1086,1088,5,6,0,0,1087,1074, + 1,0,0,0,1087,1085,1,0,0,0,1088,205,1,0,0,0,1089,1090,3,228,114,0, + 1090,1091,5,2,0,0,1091,1092,3,210,105,0,1092,207,1,0,0,0,1093,1094, + 5,3,0,0,1094,1099,3,210,105,0,1095,1096,5,1,0,0,1096,1098,3,210, + 105,0,1097,1095,1,0,0,0,1098,1101,1,0,0,0,1099,1097,1,0,0,0,1099, + 1100,1,0,0,0,1100,1102,1,0,0,0,1101,1099,1,0,0,0,1102,1103,5,4,0, + 0,1103,1107,1,0,0,0,1104,1105,5,3,0,0,1105,1107,5,4,0,0,1106,1093, + 1,0,0,0,1106,1104,1,0,0,0,1107,209,1,0,0,0,1108,1118,5,161,0,0,1109, + 1118,5,160,0,0,1110,1118,5,7,0,0,1111,1118,5,8,0,0,1112,1118,5,9, + 0,0,1113,1118,3,206,103,0,1114,1118,3,208,104,0,1115,1118,3,204, + 102,0,1116,1118,3,228,114,0,1117,1108,1,0,0,0,1117,1109,1,0,0,0, + 1117,1110,1,0,0,0,1117,1111,1,0,0,0,1117,1112,1,0,0,0,1117,1113, + 1,0,0,0,1117,1114,1,0,0,0,1117,1115,1,0,0,0,1117,1116,1,0,0,0,1118, + 211,1,0,0,0,1119,1123,3,218,109,0,1120,1123,3,220,110,0,1121,1123, + 3,222,111,0,1122,1119,1,0,0,0,1122,1120,1,0,0,0,1122,1121,1,0,0, + 0,1123,213,1,0,0,0,1124,1127,3,212,106,0,1125,1127,3,224,112,0,1126, + 1124,1,0,0,0,1126,1125,1,0,0,0,1127,215,1,0,0,0,1128,1131,3,214, + 107,0,1129,1131,3,226,113,0,1130,1128,1,0,0,0,1130,1129,1,0,0,0, + 1131,217,1,0,0,0,1132,1133,5,155,0,0,1133,219,1,0,0,0,1134,1135, + 5,154,0,0,1135,221,1,0,0,0,1136,1137,5,156,0,0,1137,223,1,0,0,0, + 1138,1139,5,157,0,0,1139,225,1,0,0,0,1140,1141,5,158,0,0,1141,227, + 1,0,0,0,1142,1150,5,159,0,0,1143,1150,5,153,0,0,1144,1150,3,230, + 115,0,1145,1150,3,196,98,0,1146,1150,3,198,99,0,1147,1150,3,200, + 100,0,1148,1150,3,216,108,0,1149,1142,1,0,0,0,1149,1143,1,0,0,0, + 1149,1144,1,0,0,0,1149,1145,1,0,0,0,1149,1146,1,0,0,0,1149,1147, + 1,0,0,0,1149,1148,1,0,0,0,1150,229,1,0,0,0,1151,1152,7,9,0,0,1152, + 231,1,0,0,0,89,241,252,308,318,333,354,364,370,384,389,395,400,411, + 417,422,430,445,461,466,477,488,496,503,512,520,527,532,539,553, + 558,570,575,584,589,599,604,612,620,634,639,648,658,663,671,687, + 698,708,713,721,727,738,743,763,773,786,795,805,812,834,843,857, + 866,876,885,903,917,928,939,953,960,970,973,983,995,1005,1038,1041, + 1051,1062,1072,1080,1087,1099,1106,1117,1122,1126,1130,1149 + ] + +class ASLParser ( Parser ): + + grammarFileName = "ASLParser.g4" + + atn = ATNDeserializer().deserialize(serializedATN()) + + decisionsToDFA = [ DFA(ds, i) for i, ds in enumerate(atn.decisionToState) ] + + sharedContextCache = PredictionContextCache() + + literalNames = [ "", "','", "':'", "'['", "']'", "'{'", "'}'", + "'true'", "'false'", "'null'", "'\"Comment\"'", "'\"States\"'", + "'\"StartAt\"'", "'\"NextState\"'", "'\"Version\"'", + "'\"Type\"'", "'\"Task\"'", "'\"Choice\"'", "'\"Fail\"'", + "'\"Succeed\"'", "'\"Pass\"'", "'\"Wait\"'", "'\"Parallel\"'", + "'\"Map\"'", "'\"Choices\"'", "'\"Condition\"'", "'\"Variable\"'", + "'\"Default\"'", "'\"Branches\"'", "'\"And\"'", "'\"BooleanEquals\"'", + "'\"BooleanEqualsPath\"'", "'\"IsBoolean\"'", "'\"IsNull\"'", + "'\"IsNumeric\"'", "'\"IsPresent\"'", "'\"IsString\"'", + "'\"IsTimestamp\"'", "'\"Not\"'", "'\"NumericEquals\"'", + "'\"NumericEqualsPath\"'", "'\"NumericGreaterThan\"'", + "'\"NumericGreaterThanPath\"'", "'\"NumericGreaterThanEquals\"'", + "'\"NumericGreaterThanEqualsPath\"'", "'\"NumericLessThan\"'", + "'\"NumericLessThanPath\"'", "'\"NumericLessThanEquals\"'", + "'\"NumericLessThanEqualsPath\"'", "'\"Or\"'", "'\"StringEquals\"'", + "'\"StringEqualsPath\"'", "'\"StringGreaterThan\"'", + "'\"StringGreaterThanPath\"'", "'\"StringGreaterThanEquals\"'", + "'\"StringGreaterThanEqualsPath\"'", "'\"StringLessThan\"'", + "'\"StringLessThanPath\"'", "'\"StringLessThanEquals\"'", + "'\"StringLessThanEqualsPath\"'", "'\"StringMatches\"'", + "'\"TimestampEquals\"'", "'\"TimestampEqualsPath\"'", + "'\"TimestampGreaterThan\"'", "'\"TimestampGreaterThanPath\"'", + "'\"TimestampGreaterThanEquals\"'", "'\"TimestampGreaterThanEqualsPath\"'", + "'\"TimestampLessThan\"'", "'\"TimestampLessThanPath\"'", + "'\"TimestampLessThanEquals\"'", "'\"TimestampLessThanEqualsPath\"'", + "'\"SecondsPath\"'", "'\"Seconds\"'", "'\"TimestampPath\"'", + "'\"Timestamp\"'", "'\"TimeoutSeconds\"'", "'\"TimeoutSecondsPath\"'", + "'\"HeartbeatSeconds\"'", "'\"HeartbeatSecondsPath\"'", + "'\"ProcessorConfig\"'", "'\"Mode\"'", "'\"INLINE\"'", + "'\"DISTRIBUTED\"'", "'\"ExecutionType\"'", "'\"STANDARD\"'", + "'\"ItemProcessor\"'", "'\"Iterator\"'", "'\"ItemSelector\"'", + "'\"MaxConcurrencyPath\"'", "'\"MaxConcurrency\"'", + "'\"Resource\"'", "'\"InputPath\"'", "'\"OutputPath\"'", + "'\"Items\"'", "'\"ItemsPath\"'", "'\"ResultPath\"'", + "'\"Result\"'", "'\"Parameters\"'", "'\"Credentials\"'", + "'\"RoleArn\"'", "'\"RoleArn.$\"'", "'\"ResultSelector\"'", + "'\"ItemReader\"'", "'\"ReaderConfig\"'", "'\"InputType\"'", + "'\"CSVHeaderLocation\"'", "'\"CSVHeaders\"'", "'\"MaxItems\"'", + "'\"MaxItemsPath\"'", "'\"ToleratedFailureCount\"'", + "'\"ToleratedFailureCountPath\"'", "'\"ToleratedFailurePercentage\"'", + "'\"ToleratedFailurePercentagePath\"'", "'\"Label\"'", + "'\"ResultWriter\"'", "'\"Next\"'", "'\"End\"'", "'\"Cause\"'", + "'\"CausePath\"'", "'\"Error\"'", "'\"ErrorPath\"'", + "'\"Retry\"'", "'\"ErrorEquals\"'", "'\"IntervalSeconds\"'", + "'\"MaxAttempts\"'", "'\"BackoffRate\"'", "'\"MaxDelaySeconds\"'", + "'\"JitterStrategy\"'", "'\"FULL\"'", "'\"NONE\"'", + "'\"Catch\"'", "'\"QueryLanguage\"'", "'\"JSONPath\"'", + "'\"JSONata\"'", "'\"Assign\"'", "'\"Output\"'", "'\"Arguments\"'", + "'\"States.ALL\"'", "'\"States.DataLimitExceeded\"'", + "'\"States.HeartbeatTimeout\"'", "'\"States.Timeout\"'", + "'\"States.TaskFailed\"'", "'\"States.Permissions\"'", + "'\"States.ResultPathMatchFailure\"'", "'\"States.ParameterPathFailure\"'", + "'\"States.BranchFailed\"'", "'\"States.NoChoiceMatched\"'", + "'\"States.IntrinsicFailure\"'", "'\"States.ExceedToleratedFailureThreshold\"'", + "'\"States.ItemReaderFailed\"'", "'\"States.ResultWriterFailed\"'", + "'\"States.QueryEvaluationError\"'", "'\"States.Runtime\"'" ] + + symbolicNames = [ "", "COMMA", "COLON", "LBRACK", "RBRACK", + "LBRACE", "RBRACE", "TRUE", "FALSE", "NULL", "COMMENT", + "STATES", "STARTAT", "NEXTSTATE", "VERSION", "TYPE", + "TASK", "CHOICE", "FAIL", "SUCCEED", "PASS", "WAIT", + "PARALLEL", "MAP", "CHOICES", "CONDITION", "VARIABLE", + "DEFAULT", "BRANCHES", "AND", "BOOLEANEQUALS", "BOOLEANQUALSPATH", + "ISBOOLEAN", "ISNULL", "ISNUMERIC", "ISPRESENT", "ISSTRING", + "ISTIMESTAMP", "NOT", "NUMERICEQUALS", "NUMERICEQUALSPATH", + "NUMERICGREATERTHAN", "NUMERICGREATERTHANPATH", "NUMERICGREATERTHANEQUALS", + "NUMERICGREATERTHANEQUALSPATH", "NUMERICLESSTHAN", + "NUMERICLESSTHANPATH", "NUMERICLESSTHANEQUALS", "NUMERICLESSTHANEQUALSPATH", + "OR", "STRINGEQUALS", "STRINGEQUALSPATH", "STRINGGREATERTHAN", + "STRINGGREATERTHANPATH", "STRINGGREATERTHANEQUALS", + "STRINGGREATERTHANEQUALSPATH", "STRINGLESSTHAN", "STRINGLESSTHANPATH", + "STRINGLESSTHANEQUALS", "STRINGLESSTHANEQUALSPATH", + "STRINGMATCHES", "TIMESTAMPEQUALS", "TIMESTAMPEQUALSPATH", + "TIMESTAMPGREATERTHAN", "TIMESTAMPGREATERTHANPATH", + "TIMESTAMPGREATERTHANEQUALS", "TIMESTAMPGREATERTHANEQUALSPATH", + "TIMESTAMPLESSTHAN", "TIMESTAMPLESSTHANPATH", "TIMESTAMPLESSTHANEQUALS", + "TIMESTAMPLESSTHANEQUALSPATH", "SECONDSPATH", "SECONDS", + "TIMESTAMPPATH", "TIMESTAMP", "TIMEOUTSECONDS", "TIMEOUTSECONDSPATH", + "HEARTBEATSECONDS", "HEARTBEATSECONDSPATH", "PROCESSORCONFIG", + "MODE", "INLINE", "DISTRIBUTED", "EXECUTIONTYPE", + "STANDARD", "ITEMPROCESSOR", "ITERATOR", "ITEMSELECTOR", + "MAXCONCURRENCYPATH", "MAXCONCURRENCY", "RESOURCE", + "INPUTPATH", "OUTPUTPATH", "ITEMS", "ITEMSPATH", "RESULTPATH", + "RESULT", "PARAMETERS", "CREDENTIALS", "ROLEARN", + "ROLEARNPATH", "RESULTSELECTOR", "ITEMREADER", "READERCONFIG", + "INPUTTYPE", "CSVHEADERLOCATION", "CSVHEADERS", "MAXITEMS", + "MAXITEMSPATH", "TOLERATEDFAILURECOUNT", "TOLERATEDFAILURECOUNTPATH", + "TOLERATEDFAILUREPERCENTAGE", "TOLERATEDFAILUREPERCENTAGEPATH", + "LABEL", "RESULTWRITER", "NEXT", "END", "CAUSE", "CAUSEPATH", + "ERROR", "ERRORPATH", "RETRY", "ERROREQUALS", "INTERVALSECONDS", + "MAXATTEMPTS", "BACKOFFRATE", "MAXDELAYSECONDS", "JITTERSTRATEGY", + "FULL", "NONE", "CATCH", "QUERYLANGUAGE", "JSONPATH", + "JSONATA", "ASSIGN", "OUTPUT", "ARGUMENTS", "ERRORNAMEStatesALL", + "ERRORNAMEStatesDataLimitExceeded", "ERRORNAMEStatesHeartbeatTimeout", + "ERRORNAMEStatesTimeout", "ERRORNAMEStatesTaskFailed", + "ERRORNAMEStatesPermissions", "ERRORNAMEStatesResultPathMatchFailure", + "ERRORNAMEStatesParameterPathFailure", "ERRORNAMEStatesBranchFailed", + "ERRORNAMEStatesNoChoiceMatched", "ERRORNAMEStatesIntrinsicFailure", + "ERRORNAMEStatesExceedToleratedFailureThreshold", + "ERRORNAMEStatesItemReaderFailed", "ERRORNAMEStatesResultWriterFailed", + "ERRORNAMEStatesQueryEvaluationError", "ERRORNAMEStatesRuntime", + "STRINGDOLLAR", "STRINGPATHCONTEXTOBJ", "STRINGPATH", + "STRINGVAR", "STRINGINTRINSICFUNC", "STRINGJSONATA", + "STRING", "INT", "NUMBER", "WS" ] + + RULE_state_machine = 0 + RULE_program_decl = 1 + RULE_top_layer_stmt = 2 + RULE_startat_decl = 3 + RULE_comment_decl = 4 + RULE_version_decl = 5 + RULE_query_language_decl = 6 + RULE_state_stmt = 7 + RULE_states_decl = 8 + RULE_state_decl = 9 + RULE_state_decl_body = 10 + RULE_type_decl = 11 + RULE_next_decl = 12 + RULE_resource_decl = 13 + RULE_input_path_decl = 14 + RULE_result_decl = 15 + RULE_result_path_decl = 16 + RULE_output_path_decl = 17 + RULE_end_decl = 18 + RULE_default_decl = 19 + RULE_error_decl = 20 + RULE_cause_decl = 21 + RULE_seconds_decl = 22 + RULE_timestamp_decl = 23 + RULE_items_decl = 24 + RULE_items_path_decl = 25 + RULE_max_concurrency_decl = 26 + RULE_parameters_decl = 27 + RULE_credentials_decl = 28 + RULE_role_arn_decl = 29 + RULE_timeout_seconds_decl = 30 + RULE_heartbeat_seconds_decl = 31 + RULE_payload_tmpl_decl = 32 + RULE_payload_binding = 33 + RULE_payload_arr_decl = 34 + RULE_payload_value_decl = 35 + RULE_payload_value_lit = 36 + RULE_assign_decl = 37 + RULE_assign_decl_body = 38 + RULE_assign_decl_binding = 39 + RULE_assign_template_value_object = 40 + RULE_assign_template_binding = 41 + RULE_assign_template_value = 42 + RULE_assign_template_value_array = 43 + RULE_assign_template_value_terminal = 44 + RULE_arguments_decl = 45 + RULE_output_decl = 46 + RULE_jsonata_template_value_object = 47 + RULE_jsonata_template_binding = 48 + RULE_jsonata_template_value = 49 + RULE_jsonata_template_value_array = 50 + RULE_jsonata_template_value_terminal = 51 + RULE_result_selector_decl = 52 + RULE_state_type = 53 + RULE_choices_decl = 54 + RULE_choice_rule = 55 + RULE_comparison_variable_stmt = 56 + RULE_comparison_composite_stmt = 57 + RULE_comparison_composite = 58 + RULE_variable_decl = 59 + RULE_comparison_func = 60 + RULE_branches_decl = 61 + RULE_item_processor_decl = 62 + RULE_item_processor_item = 63 + RULE_processor_config_decl = 64 + RULE_processor_config_field = 65 + RULE_mode_decl = 66 + RULE_mode_type = 67 + RULE_execution_decl = 68 + RULE_execution_type = 69 + RULE_iterator_decl = 70 + RULE_iterator_decl_item = 71 + RULE_item_selector_decl = 72 + RULE_item_reader_decl = 73 + RULE_items_reader_field = 74 + RULE_reader_config_decl = 75 + RULE_reader_config_field = 76 + RULE_input_type_decl = 77 + RULE_csv_header_location_decl = 78 + RULE_csv_headers_decl = 79 + RULE_max_items_decl = 80 + RULE_tolerated_failure_count_decl = 81 + RULE_tolerated_failure_percentage_decl = 82 + RULE_label_decl = 83 + RULE_result_writer_decl = 84 + RULE_result_writer_field = 85 + RULE_retry_decl = 86 + RULE_retrier_decl = 87 + RULE_retrier_stmt = 88 + RULE_error_equals_decl = 89 + RULE_interval_seconds_decl = 90 + RULE_max_attempts_decl = 91 + RULE_backoff_rate_decl = 92 + RULE_max_delay_seconds_decl = 93 + RULE_jitter_strategy_decl = 94 + RULE_catch_decl = 95 + RULE_catcher_decl = 96 + RULE_catcher_stmt = 97 + RULE_comparison_op = 98 + RULE_choice_operator = 99 + RULE_states_error_name = 100 + RULE_error_name = 101 + RULE_json_obj_decl = 102 + RULE_json_binding = 103 + RULE_json_arr_decl = 104 + RULE_json_value_decl = 105 + RULE_string_sampler = 106 + RULE_string_expression_simple = 107 + RULE_string_expression = 108 + RULE_string_jsonpath = 109 + RULE_string_context_path = 110 + RULE_string_variable_sample = 111 + RULE_string_intrinsic_function = 112 + RULE_string_jsonata = 113 + RULE_string_literal = 114 + RULE_soft_string_keyword = 115 + + ruleNames = [ "state_machine", "program_decl", "top_layer_stmt", "startat_decl", + "comment_decl", "version_decl", "query_language_decl", + "state_stmt", "states_decl", "state_decl", "state_decl_body", + "type_decl", "next_decl", "resource_decl", "input_path_decl", + "result_decl", "result_path_decl", "output_path_decl", + "end_decl", "default_decl", "error_decl", "cause_decl", + "seconds_decl", "timestamp_decl", "items_decl", "items_path_decl", + "max_concurrency_decl", "parameters_decl", "credentials_decl", + "role_arn_decl", "timeout_seconds_decl", "heartbeat_seconds_decl", + "payload_tmpl_decl", "payload_binding", "payload_arr_decl", + "payload_value_decl", "payload_value_lit", "assign_decl", + "assign_decl_body", "assign_decl_binding", "assign_template_value_object", + "assign_template_binding", "assign_template_value", "assign_template_value_array", + "assign_template_value_terminal", "arguments_decl", "output_decl", + "jsonata_template_value_object", "jsonata_template_binding", + "jsonata_template_value", "jsonata_template_value_array", + "jsonata_template_value_terminal", "result_selector_decl", + "state_type", "choices_decl", "choice_rule", "comparison_variable_stmt", + "comparison_composite_stmt", "comparison_composite", + "variable_decl", "comparison_func", "branches_decl", + "item_processor_decl", "item_processor_item", "processor_config_decl", + "processor_config_field", "mode_decl", "mode_type", "execution_decl", + "execution_type", "iterator_decl", "iterator_decl_item", + "item_selector_decl", "item_reader_decl", "items_reader_field", + "reader_config_decl", "reader_config_field", "input_type_decl", + "csv_header_location_decl", "csv_headers_decl", "max_items_decl", + "tolerated_failure_count_decl", "tolerated_failure_percentage_decl", + "label_decl", "result_writer_decl", "result_writer_field", + "retry_decl", "retrier_decl", "retrier_stmt", "error_equals_decl", + "interval_seconds_decl", "max_attempts_decl", "backoff_rate_decl", + "max_delay_seconds_decl", "jitter_strategy_decl", "catch_decl", + "catcher_decl", "catcher_stmt", "comparison_op", "choice_operator", + "states_error_name", "error_name", "json_obj_decl", "json_binding", + "json_arr_decl", "json_value_decl", "string_sampler", + "string_expression_simple", "string_expression", "string_jsonpath", + "string_context_path", "string_variable_sample", "string_intrinsic_function", + "string_jsonata", "string_literal", "soft_string_keyword" ] + + EOF = Token.EOF + COMMA=1 + COLON=2 + LBRACK=3 + RBRACK=4 + LBRACE=5 + RBRACE=6 + TRUE=7 + FALSE=8 + NULL=9 + COMMENT=10 + STATES=11 + STARTAT=12 + NEXTSTATE=13 + VERSION=14 + TYPE=15 + TASK=16 + CHOICE=17 + FAIL=18 + SUCCEED=19 + PASS=20 + WAIT=21 + PARALLEL=22 + MAP=23 + CHOICES=24 + CONDITION=25 + VARIABLE=26 + DEFAULT=27 + BRANCHES=28 + AND=29 + BOOLEANEQUALS=30 + BOOLEANQUALSPATH=31 + ISBOOLEAN=32 + ISNULL=33 + ISNUMERIC=34 + ISPRESENT=35 + ISSTRING=36 + ISTIMESTAMP=37 + NOT=38 + NUMERICEQUALS=39 + NUMERICEQUALSPATH=40 + NUMERICGREATERTHAN=41 + NUMERICGREATERTHANPATH=42 + NUMERICGREATERTHANEQUALS=43 + NUMERICGREATERTHANEQUALSPATH=44 + NUMERICLESSTHAN=45 + NUMERICLESSTHANPATH=46 + NUMERICLESSTHANEQUALS=47 + NUMERICLESSTHANEQUALSPATH=48 + OR=49 + STRINGEQUALS=50 + STRINGEQUALSPATH=51 + STRINGGREATERTHAN=52 + STRINGGREATERTHANPATH=53 + STRINGGREATERTHANEQUALS=54 + STRINGGREATERTHANEQUALSPATH=55 + STRINGLESSTHAN=56 + STRINGLESSTHANPATH=57 + STRINGLESSTHANEQUALS=58 + STRINGLESSTHANEQUALSPATH=59 + STRINGMATCHES=60 + TIMESTAMPEQUALS=61 + TIMESTAMPEQUALSPATH=62 + TIMESTAMPGREATERTHAN=63 + TIMESTAMPGREATERTHANPATH=64 + TIMESTAMPGREATERTHANEQUALS=65 + TIMESTAMPGREATERTHANEQUALSPATH=66 + TIMESTAMPLESSTHAN=67 + TIMESTAMPLESSTHANPATH=68 + TIMESTAMPLESSTHANEQUALS=69 + TIMESTAMPLESSTHANEQUALSPATH=70 + SECONDSPATH=71 + SECONDS=72 + TIMESTAMPPATH=73 + TIMESTAMP=74 + TIMEOUTSECONDS=75 + TIMEOUTSECONDSPATH=76 + HEARTBEATSECONDS=77 + HEARTBEATSECONDSPATH=78 + PROCESSORCONFIG=79 + MODE=80 + INLINE=81 + DISTRIBUTED=82 + EXECUTIONTYPE=83 + STANDARD=84 + ITEMPROCESSOR=85 + ITERATOR=86 + ITEMSELECTOR=87 + MAXCONCURRENCYPATH=88 + MAXCONCURRENCY=89 + RESOURCE=90 + INPUTPATH=91 + OUTPUTPATH=92 + ITEMS=93 + ITEMSPATH=94 + RESULTPATH=95 + RESULT=96 + PARAMETERS=97 + CREDENTIALS=98 + ROLEARN=99 + ROLEARNPATH=100 + RESULTSELECTOR=101 + ITEMREADER=102 + READERCONFIG=103 + INPUTTYPE=104 + CSVHEADERLOCATION=105 + CSVHEADERS=106 + MAXITEMS=107 + MAXITEMSPATH=108 + TOLERATEDFAILURECOUNT=109 + TOLERATEDFAILURECOUNTPATH=110 + TOLERATEDFAILUREPERCENTAGE=111 + TOLERATEDFAILUREPERCENTAGEPATH=112 + LABEL=113 + RESULTWRITER=114 + NEXT=115 + END=116 + CAUSE=117 + CAUSEPATH=118 + ERROR=119 + ERRORPATH=120 + RETRY=121 + ERROREQUALS=122 + INTERVALSECONDS=123 + MAXATTEMPTS=124 + BACKOFFRATE=125 + MAXDELAYSECONDS=126 + JITTERSTRATEGY=127 + FULL=128 + NONE=129 + CATCH=130 + QUERYLANGUAGE=131 + JSONPATH=132 + JSONATA=133 + ASSIGN=134 + OUTPUT=135 + ARGUMENTS=136 + ERRORNAMEStatesALL=137 + ERRORNAMEStatesDataLimitExceeded=138 + ERRORNAMEStatesHeartbeatTimeout=139 + ERRORNAMEStatesTimeout=140 + ERRORNAMEStatesTaskFailed=141 + ERRORNAMEStatesPermissions=142 + ERRORNAMEStatesResultPathMatchFailure=143 + ERRORNAMEStatesParameterPathFailure=144 + ERRORNAMEStatesBranchFailed=145 + ERRORNAMEStatesNoChoiceMatched=146 + ERRORNAMEStatesIntrinsicFailure=147 + ERRORNAMEStatesExceedToleratedFailureThreshold=148 + ERRORNAMEStatesItemReaderFailed=149 + ERRORNAMEStatesResultWriterFailed=150 + ERRORNAMEStatesQueryEvaluationError=151 + ERRORNAMEStatesRuntime=152 + STRINGDOLLAR=153 + STRINGPATHCONTEXTOBJ=154 + STRINGPATH=155 + STRINGVAR=156 + STRINGINTRINSICFUNC=157 + STRINGJSONATA=158 + STRING=159 + INT=160 + NUMBER=161 + WS=162 + + def __init__(self, input:TokenStream, output:TextIO = sys.stdout): + super().__init__(input, output) + self.checkVersion("4.13.2") + self._interp = ParserATNSimulator(self, self.atn, self.decisionsToDFA, self.sharedContextCache) + self._predicates = None + + + + + class State_machineContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def program_decl(self): + return self.getTypedRuleContext(ASLParser.Program_declContext,0) + + + def EOF(self): + return self.getToken(ASLParser.EOF, 0) + + def getRuleIndex(self): + return ASLParser.RULE_state_machine + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterState_machine" ): + listener.enterState_machine(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitState_machine" ): + listener.exitState_machine(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitState_machine" ): + return visitor.visitState_machine(self) + else: + return visitor.visitChildren(self) + + + + + def state_machine(self): + + localctx = ASLParser.State_machineContext(self, self._ctx, self.state) + self.enterRule(localctx, 0, self.RULE_state_machine) + try: + self.enterOuterAlt(localctx, 1) + self.state = 232 + self.program_decl() + self.state = 233 + self.match(ASLParser.EOF) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Program_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def top_layer_stmt(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Top_layer_stmtContext) + else: + return self.getTypedRuleContext(ASLParser.Top_layer_stmtContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_program_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterProgram_decl" ): + listener.enterProgram_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitProgram_decl" ): + listener.exitProgram_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitProgram_decl" ): + return visitor.visitProgram_decl(self) + else: + return visitor.visitChildren(self) + + + + + def program_decl(self): + + localctx = ASLParser.Program_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 2, self.RULE_program_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 235 + self.match(ASLParser.LBRACE) + self.state = 236 + self.top_layer_stmt() + self.state = 241 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 237 + self.match(ASLParser.COMMA) + self.state = 238 + self.top_layer_stmt() + self.state = 243 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 244 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Top_layer_stmtContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def version_decl(self): + return self.getTypedRuleContext(ASLParser.Version_declContext,0) + + + def query_language_decl(self): + return self.getTypedRuleContext(ASLParser.Query_language_declContext,0) + + + def startat_decl(self): + return self.getTypedRuleContext(ASLParser.Startat_declContext,0) + + + def states_decl(self): + return self.getTypedRuleContext(ASLParser.States_declContext,0) + + + def timeout_seconds_decl(self): + return self.getTypedRuleContext(ASLParser.Timeout_seconds_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_top_layer_stmt + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTop_layer_stmt" ): + listener.enterTop_layer_stmt(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTop_layer_stmt" ): + listener.exitTop_layer_stmt(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTop_layer_stmt" ): + return visitor.visitTop_layer_stmt(self) + else: + return visitor.visitChildren(self) + + + + + def top_layer_stmt(self): + + localctx = ASLParser.Top_layer_stmtContext(self, self._ctx, self.state) + self.enterRule(localctx, 4, self.RULE_top_layer_stmt) + try: + self.state = 252 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [10]: + self.enterOuterAlt(localctx, 1) + self.state = 246 + self.comment_decl() + pass + elif token in [14]: + self.enterOuterAlt(localctx, 2) + self.state = 247 + self.version_decl() + pass + elif token in [131]: + self.enterOuterAlt(localctx, 3) + self.state = 248 + self.query_language_decl() + pass + elif token in [12]: + self.enterOuterAlt(localctx, 4) + self.state = 249 + self.startat_decl() + pass + elif token in [11]: + self.enterOuterAlt(localctx, 5) + self.state = 250 + self.states_decl() + pass + elif token in [75, 76]: + self.enterOuterAlt(localctx, 6) + self.state = 251 + self.timeout_seconds_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Startat_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STARTAT(self): + return self.getToken(ASLParser.STARTAT, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_startat_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterStartat_decl" ): + listener.enterStartat_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitStartat_decl" ): + listener.exitStartat_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitStartat_decl" ): + return visitor.visitStartat_decl(self) + else: + return visitor.visitChildren(self) + + + + + def startat_decl(self): + + localctx = ASLParser.Startat_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 6, self.RULE_startat_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 254 + self.match(ASLParser.STARTAT) + self.state = 255 + self.match(ASLParser.COLON) + self.state = 256 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Comment_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def COMMENT(self): + return self.getToken(ASLParser.COMMENT, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_comment_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterComment_decl" ): + listener.enterComment_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitComment_decl" ): + listener.exitComment_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitComment_decl" ): + return visitor.visitComment_decl(self) + else: + return visitor.visitChildren(self) + + + + + def comment_decl(self): + + localctx = ASLParser.Comment_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 8, self.RULE_comment_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 258 + self.match(ASLParser.COMMENT) + self.state = 259 + self.match(ASLParser.COLON) + self.state = 260 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Version_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def VERSION(self): + return self.getToken(ASLParser.VERSION, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_version_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterVersion_decl" ): + listener.enterVersion_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitVersion_decl" ): + listener.exitVersion_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitVersion_decl" ): + return visitor.visitVersion_decl(self) + else: + return visitor.visitChildren(self) + + + + + def version_decl(self): + + localctx = ASLParser.Version_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 10, self.RULE_version_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 262 + self.match(ASLParser.VERSION) + self.state = 263 + self.match(ASLParser.COLON) + self.state = 264 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Query_language_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def QUERYLANGUAGE(self): + return self.getToken(ASLParser.QUERYLANGUAGE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def JSONPATH(self): + return self.getToken(ASLParser.JSONPATH, 0) + + def JSONATA(self): + return self.getToken(ASLParser.JSONATA, 0) + + def getRuleIndex(self): + return ASLParser.RULE_query_language_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterQuery_language_decl" ): + listener.enterQuery_language_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitQuery_language_decl" ): + listener.exitQuery_language_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitQuery_language_decl" ): + return visitor.visitQuery_language_decl(self) + else: + return visitor.visitChildren(self) + + + + + def query_language_decl(self): + + localctx = ASLParser.Query_language_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 12, self.RULE_query_language_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 266 + self.match(ASLParser.QUERYLANGUAGE) + self.state = 267 + self.match(ASLParser.COLON) + self.state = 268 + _la = self._input.LA(1) + if not(_la==132 or _la==133): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class State_stmtContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def query_language_decl(self): + return self.getTypedRuleContext(ASLParser.Query_language_declContext,0) + + + def type_decl(self): + return self.getTypedRuleContext(ASLParser.Type_declContext,0) + + + def input_path_decl(self): + return self.getTypedRuleContext(ASLParser.Input_path_declContext,0) + + + def resource_decl(self): + return self.getTypedRuleContext(ASLParser.Resource_declContext,0) + + + def next_decl(self): + return self.getTypedRuleContext(ASLParser.Next_declContext,0) + + + def result_decl(self): + return self.getTypedRuleContext(ASLParser.Result_declContext,0) + + + def result_path_decl(self): + return self.getTypedRuleContext(ASLParser.Result_path_declContext,0) + + + def output_path_decl(self): + return self.getTypedRuleContext(ASLParser.Output_path_declContext,0) + + + def end_decl(self): + return self.getTypedRuleContext(ASLParser.End_declContext,0) + + + def default_decl(self): + return self.getTypedRuleContext(ASLParser.Default_declContext,0) + + + def choices_decl(self): + return self.getTypedRuleContext(ASLParser.Choices_declContext,0) + + + def error_decl(self): + return self.getTypedRuleContext(ASLParser.Error_declContext,0) + + + def cause_decl(self): + return self.getTypedRuleContext(ASLParser.Cause_declContext,0) + + + def seconds_decl(self): + return self.getTypedRuleContext(ASLParser.Seconds_declContext,0) + + + def timestamp_decl(self): + return self.getTypedRuleContext(ASLParser.Timestamp_declContext,0) + + + def items_decl(self): + return self.getTypedRuleContext(ASLParser.Items_declContext,0) + + + def items_path_decl(self): + return self.getTypedRuleContext(ASLParser.Items_path_declContext,0) + + + def item_processor_decl(self): + return self.getTypedRuleContext(ASLParser.Item_processor_declContext,0) + + + def iterator_decl(self): + return self.getTypedRuleContext(ASLParser.Iterator_declContext,0) + + + def item_selector_decl(self): + return self.getTypedRuleContext(ASLParser.Item_selector_declContext,0) + + + def item_reader_decl(self): + return self.getTypedRuleContext(ASLParser.Item_reader_declContext,0) + + + def max_concurrency_decl(self): + return self.getTypedRuleContext(ASLParser.Max_concurrency_declContext,0) + + + def timeout_seconds_decl(self): + return self.getTypedRuleContext(ASLParser.Timeout_seconds_declContext,0) + + + def heartbeat_seconds_decl(self): + return self.getTypedRuleContext(ASLParser.Heartbeat_seconds_declContext,0) + + + def branches_decl(self): + return self.getTypedRuleContext(ASLParser.Branches_declContext,0) + + + def parameters_decl(self): + return self.getTypedRuleContext(ASLParser.Parameters_declContext,0) + + + def retry_decl(self): + return self.getTypedRuleContext(ASLParser.Retry_declContext,0) + + + def catch_decl(self): + return self.getTypedRuleContext(ASLParser.Catch_declContext,0) + + + def result_selector_decl(self): + return self.getTypedRuleContext(ASLParser.Result_selector_declContext,0) + + + def tolerated_failure_count_decl(self): + return self.getTypedRuleContext(ASLParser.Tolerated_failure_count_declContext,0) + + + def tolerated_failure_percentage_decl(self): + return self.getTypedRuleContext(ASLParser.Tolerated_failure_percentage_declContext,0) + + + def label_decl(self): + return self.getTypedRuleContext(ASLParser.Label_declContext,0) + + + def result_writer_decl(self): + return self.getTypedRuleContext(ASLParser.Result_writer_declContext,0) + + + def assign_decl(self): + return self.getTypedRuleContext(ASLParser.Assign_declContext,0) + + + def arguments_decl(self): + return self.getTypedRuleContext(ASLParser.Arguments_declContext,0) + + + def output_decl(self): + return self.getTypedRuleContext(ASLParser.Output_declContext,0) + + + def credentials_decl(self): + return self.getTypedRuleContext(ASLParser.Credentials_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_state_stmt + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterState_stmt" ): + listener.enterState_stmt(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitState_stmt" ): + listener.exitState_stmt(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitState_stmt" ): + return visitor.visitState_stmt(self) + else: + return visitor.visitChildren(self) + + + + + def state_stmt(self): + + localctx = ASLParser.State_stmtContext(self, self._ctx, self.state) + self.enterRule(localctx, 14, self.RULE_state_stmt) + try: + self.state = 308 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [10]: + self.enterOuterAlt(localctx, 1) + self.state = 270 + self.comment_decl() + pass + elif token in [131]: + self.enterOuterAlt(localctx, 2) + self.state = 271 + self.query_language_decl() + pass + elif token in [15]: + self.enterOuterAlt(localctx, 3) + self.state = 272 + self.type_decl() + pass + elif token in [91]: + self.enterOuterAlt(localctx, 4) + self.state = 273 + self.input_path_decl() + pass + elif token in [90]: + self.enterOuterAlt(localctx, 5) + self.state = 274 + self.resource_decl() + pass + elif token in [115]: + self.enterOuterAlt(localctx, 6) + self.state = 275 + self.next_decl() + pass + elif token in [96]: + self.enterOuterAlt(localctx, 7) + self.state = 276 + self.result_decl() + pass + elif token in [95]: + self.enterOuterAlt(localctx, 8) + self.state = 277 + self.result_path_decl() + pass + elif token in [92]: + self.enterOuterAlt(localctx, 9) + self.state = 278 + self.output_path_decl() + pass + elif token in [116]: + self.enterOuterAlt(localctx, 10) + self.state = 279 + self.end_decl() + pass + elif token in [27]: + self.enterOuterAlt(localctx, 11) + self.state = 280 + self.default_decl() + pass + elif token in [24]: + self.enterOuterAlt(localctx, 12) + self.state = 281 + self.choices_decl() + pass + elif token in [119, 120]: + self.enterOuterAlt(localctx, 13) + self.state = 282 + self.error_decl() + pass + elif token in [117, 118]: + self.enterOuterAlt(localctx, 14) + self.state = 283 + self.cause_decl() + pass + elif token in [71, 72]: + self.enterOuterAlt(localctx, 15) + self.state = 284 + self.seconds_decl() + pass + elif token in [73, 74]: + self.enterOuterAlt(localctx, 16) + self.state = 285 + self.timestamp_decl() + pass + elif token in [93]: + self.enterOuterAlt(localctx, 17) + self.state = 286 + self.items_decl() + pass + elif token in [94]: + self.enterOuterAlt(localctx, 18) + self.state = 287 + self.items_path_decl() + pass + elif token in [85]: + self.enterOuterAlt(localctx, 19) + self.state = 288 + self.item_processor_decl() + pass + elif token in [86]: + self.enterOuterAlt(localctx, 20) + self.state = 289 + self.iterator_decl() + pass + elif token in [87]: + self.enterOuterAlt(localctx, 21) + self.state = 290 + self.item_selector_decl() + pass + elif token in [102]: + self.enterOuterAlt(localctx, 22) + self.state = 291 + self.item_reader_decl() + pass + elif token in [88, 89]: + self.enterOuterAlt(localctx, 23) + self.state = 292 + self.max_concurrency_decl() + pass + elif token in [75, 76]: + self.enterOuterAlt(localctx, 24) + self.state = 293 + self.timeout_seconds_decl() + pass + elif token in [77, 78]: + self.enterOuterAlt(localctx, 25) + self.state = 294 + self.heartbeat_seconds_decl() + pass + elif token in [28]: + self.enterOuterAlt(localctx, 26) + self.state = 295 + self.branches_decl() + pass + elif token in [97]: + self.enterOuterAlt(localctx, 27) + self.state = 296 + self.parameters_decl() + pass + elif token in [121]: + self.enterOuterAlt(localctx, 28) + self.state = 297 + self.retry_decl() + pass + elif token in [130]: + self.enterOuterAlt(localctx, 29) + self.state = 298 + self.catch_decl() + pass + elif token in [101]: + self.enterOuterAlt(localctx, 30) + self.state = 299 + self.result_selector_decl() + pass + elif token in [109, 110]: + self.enterOuterAlt(localctx, 31) + self.state = 300 + self.tolerated_failure_count_decl() + pass + elif token in [111, 112]: + self.enterOuterAlt(localctx, 32) + self.state = 301 + self.tolerated_failure_percentage_decl() + pass + elif token in [113]: + self.enterOuterAlt(localctx, 33) + self.state = 302 + self.label_decl() + pass + elif token in [114]: + self.enterOuterAlt(localctx, 34) + self.state = 303 + self.result_writer_decl() + pass + elif token in [134]: + self.enterOuterAlt(localctx, 35) + self.state = 304 + self.assign_decl() + pass + elif token in [136]: + self.enterOuterAlt(localctx, 36) + self.state = 305 + self.arguments_decl() + pass + elif token in [135]: + self.enterOuterAlt(localctx, 37) + self.state = 306 + self.output_decl() + pass + elif token in [98]: + self.enterOuterAlt(localctx, 38) + self.state = 307 + self.credentials_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class States_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STATES(self): + return self.getToken(ASLParser.STATES, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def state_decl(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.State_declContext) + else: + return self.getTypedRuleContext(ASLParser.State_declContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_states_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterStates_decl" ): + listener.enterStates_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitStates_decl" ): + listener.exitStates_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitStates_decl" ): + return visitor.visitStates_decl(self) + else: + return visitor.visitChildren(self) + + + + + def states_decl(self): + + localctx = ASLParser.States_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 16, self.RULE_states_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 310 + self.match(ASLParser.STATES) + self.state = 311 + self.match(ASLParser.COLON) + self.state = 312 + self.match(ASLParser.LBRACE) + self.state = 313 + self.state_decl() + self.state = 318 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 314 + self.match(ASLParser.COMMA) + self.state = 315 + self.state_decl() + self.state = 320 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 321 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class State_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def state_decl_body(self): + return self.getTypedRuleContext(ASLParser.State_decl_bodyContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_state_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterState_decl" ): + listener.enterState_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitState_decl" ): + listener.exitState_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitState_decl" ): + return visitor.visitState_decl(self) + else: + return visitor.visitChildren(self) + + + + + def state_decl(self): + + localctx = ASLParser.State_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 18, self.RULE_state_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 323 + self.string_literal() + self.state = 324 + self.match(ASLParser.COLON) + self.state = 325 + self.state_decl_body() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class State_decl_bodyContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def state_stmt(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.State_stmtContext) + else: + return self.getTypedRuleContext(ASLParser.State_stmtContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_state_decl_body + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterState_decl_body" ): + listener.enterState_decl_body(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitState_decl_body" ): + listener.exitState_decl_body(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitState_decl_body" ): + return visitor.visitState_decl_body(self) + else: + return visitor.visitChildren(self) + + + + + def state_decl_body(self): + + localctx = ASLParser.State_decl_bodyContext(self, self._ctx, self.state) + self.enterRule(localctx, 20, self.RULE_state_decl_body) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 327 + self.match(ASLParser.LBRACE) + self.state = 328 + self.state_stmt() + self.state = 333 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 329 + self.match(ASLParser.COMMA) + self.state = 330 + self.state_stmt() + self.state = 335 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 336 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Type_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def TYPE(self): + return self.getToken(ASLParser.TYPE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def state_type(self): + return self.getTypedRuleContext(ASLParser.State_typeContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_type_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterType_decl" ): + listener.enterType_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitType_decl" ): + listener.exitType_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitType_decl" ): + return visitor.visitType_decl(self) + else: + return visitor.visitChildren(self) + + + + + def type_decl(self): + + localctx = ASLParser.Type_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 22, self.RULE_type_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 338 + self.match(ASLParser.TYPE) + self.state = 339 + self.match(ASLParser.COLON) + self.state = 340 + self.state_type() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Next_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def NEXT(self): + return self.getToken(ASLParser.NEXT, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_next_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterNext_decl" ): + listener.enterNext_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitNext_decl" ): + listener.exitNext_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitNext_decl" ): + return visitor.visitNext_decl(self) + else: + return visitor.visitChildren(self) + + + + + def next_decl(self): + + localctx = ASLParser.Next_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 24, self.RULE_next_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 342 + self.match(ASLParser.NEXT) + self.state = 343 + self.match(ASLParser.COLON) + self.state = 344 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Resource_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def RESOURCE(self): + return self.getToken(ASLParser.RESOURCE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_resource_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterResource_decl" ): + listener.enterResource_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitResource_decl" ): + listener.exitResource_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitResource_decl" ): + return visitor.visitResource_decl(self) + else: + return visitor.visitChildren(self) + + + + + def resource_decl(self): + + localctx = ASLParser.Resource_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 26, self.RULE_resource_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 346 + self.match(ASLParser.RESOURCE) + self.state = 347 + self.match(ASLParser.COLON) + self.state = 348 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Input_path_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def INPUTPATH(self): + return self.getToken(ASLParser.INPUTPATH, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def NULL(self): + return self.getToken(ASLParser.NULL, 0) + + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_input_path_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterInput_path_decl" ): + listener.enterInput_path_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitInput_path_decl" ): + listener.exitInput_path_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitInput_path_decl" ): + return visitor.visitInput_path_decl(self) + else: + return visitor.visitChildren(self) + + + + + def input_path_decl(self): + + localctx = ASLParser.Input_path_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 28, self.RULE_input_path_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 350 + self.match(ASLParser.INPUTPATH) + self.state = 351 + self.match(ASLParser.COLON) + self.state = 354 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [9]: + self.state = 352 + self.match(ASLParser.NULL) + pass + elif token in [154, 155, 156]: + self.state = 353 + self.string_sampler() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Result_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def RESULT(self): + return self.getToken(ASLParser.RESULT, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def json_value_decl(self): + return self.getTypedRuleContext(ASLParser.Json_value_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_result_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterResult_decl" ): + listener.enterResult_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitResult_decl" ): + listener.exitResult_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitResult_decl" ): + return visitor.visitResult_decl(self) + else: + return visitor.visitChildren(self) + + + + + def result_decl(self): + + localctx = ASLParser.Result_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 30, self.RULE_result_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 356 + self.match(ASLParser.RESULT) + self.state = 357 + self.match(ASLParser.COLON) + self.state = 358 + self.json_value_decl() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Result_path_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def RESULTPATH(self): + return self.getToken(ASLParser.RESULTPATH, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def NULL(self): + return self.getToken(ASLParser.NULL, 0) + + def string_jsonpath(self): + return self.getTypedRuleContext(ASLParser.String_jsonpathContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_result_path_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterResult_path_decl" ): + listener.enterResult_path_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitResult_path_decl" ): + listener.exitResult_path_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitResult_path_decl" ): + return visitor.visitResult_path_decl(self) + else: + return visitor.visitChildren(self) + + + + + def result_path_decl(self): + + localctx = ASLParser.Result_path_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 32, self.RULE_result_path_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 360 + self.match(ASLParser.RESULTPATH) + self.state = 361 + self.match(ASLParser.COLON) + self.state = 364 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [9]: + self.state = 362 + self.match(ASLParser.NULL) + pass + elif token in [155]: + self.state = 363 + self.string_jsonpath() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Output_path_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def OUTPUTPATH(self): + return self.getToken(ASLParser.OUTPUTPATH, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def NULL(self): + return self.getToken(ASLParser.NULL, 0) + + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_output_path_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterOutput_path_decl" ): + listener.enterOutput_path_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitOutput_path_decl" ): + listener.exitOutput_path_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitOutput_path_decl" ): + return visitor.visitOutput_path_decl(self) + else: + return visitor.visitChildren(self) + + + + + def output_path_decl(self): + + localctx = ASLParser.Output_path_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 34, self.RULE_output_path_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 366 + self.match(ASLParser.OUTPUTPATH) + self.state = 367 + self.match(ASLParser.COLON) + self.state = 370 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [9]: + self.state = 368 + self.match(ASLParser.NULL) + pass + elif token in [154, 155, 156]: + self.state = 369 + self.string_sampler() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class End_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def END(self): + return self.getToken(ASLParser.END, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def TRUE(self): + return self.getToken(ASLParser.TRUE, 0) + + def FALSE(self): + return self.getToken(ASLParser.FALSE, 0) + + def getRuleIndex(self): + return ASLParser.RULE_end_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterEnd_decl" ): + listener.enterEnd_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitEnd_decl" ): + listener.exitEnd_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitEnd_decl" ): + return visitor.visitEnd_decl(self) + else: + return visitor.visitChildren(self) + + + + + def end_decl(self): + + localctx = ASLParser.End_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 36, self.RULE_end_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 372 + self.match(ASLParser.END) + self.state = 373 + self.match(ASLParser.COLON) + self.state = 374 + _la = self._input.LA(1) + if not(_la==7 or _la==8): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Default_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def DEFAULT(self): + return self.getToken(ASLParser.DEFAULT, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_default_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterDefault_decl" ): + listener.enterDefault_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitDefault_decl" ): + listener.exitDefault_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitDefault_decl" ): + return visitor.visitDefault_decl(self) + else: + return visitor.visitChildren(self) + + + + + def default_decl(self): + + localctx = ASLParser.Default_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 38, self.RULE_default_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 376 + self.match(ASLParser.DEFAULT) + self.state = 377 + self.match(ASLParser.COLON) + self.state = 378 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Error_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_error_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Error_pathContext(Error_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Error_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ERRORPATH(self): + return self.getToken(ASLParser.ERRORPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_expression_simple(self): + return self.getTypedRuleContext(ASLParser.String_expression_simpleContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterError_path" ): + listener.enterError_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitError_path" ): + listener.exitError_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitError_path" ): + return visitor.visitError_path(self) + else: + return visitor.visitChildren(self) + + + class ErrorContext(Error_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Error_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ERROR(self): + return self.getToken(ASLParser.ERROR, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterError" ): + listener.enterError(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitError" ): + listener.exitError(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitError" ): + return visitor.visitError(self) + else: + return visitor.visitChildren(self) + + + + def error_decl(self): + + localctx = ASLParser.Error_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 40, self.RULE_error_decl) + try: + self.state = 389 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [119]: + localctx = ASLParser.ErrorContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 380 + self.match(ASLParser.ERROR) + self.state = 381 + self.match(ASLParser.COLON) + self.state = 384 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,8,self._ctx) + if la_ == 1: + self.state = 382 + self.string_jsonata() + pass + + elif la_ == 2: + self.state = 383 + self.string_literal() + pass + + + pass + elif token in [120]: + localctx = ASLParser.Error_pathContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 386 + self.match(ASLParser.ERRORPATH) + self.state = 387 + self.match(ASLParser.COLON) + self.state = 388 + self.string_expression_simple() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Cause_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_cause_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Cause_pathContext(Cause_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Cause_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def CAUSEPATH(self): + return self.getToken(ASLParser.CAUSEPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_expression_simple(self): + return self.getTypedRuleContext(ASLParser.String_expression_simpleContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCause_path" ): + listener.enterCause_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCause_path" ): + listener.exitCause_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCause_path" ): + return visitor.visitCause_path(self) + else: + return visitor.visitChildren(self) + + + class CauseContext(Cause_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Cause_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def CAUSE(self): + return self.getToken(ASLParser.CAUSE, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCause" ): + listener.enterCause(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCause" ): + listener.exitCause(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCause" ): + return visitor.visitCause(self) + else: + return visitor.visitChildren(self) + + + + def cause_decl(self): + + localctx = ASLParser.Cause_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 42, self.RULE_cause_decl) + try: + self.state = 400 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [117]: + localctx = ASLParser.CauseContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 391 + self.match(ASLParser.CAUSE) + self.state = 392 + self.match(ASLParser.COLON) + self.state = 395 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,10,self._ctx) + if la_ == 1: + self.state = 393 + self.string_jsonata() + pass + + elif la_ == 2: + self.state = 394 + self.string_literal() + pass + + + pass + elif token in [118]: + localctx = ASLParser.Cause_pathContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 397 + self.match(ASLParser.CAUSEPATH) + self.state = 398 + self.match(ASLParser.COLON) + self.state = 399 + self.string_expression_simple() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Seconds_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_seconds_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Seconds_jsonataContext(Seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def SECONDS(self): + return self.getToken(ASLParser.SECONDS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterSeconds_jsonata" ): + listener.enterSeconds_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitSeconds_jsonata" ): + listener.exitSeconds_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitSeconds_jsonata" ): + return visitor.visitSeconds_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Seconds_pathContext(Seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def SECONDSPATH(self): + return self.getToken(ASLParser.SECONDSPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterSeconds_path" ): + listener.enterSeconds_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitSeconds_path" ): + listener.exitSeconds_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitSeconds_path" ): + return visitor.visitSeconds_path(self) + else: + return visitor.visitChildren(self) + + + class Seconds_intContext(Seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def SECONDS(self): + return self.getToken(ASLParser.SECONDS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterSeconds_int" ): + listener.enterSeconds_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitSeconds_int" ): + listener.exitSeconds_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitSeconds_int" ): + return visitor.visitSeconds_int(self) + else: + return visitor.visitChildren(self) + + + + def seconds_decl(self): + + localctx = ASLParser.Seconds_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 44, self.RULE_seconds_decl) + try: + self.state = 411 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,12,self._ctx) + if la_ == 1: + localctx = ASLParser.Seconds_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 402 + self.match(ASLParser.SECONDS) + self.state = 403 + self.match(ASLParser.COLON) + self.state = 404 + self.string_jsonata() + pass + + elif la_ == 2: + localctx = ASLParser.Seconds_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 405 + self.match(ASLParser.SECONDS) + self.state = 406 + self.match(ASLParser.COLON) + self.state = 407 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Seconds_pathContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 408 + self.match(ASLParser.SECONDSPATH) + self.state = 409 + self.match(ASLParser.COLON) + self.state = 410 + self.string_sampler() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Timestamp_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_timestamp_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Timestamp_pathContext(Timestamp_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Timestamp_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TIMESTAMPPATH(self): + return self.getToken(ASLParser.TIMESTAMPPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTimestamp_path" ): + listener.enterTimestamp_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTimestamp_path" ): + listener.exitTimestamp_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTimestamp_path" ): + return visitor.visitTimestamp_path(self) + else: + return visitor.visitChildren(self) + + + class TimestampContext(Timestamp_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Timestamp_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TIMESTAMP(self): + return self.getToken(ASLParser.TIMESTAMP, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTimestamp" ): + listener.enterTimestamp(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTimestamp" ): + listener.exitTimestamp(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTimestamp" ): + return visitor.visitTimestamp(self) + else: + return visitor.visitChildren(self) + + + + def timestamp_decl(self): + + localctx = ASLParser.Timestamp_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 46, self.RULE_timestamp_decl) + try: + self.state = 422 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [74]: + localctx = ASLParser.TimestampContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 413 + self.match(ASLParser.TIMESTAMP) + self.state = 414 + self.match(ASLParser.COLON) + self.state = 417 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,13,self._ctx) + if la_ == 1: + self.state = 415 + self.string_jsonata() + pass + + elif la_ == 2: + self.state = 416 + self.string_literal() + pass + + + pass + elif token in [73]: + localctx = ASLParser.Timestamp_pathContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 419 + self.match(ASLParser.TIMESTAMPPATH) + self.state = 420 + self.match(ASLParser.COLON) + self.state = 421 + self.string_sampler() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Items_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_items_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Items_arrayContext(Items_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Items_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ITEMS(self): + return self.getToken(ASLParser.ITEMS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def jsonata_template_value_array(self): + return self.getTypedRuleContext(ASLParser.Jsonata_template_value_arrayContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItems_array" ): + listener.enterItems_array(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItems_array" ): + listener.exitItems_array(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItems_array" ): + return visitor.visitItems_array(self) + else: + return visitor.visitChildren(self) + + + class Items_jsonataContext(Items_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Items_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ITEMS(self): + return self.getToken(ASLParser.ITEMS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItems_jsonata" ): + listener.enterItems_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItems_jsonata" ): + listener.exitItems_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItems_jsonata" ): + return visitor.visitItems_jsonata(self) + else: + return visitor.visitChildren(self) + + + + def items_decl(self): + + localctx = ASLParser.Items_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 48, self.RULE_items_decl) + try: + self.state = 430 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,15,self._ctx) + if la_ == 1: + localctx = ASLParser.Items_arrayContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 424 + self.match(ASLParser.ITEMS) + self.state = 425 + self.match(ASLParser.COLON) + self.state = 426 + self.jsonata_template_value_array() + pass + + elif la_ == 2: + localctx = ASLParser.Items_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 427 + self.match(ASLParser.ITEMS) + self.state = 428 + self.match(ASLParser.COLON) + self.state = 429 + self.string_jsonata() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Items_path_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ITEMSPATH(self): + return self.getToken(ASLParser.ITEMSPATH, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_items_path_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItems_path_decl" ): + listener.enterItems_path_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItems_path_decl" ): + listener.exitItems_path_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItems_path_decl" ): + return visitor.visitItems_path_decl(self) + else: + return visitor.visitChildren(self) + + + + + def items_path_decl(self): + + localctx = ASLParser.Items_path_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 50, self.RULE_items_path_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 432 + self.match(ASLParser.ITEMSPATH) + self.state = 433 + self.match(ASLParser.COLON) + self.state = 434 + self.string_sampler() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Max_concurrency_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_max_concurrency_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Max_concurrency_jsonataContext(Max_concurrency_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Max_concurrency_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def MAXCONCURRENCY(self): + return self.getToken(ASLParser.MAXCONCURRENCY, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_concurrency_jsonata" ): + listener.enterMax_concurrency_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_concurrency_jsonata" ): + listener.exitMax_concurrency_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_concurrency_jsonata" ): + return visitor.visitMax_concurrency_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Max_concurrency_pathContext(Max_concurrency_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Max_concurrency_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def MAXCONCURRENCYPATH(self): + return self.getToken(ASLParser.MAXCONCURRENCYPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_concurrency_path" ): + listener.enterMax_concurrency_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_concurrency_path" ): + listener.exitMax_concurrency_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_concurrency_path" ): + return visitor.visitMax_concurrency_path(self) + else: + return visitor.visitChildren(self) + + + class Max_concurrency_intContext(Max_concurrency_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Max_concurrency_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def MAXCONCURRENCY(self): + return self.getToken(ASLParser.MAXCONCURRENCY, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_concurrency_int" ): + listener.enterMax_concurrency_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_concurrency_int" ): + listener.exitMax_concurrency_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_concurrency_int" ): + return visitor.visitMax_concurrency_int(self) + else: + return visitor.visitChildren(self) + + + + def max_concurrency_decl(self): + + localctx = ASLParser.Max_concurrency_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 52, self.RULE_max_concurrency_decl) + try: + self.state = 445 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,16,self._ctx) + if la_ == 1: + localctx = ASLParser.Max_concurrency_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 436 + self.match(ASLParser.MAXCONCURRENCY) + self.state = 437 + self.match(ASLParser.COLON) + self.state = 438 + self.string_jsonata() + pass + + elif la_ == 2: + localctx = ASLParser.Max_concurrency_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 439 + self.match(ASLParser.MAXCONCURRENCY) + self.state = 440 + self.match(ASLParser.COLON) + self.state = 441 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Max_concurrency_pathContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 442 + self.match(ASLParser.MAXCONCURRENCYPATH) + self.state = 443 + self.match(ASLParser.COLON) + self.state = 444 + self.string_sampler() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Parameters_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def PARAMETERS(self): + return self.getToken(ASLParser.PARAMETERS, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def payload_tmpl_decl(self): + return self.getTypedRuleContext(ASLParser.Payload_tmpl_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_parameters_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterParameters_decl" ): + listener.enterParameters_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitParameters_decl" ): + listener.exitParameters_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitParameters_decl" ): + return visitor.visitParameters_decl(self) + else: + return visitor.visitChildren(self) + + + + + def parameters_decl(self): + + localctx = ASLParser.Parameters_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 54, self.RULE_parameters_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 447 + self.match(ASLParser.PARAMETERS) + self.state = 448 + self.match(ASLParser.COLON) + self.state = 449 + self.payload_tmpl_decl() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Credentials_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def CREDENTIALS(self): + return self.getToken(ASLParser.CREDENTIALS, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def role_arn_decl(self): + return self.getTypedRuleContext(ASLParser.Role_arn_declContext,0) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def getRuleIndex(self): + return ASLParser.RULE_credentials_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCredentials_decl" ): + listener.enterCredentials_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCredentials_decl" ): + listener.exitCredentials_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCredentials_decl" ): + return visitor.visitCredentials_decl(self) + else: + return visitor.visitChildren(self) + + + + + def credentials_decl(self): + + localctx = ASLParser.Credentials_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 56, self.RULE_credentials_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 451 + self.match(ASLParser.CREDENTIALS) + self.state = 452 + self.match(ASLParser.COLON) + self.state = 453 + self.match(ASLParser.LBRACE) + self.state = 454 + self.role_arn_decl() + self.state = 455 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Role_arn_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_role_arn_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Role_arnContext(Role_arn_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Role_arn_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ROLEARN(self): + return self.getToken(ASLParser.ROLEARN, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterRole_arn" ): + listener.enterRole_arn(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitRole_arn" ): + listener.exitRole_arn(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitRole_arn" ): + return visitor.visitRole_arn(self) + else: + return visitor.visitChildren(self) + + + class Role_pathContext(Role_arn_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Role_arn_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ROLEARNPATH(self): + return self.getToken(ASLParser.ROLEARNPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_expression_simple(self): + return self.getTypedRuleContext(ASLParser.String_expression_simpleContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterRole_path" ): + listener.enterRole_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitRole_path" ): + listener.exitRole_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitRole_path" ): + return visitor.visitRole_path(self) + else: + return visitor.visitChildren(self) + + + + def role_arn_decl(self): + + localctx = ASLParser.Role_arn_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 58, self.RULE_role_arn_decl) + try: + self.state = 466 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [99]: + localctx = ASLParser.Role_arnContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 457 + self.match(ASLParser.ROLEARN) + self.state = 458 + self.match(ASLParser.COLON) + self.state = 461 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,17,self._ctx) + if la_ == 1: + self.state = 459 + self.string_jsonata() + pass + + elif la_ == 2: + self.state = 460 + self.string_literal() + pass + + + pass + elif token in [100]: + localctx = ASLParser.Role_pathContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 463 + self.match(ASLParser.ROLEARNPATH) + self.state = 464 + self.match(ASLParser.COLON) + self.state = 465 + self.string_expression_simple() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Timeout_seconds_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_timeout_seconds_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Timeout_seconds_jsonataContext(Timeout_seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Timeout_seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TIMEOUTSECONDS(self): + return self.getToken(ASLParser.TIMEOUTSECONDS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTimeout_seconds_jsonata" ): + listener.enterTimeout_seconds_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTimeout_seconds_jsonata" ): + listener.exitTimeout_seconds_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTimeout_seconds_jsonata" ): + return visitor.visitTimeout_seconds_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Timeout_seconds_pathContext(Timeout_seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Timeout_seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TIMEOUTSECONDSPATH(self): + return self.getToken(ASLParser.TIMEOUTSECONDSPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTimeout_seconds_path" ): + listener.enterTimeout_seconds_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTimeout_seconds_path" ): + listener.exitTimeout_seconds_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTimeout_seconds_path" ): + return visitor.visitTimeout_seconds_path(self) + else: + return visitor.visitChildren(self) + + + class Timeout_seconds_intContext(Timeout_seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Timeout_seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TIMEOUTSECONDS(self): + return self.getToken(ASLParser.TIMEOUTSECONDS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTimeout_seconds_int" ): + listener.enterTimeout_seconds_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTimeout_seconds_int" ): + listener.exitTimeout_seconds_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTimeout_seconds_int" ): + return visitor.visitTimeout_seconds_int(self) + else: + return visitor.visitChildren(self) + + + + def timeout_seconds_decl(self): + + localctx = ASLParser.Timeout_seconds_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 60, self.RULE_timeout_seconds_decl) + try: + self.state = 477 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,19,self._ctx) + if la_ == 1: + localctx = ASLParser.Timeout_seconds_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 468 + self.match(ASLParser.TIMEOUTSECONDS) + self.state = 469 + self.match(ASLParser.COLON) + self.state = 470 + self.string_jsonata() + pass + + elif la_ == 2: + localctx = ASLParser.Timeout_seconds_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 471 + self.match(ASLParser.TIMEOUTSECONDS) + self.state = 472 + self.match(ASLParser.COLON) + self.state = 473 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Timeout_seconds_pathContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 474 + self.match(ASLParser.TIMEOUTSECONDSPATH) + self.state = 475 + self.match(ASLParser.COLON) + self.state = 476 + self.string_sampler() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Heartbeat_seconds_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_heartbeat_seconds_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Heartbeat_seconds_intContext(Heartbeat_seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Heartbeat_seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def HEARTBEATSECONDS(self): + return self.getToken(ASLParser.HEARTBEATSECONDS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterHeartbeat_seconds_int" ): + listener.enterHeartbeat_seconds_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitHeartbeat_seconds_int" ): + listener.exitHeartbeat_seconds_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitHeartbeat_seconds_int" ): + return visitor.visitHeartbeat_seconds_int(self) + else: + return visitor.visitChildren(self) + + + class Heartbeat_seconds_jsonataContext(Heartbeat_seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Heartbeat_seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def HEARTBEATSECONDS(self): + return self.getToken(ASLParser.HEARTBEATSECONDS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterHeartbeat_seconds_jsonata" ): + listener.enterHeartbeat_seconds_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitHeartbeat_seconds_jsonata" ): + listener.exitHeartbeat_seconds_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitHeartbeat_seconds_jsonata" ): + return visitor.visitHeartbeat_seconds_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Heartbeat_seconds_pathContext(Heartbeat_seconds_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Heartbeat_seconds_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def HEARTBEATSECONDSPATH(self): + return self.getToken(ASLParser.HEARTBEATSECONDSPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterHeartbeat_seconds_path" ): + listener.enterHeartbeat_seconds_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitHeartbeat_seconds_path" ): + listener.exitHeartbeat_seconds_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitHeartbeat_seconds_path" ): + return visitor.visitHeartbeat_seconds_path(self) + else: + return visitor.visitChildren(self) + + + + def heartbeat_seconds_decl(self): + + localctx = ASLParser.Heartbeat_seconds_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 62, self.RULE_heartbeat_seconds_decl) + try: + self.state = 488 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,20,self._ctx) + if la_ == 1: + localctx = ASLParser.Heartbeat_seconds_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 479 + self.match(ASLParser.HEARTBEATSECONDS) + self.state = 480 + self.match(ASLParser.COLON) + self.state = 481 + self.string_jsonata() + pass + + elif la_ == 2: + localctx = ASLParser.Heartbeat_seconds_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 482 + self.match(ASLParser.HEARTBEATSECONDS) + self.state = 483 + self.match(ASLParser.COLON) + self.state = 484 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Heartbeat_seconds_pathContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 485 + self.match(ASLParser.HEARTBEATSECONDSPATH) + self.state = 486 + self.match(ASLParser.COLON) + self.state = 487 + self.string_sampler() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Payload_tmpl_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def payload_binding(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Payload_bindingContext) + else: + return self.getTypedRuleContext(ASLParser.Payload_bindingContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_payload_tmpl_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_tmpl_decl" ): + listener.enterPayload_tmpl_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_tmpl_decl" ): + listener.exitPayload_tmpl_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_tmpl_decl" ): + return visitor.visitPayload_tmpl_decl(self) + else: + return visitor.visitChildren(self) + + + + + def payload_tmpl_decl(self): + + localctx = ASLParser.Payload_tmpl_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 64, self.RULE_payload_tmpl_decl) + self._la = 0 # Token type + try: + self.state = 503 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,22,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 490 + self.match(ASLParser.LBRACE) + self.state = 491 + self.payload_binding() + self.state = 496 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 492 + self.match(ASLParser.COMMA) + self.state = 493 + self.payload_binding() + self.state = 498 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 499 + self.match(ASLParser.RBRACE) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 501 + self.match(ASLParser.LBRACE) + self.state = 502 + self.match(ASLParser.RBRACE) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Payload_bindingContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_payload_binding + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Payload_binding_sampleContext(Payload_bindingContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Payload_bindingContext + super().__init__(parser) + self.copyFrom(ctx) + + def STRINGDOLLAR(self): + return self.getToken(ASLParser.STRINGDOLLAR, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_expression_simple(self): + return self.getTypedRuleContext(ASLParser.String_expression_simpleContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_binding_sample" ): + listener.enterPayload_binding_sample(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_binding_sample" ): + listener.exitPayload_binding_sample(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_binding_sample" ): + return visitor.visitPayload_binding_sample(self) + else: + return visitor.visitChildren(self) + + + class Payload_binding_valueContext(Payload_bindingContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Payload_bindingContext + super().__init__(parser) + self.copyFrom(ctx) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def payload_value_decl(self): + return self.getTypedRuleContext(ASLParser.Payload_value_declContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_binding_value" ): + listener.enterPayload_binding_value(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_binding_value" ): + listener.exitPayload_binding_value(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_binding_value" ): + return visitor.visitPayload_binding_value(self) + else: + return visitor.visitChildren(self) + + + + def payload_binding(self): + + localctx = ASLParser.Payload_bindingContext(self, self._ctx, self.state) + self.enterRule(localctx, 66, self.RULE_payload_binding) + try: + self.state = 512 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,23,self._ctx) + if la_ == 1: + localctx = ASLParser.Payload_binding_sampleContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 505 + self.match(ASLParser.STRINGDOLLAR) + self.state = 506 + self.match(ASLParser.COLON) + self.state = 507 + self.string_expression_simple() + pass + + elif la_ == 2: + localctx = ASLParser.Payload_binding_valueContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 508 + self.string_literal() + self.state = 509 + self.match(ASLParser.COLON) + self.state = 510 + self.payload_value_decl() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Payload_arr_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def payload_value_decl(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Payload_value_declContext) + else: + return self.getTypedRuleContext(ASLParser.Payload_value_declContext,i) + + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_payload_arr_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_arr_decl" ): + listener.enterPayload_arr_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_arr_decl" ): + listener.exitPayload_arr_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_arr_decl" ): + return visitor.visitPayload_arr_decl(self) + else: + return visitor.visitChildren(self) + + + + + def payload_arr_decl(self): + + localctx = ASLParser.Payload_arr_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 68, self.RULE_payload_arr_decl) + self._la = 0 # Token type + try: + self.state = 527 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,25,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 514 + self.match(ASLParser.LBRACK) + self.state = 515 + self.payload_value_decl() + self.state = 520 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 516 + self.match(ASLParser.COMMA) + self.state = 517 + self.payload_value_decl() + self.state = 522 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 523 + self.match(ASLParser.RBRACK) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 525 + self.match(ASLParser.LBRACK) + self.state = 526 + self.match(ASLParser.RBRACK) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Payload_value_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def payload_arr_decl(self): + return self.getTypedRuleContext(ASLParser.Payload_arr_declContext,0) + + + def payload_tmpl_decl(self): + return self.getTypedRuleContext(ASLParser.Payload_tmpl_declContext,0) + + + def payload_value_lit(self): + return self.getTypedRuleContext(ASLParser.Payload_value_litContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_payload_value_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_value_decl" ): + listener.enterPayload_value_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_value_decl" ): + listener.exitPayload_value_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_value_decl" ): + return visitor.visitPayload_value_decl(self) + else: + return visitor.visitChildren(self) + + + + + def payload_value_decl(self): + + localctx = ASLParser.Payload_value_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 70, self.RULE_payload_value_decl) + try: + self.state = 532 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [3]: + self.enterOuterAlt(localctx, 1) + self.state = 529 + self.payload_arr_decl() + pass + elif token in [5]: + self.enterOuterAlt(localctx, 2) + self.state = 530 + self.payload_tmpl_decl() + pass + elif token in [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161]: + self.enterOuterAlt(localctx, 3) + self.state = 531 + self.payload_value_lit() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Payload_value_litContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_payload_value_lit + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Payload_value_boolContext(Payload_value_litContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Payload_value_litContext + super().__init__(parser) + self.copyFrom(ctx) + + def TRUE(self): + return self.getToken(ASLParser.TRUE, 0) + def FALSE(self): + return self.getToken(ASLParser.FALSE, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_value_bool" ): + listener.enterPayload_value_bool(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_value_bool" ): + listener.exitPayload_value_bool(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_value_bool" ): + return visitor.visitPayload_value_bool(self) + else: + return visitor.visitChildren(self) + + + class Payload_value_intContext(Payload_value_litContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Payload_value_litContext + super().__init__(parser) + self.copyFrom(ctx) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_value_int" ): + listener.enterPayload_value_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_value_int" ): + listener.exitPayload_value_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_value_int" ): + return visitor.visitPayload_value_int(self) + else: + return visitor.visitChildren(self) + + + class Payload_value_strContext(Payload_value_litContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Payload_value_litContext + super().__init__(parser) + self.copyFrom(ctx) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_value_str" ): + listener.enterPayload_value_str(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_value_str" ): + listener.exitPayload_value_str(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_value_str" ): + return visitor.visitPayload_value_str(self) + else: + return visitor.visitChildren(self) + + + class Payload_value_floatContext(Payload_value_litContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Payload_value_litContext + super().__init__(parser) + self.copyFrom(ctx) + + def NUMBER(self): + return self.getToken(ASLParser.NUMBER, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_value_float" ): + listener.enterPayload_value_float(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_value_float" ): + listener.exitPayload_value_float(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_value_float" ): + return visitor.visitPayload_value_float(self) + else: + return visitor.visitChildren(self) + + + class Payload_value_nullContext(Payload_value_litContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Payload_value_litContext + super().__init__(parser) + self.copyFrom(ctx) + + def NULL(self): + return self.getToken(ASLParser.NULL, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterPayload_value_null" ): + listener.enterPayload_value_null(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitPayload_value_null" ): + listener.exitPayload_value_null(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitPayload_value_null" ): + return visitor.visitPayload_value_null(self) + else: + return visitor.visitChildren(self) + + + + def payload_value_lit(self): + + localctx = ASLParser.Payload_value_litContext(self, self._ctx, self.state) + self.enterRule(localctx, 72, self.RULE_payload_value_lit) + self._la = 0 # Token type + try: + self.state = 539 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [161]: + localctx = ASLParser.Payload_value_floatContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 534 + self.match(ASLParser.NUMBER) + pass + elif token in [160]: + localctx = ASLParser.Payload_value_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 535 + self.match(ASLParser.INT) + pass + elif token in [7, 8]: + localctx = ASLParser.Payload_value_boolContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 536 + _la = self._input.LA(1) + if not(_la==7 or _la==8): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + pass + elif token in [9]: + localctx = ASLParser.Payload_value_nullContext(self, localctx) + self.enterOuterAlt(localctx, 4) + self.state = 537 + self.match(ASLParser.NULL) + pass + elif token in [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159]: + localctx = ASLParser.Payload_value_strContext(self, localctx) + self.enterOuterAlt(localctx, 5) + self.state = 538 + self.string_literal() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ASSIGN(self): + return self.getToken(ASLParser.ASSIGN, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def assign_decl_body(self): + return self.getTypedRuleContext(ASLParser.Assign_decl_bodyContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_assign_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_decl" ): + listener.enterAssign_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_decl" ): + listener.exitAssign_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_decl" ): + return visitor.visitAssign_decl(self) + else: + return visitor.visitChildren(self) + + + + + def assign_decl(self): + + localctx = ASLParser.Assign_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 74, self.RULE_assign_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 541 + self.match(ASLParser.ASSIGN) + self.state = 542 + self.match(ASLParser.COLON) + self.state = 543 + self.assign_decl_body() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_decl_bodyContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def assign_decl_binding(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Assign_decl_bindingContext) + else: + return self.getTypedRuleContext(ASLParser.Assign_decl_bindingContext,i) + + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_assign_decl_body + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_decl_body" ): + listener.enterAssign_decl_body(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_decl_body" ): + listener.exitAssign_decl_body(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_decl_body" ): + return visitor.visitAssign_decl_body(self) + else: + return visitor.visitChildren(self) + + + + + def assign_decl_body(self): + + localctx = ASLParser.Assign_decl_bodyContext(self, self._ctx, self.state) + self.enterRule(localctx, 76, self.RULE_assign_decl_body) + self._la = 0 # Token type + try: + self.state = 558 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,29,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 545 + self.match(ASLParser.LBRACE) + self.state = 546 + self.match(ASLParser.RBRACE) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 547 + self.match(ASLParser.LBRACE) + self.state = 548 + self.assign_decl_binding() + self.state = 553 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 549 + self.match(ASLParser.COMMA) + self.state = 550 + self.assign_decl_binding() + self.state = 555 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 556 + self.match(ASLParser.RBRACE) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_decl_bindingContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def assign_template_binding(self): + return self.getTypedRuleContext(ASLParser.Assign_template_bindingContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_assign_decl_binding + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_decl_binding" ): + listener.enterAssign_decl_binding(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_decl_binding" ): + listener.exitAssign_decl_binding(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_decl_binding" ): + return visitor.visitAssign_decl_binding(self) + else: + return visitor.visitChildren(self) + + + + + def assign_decl_binding(self): + + localctx = ASLParser.Assign_decl_bindingContext(self, self._ctx, self.state) + self.enterRule(localctx, 78, self.RULE_assign_decl_binding) + try: + self.enterOuterAlt(localctx, 1) + self.state = 560 + self.assign_template_binding() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_template_value_objectContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def assign_template_binding(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Assign_template_bindingContext) + else: + return self.getTypedRuleContext(ASLParser.Assign_template_bindingContext,i) + + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_assign_template_value_object + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_object" ): + listener.enterAssign_template_value_object(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_object" ): + listener.exitAssign_template_value_object(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_object" ): + return visitor.visitAssign_template_value_object(self) + else: + return visitor.visitChildren(self) + + + + + def assign_template_value_object(self): + + localctx = ASLParser.Assign_template_value_objectContext(self, self._ctx, self.state) + self.enterRule(localctx, 80, self.RULE_assign_template_value_object) + self._la = 0 # Token type + try: + self.state = 575 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,31,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 562 + self.match(ASLParser.LBRACE) + self.state = 563 + self.match(ASLParser.RBRACE) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 564 + self.match(ASLParser.LBRACE) + self.state = 565 + self.assign_template_binding() + self.state = 570 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 566 + self.match(ASLParser.COMMA) + self.state = 567 + self.assign_template_binding() + self.state = 572 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 573 + self.match(ASLParser.RBRACE) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_template_bindingContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_assign_template_binding + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Assign_template_binding_valueContext(Assign_template_bindingContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_bindingContext + super().__init__(parser) + self.copyFrom(ctx) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def assign_template_value(self): + return self.getTypedRuleContext(ASLParser.Assign_template_valueContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_binding_value" ): + listener.enterAssign_template_binding_value(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_binding_value" ): + listener.exitAssign_template_binding_value(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_binding_value" ): + return visitor.visitAssign_template_binding_value(self) + else: + return visitor.visitChildren(self) + + + class Assign_template_binding_string_expression_simpleContext(Assign_template_bindingContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_bindingContext + super().__init__(parser) + self.copyFrom(ctx) + + def STRINGDOLLAR(self): + return self.getToken(ASLParser.STRINGDOLLAR, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_expression_simple(self): + return self.getTypedRuleContext(ASLParser.String_expression_simpleContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_binding_string_expression_simple" ): + listener.enterAssign_template_binding_string_expression_simple(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_binding_string_expression_simple" ): + listener.exitAssign_template_binding_string_expression_simple(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_binding_string_expression_simple" ): + return visitor.visitAssign_template_binding_string_expression_simple(self) + else: + return visitor.visitChildren(self) + + + + def assign_template_binding(self): + + localctx = ASLParser.Assign_template_bindingContext(self, self._ctx, self.state) + self.enterRule(localctx, 82, self.RULE_assign_template_binding) + try: + self.state = 584 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,32,self._ctx) + if la_ == 1: + localctx = ASLParser.Assign_template_binding_string_expression_simpleContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 577 + self.match(ASLParser.STRINGDOLLAR) + self.state = 578 + self.match(ASLParser.COLON) + self.state = 579 + self.string_expression_simple() + pass + + elif la_ == 2: + localctx = ASLParser.Assign_template_binding_valueContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 580 + self.string_literal() + self.state = 581 + self.match(ASLParser.COLON) + self.state = 582 + self.assign_template_value() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_template_valueContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def assign_template_value_object(self): + return self.getTypedRuleContext(ASLParser.Assign_template_value_objectContext,0) + + + def assign_template_value_array(self): + return self.getTypedRuleContext(ASLParser.Assign_template_value_arrayContext,0) + + + def assign_template_value_terminal(self): + return self.getTypedRuleContext(ASLParser.Assign_template_value_terminalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_assign_template_value + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value" ): + listener.enterAssign_template_value(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value" ): + listener.exitAssign_template_value(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value" ): + return visitor.visitAssign_template_value(self) + else: + return visitor.visitChildren(self) + + + + + def assign_template_value(self): + + localctx = ASLParser.Assign_template_valueContext(self, self._ctx, self.state) + self.enterRule(localctx, 84, self.RULE_assign_template_value) + try: + self.state = 589 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [5]: + self.enterOuterAlt(localctx, 1) + self.state = 586 + self.assign_template_value_object() + pass + elif token in [3]: + self.enterOuterAlt(localctx, 2) + self.state = 587 + self.assign_template_value_array() + pass + elif token in [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161]: + self.enterOuterAlt(localctx, 3) + self.state = 588 + self.assign_template_value_terminal() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_template_value_arrayContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def assign_template_value(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Assign_template_valueContext) + else: + return self.getTypedRuleContext(ASLParser.Assign_template_valueContext,i) + + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_assign_template_value_array + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_array" ): + listener.enterAssign_template_value_array(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_array" ): + listener.exitAssign_template_value_array(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_array" ): + return visitor.visitAssign_template_value_array(self) + else: + return visitor.visitChildren(self) + + + + + def assign_template_value_array(self): + + localctx = ASLParser.Assign_template_value_arrayContext(self, self._ctx, self.state) + self.enterRule(localctx, 86, self.RULE_assign_template_value_array) + self._la = 0 # Token type + try: + self.state = 604 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,35,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 591 + self.match(ASLParser.LBRACK) + self.state = 592 + self.match(ASLParser.RBRACK) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 593 + self.match(ASLParser.LBRACK) + self.state = 594 + self.assign_template_value() + self.state = 599 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 595 + self.match(ASLParser.COMMA) + self.state = 596 + self.assign_template_value() + self.state = 601 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 602 + self.match(ASLParser.RBRACK) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Assign_template_value_terminalContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_assign_template_value_terminal + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Assign_template_value_terminal_nullContext(Assign_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def NULL(self): + return self.getToken(ASLParser.NULL, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_terminal_null" ): + listener.enterAssign_template_value_terminal_null(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_terminal_null" ): + listener.exitAssign_template_value_terminal_null(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_terminal_null" ): + return visitor.visitAssign_template_value_terminal_null(self) + else: + return visitor.visitChildren(self) + + + class Assign_template_value_terminal_string_literalContext(Assign_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_terminal_string_literal" ): + listener.enterAssign_template_value_terminal_string_literal(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_terminal_string_literal" ): + listener.exitAssign_template_value_terminal_string_literal(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_terminal_string_literal" ): + return visitor.visitAssign_template_value_terminal_string_literal(self) + else: + return visitor.visitChildren(self) + + + class Assign_template_value_terminal_intContext(Assign_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_terminal_int" ): + listener.enterAssign_template_value_terminal_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_terminal_int" ): + listener.exitAssign_template_value_terminal_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_terminal_int" ): + return visitor.visitAssign_template_value_terminal_int(self) + else: + return visitor.visitChildren(self) + + + class Assign_template_value_terminal_boolContext(Assign_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def TRUE(self): + return self.getToken(ASLParser.TRUE, 0) + def FALSE(self): + return self.getToken(ASLParser.FALSE, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_terminal_bool" ): + listener.enterAssign_template_value_terminal_bool(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_terminal_bool" ): + listener.exitAssign_template_value_terminal_bool(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_terminal_bool" ): + return visitor.visitAssign_template_value_terminal_bool(self) + else: + return visitor.visitChildren(self) + + + class Assign_template_value_terminal_floatContext(Assign_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def NUMBER(self): + return self.getToken(ASLParser.NUMBER, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_terminal_float" ): + listener.enterAssign_template_value_terminal_float(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_terminal_float" ): + listener.exitAssign_template_value_terminal_float(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_terminal_float" ): + return visitor.visitAssign_template_value_terminal_float(self) + else: + return visitor.visitChildren(self) + + + class Assign_template_value_terminal_string_jsonataContext(Assign_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Assign_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterAssign_template_value_terminal_string_jsonata" ): + listener.enterAssign_template_value_terminal_string_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitAssign_template_value_terminal_string_jsonata" ): + listener.exitAssign_template_value_terminal_string_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitAssign_template_value_terminal_string_jsonata" ): + return visitor.visitAssign_template_value_terminal_string_jsonata(self) + else: + return visitor.visitChildren(self) + + + + def assign_template_value_terminal(self): + + localctx = ASLParser.Assign_template_value_terminalContext(self, self._ctx, self.state) + self.enterRule(localctx, 88, self.RULE_assign_template_value_terminal) + self._la = 0 # Token type + try: + self.state = 612 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,36,self._ctx) + if la_ == 1: + localctx = ASLParser.Assign_template_value_terminal_floatContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 606 + self.match(ASLParser.NUMBER) + pass + + elif la_ == 2: + localctx = ASLParser.Assign_template_value_terminal_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 607 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Assign_template_value_terminal_boolContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 608 + _la = self._input.LA(1) + if not(_la==7 or _la==8): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + pass + + elif la_ == 4: + localctx = ASLParser.Assign_template_value_terminal_nullContext(self, localctx) + self.enterOuterAlt(localctx, 4) + self.state = 609 + self.match(ASLParser.NULL) + pass + + elif la_ == 5: + localctx = ASLParser.Assign_template_value_terminal_string_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 5) + self.state = 610 + self.string_jsonata() + pass + + elif la_ == 6: + localctx = ASLParser.Assign_template_value_terminal_string_literalContext(self, localctx) + self.enterOuterAlt(localctx, 6) + self.state = 611 + self.string_literal() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Arguments_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_arguments_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Arguments_string_jsonataContext(Arguments_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Arguments_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ARGUMENTS(self): + return self.getToken(ASLParser.ARGUMENTS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterArguments_string_jsonata" ): + listener.enterArguments_string_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitArguments_string_jsonata" ): + listener.exitArguments_string_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitArguments_string_jsonata" ): + return visitor.visitArguments_string_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Arguments_jsonata_template_value_objectContext(Arguments_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Arguments_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def ARGUMENTS(self): + return self.getToken(ASLParser.ARGUMENTS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def jsonata_template_value_object(self): + return self.getTypedRuleContext(ASLParser.Jsonata_template_value_objectContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterArguments_jsonata_template_value_object" ): + listener.enterArguments_jsonata_template_value_object(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitArguments_jsonata_template_value_object" ): + listener.exitArguments_jsonata_template_value_object(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitArguments_jsonata_template_value_object" ): + return visitor.visitArguments_jsonata_template_value_object(self) + else: + return visitor.visitChildren(self) + + + + def arguments_decl(self): + + localctx = ASLParser.Arguments_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 90, self.RULE_arguments_decl) + try: + self.state = 620 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,37,self._ctx) + if la_ == 1: + localctx = ASLParser.Arguments_jsonata_template_value_objectContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 614 + self.match(ASLParser.ARGUMENTS) + self.state = 615 + self.match(ASLParser.COLON) + self.state = 616 + self.jsonata_template_value_object() + pass + + elif la_ == 2: + localctx = ASLParser.Arguments_string_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 617 + self.match(ASLParser.ARGUMENTS) + self.state = 618 + self.match(ASLParser.COLON) + self.state = 619 + self.string_jsonata() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Output_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def OUTPUT(self): + return self.getToken(ASLParser.OUTPUT, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def jsonata_template_value(self): + return self.getTypedRuleContext(ASLParser.Jsonata_template_valueContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_output_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterOutput_decl" ): + listener.enterOutput_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitOutput_decl" ): + listener.exitOutput_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitOutput_decl" ): + return visitor.visitOutput_decl(self) + else: + return visitor.visitChildren(self) + + + + + def output_decl(self): + + localctx = ASLParser.Output_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 92, self.RULE_output_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 622 + self.match(ASLParser.OUTPUT) + self.state = 623 + self.match(ASLParser.COLON) + self.state = 624 + self.jsonata_template_value() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Jsonata_template_value_objectContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def jsonata_template_binding(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Jsonata_template_bindingContext) + else: + return self.getTypedRuleContext(ASLParser.Jsonata_template_bindingContext,i) + + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_jsonata_template_value_object + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_object" ): + listener.enterJsonata_template_value_object(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_object" ): + listener.exitJsonata_template_value_object(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_object" ): + return visitor.visitJsonata_template_value_object(self) + else: + return visitor.visitChildren(self) + + + + + def jsonata_template_value_object(self): + + localctx = ASLParser.Jsonata_template_value_objectContext(self, self._ctx, self.state) + self.enterRule(localctx, 94, self.RULE_jsonata_template_value_object) + self._la = 0 # Token type + try: + self.state = 639 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,39,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 626 + self.match(ASLParser.LBRACE) + self.state = 627 + self.match(ASLParser.RBRACE) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 628 + self.match(ASLParser.LBRACE) + self.state = 629 + self.jsonata_template_binding() + self.state = 634 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 630 + self.match(ASLParser.COMMA) + self.state = 631 + self.jsonata_template_binding() + self.state = 636 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 637 + self.match(ASLParser.RBRACE) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Jsonata_template_bindingContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def jsonata_template_value(self): + return self.getTypedRuleContext(ASLParser.Jsonata_template_valueContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_jsonata_template_binding + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_binding" ): + listener.enterJsonata_template_binding(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_binding" ): + listener.exitJsonata_template_binding(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_binding" ): + return visitor.visitJsonata_template_binding(self) + else: + return visitor.visitChildren(self) + + + + + def jsonata_template_binding(self): + + localctx = ASLParser.Jsonata_template_bindingContext(self, self._ctx, self.state) + self.enterRule(localctx, 96, self.RULE_jsonata_template_binding) + try: + self.enterOuterAlt(localctx, 1) + self.state = 641 + self.string_literal() + self.state = 642 + self.match(ASLParser.COLON) + self.state = 643 + self.jsonata_template_value() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Jsonata_template_valueContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def jsonata_template_value_object(self): + return self.getTypedRuleContext(ASLParser.Jsonata_template_value_objectContext,0) + + + def jsonata_template_value_array(self): + return self.getTypedRuleContext(ASLParser.Jsonata_template_value_arrayContext,0) + + + def jsonata_template_value_terminal(self): + return self.getTypedRuleContext(ASLParser.Jsonata_template_value_terminalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_jsonata_template_value + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value" ): + listener.enterJsonata_template_value(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value" ): + listener.exitJsonata_template_value(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value" ): + return visitor.visitJsonata_template_value(self) + else: + return visitor.visitChildren(self) + + + + + def jsonata_template_value(self): + + localctx = ASLParser.Jsonata_template_valueContext(self, self._ctx, self.state) + self.enterRule(localctx, 98, self.RULE_jsonata_template_value) + try: + self.state = 648 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [5]: + self.enterOuterAlt(localctx, 1) + self.state = 645 + self.jsonata_template_value_object() + pass + elif token in [3]: + self.enterOuterAlt(localctx, 2) + self.state = 646 + self.jsonata_template_value_array() + pass + elif token in [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161]: + self.enterOuterAlt(localctx, 3) + self.state = 647 + self.jsonata_template_value_terminal() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Jsonata_template_value_arrayContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def jsonata_template_value(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Jsonata_template_valueContext) + else: + return self.getTypedRuleContext(ASLParser.Jsonata_template_valueContext,i) + + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_jsonata_template_value_array + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_array" ): + listener.enterJsonata_template_value_array(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_array" ): + listener.exitJsonata_template_value_array(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_array" ): + return visitor.visitJsonata_template_value_array(self) + else: + return visitor.visitChildren(self) + + + + + def jsonata_template_value_array(self): + + localctx = ASLParser.Jsonata_template_value_arrayContext(self, self._ctx, self.state) + self.enterRule(localctx, 100, self.RULE_jsonata_template_value_array) + self._la = 0 # Token type + try: + self.state = 663 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,42,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 650 + self.match(ASLParser.LBRACK) + self.state = 651 + self.match(ASLParser.RBRACK) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 652 + self.match(ASLParser.LBRACK) + self.state = 653 + self.jsonata_template_value() + self.state = 658 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 654 + self.match(ASLParser.COMMA) + self.state = 655 + self.jsonata_template_value() + self.state = 660 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 661 + self.match(ASLParser.RBRACK) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Jsonata_template_value_terminalContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_jsonata_template_value_terminal + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Jsonata_template_value_terminal_boolContext(Jsonata_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Jsonata_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def TRUE(self): + return self.getToken(ASLParser.TRUE, 0) + def FALSE(self): + return self.getToken(ASLParser.FALSE, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_terminal_bool" ): + listener.enterJsonata_template_value_terminal_bool(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_terminal_bool" ): + listener.exitJsonata_template_value_terminal_bool(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_terminal_bool" ): + return visitor.visitJsonata_template_value_terminal_bool(self) + else: + return visitor.visitChildren(self) + + + class Jsonata_template_value_terminal_string_jsonataContext(Jsonata_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Jsonata_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_terminal_string_jsonata" ): + listener.enterJsonata_template_value_terminal_string_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_terminal_string_jsonata" ): + listener.exitJsonata_template_value_terminal_string_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_terminal_string_jsonata" ): + return visitor.visitJsonata_template_value_terminal_string_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Jsonata_template_value_terminal_intContext(Jsonata_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Jsonata_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_terminal_int" ): + listener.enterJsonata_template_value_terminal_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_terminal_int" ): + listener.exitJsonata_template_value_terminal_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_terminal_int" ): + return visitor.visitJsonata_template_value_terminal_int(self) + else: + return visitor.visitChildren(self) + + + class Jsonata_template_value_terminal_string_literalContext(Jsonata_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Jsonata_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_terminal_string_literal" ): + listener.enterJsonata_template_value_terminal_string_literal(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_terminal_string_literal" ): + listener.exitJsonata_template_value_terminal_string_literal(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_terminal_string_literal" ): + return visitor.visitJsonata_template_value_terminal_string_literal(self) + else: + return visitor.visitChildren(self) + + + class Jsonata_template_value_terminal_floatContext(Jsonata_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Jsonata_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def NUMBER(self): + return self.getToken(ASLParser.NUMBER, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_terminal_float" ): + listener.enterJsonata_template_value_terminal_float(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_terminal_float" ): + listener.exitJsonata_template_value_terminal_float(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_terminal_float" ): + return visitor.visitJsonata_template_value_terminal_float(self) + else: + return visitor.visitChildren(self) + + + class Jsonata_template_value_terminal_nullContext(Jsonata_template_value_terminalContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Jsonata_template_value_terminalContext + super().__init__(parser) + self.copyFrom(ctx) + + def NULL(self): + return self.getToken(ASLParser.NULL, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJsonata_template_value_terminal_null" ): + listener.enterJsonata_template_value_terminal_null(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJsonata_template_value_terminal_null" ): + listener.exitJsonata_template_value_terminal_null(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJsonata_template_value_terminal_null" ): + return visitor.visitJsonata_template_value_terminal_null(self) + else: + return visitor.visitChildren(self) + + + + def jsonata_template_value_terminal(self): + + localctx = ASLParser.Jsonata_template_value_terminalContext(self, self._ctx, self.state) + self.enterRule(localctx, 102, self.RULE_jsonata_template_value_terminal) + self._la = 0 # Token type + try: + self.state = 671 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,43,self._ctx) + if la_ == 1: + localctx = ASLParser.Jsonata_template_value_terminal_floatContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 665 + self.match(ASLParser.NUMBER) + pass + + elif la_ == 2: + localctx = ASLParser.Jsonata_template_value_terminal_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 666 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Jsonata_template_value_terminal_boolContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 667 + _la = self._input.LA(1) + if not(_la==7 or _la==8): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + pass + + elif la_ == 4: + localctx = ASLParser.Jsonata_template_value_terminal_nullContext(self, localctx) + self.enterOuterAlt(localctx, 4) + self.state = 668 + self.match(ASLParser.NULL) + pass + + elif la_ == 5: + localctx = ASLParser.Jsonata_template_value_terminal_string_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 5) + self.state = 669 + self.string_jsonata() + pass + + elif la_ == 6: + localctx = ASLParser.Jsonata_template_value_terminal_string_literalContext(self, localctx) + self.enterOuterAlt(localctx, 6) + self.state = 670 + self.string_literal() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Result_selector_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def RESULTSELECTOR(self): + return self.getToken(ASLParser.RESULTSELECTOR, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def payload_tmpl_decl(self): + return self.getTypedRuleContext(ASLParser.Payload_tmpl_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_result_selector_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterResult_selector_decl" ): + listener.enterResult_selector_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitResult_selector_decl" ): + listener.exitResult_selector_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitResult_selector_decl" ): + return visitor.visitResult_selector_decl(self) + else: + return visitor.visitChildren(self) + + + + + def result_selector_decl(self): + + localctx = ASLParser.Result_selector_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 104, self.RULE_result_selector_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 673 + self.match(ASLParser.RESULTSELECTOR) + self.state = 674 + self.match(ASLParser.COLON) + self.state = 675 + self.payload_tmpl_decl() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class State_typeContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def TASK(self): + return self.getToken(ASLParser.TASK, 0) + + def PASS(self): + return self.getToken(ASLParser.PASS, 0) + + def CHOICE(self): + return self.getToken(ASLParser.CHOICE, 0) + + def FAIL(self): + return self.getToken(ASLParser.FAIL, 0) + + def SUCCEED(self): + return self.getToken(ASLParser.SUCCEED, 0) + + def WAIT(self): + return self.getToken(ASLParser.WAIT, 0) + + def MAP(self): + return self.getToken(ASLParser.MAP, 0) + + def PARALLEL(self): + return self.getToken(ASLParser.PARALLEL, 0) + + def getRuleIndex(self): + return ASLParser.RULE_state_type + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterState_type" ): + listener.enterState_type(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitState_type" ): + listener.exitState_type(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitState_type" ): + return visitor.visitState_type(self) + else: + return visitor.visitChildren(self) + + + + + def state_type(self): + + localctx = ASLParser.State_typeContext(self, self._ctx, self.state) + self.enterRule(localctx, 106, self.RULE_state_type) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 677 + _la = self._input.LA(1) + if not((((_la) & ~0x3f) == 0 and ((1 << _la) & 16711680) != 0)): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Choices_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def CHOICES(self): + return self.getToken(ASLParser.CHOICES, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def choice_rule(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Choice_ruleContext) + else: + return self.getTypedRuleContext(ASLParser.Choice_ruleContext,i) + + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_choices_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterChoices_decl" ): + listener.enterChoices_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitChoices_decl" ): + listener.exitChoices_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitChoices_decl" ): + return visitor.visitChoices_decl(self) + else: + return visitor.visitChildren(self) + + + + + def choices_decl(self): + + localctx = ASLParser.Choices_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 108, self.RULE_choices_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 679 + self.match(ASLParser.CHOICES) + self.state = 680 + self.match(ASLParser.COLON) + self.state = 681 + self.match(ASLParser.LBRACK) + self.state = 682 + self.choice_rule() + self.state = 687 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 683 + self.match(ASLParser.COMMA) + self.state = 684 + self.choice_rule() + self.state = 689 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 690 + self.match(ASLParser.RBRACK) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Choice_ruleContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_choice_rule + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Choice_rule_comparison_variableContext(Choice_ruleContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Choice_ruleContext + super().__init__(parser) + self.copyFrom(ctx) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + def comparison_variable_stmt(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Comparison_variable_stmtContext) + else: + return self.getTypedRuleContext(ASLParser.Comparison_variable_stmtContext,i) + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterChoice_rule_comparison_variable" ): + listener.enterChoice_rule_comparison_variable(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitChoice_rule_comparison_variable" ): + listener.exitChoice_rule_comparison_variable(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitChoice_rule_comparison_variable" ): + return visitor.visitChoice_rule_comparison_variable(self) + else: + return visitor.visitChildren(self) + + + class Choice_rule_comparison_compositeContext(Choice_ruleContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Choice_ruleContext + super().__init__(parser) + self.copyFrom(ctx) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + def comparison_composite_stmt(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Comparison_composite_stmtContext) + else: + return self.getTypedRuleContext(ASLParser.Comparison_composite_stmtContext,i) + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterChoice_rule_comparison_composite" ): + listener.enterChoice_rule_comparison_composite(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitChoice_rule_comparison_composite" ): + listener.exitChoice_rule_comparison_composite(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitChoice_rule_comparison_composite" ): + return visitor.visitChoice_rule_comparison_composite(self) + else: + return visitor.visitChildren(self) + + + + def choice_rule(self): + + localctx = ASLParser.Choice_ruleContext(self, self._ctx, self.state) + self.enterRule(localctx, 110, self.RULE_choice_rule) + self._la = 0 # Token type + try: + self.state = 713 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,47,self._ctx) + if la_ == 1: + localctx = ASLParser.Choice_rule_comparison_variableContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 692 + self.match(ASLParser.LBRACE) + self.state = 693 + self.comparison_variable_stmt() + self.state = 696 + self._errHandler.sync(self) + _la = self._input.LA(1) + while True: + self.state = 694 + self.match(ASLParser.COMMA) + self.state = 695 + self.comparison_variable_stmt() + self.state = 698 + self._errHandler.sync(self) + _la = self._input.LA(1) + if not (_la==1): + break + + self.state = 700 + self.match(ASLParser.RBRACE) + pass + + elif la_ == 2: + localctx = ASLParser.Choice_rule_comparison_compositeContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 702 + self.match(ASLParser.LBRACE) + self.state = 703 + self.comparison_composite_stmt() + self.state = 708 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 704 + self.match(ASLParser.COMMA) + self.state = 705 + self.comparison_composite_stmt() + self.state = 710 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 711 + self.match(ASLParser.RBRACE) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Comparison_variable_stmtContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def variable_decl(self): + return self.getTypedRuleContext(ASLParser.Variable_declContext,0) + + + def comparison_func(self): + return self.getTypedRuleContext(ASLParser.Comparison_funcContext,0) + + + def next_decl(self): + return self.getTypedRuleContext(ASLParser.Next_declContext,0) + + + def assign_decl(self): + return self.getTypedRuleContext(ASLParser.Assign_declContext,0) + + + def output_decl(self): + return self.getTypedRuleContext(ASLParser.Output_declContext,0) + + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_comparison_variable_stmt + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterComparison_variable_stmt" ): + listener.enterComparison_variable_stmt(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitComparison_variable_stmt" ): + listener.exitComparison_variable_stmt(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitComparison_variable_stmt" ): + return visitor.visitComparison_variable_stmt(self) + else: + return visitor.visitChildren(self) + + + + + def comparison_variable_stmt(self): + + localctx = ASLParser.Comparison_variable_stmtContext(self, self._ctx, self.state) + self.enterRule(localctx, 112, self.RULE_comparison_variable_stmt) + try: + self.state = 721 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [26]: + self.enterOuterAlt(localctx, 1) + self.state = 715 + self.variable_decl() + pass + elif token in [25, 30, 31, 32, 33, 34, 35, 36, 37, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70]: + self.enterOuterAlt(localctx, 2) + self.state = 716 + self.comparison_func() + pass + elif token in [115]: + self.enterOuterAlt(localctx, 3) + self.state = 717 + self.next_decl() + pass + elif token in [134]: + self.enterOuterAlt(localctx, 4) + self.state = 718 + self.assign_decl() + pass + elif token in [135]: + self.enterOuterAlt(localctx, 5) + self.state = 719 + self.output_decl() + pass + elif token in [10]: + self.enterOuterAlt(localctx, 6) + self.state = 720 + self.comment_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Comparison_composite_stmtContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def comparison_composite(self): + return self.getTypedRuleContext(ASLParser.Comparison_compositeContext,0) + + + def next_decl(self): + return self.getTypedRuleContext(ASLParser.Next_declContext,0) + + + def assign_decl(self): + return self.getTypedRuleContext(ASLParser.Assign_declContext,0) + + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_comparison_composite_stmt + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterComparison_composite_stmt" ): + listener.enterComparison_composite_stmt(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitComparison_composite_stmt" ): + listener.exitComparison_composite_stmt(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitComparison_composite_stmt" ): + return visitor.visitComparison_composite_stmt(self) + else: + return visitor.visitChildren(self) + + + + + def comparison_composite_stmt(self): + + localctx = ASLParser.Comparison_composite_stmtContext(self, self._ctx, self.state) + self.enterRule(localctx, 114, self.RULE_comparison_composite_stmt) + try: + self.state = 727 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [29, 38, 49]: + self.enterOuterAlt(localctx, 1) + self.state = 723 + self.comparison_composite() + pass + elif token in [115]: + self.enterOuterAlt(localctx, 2) + self.state = 724 + self.next_decl() + pass + elif token in [134]: + self.enterOuterAlt(localctx, 3) + self.state = 725 + self.assign_decl() + pass + elif token in [10]: + self.enterOuterAlt(localctx, 4) + self.state = 726 + self.comment_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Comparison_compositeContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def choice_operator(self): + return self.getTypedRuleContext(ASLParser.Choice_operatorContext,0) + + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def choice_rule(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Choice_ruleContext) + else: + return self.getTypedRuleContext(ASLParser.Choice_ruleContext,i) + + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_comparison_composite + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterComparison_composite" ): + listener.enterComparison_composite(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitComparison_composite" ): + listener.exitComparison_composite(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitComparison_composite" ): + return visitor.visitComparison_composite(self) + else: + return visitor.visitChildren(self) + + + + + def comparison_composite(self): + + localctx = ASLParser.Comparison_compositeContext(self, self._ctx, self.state) + self.enterRule(localctx, 116, self.RULE_comparison_composite) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 729 + self.choice_operator() + self.state = 730 + self.match(ASLParser.COLON) + self.state = 743 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [5]: + self.state = 731 + self.choice_rule() + pass + elif token in [3]: + self.state = 732 + self.match(ASLParser.LBRACK) + self.state = 733 + self.choice_rule() + self.state = 738 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 734 + self.match(ASLParser.COMMA) + self.state = 735 + self.choice_rule() + self.state = 740 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 741 + self.match(ASLParser.RBRACK) + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Variable_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def VARIABLE(self): + return self.getToken(ASLParser.VARIABLE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_variable_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterVariable_decl" ): + listener.enterVariable_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitVariable_decl" ): + listener.exitVariable_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitVariable_decl" ): + return visitor.visitVariable_decl(self) + else: + return visitor.visitChildren(self) + + + + + def variable_decl(self): + + localctx = ASLParser.Variable_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 118, self.RULE_variable_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 745 + self.match(ASLParser.VARIABLE) + self.state = 746 + self.match(ASLParser.COLON) + self.state = 747 + self.string_sampler() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Comparison_funcContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_comparison_func + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Condition_string_jsonataContext(Comparison_funcContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Comparison_funcContext + super().__init__(parser) + self.copyFrom(ctx) + + def CONDITION(self): + return self.getToken(ASLParser.CONDITION, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCondition_string_jsonata" ): + listener.enterCondition_string_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCondition_string_jsonata" ): + listener.exitCondition_string_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCondition_string_jsonata" ): + return visitor.visitCondition_string_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Comparison_func_string_variable_sampleContext(Comparison_funcContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Comparison_funcContext + super().__init__(parser) + self.copyFrom(ctx) + + def comparison_op(self): + return self.getTypedRuleContext(ASLParser.Comparison_opContext,0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_variable_sample(self): + return self.getTypedRuleContext(ASLParser.String_variable_sampleContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterComparison_func_string_variable_sample" ): + listener.enterComparison_func_string_variable_sample(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitComparison_func_string_variable_sample" ): + listener.exitComparison_func_string_variable_sample(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitComparison_func_string_variable_sample" ): + return visitor.visitComparison_func_string_variable_sample(self) + else: + return visitor.visitChildren(self) + + + class Condition_litContext(Comparison_funcContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Comparison_funcContext + super().__init__(parser) + self.copyFrom(ctx) + + def CONDITION(self): + return self.getToken(ASLParser.CONDITION, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def TRUE(self): + return self.getToken(ASLParser.TRUE, 0) + def FALSE(self): + return self.getToken(ASLParser.FALSE, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCondition_lit" ): + listener.enterCondition_lit(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCondition_lit" ): + listener.exitCondition_lit(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCondition_lit" ): + return visitor.visitCondition_lit(self) + else: + return visitor.visitChildren(self) + + + class Comparison_func_valueContext(Comparison_funcContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Comparison_funcContext + super().__init__(parser) + self.copyFrom(ctx) + + def comparison_op(self): + return self.getTypedRuleContext(ASLParser.Comparison_opContext,0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def json_value_decl(self): + return self.getTypedRuleContext(ASLParser.Json_value_declContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterComparison_func_value" ): + listener.enterComparison_func_value(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitComparison_func_value" ): + listener.exitComparison_func_value(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitComparison_func_value" ): + return visitor.visitComparison_func_value(self) + else: + return visitor.visitChildren(self) + + + + def comparison_func(self): + + localctx = ASLParser.Comparison_funcContext(self, self._ctx, self.state) + self.enterRule(localctx, 120, self.RULE_comparison_func) + self._la = 0 # Token type + try: + self.state = 763 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,52,self._ctx) + if la_ == 1: + localctx = ASLParser.Condition_litContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 749 + self.match(ASLParser.CONDITION) + self.state = 750 + self.match(ASLParser.COLON) + self.state = 751 + _la = self._input.LA(1) + if not(_la==7 or _la==8): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + pass + + elif la_ == 2: + localctx = ASLParser.Condition_string_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 752 + self.match(ASLParser.CONDITION) + self.state = 753 + self.match(ASLParser.COLON) + self.state = 754 + self.string_jsonata() + pass + + elif la_ == 3: + localctx = ASLParser.Comparison_func_string_variable_sampleContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 755 + self.comparison_op() + self.state = 756 + self.match(ASLParser.COLON) + self.state = 757 + self.string_variable_sample() + pass + + elif la_ == 4: + localctx = ASLParser.Comparison_func_valueContext(self, localctx) + self.enterOuterAlt(localctx, 4) + self.state = 759 + self.comparison_op() + self.state = 760 + self.match(ASLParser.COLON) + self.state = 761 + self.json_value_decl() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Branches_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def BRANCHES(self): + return self.getToken(ASLParser.BRANCHES, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def program_decl(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Program_declContext) + else: + return self.getTypedRuleContext(ASLParser.Program_declContext,i) + + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_branches_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterBranches_decl" ): + listener.enterBranches_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitBranches_decl" ): + listener.exitBranches_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitBranches_decl" ): + return visitor.visitBranches_decl(self) + else: + return visitor.visitChildren(self) + + + + + def branches_decl(self): + + localctx = ASLParser.Branches_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 122, self.RULE_branches_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 765 + self.match(ASLParser.BRANCHES) + self.state = 766 + self.match(ASLParser.COLON) + self.state = 767 + self.match(ASLParser.LBRACK) + self.state = 768 + self.program_decl() + self.state = 773 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 769 + self.match(ASLParser.COMMA) + self.state = 770 + self.program_decl() + self.state = 775 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 776 + self.match(ASLParser.RBRACK) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Item_processor_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ITEMPROCESSOR(self): + return self.getToken(ASLParser.ITEMPROCESSOR, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def item_processor_item(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Item_processor_itemContext) + else: + return self.getTypedRuleContext(ASLParser.Item_processor_itemContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_item_processor_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItem_processor_decl" ): + listener.enterItem_processor_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItem_processor_decl" ): + listener.exitItem_processor_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItem_processor_decl" ): + return visitor.visitItem_processor_decl(self) + else: + return visitor.visitChildren(self) + + + + + def item_processor_decl(self): + + localctx = ASLParser.Item_processor_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 124, self.RULE_item_processor_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 778 + self.match(ASLParser.ITEMPROCESSOR) + self.state = 779 + self.match(ASLParser.COLON) + self.state = 780 + self.match(ASLParser.LBRACE) + self.state = 781 + self.item_processor_item() + self.state = 786 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 782 + self.match(ASLParser.COMMA) + self.state = 783 + self.item_processor_item() + self.state = 788 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 789 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Item_processor_itemContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def processor_config_decl(self): + return self.getTypedRuleContext(ASLParser.Processor_config_declContext,0) + + + def startat_decl(self): + return self.getTypedRuleContext(ASLParser.Startat_declContext,0) + + + def states_decl(self): + return self.getTypedRuleContext(ASLParser.States_declContext,0) + + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_item_processor_item + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItem_processor_item" ): + listener.enterItem_processor_item(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItem_processor_item" ): + listener.exitItem_processor_item(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItem_processor_item" ): + return visitor.visitItem_processor_item(self) + else: + return visitor.visitChildren(self) + + + + + def item_processor_item(self): + + localctx = ASLParser.Item_processor_itemContext(self, self._ctx, self.state) + self.enterRule(localctx, 126, self.RULE_item_processor_item) + try: + self.state = 795 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [79]: + self.enterOuterAlt(localctx, 1) + self.state = 791 + self.processor_config_decl() + pass + elif token in [12]: + self.enterOuterAlt(localctx, 2) + self.state = 792 + self.startat_decl() + pass + elif token in [11]: + self.enterOuterAlt(localctx, 3) + self.state = 793 + self.states_decl() + pass + elif token in [10]: + self.enterOuterAlt(localctx, 4) + self.state = 794 + self.comment_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Processor_config_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def PROCESSORCONFIG(self): + return self.getToken(ASLParser.PROCESSORCONFIG, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def processor_config_field(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Processor_config_fieldContext) + else: + return self.getTypedRuleContext(ASLParser.Processor_config_fieldContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_processor_config_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterProcessor_config_decl" ): + listener.enterProcessor_config_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitProcessor_config_decl" ): + listener.exitProcessor_config_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitProcessor_config_decl" ): + return visitor.visitProcessor_config_decl(self) + else: + return visitor.visitChildren(self) + + + + + def processor_config_decl(self): + + localctx = ASLParser.Processor_config_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 128, self.RULE_processor_config_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 797 + self.match(ASLParser.PROCESSORCONFIG) + self.state = 798 + self.match(ASLParser.COLON) + self.state = 799 + self.match(ASLParser.LBRACE) + self.state = 800 + self.processor_config_field() + self.state = 805 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 801 + self.match(ASLParser.COMMA) + self.state = 802 + self.processor_config_field() + self.state = 807 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 808 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Processor_config_fieldContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def mode_decl(self): + return self.getTypedRuleContext(ASLParser.Mode_declContext,0) + + + def execution_decl(self): + return self.getTypedRuleContext(ASLParser.Execution_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_processor_config_field + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterProcessor_config_field" ): + listener.enterProcessor_config_field(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitProcessor_config_field" ): + listener.exitProcessor_config_field(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitProcessor_config_field" ): + return visitor.visitProcessor_config_field(self) + else: + return visitor.visitChildren(self) + + + + + def processor_config_field(self): + + localctx = ASLParser.Processor_config_fieldContext(self, self._ctx, self.state) + self.enterRule(localctx, 130, self.RULE_processor_config_field) + try: + self.state = 812 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [80]: + self.enterOuterAlt(localctx, 1) + self.state = 810 + self.mode_decl() + pass + elif token in [83]: + self.enterOuterAlt(localctx, 2) + self.state = 811 + self.execution_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Mode_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def MODE(self): + return self.getToken(ASLParser.MODE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def mode_type(self): + return self.getTypedRuleContext(ASLParser.Mode_typeContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_mode_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMode_decl" ): + listener.enterMode_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMode_decl" ): + listener.exitMode_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMode_decl" ): + return visitor.visitMode_decl(self) + else: + return visitor.visitChildren(self) + + + + + def mode_decl(self): + + localctx = ASLParser.Mode_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 132, self.RULE_mode_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 814 + self.match(ASLParser.MODE) + self.state = 815 + self.match(ASLParser.COLON) + self.state = 816 + self.mode_type() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Mode_typeContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def INLINE(self): + return self.getToken(ASLParser.INLINE, 0) + + def DISTRIBUTED(self): + return self.getToken(ASLParser.DISTRIBUTED, 0) + + def getRuleIndex(self): + return ASLParser.RULE_mode_type + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMode_type" ): + listener.enterMode_type(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMode_type" ): + listener.exitMode_type(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMode_type" ): + return visitor.visitMode_type(self) + else: + return visitor.visitChildren(self) + + + + + def mode_type(self): + + localctx = ASLParser.Mode_typeContext(self, self._ctx, self.state) + self.enterRule(localctx, 134, self.RULE_mode_type) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 818 + _la = self._input.LA(1) + if not(_la==81 or _la==82): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Execution_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def EXECUTIONTYPE(self): + return self.getToken(ASLParser.EXECUTIONTYPE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def execution_type(self): + return self.getTypedRuleContext(ASLParser.Execution_typeContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_execution_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterExecution_decl" ): + listener.enterExecution_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitExecution_decl" ): + listener.exitExecution_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitExecution_decl" ): + return visitor.visitExecution_decl(self) + else: + return visitor.visitChildren(self) + + + + + def execution_decl(self): + + localctx = ASLParser.Execution_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 136, self.RULE_execution_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 820 + self.match(ASLParser.EXECUTIONTYPE) + self.state = 821 + self.match(ASLParser.COLON) + self.state = 822 + self.execution_type() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Execution_typeContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STANDARD(self): + return self.getToken(ASLParser.STANDARD, 0) + + def getRuleIndex(self): + return ASLParser.RULE_execution_type + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterExecution_type" ): + listener.enterExecution_type(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitExecution_type" ): + listener.exitExecution_type(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitExecution_type" ): + return visitor.visitExecution_type(self) + else: + return visitor.visitChildren(self) + + + + + def execution_type(self): + + localctx = ASLParser.Execution_typeContext(self, self._ctx, self.state) + self.enterRule(localctx, 138, self.RULE_execution_type) + try: + self.enterOuterAlt(localctx, 1) + self.state = 824 + self.match(ASLParser.STANDARD) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Iterator_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ITERATOR(self): + return self.getToken(ASLParser.ITERATOR, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def iterator_decl_item(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Iterator_decl_itemContext) + else: + return self.getTypedRuleContext(ASLParser.Iterator_decl_itemContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_iterator_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterIterator_decl" ): + listener.enterIterator_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitIterator_decl" ): + listener.exitIterator_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitIterator_decl" ): + return visitor.visitIterator_decl(self) + else: + return visitor.visitChildren(self) + + + + + def iterator_decl(self): + + localctx = ASLParser.Iterator_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 140, self.RULE_iterator_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 826 + self.match(ASLParser.ITERATOR) + self.state = 827 + self.match(ASLParser.COLON) + self.state = 828 + self.match(ASLParser.LBRACE) + self.state = 829 + self.iterator_decl_item() + self.state = 834 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 830 + self.match(ASLParser.COMMA) + self.state = 831 + self.iterator_decl_item() + self.state = 836 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 837 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Iterator_decl_itemContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def startat_decl(self): + return self.getTypedRuleContext(ASLParser.Startat_declContext,0) + + + def states_decl(self): + return self.getTypedRuleContext(ASLParser.States_declContext,0) + + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def processor_config_decl(self): + return self.getTypedRuleContext(ASLParser.Processor_config_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_iterator_decl_item + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterIterator_decl_item" ): + listener.enterIterator_decl_item(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitIterator_decl_item" ): + listener.exitIterator_decl_item(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitIterator_decl_item" ): + return visitor.visitIterator_decl_item(self) + else: + return visitor.visitChildren(self) + + + + + def iterator_decl_item(self): + + localctx = ASLParser.Iterator_decl_itemContext(self, self._ctx, self.state) + self.enterRule(localctx, 142, self.RULE_iterator_decl_item) + try: + self.state = 843 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [12]: + self.enterOuterAlt(localctx, 1) + self.state = 839 + self.startat_decl() + pass + elif token in [11]: + self.enterOuterAlt(localctx, 2) + self.state = 840 + self.states_decl() + pass + elif token in [10]: + self.enterOuterAlt(localctx, 3) + self.state = 841 + self.comment_decl() + pass + elif token in [79]: + self.enterOuterAlt(localctx, 4) + self.state = 842 + self.processor_config_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Item_selector_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ITEMSELECTOR(self): + return self.getToken(ASLParser.ITEMSELECTOR, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def assign_template_value_object(self): + return self.getTypedRuleContext(ASLParser.Assign_template_value_objectContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_item_selector_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItem_selector_decl" ): + listener.enterItem_selector_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItem_selector_decl" ): + listener.exitItem_selector_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItem_selector_decl" ): + return visitor.visitItem_selector_decl(self) + else: + return visitor.visitChildren(self) + + + + + def item_selector_decl(self): + + localctx = ASLParser.Item_selector_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 144, self.RULE_item_selector_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 845 + self.match(ASLParser.ITEMSELECTOR) + self.state = 846 + self.match(ASLParser.COLON) + self.state = 847 + self.assign_template_value_object() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Item_reader_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ITEMREADER(self): + return self.getToken(ASLParser.ITEMREADER, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def items_reader_field(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Items_reader_fieldContext) + else: + return self.getTypedRuleContext(ASLParser.Items_reader_fieldContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_item_reader_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItem_reader_decl" ): + listener.enterItem_reader_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItem_reader_decl" ): + listener.exitItem_reader_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItem_reader_decl" ): + return visitor.visitItem_reader_decl(self) + else: + return visitor.visitChildren(self) + + + + + def item_reader_decl(self): + + localctx = ASLParser.Item_reader_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 146, self.RULE_item_reader_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 849 + self.match(ASLParser.ITEMREADER) + self.state = 850 + self.match(ASLParser.COLON) + self.state = 851 + self.match(ASLParser.LBRACE) + self.state = 852 + self.items_reader_field() + self.state = 857 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 853 + self.match(ASLParser.COMMA) + self.state = 854 + self.items_reader_field() + self.state = 859 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 860 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Items_reader_fieldContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def resource_decl(self): + return self.getTypedRuleContext(ASLParser.Resource_declContext,0) + + + def reader_config_decl(self): + return self.getTypedRuleContext(ASLParser.Reader_config_declContext,0) + + + def parameters_decl(self): + return self.getTypedRuleContext(ASLParser.Parameters_declContext,0) + + + def arguments_decl(self): + return self.getTypedRuleContext(ASLParser.Arguments_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_items_reader_field + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterItems_reader_field" ): + listener.enterItems_reader_field(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitItems_reader_field" ): + listener.exitItems_reader_field(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitItems_reader_field" ): + return visitor.visitItems_reader_field(self) + else: + return visitor.visitChildren(self) + + + + + def items_reader_field(self): + + localctx = ASLParser.Items_reader_fieldContext(self, self._ctx, self.state) + self.enterRule(localctx, 148, self.RULE_items_reader_field) + try: + self.state = 866 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [90]: + self.enterOuterAlt(localctx, 1) + self.state = 862 + self.resource_decl() + pass + elif token in [103]: + self.enterOuterAlt(localctx, 2) + self.state = 863 + self.reader_config_decl() + pass + elif token in [97]: + self.enterOuterAlt(localctx, 3) + self.state = 864 + self.parameters_decl() + pass + elif token in [136]: + self.enterOuterAlt(localctx, 4) + self.state = 865 + self.arguments_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Reader_config_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def READERCONFIG(self): + return self.getToken(ASLParser.READERCONFIG, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def reader_config_field(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Reader_config_fieldContext) + else: + return self.getTypedRuleContext(ASLParser.Reader_config_fieldContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_reader_config_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterReader_config_decl" ): + listener.enterReader_config_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitReader_config_decl" ): + listener.exitReader_config_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitReader_config_decl" ): + return visitor.visitReader_config_decl(self) + else: + return visitor.visitChildren(self) + + + + + def reader_config_decl(self): + + localctx = ASLParser.Reader_config_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 150, self.RULE_reader_config_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 868 + self.match(ASLParser.READERCONFIG) + self.state = 869 + self.match(ASLParser.COLON) + self.state = 870 + self.match(ASLParser.LBRACE) + self.state = 871 + self.reader_config_field() + self.state = 876 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 872 + self.match(ASLParser.COMMA) + self.state = 873 + self.reader_config_field() + self.state = 878 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 879 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Reader_config_fieldContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def input_type_decl(self): + return self.getTypedRuleContext(ASLParser.Input_type_declContext,0) + + + def csv_header_location_decl(self): + return self.getTypedRuleContext(ASLParser.Csv_header_location_declContext,0) + + + def csv_headers_decl(self): + return self.getTypedRuleContext(ASLParser.Csv_headers_declContext,0) + + + def max_items_decl(self): + return self.getTypedRuleContext(ASLParser.Max_items_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_reader_config_field + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterReader_config_field" ): + listener.enterReader_config_field(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitReader_config_field" ): + listener.exitReader_config_field(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitReader_config_field" ): + return visitor.visitReader_config_field(self) + else: + return visitor.visitChildren(self) + + + + + def reader_config_field(self): + + localctx = ASLParser.Reader_config_fieldContext(self, self._ctx, self.state) + self.enterRule(localctx, 152, self.RULE_reader_config_field) + try: + self.state = 885 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [104]: + self.enterOuterAlt(localctx, 1) + self.state = 881 + self.input_type_decl() + pass + elif token in [105]: + self.enterOuterAlt(localctx, 2) + self.state = 882 + self.csv_header_location_decl() + pass + elif token in [106]: + self.enterOuterAlt(localctx, 3) + self.state = 883 + self.csv_headers_decl() + pass + elif token in [107, 108]: + self.enterOuterAlt(localctx, 4) + self.state = 884 + self.max_items_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Input_type_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def INPUTTYPE(self): + return self.getToken(ASLParser.INPUTTYPE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_input_type_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterInput_type_decl" ): + listener.enterInput_type_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitInput_type_decl" ): + listener.exitInput_type_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitInput_type_decl" ): + return visitor.visitInput_type_decl(self) + else: + return visitor.visitChildren(self) + + + + + def input_type_decl(self): + + localctx = ASLParser.Input_type_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 154, self.RULE_input_type_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 887 + self.match(ASLParser.INPUTTYPE) + self.state = 888 + self.match(ASLParser.COLON) + self.state = 889 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Csv_header_location_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def CSVHEADERLOCATION(self): + return self.getToken(ASLParser.CSVHEADERLOCATION, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_csv_header_location_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCsv_header_location_decl" ): + listener.enterCsv_header_location_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCsv_header_location_decl" ): + listener.exitCsv_header_location_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCsv_header_location_decl" ): + return visitor.visitCsv_header_location_decl(self) + else: + return visitor.visitChildren(self) + + + + + def csv_header_location_decl(self): + + localctx = ASLParser.Csv_header_location_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 156, self.RULE_csv_header_location_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 891 + self.match(ASLParser.CSVHEADERLOCATION) + self.state = 892 + self.match(ASLParser.COLON) + self.state = 893 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Csv_headers_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def CSVHEADERS(self): + return self.getToken(ASLParser.CSVHEADERS, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def string_literal(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.String_literalContext) + else: + return self.getTypedRuleContext(ASLParser.String_literalContext,i) + + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_csv_headers_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCsv_headers_decl" ): + listener.enterCsv_headers_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCsv_headers_decl" ): + listener.exitCsv_headers_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCsv_headers_decl" ): + return visitor.visitCsv_headers_decl(self) + else: + return visitor.visitChildren(self) + + + + + def csv_headers_decl(self): + + localctx = ASLParser.Csv_headers_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 158, self.RULE_csv_headers_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 895 + self.match(ASLParser.CSVHEADERS) + self.state = 896 + self.match(ASLParser.COLON) + self.state = 897 + self.match(ASLParser.LBRACK) + self.state = 898 + self.string_literal() + self.state = 903 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 899 + self.match(ASLParser.COMMA) + self.state = 900 + self.string_literal() + self.state = 905 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 906 + self.match(ASLParser.RBRACK) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Max_items_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_max_items_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Max_items_string_jsonataContext(Max_items_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Max_items_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def MAXITEMS(self): + return self.getToken(ASLParser.MAXITEMS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_items_string_jsonata" ): + listener.enterMax_items_string_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_items_string_jsonata" ): + listener.exitMax_items_string_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_items_string_jsonata" ): + return visitor.visitMax_items_string_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Max_items_intContext(Max_items_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Max_items_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def MAXITEMS(self): + return self.getToken(ASLParser.MAXITEMS, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_items_int" ): + listener.enterMax_items_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_items_int" ): + listener.exitMax_items_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_items_int" ): + return visitor.visitMax_items_int(self) + else: + return visitor.visitChildren(self) + + + class Max_items_pathContext(Max_items_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Max_items_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def MAXITEMSPATH(self): + return self.getToken(ASLParser.MAXITEMSPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_items_path" ): + listener.enterMax_items_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_items_path" ): + listener.exitMax_items_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_items_path" ): + return visitor.visitMax_items_path(self) + else: + return visitor.visitChildren(self) + + + + def max_items_decl(self): + + localctx = ASLParser.Max_items_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 160, self.RULE_max_items_decl) + try: + self.state = 917 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,65,self._ctx) + if la_ == 1: + localctx = ASLParser.Max_items_string_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 908 + self.match(ASLParser.MAXITEMS) + self.state = 909 + self.match(ASLParser.COLON) + self.state = 910 + self.string_jsonata() + pass + + elif la_ == 2: + localctx = ASLParser.Max_items_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 911 + self.match(ASLParser.MAXITEMS) + self.state = 912 + self.match(ASLParser.COLON) + self.state = 913 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Max_items_pathContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 914 + self.match(ASLParser.MAXITEMSPATH) + self.state = 915 + self.match(ASLParser.COLON) + self.state = 916 + self.string_sampler() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Tolerated_failure_count_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_tolerated_failure_count_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Tolerated_failure_count_intContext(Tolerated_failure_count_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Tolerated_failure_count_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TOLERATEDFAILURECOUNT(self): + return self.getToken(ASLParser.TOLERATEDFAILURECOUNT, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTolerated_failure_count_int" ): + listener.enterTolerated_failure_count_int(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTolerated_failure_count_int" ): + listener.exitTolerated_failure_count_int(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTolerated_failure_count_int" ): + return visitor.visitTolerated_failure_count_int(self) + else: + return visitor.visitChildren(self) + + + class Tolerated_failure_count_pathContext(Tolerated_failure_count_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Tolerated_failure_count_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TOLERATEDFAILURECOUNTPATH(self): + return self.getToken(ASLParser.TOLERATEDFAILURECOUNTPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTolerated_failure_count_path" ): + listener.enterTolerated_failure_count_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTolerated_failure_count_path" ): + listener.exitTolerated_failure_count_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTolerated_failure_count_path" ): + return visitor.visitTolerated_failure_count_path(self) + else: + return visitor.visitChildren(self) + + + class Tolerated_failure_count_string_jsonataContext(Tolerated_failure_count_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Tolerated_failure_count_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TOLERATEDFAILURECOUNT(self): + return self.getToken(ASLParser.TOLERATEDFAILURECOUNT, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTolerated_failure_count_string_jsonata" ): + listener.enterTolerated_failure_count_string_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTolerated_failure_count_string_jsonata" ): + listener.exitTolerated_failure_count_string_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTolerated_failure_count_string_jsonata" ): + return visitor.visitTolerated_failure_count_string_jsonata(self) + else: + return visitor.visitChildren(self) + + + + def tolerated_failure_count_decl(self): + + localctx = ASLParser.Tolerated_failure_count_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 162, self.RULE_tolerated_failure_count_decl) + try: + self.state = 928 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,66,self._ctx) + if la_ == 1: + localctx = ASLParser.Tolerated_failure_count_string_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 919 + self.match(ASLParser.TOLERATEDFAILURECOUNT) + self.state = 920 + self.match(ASLParser.COLON) + self.state = 921 + self.string_jsonata() + pass + + elif la_ == 2: + localctx = ASLParser.Tolerated_failure_count_intContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 922 + self.match(ASLParser.TOLERATEDFAILURECOUNT) + self.state = 923 + self.match(ASLParser.COLON) + self.state = 924 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + localctx = ASLParser.Tolerated_failure_count_pathContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 925 + self.match(ASLParser.TOLERATEDFAILURECOUNTPATH) + self.state = 926 + self.match(ASLParser.COLON) + self.state = 927 + self.string_sampler() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Tolerated_failure_percentage_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + + def getRuleIndex(self): + return ASLParser.RULE_tolerated_failure_percentage_decl + + + def copyFrom(self, ctx:ParserRuleContext): + super().copyFrom(ctx) + + + + class Tolerated_failure_percentage_pathContext(Tolerated_failure_percentage_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Tolerated_failure_percentage_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TOLERATEDFAILUREPERCENTAGEPATH(self): + return self.getToken(ASLParser.TOLERATEDFAILUREPERCENTAGEPATH, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTolerated_failure_percentage_path" ): + listener.enterTolerated_failure_percentage_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTolerated_failure_percentage_path" ): + listener.exitTolerated_failure_percentage_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTolerated_failure_percentage_path" ): + return visitor.visitTolerated_failure_percentage_path(self) + else: + return visitor.visitChildren(self) + + + class Tolerated_failure_percentage_string_jsonataContext(Tolerated_failure_percentage_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Tolerated_failure_percentage_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TOLERATEDFAILUREPERCENTAGE(self): + return self.getToken(ASLParser.TOLERATEDFAILUREPERCENTAGE, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTolerated_failure_percentage_string_jsonata" ): + listener.enterTolerated_failure_percentage_string_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTolerated_failure_percentage_string_jsonata" ): + listener.exitTolerated_failure_percentage_string_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTolerated_failure_percentage_string_jsonata" ): + return visitor.visitTolerated_failure_percentage_string_jsonata(self) + else: + return visitor.visitChildren(self) + + + class Tolerated_failure_percentage_numberContext(Tolerated_failure_percentage_declContext): + + def __init__(self, parser, ctx:ParserRuleContext): # actually a ASLParser.Tolerated_failure_percentage_declContext + super().__init__(parser) + self.copyFrom(ctx) + + def TOLERATEDFAILUREPERCENTAGE(self): + return self.getToken(ASLParser.TOLERATEDFAILUREPERCENTAGE, 0) + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + def NUMBER(self): + return self.getToken(ASLParser.NUMBER, 0) + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterTolerated_failure_percentage_number" ): + listener.enterTolerated_failure_percentage_number(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitTolerated_failure_percentage_number" ): + listener.exitTolerated_failure_percentage_number(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitTolerated_failure_percentage_number" ): + return visitor.visitTolerated_failure_percentage_number(self) + else: + return visitor.visitChildren(self) + + + + def tolerated_failure_percentage_decl(self): + + localctx = ASLParser.Tolerated_failure_percentage_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 164, self.RULE_tolerated_failure_percentage_decl) + try: + self.state = 939 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,67,self._ctx) + if la_ == 1: + localctx = ASLParser.Tolerated_failure_percentage_string_jsonataContext(self, localctx) + self.enterOuterAlt(localctx, 1) + self.state = 930 + self.match(ASLParser.TOLERATEDFAILUREPERCENTAGE) + self.state = 931 + self.match(ASLParser.COLON) + self.state = 932 + self.string_jsonata() + pass + + elif la_ == 2: + localctx = ASLParser.Tolerated_failure_percentage_numberContext(self, localctx) + self.enterOuterAlt(localctx, 2) + self.state = 933 + self.match(ASLParser.TOLERATEDFAILUREPERCENTAGE) + self.state = 934 + self.match(ASLParser.COLON) + self.state = 935 + self.match(ASLParser.NUMBER) + pass + + elif la_ == 3: + localctx = ASLParser.Tolerated_failure_percentage_pathContext(self, localctx) + self.enterOuterAlt(localctx, 3) + self.state = 936 + self.match(ASLParser.TOLERATEDFAILUREPERCENTAGEPATH) + self.state = 937 + self.match(ASLParser.COLON) + self.state = 938 + self.string_sampler() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Label_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LABEL(self): + return self.getToken(ASLParser.LABEL, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_label_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterLabel_decl" ): + listener.enterLabel_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitLabel_decl" ): + listener.exitLabel_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitLabel_decl" ): + return visitor.visitLabel_decl(self) + else: + return visitor.visitChildren(self) + + + + + def label_decl(self): + + localctx = ASLParser.Label_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 166, self.RULE_label_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 941 + self.match(ASLParser.LABEL) + self.state = 942 + self.match(ASLParser.COLON) + self.state = 943 + self.string_literal() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Result_writer_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def RESULTWRITER(self): + return self.getToken(ASLParser.RESULTWRITER, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def result_writer_field(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Result_writer_fieldContext) + else: + return self.getTypedRuleContext(ASLParser.Result_writer_fieldContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_result_writer_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterResult_writer_decl" ): + listener.enterResult_writer_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitResult_writer_decl" ): + listener.exitResult_writer_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitResult_writer_decl" ): + return visitor.visitResult_writer_decl(self) + else: + return visitor.visitChildren(self) + + + + + def result_writer_decl(self): + + localctx = ASLParser.Result_writer_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 168, self.RULE_result_writer_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 945 + self.match(ASLParser.RESULTWRITER) + self.state = 946 + self.match(ASLParser.COLON) + self.state = 947 + self.match(ASLParser.LBRACE) + self.state = 948 + self.result_writer_field() + self.state = 953 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 949 + self.match(ASLParser.COMMA) + self.state = 950 + self.result_writer_field() + self.state = 955 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 956 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Result_writer_fieldContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def resource_decl(self): + return self.getTypedRuleContext(ASLParser.Resource_declContext,0) + + + def parameters_decl(self): + return self.getTypedRuleContext(ASLParser.Parameters_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_result_writer_field + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterResult_writer_field" ): + listener.enterResult_writer_field(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitResult_writer_field" ): + listener.exitResult_writer_field(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitResult_writer_field" ): + return visitor.visitResult_writer_field(self) + else: + return visitor.visitChildren(self) + + + + + def result_writer_field(self): + + localctx = ASLParser.Result_writer_fieldContext(self, self._ctx, self.state) + self.enterRule(localctx, 170, self.RULE_result_writer_field) + try: + self.state = 960 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [90]: + self.enterOuterAlt(localctx, 1) + self.state = 958 + self.resource_decl() + pass + elif token in [97]: + self.enterOuterAlt(localctx, 2) + self.state = 959 + self.parameters_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Retry_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def RETRY(self): + return self.getToken(ASLParser.RETRY, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def retrier_decl(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Retrier_declContext) + else: + return self.getTypedRuleContext(ASLParser.Retrier_declContext,i) + + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_retry_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterRetry_decl" ): + listener.enterRetry_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitRetry_decl" ): + listener.exitRetry_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitRetry_decl" ): + return visitor.visitRetry_decl(self) + else: + return visitor.visitChildren(self) + + + + + def retry_decl(self): + + localctx = ASLParser.Retry_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 172, self.RULE_retry_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 962 + self.match(ASLParser.RETRY) + self.state = 963 + self.match(ASLParser.COLON) + self.state = 964 + self.match(ASLParser.LBRACK) + self.state = 973 + self._errHandler.sync(self) + _la = self._input.LA(1) + if _la==5: + self.state = 965 + self.retrier_decl() + self.state = 970 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 966 + self.match(ASLParser.COMMA) + self.state = 967 + self.retrier_decl() + self.state = 972 + self._errHandler.sync(self) + _la = self._input.LA(1) + + + + self.state = 975 + self.match(ASLParser.RBRACK) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Retrier_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def retrier_stmt(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Retrier_stmtContext) + else: + return self.getTypedRuleContext(ASLParser.Retrier_stmtContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_retrier_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterRetrier_decl" ): + listener.enterRetrier_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitRetrier_decl" ): + listener.exitRetrier_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitRetrier_decl" ): + return visitor.visitRetrier_decl(self) + else: + return visitor.visitChildren(self) + + + + + def retrier_decl(self): + + localctx = ASLParser.Retrier_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 174, self.RULE_retrier_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 977 + self.match(ASLParser.LBRACE) + self.state = 978 + self.retrier_stmt() + self.state = 983 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 979 + self.match(ASLParser.COMMA) + self.state = 980 + self.retrier_stmt() + self.state = 985 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 986 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Retrier_stmtContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def error_equals_decl(self): + return self.getTypedRuleContext(ASLParser.Error_equals_declContext,0) + + + def interval_seconds_decl(self): + return self.getTypedRuleContext(ASLParser.Interval_seconds_declContext,0) + + + def max_attempts_decl(self): + return self.getTypedRuleContext(ASLParser.Max_attempts_declContext,0) + + + def backoff_rate_decl(self): + return self.getTypedRuleContext(ASLParser.Backoff_rate_declContext,0) + + + def max_delay_seconds_decl(self): + return self.getTypedRuleContext(ASLParser.Max_delay_seconds_declContext,0) + + + def jitter_strategy_decl(self): + return self.getTypedRuleContext(ASLParser.Jitter_strategy_declContext,0) + + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_retrier_stmt + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterRetrier_stmt" ): + listener.enterRetrier_stmt(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitRetrier_stmt" ): + listener.exitRetrier_stmt(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitRetrier_stmt" ): + return visitor.visitRetrier_stmt(self) + else: + return visitor.visitChildren(self) + + + + + def retrier_stmt(self): + + localctx = ASLParser.Retrier_stmtContext(self, self._ctx, self.state) + self.enterRule(localctx, 176, self.RULE_retrier_stmt) + try: + self.state = 995 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [122]: + self.enterOuterAlt(localctx, 1) + self.state = 988 + self.error_equals_decl() + pass + elif token in [123]: + self.enterOuterAlt(localctx, 2) + self.state = 989 + self.interval_seconds_decl() + pass + elif token in [124]: + self.enterOuterAlt(localctx, 3) + self.state = 990 + self.max_attempts_decl() + pass + elif token in [125]: + self.enterOuterAlt(localctx, 4) + self.state = 991 + self.backoff_rate_decl() + pass + elif token in [126]: + self.enterOuterAlt(localctx, 5) + self.state = 992 + self.max_delay_seconds_decl() + pass + elif token in [127]: + self.enterOuterAlt(localctx, 6) + self.state = 993 + self.jitter_strategy_decl() + pass + elif token in [10]: + self.enterOuterAlt(localctx, 7) + self.state = 994 + self.comment_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Error_equals_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ERROREQUALS(self): + return self.getToken(ASLParser.ERROREQUALS, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def error_name(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Error_nameContext) + else: + return self.getTypedRuleContext(ASLParser.Error_nameContext,i) + + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_error_equals_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterError_equals_decl" ): + listener.enterError_equals_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitError_equals_decl" ): + listener.exitError_equals_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitError_equals_decl" ): + return visitor.visitError_equals_decl(self) + else: + return visitor.visitChildren(self) + + + + + def error_equals_decl(self): + + localctx = ASLParser.Error_equals_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 178, self.RULE_error_equals_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 997 + self.match(ASLParser.ERROREQUALS) + self.state = 998 + self.match(ASLParser.COLON) + self.state = 999 + self.match(ASLParser.LBRACK) + self.state = 1000 + self.error_name() + self.state = 1005 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 1001 + self.match(ASLParser.COMMA) + self.state = 1002 + self.error_name() + self.state = 1007 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 1008 + self.match(ASLParser.RBRACK) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Interval_seconds_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def INTERVALSECONDS(self): + return self.getToken(ASLParser.INTERVALSECONDS, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def getRuleIndex(self): + return ASLParser.RULE_interval_seconds_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterInterval_seconds_decl" ): + listener.enterInterval_seconds_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitInterval_seconds_decl" ): + listener.exitInterval_seconds_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitInterval_seconds_decl" ): + return visitor.visitInterval_seconds_decl(self) + else: + return visitor.visitChildren(self) + + + + + def interval_seconds_decl(self): + + localctx = ASLParser.Interval_seconds_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 180, self.RULE_interval_seconds_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1010 + self.match(ASLParser.INTERVALSECONDS) + self.state = 1011 + self.match(ASLParser.COLON) + self.state = 1012 + self.match(ASLParser.INT) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Max_attempts_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def MAXATTEMPTS(self): + return self.getToken(ASLParser.MAXATTEMPTS, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def getRuleIndex(self): + return ASLParser.RULE_max_attempts_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_attempts_decl" ): + listener.enterMax_attempts_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_attempts_decl" ): + listener.exitMax_attempts_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_attempts_decl" ): + return visitor.visitMax_attempts_decl(self) + else: + return visitor.visitChildren(self) + + + + + def max_attempts_decl(self): + + localctx = ASLParser.Max_attempts_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 182, self.RULE_max_attempts_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1014 + self.match(ASLParser.MAXATTEMPTS) + self.state = 1015 + self.match(ASLParser.COLON) + self.state = 1016 + self.match(ASLParser.INT) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Backoff_rate_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def BACKOFFRATE(self): + return self.getToken(ASLParser.BACKOFFRATE, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def NUMBER(self): + return self.getToken(ASLParser.NUMBER, 0) + + def getRuleIndex(self): + return ASLParser.RULE_backoff_rate_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterBackoff_rate_decl" ): + listener.enterBackoff_rate_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitBackoff_rate_decl" ): + listener.exitBackoff_rate_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitBackoff_rate_decl" ): + return visitor.visitBackoff_rate_decl(self) + else: + return visitor.visitChildren(self) + + + + + def backoff_rate_decl(self): + + localctx = ASLParser.Backoff_rate_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 184, self.RULE_backoff_rate_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1018 + self.match(ASLParser.BACKOFFRATE) + self.state = 1019 + self.match(ASLParser.COLON) + self.state = 1020 + _la = self._input.LA(1) + if not(_la==160 or _la==161): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Max_delay_seconds_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def MAXDELAYSECONDS(self): + return self.getToken(ASLParser.MAXDELAYSECONDS, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def getRuleIndex(self): + return ASLParser.RULE_max_delay_seconds_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterMax_delay_seconds_decl" ): + listener.enterMax_delay_seconds_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitMax_delay_seconds_decl" ): + listener.exitMax_delay_seconds_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitMax_delay_seconds_decl" ): + return visitor.visitMax_delay_seconds_decl(self) + else: + return visitor.visitChildren(self) + + + + + def max_delay_seconds_decl(self): + + localctx = ASLParser.Max_delay_seconds_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 186, self.RULE_max_delay_seconds_decl) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1022 + self.match(ASLParser.MAXDELAYSECONDS) + self.state = 1023 + self.match(ASLParser.COLON) + self.state = 1024 + self.match(ASLParser.INT) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Jitter_strategy_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def JITTERSTRATEGY(self): + return self.getToken(ASLParser.JITTERSTRATEGY, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def FULL(self): + return self.getToken(ASLParser.FULL, 0) + + def NONE(self): + return self.getToken(ASLParser.NONE, 0) + + def getRuleIndex(self): + return ASLParser.RULE_jitter_strategy_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJitter_strategy_decl" ): + listener.enterJitter_strategy_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJitter_strategy_decl" ): + listener.exitJitter_strategy_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJitter_strategy_decl" ): + return visitor.visitJitter_strategy_decl(self) + else: + return visitor.visitChildren(self) + + + + + def jitter_strategy_decl(self): + + localctx = ASLParser.Jitter_strategy_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 188, self.RULE_jitter_strategy_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1026 + self.match(ASLParser.JITTERSTRATEGY) + self.state = 1027 + self.match(ASLParser.COLON) + self.state = 1028 + _la = self._input.LA(1) + if not(_la==128 or _la==129): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Catch_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def CATCH(self): + return self.getToken(ASLParser.CATCH, 0) + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def catcher_decl(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Catcher_declContext) + else: + return self.getTypedRuleContext(ASLParser.Catcher_declContext,i) + + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_catch_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCatch_decl" ): + listener.enterCatch_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCatch_decl" ): + listener.exitCatch_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCatch_decl" ): + return visitor.visitCatch_decl(self) + else: + return visitor.visitChildren(self) + + + + + def catch_decl(self): + + localctx = ASLParser.Catch_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 190, self.RULE_catch_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1030 + self.match(ASLParser.CATCH) + self.state = 1031 + self.match(ASLParser.COLON) + self.state = 1032 + self.match(ASLParser.LBRACK) + self.state = 1041 + self._errHandler.sync(self) + _la = self._input.LA(1) + if _la==5: + self.state = 1033 + self.catcher_decl() + self.state = 1038 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 1034 + self.match(ASLParser.COMMA) + self.state = 1035 + self.catcher_decl() + self.state = 1040 + self._errHandler.sync(self) + _la = self._input.LA(1) + + + + self.state = 1043 + self.match(ASLParser.RBRACK) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Catcher_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def catcher_stmt(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Catcher_stmtContext) + else: + return self.getTypedRuleContext(ASLParser.Catcher_stmtContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_catcher_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCatcher_decl" ): + listener.enterCatcher_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCatcher_decl" ): + listener.exitCatcher_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCatcher_decl" ): + return visitor.visitCatcher_decl(self) + else: + return visitor.visitChildren(self) + + + + + def catcher_decl(self): + + localctx = ASLParser.Catcher_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 192, self.RULE_catcher_decl) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1045 + self.match(ASLParser.LBRACE) + self.state = 1046 + self.catcher_stmt() + self.state = 1051 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 1047 + self.match(ASLParser.COMMA) + self.state = 1048 + self.catcher_stmt() + self.state = 1053 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 1054 + self.match(ASLParser.RBRACE) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Catcher_stmtContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def error_equals_decl(self): + return self.getTypedRuleContext(ASLParser.Error_equals_declContext,0) + + + def result_path_decl(self): + return self.getTypedRuleContext(ASLParser.Result_path_declContext,0) + + + def next_decl(self): + return self.getTypedRuleContext(ASLParser.Next_declContext,0) + + + def assign_decl(self): + return self.getTypedRuleContext(ASLParser.Assign_declContext,0) + + + def output_decl(self): + return self.getTypedRuleContext(ASLParser.Output_declContext,0) + + + def comment_decl(self): + return self.getTypedRuleContext(ASLParser.Comment_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_catcher_stmt + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterCatcher_stmt" ): + listener.enterCatcher_stmt(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitCatcher_stmt" ): + listener.exitCatcher_stmt(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitCatcher_stmt" ): + return visitor.visitCatcher_stmt(self) + else: + return visitor.visitChildren(self) + + + + + def catcher_stmt(self): + + localctx = ASLParser.Catcher_stmtContext(self, self._ctx, self.state) + self.enterRule(localctx, 194, self.RULE_catcher_stmt) + try: + self.state = 1062 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [122]: + self.enterOuterAlt(localctx, 1) + self.state = 1056 + self.error_equals_decl() + pass + elif token in [95]: + self.enterOuterAlt(localctx, 2) + self.state = 1057 + self.result_path_decl() + pass + elif token in [115]: + self.enterOuterAlt(localctx, 3) + self.state = 1058 + self.next_decl() + pass + elif token in [134]: + self.enterOuterAlt(localctx, 4) + self.state = 1059 + self.assign_decl() + pass + elif token in [135]: + self.enterOuterAlt(localctx, 5) + self.state = 1060 + self.output_decl() + pass + elif token in [10]: + self.enterOuterAlt(localctx, 6) + self.state = 1061 + self.comment_decl() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Comparison_opContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def BOOLEANEQUALS(self): + return self.getToken(ASLParser.BOOLEANEQUALS, 0) + + def BOOLEANQUALSPATH(self): + return self.getToken(ASLParser.BOOLEANQUALSPATH, 0) + + def ISBOOLEAN(self): + return self.getToken(ASLParser.ISBOOLEAN, 0) + + def ISNULL(self): + return self.getToken(ASLParser.ISNULL, 0) + + def ISNUMERIC(self): + return self.getToken(ASLParser.ISNUMERIC, 0) + + def ISPRESENT(self): + return self.getToken(ASLParser.ISPRESENT, 0) + + def ISSTRING(self): + return self.getToken(ASLParser.ISSTRING, 0) + + def ISTIMESTAMP(self): + return self.getToken(ASLParser.ISTIMESTAMP, 0) + + def NUMERICEQUALS(self): + return self.getToken(ASLParser.NUMERICEQUALS, 0) + + def NUMERICEQUALSPATH(self): + return self.getToken(ASLParser.NUMERICEQUALSPATH, 0) + + def NUMERICGREATERTHAN(self): + return self.getToken(ASLParser.NUMERICGREATERTHAN, 0) + + def NUMERICGREATERTHANPATH(self): + return self.getToken(ASLParser.NUMERICGREATERTHANPATH, 0) + + def NUMERICGREATERTHANEQUALS(self): + return self.getToken(ASLParser.NUMERICGREATERTHANEQUALS, 0) + + def NUMERICGREATERTHANEQUALSPATH(self): + return self.getToken(ASLParser.NUMERICGREATERTHANEQUALSPATH, 0) + + def NUMERICLESSTHAN(self): + return self.getToken(ASLParser.NUMERICLESSTHAN, 0) + + def NUMERICLESSTHANPATH(self): + return self.getToken(ASLParser.NUMERICLESSTHANPATH, 0) + + def NUMERICLESSTHANEQUALS(self): + return self.getToken(ASLParser.NUMERICLESSTHANEQUALS, 0) + + def NUMERICLESSTHANEQUALSPATH(self): + return self.getToken(ASLParser.NUMERICLESSTHANEQUALSPATH, 0) + + def STRINGEQUALS(self): + return self.getToken(ASLParser.STRINGEQUALS, 0) + + def STRINGEQUALSPATH(self): + return self.getToken(ASLParser.STRINGEQUALSPATH, 0) + + def STRINGGREATERTHAN(self): + return self.getToken(ASLParser.STRINGGREATERTHAN, 0) + + def STRINGGREATERTHANPATH(self): + return self.getToken(ASLParser.STRINGGREATERTHANPATH, 0) + + def STRINGGREATERTHANEQUALS(self): + return self.getToken(ASLParser.STRINGGREATERTHANEQUALS, 0) + + def STRINGGREATERTHANEQUALSPATH(self): + return self.getToken(ASLParser.STRINGGREATERTHANEQUALSPATH, 0) + + def STRINGLESSTHAN(self): + return self.getToken(ASLParser.STRINGLESSTHAN, 0) + + def STRINGLESSTHANPATH(self): + return self.getToken(ASLParser.STRINGLESSTHANPATH, 0) + + def STRINGLESSTHANEQUALS(self): + return self.getToken(ASLParser.STRINGLESSTHANEQUALS, 0) + + def STRINGLESSTHANEQUALSPATH(self): + return self.getToken(ASLParser.STRINGLESSTHANEQUALSPATH, 0) + + def STRINGMATCHES(self): + return self.getToken(ASLParser.STRINGMATCHES, 0) + + def TIMESTAMPEQUALS(self): + return self.getToken(ASLParser.TIMESTAMPEQUALS, 0) + + def TIMESTAMPEQUALSPATH(self): + return self.getToken(ASLParser.TIMESTAMPEQUALSPATH, 0) + + def TIMESTAMPGREATERTHAN(self): + return self.getToken(ASLParser.TIMESTAMPGREATERTHAN, 0) + + def TIMESTAMPGREATERTHANPATH(self): + return self.getToken(ASLParser.TIMESTAMPGREATERTHANPATH, 0) + + def TIMESTAMPGREATERTHANEQUALS(self): + return self.getToken(ASLParser.TIMESTAMPGREATERTHANEQUALS, 0) + + def TIMESTAMPGREATERTHANEQUALSPATH(self): + return self.getToken(ASLParser.TIMESTAMPGREATERTHANEQUALSPATH, 0) + + def TIMESTAMPLESSTHAN(self): + return self.getToken(ASLParser.TIMESTAMPLESSTHAN, 0) + + def TIMESTAMPLESSTHANPATH(self): + return self.getToken(ASLParser.TIMESTAMPLESSTHANPATH, 0) + + def TIMESTAMPLESSTHANEQUALS(self): + return self.getToken(ASLParser.TIMESTAMPLESSTHANEQUALS, 0) + + def TIMESTAMPLESSTHANEQUALSPATH(self): + return self.getToken(ASLParser.TIMESTAMPLESSTHANEQUALSPATH, 0) + + def getRuleIndex(self): + return ASLParser.RULE_comparison_op + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterComparison_op" ): + listener.enterComparison_op(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitComparison_op" ): + listener.exitComparison_op(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitComparison_op" ): + return visitor.visitComparison_op(self) + else: + return visitor.visitChildren(self) + + + + + def comparison_op(self): + + localctx = ASLParser.Comparison_opContext(self, self._ctx, self.state) + self.enterRule(localctx, 196, self.RULE_comparison_op) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1064 + _la = self._input.LA(1) + if not(((((_la - 30)) & ~0x3f) == 0 and ((1 << (_la - 30)) & 2199022731007) != 0)): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Choice_operatorContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def NOT(self): + return self.getToken(ASLParser.NOT, 0) + + def AND(self): + return self.getToken(ASLParser.AND, 0) + + def OR(self): + return self.getToken(ASLParser.OR, 0) + + def getRuleIndex(self): + return ASLParser.RULE_choice_operator + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterChoice_operator" ): + listener.enterChoice_operator(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitChoice_operator" ): + listener.exitChoice_operator(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitChoice_operator" ): + return visitor.visitChoice_operator(self) + else: + return visitor.visitChildren(self) + + + + + def choice_operator(self): + + localctx = ASLParser.Choice_operatorContext(self, self._ctx, self.state) + self.enterRule(localctx, 198, self.RULE_choice_operator) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1066 + _la = self._input.LA(1) + if not((((_la) & ~0x3f) == 0 and ((1 << _la) & 563225368199168) != 0)): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class States_error_nameContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def ERRORNAMEStatesALL(self): + return self.getToken(ASLParser.ERRORNAMEStatesALL, 0) + + def ERRORNAMEStatesDataLimitExceeded(self): + return self.getToken(ASLParser.ERRORNAMEStatesDataLimitExceeded, 0) + + def ERRORNAMEStatesHeartbeatTimeout(self): + return self.getToken(ASLParser.ERRORNAMEStatesHeartbeatTimeout, 0) + + def ERRORNAMEStatesTimeout(self): + return self.getToken(ASLParser.ERRORNAMEStatesTimeout, 0) + + def ERRORNAMEStatesTaskFailed(self): + return self.getToken(ASLParser.ERRORNAMEStatesTaskFailed, 0) + + def ERRORNAMEStatesPermissions(self): + return self.getToken(ASLParser.ERRORNAMEStatesPermissions, 0) + + def ERRORNAMEStatesResultPathMatchFailure(self): + return self.getToken(ASLParser.ERRORNAMEStatesResultPathMatchFailure, 0) + + def ERRORNAMEStatesParameterPathFailure(self): + return self.getToken(ASLParser.ERRORNAMEStatesParameterPathFailure, 0) + + def ERRORNAMEStatesBranchFailed(self): + return self.getToken(ASLParser.ERRORNAMEStatesBranchFailed, 0) + + def ERRORNAMEStatesNoChoiceMatched(self): + return self.getToken(ASLParser.ERRORNAMEStatesNoChoiceMatched, 0) + + def ERRORNAMEStatesIntrinsicFailure(self): + return self.getToken(ASLParser.ERRORNAMEStatesIntrinsicFailure, 0) + + def ERRORNAMEStatesExceedToleratedFailureThreshold(self): + return self.getToken(ASLParser.ERRORNAMEStatesExceedToleratedFailureThreshold, 0) + + def ERRORNAMEStatesItemReaderFailed(self): + return self.getToken(ASLParser.ERRORNAMEStatesItemReaderFailed, 0) + + def ERRORNAMEStatesResultWriterFailed(self): + return self.getToken(ASLParser.ERRORNAMEStatesResultWriterFailed, 0) + + def ERRORNAMEStatesRuntime(self): + return self.getToken(ASLParser.ERRORNAMEStatesRuntime, 0) + + def ERRORNAMEStatesQueryEvaluationError(self): + return self.getToken(ASLParser.ERRORNAMEStatesQueryEvaluationError, 0) + + def getRuleIndex(self): + return ASLParser.RULE_states_error_name + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterStates_error_name" ): + listener.enterStates_error_name(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitStates_error_name" ): + listener.exitStates_error_name(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitStates_error_name" ): + return visitor.visitStates_error_name(self) + else: + return visitor.visitChildren(self) + + + + + def states_error_name(self): + + localctx = ASLParser.States_error_nameContext(self, self._ctx, self.state) + self.enterRule(localctx, 200, self.RULE_states_error_name) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1068 + _la = self._input.LA(1) + if not(((((_la - 137)) & ~0x3f) == 0 and ((1 << (_la - 137)) & 65535) != 0)): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Error_nameContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def states_error_name(self): + return self.getTypedRuleContext(ASLParser.States_error_nameContext,0) + + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_error_name + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterError_name" ): + listener.enterError_name(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitError_name" ): + listener.exitError_name(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitError_name" ): + return visitor.visitError_name(self) + else: + return visitor.visitChildren(self) + + + + + def error_name(self): + + localctx = ASLParser.Error_nameContext(self, self._ctx, self.state) + self.enterRule(localctx, 202, self.RULE_error_name) + try: + self.state = 1072 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,79,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 1070 + self.states_error_name() + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 1071 + self.string_literal() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Json_obj_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACE(self): + return self.getToken(ASLParser.LBRACE, 0) + + def json_binding(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Json_bindingContext) + else: + return self.getTypedRuleContext(ASLParser.Json_bindingContext,i) + + + def RBRACE(self): + return self.getToken(ASLParser.RBRACE, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_json_obj_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJson_obj_decl" ): + listener.enterJson_obj_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJson_obj_decl" ): + listener.exitJson_obj_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJson_obj_decl" ): + return visitor.visitJson_obj_decl(self) + else: + return visitor.visitChildren(self) + + + + + def json_obj_decl(self): + + localctx = ASLParser.Json_obj_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 204, self.RULE_json_obj_decl) + self._la = 0 # Token type + try: + self.state = 1087 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,81,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 1074 + self.match(ASLParser.LBRACE) + self.state = 1075 + self.json_binding() + self.state = 1080 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 1076 + self.match(ASLParser.COMMA) + self.state = 1077 + self.json_binding() + self.state = 1082 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 1083 + self.match(ASLParser.RBRACE) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 1085 + self.match(ASLParser.LBRACE) + self.state = 1086 + self.match(ASLParser.RBRACE) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Json_bindingContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def COLON(self): + return self.getToken(ASLParser.COLON, 0) + + def json_value_decl(self): + return self.getTypedRuleContext(ASLParser.Json_value_declContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_json_binding + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJson_binding" ): + listener.enterJson_binding(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJson_binding" ): + listener.exitJson_binding(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJson_binding" ): + return visitor.visitJson_binding(self) + else: + return visitor.visitChildren(self) + + + + + def json_binding(self): + + localctx = ASLParser.Json_bindingContext(self, self._ctx, self.state) + self.enterRule(localctx, 206, self.RULE_json_binding) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1089 + self.string_literal() + self.state = 1090 + self.match(ASLParser.COLON) + self.state = 1091 + self.json_value_decl() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Json_arr_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def LBRACK(self): + return self.getToken(ASLParser.LBRACK, 0) + + def json_value_decl(self, i:int=None): + if i is None: + return self.getTypedRuleContexts(ASLParser.Json_value_declContext) + else: + return self.getTypedRuleContext(ASLParser.Json_value_declContext,i) + + + def RBRACK(self): + return self.getToken(ASLParser.RBRACK, 0) + + def COMMA(self, i:int=None): + if i is None: + return self.getTokens(ASLParser.COMMA) + else: + return self.getToken(ASLParser.COMMA, i) + + def getRuleIndex(self): + return ASLParser.RULE_json_arr_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJson_arr_decl" ): + listener.enterJson_arr_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJson_arr_decl" ): + listener.exitJson_arr_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJson_arr_decl" ): + return visitor.visitJson_arr_decl(self) + else: + return visitor.visitChildren(self) + + + + + def json_arr_decl(self): + + localctx = ASLParser.Json_arr_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 208, self.RULE_json_arr_decl) + self._la = 0 # Token type + try: + self.state = 1106 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,83,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 1093 + self.match(ASLParser.LBRACK) + self.state = 1094 + self.json_value_decl() + self.state = 1099 + self._errHandler.sync(self) + _la = self._input.LA(1) + while _la==1: + self.state = 1095 + self.match(ASLParser.COMMA) + self.state = 1096 + self.json_value_decl() + self.state = 1101 + self._errHandler.sync(self) + _la = self._input.LA(1) + + self.state = 1102 + self.match(ASLParser.RBRACK) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 1104 + self.match(ASLParser.LBRACK) + self.state = 1105 + self.match(ASLParser.RBRACK) + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Json_value_declContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def NUMBER(self): + return self.getToken(ASLParser.NUMBER, 0) + + def INT(self): + return self.getToken(ASLParser.INT, 0) + + def TRUE(self): + return self.getToken(ASLParser.TRUE, 0) + + def FALSE(self): + return self.getToken(ASLParser.FALSE, 0) + + def NULL(self): + return self.getToken(ASLParser.NULL, 0) + + def json_binding(self): + return self.getTypedRuleContext(ASLParser.Json_bindingContext,0) + + + def json_arr_decl(self): + return self.getTypedRuleContext(ASLParser.Json_arr_declContext,0) + + + def json_obj_decl(self): + return self.getTypedRuleContext(ASLParser.Json_obj_declContext,0) + + + def string_literal(self): + return self.getTypedRuleContext(ASLParser.String_literalContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_json_value_decl + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterJson_value_decl" ): + listener.enterJson_value_decl(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitJson_value_decl" ): + listener.exitJson_value_decl(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitJson_value_decl" ): + return visitor.visitJson_value_decl(self) + else: + return visitor.visitChildren(self) + + + + + def json_value_decl(self): + + localctx = ASLParser.Json_value_declContext(self, self._ctx, self.state) + self.enterRule(localctx, 210, self.RULE_json_value_decl) + try: + self.state = 1117 + self._errHandler.sync(self) + la_ = self._interp.adaptivePredict(self._input,84,self._ctx) + if la_ == 1: + self.enterOuterAlt(localctx, 1) + self.state = 1108 + self.match(ASLParser.NUMBER) + pass + + elif la_ == 2: + self.enterOuterAlt(localctx, 2) + self.state = 1109 + self.match(ASLParser.INT) + pass + + elif la_ == 3: + self.enterOuterAlt(localctx, 3) + self.state = 1110 + self.match(ASLParser.TRUE) + pass + + elif la_ == 4: + self.enterOuterAlt(localctx, 4) + self.state = 1111 + self.match(ASLParser.FALSE) + pass + + elif la_ == 5: + self.enterOuterAlt(localctx, 5) + self.state = 1112 + self.match(ASLParser.NULL) + pass + + elif la_ == 6: + self.enterOuterAlt(localctx, 6) + self.state = 1113 + self.json_binding() + pass + + elif la_ == 7: + self.enterOuterAlt(localctx, 7) + self.state = 1114 + self.json_arr_decl() + pass + + elif la_ == 8: + self.enterOuterAlt(localctx, 8) + self.state = 1115 + self.json_obj_decl() + pass + + elif la_ == 9: + self.enterOuterAlt(localctx, 9) + self.state = 1116 + self.string_literal() + pass + + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_samplerContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def string_jsonpath(self): + return self.getTypedRuleContext(ASLParser.String_jsonpathContext,0) + + + def string_context_path(self): + return self.getTypedRuleContext(ASLParser.String_context_pathContext,0) + + + def string_variable_sample(self): + return self.getTypedRuleContext(ASLParser.String_variable_sampleContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_string_sampler + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_sampler" ): + listener.enterString_sampler(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_sampler" ): + listener.exitString_sampler(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_sampler" ): + return visitor.visitString_sampler(self) + else: + return visitor.visitChildren(self) + + + + + def string_sampler(self): + + localctx = ASLParser.String_samplerContext(self, self._ctx, self.state) + self.enterRule(localctx, 212, self.RULE_string_sampler) + try: + self.state = 1122 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [155]: + self.enterOuterAlt(localctx, 1) + self.state = 1119 + self.string_jsonpath() + pass + elif token in [154]: + self.enterOuterAlt(localctx, 2) + self.state = 1120 + self.string_context_path() + pass + elif token in [156]: + self.enterOuterAlt(localctx, 3) + self.state = 1121 + self.string_variable_sample() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_expression_simpleContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def string_sampler(self): + return self.getTypedRuleContext(ASLParser.String_samplerContext,0) + + + def string_intrinsic_function(self): + return self.getTypedRuleContext(ASLParser.String_intrinsic_functionContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_string_expression_simple + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_expression_simple" ): + listener.enterString_expression_simple(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_expression_simple" ): + listener.exitString_expression_simple(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_expression_simple" ): + return visitor.visitString_expression_simple(self) + else: + return visitor.visitChildren(self) + + + + + def string_expression_simple(self): + + localctx = ASLParser.String_expression_simpleContext(self, self._ctx, self.state) + self.enterRule(localctx, 214, self.RULE_string_expression_simple) + try: + self.state = 1126 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [154, 155, 156]: + self.enterOuterAlt(localctx, 1) + self.state = 1124 + self.string_sampler() + pass + elif token in [157]: + self.enterOuterAlt(localctx, 2) + self.state = 1125 + self.string_intrinsic_function() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_expressionContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def string_expression_simple(self): + return self.getTypedRuleContext(ASLParser.String_expression_simpleContext,0) + + + def string_jsonata(self): + return self.getTypedRuleContext(ASLParser.String_jsonataContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_string_expression + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_expression" ): + listener.enterString_expression(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_expression" ): + listener.exitString_expression(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_expression" ): + return visitor.visitString_expression(self) + else: + return visitor.visitChildren(self) + + + + + def string_expression(self): + + localctx = ASLParser.String_expressionContext(self, self._ctx, self.state) + self.enterRule(localctx, 216, self.RULE_string_expression) + try: + self.state = 1130 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [154, 155, 156, 157]: + self.enterOuterAlt(localctx, 1) + self.state = 1128 + self.string_expression_simple() + pass + elif token in [158]: + self.enterOuterAlt(localctx, 2) + self.state = 1129 + self.string_jsonata() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_jsonpathContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STRINGPATH(self): + return self.getToken(ASLParser.STRINGPATH, 0) + + def getRuleIndex(self): + return ASLParser.RULE_string_jsonpath + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_jsonpath" ): + listener.enterString_jsonpath(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_jsonpath" ): + listener.exitString_jsonpath(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_jsonpath" ): + return visitor.visitString_jsonpath(self) + else: + return visitor.visitChildren(self) + + + + + def string_jsonpath(self): + + localctx = ASLParser.String_jsonpathContext(self, self._ctx, self.state) + self.enterRule(localctx, 218, self.RULE_string_jsonpath) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1132 + self.match(ASLParser.STRINGPATH) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_context_pathContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STRINGPATHCONTEXTOBJ(self): + return self.getToken(ASLParser.STRINGPATHCONTEXTOBJ, 0) + + def getRuleIndex(self): + return ASLParser.RULE_string_context_path + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_context_path" ): + listener.enterString_context_path(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_context_path" ): + listener.exitString_context_path(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_context_path" ): + return visitor.visitString_context_path(self) + else: + return visitor.visitChildren(self) + + + + + def string_context_path(self): + + localctx = ASLParser.String_context_pathContext(self, self._ctx, self.state) + self.enterRule(localctx, 220, self.RULE_string_context_path) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1134 + self.match(ASLParser.STRINGPATHCONTEXTOBJ) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_variable_sampleContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STRINGVAR(self): + return self.getToken(ASLParser.STRINGVAR, 0) + + def getRuleIndex(self): + return ASLParser.RULE_string_variable_sample + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_variable_sample" ): + listener.enterString_variable_sample(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_variable_sample" ): + listener.exitString_variable_sample(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_variable_sample" ): + return visitor.visitString_variable_sample(self) + else: + return visitor.visitChildren(self) + + + + + def string_variable_sample(self): + + localctx = ASLParser.String_variable_sampleContext(self, self._ctx, self.state) + self.enterRule(localctx, 222, self.RULE_string_variable_sample) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1136 + self.match(ASLParser.STRINGVAR) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_intrinsic_functionContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STRINGINTRINSICFUNC(self): + return self.getToken(ASLParser.STRINGINTRINSICFUNC, 0) + + def getRuleIndex(self): + return ASLParser.RULE_string_intrinsic_function + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_intrinsic_function" ): + listener.enterString_intrinsic_function(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_intrinsic_function" ): + listener.exitString_intrinsic_function(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_intrinsic_function" ): + return visitor.visitString_intrinsic_function(self) + else: + return visitor.visitChildren(self) + + + + + def string_intrinsic_function(self): + + localctx = ASLParser.String_intrinsic_functionContext(self, self._ctx, self.state) + self.enterRule(localctx, 224, self.RULE_string_intrinsic_function) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1138 + self.match(ASLParser.STRINGINTRINSICFUNC) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_jsonataContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STRINGJSONATA(self): + return self.getToken(ASLParser.STRINGJSONATA, 0) + + def getRuleIndex(self): + return ASLParser.RULE_string_jsonata + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_jsonata" ): + listener.enterString_jsonata(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_jsonata" ): + listener.exitString_jsonata(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_jsonata" ): + return visitor.visitString_jsonata(self) + else: + return visitor.visitChildren(self) + + + + + def string_jsonata(self): + + localctx = ASLParser.String_jsonataContext(self, self._ctx, self.state) + self.enterRule(localctx, 226, self.RULE_string_jsonata) + try: + self.enterOuterAlt(localctx, 1) + self.state = 1140 + self.match(ASLParser.STRINGJSONATA) + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class String_literalContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def STRING(self): + return self.getToken(ASLParser.STRING, 0) + + def STRINGDOLLAR(self): + return self.getToken(ASLParser.STRINGDOLLAR, 0) + + def soft_string_keyword(self): + return self.getTypedRuleContext(ASLParser.Soft_string_keywordContext,0) + + + def comparison_op(self): + return self.getTypedRuleContext(ASLParser.Comparison_opContext,0) + + + def choice_operator(self): + return self.getTypedRuleContext(ASLParser.Choice_operatorContext,0) + + + def states_error_name(self): + return self.getTypedRuleContext(ASLParser.States_error_nameContext,0) + + + def string_expression(self): + return self.getTypedRuleContext(ASLParser.String_expressionContext,0) + + + def getRuleIndex(self): + return ASLParser.RULE_string_literal + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterString_literal" ): + listener.enterString_literal(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitString_literal" ): + listener.exitString_literal(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitString_literal" ): + return visitor.visitString_literal(self) + else: + return visitor.visitChildren(self) + + + + + def string_literal(self): + + localctx = ASLParser.String_literalContext(self, self._ctx, self.state) + self.enterRule(localctx, 228, self.RULE_string_literal) + try: + self.state = 1149 + self._errHandler.sync(self) + token = self._input.LA(1) + if token in [159]: + self.enterOuterAlt(localctx, 1) + self.state = 1142 + self.match(ASLParser.STRING) + pass + elif token in [153]: + self.enterOuterAlt(localctx, 2) + self.state = 1143 + self.match(ASLParser.STRINGDOLLAR) + pass + elif token in [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 134, 135, 136]: + self.enterOuterAlt(localctx, 3) + self.state = 1144 + self.soft_string_keyword() + pass + elif token in [30, 31, 32, 33, 34, 35, 36, 37, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70]: + self.enterOuterAlt(localctx, 4) + self.state = 1145 + self.comparison_op() + pass + elif token in [29, 38, 49]: + self.enterOuterAlt(localctx, 5) + self.state = 1146 + self.choice_operator() + pass + elif token in [137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152]: + self.enterOuterAlt(localctx, 6) + self.state = 1147 + self.states_error_name() + pass + elif token in [154, 155, 156, 157, 158]: + self.enterOuterAlt(localctx, 7) + self.state = 1148 + self.string_expression() + pass + else: + raise NoViableAltException(self) + + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + class Soft_string_keywordContext(ParserRuleContext): + __slots__ = 'parser' + + def __init__(self, parser, parent:ParserRuleContext=None, invokingState:int=-1): + super().__init__(parent, invokingState) + self.parser = parser + + def QUERYLANGUAGE(self): + return self.getToken(ASLParser.QUERYLANGUAGE, 0) + + def ASSIGN(self): + return self.getToken(ASLParser.ASSIGN, 0) + + def ARGUMENTS(self): + return self.getToken(ASLParser.ARGUMENTS, 0) + + def OUTPUT(self): + return self.getToken(ASLParser.OUTPUT, 0) + + def COMMENT(self): + return self.getToken(ASLParser.COMMENT, 0) + + def STATES(self): + return self.getToken(ASLParser.STATES, 0) + + def STARTAT(self): + return self.getToken(ASLParser.STARTAT, 0) + + def NEXTSTATE(self): + return self.getToken(ASLParser.NEXTSTATE, 0) + + def TYPE(self): + return self.getToken(ASLParser.TYPE, 0) + + def TASK(self): + return self.getToken(ASLParser.TASK, 0) + + def CHOICE(self): + return self.getToken(ASLParser.CHOICE, 0) + + def FAIL(self): + return self.getToken(ASLParser.FAIL, 0) + + def SUCCEED(self): + return self.getToken(ASLParser.SUCCEED, 0) + + def PASS(self): + return self.getToken(ASLParser.PASS, 0) + + def WAIT(self): + return self.getToken(ASLParser.WAIT, 0) + + def PARALLEL(self): + return self.getToken(ASLParser.PARALLEL, 0) + + def MAP(self): + return self.getToken(ASLParser.MAP, 0) + + def CHOICES(self): + return self.getToken(ASLParser.CHOICES, 0) + + def CONDITION(self): + return self.getToken(ASLParser.CONDITION, 0) + + def VARIABLE(self): + return self.getToken(ASLParser.VARIABLE, 0) + + def DEFAULT(self): + return self.getToken(ASLParser.DEFAULT, 0) + + def BRANCHES(self): + return self.getToken(ASLParser.BRANCHES, 0) + + def SECONDSPATH(self): + return self.getToken(ASLParser.SECONDSPATH, 0) + + def SECONDS(self): + return self.getToken(ASLParser.SECONDS, 0) + + def TIMESTAMPPATH(self): + return self.getToken(ASLParser.TIMESTAMPPATH, 0) + + def TIMESTAMP(self): + return self.getToken(ASLParser.TIMESTAMP, 0) + + def TIMEOUTSECONDS(self): + return self.getToken(ASLParser.TIMEOUTSECONDS, 0) + + def TIMEOUTSECONDSPATH(self): + return self.getToken(ASLParser.TIMEOUTSECONDSPATH, 0) + + def HEARTBEATSECONDS(self): + return self.getToken(ASLParser.HEARTBEATSECONDS, 0) + + def HEARTBEATSECONDSPATH(self): + return self.getToken(ASLParser.HEARTBEATSECONDSPATH, 0) + + def PROCESSORCONFIG(self): + return self.getToken(ASLParser.PROCESSORCONFIG, 0) + + def MODE(self): + return self.getToken(ASLParser.MODE, 0) + + def INLINE(self): + return self.getToken(ASLParser.INLINE, 0) + + def DISTRIBUTED(self): + return self.getToken(ASLParser.DISTRIBUTED, 0) + + def EXECUTIONTYPE(self): + return self.getToken(ASLParser.EXECUTIONTYPE, 0) + + def STANDARD(self): + return self.getToken(ASLParser.STANDARD, 0) + + def ITEMS(self): + return self.getToken(ASLParser.ITEMS, 0) + + def ITEMPROCESSOR(self): + return self.getToken(ASLParser.ITEMPROCESSOR, 0) + + def ITERATOR(self): + return self.getToken(ASLParser.ITERATOR, 0) + + def ITEMSELECTOR(self): + return self.getToken(ASLParser.ITEMSELECTOR, 0) + + def MAXCONCURRENCY(self): + return self.getToken(ASLParser.MAXCONCURRENCY, 0) + + def MAXCONCURRENCYPATH(self): + return self.getToken(ASLParser.MAXCONCURRENCYPATH, 0) + + def RESOURCE(self): + return self.getToken(ASLParser.RESOURCE, 0) + + def INPUTPATH(self): + return self.getToken(ASLParser.INPUTPATH, 0) + + def OUTPUTPATH(self): + return self.getToken(ASLParser.OUTPUTPATH, 0) + + def ITEMSPATH(self): + return self.getToken(ASLParser.ITEMSPATH, 0) + + def RESULTPATH(self): + return self.getToken(ASLParser.RESULTPATH, 0) + + def RESULT(self): + return self.getToken(ASLParser.RESULT, 0) + + def PARAMETERS(self): + return self.getToken(ASLParser.PARAMETERS, 0) + + def CREDENTIALS(self): + return self.getToken(ASLParser.CREDENTIALS, 0) + + def ROLEARN(self): + return self.getToken(ASLParser.ROLEARN, 0) + + def ROLEARNPATH(self): + return self.getToken(ASLParser.ROLEARNPATH, 0) + + def RESULTSELECTOR(self): + return self.getToken(ASLParser.RESULTSELECTOR, 0) + + def ITEMREADER(self): + return self.getToken(ASLParser.ITEMREADER, 0) + + def READERCONFIG(self): + return self.getToken(ASLParser.READERCONFIG, 0) + + def INPUTTYPE(self): + return self.getToken(ASLParser.INPUTTYPE, 0) + + def CSVHEADERLOCATION(self): + return self.getToken(ASLParser.CSVHEADERLOCATION, 0) + + def CSVHEADERS(self): + return self.getToken(ASLParser.CSVHEADERS, 0) + + def MAXITEMS(self): + return self.getToken(ASLParser.MAXITEMS, 0) + + def MAXITEMSPATH(self): + return self.getToken(ASLParser.MAXITEMSPATH, 0) + + def TOLERATEDFAILURECOUNT(self): + return self.getToken(ASLParser.TOLERATEDFAILURECOUNT, 0) + + def TOLERATEDFAILURECOUNTPATH(self): + return self.getToken(ASLParser.TOLERATEDFAILURECOUNTPATH, 0) + + def TOLERATEDFAILUREPERCENTAGE(self): + return self.getToken(ASLParser.TOLERATEDFAILUREPERCENTAGE, 0) + + def TOLERATEDFAILUREPERCENTAGEPATH(self): + return self.getToken(ASLParser.TOLERATEDFAILUREPERCENTAGEPATH, 0) + + def LABEL(self): + return self.getToken(ASLParser.LABEL, 0) + + def RESULTWRITER(self): + return self.getToken(ASLParser.RESULTWRITER, 0) + + def NEXT(self): + return self.getToken(ASLParser.NEXT, 0) + + def END(self): + return self.getToken(ASLParser.END, 0) + + def CAUSE(self): + return self.getToken(ASLParser.CAUSE, 0) + + def ERROR(self): + return self.getToken(ASLParser.ERROR, 0) + + def RETRY(self): + return self.getToken(ASLParser.RETRY, 0) + + def ERROREQUALS(self): + return self.getToken(ASLParser.ERROREQUALS, 0) + + def INTERVALSECONDS(self): + return self.getToken(ASLParser.INTERVALSECONDS, 0) + + def MAXATTEMPTS(self): + return self.getToken(ASLParser.MAXATTEMPTS, 0) + + def BACKOFFRATE(self): + return self.getToken(ASLParser.BACKOFFRATE, 0) + + def MAXDELAYSECONDS(self): + return self.getToken(ASLParser.MAXDELAYSECONDS, 0) + + def JITTERSTRATEGY(self): + return self.getToken(ASLParser.JITTERSTRATEGY, 0) + + def FULL(self): + return self.getToken(ASLParser.FULL, 0) + + def NONE(self): + return self.getToken(ASLParser.NONE, 0) + + def CATCH(self): + return self.getToken(ASLParser.CATCH, 0) + + def VERSION(self): + return self.getToken(ASLParser.VERSION, 0) + + def getRuleIndex(self): + return ASLParser.RULE_soft_string_keyword + + def enterRule(self, listener:ParseTreeListener): + if hasattr( listener, "enterSoft_string_keyword" ): + listener.enterSoft_string_keyword(self) + + def exitRule(self, listener:ParseTreeListener): + if hasattr( listener, "exitSoft_string_keyword" ): + listener.exitSoft_string_keyword(self) + + def accept(self, visitor:ParseTreeVisitor): + if hasattr( visitor, "visitSoft_string_keyword" ): + return visitor.visitSoft_string_keyword(self) + else: + return visitor.visitChildren(self) + + + + + def soft_string_keyword(self): + + localctx = ASLParser.Soft_string_keywordContext(self, self._ctx, self.state) + self.enterRule(localctx, 230, self.RULE_soft_string_keyword) + self._la = 0 # Token type + try: + self.enterOuterAlt(localctx, 1) + self.state = 1151 + _la = self._input.LA(1) + if not(((((_la - 10)) & ~0x3f) == 0 and ((1 << (_la - 10)) & -2305843009213169665) != 0) or ((((_la - 74)) & ~0x3f) == 0 and ((1 << (_la - 74)) & 8358592947469418495) != 0)): + self._errHandler.recoverInline(self) + else: + self._errHandler.reportMatch(self) + self.consume() + except RecognitionException as re: + localctx.exception = re + self._errHandler.reportError(self, re) + self._errHandler.recover(self, re) + finally: + self.exitRule() + return localctx + + + + + diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParserListener.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParserListener.py new file mode 100644 index 0000000000000..ad736a14516e2 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParserListener.py @@ -0,0 +1,1416 @@ +# Generated from ASLParser.g4 by ANTLR 4.13.2 +from antlr4 import * +if "." in __name__: + from .ASLParser import ASLParser +else: + from ASLParser import ASLParser + +# This class defines a complete listener for a parse tree produced by ASLParser. +class ASLParserListener(ParseTreeListener): + + # Enter a parse tree produced by ASLParser#state_machine. + def enterState_machine(self, ctx:ASLParser.State_machineContext): + pass + + # Exit a parse tree produced by ASLParser#state_machine. + def exitState_machine(self, ctx:ASLParser.State_machineContext): + pass + + + # Enter a parse tree produced by ASLParser#program_decl. + def enterProgram_decl(self, ctx:ASLParser.Program_declContext): + pass + + # Exit a parse tree produced by ASLParser#program_decl. + def exitProgram_decl(self, ctx:ASLParser.Program_declContext): + pass + + + # Enter a parse tree produced by ASLParser#top_layer_stmt. + def enterTop_layer_stmt(self, ctx:ASLParser.Top_layer_stmtContext): + pass + + # Exit a parse tree produced by ASLParser#top_layer_stmt. + def exitTop_layer_stmt(self, ctx:ASLParser.Top_layer_stmtContext): + pass + + + # Enter a parse tree produced by ASLParser#startat_decl. + def enterStartat_decl(self, ctx:ASLParser.Startat_declContext): + pass + + # Exit a parse tree produced by ASLParser#startat_decl. + def exitStartat_decl(self, ctx:ASLParser.Startat_declContext): + pass + + + # Enter a parse tree produced by ASLParser#comment_decl. + def enterComment_decl(self, ctx:ASLParser.Comment_declContext): + pass + + # Exit a parse tree produced by ASLParser#comment_decl. + def exitComment_decl(self, ctx:ASLParser.Comment_declContext): + pass + + + # Enter a parse tree produced by ASLParser#version_decl. + def enterVersion_decl(self, ctx:ASLParser.Version_declContext): + pass + + # Exit a parse tree produced by ASLParser#version_decl. + def exitVersion_decl(self, ctx:ASLParser.Version_declContext): + pass + + + # Enter a parse tree produced by ASLParser#query_language_decl. + def enterQuery_language_decl(self, ctx:ASLParser.Query_language_declContext): + pass + + # Exit a parse tree produced by ASLParser#query_language_decl. + def exitQuery_language_decl(self, ctx:ASLParser.Query_language_declContext): + pass + + + # Enter a parse tree produced by ASLParser#state_stmt. + def enterState_stmt(self, ctx:ASLParser.State_stmtContext): + pass + + # Exit a parse tree produced by ASLParser#state_stmt. + def exitState_stmt(self, ctx:ASLParser.State_stmtContext): + pass + + + # Enter a parse tree produced by ASLParser#states_decl. + def enterStates_decl(self, ctx:ASLParser.States_declContext): + pass + + # Exit a parse tree produced by ASLParser#states_decl. + def exitStates_decl(self, ctx:ASLParser.States_declContext): + pass + + + # Enter a parse tree produced by ASLParser#state_decl. + def enterState_decl(self, ctx:ASLParser.State_declContext): + pass + + # Exit a parse tree produced by ASLParser#state_decl. + def exitState_decl(self, ctx:ASLParser.State_declContext): + pass + + + # Enter a parse tree produced by ASLParser#state_decl_body. + def enterState_decl_body(self, ctx:ASLParser.State_decl_bodyContext): + pass + + # Exit a parse tree produced by ASLParser#state_decl_body. + def exitState_decl_body(self, ctx:ASLParser.State_decl_bodyContext): + pass + + + # Enter a parse tree produced by ASLParser#type_decl. + def enterType_decl(self, ctx:ASLParser.Type_declContext): + pass + + # Exit a parse tree produced by ASLParser#type_decl. + def exitType_decl(self, ctx:ASLParser.Type_declContext): + pass + + + # Enter a parse tree produced by ASLParser#next_decl. + def enterNext_decl(self, ctx:ASLParser.Next_declContext): + pass + + # Exit a parse tree produced by ASLParser#next_decl. + def exitNext_decl(self, ctx:ASLParser.Next_declContext): + pass + + + # Enter a parse tree produced by ASLParser#resource_decl. + def enterResource_decl(self, ctx:ASLParser.Resource_declContext): + pass + + # Exit a parse tree produced by ASLParser#resource_decl. + def exitResource_decl(self, ctx:ASLParser.Resource_declContext): + pass + + + # Enter a parse tree produced by ASLParser#input_path_decl. + def enterInput_path_decl(self, ctx:ASLParser.Input_path_declContext): + pass + + # Exit a parse tree produced by ASLParser#input_path_decl. + def exitInput_path_decl(self, ctx:ASLParser.Input_path_declContext): + pass + + + # Enter a parse tree produced by ASLParser#result_decl. + def enterResult_decl(self, ctx:ASLParser.Result_declContext): + pass + + # Exit a parse tree produced by ASLParser#result_decl. + def exitResult_decl(self, ctx:ASLParser.Result_declContext): + pass + + + # Enter a parse tree produced by ASLParser#result_path_decl. + def enterResult_path_decl(self, ctx:ASLParser.Result_path_declContext): + pass + + # Exit a parse tree produced by ASLParser#result_path_decl. + def exitResult_path_decl(self, ctx:ASLParser.Result_path_declContext): + pass + + + # Enter a parse tree produced by ASLParser#output_path_decl. + def enterOutput_path_decl(self, ctx:ASLParser.Output_path_declContext): + pass + + # Exit a parse tree produced by ASLParser#output_path_decl. + def exitOutput_path_decl(self, ctx:ASLParser.Output_path_declContext): + pass + + + # Enter a parse tree produced by ASLParser#end_decl. + def enterEnd_decl(self, ctx:ASLParser.End_declContext): + pass + + # Exit a parse tree produced by ASLParser#end_decl. + def exitEnd_decl(self, ctx:ASLParser.End_declContext): + pass + + + # Enter a parse tree produced by ASLParser#default_decl. + def enterDefault_decl(self, ctx:ASLParser.Default_declContext): + pass + + # Exit a parse tree produced by ASLParser#default_decl. + def exitDefault_decl(self, ctx:ASLParser.Default_declContext): + pass + + + # Enter a parse tree produced by ASLParser#error. + def enterError(self, ctx:ASLParser.ErrorContext): + pass + + # Exit a parse tree produced by ASLParser#error. + def exitError(self, ctx:ASLParser.ErrorContext): + pass + + + # Enter a parse tree produced by ASLParser#error_path. + def enterError_path(self, ctx:ASLParser.Error_pathContext): + pass + + # Exit a parse tree produced by ASLParser#error_path. + def exitError_path(self, ctx:ASLParser.Error_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#cause. + def enterCause(self, ctx:ASLParser.CauseContext): + pass + + # Exit a parse tree produced by ASLParser#cause. + def exitCause(self, ctx:ASLParser.CauseContext): + pass + + + # Enter a parse tree produced by ASLParser#cause_path. + def enterCause_path(self, ctx:ASLParser.Cause_pathContext): + pass + + # Exit a parse tree produced by ASLParser#cause_path. + def exitCause_path(self, ctx:ASLParser.Cause_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#seconds_jsonata. + def enterSeconds_jsonata(self, ctx:ASLParser.Seconds_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#seconds_jsonata. + def exitSeconds_jsonata(self, ctx:ASLParser.Seconds_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#seconds_int. + def enterSeconds_int(self, ctx:ASLParser.Seconds_intContext): + pass + + # Exit a parse tree produced by ASLParser#seconds_int. + def exitSeconds_int(self, ctx:ASLParser.Seconds_intContext): + pass + + + # Enter a parse tree produced by ASLParser#seconds_path. + def enterSeconds_path(self, ctx:ASLParser.Seconds_pathContext): + pass + + # Exit a parse tree produced by ASLParser#seconds_path. + def exitSeconds_path(self, ctx:ASLParser.Seconds_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#timestamp. + def enterTimestamp(self, ctx:ASLParser.TimestampContext): + pass + + # Exit a parse tree produced by ASLParser#timestamp. + def exitTimestamp(self, ctx:ASLParser.TimestampContext): + pass + + + # Enter a parse tree produced by ASLParser#timestamp_path. + def enterTimestamp_path(self, ctx:ASLParser.Timestamp_pathContext): + pass + + # Exit a parse tree produced by ASLParser#timestamp_path. + def exitTimestamp_path(self, ctx:ASLParser.Timestamp_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#items_array. + def enterItems_array(self, ctx:ASLParser.Items_arrayContext): + pass + + # Exit a parse tree produced by ASLParser#items_array. + def exitItems_array(self, ctx:ASLParser.Items_arrayContext): + pass + + + # Enter a parse tree produced by ASLParser#items_jsonata. + def enterItems_jsonata(self, ctx:ASLParser.Items_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#items_jsonata. + def exitItems_jsonata(self, ctx:ASLParser.Items_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#items_path_decl. + def enterItems_path_decl(self, ctx:ASLParser.Items_path_declContext): + pass + + # Exit a parse tree produced by ASLParser#items_path_decl. + def exitItems_path_decl(self, ctx:ASLParser.Items_path_declContext): + pass + + + # Enter a parse tree produced by ASLParser#max_concurrency_jsonata. + def enterMax_concurrency_jsonata(self, ctx:ASLParser.Max_concurrency_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#max_concurrency_jsonata. + def exitMax_concurrency_jsonata(self, ctx:ASLParser.Max_concurrency_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#max_concurrency_int. + def enterMax_concurrency_int(self, ctx:ASLParser.Max_concurrency_intContext): + pass + + # Exit a parse tree produced by ASLParser#max_concurrency_int. + def exitMax_concurrency_int(self, ctx:ASLParser.Max_concurrency_intContext): + pass + + + # Enter a parse tree produced by ASLParser#max_concurrency_path. + def enterMax_concurrency_path(self, ctx:ASLParser.Max_concurrency_pathContext): + pass + + # Exit a parse tree produced by ASLParser#max_concurrency_path. + def exitMax_concurrency_path(self, ctx:ASLParser.Max_concurrency_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#parameters_decl. + def enterParameters_decl(self, ctx:ASLParser.Parameters_declContext): + pass + + # Exit a parse tree produced by ASLParser#parameters_decl. + def exitParameters_decl(self, ctx:ASLParser.Parameters_declContext): + pass + + + # Enter a parse tree produced by ASLParser#credentials_decl. + def enterCredentials_decl(self, ctx:ASLParser.Credentials_declContext): + pass + + # Exit a parse tree produced by ASLParser#credentials_decl. + def exitCredentials_decl(self, ctx:ASLParser.Credentials_declContext): + pass + + + # Enter a parse tree produced by ASLParser#role_arn. + def enterRole_arn(self, ctx:ASLParser.Role_arnContext): + pass + + # Exit a parse tree produced by ASLParser#role_arn. + def exitRole_arn(self, ctx:ASLParser.Role_arnContext): + pass + + + # Enter a parse tree produced by ASLParser#role_path. + def enterRole_path(self, ctx:ASLParser.Role_pathContext): + pass + + # Exit a parse tree produced by ASLParser#role_path. + def exitRole_path(self, ctx:ASLParser.Role_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#timeout_seconds_jsonata. + def enterTimeout_seconds_jsonata(self, ctx:ASLParser.Timeout_seconds_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#timeout_seconds_jsonata. + def exitTimeout_seconds_jsonata(self, ctx:ASLParser.Timeout_seconds_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#timeout_seconds_int. + def enterTimeout_seconds_int(self, ctx:ASLParser.Timeout_seconds_intContext): + pass + + # Exit a parse tree produced by ASLParser#timeout_seconds_int. + def exitTimeout_seconds_int(self, ctx:ASLParser.Timeout_seconds_intContext): + pass + + + # Enter a parse tree produced by ASLParser#timeout_seconds_path. + def enterTimeout_seconds_path(self, ctx:ASLParser.Timeout_seconds_pathContext): + pass + + # Exit a parse tree produced by ASLParser#timeout_seconds_path. + def exitTimeout_seconds_path(self, ctx:ASLParser.Timeout_seconds_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#heartbeat_seconds_jsonata. + def enterHeartbeat_seconds_jsonata(self, ctx:ASLParser.Heartbeat_seconds_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#heartbeat_seconds_jsonata. + def exitHeartbeat_seconds_jsonata(self, ctx:ASLParser.Heartbeat_seconds_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#heartbeat_seconds_int. + def enterHeartbeat_seconds_int(self, ctx:ASLParser.Heartbeat_seconds_intContext): + pass + + # Exit a parse tree produced by ASLParser#heartbeat_seconds_int. + def exitHeartbeat_seconds_int(self, ctx:ASLParser.Heartbeat_seconds_intContext): + pass + + + # Enter a parse tree produced by ASLParser#heartbeat_seconds_path. + def enterHeartbeat_seconds_path(self, ctx:ASLParser.Heartbeat_seconds_pathContext): + pass + + # Exit a parse tree produced by ASLParser#heartbeat_seconds_path. + def exitHeartbeat_seconds_path(self, ctx:ASLParser.Heartbeat_seconds_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_tmpl_decl. + def enterPayload_tmpl_decl(self, ctx:ASLParser.Payload_tmpl_declContext): + pass + + # Exit a parse tree produced by ASLParser#payload_tmpl_decl. + def exitPayload_tmpl_decl(self, ctx:ASLParser.Payload_tmpl_declContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_binding_sample. + def enterPayload_binding_sample(self, ctx:ASLParser.Payload_binding_sampleContext): + pass + + # Exit a parse tree produced by ASLParser#payload_binding_sample. + def exitPayload_binding_sample(self, ctx:ASLParser.Payload_binding_sampleContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_binding_value. + def enterPayload_binding_value(self, ctx:ASLParser.Payload_binding_valueContext): + pass + + # Exit a parse tree produced by ASLParser#payload_binding_value. + def exitPayload_binding_value(self, ctx:ASLParser.Payload_binding_valueContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_arr_decl. + def enterPayload_arr_decl(self, ctx:ASLParser.Payload_arr_declContext): + pass + + # Exit a parse tree produced by ASLParser#payload_arr_decl. + def exitPayload_arr_decl(self, ctx:ASLParser.Payload_arr_declContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_value_decl. + def enterPayload_value_decl(self, ctx:ASLParser.Payload_value_declContext): + pass + + # Exit a parse tree produced by ASLParser#payload_value_decl. + def exitPayload_value_decl(self, ctx:ASLParser.Payload_value_declContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_value_float. + def enterPayload_value_float(self, ctx:ASLParser.Payload_value_floatContext): + pass + + # Exit a parse tree produced by ASLParser#payload_value_float. + def exitPayload_value_float(self, ctx:ASLParser.Payload_value_floatContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_value_int. + def enterPayload_value_int(self, ctx:ASLParser.Payload_value_intContext): + pass + + # Exit a parse tree produced by ASLParser#payload_value_int. + def exitPayload_value_int(self, ctx:ASLParser.Payload_value_intContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_value_bool. + def enterPayload_value_bool(self, ctx:ASLParser.Payload_value_boolContext): + pass + + # Exit a parse tree produced by ASLParser#payload_value_bool. + def exitPayload_value_bool(self, ctx:ASLParser.Payload_value_boolContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_value_null. + def enterPayload_value_null(self, ctx:ASLParser.Payload_value_nullContext): + pass + + # Exit a parse tree produced by ASLParser#payload_value_null. + def exitPayload_value_null(self, ctx:ASLParser.Payload_value_nullContext): + pass + + + # Enter a parse tree produced by ASLParser#payload_value_str. + def enterPayload_value_str(self, ctx:ASLParser.Payload_value_strContext): + pass + + # Exit a parse tree produced by ASLParser#payload_value_str. + def exitPayload_value_str(self, ctx:ASLParser.Payload_value_strContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_decl. + def enterAssign_decl(self, ctx:ASLParser.Assign_declContext): + pass + + # Exit a parse tree produced by ASLParser#assign_decl. + def exitAssign_decl(self, ctx:ASLParser.Assign_declContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_decl_body. + def enterAssign_decl_body(self, ctx:ASLParser.Assign_decl_bodyContext): + pass + + # Exit a parse tree produced by ASLParser#assign_decl_body. + def exitAssign_decl_body(self, ctx:ASLParser.Assign_decl_bodyContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_decl_binding. + def enterAssign_decl_binding(self, ctx:ASLParser.Assign_decl_bindingContext): + pass + + # Exit a parse tree produced by ASLParser#assign_decl_binding. + def exitAssign_decl_binding(self, ctx:ASLParser.Assign_decl_bindingContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_object. + def enterAssign_template_value_object(self, ctx:ASLParser.Assign_template_value_objectContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_object. + def exitAssign_template_value_object(self, ctx:ASLParser.Assign_template_value_objectContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_binding_string_expression_simple. + def enterAssign_template_binding_string_expression_simple(self, ctx:ASLParser.Assign_template_binding_string_expression_simpleContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_binding_string_expression_simple. + def exitAssign_template_binding_string_expression_simple(self, ctx:ASLParser.Assign_template_binding_string_expression_simpleContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_binding_value. + def enterAssign_template_binding_value(self, ctx:ASLParser.Assign_template_binding_valueContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_binding_value. + def exitAssign_template_binding_value(self, ctx:ASLParser.Assign_template_binding_valueContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value. + def enterAssign_template_value(self, ctx:ASLParser.Assign_template_valueContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value. + def exitAssign_template_value(self, ctx:ASLParser.Assign_template_valueContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_array. + def enterAssign_template_value_array(self, ctx:ASLParser.Assign_template_value_arrayContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_array. + def exitAssign_template_value_array(self, ctx:ASLParser.Assign_template_value_arrayContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_terminal_float. + def enterAssign_template_value_terminal_float(self, ctx:ASLParser.Assign_template_value_terminal_floatContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_terminal_float. + def exitAssign_template_value_terminal_float(self, ctx:ASLParser.Assign_template_value_terminal_floatContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_terminal_int. + def enterAssign_template_value_terminal_int(self, ctx:ASLParser.Assign_template_value_terminal_intContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_terminal_int. + def exitAssign_template_value_terminal_int(self, ctx:ASLParser.Assign_template_value_terminal_intContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_terminal_bool. + def enterAssign_template_value_terminal_bool(self, ctx:ASLParser.Assign_template_value_terminal_boolContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_terminal_bool. + def exitAssign_template_value_terminal_bool(self, ctx:ASLParser.Assign_template_value_terminal_boolContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_terminal_null. + def enterAssign_template_value_terminal_null(self, ctx:ASLParser.Assign_template_value_terminal_nullContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_terminal_null. + def exitAssign_template_value_terminal_null(self, ctx:ASLParser.Assign_template_value_terminal_nullContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_terminal_string_jsonata. + def enterAssign_template_value_terminal_string_jsonata(self, ctx:ASLParser.Assign_template_value_terminal_string_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_terminal_string_jsonata. + def exitAssign_template_value_terminal_string_jsonata(self, ctx:ASLParser.Assign_template_value_terminal_string_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#assign_template_value_terminal_string_literal. + def enterAssign_template_value_terminal_string_literal(self, ctx:ASLParser.Assign_template_value_terminal_string_literalContext): + pass + + # Exit a parse tree produced by ASLParser#assign_template_value_terminal_string_literal. + def exitAssign_template_value_terminal_string_literal(self, ctx:ASLParser.Assign_template_value_terminal_string_literalContext): + pass + + + # Enter a parse tree produced by ASLParser#arguments_jsonata_template_value_object. + def enterArguments_jsonata_template_value_object(self, ctx:ASLParser.Arguments_jsonata_template_value_objectContext): + pass + + # Exit a parse tree produced by ASLParser#arguments_jsonata_template_value_object. + def exitArguments_jsonata_template_value_object(self, ctx:ASLParser.Arguments_jsonata_template_value_objectContext): + pass + + + # Enter a parse tree produced by ASLParser#arguments_string_jsonata. + def enterArguments_string_jsonata(self, ctx:ASLParser.Arguments_string_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#arguments_string_jsonata. + def exitArguments_string_jsonata(self, ctx:ASLParser.Arguments_string_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#output_decl. + def enterOutput_decl(self, ctx:ASLParser.Output_declContext): + pass + + # Exit a parse tree produced by ASLParser#output_decl. + def exitOutput_decl(self, ctx:ASLParser.Output_declContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_object. + def enterJsonata_template_value_object(self, ctx:ASLParser.Jsonata_template_value_objectContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_object. + def exitJsonata_template_value_object(self, ctx:ASLParser.Jsonata_template_value_objectContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_binding. + def enterJsonata_template_binding(self, ctx:ASLParser.Jsonata_template_bindingContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_binding. + def exitJsonata_template_binding(self, ctx:ASLParser.Jsonata_template_bindingContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value. + def enterJsonata_template_value(self, ctx:ASLParser.Jsonata_template_valueContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value. + def exitJsonata_template_value(self, ctx:ASLParser.Jsonata_template_valueContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_array. + def enterJsonata_template_value_array(self, ctx:ASLParser.Jsonata_template_value_arrayContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_array. + def exitJsonata_template_value_array(self, ctx:ASLParser.Jsonata_template_value_arrayContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_terminal_float. + def enterJsonata_template_value_terminal_float(self, ctx:ASLParser.Jsonata_template_value_terminal_floatContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_terminal_float. + def exitJsonata_template_value_terminal_float(self, ctx:ASLParser.Jsonata_template_value_terminal_floatContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_terminal_int. + def enterJsonata_template_value_terminal_int(self, ctx:ASLParser.Jsonata_template_value_terminal_intContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_terminal_int. + def exitJsonata_template_value_terminal_int(self, ctx:ASLParser.Jsonata_template_value_terminal_intContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_terminal_bool. + def enterJsonata_template_value_terminal_bool(self, ctx:ASLParser.Jsonata_template_value_terminal_boolContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_terminal_bool. + def exitJsonata_template_value_terminal_bool(self, ctx:ASLParser.Jsonata_template_value_terminal_boolContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_terminal_null. + def enterJsonata_template_value_terminal_null(self, ctx:ASLParser.Jsonata_template_value_terminal_nullContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_terminal_null. + def exitJsonata_template_value_terminal_null(self, ctx:ASLParser.Jsonata_template_value_terminal_nullContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_terminal_string_jsonata. + def enterJsonata_template_value_terminal_string_jsonata(self, ctx:ASLParser.Jsonata_template_value_terminal_string_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_terminal_string_jsonata. + def exitJsonata_template_value_terminal_string_jsonata(self, ctx:ASLParser.Jsonata_template_value_terminal_string_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#jsonata_template_value_terminal_string_literal. + def enterJsonata_template_value_terminal_string_literal(self, ctx:ASLParser.Jsonata_template_value_terminal_string_literalContext): + pass + + # Exit a parse tree produced by ASLParser#jsonata_template_value_terminal_string_literal. + def exitJsonata_template_value_terminal_string_literal(self, ctx:ASLParser.Jsonata_template_value_terminal_string_literalContext): + pass + + + # Enter a parse tree produced by ASLParser#result_selector_decl. + def enterResult_selector_decl(self, ctx:ASLParser.Result_selector_declContext): + pass + + # Exit a parse tree produced by ASLParser#result_selector_decl. + def exitResult_selector_decl(self, ctx:ASLParser.Result_selector_declContext): + pass + + + # Enter a parse tree produced by ASLParser#state_type. + def enterState_type(self, ctx:ASLParser.State_typeContext): + pass + + # Exit a parse tree produced by ASLParser#state_type. + def exitState_type(self, ctx:ASLParser.State_typeContext): + pass + + + # Enter a parse tree produced by ASLParser#choices_decl. + def enterChoices_decl(self, ctx:ASLParser.Choices_declContext): + pass + + # Exit a parse tree produced by ASLParser#choices_decl. + def exitChoices_decl(self, ctx:ASLParser.Choices_declContext): + pass + + + # Enter a parse tree produced by ASLParser#choice_rule_comparison_variable. + def enterChoice_rule_comparison_variable(self, ctx:ASLParser.Choice_rule_comparison_variableContext): + pass + + # Exit a parse tree produced by ASLParser#choice_rule_comparison_variable. + def exitChoice_rule_comparison_variable(self, ctx:ASLParser.Choice_rule_comparison_variableContext): + pass + + + # Enter a parse tree produced by ASLParser#choice_rule_comparison_composite. + def enterChoice_rule_comparison_composite(self, ctx:ASLParser.Choice_rule_comparison_compositeContext): + pass + + # Exit a parse tree produced by ASLParser#choice_rule_comparison_composite. + def exitChoice_rule_comparison_composite(self, ctx:ASLParser.Choice_rule_comparison_compositeContext): + pass + + + # Enter a parse tree produced by ASLParser#comparison_variable_stmt. + def enterComparison_variable_stmt(self, ctx:ASLParser.Comparison_variable_stmtContext): + pass + + # Exit a parse tree produced by ASLParser#comparison_variable_stmt. + def exitComparison_variable_stmt(self, ctx:ASLParser.Comparison_variable_stmtContext): + pass + + + # Enter a parse tree produced by ASLParser#comparison_composite_stmt. + def enterComparison_composite_stmt(self, ctx:ASLParser.Comparison_composite_stmtContext): + pass + + # Exit a parse tree produced by ASLParser#comparison_composite_stmt. + def exitComparison_composite_stmt(self, ctx:ASLParser.Comparison_composite_stmtContext): + pass + + + # Enter a parse tree produced by ASLParser#comparison_composite. + def enterComparison_composite(self, ctx:ASLParser.Comparison_compositeContext): + pass + + # Exit a parse tree produced by ASLParser#comparison_composite. + def exitComparison_composite(self, ctx:ASLParser.Comparison_compositeContext): + pass + + + # Enter a parse tree produced by ASLParser#variable_decl. + def enterVariable_decl(self, ctx:ASLParser.Variable_declContext): + pass + + # Exit a parse tree produced by ASLParser#variable_decl. + def exitVariable_decl(self, ctx:ASLParser.Variable_declContext): + pass + + + # Enter a parse tree produced by ASLParser#condition_lit. + def enterCondition_lit(self, ctx:ASLParser.Condition_litContext): + pass + + # Exit a parse tree produced by ASLParser#condition_lit. + def exitCondition_lit(self, ctx:ASLParser.Condition_litContext): + pass + + + # Enter a parse tree produced by ASLParser#condition_string_jsonata. + def enterCondition_string_jsonata(self, ctx:ASLParser.Condition_string_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#condition_string_jsonata. + def exitCondition_string_jsonata(self, ctx:ASLParser.Condition_string_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#comparison_func_string_variable_sample. + def enterComparison_func_string_variable_sample(self, ctx:ASLParser.Comparison_func_string_variable_sampleContext): + pass + + # Exit a parse tree produced by ASLParser#comparison_func_string_variable_sample. + def exitComparison_func_string_variable_sample(self, ctx:ASLParser.Comparison_func_string_variable_sampleContext): + pass + + + # Enter a parse tree produced by ASLParser#comparison_func_value. + def enterComparison_func_value(self, ctx:ASLParser.Comparison_func_valueContext): + pass + + # Exit a parse tree produced by ASLParser#comparison_func_value. + def exitComparison_func_value(self, ctx:ASLParser.Comparison_func_valueContext): + pass + + + # Enter a parse tree produced by ASLParser#branches_decl. + def enterBranches_decl(self, ctx:ASLParser.Branches_declContext): + pass + + # Exit a parse tree produced by ASLParser#branches_decl. + def exitBranches_decl(self, ctx:ASLParser.Branches_declContext): + pass + + + # Enter a parse tree produced by ASLParser#item_processor_decl. + def enterItem_processor_decl(self, ctx:ASLParser.Item_processor_declContext): + pass + + # Exit a parse tree produced by ASLParser#item_processor_decl. + def exitItem_processor_decl(self, ctx:ASLParser.Item_processor_declContext): + pass + + + # Enter a parse tree produced by ASLParser#item_processor_item. + def enterItem_processor_item(self, ctx:ASLParser.Item_processor_itemContext): + pass + + # Exit a parse tree produced by ASLParser#item_processor_item. + def exitItem_processor_item(self, ctx:ASLParser.Item_processor_itemContext): + pass + + + # Enter a parse tree produced by ASLParser#processor_config_decl. + def enterProcessor_config_decl(self, ctx:ASLParser.Processor_config_declContext): + pass + + # Exit a parse tree produced by ASLParser#processor_config_decl. + def exitProcessor_config_decl(self, ctx:ASLParser.Processor_config_declContext): + pass + + + # Enter a parse tree produced by ASLParser#processor_config_field. + def enterProcessor_config_field(self, ctx:ASLParser.Processor_config_fieldContext): + pass + + # Exit a parse tree produced by ASLParser#processor_config_field. + def exitProcessor_config_field(self, ctx:ASLParser.Processor_config_fieldContext): + pass + + + # Enter a parse tree produced by ASLParser#mode_decl. + def enterMode_decl(self, ctx:ASLParser.Mode_declContext): + pass + + # Exit a parse tree produced by ASLParser#mode_decl. + def exitMode_decl(self, ctx:ASLParser.Mode_declContext): + pass + + + # Enter a parse tree produced by ASLParser#mode_type. + def enterMode_type(self, ctx:ASLParser.Mode_typeContext): + pass + + # Exit a parse tree produced by ASLParser#mode_type. + def exitMode_type(self, ctx:ASLParser.Mode_typeContext): + pass + + + # Enter a parse tree produced by ASLParser#execution_decl. + def enterExecution_decl(self, ctx:ASLParser.Execution_declContext): + pass + + # Exit a parse tree produced by ASLParser#execution_decl. + def exitExecution_decl(self, ctx:ASLParser.Execution_declContext): + pass + + + # Enter a parse tree produced by ASLParser#execution_type. + def enterExecution_type(self, ctx:ASLParser.Execution_typeContext): + pass + + # Exit a parse tree produced by ASLParser#execution_type. + def exitExecution_type(self, ctx:ASLParser.Execution_typeContext): + pass + + + # Enter a parse tree produced by ASLParser#iterator_decl. + def enterIterator_decl(self, ctx:ASLParser.Iterator_declContext): + pass + + # Exit a parse tree produced by ASLParser#iterator_decl. + def exitIterator_decl(self, ctx:ASLParser.Iterator_declContext): + pass + + + # Enter a parse tree produced by ASLParser#iterator_decl_item. + def enterIterator_decl_item(self, ctx:ASLParser.Iterator_decl_itemContext): + pass + + # Exit a parse tree produced by ASLParser#iterator_decl_item. + def exitIterator_decl_item(self, ctx:ASLParser.Iterator_decl_itemContext): + pass + + + # Enter a parse tree produced by ASLParser#item_selector_decl. + def enterItem_selector_decl(self, ctx:ASLParser.Item_selector_declContext): + pass + + # Exit a parse tree produced by ASLParser#item_selector_decl. + def exitItem_selector_decl(self, ctx:ASLParser.Item_selector_declContext): + pass + + + # Enter a parse tree produced by ASLParser#item_reader_decl. + def enterItem_reader_decl(self, ctx:ASLParser.Item_reader_declContext): + pass + + # Exit a parse tree produced by ASLParser#item_reader_decl. + def exitItem_reader_decl(self, ctx:ASLParser.Item_reader_declContext): + pass + + + # Enter a parse tree produced by ASLParser#items_reader_field. + def enterItems_reader_field(self, ctx:ASLParser.Items_reader_fieldContext): + pass + + # Exit a parse tree produced by ASLParser#items_reader_field. + def exitItems_reader_field(self, ctx:ASLParser.Items_reader_fieldContext): + pass + + + # Enter a parse tree produced by ASLParser#reader_config_decl. + def enterReader_config_decl(self, ctx:ASLParser.Reader_config_declContext): + pass + + # Exit a parse tree produced by ASLParser#reader_config_decl. + def exitReader_config_decl(self, ctx:ASLParser.Reader_config_declContext): + pass + + + # Enter a parse tree produced by ASLParser#reader_config_field. + def enterReader_config_field(self, ctx:ASLParser.Reader_config_fieldContext): + pass + + # Exit a parse tree produced by ASLParser#reader_config_field. + def exitReader_config_field(self, ctx:ASLParser.Reader_config_fieldContext): + pass + + + # Enter a parse tree produced by ASLParser#input_type_decl. + def enterInput_type_decl(self, ctx:ASLParser.Input_type_declContext): + pass + + # Exit a parse tree produced by ASLParser#input_type_decl. + def exitInput_type_decl(self, ctx:ASLParser.Input_type_declContext): + pass + + + # Enter a parse tree produced by ASLParser#csv_header_location_decl. + def enterCsv_header_location_decl(self, ctx:ASLParser.Csv_header_location_declContext): + pass + + # Exit a parse tree produced by ASLParser#csv_header_location_decl. + def exitCsv_header_location_decl(self, ctx:ASLParser.Csv_header_location_declContext): + pass + + + # Enter a parse tree produced by ASLParser#csv_headers_decl. + def enterCsv_headers_decl(self, ctx:ASLParser.Csv_headers_declContext): + pass + + # Exit a parse tree produced by ASLParser#csv_headers_decl. + def exitCsv_headers_decl(self, ctx:ASLParser.Csv_headers_declContext): + pass + + + # Enter a parse tree produced by ASLParser#max_items_string_jsonata. + def enterMax_items_string_jsonata(self, ctx:ASLParser.Max_items_string_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#max_items_string_jsonata. + def exitMax_items_string_jsonata(self, ctx:ASLParser.Max_items_string_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#max_items_int. + def enterMax_items_int(self, ctx:ASLParser.Max_items_intContext): + pass + + # Exit a parse tree produced by ASLParser#max_items_int. + def exitMax_items_int(self, ctx:ASLParser.Max_items_intContext): + pass + + + # Enter a parse tree produced by ASLParser#max_items_path. + def enterMax_items_path(self, ctx:ASLParser.Max_items_pathContext): + pass + + # Exit a parse tree produced by ASLParser#max_items_path. + def exitMax_items_path(self, ctx:ASLParser.Max_items_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#tolerated_failure_count_string_jsonata. + def enterTolerated_failure_count_string_jsonata(self, ctx:ASLParser.Tolerated_failure_count_string_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#tolerated_failure_count_string_jsonata. + def exitTolerated_failure_count_string_jsonata(self, ctx:ASLParser.Tolerated_failure_count_string_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#tolerated_failure_count_int. + def enterTolerated_failure_count_int(self, ctx:ASLParser.Tolerated_failure_count_intContext): + pass + + # Exit a parse tree produced by ASLParser#tolerated_failure_count_int. + def exitTolerated_failure_count_int(self, ctx:ASLParser.Tolerated_failure_count_intContext): + pass + + + # Enter a parse tree produced by ASLParser#tolerated_failure_count_path. + def enterTolerated_failure_count_path(self, ctx:ASLParser.Tolerated_failure_count_pathContext): + pass + + # Exit a parse tree produced by ASLParser#tolerated_failure_count_path. + def exitTolerated_failure_count_path(self, ctx:ASLParser.Tolerated_failure_count_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#tolerated_failure_percentage_string_jsonata. + def enterTolerated_failure_percentage_string_jsonata(self, ctx:ASLParser.Tolerated_failure_percentage_string_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#tolerated_failure_percentage_string_jsonata. + def exitTolerated_failure_percentage_string_jsonata(self, ctx:ASLParser.Tolerated_failure_percentage_string_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#tolerated_failure_percentage_number. + def enterTolerated_failure_percentage_number(self, ctx:ASLParser.Tolerated_failure_percentage_numberContext): + pass + + # Exit a parse tree produced by ASLParser#tolerated_failure_percentage_number. + def exitTolerated_failure_percentage_number(self, ctx:ASLParser.Tolerated_failure_percentage_numberContext): + pass + + + # Enter a parse tree produced by ASLParser#tolerated_failure_percentage_path. + def enterTolerated_failure_percentage_path(self, ctx:ASLParser.Tolerated_failure_percentage_pathContext): + pass + + # Exit a parse tree produced by ASLParser#tolerated_failure_percentage_path. + def exitTolerated_failure_percentage_path(self, ctx:ASLParser.Tolerated_failure_percentage_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#label_decl. + def enterLabel_decl(self, ctx:ASLParser.Label_declContext): + pass + + # Exit a parse tree produced by ASLParser#label_decl. + def exitLabel_decl(self, ctx:ASLParser.Label_declContext): + pass + + + # Enter a parse tree produced by ASLParser#result_writer_decl. + def enterResult_writer_decl(self, ctx:ASLParser.Result_writer_declContext): + pass + + # Exit a parse tree produced by ASLParser#result_writer_decl. + def exitResult_writer_decl(self, ctx:ASLParser.Result_writer_declContext): + pass + + + # Enter a parse tree produced by ASLParser#result_writer_field. + def enterResult_writer_field(self, ctx:ASLParser.Result_writer_fieldContext): + pass + + # Exit a parse tree produced by ASLParser#result_writer_field. + def exitResult_writer_field(self, ctx:ASLParser.Result_writer_fieldContext): + pass + + + # Enter a parse tree produced by ASLParser#retry_decl. + def enterRetry_decl(self, ctx:ASLParser.Retry_declContext): + pass + + # Exit a parse tree produced by ASLParser#retry_decl. + def exitRetry_decl(self, ctx:ASLParser.Retry_declContext): + pass + + + # Enter a parse tree produced by ASLParser#retrier_decl. + def enterRetrier_decl(self, ctx:ASLParser.Retrier_declContext): + pass + + # Exit a parse tree produced by ASLParser#retrier_decl. + def exitRetrier_decl(self, ctx:ASLParser.Retrier_declContext): + pass + + + # Enter a parse tree produced by ASLParser#retrier_stmt. + def enterRetrier_stmt(self, ctx:ASLParser.Retrier_stmtContext): + pass + + # Exit a parse tree produced by ASLParser#retrier_stmt. + def exitRetrier_stmt(self, ctx:ASLParser.Retrier_stmtContext): + pass + + + # Enter a parse tree produced by ASLParser#error_equals_decl. + def enterError_equals_decl(self, ctx:ASLParser.Error_equals_declContext): + pass + + # Exit a parse tree produced by ASLParser#error_equals_decl. + def exitError_equals_decl(self, ctx:ASLParser.Error_equals_declContext): + pass + + + # Enter a parse tree produced by ASLParser#interval_seconds_decl. + def enterInterval_seconds_decl(self, ctx:ASLParser.Interval_seconds_declContext): + pass + + # Exit a parse tree produced by ASLParser#interval_seconds_decl. + def exitInterval_seconds_decl(self, ctx:ASLParser.Interval_seconds_declContext): + pass + + + # Enter a parse tree produced by ASLParser#max_attempts_decl. + def enterMax_attempts_decl(self, ctx:ASLParser.Max_attempts_declContext): + pass + + # Exit a parse tree produced by ASLParser#max_attempts_decl. + def exitMax_attempts_decl(self, ctx:ASLParser.Max_attempts_declContext): + pass + + + # Enter a parse tree produced by ASLParser#backoff_rate_decl. + def enterBackoff_rate_decl(self, ctx:ASLParser.Backoff_rate_declContext): + pass + + # Exit a parse tree produced by ASLParser#backoff_rate_decl. + def exitBackoff_rate_decl(self, ctx:ASLParser.Backoff_rate_declContext): + pass + + + # Enter a parse tree produced by ASLParser#max_delay_seconds_decl. + def enterMax_delay_seconds_decl(self, ctx:ASLParser.Max_delay_seconds_declContext): + pass + + # Exit a parse tree produced by ASLParser#max_delay_seconds_decl. + def exitMax_delay_seconds_decl(self, ctx:ASLParser.Max_delay_seconds_declContext): + pass + + + # Enter a parse tree produced by ASLParser#jitter_strategy_decl. + def enterJitter_strategy_decl(self, ctx:ASLParser.Jitter_strategy_declContext): + pass + + # Exit a parse tree produced by ASLParser#jitter_strategy_decl. + def exitJitter_strategy_decl(self, ctx:ASLParser.Jitter_strategy_declContext): + pass + + + # Enter a parse tree produced by ASLParser#catch_decl. + def enterCatch_decl(self, ctx:ASLParser.Catch_declContext): + pass + + # Exit a parse tree produced by ASLParser#catch_decl. + def exitCatch_decl(self, ctx:ASLParser.Catch_declContext): + pass + + + # Enter a parse tree produced by ASLParser#catcher_decl. + def enterCatcher_decl(self, ctx:ASLParser.Catcher_declContext): + pass + + # Exit a parse tree produced by ASLParser#catcher_decl. + def exitCatcher_decl(self, ctx:ASLParser.Catcher_declContext): + pass + + + # Enter a parse tree produced by ASLParser#catcher_stmt. + def enterCatcher_stmt(self, ctx:ASLParser.Catcher_stmtContext): + pass + + # Exit a parse tree produced by ASLParser#catcher_stmt. + def exitCatcher_stmt(self, ctx:ASLParser.Catcher_stmtContext): + pass + + + # Enter a parse tree produced by ASLParser#comparison_op. + def enterComparison_op(self, ctx:ASLParser.Comparison_opContext): + pass + + # Exit a parse tree produced by ASLParser#comparison_op. + def exitComparison_op(self, ctx:ASLParser.Comparison_opContext): + pass + + + # Enter a parse tree produced by ASLParser#choice_operator. + def enterChoice_operator(self, ctx:ASLParser.Choice_operatorContext): + pass + + # Exit a parse tree produced by ASLParser#choice_operator. + def exitChoice_operator(self, ctx:ASLParser.Choice_operatorContext): + pass + + + # Enter a parse tree produced by ASLParser#states_error_name. + def enterStates_error_name(self, ctx:ASLParser.States_error_nameContext): + pass + + # Exit a parse tree produced by ASLParser#states_error_name. + def exitStates_error_name(self, ctx:ASLParser.States_error_nameContext): + pass + + + # Enter a parse tree produced by ASLParser#error_name. + def enterError_name(self, ctx:ASLParser.Error_nameContext): + pass + + # Exit a parse tree produced by ASLParser#error_name. + def exitError_name(self, ctx:ASLParser.Error_nameContext): + pass + + + # Enter a parse tree produced by ASLParser#json_obj_decl. + def enterJson_obj_decl(self, ctx:ASLParser.Json_obj_declContext): + pass + + # Exit a parse tree produced by ASLParser#json_obj_decl. + def exitJson_obj_decl(self, ctx:ASLParser.Json_obj_declContext): + pass + + + # Enter a parse tree produced by ASLParser#json_binding. + def enterJson_binding(self, ctx:ASLParser.Json_bindingContext): + pass + + # Exit a parse tree produced by ASLParser#json_binding. + def exitJson_binding(self, ctx:ASLParser.Json_bindingContext): + pass + + + # Enter a parse tree produced by ASLParser#json_arr_decl. + def enterJson_arr_decl(self, ctx:ASLParser.Json_arr_declContext): + pass + + # Exit a parse tree produced by ASLParser#json_arr_decl. + def exitJson_arr_decl(self, ctx:ASLParser.Json_arr_declContext): + pass + + + # Enter a parse tree produced by ASLParser#json_value_decl. + def enterJson_value_decl(self, ctx:ASLParser.Json_value_declContext): + pass + + # Exit a parse tree produced by ASLParser#json_value_decl. + def exitJson_value_decl(self, ctx:ASLParser.Json_value_declContext): + pass + + + # Enter a parse tree produced by ASLParser#string_sampler. + def enterString_sampler(self, ctx:ASLParser.String_samplerContext): + pass + + # Exit a parse tree produced by ASLParser#string_sampler. + def exitString_sampler(self, ctx:ASLParser.String_samplerContext): + pass + + + # Enter a parse tree produced by ASLParser#string_expression_simple. + def enterString_expression_simple(self, ctx:ASLParser.String_expression_simpleContext): + pass + + # Exit a parse tree produced by ASLParser#string_expression_simple. + def exitString_expression_simple(self, ctx:ASLParser.String_expression_simpleContext): + pass + + + # Enter a parse tree produced by ASLParser#string_expression. + def enterString_expression(self, ctx:ASLParser.String_expressionContext): + pass + + # Exit a parse tree produced by ASLParser#string_expression. + def exitString_expression(self, ctx:ASLParser.String_expressionContext): + pass + + + # Enter a parse tree produced by ASLParser#string_jsonpath. + def enterString_jsonpath(self, ctx:ASLParser.String_jsonpathContext): + pass + + # Exit a parse tree produced by ASLParser#string_jsonpath. + def exitString_jsonpath(self, ctx:ASLParser.String_jsonpathContext): + pass + + + # Enter a parse tree produced by ASLParser#string_context_path. + def enterString_context_path(self, ctx:ASLParser.String_context_pathContext): + pass + + # Exit a parse tree produced by ASLParser#string_context_path. + def exitString_context_path(self, ctx:ASLParser.String_context_pathContext): + pass + + + # Enter a parse tree produced by ASLParser#string_variable_sample. + def enterString_variable_sample(self, ctx:ASLParser.String_variable_sampleContext): + pass + + # Exit a parse tree produced by ASLParser#string_variable_sample. + def exitString_variable_sample(self, ctx:ASLParser.String_variable_sampleContext): + pass + + + # Enter a parse tree produced by ASLParser#string_intrinsic_function. + def enterString_intrinsic_function(self, ctx:ASLParser.String_intrinsic_functionContext): + pass + + # Exit a parse tree produced by ASLParser#string_intrinsic_function. + def exitString_intrinsic_function(self, ctx:ASLParser.String_intrinsic_functionContext): + pass + + + # Enter a parse tree produced by ASLParser#string_jsonata. + def enterString_jsonata(self, ctx:ASLParser.String_jsonataContext): + pass + + # Exit a parse tree produced by ASLParser#string_jsonata. + def exitString_jsonata(self, ctx:ASLParser.String_jsonataContext): + pass + + + # Enter a parse tree produced by ASLParser#string_literal. + def enterString_literal(self, ctx:ASLParser.String_literalContext): + pass + + # Exit a parse tree produced by ASLParser#string_literal. + def exitString_literal(self, ctx:ASLParser.String_literalContext): + pass + + + # Enter a parse tree produced by ASLParser#soft_string_keyword. + def enterSoft_string_keyword(self, ctx:ASLParser.Soft_string_keywordContext): + pass + + # Exit a parse tree produced by ASLParser#soft_string_keyword. + def exitSoft_string_keyword(self, ctx:ASLParser.Soft_string_keywordContext): + pass + + + +del ASLParser \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParserVisitor.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParserVisitor.py new file mode 100644 index 0000000000000..ed1b7b0611097 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/ASLParserVisitor.py @@ -0,0 +1,793 @@ +# Generated from ASLParser.g4 by ANTLR 4.13.2 +from antlr4 import * +if "." in __name__: + from .ASLParser import ASLParser +else: + from ASLParser import ASLParser + +# This class defines a complete generic visitor for a parse tree produced by ASLParser. + +class ASLParserVisitor(ParseTreeVisitor): + + # Visit a parse tree produced by ASLParser#state_machine. + def visitState_machine(self, ctx:ASLParser.State_machineContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#program_decl. + def visitProgram_decl(self, ctx:ASLParser.Program_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#top_layer_stmt. + def visitTop_layer_stmt(self, ctx:ASLParser.Top_layer_stmtContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#startat_decl. + def visitStartat_decl(self, ctx:ASLParser.Startat_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#comment_decl. + def visitComment_decl(self, ctx:ASLParser.Comment_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#version_decl. + def visitVersion_decl(self, ctx:ASLParser.Version_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#query_language_decl. + def visitQuery_language_decl(self, ctx:ASLParser.Query_language_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#state_stmt. + def visitState_stmt(self, ctx:ASLParser.State_stmtContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#states_decl. + def visitStates_decl(self, ctx:ASLParser.States_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#state_decl. + def visitState_decl(self, ctx:ASLParser.State_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#state_decl_body. + def visitState_decl_body(self, ctx:ASLParser.State_decl_bodyContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#type_decl. + def visitType_decl(self, ctx:ASLParser.Type_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#next_decl. + def visitNext_decl(self, ctx:ASLParser.Next_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#resource_decl. + def visitResource_decl(self, ctx:ASLParser.Resource_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#input_path_decl. + def visitInput_path_decl(self, ctx:ASLParser.Input_path_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#result_decl. + def visitResult_decl(self, ctx:ASLParser.Result_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#result_path_decl. + def visitResult_path_decl(self, ctx:ASLParser.Result_path_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#output_path_decl. + def visitOutput_path_decl(self, ctx:ASLParser.Output_path_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#end_decl. + def visitEnd_decl(self, ctx:ASLParser.End_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#default_decl. + def visitDefault_decl(self, ctx:ASLParser.Default_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#error. + def visitError(self, ctx:ASLParser.ErrorContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#error_path. + def visitError_path(self, ctx:ASLParser.Error_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#cause. + def visitCause(self, ctx:ASLParser.CauseContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#cause_path. + def visitCause_path(self, ctx:ASLParser.Cause_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#seconds_jsonata. + def visitSeconds_jsonata(self, ctx:ASLParser.Seconds_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#seconds_int. + def visitSeconds_int(self, ctx:ASLParser.Seconds_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#seconds_path. + def visitSeconds_path(self, ctx:ASLParser.Seconds_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#timestamp. + def visitTimestamp(self, ctx:ASLParser.TimestampContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#timestamp_path. + def visitTimestamp_path(self, ctx:ASLParser.Timestamp_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#items_array. + def visitItems_array(self, ctx:ASLParser.Items_arrayContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#items_jsonata. + def visitItems_jsonata(self, ctx:ASLParser.Items_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#items_path_decl. + def visitItems_path_decl(self, ctx:ASLParser.Items_path_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_concurrency_jsonata. + def visitMax_concurrency_jsonata(self, ctx:ASLParser.Max_concurrency_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_concurrency_int. + def visitMax_concurrency_int(self, ctx:ASLParser.Max_concurrency_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_concurrency_path. + def visitMax_concurrency_path(self, ctx:ASLParser.Max_concurrency_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#parameters_decl. + def visitParameters_decl(self, ctx:ASLParser.Parameters_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#credentials_decl. + def visitCredentials_decl(self, ctx:ASLParser.Credentials_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#role_arn. + def visitRole_arn(self, ctx:ASLParser.Role_arnContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#role_path. + def visitRole_path(self, ctx:ASLParser.Role_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#timeout_seconds_jsonata. + def visitTimeout_seconds_jsonata(self, ctx:ASLParser.Timeout_seconds_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#timeout_seconds_int. + def visitTimeout_seconds_int(self, ctx:ASLParser.Timeout_seconds_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#timeout_seconds_path. + def visitTimeout_seconds_path(self, ctx:ASLParser.Timeout_seconds_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#heartbeat_seconds_jsonata. + def visitHeartbeat_seconds_jsonata(self, ctx:ASLParser.Heartbeat_seconds_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#heartbeat_seconds_int. + def visitHeartbeat_seconds_int(self, ctx:ASLParser.Heartbeat_seconds_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#heartbeat_seconds_path. + def visitHeartbeat_seconds_path(self, ctx:ASLParser.Heartbeat_seconds_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_tmpl_decl. + def visitPayload_tmpl_decl(self, ctx:ASLParser.Payload_tmpl_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_binding_sample. + def visitPayload_binding_sample(self, ctx:ASLParser.Payload_binding_sampleContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_binding_value. + def visitPayload_binding_value(self, ctx:ASLParser.Payload_binding_valueContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_arr_decl. + def visitPayload_arr_decl(self, ctx:ASLParser.Payload_arr_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_value_decl. + def visitPayload_value_decl(self, ctx:ASLParser.Payload_value_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_value_float. + def visitPayload_value_float(self, ctx:ASLParser.Payload_value_floatContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_value_int. + def visitPayload_value_int(self, ctx:ASLParser.Payload_value_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_value_bool. + def visitPayload_value_bool(self, ctx:ASLParser.Payload_value_boolContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_value_null. + def visitPayload_value_null(self, ctx:ASLParser.Payload_value_nullContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#payload_value_str. + def visitPayload_value_str(self, ctx:ASLParser.Payload_value_strContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_decl. + def visitAssign_decl(self, ctx:ASLParser.Assign_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_decl_body. + def visitAssign_decl_body(self, ctx:ASLParser.Assign_decl_bodyContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_decl_binding. + def visitAssign_decl_binding(self, ctx:ASLParser.Assign_decl_bindingContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_object. + def visitAssign_template_value_object(self, ctx:ASLParser.Assign_template_value_objectContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_binding_string_expression_simple. + def visitAssign_template_binding_string_expression_simple(self, ctx:ASLParser.Assign_template_binding_string_expression_simpleContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_binding_value. + def visitAssign_template_binding_value(self, ctx:ASLParser.Assign_template_binding_valueContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value. + def visitAssign_template_value(self, ctx:ASLParser.Assign_template_valueContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_array. + def visitAssign_template_value_array(self, ctx:ASLParser.Assign_template_value_arrayContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_terminal_float. + def visitAssign_template_value_terminal_float(self, ctx:ASLParser.Assign_template_value_terminal_floatContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_terminal_int. + def visitAssign_template_value_terminal_int(self, ctx:ASLParser.Assign_template_value_terminal_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_terminal_bool. + def visitAssign_template_value_terminal_bool(self, ctx:ASLParser.Assign_template_value_terminal_boolContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_terminal_null. + def visitAssign_template_value_terminal_null(self, ctx:ASLParser.Assign_template_value_terminal_nullContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_terminal_string_jsonata. + def visitAssign_template_value_terminal_string_jsonata(self, ctx:ASLParser.Assign_template_value_terminal_string_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#assign_template_value_terminal_string_literal. + def visitAssign_template_value_terminal_string_literal(self, ctx:ASLParser.Assign_template_value_terminal_string_literalContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#arguments_jsonata_template_value_object. + def visitArguments_jsonata_template_value_object(self, ctx:ASLParser.Arguments_jsonata_template_value_objectContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#arguments_string_jsonata. + def visitArguments_string_jsonata(self, ctx:ASLParser.Arguments_string_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#output_decl. + def visitOutput_decl(self, ctx:ASLParser.Output_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_object. + def visitJsonata_template_value_object(self, ctx:ASLParser.Jsonata_template_value_objectContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_binding. + def visitJsonata_template_binding(self, ctx:ASLParser.Jsonata_template_bindingContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value. + def visitJsonata_template_value(self, ctx:ASLParser.Jsonata_template_valueContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_array. + def visitJsonata_template_value_array(self, ctx:ASLParser.Jsonata_template_value_arrayContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_terminal_float. + def visitJsonata_template_value_terminal_float(self, ctx:ASLParser.Jsonata_template_value_terminal_floatContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_terminal_int. + def visitJsonata_template_value_terminal_int(self, ctx:ASLParser.Jsonata_template_value_terminal_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_terminal_bool. + def visitJsonata_template_value_terminal_bool(self, ctx:ASLParser.Jsonata_template_value_terminal_boolContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_terminal_null. + def visitJsonata_template_value_terminal_null(self, ctx:ASLParser.Jsonata_template_value_terminal_nullContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_terminal_string_jsonata. + def visitJsonata_template_value_terminal_string_jsonata(self, ctx:ASLParser.Jsonata_template_value_terminal_string_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jsonata_template_value_terminal_string_literal. + def visitJsonata_template_value_terminal_string_literal(self, ctx:ASLParser.Jsonata_template_value_terminal_string_literalContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#result_selector_decl. + def visitResult_selector_decl(self, ctx:ASLParser.Result_selector_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#state_type. + def visitState_type(self, ctx:ASLParser.State_typeContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#choices_decl. + def visitChoices_decl(self, ctx:ASLParser.Choices_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#choice_rule_comparison_variable. + def visitChoice_rule_comparison_variable(self, ctx:ASLParser.Choice_rule_comparison_variableContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#choice_rule_comparison_composite. + def visitChoice_rule_comparison_composite(self, ctx:ASLParser.Choice_rule_comparison_compositeContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#comparison_variable_stmt. + def visitComparison_variable_stmt(self, ctx:ASLParser.Comparison_variable_stmtContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#comparison_composite_stmt. + def visitComparison_composite_stmt(self, ctx:ASLParser.Comparison_composite_stmtContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#comparison_composite. + def visitComparison_composite(self, ctx:ASLParser.Comparison_compositeContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#variable_decl. + def visitVariable_decl(self, ctx:ASLParser.Variable_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#condition_lit. + def visitCondition_lit(self, ctx:ASLParser.Condition_litContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#condition_string_jsonata. + def visitCondition_string_jsonata(self, ctx:ASLParser.Condition_string_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#comparison_func_string_variable_sample. + def visitComparison_func_string_variable_sample(self, ctx:ASLParser.Comparison_func_string_variable_sampleContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#comparison_func_value. + def visitComparison_func_value(self, ctx:ASLParser.Comparison_func_valueContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#branches_decl. + def visitBranches_decl(self, ctx:ASLParser.Branches_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#item_processor_decl. + def visitItem_processor_decl(self, ctx:ASLParser.Item_processor_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#item_processor_item. + def visitItem_processor_item(self, ctx:ASLParser.Item_processor_itemContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#processor_config_decl. + def visitProcessor_config_decl(self, ctx:ASLParser.Processor_config_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#processor_config_field. + def visitProcessor_config_field(self, ctx:ASLParser.Processor_config_fieldContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#mode_decl. + def visitMode_decl(self, ctx:ASLParser.Mode_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#mode_type. + def visitMode_type(self, ctx:ASLParser.Mode_typeContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#execution_decl. + def visitExecution_decl(self, ctx:ASLParser.Execution_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#execution_type. + def visitExecution_type(self, ctx:ASLParser.Execution_typeContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#iterator_decl. + def visitIterator_decl(self, ctx:ASLParser.Iterator_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#iterator_decl_item. + def visitIterator_decl_item(self, ctx:ASLParser.Iterator_decl_itemContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#item_selector_decl. + def visitItem_selector_decl(self, ctx:ASLParser.Item_selector_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#item_reader_decl. + def visitItem_reader_decl(self, ctx:ASLParser.Item_reader_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#items_reader_field. + def visitItems_reader_field(self, ctx:ASLParser.Items_reader_fieldContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#reader_config_decl. + def visitReader_config_decl(self, ctx:ASLParser.Reader_config_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#reader_config_field. + def visitReader_config_field(self, ctx:ASLParser.Reader_config_fieldContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#input_type_decl. + def visitInput_type_decl(self, ctx:ASLParser.Input_type_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#csv_header_location_decl. + def visitCsv_header_location_decl(self, ctx:ASLParser.Csv_header_location_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#csv_headers_decl. + def visitCsv_headers_decl(self, ctx:ASLParser.Csv_headers_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_items_string_jsonata. + def visitMax_items_string_jsonata(self, ctx:ASLParser.Max_items_string_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_items_int. + def visitMax_items_int(self, ctx:ASLParser.Max_items_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_items_path. + def visitMax_items_path(self, ctx:ASLParser.Max_items_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#tolerated_failure_count_string_jsonata. + def visitTolerated_failure_count_string_jsonata(self, ctx:ASLParser.Tolerated_failure_count_string_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#tolerated_failure_count_int. + def visitTolerated_failure_count_int(self, ctx:ASLParser.Tolerated_failure_count_intContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#tolerated_failure_count_path. + def visitTolerated_failure_count_path(self, ctx:ASLParser.Tolerated_failure_count_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#tolerated_failure_percentage_string_jsonata. + def visitTolerated_failure_percentage_string_jsonata(self, ctx:ASLParser.Tolerated_failure_percentage_string_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#tolerated_failure_percentage_number. + def visitTolerated_failure_percentage_number(self, ctx:ASLParser.Tolerated_failure_percentage_numberContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#tolerated_failure_percentage_path. + def visitTolerated_failure_percentage_path(self, ctx:ASLParser.Tolerated_failure_percentage_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#label_decl. + def visitLabel_decl(self, ctx:ASLParser.Label_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#result_writer_decl. + def visitResult_writer_decl(self, ctx:ASLParser.Result_writer_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#result_writer_field. + def visitResult_writer_field(self, ctx:ASLParser.Result_writer_fieldContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#retry_decl. + def visitRetry_decl(self, ctx:ASLParser.Retry_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#retrier_decl. + def visitRetrier_decl(self, ctx:ASLParser.Retrier_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#retrier_stmt. + def visitRetrier_stmt(self, ctx:ASLParser.Retrier_stmtContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#error_equals_decl. + def visitError_equals_decl(self, ctx:ASLParser.Error_equals_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#interval_seconds_decl. + def visitInterval_seconds_decl(self, ctx:ASLParser.Interval_seconds_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_attempts_decl. + def visitMax_attempts_decl(self, ctx:ASLParser.Max_attempts_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#backoff_rate_decl. + def visitBackoff_rate_decl(self, ctx:ASLParser.Backoff_rate_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#max_delay_seconds_decl. + def visitMax_delay_seconds_decl(self, ctx:ASLParser.Max_delay_seconds_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#jitter_strategy_decl. + def visitJitter_strategy_decl(self, ctx:ASLParser.Jitter_strategy_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#catch_decl. + def visitCatch_decl(self, ctx:ASLParser.Catch_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#catcher_decl. + def visitCatcher_decl(self, ctx:ASLParser.Catcher_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#catcher_stmt. + def visitCatcher_stmt(self, ctx:ASLParser.Catcher_stmtContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#comparison_op. + def visitComparison_op(self, ctx:ASLParser.Comparison_opContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#choice_operator. + def visitChoice_operator(self, ctx:ASLParser.Choice_operatorContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#states_error_name. + def visitStates_error_name(self, ctx:ASLParser.States_error_nameContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#error_name. + def visitError_name(self, ctx:ASLParser.Error_nameContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#json_obj_decl. + def visitJson_obj_decl(self, ctx:ASLParser.Json_obj_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#json_binding. + def visitJson_binding(self, ctx:ASLParser.Json_bindingContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#json_arr_decl. + def visitJson_arr_decl(self, ctx:ASLParser.Json_arr_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#json_value_decl. + def visitJson_value_decl(self, ctx:ASLParser.Json_value_declContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_sampler. + def visitString_sampler(self, ctx:ASLParser.String_samplerContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_expression_simple. + def visitString_expression_simple(self, ctx:ASLParser.String_expression_simpleContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_expression. + def visitString_expression(self, ctx:ASLParser.String_expressionContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_jsonpath. + def visitString_jsonpath(self, ctx:ASLParser.String_jsonpathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_context_path. + def visitString_context_path(self, ctx:ASLParser.String_context_pathContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_variable_sample. + def visitString_variable_sample(self, ctx:ASLParser.String_variable_sampleContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_intrinsic_function. + def visitString_intrinsic_function(self, ctx:ASLParser.String_intrinsic_functionContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_jsonata. + def visitString_jsonata(self, ctx:ASLParser.String_jsonataContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#string_literal. + def visitString_literal(self, ctx:ASLParser.String_literalContext): + return self.visitChildren(ctx) + + + # Visit a parse tree produced by ASLParser#soft_string_keyword. + def visitSoft_string_keyword(self, ctx:ASLParser.Soft_string_keywordContext): + return self.visitChildren(ctx) + + + +del ASLParser \ No newline at end of file diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/antlr/runtime/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlt4utils/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/antlt4utils/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/antlt4utils/antlr4utils.py b/localstack-core/localstack/services/stepfunctions/asl/antlt4utils/antlr4utils.py new file mode 100644 index 0000000000000..61c7d073abb19 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/antlt4utils/antlr4utils.py @@ -0,0 +1,35 @@ +import ast +from typing import Optional + +from antlr4 import ParserRuleContext +from antlr4.tree.Tree import ParseTree, TerminalNodeImpl + + +def is_production(pt: ParseTree, rule_index: Optional[int] = None) -> Optional[ParserRuleContext]: + if isinstance(pt, ParserRuleContext): + prc = pt.getRuleContext() # noqa + if rule_index is not None: + return prc if prc.getRuleIndex() == rule_index else None + return prc + return None + + +def is_terminal(pt: ParseTree, token_type: Optional[int] = None) -> Optional[TerminalNodeImpl]: + if isinstance(pt, TerminalNodeImpl): + if token_type is not None: + return pt if pt.getSymbol().type == token_type else None + return pt + return None + + +def from_string_literal(parser_rule_context: ParserRuleContext) -> Optional[str]: + string_literal = parser_rule_context.getText() + if string_literal.startswith('"') and string_literal.endswith('"'): + string_literal = string_literal[1:-1] + # Interpret escape sequences into their character representations + try: + string_literal = ast.literal_eval(f'"{string_literal}"') + except Exception: + # Fallback if literal_eval fails + pass + return string_literal diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_decl.py new file mode 100644 index 0000000000000..494fb10db595d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_decl.py @@ -0,0 +1,24 @@ +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.common.assign.assign_decl_binding import ( + AssignDeclBinding, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class AssignDecl(EvalComponent): + declaration_bindings: Final[list[AssignDeclBinding]] + + def __init__(self, declaration_bindings: list[AssignDeclBinding]): + super().__init__() + self.declaration_bindings = declaration_bindings + + def _eval_body(self, env: Environment) -> None: + declarations: dict[str, Any] = dict() + for declaration_binding in self.declaration_bindings: + declaration_binding.eval(env=env) + binding: dict[str, Any] = env.stack.pop() + declarations.update(binding) + for identifier, value in declarations.items(): + env.variable_store.set(variable_identifier=identifier, variable_value=value) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_decl_binding.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_decl_binding.py new file mode 100644 index 0000000000000..8695bfea82678 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_decl_binding.py @@ -0,0 +1,19 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_binding import ( + AssignTemplateBinding, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class AssignDeclBinding(EvalComponent): + binding: Final[AssignTemplateBinding] + + def __init__(self, binding: AssignTemplateBinding): + super().__init__() + self.binding = binding + + def _eval_body(self, env: Environment) -> None: + env.stack.append(dict()) + self.binding.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_binding.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_binding.py new file mode 100644 index 0000000000000..ad7d688595195 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_binding.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import abc +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value import ( + AssignTemplateValue, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringExpressionSimple, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class AssignTemplateBinding(EvalComponent, abc.ABC): + identifier: Final[str] + + def __init__(self, identifier: str): + super().__init__() + self.identifier = identifier + + @abc.abstractmethod + def _eval_value(self, env: Environment) -> Any: ... + + def _eval_body(self, env: Environment) -> None: + assign_object: dict = env.stack.pop() + assign_value = self._eval_value(env=env) + assign_object[self.identifier] = assign_value + env.stack.append(assign_object) + + +class AssignTemplateBindingStringExpressionSimple(AssignTemplateBinding): + string_expression_simple: Final[StringExpressionSimple] + + def __init__(self, identifier: str, string_expression_simple: StringExpressionSimple): + super().__init__(identifier=identifier) + self.string_expression_simple = string_expression_simple + + def _eval_value(self, env: Environment) -> Any: + self.string_expression_simple.eval(env=env) + value = env.stack.pop() + return value + + +class AssignTemplateBindingValue(AssignTemplateBinding): + assign_value: Final[AssignTemplateValue] + + def __init__(self, identifier: str, assign_value: AssignTemplateValue): + super().__init__(identifier=identifier) + self.assign_value = assign_value + + def _eval_value(self, env: Environment) -> Any: + self.assign_value.eval(env=env) + value = env.stack.pop() + return value diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value.py new file mode 100644 index 0000000000000..797a40f5896ac --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value.py @@ -0,0 +1,6 @@ +import abc + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent + + +class AssignTemplateValue(EvalComponent, abc.ABC): ... diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_array.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_array.py new file mode 100644 index 0000000000000..b2ff0a71ec733 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_array.py @@ -0,0 +1,20 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value import ( + AssignTemplateValue, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class AssignTemplateValueArray(AssignTemplateValue): + values: Final[list[AssignTemplateValue]] + + def __init__(self, values: list[AssignTemplateValue]): + self.values = values + + def _eval_body(self, env: Environment) -> None: + arr = list() + for value in self.values: + value.eval(env) + arr.append(env.stack.pop()) + env.stack.append(arr) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_object.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_object.py new file mode 100644 index 0000000000000..2b4c451595e9b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_object.py @@ -0,0 +1,21 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_binding import ( + AssignTemplateBinding, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value import ( + AssignTemplateValue, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class AssignTemplateValueObject(AssignTemplateValue): + bindings: Final[list[AssignTemplateBinding]] + + def __init__(self, bindings: list[AssignTemplateBinding]): + self.bindings = bindings + + def _eval_body(self, env: Environment) -> None: + env.stack.append(dict()) + for binding in self.bindings: + binding.eval(env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_terminal.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_terminal.py new file mode 100644 index 0000000000000..e7c8959ae6964 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/assign/assign_template_value_terminal.py @@ -0,0 +1,35 @@ +import abc +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value import ( + AssignTemplateValue, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class AssignTemplateValueTerminal(AssignTemplateValue, abc.ABC): ... + + +class AssignTemplateValueTerminalLit(AssignTemplateValueTerminal): + value: Final[Any] + + def __init__(self, value: Any): + super().__init__() + self.value = value + + def _eval_body(self, env: Environment) -> None: + env.stack.append(self.value) + + +class AssignTemplateValueTerminalStringJSONata(AssignTemplateValueTerminal): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _eval_body(self, env: Environment) -> None: + self.string_jsonata.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catch_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catch_decl.py new file mode 100644 index 0000000000000..6663b476b1571 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catch_decl.py @@ -0,0 +1,28 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.catch.catch_outcome import ( + CatchOutcome, +) +from localstack.services.stepfunctions.asl.component.common.catch.catcher_decl import CatcherDecl +from localstack.services.stepfunctions.asl.component.common.catch.catcher_outcome import ( + CatcherOutcome, + CatcherOutcomeCaught, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class CatchDecl(EvalComponent): + def __init__(self, catchers: list[CatcherDecl]): + self.catchers: Final[list[CatcherDecl]] = catchers + + def _eval_body(self, env: Environment) -> None: + for catcher in self.catchers: + catcher.eval(env) + catcher_outcome: CatcherOutcome = env.stack.pop() + + if isinstance(catcher_outcome, CatcherOutcomeCaught): + env.stack.append(CatchOutcome.Caught) + return + + env.stack.append(CatchOutcome.NotCaught) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catch_outcome.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catch_outcome.py new file mode 100644 index 0000000000000..e31a946c6b625 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catch_outcome.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class CatchOutcome(Enum): + Caught = 0 + NotCaught = 1 diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_decl.py new file mode 100644 index 0000000000000..44705370da1cd --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_decl.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.assign.assign_decl import AssignDecl +from localstack.services.stepfunctions.asl.component.common.catch.catcher_outcome import ( + CatcherOutcomeCaught, + CatcherOutcomeNotCaught, +) +from localstack.services.stepfunctions.asl.component.common.catch.catcher_props import CatcherProps +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.error_name.error_equals_decl import ( + ErrorEqualsDecl, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.common.flow.next import Next +from localstack.services.stepfunctions.asl.component.common.outputdecl import Output +from localstack.services.stepfunctions.asl.component.common.path.result_path import ResultPath +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class CatcherOutput(dict): + def __init__(self, error: str, cause: str): + super().__init__() + self["Error"] = error + self["Cause"] = cause + + +class CatcherDecl(EvalComponent): + DEFAULT_RESULT_PATH: Final[ResultPath] = ResultPath(result_path_src="$") + + error_equals: Final[ErrorEqualsDecl] + next_decl: Final[Next] + result_path: Final[Optional[ResultPath]] + assign: Final[Optional[AssignDecl]] + output: Final[Optional[Output]] + comment: Final[Optional[Comment]] + + def __init__( + self, + error_equals: ErrorEqualsDecl, + next_decl: Next, + result_path: Optional[ResultPath], + assign: Optional[AssignDecl], + output: Optional[Output], + comment: Optional[Comment], + ): + self.error_equals = error_equals + self.next_decl = next_decl + self.result_path = result_path + self.assign = assign + self.output = output + self.comment = comment + + @classmethod + def from_catcher_props(cls, props: CatcherProps) -> CatcherDecl: + return cls( + error_equals=props.get( + typ=ErrorEqualsDecl, + raise_on_missing=ValueError( + f"Missing ErrorEquals declaration for Catcher declaration, in props '{props}'." + ), + ), + next_decl=props.get( + typ=Next, + raise_on_missing=ValueError( + f"Missing Next declaration for Catcher declaration, in props '{props}'." + ), + ), + result_path=props.get(typ=ResultPath), + assign=props.get(typ=AssignDecl), + output=props.get(typ=Output), + comment=props.get(typ=Comment), + ) + + def _eval_body(self, env: Environment) -> None: + failure_event: FailureEvent = env.stack.pop() + + env.stack.append(failure_event.error_name) + self.error_equals.eval(env) + + equals: bool = env.stack.pop() + if equals: + # Input for the catch block is the error output. + env.stack.append(env.states.get_error_output()) + + if self.assign: + self.assign.eval(env=env) + + if self.result_path: + self.result_path.eval(env) + + # Prepare the state output: successful catch states override the states' output procedure. + if self.output: + self.output.eval(env=env) + else: + output_value = env.stack.pop() + env.states.reset(output_value) + + # Append successful output to notify the outcome upstream. + env.next_state_name = self.next_decl.name + env.stack.append(CatcherOutcomeCaught()) + else: + env.stack.append(failure_event) + env.stack.append(CatcherOutcomeNotCaught()) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_outcome.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_outcome.py new file mode 100644 index 0000000000000..83166764b103a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_outcome.py @@ -0,0 +1,12 @@ +import abc + + +class CatcherOutcome(abc.ABC): ... + + +class CatcherOutcomeCaught(CatcherOutcome): + pass + + +class CatcherOutcomeNotCaught(CatcherOutcome): + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_props.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_props.py new file mode 100644 index 0000000000000..9c7def96e86ad --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/catch/catcher_props.py @@ -0,0 +1,5 @@ +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + + +class CatcherProps(TypedProps): + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/comment.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/comment.py new file mode 100644 index 0000000000000..4698f43f4000f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/comment.py @@ -0,0 +1,8 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class Comment(Component): + def __init__(self, comment: str): + self.comment: Final[str] = comment diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/custom_error_name.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/custom_error_name.py new file mode 100644 index 0000000000000..6d4ed3954ad1f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/custom_error_name.py @@ -0,0 +1,18 @@ +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName + +ILLEGAL_CUSTOM_ERROR_PREFIX: Final[str] = "States." + + +class CustomErrorName(ErrorName): + """ + States MAY report errors with other names, which MUST NOT begin with the prefix "States.". + """ + + def __init__(self, error_name: Optional[str]): + if error_name is not None and error_name.startswith(ILLEGAL_CUSTOM_ERROR_PREFIX): + raise ValueError( + f"Custom Error Names MUST NOT begin with the prefix 'States.', got '{error_name}'." + ) + super().__init__(error_name=error_name) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/error_equals_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/error_equals_decl.py new file mode 100644 index 0000000000000..6fd2f54544b38 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/error_equals_decl.py @@ -0,0 +1,59 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ErrorEqualsDecl(EvalComponent): + """ + ErrorEquals value MUST be a non-empty array of Strings, which match Error Names. + Each Retrier MUST contain a field named "ErrorEquals" whose value MUST be a non-empty array of Strings, + which match Error Names. + """ + + _STATE_ALL_ERROR: Final[StatesErrorName] = StatesErrorName(typ=StatesErrorNameType.StatesALL) + _STATE_TASK_ERROR: Final[StatesErrorName] = StatesErrorName( + typ=StatesErrorNameType.StatesTaskFailed + ) + + def __init__(self, error_names: list[ErrorName]): + # The reserved name "States.ALL" in a Retrier’s "ErrorEquals" field is a wildcard + # and matches any Error Name. Such a value MUST appear alone in the "ErrorEquals" + # array and MUST appear in the last Retrier in the "Retry" array. + if ErrorEqualsDecl._STATE_ALL_ERROR in error_names and len(error_names) > 1: + raise ValueError( + f"States.ALL must appear alone in the ErrorEquals array, got '{error_names}'." + ) + + # TODO: how to handle duplicate ErrorName? + self.error_names: list[ErrorName] = error_names + + def _eval_body(self, env: Environment) -> None: + """ + When a state reports an error, the interpreter scans through the Retriers and, + when the Error Name appears in the value of a Retrier’s "ErrorEquals" field, implements the retry policy + described in that Retrier. + This pops the error from the stack, and appends the bool of this check. + """ + + # Try to reduce error response to ErrorName or pass exception upstream. + error_name: ErrorName = env.stack.pop() + + if ErrorEqualsDecl._STATE_ALL_ERROR in self.error_names: + res = True + elif ( + ErrorEqualsDecl._STATE_TASK_ERROR in self.error_names + and not isinstance(error_name, StatesErrorName) + ): # TODO: consider binding a 'context' variable to error_names to more formally detect their evaluation type. + res = True + else: + res = error_name in self.error_names + + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/error_name.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/error_name.py new file mode 100644 index 0000000000000..50e09e290aa4f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/error_name.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import abc +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.component import Component + + +class ErrorName(Component, abc.ABC): + error_name: Final[Optional[str]] + + def __init__(self, error_name: Optional[str]): + self.error_name = error_name + + def matches(self, error_name: Optional[str]) -> bool: + return self.error_name == error_name + + def __eq__(self, other): + if isinstance(other, ErrorName): + return self.matches(other.error_name) + return False diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/failure_event.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/failure_event.py new file mode 100644 index 0000000000000..4624ea025395b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/failure_event.py @@ -0,0 +1,103 @@ +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + EvaluationFailedEventDetails, + ExecutionFailedEventDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + + +class FailureEvent: + state_name: Final[str] + source_event_id: Final[int] + error_name: Final[Optional[ErrorName]] + event_type: Final[HistoryEventType] + event_details: Final[Optional[EventDetails]] + + def __init__( + self, + env: Environment, + error_name: Optional[ErrorName], + event_type: HistoryEventType, + event_details: Optional[EventDetails] = None, + ): + self.state_name = env.next_state_name + self.source_event_id = env.event_history_context.source_event_id + self.error_name = error_name + self.event_type = event_type + self.event_details = event_details + + +class FailureEventException(Exception): + failure_event: Final[FailureEvent] + + def __init__(self, failure_event: FailureEvent): + self.failure_event = failure_event + + def extract_error_cause_pair(self) -> Optional[tuple[Optional[str], Optional[str]]]: + if self.failure_event.event_details is None: + return None + + failure_event_spec = list(self.failure_event.event_details.values())[0] + + error = None + cause = None + if "error" in failure_event_spec: + error = failure_event_spec["error"] + if "cause" in failure_event_spec: + cause = failure_event_spec["cause"] + return error, cause + + def get_evaluation_failed_event_details(self) -> Optional[EvaluationFailedEventDetails]: + original_failed_event_details = self.failure_event.event_details[ + "evaluationFailedEventDetails" + ] + evaluation_failed_event_details = EvaluationFailedEventDetails() + + error = original_failed_event_details["error"] + cause = original_failed_event_details["cause"] + location = original_failed_event_details.get("location") + state_name = self.failure_event.state_name + + if error != StatesErrorNameType.StatesQueryEvaluationError.to_name(): + return None + if error: + evaluation_failed_event_details["error"] = error + if cause: + event_id = self.failure_event.source_event_id + decorated_cause = f"An error occurred while executing the state '{state_name}' (entered at the event id #{event_id}). {cause}" + evaluation_failed_event_details["cause"] = decorated_cause + if location: + evaluation_failed_event_details["location"] = location + if state_name: + evaluation_failed_event_details["state"] = state_name + + return evaluation_failed_event_details + + def get_execution_failed_event_details(self) -> Optional[ExecutionFailedEventDetails]: + maybe_error_cause_pair = self.extract_error_cause_pair() + if maybe_error_cause_pair is None: + return None + execution_failed_event_details = ExecutionFailedEventDetails() + error, cause = maybe_error_cause_pair + if error: + execution_failed_event_details["error"] = error + if cause: + if ( + error == StatesErrorNameType.StatesRuntime.to_name() + or error == StatesErrorNameType.StatesQueryEvaluationError.to_name() + ): + state_name = self.failure_event.state_name + event_id = self.failure_event.source_event_id + decorated_cause = f"An error occurred while executing the state '{state_name}' (entered at the event id #{event_id}). {cause}" + execution_failed_event_details["cause"] = decorated_cause + else: + execution_failed_event_details["cause"] = cause + + return execution_failed_event_details diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/states_error_name.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/states_error_name.py new file mode 100644 index 0000000000000..ef4c71a579f91 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/states_error_name.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) + + +class StatesErrorName(ErrorName): + def __init__(self, typ: StatesErrorNameType): + super().__init__(error_name=typ.to_name()) + self.typ: Final[StatesErrorNameType] = typ + + @classmethod + def from_name(cls, error_name: str) -> StatesErrorName: + error_name_type: StatesErrorNameType = StatesErrorNameType.from_name(error_name) + return cls(typ=error_name_type) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/states_error_name_type.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/states_error_name_type.py new file mode 100644 index 0000000000000..9dcda9350ffcd --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/error_name/states_error_name_type.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from enum import Enum +from typing import Final + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer + + +class StatesErrorNameType(Enum): + StatesALL = ASLLexer.ERRORNAMEStatesALL + StatesStatesDataLimitExceeded = ASLLexer.ERRORNAMEStatesDataLimitExceeded + StatesHeartbeatTimeout = ASLLexer.ERRORNAMEStatesHeartbeatTimeout + StatesTimeout = ASLLexer.ERRORNAMEStatesTimeout + StatesTaskFailed = ASLLexer.ERRORNAMEStatesTaskFailed + StatesPermissions = ASLLexer.ERRORNAMEStatesPermissions + StatesResultPathMatchFailure = ASLLexer.ERRORNAMEStatesResultPathMatchFailure + StatesParameterPathFailure = ASLLexer.ERRORNAMEStatesParameterPathFailure + StatesBranchFailed = ASLLexer.ERRORNAMEStatesBranchFailed + StatesNoChoiceMatched = ASLLexer.ERRORNAMEStatesNoChoiceMatched + StatesIntrinsicFailure = ASLLexer.ERRORNAMEStatesIntrinsicFailure + StatesExceedToleratedFailureThreshold = ASLLexer.ERRORNAMEStatesExceedToleratedFailureThreshold + StatesItemReaderFailed = ASLLexer.ERRORNAMEStatesItemReaderFailed + StatesResultWriterFailed = ASLLexer.ERRORNAMEStatesResultWriterFailed + StatesRuntime = ASLLexer.ERRORNAMEStatesRuntime + StatesQueryEvaluationError = ASLLexer.ERRORNAMEStatesQueryEvaluationError + + def to_name(self) -> str: + return _error_name(self) + + @classmethod + def from_name(cls, name: str) -> StatesErrorNameType: + error_name = _REVERSE_NAME_LOOKUP.get(name, None) + if error_name is None: + raise ValueError(f"Unknown ErrorName type, got: '{name}'.") + return cls(error_name.value) + + +def _error_name(error_name: StatesErrorNameType) -> str: + return ASLLexer.literalNames[error_name.value][2:-2] + + +def _reverse_error_name_lookup() -> dict[str, StatesErrorNameType]: + lookup: dict[str, StatesErrorNameType] = dict() + for error_name in StatesErrorNameType: + error_text: str = _error_name(error_name) + lookup[error_text] = error_name + return lookup + + +_REVERSE_NAME_LOOKUP: Final[dict[str, StatesErrorNameType]] = _reverse_error_name_lookup() diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/end.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/end.py new file mode 100644 index 0000000000000..9565d1f5c8045 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/end.py @@ -0,0 +1,9 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class End(Component): + def __init__(self, is_end: bool): + # Designates this state as a terminal state (ends the execution) if set to true. + self.is_end: Final[bool] = is_end diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/next.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/next.py new file mode 100644 index 0000000000000..d64a341646fb4 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/next.py @@ -0,0 +1,11 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class Next(Component): + name: Final[str] + + def __init__(self, name: str): + # The name of the next state that is run when the current state finishes. + self.name = name diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/start_at.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/start_at.py new file mode 100644 index 0000000000000..89653423fb8f5 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/flow/start_at.py @@ -0,0 +1,8 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class StartAt(Component): + def __init__(self, start_at_name: str): + self.start_at_name: Final[str] = start_at_name diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_binding.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_binding.py new file mode 100644 index 0000000000000..3833f14c0abdc --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_binding.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value import ( + JSONataTemplateValue, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class JSONataTemplateBinding(EvalComponent): + identifier: Final[str] + value: Final[JSONataTemplateValue] + + def __init__(self, identifier: str, value: JSONataTemplateValue): + self.identifier = identifier + self.value = value + + def _field_name(self) -> Optional[str]: + return self.identifier + + def _eval_body(self, env: Environment) -> None: + binding_container: dict = env.stack.pop() + self.value.eval(env=env) + value = env.stack.pop() + binding_container[self.identifier] = value + env.stack.append(binding_container) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value.py new file mode 100644 index 0000000000000..d1f48c79c9210 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value.py @@ -0,0 +1,6 @@ +import abc + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent + + +class JSONataTemplateValue(EvalComponent, abc.ABC): ... diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_array.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_array.py new file mode 100644 index 0000000000000..552b168299e2a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_array.py @@ -0,0 +1,20 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value import ( + JSONataTemplateValue, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class JSONataTemplateValueArray(JSONataTemplateValue): + values: Final[list[JSONataTemplateValue]] + + def __init__(self, values: list[JSONataTemplateValue]): + self.values = values + + def _eval_body(self, env: Environment) -> None: + arr = list() + for value in self.values: + value.eval(env) + arr.append(env.stack.pop()) + env.stack.append(arr) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_object.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_object.py new file mode 100644 index 0000000000000..81b1c19a00c53 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_object.py @@ -0,0 +1,21 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_binding import ( + JSONataTemplateBinding, +) +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value import ( + JSONataTemplateValue, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class JSONataTemplateValueObject(JSONataTemplateValue): + bindings: Final[list[JSONataTemplateBinding]] + + def __init__(self, bindings: list[JSONataTemplateBinding]): + self.bindings = bindings + + def _eval_body(self, env: Environment) -> None: + env.stack.append(dict()) + for binding in self.bindings: + binding.eval(env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_terminal.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_terminal.py new file mode 100644 index 0000000000000..97ce01ef43f00 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/jsonata/jsonata_template_value_terminal.py @@ -0,0 +1,35 @@ +import abc +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value import ( + JSONataTemplateValue, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class JSONataTemplateValueTerminal(JSONataTemplateValue, abc.ABC): ... + + +class JSONataTemplateValueTerminalLit(JSONataTemplateValueTerminal): + value: Final[Any] + + def __init__(self, value: Any): + super().__init__() + self.value = value + + def _eval_body(self, env: Environment) -> None: + env.stack.append(self.value) + + +class JSONataTemplateValueTerminalStringJSONata(JSONataTemplateValueTerminal): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _eval_body(self, env: Environment) -> None: + self.string_jsonata.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/outputdecl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/outputdecl.py new file mode 100644 index 0000000000000..9ddf3471204f8 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/outputdecl.py @@ -0,0 +1,19 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value import ( + JSONataTemplateValue, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class Output(EvalComponent): + jsonata_template_value: Final[JSONataTemplateValue] + + def __init__(self, jsonata_template_value: JSONataTemplateValue): + self.jsonata_template_value = jsonata_template_value + + def _eval_body(self, env: Environment) -> None: + self.jsonata_template_value.eval(env=env) + output_value = env.stack.pop() + env.states.reset(input_value=output_value) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/parargs.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/parargs.py new file mode 100644 index 0000000000000..5741e5de3c23d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/parargs.py @@ -0,0 +1,42 @@ +import abc +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value_object import ( + JSONataTemplateValueObject, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadtmpl.payload_tmpl import ( + PayloadTmpl, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class Parargs(EvalComponent, abc.ABC): + template_eval_component: Final[EvalComponent] + + def __init__(self, template_eval_component: EvalComponent): + self.template_eval_component = template_eval_component + + def _eval_body(self, env: Environment) -> None: + self.template_eval_component.eval(env=env) + + +class Parameters(Parargs): + def __init__(self, payload_tmpl: PayloadTmpl): + super().__init__(template_eval_component=payload_tmpl) + + +class Arguments(Parargs, abc.ABC): ... + + +class ArgumentsJSONataTemplateValueObject(Arguments): + def __init__(self, jsonata_template_value_object: JSONataTemplateValueObject): + super().__init__(template_eval_component=jsonata_template_value_object) + + +class ArgumentsStringJSONata(Arguments): + def __init__(self, string_jsonata: StringJSONata): + super().__init__(template_eval_component=string_jsonata) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/path/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/path/input_path.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/input_path.py new file mode 100644 index 0000000000000..8c0d4e6cbb4e7 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/input_path.py @@ -0,0 +1,53 @@ +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJsonPath, + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.json_path import NoSuchJsonPathError + + +class InputPath(EvalComponent): + string_sampler: Final[Optional[StringSampler]] + + def __init__(self, string_sampler: Optional[StringSampler]): + self.string_sampler = string_sampler + + def _eval_body(self, env: Environment) -> None: + if self.string_sampler is None: + env.stack.append(dict()) + return + if isinstance(self.string_sampler, StringJsonPath): + # JsonPaths are sampled from a given state, hence pass the state's input. + env.stack.append(env.states.get_input()) + try: + self.string_sampler.eval(env=env) + except NoSuchJsonPathError as no_such_json_path_error: + json_path = no_such_json_path_error.json_path + cause = f"Invalid path '{json_path}' : No results for path: $['{json_path[2:]}']" + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=cause + ) + ), + ) + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/path/items_path.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/items_path.py new file mode 100644 index 0000000000000..05991bd37dfa6 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/items_path.py @@ -0,0 +1,17 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ItemsPath(EvalComponent): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def _eval_body(self, env: Environment) -> None: + self.string_sampler.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/path/output_path.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/output_path.py new file mode 100644 index 0000000000000..b40586aa8e716 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/output_path.py @@ -0,0 +1,51 @@ +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.json_path import NoSuchJsonPathError + + +class OutputPath(EvalComponent): + string_sampler: Final[Optional[StringSampler]] + + def __init__(self, string_sampler: Optional[StringSampler]): + self.string_sampler = string_sampler + + def _eval_body(self, env: Environment) -> None: + if self.string_sampler is None: + env.states.reset(input_value=dict()) + return + try: + self.string_sampler.eval(env=env) + except NoSuchJsonPathError as no_such_json_path_error: + json_path = no_such_json_path_error.json_path + cause = f"Invalid path '{json_path}' : No results for path: $['{json_path[2:]}']" + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=cause + ) + ), + ) + ) + output_value = env.stack.pop() + env.states.reset(output_value) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/path/result_path.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/result_path.py new file mode 100644 index 0000000000000..bfcb3f2cfe91d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/path/result_path.py @@ -0,0 +1,31 @@ +import copy +from typing import Final, Optional + +from jsonpath_ng import parse + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ResultPath(EvalComponent): + DEFAULT_PATH: Final[str] = "$" + + result_path_src: Final[Optional[str]] + + def __init__(self, result_path_src: Optional[str]): + self.result_path_src = result_path_src + + def _eval_body(self, env: Environment) -> None: + state_input = env.states.get_input() + + # Discard task output if there is one, and set the output ot be the state's input. + if self.result_path_src is None: + env.stack.clear() + env.stack.append(state_input) + return + + # Transform the output with the input. + current_output = env.stack.pop() + result_expr = parse(self.result_path_src) + state_output = result_expr.update_or_create(state_input, copy.deepcopy(current_output)) + env.stack.append(state_output) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payload_value.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payload_value.py new file mode 100644 index 0000000000000..773195e38fe20 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payload_value.py @@ -0,0 +1,6 @@ +import abc + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent + + +class PayloadValue(EvalComponent, abc.ABC): ... diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadarr/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadarr/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadarr/payload_arr.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadarr/payload_arr.py new file mode 100644 index 0000000000000..3c9f7a3bdf9b0 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadarr/payload_arr.py @@ -0,0 +1,18 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payload_value import ( + PayloadValue, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class PayloadArr(PayloadValue): + def __init__(self, payload_values: list[PayloadValue]): + self.payload_values: Final[list[PayloadValue]] = payload_values + + def _eval_body(self, env: Environment) -> None: + arr = list() + for payload_value in self.payload_values: + payload_value.eval(env) + arr.append(env.stack.pop()) + env.stack.append(arr) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadbinding/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadbinding/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadbinding/payload_binding.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadbinding/payload_binding.py new file mode 100644 index 0000000000000..1b7d7fb527634 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadbinding/payload_binding.py @@ -0,0 +1,58 @@ +import abc +from typing import Any, Final, Optional + +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payload_value import ( + PayloadValue, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringExpressionSimple, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class PayloadBinding(PayloadValue, abc.ABC): + field: Final[str] + + def __init__(self, field: str): + self.field = field + + def _field_name(self) -> Optional[str]: + return self.field + + @abc.abstractmethod + def _eval_val(self, env: Environment) -> Any: ... + + def _eval_body(self, env: Environment) -> None: + cnt: dict = env.stack.pop() + val = self._eval_val(env=env) + cnt[self.field] = val + env.stack.append(cnt) + + +class PayloadBindingStringExpressionSimple(PayloadBinding): + string_expression_simple: Final[StringExpressionSimple] + + def __init__(self, field: str, string_expression_simple: StringExpressionSimple): + super().__init__(field=field) + self.string_expression_simple = string_expression_simple + + def _field_name(self) -> Optional[str]: + return f"{self.field}.$" + + def _eval_val(self, env: Environment) -> Any: + self.string_expression_simple.eval(env=env) + value = env.stack.pop() + return value + + +class PayloadBindingValue(PayloadBinding): + payload_value: Final[PayloadValue] + + def __init__(self, field: str, payload_value: PayloadValue): + super().__init__(field=field) + self.payload_value = payload_value + + def _eval_val(self, env: Environment) -> Any: + self.payload_value.eval(env) + val: Any = env.stack.pop() + return val diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadtmpl/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadtmpl/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadtmpl/payload_tmpl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadtmpl/payload_tmpl.py new file mode 100644 index 0000000000000..dce0cc10b2cc1 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadtmpl/payload_tmpl.py @@ -0,0 +1,19 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payload_value import ( + PayloadValue, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadbinding.payload_binding import ( + PayloadBinding, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class PayloadTmpl(PayloadValue): + def __init__(self, payload_bindings: list[PayloadBinding]): + self.payload_bindings: Final[list[PayloadBinding]] = payload_bindings + + def _eval_body(self, env: Environment) -> None: + env.stack.append(dict()) + for payload_binding in self.payload_bindings: + payload_binding.eval(env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_bool.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_bool.py new file mode 100644 index 0000000000000..3ae270a8bb51c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_bool.py @@ -0,0 +1,10 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_lit import ( + PayloadValueLit, +) + + +class PayloadValueBool(PayloadValueLit): + def __init__(self, val: bool): + self.val: Final[bool] = val diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_float.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_float.py new file mode 100644 index 0000000000000..b3acc7ef37048 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_float.py @@ -0,0 +1,10 @@ +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_lit import ( + PayloadValueLit, +) + + +class PayloadValueFloat(PayloadValueLit): + val: float + + def __init__(self, val: float): + super().__init__(val=val) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_int.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_int.py new file mode 100644 index 0000000000000..f795b2911f125 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_int.py @@ -0,0 +1,10 @@ +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_lit import ( + PayloadValueLit, +) + + +class PayloadValueInt(PayloadValueLit): + val: int + + def __init__(self, val: int): + super().__init__(val=val) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_lit.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_lit.py new file mode 100644 index 0000000000000..9ca7767d5967c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_lit.py @@ -0,0 +1,17 @@ +import abc +from typing import Any + +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payload_value import ( + PayloadValue, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class PayloadValueLit(PayloadValue, abc.ABC): + val: Any + + def __init__(self, val: Any): + self.val: Any = val + + def _eval_body(self, env: Environment) -> None: + env.stack.append(self.val) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_null.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_null.py new file mode 100644 index 0000000000000..90151374fb8cb --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_null.py @@ -0,0 +1,10 @@ +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_lit import ( + PayloadValueLit, +) + + +class PayloadValueNull(PayloadValueLit): + val: None + + def __init__(self): + super().__init__(val=None) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_str.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_str.py new file mode 100644 index 0000000000000..d167dc667ac31 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/payload/payloadvalue/payloadvaluelit/payload_value_str.py @@ -0,0 +1,10 @@ +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_lit import ( + PayloadValueLit, +) + + +class PayloadValueStr(PayloadValueLit): + val: str + + def __init__(self, val: str): + super().__init__(val=val) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/query_language.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/query_language.py new file mode 100644 index 0000000000000..a1c97e255a7bc --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/query_language.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import enum +from typing import Final + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer +from localstack.services.stepfunctions.asl.component.component import Component + + +class QueryLanguageMode(enum.Enum): + JSONPath = ASLLexer.JSONPATH + JSONata = ASLLexer.JSONATA + + def __str__(self): + return self.name + + def __repr__(self): + return f"QueryLanguageMode.{self}({self.value})" + + +DEFAULT_QUERY_LANGUAGE_MODE: Final[QueryLanguageMode] = QueryLanguageMode.JSONPath + + +class QueryLanguage(Component): + query_language_mode: Final[QueryLanguageMode] + + def __init__(self, query_language_mode: QueryLanguageMode = DEFAULT_QUERY_LANGUAGE_MODE): + self.query_language_mode = query_language_mode diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/result_selector.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/result_selector.py new file mode 100644 index 0000000000000..b194c514d8fb9 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/result_selector.py @@ -0,0 +1,17 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadtmpl.payload_tmpl import ( + PayloadTmpl, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ResultSelector(EvalComponent): + payload_tmpl: Final[PayloadTmpl] + + def __init__(self, payload_tmpl: PayloadTmpl): + self.payload_tmpl = payload_tmpl + + def _eval_body(self, env: Environment) -> None: + self.payload_tmpl.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/backoff_rate_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/backoff_rate_decl.py new file mode 100644 index 0000000000000..6fa1d37ac578b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/backoff_rate_decl.py @@ -0,0 +1,41 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class BackoffRateDecl(EvalComponent): + """ + "BackoffRate": a number which is the multiplier that increases the retry interval on each + attempt (default: 2.0). The value of BackoffRate MUST be greater than or equal to 1.0. + """ + + DEFAULT_RATE: Final[float] = 2.0 + MIN_RATE: Final[float] = 1.0 + + def __init__(self, rate: float = DEFAULT_RATE): + if not (rate >= self.MIN_RATE): + raise ValueError( + f"The value of BackoffRate MUST be greater than or equal to {BackoffRateDecl.MIN_RATE}, got '{rate}'." + ) + self.rate: Final[float] = rate + + def _next_multiplier_key(self) -> str: + return f"BackoffRateDecl-{self.heap_key}-next_multiplier" + + def _access_next_multiplier(self, env: Environment) -> float: + return env.heap.get(self._next_multiplier_key(), 1.0) + + def _store_next_multiplier(self, env: Environment, next_multiplier: float) -> None: + env.heap[self._next_multiplier_key()] = next_multiplier + + def _eval_body(self, env: Environment) -> None: + interval_seconds: int = env.stack.pop() + + next_multiplier: float = self._access_next_multiplier(env=env) + + next_interval_seconds = interval_seconds * next_multiplier + env.stack.append(next_interval_seconds) + + next_multiplier *= self.rate + self._store_next_multiplier(env=env, next_multiplier=next_multiplier) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/interval_seconds_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/interval_seconds_decl.py new file mode 100644 index 0000000000000..8772c0276e290 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/interval_seconds_decl.py @@ -0,0 +1,26 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class IntervalSecondsDecl(EvalComponent): + """ + IntervalSeconds: its value MUST be a positive integer, representing the number of seconds before the + first retry attempt (default value: 1); + """ + + DEFAULT_SECONDS: Final[int] = 1 + MAX_VALUE: Final[int] = 99999999 + + def __init__(self, seconds: int = DEFAULT_SECONDS): + if not (1 <= seconds <= IntervalSecondsDecl.MAX_VALUE): + raise ValueError( + f"IntervalSeconds value MUST be a positive integer between " + f"1 and {IntervalSecondsDecl.MAX_VALUE}, got '{seconds}'." + ) + + self.seconds: Final[int] = seconds + + def _eval_body(self, env: Environment) -> None: + env.stack.append(self.seconds) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/jitter_strategy_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/jitter_strategy_decl.py new file mode 100644 index 0000000000000..4f0a641946d73 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/jitter_strategy_decl.py @@ -0,0 +1,35 @@ +import enum +import random +from typing import Final + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class JitterStrategy(enum.Enum): + FULL = ASLLexer.FULL + NONE = ASLLexer.NONE + + def __str__(self): + return self.name + + def __repr__(self): + return f"JitterStrategy.{self}({self.value})" + + +class JitterStrategyDecl(EvalComponent): + DEFAULT_STRATEGY: Final[JitterStrategy] = JitterStrategy.NONE + + jitter_strategy: Final[JitterStrategy] + + def __init__(self, jitter_strategy: JitterStrategy = JitterStrategy.NONE): + self.jitter_strategy = jitter_strategy + + def _eval_body(self, env: Environment) -> None: + if self.jitter_strategy == JitterStrategy.NONE: + return + + interval_seconds = env.stack.pop() + jitter_interval = random.uniform(0, interval_seconds) + env.stack.append(jitter_interval) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/max_attempts_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/max_attempts_decl.py new file mode 100644 index 0000000000000..ef8f71ad396b4 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/max_attempts_decl.py @@ -0,0 +1,45 @@ +import enum +from typing import Final + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class MaxAttemptsOutcome(enum.Enum): + EXHAUSTED = False + SUCCESS = True + + +class MaxAttemptsDecl(EvalComponent): + """ + "MaxAttempts": value MUST be a non-negative integer, representing the maximum number + of retry attempts (default: 3) + """ + + DEFAULT_ATTEMPTS: Final[int] = 3 + MAX_VALUE: Final[int] = 99999999 + + attempts: Final[int] + + def __init__(self, attempts: int = DEFAULT_ATTEMPTS): + if not (0 <= attempts <= MaxAttemptsDecl.MAX_VALUE): + raise ValueError( + f"MaxAttempts value MUST be a positive integer between " + f"0 and {MaxAttemptsDecl.MAX_VALUE}, got '{attempts}'." + ) + self.attempts = attempts + + def _attempt_number_key(self) -> str: + return f"MaxAttemptsDecl-{self.heap_key}-attempt_number" + + def _access_attempt_number(self, env: Environment) -> int: + return env.heap.get(self._attempt_number_key(), -1) + + def _store_attempt_number(self, env: Environment, attempt_number: float) -> None: + env.heap[self._attempt_number_key()] = attempt_number + + def _eval_body(self, env: Environment) -> None: + attempt_number: int = self._access_attempt_number(env=env) + attempt_number += 1 + env.stack.append(MaxAttemptsOutcome(attempt_number < self.attempts)) + self._store_attempt_number(env=env, attempt_number=attempt_number) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/max_delay_seconds_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/max_delay_seconds_decl.py new file mode 100644 index 0000000000000..9e1a57352f1cd --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/max_delay_seconds_decl.py @@ -0,0 +1,24 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class MaxDelaySecondsDecl(EvalComponent): + MAX_VALUE: Final[int] = 31622401 + + max_delays_seconds: Final[int] + + def __init__(self, max_delays_seconds: int = MAX_VALUE): + if not (1 <= max_delays_seconds <= MaxDelaySecondsDecl.MAX_VALUE): + raise ValueError( + f"MaxDelaySeconds value MUST be a positive integer between " + f"1 and {MaxDelaySecondsDecl.MAX_VALUE}, got '{max_delays_seconds}'." + ) + + self.max_delays_seconds = max_delays_seconds + + def _eval_body(self, env: Environment) -> None: + interval_seconds = env.stack.pop() + new_interval_seconds = min(interval_seconds, self.max_delays_seconds) + env.stack.append(new_interval_seconds) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_decl.py new file mode 100644 index 0000000000000..108a4f97790e5 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_decl.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import time +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.error_name.error_equals_decl import ( + ErrorEqualsDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.backoff_rate_decl import ( + BackoffRateDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.interval_seconds_decl import ( + IntervalSecondsDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.jitter_strategy_decl import ( + JitterStrategyDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.max_attempts_decl import ( + MaxAttemptsDecl, + MaxAttemptsOutcome, +) +from localstack.services.stepfunctions.asl.component.common.retry.max_delay_seconds_decl import ( + MaxDelaySecondsDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.retrier_outcome import ( + RetrierOutcome, +) +from localstack.services.stepfunctions.asl.component.common.retry.retrier_props import RetrierProps +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class RetrierDecl(EvalComponent): + error_equals: Final[ErrorEqualsDecl] + interval_seconds: Final[IntervalSecondsDecl] + max_attempts: Final[MaxAttemptsDecl] + backoff_rate: Final[BackoffRateDecl] + max_delay_seconds: Final[MaxDelaySecondsDecl] + jitter_strategy: Final[JitterStrategyDecl] + comment: Final[Optional[Comment]] + + def __init__( + self, + error_equals: ErrorEqualsDecl, + interval_seconds: Optional[IntervalSecondsDecl] = None, + max_attempts: Optional[MaxAttemptsDecl] = None, + backoff_rate: Optional[BackoffRateDecl] = None, + max_delay_seconds: Optional[MaxDelaySecondsDecl] = None, + jitter_strategy: Optional[JitterStrategyDecl] = None, + comment: Optional[Comment] = None, + ): + self.error_equals = error_equals + self.interval_seconds = interval_seconds or IntervalSecondsDecl() + self.max_attempts = max_attempts or MaxAttemptsDecl() + self.backoff_rate = backoff_rate or BackoffRateDecl() + self.max_delay_seconds = max_delay_seconds or MaxDelaySecondsDecl() + self.jitter_strategy = jitter_strategy or JitterStrategyDecl() + self.comment = comment + + @classmethod + def from_retrier_props(cls, props: RetrierProps) -> RetrierDecl: + return cls( + error_equals=props.get( + typ=ErrorEqualsDecl, + raise_on_missing=ValueError( + f"Missing ErrorEquals declaration for Retrier declaration, in props '{props}'." + ), + ), + interval_seconds=props.get(IntervalSecondsDecl), + max_attempts=props.get(MaxAttemptsDecl), + backoff_rate=props.get(BackoffRateDecl), + max_delay_seconds=props.get(MaxDelaySecondsDecl), + jitter_strategy=props.get(JitterStrategyDecl), + comment=props.get(Comment), + ) + + def _eval_body(self, env: Environment) -> None: + # When a state reports an error, the interpreter scans through the Retriers and, when the Error Name appears + # in the value of a Retrier’s "ErrorEquals" field, implements the retry policy described in that Retrier. + + self.error_equals.eval(env) + res: bool = env.stack.pop() + + # This Retrier does not match + if not res: + env.stack.append(RetrierOutcome.Skipped) + return + + # Request another attempt. + self.max_attempts.eval(env=env) + max_attempts_outcome = env.stack.pop() + if max_attempts_outcome == MaxAttemptsOutcome.EXHAUSTED: + env.stack.append(RetrierOutcome.Failed) + return + + # Compute the next interval. + self.interval_seconds.eval(env=env) + self.backoff_rate.eval(env=env) + self.max_delay_seconds.eval(env=env) + self.jitter_strategy.eval(env=env) + + # Execute wait. + interval_seconds: float = env.stack.pop() + time.sleep(interval_seconds) + + env.stack.append(RetrierOutcome.Executed) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_outcome.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_outcome.py new file mode 100644 index 0000000000000..d2237bdd88631 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_outcome.py @@ -0,0 +1,7 @@ +import enum + + +class RetrierOutcome(enum.Enum): + Executed = 0 + Failed = 1 + Skipped = 2 diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_props.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_props.py new file mode 100644 index 0000000000000..1f6d8f4198294 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retrier_props.py @@ -0,0 +1,5 @@ +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + + +class RetrierProps(TypedProps): + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retry_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retry_decl.py new file mode 100644 index 0000000000000..77a71416c8f90 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retry_decl.py @@ -0,0 +1,35 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName +from localstack.services.stepfunctions.asl.component.common.retry.retrier_decl import RetrierDecl +from localstack.services.stepfunctions.asl.component.common.retry.retrier_outcome import ( + RetrierOutcome, +) +from localstack.services.stepfunctions.asl.component.common.retry.retry_outcome import RetryOutcome +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class RetryDecl(EvalComponent): + def __init__(self, retriers: list[RetrierDecl]): + self.retriers: Final[list[RetrierDecl]] = retriers + + def _eval_body(self, env: Environment) -> None: + error_name: ErrorName = env.stack.pop() + + for retrier in self.retriers: + env.stack.append(error_name) + retrier.eval(env) + outcome: RetrierOutcome = env.stack.pop() + + match outcome: + case RetrierOutcome.Skipped: + continue + case RetrierOutcome.Executed: + env.stack.append(RetryOutcome.CanRetry) + return + case RetrierOutcome.Failed: + env.stack.append(RetryOutcome.CannotRetry) + return + + env.stack.append(RetryOutcome.NoRetrier) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retry_outcome.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retry_outcome.py new file mode 100644 index 0000000000000..7e12c3f1e1923 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/retry/retry_outcome.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class RetryOutcome(Enum): + CanRetry = 0 + CannotRetry = 1 + NoRetrier = 2 diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/string/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/string/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/string/string_expression.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/string/string_expression.py new file mode 100644 index 0000000000000..3f4be28c7e14c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/string/string_expression.py @@ -0,0 +1,209 @@ +import abc +import copy +from typing import Any, Final, Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.events.utils import to_json_str +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguageMode +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.intrinsic.jsonata import ( + get_intrinsic_functions_declarations, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.jsonata.jsonata import ( + JSONataExpression, + VariableDeclarations, + VariableReference, + compose_jsonata_expression, + eval_jsonata_expression, + extract_jsonata_variable_references, +) +from localstack.services.stepfunctions.asl.jsonata.validations import ( + validate_jsonata_expression_output, +) +from localstack.services.stepfunctions.asl.utils.json_path import ( + NoSuchJsonPathError, + extract_json, +) + +JSONPATH_ROOT_PATH: Final[str] = "$" + + +class StringExpression(EvalComponent, abc.ABC): + literal_value: Final[str] + + def __init__(self, literal_value: str): + self.literal_value = literal_value + + def _field_name(self) -> Optional[str]: + return None + + +class StringExpressionSimple(StringExpression, abc.ABC): ... + + +class StringSampler(StringExpressionSimple, abc.ABC): ... + + +class StringLiteral(StringExpression): + def _eval_body(self, env: Environment) -> None: + env.stack.append(self.literal_value) + + +class StringJsonPath(StringSampler): + json_path: Final[str] + + def __init__(self, json_path: str): + super().__init__(literal_value=json_path) + self.json_path = json_path + + def _eval_body(self, env: Environment) -> None: + input_value: Any = env.stack[-1] + if self.json_path == JSONPATH_ROOT_PATH: + output_value = input_value + else: + output_value = extract_json(self.json_path, input_value) + # TODO: introduce copy on write approach + env.stack.append(copy.deepcopy(output_value)) + + +class StringContextPath(StringJsonPath): + context_object_path: Final[str] + + def __init__(self, context_object_path: str): + json_path = context_object_path[1:] + super().__init__(json_path=json_path) + self.context_object_path = context_object_path + + def _eval_body(self, env: Environment) -> None: + input_value = env.states.context_object.context_object_data + if self.json_path == JSONPATH_ROOT_PATH: + output_value = input_value + else: + try: + output_value = extract_json(self.json_path, input_value) + except NoSuchJsonPathError: + input_value_json_str = to_json_str(input_value) + cause = ( + f"The JSONPath '${self.json_path}' specified for the field '{env.next_field_name}' " + f"could not be found in the input '{input_value_json_str}'" + ) + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=cause + ) + ), + ) + ) + # TODO: introduce copy on write approach + env.stack.append(copy.deepcopy(output_value)) + + +class StringVariableSample(StringSampler): + query_language_mode: Final[QueryLanguageMode] + expression: Final[str] + + def __init__(self, query_language_mode: QueryLanguageMode, expression: str): + super().__init__(literal_value=expression) + self.query_language_mode = query_language_mode + self.expression = expression + + def _eval_body(self, env: Environment) -> None: + # Get the variables sampled in the jsonata expression. + expression_variable_references: set[VariableReference] = ( + extract_jsonata_variable_references(self.expression) + ) + variable_declarations_list = list() + if self.query_language_mode == QueryLanguageMode.JSONata: + # Sample $states values into expression. + states_variable_declarations: VariableDeclarations = ( + env.states.to_variable_declarations( + variable_references=expression_variable_references + ) + ) + variable_declarations_list.append(states_variable_declarations) + + # Sample Variable store values in to expression. + # TODO: this could be optimised by sampling only those invoked. + variable_declarations: VariableDeclarations = env.variable_store.get_variable_declarations() + variable_declarations_list.append(variable_declarations) + + rich_jsonata_expression: JSONataExpression = compose_jsonata_expression( + final_jsonata_expression=self.expression, + variable_declarations_list=variable_declarations_list, + ) + result = eval_jsonata_expression(rich_jsonata_expression) + env.stack.append(result) + + +class StringIntrinsicFunction(StringExpressionSimple): + intrinsic_function_derivation: Final[str] + function: Final[EvalComponent] + + def __init__(self, intrinsic_function_derivation: str, function: EvalComponent) -> None: + super().__init__(literal_value=intrinsic_function_derivation) + self.intrinsic_function_derivation = intrinsic_function_derivation + self.function = function + + def _eval_body(self, env: Environment) -> None: + self.function.eval(env=env) + + +class StringJSONata(StringExpression): + expression: Final[str] + + def __init__(self, expression: str): + super().__init__(literal_value=expression) + # TODO: check for illegal functions ($, $$, $eval) + self.expression = expression + + def _eval_body(self, env: Environment) -> None: + # Get the variables sampled in the jsonata expression. + expression_variable_references: set[VariableReference] = ( + extract_jsonata_variable_references(self.expression) + ) + + # Sample declarations for used intrinsic functions. Place this at the start allowing users to + # override these identifiers with custom variable declarations. + functions_variable_declarations: VariableDeclarations = ( + get_intrinsic_functions_declarations(variable_references=expression_variable_references) + ) + + # Sample $states values into expression. + states_variable_declarations: VariableDeclarations = env.states.to_variable_declarations( + variable_references=expression_variable_references + ) + + # Sample Variable store values in to expression. + # TODO: this could be optimised by sampling only those invoked. + variable_declarations: VariableDeclarations = env.variable_store.get_variable_declarations() + + rich_jsonata_expression: JSONataExpression = compose_jsonata_expression( + final_jsonata_expression=self.expression, + variable_declarations_list=[ + functions_variable_declarations, + states_variable_declarations, + variable_declarations, + ], + ) + result = eval_jsonata_expression(rich_jsonata_expression) + + validate_jsonata_expression_output(env, self.expression, rich_jsonata_expression, result) + + env.stack.append(result) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/heartbeat.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/heartbeat.py new file mode 100644 index 0000000000000..c268239346079 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/heartbeat.py @@ -0,0 +1,89 @@ +import abc +from typing import Final + +from localstack.aws.api.stepfunctions import ExecutionFailedEventDetails, HistoryEventType +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.json_path import NoSuchJsonPathError + + +class Heartbeat(EvalComponent, abc.ABC): + @abc.abstractmethod + def _eval_seconds(self, env: Environment) -> int: ... + + def _eval_body(self, env: Environment) -> None: + seconds = self._eval_seconds(env=env) + env.stack.append(seconds) + + +class HeartbeatSeconds(Heartbeat): + def __init__(self, heartbeat_seconds: int): + if not isinstance(heartbeat_seconds, int) and heartbeat_seconds <= 0: + raise ValueError( + f"Expected non-negative integer for HeartbeatSeconds, got '{heartbeat_seconds}' instead." + ) + self.heartbeat_seconds: Final[int] = heartbeat_seconds + + def _eval_seconds(self, env: Environment) -> int: + return self.heartbeat_seconds + + +class HeartbeatSecondsJSONata(Heartbeat): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _eval_seconds(self, env: Environment) -> int: + self.string_jsonata.eval(env=env) + # TODO: add snapshot tests to verify AWS's behaviour about non integer values. + seconds = int(env.stack.pop()) + return seconds + + +class HeartbeatSecondsPath(Heartbeat): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def _eval_seconds(self, env: Environment) -> int: + try: + self.string_sampler.eval(env=env) + except NoSuchJsonPathError as no_such_json_path_error: + json_path = no_such_json_path_error.json_path + cause = f"Invalid path '{json_path}' : No results for path: $['{json_path[2:]}']" + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=cause + ) + ), + ) + ) + seconds = env.stack.pop() + if not isinstance(seconds, int) and seconds <= 0: + raise ValueError( + f"Expected non-negative integer for HeartbeatSecondsPath, got '{seconds}' instead." + ) + return seconds diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/timeout.py b/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/timeout.py new file mode 100644 index 0000000000000..03ae1a6ba2e33 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/common/timeouts/timeout.py @@ -0,0 +1,113 @@ +import abc +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + ExecutionFailedEventDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.json_path import NoSuchJsonPathError + + +class EvalTimeoutError(TimeoutError): + pass + + +class Timeout(EvalComponent, abc.ABC): + @abc.abstractmethod + def is_default_value(self) -> bool: ... + + @abc.abstractmethod + def _eval_seconds(self, env: Environment) -> int: ... + + def _eval_body(self, env: Environment) -> None: + seconds = self._eval_seconds(env=env) + env.stack.append(seconds) + + +class TimeoutSeconds(Timeout): + DEFAULT_TIMEOUT_SECONDS: Final[int] = 99999999 + + def __init__(self, timeout_seconds: int, is_default: Optional[bool] = None): + if not isinstance(timeout_seconds, int) and timeout_seconds <= 0: + raise ValueError( + f"Expected non-negative integer for TimeoutSeconds, got '{timeout_seconds}' instead." + ) + self.timeout_seconds: Final[int] = timeout_seconds + self.is_default: Optional[bool] = is_default + + def is_default_value(self) -> bool: + if self.is_default is not None: + return self.is_default + return self.timeout_seconds == self.DEFAULT_TIMEOUT_SECONDS + + def _eval_seconds(self, env: Environment) -> int: + return self.timeout_seconds + + +class TimeoutSecondsJSONata(Timeout): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def is_default_value(self) -> bool: + return False + + def _eval_seconds(self, env: Environment) -> int: + self.string_jsonata.eval(env=env) + # TODO: add snapshot tests to verify AWS's behaviour about non integer values. + seconds = int(env.stack.pop()) + return seconds + + +class TimeoutSecondsPath(Timeout): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def is_default_value(self) -> bool: + return False + + def _eval_seconds(self, env: Environment) -> int: + try: + self.string_sampler.eval(env=env) + except NoSuchJsonPathError as no_such_json_path_error: + json_path = no_such_json_path_error.json_path + cause = f"Invalid path '{json_path}' : No results for path: $['{json_path[2:]}']" + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=cause + ) + ), + ) + ) + seconds = env.stack.pop() + if not isinstance(seconds, int) and seconds <= 0: + raise ValueError( + f"Expected non-negative integer for TimeoutSecondsPath, got '{seconds}' instead." + ) + return seconds diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/component.py b/localstack-core/localstack/services/stepfunctions/asl/component/component.py new file mode 100644 index 0000000000000..029db9d43bce7 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/component.py @@ -0,0 +1,9 @@ +import abc + + +class Component(abc.ABC): + def __str__(self): + return f"({self.__class__.__name__}| {vars(self)}" + + def __repr__(self): + return str(self) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/eval_component.py b/localstack-core/localstack/services/stepfunctions/asl/component/eval_component.py new file mode 100644 index 0000000000000..cd7940208f5cc --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/eval_component.py @@ -0,0 +1,86 @@ +import abc +import logging +from typing import Optional + +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.component import Component +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.utils.strings import long_uid + +LOG = logging.getLogger(__name__) + + +class EvalComponent(Component, abc.ABC): + __heap_key: Optional[str] = None + + @property + def heap_key(self) -> str: + if self.__heap_key is None: + self.__heap_key = long_uid() + return self.__heap_key + + def _log_evaluation_step(self, subject: str = "Generic") -> None: + if LOG.isEnabledFor(logging.DEBUG): + LOG.debug( + "[ASL] [%s] [%s]: '%s'", + subject.lower()[:4], + self.__class__.__name__, + repr(self), + ) + + def _log_failure_event_exception(self, failure_event_exception: FailureEventException) -> None: + error_log_parts = ["Exception=FailureEventException"] + + error_name = failure_event_exception.failure_event.error_name + if error_name: + error_log_parts.append(f"Error={error_name.error_name}") + + event_details = failure_event_exception.failure_event.event_details + if event_details: + error_log_parts.append(f"Details={to_json_str(event_details)}") + + error_log = ", ".join(error_log_parts) + component_repr = repr(self) + LOG.error("%s at '%s'", error_log, component_repr) + + def _log_exception(self, exception: Exception) -> None: + exception_name = exception.__class__.__name__ + + error_log_parts = [f"Exception={exception_name}"] + + exception_body = list(exception.args) + if exception_body: + error_log_parts.append(f"Details={exception_body}") + else: + error_log_parts.append("Details=None-Available") + + error_log = ", ".join(error_log_parts) + component_repr = repr(self) + LOG.error("%s at '%s'", error_log, component_repr) + + def eval(self, env: Environment) -> None: + if env.is_running(): + self._log_evaluation_step("Computing") + try: + field_name = self._field_name() + if field_name is not None: + env.next_field_name = field_name + self._eval_body(env) + except FailureEventException as failure_event_exception: + self._log_failure_event_exception(failure_event_exception=failure_event_exception) + raise failure_event_exception + except Exception as exception: + self._log_exception(exception=exception) + raise exception + else: + self._log_evaluation_step("Pruning") + + @abc.abstractmethod + def _eval_body(self, env: Environment) -> None: + raise NotImplementedError() + + def _field_name(self) -> Optional[str]: + return self.__class__.__name__ diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/argument/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/argument/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/argument/argument.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/argument/argument.py new file mode 100644 index 0000000000000..6438471c8becb --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/argument/argument.py @@ -0,0 +1,105 @@ +import abc +from typing import Any, Final, Optional + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringVariableSample, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.json_path import extract_json + + +class Argument(EvalComponent, abc.ABC): + """ + Represents an Intrinsic Function argument that can be evaluated and whose + result is pushed onto the stack. + + Subclasses must override `_eval_argument()` to evaluate the specific value + of the argument they represent. This abstract class manages the type and + environment handling by appending the evaluated result to the environment's + stack in `_eval_body`. + + The `_eval_body` method calls `_eval_argument()` and pushes the resulting + value to the stack. + """ + + @abc.abstractmethod + def _eval_argument(self, env: Environment) -> Any: ... + + def _eval_body(self, env: Environment) -> None: + argument = self._eval_argument(env=env) + env.stack.append(argument) + + +class ArgumentLiteral(Argument): + definition_value: Final[Optional[Any]] + + def __init__(self, definition_value: Optional[Any]): + self.definition_value = definition_value + + def _eval_argument(self, env: Environment) -> Any: + return self.definition_value + + +class ArgumentJsonPath(Argument): + json_path: Final[str] + + def __init__(self, json_path: str): + self.json_path = json_path + + def _eval_argument(self, env: Environment) -> Any: + inp = env.stack[-1] + value = extract_json(self.json_path, inp) + return value + + +class ArgumentContextPath(ArgumentJsonPath): + def __init__(self, context_path: str): + json_path = context_path[1:] + super().__init__(json_path=json_path) + + def _eval_argument(self, env: Environment) -> Any: + value = extract_json(self.json_path, env.states.context_object.context_object_data) + return value + + +class ArgumentFunction(Argument): + function: Final[EvalComponent] + + def __init__(self, function: EvalComponent): + self.function = function + + def _eval_argument(self, env: Environment) -> Any: + self.function.eval(env=env) + output_value = env.stack.pop() + return output_value + + +class ArgumentVar(Argument): + string_variable_sample: Final[StringVariableSample] + + def __init__(self, string_variable_sample: StringVariableSample): + super().__init__() + self.string_variable_sample = string_variable_sample + + def _eval_argument(self, env: Environment) -> Any: + self.string_variable_sample.eval(env=env) + value = env.stack.pop() + return value + + +class ArgumentList(Argument): + arguments: Final[list[Argument]] + size: Final[int] + + def __init__(self, arguments: list[Argument]): + self.arguments = arguments + self.size = len(arguments) + + def _eval_argument(self, env: Environment) -> Any: + values = list() + for argument in self.arguments: + argument.eval(env=env) + argument_value = env.stack.pop() + values.append(argument_value) + return values diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/component.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/component.py new file mode 100644 index 0000000000000..98b1e50e47d94 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/component.py @@ -0,0 +1,11 @@ +import abc + + +class Component(abc.ABC): + # TODO. + def __str__(self): + return str(self.__dict__) + + # TODO. + def __repr__(self): + return self.__str__() diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/function.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/function.py new file mode 100644 index 0000000000000..dd41bdeab2028 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/function.py @@ -0,0 +1,17 @@ +import abc +from typing import Final + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ArgumentList +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.function_name import ( + FunctionName, +) + + +class Function(EvalComponent, abc.ABC): + name: FunctionName + argument_list: Final[ArgumentList] + + def __init__(self, name: FunctionName, argument_list: ArgumentList): + self.name = name + self.argument_list = argument_list diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array.py new file mode 100644 index 0000000000000..1b10fa1e97735 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array.py @@ -0,0 +1,28 @@ +from typing import Any + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class Array(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.Array), + argument_list=argument_list, + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + values: list[Any] = env.stack.pop() + env.stack.append(values) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_contains.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_contains.py new file mode 100644 index 0000000000000..340fa5ec6d2a9 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_contains.py @@ -0,0 +1,50 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ArrayContains(StatesFunction): + # Determines if a specific value is present in an array. + # + # For example: + # With input + # { + # "inputArray": [1,2,3,4,5,6,7,8,9], + # "lookingFor": 5 + # } + # + # The call: + # States.ArrayContains($.inputArray, $.lookingFor) + # + # Returns: + # true + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.ArrayContains), + argument_list=argument_list, + ) + if argument_list.size != 2: + raise ValueError( + f"Expected 2 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + array = args[0] + value = args[1] + if not isinstance(array, list): + raise TypeError(f"Expected an array type as first argument, but got {array}.") + contains = value in array + env.stack.append(contains) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_get_item.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_get_item.py new file mode 100644 index 0000000000000..fc9448d28d5a5 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_get_item.py @@ -0,0 +1,54 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ArrayGetItem(StatesFunction): + # Returns a specified index's value. + # + # For example: + # With input + # { + # "inputArray": [1,2,3,4,5,6,7,8,9], + # "index": 5 + # } + # + # The call + # States.ArrayGetItem($.inputArray, $.index) + # + # Returns + # 6 + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.ArrayGetItem), + argument_list=argument_list, + ) + if argument_list.size != 2: + raise ValueError( + f"Expected 2 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + index = args.pop() + if not isinstance(index, int): + raise TypeError(f"Expected an integer index value, but got '{index}'.") + + array = args.pop() + if not isinstance(array, list): + raise TypeError(f"Expected an array type, but got '{array}'.") + + item = array[index] + env.stack.append(item) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_length.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_length.py new file mode 100644 index 0000000000000..f1050fab9aaf2 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_length.py @@ -0,0 +1,49 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ArrayLength(StatesFunction): + # Returns the length of the array. + # + # For example: + # With input + # { + # "inputArray": [1,2,3,4,5,6,7,8,9] + # } + # + # The call + # States.ArrayLength($.inputArray) + # + # Returns + # 9 + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.ArrayLength), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + array = args.pop() + if not isinstance(array, list): + raise TypeError(f"Expected an array type, but got '{array}'.") + + length = len(array) + env.stack.append(length) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_partition.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_partition.py new file mode 100644 index 0000000000000..a12b2780c0faf --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_partition.py @@ -0,0 +1,66 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ArrayPartition(StatesFunction): + # Partitions the input array. + # + # For example: + # With input + # { + # "inputArray": [1, 2, 3, 4, 5, 6, 7, 8, 9] + # } + # + # The call + # States.ArrayPartition($.inputArray,4) + # + # Returns + # [ [1,2,3,4], [5,6,7,8], [9]] + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.ArrayPartition), + argument_list=argument_list, + ) + if argument_list.size != 2: + raise ValueError( + f"Expected 2 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + chunk_size = args.pop() + if not isinstance(chunk_size, (int, float)): + raise TypeError(f"Expected an integer value as chunk_size, but got {chunk_size}.") + chunk_size = round(chunk_size) + if chunk_size < 0: + raise ValueError( + f"Expected a non-zero, positive integer as chuck_size, but got {chunk_size}." + ) + + array = args.pop() + if not isinstance(array, list): + raise TypeError(f"Expected an array type as first argument, but got {array}.") + + chunks = self._to_chunks(array=array, chunk_size=chunk_size) + env.stack.append(chunks) + + @staticmethod + def _to_chunks(array: list, chunk_size: int): + chunks = list() + for i in range(0, len(array), chunk_size): + chunks.append(array[i : i + chunk_size]) + return chunks diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_range.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_range.py new file mode 100644 index 0000000000000..5528d62b57159 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_range.py @@ -0,0 +1,56 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ArrayRange(StatesFunction): + # Creates a new array containing a specific range of elements. + # + # For example: + # The call + # States.ArrayRange(1, 9, 2) + # + # Returns + # [1,3,5,7,9] + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.ArrayRange), + argument_list=argument_list, + ) + if argument_list.size != 3: + raise ValueError( + f"Expected 3 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + range_vals = env.stack.pop() + + for range_val in range_vals: + if not isinstance(range_val, (int, float)): + raise TypeError( + f"Expected 3 integer arguments for function type '{type(self)}', but got: '{range_vals}'." + ) + first = round(range_vals[0]) + last = round(range_vals[1]) + step = round(range_vals[2]) + + if step <= 0: + raise ValueError(f"Expected step argument to be non negative, but got: '{step}'.") + + array = list(range(first, last + 1, step)) + + if len(array) > 1000: + raise ValueError(f"Arrays cannot contain more than 1000 items, size: {len(array)}.") + + env.stack.append(array) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_unique.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_unique.py new file mode 100644 index 0000000000000..93833f686ba41 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/array/array_unique.py @@ -0,0 +1,54 @@ +from collections import OrderedDict + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ArrayUnique(StatesFunction): + # Removes duplicate values from an array and returns an array containing only unique elements + # + # For example: + # With input + # { + # "inputArray": [1,2,3,3,3,3,3,3,4] + # } + # + # The call + # States.ArrayUnique($.inputArray) + # + # Returns + # [1,2,3,4] + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.ArrayUnique), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + array = args.pop() + if not isinstance(array, list): + raise TypeError(f"Expected an array type, but got '{array}'.") + + # Remove duplicates through an ordered set, in this + # case we consider they key set of an ordered dict. + items_odict = OrderedDict.fromkeys(array).keys() + unique_array = list(items_odict) + env.stack.append(unique_array) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/base_64_decode.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/base_64_decode.py new file mode 100644 index 0000000000000..8a4ebe8d94835 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/base_64_decode.py @@ -0,0 +1,60 @@ +import base64 +from typing import Final + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_fuinction_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class Base64Decode(StatesFunction): + # Encodes data based on MIME Base64 encoding scheme. + # + # For example: + # With input + # { + # "input": "Data to encode" + # } + # + # The call + # "base64.$": "States.Base64Encode($.input)" + # + # Returns + # {"base64": "RGF0YSB0byBlbmNvZGU="} + + MAX_INPUT_CHAR_LEN: Final[int] = 10_000 + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.Base64Decode), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + base64_string: str = args.pop() + if len(base64_string) > self.MAX_INPUT_CHAR_LEN: + raise ValueError( + f"Maximum input string for function type '{type(self)}' " + f"is '{self.MAX_INPUT_CHAR_LEN}', but got '{len(base64_string)}'." + ) + + base64_string_bytes = base64_string.encode("ascii") + string_bytes = base64.b64decode(base64_string_bytes) + string = string_bytes.decode("ascii") + env.stack.append(string) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/base_64_encode.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/base_64_encode.py new file mode 100644 index 0000000000000..33a72f845c0b1 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/encoding_decoding/base_64_encode.py @@ -0,0 +1,60 @@ +import base64 +from typing import Final + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_fuinction_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class Base64Encode(StatesFunction): + # Decodes data based on MIME Base64 encoding scheme. + # + # For example: + # With input + # { + # "base64": "RGF0YSB0byBlbmNvZGU=" + # } + # + # The call + # "data.$": "States.Base64Decode($.base64)" + # + # Returns + # {"data": "Decoded data"} + + MAX_INPUT_CHAR_LEN: Final[int] = 10_000 + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.Base64Encode), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + string: str = args.pop() + if len(string) > self.MAX_INPUT_CHAR_LEN: + raise ValueError( + f"Maximum input string for function type '{type(self)}' " + f"is '{self.MAX_INPUT_CHAR_LEN}', but got '{len(string)}'." + ) + + string_bytes = string.encode("ascii") + string_base64_bytes = base64.b64encode(string_bytes) + base64_string = string_base64_bytes.decode("ascii") + env.stack.append(base64_string) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/factory.py new file mode 100644 index 0000000000000..bbfb779802782 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/factory.py @@ -0,0 +1,106 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ArgumentList +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.array import ( + array, + array_contains, + array_get_item, + array_length, + array_partition, + array_range, + array_unique, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.encoding_decoding import ( + base_64_decode, + base_64_encode, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.generic import ( + string_format, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.hash_calculations import ( + hash_func, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.json_manipulation import ( + json_merge, + json_to_string, + string_to_json, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.math_operations import ( + math_add, + math_random, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.string_operations import ( + string_split, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.unique_id_generation import ( + uuid, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) + + +# TODO: could use reflection on StatesFunctionNameType values. +class StatesFunctionFactory: + @staticmethod + def from_name(func_name: StatesFunctionName, argument_list: ArgumentList) -> StatesFunction: + match func_name.function_type: + # Array. + case StatesFunctionNameType.Array: + return array.Array(argument_list=argument_list) + case StatesFunctionNameType.ArrayPartition: + return array_partition.ArrayPartition(argument_list=argument_list) + case StatesFunctionNameType.ArrayContains: + return array_contains.ArrayContains(argument_list=argument_list) + case StatesFunctionNameType.ArrayRange: + return array_range.ArrayRange(argument_list=argument_list) + case StatesFunctionNameType.ArrayGetItem: + return array_get_item.ArrayGetItem(argument_list=argument_list) + case StatesFunctionNameType.ArrayLength: + return array_length.ArrayLength(argument_list=argument_list) + case StatesFunctionNameType.ArrayUnique: + return array_unique.ArrayUnique(argument_list=argument_list) + + # JSON Manipulation + case StatesFunctionNameType.JsonToString: + return json_to_string.JsonToString(argument_list=argument_list) + case StatesFunctionNameType.StringToJson: + return string_to_json.StringToJson(argument_list=argument_list) + case StatesFunctionNameType.JsonMerge: + return json_merge.JsonMerge(argument_list=argument_list) + + # Unique Id Generation. + case StatesFunctionNameType.UUID: + return uuid.UUID(argument_list=argument_list) + + # String Operations. + case StatesFunctionNameType.StringSplit: + return string_split.StringSplit(argument_list=argument_list) + + # Hash Calculations. + case StatesFunctionNameType.Hash: + return hash_func.HashFunc(argument_list=argument_list) + + # Encoding and Decoding. + case StatesFunctionNameType.Base64Encode: + return base_64_encode.Base64Encode(argument_list=argument_list) + case StatesFunctionNameType.Base64Decode: + return base_64_decode.Base64Decode(argument_list=argument_list) + + # Math Operations. + case StatesFunctionNameType.MathRandom: + return math_random.MathRandom(argument_list=argument_list) + case StatesFunctionNameType.MathAdd: + return math_add.MathAdd(argument_list=argument_list) + + # Generic. + case StatesFunctionNameType.Format: + return string_format.StringFormat(argument_list=argument_list) + + # Unsupported. + case unsupported: + raise NotImplementedError(unsupported) # noqa diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/generic/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/generic/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/generic/string_format.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/generic/string_format.py new file mode 100644 index 0000000000000..86e8b50050518 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/generic/string_format.py @@ -0,0 +1,105 @@ +import json +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentContextPath, + ArgumentJsonPath, + ArgumentList, + ArgumentLiteral, + ArgumentVar, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StringFormat(StatesFunction): + # It constructs a string from both literal and interpolated values. This function takes one or more arguments. + # The value of the first argument must be a string, and may include zero or more instances of the character + # sequence {}. The interpreter returns the string defined in the first argument with each {} replaced by the value + # of the positionally-corresponding argument in the Intrinsic invocation. + # + # For example: + # With input + # { + # "name": "Arnav", + # "template": "Hello, my name is {}." + # } + # + # Calls + # States.Format('Hello, my name is {}.', $.name) + # States.Format($.template, $.name) + # + # Return + # Hello, my name is Arnav. + _DELIMITER: Final[str] = "{}" + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.Format), + argument_list=argument_list, + ) + if argument_list.size == 0: + raise ValueError( + f"Expected at least 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + first_argument = argument_list.arguments[0] + if isinstance(first_argument, ArgumentLiteral) and not isinstance( + first_argument.definition_value, str + ): + raise ValueError( + f"Expected the first argument for function type '{type(self)}' to be a string, but got: '{first_argument.definition_value}'." + ) + elif not isinstance( + first_argument, (ArgumentLiteral, ArgumentVar, ArgumentJsonPath, ArgumentContextPath) + ): + raise ValueError( + f"Expected the first argument for function type '{type(self)}' to be a string, but got: '{first_argument}'." + ) + + def _eval_body(self, env: Environment) -> None: + # TODO: investigate behaviour for incorrect number of arguments in string format. + self.argument_list.eval(env=env) + args = env.stack.pop() + + string_format: str = args[0] + values: list[Any] = args[1:] + + values_str_repr = map(self._to_str_repr, values) + string_result = string_format.format(*values_str_repr) + + env.stack.append(string_result) + + @staticmethod + def _to_str_repr(value: Any) -> str: + # Converts a value or object to a string representation compatible with sfn. + # For example: + # Input object + # { + # "Arg1": 1, + # "Arg2": [] + # } + # Is mapped to the string + # {Arg1=1, Arg2=[]} + + if isinstance(value, str): + return value + elif isinstance(value, list): + value_parts: list[str] = list(map(StringFormat._to_str_repr, value)) + return f"[{', '.join(value_parts)}]" + elif isinstance(value, dict): + dict_items = list() + for d_key, d_value in value.items(): + d_value_lit = StringFormat._to_str_repr(d_value) + dict_items.append(f"{d_key}={d_value_lit}") + return f"{{{', '.join(dict_items)}}}" + else: + # Return json representation of terminal value. + return json.dumps(value) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/hash_algorithm.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/hash_algorithm.py new file mode 100644 index 0000000000000..efb4239e14f43 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/hash_algorithm.py @@ -0,0 +1,9 @@ +import enum + + +class HashAlgorithm(enum.Enum): + MD5 = "MD5" + SHA_1 = "SHA-1" + SHA_256 = "SHA-256" + SHA_384 = "SHA-384" + SHA_512 = "SHA-512" diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/hash_func.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/hash_func.py new file mode 100644 index 0000000000000..135f73826f86b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/hash_calculations/hash_func.py @@ -0,0 +1,76 @@ +import hashlib +from typing import Final + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.hash_calculations.hash_algorithm import ( + HashAlgorithm, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class HashFunc(StatesFunction): + MAX_INPUT_CHAR_LEN: Final[int] = 10_000 + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.Hash), + argument_list=argument_list, + ) + if argument_list.size != 2: + raise ValueError( + f"Expected 2 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + @staticmethod + def _hash_inp_with_alg(inp: str, alg: HashAlgorithm) -> str: + inp_enc = inp.encode() + hash_inp = None + match alg: + case HashAlgorithm.MD5: + hash_inp = hashlib.md5(inp_enc) + case HashAlgorithm.SHA_1: + hash_inp = hashlib.sha1(inp_enc) + case HashAlgorithm.SHA_256: + hash_inp = hashlib.sha256(inp_enc) + case HashAlgorithm.SHA_384: + hash_inp = hashlib.sha384(inp_enc) + case HashAlgorithm.SHA_512: + hash_inp = hashlib.sha512(inp_enc) + hash_value: str = hash_inp.hexdigest() + return hash_value + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + algorithm = args.pop() + try: + hash_algorithm = HashAlgorithm(algorithm) + except Exception: + raise ValueError(f"Unknown hash function '{algorithm}'.") + + input_data = args.pop() + if not isinstance(input_data, str): + raise TypeError( + f"Expected string type as input data for function type '{type(self)}', but got: '{input_data}'." + ) + + if len(input_data) > self.MAX_INPUT_CHAR_LEN: + raise ValueError( + f"Maximum character input length for for function type '{type(self)}' " + f"is '{self.MAX_INPUT_CHAR_LEN}', but got '{len(input_data)}'." + ) + + res = self._hash_inp_with_alg(input_data, hash_algorithm) + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/json_merge.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/json_merge.py new file mode 100644 index 0000000000000..a6e9221d26c81 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/json_merge.py @@ -0,0 +1,89 @@ +import copy +from typing import Any + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class JsonMerge(StatesFunction): + # Merges two JSON objects into a single object + # + # For example: + # With input + # { + # "json1": { "a": {"a1": 1, "a2": 2}, "b": 2, }, + # "json2": { "a": {"a3": 1, "a4": 2}, "c": 3 } + # } + # + # Call + # "output.$": "States.JsonMerge($.json1, $.json2, false)" + # + # Returns + # { + # "output": { + # "a": {"a3": 1, "a4": 2}, + # "b": 2, + # "c": 3 + # } + # } + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.JsonMerge), + argument_list=argument_list, + ) + if argument_list.size != 3: + raise ValueError( + f"Expected 3 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + @staticmethod + def _validate_is_deep_merge_argument(is_deep_merge: Any) -> None: + if not isinstance(is_deep_merge, bool): + raise TypeError( + f"Expected boolean value for deep merge mode, but got: '{is_deep_merge}'." + ) + if is_deep_merge: + # This is AWS's limitation, not LocalStack's. + raise NotImplementedError( + "Currently, Step Functions only supports the shallow merging mode; " + "therefore, you must specify the boolean value as false." + ) + + @staticmethod + def _validate_merge_argument(argument: Any, num: int) -> None: + if not isinstance(argument, dict): + raise TypeError(f"Expected a JSON object the argument {num}, but got: '{argument}'.") + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + is_deep_merge = args.pop() + self._validate_is_deep_merge_argument(is_deep_merge) + + snd = args.pop() + self._validate_merge_argument(snd, 2) + + fst = args.pop() + self._validate_merge_argument(snd, 2) + + # Currently, Step Functions only supports the shallow merging mode; therefore, you must specify the boolean + # value as false. In the shallow mode, if the same key exists in both JSON objects, the latter object's key + # overrides the same key in the first object. Additionally, objects nested within a JSON object aren't merged + # when you use shallow merging. + merged = copy.deepcopy(fst) + merged.update(snd) + + env.stack.append(merged) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/json_to_string.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/json_to_string.py new file mode 100644 index 0000000000000..9dfff92d8c449 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/json_to_string.py @@ -0,0 +1,34 @@ +import json + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class JsonToString(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.JsonToString), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + json_obj: json = args.pop() + json_string: str = json.dumps(json_obj, separators=(",", ":")) + env.stack.append(json_string) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/string_to_json.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/string_to_json.py new file mode 100644 index 0000000000000..cc42874cf2baa --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/json_manipulation/string_to_json.py @@ -0,0 +1,39 @@ +import json + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StringToJson(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.StringToJson), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + string_json: str = args.pop() + + if string_json is not None and string_json.strip(): + json_obj: json = json.loads(string_json) + else: + json_obj: json = None + env.stack.append(json_obj) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/math_add.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/math_add.py new file mode 100644 index 0000000000000..c4124f1195159 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/math_add.py @@ -0,0 +1,78 @@ +import decimal +from typing import Any + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_fuinction_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +def _round_like_java(f: float) -> int: + # this behaves a bit weird for boundary values + # AWS stepfunctions is implemented in Java, so we need to adjust the rounding accordingly + # python by default rounds half to even + if f >= 0: + decimal.getcontext().rounding = decimal.ROUND_HALF_UP + else: + decimal.getcontext().rounding = decimal.ROUND_HALF_DOWN + d = decimal.Decimal(f) + return round(d, 0) + + +class MathAdd(StatesFunction): + # Returns the sum of two numbers. + # + # For example: + # With input + # { + # "value1": 111, + # "step": -1 + # } + # + # Call + # "value1.$": "States.MathAdd($.value1, $.step)" + # + # Returns + # {"value1": 110 } + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.MathAdd), + argument_list=argument_list, + ) + if argument_list.size != 2: + raise ValueError( + f"Expected 2 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + @staticmethod + def _validate_integer_value(value: Any) -> int: + if not isinstance(value, (int, float)): + raise TypeError(f"Expected integer value, but got: '{value}'.") + # If you specify a non-integer value for one or both the arguments, + # Step Functions will round it off to the nearest integer. + + if isinstance(value, float): + result = _round_like_java(value) + return int(result) + + return value + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + a = self._validate_integer_value(args[0]) + b = self._validate_integer_value(args[1]) + + res = a + b + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/math_random.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/math_random.py new file mode 100644 index 0000000000000..b50d1dcb4368d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/math_operations/math_random.py @@ -0,0 +1,67 @@ +import random +from typing import Any + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_fuinction_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class MathRandom(StatesFunction): + # Returns a random number between the specified start and end number. + # + # For example: + # With input + # { + # "start": 1, + # "end": 999 + # } + # + # Call + # "random.$": "States.MathRandom($.start, $.end)" + # + # Returns + # {"random": 456 } + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.MathRandom), + argument_list=argument_list, + ) + if argument_list.size < 2 or argument_list.size > 3: + raise ValueError( + f"Expected 2-3 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + @staticmethod + def _validate_integer_value(value: Any, argument_name: str) -> int: + if not isinstance(value, (int, float)): + raise TypeError(f"Expected integer value for {argument_name}, but got: '{value}'.") + # If you specify a non-integer value for the start number or end number argument, + # Step Functions will round it off to the nearest integer. + return int(value) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + seed = None + if self.argument_list.size == 3: + seed = args.pop() + self._validate_integer_value(seed, "seed") + + end = self._validate_integer_value(args.pop(), "end") + start = self._validate_integer_value(args.pop(), "start") + + rand_gen = random.Random(seed) + rand_int = rand_gen.randint(start, end) + env.stack.append(rand_int) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function.py new file mode 100644 index 0000000000000..dfb4b6e420560 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function.py @@ -0,0 +1,16 @@ +import abc + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.function import Function +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) + + +class StatesFunction(Function, abc.ABC): + name: StatesFunctionName + + def __init__(self, states_name: StatesFunctionName, argument_list: ArgumentList): + super().__init__(name=states_name, argument_list=argument_list) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_array.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_array.py new file mode 100644 index 0000000000000..5cce091f0fd85 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_array.py @@ -0,0 +1,31 @@ +from typing import Any + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StatesFunctionArray(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.Array), + argument_list=argument_list, + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + values: list[Any] = list() + for _ in range(self.argument_list.size): + values.append(env.stack.pop()) + values.reverse() + env.stack.append(values) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_format.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_format.py new file mode 100644 index 0000000000000..8b71a07fbd122 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_format.py @@ -0,0 +1,56 @@ +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, + ArgumentLiteral, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StatesFunctionFormat(StatesFunction): + _DELIMITER: Final[str] = "{}" + + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.Format), + argument_list=argument_list, + ) + if argument_list.size == 0: + raise ValueError( + f"Expected at least 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + first_argument = argument_list.arguments[0] + if not ( + isinstance(first_argument, ArgumentLiteral) + and isinstance(first_argument.definition_value, str) + ): + raise ValueError( + f"Expected the first argument for function type '{type(self)}' to be a string, but got: '{first_argument}'." + ) + + def _eval_body(self, env: Environment) -> None: + # TODO: investigate behaviour for incorrect number of arguments in string format. + self.argument_list.eval(env=env) + + values: list[Any] = list() + for _ in range(self.argument_list.size): + values.append(env.stack.pop()) + string_format: str = values.pop() + values.reverse() + + string_format_parts: list[str] = string_format.split(self._DELIMITER) + string_result: str = "" + for part in string_format_parts: + string_result += part + string_result += values.pop() + + env.stack.append(string_result) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_json_to_string.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_json_to_string.py new file mode 100644 index 0000000000000..f2a29724dad80 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_json_to_string.py @@ -0,0 +1,33 @@ +import json + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StatesFunctionJsonToString(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.JsonToString), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + json_obj: json = env.stack.pop() + json_string: str = json.dumps(json_obj) + env.stack.append(json_string) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_string_to_json.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_string_to_json.py new file mode 100644 index 0000000000000..1dde28d4257e1 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_string_to_json.py @@ -0,0 +1,33 @@ +import json + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StatesFunctionStringToJson(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.StringToJson), + argument_list=argument_list, + ) + if argument_list.size != 1: + raise ValueError( + f"Expected 1 argument for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + string_json: str = env.stack.pop() + json_obj: json = json.loads(string_json) + env.stack.append(json_obj) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_uuid.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_uuid.py new file mode 100644 index 0000000000000..34b23541e0b0a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/states_function_uuid.py @@ -0,0 +1,29 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.utils.strings import long_uid + + +class StatesFunctionUUID(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.UUID), + argument_list=argument_list, + ) + if argument_list.size != 0: + raise ValueError( + f"Expected no arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + env.stack.append(long_uid()) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/string_operations/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/string_operations/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/string_operations/string_split.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/string_operations/string_split.py new file mode 100644 index 0000000000000..a1187e9aa4465 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/string_operations/string_split.py @@ -0,0 +1,69 @@ +import re + +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StringSplit(StatesFunction): + # Splits a string into an array of values. + # + # For example: + # With input + # { + # "inputString": "This.is+a,test=string", + # "splitter": ".+,=" + # } + # + # The call + # { + # "myStringArray.$": "States.StringSplit($.inputString, $.splitter)" + # } + # + # Returns + # {"myStringArray": [ + # "This", + # "is", + # "a", + # "test", + # "string" + # ]} + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.StringSplit), + argument_list=argument_list, + ) + if argument_list.size != 2: + raise ValueError( + f"Expected 2 arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + self.argument_list.eval(env=env) + args = env.stack.pop() + + del_chars = args.pop() + if not isinstance(del_chars, str): + raise ValueError( + f"Expected string value as delimiting characters, but got '{del_chars}'." + ) + + string = args.pop() + if not isinstance(del_chars, str): + raise ValueError(f"Expected string value, but got '{del_chars}'.") + + pattern = "|".join(re.escape(c) for c in del_chars) + + parts = re.split(pattern, string) + parts_clean = list(filter(bool, parts)) + env.stack.append(parts_clean) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/unique_id_generation/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/unique_id_generation/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/unique_id_generation/uuid.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/unique_id_generation/uuid.py new file mode 100644 index 0000000000000..1a0d6a75f7b09 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/function/statesfunction/unique_id_generation/uuid.py @@ -0,0 +1,29 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + ArgumentList, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.utils.strings import long_uid + + +class UUID(StatesFunction): + def __init__(self, argument_list: ArgumentList): + super().__init__( + states_name=StatesFunctionName(function_type=StatesFunctionNameType.UUID), + argument_list=argument_list, + ) + if argument_list.size != 0: + raise ValueError( + f"Expected no arguments for function type '{type(self)}', but got: '{argument_list}'." + ) + + def _eval_body(self, env: Environment) -> None: + env.stack.append(long_uid()) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/custom_function_name.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/custom_function_name.py new file mode 100644 index 0000000000000..ba92c7a5837bf --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/custom_function_name.py @@ -0,0 +1,8 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.function_name import ( + FunctionName, +) + + +class CustomFunctionName(FunctionName): + def __init__(self, name: str): + super().__init__(name=name) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/function_name.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/function_name.py new file mode 100644 index 0000000000000..c90af50033a85 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/function_name.py @@ -0,0 +1,10 @@ +import abc + +from localstack.services.stepfunctions.asl.component.component import Component + + +class FunctionName(Component, abc.ABC): + name: str + + def __init__(self, name: str): + self.name = name diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/state_fuinction_name_types.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/state_fuinction_name_types.py new file mode 100644 index 0000000000000..3c4b142c15ef9 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/state_fuinction_name_types.py @@ -0,0 +1,27 @@ +from enum import Enum + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicLexer import ASLIntrinsicLexer + + +class StatesFunctionNameType(Enum): + Format = ASLIntrinsicLexer.Format + StringToJson = ASLIntrinsicLexer.StringToJson + JsonToString = ASLIntrinsicLexer.JsonToString + Array = ASLIntrinsicLexer.Array + ArrayPartition = ASLIntrinsicLexer.ArrayPartition + ArrayContains = ASLIntrinsicLexer.ArrayContains + ArrayRange = ASLIntrinsicLexer.ArrayRange + ArrayGetItem = ASLIntrinsicLexer.ArrayGetItem + ArrayLength = ASLIntrinsicLexer.ArrayLength + ArrayUnique = ASLIntrinsicLexer.ArrayUnique + Base64Encode = ASLIntrinsicLexer.Base64Encode + Base64Decode = ASLIntrinsicLexer.Base64Decode + Hash = ASLIntrinsicLexer.Hash + JsonMerge = ASLIntrinsicLexer.JsonMerge + MathRandom = ASLIntrinsicLexer.MathRandom + MathAdd = ASLIntrinsicLexer.MathAdd + StringSplit = ASLIntrinsicLexer.StringSplit + UUID = ASLIntrinsicLexer.UUID + + def name(self) -> str: + return ASLIntrinsicLexer.symbolicNames[self.value][1:-1] diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/state_function_name_types.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/state_function_name_types.py new file mode 100644 index 0000000000000..3c4b142c15ef9 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/state_function_name_types.py @@ -0,0 +1,27 @@ +from enum import Enum + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicLexer import ASLIntrinsicLexer + + +class StatesFunctionNameType(Enum): + Format = ASLIntrinsicLexer.Format + StringToJson = ASLIntrinsicLexer.StringToJson + JsonToString = ASLIntrinsicLexer.JsonToString + Array = ASLIntrinsicLexer.Array + ArrayPartition = ASLIntrinsicLexer.ArrayPartition + ArrayContains = ASLIntrinsicLexer.ArrayContains + ArrayRange = ASLIntrinsicLexer.ArrayRange + ArrayGetItem = ASLIntrinsicLexer.ArrayGetItem + ArrayLength = ASLIntrinsicLexer.ArrayLength + ArrayUnique = ASLIntrinsicLexer.ArrayUnique + Base64Encode = ASLIntrinsicLexer.Base64Encode + Base64Decode = ASLIntrinsicLexer.Base64Decode + Hash = ASLIntrinsicLexer.Hash + JsonMerge = ASLIntrinsicLexer.JsonMerge + MathRandom = ASLIntrinsicLexer.MathRandom + MathAdd = ASLIntrinsicLexer.MathAdd + StringSplit = ASLIntrinsicLexer.StringSplit + UUID = ASLIntrinsicLexer.UUID + + def name(self) -> str: + return ASLIntrinsicLexer.symbolicNames[self.value][1:-1] diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/states_function_name.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/states_function_name.py new file mode 100644 index 0000000000000..c9623f8c0c112 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/functionname/states_function_name.py @@ -0,0 +1,12 @@ +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.function_name import ( + FunctionName, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) + + +class StatesFunctionName(FunctionName): + def __init__(self, function_type: StatesFunctionNameType): + super().__init__(name=function_type.name()) + self.function_type: StatesFunctionNameType = function_type diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/jsonata.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/jsonata.py new file mode 100644 index 0000000000000..8602aed713e63 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/jsonata.py @@ -0,0 +1,85 @@ +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.jsonata.jsonata import ( + VariableDeclarations, + VariableReference, +) + +_VARIABLE_REFERENCE_PARTITION: Final[VariableReference] = "$partition" +_DECLARATION_PARTITION: Final[str] = """ +$partition:=function($array,$chunk_size){ + $chunk_size=0?null: + $chunk_size>=$count($array)?[[$array]]: + $map( + [0..$floor($count($array)/$chunk_size)-(1-$count($array)%$chunk_size)], + function($i){ + $filter($array,function($v,$index){ + $index>=$i*$chunk_size and $index<($i+1)*$chunk_size + }) + } + ) +}; +""".replace("\n", "") + +_VARIABLE_REFERENCE_RANGE: Final[VariableReference] = "$range" +_DECLARATION_RANGE: Final[str] = """ +$range:=function($first,$last,$step){ + $first>$last and $step>0?[]: + $first<$last and $step<0?[]: + $map([0..$floor(($last-$first)/$step)],function($i){ + $first+$i*$step + }) +}; +""".replace("\n", "") + +# TODO: add support for $hash. +_VARIABLE_REFERENCE_HASH: Final[VariableReference] = "$hash" +_DECLARATION_HASH: Final[VariableReference] = """ +$hash:=function($value,$algo){ + "Function $hash is currently not supported" +}; +""".replace("\n", "") + +_VARIABLE_REFERENCE_RANDOMSEEDED: Final[VariableReference] = "$randomSeeded" +_DECLARATION_RANDOMSEEDED: Final[str] = """ +$randomSeeded:=function($seed){ + ($seed*9301+49297)%233280/233280 +}; +""" + +# TODO: add support for $uuid +_VARIABLE_REFERENCE_UUID: Final[VariableReference] = "$uuid" +_DECLARATION_UUID: Final[str] = """ +$uuid:=function(){ + "Function $uuid is currently not supported" +}; +""" + +_VARIABLE_REFERENCE_PARSE: Final[VariableReference] = "$parse" +_DECLARATION_PARSE: Final[str] = """ +$parse:=function($v){ + $eval($v) +}; +""" + +_DECLARATION_BY_VARIABLE_REFERENCE: Final[dict[VariableReference, str]] = { + _VARIABLE_REFERENCE_PARTITION: _DECLARATION_PARTITION, + _VARIABLE_REFERENCE_RANGE: _DECLARATION_RANGE, + _VARIABLE_REFERENCE_HASH: _DECLARATION_HASH, + _VARIABLE_REFERENCE_RANDOMSEEDED: _DECLARATION_RANDOMSEEDED, + _VARIABLE_REFERENCE_UUID: _DECLARATION_UUID, + _VARIABLE_REFERENCE_PARSE: _DECLARATION_PARSE, +} + + +def get_intrinsic_functions_declarations( + variable_references: set[VariableReference], +) -> VariableDeclarations: + declarations: list[str] = list() + for variable_reference in variable_references: + declaration: Optional[VariableDeclarations] = _DECLARATION_BY_VARIABLE_REFERENCE.get( + variable_reference + ) + if declaration: + declarations.append(declaration) + return "".join(declarations) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/member.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/member.py new file mode 100644 index 0000000000000..2e66657a2b59f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/member.py @@ -0,0 +1,16 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.intrinsic.component import Component + + +class Member(Component): ... + + +class IdentifiedMember(Member): + def __init__(self, identifier: str): + self.identifier: Final[str] = identifier + + +class DollarMember(IdentifiedMember): + def __init__(self): + super(DollarMember, self).__init__(identifier="$") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/member_access.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/member_access.py new file mode 100644 index 0000000000000..44fee4ff2783a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/member_access.py @@ -0,0 +1,9 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.intrinsic.member import Member + + +class MemberAccess(Member): + def __init__(self, subject: Member, target: Member): + self.subject: Final[Member] = subject + self.target: Final[Member] = target diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/program.py b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/program.py new file mode 100644 index 0000000000000..c9f1c57255e1a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/intrinsic/program.py @@ -0,0 +1,8 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.intrinsic.component import Component + + +class Program(Component): + def __init__(self): + self.statements: Final[list[Component]] = [] diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/program/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/program/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/program/program.py b/localstack-core/localstack/services/stepfunctions/asl/component/program/program.py new file mode 100644 index 0000000000000..e86a5cd076620 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/program/program.py @@ -0,0 +1,152 @@ +import logging +import threading +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + ExecutionAbortedEventDetails, + ExecutionFailedEventDetails, + ExecutionSucceededEventDetails, + ExecutionTimedOutEventDetails, + HistoryEventExecutionDataDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import TimeoutSeconds +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.program.version import Version +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramEnded, + ProgramError, + ProgramState, + ProgramStopped, + ProgramTimedOut, +) +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.utils.collections import select_from_typed_dict +from localstack.utils.threads import TMP_THREADS + +LOG = logging.getLogger(__name__) + + +class Program(EvalComponent): + query_language: Final[QueryLanguage] + start_at: Final[StartAt] + states: Final[States] + timeout_seconds: Final[Optional[TimeoutSeconds]] + comment: Final[Optional[Comment]] + version: Final[Optional[Version]] + + def __init__( + self, + query_language: QueryLanguage, + start_at: StartAt, + states: States, + timeout_seconds: Optional[TimeoutSeconds], + comment: Optional[Comment] = None, + version: Optional[Version] = None, + ): + self.query_language = query_language + self.start_at = start_at + self.states = states + self.timeout_seconds = timeout_seconds + self.comment = comment + self.version = version + + def _get_state(self, state_name: str) -> CommonStateField: + state: Optional[CommonStateField] = self.states.states.get(state_name, None) + if state is None: + raise ValueError(f"No such state {state}.") + return state + + def eval(self, env: Environment) -> None: + timeout = self.timeout_seconds.timeout_seconds if self.timeout_seconds else None + env.next_state_name = self.start_at.start_at_name + worker_thread = threading.Thread(target=super().eval, args=(env,), daemon=True) + TMP_THREADS.append(worker_thread) + worker_thread.start() + worker_thread.join(timeout=timeout) + is_timeout = worker_thread.is_alive() + if is_timeout: + env.set_timed_out() + + def _eval_body(self, env: Environment) -> None: + try: + while env.is_running(): + next_state: CommonStateField = self._get_state(env.next_state_name) + next_state.eval(env) + # Garbage collect hanging values added by this last state. + env.stack.clear() + env.heap.clear() + except FailureEventException as ex: + env.set_error(error=ex.get_execution_failed_event_details()) + except Exception as ex: + cause = f"{type(ex).__name__}({str(ex)})" + LOG.error("Stepfunctions computation ended with exception '%s'.", cause) + env.set_error( + ExecutionFailedEventDetails( + error=StatesErrorName(typ=StatesErrorNameType.StatesRuntime).error_name, + cause=cause, + ) + ) + + # If the program is evaluating within a frames then these are not allowed to produce program termination states. + if env.is_frame(): + return + + program_state: ProgramState = env.program_state() + if isinstance(program_state, ProgramError): + exec_failed_event_details = select_from_typed_dict( + typed_dict=ExecutionFailedEventDetails, obj=program_state.error or dict() + ) + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails(executionFailedEventDetails=exec_failed_event_details), + ) + elif isinstance(program_state, ProgramStopped): + env.event_history_context.source_event_id = 0 + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ExecutionAborted, + event_details=EventDetails( + executionAbortedEventDetails=ExecutionAbortedEventDetails( + error=program_state.error, cause=program_state.cause + ) + ), + ) + elif isinstance(program_state, ProgramTimedOut): + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ExecutionTimedOut, + event_details=EventDetails( + executionTimedOutEventDetails=ExecutionTimedOutEventDetails() + ), + ) + elif isinstance(program_state, ProgramEnded): + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ExecutionSucceeded, + event_details=EventDetails( + executionSucceededEventDetails=ExecutionSucceededEventDetails( + output=to_json_str(env.states.get_input(), separators=(",", ":")), + outputDetails=HistoryEventExecutionDataDetails( + truncated=False # Always False for api calls. + ), + ) + ), + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/program/states.py b/localstack-core/localstack/services/stepfunctions/asl/component/program/states.py new file mode 100644 index 0000000000000..61fa0a8c3f757 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/program/states.py @@ -0,0 +1,7 @@ +from localstack.services.stepfunctions.asl.component.component import Component +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField + + +class States(Component): + def __init__(self): + self.states: dict[str, CommonStateField] = dict() diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/program/version.py b/localstack-core/localstack/services/stepfunctions/asl/component/program/version.py new file mode 100644 index 0000000000000..d951a69a89acf --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/program/version.py @@ -0,0 +1,17 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class Version(Component): + _SUPPORTED_VERSIONS: Final[set[str]] = {"1.0"} + + version: Final[str] + + def __init__(self, version: str): + if version not in self._SUPPORTED_VERSIONS: + raise ValueError( + f"Version value '{version}' is not accepted. Supported Versions: {list(self._SUPPORTED_VERSIONS)}" + ) + + self.version = version diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state.py new file mode 100644 index 0000000000000..7e7004b27e31d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import abc +import datetime +import json +import logging +from abc import ABC +from typing import Final, Optional, Union + +from localstack.aws.api.stepfunctions import ( + ExecutionFailedEventDetails, + HistoryEventExecutionDataDetails, + HistoryEventType, + StateEnteredEventDetails, + StateExitedEventDetails, + TaskFailedEventDetails, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_decl import AssignDecl +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.flow.end import End +from localstack.services.stepfunctions.asl.component.common.flow.next import Next +from localstack.services.stepfunctions.asl.component.common.outputdecl import Output +from localstack.services.stepfunctions.asl.component.common.path.input_path import ( + InputPath, +) +from localstack.services.stepfunctions.asl.component.common.path.output_path import OutputPath +from localstack.services.stepfunctions.asl.component.common.query_language import ( + QueryLanguage, + QueryLanguageMode, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + JSONPATH_ROOT_PATH, + StringJsonPath, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.state.state_continue_with import ( + ContinueWith, + ContinueWithEnd, + ContinueWithNext, +) +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.component.state.state_type import StateType +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.eval.program_state import ProgramRunning +from localstack.services.stepfunctions.asl.eval.states import StateData +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.asl.utils.json_path import NoSuchJsonPathError +from localstack.services.stepfunctions.quotas import is_within_size_quota + +LOG = logging.getLogger(__name__) + + +class CommonStateField(EvalComponent, ABC): + name: str + + query_language: QueryLanguage + + # The state's type. + state_type: StateType + + # There can be any number of terminal states per state machine. Only one of Next or End can + # be used in a state. Some state types, such as Choice, don't support or use the End field. + continue_with: ContinueWith + + # Holds a human-readable description of the state. + comment: Optional[Comment] + + # A path that selects a portion of the state's input to be passed to the state's state_task for processing. + # If omitted, it has the value $ which designates the entire input. + input_path: Optional[InputPath] + + # A path that selects a portion of the state's output to be passed to the next state. + # If omitted, it has the value $ which designates the entire output. + output_path: Optional[OutputPath] + + assign_decl: Optional[AssignDecl] + + output: Optional[Output] + + state_entered_event_type: Final[HistoryEventType] + state_exited_event_type: Final[Optional[HistoryEventType]] + + def __init__( + self, + state_entered_event_type: HistoryEventType, + state_exited_event_type: Optional[HistoryEventType], + ): + self.state_entered_event_type = state_entered_event_type + self.state_exited_event_type = state_exited_event_type + + def from_state_props(self, state_props: StateProps) -> None: + self.name = state_props.name + self.query_language = state_props.get(QueryLanguage) or QueryLanguage() + self.state_type = state_props.get(StateType) + self.continue_with = ( + ContinueWithEnd() if state_props.get(End) else ContinueWithNext(state_props.get(Next)) + ) + self.comment = state_props.get(Comment) + self.assign_decl = state_props.get(AssignDecl) + # JSONPath sub-productions. + if self.query_language.query_language_mode == QueryLanguageMode.JSONPath: + self.input_path = state_props.get(InputPath) or InputPath( + StringJsonPath(JSONPATH_ROOT_PATH) + ) + self.output_path = state_props.get(OutputPath) or OutputPath( + StringJsonPath(JSONPATH_ROOT_PATH) + ) + self.output = None + # JSONata sub-productions. + else: + self.input_path = None + self.output_path = None + self.output = state_props.get(Output) + + def _set_next(self, env: Environment) -> None: + if env.next_state_name != self.name: + # Next was already overridden. + return + + if isinstance(self.continue_with, ContinueWithNext): + env.next_state_name = self.continue_with.next_state.name + elif isinstance(self.continue_with, ContinueWithEnd): # This includes ContinueWithSuccess + env.set_ended() + else: + LOG.error("Could not handle ContinueWith type of '%s'.", type(self.continue_with)) + + def _is_language_query_jsonpath(self) -> bool: + return self.query_language.query_language_mode == QueryLanguageMode.JSONPath + + def _get_state_entered_event_details(self, env: Environment) -> StateEnteredEventDetails: + return StateEnteredEventDetails( + name=self.name, + input=to_json_str(env.states.get_input(), separators=(",", ":")), + inputDetails=HistoryEventExecutionDataDetails( + truncated=False # Always False for api calls. + ), + ) + + def _get_state_exited_event_details(self, env: Environment) -> StateExitedEventDetails: + event_details = StateExitedEventDetails( + name=self.name, + output=to_json_str(env.states.get_input(), separators=(",", ":")), + outputDetails=HistoryEventExecutionDataDetails( + truncated=False # Always False for api calls. + ), + ) + # TODO add typing when these become available in boto. + assigned_variables = env.variable_store.get_assigned_variables() + env.variable_store.reset_tracing() + if assigned_variables: + event_details["assignedVariables"] = assigned_variables # noqa + event_details["assignedVariablesDetails"] = {"truncated": False} # noqa + return event_details + + def _verify_size_quota(self, env: Environment, value: Union[str, json]) -> None: + is_within: bool = is_within_size_quota(value) + if is_within: + return + error_type = StatesErrorNameType.StatesStatesDataLimitExceeded + cause = ( + f"The state/task '{self.name}' returned a result with a size exceeding " + f"the maximum number of bytes service limit." + ) + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=error_type), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=error_type.to_name(), + cause=cause, + ) + ), + ) + ) + + def _eval_state_input(self, env: Environment) -> None: + # Filter the input onto the stack. + if self.input_path: + self.input_path.eval(env) + else: + env.stack.append(env.states.get_input()) + + @abc.abstractmethod + def _eval_state(self, env: Environment) -> None: ... + + def _eval_state_output(self, env: Environment) -> None: + # Process output value as next state input. + if self.output_path: + self.output_path.eval(env=env) + elif self.output: + self.output.eval(env=env) + else: + current_output = env.stack.pop() + env.states.reset(input_value=current_output) + + def _eval_body(self, env: Environment) -> None: + env.event_manager.add_event( + context=env.event_history_context, + event_type=self.state_entered_event_type, + event_details=EventDetails( + stateEnteredEventDetails=self._get_state_entered_event_details(env=env) + ), + ) + env.states.context_object.context_object_data["State"] = StateData( + EnteredTime=datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), Name=self.name + ) + + self._eval_state_input(env=env) + + try: + self._eval_state(env) + except NoSuchJsonPathError as no_such_json_path_error: + data_json_str = to_json_str(no_such_json_path_error.data) + cause = ( + f"The JSONPath '{no_such_json_path_error.json_path}' specified for the field '{env.next_field_name}' " + f"could not be found in the input '{data_json_str}'" + ) + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=cause + ) + ), + ) + ) + + if not isinstance(env.program_state(), ProgramRunning): + return + + self._eval_state_output(env=env) + + self._verify_size_quota(env=env, value=env.states.get_input()) + + self._set_next(env) + + if self.state_exited_event_type is not None: + env.event_manager.add_event( + context=env.event_history_context, + event_type=self.state_exited_event_type, + event_details=EventDetails( + stateExitedEventDetails=self._get_state_exited_event_details(env=env), + ), + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/choice_rule.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/choice_rule.py new file mode 100644 index 0000000000000..a946eec561292 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/choice_rule.py @@ -0,0 +1,43 @@ +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.assign.assign_decl import AssignDecl +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.flow.next import Next +from localstack.services.stepfunctions.asl.component.common.outputdecl import Output +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_type import ( + Comparison, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ChoiceRule(EvalComponent): + comparison: Final[Optional[Comparison]] + next_stmt: Final[Optional[Next]] + comment: Final[Optional[Comment]] + assign: Final[Optional[AssignDecl]] + output: Final[Optional[Output]] + + def __init__( + self, + comparison: Optional[Comparison], + next_stmt: Optional[Next], + comment: Optional[Comment], + assign: Optional[AssignDecl], + output: Optional[Output], + ): + self.comparison = comparison + self.next_stmt = next_stmt + self.comment = comment + self.assign = assign + self.output = output + + def _eval_body(self, env: Environment) -> None: + self.comparison.eval(env) + is_condition_true: bool = env.stack[-1] + if not is_condition_true: + return + if self.assign: + self.assign.eval(env=env) + if self.output: + self.output.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/choices_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/choices_decl.py new file mode 100644 index 0000000000000..e557b231f54aa --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/choices_decl.py @@ -0,0 +1,11 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component +from localstack.services.stepfunctions.asl.component.state.state_choice.choice_rule import ( + ChoiceRule, +) + + +class ChoicesDecl(Component): + def __init__(self, rules: list[ChoiceRule]): + self.rules: Final[list[ChoiceRule]] = rules diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison.py new file mode 100644 index 0000000000000..d70065dc56a92 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import abc +from enum import Enum +from typing import Any, Final + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.choice_rule import ( + ChoiceRule, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_type import ( + Comparison, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + + +class ComparisonCompositeProps(TypedProps): + def add(self, instance: Any) -> None: + inst_type = type(instance) + + if issubclass(inst_type, ComparisonComposite): + super()._add(ComparisonComposite, instance) + return + + super().add(instance) + + +class ConditionJSONataLit(Comparison): + literal: Final[bool] + + def __init__(self, literal: bool): + self.literal = literal + + def _eval_body(self, env: Environment) -> None: + env.stack.append(self.literal) + + +class ConditionStringJSONata(Comparison): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _eval_body(self, env: Environment) -> None: + self.string_jsonata.eval(env=env) + result = env.stack[-1] + if not isinstance(result, bool): + # TODO: add snapshot tests to verify AWS's behaviour about non boolean values. + raise RuntimeError( + f"Expected Condition to produce a boolean result but got result of type '{type(result)}' instead." + ) + + +class ComparisonComposite(Comparison, abc.ABC): + class ChoiceOp(Enum): + And = ASLLexer.AND + Or = ASLLexer.OR + Not = ASLLexer.NOT + + operator: Final[ComparisonComposite.ChoiceOp] + + def __init__(self, operator: ComparisonComposite.ChoiceOp): + self.operator = operator + + +class ComparisonCompositeSingle(ComparisonComposite, abc.ABC): + rule: Final[ChoiceRule] + + def __init__(self, operator: ComparisonComposite.ChoiceOp, rule: ChoiceRule): + super(ComparisonCompositeSingle, self).__init__(operator=operator) + self.rule = rule + + +class ComparisonCompositeMulti(ComparisonComposite, abc.ABC): + rules: Final[list[ChoiceRule]] + + def __init__(self, operator: ComparisonComposite.ChoiceOp, rules: list[ChoiceRule]): + super(ComparisonCompositeMulti, self).__init__(operator=operator) + self.rules = rules + + +class ComparisonCompositeNot(ComparisonCompositeSingle): + def __init__(self, rule: ChoiceRule): + super(ComparisonCompositeNot, self).__init__( + operator=ComparisonComposite.ChoiceOp.Not, rule=rule + ) + + def _eval_body(self, env: Environment) -> None: + self.rule.eval(env) + tmp: bool = env.stack.pop() + res = tmp is False + env.stack.append(res) + + +class ComparisonCompositeAnd(ComparisonCompositeMulti): + def __init__(self, rules: list[ChoiceRule]): + super(ComparisonCompositeAnd, self).__init__( + operator=ComparisonComposite.ChoiceOp.And, rules=rules + ) + + def _eval_body(self, env: Environment) -> None: + res = True + for rule in self.rules: + rule.eval(env) + rule_out = env.stack.pop() + if not rule_out: + res = False + break # TODO: Lazy evaluation? Can use all function instead? how's eval for that? + env.stack.append(res) + + +class ComparisonCompositeOr(ComparisonCompositeMulti): + def __init__(self, rules: list[ChoiceRule]): + super(ComparisonCompositeOr, self).__init__( + operator=ComparisonComposite.ChoiceOp.Or, rules=rules + ) + + def _eval_body(self, env: Environment) -> None: + res = False + for rule in self.rules: + rule.eval(env) + rule_out = env.stack.pop() + res = res or rule_out + if res: + break # TODO: Lazy evaluation? Can use all function instead? how's eval for that? + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_func.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_func.py new file mode 100644 index 0000000000000..cf5d6c9bfb2b1 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_func.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import abc +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringVariableSample, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_type import ( + Comparison, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.factory import ( + OperatorFactory, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.operator import ( + Operator, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ComparisonFunc(Comparison, abc.ABC): + operator_type: Final[ComparisonOperatorType] + + def __init__(self, operator_type: ComparisonOperatorType): + self.operator_type = operator_type + + +class ComparisonFuncValue(ComparisonFunc): + value: Final[Any] + + def __init__(self, operator_type: ComparisonOperatorType, value: Any): + super().__init__(operator_type=operator_type) + self.value = value + + def _eval_body(self, env: Environment) -> None: + operator: Operator = OperatorFactory.get(self.operator_type) + operator.eval(env=env, value=self.value) + + +class ComparisonFuncStringVariableSample(ComparisonFuncValue): + _COMPARISON_FUNC_VAR_VALUE: Final[str] = "$" + string_variable_sample: Final[StringVariableSample] + + def __init__( + self, operator_type: ComparisonOperatorType, string_variable_sample: StringVariableSample + ): + super().__init__(operator_type=operator_type, value=self._COMPARISON_FUNC_VAR_VALUE) + self.string_variable_sample = string_variable_sample + + def _eval_body(self, env: Environment) -> None: + self.string_variable_sample.eval(env=env) + super()._eval_body(env=env) + # Purge the outcome of the variable sampling form the + # stack as operators do not digest the input value. + del env.stack[-2] diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_operator_type.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_operator_type.py new file mode 100644 index 0000000000000..3c9722f721ce9 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_operator_type.py @@ -0,0 +1,45 @@ +from enum import Enum + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer + + +class ComparisonOperatorType(Enum): + BooleanEquals = ASLLexer.BOOLEANEQUALS + BooleanEqualsPath = ASLLexer.BOOLEANQUALSPATH + IsBoolean = ASLLexer.ISBOOLEAN + IsNull = ASLLexer.ISNULL + IsNumeric = ASLLexer.ISNUMERIC + IsPresent = ASLLexer.ISPRESENT + IsString = ASLLexer.ISSTRING + IsTimestamp = ASLLexer.ISTIMESTAMP + NumericEquals = ASLLexer.NUMERICEQUALS + NumericEqualsPath = ASLLexer.NUMERICEQUALSPATH + NumericGreaterThan = ASLLexer.NUMERICGREATERTHAN + NumericGreaterThanPath = ASLLexer.NUMERICGREATERTHANPATH + NumericGreaterThanEquals = ASLLexer.NUMERICGREATERTHANEQUALS + NumericGreaterThanEqualsPath = ASLLexer.NUMERICGREATERTHANEQUALSPATH + NumericLessThan = ASLLexer.NUMERICLESSTHAN + NumericLessThanPath = ASLLexer.NUMERICLESSTHANPATH + NumericLessThanEquals = ASLLexer.NUMERICLESSTHANEQUALS + NumericLessThanEqualsPath = ASLLexer.NUMERICLESSTHANEQUALSPATH + StringEquals = ASLLexer.STRINGEQUALS + StringEqualsPath = ASLLexer.STRINGEQUALSPATH + StringGreaterThan = ASLLexer.STRINGGREATERTHAN + StringGreaterThanPath = ASLLexer.STRINGGREATERTHANPATH + StringGreaterThanEquals = ASLLexer.STRINGGREATERTHANEQUALS + StringGreaterThanEqualsPath = ASLLexer.STRINGGREATERTHANEQUALSPATH + StringLessThan = ASLLexer.STRINGLESSTHAN + StringLessThanPath = ASLLexer.STRINGLESSTHANPATH + StringLessThanEquals = ASLLexer.STRINGLESSTHANEQUALS + StringLessThanEqualsPath = ASLLexer.STRINGLESSTHANEQUALSPATH + StringMatches = ASLLexer.STRINGMATCHES + TimestampEquals = ASLLexer.TIMESTAMPEQUALS + TimestampEqualsPath = ASLLexer.TIMESTAMPEQUALSPATH + TimestampGreaterThan = ASLLexer.TIMESTAMPGREATERTHAN + TimestampGreaterThanPath = ASLLexer.TIMESTAMPGREATERTHANPATH + TimestampGreaterThanEquals = ASLLexer.TIMESTAMPGREATERTHANEQUALS + TimestampGreaterThanEqualsPath = ASLLexer.TIMESTAMPGREATERTHANEQUALSPATH + TimestampLessThan = ASLLexer.TIMESTAMPLESSTHAN + TimestampLessThanPath = ASLLexer.TIMESTAMPLESSTHANPATH + TimestampLessThanEquals = ASLLexer.TIMESTAMPLESSTHANEQUALS + TimestampLessThanEqualsPath = ASLLexer.TIMESTAMPLESSTHANEQUALSPATH diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_type.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_type.py new file mode 100644 index 0000000000000..e1989a3cc5593 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_type.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from abc import ABC + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent + + +class Comparison(EvalComponent, ABC): ... diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_variable.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_variable.py new file mode 100644 index 0000000000000..724fc5de32850 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/comparison_variable.py @@ -0,0 +1,27 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_func import ( + ComparisonFunc, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_type import ( + Comparison, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.variable import ( + Variable, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ComparisonVariable(Comparison): + variable: Final[Variable] + comparison_function: Final[ComparisonFunc] + + def __init__(self, variable: Variable, func: ComparisonFunc): + self.variable = variable + self.comparison_function = func + + def _eval_body(self, env: Environment) -> None: + variable: Variable = self.variable + variable.eval(env) + comparison_function: ComparisonFunc = self.comparison_function + comparison_function.eval(env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/factory.py new file mode 100644 index 0000000000000..6bad8fb919c45 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/factory.py @@ -0,0 +1,20 @@ +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.implementations.boolean_equals import * # noqa +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.implementations.is_operator import * # noqa +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.implementations.numeric import * # noqa +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.implementations.string_operators import * # noqa +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.implementations.timestamp_operators import * # noqa +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.operator import ( + Operator, +) + + +class OperatorFactory: + @staticmethod + def get(typ: ComparisonOperatorType) -> Operator: + op = Operator.get((str(typ)), raise_if_missing=False) + if op is None: + raise NotImplementedError(f"{typ} is not supported.") + return op diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/boolean_equals.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/boolean_equals.py new file mode 100644 index 0000000000000..8461ada2c1627 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/boolean_equals.py @@ -0,0 +1,44 @@ +from typing import Any + +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.operator import ( + Operator, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.json_path import extract_json + + +class BooleanEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.BooleanEquals) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = False + if isinstance(variable, bool): + res = variable is value + env.stack.append(res) + + +class BooleanEqualsPath(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.BooleanEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + + inp = env.stack[-1] + comp_value: bool = extract_json(value, inp) + if not isinstance(comp_value, bool): + raise TypeError(f"Expected type bool, but got '{comp_value}' from path '{value}'.") + + res = False + if isinstance(variable, bool): + res = bool(variable) is comp_value + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/is_operator.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/is_operator.py new file mode 100644 index 0000000000000..e998ae1a50a0c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/is_operator.py @@ -0,0 +1,110 @@ +import datetime +import logging +from typing import Any, Final, Optional + +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.operator import ( + Operator, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.variable import ( + NoSuchVariable, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + +LOG = logging.getLogger(__name__) + + +class IsBoolean(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.IsBoolean) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = isinstance(variable, bool) is value + env.stack.append(res) + + +class IsNull(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.IsNull) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + is_null = variable is None and not isinstance(variable, NoSuchVariable) + res = is_null is value + env.stack.append(res) + + +class IsNumeric(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.IsNumeric) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = (isinstance(variable, (int, float)) and not isinstance(variable, bool)) is value + env.stack.append(res) + + +class IsPresent(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.IsPresent) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = isinstance(variable, NoSuchVariable) is not value + env.stack.append(res) + + +class IsString(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.IsString) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = isinstance(variable, str) is value + env.stack.append(res) + + +class IsTimestamp(Operator): + # Timestamps are strings which MUST conform to the RFC3339 profile of ISO 8601, with the further restrictions that + # an uppercase "T" character MUST be used to separate date and time, and an uppercase "Z" character MUST be + # present in the absence of a numeric time zone offset, for example "2016-03-14T01:59:00Z". + TIMESTAMP_FORMAT: Final[str] = "%Y-%m-%dT%H:%M:%SZ" + + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.IsTimestamp) + + @staticmethod + def string_to_timestamp(string: str) -> Optional[datetime.datetime]: + try: + return datetime.datetime.strptime(string, IsTimestamp.TIMESTAMP_FORMAT) + except Exception: + return None + + @staticmethod + def is_timestamp(inp: Any) -> bool: + return isinstance(inp, str) and IsTimestamp.string_to_timestamp(inp) is not None + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + LOG.warning( + "State Choice's 'IsTimestamp' operator is not fully supported for input '%s' and target '%s'.", + variable, + value, + ) + res = IsTimestamp.is_timestamp(variable) is value + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/numeric.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/numeric.py new file mode 100644 index 0000000000000..aee40d4d623a0 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/numeric.py @@ -0,0 +1,179 @@ +from typing import Any + +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.operator import ( + Operator, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.json_path import extract_json + + +def _is_numeric(variable: Any) -> bool: + return isinstance(variable, (int, float)) and not isinstance(variable, bool) + + +class NumericEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if _is_numeric(variable): + res = variable == comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = NumericEquals._compare(variable, value) + env.stack.append(res) + + +class NumericEqualsPath(NumericEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = NumericEquals._compare(variable, comp_value) + env.stack.append(res) + + +class NumericGreaterThan(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericGreaterThan) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if _is_numeric(variable): + res = variable > comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = NumericGreaterThan._compare(variable, value) + env.stack.append(res) + + +class NumericGreaterThanPath(NumericGreaterThan): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericGreaterThanPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = NumericGreaterThanPath._compare(variable, comp_value) + env.stack.append(res) + + +class NumericGreaterThanEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericGreaterThanEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if _is_numeric(variable): + res = variable >= comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = NumericGreaterThanEquals._compare(variable, value) + env.stack.append(res) + + +class NumericGreaterThanEqualsPath(NumericGreaterThanEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericGreaterThanEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = NumericGreaterThanEqualsPath._compare(variable, comp_value) + env.stack.append(res) + + +class NumericLessThan(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericLessThan) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if _is_numeric(variable): + res = variable < comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = NumericLessThan._compare(variable, value) + env.stack.append(res) + + +class NumericLessThanPath(NumericLessThan): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericLessThanPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = NumericLessThanPath._compare(variable, comp_value) + env.stack.append(res) + + +class NumericLessThanEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericLessThanEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if _is_numeric(variable): + res = variable <= comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = NumericLessThanEquals._compare(variable, value) + env.stack.append(res) + + +class NumericLessThanEqualsPath(NumericLessThanEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.NumericLessThanEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = NumericLessThanEqualsPath._compare(variable, comp_value) + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/string_operators.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/string_operators.py new file mode 100644 index 0000000000000..e2fdfa714324f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/string_operators.py @@ -0,0 +1,195 @@ +import fnmatch +from typing import Any + +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.operator import ( + Operator, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.json_path import extract_json + + +class StringEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + res = variable == comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = StringEquals._compare(variable, value) + env.stack.append(res) + + +class StringEqualsPath(StringEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = StringEqualsPath._compare(variable, comp_value) + env.stack.append(res) + + +class StringGreaterThan(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringGreaterThan) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + res = variable > comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = StringGreaterThan._compare(variable, value) + env.stack.append(res) + + +class StringGreaterThanPath(StringGreaterThan): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringGreaterThanPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = StringGreaterThanPath._compare(variable, comp_value) + env.stack.append(res) + + +class StringGreaterThanEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringGreaterThanEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + res = variable >= comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = StringGreaterThanEquals._compare(variable, value) + env.stack.append(res) + + +class StringGreaterThanEqualsPath(StringGreaterThanEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringGreaterThanEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = StringGreaterThanEqualsPath._compare(variable, comp_value) + env.stack.append(res) + + +class StringLessThan(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringLessThan) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + res = variable < comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = StringLessThan._compare(variable, value) + env.stack.append(res) + + +class StringLessThanPath(StringLessThan): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringLessThanPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = StringLessThanPath._compare(variable, comp_value) + env.stack.append(res) + + +class StringLessThanEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringLessThanEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + res = variable <= comparison_value + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = StringLessThanEquals._compare(variable, value) + env.stack.append(res) + + +class StringLessThanEqualsPath(StringLessThanEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringLessThanEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = StringLessThanEqualsPath._compare(variable, comp_value) + env.stack.append(res) + + +class StringMatches(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.StringMatches) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + res = fnmatch.fnmatch(variable, comparison_value) + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = StringMatches._compare(variable, value) + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/timestamp_operators.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/timestamp_operators.py new file mode 100644 index 0000000000000..d1b9b57fc2c2e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/implementations/timestamp_operators.py @@ -0,0 +1,193 @@ +from typing import Any + +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.implementations.is_operator import ( + IsTimestamp, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.operator.operator import ( + Operator, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.json_path import extract_json + + +class TimestampEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + a = IsTimestamp.string_to_timestamp(variable) + if a is not None: + b = IsTimestamp.string_to_timestamp(comparison_value) + res = a == b + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = TimestampEquals._compare(variable, value) + env.stack.append(res) + + +class TimestampEqualsPath(TimestampEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = TimestampEqualsPath._compare(variable, comp_value) + env.stack.append(res) + + +class TimestampGreaterThan(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampGreaterThan) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + a = IsTimestamp.string_to_timestamp(variable) + if a is not None: + b = IsTimestamp.string_to_timestamp(comparison_value) + res = a > b + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = TimestampGreaterThan._compare(variable, value) + env.stack.append(res) + + +class TimestampGreaterThanPath(TimestampGreaterThan): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampGreaterThanPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = TimestampGreaterThanPath._compare(variable, comp_value) + env.stack.append(res) + + +class TimestampGreaterThanEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampGreaterThanEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + a = IsTimestamp.string_to_timestamp(variable) + if a is not None: + b = IsTimestamp.string_to_timestamp(comparison_value) + res = a >= b + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = TimestampGreaterThanEquals._compare(variable, value) + env.stack.append(res) + + +class TimestampGreaterThanEqualsPath(TimestampGreaterThanEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampGreaterThanEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = TimestampGreaterThanEqualsPath._compare(variable, comp_value) + env.stack.append(res) + + +class TimestampLessThan(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampLessThan) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + a = IsTimestamp.string_to_timestamp(variable) + if a is not None: + b = IsTimestamp.string_to_timestamp(comparison_value) + res = a < b + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = TimestampLessThan._compare(variable, value) + env.stack.append(res) + + +class TimestampLessThanPath(TimestampLessThan): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampLessThanPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = TimestampLessThanPath._compare(variable, comp_value) + env.stack.append(res) + + +class TimestampLessThanEquals(Operator): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampLessThanEquals) + + @staticmethod + def _compare(variable: Any, comparison_value: Any) -> bool: + res = False + if isinstance(variable, str): + a = IsTimestamp.string_to_timestamp(variable) + if a is not None: + b = IsTimestamp.string_to_timestamp(comparison_value) + res = a <= b + return res + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + res = TimestampLessThanEquals._compare(variable, value) + env.stack.append(res) + + +class TimestampLessThanEqualsPath(TimestampLessThanEquals): + @staticmethod + def impl_name() -> str: + return str(ComparisonOperatorType.TimestampLessThanEqualsPath) + + @staticmethod + def eval(env: Environment, value: Any) -> None: + variable = env.stack.pop() + inp = env.stack[-1] + comp_value = extract_json(value, inp) + res = TimestampLessThanEqualsPath._compare(variable, comp_value) + env.stack.append(res) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/operator.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/operator.py new file mode 100644 index 0000000000000..56c4867fdc6dd --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/operator/operator.py @@ -0,0 +1,12 @@ +import abc +from typing import Any + +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.utils.objects import SubtypesInstanceManager + + +class Operator(abc.ABC, SubtypesInstanceManager): + @staticmethod + @abc.abstractmethod + def eval(env: Environment, value: Any) -> None: + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/variable.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/variable.py new file mode 100644 index 0000000000000..ca49a2bf3bae4 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/comparison/variable.py @@ -0,0 +1,27 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class NoSuchVariable: + def __init__(self, path: str): + self.path: Final[str] = path + + +class Variable(EvalComponent): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def _eval_body(self, env: Environment) -> None: + try: + self.string_sampler.eval(env=env) + value = env.stack.pop() + except Exception as ex: + value = NoSuchVariable(f"{self.string_sampler.literal_value}, {ex}") + env.stack.append(value) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/default_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/default_decl.py new file mode 100644 index 0000000000000..e61d9d2069421 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/default_decl.py @@ -0,0 +1,8 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class DefaultDecl(Component): + def __init__(self, state_name: str): + self.state_name: Final[str] = state_name diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/state_choice.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/state_choice.py new file mode 100644 index 0000000000000..99d21029a3fc3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_choice/state_choice.py @@ -0,0 +1,78 @@ +from typing import Optional + +from localstack.aws.api.stepfunctions import HistoryEventType +from localstack.services.stepfunctions.asl.component.common.flow.end import End +from localstack.services.stepfunctions.asl.component.common.flow.next import Next +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_choice.choices_decl import ( + ChoicesDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.default_decl import ( + DefaultDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StateChoice(CommonStateField): + choices_decl: ChoicesDecl + default_state: Optional[DefaultDecl] + + def __init__(self): + super(StateChoice, self).__init__( + state_entered_event_type=HistoryEventType.ChoiceStateEntered, + state_exited_event_type=HistoryEventType.ChoiceStateExited, + ) + self.default_state = None + self._next_state_name = None + + def from_state_props(self, state_props: StateProps) -> None: + super(StateChoice, self).from_state_props(state_props) + self.choices_decl = state_props.get(ChoicesDecl) + self.default_state = state_props.get(DefaultDecl) + + if state_props.get(Next) or state_props.get(End): + raise ValueError( + "Choice states don't support the End field. " + "In addition, they use Next only inside their Choices field. " + f"With state '{self}'." + ) + + def _set_next(self, env: Environment) -> None: + pass + + def _eval_state(self, env: Environment) -> None: + for rule in self.choices_decl.rules: + rule.eval(env) + res = env.stack.pop() + if res is True: + if not rule.next_stmt: + raise RuntimeError( + f"Missing Next definition for state_choice rule '{rule}' in choices '{self}'." + ) + env.stack.append(rule.next_stmt.name) + return + + if self.default_state is None: + raise RuntimeError("No branching option reached in state %s", self.name) + env.stack.append(self.default_state.state_name) + + def _eval_state_output(self, env: Environment) -> None: + next_state_name: str = env.stack.pop() + + # No choice rule matched: the default state is evaluated. + if self.default_state and self.default_state.state_name == next_state_name: + if self.assign_decl: + self.assign_decl.eval(env=env) + if self.output: + self.output.eval(env=env) + + # Handle legacy output sequences if in JsonPath mode. + if self._is_language_query_jsonpath(): + if self.output_path: + self.output_path.eval(env=env) + else: + current_output = env.stack.pop() + env.states.reset(input_value=current_output) + + env.next_state_name = next_state_name diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_continue_with.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_continue_with.py new file mode 100644 index 0000000000000..4253f10074126 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_continue_with.py @@ -0,0 +1,19 @@ +import abc + +from localstack.services.stepfunctions.asl.component.common.flow.next import Next + + +class ContinueWith(abc.ABC): ... + + +class ContinueWithEnd(ContinueWith): + pass + + +class ContinueWithNext(ContinueWith): + def __init__(self, next_state: Next): + self.next_state: Next = next_state + + +class ContinueWithSuccess(ContinueWithEnd): + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/execute_state.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/execute_state.py new file mode 100644 index 0000000000000..c32150cb3eb12 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/execute_state.py @@ -0,0 +1,277 @@ +import abc +import copy +import logging +import threading +from threading import Thread +from typing import Any, Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.catch.catch_decl import CatchDecl +from localstack.services.stepfunctions.asl.component.common.catch.catch_outcome import CatchOutcome +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.path.result_path import ResultPath +from localstack.services.stepfunctions.asl.component.common.result_selector import ResultSelector +from localstack.services.stepfunctions.asl.component.common.retry.retry_decl import RetryDecl +from localstack.services.stepfunctions.asl.component.common.retry.retry_outcome import RetryOutcome +from localstack.services.stepfunctions.asl.component.common.timeouts.heartbeat import ( + Heartbeat, + HeartbeatSeconds, +) +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import ( + EvalTimeoutError, + Timeout, + TimeoutSeconds, +) +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.utils.common import TMP_THREADS + +LOG = logging.getLogger(__name__) + + +class ExecutionState(CommonStateField, abc.ABC): + def __init__( + self, + state_entered_event_type: HistoryEventType, + state_exited_event_type: Optional[HistoryEventType], + ): + super().__init__( + state_entered_event_type=state_entered_event_type, + state_exited_event_type=state_exited_event_type, + ) + # ResultPath (Optional) + # Specifies where (in the input) to place the results of executing the state_task that's specified in Resource. + # The input is then filtered as specified by the OutputPath field (if present) before being used as the + # state's output. + self.result_path: Optional[ResultPath] = None + + # ResultSelector (Optional) + # Pass a collection of key value pairs, where the values are static or selected from the result. + self.result_selector: Optional[ResultSelector] = None + + # Retry (Optional) + # An array of objects, called Retriers, that define a retry policy if the state encounters runtime errors. + self.retry: Optional[RetryDecl] = None + + # Catch (Optional) + # An array of objects, called Catchers, that define a fallback state. This state is executed if the state + # encounters runtime errors and its retry policy is exhausted or isn't defined. + self.catch: Optional[CatchDecl] = None + + # TimeoutSeconds (Optional) + # If the state_task runs longer than the specified seconds, this state fails with a States.Timeout error name. + # Must be a positive, non-zero integer. If not provided, the default value is 99999999. The count begins after + # the state_task has been started, for example, when ActivityStarted or LambdaFunctionStarted are logged in the + # Execution event history. + # TimeoutSecondsPath (Optional) + # If you want to provide a timeout value dynamically from the state input using a reference path, use + # TimeoutSecondsPath. When resolved, the reference path must select fields whose values are positive integers. + # A Task state cannot include both TimeoutSeconds and TimeoutSecondsPath + # TimeoutSeconds and TimeoutSecondsPath fields are encoded by the timeout type. + self.timeout: Timeout = TimeoutSeconds( + timeout_seconds=TimeoutSeconds.DEFAULT_TIMEOUT_SECONDS + ) + + # HeartbeatSeconds (Optional) + # If more time than the specified seconds elapses between heartbeats from the task, this state fails with a + # States.Timeout error name. Must be a positive, non-zero integer less than the number of seconds specified in + # the TimeoutSeconds field. If not provided, the default value is 99999999. For Activities, the count begins + # when GetActivityTask receives a token and ActivityStarted is logged in the Execution event history. + # HeartbeatSecondsPath (Optional) + # If you want to provide a heartbeat value dynamically from the state input using a reference path, use + # HeartbeatSecondsPath. When resolved, the reference path must select fields whose values are positive integers. + # A Task state cannot include both HeartbeatSeconds and HeartbeatSecondsPath + # HeartbeatSeconds and HeartbeatSecondsPath fields are encoded by the Heartbeat type. + self.heartbeat: Optional[Heartbeat] = None + + def from_state_props(self, state_props: StateProps) -> None: + super().from_state_props(state_props=state_props) + self.result_path = state_props.get(ResultPath) or ResultPath( + result_path_src=ResultPath.DEFAULT_PATH + ) + self.result_selector = state_props.get(ResultSelector) + self.retry = state_props.get(RetryDecl) + self.catch = state_props.get(CatchDecl) + + # If provided, the "HeartbeatSeconds" interval MUST be smaller than the "TimeoutSeconds" value. + # If not provided, the default value of "TimeoutSeconds" is 60. + timeout = state_props.get(Timeout) + heartbeat = state_props.get(Heartbeat) + if isinstance(timeout, TimeoutSeconds) and isinstance(heartbeat, HeartbeatSeconds): + if timeout.timeout_seconds <= heartbeat.heartbeat_seconds: + raise RuntimeError( + f"'HeartbeatSeconds' interval MUST be smaller than the 'TimeoutSeconds' value, " + f"got '{timeout.timeout_seconds}' and '{heartbeat.heartbeat_seconds}' respectively." + ) + if heartbeat is not None and timeout is None: + timeout = TimeoutSeconds(timeout_seconds=60, is_default=True) + + if timeout is not None: + self.timeout = timeout + if heartbeat is not None: + self.heartbeat = heartbeat + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, FailureEventException): + return ex.failure_event + LOG.warning( + "State Task encountered an unhandled exception that lead to a State.Runtime error." + ) + return FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), + cause=str(ex), + ) + ), + ) + + @abc.abstractmethod + def _eval_execution(self, env: Environment) -> None: ... + + def _handle_retry(self, env: Environment, failure_event: FailureEvent) -> RetryOutcome: + env.stack.append(failure_event.error_name) + self.retry.eval(env) + res: RetryOutcome = env.stack.pop() + if res == RetryOutcome.CanRetry: + retry_count = env.states.context_object.context_object_data["State"]["RetryCount"] + env.states.context_object.context_object_data["State"]["RetryCount"] = retry_count + 1 + return res + + def _handle_catch(self, env: Environment, failure_event: FailureEvent) -> None: + env.stack.append(failure_event) + self.catch.eval(env) + + def _handle_uncaught(self, env: Environment, failure_event: FailureEvent) -> None: + self._terminate_with_event(env=env, failure_event=failure_event) + + @staticmethod + def _terminate_with_event(env: Environment, failure_event: FailureEvent) -> None: + raise FailureEventException(failure_event=failure_event) + + def _evaluate_with_timeout(self, env: Environment) -> None: + self.timeout.eval(env=env) + timeout_seconds: int = env.stack.pop() + + frame: Environment = env.open_frame() + frame.states.reset(input_value=env.states.get_input()) + frame.stack = copy.deepcopy(env.stack) + execution_outputs: list[Any] = list() + execution_exceptions: list[Optional[Exception]] = [None] + terminated_event = threading.Event() + + def _exec_and_notify(): + try: + self._eval_execution(frame) + execution_outputs.extend(frame.stack) + except Exception as ex: + execution_exceptions.append(ex) + terminated_event.set() + + thread = Thread(target=_exec_and_notify, daemon=True) + TMP_THREADS.append(thread) + thread.start() + + finished_on_time: bool = terminated_event.wait(timeout_seconds) + frame.set_ended() + env.close_frame(frame) + + execution_exception = execution_exceptions.pop() + if execution_exception: + raise execution_exception + + if not finished_on_time: + raise EvalTimeoutError() + + execution_output = execution_outputs.pop() + env.stack.append(execution_output) + + if not self._is_language_query_jsonpath(): + env.states.set_result(execution_output) + + if self.assign_decl: + self.assign_decl.eval(env=env) + + if self.result_selector: + self.result_selector.eval(env=env) + + if self.result_path: + self.result_path.eval(env) + else: + res = env.stack.pop() + env.states.reset(input_value=res) + + @staticmethod + def _construct_error_output_value(failure_event: FailureEvent) -> Any: + specs_event_details = list(failure_event.event_details.values()) + if ( + len(specs_event_details) != 1 + and "error" in specs_event_details + and "cause" in specs_event_details + ): + raise RuntimeError( + f"Internal Error: invalid event details declaration in FailureEvent: '{failure_event}'." + ) + spec_event_details: dict = list(failure_event.event_details.values())[0] + return { + # If no cause or error fields are given, AWS binds an empty string; otherwise it attaches the value. + "Error": spec_event_details.get("error", ""), + "Cause": spec_event_details.get("cause", ""), + } + + def _eval_state(self, env: Environment) -> None: + # Initialise the retry counter for execution states. + env.states.context_object.context_object_data["State"]["RetryCount"] = 0 + + # Attempt to evaluate the state's logic through until it's successful, caught, or retries have run out. + while env.is_running(): + try: + self._evaluate_with_timeout(env) + break + except Exception as ex: + failure_event: FailureEvent = self._from_error(env=env, ex=ex) + env.event_manager.add_event( + context=env.event_history_context, + event_type=failure_event.event_type, + event_details=failure_event.event_details, + ) + error_output = self._construct_error_output_value(failure_event=failure_event) + env.states.set_error_output(error_output) + env.states.set_result(error_output) + + if self.retry: + retry_outcome: RetryOutcome = self._handle_retry( + env=env, failure_event=failure_event + ) + if retry_outcome == RetryOutcome.CanRetry: + continue + + if self.catch: + self._handle_catch(env=env, failure_event=failure_event) + catch_outcome: CatchOutcome = env.stack[-1] + if catch_outcome == CatchOutcome.Caught: + break + + self._handle_uncaught(env=env, failure_event=failure_event) + + def _eval_state_output(self, env: Environment) -> None: + # Obtain a reference to the state output. + output = env.stack[-1] + # CatcherOutputs (i.e. outputs of Catch blocks) are never subjects of output normalisers, + # the entire value is instead passed by value as input to the next state, or program output. + if not isinstance(output, CatchOutcome): + super()._eval_state_output(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/execution_type.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/execution_type.py new file mode 100644 index 0000000000000..bc4a90718dc65 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/execution_type.py @@ -0,0 +1,7 @@ +from enum import Enum + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer + + +class ExecutionType(Enum): + Standard = ASLLexer.STANDARD diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/item_reader_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/item_reader_decl.py new file mode 100644 index 0000000000000..ed8e325034c56 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/item_reader_decl.py @@ -0,0 +1,74 @@ +import copy +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.parargs import Parargs +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.reader_config_decl import ( + ReaderConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_eval import ( + ResourceEval, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_eval_factory import ( + resource_eval_for, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_output_transformer.resource_output_transformer import ( + ResourceOutputTransformer, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_output_transformer.resource_output_transformer_factory import ( + resource_output_transformer_for, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + Resource, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ItemReader(EvalComponent): + resource_eval: Final[ResourceEval] + parargs: Final[Optional[Parargs]] + reader_config: Final[Optional[ReaderConfig]] + resource_output_transformer: Optional[ResourceOutputTransformer] + + def __init__( + self, + resource: Resource, + parargs: Optional[Parargs], + reader_config: Optional[ReaderConfig], + ): + self.resource_eval = resource_eval_for(resource=resource) + self.parargs = parargs + self.reader_config = reader_config + + self.resource_output_transformer = None + if self.reader_config: + self.resource_output_transformer = resource_output_transformer_for( + input_type=self.reader_config.input_type + ) + + @property + def resource(self): + return self.resource_eval.resource + + def __str__(self): + class_dict = copy.deepcopy(self.__dict__) + del class_dict["resource_eval"] + class_dict["resource"] = self.resource + return f"({self.__class__.__name__}| {class_dict})" + + def _eval_body(self, env: Environment) -> None: + resource_config = None + if self.reader_config: + self.reader_config.eval(env=env) + resource_config = env.stack.pop() + + if self.parargs: + self.parargs.eval(env=env) + else: + env.stack.append(dict()) + + self.resource_eval.eval_resource(env=env) + + if self.reader_config: + env.stack.append(resource_config) + self.resource_output_transformer.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/csv_header_location.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/csv_header_location.py new file mode 100644 index 0000000000000..38f32316f35bf --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/csv_header_location.py @@ -0,0 +1,18 @@ +import enum +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class CSVHeaderLocationValue(enum.Enum): + FIRST_ROW = "FIRST_ROW" + GIVEN = "GIVEN" + + +class CSVHeaderLocation(Component): + csv_header_location_value: Final[CSVHeaderLocationValue] + + def __init__(self, csv_header_location_value: str): + self.csv_header_location_value = CSVHeaderLocationValue( + csv_header_location_value + ) # Pass error upstream. diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/csv_headers.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/csv_headers.py new file mode 100644 index 0000000000000..1f6c61fadd150 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/csv_headers.py @@ -0,0 +1,10 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class CSVHeaders(Component): + header_names: Final[list[str]] + + def __init__(self, header_names: list[str]): + self.header_names = header_names diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/input_type.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/input_type.py new file mode 100644 index 0000000000000..bfe4806ddcead --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/input_type.py @@ -0,0 +1,27 @@ +import enum +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class InputTypeValue(enum.Enum): + """ + Represents the supported InputType values for ItemReader configurations. + """ + + # TODO: add support for MANIFEST InputTypeValue. + CSV = "CSV" + JSON = "JSON" + + +class InputType(Component): + """ + "InputType" Specifies the type of Amazon S3 data source, such as CSV file, object, JSON file, or an + Amazon S3 inventory list. In Workflow Studio, you can select an input type from the Amazon S3 item + source dropdown list under the Item source field. + """ + + input_type_value: Final[InputTypeValue] + + def __init__(self, input_type: str): + self.input_type_value = InputTypeValue(input_type) # Pass error upstream. diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/max_items_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/max_items_decl.py new file mode 100644 index 0000000000000..6c2e109d75f76 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/max_items_decl.py @@ -0,0 +1,130 @@ +import abc +from typing import Final + +from localstack.aws.api.stepfunctions import ExecutionFailedEventDetails, HistoryEventType +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + + +class MaxItemsDecl(EvalComponent, abc.ABC): + """ + "MaxItems": Limits the number of data items passed to the Map state. For example, suppose that you provide a + CSV file that contains 1000 rows and specify a limit of 100. Then, the interpreter passes only 100 rows to the + Map state. The Map state processes items in sequential order, starting after the header row. + Currently, you can specify a limit of up to 100,000,000 + """ + + MAX_VALUE: Final[int] = 100_000_000 + + def _clip_value(self, value: int) -> int: + if value == 0: + return self.MAX_VALUE + return min(value, self.MAX_VALUE) + + @abc.abstractmethod + def _get_value(self, env: Environment) -> int: ... + + def _eval_body(self, env: Environment) -> None: + max_items: int = self._get_value(env=env) + max_items = self._clip_value(max_items) + env.stack.append(max_items) + + +class MaxItemsInt(MaxItemsDecl): + max_items: Final[int] + + def __init__(self, max_items: int = MaxItemsDecl.MAX_VALUE): + if max_items < 0 or max_items > MaxItemsInt.MAX_VALUE: + raise ValueError( + f"MaxItems value MUST be a non-negative integer " + f"non greater than '{MaxItemsInt.MAX_VALUE}', got '{max_items}'." + ) + self.max_items = max_items + + def _get_value(self, env: Environment) -> int: + return self.max_items + + +class MaxItemsStringJSONata(MaxItemsDecl): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _get_value(self, env: Environment) -> int: + # TODO: add snapshot tests to verify AWS's behaviour about non integer values. + self.string_jsonata.eval(env=env) + max_items: int = int(env.stack.pop()) + return max_items + + +class MaxItemsPath(MaxItemsDecl): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def _validate_value(self, env: Environment, value: int) -> None: + if not isinstance(value, int): + # TODO: Note, this error appears to be validated at a earlier stage in AWS Step Functions, unlike the + # negative integer check that is validated at this exact depth. + error_typ = StatesErrorNameType.StatesItemReaderFailed + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=error_typ), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=error_typ.to_name(), + cause=( + f"The MaxItemsPath field refers to value '{value}' " + f"which is not a valid integer: {self.string_sampler.literal_value}" + ), + ) + ), + ) + ) + if value < 0: + error_typ = StatesErrorNameType.StatesItemReaderFailed + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=error_typ), + event_type=HistoryEventType.MapRunFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=error_typ.to_name(), + cause="field MaxItems must be positive", + ) + ), + ) + ) + + def _get_value(self, env: Environment) -> int: + self.string_sampler.eval(env=env) + max_items = env.stack.pop() + if isinstance(max_items, str): + try: + max_items = int(max_items) + except Exception: + # Pass incorrect type forward for validation and error reporting + pass + self._validate_value(env=env, value=max_items) + return max_items diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/reader_config_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/reader_config_decl.py new file mode 100644 index 0000000000000..fff888b474b5a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/reader_config_decl.py @@ -0,0 +1,76 @@ +from typing import Final, Optional, TypedDict + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.csv_header_location import ( + CSVHeaderLocation, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.csv_headers import ( + CSVHeaders, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.input_type import ( + InputType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.max_items_decl import ( + MaxItemsDecl, + MaxItemsInt, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class InputTypeOutput(str): + CSV = "CSV" + JSON = "JSON" + + +class CSVHeaderLocationOutput(str): + FIRST_ROW = "FIRST_ROW" + GIVEN = "GIVEN" + + +CSVHeadersOutput = list[str] +MaxItemsValueOutput = int + + +class ReaderConfigOutput(TypedDict): + InputType: InputTypeOutput + CSVHeaderLocation: CSVHeaderLocationOutput + CSVHeaders: Optional[CSVHeadersOutput] + MaxItemsValue: MaxItemsValueOutput + + +class ReaderConfig(EvalComponent): + input_type: Final[InputType] + max_items_decl: Final[MaxItemsDecl] + csv_header_location: Final[CSVHeaderLocation] + csv_headers: Optional[CSVHeaders] + + def __init__( + self, + input_type: InputType, + csv_header_location: CSVHeaderLocation, + csv_headers: Optional[CSVHeaders], + max_items_decl: Optional[MaxItemsDecl], + ): + self.input_type = input_type + self.max_items_decl = max_items_decl or MaxItemsInt() + self.csv_header_location = csv_header_location + self.csv_headers = csv_headers + # TODO: verify behaviours: + # - csv fields are declared with json input type + # - headers are declared with first_fow location set + + def _eval_body(self, env: Environment) -> None: + self.max_items_decl.eval(env=env) + max_items_value: int = env.stack.pop() + + reader_config_output = ReaderConfigOutput( + InputType=InputTypeOutput(self.input_type.input_type_value), + MaxItemsValue=max_items_value, + ) + if self.csv_header_location: + reader_config_output["CSVHeaderLocation"] = ( + self.csv_header_location.csv_header_location_value.value + ) + if self.csv_headers: + reader_config_output["CSVHeaders"] = self.csv_headers.header_names + env.stack.append(reader_config_output) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/reader_config_props.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/reader_config_props.py new file mode 100644 index 0000000000000..f2ec88c87bba6 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/reader_config/reader_config_props.py @@ -0,0 +1,23 @@ +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.max_items_decl import ( + MaxItemsDecl, +) +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + + +class ReaderConfigProps(TypedProps): + _UNIQUE_SUB_INSTANCES: Final[set[type]] = {MaxItemsDecl} + name: str + + def add(self, instance: Any) -> None: + inst_type = type(instance) + + # Subclasses + for typ in self._UNIQUE_SUB_INSTANCES: + if issubclass(inst_type, typ): + super()._add(typ, instance) + return + + # Base and delegate to preprocessor. + super().add(instance) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval.py new file mode 100644 index 0000000000000..3de89f49f0c2a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval.py @@ -0,0 +1,16 @@ +import abc +from typing import Final + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ServiceResource, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ResourceEval(abc.ABC): + resource: Final[ServiceResource] + + def __init__(self, resource: ServiceResource): + self.resource = resource + + def eval_resource(self, env: Environment) -> None: ... diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval_factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval_factory.py new file mode 100644 index 0000000000000..59edc54d125cf --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval_factory.py @@ -0,0 +1,20 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_eval import ( + ResourceEval, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_eval_s3 import ( + ResourceEvalS3, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + Resource, + ServiceResource, +) + + +def resource_eval_for(resource: Resource) -> ResourceEval: + if isinstance(resource, ServiceResource): + match resource.service_name: + case "s3": + return ResourceEvalS3(resource=resource) + raise ValueError( + f"ItemReader's Resource fields must be states service resource, instead got '{resource.resource_arn}'." + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval_s3.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval_s3.py new file mode 100644 index 0000000000000..262c4f00ca540 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_eval_s3.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Callable, Final + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_eval import ( + ResourceEval, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.utils.strings import camel_to_snake_case, to_str + + +class ResourceEvalS3(ResourceEval): + _HANDLER_REFLECTION_PREFIX: Final[str] = "_handle_" + _API_ACTION_HANDLER_TYPE = Callable[[Environment, ResourceRuntimePart, StateCredentials], None] + + @staticmethod + def _get_s3_client( + resource_runtime_part: ResourceRuntimePart, state_credentials: StateCredentials + ): + return boto_client_for( + region=resource_runtime_part.region, service="s3", state_credentials=state_credentials + ) + + @staticmethod + def _handle_get_object( + env: Environment, + resource_runtime_part: ResourceRuntimePart, + state_credentials: StateCredentials, + ) -> None: + s3_client = ResourceEvalS3._get_s3_client( + resource_runtime_part=resource_runtime_part, state_credentials=state_credentials + ) + parameters = env.stack.pop() + response = s3_client.get_object(**parameters) # noqa + content = to_str(response["Body"].read()) + env.stack.append(content) + + @staticmethod + def _handle_list_objects_v2( + env: Environment, + resource_runtime_part: ResourceRuntimePart, + state_credentials: StateCredentials, + ) -> None: + s3_client = ResourceEvalS3._get_s3_client( + resource_runtime_part=resource_runtime_part, state_credentials=state_credentials + ) + parameters = env.stack.pop() + response = s3_client.list_objects_v2(**parameters) # noqa + contents = response["Contents"] + env.stack.append(contents) + + def _get_api_action_handler(self) -> ResourceEvalS3._API_ACTION_HANDLER_TYPE: + api_action = camel_to_snake_case(self.resource.api_action).strip() + handler_name = ResourceEvalS3._HANDLER_REFLECTION_PREFIX + api_action + resolver_handler = getattr(self, handler_name) + if resolver_handler is None: + raise ValueError(f"Unknown s3 action '{api_action}'.") + return resolver_handler + + def eval_resource(self, env: Environment) -> None: + self.resource.eval(env=env) + resource_runtime_part: ResourceRuntimePart = env.stack.pop() + resolver_handler = self._get_api_action_handler() + state_credentials = StateCredentials(role_arn=env.aws_execution_details.role_arn) + resolver_handler(env, resource_runtime_part, state_credentials) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer.py new file mode 100644 index 0000000000000..5a97d63aebe57 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer.py @@ -0,0 +1,6 @@ +import abc + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent + + +class ResourceOutputTransformer(EvalComponent, abc.ABC): ... diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_csv.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_csv.py new file mode 100644 index 0000000000000..065aacdbc56fc --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_csv.py @@ -0,0 +1,75 @@ +import csv +import io +import itertools +from collections import OrderedDict + +from localstack.aws.api.stepfunctions import HistoryEventType, MapRunFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.reader_config_decl import ( + CSVHeaderLocationOutput, + ReaderConfigOutput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_output_transformer.resource_output_transformer import ( + ResourceOutputTransformer, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + + +class ResourceOutputTransformerCSV(ResourceOutputTransformer): + def _eval_body(self, env: Environment) -> None: + reader_config: ReaderConfigOutput = env.stack.pop() + resource_value: str = env.stack.pop() + + csv_file = io.StringIO(resource_value) + csv_reader = csv.reader(csv_file) + + max_items: int = reader_config["MaxItemsValue"] + csv_reader_slice = itertools.islice(csv_reader, max_items) + + match reader_config["CSVHeaderLocation"]: + case CSVHeaderLocationOutput.FIRST_ROW: + headers = next(csv_reader) + case CSVHeaderLocationOutput.GIVEN: + headers = reader_config["CSVHeaders"] + case unknown: + raise ValueError(f"Unknown CSVHeaderLocation value '{unknown}'.") + + if len(set(headers)) < len(headers): + error_name = StatesErrorName(typ=StatesErrorNameType.StatesItemReaderFailed) + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + mapRunFailedEventDetails=MapRunFailedEventDetails( + error=error_name.error_name, + cause="CSV headers cannot contain duplicates.", + ) + ), + ) + raise FailureEventException(failure_event=failure_event) + + transformed_outputs = list() + for row in csv_reader_slice: + transformed_output = dict() + for i, header in enumerate(headers): + transformed_output[header] = row[i] if i < len(row) else "" + transformed_outputs.append( + OrderedDict( + sorted( + transformed_output.items(), key=lambda item: (item[0].isalpha(), item[0]) + ) + ) + ) + + env.stack.append(transformed_outputs) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_factory.py new file mode 100644 index 0000000000000..f26021ccd4e71 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_factory.py @@ -0,0 +1,23 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.input_type import ( + InputType, + InputTypeValue, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_output_transformer.resource_output_transformer import ( + ResourceOutputTransformer, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_output_transformer.resource_output_transformer_csv import ( + ResourceOutputTransformerCSV, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_output_transformer.resource_output_transformer_json import ( + ResourceOutputTransformerJson, +) + + +def resource_output_transformer_for(input_type: InputType) -> ResourceOutputTransformer: + match input_type.input_type_value: + case InputTypeValue.CSV: + return ResourceOutputTransformerCSV() + case InputTypeValue.JSON: + return ResourceOutputTransformerJson() + case unknown: + raise ValueError(f"Unknown InputType value: '{unknown}'.") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_json.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_json.py new file mode 100644 index 0000000000000..02769e8f5a6e0 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_reader/resource_eval/resource_output_transformer/resource_output_transformer_json.py @@ -0,0 +1,48 @@ +import json + +from localstack.aws.api.stepfunctions import HistoryEventType, MapRunFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.reader_config_decl import ( + ReaderConfigOutput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.resource_eval.resource_output_transformer.resource_output_transformer import ( + ResourceOutputTransformer, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + + +class ResourceOutputTransformerJson(ResourceOutputTransformer): + def _eval_body(self, env: Environment) -> None: + reader_config: ReaderConfigOutput = env.stack.pop() + resource_value: str = env.stack.pop() + + json_list = json.loads(resource_value) + + if not isinstance(json_list, list): + error_name = StatesErrorName(typ=StatesErrorNameType.StatesItemReaderFailed) + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + mapRunFailedEventDetails=MapRunFailedEventDetails( + error=error_name.error_name, + cause="Attempting to map over non-iterable node.", + ) + ), + ) + raise FailureEventException(failure_event=failure_event) + + max_items = reader_config["MaxItemsValue"] + json_list_slice = json_list[:max_items] + env.stack.append(json_list_slice) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_selector.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_selector.py new file mode 100644 index 0000000000000..a096c004270c8 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/item_selector.py @@ -0,0 +1,17 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value_object import ( + AssignTemplateValueObject, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ItemSelector(EvalComponent): + template_value_object: Final[AssignTemplateValueObject] + + def __init__(self, template_value_object: AssignTemplateValueObject): + self.template_value_object = template_value_object + + def _eval_body(self, env: Environment) -> None: + self.template_value_object.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/items/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/items/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/items/items.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/items/items.py new file mode 100644 index 0000000000000..79aa25edb2988 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/items/items.py @@ -0,0 +1,90 @@ +import abc +from typing import Final + +from localstack.aws.api.stepfunctions import ( + EvaluationFailedEventDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value_array import ( + JSONataTemplateValueArray, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + + +class Items(EvalComponent, abc.ABC): ... + + +class ItemsArray(Items): + jsonata_template_value_array: Final[JSONataTemplateValueArray] + + def __init__(self, jsonata_template_value_array: JSONataTemplateValueArray): + super().__init__() + self.jsonata_template_value_array = jsonata_template_value_array + + def _eval_body(self, env: Environment) -> None: + self.jsonata_template_value_array.eval(env=env) + + +class ItemsJSONata(Items): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + self.string_jsonata = string_jsonata + + def _eval_body(self, env: Environment) -> None: + self.string_jsonata.eval(env=env) + items = env.stack[-1] + if not isinstance(items, list): + # FIXME: If we pass in a 'function' type, the JSONata lib will return a dict and the + # 'unsupported result type state' wont be reached. + def _get_jsonata_value_type_pair(items) -> tuple[str, str]: + match items: + case None: + return "null", "null" + case int() | float(): + if isinstance(items, bool): + return "true" if items else "false", "boolean" + return items, "number" + case str(): + return f'"{items}"', "string" + case dict(): + return to_json_str(items, separators=(",", ":")), "object" + + expr = self.string_jsonata.literal_value + if jsonata_pair := _get_jsonata_value_type_pair(items): + jsonata_value, jsonata_type = jsonata_pair + error_cause = ( + f"The JSONata expression '{expr}' specified for the field 'Items' returned an unexpected result type. " + f"Expected 'array', but was '{jsonata_type}' for value: {jsonata_value}" + ) + else: + error_cause = f"The JSONata expression '{expr}' for the field 'Items' returned an unsupported result type." + + error_name = StatesErrorName(typ=StatesErrorNameType.StatesQueryEvaluationError) + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.EvaluationFailed, + event_details=EventDetails( + evaluationFailedEventDetails=EvaluationFailedEventDetails( + error=error_name.error_name, cause=error_cause, location="Items" + ) + ), + ) + raise FailureEventException(failure_event=failure_event) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/distributed_iteration_component.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/distributed_iteration_component.py new file mode 100644 index 0000000000000..841a9db4f453a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/distributed_iteration_component.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import abc +import json +from typing import Any, Final, Optional + +from localstack.aws.api.stepfunctions import ( + HistoryEventType, + MapRunFailedEventDetails, + MapRunStartedEventDetails, + MapRunStatus, +) +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.program.program import Program +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.item_reader_decl import ( + ItemReader, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.inline_iteration_component import ( + InlineIterationComponent, + InlineIterationComponentEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.map_run_record import ( + MapRunRecord, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.processor_config import ( + ProcessorConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobClosed, + JobPool, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.max_concurrency import ( + DEFAULT_MAX_CONCURRENCY_VALUE, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.eval.event.event_manager import ( + EventManager, +) + + +class DistributedIterationComponentEvalInput(InlineIterationComponentEvalInput): + item_reader: Final[Optional[ItemReader]] + label: Final[Optional[str]] + map_run_record: Final[MapRunRecord] + + def __init__( + self, + state_name: str, + max_concurrency: int, + input_items: list[json], + parameters: Optional[Parameters], + item_selector: Optional[ItemSelector], + item_reader: Optional[ItemReader], + tolerated_failure_count: int, + tolerated_failure_percentage: float, + label: Optional[str], + map_run_record: MapRunRecord, + ): + super().__init__( + state_name=state_name, + max_concurrency=max_concurrency, + input_items=input_items, + parameters=parameters, + item_selector=item_selector, + ) + self.item_reader = item_reader + self.tolerated_failure_count = tolerated_failure_count + self.tolerated_failure_percentage = tolerated_failure_percentage + self.label = label + self.map_run_record = map_run_record + + +class DistributedIterationComponent(InlineIterationComponent, abc.ABC): + def __init__( + self, + query_language: QueryLanguage, + start_at: StartAt, + states: States, + comment: Comment, + processor_config: ProcessorConfig, + ): + super().__init__( + query_language=query_language, + start_at=start_at, + states=states, + comment=comment, + processor_config=processor_config, + ) + + def _map_run( + self, env: Environment, eval_input: DistributedIterationComponentEvalInput + ) -> None: + input_items: list[json] = env.stack.pop() + + input_item_program: Final[Program] = self._get_iteration_program() + job_pool = JobPool(job_program=input_item_program, job_inputs=input_items) + + # TODO: add watch on map_run_record update event and adjust the number of running workers accordingly. + max_concurrency = eval_input.map_run_record.max_concurrency + workers_number = ( + len(input_items) + if max_concurrency == DEFAULT_MAX_CONCURRENCY_VALUE + else max_concurrency + ) + for _ in range(workers_number): + self._launch_worker(env=env, eval_input=eval_input, job_pool=job_pool) + + job_pool.await_jobs() + + worker_exception: Optional[Exception] = job_pool.get_worker_exception() + if worker_exception is not None: + raise worker_exception + + closed_jobs: list[JobClosed] = job_pool.get_closed_jobs() + outputs: list[Any] = [closed_job.job_output for closed_job in closed_jobs] + + env.stack.append(outputs) + + def _eval_body(self, env: Environment) -> None: + eval_input: DistributedIterationComponentEvalInput = env.stack.pop() + map_run_record = eval_input.map_run_record + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapRunStarted, + event_details=EventDetails( + mapRunStartedEventDetails=MapRunStartedEventDetails( + mapRunArn=map_run_record.map_run_arn + ) + ), + ) + + parent_event_manager = env.event_manager + try: + if eval_input.item_reader: + eval_input.item_reader.eval(env=env) + else: + env.stack.append(eval_input.input_items) + + env.event_manager = EventManager() + self._map_run(env=env, eval_input=eval_input) + + except FailureEventException as failure_event_ex: + map_run_fail_event_detail = MapRunFailedEventDetails() + + maybe_error_cause_pair = failure_event_ex.extract_error_cause_pair() + if maybe_error_cause_pair: + error, cause = maybe_error_cause_pair + if error: + map_run_fail_event_detail["error"] = error + if cause: + map_run_fail_event_detail["cause"] = cause + + env.event_manager = parent_event_manager + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapRunFailed, + event_details=EventDetails(mapRunFailedEventDetails=map_run_fail_event_detail), + ) + map_run_record.set_stop(status=MapRunStatus.FAILED) + raise failure_event_ex + + except Exception as ex: + env.event_manager = parent_event_manager + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapRunFailed, + event_details=EventDetails(mapRunFailedEventDetails=MapRunFailedEventDetails()), + ) + map_run_record.set_stop(status=MapRunStatus.FAILED) + raise ex + finally: + env.event_manager = parent_event_manager + + # TODO: review workflow of program stops and map run stops + env.event_manager.add_event( + context=env.event_history_context, event_type=HistoryEventType.MapRunSucceeded + ) + map_run_record.set_stop(status=MapRunStatus.SUCCEEDED) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/inline_iteration_component.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/inline_iteration_component.py new file mode 100644 index 0000000000000..3eb020678142c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/inline_iteration_component.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import abc +import json +import threading +from typing import Any, Final, Optional + +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.program.program import Program +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.processor_config import ( + ProcessorConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_component import ( + IterationComponent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_worker import ( + IterationWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobClosed, + JobPool, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.max_concurrency import ( + DEFAULT_MAX_CONCURRENCY_VALUE, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.utils.threads import TMP_THREADS + + +class InlineIterationComponentEvalInput: + state_name: Final[str] + max_concurrency: Final[int] + input_items: Final[list[json]] + parameters: Final[Optional[Parameters]] + item_selector: Final[Optional[ItemSelector]] + + def __init__( + self, + state_name: str, + max_concurrency: int, + input_items: list[json], + parameters: Optional[Parameters], + item_selector: Optional[ItemSelector], + ): + self.state_name = state_name + self.max_concurrency = max_concurrency + self.input_items = input_items + self.parameters = parameters + self.item_selector = item_selector + + +class InlineIterationComponent(IterationComponent, abc.ABC): + _processor_config: Final[ProcessorConfig] + + def __init__( + self, + query_language: QueryLanguage, + start_at: StartAt, + states: States, + processor_config: ProcessorConfig, + comment: Optional[Comment], + ): + super().__init__( + query_language=query_language, start_at=start_at, states=states, comment=comment + ) + self._processor_config = processor_config + + @abc.abstractmethod + def _create_worker( + self, env: Environment, eval_input: InlineIterationComponentEvalInput, job_pool: JobPool + ) -> IterationWorker: ... + + def _launch_worker( + self, env: Environment, eval_input: InlineIterationComponentEvalInput, job_pool: JobPool + ) -> IterationWorker: + worker = self._create_worker(env=env, eval_input=eval_input, job_pool=job_pool) + worker_thread = threading.Thread(target=worker.eval, daemon=True) + TMP_THREADS.append(worker_thread) + worker_thread.start() + return worker + + def _eval_body(self, env: Environment) -> None: + eval_input = env.stack.pop() + + max_concurrency: int = eval_input.max_concurrency + input_items: list[json] = eval_input.input_items + + input_item_program: Final[Program] = self._get_iteration_program() + job_pool = JobPool(job_program=input_item_program, job_inputs=eval_input.input_items) + + number_of_workers = ( + len(input_items) + if max_concurrency == DEFAULT_MAX_CONCURRENCY_VALUE + else max_concurrency + ) + for _ in range(number_of_workers): + self._launch_worker(env=env, eval_input=eval_input, job_pool=job_pool) + + job_pool.await_jobs() + + worker_exception: Optional[Exception] = job_pool.get_worker_exception() + if worker_exception is not None: + raise worker_exception + + closed_jobs: list[JobClosed] = job_pool.get_closed_jobs() + outputs: list[Any] = [closed_job.job_output for closed_job in closed_jobs] + + env.stack.append(outputs) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/distributed_item_processor.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/distributed_item_processor.py new file mode 100644 index 0000000000000..bd669394c8e04 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/distributed_item_processor.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.distributed_iteration_component import ( + DistributedIterationComponent, + DistributedIterationComponentEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.distributed_item_processor_worker import ( + DistributedItemProcessorWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.processor_config import ( + ProcessorConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + + +class DistributedItemProcessorEvalInput(DistributedIterationComponentEvalInput): + pass + + +class DistributedItemProcessor(DistributedIterationComponent): + @classmethod + def from_props(cls, props: TypedProps) -> DistributedItemProcessor: + item_processor = cls( + query_language=props.get(QueryLanguage) or QueryLanguage(), + start_at=props.get( + typ=StartAt, + raise_on_missing=ValueError(f"Missing StartAt declaration in props '{props}'."), + ), + states=props.get( + typ=States, + raise_on_missing=ValueError(f"Missing States declaration in props '{props}'."), + ), + comment=props.get(Comment), + processor_config=props.get(ProcessorConfig) or ProcessorConfig(), + ) + return item_processor + + def _create_worker( + self, env: Environment, eval_input: DistributedItemProcessorEvalInput, job_pool: JobPool + ) -> DistributedItemProcessorWorker: + return DistributedItemProcessorWorker( + work_name=eval_input.state_name, + job_pool=job_pool, + env=env, + item_reader=eval_input.item_reader, + parameters=eval_input.parameters, + item_selector=eval_input.item_selector, + map_run_record=eval_input.map_run_record, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/distributed_item_processor_worker.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/distributed_item_processor_worker.py new file mode 100644 index 0000000000000..bde4c49bdf073 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/distributed_item_processor_worker.py @@ -0,0 +1,156 @@ +import logging +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import EvalTimeoutError +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.item_reader_decl import ( + ItemReader, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.inline_item_processor_worker import ( + InlineItemProcessorWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.map_run_record import ( + MapRunRecord, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + Job, + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramError, + ProgramState, + ProgramStopped, +) +from localstack.services.stepfunctions.asl.eval.states import ItemData, MapData +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +LOG = logging.getLogger(__name__) + + +class DistributedItemProcessorWorker(InlineItemProcessorWorker): + _item_reader: Final[ItemReader] + _map_run_record: MapRunRecord + + def __init__( + self, + work_name: str, + job_pool: JobPool, + env: Environment, + item_reader: ItemReader, + parameters: Optional[Parameters], + item_selector: Optional[ItemSelector], + map_run_record: MapRunRecord, + ): + super().__init__( + work_name=work_name, + job_pool=job_pool, + env=env, + parameters=parameters, + item_selector=item_selector, + ) + self._item_reader = item_reader + self._map_run_record = map_run_record + + def _eval_job(self, env: Environment, job: Job) -> None: + self._map_run_record.item_counter.total.count() + self._map_run_record.item_counter.running.count() + + self._map_run_record.execution_counter.total.count() + self._map_run_record.execution_counter.running.count() + + job_output = None + try: + env.states.context_object.context_object_data["Map"] = MapData( + Item=ItemData(Index=job.job_index, Value=job.job_input) + ) + + env.states.reset(job.job_input) + env.stack.append(env.states.get_input()) + self._eval_input(env_frame=env) + + job.job_program.eval(env) + + # TODO: verify behaviour with all of these scenarios. + end_program_state: ProgramState = env.program_state() + if isinstance(end_program_state, ProgramError): + self._map_run_record.execution_counter.failed.count() + self._map_run_record.item_counter.failed.count() + job_output = None + elif isinstance(end_program_state, ProgramStopped): + self._map_run_record.execution_counter.aborted.count() + self._map_run_record.item_counter.aborted.count() + else: + self._map_run_record.item_counter.succeeded.count() + self._map_run_record.item_counter.results_written.count() + + self._map_run_record.execution_counter.succeeded.count() + self._map_run_record.execution_counter.results_written.count() + self._map_run_record.execution_counter.running.offset(-1) + + job_output = env.states.get_input() + + except EvalTimeoutError as timeout_error: + LOG.debug( + "MapRun worker Timeout Error '%s' for input '%s'.", + timeout_error, + to_json_str(job.job_input), + ) + self._map_run_record.item_counter.timed_out.count() + + except FailureEventException as failure_event_ex: + LOG.debug( + "MapRun worker Event Exception '%s' for input '%s'.", + to_json_str(failure_event_ex.failure_event), + to_json_str(job.job_input), + ) + self._map_run_record.item_counter.failed.count() + + except Exception as exception: + LOG.debug( + "MapRun worker Error '%s' for input '%s'.", + exception, + to_json_str(job.job_input), + ) + self._map_run_record.item_counter.failed.count() + + finally: + self._map_run_record.item_counter.running.offset(-1) + job.job_output = job_output + + def _eval_pool(self, job: Optional[Job], worker_frame: Environment) -> None: + if job is None: + self._env.delete_frame(worker_frame) + return + + # Evaluate the job. + job_frame = worker_frame.open_inner_frame() + self._eval_job(env=job_frame, job=job) + worker_frame.delete_frame(job_frame) + + # Evaluation terminates here due to exception in job, or worker was stopped. + if isinstance(job.job_output, Exception) or self.stopped(): + self._env.delete_frame(worker_frame) + self._job_pool.close_job(job) + return + + next_job: Job = self._job_pool.next_job() + # Iteration will terminate after this job. + if next_job is None: + # The frame has to be closed before the job, to ensure the owner environment is correctly updated + # before the evaluation continues; map states await for job termination not workers termination. + self._env.delete_frame(worker_frame) + self._job_pool.close_job(job) + return + + self._job_pool.close_job(job) + self._eval_pool(job=next_job, worker_frame=worker_frame) + + def eval(self) -> None: + self._eval_pool(job=self._job_pool.next_job(), worker_frame=self._env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/inline_item_processor.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/inline_item_processor.py new file mode 100644 index 0000000000000..8b1d4012ddf5c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/inline_item_processor.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import logging + +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.inline_iteration_component import ( + InlineIterationComponent, + InlineIterationComponentEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.inline_item_processor_worker import ( + InlineItemProcessorWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.processor_config import ( + ProcessorConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + +LOG = logging.getLogger(__name__) + + +class InlineItemProcessorEvalInput(InlineIterationComponentEvalInput): + pass + + +class InlineItemProcessor(InlineIterationComponent): + @classmethod + def from_props(cls, props: TypedProps) -> InlineItemProcessor: + if not props.get(States): + raise ValueError(f"Missing States declaration in props '{props}'.") + if not props.get(StartAt): + raise ValueError(f"Missing StartAt declaration in props '{props}'.") + item_processor = cls( + query_language=props.get(QueryLanguage) or QueryLanguage(), + start_at=props.get(StartAt), + states=props.get(States), + comment=props.get(Comment), + processor_config=props.get(ProcessorConfig) or ProcessorConfig(), + ) + return item_processor + + def _create_worker( + self, env: Environment, eval_input: InlineItemProcessorEvalInput, job_pool: JobPool + ) -> InlineItemProcessorWorker: + return InlineItemProcessorWorker( + work_name=eval_input.state_name, + job_pool=job_pool, + env=env, + item_selector=eval_input.item_selector, + parameters=eval_input.parameters, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/inline_item_processor_worker.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/inline_item_processor_worker.py new file mode 100644 index 0000000000000..2562108ebac80 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/inline_item_processor_worker.py @@ -0,0 +1,49 @@ +import logging +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_worker import ( + IterationWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + +LOG = logging.getLogger(__name__) + + +class InlineItemProcessorWorker(IterationWorker): + _parameters: Final[Optional[Parameters]] + _item_selector: Final[Optional[ItemSelector]] + + def __init__( + self, + work_name: str, + job_pool: JobPool, + env: Environment, + item_selector: Optional[ItemSelector], + parameters: Optional[Parameters], + ): + super().__init__(work_name=work_name, job_pool=job_pool, env=env) + self._item_selector = item_selector + self._parameters = parameters + + def _eval_input(self, env_frame: Environment) -> None: + if not self._parameters and not self._item_selector: + return + + map_state_input = self._env.stack[-1] + env_frame.states.reset(input_value=map_state_input) + env_frame.stack.append(map_state_input) + + if self._item_selector: + self._item_selector.eval(env_frame) + elif self._parameters: + self._parameters.eval(env_frame) + + output_value = env_frame.stack[-1] + env_frame.states.reset(input_value=output_value) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/item_processor_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/item_processor_decl.py new file mode 100644 index 0000000000000..5f9131cb12191 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/item_processor_decl.py @@ -0,0 +1,7 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_declaration import ( + IterationDecl, +) + + +class ItemProcessorDecl(IterationDecl): + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/item_processor_factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/item_processor_factory.py new file mode 100644 index 0000000000000..b633903959be7 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/item_processor_factory.py @@ -0,0 +1,37 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.distributed_item_processor import ( + DistributedItemProcessor, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.inline_item_processor import ( + InlineItemProcessor, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.item_processor_decl import ( + ItemProcessorDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_component import ( + IterationComponent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.mode import ( + Mode, +) + + +def from_item_processor_decl(item_processor_decl: ItemProcessorDecl) -> IterationComponent: + match item_processor_decl.processor_config.mode: + case Mode.Inline: + return InlineItemProcessor( + query_language=item_processor_decl.query_language, + start_at=item_processor_decl.start_at, + states=item_processor_decl.states, + comment=item_processor_decl.comment, + processor_config=item_processor_decl.processor_config, + ) + case Mode.Distributed: + return DistributedItemProcessor( + query_language=item_processor_decl.query_language, + start_at=item_processor_decl.start_at, + states=item_processor_decl.states, + comment=item_processor_decl.comment, + processor_config=item_processor_decl.processor_config, + ) + case unknown: + raise ValueError(f"Unknown Map state processing mode: '{unknown}'.") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/map_run_record.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/map_run_record.py new file mode 100644 index 0000000000000..52599e7abf489 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/map_run_record.py @@ -0,0 +1,205 @@ +import abc +import datetime +import threading +from collections import OrderedDict +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + Arn, + DescribeMapRunOutput, + LongArn, + MapRunExecutionCounts, + MapRunItemCounts, + MapRunListItem, + MapRunStatus, + Timestamp, +) +from localstack.utils.strings import long_uid + + +class Counter: + _mutex: Final[threading.Lock] + _count: int + + def __init__(self): + self._mutex = threading.Lock() + self._count = 0 + + def offset(self, offset: int) -> None: + with self._mutex: + self._count = self._count + offset + + def count(self, increment: int = 1) -> None: + with self._mutex: + self._count += increment + + def get(self) -> int: + return self._count + + +class ProgressCounter(abc.ABC): + aborted: Final[Counter] + failed: Final[Counter] + pending: Final[Counter] + results_written: Final[Counter] + running: Final[Counter] + succeeded: Final[Counter] + timed_out: Final[Counter] + total: Final[Counter] + + def __init__(self): + self.aborted = Counter() + self.failed = Counter() + self.pending = Counter() + self.results_written = Counter() + self.running = Counter() + self.succeeded = Counter() + self.timed_out = Counter() + self.total = Counter() + + +class ExecutionCounter(ProgressCounter): + def describe(self) -> MapRunExecutionCounts: + return MapRunExecutionCounts( + aborted=self.aborted.get(), + failed=self.failed.get(), + pending=self.pending.get(), + resultsWritten=self.results_written.get(), + running=self.running.get(), + succeeded=self.succeeded.get(), + timedOut=self.timed_out.get(), + total=self.total.get(), + ) + + +class ItemCounter(ProgressCounter): + def describe(self) -> MapRunItemCounts: + return MapRunItemCounts( + aborted=self.aborted.get(), + failed=self.failed.get(), + pending=self.pending.get(), + resultsWritten=self.results_written.get(), + running=self.running.get(), + succeeded=self.succeeded.get(), + timedOut=self.timed_out.get(), + total=self.total.get(), + ) + + +class MapRunRecord: + update_event: Final[threading.Event] + map_state_machine_arn: Final[ + LongArn + ] # This is the original state machine arn plut the map run arn postfix. + execution_arn: Final[Arn] + map_run_arn: Final[LongArn] + max_concurrency: int + execution_counter: Final[ExecutionCounter] + item_counter: Final[ItemCounter] + start_date: Timestamp + status: MapRunStatus + stop_date: Optional[Timestamp] + # TODO: add support for failure toleration fields. + tolerated_failure_count: int + tolerated_failure_percentage: float + + def __init__( + self, + state_machine_arn: Arn, + execution_arn: Arn, + max_concurrency: int, + tolerated_failure_count: int, + tolerated_failure_percentage: float, + label: Optional[str], + ): + self.update_event = threading.Event() + ( + map_state_machine_arn, + map_run_arn, + ) = self._generate_map_run_arns(state_machine_arn=state_machine_arn, label=label) + self.map_run_arn = map_run_arn + self.map_state_machine_arn = map_state_machine_arn + self.execution_arn = execution_arn + self.max_concurrency = max_concurrency + self.execution_counter = ExecutionCounter() + self.item_counter = ItemCounter() + self.start_date = datetime.datetime.now(tz=datetime.timezone.utc) + self.status = MapRunStatus.RUNNING + self.stop_date = None + self.tolerated_failure_count = tolerated_failure_count + self.tolerated_failure_percentage = tolerated_failure_percentage + + @staticmethod + def _generate_map_run_arns( + state_machine_arn: Arn, label: Optional[str] + ) -> tuple[LongArn, LongArn]: + # Generate a new MapRunArn given the StateMachineArn, such that: + # inp: arn:aws:states::111111111111:stateMachine: + # MRA: arn:aws:states::111111111111:mapRun:/: + # SMA: arn:aws:states::111111111111:mapRun:/ + map_run_arn = state_machine_arn.replace(":stateMachine:", ":mapRun:") + part_1 = long_uid() if label is None else label + map_run_arn = f"{map_run_arn}/{part_1}:{long_uid()}" + return f"{state_machine_arn}/{part_1}", map_run_arn + + def set_stop(self, status: MapRunStatus): + self.status = status + self.stop_date = datetime.datetime.now(tz=datetime.timezone.utc) + + def describe(self) -> DescribeMapRunOutput: + describe_output = DescribeMapRunOutput( + mapRunArn=self.map_run_arn, + executionArn=self.execution_arn, + status=self.status, + startDate=self.start_date, + maxConcurrency=self.max_concurrency, + toleratedFailurePercentage=self.tolerated_failure_percentage, + toleratedFailureCount=self.tolerated_failure_count, + itemCounts=self.item_counter.describe(), + executionCounts=self.execution_counter.describe(), + ) + stop_date = self.stop_date + if stop_date is not None: + describe_output["stopDate"] = self.stop_date + return describe_output + + def list_item(self) -> MapRunListItem: + list_item = MapRunListItem( + executionArn=self.execution_arn, + mapRunArn=self.map_run_arn, + stateMachineArn=self.map_state_machine_arn, + startDate=self.start_date, + ) + if self.stop_date: + list_item["stopDate"] = self.stop_date + return list_item + + def update( + self, + max_concurrency: Optional[int], + tolerated_failure_count: Optional[int], + tolerated_failure_percentage: Optional[float], + ) -> None: + if max_concurrency is not None: + self.max_concurrency = max_concurrency + if tolerated_failure_count is not None: + self.tolerated_failure_count = tolerated_failure_count + if tolerated_failure_percentage is not None: + self.tolerated_failure_percentage = tolerated_failure_percentage + self.update_event.set() + + +class MapRunRecordPoolManager: + _pool: dict[LongArn, MapRunRecord] + + def __init__(self): + self._pool = OrderedDict() + + def add(self, map_run_record: MapRunRecord) -> None: + self._pool[map_run_record.map_run_arn] = map_run_record + + def get(self, map_run_arn: LongArn) -> Optional[MapRunRecord]: + return self._pool.get(map_run_arn) + + def get_all(self) -> list[MapRunRecord]: + return list(self._pool.values()) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/processor_config.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/processor_config.py new file mode 100644 index 0000000000000..76e804b42cf67 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/itemprocessor/processor_config.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.execution_type import ( + ExecutionType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.mode import ( + Mode, +) + + +class ProcessorConfig(Component): + DEFAULT_MODE: Final[Mode] = Mode.Inline + DEFAULT_EXECUTION_TYPE: Final[ExecutionType] = ExecutionType.Standard + + mode: Final[Mode] + execution_type: Final[ExecutionType] + + def __init__( + self, mode: Mode = DEFAULT_MODE, execution_type: ExecutionType = DEFAULT_EXECUTION_TYPE + ): + super().__init__() + self.mode = mode + self.execution_type = execution_type diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_component.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_component.py new file mode 100644 index 0000000000000..92e1be15ccd64 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_component.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import abc +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.program.program import Program +from localstack.services.stepfunctions.asl.component.program.states import States + + +class IterationComponent(EvalComponent, abc.ABC): + # Ensure no member variables are used to keep track of the state of + # iteration components: the evaluation must be stateless as for all + # EvalComponents to ensure they can be reused or used concurrently. + _query_language: Final[QueryLanguage] + _start_at: Final[StartAt] + _states: Final[States] + _comment: Final[Optional[Comment]] + + def __init__( + self, + query_language: QueryLanguage, + start_at: StartAt, + states: States, + comment: Optional[Comment], + ): + self._query_language = query_language + self._start_at = start_at + self._states = states + self._comment = comment + + def _get_iteration_program(self) -> Program: + return Program( + query_language=self._query_language, + start_at=self._start_at, + states=self._states, + timeout_seconds=None, + comment=self._comment, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_declaration.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_declaration.py new file mode 100644 index 0000000000000..b26b87ec1437e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_declaration.py @@ -0,0 +1,32 @@ +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.component import Component +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.processor_config import ( + ProcessorConfig, +) + + +class IterationDecl(Component): + comment: Final[Optional[Comment]] + query_language: Final[QueryLanguage] + start_at: Final[StartAt] + states: Final[States] + processor_config: Final[ProcessorConfig] + + def __init__( + self, + comment: Optional[Comment], + query_language: QueryLanguage, + start_at: StartAt, + states: States, + processor_config: ProcessorConfig, + ): + self.comment = comment + self.query_language = query_language + self.start_at = start_at + self.states = states + self.processor_config = processor_config diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_worker.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_worker.py new file mode 100644 index 0000000000000..1603149ca0b57 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iteration_worker.py @@ -0,0 +1,205 @@ +import abc +import logging +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, MapIterationEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + Job, + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramError, + ProgramState, + ProgramStopped, +) +from localstack.services.stepfunctions.asl.eval.states import ItemData, MapData + +LOG = logging.getLogger(__name__) + + +class IterationWorker(abc.ABC): + _work_name: Final[str] + _job_pool: Final[JobPool] + _env: Final[Environment] + _stop_signal_received: bool + + def __init__( + self, + work_name: str, + job_pool: JobPool, + env: Environment, + ): + self._work_name = work_name + self._job_pool = job_pool + self._env = env + self._stop_signal_received = False + + def sig_stop(self): + self._stop_signal_received = True + + def stopped(self): + return self._stop_signal_received + + @abc.abstractmethod + def _eval_input(self, env_frame: Environment) -> None: ... + + def _eval_job(self, env: Environment, job: Job) -> None: + map_iteration_event_details = MapIterationEventDetails( + name=self._work_name, index=job.job_index + ) + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapIterationStarted, + event_details=EventDetails(mapIterationStartedEventDetails=map_iteration_event_details), + ) + + job_output = RuntimeError( + f"Unexpected Runtime Error in ItemProcessor worker for input '{job.job_index}'." + ) + try: + env.states.context_object.context_object_data["Map"] = MapData( + Item=ItemData(Index=job.job_index, Value=job.job_input) + ) + + env.states.reset(input_value=job.job_input) + self._eval_input(env_frame=env) + + job.job_program.eval(env) + + # Program evaluation suppressed runtime exceptions into an execution exception in the program state. + # Hence, here the routine extract this error triggering FailureEventExceptions, to allow the error at this + # depth to be logged appropriately and propagate to parent states. + + # In case of internal error that lead to failure, then raise execution Exception + # and hence leading to a MapIterationFailed event. + end_program_state: ProgramState = env.program_state() + if isinstance(end_program_state, ProgramError): + error_name = end_program_state.error.get("error") + if error_name is not None: + error_name = CustomErrorName(error_name=error_name) + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.MapIterationFailed, + event_details=EventDetails( + executionFailedEventDetails=end_program_state.error + ), + ) + ) + # If instead the program (parent state machine) was halted, then raise an execution Exception. + elif isinstance(end_program_state, ProgramStopped): + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=CustomErrorName(error_name=HistoryEventType.MapIterationAborted), + event_type=HistoryEventType.MapIterationAborted, + event_details=EventDetails( + executionFailedEventDetails=end_program_state.error + ), + ) + ) + + # Otherwise, execution succeeded and the output of this operation is available. + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapIterationSucceeded, + event_details=EventDetails( + mapIterationSucceededEventDetails=map_iteration_event_details + ), + update_source_event_id=False, + ) + # Extract the output otherwise. + job_output = env.states.get_input() + + except FailureEventException as failure_event_ex: + # Extract the output to be this exception: this will trigger a failure workflow in the jobs pool. + job_output = failure_event_ex + + # At this depth, the next event is either a MapIterationFailed (for any reasons) or a MapIterationAborted + # if explicitly indicated. + if failure_event_ex.failure_event.event_type == HistoryEventType.MapIterationAborted: + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapIterationAborted, + event_details=EventDetails( + mapIterationAbortedEventDetails=map_iteration_event_details + ), + update_source_event_id=False, + ) + else: + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapIterationFailed, + event_details=EventDetails( + mapIterationFailedEventDetails=map_iteration_event_details + ), + update_source_event_id=False, + ) + + except Exception as ex: + # Error case. + LOG.warning( + "Unhandled termination error in item processor worker for job '%s'.", + job.job_index, + ) + + # Pass the exception upstream leading to evaluation halt. + job_output = ex + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapIterationFailed, + event_details=EventDetails( + mapIterationFailedEventDetails=map_iteration_event_details + ), + update_source_event_id=False, + ) + + finally: + job.job_output = job_output + + def _eval_pool(self, job: Optional[Job], worker_frame: Environment) -> None: + # Note: the frame has to be closed before the job, to ensure the owner environment is correctly updated + # before the evaluation continues; map states await for job termination not workers termination. + if job is None: + self._env.close_frame(worker_frame) + return + + # Evaluate the job. + job_frame = worker_frame.open_inner_frame() + self._eval_job(env=job_frame, job=job) + worker_frame.close_frame(job_frame) + + # Evaluation terminates here due to exception in job, or worker was stopped. + if isinstance(job.job_output, Exception) or self.stopped(): + self._env.close_frame(worker_frame) + self._job_pool.close_job(job) + return + + next_job: Job = self._job_pool.next_job() + # Iteration will terminate after this job. + if next_job is None: + # Non-faulty terminal iteration update events are used as source of the following states. + worker_frame.event_history_context.source_event_id = ( + job_frame.event_history_context.last_published_event_id + ) + self._env.close_frame(worker_frame) + self._job_pool.close_job(job) + return + + self._job_pool.close_job(job) + self._eval_pool(job=next_job, worker_frame=worker_frame) + + def eval(self) -> None: + self._eval_pool(job=self._job_pool.next_job(), worker_frame=self._env.open_frame()) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/distributed_iterator.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/distributed_iterator.py new file mode 100644 index 0000000000000..039007fc31229 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/distributed_iterator.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.distributed_iteration_component import ( + DistributedIterationComponent, + DistributedIterationComponentEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.processor_config import ( + ProcessorConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.distributed_iterator_worker import ( + DistributedIteratorWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + + +class DistributedIteratorEvalInput(DistributedIterationComponentEvalInput): + pass + + +class DistributedIterator(DistributedIterationComponent): + @classmethod + def from_props(cls, props: TypedProps) -> DistributedIterator: + item_processor = cls( + query_language=props.get(QueryLanguage) or QueryLanguage(), + start_at=props.get( + typ=StartAt, + raise_on_missing=ValueError(f"Missing StartAt declaration in props '{props}'."), + ), + states=props.get( + typ=States, + raise_on_missing=ValueError(f"Missing States declaration in props '{props}'."), + ), + comment=props.get(Comment), + processor_config=props.get(ProcessorConfig), + ) + return item_processor + + def _create_worker( + self, env: Environment, eval_input: DistributedIteratorEvalInput, job_pool: JobPool + ) -> DistributedIteratorWorker: + return DistributedIteratorWorker( + work_name=eval_input.state_name, + job_pool=job_pool, + env=env, + parameters=eval_input.parameters, + map_run_record=eval_input.map_run_record, + item_selector=eval_input.item_selector, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/distributed_iterator_worker.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/distributed_iterator_worker.py new file mode 100644 index 0000000000000..583ab6e666473 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/distributed_iterator_worker.py @@ -0,0 +1,131 @@ +from typing import Optional + +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import EvalTimeoutError +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.map_run_record import ( + MapRunRecord, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.inline_iterator_worker import ( + InlineIteratorWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + Job, + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramError, + ProgramState, + ProgramStopped, +) +from localstack.services.stepfunctions.asl.eval.states import ItemData, MapData + + +class DistributedIteratorWorker(InlineIteratorWorker): + _map_run_record: MapRunRecord + + def __init__( + self, + work_name: str, + job_pool: JobPool, + env: Environment, + parameters: Optional[Parameters], + map_run_record: MapRunRecord, + item_selector: Optional[ItemSelector], + ): + super().__init__( + work_name=work_name, + job_pool=job_pool, + env=env, + parameters=parameters, + item_selector=item_selector, + ) + self._map_run_record = map_run_record + + def _eval_job(self, env: Environment, job: Job) -> None: + self._map_run_record.item_counter.total.count() + self._map_run_record.item_counter.running.count() + + self._map_run_record.execution_counter.total.count() + self._map_run_record.execution_counter.running.count() + + job_output = None + try: + env.states.context_object.context_object_data["Map"] = MapData( + Item=ItemData(Index=job.job_index, Value=job.job_input) + ) + + env.states.reset(input_value=job.job_input) + env.stack.append(env.states.get_input()) + self._eval_input(env_frame=env) + + job.job_program.eval(env) + + # TODO: verify behaviour with all of these scenarios. + end_program_state: ProgramState = env.program_state() + if isinstance(end_program_state, ProgramError): + self._map_run_record.execution_counter.failed.count() + self._map_run_record.item_counter.failed.count() + job_output = None + elif isinstance(end_program_state, ProgramStopped): + self._map_run_record.execution_counter.aborted.count() + self._map_run_record.item_counter.aborted.count() + else: + self._map_run_record.item_counter.succeeded.count() + self._map_run_record.item_counter.results_written.count() + + self._map_run_record.execution_counter.succeeded.count() + self._map_run_record.execution_counter.results_written.count() + self._map_run_record.execution_counter.running.offset(-1) + + job_output = env.states.get_input() + + except EvalTimeoutError: + self._map_run_record.item_counter.timed_out.count() + + except FailureEventException: + self._map_run_record.item_counter.failed.count() + + except Exception: + self._map_run_record.item_counter.failed.count() + + finally: + self._map_run_record.item_counter.running.offset(-1) + job.job_output = job_output + + def _eval_pool(self, job: Optional[Job], worker_frame: Environment) -> None: + # Note: the frame has to be closed before the job, to ensure the owner environment is correctly updated + # before the evaluation continues; map states await for job termination not workers termination. + if job is None: + self._env.delete_frame(worker_frame) + return + + # Evaluate the job. + job_frame = worker_frame.open_frame() + self._eval_job(env=job_frame, job=job) + worker_frame.delete_frame(job_frame) + + # Evaluation terminates here due to exception in job, or worker was stopped. + if isinstance(job.job_output, Exception) or self.stopped(): + self._env.delete_frame(worker_frame) + self._job_pool.close_job(job) + return + + next_job: Job = self._job_pool.next_job() + # Iteration will terminate after this job. + if next_job is None: + self._env.delete_frame(worker_frame) + self._job_pool.close_job(job) + return + + self._job_pool.close_job(job) + self._eval_pool(job=next_job, worker_frame=worker_frame) + + def eval(self) -> None: + self._eval_pool(job=self._job_pool.next_job(), worker_frame=self._env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/inline_iterator.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/inline_iterator.py new file mode 100644 index 0000000000000..6100e412df44c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/inline_iterator.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.inline_iteration_component import ( + InlineIterationComponent, + InlineIterationComponentEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.inline_iterator_worker import ( + InlineIteratorWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.iterator_decl import ( + IteratorDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + +LOG = logging.getLogger(__name__) + + +class InlineIteratorEvalInput(InlineIterationComponentEvalInput): + pass + + +class InlineIterator(InlineIterationComponent): + _eval_input: Optional[InlineIteratorEvalInput] + + def _create_worker( + self, env: Environment, eval_input: InlineIteratorEvalInput, job_pool: JobPool + ) -> InlineIteratorWorker: + return InlineIteratorWorker( + work_name=eval_input.state_name, + job_pool=job_pool, + env=env, + parameters=eval_input.parameters, + item_selector=eval_input.item_selector, + ) + + @classmethod + def from_declaration(cls, iterator_decl: IteratorDecl): + return cls( + query_language=iterator_decl.query_language, + start_at=iterator_decl.start_at, + states=iterator_decl.states, + comment=iterator_decl.comment, + processor_config=iterator_decl.processor_config, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/inline_iterator_worker.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/inline_iterator_worker.py new file mode 100644 index 0000000000000..45db68a00e8b1 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/inline_iterator_worker.py @@ -0,0 +1,48 @@ +import logging +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_worker import ( + IterationWorker, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.job import ( + JobPool, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + +LOG = logging.getLogger(__name__) + + +class InlineIteratorWorker(IterationWorker): + _parameters: Final[Optional[Parameters]] + _item_selector: Final[Optional[ItemSelector]] + + def __init__( + self, + work_name: str, + job_pool: JobPool, + env: Environment, + item_selector: Optional[ItemSelector], + parameters: Optional[Parameters], + ): + super().__init__(work_name=work_name, job_pool=job_pool, env=env) + self._item_selector = item_selector + self._parameters = parameters + + def _eval_input(self, env_frame: Environment) -> None: + if not self._parameters and not self._item_selector: + return + + map_state_input = self._env.stack[-1] + env_frame.states.reset(input_value=map_state_input) + env_frame.stack.append(env_frame.states.get_input()) + + if self._item_selector: + self._item_selector.eval(env_frame) + elif self._parameters: + self._parameters.eval(env_frame) + + env_frame.states.reset(input_value=env_frame.stack[-1]) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/iterator_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/iterator_decl.py new file mode 100644 index 0000000000000..c49bde9a40e64 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/iterator_decl.py @@ -0,0 +1,7 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_declaration import ( + IterationDecl, +) + + +class IteratorDecl(IterationDecl): + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/iterator_factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/iterator_factory.py new file mode 100644 index 0000000000000..287a82fce6c9b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/iterator/iterator_factory.py @@ -0,0 +1,37 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_component import ( + IterationComponent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.distributed_iterator import ( + DistributedIterator, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.inline_iterator import ( + InlineIterator, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.iterator_decl import ( + IteratorDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.mode import ( + Mode, +) + + +def from_iterator_decl(iterator_decl: IteratorDecl) -> IterationComponent: + match iterator_decl.processor_config.mode: + case Mode.Inline: + return InlineIterator( + query_language=iterator_decl.query_language, + start_at=iterator_decl.start_at, + states=iterator_decl.states, + comment=iterator_decl.comment, + processor_config=iterator_decl.processor_config, + ) + case Mode.Distributed: + return DistributedIterator( + query_language=iterator_decl.query_language, + start_at=iterator_decl.start_at, + states=iterator_decl.states, + comment=iterator_decl.comment, + processor_config=iterator_decl.processor_config, + ) + case unknown: + raise ValueError(f"Unknown Map state processing mode: '{unknown}'.") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/job.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/job.py new file mode 100644 index 0000000000000..1ef24a6e17593 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/iteration/job.py @@ -0,0 +1,104 @@ +import copy +import logging +import threading +from typing import Any, Final, Optional + +from localstack.services.stepfunctions.asl.component.program.program import Program +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +LOG = logging.getLogger(__name__) + + +class Job: + job_index: Final[int] + job_program: Final[Program] + job_input: Final[Optional[Any]] + job_output: Optional[Any] + + def __init__(self, job_index: int, job_program: Program, job_input: Optional[Any]): + self.job_index = job_index + self.job_program = job_program + self.job_input = job_input + self.job_output = None + + +class JobClosed: + job_index: Final[int] + job_output: Optional[Any] + + def __init__(self, job_index: int, job_output: Optional[Any]): + self.job_index = job_index + self.job_output = job_output + + def __hash__(self): + return hash(self.job_index) + + +class JobPool: + _mutex: Final[threading.Lock] + _termination_event: Final[threading.Event] + _worker_exception: Optional[Exception] + + _jobs_number: Final[int] + _open_jobs: Final[list[Job]] + _closed_jobs: Final[set[JobClosed]] + + def __init__(self, job_program: Program, job_inputs: list[Any]): + self._mutex = threading.Lock() + self._termination_event = threading.Event() + self._worker_exception = None + + self._jobs_number = len(job_inputs) + self._open_jobs = [ + Job(job_index=job_index, job_program=job_program, job_input=job_input) + for job_index, job_input in enumerate(job_inputs) + ] + self._open_jobs.reverse() + self._closed_jobs = set() + + def next_job(self) -> Optional[Any]: + with self._mutex: + if self._worker_exception is not None: + return None + try: + return self._open_jobs.pop() + except IndexError: + return None + + def _is_terminated(self) -> bool: + return len(self._closed_jobs) == self._jobs_number or self._worker_exception is not None + + def _notify_on_termination(self) -> None: + if self._is_terminated(): + self._termination_event.set() + + def get_worker_exception(self) -> Optional[Exception]: + return self._worker_exception + + def close_job(self, job: Job) -> None: + with self._mutex: + if self._is_terminated(): + return + + if job in self._closed_jobs: + LOG.warning( + "Duplicate execution of Job with index '%s' and input '%s'", + job.job_index, + to_json_str(job.job_input), + ) + + if isinstance(job.job_output, Exception): + self._worker_exception = job.job_output + else: + self._closed_jobs.add(JobClosed(job_index=job.job_index, job_output=job.job_output)) + + self._notify_on_termination() + + def get_closed_jobs(self) -> list[JobClosed]: + with self._mutex: + closed_jobs = copy.deepcopy(self._closed_jobs) + return sorted(closed_jobs, key=lambda closed_job: closed_job.job_index) + + def await_jobs(self) -> None: + if not self._is_terminated(): + self._termination_event.wait() diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/label.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/label.py new file mode 100644 index 0000000000000..3b9c2bce7be6a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/label.py @@ -0,0 +1,24 @@ +from itertools import chain +from typing import Final + +from localstack.services.stepfunctions.asl.component.component import Component + + +class Label(Component): + label: Final[str] + + def __init__(self, label: str): + self.label = label.encode().decode("unicode-escape") + + if len(self.label) == 0: + raise ValueError("Label cannot be empty") + + if len(self.label) > 40: + raise ValueError("Label cannot exceed 40 characters") + + for invalid_char in list(' ?*<>{}[]:;,\\|^~$#%&`"') + [ + chr(i) for i in chain(range(0x00, 0x20), range(0x7F, 0xA0)) + ]: + if invalid_char in self.label: + escaped_char = invalid_char.encode("unicode-escape").decode() + raise ValueError(f"Label contains invalid character: '{escaped_char}'") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/max_concurrency.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/max_concurrency.py new file mode 100644 index 0000000000000..2aa4de3920e1e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/max_concurrency.py @@ -0,0 +1,104 @@ +import abc +from typing import Final + +from localstack.aws.api.stepfunctions import ExecutionFailedEventDetails, HistoryEventType +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +DEFAULT_MAX_CONCURRENCY_VALUE: Final[int] = 0 # No limit. + + +class MaxConcurrencyDecl(EvalComponent, abc.ABC): + @abc.abstractmethod + def _eval_max_concurrency(self, env: Environment) -> int: ... + + def _eval_body(self, env: Environment) -> None: + max_concurrency_value = self._eval_max_concurrency(env=env) + env.stack.append(max_concurrency_value) + + +class MaxConcurrency(MaxConcurrencyDecl): + max_concurrency_value: Final[int] + + def __init__(self, num: int = DEFAULT_MAX_CONCURRENCY_VALUE): + super().__init__() + self.max_concurrency_value = num + + def _eval_max_concurrency(self, env: Environment) -> int: + return self.max_concurrency_value + + +class MaxConcurrencyJSONata(MaxConcurrencyDecl): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _eval_max_concurrency(self, env: Environment) -> int: + self.string_jsonata.eval(env=env) + # TODO: add snapshot tests to verify AWS's behaviour about non integer values. + seconds = int(env.stack.pop()) + return seconds + + +class MaxConcurrencyPath(MaxConcurrency): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + super().__init__() + self.string_sampler = string_sampler + + def _eval_max_concurrency(self, env: Environment) -> int: + self.string_sampler.eval(env=env) + max_concurrency_value = env.stack.pop() + + if not isinstance(max_concurrency_value, int): + try: + max_concurrency_value = int(max_concurrency_value) + except Exception: + # Pass the wrong type forward. + pass + + error_cause = None + if not isinstance(max_concurrency_value, int): + value_str = ( + to_json_str(max_concurrency_value) + if not isinstance(max_concurrency_value, str) + else max_concurrency_value + ) + error_cause = f'The MaxConcurrencyPath field refers to value "{value_str}" which is not a valid integer: {self.string_sampler.literal_value}' + elif max_concurrency_value < 0: + error_cause = f"Expected non-negative integer for MaxConcurrency, got '{max_concurrency_value}' instead." + + if error_cause is not None: + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=error_cause + ) + ), + ) + ) + + return max_concurrency_value diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/mode.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/mode.py new file mode 100644 index 0000000000000..3baccf195fb74 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/mode.py @@ -0,0 +1,8 @@ +from enum import Enum + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer + + +class Mode(Enum): + Inline = ASLLexer.INLINE + Distributed = ASLLexer.DISTRIBUTED diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval.py new file mode 100644 index 0000000000000..3de89f49f0c2a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval.py @@ -0,0 +1,16 @@ +import abc +from typing import Final + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ServiceResource, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ResourceEval(abc.ABC): + resource: Final[ServiceResource] + + def __init__(self, resource: ServiceResource): + self.resource = resource + + def eval_resource(self, env: Environment) -> None: ... diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval_factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval_factory.py new file mode 100644 index 0000000000000..7695e3ee58bf3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval_factory.py @@ -0,0 +1,20 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.result_writer.resource_eval.resource_eval import ( + ResourceEval, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.result_writer.resource_eval.resource_eval_s3 import ( + ResourceEvalS3, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + Resource, + ServiceResource, +) + + +def resource_eval_for(resource: Resource) -> ResourceEval: + if isinstance(resource, ServiceResource): + match resource.service_name: + case "s3": + return ResourceEvalS3(resource=resource) + raise ValueError( + f"ResultWriter's Resource fields must be states service resource, instead got '{resource.resource_arn}'." + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval_s3.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval_s3.py new file mode 100644 index 0000000000000..178c9653c83c6 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/resource_eval/resource_eval_s3.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from typing import Callable, Final + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.result_writer.resource_eval.resource_eval import ( + ResourceEval, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.utils.strings import camel_to_snake_case + + +class ResourceEvalS3(ResourceEval): + _HANDLER_REFLECTION_PREFIX: Final[str] = "_handle_" + _API_ACTION_HANDLER_TYPE = Callable[[Environment, ResourceRuntimePart, StateCredentials], None] + + @staticmethod + def _get_s3_client( + resource_runtime_part: ResourceRuntimePart, state_credentials: StateCredentials + ): + return boto_client_for( + service="s3", region=resource_runtime_part.region, state_credentials=state_credentials + ) + + @staticmethod + def _handle_put_object( + env: Environment, + resource_runtime_part: ResourceRuntimePart, + state_credentials: StateCredentials, + ) -> None: + parameters = env.stack.pop() + env.stack.pop() # TODO: results + + s3_client = ResourceEvalS3._get_s3_client( + resource_runtime_part=resource_runtime_part, state_credentials=state_credentials + ) + map_run_record = env.map_run_record_pool_manager.get_all().pop() + map_run_uuid = map_run_record.map_run_arn.split(":")[-1] + if parameters["Prefix"] != "" and not parameters["Prefix"].endswith("/"): + parameters["Prefix"] += "/" + + # TODO: generate result files and upload them to s3. + body = { + "DestinationBucket": parameters["Bucket"], + "MapRunArn": map_run_record.map_run_arn, + "ResultFiles": {"FAILED": [], "PENDING": [], "SUCCEEDED": []}, + } + key = parameters["Prefix"] + map_run_uuid + "/manifest.json" + s3_client.put_object( + Bucket=parameters["Bucket"], Key=key, Body=json.dumps(body, indent=2).encode("utf8") + ) + env.stack.append( + { + "MapRunArn": map_run_record.map_run_arn, + "ResultWriterDetails": {"Bucket": parameters["Bucket"], "Key": key}, + } + ) + + def _get_api_action_handler(self) -> ResourceEvalS3._API_ACTION_HANDLER_TYPE: + api_action = camel_to_snake_case(self.resource.api_action).strip() + handler_name = ResourceEvalS3._HANDLER_REFLECTION_PREFIX + api_action + resolver_handler = getattr(self, handler_name) + if resolver_handler is None: + raise ValueError(f"Unknown s3 action '{api_action}'.") + return resolver_handler + + def eval_resource(self, env: Environment) -> None: + self.resource.eval(env=env) + resource_runtime_part: ResourceRuntimePart = env.stack.pop() + resolver_handler = self._get_api_action_handler() + state_credentials = StateCredentials(role_arn=env.aws_execution_details.role_arn) + resolver_handler(env, resource_runtime_part, state_credentials) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/result_writer_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/result_writer_decl.py new file mode 100644 index 0000000000000..244c78417aab4 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/result_writer/result_writer_decl.py @@ -0,0 +1,45 @@ +import copy +import logging +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.parargs import Parargs +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.result_writer.resource_eval.resource_eval import ( + ResourceEval, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.result_writer.resource_eval.resource_eval_factory import ( + resource_eval_for, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + Resource, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + +LOG = logging.getLogger(__name__) + + +class ResultWriter(EvalComponent): + resource_eval: Final[ResourceEval] + parargs: Final[Parargs] + + def __init__( + self, + resource: Resource, + parargs: Parargs, + ): + self.resource_eval = resource_eval_for(resource=resource) + self.parargs = parargs + + @property + def resource(self): + return self.resource_eval.resource + + def __str__(self): + class_dict = copy.deepcopy(self.__dict__) + del class_dict["resource_eval"] + class_dict["resource"] = self.resource + return f"({self.__class__.__name__}| {class_dict})" + + def _eval_body(self, env: Environment) -> None: + self.parargs.eval(env=env) + self.resource_eval.eval_resource(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/state_map.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/state_map.py new file mode 100644 index 0000000000000..ea0aebac7751d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/state_map.py @@ -0,0 +1,347 @@ +import copy +from typing import Optional + +from localstack.aws.api.stepfunctions import ( + EvaluationFailedEventDetails, + HistoryEventType, + MapStateStartedEventDetails, +) +from localstack.services.stepfunctions.asl.component.common.catch.catch_decl import CatchDecl +from localstack.services.stepfunctions.asl.component.common.catch.catch_outcome import CatchOutcome +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters, Parargs +from localstack.services.stepfunctions.asl.component.common.path.items_path import ItemsPath +from localstack.services.stepfunctions.asl.component.common.path.result_path import ResultPath +from localstack.services.stepfunctions.asl.component.common.result_selector import ResultSelector +from localstack.services.stepfunctions.asl.component.common.retry.retry_decl import RetryDecl +from localstack.services.stepfunctions.asl.component.common.retry.retry_outcome import RetryOutcome +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + JSONPATH_ROOT_PATH, + StringJsonPath, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.execute_state import ( + ExecutionState, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.item_reader_decl import ( + ItemReader, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.items.items import ( + Items, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.distributed_iteration_component import ( + DistributedIterationComponent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.distributed_item_processor import ( + DistributedItemProcessor, + DistributedItemProcessorEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.inline_item_processor import ( + InlineItemProcessor, + InlineItemProcessorEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.item_processor_decl import ( + ItemProcessorDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.item_processor_factory import ( + from_item_processor_decl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.map_run_record import ( + MapRunRecord, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iteration_component import ( + IterationComponent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.distributed_iterator import ( + DistributedIterator, + DistributedIteratorEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.inline_iterator import ( + InlineIterator, + InlineIteratorEvalInput, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.iterator_decl import ( + IteratorDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.iterator_factory import ( + from_iterator_decl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.label import ( + Label, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.max_concurrency import ( + MaxConcurrency, + MaxConcurrencyDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.result_writer.result_writer_decl import ( + ResultWriter, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.tolerated_failure import ( + ToleratedFailureCountDecl, + ToleratedFailureCountInt, + ToleratedFailurePercentage, + ToleratedFailurePercentageDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + + +class StateMap(ExecutionState): + items: Optional[Items] + items_path: Optional[ItemsPath] + iteration_component: IterationComponent + item_reader: Optional[ItemReader] + item_selector: Optional[ItemSelector] + parameters: Optional[Parameters] + max_concurrency_decl: MaxConcurrencyDecl + tolerated_failure_count_decl: ToleratedFailureCountDecl + tolerated_failure_percentage_decl: ToleratedFailurePercentage + result_path: Optional[ResultPath] + result_selector: ResultSelector + retry: Optional[RetryDecl] + catch: Optional[CatchDecl] + label: Optional[Label] + result_writer: Optional[ResultWriter] + + def __init__(self): + super(StateMap, self).__init__( + state_entered_event_type=HistoryEventType.MapStateEntered, + state_exited_event_type=HistoryEventType.MapStateExited, + ) + + def from_state_props(self, state_props: StateProps) -> None: + super(StateMap, self).from_state_props(state_props) + if self._is_language_query_jsonpath(): + self.items = None + self.items_path = state_props.get(ItemsPath) or ItemsPath( + string_sampler=StringJsonPath(JSONPATH_ROOT_PATH) + ) + else: + # TODO: add snapshot test to assert what missing definitions of items means for a states map + self.items_path = None + self.items = state_props.get(Items) + self.item_reader = state_props.get(ItemReader) + self.item_selector = state_props.get(ItemSelector) + self.parameters = state_props.get(Parargs) + self.max_concurrency_decl = state_props.get(MaxConcurrencyDecl) or MaxConcurrency() + self.tolerated_failure_count_decl = ( + state_props.get(ToleratedFailureCountDecl) or ToleratedFailureCountInt() + ) + self.tolerated_failure_percentage_decl = ( + state_props.get(ToleratedFailurePercentageDecl) or ToleratedFailurePercentage() + ) + self.result_path = state_props.get(ResultPath) or ResultPath( + result_path_src=ResultPath.DEFAULT_PATH + ) + self.result_selector = state_props.get(ResultSelector) + self.retry = state_props.get(RetryDecl) + self.catch = state_props.get(CatchDecl) + self.label = state_props.get(Label) + self.result_writer = state_props.get(ResultWriter) + + iterator_decl = state_props.get(typ=IteratorDecl) + item_processor_decl = state_props.get(typ=ItemProcessorDecl) + + if iterator_decl and item_processor_decl: + raise ValueError("Cannot define both Iterator and ItemProcessor.") + + iteration_decl = iterator_decl or item_processor_decl + if iteration_decl is None: + raise ValueError(f"Missing ItemProcessor/Iterator definition in props '{state_props}'.") + + if isinstance(iteration_decl, IteratorDecl): + self.iteration_component = from_iterator_decl(iteration_decl) + elif isinstance(iteration_decl, ItemProcessorDecl): + self.iteration_component = from_item_processor_decl(iteration_decl) + else: + raise ValueError(f"Unknown value for IteratorDecl '{iteration_decl}'.") + + def _eval_execution(self, env: Environment) -> None: + self.max_concurrency_decl.eval(env=env) + max_concurrency_num = env.stack.pop() + label = self.label.label if self.label else None + + # Despite MaxConcurrency and Tolerance fields being state level fields, AWS StepFunctions evaluates only + # MaxConcurrency as a state level field. In contrast, Tolerance is evaluated only after the state start + # event but is logged with event IDs coherent with state level fields. To adhere to this quirk, an evaluation + # frame from this point is created for the evaluation of Tolerance fields following the state start event. + frame: Environment = env.open_frame() + frame.states.reset(input_value=env.states.get_input()) + frame.stack = copy.deepcopy(env.stack) + + try: + # ItemsPath in DistributedMap states is only used if a JSONinput is passed from the previous state. + if ( + not isinstance(self.iteration_component, DistributedIterationComponent) + or self.item_reader is None + ): + if self.items_path: + self.items_path.eval(env=env) + + if self.items: + self.items.eval(env=env) + + if self.item_reader: + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapStateStarted, + event_details=EventDetails( + mapStateStartedEventDetails=MapStateStartedEventDetails(length=0) + ), + ) + input_items = None + else: + input_items = env.stack.pop() + # TODO: This should probably be raised within an Items EvalComponent + if not isinstance(input_items, list): + error_name = StatesErrorName(typ=StatesErrorNameType.StatesQueryEvaluationError) + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.EvaluationFailed, + event_details=EventDetails( + evaluationFailedEventDetails=EvaluationFailedEventDetails( + cause=f"Map state input must be an array but was: {type(input_items)}", + error=error_name.error_name, + ) + ), + ) + raise FailureEventException(failure_event=failure_event) + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapStateStarted, + event_details=EventDetails( + mapStateStartedEventDetails=MapStateStartedEventDetails( + length=len(input_items) + ) + ), + ) + + self.tolerated_failure_count_decl.eval(env=frame) + tolerated_failure_count = frame.stack.pop() + self.tolerated_failure_percentage_decl.eval(env=frame) + tolerated_failure_percentage = frame.stack.pop() + finally: + env.close_frame(frame) + + if isinstance(self.iteration_component, InlineIterator): + eval_input = InlineIteratorEvalInput( + state_name=self.name, + max_concurrency=max_concurrency_num, + input_items=input_items, + parameters=self.parameters, + item_selector=self.item_selector, + ) + elif isinstance(self.iteration_component, InlineItemProcessor): + eval_input = InlineItemProcessorEvalInput( + state_name=self.name, + max_concurrency=max_concurrency_num, + input_items=input_items, + item_selector=self.item_selector, + parameters=self.parameters, + ) + else: + map_run_record = MapRunRecord( + state_machine_arn=env.states.context_object.context_object_data["StateMachine"][ + "Id" + ], + execution_arn=env.states.context_object.context_object_data["Execution"]["Id"], + max_concurrency=max_concurrency_num, + tolerated_failure_count=tolerated_failure_count, + tolerated_failure_percentage=tolerated_failure_percentage, + label=label, + ) + env.map_run_record_pool_manager.add(map_run_record) + # Choose the distributed input type depending on whether the definition + # asks for the legacy Iterator component or an ItemProcessor + if isinstance(self.iteration_component, DistributedIterator): + distributed_eval_input_class = DistributedIteratorEvalInput + elif isinstance(self.iteration_component, DistributedItemProcessor): + distributed_eval_input_class = DistributedItemProcessorEvalInput + else: + raise RuntimeError( + f"Unknown iteration component of type '{type(self.iteration_component)}' '{self.iteration_component}'." + ) + eval_input = distributed_eval_input_class( + state_name=self.name, + max_concurrency=max_concurrency_num, + input_items=input_items, + parameters=self.parameters, + item_selector=self.item_selector, + item_reader=self.item_reader, + tolerated_failure_count=tolerated_failure_count, + tolerated_failure_percentage=tolerated_failure_percentage, + label=label, + map_run_record=map_run_record, + ) + + env.stack.append(eval_input) + self.iteration_component.eval(env) + + if self.result_writer: + self.result_writer.eval(env) + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapStateSucceeded, + update_source_event_id=False, + ) + + def _eval_state(self, env: Environment) -> None: + # Initialise the retry counter for execution states. + env.states.context_object.context_object_data["State"]["RetryCount"] = 0 + + # Attempt to evaluate the state's logic through until it's successful, caught, or retries have run out. + while env.is_running(): + try: + self._evaluate_with_timeout(env) + break + except Exception as ex: + failure_event: FailureEvent = self._from_error(env=env, ex=ex) + error_output = self._construct_error_output_value(failure_event=failure_event) + env.states.set_error_output(error_output) + env.states.set_result(error_output) + + if self.retry: + retry_outcome: RetryOutcome = self._handle_retry( + env=env, failure_event=failure_event + ) + if retry_outcome == RetryOutcome.CanRetry: + continue + + if failure_event.event_type != HistoryEventType.ExecutionFailed: + if ( + isinstance(ex, FailureEventException) + and failure_event.event_type == HistoryEventType.EvaluationFailed + ): + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.EvaluationFailed, + event_details=EventDetails( + evaluationFailedEventDetails=ex.get_evaluation_failed_event_details(), + ), + ) + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.MapStateFailed, + ) + + if self.catch: + self._handle_catch(env=env, failure_event=failure_event) + catch_outcome: CatchOutcome = env.stack[-1] + if catch_outcome == CatchOutcome.Caught: + break + + self._handle_uncaught(env=env, failure_event=failure_event) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/tolerated_failure.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/tolerated_failure.py new file mode 100644 index 0000000000000..c4284c388c402 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_map/tolerated_failure.py @@ -0,0 +1,198 @@ +import abc +from typing import Final + +from localstack.aws.api.stepfunctions import ExecutionFailedEventDetails, HistoryEventType +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, + StringSampler, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +TOLERATED_FAILURE_COUNT_MIN: Final[int] = 0 +TOLERATED_FAILURE_COUNT_DEFAULT: Final[int] = 0 +TOLERATED_FAILURE_PERCENTAGE_MIN: Final[float] = 0.0 +TOLERATED_FAILURE_PERCENTAGE_DEFAULT: Final[float] = 0.0 +TOLERATED_FAILURE_PERCENTAGE_MAX: Final[float] = 100.0 + + +class ToleratedFailureCountDecl(EvalComponent, abc.ABC): + @abc.abstractmethod + def _eval_tolerated_failure_count(self, env: Environment) -> int: ... + + def _eval_body(self, env: Environment) -> None: + tolerated_failure_count = self._eval_tolerated_failure_count(env=env) + env.stack.append(tolerated_failure_count) + + +class ToleratedFailureCountInt(ToleratedFailureCountDecl): + tolerated_failure_count: Final[int] + + def __init__(self, tolerated_failure_count: int = TOLERATED_FAILURE_COUNT_DEFAULT): + self.tolerated_failure_count = tolerated_failure_count + + def _eval_tolerated_failure_count(self, env: Environment) -> int: + return self.tolerated_failure_count + + +class ToleratedFailureCountStringJSONata(ToleratedFailureCountDecl): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _eval_tolerated_failure_count(self, env: Environment) -> int: + # TODO: add snapshot tests to verify AWS's behaviour about non integer values. + self.string_jsonata.eval(env=env) + failure_count: int = int(env.stack.pop()) + return failure_count + + +class ToleratedFailureCountPath(ToleratedFailureCountDecl): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def _eval_tolerated_failure_count(self, env: Environment) -> int: + self.string_sampler.eval(env=env) + tolerated_failure_count = env.stack.pop() + + if isinstance(tolerated_failure_count, str): + try: + tolerated_failure_count = int(tolerated_failure_count) + except Exception: + # Pass the invalid type forward for validation error + pass + + error_cause = None + if not isinstance(tolerated_failure_count, int): + value_str = ( + to_json_str(tolerated_failure_count) + if not isinstance(tolerated_failure_count, str) + else tolerated_failure_count + ) + error_cause = ( + f'The ToleratedFailureCountPath field refers to value "{value_str}" ' + f"which is not a valid integer: {self.string_sampler.literal_value}" + ) + + elif tolerated_failure_count < TOLERATED_FAILURE_COUNT_MIN: + error_cause = "ToleratedFailureCount cannot be negative." + + if error_cause is not None: + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=error_cause + ) + ), + ) + ) + + return tolerated_failure_count + + +class ToleratedFailurePercentageDecl(EvalComponent, abc.ABC): + @abc.abstractmethod + def _eval_tolerated_failure_percentage(self, env: Environment) -> float: ... + + def _eval_body(self, env: Environment) -> None: + tolerated_failure_percentage = self._eval_tolerated_failure_percentage(env=env) + env.stack.append(tolerated_failure_percentage) + + +class ToleratedFailurePercentage(ToleratedFailurePercentageDecl): + tolerated_failure_percentage: Final[float] + + def __init__(self, tolerated_failure_percentage: float = TOLERATED_FAILURE_PERCENTAGE_DEFAULT): + self.tolerated_failure_percentage = tolerated_failure_percentage + + def _eval_tolerated_failure_percentage(self, env: Environment) -> float: + return self.tolerated_failure_percentage + + +class ToleratedFailurePercentageStringJSONata(ToleratedFailurePercentageDecl): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _eval_tolerated_failure_percentage(self, env: Environment) -> float: + # TODO: add snapshot tests to verify AWS's behaviour about non floating values. + self.string_jsonata.eval(env=env) + failure_percentage: int = int(env.stack.pop()) + return failure_percentage + + +class ToleratedFailurePercentagePath(ToleratedFailurePercentageDecl): + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def _eval_tolerated_failure_percentage(self, env: Environment) -> float: + self.string_sampler.eval(env=env) + tolerated_failure_percentage = env.stack.pop() + + if isinstance(tolerated_failure_percentage, str): + try: + tolerated_failure_percentage = int(tolerated_failure_percentage) + except Exception: + # Pass the invalid type forward for validation error + pass + + if isinstance(tolerated_failure_percentage, int): + tolerated_failure_percentage = float(tolerated_failure_percentage) + + error_cause = None + if not isinstance(tolerated_failure_percentage, float): + value_str = ( + to_json_str(tolerated_failure_percentage) + if not isinstance(tolerated_failure_percentage, str) + else tolerated_failure_percentage + ) + error_cause = ( + f'The ToleratedFailurePercentagePath field refers to value "{value_str}" ' + f"which is not a valid float: {self.string_sampler.literal_value}" + ) + elif ( + not TOLERATED_FAILURE_PERCENTAGE_MIN + <= tolerated_failure_percentage + <= TOLERATED_FAILURE_PERCENTAGE_MAX + ): + error_cause = "ToleratedFailurePercentage must be between 0 and 100." + + if error_cause is not None: + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=error_cause + ) + ), + ) + ) + + return tolerated_failure_percentage diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/branch_worker.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/branch_worker.py new file mode 100644 index 0000000000000..51ef19322cf5e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/branch_worker.py @@ -0,0 +1,53 @@ +import abc +import logging +import threading +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import Timestamp +from localstack.services.stepfunctions.asl.component.program.program import Program +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.utils.threads import TMP_THREADS + +LOG = logging.getLogger(__name__) + + +class BranchWorker: + class BranchWorkerComm(abc.ABC): + @abc.abstractmethod + def on_terminated(self, env: Environment): ... + + _branch_worker_comm: Final[BranchWorkerComm] + _program: Final[Program] + _worker_thread: Optional[threading.Thread] + env: Final[Environment] + + def __init__(self, branch_worker_comm: BranchWorkerComm, program: Program, env: Environment): + self._branch_worker_comm = branch_worker_comm + self._program = program + self.env = env + self._worker_thread = None + + def _thread_routine(self) -> None: + LOG.info("[BranchWorker] [launched] [id: %s]", self._worker_thread.native_id) + self._program.eval(self.env) + LOG.info("[BranchWorker] [terminated] [id: %s]", self._worker_thread.native_id) + self._branch_worker_comm.on_terminated(env=self.env) + + def start(self): + if self._worker_thread is not None: + raise RuntimeError(f"Attempted to rerun BranchWorker for program ${self._program}.") + + self._worker_thread = threading.Thread( + target=self._thread_routine, name=f"BranchWorker_${self._program}", daemon=True + ) + TMP_THREADS.append(self._worker_thread) + self._worker_thread.start() + + def stop(self, stop_date: Timestamp, cause: Optional[str], error: Optional[str]) -> None: + env = self.env + if env: + try: + env.set_stop(stop_date, cause, error) + except Exception: + # Ignore closing exceptions, this method attempts to release resources earlier. + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/branches_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/branches_decl.py new file mode 100644 index 0000000000000..d9c268e776f66 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/branches_decl.py @@ -0,0 +1,113 @@ +import datetime +import threading +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ExecutionFailedEventDetails, HistoryEventType +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.program.program import Program +from localstack.services.stepfunctions.asl.component.state.state_execution.state_parallel.branch_worker import ( + BranchWorker, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.eval.program_state import ProgramError, ProgramState +from localstack.utils.collections import select_from_typed_dict + + +class BranchWorkerPool(BranchWorker.BranchWorkerComm): + _mutex: Final[threading.Lock] + _termination_event: Final[threading.Event] + _active_workers_num: int + + _terminated_with_error: Optional[ExecutionFailedEventDetails] + + def __init__(self, workers_num: int): + self._mutex = threading.Lock() + self._termination_event = threading.Event() + self._active_workers_num = workers_num + + self._terminated_with_error = None + + def on_terminated(self, env: Environment): + if self._termination_event.is_set(): + return + with self._mutex: + end_program_state: ProgramState = env.program_state() + if isinstance(end_program_state, ProgramError): + self._terminated_with_error = select_from_typed_dict( + typed_dict=ExecutionFailedEventDetails, obj=end_program_state.error or dict() + ) + self._termination_event.set() + else: + self._active_workers_num -= 1 + if self._active_workers_num == 0: + self._termination_event.set() + + def wait(self): + self._termination_event.wait() + + def get_exit_event_details(self) -> Optional[ExecutionFailedEventDetails]: + return self._terminated_with_error + + +class BranchesDecl(EvalComponent): + def __init__(self, programs: list[Program]): + self.programs: Final[list[Program]] = programs + + def _eval_body(self, env: Environment) -> None: + # Input value for every state_parallel process. + input_val = env.stack.pop() + + branch_worker_pool = BranchWorkerPool(workers_num=len(self.programs)) + + branch_workers: list[BranchWorker] = list() + for program in self.programs: + # Environment frame for this sub process. + env_frame: Environment = env.open_inner_frame() + env_frame.states.reset(input_value=input_val) + + # Launch the worker. + worker = BranchWorker( + branch_worker_comm=branch_worker_pool, program=program, env=env_frame + ) + branch_workers.append(worker) + + worker.start() + + branch_worker_pool.wait() + + # Propagate exception if parallel task failed. + exit_event_details: Optional[ExecutionFailedEventDetails] = ( + branch_worker_pool.get_exit_event_details() + ) + if exit_event_details is not None: + for branch_worker in branch_workers: + branch_worker.stop(stop_date=datetime.datetime.now(), cause=None, error=None) + env.close_frame(branch_worker.env) + + exit_error_name = exit_event_details.get("error") + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=CustomErrorName(error_name=exit_error_name), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails(executionFailedEventDetails=exit_event_details), + ) + ) + + # Collect the results and return. + result_list = list() + + for worker in branch_workers: + env_frame = worker.env + result_list.append(env_frame.states.get_input()) + env.close_frame(env_frame) + + env.stack.append(result_list) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/state_parallel.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/state_parallel.py new file mode 100644 index 0000000000000..ce7c5c42d4109 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_parallel/state_parallel.py @@ -0,0 +1,97 @@ +import copy +from typing import Optional + +from localstack.aws.api.stepfunctions import HistoryEventType +from localstack.services.stepfunctions.asl.component.common.catch.catch_outcome import CatchOutcome +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.parargs import Parargs +from localstack.services.stepfunctions.asl.component.common.retry.retry_outcome import RetryOutcome +from localstack.services.stepfunctions.asl.component.state.state_execution.execute_state import ( + ExecutionState, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_parallel.branches_decl import ( + BranchesDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StateParallel(ExecutionState): + # Branches (Required) + # An array of objects that specify state machines to execute in state_parallel. Each such state + # machine object must have fields named States and StartAt, whose meanings are exactly + # like those in the top level of a state machine. + branches: BranchesDecl + parargs: Optional[Parargs] + + def __init__(self): + super().__init__( + state_entered_event_type=HistoryEventType.ParallelStateEntered, + state_exited_event_type=HistoryEventType.ParallelStateExited, + ) + + def from_state_props(self, state_props: StateProps) -> None: + super(StateParallel, self).from_state_props(state_props) + self.branches = state_props.get( + typ=BranchesDecl, + raise_on_missing=ValueError(f"Missing Branches definition in props '{state_props}'."), + ) + self.parargs = state_props.get(Parargs) + + def _eval_execution(self, env: Environment) -> None: + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ParallelStateStarted, + ) + self.branches.eval(env) + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ParallelStateSucceeded, + update_source_event_id=False, + ) + + def _eval_state(self, env: Environment) -> None: + # Initialise the retry counter for execution states. + env.states.context_object.context_object_data["State"]["RetryCount"] = 0 + + # Compute the branches' input: if declared this is the parameters, else the current memory state. + if self.parargs is not None: + self.parargs.eval(env=env) + # In both cases, the inputs are copied by value to the branches, to avoid cross branch state manipulation, and + # cached to allow them to be resubmitted in case of failure. + input_value = copy.deepcopy(env.stack.pop()) + + # Attempt to evaluate the state's logic through until it's successful, caught, or retries have run out. + while env.is_running(): + try: + env.stack.append(input_value) + self._evaluate_with_timeout(env) + break + except FailureEventException as failure_event_ex: + failure_event: FailureEvent = self._from_error(env=env, ex=failure_event_ex) + error_output = self._construct_error_output_value(failure_event=failure_event) + env.states.set_error_output(error_output) + env.states.set_result(error_output) + + if self.retry is not None: + retry_outcome: RetryOutcome = self._handle_retry( + env=env, failure_event=failure_event + ) + if retry_outcome == RetryOutcome.CanRetry: + continue + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ParallelStateFailed, + ) + + if self.catch is not None: + self._handle_catch(env=env, failure_event=failure_event) + catch_outcome: CatchOutcome = env.stack[-1] + if catch_outcome == CatchOutcome.Caught: + break + + self._handle_uncaught(env=env, failure_event=failure_event) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/credentials.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/credentials.py new file mode 100644 index 0000000000000..6839dc1c64a97 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/credentials.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringExpression, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +@dataclass +class StateCredentials: + role_arn: str + + +class RoleArn(EvalComponent): + string_expression: Final[StringExpression] + + def __init__(self, string_expression: StringExpression): + self.string_expression = string_expression + + def _eval_body(self, env: Environment) -> None: + self.string_expression.eval(env=env) + + +class Credentials(EvalComponent): + role_arn: Final[RoleArn] + + def __init__(self, role_arn: RoleArn): + self.role_arn = role_arn + + def _eval_body(self, env: Environment) -> None: + self.role_arn.eval(env=env) + role_arn = env.stack.pop() + computes_credentials = StateCredentials(role_arn=role_arn) + env.stack.append(computes_credentials) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/lambda_eval_utils.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/lambda_eval_utils.py new file mode 100644 index 0000000000000..9f59414b844ab --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/lambda_eval_utils.py @@ -0,0 +1,102 @@ +import json +from json import JSONDecodeError +from typing import IO, Any, Final, Optional, Union + +from localstack.aws.api.lambda_ import InvocationResponse +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.mock_eval_utils import ( + eval_mocked_response, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.mocking.mock_config import MockedResponse +from localstack.utils.collections import select_from_typed_dict +from localstack.utils.strings import to_bytes + + +class LambdaFunctionErrorException(Exception): + function_error: Final[Optional[str]] + payload: Final[str] + + def __init__(self, function_error: Optional[str], payload: str): + self.function_error = function_error + self.payload = payload + + +def _from_payload(payload_streaming_body: IO[bytes]) -> Union[json, str]: + """ + This method extracts the lambda payload. The payload may be a string or a JSON stringified object. + In the first case, this function converts the output into a UTF-8 string, otherwise it parses the + JSON string into a JSON object. + """ + + payload_bytes: bytes = payload_streaming_body.read() + decoded_data: str = payload_bytes.decode("utf-8") + try: + json_data: json = json.loads(decoded_data) + return json_data + except (UnicodeDecodeError, json.JSONDecodeError): + return decoded_data + + +def _mocked_invoke_lambda_function(env: Environment) -> InvocationResponse: + mocked_response: MockedResponse = env.get_current_mocked_response() + eval_mocked_response(env=env, mocked_response=mocked_response) + invocation_resp: InvocationResponse = env.stack.pop() + return invocation_resp + + +def _invoke_lambda_function( + parameters: dict, region: str, state_credentials: StateCredentials +) -> InvocationResponse: + lambda_client = boto_client_for( + service="lambda", region=region, state_credentials=state_credentials + ) + + invocation_response: InvocationResponse = lambda_client.invoke(**parameters) + + payload = invocation_response["Payload"] + payload_json = _from_payload(payload) + invocation_response["Payload"] = payload_json + + return invocation_response + + +def execute_lambda_function_integration( + env: Environment, parameters: dict, region: str, state_credentials: StateCredentials +) -> None: + if env.is_mocked_mode(): + invocation_response: InvocationResponse = _mocked_invoke_lambda_function(env=env) + else: + invocation_response: InvocationResponse = _invoke_lambda_function( + parameters=parameters, region=region, state_credentials=state_credentials + ) + + function_error: Optional[str] = invocation_response.get("FunctionError") + if function_error: + payload_json = invocation_response["Payload"] + payload_str = json.dumps(payload_json, separators=(",", ":")) + raise LambdaFunctionErrorException(function_error, payload_str) + + response = select_from_typed_dict(typed_dict=InvocationResponse, obj=invocation_response) # noqa + env.stack.append(response) + + +def to_payload_type(payload: Any) -> Optional[bytes]: + if isinstance(payload, bytes): + return payload + + if payload is None: + str_value = to_json_str(dict()) + elif isinstance(payload, str): + try: + json.loads(payload) + str_value = payload + except JSONDecodeError: + str_value = to_json_str(payload) + else: + str_value = to_json_str(payload) + return to_bytes(str_value) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/mock_eval_utils.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/mock_eval_utils.py new file mode 100644 index 0000000000000..aa8a9c423f433 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/mock_eval_utils.py @@ -0,0 +1,45 @@ +import copy + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.mocking.mock_config import ( + MockedResponse, + MockedResponseReturn, + MockedResponseThrow, +) + + +def _eval_mocked_response_throw(env: Environment, mocked_response: MockedResponseThrow) -> None: + task_failed_event_details = TaskFailedEventDetails( + error=mocked_response.error, cause=mocked_response.cause + ) + error_name = CustomErrorName(mocked_response.error) + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails(taskFailedEventDetails=task_failed_event_details), + ) + raise FailureEventException(failure_event=failure_event) + + +def _eval_mocked_response_return(env: Environment, mocked_response: MockedResponseReturn) -> None: + payload_copy = copy.deepcopy(mocked_response.payload) + env.stack.append(payload_copy) + + +def eval_mocked_response(env: Environment, mocked_response: MockedResponse) -> None: + if isinstance(mocked_response, MockedResponseReturn): + _eval_mocked_response_return(env=env, mocked_response=mocked_response) + elif isinstance(mocked_response, MockedResponseThrow): + _eval_mocked_response_throw(env=env, mocked_response=mocked_response) + else: + raise RuntimeError(f"Invalid MockedResponse type '{type(mocked_response)}'") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/resource.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/resource.py new file mode 100644 index 0000000000000..ce1d4288d5a5c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/resource.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import abc +from itertools import takewhile +from typing import Final, Optional + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class ResourceCondition(str): + WaitForTaskToken = "waitForTaskToken" + Sync2 = "sync:2" + Sync = "sync" + + +class ResourceARN: + arn: str + partition: str + service: str + region: str + account: str + task_type: str + name: str + option: str + + def __init__( + self, + arn: str, + partition: str, + service: str, + region: str, + account: str, + task_type: str, + name: str, + option: Optional[str], + ): + self.arn = arn + self.partition = partition + self.service = service + self.region = region + self.account = account + self.task_type = task_type + self.name = name + self.option = option + + @staticmethod + def _consume_until(text: str, symbol: str) -> tuple[str, str]: + value = "".join(takewhile(lambda c: c != symbol, text)) + tail_idx = len(value) + 1 + return value, text[tail_idx:] + + @classmethod + def from_arn(cls, arn: str) -> ResourceARN: + _, arn_tail = ResourceARN._consume_until(arn, ":") + partition, arn_tail = ResourceARN._consume_until(arn_tail, ":") + service, arn_tail = ResourceARN._consume_until(arn_tail, ":") + region, arn_tail = ResourceARN._consume_until(arn_tail, ":") + account, arn_tail = ResourceARN._consume_until(arn_tail, ":") + task_type, arn_tail = ResourceARN._consume_until(arn_tail, ":") + name, arn_tail = ResourceARN._consume_until(arn_tail, ".") + option = arn_tail + return cls( + arn=arn, + partition=partition, + service=service, + region=region, + account=account, + task_type=task_type, + name=name, + option=option, + ) + + +class ResourceRuntimePart: + account: Final[str] + region: Final[str] + + def __init__(self, account: str, region: str): + self.region = region + self.account = account + + +class Resource(EvalComponent, abc.ABC): + _region: Final[str] + _account: Final[str] + resource_arn: Final[str] + partition: Final[str] + + def __init__(self, resource_arn: ResourceARN): + self._region = resource_arn.region + self._account = resource_arn.account + self.resource_arn = resource_arn.arn + self.partition = resource_arn.partition + + @staticmethod + def from_resource_arn(arn: str) -> Resource: + resource_arn = ResourceARN.from_arn(arn) + match resource_arn.service, resource_arn.task_type: + case "lambda", "function": + return LambdaResource(resource_arn=resource_arn) + case "states", "activity": + return ActivityResource(resource_arn=resource_arn) + case "states", _: + return ServiceResource(resource_arn=resource_arn) + + def _eval_runtime_part(self, env: Environment) -> ResourceRuntimePart: + region = self._region if self._region else env.aws_execution_details.region + account = self._account if self._account else env.aws_execution_details.account + return ResourceRuntimePart( + account=account, + region=region, + ) + + def _eval_body(self, env: Environment) -> None: + runtime_part = self._eval_runtime_part(env=env) + env.stack.append(runtime_part) + + +class ActivityResource(Resource): + name: Final[str] + + def __init__(self, resource_arn: ResourceARN): + super().__init__(resource_arn=resource_arn) + self.name = resource_arn.name + + +class LambdaResource(Resource): + function_name: Final[str] + + def __init__(self, resource_arn: ResourceARN): + super().__init__(resource_arn=resource_arn) + self.function_name = resource_arn.name + + +class ServiceResource(Resource): + service_name: Final[str] + api_name: Final[str] + api_action: Final[str] + condition: Final[Optional[str]] + + def __init__(self, resource_arn: ResourceARN): + super().__init__(resource_arn=resource_arn) + self.service_name = resource_arn.task_type + + name_parts = resource_arn.name.split(":") + if len(name_parts) == 1: + self.api_name = self.service_name + self.api_action = resource_arn.name + elif len(name_parts) > 1: + self.api_name = name_parts[0] + self.api_action = name_parts[1] + else: + raise RuntimeError(f"Incorrect definition of ResourceArn.name: '{resource_arn.name}'.") + + self.condition = None + option = resource_arn.option + if option: + match option: + case ResourceCondition.WaitForTaskToken: + self.condition = ResourceCondition.WaitForTaskToken + case "sync": + self.condition = ResourceCondition.Sync + case "sync:2": + self.condition = ResourceCondition.Sync2 + case unsupported: + raise RuntimeError(f"Unsupported condition '{unsupported}'.") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service.py new file mode 100644 index 0000000000000..c385368c25dc2 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import abc +import copy +import json +import logging +from typing import Any, Final, Optional, Union + +from botocore.model import ListShape, OperationModel, Shape, StringShape, StructureShape +from botocore.response import StreamingBody + +from localstack.aws.api.stepfunctions import ( + HistoryEventExecutionDataDetails, + HistoryEventType, + TaskCredentials, + TaskFailedEventDetails, + TaskScheduledEventDetails, + TaskStartedEventDetails, + TaskSucceededEventDetails, + TaskTimedOutEventDetails, +) +from localstack.aws.spec import load_service +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.mock_eval_utils import ( + eval_mocked_response, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceRuntimePart, + ServiceResource, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.state_task import ( + StateTask, +) +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.mocking.mock_config import MockedResponse +from localstack.services.stepfunctions.quotas import is_within_size_quota +from localstack.utils.strings import camel_to_snake_case, snake_to_camel_case, to_bytes, to_str + +LOG = logging.getLogger(__name__) + + +class StateTaskService(StateTask, abc.ABC): + resource: ServiceResource + + _SERVICE_NAME_SFN_TO_BOTO_OVERRIDES: Final[dict[str, str]] = { + "sfn": "stepfunctions", + "states": "stepfunctions", + } + + def from_state_props(self, state_props: StateProps) -> None: + super().from_state_props(state_props=state_props) + # Validate the service integration is supported on program creation. + self._validate_service_integration_is_supported() + + def _validate_service_integration_is_supported(self): + # Validate the service integration is supported. + supported_parameters = self._get_supported_parameters() + if supported_parameters is None: + raise ValueError( + f"The resource provided {self.resource.resource_arn} not recognized. " + "The value is not a valid resource ARN, or the resource is not available in this region." + ) + + def _get_sfn_resource(self) -> str: + return self.resource.api_action + + def _get_sfn_resource_type(self) -> str: + return self.resource.service_name + + def _get_timed_out_failure_event(self, env: Environment) -> FailureEvent: + return FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTimeout), + event_type=HistoryEventType.TaskTimedOut, + event_details=EventDetails( + taskTimedOutEventDetails=TaskTimedOutEventDetails( + resourceType=self._get_sfn_resource_type(), + resource=self._get_sfn_resource(), + error=StatesErrorNameType.StatesTimeout.to_name(), + ) + ), + ) + + @staticmethod + def _get_boto_operation_model( + boto_service_name: str, service_action_name: str + ) -> OperationModel: + norm_service_action_name = camel_to_snake_case(service_action_name) + + service = load_service(service=boto_service_name) + + boto_operation_names = { + camel_to_snake_case(operation_name): operation_name + for operation_name in service.operation_names + } # noqa + boto_operation_name = boto_operation_names.get(norm_service_action_name) + if boto_operation_name is None: + raise RuntimeError( + f"No api action named '{service_action_name}' available for service '{boto_service_name}'." + ) + + operation_model = service.operation_model(boto_operation_name) + return operation_model + + def _to_boto_request_value(self, request_value: Any, value_shape: Shape) -> Any: + boto_request_value = request_value + if isinstance(value_shape, StructureShape): + self._to_boto_request(request_value, value_shape) + elif isinstance(value_shape, ListShape) and isinstance(request_value, list): + for request_list_value in request_value: + self._to_boto_request_value(request_list_value, value_shape.member) # noqa + elif isinstance(value_shape, StringShape) and not isinstance(request_value, str): + boto_request_value = to_json_str(request_value) + elif value_shape.type_name == "blob" and not isinstance(boto_request_value, bytes): + boto_request_value = to_json_str(request_value, separators=(",", ":")) + boto_request_value = to_bytes(boto_request_value) + return boto_request_value + + def _to_boto_request(self, parameters: dict, structure_shape: StructureShape) -> None: + if not isinstance(structure_shape, StructureShape): + LOG.warning( + "Step Functions could not normalise the request for integration '%s' due to the unexpected request template value of type '%s'", + self.resource.resource_arn, + type(structure_shape), + ) + return + shape_members = structure_shape.members + norm_member_binds: dict[str, tuple[str, StructureShape]] = { + camel_to_snake_case(member_key): (member_key, member_value) + for member_key, member_value in shape_members.items() + } + parameters_bind_keys: list[str] = list(parameters.keys()) + for parameter_key in parameters_bind_keys: + norm_parameter_key = camel_to_snake_case(parameter_key) + norm_member_bind: Optional[tuple[str, Optional[StructureShape]]] = ( + norm_member_binds.get(norm_parameter_key) + ) + if norm_member_bind is not None: + norm_member_bind_key, norm_member_bind_shape = norm_member_bind + parameter_value = parameters.pop(parameter_key) + parameter_value = self._to_boto_request_value( + parameter_value, norm_member_bind_shape + ) + parameters[norm_member_bind_key] = parameter_value + + @staticmethod + def _to_sfn_cased(member_key: str) -> str: + # Normalise the string to snake case, e.g. "HelloWorld_hello__world" -> "hello_world_hello_world" + norm_member_key = camel_to_snake_case(member_key) + # Normalise the snake case to camel case, e.g. "hello_world_hello_world" -> "HelloWorldHelloWorld" + norm_member_key = snake_to_camel_case(norm_member_key) + return norm_member_key + + @staticmethod + def _from_boto_response_value(response_value: Any) -> Any: + if isinstance(response_value, StreamingBody): + body_str = to_str(response_value.read()) + return body_str + return response_value + + def _from_boto_response(self, response: Any, structure_shape: StructureShape) -> None: + if not isinstance(response, dict): + return + + if not isinstance(structure_shape, StructureShape): + LOG.warning( + "Step Functions could not normalise the response of integration '%s' due to the unexpected request template value of type '%s'", + self.resource.resource_arn, + type(structure_shape), + ) + return + + shape_members = structure_shape.members + response_bind_keys: list[str] = list(response.keys()) + for response_key in response_bind_keys: + norm_response_key = self._to_sfn_cased(response_key) + if response_key in shape_members: + shape_member = shape_members[response_key] + + response_value = response.pop(response_key) + response_value = self._from_boto_response_value(response_value) + + if isinstance(shape_member, StructureShape): + self._from_boto_response(response_value, shape_member) + elif isinstance(shape_member, ListShape) and isinstance(response_value, list): + for response_value_member in response_value: + self._from_boto_response(response_value_member, shape_member.member) # noqa + + response[norm_response_key] = response_value + + def _get_boto_service_name(self, boto_service_name: Optional[str] = None) -> str: + api_name = boto_service_name or self.resource.api_name + return self._SERVICE_NAME_SFN_TO_BOTO_OVERRIDES.get(api_name, api_name) + + def _get_boto_service_action(self, service_action_name: Optional[str] = None) -> str: + api_action = service_action_name or self.resource.api_action + return camel_to_snake_case(api_action) + + def _normalise_parameters( + self, + parameters: dict, + boto_service_name: Optional[str] = None, + service_action_name: Optional[str] = None, + ) -> None: + boto_service_name = self._get_boto_service_name(boto_service_name=boto_service_name) + service_action_name = self._get_boto_service_action(service_action_name=service_action_name) + input_shape = self._get_boto_operation_model( + boto_service_name=boto_service_name, service_action_name=service_action_name + ).input_shape + if input_shape is not None: + self._to_boto_request(parameters, input_shape) # noqa + + def _normalise_response( + self, + response: Any, + boto_service_name: Optional[str] = None, + service_action_name: Optional[str] = None, + ) -> None: + boto_service_name = self._get_boto_service_name(boto_service_name=boto_service_name) + service_action_name = self._get_boto_service_action(service_action_name=service_action_name) + output_shape = self._get_boto_operation_model( + boto_service_name=boto_service_name, service_action_name=service_action_name + ).output_shape + if output_shape is not None: + self._from_boto_response(response, output_shape) # noqa + + def _verify_size_quota(self, env: Environment, value: Union[str, json]) -> None: + is_within: bool = is_within_size_quota(value) + if is_within: + return + resource_type = self._get_sfn_resource_type() + resource = self._get_sfn_resource() + cause = ( + f"The state/task '{resource_type}' returned a result with a size " + "exceeding the maximum number of bytes service limit." + ) + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesStatesDataLimitExceeded), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=StatesErrorNameType.StatesStatesDataLimitExceeded.to_name(), + cause=cause, + resourceType=resource_type, + resource=resource, + ) + ), + ) + ) + + @abc.abstractmethod + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): ... + + def _before_eval_execution( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + raw_parameters: dict, + state_credentials: StateCredentials, + ) -> None: + parameters_str = to_json_str(raw_parameters) + + scheduled_event_details = TaskScheduledEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + region=resource_runtime_part.region, + parameters=parameters_str, + ) + if not self.timeout.is_default_value(): + self.timeout.eval(env=env) + timeout_seconds = env.stack.pop() + scheduled_event_details["timeoutInSeconds"] = timeout_seconds + if self.heartbeat is not None: + self.heartbeat.eval(env=env) + heartbeat_seconds = env.stack.pop() + scheduled_event_details["heartbeatInSeconds"] = heartbeat_seconds + if self.credentials: + scheduled_event_details["taskCredentials"] = TaskCredentials( + roleArn=state_credentials.role_arn + ) + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.TaskScheduled, + event_details=EventDetails(taskScheduledEventDetails=scheduled_event_details), + ) + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.TaskStarted, + event_details=EventDetails( + taskStartedEventDetails=TaskStartedEventDetails( + resource=self._get_sfn_resource(), resourceType=self._get_sfn_resource_type() + ) + ), + ) + + def _after_eval_execution( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> None: + output = env.stack[-1] + self._verify_size_quota(env=env, value=output) + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.TaskSucceeded, + event_details=EventDetails( + taskSucceededEventDetails=TaskSucceededEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + output=to_json_str(output), + outputDetails=HistoryEventExecutionDataDetails(truncated=False), + ) + ), + ) + + def _eval_execution(self, env: Environment) -> None: + self.resource.eval(env=env) + resource_runtime_part: ResourceRuntimePart = env.stack.pop() + + raw_parameters = self._eval_parameters(env=env) + state_credentials = self._eval_state_credentials(env=env) + + self._before_eval_execution( + env=env, + resource_runtime_part=resource_runtime_part, + raw_parameters=raw_parameters, + state_credentials=state_credentials, + ) + + normalised_parameters = copy.deepcopy(raw_parameters) + self._normalise_parameters(normalised_parameters) + + if env.is_mocked_mode(): + mocked_response: MockedResponse = env.get_current_mocked_response() + eval_mocked_response(env=env, mocked_response=mocked_response) + else: + self._eval_service_task( + env=env, + resource_runtime_part=resource_runtime_part, + normalised_parameters=normalised_parameters, + state_credentials=state_credentials, + ) + + output_value = env.stack[-1] + self._normalise_response(output_value) + + self._after_eval_execution( + env=env, + resource_runtime_part=resource_runtime_part, + normalised_parameters=normalised_parameters, + state_credentials=state_credentials, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_api_gateway.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_api_gateway.py new file mode 100644 index 0000000000000..b4d8c660a8f81 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_api_gateway.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import http +import json +import logging +from json import JSONDecodeError +from typing import Any, Final, Optional, TypedDict +from urllib.parse import urlencode, urljoin, urlparse + +import requests +from requests import Response + +from localstack import config +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.constants import ( + APPLICATION_JSON, + HEADER_CONTENT_TYPE, + PATH_USER_REQUEST, +) +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.utils.collections import select_from_typed_dict +from localstack.utils.strings import long_uid +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, +} + +ApiEndpoint = str +Headers = dict +Stage = str +Path = str +QueryParameters = dict +RequestBody = dict | str +ResponseBody = dict | str +StatusCode = int +StatusText = str +AllowNullValues = bool + + +class Method(str): + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + PATCH = "PATCH" + HEAD = "HEAD" + OPTIONS = "OPTIONS" + + +class AuthType(str): + NO_AUTH = "NO_AUTH" + IAM_ROLE = "IAM_ROLE" + RESOURCE_POLICY = "RESOURCE_POLICY" + + +class TaskParameters(TypedDict): + ApiEndpoint: ApiEndpoint + Method: Method + Headers: Optional[Headers] + Stage: Optional[Stage] + Path: Optional[Path] + QueryParameters: Optional[QueryParameters] + RequestBody: Optional[RequestBody] + AllowNullValues: Optional[AllowNullValues] + AuthType: Optional[AuthType] + + +class InvokeOutput(TypedDict): + Headers: Headers + ResponseBody: ResponseBody + StatusCode: StatusCode + StatusText: StatusText + + +class SupportedApiCalls(str): + invoke = "invoke" + + +class SfnGatewayException(Exception): + parameters: Final[TaskParameters] + response: Final[Response] + + def __init__(self, parameters: TaskParameters, response: Response): + self.parameters = parameters + self.response = response + + +class StateTaskServiceApiGateway(StateTaskServiceCallback): + _SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + SupportedApiCalls.invoke: set(TaskParameters.__required_keys__) # noqa + } + + _FORBIDDEN_HTTP_HEADERS_PREFIX: Final[set[str]] = {"X-Forwarded", "X-Amz", "X-Amzn"} + _FORBIDDEN_HTTP_HEADERS: Final[set[str]] = { + "Authorization", + "Connection", + "Content-md5", + "Expect", + "Host", + "Max-Forwards", + "Proxy-Authenticate", + "Server", + "TE", + "Transfer-Encoding", + "Trailer", + "Upgrade", + "Via", + "Www-Authenticate", + } + + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return self._SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + def _normalise_parameters( + self, + parameters: dict, + boto_service_name: Optional[str] = None, + service_action_name: Optional[str] = None, + ) -> None: + # ApiGateway does not support botocore request relay. + pass + + def _normalise_response( + self, + response: Any, + boto_service_name: Optional[str] = None, + service_action_name: Optional[str] = None, + ) -> None: + # ApiGateway does not support botocore request relay. + pass + + @staticmethod + def _query_parameters_of(parameters: TaskParameters) -> Optional[str]: + query_str = None + query_parameters = parameters.get("QueryParameters") + # TODO: add support for AllowNullValues. + if query_parameters is not None: + for key, value in list(query_parameters.items()): + if value: + query_parameters[key] = value[-1] + else: + query_parameters[key] = "" + query_str = f"?{urlencode(query_parameters)}" + return query_str + + @staticmethod + def _headers_of(parameters: TaskParameters) -> Optional[dict]: + headers = parameters.get("Headers", dict()) + if headers: + for key in headers.keys(): + # TODO: the following check takes place at parse time. + if key in StateTaskServiceApiGateway._FORBIDDEN_HTTP_HEADERS: + raise ValueError(f"The 'Headers' field contains unsupported values: {key}") + for forbidden_prefix in StateTaskServiceApiGateway._FORBIDDEN_HTTP_HEADERS_PREFIX: + if key.startswith(forbidden_prefix): + raise ValueError(f"The 'Headers' field contains unsupported values: {key}") + + value = headers.get(key) + if isinstance(value, list): + headers[key] = f"[{','.join(value)}]" + + if "RequestBody" in parameters: + headers[HEADER_CONTENT_TYPE] = APPLICATION_JSON + headers["Accept"] = APPLICATION_JSON + return headers + + @staticmethod + def _path_based_url_of(api_endpoint: ApiEndpoint) -> ApiEndpoint: + # Attempts to convert an url based api endpoint: + # .execute-api.<-region->.localhost.localstack.cloud + # To a path based: + # http://localhost:4566/restapis/ + # TODO: this heavily normalises url based api endpoints to path endpoint. + # there's an argument to be made that this may mast implementation mistakes: investigate further. + url_spec = urlparse(api_endpoint) + url_path = url_spec.path + if not url_path.endswith(localstack_host().host): + return api_endpoint + path_parts = url_path.split(".") + api_id = path_parts[0] + path_based_api_endpoint = f"{config.internal_service_url()}/restapis/{api_id}" + return path_based_api_endpoint + + @staticmethod + def _invoke_url_of(parameters: TaskParameters) -> str: + given_api_endpoint = parameters["ApiEndpoint"] + api_endpoint = StateTaskServiceApiGateway._path_based_url_of(given_api_endpoint) + if given_api_endpoint != api_endpoint: + LOG.warning( + "ApiEndpoint '%s' ignored in favour of %s", + given_api_endpoint, + api_endpoint, + ) + + url_base = api_endpoint + "/" + # http://localhost:4566/restapis///_user_request_/(?)? + url_tail = "/".join( + [ + parameters.get("Stage", ""), + PATH_USER_REQUEST, + parameters.get("Path", ""), + StateTaskServiceApiGateway._query_parameters_of(parameters) or "", + ] + ) + invoke_url = urljoin(url_base, url_tail) + return invoke_url + + @staticmethod + def _invoke_output_of(response: Response) -> InvokeOutput: + status_code = response.status_code + status_text = http.HTTPStatus(status_code).phrase + + headers = dict(response.headers) + + try: + response_body = response.json() + except JSONDecodeError: + response_body = response.text + if response_body == json.dumps(dict()): + response_body = dict() + + # since we are not using a case-insensitive dict, and we want to remove a header, for server + # compatibility we should consider both casing variants + headers.pop("server", None) + headers.pop("Server", None) + if "date" in headers: + headers["Date"] = [headers.pop("date")] + headers[HEADER_CONTENT_TYPE] = [APPLICATION_JSON] + headers["Content-Length"] = [headers["Content-Length"]] + # TODO: add support for the following generated fields. + headers["Connection"] = ["keep-alive"] + headers["x-amz-apigw-id"] = [long_uid()] + headers["X-Amz-Cf-Id"] = [long_uid()] + headers["X-Amz-Cf-Pop"] = [long_uid()] + headers["x-amzn-RequestId"] = [long_uid()] + headers["X-Amzn-Trace-Id"] = [long_uid()] + headers["X-Cache"] = ["Miss from cloudfront"] + headers["Via"] = ["UNSUPPORTED"] + + return InvokeOutput( + Headers=headers, + ResponseBody=response_body, + StatusCode=status_code, + StatusText=status_text, + ) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, SfnGatewayException): + error_name = f"ApiGateway.{ex.response.status_code}" + cause = ex.response.text + else: + ex_name = ex.__class__.__name__ + error_name = f"ApiGateway.{ex_name}" + cause = str(ex) + return FailureEvent( + env=env, + error_name=CustomErrorName(error_name), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=error_name, + cause=cause, # TODO: add support for cause decoration. + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + # TODO: add support for task credentials + + task_parameters: TaskParameters = select_from_typed_dict( + typed_dict=TaskParameters, obj=normalised_parameters + ) + + method = task_parameters["Method"] + invoke_url = self._invoke_url_of(task_parameters) + headers = self._headers_of(task_parameters) + json_data = task_parameters.get("RequestBody") + + # RequestBody is only supported for PATCH, POST, and PUT + if json_data is not None and method not in {Method.PATCH, Method.POST, Method.PUT}: + raise ValueError() # TODO + + response: Response = getattr(requests, method.lower())( + invoke_url, headers=headers, json=json_data + ) + + if response.status_code != 200: + raise SfnGatewayException(parameters=task_parameters, response=response) + + invoke_output = self._invoke_output_of(response) + env.stack.append(invoke_output) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_aws_sdk.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_aws_sdk.py new file mode 100644 index 0000000000000..aff2642e29710 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_aws_sdk.py @@ -0,0 +1,138 @@ +import logging +from typing import Final + +from botocore.exceptions import ClientError, UnknownServiceError + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.aws.spec import get_service_catalog +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for + +LOG = logging.getLogger(__name__) + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, +} + +# Defines bindings of lower-cased service names to the StepFunctions service name included in error messages. +_SERVICE_ERROR_NAMES = {"dynamodb": "DynamoDb", "sfn": "Sfn"} + + +class StateTaskServiceAwsSdk(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _validate_service_integration_is_supported(self): + # As no aws-sdk support catalog is available, allow invalid aws-sdk integration to fail at runtime. + pass + + def _get_sfn_resource_type(self) -> str: + return f"{self.resource.service_name}:{self.resource.api_name}" + + @staticmethod + def _normalise_service_error_name(service_name: str) -> str: + # Computes the normalised service error name for the given service. + + # Return the explicit binding if one exists. + service_name_lower = service_name.lower() + if service_name_lower in _SERVICE_ERROR_NAMES: + return _SERVICE_ERROR_NAMES[service_name_lower] + + # Attempt to retrieve the service name from the catalog. + try: + service_model = get_service_catalog().get(service_name) + if service_model is not None: + sfn_normalised_service_name = service_model.service_id.replace(" ", "") + return sfn_normalised_service_name + except UnknownServiceError: + LOG.warning( + "No service for name '%s' when building aws-sdk service error name.", + service_name, + ) + + # Revert to returning the resource's service name and log the missing binding. + LOG.error( + "No normalised service error name for aws-sdk integration was found for service: '%s'", + service_name, + ) + return service_name + + @staticmethod + def _normalise_exception_name(norm_service_name: str, ex: Exception) -> str: + ex_name = ex.__class__.__name__ + norm_ex_name = ( + f"{norm_service_name}.{norm_service_name if ex_name == 'ClientError' else ex_name}" + ) + if not norm_ex_name.endswith("Exception"): + norm_ex_name += "Exception" + return norm_ex_name + + def _get_task_failure_event(self, env: Environment, error: str, cause: str) -> FailureEvent: + return FailureEvent( + env=env, + error_name=ErrorName(error_name=error), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + error=error, + cause=cause, + ) + ), + ) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, ClientError): + norm_service_name: str = self._normalise_service_error_name(self.resource.api_name) + error: str = self._normalise_exception_name(norm_service_name, ex) + + error_message: str = ex.response["Error"]["Message"] + cause_details = [ + f"Service: {norm_service_name}", + f"Status Code: {ex.response['ResponseMetadata']['HTTPStatusCode']}", + f"Request ID: {ex.response['ResponseMetadata']['RequestId']}", + ] + if "HostId" in ex.response["ResponseMetadata"]: + cause_details.append( + f"Extended Request ID: {ex.response['ResponseMetadata']['HostId']}" + ) + + cause: str = f"{error_message} ({', '.join(cause_details)})" + failure_event = self._get_task_failure_event(env=env, error=error, cause=cause) + return failure_event + return super()._from_error(env=env, ex=ex) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + api_client = boto_client_for( + service=service_name, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + response = getattr(api_client, api_action)(**normalised_parameters) or dict() + if response: + response.pop("ResponseMetadata", None) + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_batch.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_batch.py new file mode 100644 index 0000000000000..bc83e1f327121 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_batch.py @@ -0,0 +1,200 @@ +from typing import Any, Callable, Final, Optional + +from botocore.exceptions import ClientError +from moto.batch.utils import JobStatus + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, + ResourceCondition.Sync, +} + +_BATCH_JOB_TERMINATION_STATUS_SET: Final[set[JobStatus]] = {JobStatus.SUCCEEDED, JobStatus.FAILED} + +_ENVIRONMENT_VARIABLE_MANAGED_BY_AWS: Final[str] = "MANAGED_BY_AWS" +_ENVIRONMENT_VARIABLE_MANAGED_BY_AWS_VALUE: Final[str] = "STARTED_BY_STEP_FUNCTIONS" + +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "submitjob": { + "ArrayProperties", + "ContainerOverrides", + "DependsOn", + "JobDefinition", + "JobName", + "JobQueue", + "Parameters", + "RetryStrategy", + "Timeout", + "Tags", + } +} + + +class StateTaskServiceBatch(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + @staticmethod + def _attach_aws_environment_variables(parameters: dict) -> None: + # Attaches to the ContainerOverrides environment variables the AWS managed flags. + container_overrides = parameters.get("ContainerOverrides") + if container_overrides is None: + container_overrides = dict() + parameters["ContainerOverrides"] = container_overrides + + environment = container_overrides.get("Environment") + if environment is None: + environment = list() + container_overrides["Environment"] = environment + + environment.append( + { + "name": _ENVIRONMENT_VARIABLE_MANAGED_BY_AWS, + "value": _ENVIRONMENT_VARIABLE_MANAGED_BY_AWS_VALUE, + } + ) + + def _before_eval_execution( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + raw_parameters: dict, + state_credentials: StateCredentials, + ) -> None: + if self.resource.condition == ResourceCondition.Sync: + self._attach_aws_environment_variables(parameters=raw_parameters) + super()._before_eval_execution( + env=env, + resource_runtime_part=resource_runtime_part, + raw_parameters=raw_parameters, + state_credentials=state_credentials, + ) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, ClientError): + error_code = ex.response["Error"]["Code"] + error_name = f"Batch.{error_code}" + status_code = ex.response["ResponseMetadata"]["HTTPStatusCode"] + error_message = ex.response["Error"]["Message"] + request_id = ex.response["ResponseMetadata"]["RequestId"] + response_details = "; ".join( + [ + "Service: AWSBatch", + f"Status Code: {status_code}", + f"Error Code: {error_code}", + f"Request ID: {request_id}", + "Proxy: null", + ] + ) + cause = f"Error executing request, Exception : {error_message}, RequestId: {request_id} ({response_details})" + return FailureEvent( + env=env, + error_name=CustomErrorName(error_name), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=error_name, + cause=cause, + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + return super()._from_error(env=env, ex=ex) + + def _build_sync_resolver( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + batch_client = boto_client_for( + service="batch", + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + submission_output: dict = env.stack.pop() + job_id = submission_output["JobId"] + + def _sync_resolver() -> Optional[dict]: + describe_jobs_response = batch_client.describe_jobs(jobs=[job_id]) + describe_jobs = describe_jobs_response["jobs"] + if describe_jobs: + describe_job = describe_jobs[0] + describe_job_status: JobStatus = describe_job["status"] + # Add raise error if abnormal state + if describe_job_status in _BATCH_JOB_TERMINATION_STATUS_SET: + self._normalise_response( + response=describe_jobs_response, service_action_name="describe_jobs" + ) + if describe_job_status == JobStatus.SUCCEEDED: + return describe_job + + raise FailureEventException( + FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTaskFailed), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + error=StatesErrorNameType.StatesTaskFailed.to_name(), + cause=to_json_str(describe_job), + ) + ), + ) + ) + return None + + return _sync_resolver + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + batch_client = boto_client_for( + region=resource_runtime_part.region, + service=service_name, + state_credentials=state_credentials, + ) + response = getattr(batch_client, api_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_callback.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_callback.py new file mode 100644 index 0000000000000..bed6e8b78fdd5 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_callback.py @@ -0,0 +1,360 @@ +import abc +import json +import threading +import time +from typing import Any, Callable, Final, Optional + +from localstack.aws.api.stepfunctions import ( + HistoryEventExecutionDataDetails, + HistoryEventType, + TaskFailedEventDetails, + TaskSubmittedEventDetails, +) +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service import ( + StateTaskService, +) +from localstack.services.stepfunctions.asl.eval.callback.callback import ( + CallbackEndpoint, + CallbackOutcome, + CallbackOutcomeFailure, + CallbackOutcomeFailureError, + CallbackOutcomeSuccess, + CallbackOutcomeTimedOut, + CallbackTimeoutError, + HeartbeatEndpoint, + HeartbeatTimedOut, + HeartbeatTimeoutError, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.utils.threads import TMP_THREADS + +# TODO: consider implementing a polling pattern similar to that observable from AWS: +# https://repost.aws/questions/QUFFlHcbvIQFe-bS3RAi7TWA/a-glue-job-in-a-step-function-is-taking-so-long-to-continue-the-next-step +_DELAY_SECONDS_SYNC_CONDITION_CHECK: Final[float] = 0.5 + + +class StateTaskServiceCallback(StateTaskService, abc.ABC): + _supported_integration_patterns: Final[set[ResourceCondition]] + + def __init__(self, supported_integration_patterns: set[ResourceCondition]): + super().__init__() + self._supported_integration_patterns = supported_integration_patterns + + def _get_sfn_resource(self) -> str: + resource = super()._get_sfn_resource() + if self.resource.condition is not None: + resource += f".{self.resource.condition}" + return resource + + def _build_sync_resolver( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + raise RuntimeError( + f"Unsupported .sync callback procedure in resource {self.resource.resource_arn}" + ) + + def _build_sync2_resolver( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + raise RuntimeError( + f"Unsupported .sync2 callback procedure in resource {self.resource.resource_arn}" + ) + + def _eval_wait_for_task_token( + self, + env: Environment, + timeout_seconds: int, + callback_endpoint: CallbackEndpoint, + heartbeat_endpoint: Optional[HeartbeatEndpoint], + ) -> CallbackOutcome: + outcome: Optional[CallbackOutcome] + if heartbeat_endpoint is not None: + outcome = self._wait_for_task_token_heartbeat( + env, callback_endpoint, heartbeat_endpoint + ) + else: + outcome = self._wait_for_task_token_timeout(timeout_seconds, callback_endpoint) + if outcome is None: + return CallbackOutcomeTimedOut(callback_id=callback_endpoint.callback_id) + return outcome + + def _eval_sync( + self, + env: Environment, + sync_resolver: Callable[[], Optional[Any]], + timeout_seconds: Optional[int], + callback_endpoint: Optional[CallbackEndpoint], + heartbeat_endpoint: Optional[HeartbeatEndpoint], + ) -> CallbackOutcome | Any: + callback_output: Optional[CallbackOutcome] = None + + # Listen for WaitForTaskToken signals if an endpoint is provided. + if callback_endpoint is not None: + + def _local_update_wait_for_task_token(): + nonlocal callback_output + callback_output = self._eval_wait_for_task_token( + env=env, + timeout_seconds=timeout_seconds, + callback_endpoint=callback_endpoint, + heartbeat_endpoint=heartbeat_endpoint, + ) + + thread_wait_for_task_token = threading.Thread( + target=_local_update_wait_for_task_token, + name=f"WaitForTaskToken_SyncTask_{self.resource.resource_arn}", + daemon=True, + ) + TMP_THREADS.append(thread_wait_for_task_token) + thread_wait_for_task_token.start() + # Note: the stopping of this worker thread is handled indirectly through the state of env. + # an exception in this thread will invalidate env, and therefore the worker thread. + # hence why here there are no explicit stopping logic for thread_wait_for_task_token. + + sync_result: Optional[Any] = None + while env.is_running(): + sync_result = sync_resolver() + if callback_output or sync_result: + break + else: + time.sleep(_DELAY_SECONDS_SYNC_CONDITION_CHECK) + + return callback_output or sync_result + + def _eval_integration_pattern( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> None: + task_output = env.stack.pop() + + # Initialise the waitForTaskToken Callback endpoint for this task if supported. + callback_endpoint: Optional[CallbackEndpoint] = None + if ResourceCondition.WaitForTaskToken in self._supported_integration_patterns: + callback_id = env.states.context_object.context_object_data["Task"]["Token"] + callback_endpoint = env.callback_pool_manager.get(callback_id) + + # Setup resources for timeout control. + self.timeout.eval(env=env) + timeout_seconds = env.stack.pop() + + # Setup resources for heartbeat workloads if necessary. + heartbeat_endpoint: Optional[HeartbeatEndpoint] = None + if self.heartbeat: + self.heartbeat.eval(env=env) + heartbeat_seconds = env.stack.pop() + heartbeat_endpoint: HeartbeatEndpoint = callback_endpoint.setup_heartbeat_endpoint( + heartbeat_seconds=heartbeat_seconds + ) + + # Collect the output of the integration pattern. + outcome: CallbackOutcome | Any + try: + if self.resource.condition == ResourceCondition.WaitForTaskToken: + outcome = self._eval_wait_for_task_token( + env=env, + timeout_seconds=timeout_seconds, + callback_endpoint=callback_endpoint, + heartbeat_endpoint=heartbeat_endpoint, + ) + else: + # Sync operations require the task output as input. + env.stack.append(task_output) + if self.resource.condition == ResourceCondition.Sync: + sync_resolver = self._build_sync_resolver( + env=env, + resource_runtime_part=resource_runtime_part, + normalised_parameters=normalised_parameters, + state_credentials=state_credentials, + ) + else: + # The condition checks about the resource's condition is exhaustive leaving + # only Sync2 ResourceCondition types in this block. + sync_resolver = self._build_sync2_resolver( + env=env, + resource_runtime_part=resource_runtime_part, + normalised_parameters=normalised_parameters, + state_credentials=state_credentials, + ) + + outcome = self._eval_sync( + env=env, + timeout_seconds=timeout_seconds, + callback_endpoint=callback_endpoint, + heartbeat_endpoint=heartbeat_endpoint, + sync_resolver=sync_resolver, + ) + except Exception as integration_exception: + outcome = integration_exception + finally: + # Now that the outcome is collected or the exception is about to be passed upstream, and the process has + # finished, ensure all waiting # threads on this endpoint (or task) will stop. This is in an effort to + # release resources sooner than when these would eventually synchronise with the updated environment + # state of this task. + callback_endpoint.interrupt_all() + + # Handle Callback outcome types. + if isinstance(outcome, CallbackOutcomeTimedOut): + raise CallbackTimeoutError() + elif isinstance(outcome, HeartbeatTimedOut): + raise HeartbeatTimeoutError() + elif isinstance(outcome, CallbackOutcomeFailure): + raise CallbackOutcomeFailureError(callback_outcome_failure=outcome) + elif isinstance(outcome, CallbackOutcomeSuccess): + outcome_output = json.loads(outcome.output) + env.stack.append(outcome_output) + # Pass evaluation exception upstream for error handling. + elif isinstance(outcome, Exception): + raise outcome + # Otherwise the outcome is the result of the integration pattern (sync, sync2) + # therefore push it onto the evaluation stack for the next operations. + else: + env.stack.append(outcome) + + def _wait_for_task_token_timeout( # noqa + self, + timeout_seconds: int, + callback_endpoint: CallbackEndpoint, + ) -> Optional[CallbackOutcome]: + # Awaits a callback notification and returns the outcome received. + # If the operation times out or is interrupted it returns None. + + # Although the timeout is handled already be the superclass (ExecutionState), + # the timeout value is specified here too, to allow this child process to terminate earlier even if + # discarded by the main process. + # Note: although this is the same timeout value, this can only decay strictly after the first timeout + # started as it is invoked strictly later. + outcome: Optional[CallbackOutcome] = callback_endpoint.wait(timeout=timeout_seconds) + return outcome + + def _wait_for_task_token_heartbeat( # noqa + self, + env: Environment, + callback_endpoint: CallbackEndpoint, + heartbeat_endpoint: HeartbeatEndpoint, + ) -> Optional[CallbackOutcome]: + outcome = None + while ( + env.is_running() + and outcome + is None # Note: the lifetime of this environment is this task's not the entire state machine program. + ): # Until subprocess hasn't timed out or result wasn't received. + received = heartbeat_endpoint.clear_and_wait() + if not received and env.is_running(): # Heartbeat timed out. + outcome = HeartbeatTimedOut() + else: + outcome = callback_endpoint.get_outcome() + return outcome + + def _assert_integration_pattern_is_supported(self): + integration_pattern = self.resource.condition + if integration_pattern not in self._supported_integration_patterns: + raise RuntimeError( + f"Unsupported {integration_pattern} callback procedure in resource {self.resource.resource_arn}" + ) + + def _is_integration_pattern(self): + return self.resource.condition is not None + + def _get_callback_outcome_failure_event( + self, env: Environment, ex: CallbackOutcomeFailureError + ) -> FailureEvent: + callback_outcome_failure: CallbackOutcomeFailure = ex.callback_outcome_failure + error: Optional[str] = callback_outcome_failure.error + return FailureEvent( + env=env, + error_name=CustomErrorName(error_name=error), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + resourceType=self._get_sfn_resource_type(), + resource=self._get_sfn_resource(), + error=error, + cause=callback_outcome_failure.cause, + ) + ), + ) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, CallbackOutcomeFailureError): + return self._get_callback_outcome_failure_event(env=env, ex=ex) + return super()._from_error(env=env, ex=ex) + + def _eval_body(self, env: Environment) -> None: + # Generate a TaskToken uuid within the context object, if this task resources has a callback condition. + # https://docs.aws.amazon.com/step-functions/latest/dg/connect-to-resource.html#connect-wait-token + if ( + self._is_integration_pattern() + and ResourceCondition.WaitForTaskToken in self._supported_integration_patterns + ): + self._assert_integration_pattern_is_supported() + task_token = env.states.context_object.update_task_token() + env.callback_pool_manager.add(task_token) + + super()._eval_body(env=env) + + # Ensure the TaskToken field is reset, as this is only available during waitForTaskToken task evaluations. + env.states.context_object.context_object_data.pop("Task", None) + + def _after_eval_execution( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> None: + # TODO: In Mock mode, when simulating a failure, the mock response is handled by + # super()._eval_execution, so this block is never executed. Consequently, the + # "TaskSubmitted" event isn’t recorded in the event history. + if self._is_integration_pattern(): + output = env.stack[-1] + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.TaskSubmitted, + event_details=EventDetails( + taskSubmittedEventDetails=TaskSubmittedEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + output=to_json_str(output), + outputDetails=HistoryEventExecutionDataDetails(truncated=False), + ) + ), + ) + if not env.is_mocked_mode(): + self._eval_integration_pattern( + env=env, + resource_runtime_part=resource_runtime_part, + normalised_parameters=normalised_parameters, + state_credentials=state_credentials, + ) + super()._after_eval_execution( + env=env, + resource_runtime_part=resource_runtime_part, + normalised_parameters=normalised_parameters, + state_credentials=state_credentials, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_dynamodb.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_dynamodb.py new file mode 100644 index 0000000000000..9fb484abc6362 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_dynamodb.py @@ -0,0 +1,147 @@ +from typing import Final, Optional + +from botocore.exceptions import ClientError + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service import ( + StateTaskService, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for + +_ERROR_NAME_AWS: Final[str] = "DynamoDB.AmazonDynamoDBException" + +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "getitem": { + "Key", + "TableName", + "AttributesToGet", + "ConsistentRead", + "ExpressionAttributeNames", + "ProjectionExpression", + "ReturnConsumedCapacity", + }, + "putitem": { + "Item", + "TableName", + "ConditionalOperator", + "ConditionExpression", + "Expected", + "ExpressionAttributeNames", + "ExpressionAttributeValues", + "ReturnConsumedCapacity", + "ReturnItemCollectionMetrics", + "ReturnValues", + }, + "deleteitem": { + "Key", + "TableName", + "ConditionalOperator", + "ConditionExpression", + "Expected", + "ExpressionAttributeNames", + "ExpressionAttributeValues", + "ReturnConsumedCapacity", + "ReturnItemCollectionMetrics", + "ReturnValues", + }, + "updateitem": { + "Key", + "TableName", + "AttributeUpdates", + "ConditionalOperator", + "ConditionExpression", + "Expected", + "ExpressionAttributeNames", + "ExpressionAttributeValues", + "ReturnConsumedCapacity", + "ReturnItemCollectionMetrics", + "ReturnValues", + "UpdateExpression", + }, +} + + +class StateTaskServiceDynamoDB(StateTaskService): + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + @staticmethod + def _error_cause_from_client_error(client_error: ClientError) -> tuple[str, str]: + error_code: str = client_error.response["Error"]["Code"] + error_msg: str = client_error.response["Error"]["Message"] + response_details = "; ".join( + [ + "Service: AmazonDynamoDBv2", + f"Status Code: {client_error.response['ResponseMetadata']['HTTPStatusCode']}", + f"Error Code: {error_code}", + f"Request ID: {client_error.response['ResponseMetadata']['RequestId']}", + "Proxy: null", + ] + ) + error = f"DynamoDB.{error_code}" + cause = f"{error_msg} ({response_details})" + return error, cause + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, ClientError): + error, cause = self._error_cause_from_client_error(ex) + error_name = CustomErrorName(error) + return FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=error, + cause=cause, + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + else: + return FailureEvent( + env=env, + error_name=CustomErrorName(_ERROR_NAME_AWS), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=_ERROR_NAME_AWS, + cause=str(ex), # TODO: update to report expected cause. + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + dynamodb_client = boto_client_for( + service=service_name, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + response = getattr(dynamodb_client, api_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_ecs.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_ecs.py new file mode 100644 index 0000000000000..3b3473aaa848c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_ecs.py @@ -0,0 +1,127 @@ +from typing import Any, Callable, Final, Optional + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, + ResourceCondition.Sync, +} + +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "runtask": { + "Cluster", + "Group", + "LaunchType", + "NetworkConfiguration", + "Overrides", + "PlacementConstraints", + "PlacementStrategy", + "PlatformVersion", + "PropagateTags", + "TaskDefinition", + "EnableExecuteCommand", + } +} + +_STARTED_BY_PARAMETER_RAW_KEY: Final[str] = "StartedBy" +_STARTED_BY_PARAMETER_VALUE: Final[str] = "AWS Step Functions" + + +class StateTaskServiceEcs(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + def _before_eval_execution( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + raw_parameters: dict, + state_credentials: StateCredentials, + ) -> None: + if self.resource.condition == ResourceCondition.Sync: + raw_parameters[_STARTED_BY_PARAMETER_RAW_KEY] = _STARTED_BY_PARAMETER_VALUE + super()._before_eval_execution( + env=env, + resource_runtime_part=resource_runtime_part, + raw_parameters=raw_parameters, + state_credentials=state_credentials, + ) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + ecs_client = boto_client_for( + region=resource_runtime_part.region, + service=service_name, + state_credentials=state_credentials, + ) + response = getattr(ecs_client, api_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + + # AWS outputs the description of the task, not the output of run_task. + match self._get_boto_service_action(): + case "run_task": + self._normalise_response(response=response, service_action_name="run_task") + cluster_arn: str = response["Tasks"][0]["ClusterArn"] + task_arn: str = response["Tasks"][0]["TaskArn"] + describe_tasks_output = ecs_client.describe_tasks( + cluster=cluster_arn, tasks=[task_arn] + ) + describe_tasks_output.pop("ResponseMetadata", None) + self._normalise_response( + response=describe_tasks_output, service_action_name="describe_tasks" + ) + env.stack.append(describe_tasks_output) + return + + env.stack.append(response) + + def _build_sync_resolver( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + ecs_client = boto_client_for( + service="ecs", + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + submission_output: dict = env.stack.pop() + task_arn: str = submission_output["Tasks"][0]["TaskArn"] + cluster_arn: str = submission_output["Tasks"][0]["ClusterArn"] + + def _sync_resolver() -> Optional[dict]: + describe_tasks_output = ecs_client.describe_tasks(cluster=cluster_arn, tasks=[task_arn]) + last_status: str = describe_tasks_output["tasks"][0]["lastStatus"] + + if last_status == "STOPPED": + self._normalise_response( + response=describe_tasks_output, service_action_name="describe_tasks" + ) + return describe_tasks_output["Tasks"][0] # noqa + + return None + + return _sync_resolver diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_events.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_events.py new file mode 100644 index 0000000000000..19640f84ab02f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_events.py @@ -0,0 +1,112 @@ +import json +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, +} +_FAILED_ENTRY_ERROR_NAME: Final[ErrorName] = CustomErrorName(error_name="EventBridge.FailedEntry") + +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = {"putevents": {"Entries"}} + + +class SfnFailedEntryCountException(RuntimeError): + cause: Final[Optional[dict]] + + def __init__(self, cause: Optional[dict]): + super().__init__(json.dumps(cause)) + self.cause = cause + + +class StateTaskServiceEvents(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, SfnFailedEntryCountException): + return FailureEvent( + env=env, + error_name=_FAILED_ENTRY_ERROR_NAME, + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=_FAILED_ENTRY_ERROR_NAME.error_name, + cause=ex.cause, + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + return super()._from_error(env=env, ex=ex) + + @staticmethod + def _normalised_request_parameters(env: Environment, parameters: dict): + entries = parameters.get("Entries", []) + for entry in entries: + # Optimised integration for events automatically stringifies "Entries.Detail" if this is not a string, + # and only if these are json objects. + if "Detail" in entry: + detail = entry.get("Detail") + if isinstance(detail, dict): + entry["Detail"] = to_json_str(detail) # Pass runtime error upstream. + + # The execution ARN and the state machine ARN are automatically appended to the Resources + # field of each PutEventsRequestEntry. + resources = entry.get("Resources", []) + resources.append(env.states.context_object.context_object_data["StateMachine"]["Id"]) + resources.append(env.states.context_object.context_object_data["Execution"]["Id"]) + entry["Resources"] = resources + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + self._normalised_request_parameters(env=env, parameters=normalised_parameters) + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + events_client = boto_client_for( + service=service_name, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + response = getattr(events_client, api_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + + # If the response from PutEvents contains a non-zero FailedEntryCount then the + # Task state fails with the error EventBridge.FailedEntry. + if self.resource.api_action == "putEvents": + failed_entry_count = response.get("FailedEntryCount", 0) + if failed_entry_count > 0: + # TODO: pipe events' cause in the exception object. At them moment + # LS events does not update this field. + raise SfnFailedEntryCountException(cause=response) + + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_factory.py new file mode 100644 index 0000000000000..bd89c6ccc61ea --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_factory.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Final + +from antlr4 import RecognitionException + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service import ( + StateTaskService, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_api_gateway import ( + StateTaskServiceApiGateway, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_aws_sdk import ( + StateTaskServiceAwsSdk, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_batch import ( + StateTaskServiceBatch, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_dynamodb import ( + StateTaskServiceDynamoDB, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_ecs import ( + StateTaskServiceEcs, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_events import ( + StateTaskServiceEvents, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_glue import ( + StateTaskServiceGlue, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_lambda import ( + StateTaskServiceLambda, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_sfn import ( + StateTaskServiceSfn, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_sns import ( + StateTaskServiceSns, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_sqs import ( + StateTaskServiceSqs, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_unsupported import ( + StateTaskServiceUnsupported, +) + +_UNSUPPORTED_SERVICE_NAMES: Final[set[str]] = { + "athena", + "bedrock", + "codebuild", + "eks", + "elasticmapreduce", + "emr-containers", + "emr-serverless", + "databrew", + "mediaconvert", + "sagemaker", +} + + +# TODO: improve on factory constructor (don't use SubtypeManager: cannot reuse state task instances). +def state_task_service_for(service_name: str) -> StateTaskService: + match service_name: + case "aws-sdk": + return StateTaskServiceAwsSdk() + case "lambda": + return StateTaskServiceLambda() + case "sqs": + return StateTaskServiceSqs() + case "states": + return StateTaskServiceSfn() + case "dynamodb": + return StateTaskServiceDynamoDB() + case "apigateway": + return StateTaskServiceApiGateway() + case "sns": + return StateTaskServiceSns() + case "events": + return StateTaskServiceEvents() + case "ecs": + return StateTaskServiceEcs() + case "glue": + return StateTaskServiceGlue() + case "batch": + return StateTaskServiceBatch() + case _ if service_name in _UNSUPPORTED_SERVICE_NAMES: + return StateTaskServiceUnsupported() + case unknown: + raise RecognitionException(f"Unknown service '{unknown}'") diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_glue.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_glue.py new file mode 100644 index 0000000000000..f66a00e26d4ef --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_glue.py @@ -0,0 +1,240 @@ +from typing import Any, Callable, Final, Optional + +import boto3 +from botocore.exceptions import ClientError + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.Sync, +} + +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "startjobrun": { + "JobName", + "JobRunQueuingEnabled", + "JobRunId", + "Arguments", + "AllocatedCapacity", + "Timeout", + "MaxCapacity", + "SecurityConfiguration", + "NotificationProperty", + "WorkerType", + "NumberOfWorkers", + "ExecutionClass", + } +} + +# Set of JobRunState value that indicate the JobRun had terminated in an abnormal state. +_JOB_RUN_STATE_ABNORMAL_TERMINAL_VALUE: Final[set[str]] = {"FAILED", "TIMEOUT", "ERROR"} + +# Set of JobRunState values that indicate the JobRun has terminated. +_JOB_RUN_STATE_TERMINAL_VALUES: Final[set[str]] = { + "STOPPED", + "SUCCEEDED", + *_JOB_RUN_STATE_ABNORMAL_TERMINAL_VALUE, +} + +# The handler function name prefix for StateTaskServiceGlue objects. +_HANDLER_REFLECTION_PREFIX: Final[str] = "_handle_" +# The sync handler function name prefix for StateTaskServiceGlue objects. +_SYNC_HANDLER_REFLECTION_PREFIX: Final[str] = "_sync_to_" +# The type of (sync)handler function for StateTaskServiceGlue objects. +_API_ACTION_HANDLER_TYPE = Callable[ + [Environment, ResourceRuntimePart, dict, StateCredentials], None +] +# The type of (sync)handler builder function for StateTaskServiceGlue objects. +_API_ACTION_HANDLER_BUILDER_TYPE = Callable[ + [Environment, ResourceRuntimePart, dict, StateCredentials], Callable[[], Optional[Any]] +] + + +class StateTaskServiceGlue(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + def _get_api_action_handler(self) -> _API_ACTION_HANDLER_TYPE: + api_action = self._get_boto_service_action() + handler_name = _HANDLER_REFLECTION_PREFIX + api_action + resolver_handler = getattr(self, handler_name) + if resolver_handler is None: + raise ValueError(f"Unknown or unsupported glue action '{api_action}'.") + return resolver_handler + + def _get_api_action_sync_builder_handler(self) -> _API_ACTION_HANDLER_BUILDER_TYPE: + api_action = self._get_boto_service_action() + handler_name = _SYNC_HANDLER_REFLECTION_PREFIX + api_action + resolver_handler = getattr(self, handler_name) + if resolver_handler is None: + raise ValueError(f"Unknown or unsupported glue action '{api_action}'.") + return resolver_handler + + @staticmethod + def _get_glue_client( + resource_runtime_part: ResourceRuntimePart, state_credentials: StateCredentials + ) -> boto3.client: + return boto_client_for( + service="glue", + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, ClientError): + error_code = ex.response["Error"]["Code"] + error_name: str = f"Glue.{error_code}" + return FailureEvent( + env=env, + error_name=CustomErrorName(error_name), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=error_name, + cause=ex.response["Error"]["Message"], + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + return super()._from_error(env=env, ex=ex) + + def _wait_for_task_token( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + ) -> None: + raise RuntimeError( + f"Unsupported .waitForTaskToken callback procedure in resource {self.resource.resource_arn}" + ) + + def _handle_start_job_run( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + computed_credentials: StateCredentials, + ): + glue_client = self._get_glue_client( + resource_runtime_part=resource_runtime_part, state_credentials=computed_credentials + ) + response = glue_client.start_job_run(**normalised_parameters) + response.pop("ResponseMetadata", None) + # AWS StepFunctions extracts the JobName from the request and inserts it into the response, which + # normally only contains JobRunID; as this is a required field for start_job_run, the access at + # this depth is safe. + response["JobName"] = normalised_parameters.get("JobName") + env.stack.append(response) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + # Source the action handler and delegate the evaluation. + api_action_handler = self._get_api_action_handler() + api_action_handler(env, resource_runtime_part, normalised_parameters, state_credentials) + + def _sync_to_start_job_run( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + # Poll the job run state from glue, using GetJobRun until the job has terminated. Hence, append the output + # of GetJobRun to the state. + + # Access the JobName and the JobRunId from the StartJobRun output call that must + # have occurred before this point. + start_job_run_output: dict = env.stack.pop() + job_name: str = start_job_run_output["JobName"] + job_run_id: str = start_job_run_output["JobRunId"] + + glue_client = self._get_glue_client( + resource_runtime_part=resource_runtime_part, state_credentials=state_credentials + ) + + def _sync_resolver() -> Optional[Any]: + # Sample GetJobRun until completion. + get_job_run_response: dict = glue_client.get_job_run(JobName=job_name, RunId=job_run_id) + job_run: dict = get_job_run_response["JobRun"] + job_run_state: str = job_run["JobRunState"] + + # If the job run has not terminated, continue and check later. + is_terminated: bool = job_run_state in _JOB_RUN_STATE_TERMINAL_VALUES + if not is_terminated: + return None + + # AWS StepFunctions appears to append attach the JobName to the output both in case of error or success. + job_run["JobName"] = job_name + + # If the job run terminated in a normal state, return the result. + is_abnormal_termination = job_run_state in _JOB_RUN_STATE_ABNORMAL_TERMINAL_VALUE + if not is_abnormal_termination: + return job_run + + # If the job run has terminated with an abnormal state, raise the error in stepfunctions. + raise FailureEventException( + FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTaskFailed), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + error=StatesErrorNameType.StatesTaskFailed.to_name(), + cause=to_json_str(job_run), + ) + ), + ) + ) + + return _sync_resolver + + def _build_sync_resolver( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + sync_resolver_builder = self._get_api_action_sync_builder_handler() + sync_resolver = sync_resolver_builder( + env, resource_runtime_part, normalised_parameters, state_credentials + ) + return sync_resolver diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_lambda.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_lambda.py new file mode 100644 index 0000000000000..8feebfa1cdc29 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_lambda.py @@ -0,0 +1,132 @@ +import json +import logging +from typing import Final, Optional + +from botocore.exceptions import ClientError + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task import ( + lambda_eval_utils, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + +LOG = logging.getLogger(__name__) + + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, +} +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "invoke": { + "ClientContext", + "FunctionName", + "InvocationType", + "Qualifier", + "Payload", + # Outside the specification, but supported in practice: + "LogType", + } +} + + +class StateTaskServiceLambda(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + @staticmethod + def _error_cause_from_client_error(client_error: ClientError) -> tuple[str, str]: + error_code: str = client_error.response["Error"]["Code"] + error_msg: str = client_error.response["Error"]["Message"] + response_details = "; ".join( + [ + "Service: AWSLambda", + f"Status Code: {client_error.response['ResponseMetadata']['HTTPStatusCode']}", + f"Error Code: {error_code}", + f"Request ID: {client_error.response['ResponseMetadata']['RequestId']}", + "Proxy: null", + ] + ) + error = f"Lambda.{error_code}" + cause = f"{error_msg} ({response_details})" + return error, cause + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, lambda_eval_utils.LambdaFunctionErrorException): + cause = ex.payload + try: + cause_object = json.loads(cause) + error = cause_object["errorType"] + except Exception as ex: + LOG.warning( + "Could not retrieve 'errorType' field from LambdaFunctionErrorException object: %s", + ex, + ) + error = "Exception" + error_name = CustomErrorName(error) + elif isinstance(ex, ClientError): + error, cause = self._error_cause_from_client_error(ex) + error_name = CustomErrorName(error) + else: + return super()._from_error(env=env, ex=ex) + return FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=error, + cause=cause, + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + + def _normalise_parameters( + self, + parameters: dict, + boto_service_name: Optional[str] = None, + service_action_name: Optional[str] = None, + ) -> None: + # Run Payload value casting before normalisation. + if "Payload" in parameters: + parameters["Payload"] = lambda_eval_utils.to_payload_type(parameters["Payload"]) + super()._normalise_parameters( + parameters=parameters, + boto_service_name=boto_service_name, + service_action_name=service_action_name, + ) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + lambda_eval_utils.execute_lambda_function_integration( + env=env, + parameters=normalised_parameters, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sfn.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sfn.py new file mode 100644 index 0000000000000..33bafc723a00e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sfn.py @@ -0,0 +1,239 @@ +import json +from typing import Any, Callable, Final, Optional + +from botocore.exceptions import ClientError + +from localstack.aws.api.stepfunctions import ( + DescribeExecutionOutput, + ExecutionStatus, + HistoryEventType, + TaskFailedEventDetails, +) +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.utils.collections import select_from_typed_dict + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, + ResourceCondition.Sync, + ResourceCondition.Sync2, +} +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "startexecution": {"Input", "Name", "StateMachineArn"} +} + + +class StateTaskServiceSfn(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, ClientError): + error_code = ex.response["Error"]["Code"] + error_name: str = f"StepFunctions.{error_code}Exception" + error_cause_details = [ + "Service: AWSStepFunctions", + f"Status Code: {ex.response['ResponseMetadata']['HTTPStatusCode']}", + f"Error Code: {error_code}", + f"Request ID: {ex.response['ResponseMetadata']['RequestId']}", + "Proxy: null", # TODO: investigate this proxy value. + ] + if "HostId" in ex.response["ResponseMetadata"]: + error_cause_details.append( + f"Extended Request ID: {ex.response['ResponseMetadata']['HostId']}" + ) + error_cause: str = ( + f"{ex.response['Error']['Message']} ({'; '.join(error_cause_details)})" + ) + return FailureEvent( + env=env, + error_name=CustomErrorName(error_name), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=error_name, + cause=error_cause, + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + return super()._from_error(env=env, ex=ex) + + def _normalise_parameters( + self, + parameters: dict, + boto_service_name: Optional[str] = None, + service_action_name: Optional[str] = None, + ) -> None: + if service_action_name is None: + if self._get_boto_service_action() == "start_execution": + optional_input = parameters.get("Input") + if not isinstance(optional_input, str): + # AWS Sfn's documentation states: + # If you don't include any JSON input data, you still must include the two braces. + if optional_input is None: + optional_input = {} + parameters["Input"] = to_json_str(optional_input, separators=(",", ":")) + super()._normalise_parameters( + parameters=parameters, + boto_service_name=boto_service_name, + service_action_name=service_action_name, + ) + + def _build_sync_resolver( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + sfn_client = boto_client_for( + service="stepfunctions", + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + submission_output: dict = env.stack.pop() + execution_arn: str = submission_output["ExecutionArn"] + + def _sync_resolver() -> Optional[Any]: + describe_execution_output = sfn_client.describe_execution(executionArn=execution_arn) + describe_execution_output: DescribeExecutionOutput = select_from_typed_dict( + DescribeExecutionOutput, describe_execution_output + ) + execution_status: ExecutionStatus = describe_execution_output["status"] + + if execution_status == ExecutionStatus.RUNNING: + return None + + self._normalise_response( + response=describe_execution_output, service_action_name="describe_execution" + ) + if execution_status == ExecutionStatus.SUCCEEDED: + return describe_execution_output + else: + raise FailureEventException( + FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTaskFailed), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + error=StatesErrorNameType.StatesTaskFailed.to_name(), + cause=to_json_str(describe_execution_output), + ) + ), + ) + ) + + return _sync_resolver + + @staticmethod + def _sync2_api_output_of(typ: type, value: json) -> None: + def _replace_with_json_if_str(key: str) -> None: + inner_value = value.get(key) + if isinstance(inner_value, str): + value[key] = json.loads(inner_value) + + match typ: + case DescribeExecutionOutput: # noqa + _replace_with_json_if_str("input") + _replace_with_json_if_str("output") + + def _build_sync2_resolver( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ) -> Callable[[], Optional[Any]]: + sfn_client = boto_client_for( + region=resource_runtime_part.region, + service="stepfunctions", + state_credentials=state_credentials, + ) + submission_output: dict = env.stack.pop() + execution_arn: str = submission_output["ExecutionArn"] + + def _sync2_resolver() -> Optional[Any]: + describe_execution_output = sfn_client.describe_execution(executionArn=execution_arn) + describe_execution_output: DescribeExecutionOutput = select_from_typed_dict( + DescribeExecutionOutput, describe_execution_output + ) + execution_status: ExecutionStatus = describe_execution_output["status"] + + if execution_status == ExecutionStatus.RUNNING: + return None + + self._sync2_api_output_of(typ=DescribeExecutionOutput, value=describe_execution_output) + self._normalise_response( + response=describe_execution_output, service_action_name="describe_execution" + ) + if execution_status == ExecutionStatus.SUCCEEDED: + return describe_execution_output + else: + raise FailureEventException( + FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTaskFailed), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + error=StatesErrorNameType.StatesTaskFailed.to_name(), + cause=to_json_str(describe_execution_output), + ) + ), + ) + ) + + return _sync2_resolver + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + sfn_client = boto_client_for( + region=resource_runtime_part.region, + service=service_name, + state_credentials=state_credentials, + ) + response = getattr(sfn_client, api_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sns.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sns.py new file mode 100644 index 0000000000000..45c6693d0dafd --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sns.py @@ -0,0 +1,111 @@ +from typing import Final, Optional + +from botocore.exceptions import ClientError + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, +} +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "publish": { + "Message", + "MessageAttributes", + "MessageStructure", + "MessageDeduplicationId", + "MessageGroupId", + "PhoneNumber", + "Subject", + "TargetArn", + "TopicArn", + } +} + + +class StateTaskServiceSns(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, ClientError): + error_code = ex.response["Error"]["Code"] + + exception_name = error_code + if not exception_name.endswith("Exception"): + exception_name += "Exception" + error_name = f"SNS.{exception_name}" + + error_message = ex.response["Error"]["Message"] + status_code = ex.response["ResponseMetadata"]["HTTPStatusCode"] + request_id = ex.response["ResponseMetadata"]["RequestId"] + error_cause = ( + f"{error_message} " + f"(Service: AmazonSNS; " + f"Status Code: {status_code}; " + f"Error Code: {error_code}; " + f"Request ID: {request_id}; " + f"Proxy: null)" + ) + + return FailureEvent( + env=env, + error_name=CustomErrorName(error_name=error_name), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=error_name, + cause=error_cause, + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + return super()._from_error(env=env, ex=ex) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + sns_client = boto_client_for( + service=service_name, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + + # Optimised integration automatically stringifies + if "Message" in normalised_parameters and not isinstance( + message := normalised_parameters["Message"], str + ): + normalised_parameters["Message"] = to_json_str(message) + + response = getattr(sns_client, api_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sqs.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sqs.py new file mode 100644 index 0000000000000..836cb8ad1b95b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_sqs.py @@ -0,0 +1,113 @@ +from typing import Any, Final, Optional + +from botocore.exceptions import ClientError + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, +} +_ERROR_NAME_CLIENT: Final[str] = "SQS.SdkClientException" +_ERROR_NAME_AWS: Final[str] = "SQS.AmazonSQSException" +_SUPPORTED_API_PARAM_BINDINGS: Final[dict[str, set[str]]] = { + "sendmessage": { + "DelaySeconds", + "MessageAttributes", + "MessageBody", + "MessageDeduplicationId", + "MessageGroupId", + "QueueUrl", + } +} + + +class StateTaskServiceSqs(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _get_supported_parameters(self) -> Optional[set[str]]: + return _SUPPORTED_API_PARAM_BINDINGS.get(self.resource.api_action.lower()) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, ClientError): + return FailureEvent( + env=env, + error_name=CustomErrorName(_ERROR_NAME_CLIENT), + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails( + taskFailedEventDetails=TaskFailedEventDetails( + error=_ERROR_NAME_CLIENT, + cause=ex.response["Error"][ + "Message" + ], # TODO: update to report expected cause. + resource=self._get_sfn_resource(), + resourceType=self._get_sfn_resource_type(), + ) + ), + ) + return super()._from_error(env=env, ex=ex) + + def _normalise_response( + self, + response: Any, + boto_service_name: Optional[str] = None, + service_action_name: Optional[str] = None, + ) -> None: + super()._normalise_response( + response=response, + boto_service_name=boto_service_name, + service_action_name=service_action_name, + ) + # Normalise output value keys to SFN standard for Md5OfMessageBody and Md5OfMessageAttributes + if response and "Md5OfMessageBody" in response: + md5_message_body = response.pop("Md5OfMessageBody") + response["MD5OfMessageBody"] = md5_message_body + + if response and "Md5OfMessageAttributes" in response: + md5_message_attributes = response.pop("Md5OfMessageAttributes") + response["MD5OfMessageAttributes"] = md5_message_attributes + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + # TODO: Stepfunctions automatically dumps to json MessageBody's definitions. + # Are these other similar scenarios? + if "MessageBody" in normalised_parameters: + message_body = normalised_parameters["MessageBody"] + if message_body is not None and not isinstance(message_body, str): + normalised_parameters["MessageBody"] = to_json_str(message_body) + + service_name = self._get_boto_service_name() + api_action = self._get_boto_service_action() + sqs_client = boto_client_for( + service=service_name, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + response = getattr(sqs_client, api_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_unsupported.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_unsupported.py new file mode 100644 index 0000000000000..0719c6d2e73a3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_unsupported.py @@ -0,0 +1,63 @@ +import logging +from typing import Final + +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ResourceCondition, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_callback import ( + StateTaskServiceCallback, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.utils.boto_client import boto_client_for + +LOG = logging.getLogger(__name__) + +_SUPPORTED_INTEGRATION_PATTERNS: Final[set[ResourceCondition]] = { + ResourceCondition.WaitForTaskToken, +} + + +class StateTaskServiceUnsupported(StateTaskServiceCallback): + def __init__(self): + super().__init__(supported_integration_patterns=_SUPPORTED_INTEGRATION_PATTERNS) + + def _validate_service_integration_is_supported(self): + # Attempts to execute any derivation; logging this incident on creation. + self._log_unsupported_warning() + + def _log_unsupported_warning(self): + # Logs that the optimised service integration is not supported, + # however the request is being forwarded to the service. + service_name = self._get_boto_service_name() + resource_arn = self.resource.resource_arn + LOG.warning( + "Unsupported Optimised service integration for service_name '%s' in resource: '%s'. " + "Attempting to forward request to service.", + service_name, + resource_arn, + ) + + def _eval_service_task( + self, + env: Environment, + resource_runtime_part: ResourceRuntimePart, + normalised_parameters: dict, + state_credentials: StateCredentials, + ): + # Logs that the evaluation of this optimised service integration is not supported + # and relays the call to the target service with the computed parameters. + self._log_unsupported_warning() + service_name = self._get_boto_service_name() + boto_action = self._get_boto_service_action() + boto_client = boto_client_for( + service=service_name, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + response = getattr(boto_client, boto_action)(**normalised_parameters) + response.pop("ResponseMetadata", None) + env.stack.append(response) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task.py new file mode 100644 index 0000000000000..79c5f496d7bf8 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import abc +from typing import Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskTimedOutEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.parargs import Parargs +from localstack.services.stepfunctions.asl.component.state.state_execution.execute_state import ( + ExecutionState, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + Credentials, + StateCredentials, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + Resource, +) +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + + +class StateTask(ExecutionState, abc.ABC): + resource: Resource + parargs: Optional[Parargs] + credentials: Optional[Credentials] + + def __init__(self): + super(StateTask, self).__init__( + state_entered_event_type=HistoryEventType.TaskStateEntered, + state_exited_event_type=HistoryEventType.TaskStateExited, + ) + + def from_state_props(self, state_props: StateProps) -> None: + super(StateTask, self).from_state_props(state_props) + self.resource = state_props.get(Resource) + self.parargs = state_props.get(Parargs) + self.credentials = state_props.get(Credentials) + + def _get_supported_parameters(self) -> Optional[set[str]]: # noqa + return None + + def _eval_parameters(self, env: Environment) -> dict: + # Eval raw parameters. + parameters = dict() + if self.parargs is not None: + self.parargs.eval(env=env) + parameters = env.stack.pop() + + # Handle supported parameters. + supported_parameters = self._get_supported_parameters() + if supported_parameters: + unsupported_parameters: list[str] = [ + parameter + for parameter in parameters.keys() + if parameter not in supported_parameters + ] + for unsupported_parameter in unsupported_parameters: + parameters.pop(unsupported_parameter, None) + + return parameters + + def _eval_state_credentials(self, env: Environment) -> StateCredentials: + if not self.credentials: + state_credentials = StateCredentials(role_arn=env.aws_execution_details.role_arn) + else: + self.credentials.eval(env=env) + state_credentials = env.stack.pop() + return state_credentials + + def _get_timed_out_failure_event(self, env: Environment) -> FailureEvent: + return FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTimeout), + event_type=HistoryEventType.TaskTimedOut, + event_details=EventDetails( + taskTimedOutEventDetails=TaskTimedOutEventDetails( + error=StatesErrorNameType.StatesTimeout.to_name(), + ) + ), + ) + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, TimeoutError): + return self._get_timed_out_failure_event(env) + return super()._from_error(env=env, ex=ex) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_activitiy.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_activitiy.py new file mode 100644 index 0000000000000..bfff9c4855e70 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_activitiy.py @@ -0,0 +1,207 @@ +import json + +from botocore.exceptions import ClientError + +from localstack.aws.api.stepfunctions import ( + ActivityDoesNotExist, + ActivityFailedEventDetails, + ActivityScheduledEventDetails, + ActivityStartedEventDetails, + ActivitySucceededEventDetails, + ActivityTimedOutEventDetails, + ExecutionFailedEventDetails, + HistoryEventExecutionDataDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import ( + EvalTimeoutError, + TimeoutSeconds, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ActivityResource, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.state_task import ( + StateTask, +) +from localstack.services.stepfunctions.asl.eval.callback.callback import ( + ActivityTaskStartOutcome, + CallbackOutcomeFailure, + CallbackOutcomeFailureError, + CallbackOutcomeSuccess, + CallbackTimeoutError, + HeartbeatTimeoutError, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + + +class StateTaskActivity(StateTask): + resource: ActivityResource + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, TimeoutError): + return FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTimeout), + event_type=HistoryEventType.ActivityTimedOut, + event_details=EventDetails( + activityTimedOutEventDetails=ActivityTimedOutEventDetails( + error=StatesErrorNameType.StatesTimeout.to_name(), + ) + ), + ) + + if isinstance(ex, FailureEventException): + raise ex + + if isinstance(ex, CallbackOutcomeFailureError): + error = ex.callback_outcome_failure.error + error_name = CustomErrorName(error) + cause = ex.callback_outcome_failure.cause + else: + error_name = StatesErrorName(typ=StatesErrorNameType.StatesRuntime) + error = error_name.error_name + cause = ex.response["Error"]["Message"] if isinstance(ex, ClientError) else str(ex) + return FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.ActivityFailed, + event_details=EventDetails( + activityFailedEventDetails=ActivityFailedEventDetails(error=error, cause=cause) + ), + ) + + def _eval_parameters(self, env: Environment) -> dict: + if self.parargs: + self.parargs.eval(env=env) + activity_input = env.stack.pop() + return activity_input + + def _eval_execution(self, env: Environment) -> None: + # Compute the task input. + activity_task_input = self._eval_parameters(env=env) + if not isinstance(activity_task_input, str): + activity_task_input = to_json_str(activity_task_input) + + # Compute the timeout and heartbeat for this task. + timeout_seconds = TimeoutSeconds.DEFAULT_TIMEOUT_SECONDS + + if not self.timeout.is_default_value(): + self.timeout.eval(env=env) + timeout_seconds = env.stack.pop() + + heartbeat_seconds = None + if self.heartbeat: + self.heartbeat.eval(env=env) + heartbeat_seconds = env.stack.pop() + + # Publish the activity task on the callback manager. + task_token = env.states.context_object.update_task_token() + try: + callback_endpoint = env.callback_pool_manager.add_activity_task( + callback_id=task_token, + activity_arn=self.resource.resource_arn, + activity_input=activity_task_input, + ) + except ActivityDoesNotExist: + failure_event = FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), + cause="The activity activity_arn does not exist.", + ) + ), + ) + raise FailureEventException(failure_event=failure_event) + + # Log the task is scheduled. + scheduled_event_details = ActivityScheduledEventDetails( + resource=self.resource.resource_arn, + input=activity_task_input, + inputDetails=HistoryEventExecutionDataDetails( + truncated=False # Always False for api calls. + ), + ) + if timeout_seconds != TimeoutSeconds.DEFAULT_TIMEOUT_SECONDS: + scheduled_event_details["timeoutInSeconds"] = timeout_seconds + if heartbeat_seconds is not None: + scheduled_event_details["heartbeatInSeconds"] = heartbeat_seconds + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ActivityScheduled, + event_details=EventDetails(activityScheduledEventDetails=scheduled_event_details), + ) + + # Await for the task to be sampled with timeout. + activity_task_start_endpoint = callback_endpoint.get_activity_task_start_endpoint() + task_start_outcome = activity_task_start_endpoint.wait(timeout_seconds=timeout_seconds) + # Log the task was sampled or timeout error if not. + if isinstance(task_start_outcome, ActivityTaskStartOutcome): + started_event_details = ActivityStartedEventDetails() + if task_start_outcome.worker_name is not None: + started_event_details["workerName"] = task_start_outcome.worker_name + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ActivityStarted, + event_details=EventDetails(activityStartedEventDetails=started_event_details), + ) + else: + raise EvalTimeoutError() + + # Await for the task outcome, with a heartbeat or timeout strategy. + outcome = None + if heartbeat_seconds is None: + # Total timeout is already handled upstream. Here we specify a timeout to allow this child operation to + # terminate gracefully sooner. This is why we don't compute the residual outcome. + outcome = callback_endpoint.wait(timeout=timeout_seconds) + else: + heartbeat_endpoint = callback_endpoint.setup_heartbeat_endpoint( + heartbeat_seconds=heartbeat_seconds + ) + while ( + env.is_running() and outcome is None + ): # Until subprocess hasn't timed out or result wasn't received. + received = heartbeat_endpoint.clear_and_wait() + if not received and env.is_running(): # Heartbeat timed out. + raise HeartbeatTimeoutError() + outcome = callback_endpoint.get_outcome() + + if outcome is None: + raise CallbackTimeoutError() + if isinstance(outcome, CallbackOutcomeSuccess): + outcome_output = json.loads(outcome.output) + env.stack.append(outcome_output) + elif isinstance(outcome, CallbackOutcomeFailure): + raise CallbackOutcomeFailureError(callback_outcome_failure=outcome) + else: + raise NotImplementedError(f"Unsupported CallbackOutcome type '{type(outcome)}'.") + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.ActivitySucceeded, + event_details=EventDetails( + activitySucceededEventDetails=ActivitySucceededEventDetails( + output=outcome.output, + outputDetails=HistoryEventExecutionDataDetails( + truncated=False # Always False for api calls. + ), + ) + ), + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_factory.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_factory.py new file mode 100644 index 0000000000000..4f474499d95dd --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_factory.py @@ -0,0 +1,34 @@ +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ActivityResource, + LambdaResource, + Resource, + ServiceResource, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_factory import ( + state_task_service_for, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.state_task import ( + StateTask, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.state_task_activitiy import ( + StateTaskActivity, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.state_task_lambda import ( + StateTaskLambda, +) + + +def state_task_for(resource: Resource) -> StateTask: + if not resource: + raise ValueError("No Resource declaration in State Task.") + if isinstance(resource, ServiceResource): + state = state_task_service_for(service_name=resource.service_name) + elif isinstance(resource, LambdaResource): + state = StateTaskLambda() + elif isinstance(resource, ActivityResource): + state = StateTaskActivity() + else: + raise NotImplementedError( + f"Resource of type '{type(resource)}' are not supported: '{resource}'." + ) + return state diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_lambda.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_lambda.py new file mode 100644 index 0000000000000..d33fc290b611e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_execution/state_task/state_task_lambda.py @@ -0,0 +1,192 @@ +import json +import logging +from typing import Union + +from botocore.exceptions import ClientError + +from localstack.aws.api.lambda_ import InvocationRequest, InvocationType +from localstack.aws.api.stepfunctions import ( + HistoryEventExecutionDataDetails, + HistoryEventType, + LambdaFunctionFailedEventDetails, + LambdaFunctionScheduledEventDetails, + LambdaFunctionSucceededEventDetails, + LambdaFunctionTimedOutEventDetails, + TaskCredentials, +) +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task import ( + lambda_eval_utils, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + LambdaResource, + ResourceRuntimePart, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.state_task import ( + StateTask, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.quotas import is_within_size_quota + +LOG = logging.getLogger(__name__) + + +class StateTaskLambda(StateTask): + resource: LambdaResource + + def _from_error(self, env: Environment, ex: Exception) -> FailureEvent: + if isinstance(ex, TimeoutError): + return FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesTimeout), + event_type=HistoryEventType.LambdaFunctionTimedOut, + event_details=EventDetails( + lambdaFunctionTimedOutEventDetails=LambdaFunctionTimedOutEventDetails( + error=StatesErrorNameType.StatesTimeout.to_name(), + ) + ), + ) + if isinstance(ex, FailureEventException): + return ex.failure_event + + error = "Exception" + if isinstance(ex, ClientError): + error_name = CustomErrorName(error) + cause = ex.response["Error"]["Message"] + elif isinstance(ex, lambda_eval_utils.LambdaFunctionErrorException): + cause = ex.payload + try: + cause_object = json.loads(cause) + error = cause_object["errorType"] + except Exception as ex: + LOG.warning( + "Could not retrieve 'errorType' field from LambdaFunctionErrorException object: %s", + ex, + ) + error_name = CustomErrorName(error) + else: + error_name = StatesErrorName(StatesErrorNameType.StatesTaskFailed) + cause = str(ex) + + return FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.LambdaFunctionFailed, + event_details=EventDetails( + lambdaFunctionFailedEventDetails=LambdaFunctionFailedEventDetails( + error=error, + cause=cause, + ) + ), + ) + + def _verify_size_quota(self, env: Environment, value: Union[str, json]) -> None: + is_within: bool = is_within_size_quota(value=value) + if is_within: + return + error_type = StatesErrorNameType.StatesStatesDataLimitExceeded + cause = ( + f"The state/task '{self.resource.resource_arn}' returned a result " + "with a size exceeding the maximum number of bytes service limit." + ) + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=error_type), + event_type=HistoryEventType.LambdaFunctionFailed, + event_details=EventDetails( + lambdaFunctionFailedEventDetails=LambdaFunctionFailedEventDetails( + error=error_type.to_name(), + cause=cause, + ) + ), + ) + ) + + def _eval_parameters(self, env: Environment) -> dict: + if self.parargs: + self.parargs.eval(env=env) + + payload = env.stack.pop() + parameters = InvocationRequest( + FunctionName=self.resource.resource_arn, + InvocationType=InvocationType.RequestResponse, + Payload=payload, + ) + return parameters + + def _eval_execution(self, env: Environment) -> None: + parameters = self._eval_parameters(env=env) + state_credentials = self._eval_state_credentials(env=env) + payload = parameters["Payload"] + + scheduled_event_details = LambdaFunctionScheduledEventDetails( + resource=self.resource.resource_arn, + input=to_json_str(payload), + inputDetails=HistoryEventExecutionDataDetails( + truncated=False # Always False for api calls. + ), + ) + if not self.timeout.is_default_value(): + self.timeout.eval(env=env) + timeout_seconds = env.stack.pop() + scheduled_event_details["timeoutInSeconds"] = timeout_seconds + if self.credentials: + scheduled_event_details["taskCredentials"] = TaskCredentials( + roleArn=state_credentials.role_arn + ) + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.LambdaFunctionScheduled, + event_details=EventDetails(lambdaFunctionScheduledEventDetails=scheduled_event_details), + ) + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.LambdaFunctionStarted, + ) + + self.resource.eval(env=env) + resource_runtime_part: ResourceRuntimePart = env.stack.pop() + + parameters["Payload"] = lambda_eval_utils.to_payload_type(parameters["Payload"]) + lambda_eval_utils.execute_lambda_function_integration( + env=env, + parameters=parameters, + region=resource_runtime_part.region, + state_credentials=state_credentials, + ) + + # In lambda invocations, only payload is passed on as output. + output = env.stack.pop() + self._verify_size_quota(env=env, value=output) + + output_payload = output["Payload"] + env.stack.append(output_payload) + + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.LambdaFunctionSucceeded, + event_details=EventDetails( + lambdaFunctionSucceededEventDetails=LambdaFunctionSucceededEventDetails( + output=to_json_str(output_payload), + outputDetails=HistoryEventExecutionDataDetails( + truncated=False # Always False for api calls. + ), + ) + ), + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/cause_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/cause_decl.py new file mode 100644 index 0000000000000..60dda85944d7a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/cause_decl.py @@ -0,0 +1,48 @@ +import abc +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringExpression, + StringIntrinsicFunction, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_fuinction_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + +_STRING_RETURN_FUNCTIONS: Final[set[str]] = { + typ.name() + for typ in [ + StatesFunctionNameType.Format, + StatesFunctionNameType.JsonToString, + StatesFunctionNameType.ArrayGetItem, + StatesFunctionNameType.Base64Decode, + StatesFunctionNameType.Base64Encode, + StatesFunctionNameType.Hash, + StatesFunctionNameType.UUID, + ] +} + + +class CauseDecl(EvalComponent, abc.ABC): ... + + +class Cause(CauseDecl): + string_expression: Final[StringExpression] + + def __init__(self, string_expression: StringExpression): + self.string_expression = string_expression + + def _eval_body(self, env: Environment) -> None: + self.string_expression.eval(env=env) + + +class CausePath(Cause): + def __init__(self, string_expression: StringExpression): + super().__init__(string_expression=string_expression) + if isinstance(string_expression, StringIntrinsicFunction): + if string_expression.function.name.name not in _STRING_RETURN_FUNCTIONS: + raise ValueError( + f"Unsupported Intrinsic Function for CausePath declaration: '{string_expression.intrinsic_function_derivation}'." + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/error_decl.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/error_decl.py new file mode 100644 index 0000000000000..a5a7ba89c2648 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/error_decl.py @@ -0,0 +1,48 @@ +import abc +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringExpression, + StringIntrinsicFunction, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_fuinction_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + +_STRING_RETURN_FUNCTIONS: Final[set[str]] = { + typ.name() + for typ in [ + StatesFunctionNameType.Format, + StatesFunctionNameType.JsonToString, + StatesFunctionNameType.ArrayGetItem, + StatesFunctionNameType.Base64Decode, + StatesFunctionNameType.Base64Encode, + StatesFunctionNameType.Hash, + StatesFunctionNameType.UUID, + ] +} + + +class ErrorDecl(EvalComponent, abc.ABC): ... + + +class Error(ErrorDecl): + string_expression: Final[StringExpression] + + def __init__(self, string_expression: StringExpression): + self.string_expression = string_expression + + def _eval_body(self, env: Environment) -> None: + self.string_expression.eval(env=env) + + +class ErrorPath(Error): + def __init__(self, string_expression: StringExpression): + super().__init__(string_expression=string_expression) + if isinstance(string_expression, StringIntrinsicFunction): + if string_expression.function.name.name not in _STRING_RETURN_FUNCTIONS: + raise ValueError( + f"Unsupported Intrinsic Function for ErrorPath declaration: '{string_expression.intrinsic_function_derivation}'." + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/state_fail.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/state_fail.py new file mode 100644 index 0000000000000..608b27f2044fc --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_fail/state_fail.py @@ -0,0 +1,54 @@ +from typing import Optional + +from localstack.aws.api.stepfunctions import HistoryEventType, TaskFailedEventDetails +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_fail.cause_decl import CauseDecl +from localstack.services.stepfunctions.asl.component.state.state_fail.error_decl import ErrorDecl +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + + +class StateFail(CommonStateField): + def __init__(self): + super().__init__( + state_entered_event_type=HistoryEventType.FailStateEntered, + state_exited_event_type=None, + ) + self.cause: Optional[CauseDecl] = None + self.error: Optional[ErrorDecl] = None + + def from_state_props(self, state_props: StateProps) -> None: + super(StateFail, self).from_state_props(state_props) + self.cause = state_props.get(CauseDecl) + self.error = state_props.get(ErrorDecl) + + def _eval_state(self, env: Environment) -> None: + task_failed_event_details = TaskFailedEventDetails() + + error_value = None + if self.error: + self.error.eval(env=env) + error_value = env.stack.pop() + task_failed_event_details["error"] = error_value + + if self.cause: + self.cause.eval(env=env) + cause_value = env.stack.pop() + task_failed_event_details["cause"] = cause_value + + error_name = CustomErrorName(error_value) if error_value else None + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.TaskFailed, + event_details=EventDetails(taskFailedEventDetails=task_failed_event_details), + ) + raise FailureEventException(failure_event=failure_event) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/result.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/result.py new file mode 100644 index 0000000000000..11e86ed536654 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/result.py @@ -0,0 +1,14 @@ +import json + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class Result(EvalComponent): + result_obj: json + + def __init__(self, result_obj: json): + self.result_obj = result_obj + + def _eval_body(self, env: Environment) -> None: + env.stack.append(self.result_obj) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/state_pass.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/state_pass.py new file mode 100644 index 0000000000000..3a13b935b73ac --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_pass/state_pass.py @@ -0,0 +1,59 @@ +from typing import Optional + +from localstack.aws.api.stepfunctions import ( + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters, Parargs +from localstack.services.stepfunctions.asl.component.common.path.result_path import ResultPath +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_pass.result import Result +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StatePass(CommonStateField): + def __init__(self): + super(StatePass, self).__init__( + state_entered_event_type=HistoryEventType.PassStateEntered, + state_exited_event_type=HistoryEventType.PassStateExited, + ) + + # Result (Optional) + # Refers to the output of a virtual state_task that is passed on to the next state. If you include the ResultPath + # field in your state machine definition, Result is placed as specified by ResultPath and passed on to the + self.result: Optional[Result] = None + + # ResultPath (Optional) + # Specifies where to place the output (relative to the input) of the virtual state_task specified in Result. The input + # is further filtered as specified by the OutputPath field (if present) before being used as the state's output. + self.result_path: Optional[ResultPath] = None + + # Parameters (Optional) + # Creates a collection of key-value pairs that will be passed as input. You can specify Parameters as a static + # value or select from the input using a path. + self.parameters: Optional[Parameters] = None + + def from_state_props(self, state_props: StateProps) -> None: + super(StatePass, self).from_state_props(state_props) + self.result = state_props.get(Result) + self.result_path = state_props.get(ResultPath) or ResultPath( + result_path_src=ResultPath.DEFAULT_PATH + ) + self.parameters = state_props.get(Parargs) + + def _eval_state(self, env: Environment) -> None: + if self.parameters: + self.parameters.eval(env=env) + + if self.result: + self.result.eval(env=env) + + if not self._is_language_query_jsonpath(): + output_value = env.stack[-1] + env.states.set_result(output_value) + + if self.assign_decl: + self.assign_decl.eval(env=env) + + if self.result_path: + self.result_path.eval(env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_props.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_props.py new file mode 100644 index 0000000000000..8c56165ce58c3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_props.py @@ -0,0 +1,74 @@ +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.common.flow.end import End +from localstack.services.stepfunctions.asl.component.common.flow.next import Next +from localstack.services.stepfunctions.asl.component.common.parargs import Parargs +from localstack.services.stepfunctions.asl.component.common.timeouts.heartbeat import Heartbeat +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import Timeout +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_type import ( + Comparison, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.variable import ( + Variable, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.max_items_decl import ( + MaxItemsDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.items.items import ( + Items, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.max_concurrency import ( + MaxConcurrencyDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.tolerated_failure import ( + ToleratedFailureCountDecl, + ToleratedFailurePercentageDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + Resource, +) +from localstack.services.stepfunctions.asl.component.state.state_fail.cause_decl import CauseDecl +from localstack.services.stepfunctions.asl.component.state.state_fail.error_decl import ErrorDecl +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.wait_function import ( + WaitFunction, +) +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + +UNIQUE_SUBINSTANCES: Final[set[type]] = { + Items, + Resource, + WaitFunction, + Timeout, + Heartbeat, + MaxItemsDecl, + MaxConcurrencyDecl, + ToleratedFailureCountDecl, + ToleratedFailurePercentageDecl, + ErrorDecl, + CauseDecl, + Variable, + Parargs, + Comparison, +} + + +class StateProps(TypedProps): + name: str + + def add(self, instance: Any) -> None: + inst_type = type(instance) + + # End-Next conflicts: + if inst_type == End and Next in self._instance_by_type: + raise ValueError(f"End redefines Next, from '{self.get(Next)}' to '{instance}'.") + if inst_type == Next and End in self._instance_by_type: + raise ValueError(f"Next redefines End, from '{self.get(End)}' to '{instance}'.") + + # Subclasses + for typ in UNIQUE_SUBINSTANCES: + if issubclass(inst_type, typ): + super()._add(typ, instance) + return + + # Base and delegate to preprocessor. + super().add(instance) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_succeed/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_succeed/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_succeed/state_succeed.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_succeed/state_succeed.py new file mode 100644 index 0000000000000..f6423e3e221d9 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_succeed/state_succeed.py @@ -0,0 +1,31 @@ +from localstack.aws.api.stepfunctions import HistoryEventType +from localstack.services.stepfunctions.asl.component.common.flow.end import End +from localstack.services.stepfunctions.asl.component.common.flow.next import Next +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_continue_with import ( + ContinueWithSuccess, +) +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StateSucceed(CommonStateField): + def __init__(self): + super().__init__( + state_entered_event_type=HistoryEventType.SucceedStateEntered, + state_exited_event_type=HistoryEventType.SucceedStateExited, + ) + + def from_state_props(self, state_props: StateProps) -> None: + super(StateSucceed, self).from_state_props(state_props) + # TODO: assert all other fields are undefined? + + # No Next or End field: Succeed states are terminal states. + if state_props.get(Next) or state_props.get(End): + raise ValueError( + f"No Next or End field: Succeed states are terminal states: with state '{self}'." + ) + self.continue_with = ContinueWithSuccess() + + def _eval_state(self, env: Environment) -> None: + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_type.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_type.py new file mode 100644 index 0000000000000..c117789aa8304 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_type.py @@ -0,0 +1,14 @@ +from enum import Enum + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer + + +class StateType(Enum): + Task = ASLLexer.TASK + Pass = ASLLexer.PASS + Choice = ASLLexer.CHOICE + Fail = ASLLexer.FAIL + Succeed = ASLLexer.SUCCEED + Wait = ASLLexer.WAIT + Map = ASLLexer.MAP + Parallel = ASLLexer.PARALLEL diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/state_wait.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/state_wait.py new file mode 100644 index 0000000000000..958377cbcc7e8 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/state_wait.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from localstack.aws.api.stepfunctions import HistoryEventType +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.wait_function import ( + WaitFunction, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class StateWait(CommonStateField): + wait_function: WaitFunction + + def __init__(self): + super().__init__( + state_entered_event_type=HistoryEventType.WaitStateEntered, + state_exited_event_type=HistoryEventType.WaitStateExited, + ) + + def from_state_props(self, state_props: StateProps) -> None: + super(StateWait, self).from_state_props(state_props) + self.wait_function = state_props.get( + typ=WaitFunction, + raise_on_missing=ValueError(f"Undefined WaitFunction for StateWait: '{self}'."), + ) + + def _eval_state(self, env: Environment) -> None: + self.wait_function.eval(env) + if self.assign_decl: + self.assign_decl.eval(env=env) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/seconds.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/seconds.py new file mode 100644 index 0000000000000..d7a3fc79b8731 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/seconds.py @@ -0,0 +1,35 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringJSONata, +) +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.wait_function import ( + WaitFunction, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment + + +class Seconds(WaitFunction): + # Seconds + # A time, in seconds, to state_wait before beginning the state specified in the Next + # field. You must specify time as a positive, integer value. + + def __init__(self, seconds: int): + self.seconds: Final[int] = seconds + + def _get_wait_seconds(self, env: Environment) -> int: + return self.seconds + + +class SecondsJSONata(WaitFunction): + string_jsonata: Final[StringJSONata] + + def __init__(self, string_jsonata: StringJSONata): + super().__init__() + self.string_jsonata = string_jsonata + + def _get_wait_seconds(self, env: Environment) -> int: + # TODO: add snapshot tests to verify AWS's behaviour about non integer values. + self.string_jsonata.eval(env=env) + max_items: int = int(env.stack.pop()) + return max_items diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/seconds_path.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/seconds_path.py new file mode 100644 index 0000000000000..af840602c5133 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/seconds_path.py @@ -0,0 +1,83 @@ +from typing import Any, Final + +from localstack.aws.api.stepfunctions import ( + ExecutionFailedEventDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringSampler, +) +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.wait_function import ( + WaitFunction, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.utils.json_path import NoSuchJsonPathError + + +class SecondsPath(WaitFunction): + # SecondsPath + # A time, in seconds, to state_wait before beginning the state specified in the Next + # field, specified using a path from the state's input data. + # You must specify an integer value for this field. + string_sampler: Final[StringSampler] + + def __init__(self, string_sampler: StringSampler): + self.string_sampler = string_sampler + + def _validate_seconds_value(self, env: Environment, seconds: Any): + if isinstance(seconds, int) and seconds >= 0: + return + error_type = StatesErrorNameType.StatesRuntime + + assignment_description = f"{self.string_sampler.literal_value} == {seconds}" + if not isinstance(seconds, int): + cause = f"The SecondsPath parameter cannot be parsed as a long value: {assignment_description}" + else: # seconds < 0 + cause = ( + f"The SecondsPath parameter references a negative value: {assignment_description}" + ) + + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=error_type), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=error_type.to_name(), cause=cause + ) + ), + ) + ) + + def _get_wait_seconds(self, env: Environment) -> int: + try: + self.string_sampler.eval(env=env) + except NoSuchJsonPathError as no_such_json_path_error: + cause = f"The SecondsPath parameter does not reference an input value: {no_such_json_path_error.json_path}" + raise FailureEventException( + failure_event=FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), cause=cause + ) + ), + ) + ) + seconds = env.stack.pop() + self._validate_seconds_value(env=env, seconds=seconds) + return seconds diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/timestamp.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/timestamp.py new file mode 100644 index 0000000000000..f26583bf77d10 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/timestamp.py @@ -0,0 +1,102 @@ +import datetime +import re +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ExecutionFailedEventDetails, HistoryEventType +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringExpression, + StringLiteral, +) +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.wait_function import ( + WaitFunction, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails + +TIMESTAMP_FORMAT: Final[str] = "%Y-%m-%dT%H:%M:%SZ" +# TODO: could be a bit more exact (e.g. 90 shouldn't be a valid minute) +TIMESTAMP_PATTERN: Final[str] = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$" + + +class Timestamp(WaitFunction): + string: Final[StringExpression] + + def __init__(self, string: StringExpression): + self.string = string + # If a string literal, assert it encodes a valid timestamp. + if isinstance(string, StringLiteral): + timestamp = string.literal_value + if self._from_timestamp_string(timestamp) is None: + raise ValueError( + "The Timestamp value does not reference a valid ISO-8601 " + f"extended offset date-time format string: '{timestamp}'" + ) + + @staticmethod + def _is_valid_timestamp_pattern(timestamp: str) -> bool: + return re.match(TIMESTAMP_PATTERN, timestamp) is not None + + @staticmethod + def _from_timestamp_string(timestamp: str) -> Optional[datetime]: + if not Timestamp._is_valid_timestamp_pattern(timestamp): + return None + try: + # anything lower than seconds is truncated + processed_timestamp = timestamp.rsplit(".", 2)[0] + # add back the "Z" suffix if we removed it + if not processed_timestamp.endswith("Z"): + processed_timestamp = f"{processed_timestamp}Z" + datetime_timestamp = datetime.datetime.strptime(processed_timestamp, TIMESTAMP_FORMAT) + return datetime_timestamp + except Exception: + return None + + def _create_failure_event(self, env: Environment, timestamp_str: str) -> FailureEvent: + return FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), + cause="The Timestamp parameter does not reference a valid ISO-8601 " + f"extended offset date-time format string: {self.string.literal_value} == {timestamp_str}", + ) + ), + ) + + def _get_wait_seconds(self, env: Environment) -> int: + self.string.eval(env=env) + timestamp_str: str = env.stack.pop() + timestamp = self._from_timestamp_string(timestamp=timestamp_str) + if timestamp is None: + raise FailureEventException(self._create_failure_event(env, timestamp_str)) + delta = timestamp - datetime.datetime.now() + delta_sec = int(delta.total_seconds()) + return delta_sec + + +class TimestampPath(Timestamp): + def _create_failure_event(self, env: Environment, timestamp_str: str) -> FailureEvent: + return FailureEvent( + env=env, + error_name=StatesErrorName(typ=StatesErrorNameType.StatesRuntime), + event_type=HistoryEventType.ExecutionFailed, + event_details=EventDetails( + executionFailedEventDetails=ExecutionFailedEventDetails( + error=StatesErrorNameType.StatesRuntime.to_name(), + cause="The TimestampPath parameter does not reference a valid ISO-8601 " + f"extended offset date-time format string: {self.string.literal_value} == {timestamp_str}", + ) + ), + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/wait_function.py b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/wait_function.py new file mode 100644 index 0000000000000..48611e897c532 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/state/state_wait/wait_function/wait_function.py @@ -0,0 +1,41 @@ +import abc +import logging +import time + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment + +LOG = logging.getLogger(__name__) + + +class WaitFunction(EvalComponent, abc.ABC): + @abc.abstractmethod + def _get_wait_seconds(self, env: Environment) -> int: ... + + def _wait_interval(self, env: Environment, wait_seconds: int) -> None: + t0 = time.time() + if wait_seconds > 0: + env.program_state_event.wait(wait_seconds) + t1 = time.time() + round_sec_waited = int(t1 - t0) + wait_seconds_delta = wait_seconds - round_sec_waited + if wait_seconds_delta <= 0: + return + elif env.is_running(): + # Unrelated interrupt: continue waiting. + LOG.warning( + "Wait function '%s' successfully reentered waiting for another '%s' seconds.", + self, + wait_seconds_delta, + ) + return self._wait_interval(env=env, wait_seconds=wait_seconds_delta) + else: + LOG.info( + "Wait function '%s' successfully interrupted after '%s' seconds.", + self, + round_sec_waited, + ) + + def _eval_body(self, env: Environment) -> None: + w_sec = self._get_wait_seconds(env=env) + self._wait_interval(env=env, wait_seconds=w_sec) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/test_state/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/test_state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/test_state/program/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/test_state/program/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/test_state/program/test_state_program.py b/localstack-core/localstack/services/stepfunctions/asl/component/test_state/program/test_state_program.py new file mode 100644 index 0000000000000..a89aa948605d7 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/test_state/program/test_state_program.py @@ -0,0 +1,61 @@ +import logging +import threading +from typing import Final + +from localstack.aws.api.stepfunctions import ( + ExecutionFailedEventDetails, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.eval.test_state.environment import TestStateEnvironment +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.utils.threads import TMP_THREADS + +LOG = logging.getLogger(__name__) + +TEST_CASE_EXECUTION_TIMEOUT_SECONDS: Final[int] = 300 # 5 minutes. + + +class TestStateProgram(EvalComponent): + test_state: Final[CommonStateField] + + def __init__( + self, + test_state: CommonStateField, + ): + self.test_state = test_state + + def eval(self, env: TestStateEnvironment) -> None: + env.next_state_name = self.test_state.name + worker_thread = threading.Thread(target=super().eval, args=(env,), daemon=True) + TMP_THREADS.append(worker_thread) + worker_thread.start() + worker_thread.join(timeout=TEST_CASE_EXECUTION_TIMEOUT_SECONDS) + is_timeout = worker_thread.is_alive() + if is_timeout: + env.set_timed_out() + + def _eval_body(self, env: TestStateEnvironment) -> None: + try: + env.inspection_data["input"] = to_json_str(env.states.get_input()) + self.test_state.eval(env=env) + except FailureEventException as ex: + env.set_error(error=ex.get_execution_failed_event_details()) + except Exception as ex: + cause = f"{type(ex).__name__}({str(ex)})" + LOG.error("Stepfunctions computation ended with exception '%s'.", cause) + env.set_error( + ExecutionFailedEventDetails( + error=StatesErrorName(typ=StatesErrorNameType.StatesRuntime).error_name, + cause=cause, + ) + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/test_state/state/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/component/test_state/state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/component/test_state/state/test_state_state_props.py b/localstack-core/localstack/services/stepfunctions/asl/component/test_state/state/test_state_state_props.py new file mode 100644 index 0000000000000..00d65036f0653 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/component/test_state/state/test_state_state_props.py @@ -0,0 +1,21 @@ +from typing import Any, Final + +from localstack.services.stepfunctions.asl.component.common.parargs import Parargs +from localstack.services.stepfunctions.asl.component.common.path.input_path import InputPath +from localstack.services.stepfunctions.asl.component.common.path.result_path import ResultPath +from localstack.services.stepfunctions.asl.component.common.result_selector import ResultSelector +from localstack.services.stepfunctions.asl.component.state.state_pass.result import Result +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps + +EQUAL_SUBTYPES: Final[list[type]] = [InputPath, Parargs, ResultSelector, ResultPath, Result] + + +class TestStateStateProps(StateProps): + def add(self, instance: Any) -> None: + inst_type = type(instance) + # Subclasses + for typ in EQUAL_SUBTYPES: + if issubclass(inst_type, typ): + self._add(typ, instance) + return + super().add(instance=instance) diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/eval/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/callback/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/eval/callback/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/callback/callback.py b/localstack-core/localstack/services/stepfunctions/asl/eval/callback/callback.py new file mode 100644 index 0000000000000..c5c27a05f4723 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/callback/callback.py @@ -0,0 +1,251 @@ +import abc +from collections import OrderedDict +from threading import Event, Lock +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ActivityDoesNotExist, Arn +from localstack.services.stepfunctions.backend.activity import Activity, ActivityTask +from localstack.utils.strings import long_uid + +CallbackId = str + + +class CallbackOutcome(abc.ABC): + callback_id: Final[CallbackId] + + def __init__(self, callback_id: str): + self.callback_id = callback_id + + +class CallbackOutcomeSuccess(CallbackOutcome): + output: Final[str] + + def __init__(self, callback_id: CallbackId, output: str): + super().__init__(callback_id=callback_id) + self.output = output + + +class CallbackOutcomeFailure(CallbackOutcome): + error: Final[Optional[str]] + cause: Final[Optional[str]] + + def __init__(self, callback_id: CallbackId, error: Optional[str], cause: Optional[str]): + super().__init__(callback_id=callback_id) + self.error = error + self.cause = cause + + +class CallbackOutcomeTimedOut(CallbackOutcome): + pass + + +class CallbackTimeoutError(TimeoutError): + pass + + +class CallbackConsumerError(abc.ABC): ... + + +class CallbackConsumerTimeout(CallbackConsumerError): + pass + + +class CallbackConsumerLeft(CallbackConsumerError): + pass + + +class HeartbeatEndpoint: + _mutex: Final[Lock] + _next_heartbeat_event: Final[Event] + _heartbeat_seconds: Final[int] + + def __init__(self, heartbeat_seconds: int): + self._mutex = Lock() + self._next_heartbeat_event = Event() + self._heartbeat_seconds = heartbeat_seconds + + def clear_and_wait(self) -> bool: + with self._mutex: + if self._next_heartbeat_event.is_set(): + self._next_heartbeat_event.clear() + return True + return self._next_heartbeat_event.wait(timeout=self._heartbeat_seconds) + + def notify(self): + with self._mutex: + self._next_heartbeat_event.set() + + +class HeartbeatTimeoutError(TimeoutError): + pass + + +class HeartbeatTimedOut(CallbackConsumerError): + pass + + +class ActivityTaskStartOutcome: + worker_name: Optional[str] + + def __init__(self, worker_name: Optional[str] = None): + self.worker_name = worker_name + + +class ActivityTaskStartEndpoint: + _next_activity_task_start_event: Final[Event] + _outcome: Optional[ActivityTaskStartOutcome] + + def __init__(self): + self._next_activity_task_start_event = Event() + + def wait(self, timeout_seconds: float) -> Optional[ActivityTaskStartOutcome]: + self._next_activity_task_start_event.wait(timeout=timeout_seconds) + return self._outcome + + def notify(self, activity_task: ActivityTaskStartOutcome) -> None: + self._outcome = activity_task + self._next_activity_task_start_event.set() + + +class CallbackEndpoint: + callback_id: Final[CallbackId] + _notify_event: Final[Event] + _outcome: Optional[CallbackOutcome] + consumer_error: Optional[CallbackConsumerError] + _heartbeat_endpoint: Optional[HeartbeatEndpoint] + + def __init__(self, callback_id: CallbackId): + self.callback_id = callback_id + self._notify_event = Event() + self._outcome = None + self.consumer_error = None + self._heartbeat_endpoint = None + + def setup_heartbeat_endpoint(self, heartbeat_seconds: int) -> HeartbeatEndpoint: + self._heartbeat_endpoint = HeartbeatEndpoint(heartbeat_seconds=heartbeat_seconds) + return self._heartbeat_endpoint + + def interrupt_all(self) -> None: + # Interrupts all waiting processes on this endpoint. + self._notify_event.set() + heartbeat_endpoint = self._heartbeat_endpoint + if heartbeat_endpoint is not None: + heartbeat_endpoint.notify() + + def notify(self, outcome: CallbackOutcome): + self._outcome = outcome + self._notify_event.set() + if self._heartbeat_endpoint: + self._heartbeat_endpoint.notify() + + def notify_heartbeat(self) -> bool: + if not self._heartbeat_endpoint: + return False + self._heartbeat_endpoint.notify() + return True + + def wait(self, timeout: Optional[float] = None) -> Optional[CallbackOutcome]: + self._notify_event.wait(timeout=timeout) + return self._outcome + + def get_outcome(self) -> Optional[CallbackOutcome]: + return self._outcome + + def report(self, consumer_error: CallbackConsumerError) -> None: + self.consumer_error = consumer_error + + +class ActivityCallbackEndpoint(CallbackEndpoint): + _activity_task_start_endpoint: Final[ActivityTaskStartEndpoint] + _activity_input: Final[str] + + def __init__(self, callback_id: str, activity_input: str): + super().__init__(callback_id=callback_id) + self._activity_input = activity_input + self._activity_task_start_endpoint = ActivityTaskStartEndpoint() + + def get_activity_input(self) -> str: + return self._activity_input + + def get_activity_task_start_endpoint(self) -> ActivityTaskStartEndpoint: + return self._activity_task_start_endpoint + + def notify_activity_task_start(self, worker_name: Optional[str]) -> None: + self._activity_task_start_endpoint.notify(ActivityTaskStartOutcome(worker_name=worker_name)) + + +class CallbackNotifyConsumerError(RuntimeError): + callback_consumer_error: CallbackConsumerError + + def __init__(self, callback_consumer_error: CallbackConsumerError): + self.callback_consumer_error = callback_consumer_error + + +class CallbackOutcomeFailureError(RuntimeError): + callback_outcome_failure: CallbackOutcomeFailure + + def __init__(self, callback_outcome_failure: CallbackOutcomeFailure): + self.callback_outcome_failure = callback_outcome_failure + + +class CallbackPoolManager: + _activity_store: Final[dict[CallbackId, Activity]] + _pool: Final[dict[CallbackId, CallbackEndpoint]] + + def __init__(self, activity_store: dict[Arn, Activity]): + self._activity_store = activity_store + self._pool = OrderedDict() + + def get(self, callback_id: CallbackId) -> Optional[CallbackEndpoint]: + return self._pool.get(callback_id) + + def add(self, callback_id: CallbackId) -> CallbackEndpoint: + if callback_id in self._pool: + raise ValueError("Duplicate callback token id value.") + callback_endpoint = CallbackEndpoint(callback_id=callback_id) + self._pool[callback_id] = callback_endpoint + return callback_endpoint + + def add_activity_task( + self, callback_id: CallbackId, activity_arn: Arn, activity_input: str + ) -> ActivityCallbackEndpoint: + if callback_id in self._pool: + raise ValueError("Duplicate callback token id value.") + + maybe_activity: Optional[Activity] = self._activity_store.get(activity_arn) + if maybe_activity is None: + raise ActivityDoesNotExist() + + maybe_activity.add_task(ActivityTask(task_token=callback_id, task_input=activity_input)) + + callback_endpoint = ActivityCallbackEndpoint( + callback_id=callback_id, activity_input=activity_input + ) + self._pool[callback_id] = callback_endpoint + return callback_endpoint + + def generate(self) -> CallbackEndpoint: + return self.add(long_uid()) + + def notify(self, callback_id: CallbackId, outcome: CallbackOutcome) -> bool: + callback_endpoint = self._pool.get(callback_id, None) + if callback_endpoint is None: + return False + + consumer_error: Optional[CallbackConsumerError] = callback_endpoint.consumer_error + if consumer_error is not None: + raise CallbackNotifyConsumerError(callback_consumer_error=consumer_error) + + callback_endpoint.notify(outcome=outcome) + return True + + def heartbeat(self, callback_id: CallbackId) -> bool: + callback_endpoint = self._pool.get(callback_id, None) + if callback_endpoint is None: + return False + + consumer_error: Optional[CallbackConsumerError] = callback_endpoint.consumer_error + if consumer_error is not None: + raise CallbackNotifyConsumerError(callback_consumer_error=consumer_error) + + return callback_endpoint.notify_heartbeat() diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/contex_object.py b/localstack-core/localstack/services/stepfunctions/asl/eval/contex_object.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/count_down_latch.py b/localstack-core/localstack/services/stepfunctions/asl/eval/count_down_latch.py new file mode 100644 index 0000000000000..46ada63f6f80c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/count_down_latch.py @@ -0,0 +1,21 @@ +import threading + + +class CountDownLatch: + # TODO: add timeout support. + def __init__(self, num: int): + self._num: int = num + self.lock = threading.Condition() + + def count_down(self) -> None: + self.lock.acquire() + self._num -= 1 + if self._num <= 0: + self.lock.notify_all() + self.lock.release() + + def wait(self) -> None: + self.lock.acquire() + while self._num > 0: + self.lock.wait() + self.lock.release() diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/environment.py b/localstack-core/localstack/services/stepfunctions/asl/eval/environment.py new file mode 100644 index 0000000000000..ecb90be5b8d07 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/environment.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import copy +import logging +import threading +from typing import Any, Final, Optional + +from localstack.aws.api.stepfunctions import ( + Arn, + ExecutionFailedEventDetails, + StateMachineType, + Timestamp, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.map_run_record import ( + MapRunRecordPoolManager, +) +from localstack.services.stepfunctions.asl.eval.callback.callback import CallbackPoolManager +from localstack.services.stepfunctions.asl.eval.evaluation_details import AWSExecutionDetails +from localstack.services.stepfunctions.asl.eval.event.event_manager import ( + EventHistoryContext, + EventManager, +) +from localstack.services.stepfunctions.asl.eval.event.logging import ( + CloudWatchLoggingSession, +) +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramEnded, + ProgramError, + ProgramRunning, + ProgramState, + ProgramStopped, + ProgramTimedOut, +) +from localstack.services.stepfunctions.asl.eval.states import ContextObjectData, States +from localstack.services.stepfunctions.asl.eval.variable_store import VariableStore +from localstack.services.stepfunctions.backend.activity import Activity +from localstack.services.stepfunctions.mocking.mock_config import MockedResponse, MockTestCase + +LOG = logging.getLogger(__name__) + + +class Environment: + _state_mutex: Final[threading.RLock()] + _program_state: Optional[ProgramState] + program_state_event: Final[threading.Event()] + + event_manager: EventManager + event_history_context: Final[EventHistoryContext] + cloud_watch_logging_session: Final[Optional[CloudWatchLoggingSession]] + aws_execution_details: Final[AWSExecutionDetails] + execution_type: Final[StateMachineType] + callback_pool_manager: CallbackPoolManager + map_run_record_pool_manager: MapRunRecordPoolManager + activity_store: Final[dict[Arn, Activity]] + mock_test_case: Optional[MockTestCase] = None + + _frames: Final[list[Environment]] + _is_frame: bool = False + + heap: dict[str, Any] = dict() + stack: list[Any] = list() + states: Final[States] + variable_store: Final[VariableStore] + + def __init__( + self, + aws_execution_details: AWSExecutionDetails, + execution_type: StateMachineType, + context: ContextObjectData, + event_history_context: EventHistoryContext, + cloud_watch_logging_session: Optional[CloudWatchLoggingSession], + activity_store: dict[Arn, Activity], + variable_store: Optional[VariableStore] = None, + mock_test_case: Optional[MockTestCase] = None, + ): + super(Environment, self).__init__() + self._state_mutex = threading.RLock() + self._program_state = None + self.program_state_event = threading.Event() + + self.cloud_watch_logging_session = cloud_watch_logging_session + self.event_manager = EventManager(cloud_watch_logging_session=cloud_watch_logging_session) + self.event_history_context = event_history_context + + self.aws_execution_details = aws_execution_details + self.execution_type = execution_type + self.callback_pool_manager = CallbackPoolManager(activity_store=activity_store) + self.map_run_record_pool_manager = MapRunRecordPoolManager() + + self.activity_store = activity_store + + self.mock_test_case = mock_test_case + + self._frames = list() + self._is_frame = False + + self.heap = dict() + self.stack = list() + self.states = States(context=context) + self.variable_store = variable_store or VariableStore() + + @classmethod + def as_frame_of( + cls, env: Environment, event_history_frame_cache: Optional[EventHistoryContext] = None + ) -> Environment: + return Environment.as_inner_frame_of( + env=env, + variable_store=env.variable_store, + event_history_frame_cache=event_history_frame_cache, + ) + + @classmethod + def as_inner_frame_of( + cls, + env: Environment, + variable_store: VariableStore, + event_history_frame_cache: Optional[EventHistoryContext] = None, + ) -> Environment: + # Construct the frame's context object data. + context = ContextObjectData( + Execution=env.states.context_object.context_object_data["Execution"], + StateMachine=env.states.context_object.context_object_data["StateMachine"], + ) + if "Task" in env.states.context_object.context_object_data: + context["Task"] = env.states.context_object.context_object_data["Task"] + + # The default logic provisions for child frame to extend the source frame event id. + if event_history_frame_cache is None: + event_history_frame_cache = EventHistoryContext( + previous_event_id=env.event_history_context.source_event_id + ) + + frame = cls( + aws_execution_details=env.aws_execution_details, + execution_type=env.execution_type, + context=context, + event_history_context=event_history_frame_cache, + cloud_watch_logging_session=env.cloud_watch_logging_session, + activity_store=env.activity_store, + variable_store=variable_store, + mock_test_case=env.mock_test_case, + ) + frame._is_frame = True + frame.event_manager = env.event_manager + if "State" in env.states.context_object.context_object_data: + frame.states.context_object.context_object_data["State"] = copy.deepcopy( + env.states.context_object.context_object_data["State"] + ) + frame.callback_pool_manager = env.callback_pool_manager + frame.map_run_record_pool_manager = env.map_run_record_pool_manager + frame.heap = dict() + frame._program_state = copy.deepcopy(env._program_state) + return frame + + @property + def next_state_name(self) -> Optional[str]: + next_state_name: Optional[str] = None + program_state = self._program_state + if isinstance(program_state, ProgramRunning): + next_state_name = program_state.next_state_name + return next_state_name + + @next_state_name.setter + def next_state_name(self, next_state_name: str) -> None: + if self._program_state is None: + self._program_state = ProgramRunning() + + if isinstance(self._program_state, ProgramRunning): + self._program_state.next_state_name = next_state_name + else: + raise RuntimeError( + f"Could not set NextState value when in state '{type(self._program_state)}'." + ) + + @property + def next_field_name(self) -> Optional[str]: + next_field_name: Optional[str] = None + program_state = self._program_state + if isinstance(program_state, ProgramRunning): + next_field_name = program_state.next_field_name + return next_field_name + + @next_field_name.setter + def next_field_name(self, next_field_name: str) -> None: + if isinstance(self._program_state, ProgramRunning): + self._program_state.next_field_name = next_field_name + else: + raise RuntimeError( + f"Could not set NextField value when in state '{type(self._program_state)}'." + ) + + def program_state(self) -> ProgramState: + return copy.deepcopy(self._program_state) + + def is_running(self) -> bool: + return isinstance(self._program_state, ProgramRunning) + + def set_ended(self) -> None: + with self._state_mutex: + if isinstance(self._program_state, ProgramRunning): + self._program_state = ProgramEnded() + for frame in self._frames: + frame.set_ended() + self.program_state_event.set() + self.program_state_event.clear() + + def set_error(self, error: ExecutionFailedEventDetails) -> None: + with self._state_mutex: + self._program_state = ProgramError(error=error) + for frame in self._frames: + frame.set_error(error=error) + self.program_state_event.set() + self.program_state_event.clear() + + def set_timed_out(self) -> None: + with self._state_mutex: + self._program_state = ProgramTimedOut() + for frame in self._frames: + frame.set_timed_out() + self.program_state_event.set() + self.program_state_event.clear() + + def set_stop(self, stop_date: Timestamp, cause: Optional[str], error: Optional[str]) -> None: + with self._state_mutex: + if isinstance(self._program_state, ProgramRunning): + self._program_state = ProgramStopped(stop_date=stop_date, cause=cause, error=error) + for frame in self._frames: + frame.set_stop(stop_date=stop_date, cause=cause, error=error) + self.program_state_event.set() + self.program_state_event.clear() + + def open_frame( + self, event_history_context: Optional[EventHistoryContext] = None + ) -> Environment: + with self._state_mutex: + frame = self.as_frame_of(env=self, event_history_frame_cache=event_history_context) + self._frames.append(frame) + return frame + + def open_inner_frame( + self, event_history_context: Optional[EventHistoryContext] = None + ) -> Environment: + with self._state_mutex: + variable_store = VariableStore.as_inner_scope_of( + outer_variable_store=self.variable_store + ) + frame = self.as_inner_frame_of( + env=self, + variable_store=variable_store, + event_history_frame_cache=event_history_context, + ) + self._frames.append(frame) + return frame + + def close_frame(self, frame: Environment) -> None: + with self._state_mutex: + if frame in self._frames: + self._frames.remove(frame) + self.event_history_context.integrate(frame.event_history_context) + + def delete_frame(self, frame: Environment) -> None: + with self._state_mutex: + if frame in self._frames: + self._frames.remove(frame) + + def is_frame(self) -> bool: + return self._is_frame + + def is_standard_workflow(self) -> bool: + return self.execution_type == StateMachineType.STANDARD + + def is_mocked_mode(self) -> bool: + """ + Returns True if the state machine is running in mock mode and the current + state has a defined mock configuration in the target environment or frame; + otherwise, returns False. + """ + return ( + self.mock_test_case is not None + and self.next_state_name in self.mock_test_case.state_mocked_responses + ) + + def get_current_mocked_response(self) -> MockedResponse: + if not self.is_mocked_mode(): + raise RuntimeError( + "Cannot retrieve mocked response: execution is not operating in mocked mode" + ) + state_name = self.next_state_name + state_mocked_responses: Optional = self.mock_test_case.state_mocked_responses.get( + state_name + ) + if state_mocked_responses is None: + raise RuntimeError(f"No mocked response definition for state '{state_name}'") + retry_count = self.states.context_object.context_object_data["State"]["RetryCount"] + if len(state_mocked_responses.mocked_responses) <= retry_count: + raise RuntimeError( + f"No mocked response definition for state '{state_name}' " + f"and retry number '{retry_count}'" + ) + return state_mocked_responses.mocked_responses[retry_count] diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/evaluation_details.py b/localstack-core/localstack/services/stepfunctions/asl/eval/evaluation_details.py new file mode 100644 index 0000000000000..d053ae70e2187 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/evaluation_details.py @@ -0,0 +1,60 @@ +from typing import Any, Final, Optional + +from localstack.aws.api.stepfunctions import Arn, Definition, LongArn, StateMachineType + + +class AWSExecutionDetails: + account: Final[str] + region: Final[str] + role_arn: Final[str] + + def __init__(self, account: str, region: str, role_arn: str): + self.account = account + self.region = region + self.role_arn = role_arn + + +class ExecutionDetails: + arn: Final[LongArn] + name: Final[str] + role_arn: Final[Arn] + inpt: Final[Optional[Any]] + start_time: Final[str] + + def __init__( + self, arn: LongArn, name: str, role_arn: Arn, inpt: Optional[Any], start_time: str + ): + self.arn = arn + self.name = name + self.role_arn = role_arn + self.inpt = inpt + self.start_time = start_time + + +class StateMachineDetails: + arn: Final[Arn] + name: Final[str] + typ: Final[StateMachineType] + definition: Final[Definition] + + def __init__(self, arn: Arn, name: str, typ: StateMachineType, definition: str): + self.arn = arn + self.name = name + self.typ = typ + self.definition = definition + + +class EvaluationDetails: + aws_execution_details: Final[AWSExecutionDetails] + execution_details: Final[ExecutionDetails] + state_machine_details: Final[StateMachineDetails] + + def __init__( + self, + aws_execution_details: AWSExecutionDetails, + execution_details: ExecutionDetails, + state_machine_details: StateMachineDetails, + ): + self.aws_execution_details = aws_execution_details + self.execution_details = execution_details + self.state_machine_details = state_machine_details diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/event/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/eval/event/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/event/event_detail.py b/localstack-core/localstack/services/stepfunctions/asl/eval/event/event_detail.py new file mode 100644 index 0000000000000..c096a8d3f9556 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/event/event_detail.py @@ -0,0 +1,74 @@ +from typing import NotRequired, TypedDict + +from localstack.aws.api.stepfunctions import ( + ActivityFailedEventDetails, + ActivityScheduledEventDetails, + ActivityScheduleFailedEventDetails, + ActivityStartedEventDetails, + ActivitySucceededEventDetails, + ActivityTimedOutEventDetails, + EvaluationFailedEventDetails, + ExecutionAbortedEventDetails, + ExecutionFailedEventDetails, + ExecutionStartedEventDetails, + ExecutionSucceededEventDetails, + ExecutionTimedOutEventDetails, + LambdaFunctionFailedEventDetails, + LambdaFunctionScheduledEventDetails, + LambdaFunctionScheduleFailedEventDetails, + LambdaFunctionStartFailedEventDetails, + LambdaFunctionSucceededEventDetails, + LambdaFunctionTimedOutEventDetails, + MapIterationEventDetails, + MapRunFailedEventDetails, + MapRunStartedEventDetails, + MapStateStartedEventDetails, + StateEnteredEventDetails, + StateExitedEventDetails, + TaskFailedEventDetails, + TaskScheduledEventDetails, + TaskStartedEventDetails, + TaskStartFailedEventDetails, + TaskSubmitFailedEventDetails, + TaskSubmittedEventDetails, + TaskSucceededEventDetails, + TaskTimedOutEventDetails, +) + + +class EventDetails(TypedDict): + activityFailedEventDetails: NotRequired[ActivityFailedEventDetails] + activityScheduleFailedEventDetails: NotRequired[ActivityScheduleFailedEventDetails] + activityScheduledEventDetails: NotRequired[ActivityScheduledEventDetails] + activityStartedEventDetails: NotRequired[ActivityStartedEventDetails] + activitySucceededEventDetails: NotRequired[ActivitySucceededEventDetails] + activityTimedOutEventDetails: NotRequired[ActivityTimedOutEventDetails] + taskFailedEventDetails: NotRequired[TaskFailedEventDetails] + taskScheduledEventDetails: NotRequired[TaskScheduledEventDetails] + taskStartFailedEventDetails: NotRequired[TaskStartFailedEventDetails] + taskStartedEventDetails: NotRequired[TaskStartedEventDetails] + taskSubmitFailedEventDetails: NotRequired[TaskSubmitFailedEventDetails] + taskSubmittedEventDetails: NotRequired[TaskSubmittedEventDetails] + taskSucceededEventDetails: NotRequired[TaskSucceededEventDetails] + taskTimedOutEventDetails: NotRequired[TaskTimedOutEventDetails] + evaluationFailedEventDetails: NotRequired[EvaluationFailedEventDetails] + executionFailedEventDetails: NotRequired[ExecutionFailedEventDetails] + executionStartedEventDetails: NotRequired[ExecutionStartedEventDetails] + executionSucceededEventDetails: NotRequired[ExecutionSucceededEventDetails] + executionAbortedEventDetails: NotRequired[ExecutionAbortedEventDetails] + executionTimedOutEventDetails: NotRequired[ExecutionTimedOutEventDetails] + mapStateStartedEventDetails: NotRequired[MapStateStartedEventDetails] + mapIterationStartedEventDetails: NotRequired[MapIterationEventDetails] + mapIterationSucceededEventDetails: NotRequired[MapIterationEventDetails] + mapIterationFailedEventDetails: NotRequired[MapIterationEventDetails] + mapIterationAbortedEventDetails: NotRequired[MapIterationEventDetails] + lambdaFunctionFailedEventDetails: NotRequired[LambdaFunctionFailedEventDetails] + lambdaFunctionScheduleFailedEventDetails: NotRequired[LambdaFunctionScheduleFailedEventDetails] + lambdaFunctionScheduledEventDetails: NotRequired[LambdaFunctionScheduledEventDetails] + lambdaFunctionStartFailedEventDetails: NotRequired[LambdaFunctionStartFailedEventDetails] + lambdaFunctionSucceededEventDetails: NotRequired[LambdaFunctionSucceededEventDetails] + lambdaFunctionTimedOutEventDetails: NotRequired[LambdaFunctionTimedOutEventDetails] + stateEnteredEventDetails: NotRequired[StateEnteredEventDetails] + stateExitedEventDetails: NotRequired[StateExitedEventDetails] + mapRunStartedEventDetails: NotRequired[MapRunStartedEventDetails] + mapRunFailedEventDetails: NotRequired[MapRunFailedEventDetails] diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/event/event_manager.py b/localstack-core/localstack/services/stepfunctions/asl/eval/event/event_manager.py new file mode 100644 index 0000000000000..8a9ea31a47287 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/event/event_manager.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import copy +import datetime +import logging +import threading +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + HistoryEvent, + HistoryEventList, + HistoryEventType, + LongArn, + Timestamp, +) +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.eval.event.logging import ( + CloudWatchLoggingSession, + HistoryLog, +) +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +LOG = logging.getLogger(__name__) + + +class EventHistoryContext: + # The '0' event is the source event id of the program execution. + _PROGRAM_START_EVENT_ID: Final[int] = 0 + + source_event_id: int + last_published_event_id: int + + def __init__(self, previous_event_id: int): + self.source_event_id = previous_event_id + self.last_published_event_id = previous_event_id + + @classmethod + def of_program_start(cls) -> EventHistoryContext: + return cls(previous_event_id=cls._PROGRAM_START_EVENT_ID) + + def integrate(self, other: EventHistoryContext) -> None: + self.source_event_id = max(self.source_event_id, other.source_event_id) + self.last_published_event_id = max( + self.last_published_event_id, other.last_published_event_id + ) + + +class EventIdGenerator: + _next_id: int + + def __init__(self): + self._next_id = 1 + + def get(self) -> int: + next_id = self._next_id + self._next_id += 1 + return next_id + + +class EventManager: + _mutex: Final[threading.Lock] + _event_id_gen: EventIdGenerator + _history_event_list: Final[HistoryEventList] + _cloud_watch_logging_session: Final[Optional[CloudWatchLoggingSession]] + + def __init__(self, cloud_watch_logging_session: Optional[CloudWatchLoggingSession] = None): + self._mutex = threading.Lock() + self._event_id_gen = EventIdGenerator() + self._history_event_list = list() + self._cloud_watch_logging_session = cloud_watch_logging_session + + def add_event( + self, + context: EventHistoryContext, + event_type: HistoryEventType, + event_details: Optional[EventDetails] = None, + timestamp: Timestamp = None, + update_source_event_id: bool = True, + ) -> int: + with self._mutex: + event_id: int = self._event_id_gen.get() + source_event_id: int = context.source_event_id + timestamp = timestamp or self._get_current_timestamp() + + context.last_published_event_id = event_id + if update_source_event_id: + context.source_event_id = event_id + + self._publish_history_event( + event_id=event_id, + source_event_id=source_event_id, + event_type=event_type, + timestamp=timestamp, + event_details=event_details, + ) + self._publish_history_log( + event_id=event_id, + source_event_id=source_event_id, + event_type=event_type, + timestamp=timestamp, + event_details=event_details, + ) + + return event_id + + @staticmethod + def _get_current_timestamp() -> datetime.datetime: + return datetime.datetime.now(tz=datetime.timezone.utc) + + @staticmethod + def _create_history_event( + event_id: int, + source_event_id: int, + event_type: HistoryEventType, + timestamp: datetime.datetime, + event_details: Optional[EventDetails], + ) -> HistoryEvent: + history_event = HistoryEvent() + if event_details is not None: + history_event.update(event_details) + history_event["id"] = event_id + history_event["previousEventId"] = source_event_id + history_event["type"] = event_type + history_event["timestamp"] = timestamp + return history_event + + def _publish_history_event( + self, + event_id: int, + source_event_id: int, + event_type: HistoryEventType, + timestamp: datetime.datetime, + event_details: Optional[EventDetails], + ): + history_event = self._create_history_event( + event_id=event_id, + source_event_id=source_event_id, + event_type=event_type, + timestamp=timestamp, + event_details=event_details, + ) + self._history_event_list.append(history_event) + + @staticmethod + def _remove_data_from_history_log(details_body: dict) -> None: + remove_keys = ["input", "inputDetails", "output", "outputDetails"] + for remove_key in remove_keys: + details_body.pop(remove_key, None) + + @staticmethod + def _create_history_log( + event_id: int, + source_event_id: int, + event_type: HistoryEventType, + timestamp: datetime.datetime, + execution_arn: LongArn, + event_details: Optional[EventDetails], + include_execution_data: bool, + ) -> HistoryLog: + log = HistoryLog( + id=str(event_id), + previous_event_id=str(source_event_id), + event_timestamp=timestamp, + type=event_type, + execution_arn=execution_arn, + ) + if event_details: + if len(event_details) > 1: + LOG.warning( + "Event details with multiple bindings: %s", + to_json_str(event_details), + ) + details_body = next(iter(event_details.values())) + if not include_execution_data: + # clone the object before modifying it as the change is limited to the history log value. + details_body = copy.deepcopy(details_body) + EventManager._remove_data_from_history_log(details_body=details_body) + log["details"] = details_body + return log + + def _publish_history_log( + self, + event_id: int, + source_event_id: int, + event_type: HistoryEventType, + timestamp: datetime.datetime, + event_details: Optional[EventDetails], + ): + # No logging session for this execution. + if self._cloud_watch_logging_session is None: + return + + # This event is not recorded by this execution's logging configuration. + if not self._cloud_watch_logging_session.log_level_filter(history_event_type=event_type): + return + + history_log = self._create_history_log( + event_id=event_id, + source_event_id=source_event_id, + event_type=event_type, + timestamp=timestamp, + execution_arn=self._cloud_watch_logging_session.execution_arn, + event_details=event_details, + include_execution_data=self._cloud_watch_logging_session.configuration.include_execution_data, + ) + self._cloud_watch_logging_session.publish_history_log(history_log=history_log) + + def get_event_history(self) -> HistoryEventList: + with self._mutex: + return copy.deepcopy(self._history_event_list) diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/event/logging.py b/localstack-core/localstack/services/stepfunctions/asl/eval/event/logging.py new file mode 100644 index 0000000000000..de504ad2a8255 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/event/logging.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Final, NotRequired, Optional, TypedDict + +from botocore.client import BaseClient +from botocore.exceptions import ClientError +from botocore.utils import InvalidArnException + +from localstack.aws.api.logs import InputLogEvent +from localstack.aws.api.stepfunctions import ( + HistoryEventType, + InvalidLoggingConfiguration, + LoggingConfiguration, + LogLevel, + LongArn, +) +from localstack.aws.connect import connect_to +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.utils.aws.arns import ( + ArnData, + parse_arn, +) + +LOG = logging.getLogger(__name__) + +ExecutionEventLogDetails = dict + +# The following event type sets are compiled according to AWS's +# log level definitions: https://docs.aws.amazon.com/step-functions/latest/dg/cloudwatch-log-level.html +_ERROR_LOG_EVENT_TYPES: Final[set[HistoryEventType]] = { + HistoryEventType.ExecutionAborted, + HistoryEventType.ExecutionFailed, + HistoryEventType.ExecutionTimedOut, + HistoryEventType.FailStateEntered, + HistoryEventType.LambdaFunctionFailed, + HistoryEventType.LambdaFunctionScheduleFailed, + HistoryEventType.LambdaFunctionStartFailed, + HistoryEventType.LambdaFunctionTimedOut, + HistoryEventType.MapStateAborted, + HistoryEventType.MapStateFailed, + HistoryEventType.MapIterationAborted, + HistoryEventType.MapIterationFailed, + HistoryEventType.MapRunAborted, + HistoryEventType.MapRunFailed, + HistoryEventType.ParallelStateAborted, + HistoryEventType.ParallelStateFailed, + HistoryEventType.TaskFailed, + HistoryEventType.TaskStartFailed, + HistoryEventType.TaskStateAborted, + HistoryEventType.TaskSubmitFailed, + HistoryEventType.TaskTimedOut, + HistoryEventType.WaitStateAborted, +} +_FATAL_LOG_EVENT_TYPES: Final[set[HistoryEventType]] = { + HistoryEventType.ExecutionAborted, + HistoryEventType.ExecutionFailed, + HistoryEventType.ExecutionTimedOut, +} + + +# The LogStreamName used when creating the empty Log Stream when validating the logging configuration. +VALIDATION_LOG_STREAM_NAME: Final[str] = ( + "log_stream_created_by_aws_to_validate_log_delivery_subscriptions" +) + + +def is_logging_enabled_for(log_level: LogLevel, history_event_type: HistoryEventType) -> bool: + # Checks whether the history event type is in the context of a give LogLevel. + if log_level == LogLevel.ALL: + return True + elif log_level == LogLevel.OFF: + return False + elif log_level == LogLevel.ERROR: + return history_event_type in _ERROR_LOG_EVENT_TYPES + elif log_level == LogLevel.FATAL: + return history_event_type in _FATAL_LOG_EVENT_TYPES + else: + LOG.error("Unknown LogLevel '%s'", log_level) + + +class CloudWatchLoggingConfiguration: + state_machine_arn: Final[LongArn] + log_level: Final[LogLevel] + log_account_id: Final[str] + log_region: Final[str] + log_group_name: Final[str] + log_stream_name: Final[str] + include_execution_data: Final[bool] + + def __init__( + self, + state_machine_arn: LongArn, + log_account_id: str, + log_region: str, + log_group_name: str, + log_level: LogLevel, + include_execution_data: bool, + ): + self.state_machine_arn = state_machine_arn + self.log_level = log_level + self.log_group_name = log_group_name + self.log_account_id = log_account_id + self.log_region = log_region + # TODO: AWS appears to append a date and a serial number to the log + # stream name: more investigations are needed in this area. + self.log_stream_name = f"states/{state_machine_arn}" + self.include_execution_data = include_execution_data + + @staticmethod + def extract_log_arn_parts_from( + logging_configuration: LoggingConfiguration, + ) -> Optional[tuple[str, str, str]]: + # Returns a tuple with: account_id, region, and log group name if the logging configuration + # specifies a valid cloud watch log group arn, none otherwise. + + destinations = logging_configuration.get("destinations") + if not destinations or len(destinations) > 1: # Only one destination can be defined. + return None + + log_group = destinations[0].get("cloudWatchLogsLogGroup") + if not log_group: + return None + + log_group_arn = log_group.get("logGroupArn") + if not log_group_arn: + return None + + try: + arn_data: ArnData = parse_arn(log_group_arn) + except InvalidArnException: + return None + + log_region = arn_data.get("region") + if log_region is None: + return None + + log_account_id = arn_data.get("account") + if log_account_id is None: + return None + + log_resource = arn_data.get("resource") + if log_resource is None: + return None + + log_resource_parts = log_resource.split("log-group:") + if not log_resource_parts: + return None + + log_group_name = log_resource_parts[-1].split(":")[0] + return log_account_id, log_region, log_group_name + + @staticmethod + def from_logging_configuration( + state_machine_arn: LongArn, + logging_configuration: LoggingConfiguration, + ) -> Optional[CloudWatchLoggingConfiguration]: + log_level = logging_configuration.get("level", LogLevel.OFF) + if log_level == LogLevel.OFF: + return None + + log_arn_parts = CloudWatchLoggingConfiguration.extract_log_arn_parts_from( + logging_configuration=logging_configuration + ) + if not log_arn_parts: + return None + log_account_id, log_region, log_group_name = log_arn_parts + + include_execution_data = logging_configuration["includeExecutionData"] + + return CloudWatchLoggingConfiguration( + state_machine_arn=state_machine_arn, + log_account_id=log_account_id, + log_region=log_region, + log_group_name=log_group_name, + log_level=log_level, + include_execution_data=include_execution_data, + ) + + def validate(self) -> None: + # Asserts that the logging configuration can be used for logging. + logs_client = connect_to( + aws_access_key_id=self.log_account_id, region_name=self.log_region + ).logs + try: + logs_client.create_log_stream( + logGroupName=self.log_group_name, logStreamName=VALIDATION_LOG_STREAM_NAME + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code != "ResourceAlreadyExistsException": + raise InvalidLoggingConfiguration( + "Invalid Logging Configuration: Log Destination not found." + ) + + +class HistoryLog(TypedDict): + id: str + previous_event_id: str + event_timestamp: datetime + type: HistoryEventType + execution_arn: LongArn + details: NotRequired[ExecutionEventLogDetails] + + +class CloudWatchLoggingSession: + execution_arn: Final[LongArn] + configuration: Final[CloudWatchLoggingConfiguration] + _logs_client: Final[BaseClient] + _is_log_stream_available: bool + + def __init__(self, execution_arn: LongArn, configuration: CloudWatchLoggingConfiguration): + self.execution_arn = execution_arn + self.configuration = configuration + self._logs_client = connect_to( + aws_access_key_id=self.configuration.log_account_id, + region_name=self.configuration.log_region, + ).logs + + def log_level_filter(self, history_event_type: HistoryEventType) -> bool: + # Checks whether the history event type should be logged in this session. + return is_logging_enabled_for( + log_level=self.configuration.log_level, history_event_type=history_event_type + ) + + def publish_history_log(self, history_log: HistoryLog) -> None: + timestamp_value = int(history_log["event_timestamp"].timestamp() * 1000) + message = to_json_str(history_log) + log_events = [InputLogEvent(timestamp=timestamp_value, message=message)] + LOG.debug( + "New CloudWatch Log for execution '%s' with message: '%s'", + self.execution_arn, + message, + ) + self._publish_history_log_or_setup(log_events=log_events) + + def _publish_history_log_or_setup(self, log_events: list[InputLogEvent]): + # Attempts to put the events into the given log group and stream, and attempts to create the stream if + # this does not already exist. + is_events_put = self._put_events(log_events=log_events) + if is_events_put: + return + + is_setup = self._setup() + if not is_setup: + LOG.debug( + "CloudWatch Log was not published due to setup errors encountered " + "while creating the LogStream for execution '%s'.", + self.execution_arn, + ) + return + + self._put_events(log_events=log_events) + + def _put_events(self, log_events: list[InputLogEvent]) -> bool: + # Puts the events to the targe log group and stream, and returns false if the LogGroup or LogStream could + # not be found, true otherwise. + try: + self._logs_client.put_log_events( + logGroupName=self.configuration.log_group_name, + logStreamName=self.configuration.log_stream_name, + logEvents=log_events, + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code == "ResourceNotFoundException": + return False + except Exception as ignored: + LOG.warning( + "State Machine execution log event could not be published due to an error: '%s'", + ignored, + ) + return True + + def _setup(self) -> bool: + # Create the log stream if one does not exist already. + # TODO: enhance the verification logic to match AWS's logic to ensure IAM features work as expected. + # https://docs.aws.amazon.com/step-functions/latest/dg/cw-logs.html#cloudwatch-iam-policy + try: + self._logs_client.create_log_stream( + logGroupName=self.configuration.log_group_name, + logStreamName=self.configuration.log_stream_name, + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code != "ResourceAlreadyExistsException": + LOG.error( + "Could not create execution log stream for execution '%s' due to %s", + self.execution_arn, + error, + ) + return False + return True diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/program_state.py b/localstack-core/localstack/services/stepfunctions/asl/eval/program_state.py new file mode 100644 index 0000000000000..00f3af00cb82f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/program_state.py @@ -0,0 +1,64 @@ +import abc +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ExecutionFailedEventDetails, Timestamp + + +class ProgramState(abc.ABC): ... + + +class ProgramEnded(ProgramState): + pass + + +class ProgramStopped(ProgramState): + def __init__(self, stop_date: Timestamp, error: Optional[str], cause: Optional[str]): + super().__init__() + self.stop_date: Timestamp = stop_date + self.error: Optional[str] = error + self.cause: Optional[str] = cause + + +class ProgramRunning(ProgramState): + _next_state_name: Optional[str] + _next_field_name: Optional[str] + + def __init__(self): + super().__init__() + self._next_state_name = None + self._next_field_name = None + + @property + def next_state_name(self) -> str: + next_state_name = self._next_state_name + if next_state_name is None: + raise RuntimeError("Could not retrieve NextState from uninitialised ProgramState.") + return next_state_name + + @next_state_name.setter + def next_state_name(self, next_state_name) -> None: + self._next_state_name = next_state_name + self._next_field_name = None + + @property + def next_field_name(self) -> str: + return self._next_field_name + + @next_field_name.setter + def next_field_name(self, next_field_name) -> None: + next_state_name = self._next_state_name + if next_state_name is None: + raise RuntimeError("Could not set NextField from uninitialised ProgramState.") + self._next_field_name = next_field_name + + +class ProgramError(ProgramState): + error: Final[Optional[ExecutionFailedEventDetails]] + + def __init__(self, error: Optional[ExecutionFailedEventDetails]): + super().__init__() + self.error = error + + +class ProgramTimedOut(ProgramState): + pass diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/states.py b/localstack-core/localstack/services/stepfunctions/asl/eval/states.py new file mode 100644 index 0000000000000..295e4149344e7 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/states.py @@ -0,0 +1,155 @@ +import copy +from typing import Any, Final, NotRequired, Optional, TypedDict + +from localstack.services.stepfunctions.asl.jsonata.jsonata import ( + VariableDeclarations, + VariableReference, + encode_jsonata_variable_declarations, +) +from localstack.services.stepfunctions.asl.utils.json_path import extract_json +from localstack.utils.strings import long_uid + +_STATES_PREFIX: Final[str] = "$states" +_STATES_INPUT_PREFIX: Final[str] = "$states.input" +_STATES_CONTEXT_PREFIX: Final[str] = "$states.context" +_STATES_RESULT_PREFIX: Final[str] = "$states.result" +_STATES_ERROR_OUTPUT_PREFIX: Final[str] = "$states.errorOutput" + + +class ExecutionData(TypedDict): + Id: str + Input: Optional[Any] + Name: str + RoleArn: str + StartTime: str # Format: ISO 8601. + + +class StateData(TypedDict): + EnteredTime: str # Format: ISO 8601. + Name: str + RetryCount: int + + +class StateMachineData(TypedDict): + Id: str + Name: str + + +class TaskData(TypedDict): + Token: str + + +class ItemData(TypedDict): + # Contains the index number for the array item that is being currently processed. + Index: int + # Contains the array item being processed. + Value: Optional[Any] + + +class MapData(TypedDict): + Item: ItemData + + +class ContextObjectData(TypedDict): + Execution: ExecutionData + State: NotRequired[StateData] + StateMachine: StateMachineData + Task: NotRequired[TaskData] # Null if the Parameters field is outside a task state. + Map: NotRequired[MapData] # Only available when processing a Map state. + + +class ContextObject: + context_object_data: Final[ContextObjectData] + + def __init__(self, context_object: ContextObjectData): + self.context_object_data = context_object + + def update_task_token(self) -> str: + new_token = long_uid() + self.context_object_data["Task"] = TaskData(Token=new_token) + return new_token + + +class StatesData(TypedDict): + input: Any + context: ContextObjectData + result: NotRequired[Optional[Any]] + errorOutput: NotRequired[Optional[Any]] + + +class States: + _states_data: Final[StatesData] + context_object: Final[ContextObject] + + def __init__(self, context: ContextObjectData): + input_value = context["Execution"]["Input"] + self._states_data = StatesData(input=input_value, context=context) + self.context_object = ContextObject(context_object=context) + + @staticmethod + def _extract(query: Optional[str], data: Any) -> Any: + if query is None: + result = data + else: + result = extract_json(query, data) + return copy.deepcopy(result) + + def extract(self, query: str) -> Any: + if not query.startswith(_STATES_PREFIX): + raise RuntimeError(f"No such variable {query} in $states") + jsonpath_states_query = "$." + query[1:] + return self._extract(jsonpath_states_query, self._states_data) + + def get_input(self, query: Optional[str] = None) -> Any: + return self._extract(query, self._states_data["input"]) + + def reset(self, input_value: Any) -> None: + clone_input_value = copy.deepcopy(input_value) + self._states_data["input"] = clone_input_value + self._states_data["result"] = None + self._states_data["errorOutput"] = None + + def get_context(self, query: Optional[str] = None) -> Any: + return self._extract(query, self._states_data["context"]) + + def get_result(self, query: Optional[str] = None) -> Any: + if "result" not in self._states_data: + raise RuntimeError("Illegal access to $states.result") + return self._extract(query, self._states_data["result"]) + + def set_result(self, result: Any) -> Any: + clone_result = copy.deepcopy(result) + self._states_data["result"] = clone_result + + def get_error_output(self, query: Optional[str] = None) -> Any: + if "errorOutput" not in self._states_data: + raise RuntimeError("Illegal access to $states.errorOutput") + return self._extract(query, self._states_data["errorOutput"]) + + def set_error_output(self, error_output: Any) -> None: + clone_error_output = copy.deepcopy(error_output) + self._states_data["errorOutput"] = clone_error_output + + def to_variable_declarations( + self, variable_references: Optional[set[VariableReference]] = None + ) -> VariableDeclarations: + if not variable_references or _STATES_PREFIX in variable_references: + return encode_jsonata_variable_declarations( + bindings={_STATES_PREFIX: self._states_data} + ) + candidate_sub_states = { + "input": _STATES_INPUT_PREFIX, + "context": _STATES_CONTEXT_PREFIX, + "result": _STATES_RESULT_PREFIX, + "errorOutput": _STATES_ERROR_OUTPUT_PREFIX, + } + sub_states = dict() + for variable_reference in variable_references: + if not candidate_sub_states: + break + for sub_states_key, sub_states_prefix in candidate_sub_states.items(): + if variable_reference.startswith(sub_states_prefix): + sub_states[sub_states_key] = self._states_data[sub_states_key] # noqa + del candidate_sub_states[sub_states_key] + break + return encode_jsonata_variable_declarations(bindings={_STATES_PREFIX: sub_states}) diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/environment.py b/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/environment.py new file mode 100644 index 0000000000000..8db4b0e427cac --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/environment.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Optional + +from localstack.aws.api.stepfunctions import Arn, InspectionData, StateMachineType +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.evaluation_details import AWSExecutionDetails +from localstack.services.stepfunctions.asl.eval.event.event_manager import ( + EventHistoryContext, +) +from localstack.services.stepfunctions.asl.eval.event.logging import ( + CloudWatchLoggingSession, +) +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramRunning, +) +from localstack.services.stepfunctions.asl.eval.states import ContextObjectData +from localstack.services.stepfunctions.asl.eval.test_state.program_state import ( + ProgramChoiceSelected, +) +from localstack.services.stepfunctions.asl.eval.variable_store import VariableStore +from localstack.services.stepfunctions.backend.activity import Activity + + +class TestStateEnvironment(Environment): + inspection_data: InspectionData + + def __init__( + self, + aws_execution_details: AWSExecutionDetails, + execution_type: StateMachineType, + context: ContextObjectData, + event_history_context: EventHistoryContext, + activity_store: dict[Arn, Activity], + cloud_watch_logging_session: Optional[CloudWatchLoggingSession] = None, + ): + super().__init__( + aws_execution_details=aws_execution_details, + execution_type=execution_type, + context=context, + event_history_context=event_history_context, + cloud_watch_logging_session=cloud_watch_logging_session, + activity_store=activity_store, + ) + self.inspection_data = InspectionData() + + def as_frame_of( + cls, + env: TestStateEnvironment, + event_history_frame_cache: Optional[EventHistoryContext] = None, + ) -> Environment: + frame = super().as_frame_of(env=env, event_history_frame_cache=event_history_frame_cache) + frame.inspection_data = env.inspection_data + return frame + + def as_inner_frame_of( + cls, + env: TestStateEnvironment, + variable_store: VariableStore, + event_history_frame_cache: Optional[EventHistoryContext] = None, + ) -> Environment: + frame = super().as_inner_frame_of( + env=env, + event_history_frame_cache=event_history_frame_cache, + variable_store=variable_store, + ) + frame.inspection_data = env.inspection_data + return frame + + def set_choice_selected(self, next_state_name: str) -> None: + with self._state_mutex: + if isinstance(self._program_state, ProgramRunning): + self._program_state = ProgramChoiceSelected(next_state_name=next_state_name) + self.program_state_event.set() + self.program_state_event.clear() + else: + raise RuntimeError("Cannot set choice selected for non running ProgramState.") diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/program_state.py b/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/program_state.py new file mode 100644 index 0000000000000..d9576ceda285b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/test_state/program_state.py @@ -0,0 +1,11 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.eval.program_state import ProgramState + + +class ProgramChoiceSelected(ProgramState): + next_state_name: Final[str] + + def __init__(self, next_state_name: str): + super().__init__() + self.next_state_name = next_state_name diff --git a/localstack-core/localstack/services/stepfunctions/asl/eval/variable_store.py b/localstack-core/localstack/services/stepfunctions/asl/eval/variable_store.py new file mode 100644 index 0000000000000..055fb9355ca5c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/eval/variable_store.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Any, Final, Optional + +from localstack.services.stepfunctions.asl.jsonata.jsonata import ( + VariableDeclarations, + encode_jsonata_variable_declarations, +) +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +VariableIdentifier = str +VariableValue = Any + + +class VariableStoreError(RuntimeError): + message: Final[str] + + def __init__(self, message: str): + self.message = message + + def __str__(self): + return f"{self.__class__.__name__} {self.message}" + + def __repr__(self): + return str(self) + + +class NoSuchVariable(VariableStoreError): + variable_identifier: Final[VariableIdentifier] + + def __init__(self, variable_identifier: VariableIdentifier): + super().__init__(message=f"No such variable '{variable_identifier}' in scope") + self.variable_identifier = variable_identifier + + +class IllegalOuterScopeWrite(VariableStoreError): + variable_identifier: Final[VariableIdentifier] + variable_value: Final[VariableValue] + + def __init__(self, variable_identifier: VariableIdentifier, variable_value: VariableValue): + super().__init__( + message=f"Cannot bind value '{variable_value}' to variable '{variable_identifier}' as it belongs to an outer scope." + ) + self.variable_identifier = variable_identifier + self.variable_value = variable_value + + +class VariableStore: + _outer_scope: Final[dict] + _inner_scope: Final[dict] + + _declaration_tracing: Final[set[str]] + + _outer_variable_declaration_cache: Optional[VariableDeclarations] + _variable_declarations_cache: Optional[VariableDeclarations] + + def __init__(self): + self._outer_scope = dict() + self._inner_scope = dict() + self._declaration_tracing = set() + self._outer_variable_declaration_cache = None + self._variable_declarations_cache = None + + @classmethod + def as_inner_scope_of(cls, outer_variable_store: VariableStore) -> VariableStore: + inner_variable_store = cls() + inner_variable_store._outer_scope.update(outer_variable_store._outer_scope) + inner_variable_store._outer_scope.update(outer_variable_store._inner_scope) + return inner_variable_store + + def reset_tracing(self) -> None: + self._declaration_tracing.clear() + + # TODO: add typing when this available in service init. + def get_assigned_variables(self) -> dict[str, str]: + assigned_variables: dict[str, str] = dict() + for traced_declaration_identifier in self._declaration_tracing: + traced_declaration_value = self.get(traced_declaration_identifier) + if isinstance(traced_declaration_value, str): + traced_declaration_value_json_str = f'"{traced_declaration_value}"' + else: + traced_declaration_value_json_str: str = to_json_str( + traced_declaration_value, separators=(",", ":") + ) + assigned_variables[traced_declaration_identifier] = traced_declaration_value_json_str + return assigned_variables + + def get(self, variable_identifier: VariableIdentifier) -> VariableValue: + if variable_identifier in self._inner_scope: + return self._inner_scope[variable_identifier] + if variable_identifier in self._outer_scope: + return self._outer_scope[variable_identifier] + raise NoSuchVariable(variable_identifier=variable_identifier) + + def set(self, variable_identifier: VariableIdentifier, variable_value: VariableValue) -> None: + if variable_identifier in self._outer_scope: + raise IllegalOuterScopeWrite( + variable_identifier=variable_identifier, variable_value=variable_value + ) + self._declaration_tracing.add(variable_identifier) + self._inner_scope[variable_identifier] = variable_value + self._variable_declarations_cache = None + + @staticmethod + def _to_variable_declarations(bindings: dict[str, Any]) -> VariableDeclarations: + variables = {f"${key}": value for key, value in bindings.items()} + encoded = encode_jsonata_variable_declarations(variables) + return encoded + + def get_variable_declarations(self) -> VariableDeclarations: + if self._variable_declarations_cache is not None: + return self._variable_declarations_cache + if self._outer_variable_declaration_cache is None: + self._outer_variable_declaration_cache = self._to_variable_declarations( + self._outer_scope + ) + inner_variable_declarations_cache = self._to_variable_declarations(self._inner_scope) + self._variable_declarations_cache = "".join( + [self._outer_variable_declaration_cache, inner_variable_declarations_cache] + ) + return self._variable_declarations_cache diff --git a/localstack-core/localstack/services/stepfunctions/asl/jsonata/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/jsonata/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/jsonata/jsonata.py b/localstack-core/localstack/services/stepfunctions/asl/jsonata/jsonata.py new file mode 100644 index 0000000000000..1fa837f68815e --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/jsonata/jsonata.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Callable, Final, Optional + +import jpype +import jpype.imports + +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.packages import jpype_jsonata_package +from localstack.utils.objects import singleton_factory + +JSONataExpression = str +VariableReference = str +VariableDeclarations = str + +_PATTERN_VARIABLE_REFERENCE: Final[re.Pattern] = re.compile( + r"\$\$|\$[a-zA-Z0-9_$]+(?:\.[a-zA-Z0-9_][a-zA-Z0-9_$]*)*|\$" +) +_ILLEGAL_VARIABLE_REFERENCES: Final[set[str]] = {"$", "$$"} +_VARIABLE_REFERENCE_ASSIGNMENT_OPERATOR: Final[str] = ":=" +_VARIABLE_REFERENCE_ASSIGNMENT_STOP_SYMBOL: Final[str] = ";" +_EXPRESSION_OPEN_SYMBOL: Final[str] = "(" +_EXPRESSION_CLOSE_SYMBOL: Final[str] = ")" + + +class JSONataException(Exception): + error: Final[str] + details: Optional[str] + + def __init__(self, error: str, details: Optional[str]): + self.error = error + self.details = details + + +class _JSONataJVMBridge: + _java_OBJECT_MAPPER: "com.fasterxml.jackson.databind.ObjectMapper" # noqa + _java_JSONATA: "com.dashjoin.jsonata.Jsonata.jsonata" # noqa + + def __init__(self): + installer = jpype_jsonata_package.get_installer() + installer.install() + + from jpype import config as jpype_config + + jpype_config.destroy_jvm = False + + # Limitation: We can only start one JVM instance within LocalStack and using JPype for another purpose + # (e.g., event-ruler) fails unless we change the way we load/reload the classpath. + jvm_path = installer.get_java_lib_path() + jsonata_libs_path = Path(installer.get_installed_dir()) + jsonata_libs_pattern = jsonata_libs_path.joinpath("*") + jpype.startJVM(jvm_path, classpath=[jsonata_libs_pattern], interrupt=False) + + from com.fasterxml.jackson.databind import ObjectMapper # noqa + from com.dashjoin.jsonata.Jsonata import jsonata # noqa + + self._java_OBJECT_MAPPER = ObjectMapper() + self._java_JSONATA = jsonata + + @staticmethod + @singleton_factory + def get() -> _JSONataJVMBridge: + return _JSONataJVMBridge() + + def eval_jsonata(self, jsonata_expression: JSONataExpression) -> Any: + try: + # Evaluate the JSONata expression with the JVM. + # TODO: Investigate whether it is worth moving this chain of statements (java_*) to a + # Java program to reduce i/o between the JVM and this runtime. + java_expression = self._java_JSONATA(jsonata_expression) + java_output = java_expression.evaluate(None) + java_output_string = self._java_OBJECT_MAPPER.writeValueAsString(java_output) + + # Compute a Python json object from the java string, this is to: + # 1. Ensure we fully end interactions with the JVM about this value here; + # 2. The output object may undergo under operations that are not compatible + # with jpype objects (such as json.dumps, equality, instanceof, etc.). + result_str: str = str(java_output_string) + result_json = json.loads(result_str) + + return result_json + except Exception as ex: + raise JSONataException("UNKNOWN", str(ex)) + + +# Lazy initialization of the `eval_jsonata` function pointer. +# This ensures the JVM is only started when JSONata functionality is needed. +_eval_jsonata: Optional[Callable[[JSONataExpression], Any]] = None + + +def eval_jsonata_expression(jsonata_expression: JSONataExpression) -> Any: + global _eval_jsonata + if _eval_jsonata is None: + # Initialize _eval_jsonata only when invoked for the first time using the Singleton pattern. + _eval_jsonata = _JSONataJVMBridge.get().eval_jsonata + return _eval_jsonata(jsonata_expression) + + +class IllegalJSONataVariableReference(ValueError): + variable_reference: Final[VariableReference] + + def __init__(self, variable_reference: VariableReference): + self.variable_reference = variable_reference + + +def extract_jsonata_variable_references( + jsonata_expression: JSONataExpression, +) -> set[VariableReference]: + if not jsonata_expression: + return set() + variable_references: list[VariableReference] = _PATTERN_VARIABLE_REFERENCE.findall( + jsonata_expression + ) + for variable_reference in variable_references: + if variable_reference in _ILLEGAL_VARIABLE_REFERENCES: + raise IllegalJSONataVariableReference(variable_reference=variable_reference) + return set(variable_references) + + +def encode_jsonata_variable_declarations( + bindings: dict[VariableReference, Any], +) -> VariableDeclarations: + declarations_parts: list[str] = list() + for variable_reference, value in bindings.items(): + if isinstance(value, str): + value_str_lit = f'"{value}"' + else: + value_str_lit = to_json_str(value, separators=(",", ":")) + declarations_parts.extend( + [ + variable_reference, + _VARIABLE_REFERENCE_ASSIGNMENT_OPERATOR, + value_str_lit, + _VARIABLE_REFERENCE_ASSIGNMENT_STOP_SYMBOL, + ] + ) + return "".join(declarations_parts) + + +def compose_jsonata_expression( + final_jsonata_expression: JSONataExpression, + variable_declarations_list: list[VariableDeclarations], +) -> JSONataExpression: + variable_declarations = "".join(variable_declarations_list) + expression = "".join( + [ + _EXPRESSION_OPEN_SYMBOL, + variable_declarations, + final_jsonata_expression, + _EXPRESSION_CLOSE_SYMBOL, + ] + ) + return expression diff --git a/localstack-core/localstack/services/stepfunctions/asl/jsonata/validations.py b/localstack-core/localstack/services/stepfunctions/asl/jsonata/validations.py new file mode 100644 index 0000000000000..defc6bfe08517 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/jsonata/validations.py @@ -0,0 +1,91 @@ +from typing import Final + +from localstack.aws.api.stepfunctions import ( + EvaluationFailedEventDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.common.error_name.failure_event import ( + FailureEvent, + FailureEventException, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.jsonata.jsonata import ( + eval_jsonata_expression, +) + +_SUPPORTED_JSONATA_TYPES: Final[set[str]] = { + "null", + "number", + "string", + "boolean", + "array", + "object", +} + + +def _validate_null_output(env: Environment, expression: str, rich_jsonata_expression: str) -> None: + exists: bool = eval_jsonata_expression(f"$exists({rich_jsonata_expression})") + if exists: + return + error_name = StatesErrorName(typ=StatesErrorNameType.StatesQueryEvaluationError) + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.EvaluationFailed, + event_details=EventDetails( + evaluationFailedEventDetails=EvaluationFailedEventDetails( + # TODO: Add snapshot test to investigate behaviour for field string + cause=f"The JSONata expression '{expression}' returned nothing (undefined).", + error=error_name.error_name, + ) + ), + ) + raise FailureEventException(failure_event=failure_event) + + +def _validate_string_output( + env: Environment, expression: str, rich_jsonata_expression: str +) -> None: + jsonata_type: str = eval_jsonata_expression(f"$type({rich_jsonata_expression})") + if jsonata_type in _SUPPORTED_JSONATA_TYPES: + return + error_name = StatesErrorName(typ=StatesErrorNameType.StatesQueryEvaluationError) + failure_event = FailureEvent( + env=env, + error_name=error_name, + event_type=HistoryEventType.EvaluationFailed, + event_details=EventDetails( + evaluationFailedEventDetails=EvaluationFailedEventDetails( + # TODO: Add snapshot test to investigate behaviour for field string + cause=f"The JSONata expression '{expression}' returned an unsupported result type.", + error=error_name.error_name, + ) + ), + ) + raise FailureEventException(failure_event=failure_event) + + +def validate_jsonata_expression_output( + env: Environment, expression: str, rich_jsonata_expression: str, jsonata_result: str +) -> None: + try: + if jsonata_result is None: + _validate_null_output(env, expression, rich_jsonata_expression) + if isinstance(jsonata_result, str): + _validate_string_output(env, expression, rich_jsonata_expression) + except FailureEventException as ex: + env.event_manager.add_event( + context=env.event_history_context, + event_type=HistoryEventType.EvaluationFailed, + event_details=EventDetails( + evaluationFailedEventDetails=ex.get_evaluation_failed_event_details() + ), + ) + raise ex diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/parse/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/asl_parser.py b/localstack-core/localstack/services/stepfunctions/asl/parse/asl_parser.py new file mode 100644 index 0000000000000..29c9c93f53bf5 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/parse/asl_parser.py @@ -0,0 +1,71 @@ +import abc +from typing import Final + +from antlr4 import CommonTokenStream, InputStream, ParserRuleContext +from antlr4.error.ErrorListener import ErrorListener + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.parse.preprocessor import Preprocessor + + +class SyntaxErrorListener(ErrorListener): + errors: Final[list[str]] + + def __init__(self): + super().__init__() + self.errors = list() + + def syntaxError(self, recognizer, offending_symbol, line, column, message, exception): + log_parts = [f"line {line}:{column}"] + if offending_symbol is not None and offending_symbol.text: + log_parts.append(f"at {offending_symbol.text}") + if message: + log_parts.append(message) + error_log = ", ".join(log_parts) + self.errors.append(error_log) + + +class ASLParserException(Exception): + errors: Final[list[str]] + + def __init__(self, errors: list[str]): + self.errors = errors + + def __str__(self): + return repr(self) + + def __repr__(self): + if not self.errors: + error_str = "No error details available" + elif len(self.errors) == 1: + error_str = self.errors[0] + else: + error_str = str(self.errors) + return f"ASLParserException {error_str}" + + +class AmazonStateLanguageParser(abc.ABC): + @staticmethod + def parse(definition: str) -> tuple[EvalComponent, ParserRuleContext]: + # Attempt to build the AST and look out for syntax errors. + syntax_error_listener = SyntaxErrorListener() + + input_stream = InputStream(definition) + lexer = ASLLexer(input_stream) + stream = CommonTokenStream(lexer) + parser = ASLParser(stream) + parser.removeErrorListeners() + parser.addErrorListener(syntax_error_listener) + tree = parser.state_machine() + + errors = syntax_error_listener.errors + if errors: + raise ASLParserException(errors=errors) + + # Attempt to preprocess the AST into evaluation components. + preprocessor = Preprocessor() + program = preprocessor.visit(tree) + + return program, tree diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/intrinsic_parser.py b/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/intrinsic_parser.py new file mode 100644 index 0000000000000..b72696298cb19 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/intrinsic_parser.py @@ -0,0 +1,24 @@ +import abc + +from antlr4 import CommonTokenStream, InputStream +from antlr4.ParserRuleContext import ParserRuleContext + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicLexer import ASLIntrinsicLexer +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicParser import ( + ASLIntrinsicParser, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.function import Function +from localstack.services.stepfunctions.asl.parse.intrinsic.preprocessor import Preprocessor + + +class IntrinsicParser(abc.ABC): + @staticmethod + def parse(src: str) -> tuple[Function, ParserRuleContext]: + input_stream = InputStream(src) + lexer = ASLIntrinsicLexer(input_stream) + stream = CommonTokenStream(lexer) + parser = ASLIntrinsicParser(stream) + tree = parser.func_decl() + preprocessor = Preprocessor() + function: Function = preprocessor.visit(tree) + return function, tree diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/preprocessor.py b/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/preprocessor.py new file mode 100644 index 0000000000000..c25f0345b1b0d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/parse/intrinsic/preprocessor.py @@ -0,0 +1,139 @@ +import re +from typing import Optional + +from antlr4.tree.Tree import ParseTree, TerminalNodeImpl + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicLexer import ASLIntrinsicLexer +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicParser import ( + ASLIntrinsicParser, +) +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicParserVisitor import ( + ASLIntrinsicParserVisitor, +) +from localstack.services.stepfunctions.asl.antlt4utils.antlr4utils import ( + is_production, + is_terminal, +) +from localstack.services.stepfunctions.asl.component.common.query_language import ( + QueryLanguageMode, +) +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringVariableSample, +) +from localstack.services.stepfunctions.asl.component.component import Component +from localstack.services.stepfunctions.asl.component.intrinsic.argument.argument import ( + Argument, + ArgumentContextPath, + ArgumentFunction, + ArgumentJsonPath, + ArgumentList, + ArgumentLiteral, + ArgumentVar, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.function import Function +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.factory import ( + StatesFunctionFactory, +) +from localstack.services.stepfunctions.asl.component.intrinsic.function.statesfunction.states_function import ( + StatesFunction, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.state_function_name_types import ( + StatesFunctionNameType, +) +from localstack.services.stepfunctions.asl.component.intrinsic.functionname.states_function_name import ( + StatesFunctionName, +) + + +class Preprocessor(ASLIntrinsicParserVisitor): + @staticmethod + def _replace_escaped_characters(match): + escaped_char = match.group(1) + if escaped_char.isalpha(): + replacements = {"n": "\n", "t": "\t", "r": "\r"} + return replacements.get(escaped_char, escaped_char) + elif escaped_char == '"': + return '"' + else: + return match.group(0) + + @staticmethod + def _text_of_str(parse_tree: ParseTree) -> str: + pt = is_production(parse_tree) or is_terminal(parse_tree) + inner_str = pt.getText() + inner_str = inner_str[1:-1] + inner_str = re.sub(r"\\(.)", Preprocessor._replace_escaped_characters, inner_str) + return inner_str + + def visitFunc_arg_int(self, ctx: ASLIntrinsicParser.Func_arg_intContext) -> ArgumentLiteral: + integer = int(ctx.INT().getText()) + return ArgumentLiteral(definition_value=integer) + + def visitFunc_arg_float(self, ctx: ASLIntrinsicParser.Func_arg_floatContext) -> ArgumentLiteral: + number = float(ctx.INT().getText()) + return ArgumentLiteral(definition_value=number) + + def visitFunc_arg_string( + self, ctx: ASLIntrinsicParser.Func_arg_stringContext + ) -> ArgumentLiteral: + text: str = self._text_of_str(ctx.STRING()) + return ArgumentLiteral(definition_value=text) + + def visitFunc_arg_bool(self, ctx: ASLIntrinsicParser.Func_arg_boolContext) -> ArgumentLiteral: + bool_term: TerminalNodeImpl = ctx.children[0] + bool_term_rule: int = bool_term.getSymbol().type + bool_val: bool = bool_term_rule == ASLIntrinsicLexer.TRUE + return ArgumentLiteral(definition_value=bool_val) + + def visitFunc_arg_list(self, ctx: ASLIntrinsicParser.Func_arg_listContext) -> ArgumentList: + arguments: list[Argument] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, Argument): + arguments.append(cmp) + return ArgumentList(arguments=arguments) + + def visitFunc_arg_context_path( + self, ctx: ASLIntrinsicParser.Func_arg_context_pathContext + ) -> ArgumentContextPath: + context_path: str = ctx.CONTEXT_PATH_STRING().getText() + return ArgumentContextPath(context_path=context_path) + + def visitFunc_arg_json_path( + self, ctx: ASLIntrinsicParser.Func_arg_json_pathContext + ) -> ArgumentJsonPath: + json_path: str = ctx.JSON_PATH_STRING().getText() + return ArgumentJsonPath(json_path=json_path) + + def visitFunc_arg_var(self, ctx: ASLIntrinsicParser.Func_arg_varContext) -> ArgumentVar: + expression: str = ctx.STRING_VARIABLE().getText() + string_variable_sample = StringVariableSample( + query_language_mode=QueryLanguageMode.JSONPath, expression=expression + ) + return ArgumentVar(string_variable_sample=string_variable_sample) + + def visitFunc_arg_func_decl( + self, ctx: ASLIntrinsicParser.Func_arg_func_declContext + ) -> ArgumentFunction: + function: Function = self.visit(ctx.states_func_decl()) + return ArgumentFunction(function=function) + + def visitState_fun_name( + self, ctx: ASLIntrinsicParser.State_fun_nameContext + ) -> StatesFunctionName: + tok_typ: int = ctx.children[0].symbol.type + name_typ = StatesFunctionNameType(tok_typ) + return StatesFunctionName(function_type=name_typ) + + def visitStates_func_decl( + self, ctx: ASLIntrinsicParser.States_func_declContext + ) -> StatesFunction: + func_name: StatesFunctionName = self.visit(ctx.state_fun_name()) + argument_list: ArgumentList = self.visit(ctx.func_arg_list()) + func: StatesFunction = StatesFunctionFactory.from_name( + func_name=func_name, argument_list=argument_list + ) + return func + + def visitFunc_decl(self, ctx: ASLIntrinsicParser.Func_declContext) -> Function: + return self.visit(ctx.children[0]) diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/preprocessor.py b/localstack-core/localstack/services/stepfunctions/asl/parse/preprocessor.py new file mode 100644 index 0000000000000..93132888e920b --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/parse/preprocessor.py @@ -0,0 +1,1511 @@ +import json +import logging +from typing import Any, Optional + +from antlr4 import ParserRuleContext +from antlr4.tree.Tree import ParseTree, TerminalNodeImpl + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParserVisitor import ASLParserVisitor +from localstack.services.stepfunctions.asl.antlt4utils.antlr4utils import ( + from_string_literal, + is_production, + is_terminal, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_decl import AssignDecl +from localstack.services.stepfunctions.asl.component.common.assign.assign_decl_binding import ( + AssignDeclBinding, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_binding import ( + AssignTemplateBinding, + AssignTemplateBindingStringExpressionSimple, + AssignTemplateBindingValue, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value import ( + AssignTemplateValue, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value_array import ( + AssignTemplateValueArray, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value_object import ( + AssignTemplateValueObject, +) +from localstack.services.stepfunctions.asl.component.common.assign.assign_template_value_terminal import ( + AssignTemplateValueTerminal, + AssignTemplateValueTerminalLit, + AssignTemplateValueTerminalStringJSONata, +) +from localstack.services.stepfunctions.asl.component.common.catch.catch_decl import CatchDecl +from localstack.services.stepfunctions.asl.component.common.catch.catcher_decl import CatcherDecl +from localstack.services.stepfunctions.asl.component.common.catch.catcher_props import CatcherProps +from localstack.services.stepfunctions.asl.component.common.comment import Comment +from localstack.services.stepfunctions.asl.component.common.error_name.custom_error_name import ( + CustomErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.error_equals_decl import ( + ErrorEqualsDecl, +) +from localstack.services.stepfunctions.asl.component.common.error_name.error_name import ErrorName +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name import ( + StatesErrorName, +) +from localstack.services.stepfunctions.asl.component.common.error_name.states_error_name_type import ( + StatesErrorNameType, +) +from localstack.services.stepfunctions.asl.component.common.flow.end import End +from localstack.services.stepfunctions.asl.component.common.flow.next import Next +from localstack.services.stepfunctions.asl.component.common.flow.start_at import StartAt +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_binding import ( + JSONataTemplateBinding, +) +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value import ( + JSONataTemplateValue, +) +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value_array import ( + JSONataTemplateValueArray, +) +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value_object import ( + JSONataTemplateValueObject, +) +from localstack.services.stepfunctions.asl.component.common.jsonata.jsonata_template_value_terminal import ( + JSONataTemplateValueTerminalLit, + JSONataTemplateValueTerminalStringJSONata, +) +from localstack.services.stepfunctions.asl.component.common.outputdecl import Output +from localstack.services.stepfunctions.asl.component.common.parargs import ( + ArgumentsJSONataTemplateValueObject, + ArgumentsStringJSONata, + Parameters, + Parargs, +) +from localstack.services.stepfunctions.asl.component.common.path.input_path import InputPath +from localstack.services.stepfunctions.asl.component.common.path.items_path import ItemsPath +from localstack.services.stepfunctions.asl.component.common.path.output_path import OutputPath +from localstack.services.stepfunctions.asl.component.common.path.result_path import ResultPath +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payload_value import ( + PayloadValue, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadarr.payload_arr import ( + PayloadArr, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadbinding.payload_binding import ( + PayloadBinding, + PayloadBindingStringExpressionSimple, + PayloadBindingValue, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadtmpl.payload_tmpl import ( + PayloadTmpl, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_bool import ( + PayloadValueBool, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_float import ( + PayloadValueFloat, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_int import ( + PayloadValueInt, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_null import ( + PayloadValueNull, +) +from localstack.services.stepfunctions.asl.component.common.payload.payloadvalue.payloadvaluelit.payload_value_str import ( + PayloadValueStr, +) +from localstack.services.stepfunctions.asl.component.common.query_language import ( + QueryLanguage, + QueryLanguageMode, +) +from localstack.services.stepfunctions.asl.component.common.result_selector import ResultSelector +from localstack.services.stepfunctions.asl.component.common.retry.backoff_rate_decl import ( + BackoffRateDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.interval_seconds_decl import ( + IntervalSecondsDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.jitter_strategy_decl import ( + JitterStrategy, + JitterStrategyDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.max_attempts_decl import ( + MaxAttemptsDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.max_delay_seconds_decl import ( + MaxDelaySecondsDecl, +) +from localstack.services.stepfunctions.asl.component.common.retry.retrier_decl import RetrierDecl +from localstack.services.stepfunctions.asl.component.common.retry.retrier_props import RetrierProps +from localstack.services.stepfunctions.asl.component.common.retry.retry_decl import RetryDecl +from localstack.services.stepfunctions.asl.component.common.string.string_expression import ( + StringContextPath, + StringExpression, + StringExpressionSimple, + StringIntrinsicFunction, + StringJSONata, + StringJsonPath, + StringLiteral, + StringSampler, + StringVariableSample, +) +from localstack.services.stepfunctions.asl.component.common.timeouts.heartbeat import ( + HeartbeatSeconds, + HeartbeatSecondsJSONata, + HeartbeatSecondsPath, +) +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import ( + TimeoutSeconds, + TimeoutSecondsJSONata, + TimeoutSecondsPath, +) +from localstack.services.stepfunctions.asl.component.component import Component +from localstack.services.stepfunctions.asl.component.program.program import Program +from localstack.services.stepfunctions.asl.component.program.states import States +from localstack.services.stepfunctions.asl.component.program.version import Version +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_choice.choice_rule import ( + ChoiceRule, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.choices_decl import ( + ChoicesDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison import ( + ComparisonComposite, + ComparisonCompositeAnd, + ComparisonCompositeNot, + ComparisonCompositeOr, + ComparisonCompositeProps, + ConditionJSONataLit, + ConditionStringJSONata, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_func import ( + ComparisonFunc, + ComparisonFuncStringVariableSample, + ComparisonFuncValue, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_operator_type import ( + ComparisonOperatorType, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_type import ( + Comparison, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.comparison_variable import ( + ComparisonVariable, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.comparison.variable import ( + Variable, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.default_decl import ( + DefaultDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_choice.state_choice import ( + StateChoice, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.execution_type import ( + ExecutionType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.item_reader_decl import ( + ItemReader, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.csv_header_location import ( + CSVHeaderLocation, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.csv_headers import ( + CSVHeaders, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.input_type import ( + InputType, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.max_items_decl import ( + MaxItemsDecl, + MaxItemsInt, + MaxItemsPath, + MaxItemsStringJSONata, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.reader_config_decl import ( + ReaderConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_reader.reader_config.reader_config_props import ( + ReaderConfigProps, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.item_selector import ( + ItemSelector, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.items.items import ( + ItemsArray, + ItemsJSONata, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.item_processor_decl import ( + ItemProcessorDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.processor_config import ( + ProcessorConfig, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.iterator.iterator_decl import ( + IteratorDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.label import ( + Label, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.max_concurrency import ( + MaxConcurrency, + MaxConcurrencyJSONata, + MaxConcurrencyPath, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.mode import ( + Mode, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.result_writer.result_writer_decl import ( + ResultWriter, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.state_map import ( + StateMap, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.tolerated_failure import ( + ToleratedFailureCountInt, + ToleratedFailureCountPath, + ToleratedFailurePercentage, + ToleratedFailurePercentagePath, + ToleratedFailurePercentageStringJSONata, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_parallel.branches_decl import ( + BranchesDecl, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_parallel.state_parallel import ( + StateParallel, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + Credentials, + RoleArn, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + Resource, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.state_task_factory import ( + state_task_for, +) +from localstack.services.stepfunctions.asl.component.state.state_fail.cause_decl import ( + Cause, + CausePath, +) +from localstack.services.stepfunctions.asl.component.state.state_fail.error_decl import ( + Error, + ErrorPath, +) +from localstack.services.stepfunctions.asl.component.state.state_fail.state_fail import StateFail +from localstack.services.stepfunctions.asl.component.state.state_pass.result import Result +from localstack.services.stepfunctions.asl.component.state.state_pass.state_pass import StatePass +from localstack.services.stepfunctions.asl.component.state.state_props import StateProps +from localstack.services.stepfunctions.asl.component.state.state_succeed.state_succeed import ( + StateSucceed, +) +from localstack.services.stepfunctions.asl.component.state.state_type import StateType +from localstack.services.stepfunctions.asl.component.state.state_wait.state_wait import StateWait +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.seconds import ( + Seconds, + SecondsJSONata, +) +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.seconds_path import ( + SecondsPath, +) +from localstack.services.stepfunctions.asl.component.state.state_wait.wait_function.timestamp import ( + Timestamp, + TimestampPath, +) +from localstack.services.stepfunctions.asl.parse.intrinsic.intrinsic_parser import IntrinsicParser +from localstack.services.stepfunctions.asl.parse.typed_props import TypedProps + +LOG = logging.getLogger(__name__) + + +class Preprocessor(ASLParserVisitor): + _query_language_per_scope: list[QueryLanguage] = list() + + def _get_current_query_language(self) -> QueryLanguage: + return self._query_language_per_scope[-1] + + def _open_query_language_scope(self, parse_tree: ParseTree) -> None: + production = is_production(parse_tree) + if production is None: + raise RuntimeError(f"Cannot expect QueryLanguage definition at depth: {parse_tree}") + + # Extract the QueryLanguage declaration at this ParseTree level, if any. + query_language = None + for child in production.children: + sub_production = is_production(child, ASLParser.RULE_top_layer_stmt) or is_production( + child, ASLParser.RULE_state_stmt + ) + if sub_production is not None: + child = sub_production.children[0] + sub_production = is_production(child, ASLParser.RULE_query_language_decl) + if sub_production is not None: + query_language = self.visit(sub_production) + break + + # Check this is the initial scope, if so set the initial value to the declaration or the default. + if not self._query_language_per_scope: + if query_language is None: + query_language = QueryLanguage() + # Otherwise, check for logical conflicts and add the latest or inherited value to as the next scope. + else: + current_query_language = self._get_current_query_language() + if query_language is None: + query_language = current_query_language + if ( + current_query_language.query_language_mode == QueryLanguageMode.JSONata + and query_language.query_language_mode == QueryLanguageMode.JSONPath + ): + raise ValueError( + f"Cannot downgrade from JSONata context to a JSONPath context at: {parse_tree}" + ) + + self._query_language_per_scope.append(query_language) + + def _close_query_language_scope(self) -> None: + self._query_language_per_scope.pop() + + def _is_query_language(self, query_language_mode: QueryLanguageMode) -> bool: + current_query_language = self._get_current_query_language() + return current_query_language.query_language_mode == query_language_mode + + def _raise_if_query_language_is_not( + self, query_language_mode: QueryLanguageMode, ctx: ParserRuleContext + ) -> None: + if not self._is_query_language(query_language_mode=query_language_mode): + raise ValueError( + f"Unsupported declaration in QueryLanguage={query_language_mode} block: {ctx.getText()}" + ) + + @staticmethod + def _inner_string_of(parser_rule_context: ParserRuleContext) -> Optional[str]: + if is_terminal(parser_rule_context, ASLLexer.NULL): + return None + inner_str = parser_rule_context.getText() + if inner_str.startswith('"') and inner_str.endswith('"'): + inner_str = inner_str[1:-1] + return inner_str + + def _inner_jsonata_expr(self, ctx: ParserRuleContext) -> str: + self._raise_if_query_language_is_not(query_language_mode=QueryLanguageMode.JSONata, ctx=ctx) + inner_string_value = from_string_literal(parser_rule_context=ctx) + # Strip the start and end jsonata symbols {%%} + expression_body = inner_string_value[2:-2] + # Often leading and trailing spaces are used around the body: remove. + expression = expression_body.strip() + return expression + + def visitComment_decl(self, ctx: ASLParser.Comment_declContext) -> Comment: + inner_str = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return Comment(comment=inner_str) + + def visitVersion_decl(self, ctx: ASLParser.Version_declContext) -> Version: + version_str = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return Version(version=version_str) + + def visitStartat_decl(self, ctx: ASLParser.Startat_declContext) -> StartAt: + inner_str = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return StartAt(start_at_name=inner_str) + + def visitStates_decl(self, ctx: ASLParser.States_declContext) -> States: + states = States() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, CommonStateField): + # TODO move check to setter or checker layer? + if cmp.name in states.states: + raise ValueError(f"State redefinition {child.getText()}") + states.states[cmp.name] = cmp + return states + + def visitType_decl(self, ctx: ASLParser.Type_declContext) -> StateType: + return self.visit(ctx.state_type()) + + def visitState_type(self, ctx: ASLParser.State_typeContext) -> StateType: + state_type: int = ctx.children[0].symbol.type + return StateType(state_type) + + def visitResource_decl(self, ctx: ASLParser.Resource_declContext) -> Resource: + inner_str = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return Resource.from_resource_arn(inner_str) + + def visitEnd_decl(self, ctx: ASLParser.End_declContext) -> End: + bool_child: ParseTree = ctx.children[-1] + bool_term: Optional[TerminalNodeImpl] = is_terminal(bool_child) + if bool_term is None: + raise ValueError(f"Could not derive End from declaration context '{ctx.getText()}'") + bool_term_rule: int = bool_term.getSymbol().type + is_end = bool_term_rule == ASLLexer.TRUE + return End(is_end=is_end) + + def visitNext_decl(self, ctx: ASLParser.Next_declContext) -> Next: + inner_str = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return Next(name=inner_str) + + def visitResult_path_decl(self, ctx: ASLParser.Result_path_declContext) -> ResultPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + inner_str = self._inner_string_of(parser_rule_context=ctx.children[-1]) + return ResultPath(result_path_src=inner_str) + + def visitInput_path_decl(self, ctx: ASLParser.Input_path_declContext) -> InputPath: + string_sampler: Optional[StringSampler] = None + if not is_terminal(pt=ctx.children[-1], token_type=ASLLexer.NULL): + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return InputPath(string_sampler=string_sampler) + + def visitOutput_path_decl(self, ctx: ASLParser.Output_path_declContext) -> OutputPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: Optional[StringSampler] = None + if is_production(ctx.children[-1], ASLParser.RULE_string_sampler): + string_sampler: StringSampler = self.visitString_sampler(ctx.children[-1]) + return OutputPath(string_sampler=string_sampler) + + def visitResult_decl(self, ctx: ASLParser.Result_declContext) -> Result: + json_decl = ctx.json_value_decl() + json_str: str = json_decl.getText() + json_obj: json = json.loads(json_str) + return Result(result_obj=json_obj) + + def visitParameters_decl(self, ctx: ASLParser.Parameters_declContext) -> Parameters: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + payload_tmpl: PayloadTmpl = self.visit(ctx.payload_tmpl_decl()) + return Parameters(payload_tmpl=payload_tmpl) + + def visitTimeout_seconds_int(self, ctx: ASLParser.Timeout_seconds_intContext) -> TimeoutSeconds: + seconds = int(ctx.INT().getText()) + return TimeoutSeconds(timeout_seconds=seconds) + + def visitTimeout_seconds_jsonata( + self, ctx: ASLParser.Timeout_seconds_jsonataContext + ) -> TimeoutSecondsJSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return TimeoutSecondsJSONata(string_jsonata=string_jsonata) + + def visitTimeout_seconds_path( + self, ctx: ASLParser.Timeout_seconds_pathContext + ) -> TimeoutSecondsPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return TimeoutSecondsPath(string_sampler=string_sampler) + + def visitHeartbeat_seconds_int( + self, ctx: ASLParser.Heartbeat_seconds_intContext + ) -> HeartbeatSeconds: + seconds = int(ctx.INT().getText()) + return HeartbeatSeconds(heartbeat_seconds=seconds) + + def visitHeartbeat_seconds_jsonata( + self, ctx: ASLParser.Heartbeat_seconds_jsonataContext + ) -> HeartbeatSecondsJSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return HeartbeatSecondsJSONata(string_jsonata=string_jsonata) + + def visitHeartbeat_seconds_path( + self, ctx: ASLParser.Heartbeat_seconds_pathContext + ) -> HeartbeatSecondsPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return HeartbeatSecondsPath(string_sampler=string_sampler) + + def visitResult_selector_decl( + self, ctx: ASLParser.Result_selector_declContext + ) -> ResultSelector: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + payload_tmpl: PayloadTmpl = self.visit(ctx.payload_tmpl_decl()) + return ResultSelector(payload_tmpl=payload_tmpl) + + def visitBranches_decl(self, ctx: ASLParser.Branches_declContext) -> BranchesDecl: + programs: list[Program] = [] + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, Program): + programs.append(cmp) + return BranchesDecl(programs=programs) + + def visitState_decl_body(self, ctx: ASLParser.State_decl_bodyContext) -> StateProps: + self._open_query_language_scope(ctx) + state_props = StateProps() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + state_props.add(cmp) + if state_props.get(QueryLanguage) is None: + state_props.add(self._get_current_query_language()) + self._close_query_language_scope() + return state_props + + def visitState_decl(self, ctx: ASLParser.State_declContext) -> CommonStateField: + state_name = self._inner_string_of(parser_rule_context=ctx.string_literal()) + state_props: StateProps = self.visit(ctx.state_decl_body()) + state_props.name = state_name + common_state_field = self._common_state_field_of(state_props=state_props) + return common_state_field + + @staticmethod + def _common_state_field_of(state_props: StateProps) -> CommonStateField: + # TODO: use subtype loading strategy. + match state_props.get(StateType): + case StateType.Task: + resource: Resource = state_props.get(Resource) + state = state_task_for(resource) + case StateType.Pass: + state = StatePass() + case StateType.Choice: + state = StateChoice() + case StateType.Fail: + state = StateFail() + case StateType.Succeed: + state = StateSucceed() + case StateType.Wait: + state = StateWait() + case StateType.Map: + state = StateMap() + case StateType.Parallel: + state = StateParallel() + case None: + raise TypeError("No Type declaration for State in context.") + case unknown: + raise TypeError( + f"Unknown StateType value '{unknown}' in StateProps object in context." # noqa + ) + state.from_state_props(state_props) + return state + + def visitCondition_lit(self, ctx: ASLParser.Condition_litContext) -> ConditionJSONataLit: + self._raise_if_query_language_is_not(query_language_mode=QueryLanguageMode.JSONata, ctx=ctx) + bool_child: ParseTree = ctx.children[-1] + bool_term: Optional[TerminalNodeImpl] = is_terminal(bool_child) + if bool_term is None: + raise ValueError( + f"Could not derive boolean literal from declaration context '{ctx.getText()}'." + ) + bool_term_rule: int = bool_term.getSymbol().type + bool_val: bool = bool_term_rule == ASLLexer.TRUE + return ConditionJSONataLit(literal=bool_val) + + def visitCondition_string_jsonata( + self, ctx: ASLParser.Condition_string_jsonataContext + ) -> ConditionStringJSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx=ctx.string_jsonata()) + return ConditionStringJSONata(string_jsonata=string_jsonata) + + def visitVariable_decl(self, ctx: ASLParser.Variable_declContext) -> Variable: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx=ctx.string_sampler()) + return Variable(string_sampler=string_sampler) + + def visitComparison_op(self, ctx: ASLParser.Comparison_opContext) -> ComparisonOperatorType: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + try: + operator_type: int = ctx.children[0].symbol.type + return ComparisonOperatorType(operator_type) + except Exception: + raise ValueError(f"Could not derive ComparisonOperator from context '{ctx.getText()}'.") + + def visitComparison_func_value( + self, ctx: ASLParser.Comparison_func_valueContext + ) -> ComparisonFuncValue: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + comparison_op: ComparisonOperatorType = self.visit(ctx.comparison_op()) + json_decl = ctx.json_value_decl() + json_str: str = json_decl.getText() + json_obj: Any = json.loads(json_str) + return ComparisonFuncValue(operator_type=comparison_op, value=json_obj) + + def visitComparison_func_string_variable_sample( + self, ctx: ASLParser.Comparison_func_string_variable_sampleContext + ) -> ComparisonFuncStringVariableSample: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + comparison_op: ComparisonOperatorType = self.visit(ctx.comparison_op()) + string_variable_sample: StringVariableSample = self.visitString_variable_sample( + ctx.string_variable_sample() + ) + return ComparisonFuncStringVariableSample( + operator_type=comparison_op, string_variable_sample=string_variable_sample + ) + + def visitDefault_decl(self, ctx: ASLParser.Default_declContext) -> DefaultDecl: + state_name = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return DefaultDecl(state_name=state_name) + + def visitChoice_operator( + self, ctx: ASLParser.Choice_operatorContext + ) -> ComparisonComposite.ChoiceOp: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + pt: Optional[TerminalNodeImpl] = is_terminal(ctx.children[0]) + if not pt: + raise ValueError(f"Could not derive ChoiceOperator in block '{ctx.getText()}'.") + return ComparisonComposite.ChoiceOp(pt.symbol.type) + + def visitComparison_composite( + self, ctx: ASLParser.Comparison_compositeContext + ) -> ComparisonComposite: + choice_op: ComparisonComposite.ChoiceOp = self.visit(ctx.choice_operator()) + rules: list[ChoiceRule] = list() + for child in ctx.children[1:]: + cmp: Optional[Component] = self.visit(child) + if not cmp: + continue + elif isinstance(cmp, ChoiceRule): + rules.append(cmp) + + match choice_op: + case ComparisonComposite.ChoiceOp.Not: + if len(rules) != 1: + raise ValueError( + f"ComparisonCompositeNot must carry only one ComparisonCompositeStmt in: '{ctx.getText()}'." + ) + return ComparisonCompositeNot(rule=rules[0]) + case ComparisonComposite.ChoiceOp.And: + return ComparisonCompositeAnd(rules=rules) + case ComparisonComposite.ChoiceOp.Or: + return ComparisonCompositeOr(rules=rules) + + def visitChoice_rule_comparison_composite( + self, ctx: ASLParser.Choice_rule_comparison_compositeContext + ) -> ChoiceRule: + composite_stmts = ComparisonCompositeProps() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + composite_stmts.add(cmp) + return ChoiceRule( + comparison=composite_stmts.get( + typ=ComparisonComposite, + raise_on_missing=ValueError( + f"Expecting a 'ComparisonComposite' definition at '{ctx.getText()}'." + ), + ), + next_stmt=composite_stmts.get(Next), + comment=composite_stmts.get(Comment), + assign=composite_stmts.get(AssignDecl), + output=composite_stmts.get(Output), + ) + + def visitChoice_rule_comparison_variable( + self, ctx: ASLParser.Choice_rule_comparison_variableContext + ) -> ChoiceRule: + comparison_stmts = StateProps() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + comparison_stmts.add(cmp) + if self._is_query_language(query_language_mode=QueryLanguageMode.JSONPath): + variable: Variable = comparison_stmts.get( + typ=Variable, + raise_on_missing=ValueError( + f"Expected a Variable declaration in '{ctx.getText()}'." + ), + ) + comparison_func: Comparison = comparison_stmts.get( + typ=Comparison, + raise_on_missing=ValueError( + f"Expected a ComparisonFunction declaration in '{ctx.getText()}'." + ), + ) + if not isinstance(comparison_func, ComparisonFunc): + raise ValueError(f"Expected a ComparisonFunction declaration in '{ctx.getText()}'") + comparison_variable = ComparisonVariable(variable=variable, func=comparison_func) + return ChoiceRule( + comparison=comparison_variable, + next_stmt=comparison_stmts.get(Next), + comment=comparison_stmts.get(Comment), + assign=None, + output=None, + ) + else: + condition: Comparison = comparison_stmts.get( + typ=Comparison, + raise_on_missing=ValueError( + f"Expected a Condition declaration in '{ctx.getText()}'" + ), + ) + return ChoiceRule( + comparison=condition, + next_stmt=comparison_stmts.get(Next), + comment=comparison_stmts.get(Comment), + assign=comparison_stmts.get(AssignDecl), + output=comparison_stmts.get(Output), + ) + + def visitChoices_decl(self, ctx: ASLParser.Choices_declContext) -> ChoicesDecl: + rules: list[ChoiceRule] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if not cmp: + continue + elif isinstance(cmp, ChoiceRule): + rules.append(cmp) + return ChoicesDecl(rules=rules) + + def visitError(self, ctx: ASLParser.ErrorContext) -> Error: + string_expression: StringExpression = self.visit(ctx.children[-1]) + return Error(string_expression=string_expression) + + def visitError_path(self, ctx: ASLParser.Error_pathContext) -> ErrorPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_expression: StringExpression = self.visit(ctx.children[-1]) + return ErrorPath(string_expression=string_expression) + + def visitCause(self, ctx: ASLParser.CauseContext) -> Cause: + string_expression: StringExpression = self.visit(ctx.children[-1]) + return Cause(string_expression=string_expression) + + def visitCause_path(self, ctx: ASLParser.Cause_pathContext) -> CausePath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_expression: StringExpression = self.visit(ctx.children[-1]) + return CausePath(string_expression=string_expression) + + def visitRole_arn(self, ctx: ASLParser.Role_arnContext) -> RoleArn: + string_expression: StringExpression = self.visit(ctx.children[-1]) + return RoleArn(string_expression=string_expression) + + def visitRole_path(self, ctx: ASLParser.Role_pathContext) -> RoleArn: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_expression_simple: StringExpressionSimple = self.visitString_expression_simple( + ctx=ctx.string_expression_simple() + ) + return RoleArn(string_expression=string_expression_simple) + + def visitCredentials_decl(self, ctx: ASLParser.Credentials_declContext) -> Credentials: + role_arn: RoleArn = self.visit(ctx.role_arn_decl()) + return Credentials(role_arn=role_arn) + + def visitSeconds_int(self, ctx: ASLParser.Seconds_intContext) -> Seconds: + return Seconds(seconds=int(ctx.INT().getText())) + + def visitSeconds_jsonata(self, ctx: ASLParser.Seconds_jsonataContext) -> SecondsJSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return SecondsJSONata(string_jsonata=string_jsonata) + + def visitSeconds_path(self, ctx: ASLParser.Seconds_pathContext) -> SecondsPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx=ctx.string_sampler()) + return SecondsPath(string_sampler=string_sampler) + + def visitItems_path_decl(self, ctx: ASLParser.Items_path_declContext) -> ItemsPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return ItemsPath(string_sampler=string_sampler) + + def visitMax_concurrency_int(self, ctx: ASLParser.Max_concurrency_intContext) -> MaxConcurrency: + return MaxConcurrency(num=int(ctx.INT().getText())) + + def visitMax_concurrency_jsonata( + self, ctx: ASLParser.Max_concurrency_jsonataContext + ) -> MaxConcurrencyJSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return MaxConcurrencyJSONata(string_jsonata=string_jsonata) + + def visitMax_concurrency_path( + self, ctx: ASLParser.Max_concurrency_pathContext + ) -> MaxConcurrencyPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return MaxConcurrencyPath(string_sampler=string_sampler) + + def visitMode_decl(self, ctx: ASLParser.Mode_declContext) -> Mode: + mode_type: int = self.visit(ctx.mode_type()) + return Mode(mode_type) + + def visitMode_type(self, ctx: ASLParser.Mode_typeContext) -> int: + return ctx.children[0].symbol.type + + def visitExecution_decl(self, ctx: ASLParser.Execution_declContext) -> ExecutionType: + execution_type: int = self.visit(ctx.execution_type()) + return ExecutionType(execution_type) + + def visitExecution_type(self, ctx: ASLParser.Execution_typeContext) -> int: + return ctx.children[0].symbol.type + + def visitTimestamp(self, ctx: ASLParser.TimestampContext) -> Timestamp: + string: StringExpression = self.visit(ctx.children[-1]) + return Timestamp(string=string) + + def visitTimestamp_path(self, ctx: ASLParser.Timestamp_pathContext) -> TimestampPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return TimestampPath(string=string_sampler) + + def visitProcessor_config_decl( + self, ctx: ASLParser.Processor_config_declContext + ) -> ProcessorConfig: + props = TypedProps() + for child in ctx.children: + cmp = self.visit(child) + props.add(cmp) + return ProcessorConfig( + mode=props.get(typ=Mode) or ProcessorConfig.DEFAULT_MODE, + execution_type=props.get(typ=ExecutionType) or ProcessorConfig.DEFAULT_EXECUTION_TYPE, + ) + + def visitItem_processor_item(self, ctx: ASLParser.Item_processor_itemContext) -> Component: + return self.visit(ctx.children[0]) + + def visitItem_processor_decl( + self, ctx: ASLParser.Item_processor_declContext + ) -> ItemProcessorDecl: + props = TypedProps() + for child in ctx.children: + cmp = self.visit(child) + props.add(cmp) + return ItemProcessorDecl( + query_language=props.get(QueryLanguage) or QueryLanguage(), + start_at=props.get( + typ=StartAt, + raise_on_missing=ValueError( + f"Expected a StartAt declaration at '{ctx.getText()}'." + ), + ), + states=props.get( + typ=States, + raise_on_missing=ValueError(f"Expected a States declaration at '{ctx.getText()}'."), + ), + comment=props.get(typ=Comment), + processor_config=props.get(typ=ProcessorConfig) or ProcessorConfig(), + ) + + def visitIterator_decl(self, ctx: ASLParser.Iterator_declContext) -> IteratorDecl: + props = TypedProps() + for child in ctx.children: + cmp = self.visit(child) + props.add(cmp) + return IteratorDecl( + comment=props.get(typ=Comment), + query_language=self._get_current_query_language(), + start_at=props.get( + typ=StartAt, + raise_on_missing=ValueError( + f"Expected a StartAt declaration at '{ctx.getText()}'." + ), + ), + states=props.get( + typ=States, + raise_on_missing=ValueError(f"Expected a States declaration at '{ctx.getText()}'."), + ), + processor_config=props.get(typ=ProcessorConfig) or ProcessorConfig(), + ) + + def visitItem_selector_decl(self, ctx: ASLParser.Item_selector_declContext) -> ItemSelector: + template_value_object = self.visitAssign_template_value_object( + ctx=ctx.assign_template_value_object() + ) + return ItemSelector(template_value_object=template_value_object) + + def visitItem_reader_decl(self, ctx: ASLParser.Item_reader_declContext) -> ItemReader: + props = StateProps() + for child in ctx.children[3:-1]: + cmp = self.visit(child) + props.add(cmp) + resource: Resource = props.get( + typ=Resource, + raise_on_missing=ValueError(f"Expected a Resource declaration at '{ctx.getText()}'."), + ) + return ItemReader( + resource=resource, + parargs=props.get(Parargs), + reader_config=props.get(ReaderConfig), + ) + + def visitReader_config_decl(self, ctx: ASLParser.Reader_config_declContext) -> ReaderConfig: + props = ReaderConfigProps() + for child in ctx.children: + cmp = self.visit(child) + props.add(cmp) + return ReaderConfig( + input_type=props.get( + typ=InputType, + raise_on_missing=ValueError( + f"Expected a InputType declaration at '{ctx.getText()}'." + ), + ), + max_items_decl=props.get(typ=MaxItemsDecl), + csv_header_location=props.get(CSVHeaderLocation), + csv_headers=props.get(CSVHeaders), + ) + + def visitInput_type_decl(self, ctx: ASLParser.Input_type_declContext) -> InputType: + input_type = self._inner_string_of(ctx.string_literal()) + return InputType(input_type=input_type) + + def visitCsv_header_location_decl( + self, ctx: ASLParser.Csv_header_location_declContext + ) -> CSVHeaderLocation: + value = self._inner_string_of(ctx.string_literal()) + return CSVHeaderLocation(csv_header_location_value=value) + + def visitCsv_headers_decl(self, ctx: ASLParser.Csv_headers_declContext) -> CSVHeaders: + csv_headers: list[str] = list() + for child in ctx.children[3:-1]: + maybe_str = is_production(pt=child, rule_index=ASLParser.RULE_string_literal) + if maybe_str is not None: + csv_headers.append(self._inner_string_of(maybe_str)) + # TODO: check for empty headers behaviour. + return CSVHeaders(header_names=csv_headers) + + def visitMax_items_path(self, ctx: ASLParser.Max_items_pathContext) -> MaxItemsPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + string_sampler: StringSampler = self.visitString_sampler(ctx=ctx.string_sampler()) + return MaxItemsPath(string_sampler=string_sampler) + + def visitMax_items_int(self, ctx: ASLParser.Max_items_intContext) -> MaxItemsInt: + return MaxItemsInt(max_items=int(ctx.INT().getText())) + + def visitMax_items_string_jsonata( + self, ctx: ASLParser.Max_items_string_jsonataContext + ) -> MaxItemsStringJSONata: + self._raise_if_query_language_is_not(query_language_mode=QueryLanguageMode.JSONata, ctx=ctx) + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return MaxItemsStringJSONata(string_jsonata=string_jsonata) + + def visitTolerated_failure_count_int( + self, ctx: ASLParser.Tolerated_failure_count_intContext + ) -> ToleratedFailureCountInt: + LOG.warning( + "ToleratedFailureCount declarations currently have no effect on the program evaluation." + ) + count = int(ctx.INT().getText()) + return ToleratedFailureCountInt(tolerated_failure_count=count) + + def visitTolerated_failure_count_string_jsonata( + self, ctx: ASLParser.Tolerated_failure_count_string_jsonataContext + ) -> ToleratedFailurePercentageStringJSONata: + LOG.warning( + "ToleratedFailureCount declarations currently have no effect on the program evaluation." + ) + string_jsonata: StringJSONata = self.visitString_jsonata(ctx=ctx.string_jsonata()) + return ToleratedFailurePercentageStringJSONata(string_jsonata=string_jsonata) + + def visitTolerated_failure_count_path( + self, ctx: ASLParser.Tolerated_failure_count_pathContext + ) -> ToleratedFailureCountPath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + LOG.warning( + "ToleratedFailureCountPath declarations currently have no effect on the program evaluation." + ) + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return ToleratedFailureCountPath(string_sampler=string_sampler) + + def visitTolerated_failure_percentage_number( + self, ctx: ASLParser.Tolerated_failure_percentage_numberContext + ) -> ToleratedFailurePercentage: + LOG.warning( + "ToleratedFailurePercentage declarations currently have no effect on the program evaluation." + ) + percentage = float(ctx.NUMBER().getText()) + return ToleratedFailurePercentage(tolerated_failure_percentage=percentage) + + def visitTolerated_failure_percentage_string_jsonata( + self, ctx: ASLParser.Tolerated_failure_percentage_string_jsonataContext + ) -> ToleratedFailurePercentageStringJSONata: + LOG.warning( + "ToleratedFailurePercentage declarations currently have no effect on the program evaluation." + ) + string_jsonata: StringJSONata = self.visitString_jsonata(ctx=ctx.string_jsonata()) + return ToleratedFailurePercentageStringJSONata(string_jsonata=string_jsonata) + + def visitTolerated_failure_percentage_path( + self, ctx: ASLParser.Tolerated_failure_percentage_pathContext + ) -> ToleratedFailurePercentagePath: + self._raise_if_query_language_is_not( + query_language_mode=QueryLanguageMode.JSONPath, ctx=ctx + ) + LOG.warning( + "ToleratedFailurePercentagePath declarations currently have no effect on the program evaluation." + ) + string_sampler: StringSampler = self.visitString_sampler(ctx.string_sampler()) + return ToleratedFailurePercentagePath(string_sampler=string_sampler) + + def visitLabel_decl(self, ctx: ASLParser.Label_declContext) -> Label: + label = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return Label(label=label) + + def visitResult_writer_decl(self, ctx: ASLParser.Result_writer_declContext) -> ResultWriter: + props = StateProps() + for child in ctx.children[3:-1]: + cmp = self.visit(child) + props.add(cmp) + resource: Resource = props.get( + typ=Resource, + raise_on_missing=ValueError(f"Expected a Resource declaration at '{ctx.getText()}'."), + ) + # TODO: add tests for arguments in jsonata blocks using result writer + parargs: Parargs = props.get( + typ=Parargs, + raise_on_missing=ValueError( + f"Expected a Parameters/Arguments declaration at '{ctx.getText()}'." + ), + ) + return ResultWriter(resource=resource, parargs=parargs) + + def visitRetry_decl(self, ctx: ASLParser.Retry_declContext) -> RetryDecl: + retriers: list[RetrierDecl] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, RetrierDecl): + retriers.append(cmp) + return RetryDecl(retriers=retriers) + + def visitRetrier_decl(self, ctx: ASLParser.Retrier_declContext) -> RetrierDecl: + props = RetrierProps() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + props.add(cmp) + return RetrierDecl.from_retrier_props(props=props) + + def visitRetrier_stmt(self, ctx: ASLParser.Retrier_stmtContext): + return self.visit(ctx.children[0]) + + def visitError_equals_decl(self, ctx: ASLParser.Error_equals_declContext) -> ErrorEqualsDecl: + error_names: list[ErrorName] = list() + for child in ctx.children: + cmp = self.visit(child) + if isinstance(cmp, ErrorName): + error_names.append(cmp) + return ErrorEqualsDecl(error_names=error_names) + + def visitError_name(self, ctx: ASLParser.Error_nameContext) -> ErrorName: + pt = ctx.children[0] + + # Case: StatesErrorName. + prc: Optional[ParserRuleContext] = is_production( + pt=pt, rule_index=ASLParser.RULE_states_error_name + ) + if prc: + return self.visit(prc) + + # Case CustomErrorName. + error_name = self._inner_string_of(parser_rule_context=ctx.string_literal()) + return CustomErrorName(error_name=error_name) + + def visitStates_error_name(self, ctx: ASLParser.States_error_nameContext) -> StatesErrorName: + pt: Optional[TerminalNodeImpl] = is_terminal(ctx.children[0]) + if not pt: + raise ValueError(f"Could not derive ErrorName in block '{ctx.getText()}'.") + states_error_name_type = StatesErrorNameType(pt.symbol.type) + return StatesErrorName(states_error_name_type) + + def visitInterval_seconds_decl( + self, ctx: ASLParser.Interval_seconds_declContext + ) -> IntervalSecondsDecl: + return IntervalSecondsDecl(seconds=int(ctx.INT().getText())) + + def visitMax_attempts_decl(self, ctx: ASLParser.Max_attempts_declContext) -> MaxAttemptsDecl: + return MaxAttemptsDecl(attempts=int(ctx.INT().getText())) + + def visitBackoff_rate_decl(self, ctx: ASLParser.Backoff_rate_declContext) -> BackoffRateDecl: + return BackoffRateDecl(rate=float(ctx.children[-1].getText())) + + def visitMax_delay_seconds_decl( + self, ctx: ASLParser.Max_delay_seconds_declContext + ) -> MaxDelaySecondsDecl: + return MaxDelaySecondsDecl(max_delays_seconds=int(ctx.INT().getText())) + + def visitJitter_strategy_decl( + self, ctx: ASLParser.Jitter_strategy_declContext + ) -> JitterStrategyDecl: + last_child: ParseTree = ctx.children[-1] + strategy_child: Optional[TerminalNodeImpl] = is_terminal(last_child) + strategy_value = strategy_child.getSymbol().type + jitter_strategy = JitterStrategy(strategy_value) + return JitterStrategyDecl(jitter_strategy=jitter_strategy) + + def visitCatch_decl(self, ctx: ASLParser.Catch_declContext) -> CatchDecl: + catchers: list[CatcherDecl] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, CatcherDecl): + catchers.append(cmp) + return CatchDecl(catchers=catchers) + + def visitCatcher_decl(self, ctx: ASLParser.Catcher_declContext) -> CatcherDecl: + props = CatcherProps() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + props.add(cmp) + if self._is_query_language(QueryLanguageMode.JSONPath) and not props.get(ResultPath): + props.add(CatcherDecl.DEFAULT_RESULT_PATH) + return CatcherDecl.from_catcher_props(props=props) + + def visitPayload_value_float( + self, ctx: ASLParser.Payload_value_floatContext + ) -> PayloadValueFloat: + return PayloadValueFloat(val=float(ctx.NUMBER().getText())) + + def visitPayload_value_int(self, ctx: ASLParser.Payload_value_intContext) -> PayloadValueInt: + return PayloadValueInt(val=int(ctx.INT().getText())) + + def visitPayload_value_bool(self, ctx: ASLParser.Payload_value_boolContext) -> PayloadValueBool: + bool_child: ParseTree = ctx.children[0] + bool_term: Optional[TerminalNodeImpl] = is_terminal(bool_child) + if bool_term is None: + raise ValueError( + f"Could not derive PayloadValueBool from declaration context '{ctx.getText()}'." + ) + bool_term_rule: int = bool_term.getSymbol().type + bool_val: bool = bool_term_rule == ASLLexer.TRUE + return PayloadValueBool(val=bool_val) + + def visitPayload_value_null(self, ctx: ASLParser.Payload_value_nullContext) -> PayloadValueNull: + return PayloadValueNull() + + def visitPayload_value_str(self, ctx: ASLParser.Payload_value_strContext) -> PayloadValueStr: + string_literal: StringLiteral = self.visitString_literal(ctx=ctx.string_literal()) + return PayloadValueStr(val=string_literal.literal_value) + + def visitPayload_binding_sample( + self, ctx: ASLParser.Payload_binding_sampleContext + ) -> PayloadBindingStringExpressionSimple: + string_dollar: str = self._inner_string_of(parser_rule_context=ctx.STRINGDOLLAR()) + field = string_dollar[:-2] + string_expression_simple: StringExpressionSimple = self.visitString_expression_simple( + ctx.string_expression_simple() + ) + return PayloadBindingStringExpressionSimple( + field=field, string_expression_simple=string_expression_simple + ) + + def visitPayload_binding_value( + self, ctx: ASLParser.Payload_binding_valueContext + ) -> PayloadBindingValue: + string_literal: StringLiteral = self.visitString_literal(ctx=ctx.string_literal()) + payload_value: PayloadValue = self.visit(ctx.payload_value_decl()) + return PayloadBindingValue(field=string_literal.literal_value, payload_value=payload_value) + + def visitPayload_arr_decl(self, ctx: ASLParser.Payload_arr_declContext) -> PayloadArr: + payload_values: list[PayloadValue] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, PayloadValue): + payload_values.append(cmp) + return PayloadArr(payload_values=payload_values) + + def visitPayload_tmpl_decl(self, ctx: ASLParser.Payload_tmpl_declContext) -> PayloadTmpl: + payload_bindings: list[PayloadBinding] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, PayloadBinding): + payload_bindings.append(cmp) + return PayloadTmpl(payload_bindings=payload_bindings) + + def visitPayload_value_decl(self, ctx: ASLParser.Payload_value_declContext) -> PayloadValue: + value = ctx.children[0] + return self.visit(value) + + def visitProgram_decl(self, ctx: ASLParser.Program_declContext) -> Program: + self._open_query_language_scope(ctx) + props = TypedProps() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + props.add(cmp) + if props.get(QueryLanguage) is None: + props.add(self._get_current_query_language()) + program = Program( + query_language=props.get(typ=QueryLanguage) or QueryLanguage(), + start_at=props.get( + typ=StartAt, + raise_on_missing=ValueError( + f"No '{StartAt}' definition for Program in context: '{ctx.getText()}'." + ), + ), + states=props.get( + typ=States, + raise_on_missing=ValueError( + f"No '{States}' definition for Program in context: '{ctx.getText()}'." + ), + ), + timeout_seconds=props.get(TimeoutSeconds), + comment=props.get(typ=Comment), + version=props.get(typ=Version), + ) + self._close_query_language_scope() + return program + + def visitState_machine(self, ctx: ASLParser.State_machineContext) -> Program: + return self.visit(ctx.program_decl()) + + def visitQuery_language_decl(self, ctx: ASLParser.Query_language_declContext) -> QueryLanguage: + query_language_mode_int = ctx.children[-1].getSymbol().type + query_language_mode = QueryLanguageMode(value=query_language_mode_int) + return QueryLanguage(query_language_mode=query_language_mode) + + def visitAssign_template_value_terminal_float( + self, ctx: ASLParser.Assign_template_value_terminal_floatContext + ) -> AssignTemplateValueTerminalLit: + float_value = float(ctx.NUMBER().getText()) + return AssignTemplateValueTerminalLit(value=float_value) + + def visitAssign_template_value_terminal_int( + self, ctx: ASLParser.Assign_template_value_terminal_intContext + ) -> AssignTemplateValueTerminalLit: + int_value = int(ctx.INT().getText()) + return AssignTemplateValueTerminalLit(value=int_value) + + def visitAssign_template_value_terminal_bool( + self, ctx: ASLParser.Assign_template_value_terminal_boolContext + ) -> AssignTemplateValueTerminalLit: + bool_term_rule: int = ctx.children[0].getSymbol().type + bool_value: bool = bool_term_rule == ASLLexer.TRUE + return AssignTemplateValueTerminalLit(value=bool_value) + + def visitAssign_template_value_terminal_null( + self, ctx: ASLParser.Assign_template_value_terminal_nullContext + ) -> AssignTemplateValueTerminalLit: + return AssignTemplateValueTerminalLit(value=None) + + def visitAssign_template_value_terminal_string_jsonata( + self, ctx: ASLParser.Assign_template_value_terminal_string_jsonataContext + ) -> AssignTemplateValueTerminal: + # Return a JSONata expression resolver or a suppressed depending on the current language mode. + current_query_language = self._get_current_query_language() + if current_query_language.query_language_mode == QueryLanguageMode.JSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return AssignTemplateValueTerminalStringJSONata(string_jsonata=string_jsonata) + else: + inner_string_value = self._inner_string_of(parser_rule_context=ctx.string_jsonata()) + return AssignTemplateValueTerminalLit(value=inner_string_value) + + def visitAssign_template_value_terminal_string_literal( + self, ctx: ASLParser.Assign_template_value_terminal_string_literalContext + ) -> AssignTemplateValueTerminal: + string_literal = self._inner_string_of(ctx.string_literal()) + return AssignTemplateValueTerminalLit(value=string_literal) + + def visitAssign_template_value(self, ctx: ASLParser.Assign_template_valueContext): + return self.visit(ctx.children[0]) + + def visitAssign_template_value_array( + self, ctx: ASLParser.Assign_template_value_arrayContext + ) -> AssignTemplateValueArray: + values: list[AssignTemplateValue] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, AssignTemplateValue): + values.append(cmp) + return AssignTemplateValueArray(values=values) + + def visitAssign_template_value_object( + self, ctx: ASLParser.Assign_template_value_objectContext + ) -> AssignTemplateValueObject: + bindings: list[AssignTemplateBinding] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, AssignTemplateBinding): + bindings.append(cmp) + return AssignTemplateValueObject(bindings=bindings) + + def visitAssign_template_binding_value( + self, ctx: ASLParser.Assign_template_binding_valueContext + ) -> AssignTemplateBindingValue: + string_literal: StringLiteral = self.visitString_literal(ctx=ctx.string_literal()) + assign_value: AssignTemplateValue = self.visit(ctx.assign_template_value()) + return AssignTemplateBindingValue( + identifier=string_literal.literal_value, assign_value=assign_value + ) + + def visitAssign_template_binding_string_expression_simple( + self, ctx: ASLParser.Assign_template_binding_string_expression_simpleContext + ) -> AssignTemplateBindingStringExpressionSimple: + identifier: str = self._inner_string_of(ctx.STRINGDOLLAR()) + identifier = identifier[:-2] + string_expression_simple: StringExpressionSimple = self.visitString_expression_simple( + ctx.string_expression_simple() + ) + return AssignTemplateBindingStringExpressionSimple( + identifier=identifier, string_expression_simple=string_expression_simple + ) + + def visitAssign_decl_binding( + self, ctx: ASLParser.Assign_decl_bindingContext + ) -> AssignDeclBinding: + binding: AssignTemplateBinding = self.visit(ctx.assign_template_binding()) + return AssignDeclBinding(binding=binding) + + def visitAssign_decl_body( + self, ctx: ASLParser.Assign_decl_bodyContext + ) -> list[AssignDeclBinding]: + bindings: list[AssignDeclBinding] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, AssignDeclBinding): + bindings.append(cmp) + return bindings + + def visitAssign_decl(self, ctx: ASLParser.Assign_declContext) -> AssignDecl: + declaration_bindings: list[AssignDeclBinding] = self.visit(ctx.assign_decl_body()) + return AssignDecl(declaration_bindings=declaration_bindings) + + def visitJsonata_template_value_terminal_float( + self, ctx: ASLParser.Jsonata_template_value_terminal_floatContext + ) -> JSONataTemplateValueTerminalLit: + float_value = float(ctx.NUMBER().getText()) + return JSONataTemplateValueTerminalLit(value=float_value) + + def visitJsonata_template_value_terminal_int( + self, ctx: ASLParser.Jsonata_template_value_terminal_intContext + ) -> JSONataTemplateValueTerminalLit: + int_value = int(ctx.INT().getText()) + return JSONataTemplateValueTerminalLit(value=int_value) + + def visitJsonata_template_value_terminal_bool( + self, ctx: ASLParser.Jsonata_template_value_terminal_boolContext + ) -> JSONataTemplateValueTerminalLit: + bool_term_rule: int = ctx.children[0].getSymbol().type + bool_value: bool = bool_term_rule == ASLLexer.TRUE + return JSONataTemplateValueTerminalLit(value=bool_value) + + def visitJsonata_template_value_terminal_null( + self, ctx: ASLParser.Jsonata_template_value_terminal_nullContext + ) -> JSONataTemplateValueTerminalLit: + return JSONataTemplateValueTerminalLit(value=None) + + def visitJsonata_template_value_terminal_string_jsonata( + self, ctx: ASLParser.Jsonata_template_value_terminal_string_jsonataContext + ) -> JSONataTemplateValueTerminalStringJSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return JSONataTemplateValueTerminalStringJSONata(string_jsonata=string_jsonata) + + def visitJsonata_template_value_terminal_string_literal( + self, ctx: ASLParser.Jsonata_template_value_terminal_string_literalContext + ) -> JSONataTemplateValueTerminalLit: + string = from_string_literal(ctx.string_literal()) + return JSONataTemplateValueTerminalLit(value=string) + + def visitJsonata_template_value( + self, ctx: ASLParser.Jsonata_template_valueContext + ) -> JSONataTemplateValue: + return self.visit(ctx.children[0]) + + def visitJsonata_template_value_array( + self, ctx: ASLParser.Jsonata_template_value_arrayContext + ) -> JSONataTemplateValueArray: + values: list[JSONataTemplateValue] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, JSONataTemplateValue): + values.append(cmp) + return JSONataTemplateValueArray(values=values) + + def visitJsonata_template_value_object( + self, ctx: ASLParser.Jsonata_template_value_objectContext + ) -> JSONataTemplateValueObject: + bindings: list[JSONataTemplateBinding] = list() + for child in ctx.children: + cmp: Optional[Component] = self.visit(child) + if isinstance(cmp, JSONataTemplateBinding): + bindings.append(cmp) + return JSONataTemplateValueObject(bindings=bindings) + + def visitJsonata_template_binding( + self, ctx: ASLParser.Jsonata_template_bindingContext + ) -> JSONataTemplateBinding: + identifier: str = self._inner_string_of(ctx.string_literal()) + value: JSONataTemplateValue = self.visit(ctx.jsonata_template_value()) + return JSONataTemplateBinding(identifier=identifier, value=value) + + def visitArguments_string_jsonata( + self, ctx: ASLParser.Arguments_string_jsonataContext + ) -> ArgumentsStringJSONata: + self._raise_if_query_language_is_not(query_language_mode=QueryLanguageMode.JSONata, ctx=ctx) + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return ArgumentsStringJSONata(string_jsonata=string_jsonata) + + def visitArguments_jsonata_template_value_object( + self, ctx: ASLParser.Arguments_jsonata_template_value_objectContext + ) -> ArgumentsJSONataTemplateValueObject: + self._raise_if_query_language_is_not(query_language_mode=QueryLanguageMode.JSONata, ctx=ctx) + jsonata_template_value_object: JSONataTemplateValueObject = self.visit( + ctx.jsonata_template_value_object() + ) + return ArgumentsJSONataTemplateValueObject( + jsonata_template_value_object=jsonata_template_value_object + ) + + def visitOutput_decl(self, ctx: ASLParser.Output_declContext) -> Output: + jsonata_template_value: JSONataTemplateValue = self.visit(ctx.jsonata_template_value()) + return Output(jsonata_template_value=jsonata_template_value) + + def visitItems_array(self, ctx: ASLParser.Items_arrayContext) -> ItemsArray: + jsonata_template_value_array: JSONataTemplateValueArray = self.visit( + ctx.jsonata_template_value_array() + ) + return ItemsArray(jsonata_template_value_array=jsonata_template_value_array) + + def visitItems_jsonata(self, ctx: ASLParser.Items_jsonataContext) -> ItemsJSONata: + string_jsonata: StringJSONata = self.visitString_jsonata(ctx.string_jsonata()) + return ItemsJSONata(string_jsonata=string_jsonata) + + def visitString_sampler(self, ctx: ASLParser.String_samplerContext) -> StringSampler: + return self.visit(ctx.children[0]) + + def visitString_literal(self, ctx: ASLParser.String_literalContext) -> StringLiteral: + string_literal = from_string_literal(parser_rule_context=ctx) + return StringLiteral(literal_value=string_literal) + + def visitString_jsonpath(self, ctx: ASLParser.String_jsonpathContext) -> StringJsonPath: + json_path: str = self._inner_string_of(parser_rule_context=ctx) + return StringJsonPath(json_path=json_path) + + def visitString_context_path( + self, ctx: ASLParser.String_context_pathContext + ) -> StringContextPath: + context_object_path: str = self._inner_string_of(parser_rule_context=ctx) + return StringContextPath(context_object_path=context_object_path) + + def visitString_variable_sample( + self, ctx: ASLParser.String_variable_sampleContext + ) -> StringVariableSample: + query_language_mode: QueryLanguageMode = ( + self._get_current_query_language().query_language_mode + ) + expression: str = self._inner_string_of(parser_rule_context=ctx) + return StringVariableSample(query_language_mode=query_language_mode, expression=expression) + + def visitString_jsonata(self, ctx: ASLParser.String_jsonataContext) -> StringJSONata: + self._raise_if_query_language_is_not(query_language_mode=QueryLanguageMode.JSONata, ctx=ctx) + expression = self._inner_jsonata_expr(ctx=ctx) + return StringJSONata(expression=expression) + + def visitString_intrinsic_function( + self, ctx: ASLParser.String_intrinsic_functionContext + ) -> StringIntrinsicFunction: + intrinsic_function_derivation = ctx.STRINGINTRINSICFUNC().getText()[1:-1] + function, _ = IntrinsicParser.parse(intrinsic_function_derivation) + return StringIntrinsicFunction( + intrinsic_function_derivation=intrinsic_function_derivation, function=function + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/asl_parser.py b/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/asl_parser.py new file mode 100644 index 0000000000000..d4c4b8b3ef582 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/asl_parser.py @@ -0,0 +1,39 @@ +from antlr4 import CommonTokenStream, InputStream, ParserRuleContext + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLLexer import ASLLexer +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.parse.asl_parser import ( + AmazonStateLanguageParser, + ASLParserException, + SyntaxErrorListener, +) +from localstack.services.stepfunctions.asl.parse.test_state.preprocessor import ( + TestStatePreprocessor, +) + + +class TestStateAmazonStateLanguageParser(AmazonStateLanguageParser): + @staticmethod + def parse(definition: str) -> tuple[EvalComponent, ParserRuleContext]: + # Attempt to build the AST and look out for syntax errors. + syntax_error_listener = SyntaxErrorListener() + + input_stream = InputStream(definition) + lexer = ASLLexer(input_stream) + stream = CommonTokenStream(lexer) + parser = ASLParser(stream) + parser.removeErrorListeners() + parser.addErrorListener(syntax_error_listener) + # Unlike the main Program parser, TestState parsing occurs at a state declaration level. + tree = parser.state_decl_body() + + errors = syntax_error_listener.errors + if errors: + raise ASLParserException(errors=errors) + + # Attempt to preprocess the AST into evaluation components. + preprocessor = TestStatePreprocessor() + test_state_program = preprocessor.visit(tree) + + return test_state_program, tree diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/preprocessor.py b/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/preprocessor.py new file mode 100644 index 0000000000000..0565f74a67a55 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/parse/test_state/preprocessor.py @@ -0,0 +1,131 @@ +import enum +from typing import Final + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.component.common.parargs import Parameters +from localstack.services.stepfunctions.asl.component.common.path.input_path import InputPath +from localstack.services.stepfunctions.asl.component.common.path.result_path import ResultPath +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguage +from localstack.services.stepfunctions.asl.component.common.result_selector import ResultSelector +from localstack.services.stepfunctions.asl.component.state.state import CommonStateField +from localstack.services.stepfunctions.asl.component.state.state_choice.state_choice import ( + StateChoice, +) +from localstack.services.stepfunctions.asl.component.state.state_execution.execute_state import ( + ExecutionState, +) +from localstack.services.stepfunctions.asl.component.state.state_pass.result import Result +from localstack.services.stepfunctions.asl.component.test_state.program.test_state_program import ( + TestStateProgram, +) +from localstack.services.stepfunctions.asl.component.test_state.state.test_state_state_props import ( + TestStateStateProps, +) +from localstack.services.stepfunctions.asl.eval.test_state.environment import TestStateEnvironment +from localstack.services.stepfunctions.asl.parse.preprocessor import Preprocessor +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + + +class InspectionDataKey(enum.Enum): + INPUT = "input" + AFTER_INPUT_PATH = "afterInputPath" + AFTER_PARAMETERS = "afterParameters" + RESULT = "result" + AFTER_RESULT_SELECTOR = "afterResultSelector" + AFTER_RESULT_PATH = "afterResultPath" + REQUEST = "request" + RESPONSE = "response" + + +def _decorated_updated_choice_inspection_data(method): + def wrapper(env: TestStateEnvironment, *args, **kwargs): + method(env, *args, **kwargs) + env.set_choice_selected(env.next_state_name) + + return wrapper + + +def _decorated_updates_inspection_data(method, inspection_data_key: InspectionDataKey): + def wrapper(env: TestStateEnvironment, *args, **kwargs): + method(env, *args, **kwargs) + result = to_json_str(env.stack[-1]) + # We know that the enum value used here corresponds to a supported inspection data field by design. + env.inspection_data[inspection_data_key.value] = result # noqa + + return wrapper + + +def _decorate_state_field(state_field: CommonStateField) -> None: + if isinstance(state_field, ExecutionState): + state_field._eval_execution = _decorated_updates_inspection_data( + # As part of the decoration process, we intentionally access this protected member + # to facilitate the decorator's functionality. + method=state_field._eval_execution, # noqa + inspection_data_key=InspectionDataKey.RESULT, + ) + elif isinstance(state_field, StateChoice): + state_field._eval_body = _decorated_updated_choice_inspection_data( + # As part of the decoration process, we intentionally access this protected member + # to facilitate the decorator's functionality. + method=state_field._eval_body # noqa + ) + + +class TestStatePreprocessor(Preprocessor): + STATE_NAME: Final[str] = "TestState" + + def visitState_decl_body(self, ctx: ASLParser.State_decl_bodyContext) -> TestStateProgram: + self._open_query_language_scope(ctx) + state_props = TestStateStateProps() + state_props.name = self.STATE_NAME + for child in ctx.children: + cmp = self.visit(child) + state_props.add(cmp) + state_field = self._common_state_field_of(state_props=state_props) + if state_props.get(QueryLanguage) is None: + state_props.add(self._get_current_query_language()) + _decorate_state_field(state_field) + self._close_query_language_scope() + return TestStateProgram(state_field) + + def visitInput_path_decl(self, ctx: ASLParser.Input_path_declContext) -> InputPath: + input_path: InputPath = super().visitInput_path_decl(ctx=ctx) + input_path._eval_body = _decorated_updates_inspection_data( + method=input_path._eval_body, # noqa + inspection_data_key=InspectionDataKey.AFTER_INPUT_PATH, + ) + return input_path + + def visitParameters_decl(self, ctx: ASLParser.Parameters_declContext) -> Parameters: + parameters: Parameters = super().visitParameters_decl(ctx=ctx) + parameters._eval_body = _decorated_updates_inspection_data( + method=parameters._eval_body, # noqa + inspection_data_key=InspectionDataKey.AFTER_PARAMETERS, + ) + return parameters + + def visitResult_selector_decl( + self, ctx: ASLParser.Result_selector_declContext + ) -> ResultSelector: + result_selector: ResultSelector = super().visitResult_selector_decl(ctx=ctx) + result_selector._eval_body = _decorated_updates_inspection_data( + method=result_selector._eval_body, # noqa + inspection_data_key=InspectionDataKey.AFTER_RESULT_SELECTOR, + ) + return result_selector + + def visitResult_path_decl(self, ctx: ASLParser.Result_path_declContext) -> ResultPath: + result_path: ResultPath = super().visitResult_path_decl(ctx=ctx) + result_path._eval_body = _decorated_updates_inspection_data( + method=result_path._eval_body, # noqa + inspection_data_key=InspectionDataKey.AFTER_RESULT_PATH, + ) + return result_path + + def visitResult_decl(self, ctx: ASLParser.Result_declContext) -> Result: + result: Result = super().visitResult_decl(ctx=ctx) + result._eval_body = _decorated_updates_inspection_data( + method=result._eval_body, + inspection_data_key=InspectionDataKey.RESULT, # noqa + ) + return result diff --git a/localstack-core/localstack/services/stepfunctions/asl/parse/typed_props.py b/localstack-core/localstack/services/stepfunctions/asl/parse/typed_props.py new file mode 100644 index 0000000000000..fda0913ec86b2 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/parse/typed_props.py @@ -0,0 +1,30 @@ +from collections import OrderedDict +from typing import Any, Optional + + +class TypedProps: + def __init__(self): + self._instance_by_type: dict[type, Any] = OrderedDict() + + def add(self, instance: Any) -> None: + self._add(type(instance), instance) + + def _add(self, typ: type, instance: Any) -> None: + if instance is None: + return + if typ in self._instance_by_type: + raise ValueError( + f"Redefinition of type '{typ}', from '{self._instance_by_type[typ]}' to '{instance}'." + ) + self._instance_by_type[typ] = instance + + def get(self, typ: type, raise_on_missing: Optional[Exception] = None) -> Optional[Any]: + if raise_on_missing and typ not in self._instance_by_type: + raise raise_on_missing + return self._instance_by_type.get(typ) + + def __repr__(self): + return str(self._instance_by_type) + + def __str__(self): + return repr(self) diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/express_static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/express_static_analyser.py new file mode 100644 index 0000000000000..9242215e23d0d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/express_static_analyser.py @@ -0,0 +1,34 @@ +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ActivityResource, + Resource, + ResourceCondition, + ServiceResource, +) +from localstack.services.stepfunctions.asl.static_analyser.static_analyser import StaticAnalyser + + +class ExpressStaticAnalyser(StaticAnalyser): + def visitResource_decl(self, ctx: ASLParser.Resource_declContext) -> None: + # TODO add resource path to the error messages. + + resource_str: str = ctx.string_literal().getText()[1:-1] + resource = Resource.from_resource_arn(resource_str) + + if isinstance(resource, ActivityResource): + raise ValueError( + "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: " + "Express state machine does not support Activity ARN'" + ) + + if isinstance(resource, ServiceResource): + if resource.condition == ResourceCondition.WaitForTaskToken: + raise ValueError( + "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: " + "Express state machine does not support '.sync' service integration." + ) + if resource.condition is not None: + raise ValueError( + "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: " + f"Express state machine does not support .'{resource.condition}' service integration." + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/intrinsic_static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/intrinsic_static_analyser.py new file mode 100644 index 0000000000000..b3d11c27d0646 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/intrinsic_static_analyser.py @@ -0,0 +1,12 @@ +import abc + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicParserVisitor import ( + ASLIntrinsicParserVisitor, +) +from localstack.services.stepfunctions.asl.parse.intrinsic.intrinsic_parser import IntrinsicParser + + +class IntrinsicStaticAnalyser(ASLIntrinsicParserVisitor, abc.ABC): + def analyse(self, definition: str) -> None: + _, parser_rule_context = IntrinsicParser.parse(definition) + self.visit(parser_rule_context) diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/variable_names_intrinsic_static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/variable_names_intrinsic_static_analyser.py new file mode 100644 index 0000000000000..6c4514183bfa3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/intrinsic/variable_names_intrinsic_static_analyser.py @@ -0,0 +1,41 @@ +from localstack.aws.api.stepfunctions import VariableName, VariableNameList +from localstack.services.stepfunctions.asl.antlr.runtime.ASLIntrinsicParser import ( + ASLIntrinsicParser, +) +from localstack.services.stepfunctions.asl.jsonata.jsonata import ( + VariableReference, + extract_jsonata_variable_references, +) +from localstack.services.stepfunctions.asl.static_analyser.intrinsic.intrinsic_static_analyser import ( + IntrinsicStaticAnalyser, +) + + +class VariableNamesIntrinsicStaticAnalyser(IntrinsicStaticAnalyser): + _variable_names: VariableNameList + + def __init__(self): + super().__init__() + self._variable_names = list() + + @staticmethod + def process_and_get(definition: str) -> VariableNameList: + analyser = VariableNamesIntrinsicStaticAnalyser() + analyser.analyse(definition=definition) + return analyser.get_variable_name_list() + + def get_variable_name_list(self) -> VariableNameList: + return self._variable_names + + def visitFunc_arg_list(self, ctx: ASLIntrinsicParser.Func_arg_listContext) -> None: + # TODO: the extraction logic is not always in the same order as AWS's + for child in ctx.children[::-1]: + self.visit(child) + + def visitFunc_arg_var(self, ctx: ASLIntrinsicParser.Func_arg_varContext) -> None: + variable_references: set[VariableReference] = extract_jsonata_variable_references( + ctx.STRING_VARIABLE().getText() + ) + for variable_reference in variable_references: + variable_name: VariableName = variable_reference[1:] + self._variable_names.append(variable_name) diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/static_analyser.py new file mode 100644 index 0000000000000..81b8c576953fe --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/static_analyser.py @@ -0,0 +1,10 @@ +import abc + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParserVisitor import ASLParserVisitor +from localstack.services.stepfunctions.asl.parse.asl_parser import AmazonStateLanguageParser + + +class StaticAnalyser(ASLParserVisitor, abc.ABC): + def analyse(self, definition: str) -> None: + _, parser_rule_context = AmazonStateLanguageParser.parse(definition) + self.visit(parser_rule_context) diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/test_state/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/test_state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/test_state/test_state_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/test_state/test_state_analyser.py new file mode 100644 index 0000000000000..79cb80196b54d --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/test_state/test_state_analyser.py @@ -0,0 +1,49 @@ +from typing import Final + +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.resource import ( + ActivityResource, + Resource, + ServiceResource, +) +from localstack.services.stepfunctions.asl.component.state.state_type import StateType +from localstack.services.stepfunctions.asl.parse.test_state.asl_parser import ( + TestStateAmazonStateLanguageParser, +) +from localstack.services.stepfunctions.asl.static_analyser.static_analyser import StaticAnalyser + + +class TestStateStaticAnalyser(StaticAnalyser): + _SUPPORTED_STATE_TYPES: Final[set[StateType]] = { + StateType.Task, + StateType.Pass, + StateType.Wait, + StateType.Choice, + StateType.Succeed, + StateType.Fail, + } + + def analyse(self, definition) -> None: + _, parser_rule_context = TestStateAmazonStateLanguageParser.parse(definition) + self.visit(parser_rule_context) + + def visitState_type(self, ctx: ASLParser.State_typeContext) -> None: + state_type_value: int = ctx.children[0].symbol.type + state_type = StateType(state_type_value) + if state_type not in self._SUPPORTED_STATE_TYPES: + raise ValueError(f"Unsupported state type for TestState runs '{state_type}'.") + + def visitResource_decl(self, ctx: ASLParser.Resource_declContext) -> None: + resource_str: str = ctx.string_literal().getText()[1:-1] + resource = Resource.from_resource_arn(resource_str) + + if isinstance(resource, ActivityResource): + raise ValueError( + f"ActivityResources are not supported for TestState runs {resource_str}." + ) + + if isinstance(resource, ServiceResource): + if resource.condition is not None: + raise ValueError( + f"Service integration patterns are not supported for TestState runs {resource_str}." + ) diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py new file mode 100644 index 0000000000000..65d5029e137c7 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/usage_metrics_static_analyser.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +from typing import Final + +from localstack.services.stepfunctions import analytics +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.component.common.query_language import ( + QueryLanguageMode, +) +from localstack.services.stepfunctions.asl.static_analyser.static_analyser import StaticAnalyser + +LOG = logging.getLogger(__name__) + + +class QueryLanguage(str): + JSONPath = QueryLanguageMode.JSONPath.name + JSONata = QueryLanguageMode.JSONata.name + Both = "JSONPath+JSONata" + + +class UsageMetricsStaticAnalyser(StaticAnalyser): + @staticmethod + def process(definition: str) -> UsageMetricsStaticAnalyser: + analyser = UsageMetricsStaticAnalyser() + try: + # Run the static analyser. + analyser.analyse(definition=definition) + + # Determine which query language is being used in this state machine. + query_modes = analyser.query_language_modes + if len(query_modes) == 2: + language_used = QueryLanguage.Both + elif QueryLanguageMode.JSONata in query_modes: + language_used = QueryLanguage.JSONata + else: + language_used = QueryLanguage.JSONPath + + # Determine is the state machine uses the variables feature. + uses_variables = analyser.uses_variables + + # Count. + analytics.language_features_counter.labels( + query_language=language_used, uses_variables=uses_variables + ).increment() + except Exception as e: + LOG.warning( + "Failed to record Step Functions metrics from static analysis", + exc_info=e, + ) + return analyser + + query_language_modes: Final[set[QueryLanguageMode]] + uses_variables: bool + + def __init__(self): + super().__init__() + self.query_language_modes = set() + self.uses_variables = False + + def visitQuery_language_decl(self, ctx: ASLParser.Query_language_declContext): + if len(self.query_language_modes) == 2: + # Both query language modes have been confirmed to be in use. + return + query_language_mode_int = ctx.children[-1].getSymbol().type + query_language_mode = QueryLanguageMode(value=query_language_mode_int) + self.query_language_modes.add(query_language_mode) + + def visitState_decl(self, ctx: ASLParser.State_declContext): + # If before entering a state, no query language was explicitly enforced, then we know + # this is the first state operating under the default mode (JSONPath) + if not self.query_language_modes: + self.query_language_modes.add(QueryLanguageMode.JSONPath) + super().visitState_decl(ctx=ctx) + + def visitString_literal(self, ctx: ASLParser.String_literalContext): + # Prune everything parsed as a string literal. + return + + def visitString_variable_sample(self, ctx: ASLParser.String_variable_sampleContext): + self.uses_variables = True + + def visitAssign_decl(self, ctx: ASLParser.Assign_declContext): + self.uses_variables = True diff --git a/localstack-core/localstack/services/stepfunctions/asl/static_analyser/variable_references_static_analyser.py b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/variable_references_static_analyser.py new file mode 100644 index 0000000000000..93edc9a06a97f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/static_analyser/variable_references_static_analyser.py @@ -0,0 +1,82 @@ +from collections import OrderedDict +from typing import Final + +from localstack.aws.api.stepfunctions import ( + StateName, + VariableName, + VariableNameList, + VariableReferences, +) +from localstack.services.stepfunctions.asl.antlr.runtime.ASLParser import ASLParser +from localstack.services.stepfunctions.asl.jsonata.jsonata import ( + VariableReference, + extract_jsonata_variable_references, +) +from localstack.services.stepfunctions.asl.static_analyser.intrinsic.variable_names_intrinsic_static_analyser import ( + VariableNamesIntrinsicStaticAnalyser, +) +from localstack.services.stepfunctions.asl.static_analyser.static_analyser import StaticAnalyser + + +class VariableReferencesStaticAnalyser(StaticAnalyser): + @staticmethod + def process_and_get(definition: str) -> VariableReferences: + analyser = VariableReferencesStaticAnalyser() + analyser.analyse(definition=definition) + return analyser.get_variable_references() + + _fringe_state_names: Final[list[StateName]] + _variable_references: Final[VariableReferences] + + def __init__(self): + super().__init__() + self._fringe_state_names = list() + self._variable_references = OrderedDict() + + def get_variable_references(self) -> VariableReferences: + return self._variable_references + + def _enter_state(self, state_name: StateName) -> None: + self._fringe_state_names.append(state_name) + + def _exit_state(self) -> None: + self._fringe_state_names.pop() + + def visitState_decl(self, ctx: ASLParser.State_declContext) -> None: + state_name: str = ctx.string_literal().getText()[1:-1] + self._enter_state(state_name=state_name) + super().visitState_decl(ctx=ctx) + self._exit_state() + + def _put_variable_reference(self, variable_reference: VariableReference) -> None: + variable_name: VariableName = variable_reference[1:] + self._put_variable_name(variable_name) + + def _put_variable_name(self, variable_name: VariableName) -> None: + state_name = self._fringe_state_names[-1] + variable_name_list: VariableNameList = self._variable_references.get(state_name, list()) + if variable_name in variable_name_list: + return + variable_name_list.append(variable_name) + if state_name not in self._variable_references: + self._variable_references[state_name] = variable_name_list + + def visitString_variable_sample(self, ctx: ASLParser.String_variable_sampleContext): + reference_body = ctx.getText()[1:-1] + variable_references: set[VariableReference] = extract_jsonata_variable_references( + reference_body + ) + for variable_reference in variable_references: + self._put_variable_reference(variable_reference) + + def visitString_intrinsic_function(self, ctx: ASLParser.String_intrinsic_functionContext): + definition_body = ctx.getText()[1:-1] + variable_name_list: VariableNameList = VariableNamesIntrinsicStaticAnalyser.process_and_get( + definition_body + ) + for variable_name in variable_name_list: + self._put_variable_name(variable_name) + + def visitString_literal(self, ctx: ASLParser.String_literalContext): + # Prune everything parsed as a string literal. + return diff --git a/localstack-core/localstack/services/stepfunctions/asl/utils/__init__.py b/localstack-core/localstack/services/stepfunctions/asl/utils/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/asl/utils/boto_client.py b/localstack-core/localstack/services/stepfunctions/asl/utils/boto_client.py new file mode 100644 index 0000000000000..c7facf1bb532c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/utils/boto_client.py @@ -0,0 +1,27 @@ +from botocore.client import BaseClient +from botocore.config import Config + +from localstack.aws.connect import connect_to +from localstack.services.stepfunctions.asl.component.common.timeouts.timeout import TimeoutSeconds +from localstack.services.stepfunctions.asl.component.state.state_execution.state_task.credentials import ( + StateCredentials, +) +from localstack.utils.aws.client_types import ServicePrincipal + +_BOTO_CLIENT_CONFIG = config = Config( + parameter_validation=False, + retries={"total_max_attempts": 1}, + connect_timeout=TimeoutSeconds.DEFAULT_TIMEOUT_SECONDS, + read_timeout=TimeoutSeconds.DEFAULT_TIMEOUT_SECONDS, + tcp_keepalive=True, +) + + +def boto_client_for(service: str, region: str, state_credentials: StateCredentials) -> BaseClient: + client_factory = connect_to.with_assumed_role( + role_arn=state_credentials.role_arn, + service_principal=ServicePrincipal.states, + region_name=region, + config=_BOTO_CLIENT_CONFIG, + ) + return client_factory.get_client(service=service) diff --git a/localstack-core/localstack/services/stepfunctions/asl/utils/encoding.py b/localstack-core/localstack/services/stepfunctions/asl/utils/encoding.py new file mode 100644 index 0000000000000..893db6cc28f44 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/utils/encoding.py @@ -0,0 +1,16 @@ +import datetime +import json +from json import JSONEncoder +from typing import Any, Optional + + +class _DateTimeEncoder(JSONEncoder): + def default(self, obj): + if isinstance(obj, (datetime.date, datetime.datetime)): + return obj.isoformat() + else: + return str(obj) + + +def to_json_str(obj: Any, separators: Optional[tuple[str, str]] = None) -> str: + return json.dumps(obj, cls=_DateTimeEncoder, separators=separators) diff --git a/localstack-core/localstack/services/stepfunctions/asl/utils/json_path.py b/localstack-core/localstack/services/stepfunctions/asl/utils/json_path.py new file mode 100644 index 0000000000000..2447458683daf --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/asl/utils/json_path.py @@ -0,0 +1,66 @@ +import re +from typing import Any, Final, Optional + +from jsonpath_ng.ext import parse +from jsonpath_ng.jsonpath import Index + +from localstack.services.events.utils import to_json_str + +_PATTERN_SINGLETON_ARRAY_ACCESS_OUTPUT: Final[str] = r"\[\d+\]$" +_PATTERN_SLICE_OR_WILDCARD_ACCESS = r"\$(?:\.[^[]+\[(?:\*|\d*:\d*)\]|\[\*\])(?:\.[^[]+)*$" + + +def _is_singleton_array_access(path: str) -> bool: + # Returns true if the json path terminates with a literal singleton array access. + return bool(re.search(_PATTERN_SINGLETON_ARRAY_ACCESS_OUTPUT, path)) + + +def _contains_slice_or_wildcard_array(path: str) -> bool: + # Returns true if the json path contains a slice or wildcard in the array. + # Slices at the root are discarded, but wildcard at the root is allowed. + return bool(re.search(_PATTERN_SLICE_OR_WILDCARD_ACCESS, path)) + + +class NoSuchJsonPathError(Exception): + json_path: Final[str] + data: Final[Any] + _message: Optional[str] + + def __init__(self, json_path: str, data: Any): + self.json_path = json_path + self.data = data + self._message = None + + @property + def message(self) -> str: + if self._message is None: + data_json_str = to_json_str(self.data) + self._message = ( + f"The JSONPath '{self.json_path}' could not be found in the input '{data_json_str}'" + ) + return self._message + + def __str__(self): + return self.message + + +def extract_json(path: str, data: Any) -> Any: + input_expr = parse(path) + + matches = input_expr.find(data) + if not matches: + if _contains_slice_or_wildcard_array(path): + return [] + raise NoSuchJsonPathError(json_path=path, data=data) + + if len(matches) > 1 or isinstance(matches[0].path, Index): + value = [match.value for match in matches] + + # AWS StepFunctions breaks jsonpath specifications and instead + # unpacks literal singleton array accesses. + if _is_singleton_array_access(path=path) and len(value) == 1: + value = value[0] + else: + value = matches[0].value + + return value diff --git a/localstack-core/localstack/services/stepfunctions/backend/__init__.py b/localstack-core/localstack/services/stepfunctions/backend/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/backend/activity.py b/localstack-core/localstack/services/stepfunctions/backend/activity.py new file mode 100644 index 0000000000000..8800dbd3fa122 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/activity.py @@ -0,0 +1,49 @@ +import datetime +from collections import deque +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + ActivityListItem, + Arn, + DescribeActivityOutput, + Name, + Timestamp, +) + + +class ActivityTask: + task_input: Final[str] + task_token: Final[str] + + def __init__(self, task_token: str, task_input: str): + self.task_token = task_token + self.task_input = task_input + + +class Activity: + arn: Final[Arn] + name: Final[Name] + creation_date: Final[Timestamp] + _tasks: Final[deque[ActivityTask]] + + def __init__(self, arn: Arn, name: Name, creation_date: Optional[Timestamp] = None): + self.arn = arn + self.name = name + self.creation_date = creation_date or datetime.datetime.now(tz=datetime.timezone.utc) + self._tasks = deque() + + def add_task(self, task: ActivityTask): + self._tasks.append(task) + + def get_task(self) -> Optional[ActivityTask]: + return self._tasks.popleft() + + def to_describe_activity_output(self) -> DescribeActivityOutput: + return DescribeActivityOutput( + activityArn=self.arn, name=self.name, creationDate=self.creation_date + ) + + def to_activity_list_item(self) -> ActivityListItem: + return ActivityListItem( + activityArn=self.arn, name=self.name, creationDate=self.creation_date + ) diff --git a/localstack-core/localstack/services/stepfunctions/backend/alias.py b/localstack-core/localstack/services/stepfunctions/backend/alias.py new file mode 100644 index 0000000000000..155890abf4cb3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/alias.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import copy +import datetime +import random +import threading +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + AliasDescription, + Arn, + CharacterRestrictedName, + DescribeStateMachineAliasOutput, + PageToken, + RoutingConfigurationList, + StateMachineAliasListItem, +) +from localstack.utils.strings import token_generator + + +class Alias: + _mutex: Final[threading.Lock] + update_date: Optional[datetime.datetime] + name: Final[CharacterRestrictedName] + _description: Optional[AliasDescription] + _routing_configuration_list: RoutingConfigurationList + _state_machine_version_arns: list[Arn] + _execution_probability_distribution: list[int] + state_machine_alias_arn: Final[Arn] + tokenized_state_machine_alias_arn: Final[PageToken] + create_date: datetime.datetime + + def __init__( + self, + state_machine_arn: Arn, + name: CharacterRestrictedName, + description: Optional[AliasDescription], + routing_configuration_list: RoutingConfigurationList, + ): + self._mutex = threading.Lock() + self.update_date = None + self.name = name + self._description = None + self.state_machine_alias_arn = f"{state_machine_arn}:{name}" + self.tokenized_state_machine_alias_arn = token_generator(self.state_machine_alias_arn) + self.update(description=description, routing_configuration_list=routing_configuration_list) + self.create_date = self._get_mutex_date() + + def __hash__(self): + return hash(self.state_machine_alias_arn) + + def __eq__(self, other): + if isinstance(other, Alias): + return self.is_idempotent(other=other) + return False + + def is_idempotent(self, other: Alias) -> bool: + return all( + [ + self.state_machine_alias_arn == other.state_machine_alias_arn, + self.name == other.name, + self._description == other._description, + self._routing_configuration_list == other._routing_configuration_list, + ] + ) + + @staticmethod + def _get_mutex_date() -> datetime.datetime: + return datetime.datetime.now(tz=datetime.timezone.utc) + + def get_routing_configuration_list(self) -> RoutingConfigurationList: + return copy.deepcopy(self._routing_configuration_list) + + def is_router_for(self, state_machine_version_arn: Arn) -> bool: + with self._mutex: + return state_machine_version_arn in self._state_machine_version_arns + + def update( + self, + description: Optional[AliasDescription], + routing_configuration_list: RoutingConfigurationList, + ) -> None: + with self._mutex: + self.update_date = self._get_mutex_date() + + if description is not None: + self._description = description + + if routing_configuration_list: + self._routing_configuration_list = routing_configuration_list + self._state_machine_version_arns = list() + self._execution_probability_distribution = list() + for routing_configuration in routing_configuration_list: + self._state_machine_version_arns.append( + routing_configuration["stateMachineVersionArn"] + ) + self._execution_probability_distribution.append(routing_configuration["weight"]) + + def sample(self): + with self._mutex: + samples = random.choices( + self._state_machine_version_arns, + weights=self._execution_probability_distribution, + k=1, + ) + state_machine_version_arn = samples[0] + return state_machine_version_arn + + def to_description(self) -> DescribeStateMachineAliasOutput: + with self._mutex: + description = DescribeStateMachineAliasOutput( + creationDate=self.create_date, + name=self.name, + description=self._description, + routingConfiguration=self._routing_configuration_list, + stateMachineAliasArn=self.state_machine_alias_arn, + ) + if self.update_date is not None: + description["updateDate"] = self.update_date + return description + + def to_item(self) -> StateMachineAliasListItem: + return StateMachineAliasListItem( + stateMachineAliasArn=self.state_machine_alias_arn, creationDate=self.create_date + ) diff --git a/localstack-core/localstack/services/stepfunctions/backend/execution.py b/localstack-core/localstack/services/stepfunctions/backend/execution.py new file mode 100644 index 0000000000000..552497557193f --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/execution.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import datetime +import json +import logging +from typing import Final, Optional + +from localstack.aws.api.events import PutEventsRequestEntry +from localstack.aws.api.stepfunctions import ( + Arn, + CloudWatchEventsExecutionDataDetails, + DescribeExecutionOutput, + DescribeStateMachineForExecutionOutput, + ExecutionListItem, + ExecutionStatus, + GetExecutionHistoryOutput, + HistoryEventList, + InvalidName, + SensitiveCause, + SensitiveError, + StartExecutionOutput, + StartSyncExecutionOutput, + StateMachineType, + SyncExecutionStatus, + Timestamp, + TraceHeader, + VariableReferences, +) +from localstack.aws.connect import connect_to +from localstack.services.stepfunctions.asl.eval.evaluation_details import ( + AWSExecutionDetails, + EvaluationDetails, + ExecutionDetails, + StateMachineDetails, +) +from localstack.services.stepfunctions.asl.eval.event.logging import ( + CloudWatchLoggingSession, +) +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramEnded, + ProgramError, + ProgramState, + ProgramStopped, + ProgramTimedOut, +) +from localstack.services.stepfunctions.asl.static_analyser.variable_references_static_analyser import ( + VariableReferencesStaticAnalyser, +) +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.backend.activity import Activity +from localstack.services.stepfunctions.backend.execution_worker import ( + ExecutionWorker, + SyncExecutionWorker, +) +from localstack.services.stepfunctions.backend.execution_worker_comm import ( + ExecutionWorkerCommunication, +) +from localstack.services.stepfunctions.backend.state_machine import ( + StateMachineInstance, + StateMachineVersion, +) +from localstack.services.stepfunctions.mocking.mock_config import MockTestCase + +LOG = logging.getLogger(__name__) + + +class BaseExecutionWorkerCommunication(ExecutionWorkerCommunication): + execution: Final[Execution] + + def __init__(self, execution: Execution): + self.execution = execution + + def _reflect_execution_status(self): + exit_program_state: ProgramState = self.execution.exec_worker.env.program_state() + self.execution.stop_date = datetime.datetime.now(tz=datetime.timezone.utc) + if isinstance(exit_program_state, ProgramEnded): + self.execution.exec_status = ExecutionStatus.SUCCEEDED + self.execution.output = self.execution.exec_worker.env.states.get_input() + elif isinstance(exit_program_state, ProgramStopped): + self.execution.exec_status = ExecutionStatus.ABORTED + elif isinstance(exit_program_state, ProgramError): + self.execution.exec_status = ExecutionStatus.FAILED + self.execution.error = exit_program_state.error.get("error") + self.execution.cause = exit_program_state.error.get("cause") + elif isinstance(exit_program_state, ProgramTimedOut): + self.execution.exec_status = ExecutionStatus.TIMED_OUT + else: + raise RuntimeWarning( + f"Execution ended with unsupported ProgramState type '{type(exit_program_state)}'." + ) + + def terminated(self) -> None: + self._reflect_execution_status() + self.execution.publish_execution_status_change_event() + + +class Execution: + name: Final[str] + sm_type: Final[StateMachineType] + role_arn: Final[Arn] + exec_arn: Final[Arn] + + account_id: str + region_name: str + + state_machine: Final[StateMachineInstance] + state_machine_arn: Final[Arn] + state_machine_version_arn: Final[Optional[Arn]] + state_machine_alias_arn: Final[Optional[Arn]] + + mock_test_case: Final[Optional[MockTestCase]] + + start_date: Final[Timestamp] + input_data: Final[Optional[json]] + input_details: Final[Optional[CloudWatchEventsExecutionDataDetails]] + trace_header: Final[Optional[TraceHeader]] + _cloud_watch_logging_session: Final[Optional[CloudWatchLoggingSession]] + + exec_status: Optional[ExecutionStatus] + stop_date: Optional[Timestamp] + + output: Optional[json] + output_details: Optional[CloudWatchEventsExecutionDataDetails] + + error: Optional[SensitiveError] + cause: Optional[SensitiveCause] + + exec_worker: Optional[ExecutionWorker] + + _activity_store: dict[Arn, Activity] + + def __init__( + self, + name: str, + sm_type: StateMachineType, + role_arn: Arn, + exec_arn: Arn, + account_id: str, + region_name: str, + state_machine: StateMachineInstance, + start_date: Timestamp, + cloud_watch_logging_session: Optional[CloudWatchLoggingSession], + activity_store: dict[Arn, Activity], + input_data: Optional[json] = None, + trace_header: Optional[TraceHeader] = None, + state_machine_alias_arn: Optional[Arn] = None, + mock_test_case: Optional[MockTestCase] = None, + ): + self.name = name + self.sm_type = sm_type + self.role_arn = role_arn + self.exec_arn = exec_arn + self.account_id = account_id + self.region_name = region_name + self.state_machine = state_machine + if isinstance(state_machine, StateMachineVersion): + self.state_machine_arn = state_machine.source_arn + self.state_machine_version_arn = state_machine.arn + else: + self.state_machine_arn = state_machine.arn + self.state_machine_version_arn = None + self.state_machine_alias_arn = state_machine_alias_arn + self.start_date = start_date + self._cloud_watch_logging_session = cloud_watch_logging_session + self.input_data = input_data + self.input_details = CloudWatchEventsExecutionDataDetails(included=True) + self.trace_header = trace_header + self.exec_status = None + self.stop_date = None + self.output = None + self.output_details = CloudWatchEventsExecutionDataDetails(included=True) + self.exec_worker = None + self.error = None + self.cause = None + self._activity_store = activity_store + self.mock_test_case = mock_test_case + + def _get_events_client(self): + return connect_to(aws_access_key_id=self.account_id, region_name=self.region_name).events + + def to_start_output(self) -> StartExecutionOutput: + return StartExecutionOutput(executionArn=self.exec_arn, startDate=self.start_date) + + def to_describe_output(self) -> DescribeExecutionOutput: + describe_output = DescribeExecutionOutput( + executionArn=self.exec_arn, + stateMachineArn=self.state_machine_arn, + name=self.name, + status=self.exec_status, + startDate=self.start_date, + stopDate=self.stop_date, + input=to_json_str(self.input_data, separators=(",", ":")), + inputDetails=self.input_details, + traceHeader=self.trace_header, + ) + if describe_output["status"] == ExecutionStatus.SUCCEEDED: + describe_output["output"] = to_json_str(self.output, separators=(",", ":")) + describe_output["outputDetails"] = self.output_details + if self.error is not None: + describe_output["error"] = self.error + if self.cause is not None: + describe_output["cause"] = self.cause + if self.state_machine_version_arn is not None: + describe_output["stateMachineVersionArn"] = self.state_machine_version_arn + if self.state_machine_alias_arn is not None: + describe_output["stateMachineAliasArn"] = self.state_machine_alias_arn + return describe_output + + def to_describe_state_machine_for_execution_output( + self, + ) -> DescribeStateMachineForExecutionOutput: + state_machine: StateMachineInstance = self.state_machine + state_machine_arn = ( + state_machine.source_arn + if isinstance(state_machine, StateMachineVersion) + else state_machine.arn + ) + out = DescribeStateMachineForExecutionOutput( + stateMachineArn=state_machine_arn, + name=state_machine.name, + definition=state_machine.definition, + roleArn=self.role_arn, + # The date and time the state machine associated with an execution was updated. + updateDate=state_machine.create_date, + loggingConfiguration=state_machine.logging_config, + ) + revision_id = self.state_machine.revision_id + if self.state_machine.revision_id: + out["revisionId"] = revision_id + variable_references: VariableReferences = VariableReferencesStaticAnalyser.process_and_get( + definition=self.state_machine.definition + ) + if variable_references: + out["variableReferences"] = variable_references + return out + + def to_execution_list_item(self) -> ExecutionListItem: + if isinstance(self.state_machine, StateMachineVersion): + state_machine_arn = self.state_machine.source_arn + state_machine_version_arn = self.state_machine.arn + else: + state_machine_arn = self.state_machine.arn + state_machine_version_arn = None + + item = ExecutionListItem( + executionArn=self.exec_arn, + stateMachineArn=state_machine_arn, + name=self.name, + status=self.exec_status, + startDate=self.start_date, + stopDate=self.stop_date, + ) + if state_machine_version_arn is not None: + item["stateMachineVersionArn"] = state_machine_version_arn + if self.state_machine_alias_arn is not None: + item["stateMachineAliasArn"] = self.state_machine_alias_arn + return item + + def to_history_output(self) -> GetExecutionHistoryOutput: + env = self.exec_worker.env + event_history: HistoryEventList = list() + if env is not None: + # The execution has not started yet. + event_history: HistoryEventList = env.event_manager.get_event_history() + return GetExecutionHistoryOutput(events=event_history) + + @staticmethod + def _to_serialized_date(timestamp: datetime.datetime) -> str: + """See test in tests.aws.services.stepfunctions.v2.base.test_base.TestSnfBase.test_execution_dateformat""" + return ( + f"{timestamp.astimezone(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z" + ) + + def _get_start_execution_worker_comm(self) -> BaseExecutionWorkerCommunication: + return BaseExecutionWorkerCommunication(self) + + def _get_start_aws_execution_details(self) -> AWSExecutionDetails: + return AWSExecutionDetails( + account=self.account_id, region=self.region_name, role_arn=self.role_arn + ) + + def get_start_execution_details(self) -> ExecutionDetails: + return ExecutionDetails( + arn=self.exec_arn, + name=self.name, + role_arn=self.role_arn, + inpt=self.input_data, + start_time=self._to_serialized_date(self.start_date), + ) + + def get_start_state_machine_details(self) -> StateMachineDetails: + return StateMachineDetails( + arn=self.state_machine.arn, + name=self.state_machine.name, + typ=self.state_machine.sm_type, + definition=self.state_machine.definition, + ) + + def _get_start_execution_worker(self) -> ExecutionWorker: + return ExecutionWorker( + evaluation_details=EvaluationDetails( + aws_execution_details=self._get_start_aws_execution_details(), + execution_details=self.get_start_execution_details(), + state_machine_details=self.get_start_state_machine_details(), + ), + exec_comm=self._get_start_execution_worker_comm(), + cloud_watch_logging_session=self._cloud_watch_logging_session, + activity_store=self._activity_store, + mock_test_case=self.mock_test_case, + ) + + def start(self) -> None: + # TODO: checks exec_worker does not exists already? + if self.exec_worker: + raise InvalidName() # TODO. + self.exec_worker = self._get_start_execution_worker() + self.exec_status = ExecutionStatus.RUNNING + self.publish_execution_status_change_event() + self.exec_worker.start() + + def stop(self, stop_date: datetime.datetime, error: Optional[str], cause: Optional[str]): + exec_worker: Optional[ExecutionWorker] = self.exec_worker + if exec_worker: + exec_worker.stop(stop_date=stop_date, cause=cause, error=error) + + def publish_execution_status_change_event(self): + input_value = ( + dict() if not self.input_data else to_json_str(self.input_data, separators=(",", ":")) + ) + output_value = ( + None if self.output is None else to_json_str(self.output, separators=(",", ":")) + ) + output_details = None if output_value is None else self.output_details + entry = PutEventsRequestEntry( + Source="aws.states", + Resources=[self.exec_arn], + DetailType="Step Functions Execution Status Change", + Detail=to_json_str( + # Note: this operation carries significant changes from a describe_execution request. + DescribeExecutionOutput( + executionArn=self.exec_arn, + stateMachineArn=self.state_machine.arn, + stateMachineAliasArn=None, + stateMachineVersionArn=None, + name=self.name, + status=self.exec_status, + startDate=self.start_date, + stopDate=self.stop_date, + input=input_value, + inputDetails=self.input_details, + output=output_value, + outputDetails=output_details, + error=self.error, + cause=self.cause, + ) + ), + ) + try: + self._get_events_client().put_events(Entries=[entry]) + except Exception: + LOG.exception( + "Unable to send notification of Entry='%s' for Step Function execution with Arn='%s' to EventBridge.", + entry, + self.exec_arn, + ) + + +class SyncExecutionWorkerCommunication(BaseExecutionWorkerCommunication): + execution: Final[SyncExecution] + + def _reflect_execution_status(self) -> None: + super()._reflect_execution_status() + exit_status: ExecutionStatus = self.execution.exec_status + if exit_status == ExecutionStatus.SUCCEEDED: + self.execution.sync_execution_status = SyncExecutionStatus.SUCCEEDED + elif exit_status == ExecutionStatus.TIMED_OUT: + self.execution.sync_execution_status = SyncExecutionStatus.TIMED_OUT + else: + self.execution.sync_execution_status = SyncExecutionStatus.FAILED + + +class SyncExecution(Execution): + sync_execution_status: Optional[SyncExecutionStatus] = None + + def _get_start_execution_worker(self) -> SyncExecutionWorker: + return SyncExecutionWorker( + evaluation_details=EvaluationDetails( + aws_execution_details=self._get_start_aws_execution_details(), + execution_details=self.get_start_execution_details(), + state_machine_details=self.get_start_state_machine_details(), + ), + exec_comm=self._get_start_execution_worker_comm(), + cloud_watch_logging_session=self._cloud_watch_logging_session, + activity_store=self._activity_store, + mock_test_case=self.mock_test_case, + ) + + def _get_start_execution_worker_comm(self) -> BaseExecutionWorkerCommunication: + return SyncExecutionWorkerCommunication(self) + + def to_start_sync_execution_output(self) -> StartSyncExecutionOutput: + start_output = StartSyncExecutionOutput( + executionArn=self.exec_arn, + stateMachineArn=self.state_machine.arn, + name=self.name, + status=self.sync_execution_status, + startDate=self.start_date, + stopDate=self.stop_date, + input=to_json_str(self.input_data, separators=(",", ":")), + inputDetails=self.input_details, + traceHeader=self.trace_header, + ) + if self.sync_execution_status == SyncExecutionStatus.SUCCEEDED: + start_output["output"] = to_json_str(self.output, separators=(",", ":")) + if self.output_details: + start_output["outputDetails"] = self.output_details + if self.error is not None: + start_output["error"] = self.error + if self.cause is not None: + start_output["cause"] = self.cause + return start_output diff --git a/localstack-core/localstack/services/stepfunctions/backend/execution_worker.py b/localstack-core/localstack/services/stepfunctions/backend/execution_worker.py new file mode 100644 index 0000000000000..c2d14c2085295 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/execution_worker.py @@ -0,0 +1,123 @@ +import datetime +from threading import Thread +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + Arn, + ExecutionStartedEventDetails, + HistoryEventExecutionDataDetails, + HistoryEventType, +) +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.evaluation_details import EvaluationDetails +from localstack.services.stepfunctions.asl.eval.event.event_detail import EventDetails +from localstack.services.stepfunctions.asl.eval.event.event_manager import ( + EventHistoryContext, +) +from localstack.services.stepfunctions.asl.eval.event.logging import ( + CloudWatchLoggingSession, +) +from localstack.services.stepfunctions.asl.eval.states import ( + ContextObjectData, + ExecutionData, + StateMachineData, +) +from localstack.services.stepfunctions.asl.parse.asl_parser import AmazonStateLanguageParser +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.backend.activity import Activity +from localstack.services.stepfunctions.backend.execution_worker_comm import ( + ExecutionWorkerCommunication, +) +from localstack.services.stepfunctions.mocking.mock_config import MockTestCase +from localstack.utils.common import TMP_THREADS + + +class ExecutionWorker: + _evaluation_details: Final[EvaluationDetails] + _execution_communication: Final[ExecutionWorkerCommunication] + _cloud_watch_logging_session: Final[Optional[CloudWatchLoggingSession]] + _mock_test_case: Final[Optional[MockTestCase]] + _activity_store: dict[Arn, Activity] + + env: Optional[Environment] + + def __init__( + self, + evaluation_details: EvaluationDetails, + exec_comm: ExecutionWorkerCommunication, + cloud_watch_logging_session: Optional[CloudWatchLoggingSession], + activity_store: dict[Arn, Activity], + mock_test_case: Optional[MockTestCase] = None, + ): + self._evaluation_details = evaluation_details + self._execution_communication = exec_comm + self._cloud_watch_logging_session = cloud_watch_logging_session + self._mock_test_case = mock_test_case + self._activity_store = activity_store + self.env = None + + def _get_evaluation_entrypoint(self) -> EvalComponent: + return AmazonStateLanguageParser.parse( + self._evaluation_details.state_machine_details.definition + )[0] + + def _get_evaluation_environment(self) -> Environment: + return Environment( + aws_execution_details=self._evaluation_details.aws_execution_details, + execution_type=self._evaluation_details.state_machine_details.typ, + context=ContextObjectData( + Execution=ExecutionData( + Id=self._evaluation_details.execution_details.arn, + Input=self._evaluation_details.execution_details.inpt, + Name=self._evaluation_details.execution_details.name, + RoleArn=self._evaluation_details.execution_details.role_arn, + StartTime=self._evaluation_details.execution_details.start_time, + ), + StateMachine=StateMachineData( + Id=self._evaluation_details.state_machine_details.arn, + Name=self._evaluation_details.state_machine_details.name, + ), + ), + event_history_context=EventHistoryContext.of_program_start(), + cloud_watch_logging_session=self._cloud_watch_logging_session, + activity_store=self._activity_store, + mock_test_case=self._mock_test_case, + ) + + def _execution_logic(self): + program = self._get_evaluation_entrypoint() + self.env = self._get_evaluation_environment() + + self.env.event_manager.add_event( + context=self.env.event_history_context, + event_type=HistoryEventType.ExecutionStarted, + event_details=EventDetails( + executionStartedEventDetails=ExecutionStartedEventDetails( + input=to_json_str(self._evaluation_details.execution_details.inpt), + inputDetails=HistoryEventExecutionDataDetails( + truncated=False + ), # Always False for api calls. + roleArn=self._evaluation_details.aws_execution_details.role_arn, + ) + ), + update_source_event_id=False, + ) + + program.eval(self.env) + + self._execution_communication.terminated() + + def start(self): + execution_logic_thread = Thread(target=self._execution_logic, daemon=True) + TMP_THREADS.append(execution_logic_thread) + execution_logic_thread.start() + + def stop(self, stop_date: datetime.datetime, error: Optional[str], cause: Optional[str]): + self.env.set_stop(stop_date=stop_date, cause=cause, error=error) + + +class SyncExecutionWorker(ExecutionWorker): + def start(self): + # bypass the native async execution of ASL programs. + self._execution_logic() diff --git a/localstack-core/localstack/services/stepfunctions/backend/execution_worker_comm.py b/localstack-core/localstack/services/stepfunctions/backend/execution_worker_comm.py new file mode 100644 index 0000000000000..c2e1d74849bbe --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/execution_worker_comm.py @@ -0,0 +1,12 @@ +import abc + + +class ExecutionWorkerCommunication(abc.ABC): + """ + Defines abstract callbacks for Execution's workers to report their progress, such as termination. + Execution instances define custom callbacks routines to update their state according to the latest + relevant state machine evaluation steps. + """ + + @abc.abstractmethod + def terminated(self) -> None: ... diff --git a/localstack-core/localstack/services/stepfunctions/backend/state_machine.py b/localstack-core/localstack/services/stepfunctions/backend/state_machine.py new file mode 100644 index 0000000000000..71c82f55c881c --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/state_machine.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import abc +import datetime +import json +from collections import OrderedDict +from typing import Final, Optional + +from localstack.aws.api.stepfunctions import ( + Arn, + Definition, + DescribeStateMachineOutput, + LoggingConfiguration, + Name, + RevisionId, + StateMachineListItem, + StateMachineStatus, + StateMachineType, + StateMachineVersionListItem, + Tag, + TagKeyList, + TagList, + TracingConfiguration, + ValidationException, + VariableReferences, +) +from localstack.services.stepfunctions.asl.eval.event.logging import ( + CloudWatchLoggingConfiguration, +) +from localstack.services.stepfunctions.asl.static_analyser.variable_references_static_analyser import ( + VariableReferencesStaticAnalyser, +) +from localstack.services.stepfunctions.backend.alias import Alias +from localstack.utils.strings import long_uid + + +class StateMachineInstance: + name: Name + arn: Arn + revision_id: Optional[RevisionId] + definition: Definition + role_arn: Arn + create_date: datetime.datetime + sm_type: StateMachineType + logging_config: LoggingConfiguration + cloud_watch_logging_configuration: Optional[CloudWatchLoggingConfiguration] + tags: Optional[TagList] + tracing_config: Optional[TracingConfiguration] + + def __init__( + self, + name: Name, + arn: Arn, + definition: Definition, + role_arn: Arn, + logging_config: LoggingConfiguration, + cloud_watch_logging_configuration: Optional[CloudWatchLoggingConfiguration] = None, + create_date: Optional[datetime.datetime] = None, + sm_type: Optional[StateMachineType] = None, + tags: Optional[TagList] = None, + tracing_config: Optional[TracingConfiguration] = None, + ): + self.name = name + self.arn = arn + self.revision_id = None + self.definition = definition + self.role_arn = role_arn + self.create_date = create_date or datetime.datetime.now(tz=datetime.timezone.utc) + self.sm_type = sm_type or StateMachineType.STANDARD + self.logging_config = logging_config + self.cloud_watch_logging_configuration = cloud_watch_logging_configuration + self.tags = tags + self.tracing_config = tracing_config + + def describe(self) -> DescribeStateMachineOutput: + describe_output = DescribeStateMachineOutput( + stateMachineArn=self.arn, + name=self.name, + status=StateMachineStatus.ACTIVE, + definition=self.definition, + roleArn=self.role_arn, + type=self.sm_type, + creationDate=self.create_date, + loggingConfiguration=self.logging_config, + ) + + if self.revision_id: + describe_output["revisionId"] = self.revision_id + + variable_references: VariableReferences = VariableReferencesStaticAnalyser.process_and_get( + definition=self.definition + ) + if variable_references: + describe_output["variableReferences"] = variable_references + + return describe_output + + @abc.abstractmethod + def itemise(self): ... + + +class TestStateMachine(StateMachineInstance): + def __init__( + self, + name: Name, + arn: Arn, + definition: Definition, + role_arn: Arn, + create_date: Optional[datetime.datetime] = None, + ): + super().__init__( + name, + arn, + definition, + role_arn, + create_date, + StateMachineType.STANDARD, + None, + None, + None, + ) + + def itemise(self): + raise NotImplementedError("TestStateMachine does not support itemise.") + + +class TagManager: + _tags: Final[dict[str, Optional[str]]] + + def __init__(self): + self._tags = OrderedDict() + + @staticmethod + def _validate_key_value(key: str) -> None: + if not key: + raise ValidationException() + + @staticmethod + def _validate_tag_value(value: str) -> None: + if value is None: + raise ValidationException() + + def add_all(self, tags: TagList) -> None: + for tag in tags: + tag_key = tag["key"] + tag_value = tag["value"] + self._validate_key_value(key=tag_key) + self._validate_tag_value(value=tag_value) + self._tags[tag_key] = tag_value + + def remove_all(self, keys: TagKeyList): + for key in keys: + self._validate_key_value(key=key) + self._tags.pop(key, None) + + def to_tag_list(self) -> TagList: + tag_list = list() + for key, value in self._tags.items(): + tag_list.append(Tag(key=key, value=value)) + return tag_list + + +class StateMachineRevision(StateMachineInstance): + _next_version_number: int + versions: Final[dict[RevisionId, Arn]] + tag_manager: Final[TagManager] + aliases: Final[set[Alias]] + + def __init__( + self, + name: Name, + arn: Arn, + definition: Definition, + role_arn: Arn, + logging_config: LoggingConfiguration, + cloud_watch_logging_configuration: Optional[CloudWatchLoggingConfiguration], + create_date: Optional[datetime.datetime] = None, + sm_type: Optional[StateMachineType] = None, + tags: Optional[TagList] = None, + tracing_config: Optional[TracingConfiguration] = None, + ): + super().__init__( + name, + arn, + definition, + role_arn, + logging_config, + cloud_watch_logging_configuration, + create_date, + sm_type, + tags, + tracing_config, + ) + self.versions = dict() + self._version_number = 0 + self.tag_manager = TagManager() + if tags: + self.tag_manager.add_all(tags) + self.aliases = set() + + def create_revision( + self, + definition: Optional[str], + role_arn: Optional[Arn], + logging_configuration: Optional[LoggingConfiguration], + ) -> Optional[RevisionId]: + update_definition = definition and json.loads(definition) != json.loads(self.definition) + if update_definition: + self.definition = definition + + update_role_arn = role_arn and role_arn != self.role_arn + if update_role_arn: + self.role_arn = role_arn + + update_logging_configuration = ( + logging_configuration and logging_configuration != self.logging_config + ) + if update_logging_configuration: + self.logging_config = logging_configuration + self.cloud_watch_logging_configuration = ( + CloudWatchLoggingConfiguration.from_logging_configuration( + state_machine_arn=self.arn, logging_configuration=self.logging_config + ) + ) + + if any([update_definition, update_role_arn, update_logging_configuration]): + self.revision_id = long_uid() + + return self.revision_id + + def create_version(self, description: Optional[str]) -> Optional[StateMachineVersion]: + if self.revision_id not in self.versions: + self._version_number += 1 + version = StateMachineVersion( + self, version=self._version_number, description=description + ) + self.versions[self.revision_id] = version.arn + + return version + return None + + def delete_version(self, state_machine_version_arn: Arn) -> None: + source_revision_id = None + for revision_id, version_arn in self.versions.items(): + if version_arn == state_machine_version_arn: + source_revision_id = revision_id + break + self.versions.pop(source_revision_id, None) + + def itemise(self) -> StateMachineListItem: + return StateMachineListItem( + stateMachineArn=self.arn, + name=self.name, + type=self.sm_type, + creationDate=self.create_date, + ) + + +class StateMachineVersion(StateMachineInstance): + source_arn: Arn + version: int + description: Optional[str] + + def __init__( + self, state_machine_revision: StateMachineRevision, version: int, description: Optional[str] + ): + version_arn = f"{state_machine_revision.arn}:{version}" + super().__init__( + name=state_machine_revision.name, + arn=version_arn, + definition=state_machine_revision.definition, + role_arn=state_machine_revision.role_arn, + create_date=datetime.datetime.now(tz=datetime.timezone.utc), + sm_type=state_machine_revision.sm_type, + logging_config=state_machine_revision.logging_config, + cloud_watch_logging_configuration=state_machine_revision.cloud_watch_logging_configuration, + tags=state_machine_revision.tags, + tracing_config=state_machine_revision.tracing_config, + ) + self.source_arn = state_machine_revision.arn + self.revision_id = state_machine_revision.revision_id + self.version = version + self.description = description + + def describe(self) -> DescribeStateMachineOutput: + describe_output: DescribeStateMachineOutput = super().describe() + if self.description: + describe_output["description"] = self.description + return describe_output + + def itemise(self) -> StateMachineVersionListItem: + return StateMachineVersionListItem( + stateMachineVersionArn=self.arn, creationDate=self.create_date + ) diff --git a/localstack-core/localstack/services/stepfunctions/backend/store.py b/localstack-core/localstack/services/stepfunctions/backend/store.py new file mode 100644 index 0000000000000..825fb2b630c83 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/store.py @@ -0,0 +1,24 @@ +from collections import OrderedDict +from typing import Final + +from localstack.aws.api.stepfunctions import Arn +from localstack.services.stepfunctions.backend.activity import Activity +from localstack.services.stepfunctions.backend.alias import Alias +from localstack.services.stepfunctions.backend.execution import Execution +from localstack.services.stepfunctions.backend.state_machine import StateMachineInstance +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + + +class SFNStore(BaseStore): + # Maps ARNs to state machines. + state_machines: Final[dict[Arn, StateMachineInstance]] = LocalAttribute(default=dict) + # Map Alias ARNs to state machine aliases + aliases: Final[dict[Arn, Alias]] = LocalAttribute(default=dict) + # Maps Execution-ARNs to state machines. + executions: Final[dict[Arn, Execution]] = LocalAttribute( + default=OrderedDict + ) # TODO: when snapshot to pods stop execution(?) + activities: Final[OrderedDict[Arn, Activity]] = LocalAttribute(default=dict) + + +sfn_stores: Final[AccountRegionBundle] = AccountRegionBundle("stepfunctions", SFNStore) diff --git a/localstack-core/localstack/services/stepfunctions/backend/test_state/__init__.py b/localstack-core/localstack/services/stepfunctions/backend/test_state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/backend/test_state/execution.py b/localstack-core/localstack/services/stepfunctions/backend/test_state/execution.py new file mode 100644 index 0000000000000..cc200f09b29c6 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/test_state/execution.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import logging +import threading +from typing import Optional + +from localstack.aws.api.stepfunctions import ( + Arn, + ExecutionStatus, + InspectionLevel, + StateMachineType, + TestExecutionStatus, + TestStateOutput, + Timestamp, +) +from localstack.services.stepfunctions.asl.eval.evaluation_details import EvaluationDetails +from localstack.services.stepfunctions.asl.eval.program_state import ( + ProgramEnded, + ProgramError, + ProgramState, +) +from localstack.services.stepfunctions.asl.eval.test_state.program_state import ( + ProgramChoiceSelected, +) +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.backend.activity import Activity +from localstack.services.stepfunctions.backend.execution import ( + BaseExecutionWorkerCommunication, + Execution, +) +from localstack.services.stepfunctions.backend.state_machine import StateMachineInstance +from localstack.services.stepfunctions.backend.test_state.execution_worker import ( + TestStateExecutionWorker, +) + +LOG = logging.getLogger(__name__) + + +class TestStateExecution(Execution): + exec_worker: Optional[TestStateExecutionWorker] + next_state: Optional[str] + + class TestCaseExecutionWorkerCommunication(BaseExecutionWorkerCommunication): + _execution: TestStateExecution + + def terminated(self) -> None: + exit_program_state: ProgramState = self.execution.exec_worker.env.program_state() + if isinstance(exit_program_state, ProgramChoiceSelected): + self.execution.exec_status = ExecutionStatus.SUCCEEDED + self.execution.output = self.execution.exec_worker.env.states.get_input() + self.execution.next_state = exit_program_state.next_state_name + else: + self._reflect_execution_status() + + def __init__( + self, + name: str, + role_arn: Arn, + exec_arn: Arn, + account_id: str, + region_name: str, + state_machine: StateMachineInstance, + start_date: Timestamp, + activity_store: dict[Arn, Activity], + input_data: Optional[dict] = None, + ): + super().__init__( + name=name, + sm_type=StateMachineType.STANDARD, + role_arn=role_arn, + exec_arn=exec_arn, + account_id=account_id, + region_name=region_name, + state_machine=state_machine, + start_date=start_date, + activity_store=activity_store, + input_data=input_data, + cloud_watch_logging_session=None, + trace_header=None, + ) + self._execution_terminated_event = threading.Event() + self.next_state = None + + def _get_start_execution_worker_comm(self) -> BaseExecutionWorkerCommunication: + return self.TestCaseExecutionWorkerCommunication(self) + + def _get_start_execution_worker(self) -> TestStateExecutionWorker: + return TestStateExecutionWorker( + evaluation_details=EvaluationDetails( + aws_execution_details=self._get_start_aws_execution_details(), + execution_details=self.get_start_execution_details(), + state_machine_details=self.get_start_state_machine_details(), + ), + exec_comm=self._get_start_execution_worker_comm(), + cloud_watch_logging_session=self._cloud_watch_logging_session, + activity_store=self._activity_store, + ) + + def publish_execution_status_change_event(self): + # Do not publish execution status change events during test state execution. + pass + + def to_test_state_output(self, inspection_level: InspectionLevel) -> TestStateOutput: + exit_program_state: ProgramState = self.exec_worker.env.program_state() + if isinstance(exit_program_state, ProgramEnded): + output_str = to_json_str(self.output) + test_state_output = TestStateOutput( + status=TestExecutionStatus.SUCCEEDED, output=output_str + ) + elif isinstance(exit_program_state, ProgramError): + test_state_output = TestStateOutput( + status=TestExecutionStatus.FAILED, + error=exit_program_state.error["error"], + cause=exit_program_state.error["cause"], + ) + elif isinstance(exit_program_state, ProgramChoiceSelected): + output_str = to_json_str(self.output) + test_state_output = TestStateOutput( + status=TestExecutionStatus.SUCCEEDED, nextState=self.next_state, output=output_str + ) + else: + # TODO: handle other statuses + LOG.warning( + "Unsupported StateMachine exit type for TestState '%s'", + type(exit_program_state), + ) + output_str = to_json_str(self.output) + test_state_output = TestStateOutput( + status=TestExecutionStatus.FAILED, output=output_str + ) + + match inspection_level: + case InspectionLevel.TRACE: + test_state_output["inspectionData"] = self.exec_worker.env.inspection_data + case InspectionLevel.DEBUG: + test_state_output["inspectionData"] = self.exec_worker.env.inspection_data + + return test_state_output diff --git a/localstack-core/localstack/services/stepfunctions/backend/test_state/execution_worker.py b/localstack-core/localstack/services/stepfunctions/backend/test_state/execution_worker.py new file mode 100644 index 0000000000000..b70c7d41bd6a3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/backend/test_state/execution_worker.py @@ -0,0 +1,48 @@ +from typing import Optional + +from localstack.services.stepfunctions.asl.component.eval_component import EvalComponent +from localstack.services.stepfunctions.asl.eval.environment import Environment +from localstack.services.stepfunctions.asl.eval.event.event_manager import ( + EventHistoryContext, +) +from localstack.services.stepfunctions.asl.eval.states import ( + ContextObjectData, + ExecutionData, + StateMachineData, +) +from localstack.services.stepfunctions.asl.eval.test_state.environment import TestStateEnvironment +from localstack.services.stepfunctions.asl.parse.test_state.asl_parser import ( + TestStateAmazonStateLanguageParser, +) +from localstack.services.stepfunctions.backend.execution_worker import SyncExecutionWorker + + +class TestStateExecutionWorker(SyncExecutionWorker): + env: Optional[TestStateEnvironment] + + def _get_evaluation_entrypoint(self) -> EvalComponent: + return TestStateAmazonStateLanguageParser.parse( + self._evaluation_details.state_machine_details.definition + )[0] + + def _get_evaluation_environment(self) -> Environment: + return TestStateEnvironment( + aws_execution_details=self._evaluation_details.aws_execution_details, + execution_type=self._evaluation_details.state_machine_details.typ, + context=ContextObjectData( + Execution=ExecutionData( + Id=self._evaluation_details.execution_details.arn, + Input=self._evaluation_details.execution_details.inpt, + Name=self._evaluation_details.execution_details.name, + RoleArn=self._evaluation_details.execution_details.role_arn, + StartTime=self._evaluation_details.execution_details.start_time, + ), + StateMachine=StateMachineData( + Id=self._evaluation_details.state_machine_details.arn, + Name=self._evaluation_details.state_machine_details.name, + ), + ), + event_history_context=EventHistoryContext.of_program_start(), + cloud_watch_logging_session=self._cloud_watch_logging_session, + activity_store=self._activity_store, + ) diff --git a/localstack-core/localstack/services/stepfunctions/mocking/__init__.py b/localstack-core/localstack/services/stepfunctions/mocking/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/mocking/mock_config.py b/localstack-core/localstack/services/stepfunctions/mocking/mock_config.py new file mode 100644 index 0000000000000..25f71acee35d5 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/mocking/mock_config.py @@ -0,0 +1,214 @@ +import abc +from typing import Any, Final, Optional + +from localstack.services.stepfunctions.mocking.mock_config_file import ( + RawMockConfig, + RawResponseModel, + RawTestCase, + _load_sfn_raw_mock_config, +) + + +class MockedResponse(abc.ABC): + range_start: Final[int] + range_end: Final[int] + + def __init__(self, range_start: int, range_end: int): + super().__init__() + if range_start < 0 or range_end < 0: + raise ValueError( + f"Invalid range: both '{range_start}' and '{range_end}' must be positive integers." + ) + if range_start != range_end and range_end < range_start + 1: + raise ValueError( + f"Invalid range: values must be equal or '{range_start}' " + f"must be at least one greater than '{range_end}'." + ) + self.range_start = range_start + self.range_end = range_end + + +class MockedResponseReturn(MockedResponse): + payload: Final[Any] + + def __init__(self, range_start: int, range_end: int, payload: Any): + super().__init__(range_start=range_start, range_end=range_end) + self.payload = payload + + +class MockedResponseThrow(MockedResponse): + error: Final[str] + cause: Final[str] + + def __init__(self, range_start: int, range_end: int, error: str, cause: str): + super().__init__(range_start=range_start, range_end=range_end) + self.error = error + self.cause = cause + + +class StateMockedResponses: + state_name: Final[str] + mocked_response_name: Final[str] + mocked_responses: Final[list[MockedResponse]] + + def __init__( + self, state_name: str, mocked_response_name: str, mocked_responses: list[MockedResponse] + ): + self.state_name = state_name + self.mocked_response_name = mocked_response_name + self.mocked_responses = list() + last_range_end: int = -1 + mocked_responses_sorted = sorted(mocked_responses, key=lambda mr: mr.range_start) + for mocked_response in mocked_responses_sorted: + if not mocked_response.range_start - last_range_end == 1: + raise RuntimeError( + f"Inconsistent event numbering detected for state '{state_name}': " + f"the previous mocked response ended at event '{last_range_end}' " + f"while the next response '{mocked_response_name}' " + f"starts at event '{mocked_response.range_start}'. " + "Mock responses must be consecutively numbered. " + f"Expected the next response to begin at event {last_range_end + 1}." + ) + repeats = mocked_response.range_end - mocked_response.range_start + 1 + self.mocked_responses.extend([mocked_response] * repeats) + last_range_end = mocked_response.range_end + + +class MockTestCase: + state_machine_name: Final[str] + test_case_name: Final[str] + state_mocked_responses: Final[dict[str, StateMockedResponses]] + + def __init__( + self, + state_machine_name: str, + test_case_name: str, + state_mocked_responses_list: list[StateMockedResponses], + ): + self.state_machine_name = state_machine_name + self.test_case_name = test_case_name + self.state_mocked_responses = dict() + for state_mocked_response in state_mocked_responses_list: + state_name = state_mocked_response.state_name + if state_name in self.state_mocked_responses: + raise RuntimeError( + f"Duplicate definition of state '{state_name}' for test case '{test_case_name}'" + ) + self.state_mocked_responses[state_name] = state_mocked_response + + +def _parse_mocked_response_range(string_definition: str) -> tuple[int, int]: + definition_parts = string_definition.strip().split("-") + if len(definition_parts) == 1: + range_part = definition_parts[0] + try: + range_value = int(range_part) + return range_value, range_value + except Exception: + raise RuntimeError( + f"Unknown mocked response retry range value '{range_part}', not a valid integer" + ) + elif len(definition_parts) == 2: + range_part_start = definition_parts[0] + range_part_end = definition_parts[1] + try: + return int(range_part_start), int(range_part_end) + except Exception: + raise RuntimeError( + f"Unknown mocked response retry range value '{range_part_start}:{range_part_end}', " + "not valid integer values" + ) + else: + raise RuntimeError( + f"Unknown mocked response retry range definition '{string_definition}', " + "range definition should consist of one integer (e.g. '0'), or a integer range (e.g. '1-2')'." + ) + + +def _mocked_response_from_raw( + raw_response_model_range: str, raw_response_model: RawResponseModel +) -> MockedResponse: + range_start, range_end = _parse_mocked_response_range(raw_response_model_range) + if raw_response_model.Return: + payload = raw_response_model.Return.model_dump() + return MockedResponseReturn(range_start=range_start, range_end=range_end, payload=payload) + throw_definition = raw_response_model.Throw + return MockedResponseThrow( + range_start=range_start, + range_end=range_end, + error=throw_definition.Error, + cause=throw_definition.Cause, + ) + + +def _mocked_responses_from_raw( + mocked_response_name: str, raw_mock_config: RawMockConfig +) -> list[MockedResponse]: + raw_response_models: Optional[dict[str, RawResponseModel]] = ( + raw_mock_config.MockedResponses.get(mocked_response_name) + ) + if not raw_response_models: + raise RuntimeError( + f"No definitions for mocked response '{mocked_response_name}' in the mock configuration file." + ) + mocked_responses: list[MockedResponse] = list() + for raw_response_model_range, raw_response_model in raw_response_models.items(): + mocked_response: MockedResponse = _mocked_response_from_raw( + raw_response_model_range=raw_response_model_range, raw_response_model=raw_response_model + ) + mocked_responses.append(mocked_response) + return mocked_responses + + +def _state_mocked_responses_from_raw( + state_name: str, mocked_response_name: str, raw_mock_config: RawMockConfig +) -> StateMockedResponses: + mocked_responses = _mocked_responses_from_raw( + mocked_response_name=mocked_response_name, raw_mock_config=raw_mock_config + ) + return StateMockedResponses( + state_name=state_name, + mocked_response_name=mocked_response_name, + mocked_responses=mocked_responses, + ) + + +def _mock_test_case_from_raw( + state_machine_name: str, test_case_name: str, raw_mock_config: RawMockConfig +) -> MockTestCase: + state_machine = raw_mock_config.StateMachines.get(state_machine_name) + if not state_machine: + raise RuntimeError( + f"No definitions for state machine '{state_machine_name}' in the mock configuration file." + ) + test_case: RawTestCase = state_machine.TestCases.get(test_case_name) + if not test_case: + raise RuntimeError( + f"No definitions for test case '{test_case_name}' and " + f"state machine '{state_machine_name}' in the mock configuration file." + ) + state_mocked_responses_list: list[StateMockedResponses] = list() + for state_name, mocked_response_name in test_case.root.items(): + state_mocked_responses = _state_mocked_responses_from_raw( + state_name=state_name, + mocked_response_name=mocked_response_name, + raw_mock_config=raw_mock_config, + ) + state_mocked_responses_list.append(state_mocked_responses) + return MockTestCase( + state_machine_name=state_machine_name, + test_case_name=test_case_name, + state_mocked_responses_list=state_mocked_responses_list, + ) + + +def load_mock_test_case_for(state_machine_name: str, test_case_name: str) -> Optional[MockTestCase]: + raw_mock_config: Optional[RawMockConfig] = _load_sfn_raw_mock_config() + if raw_mock_config is None: + return None + mock_test_case: MockTestCase = _mock_test_case_from_raw( + state_machine_name=state_machine_name, + test_case_name=test_case_name, + raw_mock_config=raw_mock_config, + ) + return mock_test_case diff --git a/localstack-core/localstack/services/stepfunctions/mocking/mock_config_file.py b/localstack-core/localstack/services/stepfunctions/mocking/mock_config_file.py new file mode 100644 index 0000000000000..145ffd20750a2 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/mocking/mock_config_file.py @@ -0,0 +1,187 @@ +import logging +import os +from functools import lru_cache +from json import JSONDecodeError +from typing import Any, Dict, Final, Optional + +from pydantic import BaseModel, RootModel, ValidationError, model_validator + +from localstack import config + +LOG = logging.getLogger(__name__) + +_RETURN_KEY: Final[str] = "Return" +_THROW_KEY: Final[str] = "Throw" + + +class RawReturnResponse(RootModel[Any]): + """ + Represents a return response. + Accepts any fields. + """ + + model_config = {"frozen": True} + + +class RawThrowResponse(BaseModel): + """ + Represents an error response. + Both 'Error' and 'Cause' are required. + """ + + model_config = {"frozen": True} + + Error: str + Cause: str + + +class RawResponseModel(BaseModel): + """ + A response step must include exactly one of: + - 'Return': a ReturnResponse object. + - 'Throw': a ThrowResponse object. + """ + + model_config = {"frozen": True} + + Return: Optional[RawReturnResponse] = None + Throw: Optional[RawThrowResponse] = None + + @model_validator(mode="before") + def validate_response(cls, data: dict) -> dict: + if _RETURN_KEY in data and _THROW_KEY in data: + raise ValueError(f"Response cannot contain both '{_RETURN_KEY}' and '{_THROW_KEY}'") + if _RETURN_KEY not in data and _THROW_KEY not in data: + raise ValueError(f"Response must contain one of '{_RETURN_KEY}' or '{_THROW_KEY}'") + return data + + +class RawTestCase(RootModel[Dict[str, str]]): + """ + Represents an individual test case. + The keys are state names (e.g., 'LambdaState', 'SQSState') + and the values are the names of the mocked response configurations. + """ + + model_config = {"frozen": True} + + +class RawStateMachine(BaseModel): + """ + Represents a state machine configuration containing multiple test cases. + """ + + model_config = {"frozen": True} + + TestCases: Dict[str, RawTestCase] + + +class RawMockConfig(BaseModel): + """ + The root configuration that contains: + - StateMachines: mapping state machine names to their configuration. + - MockedResponses: mapping response configuration names to response steps. + Each response step is keyed (e.g. "0", "1-2") and maps to a ResponseModel. + """ + + model_config = {"frozen": True} + + StateMachines: Dict[str, RawStateMachine] + MockedResponses: Dict[str, Dict[str, RawResponseModel]] + + +@lru_cache(maxsize=1) +def _read_sfn_raw_mock_config(file_path: str, modified_epoch: int) -> Optional[RawMockConfig]: # noqa + """ + Load and cache the Step Functions mock configuration from a JSON file. + + This function is memoized using `functools.lru_cache` to avoid re-reading the file + from disk unless it has changed. The `modified_epoch` parameter is used solely to + trigger cache invalidation when the file is updated. If either the file path or the + modified timestamp changes, the cached result is discarded and the file is reloaded. + + Parameters: + file_path (str): + The absolute path to the JSON configuration file. + + modified_epoch (int): + The last modified time of the file, in epoch seconds. This value is used + as part of the cache key to ensure the cache is refreshed when the file is updated. + + Returns: + Optional[dict]: + The parsed configuration as a dictionary if the file is successfully loaded, + or `None` if an error occurs during reading or parsing. + + Notes: + - The `modified_epoch` argument is not used inside the function logic, but is + necessary to ensure cache correctness via `lru_cache`. + - Logging is used to capture warnings if file access or parsing fails. + """ + try: + with open(file_path, "r") as df: + mock_config_str = df.read() + mock_config: RawMockConfig = RawMockConfig.model_validate_json(mock_config_str) + return mock_config + except (OSError, IOError) as file_error: + LOG.error("Failed to open mock configuration file '%s'. Error: %s", file_path, file_error) + return None + except ValidationError as validation_error: + errors = validation_error.errors() + if not errors: + # No detailed errors provided by Pydantic + LOG.error( + "Validation failed for mock configuration file at '%s'. " + "The file must contain a valid mock configuration.", + file_path, + ) + else: + for err in errors: + location = ".".join(str(loc) for loc in err["loc"]) + message = err["msg"] + error_type = err["type"] + LOG.error( + "Mock configuration file error at '%s': %s (%s)", + location, + message, + error_type, + ) + # TODO: add tests to ensure the hot-reloading of the mock configuration + # file works as expected, and inform the user with the info below: + # LOG.info( + # "Changes to the mock configuration file will be applied at the " + # "next mock execution without requiring a LocalStack restart." + # ) + return None + except JSONDecodeError as json_error: + LOG.error( + "Malformed JSON in mock configuration file at '%s'. Error: %s", + file_path, + json_error, + ) + # TODO: add tests to ensure the hot-reloading of the mock configuration + # file works as expected, and inform the user with the info below: + # LOG.info( + # "Changes to the mock configuration file will be applied at the " + # "next mock execution without requiring a LocalStack restart." + # ) + return None + + +def _load_sfn_raw_mock_config() -> Optional[RawMockConfig]: + configuration_file_path = config.SFN_MOCK_CONFIG + if not configuration_file_path: + return None + + try: + modified_time = int(os.path.getmtime(configuration_file_path)) + except Exception as ex: + LOG.warning( + "Unable to access the step functions mock configuration file at '%s' due to %s", + configuration_file_path, + ex, + ) + return None + + mock_config = _read_sfn_raw_mock_config(configuration_file_path, modified_time) + return mock_config diff --git a/localstack-core/localstack/services/stepfunctions/packages.py b/localstack-core/localstack/services/stepfunctions/packages.py new file mode 100644 index 0000000000000..b96f7a8d775f0 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/packages.py @@ -0,0 +1,39 @@ +from localstack.packages import Package, PackageInstaller +from localstack.packages.core import MavenPackageInstaller +from localstack.packages.java import JavaInstallerMixin + +JSONATA_DEFAULT_VERSION = "0.9.7" +JACKSON_DEFAULT_VERSION = "2.16.2" + +JSONATA_JACKSON_VERSION_STORE = {JSONATA_DEFAULT_VERSION: JACKSON_DEFAULT_VERSION} + + +class JSONataPackage(Package): + def __init__(self): + super().__init__("JSONataLibs", JSONATA_DEFAULT_VERSION) + + # Match the dynamodb-local JRE version to reduce the LocalStack image size by sharing the same JRE version + self.java_version = "21" + + def get_versions(self) -> list[str]: + return list(JSONATA_JACKSON_VERSION_STORE.keys()) + + def _get_installer(self, version: str) -> PackageInstaller: + return JSONataPackageInstaller(version) + + +class JSONataPackageInstaller(JavaInstallerMixin, MavenPackageInstaller): + def __init__(self, version: str): + jackson_version = JSONATA_JACKSON_VERSION_STORE[version] + super().__init__( + f"pkg:maven/com.dashjoin/jsonata@{version}", + # jackson-databind is imported in jsonata.py as "from com.fasterxml.jackson.databind import ObjectMapper" + # jackson-annotations and jackson-core are dependencies of jackson-databind: + # https://central.sonatype.com/artifact/com.fasterxml.jackson.core/jackson-databind/dependencies + f"pkg:maven/com.fasterxml.jackson.core/jackson-core@{jackson_version}", + f"pkg:maven/com.fasterxml.jackson.core/jackson-annotations@{jackson_version}", + f"pkg:maven/com.fasterxml.jackson.core/jackson-databind@{jackson_version}", + ) + + +jpype_jsonata_package = JSONataPackage() diff --git a/localstack-core/localstack/services/stepfunctions/plugins.py b/localstack-core/localstack/services/stepfunctions/plugins.py new file mode 100644 index 0000000000000..b407ee2875396 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/plugins.py @@ -0,0 +1,9 @@ +from localstack.packages import Package, package + + +@package(name="jpype-jsonata") +def jpype_jsonata_package() -> Package: + """The Java-based jsonata library uses JPype and depends on a JVM installation.""" + from localstack.services.stepfunctions.packages import jpype_jsonata_package + + return jpype_jsonata_package diff --git a/localstack-core/localstack/services/stepfunctions/provider.py b/localstack-core/localstack/services/stepfunctions/provider.py new file mode 100644 index 0000000000000..2202014eb0b90 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/provider.py @@ -0,0 +1,1675 @@ +import copy +import datetime +import json +import logging +import re +import time +from typing import Final, Optional + +from localstack.aws.api import CommonServiceException, RequestContext +from localstack.aws.api.stepfunctions import ( + ActivityDoesNotExist, + AliasDescription, + Arn, + CharacterRestrictedName, + ConflictException, + CreateActivityOutput, + CreateStateMachineAliasOutput, + CreateStateMachineInput, + CreateStateMachineOutput, + Definition, + DeleteActivityOutput, + DeleteStateMachineAliasOutput, + DeleteStateMachineOutput, + DeleteStateMachineVersionOutput, + DescribeActivityOutput, + DescribeExecutionOutput, + DescribeMapRunOutput, + DescribeStateMachineAliasOutput, + DescribeStateMachineForExecutionOutput, + DescribeStateMachineOutput, + EncryptionConfiguration, + ExecutionDoesNotExist, + ExecutionList, + ExecutionRedriveFilter, + ExecutionStatus, + GetActivityTaskOutput, + GetExecutionHistoryOutput, + IncludedData, + IncludeExecutionDataGetExecutionHistory, + InspectionLevel, + InvalidArn, + InvalidDefinition, + InvalidExecutionInput, + InvalidLoggingConfiguration, + InvalidName, + InvalidToken, + ListActivitiesOutput, + ListExecutionsOutput, + ListExecutionsPageToken, + ListMapRunsOutput, + ListStateMachineAliasesOutput, + ListStateMachinesOutput, + ListStateMachineVersionsOutput, + ListTagsForResourceOutput, + LoggingConfiguration, + LogLevel, + LongArn, + MaxConcurrency, + MissingRequiredParameter, + Name, + PageSize, + PageToken, + Publish, + PublishStateMachineVersionOutput, + ResourceNotFound, + RevealSecrets, + ReverseOrder, + RevisionId, + RoutingConfigurationList, + SendTaskFailureOutput, + SendTaskHeartbeatOutput, + SendTaskSuccessOutput, + SensitiveCause, + SensitiveData, + SensitiveError, + StartExecutionOutput, + StartSyncExecutionOutput, + StateMachineAliasList, + StateMachineAlreadyExists, + StateMachineDoesNotExist, + StateMachineList, + StateMachineType, + StateMachineTypeNotSupported, + StepfunctionsApi, + StopExecutionOutput, + TagKeyList, + TagList, + TagResourceOutput, + TaskDoesNotExist, + TaskTimedOut, + TaskToken, + TestStateOutput, + ToleratedFailureCount, + ToleratedFailurePercentage, + TraceHeader, + TracingConfiguration, + UntagResourceOutput, + UpdateMapRunOutput, + UpdateStateMachineAliasOutput, + UpdateStateMachineOutput, + ValidateStateMachineDefinitionDiagnostic, + ValidateStateMachineDefinitionDiagnosticList, + ValidateStateMachineDefinitionInput, + ValidateStateMachineDefinitionOutput, + ValidateStateMachineDefinitionResultCode, + ValidateStateMachineDefinitionSeverity, + ValidationException, + VersionDescription, +) +from localstack.services.plugins import ServiceLifecycleHook +from localstack.services.stepfunctions.asl.component.state.state_execution.state_map.iteration.itemprocessor.map_run_record import ( + MapRunRecord, +) +from localstack.services.stepfunctions.asl.eval.callback.callback import ( + ActivityCallbackEndpoint, + CallbackConsumerTimeout, + CallbackNotifyConsumerError, + CallbackOutcomeFailure, + CallbackOutcomeSuccess, +) +from localstack.services.stepfunctions.asl.eval.event.logging import ( + CloudWatchLoggingConfiguration, + CloudWatchLoggingSession, +) +from localstack.services.stepfunctions.asl.parse.asl_parser import ( + ASLParserException, +) +from localstack.services.stepfunctions.asl.static_analyser.express_static_analyser import ( + ExpressStaticAnalyser, +) +from localstack.services.stepfunctions.asl.static_analyser.static_analyser import ( + StaticAnalyser, +) +from localstack.services.stepfunctions.asl.static_analyser.test_state.test_state_analyser import ( + TestStateStaticAnalyser, +) +from localstack.services.stepfunctions.asl.static_analyser.usage_metrics_static_analyser import ( + UsageMetricsStaticAnalyser, +) +from localstack.services.stepfunctions.backend.activity import Activity, ActivityTask +from localstack.services.stepfunctions.backend.alias import Alias +from localstack.services.stepfunctions.backend.execution import Execution, SyncExecution +from localstack.services.stepfunctions.backend.state_machine import ( + StateMachineInstance, + StateMachineRevision, + StateMachineVersion, + TestStateMachine, +) +from localstack.services.stepfunctions.backend.store import SFNStore, sfn_stores +from localstack.services.stepfunctions.backend.test_state.execution import ( + TestStateExecution, +) +from localstack.services.stepfunctions.mocking.mock_config import ( + MockTestCase, + load_mock_test_case_for, +) +from localstack.services.stepfunctions.stepfunctions_utils import ( + assert_pagination_parameters_valid, + get_next_page_token_from_arn, + normalise_max_results, +) +from localstack.state import StateVisitor +from localstack.utils.aws.arns import ( + ARN_PARTITION_REGEX, + stepfunctions_activity_arn, + stepfunctions_express_execution_arn, + stepfunctions_standard_execution_arn, + stepfunctions_state_machine_arn, +) +from localstack.utils.collections import PaginatedList +from localstack.utils.strings import long_uid, short_uid + +LOG = logging.getLogger(__name__) + + +class StepFunctionsProvider(StepfunctionsApi, ServiceLifecycleHook): + _TEST_STATE_MAX_TIMEOUT_SECONDS: Final[int] = 300 # 5 minutes. + + @staticmethod + def get_store(context: RequestContext) -> SFNStore: + return sfn_stores[context.account_id][context.region] + + def accept_state_visitor(self, visitor: StateVisitor): + visitor.visit(sfn_stores) + + _STATE_MACHINE_ARN_REGEX: Final[re.Pattern] = re.compile( + rf"{ARN_PARTITION_REGEX}:states:[a-z0-9-]+:[0-9]{{12}}:stateMachine:[a-zA-Z0-9-_.]+(:\d+)?(:[a-zA-Z0-9-_.]+)*(?:#[a-zA-Z0-9-_]+)?$" + ) + + _STATE_MACHINE_EXECUTION_ARN_REGEX: Final[re.Pattern] = re.compile( + rf"{ARN_PARTITION_REGEX}:states:[a-z0-9-]+:[0-9]{{12}}:(stateMachine|execution|express):[a-zA-Z0-9-_.]+(:\d+)?(:[a-zA-Z0-9-_.]+)*$" + ) + + _ACTIVITY_ARN_REGEX: Final[re.Pattern] = re.compile( + rf"{ARN_PARTITION_REGEX}:states:[a-z0-9-]+:[0-9]{{12}}:activity:[a-zA-Z0-9-_\.]{{1,80}}$" + ) + + _ALIAS_ARN_REGEX: Final[re.Pattern] = re.compile( + rf"{ARN_PARTITION_REGEX}:states:[a-z0-9-]+:[0-9]{{12}}:stateMachine:[A-Za-z0-9_.-]+:[A-Za-z_.-]+[A-Za-z0-9_.-]{{0,80}}$" + ) + + _ALIAS_NAME_REGEX: Final[re.Pattern] = re.compile(r"^(?=.*[a-zA-Z_\-\.])[a-zA-Z0-9_\-\.]+$") + + @staticmethod + def _validate_state_machine_arn(state_machine_arn: str) -> None: + # TODO: InvalidArn exception message do not communicate which part of the ARN is incorrect. + if not StepFunctionsProvider._STATE_MACHINE_ARN_REGEX.match(state_machine_arn): + raise InvalidArn(f"Invalid arn: '{state_machine_arn}'") + + @staticmethod + def _raise_state_machine_does_not_exist(state_machine_arn: str) -> None: + raise StateMachineDoesNotExist(f"State Machine Does Not Exist: '{state_machine_arn}'") + + @staticmethod + def _validate_state_machine_execution_arn(execution_arn: str) -> None: + # TODO: InvalidArn exception message do not communicate which part of the ARN is incorrect. + if not StepFunctionsProvider._STATE_MACHINE_EXECUTION_ARN_REGEX.match(execution_arn): + raise InvalidArn(f"Invalid arn: '{execution_arn}'") + + @staticmethod + def _validate_activity_arn(activity_arn: str) -> None: + # TODO: InvalidArn exception message do not communicate which part of the ARN is incorrect. + if not StepFunctionsProvider._ACTIVITY_ARN_REGEX.match(activity_arn): + raise InvalidArn(f"Invalid arn: '{activity_arn}'") + + @staticmethod + def _validate_state_machine_alias_arn(state_machine_alias_arn: Arn) -> None: + if not StepFunctionsProvider._ALIAS_ARN_REGEX.match(state_machine_alias_arn): + raise InvalidArn(f"Invalid arn: '{state_machine_alias_arn}'") + + def _raise_state_machine_type_not_supported(self): + raise StateMachineTypeNotSupported( + "This operation is not supported by this type of state machine" + ) + + @staticmethod + def _raise_resource_type_not_in_context(resource_type: str) -> None: + lower_resource_type = resource_type.lower() + raise InvalidArn( + f"Invalid Arn: 'Resource type not valid in this context: {lower_resource_type}'" + ) + + @staticmethod + def _validate_activity_name(name: str) -> None: + # The activity name is validated according to the AWS StepFunctions documentation, the name should not contain: + # - white space + # - brackets < > { } [ ] + # - wildcard characters ? * + # - special characters " # % \ ^ | ~ ` $ & , ; : / + # - control characters (U+0000-001F, U+007F-009F) + # https://docs.aws.amazon.com/step-functions/latest/apireference/API_CreateActivity.html#API_CreateActivity_RequestSyntax + if not (1 <= len(name) <= 80): + raise InvalidName(f"Invalid Name: '{name}'") + invalid_chars = set(' <>{}[]?*"#%\\^|~`$&,;:/') + control_chars = {chr(i) for i in range(32)} | {chr(i) for i in range(127, 160)} + invalid_chars |= control_chars + for char in name: + if char in invalid_chars: + raise InvalidName(f"Invalid Name: '{name}'") + + @staticmethod + def _validate_state_machine_alias_name(name: CharacterRestrictedName) -> None: + len_name = len(name) + if len_name > 80: + raise ValidationException( + f"1 validation error detected: Value '{name}' at 'name' failed to satisfy constraint: " + f"Member must have length less than or equal to 80" + ) + if not StepFunctionsProvider._ALIAS_NAME_REGEX.match(name): + raise ValidationException( + # TODO: explore more error cases in which more than one validation error may occur which results + # in the counter below being greater than 1. + f"1 validation error detected: Value '{name}' at 'name' failed to satisfy constraint: " + f"Member must satisfy regular expression pattern: ^(?=.*[a-zA-Z_\\-\\.])[a-zA-Z0-9_\\-\\.]+$" + ) + + def _get_execution(self, context: RequestContext, execution_arn: Arn) -> Execution: + execution: Optional[Execution] = self.get_store(context).executions.get(execution_arn) + if not execution: + raise ExecutionDoesNotExist(f"Execution Does Not Exist: '{execution_arn}'") + return execution + + def _get_executions( + self, + context: RequestContext, + execution_status: Optional[ExecutionStatus] = None, + ): + store = self.get_store(context) + execution: list[Execution] = list(store.executions.values()) + if execution_status: + execution = list( + filter( + lambda e: e.exec_status == execution_status, + store.executions.values(), + ) + ) + return execution + + def _get_activity(self, context: RequestContext, activity_arn: Arn) -> Activity: + maybe_activity: Optional[Activity] = self.get_store(context).activities.get( + activity_arn, None + ) + if maybe_activity is None: + raise ActivityDoesNotExist(f"Activity Does Not Exist: '{activity_arn}'") + return maybe_activity + + def _idempotent_revision( + self, + context: RequestContext, + name: str, + definition: Definition, + state_machine_type: StateMachineType, + logging_configuration: LoggingConfiguration, + tracing_configuration: TracingConfiguration, + ) -> Optional[StateMachineRevision]: + # CreateStateMachine's idempotency check is based on the state machine name, definition, type, + # LoggingConfiguration and TracingConfiguration. + # If a following request has a different roleArn or tags, Step Functions will ignore these differences and + # treat it as an idempotent request of the previous. In this case, roleArn and tags will not be updated, even + # if they are different. + state_machines: list[StateMachineInstance] = list( + self.get_store(context).state_machines.values() + ) + revisions = filter(lambda sm: isinstance(sm, StateMachineRevision), state_machines) + for state_machine in revisions: + check = all( + [ + state_machine.name == name, + state_machine.definition == definition, + state_machine.sm_type == state_machine_type, + state_machine.logging_config == logging_configuration, + state_machine.tracing_config == tracing_configuration, + ] + ) + if check: + return state_machine + return None + + def _idempotent_start_execution( + self, + execution: Optional[Execution], + state_machine: StateMachineInstance, + name: Name, + input_data: SensitiveData, + ) -> Optional[Execution]: + # StartExecution is idempotent for STANDARD workflows. For a STANDARD workflow, + # if you call StartExecution with the same name and input as a running execution, + # the call succeeds and return the same response as the original request. + # If the execution is closed or if the input is different, + # it returns a 400 ExecutionAlreadyExists error. You can reuse names after 90 days. + + if not execution: + return None + + match (name, input_data, execution.exec_status, state_machine.sm_type): + case ( + execution.name, + execution.input_data, + ExecutionStatus.RUNNING, + StateMachineType.STANDARD, + ): + return execution + + raise CommonServiceException( + code="ExecutionAlreadyExists", + message=f"Execution Already Exists: '{execution.exec_arn}'", + ) + + def _revision_by_name( + self, context: RequestContext, name: str + ) -> Optional[StateMachineInstance]: + state_machines: list[StateMachineInstance] = list( + self.get_store(context).state_machines.values() + ) + for state_machine in state_machines: + if isinstance(state_machine, StateMachineRevision) and state_machine.name == name: + return state_machine + return None + + @staticmethod + def _validate_definition(definition: str, static_analysers: list[StaticAnalyser]) -> None: + try: + for static_analyser in static_analysers: + static_analyser.analyse(definition) + except ASLParserException as asl_parser_exception: + invalid_definition = InvalidDefinition() + invalid_definition.message = repr(asl_parser_exception) + raise invalid_definition + except Exception as exception: + exception_name = exception.__class__.__name__ + exception_args = list(exception.args) + invalid_definition = InvalidDefinition() + invalid_definition.message = ( + f"Error={exception_name} Args={exception_args} in definition '{definition}'." + ) + raise invalid_definition + + @staticmethod + def _sanitise_logging_configuration( + logging_configuration: LoggingConfiguration, + ) -> None: + level = logging_configuration.get("level") + destinations = logging_configuration.get("destinations") + + if destinations is not None and len(destinations) > 1: + raise InvalidLoggingConfiguration( + "Invalid Logging Configuration: Must specify exactly one Log Destination." + ) + + # A LogLevel that is not OFF, should have a destination. + if level is not None and level != LogLevel.OFF and not destinations: + raise InvalidLoggingConfiguration( + "Invalid Logging Configuration: Must specify exactly one Log Destination." + ) + + # Default for level is OFF. + level = level or LogLevel.OFF + + # Default for includeExecutionData is False. + include_flag = logging_configuration.get("includeExecutionData", False) + + # Update configuration object. + logging_configuration["level"] = level + logging_configuration["includeExecutionData"] = include_flag + + def create_state_machine( + self, context: RequestContext, request: CreateStateMachineInput, **kwargs + ) -> CreateStateMachineOutput: + if not request.get("publish", False) and request.get("versionDescription"): + raise ValidationException("Version description can only be set when publish is true") + + # Extract parameters and set defaults. + state_machine_name = request["name"] + state_machine_role_arn = request["roleArn"] + state_machine_definition = request["definition"] + state_machine_type = request.get("type") or StateMachineType.STANDARD + state_machine_tracing_configuration = request.get("tracingConfiguration") + state_machine_tags = request.get("tags") + state_machine_logging_configuration = request.get( + "loggingConfiguration", LoggingConfiguration() + ) + self._sanitise_logging_configuration( + logging_configuration=state_machine_logging_configuration + ) + + # CreateStateMachine is an idempotent API. Subsequent requests won't create a duplicate resource if it was + # already created. + idem_state_machine: Optional[StateMachineRevision] = self._idempotent_revision( + context=context, + name=state_machine_name, + definition=state_machine_definition, + state_machine_type=state_machine_type, + logging_configuration=state_machine_logging_configuration, + tracing_configuration=state_machine_tracing_configuration, + ) + if idem_state_machine is not None: + return CreateStateMachineOutput( + stateMachineArn=idem_state_machine.arn, + creationDate=idem_state_machine.create_date, + ) + + # Assert this state machine name is unique. + state_machine_with_name: Optional[StateMachineRevision] = self._revision_by_name( + context=context, name=state_machine_name + ) + if state_machine_with_name is not None: + raise StateMachineAlreadyExists( + f"State Machine Already Exists: '{state_machine_with_name.arn}'" + ) + + # Compute the state machine's Arn. + state_machine_arn = stepfunctions_state_machine_arn( + name=state_machine_name, + account_id=context.account_id, + region_name=context.region, + ) + state_machines = self.get_store(context).state_machines + + # Reduce the logging configuration to a usable cloud watch representation, and validate the destinations + # if any were given. + cloud_watch_logging_configuration = ( + CloudWatchLoggingConfiguration.from_logging_configuration( + state_machine_arn=state_machine_arn, + logging_configuration=state_machine_logging_configuration, + ) + ) + if cloud_watch_logging_configuration is not None: + cloud_watch_logging_configuration.validate() + + # Run static analysers on the definition given. + if state_machine_type == StateMachineType.EXPRESS: + StepFunctionsProvider._validate_definition( + definition=state_machine_definition, + static_analysers=[ExpressStaticAnalyser()], + ) + else: + StepFunctionsProvider._validate_definition( + definition=state_machine_definition, static_analysers=[StaticAnalyser()] + ) + + # Create the state machine and add it to the store. + state_machine = StateMachineRevision( + name=state_machine_name, + arn=state_machine_arn, + role_arn=state_machine_role_arn, + definition=state_machine_definition, + sm_type=state_machine_type, + logging_config=state_machine_logging_configuration, + cloud_watch_logging_configuration=cloud_watch_logging_configuration, + tracing_config=state_machine_tracing_configuration, + tags=state_machine_tags, + ) + state_machines[state_machine_arn] = state_machine + + create_output = CreateStateMachineOutput( + stateMachineArn=state_machine.arn, creationDate=state_machine.create_date + ) + + # Create the first version if the 'publish' flag is used. + if request.get("publish", False): + version_description = request.get("versionDescription") + state_machine_version = state_machine.create_version(description=version_description) + if state_machine_version is not None: + state_machine_version_arn = state_machine_version.arn + state_machines[state_machine_version_arn] = state_machine_version + create_output["stateMachineVersionArn"] = state_machine_version_arn + + # Run static analyser on definition and collect usage metrics + UsageMetricsStaticAnalyser.process(state_machine_definition) + + return create_output + + def _validate_state_machine_alias_routing_configuration( + self, context: RequestContext, routing_configuration_list: RoutingConfigurationList + ) -> None: + # TODO: to match AWS's approach best validation exceptions could be + # built in a process decoupled from the provider. + + routing_configuration_list_len = len(routing_configuration_list) + if not (1 <= routing_configuration_list_len <= 2): + # Replicate the object string dump format: + # [RoutingConfigurationListItem(stateMachineVersionArn=arn_no_quotes, weight=int), ...] + routing_configuration_serialization_parts = [] + for routing_configuration in routing_configuration_list: + routing_configuration_serialization_parts.append( + "".join( + [ + "RoutingConfigurationListItem(stateMachineVersionArn=", + routing_configuration["stateMachineVersionArn"], + ", weight=", + str(routing_configuration["weight"]), + ")", + ] + ) + ) + routing_configuration_serialization_list = ( + f"[{', '.join(routing_configuration_serialization_parts)}]" + ) + raise ValidationException( + f"1 validation error detected: Value '{routing_configuration_serialization_list}' " + "at 'routingConfiguration' failed to " + "satisfy constraint: Member must have length less than or equal to 2" + ) + + routing_configuration_arn_list = [ + routing_configuration["stateMachineVersionArn"] + for routing_configuration in routing_configuration_list + ] + if len(set(routing_configuration_arn_list)) < routing_configuration_list_len: + arn_list_string = f"[{', '.join(routing_configuration_arn_list)}]" + raise ValidationException( + "Routing configuration must contain distinct state machine version ARNs. " + f"Received: {arn_list_string}" + ) + + routing_weights = [ + routing_configuration["weight"] for routing_configuration in routing_configuration_list + ] + for i, weight in enumerate(routing_weights): + # TODO: check for weight type. + if weight < 0: + raise ValidationException( + f"Invalid value for parameter routingConfiguration[{i + 1}].weight, value: {weight}, valid min value: 0" + ) + if weight > 100: + raise ValidationException( + f"1 validation error detected: Value '{weight}' at 'routingConfiguration.{i + 1}.member.weight' " + "failed to satisfy constraint: Member must have value less than or equal to 100" + ) + routing_weights_sum = sum(routing_weights) + if not routing_weights_sum == 100: + raise ValidationException( + f"Sum of routing configuration weights must equal 100. Received: {json.dumps(routing_weights)}" + ) + + store = self.get_store(context=context) + state_machines = store.state_machines + + first_routing_qualified_arn = routing_configuration_arn_list[0] + shared_state_machine_revision_arn = self._get_state_machine_arn_from_qualified_arn( + qualified_arn=first_routing_qualified_arn + ) + for routing_configuration_arn in routing_configuration_arn_list: + maybe_state_machine_version = state_machines.get(routing_configuration_arn) + if not isinstance(maybe_state_machine_version, StateMachineVersion): + arn_list_string = f"[{', '.join(routing_configuration_arn_list)}]" + raise ValidationException( + f"Routing configuration must contain state machine version ARNs. Received: {arn_list_string}" + ) + state_machine_revision_arn = self._get_state_machine_arn_from_qualified_arn( + qualified_arn=routing_configuration_arn + ) + if state_machine_revision_arn != shared_state_machine_revision_arn: + raise ValidationException("TODO") + + @staticmethod + def _get_state_machine_arn_from_qualified_arn(qualified_arn: Arn) -> Arn: + last_colon_index = qualified_arn.rfind(":") + base_arn = qualified_arn[:last_colon_index] + return base_arn + + def create_state_machine_alias( + self, + context: RequestContext, + name: CharacterRestrictedName, + routing_configuration: RoutingConfigurationList, + description: AliasDescription = None, + **kwargs, + ) -> CreateStateMachineAliasOutput: + # Validate the inputs. + self._validate_state_machine_alias_name(name=name) + self._validate_state_machine_alias_routing_configuration( + context=context, routing_configuration_list=routing_configuration + ) + + # Determine the state machine arn this alias maps to, + # do so unsafely as validation already took place before initialisation. + first_routing_qualified_arn = routing_configuration[0]["stateMachineVersionArn"] + state_machine_revision_arn = self._get_state_machine_arn_from_qualified_arn( + qualified_arn=first_routing_qualified_arn + ) + alias = Alias( + state_machine_arn=state_machine_revision_arn, + name=name, + description=description, + routing_configuration_list=routing_configuration, + ) + state_machine_alias_arn = alias.state_machine_alias_arn + + store = self.get_store(context=context) + + aliases = store.aliases + if maybe_idempotent_alias := aliases.get(state_machine_alias_arn): + if alias.is_idempotent(maybe_idempotent_alias): + return CreateStateMachineAliasOutput( + stateMachineAliasArn=state_machine_alias_arn, creationDate=alias.create_date + ) + else: + # CreateStateMachineAlias is an idempotent API. Idempotent requests won't create duplicate resources. + raise ConflictException( + "Failed to create alias because an alias with the same name and a " + "different routing configuration already exists." + ) + aliases[state_machine_alias_arn] = alias + + state_machine_revision = store.state_machines.get(state_machine_revision_arn) + if not isinstance(state_machine_revision, StateMachineRevision): + # The state machine was deleted but not the version referenced in this context. + raise RuntimeError(f"No state machine revision for arn '{state_machine_revision_arn}'") + state_machine_revision.aliases.add(alias) + + return CreateStateMachineAliasOutput( + stateMachineAliasArn=state_machine_alias_arn, creationDate=alias.create_date + ) + + def describe_state_machine( + self, + context: RequestContext, + state_machine_arn: Arn, + included_data: IncludedData = None, + **kwargs, + ) -> DescribeStateMachineOutput: + self._validate_state_machine_arn(state_machine_arn) + state_machine = self.get_store(context).state_machines.get(state_machine_arn) + if state_machine is None: + self._raise_state_machine_does_not_exist(state_machine_arn) + return state_machine.describe() + + def describe_state_machine_alias( + self, context: RequestContext, state_machine_alias_arn: Arn, **kwargs + ) -> DescribeStateMachineAliasOutput: + self._validate_state_machine_alias_arn(state_machine_alias_arn=state_machine_alias_arn) + alias: Optional[Alias] = self.get_store(context=context).aliases.get( + state_machine_alias_arn + ) + if alias is None: + # TODO: assemble the correct exception + raise ValidationException() + description = alias.to_description() + return description + + def describe_state_machine_for_execution( + self, + context: RequestContext, + execution_arn: Arn, + included_data: IncludedData = None, + **kwargs, + ) -> DescribeStateMachineForExecutionOutput: + self._validate_state_machine_execution_arn(execution_arn) + execution: Execution = self._get_execution(context=context, execution_arn=execution_arn) + return execution.to_describe_state_machine_for_execution_output() + + def send_task_heartbeat( + self, context: RequestContext, task_token: TaskToken, **kwargs + ) -> SendTaskHeartbeatOutput: + running_executions: list[Execution] = self._get_executions(context, ExecutionStatus.RUNNING) + for execution in running_executions: + try: + if execution.exec_worker.env.callback_pool_manager.heartbeat( + callback_id=task_token + ): + return SendTaskHeartbeatOutput() + except CallbackNotifyConsumerError as consumer_error: + if isinstance(consumer_error, CallbackConsumerTimeout): + raise TaskTimedOut() + else: + raise TaskDoesNotExist() + raise InvalidToken() + + def send_task_success( + self, + context: RequestContext, + task_token: TaskToken, + output: SensitiveData, + **kwargs, + ) -> SendTaskSuccessOutput: + outcome = CallbackOutcomeSuccess(callback_id=task_token, output=output) + running_executions: list[Execution] = self._get_executions(context, ExecutionStatus.RUNNING) + for execution in running_executions: + try: + if execution.exec_worker.env.callback_pool_manager.notify( + callback_id=task_token, outcome=outcome + ): + return SendTaskSuccessOutput() + except CallbackNotifyConsumerError as consumer_error: + if isinstance(consumer_error, CallbackConsumerTimeout): + raise TaskTimedOut() + else: + raise TaskDoesNotExist() + raise InvalidToken("Invalid token") + + def send_task_failure( + self, + context: RequestContext, + task_token: TaskToken, + error: SensitiveError = None, + cause: SensitiveCause = None, + **kwargs, + ) -> SendTaskFailureOutput: + outcome = CallbackOutcomeFailure(callback_id=task_token, error=error, cause=cause) + store = self.get_store(context) + for execution in store.executions.values(): + try: + if execution.exec_worker.env.callback_pool_manager.notify( + callback_id=task_token, outcome=outcome + ): + return SendTaskFailureOutput() + except CallbackNotifyConsumerError as consumer_error: + if isinstance(consumer_error, CallbackConsumerTimeout): + raise TaskTimedOut() + else: + raise TaskDoesNotExist() + raise InvalidToken("Invalid token") + + @staticmethod + def _get_state_machine_arn(state_machine_arn: str) -> str: + """Extract the state machine ARN by removing the test case suffix.""" + return state_machine_arn.split("#")[0] + + @staticmethod + def _get_mock_test_case( + state_machine_arn: str, state_machine_name: str + ) -> Optional[MockTestCase]: + """Extract and load a mock test case from a state machine ARN if present.""" + parts = state_machine_arn.split("#") + if len(parts) != 2: + return None + + mock_test_case_name = parts[1] + mock_test_case = load_mock_test_case_for( + state_machine_name=state_machine_name, test_case_name=mock_test_case_name + ) + if mock_test_case is None: + raise InvalidName( + f"Invalid mock test case name '{mock_test_case_name}' " + f"for state machine '{state_machine_name}'." + "Either the test case is not defined or the mock configuration file " + "could not be loaded. See logs for details." + ) + return mock_test_case + + def start_execution( + self, + context: RequestContext, + state_machine_arn: Arn, + name: Name = None, + input: SensitiveData = None, + trace_header: TraceHeader = None, + **kwargs, + ) -> StartExecutionOutput: + self._validate_state_machine_arn(state_machine_arn) + + base_arn = self._get_state_machine_arn(state_machine_arn) + store = self.get_store(context=context) + + alias: Optional[Alias] = store.aliases.get(base_arn) + alias_sample_state_machine_version_arn = alias.sample() if alias is not None else None + unsafe_state_machine: Optional[StateMachineInstance] = store.state_machines.get( + alias_sample_state_machine_version_arn or base_arn + ) + if not unsafe_state_machine: + self._raise_state_machine_does_not_exist(base_arn) + + # Update event change parameters about the state machine and should not affect those about this execution. + state_machine_clone = copy.deepcopy(unsafe_state_machine) + + if input is None: + input_data = dict() + else: + try: + input_data = json.loads(input) + except Exception as ex: + raise InvalidExecutionInput(str(ex)) # TODO: report parsing error like AWS. + + normalised_state_machine_arn = ( + state_machine_clone.source_arn + if isinstance(state_machine_clone, StateMachineVersion) + else state_machine_clone.arn + ) + exec_name = name or long_uid() # TODO: validate name format + if state_machine_clone.sm_type == StateMachineType.STANDARD: + exec_arn = stepfunctions_standard_execution_arn(normalised_state_machine_arn, exec_name) + else: + # Exhaustive check on STANDARD and EXPRESS type, validated on creation. + exec_arn = stepfunctions_express_execution_arn(normalised_state_machine_arn, exec_name) + + if execution := store.executions.get(exec_arn): + # Return already running execution if name and input match + existing_execution = self._idempotent_start_execution( + execution=execution, + state_machine=state_machine_clone, + name=name, + input_data=input_data, + ) + + if existing_execution: + return existing_execution.to_start_output() + + # Create the execution logging session, if logging is configured. + cloud_watch_logging_session = None + if state_machine_clone.cloud_watch_logging_configuration is not None: + cloud_watch_logging_session = CloudWatchLoggingSession( + execution_arn=exec_arn, + configuration=state_machine_clone.cloud_watch_logging_configuration, + ) + + mock_test_case = self._get_mock_test_case(state_machine_arn, state_machine_clone.name) + + execution = Execution( + name=exec_name, + sm_type=state_machine_clone.sm_type, + role_arn=state_machine_clone.role_arn, + exec_arn=exec_arn, + account_id=context.account_id, + region_name=context.region, + state_machine=state_machine_clone, + state_machine_alias_arn=alias.state_machine_alias_arn if alias is not None else None, + start_date=datetime.datetime.now(tz=datetime.timezone.utc), + cloud_watch_logging_session=cloud_watch_logging_session, + input_data=input_data, + trace_header=trace_header, + activity_store=self.get_store(context).activities, + mock_test_case=mock_test_case, + ) + + store.executions[exec_arn] = execution + + execution.start() + return execution.to_start_output() + + def start_sync_execution( + self, + context: RequestContext, + state_machine_arn: Arn, + name: Name = None, + input: SensitiveData = None, + trace_header: TraceHeader = None, + included_data: IncludedData = None, + **kwargs, + ) -> StartSyncExecutionOutput: + self._validate_state_machine_arn(state_machine_arn) + + base_arn = self._get_state_machine_arn(state_machine_arn) + unsafe_state_machine: Optional[StateMachineInstance] = self.get_store( + context + ).state_machines.get(base_arn) + if not unsafe_state_machine: + self._raise_state_machine_does_not_exist(base_arn) + + if unsafe_state_machine.sm_type == StateMachineType.STANDARD: + self._raise_state_machine_type_not_supported() + + # Update event change parameters about the state machine and should not affect those about this execution. + state_machine_clone = copy.deepcopy(unsafe_state_machine) + + if input is None: + input_data = dict() + else: + try: + input_data = json.loads(input) + except Exception as ex: + raise InvalidExecutionInput(str(ex)) # TODO: report parsing error like AWS. + + normalised_state_machine_arn = ( + state_machine_clone.source_arn + if isinstance(state_machine_clone, StateMachineVersion) + else state_machine_clone.arn + ) + exec_name = name or long_uid() # TODO: validate name format + exec_arn = stepfunctions_express_execution_arn(normalised_state_machine_arn, exec_name) + + if exec_arn in self.get_store(context).executions: + raise InvalidName() # TODO + + # Create the execution logging session, if logging is configured. + cloud_watch_logging_session = None + if state_machine_clone.cloud_watch_logging_configuration is not None: + cloud_watch_logging_session = CloudWatchLoggingSession( + execution_arn=exec_arn, + configuration=state_machine_clone.cloud_watch_logging_configuration, + ) + + mock_test_case = self._get_mock_test_case(state_machine_arn, state_machine_clone.name) + + execution = SyncExecution( + name=exec_name, + sm_type=state_machine_clone.sm_type, + role_arn=state_machine_clone.role_arn, + exec_arn=exec_arn, + account_id=context.account_id, + region_name=context.region, + state_machine=state_machine_clone, + start_date=datetime.datetime.now(tz=datetime.timezone.utc), + cloud_watch_logging_session=cloud_watch_logging_session, + input_data=input_data, + trace_header=trace_header, + activity_store=self.get_store(context).activities, + mock_test_case=mock_test_case, + ) + self.get_store(context).executions[exec_arn] = execution + + execution.start() + return execution.to_start_sync_execution_output() + + def describe_execution( + self, + context: RequestContext, + execution_arn: Arn, + included_data: IncludedData = None, + **kwargs, + ) -> DescribeExecutionOutput: + self._validate_state_machine_execution_arn(execution_arn) + execution: Execution = self._get_execution(context=context, execution_arn=execution_arn) + + # Action only compatible with STANDARD workflows. + if execution.sm_type != StateMachineType.STANDARD: + self._raise_resource_type_not_in_context(resource_type=execution.sm_type) + + return execution.to_describe_output() + + @staticmethod + def _list_execution_filter( + ex: Execution, state_machine_arn: str, status_filter: Optional[str] + ) -> bool: + state_machine_reference_arn_set = {ex.state_machine_arn, ex.state_machine_version_arn} + if state_machine_arn not in state_machine_reference_arn_set: + return False + + if not status_filter: + return True + return ex.exec_status == status_filter + + def list_executions( + self, + context: RequestContext, + state_machine_arn: Arn = None, + status_filter: ExecutionStatus = None, + max_results: PageSize = None, + next_token: ListExecutionsPageToken = None, + map_run_arn: LongArn = None, + redrive_filter: ExecutionRedriveFilter = None, + **kwargs, + ) -> ListExecutionsOutput: + self._validate_state_machine_arn(state_machine_arn) + assert_pagination_parameters_valid( + max_results=max_results, + next_token=next_token, + next_token_length_limit=3096, + ) + max_results = normalise_max_results(max_results) + + state_machine = self.get_store(context).state_machines.get(state_machine_arn) + if state_machine is None: + self._raise_state_machine_does_not_exist(state_machine_arn) + + if state_machine.sm_type != StateMachineType.STANDARD: + self._raise_state_machine_type_not_supported() + + # TODO: add support for paging + + allowed_execution_status = [ + ExecutionStatus.SUCCEEDED, + ExecutionStatus.TIMED_OUT, + ExecutionStatus.PENDING_REDRIVE, + ExecutionStatus.ABORTED, + ExecutionStatus.FAILED, + ExecutionStatus.RUNNING, + ] + + validation_errors = [] + + if status_filter and status_filter not in allowed_execution_status: + validation_errors.append( + f"Value '{status_filter}' at 'statusFilter' failed to satisfy constraint: Member must satisfy enum value set: [{', '.join(allowed_execution_status)}]" + ) + + if not state_machine_arn and not map_run_arn: + validation_errors.append("Must provide a StateMachine ARN or MapRun ARN") + + if validation_errors: + errors_message = "; ".join(validation_errors) + message = f"{len(validation_errors)} validation {'errors' if len(validation_errors) > 1 else 'error'} detected: {errors_message}" + raise CommonServiceException(message=message, code="ValidationException") + + executions: ExecutionList = [ + execution.to_execution_list_item() + for execution in self.get_store(context).executions.values() + if self._list_execution_filter( + execution, + state_machine_arn=state_machine_arn, + status_filter=status_filter, + ) + ] + + executions.sort(key=lambda item: item["startDate"], reverse=True) + + paginated_executions = PaginatedList(executions) + page, token_for_next_page = paginated_executions.get_page( + token_generator=lambda item: get_next_page_token_from_arn(item.get("executionArn")), + page_size=max_results, + next_token=next_token, + ) + + return ListExecutionsOutput(executions=page, nextToken=token_for_next_page) + + def list_state_machines( + self, + context: RequestContext, + max_results: PageSize = None, + next_token: PageToken = None, + **kwargs, + ) -> ListStateMachinesOutput: + assert_pagination_parameters_valid(max_results, next_token) + max_results = normalise_max_results(max_results) + + state_machines: StateMachineList = [ + sm.itemise() + for sm in self.get_store(context).state_machines.values() + if isinstance(sm, StateMachineRevision) + ] + state_machines.sort(key=lambda item: item["name"]) + + paginated_state_machines = PaginatedList(state_machines) + page, token_for_next_page = paginated_state_machines.get_page( + token_generator=lambda item: get_next_page_token_from_arn(item.get("stateMachineArn")), + page_size=max_results, + next_token=next_token, + ) + + return ListStateMachinesOutput(stateMachines=page, nextToken=token_for_next_page) + + def list_state_machine_aliases( + self, + context: RequestContext, + state_machine_arn: Arn, + next_token: PageToken = None, + max_results: PageSize = None, + **kwargs, + ) -> ListStateMachineAliasesOutput: + assert_pagination_parameters_valid(max_results, next_token) + + self._validate_state_machine_arn(state_machine_arn) + state_machines = self.get_store(context).state_machines + state_machine_revision = state_machines.get(state_machine_arn) + if not isinstance(state_machine_revision, StateMachineRevision): + raise InvalidArn(f"Invalid arn: {state_machine_arn}") + + state_machine_aliases: StateMachineAliasList = list() + valid_token_found = next_token is None + + for alias in state_machine_revision.aliases: + state_machine_aliases.append(alias.to_item()) + if alias.tokenized_state_machine_alias_arn == next_token: + valid_token_found = True + + if not valid_token_found: + raise InvalidToken("Invalid Token: 'Invalid token'") + + state_machine_aliases.sort(key=lambda item: item["creationDate"]) + + paginated_list = PaginatedList(state_machine_aliases) + + paginated_aliases, next_token = paginated_list.get_page( + token_generator=lambda item: get_next_page_token_from_arn( + item.get("stateMachineAliasArn") + ), + next_token=next_token, + page_size=100 if max_results == 0 or max_results is None else max_results, + ) + + return ListStateMachineAliasesOutput( + stateMachineAliases=paginated_aliases, nextToken=next_token + ) + + def list_state_machine_versions( + self, + context: RequestContext, + state_machine_arn: Arn, + next_token: PageToken = None, + max_results: PageSize = None, + **kwargs, + ) -> ListStateMachineVersionsOutput: + self._validate_state_machine_arn(state_machine_arn) + assert_pagination_parameters_valid(max_results, next_token) + max_results = normalise_max_results(max_results) + + state_machines = self.get_store(context).state_machines + state_machine_revision = state_machines.get(state_machine_arn) + if not isinstance(state_machine_revision, StateMachineRevision): + raise InvalidArn(f"Invalid arn: {state_machine_arn}") + + state_machine_version_items = list() + for version_arn in state_machine_revision.versions.values(): + state_machine_version = state_machines[version_arn] + if isinstance(state_machine_version, StateMachineVersion): + state_machine_version_items.append(state_machine_version.itemise()) + else: + raise RuntimeError( + f"Expected {version_arn} to be a StateMachine Version, but got '{type(state_machine_version)}'." + ) + + state_machine_version_items.sort(key=lambda item: item["creationDate"], reverse=True) + + paginated_state_machine_versions = PaginatedList(state_machine_version_items) + page, token_for_next_page = paginated_state_machine_versions.get_page( + token_generator=lambda item: get_next_page_token_from_arn( + item.get("stateMachineVersionArn") + ), + page_size=max_results, + next_token=next_token, + ) + + return ListStateMachineVersionsOutput( + stateMachineVersions=page, nextToken=token_for_next_page + ) + + def get_execution_history( + self, + context: RequestContext, + execution_arn: Arn, + max_results: PageSize = None, + reverse_order: ReverseOrder = None, + next_token: PageToken = None, + include_execution_data: IncludeExecutionDataGetExecutionHistory = None, + **kwargs, + ) -> GetExecutionHistoryOutput: + # TODO: add support for paging, ordering, and other manipulations. + self._validate_state_machine_execution_arn(execution_arn) + execution: Execution = self._get_execution(context=context, execution_arn=execution_arn) + + # Action only compatible with STANDARD workflows. + if execution.sm_type != StateMachineType.STANDARD: + self._raise_resource_type_not_in_context(resource_type=execution.sm_type) + + history: GetExecutionHistoryOutput = execution.to_history_output() + if reverse_order: + history["events"].reverse() + return history + + def delete_state_machine( + self, context: RequestContext, state_machine_arn: Arn, **kwargs + ) -> DeleteStateMachineOutput: + # TODO: halt executions? + self._validate_state_machine_arn(state_machine_arn) + state_machines = self.get_store(context).state_machines + state_machine = state_machines.get(state_machine_arn) + if isinstance(state_machine, StateMachineRevision): + state_machines.pop(state_machine_arn) + for version_arn in state_machine.versions.values(): + state_machines.pop(version_arn, None) + return DeleteStateMachineOutput() + + def delete_state_machine_alias( + self, context: RequestContext, state_machine_alias_arn: Arn, **kwargs + ) -> DeleteStateMachineAliasOutput: + self._validate_state_machine_alias_arn(state_machine_alias_arn=state_machine_alias_arn) + store = self.get_store(context=context) + aliases = store.aliases + if (alias := aliases.pop(state_machine_alias_arn, None)) is not None: + state_machines = store.state_machines + for routing_configuration in alias.get_routing_configuration_list(): + state_machine_version_arn = routing_configuration["stateMachineVersionArn"] + if ( + state_machine_version := state_machines.get(state_machine_version_arn) + ) is None or not isinstance(state_machine_version, StateMachineVersion): + continue + if ( + state_machine_revision := state_machines.get(state_machine_version.source_arn) + ) is None or not isinstance(state_machine_revision, StateMachineRevision): + continue + state_machine_revision.aliases.discard(alias) + return DeleteStateMachineOutput() + + def delete_state_machine_version( + self, context: RequestContext, state_machine_version_arn: LongArn, **kwargs + ) -> DeleteStateMachineVersionOutput: + self._validate_state_machine_arn(state_machine_version_arn) + state_machines = self.get_store(context).state_machines + + if not ( + state_machine_version := state_machines.get(state_machine_version_arn) + ) or not isinstance(state_machine_version, StateMachineVersion): + return DeleteStateMachineVersionOutput() + + if ( + state_machine_revision := state_machines.get(state_machine_version.source_arn) + ) and isinstance(state_machine_revision, StateMachineRevision): + referencing_alias_names: list[str] = list() + for alias in state_machine_revision.aliases: + if alias.is_router_for(state_machine_version_arn=state_machine_version_arn): + referencing_alias_names.append(alias.name) + if referencing_alias_names: + referencing_alias_names_list_body = ", ".join(referencing_alias_names) + raise ConflictException( + "Version to be deleted must not be referenced by an alias. " + f"Current list of aliases referencing this version: [{referencing_alias_names_list_body}]" + ) + state_machine_revision.delete_version(state_machine_version_arn) + + state_machines.pop(state_machine_version.arn, None) + return DeleteStateMachineVersionOutput() + + def stop_execution( + self, + context: RequestContext, + execution_arn: Arn, + error: SensitiveError = None, + cause: SensitiveCause = None, + **kwargs, + ) -> StopExecutionOutput: + self._validate_state_machine_execution_arn(execution_arn) + execution: Execution = self._get_execution(context=context, execution_arn=execution_arn) + + # Action only compatible with STANDARD workflows. + if execution.sm_type != StateMachineType.STANDARD: + self._raise_resource_type_not_in_context(resource_type=execution.sm_type) + + stop_date = datetime.datetime.now(tz=datetime.timezone.utc) + execution.stop(stop_date=stop_date, cause=cause, error=error) + return StopExecutionOutput(stopDate=stop_date) + + def update_state_machine( + self, + context: RequestContext, + state_machine_arn: Arn, + definition: Definition = None, + role_arn: Arn = None, + logging_configuration: LoggingConfiguration = None, + tracing_configuration: TracingConfiguration = None, + publish: Publish = None, + version_description: VersionDescription = None, + encryption_configuration: EncryptionConfiguration = None, + **kwargs, + ) -> UpdateStateMachineOutput: + self._validate_state_machine_arn(state_machine_arn) + state_machines = self.get_store(context).state_machines + + state_machine = state_machines.get(state_machine_arn) + if not isinstance(state_machine, StateMachineRevision): + self._raise_state_machine_does_not_exist(state_machine_arn) + + # TODO: Add logic to handle metrics for when SFN definitions update + if not any([definition, role_arn, logging_configuration]): + raise MissingRequiredParameter( + "Either the definition, the role ARN, the LoggingConfiguration, " + "or the TracingConfiguration must be specified" + ) + + if definition is not None: + self._validate_definition(definition=definition, static_analysers=[StaticAnalyser()]) + + if logging_configuration is not None: + self._sanitise_logging_configuration(logging_configuration=logging_configuration) + + revision_id = state_machine.create_revision( + definition=definition, + role_arn=role_arn, + logging_configuration=logging_configuration, + ) + + version_arn = None + if publish: + version = state_machine.create_version(description=version_description) + if version is not None: + version_arn = version.arn + state_machines[version_arn] = version + else: + target_revision_id = revision_id or state_machine.revision_id + version_arn = state_machine.versions[target_revision_id] + + update_output = UpdateStateMachineOutput( + updateDate=datetime.datetime.now(tz=datetime.timezone.utc) + ) + if revision_id is not None: + update_output["revisionId"] = revision_id + if version_arn is not None: + update_output["stateMachineVersionArn"] = version_arn + return update_output + + def update_state_machine_alias( + self, + context: RequestContext, + state_machine_alias_arn: Arn, + description: AliasDescription = None, + routing_configuration: RoutingConfigurationList = None, + **kwargs, + ) -> UpdateStateMachineAliasOutput: + self._validate_state_machine_alias_arn(state_machine_alias_arn=state_machine_alias_arn) + if not any([description, routing_configuration]): + raise MissingRequiredParameter( + "Either the description or the RoutingConfiguration must be specified" + ) + if routing_configuration is not None: + self._validate_state_machine_alias_routing_configuration( + context=context, routing_configuration_list=routing_configuration + ) + store = self.get_store(context=context) + alias = store.aliases.get(state_machine_alias_arn) + if alias is None: + raise ResourceNotFound("Request references a resource that does not exist.") + + alias.update(description=description, routing_configuration_list=routing_configuration) + return UpdateStateMachineAliasOutput(updateDate=alias.update_date) + + def publish_state_machine_version( + self, + context: RequestContext, + state_machine_arn: Arn, + revision_id: RevisionId = None, + description: VersionDescription = None, + **kwargs, + ) -> PublishStateMachineVersionOutput: + self._validate_state_machine_arn(state_machine_arn) + state_machines = self.get_store(context).state_machines + + state_machine_revision = state_machines.get(state_machine_arn) + if not isinstance(state_machine_revision, StateMachineRevision): + self._raise_state_machine_does_not_exist(state_machine_arn) + + if revision_id is not None and state_machine_revision.revision_id != revision_id: + raise ConflictException( + f"Failed to publish the State Machine version for revision {revision_id}. " + f"The current State Machine revision is {state_machine_revision.revision_id}." + ) + + state_machine_version = state_machine_revision.create_version(description=description) + if state_machine_version is not None: + state_machines[state_machine_version.arn] = state_machine_version + else: + target_revision_id = revision_id or state_machine_revision.revision_id + state_machine_version_arn = state_machine_revision.versions.get(target_revision_id) + state_machine_version = state_machines[state_machine_version_arn] + + return PublishStateMachineVersionOutput( + creationDate=state_machine_version.create_date, + stateMachineVersionArn=state_machine_version.arn, + ) + + def tag_resource( + self, context: RequestContext, resource_arn: Arn, tags: TagList, **kwargs + ) -> TagResourceOutput: + # TODO: add tagging for activities. + state_machines = self.get_store(context).state_machines + state_machine = state_machines.get(resource_arn) + if not isinstance(state_machine, StateMachineRevision): + raise ResourceNotFound(f"Resource not found: '{resource_arn}'") + + state_machine.tag_manager.add_all(tags) + return TagResourceOutput() + + def untag_resource( + self, context: RequestContext, resource_arn: Arn, tag_keys: TagKeyList, **kwargs + ) -> UntagResourceOutput: + # TODO: add untagging for activities. + state_machines = self.get_store(context).state_machines + state_machine = state_machines.get(resource_arn) + if not isinstance(state_machine, StateMachineRevision): + raise ResourceNotFound(f"Resource not found: '{resource_arn}'") + + state_machine.tag_manager.remove_all(tag_keys) + return UntagResourceOutput() + + def list_tags_for_resource( + self, context: RequestContext, resource_arn: Arn, **kwargs + ) -> ListTagsForResourceOutput: + # TODO: add untagging for activities. + state_machines = self.get_store(context).state_machines + state_machine = state_machines.get(resource_arn) + if not isinstance(state_machine, StateMachineRevision): + raise ResourceNotFound(f"Resource not found: '{resource_arn}'") + + tags: TagList = state_machine.tag_manager.to_tag_list() + return ListTagsForResourceOutput(tags=tags) + + def describe_map_run( + self, context: RequestContext, map_run_arn: LongArn, **kwargs + ) -> DescribeMapRunOutput: + store = self.get_store(context) + for execution in store.executions.values(): + map_run_record: Optional[MapRunRecord] = ( + execution.exec_worker.env.map_run_record_pool_manager.get(map_run_arn) + ) + if map_run_record is not None: + return map_run_record.describe() + raise ResourceNotFound() + + def list_map_runs( + self, + context: RequestContext, + execution_arn: Arn, + max_results: PageSize = None, + next_token: PageToken = None, + **kwargs, + ) -> ListMapRunsOutput: + # TODO: add support for paging. + execution = self._get_execution(context=context, execution_arn=execution_arn) + map_run_records: list[MapRunRecord] = ( + execution.exec_worker.env.map_run_record_pool_manager.get_all() + ) + return ListMapRunsOutput( + mapRuns=[map_run_record.list_item() for map_run_record in map_run_records] + ) + + def update_map_run( + self, + context: RequestContext, + map_run_arn: LongArn, + max_concurrency: MaxConcurrency = None, + tolerated_failure_percentage: ToleratedFailurePercentage = None, + tolerated_failure_count: ToleratedFailureCount = None, + **kwargs, + ) -> UpdateMapRunOutput: + if tolerated_failure_percentage is not None or tolerated_failure_count is not None: + raise NotImplementedError( + "Updating of ToleratedFailureCount and ToleratedFailurePercentage is currently unsupported." + ) + # TODO: investigate behaviour of empty requests. + store = self.get_store(context) + for execution in store.executions.values(): + map_run_record: Optional[MapRunRecord] = ( + execution.exec_worker.env.map_run_record_pool_manager.get(map_run_arn) + ) + if map_run_record is not None: + map_run_record.update( + max_concurrency=max_concurrency, + tolerated_failure_count=tolerated_failure_count, + tolerated_failure_percentage=tolerated_failure_percentage, + ) + LOG.warning( + "StepFunctions UpdateMapRun changes are currently not being reflected in the MapRun instances." + ) + return UpdateMapRunOutput() + raise ResourceNotFound() + + def test_state( + self, + context: RequestContext, + definition: Definition, + role_arn: Arn = None, + input: SensitiveData = None, + inspection_level: InspectionLevel = None, + reveal_secrets: RevealSecrets = None, + variables: SensitiveData = None, + **kwargs, + ) -> TestStateOutput: + StepFunctionsProvider._validate_definition( + definition=definition, static_analysers=[TestStateStaticAnalyser()] + ) + + name: Optional[Name] = f"TestState-{short_uid()}" + arn = stepfunctions_state_machine_arn( + name=name, account_id=context.account_id, region_name=context.region + ) + state_machine = TestStateMachine( + name=name, + arn=arn, + role_arn=role_arn, + definition=definition, + ) + exec_arn = stepfunctions_standard_execution_arn(state_machine.arn, name) + + input_json = json.loads(input) + execution = TestStateExecution( + name=name, + role_arn=role_arn, + exec_arn=exec_arn, + account_id=context.account_id, + region_name=context.region, + state_machine=state_machine, + start_date=datetime.datetime.now(tz=datetime.timezone.utc), + input_data=input_json, + activity_store=self.get_store(context).activities, + ) + execution.start() + + test_state_output = execution.to_test_state_output( + inspection_level=inspection_level or InspectionLevel.INFO + ) + + return test_state_output + + def create_activity( + self, + context: RequestContext, + name: Name, + tags: TagList = None, + encryption_configuration: EncryptionConfiguration = None, + **kwargs, + ) -> CreateActivityOutput: + self._validate_activity_name(name=name) + + activity_arn = stepfunctions_activity_arn( + name=name, account_id=context.account_id, region_name=context.region + ) + activities = self.get_store(context).activities + if activity_arn not in activities: + activity = Activity(arn=activity_arn, name=name) + activities[activity_arn] = activity + else: + activity = activities[activity_arn] + + return CreateActivityOutput(activityArn=activity.arn, creationDate=activity.creation_date) + + def delete_activity( + self, context: RequestContext, activity_arn: Arn, **kwargs + ) -> DeleteActivityOutput: + self._validate_activity_arn(activity_arn) + self.get_store(context).activities.pop(activity_arn, None) + return DeleteActivityOutput() + + def describe_activity( + self, context: RequestContext, activity_arn: Arn, **kwargs + ) -> DescribeActivityOutput: + self._validate_activity_arn(activity_arn) + activity = self._get_activity(context=context, activity_arn=activity_arn) + return activity.to_describe_activity_output() + + def list_activities( + self, + context: RequestContext, + max_results: PageSize = None, + next_token: PageToken = None, + **kwargs, + ) -> ListActivitiesOutput: + activities: list[Activity] = list(self.get_store(context).activities.values()) + return ListActivitiesOutput( + activities=[activity.to_activity_list_item() for activity in activities] + ) + + def _send_activity_task_started( + self, + context: RequestContext, + task_token: TaskToken, + worker_name: Optional[Name], + ) -> None: + executions: list[Execution] = self._get_executions(context) + for execution in executions: + callback_endpoint = execution.exec_worker.env.callback_pool_manager.get( + callback_id=task_token + ) + if isinstance(callback_endpoint, ActivityCallbackEndpoint): + callback_endpoint.notify_activity_task_start(worker_name=worker_name) + return + raise InvalidToken() + + @staticmethod + def _pull_activity_task(activity: Activity) -> Optional[ActivityTask]: + seconds_left = 60 + while seconds_left > 0: + try: + return activity.get_task() + except IndexError: + time.sleep(1) + seconds_left -= 1 + return None + + def get_activity_task( + self, + context: RequestContext, + activity_arn: Arn, + worker_name: Name = None, + **kwargs, + ) -> GetActivityTaskOutput: + self._validate_activity_arn(activity_arn) + + activity = self._get_activity(context=context, activity_arn=activity_arn) + maybe_task: Optional[ActivityTask] = self._pull_activity_task(activity=activity) + if maybe_task is not None: + self._send_activity_task_started( + context, maybe_task.task_token, worker_name=worker_name + ) + return GetActivityTaskOutput( + taskToken=maybe_task.task_token, input=maybe_task.task_input + ) + + return GetActivityTaskOutput(taskToken=None, input=None) + + def validate_state_machine_definition( + self, context: RequestContext, request: ValidateStateMachineDefinitionInput, **kwargs + ) -> ValidateStateMachineDefinitionOutput: + # TODO: increase parity of static analysers, current implementation is an unblocker for this API action. + # TODO: add support for ValidateStateMachineDefinitionSeverity + # TODO: add support for ValidateStateMachineDefinitionMaxResult + + state_machine_type: StateMachineType = request.get("type", StateMachineType.STANDARD) + definition: str = request["definition"] + + static_analysers = list() + if state_machine_type == StateMachineType.STANDARD: + static_analysers.append(StaticAnalyser()) + else: + static_analysers.append(ExpressStaticAnalyser()) + + diagnostics: ValidateStateMachineDefinitionDiagnosticList = list() + try: + StepFunctionsProvider._validate_definition( + definition=definition, static_analysers=static_analysers + ) + validation_result = ValidateStateMachineDefinitionResultCode.OK + except InvalidDefinition as invalid_definition: + validation_result = ValidateStateMachineDefinitionResultCode.FAIL + diagnostics.append( + ValidateStateMachineDefinitionDiagnostic( + severity=ValidateStateMachineDefinitionSeverity.ERROR, + code="SCHEMA_VALIDATION_FAILED", + message=invalid_definition.message, + ) + ) + except Exception as ex: + validation_result = ValidateStateMachineDefinitionResultCode.FAIL + LOG.error("Unknown error during validation %s", ex) + + return ValidateStateMachineDefinitionOutput( + result=validation_result, diagnostics=diagnostics, truncated=False + ) diff --git a/localstack-core/localstack/services/stepfunctions/quotas.py b/localstack-core/localstack/services/stepfunctions/quotas.py new file mode 100644 index 0000000000000..bf55f8256f51a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/quotas.py @@ -0,0 +1,13 @@ +import json +from typing import Final, Union + +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str + +MAX_STATE_SIZE_UTF8_BYTES: Final[int] = 256 * 1024 # 256 KB of data as a UTF-8 encoded string. + + +def is_within_size_quota(value: Union[str, json]) -> bool: + item_str = value if isinstance(value, str) else to_json_str(value) + item_bytes = item_str.encode("utf-8") + len_item_bytes = len(item_bytes) + return len_item_bytes < MAX_STATE_SIZE_UTF8_BYTES diff --git a/localstack-core/localstack/services/stepfunctions/resource_providers/__init__.py b/localstack-core/localstack/services/stepfunctions/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity.py b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity.py new file mode 100644 index 0000000000000..bea92e160ec03 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity.py @@ -0,0 +1,114 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) + + +class StepFunctionsActivityProperties(TypedDict): + Name: Optional[str] + Arn: Optional[str] + Tags: Optional[list[TagsEntry]] + + +class TagsEntry(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class StepFunctionsActivityProvider(ResourceProvider[StepFunctionsActivityProperties]): + TYPE = "AWS::StepFunctions::Activity" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[StepFunctionsActivityProperties], + ) -> ProgressEvent[StepFunctionsActivityProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Arn + + Required properties: + - Name + + Create-only properties: + - /properties/Name + + Read-only properties: + - /properties/Arn + + IAM permissions required: + - states:CreateActivity + + """ + model = request.desired_state + step_functions = request.aws_client_factory.stepfunctions + response = step_functions.create_activity(**model) + model["Arn"] = response["activityArn"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def read( + self, + request: ResourceRequest[StepFunctionsActivityProperties], + ) -> ProgressEvent[StepFunctionsActivityProperties]: + """ + Fetch resource information + + IAM permissions required: + - states:DescribeActivity + - states:ListTagsForResource + """ + raise NotImplementedError + + def delete( + self, + request: ResourceRequest[StepFunctionsActivityProperties], + ) -> ProgressEvent[StepFunctionsActivityProperties]: + """ + Delete a resource + + IAM permissions required: + - states:DeleteActivity + """ + model = request.desired_state + step_functions = request.aws_client_factory.stepfunctions + + step_functions.delete_activity(activityArn=model["Arn"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[StepFunctionsActivityProperties], + ) -> ProgressEvent[StepFunctionsActivityProperties]: + """ + Update a resource + + IAM permissions required: + - states:ListTagsForResource + - states:TagResource + - states:UntagResource + """ + raise NotImplementedError diff --git a/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity.schema.json b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity.schema.json new file mode 100644 index 0000000000000..9a1f2bb156ca3 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity.schema.json @@ -0,0 +1,92 @@ +{ + "typeName": "AWS::StepFunctions::Activity", + "description": "Resource schema for Activity", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-stepfunctions.git", + "definitions": { + "TagsEntry": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "additionalProperties": false, + "required": [ + "Key", + "Value" + ] + } + }, + "properties": { + "Arn": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "Name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/TagsEntry" + } + } + }, + "additionalProperties": false, + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "required": [ + "Name" + ], + "primaryIdentifier": [ + "/properties/Arn" + ], + "readOnlyProperties": [ + "/properties/Arn" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "handlers": { + "create": { + "permissions": [ + "states:CreateActivity" + ] + }, + "read": { + "permissions": [ + "states:DescribeActivity", + "states:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "states:ListTagsForResource", + "states:TagResource", + "states:UntagResource" + ] + }, + "delete": { + "permissions": [ + "states:DeleteActivity" + ] + } + } +} diff --git a/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity_plugin.py b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity_plugin.py new file mode 100644 index 0000000000000..b8f8891464a39 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_activity_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class StepFunctionsActivityProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::StepFunctions::Activity" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.stepfunctions.resource_providers.aws_stepfunctions_activity import ( + StepFunctionsActivityProvider, + ) + + self.factory = StepFunctionsActivityProvider diff --git a/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine.py b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine.py new file mode 100644 index 0000000000000..a1dd521ab5d4a --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine.py @@ -0,0 +1,250 @@ +# LocalStack Resource Provider Scaffolding v2 +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Optional, TypedDict + +import localstack.services.cloudformation.provider_utils as util +from localstack.services.cloudformation.resource_provider import ( + LOG, + OperationStatus, + ProgressEvent, + ResourceProvider, + ResourceRequest, +) +from localstack.utils.strings import to_str + + +class StepFunctionsStateMachineProperties(TypedDict): + RoleArn: Optional[str] + Arn: Optional[str] + Definition: Optional[dict] + DefinitionS3Location: Optional[S3Location] + DefinitionString: Optional[str] + DefinitionSubstitutions: Optional[dict] + LoggingConfiguration: Optional[LoggingConfiguration] + Name: Optional[str] + StateMachineName: Optional[str] + StateMachineRevisionId: Optional[str] + StateMachineType: Optional[str] + Tags: Optional[list[TagsEntry]] + TracingConfiguration: Optional[TracingConfiguration] + + +class CloudWatchLogsLogGroup(TypedDict): + LogGroupArn: Optional[str] + + +class LogDestination(TypedDict): + CloudWatchLogsLogGroup: Optional[CloudWatchLogsLogGroup] + + +class LoggingConfiguration(TypedDict): + Destinations: Optional[list[LogDestination]] + IncludeExecutionData: Optional[bool] + Level: Optional[str] + + +class TracingConfiguration(TypedDict): + Enabled: Optional[bool] + + +class S3Location(TypedDict): + Bucket: Optional[str] + Key: Optional[str] + Version: Optional[str] + + +class TagsEntry(TypedDict): + Key: Optional[str] + Value: Optional[str] + + +REPEATED_INVOCATION = "repeated_invocation" + + +class StepFunctionsStateMachineProvider(ResourceProvider[StepFunctionsStateMachineProperties]): + TYPE = "AWS::StepFunctions::StateMachine" # Autogenerated. Don't change + SCHEMA = util.get_schema_path(Path(__file__)) # Autogenerated. Don't change + + def create( + self, + request: ResourceRequest[StepFunctionsStateMachineProperties], + ) -> ProgressEvent[StepFunctionsStateMachineProperties]: + """ + Create a new resource. + + Primary identifier fields: + - /properties/Arn + + Required properties: + - RoleArn + + Create-only properties: + - /properties/StateMachineName + - /properties/StateMachineType + + Read-only properties: + - /properties/Arn + - /properties/Name + - /properties/StateMachineRevisionId + + IAM permissions required: + - states:CreateStateMachine + - iam:PassRole + - s3:GetObject + + """ + model = request.desired_state + step_function = request.aws_client_factory.stepfunctions + + if not model.get("StateMachineName"): + model["StateMachineName"] = util.generate_default_name( + stack_name=request.stack_name, logical_resource_id=request.logical_resource_id + ) + + params = { + "name": model.get("StateMachineName"), + "roleArn": model.get("RoleArn"), + "type": model.get("StateMachineType", "STANDARD"), + } + logging_configuration = model.get("LoggingConfiguration") + if logging_configuration is not None: + params["loggingConfiguration"] = logging_configuration + + # get definition + s3_client = request.aws_client_factory.s3 + + definition_str = self._get_definition(model, s3_client) + + params["definition"] = definition_str + + response = step_function.create_state_machine(**params) + + model["Arn"] = response["stateMachineArn"] + model["Name"] = model["StateMachineName"] + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def _get_definition(self, model, s3_client): + if "DefinitionString" in model: + definition_str = model.get("DefinitionString") + elif "DefinitionS3Location" in model: + # TODO: currently not covered by tests - add a test to mimick the behavior of "sam deploy ..." + s3_location = model.get("DefinitionS3Location") + LOG.debug("Fetching state machine definition from S3: %s", s3_location) + result = s3_client.get_object(Bucket=s3_location["Bucket"], Key=s3_location["Key"]) + definition_str = to_str(result["Body"].read()) + elif "Definition" in model: + definition = model.get("Definition") + definition_str = json.dumps(definition) + else: + definition_str = None + + substitutions = model.get("DefinitionSubstitutions") + if substitutions is not None: + definition_str = _apply_substitutions(definition_str, substitutions) + return definition_str + + def read( + self, + request: ResourceRequest[StepFunctionsStateMachineProperties], + ) -> ProgressEvent[StepFunctionsStateMachineProperties]: + """ + Fetch resource information + + IAM permissions required: + - states:DescribeStateMachine + - states:ListTagsForResource + """ + raise NotImplementedError + + def list( + self, request: ResourceRequest[StepFunctionsStateMachineProperties] + ) -> ProgressEvent[StepFunctionsStateMachineProperties]: + resources = request.aws_client_factory.stepfunctions.list_state_machines()["stateMachines"] + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_models=[ + StepFunctionsStateMachineProperties(Arn=resource["stateMachineArn"]) + for resource in resources + ], + ) + + def delete( + self, + request: ResourceRequest[StepFunctionsStateMachineProperties], + ) -> ProgressEvent[StepFunctionsStateMachineProperties]: + """ + Delete a resource + + IAM permissions required: + - states:DeleteStateMachine + - states:DescribeStateMachine + """ + model = request.desired_state + step_function = request.aws_client_factory.stepfunctions + + step_function.delete_state_machine(stateMachineArn=model["Arn"]) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + def update( + self, + request: ResourceRequest[StepFunctionsStateMachineProperties], + ) -> ProgressEvent[StepFunctionsStateMachineProperties]: + """ + Update a resource + + IAM permissions required: + - states:UpdateStateMachine + - states:TagResource + - states:UntagResource + - states:ListTagsForResource + - iam:PassRole + """ + model = request.desired_state + step_function = request.aws_client_factory.stepfunctions + + if not model.get("Arn"): + model["Arn"] = request.previous_state["Arn"] + + definition_str = self._get_definition(model, request.aws_client_factory.s3) + params = { + "stateMachineArn": model["Arn"], + "definition": definition_str, + } + logging_configuration = model.get("LoggingConfiguration") + if logging_configuration is not None: + params["loggingConfiguration"] = logging_configuration + + step_function.update_state_machine(**params) + + return ProgressEvent( + status=OperationStatus.SUCCESS, + resource_model=model, + custom_context=request.custom_context, + ) + + +def _apply_substitutions(definition: str, substitutions: dict[str, str]) -> str: + substitution_regex = re.compile("\\${[a-zA-Z0-9_]+}") # might be a bit too strict in some cases + tokens = substitution_regex.findall(definition) + result = definition + for token in tokens: + raw_token = token[2:-1] # strip ${ and } + if raw_token not in substitutions: + raise + result = result.replace(token, substitutions[raw_token]) + + return result diff --git a/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine.schema.json b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine.schema.json new file mode 100644 index 0000000000000..607e1a9bccdab --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine.schema.json @@ -0,0 +1,250 @@ +{ + "typeName": "AWS::StepFunctions::StateMachine", + "description": "Resource schema for StateMachine", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-stepfunctions.git", + "definitions": { + "TagsEntry": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "Value": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "additionalProperties": false, + "required": [ + "Key", + "Value" + ] + }, + "CloudWatchLogsLogGroup": { + "type": "object", + "additionalProperties": false, + "properties": { + "LogGroupArn": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "LogDestination": { + "type": "object", + "additionalProperties": false, + "properties": { + "CloudWatchLogsLogGroup": { + "$ref": "#/definitions/CloudWatchLogsLogGroup" + } + } + }, + "LoggingConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Level": { + "type": "string", + "enum": [ + "ALL", + "ERROR", + "FATAL", + "OFF" + ] + }, + "IncludeExecutionData": { + "type": "boolean" + }, + "Destinations": { + "type": "array", + "minItems": 1, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/LogDestination" + } + } + } + }, + "TracingConfiguration": { + "type": "object", + "additionalProperties": false, + "properties": { + "Enabled": { + "type": "boolean" + } + } + }, + "S3Location": { + "type": "object", + "additionalProperties": false, + "properties": { + "Bucket": { + "type": "string" + }, + "Key": { + "type": "string" + }, + "Version": { + "type": "string" + } + }, + "required": [ + "Bucket", + "Key" + ] + }, + "DefinitionSubstitutions": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + ".*": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "boolean" + } + ] + } + }, + "minProperties": 1 + }, + "Definition": { + "type": "object", + "minProperties": 1 + } + }, + "properties": { + "Arn": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "Name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "DefinitionString": { + "type": "string", + "minLength": 1, + "maxLength": 1048576 + }, + "RoleArn": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "StateMachineName": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "StateMachineType": { + "type": "string", + "enum": [ + "STANDARD", + "EXPRESS" + ] + }, + "StateMachineRevisionId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "LoggingConfiguration": { + "$ref": "#/definitions/LoggingConfiguration" + }, + "TracingConfiguration": { + "$ref": "#/definitions/TracingConfiguration" + }, + "DefinitionS3Location": { + "$ref": "#/definitions/S3Location" + }, + "DefinitionSubstitutions": { + "$ref": "#/definitions/DefinitionSubstitutions" + }, + "Definition": { + "$ref": "#/definitions/Definition" + }, + "Tags": { + "type": "array", + "uniqueItems": false, + "insertionOrder": false, + "items": { + "$ref": "#/definitions/TagsEntry" + } + } + }, + "required": [ + "RoleArn" + ], + "tagging": { + "taggable": true, + "tagOnCreate": true, + "tagUpdatable": true, + "cloudFormationSystemTags": true, + "tagProperty": "/properties/Tags" + }, + "additionalProperties": false, + "readOnlyProperties": [ + "/properties/Arn", + "/properties/Name", + "/properties/StateMachineRevisionId" + ], + "createOnlyProperties": [ + "/properties/StateMachineName", + "/properties/StateMachineType" + ], + "writeOnlyProperties": [ + "/properties/Definition", + "/properties/DefinitionS3Location", + "/properties/DefinitionSubstitutions" + ], + "primaryIdentifier": [ + "/properties/Arn" + ], + "handlers": { + "create": { + "permissions": [ + "states:CreateStateMachine", + "iam:PassRole", + "s3:GetObject" + ] + }, + "read": { + "permissions": [ + "states:DescribeStateMachine", + "states:ListTagsForResource" + ] + }, + "update": { + "permissions": [ + "states:UpdateStateMachine", + "states:TagResource", + "states:UntagResource", + "states:ListTagsForResource", + "iam:PassRole" + ] + }, + "delete": { + "permissions": [ + "states:DeleteStateMachine", + "states:DescribeStateMachine" + ] + }, + "list": { + "permissions": [ + "states:ListStateMachines" + ] + } + } +} diff --git a/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine_plugin.py b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine_plugin.py new file mode 100644 index 0000000000000..744ff8120e5f6 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/resource_providers/aws_stepfunctions_statemachine_plugin.py @@ -0,0 +1,20 @@ +from typing import Optional, Type + +from localstack.services.cloudformation.resource_provider import ( + CloudFormationResourceProviderPlugin, + ResourceProvider, +) + + +class StepFunctionsStateMachineProviderPlugin(CloudFormationResourceProviderPlugin): + name = "AWS::StepFunctions::StateMachine" + + def __init__(self): + self.factory: Optional[Type[ResourceProvider]] = None + + def load(self): + from localstack.services.stepfunctions.resource_providers.aws_stepfunctions_statemachine import ( + StepFunctionsStateMachineProvider, + ) + + self.factory = StepFunctionsStateMachineProvider diff --git a/localstack-core/localstack/services/stepfunctions/stepfunctions_utils.py b/localstack-core/localstack/services/stepfunctions/stepfunctions_utils.py new file mode 100644 index 0000000000000..95133b4ed47e8 --- /dev/null +++ b/localstack-core/localstack/services/stepfunctions/stepfunctions_utils.py @@ -0,0 +1,69 @@ +import base64 +import logging +from typing import Dict + +from localstack.aws.api.stepfunctions import ValidationException +from localstack.aws.connect import connect_to +from localstack.utils.aws.arns import parse_arn +from localstack.utils.common import retry +from localstack.utils.strings import to_bytes, to_str + +LOG = logging.getLogger(__name__) + + +def await_sfn_execution_result(execution_arn: str, timeout_secs: int = 60) -> Dict: + """Wait until the given SFN execution ARN is no longer in RUNNING status, then return execution result.""" + + arn_data = parse_arn(execution_arn) + + client = connect_to( + aws_access_key_id=arn_data["account"], region_name=arn_data["region"] + ).stepfunctions + + def _get_result(): + result = client.describe_execution(executionArn=execution_arn) + assert result["status"] != "RUNNING" + return result + + return retry(_get_result, sleep=2, retries=timeout_secs / 2) + + +def get_next_page_token_from_arn(resource_arn: str) -> str: + return to_str(base64.b64encode(to_bytes(resource_arn))) + + +_DEFAULT_SFN_MAX_RESULTS: int = 100 + + +def normalise_max_results(max_results: int = 100) -> int: + if not max_results: + return _DEFAULT_SFN_MAX_RESULTS + return max_results + + +def assert_pagination_parameters_valid( + max_results: int, + next_token: str, + next_token_length_limit: int = 1024, + max_results_upper_limit: int = 1000, +) -> None: + validation_errors = [] + + match max_results: + case int() if max_results > max_results_upper_limit: + validation_errors.append( + f"Value '{max_results}' at 'maxResults' failed to satisfy constraint: " + f"Member must have value less than or equal to {max_results_upper_limit}" + ) + + match next_token: + case str() if len(next_token) > next_token_length_limit: + validation_errors.append( + f"Value '{next_token}' at 'nextToken' failed to satisfy constraint: " + f"Member must have length less than or equal to {next_token_length_limit}" + ) + + if validation_errors: + errors_message = "; ".join(validation_errors) + message = f"{len(validation_errors)} validation {'errors' if len(validation_errors) > 1 else 'error'} detected: {errors_message}" + raise ValidationException(message) diff --git a/localstack-core/localstack/services/stores.py b/localstack-core/localstack/services/stores.py new file mode 100644 index 0000000000000..af4d7d1b8b068 --- /dev/null +++ b/localstack-core/localstack/services/stores.py @@ -0,0 +1,346 @@ +""" +Base class and utilities for provider stores. + +Stores provide storage for AWS service providers and are analogous to Moto's BackendDict. + +By convention, Stores are to be defined in `models` submodule of the service +by subclassing BaseStore e.g. `localstack.services.sqs.models.SqsStore` +Also by convention, cross-region and cross-account attributes are declared in CAPITAL_CASE + + class SqsStore(BaseStore): + queues: dict[str, SqsQueue] = LocalAttribute(default=dict) + DELETED: dict[str, float] = CrossRegionAttribute(default=dict) + +Stores are then wrapped in AccountRegionBundle + + sqs_stores = AccountRegionBundle('sqs', SqsStore) + +Access patterns are as follows + + account_id = '001122334455' + sqs_stores[account_id] # -> RegionBundle + sqs_stores[account_id]['ap-south-1'] # -> SqsStore + sqs_stores[account_id]['ap-south-1'].queues # -> {} + +There should be a single declaration of a Store for a given service. If a service +has both Community and Pro providers, it must be declared as in Community codebase. +All Pro attributes must be declared within. + +While not recommended, store classes may define member helper functions and properties. +""" + +import re +from collections.abc import Callable +from threading import RLock +from typing import Any, Generic, Iterator, Type, TypeVar, Union + +from localstack import config +from localstack.utils.aws.aws_stack import get_valid_regions_for_service + +LOCAL_ATTR_PREFIX = "attr_" + +BaseStoreType = TypeVar("BaseStoreType") + + +# +# Descriptor protocol classes +# + + +class LocalAttribute: + """ + Descriptor protocol for marking store attributes as local to a region. + """ + + def __init__(self, default: Union[Callable, int, float, str, bool, None]): + """ + :param default: Default value assigned to the local attribute. Must be a scalar + or a callable. + """ + self.default = default + + def __set_name__(self, owner, name): + self.name = LOCAL_ATTR_PREFIX + name + + def __get__(self, obj: BaseStoreType, objtype=None) -> Any: + if not hasattr(obj, self.name): + if isinstance(self.default, Callable): + value = self.default() + else: + value = self.default + setattr(obj, self.name, value) + + return getattr(obj, self.name) + + def __set__(self, obj: BaseStoreType, value: Any): + setattr(obj, self.name, value) + + +class CrossRegionAttribute: + """ + Descriptor protocol for marking store attributes as shared across all regions. + """ + + def __init__(self, default: Union[Callable, int, float, str, bool, None]): + """ + :param default: The default value assigned to the cross-region attribute. + This must be a scalar or a callable. + """ + self.default = default + + def __set_name__(self, owner, name): + self.name = name + + def __get__(self, obj: BaseStoreType, objtype=None) -> Any: + self._check_region_store_association(obj) + + if self.name not in obj._global: + if isinstance(self.default, Callable): + obj._global[self.name] = self.default() + else: + obj._global[self.name] = self.default + + return obj._global[self.name] + + def __set__(self, obj: BaseStoreType, value: Any): + self._check_region_store_association(obj) + + obj._global[self.name] = value + + def _check_region_store_association(self, obj): + if not hasattr(obj, "_global"): + # Raise if a Store is instantiated outside of a RegionBundle + raise AttributeError( + "Could not resolve cross-region attribute because there is no associated RegionBundle" + ) + + +class CrossAccountAttribute: + """ + Descriptor protocol for marking a store attributes as shared across all regions and accounts. + + This should be used for resources that are identified by ARNs. + """ + + def __init__(self, default: Union[Callable, int, float, str, bool, None]): + """ + :param default: The default value assigned to the cross-account attribute. + This must be a scalar or a callable. + """ + self.default = default + + def __set_name__(self, owner, name): + self.name = name + + def __get__(self, obj: BaseStoreType, objtype=None) -> Any: + self._check_account_store_association(obj) + + if self.name not in obj._universal: + if isinstance(self.default, Callable): + obj._universal[self.name] = self.default() + else: + obj._universal[self.name] = self.default + + return obj._universal[self.name] + + def __set__(self, obj: BaseStoreType, value: Any): + self._check_account_store_association(obj) + + obj._universal[self.name] = value + + def _check_account_store_association(self, obj): + if not hasattr(obj, "_universal"): + # Raise if a Store is instantiated outside an AccountRegionBundle + raise AttributeError( + "Could not resolve cross-account attribute because there is no associated AccountRegionBundle" + ) + + +# +# Base models +# + + +class BaseStore: + """ + Base class for defining stores for LocalStack providers. + """ + + _service_name: str + _account_id: str + _region_name: str + _global: dict + _universal: dict + + def __repr__(self): + try: + repr_templ = "<{name} object for {service_name} at {account_id}/{region_name}>" + return repr_templ.format( + name=self.__class__.__name__, + service_name=self._service_name, + account_id=self._account_id, + region_name=self._region_name, + ) + except AttributeError: + return super().__repr__() + + +# +# Encapsulations +# + + +class RegionBundle(dict, Generic[BaseStoreType]): + """ + Encapsulation for stores across all regions for a specific AWS account ID. + """ + + def __init__( + self, + service_name: str, + store: Type[BaseStoreType], + account_id: str, + validate: bool = True, + lock: RLock = None, + universal: dict = None, + ): + self.store = store + self.account_id = account_id + self.service_name = service_name + self.validate = validate + self.lock = lock or RLock() + + self.valid_regions = get_valid_regions_for_service(service_name) + + # Keeps track of all cross-region attributes. This dict is maintained at + # a region level (hence in RegionBundle). A ref is passed to every store + # intialised in this region so that backref is possible. + self._global = {} + + # Keeps track of all cross-account attributes. This dict is maintained at + # the account level (ie. AccountRegionBundle). A ref is passed down from + # AccountRegionBundle to RegionBundle to individual stores to enable backref. + self._universal = universal + + def __getitem__(self, region_name) -> BaseStoreType: + if ( + not config.ALLOW_NONSTANDARD_REGIONS + and self.validate + and region_name not in self.valid_regions + ): + raise ValueError( + f"'{region_name}' is not a valid AWS region name for {self.service_name}" + ) + + with self.lock: + if region_name not in self.keys(): + store_obj = self.store() + + store_obj._global = self._global + store_obj._universal = self._universal + store_obj.service_name = self.service_name + store_obj._account_id = self.account_id + store_obj._region_name = region_name + + self[region_name] = store_obj + + return super().__getitem__(region_name) + + def reset(self, _reset_universal: bool = False): + """ + Clear all store data. + + This only deletes the data held in the stores. All instantiated stores + are retained. This includes data shared by all stores in this account + and marked by the CrossRegionAttribute descriptor. + + Data marked by CrossAccountAttribute descriptor is only cleared when + `_reset_universal` is set. Note that this escapes the logical boundary of + the account associated with this RegionBundle and affects *all* accounts. + Hence this argument is not intended for public use and is only used when + invoking this method from AccountRegionBundle. + """ + # For safety, clear data in all referenced store instances, if any + for store_inst in self.values(): + attrs = list(store_inst.__dict__.keys()) + for attr in attrs: + # reset the cross-region attributes + if attr == "_global": + store_inst._global.clear() + + if attr == "_universal" and _reset_universal: + store_inst._universal.clear() + + # reset the local attributes + elif attr.startswith(LOCAL_ATTR_PREFIX): + delattr(store_inst, attr) + + self._global.clear() + + with self.lock: + self.clear() + + +class AccountRegionBundle(dict, Generic[BaseStoreType]): + """ + Encapsulation for all stores for all AWS account IDs. + """ + + def __init__(self, service_name: str, store: Type[BaseStoreType], validate: bool = True): + """ + :param service_name: Name of the service. Must be a valid service defined in botocore. + :param store: Class definition of the Store + :param validate: Whether to raise if invalid region names or account IDs are used during subscription + """ + self.service_name = service_name + self.store = store + self.validate = validate + self.lock = RLock() + + # Keeps track of all cross-account attributes. This dict is maintained at + # the account level (hence in AccountRegionBundle). A ref is passed to + # every region bundle, which in turn passes it to every store in it. + self._universal = {} + + def __getitem__(self, account_id: str) -> RegionBundle[BaseStoreType]: + if self.validate and not re.match(r"\d{12}", account_id): + raise ValueError(f"'{account_id}' is not a valid AWS account ID") + + with self.lock: + if account_id not in self.keys(): + self[account_id] = RegionBundle( + service_name=self.service_name, + store=self.store, + account_id=account_id, + validate=self.validate, + lock=self.lock, + universal=self._universal, + ) + + return super().__getitem__(account_id) + + def reset(self): + """ + Clear all store data. + + This only deletes the data held in the stores. All instantiated stores are retained. + """ + # For safety, clear all referenced region bundles, if any + for region_bundle in self.values(): + region_bundle.reset(_reset_universal=True) + + self._universal.clear() + + with self.lock: + self.clear() + + def iter_stores(self) -> Iterator[tuple[str, str, BaseStoreType]]: + """ + Iterate over a flattened view of all stores in this AccountRegionBundle, where each record is a + tuple of account id, region name, and the store within that account and region. Example:: + + :return: an iterator + """ + for account_id, region_stores in self.items(): + for region_name, store in region_stores.items(): + yield account_id, region_name, store diff --git a/localstack-core/localstack/services/sts/__init__.py b/localstack-core/localstack/services/sts/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/sts/models.py b/localstack-core/localstack/services/sts/models.py new file mode 100644 index 0000000000000..de28b1e723647 --- /dev/null +++ b/localstack-core/localstack/services/sts/models.py @@ -0,0 +1,21 @@ +from typing import TypedDict + +from localstack.aws.api.sts import Tag +from localstack.services.stores import AccountRegionBundle, BaseStore, CrossRegionAttribute + + +class SessionConfig(TypedDict): + # => {"Key": , "Value": } + tags: dict[str, Tag] + # list of lowercase transitive tag keys + transitive_tags: list[str] + # other stored context variables + iam_context: dict[str, str | list[str]] + + +class STSStore(BaseStore): + # maps access key ids to tagging config for the session they belong to + sessions: dict[str, SessionConfig] = CrossRegionAttribute(default=dict) + + +sts_stores = AccountRegionBundle("sts", STSStore) diff --git a/localstack-core/localstack/services/sts/provider.py b/localstack-core/localstack/services/sts/provider.py new file mode 100644 index 0000000000000..b53e7b0a1684e --- /dev/null +++ b/localstack-core/localstack/services/sts/provider.py @@ -0,0 +1,108 @@ +import logging + +from localstack.aws.api import RequestContext, ServiceException +from localstack.aws.api.sts import ( + AssumeRoleResponse, + GetCallerIdentityResponse, + ProvidedContextsListType, + StsApi, + arnType, + externalIdType, + policyDescriptorListType, + roleDurationSecondsType, + roleSessionNameType, + serialNumberType, + sourceIdentityType, + tagKeyListType, + tagListType, + tokenCodeType, + unrestrictedSessionPolicyDocumentType, +) +from localstack.services.iam.iam_patches import apply_iam_patches +from localstack.services.moto import call_moto +from localstack.services.plugins import ServiceLifecycleHook +from localstack.services.sts.models import SessionConfig, sts_stores +from localstack.utils.aws.arns import extract_account_id_from_arn +from localstack.utils.aws.request_context import extract_access_key_id_from_auth_header + +LOG = logging.getLogger(__name__) + + +class InvalidParameterValueError(ServiceException): + code = "InvalidParameterValue" + status_code = 400 + sender_fault = True + + +class StsProvider(StsApi, ServiceLifecycleHook): + def __init__(self): + apply_iam_patches() + + def get_caller_identity(self, context: RequestContext, **kwargs) -> GetCallerIdentityResponse: + response = call_moto(context) + if "user/moto" in response["Arn"] and "sts" in response["Arn"]: + response["Arn"] = f"arn:{context.partition}:iam::{response['Account']}:root" + return response + + def assume_role( + self, + context: RequestContext, + role_arn: arnType, + role_session_name: roleSessionNameType, + policy_arns: policyDescriptorListType = None, + policy: unrestrictedSessionPolicyDocumentType = None, + duration_seconds: roleDurationSecondsType = None, + tags: tagListType = None, + transitive_tag_keys: tagKeyListType = None, + external_id: externalIdType = None, + serial_number: serialNumberType = None, + token_code: tokenCodeType = None, + source_identity: sourceIdentityType = None, + provided_contexts: ProvidedContextsListType = None, + **kwargs, + ) -> AssumeRoleResponse: + target_account_id = extract_account_id_from_arn(role_arn) + access_key_id = extract_access_key_id_from_auth_header(context.request.headers) + store = sts_stores[target_account_id]["us-east-1"] + existing_session_config = store.sessions.get(access_key_id, {}) + + if tags: + tag_keys = {tag["Key"].lower() for tag in tags} + # if the lower-cased set is smaller than the number of keys, there have to be some duplicates. + if len(tag_keys) < len(tags): + raise InvalidParameterValueError( + "Duplicate tag keys found. Please note that Tag keys are case insensitive." + ) + + # prevent transitive tags from being overridden + if existing_session_config: + if set(existing_session_config["transitive_tags"]).intersection(tag_keys): + raise InvalidParameterValueError( + "One of the specified transitive tag keys can't be set because it conflicts with a transitive tag key from the calling session." + ) + if transitive_tag_keys: + transitive_tag_key_set = {key.lower() for key in transitive_tag_keys} + if not transitive_tag_key_set <= tag_keys: + raise InvalidParameterValueError( + "The specified transitive tag key must be included in the requested tags." + ) + + response: AssumeRoleResponse = call_moto(context) + + transitive_tag_keys = transitive_tag_keys or [] + tags = tags or [] + transformed_tags = {tag["Key"].lower(): tag for tag in tags} + # propagate transitive tags + if existing_session_config: + for tag in existing_session_config["transitive_tags"]: + transformed_tags[tag] = existing_session_config["tags"][tag] + transitive_tag_keys += existing_session_config["transitive_tags"] + if transformed_tags: + # store session tagging config + access_key_id = response["Credentials"]["AccessKeyId"] + store.sessions[access_key_id] = SessionConfig( + tags=transformed_tags, + transitive_tags=[key.lower() for key in transitive_tag_keys], + iam_context={}, + ) + return response diff --git a/localstack-core/localstack/services/support/__init__.py b/localstack-core/localstack/services/support/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/support/provider.py b/localstack-core/localstack/services/support/provider.py new file mode 100644 index 0000000000000..5a31be07baf6d --- /dev/null +++ b/localstack-core/localstack/services/support/provider.py @@ -0,0 +1,7 @@ +from abc import ABC + +from localstack.aws.api.support import SupportApi + + +class SupportProvider(SupportApi, ABC): + pass diff --git a/localstack-core/localstack/services/swf/__init__.py b/localstack-core/localstack/services/swf/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/swf/provider.py b/localstack-core/localstack/services/swf/provider.py new file mode 100644 index 0000000000000..b21f71dfaa915 --- /dev/null +++ b/localstack-core/localstack/services/swf/provider.py @@ -0,0 +1,7 @@ +from abc import ABC + +from localstack.aws.api.swf import SwfApi + + +class SWFProvider(SwfApi, ABC): + pass diff --git a/localstack-core/localstack/services/transcribe/__init__.py b/localstack-core/localstack/services/transcribe/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/services/transcribe/models.py b/localstack-core/localstack/services/transcribe/models.py new file mode 100644 index 0000000000000..4f9935a310501 --- /dev/null +++ b/localstack-core/localstack/services/transcribe/models.py @@ -0,0 +1,9 @@ +from localstack.aws.api.transcribe import TranscriptionJob, TranscriptionJobName +from localstack.services.stores import AccountRegionBundle, BaseStore, LocalAttribute + + +class TranscribeStore(BaseStore): + transcription_jobs: dict[TranscriptionJobName, TranscriptionJob] = LocalAttribute(default=dict) # type: ignore[assignment] + + +transcribe_stores = AccountRegionBundle("transcribe", TranscribeStore) diff --git a/localstack-core/localstack/services/transcribe/packages.py b/localstack-core/localstack/services/transcribe/packages.py new file mode 100644 index 0000000000000..14faf968c2159 --- /dev/null +++ b/localstack-core/localstack/services/transcribe/packages.py @@ -0,0 +1,25 @@ +from typing import List + +from localstack.packages import Package +from localstack.packages.core import PythonPackageInstaller + +_VOSK_DEFAULT_VERSION = "0.3.43" + + +class VoskPackage(Package[PythonPackageInstaller]): + def __init__(self, default_version: str = _VOSK_DEFAULT_VERSION): + super().__init__(name="Vosk", default_version=default_version) + + def _get_installer(self, version: str) -> PythonPackageInstaller: + return VoskPackageInstaller(version) + + def get_versions(self) -> List[str]: + return [_VOSK_DEFAULT_VERSION] + + +class VoskPackageInstaller(PythonPackageInstaller): + def __init__(self, version: str): + super().__init__("vosk", version) + + +vosk_package = VoskPackage() diff --git a/localstack-core/localstack/services/transcribe/plugins.py b/localstack-core/localstack/services/transcribe/plugins.py new file mode 100644 index 0000000000000..78cc12751894d --- /dev/null +++ b/localstack-core/localstack/services/transcribe/plugins.py @@ -0,0 +1,9 @@ +from localstack.packages import Package, package +from localstack.packages.core import PythonPackageInstaller + + +@package(name="vosk") +def vosk_package() -> Package[PythonPackageInstaller]: + from localstack.services.transcribe.packages import vosk_package + + return vosk_package diff --git a/localstack-core/localstack/services/transcribe/provider.py b/localstack-core/localstack/services/transcribe/provider.py new file mode 100644 index 0000000000000..b0d1f62d458ed --- /dev/null +++ b/localstack-core/localstack/services/transcribe/provider.py @@ -0,0 +1,415 @@ +import datetime +import json +import logging +import threading +import wave +from functools import cache +from pathlib import Path +from typing import Any, Tuple +from zipfile import ZipFile + +from localstack import config +from localstack.aws.api import RequestContext, handler +from localstack.aws.api.transcribe import ( + BadRequestException, + ConflictException, + GetTranscriptionJobResponse, + LanguageCode, + ListTranscriptionJobsResponse, + MaxResults, + MediaFormat, + NextToken, + NotFoundException, + StartTranscriptionJobRequest, + StartTranscriptionJobResponse, + TranscribeApi, + Transcript, + TranscriptionJob, + TranscriptionJobName, + TranscriptionJobStatus, + TranscriptionJobSummary, +) +from localstack.aws.connect import connect_to +from localstack.constants import HUGGING_FACE_ENDPOINT +from localstack.packages.ffmpeg import ffmpeg_package +from localstack.services.s3.utils import ( + get_bucket_and_key_from_presign_url, + get_bucket_and_key_from_s3_uri, +) +from localstack.services.transcribe.models import TranscribeStore, transcribe_stores +from localstack.services.transcribe.packages import vosk_package +from localstack.utils.files import new_tmp_file +from localstack.utils.http import download +from localstack.utils.run import run +from localstack.utils.threads import start_thread + +# Amazon Transcribe service calls are limited to four hours (or 2 GB) per API call for our batch service. +# The streaming service can accommodate open connections up to four hours long. +# See https://aws.amazon.com/transcribe/faqs/ +MAX_AUDIO_DURATION_SECONDS = 60 * 60 * 4 + +LOG = logging.getLogger(__name__) + +VOSK_MODELS_URL = f"{HUGGING_FACE_ENDPOINT}/vosk-models/resolve/main/" + +# Map of language codes to Vosk language models +# See https://docs.aws.amazon.com/transcribe/latest/dg/supported-languages.html +LANGUAGE_MODELS = { + LanguageCode.ca_ES: "vosk-model-small-ca-0.4", + LanguageCode.cs_CZ: "vosk-model-small-cs-0.4-rhasspy", + LanguageCode.en_GB: "vosk-model-small-en-gb-0.15", + LanguageCode.en_IN: "vosk-model-small-en-in-0.4", + LanguageCode.en_US: "vosk-model-small-en-us-0.15", + LanguageCode.fa_IR: "vosk-model-small-fa-0.42", + LanguageCode.fr_FR: "vosk-model-small-fr-0.22", + LanguageCode.de_DE: "vosk-model-small-de-0.15", + LanguageCode.es_ES: "vosk-model-small-es-0.42", + LanguageCode.gu_IN: "vosk-model-small-gu-0.42", + LanguageCode.hi_IN: "vosk-model-small-hi-0.22", + LanguageCode.it_IT: "vosk-model-small-it-0.22", + LanguageCode.ja_JP: "vosk-model-small-ja-0.22", + LanguageCode.kk_KZ: "vosk-model-small-kz-0.15", + LanguageCode.ko_KR: "vosk-model-small-ko-0.22", + LanguageCode.nl_NL: "vosk-model-small-nl-0.22", + LanguageCode.pl_PL: "vosk-model-small-pl-0.22", + LanguageCode.pt_BR: "vosk-model-small-pt-0.3", + LanguageCode.ru_RU: "vosk-model-small-ru-0.22", + LanguageCode.te_IN: "vosk-model-small-te-0.42", + LanguageCode.tr_TR: "vosk-model-small-tr-0.3", + LanguageCode.uk_UA: "vosk-model-small-uk-v3-nano", + LanguageCode.uz_UZ: "vosk-model-small-uz-0.22", + LanguageCode.vi_VN: "vosk-model-small-vn-0.4", + LanguageCode.zh_CN: "vosk-model-small-cn-0.22", +} + +LANGUAGE_MODEL_DIR = Path(config.dirs.cache) / "vosk" + +# List of ffmpeg format names that correspond the supported formats by AWS +# See https://docs.aws.amazon.com/transcribe/latest/dg/how-input.html +SUPPORTED_FORMAT_NAMES = { + "amr": MediaFormat.amr, + "flac": MediaFormat.flac, + "mp3": MediaFormat.mp3, + "mov,mp4,m4a,3gp,3g2,mj2": MediaFormat.mp4, + "ogg": MediaFormat.ogg, + "matroska,webm": MediaFormat.webm, + "wav": MediaFormat.wav, +} + +# Mutex for when downloading models +_DL_LOCK = threading.Lock() + + +class TranscribeProvider(TranscribeApi): + def get_transcription_job( + self, context: RequestContext, transcription_job_name: TranscriptionJobName, **kwargs: Any + ) -> GetTranscriptionJobResponse: + store = transcribe_stores[context.account_id][context.region] + + if job := store.transcription_jobs.get(transcription_job_name): + # fetch output key and output bucket + output_bucket, output_key = get_bucket_and_key_from_presign_url( + job["Transcript"]["TranscriptFileUri"] # type: ignore[index,arg-type] + ) + job["Transcript"]["TranscriptFileUri"] = connect_to().s3.generate_presigned_url( # type: ignore[index] + "get_object", + Params={"Bucket": output_bucket, "Key": output_key}, + ExpiresIn=60 * 15, + ) + return GetTranscriptionJobResponse(TranscriptionJob=job) + + raise NotFoundException( + "The requested job couldn't be found. Check the job name and try your request again." + ) + + @staticmethod + @cache + def _setup_vosk() -> None: + # Install and configure vosk + vosk_package.install() + + from vosk import SetLogLevel # type: ignore[import-not-found] # noqa + + # Suppress Vosk logging + SetLogLevel(-1) + + @handler("StartTranscriptionJob", expand=False) + def start_transcription_job( # type: ignore[override] + self, + context: RequestContext, + request: StartTranscriptionJobRequest, + ) -> StartTranscriptionJobResponse: + job_name = request["TranscriptionJobName"] + media = request["Media"] + language_code = request.get("LanguageCode") + + if not language_code: + raise BadRequestException("Language code is missing") + + if language_code not in LANGUAGE_MODELS: + raise BadRequestException(f"Language code must be one of {LANGUAGE_MODELS.keys()}") + + store = transcribe_stores[context.account_id][context.region] + + if job_name in store.transcription_jobs: + raise ConflictException( + "The requested job name already exists. Use a different job name." + ) + + s3_path = request["Media"]["MediaFileUri"] + output_bucket = request.get("OutputBucketName", get_bucket_and_key_from_s3_uri(s3_path)[0]) # type: ignore[arg-type] + output_key = request.get("OutputKey") + + if not output_key: + output_key = f"{job_name}.json" + + s3_client = connect_to().s3 + + # the presign url is valid for 15 minutes + presign_url = s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": output_bucket, "Key": output_key}, + ExpiresIn=60 * 15, + ) + + transcript = Transcript(TranscriptFileUri=presign_url) + + job = TranscriptionJob( + TranscriptionJobName=job_name, + LanguageCode=language_code, + Media=media, + CreationTime=datetime.datetime.utcnow(), + StartTime=datetime.datetime.utcnow(), + TranscriptionJobStatus=TranscriptionJobStatus.QUEUED, + Transcript=transcript, + ) + store.transcription_jobs[job_name] = job + + start_thread(self._run_transcription_job, (store, job_name)) + + return StartTranscriptionJobResponse(TranscriptionJob=job) + + def list_transcription_jobs( + self, + context: RequestContext, + status: TranscriptionJobStatus | None = None, + job_name_contains: TranscriptionJobName | None = None, + next_token: NextToken | None = None, + max_results: MaxResults | None = None, + **kwargs: Any, + ) -> ListTranscriptionJobsResponse: + store = transcribe_stores[context.account_id][context.region] + summaries = [] + for job in store.transcription_jobs.values(): + summaries.append( + TranscriptionJobSummary( + TranscriptionJobName=job["TranscriptionJobName"], + LanguageCode=job["LanguageCode"], + CreationTime=job["CreationTime"], + StartTime=job["StartTime"], + TranscriptionJobStatus=job["TranscriptionJobStatus"], + CompletionTime=job.get("CompletionTime"), + FailureReason=job.get("FailureReason"), + ) + ) + + return ListTranscriptionJobsResponse(TranscriptionJobSummaries=summaries) + + def delete_transcription_job( + self, context: RequestContext, transcription_job_name: TranscriptionJobName, **kwargs: Any + ) -> None: + store = transcribe_stores[context.account_id][context.region] + + if transcription_job_name not in store.transcription_jobs: + raise NotFoundException( + "The requested job couldn't be found. Check the job name and try your request again." + ) + + store.transcription_jobs.pop(transcription_job_name) + + # + # Utils + # + + @staticmethod + def download_model(name: str) -> str: + """ + Download a Vosk language model to LocalStack cache directory. Do nothing if model is already downloaded. + + While can Vosk also download a model if not available locally, it saves it to a + non-configurable location ~/.cache/vosk. + """ + model_path = LANGUAGE_MODEL_DIR / name + + with _DL_LOCK: + # check if model path exists and is not empty + if model_path.exists() and any(model_path.iterdir()): + LOG.debug("Using a pre-downloaded language model: %s", model_path) + return str(model_path) + else: + model_path.mkdir(parents=True) + + model_zip_path = str(model_path) + ".zip" + + LOG.debug("Downloading language model: %s", model_path.name) + + from vosk import MODEL_PRE_URL # noqa + + download_urls = [MODEL_PRE_URL, VOSK_MODELS_URL] + + for url in download_urls: + try: + download(url + str(model_path.name) + ".zip", model_zip_path, verify_ssl=False) + except Exception as e: + LOG.warning("Failed to download model from %s: %s", url, e) + continue + break + + LOG.debug("Extracting language model: %s", model_path.name) + with ZipFile(model_zip_path, "r") as model_ref: + model_ref.extractall(model_path.parent) + + Path(model_zip_path).unlink() + + return str(model_path) + + # + # Threads + # + + def _run_transcription_job(self, args: Tuple[TranscribeStore, str]) -> None: + store, job_name = args + + job = store.transcription_jobs[job_name] + job["StartTime"] = datetime.datetime.utcnow() + job["TranscriptionJobStatus"] = TranscriptionJobStatus.IN_PROGRESS + + failure_reason = None + + try: + LOG.debug("Starting transcription: %s", job_name) + + # Get file from S3 + file_path = new_tmp_file() + s3_client = connect_to().s3 + s3_path: str = job["Media"]["MediaFileUri"] # type: ignore[index,assignment] + bucket, _, key = s3_path.removeprefix("s3://").partition("/") + s3_client.download_file(Bucket=bucket, Key=key, Filename=file_path) + + ffmpeg_package.install() + ffmpeg_bin = ffmpeg_package.get_installer().get_ffmpeg_path() + ffprobe_bin = ffmpeg_package.get_installer().get_ffprobe_path() + + LOG.debug("Determining media format") + # TODO set correct failure_reason if ffprobe execution fails + ffprobe_output = json.loads( + run( # type: ignore[arg-type] + f"{ffprobe_bin} -show_streams -show_format -print_format json -hide_banner -v error {file_path}" + ) + ) + format = ffprobe_output["format"]["format_name"] + LOG.debug("Media format detected as: %s", format) + job["MediaFormat"] = SUPPORTED_FORMAT_NAMES[format] + duration = ffprobe_output["format"]["duration"] + + if float(duration) >= MAX_AUDIO_DURATION_SECONDS: + failure_reason = "Invalid file size: file size too large. Maximum audio duration is 4.000000 hours.Check the length of the file and try your request again." + raise RuntimeError() + + # Determine the sample rate of input audio if possible + for stream in ffprobe_output["streams"]: + if stream["codec_type"] == "audio": + job["MediaSampleRateHertz"] = int(stream["sample_rate"]) + + if format in SUPPORTED_FORMAT_NAMES: + wav_path = new_tmp_file(suffix=".wav") + LOG.debug("Transcoding media to wav") + # TODO set correct failure_reason if ffmpeg execution fails + run( + f"{ffmpeg_bin} -y -nostdin -loglevel quiet -i '{file_path}' -ar 16000 -ac 1 '{wav_path}'" + ) + else: + failure_reason = f"Unsupported media format: {format}" + raise RuntimeError() + + # Check if file is valid wav + audio = wave.open(wav_path, "rb") + if ( + audio.getnchannels() != 1 + or audio.getsampwidth() != 2 + or audio.getcomptype() != "NONE" + ): + # Fail job + failure_reason = ( + "Audio file must be mono PCM WAV format. Transcoding may have failed. " + ) + raise RuntimeError() + + # Prepare transcriber + language_code: str = job["LanguageCode"] # type: ignore[assignment] + model_name = LANGUAGE_MODELS[language_code] # type: ignore[index] + self._setup_vosk() + model_path = self.download_model(model_name) + from vosk import KaldiRecognizer, Model # noqa + + model = Model(model_path=model_path, model_name=model_name) + + tc = KaldiRecognizer(model, audio.getframerate()) + tc.SetWords(True) + tc.SetPartialWords(True) + + # Start transcription + while True: + data = audio.readframes(4000) + if len(data) == 0: + break + tc.AcceptWaveform(data) + + tc_result = json.loads(tc.FinalResult()) + + # Convert to AWS format + items = [] + for unigram in tc_result["result"]: + items.append( + { + "start_time": unigram["start"], + "end_time": unigram["end"], + "type": "pronunciation", + "alternatives": [ + { + "confidence": unigram["conf"], + "content": unigram["word"], + } + ], + } + ) + output = { + "jobName": job_name, + "status": TranscriptionJobStatus.COMPLETED, + "results": { + "transcripts": [ + { + "transcript": tc_result["text"], + } + ], + "items": items, + }, + } + + # Save to S3 + output_s3_path: str = job["Transcript"]["TranscriptFileUri"] # type: ignore[index,assignment] + output_bucket, output_key = get_bucket_and_key_from_presign_url(output_s3_path) + s3_client.put_object(Bucket=output_bucket, Key=output_key, Body=json.dumps(output)) + + # Update job details + job["CompletionTime"] = datetime.datetime.utcnow() + job["TranscriptionJobStatus"] = TranscriptionJobStatus.COMPLETED + job["MediaFormat"] = MediaFormat.wav + + LOG.info("Transcription job completed: %s", job_name) + + except Exception as exc: + job["FailureReason"] = failure_reason or str(exc) + job["TranscriptionJobStatus"] = TranscriptionJobStatus.FAILED + + LOG.exception("Transcription job %s failed: %s", job_name, job["FailureReason"]) diff --git a/localstack-core/localstack/state/__init__.py b/localstack-core/localstack/state/__init__.py new file mode 100644 index 0000000000000..e7e0401f97113 --- /dev/null +++ b/localstack-core/localstack/state/__init__.py @@ -0,0 +1,19 @@ +from .core import ( + AssetDirectory, + Decoder, + Encoder, + StateContainer, + StateLifecycleHook, + StateVisitable, + StateVisitor, +) + +__all__ = [ + "StateVisitable", + "StateVisitor", + "StateLifecycleHook", + "AssetDirectory", + "StateContainer", + "Encoder", + "Decoder", +] diff --git a/localstack-core/localstack/state/core.py b/localstack-core/localstack/state/core.py new file mode 100644 index 0000000000000..ae41f47b17469 --- /dev/null +++ b/localstack-core/localstack/state/core.py @@ -0,0 +1,138 @@ +"""Core concepts of the persistence API.""" + +import io +import os +import pathlib +from typing import IO, Any, Protocol, runtime_checkable + + +class StateContainer(Protocol): + """While a StateContainer can in principle be anything, localstack currently supports by default the following + containers: + + - BackendDict (moto backend state) + - AccountRegionBundle (localstack stores) + - AssetDirectory (folders on disk) + """ + + service_name: str + + +class StateLifecycleHook: + """ + There are three well-known state manipulation operations for a service provider: + + - reset: the state within the service provider is reset, stores cleared, directories removed + - save: the state of the service provider is extracted and stored into some format (on disk, pods, ...) + - load: the state is injected into the service, or state directories on disk are restored + """ + + def on_before_state_reset(self) -> None: + """Hook triggered before the provider's state containers are reset/cleared.""" + pass + + def on_after_state_reset(self) -> None: + """Hook triggered after the provider's state containers have been reset/cleared.""" + pass + + def on_before_state_save(self) -> None: + """Hook triggered before the provider's state containers are saved.""" + pass + + def on_after_state_save(self) -> None: + """Hook triggered after the provider's state containers have been saved.""" + pass + + def on_before_state_load(self) -> None: + """Hook triggered before a previously serialized state is loaded into the provider's state containers.""" + pass + + def on_after_state_load(self) -> None: + """Hook triggered after a previously serialized state has been loaded into the provider's state containers.""" + pass + + +class StateVisitor: + def visit(self, state_container: StateContainer): + """ + Visit (=do something with) a given state container. A state container can be anything that holds service state. + An AccountRegionBundle, a moto BackendDict, or a directory containing assets. + """ + raise NotImplementedError + + +@runtime_checkable +class StateVisitable(Protocol): + def accept_state_visitor(self, visitor: StateVisitor): + """ + Accept a StateVisitor. The implementing method should call visit not necessarily on itself, but can also call + the visit method on the state container it holds. The common case is calling visit on the stores of a provider. + :param visitor: the StateVisitor + """ + + +class AssetDirectory: + """ + A state container manifested as a directory on the file system. + """ + + service_name: str + path: pathlib.Path + + def __init__(self, service_name: str, path: str | os.PathLike): + if not service_name: + raise ValueError("service name must be set") + + if not path: + raise ValueError("path must be set") + + if not isinstance(path, os.PathLike): + path = pathlib.Path(path) + + self.service_name = service_name + self.path = path + + def __str__(self) -> str: + return str(self.path) + + +class Encoder: + def encodes(self, obj: Any) -> bytes: + """ + Encode an object into bytes. + + :param obj: the object to encode + :return: the encoded object + """ + b = io.BytesIO() + self.encode(obj, b) + return b.getvalue() + + def encode(self, obj: Any, file: IO[bytes]): + """ + Encode an object into bytes. + + :param obj: the object to encode + :param file: the file to write the encoded data into + """ + raise NotImplementedError + + +class Decoder: + def decodes(self, data: bytes) -> Any: + """ + Decode a previously encoded object. + + :param data: the encoded object to decode + :return: the decoded object + """ + return self.decode(io.BytesIO(data)) + + def decode(self, file: IO[bytes]) -> Any: + """ + Decode a previously encoded object. + + :param file: the io object containing the object to decode + :return: the decoded object + """ + raise NotImplementedError diff --git a/localstack-core/localstack/state/inspect.py b/localstack-core/localstack/state/inspect.py new file mode 100644 index 0000000000000..f5b10c6e3e2e4 --- /dev/null +++ b/localstack-core/localstack/state/inspect.py @@ -0,0 +1,112 @@ +"""Utilities to inspect services and their state containers.""" + +import importlib +import logging +from functools import singledispatchmethod +from typing import Any, Dict, Optional, TypedDict + +from moto.core.base_backend import BackendDict + +from localstack.services.stores import AccountRegionBundle +from localstack.state.core import StateVisitor + +LOG = logging.getLogger(__name__) + + +class ServiceBackend(TypedDict, total=False): + """Wrapper of the possible type of backends that a service can use.""" + + localstack: AccountRegionBundle | None + moto: BackendDict | Dict | None + + +class ServiceBackendCollectorVisitor(StateVisitor): + """Implementation of StateVisitor meant to collect the backends that a given service use to hold its state.""" + + store: AccountRegionBundle | None + backend_dict: BackendDict | Dict | None + + def __init__(self) -> None: + self.store = None + self.backend_dict = None + + @singledispatchmethod + def visit(self, state_container: Any): + raise NotImplementedError("Can't restore state container of type %s", type(state_container)) + + @visit.register(AccountRegionBundle) + def _(self, state_container: AccountRegionBundle): + self.store = state_container + + @visit.register(BackendDict) + def _(self, state_container: BackendDict): + self.backend_dict = state_container + + def collect(self) -> ServiceBackend: + service_backend = ServiceBackend() + if self.store: + service_backend.update({"localstack": self.store}) + if self.backend_dict: + service_backend.update({"moto": self.backend_dict}) + return service_backend + + +class ReflectionStateLocator: + """ + Implementation of the StateVisitable protocol that uses reflection to visit and collect anything that hold state + for a service, based on the assumption that AccountRegionBundle and BackendDict are stored in a predictable + location with a predictable naming. + """ + + provider: Any + + def __init__(self, provider: Optional[Any] = None, service: Optional[str] = None): + self.provider = provider + self.service = service or provider.service + + def accept_state_visitor(self, visitor: StateVisitor): + # needed for services like cognito-idp + service_name: str = self.service.replace("-", "_") + LOG.debug("Visit stores for %s", service_name) + + # try to load AccountRegionBundle from predictable location + attribute_name = f"{service_name}_stores" + module_name = f"localstack.pro.core.services.{service_name}.models" + + # it first looks for a module in ext; eventually, it falls back to community + attribute = _load_attribute_from_module(module_name, attribute_name) + if attribute is None: + module_name = f"localstack.services.{service_name}.models" + attribute = _load_attribute_from_module(module_name, attribute_name) + + if attribute is not None: + visitor.visit(attribute) + + # try to load BackendDict from predictable location + module_name = f"moto.{service_name}.models" + attribute_name = f"{service_name}_backends" + attribute = _load_attribute_from_module(module_name, attribute_name) + + if attribute is None and "_" in attribute_name: + # some services like application_autoscaling do have a backend without the underscore + service_name_tmp = service_name.replace("_", "") + module_name = f"moto.{service_name_tmp}.models" + attribute_name = f"{service_name_tmp}_backends" + attribute = _load_attribute_from_module(module_name, attribute_name) + + if attribute is not None: + visitor.visit(attribute) + + +def _load_attribute_from_module(module_name: str, attribute_name: str) -> Any | None: + """ + Attempts at getting an attribute from a given module. + :return the attribute or None, if the attribute can't be found + """ + try: + module = importlib.import_module(module_name) + attr = getattr(module, attribute_name) + LOG.debug("Found attribute %s in module %s", attribute_name, module_name) + return attr + except (ModuleNotFoundError, AttributeError): + return None diff --git a/localstack-core/localstack/state/pickle.py b/localstack-core/localstack/state/pickle.py new file mode 100644 index 0000000000000..1b4535a5f5ca3 --- /dev/null +++ b/localstack-core/localstack/state/pickle.py @@ -0,0 +1,348 @@ +""" +A small wrapper around dill that integrates with our state API, and allows registering custom serializer methods for +class hierarchies. + +For your convenience, you can simply call ``dumps`` or ``loads`` as you would pickle or dill:: + + from localstack.state import pickle + foo = pickle.loads(pickle.dumps(Foo())) + + +You can register custom state serializers and deserializers to dill's dispatch table, but can also apply them to the +entire subclass hierarchy:: + + @register(PriorityQueue, subclasses=True) + def my_queue_pickler(pickler, obj): + pickler.save_reduce(_recreate, (type(obj), obj.queue,), obj=obj) + + def _recreate(obj_type, obj_queue): + # this method will be called when the object is de-serialized. you won't be able to reach it with the + # debugger though, it's saved into the pickle! Make sure it's outside the actual reduce hook, otherwise a new + # function is created every time for every serialized object of that type. + + q = obj_type() + q.queue = obj_queue + return q + +To learn more about this mechanism, read https://docs.python.org/3/library/copyreg.html and +https://dill.readthedocs.io/en/latest/index.html?highlight=register#dill.Pickler.dispatch. +""" + +import inspect +from typing import Any, BinaryIO, Callable, Generic, Type, TypeVar + +import dill +from dill._dill import MetaCatchingDict + +from .core import Decoder, Encoder + +_T = TypeVar("_T") + +PythonPickler = Any +"""Type placeholder for pickle._Pickler (which has for instance the save_reduce method)""" + + +def register(cls: Type = None, subclasses: bool = False): + """ + Decorator to register a custom type or type tree into the dill pickling dispatcher table. + + :param cls: the type + :param subclasses: whether to dispatch all subclasses to this function as well + :return: + """ + + def _wrapper(fn: Any | Callable[[PythonPickler, Any], None]): + if inspect.isclass(fn) and issubclass(fn, ObjectStateReducer): + if cls is not None: + raise ValueError("superfluous cls attribute for registering classes") + obj = fn.create() + add_dispatch_entry(obj.cls, obj._pickle, subclasses) + elif callable(fn): + add_dispatch_entry(cls, fn, subclasses=subclasses) + else: + raise ValueError("cannot register %s" % fn) + + return fn + + return _wrapper + + +def reducer(cls: Type, restore: Callable = None, subclasses: bool = False): + """ + Convenience decorator to simplify the following pattern:: + + def _create_something(attr1, attr2): + return Something(attr1, attr2) + + @register(Something) + def pickle_something(pickler, obj): + attr1 = obj.attr1 + attr2 = obj.attr2 + return pickler.save_reduce(_create_something, (attr1, attr2), obj=obj) + + into:: + + def _create_something(attr1, attr2): + return Something(attr1, attr2) + + @reducer(Something, _create_something) + def pickle_something(pickler, obj): + return obj.attr1, obj.attr2 + + in some cases, if your constructor matches the arguments you return, into:: + + @reducer(Something) + def pickle_something(pickler, obj): + return obj.attr1, obj.attr2 + + Note that this option creates larger pickles than the previous option, since this option also needs to store the + ``Something`` class into the pickle. + + :param cls: + :param restore: + :param subclasses: + :return: + """ + + def _wrapper(fn): + def _reducer(pickler, obj): + return pickler.save_reduce(restore or cls, fn(obj), obj=obj) + + add_dispatch_entry(cls, _reducer, subclasses) + return fn + + return _wrapper + + +def add_dispatch_entry( + cls: Type, fn: Callable[[PythonPickler, Any], None], subclasses: bool = False +): + Pickler.dispatch_overwrite[cls] = fn + if subclasses: + Pickler.match_subclasses_of.add(cls) + + +def remove_dispatch_entry(cls: Type): + try: + del Pickler.dispatch_overwrite[cls] + except KeyError: + pass + + try: + Pickler.match_subclasses_of.remove(cls) + except KeyError: + pass + + +def dumps(obj: Any) -> bytes: + """ + Pickle an object into bytes using a ``PickleEncoder``. + + :param obj: the object to pickle + :return: the pickled object + """ + return PickleEncoder().encodes(obj) + + +def dump(obj: Any, file: BinaryIO): + """ + Pickle an object into a buffer using a ``PickleEncoder``. + + :param obj: the object to pickle + :param file: the IO buffer + """ + return PickleEncoder().encode(obj, file) + + +def loads(data: bytes) -> Any: + """ + Unpickle am object from bytes using a ``PickleDecoder``. + + :param data: the pickled object + :return: the unpickled object + """ + return PickleDecoder().decodes(data) + + +def load(file: BinaryIO) -> Any: + """ + Unpickle am object from a buffer using a ``PickleDecoder``. + + :param file: the buffer containing the pickled object + :return: the unpickled object + """ + return PickleDecoder().decode(file) + + +class _SuperclassMatchingTypeDict(MetaCatchingDict): + """ + A special dictionary where keys are types, and keys are also optionally matched on their subclasses. Types where + subclass matching should happen can be registered through the ``dispatch_subclasses_of`` property. Example:: + + d = _SuperclassMatchingTypeDict() + d[dict] = "a dict" + d[defaultdict] # raises key error + d.match_subclasses_of.add(dict) + d[defaultdict] # returns "a dict" + + """ + + def __init__(self, seq=None, match_subclasses_of: set[Type] = None): + if seq is not None: + super().__init__(seq) + else: + super().__init__() + + self.match_subclasses_of = match_subclasses_of or set() + + def __missing__(self, key): + for c in key.__mro__[1:]: + # traverse the superclasses upwards until a dispatcher is found + if c not in self.match_subclasses_of: + continue + + if fn := super().get(c): + return fn + + return super().__missing__(key) + + +class Pickler(dill.Pickler): + """ + Custom dill pickler that considers dispatchers and subclass dispatchers registered via ``register``. + """ + + match_subclasses_of: set[Type] = set() + dispatch_overwrite: dict[Type, Callable] = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # create the dispatch table (inherit the dill dispatchers) + dispatch = _SuperclassMatchingTypeDict(dill.Pickler.dispatch.copy()) + dispatch.update(Pickler.dispatch_overwrite.copy()) # makes sure ours take precedence + dispatch.match_subclasses_of.update(Pickler.match_subclasses_of.copy()) + self.dispatch = dispatch + + +class PickleEncoder(Encoder): + """ + An Encoder that use a dill pickling under the hood, and by default uses the custom ``Pickler`` that can be + extended with custom serializers. + """ + + pickler_class: Type[dill.Pickler] + + def __init__(self, pickler_class: Type[dill.Pickler] = None): + self.pickler_class = pickler_class or Pickler + + def encode(self, obj: Any, file: BinaryIO): + return self.pickler_class(file).dump(obj) + + +class PickleDecoder(Decoder): + """ + A Decoder that use a dill pickling under the hood, and by default uses the custom ``Unpickler`` that can be + extended with custom serializers. + """ + + unpickler_class: Type[dill.Unpickler] + + def __init__(self, unpickler_class: Type[dill.Unpickler] = None): + self.unpickler_class = unpickler_class or dill.Unpickler + + def decode(self, file: BinaryIO) -> Any: + return self.unpickler_class(file).load() + + +class ObjectStateReducer(Generic[_T]): + """ + A generalization of the following pattern:: + + def _create_something(cls: Type, state: dict): + obj = cls.__new__(self.cls) + + # do stuff on the state (perhaps re-create some attributes) + state["this_one_doesnt_serialize"] = restore(state["this_one_serialized"]) + + obj.__dict__.update(state) + return obj + + @register(Something) + def pickle_something(pickler, obj): + state = obj.__dict__.copy() + state.pop("this_one_doesnt_serialize") + return pickler.save_reduce(_create_something, (state,), obj=obj) + + + With the ObjectStateReducer, this can now be expressed as: + + @register() + class SomethingPickler(ObjectStatePickler): + cls = Something + + def prepare(state: dict): + state.pop("this_one_doesnt_serialize") + + def restore(state: dict): + state["this_one_doesnt_serialize"] = restore(state["this_one_serialized"]) + """ + + cls: _T + + @classmethod + def create(cls): + return cls() + + def register(self, subclasses=False): + """ + Registers this ObjectStateReducer's reducer function. See ``pickle.register``. + """ + add_dispatch_entry(self.cls, self._pickle, subclasses=subclasses) + + def _pickle(self, pickler, obj: _T): + state = self.get_state(obj) + self.prepare(obj, state) + return pickler.save_reduce(self._unpickle, (state,), obj=obj) + + def _unpickle(self, state: dict) -> dict: + obj = self.cls.__new__(self.cls) + self.restore(obj, state) + self.set_state(obj, state) + return obj + + def get_state(self, obj: _T) -> Any: + """ + Return the objects state. Can be overwritten by subclasses to return custom state. + + :param obj: the object + :return: the unprepared state + """ + return obj.__dict__.copy() + + def set_state(self, obj: _T, state: Any): + """ + Set the state of the object. Can be overwritten by subclasses to set custom state. + + :param obj: the object + :param state: the restored object state. + """ + obj.__dict__.update(state) + + def prepare(self, obj: _T, state: Any): + """ + Can be overwritten by subclasses to prepare the object state for pickling. + + :param obj: the object + :param state: the object state to serialize + """ + pass + + def restore(self, obj: _T, state: Any): + """ + Can be overwritten by subclasses to modify the object state to restore any previously removed attributes. + + :param obj: the object + :param state: the object's state to restore + """ + pass diff --git a/localstack-core/localstack/state/snapshot.py b/localstack-core/localstack/state/snapshot.py new file mode 100644 index 0000000000000..9f936fb280dda --- /dev/null +++ b/localstack-core/localstack/state/snapshot.py @@ -0,0 +1,22 @@ +from plux import Plugin + +from .core import StateVisitor + + +class SnapshotPersistencePlugin(Plugin): + """ + A plugin for the snapshot persistence mechanism, which allows you to return custom visitors for saving or loading + state, if the service has a particular logic. + """ + + namespace: str = "localstack.persistence.snapshot" + """Plugin namespace""" + + name: str + """Name of the plugin corresponds to the name of the service this plugin is loaded for. To be set by the Plugin.""" + + def create_load_snapshot_visitor(self, service: str, data_dir: str) -> StateVisitor: + raise NotImplementedError + + def create_save_snapshot_visitor(self, service: str, data_dir: str) -> StateVisitor: + raise NotImplementedError diff --git a/localstack-core/localstack/testing/__init__.py b/localstack-core/localstack/testing/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/aws/__init__.py b/localstack-core/localstack/testing/aws/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/aws/asf_utils.py b/localstack-core/localstack/testing/aws/asf_utils.py new file mode 100644 index 0000000000000..33035496ebf2f --- /dev/null +++ b/localstack-core/localstack/testing/aws/asf_utils.py @@ -0,0 +1,177 @@ +import importlib +import importlib.util +import inspect +import pkgutil +import re +from types import FunctionType, ModuleType, NoneType, UnionType +from typing import Optional, Pattern, Union, get_args, get_origin + + +def _import_submodules( + package_name: str, module_regex: Optional[Pattern] = None, recursive: bool = True +) -> dict[str, ModuleType]: + """ + Imports all submodules of the given package with the defined (optional) module_suffix. + + :param package_name: To start the loading / importing at + :param module_regex: Optional regex to filter the module names for + :param recursive: True if the package should be loaded recursively + :return: + """ + package = importlib.import_module(package_name) + results = {} + for loader, name, is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + "."): + if not module_regex or module_regex.match(name): + results[name] = importlib.import_module(name) + if recursive and is_pkg: + results.update(_import_submodules(name, module_regex, recursive)) + return results + + +def _collect_provider_classes( + provider_module: str, provider_module_regex: Pattern, provider_class_regex: Pattern +) -> list[type]: + """ + Collects all provider implementation classes which should be tested. + :param provider_module: module to start collecting in + :param provider_module_regex: Regex to filter the module names for + :param provider_class_regex: Regex to filter the provider class names for + :return: list of classes to check the operation signatures of + """ + provider_classes = [] + provider_modules = _import_submodules(provider_module, provider_module_regex) + # check that all these files don't import any encrypted code + for _, mod in provider_modules.items(): + # get all classes of the module which end with "Provider" + classes = [ + cls_obj + for cls_name, cls_obj in inspect.getmembers(mod) + if inspect.isclass(cls_obj) and provider_class_regex.match(cls_name) + ] + provider_classes.extend(classes) + return provider_classes + + +def collect_implemented_provider_operations( + provider_module: str = "localstack.services", + provider_module_regex: Pattern = re.compile(r".*\.provider[A-Za-z_0-9]*$"), + provider_class_regex: Pattern = re.compile(r".*Provider$"), + asf_api_module: str = "localstack.aws.api", +) -> list[tuple[type, type, str]]: + """ + Collects all implemented operations on all provider classes together with their base classes (generated API classes). + :param provider_module: module to start collecting in + :param provider_module_regex: Regex to filter the module names for + :param provider_class_regex: Regex to filter the provider class names for + :param asf_api_module: module which contains the generated ASF APIs + :return: list of tuple, where each tuple is (provider_class: type, base_class: type, provider_function: str) + """ + results = [] + provider_classes = _collect_provider_classes( + provider_module, provider_module_regex, provider_class_regex + ) + for provider_class in provider_classes: + for base_class in provider_class.__bases__: + base_parent_module = ".".join(base_class.__module__.split(".")[:-1]) + if base_parent_module == asf_api_module: + # find all functions on the provider class which are also defined in the super class and are not dunder functions + provider_functions = [ + method + for method in dir(provider_class) + if hasattr(base_class, method) + and isinstance(getattr(base_class, method), FunctionType) + and method.startswith("__") is False + ] + for provider_function in provider_functions: + results.append((provider_class, base_class, provider_function)) + return results + + +def check_provider_signature(sub_class: type, base_class: type, method_name: str) -> None: + """ + Checks if the signature of a given provider method is equal to the signature of the function with the same name on the base class. + + :param sub_class: provider class to check the given method's signature of + :param base_class: API class to check the given method's signature against + :param method_name: name of the method on the sub_class and base_class to compare + :raise: AssertionError if the two signatures are not equal + """ + try: + sub_function = getattr(sub_class, method_name) + except AttributeError: + raise AttributeError( + f"Given method name ('{method_name}') is not a method of the sub class ('{sub_class.__name__}')." + ) + + if not isinstance(sub_function, FunctionType): + raise AttributeError( + f"Given method name ('{method_name}') is not a method of the sub class ('{sub_class.__name__}')." + ) + + if not getattr(sub_function, "expand_parameters", True): + # if the operation on the subclass has the "expand_parameters" attribute (it has a handler decorator) set to False, we don't care + return + + if wrapped := getattr(sub_function, "__wrapped__", False): + # if the operation on the subclass has a decorator, unwrap it + sub_function = wrapped + + try: + base_function = getattr(base_class, method_name) + # unwrap from the handler decorator + base_function = base_function.__wrapped__ + + sub_spec = inspect.getfullargspec(sub_function) + base_spec = inspect.getfullargspec(base_function) + + error_msg = f"{sub_class.__name__}#{method_name} breaks with {base_class.__name__}#{method_name}. This can also be caused by 'from __future__ import annotations' in a provider file!" + + # Assert that the signature is correct + assert sub_spec.args == base_spec.args, error_msg + assert sub_spec.varargs == base_spec.varargs, error_msg + assert sub_spec.varkw == base_spec.varkw, error_msg + assert sub_spec.defaults == base_spec.defaults, ( + error_msg + f"\n{sub_spec.defaults} != {base_spec.defaults}" + ) + assert sub_spec.kwonlyargs == base_spec.kwonlyargs, error_msg + assert sub_spec.kwonlydefaults == base_spec.kwonlydefaults, error_msg + + # Assert that the typing of the implementation is equal to the base + for kwarg in sub_spec.annotations: + if kwarg == "return": + assert sub_spec.annotations[kwarg] == base_spec.annotations[kwarg] + else: + # The API currently marks everything as required, and optional args are configured as: + # arg: ArgType = None + # which is obviously incorrect. + # Implementations sometimes do this correctly: + # arg: ArgType | None = None + # These should be considered equal, so until the API is fixed, we remove any Optionals + # This also gives us the flexibility to correct the API without fixing all implementations at the same time + + if kwarg not in base_spec.annotations: + # Typically happens when the implementation uses '**kwargs: Any' + # This parameter is not part of the base spec, so we can't compare types + continue + + sub_type = _remove_optional(sub_spec.annotations[kwarg]) + base_type = _remove_optional(base_spec.annotations[kwarg]) + assert sub_type == base_type, ( + f"Types for {kwarg} are different - {sub_type} instead of {base_type}" + ) + + except AttributeError: + # the function is not defined in the superclass + pass + + +def _remove_optional(_type: type) -> list[type]: + if get_origin(_type) in [Union, UnionType]: + union_types = list(get_args(_type)) + try: + union_types.remove(NoneType) + except ValueError: + # Union of some other kind, like 'str | int' + pass + return union_types + return [_type] diff --git a/localstack-core/localstack/testing/aws/cloudformation_utils.py b/localstack-core/localstack/testing/aws/cloudformation_utils.py new file mode 100644 index 0000000000000..0ef513dec8456 --- /dev/null +++ b/localstack-core/localstack/testing/aws/cloudformation_utils.py @@ -0,0 +1,44 @@ +import os +import pathlib + +import jinja2 + +from localstack.utils.common import load_file + + +def load_template_file(file_path: str | os.PathLike, *, path_ctx: str | os.PathLike = None) -> str: + """ + Load a cloudformation file (YAML or JSON) + + Note this now requires providing the file_path as a proper structured object. + In turn this makes it easier to find proper templates by using IDE autocomplete features when selecting templates + + :param file_path: path to file + :param path_ctx: *must* be provided if file_path is not an absolute path + + :returns default encoded string representation of file contents + """ + + file_path_obj = pathlib.Path(file_path) + + if file_path_obj.suffix not in [".yaml", ".yml", ".json"]: + raise ValueError("Unsupported suffix for template file") + + if path_ctx is not None: + file_path_obj = file_path_obj.relative_to(path_ctx) + elif not file_path_obj.is_absolute(): + raise ValueError("Provided path must be absolute if no path_ctx is provided") + + return load_file(file_path_obj.absolute()) + + +# TODO: TBH this utility really doesn't add anything, probably better to just remove it +def render_template(template_body: str, **template_vars) -> str: + """render a template with jinja""" + if template_vars: + template_body = jinja2.Template(template_body).render(**template_vars) + return template_body + + +def load_template_raw(path: str): + return load_template_file(path) diff --git a/localstack-core/localstack/testing/aws/eventbus_utils.py b/localstack-core/localstack/testing/aws/eventbus_utils.py new file mode 100644 index 0000000000000..5da33a9354fbf --- /dev/null +++ b/localstack-core/localstack/testing/aws/eventbus_utils.py @@ -0,0 +1,54 @@ +import json + +import requests + +from localstack import config +from localstack.testing.aws.util import is_aws_cloud +from localstack.utils.aws.client_types import TypedServiceClientFactory + + +def trigger_scheduled_rule(rule_arn: str): + """ + Call the internal /_aws/events/rules//trigger endpoint to expire the deadline of a rule and + trigger it ASAP. + + :param rule_arn: the rule to run + :raises ValueError: if the response return a >=400 code + """ + if is_aws_cloud(): + return + + url = config.internal_service_url() + f"/_aws/events/rules/{rule_arn}/trigger" + response = requests.get(url) + if not response.ok: + raise ValueError( + f"Error triggering rule {rule_arn}: {response.status_code},{response.text}" + ) + + +def allow_event_rule_to_sqs_queue( + aws_client: TypedServiceClientFactory, + sqs_queue_url: str, + sqs_queue_arn: str, + event_rule_arn: str, +): + """Creates an SQS Queue Policy that allows te given eventbus rule to write tho the given sqs queue.""" + return aws_client.sqs.set_queue_attributes( + QueueUrl=sqs_queue_url, + Attributes={ + "Policy": json.dumps( + { + "Statement": [ + { + "Sid": "AllowEventsToQueue", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": sqs_queue_arn, + "Condition": {"ArnEquals": {"aws:SourceArn": event_rule_arn}}, + } + ] + } + ) + }, + ) diff --git a/localstack-core/localstack/testing/aws/lambda_utils.py b/localstack-core/localstack/testing/aws/lambda_utils.py new file mode 100644 index 0000000000000..764605f46962a --- /dev/null +++ b/localstack-core/localstack/testing/aws/lambda_utils.py @@ -0,0 +1,342 @@ +import itertools +import json +import logging +import os +import subprocess +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING, Literal, Mapping, Optional, Sequence, overload + +from localstack import config +from localstack.services.lambda_.runtimes import RUNTIMES_AGGREGATED +from localstack.utils.files import load_file +from localstack.utils.platform import Arch, get_arch +from localstack.utils.strings import short_uid +from localstack.utils.sync import ShortCircuitWaitException, retry +from localstack.utils.testutil import get_lambda_log_events + +if TYPE_CHECKING: + from mypy_boto3_lambda import LambdaClient + from mypy_boto3_lambda.literals import ArchitectureType, PackageTypeType, RuntimeType + from mypy_boto3_lambda.type_defs import ( + DeadLetterConfigTypeDef, + EnvironmentTypeDef, + EphemeralStorageTypeDef, + FileSystemConfigTypeDef, + FunctionCodeTypeDef, + FunctionConfigurationResponseMetadataTypeDef, + ImageConfigTypeDef, + TracingConfigTypeDef, + VpcConfigTypeDef, + ) + +LOG = logging.getLogger(__name__) + +HANDLERS = { + **dict.fromkeys(RUNTIMES_AGGREGATED.get("nodejs"), "index.handler"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("python"), "handler.handler"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("java"), "echo.Handler"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("ruby"), "function.handler"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("dotnet"), "dotnet::Dotnet.Function::FunctionHandler"), + # The handler value does not matter unless the custom runtime reads it in some way, but it is a required field. + **dict.fromkeys(RUNTIMES_AGGREGATED.get("provided"), "function.handler"), +} + +PACKAGE_FOR_RUNTIME = { + **dict.fromkeys(RUNTIMES_AGGREGATED.get("nodejs"), "nodejs"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("python"), "python"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("java"), "java"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("ruby"), "ruby"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("dotnet"), "dotnet"), + **dict.fromkeys(RUNTIMES_AGGREGATED.get("provided"), "provided"), +} + + +def generate_tests(metafunc): + i = next(metafunc.definition.iter_markers("multiruntime"), None) + if not i: + return + if i.args: + raise ValueError("doofus") + + scenario = i.kwargs["scenario"] + runtimes = i.kwargs.get("runtimes") + if not runtimes: + runtimes = list(RUNTIMES_AGGREGATED.keys()) + ids = list( + itertools.chain.from_iterable( + RUNTIMES_AGGREGATED.get(runtime) or [runtime] for runtime in runtimes + ) + ) + arg_values = [(scenario, runtime, HANDLERS[runtime]) for runtime in ids] + + metafunc.parametrize( + argvalues=arg_values, + argnames="multiruntime_lambda", + indirect=True, + ids=ids, + ) + + +def package_for_lang(scenario: str, runtime: str, root_folder: Path) -> str: + """ + :param scenario: which scenario to run + :param runtime: which runtime to build + :param root_folder: The root folder for the scenarios + :return: path to built zip file + """ + runtime_folder = PACKAGE_FOR_RUNTIME[runtime] + + common_dir = root_folder / "functions" / "common" + scenario_dir = common_dir / scenario + runtime_dir_candidate = scenario_dir / runtime + generic_runtime_dir_candidate = scenario_dir / runtime_folder + + # if a more specific folder exists, use that one + # otherwise: try to fall back to generic runtime (e.g. python for python3.12) + if runtime_dir_candidate.exists() and runtime_dir_candidate.is_dir(): + runtime_dir = runtime_dir_candidate + else: + runtime_dir = generic_runtime_dir_candidate + + build_dir = runtime_dir / "build" + package_path = runtime_dir / "handler.zip" + + # caching step + # TODO: add invalidation (e.g. via storing a hash besides this of all files in src) + if os.path.exists(package_path) and os.path.isfile(package_path): + return package_path + + # packaging + # Use the default Lambda architecture x86_64 unless the ignore architecture flag is configured. + # This enables local testing of both architectures on multi-architecture platforms such as Apple Silicon machines. + architecture = "x86_64" + if config.LAMBDA_IGNORE_ARCHITECTURE: + architecture = "arm64" if get_arch() == Arch.arm64 else "x86_64" + build_cmd = ["make", "build", f"ARCHITECTURE={architecture}"] + LOG.debug( + "Building Lambda function for scenario %s and runtime %s using %s.", + scenario, + runtime, + " ".join(build_cmd), + ) + result = subprocess.run(build_cmd, cwd=runtime_dir) + if result.returncode != 0: + raise Exception( + f"Failed to build multiruntime {scenario=} for {runtime=} with error code: {result.returncode}" + ) + + # check again if the zip file is now present + if os.path.exists(package_path) and os.path.isfile(package_path): + return package_path + + # check something is in build now + target_empty = len(os.listdir(build_dir)) <= 0 + if target_empty: + raise Exception(f"Failed to build multiruntime {scenario=} for {runtime=} ") + + with zipfile.ZipFile(package_path, "w", strict_timestamps=True) as zf: + for root, dirs, files in os.walk(build_dir): + rel_dir = os.path.relpath(root, build_dir) + for f in files: + zf.write(os.path.join(root, f), arcname=os.path.join(rel_dir, f)) + + # make sure package file has been generated + assert package_path.exists() and package_path.is_file() + return package_path + + +class ParametrizedLambda: + lambda_client: "LambdaClient" + function_names: list[str] + scenario: str + runtime: str + handler: str + zip_file_path: str + role: str + + def __init__( + self, + lambda_client: "LambdaClient", + scenario: str, + runtime: str, + handler: str, + zip_file_path: str, + role: str, + ): + self.function_names = [] + self.lambda_client = lambda_client + self.scenario = scenario + self.runtime = runtime + self.handler = handler + self.zip_file_path = zip_file_path + self.role = role + + @overload + def create_function( + self, + *, + FunctionName: Optional[str] = None, + Role: Optional[str] = None, + Code: Optional["FunctionCodeTypeDef"] = None, + Runtime: Optional["RuntimeType"] = None, + Handler: Optional[str] = None, + Description: Optional[str] = None, + Timeout: Optional[int] = None, + MemorySize: Optional[int] = None, + Publish: Optional[bool] = None, + VpcConfig: Optional["VpcConfigTypeDef"] = None, + PackageType: Optional["PackageTypeType"] = None, + DeadLetterConfig: Optional["DeadLetterConfigTypeDef"] = None, + Environment: Optional["EnvironmentTypeDef"] = None, + KMSKeyArn: Optional[str] = None, + TracingConfig: Optional["TracingConfigTypeDef"] = None, + Tags: Optional[Mapping[str, str]] = None, + Layers: Optional[Sequence[str]] = None, + FileSystemConfigs: Optional[Sequence["FileSystemConfigTypeDef"]] = None, + ImageConfig: Optional["ImageConfigTypeDef"] = None, + CodeSigningConfigArn: Optional[str] = None, + Architectures: Optional[Sequence["ArchitectureType"]] = None, + EphemeralStorage: Optional["EphemeralStorageTypeDef"] = None, + ) -> "FunctionConfigurationResponseMetadataTypeDef": ... + + def create_function(self, **kwargs): + kwargs.setdefault("FunctionName", f"{self.scenario}-{short_uid()}") + kwargs.setdefault("Runtime", self.runtime) + kwargs.setdefault("Handler", self.handler) + kwargs.setdefault("Role", self.role) + kwargs.setdefault("Code", {"ZipFile": load_file(self.zip_file_path, mode="rb")}) + + def _create_function(): + return self.lambda_client.create_function(**kwargs) + + # @AWS, takes about 10s until the role/policy is "active", until then it will fail + # localstack should normally not require the retries and will just continue here + result = retry(_create_function, retries=3, sleep=4) + self.function_names.append(result["FunctionArn"]) + self.lambda_client.get_waiter("function_active_v2").wait( + FunctionName=kwargs.get("FunctionName") + ) + + return result + + def destroy(self): + for function_name in self.function_names: + try: + self.lambda_client.delete_function(FunctionName=function_name) + except Exception as e: + LOG.debug("Error deleting function %s: %s", function_name, e) + + +def update_done(client, function_name): + """wait fn for checking 'LastUpdateStatus' of lambda""" + + def _update_done(): + last_update_status = client.get_function_configuration(FunctionName=function_name)[ + "LastUpdateStatus" + ] + if last_update_status == "Failed": + raise ShortCircuitWaitException(f"Lambda Config update failed: {last_update_status=}") + else: + return last_update_status == "Successful" + + return _update_done + + +def concurrency_update_done(client, function_name, qualifier): + """wait fn for ProvisionedConcurrencyConfig 'Status'""" + + def _concurrency_update_done(): + status = client.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=qualifier + )["Status"] + if status == "FAILED": + raise ShortCircuitWaitException(f"Concurrency update failed: {status=}") + else: + return status == "READY" + + return _concurrency_update_done + + +def get_invoke_init_type( + client, function_name, qualifier +) -> Literal["on-demand", "provisioned-concurrency"]: + """check the environment in the lambda for AWS_LAMBDA_INITIALIZATION_TYPE indicating ondemand/provisioned""" + invoke_result = client.invoke(FunctionName=function_name, Qualifier=qualifier) + return json.load(invoke_result["Payload"]) + + +lambda_role = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} +esm_lambda_permission = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sqs:*", + "sns:*", + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:ListStreams", + "kinesis:DescribeStream", + "kinesis:DescribeStreamSummary", + "kinesis:GetRecords", + "kinesis:GetShardIterator", + "kinesis:ListShards", + "kinesis:ListStreams", + "kinesis:SubscribeToShard", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "s3:ListBucket", + "s3:PutObject", + ], + "Resource": ["*"], + } + ], +} + + +def _await_event_source_mapping_state(lambda_client, uuid, state, retries=30): + def assert_mapping_disabled(): + assert lambda_client.get_event_source_mapping(UUID=uuid)["State"] == state + + retry(assert_mapping_disabled, sleep_before=2, retries=retries) + + +def _await_event_source_mapping_enabled(lambda_client, uuid, retries=30): + return _await_event_source_mapping_state( + lambda_client=lambda_client, uuid=uuid, retries=retries, state="Enabled" + ) + + +def _await_dynamodb_table_active(dynamodb_client, table_name, retries=6): + def assert_table_active(): + assert ( + dynamodb_client.describe_table(TableName=table_name)["Table"]["TableStatus"] == "ACTIVE" + ) + + retry(assert_table_active, retries=retries, sleep_before=2) + + +def _get_lambda_invocation_events(logs_client, function_name, expected_num_events, retries=30): + def get_events(): + events = get_lambda_log_events(function_name, logs_client=logs_client) + assert len(events) == expected_num_events + return events + + return retry(get_events, retries=retries, sleep_before=5, sleep=5) + + +def is_docker_runtime_executor(): + return config.LAMBDA_RUNTIME_EXECUTOR in ["docker", ""] diff --git a/localstack-core/localstack/testing/aws/util.py b/localstack-core/localstack/testing/aws/util.py new file mode 100644 index 0000000000000..2fadd02b9b257 --- /dev/null +++ b/localstack-core/localstack/testing/aws/util.py @@ -0,0 +1,247 @@ +import functools +import os +from typing import Callable, Dict, TypeVar + +import boto3 +import botocore +from botocore.awsrequest import AWSPreparedRequest, AWSResponse +from botocore.client import BaseClient +from botocore.compat import HTTPHeaders +from botocore.config import Config +from botocore.exceptions import ClientError + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.connect import ( + ClientFactory, + ExternalAwsClientFactory, + ExternalClientFactory, + ServiceLevelClientFactory, +) +from localstack.aws.forwarder import create_http_request +from localstack.aws.protocol.parser import create_parser +from localstack.aws.spec import LOCALSTACK_BUILTIN_DATA_PATH, load_service +from localstack.config import is_env_true +from localstack.testing.config import ( + SECONDARY_TEST_AWS_ACCESS_KEY_ID, + SECONDARY_TEST_AWS_PROFILE, + SECONDARY_TEST_AWS_SECRET_ACCESS_KEY, + SECONDARY_TEST_AWS_SESSION_TOKEN, + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_REGION_NAME, + TEST_AWS_SECRET_ACCESS_KEY, +) +from localstack.utils.aws.arns import get_partition +from localstack.utils.aws.request_context import get_account_id_from_request +from localstack.utils.sync import poll_condition + + +def is_aws_cloud() -> bool: + return os.environ.get("TEST_TARGET", "") == "AWS_CLOUD" + + +def in_default_partition() -> bool: + return is_aws_cloud() or get_partition(TEST_AWS_REGION_NAME) == "aws" + + +def get_lambda_logs(func_name, logs_client): + log_group_name = f"/aws/lambda/{func_name}" + streams = logs_client.describe_log_streams(logGroupName=log_group_name)["logStreams"] + streams = sorted(streams, key=lambda x: x["creationTime"], reverse=True) + log_events = logs_client.get_log_events( + logGroupName=log_group_name, logStreamName=streams[0]["logStreamName"] + )["events"] + return log_events + + +def bucket_exists(client, bucket_name: str) -> bool: + buckets = client.list_buckets() + for bucket in buckets["Buckets"]: + if bucket["Name"] == bucket_name: + return True + return False + + +def wait_for_user(keys, region_name: str): + sts_client = create_client_with_keys(service="sts", keys=keys, region_name=region_name) + + def is_user_ready(): + try: + sts_client.get_caller_identity() + return True + except ClientError as e: + if e.response["Error"]["Code"] == "InvalidClientTokenId": + return False + return True + + # wait until the given user is ready, takes AWS IAM a while... + poll_condition(is_user_ready, interval=5, timeout=20) + + +def create_client_with_keys( + service: str, + keys: Dict[str, str], + region_name: str, + client_config: Config = None, +): + """ + Create a boto client with the given access key, targeted against LS per default, but to AWS if TEST_TARGET is set + accordingly. + + :param service: Service to create the Client for + :param keys: Access Keys + :param region_name: Region for the client + :param client_config: + :return: + """ + return boto3.client( + service, + region_name=region_name, + aws_access_key_id=keys["AccessKeyId"], + aws_secret_access_key=keys["SecretAccessKey"], + aws_session_token=keys.get("SessionToken"), + config=client_config, + endpoint_url=config.internal_service_url() if not is_aws_cloud() else None, + ) + + +def create_request_context( + service_name: str, operation_name: str, region: str, aws_request: AWSPreparedRequest +) -> RequestContext: + if hasattr(aws_request.body, "read"): + aws_request.body = aws_request.body.read() + request = create_http_request(aws_request) + + context = RequestContext(request=request) + context.service = load_service(service_name) + context.operation = context.service.operation_model(operation_name=operation_name) + context.region = region + parser = create_parser(context.service) + _, instance = parser.parse(context.request) + context.service_request = instance + context.account_id = get_account_id_from_request(context.request) + return context + + +class _RequestContextClient: + _client: BaseClient + + def __init__(self, client: BaseClient): + self._client = client + + def __getattr__(self, item): + target = getattr(self._client, item) + if not isinstance(target, Callable): + return target + + @functools.wraps(target) + def wrapper_method(*args, **kwargs): + service_name = self._client.meta.service_model.service_name + operation_name = self._client.meta.method_to_api_mapping[item] + region = self._client.meta.region_name + prepared_request = None + + def event_handler(request: AWSPreparedRequest, **_): + nonlocal prepared_request + prepared_request = request + # we need to return an AWS Response here + aws_response = AWSResponse( + url=request.url, status_code=200, headers=HTTPHeaders(), raw=None + ) + aws_response._content = b"" + return aws_response + + self._client.meta.events.register( + f"before-send.{service_name}.{operation_name}", handler=event_handler + ) + try: + target(*args, **kwargs) + except Exception: + pass + self._client.meta.events.unregister( + f"before-send.{service_name}.{operation_name}", handler=event_handler + ) + + return create_request_context( + service_name=service_name, + operation_name=operation_name, + region=region, + aws_request=prepared_request, + ) + + return wrapper_method + + +T = TypeVar("T", bound=BaseClient) + + +def RequestContextClient(client: T) -> T: + return _RequestContextClient(client) # noqa + + +# Used for the aws_session, aws_client_factory and aws_client pytest fixtures +# Supports test executions against both LocalStack and production AWS + +# TODO: Add the ability to use config profiles for primary and secondary clients +# See https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html#using-a-configuration-file + + +def base_aws_session() -> boto3.Session: + # When running against AWS, initial credentials must be read from environment or config file + if is_aws_cloud(): + return boto3.Session() + + # Otherwise, when running against LS, use primary test credentials to start with + # This set here in the session so that both `aws_client` and `aws_client_factory` can work without explicit creds. + session = boto3.Session( + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=TEST_AWS_SECRET_ACCESS_KEY, + ) + # make sure we consider our custom data paths for legacy specs (like SQS query protocol) + session._loader.search_paths.insert(0, LOCALSTACK_BUILTIN_DATA_PATH) + return session + + +def secondary_aws_session() -> boto3.Session: + if is_aws_cloud() and SECONDARY_TEST_AWS_PROFILE: + return boto3.Session(profile_name=SECONDARY_TEST_AWS_PROFILE) + + # Otherwise, when running against LS or AWS, but have no profile set for the secondary account, + # we use secondary test credentials to initialize the session. + # This set here in the session so that both `secondary_aws_client` and `secondary_aws_client_factory` can work + # without explicit creds. + session = boto3.Session( + aws_access_key_id=SECONDARY_TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=SECONDARY_TEST_AWS_SECRET_ACCESS_KEY, + aws_session_token=SECONDARY_TEST_AWS_SESSION_TOKEN, + ) + if not is_aws_cloud(): + # make sure we consider our custom data paths for legacy specs (like SQS query protocol), only if we run against + # LocalStack + session._loader.search_paths.append(LOCALSTACK_BUILTIN_DATA_PATH) + return session + + +def base_aws_client_factory(session: boto3.Session) -> ClientFactory: + config = None + if is_env_true("TEST_DISABLE_RETRIES_AND_TIMEOUTS"): + config = botocore.config.Config( + connect_timeout=1_000, + read_timeout=1_000, + retries={"total_max_attempts": 1}, + ) + + if is_aws_cloud(): + return ExternalAwsClientFactory(session=session, config=config) + else: + if not config: + config = botocore.config.Config() + + # Prevent this fixture from using the region configured in system config + config = config.merge(botocore.config.Config(region_name=TEST_AWS_REGION_NAME)) + return ExternalClientFactory(session=session, config=config) + + +def base_testing_aws_client(client_factory: ClientFactory) -> ServiceLevelClientFactory: + # Primary test credentials are already set in the boto3 session, so they're not set here again + return client_factory() diff --git a/localstack-core/localstack/testing/config.py b/localstack-core/localstack/testing/config.py new file mode 100644 index 0000000000000..f6191e9faa977 --- /dev/null +++ b/localstack-core/localstack/testing/config.py @@ -0,0 +1,20 @@ +import os + +from localstack.constants import DEFAULT_AWS_ACCOUNT_ID + +# Credentials used in the test suite +# These can be overridden if the tests are being run against AWS +TEST_AWS_ACCOUNT_ID = os.getenv("TEST_AWS_ACCOUNT_ID") or DEFAULT_AWS_ACCOUNT_ID +# If a structured access key ID is used, it must correspond to the account ID +TEST_AWS_ACCESS_KEY_ID = os.getenv("TEST_AWS_ACCESS_KEY_ID") or "test" +TEST_AWS_SECRET_ACCESS_KEY = os.getenv("TEST_AWS_SECRET_ACCESS_KEY") or "test" +TEST_AWS_REGION_NAME = os.getenv("TEST_AWS_REGION_NAME") or "us-east-1" + +# Secondary test AWS profile - only used for testing against AWS +SECONDARY_TEST_AWS_PROFILE = os.getenv("SECONDARY_TEST_AWS_PROFILE") +# Additional credentials used in the test suite (when running cross-account tests) +SECONDARY_TEST_AWS_ACCOUNT_ID = os.getenv("SECONDARY_TEST_AWS_ACCOUNT_ID") or "000000000002" +SECONDARY_TEST_AWS_ACCESS_KEY_ID = os.getenv("SECONDARY_TEST_AWS_ACCESS_KEY_ID") or "000000000002" +SECONDARY_TEST_AWS_SECRET_ACCESS_KEY = os.getenv("SECONDARY_TEST_AWS_SECRET_ACCESS_KEY") or "test2" +SECONDARY_TEST_AWS_SESSION_TOKEN = os.getenv("SECONDARY_TEST_AWS_SESSION_TOKEN") +SECONDARY_TEST_AWS_REGION_NAME = os.getenv("SECONDARY_TEST_AWS_REGION_NAME") or "ap-southeast-1" diff --git a/localstack-core/localstack/testing/pytest/__init__.py b/localstack-core/localstack/testing/pytest/__init__.py new file mode 100644 index 0000000000000..a92cec4e13581 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/__init__.py @@ -0,0 +1,3 @@ +from localstack.testing.pytest.marking import Markers + +markers = Markers diff --git a/localstack-core/localstack/testing/pytest/bootstrap.py b/localstack-core/localstack/testing/pytest/bootstrap.py new file mode 100644 index 0000000000000..f9ead8e2d2fc0 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/bootstrap.py @@ -0,0 +1,8 @@ +import pytest + +from localstack import config + + +@pytest.fixture(scope="session", autouse=True) +def setup_host_config_dirs(): + config.dirs.mkdirs() diff --git a/localstack-core/localstack/testing/pytest/cloudformation/__init__.py b/localstack-core/localstack/testing/pytest/cloudformation/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/pytest/cloudformation/fixtures.py b/localstack-core/localstack/testing/pytest/cloudformation/fixtures.py new file mode 100644 index 0000000000000..745a547f078c3 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/cloudformation/fixtures.py @@ -0,0 +1,242 @@ +import json +from collections import defaultdict +from typing import Callable, Optional, TypedDict + +import pytest + +from localstack.aws.api.cloudformation import DescribeChangeSetOutput, StackEvent +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.utils.functions import call_safe +from localstack.utils.strings import short_uid + + +class NormalizedEvent(TypedDict): + PhysicalResourceId: Optional[str] + LogicalResourceId: str + ResourceType: str + ResourceStatus: str + Timestamp: str + + +PerResourceStackEvents = dict[str, list[NormalizedEvent]] + + +def normalize_event(event: StackEvent) -> NormalizedEvent: + return NormalizedEvent( + PhysicalResourceId=event.get("PhysicalResourceId"), + LogicalResourceId=event.get("LogicalResourceId"), + ResourceType=event.get("ResourceType"), + ResourceStatus=event.get("ResourceStatus"), + Timestamp=event.get("Timestamp"), + ) + + +@pytest.fixture +def capture_per_resource_events( + aws_client: ServiceLevelClientFactory, +) -> Callable[[str], PerResourceStackEvents]: + def capture(stack_name: str) -> dict: + events = aws_client.cloudformation.describe_stack_events(StackName=stack_name)[ + "StackEvents" + ] + per_resource_events = defaultdict(list) + for event in events: + # TODO: not supported events + if event.get("ResourceStatus") in { + "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "DELETE_IN_PROGRESS", + "DELETE_COMPLETE", + }: + continue + + if logical_resource_id := event.get("LogicalResourceId"): + resource_name = ( + logical_resource_id + if logical_resource_id != event.get("StackName") + else "Stack" + ) + normalized_event = normalize_event(event) + per_resource_events[resource_name].append(normalized_event) + + for resource_id in per_resource_events: + per_resource_events[resource_id].sort(key=lambda event: event["Timestamp"]) + + filtered_per_resource_events = {} + for resource_id in per_resource_events: + events = [] + last: tuple[str, str, str] | None = None + + for event in per_resource_events[resource_id]: + unique_key = ( + event["LogicalResourceId"], + event["ResourceStatus"], + event["ResourceType"], + ) + if last is None: + events.append(event) + last = unique_key + continue + + if unique_key == last: + continue + + events.append(event) + last = unique_key + + filtered_per_resource_events[resource_id] = events + + return filtered_per_resource_events + + return capture + + +def _normalise_describe_change_set_output(value: DescribeChangeSetOutput) -> None: + value.get("Changes", list()).sort( + key=lambda change: change.get("ResourceChange", dict()).get("LogicalResourceId", str()) + ) + + +@pytest.fixture +def capture_update_process(aws_client_no_retry, cleanups, capture_per_resource_events): + """ + Fixture to deploy a new stack (via creating and executing a change set), then updating the + stack with a second template (via creating and executing a change set). + """ + + stack_name = f"stack-{short_uid()}" + change_set_name = f"cs-{short_uid()}" + + def inner( + snapshot, t1: dict | str, t2: dict | str, p1: dict | None = None, p2: dict | None = None + ): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + if isinstance(t1, dict): + t1 = json.dumps(t1) + elif isinstance(t1, str): + with open(t1) as infile: + t1 = infile.read() + if isinstance(t2, dict): + t2 = json.dumps(t2) + elif isinstance(t2, str): + with open(t2) as infile: + t2 = infile.read() + + p1 = p1 or {} + p2 = p2 or {} + + # deploy original stack + change_set_details = aws_client_no_retry.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=t1, + ChangeSetType="CREATE", + Parameters=[{"ParameterKey": k, "ParameterValue": v} for (k, v) in p1.items()], + ) + snapshot.match("create-change-set-1", change_set_details) + stack_id = change_set_details["StackId"] + change_set_id = change_set_details["Id"] + aws_client_no_retry.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=change_set_id + ) + cleanups.append( + lambda: call_safe( + aws_client_no_retry.cloudformation.delete_change_set, + kwargs=dict(ChangeSetName=change_set_id), + ) + ) + + describe_change_set_with_prop_values = ( + aws_client_no_retry.cloudformation.describe_change_set( + ChangeSetName=change_set_id, IncludePropertyValues=True + ) + ) + _normalise_describe_change_set_output(describe_change_set_with_prop_values) + snapshot.match("describe-change-set-1-prop-values", describe_change_set_with_prop_values) + + describe_change_set_without_prop_values = ( + aws_client_no_retry.cloudformation.describe_change_set( + ChangeSetName=change_set_id, IncludePropertyValues=False + ) + ) + _normalise_describe_change_set_output(describe_change_set_without_prop_values) + snapshot.match("describe-change-set-1", describe_change_set_without_prop_values) + + execute_results = aws_client_no_retry.cloudformation.execute_change_set( + ChangeSetName=change_set_id + ) + snapshot.match("execute-change-set-1", execute_results) + aws_client_no_retry.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack_id + ) + + # ensure stack deletion + cleanups.append( + lambda: call_safe( + aws_client_no_retry.cloudformation.delete_stack, kwargs=dict(StackName=stack_id) + ) + ) + + describe = aws_client_no_retry.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][ + 0 + ] + snapshot.match("post-create-1-describe", describe) + + # update stack + change_set_details = aws_client_no_retry.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=t2, + ChangeSetType="UPDATE", + Parameters=[{"ParameterKey": k, "ParameterValue": v} for (k, v) in p2.items()], + ) + snapshot.match("create-change-set-2", change_set_details) + stack_id = change_set_details["StackId"] + change_set_id = change_set_details["Id"] + aws_client_no_retry.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=change_set_id + ) + + describe_change_set_with_prop_values = ( + aws_client_no_retry.cloudformation.describe_change_set( + ChangeSetName=change_set_id, IncludePropertyValues=True + ) + ) + _normalise_describe_change_set_output(describe_change_set_with_prop_values) + snapshot.match("describe-change-set-2-prop-values", describe_change_set_with_prop_values) + + describe_change_set_without_prop_values = ( + aws_client_no_retry.cloudformation.describe_change_set( + ChangeSetName=change_set_id, IncludePropertyValues=False + ) + ) + _normalise_describe_change_set_output(describe_change_set_without_prop_values) + snapshot.match("describe-change-set-2", describe_change_set_without_prop_values) + + execute_results = aws_client_no_retry.cloudformation.execute_change_set( + ChangeSetName=change_set_id + ) + snapshot.match("execute-change-set-2", execute_results) + aws_client_no_retry.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack_id + ) + + describe = aws_client_no_retry.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][ + 0 + ] + snapshot.match("post-create-2-describe", describe) + + # delete stack + aws_client_no_retry.cloudformation.delete_stack(StackName=stack_id) + aws_client_no_retry.cloudformation.get_waiter("stack_delete_complete").wait( + StackName=stack_id + ) + describe = aws_client_no_retry.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][ + 0 + ] + snapshot.match("delete-describe", describe) + + events = capture_per_resource_events(stack_id) + snapshot.match("per-resource-events", events) + + yield inner diff --git a/localstack-core/localstack/testing/pytest/container.py b/localstack-core/localstack/testing/pytest/container.py new file mode 100644 index 0000000000000..fd904f6a86233 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/container.py @@ -0,0 +1,278 @@ +import logging +import os +import shlex +import threading +from typing import Callable, Generator, List, Optional + +import pytest + +from localstack import constants +from localstack.utils.bootstrap import Container, RunningContainer, get_docker_image_to_start +from localstack.utils.container_utils.container_client import ( + CancellableStream, + ContainerConfiguration, + ContainerConfigurator, + NoSuchNetwork, + PortMappings, + VolumeMappings, +) +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.strings import short_uid +from localstack.utils.sync import poll_condition + +LOG = logging.getLogger(__name__) + +ENV_TEST_CONTAINER_MOUNT_SOURCES = "TEST_CONTAINER_MOUNT_SOURCES" +"""Environment variable used to indicate that we should mount LocalStack source files into the container.""" + +ENV_TEST_CONTAINER_MOUNT_DEPENDENCIES = "TEST_CONTAINER_MOUNT_DEPENDENCIES" +"""Environment variable used to indicate that we should mount dependencies into the container.""" + + +class ContainerFactory: + def __init__(self): + self._containers: List[Container] = [] + + def __call__( + self, + # convenience properties + pro: bool = False, + publish: Optional[List[int]] = None, + configurators: Optional[List[ContainerConfigurator]] = None, + # ContainerConfig properties + **kwargs, + ) -> Container: + port_configuration = PortMappings() + if publish: + for port in publish: + port_configuration.add(port) + + container_configuration = ContainerConfiguration( + image_name=get_docker_image_to_start(), + name=None, + volumes=VolumeMappings(), + remove=True, + ports=port_configuration, + entrypoint=os.environ.get("ENTRYPOINT"), + command=shlex.split(os.environ.get("CMD", "")) or None, + env_vars={}, + ) + + # handle the convenience options + if pro: + container_configuration.env_vars["GATEWAY_LISTEN"] = "0.0.0.0:4566,0.0.0.0:443" + container_configuration.env_vars["LOCALSTACK_AUTH_TOKEN"] = os.environ.get( + "LOCALSTACK_AUTH_TOKEN", "test" + ) + + # override values from kwargs + for key, value in kwargs.items(): + setattr(container_configuration, key, value) + + container = Container(container_configuration) + + if configurators: + container.configure(configurators) + + # track the container so we can remove it later + self._containers.append(container) + return container + + def remove_all_containers(self): + failures = [] + for container in self._containers: + if not container.running_container: + # container is not running + continue + + try: + container.running_container.shutdown() + except Exception as e: + failures.append((container, e)) + + if failures: + for container, ex in failures: + LOG.error( + "Failed to remove container %s", + container.running_container.id, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + +class LogStreamFactory: + def __init__(self): + self.streams: list[CancellableStream] = [] + self.stop_events: list[threading.Event] = [] + self.mutex = threading.RLock() + + def __call__(self, container: Container, callback: Callable[[str], None] = None) -> None: + """ + Create and start a new log stream thread. The thread starts immediately and waits for the container + to move into a running state. Once it's running, it will attempt to stream the container logs. If + the container is already closed by then, an exception will be raised in the thread and it will + terminate. + + :param container: the container to stream the logs from + :param callback: an optional callback called on each log line. + """ + stop = threading.Event() + self.stop_events.append(stop) + + def _can_continue(): + if stop.is_set(): + return True + if not container.running_container: + return False + return container.running_container.is_running() + + def _run_stream_container_logs(): + # wait until either the container is running or the test was terminated + poll_condition(_can_continue) + with self.mutex: + if stop.is_set(): + return + + stream = container.running_container.stream_logs() + self.streams.append(stream) + + # create a default logger + if callback is None: + log = logging.getLogger(f"container.{container.running_container.name}") + log.setLevel(level=logging.DEBUG) + _callback = log.debug + else: + _callback = callback + + for line in stream: + _callback(line.decode("utf-8").rstrip(os.linesep)) + + t = threading.Thread( + target=_run_stream_container_logs, + name=threading._newname("log-stream-%d"), + daemon=True, + ) + t.start() + + def close(self): + with self.mutex: + for _event in self.stop_events: + _event.set() + + for _stream in self.streams: + _stream.close() + + +@pytest.fixture +def container_factory() -> Generator[ContainerFactory, None, None]: + factory = ContainerFactory() + yield factory + factory.remove_all_containers() + + +@pytest.fixture(scope="session") +def wait_for_localstack_ready(): + def _wait_for(container: RunningContainer, timeout: Optional[float] = None): + container.wait_until_ready(timeout) + + poll_condition( + lambda: constants.READY_MARKER_OUTPUT in container.get_logs().splitlines(), + timeout=timeout, + ) + + return _wait_for + + +@pytest.fixture +def ensure_network(): + networks = [] + + def _ensure_network(name: str): + try: + DOCKER_CLIENT.inspect_network(name) + except NoSuchNetwork: + DOCKER_CLIENT.create_network(name) + networks.append(name) + + yield _ensure_network + + for network_name in networks: + # detach attached containers + details = DOCKER_CLIENT.inspect_network(network_name) + for container_id in details.get("Containers", []): + DOCKER_CLIENT.disconnect_container_from_network( + network_name=network_name, container_name_or_id=container_id + ) + DOCKER_CLIENT.delete_network(network_name) + + +@pytest.fixture +def docker_network(ensure_network): + network_name = f"net-{short_uid()}" + ensure_network(network_name) + return network_name + + +@pytest.fixture +def dns_query_from_container(container_factory: ContainerFactory, monkeypatch): + """ + Run the LocalStack container after installing dig + """ + containers: list[RunningContainer] = [] + + def query(name: str, ip_address: str, port: int = 53, **kwargs) -> tuple[bytes, bytes]: + container = container_factory( + image_name="localstack/localstack", + command=["infinity"], + entrypoint="sleep", + **kwargs, + ) + running_container = container.start() + containers.append(running_container) + + command = [ + "bash", + "-c", + f"apt-get install -y --no-install-recommends dnsutils >/dev/null && dig +short @{ip_address} -p {port} {name}", + ] + # The CmdDockerClient has its output set to a logfile. We must patch + # the client to ensure the output of the command goes to stdout. We use + # a monkeypatch.context here to make sure the scope of the patching is + # minimal. + with monkeypatch.context() as m: + m.setattr(running_container.container_client, "default_run_outfile", None) + stdout, stderr = running_container.exec_in_container(command=command) + return stdout, stderr + + yield query + + for container in containers: + container.shutdown() + + +@pytest.fixture +def stream_container_logs() -> Generator[LogStreamFactory, None, None]: + """ + Factory fixture for streaming logs of containers in the background. Invoke as follows:: + + def test_container(container_factory, stream_container_logs): + container: Container = container_factory(...) + + with container.start() as running_container: + stream_container_logs(container) + + This will start a background thread that streams the container logs to a python logger + ``containers.``. You can find it in the logs as:: + + 2023-09-03T18:49:06.236 DEBUG --- [log-stream-1] container.localstack-5a4c3678 : foobar + 2023-09-03T18:49:06.236 DEBUG --- [log-stream-1] container.localstack-5a4c3678 : hello world + + The function ``stream_container_logs`` also accepts a ``callback`` argument that can be used to + overwrite the default logging mechanism. For example, to print every log line directly to stdout, call:: + + stream_container_logs(container, callback=print) + + :return: a factory to start log streams + """ + factory = LogStreamFactory() + yield factory + factory.close() diff --git a/localstack-core/localstack/testing/pytest/detect_thread_leakage.py b/localstack-core/localstack/testing/pytest/detect_thread_leakage.py new file mode 100644 index 0000000000000..b36ffc7260e63 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/detect_thread_leakage.py @@ -0,0 +1,48 @@ +import inspect +import json +import sys +import threading +import traceback + +import psutil +import pytest + + +@pytest.hookimpl(trylast=True) +def pytest_unconfigure(config): + print( + f"Still running threads after pytest unconfigure: {threading.enumerate()}, Count: {threading.active_count()}" + ) + thread_frames = [ + (sys._current_frames().get(thread.ident), thread) for thread in threading.enumerate() + ] + info_tuples = [ + { + "file_name": frame.f_code.co_filename, + "function_name": frame.f_code.co_name, + "line_no": frame.f_code.co_firstlineno, + "frame_traceback": traceback.format_stack(frame), + "thread_name": thread.name, + "thread_target": repr(thread._target) if hasattr(thread, "_target") else None, + "thread_target_file": inspect.getfile(thread._target) + if hasattr(thread, "_target") and thread._target + else None, + } + for frame, thread in thread_frames + if frame + ] + print(f"Thread actions: {json.dumps(info_tuples, indent=None)}") + current_process = psutil.Process() + children = current_process.children(recursive=True) + + process_information_list = [] + + for child in children: + try: + process_information_list.append( + {"cmdline": child.cmdline(), "pid": child.pid, "status": child.status()} + ) + except Exception as e: + print(f"Error while collecting process information of {child.pid}: {e}") + + print(f"Subprocesses: {json.dumps(process_information_list, indent=None)}") diff --git a/localstack-core/localstack/testing/pytest/filters.py b/localstack-core/localstack/testing/pytest/filters.py new file mode 100644 index 0000000000000..2e7f0a8d0a780 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/filters.py @@ -0,0 +1,30 @@ +from typing import List + +import pytest +from _pytest.config import Config, PytestPluginManager +from _pytest.config.argparsing import Parser +from _pytest.main import Session +from _pytest.nodes import Item + + +@pytest.hookimpl +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager): + parser.addoption("--filter-fixtures", action="store") + + +@pytest.hookimpl +def pytest_collection_modifyitems(session: Session, config: Config, items: List[Item]): + filter_fixtures_option = config.getoption("--filter-fixtures") + if filter_fixtures_option: + # TODO: add more sophisticated combinations (=> like pytest -m and -k) + # currently this is implemented in a way that any overlap between the fixture names will lead to selection + filter_fixtures = set(filter_fixtures_option.split(",")) + selected = [] + deselected = [] + for item in items: + if hasattr(item, "fixturenames") and filter_fixtures.isdisjoint(set(item.fixturenames)): + deselected.append(item) + else: + selected.append(item) + items[:] = selected + config.hook.pytest_deselected(items=deselected) diff --git a/localstack-core/localstack/testing/pytest/find_orphaned_snapshots.py b/localstack-core/localstack/testing/pytest/find_orphaned_snapshots.py new file mode 100644 index 0000000000000..349f70edfc1cc --- /dev/null +++ b/localstack-core/localstack/testing/pytest/find_orphaned_snapshots.py @@ -0,0 +1,39 @@ +""" + + +We want to detect a few things + +1. Tests that use the snapshot fixture but don't have a recorded snapshot +2. Snapshots without a corresponding +""" + +import pytest +from _pytest.config import Config, PytestPluginManager +from _pytest.config.argparsing import Parser +from _pytest.main import Session +from _pytest.nodes import Item + + +@pytest.hookimpl +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager): + parser.addoption("--filter-fixtures", action="store") # TODO: take a directory as input + + +@pytest.hookimpl +def pytest_collection_modifyitems(session: Session, config: Config, items: list[Item]): + # for each file load the corresponding snapshot file + + ff = config.getoption("--filter-fixtures") + if ff: + # TODO: add more sophisticated combinations (=> like pytest -m and -k) + # currently this is implemented in a way that any overlap between the fixture names will lead to selection + filter_fixtures = set(ff.split(",")) + selected = [] + deselected = [] + for item in items: + if hasattr(item, "fixturenames") and filter_fixtures.isdisjoint(set(item.fixturenames)): + deselected.append(item) + else: + selected.append(item) + items[:] = selected + config.hook.pytest_deselected(items=deselected) diff --git a/localstack-core/localstack/testing/pytest/fixture_conflicts.py b/localstack-core/localstack/testing/pytest/fixture_conflicts.py new file mode 100644 index 0000000000000..8ecb880d28110 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/fixture_conflicts.py @@ -0,0 +1,37 @@ +""" +This pytest plugin makes sure there's only a single fixture definition for each fixture name when executing a test. + +The behavior here can be disabled with the option --ignore-fixture-conflicts which will then behave like pytest does by default (i.e. allow multiple defs). +""" + +import logging + +import pytest +from _pytest.config import PytestPluginManager +from _pytest.config.argparsing import Parser +from _pytest.nodes import Item +from _pytest.python import Function + +LOG = logging.getLogger(__name__) + + +@pytest.hookimpl +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager): + parser.addoption( + "--ignore-fixture-conflicts", + action="store_true", + help="When enabled, allows multiple fixture definitions to exist for a single fixture name.", + ) + + +@pytest.hookimpl +def pytest_runtest_setup(item: Item): + if not item.config.getoption("--ignore-fixture-conflicts", False): + if isinstance(item, Function): + # unfortunately there don't seem to be proper fixture initialization hooks and + # the fixture names only include a single entry even when multiple definitions are found + # so we need to check the internal name2fixturedefs dict instead + defs = item._fixtureinfo.name2fixturedefs + multi_defs = [k for k, v in defs.items() if len(v) > 1 and "snapshot" not in k] + if multi_defs: + pytest.exit(f"Aborting. Detected multiple defs for fixtures: {multi_defs}") diff --git a/localstack-core/localstack/testing/pytest/fixtures.py b/localstack-core/localstack/testing/pytest/fixtures.py new file mode 100644 index 0000000000000..93f17e84ca7ef --- /dev/null +++ b/localstack-core/localstack/testing/pytest/fixtures.py @@ -0,0 +1,2618 @@ +import contextlib +import dataclasses +import json +import logging +import os +import re +import textwrap +import time +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple + +import botocore.auth +import botocore.config +import botocore.credentials +import botocore.session +import pytest +from _pytest.config import Config +from _pytest.nodes import Item +from botocore.exceptions import ClientError +from botocore.regions import EndpointResolver +from pytest_httpserver import HTTPServer +from werkzeug import Request, Response + +from localstack import config +from localstack.aws.api.ec2 import CreateSecurityGroupRequest +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.services.stores import ( + AccountRegionBundle, + BaseStore, + CrossAccountAttribute, + CrossRegionAttribute, + LocalAttribute, +) +from localstack.testing.aws.cloudformation_utils import load_template_file, render_template +from localstack.testing.aws.util import get_lambda_logs, is_aws_cloud +from localstack.testing.config import ( + SECONDARY_TEST_AWS_ACCOUNT_ID, + SECONDARY_TEST_AWS_REGION_NAME, + TEST_AWS_ACCOUNT_ID, + TEST_AWS_REGION_NAME, +) +from localstack.utils import testutil +from localstack.utils.aws.arns import get_partition +from localstack.utils.aws.client import SigningHttpClient +from localstack.utils.aws.resources import create_dynamodb_table +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.collections import ensure_list, select_from_typed_dict +from localstack.utils.functions import call_safe, run_safe +from localstack.utils.http import safe_requests as requests +from localstack.utils.id_generator import ResourceIdentifier, localstack_id_manager +from localstack.utils.json import CustomEncoder, json_safe +from localstack.utils.net import wait_for_port_open +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import ShortCircuitWaitException, poll_condition, retry, wait_until + +LOG = logging.getLogger(__name__) + +# URL of public HTTP echo server, used primarily for AWS parity/snapshot testing +PUBLIC_HTTP_ECHO_SERVER_URL = "http://httpbin.org" + +WAITER_CHANGE_SET_CREATE_COMPLETE = "change_set_create_complete" +WAITER_STACK_CREATE_COMPLETE = "stack_create_complete" +WAITER_STACK_UPDATE_COMPLETE = "stack_update_complete" +WAITER_STACK_DELETE_COMPLETE = "stack_delete_complete" + + +if TYPE_CHECKING: + from mypy_boto3_sqs import SQSClient + from mypy_boto3_sqs.type_defs import MessageTypeDef + + +@pytest.fixture(scope="session") +def aws_client_no_retry(aws_client_factory): + """ + This fixture can be used to obtain Boto clients with disabled retries for testing. + botocore docs: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#configuring-a-retry-mode + + Use this client when testing exceptions (i.e., with pytest.raises(...)) or expected errors (e.g., status code 500) + to avoid unnecessary retries and mitigate test flakiness if the tested error condition is time-bound. + + This client is needed for the following errors, exceptions, and HTTP status codes defined by the legacy retry mode: + https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html#legacy-retry-mode + General socket/connection errors: + * ConnectionError + * ConnectionClosedError + * ReadTimeoutError + * EndpointConnectionError + + Service-side throttling/limit errors and exceptions: + * Throttling + * ThrottlingException + * ThrottledException + * RequestThrottledException + * ProvisionedThroughputExceededException + + HTTP status codes: 429, 500, 502, 503, 504, and 509 + + Hence, this client is not needed for a `ResourceNotFound` error (but it doesn't harm). + """ + no_retry_config = botocore.config.Config(retries={"max_attempts": 1}) + return aws_client_factory(config=no_retry_config) + + +@pytest.fixture(scope="class") +def aws_http_client_factory(aws_session): + """ + Returns a factory for creating new ``SigningHttpClient`` instances using a configurable botocore request signer. + The default signer is a SigV4QueryAuth. The credentials are extracted from the ``boto3_sessions`` fixture that + transparently uses your global profile when TEST_TARGET=AWS_CLOUD, or test credentials when running against + LocalStack. + + Example invocations + + client = aws_signing_http_client_factory("sqs") + client.get("http://localhost:4566/000000000000/my-queue") + + or + client = aws_signing_http_client_factory("dynamodb", signer_factory=SigV4Auth) + client.post("...") + """ + + def factory( + service: str, + region: str = None, + signer_factory: Callable[ + [botocore.credentials.Credentials, str, str], botocore.auth.BaseSigner + ] = botocore.auth.SigV4QueryAuth, + endpoint_url: str = None, + aws_access_key_id: str = None, + aws_secret_access_key: str = None, + ): + region = region or TEST_AWS_REGION_NAME + + if aws_access_key_id or aws_secret_access_key: + credentials = botocore.credentials.Credentials( + access_key=aws_access_key_id, secret_key=aws_secret_access_key + ) + else: + credentials = aws_session.get_credentials() + + creds = credentials.get_frozen_credentials() + + if not endpoint_url: + if is_aws_cloud(): + # FIXME: this is a bit raw. we should probably re-use boto in a better way + resolver: EndpointResolver = aws_session._session.get_component("endpoint_resolver") + endpoint_url = "https://" + resolver.construct_endpoint(service, region)["hostname"] + else: + endpoint_url = config.internal_service_url() + + return SigningHttpClient(signer_factory(creds, service, region), endpoint_url=endpoint_url) + + return factory + + +@pytest.fixture(scope="class") +def s3_vhost_client(aws_client_factory, region_name): + return aws_client_factory( + config=botocore.config.Config(s3={"addressing_style": "virtual"}), region_name=region_name + ).s3 + + +@pytest.fixture +def dynamodb_wait_for_table_active(aws_client): + def wait_for_table_active(table_name: str, client=None): + def wait(): + return (client or aws_client.dynamodb).describe_table(TableName=table_name)["Table"][ + "TableStatus" + ] == "ACTIVE" + + poll_condition(wait, timeout=30) + + return wait_for_table_active + + +@pytest.fixture +def dynamodb_create_table_with_parameters(dynamodb_wait_for_table_active, aws_client): + tables = [] + + def factory(**kwargs): + if "TableName" not in kwargs: + kwargs["TableName"] = f"test-table-{short_uid()}" + + tables.append(kwargs["TableName"]) + response = aws_client.dynamodb.create_table(**kwargs) + dynamodb_wait_for_table_active(kwargs["TableName"]) + return response + + yield factory + + # cleanup + for table in tables: + try: + # table has to be in ACTIVE state before deletion + dynamodb_wait_for_table_active(table) + aws_client.dynamodb.delete_table(TableName=table) + except Exception as e: + LOG.debug("error cleaning up table %s: %s", table, e) + + +@pytest.fixture +def dynamodb_create_table(dynamodb_wait_for_table_active, aws_client): + # beware, this swallows exception in create_dynamodb_table utility function + tables = [] + + def factory(**kwargs): + kwargs["client"] = aws_client.dynamodb + if "table_name" not in kwargs: + kwargs["table_name"] = f"test-table-{short_uid()}" + if "partition_key" not in kwargs: + kwargs["partition_key"] = "id" + + tables.append(kwargs["table_name"]) + + return create_dynamodb_table(**kwargs) + + yield factory + + # cleanup + for table in tables: + try: + # table has to be in ACTIVE state before deletion + dynamodb_wait_for_table_active(table) + aws_client.dynamodb.delete_table(TableName=table) + except Exception as e: + LOG.debug("error cleaning up table %s: %s", table, e) + + +@pytest.fixture +def s3_create_bucket(s3_empty_bucket, aws_client): + buckets = [] + + def factory(**kwargs) -> str: + if "Bucket" not in kwargs: + kwargs["Bucket"] = "test-bucket-%s" % short_uid() + + if ( + "CreateBucketConfiguration" not in kwargs + and aws_client.s3.meta.region_name != "us-east-1" + ): + kwargs["CreateBucketConfiguration"] = { + "LocationConstraint": aws_client.s3.meta.region_name + } + + aws_client.s3.create_bucket(**kwargs) + buckets.append(kwargs["Bucket"]) + return kwargs["Bucket"] + + yield factory + + # cleanup + for bucket in buckets: + try: + s3_empty_bucket(bucket) + aws_client.s3.delete_bucket(Bucket=bucket) + except Exception as e: + LOG.debug("error cleaning up bucket %s: %s", bucket, e) + + +@pytest.fixture +def s3_create_bucket_with_client(s3_empty_bucket, aws_client): + buckets = [] + + def factory(s3_client, **kwargs) -> str: + if "Bucket" not in kwargs: + kwargs["Bucket"] = f"test-bucket-{short_uid()}" + + response = s3_client.create_bucket(**kwargs) + buckets.append(kwargs["Bucket"]) + return response + + yield factory + + # cleanup + for bucket in buckets: + try: + s3_empty_bucket(bucket) + aws_client.s3.delete_bucket(Bucket=bucket) + except Exception as e: + LOG.debug("error cleaning up bucket %s: %s", bucket, e) + + +@pytest.fixture +def s3_bucket(s3_create_bucket, aws_client) -> str: + region = aws_client.s3.meta.region_name + kwargs = {} + if region != "us-east-1": + kwargs["CreateBucketConfiguration"] = {"LocationConstraint": region} + return s3_create_bucket(**kwargs) + + +@pytest.fixture +def s3_empty_bucket(aws_client): + """ + Returns a factory that given a bucket name, deletes all objects and deletes all object versions + """ + + # Boto resource would make this a straightforward task, but our internal client does not support Boto resource + # FIXME: this won't work when bucket has more than 1000 objects + def factory(bucket_name: str): + kwargs = {} + try: + aws_client.s3.get_object_lock_configuration(Bucket=bucket_name) + kwargs["BypassGovernanceRetention"] = True + except ClientError: + pass + + response = aws_client.s3.list_objects_v2(Bucket=bucket_name) + objects = [{"Key": obj["Key"]} for obj in response.get("Contents", [])] + if objects: + aws_client.s3.delete_objects( + Bucket=bucket_name, + Delete={"Objects": objects}, + **kwargs, + ) + + response = aws_client.s3.list_object_versions(Bucket=bucket_name) + versions = response.get("Versions", []) + versions.extend(response.get("DeleteMarkers", [])) + + object_versions = [{"Key": obj["Key"], "VersionId": obj["VersionId"]} for obj in versions] + if object_versions: + aws_client.s3.delete_objects( + Bucket=bucket_name, + Delete={"Objects": object_versions}, + **kwargs, + ) + + yield factory + + +@pytest.fixture +def sqs_create_queue(aws_client): + queue_urls = [] + + def factory(**kwargs): + if "QueueName" not in kwargs: + kwargs["QueueName"] = "test-queue-%s" % short_uid() + + response = aws_client.sqs.create_queue(**kwargs) + url = response["QueueUrl"] + queue_urls.append(url) + + return url + + yield factory + + # cleanup + for queue_url in queue_urls: + try: + aws_client.sqs.delete_queue(QueueUrl=queue_url) + except Exception as e: + LOG.debug("error cleaning up queue %s: %s", queue_url, e) + + +@pytest.fixture +def sqs_receive_messages_delete(aws_client): + def factory( + queue_url: str, + expected_messages: Optional[int] = None, + wait_time: Optional[int] = 5, + ): + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + MessageAttributeNames=["All"], + VisibilityTimeout=0, + WaitTimeSeconds=wait_time, + ) + messages = [] + for m in response["Messages"]: + message = json.loads(to_str(m["Body"])) + messages.append(message) + + if expected_messages is not None: + assert len(messages) == expected_messages + + for message in response["Messages"]: + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"] + ) + + return messages + + return factory + + +@pytest.fixture +def sqs_receive_num_messages(sqs_receive_messages_delete): + def factory(queue_url: str, expected_messages: int, max_iterations: int = 3): + all_messages = [] + for _ in range(max_iterations): + try: + messages = sqs_receive_messages_delete(queue_url, wait_time=5) + except KeyError: + # there were no messages + continue + all_messages.extend(messages) + + if len(all_messages) >= expected_messages: + return all_messages[:expected_messages] + + raise AssertionError(f"max iterations reached with {len(all_messages)} messages received") + + return factory + + +@pytest.fixture +def sqs_collect_messages(aws_client): + """Collects SQS messages from a given queue_url and deletes them by default. + Example usage: + messages = sqs_collect_messages( + my_queue_url, + expected=2, + timeout=10, + attribute_names=["All"], + message_attribute_names=["All"], + ) + """ + + def factory( + queue_url: str, + expected: int, + timeout: int, + delete: bool = True, + attribute_names: list[str] = None, + message_attribute_names: list[str] = None, + max_number_of_messages: int = 1, + wait_time_seconds: int = 5, + sqs_client: "SQSClient | None" = None, + ) -> list["MessageTypeDef"]: + sqs_client = sqs_client or aws_client.sqs + collected = [] + + def _receive(): + response = sqs_client.receive_message( + QueueUrl=queue_url, + # Maximum is 20 seconds. Performs long polling. + WaitTimeSeconds=wait_time_seconds, + # Maximum 10 messages + MaxNumberOfMessages=max_number_of_messages, + AttributeNames=attribute_names or [], + MessageAttributeNames=message_attribute_names or [], + ) + + if messages := response.get("Messages"): + collected.extend(messages) + + if delete: + for m in messages: + sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"] + ) + + return len(collected) >= expected + + if not poll_condition(_receive, timeout=timeout): + raise TimeoutError( + f"gave up waiting for messages (expected={expected}, actual={len(collected)}" + ) + + return collected + + yield factory + + +@pytest.fixture +def sqs_queue(sqs_create_queue): + return sqs_create_queue() + + +@pytest.fixture +def sqs_get_queue_arn(aws_client) -> Callable: + def _get_queue_arn(queue_url: str) -> str: + return aws_client.sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"])[ + "Attributes" + ]["QueueArn"] + + return _get_queue_arn + + +@pytest.fixture +def sqs_queue_exists(aws_client): + def _queue_exists(queue_url: str) -> bool: + """ + Checks whether a queue with the given queue URL exists. + :param queue_url: the queue URL + :return: true if the queue exists, false otherwise + """ + try: + result = aws_client.sqs.get_queue_url(QueueName=queue_url.split("/")[-1]) + return result.get("QueueUrl") == queue_url + except ClientError as e: + if "NonExistentQueue" in e.response["Error"]["Code"]: + return False + raise + + yield _queue_exists + + +@pytest.fixture +def sns_create_topic(aws_client): + topic_arns = [] + + def _create_topic(**kwargs): + if "Name" not in kwargs: + kwargs["Name"] = "test-topic-%s" % short_uid() + response = aws_client.sns.create_topic(**kwargs) + topic_arns.append(response["TopicArn"]) + return response + + yield _create_topic + + for topic_arn in topic_arns: + try: + aws_client.sns.delete_topic(TopicArn=topic_arn) + except Exception as e: + LOG.debug("error cleaning up topic %s: %s", topic_arn, e) + + +@pytest.fixture +def sns_wait_for_topic_delete(aws_client): + def wait_for_topic_delete(topic_arn: str) -> None: + def wait(): + try: + aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + return False + except Exception as e: + if "NotFound" in e.response["Error"]["Code"]: + return True + + raise + + poll_condition(wait, timeout=30) + + return wait_for_topic_delete + + +@pytest.fixture +def sns_subscription(aws_client): + sub_arns = [] + + def _create_sub(**kwargs): + if kwargs.get("ReturnSubscriptionArn") is None: + kwargs["ReturnSubscriptionArn"] = True + + # requires 'TopicArn', 'Protocol', and 'Endpoint' + response = aws_client.sns.subscribe(**kwargs) + sub_arn = response["SubscriptionArn"] + sub_arns.append(sub_arn) + return response + + yield _create_sub + + for sub_arn in sub_arns: + try: + aws_client.sns.unsubscribe(SubscriptionArn=sub_arn) + except Exception as e: + LOG.debug("error cleaning up subscription %s: %s", sub_arn, e) + + +@pytest.fixture +def sns_topic(sns_create_topic, aws_client): + topic_arn = sns_create_topic()["TopicArn"] + return aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + + +@pytest.fixture +def sns_allow_topic_sqs_queue(aws_client): + def _allow_sns_topic(sqs_queue_url, sqs_queue_arn, sns_topic_arn) -> None: + # allow topic to write to sqs queue + aws_client.sqs.set_queue_attributes( + QueueUrl=sqs_queue_url, + Attributes={ + "Policy": json.dumps( + { + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "sns.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": sqs_queue_arn, + "Condition": {"ArnEquals": {"aws:SourceArn": sns_topic_arn}}, + } + ] + } + ) + }, + ) + + return _allow_sns_topic + + +@pytest.fixture +def sns_create_sqs_subscription(sns_allow_topic_sqs_queue, sqs_get_queue_arn, aws_client): + subscriptions = [] + + def _factory(topic_arn: str, queue_url: str, **kwargs) -> Dict[str, str]: + queue_arn = sqs_get_queue_arn(queue_url) + + # connect sns topic to sqs + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn, **kwargs + ) + subscription_arn = subscription["SubscriptionArn"] + + # allow topic to write to sqs queue + sns_allow_topic_sqs_queue( + sqs_queue_url=queue_url, sqs_queue_arn=queue_arn, sns_topic_arn=topic_arn + ) + + subscriptions.append(subscription_arn) + return aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn)[ + "Attributes" + ] + + yield _factory + + for arn in subscriptions: + try: + aws_client.sns.unsubscribe(SubscriptionArn=arn) + except Exception as e: + LOG.error("error cleaning up subscription %s: %s", arn, e) + + +@pytest.fixture +def sns_create_http_endpoint(sns_create_topic, sns_subscription, aws_client): + # This fixture can be used with manual setup to expose the HTTPServer fixture to AWS. One example is to use a + # a service like localhost.run, and set up a specific port to start the `HTTPServer(port=40000)` for example, + # and tunnel `localhost:40000` to a specific domain that you can manually return from this fixture. + http_servers = [] + + def _create_http_endpoint( + raw_message_delivery: bool = False, + ) -> Tuple[str, str, str, HTTPServer]: + server = HTTPServer() + server.start() + http_servers.append(server) + server.expect_request("/sns-endpoint").respond_with_data(status=200) + endpoint_url = server.url_for("/sns-endpoint") + wait_for_port_open(endpoint_url) + + topic_arn = sns_create_topic()["TopicArn"] + subscription = sns_subscription(TopicArn=topic_arn, Protocol="http", Endpoint=endpoint_url) + subscription_arn = subscription["SubscriptionArn"] + delivery_policy = { + "healthyRetryPolicy": { + "minDelayTarget": 1, + "maxDelayTarget": 1, + "numRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "numMaxDelayRetries": 0, + "backoffFunction": "linear", + }, + "sicklyRetryPolicy": None, + "throttlePolicy": {"maxReceivesPerSecond": 1000}, + "guaranteed": False, + } + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="DeliveryPolicy", + AttributeValue=json.dumps(delivery_policy), + ) + + if raw_message_delivery: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + + return topic_arn, subscription_arn, endpoint_url, server + + yield _create_http_endpoint + + for http_server in http_servers: + if http_server.is_running(): + http_server.stop() + + +@pytest.fixture +def route53_hosted_zone(aws_client): + hosted_zones = [] + + def factory(**kwargs): + if "Name" not in kwargs: + kwargs["Name"] = f"www.{short_uid()}.com." + if "CallerReference" not in kwargs: + kwargs["CallerReference"] = f"caller-ref-{short_uid()}" + response = aws_client.route53.create_hosted_zone( + Name=kwargs["Name"], CallerReference=kwargs["CallerReference"] + ) + hosted_zones.append(response["HostedZone"]["Id"]) + return response + + yield factory + + for zone in hosted_zones: + try: + aws_client.route53.delete_hosted_zone(Id=zone) + except Exception as e: + LOG.debug("error cleaning up route53 HostedZone %s: %s", zone, e) + + +@pytest.fixture +def transcribe_create_job(s3_bucket, aws_client): + job_names = [] + + def _create_job(audio_file: str, params: Optional[dict[str, Any]] = None) -> str: + s3_key = "test-clip.wav" + + if not params: + params = {} + + if "TranscriptionJobName" not in params: + params["TranscriptionJobName"] = f"test-transcribe-{short_uid()}" + + if "LanguageCode" not in params: + params["LanguageCode"] = "en-GB" + + if "Media" not in params: + params["Media"] = {"MediaFileUri": f"s3://{s3_bucket}/{s3_key}"} + + # upload test wav to a s3 bucket + with open(audio_file, "rb") as f: + aws_client.s3.upload_fileobj(f, s3_bucket, s3_key) + + response = aws_client.transcribe.start_transcription_job(**params) + + job_name = response["TranscriptionJob"]["TranscriptionJobName"] + job_names.append(job_name) + + return job_name + + yield _create_job + + for job_name in job_names: + with contextlib.suppress(ClientError): + aws_client.transcribe.delete_transcription_job(TranscriptionJobName=job_name) + + +@pytest.fixture +def kinesis_create_stream(aws_client): + stream_names = [] + + def _create_stream(**kwargs): + if "StreamName" not in kwargs: + kwargs["StreamName"] = f"test-stream-{short_uid()}" + aws_client.kinesis.create_stream(**kwargs) + stream_names.append(kwargs["StreamName"]) + return kwargs["StreamName"] + + yield _create_stream + + for stream_name in stream_names: + try: + aws_client.kinesis.delete_stream(StreamName=stream_name, EnforceConsumerDeletion=True) + except Exception as e: + LOG.debug("error cleaning up kinesis stream %s: %s", stream_name, e) + + +@pytest.fixture +def wait_for_stream_ready(aws_client): + def _wait_for_stream_ready(stream_name: str): + def is_stream_ready(): + describe_stream_response = aws_client.kinesis.describe_stream(StreamName=stream_name) + return describe_stream_response["StreamDescription"]["StreamStatus"] in [ + "ACTIVE", + "UPDATING", + ] + + return poll_condition(is_stream_ready) + + return _wait_for_stream_ready + + +@pytest.fixture +def wait_for_delivery_stream_ready(aws_client): + def _wait_for_stream_ready(delivery_stream_name: str): + def is_stream_ready(): + describe_stream_response = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=delivery_stream_name + ) + return ( + describe_stream_response["DeliveryStreamDescription"]["DeliveryStreamStatus"] + == "ACTIVE" + ) + + poll_condition(is_stream_ready) + + return _wait_for_stream_ready + + +@pytest.fixture +def wait_for_dynamodb_stream_ready(aws_client): + def _wait_for_stream_ready(stream_arn: str, client=None): + def is_stream_ready(): + ddb_client = client or aws_client.dynamodbstreams + describe_stream_response = ddb_client.describe_stream(StreamArn=stream_arn) + return describe_stream_response["StreamDescription"]["StreamStatus"] == "ENABLED" + + return poll_condition(is_stream_ready) + + return _wait_for_stream_ready + + +@pytest.fixture() +def kms_create_key(aws_client_factory): + key_ids = [] + + def _create_key(region_name: str = None, **kwargs): + if "Description" not in kwargs: + kwargs["Description"] = f"test description - {short_uid()}" + key_metadata = aws_client_factory(region_name=region_name).kms.create_key(**kwargs)[ + "KeyMetadata" + ] + key_ids.append((region_name, key_metadata["KeyId"])) + return key_metadata + + yield _create_key + + for region_name, key_id in key_ids: + try: + # shortest amount of time you can schedule the deletion + aws_client_factory(region_name=region_name).kms.schedule_key_deletion( + KeyId=key_id, PendingWindowInDays=7 + ) + except Exception as e: + exception_message = str(e) + # Some tests schedule their keys for deletion themselves. + if ( + "KMSInvalidStateException" not in exception_message + or "is pending deletion" not in exception_message + ): + LOG.debug("error cleaning up KMS key %s: %s", key_id, e) + + +@pytest.fixture() +def kms_replicate_key(aws_client_factory): + key_ids = [] + + def _replicate_key(region_from=None, **kwargs): + region_to = kwargs.get("ReplicaRegion") + key_ids.append((region_to, kwargs.get("KeyId"))) + return aws_client_factory(region_name=region_from).kms.replicate_key(**kwargs) + + yield _replicate_key + + for region_to, key_id in key_ids: + try: + # shortest amount of time you can schedule the deletion + aws_client_factory(region_name=region_to).kms.schedule_key_deletion( + KeyId=key_id, PendingWindowInDays=7 + ) + except Exception as e: + LOG.debug("error cleaning up KMS key %s: %s", key_id, e) + + +# kms_create_key fixture is used here not just to be able to create aliases without a key specified, +# but also to make sure that kms_create_key gets executed before and teared down after kms_create_alias - +# to make sure that we clean up aliases before keys get cleaned up. +@pytest.fixture() +def kms_create_alias(kms_create_key, aws_client): + aliases = [] + + def _create_alias(**kwargs): + if "AliasName" not in kwargs: + kwargs["AliasName"] = f"alias/{short_uid()}" + if "TargetKeyId" not in kwargs: + kwargs["TargetKeyId"] = kms_create_key()["KeyId"] + + aws_client.kms.create_alias(**kwargs) + aliases.append(kwargs["AliasName"]) + return kwargs["AliasName"] + + yield _create_alias + + for alias in aliases: + try: + aws_client.kms.delete_alias(AliasName=alias) + except Exception as e: + LOG.debug("error cleaning up KMS alias %s: %s", alias, e) + + +@pytest.fixture() +def kms_create_grant(kms_create_key, aws_client): + grants = [] + + def _create_grant(**kwargs): + # Just a random ARN, since KMS in LocalStack currently doesn't validate GranteePrincipal, + # but some GranteePrincipal is required to create a grant. + GRANTEE_PRINCIPAL_ARN = ( + "arn:aws:kms:eu-central-1:123456789876:key/198a5a78-52c3-489f-ac70-b06a4d11027a" + ) + + if "Operations" not in kwargs: + kwargs["Operations"] = ["Decrypt", "Encrypt"] + if "GranteePrincipal" not in kwargs: + kwargs["GranteePrincipal"] = GRANTEE_PRINCIPAL_ARN + if "KeyId" not in kwargs: + kwargs["KeyId"] = kms_create_key()["KeyId"] + + grant_id = aws_client.kms.create_grant(**kwargs)["GrantId"] + grants.append((grant_id, kwargs["KeyId"])) + return grant_id, kwargs["KeyId"] + + yield _create_grant + + for grant_id, key_id in grants: + try: + aws_client.kms.retire_grant(GrantId=grant_id, KeyId=key_id) + except Exception as e: + LOG.debug("error cleaning up KMS grant %s: %s", grant_id, e) + + +@pytest.fixture +def kms_key(kms_create_key): + return kms_create_key() + + +@pytest.fixture +def kms_grant_and_key(kms_key, aws_client): + user_arn = aws_client.sts.get_caller_identity()["Arn"] + + return [ + aws_client.kms.create_grant( + KeyId=kms_key["KeyId"], + GranteePrincipal=user_arn, + Operations=["Decrypt", "Encrypt"], + ), + kms_key, + ] + + +@pytest.fixture +def opensearch_wait_for_cluster(aws_client): + def _wait_for_cluster(domain_name: str): + def finished_processing(): + status = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"] + return status["DomainProcessingStatus"] == "Active" and "Endpoint" in status + + assert poll_condition( + finished_processing, timeout=25 * 60, **({"interval": 10} if is_aws_cloud() else {}) + ), f"could not start domain: {domain_name}" + + return _wait_for_cluster + + +@pytest.fixture +def opensearch_create_domain(opensearch_wait_for_cluster, aws_client): + domains = [] + + def factory(**kwargs) -> str: + if "DomainName" not in kwargs: + kwargs["DomainName"] = f"test-domain-{short_uid()}" + + aws_client.opensearch.create_domain(**kwargs) + + opensearch_wait_for_cluster(domain_name=kwargs["DomainName"]) + + domains.append(kwargs["DomainName"]) + return kwargs["DomainName"] + + yield factory + + # cleanup + for domain in domains: + try: + aws_client.opensearch.delete_domain(DomainName=domain) + except Exception as e: + LOG.debug("error cleaning up domain %s: %s", domain, e) + + +@pytest.fixture +def opensearch_domain(opensearch_create_domain) -> str: + return opensearch_create_domain() + + +@pytest.fixture +def opensearch_endpoint(opensearch_domain, aws_client) -> str: + status = aws_client.opensearch.describe_domain(DomainName=opensearch_domain)["DomainStatus"] + assert "Endpoint" in status + return f"https://{status['Endpoint']}" + + +@pytest.fixture +def opensearch_document_path(opensearch_endpoint, aws_client): + document = { + "first_name": "Boba", + "last_name": "Fett", + "age": 41, + "about": "I'm just a simple man, trying to make my way in the universe.", + "interests": ["mandalorian armor", "tusken culture"], + } + document_path = f"{opensearch_endpoint}/bountyhunters/_doc/1" + response = requests.put( + document_path, + data=json.dumps(document), + headers={"content-type": "application/json", "Accept-encoding": "identity"}, + ) + assert response.status_code == 201, f"could not create document at: {document_path}" + return document_path + + +# Cleanup fixtures +@pytest.fixture +def cleanup_stacks(aws_client): + def _cleanup_stacks(stacks: List[str]) -> None: + stacks = ensure_list(stacks) + for stack in stacks: + try: + aws_client.cloudformation.delete_stack(StackName=stack) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack) + except Exception: + LOG.debug("Failed to cleanup stack '%s'", stack) + + return _cleanup_stacks + + +@pytest.fixture +def cleanup_changesets(aws_client): + def _cleanup_changesets(changesets: List[str]) -> None: + changesets = ensure_list(changesets) + for cs in changesets: + try: + aws_client.cloudformation.delete_change_set(ChangeSetName=cs) + except Exception: + LOG.debug("Failed to cleanup changeset '%s'", cs) + + return _cleanup_changesets + + +# Helpers for Cfn + + +# TODO: exports(!) +@dataclasses.dataclass(frozen=True) +class DeployResult: + change_set_id: str + stack_id: str + stack_name: str + change_set_name: str + outputs: Dict[str, str] + + destroy: Callable[[], None] + + +class StackDeployError(Exception): + def __init__(self, describe_res: dict, events: list[dict]): + self.describe_result = describe_res + self.events = events + + encoded_describe_output = json.dumps(self.describe_result, cls=CustomEncoder) + if config.CFN_VERBOSE_ERRORS: + msg = f"Describe output:\n{encoded_describe_output}\nEvents:\n{self.format_events(events)}" + else: + msg = f"Describe output:\n{encoded_describe_output}\nFailing resources:\n{self.format_events(events)}" + + super().__init__(msg) + + def format_events(self, events: list[dict]) -> str: + formatted_events = [] + + chronological_events = sorted(events, key=lambda event: event["Timestamp"]) + for event in chronological_events: + if event["ResourceStatus"].endswith("FAILED") or config.CFN_VERBOSE_ERRORS: + formatted_events.append(self.format_event(event)) + + return "\n".join(formatted_events) + + @staticmethod + def format_event(event: dict) -> str: + if reason := event.get("ResourceStatusReason"): + reason = reason.replace("\n", "; ") + return f"- {event['LogicalResourceId']} ({event['ResourceType']}) -> {event['ResourceStatus']} ({reason})" + else: + return f"- {event['LogicalResourceId']} ({event['ResourceType']}) -> {event['ResourceStatus']}" + + +@pytest.fixture +def deploy_cfn_template( + aws_client: ServiceLevelClientFactory, +): + state: list[tuple[str, Callable]] = [] + + def _deploy( + *, + is_update: Optional[bool] = False, + stack_name: Optional[str] = None, + change_set_name: Optional[str] = None, + template: Optional[str] = None, + template_path: Optional[str | os.PathLike] = None, + template_mapping: Optional[Dict[str, Any]] = None, + parameters: Optional[Dict[str, str]] = None, + role_arn: Optional[str] = None, + max_wait: Optional[int] = None, + delay_between_polls: Optional[int] = 2, + custom_aws_client: Optional[ServiceLevelClientFactory] = None, + ) -> DeployResult: + if is_update: + assert stack_name + stack_name = stack_name or f"stack-{short_uid()}" + change_set_name = change_set_name or f"change-set-{short_uid()}" + + if max_wait is None: + max_wait = 1800 if is_aws_cloud() else 180 + + if template_path is not None: + template = load_template_file(template_path) + if template is None: + raise RuntimeError(f"Could not find file {os.path.realpath(template_path)}") + template_rendered = render_template(template, **(template_mapping or {})) + + kwargs = dict( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_rendered, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ChangeSetType=("UPDATE" if is_update else "CREATE"), + Parameters=[ + { + "ParameterKey": k, + "ParameterValue": v, + } + for (k, v) in (parameters or {}).items() + ], + ) + if role_arn is not None: + kwargs["RoleARN"] = role_arn + + cfn_aws_client = custom_aws_client if custom_aws_client is not None else aws_client + + response = cfn_aws_client.cloudformation.create_change_set(**kwargs) + + change_set_id = response["Id"] + stack_id = response["StackId"] + + cfn_aws_client.cloudformation.get_waiter(WAITER_CHANGE_SET_CREATE_COMPLETE).wait( + ChangeSetName=change_set_id + ) + cfn_aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + stack_waiter = cfn_aws_client.cloudformation.get_waiter( + WAITER_STACK_UPDATE_COMPLETE if is_update else WAITER_STACK_CREATE_COMPLETE + ) + + try: + stack_waiter.wait( + StackName=stack_id, + WaiterConfig={ + "Delay": delay_between_polls, + "MaxAttempts": max_wait / delay_between_polls, + }, + ) + except botocore.exceptions.WaiterError as e: + raise StackDeployError( + cfn_aws_client.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][0], + cfn_aws_client.cloudformation.describe_stack_events(StackName=stack_id)[ + "StackEvents" + ], + ) from e + + describe_stack_res = cfn_aws_client.cloudformation.describe_stacks(StackName=stack_id)[ + "Stacks" + ][0] + outputs = describe_stack_res.get("Outputs", []) + + mapped_outputs = {o["OutputKey"]: o.get("OutputValue") for o in outputs} + + def _destroy_stack(): + cfn_aws_client.cloudformation.delete_stack(StackName=stack_id) + cfn_aws_client.cloudformation.get_waiter(WAITER_STACK_DELETE_COMPLETE).wait( + StackName=stack_id, + WaiterConfig={ + "Delay": delay_between_polls, + "MaxAttempts": max_wait / delay_between_polls, + }, + ) + + state.append((stack_id, _destroy_stack)) + + return DeployResult( + change_set_id, stack_id, stack_name, change_set_name, mapped_outputs, _destroy_stack + ) + + yield _deploy + + # delete the stacks in the reverse order they were created in case of inter-stack dependencies + for stack_id, teardown in state[::-1]: + try: + teardown() + except Exception as e: + LOG.debug("Failed cleaning up stack stack_id=%s: %s", stack_id, e) + + +@pytest.fixture +def is_change_set_created_and_available(aws_client): + def _is_change_set_created_and_available(change_set_id: str): + def _inner(): + change_set = aws_client.cloudformation.describe_change_set(ChangeSetName=change_set_id) + return ( + # TODO: CREATE_FAILED should also not lead to further retries + change_set.get("Status") == "CREATE_COMPLETE" + and change_set.get("ExecutionStatus") == "AVAILABLE" + ) + + return _inner + + return _is_change_set_created_and_available + + +@pytest.fixture +def is_change_set_failed_and_unavailable(aws_client): + def _is_change_set_created_and_available(change_set_id: str): + def _inner(): + change_set = aws_client.cloudformation.describe_change_set(ChangeSetName=change_set_id) + return ( + # TODO: CREATE_FAILED should also not lead to further retries + change_set.get("Status") == "FAILED" + and change_set.get("ExecutionStatus") == "UNAVAILABLE" + ) + + return _inner + + return _is_change_set_created_and_available + + +@pytest.fixture +def is_stack_created(aws_client): + return _has_stack_status(aws_client.cloudformation, ["CREATE_COMPLETE", "CREATE_FAILED"]) + + +@pytest.fixture +def is_stack_updated(aws_client): + return _has_stack_status(aws_client.cloudformation, ["UPDATE_COMPLETE", "UPDATE_FAILED"]) + + +@pytest.fixture +def is_stack_deleted(aws_client): + return _has_stack_status(aws_client.cloudformation, ["DELETE_COMPLETE"]) + + +def _has_stack_status(cfn_client, statuses: List[str]): + def _has_status(stack_id: str): + def _inner(): + resp = cfn_client.describe_stacks(StackName=stack_id) + s = resp["Stacks"][0] # since the lookup uses the id we can only get a single response + return s.get("StackStatus") in statuses + + return _inner + + return _has_status + + +@pytest.fixture +def is_change_set_finished(aws_client): + def _is_change_set_finished(change_set_id: str, stack_name: Optional[str] = None): + def _inner(): + kwargs = {"ChangeSetName": change_set_id} + if stack_name: + kwargs["StackName"] = stack_name + + check_set = aws_client.cloudformation.describe_change_set(**kwargs) + + if check_set.get("ExecutionStatus") == "EXECUTE_FAILED": + LOG.warning("Change set failed") + raise ShortCircuitWaitException() + + return check_set.get("ExecutionStatus") == "EXECUTE_COMPLETE" + + return _inner + + return _is_change_set_finished + + +@pytest.fixture +def wait_until_lambda_ready(aws_client): + def _wait_until_ready(function_name: str, qualifier: str = None, client=None): + client = client or aws_client.lambda_ + + def _is_not_pending(): + kwargs = {} + if qualifier: + kwargs["Qualifier"] = qualifier + try: + result = ( + client.get_function(FunctionName=function_name)["Configuration"]["State"] + != "Pending" + ) + LOG.debug("lambda state result: result=%s", result) + return result + except Exception as e: + LOG.error(e) + raise + + wait_until(_is_not_pending) + + return _wait_until_ready + + +role_assume_policy = """ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} +""".strip() + +role_policy = """ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": [ + "*" + ] + } + ] +} +""".strip() + + +@pytest.fixture +def create_lambda_function_aws(aws_client): + lambda_arns = [] + + def _create_lambda_function(**kwargs): + def _create_function(): + resp = aws_client.lambda_.create_function(**kwargs) + lambda_arns.append(resp["FunctionArn"]) + + def _is_not_pending(): + try: + result = ( + aws_client.lambda_.get_function(FunctionName=resp["FunctionName"])[ + "Configuration" + ]["State"] + != "Pending" + ) + return result + except Exception as e: + LOG.error(e) + raise + + wait_until(_is_not_pending) + return resp + + # @AWS, takes about 10s until the role/policy is "active", until then it will fail + # localstack should normally not require the retries and will just continue here + return retry(_create_function, retries=3, sleep=4) + + yield _create_lambda_function + + for arn in lambda_arns: + try: + aws_client.lambda_.delete_function(FunctionName=arn) + except Exception: + LOG.debug("Unable to delete function arn=%s in cleanup", arn) + + +@pytest.fixture +def create_lambda_function(aws_client, wait_until_lambda_ready, lambda_su_role): + lambda_arns_and_clients = [] + log_groups = [] + lambda_client = aws_client.lambda_ + logs_client = aws_client.logs + s3_client = aws_client.s3 + + def _create_lambda_function(*args, **kwargs): + client = kwargs.get("client") or lambda_client + kwargs["client"] = client + kwargs["s3_client"] = s3_client + func_name = kwargs.get("func_name") + assert func_name + del kwargs["func_name"] + + if not kwargs.get("role"): + kwargs["role"] = lambda_su_role + + def _create_function(): + resp = testutil.create_lambda_function(func_name, **kwargs) + lambda_arns_and_clients.append((resp["CreateFunctionResponse"]["FunctionArn"], client)) + wait_until_lambda_ready(function_name=func_name, client=client) + log_group_name = f"/aws/lambda/{func_name}" + log_groups.append(log_group_name) + return resp + + # @AWS, takes about 10s until the role/policy is "active", until then it will fail + # localstack should normally not require the retries and will just continue here + return retry(_create_function, retries=3, sleep=4) + + yield _create_lambda_function + + for arn, client in lambda_arns_and_clients: + try: + client.delete_function(FunctionName=arn) + except Exception: + LOG.debug("Unable to delete function arn=%s in cleanup", arn) + + for log_group_name in log_groups: + try: + logs_client.delete_log_group(logGroupName=log_group_name) + except Exception: + LOG.debug("Unable to delete log group %s in cleanup", log_group_name) + + +@pytest.fixture +def create_echo_http_server(aws_client, create_lambda_function): + from localstack.aws.api.lambda_ import Runtime + + lambda_client = aws_client.lambda_ + handler_code = textwrap.dedent( + """ + import json + import os + + + def make_response(body: dict, status_code: int = 200): + return { + "statusCode": status_code, + "headers": {"Content-Type": "application/json"}, + "body": body, + } + + + def trim_headers(headers): + if not int(os.getenv("TRIM_X_HEADERS", 0)): + return headers + return { + key: value for key, value in headers.items() + if not (key.startswith("x-amzn") or key.startswith("x-forwarded-")) + } + + + def handler(event, context): + print(json.dumps(event)) + response = { + "args": event.get("queryStringParameters", {}), + "data": event.get("body", ""), + "domain": event["requestContext"].get("domainName", ""), + "headers": trim_headers(event.get("headers", {})), + "method": event["requestContext"]["http"].get("method", ""), + "origin": event["requestContext"]["http"].get("sourceIp", ""), + "path": event["requestContext"]["http"].get("path", ""), + } + return make_response(response)""" + ) + + def _create_echo_http_server(trim_x_headers: bool = False) -> str: + """Creates a server that will echo any request. Any request will be returned with the + following format. Any unset values will have those defaults. + `trim_x_headers` can be set to True to trim some headers that are automatically added by lambda in + order to create easier Snapshot testing. Default: `False` + { + "args": {}, + "headers": {}, + "data": "", + "method": "", + "domain": "", + "origin": "", + "path": "" + }""" + zip_file = testutil.create_lambda_archive(handler_code, get_content=True) + func_name = f"echo-http-{short_uid()}" + create_lambda_function( + func_name=func_name, + zip_file=zip_file, + runtime=Runtime.python3_12, + envvars={"TRIM_X_HEADERS": "1" if trim_x_headers else "0"}, + ) + url_response = lambda_client.create_function_url_config( + FunctionName=func_name, AuthType="NONE" + ) + aws_client.lambda_.add_permission( + FunctionName=func_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + return url_response["FunctionUrl"] + + yield _create_echo_http_server + + +@pytest.fixture +def create_event_source_mapping(aws_client): + uuids = [] + + def _create_event_source_mapping(*args, **kwargs): + response = aws_client.lambda_.create_event_source_mapping(*args, **kwargs) + uuids.append(response["UUID"]) + return response + + yield _create_event_source_mapping + + for uuid in uuids: + try: + aws_client.lambda_.delete_event_source_mapping(UUID=uuid) + except Exception: + LOG.debug("Unable to delete event source mapping %s in cleanup", uuid) + + +@pytest.fixture +def check_lambda_logs(aws_client): + def _check_logs(func_name: str, expected_lines: List[str] = None) -> List[str]: + if not expected_lines: + expected_lines = [] + log_events = get_lambda_logs(func_name, logs_client=aws_client.logs) + log_messages = [e["message"] for e in log_events] + for line in expected_lines: + if ".*" in line: + found = [re.match(line, m, flags=re.DOTALL) for m in log_messages] + if any(found): + continue + assert line in log_messages + return log_messages + + return _check_logs + + +@pytest.fixture +def create_policy(aws_client): + policy_arns = [] + + def _create_policy(*args, iam_client=None, **kwargs): + iam_client = iam_client or aws_client.iam + if "PolicyName" not in kwargs: + kwargs["PolicyName"] = f"policy-{short_uid()}" + response = iam_client.create_policy(*args, **kwargs) + policy_arn = response["Policy"]["Arn"] + policy_arns.append((policy_arn, iam_client)) + return response + + yield _create_policy + + for policy_arn, iam_client in policy_arns: + try: + iam_client.delete_policy(PolicyArn=policy_arn) + except Exception: + LOG.debug("Could not delete policy '%s' during test cleanup", policy_arn) + + +@pytest.fixture +def create_user(aws_client): + usernames = [] + + def _create_user(**kwargs): + if "UserName" not in kwargs: + kwargs["UserName"] = f"user-{short_uid()}" + response = aws_client.iam.create_user(**kwargs) + usernames.append(response["User"]["UserName"]) + return response + + yield _create_user + + for username in usernames: + try: + inline_policies = aws_client.iam.list_user_policies(UserName=username)["PolicyNames"] + except ClientError as e: + LOG.debug( + "Cannot list user policies: %s. User %s probably already deleted...", e, username + ) + continue + + for inline_policy in inline_policies: + try: + aws_client.iam.delete_user_policy(UserName=username, PolicyName=inline_policy) + except Exception: + LOG.debug( + "Could not delete user policy '%s' from '%s' during cleanup", + inline_policy, + username, + ) + attached_policies = aws_client.iam.list_attached_user_policies(UserName=username)[ + "AttachedPolicies" + ] + for attached_policy in attached_policies: + try: + aws_client.iam.detach_user_policy( + UserName=username, PolicyArn=attached_policy["PolicyArn"] + ) + except Exception: + LOG.debug( + "Error detaching policy '%s' from user '%s'", + attached_policy["PolicyArn"], + username, + ) + access_keys = aws_client.iam.list_access_keys(UserName=username)["AccessKeyMetadata"] + for access_key in access_keys: + try: + aws_client.iam.delete_access_key( + UserName=username, AccessKeyId=access_key["AccessKeyId"] + ) + except Exception: + LOG.debug( + "Error deleting access key '%s' from user '%s'", + access_key["AccessKeyId"], + username, + ) + + try: + aws_client.iam.delete_user(UserName=username) + except Exception as e: + LOG.debug("Error deleting user '%s' during test cleanup: %s", username, e) + + +@pytest.fixture +def wait_and_assume_role(aws_client): + def _wait_and_assume_role(role_arn: str, session_name: str = None, **kwargs): + if not session_name: + session_name = f"session-{short_uid()}" + + def assume_role(): + return aws_client.sts.assume_role( + RoleArn=role_arn, RoleSessionName=session_name, **kwargs + )["Credentials"] + + # need to retry a couple of times before we are allowed to assume this role in AWS + keys = retry(assume_role, sleep=5, retries=4) + return keys + + return _wait_and_assume_role + + +@pytest.fixture +def create_role(aws_client): + role_names = [] + + def _create_role(iam_client=None, **kwargs): + if not kwargs.get("RoleName"): + kwargs["RoleName"] = f"role-{short_uid()}" + iam_client = iam_client or aws_client.iam + result = iam_client.create_role(**kwargs) + role_names.append((result["Role"]["RoleName"], iam_client)) + return result + + yield _create_role + + for role_name, iam_client in role_names: + # detach policies + try: + attached_policies = iam_client.list_attached_role_policies(RoleName=role_name)[ + "AttachedPolicies" + ] + except ClientError as e: + LOG.debug( + "Cannot list attached role policies: %s. Role %s probably already deleted...", + e, + role_name, + ) + continue + for attached_policy in attached_policies: + try: + iam_client.detach_role_policy( + RoleName=role_name, PolicyArn=attached_policy["PolicyArn"] + ) + except Exception: + LOG.debug( + "Could not detach role policy '%s' from '%s' during cleanup", + attached_policy["PolicyArn"], + role_name, + ) + role_policies = iam_client.list_role_policies(RoleName=role_name)["PolicyNames"] + for role_policy in role_policies: + try: + iam_client.delete_role_policy(RoleName=role_name, PolicyName=role_policy) + except Exception: + LOG.debug( + "Could not delete role policy '%s' from '%s' during cleanup", + role_policy, + role_name, + ) + try: + iam_client.delete_role(RoleName=role_name) + except Exception: + LOG.debug("Could not delete role '%s' during cleanup", role_name) + + +@pytest.fixture +def create_parameter(aws_client): + params = [] + + def _create_parameter(**kwargs): + params.append(kwargs["Name"]) + return aws_client.ssm.put_parameter(**kwargs) + + yield _create_parameter + + for param in params: + aws_client.ssm.delete_parameter(Name=param) + + +@pytest.fixture +def create_secret(aws_client): + items = [] + + def _create_parameter(**kwargs): + create_response = aws_client.secretsmanager.create_secret(**kwargs) + items.append(create_response["ARN"]) + return create_response + + yield _create_parameter + + for item in items: + aws_client.secretsmanager.delete_secret(SecretId=item, ForceDeleteWithoutRecovery=True) + + +# TODO Figure out how to make cert creation tests pass against AWS. +# +# We would like to have localstack tests to pass not just against localstack, but also against AWS to make sure +# our emulation is correct. Unfortunately, with certificate creation there are some issues. +# +# In AWS newly created ACM certificates have to be validated either by email or by DNS. The latter is +# by adding some CNAME records as requested by ASW in response to a certificate request. +# For testing purposes the DNS one seems to be easier, at least as long as DNS is handled by Region53 AWS DNS service. +# +# The other possible option is to use IAM certificates instead of ACM ones. Those just have to be uploaded from files +# created by openssl etc. Not sure if there are other issues after that. +# +# The third option might be having in AWS some certificates created in advance - so they do not require validation +# and can be easily used in tests. The issie with such an approach is that for AppSync, for example, in order to +# register a domain name (https://docs.aws.amazon.com/appsync/latest/APIReference/API_CreateDomainName.html), +# the domain name in the API request has to match the domain name used in certificate creation. Which means that with +# pre-created certificates we would have to use specific domain names instead of random ones. +@pytest.fixture +def acm_request_certificate(aws_client_factory): + certificate_arns = [] + + def factory(**kwargs) -> str: + if "DomainName" not in kwargs: + kwargs["DomainName"] = f"test-domain-{short_uid()}.localhost.localstack.cloud" + + region_name = kwargs.pop("region_name", None) + acm_client = aws_client_factory(region_name=region_name).acm + + response = acm_client.request_certificate(**kwargs) + created_certificate_arn = response["CertificateArn"] + certificate_arns.append((created_certificate_arn, region_name)) + return response + + yield factory + + # cleanup + for certificate_arn, region_name in certificate_arns: + try: + acm_client = aws_client_factory(region_name=region_name).acm + acm_client.delete_certificate(CertificateArn=certificate_arn) + except Exception as e: + LOG.debug("error cleaning up certificate %s: %s", certificate_arn, e) + + +role_policy_su = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": ["*"], "Resource": ["*"]}], +} + + +@pytest.fixture(scope="session") +def lambda_su_role(aws_client): + role_name = f"lambda-autogenerated-{short_uid()}" + role = aws_client.iam.create_role( + RoleName=role_name, AssumeRolePolicyDocument=role_assume_policy + )["Role"] + policy_name = f"lambda-autogenerated-{short_uid()}" + policy_arn = aws_client.iam.create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(role_policy_su) + )["Policy"]["Arn"] + aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + + if is_aws_cloud(): # dirty but necessary + time.sleep(10) + + yield role["Arn"] + + run_safe(aws_client.iam.detach_role_policy(RoleName=role_name, PolicyArn=policy_arn)) + run_safe(aws_client.iam.delete_role(RoleName=role_name)) + run_safe(aws_client.iam.delete_policy(PolicyArn=policy_arn)) + + +@pytest.fixture +def create_iam_role_and_attach_policy(aws_client): + """ + Fixture that creates an IAM role with given role definition and predefined policy ARN. + + Use this fixture with AWS managed policies like 'AmazonS3ReadOnlyAccess' or 'AmazonKinesisFullAccess'. + """ + roles = [] + + def _inner(**kwargs: dict[str, any]) -> str: + """ + :param dict RoleDefinition: role definition document + :param str PolicyArn: policy ARN + :param str RoleName: role name (autogenerated if omitted) + :return: role ARN + """ + if "RoleName" not in kwargs: + kwargs["RoleName"] = f"test-role-{short_uid()}" + + role = kwargs["RoleName"] + role_policy = json.dumps(kwargs["RoleDefinition"]) + + result = aws_client.iam.create_role(RoleName=role, AssumeRolePolicyDocument=role_policy) + role_arn = result["Role"]["Arn"] + + policy_arn = kwargs["PolicyArn"] + aws_client.iam.attach_role_policy(PolicyArn=policy_arn, RoleName=role) + + roles.append(role) + return role_arn + + yield _inner + + for role in roles: + try: + aws_client.iam.delete_role(RoleName=role) + except Exception as exc: + LOG.debug("Error deleting IAM role '%s': %s", role, exc) + + +@pytest.fixture +def create_iam_role_with_policy(aws_client): + """ + Fixture that creates an IAM role with given role definition and policy definition. + """ + roles = {} + + def _create_role_and_policy(**kwargs: dict[str, any]) -> str: + """ + :param dict RoleDefinition: role definition document + :param dict PolicyDefinition: policy definition document + :param str PolicyName: policy name (autogenerated if omitted) + :param str RoleName: role name (autogenerated if omitted) + :return: role ARN + """ + if "RoleName" not in kwargs: + kwargs["RoleName"] = f"test-role-{short_uid()}" + role = kwargs["RoleName"] + if "PolicyName" not in kwargs: + kwargs["PolicyName"] = f"test-policy-{short_uid()}" + policy = kwargs["PolicyName"] + role_policy = json.dumps(kwargs["RoleDefinition"]) + + result = aws_client.iam.create_role(RoleName=role, AssumeRolePolicyDocument=role_policy) + role_arn = result["Role"]["Arn"] + + policy_document = json.dumps(kwargs["PolicyDefinition"]) + aws_client.iam.put_role_policy( + RoleName=role, PolicyName=policy, PolicyDocument=policy_document + ) + roles[role] = policy + return role_arn + + yield _create_role_and_policy + + for role_name, policy_name in roles.items(): + try: + aws_client.iam.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + except Exception as exc: + LOG.debug("Error deleting IAM role policy '%s' '%s': %s", role_name, policy_name, exc) + try: + aws_client.iam.delete_role(RoleName=role_name) + except Exception as exc: + LOG.debug("Error deleting IAM role '%s': %s", role_name, exc) + + +@pytest.fixture +def firehose_create_delivery_stream(wait_for_delivery_stream_ready, aws_client): + delivery_stream_names = [] + + def _create_delivery_stream(**kwargs): + if "DeliveryStreamName" not in kwargs: + kwargs["DeliveryStreamName"] = f"test-delivery-stream-{short_uid()}" + delivery_stream_name = kwargs["DeliveryStreamName"] + response = aws_client.firehose.create_delivery_stream(**kwargs) + delivery_stream_names.append(delivery_stream_name) + wait_for_delivery_stream_ready(delivery_stream_name) + return response + + yield _create_delivery_stream + + for delivery_stream_name in delivery_stream_names: + try: + aws_client.firehose.delete_delivery_stream(DeliveryStreamName=delivery_stream_name) + except Exception: + LOG.info("Failed to delete delivery stream %s", delivery_stream_name) + + +@pytest.fixture +def ses_configuration_set(aws_client): + configuration_set_names = [] + + def factory(name: str) -> None: + aws_client.ses.create_configuration_set( + ConfigurationSet={ + "Name": name, + }, + ) + configuration_set_names.append(name) + + yield factory + + for configuration_set_name in configuration_set_names: + aws_client.ses.delete_configuration_set(ConfigurationSetName=configuration_set_name) + + +@pytest.fixture +def ses_configuration_set_sns_event_destination(aws_client): + event_destinations = [] + + def factory(config_set_name: str, event_destination_name: str, topic_arn: str) -> None: + aws_client.ses.create_configuration_set_event_destination( + ConfigurationSetName=config_set_name, + EventDestination={ + "Name": event_destination_name, + "Enabled": True, + "MatchingEventTypes": ["send", "bounce", "delivery", "open", "click"], + "SNSDestination": { + "TopicARN": topic_arn, + }, + }, + ) + event_destinations.append((config_set_name, event_destination_name)) + + yield factory + + for created_config_set_name, created_event_destination_name in event_destinations: + aws_client.ses.delete_configuration_set_event_destination( + ConfigurationSetName=created_config_set_name, + EventDestinationName=created_event_destination_name, + ) + + +@pytest.fixture +def ses_email_template(aws_client): + template_names = [] + + def factory(name: str, contents: str, subject: str = f"Email template {short_uid()}"): + aws_client.ses.create_template( + Template={ + "TemplateName": name, + "SubjectPart": subject, + "TextPart": contents, + } + ) + template_names.append(name) + + yield factory + + for template_name in template_names: + aws_client.ses.delete_template(TemplateName=template_name) + + +@pytest.fixture +def ses_verify_identity(aws_client): + identities = [] + + def factory(email_address: str) -> None: + aws_client.ses.verify_email_identity(EmailAddress=email_address) + + yield factory + + for identity in identities: + aws_client.ses.delete_identity(Identity=identity) + + +@pytest.fixture +def setup_sender_email_address(ses_verify_identity): + """ + If the test is running against AWS then assume the email address passed is already + verified, and passes the given email address through. Otherwise, it generates one random + email address and verify them. + """ + + def inner(sender_email_address: Optional[str] = None) -> str: + if is_aws_cloud(): + if sender_email_address is None: + raise ValueError( + "sender_email_address must be specified to run this test against AWS" + ) + else: + # overwrite the given parameters with localstack specific ones + sender_email_address = f"sender-{short_uid()}@example.com" + ses_verify_identity(sender_email_address) + + return sender_email_address + + return inner + + +@pytest.fixture +def ec2_create_security_group(aws_client): + ec2_sgs = [] + + def factory(ports=None, ip_protocol: str = "tcp", **kwargs): + """ + Create the target group and authorize the security group ingress. + :param ports: list of ports to be authorized for the ingress rule. + :param ip_protocol: the ip protocol for the permissions (tcp by default) + """ + if "GroupName" not in kwargs: + # FIXME: This will fail against AWS since the sg prefix is not valid for GroupName + # > "Group names may not be in the format sg-*". + kwargs["GroupName"] = f"sg-{short_uid()}" + # Making sure the call to CreateSecurityGroup gets the right arguments + _args = select_from_typed_dict(CreateSecurityGroupRequest, kwargs) + security_group = aws_client.ec2.create_security_group(**_args) + security_group_id = security_group["GroupId"] + + # FIXME: If 'ports' is None or an empty list, authorize_security_group_ingress will fail due to missing IpPermissions. + # Must ensure ports are explicitly provided or skip authorization entirely if not required. + permissions = [ + { + "FromPort": port, + "IpProtocol": ip_protocol, + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + "ToPort": port, + } + for port in ports or [] + ] + aws_client.ec2.authorize_security_group_ingress( + GroupId=security_group_id, + IpPermissions=permissions, + ) + + ec2_sgs.append(security_group_id) + return security_group + + yield factory + + for sg_group_id in ec2_sgs: + try: + aws_client.ec2.delete_security_group(GroupId=sg_group_id) + except Exception as e: + LOG.debug("Error cleaning up EC2 security group: %s, %s", sg_group_id, e) + + +@pytest.fixture +def cleanups(): + cleanup_fns = [] + + yield cleanup_fns + + for cleanup_callback in cleanup_fns[::-1]: + try: + cleanup_callback() + except Exception as e: + LOG.warning("Failed to execute cleanup", exc_info=e) + + +@pytest.fixture(scope="session") +def account_id(aws_client): + if is_aws_cloud() or is_api_enabled("sts"): + return aws_client.sts.get_caller_identity()["Account"] + else: + return TEST_AWS_ACCOUNT_ID + + +@pytest.fixture(scope="session") +def region_name(aws_client): + if is_aws_cloud() or is_api_enabled("sts"): + return aws_client.sts.meta.region_name + else: + return TEST_AWS_REGION_NAME + + +@pytest.fixture(scope="session") +def partition(region_name): + return get_partition(region_name) + + +@pytest.fixture(scope="session") +def secondary_account_id(secondary_aws_client): + if is_aws_cloud() or is_api_enabled("sts"): + return secondary_aws_client.sts.get_caller_identity()["Account"] + else: + return SECONDARY_TEST_AWS_ACCOUNT_ID + + +@pytest.fixture(scope="session") +def secondary_region_name(): + return SECONDARY_TEST_AWS_REGION_NAME + + +@pytest.hookimpl +def pytest_collection_modifyitems(config: Config, items: list[Item]): + only_localstack = pytest.mark.skipif( + is_aws_cloud(), + reason="test only applicable if run against localstack", + ) + for item in items: + for mark in item.iter_markers(): + if mark.name.endswith("only_localstack"): + item.add_marker(only_localstack) + if hasattr(item, "fixturenames") and "snapshot" in item.fixturenames: + # add a marker that indicates that this test is snapshot validated + # if it uses the snapshot fixture -> allows selecting only snapshot + # validated tests in order to capture new snapshots for a whole + # test file with "-m snapshot_validated" + item.add_marker("snapshot_validated") + + +@pytest.fixture +def sample_stores() -> AccountRegionBundle: + class SampleStore(BaseStore): + CROSS_ACCOUNT_ATTR = CrossAccountAttribute(default=list) + CROSS_REGION_ATTR = CrossRegionAttribute(default=list) + region_specific_attr = LocalAttribute(default=list) + + return AccountRegionBundle("zzz", SampleStore, validate=False) + + +@pytest.fixture +def create_rest_apigw(aws_client_factory): + rest_apis = [] + retry_boto_config = None + if is_aws_cloud(): + retry_boto_config = botocore.config.Config( + # Api gateway can throttle requests pretty heavily. Leading to potentially undeleted apis + retries={"max_attempts": 10, "mode": "adaptive"} + ) + + def _create_apigateway_function(**kwargs): + client_region_name = kwargs.pop("region_name", None) + apigateway_client = aws_client_factory( + region_name=client_region_name, config=retry_boto_config + ).apigateway + kwargs.setdefault("name", f"api-{short_uid()}") + + response = apigateway_client.create_rest_api(**kwargs) + api_id = response.get("id") + rest_apis.append((api_id, client_region_name)) + + return api_id, response.get("name"), response.get("rootResourceId") + + yield _create_apigateway_function + + for rest_api_id, _client_region_name in rest_apis: + apigateway_client = aws_client_factory( + region_name=_client_region_name, + config=retry_boto_config, + ).apigateway + # First, retrieve the usage plans associated with the REST API + usage_plan_ids = [] + usage_plans = apigateway_client.get_usage_plans() + for item in usage_plans.get("items", []): + api_stages = item.get("apiStages", []) + usage_plan_ids.extend( + item.get("id") for api_stage in api_stages if api_stage.get("apiId") == rest_api_id + ) + + # Then delete the API, as you can't delete the UsagePlan if a stage is associated with it + with contextlib.suppress(Exception): + apigateway_client.delete_rest_api(restApiId=rest_api_id) + + # finally delete the usage plans and the API Keys linked to it + for usage_plan_id in usage_plan_ids: + usage_plan_keys = apigateway_client.get_usage_plan_keys(usagePlanId=usage_plan_id) + for key in usage_plan_keys.get("items", []): + apigateway_client.delete_api_key(apiKey=key["id"]) + apigateway_client.delete_usage_plan(usagePlanId=usage_plan_id) + + +@pytest.fixture +def create_rest_apigw_openapi(aws_client_factory): + rest_apis = [] + + def _create_apigateway_function(**kwargs): + region_name = kwargs.pop("region_name", None) + apigateway_client = aws_client_factory(region_name=region_name).apigateway + + response = apigateway_client.import_rest_api(**kwargs) + api_id = response.get("id") + rest_apis.append((api_id, region_name)) + return api_id, response + + yield _create_apigateway_function + + for rest_api_id, region_name in rest_apis: + with contextlib.suppress(Exception): + apigateway_client = aws_client_factory(region_name=region_name).apigateway + apigateway_client.delete_rest_api(restApiId=rest_api_id) + + +@pytest.fixture +def assert_host_customisation(monkeypatch): + localstack_host = "foo.bar" + monkeypatch.setattr( + config, "LOCALSTACK_HOST", config.HostAndPort(host=localstack_host, port=8888) + ) + + def asserter( + url: str, + *, + custom_host: Optional[str] = None, + ): + if custom_host is not None: + assert custom_host in url, f"Could not find `{custom_host}` in `{url}`" + + assert localstack_host not in url + else: + assert localstack_host in url, f"Could not find `{localstack_host}` in `{url}`" + + yield asserter + + +@pytest.fixture +def echo_http_server(httpserver: HTTPServer): + """Spins up a local HTTP echo server and returns the endpoint URL""" + + def _echo(request: Request) -> Response: + request_json = None + if request.is_json: + with contextlib.suppress(ValueError): + request_json = json.loads(request.data) + result = { + "data": request.data or "{}", + "headers": dict(request.headers), + "url": request.url, + "method": request.method, + "json": request_json, + } + response_body = json.dumps(json_safe(result)) + return Response(response_body, status=200) + + httpserver.expect_request("").respond_with_handler(_echo) + http_endpoint = httpserver.url_for("/") + + return http_endpoint + + +@pytest.fixture +def echo_http_server_post(echo_http_server): + """ + Returns an HTTP echo server URL for POST requests that work both locally and for parity tests (against real AWS) + """ + if is_aws_cloud(): + return f"{PUBLIC_HTTP_ECHO_SERVER_URL}/post" + + return f"{echo_http_server}post" + + +def create_policy_doc(effect: str, actions: List, resource=None) -> Dict: + actions = ensure_list(actions) + resource = resource or "*" + return { + "Version": "2012-10-17", + "Statement": [ + { + # TODO statement ids have to be alphanumeric [0-9A-Za-z], write a test for it + "Sid": f"s{short_uid()}", + "Effect": effect, + "Action": actions, + "Resource": resource, + } + ], + } + + +@pytest.fixture +def create_policy_generated_document(create_policy): + def _create_policy_with_doc(effect, actions, policy_name=None, resource=None, iam_client=None): + policy_name = policy_name or f"p-{short_uid()}" + policy = create_policy_doc(effect, actions, resource=resource) + response = create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(policy), iam_client=iam_client + ) + policy_arn = response["Policy"]["Arn"] + return policy_arn + + return _create_policy_with_doc + + +@pytest.fixture +def create_role_with_policy(create_role, create_policy_generated_document, aws_client): + def _create_role_with_policy( + effect, actions, assume_policy_doc, resource=None, attach=True, iam_client=None + ): + iam_client = iam_client or aws_client.iam + + role_name = f"role-{short_uid()}" + result = create_role( + RoleName=role_name, AssumeRolePolicyDocument=assume_policy_doc, iam_client=iam_client + ) + role_arn = result["Role"]["Arn"] + policy_name = f"p-{short_uid()}" + + if attach: + # create role and attach role policy + policy_arn = create_policy_generated_document( + effect, actions, policy_name=policy_name, resource=resource, iam_client=iam_client + ) + iam_client.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + else: + # put role policy + policy_document = create_policy_doc(effect, actions, resource=resource) + policy_document = json.dumps(policy_document) + iam_client.put_role_policy( + RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy_document + ) + + return role_name, role_arn + + return _create_role_with_policy + + +@pytest.fixture +def create_user_with_policy(create_policy_generated_document, create_user, aws_client): + def _create_user_with_policy(effect, actions, resource=None): + policy_arn = create_policy_generated_document(effect, actions, resource=resource) + username = f"user-{short_uid()}" + create_user(UserName=username) + aws_client.iam.attach_user_policy(UserName=username, PolicyArn=policy_arn) + keys = aws_client.iam.create_access_key(UserName=username)["AccessKey"] + return username, keys + + return _create_user_with_policy + + +@pytest.fixture() +def register_extension(s3_bucket, aws_client): + cfn_client = aws_client.cloudformation + extensions_arns = [] + + def _register(extension_name, extension_type, artifact_path): + bucket = s3_bucket + key = f"artifact-{short_uid()}" + + aws_client.s3.upload_file(artifact_path, bucket, key) + + register_response = cfn_client.register_type( + Type=extension_type, + TypeName=extension_name, + SchemaHandlerPackage=f"s3://{bucket}/{key}", + ) + + registration_token = register_response["RegistrationToken"] + cfn_client.get_waiter("type_registration_complete").wait( + RegistrationToken=registration_token + ) + + describe_response = cfn_client.describe_type_registration( + RegistrationToken=registration_token + ) + + extensions_arns.append(describe_response["TypeArn"]) + cfn_client.set_type_default_version(Arn=describe_response["TypeVersionArn"]) + + return describe_response + + yield _register + + for arn in extensions_arns: + versions = cfn_client.list_type_versions(Arn=arn)["TypeVersionSummaries"] + for v in versions: + try: + cfn_client.deregister_type(Arn=v["Arn"]) + except Exception: + continue + cfn_client.deregister_type(Arn=arn) + + +@pytest.fixture +def hosted_zone(aws_client): + zone_ids = [] + + def factory(**kwargs): + if "CallerReference" not in kwargs: + kwargs["CallerReference"] = f"ref-{short_uid()}" + response = aws_client.route53.create_hosted_zone(**kwargs) + zone_id = response["HostedZone"]["Id"] + zone_ids.append(zone_id) + return response + + yield factory + + for zone_id in zone_ids[::-1]: + aws_client.route53.delete_hosted_zone(Id=zone_id) + + +@pytest.fixture +def openapi_validate(monkeypatch): + monkeypatch.setattr(config, "OPENAPI_VALIDATE_RESPONSE", "true") + monkeypatch.setattr(config, "OPENAPI_VALIDATE_REQUEST", "true") + + +@pytest.fixture +def set_resource_custom_id(): + set_ids = [] + + def _set_custom_id(resource_identifier: ResourceIdentifier, custom_id): + localstack_id_manager.set_custom_id( + resource_identifier=resource_identifier, custom_id=custom_id + ) + set_ids.append(resource_identifier) + + yield _set_custom_id + + for resource_identifier in set_ids: + localstack_id_manager.unset_custom_id(resource_identifier) + + +############################### +# Events (EventBridge) fixtures +############################### + + +@pytest.fixture +def events_create_event_bus(aws_client, region_name, account_id): + event_bus_names = [] + + def _create_event_bus(**kwargs): + if "Name" not in kwargs: + kwargs["Name"] = f"test-event-bus-{short_uid()}" + + response = aws_client.events.create_event_bus(**kwargs) + event_bus_names.append(kwargs["Name"]) + return response + + yield _create_event_bus + + for event_bus_name in event_bus_names: + try: + response = aws_client.events.list_rules(EventBusName=event_bus_name) + rules = [rule["Name"] for rule in response["Rules"]] + + # Delete all rules for the current event bus + for rule in rules: + try: + response = aws_client.events.list_targets_by_rule( + Rule=rule, EventBusName=event_bus_name + ) + targets = [target["Id"] for target in response["Targets"]] + + # Remove all targets for the current rule + if targets: + for target in targets: + aws_client.events.remove_targets( + Rule=rule, EventBusName=event_bus_name, Ids=[target] + ) + + aws_client.events.delete_rule(Name=rule, EventBusName=event_bus_name) + except Exception as e: + LOG.warning("Failed to delete rule %s: %s", rule, e) + + # Delete archives for event bus + event_source_arn = ( + f"arn:aws:events:{region_name}:{account_id}:event-bus/{event_bus_name}" + ) + response = aws_client.events.list_archives(EventSourceArn=event_source_arn) + archives = [archive["ArchiveName"] for archive in response["Archives"]] + for archive in archives: + try: + aws_client.events.delete_archive(ArchiveName=archive) + except Exception as e: + LOG.warning("Failed to delete archive %s: %s", archive, e) + + aws_client.events.delete_event_bus(Name=event_bus_name) + except Exception as e: + LOG.warning("Failed to delete event bus %s: %s", event_bus_name, e) + + +@pytest.fixture +def events_put_rule(aws_client): + rules = [] + + def _put_rule(**kwargs): + if "Name" not in kwargs: + kwargs["Name"] = f"rule-{short_uid()}" + + response = aws_client.events.put_rule(**kwargs) + rules.append((kwargs["Name"], kwargs.get("EventBusName", "default"))) + return response + + yield _put_rule + + for rule, event_bus_name in rules: + try: + response = aws_client.events.list_targets_by_rule( + Rule=rule, EventBusName=event_bus_name + ) + targets = [target["Id"] for target in response["Targets"]] + + # Remove all targets for the current rule + if targets: + for target in targets: + aws_client.events.remove_targets( + Rule=rule, EventBusName=event_bus_name, Ids=[target] + ) + + aws_client.events.delete_rule(Name=rule, EventBusName=event_bus_name) + except Exception as e: + LOG.warning("Failed to delete rule %s: %s", rule, e) + + +@pytest.fixture +def events_create_rule(aws_client): + rules = [] + + def _create_rule(**kwargs): + rule_name = kwargs["Name"] + bus_name = kwargs.get("EventBusName", "") + pattern = kwargs.get("EventPattern", {}) + schedule = kwargs.get("ScheduleExpression", "") + rule_arn = aws_client.events.put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(pattern), + ScheduleExpression=schedule, + )["RuleArn"] + rules.append({"name": rule_name, "bus": bus_name}) + return rule_arn + + yield _create_rule + + for rule in rules: + targets = aws_client.events.list_targets_by_rule( + Rule=rule["name"], EventBusName=rule["bus"] + )["Targets"] + + targetIds = [target["Id"] for target in targets] + if len(targetIds) > 0: + aws_client.events.remove_targets( + Rule=rule["name"], EventBusName=rule["bus"], Ids=targetIds + ) + + aws_client.events.delete_rule(Name=rule["name"], EventBusName=rule["bus"]) + + +@pytest.fixture +def sqs_as_events_target(aws_client, sqs_get_queue_arn): + queue_urls = [] + + def _sqs_as_events_target(queue_name: str | None = None) -> tuple[str, str]: + if not queue_name: + queue_name = f"tests-queue-{short_uid()}" + sqs_client = aws_client.sqs + queue_url = sqs_client.create_queue(QueueName=queue_name)["QueueUrl"] + queue_urls.append(queue_url) + queue_arn = sqs_get_queue_arn(queue_url) + policy = { + "Version": "2012-10-17", + "Id": f"sqs-eventbridge-{short_uid()}", + "Statement": [ + { + "Sid": f"SendMessage-{short_uid()}", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": queue_arn, + } + ], + } + sqs_client.set_queue_attributes( + QueueUrl=queue_url, Attributes={"Policy": json.dumps(policy)} + ) + return queue_url, queue_arn + + yield _sqs_as_events_target + + for queue_url in queue_urls: + try: + aws_client.sqs.delete_queue(QueueUrl=queue_url) + except Exception as e: + LOG.debug("error cleaning up queue %s: %s", queue_url, e) + + +@pytest.fixture +def clean_up( + aws_client, +): # TODO: legacy clean up fixtures for eventbridge - remove and use individual fixtures for creating resources instead + def _clean_up( + bus_name=None, + rule_name=None, + target_ids=None, + queue_url=None, + log_group_name=None, + ): + events_client = aws_client.events + kwargs = {"EventBusName": bus_name} if bus_name else {} + if target_ids: + target_ids = target_ids if isinstance(target_ids, list) else [target_ids] + call_safe( + events_client.remove_targets, + kwargs=dict(Rule=rule_name, Ids=target_ids, Force=True, **kwargs), + ) + if rule_name: + call_safe(events_client.delete_rule, kwargs=dict(Name=rule_name, Force=True, **kwargs)) + if bus_name: + call_safe(events_client.delete_event_bus, kwargs=dict(Name=bus_name)) + if queue_url: + sqs_client = aws_client.sqs + call_safe(sqs_client.delete_queue, kwargs=dict(QueueUrl=queue_url)) + if log_group_name: + logs_client = aws_client.logs + + def _delete_log_group(): + log_streams = logs_client.describe_log_streams(logGroupName=log_group_name) + for log_stream in log_streams["logStreams"]: + logs_client.delete_log_stream( + logGroupName=log_group_name, logStreamName=log_stream["logStreamName"] + ) + logs_client.delete_log_group(logGroupName=log_group_name) + + call_safe(_delete_log_group) + + yield _clean_up diff --git a/localstack-core/localstack/testing/pytest/in_memory_localstack.py b/localstack-core/localstack/testing/pytest/in_memory_localstack.py new file mode 100644 index 0000000000000..d31a570ac4b30 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/in_memory_localstack.py @@ -0,0 +1,107 @@ +"""Pytest plugin that spins up a single localstack instance in the current interpreter that is shared +across the current test session. + +Use in your module as follows:: + + pytest_plugins = "localstack.testing.pytest.in_memory_localstack" + + @pytest.hookimpl() + def pytest_configure(config): + config.option.start_localstack = True + +You can explicitly disable starting localstack by setting ``TEST_SKIP_LOCALSTACK_START=1`` or +``TEST_TARGET=AWS_CLOUD``.""" + +import logging +import os +import threading + +import pytest +from _pytest.config import PytestPluginManager +from _pytest.config.argparsing import Parser +from _pytest.main import Session + +from localstack import config as localstack_config +from localstack.config import is_env_true +from localstack.constants import ENV_INTERNAL_TEST_RUN + +LOG = logging.getLogger(__name__) +LOG.info("Pytest plugin for in-memory-localstack session loaded.") + +if localstack_config.is_collect_metrics_mode(): + pytest_plugins = "localstack.testing.pytest.metric_collection" + +_started = threading.Event() + + +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager): + parser.addoption( + "--start-localstack", + action="store_true", + default=False, + ) + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtestloop(session: Session): + # avoid starting up localstack if we only collect the tests (-co / --collect-only) + if session.config.option.collectonly: + return + + if not session.config.option.start_localstack: + return + + from localstack.testing.aws.util import is_aws_cloud + + if is_env_true("TEST_SKIP_LOCALSTACK_START"): + LOG.info("TEST_SKIP_LOCALSTACK_START is set, not starting localstack") + return + + if is_aws_cloud(): + if not is_env_true("TEST_FORCE_LOCALSTACK_START"): + LOG.info("Test running against aws, not starting localstack") + return + LOG.info("TEST_FORCE_LOCALSTACK_START is set, a Localstack instance will be created.") + + from localstack.utils.common import safe_requests + + if is_aws_cloud(): + localstack_config.DEFAULT_DELAY = 5 + localstack_config.DEFAULT_MAX_ATTEMPTS = 60 + + # configure + os.environ[ENV_INTERNAL_TEST_RUN] = "1" + safe_requests.verify_ssl = False + + from localstack.runtime import current + + _started.set() + runtime = current.initialize_runtime() + # start runtime asynchronously + threading.Thread(target=runtime.run).start() + + # wait for runtime to be ready + if not runtime.ready.wait(timeout=120): + raise TimeoutError("gave up waiting for runtime to be ready") + + +@pytest.hookimpl(trylast=True) +def pytest_sessionfinish(session: Session): + # last pytest lifecycle hook (before pytest exits) + if not _started.is_set(): + return + + from localstack.runtime import get_current_runtime + + try: + get_current_runtime() + except ValueError: + LOG.warning("Could not access the current runtime in a pytest sessionfinish hook.") + return + + get_current_runtime().shutdown() + LOG.info("waiting for runtime to stop") + + # wait for runtime to shut down + if not get_current_runtime().stopped.wait(timeout=20): + LOG.warning("gave up waiting for runtime to stop, returning anyway") diff --git a/localstack-core/localstack/testing/pytest/marker_report.py b/localstack-core/localstack/testing/pytest/marker_report.py new file mode 100644 index 0000000000000..03b8bc28f87d2 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/marker_report.py @@ -0,0 +1,157 @@ +import dataclasses +import datetime +import json +import os.path +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import requests + +if TYPE_CHECKING: + from _pytest.config import Config, PytestPluginManager + from _pytest.config.argparsing import Parser + + +@dataclasses.dataclass +class MarkerReportEntry: + node_id: str + file_path: str + markers: "list[str]" + + +@dataclasses.dataclass +class TinybirdReportRow: + timestamp: str + node_id: str + project_name: str + # code_owners: str # comma separated + file_path: str # TODO: recreate data source at some point to remove this? + service: str + markers: str # comma separated list + aws_marker: str # TODO: this is a bit redundant but easier for now to process + commit_sha: str + + +@dataclasses.dataclass +class MarkerReport: + prefix_filter: str + entries: "list[MarkerReportEntry]" = dataclasses.field(default_factory=list) + aggregated_report: "dict[str, int]" = dataclasses.field(default_factory=dict) + + def create_aggregated_report(self): + for entry in self.entries: + for marker in entry.markers: + self.aggregated_report.setdefault(marker, 0) + self.aggregated_report[marker] += 1 + + +@pytest.hookimpl +def pytest_addoption(parser: "Parser", pluginmanager: "PytestPluginManager"): + """ + Standard usage. Will create a report for all markers under ./target/marker-report-.json + $ python -m pytest tests/aws/ --marker-report + + Advanced usage. Will create a report for all markers under ./target2/marker-report-.json + $ python -m pytest tests/aws/ --marker-report --marker-report-path target2/ + + Advanced usage. Only includes markers with `aws_` prefix in the report. + $ python -m pytest tests/aws/ --marker-report --marker-report-prefix "aws_" + """ + # TODO: --marker-report-* flags should imply --marker-report + parser.addoption("--marker-report", action="store_true") + parser.addoption("--marker-report-prefix", action="store") + parser.addoption("--marker-report-path", action="store") + parser.addoption("--marker-report-summary", action="store_true") + parser.addoption("--marker-report-tinybird-upload", action="store_true") + + +def _get_svc_from_node_id(node_id: str) -> str: + if node_id.startswith("tests/aws/services/"): + parts = node_id.split("/") + return parts[3] + return "" + + +def _get_aws_marker_from_markers(markers: "list[str]") -> str: + for marker in markers: + if marker.startswith("aws_"): + return marker + return "" + + +@pytest.hookimpl +def pytest_collection_modifyitems( + session: pytest.Session, config: "Config", items: "list[pytest.Item]" +) -> None: + """Generate a report about the pytest markers used""" + + if not config.option.marker_report: + return + + report = MarkerReport(prefix_filter=config.option.marker_report_prefix or "") + + # go through collected items to collect their markers + for item in items: + markers = set() + for mark in item.iter_markers(): + if mark.name.startswith(report.prefix_filter): + markers.add(mark.name) + + report_entry = MarkerReportEntry( + node_id=item.nodeid, file_path=item.fspath.strpath, markers=list(markers) + ) + report.entries.append(report_entry) + + report.create_aggregated_report() + + if config.option.marker_report_path: + report_directory = Path(config.option.marker_report_path) + if not report_directory.is_absolute(): + report_directory = config.rootpath / report_directory + report_directory.mkdir(parents=True, exist_ok=True) + report_path = report_directory / f"marker-report-{time.time_ns()}.json" + + with open(report_path, "w") as fd: + json.dump(dataclasses.asdict(report), fd, indent=2, sort_keys=True) + + if config.option.marker_report_tinybird_upload: + project_name = os.environ.get("MARKER_REPORT_PROJECT_NAME", "localstack") + datasource_name = "pytest_markers__v0" + token = os.environ.get("MARKER_REPORT_TINYBIRD_TOKEN") + url = f"https://api.tinybird.co/v0/events?name={datasource_name}&token={token}" + + timestamp = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + tinybird_data = [ + dataclasses.asdict( + TinybirdReportRow( + timestamp=timestamp, + node_id=x.node_id, + project_name=project_name, + file_path=x.file_path, + service=_get_svc_from_node_id(x.node_id), + markers=",".join(sorted(x.markers)), + aws_marker=_get_aws_marker_from_markers(x.markers), + commit_sha=os.environ.get("MARKER_REPORT_COMMIT_SHA", ""), + ) + ) + for x in report.entries + ] + + data = "\n".join(json.dumps(x) for x in tinybird_data) + + response = requests.post(url, data=data, timeout=20) + + if response.status_code != 202: + print(f"Error while uploading marker report to tinybird: {response.status_code}.") + else: + print("Successfully uploaded marker report to tinybird.") + + if config.option.marker_report_summary: + print("\n=========================") + print("MARKER REPORT (SUMMARY)") + print("=========================") + for k, v in report.aggregated_report.items(): + print(f"{k}: {v}") + print("=========================\n") diff --git a/localstack-core/localstack/testing/pytest/marking.py b/localstack-core/localstack/testing/pytest/marking.py new file mode 100644 index 0000000000000..5afcca6cdc24f --- /dev/null +++ b/localstack-core/localstack/testing/pytest/marking.py @@ -0,0 +1,210 @@ +""" +Custom pytest mark typings +""" + +import os +from typing import TYPE_CHECKING, Callable, List, Optional + +import pytest +from _pytest.config import PytestPluginManager +from _pytest.config.argparsing import Parser + + +class AwsCompatibilityMarkers: + # test has been successfully run against AWS, ideally multiple times + validated = pytest.mark.aws_validated + + # implies aws_validated. test needs additional setup, configuration or some other steps not included in the test setup itself + manual_setup_required = pytest.mark.aws_manual_setup_required + + # fails against AWS but should be made runnable against AWS in the future, basically a TODO + needs_fixing = pytest.mark.aws_needs_fixing + + # only runnable against localstack by design + only_localstack = pytest.mark.aws_only_localstack + + # it's unknown if the test works (reliably) against AWS or not + unknown = pytest.mark.aws_unknown + + +class ParityMarkers: + aws_validated = pytest.mark.aws_validated + only_localstack = pytest.mark.only_localstack + + +class SkipSnapshotVerifyMarker: + def __call__( + self, + *, + paths: "Optional[List[str]]" = None, + condition: "Optional[Callable[[...], bool]]" = None, + ): ... + + +class MultiRuntimeMarker: + def __call__(self, *, scenario: str, runtimes: Optional[List[str]] = None): ... + + +class SnapshotMarkers: + skip_snapshot_verify: SkipSnapshotVerifyMarker = pytest.mark.skip_snapshot_verify + + +class Markers: + aws = AwsCompatibilityMarkers + parity = ParityMarkers # TODO: in here for compatibility sake. Remove when -ext has been refactored to use @markers.aws.* + snapshot = SnapshotMarkers + + multiruntime: MultiRuntimeMarker = pytest.mark.multiruntime + + # test selection + acceptance_test = pytest.mark.acceptance_test + skip_offline = pytest.mark.skip_offline + only_on_amd64 = pytest.mark.only_on_amd64 + only_on_arm64 = pytest.mark.only_on_arm64 + resource_heavy = pytest.mark.resource_heavy + only_in_docker = pytest.mark.only_in_docker + # Tests to execute when updating snapshots for a new Lambda runtime + lambda_runtime_update = pytest.mark.lambda_runtime_update + + +# pytest plugin +if TYPE_CHECKING: + from _pytest.config import Config + + +@pytest.hookimpl +def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager): + parser.addoption( + "--offline", + action="store_true", + default=False, + help="test run will not have an internet connection", + ) + + +def enforce_single_aws_marker(items: List[pytest.Item]): + """Enforce that each test has exactly one aws compatibility marker""" + marker_errors = [] + + for item in items: + # we should only concern ourselves with tests in tests/aws/ + if "tests/aws" not in item.fspath.dirname: + continue + + aws_markers = list() + for mark in item.iter_markers(): + if mark.name.startswith("aws_"): + aws_markers.append(mark.name) + + if len(aws_markers) > 1: + marker_errors.append(f"{item.nodeid}: Too many aws markers specified: {aws_markers}") + elif len(aws_markers) == 0: + marker_errors.append( + f"{item.nodeid}: Missing aws marker. Specify at least one marker, e.g. @markers.aws.validated" + ) + + if marker_errors: + raise pytest.UsageError(*marker_errors) + + +def filter_by_markers(config: "Config", items: List[pytest.Item]): + """Filter tests by markers.""" + from localstack import config as localstack_config + from localstack.utils.bootstrap import in_ci + from localstack.utils.platform import Arch, get_arch + + is_offline = config.getoption("--offline") + is_in_docker = localstack_config.is_in_docker + is_in_ci = in_ci() + is_amd64 = get_arch() == Arch.amd64 + is_arm64 = get_arch() == Arch.arm64 + # Inlining `is_aws_cloud()` here because localstack.testing.aws.util imports boto3, + # which is not installed for the CLI tests + is_real_aws = os.environ.get("TEST_TARGET", "") == "AWS_CLOUD" + + if is_real_aws: + # Do not skip any tests if they are executed against real AWS + return + + skip_offline = pytest.mark.skip( + reason="Test cannot be executed offline / in a restricted network environment. " + "Add network connectivity and remove the --offline option when running " + "the test." + ) + only_in_docker = pytest.mark.skip( + reason="Test requires execution inside Docker (e.g., to install system packages)" + ) + only_on_amd64 = pytest.mark.skip( + reason="Test uses features that are currently only supported for AMD64. Skipping in CI." + ) + only_on_arm64 = pytest.mark.skip( + reason="Test uses features that are currently only supported for ARM64. Skipping in CI." + ) + + for item in items: + if is_offline and "skip_offline" in item.keywords: + item.add_marker(skip_offline) + if not is_in_docker and "only_in_docker" in item.keywords: + item.add_marker(only_in_docker) + if is_in_ci and not is_amd64 and "only_on_amd64" in item.keywords: + item.add_marker(only_on_amd64) + if is_in_ci and not is_arm64 and "only_on_arm64" in item.keywords: + item.add_marker(only_on_arm64) + + +@pytest.hookimpl +def pytest_collection_modifyitems( + session: pytest.Session, config: "Config", items: List[pytest.Item] +) -> None: + enforce_single_aws_marker(items) + filter_by_markers(config, items) + + +@pytest.hookimpl +def pytest_configure(config): + config.addinivalue_line( + "markers", + "skip_offline: mark the test to be skipped when the tests are run offline " + "(this test explicitly / semantically needs an internet connection)", + ) + config.addinivalue_line( + "markers", + "only_on_amd64: mark the test as running only in an amd64 (i.e., x86_64) environment", + ) + config.addinivalue_line( + "markers", + "only_on_arm64: mark the test as running only in an arm64 environment", + ) + config.addinivalue_line( + "markers", + "only_in_docker: mark the test as running only in Docker (e.g., requires installation of system packages)", + ) + config.addinivalue_line( + "markers", + "resource_heavy: mark the test as resource-heavy, e.g., downloading very large external dependencies, " + "or requiring high amount of RAM/CPU (can be systematically sampled/optimized in the future)", + ) + config.addinivalue_line( + "markers", + "aws_validated: mark the test as validated / verified against real AWS", + ) + config.addinivalue_line( + "markers", + "aws_only_localstack: mark the test as inherently incompatible with AWS, e.g. when testing localstack-specific features", + ) + config.addinivalue_line( + "markers", + "aws_needs_fixing: test fails against AWS but it shouldn't. Might need refactoring, additional permissions, etc.", + ) + config.addinivalue_line( + "markers", + "aws_manual_setup_required: validated against real AWS but needs additional setup or account configuration (e.g. increased service quotas)", + ) + config.addinivalue_line( + "markers", + "aws_unknown: it's unknown if the test works (reliably) against AWS or not", + ) + config.addinivalue_line( + "markers", + "multiruntime: parametrize test against multiple Lambda runtimes", + ) diff --git a/localstack-core/localstack/testing/pytest/metric_collection.py b/localstack-core/localstack/testing/pytest/metric_collection.py new file mode 100644 index 0000000000000..c480a5330ac29 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/metric_collection.py @@ -0,0 +1,68 @@ +import csv +import os +import re +from datetime import datetime +from pathlib import Path +from typing import Optional + +import pytest +from _pytest.main import Session +from _pytest.nodes import Item + +from localstack.aws.handlers.metric_handler import Metric, MetricHandler +from localstack.utils.strings import short_uid + +BASE_PATH = os.path.join(os.path.dirname(__file__), "../../../../target/metric_reports") +FNAME_RAW_DATA_CSV = os.path.join( + BASE_PATH, + f"metric-report-raw-data-{datetime.utcnow().strftime('%Y-%m-%d__%H_%M_%S')}-{short_uid()}.csv", +) + + +@pytest.hookimpl() +def pytest_sessionstart(session: "Session") -> None: + Path(BASE_PATH).mkdir(parents=True, exist_ok=True) + pattern = re.compile("--junitxml=(.*)\\.xml") + if session.config.invocation_params: + for ip in session.config.invocation_params.args: + if m := pattern.match(ip): + report_file_name = m.groups()[-1].split("/")[-1] + global FNAME_RAW_DATA_CSV + FNAME_RAW_DATA_CSV = os.path.join( + BASE_PATH, + f"metric-report-raw-data-{datetime.utcnow().strftime('%Y-%m-%d__%H_%M_%S')}-{report_file_name}.csv", + ) + + with open(FNAME_RAW_DATA_CSV, "w") as fd: + writer = csv.writer(fd) + writer.writerow(Metric.RAW_DATA_HEADER) + + +@pytest.hookimpl(trylast=True) +def pytest_runtest_teardown(item: "Item", nextitem: Optional["Item"]) -> None: + node_id = item.nodeid + xfail = False + aws_validated = False + snapshot = False + skipped = "" + + for _ in item.iter_markers(name="xfail"): + xfail = True + for _ in item.iter_markers(name="aws_validated"): + aws_validated = True + if hasattr(item, "fixturenames") and "snapshot" in item.fixturenames: + snapshot = True + for sk in item.iter_markers(name="skip_snapshot_verify"): + skipped = sk.kwargs.get("paths", "all") + + for metric in MetricHandler.metric_data: + metric.xfail = xfail + metric.aws_validated = aws_validated + metric.snapshot = snapshot + metric.node_id = node_id + metric.snapshot_skipped_paths = skipped + + with open(FNAME_RAW_DATA_CSV, "a") as fd: + writer = csv.writer(fd) + writer.writerows(MetricHandler.metric_data) + MetricHandler.metric_data.clear() diff --git a/localstack-core/localstack/testing/pytest/path_filter.py b/localstack-core/localstack/testing/pytest/path_filter.py new file mode 100644 index 0000000000000..d3e13c0016143 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/path_filter.py @@ -0,0 +1,91 @@ +""" +A pytest plugin that limits test selection based on an input file. +The input file is a plaintext file with one subpath entry per line. +After gathering all potential tests, the candidates are filtered by matching with these entries. +At least one entry has to match for the test to be included in the test run. + +Example usage: `pytest --path-filter=test_selection.txt` + +File content of `test_selection.txt`: + +``` +tests/mymodule/ +tests/myothermodule/test_conrete_thing.py +``` + +There are also special values that represent +a) SENTINEL_NO_TEST: change is not classified (=> run everything) +b) SENTINEL_ALL_TESTS: change that is explicitly classified but doesn't require running a test + +If all detected changes are in category b) there will be NO tests executed (!). +If any change in category a) is detected, ALL tests will be executed. + +""" + +import os + +import pytest +from _pytest.main import Session + +from localstack.testing.testselection.matching import SENTINEL_ALL_TESTS, SENTINEL_NO_TEST + + +def pytest_addoption(parser): + parser.addoption( + "--path-filter", + action="store", + help="Path to the file containing path substrings for test selection", + ) + + +# tryfirst would IMO make the most sense since I don't see a reason why other plugins should operate on the other tests at all +# the pytest-split plugin is executed with trylast=True, so it should come after this one +@pytest.hookimpl(tryfirst=True) +def pytest_collection_modifyitems(config, items): + pathfilter_file = config.getoption("--path-filter") + if not pathfilter_file: + return + + if not os.path.exists(pathfilter_file): + raise ValueError(f"Pathfilter file does not exist: {pathfilter_file}") + + with open(pathfilter_file, "r") as f: + pathfilter_substrings = [line.strip() for line in f.readlines() if line.strip()] + + if not pathfilter_substrings: + return # No filtering if the list is empty => full test suite + + # this is technically redundant since we can just add "tests/" instead as a line item. still prefer to be explicit here + if any(p == SENTINEL_ALL_TESTS for p in pathfilter_substrings): + return # at least one change should lead to a full run + + # technically doesn't even need to be checked since the loop below will take care of it + if all(p == SENTINEL_NO_TEST for p in pathfilter_substrings): + items[:] = [] + # we only got sentinal values that signal a change that doesn't need to be tested, so delesect all + config.hook.pytest_deselected(items=items) + return + + # Filter tests based on the path substrings + selected = [] + deselected = [] + for item in items: + if any(substr in item.fspath.strpath for substr in pathfilter_substrings): + selected.append(item) + else: + deselected.append(item) + + # Update list of test items to only those selected + items[:] = selected + config.hook.pytest_deselected(items=deselected) + + +def pytest_sessionfinish(session: Session, exitstatus): + """ + Tests might be split and thus there can be splits which don't select any tests right now + + This is only applied if we're actually using the plugin + """ + pathfilter_file = session.config.getoption("--path-filter") + if pathfilter_file and exitstatus == 5: + session.exitstatus = 0 diff --git a/localstack-core/localstack/testing/pytest/stepfunctions/__init__.py b/localstack-core/localstack/testing/pytest/stepfunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/pytest/stepfunctions/fixtures.py b/localstack-core/localstack/testing/pytest/stepfunctions/fixtures.py new file mode 100644 index 0000000000000..13a134d269e85 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/stepfunctions/fixtures.py @@ -0,0 +1,869 @@ +import json +import logging +import os +import shutil +import tempfile +from typing import Final + +import pytest +from botocore.config import Config +from localstack_snapshot.snapshots.transformer import ( + JsonpathTransformer, + RegexTransformer, +) + +from localstack.aws.api.stepfunctions import StateMachineType +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest.stepfunctions.utils import await_execution_success +from localstack.utils.strings import short_uid + +LOG = logging.getLogger(__name__) + + +@pytest.fixture +def sfn_snapshot(snapshot): + snapshot.add_transformers_list(snapshot.transform.stepfunctions_api()) + return snapshot + + +@pytest.fixture +def sfn_batch_snapshot(sfn_snapshot): + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..JobDefinition", replacement="job-definition") + ) + sfn_snapshot.add_transformer(JsonpathTransformer(jsonpath="$..JobName", replacement="job-name")) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..JobQueue", replacement="job-queue") + ) + sfn_snapshot.add_transformer(JsonpathTransformer(jsonpath="$..roleArn", replacement="role-arn")) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..x-amz-apigw-id", replacement="x-amz-apigw-id", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..X-Amzn-Trace-Id", replacement="X-Amzn-Trace-Id", replace_reference=False + ) + ) + sfn_snapshot.add_transformer(JsonpathTransformer(jsonpath="$..TaskArn", replacement="task-arn")) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..ExecutionRoleArn", replacement="execution-role-arn") + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..LogStreamName", replacement="log-stream-name") + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..StartedAt", replacement="time", replace_reference=False) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..StoppedAt", replacement="time", replace_reference=False) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..CreatedAt", replacement="time", replace_reference=False) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..PrivateIpv4Address", + replacement="private-ipv4-address", + replace_reference=False, + ) + ) + return sfn_snapshot + + +@pytest.fixture +def sfn_ecs_snapshot(sfn_snapshot): + sfn_snapshot.add_transformer(JsonpathTransformer(jsonpath="$..TaskArn", replacement="task_arn")) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..ContainerArn", replacement="container_arn") + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..PrivateIpv4Address", replacement="private_ipv4_address") + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..RuntimeId", replacement="runtime_id") + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..ImageDigest", replacement="image_digest") + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..PullStartedAt", replacement="time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..PullStoppedAt", replacement="time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..StartedAt", replacement="time", replace_reference=False) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..StoppedAt", replacement="time", replace_reference=False) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..StoppingAt", replacement="time", replace_reference=False) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer(jsonpath="$..CreatedAt", replacement="time", replace_reference=False) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..ExecutionStoppedAt", replacement="time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..ConnectivityAt", replacement="time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..PullStartedAt", replacement="time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..PullStoppedAt", replacement="time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer(RegexTransformer("subnet-[0-9a-zA-Z]+", "subnet_value")) + sfn_snapshot.add_transformer(RegexTransformer("sg-[0-9a-zA-Z]+", "sg_value")) + sfn_snapshot.add_transformer(RegexTransformer("eni-[0-9a-zA-Z]+", "eni_value")) + sfn_snapshot.add_transformer(RegexTransformer("ip-[0-9-]+", "ip_value")) + sfn_snapshot.add_transformer( + RegexTransformer(":".join(["[0-9a-z][0-9a-z]?[0-9a-z]?"] * 4), "ip_value") + ) + sfn_snapshot.add_transformer(RegexTransformer(":".join(["[0-9a-z][0-9a-z]+"] * 6), "mac_value")) + return sfn_snapshot + + +@pytest.fixture +def aws_client_no_sync_prefix(aws_client_factory): + # For StartSyncExecution and TestState calls, boto will prepend "sync-" to the endpoint string. + # As we operate on localhost, this function creates a new stepfunctions client with that functionality disabled. + return aws_client_factory(config=Config(inject_host_prefix=is_aws_cloud())) + + +@pytest.fixture +def mock_config_file(): + tmp_dir = tempfile.mkdtemp() + file_path = os.path.join(tmp_dir, "MockConfigFile.json") + + def write_json_to_mock_file(mock_config): + with open(file_path, "w") as df: + json.dump(mock_config, df) # noqa + df.flush() + return file_path + + try: + yield write_json_to_mock_file + finally: + try: + os.remove(file_path) + except Exception as ex: + LOG.error("Error removing temporary MockConfigFile.json: %s", ex) + finally: + shutil.rmtree( + tmp_dir, + ignore_errors=True, + onerror=lambda _, path, exc_info: LOG.error( + "Error removing temporary MockConfigFile.json: %s, %s", path, exc_info + ), + ) + + +@pytest.fixture +def create_state_machine_iam_role(cleanups, create_state_machine): + def _create(target_aws_client): + iam_client = target_aws_client.iam + stepfunctions_client = target_aws_client.stepfunctions + + role_name = f"test-sfn-role-{short_uid()}" + policy_name = f"test-sfn-policy-{short_uid()}" + role = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": ["states.amazonaws.com"]}, + "Action": ["sts:AssumeRole"], + } + ], + } + ), + ) + cleanups.append(lambda: iam_client.delete_role(RoleName=role_name)) + role_arn = role["Role"]["Arn"] + + policy = iam_client.create_policy( + PolicyName=policy_name, + PolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["*"], + "Resource": ["*"], + } + ], + } + ), + ) + cleanups.append(lambda: iam_client.delete_policy(PolicyArn=policy["Policy"]["Arn"])) + iam_client.attach_role_policy(RoleName=role_name, PolicyArn=policy["Policy"]["Arn"]) + cleanups.append( + lambda: iam_client.detach_role_policy( + RoleName=role_name, PolicyArn=policy["Policy"]["Arn"] + ) + ) + + def _wait_sfn_can_assume_role(): + sm_name = f"test-wait-sfn-can-assume-role-{short_uid()}" + sm_def = { + "Comment": "_wait_sfn_can_assume_role", + "StartAt": "PullAssumeRole", + "States": { + "PullAssumeRole": { + "Type": "Task", + "Parameters": {}, + "Resource": "arn:aws:states:::aws-sdk:s3:listBuckets", + "Catch": [ + { + "ErrorEquals": ["States.TaskFailed"], + "Next": "WaitAndPull", + } + ], + "End": True, + }, + "WaitAndPull": {"Type": "Wait", "Seconds": 5, "Next": "PullAssumeRole"}, + }, + } + creation_resp = create_state_machine( + target_aws_client, name=sm_name, definition=json.dumps(sm_def), roleArn=role_arn + ) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = stepfunctions_client.start_execution( + stateMachineArn=state_machine_arn, input="{}" + ) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=stepfunctions_client, execution_arn=execution_arn + ) + + stepfunctions_client.delete_state_machine(stateMachineArn=state_machine_arn) + + if is_aws_cloud(): + _wait_sfn_can_assume_role() + + return role_arn + + return _create + + +@pytest.fixture +def create_state_machine(): + created_state_machine_references = list() + + def _create_state_machine(target_aws_client, **kwargs): + sfn_client = target_aws_client.stepfunctions + create_output = sfn_client.create_state_machine(**kwargs) + create_output_arn = create_output["stateMachineArn"] + created_state_machine_references.append( + (create_output_arn, kwargs.get("type", StateMachineType.STANDARD), sfn_client) + ) + return create_output + + yield _create_state_machine + + # Delete all state machine, attempting to stop all running executions of STANDARD state machines, + # as other types, such as EXPRESS, cannot be manually stopped. + for arn, typ, client in created_state_machine_references: + try: + if typ == StateMachineType.STANDARD: + executions = client.list_executions(stateMachineArn=arn) + for execution in executions["executions"]: + client.stop_execution(executionArn=execution["executionArn"]) + client.delete_state_machine(stateMachineArn=arn) + except Exception as ex: + LOG.debug("Unable to delete state machine '%s' during cleanup: %s", arn, ex) + + +@pytest.fixture +def create_state_machine_alias(): + state_machine_alias_arn_and_client = list() + + def _create_state_machine_alias(target_aws_client, **kwargs): + step_functions_client = target_aws_client.stepfunctions + create_state_machine_response = step_functions_client.create_state_machine_alias(**kwargs) + state_machine_alias_arn_and_client.append( + (create_state_machine_response["stateMachineAliasArn"], step_functions_client) + ) + return create_state_machine_response + + yield _create_state_machine_alias + + for state_machine_alias_arn, sfn_client in state_machine_alias_arn_and_client: + try: + sfn_client.delete_state_machine_alias(stateMachineAliasArn=state_machine_alias_arn) + except Exception as ex: + LOG.debug( + "Unable to delete the state machine alias '%s' during cleanup due '%s'", + state_machine_alias_arn, + ex, + ) + + +@pytest.fixture +def create_activity(aws_client): + activities_arns: Final[list[str]] = list() + + def _create_activity(**kwargs): + create_output = aws_client.stepfunctions.create_activity(**kwargs) + create_output_arn = create_output["activityArn"] + activities_arns.append(create_output_arn) + return create_output + + yield _create_activity + + for activity_arn in activities_arns: + try: + aws_client.stepfunctions.delete_activity(activityArn=activity_arn) + except Exception: + LOG.debug("Unable to delete Activity '%s' during cleanup.", activity_arn) + + +@pytest.fixture +def sqs_send_task_success_state_machine( + aws_client, create_state_machine, create_state_machine_iam_role +): + def _create_state_machine(sqs_queue_url): + snf_role_arn = create_state_machine_iam_role(aws_client) + sm_name: str = f"sqs_send_task_success_state_machine_{short_uid()}" + + template = { + "Comment": "sqs_success_on_task_token", + "StartAt": "Iterate", + "States": { + "Iterate": { + "Type": "Pass", + "Parameters": {"Count.$": "States.MathAdd($.Iterator.Count, -1)"}, + "ResultPath": "$.Iterator", + "Next": "IterateStep", + }, + "IterateStep": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterator.Count", + "NumericLessThanEquals": 0, + "Next": "NoMoreCycles", + } + ], + "Default": "WaitAndReceive", + }, + "WaitAndReceive": {"Type": "Wait", "Seconds": 1, "Next": "Receive"}, + "Receive": { + "Type": "Task", + "Parameters": {"QueueUrl.$": "$.QueueUrl"}, + "Resource": "arn:aws:states:::aws-sdk:sqs:receiveMessage", + "ResultPath": "$.SQSOutput", + "Next": "CheckMessages", + }, + "CheckMessages": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.SQSOutput.Messages", + "IsPresent": True, + "Next": "SendSuccesses", + } + ], + "Default": "Iterate", + }, + "SendSuccesses": { + "Type": "Map", + "InputPath": "$.SQSOutput.Messages", + "ItemProcessor": { + "ProcessorConfig": {"Mode": "INLINE"}, + "StartAt": "ParseBody", + "States": { + "ParseBody": { + "Type": "Pass", + "Parameters": {"Body.$": "States.StringToJson($.Body)"}, + "Next": "Send", + }, + "Send": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskSuccess", + "Parameters": { + "Output.$": "States.JsonToString($.Body.Message)", + "TaskToken.$": "$.Body.TaskToken", + }, + "End": True, + }, + }, + }, + "ResultPath": None, + "Next": "Iterate", + }, + "NoMoreCycles": {"Type": "Pass", "End": True}, + }, + } + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=json.dumps(template), roleArn=snf_role_arn + ) + state_machine_arn = creation_resp["stateMachineArn"] + + aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, + input=json.dumps({"QueueUrl": sqs_queue_url, "Iterator": {"Count": 300}}), + ) + + return _create_state_machine + + +@pytest.fixture +def sqs_send_task_failure_state_machine( + aws_client, create_state_machine, create_state_machine_iam_role +): + def _create_state_machine(sqs_queue_url): + snf_role_arn = create_state_machine_iam_role(aws_client) + sm_name: str = f"sqs_send_task_failure_state_machine_{short_uid()}" + + template = { + "Comment": "sqs_failure_on_task_token", + "StartAt": "Iterate", + "States": { + "Iterate": { + "Type": "Pass", + "Parameters": {"Count.$": "States.MathAdd($.Iterator.Count, -1)"}, + "ResultPath": "$.Iterator", + "Next": "IterateStep", + }, + "IterateStep": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterator.Count", + "NumericLessThanEquals": 0, + "Next": "NoMoreCycles", + } + ], + "Default": "WaitAndReceive", + }, + "WaitAndReceive": {"Type": "Wait", "Seconds": 1, "Next": "Receive"}, + "Receive": { + "Type": "Task", + "Parameters": {"QueueUrl.$": "$.QueueUrl"}, + "Resource": "arn:aws:states:::aws-sdk:sqs:receiveMessage", + "ResultPath": "$.SQSOutput", + "Next": "CheckMessages", + }, + "CheckMessages": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.SQSOutput.Messages", + "IsPresent": True, + "Next": "SendFailure", + } + ], + "Default": "Iterate", + }, + "SendFailure": { + "Type": "Map", + "InputPath": "$.SQSOutput.Messages", + "ItemProcessor": { + "ProcessorConfig": {"Mode": "INLINE"}, + "StartAt": "ParseBody", + "States": { + "ParseBody": { + "Type": "Pass", + "Parameters": {"Body.$": "States.StringToJson($.Body)"}, + "Next": "Send", + }, + "Send": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskFailure", + "Parameters": { + "Error": "Failure error", + "Cause": "Failure cause", + "TaskToken.$": "$.Body.TaskToken", + }, + "End": True, + }, + }, + }, + "ResultPath": None, + "Next": "Iterate", + }, + "NoMoreCycles": {"Type": "Pass", "End": True}, + }, + } + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=json.dumps(template), roleArn=snf_role_arn + ) + state_machine_arn = creation_resp["stateMachineArn"] + + aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, + input=json.dumps({"QueueUrl": sqs_queue_url, "Iterator": {"Count": 300}}), + ) + + return _create_state_machine + + +@pytest.fixture +def sqs_send_heartbeat_and_task_success_state_machine( + aws_client, create_state_machine, create_state_machine_iam_role +): + def _create_state_machine(sqs_queue_url): + snf_role_arn = create_state_machine_iam_role(aws_client) + sm_name: str = f"sqs_send_heartbeat_and_task_success_state_machine_{short_uid()}" + + template = { + "Comment": "SQS_HEARTBEAT_SUCCESS_ON_TASK_TOKEN", + "StartAt": "Iterate", + "States": { + "Iterate": { + "Type": "Pass", + "Parameters": {"Count.$": "States.MathAdd($.Iterator.Count, -1)"}, + "ResultPath": "$.Iterator", + "Next": "IterateStep", + }, + "IterateStep": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterator.Count", + "NumericLessThanEquals": 0, + "Next": "NoMoreCycles", + } + ], + "Default": "WaitAndReceive", + }, + "WaitAndReceive": {"Type": "Wait", "Seconds": 1, "Next": "Receive"}, + "Receive": { + "Type": "Task", + "Parameters": {"QueueUrl.$": "$.QueueUrl"}, + "Resource": "arn:aws:states:::aws-sdk:sqs:receiveMessage", + "ResultPath": "$.SQSOutput", + "Next": "CheckMessages", + }, + "CheckMessages": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.SQSOutput.Messages", + "IsPresent": True, + "Next": "SendSuccesses", + } + ], + "Default": "Iterate", + }, + "SendSuccesses": { + "Type": "Map", + "InputPath": "$.SQSOutput.Messages", + "ItemProcessor": { + "ProcessorConfig": {"Mode": "INLINE"}, + "StartAt": "ParseBody", + "States": { + "ParseBody": { + "Type": "Pass", + "Parameters": {"Body.$": "States.StringToJson($.Body)"}, + "Next": "WaitBeforeHeartbeat", + }, + "WaitBeforeHeartbeat": { + "Type": "Wait", + "Seconds": 5, + "Next": "SendHeartbeat", + }, + "SendHeartbeat": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskHeartbeat", + "Parameters": {"TaskToken.$": "$.Body.TaskToken"}, + "ResultPath": None, + "Next": "SendSuccess", + }, + "SendSuccess": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskSuccess", + "Parameters": { + "Output.$": "States.JsonToString($.Body.Message)", + "TaskToken.$": "$.Body.TaskToken", + }, + "End": True, + }, + }, + }, + "ResultPath": None, + "Next": "Iterate", + }, + "NoMoreCycles": {"Type": "Pass", "End": True}, + }, + } + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=json.dumps(template), roleArn=snf_role_arn + ) + state_machine_arn = creation_resp["stateMachineArn"] + + aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, + input=json.dumps({"QueueUrl": sqs_queue_url, "Iterator": {"Count": 300}}), + ) + + return _create_state_machine + + +@pytest.fixture +def sfn_activity_consumer(aws_client, create_state_machine, create_state_machine_iam_role): + def _create_state_machine(template, activity_arn): + snf_role_arn = create_state_machine_iam_role(aws_client) + sm_name: str = f"activity_send_task_failure_on_task_{short_uid()}" + definition = json.dumps(template) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition, roleArn=snf_role_arn + ) + state_machine_arn = creation_resp["stateMachineArn"] + + aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, + input=json.dumps({"ActivityArn": activity_arn}), + ) + + return _create_state_machine + + +@pytest.fixture +def events_to_sqs_queue(events_create_rule, sqs_create_queue, sqs_get_queue_arn, aws_client): + def _setup(event_pattern): + queue_name = f"test-queue-{short_uid()}" + rule_name = f"test-rule-{short_uid()}" + target_id = f"test-target-{short_uid()}" + + rule_arn = events_create_rule( + Name=rule_name, EventBusName="default", EventPattern=event_pattern + ) + + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + queue_policy = { + "Statement": [ + { + "Sid": "StepFunctionsEventRule", + "Resource": queue_arn, + "Action": "sqs:SendMessage", + "Principal": {"Service": "events.amazonaws.com"}, + "Condition": {"ArnEquals": {"aws:SourceArn": rule_arn}}, + "Effect": "Allow", + } + ] + } + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url, + Attributes={"Policy": json.dumps(queue_policy), "ReceiveMessageWaitTimeSeconds": "1"}, + ) + + aws_client.events.put_targets(Rule=rule_name, Targets=[{"Id": target_id, "Arn": queue_arn}]) + + return queue_url + + return _setup + + +@pytest.fixture +def sfn_events_to_sqs_queue(events_to_sqs_queue): + def _create(state_machine_arn: str) -> str: + event_pattern = { + "source": ["aws.states"], + "detail": { + "stateMachineArn": [state_machine_arn], + }, + } + return events_to_sqs_queue(event_pattern=event_pattern) + + return _create + + +@pytest.fixture +def sfn_glue_create_job(aws_client, create_role, create_policy, wait_and_assume_role): + job_names = [] + + def _execute(**kwargs): + job_name = f"glue-job-{short_uid()}" + + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole", + } + ], + } + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["*"], + "Resource": "*", + }, + ], + } + + role = create_role(AssumeRolePolicyDocument=json.dumps(assume_role_policy_document)) + role_name = role["Role"]["RoleName"] + role_arn = role["Role"]["Arn"] + + policy = create_policy(PolicyDocument=json.dumps(policy_document)) + policy_arn = policy["Policy"]["Arn"] + + aws_client.iam.attach_role_policy( + RoleName=role_name, + PolicyArn=policy_arn, + ) + + wait_and_assume_role(role_arn) + + aws_client.glue.create_job(Name=job_name, Role=role_arn, **kwargs) + + job_names.append(job_name) + return job_name + + yield _execute + + for job_name in job_names: + try: + aws_client.glue.delete_job(JobName=job_name) + except Exception as ex: + # TODO: the glue provider should not fail on deletion of deleted job, however this is currently the case. + LOG.warning("Could not delete job '%s': %s", job_name, ex) + + +@pytest.fixture +def sfn_create_log_group(aws_client, snapshot): + log_group_names = [] + + def _create() -> str: + log_group_name = f"/aws/vendedlogs/states/sfn-test-group-{short_uid()}" + snapshot.add_transformer(RegexTransformer(log_group_name, "log_group_name")) + aws_client.logs.create_log_group(logGroupName=log_group_name) + log_group_names.append(log_group_name) + + return log_group_name + + yield _create + + for log_group_name in log_group_names: + try: + aws_client.logs.delete_log_group(logGroupName=log_group_name) + except Exception: + LOG.debug("Cannot delete log group %s", log_group_name) + + +@pytest.fixture +def create_cross_account_admin_role_and_policy(create_state_machine, create_state_machine_iam_role): + created = list() + + def _create_role_and_policy(trusting_aws_client, trusted_aws_client, trusted_account_id) -> str: + trusting_iam_client = trusting_aws_client.iam + + role_name = f"admin-test-role-cross-account-{short_uid()}" + policy_name = f"admin-test-policy-cross-account-{short_uid()}" + + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{trusted_account_id}:root"}, + "Action": "sts:AssumeRole", + } + ], + } + + create_role_response = trusting_iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(trust_policy), + ) + role_arn = create_role_response["Role"]["Arn"] + + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*", + } + ], + } + + trusting_iam_client.put_role_policy( + RoleName=role_name, PolicyName=policy_name, PolicyDocument=json.dumps(policy_document) + ) + + def _wait_sfn_can_assume_admin_role(): + trusted_stepfunctions_client = trusted_aws_client.stepfunctions + sm_name = f"test-wait-sfn-can-assume-cross-account-admin-role-{short_uid()}" + sm_role = create_state_machine_iam_role(trusted_aws_client) + sm_def = { + "StartAt": "PullAssumeRole", + "States": { + "PullAssumeRole": { + "Type": "Task", + "Parameters": {}, + "Resource": "arn:aws:states:::aws-sdk:s3:listBuckets", + "Credentials": {"RoleArn": role_arn}, + "Retry": [ + { + "ErrorEquals": ["States.ALL"], + "IntervalSeconds": 2, + "MaxAttempts": 60, + } + ], + "End": True, + } + }, + } + creation_response = create_state_machine( + trusted_aws_client, name=sm_name, definition=json.dumps(sm_def), roleArn=sm_role + ) + state_machine_arn = creation_response["stateMachineArn"] + + exec_resp = trusted_stepfunctions_client.start_execution( + stateMachineArn=state_machine_arn, input="{}" + ) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=trusted_stepfunctions_client, execution_arn=execution_arn + ) + + trusted_stepfunctions_client.delete_state_machine(stateMachineArn=state_machine_arn) + + if is_aws_cloud(): + _wait_sfn_can_assume_admin_role() + + return role_arn + + yield _create_role_and_policy + + for aws_client, role_name, policy_name in created: + aws_client.iam.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + aws_client.iam.delete_role(RoleName=role_name) diff --git a/localstack-core/localstack/testing/pytest/stepfunctions/utils.py b/localstack-core/localstack/testing/pytest/stepfunctions/utils.py new file mode 100644 index 0000000000000..401b6173d66f4 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/stepfunctions/utils.py @@ -0,0 +1,973 @@ +import json +import logging +from typing import Callable, Final, Optional + +from botocore.exceptions import ClientError +from jsonpath_ng.ext import parse +from localstack_snapshot.snapshots.transformer import ( + JsonpathTransformer, + RegexTransformer, + TransformContext, +) + +from localstack import config +from localstack.aws.api.stepfunctions import ( + Arn, + CloudWatchLogsLogGroup, + CreateStateMachineOutput, + Definition, + ExecutionStatus, + HistoryEventList, + HistoryEventType, + LogDestination, + LoggingConfiguration, + LogLevel, + LongArn, + StateMachineType, +) +from localstack.services.stepfunctions.asl.eval.event.logging import is_logging_enabled_for +from localstack.services.stepfunctions.asl.utils.encoding import to_json_str +from localstack.services.stepfunctions.asl.utils.json_path import NoSuchJsonPathError, extract_json +from localstack.testing.aws.util import is_aws_cloud +from localstack.utils.strings import short_uid +from localstack.utils.sync import poll_condition + +LOG = logging.getLogger(__name__) + + +# For EXPRESS state machines, the deletion will happen eventually (usually less than a minute). +# Running executions may emit logs after DeleteStateMachine API is called. +_DELETION_TIMEOUT_SECS: Final[int] = 120 +_SAMPLING_INTERVAL_SECONDS_AWS_CLOUD: Final[int] = 1 +_SAMPLING_INTERVAL_SECONDS_LOCALSTACK: Final[float] = 0.2 + + +def _get_sampling_interval_seconds() -> int | float: + return ( + _SAMPLING_INTERVAL_SECONDS_AWS_CLOUD + if is_aws_cloud() + else _SAMPLING_INTERVAL_SECONDS_LOCALSTACK + ) + + +def await_no_state_machines_listed(stepfunctions_client): + def _is_empty_state_machine_list(): + lst_resp = stepfunctions_client.list_state_machines() + state_machines = lst_resp["stateMachines"] + return not bool(state_machines) + + success = poll_condition( + condition=_is_empty_state_machine_list, + timeout=_DELETION_TIMEOUT_SECS, + interval=_get_sampling_interval_seconds(), + ) + if not success: + LOG.warning("Timed out whilst awaiting for listing to be empty.") + + +def _is_state_machine_alias_listed( + stepfunctions_client, state_machine_arn: Arn, state_machine_alias_arn: Arn +): + list_state_machine_aliases_list = stepfunctions_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn + ) + state_machine_aliases = list_state_machine_aliases_list["stateMachineAliases"] + for state_machine_alias in state_machine_aliases: + if state_machine_alias["stateMachineAliasArn"] == state_machine_alias_arn: + return True + return False + + +def await_state_machine_alias_is_created( + stepfunctions_client, state_machine_arn: Arn, state_machine_alias_arn: Arn +): + success = poll_condition( + condition=lambda: _is_state_machine_alias_listed( + stepfunctions_client=stepfunctions_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ), + timeout=_DELETION_TIMEOUT_SECS, + interval=_get_sampling_interval_seconds(), + ) + if not success: + LOG.warning("Timed out whilst awaiting for listing to be empty.") + + +def await_state_machine_alias_is_deleted( + stepfunctions_client, state_machine_arn: Arn, state_machine_alias_arn: Arn +): + success = poll_condition( + condition=lambda: not _is_state_machine_alias_listed( + stepfunctions_client=stepfunctions_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ), + timeout=_DELETION_TIMEOUT_SECS, + interval=_get_sampling_interval_seconds(), + ) + if not success: + LOG.warning("Timed out whilst awaiting for listing to be empty.") + + +def _is_state_machine_listed(stepfunctions_client, state_machine_arn: str) -> bool: + lst_resp = stepfunctions_client.list_state_machines() + state_machines = lst_resp["stateMachines"] + for state_machine in state_machines: + if state_machine["stateMachineArn"] == state_machine_arn: + return True + return False + + +def _is_state_machine_version_listed( + stepfunctions_client, state_machine_arn: str, state_machine_version_arn: str +) -> bool: + lst_resp = stepfunctions_client.list_state_machine_versions(stateMachineArn=state_machine_arn) + versions = lst_resp["stateMachineVersions"] + for version in versions: + if version["stateMachineVersionArn"] == state_machine_version_arn: + return True + return False + + +def await_state_machine_not_listed(stepfunctions_client, state_machine_arn: str): + success = poll_condition( + condition=lambda: not _is_state_machine_listed(stepfunctions_client, state_machine_arn), + timeout=_DELETION_TIMEOUT_SECS, + interval=_get_sampling_interval_seconds(), + ) + if not success: + LOG.warning("Timed out whilst awaiting for listing to exclude '%s'.", state_machine_arn) + + +def await_state_machine_listed(stepfunctions_client, state_machine_arn: str): + success = poll_condition( + condition=lambda: _is_state_machine_listed(stepfunctions_client, state_machine_arn), + timeout=_DELETION_TIMEOUT_SECS, + interval=_get_sampling_interval_seconds(), + ) + if not success: + LOG.warning("Timed out whilst awaiting for listing to include '%s'.", state_machine_arn) + + +def await_state_machine_version_not_listed( + stepfunctions_client, state_machine_arn: str, state_machine_version_arn: str +): + success = poll_condition( + condition=lambda: not _is_state_machine_version_listed( + stepfunctions_client, state_machine_arn, state_machine_version_arn + ), + timeout=_DELETION_TIMEOUT_SECS, + interval=_get_sampling_interval_seconds(), + ) + if not success: + LOG.warning( + "Timed out whilst awaiting for version of %s to exclude '%s'.", + state_machine_arn, + state_machine_version_arn, + ) + + +def await_state_machine_version_listed( + stepfunctions_client, state_machine_arn: str, state_machine_version_arn: str +): + success = poll_condition( + condition=lambda: _is_state_machine_version_listed( + stepfunctions_client, state_machine_arn, state_machine_version_arn + ), + timeout=_DELETION_TIMEOUT_SECS, + interval=_get_sampling_interval_seconds(), + ) + if not success: + LOG.warning( + "Timed out whilst awaiting for version of %s to include '%s'.", + state_machine_arn, + state_machine_version_arn, + ) + + +def await_on_execution_events( + stepfunctions_client, execution_arn: str, check_func: Callable[[HistoryEventList], bool] +) -> HistoryEventList: + events: HistoryEventList = list() + + def _run_check(): + nonlocal events + events.clear() + try: + hist_resp = stepfunctions_client.get_execution_history(executionArn=execution_arn) + except ClientError: + return False + events.extend(sorted(hist_resp.get("events", []), key=lambda event: event.get("timestamp"))) + res: bool = check_func(events) + return res + + assert poll_condition( + condition=_run_check, timeout=120, interval=_get_sampling_interval_seconds() + ) + return events + + +def await_execution_success(stepfunctions_client, execution_arn: str) -> HistoryEventList: + def _check_last_is_success(events: HistoryEventList) -> bool: + if len(events) > 0: + last_event = events[-1] + return "executionSucceededEventDetails" in last_event + return False + + return await_on_execution_events( + stepfunctions_client=stepfunctions_client, + execution_arn=execution_arn, + check_func=_check_last_is_success, + ) + + +def await_list_execution_status( + stepfunctions_client, state_machine_arn: str, execution_arn: str, status: str +): + """required as there is some eventual consistency in list_executions vs describe_execution and get_execution_history""" + + def _run_check(): + list_resp = stepfunctions_client.list_executions( + stateMachineArn=state_machine_arn, statusFilter=status + ) + for execution in list_resp.get("executions", []): + if execution["executionArn"] != execution_arn or execution["status"] != status: + continue + return True + return False + + success = poll_condition( + condition=_run_check, timeout=120, interval=_get_sampling_interval_seconds() + ) + if not success: + LOG.warning( + "Timed out whilst awaiting for execution status %s to satisfy condition for execution '%s'.", + status, + execution_arn, + ) + + +def _is_last_history_event_terminal(events: HistoryEventList) -> bool: + if len(events) > 0: + last_event = events[-1] + last_event_type = last_event.get("type") + return last_event_type is None or last_event_type in { + HistoryEventType.ExecutionFailed, + HistoryEventType.ExecutionAborted, + HistoryEventType.ExecutionTimedOut, + HistoryEventType.ExecutionSucceeded, + } + return False + + +def await_execution_terminated(stepfunctions_client, execution_arn: str) -> HistoryEventList: + return await_on_execution_events( + stepfunctions_client=stepfunctions_client, + execution_arn=execution_arn, + check_func=_is_last_history_event_terminal, + ) + + +def await_execution_lists_terminated( + stepfunctions_client, state_machine_arn: str, execution_arn: str +): + def _check_last_is_terminal() -> bool: + list_output = stepfunctions_client.list_executions(stateMachineArn=state_machine_arn) + executions = list_output["executions"] + for execution in executions: + if execution["executionArn"] == execution_arn: + return execution["status"] != ExecutionStatus.RUNNING + return False + + success = poll_condition( + condition=_check_last_is_terminal, timeout=120, interval=_get_sampling_interval_seconds() + ) + if not success: + LOG.warning( + "Timed out whilst awaiting for execution events to satisfy condition for execution '%s'.", + execution_arn, + ) + + +def await_execution_started(stepfunctions_client, execution_arn: str) -> HistoryEventList: + def _check_stated_exists(events: HistoryEventList) -> bool: + for event in events: + return "executionStartedEventDetails" in event + return False + + return await_on_execution_events( + stepfunctions_client=stepfunctions_client, + execution_arn=execution_arn, + check_func=_check_stated_exists, + ) + + +def await_execution_aborted(stepfunctions_client, execution_arn: str): + def _run_check(): + desc_res = stepfunctions_client.describe_execution(executionArn=execution_arn) + status: ExecutionStatus = desc_res["status"] + return status == ExecutionStatus.ABORTED + + success = poll_condition( + condition=_run_check, timeout=120, interval=_get_sampling_interval_seconds() + ) + if not success: + LOG.warning("Timed out whilst awaiting for execution '%s' to abort.", execution_arn) + + +def get_expected_execution_logs( + stepfunctions_client, log_level: LogLevel, execution_arn: LongArn +) -> HistoryEventList: + execution_history = stepfunctions_client.get_execution_history(executionArn=execution_arn) + execution_history_events = execution_history["events"] + expected_events = [ + event + for event in execution_history_events + if is_logging_enabled_for(log_level=log_level, history_event_type=event["type"]) + ] + return expected_events + + +def is_execution_logs_list_complete( + expected_events: HistoryEventList, +) -> Callable[[HistoryEventList], bool]: + def _validation_function(log_events: list) -> bool: + if not expected_events: + return True + return len(expected_events) == len(log_events) + + return _validation_function + + +def _await_on_execution_log_stream_created(target_aws_client, log_group_name: str) -> str: + logs_client = target_aws_client.logs + log_stream_name = str() + + def _run_check(): + nonlocal log_stream_name + try: + log_streams = logs_client.describe_log_streams(logGroupName=log_group_name)[ + "logStreams" + ] + if not log_streams: + return False + + log_stream_name = log_streams[-1]["logStreamName"] + if ( + log_stream_name + == "log_stream_created_by_aws_to_validate_log_delivery_subscriptions" + ): + # SFN has not yet create the log stream for the execution, only the validation steam. + return False + return True + except ClientError: + return False + + assert poll_condition(condition=_run_check) + return log_stream_name + + +def await_on_execution_logs( + target_aws_client, + log_group_name: str, + validation_function: Callable[[HistoryEventList], bool] = None, +) -> HistoryEventList: + log_stream_name = _await_on_execution_log_stream_created(target_aws_client, log_group_name) + + logs_client = target_aws_client.logs + events: HistoryEventList = list() + + def _run_check(): + nonlocal events + events.clear() + try: + log_events = logs_client.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name, startFromHead=True + )["events"] + events.extend([json.loads(e["message"]) for e in log_events]) + except ClientError: + return False + + res = validation_function(events) + return res + + assert poll_condition(condition=_run_check) + return events + + +def create_state_machine_with_iam_role( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + definition: Definition, + logging_configuration: Optional[LoggingConfiguration] = None, + state_machine_name: Optional[str] = None, + state_machine_type: StateMachineType = StateMachineType.STANDARD, +): + snf_role_arn = create_state_machine_iam_role(target_aws_client=target_aws_client) + snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + snapshot.add_transformer( + RegexTransformer( + "Extended Request ID: [a-zA-Z0-9-/=+]+", + "Extended Request ID: ", + ) + ) + snapshot.add_transformer( + RegexTransformer("Request ID: [a-zA-Z0-9-]+", "Request ID: ") + ) + + sm_name: str = state_machine_name or f"statemachine_create_and_record_execution_{short_uid()}" + create_arguments = { + "name": sm_name, + "definition": definition, + "roleArn": snf_role_arn, + "type": state_machine_type, + } + if logging_configuration is not None: + create_arguments["loggingConfiguration"] = logging_configuration + creation_resp = create_state_machine(target_aws_client, **create_arguments) + snapshot.add_transformer(snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + return state_machine_arn + + +def launch_and_record_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, + verify_execution_description=False, +) -> LongArn: + stepfunctions_client = target_aws_client.stepfunctions + exec_resp = stepfunctions_client.start_execution( + stateMachineArn=state_machine_arn, input=execution_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=stepfunctions_client, execution_arn=execution_arn + ) + + if verify_execution_description: + describe_execution = stepfunctions_client.describe_execution(executionArn=execution_arn) + sfn_snapshot.match("describe_execution", describe_execution) + + get_execution_history = stepfunctions_client.get_execution_history(executionArn=execution_arn) + + # Transform all map runs if any. + try: + map_run_arns = extract_json("$..mapRunArn", get_execution_history) + if isinstance(map_run_arns, str): + map_run_arns = [map_run_arns] + for i, map_run_arn in enumerate(list(set(map_run_arns))): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_map_run_arn(map_run_arn, i)) + except NoSuchJsonPathError: + # No mapRunArns + pass + + sfn_snapshot.match("get_execution_history", get_execution_history) + + return execution_arn + + +def launch_and_record_mocked_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, + test_name, +) -> LongArn: + stepfunctions_client = target_aws_client.stepfunctions + exec_resp = stepfunctions_client.start_execution( + stateMachineArn=f"{state_machine_arn}#{test_name}", input=execution_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=stepfunctions_client, execution_arn=execution_arn + ) + + get_execution_history = stepfunctions_client.get_execution_history(executionArn=execution_arn) + + # Transform all map runs if any. + try: + map_run_arns = extract_json("$..mapRunArn", get_execution_history) + if isinstance(map_run_arns, str): + map_run_arns = [map_run_arns] + for i, map_run_arn in enumerate(list(set(map_run_arns))): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_map_run_arn(map_run_arn, i)) + except NoSuchJsonPathError: + # No mapRunArns + pass + + sfn_snapshot.match("get_execution_history", get_execution_history) + + return execution_arn + + +def launch_and_record_mocked_sync_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, + test_name, +) -> LongArn: + stepfunctions_client = target_aws_client.stepfunctions + + exec_resp = stepfunctions_client.start_sync_execution( + stateMachineArn=f"{state_machine_arn}#{test_name}", + input=execution_input, + ) + + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_sync_exec_arn(exec_resp, 0)) + + sfn_snapshot.match("start_execution_sync_response", exec_resp) + + return exec_resp["executionArn"] + + +def launch_and_record_logs( + target_aws_client, + state_machine_arn, + execution_input, + log_level, + log_group_name, + sfn_snapshot, +): + execution_arn = launch_and_record_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, + ) + expected_events = get_expected_execution_logs( + target_aws_client.stepfunctions, log_level, execution_arn + ) + + if log_level == LogLevel.OFF or not expected_events: + # The test should terminate here, as no log streams for this execution would have been created. + return + + logs_validation_function = is_execution_logs_list_complete(expected_events) + logged_execution_events = await_on_execution_logs( + target_aws_client, log_group_name, logs_validation_function + ) + + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..event_timestamp", + replacement="timestamp", + replace_reference=False, + ) + ) + sfn_snapshot.match("logged_execution_events", logged_execution_events) + + +def create_and_record_execution( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + execution_input, + verify_execution_description=False, +) -> LongArn: + state_machine_arn = create_state_machine_with_iam_role( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + exeuction_arn = launch_and_record_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, + verify_execution_description, + ) + return exeuction_arn + + +def create_and_record_mocked_execution( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + execution_input, + state_machine_name, + test_name, + state_machine_type: StateMachineType = StateMachineType.STANDARD, +) -> LongArn: + state_machine_arn = create_state_machine_with_iam_role( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + state_machine_name=state_machine_name, + state_machine_type=state_machine_type, + ) + execution_arn = launch_and_record_mocked_execution( + target_aws_client, sfn_snapshot, state_machine_arn, execution_input, test_name + ) + return execution_arn + + +def create_and_record_mocked_sync_execution( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + execution_input, + state_machine_name, + test_name, +) -> LongArn: + state_machine_arn = create_state_machine_with_iam_role( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + state_machine_name=state_machine_name, + state_machine_type=StateMachineType.EXPRESS, + ) + execution_arn = launch_and_record_mocked_sync_execution( + target_aws_client, sfn_snapshot, state_machine_arn, execution_input, test_name + ) + return execution_arn + + +def create_and_run_mock( + target_aws_client, + monkeypatch, + mock_config_file, + mock_config: dict, + state_machine_name: str, + definition_template: dict, + execution_input: str, + test_name: str, +): + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + + sfn_client = target_aws_client.stepfunctions + + state_machine_name: str = state_machine_name or f"mocked_statemachine_{short_uid()}" + definition = json.dumps(definition_template) + creation_response = sfn_client.create_state_machine( + name=state_machine_name, + definition=definition, + roleArn="arn:aws:iam::111111111111:role/mock-role/mocked-run", + ) + state_machine_arn = creation_response["stateMachineArn"] + + test_case_arn = f"{state_machine_arn}#{test_name}" + execution = sfn_client.start_execution(stateMachineArn=test_case_arn, input=execution_input) + execution_arn = execution["executionArn"] + + await_execution_terminated(stepfunctions_client=sfn_client, execution_arn=execution_arn) + sfn_client.delete_state_machine(stateMachineArn=state_machine_arn) + + return execution_arn + + +def create_and_record_logs( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition, + execution_input, + log_level: LogLevel, + include_execution_data: bool, +): + state_machine_arn = create_state_machine_with_iam_role( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + + log_group_name = sfn_create_log_group() + log_group_arn = target_aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration = LoggingConfiguration( + level=log_level, + includeExecutionData=include_execution_data, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + target_aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, loggingConfiguration=logging_configuration + ) + + launch_and_record_logs( + target_aws_client, + state_machine_arn, + execution_input, + log_level, + log_group_name, + sfn_snapshot, + ) + + +def launch_and_record_sync_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, +): + exec_resp = target_aws_client.stepfunctions.start_sync_execution( + stateMachineArn=state_machine_arn, + input=execution_input, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_sync_exec_arn(exec_resp, 0)) + sfn_snapshot.match("start_execution_sync_response", exec_resp) + + +def create_and_record_express_sync_execution( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + execution_input, +): + snf_role_arn = create_state_machine_iam_role(target_aws_client=target_aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "sfn_role_arn")) + + creation_response = create_state_machine( + target_aws_client, + name=f"express_statemachine_{short_uid()}", + definition=definition, + roleArn=snf_role_arn, + type=StateMachineType.EXPRESS, + ) + state_machine_arn = creation_response["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_response, 0)) + sfn_snapshot.match("creation_response", creation_response) + + launch_and_record_sync_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, + ) + + +def launch_and_record_express_async_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + log_group_name, + execution_input, +): + start_execution = target_aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=execution_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_express_exec_arn(start_execution, 0)) + execution_arn = start_execution["executionArn"] + + event_list = await_on_execution_logs( + target_aws_client, log_group_name, validation_function=_is_last_history_event_terminal + ) + # Snapshot only the end event, as AWS StepFunctions implements a flaky approach to logging previous events. + end_event = event_list[-1] + sfn_snapshot.match("end_event", end_event) + + return execution_arn + + +def create_and_record_express_async_execution( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition, + execution_input, + include_execution_data: bool = True, +) -> tuple[LongArn, LongArn]: + snf_role_arn = create_state_machine_iam_role(target_aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "sfn_role_arn")) + + log_group_name = sfn_create_log_group() + log_group_arn = target_aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration = LoggingConfiguration( + level=LogLevel.ALL, + includeExecutionData=include_execution_data, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + + creation_response = create_state_machine( + target_aws_client, + name=f"express_statemachine_{short_uid()}", + definition=definition, + roleArn=snf_role_arn, + type=StateMachineType.EXPRESS, + loggingConfiguration=logging_configuration, + ) + state_machine_arn = creation_response["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_response, 0)) + sfn_snapshot.match("creation_response", creation_response) + + execution_arn = launch_and_record_express_async_execution( + target_aws_client, + sfn_snapshot, + state_machine_arn, + log_group_name, + execution_input, + ) + return state_machine_arn, execution_arn + + +def create_and_record_events( + create_state_machine_iam_role, + create_state_machine, + sfn_events_to_sqs_queue, + target_aws_client, + sfn_snapshot, + definition, + execution_input, +): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformers_list( + [ + JsonpathTransformer( + jsonpath="$..detail.startDate", + replacement="start-date", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..detail.stopDate", + replacement="stop-date", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..detail.name", + replacement="test_event_bridge_events-{short_uid()}", + replace_reference=False, + ), + ] + ) + + snf_role_arn = create_state_machine_iam_role(target_aws_client) + create_output: CreateStateMachineOutput = create_state_machine( + target_aws_client, + name=f"test_event_bridge_events-{short_uid()}", + definition=definition, + roleArn=snf_role_arn, + ) + state_machine_arn = create_output["stateMachineArn"] + + queue_url = sfn_events_to_sqs_queue(state_machine_arn=state_machine_arn) + + start_execution = target_aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=execution_input + ) + execution_arn = start_execution["executionArn"] + await_execution_terminated( + stepfunctions_client=target_aws_client.stepfunctions, execution_arn=execution_arn + ) + + stepfunctions_events = list() + + def _get_events(): + received = target_aws_client.sqs.receive_message(QueueUrl=queue_url) + for message in received.get("Messages", []): + body = json.loads(message["Body"]) + stepfunctions_events.append(body) + stepfunctions_events.sort(key=lambda e: e["time"]) + return stepfunctions_events and stepfunctions_events[-1]["detail"]["status"] != "RUNNING" + + poll_condition(_get_events, timeout=60) + + sfn_snapshot.match("stepfunctions_events", stepfunctions_events) + + +def record_sqs_events(target_aws_client, queue_url, sfn_snapshot, num_events): + stepfunctions_events = list() + + def _get_events(): + received = target_aws_client.sqs.receive_message(QueueUrl=queue_url) + for message in received.get("Messages", []): + body = json.loads(message["Body"]) + stepfunctions_events.append(body) + stepfunctions_events.sort(key=lambda e: e["time"]) + return len(stepfunctions_events) == num_events + + poll_condition(_get_events, timeout=60) + stepfunctions_events.sort(key=lambda e: json.dumps(e.get("detail", dict()))) + sfn_snapshot.match("stepfunctions_events", stepfunctions_events) + + +class SfnNoneRecursiveParallelTransformer: + """ + Normalises a sublist of events triggered in by a Parallel state to be order-independent. + """ + + def __init__(self, events_jsonpath: str = "$..events"): + self.events_jsonpath: str = events_jsonpath + + @staticmethod + def _normalise_events(events: list[dict]) -> None: + start_idx = None + sublist = list() + in_sublist = False + for i, event in enumerate(events): + event_type = event.get("type") + if event_type is None: + LOG.debug( + "No 'type' in event item '%s'.", + event, + ) + in_sublist = False + + elif event_type in { + None, + HistoryEventType.ParallelStateSucceeded, + HistoryEventType.ParallelStateAborted, + HistoryEventType.ParallelStateExited, + HistoryEventType.ParallelStateFailed, + }: + events[start_idx:i] = sorted(sublist, key=lambda e: to_json_str(e)) + in_sublist = False + elif event_type == HistoryEventType.ParallelStateStarted: + in_sublist = True + sublist = [] + start_idx = i + 1 + elif in_sublist: + event["id"] = (0,) + event["previousEventId"] = 0 + sublist.append(event) + + def transform(self, input_data: dict, *, ctx: TransformContext) -> dict: + pattern = parse("$..events") + events = pattern.find(input_data) + if not events: + LOG.debug("No Stepfunctions 'events' for jsonpath '%s'.", self.events_jsonpath) + return input_data + + for events_data in events: + self._normalise_events(events_data.value) + + return input_data diff --git a/localstack-core/localstack/testing/pytest/util.py b/localstack-core/localstack/testing/pytest/util.py new file mode 100644 index 0000000000000..28e88a8dbef24 --- /dev/null +++ b/localstack-core/localstack/testing/pytest/util.py @@ -0,0 +1,27 @@ +import os +import pwd +from multiprocessing import Process, ProcessError +from typing import Callable + + +def run_as_os_user(target: Callable, uid: str | int, gid: str | int = None): + """ + Run the given callable under a different OS user and (optionally) group, in a forked subprocess. + :param target: the function to call in the subprocess + :param uid: either the user name (string) or numeric user ID + :param gid: optionally, either the group name (string) or numeric group ID + """ + + def _wrapper(): + if gid is not None: + _gid = pwd.getpwnam(gid).pw_gid if isinstance(gid, str) else gid + os.setgid(_gid) + _uid = pwd.getpwnam(uid).pw_uid if isinstance(uid, str) else uid + os.setuid(_uid) + return target() + + proc = Process(target=_wrapper) + proc.start() + proc.join() + if proc.exitcode != 0: + raise ProcessError(f"Process exited with code {proc.exitcode}") diff --git a/localstack-core/localstack/testing/pytest/validation_tracking.py b/localstack-core/localstack/testing/pytest/validation_tracking.py new file mode 100644 index 0000000000000..cb3fd9eb48dae --- /dev/null +++ b/localstack-core/localstack/testing/pytest/validation_tracking.py @@ -0,0 +1,164 @@ +""" +When a test (in tests/aws) is executed against AWS, we want to track the date of the last successful run. + +Keeping a record of how long ago a test was validated last, +we can periodically re-validate ALL AWS-targeting tests (and therefore not only just snapshot-using tests). +""" + +import datetime +import json +import os +from pathlib import Path +from typing import Dict, Optional + +import pytest +from pluggy import Result +from pytest import StashKey, TestReport + +from localstack.testing.aws.util import is_aws_cloud + +durations_key = StashKey[Dict[str, float]]() +""" +Stores phase durations on the test node between execution phases. +See https://docs.pytest.org/en/latest/reference/reference.html#pytest.Stash +""" +test_failed_key = StashKey[bool]() +""" +Stores information from call execution phase about whether the test failed. +""" + + +def find_validation_data_for_item(item: pytest.Item) -> Optional[dict]: + base_path = os.path.join(item.fspath.dirname, item.fspath.purebasename) + snapshot_path = f"{base_path}.validation.json" + + if not os.path.exists(snapshot_path): + return None + + with open(snapshot_path, "r") as fd: + file_content = json.load(fd) + return file_content.get(item.nodeid) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo): + """ + This hook is called after each test execution phase (setup, call, teardown). + """ + result: Result = yield + report: TestReport = result.get_result() + + if call.when == "setup": + _makereport_setup(item, call) + elif call.when == "call": + _makereport_call(item, call) + elif call.when == "teardown": + _makereport_teardown(item, call) + + return report + + +def _stash_phase_duration(call, item): + durations_by_phase = item.stash.setdefault(durations_key, {}) + durations_by_phase[call.when] = round(call.duration, 2) + + +def _makereport_setup(item: pytest.Item, call: pytest.CallInfo): + _stash_phase_duration(call, item) + + +def _makereport_call(item: pytest.Item, call: pytest.CallInfo): + _stash_phase_duration(call, item) + item.stash[test_failed_key] = call.excinfo is not None + + +def _makereport_teardown(item: pytest.Item, call: pytest.CallInfo): + _stash_phase_duration(call, item) + + # only update the file when running against AWS and the test finishes successfully + if not is_aws_cloud() or item.stash.get(test_failed_key, True): + return + + base_path = os.path.join(item.fspath.dirname, item.fspath.purebasename) + file_path = Path(f"{base_path}.validation.json") + file_path.touch() + with file_path.open(mode="r+") as fd: + # read existing state from file + try: + content = json.load(fd) + except json.JSONDecodeError: # expected on the first try (empty file) + content = {} + + test_execution_data = content.setdefault(item.nodeid, {}) + + timestamp = datetime.datetime.now(tz=datetime.timezone.utc) + test_execution_data["last_validated_date"] = timestamp.isoformat(timespec="seconds") + + durations_by_phase = item.stash[durations_key] + test_execution_data["durations_in_seconds"] = durations_by_phase + + total_duration = sum(durations_by_phase.values()) + durations_by_phase["total"] = round(total_duration, 2) + + # For json.dump sorted test entries enable consistent diffs. + # But test execution data is more readable in insert order for each step (setup, call, teardown). + # Hence, not using global sort_keys=True for json.dump but rather additionally sorting top-level dict only. + content = dict(sorted(content.items())) + + # save updates + fd.truncate(0) # clear existing content + fd.seek(0) + json.dump(content, fd, indent=2) + fd.write("\n") # add trailing newline for linter and Git compliance + + +@pytest.hookimpl +def pytest_addoption(parser: pytest.Parser, pluginmanager: pytest.PytestPluginManager): + parser.addoption("--validation-date-limit-days", action="store") + parser.addoption("--validation-date-limit-timestamp", action="store") + + +@pytest.hookimpl(trylast=True) +def pytest_collection_modifyitems( + session: pytest.Session, config: pytest.Config, items: list[pytest.Item] +): + """ + Collect only items that have a validation timestamp earlier than the user-provided reference timestamp + + Example usage: + - pytest ... --validation-date-limit-days=10 + - pytest ... --validation-date-limit-timestamp="2023-12-01T00:00:00" + + """ + # handle two potential config options (relative vs. absolute limits) + if config.option.validation_date_limit_days is not None: + reference_date = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta( + days=int(config.option.validation_date_limit_days) + ) + elif config.option.validation_date_limit_timestamp is not None: + reference_date = datetime.datetime.fromisoformat( + config.option.validation_date_limit_timestamp + ) + else: + return + + selected = [] # items to collect + deselected = [] # items to drop + + for item in items: + validation_data = find_validation_data_for_item(item) + if not validation_data: + deselected.append(item) + continue + + last_validated_date = datetime.datetime.fromisoformat( + validation_data["last_validated_date"] + ) + + if last_validated_date < reference_date: + selected.append(item) + else: + deselected.append(item) + + items[:] = selected + config.hook.pytest_deselected(items=deselected) diff --git a/localstack-core/localstack/testing/scenario/__init__.py b/localstack-core/localstack/testing/scenario/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/scenario/cdk_lambda_helper.py b/localstack-core/localstack/testing/scenario/cdk_lambda_helper.py new file mode 100644 index 0000000000000..18233edcdf6e8 --- /dev/null +++ b/localstack-core/localstack/testing/scenario/cdk_lambda_helper.py @@ -0,0 +1,229 @@ +import base64 +import os +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING + +from botocore.exceptions import ClientError + +from localstack.utils.aws.resources import create_s3_bucket +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.run import LOG, run + +if TYPE_CHECKING: + from mypy_boto3_ecr import ECRClient + from mypy_boto3_s3 import S3Client + + +def load_python_lambda_to_s3( + s3_client: "S3Client", + bucket_name: str, + key_name: str, + code_path: str, + additional_python_packages: list[str] = None, +): + """ + Helper function to setup Lambdas that need additional python libs. + Will create a temp-zip and upload in the s3 bucket. + Installs additional libs and package with the zip + + :param s3_client: client for S3 + :param bucket_name: bucket name (bucket will be created) + :param key_name: key name for the uploaded zip file + :param code_path: the path to the source code that should be included + :param additional_python_packages: a list of strings with python packages that are required to run the lambda + :return: None + """ + try: + temp_dir = tempfile.mkdtemp() + tmp_zip_path = os.path.join(tempfile.gettempdir(), "helper.zip") + # install python packages + if additional_python_packages: + try: + run(f"cd {temp_dir} && pip install {' '.join(additional_python_packages)} -t .") + except Exception as e: + LOG.error( + "Could not install additional packages %s: %s", additional_python_packages, e + ) + # add the lambda to the directory + _zip_lambda_resources( + lambda_code_path=code_path, + handler_file_name="index.py", + resources_dir=temp_dir, + zip_path=tmp_zip_path, + ) + _upload_to_s3(s3_client, bucket_name=bucket_name, key_name=key_name, file=tmp_zip_path) + + finally: + if temp_dir: + shutil.rmtree(temp_dir) + if tmp_zip_path and os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) + + +def load_nodejs_lambda_to_s3( + s3_client: "S3Client", + bucket_name: str, + key_name: str, + code_path: str, + additional_nodjs_packages: list[str] = None, + additional_nodejs_packages: list[str] = None, + additional_resources: list[str] = None, +): + """ + Helper function to setup nodeJS Lambdas that need additional libs. + Will create a temp-zip and upload in the s3 bucket. + Installs additional libs and package with the zip + + :param s3_client: client for S3 + :param bucket_name: bucket name (bucket will be created) + :param key_name: key name for the uploaded zip file + :param code_path: the path to the source code that should be included + :param additional_nodjs_packages: a list of strings with nodeJS packages that are required to run the lambda + :param additional_nodejs_packages: a list of strings with nodeJS packages that are required to run the lambda + :param additional_resources: list of path-strings to resources or internal libs that should be packaged into the lambda + :return: None + """ + additional_resources = additional_resources or [] + + if additional_nodjs_packages: + additional_nodejs_packages = additional_nodejs_packages or [] + additional_nodejs_packages.extend(additional_nodjs_packages) + + try: + temp_dir = tempfile.mkdtemp() + tmp_zip_path = os.path.join(tempfile.gettempdir(), "helper.zip") + + # Install NodeJS packages + if additional_nodejs_packages: + try: + os.mkdir(os.path.join(temp_dir, "node_modules")) + run(f"cd {temp_dir} && npm install {' '.join(additional_nodejs_packages)} ") + except Exception as e: + LOG.error( + "Could not install additional packages %s: %s", additional_nodejs_packages, e + ) + + for r in additional_resources: + try: + path = Path(r) + if path.is_dir(): + dir_name = os.path.basename(path) + dest_dir = os.path.join(temp_dir, dir_name) + shutil.copytree(path, dest_dir) + elif path.is_file(): + new_resource_temp_path = os.path.join(temp_dir, os.path.basename(path)) + shutil.copy2(path, new_resource_temp_path) + except Exception as e: + LOG.error("Could not copy additional resources %s: %s", r, e) + + _zip_lambda_resources( + lambda_code_path=code_path, + handler_file_name="index.js", + resources_dir=temp_dir, + zip_path=tmp_zip_path, + ) + _upload_to_s3(s3_client, bucket_name=bucket_name, key_name=key_name, file=tmp_zip_path) + finally: + if temp_dir: + shutil.rmtree(temp_dir) + if tmp_zip_path and os.path.exists(tmp_zip_path): + os.remove(tmp_zip_path) + + +def _zip_lambda_resources( + lambda_code_path: str, handler_file_name: str, resources_dir: str, zip_path: str +): + # add the lambda to the directory + new_resource_temp_path = os.path.join(resources_dir, handler_file_name) + shutil.copy2(lambda_code_path, new_resource_temp_path) + + with zipfile.ZipFile(zip_path, "w") as temp_zip: + # Add the contents of the existing ZIP file + for root, _, files in os.walk(resources_dir): + for file in files: + file_path = os.path.join(root, file) + archive_name = os.path.relpath(file_path, resources_dir) + temp_zip.write(file_path, archive_name) + + +def generate_ecr_image_from_dockerfile( + ecr_client: "ECRClient", + repository_name: str, + file_path: str, + build_in_place: bool = False, +): + """ + Helper function to generate an ECR image from a dockerfile. + + :param ecr_client: client for ECR + :param repository_name: name for the repository to be created + :param file_path: path of the file to be used + :param build_in_place: build the container image in place rather than copying to a temporary location. + This is useful if the build context has other files. + :return: None + """ + repository_uri = ecr_client.create_repository( + repositoryName=repository_name, + )["repository"]["repositoryUri"] + + auth_response = ecr_client.get_authorization_token() + auth_token = auth_response["authorizationData"][0]["authorizationToken"].encode() + username, password = base64.b64decode(auth_token).decode().split(":") + registry = auth_response["authorizationData"][0]["proxyEndpoint"] + DOCKER_CLIENT.login(username, password, registry=registry) + + if build_in_place: + destination_file = file_path + else: + temp_dir = tempfile.mkdtemp() + destination_file = os.path.join(temp_dir, "Dockerfile") + shutil.copy2(file_path, destination_file) + DOCKER_CLIENT.build_image(dockerfile_path=destination_file, image_name=repository_uri) + DOCKER_CLIENT.push_image(repository_uri) + + +def generate_ecr_image_from_docker_image( + ecr_client: "ECRClient", repository_name: str, image_name: str, platform: str = "linux/amd64" +): + """ + Parameters + ---------- + ecr_client + repository_name + image_name + platform + + Returns + ------- + + """ + + DOCKER_CLIENT.pull_image(image_name, platform=platform) + + repository_uri = ecr_client.create_repository( + repositoryName=repository_name, + )["repository"]["repositoryUri"] + + auth_response = ecr_client.get_authorization_token() + auth_token = auth_response["authorizationData"][0]["authorizationToken"].encode() + username, password = base64.b64decode(auth_token).decode().split(":") + registry = auth_response["authorizationData"][0]["proxyEndpoint"] + DOCKER_CLIENT.login(username, password, registry=registry) + + DOCKER_CLIENT.tag_image(image_name, repository_uri) + DOCKER_CLIENT.push_image(repository_uri) + + +def _upload_to_s3(s3_client: "S3Client", bucket_name: str, key_name: str, file: str): + try: + create_s3_bucket(bucket_name, s3_client) + except ClientError as exc: + # when creating an already existing bucket, regions differ in their behavior: + # us-east-1 will silently pass (idempotent) + # any other region will return a `BucketAlreadyOwnedByYou` exception. + if exc.response["Error"]["Code"] != "BucketAlreadyOwnedByYou": + raise exc + s3_client.upload_file(Filename=file, Bucket=bucket_name, Key=key_name) diff --git a/localstack-core/localstack/testing/scenario/provisioning.py b/localstack-core/localstack/testing/scenario/provisioning.py new file mode 100644 index 0000000000000..cc384d3046c65 --- /dev/null +++ b/localstack-core/localstack/testing/scenario/provisioning.py @@ -0,0 +1,426 @@ +import json +import logging +import warnings +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Callable, ContextManager, Optional + +import aws_cdk as cdk +from botocore.exceptions import ClientError, WaiterError + +from localstack.config import is_env_true +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest.fixtures import StackDeployError +from localstack.utils.aws.resources import create_s3_bucket +from localstack.utils.files import load_file +from localstack.utils.functions import call_safe +from localstack.utils.strings import short_uid + +if TYPE_CHECKING: + from mypy_boto3_s3 import S3Client + +from localstack.aws.api.cloudformation import Capability +from localstack.aws.connect import ServiceLevelClientFactory + +LOG = logging.getLogger(__name__) +CDK_BOOTSTRAP_PARAM = "/cdk-bootstrap/hnb659fds/version" +WAITER_CONFIG_AWS = { + "Delay": 6, + "MaxAttempts": 600, +} # total timeout ~1 hour (6 * 600 = 3_600 seconds) +# total timeout ~10 minutes +WAITER_CONFIG_LS = {"Delay": 1, "MaxAttempts": 600} +CFN_MAX_TEMPLATE_SIZE = 51_200 + + +# TODO: move/unify with utils +def cleanup_s3_bucket(s3_client: "S3Client", bucket_name: str, delete_bucket: bool = False): + LOG.debug("Cleaning provisioned S3 Bucket %s", bucket_name) + try: + objs = s3_client.list_objects_v2(Bucket=bucket_name) + objs_num = objs["KeyCount"] + if objs_num > 0: + LOG.debug("Deleting %s objects from bucket_name=%s", objs_num, bucket_name) + obj_keys = [{"Key": o["Key"]} for o in objs["Contents"]] + s3_client.delete_objects(Bucket=bucket_name, Delete={"Objects": obj_keys}) + if delete_bucket: + s3_client.delete_bucket(Bucket=bucket_name) + except Exception: + LOG.warning( + "Failed to clean provisioned S3 Bucket bucket_name=%s", + bucket_name, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + + +# TODO: cross-account tests +# TODO: cross-region references +# TODO: explore asset handling +# TODO: use CDK App as central construct instead of individual stacks +class InfraProvisioner: + """ + An InfraProvisioner encapsulates logic around the setup and teardown of multiple CDK stacks and custom provisioning steps. + Use it to set up your infrastructure against which you can then execute individual or multiple integration tests. + """ + + cloudformation_stacks: dict[str, dict] + custom_cleanup_steps: list[Callable] + custom_setup_steps: list[Callable] + aws_client: ServiceLevelClientFactory + namespace: str + base_path: str | None + cdk_app: cdk.App + persist_output: bool + force_synth: bool + + def __init__( + self, + aws_client: ServiceLevelClientFactory, + namespace: str, + base_path: Optional[str] = None, + force_synth: Optional[bool] = False, + persist_output: Optional[bool] = False, + ): + """ + :param namespace: repo-unique identifier for this CDK app. + A directory with this name will be created at `tests/aws/cdk_templates//` + :param base_path: absolute path to `tests/aws/cdk_templates` where synthesized artifacts are stored + :param aws_client: an aws client factory + :param force_template_update: set to True to always re-synth the CDK app + :return: an instantiated CDK InfraProvisioner which can be used to deploy a CDK app + """ + self.namespace = namespace + self.base_path = base_path + self.cloudformation_stacks = {} + self.custom_cleanup_steps = [] + self.custom_setup_steps = [] + self.aws_client = aws_client + self.force_synth = force_synth + self.persist_output = persist_output + if self.base_path is None: + self.persist_output = False + self.cdk_app = cdk.App(default_stack_synthesizer=cdk.BootstraplessSynthesizer()) + + def get_asset_bucket(self): + account = self.aws_client.sts.get_caller_identity()["Account"] + region = self.aws_client.sts.meta.region_name + return f"localstack-testing-{account}-{region}" + + @contextmanager + def provisioner( + self, skip_deployment: Optional[bool] = False, skip_teardown: Optional[bool] = False + ) -> ContextManager["InfraProvisioner"]: + """ + :param skip_deployment: Set to True to skip stack creation and re-use existing stack without modifications. + Also skips custom setup steps. + Use-case: When you only want to regenerate the synthesized template without actually deploying. + :param skip_teardown: Set to True to skip deleting any previously created stacks. + Also skips custom teardown steps. + Use-case: When you're dealing with resource-heavy stacks that take a long time to provision. + The provisioner will perform a stack update instead of a create, should the stack still exist. + + Example usage: + def my_fixture(infrastructure_setup): + ... + infra = infrastructure_setup(namespace="...") + with infra.provisioner() as prov: + yield prov + """ + try: + self.provision(skip_deployment=skip_deployment) + # TODO: return "sub-view" on InfraProvisioner here for clearer API + yield self + finally: + if not skip_teardown: + self.teardown() + else: + LOG.debug("Skipping teardown. Resources and stacks are not deleted.") + + def provision(self, skip_deployment: Optional[bool] = False): + """ + Execute all previously added custom provisioning steps and deploy added CDK stacks via CloudFormation. + + Already deployed stacks will be updated instead. + """ + self._synth() + if skip_deployment: + LOG.debug("Skipping deployment. Assuming stacks have already been created") + return + + is_update = False + + if all( + self._is_stack_deployed(stack_name, stack) + for stack_name, stack in self.cloudformation_stacks.items() + ): + LOG.debug("All stacks are already deployed. Skipping the provisioning.") + # TODO: in localstack we might want to do a delete/create + # but generally this won't be a common use case when developing against LocalStack + is_update = True + + self._bootstrap() + self._run_manual_setup_tasks() + for stack_name, stack in self.cloudformation_stacks.items(): + change_set_name = f"test-cs-{short_uid()}" + if len(stack["Template"]) > CFN_MAX_TEMPLATE_SIZE: + # if the template size is too big, we need to upload it to s3 first + # and use TemplateURL instead to point to the template in s3 + template_bucket_name = self._template_bucket_name() + self._create_bucket_if_not_exists(template_bucket_name) + key = f"{stack_name}.yaml" + self.aws_client.s3.put_object( + Bucket=template_bucket_name, Key=key, Body=stack["Template"] + ) + url = self.aws_client.s3.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": template_bucket_name, "Key": key}, + ExpiresIn=10, + ) + + change_set = self.aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateURL=url, + ChangeSetType="UPDATE" if is_update else "CREATE", + Capabilities=[ + Capability.CAPABILITY_AUTO_EXPAND, + Capability.CAPABILITY_IAM, + Capability.CAPABILITY_NAMED_IAM, + ], + ) + else: + change_set = self.aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=stack["Template"], + ChangeSetType="UPDATE" if is_update else "CREATE", + Capabilities=[ + Capability.CAPABILITY_AUTO_EXPAND, + Capability.CAPABILITY_IAM, + Capability.CAPABILITY_NAMED_IAM, + ], + ) + stack_id = self.cloudformation_stacks[stack_name]["StackId"] = change_set["StackId"] + try: + self.aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=change_set["Id"], + WaiterConfig=WAITER_CONFIG_AWS if is_aws_cloud() else WAITER_CONFIG_LS, + ) + except WaiterError: + # it's OK if we don't have any updates to perform here (!) + # there is no specific error code unfortunately + if not (is_update): + raise + else: + LOG.warning("Execution of change set %s failed. Assuming no changes detected.") + else: + self.aws_client.cloudformation.execute_change_set(ChangeSetName=change_set["Id"]) + try: + self.aws_client.cloudformation.get_waiter( + "stack_update_complete" if is_update else "stack_create_complete" + ).wait( + StackName=stack_id, + WaiterConfig=WAITER_CONFIG_AWS if is_aws_cloud() else WAITER_CONFIG_LS, + ) + + except WaiterError as e: + raise StackDeployError( + self.aws_client.cloudformation.describe_stacks(StackName=stack_id)[ + "Stacks" + ][0], + self.aws_client.cloudformation.describe_stack_events(StackName=stack_id)[ + "StackEvents" + ], + ) from e + + if stack["AutoCleanS3"]: + stack_resources = self.aws_client.cloudformation.describe_stack_resources( + StackName=stack_id + )["StackResources"] + s3_buckets = [ + r["PhysicalResourceId"] + for r in stack_resources + if r["ResourceType"] == "AWS::S3::Bucket" + ] + + for s3_bucket in s3_buckets: + self.custom_cleanup_steps.append( + lambda bucket=s3_bucket: cleanup_s3_bucket( + self.aws_client.s3, bucket, delete_bucket=False + ) + ) + + # TODO: move this to a CFn testing utility + def get_stack_outputs(self, stack_name: str) -> dict[str, str]: + """ + A simple helper function to extract outputs of a deployed stack in a simple : format. + """ + describe_stack = self.aws_client.cloudformation.describe_stacks(StackName=stack_name) + raw_outputs = describe_stack["Stacks"][0].get("Outputs", {}) + outputs = {o["OutputKey"]: o["OutputValue"] for o in raw_outputs} + return outputs + + def teardown(self): + """ + Reverse operation of `InfraProvisioner.provision`. + First performs any registered clean-up tasks in reverse order and afterwards deletes any previously created CloudFormation stacks + """ + for fn in self.custom_cleanup_steps[::-1]: # traverse in reverse order + call_safe(fn) + + # TODO: dependency detection (coming with proper synth support) + for stack_name, stack in reversed(self.cloudformation_stacks.items()): + try: + stack_id = stack.get("StackId", stack_name) + self.aws_client.cloudformation.delete_stack(StackName=stack_id) + self.aws_client.cloudformation.get_waiter("stack_delete_complete").wait( + StackName=stack_id, + WaiterConfig=WAITER_CONFIG_AWS if is_aws_cloud() else WAITER_CONFIG_LS, + ) + except Exception: + LOG.warning( + "Failed to delete stack %s", + stack_name, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + # TODO log-groups created by lambda are not automatically cleaned up by CDK + + if not is_aws_cloud(): + # TODO: also clean up s3 bucket on localstack? + # does it even make sense to do a general "de-bootstrapping" after each test? + try: + self.aws_client.ssm.delete_parameter(Name=CDK_BOOTSTRAP_PARAM) + except Exception: + pass + + # clean & delete asset bucket + cleanup_s3_bucket(self.aws_client.s3, self.get_asset_bucket(), delete_bucket=True) + + def add_cdk_stack( + self, + cdk_stack: cdk.Stack, + autoclean_buckets: Optional[bool] = True, + ): + """ + Register a CDK stack to be deployed in a later `InfraProvisioner.provision` call. + Custom tasks registered via `InfraProvisioner.add_custom_setup` will be executed before any stack deployments. + + CAVEAT: `InfraProvisioner` currently does not support CDK-generated assets. + If you need any assets, such as zip files uploaded to s3, please use `InfraProvisioner.add_custom_setup`. + """ + # TODO: unify this after refactoring existing usage + if self.persist_output: + dir_path = self._get_template_path() + dir_path.mkdir(exist_ok=True, parents=True) + template_path = dir_path / f"{cdk_stack.stack_name}.json" + + should_update_template = ( + is_env_true("TEST_CDK_FORCE_SYNTH") or self.force_synth + ) # EXPERIMENTAL / API subject to change + if not template_path.exists() or should_update_template: + with open(template_path, "wt") as fd: + template_json = cdk.assertions.Template.from_stack(cdk_stack).to_json() + json.dump(template_json, fd, indent=2) + # add trailing newline for linter and Git compliance + fd.write("\n") + + self.cloudformation_stacks[cdk_stack.stack_name] = { + "StackName": cdk_stack.stack_name, + "Template": load_file(template_path), + "AutoCleanS3": autoclean_buckets, + } + else: + template_json = cdk.assertions.Template.from_stack(cdk_stack).to_json() + template_str = json.dumps(template_json) + self.cloudformation_stacks[cdk_stack.stack_name] = { + "StackName": cdk_stack.stack_name, + "Template": template_str, + "AutoCleanS3": autoclean_buckets, + } + + def add_custom_teardown(self, cleanup_task: Callable): + """ + Register a custom teardown task. + Anything registered here will be executed on InfraProvisioner.teardown BEFORE any stack deletions. + """ + self.custom_cleanup_steps.append(cleanup_task) + + def add_custom_setup(self, setup_task: Callable): + """ + Register a custom setup task. + Anything registered here will be executed on InfraProvisioner.provision BEFORE any stack operations. + """ + self.custom_setup_steps.append(setup_task) + + # TODO: remove after removing any usage + def add_custom_setup_provisioning_step(self, setup_task: Callable): + """ + DEPRECATED. Use add_custom_setup instead. + + Register a custom setup task. + Anything registered here will be executed on InfraProvisioner.provision BEFORE any stack operations. + """ + warnings.warn( + "`add_custom_setup_provisioning_step` is deprecated. Use `add_custom_setup`", + DeprecationWarning, + stacklevel=2, + ) + self.add_custom_setup(setup_task) + + def _bootstrap(self): + # TODO: add proper bootstrap template to deploy here if there's no parameter yet + self._create_bucket_if_not_exists(self.get_asset_bucket()) + + try: + self.aws_client.ssm.get_parameter(Name=CDK_BOOTSTRAP_PARAM) + except self.aws_client.ssm.exceptions.ParameterNotFound: + self.aws_client.ssm.put_parameter(Name=CDK_BOOTSTRAP_PARAM, Type="String", Value="10") + + def _run_manual_setup_tasks(self): + for fn in self.custom_setup_steps: + fn() + + def _is_stack_deployed(self, stack_name: str, stack: dict) -> bool: + try: + describe_stack = self.aws_client.cloudformation.describe_stacks(StackName=stack_name) + if outputs := describe_stack["Stacks"][0].get("Outputs"): + stack["Outputs"] = {o["OutputKey"]: o["OutputValue"] for o in outputs} + except Exception: + return False + # TODO should we try to run teardown first, if the status is not "CREATE_COMPLETE"? + return describe_stack["Stacks"][0]["StackStatus"] in [ + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + "UPDATE_ROLLBACK_COMPLETE", + ] + + def _get_template_path(self) -> Path: + return Path(self.base_path) / self.namespace + + def _template_bucket_name(self): + # TODO: unify this when we use the proper bootstrap template for wider asset support + account_id = self.aws_client.sts.get_caller_identity()["Account"] + region = self.aws_client.sts.meta.region_name + return f"localstack-testing-assets-{account_id}-{region}" + + def _create_bucket_if_not_exists(self, template_bucket_name: str): + try: + self.aws_client.s3.head_bucket(Bucket=template_bucket_name) + except ClientError as exc: + if exc.response["Error"]["Code"] != "404": + raise + create_s3_bucket(template_bucket_name, s3_client=self.aws_client.s3) + + def _synth(self): + # TODO: this doesn't actually synth a CloudAssembly yet + stacks = self.cdk_app.node.children + if not stacks: + return + + for stack in self.cdk_app.node.children: + self.add_cdk_stack(cdk_stack=stack) + + # TODO: move to a util class/module + @staticmethod + def get_asset_bucket_cdk(stack: cdk.Stack): + return cdk.Fn.join("-", ["localstack", "testing", stack.account, stack.region]) diff --git a/localstack-core/localstack/testing/snapshots/__init__.py b/localstack-core/localstack/testing/snapshots/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/snapshots/transformer_utility.py b/localstack-core/localstack/testing/snapshots/transformer_utility.py new file mode 100644 index 0000000000000..6e6d35ba70689 --- /dev/null +++ b/localstack-core/localstack/testing/snapshots/transformer_utility.py @@ -0,0 +1,929 @@ +import json +import logging +import re +from datetime import datetime +from json import JSONDecodeError +from typing import Optional, Pattern + +from localstack_snapshot.snapshots.transformer import ( + PATTERN_ISO8601, + GenericTransformer, + JsonpathTransformer, + KeyValueBasedTransformer, + RegexTransformer, + ResponseMetaDataTransformer, + SortingTransformer, + TimestampTransformer, +) + +from localstack.aws.api.secretsmanager import CreateSecretResponse +from localstack.aws.api.stepfunctions import ( + CreateStateMachineOutput, + LongArn, + StartExecutionOutput, + StartSyncExecutionOutput, +) +from localstack.utils.net import IP_REGEX + +LOG = logging.getLogger(__name__) + + +PATTERN_UUID = re.compile( + r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" +) + +PATTERN_ARN = re.compile(r"arn:(aws[a-zA-Z-]*)?:([a-zA-Z0-9-_.]+)?:([^:]+)?:(\d{12})?:(.*)") +PATTERN_ARN_CHANGESET = re.compile( + r"arn:(aws[a-zA-Z-]*)?:([a-zA-Z0-9-_.]+)?:([^:]+)?:(\d{12})?:changeSet/([^/]+)" +) +PATTERN_LOGSTREAM_ID: Pattern[str] = re.compile( + # r"\d{4}/\d{2}/\d{2}/\[((\$LATEST)|\d+)\][0-9a-f]{32}" # TODO - this was originally included + # but some responses from LS look like this: 2022/5/30/[$LATEST]20b0964ab88b01c1 -> might not be correct on LS? + r"\d{4}/\d{1,2}/\d{1,2}/\[((\$LATEST)|\d+)\][0-9a-f]{8,32}" +) +PATTERN_KEY_ARN = re.compile( + r"arn:(aws[a-zA-Z-]*)?:([a-zA-Z0-9-_.]+)?:([^:]+)?:(\d{12})?:key/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" +) + + +# TODO: split into generic/aws and put into lib +class TransformerUtility: + @staticmethod + def key_value( + key: str, value_replacement: Optional[str] = None, reference_replacement: bool = True + ): + """Creates a new KeyValueBasedTransformer. If the key matches, the value will be replaced. + + :param key: the name of the key which should be replaced + :param value_replacement: the value which will replace the original value. + By default it is the key-name in lowercase, separated with hyphen + :param reference_replacement: if False, only the original value for this key will be replaced. + If True all references of this value will be replaced (using a regex pattern), for the entire test case. + In this case, the replaced value will be nummerated as well. + Default: True + + :return: KeyValueBasedTransformer + """ + return KeyValueBasedTransformer( + lambda k, v: v if k == key and (v is not None and v != "") else None, + replacement=value_replacement or _replace_camel_string_with_hyphen(key), + replace_reference=reference_replacement, + ) + + @staticmethod + def resource_name(replacement_name: str = "resource"): + """Creates a new KeyValueBasedTransformer for the resource name. + + :param replacement_name ARN of a resource to extract name from + :return: KeyValueBasedTransformer + """ + return KeyValueBasedTransformer(_resource_name_transformer, replacement_name) + + @staticmethod + def jsonpath(jsonpath: str, value_replacement: str, reference_replacement: bool = True): + """Creates a new JsonpathTransformer. If the jsonpath matches, the value will be replaced. + + :param jsonpath: the jsonpath that should be matched + :param value_replacement: the value which will replace the original value. + By default it is the key-name in lowercase, separated with hyphen + :param reference_replacement: if False, only the original value for this key will be replaced. + If True all references of this value will be replaced (using a regex pattern), for the entire test case. + In this case, the replaced value will be nummerated as well. + Default: True + + :return: JsonpathTransformer + """ + return JsonpathTransformer( + jsonpath=jsonpath, + replacement=value_replacement, + replace_reference=reference_replacement, + ) + + @staticmethod + def regex(regex: str | Pattern[str], replacement: str): + """Creates a new RegexTransformer. All matches in the string-converted dict will be replaced. + + :param regex: the regex that should be matched + :param replacement: the value which will replace the original value. + + :return: RegexTransformer + """ + return RegexTransformer(regex, replacement) + + @staticmethod + def remove_key(key: str): + """Creates a new GenericTransformer that removes all instances of the specified key. + + :param key: the name of the key which should be removed from all responses + :return: GenericTransformer + """ + + def _remove_key_recursive(snapshot_content: dict, *_) -> dict: + def _remove_key_from_data(data): + if isinstance(data, dict): + return {k: _remove_key_from_data(v) for k, v in data.items() if k != key} + elif isinstance(data, list): + return [_remove_key_from_data(item) for item in data] + return data + + return {k: _remove_key_from_data(v) for k, v in snapshot_content.items()} + + return GenericTransformer(_remove_key_recursive) + + # TODO add more utility functions? e.g. key_value with function as parameter? + + @staticmethod + def lambda_api(): + """ + :return: array with Transformers, for lambda api. + """ + return [ + TransformerUtility.key_value("FunctionName"), + TransformerUtility.key_value( + "CodeSize", value_replacement="", reference_replacement=False + ), + TransformerUtility.jsonpath( + jsonpath="$..Code.Location", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.jsonpath( + jsonpath="$..Content.Location", + value_replacement="", + reference_replacement=False, + ), + KeyValueBasedTransformer(_resource_name_transformer, "resource"), + KeyValueBasedTransformer( + _log_stream_name_transformer, "log-stream-name", replace_reference=True + ), + ] + + @staticmethod + def lambda_report_logs(): + """Transformers for Lambda REPORT logs replacing dynamic metrics including: + * Duration + * Billed Duration + * Max Memory Used + * Init Duration + + Excluding: + * Memory Size + """ + return [ + TransformerUtility.regex( + re.compile(r"Duration: \d+(\.\d{2})? ms"), "Duration: ms" + ), + TransformerUtility.regex(re.compile(r"Used: \d+ MB"), "Used: MB"), + ] + + @staticmethod + def apigateway_api(): + return [ + TransformerUtility.key_value("id"), + TransformerUtility.key_value("name"), + TransformerUtility.key_value("parentId"), + TransformerUtility.key_value("rootResourceId"), + ] + + @staticmethod + def apigateway_proxy_event(): + return [ + TransformerUtility.key_value("extendedRequestId"), + TransformerUtility.key_value("resourceId"), + TransformerUtility.key_value("sourceIp"), + TransformerUtility.jsonpath("$..headers.X-Amz-Cf-Id", value_replacement="cf-id"), + TransformerUtility.jsonpath( + "$..headers.CloudFront-Viewer-ASN", value_replacement="cloudfront-asn" + ), + TransformerUtility.jsonpath( + "$..headers.CloudFront-Viewer-Country", value_replacement="cloudfront-country" + ), + TransformerUtility.jsonpath("$..headers.Via", value_replacement="via"), + TransformerUtility.jsonpath("$..headers.X-Amzn-Trace-Id", value_replacement="trace-id"), + TransformerUtility.jsonpath( + "$..requestContext.requestTime", + value_replacement="", + reference_replacement=False, + ), + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "requestTimeEpoch" else None, + "", + replace_reference=False, + ), + TransformerUtility.regex(IP_REGEX.strip("^$"), ""), + ] + + @staticmethod + def apigateway_invocation_headers(): + return [ + TransformerUtility.key_value("apigw-id"), + TransformerUtility.key_value("Via"), + TransformerUtility.key_value( + "Date", value_replacement="", reference_replacement=False + ), + TransformerUtility.key_value( + "x-amz-apigw-id", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.key_value( + "x-amzn-Remapped-Date", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.key_value( + "X-Amzn-Trace-Id", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.key_value("X-Amzn-Apigateway-Api-Id"), + TransformerUtility.key_value("X-Forwarded-For"), + TransformerUtility.key_value( + "X-Forwarded-Port", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.key_value( + "X-Forwarded-Proto", + value_replacement="", + reference_replacement=False, + ), + ] + + @staticmethod + def apigatewayv2_jwt_authorizer_event(): + return [ + TransformerUtility.jsonpath("$..claims.auth_time", "claims-auth-time"), + TransformerUtility.jsonpath("$..claims.client_id", "claims-client-id"), + TransformerUtility.jsonpath("$..claims.exp", "claims-exp"), + TransformerUtility.jsonpath("$..claims.iat", "claims-iat"), + TransformerUtility.jsonpath("$..claims.jti", "claims-jti"), + TransformerUtility.jsonpath("$..claims.sub", "claims-sub"), + ] + + @staticmethod + def apigatewayv2_lambda_proxy_event(): + return [ + TransformerUtility.key_value("resourceId"), + TransformerUtility.key_value("sourceIp"), + TransformerUtility.jsonpath("$..requestContext.accountId", "account-id"), + TransformerUtility.jsonpath("$..requestContext.apiId", "api-id"), + TransformerUtility.jsonpath("$..requestContext.domainName", "domain-name"), + TransformerUtility.jsonpath("$..requestContext.domainPrefix", "domain-prefix"), + TransformerUtility.jsonpath( + "$..requestContext.extendedRequestId", "extended-request-id" + ), + TransformerUtility.jsonpath("$..requestContext.requestId", "request-id"), + TransformerUtility.jsonpath( + "$..requestContext.requestTime", + value_replacement="", + reference_replacement=False, + ), + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "requestTimeEpoch" else None, + "", + replace_reference=False, + ), + TransformerUtility.key_value("time"), + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "timeEpoch" else None, + "", + replace_reference=False, + ), + TransformerUtility.jsonpath("$..multiValueHeaders.Host[*]", "host"), + TransformerUtility.jsonpath( + "$..multiValueHeaders.X-Forwarded-For[*]", "x-forwarded-for" + ), + TransformerUtility.jsonpath( + "$..multiValueHeaders.X-Forwarded-Port[*]", "x-forwarded-port" + ), + TransformerUtility.jsonpath( + "$..multiValueHeaders.X-Forwarded-Proto[*]", "x-forwarded-proto" + ), + TransformerUtility.jsonpath( + "$..multiValueHeaders.X-Amzn-Trace-Id[*]", "x-amzn-trace-id" + ), + TransformerUtility.jsonpath("$..multiValueHeaders.authorization[*]", "authorization"), + TransformerUtility.jsonpath("$..multiValueHeaders.User-Agent[*]", "user-agent"), + TransformerUtility.regex(r"python-requests/\d+\.\d+(\.\d+)?", "python-requests/x.x.x"), + ] + + @staticmethod + def cloudformation_api(): + """ + :return: array with Transformers, for cloudformation api. + """ + return [ + KeyValueBasedTransformer(_resource_name_transformer, "resource"), + KeyValueBasedTransformer(_change_set_id_transformer, "change-set-id"), + TransformerUtility.key_value("ChangeSetName"), + TransformerUtility.key_value("ChangeSetId"), + TransformerUtility.key_value("StackName"), + ] + + @staticmethod + def cfn_stack_resource(): + """ + :return: array with Transformers, for cloudformation stack resource description; + recommended for verifying the stack resources deployed for scenario tests + """ + return [ + KeyValueBasedTransformer(_resource_name_transformer, "resource"), + KeyValueBasedTransformer(_change_set_id_transformer, "change-set-id"), + TransformerUtility.key_value("LogicalResourceId"), + TransformerUtility.key_value("PhysicalResourceId", reference_replacement=False), + ] + + @staticmethod + def dynamodb_api(): + """ + :return: array with Transformers, for dynamodb api. + """ + return [ + RegexTransformer( + r"([a-zA-Z0-9-_.]*)?test_table_([a-zA-Z0-9-_.]*)?", replacement="" + ), + ] + + @staticmethod + def dynamodb_streams_api(): + return [ + RegexTransformer( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$", replacement="" + ), + TransformerUtility.key_value("TableName"), + TransformerUtility.key_value("TableStatus"), + TransformerUtility.key_value("LatestStreamLabel"), + TransformerUtility.key_value("StartingSequenceNumber", reference_replacement=False), + TransformerUtility.key_value("ShardId"), + TransformerUtility.key_value("StreamLabel"), + TransformerUtility.key_value("SequenceNumber"), + TransformerUtility.key_value("eventID"), + ] + + @staticmethod + def iam_api(): + """ + :return: array with Transformers, for iam api. + """ + return [ + TransformerUtility.key_value("UserName"), + TransformerUtility.key_value("UserId"), + TransformerUtility.key_value("RoleId"), + TransformerUtility.key_value("RoleName"), + TransformerUtility.key_value("PolicyName"), + TransformerUtility.key_value("PolicyId"), + TransformerUtility.key_value("GroupName"), + ] + + @staticmethod + def transcribe_api(): + """ + :return: array with Transformers, for iam api. + """ + return [ + RegexTransformer( + r"([a-zA-Z0-9-_.]*)?\/test-bucket-([a-zA-Z0-9-_.]*)?", replacement="" + ), + TransformerUtility.key_value("TranscriptionJobName", "transcription-job"), + TransformerUtility.key_value("jobName", "job-name"), + TransformerUtility.jsonpath( + jsonpath="$..Transcript..TranscriptFileUri", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.key_value("NextToken", "token", reference_replacement=False), + ] + + @staticmethod + def s3_api(): + """ + :return: array with Transformers, for s3 api. + """ + + s3 = [ + TransformerUtility.key_value("Name", value_replacement="bucket-name"), + TransformerUtility.key_value("BucketName"), + TransformerUtility.key_value("VersionId"), + TransformerUtility.jsonpath( + jsonpath="$..Owner.DisplayName", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.jsonpath( + jsonpath="$..Owner.ID", value_replacement="", reference_replacement=False + ), + ] + # for s3 notifications: + s3.extend(TransformerUtility.s3_notifications_transformer()) + return s3 + + @staticmethod + def s3_notifications_transformer(): + return [ + TransformerUtility.jsonpath( + "$..responseElements.x-amz-id-2", "amz-id", reference_replacement=False + ), + TransformerUtility.jsonpath( + "$..responseElements.x-amz-request-id", + "amz-request-id", + reference_replacement=False, + ), + TransformerUtility.jsonpath("$..s3.configurationId", "config-id"), + TransformerUtility.jsonpath( + "$..s3.object.sequencer", "sequencer", reference_replacement=False + ), + TransformerUtility.jsonpath("$..s3.bucket.ownerIdentity.principalId", "principal-id"), + TransformerUtility.jsonpath("$..userIdentity.principalId", "principal-id"), + TransformerUtility.jsonpath("$..requestParameters.sourceIPAddress", "ip-address"), + TransformerUtility.jsonpath( + "$..s3.object.versionId", + "version-id", + reference_replacement=False, + ), + ] + + @staticmethod + def s3_dynamodb_notifications(): + return [ + TransformerUtility.jsonpath("$..uuid.S", "uuid"), + TransformerUtility.jsonpath("$..M.requestParameters.M.sourceIPAddress.S", "ip-address"), + TransformerUtility.jsonpath( + "$..M.responseElements.M.x-amz-id-2.S", "amz-id", reference_replacement=False + ), + TransformerUtility.jsonpath( + "$..M.responseElements.M.x-amz-request-id.S", + "amz-request-id", + reference_replacement=False, + ), + TransformerUtility.jsonpath("$..M.s3.M.bucket.M.name.S", "bucket-name"), + TransformerUtility.jsonpath("$..M.s3.M.bucket.M.arn.S", "bucket-arn"), + TransformerUtility.jsonpath( + "$..M.s3.M.bucket.M.ownerIdentity.M.principalId.S", "principal-id" + ), + TransformerUtility.jsonpath("$..M.s3.M.configurationId.S", "config-id"), + TransformerUtility.jsonpath("$..M.s3.M.object.M.key.S", "object-key"), + TransformerUtility.jsonpath( + "$..M.s3.M.object.M.sequencer.S", "sequencer", reference_replacement=False + ), + TransformerUtility.jsonpath("$..M.userIdentity.M.principalId.S", "principal-id"), + ] + + @staticmethod + def kinesis_api(): + """ + :return: array with Transformers, for kinesis api. + """ + return [ + JsonpathTransformer( + jsonpath="$..Records..SequenceNumber", + replacement="sequence_number", + replace_reference=True, + ), + TransformerUtility.key_value("SequenceNumber", "sequence_number"), + TransformerUtility.key_value("StartingSequenceNumber", "starting_sequence_number"), + TransformerUtility.key_value("ShardId", "shard_id"), + TransformerUtility.key_value("NextShardIterator", "next_shard_iterator"), + TransformerUtility.key_value( + "EndingHashKey", "ending_hash", reference_replacement=False + ), + TransformerUtility.key_value( + "StartingHashKey", "starting_hash", reference_replacement=False + ), + TransformerUtility.key_value(_resource_name_transformer, "ConsumerARN"), + RegexTransformer( + r"([a-zA-Z0-9-_.]*)?\/consumer:([0-9-_.]*)?", + replacement="", + ), + RegexTransformer( + r"([a-zA-Z0-9-_.]*)?\/test-stream-([a-zA-Z0-9-_.]*)?", + replacement="", + ), + TransformerUtility.key_value( + "ContinuationSequenceNumber", "" + ), + ] + + @staticmethod + def route53resolver_api(): + """ + :return: array with Transformers, for route53resolver api. + """ + return [ + TransformerUtility.key_value( + "SecurityGroupIds", value_replacement="sg-ids", reference_replacement=False + ), + TransformerUtility.key_value("Id"), + TransformerUtility.key_value("HostVPCId", "host-vpc-id"), + KeyValueBasedTransformer(_resource_name_transformer, "Arn"), + TransformerUtility.key_value("CreatorRequestId"), + TransformerUtility.key_value("StatusMessage", reference_replacement=False), + ] + + @staticmethod + def route53_api(): + return [ + TransformerUtility.jsonpath("$..HostedZone.CallerReference", "caller-reference"), + TransformerUtility.jsonpath( + jsonpath="$..DelegationSet.NameServers", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.jsonpath( + jsonpath="$..ChangeInfo.Status", value_replacement="status" + ), + KeyValueBasedTransformer(_route53_hosted_zone_id_transformer, "zone-id"), + TransformerUtility.regex(r"/change/[A-Za-z0-9]+", "/change/"), + TransformerUtility.jsonpath( + jsonpath="$..HostedZone.Name", value_replacement="zone_name" + ), + ] + + @staticmethod + def sqs_api(): + """ + :return: array with Transformers, for sqs api. + """ + return [ + TransformerUtility.key_value("ReceiptHandle"), + TransformerUtility.key_value("TaskHandle"), + TransformerUtility.key_value( + "SenderId" + ), # TODO: flaky against AWS (e.g. /Attributes/SenderId '' β†’ '' ... (expected β†’ actual)) + TransformerUtility.key_value("SequenceNumber"), + TransformerUtility.jsonpath("$..MessageAttributes.RequestID.StringValue", "request-id"), + KeyValueBasedTransformer(_resource_name_transformer, "resource"), + ] + + @staticmethod + def kms_api(): + """ + :return: array with Transformers, for kms api. + """ + return [ + TransformerUtility.key_value("KeyId"), + TransformerUtility.jsonpath( + jsonpath="$..Signature", + value_replacement="", + reference_replacement=False, + ), + TransformerUtility.jsonpath( + jsonpath="$..Mac", value_replacement="", reference_replacement=False + ), + TransformerUtility.key_value("CiphertextBlob", reference_replacement=False), + TransformerUtility.key_value("Plaintext", reference_replacement=False), + RegexTransformer(PATTERN_KEY_ARN, replacement=""), + ] + + @staticmethod + def sns_api(): + """ + :return: array with Transformers, for sns api. + """ + return [ + TransformerUtility.key_value("ReceiptHandle"), + TransformerUtility.key_value("SequenceNumber"), # this might need to be in SQS + TransformerUtility.key_value( + "Signature", value_replacement="", reference_replacement=False + ), + # the body of SNS messages contains a timestamp, need to ignore the hash + TransformerUtility.key_value("MD5OfBody", "", reference_replacement=False), + # this can interfere in ARN with the accountID + TransformerUtility.key_value( + "SenderId", value_replacement="", reference_replacement=False + ), + KeyValueBasedTransformer( + _sns_pem_file_token_transformer, + replacement="signing-cert-file", + ), + # replaces the domain in "SigningCertURL" URL (KeyValue won't work as it replaces reference, and if + # replace_reference is False, then it replaces the whole key + RegexTransformer( + r"(?i)(?<=SigningCertURL[\"|']:\s[\"|'])(https?.*?)(?=/\SimpleNotificationService-)", + replacement="", + ), + # replaces the domain in "UnsubscribeURL" URL (KeyValue won't work as it replaces reference, and if + # replace_reference is False, then it replaces the whole key + RegexTransformer( + r"(?i)(?<=UnsubscribeURL[\"|']:\s[\"|'])(https?.*?)(?=/\?Action=Unsubscribe)", + replacement="", + ), + KeyValueBasedTransformer(_resource_name_transformer, "resource"), + # add a special transformer with 'resource' replacement for SubscriptionARN in UnsubscribeURL + KeyValueBasedTransformer( + _sns_unsubscribe_url_subscription_arn_transformer, replacement="resource" + ), + ] + + @staticmethod + def cloudwatch_api(): + """ + :return: array with Transformers, for cloudwatch api. + """ + return [ + TransformerUtility.key_value("AlarmName"), + TransformerUtility.key_value("Namespace"), + KeyValueBasedTransformer(_resource_name_transformer, "SubscriptionArn"), + TransformerUtility.key_value("Region", "region-name-full"), + ] + + @staticmethod + def logs_api(): + """ + :return: array with Transformers, for logs api + """ + return [ + TransformerUtility.key_value("logGroupName"), + TransformerUtility.key_value("logStreamName"), + TransformerUtility.key_value("creationTime", "", reference_replacement=False), + TransformerUtility.key_value( + "firstEventTimestamp", "", reference_replacement=False + ), + TransformerUtility.key_value( + "lastEventTimestamp", "", reference_replacement=False + ), + TransformerUtility.key_value( + "lastIngestionTime", "", reference_replacement=False + ), + TransformerUtility.key_value("nextToken", "", reference_replacement=False), + ] + + @staticmethod + def secretsmanager_api(): + return [ + KeyValueBasedTransformer( + lambda k, v: ( + k + if (isinstance(k, str) and isinstance(v, list) and re.match(PATTERN_UUID, k)) + else None + ), + "version_uuid", + ), + KeyValueBasedTransformer( + lambda k, v: ( + v + if ( + isinstance(k, str) + and k == "VersionId" + and isinstance(v, str) + and re.match(PATTERN_UUID, v) + ) + else None + ), + "version_uuid", + ), + KeyValueBasedTransformer( + lambda k, v: ( + v + if ( + isinstance(k, str) + and k == "RotationLambdaARN" + and isinstance(v, str) + and re.match(PATTERN_ARN, v) + ) + else None + ), + "lambda-arn", + ), + SortingTransformer("VersionStages"), + SortingTransformer("Versions", lambda e: e.get("CreatedDate")), + ] + + @staticmethod + def secretsmanager_secret_id_arn(create_secret_res: CreateSecretResponse, index: int): + secret_id_repl = f"" + arn_part_repl = f"" + + secret_id: str = create_secret_res["Name"] + arn_part: str = "".join(create_secret_res["ARN"].rpartition("-")[-2:]) + + return [ + RegexTransformer(arn_part, arn_part_repl), + RegexTransformer(secret_id, secret_id_repl), + ] + + @staticmethod + def sfn_sm_create_arn(create_sm_res: CreateStateMachineOutput, index: int): + arn_part_repl = f"" + arn_part: str = "".join(create_sm_res["stateMachineArn"].rpartition(":")[-1]) + return RegexTransformer(arn_part, arn_part_repl) + + @staticmethod + def sfn_sm_exec_arn(start_exec: StartExecutionOutput, index: int): + arn_part_repl = f"" + arn_part: str = "".join(start_exec["executionArn"].rpartition(":")[-1]) + return RegexTransformer(arn_part, arn_part_repl) + + @staticmethod + def sfn_sm_express_exec_arn(start_exec: StartExecutionOutput, index: int): + arn_parts = start_exec["executionArn"].split(":") + return [ + RegexTransformer(arn_parts[-2], f""), + RegexTransformer(arn_parts[-1], f""), + ] + + @staticmethod + def sfn_sm_sync_exec_arn(start_exec: StartSyncExecutionOutput, index: int): + arn_parts = start_exec["executionArn"].split(":") + return [ + RegexTransformer(arn_parts[-2], f""), + RegexTransformer(arn_parts[-1], f""), + ] + + @staticmethod + def sfn_map_run_arn(map_run_arn: LongArn, index: int) -> list[RegexTransformer]: + map_run_arn_part = map_run_arn.split("/")[-1] + arn_parts = map_run_arn_part.split(":") + transformers = [ + RegexTransformer(arn_parts[1], f""), + ] + if re.match(PATTERN_UUID, arn_parts[0]): + transformers.append(RegexTransformer(arn_parts[0], f"")) + return transformers + + @staticmethod + def sfn_sqs_integration(): + return [ + *TransformerUtility.sqs_api(), + # Transform MD5OfMessageBody value bindings as in StepFunctions these are not deterministic + # about the input message. + TransformerUtility.key_value("MD5OfMessageBody"), + TransformerUtility.key_value("MD5OfMessageAttributes"), + ] + + @staticmethod + def stepfunctions_api(): + return [ + JsonpathTransformer( + "$..SdkHttpMetadata..Date", + "date", + replace_reference=False, + ), + JsonpathTransformer( + "$..SdkResponseMetadata..RequestId", + "RequestId", + replace_reference=False, + ), + JsonpathTransformer( + "$..X-Amzn-Trace-Id", + "X-Amzn-Trace-Id", + replace_reference=False, + ), + JsonpathTransformer( + "$..X-Amzn-Trace-Id", + "X-Amzn-Trace-Id", + replace_reference=False, + ), + JsonpathTransformer( + "$..x-amz-crc32", + "x-amz-crc32", + replace_reference=False, + ), + JsonpathTransformer( + "$..x-amzn-RequestId", + "x-amzn-RequestId", + replace_reference=False, + ), + KeyValueBasedTransformer(_transform_stepfunctions_cause_details, "json-input"), + ] + + # TODO add example + # @staticmethod + # def custom(fn: Callable[[dict], dict]) -> Transformer: + # return GenericTransformer(fn) + + +def _sns_pem_file_token_transformer(key: str, val: str) -> str: + if isinstance(val, str) and key.lower() == "SigningCertURL".lower(): + pattern = re.compile(r".*SimpleNotificationService-(.*\.pem)") + match = re.match(pattern, val) + if match: + return match.groups()[0] + + +def _sns_unsubscribe_url_subscription_arn_transformer(key: str, val: str) -> str: + if isinstance(val, str) and key.lower() == "UnsubscribeURL".lower(): + pattern = re.compile(r".*(?<=\?Action=Unsubscribe&SubscriptionArn=).*:(.*)") + match = re.match(pattern, val) + if match: + return match.groups()[0] + + +def _replace_camel_string_with_hyphen(input_string: str): + return "".join(["-" + char.lower() if char.isupper() else char for char in input_string]).strip( + "-" + ) + + +def _log_stream_name_transformer(key: str, val: str) -> str: + if isinstance(val, str) and (key == "log_stream_name" or key == "logStreamName"): + match = re.match(PATTERN_LOGSTREAM_ID, val) + if match: + return val + return None + + +def _route53_hosted_zone_id_transformer(key: str, val: str) -> str: + if isinstance(val, str) and key == "Id": + match = re.match(r".*/hostedzone/([A-Za-z0-9]+)", val) + if match: + return match.groups()[0] + + +# TODO: actual and declared type diverge +def _resource_name_transformer(key: str, val: str) -> str: + if isinstance(val, str): + match = re.match(PATTERN_ARN, val) + if match: + res = match.groups()[-1] + if res.startswith("<") and res.endswith(">"): + # value was already replaced + # TODO: this isn't enforced or unfortunately even upheld via standard right now + return None + if ":changeSet/" in val: + return val.split(":changeSet/")[-1] + if "/" in res: + return res.split("/")[-1] + if res.startswith("function:"): + res = res.replace("function:", "") + if "$" in res: + res = res.split("$")[0].rstrip(":") + return res + if res.startswith("layer:"): + # extract layer name from arn + match res.split(":"): + case _, layer_name, _: # noqa + return layer_name # noqa + case _, layer_name: # noqa + return layer_name # noqa + if ":" in res: + return res.split(":")[-1] # TODO might not work for every replacement + return res + return None + + +def _transform_stepfunctions_cause_details(key: str, val: str) -> str: + if key == "cause" and isinstance(val, str): + # the cause might contain the entire input, including http metadata (date, request-ids etc). + # the input is a json: if we can match the regex and parse it as a json, we remove this part from the response + regex = r".*'({.*})'" + match = re.match(regex, val) + if match: + json_input = match.groups()[0] + try: + json.loads(json_input) + return json_input + except JSONDecodeError: + return None + return None + + +def _change_set_id_transformer(key: str, val: str) -> str: + if key == "Id" and isinstance(val, str): + match = re.match(PATTERN_ARN_CHANGESET, val) + if match: + return match.groups()[-1] + return None + + +# TODO maybe move to a different place? +# Basic Transformation - added automatically to each snapshot (in the fixture) +SNAPSHOT_BASIC_TRANSFORMER_NEW = [ + ResponseMetaDataTransformer(), + KeyValueBasedTransformer( + lambda k, v: ( + v + if (isinstance(v, str) and k.lower().endswith("id") and re.match(PATTERN_UUID, v)) + else None + ), + "uuid", + ), + TimestampTransformer(), +] + +SNAPSHOT_BASIC_TRANSFORMER = [ + ResponseMetaDataTransformer(), + KeyValueBasedTransformer( + lambda k, v: ( + v + if (isinstance(v, str) and k.lower().endswith("id") and re.match(PATTERN_UUID, v)) + else None + ), + "uuid", + ), + RegexTransformer(PATTERN_ISO8601, "date"), + KeyValueBasedTransformer( + lambda k, v: (v if isinstance(v, datetime) else None), "datetime", replace_reference=False + ), + KeyValueBasedTransformer( + lambda k, v: str(v) + if ( + re.compile(r"^.*timestamp.*$", flags=re.IGNORECASE).match(k) + or k in ("creationTime", "ingestionTime") + ) + and not PATTERN_ISO8601.match(str(v)) + else None, + "timestamp", + replace_reference=False, + ), +] diff --git a/localstack-core/localstack/testing/testselection/__init__.py b/localstack-core/localstack/testing/testselection/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/testselection/git.py b/localstack-core/localstack/testing/testselection/git.py new file mode 100644 index 0000000000000..d41c56ea49e80 --- /dev/null +++ b/localstack-core/localstack/testing/testselection/git.py @@ -0,0 +1,23 @@ +import subprocess + + +def get_changed_files_from_git_diff(repo: str, base_ref: str, head_ref: str) -> [str]: + """ + Find list of files that are affected by changes made on head_ref in comparison to the base_ref. + The base_ref is usually a merge-base of the actual base ref (just like how GitHub shows you the changes in comparison to latest master) + """ + cmd = ["git", "-C", repo, "diff", "--name-only", base_ref, head_ref] + output = subprocess.check_output(cmd, encoding="UTF-8") + return [line.strip() for line in output.splitlines() if line.strip()] + + +def find_merge_base(repo: str, base_branch: str, head_branch: str) -> str: + cmd = ["git", "-C", repo, "merge-base", base_branch, head_branch] + output = subprocess.check_output(cmd, encoding="UTF-8") + return output.strip() + + +def get_branch_name(repo: str) -> str: + cmd = ["git", "-C", repo, "rev-parse", "--abbrev-ref", "HEAD"] + output = subprocess.check_output(cmd, encoding="UTF-8") + return output.strip() diff --git a/localstack-core/localstack/testing/testselection/github.py b/localstack-core/localstack/testing/testselection/github.py new file mode 100644 index 0000000000000..5fa2ed8f61e4b --- /dev/null +++ b/localstack-core/localstack/testing/testselection/github.py @@ -0,0 +1,49 @@ +import requests + +GITHUB_V3_JSON = "application/vnd.github.v3+json" + +# We assume that test selection scripts are only run on repositories under the localstack organization +REPO_OWNER = "localstack" + + +def get_pr_details_from_number(repo_name: str, pr_number: str, token: str) -> (str, str): + """ + Fetch the base commit SHA, and the head commit SHA of a given pull request number from a GitHub repository. + """ + url = f"https://api.github.com/repos/{REPO_OWNER}/{repo_name}/pulls/{pr_number}" + headers = {"Accept": GITHUB_V3_JSON} + if token: + headers["Authorization"] = f"token {token}" + response = requests.get(url, headers=headers) + if response.status_code != 200: + raise ValueError(f"Failed to fetch PR data for PR #{pr_number}: {response.text}") + pr_data = response.json() + return pr_data["base"]["sha"], pr_data["head"]["sha"] + + +def get_pr_details_from_branch(repo_name: str, branch: str, token: str) -> (str, str): + """ + Fetch the base commit SHA, and the head commit SHA of a given branch from a GitHub repository. + """ + url = f"https://api.github.com/repos/{REPO_OWNER}/{repo_name}/pulls?head={REPO_OWNER}:{branch}" + headers = {"Accept": GITHUB_V3_JSON} + if token: + headers["Authorization"] = f"token {token}" + response = requests.get(url, headers=headers) + if response.status_code != 200: + raise ValueError(f"Failed to fetch PR data for branch {branch}: {response.text}") + pr_data = response.json() + if len(pr_data) != 1: + raise ValueError(f"Expected 1 PR for branch {branch}, but got {len(pr_data)} PRs") + print(f"Detected PR Number #{pr_data[0]['number']}") + return pr_data[0]["base"]["sha"], pr_data[0]["head"]["sha"] + + +def get_pr_details_from_url(repo_name: str, pr_url: str, token: str) -> (str, str): + """ + Extract base and head sha from a given PR URL + Example pr_url: https://github.com/localstack/localstack/pull/1 + """ + parts = pr_url.split("/") + pr_number = parts[-1] + return get_pr_details_from_number(repo_name, pr_number, token) diff --git a/localstack-core/localstack/testing/testselection/matching.py b/localstack-core/localstack/testing/testselection/matching.py new file mode 100644 index 0000000000000..4bf5e9bfaca2d --- /dev/null +++ b/localstack-core/localstack/testing/testselection/matching.py @@ -0,0 +1,209 @@ +import fnmatch +import pathlib +import re +from collections import defaultdict +from typing import Callable, Iterable, Optional + +from localstack.aws.scaffold import is_keyword + +# TODO: extract API Dependencies and composites to constants or similar + +SENTINEL_NO_TEST = "SENTINEL_NO_TEST" # a line item which signals that we don't default to everything, we just don't want to actually want to run a test => useful to differentiate between empty / nothing +SENTINEL_ALL_TESTS = "SENTINEL_ALL_TESTS" # a line item which signals that we don't default to everything, we just don't want to actually want to run a test => useful to differentiate between empty / nothing + +DEFAULT_SEARCH_PATTERNS = ( + r"localstack/services/([^/]+)/.+", + r"localstack/aws/api/([^/]+)/__init__\.py", + r"tests/aws/services/([^/]+)/.+", +) + + +def _map_to_module_name(service_name: str) -> str: + """sanitize a service name like we're doing when scaffolding, e.g. lambda => lambda_""" + service_name = service_name.replace("-", "_") + # handle service names which are reserved keywords in python (f.e. lambda) + if is_keyword(service_name): + service_name += "_" + return service_name + + +def _map_to_service_name(module_name: str) -> str: + """map a sanitized module name to a service name, e.g. lambda_ => lambda""" + if module_name.endswith("_"): + return module_name[:-1] + return module_name.replace("_", "-") + + +def resolve_dependencies(module_name: str, api_dependencies: dict[str, Iterable[str]]) -> set[str]: + """ + Resolves dependencies for a given service module name + + :param module_name: the name of the service to resolve (e.g. lambda_) + :param api_dependencies: dict of API dependencies where each key is the service and its value a list of services it + depends on + :return: set of resolved _service names_ that the service depends on (e.g. sts) + """ + svc_name = _map_to_service_name(module_name) + return set(_reverse_dependency_map(api_dependencies).get(svc_name, [])) + + +# TODO: might want to cache that, but for now it shouldn't be too much overhead +def _reverse_dependency_map(dependency_map: dict[str, Iterable[str]]) -> dict[str, Iterable[str]]: + """ + The current API_DEPENDENCIES actually maps the services to their own dependencies. + In our case here we need the inverse of this, we need to of which other services this service is a dependency of. + """ + result = {} + for svc, deps in dependency_map.items(): + for dep in deps: + result.setdefault(dep, set()).add(svc) + return result + + +def get_test_dir_for_service(svc: str) -> str: + return f"tests/aws/services/{svc}" + + +def get_directory(t: str) -> str: + # we take the parent of the match file, and we split it in parts + parent_parts = pathlib.PurePath(t).parent.parts + # we remove any parts that can be present in front of the first `tests` folder, could be caused by namespacing + root = parent_parts.index("tests") + folder_path = "/".join(parent_parts[root:]) + "/" + return folder_path + + +class Matcher: + def __init__(self, matching_func: Callable[[str], bool]): + self.matching_func = matching_func + + def full_suite(self): + return lambda t: [SENTINEL_ALL_TESTS] if self.matching_func(t) else [] + + def ignore(self): + return lambda t: [SENTINEL_NO_TEST] if self.matching_func(t) else [] + + def service_tests(self, services: list[str]): + return ( + lambda t: [get_test_dir_for_service(svc) for svc in services] + if self.matching_func(t) + else [] + ) + + def passthrough(self): + return lambda t: [t] if self.matching_func(t) else [] + + def directory(self, paths: list[str] = None): + """Enables executing tests on a full directory if the file is matched. + By default, it will return the directory of the modified file. + If the argument `paths` is provided, it will instead return the provided list. + """ + return lambda t: (paths or [get_directory(t)]) if self.matching_func(t) else [] + + +class Matchers: + @staticmethod + def glob(glob: str) -> Matcher: + return Matcher(lambda t: fnmatch.fnmatch(t, glob)) + + @staticmethod + def regex(glob: str) -> Matcher: + return Matcher(lambda t: bool(re.match(t, glob))) + + @staticmethod + def prefix(prefix: str) -> Matcher: + return Matcher(lambda t: t.startswith(prefix)) + + +def generic_service_test_matching_rule( + changed_file_path: str, + api_dependencies: Optional[dict[str, Iterable[str]]] = None, + search_patterns: Iterable[str] = DEFAULT_SEARCH_PATTERNS, + test_dirs: Iterable[str] = ("tests/aws/services",), +) -> set[str]: + """ + Generic matching of changes in service files to their tests + + :param api_dependencies: dict of API dependencies where each key is the service and its value a list of services it depends on + :param changed_file_path: the file path of the detected change + :param search_patterns: list of regex patterns to search for in the changed file path + :param test_dirs: list of test directories to match for a changed service + :return: list of partial test file path filters for the matching service and all services it depends on + """ + # TODO: consider API_COMPOSITES + + if api_dependencies is None: + from localstack.utils.bootstrap import API_DEPENDENCIES, API_DEPENDENCIES_OPTIONAL + + # merge the mandatory and optional service dependencies + api_dependencies = defaultdict(set) + for service, mandatory_dependencies in API_DEPENDENCIES.items(): + api_dependencies[service].update(mandatory_dependencies) + + for service, optional_dependencies in API_DEPENDENCIES_OPTIONAL.items(): + api_dependencies[service].update(optional_dependencies) + + match = None + for pattern in search_patterns: + match = re.findall(pattern, changed_file_path) + if match: + break + + if match: + changed_service = match[0] + changed_services = [changed_service] + service_dependencies = resolve_dependencies(changed_service, api_dependencies) + changed_services.extend(service_dependencies) + changed_service_module_names = [_map_to_module_name(svc) for svc in changed_services] + return { + f"{test_dir}/{svc}/" for test_dir in test_dirs for svc in changed_service_module_names + } + + return set() + + +MatchingRule = Callable[[str], Iterable[str]] + + +def check_rule_has_matches(rule: MatchingRule, files: Iterable[str]) -> bool: + """maintenance utility to check if a rule has any matches at all in the given directory""" + detected_tests = set() + for file in files: + detected_tests.update(rule(file)) + return len(detected_tests) > 0 + + +MATCHING_RULES: list[MatchingRule] = [ + # Generic rules + generic_service_test_matching_rule, # always *at least* the service tests and dependencies + Matchers.glob( + "tests/**/test_*.py" + ).passthrough(), # changes in a test file should always at least test that file + # CI + Matchers.glob(".github/**").full_suite(), + Matchers.glob(".circleci/**").full_suite(), + # dependencies / project setup + Matchers.glob("requirements*.txt").full_suite(), + Matchers.glob("setup.cfg").full_suite(), + Matchers.glob("setup.py").full_suite(), + Matchers.glob("pyproject.toml").full_suite(), + Matchers.glob("Dockerfile").full_suite(), + Matchers.glob("Makefile").full_suite(), + Matchers.glob("bin/**").full_suite(), + Matchers.glob("localstack/config.py").full_suite(), + Matchers.glob("localstack/constants.py").full_suite(), + Matchers.glob("localstack/plugins.py").full_suite(), + Matchers.glob("localstack/utils/**").full_suite(), + # testing + Matchers.glob("localstack/testing/**").full_suite(), + Matchers.glob("**/conftest.py").directory(), + Matchers.glob("**/fixtures.py").full_suite(), + # ignore + Matchers.glob("**/*.md").ignore(), + Matchers.glob("doc/**").ignore(), + Matchers.glob("CODEOWNERS").ignore(), + Matchers.glob(".gitignore").ignore(), + Matchers.glob(".git-blame-ignore-revs").ignore(), + # lambda + Matchers.glob("tests/aws/services/lambda_/functions/**").service_tests(services=["lambda"]), +] diff --git a/localstack-core/localstack/testing/testselection/opt_out.py b/localstack-core/localstack/testing/testselection/opt_out.py new file mode 100644 index 0000000000000..32fdf17b9cc26 --- /dev/null +++ b/localstack-core/localstack/testing/testselection/opt_out.py @@ -0,0 +1,18 @@ +import fnmatch +from typing import Iterable + +OPT_OUT = [] + + +def opted_out(changed_files: list[str], opt_out: Iterable[str] | None = None) -> bool: + """ + Do not perform test selection if at least one file is opted out + + :param changed_files: List of changed file paths + :param opt_out: Iterable of globs to match the changed files against. Defaults to the rules defined in OPT_OUT + :return: True if any changed file matches at least one glob, False otherwise + """ + if opt_out is None: + opt_out = OPT_OUT + + return any(any(fnmatch.fnmatch(cf, glob) for glob in opt_out) for cf in changed_files) diff --git a/localstack-core/localstack/testing/testselection/scripts/__init__.py b/localstack-core/localstack/testing/testselection/scripts/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/testing/testselection/scripts/filter_by_test_selection.py b/localstack-core/localstack/testing/testselection/scripts/filter_by_test_selection.py new file mode 100644 index 0000000000000..81571dfb8b1ca --- /dev/null +++ b/localstack-core/localstack/testing/testselection/scripts/filter_by_test_selection.py @@ -0,0 +1,34 @@ +import sys + + +def filter_test_files(tests: list[str], selected_tests: list[str]): + """Filter list of test files by test selection file. Result is written to stdout""" + + # TODO: SENTINEL_NO_TESTS ... not sure how we'd handle it here without failing the circleci selection + if "SENTINEL_ALL_TESTS" in selected_tests: + for line in tests: + print(line) + return + + for line in tests: + if any(fc in line for fc in selected_tests): + print(line) + + +def main(): + if len(sys.argv) != 2: + print( + "Usage: python -m localstack.testing.testselection.scripts.filter_by_test_selection ", + file=sys.stderr, + ) + sys.exit(1) + + testselection_file_path = sys.argv[1] + with open(testselection_file_path, "r") as file: + selected_tests = [line.strip() for line in file.readlines() if line.strip()] + test_files = [line.strip() for line in sys.stdin] + filter_test_files(test_files, selected_tests) + + +if __name__ == "__main__": + main() diff --git a/localstack-core/localstack/testing/testselection/scripts/generate_test_selection.py b/localstack-core/localstack/testing/testselection/scripts/generate_test_selection.py new file mode 100644 index 0000000000000..31af5e5d983b5 --- /dev/null +++ b/localstack-core/localstack/testing/testselection/scripts/generate_test_selection.py @@ -0,0 +1,122 @@ +""" +USAGE: python -m localstack.testing.testselection.scripts.generate_test_selection \ + [--base-commit-sha \ + --head-commit-sha ] + [ --pr-url ] +Optionally set the GITHUB_API_TOKEN environment variable to use the GitHub API. +(when using --pr-url, or no commit SHAs provided) +""" + +import argparse +import os +import sys +from pathlib import Path +from typing import Iterable + +from localstack.testing.testselection.git import ( + find_merge_base, + get_branch_name, + get_changed_files_from_git_diff, +) +from localstack.testing.testselection.github import ( + get_pr_details_from_branch, + get_pr_details_from_url, +) +from localstack.testing.testselection.matching import MatchingRule +from localstack.testing.testselection.opt_out import opted_out +from localstack.testing.testselection.testselection import get_affected_tests_from_changes + + +def generate_test_selection( + opt_out: Iterable[str] | None = None, + matching_rules: list[MatchingRule] | None = None, + repo_name: str = "localstack", +): + parser = argparse.ArgumentParser( + description="Generate test selection from a range of commits or a PR URL. " + "Determine the corresponding PR based on the current branch if neither provided." + ) + parser.add_argument("repo_root_path", type=str, help="Path to the git repository root") + parser.add_argument("output_file_path", type=str, help="Path to the output file") + + parser.add_argument( + "--base-commit-sha", + type=str, + help="Base commit SHA", + ) + parser.add_argument( + "--head-commit-sha", + type=str, + help="Head commit SHA", + ) + parser.add_argument( + "--pr-url", + type=str, + help="URL to a PR", + ) + + args = parser.parse_args() + + repo_root_path = args.repo_root_path + output_file_path = args.output_file_path + github_token = os.environ.get("GITHUB_API_TOKEN") + # Handle the mismatch between python module name and github repo name on dependent modules + github_repo_name = repo_name.replace("_", "-") + + if args.base_commit_sha is not None and args.head_commit_sha is not None: + base_commit_sha = args.base_commit_sha + head_commit_sha = args.head_commit_sha + elif args.pr_url is not None: + print(f"PR URL: {args.pr_url}") + base_commit_sha, head_commit_sha = get_pr_details_from_url( + repo_name, args.pr_url, github_token + ) + else: + print("Neither commit SHAs nor PR URL provided.") + current_branch = get_branch_name(repo_root_path) + print(f"Determining based on current branch. ({current_branch})") + base_commit_sha, head_commit_sha = get_pr_details_from_branch( + github_repo_name, current_branch, github_token + ) + + print(f"Base Commit SHA: {base_commit_sha}") + print(f"Head Commit SHA: {head_commit_sha}") + + merge_base_commit = find_merge_base(repo_root_path, base_commit_sha, head_commit_sha) + changed_files = get_changed_files_from_git_diff( + repo_root_path, merge_base_commit, head_commit_sha + ) + + print("Checking for confirming to opt-in guards") + if opted_out(changed_files, opt_out=opt_out): + print( + f"Explicitly opted out changed file. Remove from {repo_name}/testing/testselection/opt_out.py if needed" + ) + test_files = ["SENTINEL_ALL_TESTS"] + else: + test_files = get_affected_tests_from_changes(changed_files, matching_rules=matching_rules) + + print(f"Number of changed files detected: {len(changed_files)}") + for cf in sorted(changed_files): + print(f"\t{cf}") + print(f"Number of affected test determined: {len(test_files)}") + for tf in sorted(test_files): + print(f"\t{tf}") + + if not test_files: + print("No tests selected, returning") + sys.exit(0) + + print(f"Writing test selection to {output_file_path}") + output_file = Path(output_file_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + with open(output_file, "w") as fd: + for test_file in test_files: + fd.write(test_file) + fd.write("\n") + + print(f"Successfully written test selection to {output_file_path}") + + +if __name__ == "__main__": + generate_test_selection() diff --git a/localstack-core/localstack/testing/testselection/testselection.py b/localstack-core/localstack/testing/testselection/testselection.py new file mode 100644 index 0000000000000..e1a3799cc5c3d --- /dev/null +++ b/localstack-core/localstack/testing/testselection/testselection.py @@ -0,0 +1,31 @@ +from typing import Iterable, Optional + +from localstack.testing.testselection.matching import MATCHING_RULES, MatchingRule + + +def get_affected_tests_from_changes( + changed_files: Iterable[str], matching_rules: Optional[list[MatchingRule]] = None +) -> list[str]: + """ + Generate test selectors based on the changed files and matching rules to apply to. + + :param matching_rules: A list of matching rules which are used to generate test selectors from a given changed file + :param changed_files: Iterable of file paths where changes were detected + :return: Sorted list of test selectors + """ + if matching_rules is None: + matching_rules = MATCHING_RULES + + result = set() + for changed_file in changed_files: + added_test_rules = set() + for rule in matching_rules: + added_test_rules.update(rule(changed_file)) + + # default case where no rule was matching where we default to execute all tests + if len(added_test_rules) == 0: + print(f"Change to file not covered via rules: {changed_file}") + added_test_rules.add("SENTINEL_ALL_TESTS") + result.update(added_test_rules) + + return sorted(result) diff --git a/localstack-core/localstack/utils/__init__.py b/localstack-core/localstack/utils/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/analytics/__init__.py b/localstack-core/localstack/utils/analytics/__init__.py new file mode 100644 index 0000000000000..57c331fa9ae54 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/__init__.py @@ -0,0 +1,12 @@ +from .logger import EventLogger +from .metadata import get_session_id +from .publisher import GlobalAnalyticsBus + +name = "analytics" + + +def _create_global_analytics_bus(): + return GlobalAnalyticsBus() + + +log = EventLogger(handler=_create_global_analytics_bus(), session_id=get_session_id()) diff --git a/localstack-core/localstack/utils/analytics/cli.py b/localstack-core/localstack/utils/analytics/cli.py new file mode 100644 index 0000000000000..ebab4f37bb451 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/cli.py @@ -0,0 +1,68 @@ +import datetime +import functools +from multiprocessing import Process +from typing import List + +import click + +from localstack import config + +from .client import AnalyticsClient +from .events import Event, EventMetadata +from .metadata import get_session_id +from .publisher import AnalyticsClientPublisher + +ANALYTICS_API_RESPONSE_TIMEOUT_SECS = 0.5 + + +def _publish_cmd_as_analytics_event(command_name: str, params: List[str]): + event = Event( + name="cli_cmd", + payload={"cmd": command_name, "params": params}, + metadata=EventMetadata( + session_id=get_session_id(), + client_time=str(datetime.datetime.now()), # TODO: consider using utcnow() + ), + ) + publisher = AnalyticsClientPublisher(AnalyticsClient()) + publisher.publish([event]) + + +def _get_parent_commands(ctx: click.Context) -> List[str]: + parent_commands = [] + parent = ctx.parent + while parent is not None: + parent_commands.insert(0, parent.command.name) + parent = parent.parent + return parent_commands + + +def publish_invocation(fn): + """ + Decorator for capturing CLI commands from Click and publishing them to the backend as analytics events. + This decorator should only be used on outermost subcommands, e.g. "localstack status docker" not "localstack status" + otherwise it may publish multiple events for a single invocation. + If DISABLE_EVENTS is set then nothing is collected. + For performance reasons, the API call to the backend runs on a separate process and is killed if it takes longer + than ANALYTICS_API_RESPONSE_TIMEOUT_SECS. + The emitted event contains the invoked command, plus any parameter names if their associated values are truthy (but + not the values themselves). + """ + + @functools.wraps(fn) + def publisher_wrapper(*args, **kwargs): + if config.DISABLE_EVENTS: + return fn(*args, **kwargs) + + ctx = click.get_current_context() + full_command = " ".join(_get_parent_commands(ctx) + [ctx.command.name]) + publish_cmd_process = Process( + target=_publish_cmd_as_analytics_event, + args=(full_command, [k for k, v in ctx.params.items() if v]), + ) + publish_cmd_process.start() + publish_cmd_process.join(ANALYTICS_API_RESPONSE_TIMEOUT_SECS) + publish_cmd_process.terminate() + return fn(*args, **kwargs) + + return publisher_wrapper diff --git a/localstack-core/localstack/utils/analytics/client.py b/localstack-core/localstack/utils/analytics/client.py new file mode 100644 index 0000000000000..707a5e17e803d --- /dev/null +++ b/localstack-core/localstack/utils/analytics/client.py @@ -0,0 +1,111 @@ +""" +Client for the analytics backend. +""" + +import logging +from typing import Any, Dict, List + +import requests + +from localstack import config, constants +from localstack.utils.http import get_proxies +from localstack.utils.time import now + +from .events import Event, EventMetadata +from .metadata import ClientMetadata, get_session_id + +LOG = logging.getLogger(__name__) + + +class SessionResponse: + response: Dict[str, Any] + status: int + + def __init__(self, response: Dict[str, Any], status: int = 200): + self.response = response + self.status = status + + def track_events(self) -> bool: + return self.response.get("track_events") + + def __repr__(self): + return f"SessionResponse({self.status},{self.response!r})" + + +class AnalyticsClient: + api: str + + def __init__(self, api=None): + self.api = (api or constants.ANALYTICS_API).rstrip("/") + self.debug = config.DEBUG_ANALYTICS + + self.endpoint_session = self.api + "/session" + self.endpoint_events = self.api + "/events" + + self.localstack_session_id = get_session_id() + self.session = requests.Session() + + def close(self): + self.session.close() + + def start_session(self, metadata: ClientMetadata) -> SessionResponse: + # FIXME: re-using Event as request object this way is kind of a hack + request = Event( + "session", EventMetadata(self.localstack_session_id, str(now())), payload=metadata + ) + + response = self.session.post( + self.endpoint_session, + headers=self._create_headers(), + json=request.asdict(), + proxies=get_proxies(), + ) + + # 403 errors may indicate that track_events=False + if response.ok or response.status_code == 403: + return SessionResponse(response.json(), status=response.status_code) + + raise ValueError( + f"error during session initiation with analytics backend. code: {response.status_code}" + ) + + # TODO: naming seems confusing since this doesn't actually append, but directly sends all passed events via HTTP + def append_events(self, events: List[Event]): + # TODO: add compression to append_events + # it would maybe be useful to compress analytics data, but it's unclear how that will + # affect performance and what the benefit is. need to measure first. + + endpoint = self.endpoint_events + + if not events: + return + + docs = [] + for event in events: + try: + docs.append(event.asdict()) + except Exception: + if self.debug: + LOG.exception("error while recording event %s", event) + + headers = self._create_headers() + + if self.debug: + LOG.debug("posting to %s events %s", endpoint, docs) + + # FIXME: fault tolerance/timeouts + response = self.session.post( + endpoint, json={"events": docs}, headers=headers, proxies=get_proxies() + ) + + if self.debug: + LOG.debug("response from %s was: %s %s", endpoint, response.status_code, response.text) + + # TODO: Add response type to analytics client + return response + + def _create_headers(self) -> Dict[str, str]: + return { + "User-Agent": "localstack/" + constants.VERSION, + "Localstack-Session-ID": self.localstack_session_id, + } diff --git a/localstack-core/localstack/utils/analytics/events.py b/localstack-core/localstack/utils/analytics/events.py new file mode 100644 index 0000000000000..2b30b673b7032 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/events.py @@ -0,0 +1,30 @@ +import abc +import dataclasses +from typing import Any, Dict, Union + +EventPayload = Union[Dict[str, Any], Any] # FIXME: better typing + + +@dataclasses.dataclass +class EventMetadata: + session_id: str + client_time: str + + +@dataclasses.dataclass +class Event: + name: str + metadata: EventMetadata = None + payload: EventPayload = None + + def asdict(self): + return dataclasses.asdict(self) + + +class EventHandler(abc.ABC): + """ + Event handlers dispatch events to specific destinations. + """ + + def handle(self, event: Event): + raise NotImplementedError diff --git a/localstack-core/localstack/utils/analytics/logger.py b/localstack-core/localstack/utils/analytics/logger.py new file mode 100644 index 0000000000000..d9152238bc668 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/logger.py @@ -0,0 +1,48 @@ +import datetime +import hashlib + +from localstack.utils.strings import to_bytes + +from .events import Event, EventHandler, EventMetadata, EventPayload +from .metadata import get_session_id + + +def get_hash(value) -> str: + max_length = 10 + digest = hashlib.sha1() + digest.update(to_bytes(str(value))) + result = digest.hexdigest() + return result[:max_length] + + +class EventLogger: + """ + High-level interface over analytics event abstraction. Expose specific event types as + concrete functions to call in the code. + """ + + def __init__(self, handler: EventHandler, session_id: str = None): + self.handler = handler + self.session_id = session_id or get_session_id() + + @staticmethod + def hash(value): + return get_hash(value) + + def event(self, event: str, payload: EventPayload = None, **kwargs): + if kwargs: + if payload is None: + payload = kwargs + else: + raise ValueError("either use payload or set kwargs, not both") + + self._log(event, payload=payload) + + def _log(self, event: str, payload: EventPayload = None): + self.handler.handle(Event(name=event, metadata=self._metadata(), payload=payload)) + + def _metadata(self) -> EventMetadata: + return EventMetadata( + session_id=self.session_id, + client_time=str(datetime.datetime.now()), + ) diff --git a/localstack-core/localstack/utils/analytics/metadata.py b/localstack-core/localstack/utils/analytics/metadata.py new file mode 100644 index 0000000000000..da135c861a323 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metadata.py @@ -0,0 +1,247 @@ +import dataclasses +import logging +import os +import platform +from typing import Optional + +from localstack import config +from localstack.constants import VERSION +from localstack.runtime import get_current_runtime, hooks +from localstack.utils.bootstrap import Container +from localstack.utils.files import rm_rf +from localstack.utils.functions import call_safe +from localstack.utils.json import FileMappedDocument +from localstack.utils.objects import singleton_factory +from localstack.utils.strings import long_uid, md5 + +LOG = logging.getLogger(__name__) + +_PHYSICAL_ID_SALT = "ls" + + +@dataclasses.dataclass +class ClientMetadata: + session_id: str + machine_id: str + api_key: str + system: str + version: str + is_ci: bool + is_docker: bool + is_testing: bool + product: str + edition: str + + def __repr__(self): + d = dataclasses.asdict(self) + + # anonymize api_key + k = d.get("api_key") + if k: + k = "*" * len(k) + d["api_key"] = k + + return "ClientMetadata(%s)" % d + + +def get_version_string() -> str: + gh = config.LOCALSTACK_BUILD_GIT_HASH + if gh: + return f"{VERSION}:{gh}" + else: + return VERSION + + +def read_client_metadata() -> ClientMetadata: + return ClientMetadata( + session_id=get_session_id(), + machine_id=get_machine_id(), + api_key=get_api_key_or_auth_token() or "", # api key should not be None + system=get_system(), + version=get_version_string(), + is_ci=os.getenv("CI") is not None, + is_docker=config.is_in_docker, + is_testing=config.is_local_test_mode(), + product=get_localstack_product(), + edition=os.getenv("LOCALSTACK_TELEMETRY_EDITION") or get_localstack_edition(), + ) + + +@singleton_factory +def get_session_id() -> str: + """ + Returns the unique ID for this LocalStack session. + :return: a UUID + """ + return _generate_session_id() + + +@singleton_factory +def get_client_metadata() -> ClientMetadata: + metadata = read_client_metadata() + + if config.DEBUG_ANALYTICS: + LOG.info("resolved client metadata: %s", metadata) + + return metadata + + +@singleton_factory +def get_machine_id() -> str: + cache_path = os.path.join(config.dirs.cache, "machine.json") + try: + doc = FileMappedDocument(cache_path) + except Exception: + # it's possible that the file is somehow messed up, so we try to delete the file first and try again. + # if that fails, we return a generated ID. + call_safe(rm_rf, args=(cache_path,)) + + try: + doc = FileMappedDocument(cache_path) + except Exception: + return _generate_machine_id() + + if "machine_id" not in doc: + # generate a machine id + doc["machine_id"] = _generate_machine_id() + # try to cache the machine ID + call_safe(doc.save) + + return doc["machine_id"] + + +def get_localstack_edition() -> str: + # Generator expression to find the first hidden file ending with '-version' + version_file = next( + ( + f + for f in os.listdir(config.dirs.static_libs) + if f.startswith(".") and f.endswith("-version") + ), + None, + ) + + # Return the base name of the version file, or unknown if no file is found + return version_file.removesuffix("-version").removeprefix(".") if version_file else "unknown" + + +def get_localstack_product() -> str: + """ + Returns the telemetry product name from the env var, runtime, or "unknown". + """ + try: + runtime_product = get_current_runtime().components.name + except ValueError: + runtime_product = None + + return os.getenv("LOCALSTACK_TELEMETRY_PRODUCT") or runtime_product or "unknown" + + +def is_license_activated() -> bool: + try: + from localstack.pro.core import config # noqa + except ImportError: + return False + + try: + from localstack.pro.core.bootstrap import licensingv2 + + return licensingv2.get_licensed_environment().activated + except Exception: + LOG.exception("Could not determine license activation status") + return False + + +def _generate_session_id() -> str: + return long_uid() + + +def _anonymize_physical_id(physical_id: str) -> str: + """ + Returns 12 digits of the salted hash of the given physical ID. + + :param physical_id: the physical id + :return: an anonymized 12 digit value representing the physical ID. + """ + hashed = md5(_PHYSICAL_ID_SALT + physical_id) + return hashed[:12] + + +def _generate_machine_id() -> str: + try: + # try to get a robust ID from the docker socket (which will be the same from the host and the + # container) + from localstack.utils.docker_utils import DOCKER_CLIENT + + docker_id = DOCKER_CLIENT.get_system_id() + # some systems like podman don't return a stable ID, so we double-check that here + if docker_id == DOCKER_CLIENT.get_system_id(): + return f"dkr_{_anonymize_physical_id(docker_id)}" + except Exception: + pass + + if config.is_in_docker: + return f"gen_{long_uid()[:12]}" + + # this can potentially be useful when generated on the host using the CLI and then mounted into the + # container via machine.json + try: + if os.path.exists("/etc/machine-id"): + with open("/etc/machine-id") as fd: + machine_id = str(fd.read()).strip() + if machine_id: + return f"sys_{_anonymize_physical_id(machine_id)}" + except Exception: + pass + + # always fall back to a generated id + return f"gen_{long_uid()[:12]}" + + +def get_api_key_or_auth_token() -> Optional[str]: + # TODO: this is duplicated code from ext, but should probably migrate that to localstack + auth_token = os.environ.get("LOCALSTACK_AUTH_TOKEN", "").strip("'\" ") + if auth_token: + return auth_token + + api_key = os.environ.get("LOCALSTACK_API_KEY", "").strip("'\" ") + if api_key: + return api_key + + return None + + +@singleton_factory +def get_system() -> str: + try: + # try to get the system from the docker socket + from localstack.utils.docker_utils import DOCKER_CLIENT + + system = DOCKER_CLIENT.get_system_info() + if system.get("OSType"): + return system.get("OSType").lower() + except Exception: + pass + + if config.is_in_docker: + return "docker" + + return platform.system().lower() + + +@hooks.prepare_host() +def prepare_host_machine_id(): + # lazy-init machine ID into cache on the host, which can then be used in the container + get_machine_id() + + +@hooks.configure_localstack_container() +def _mount_machine_file(container: Container): + from localstack.utils.container_utils.container_client import BindMount + + # mount tha machine file from the host's CLI cache directory into the appropriate location in the + # container + machine_file = os.path.join(config.dirs.cache, "machine.json") + if os.path.isfile(machine_file): + target = os.path.join(config.dirs.for_container().cache, "machine.json") + container.config.volumes.add(BindMount(machine_file, target, read_only=True)) diff --git a/localstack-core/localstack/utils/analytics/metrics/__init__.py b/localstack-core/localstack/utils/analytics/metrics/__init__.py new file mode 100644 index 0000000000000..2d935429e982b --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/__init__.py @@ -0,0 +1,6 @@ +"""LocalStack metrics instrumentation framework""" + +from .counter import Counter, LabeledCounter +from .registry import MetricRegistry, MetricRegistryKey + +__all__ = ["Counter", "LabeledCounter", "MetricRegistry", "MetricRegistryKey"] diff --git a/localstack-core/localstack/utils/analytics/metrics/api.py b/localstack-core/localstack/utils/analytics/metrics/api.py new file mode 100644 index 0000000000000..f8d79483d666b --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/api.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Protocol + + +class Payload(Protocol): + def as_dict(self) -> dict[str, Any]: ... + + +class Metric(ABC): + """ + Base class for all metrics (e.g., Counter, Gauge). + Each subclass must implement the `collect()` method. + """ + + _namespace: str + _name: str + _schema_version: int + + def __init__(self, namespace: str, name: str, schema_version: int = 1): + if not namespace or namespace.strip() == "": + raise ValueError("Namespace must be non-empty string.") + self._namespace = namespace + + if not name or name.strip() == "": + raise ValueError("Metric name must be non-empty string.") + self._name = name + + if schema_version is None: + raise ValueError("An explicit schema_version is required for Counter metrics") + + if not isinstance(schema_version, int): + raise TypeError("Schema version must be an integer.") + + if schema_version <= 0: + raise ValueError("Schema version must be greater than zero.") + + self._schema_version = schema_version + + @property + def namespace(self) -> str: + return self._namespace + + @property + def name(self) -> str: + return self._name + + @property + def schema_version(self) -> int: + return self._schema_version + + @abstractmethod + def collect(self) -> list[Payload]: + """ + Collects and returns metric data. Subclasses must implement this to return collected metric data. + """ + pass diff --git a/localstack-core/localstack/utils/analytics/metrics/counter.py b/localstack-core/localstack/utils/analytics/metrics/counter.py new file mode 100644 index 0000000000000..42dfa5a673e9c --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/counter.py @@ -0,0 +1,219 @@ +import threading +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, Optional, Union + +from localstack import config + +from .api import Metric +from .registry import MetricRegistry + + +@dataclass(frozen=True) +class CounterPayload: + """A data object storing the value of a Counter metric.""" + + namespace: str + name: str + value: int + type: str + schema_version: int + + def as_dict(self) -> dict[str, Any]: + return { + "namespace": self.namespace, + "name": self.name, + "value": self.value, + "type": self.type, + "schema_version": self.schema_version, + } + + +@dataclass(frozen=True) +class LabeledCounterPayload: + """A data object storing the value of a LabeledCounter metric.""" + + namespace: str + name: str + value: int + type: str + schema_version: int + labels: dict[str, Union[str, float]] + + def as_dict(self) -> dict[str, Any]: + payload_dict = { + "namespace": self.namespace, + "name": self.name, + "value": self.value, + "type": self.type, + "schema_version": self.schema_version, + } + + for i, (label_name, label_value) in enumerate(self.labels.items(), 1): + payload_dict[f"label_{i}"] = label_name + payload_dict[f"label_{i}_value"] = label_value + + return payload_dict + + +class ThreadSafeCounter: + """ + A thread-safe counter for any kind of tracking. + This class should not be instantiated directly, use Counter or LabeledCounter instead. + """ + + _mutex: threading.Lock + _count: int + + def __init__(self): + super(ThreadSafeCounter, self).__init__() + self._mutex = threading.Lock() + self._count = 0 + + @property + def count(self) -> int: + return self._count + + def increment(self, value: int = 1) -> None: + """Increments the counter unless events are disabled.""" + if config.DISABLE_EVENTS: + return + + if value <= 0: + raise ValueError("Increment value must be positive.") + + with self._mutex: + self._count += value + + def reset(self) -> None: + """Resets the counter to zero unless events are disabled.""" + if config.DISABLE_EVENTS: + return + + with self._mutex: + self._count = 0 + + +class Counter(Metric, ThreadSafeCounter): + """ + A thread-safe, unlabeled counter for tracking the total number of occurrences of a specific event. + This class is intended for metrics that do not require differentiation across dimensions. + For use cases where metrics need to be grouped or segmented by labels, use `LabeledCounter` instead. + """ + + _type: str + + def __init__(self, namespace: str, name: str, schema_version: int = 1): + Metric.__init__(self, namespace=namespace, name=name, schema_version=schema_version) + ThreadSafeCounter.__init__(self) + + self._type = "counter" + MetricRegistry().register(self) + + def collect(self) -> list[CounterPayload]: + """Collects the metric unless events are disabled.""" + if config.DISABLE_EVENTS: + return list() + + if self._count == 0: + # Return an empty list if the count is 0, as there are no metrics to send to the analytics backend. + return list() + + return [ + CounterPayload( + namespace=self._namespace, + name=self.name, + value=self._count, + type=self._type, + schema_version=self._schema_version, + ) + ] + + +class LabeledCounter(Metric): + """ + A thread-safe counter for tracking occurrences of an event across multiple combinations of label values. + It enables fine-grained metric collection and analysis, with each unique label set stored and counted independently. + Use this class when you need dimensional insights into event occurrences. + For simpler, unlabeled use cases, see the `Counter` class. + """ + + _type: str + _labels: list[str] + _label_values: tuple[Optional[Union[str, float]], ...] + _counters_by_label_values: defaultdict[ + tuple[Optional[Union[str, float]], ...], ThreadSafeCounter + ] + + def __init__(self, namespace: str, name: str, labels: list[str], schema_version: int = 1): + super(LabeledCounter, self).__init__( + namespace=namespace, name=name, schema_version=schema_version + ) + + if not labels: + raise ValueError("At least one label is required; the labels list cannot be empty.") + + if any(not label for label in labels): + raise ValueError("Labels must be non-empty strings.") + + if len(labels) > 6: + raise ValueError("Too many labels: counters allow a maximum of 6.") + + self._type = "counter" + self._labels = labels + self._counters_by_label_values = defaultdict(ThreadSafeCounter) + MetricRegistry().register(self) + + def labels(self, **kwargs: Union[str, float, None]) -> ThreadSafeCounter: + """ + Create a scoped counter instance with specific label values. + + This method assigns values to the predefined labels of a labeled counter and returns + a ThreadSafeCounter object that allows tracking metrics for that specific + combination of label values. + + :raises ValueError: + - If the set of keys provided labels does not match the expected set of labels. + """ + if set(self._labels) != set(kwargs.keys()): + raise ValueError(f"Expected labels {self._labels}, got {list(kwargs.keys())}") + + _label_values = tuple(kwargs[label] for label in self._labels) + + return self._counters_by_label_values[_label_values] + + def collect(self) -> list[LabeledCounterPayload]: + if config.DISABLE_EVENTS: + return list() + + payload = [] + num_labels = len(self._labels) + + for label_values, counter in self._counters_by_label_values.items(): + if counter.count == 0: + continue # Skip items with a count of 0, as they should not be sent to the analytics backend. + + if len(label_values) != num_labels: + raise ValueError( + f"Label count mismatch: expected {num_labels} labels {self._labels}, " + f"but got {len(label_values)} values {label_values}." + ) + + # Create labels dictionary + labels_dict = { + label_name: label_value + for label_name, label_value in zip(self._labels, label_values) + } + + payload.append( + LabeledCounterPayload( + namespace=self._namespace, + name=self.name, + value=counter.count, + type=self._type, + schema_version=self._schema_version, + labels=labels_dict, + ) + ) + + return payload diff --git a/localstack-core/localstack/utils/analytics/metrics/publisher.py b/localstack-core/localstack/utils/analytics/metrics/publisher.py new file mode 100644 index 0000000000000..52639fbc80e93 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/publisher.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from localstack import config +from localstack.runtime import hooks +from localstack.utils.analytics import get_session_id +from localstack.utils.analytics.events import Event, EventMetadata +from localstack.utils.analytics.publisher import AnalyticsClientPublisher + +from .registry import MetricRegistry + + +@hooks.on_infra_shutdown() +def publish_metrics() -> None: + """ + Collects all the registered metrics and immediately sends them to the analytics service. + Skips execution if event tracking is disabled (`config.DISABLE_EVENTS`). + + This function is automatically triggered on infrastructure shutdown. + """ + if config.DISABLE_EVENTS: + return + + collected_metrics = MetricRegistry().collect() + if not collected_metrics.payload: # Skip publishing if no metrics remain after filtering + return + + metadata = EventMetadata( + session_id=get_session_id(), + client_time=str(datetime.now()), + ) + + if collected_metrics: + publisher = AnalyticsClientPublisher() + publisher.publish( + [Event(name="ls_metrics", metadata=metadata, payload=collected_metrics.as_dict())] + ) diff --git a/localstack-core/localstack/utils/analytics/metrics/registry.py b/localstack-core/localstack/utils/analytics/metrics/registry.py new file mode 100644 index 0000000000000..50f23c345ad67 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/metrics/registry.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from typing import Any + +from .api import Metric, Payload + +LOG = logging.getLogger(__name__) + + +@dataclass +class MetricPayload: + """ + A data object storing the value of all metrics collected during the execution of the application. + """ + + _payload: list[Payload] + + @property + def payload(self) -> list[Payload]: + return self._payload + + def __init__(self, payload: list[Payload]): + self._payload = payload + + def as_dict(self) -> dict[str, list[dict[str, Any]]]: + return {"metrics": [payload.as_dict() for payload in self._payload]} + + +@dataclass(frozen=True) +class MetricRegistryKey: + """A unique identifier for a metric, composed of namespace and name.""" + + namespace: str + name: str + + +class MetricRegistry: + """ + A Singleton class responsible for managing all registered metrics. + Provides methods for retrieving and collecting metrics. + """ + + _instance: "MetricRegistry" = None + _mutex: threading.Lock = threading.Lock() + + def __new__(cls): + # avoid locking if the instance already exist + if cls._instance is None: + with cls._mutex: + # Prevents race conditions when multiple threads enter the first check simultaneously + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not hasattr(self, "_registry"): + self._registry = dict() + + @property + def registry(self) -> dict[MetricRegistryKey, Metric]: + return self._registry + + def register(self, metric: Metric) -> None: + """ + Registers a metric instance. + + Raises a TypeError if the object is not a Metric, + or a ValueError if a metric with the same namespace and name is already registered + """ + if not isinstance(metric, Metric): + raise TypeError("Only subclasses of `Metric` can be registered.") + + if not metric.namespace: + raise ValueError("Metric 'namespace' must be defined and non-empty.") + + registry_unique_key = MetricRegistryKey(namespace=metric.namespace, name=metric.name) + if registry_unique_key in self._registry: + raise ValueError( + f"A metric named '{metric.name}' already exists in the '{metric.namespace}' namespace" + ) + + self._registry[registry_unique_key] = metric + + def collect(self) -> MetricPayload: + """ + Collects all registered metrics. + """ + payload = [ + metric + for metric_instance in self._registry.values() + for metric in metric_instance.collect() + ] + + return MetricPayload(payload=payload) diff --git a/localstack-core/localstack/utils/analytics/publisher.py b/localstack-core/localstack/utils/analytics/publisher.py new file mode 100644 index 0000000000000..48faf6d293625 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/publisher.py @@ -0,0 +1,279 @@ +import abc +import atexit +import logging +import threading +import time +from queue import Full, Queue +from typing import List, Optional + +from localstack import config +from localstack.utils.threads import start_thread, start_worker_thread + +from .client import AnalyticsClient +from .events import Event, EventHandler +from .metadata import get_client_metadata + +LOG = logging.getLogger(__name__) + + +class Publisher(abc.ABC): + """ + A publisher takes a batch of events and publishes them to a destination. + """ + + def publish(self, events: List[Event]): + raise NotImplementedError + + def close(self): + pass + + +class AnalyticsClientPublisher(Publisher): + client: AnalyticsClient + + def __init__(self, client: AnalyticsClient = None) -> None: + super().__init__() + self.client = client or AnalyticsClient() + + def publish(self, events: List[Event]): + self.client.append_events(events) + + def close(self): + self.client.close() + + +class Printer(Publisher): + """ + Publisher that prints serialized events to stdout. + """ + + def publish(self, events: List[Event]): + for event in events: + print(event.asdict()) + + +class PublisherBuffer(EventHandler): + """ + A PublisherBuffer is an EventHandler that collects events into a buffer until a flush condition is + met, and then flushes the buffer to a Publisher. The condition is either a given buffer size or + a time interval, whatever occurs first. The buffer is also flushed when the recorder is stopped + via `close()`. Internally it uses a simple event-loop mechanism to multiplex commands on a + single thread. + """ + + flush_size: int + flush_interval: float + + _cmd_flush = "__FLUSH__" + _cmd_stop = "__STOP__" + + # FIXME: figure out good default values + def __init__( + self, publisher: Publisher, flush_size: int = 20, flush_interval: float = 10, maxsize=0 + ): + self._publisher = publisher + self._queue = Queue(maxsize=maxsize) + self._command_queue = Queue() + + self.flush_size = flush_size + self.flush_interval = flush_interval + + self._last_flush = time.time() + self._stopping = threading.Event() + self._stopped = threading.Event() + + def handle(self, event: Event): + self._queue.put_nowait(event) + self.checked_flush() + + def close(self): + if self._stopping.is_set(): + return + + self._stopping.set() + self._command_queue.put(self._cmd_stop) + + def close_sync(self, timeout: Optional[float] = None): + self.close() + return self._stopped.wait(timeout) + + def flush(self): + self._command_queue.put(self._cmd_flush) + self._last_flush = time.time() + + def checked_flush(self): + """ + Runs flush only if a flush condition is met. + """ + if config.DEBUG_ANALYTICS: + LOG.debug( + "analytics queue size: %d, command queue size: %d, time since last flush: %.1fs", + self._queue.qsize(), + self._command_queue.qsize(), + time.time() - self._last_flush, + ) + + if self._queue.qsize() >= self.flush_size: + self.flush() + return + if time.time() - self._last_flush >= self.flush_interval: + self.flush() + return + + def _run_flush_schedule(self, *_): + while True: + if self._stopping.wait(self.flush_interval): + return + self.checked_flush() + + def run(self, *_): + flush_scheduler = start_thread(self._run_flush_schedule, name="analytics-publishbuffer") + + try: + while True: + command = self._command_queue.get() + + if command is self._cmd_flush or command is self._cmd_stop: + try: + self._do_flush() + except Exception: + if config.DEBUG_ANALYTICS: + LOG.exception("error while flushing events") + + if command is self._cmd_stop: + return + finally: + self._stopped.set() + flush_scheduler.stop() + self._publisher.close() + if config.DEBUG_ANALYTICS: + LOG.debug("Exit analytics publisher") + + def _do_flush(self): + queue = self._queue + events = [] + + for _ in range(queue.qsize()): + event = queue.get_nowait() + events.append(event) + + if config.DEBUG_ANALYTICS: + LOG.debug("collected %d events to publish", len(events)) + + self._publisher.publish(events) + + +class GlobalAnalyticsBus(PublisherBuffer): + def __init__( + self, client: AnalyticsClient = None, flush_size=20, flush_interval=10, max_buffer_size=1000 + ) -> None: + self._client = client or AnalyticsClient() + self._publisher = AnalyticsClientPublisher(self._client) + + super().__init__( + self._publisher, + flush_size=flush_size, + flush_interval=flush_interval, + maxsize=max_buffer_size, + ) + + self._started = False + self._startup_complete = False + self._startup_mutex = threading.Lock() + self._buffer_thread = None + + self.force_tracking = False # allow class to ignore all other tracking config + self.tracking_disabled = False # disables tracking if global config would otherwise track + + @property + def is_tracking_disabled(self): + if self.force_tracking: + return False + + # don't track if event tracking is disabled globally + if config.DISABLE_EVENTS: + return True + # don't track for internal test runs (like integration tests) + if config.is_local_test_mode(): + return True + if self.tracking_disabled: + return True + + return False + + def _do_flush(self): + if self.tracking_disabled: + # flushing although tracking has been disabled most likely means that _do_start_retry + # has failed, tracking is now disabled, and the system tries to flush the queued + # events. we use this opportunity to shut down the tracker and clear the queue, since + # no tracking should happen from this point on. + if config.DEBUG_ANALYTICS: + LOG.debug("attempting to flush while tracking is disabled, shutting down tracker") + self.close_sync(timeout=10) + self._queue.queue.clear() + return + + super()._do_flush() + + def flush(self): + if not self._startup_complete: + # don't flush until _do_start_retry has completed (command queue would fill up) + return + + super().flush() + + def handle(self, event: Event): + """ + Publish an event to the global analytics event publisher. + """ + if self.is_tracking_disabled: + if config.DEBUG_ANALYTICS: + LOG.debug("skipping event %s", event) + return + + if not self._started: + self._start() + + try: + super().handle(event) + except Full: + if config.DEBUG_ANALYTICS: + LOG.warning("event queue is full, dropping event %s", event) + + def _start(self): + with self._startup_mutex: + if self._started: + return + self._started = True + + # startup has to run async, otherwise first call to handle() could block a long time. + start_worker_thread(self._do_start_retry) + + def _do_start_retry(self, *_): + # TODO: actually retry + try: + if config.DEBUG_ANALYTICS: + LOG.debug("trying to register session with analytics backend") + response = self._client.start_session(get_client_metadata()) + if config.DEBUG_ANALYTICS: + LOG.debug("session endpoint returned: %s", response) + + if not response.track_events(): + if config.DEBUG_ANALYTICS: + LOG.debug("gracefully disabling analytics tracking") + self.tracking_disabled = True + + except Exception: + self.tracking_disabled = True + if config.DEBUG_ANALYTICS: + LOG.exception("error while registering session. disabling tracking") + return + finally: + self._startup_complete = True + + start_thread(self.run, name="global-analytics-bus") + + def _do_close(): + self.close_sync(timeout=2) + + atexit.register(_do_close) diff --git a/localstack-core/localstack/utils/analytics/service_request_aggregator.py b/localstack-core/localstack/utils/analytics/service_request_aggregator.py new file mode 100644 index 0000000000000..f503235201c45 --- /dev/null +++ b/localstack-core/localstack/utils/analytics/service_request_aggregator.py @@ -0,0 +1,125 @@ +import datetime +import logging +import threading +from collections import Counter +from typing import Dict, List, NamedTuple, Optional + +from localstack import config +from localstack.runtime.shutdown import SHUTDOWN_HANDLERS +from localstack.utils import analytics +from localstack.utils.scheduler import Scheduler + +LOG = logging.getLogger(__name__) + +DEFAULT_FLUSH_INTERVAL_SECS = 15 +EVENT_NAME = "aws_request_agg" +OPTIONAL_FIELDS = ["err_type"] + + +class ServiceRequestInfo(NamedTuple): + service: str + operation: str + status_code: int + err_type: Optional[str] = None + + +class ServiceRequestAggregator: + """ + Collects API call data, aggregates it into small batches, and periodically emits (flushes) it as an + analytics event. + """ + + def __init__(self, flush_interval: float = DEFAULT_FLUSH_INTERVAL_SECS): + self.counter = Counter() + self._flush_interval = flush_interval + self._flush_scheduler = Scheduler() + self._mutex = threading.RLock() + self._period_start_time = datetime.datetime.utcnow() + self._is_started = False + self._is_shutdown = False + + def start(self): + """ + Start a thread that periodically flushes HTTP response data aggregations as analytics events + :returns: the thread containing the running flush scheduler + """ + with self._mutex: + if self._is_started: + return + self._is_started = True + + # schedule flush task + self._flush_scheduler.schedule( + func=self._flush, period=self._flush_interval, fixed_rate=True + ) + + # start thread + _flush_scheduler_thread = threading.Thread( + target=self._flush_scheduler.run, daemon=True + ) + _flush_scheduler_thread.start() + + SHUTDOWN_HANDLERS.register(self.shutdown) + + def shutdown(self): + with self._mutex: + if not self._is_started: + return + if self._is_shutdown: + return + self._is_shutdown = True + + self._flush() + self._flush_scheduler.close() + SHUTDOWN_HANDLERS.unregister(self.shutdown) + + def add_request(self, request_info: ServiceRequestInfo): + """ + Add an API call for aggregation and collection. + + :param request_info: information about the API call. + """ + if config.DISABLE_EVENTS: + return + + if self._is_shutdown: + return + + with self._mutex: + self.counter[request_info] += 1 + + def _flush(self): + """ + Flushes the current batch of HTTP response data as an analytics event. + This happens automatically in the background. + """ + with self._mutex: + try: + if len(self.counter) == 0: + return + analytics_payload = self._create_analytics_payload() + self._emit_payload(analytics_payload) + self.counter.clear() + finally: + self._period_start_time = datetime.datetime.utcnow() + + def _create_analytics_payload(self): + return { + "period_start_time": self._period_start_time.isoformat() + "Z", + "period_end_time": datetime.datetime.utcnow().isoformat() + "Z", + "api_calls": self._aggregate_api_calls(self.counter), + } + + def _aggregate_api_calls(self, counter) -> List: + aggregations = [] + for api_call_info, count in counter.items(): + doc = api_call_info._asdict() + for field in OPTIONAL_FIELDS: + if doc.get(field) is None: + del doc[field] + doc["count"] = count + aggregations.append(doc) + return aggregations + + def _emit_payload(self, analytics_payload: Dict): + analytics.log.event(EVENT_NAME, analytics_payload) diff --git a/localstack-core/localstack/utils/archives.py b/localstack-core/localstack/utils/archives.py new file mode 100644 index 0000000000000..e3b3673541a80 --- /dev/null +++ b/localstack-core/localstack/utils/archives.py @@ -0,0 +1,271 @@ +import glob +import io +import logging +import os +import re +import tarfile +import tempfile +import time +import zipfile +from subprocess import Popen +from typing import IO, Literal, Optional, Union + +from localstack.constants import MAVEN_REPO_URL +from localstack.utils.files import load_file, mkdir, new_tmp_file, rm_rf, save_file +from localstack.utils.http import download +from localstack.utils.run import run + +from .checksum import verify_local_file_with_checksum_url +from .run import is_command_available +from .strings import truncate + +LOG = logging.getLogger(__name__) + + +StrPath = Union[str, os.PathLike] + + +def is_zip_file(content): + stream = io.BytesIO(content) + return zipfile.is_zipfile(stream) + + +def get_unzipped_size(zip_file: Union[str, IO[bytes]]): + """Returns the size of the unzipped file.""" + with zipfile.ZipFile(zip_file, "r") as zip_ref: + return sum(f.file_size for f in zip_ref.infolist()) + + +def unzip(path: str, target_dir: str, overwrite: bool = True) -> Optional[Union[str, Popen]]: + from localstack.utils.platform import is_debian + + use_native_cmd = is_debian() or is_command_available("unzip") + if use_native_cmd: + # Running the native command can be an order of magnitude faster in the container. Also, `unzip` + # is capable of extracting zip files with incorrect CRC codes (sometimes happens, e.g., with some + # Node.js/Serverless versions), which can fail with Python's `zipfile` (extracting empty files). + flags = ["-o"] if overwrite else [] + flags += ["-q"] + try: + cmd = ["unzip"] + flags + [path] + return run(cmd, cwd=target_dir, print_error=False) + except Exception as e: + error_str = truncate(str(e), max_length=200) + LOG.info( + 'Unable to use native "unzip" command (using fallback mechanism): %s', error_str + ) + + try: + zip_ref = zipfile.ZipFile(path, "r") + except Exception as e: + LOG.warning("Unable to open zip file: %s: %s", path, e) + raise e + + def _unzip_file_entry(zip_ref, file_entry, target_dir): + """Extracts a Zipfile entry and preserves permissions""" + out_path = os.path.join(target_dir, file_entry.filename) + if use_native_cmd and os.path.exists(out_path) and os.path.getsize(out_path) > 0: + # this can happen under certain circumstances if the native "unzip" command + # fails with a non-zero exit code, yet manages to extract parts of the zip file + return + zip_ref.extract(file_entry.filename, path=target_dir) + perm = file_entry.external_attr >> 16 + # Make sure to preserve file permissions in the zip file + # https://www.burgundywall.com/post/preserving-file-perms-with-python-zipfile-module + os.chmod(out_path, perm or 0o777) + + try: + for file_entry in zip_ref.infolist(): + _unzip_file_entry(zip_ref, file_entry, target_dir) + finally: + zip_ref.close() + + +def untar(path: str, target_dir: str): + mode = "r:gz" if path.endswith("gz") else "r" + with tarfile.open(path, mode) as tar: + tar.extractall(path=target_dir) + + +def create_zip_file_cli(source_path: StrPath, base_dir: StrPath, zip_file: StrPath): + """ + Creates a zip archive by using the native zip command. The native command can be an order of magnitude faster in CI + """ + source = "." if source_path == base_dir else os.path.basename(source_path) + run(["zip", "-r", zip_file, source], cwd=base_dir) + + +def create_zip_file_python( + base_dir: StrPath, + zip_file: StrPath, + mode: Literal["r", "w", "x", "a"] = "w", + content_root: Optional[str] = None, +): + with zipfile.ZipFile(zip_file, mode) as zip_file: + for root, dirs, files in os.walk(base_dir): + for name in files: + full_name = os.path.join(root, name) + relative = os.path.relpath(root, start=base_dir) + if content_root: + dest = os.path.join(content_root, relative, name) + else: + dest = os.path.join(relative, name) + zip_file.write(full_name, dest) + + +def add_file_to_jar(class_file, class_url, target_jar, base_dir=None): + base_dir = base_dir or os.path.dirname(target_jar) + patch_class_file = os.path.join(base_dir, class_file) + if not os.path.exists(patch_class_file): + download(class_url, patch_class_file) + run(["zip", target_jar, class_file], cwd=base_dir) + + +def update_jar_manifest( + jar_file_name: str, parent_dir: str, search: Union[str, re.Pattern], replace: str +): + manifest_file_path = "META-INF/MANIFEST.MF" + jar_path = os.path.join(parent_dir, jar_file_name) + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_manifest_file = os.path.join(tmp_dir, manifest_file_path) + run(["unzip", "-o", jar_path, manifest_file_path], cwd=tmp_dir) + manifest = load_file(tmp_manifest_file) + + # return if the search pattern does not match (for idempotence, to avoid file permission issues further below) + if isinstance(search, re.Pattern): + if not search.search(manifest): + return + manifest = search.sub(replace, manifest, 1) + else: + if search not in manifest: + return + manifest = manifest.replace(search, replace, 1) + + manifest_file = os.path.join(parent_dir, manifest_file_path) + save_file(manifest_file, manifest) + run(["zip", jar_file_name, manifest_file_path], cwd=parent_dir) + + +def upgrade_jar_file(base_dir: str, file_glob: str, maven_asset: str): + """ + Upgrade the matching Java JAR file in a local directory with the given Maven asset + :param base_dir: base directory to search the JAR file to replace in + :param file_glob: glob pattern for the JAR file to replace + :param maven_asset: name of Maven asset to download, in the form ":" + """ + + local_path = os.path.join(base_dir, file_glob) + parent_dir = os.path.dirname(local_path) + maven_asset = maven_asset.replace(":", "/") + parts = maven_asset.split("/") + maven_asset_url = f"{MAVEN_REPO_URL}/{maven_asset}/{parts[-2]}-{parts[-1]}.jar" + target_file = os.path.join(parent_dir, os.path.basename(maven_asset_url)) + if os.path.exists(target_file): + # avoid re-downloading the newer JAR version if it already exists locally + return + matches = glob.glob(local_path) + if not matches: + return + for match in matches: + os.remove(match) + download(maven_asset_url, target_file) + + +def download_and_extract( + archive_url: str, + target_dir: str, + retries: Optional[int] = 0, + sleep: Optional[int] = 3, + tmp_archive: Optional[str] = None, + checksum_url: Optional[str] = None, +) -> None: + """ + Download and extract an archive to a target directory with optional checksum verification. + + Checksum verification is only performed if a `checksum_url` is provided. + Else, the archive is downloaded and extracted without verification. + + :param archive_url: URL of the archive to download + :param target_dir: Directory to extract the archive contents to + :param retries: Number of download retries (default: 0) + :param sleep: Sleep time between retries in seconds (default: 3) + :param tmp_archive: Optional path for the temporary archive file + :param checksum_url: Optional URL of the checksum file for verification + :return: None + """ + mkdir(target_dir) + + _, ext = os.path.splitext(tmp_archive or archive_url) + tmp_archive = tmp_archive or new_tmp_file() + if not os.path.exists(tmp_archive) or os.path.getsize(tmp_archive) <= 0: + # create temporary placeholder file, to avoid duplicate parallel downloads + save_file(tmp_archive, "") + + for i in range(retries + 1): + try: + download(archive_url, tmp_archive) + break + except Exception as e: + LOG.warning( + "Attempt %d. Failed to download archive from %s: %s", + i + 1, + archive_url, + e, + ) + # only sleep between retries, not after the last one + if i < retries: + time.sleep(sleep) + + # if the temporary file we created above hasn't been replaced, we assume failure + if os.path.getsize(tmp_archive) <= 0: + raise Exception("Failed to download archive from %s: . Retries exhausted", archive_url) + + # Verify checksum if provided + if checksum_url: + LOG.info("Verifying archive integrity...") + try: + verify_local_file_with_checksum_url( + file_path=tmp_archive, + checksum_url=checksum_url, + ) + except Exception as e: + # clean up the corrupted download + rm_rf(tmp_archive) + raise e + + if ext == ".zip": + unzip(tmp_archive, target_dir) + elif ext in ( + ".bz2", + ".gz", + ".tgz", + ".xz", + ): + untar(tmp_archive, target_dir) + else: + raise Exception(f"Unsupported archive format: {ext}") + + +def download_and_extract_with_retry( + archive_url, + tmp_archive, + target_dir, + checksum_url: Optional[str] = None, +): + try: + download_and_extract( + archive_url, + target_dir, + tmp_archive=tmp_archive, + checksum_url=checksum_url, + ) + except Exception as e: + # try deleting and re-downloading the zip file + LOG.info("Unable to extract file, re-downloading ZIP archive %s: %s", tmp_archive, e) + rm_rf(tmp_archive) + download_and_extract( + archive_url, + target_dir, + tmp_archive=tmp_archive, + checksum_url=checksum_url, + ) diff --git a/localstack-core/localstack/utils/async_utils.py b/localstack-core/localstack/utils/async_utils.py new file mode 100644 index 0000000000000..5b6ac22636ca3 --- /dev/null +++ b/localstack-core/localstack/utils/async_utils.py @@ -0,0 +1,13 @@ +# DEPRECATED: use localstack.utils.asyncio +from .asyncio import ( # noqa + EVENT_LOOPS, + THREAD_POOL, + AdaptiveThreadPool, + AsyncThread, + ensure_event_loop, + get_main_event_loop, + get_named_event_loop, + receive_from_queue, + run_coroutine, + run_sync, +) diff --git a/localstack-core/localstack/utils/asyncio.py b/localstack-core/localstack/utils/asyncio.py new file mode 100644 index 0000000000000..745dab6abae4d --- /dev/null +++ b/localstack-core/localstack/utils/asyncio.py @@ -0,0 +1,157 @@ +import asyncio +import concurrent.futures.thread +import functools +import time +from contextvars import copy_context + +from .run import FuncThread +from .threads import TMP_THREADS, start_worker_thread + +# reference to named event loop instances +EVENT_LOOPS = {} + + +class DaemonAwareThreadPool(concurrent.futures.thread.ThreadPoolExecutor): + """ + This thread pool executor removes the threads it creates from the global ``_thread_queues`` of + ``concurrent.futures.thread``, which joins all created threads at python exit and will block + interpreter shutdown if any threads are still running, even if they are daemon threads. Threads created + by the thread pool will be daemon threads by default if the thread owning the thread pool is also a + deamon thread. + """ + + def _adjust_thread_count(self) -> None: + super()._adjust_thread_count() + + for t in self._threads: + if not t.daemon: + continue + try: + del concurrent.futures.thread._threads_queues[t] + except KeyError: + pass + + +class AdaptiveThreadPool(DaemonAwareThreadPool): + """Thread pool executor that maintains a maximum of 'core_size' reusable threads in + the core pool, and creates new thread instances as needed (if the core pool is full).""" + + DEFAULT_CORE_POOL_SIZE = 30 + + def __init__(self, core_size=None): + self.core_size = core_size or self.DEFAULT_CORE_POOL_SIZE + super(AdaptiveThreadPool, self).__init__(max_workers=self.core_size) + + def submit(self, fn, *args, **kwargs): + # if idle threads are available, don't spin new threads + if self.has_idle_threads(): + return super(AdaptiveThreadPool, self).submit(fn, *args, **kwargs) + + def _run(*tmpargs): + return fn(*args, **kwargs) + + thread = start_worker_thread(_run) + return thread.result_future + + def has_idle_threads(self): + if hasattr(self, "_idle_semaphore"): + return self._idle_semaphore.acquire(timeout=0) + num_threads = len(self._threads) + return num_threads < self._max_workers + + +# Thread pool executor for running sync functions in async context. +# Note: For certain APIs like DynamoDB, we need 3x threads for each parallel request, +# as during request processing the API calls out to the DynamoDB API again (recursively). +# (TODO: This could potentially be improved if we move entirely to asyncio functions.) +THREAD_POOL = AdaptiveThreadPool() +TMP_THREADS.append(THREAD_POOL) + + +class AsyncThread(FuncThread): + def __init__(self, async_func_gen=None, loop=None): + """Pass a function that receives an event loop instance and a shutdown event, + and returns an async function.""" + FuncThread.__init__(self, self.run_func, None, name="asyncio-thread") + self.async_func_gen = async_func_gen + self.loop = loop + self.shutdown_event = None + + def run_func(self, *args): + loop = self.loop or ensure_event_loop() + self.shutdown_event = asyncio.Event() + if self.async_func_gen: + self.async_func = async_func = self.async_func_gen(loop, self.shutdown_event) + if async_func: + loop.run_until_complete(async_func) + loop.run_forever() + + def stop(self, quiet=None): + if self.shutdown_event: + self.shutdown_event.set() + self.shutdown_event = None + + @classmethod + def run_async(cls, func=None, loop=None): + thread = cls(func, loop=loop) + thread.start() + TMP_THREADS.append(thread) + return thread + + +async def run_sync(func, *args, thread_pool=None, **kwargs): + loop = asyncio.get_running_loop() + thread_pool = thread_pool or THREAD_POOL + func_wrapped = functools.partial(func, *args, **kwargs) + return await loop.run_in_executor(thread_pool, copy_context().run, func_wrapped) + + +def run_coroutine(coroutine, loop=None): + """Run an async coroutine in a threadsafe way in the main event loop""" + loop = loop or get_main_event_loop() + future = asyncio.run_coroutine_threadsafe(coroutine, loop) + return future.result() + + +def ensure_event_loop(): + """Ensure that an event loop is defined for the currently running thread""" + try: + return asyncio.get_event_loop() + except Exception: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + +def get_main_event_loop(): + return get_named_event_loop("_main_") + + +def get_named_event_loop(name): + result = EVENT_LOOPS.get(name) + if result: + return result + + def async_func_gen(loop, shutdown_event): + EVENT_LOOPS[name] = loop + + AsyncThread.run_async(async_func_gen) + time.sleep(1) + return EVENT_LOOPS[name] + + +async def receive_from_queue(queue): + from localstack.runtime import events + + def get(): + # run in a retry loop (instead of blocking forever) to allow for graceful shutdown + while True: + try: + if events.infra_stopping.is_set(): + return + return queue.get(timeout=1) + except Exception: + pass + + msg = await run_sync(get) + return msg diff --git a/localstack-core/localstack/utils/auth.py b/localstack-core/localstack/utils/auth.py new file mode 100644 index 0000000000000..88916f22fa370 --- /dev/null +++ b/localstack-core/localstack/utils/auth.py @@ -0,0 +1,75 @@ +import logging + +from botocore.auth import HmacV1Auth, SigV4QueryAuth +from botocore.exceptions import NoCredentialsError + +logger = logging.getLogger(__name__) + +SIGV4_TIMESTAMP = "%Y%m%dT%H%M%SZ" +UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD" + + +class HmacV1QueryAuth(HmacV1Auth): + """ + Generates a presigned request for s3. + + Spec from this document: + + http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html + #RESTAuthenticationQueryStringAuth + + """ + + DEFAULT_EXPIRES = 3600 + + def __init__(self, credentials, expires=DEFAULT_EXPIRES): + self.credentials = credentials + self._expires = expires + + def _get_date(self): + return str(int(int(self._expires))) + + def get_signature(self, string_to_sign): + return self.sign_string(string_to_sign) + + def get_string_to_sign(self, method, split, headers, expires=None, auth_path=None): + if self.credentials.token: + headers["x-amz-security-token"] = self.credentials.token + string_to_sign = self.canonical_string(method, split, headers, auth_path=auth_path) + return string_to_sign + + +class S3SigV4QueryAuth(SigV4QueryAuth): + """S3 SigV4 auth using query parameters. + + This signer will sign a request using query parameters and signature + version 4, i.e a "presigned url" signer. + + Based off of: + + http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html + + """ + + def add_auth(self, request, x_amz_date): + if self.credentials is None: + raise NoCredentialsError + request.context["timestamp"] = x_amz_date + # This could be a retry. Make sure the previous + # authorization header is removed first. + self._modify_request_before_signing(request) + canonical_request = self.canonical_request(request) + string_to_sign = self.string_to_sign(request, canonical_request) + signature = self.signature(string_to_sign, request) + return signature + + def _normalize_url_path(self, path): + # For S3, we do not normalize the path. + return path + + def payload(self, request): + # From the doc link above: + # "You don't include a payload hash in the Canonical Request, because + # when you create a presigned URL, you don't know anything about the + # payload. Instead, you use a constant string "UNSIGNED-PAYLOAD". + return UNSIGNED_PAYLOAD diff --git a/localstack-core/localstack/utils/aws/__init__.py b/localstack-core/localstack/utils/aws/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/aws/arns.py b/localstack-core/localstack/utils/aws/arns.py new file mode 100644 index 0000000000000..5b6f139473bac --- /dev/null +++ b/localstack-core/localstack/utils/aws/arns.py @@ -0,0 +1,606 @@ +import logging +import re +from functools import cache +from typing import Optional, TypedDict + +from botocore.utils import ArnParser, InvalidArnException + +from localstack.aws.accounts import DEFAULT_AWS_ACCOUNT_ID +from localstack.aws.connect import connect_to +from localstack.utils.strings import long_uid + +LOG = logging.getLogger(__name__) + +# +# Partition Utilities +# + +DEFAULT_PARTITION = "aws" +REGION_PREFIX_TO_PARTITION = { + # (region prefix, aws partition) + "cn-": "aws-cn", + "us-gov-": "aws-us-gov", + "us-iso-": "aws-iso", + "us-isob-": "aws-iso-b", +} +PARTITION_NAMES = list(REGION_PREFIX_TO_PARTITION.values()) + [DEFAULT_PARTITION] +ARN_PARTITION_REGEX = r"^arn:(" + "|".join(sorted(PARTITION_NAMES)) + ")" + + +def get_partition(region: Optional[str]) -> str: + if not region: + return DEFAULT_PARTITION + if region in PARTITION_NAMES: + return region + for prefix in REGION_PREFIX_TO_PARTITION: + if region.startswith(prefix): + return REGION_PREFIX_TO_PARTITION[prefix] + return DEFAULT_PARTITION + + +# +# ARN parsing utilities +# + + +class ArnData(TypedDict): + partition: str + service: str + region: str + account: str + resource: str + + +_arn_parser = ArnParser() + + +def parse_arn(arn: str) -> ArnData: + """ + Uses a botocore ArnParser to parse an arn. + + :param arn: the arn string to parse + :returns: a dictionary containing the ARN components + :raises InvalidArnException: if the arn is invalid + """ + return _arn_parser.parse_arn(arn) + + +def extract_account_id_from_arn(arn: str) -> Optional[str]: + try: + return parse_arn(arn).get("account") + except InvalidArnException: + return None + + +def extract_region_from_arn(arn: str) -> Optional[str]: + try: + return parse_arn(arn).get("region") + except InvalidArnException: + return None + + +def extract_service_from_arn(arn: str) -> Optional[str]: + try: + return parse_arn(arn).get("service") + except InvalidArnException: + return None + + +def extract_resource_from_arn(arn: str) -> Optional[str]: + try: + return parse_arn(arn).get("resource") + except InvalidArnException: + return None + + +# +# Generic ARN builder +# + + +def _resource_arn(name: str, pattern: str, account_id: str, region_name: str) -> str: + if ":" in name: + return name + if len(pattern.split("%s")) == 4: + return pattern % (get_partition(region_name), account_id, name) + return pattern % (get_partition(region_name), region_name, account_id, name) + + +# +# ARN builders for specific resource types +# + +# +# IAM +# + + +def iam_role_arn(role_name: str, account_id: str, region_name: str) -> str: + if not role_name: + return role_name + if re.match(f"{ARN_PARTITION_REGEX}:iam::", role_name): + return role_name + return "arn:%s:iam::%s:role/%s" % (get_partition(region_name), account_id, role_name) + + +def iam_resource_arn(resource: str, account_id: str, role: str = None) -> str: + if not role: + role = f"role-{resource}" + # Only used in tests, so we can hardcode the region for now + return iam_role_arn(role_name=role, account_id=account_id, region_name="us-east-1") + + +# +# Secretsmanager +# + + +def secretsmanager_secret_arn( + secret_id: str, account_id: str, region_name: str, random_suffix: str = None +) -> str: + if ":" in (secret_id or ""): + return secret_id + pattern = "arn:%s:secretsmanager:%s:%s:secret:%s" + arn = _resource_arn(secret_id, pattern, account_id=account_id, region_name=region_name) + if random_suffix: + arn += f"-{random_suffix}" + return arn + + +# +# Cloudformation +# + + +def cloudformation_stack_arn( + stack_name: str, stack_id: str, account_id: str, region_name: str +) -> str: + pattern = "arn:%s:cloudformation:%s:%s:stack/%s/{stack_id}".format(stack_id=stack_id) + return _resource_arn(stack_name, pattern, account_id=account_id, region_name=region_name) + + +def cloudformation_change_set_arn( + change_set_name: str, change_set_id: str, account_id: str, region_name: str +) -> str: + pattern = "arn:%s:cloudformation:%s:%s:changeSet/%s/{cs_id}".format(cs_id=change_set_id) + return _resource_arn(change_set_name, pattern, account_id=account_id, region_name=region_name) + + +# +# DynamoDB +# + + +def dynamodb_table_arn(table_name: str, account_id: str, region_name: str) -> str: + table_name = table_name.split(":table/")[-1] + pattern = "arn:%s:dynamodb:%s:%s:table/%s" + return _resource_arn(table_name, pattern, account_id=account_id, region_name=region_name) + + +def dynamodb_stream_arn( + table_name: str, latest_stream_label: str, account_id: str, region_name: str +) -> str: + return "arn:%s:dynamodb:%s:%s:table/%s/stream/%s" % ( + get_partition(region_name), + region_name, + account_id, + table_name, + latest_stream_label, + ) + + +# +# Cloudwatch +# + + +def cloudwatch_alarm_arn(alarm_name: str, account_id: str, region_name: str) -> str: + # format pattern directly as alarm_name can include ":" and this is not supported by the helper _resource_arn + return ( + f"arn:{get_partition(region_name)}:cloudwatch:{region_name}:{account_id}:alarm:{alarm_name}" + ) + + +def cloudwatch_dashboard_arn(dashboard_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:cloudwatch::%s:dashboard/%s" + return _resource_arn(dashboard_name, pattern, account_id=account_id, region_name=region_name) + + +# +# Logs +# + + +def log_group_arn(group_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:logs:%s:%s:log-group:%s" + return _resource_arn(group_name, pattern, account_id=account_id, region_name=region_name) + + +# +# Events +# + + +def events_archive_arn(archive_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:events:%s:%s:archive/%s" + return _resource_arn(archive_name, pattern, account_id=account_id, region_name=region_name) + + +def event_bus_arn(bus_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:events:%s:%s:event-bus/%s" + return _resource_arn(bus_name, pattern, account_id=account_id, region_name=region_name) + + +def events_replay_arn(replay_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:events:%s:%s:replay/%s" + return _resource_arn(replay_name, pattern, account_id=account_id, region_name=region_name) + + +def events_rule_arn( + rule_name: str, account_id: str, region_name: str, event_bus_name: str = "default" +) -> str: + pattern = "arn:%s:events:%s:%s:rule/%s" + if event_bus_name != "default": + rule_name = f"{event_bus_name}/{rule_name}" + return _resource_arn(rule_name, pattern, account_id=account_id, region_name=region_name) + + +def events_connection_arn( + connection_name: str, connection_id: str, account_id: str, region_name: str +) -> str: + name = f"{connection_name}/{connection_id}" + pattern = "arn:%s:events:%s:%s:connection/%s" + return _resource_arn(name, pattern, account_id=account_id, region_name=region_name) + + +def events_api_destination_arn( + api_destination_name: str, api_destination_id: str, account_id: str, region_name: str +) -> str: + name = f"{api_destination_name}/{api_destination_id}" + pattern = "arn:%s:events:%s:%s:api-destination/%s" + return _resource_arn(name, pattern, account_id=account_id, region_name=region_name) + + +# +# Lambda +# + + +def lambda_function_arn(function_name: str, account_id: str, region_name: str) -> str: + return lambda_function_or_layer_arn( + "function", function_name, version=None, account_id=account_id, region_name=region_name + ) + + +def lambda_layer_arn(layer_name: str, account_id: str, region_name: str) -> str: + return lambda_function_or_layer_arn( + "layer", layer_name, version=None, account_id=account_id, region_name=region_name + ) + + +def lambda_code_signing_arn(code_signing_id: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:lambda:%s:%s:code-signing-config:%s" + return _resource_arn(code_signing_id, pattern, account_id=account_id, region_name=region_name) + + +def lambda_event_source_mapping_arn(uuid: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:lambda:%s:%s:event-source-mapping:%s" + return _resource_arn(uuid, pattern, account_id=account_id, region_name=region_name) + + +def lambda_function_or_layer_arn( + type: str, + entity_name: str, + version: Optional[str], + account_id: str, + region_name: str, +) -> str: + pattern = "arn:([a-z-]+):lambda:.*:.*:(function|layer):.*" + if re.match(pattern, entity_name): + return entity_name + if ":" in entity_name: + client = connect_to(aws_access_key_id=account_id, region_name=region_name).lambda_ + entity_name, _, alias = entity_name.rpartition(":") + try: + alias_response = client.get_alias(FunctionName=entity_name, Name=alias) + version = alias_response["FunctionVersion"] + + except Exception as e: + msg = f"Alias {alias} of {entity_name} not found" + LOG.info("%s: %s", msg, e) + raise Exception(msg) + + result = ( + f"arn:{get_partition(region_name)}:lambda:{region_name}:{account_id}:{type}:{entity_name}" + ) + if version: + result = f"{result}:{version}" + return result + + +# +# Stepfunctions +# + + +def stepfunctions_state_machine_arn(name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:states:%s:%s:stateMachine:%s" + return _resource_arn(name, pattern, account_id=account_id, region_name=region_name) + + +def stepfunctions_standard_execution_arn(state_machine_arn: str, execution_name: str) -> str: + arn_data: ArnData = parse_arn(state_machine_arn) + standard_execution_arn = ":".join( + [ + "arn", + arn_data["partition"], + arn_data["service"], + arn_data["region"], + arn_data["account"], + "execution", + "".join(arn_data["resource"].split(":")[1:]), + execution_name, + ] + ) + return standard_execution_arn + + +def stepfunctions_express_execution_arn(state_machine_arn: str, execution_name: str) -> str: + arn_data: ArnData = parse_arn(state_machine_arn) + express_execution_arn = ":".join( + [ + "arn", + arn_data["partition"], + arn_data["service"], + arn_data["region"], + arn_data["account"], + "express", + "".join(arn_data["resource"].split(":")[1:]), + execution_name, + long_uid(), + ] + ) + return express_execution_arn + + +def stepfunctions_activity_arn(name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:states:%s:%s:activity:%s" + return _resource_arn(name, pattern, account_id=account_id, region_name=region_name) + + +# +# Cognito +# + + +def cognito_user_pool_arn(user_pool_id: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:cognito-idp:%s:%s:userpool/%s" + return _resource_arn(user_pool_id, pattern, account_id=account_id, region_name=region_name) + + +# +# Kinesis +# + + +def kinesis_stream_arn(stream_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:kinesis:%s:%s:stream/%s" + return _resource_arn(stream_name, pattern, account_id, region_name) + + +# +# Elasticsearch +# + + +def elasticsearch_domain_arn(domain_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:es:%s:%s:domain/%s" + return _resource_arn(domain_name, pattern, account_id=account_id, region_name=region_name) + + +# +# Firehose +# + + +def firehose_stream_arn(stream_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:firehose:%s:%s:deliverystream/%s" + return _resource_arn(stream_name, pattern, account_id=account_id, region_name=region_name) + + +# +# KMS +# + + +def kms_key_arn(key_id: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:kms:%s:%s:key/%s" + return _resource_arn(key_id, pattern, account_id=account_id, region_name=region_name) + + +def kms_alias_arn(alias_name: str, account_id: str, region_name: str): + if not alias_name.startswith("alias/"): + alias_name = "alias/" + alias_name + pattern = "arn:%s:kms:%s:%s:%s" + return _resource_arn(alias_name, pattern, account_id=account_id, region_name=region_name) + + +# +# SSM +# + + +def ssm_parameter_arn(param_name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:ssm:%s:%s:parameter/%s" + param_name = param_name.lstrip("/") + return _resource_arn(param_name, pattern, account_id=account_id, region_name=region_name) + + +# +# S3 +# + + +def s3_bucket_arn(bucket_name_or_arn: str, region="us-east-1") -> str: + bucket_name = s3_bucket_name(bucket_name_or_arn) + return f"arn:{get_partition(region)}:s3:::{bucket_name}" + + +# +# SQS +# + + +def sqs_queue_arn(queue_name: str, account_id: str, region_name: str) -> str: + queue_name = queue_name.split("/")[-1] + return "arn:%s:sqs:%s:%s:%s" % (get_partition(region_name), region_name, account_id, queue_name) + + +# +# APIGW +# + + +def apigateway_restapi_arn(api_id: str, account_id: str, region_name: str) -> str: + return "arn:%s:apigateway:%s:%s:/restapis/%s" % ( + get_partition(region_name), + region_name, + account_id, + api_id, + ) + + +def apigateway_invocations_arn(lambda_uri: str, region_name: str) -> str: + return "arn:%s:apigateway:%s:lambda:path/2015-03-31/functions/%s/invocations" % ( + get_partition(region_name), + region_name, + lambda_uri, + ) + + +# +# SNS +# + + +def sns_topic_arn(topic_name: str, account_id: str, region_name: str) -> str: + return f"arn:{get_partition(region_name)}:sns:{region_name}:{account_id}:{topic_name}" + + +# +# ECR +# + + +def ecr_repository_arn(name: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:ecr:%s:%s:repository/%s" + return _resource_arn(name, pattern, account_id=account_id, region_name=region_name) + + +# +# Route53 +# + + +def route53_resolver_firewall_rule_group_arn(id: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:route53resolver:%s:%s:firewall-rule-group/%s" + return _resource_arn(id, pattern, account_id=account_id, region_name=region_name) + + +def route53_resolver_firewall_domain_list_arn(id: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:route53resolver:%s:%s:firewall-domain-list/%s" + return _resource_arn(id, pattern, account_id=account_id, region_name=region_name) + + +def route53_resolver_firewall_rule_group_associations_arn( + id: str, account_id: str, region_name: str +) -> str: + pattern = "arn:%s:route53resolver:%s:%s:firewall-rule-group-association/%s" + return _resource_arn(id, pattern, account_id=account_id, region_name=region_name) + + +def route53_resolver_query_log_config_arn(id: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:route53resolver:%s:%s:resolver-query-log-config/%s" + return _resource_arn(id, pattern, account_id=account_id, region_name=region_name) + + +# +# SES +# + + +def ses_identity_arn(email: str, account_id: str, region_name: str) -> str: + pattern = "arn:%s:ses:%s:%s:identity/%s" + return _resource_arn(email, pattern, account_id=account_id, region_name=region_name) + + +# +# Other ARN related helpers +# + + +def opensearch_domain_name(domain_arn: str) -> str: + return domain_arn.rpartition("/")[2] + + +def firehose_name(firehose_arn: str) -> str: + return firehose_arn.split("/")[-1] + + +def kinesis_stream_name(kinesis_arn: str) -> str: + return kinesis_arn.split(":stream/")[-1] + + +def lambda_function_name(name_or_arn: str) -> str: + if ":" in name_or_arn: + arn = parse_arn(name_or_arn) + if arn["service"] != "lambda": + raise ValueError("arn is not a lambda arn %s" % name_or_arn) + + return parse_arn(name_or_arn)["resource"].split(":")[1] + else: + return name_or_arn + + +@cache +def sqs_queue_url_for_arn(queue_arn: str) -> str: + """ + Return the SQS queue URL for the given queue ARN. + """ + if "://" in queue_arn: + return queue_arn + + try: + arn = parse_arn(queue_arn) + account_id = arn["account"] + region_name = arn["region"] + queue_name = arn["resource"] + except InvalidArnException: + account_id = DEFAULT_AWS_ACCOUNT_ID + region_name = None + queue_name = queue_arn + + sqs_client = connect_to(region_name=region_name).sqs + result = sqs_client.get_queue_url(QueueName=queue_name, QueueOwnerAWSAccountId=account_id)[ + "QueueUrl" + ] + return result + + +def sqs_queue_name(queue_arn: str) -> str: + if ":" in queue_arn: + return parse_arn(queue_arn)["resource"] + else: + return queue_arn + + +def s3_bucket_name(bucket_name_or_arn: str) -> str: + return bucket_name_or_arn.split(":::")[-1] + + +def is_arn(possible_arn: str) -> bool: + try: + parse_arn(possible_arn) + return True + except InvalidArnException: + return False diff --git a/localstack-core/localstack/utils/aws/aws_responses.py b/localstack-core/localstack/utils/aws/aws_responses.py new file mode 100644 index 0000000000000..509b7a8a32889 --- /dev/null +++ b/localstack-core/localstack/utils/aws/aws_responses.py @@ -0,0 +1,226 @@ +import binascii +import datetime +import json +import re +from binascii import crc32 +from typing import Any, Dict, Optional, Union +from urllib.parse import parse_qs + +import xmltodict +from requests.models import CaseInsensitiveDict +from requests.models import Response as RequestsResponse + +from localstack.constants import APPLICATION_JSON, HEADER_CONTENT_TYPE +from localstack.utils.json import json_safe +from localstack.utils.strings import short_uid, str_startswith_ignore_case, to_bytes, to_str + +REGEX_FLAGS = re.MULTILINE | re.DOTALL + +regex_url_start = re.compile("^[a-z]{2,5}://") + + +class ErrorResponse(Exception): + def __init__(self, response): + self.response = response + + +def requests_error_response_json(message, code=500, error_type="InternalFailure"): + result = { + "Type": "User" if code < 500 else "Server", + "message": message, + "__type": error_type, + } + headers = {"x-amzn-errortype": error_type} + return requests_response(json.dumps(result), status_code=code, headers=headers) + + +def requests_error_response_xml( + message: str, + code: Optional[int] = 400, + code_string: Optional[str] = "InvalidParameter", + service: Optional[str] = None, + xmlns: Optional[str] = None, +): + response = RequestsResponse() + xmlns = xmlns or "http://%s.amazonaws.com/doc/2010-03-31/" % service + response._content = """ + Sender + {code_string} + {message} + {req_id} + """.format( + xmlns=xmlns, message=message, code_string=code_string, req_id=short_uid() + ) + response.status_code = code + return response + + +def requests_error_response_xml_signature_calculation( + message, + string_to_sign=None, + signature=None, + expires=None, + code=400, + code_string="AccessDenied", + aws_access_token="temp", +): + response = RequestsResponse() + response_template = """ + + {code_string} + {message} + {req_id} + {host_id} + """.format( + message=message, + code_string=code_string, + req_id=short_uid(), + host_id=short_uid(), + ) + + parsed_response = xmltodict.parse(response_template) + response.status_code = code + + if signature and string_to_sign or code_string == "SignatureDoesNotMatch": + bytes_signature = binascii.hexlify(bytes(signature, encoding="utf-8")) + parsed_response["Error"]["Code"] = code_string + parsed_response["Error"]["AWSAccessKeyId"] = aws_access_token + parsed_response["Error"]["StringToSign"] = string_to_sign + parsed_response["Error"]["SignatureProvided"] = signature + parsed_response["Error"]["StringToSignBytes"] = "{}".format(bytes_signature.decode("utf-8")) + set_response_content(response, xmltodict.unparse(parsed_response)) + + if expires and code_string == "AccessDenied": + server_time = datetime.datetime.utcnow().isoformat()[:-4] + expires_isoformat = datetime.datetime.fromtimestamp(int(expires)).isoformat()[:-4] + parsed_response["Error"]["Code"] = code_string + parsed_response["Error"]["Expires"] = "{}Z".format(expires_isoformat) + parsed_response["Error"]["ServerTime"] = "{}Z".format(server_time) + set_response_content(response, xmltodict.unparse(parsed_response)) + + if not signature and not expires and code_string == "AccessDenied": + set_response_content(response, xmltodict.unparse(parsed_response)) + + if response._content: + return response + + +def requests_error_response( + req_headers: Dict, + message: Union[str, bytes], + code: int = 500, + error_type: str = "InternalFailure", + service: str = None, + xmlns: str = None, +): + is_json = is_json_request(req_headers) + if is_json: + return requests_error_response_json(message=message, code=code, error_type=error_type) + return requests_error_response_xml( + message, code=code, code_string=error_type, service=service, xmlns=xmlns + ) + + +def is_json_request(req_headers: Dict) -> bool: + ctype = req_headers.get("Content-Type", "") + accept = req_headers.get("Accept", "") + return "json" in ctype or "json" in accept + + +def is_invalid_html_response(headers, content) -> bool: + content_type = headers.get("Content-Type", "") + return "text/html" in content_type and not str_startswith_ignore_case(content, " Dict[str, Any]: + system_attributes = {} + if "X-Amzn-Trace-Id" in headers: + system_attributes["AWSTraceHeader"] = { + "DataType": "String", + "StringValue": str(headers["X-Amzn-Trace-Id"]), + } + return system_attributes + + +def parse_query_string(url_or_qs: str, multi_values=False) -> Dict[str, str]: + url_or_qs = str(url_or_qs or "").strip() + # we match if the `url_or_qs` passed is maybe a URL + if regex_url_start.match(url_or_qs) and "?" not in url_or_qs: + url_or_qs = f"{url_or_qs}?" + url_or_qs = url_or_qs.split("?", maxsplit=1)[-1] + result = parse_qs(url_or_qs, keep_blank_values=True) + if not multi_values: + result = {k: v[0] for k, v in result.items()} + return result + + +def calculate_crc32(content: Union[str, bytes]) -> int: + return crc32(to_bytes(content)) & 0xFFFFFFFF + + +class LambdaResponse: + """Helper class to support multi_value_headers in Lambda responses""" + + def __init__(self): + self._content = False + self.status_code = None + self.multi_value_headers = CaseInsensitiveDict() + self.headers = CaseInsensitiveDict() + + @property + def content(self): + return self._content diff --git a/localstack-core/localstack/utils/aws/aws_stack.py b/localstack-core/localstack/utils/aws/aws_stack.py new file mode 100644 index 0000000000000..8ca6107337b49 --- /dev/null +++ b/localstack-core/localstack/utils/aws/aws_stack.py @@ -0,0 +1,100 @@ +import logging +import re +import socket +from functools import lru_cache +from typing import List, Union + +import boto3 + +from localstack import config +from localstack.config import S3_VIRTUAL_HOSTNAME +from localstack.constants import ( + LOCALHOST, +) +from localstack.utils.strings import is_string_or_bytes, to_str + +# set up logger +LOG = logging.getLogger(__name__) + +# cached value used to determine the DNS status of the S3 hostname (whether it can be resolved properly) +CACHE_S3_HOSTNAME_DNS_STATUS = None + + +@lru_cache() +def get_valid_regions(): + session = boto3.Session() + valid_regions = set() + for partition in set(session.get_available_partitions()): + for region in session.get_available_regions("sns", partition): + valid_regions.add(region) + return valid_regions + + +# FIXME: AWS recommends use of SSM parameter store to determine per region availability +# https://github.com/aws/aws-sdk/issues/206#issuecomment-1471354853 +@lru_cache() +def get_valid_regions_for_service(service_name): + session = boto3.Session() + regions = list(session.get_available_regions(service_name)) + regions.extend(session.get_available_regions("cloudwatch", partition_name="aws-us-gov")) + regions.extend(session.get_available_regions("cloudwatch", partition_name="aws-cn")) + return regions + + +def get_boto3_region() -> str: + """Return the region name, as determined from the environment when creating a new boto3 session""" + return boto3.session.Session().region_name + + +def get_local_service_url(service_name_or_port: Union[str, int]) -> str: + """Return the local service URL for the given service name or port.""" + # TODO(srw): we don't need to differentiate on service name any more, so remove the argument + if isinstance(service_name_or_port, int): + return f"{config.get_protocol()}://{LOCALHOST}:{service_name_or_port}" + return config.internal_service_url() + + +def get_s3_hostname(): + global CACHE_S3_HOSTNAME_DNS_STATUS + if CACHE_S3_HOSTNAME_DNS_STATUS is None: + try: + assert socket.gethostbyname(S3_VIRTUAL_HOSTNAME) + CACHE_S3_HOSTNAME_DNS_STATUS = True + except socket.error: + CACHE_S3_HOSTNAME_DNS_STATUS = False + if CACHE_S3_HOSTNAME_DNS_STATUS: + return S3_VIRTUAL_HOSTNAME + return LOCALHOST + + +def fix_account_id_in_arns( + response, replacement: str, colon_delimiter: str = ":", existing: Union[str, List[str]] = None +): + """Fix the account ID in the ARNs returned in the given Flask response or string""" + from moto.core import DEFAULT_ACCOUNT_ID + + existing = existing or ["123456789", "1234567890", DEFAULT_ACCOUNT_ID] + existing = existing if isinstance(existing, list) else [existing] + is_str_obj = is_string_or_bytes(response) + content = to_str(response if is_str_obj else response._content) + + replacement = r"arn{col}aws{col}\1{col}\2{col}{acc}{col}".format( + col=colon_delimiter, acc=replacement + ) + for acc_id in existing: + regex = r"arn{col}aws{col}([^:%]+){col}([^:%]*){col}{acc}{col}".format( + col=colon_delimiter, acc=acc_id + ) + content = re.sub(regex, replacement, content) + + if not is_str_obj: + response._content = content + response.headers["Content-Length"] = len(response._content) + return response + return content + + +def inject_test_credentials_into_env(env): + if "AWS_ACCESS_KEY_ID" not in env and "AWS_SECRET_ACCESS_KEY" not in env: + env["AWS_ACCESS_KEY_ID"] = "test" + env["AWS_SECRET_ACCESS_KEY"] = "test" diff --git a/localstack-core/localstack/utils/aws/client.py b/localstack-core/localstack/utils/aws/client.py new file mode 100644 index 0000000000000..8e263013f88a2 --- /dev/null +++ b/localstack-core/localstack/utils/aws/client.py @@ -0,0 +1,83 @@ +import requests +from botocore.auth import BaseSigner +from botocore.awsrequest import AWSRequest + + +class SigningHttpClient: + """ + A wrapper around ``requests`` that uses botocore to sign HTTP requests using a ``botocore.auth.BaseSigner``. + + For example, using a Sig4QueryAuth signer, invocations to ``client.get( + "http://localhost:4566/000000000000/test-queue")`` it will transparently change the URL to something like: + + http://localhost:4566/000000000000/test-queue + ?X-Amz-Algorithm=AWS4-HMAC-SHA256 + &X-Amz-Credential=__test_call__%2F20220513%2Fus-east-1%2Fsqs%2Faws4_request + &X-Amz-Date=20220513T192006Z + &X-Amz-Expires=3600 + &X-Amz-SignedHeaders=host + &X-Amz-Signature=ae39eb839d0501d731d5dccffd1e6e86fab53749f956caabbb8211b6593f5f9d + + You can also create a client with an endpoint_url set, where you can then make requests with the hostname part. + For example, to create a raw GetQueueUrl request, run: + + client = SigningHttpClient(signer, endpoint_url="https://sqs.us-east-1.amazonaws.com") + client.post("/", params={"Action", "GetQueueUrl", "QueueName": "my-queue"}) + """ + + def __init__( + self, signer: BaseSigner, session: requests.Session = None, endpoint_url: str = None + ): + self.signer = signer + self.session = session or requests.Session() + self.endpoint_url = endpoint_url + + def request(self, method, url, **kwargs) -> requests.Response: + if url.startswith("/"): + if not self.endpoint_url: + raise ValueError("no hostname provided in url and no endpoint_url set") + + url = self.endpoint_url.rstrip("/") + url + + request = AWSRequest( + method=method, + url=url, + data=kwargs.pop("data", None), + params=kwargs.pop("params", None), + headers=kwargs.pop("headers", None), + ) + request = self.sign(request) + + url = request.url + method = request.method + + kwargs["data"] = request.data + kwargs["params"] = request.params + kwargs["headers"] = request.headers + + return self.session.request(method, url, **kwargs) + + def get(self, url, **kwargs): + return self.request("GET", url, **kwargs) + + def head(self, url, **kwargs): + return self.request("HEAD", url, **kwargs) + + def post(self, url, **kwargs): + return self.request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.request("PUT", url, **kwargs) + + def patch(self, url, **kwargs): + return self.request("PATCH", url, **kwargs) + + def delete(self, url, **kwargs): + return self.request("DELETE", url, **kwargs) + + def options(self, url, **kwargs): + return self.request("OPTIONS", url, **kwargs) + + def sign(self, request: AWSRequest) -> AWSRequest: + self.signer.add_auth(request) + return request diff --git a/localstack-core/localstack/utils/aws/client_types.py b/localstack-core/localstack/utils/aws/client_types.py new file mode 100644 index 0000000000000..1fd9f3a84df5e --- /dev/null +++ b/localstack-core/localstack/utils/aws/client_types.py @@ -0,0 +1,296 @@ +import abc +from typing import TYPE_CHECKING, Union + +""" +Installing additional types locally: + +pip install "boto3-stubs[application-autoscaling]" + +Ideally please add them to the list below and in our pyproject.toml typehint dependencies as well + +""" +if TYPE_CHECKING: + from mypy_boto3_acm import ACMClient + from mypy_boto3_acm_pca import ACMPCAClient + from mypy_boto3_amplify import AmplifyClient + from mypy_boto3_apigateway import APIGatewayClient + from mypy_boto3_apigatewayv2 import ApiGatewayV2Client + from mypy_boto3_appconfig import AppConfigClient + from mypy_boto3_appconfigdata import AppConfigDataClient + from mypy_boto3_application_autoscaling import ApplicationAutoScalingClient + from mypy_boto3_appsync import AppSyncClient + from mypy_boto3_athena import AthenaClient + from mypy_boto3_autoscaling import AutoScalingClient + from mypy_boto3_backup import BackupClient + from mypy_boto3_batch import BatchClient + from mypy_boto3_ce import CostExplorerClient + from mypy_boto3_cloudcontrol import CloudControlApiClient + from mypy_boto3_cloudformation import CloudFormationClient + from mypy_boto3_cloudfront import CloudFrontClient + from mypy_boto3_cloudtrail import CloudTrailClient + from mypy_boto3_cloudwatch import CloudWatchClient + from mypy_boto3_codebuild import CodeBuildClient + from mypy_boto3_codecommit import CodeCommitClient + from mypy_boto3_codeconnections import CodeConnectionsClient + from mypy_boto3_codedeploy import CodeDeployClient + from mypy_boto3_codepipeline import CodePipelineClient + from mypy_boto3_codestar_connections import CodeStarconnectionsClient + from mypy_boto3_cognito_identity import CognitoIdentityClient + from mypy_boto3_cognito_idp import CognitoIdentityProviderClient + from mypy_boto3_dms import DatabaseMigrationServiceClient + from mypy_boto3_docdb import DocDBClient + from mypy_boto3_dynamodb import DynamoDBClient + from mypy_boto3_dynamodbstreams import DynamoDBStreamsClient + from mypy_boto3_ec2 import EC2Client + from mypy_boto3_ecr import ECRClient + from mypy_boto3_ecs import ECSClient + from mypy_boto3_efs import EFSClient + from mypy_boto3_eks import EKSClient + from mypy_boto3_elasticache import ElastiCacheClient + from mypy_boto3_elasticbeanstalk import ElasticBeanstalkClient + from mypy_boto3_elbv2 import ElasticLoadBalancingv2Client + from mypy_boto3_emr import EMRClient + from mypy_boto3_emr_serverless import EMRServerlessClient + from mypy_boto3_es import ElasticsearchServiceClient + from mypy_boto3_events import EventBridgeClient + from mypy_boto3_firehose import FirehoseClient + from mypy_boto3_fis import FISClient + from mypy_boto3_glacier import GlacierClient + from mypy_boto3_glue import GlueClient + from mypy_boto3_iam import IAMClient + from mypy_boto3_identitystore import IdentityStoreClient + from mypy_boto3_iot import IoTClient + from mypy_boto3_iot_data import IoTDataPlaneClient + from mypy_boto3_iotanalytics import IoTAnalyticsClient + from mypy_boto3_iotwireless import IoTWirelessClient + from mypy_boto3_kafka import KafkaClient + from mypy_boto3_kinesis import KinesisClient + from mypy_boto3_kinesisanalytics import KinesisAnalyticsClient + from mypy_boto3_kinesisanalyticsv2 import KinesisAnalyticsV2Client + from mypy_boto3_kms import KMSClient + from mypy_boto3_lakeformation import LakeFormationClient + from mypy_boto3_lambda import LambdaClient + from mypy_boto3_logs import CloudWatchLogsClient + from mypy_boto3_managedblockchain import ManagedBlockchainClient + from mypy_boto3_mediaconvert import MediaConvertClient + from mypy_boto3_mediastore import MediaStoreClient + from mypy_boto3_mq import MQClient + from mypy_boto3_mwaa import MWAAClient + from mypy_boto3_neptune import NeptuneClient + from mypy_boto3_opensearch import OpenSearchServiceClient + from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_pi import PIClient + from mypy_boto3_pinpoint import PinpointClient + from mypy_boto3_pipes import EventBridgePipesClient + from mypy_boto3_qldb import QLDBClient + from mypy_boto3_qldb_session import QLDBSessionClient + from mypy_boto3_rds import RDSClient + from mypy_boto3_rds_data import RDSDataServiceClient + from mypy_boto3_redshift import RedshiftClient + from mypy_boto3_redshift_data import RedshiftDataAPIServiceClient + from mypy_boto3_resource_groups import ResourceGroupsClient + from mypy_boto3_resourcegroupstaggingapi import ResourceGroupsTaggingAPIClient + from mypy_boto3_route53 import Route53Client + from mypy_boto3_route53resolver import Route53ResolverClient + from mypy_boto3_s3 import S3Client + from mypy_boto3_s3control import S3ControlClient + from mypy_boto3_sagemaker import SageMakerClient + from mypy_boto3_sagemaker_runtime import SageMakerRuntimeClient + from mypy_boto3_secretsmanager import SecretsManagerClient + from mypy_boto3_serverlessrepo import ServerlessApplicationRepositoryClient + from mypy_boto3_servicediscovery import ServiceDiscoveryClient + from mypy_boto3_ses import SESClient + from mypy_boto3_sesv2 import SESV2Client + from mypy_boto3_sns import SNSClient + from mypy_boto3_sqs import SQSClient + from mypy_boto3_ssm import SSMClient + from mypy_boto3_sso_admin import SSOAdminClient + from mypy_boto3_stepfunctions import SFNClient + from mypy_boto3_sts import STSClient + from mypy_boto3_timestream_query import TimestreamQueryClient + from mypy_boto3_timestream_write import TimestreamWriteClient + from mypy_boto3_transcribe import TranscribeServiceClient + from mypy_boto3_verifiedpermissions import VerifiedPermissionsClient + from mypy_boto3_wafv2 import WAFV2Client + from mypy_boto3_xray import XRayClient + + from localstack.aws.connect import MetadataRequestInjector + + +class TypedServiceClientFactory(abc.ABC): + acm: Union["ACMClient", "MetadataRequestInjector[ACMClient]"] + acm_pca: Union["ACMPCAClient", "MetadataRequestInjector[ACMPCAClient]"] + amplify: Union["AmplifyClient", "MetadataRequestInjector[AmplifyClient]"] + apigateway: Union["APIGatewayClient", "MetadataRequestInjector[APIGatewayClient]"] + apigatewayv2: Union["ApiGatewayV2Client", "MetadataRequestInjector[ApiGatewayV2Client]"] + appconfig: Union["AppConfigClient", "MetadataRequestInjector[AppConfigClient]"] + appconfigdata: Union["AppConfigDataClient", "MetadataRequestInjector[AppConfigDataClient]"] + appsync: Union["AppSyncClient", "MetadataRequestInjector[AppSyncClient]"] + application_autoscaling: Union[ + "ApplicationAutoScalingClient", "MetadataRequestInjector[ApplicationAutoScalingClient]" + ] + athena: Union["AthenaClient", "MetadataRequestInjector[AthenaClient]"] + autoscaling: Union["AutoScalingClient", "MetadataRequestInjector[AutoScalingClient]"] + backup: Union["BackupClient", "MetadataRequestInjector[BackupClient]"] + batch: Union["BatchClient", "MetadataRequestInjector[BatchClient]"] + ce: Union["CostExplorerClient", "MetadataRequestInjector[CostExplorerClient]"] + cloudcontrol: Union["CloudControlApiClient", "MetadataRequestInjector[CloudControlApiClient]"] + cloudformation: Union["CloudFormationClient", "MetadataRequestInjector[CloudFormationClient]"] + cloudfront: Union["CloudFrontClient", "MetadataRequestInjector[CloudFrontClient]"] + cloudtrail: Union["CloudTrailClient", "MetadataRequestInjector[CloudTrailClient]"] + cloudwatch: Union["CloudWatchClient", "MetadataRequestInjector[CloudWatchClient]"] + codebuild: Union["CodeBuildClient", "MetadataRequestInjector[CodeBuildClient]"] + codecommit: Union["CodeCommitClient", "MetadataRequestInjector[CodeCommitClient]"] + codeconnections: Union[ + "CodeConnectionsClient", "MetadataRequestInjector[CodeConnectionsClient]" + ] + codedeploy: Union["CodeDeployClient", "MetadataRequestInjector[CodeDeployClient]"] + codepipeline: Union["CodePipelineClient", "MetadataRequestInjector[CodePipelineClient]"] + codestar_connections: Union[ + "CodeStarconnectionsClient", "MetadataRequestInjector[CodeStarconnectionsClient]" + ] + cognito_identity: Union[ + "CognitoIdentityClient", "MetadataRequestInjector[CognitoIdentityClient]" + ] + cognito_idp: Union[ + "CognitoIdentityProviderClient", "MetadataRequestInjector[CognitoIdentityProviderClient]" + ] + dms: Union[ + "DatabaseMigrationServiceClient", "MetadataRequestInjector[DatabaseMigrationServiceClient]" + ] + docdb: Union["DocDBClient", "MetadataRequestInjector[DocDBClient]"] + dynamodb: Union["DynamoDBClient", "MetadataRequestInjector[DynamoDBClient]"] + dynamodbstreams: Union[ + "DynamoDBStreamsClient", "MetadataRequestInjector[DynamoDBStreamsClient]" + ] + ec2: Union["EC2Client", "MetadataRequestInjector[EC2Client]"] + ecr: Union["ECRClient", "MetadataRequestInjector[ECRClient]"] + ecs: Union["ECSClient", "MetadataRequestInjector[ECSClient]"] + efs: Union["EFSClient", "MetadataRequestInjector[EFSClient]"] + eks: Union["EKSClient", "MetadataRequestInjector[EKSClient]"] + elasticache: Union["ElastiCacheClient", "MetadataRequestInjector[ElastiCacheClient]"] + elasticbeanstalk: Union[ + "ElasticBeanstalkClient", "MetadataRequestInjector[ElasticBeanstalkClient]" + ] + elbv2: Union[ + "ElasticLoadBalancingv2Client", "MetadataRequestInjector[ElasticLoadBalancingv2Client]" + ] + emr: Union["EMRClient", "MetadataRequestInjector[EMRClient]"] + emr_serverless: Union["EMRServerlessClient", "MetadataRequestInjector[EMRServerlessClient]"] + es: Union["ElasticsearchServiceClient", "MetadataRequestInjector[ElasticsearchServiceClient]"] + events: Union["EventBridgeClient", "MetadataRequestInjector[EventBridgeClient]"] + firehose: Union["FirehoseClient", "MetadataRequestInjector[FirehoseClient]"] + fis: Union["FISClient", "MetadataRequestInjector[FISClient]"] + glacier: Union["GlacierClient", "MetadataRequestInjector[GlacierClient]"] + glue: Union["GlueClient", "MetadataRequestInjector[GlueClient]"] + iam: Union["IAMClient", "MetadataRequestInjector[IAMClient]"] + identitystore: Union["IdentityStoreClient", "MetadataRequestInjector[IdentityStoreClient]"] + iot: Union["IoTClient", "MetadataRequestInjector[IoTClient]"] + iot_data: Union["IoTDataPlaneClient", "MetadataRequestInjector[IoTDataPlaneClient]"] + iotanalytics: Union["IoTAnalyticsClient", "MetadataRequestInjector[IoTAnalyticsClient]"] + iotwireless: Union["IoTWirelessClient", "MetadataRequestInjector[IoTWirelessClient]"] + kafka: Union["KafkaClient", "MetadataRequestInjector[KafkaClient]"] + kinesis: Union["KinesisClient", "MetadataRequestInjector[KinesisClient]"] + kinesisanalytics: Union[ + "KinesisAnalyticsClient", "MetadataRequestInjector[KinesisAnalyticsClient]" + ] + kinesisanalyticsv2: Union[ + "KinesisAnalyticsV2Client", "MetadataRequestInjector[KinesisAnalyticsV2Client]" + ] + kms: Union["KMSClient", "MetadataRequestInjector[KMSClient]"] + lakeformation: Union["LakeFormationClient", "MetadataRequestInjector[LakeFormationClient]"] + lambda_: Union["LambdaClient", "MetadataRequestInjector[LambdaClient]"] + logs: Union["CloudWatchLogsClient", "MetadataRequestInjector[CloudWatchLogsClient]"] + managedblockchain: Union[ + "ManagedBlockchainClient", "MetadataRequestInjector[ManagedBlockchainClient]" + ] + mediaconvert: Union["MediaConvertClient", "MetadataRequestInjector[MediaConvertClient]"] + mediastore: Union["MediaStoreClient", "MetadataRequestInjector[MediaStoreClient]"] + mq: Union["MQClient", "MetadataRequestInjector[MQClient]"] + mwaa: Union["MWAAClient", "MetadataRequestInjector[MWAAClient]"] + neptune: Union["NeptuneClient", "MetadataRequestInjector[NeptuneClient]"] + opensearch: Union["OpenSearchServiceClient", "MetadataRequestInjector[OpenSearchServiceClient]"] + organizations: Union["OrganizationsClient", "MetadataRequestInjector[OrganizationsClient]"] + pi: Union["PIClient", "MetadataRequestInjector[PIClient]"] + pinpoint: Union["PinpointClient", "MetadataRequestInjector[PinpointClient]"] + pipes: Union["EventBridgePipesClient", "MetadataRequestInjector[EventBridgePipesClient]"] + qldb: Union["QLDBClient", "MetadataRequestInjector[QLDBClient]"] + qldb_session: Union["QLDBSessionClient", "MetadataRequestInjector[QLDBSessionClient]"] + rds: Union["RDSClient", "MetadataRequestInjector[RDSClient]"] + rds_data: Union["RDSDataServiceClient", "MetadataRequestInjector[RDSDataServiceClient]"] + redshift: Union["RedshiftClient", "MetadataRequestInjector[RedshiftClient]"] + redshift_data: Union[ + "RedshiftDataAPIServiceClient", "MetadataRequestInjector[RedshiftDataAPIServiceClient]" + ] + resource_groups: Union["ResourceGroupsClient", "MetadataRequestInjector[ResourceGroupsClient]"] + resourcegroupstaggingapi: Union[ + "ResourceGroupsTaggingAPIClient", "MetadataRequestInjector[ResourceGroupsTaggingAPIClient]" + ] + route53: Union["Route53Client", "MetadataRequestInjector[Route53Client]"] + route53resolver: Union[ + "Route53ResolverClient", "MetadataRequestInjector[Route53ResolverClient]" + ] + s3: Union["S3Client", "MetadataRequestInjector[S3Client]"] + s3control: Union["S3ControlClient", "MetadataRequestInjector[S3ControlClient]"] + sagemaker: Union["SageMakerClient", "MetadataRequestInjector[SageMakerClient]"] + sagemaker_runtime: Union[ + "SageMakerRuntimeClient", "MetadataRequestInjector[SageMakerRuntimeClient]" + ] + secretsmanager: Union["SecretsManagerClient", "MetadataRequestInjector[SecretsManagerClient]"] + serverlessrepo: Union[ + "ServerlessApplicationRepositoryClient", + "MetadataRequestInjector[ServerlessApplicationRepositoryClient]", + ] + servicediscovery: Union[ + "ServiceDiscoveryClient", "MetadataRequestInjector[ServiceDiscoveryClient]" + ] + ses: Union["SESClient", "MetadataRequestInjector[SESClient]"] + sesv2: Union["SESV2Client", "MetadataRequestInjector[SESV2Client]"] + sns: Union["SNSClient", "MetadataRequestInjector[SNSClient]"] + sqs: Union["SQSClient", "MetadataRequestInjector[SQSClient]"] + sqs_query: Union["SQSClient", "MetadataRequestInjector[SQSClient]"] + ssm: Union["SSMClient", "MetadataRequestInjector[SSMClient]"] + sso_admin: Union["SSOAdminClient", "MetadataRequestInjector[SSOAdminClient]"] + stepfunctions: Union["SFNClient", "MetadataRequestInjector[SFNClient]"] + sts: Union["STSClient", "MetadataRequestInjector[STSClient]"] + timestream_query: Union[ + "TimestreamQueryClient", "MetadataRequestInjector[TimestreamQueryClient]" + ] + timestream_write: Union[ + "TimestreamWriteClient", "MetadataRequestInjector[TimestreamWriteClient]" + ] + transcribe: Union["TranscribeServiceClient", "MetadataRequestInjector[TranscribeServiceClient]"] + verifiedpermissions: Union[ + "VerifiedPermissionsClient", "MetadataRequestInjector[VerifiedPermissionsClient]" + ] + wafv2: Union["WAFV2Client", "MetadataRequestInjector[WAFV2Client]"] + xray: Union["XRayClient", "MetadataRequestInjector[XRayClient]"] + + +class ServicePrincipal(str): + """ + Class containing defined service principals. + To add to this list, please look up the correct service principal name for the service. + They are in the format `.amazonaws.com`, and can be found in the AWS IAM documentation. + It is usually found under the `Service linked Roles` link for the respective service. + https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html + + You can also find a list of service principals here: + https://gist.github.com/shortjared/4c1e3fe52bdfa47522cfe5b41e5d6f22 + + To save some space in our DTOs, we only add the `` part of the service principal here. + """ + + apigateway = "apigateway" + cloudformation = "cloudformation" + dms = "dms" + edgelambda = "edgelambda" + events = "events" + firehose = "firehose" + lambda_ = "lambda" + logs = "logs" + pipes = "pipes" + s3 = "s3" + sns = "sns" + sqs = "sqs" + states = "states" diff --git a/localstack-core/localstack/utils/aws/dead_letter_queue.py b/localstack-core/localstack/utils/aws/dead_letter_queue.py new file mode 100644 index 0000000000000..9fdd8c70ec5e3 --- /dev/null +++ b/localstack-core/localstack/utils/aws/dead_letter_queue.py @@ -0,0 +1,147 @@ +import json +import logging +import uuid +from typing import Dict, List + +from localstack.aws.connect import connect_to +from localstack.utils.aws import arns +from localstack.utils.strings import convert_to_printable_chars, first_char_to_upper + +LOG = logging.getLogger(__name__) + + +def sns_error_to_dead_letter_queue( + sns_subscriber: dict, + message: str, + error: str, + **kwargs, +): + policy = json.loads(sns_subscriber.get("RedrivePolicy") or "{}") + target_arn = policy.get("deadLetterTargetArn") + if not target_arn: + return + if not_supported := ( + set(kwargs) - {"MessageAttributes", "MessageGroupId", "MessageDeduplicationId"} + ): + LOG.warning( + "Not publishing to the DLQ - invalid arguments passed to the DLQ '%s'", not_supported + ) + return + event = { + "message": message, + **kwargs, + } + return _send_to_dead_letter_queue(sns_subscriber["SubscriptionArn"], target_arn, event, error) + + +def _send_to_dead_letter_queue(source_arn: str, dlq_arn: str, event: Dict, error, role: str = None): + if not dlq_arn: + return + LOG.info("Sending failed execution %s to dead letter queue %s", source_arn, dlq_arn) + messages = _prepare_messages_to_dlq(source_arn, event, error) + source_service = arns.extract_service_from_arn(source_arn) + region = arns.extract_region_from_arn(dlq_arn) + if role: + clients = connect_to.with_assumed_role( + role_arn=role, service_principal=source_service, region_name=region + ) + else: + clients = connect_to(region_name=region) + if ":sqs:" in dlq_arn: + queue_url = arns.sqs_queue_url_for_arn(dlq_arn) + sqs_client = clients.sqs.request_metadata( + source_arn=source_arn, service_principal=source_service + ) + error = None + result_code = None + try: + result = sqs_client.send_message_batch(QueueUrl=queue_url, Entries=messages) + result_code = result.get("ResponseMetadata", {}).get("HTTPStatusCode") + except Exception as e: + error = e + if error or not result_code or result_code >= 400: + msg = "Unable to send message to dead letter queue %s (code %s): %s" % ( + queue_url, + result_code, + error, + ) + if "InvalidMessageContents" in str(error): + msg += f" - messages: {messages}" + LOG.info(msg) + raise Exception(msg) + elif ":sns:" in dlq_arn: + sns_client = clients.sns.request_metadata( + source_arn=source_arn, service_principal=source_service + ) + for message in messages: + sns_client.publish( + TopicArn=dlq_arn, + Message=message["MessageBody"], + MessageAttributes=message["MessageAttributes"], + ) + else: + LOG.warning("Unsupported dead letter queue type: %s", dlq_arn) + return dlq_arn + + +def _prepare_messages_to_dlq(source_arn: str, event: Dict, error) -> List[Dict]: + messages = [] + custom_attrs = { + "RequestID": {"DataType": "String", "StringValue": str(uuid.uuid4())}, + "ErrorCode": {"DataType": "String", "StringValue": "200"}, + "ErrorMessage": {"DataType": "String", "StringValue": str(error)}, + } + if ":sqs:" in source_arn: + custom_attrs["ErrorMessage"]["StringValue"] = str(error.result) + for record in event.get("Records", []): + msg_attrs = message_attributes_to_upper(record.get("messageAttributes")) + message_attrs = {**msg_attrs, **custom_attrs} + messages.append( + { + "Id": record.get("messageId"), + "MessageBody": record.get("body"), + "MessageAttributes": message_attrs, + } + ) + elif ":sns:" in source_arn: + # event can also contain: MessageAttributes, MessageGroupId, MessageDeduplicationId + message = { + "Id": str(uuid.uuid4()), + "MessageBody": event.pop("message"), + **event, + } + messages.append(message) + + elif ":lambda:" in source_arn: + custom_attrs["ErrorCode"]["DataType"] = "Number" + # not sure about what type of error can come here + try: + error_message = json.loads(error.result)["errorMessage"] + custom_attrs["ErrorMessage"]["StringValue"] = error_message + except (ValueError, KeyError): + # using old behaviour + custom_attrs["ErrorMessage"]["StringValue"] = str(error) + + messages.append( + { + "Id": str(uuid.uuid4()), + "MessageBody": json.dumps(event), + "MessageAttributes": custom_attrs, + } + ) + # make sure we only have printable strings in the message attributes + for message in messages: + if message.get("MessageAttributes"): + message["MessageAttributes"] = convert_to_printable_chars(message["MessageAttributes"]) + return messages + + +def message_attributes_to_upper(message_attrs: Dict) -> Dict: + """Convert message attribute details (first characters) to upper case (e.g., StringValue, DataType).""" + message_attrs = message_attrs or {} + for _, attr in message_attrs.items(): + if not isinstance(attr, dict): + continue + for key, value in dict(attr).items(): + attr[first_char_to_upper(key)] = attr.pop(key) + return message_attrs diff --git a/localstack-core/localstack/utils/aws/message_forwarding.py b/localstack-core/localstack/utils/aws/message_forwarding.py new file mode 100644 index 0000000000000..ad28c015b9485 --- /dev/null +++ b/localstack-core/localstack/utils/aws/message_forwarding.py @@ -0,0 +1,308 @@ +import base64 +import json +import logging +import re +import uuid +from typing import Dict, Optional + +from moto.events.models import events_backends + +from localstack.aws.connect import connect_to +from localstack.services.apigateway.legacy.helpers import extract_query_string_params +from localstack.utils import collections +from localstack.utils.aws.arns import ( + extract_account_id_from_arn, + extract_region_from_arn, + firehose_name, + sqs_queue_url_for_arn, +) +from localstack.utils.http import add_path_parameters_to_url, add_query_params_to_url +from localstack.utils.http import safe_requests as requests +from localstack.utils.strings import to_bytes, to_str +from localstack.utils.time import now_utc + +LOG = logging.getLogger(__name__) + +AUTH_BASIC = "BASIC" +AUTH_API_KEY = "API_KEY" +AUTH_OAUTH = "OAUTH_CLIENT_CREDENTIALS" + + +# TODO: refactor/split this. too much here is service specific +def send_event_to_target( + target_arn: str, + event: Dict, + target_attributes: Dict = None, + asynchronous: bool = True, + target: Dict = None, + role: str = None, + source_arn: str = None, + source_service: str = None, + events_source: str = None, # optional data for publishing to EventBridge + events_detail_type: str = None, # optional data for publishing to EventBridge +): + region = extract_region_from_arn(target_arn) + account_id = extract_account_id_from_arn(source_arn) + + if target is None: + target = {} + if role: + clients = connect_to.with_assumed_role( + role_arn=role, service_principal=source_service, region_name=region + ) + else: + clients = connect_to(aws_access_key_id=account_id, region_name=region) + + if ":lambda:" in target_arn: + lambda_client = clients.lambda_.request_metadata( + service_principal=source_service, source_arn=source_arn + ) + lambda_client.invoke( + FunctionName=target_arn, + Payload=to_bytes(json.dumps(event)), + InvocationType="Event" if asynchronous else "RequestResponse", + ) + + elif ":sns:" in target_arn: + sns_client = clients.sns.request_metadata( + service_principal=source_service, source_arn=source_arn + ) + sns_client.publish(TopicArn=target_arn, Message=json.dumps(event)) + + elif ":sqs:" in target_arn: + sqs_client = clients.sqs.request_metadata( + service_principal=source_service, source_arn=source_arn + ) + queue_url = sqs_queue_url_for_arn(target_arn) + msg_group_id = collections.get_safe(target_attributes, "$.SqsParameters.MessageGroupId") + kwargs = {"MessageGroupId": msg_group_id} if msg_group_id else {} + sqs_client.send_message( + QueueUrl=queue_url, MessageBody=json.dumps(event, separators=(",", ":")), **kwargs + ) + + elif ":states:" in target_arn: + account_id = extract_account_id_from_arn(target_arn) + stepfunctions_client = connect_to( + aws_access_key_id=account_id, region_name=region + ).stepfunctions + stepfunctions_client.start_execution(stateMachineArn=target_arn, input=json.dumps(event)) + + elif ":firehose:" in target_arn: + delivery_stream_name = firehose_name(target_arn) + firehose_client = clients.firehose.request_metadata( + service_principal=source_service, source_arn=source_arn + ) + firehose_client.put_record( + DeliveryStreamName=delivery_stream_name, + Record={"Data": to_bytes(json.dumps(event))}, + ) + + elif ":events:" in target_arn: + if ":api-destination/" in target_arn or ":destination/" in target_arn: + send_event_to_api_destination(target_arn, event, target.get("HttpParameters")) + + else: + events_client = clients.events.request_metadata( + service_principal=source_service, source_arn=source_arn + ) + eventbus_name = target_arn.split(":")[-1].split("/")[-1] + detail = event.get("detail") or event + resources = event.get("resources") or [source_arn] if source_arn else [] + events_client.put_events( + Entries=[ + { + "EventBusName": eventbus_name, + "Source": events_source or event.get("source", source_service) or "", + "DetailType": events_detail_type or event.get("detail-type", ""), + "Detail": json.dumps(detail), + "Resources": resources, + } + ] + ) + + elif ":kinesis:" in target_arn: + partition_key_path = collections.get_safe( + target_attributes, + "$.KinesisParameters.PartitionKeyPath", + default_value="$.id", + ) + + stream_name = target_arn.split("/")[-1] + partition_key = collections.get_safe(event, partition_key_path, event["id"]) + kinesis_client = clients.kinesis.request_metadata( + service_principal=source_service, source_arn=source_arn + ) + + kinesis_client.put_record( + StreamName=stream_name, + Data=to_bytes(json.dumps(event)), + PartitionKey=partition_key, + ) + + elif ":logs:" in target_arn: + log_group_name = target_arn.split(":")[6] + logs_client = clients.logs.request_metadata( + service_principal=source_service, source_arn=source_arn + ) + log_stream_name = str(uuid.uuid4()) + logs_client.create_log_stream(logGroupName=log_group_name, logStreamName=log_stream_name) + logs_client.put_log_events( + logGroupName=log_group_name, + logStreamName=log_stream_name, + logEvents=[{"timestamp": now_utc(millis=True), "message": json.dumps(event)}], + ) + else: + LOG.warning('Unsupported Events rule target ARN: "%s"', target_arn) + + +def auth_keys_from_connection(connection: Dict): + headers = {} + + auth_type = connection.get("AuthorizationType").upper() + auth_parameters = connection.get("AuthParameters") + if auth_type == AUTH_BASIC: + basic_auth_parameters = auth_parameters.get("BasicAuthParameters", {}) + username = basic_auth_parameters.get("Username", "") + password = basic_auth_parameters.get("Password", "") + auth = "Basic " + to_str( + base64.b64encode("{}:{}".format(username, password).encode("ascii")) + ) + headers.update({"authorization": auth}) + + if auth_type == AUTH_API_KEY: + api_key_parameters = auth_parameters.get("ApiKeyAuthParameters", {}) + api_key_name = api_key_parameters.get("ApiKeyName", "") + api_key_value = api_key_parameters.get("ApiKeyValue", "") + headers.update({api_key_name: api_key_value}) + + if auth_type == AUTH_OAUTH: + oauth_parameters = auth_parameters.get("OAuthParameters", {}) + oauth_method = oauth_parameters.get("HttpMethod") + + oauth_http_parameters = oauth_parameters.get("OAuthHttpParameters", {}) + oauth_endpoint = oauth_parameters.get("AuthorizationEndpoint", "") + query_object = list_of_parameters_to_object( + oauth_http_parameters.get("QueryStringParameters", []) + ) + oauth_endpoint = add_query_params_to_url(oauth_endpoint, query_object) + + client_parameters = oauth_parameters.get("ClientParameters", {}) + client_id = client_parameters.get("ClientID", "") + client_secret = client_parameters.get("ClientSecret", "") + + oauth_body = list_of_parameters_to_object(oauth_http_parameters.get("BodyParameters", [])) + oauth_body.update({"client_id": client_id, "client_secret": client_secret}) + + oauth_header = list_of_parameters_to_object( + oauth_http_parameters.get("HeaderParameters", []) + ) + oauth_result = requests.request( + method=oauth_method, + url=oauth_endpoint, + data=json.dumps(oauth_body), + headers=oauth_header, + ) + oauth_data = json.loads(oauth_result.text) + + token_type = oauth_data.get("token_type", "") + access_token = oauth_data.get("access_token", "") + auth_header = "{} {}".format(token_type, access_token) + headers.update({"authorization": auth_header}) + + return headers + + +def list_of_parameters_to_object(items): + return {item.get("Key"): item.get("Value") for item in items} + + +def send_event_to_api_destination(target_arn, event, http_parameters: Optional[Dict] = None): + """Send an event to an EventBridge API destination + See https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-api-destinations.html""" + + # ARN format: ...:api-destination/{name}/{uuid} + account_id = extract_account_id_from_arn(target_arn) + region = extract_region_from_arn(target_arn) + + api_destination_name = target_arn.split(":")[-1].split("/")[1] + events_client = connect_to(aws_access_key_id=account_id, region_name=region).events + destination = events_client.describe_api_destination(Name=api_destination_name) + + # get destination endpoint details + method = destination.get("HttpMethod", "GET") + endpoint = destination.get("InvocationEndpoint") + state = destination.get("ApiDestinationState") or "ACTIVE" + + LOG.debug('Calling EventBridge API destination (state "%s"): %s %s', state, method, endpoint) + headers = { + # default headers AWS sends with every api destination call + "User-Agent": "Amazon/EventBridge/ApiDestinations", + "Content-Type": "application/json; charset=utf-8", + "Range": "bytes=0-1048575", + "Accept-Encoding": "gzip,deflate", + "Connection": "close", + } + + endpoint = add_api_destination_authorization(destination, headers, event) + if http_parameters: + endpoint = add_target_http_parameters(http_parameters, endpoint, headers, event) + + result = requests.request( + method=method, url=endpoint, data=json.dumps(event or {}), headers=headers + ) + if result.status_code >= 400: + LOG.debug("Received code %s forwarding events: %s %s", result.status_code, method, endpoint) + if result.status_code == 429 or 500 <= result.status_code <= 600: + pass # TODO: retry logic (only retry on 429 and 5xx response status) + + +def add_api_destination_authorization(destination, headers, event): + connection_arn = destination.get("ConnectionArn", "") + connection_name = re.search(r"connection\/([a-zA-Z0-9-_]+)\/", connection_arn).group(1) + + account_id = extract_account_id_from_arn(connection_arn) + region = extract_region_from_arn(connection_arn) + + # Using backend directly due to boto hiding passwords, keys and secret values + event_backend = events_backends[account_id][region] + connection = event_backend.describe_connection(name=connection_name) + + headers.update(auth_keys_from_connection(connection)) + + auth_parameters = connection.get("AuthParameters", {}) + invocation_parameters = auth_parameters.get("InvocationHttpParameters") + + endpoint = destination.get("InvocationEndpoint") + if invocation_parameters: + header_parameters = list_of_parameters_to_object( + invocation_parameters.get("HeaderParameters", []) + ) + headers.update(header_parameters) + + body_parameters = list_of_parameters_to_object( + invocation_parameters.get("BodyParameters", []) + ) + event.update(body_parameters) + + query_parameters = invocation_parameters.get("QueryStringParameters", []) + query_object = list_of_parameters_to_object(query_parameters) + endpoint = add_query_params_to_url(endpoint, query_object) + + return endpoint + + +def add_target_http_parameters(http_parameters: Dict, endpoint: str, headers: Dict, body): + endpoint = add_path_parameters_to_url(endpoint, http_parameters.get("PathParameterValues", [])) + + # The request should prioritze connection header/query parameters over target params if there is an overlap + query_params = http_parameters.get("QueryStringParameters", {}) + prev_query_params = extract_query_string_params(endpoint)[1] + query_params.update(prev_query_params) + endpoint = add_query_params_to_url(endpoint, query_params) + + target_headers = http_parameters.get("HeaderParameters", {}) + for target_header in target_headers: + if target_header not in headers: + headers.update({target_header: target_headers.get(target_header)}) + + return endpoint diff --git a/localstack-core/localstack/utils/aws/queries.py b/localstack-core/localstack/utils/aws/queries.py new file mode 100644 index 0000000000000..8d308221d2ef6 --- /dev/null +++ b/localstack-core/localstack/utils/aws/queries.py @@ -0,0 +1,35 @@ +from localstack.aws.connect import connect_to +from localstack.utils.aws.arns import extract_region_from_arn, sqs_queue_url_for_arn +from localstack.utils.strings import to_str + + +def sqs_receive_message(queue_arn): + region_name = extract_region_from_arn(queue_arn) + client = connect_to(region_name=region_name).sqs + queue_url = sqs_queue_url_for_arn(queue_arn) + response = client.receive_message(QueueUrl=queue_url) + return response + + +def kinesis_get_latest_records( + stream_name: str, shard_id: str, count: int = 10, client=None +) -> list[dict]: + kinesis = client or connect_to().kinesis + result = [] + response = kinesis.get_shard_iterator( + StreamName=stream_name, ShardId=shard_id, ShardIteratorType="TRIM_HORIZON" + ) + shard_iterator = response["ShardIterator"] + while shard_iterator: + records_response = kinesis.get_records(ShardIterator=shard_iterator) + records = records_response["Records"] + for record in records: + try: + record["Data"] = to_str(record["Data"]) + except Exception: + pass + result.extend(records) + shard_iterator = records_response["NextShardIterator"] if records else False + while len(result) > count: + result.pop(0) + return result diff --git a/localstack-core/localstack/utils/aws/request_context.py b/localstack-core/localstack/utils/aws/request_context.py new file mode 100644 index 0000000000000..9af869aeffa19 --- /dev/null +++ b/localstack-core/localstack/utils/aws/request_context.py @@ -0,0 +1,116 @@ +""" +This module has utilities relating to creating/parsing AWS requests. +""" + +import logging +import re +from typing import Dict, Optional + +from rolo import Request as RoloRequest + +from localstack.aws.accounts import get_account_id_from_access_key_id +from localstack.constants import ( + APPLICATION_AMZ_JSON_1_0, + APPLICATION_AMZ_JSON_1_1, + APPLICATION_X_WWW_FORM_URLENCODED, + AWS_REGION_US_EAST_1, + DEFAULT_AWS_ACCOUNT_ID, +) + +LOG = logging.getLogger(__name__) + +AWS_REGION_REGEX = r"(us(-gov)?|ap|ca|cn|eu|sa)-(central|(north|south)?(east|west)?)-\d" + + +def get_account_id_from_request(request: RoloRequest) -> str: + access_key_id = ( + extract_access_key_id_from_auth_header(request.headers) or DEFAULT_AWS_ACCOUNT_ID + ) + + return get_account_id_from_access_key_id(access_key_id) + + +def extract_region_from_auth_header(headers) -> Optional[str]: + auth = headers.get("Authorization") or "" + region = re.sub(r".*Credential=[^/]+/[^/]+/([^/]+)/.*", r"\1", auth) + if region == auth: + return None + return region + + +def extract_account_id_from_auth_header(headers) -> Optional[str]: + if access_key_id := extract_access_key_id_from_auth_header(headers): + return get_account_id_from_access_key_id(access_key_id) + + +def extract_access_key_id_from_auth_header(headers: Dict[str, str]) -> Optional[str]: + auth = headers.get("Authorization") or "" + + if auth.startswith("AWS4-"): + # For Signature Version 4 + access_id = re.findall(r".*Credential=([^/]+)/[^/]+/[^/]+/.*", auth) + if len(access_id): + return access_id[0] + + elif auth.startswith("AWS "): + # For Signature Version 2 + access_id = auth.removeprefix("AWS ").split(":") + if len(access_id): + return access_id[0] + + +def extract_account_id_from_headers(headers) -> str: + return extract_account_id_from_auth_header(headers) or DEFAULT_AWS_ACCOUNT_ID + + +def extract_region_from_headers(headers) -> str: + return extract_region_from_auth_header(headers) or AWS_REGION_US_EAST_1 + + +def extract_service_name_from_auth_header(headers: Dict) -> Optional[str]: + try: + auth_header = headers.get("authorization", "") + credential_scope = auth_header.split(",")[0].split()[1] + _, _, _, service, _ = credential_scope.split("/") + return service + except Exception: + return + + +def mock_aws_request_headers( + service: str, aws_access_key_id: str, region_name: str, internal: bool = False +) -> Dict[str, str]: + """ + Returns a mock set of headers that resemble SigV4 signing method. + """ + from localstack.aws.connect import ( + INTERNAL_REQUEST_PARAMS_HEADER, + InternalRequestParameters, + dump_dto, + ) + + ctype = APPLICATION_AMZ_JSON_1_0 + if service == "kinesis": + ctype = APPLICATION_AMZ_JSON_1_1 + elif service in ["sns", "sqs", "sts", "cloudformation"]: + ctype = APPLICATION_X_WWW_FORM_URLENCODED + + # For S3 presigned URLs, we require that the client and server use the same + # access key ID to sign requests. So try to use the access key ID for the + # current request if available + headers = { + "Content-Type": ctype, + "Accept-Encoding": "identity", + "X-Amz-Date": "20160623T103251Z", # TODO: Use current date + "Authorization": ( + "AWS4-HMAC-SHA256 " + + f"Credential={aws_access_key_id}/20160623/{region_name}/{service}/aws4_request, " + + "SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234" + ), + } + + if internal: + dto = InternalRequestParameters() + headers[INTERNAL_REQUEST_PARAMS_HEADER] = dump_dto(dto) + + return headers diff --git a/localstack-core/localstack/utils/aws/resources.py b/localstack-core/localstack/utils/aws/resources.py new file mode 100644 index 0000000000000..d18a2ca4b2b0e --- /dev/null +++ b/localstack-core/localstack/utils/aws/resources.py @@ -0,0 +1,193 @@ +from localstack.aws.api.dynamodb import CreateTableOutput, DescribeTableOutput +from localstack.aws.connect import connect_to +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.utils.aws.aws_stack import LOG +from localstack.utils.functions import run_safe +from localstack.utils.sync import poll_condition + + +# TODO: make s3_client mandatory +def get_or_create_bucket(bucket_name: str, s3_client=None): + s3_client = s3_client or connect_to().s3 + try: + return s3_client.head_bucket(Bucket=bucket_name) + except Exception: + return create_s3_bucket(bucket_name, s3_client=s3_client) + + +def create_s3_bucket(bucket_name: str, s3_client=None): + """Creates a bucket in the region that is associated with the current request + context, or with the given boto3 S3 client, if specified.""" + s3_client = s3_client or connect_to().s3 + region = s3_client.meta.region_name + kwargs = {} + if region != AWS_REGION_US_EAST_1: + kwargs = {"CreateBucketConfiguration": {"LocationConstraint": region}} + return s3_client.create_bucket(Bucket=bucket_name, **kwargs) + + +# TODO: Harmonise the return value +def create_dynamodb_table( + table_name: str, + partition_key: str, + stream_view_type: str = None, + region_name: str = None, + client=None, + wait_for_active: bool = True, +) -> CreateTableOutput | DescribeTableOutput: + """Utility method to create a DynamoDB table""" + + dynamodb = client or connect_to(region_name=region_name).dynamodb + stream_spec = {"StreamEnabled": False} + key_schema = [{"AttributeName": partition_key, "KeyType": "HASH"}] + attr_defs = [{"AttributeName": partition_key, "AttributeType": "S"}] + if stream_view_type is not None: + stream_spec = {"StreamEnabled": True, "StreamViewType": stream_view_type} + table = None + try: + table = dynamodb.create_table( + TableName=table_name, + KeySchema=key_schema, + AttributeDefinitions=attr_defs, + BillingMode="PAY_PER_REQUEST", + StreamSpecification=stream_spec, + ) + except Exception as e: + if "ResourceInUseException" in str(e): + # Table already exists -> return table reference + return dynamodb.describe_table(TableName=table_name) + if "AccessDeniedException" in str(e): + raise + + def _is_active(): + return dynamodb.describe_table(TableName=table_name)["Table"]["TableStatus"] == "ACTIVE" + + if wait_for_active: + poll_condition(_is_active) + + return table + + +# TODO make client mandatory +def create_api_gateway( + name, + description=None, + resources=None, + stage_name=None, + enabled_api_keys=None, + usage_plan_name=None, + auth_creator_func=None, # function that receives an api_id and returns an authorizer_id + client=None, +): + if enabled_api_keys is None: + enabled_api_keys = [] + if not client: + client = connect_to().apigateway + resources = resources or [] + stage_name = stage_name or "testing" + usage_plan_name = usage_plan_name or "Basic Usage" + description = description or 'Test description for API "%s"' % name + + LOG.info('Creating API resources under API Gateway "%s".', name) + api = client.create_rest_api(name=name, description=description) + api_id = api["id"] + + auth_id = None + if auth_creator_func: + auth_id = auth_creator_func(api_id) + + resources_list = client.get_resources(restApiId=api_id) + root_res_id = resources_list["items"][0]["id"] + # add API resources and methods + for path, methods in resources.items(): + # create resources recursively + parent_id = root_res_id + for path_part in path.split("/"): + api_resource = client.create_resource( + restApiId=api_id, parentId=parent_id, pathPart=path_part + ) + parent_id = api_resource["id"] + # add methods to the API resource + for method in methods: + kwargs = {"authorizerId": auth_id} if auth_id else {} + client.put_method( + restApiId=api_id, + resourceId=api_resource["id"], + httpMethod=method["httpMethod"], + authorizationType=method.get("authorizationType") or "NONE", + apiKeyRequired=method.get("apiKeyRequired") or False, + requestParameters=method.get("requestParameters") or {}, + requestModels=method.get("requestModels") or {}, + **kwargs, + ) + # create integrations for this API resource/method + integrations = method["integrations"] + create_api_gateway_integrations( + api_id, + api_resource["id"], + method, + integrations, + client=client, + ) + + # deploy the API gateway + client.create_deployment(restApiId=api_id, stageName=stage_name) + return api + + +# TODO make client mandatory +def create_api_gateway_integrations(api_id, resource_id, method, integrations=None, client=None): + if integrations is None: + integrations = [] + if not client: + client = connect_to().apigateway + for integration in integrations: + req_templates = integration.get("requestTemplates") or {} + res_templates = integration.get("responseTemplates") or {} + success_code = integration.get("successCode") or "200" + client_error_code = integration.get("clientErrorCode") or "400" + server_error_code = integration.get("serverErrorCode") or "500" + request_parameters = integration.get("requestParameters") or {} + credentials = integration.get("credentials") or "" + + # create integration + client.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod=method["httpMethod"], + integrationHttpMethod=method.get("integrationHttpMethod") or method["httpMethod"], + type=integration["type"], + uri=integration["uri"], + requestTemplates=req_templates, + requestParameters=request_parameters, + credentials=credentials, + ) + response_configs = [ + {"pattern": "^2.*", "code": success_code, "res_templates": res_templates}, + {"pattern": "^4.*", "code": client_error_code, "res_templates": {}}, + {"pattern": "^5.*", "code": server_error_code, "res_templates": {}}, + ] + # create response configs + for response_config in response_configs: + # create integration response + client.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=method["httpMethod"], + statusCode=response_config["code"], + responseTemplates=response_config["res_templates"], + selectionPattern=response_config["pattern"], + ) + # create method response + client.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=method["httpMethod"], + statusCode=response_config["code"], + ) + + +def create_kinesis_stream(client, stream_name: str, shards: int = 1, delete: bool = False) -> None: + client.create_stream(StreamName=stream_name, ShardCount=shards) + if delete: + run_safe(lambda: client.delete_stream(StreamName=stream_name), print_error=False) diff --git a/localstack-core/localstack/utils/aws/templating.py b/localstack-core/localstack/utils/aws/templating.py new file mode 100644 index 0000000000000..4d9ef57897da1 --- /dev/null +++ b/localstack-core/localstack/utils/aws/templating.py @@ -0,0 +1,141 @@ +import json +import re +from typing import Any, Dict + +import airspeed + +from localstack.utils.objects import recurse_object +from localstack.utils.patch import patch + +SOURCE_NAMESPACE_VARIABLE = "__LOCALSTACK_SERVICE_SOURCE__" +APIGW_SOURCE = "APIGW" +APPSYNC_SOURCE = "APPSYNC" + + +@patch(airspeed.operators.VariableExpression.calculate) +def calculate(fn, self, namespace, loader, global_namespace=None): + result = fn(self, namespace, loader, global_namespace) + + if global_namespace is None: + global_namespace = namespace + if (source := global_namespace.top().get(SOURCE_NAMESPACE_VARIABLE)) and source == APIGW_SOURCE: + # Apigateway does not return None but returns an empty string instead + result = "" if result is None else result + + return result + + +class VelocityUtil: + """ + Simple class to mimic the behavior of variable '$util' in AWS velocity templates. + + This class defines basic shared functions, which can be overwritten/extended by + subclasses (e.g., for API Gateway, AppSync, etc). + """ + + def quiet(self, *args, **kwargs): + """No-op util function, often used as wrapper around other functions to suppress output""" + pass + + def qr(self, *args, **kwargs): + self.quiet(*args, **kwargs) + + +class VtlTemplate: + """Utility class for rendering Velocity templates""" + + def render_vtl(self, template: str, variables: Dict, as_json=False) -> str | dict: + """ + Render the given VTL template against the dict of variables. Note that this is a + potentially mutating operation which may change the values of `variables` in-place. + :param template: the template string + :param variables: dict of variables available to the template + :param as_json: whether to return the result as parsed JSON dict + :return: the rendered template string value (or dict) + """ + if variables is None: + variables = {} + + if not template: + return template + + # fix "#set" commands + template = re.sub(r"(^|\n)#\s+set(.*)", r"\1#set\2", template, count=re.MULTILINE) + + # enable syntax like "test#${foo.bar}" + empty_placeholder = " __pLaCe-HoLdEr__ " + template = re.sub( + r"([^\s]+)#\$({)?(.*)", + r"\1#%s$\2\3" % empty_placeholder, + template, + count=re.MULTILINE, + ) + + # add extensions for common string functions below + + class ExtendedString(str): + def toString(self, *_, **__): + return self + + def trim(self, *args, **kwargs): + return ExtendedString(self.strip(*args, **kwargs)) + + def toLowerCase(self, *_, **__): + return ExtendedString(self.lower()) + + def toUpperCase(self, *_, **__): + return ExtendedString(self.upper()) + + def contains(self, *args): + return self.find(*args) >= 0 + + def replaceAll(self, regex, replacement): + escaped_replacement = replacement.replace("$", "\\") + return ExtendedString(re.sub(regex, escaped_replacement, self)) + + def apply(obj, **_): + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, str): + obj[k] = ExtendedString(v) + return obj + + # loop through the variables and enable certain additional util functions (e.g., string utils) + variables = {} if variables is None else variables + recurse_object(variables, apply) + + # prepare and render template + t = airspeed.Template(template) + namespace = self.prepare_namespace(variables) + + # this steps prepares the namespace for object traversal, + # e.g, foo.bar.trim().toLowerCase().replace + input_var = variables.get("input") or {} + dict_pack = input_var.get("body") + if isinstance(dict_pack, dict): + for k, v in dict_pack.items(): + namespace.update({k: v}) + + rendered_template = t.merge(namespace) + + # revert temporary changes from the fixes above + rendered_template = rendered_template.replace(empty_placeholder, "") + + if as_json: + rendered_template = json.loads(rendered_template) + return rendered_template + + def prepare_namespace(self, variables: Dict[str, Any], source: str = "") -> Dict: + namespace = dict(variables or {}) + namespace.setdefault("context", {}) + if not namespace.get("util"): + namespace["util"] = VelocityUtil() + namespace[SOURCE_NAMESPACE_VARIABLE] = source + return namespace + + +# TODO: clean up this function, once all references have been removed (difference between context/variables unclear) +def render_velocity_template(template, context, variables=None, as_json=False): + context = context or {} + context.update(variables or {}) + return VtlTemplate().render_vtl(template, context, as_json=as_json) diff --git a/localstack-core/localstack/utils/backoff.py b/localstack-core/localstack/utils/backoff.py new file mode 100644 index 0000000000000..98512bd9b6ecf --- /dev/null +++ b/localstack-core/localstack/utils/backoff.py @@ -0,0 +1,97 @@ +import random +import time + +from pydantic import Field +from pydantic.dataclasses import dataclass + + +@dataclass +class ExponentialBackoff: + """ + ExponentialBackoff implements exponential backoff with randomization. + The backoff period increases exponentially for each retry attempt, with + optional randomization within a defined range. + + next_backoff() is calculated using the following formula: + ``` + randomized_interval = random_between(retry_interval * (1 - randomization_factor), retry_interval * (1 + randomization_factor)) + ``` + + For example, given: + `initial_interval` = 2 + `randomization_factor` = 0.5 + `multiplier` = 2 + + The next backoff will be between 1 and 3 seconds (2 * [0.5, 1.5]). + The following backoff will be between 2 and 6 seconds (4 * [0.5, 1.5]). + + Note: + - `max_interval` caps the base interval, not the randomized value + - Returns 0 when `max_retries` or `max_time_elapsed` is exceeded + - The implementation is not thread-safe + + Example sequence with defaults (initial_interval=0.5, randomization_factor=0.5, multiplier=1.5): + + | Request # | Retry Interval (seconds) | Randomized Interval (seconds) | + |-----------|----------------------|----------------------------| + | 1 | 0.5 | [0.25, 0.75] | + | 2 | 0.75 | [0.375, 1.125] | + | 3 | 1.125 | [0.562, 1.687] | + | 4 | 1.687 | [0.8435, 2.53] | + | 5 | 2.53 | [1.265, 3.795] | + | 6 | 3.795 | [1.897, 5.692] | + | 7 | 5.692 | [2.846, 8.538] | + | 8 | 8.538 | [4.269, 12.807] | + | 9 | 12.807 | [6.403, 19.210] | + | 10 | 19.210 | 0 | + + Note: The sequence stops at request #10 when `max_retries` or `max_time_elapsed` is exceeded + """ + + initial_interval: float = Field(0.5, title="Initial backoff interval in seconds", gt=0) + randomization_factor: float = Field(0.5, title="Factor to randomize backoff", ge=0, le=1) + multiplier: float = Field(1.5, title="Multiply interval by this factor each retry", gt=1) + max_interval: float = Field(60.0, title="Maximum backoff interval in seconds", gt=0) + max_retries: int = Field(-1, title="Max retry attempts (-1 for unlimited)", ge=-1) + max_time_elapsed: float = Field(-1, title="Max total time in seconds (-1 for unlimited)", ge=-1) + + def __post_init__(self): + self.retry_interval: float = 0 + self.retries: int = 0 + self.start_time: float = 0.0 + + @property + def elapsed_duration(self) -> float: + return max(time.monotonic() - self.start_time, 0) + + def reset(self) -> None: + self.retry_interval = 0 + self.retries = 0 + self.start_time = 0 + + def next_backoff(self) -> float: + if self.retry_interval == 0: + self.retry_interval = self.initial_interval + self.start_time = time.monotonic() + + self.retries += 1 + + # return 0 when max_retries is set and exceeded + if self.max_retries >= 0 and self.retries > self.max_retries: + return 0 + + # return 0 when max_time_elapsed is set and exceeded + if self.max_time_elapsed > 0 and self.elapsed_duration > self.max_time_elapsed: + return 0 + + next_interval = self.retry_interval + if 0 < self.randomization_factor <= 1: + min_interval = self.retry_interval * (1 - self.randomization_factor) + max_interval = self.retry_interval * (1 + self.randomization_factor) + # NOTE: the jittered value can exceed the max_interval + next_interval = random.uniform(min_interval, max_interval) + + # do not allow the next retry interval to exceed max_interval + self.retry_interval = min(self.max_interval, self.retry_interval * self.multiplier) + + return next_interval diff --git a/localstack-core/localstack/utils/batch_policy.py b/localstack-core/localstack/utils/batch_policy.py new file mode 100644 index 0000000000000..9ac5e575f3a49 --- /dev/null +++ b/localstack-core/localstack/utils/batch_policy.py @@ -0,0 +1,124 @@ +import copy +import time +from typing import Generic, List, Optional, TypeVar, overload + +from pydantic import Field +from pydantic.dataclasses import dataclass + +T = TypeVar("T") + +# alias to signify whether a batch policy has been triggered +BatchPolicyTriggered = bool + + +# TODO: Add batching on bytes as well. +@dataclass +class Batcher(Generic[T]): + """ + A utility for collecting items into batches and flushing them when one or more batch policy conditions are met. + + The batch policy can be created to trigger on: + - max_count: Maximum number of items added + - max_window: Maximum time window (in seconds) + + If no limits are specified, the batcher is always in triggered state. + + Example usage: + + import time + + # Triggers when 2 (or more) items are added + batcher = Batcher(max_count=2) + assert batcher.add(["item1", "item2", "item3"]) + assert batcher.flush() == ["item1", "item2", "item3"] + + # Triggers partially when 2 (or more) items are added + batcher = Batcher(max_count=2) + assert batcher.add(["item1", "item2", "item3"]) + assert batcher.flush(partial=True) == ["item1", "item2"] + assert batcher.add("item4") + assert batcher.flush(partial=True) == ["item3", "item4"] + + # Trigger 2 seconds after the first add + batcher = Batcher(max_window=2.0) + assert not batcher.add(["item1", "item2", "item3"]) + time.sleep(2.1) + assert not batcher.add(["item4"]) + assert batcher.flush() == ["item1", "item2", "item3", "item4"] + """ + + max_count: Optional[int] = Field(default=None, description="Maximum number of items", ge=0) + max_window: Optional[float] = Field( + default=None, description="Maximum time window in seconds", ge=0 + ) + + _triggered: bool = Field(default=False, init=False) + _last_batch_time: float = Field(default_factory=time.monotonic, init=False) + _batch: list[T] = Field(default_factory=list, init=False) + + @property + def period(self) -> float: + return time.monotonic() - self._last_batch_time + + def _check_batch_policy(self) -> bool: + """Check if any batch policy conditions are met""" + if self.max_count is not None and len(self._batch) >= self.max_count: + self._triggered = True + elif self.max_window is not None and self.period >= self.max_window: + self._triggered = True + elif not self.max_count and not self.max_window: + # always return true + self._triggered = True + + return self._triggered + + @overload + def add(self, item: T, *, deep_copy: bool = False) -> BatchPolicyTriggered: ... + + @overload + def add(self, items: List[T], *, deep_copy: bool = False) -> BatchPolicyTriggered: ... + + def add(self, item_or_items: T | list[T], *, deep_copy: bool = False) -> BatchPolicyTriggered: + """ + Add an item or list of items to the collected batch. + + Returns: + BatchPolicyTriggered: True if the batch policy was triggered during addition, False otherwise. + """ + if deep_copy: + item_or_items = copy.deepcopy(item_or_items) + + if isinstance(item_or_items, list): + self._batch.extend(item_or_items) + else: + self._batch.append(item_or_items) + + # Check if the last addition triggered the batch policy + return self.is_triggered() + + def flush(self, *, partial=False) -> list[T]: + result = [] + if not partial or not self.max_count: + result = self._batch.copy() + self._batch.clear() + else: + batch_size = min(self.max_count, len(self._batch)) + result = self._batch[:batch_size].copy() + self._batch = self._batch[batch_size:] + + self._last_batch_time = time.monotonic() + self._triggered = False + self._check_batch_policy() + + return result + + def duration_until_next_batch(self) -> float: + if not self.max_window: + return -1 + return max(self.max_window - self.period, -1) + + def get_current_size(self) -> int: + return len(self._batch) + + def is_triggered(self): + return self._triggered or self._check_batch_policy() diff --git a/localstack-core/localstack/utils/bootstrap.py b/localstack-core/localstack/utils/bootstrap.py new file mode 100644 index 0000000000000..6d65ef30db0f1 --- /dev/null +++ b/localstack-core/localstack/utils/bootstrap.py @@ -0,0 +1,1392 @@ +from __future__ import annotations + +import copy +import functools +import logging +import os +import re +import shlex +import signal +import threading +import time +from functools import wraps +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Union + +from localstack import config, constants +from localstack.config import ( + HostAndPort, + default_ip, + is_env_not_false, + load_environment, +) +from localstack.constants import VERSION +from localstack.runtime import hooks +from localstack.utils.container_networking import get_main_container_name +from localstack.utils.container_utils.container_client import ( + BindMount, + CancellableStream, + ContainerClient, + ContainerConfiguration, + ContainerConfigurator, + ContainerException, + NoSuchContainer, + NoSuchImage, + NoSuchNetwork, + PortMappings, + VolumeDirMount, + VolumeMappings, +) +from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.files import cache_dir, mkdir +from localstack.utils.functions import call_safe +from localstack.utils.net import get_free_tcp_port, get_free_tcp_port_range +from localstack.utils.run import is_command_available, run, to_str +from localstack.utils.serving import Server +from localstack.utils.strings import short_uid +from localstack.utils.sync import poll_condition + +LOG = logging.getLogger(__name__) + +# Mandatory dependencies of services on other services +# - maps from API names to list of other API names that they _explicitly_ depend on: : +# - an explicit service dependency is a service without which another service's basic functionality breaks +# - this mapping is used when enabling strict service loading (use SERVICES env var to allow-list services) +# - do not add "optional" dependencies of services here, use API_DEPENDENCIES_OPTIONAL instead +API_DEPENDENCIES = { + "dynamodb": ["dynamodbstreams"], + # dynamodbstreams uses kinesis under the hood + "dynamodbstreams": ["kinesis"], + # es forwards all requests to opensearch (basically an API deprecation path in AWS) + "es": ["opensearch"], + "cloudformation": ["s3", "sts"], + "lambda": ["s3", "sts"], + # firehose currently only supports kinesis as source, this could become optional when more sources are supported + "firehose": ["kinesis"], + "transcribe": ["s3"], + # secretsmanager uses lambda for rotation + "secretsmanager": ["kms", "lambda"], +} + +# Optional dependencies of services on other services +# - maps from API names to list of other API names that they _optionally_ depend on: : +# - an optional service dependency is a service without which a service's basic functionality doesn't break, +# but which is needed for certain features (f.e. for one of multiple integrations) +# - this mapping is used f.e. used for the selective test execution (localstack.testing.testselection) +# - only add optional dependencies of services here, use API_DEPENDENCIES for mandatory dependencies +API_DEPENDENCIES_OPTIONAL = { + # firehose's optional dependencies are supported delivery stream destinations + "firehose": ["es", "opensearch", "s3", "redshift"], + "lambda": [ + "cloudwatch", # Lambda metrics + "dynamodbstreams", # Event source mapping source + "events", # Lambda destination + "logs", # Function logging + "kinesis", # Event source mapping source + "sqs", # Event source mapping source + Lambda destination + "sns", # Lambda destination + "sts", # Credentials injection + # Additional dependencies to Pro-only services are defined in ext + ], + "ses": ["sns"], + "sns": ["sqs", "lambda", "firehose", "ses", "logs"], + "sqs": ["cloudwatch"], + "logs": ["lambda", "kinesis", "firehose"], + "cloudformation": ["secretsmanager", "ssm", "lambda"], + "events": ["lambda", "kinesis", "firehose", "sns", "sqs", "stepfunctions", "logs"], + "stepfunctions": ["logs", "lambda", "dynamodb", "ecs", "sns", "sqs", "apigateway", "events"], + "apigateway": [ + "s3", + "sqs", + "sns", + "kinesis", + "route53", + "servicediscovery", + "lambda", + "dynamodb", + "stepfunctions", + "events", + ], + # This is for S3 notifications and S3 KMS key + "s3": ["events", "sqs", "sns", "lambda", "kms"], + # IAM and STS are tightly coupled + "sts": ["iam"], + "iam": ["sts"], +} + +# composites define an abstract name like "serverless" that maps to a set of services +API_COMPOSITES = { + "serverless": [ + "cloudformation", + "cloudwatch", + "iam", + "sts", + "lambda", + "dynamodb", + "apigateway", + "s3", + ], + "cognito": ["cognito-idp", "cognito-identity"], + "timestream": ["timestream-write", "timestream-query"], +} + + +def log_duration(name=None, min_ms=500): + """Function decorator to log the duration of function invocations.""" + + def wrapper(f): + @wraps(f) + def wrapped(*args, **kwargs): + from time import perf_counter + + start_time = perf_counter() + try: + return f(*args, **kwargs) + finally: + end_time = perf_counter() + func_name = name or f.__name__ + duration = (end_time - start_time) * 1000 + if duration > min_ms: + LOG.info('Execution of "%s" took %.2fms', func_name, duration) + + return wrapped + + return wrapper + + +def get_docker_image_details(image_name: str = None) -> Dict[str, str]: + image_name = image_name or get_docker_image_to_start() + try: + result = DOCKER_CLIENT.inspect_image(image_name) + except ContainerException: + return {} + + digests = result.get("RepoDigests") + sha256 = digests[0].rpartition(":")[2] if digests else "Unavailable" + result = { + "id": result["Id"].replace("sha256:", "")[:12], + "sha256": sha256, + "tag": (result.get("RepoTags") or ["latest"])[0].split(":")[-1], + "created": result["Created"].split(".")[0], + } + return result + + +def get_image_environment_variable(env_name: str) -> Optional[str]: + image_name = get_docker_image_to_start() + image_info = DOCKER_CLIENT.inspect_image(image_name) + image_envs = image_info["Config"]["Env"] + + try: + found_env = next(env for env in image_envs if env.startswith(env_name)) + except StopIteration: + return None + return found_env.split("=")[1] + + +def get_container_default_logfile_location(container_name: str) -> str: + return os.path.join(config.dirs.mounted_tmp, f"{container_name}_container.log") + + +def get_server_version_from_running_container() -> str: + try: + # try to extract from existing running container + container_name = get_main_container_name() + version, _ = DOCKER_CLIENT.exec_in_container( + container_name, interactive=True, command=["bin/localstack", "--version"] + ) + version = to_str(version).strip().splitlines()[-1] + return version + except ContainerException: + try: + # try to extract by starting a new container + img_name = get_docker_image_to_start() + version, _ = DOCKER_CLIENT.run_container( + img_name, + remove=True, + interactive=True, + entrypoint="", + command=["bin/localstack", "--version"], + ) + version = to_str(version).strip().splitlines()[-1] + return version + except ContainerException: + # fall back to default constant + return VERSION + + +def get_server_version() -> str: + image_hash = get_docker_image_details()["id"] + version_cache = cache_dir() / "image_metadata" / image_hash / "localstack_version" + if version_cache.exists(): + cached_version = version_cache.read_text() + return cached_version.strip() + + env_version = get_image_environment_variable("LOCALSTACK_BUILD_VERSION") + if env_version is not None: + version_cache.parent.mkdir(exist_ok=True, parents=True) + version_cache.write_text(env_version) + return env_version + + container_version = get_server_version_from_running_container() + version_cache.parent.mkdir(exist_ok=True, parents=True) + version_cache.write_text(container_version) + + return container_version + + +def setup_logging(): + """Determine and set log level. The singleton factory makes sure the logging is only set up once.""" + from localstack.logging.setup import setup_logging_from_config + + setup_logging_from_config() + + +# -------------- +# INFRA STARTUP +# -------------- + + +def resolve_apis(services: Iterable[str]) -> Set[str]: + """ + Resolves recursively for the given collection of services (e.g., ["serverless", "cognito"]) the list of actual + API services that need to be included (e.g., {'dynamodb', 'cloudformation', 'logs', 'kinesis', 'sts', + 'cognito-identity', 's3', 'dynamodbstreams', 'apigateway', 'cloudwatch', 'lambda', 'cognito-idp', 'iam'}). + + More specifically, it does this by: + (1) resolving and adding dependencies (e.g., "dynamodbstreams" requires "kinesis"), + (2) resolving and adding composites (e.g., "serverless" describes an ensemble + including "iam", "lambda", "dynamodb", "apigateway", "s3", "sns", and "logs"), and + (3) removing duplicates from the list. + + :param services: a collection of services that can include composites (e.g., "serverless"). + :returns a set of canonical service names + """ + stack = [] + result = set() + + # perform a graph search + stack.extend(services) + while stack: + service = stack.pop() + + if service in result: + continue + + # resolve composites (like "serverless"), but do not add it to the list of results + if service in API_COMPOSITES: + stack.extend(API_COMPOSITES[service]) + continue + + result.add(service) + + # add dependencies to stack + if service in API_DEPENDENCIES: + stack.extend(API_DEPENDENCIES[service]) + + return result + + +@functools.lru_cache() +def get_enabled_apis() -> Set[str]: + """ + Returns the list of APIs that are enabled through the combination of the SERVICES variable and + STRICT_SERVICE_LOADING variable. If the SERVICES variable is empty, then it will return all available services. + Meta-services like "serverless" or "cognito", and dependencies are resolved. + + The result is cached, so it's safe to call. Clear the cache with get_enabled_apis.cache_clear(). + """ + from localstack.services.plugins import SERVICE_PLUGINS + + services_env = os.environ.get("SERVICES", "").strip() + services = SERVICE_PLUGINS.list_available() + + if services_env and is_env_not_false("STRICT_SERVICE_LOADING"): + # SERVICES and STRICT_SERVICE_LOADING are set + # we filter the result of SERVICE_PLUGINS.list_available() to cross the user-provided list with + # the available ones + enabled_services = [] + for service_port in re.split(r"\s*,\s*", services_env): + # Only extract the service name, discard the port + parts = re.split(r"[:=]", service_port) + service = parts[0] + enabled_services.append(service) + + services = [service for service in enabled_services if service in services] + # TODO: log a message if a service was not supported? see with pro loading + + return resolve_apis(services) + + +def is_api_enabled(api: str) -> bool: + return api in get_enabled_apis() + + +@functools.lru_cache() +def get_preloaded_services() -> Set[str]: + """ + Returns the list of APIs that are marked to be eager loaded through the combination of SERVICES variable and + EAGER_SERVICE_LOADING. If the SERVICES variable is empty, then it will return all available services. + Meta-services like "serverless" or "cognito", and dependencies are resolved. + + The result is cached, so it's safe to call. Clear the cache with get_preloaded_services.cache_clear(). + """ + services_env = os.environ.get("SERVICES", "").strip() + services = [] + + if services_env: + # SERVICES and EAGER_SERVICE_LOADING are set + # SERVICES env var might contain ports, but we do not support these anymore + for service_port in re.split(r"\s*,\s*", services_env): + # Only extract the service name, discard the port + parts = re.split(r"[:=]", service_port) + service = parts[0] + services.append(service) + + if not services: + from localstack.services.plugins import SERVICE_PLUGINS + + services = SERVICE_PLUGINS.list_available() + + return resolve_apis(services) + + +def start_infra_locally(): + from localstack.runtime.main import main + + return main() + + +def validate_localstack_config(name: str): + # TODO: separate functionality from CLI output + # (use exceptions to communicate errors, and return list of warnings) + from subprocess import CalledProcessError + + from localstack.cli import console + + dirname = os.getcwd() + compose_file_name = name if os.path.isabs(name) else os.path.join(dirname, name) + warns = [] + + # some systems do not have "docker-compose" aliased to "docker compose", and older systems do not have + # "docker compose" at all. By preferring the old way and falling back on the new, we should get docker compose in + # any way, if installed + if is_command_available("docker-compose"): + compose_command = ["docker-compose"] + else: + compose_command = ["docker", "compose"] + # validating docker-compose file + cmd = [*compose_command, "-f", compose_file_name, "config"] + try: + run(cmd, shell=False, print_error=False) + except CalledProcessError as e: + msg = f"{e}\n{to_str(e.output)}".strip() + raise ValueError(msg) + + import yaml # keep import here to avoid issues in test Lambdas + + # validating docker-compose variable + with open(compose_file_name) as file: + compose_content = yaml.full_load(file) + services_config = compose_content.get("services", {}) + ls_service_name = [ + name for name, svc in services_config.items() if "localstack" in svc.get("image", "") + ] + if not ls_service_name: + raise Exception( + 'No LocalStack service found in config (looking for image names containing "localstack")' + ) + if len(ls_service_name) > 1: + warns.append(f"Multiple candidates found for LocalStack service: {ls_service_name}") + ls_service_name = ls_service_name[0] + ls_service_details = services_config[ls_service_name] + image_name = ls_service_details.get("image", "") + if image_name.split(":")[0] not in constants.OFFICIAL_IMAGES: + warns.append( + f'Using custom image "{image_name}", we recommend using an official image: {constants.OFFICIAL_IMAGES}' + ) + + # prepare config options + container_name = ls_service_details.get("container_name") or "" + docker_ports = (port.split(":")[-2] for port in ls_service_details.get("ports", [])) + docker_env = { + env.split("=")[0]: env.split("=")[1] for env in ls_service_details.get("environment", {}) + } + edge_port = config.GATEWAY_LISTEN[0].port + main_container = config.MAIN_CONTAINER_NAME + + # docker-compose file validation cases + + if (main_container not in container_name) and not docker_env.get("MAIN_CONTAINER_NAME"): + warns.append( + f'Please use "container_name: {main_container}" or add "MAIN_CONTAINER_NAME" in "environment".' + ) + + def port_exposed(port): + for exposed in docker_ports: + if re.match(r"^([0-9]+-)?%s(-[0-9]+)?$" % port, exposed): + return True + + if not port_exposed(edge_port): + warns.append( + ( + f"Edge port {edge_port} is not exposed. You may have to add the entry " + 'to the "ports" section of the docker-compose file.' + ) + ) + + # print warning/info messages + for warning in warns: + console.print("[yellow]:warning:[/yellow]", warning) + if not warns: + return True + return False + + +def get_docker_image_to_start(): + image_name = os.environ.get("IMAGE_NAME") + if not image_name: + image_name = constants.DOCKER_IMAGE_NAME + if is_auth_token_configured(): + image_name = constants.DOCKER_IMAGE_NAME_PRO + return image_name + + +def extract_port_flags(user_flags, port_mappings: PortMappings): + regex = r"-p\s+([0-9]+)(\-([0-9]+))?:([0-9]+)(\-([0-9]+))?" + matches = re.match(".*%s" % regex, user_flags) + if matches: + for match in re.findall(regex, user_flags): + start = int(match[0]) + end = int(match[2] or match[0]) + start_target = int(match[3] or start) + end_target = int(match[5] or end) + port_mappings.add([start, end], [start_target, end_target]) + user_flags = re.sub(regex, r"", user_flags) + return user_flags + + +class ContainerConfigurators: + """ + A set of useful container configurators that are typical for starting the localstack container. + """ + + @staticmethod + def mount_docker_socket(cfg: ContainerConfiguration): + source = config.DOCKER_SOCK + target = "/var/run/docker.sock" + if cfg.volumes.find_target_mapping(target): + return + cfg.volumes.add(BindMount(source, target)) + cfg.env_vars["DOCKER_HOST"] = f"unix://{target}" + + @staticmethod + def mount_localstack_volume(host_path: str | os.PathLike = None): + host_path = host_path or config.VOLUME_DIR + + def _cfg(cfg: ContainerConfiguration): + if cfg.volumes.find_target_mapping(constants.DEFAULT_VOLUME_DIR): + return + cfg.volumes.add(BindMount(str(host_path), constants.DEFAULT_VOLUME_DIR)) + + return _cfg + + @staticmethod + def config_env_vars(cfg: ContainerConfiguration): + """Sets all env vars from config.CONFIG_ENV_VARS.""" + + profile_env = {} + if config.LOADED_PROFILES: + load_environment(profiles=",".join(config.LOADED_PROFILES), env=profile_env) + + non_prefixed_env_vars = [] + for env_var in config.CONFIG_ENV_VARS: + value = os.environ.get(env_var, None) + if value is not None: + if ( + env_var != "CI" + and not env_var.startswith("LOCALSTACK_") + and env_var not in profile_env + ): + # Collect all env vars that are directly forwarded from the system env + # to the container which has not been prefixed with LOCALSTACK_ here. + # Suppress the "CI" env var. + # Suppress if the env var was set from the profile. + non_prefixed_env_vars.append(env_var) + cfg.env_vars[env_var] = value + + # collectively log deprecation warnings for non-prefixed sys env vars + if non_prefixed_env_vars: + from localstack.utils.analytics import log + + for non_prefixed_env_var in non_prefixed_env_vars: + # Show a deprecation warning for each individual env var collected above + LOG.warning( + "Non-prefixed environment variable %(env_var)s is forwarded to the LocalStack container! " + "Please use `LOCALSTACK_%(env_var)s` instead of %(env_var)s to explicitly mark this environment variable to be forwarded from the CLI to the LocalStack Runtime.", + {"env_var": non_prefixed_env_var}, + ) + + log.event( + event="non_prefixed_cli_env_vars", payload={"env_vars": non_prefixed_env_vars} + ) + + @staticmethod + def random_gateway_port(cfg: ContainerConfiguration): + """Gets a random port on the host and maps it to the default edge port 4566.""" + return ContainerConfigurators.gateway_listen(get_free_tcp_port())(cfg) + + @staticmethod + def default_gateway_port(cfg: ContainerConfiguration): + """Adds 4566 to the list of port mappings""" + return ContainerConfigurators.gateway_listen(constants.DEFAULT_PORT_EDGE)(cfg) + + @staticmethod + def gateway_listen( + port: Union[int, Iterable[int], HostAndPort, Iterable[HostAndPort]], + ): + """ + Uses the given ports to configure GATEWAY_LISTEN. For instance, ``gateway_listen([4566, 443])`` would + result in the port mappings 4566:4566, 443:443, as well as ``GATEWAY_LISTEN=:4566,:443``. + + :param port: a single or list of ports, can either be int ports or HostAndPort instances + :return: a configurator + """ + if isinstance(port, int): + ports = [HostAndPort("", port)] + elif isinstance(port, HostAndPort): + ports = [port] + else: + ports = [] + for p in port: + if isinstance(p, int): + ports.append(HostAndPort("", p)) + else: + ports.append(p) + + def _cfg(cfg: ContainerConfiguration): + for _p in ports: + cfg.ports.add(_p.port) + + # gateway listen should be compiled s.t. even if we set "127.0.0.1:4566" from the host, + # it will be correctly exposed on "0.0.0.0:4566" in the container. + cfg.env_vars["GATEWAY_LISTEN"] = ",".join( + [f"{p.host if p.host != default_ip else ''}:{p.port}" for p in ports] + ) + + return _cfg + + @staticmethod + def container_name(name: str): + def _cfg(cfg: ContainerConfiguration): + cfg.name = name + cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name + + return _cfg + + @staticmethod + def random_container_name(cfg: ContainerConfiguration): + cfg.name = f"localstack-{short_uid()}" + cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name + + @staticmethod + def default_container_name(cfg: ContainerConfiguration): + cfg.name = config.MAIN_CONTAINER_NAME + cfg.env_vars["MAIN_CONTAINER_NAME"] = cfg.name + + @staticmethod + def service_port_range(cfg: ContainerConfiguration): + cfg.ports.add([config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END]) + cfg.env_vars["EXTERNAL_SERVICE_PORTS_START"] = config.EXTERNAL_SERVICE_PORTS_START + cfg.env_vars["EXTERNAL_SERVICE_PORTS_END"] = config.EXTERNAL_SERVICE_PORTS_END + + @staticmethod + def random_service_port_range(num: int = 50): + """ + Tries to find a contiguous list of random ports on the host to map to the external service port + range in the container. + """ + + def _cfg(cfg: ContainerConfiguration): + port_range = get_free_tcp_port_range(num) + cfg.ports.add([port_range.start, port_range.end]) + cfg.env_vars["EXTERNAL_SERVICE_PORTS_START"] = str(port_range.start) + cfg.env_vars["EXTERNAL_SERVICE_PORTS_END"] = str(port_range.end) + + return _cfg + + @staticmethod + def debug(cfg: ContainerConfiguration): + cfg.env_vars["DEBUG"] = "1" + + @classmethod + def develop(cls, cfg: ContainerConfiguration): + cls.env_vars( + { + "DEVELOP": "1", + } + )(cfg) + cls.port(5678)(cfg) + + @staticmethod + def network(network: str): + def _cfg(cfg: ContainerConfiguration): + cfg.network = network + + return _cfg + + @staticmethod + def custom_command(cmd: List[str]): + """ + Overwrites the container command and unsets the default entrypoint. + + :param cmd: the command to run in the container + :return: a configurator + """ + + def _cfg(cfg: ContainerConfiguration): + cfg.command = cmd + cfg.entrypoint = "" + + return _cfg + + @staticmethod + def env_vars(env_vars: Dict[str, str]): + def _cfg(cfg: ContainerConfiguration): + cfg.env_vars.update(env_vars) + + return _cfg + + @staticmethod + def port(*args, **kwargs): + def _cfg(cfg: ContainerConfiguration): + cfg.ports.add(*args, **kwargs) + + return _cfg + + @staticmethod + def volume(volume: BindMount | VolumeDirMount): + def _cfg(cfg: ContainerConfiguration): + cfg.volumes.add(volume) + + return _cfg + + @staticmethod + def cli_params(params: Dict[str, Any]): + """ + Parse docker CLI parameters and add them to the config. The currently known CLI params are:: + + --network=my-network <- stored in "network" + -e FOO=BAR -e BAR=ed <- stored in "env" + -p 4566:4566 -p 4510-4559 <- stored in "publish" + -v ./bar:/foo/bar <- stored in "volume" + + When parsed by click, the parameters would look like this:: + + { + "network": "my-network", + "env": ("FOO=BAR", "BAR=ed"), + "publish": ("4566:4566", "4510-4559"), + "volume": ("./bar:/foo/bar",), + } + + :param params: a dict of parsed parameters + :return: a configurator + """ + + # TODO: consolidate with container_client.Util.parse_additional_flags + def _cfg(cfg: ContainerConfiguration): + if params.get("network"): + cfg.network = params.get("network") + + if params.get("host_dns"): + cfg.ports.add(config.DNS_PORT, config.DNS_PORT, "udp") + cfg.ports.add(config.DNS_PORT, config.DNS_PORT, "tcp") + + # processed parsed -e, -p, and -v flags + ContainerConfigurators.env_cli_params(params.get("env"))(cfg) + ContainerConfigurators.port_cli_params(params.get("publish"))(cfg) + ContainerConfigurators.volume_cli_params(params.get("volume"))(cfg) + + return _cfg + + @staticmethod + def env_cli_params(params: Iterable[str] = None): + """ + Configures environment variables from additional CLI input through the ``-e`` options. + + :param params: a list of environment variable declarations, e.g.,: ``("foo=bar", "baz=ed")`` + :return: a configurator + """ + + def _cfg(cfg: ContainerConfiguration): + if not params: + return + + for e in params: + if "=" in e: + k, v = e.split("=", maxsplit=1) + cfg.env_vars[k] = v + else: + # there's currently no way in our abstraction to only pass the variable name (as + # you can do in docker) so we resolve the value here. + cfg.env_vars[e] = os.getenv(e) + + return _cfg + + @staticmethod + def port_cli_params(params: Iterable[str] = None): + """ + Configures port variables from additional CLI input through the ``-p`` options. + + :param params: a list of port assignments, e.g.,: ``("4000-5000", "8080:80")`` + :return: a configurator + """ + + def _cfg(cfg: ContainerConfiguration): + if not params: + return + + for port_mapping in params: + port_split = port_mapping.split(":") + protocol = "tcp" + if len(port_split) == 1: + host_port = container_port = port_split[0] + elif len(port_split) == 2: + host_port, container_port = port_split + elif len(port_split) == 3: + _, host_port, container_port = port_split + else: + raise ValueError(f"Invalid port string provided: {port_mapping}") + + host_port_split = host_port.split("-") + if len(host_port_split) == 2: + host_port = [int(host_port_split[0]), int(host_port_split[1])] + elif len(host_port_split) == 1: + host_port = int(host_port) + else: + raise ValueError(f"Invalid port string provided: {port_mapping}") + + if "/" in container_port: + container_port, protocol = container_port.split("/") + + container_port_split = container_port.split("-") + if len(container_port_split) == 2: + container_port = [int(container_port_split[0]), int(container_port_split[1])] + elif len(container_port_split) == 1: + container_port = int(container_port) + else: + raise ValueError(f"Invalid port string provided: {port_mapping}") + + cfg.ports.add(host_port, container_port, protocol) + + return _cfg + + @staticmethod + def volume_cli_params(params: Iterable[str] = None): + """ + Configures volumes from additional CLI input through the ``-v`` options. + + :param params: a list of volume declarations, e.g.,: ``("./bar:/foo/bar",)`` + :return: a configurator + """ + + def _cfg(cfg: ContainerConfiguration): + for param in params: + cfg.volumes.append(BindMount.parse(param)) + + return _cfg + + +def get_gateway_port(container: Container) -> int: + """ + Heuristically determines for the given container the port the gateway will be reachable from the host. + Parses the container's ``GATEWAY_LISTEN`` if necessary and finds the appropriate port mapping. + + :param container: the localstack container + :return: the gateway port reachable from the host + """ + candidates: List[int] + + gateway_listen = container.config.env_vars.get("GATEWAY_LISTEN") + if gateway_listen: + candidates = [ + HostAndPort.parse( + value, + default_host=constants.LOCALHOST_HOSTNAME, + default_port=constants.DEFAULT_PORT_EDGE, + ).port + for value in gateway_listen.split(",") + ] + else: + candidates = [constants.DEFAULT_PORT_EDGE] + + exposed = container.config.ports.to_dict() + + for candidate in candidates: + port = exposed.get(f"{candidate}/tcp") + if port: + return port + + raise ValueError("no gateway port mapping found") + + +def get_gateway_url( + container: Container, + hostname: str = constants.LOCALHOST_HOSTNAME, + protocol: str = "http", +) -> str: + """ + Returns the localstack container's gateway URL reachable from the host. In most cases this will be + ``http://localhost.localstack.cloud:4566``. + + :param container: the container + :param hostname: the hostname to use (default localhost.localstack.cloud) + :param protocol: the URI scheme (default http) + :return: a URL + `""" + return f"{protocol}://{hostname}:{get_gateway_port(container)}" + + +class Container: + def __init__( + self, container_config: ContainerConfiguration, docker_client: ContainerClient | None = None + ): + self.config = container_config + # marker to access the running container + self.running_container: RunningContainer | None = None + self.container_client = docker_client or DOCKER_CLIENT + + def configure(self, configurators: ContainerConfigurator | Iterable[ContainerConfigurator]): + """ + Apply the given configurators to the config of this container. + + :param configurators: + :return: + """ + try: + iterator = iter(configurators) + except TypeError: + configurators(self.config) + return + + for configurator in iterator: + configurator(self.config) + + def start(self, attach: bool = False) -> RunningContainer: + # FIXME: this is pretty awkward, but additional_flags in the LocalstackContainer API was + # always a list of ["-e FOO=BAR", ...], whereas in the DockerClient it is expected to be + # a string. so we need to re-assemble it here. the better way would be to not use + # additional_flags here all together. it is still used in ext in + # `configure_pro_container` which could be refactored to use the additional port bindings. + cfg = copy.deepcopy(self.config) + if not cfg.additional_flags: + cfg.additional_flags = "" + + # TODO: there could be a --network flag in `additional_flags`. we solve a similar problem + # for the ports using `extract_port_flags`. maybe it would be better to consolidate all + # this into the ContainerConfig object, like ContainerConfig.update_from_flags(str). + self._ensure_container_network(cfg.network) + + try: + id = self.container_client.create_container_from_config(cfg) + except ContainerException as e: + if LOG.isEnabledFor(logging.DEBUG): + LOG.exception("Error while creating container") + else: + LOG.error( + "Error while creating container: %s\n%s", e.message, to_str(e.stderr or "?") + ) + raise + + try: + self.container_client.start_container(id, attach=attach) + except ContainerException as e: + LOG.error( + "Error while starting LocalStack container: %s\n%s", + e.message, + to_str(e.stderr), + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + raise + + self.running_container = RunningContainer(id, container_config=self.config) + return self.running_container + + def _ensure_container_network(self, network: str | None = None): + """Makes sure the configured container network exists""" + if network: + if network in ["host", "bridge"]: + return + try: + self.container_client.inspect_network(network) + except NoSuchNetwork: + LOG.debug("Container network %s not found, creating it", network) + self.container_client.create_network(network) + + +class RunningContainer: + """ + Represents a LocalStack container that is running. + """ + + def __init__( + self, + id: str, + container_config: ContainerConfiguration, + docker_client: ContainerClient | None = None, + ): + self.id = id + self.config = container_config + self.container_client = docker_client or DOCKER_CLIENT + self.name = self.container_client.get_container_name(self.id) + self._shutdown = False + self._mutex = threading.Lock() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.shutdown() + + def ip_address(self, docker_network: str | None = None) -> str: + """ + Get the IP address of the container + + Optionally specify the docker network + """ + if docker_network is None: + return self.container_client.get_container_ip(container_name_or_id=self.id) + else: + return self.container_client.get_container_ipv4_for_network( + container_name_or_id=self.id, container_network=docker_network + ) + + def is_running(self) -> bool: + try: + self.container_client.inspect_container(self.id) + return True + except NoSuchContainer: + return False + + def get_logs(self) -> str: + return self.container_client.get_container_logs(self.id, safe=True) + + def stream_logs(self) -> CancellableStream: + return self.container_client.stream_container_logs(self.id) + + def wait_until_ready(self, timeout: float = None) -> bool: + return poll_condition(self.is_running, timeout) + + def shutdown(self, timeout: int = 10, remove: bool = True): + with self._mutex: + if self._shutdown: + return + self._shutdown = True + + try: + self.container_client.stop_container(container_name=self.id, timeout=timeout) + except NoSuchContainer: + pass + + if remove: + try: + self.container_client.remove_container( + container_name=self.id, force=True, check_existence=False + ) + except ContainerException as e: + if "is already in progress" in str(e): + return + raise + + def inspect(self) -> Dict[str, Union[Dict, str]]: + return self.container_client.inspect_container(container_name_or_id=self.id) + + def attach(self): + self.container_client.attach_to_container(container_name_or_id=self.id) + + def exec_in_container(self, *args, **kwargs): + return self.container_client.exec_in_container( + *args, container_name_or_id=self.id, **kwargs + ) + + def stopped(self) -> Container: + """ + Convert this running instance to a stopped instance ready to be restarted + """ + return Container(container_config=self.config, docker_client=self.container_client) + + +class ContainerLogPrinter: + """ + Waits on a container to start and then uses ``stream_logs`` to print each line of the logs. + """ + + def __init__(self, container: Container, callback: Callable[[str], None] = print): + self.container = container + self.callback = callback + + self._closed = threading.Event() + self._stream: Optional[CancellableStream] = None + + def _can_start_streaming(self): + if self._closed.is_set(): + raise IOError("Already stopped") + if not self.container.running_container: + return False + return self.container.running_container.is_running() + + def run(self): + try: + poll_condition(self._can_start_streaming) + except IOError: + return + self._stream = self.container.running_container.stream_logs() + for line in self._stream: + self.callback(line.rstrip(b"\r\n").decode("utf-8")) + + def close(self): + self._closed.set() + if self._stream: + self._stream.close() + + +class LocalstackContainerServer(Server): + container: Container | RunningContainer + + def __init__( + self, container_configuration: ContainerConfiguration | Container | None = None + ) -> None: + super().__init__(config.GATEWAY_LISTEN[0].port, config.GATEWAY_LISTEN[0].host) + + if container_configuration is None: + port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host) + for addr in config.GATEWAY_LISTEN: + port_configuration.add(addr.port) + + container_configuration = ContainerConfiguration( + image_name=get_docker_image_to_start(), + name=config.MAIN_CONTAINER_NAME, + volumes=VolumeMappings(), + remove=True, + ports=port_configuration, + entrypoint=os.environ.get("ENTRYPOINT"), + command=shlex.split(os.environ.get("CMD", "")) or None, + env_vars={}, + ) + + if isinstance(container_configuration, Container): + self.container = container_configuration + else: + self.container = Container(container_configuration) + + def is_up(self) -> bool: + """ + Checks whether the container is running, and the Ready marker has been printed to the logs. + """ + if not self.is_container_running(): + return False + + logs = self.container.get_logs() + + if constants.READY_MARKER_OUTPUT not in logs.splitlines(): + return False + + # also checks the edge port health status + return super().is_up() + + def is_container_running(self) -> bool: + # if we have not started the container then we are not up + if not isinstance(self.container, RunningContainer): + return False + + return self.container.is_running() + + def wait_is_container_running(self, timeout=None) -> bool: + return poll_condition(self.is_container_running, timeout) + + def start(self) -> bool: + if isinstance(self.container, RunningContainer): + raise RuntimeError("cannot start container as container reference has been started") + + return super().start() + + def do_run(self): + if self.is_container_running(): + raise ContainerExists( + 'LocalStack container named "%s" is already running' % self.container.name + ) + + config.dirs.mkdirs() + if not isinstance(self.container, Container): + raise ValueError(f"Invalid container type: {type(self.container)}") + + LOG.debug("starting LocalStack container") + self.container = self.container.start(attach=False) + if isinstance(DOCKER_CLIENT, CmdDockerClient): + DOCKER_CLIENT.default_run_outfile = get_container_default_logfile_location( + self.container.config.name + ) + + # block the current thread + self.container.attach() + return self.container + + def shutdown(self): + if not isinstance(self.container, RunningContainer): + raise ValueError(f"Container {self.container} not started") + + return super().shutdown() + + def do_shutdown(self): + try: + self.container.shutdown(timeout=10) + self.container = self.container.stopped() + except Exception as e: + LOG.info("error cleaning up localstack container %s: %s", self.container.name, e) + + +class ContainerExists(Exception): + pass + + +def prepare_docker_start(): + # prepare environment for docker start + container_name = config.MAIN_CONTAINER_NAME + + if DOCKER_CLIENT.is_container_running(container_name): + raise ContainerExists('LocalStack container named "%s" is already running' % container_name) + + config.dirs.mkdirs() + + +def configure_container(container: Container): + """ + Configuration routine for the LocalstackContainer. + """ + port_configuration = PortMappings(bind_host=config.GATEWAY_LISTEN[0].host) + + # base configuration + container.config.image_name = get_docker_image_to_start() + container.config.name = config.MAIN_CONTAINER_NAME + container.config.volumes = VolumeMappings() + container.config.remove = True + container.config.ports = port_configuration + container.config.entrypoint = os.environ.get("ENTRYPOINT") + container.config.command = shlex.split(os.environ.get("CMD", "")) or None + container.config.env_vars = {} + + # parse `DOCKER_FLAGS` and add them appropriately + user_flags = config.DOCKER_FLAGS + user_flags = extract_port_flags(user_flags, container.config.ports) + if container.config.additional_flags is None: + container.config.additional_flags = user_flags + else: + container.config.additional_flags = f"{container.config.additional_flags} {user_flags}" + + # get additional parameters from plux + hooks.configure_localstack_container.run(container) + + if config.DEVELOP: + container.config.ports.add(config.DEVELOP_PORT) + + container.configure( + [ + # external service port range + ContainerConfigurators.service_port_range, + ContainerConfigurators.mount_localstack_volume(config.VOLUME_DIR), + ContainerConfigurators.mount_docker_socket, + # overwrites any env vars set in the config that were previously set by configurators + ContainerConfigurators.config_env_vars, + # ensure that GATEWAY_LISTEN is taken from the config and not + # overridden by the `config_env_vars` configurator + # (when not specified in the environment). + ContainerConfigurators.gateway_listen(config.GATEWAY_LISTEN), + ] + ) + + +@log_duration() +def prepare_host(console): + """ + Prepare the host environment for running LocalStack, this should be called before start_infra_*. + """ + if os.environ.get(constants.LOCALSTACK_INFRA_PROCESS) in constants.TRUE_STRINGS: + return + + try: + mkdir(config.VOLUME_DIR) + except Exception as e: + console.print(f"Error while creating volume dir {config.VOLUME_DIR}: {e}") + if config.DEBUG: + console.print_exception() + + setup_logging() + hooks.prepare_host.run() + + +def start_infra_in_docker(console, cli_params: Dict[str, Any] = None): + prepare_docker_start() + + # create and prepare container + container_config = ContainerConfiguration(get_docker_image_to_start()) + container = Container(container_config) + ensure_container_image(console, container) + + configure_container(container) + container.configure(ContainerConfigurators.cli_params(cli_params or {})) + + status = console.status("Starting LocalStack container") + status.start() + + # printing the container log is the current way we're occupying the terminal + def _init_log_printer(line): + """Prints the console rule separator on the first line, then re-configures the callback + to print.""" + status.stop() + console.rule("LocalStack Runtime Log (press [bold][yellow]CTRL-C[/yellow][/bold] to quit)") + print(line) + log_printer.callback = print + + log_printer = ContainerLogPrinter(container, callback=_init_log_printer) + + # Set up signal handler, to enable clean shutdown across different operating systems. + # There are subtle differences across operating systems and terminal emulators when it + # comes to handling of CTRL-C - in particular, Linux sends SIGINT to the parent process, + # whereas macOS sends SIGINT to the process group, which can result in multiple SIGINT signals + # being received (e.g., when running the localstack CLI as part of a "npm run .." script). + # Hence, using a shutdown handler and synchronization event here, to avoid inconsistencies. + def shutdown_handler(*args): + with shutdown_event_lock: + if shutdown_event.is_set(): + return + shutdown_event.set() + print("Shutting down...") + server.shutdown() + + shutdown_event = threading.Event() + shutdown_event_lock = threading.RLock() + signal.signal(signal.SIGINT, shutdown_handler) + + # start the Localstack container as a Server + server = LocalstackContainerServer(container) + log_printer_thread = threading.Thread( + target=log_printer.run, name="container-log-printer", daemon=True + ) + try: + server.start() + log_printer_thread.start() + server.join() + error = server.get_error() + if error: + # if the server failed, raise the error + raise error + except KeyboardInterrupt: + print("ok, bye!") + shutdown_handler() + finally: + log_printer.close() + + +def ensure_container_image(console, container: Container): + try: + DOCKER_CLIENT.inspect_image(container.config.image_name, pull=False) + return + except NoSuchImage: + console.log("container image not found on host") + + with console.status(f"Pulling container image {container.config.image_name}"): + DOCKER_CLIENT.pull_image(container.config.image_name) + console.log("download complete") + + +def start_infra_in_docker_detached(console, cli_params: Dict[str, Any] = None): + """ + An alternative to start_infra_in_docker where the terminal is not blocked by the follow on the logfile. + """ + console.log("preparing environment") + try: + prepare_docker_start() + except ContainerExists as e: + console.print(str(e)) + return + + # create and prepare container + console.log("configuring container") + container_config = ContainerConfiguration(get_docker_image_to_start()) + container = Container(container_config) + ensure_container_image(console, container) + configure_container(container) + container.configure(ContainerConfigurators.cli_params(cli_params or {})) + + container_config.detach = True + + # start the Localstack container as a Server + console.log("starting container") + server = LocalstackContainerServer(container_config) + server.start() + server.wait_is_container_running() + console.log("detaching") + + +def wait_container_is_ready(timeout: Optional[float] = None): + """Blocks until the localstack main container is running and the ready marker has been printed.""" + container_name = config.MAIN_CONTAINER_NAME + started = time.time() + + def is_container_running(): + return DOCKER_CLIENT.is_container_running(container_name) + + if not poll_condition(is_container_running, timeout=timeout): + return False + + stream = DOCKER_CLIENT.stream_container_logs(container_name) + + # create a timer that will terminate the log stream after the remaining timeout + timer = None + if timeout: + waited = time.time() - started + remaining = timeout - waited + # check the rare case that the timeout has already been reached + if remaining <= 0: + stream.close() + return False + timer = threading.Timer(remaining, stream.close) + timer.start() + + try: + for line in stream: + line = line.decode("utf-8").strip() + if line == constants.READY_MARKER_OUTPUT: + return True + + # EOF was reached or the stream was closed + return False + finally: + call_safe(stream.close) + if timer: + # make sure the timer is stopped (does nothing if it has already run) + timer.cancel() + + +# --------------- +# UTIL FUNCTIONS +# --------------- + + +def in_ci(): + """Whether or not we are running in a CI environment""" + for key in ("CI", "TRAVIS"): + if os.environ.get(key, "") not in [False, "", "0", "false"]: + return True + return False + + +def is_auth_token_configured() -> bool: + """Whether an API key is set in the environment.""" + return ( + True + if os.environ.get("LOCALSTACK_AUTH_TOKEN", "").strip() + or os.environ.get("LOCALSTACK_API_KEY", "").strip() + else False + ) diff --git a/localstack-core/localstack/utils/checksum.py b/localstack-core/localstack/utils/checksum.py new file mode 100644 index 0000000000000..81de3b0ee60ee --- /dev/null +++ b/localstack-core/localstack/utils/checksum.py @@ -0,0 +1,313 @@ +import hashlib +import logging +import os +import re +import tempfile +from abc import ABC, abstractmethod + +from localstack.utils.files import load_file, rm_rf + +# Setup logger +LOG = logging.getLogger(__name__) + + +class ChecksumException(Exception): + """Base exception for checksum errors.""" + + pass + + +class ChecksumFormat(ABC): + """Abstract base class for checksum format parsers.""" + + @abstractmethod + def can_parse(self, content: str) -> bool: + """ + Check if this parser can handle the given content. + + :param content: The content to check + :return: True if parser can handle content, False otherwise + """ + pass + + @abstractmethod + def parse(self, content: str) -> dict[str, str]: + """ + Parse the content and return filename to checksum mapping. + + :param content: The content to parse + :return: Dictionary mapping filenames to checksums + """ + pass + + +class StandardFormat(ChecksumFormat): + """ + Handles standard checksum format. + + Supports formats like: + + * ``checksum filename`` + * ``checksum *filename`` + """ + + def can_parse(self, content: str) -> bool: + lines = content.strip().split("\n") + for line in lines[:5]: # Check first 5 lines + if re.match(r"^[a-fA-F0-9]{32,128}\s+\S+", line.strip()): + return True + return False + + def parse(self, content: str) -> dict[str, str]: + checksums = {} + for line in content.strip().split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + + # Match: checksum (whitespace) filename + match = re.match(r"^([a-fA-F0-9]{32,128})\s+(\*?)(.+)$", line) + if match: + checksum, star, filename = match.groups() + checksums[filename.strip()] = checksum.lower() + + return checksums + + +class BSDFormat(ChecksumFormat): + """ + Handles BSD-style checksum format. + + Format: ``SHA512 (filename) = checksum`` + """ + + def can_parse(self, content: str) -> bool: + lines = content.strip().split("\n") + for line in lines[:5]: + if re.match(r"^(MD5|SHA1|SHA256|SHA512)\s*\(.+\)\s*=\s*[a-fA-F0-9]+", line): + return True + return False + + def parse(self, content: str) -> dict[str, str]: + checksums = {} + for line in content.strip().split("\n"): + line = line.strip() + if not line: + continue + + # Match: ALGORITHM (filename) = checksum + match = re.match(r"^(MD5|SHA1|SHA256|SHA512)\s*\((.+)\)\s*=\s*([a-fA-F0-9]+)$", line) + if match: + algo, filename, checksum = match.groups() + checksums[filename.strip()] = checksum.lower() + + return checksums + + +class ApacheBSDFormat(ChecksumFormat): + """ + Handles Apache's BSD-style format with split checksums. + + Format:: + + filename: CHECKSUM_PART1 + CHECKSUM_PART2 + CHECKSUM_PART3 + """ + + def can_parse(self, content: str) -> bool: + lines = content.strip().split("\n") + if lines and ":" in lines[0]: + # Check if it looks like filename: hex_data + parts = lines[0].split(":", 1) + if len(parts) == 2 and re.search(r"[a-fA-F0-9\s]+", parts[1]): + return True + return False + + def parse(self, content: str) -> dict[str, str]: + checksums = {} + lines = content.strip().split("\n") + + current_file = None + checksum_parts = [] + + for line in lines: + if ":" in line and not line.startswith(" "): + # New file entry + if current_file and checksum_parts: + # Save previous file's checksum + full_checksum = "".join(checksum_parts).replace(" ", "").lower() + if re.match(r"^[a-fA-F0-9]+$", full_checksum): + checksums[current_file] = full_checksum + + # Start new file + parts = line.split(":", 1) + current_file = parts[0].strip() + checksum_part = parts[1].strip() + checksum_parts = [checksum_part] + elif line.strip() and current_file: + # Continuation of checksum + checksum_parts.append(line.strip()) + + # Don't forget the last file + if current_file and checksum_parts: + full_checksum = "".join(checksum_parts).replace(" ", "").lower() + if re.match(r"^[a-fA-F0-9]+$", full_checksum): + checksums[current_file] = full_checksum + + return checksums + + +class ChecksumParser: + """Main parser that tries different checksum formats.""" + + def __init__(self): + """Initialize parser with available format parsers.""" + self.formats = [ + StandardFormat(), + BSDFormat(), + ApacheBSDFormat(), + ] + + def parse(self, content: str) -> dict[str, str]: + """ + Try each format parser until one works. + + :param content: The content to parse + :return: Dictionary mapping filenames to checksums + """ + for format_parser in self.formats: + if format_parser.can_parse(content): + result = format_parser.parse(content) + if result: + return result + + return {} + + +def parse_checksum_file_from_url(checksum_url: str) -> dict[str, str]: + """ + Parse a SHA checksum file from a URL using multiple format parsers. + + :param checksum_url: URL of the checksum file + :return: Dictionary mapping filenames to checksums + """ + # import here to avoid circular dependency issues + from localstack.utils.http import download + + checksum_name = os.path.basename(checksum_url) + checksum_path = os.path.join(tempfile.gettempdir(), checksum_name) + try: + download(checksum_url, checksum_path) + checksum_content = load_file(checksum_path) + + parser = ChecksumParser() + checksums = parser.parse(checksum_content) + + return checksums + finally: + rm_rf(checksum_path) + + +def calculate_file_checksum(file_path: str, algorithm: str = "sha256") -> str: + """ + Calculate checksum of a local file. + + :param file_path: Path to the file + :param algorithm: Hash algorithm to use + :return: Calculated checksum as hexadecimal string + + note: Supported algorithms: 'md5', 'sha1', 'sha256', 'sha512' + """ + hash_func = getattr(hashlib, algorithm)() + + with open(file_path, "rb") as f: + # Read file in chunks to handle large files efficiently + for chunk in iter(lambda: f.read(8192), b""): + hash_func.update(chunk) + + return hash_func.hexdigest() + + +def verify_local_file_with_checksum_url(file_path: str, checksum_url: str, filename=None) -> bool: + """ + Verify a local file against checksums from an online checksum file. + + :param file_path: Path to the local file to verify + :param checksum_url: URL of the checksum file + :param filename: Filename to look for in checksum file (defaults to basename of file_path) + :return: True if verification succeeds, False otherwise + + note: The algorithm is automatically detected based on checksum length: + + * 32 characters: MD5 + * 40 characters: SHA1 + * 64 characters: SHA256 + * 128 characters: SHA512 + """ + # Get checksums from URL + LOG.debug("Fetching checksums from %s...", checksum_url) + checksums = parse_checksum_file_from_url(checksum_url) + + if not checksums: + raise ChecksumException(f"No checksums found in {checksum_url}") + + # Determine filename to look for + if filename is None: + filename = os.path.basename(file_path) + + # Find checksum for our file + if filename not in checksums: + # Try with different path variations + possible_names = [ + filename, + os.path.basename(filename), # just filename without path + filename.replace("\\", "/"), # Unix-style paths + filename.replace("/", "\\"), # Windows-style paths + ] + + found = False + for name in possible_names: + if name in checksums: + filename = name + found = True + break + + if not found: + raise ChecksumException(f"Checksum for {filename} not found in {checksum_url}") + + expected_checksum = checksums[filename] + + # Detect algorithm based on checksum length + checksum_length = len(expected_checksum) + if checksum_length == 32: + algorithm = "md5" + elif checksum_length == 40: + algorithm = "sha1" + elif checksum_length == 64: + algorithm = "sha256" + elif checksum_length == 128: + algorithm = "sha512" + else: + raise ChecksumException(f"Unsupported checksum length: {checksum_length}") + + # Calculate checksum of local file + LOG.debug("Calculating %s checksum of %s...", algorithm, file_path) + calculated_checksum = calculate_file_checksum(file_path, algorithm) + + is_valid = calculated_checksum == expected_checksum.lower() + + if not is_valid: + LOG.error( + "Checksum mismatch for %s: calculated %s, expected %s", + file_path, + calculated_checksum, + expected_checksum, + ) + raise ChecksumException( + f"Checksum mismatch for {file_path}: calculated {calculated_checksum}, expected {expected_checksum}" + ) + LOG.debug("Checksum verification successful for %s", file_path) + + # Compare checksums + return calculated_checksum == expected_checksum.lower() diff --git a/localstack-core/localstack/utils/cloudwatch/__init__.py b/localstack-core/localstack/utils/cloudwatch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/cloudwatch/cloudwatch_util.py b/localstack-core/localstack/utils/cloudwatch/cloudwatch_util.py new file mode 100644 index 0000000000000..4df924d441ba6 --- /dev/null +++ b/localstack-core/localstack/utils/cloudwatch/cloudwatch_util.py @@ -0,0 +1,250 @@ +import logging +import time +from datetime import datetime, timezone +from itertools import islice +from typing import Optional, TypedDict + +from werkzeug import Response as WerkzeugResponse + +from localstack.aws.connect import connect_to +from localstack.utils.bootstrap import is_api_enabled +from localstack.utils.strings import to_str +from localstack.utils.time import now_utc + +LOG = logging.getLogger(__name__) + + +# --------------- +# Lambda metrics +# --------------- +class SqsMetricBatchData(TypedDict, total=False): + MetricName: str + QueueName: str + Value: Optional[int] + Unit: Optional[str] + + +def dimension_lambda(kwargs): + func_name = _func_name(kwargs) + return [{"Name": "FunctionName", "Value": func_name}] + + +def publish_lambda_metric( + metric, value, kwargs, account_id: Optional[str] = None, region_name: Optional[str] = None +): + # publish metric only if CloudWatch service is available + if not is_api_enabled("cloudwatch"): + return + cw_client = connect_to(aws_access_key_id=account_id, region_name=region_name).cloudwatch + try: + cw_client.put_metric_data( + Namespace="AWS/Lambda", + MetricData=[ + { + "MetricName": metric, + "Dimensions": dimension_lambda(kwargs), + "Timestamp": datetime.utcnow().replace(tzinfo=timezone.utc), + "Value": value, + } + ], + ) + except Exception as e: + LOG.info('Unable to put metric data for metric "%s" to CloudWatch: %s', metric, e) + + +def publish_sqs_metric_batch( + account_id: str, region: str, sqs_metric_batch_data: list[SqsMetricBatchData] +): + """ + Publishes SQS metrics to CloudWatch in a single batch using the namespace "AWS/SQS" + See also: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-available-cloudwatch-metrics.html + :param account_id The account id that should be used for CloudWatch + :param region The region that should be used for CloudWatch + :param sqs_metric_batch_data data to be published + """ + if not is_api_enabled("cloudwatch"): + return + + cw_client = connect_to(region_name=region, aws_access_key_id=account_id).cloudwatch + metric_data = [] + timestamp = datetime.utcnow().replace(tzinfo=timezone.utc) + # to be on the safe-side: check the size of the data again and only insert up to 1000 data metrics at once + start = 0 + batch_size = 1000 + # can include up to 1000 metric queries for one put-metric-data call + while start < len(sqs_metric_batch_data): + # Process the current batch + for d in islice(sqs_metric_batch_data, start, start + batch_size): + metric_data.append( + { + "MetricName": d.get("MetricName"), + "Dimensions": [{"Name": "QueueName", "Value": d.get("QueueName")}], + "Unit": d.get("Unit", "Count"), + "Timestamp": timestamp, + "Value": d.get("Value", 1), + } + ) + + try: + cw_client.put_metric_data(Namespace="AWS/SQS", MetricData=metric_data) + except Exception as e: + LOG.info("Unable to put metric data for metrics to CloudWatch: %s", e) + + # Update for the next batch + metric_data.clear() + start += batch_size + + +def publish_sqs_metric( + account_id: str, + region: str, + queue_name: str, + metric: str, + value: float = 1, + unit: str = "Count", +): + """ + Publishes the metrics for SQS to CloudWatch using the namespace "AWS/SQS" + See also: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-available-cloudwatch-metrics.html + :param account_id The account id that should be used for CloudWatch + :param region The region that should be used for CloudWatch + :param queue_name The name of the queue + :param metric The metric name to be used + :param value The value of the metric data, default: 1 + :param unit The unit of the metric data, default: "Count" + """ + if not is_api_enabled("cloudwatch"): + return + cw_client = connect_to(region_name=region, aws_access_key_id=account_id).cloudwatch + try: + cw_client.put_metric_data( + Namespace="AWS/SQS", + MetricData=[ + { + "MetricName": metric, + "Dimensions": [{"Name": "QueueName", "Value": queue_name}], + "Unit": unit, + "Timestamp": datetime.utcnow().replace(tzinfo=timezone.utc), + "Value": value, + } + ], + ) + except Exception as e: + LOG.info('Unable to put metric data for metric "%s" to CloudWatch: %s', metric, e) + + +def publish_lambda_duration(time_before, kwargs): + time_after = now_utc() + publish_lambda_metric("Duration", time_after - time_before, kwargs) + + +def publish_lambda_error(time_before, kwargs): + publish_lambda_metric("Invocations", 1, kwargs) + publish_lambda_metric("Errors", 1, kwargs) + + +def publish_lambda_result(time_before, result, kwargs): + if isinstance(result, WerkzeugResponse) and result.status_code >= 400: + return publish_lambda_error(time_before, kwargs) + publish_lambda_metric("Invocations", 1, kwargs) + + +def store_cloudwatch_logs( + logs_client, + log_group_name, + log_stream_name, + log_output, + start_time=None, + auto_create_group: Optional[bool] = True, +): + if not is_api_enabled("logs"): + return + start_time = start_time or int(time.time() * 1000) + log_output = to_str(log_output) + + if auto_create_group: + # make sure that the log group exists, create it if not + try: + logs_client.create_log_group(logGroupName=log_group_name) + except Exception as e: + if "ResourceAlreadyExistsException" in str(e): + # the log group already exists, this is fine + pass + else: + raise e + + # create a new log stream for this lambda invocation + try: + logs_client.create_log_stream(logGroupName=log_group_name, logStreamName=log_stream_name) + except Exception: # TODO: narrow down + pass + + # store new log events under the log stream + finish_time = int(time.time() * 1000) + # fix for log lines that were merged into a singe line, e.g., "log line 1 ... \x1b[32mEND RequestId ..." + log_output = log_output.replace("\\x1b", "\n\\x1b") + log_output = log_output.replace("\x1b", "\n\x1b") + log_lines = log_output.split("\n") + time_diff_per_line = float(finish_time - start_time) / float(len(log_lines)) + log_events = [] + for i, line in enumerate(log_lines): + if not line: + continue + # simple heuristic: assume log lines were emitted in regular intervals + log_time = start_time + float(i) * time_diff_per_line + event = {"timestamp": int(log_time), "message": line} + log_events.append(event) + if not log_events: + return + logs_client.put_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name, logEvents=log_events + ) + + +# --------------- +# Helper methods +# --------------- + + +def _func_name(kwargs): + func_name = kwargs.get("func_name") + if not func_name: + func_name = kwargs.get("func_arn").split(":function:")[1].split(":")[0] + return func_name + + +def publish_result(ns, time_before, result, kwargs): + if ns == "lambda": + publish_lambda_result(time_before, result, kwargs) + else: + LOG.info("Unexpected CloudWatch namespace: %s", ns) + + +def publish_error(ns, time_before, e, kwargs): + if ns == "lambda": + publish_lambda_error(time_before, kwargs) + else: + LOG.info("Unexpected CloudWatch namespace: %s", ns) + + +def cloudwatched(ns): + """@cloudwatched(...) decorator for annotating methods to be monitored via CloudWatch""" + + def wrapping(func): + def wrapped(*args, **kwargs): + time_before = now_utc() + try: + result = func(*args, **kwargs) + publish_result(ns, time_before, result, kwargs) + except Exception as e: + publish_error(ns, time_before, e, kwargs) + raise e + finally: + # TODO + # time_after = now_utc() + pass + return result + + return wrapped + + return wrapping diff --git a/localstack-core/localstack/utils/collections.py b/localstack-core/localstack/utils/collections.py new file mode 100644 index 0000000000000..41860cd9a190c --- /dev/null +++ b/localstack-core/localstack/utils/collections.py @@ -0,0 +1,536 @@ +""" +This package provides custom collection types, as well as tools to analyze +and manipulate python collection (dicts, list, sets). +""" + +import logging +import re +from collections.abc import Mapping +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Sized, + Tuple, + Type, + TypedDict, + TypeVar, + Union, + cast, + get_args, + get_origin, +) + +import cachetools + +LOG = logging.getLogger(__name__) + +# default regex to match an item in a comma-separated list string +DEFAULT_REGEX_LIST_ITEM = r"[\w-]+" + + +class AccessTrackingDict(dict): + """ + Simple utility class that can be used to track (write) accesses to a dict's attributes. + Note: could also be written as a proxy, to preserve the identity of "wrapped" - for now, it + simply duplicates the entries of "wrapped" in the constructor, for simplicity. + """ + + def __init__(self, wrapped, callback: Callable[[Dict, str, List, Dict], Any] = None): + super().__init__(wrapped) + self.callback = callback + + def __setitem__(self, key, value): + self.callback and self.callback(self, "__setitem__", [key, value], {}) + return super().__setitem__(key, value) + + +class DelSafeDict(dict): + """Useful when applying jsonpatch. Use it as follows: + + obj.__dict__ = DelSafeDict(obj.__dict__) + apply_patch(obj.__dict__, patch) + """ + + def __delitem__(self, key, *args, **kwargs): + self[key] = None + + +class ImmutableList(tuple): + """ + Wrapper class to create an immutable view of a given list or sequence. + Note: Currently, this is simply a wrapper around `tuple` - could be replaced with + custom implementations over time, if needed. + """ + + +class HashableList(ImmutableList): + """Hashable, immutable list wrapper that can be used with dicts or hash sets.""" + + def __hash__(self): + return sum(hash(i) for i in self) + + +class ImmutableDict(Mapping): + """Wrapper class to create an immutable view of a given list or sequence.""" + + def __init__(self, seq=None, **kwargs): + self._dict = dict(seq, **kwargs) + + def __len__(self) -> int: + return self._dict.__len__() + + def __iter__(self) -> Iterator: + return self._dict.__iter__() + + def __getitem__(self, key): + return self._dict.__getitem__(key) + + def __eq__(self, other): + return self._dict.__eq__(other._dict if isinstance(other, ImmutableDict) else other) + + def __str__(self): + return self._dict.__str__() + + +class HashableJsonDict(ImmutableDict): + """ + Simple dict wrapper that can be used with dicts or hash sets. Note: the assumption is that the dict + can be JSON-encoded (i.e., must be acyclic and contain only lists/dicts and simple types) + """ + + def __hash__(self): + from localstack.utils.json import canonical_json + + return hash(canonical_json(self._dict)) + + +_ListType = TypeVar("_ListType") + + +class PaginatedList(List[_ListType]): + """List which can be paginated and filtered. For usage in AWS APIs with paginated responses""" + + DEFAULT_PAGE_SIZE = 50 + + def get_page( + self, + token_generator: Callable[[_ListType], str], + next_token: str = None, + page_size: int = None, + filter_function: Callable[[_ListType], bool] = None, + ) -> Tuple[List[_ListType], Optional[str]]: + if filter_function is not None: + result_list = list(filter(filter_function, self)) + else: + result_list = self + + if page_size is None: + page_size = self.DEFAULT_PAGE_SIZE + + # returns all or remaining elements in final page. + if len(result_list) <= page_size and next_token is None: + return result_list, None + + start_idx = 0 + + try: + start_item = next(item for item in result_list if token_generator(item) == next_token) + start_idx = result_list.index(start_item) + except StopIteration: + pass + + if start_idx + page_size < len(result_list): + next_token = token_generator(result_list[start_idx + page_size]) + else: + next_token = None + + return result_list[start_idx : start_idx + page_size], next_token + + +class CustomExpiryTTLCache(cachetools.TTLCache): + """TTLCache that allows to set custom expiry times for individual keys.""" + + def set_expiry(self, key: Any, ttl: Union[float, int]) -> float: + """Set the expiry of the given key in a TTLCache to ( + )""" + with self.timer as time: + # note: need to access the internal dunder API here + self._TTLCache__getlink(key).expires = expiry = time + ttl + return expiry + + +def get_safe(dictionary, path, default_value=None): + """ + Performs a safe navigation on a Dictionary object and + returns the result or default value (if specified). + The function follows a common AWS path resolution pattern "$.a.b.c". + + :type dictionary: dict + :param dictionary: Dict to perform safe navigation. + + :type path: list|str + :param path: List or dot-separated string containing the path of an attribute, + starting from the root node "$". + + :type default_value: any + :param default_value: Default value to return in case resolved value is None. + + :rtype: any + :return: Resolved value or default_value. + """ + if not isinstance(dictionary, dict) or len(dictionary) == 0: + return default_value + + attribute_path = path if isinstance(path, list) else path.split(".") + if len(attribute_path) == 0 or attribute_path[0] != "$": + raise AttributeError('Safe navigation must begin with a root node "$"') + + current_value = dictionary + for path_node in attribute_path: + if path_node == "$": + continue + + if re.compile("^\\d+$").search(str(path_node)): + path_node = int(path_node) + + if isinstance(current_value, dict) and path_node in current_value: + current_value = current_value[path_node] + elif isinstance(current_value, list) and path_node < len(current_value): + current_value = current_value[path_node] + else: + current_value = None + + return current_value or default_value + + +def set_safe_mutable(dictionary, path, value): + """ + Mutates original dict and sets the specified value under provided path. + + :type dictionary: dict + :param dictionary: Dict to mutate. + + :type path: list|str + :param path: List or dot-separated string containing the path of an attribute, + starting from the root node "$". + + :type value: any + :param value: Value to set under specified path. + + :rtype: dict + :return: Returns mutated dictionary. + """ + if not isinstance(dictionary, dict): + raise AttributeError('"dictionary" must be of type "dict"') + + attribute_path = path if isinstance(path, list) else path.split(".") + attribute_path_len = len(attribute_path) + + if attribute_path_len == 0 or attribute_path[0] != "$": + raise AttributeError('Dict navigation must begin with a root node "$"') + + current_pointer = dictionary + for i in range(attribute_path_len): + path_node = attribute_path[i] + + if path_node == "$": + continue + + if i < attribute_path_len - 1: + if path_node not in current_pointer: + current_pointer[path_node] = {} + if not isinstance(current_pointer, dict): + raise RuntimeError( + 'Error while deeply setting a dict value. Supplied path is not of type "dict"' + ) + else: + current_pointer[path_node] = value + + current_pointer = current_pointer[path_node] + + return dictionary + + +def pick_attributes(dictionary, paths): + """ + Picks selected attributes a returns them as a new dictionary. + This function works as a whitelist of attributes to keep in a new dictionary. + + :type dictionary: dict + :param dictionary: Dict to pick attributes from. + + :type paths: list of (list or str) + :param paths: List of lists or strings with dot-separated paths, starting from the root node "$". + + :rtype: dict + :return: Returns whitelisted dictionary. + """ + new_dictionary = {} + + for path in paths: + value = get_safe(dictionary, path) + + if value is not None: + set_safe_mutable(new_dictionary, path, value) + + return new_dictionary + + +def select_attributes(obj: Dict, attributes: List[str]) -> Dict: + """Select a subset of attributes from the given dict (returns a copy)""" + attributes = attributes if is_list_or_tuple(attributes) else [attributes] + return {k: v for k, v in obj.items() if k in attributes} + + +def remove_attributes(obj: Dict, attributes: List[str], recursive: bool = False) -> Dict: + """Remove a set of attributes from the given dict (in-place)""" + from localstack.utils.objects import recurse_object + + if recursive: + + def _remove(o, **kwargs): + if isinstance(o, dict): + remove_attributes(o, attributes) + return o + + return recurse_object(obj, _remove) + + attributes = ensure_list(attributes) + for attr in attributes: + obj.pop(attr, None) + return obj + + +def rename_attributes( + obj: Dict, old_to_new_attributes: Dict[str, str], in_place: bool = False +) -> Dict: + """Rename a set of attributes in the given dict object. Second parameter is a dict that maps old to + new attribute names. Default is to return a copy, but can also pass in_place=True.""" + if not in_place: + obj = dict(obj) + for old_name, new_name in old_to_new_attributes.items(): + if old_name in obj: + obj[new_name] = obj.pop(old_name) + return obj + + +def is_list_or_tuple(obj) -> bool: + return isinstance(obj, (list, tuple)) + + +def ensure_list(obj: Any, wrap_none=False) -> Optional[List]: + """Wrap the given object in a list, or return the object itself if it already is a list.""" + if obj is None and not wrap_none: + return obj + return obj if isinstance(obj, list) else [obj] + + +def to_unique_items_list(inputs, comparator=None): + """Return a list of unique items from the given input iterable. + The comparator(item1, item2) returns True/False or an int for comparison.""" + + def contained(item): + for r in result: + if comparator: + cmp_res = comparator(item, r) + if cmp_res is True or str(cmp_res) == "0": + return True + elif item == r: + return True + + result = [] + for it in inputs: + if not contained(it): + result.append(it) + return result + + +def merge_recursive(source, destination, none_values=None, overwrite=False): + if none_values is None: + none_values = [None] + for key, value in source.items(): + if isinstance(value, dict): + # get node or create one + node = destination.setdefault(key, {}) + merge_recursive(value, node, none_values=none_values, overwrite=overwrite) + else: + from requests.models import CaseInsensitiveDict + + if not isinstance(destination, (dict, CaseInsensitiveDict)): + LOG.warning( + "Destination for merging %s=%s is not dict: %s (%s)", + key, + value, + destination, + type(destination), + ) + if overwrite or destination.get(key) in none_values: + destination[key] = value + return destination + + +def merge_dicts(*dicts, **kwargs): + """Merge all dicts in `*dicts` into a single dict, and return the result. If any of the entries + in `*dicts` is None, and `default` is specified as keyword argument, then return `default`.""" + result = {} + for d in dicts: + if d is None and "default" in kwargs: + return kwargs["default"] + if d: + result.update(d) + return result + + +def remove_none_values_from_dict(dict: Dict) -> Dict: + return {k: v for (k, v) in dict.items() if v is not None} + + +def last_index_of(array, value): + """Return the last index of `value` in the given list, or -1 if it does not exist.""" + result = -1 + for i in reversed(range(len(array))): + entry = array[i] + if entry == value or (callable(value) and value(entry)): + return i + return result + + +def is_sub_dict(child_dict: Dict, parent_dict: Dict) -> bool: + """Returns whether the first dict is a sub-dict (subset) of the second dict.""" + return all(parent_dict.get(key) == val for key, val in child_dict.items()) + + +def items_equivalent(list1, list2, comparator): + """Returns whether two lists are equivalent (i.e., same items contained in both lists, + irrespective of the items' order) with respect to a comparator function.""" + + def contained(item): + for _item in list2: + if comparator(item, _item): + return True + + if len(list1) != len(list2): + return False + for item in list1: + if not contained(item): + return False + return True + + +def is_none_or_empty(obj: Union[Optional[str], Optional[list]]) -> bool: + return ( + obj is None + or (isinstance(obj, str) and obj.strip() == "") + or (isinstance(obj, Sized) and len(obj) == 0) + ) + + +def select_from_typed_dict(typed_dict: Type[TypedDict], obj: Dict, filter: bool = False) -> Dict: + """ + Select a subset of attributes from a dictionary based on the keys of a given `TypedDict`. + :param typed_dict: the `TypedDict` blueprint + :param obj: the object to filter + :param filter: if True, remove all keys with an empty (e.g., empty string or dictionary) or `None` value + :return: the resulting dictionary (it returns a copy) + """ + selection = select_attributes( + obj, [*typed_dict.__required_keys__, *typed_dict.__optional_keys__] + ) + if filter: + selection = {k: v for k, v in selection.items() if v} + return selection + + +T = TypeVar("T", bound=Dict) + + +def convert_to_typed_dict(typed_dict: Type[T], obj: Dict, strict: bool = False) -> T: + """ + Converts the given object to the given typed dict (by calling the type constructors). + Limitations: + - This does not work for ForwardRefs (type refs in quotes). + - If a type is a Union, the first type is used for the conversion. + - The conversion fails for types which cannot be instantiated with the constructor. + + :param typed_dict: to convert the given object to + :param obj: object to convert matching keys to the types defined in the typed dict + :param strict: True if a TypeError should be raised in case the conversion fails + :return: obj converted to the typed dict T + """ + result = cast(T, select_from_typed_dict(typed_dict, obj, filter=True)) + for key, key_type in typed_dict.__annotations__.items(): + if key in result: + # If it's a Union, or optional, we extract the first type argument + if get_origin(key_type) in [Union, Optional]: + key_type = get_args(key_type)[0] + # Use duck-typing to check if the dict is a typed dict + if hasattr(key_type, "__required_keys__") and hasattr(key_type, "__optional_keys__"): + result[key] = convert_to_typed_dict(key_type, result[key]) + else: + # Otherwise, we call the type's constructor (on a best-effort basis) + try: + result[key] = key_type(result[key]) + except TypeError as e: + if strict: + raise e + else: + LOG.debug("Could not convert %s to %s.", key, key_type) + return result + + +def dict_multi_values(elements: Union[List, Dict]) -> Dict[str, List[Any]]: + """ + Return a dictionary with the original keys from the list of dictionary and the + values are the list of values of the original dictionary. + """ + result_dict = {} + if isinstance(elements, dict): + for key, value in elements.items(): + if isinstance(value, list): + result_dict[key] = value + else: + result_dict[key] = [value] + elif isinstance(elements, list): + if isinstance(elements[0], list): + for key, value in elements: + if key in result_dict: + result_dict[key].append(value) + else: + result_dict[key] = [value] + else: + result_dict[elements[0]] = elements[1:] + return result_dict + + +ItemType = TypeVar("ItemType") + + +def split_list_by( + lst: Iterable[ItemType], predicate: Callable[[ItemType], bool] +) -> Tuple[List[ItemType], List[ItemType]]: + truthy, falsy = [], [] + + for item in lst: + if predicate(item): + truthy.append(item) + else: + falsy.append(item) + + return truthy, falsy + + +def is_comma_delimited_list(string: str, item_regex: Optional[str] = None) -> bool: + """ + Checks if the given string is a comma-delimited list of items. + The optional `item_regex` parameter specifies the regex pattern for each item in the list. + """ + item_regex = item_regex or DEFAULT_REGEX_LIST_ITEM + + pattern = re.compile(rf"^\s*({item_regex})(\s*,\s*{item_regex})*\s*$") + if pattern.match(string) is None: + return False + return True diff --git a/localstack-core/localstack/utils/common.py b/localstack-core/localstack/utils/common.py new file mode 100644 index 0000000000000..77b17254e1906 --- /dev/null +++ b/localstack-core/localstack/utils/common.py @@ -0,0 +1,229 @@ +from localstack import config + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.archives import get_unzipped_size, is_zip_file, untar, unzip # noqa + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.collections import ( # noqa + DelSafeDict, + HashableList, + PaginatedList, + ensure_list, + is_list_or_tuple, + is_none_or_empty, + is_sub_dict, + items_equivalent, + last_index_of, + merge_dicts, + merge_recursive, + remove_attributes, + remove_none_values_from_dict, + rename_attributes, + select_attributes, + to_unique_items_list, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.crypto import ( # noqa + PEM_CERT_END, + PEM_CERT_START, + PEM_KEY_END_REGEX, + PEM_KEY_START_REGEX, + generate_ssl_cert, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.files import ( # noqa + TMP_FILES, + chmod_r, + chown_r, + cleanup_tmp_files, + cp_r, + disk_usage, + ensure_readable, + file_exists_not_empty, + get_or_create_file, + is_empty_dir, + load_file, + mkdir, + new_tmp_dir, + new_tmp_file, + replace_in_file, + rm_rf, + save_file, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.functions import ( # noqa + call_safe, + empty_context_manager, + prevent_stack_overflow, + run_safe, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.http import ( # noqa + NetrcBypassAuth, + _RequestsSafe, + download, + get_proxies, + make_http_request, + parse_request_data, + replace_response_content, + safe_requests, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.json import ( # noqa + CustomEncoder, + FileMappedDocument, + assign_to_path, + canonical_json, + clone, + clone_safe, + extract_from_jsonpointer_path, + extract_jsonpath, + fix_json_keys, + json_safe, + parse_json_or_yaml, + try_json, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.net import ( # noqa + PortNotAvailableException, + PortRange, + get_free_tcp_port, + is_ip_address, + is_ipv4_address, + is_port_open, + port_can_be_bound, + resolve_hostname, + wait_for_port_closed, + wait_for_port_open, + wait_for_port_status, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.numbers import format_bytes, format_number, is_number # noqa + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.objects import ( # noqa + ArbitraryAccessObj, + Mock, + ObjectIdHashComparator, + SubtypesInstanceManager, + fully_qualified_class_name, + get_all_subclasses, + keys_to_lower, + not_none_or, + recurse_object, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.platform import ( # noqa + get_arch, + get_os, + in_docker, + is_debian, + is_linux, + is_mac_os, + is_windows, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.run import ( # noqa + CaptureOutput, + ShellCommandThread, + get_os_user, + is_command_available, + is_root, + kill_process_tree, + run, + run_for_max_seconds, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.strings import ( # noqa + base64_to_hex, + camel_to_snake_case, + canonicalize_bool_to_str, + convert_to_printable_chars, + first_char_to_lower, + first_char_to_upper, + is_base64, + is_string, + is_string_or_bytes, + long_uid, + md5, + short_uid, + short_uid_from_seed, + snake_to_camel_case, + str_insert, + str_remove, + str_startswith_ignore_case, + str_to_bool, + to_bytes, + to_str, + truncate, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.sync import ( # noqa + poll_condition, + retry, + sleep_forever, + synchronized, + wait_until, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.threads import ( # noqa + TMP_PROCESSES, + TMP_THREADS, + FuncThread, + cleanup_threads_and_processes, + parallelize, + start_thread, + start_worker_thread, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.time import ( # noqa + TIMESTAMP_FORMAT, + TIMESTAMP_FORMAT_MICROS, + TIMESTAMP_FORMAT_TZ, + epoch_timestamp, + isoformat_milliseconds, + mktime, + now, + now_utc, + parse_timestamp, + timestamp, + timestamp_millis, +) + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.urls import path_from_url # noqa + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.xml import obj_to_xml, strip_xmlns # noqa + + +# TODO: move somewhere sensible (probably localstack.runtime) +class ExternalServicePortsManager(PortRange): + """Manages the ports used for starting external services like ElasticSearch, OpenSearch,...""" + + def __init__(self): + super().__init__(config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END) + + +external_service_ports = ExternalServicePortsManager() +"""The PortRange object of LocalStack's external service port range. This port range is by default exposed by the +localstack container when starting via the CLI.""" + +# TODO: replace references with config.get_protocol/config.edge_ports_info +get_service_protocol = config.get_protocol + +# TODO: replace references to safe_run with localstack.utils.run.run +safe_run = run diff --git a/localstack-core/localstack/utils/config_listener.py b/localstack-core/localstack/utils/config_listener.py new file mode 100644 index 0000000000000..c8678baede5ae --- /dev/null +++ b/localstack-core/localstack/utils/config_listener.py @@ -0,0 +1,42 @@ +import json +import logging +import re +from typing import Callable, List + +from requests.models import Response + +from localstack import config + +LOG = logging.getLogger(__name__) + +CONFIG_LISTENERS: List[Callable[[str, str], None]] = [] + + +def trigger_config_listeners(variable, new_value): + LOG.debug("Updating config listeners") + for listener in CONFIG_LISTENERS: + listener(variable, new_value) + + +def update_config_variable(variable, new_value): + if new_value is not None: + LOG.info('Updating value of config variable "%s": %s', variable, new_value) + setattr(config, variable, new_value) + trigger_config_listeners(variable, new_value) + + +def _update_config_variable_handler(data): + response = Response() + data = json.loads(data) + variable = data.get("variable", "") + response._content = "{}" + response.status_code = 200 + if not re.match(r"^[_a-zA-Z0-9]+$", variable): + response.status_code = 400 + return response + new_value = data.get("value") + update_config_variable(variable, new_value) + value = getattr(config, variable, None) + result = {"variable": variable, "value": value} + response._content = json.dumps(result) + return response diff --git a/localstack-core/localstack/utils/container_networking.py b/localstack-core/localstack/utils/container_networking.py new file mode 100644 index 0000000000000..2e54dec0672ba --- /dev/null +++ b/localstack-core/localstack/utils/container_networking.py @@ -0,0 +1,143 @@ +import logging +import os +import re +from functools import lru_cache +from typing import Optional + +from localstack import config, constants +from localstack.utils.container_utils.container_client import ContainerException +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.net import get_docker_host_from_container + +LOG = logging.getLogger(__name__) + + +@lru_cache() +def get_main_container_network() -> Optional[str]: + """ + Gets the main network of the LocalStack container (if we run in one, bridge otherwise) + If there are multiple networks connected to the LocalStack container, we choose the first as "main" network + + :return: Network name + """ + if config.MAIN_DOCKER_NETWORK: + if config.is_in_docker: + networks = DOCKER_CLIENT.get_networks(get_main_container_name()) + if config.MAIN_DOCKER_NETWORK not in networks: + LOG.warning( + "The specified 'MAIN_DOCKER_NETWORK' is not connected to the LocalStack container! Falling back to %s", + networks[0], + ) + return networks[0] + return config.MAIN_DOCKER_NETWORK + + # use the default bridge network in case of host mode or if we can't resolve the networks for the main container + main_container_network = "bridge" + if config.is_in_docker: + try: + networks = DOCKER_CLIENT.get_networks(get_main_container_name()) + main_container_network = networks[0] + except Exception as e: + container_name = get_main_container_name() + LOG.info( + 'Unable to get network name of main container "%s", falling back to "bridge": %s', + container_name, + e, + ) + + LOG.info("Determined main container network: %s", main_container_network) + return main_container_network + + +@lru_cache() +def get_endpoint_for_network(network: Optional[str] = None) -> str: + """ + Get the LocalStack endpoint (= IP address) on the given network. + If a network is given, it will return the IP address/hostname of LocalStack on that network + If omitted, it will return the IP address/hostname of the main container network + This is a cached call, clear cache if networks might have changed + + :param network: Network to return the endpoint for + :return: IP address or hostname of LS on the given network + """ + container_name = get_main_container_name() + network = network or get_main_container_network() + main_container_ip = None + try: + if config.is_in_docker: + main_container_ip = DOCKER_CLIENT.get_container_ipv4_for_network( + container_name_or_id=container_name, + container_network=network, + ) + else: + # default gateway for the network should be the host + # In a Linux host-mode environment, the default gateway for the network should be the IP of the host + if config.is_in_linux: + main_container_ip = DOCKER_CLIENT.inspect_network(network)["IPAM"]["Config"][0][ + "Gateway" + ] + else: + # In a non-Linux host-mode environment, we need to determine the IP of the host by running a container + # (basically macOS host mode, i.e. this is a feature to improve the developer experience) + image_name = constants.DOCKER_IMAGE_NAME + out, _ = DOCKER_CLIENT.run_container( + image_name, + remove=True, + entrypoint="", + command=["ping", "-c", "1", "host.docker.internal"], + ) + out = out.decode(config.DEFAULT_ENCODING) if isinstance(out, bytes) else out + ip = re.match(r"PING[^\(]+\(([^\)]+)\).*", out, re.MULTILINE | re.DOTALL) + ip = ip and ip.group(1) + if ip: + main_container_ip = ip + LOG.info("Determined main container target IP: %s", main_container_ip) + except Exception as e: + LOG.info("Unable to get main container IP address: %s", e) + + if not main_container_ip: + # fall back to returning the hostname/IP of the Docker host, if we cannot determine the main container IP + return get_docker_host_from_container() + + return main_container_ip + + +def get_main_container_ip(): + """ + Get the container IP address of the LocalStack container. + Use get_endpoint_for network where possible, as it allows better control about which address to return + + :return: IP address of LocalStack container + """ + container_name = get_main_container_name() + return DOCKER_CLIENT.get_container_ip(container_name) + + +def get_main_container_id(): + """ + Return the container ID of the LocalStack container + + :return: container ID + """ + container_name = get_main_container_name() + try: + return DOCKER_CLIENT.get_container_id(container_name) + except ContainerException: + return None + + +@lru_cache() +def get_main_container_name(): + """ + Returns the container name of the LocalStack container + + :return: LocalStack container name + """ + hostname = os.environ.get("HOSTNAME") + if hostname: + try: + return DOCKER_CLIENT.get_container_name(hostname) + except ContainerException: + return config.MAIN_CONTAINER_NAME + else: + return config.MAIN_CONTAINER_NAME diff --git a/localstack-core/localstack/utils/container_utils/__init__.py b/localstack-core/localstack/utils/container_utils/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/container_utils/container_client.py b/localstack-core/localstack/utils/container_utils/container_client.py new file mode 100644 index 0000000000000..baac120b6ff3c --- /dev/null +++ b/localstack-core/localstack/utils/container_utils/container_client.py @@ -0,0 +1,1536 @@ +import dataclasses +import io +import ipaddress +import logging +import os +import re +import shlex +import sys +import tarfile +import tempfile +from abc import ABCMeta, abstractmethod +from enum import Enum, unique +from pathlib import Path +from typing import ( + Callable, + Dict, + List, + Literal, + NamedTuple, + Optional, + Protocol, + Tuple, + TypedDict, + Union, + get_args, +) + +import dotenv + +from localstack import config +from localstack.constants import DEFAULT_VOLUME_DIR +from localstack.utils.collections import HashableList, ensure_list +from localstack.utils.files import TMP_FILES, chmod_r, rm_rf, save_file +from localstack.utils.no_exit_argument_parser import NoExitArgumentParser +from localstack.utils.strings import short_uid + +LOG = logging.getLogger(__name__) + +# list of well-known image repo prefixes that should be stripped off to canonicalize image names +WELL_KNOWN_IMAGE_REPO_PREFIXES = ("localhost/", "docker.io/library/") + + +@unique +class DockerContainerStatus(Enum): + DOWN = -1 + NON_EXISTENT = 0 + UP = 1 + PAUSED = 2 + + +class DockerContainerStats(TypedDict): + """Container usage statistics""" + + Container: str + ID: str + Name: str + BlockIO: tuple[int, int] + CPUPerc: float + MemPerc: float + MemUsage: tuple[int, int] + NetIO: tuple[int, int] + PIDs: int + SDKStats: Optional[dict] + + +class ContainerException(Exception): + def __init__(self, message=None, stdout=None, stderr=None) -> None: + self.message = message or "Error during the communication with the docker daemon" + self.stdout = stdout + self.stderr = stderr + + +class NoSuchObject(ContainerException): + def __init__(self, object_id: str, message=None, stdout=None, stderr=None) -> None: + message = message or f"Docker object {object_id} not found" + super().__init__(message, stdout, stderr) + self.object_id = object_id + + +class NoSuchContainer(ContainerException): + def __init__(self, container_name_or_id: str, message=None, stdout=None, stderr=None) -> None: + message = message or f"Docker container {container_name_or_id} not found" + super().__init__(message, stdout, stderr) + self.container_name_or_id = container_name_or_id + + +class NoSuchImage(ContainerException): + def __init__(self, image_name: str, message=None, stdout=None, stderr=None) -> None: + message = message or f"Docker image {image_name} not found" + super().__init__(message, stdout, stderr) + self.image_name = image_name + + +class NoSuchNetwork(ContainerException): + def __init__(self, network_name: str, message=None, stdout=None, stderr=None) -> None: + message = message or f"Docker network {network_name} not found" + super().__init__(message, stdout, stderr) + self.network_name = network_name + + +class RegistryConnectionError(ContainerException): + def __init__(self, details: str, message=None, stdout=None, stderr=None) -> None: + message = message or f"Connection error: {details}" + super().__init__(message, stdout, stderr) + self.details = details + + +class DockerNotAvailable(ContainerException): + def __init__(self, message=None, stdout=None, stderr=None) -> None: + message = message or "Docker not available" + super().__init__(message, stdout, stderr) + + +class AccessDenied(ContainerException): + def __init__(self, object_name: str, message=None, stdout=None, stderr=None) -> None: + message = message or f"Access denied to {object_name}" + super().__init__(message, stdout, stderr) + self.object_name = object_name + + +class CancellableStream(Protocol): + """Describes a generator that can be closed. Borrowed from ``docker.types.daemon``.""" + + def __iter__(self): + raise NotImplementedError + + def __next__(self): + raise NotImplementedError + + def close(self): + raise NotImplementedError + + +class DockerPlatform(str): + """Platform in the format ``os[/arch[/variant]]``""" + + linux_amd64 = "linux/amd64" + linux_arm64 = "linux/arm64" + + +@dataclasses.dataclass +class Ulimit: + """The ``ulimit`` settings for the container. + See https://www.tutorialspoint.com/setting-ulimit-values-on-docker-containers + """ + + name: str + soft_limit: int + hard_limit: Optional[int] = None + + def __repr__(self): + """Format: =[:]""" + ulimit_string = f"{self.name}={self.soft_limit}" + if self.hard_limit: + ulimit_string += f":{self.hard_limit}" + return ulimit_string + + +# defines the type for port mappings (source->target port range) +PortRange = Union[List, HashableList] +# defines the protocol for a port range ("tcp" or "udp") +PortProtocol = str + + +def isinstance_union(obj, class_or_tuple): + # that's some dirty hack + if sys.version_info < (3, 10): + return isinstance(obj, get_args(PortRange)) + else: + return isinstance(obj, class_or_tuple) + + +class PortMappings: + """Maps source to target port ranges for Docker port mappings.""" + + # bind host to be used for defining port mappings + bind_host: str + # maps `from` port range to `to` port range for port mappings + mappings: Dict[Tuple[PortRange, PortProtocol], List] + + def __init__(self, bind_host: str = None): + self.bind_host = bind_host if bind_host else "" + self.mappings = {} + + def add( + self, + port: Union[int, PortRange], + mapped: Union[int, PortRange] = None, + protocol: PortProtocol = "tcp", + ): + mapped = mapped or port + if isinstance_union(port, PortRange): + for i in range(port[1] - port[0] + 1): + if isinstance_union(mapped, PortRange): + self.add(port[0] + i, mapped[0] + i, protocol) + else: + self.add(port[0] + i, mapped, protocol) + return + if port is None or int(port) < 0: + raise Exception(f"Unable to add mapping for invalid port: {port}") + if self.contains(port, protocol): + return + bisected_host_port = None + for (from_range, from_protocol), to_range in self.mappings.items(): + if not from_protocol == protocol: + continue + if not self.in_expanded_range(port, from_range): + continue + if not self.in_expanded_range(mapped, to_range): + continue + from_range_len = from_range[1] - from_range[0] + to_range_len = to_range[1] - to_range[0] + is_uniform = from_range_len == to_range_len + if is_uniform: + self.expand_range(port, from_range, protocol=protocol, remap=True) + self.expand_range(mapped, to_range, protocol=protocol) + else: + if not self.in_range(mapped, to_range): + continue + # extending a 1 to 1 mapping to be many to 1 + elif from_range_len == 1: + self.expand_range(port, from_range, protocol=protocol, remap=True) + # splitting a uniform mapping + else: + bisected_port_index = mapped - to_range[0] + bisected_host_port = from_range[0] + bisected_port_index + self.bisect_range(mapped, to_range, protocol=protocol) + self.bisect_range(bisected_host_port, from_range, protocol=protocol, remap=True) + break + return + if bisected_host_port is None: + port_range = [port, port] + elif bisected_host_port < port: + port_range = [bisected_host_port, port] + else: + port_range = [port, bisected_host_port] + protocol = str(protocol or "tcp").lower() + self.mappings[(HashableList(port_range), protocol)] = [mapped, mapped] + + def to_str(self) -> str: + bind_address = f"{self.bind_host}:" if self.bind_host else "" + + def entry(k, v): + from_range, protocol = k + to_range = v + # use / suffix if the protocol is not"tcp" + protocol_suffix = f"/{protocol}" if protocol != "tcp" else "" + if from_range[0] == from_range[1] and to_range[0] == to_range[1]: + return f"-p {bind_address}{from_range[0]}:{to_range[0]}{protocol_suffix}" + if from_range[0] != from_range[1] and to_range[0] == to_range[1]: + return f"-p {bind_address}{from_range[0]}-{from_range[1]}:{to_range[0]}{protocol_suffix}" + return f"-p {bind_address}{from_range[0]}-{from_range[1]}:{to_range[0]}-{to_range[1]}{protocol_suffix}" + + return " ".join([entry(k, v) for k, v in self.mappings.items()]) + + def to_list(self) -> List[str]: # TODO test + bind_address = f"{self.bind_host}:" if self.bind_host else "" + + def entry(k, v): + from_range, protocol = k + to_range = v + protocol_suffix = f"/{protocol}" if protocol != "tcp" else "" + if from_range[0] == from_range[1] and to_range[0] == to_range[1]: + return ["-p", f"{bind_address}{from_range[0]}:{to_range[0]}{protocol_suffix}"] + return [ + "-p", + f"{bind_address}{from_range[0]}-{from_range[1]}:{to_range[0]}-{to_range[1]}{protocol_suffix}", + ] + + return [item for k, v in self.mappings.items() for item in entry(k, v)] + + def to_dict(self) -> Dict[str, Union[Tuple[str, Union[int, List[int]]], int]]: + bind_address = self.bind_host or "" + + def bind_port(bind_address, host_port): + if host_port == 0: + return None + elif bind_address: + return (bind_address, host_port) + else: + return host_port + + def entry(k, v): + from_range, protocol = k + to_range = v + protocol_suffix = f"/{protocol}" + if from_range[0] != from_range[1] and to_range[0] == to_range[1]: + container_port = to_range[0] + host_ports = list(range(from_range[0], from_range[1] + 1)) + return [ + ( + f"{container_port}{protocol_suffix}", + (bind_address, host_ports) if bind_address else host_ports, + ) + ] + return [ + ( + f"{container_port}{protocol_suffix}", + bind_port(bind_address, host_port), + ) + for container_port, host_port in zip( + range(to_range[0], to_range[1] + 1), + range(from_range[0], from_range[1] + 1), + strict=False, + ) + ] + + items = [item for k, v in self.mappings.items() for item in entry(k, v)] + return dict(items) + + def contains(self, port: int, protocol: PortProtocol = "tcp") -> bool: + for from_range_w_protocol, to_range in self.mappings.items(): + from_protocol = from_range_w_protocol[1] + if from_protocol == protocol: + from_range = from_range_w_protocol[0] + if self.in_range(port, from_range): + return True + + def in_range(self, port: int, range: PortRange) -> bool: + return port >= range[0] and port <= range[1] + + def in_expanded_range(self, port: int, range: PortRange): + return port >= range[0] - 1 and port <= range[1] + 1 + + def expand_range( + self, port: int, range: PortRange, protocol: PortProtocol = "tcp", remap: bool = False + ): + """ + Expand the given port range by the given port. If remap==True, put the updated range into self.mappings + """ + if self.in_range(port, range): + return + new_range = list(range) if remap else range + if port == range[0] - 1: + new_range[0] = port + elif port == range[1] + 1: + new_range[1] = port + else: + raise Exception(f"Unable to add port {port} to existing range {range}") + if remap: + self._remap_range(range, new_range, protocol=protocol) + + def bisect_range( + self, port: int, range: PortRange, protocol: PortProtocol = "tcp", remap: bool = False + ): + """ + Bisect a port range, at the provided port. This is needed in some cases when adding a + non-uniform host to port mapping adjacent to an existing port range. + If remap==True, put the updated range into self.mappings + """ + if not self.in_range(port, range): + return + new_range = list(range) if remap else range + if port == range[0]: + new_range[0] = port + 1 + else: + new_range[1] = port - 1 + if remap: + self._remap_range(range, new_range, protocol) + + def _remap_range(self, old_key: PortRange, new_key: PortRange, protocol: PortProtocol): + self.mappings[(HashableList(new_key), protocol)] = self.mappings.pop( + (HashableList(old_key), protocol) + ) + + def __repr__(self): + return f"" + + +SimpleVolumeBind = Tuple[str, str] +"""Type alias for a simple version of VolumeBind""" + + +@dataclasses.dataclass +class BindMount: + """Represents a --volume argument run/create command. When using VolumeBind to bind-mount a file or directory + that does not yet exist on the Docker host, -v creates the endpoint for you. It is always created as a directory. + """ + + host_dir: str + container_dir: str + read_only: bool = False + + def to_str(self) -> str: + args = [] + + if self.host_dir: + args.append(self.host_dir) + + if not self.container_dir: + raise ValueError("no container dir specified") + + args.append(self.container_dir) + + if self.read_only: + args.append("ro") + + return ":".join(args) + + def to_docker_sdk_parameters(self) -> tuple[str, dict[str, str]]: + return str(self.host_dir), { + "bind": self.container_dir, + "mode": "ro" if self.read_only else "rw", + } + + @classmethod + def parse(cls, param: str) -> "BindMount": + parts = param.split(":") + if 1 > len(parts) > 3: + raise ValueError(f"Cannot parse volume bind {param}") + + volume = cls(parts[0], parts[1]) + if len(parts) == 3: + if "ro" in parts[2].split(","): + volume.read_only = True + return volume + + +@dataclasses.dataclass +class VolumeDirMount: + volume_path: str + """ + Absolute path inside /var/lib/localstack to mount into the container + """ + container_path: str + """ + Target path inside the started container + """ + read_only: bool = False + + def to_str(self) -> str: + self._validate() + from localstack.utils.docker_utils import get_host_path_for_path_in_docker + + host_dir = get_host_path_for_path_in_docker(self.volume_path) + return f"{host_dir}:{self.container_path}{':ro' if self.read_only else ''}" + + def _validate(self): + if not self.volume_path: + raise ValueError("no volume dir specified") + if config.is_in_docker and not self.volume_path.startswith(DEFAULT_VOLUME_DIR): + raise ValueError(f"volume dir not starting with {DEFAULT_VOLUME_DIR}") + if not self.container_path: + raise ValueError("no container dir specified") + + def to_docker_sdk_parameters(self) -> tuple[str, dict[str, str]]: + self._validate() + from localstack.utils.docker_utils import get_host_path_for_path_in_docker + + host_dir = get_host_path_for_path_in_docker(self.volume_path) + return host_dir, { + "bind": self.container_path, + "mode": "ro" if self.read_only else "rw", + } + + +class VolumeMappings: + mappings: List[Union[SimpleVolumeBind, BindMount]] + + def __init__(self, mappings: List[Union[SimpleVolumeBind, BindMount, VolumeDirMount]] = None): + self.mappings = mappings if mappings is not None else [] + + def add(self, mapping: Union[SimpleVolumeBind, BindMount, VolumeDirMount]): + self.append(mapping) + + def append( + self, + mapping: Union[ + SimpleVolumeBind, + BindMount, + VolumeDirMount, + ], + ): + self.mappings.append(mapping) + + def find_target_mapping( + self, container_dir: str + ) -> Optional[Union[SimpleVolumeBind, BindMount, VolumeDirMount]]: + """ + Looks through the volumes and returns the one where the container dir matches ``container_dir``. + Returns None if there is no volume mapping to the given container directory. + + :param container_dir: the target of the volume mapping, i.e., the path in the container + :return: the volume mapping or None + """ + for volume in self.mappings: + target_dir = volume[1] if isinstance(volume, tuple) else volume.container_dir + if container_dir == target_dir: + return volume + return None + + def __iter__(self): + return self.mappings.__iter__() + + def __repr__(self): + return self.mappings.__repr__() + + def __len__(self): + return len(self.mappings) + + def __getitem__(self, item: int): + return self.mappings[item] + + +VolumeType = Literal["bind", "volume"] + + +class VolumeInfo(NamedTuple): + """Container volume information.""" + + type: VolumeType + source: str + destination: str + mode: str + rw: bool + propagation: str + name: Optional[str] = None + driver: Optional[str] = None + + +@dataclasses.dataclass +class LogConfig: + type: Literal["json-file", "syslog", "journald", "gelf", "fluentd", "none", "awslogs", "splunk"] + config: Dict[str, str] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass +class ContainerConfiguration: + image_name: str + name: Optional[str] = None + volumes: VolumeMappings = dataclasses.field(default_factory=VolumeMappings) + ports: PortMappings = dataclasses.field(default_factory=PortMappings) + exposed_ports: List[str] = dataclasses.field(default_factory=list) + entrypoint: Optional[Union[List[str], str]] = None + additional_flags: Optional[str] = None + command: Optional[List[str]] = None + env_vars: Dict[str, str] = dataclasses.field(default_factory=dict) + + privileged: bool = False + remove: bool = False + interactive: bool = False + tty: bool = False + detach: bool = False + + stdin: Optional[str] = None + user: Optional[str] = None + cap_add: Optional[List[str]] = None + cap_drop: Optional[List[str]] = None + security_opt: Optional[List[str]] = None + network: Optional[str] = None + dns: Optional[str] = None + workdir: Optional[str] = None + platform: Optional[str] = None + ulimits: Optional[List[Ulimit]] = None + labels: Optional[Dict[str, str]] = None + init: Optional[bool] = None + log_config: Optional[LogConfig] = None + + +class ContainerConfigurator(Protocol): + """Protocol for functional configurators. A ContainerConfigurator modifies, when called, + a ContainerConfiguration in place.""" + + def __call__(self, configuration: ContainerConfiguration): + """ + Modify the given container configuration. + + :param configuration: the configuration to modify + """ + ... + + +@dataclasses.dataclass +class DockerRunFlags: + """Class to capture Docker run/create flags for a container. + run: https://docs.docker.com/engine/reference/commandline/run/ + create: https://docs.docker.com/engine/reference/commandline/create/ + """ + + env_vars: Optional[Dict[str, str]] + extra_hosts: Optional[Dict[str, str]] + labels: Optional[Dict[str, str]] + volumes: Optional[List[SimpleVolumeBind]] + network: Optional[str] + platform: Optional[DockerPlatform] + privileged: Optional[bool] + ports: Optional[PortMappings] + ulimits: Optional[List[Ulimit]] + user: Optional[str] + dns: Optional[List[str]] + + +class RegistryResolverStrategy(Protocol): + def resolve(self, image_name: str) -> str: ... + + +class HardCodedResolver: + def resolve(self, image_name: str) -> str: # noqa + return image_name + + +# TODO: remove Docker/Podman compatibility switches (in particular strip_wellknown_repo_prefixes=...) +# from the container client base interface and introduce derived Podman client implementations instead! +class ContainerClient(metaclass=ABCMeta): + registry_resolver_strategy: RegistryResolverStrategy = HardCodedResolver() + + @abstractmethod + def get_system_info(self) -> dict: + """Returns the docker system-wide information as dictionary (``docker info``).""" + + def get_system_id(self) -> str: + """Returns the unique and stable ID of the docker daemon.""" + return self.get_system_info()["ID"] + + @abstractmethod + def get_container_status(self, container_name: str) -> DockerContainerStatus: + """Returns the status of the container with the given name""" + pass + + def get_container_stats(self, container_name: str) -> DockerContainerStats: + """Returns the usage statistics of the container with the given name""" + pass + + def get_networks(self, container_name: str) -> List[str]: + LOG.debug("Getting networks for container: %s", container_name) + container_attrs = self.inspect_container(container_name_or_id=container_name) + return list(container_attrs["NetworkSettings"].get("Networks", {}).keys()) + + def get_container_ipv4_for_network( + self, container_name_or_id: str, container_network: str + ) -> str: + """ + Returns the IPv4 address for the container on the interface connected to the given network + :param container_name_or_id: Container to inspect + :param container_network: Network the IP address will belong to + :return: IP address of the given container on the interface connected to the given network + """ + LOG.debug( + "Getting ipv4 address for container %s in network %s.", + container_name_or_id, + container_network, + ) + # we always need the ID for this + container_id = self.get_container_id(container_name=container_name_or_id) + network_attrs = self.inspect_network(container_network) + containers = network_attrs.get("Containers") or {} + if container_id not in containers: + LOG.debug("Network attributes: %s", network_attrs) + try: + inspection = self.inspect_container(container_name_or_id=container_name_or_id) + LOG.debug("Container %s Attributes: %s", container_name_or_id, inspection) + logs = self.get_container_logs(container_name_or_id=container_name_or_id) + LOG.debug("Container %s Logs: %s", container_name_or_id, logs) + except ContainerException as e: + LOG.debug("Cannot inspect container %s: %s", container_name_or_id, e) + raise ContainerException( + "Container %s is not connected to target network %s", + container_name_or_id, + container_network, + ) + try: + ip = str(ipaddress.IPv4Interface(containers[container_id]["IPv4Address"]).ip) + except Exception as e: + raise ContainerException( + f"Unable to detect IP address for container {container_name_or_id} in network {container_network}: {e}" + ) + return ip + + @abstractmethod + def stop_container(self, container_name: str, timeout: int = 10): + """Stops container with given name + :param container_name: Container identifier (name or id) of the container to be stopped + :param timeout: Timeout after which SIGKILL is sent to the container. + """ + + @abstractmethod + def restart_container(self, container_name: str, timeout: int = 10): + """Restarts a container with the given name. + :param container_name: Container identifier + :param timeout: Seconds to wait for stop before killing the container + """ + + @abstractmethod + def pause_container(self, container_name: str): + """Pauses a container with the given name.""" + + @abstractmethod + def unpause_container(self, container_name: str): + """Unpauses a container with the given name.""" + + @abstractmethod + def remove_container(self, container_name: str, force=True, check_existence=False) -> None: + """Removes container with given name""" + + @abstractmethod + def remove_image(self, image: str, force: bool = True) -> None: + """Removes an image with given name + + :param image: Image name and tag + :param force: Force removal + """ + + @abstractmethod + def list_containers(self, filter: Union[List[str], str, None] = None, all=True) -> List[dict]: + """List all containers matching the given filters + + :return: A list of dicts with keys id, image, name, labels, status + """ + + def get_running_container_names(self) -> List[str]: + """Returns a list of the names of all running containers""" + result = self.list_containers(all=False) + result = [container["name"] for container in result] + return result + + def is_container_running(self, container_name: str) -> bool: + """Checks whether a container with a given name is currently running""" + return container_name in self.get_running_container_names() + + def create_file_in_container( + self, + container_name, + file_contents: bytes, + container_path: str, + chmod_mode: Optional[int] = None, + ) -> None: + """ + Create a file in container with the provided content. Provide the 'chmod_mode' argument if you want the file to have specific permissions. + """ + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(file_contents) + tmp.flush() + if chmod_mode is not None: + chmod_r(tmp.name, chmod_mode) + self.copy_into_container( + container_name=container_name, + local_path=tmp.name, + container_path=container_path, + ) + + @abstractmethod + def copy_into_container( + self, container_name: str, local_path: str, container_path: str + ) -> None: + """Copy contents of the given local path into the container""" + + @abstractmethod + def copy_from_container( + self, container_name: str, local_path: str, container_path: str + ) -> None: + """Copy contents of the given container to the host""" + + @abstractmethod + def pull_image( + self, + docker_image: str, + platform: Optional[DockerPlatform] = None, + log_handler: Optional[Callable[[str], None]] = None, + ) -> None: + """ + Pulls an image with a given name from a Docker registry + + :log_handler: Optional parameter that can be used to process the logs. Logs will be streamed if possible, but this is not guaranteed. + """ + + @abstractmethod + def push_image(self, docker_image: str) -> None: + """Pushes an image with a given name to a Docker registry""" + + @abstractmethod + def build_image( + self, + dockerfile_path: str, + image_name: str, + context_path: str = None, + platform: Optional[DockerPlatform] = None, + ) -> str: + """Builds an image from the given Dockerfile + + :param dockerfile_path: Path to Dockerfile, or a directory that contains a Dockerfile + :param image_name: Name of the image to be built + :param context_path: Path for build context (defaults to dirname of Dockerfile) + :param platform: Target platform for build (defaults to platform of Docker host) + :return: Build logs as a string. + """ + + @abstractmethod + def tag_image(self, source_ref: str, target_name: str) -> None: + """Tags an image with a new name + + :param source_ref: Name or ID of the image to be tagged + :param target_name: New name (tag) of the tagged image + """ + + @abstractmethod + def get_docker_image_names( + self, + strip_latest: bool = True, + include_tags: bool = True, + strip_wellknown_repo_prefixes: bool = True, + ) -> List[str]: + """ + Get all names of docker images available to the container engine + :param strip_latest: return images both with and without :latest tag + :param include_tags: include tags of the images in the names + :param strip_wellknown_repo_prefixes: whether to strip off well-known repo prefixes like + "localhost/" or "docker.io/library/" which are added by the Podman API, but not by Docker + :return: List of image names + """ + + @abstractmethod + def get_container_logs(self, container_name_or_id: str, safe: bool = False) -> str: + """Get all logs of a given container""" + + @abstractmethod + def stream_container_logs(self, container_name_or_id: str) -> CancellableStream: + """Returns a blocking generator you can iterate over to retrieve log output as it happens.""" + + @abstractmethod + def inspect_container(self, container_name_or_id: str) -> Dict[str, Union[Dict, str]]: + """Get detailed attributes of a container. + + :return: Dict containing docker attributes as returned by the daemon + """ + + def inspect_container_volumes(self, container_name_or_id) -> List[VolumeInfo]: + """Return information about the volumes mounted into the given container. + + :param container_name_or_id: the container name or id + :return: a list of volumes + """ + volumes = [] + for doc in self.inspect_container(container_name_or_id)["Mounts"]: + volumes.append(VolumeInfo(**{k.lower(): v for k, v in doc.items()})) + + return volumes + + @abstractmethod + def inspect_image( + self, image_name: str, pull: bool = True, strip_wellknown_repo_prefixes: bool = True + ) -> Dict[str, Union[dict, list, str]]: + """Get detailed attributes of an image. + + :param image_name: Image name to inspect + :param pull: Whether to pull image if not existent + :param strip_wellknown_repo_prefixes: whether to strip off well-known repo prefixes like + "localhost/" or "docker.io/library/" which are added by the Podman API, but not by Docker + :return: Dict containing docker attributes as returned by the daemon + """ + + @abstractmethod + def create_network(self, network_name: str) -> str: + """ + Creates a network with the given name + :param network_name: Name of the network + :return Network ID + """ + + @abstractmethod + def delete_network(self, network_name: str) -> None: + """ + Delete a network with the given name + :param network_name: Name of the network + """ + + @abstractmethod + def inspect_network(self, network_name: str) -> Dict[str, Union[Dict, str]]: + """Get detailed attributes of an network. + + :return: Dict containing docker attributes as returned by the daemon + """ + + @abstractmethod + def connect_container_to_network( + self, + network_name: str, + container_name_or_id: str, + aliases: Optional[List] = None, + link_local_ips: List[str] = None, + ) -> None: + """ + Connects a container to a given network + :param network_name: Network to connect the container to + :param container_name_or_id: Container to connect to the network + :param aliases: List of dns names the container should be available under in the network + :param link_local_ips: List of link-local (IPv4 or IPv6) addresses + """ + + @abstractmethod + def disconnect_container_from_network( + self, network_name: str, container_name_or_id: str + ) -> None: + """ + Disconnects a container from a given network + :param network_name: Network to disconnect the container from + :param container_name_or_id: Container to disconnect from the network + """ + + def get_container_name(self, container_id: str) -> str: + """Get the name of a container by a given identifier""" + return self.inspect_container(container_id)["Name"].lstrip("/") + + def get_container_id(self, container_name: str) -> str: + """Get the id of a container by a given name""" + return self.inspect_container(container_name)["Id"] + + @abstractmethod + def get_container_ip(self, container_name_or_id: str) -> str: + """Get the IP address of a given container + + If container has multiple networks, it will return the IP of the first + """ + + def get_image_cmd(self, docker_image: str, pull: bool = True) -> List[str]: + """Get the command for the given image + :param docker_image: Docker image to inspect + :param pull: Whether to pull if image is not present + :return: Image command in its array form + """ + cmd_list = self.inspect_image(docker_image, pull)["Config"]["Cmd"] or [] + return cmd_list + + def get_image_entrypoint(self, docker_image: str, pull: bool = True) -> str: + """Get the entry point for the given image + :param docker_image: Docker image to inspect + :param pull: Whether to pull if image is not present + :return: Image entrypoint + """ + LOG.debug("Getting the entrypoint for image: %s", docker_image) + entrypoint_list = self.inspect_image(docker_image, pull)["Config"].get("Entrypoint") or [] + return shlex.join(entrypoint_list) + + @abstractmethod + def has_docker(self) -> bool: + """Check if system has docker available""" + + @abstractmethod + def commit( + self, + container_name_or_id: str, + image_name: str, + image_tag: str, + ): + """Create an image from a running container. + + :param container_name_or_id: Source container + :param image_name: Destination image name + :param image_tag: Destination image tag + """ + + def create_container_from_config(self, container_config: ContainerConfiguration) -> str: + """ + Similar to create_container, but allows passing the whole ContainerConfiguration + :param container_config: ContainerConfiguration how to start the container + :return: Container ID + """ + return self.create_container( + image_name=container_config.image_name, + name=container_config.name, + entrypoint=container_config.entrypoint, + remove=container_config.remove, + interactive=container_config.interactive, + tty=container_config.tty, + command=container_config.command, + volumes=container_config.volumes, + ports=container_config.ports, + exposed_ports=container_config.exposed_ports, + env_vars=container_config.env_vars, + user=container_config.user, + cap_add=container_config.cap_add, + cap_drop=container_config.cap_drop, + security_opt=container_config.security_opt, + network=container_config.network, + dns=container_config.dns, + additional_flags=container_config.additional_flags, + workdir=container_config.workdir, + privileged=container_config.privileged, + platform=container_config.platform, + labels=container_config.labels, + ulimits=container_config.ulimits, + init=container_config.init, + log_config=container_config.log_config, + ) + + @abstractmethod + def create_container( + self, + image_name: str, + *, + name: Optional[str] = None, + entrypoint: Optional[Union[List[str], str]] = None, + remove: bool = False, + interactive: bool = False, + tty: bool = False, + detach: bool = False, + command: Optional[Union[List[str], str]] = None, + volumes: Optional[Union[VolumeMappings, List[SimpleVolumeBind]]] = None, + ports: Optional[PortMappings] = None, + exposed_ports: Optional[List[str]] = None, + env_vars: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cap_add: Optional[List[str]] = None, + cap_drop: Optional[List[str]] = None, + security_opt: Optional[List[str]] = None, + network: Optional[str] = None, + dns: Optional[Union[str, List[str]]] = None, + additional_flags: Optional[str] = None, + workdir: Optional[str] = None, + privileged: Optional[bool] = None, + labels: Optional[Dict[str, str]] = None, + platform: Optional[DockerPlatform] = None, + ulimits: Optional[List[Ulimit]] = None, + init: Optional[bool] = None, + log_config: Optional[LogConfig] = None, + ) -> str: + """Creates a container with the given image + + :return: Container ID + """ + + @abstractmethod + def run_container( + self, + image_name: str, + stdin: bytes = None, + *, + name: Optional[str] = None, + entrypoint: Optional[str] = None, + remove: bool = False, + interactive: bool = False, + tty: bool = False, + detach: bool = False, + command: Optional[Union[List[str], str]] = None, + volumes: Optional[Union[VolumeMappings, List[SimpleVolumeBind]]] = None, + ports: Optional[PortMappings] = None, + exposed_ports: Optional[List[str]] = None, + env_vars: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cap_add: Optional[List[str]] = None, + cap_drop: Optional[List[str]] = None, + security_opt: Optional[List[str]] = None, + network: Optional[str] = None, + dns: Optional[str] = None, + additional_flags: Optional[str] = None, + workdir: Optional[str] = None, + labels: Optional[Dict[str, str]] = None, + platform: Optional[DockerPlatform] = None, + privileged: Optional[bool] = None, + ulimits: Optional[List[Ulimit]] = None, + init: Optional[bool] = None, + log_config: Optional[LogConfig] = None, + ) -> Tuple[bytes, bytes]: + """Creates and runs a given docker container + + :return: A tuple (stdout, stderr) + """ + + def run_container_from_config( + self, container_config: ContainerConfiguration + ) -> Tuple[bytes, bytes]: + """Like ``run_container`` but uses the parameters from the configuration.""" + + return self.run_container( + image_name=container_config.image_name, + stdin=container_config.stdin, + name=container_config.name, + entrypoint=container_config.entrypoint, + remove=container_config.remove, + interactive=container_config.interactive, + tty=container_config.tty, + detach=container_config.detach, + command=container_config.command, + volumes=container_config.volumes, + ports=container_config.ports, + exposed_ports=container_config.exposed_ports, + env_vars=container_config.env_vars, + user=container_config.user, + cap_add=container_config.cap_add, + cap_drop=container_config.cap_drop, + security_opt=container_config.security_opt, + network=container_config.network, + dns=container_config.dns, + additional_flags=container_config.additional_flags, + workdir=container_config.workdir, + platform=container_config.platform, + privileged=container_config.privileged, + ulimits=container_config.ulimits, + init=container_config.init, + log_config=container_config.log_config, + ) + + @abstractmethod + def exec_in_container( + self, + container_name_or_id: str, + command: Union[List[str], str], + interactive: bool = False, + detach: bool = False, + env_vars: Optional[Dict[str, Optional[str]]] = None, + stdin: Optional[bytes] = None, + user: Optional[str] = None, + workdir: Optional[str] = None, + ) -> Tuple[bytes, bytes]: + """Execute a given command in a container + + :return: A tuple (stdout, stderr) + """ + + @abstractmethod + def start_container( + self, + container_name_or_id: str, + stdin: bytes = None, + interactive: bool = False, + attach: bool = False, + flags: Optional[str] = None, + ) -> Tuple[bytes, bytes]: + """Start a given, already created container + + :return: A tuple (stdout, stderr) if attach or interactive is set, otherwise a tuple (b"container_name_or_id", b"") + """ + + @abstractmethod + def attach_to_container(self, container_name_or_id: str): + """ + Attach local standard input, output, and error streams to a running container + """ + + @abstractmethod + def login(self, username: str, password: str, registry: Optional[str] = None) -> None: + """ + Login into an OCI registry + + :param username: Username for the registry + :param password: Password / token for the registry + :param registry: Registry url + """ + + +class Util: + MAX_ENV_ARGS_LENGTH = 20000 + + @staticmethod + def format_env_vars(key: str, value: Optional[str]): + if value is None: + return key + return f"{key}={value}" + + @classmethod + def create_env_vars_file_flag(cls, env_vars: Dict) -> Tuple[List[str], Optional[str]]: + if not env_vars: + return [], None + result = [] + env_vars = dict(env_vars) + env_file = None + if len(str(env_vars)) > cls.MAX_ENV_ARGS_LENGTH: + # default ARG_MAX=131072 in Docker - let's create an env var file if the string becomes too long... + env_file = cls.mountable_tmp_file() + env_content = "" + for name, value in dict(env_vars).items(): + if len(value) > cls.MAX_ENV_ARGS_LENGTH: + # each line in the env file has a max size as well (error "bufio.Scanner: token too long") + continue + env_vars.pop(name) + value = value.replace("\n", "\\") + env_content += f"{cls.format_env_vars(name, value)}\n" + save_file(env_file, env_content) + result += ["--env-file", env_file] + + env_vars_res = [ + item for k, v in env_vars.items() for item in ["-e", cls.format_env_vars(k, v)] + ] + result += env_vars_res + return result, env_file + + @staticmethod + def rm_env_vars_file(env_vars_file) -> None: + if env_vars_file: + return rm_rf(env_vars_file) + + @staticmethod + def mountable_tmp_file(): + f = os.path.join(config.dirs.mounted_tmp, short_uid()) + TMP_FILES.append(f) + return f + + @staticmethod + def append_without_latest(image_names: List[str]): + suffix = ":latest" + for image in list(image_names): + if image.endswith(suffix): + image_names.append(image[: -len(suffix)]) + + @staticmethod + def strip_wellknown_repo_prefixes(image_names: List[str]) -> List[str]: + """ + Remove well-known repo prefixes like `localhost/` or `docker.io/library/` from the list of given + image names. This is mostly to ensure compatibility of our Docker client with Podman API responses. + :return: a copy of the list of image names, with well-known repo prefixes removed + """ + result = [] + for image in image_names: + for prefix in WELL_KNOWN_IMAGE_REPO_PREFIXES: + if image.startswith(prefix): + image = image.removeprefix(prefix) + # strip only one of the matching prefixes (avoid multi-stripping) + break + result.append(image) + return result + + @staticmethod + def tar_path(path: str, target_path: str, is_dir: bool): + f = tempfile.NamedTemporaryFile() + with tarfile.open(mode="w", fileobj=f) as t: + abs_path = os.path.abspath(path) + arcname = ( + os.path.basename(path) + if is_dir + else (os.path.basename(target_path) or os.path.basename(path)) + ) + t.add(abs_path, arcname=arcname) + + f.seek(0) + return f + + @staticmethod + def untar_to_path(tardata, target_path): + target_path = Path(target_path) + with tarfile.open(mode="r", fileobj=io.BytesIO(b"".join(b for b in tardata))) as t: + if target_path.is_dir(): + t.extractall(path=target_path) + else: + member = t.next() + if member: + member.name = target_path.name + t.extract(member, target_path.parent) + else: + LOG.debug("File to copy empty, ignoring...") + + @staticmethod + def _read_docker_cli_env_file(env_file: str) -> Dict[str, str]: + """ + Read an environment file in docker CLI format, specified here: + https://docs.docker.com/reference/cli/docker/container/run/#env + :param env_file: Path to the environment file + :return: Read environment variables + """ + env_vars = {} + try: + with open(env_file, mode="rt") as f: + env_file_lines = f.readlines() + except FileNotFoundError as e: + LOG.error( + "Specified env file '%s' not found. Please make sure the file is properly mounted into the LocalStack container. Error: %s", + env_file, + e, + ) + raise + except OSError as e: + LOG.error( + "Could not read env file '%s'. Please make sure the LocalStack container has the permissions to read it. Error: %s", + env_file, + e, + ) + raise + for idx, line in enumerate(env_file_lines): + line = line.strip() + if not line or line.startswith("#"): + # skip comments or empty lines + continue + lhs, separator, rhs = line.partition("=") + if rhs or separator: + env_vars[lhs] = rhs + else: + # No "=" in the line, only the name => lookup in local env + if env_value := os.environ.get(lhs): + env_vars[lhs] = env_value + return env_vars + + @staticmethod + def parse_additional_flags( + additional_flags: str, + env_vars: Optional[Dict[str, str]] = None, + labels: Optional[Dict[str, str]] = None, + volumes: Optional[List[SimpleVolumeBind]] = None, + network: Optional[str] = None, + platform: Optional[DockerPlatform] = None, + ports: Optional[PortMappings] = None, + privileged: Optional[bool] = None, + user: Optional[str] = None, + ulimits: Optional[List[Ulimit]] = None, + dns: Optional[Union[str, List[str]]] = None, + ) -> DockerRunFlags: + """Parses additional CLI-formatted Docker flags, which could overwrite provided defaults. + :param additional_flags: String which contains the flag definitions inspired by the Docker CLI reference: + https://docs.docker.com/engine/reference/commandline/run/ + :param env_vars: Dict with env vars. Will be modified in place. + :param labels: Dict with labels. Will be modified in place. + :param volumes: List of mount tuples (host_path, container_path). Will be modified in place. + :param network: Existing network name (optional). Warning will be printed if network is overwritten in flags. + :param platform: Platform to execute container. Warning will be printed if platform is overwritten in flags. + :param ports: PortMapping object. Will be modified in place. + :param privileged: Run the container in privileged mode. Warning will be printed if overwritten in flags. + :param ulimits: ulimit options in the format =[:] + :param user: User to run first process. Warning will be printed if user is overwritten in flags. + :param dns: List of DNS servers to configure the container with. + :return: A DockerRunFlags object that will return new objects if respective parameters were None and + additional flags contained a flag for that object or the same which are passed otherwise. + """ + # Argparse refactoring opportunity: custom argparse actions can be used to modularize parsing (e.g., key=value) + # https://docs.python.org/3/library/argparse.html#action + + # Configure parser + parser = NoExitArgumentParser(description="Docker run flags parser") + parser.add_argument( + "--add-host", + help="Add a custom host-to-IP mapping (host:ip)", + dest="add_hosts", + action="append", + ) + parser.add_argument( + "--env", "-e", help="Set environment variables", dest="envs", action="append" + ) + parser.add_argument( + "--env-file", + help="Set environment variables via a file", + dest="env_files", + action="append", + ) + parser.add_argument( + "--compose-env-file", + help="Set environment variables via a file, with a docker-compose supported feature set.", + dest="compose_env_files", + action="append", + ) + parser.add_argument( + "--label", "-l", help="Add container meta data", dest="labels", action="append" + ) + parser.add_argument("--network", help="Connect a container to a network") + parser.add_argument( + "--platform", + type=DockerPlatform, + help="Docker platform (e.g., linux/amd64 or linux/arm64)", + ) + parser.add_argument( + "--privileged", + help="Give extended privileges to this container", + action="store_true", + ) + parser.add_argument( + "--publish", + "-p", + help="Publish container port(s) to the host", + dest="publish_ports", + action="append", + ) + parser.add_argument( + "--ulimit", help="Container ulimit settings", dest="ulimits", action="append" + ) + parser.add_argument("--user", "-u", help="Username or UID to execute first process") + parser.add_argument( + "--volume", "-v", help="Bind mount a volume", dest="volumes", action="append" + ) + parser.add_argument("--dns", help="Set custom DNS servers", dest="dns", action="append") + + # Parse + flags = shlex.split(additional_flags) + args = parser.parse_args(flags) + + # Post-process parsed flags + extra_hosts = None + if args.add_hosts: + for add_host in args.add_hosts: + extra_hosts = extra_hosts if extra_hosts is not None else {} + hosts_split = add_host.split(":") + extra_hosts[hosts_split[0]] = hosts_split[1] + + # set env file values before env values, as the latter override the earlier + if args.env_files: + env_vars = env_vars if env_vars is not None else {} + for env_file in args.env_files: + env_vars.update(Util._read_docker_cli_env_file(env_file)) + + if args.compose_env_files: + env_vars = env_vars if env_vars is not None else {} + for env_file in args.compose_env_files: + env_vars.update(dotenv.dotenv_values(env_file)) + + if args.envs: + env_vars = env_vars if env_vars is not None else {} + for env in args.envs: + lhs, _, rhs = env.partition("=") + env_vars[lhs] = rhs + + if args.labels: + labels = labels if labels is not None else {} + for label in args.labels: + key, _, value = label.partition("=") + # Only consider non-empty labels + if key: + labels[key] = value + + if args.network: + LOG.warning( + "Overwriting Docker container network '%s' with new value '%s'", + network, + args.network, + ) + network = args.network + + if args.platform: + LOG.warning( + "Overwriting Docker platform '%s' with new value '%s'", + platform, + args.platform, + ) + platform = args.platform + + if args.privileged: + LOG.warning( + "Overwriting Docker container privileged flag %s with new value %s", + privileged, + args.privileged, + ) + privileged = args.privileged + + if args.publish_ports: + for port_mapping in args.publish_ports: + port_split = port_mapping.split(":") + protocol = "tcp" + if len(port_split) == 2: + host_port, container_port = port_split + elif len(port_split) == 3: + LOG.warning( + "Host part of port mappings are ignored currently in additional flags" + ) + _, host_port, container_port = port_split + else: + raise ValueError(f"Invalid port string provided: {port_mapping}") + host_port_split = host_port.split("-") + if len(host_port_split) == 2: + host_port = [int(host_port_split[0]), int(host_port_split[1])] + elif len(host_port_split) == 1: + host_port = int(host_port) + else: + raise ValueError(f"Invalid port string provided: {port_mapping}") + if "/" in container_port: + container_port, protocol = container_port.split("/") + ports = ports if ports is not None else PortMappings() + ports.add(host_port, int(container_port), protocol) + + if args.ulimits: + ulimits = ulimits if ulimits is not None else [] + ulimits_dict = {ul.name: ul for ul in ulimits} + for ulimit in args.ulimits: + name, _, rhs = ulimit.partition("=") + soft, _, hard = rhs.partition(":") + hard_limit = int(hard) if hard else int(soft) + new_ulimit = Ulimit(name=name, soft_limit=int(soft), hard_limit=hard_limit) + if ulimits_dict.get(name): + LOG.warning("Overwriting Docker ulimit %s", new_ulimit) + ulimits_dict[name] = new_ulimit + ulimits = list(ulimits_dict.values()) + + if args.user: + LOG.warning( + "Overwriting Docker user '%s' with new value '%s'", + user, + args.user, + ) + user = args.user + + if args.volumes: + volumes = volumes if volumes is not None else [] + for volume in args.volumes: + match = re.match( + r"(?P[\w\s\\\/:\-.]+?):(?P[\w\s\/\-.]+)(?::(?Pro|rw|z|Z))?", + volume, + ) + if not match: + LOG.warning("Unable to parse volume mount Docker flags: %s", volume) + continue + host_path = match.group("host") + container_path = match.group("container") + rw_args = match.group("arg") + if rw_args: + LOG.info("Volume options like :ro or :rw are currently ignored.") + volumes.append((host_path, container_path)) + + dns = ensure_list(dns or []) + if args.dns: + LOG.info( + "Extending Docker container DNS servers %s with additional values %s", dns, args.dns + ) + dns.extend(args.dns) + + return DockerRunFlags( + env_vars=env_vars, + extra_hosts=extra_hosts, + labels=labels, + volumes=volumes, + ports=ports, + network=network, + platform=platform, + privileged=privileged, + ulimits=ulimits, + user=user, + dns=dns, + ) + + @staticmethod + def convert_mount_list_to_dict( + volumes: Union[List[SimpleVolumeBind], VolumeMappings], + ) -> Dict[str, Dict[str, str]]: + """Converts a List of (host_path, container_path) tuples to a Dict suitable as volume argument for docker sdk""" + + def _map_to_dict(paths: SimpleVolumeBind | BindMount | VolumeDirMount): + if isinstance(paths, (BindMount, VolumeDirMount)): + return paths.to_docker_sdk_parameters() + else: + return str(paths[0]), {"bind": paths[1], "mode": "rw"} + + return dict( + map( + _map_to_dict, + volumes, + ) + ) + + @staticmethod + def resolve_dockerfile_path(dockerfile_path: str) -> str: + """If the given path is a directory that contains a Dockerfile, then return the file path to it.""" + rel_path = os.path.join(dockerfile_path, "Dockerfile") + if os.path.isdir(dockerfile_path) and os.path.exists(rel_path): + return rel_path + return dockerfile_path diff --git a/localstack-core/localstack/utils/container_utils/docker_cmd_client.py b/localstack-core/localstack/utils/container_utils/docker_cmd_client.py new file mode 100644 index 0000000000000..ebb5dc7a10dd0 --- /dev/null +++ b/localstack-core/localstack/utils/container_utils/docker_cmd_client.py @@ -0,0 +1,936 @@ +import functools +import itertools +import json +import logging +import os +import re +import shlex +import subprocess +from typing import Callable, Dict, List, Optional, Tuple, Union + +from localstack import config +from localstack.utils.collections import ensure_list +from localstack.utils.container_utils.container_client import ( + AccessDenied, + BindMount, + CancellableStream, + ContainerClient, + ContainerException, + DockerContainerStats, + DockerContainerStatus, + DockerNotAvailable, + DockerPlatform, + LogConfig, + NoSuchContainer, + NoSuchImage, + NoSuchNetwork, + NoSuchObject, + PortMappings, + RegistryConnectionError, + SimpleVolumeBind, + Ulimit, + Util, + VolumeDirMount, +) +from localstack.utils.run import run +from localstack.utils.strings import first_char_to_upper, to_str + +LOG = logging.getLogger(__name__) + + +class CancellableProcessStream(CancellableStream): + process: subprocess.Popen + + def __init__(self, process: subprocess.Popen) -> None: + super().__init__() + self.process = process + + def __iter__(self): + return self + + def __next__(self): + line = self.process.stdout.readline() + if not line: + raise StopIteration + return line + + def close(self): + return self.process.terminate() + + +def parse_size_string(size_str: str) -> int: + """Parse human-readable size strings from Docker CLI into bytes""" + size_str = size_str.strip().replace(" ", "").upper() + if size_str == "0B": + return 0 + + # Match value and unit using regex + match = re.match(r"^([\d.]+)([A-Za-z]+)$", size_str) + if not match: + return 0 + + value = float(match.group(1)) + unit = match.group(2) + + unit_factors = { + "B": 1, + "KB": 10**3, + "MB": 10**6, + "GB": 10**9, + "TB": 10**12, + "KIB": 2**10, + "MIB": 2**20, + "GIB": 2**30, + "TIB": 2**40, + } + + return int(value * unit_factors.get(unit, 1)) + + +class CmdDockerClient(ContainerClient): + """ + Class for managing Docker (or Podman) containers using the command line executable. + + The client also supports targeting Podman engines, as Podman is almost a drop-in replacement + for Docker these days. The majority of compatibility switches in this class is to handle slightly + different response payloads or error messages returned by the `docker` vs `podman` commands. + """ + + default_run_outfile: Optional[str] = None + + def _docker_cmd(self) -> List[str]: + """ + Get the configured, tested Docker CMD. + :return: string to be used for running Docker commands + :raises: DockerNotAvailable exception if the Docker command or the socker is not available + """ + if not self.has_docker(): + raise DockerNotAvailable() + return shlex.split(config.DOCKER_CMD) + + def get_system_info(self) -> dict: + cmd = [ + *self._docker_cmd(), + "info", + "--format", + "{{json .}}", + ] + cmd_result = run(cmd) + + return json.loads(cmd_result) + + def get_container_status(self, container_name: str) -> DockerContainerStatus: + cmd = self._docker_cmd() + cmd += [ + "ps", + "-a", + "--filter", + f"name={container_name}", + "--format", + "{{ .Status }} - {{ .Names }}", + ] + cmd_result = run(cmd) + + # filter empty / invalid lines from docker ps output + cmd_result = next((line for line in cmd_result.splitlines() if container_name in line), "") + container_status = cmd_result.strip().lower() + if len(container_status) == 0: + return DockerContainerStatus.NON_EXISTENT + elif "(paused)" in container_status: + return DockerContainerStatus.PAUSED + elif container_status.startswith("up "): + return DockerContainerStatus.UP + else: + return DockerContainerStatus.DOWN + + def get_container_stats(self, container_name: str) -> DockerContainerStats: + cmd = self._docker_cmd() + cmd += ["stats", "--no-stream", "--format", "{{json .}}", container_name] + cmd_result = run(cmd) + raw_stats = json.loads(cmd_result) + + # BlockIO (read, write) + block_io_parts = raw_stats["BlockIO"].split("/") + block_read = parse_size_string(block_io_parts[0]) + block_write = parse_size_string(block_io_parts[1]) + + # CPU percentage + cpu_percentage = float(raw_stats["CPUPerc"].strip("%")) + + # Memory (usage, limit) + mem_parts = raw_stats["MemUsage"].split("/") + mem_used = parse_size_string(mem_parts[0]) + mem_limit = parse_size_string(mem_parts[1]) + mem_percentage = float(raw_stats["MemPerc"].strip("%")) + + # Network (rx, tx) + net_parts = raw_stats["NetIO"].split("/") + net_rx = parse_size_string(net_parts[0]) + net_tx = parse_size_string(net_parts[1]) + + return DockerContainerStats( + Container=raw_stats["ID"], + ID=raw_stats["ID"], + Name=raw_stats["Name"], + BlockIO=(block_read, block_write), + CPUPerc=round(cpu_percentage, 2), + MemPerc=round(mem_percentage, 2), + MemUsage=(mem_used, mem_limit), + NetIO=(net_rx, net_tx), + PIDs=int(raw_stats["PIDs"]), + SDKStats=None, + ) + + def stop_container(self, container_name: str, timeout: int = 10) -> None: + cmd = self._docker_cmd() + cmd += ["stop", "--time", str(timeout), container_name] + LOG.debug("Stopping container with cmd %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name, error=e) + raise ContainerException( + f"Docker process returned with errorcode {e.returncode}", e.stdout, e.stderr + ) from e + + def restart_container(self, container_name: str, timeout: int = 10) -> None: + cmd = self._docker_cmd() + cmd += ["restart", "--time", str(timeout), container_name] + LOG.debug("Restarting container with cmd %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def pause_container(self, container_name: str) -> None: + cmd = self._docker_cmd() + cmd += ["pause", container_name] + LOG.debug("Pausing container with cmd %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def unpause_container(self, container_name: str) -> None: + cmd = self._docker_cmd() + cmd += ["unpause", container_name] + LOG.debug("Unpausing container with cmd %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def remove_image(self, image: str, force: bool = True) -> None: + cmd = self._docker_cmd() + cmd += ["rmi", image] + if force: + cmd += ["--force"] + LOG.debug("Removing image %s %s", image, "(forced)" if force else "") + try: + run(cmd) + except subprocess.CalledProcessError as e: + # handle different error messages for Docker and podman + error_messages = ["No such image", "image not known"] + if any(msg in to_str(e.stdout) for msg in error_messages): + raise NoSuchImage(image, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def commit( + self, + container_name_or_id: str, + image_name: str, + image_tag: str, + ): + cmd = self._docker_cmd() + cmd += ["commit", container_name_or_id, f"{image_name}:{image_tag}"] + LOG.debug( + "Creating image from container %s as %s:%s", container_name_or_id, image_name, image_tag + ) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name_or_id, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def remove_container(self, container_name: str, force=True, check_existence=False) -> None: + if check_existence and container_name not in self.get_running_container_names(): + return + cmd = self._docker_cmd() + ["rm"] + if force: + cmd.append("-f") + cmd.append(container_name) + LOG.debug("Removing container with cmd %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def list_containers(self, filter: Union[List[str], str, None] = None, all=True) -> List[dict]: + filter = [filter] if isinstance(filter, str) else filter + cmd = self._docker_cmd() + cmd.append("ps") + if all: + cmd.append("-a") + options = [] + if filter: + options += [y for filter_item in filter for y in ["--filter", filter_item]] + cmd += options + cmd.append("--format") + cmd.append("{{json . }}") + try: + cmd_result = run(cmd).strip() + except subprocess.CalledProcessError as e: + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + container_list = [] + if cmd_result: + if cmd_result[0] == "[": + container_list = json.loads(cmd_result) + else: + container_list = [json.loads(line) for line in cmd_result.splitlines()] + result = [] + for container in container_list: + labels = self._transform_container_labels(container["Labels"]) + result.append( + { + # support both, Docker and podman API response formats (`ID` vs `Id`) + "id": container.get("ID") or container["Id"], + "image": container["Image"], + # Docker returns a single string for `Names`, whereas podman returns a list of names + "name": ensure_list(container["Names"])[0], + "status": container["State"], + "labels": labels, + } + ) + return result + + def copy_into_container( + self, container_name: str, local_path: str, container_path: str + ) -> None: + cmd = self._docker_cmd() + cmd += ["cp", local_path, f"{container_name}:{container_path}"] + LOG.debug("Copying into container with cmd: %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name, error=e) + if "does not exist" in to_str(e.stdout): + raise NoSuchContainer(container_name, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + f"Docker process returned with errorcode {e.returncode}", e.stdout, e.stderr + ) from e + + def copy_from_container( + self, container_name: str, local_path: str, container_path: str + ) -> None: + cmd = self._docker_cmd() + cmd += ["cp", f"{container_name}:{container_path}", local_path] + LOG.debug("Copying from container with cmd: %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name, error=e) + # additional check to support Podman CLI output + if re.match(".*container .+ does not exist", to_str(e.stdout)): + raise NoSuchContainer(container_name, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def pull_image( + self, + docker_image: str, + platform: Optional[DockerPlatform] = None, + log_handler: Optional[Callable[[str], None]] = None, + ) -> None: + cmd = self._docker_cmd() + docker_image = self.registry_resolver_strategy.resolve(docker_image) + cmd += ["pull", docker_image] + if platform: + cmd += ["--platform", platform] + LOG.debug("Pulling image with cmd: %s", cmd) + try: + result = run(cmd) + # note: we could stream the results, but we'll just process everything at the end for now + if log_handler: + for line in result.split("\n"): + log_handler(to_str(line)) + except subprocess.CalledProcessError as e: + stdout_str = to_str(e.stdout) + if "pull access denied" in stdout_str: + raise NoSuchImage(docker_image, stdout=e.stdout, stderr=e.stderr) + # note: error message 'access to the resource is denied' raised by Podman client + if "Trying to pull" in stdout_str and "access to the resource is denied" in stdout_str: + raise NoSuchImage(docker_image, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def push_image(self, docker_image: str) -> None: + cmd = self._docker_cmd() + cmd += ["push", docker_image] + LOG.debug("Pushing image with cmd: %s", cmd) + try: + run(cmd) + except subprocess.CalledProcessError as e: + if "is denied" in to_str(e.stdout): + raise AccessDenied(docker_image) + if "requesting higher privileges than access token allows" in to_str(e.stdout): + raise AccessDenied(docker_image) + if "access token has insufficient scopes" in to_str(e.stdout): + raise AccessDenied(docker_image) + if "does not exist" in to_str(e.stdout): + raise NoSuchImage(docker_image) + if "connection refused" in to_str(e.stdout): + raise RegistryConnectionError(e.stdout) + # note: error message 'image not known' raised by Podman client + if "image not known" in to_str(e.stdout): + raise NoSuchImage(docker_image) + raise ContainerException( + f"Docker process returned with errorcode {e.returncode}", e.stdout, e.stderr + ) from e + + def build_image( + self, + dockerfile_path: str, + image_name: str, + context_path: str = None, + platform: Optional[DockerPlatform] = None, + ): + cmd = self._docker_cmd() + dockerfile_path = Util.resolve_dockerfile_path(dockerfile_path) + context_path = context_path or os.path.dirname(dockerfile_path) + cmd += ["build", "-t", image_name, "-f", dockerfile_path] + if platform: + cmd += ["--platform", platform] + cmd += [context_path] + LOG.debug("Building Docker image: %s", cmd) + try: + return run(cmd) + except subprocess.CalledProcessError as e: + raise ContainerException( + f"Docker build process returned with error code {e.returncode}", e.stdout, e.stderr + ) from e + + def tag_image(self, source_ref: str, target_name: str) -> None: + cmd = self._docker_cmd() + cmd += ["tag", source_ref, target_name] + LOG.debug("Tagging Docker image %s as %s", source_ref, target_name) + try: + run(cmd) + except subprocess.CalledProcessError as e: + # handle different error messages for Docker and podman + error_messages = ["No such image", "image not known"] + if any(msg in to_str(e.stdout) for msg in error_messages): + raise NoSuchImage(source_ref) + raise ContainerException( + f"Docker process returned with error code {e.returncode}", e.stdout, e.stderr + ) from e + + def get_docker_image_names( + self, strip_latest=True, include_tags=True, strip_wellknown_repo_prefixes: bool = True + ): + format_string = "{{.Repository}}:{{.Tag}}" if include_tags else "{{.Repository}}" + cmd = self._docker_cmd() + cmd += ["images", "--format", format_string] + try: + output = run(cmd) + + image_names = output.splitlines() + if strip_wellknown_repo_prefixes: + image_names = Util.strip_wellknown_repo_prefixes(image_names) + if strip_latest: + Util.append_without_latest(image_names) + + return image_names + except Exception as e: + LOG.info('Unable to list Docker images via "%s": %s', cmd, e) + return [] + + def get_container_logs(self, container_name_or_id: str, safe=False) -> str: + cmd = self._docker_cmd() + cmd += ["logs", container_name_or_id] + try: + return run(cmd) + except subprocess.CalledProcessError as e: + if safe: + return "" + self._check_and_raise_no_such_container_error(container_name_or_id, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def stream_container_logs(self, container_name_or_id: str) -> CancellableStream: + self.inspect_container(container_name_or_id) # guard to check whether container is there + + cmd = self._docker_cmd() + cmd += ["logs", "--follow", container_name_or_id] + + process: subprocess.Popen = run( + cmd, asynchronous=True, outfile=subprocess.PIPE, stderr=subprocess.STDOUT + ) + + return CancellableProcessStream(process) + + def _inspect_object(self, object_name_or_id: str) -> Dict[str, Union[dict, list, str]]: + cmd = self._docker_cmd() + cmd += ["inspect", "--format", "{{json .}}", object_name_or_id] + try: + cmd_result = run(cmd, print_error=False) + except subprocess.CalledProcessError as e: + # note: case-insensitive comparison, to support Docker and Podman output formats + if "no such object" in to_str(e.stdout).lower(): + raise NoSuchObject(object_name_or_id, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + object_data = json.loads(cmd_result.strip()) + if isinstance(object_data, list): + # return first list item, for compatibility with Podman API + if len(object_data) == 1: + result = object_data[0] + # convert first character to uppercase (e.g., `name` -> `Name`), for Podman/Docker compatibility + result = {first_char_to_upper(k): v for k, v in result.items()} + return result + LOG.info( + "Expected a single object for `inspect` on ID %s, got %s", + object_name_or_id, + len(object_data), + ) + return object_data + + def inspect_container(self, container_name_or_id: str) -> Dict[str, Union[Dict, str]]: + try: + return self._inspect_object(container_name_or_id) + except NoSuchObject as e: + raise NoSuchContainer(container_name_or_id=e.object_id) + + def inspect_image( + self, + image_name: str, + pull: bool = True, + strip_wellknown_repo_prefixes: bool = True, + ) -> Dict[str, Union[dict, list, str]]: + image_name = self.registry_resolver_strategy.resolve(image_name) + try: + result = self._inspect_object(image_name) + if strip_wellknown_repo_prefixes: + if result.get("RepoDigests"): + result["RepoDigests"] = Util.strip_wellknown_repo_prefixes( + result["RepoDigests"] + ) + if result.get("RepoTags"): + result["RepoTags"] = Util.strip_wellknown_repo_prefixes(result["RepoTags"]) + return result + except NoSuchObject as e: + if pull: + self.pull_image(image_name) + return self.inspect_image(image_name, pull=False) + raise NoSuchImage(image_name=e.object_id) + + def create_network(self, network_name: str) -> str: + cmd = self._docker_cmd() + cmd += ["network", "create", network_name] + try: + return run(cmd).strip() + except subprocess.CalledProcessError as e: + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def delete_network(self, network_name: str) -> None: + cmd = self._docker_cmd() + cmd += ["network", "rm", network_name] + try: + run(cmd) + except subprocess.CalledProcessError as e: + stdout_str = to_str(e.stdout) + if re.match(r".*network (.*) not found.*", stdout_str): + raise NoSuchNetwork(network_name=network_name) + else: + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def inspect_network(self, network_name: str) -> Dict[str, Union[Dict, str]]: + try: + return self._inspect_object(network_name) + except NoSuchObject as e: + raise NoSuchNetwork(network_name=e.object_id) + + def connect_container_to_network( + self, + network_name: str, + container_name_or_id: str, + aliases: Optional[List] = None, + link_local_ips: List[str] = None, + ) -> None: + LOG.debug( + "Connecting container '%s' to network '%s' with aliases '%s'", + container_name_or_id, + network_name, + aliases, + ) + cmd = self._docker_cmd() + cmd += ["network", "connect"] + if aliases: + cmd += ["--alias", ",".join(aliases)] + if link_local_ips: + cmd += ["--link-local-ip", ",".join(link_local_ips)] + cmd += [network_name, container_name_or_id] + try: + run(cmd) + except subprocess.CalledProcessError as e: + stdout_str = to_str(e.stdout) + if re.match(r".*network (.*) not found.*", stdout_str): + raise NoSuchNetwork(network_name=network_name) + self._check_and_raise_no_such_container_error(container_name_or_id, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def disconnect_container_from_network( + self, network_name: str, container_name_or_id: str + ) -> None: + LOG.debug( + "Disconnecting container '%s' from network '%s'", container_name_or_id, network_name + ) + cmd = self._docker_cmd() + ["network", "disconnect", network_name, container_name_or_id] + try: + run(cmd) + except subprocess.CalledProcessError as e: + stdout_str = to_str(e.stdout) + if re.match(r".*network (.*) not found.*", stdout_str): + raise NoSuchNetwork(network_name=network_name) + self._check_and_raise_no_such_container_error(container_name_or_id, error=e) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def get_container_ip(self, container_name_or_id: str) -> str: + cmd = self._docker_cmd() + cmd += [ + "inspect", + "--format", + "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", + container_name_or_id, + ] + try: + result = run(cmd).strip() + return result.split(" ")[0] if result else "" + except subprocess.CalledProcessError as e: + self._check_and_raise_no_such_container_error(container_name_or_id, error=e) + # consider different error messages for Podman + if "no such object" in to_str(e.stdout).lower(): + raise NoSuchContainer(container_name_or_id, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def login(self, username: str, password: str, registry: Optional[str] = None) -> None: + cmd = self._docker_cmd() + # TODO specify password via stdin + cmd += ["login", "-u", username, "-p", password] + if registry: + cmd.append(registry) + try: + run(cmd) + except subprocess.CalledProcessError as e: + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + @functools.lru_cache(maxsize=None) + def has_docker(self) -> bool: + try: + # do not use self._docker_cmd here (would result in a loop) + run(shlex.split(config.DOCKER_CMD) + ["ps"]) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + def create_container(self, image_name: str, **kwargs) -> str: + image_name = self.registry_resolver_strategy.resolve(image_name) + cmd, env_file = self._build_run_create_cmd("create", image_name, **kwargs) + LOG.debug("Create container with cmd: %s", cmd) + try: + container_id = run(cmd) + # Note: strip off Docker warning messages like "DNS setting (--dns=127.0.0.1) may fail in containers" + container_id = container_id.strip().split("\n")[-1] + return container_id.strip() + except subprocess.CalledProcessError as e: + error_messages = ["Unable to find image", "Trying to pull"] + if any(msg in to_str(e.stdout) for msg in error_messages): + raise NoSuchImage(image_name, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + finally: + Util.rm_env_vars_file(env_file) + + def run_container(self, image_name: str, stdin=None, **kwargs) -> Tuple[bytes, bytes]: + image_name = self.registry_resolver_strategy.resolve(image_name) + cmd, env_file = self._build_run_create_cmd("run", image_name, **kwargs) + LOG.debug("Run container with cmd: %s", cmd) + try: + return self._run_async_cmd(cmd, stdin, kwargs.get("name") or "", image_name) + except ContainerException as e: + if "Trying to pull" in str(e) and "access to the resource is denied" in str(e): + raise NoSuchImage(image_name, stdout=e.stdout, stderr=e.stderr) from e + raise + finally: + Util.rm_env_vars_file(env_file) + + def exec_in_container( + self, + container_name_or_id: str, + command: Union[List[str], str], + interactive=False, + detach=False, + env_vars: Optional[Dict[str, Optional[str]]] = None, + stdin: Optional[bytes] = None, + user: Optional[str] = None, + workdir: Optional[str] = None, + ) -> Tuple[bytes, bytes]: + env_file = None + cmd = self._docker_cmd() + cmd.append("exec") + if interactive: + cmd.append("--interactive") + if detach: + cmd.append("--detach") + if user: + cmd += ["--user", user] + if workdir: + cmd += ["--workdir", workdir] + if env_vars: + env_flag, env_file = Util.create_env_vars_file_flag(env_vars) + cmd += env_flag + cmd.append(container_name_or_id) + cmd += command if isinstance(command, List) else [command] + LOG.debug("Execute command in container: %s", cmd) + try: + return self._run_async_cmd(cmd, stdin, container_name_or_id) + finally: + Util.rm_env_vars_file(env_file) + + def start_container( + self, + container_name_or_id: str, + stdin=None, + interactive: bool = False, + attach: bool = False, + flags: Optional[str] = None, + ) -> Tuple[bytes, bytes]: + cmd = self._docker_cmd() + ["start"] + if flags: + cmd.append(flags) + if interactive: + cmd.append("--interactive") + if attach: + cmd.append("--attach") + cmd.append(container_name_or_id) + LOG.debug("Start container with cmd: %s", cmd) + return self._run_async_cmd(cmd, stdin, container_name_or_id) + + def attach_to_container(self, container_name_or_id: str): + cmd = self._docker_cmd() + ["attach", container_name_or_id] + LOG.debug("Attaching to container %s", container_name_or_id) + return self._run_async_cmd(cmd, stdin=None, container_name=container_name_or_id) + + def _run_async_cmd( + self, cmd: List[str], stdin: bytes, container_name: str, image_name=None + ) -> Tuple[bytes, bytes]: + kwargs = { + "inherit_env": True, + "asynchronous": True, + "stderr": subprocess.PIPE, + "outfile": self.default_run_outfile or subprocess.PIPE, + } + if stdin: + kwargs["stdin"] = True + try: + process = run(cmd, **kwargs) + stdout, stderr = process.communicate(input=stdin) + if process.returncode != 0: + raise subprocess.CalledProcessError( + process.returncode, + cmd, + stdout, + stderr, + ) + else: + return stdout, stderr + except subprocess.CalledProcessError as e: + stderr_str = to_str(e.stderr) + if "Unable to find image" in stderr_str: + raise NoSuchImage(image_name or "", stdout=e.stdout, stderr=e.stderr) + # consider different error messages for Docker/Podman + error_messages = ("No such container", "no container with name or ID") + if any(msg.lower() in to_str(e.stderr).lower() for msg in error_messages): + raise NoSuchContainer(container_name, stdout=e.stdout, stderr=e.stderr) + raise ContainerException( + "Docker process returned with errorcode %s" % e.returncode, e.stdout, e.stderr + ) from e + + def _build_run_create_cmd( + self, + action: str, + image_name: str, + *, + name: Optional[str] = None, + entrypoint: Optional[Union[List[str], str]] = None, + remove: bool = False, + interactive: bool = False, + tty: bool = False, + detach: bool = False, + command: Optional[Union[List[str], str]] = None, + volumes: Optional[List[SimpleVolumeBind]] = None, + ports: Optional[PortMappings] = None, + exposed_ports: Optional[List[str]] = None, + env_vars: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cap_add: Optional[List[str]] = None, + cap_drop: Optional[List[str]] = None, + security_opt: Optional[List[str]] = None, + network: Optional[str] = None, + dns: Optional[Union[str, List[str]]] = None, + additional_flags: Optional[str] = None, + workdir: Optional[str] = None, + privileged: Optional[bool] = None, + labels: Optional[Dict[str, str]] = None, + platform: Optional[DockerPlatform] = None, + ulimits: Optional[List[Ulimit]] = None, + init: Optional[bool] = None, + log_config: Optional[LogConfig] = None, + ) -> Tuple[List[str], str]: + env_file = None + cmd = self._docker_cmd() + [action] + if remove: + cmd.append("--rm") + if name: + cmd += ["--name", name] + if entrypoint is not None: # empty string entrypoint can be intentional + if isinstance(entrypoint, str): + cmd += ["--entrypoint", entrypoint] + else: + cmd += ["--entrypoint", shlex.join(entrypoint)] + if privileged: + cmd += ["--privileged"] + if volumes: + cmd += [ + param for volume in volumes for param in ["-v", self._map_to_volume_param(volume)] + ] + if interactive: + cmd.append("--interactive") + if tty: + cmd.append("--tty") + if detach: + cmd.append("--detach") + if ports: + cmd += ports.to_list() + if exposed_ports: + cmd += list(itertools.chain.from_iterable(["--expose", port] for port in exposed_ports)) + if env_vars: + env_flags, env_file = Util.create_env_vars_file_flag(env_vars) + cmd += env_flags + if user: + cmd += ["--user", user] + if cap_add: + cmd += list(itertools.chain.from_iterable(["--cap-add", cap] for cap in cap_add)) + if cap_drop: + cmd += list(itertools.chain.from_iterable(["--cap-drop", cap] for cap in cap_drop)) + if security_opt: + cmd += list( + itertools.chain.from_iterable(["--security-opt", opt] for opt in security_opt) + ) + if network: + cmd += ["--network", network] + if dns: + for dns_server in ensure_list(dns): + cmd += ["--dns", dns_server] + if workdir: + cmd += ["--workdir", workdir] + if labels: + for key, value in labels.items(): + cmd += ["--label", f"{key}={value}"] + if platform: + cmd += ["--platform", platform] + if ulimits: + cmd += list( + itertools.chain.from_iterable(["--ulimit", str(ulimit)] for ulimit in ulimits) + ) + if init: + cmd += ["--init"] + if log_config: + cmd += ["--log-driver", log_config.type] + for key, value in log_config.config.items(): + cmd += ["--log-opt", f"{key}={value}"] + + if additional_flags: + cmd += shlex.split(additional_flags) + cmd.append(image_name) + if command: + cmd += command if isinstance(command, List) else [command] + return cmd, env_file + + @staticmethod + def _map_to_volume_param(volume: Union[SimpleVolumeBind, BindMount, VolumeDirMount]) -> str: + """ + Maps the mount volume, to a parameter for the -v docker cli argument. + + Examples: + (host_path, container_path) -> host_path:container_path + VolumeBind(host_dir=host_path, container_dir=container_path, read_only=True) -> host_path:container_path:ro + + :param volume: Either a SimpleVolumeBind, in essence a tuple (host_dir, container_dir), or a VolumeBind object + :return: String which is passable as parameter to the docker cli -v option + """ + if isinstance(volume, (BindMount, VolumeDirMount)): + return volume.to_str() + else: + return f"{volume[0]}:{volume[1]}" + + def _check_and_raise_no_such_container_error( + self, container_name_or_id: str, error: subprocess.CalledProcessError + ): + """ + Check the given client invocation error and raise a `NoSuchContainer` exception if it + represents a `no such container` exception from Docker or Podman. + """ + + # consider different error messages for Docker/Podman + error_messages = ("No such container", "no container with name or ID") + process_stdout_lower = to_str(error.stdout).lower() + if any(msg.lower() in process_stdout_lower for msg in error_messages): + raise NoSuchContainer(container_name_or_id, stdout=error.stdout, stderr=error.stderr) + + def _transform_container_labels(self, labels: Union[str, Dict[str, str]]) -> Dict[str, str]: + """ + Transforms the container labels returned by the docker command from the key-value pair format to a dict + :param labels: Input string, comma separated key value pairs. Example: key1=value1,key2=value2 + :return: Dict representation of the passed values, example: {"key1": "value1", "key2": "value2"} + """ + if isinstance(labels, Dict): + return labels + + labels = labels.split(",") + labels = [label.partition("=") for label in labels] + return {label[0]: label[2] for label in labels} diff --git a/localstack-core/localstack/utils/container_utils/docker_sdk_client.py b/localstack-core/localstack/utils/container_utils/docker_sdk_client.py new file mode 100644 index 0000000000000..a01761d20d44f --- /dev/null +++ b/localstack-core/localstack/utils/container_utils/docker_sdk_client.py @@ -0,0 +1,986 @@ +import base64 +import json +import logging +import os +import queue +import re +import socket +import threading +from functools import lru_cache +from time import sleep +from typing import Callable, Dict, List, Optional, Tuple, Union, cast +from urllib.parse import quote + +import docker +from docker import DockerClient +from docker.errors import APIError, ContainerError, DockerException, ImageNotFound, NotFound +from docker.models.containers import Container +from docker.types import LogConfig as DockerLogConfig +from docker.utils.socket import STDERR, STDOUT, frames_iter + +from localstack.config import LS_LOG +from localstack.constants import TRACE_LOG_LEVELS +from localstack.utils.collections import ensure_list +from localstack.utils.container_utils.container_client import ( + AccessDenied, + CancellableStream, + ContainerClient, + ContainerException, + DockerContainerStats, + DockerContainerStatus, + DockerNotAvailable, + DockerPlatform, + LogConfig, + NoSuchContainer, + NoSuchImage, + NoSuchNetwork, + PortMappings, + RegistryConnectionError, + SimpleVolumeBind, + Ulimit, + Util, +) +from localstack.utils.strings import to_bytes, to_str +from localstack.utils.threads import start_worker_thread + +LOG = logging.getLogger(__name__) +SDK_ISDIR = 1 << 31 + + +class SdkDockerClient(ContainerClient): + """ + Class for managing Docker (or Podman) using the Python Docker SDK. + + The client also supports targeting Podman engines, as Podman is almost a drop-in replacement + for Docker these days (with ongoing efforts to further streamline the two), and the Docker SDK + is doing some of the heavy lifting for us to support both target platforms. + """ + + docker_client: Optional[DockerClient] + + def __init__(self): + try: + self.docker_client = self._create_client() + logging.getLogger("urllib3").setLevel(logging.INFO) + except DockerNotAvailable: + self.docker_client = None + + def client(self): + if self.docker_client: + return self.docker_client + # if the initialization failed before, try to initialize on-demand + self.docker_client = self._create_client() + return self.docker_client + + @staticmethod + def _create_client(): + from localstack.config import DOCKER_SDK_DEFAULT_RETRIES, DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS + + for attempt in range(0, DOCKER_SDK_DEFAULT_RETRIES + 1): + try: + return docker.from_env(timeout=DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS) + except DockerException as e: + LOG.debug( + "Creating Docker SDK client failed: %s. " + "If you want to use Docker as container runtime, make sure to mount the socket at /var/run/docker.sock", + e, + exc_info=LS_LOG in TRACE_LOG_LEVELS, + ) + if attempt < DOCKER_SDK_DEFAULT_RETRIES: + # wait for a second before retrying + sleep(1) + else: + # we are out of attempts + raise DockerNotAvailable("Docker not available") from e + + def _read_from_sock(self, sock: socket, tty: bool): + """Reads multiplexed messages from a socket returned by attach_socket. + + Uses the protocol specified here: https://docs.docker.com/engine/api/v1.41/#operation/ContainerAttach + """ + stdout = b"" + stderr = b"" + for frame_type, frame_data in frames_iter(sock, tty): + if frame_type == STDOUT: + stdout += frame_data + elif frame_type == STDERR: + stderr += frame_data + else: + raise ContainerException("Invalid frame type when reading from socket") + return stdout, stderr + + def _container_path_info(self, container: Container, container_path: str): + """ + Get information about a path in the given container + :param container: Container to be inspected + :param container_path: Path in container + :return: Tuple (path_exists, path_is_directory) + """ + # Docker CLI copy uses go FileMode to determine if target is a dict or not + # https://github.com/docker/cli/blob/e3dfc2426e51776a3263cab67fbba753dd3adaa9/cli/command/container/cp.go#L260 + # The isDir Bit is the most significant bit in the 32bit struct: + # https://golang.org/src/os/types.go?s=2650:2683 + api_client = self.client().api + + def _head(path_suffix, **kwargs): + return api_client.head( + api_client.base_url + path_suffix, **api_client._set_request_timeout(kwargs) + ) + + escaped_id = quote(container.id, safe="/:") + result = _head(f"/containers/{escaped_id}/archive", params={"path": container_path}) + stats = result.headers.get("X-Docker-Container-Path-Stat") + target_exists = result.ok + + if target_exists: + stats = json.loads(base64.b64decode(stats).decode("utf-8")) + target_is_dir = target_exists and bool(stats["mode"] & SDK_ISDIR) + return target_exists, target_is_dir + + def get_system_info(self) -> dict: + return self.client().info() + + def get_container_status(self, container_name: str) -> DockerContainerStatus: + # LOG.debug("Getting container status for container: %s", container_name) # too verbose + try: + container = self.client().containers.get(container_name) + if container.status == "running": + return DockerContainerStatus.UP + elif container.status == "paused": + return DockerContainerStatus.PAUSED + else: + return DockerContainerStatus.DOWN + except NotFound: + return DockerContainerStatus.NON_EXISTENT + except APIError as e: + raise ContainerException() from e + + def get_container_stats(self, container_name: str) -> DockerContainerStats: + try: + container = self.client().containers.get(container_name) + sdk_stats = container.stats(stream=False) + + # BlockIO: (Read, Write) bytes + read_bytes = 0 + write_bytes = 0 + for entry in ( + sdk_stats.get("blkio_stats", {}).get("io_service_bytes_recursive", []) or [] + ): + if entry.get("op") == "read": + read_bytes += entry.get("value", 0) + elif entry.get("op") == "write": + write_bytes += entry.get("value", 0) + + # CPU percentage + cpu_stats = sdk_stats.get("cpu_stats", {}) + precpu_stats = sdk_stats.get("precpu_stats", {}) + + cpu_delta = cpu_stats.get("cpu_usage", {}).get("total_usage", 0) - precpu_stats.get( + "cpu_usage", {} + ).get("total_usage", 0) + + system_delta = cpu_stats.get("system_cpu_usage", 0) - precpu_stats.get( + "system_cpu_usage", 0 + ) + + online_cpus = cpu_stats.get("online_cpus", 1) + cpu_percent = ( + (cpu_delta / system_delta * 100.0 * online_cpus) if system_delta > 0 else 0.0 + ) + + # Memory (usage, limit) bytes + memory_stats = sdk_stats.get("memory_stats", {}) + mem_usage = memory_stats.get("usage", 0) + mem_limit = memory_stats.get("limit", 1) # Prevent division by zero + mem_inactive = memory_stats.get("stats", {}).get("inactive_file", 0) + used_memory = max(0, mem_usage - mem_inactive) + mem_percent = (used_memory / mem_limit * 100.0) if mem_limit else 0.0 + + # Network IO + net_rx = 0 + net_tx = 0 + for iface in sdk_stats.get("networks", {}).values(): + net_rx += iface.get("rx_bytes", 0) + net_tx += iface.get("tx_bytes", 0) + + # Container ID + container_id = sdk_stats.get("id", "")[:12] + name = sdk_stats.get("name", "").lstrip("/") + + return DockerContainerStats( + Container=container_id, + ID=container_id, + Name=name, + BlockIO=(read_bytes, write_bytes), + CPUPerc=round(cpu_percent, 2), + MemPerc=round(mem_percent, 2), + MemUsage=(used_memory, mem_limit), + NetIO=(net_rx, net_tx), + PIDs=sdk_stats.get("pids_stats", {}).get("current", 0), + SDKStats=sdk_stats, # keep the raw stats for more detailed information + ) + except NotFound: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def stop_container(self, container_name: str, timeout: int = 10) -> None: + LOG.debug("Stopping container: %s", container_name) + try: + container = self.client().containers.get(container_name) + container.stop(timeout=timeout) + except NotFound: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def restart_container(self, container_name: str, timeout: int = 10) -> None: + LOG.debug("Restarting container: %s", container_name) + try: + container = self.client().containers.get(container_name) + container.restart(timeout=timeout) + except NotFound: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def pause_container(self, container_name: str) -> None: + LOG.debug("Pausing container: %s", container_name) + try: + container = self.client().containers.get(container_name) + container.pause() + except NotFound: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def unpause_container(self, container_name: str) -> None: + LOG.debug("Unpausing container: %s", container_name) + try: + container = self.client().containers.get(container_name) + container.unpause() + except NotFound: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def remove_container(self, container_name: str, force=True, check_existence=False) -> None: + LOG.debug("Removing container: %s", container_name) + if check_existence and container_name not in self.get_running_container_names(): + LOG.debug("Aborting removing due to check_existence check") + return + try: + container = self.client().containers.get(container_name) + container.remove(force=force) + except NotFound: + if not force: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def list_containers(self, filter: Union[List[str], str, None] = None, all=True) -> List[dict]: + if filter: + filter = [filter] if isinstance(filter, str) else filter + filter = dict([f.split("=", 1) for f in filter]) + LOG.debug("Listing containers with filters: %s", filter) + try: + container_list = self.client().containers.list(filters=filter, all=all) + result = [] + for container in container_list: + try: + result.append( + { + "id": container.id, + "image": container.image, + "name": container.name, + "status": container.status, + "labels": container.labels, + } + ) + except Exception as e: + LOG.error("Error checking container %s: %s", container, e) + return result + except APIError as e: + raise ContainerException() from e + + def copy_into_container( + self, container_name: str, local_path: str, container_path: str + ) -> None: # TODO behave like https://docs.docker.com/engine/reference/commandline/cp/ + LOG.debug("Copying file %s into %s:%s", local_path, container_name, container_path) + try: + container = self.client().containers.get(container_name) + target_exists, target_isdir = self._container_path_info(container, container_path) + target_path = container_path if target_isdir else os.path.dirname(container_path) + with Util.tar_path(local_path, container_path, is_dir=target_isdir) as tar: + container.put_archive(target_path, tar) + except NotFound: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def copy_from_container( + self, + container_name: str, + local_path: str, + container_path: str, + ) -> None: + LOG.debug("Copying file from %s:%s to %s", container_name, container_path, local_path) + try: + container = self.client().containers.get(container_name) + bits, _ = container.get_archive(container_path) + Util.untar_to_path(bits, local_path) + except NotFound: + raise NoSuchContainer(container_name) + except APIError as e: + raise ContainerException() from e + + def pull_image( + self, + docker_image: str, + platform: Optional[DockerPlatform] = None, + log_handler: Optional[Callable[[str], None]] = None, + ) -> None: + LOG.debug("Pulling Docker image: %s", docker_image) + # some path in the docker image string indicates a custom repository + + docker_image = self.registry_resolver_strategy.resolve(docker_image) + kwargs: Dict[str, Union[str, bool]] = {"platform": platform} + try: + if log_handler: + # Use a lower-level API, as the 'stream' argument is not available in the higher-level `pull`-API + kwargs["stream"] = True + stream = self.client().api.pull(docker_image, **kwargs) + for line in stream: + log_handler(to_str(line)) + else: + self.client().images.pull(docker_image, **kwargs) + except ImageNotFound: + raise NoSuchImage(docker_image) + except APIError as e: + raise ContainerException() from e + + def push_image(self, docker_image: str) -> None: + LOG.debug("Pushing Docker image: %s", docker_image) + try: + result = self.client().images.push(docker_image) + # some SDK clients (e.g., 5.0.0) seem to return an error string, instead of raising + if isinstance(result, (str, bytes)) and '"errorDetail"' in to_str(result): + if "image does not exist locally" in to_str(result): + raise NoSuchImage(docker_image) + if "is denied" in to_str(result): + raise AccessDenied(docker_image) + if "requesting higher privileges than access token allows" in to_str(result): + raise AccessDenied(docker_image) + if "access token has insufficient scopes" in to_str(result): + raise AccessDenied(docker_image) + if "connection refused" in to_str(result): + raise RegistryConnectionError(result) + raise ContainerException(result) + except ImageNotFound: + raise NoSuchImage(docker_image) + except APIError as e: + # note: error message 'image not known' raised by Podman API + if "image not known" in str(e): + raise NoSuchImage(docker_image) + raise ContainerException() from e + + def build_image( + self, + dockerfile_path: str, + image_name: str, + context_path: str = None, + platform: Optional[DockerPlatform] = None, + ): + try: + dockerfile_path = Util.resolve_dockerfile_path(dockerfile_path) + context_path = context_path or os.path.dirname(dockerfile_path) + LOG.debug("Building Docker image %s from %s", image_name, dockerfile_path) + _, logs_iterator = self.client().images.build( + path=context_path, + dockerfile=dockerfile_path, + tag=image_name, + rm=True, + platform=platform, + ) + # logs_iterator is a stream of dicts. Example content: + # {'stream': 'Step 1/4 : FROM alpine'} + # ... other build steps + # {'aux': {'ID': 'sha256:4dcf90e87fb963e898f9c7a0451a40e36f8e7137454c65ae4561277081747825'}} + # {'stream': 'Successfully tagged img-5201f3e1:latest\n'} + output = "" + for log in logs_iterator: + if isinstance(log, dict) and ("stream" in log or "error" in log): + output += log.get("stream") or log["error"] + return output + except APIError as e: + raise ContainerException("Unable to build Docker image") from e + + def tag_image(self, source_ref: str, target_name: str) -> None: + try: + LOG.debug("Tagging Docker image '%s' as '%s'", source_ref, target_name) + image = self.client().images.get(source_ref) + image.tag(target_name) + except APIError as e: + if e.status_code == 404: + raise NoSuchImage(source_ref) + raise ContainerException("Unable to tag Docker image") from e + + def get_docker_image_names( + self, + strip_latest: bool = True, + include_tags: bool = True, + strip_wellknown_repo_prefixes: bool = True, + ): + try: + images = self.client().images.list() + image_names = [tag for image in images for tag in image.tags if image.tags] + if not include_tags: + image_names = [image_name.rpartition(":")[0] for image_name in image_names] + if strip_wellknown_repo_prefixes: + image_names = Util.strip_wellknown_repo_prefixes(image_names) + if strip_latest: + Util.append_without_latest(image_names) + return image_names + except APIError as e: + raise ContainerException() from e + + def get_container_logs(self, container_name_or_id: str, safe: bool = False) -> str: + try: + container = self.client().containers.get(container_name_or_id) + return to_str(container.logs()) + except NotFound: + if safe: + return "" + raise NoSuchContainer(container_name_or_id) + except APIError as e: + if safe: + return "" + raise ContainerException() from e + + def stream_container_logs(self, container_name_or_id: str) -> CancellableStream: + try: + container = self.client().containers.get(container_name_or_id) + return container.logs(stream=True, follow=True) + except NotFound: + raise NoSuchContainer(container_name_or_id) + except APIError as e: + raise ContainerException() from e + + def inspect_container(self, container_name_or_id: str) -> Dict[str, Union[Dict, str]]: + try: + return self.client().containers.get(container_name_or_id).attrs + except NotFound: + raise NoSuchContainer(container_name_or_id) + except APIError as e: + raise ContainerException() from e + + def inspect_image( + self, + image_name: str, + pull: bool = True, + strip_wellknown_repo_prefixes: bool = True, + ) -> Dict[str, Union[dict, list, str]]: + image_name = self.registry_resolver_strategy.resolve(image_name) + try: + result = self.client().images.get(image_name).attrs + if strip_wellknown_repo_prefixes: + if result.get("RepoDigests"): + result["RepoDigests"] = Util.strip_wellknown_repo_prefixes( + result["RepoDigests"] + ) + if result.get("RepoTags"): + result["RepoTags"] = Util.strip_wellknown_repo_prefixes(result["RepoTags"]) + return result + except NotFound: + if pull: + self.pull_image(image_name) + return self.inspect_image(image_name, pull=False) + raise NoSuchImage(image_name) + except APIError as e: + raise ContainerException() from e + + def create_network(self, network_name: str) -> None: + try: + return self.client().networks.create(name=network_name).id + except APIError as e: + raise ContainerException() from e + + def delete_network(self, network_name: str) -> None: + try: + return self.client().networks.get(network_name).remove() + except NotFound: + raise NoSuchNetwork(network_name) + except APIError as e: + raise ContainerException() from e + + def inspect_network(self, network_name: str) -> Dict[str, Union[Dict, str]]: + try: + return self.client().networks.get(network_name).attrs + except NotFound: + raise NoSuchNetwork(network_name) + except APIError as e: + raise ContainerException() from e + + def connect_container_to_network( + self, + network_name: str, + container_name_or_id: str, + aliases: Optional[List] = None, + link_local_ips: List[str] = None, + ) -> None: + LOG.debug( + "Connecting container '%s' to network '%s' with aliases '%s'", + container_name_or_id, + network_name, + aliases, + ) + try: + network = self.client().networks.get(network_name) + except NotFound: + raise NoSuchNetwork(network_name) + try: + network.connect( + container=container_name_or_id, + aliases=aliases, + link_local_ips=link_local_ips, + ) + except NotFound: + raise NoSuchContainer(container_name_or_id) + except APIError as e: + raise ContainerException() from e + + def disconnect_container_from_network( + self, network_name: str, container_name_or_id: str + ) -> None: + LOG.debug( + "Disconnecting container '%s' from network '%s'", container_name_or_id, network_name + ) + try: + try: + network = self.client().networks.get(network_name) + except NotFound: + raise NoSuchNetwork(network_name) + try: + network.disconnect(container_name_or_id) + except NotFound: + raise NoSuchContainer(container_name_or_id) + except APIError as e: + raise ContainerException() from e + + def get_container_ip(self, container_name_or_id: str) -> str: + networks = self.inspect_container(container_name_or_id)["NetworkSettings"]["Networks"] + network_names = list(networks) + if len(network_names) > 1: + LOG.info("Container has more than one assigned network. Picking the first one...") + return networks[network_names[0]]["IPAddress"] + + @lru_cache(maxsize=None) + def has_docker(self) -> bool: + try: + if not self.docker_client: + return False + self.client().ping() + return True + except APIError: + return False + + def remove_image(self, image: str, force: bool = True): + LOG.debug("Removing image %s %s", image, "(forced)" if force else "") + try: + self.client().images.remove(image=image, force=force) + except ImageNotFound: + if not force: + raise NoSuchImage(image) + except APIError as e: + if "image not known" in str(e): + raise NoSuchImage(image) + raise ContainerException() from e + + def commit( + self, + container_name_or_id: str, + image_name: str, + image_tag: str, + ): + LOG.debug( + "Creating image from container %s as %s:%s", container_name_or_id, image_name, image_tag + ) + try: + container = self.client().containers.get(container_name_or_id) + container.commit(repository=image_name, tag=image_tag) + except NotFound: + raise NoSuchContainer(container_name_or_id) + except APIError as e: + raise ContainerException() from e + + def start_container( + self, + container_name_or_id: str, + stdin=None, + interactive: bool = False, + attach: bool = False, + flags: Optional[str] = None, + ) -> Tuple[bytes, bytes]: + LOG.debug("Starting container %s", container_name_or_id) + try: + container = self.client().containers.get(container_name_or_id) + stdout = to_bytes(container_name_or_id) + stderr = b"" + if interactive or attach: + params = {"stdout": 1, "stderr": 1, "stream": 1} + if interactive: + params["stdin"] = 1 + sock = container.attach_socket(params=params) + sock = sock._sock if hasattr(sock, "_sock") else sock + result_queue = queue.Queue() + thread_started = threading.Event() + start_waiting = threading.Event() + + # Note: We need to be careful about potential race conditions here - .wait() should happen right + # after .start(). Hence starting a thread and asynchronously waiting for the container exit code + def wait_for_result(*_): + _exit_code = -1 + try: + thread_started.set() + start_waiting.wait() + _exit_code = container.wait()["StatusCode"] + except APIError as e: + _exit_code = 1 + raise ContainerException(str(e)) + finally: + result_queue.put(_exit_code) + + # start listener thread + start_worker_thread(wait_for_result) + thread_started.wait() + try: + # start container + container.start() + finally: + # start awaiting container result + start_waiting.set() + + # handle container input/output + # under windows, the socket has no __enter__ / cannot be used as context manager + # therefore try/finally instead of with here + try: + if stdin: + sock.sendall(to_bytes(stdin)) + sock.shutdown(socket.SHUT_WR) + stdout, stderr = self._read_from_sock(sock, False) + except socket.timeout: + LOG.debug( + "Socket timeout when talking to the I/O streams of Docker container '%s'", + container_name_or_id, + ) + finally: + sock.close() + + # get container exit code + exit_code = result_queue.get() + if exit_code: + raise ContainerException( + f"Docker container returned with exit code {exit_code}", + stdout=stdout, + stderr=stderr, + ) + else: + container.start() + return stdout, stderr + except NotFound: + raise NoSuchContainer(container_name_or_id) + except APIError as e: + raise ContainerException() from e + + def attach_to_container(self, container_name_or_id: str): + client: DockerClient = self.client() + container = cast(Container, client.containers.get(container_name_or_id)) + container.attach() + + def create_container( + self, + image_name: str, + *, + name: Optional[str] = None, + entrypoint: Optional[Union[List[str], str]] = None, + remove: bool = False, + interactive: bool = False, + tty: bool = False, + detach: bool = False, + command: Optional[Union[List[str], str]] = None, + volumes: Optional[List[SimpleVolumeBind]] = None, + ports: Optional[PortMappings] = None, + exposed_ports: Optional[List[str]] = None, + env_vars: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cap_add: Optional[List[str]] = None, + cap_drop: Optional[List[str]] = None, + security_opt: Optional[List[str]] = None, + network: Optional[str] = None, + dns: Optional[Union[str, List[str]]] = None, + additional_flags: Optional[str] = None, + workdir: Optional[str] = None, + privileged: Optional[bool] = None, + labels: Optional[Dict[str, str]] = None, + platform: Optional[DockerPlatform] = None, + ulimits: Optional[List[Ulimit]] = None, + init: Optional[bool] = None, + log_config: Optional[LogConfig] = None, + ) -> str: + LOG.debug("Creating container with attributes: %s", locals()) + extra_hosts = None + if additional_flags: + parsed_flags = Util.parse_additional_flags( + additional_flags, + env_vars=env_vars, + volumes=volumes, + network=network, + platform=platform, + privileged=privileged, + ports=ports, + ulimits=ulimits, + user=user, + dns=dns, + ) + env_vars = parsed_flags.env_vars + extra_hosts = parsed_flags.extra_hosts + volumes = parsed_flags.volumes + labels = parsed_flags.labels + network = parsed_flags.network + platform = parsed_flags.platform + privileged = parsed_flags.privileged + ports = parsed_flags.ports + ulimits = parsed_flags.ulimits + user = parsed_flags.user + dns = parsed_flags.dns + + try: + kwargs = {} + if cap_add: + kwargs["cap_add"] = cap_add + if cap_drop: + kwargs["cap_drop"] = cap_drop + if security_opt: + kwargs["security_opt"] = security_opt + if dns: + kwargs["dns"] = ensure_list(dns) + if exposed_ports: + # This is not exactly identical to --expose, as they are listed in the "HostConfig" on docker inspect + # but the behavior should be identical + kwargs["ports"] = {port: [] for port in exposed_ports} + if ports: + kwargs.setdefault("ports", {}) + kwargs["ports"].update(ports.to_dict()) + if workdir: + kwargs["working_dir"] = workdir + if privileged: + kwargs["privileged"] = True + if init: + kwargs["init"] = True + if labels: + kwargs["labels"] = labels + if log_config: + kwargs["log_config"] = DockerLogConfig( + type=log_config.type, config=log_config.config + ) + if ulimits: + kwargs["ulimits"] = [ + docker.types.Ulimit( + name=ulimit.name, soft=ulimit.soft_limit, hard=ulimit.hard_limit + ) + for ulimit in ulimits + ] + mounts = None + if volumes: + mounts = Util.convert_mount_list_to_dict(volumes) + + image_name = self.registry_resolver_strategy.resolve(image_name) + + def create_container(): + return self.client().containers.create( + image=image_name, + command=command, + auto_remove=remove, + name=name, + stdin_open=interactive, + tty=tty, + entrypoint=entrypoint, + environment=env_vars, + detach=detach, + user=user, + network=network, + volumes=mounts, + extra_hosts=extra_hosts, + platform=platform, + **kwargs, + ) + + try: + container = create_container() + except ImageNotFound: + LOG.debug("Image not found. Pulling image %s", image_name) + self.pull_image(image_name, platform) + container = create_container() + return container.id + except ImageNotFound: + raise NoSuchImage(image_name) + except APIError as e: + raise ContainerException() from e + + def run_container( + self, + image_name: str, + stdin=None, + *, + name: Optional[str] = None, + entrypoint: Optional[str] = None, + remove: bool = False, + interactive: bool = False, + tty: bool = False, + detach: bool = False, + command: Optional[Union[List[str], str]] = None, + volumes: Optional[List[SimpleVolumeBind]] = None, + ports: Optional[PortMappings] = None, + exposed_ports: Optional[List[str]] = None, + env_vars: Optional[Dict[str, str]] = None, + user: Optional[str] = None, + cap_add: Optional[List[str]] = None, + cap_drop: Optional[List[str]] = None, + security_opt: Optional[List[str]] = None, + network: Optional[str] = None, + dns: Optional[str] = None, + additional_flags: Optional[str] = None, + workdir: Optional[str] = None, + labels: Optional[Dict[str, str]] = None, + platform: Optional[DockerPlatform] = None, + privileged: Optional[bool] = None, + ulimits: Optional[List[Ulimit]] = None, + init: Optional[bool] = None, + log_config: Optional[LogConfig] = None, + ) -> Tuple[bytes, bytes]: + LOG.debug("Running container with image: %s", image_name) + container = None + try: + container = self.create_container( + image_name, + name=name, + entrypoint=entrypoint, + interactive=interactive, + tty=tty, + detach=detach, + remove=remove and detach, + command=command, + volumes=volumes, + ports=ports, + exposed_ports=exposed_ports, + env_vars=env_vars, + user=user, + cap_add=cap_add, + cap_drop=cap_drop, + security_opt=security_opt, + network=network, + dns=dns, + additional_flags=additional_flags, + workdir=workdir, + privileged=privileged, + platform=platform, + init=init, + labels=labels, + ulimits=ulimits, + log_config=log_config, + ) + result = self.start_container( + container_name_or_id=container, + stdin=stdin, + interactive=interactive, + attach=not detach, + ) + finally: + if remove and container and not detach: + self.remove_container(container) + return result + + def exec_in_container( + self, + container_name_or_id: str, + command: Union[List[str], str], + interactive=False, + detach=False, + env_vars: Optional[Dict[str, Optional[str]]] = None, + stdin: Optional[bytes] = None, + user: Optional[str] = None, + workdir: Optional[str] = None, + ) -> Tuple[bytes, bytes]: + LOG.debug("Executing command in container %s: %s", container_name_or_id, command) + try: + container: Container = self.client().containers.get(container_name_or_id) + result = container.exec_run( + cmd=command, + environment=env_vars, + user=user, + detach=detach, + stdin=interactive and bool(stdin), + socket=interactive and bool(stdin), + stdout=True, + stderr=True, + demux=True, + workdir=workdir, + ) + tty = False + if interactive and stdin: # result is a socket + sock = result[1] + sock = sock._sock if hasattr(sock, "_sock") else sock + with sock: + try: + sock.sendall(stdin) + sock.shutdown(socket.SHUT_WR) + stdout, stderr = self._read_from_sock(sock, tty) + return stdout, stderr + except socket.timeout: + pass + else: + if detach: + return b"", b"" + return_code = result[0] + if isinstance(result[1], bytes): + stdout = result[1] + stderr = b"" + else: + stdout, stderr = result[1] + if return_code != 0: + raise ContainerException( + f"Exec command returned with exit code {return_code}", stdout, stderr + ) + return stdout, stderr + except ContainerError: + raise NoSuchContainer(container_name_or_id) + except APIError as e: + raise ContainerException() from e + + def login(self, username: str, password: str, registry: Optional[str] = None) -> None: + LOG.debug("Docker login for %s", username) + try: + self.client().login(username, password=password, registry=registry, reauth=True) + except APIError as e: + raise ContainerException() from e + + +# apply patches required for podman API compatibility + + +@property +def _container_image(self): + image_id = self.attrs.get("ImageID", self.attrs["Image"]) + if image_id is None: + return None + image_ref = image_id + # Fix for podman API response: Docker returns "sha:..." for `Image`, podman returns ":". + # See https://github.com/containers/podman/issues/8329 . Without this check, the Docker client would + # blindly strip off the suffix after the colon `:` (which is the `` in podman's case) which would + # then lead to "no such image" errors. + if re.match("sha256:[0-9a-f]{64}", image_id, flags=re.IGNORECASE): + image_ref = image_id.split(":")[1] + return self.client.images.get(image_ref) + + +Container.image = _container_image diff --git a/localstack-core/localstack/utils/coverage_docs.py b/localstack-core/localstack/utils/coverage_docs.py new file mode 100644 index 0000000000000..fde4628a32f67 --- /dev/null +++ b/localstack-core/localstack/utils/coverage_docs.py @@ -0,0 +1,20 @@ +_COVERAGE_LINK_BASE = "https://docs.localstack.cloud/references/coverage" + + +def get_coverage_link_for_service(service_name: str, action_name: str) -> str: + from localstack.services.plugins import SERVICE_PLUGINS + + available_services = SERVICE_PLUGINS.list_available() + + if service_name not in available_services: + return ( + f"The API for service '{service_name}' is either not included in your current license plan " + "or has not yet been emulated by LocalStack. " + f"Please refer to {_COVERAGE_LINK_BASE} for more details." + ) + else: + return ( + f"The API action '{action_name}' for service '{service_name}' is either not available in " + "your current license plan or has not yet been emulated by LocalStack. " + f"Please refer to {_COVERAGE_LINK_BASE}/coverage_{service_name} for more information." + ) diff --git a/localstack-core/localstack/utils/crypto.py b/localstack-core/localstack/utils/crypto.py new file mode 100644 index 0000000000000..bd7150d96b871 --- /dev/null +++ b/localstack-core/localstack/utils/crypto.py @@ -0,0 +1,186 @@ +import io +import logging +import os +import re +import threading +from typing import Tuple + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .files import TMP_FILES, file_exists_not_empty, load_file, new_tmp_file, save_file +from .strings import short_uid, to_bytes, to_str +from .sync import synchronized +from .urls import localstack_host + +LOG = logging.getLogger(__name__) + +# block size for symmetric encrypt/decrypt operations +BLOCK_SIZE = 16 + +# lock for creating certificate files +SSL_CERT_LOCK = threading.RLock() + +# markers that indicate the start/end of sections in PEM cert files +PEM_CERT_START = "-----BEGIN CERTIFICATE-----" +PEM_CERT_END = "-----END CERTIFICATE-----" +PEM_KEY_START_REGEX = r"-----BEGIN(.*)PRIVATE KEY-----" +PEM_KEY_END_REGEX = r"-----END(.*)PRIVATE KEY-----" + + +@synchronized(lock=SSL_CERT_LOCK) +def generate_ssl_cert( + target_file=None, + overwrite=False, + random=False, + return_content=False, + serial_number=None, +): + # Note: Do NOT import "OpenSSL" at the root scope + # (Our test Lambdas are importing this file but don't have the module installed) + from OpenSSL import crypto + + def all_exist(*files): + return all(os.path.exists(f) for f in files) + + def store_cert_key_files(base_filename): + key_file_name = "%s.key" % base_filename + cert_file_name = "%s.crt" % base_filename + # TODO: Cleaner code to load the cert dynamically + # extract key and cert from target_file and store into separate files + content = load_file(target_file) + key_start = re.search(PEM_KEY_START_REGEX, content) + key_start = key_start.group(0) + key_end = re.search(PEM_KEY_END_REGEX, content) + key_end = key_end.group(0) + key_content = content[content.index(key_start) : content.index(key_end) + len(key_end)] + cert_content = content[ + content.index(PEM_CERT_START) : content.rindex(PEM_CERT_END) + len(PEM_CERT_END) + ] + save_file(key_file_name, key_content) + save_file(cert_file_name, cert_content) + return cert_file_name, key_file_name + + if target_file and not overwrite and file_exists_not_empty(target_file): + try: + cert_file_name, key_file_name = store_cert_key_files(target_file) + except Exception as e: + # fall back to temporary files if we cannot store/overwrite the files above + LOG.info( + "Error storing key/cert SSL files (falling back to random tmp file names): %s", e + ) + target_file_tmp = new_tmp_file() + cert_file_name, key_file_name = store_cert_key_files(target_file_tmp) + if all_exist(cert_file_name, key_file_name): + return target_file, cert_file_name, key_file_name + if random and target_file: + if "." in target_file: + target_file = target_file.replace(".", ".%s." % short_uid(), 1) + else: + target_file = "%s.%s" % (target_file, short_uid()) + + # create a key pair + k = crypto.PKey() + k.generate_key(crypto.TYPE_RSA, 2048) + + host_definition = localstack_host() + + # create a self-signed cert + cert = crypto.X509() + subj = cert.get_subject() + subj.C = "AU" + subj.ST = "Some-State" + subj.L = "Some-Locality" + subj.O = "LocalStack Org" # noqa + subj.OU = "Testing" + subj.CN = "localhost" + # Note: new requirements for recent OSX versions: https://support.apple.com/en-us/HT210176 + # More details: https://www.iol.unh.edu/blog/2019/10/10/macos-catalina-and-chrome-trust + serial_number = serial_number or 1001 + cert.set_version(2) + cert.set_serial_number(serial_number) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(2 * 365 * 24 * 60 * 60) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(k) + alt_names = ( + f"DNS:localhost,DNS:test.localhost.atlassian.io,DNS:localhost.localstack.cloud,DNS:{host_definition.host}IP:127.0.0.1" + ).encode("utf8") + cert.add_extensions( + [ + crypto.X509Extension(b"subjectAltName", False, alt_names), + crypto.X509Extension(b"basicConstraints", True, b"CA:false"), + crypto.X509Extension( + b"keyUsage", True, b"nonRepudiation,digitalSignature,keyEncipherment" + ), + crypto.X509Extension(b"extendedKeyUsage", True, b"serverAuth"), + ] + ) + cert.sign(k, "SHA256") + + cert_file = io.StringIO() + key_file = io.StringIO() + cert_file.write(to_str(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))) + key_file.write(to_str(crypto.dump_privatekey(crypto.FILETYPE_PEM, k))) + cert_file_content = cert_file.getvalue().strip() + key_file_content = key_file.getvalue().strip() + file_content = "%s\n%s" % (key_file_content, cert_file_content) + if target_file: + key_file_name = "%s.key" % target_file + cert_file_name = "%s.crt" % target_file + # check existence to avoid permission denied issues: + # https://github.com/localstack/localstack/issues/1607 + if not all_exist(target_file, key_file_name, cert_file_name): + for i in range(2): + try: + save_file(target_file, file_content) + save_file(key_file_name, key_file_content) + save_file(cert_file_name, cert_file_content) + break + except Exception as e: + if i > 0: + raise + LOG.info( + "Unable to store certificate file under %s, using tmp file instead: %s", + target_file, + e, + ) + # Fix for https://github.com/localstack/localstack/issues/1743 + target_file = "%s.pem" % new_tmp_file() + key_file_name = "%s.key" % target_file + cert_file_name = "%s.crt" % target_file + TMP_FILES.append(target_file) + TMP_FILES.append(key_file_name) + TMP_FILES.append(cert_file_name) + if not return_content: + return target_file, cert_file_name, key_file_name + return file_content + + +def pad(s: bytes) -> bytes: + return s + to_bytes((BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)) + + +def unpad(s: bytes) -> bytes: + return s[0 : -s[-1]] + + +def encrypt(key: bytes, message: bytes, iv: bytes = None, aad: bytes = None) -> Tuple[bytes, bytes]: + iv = iv or b"0" * BLOCK_SIZE + cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()) + encryptor = cipher.encryptor() + encryptor.authenticate_additional_data(aad) + encrypted = encryptor.update(pad(message)) + encryptor.finalize() + return encrypted, encryptor.tag + + +def decrypt( + key: bytes, encrypted: bytes, iv: bytes = None, tag: bytes = None, aad: bytes = None +) -> bytes: + iv = iv or b"0" * BLOCK_SIZE + cipher = Cipher(algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend()) + decryptor = cipher.decryptor() + decryptor.authenticate_additional_data(aad) + decrypted = decryptor.update(encrypted) + decryptor.finalize() + decrypted = unpad(decrypted) + return decrypted diff --git a/localstack-core/localstack/utils/diagnose.py b/localstack-core/localstack/utils/diagnose.py new file mode 100644 index 0000000000000..36b0b079631f9 --- /dev/null +++ b/localstack-core/localstack/utils/diagnose.py @@ -0,0 +1,156 @@ +"""Diagnostic tool for a localstack instance running in a container.""" + +import inspect +import os +import socket +from typing import Dict, List, Optional, Union + +from localstack import config +from localstack.constants import DEFAULT_VOLUME_DIR +from localstack.services.lambda_.invocation.docker_runtime_executor import IMAGE_PREFIX +from localstack.services.lambda_.runtimes import IMAGE_MAPPING +from localstack.utils import bootstrap +from localstack.utils.analytics.metrics import MetricRegistry +from localstack.utils.container_networking import get_main_container_name +from localstack.utils.container_utils.container_client import ContainerException, NoSuchImage +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.files import load_file + +LAMBDA_IMAGES = (f"{IMAGE_PREFIX}{postfix}" for postfix in IMAGE_MAPPING.values()) + + +DIAGNOSE_IMAGES = [ + "localstack/bigdata", + "mongo", + *LAMBDA_IMAGES, +] + +EXCLUDE_CONFIG_KEYS = { + "CONFIG_ENV_VARS", + "copyright", + "__builtins__", + "__cached__", + "__doc__", + "__file__", + "__loader__", + "__name__", + "__package__", + "__spec__", +} +ENDPOINT_RESOLVE_LIST = ["localhost.localstack.cloud", "api.localstack.cloud"] +INSPECT_DIRECTORIES = [DEFAULT_VOLUME_DIR, "/tmp"] + + +def get_localstack_logs() -> Dict: + try: + result = DOCKER_CLIENT.get_container_logs(get_main_container_name()) + except Exception as e: + result = f"error getting docker logs for container: {e}" + + return {"docker": result} + + +def get_localstack_config() -> Dict: + result = {} + for k, v in inspect.getmembers(config): + if k in EXCLUDE_CONFIG_KEYS: + continue + if inspect.isbuiltin(v): + continue + if inspect.isfunction(v): + continue + if inspect.ismodule(v): + continue + if inspect.isclass(v): + continue + if "typing." in str(type(v)): + continue + if k == "GATEWAY_LISTEN": + result[k] = config.GATEWAY_LISTEN + continue + + if hasattr(v, "__dict__"): + result[k] = v.__dict__ + else: + result[k] = v + + return result + + +def inspect_main_container() -> Union[str, Dict]: + try: + return DOCKER_CLIENT.inspect_container(get_main_container_name()) + except Exception as e: + return f"inspect failed: {e}" + + +def get_localstack_version() -> Dict[str, Optional[str]]: + return { + "build-date": os.environ.get("LOCALSTACK_BUILD_DATE"), + "build-git-hash": os.environ.get("LOCALSTACK_BUILD_GIT_HASH"), + "build-version": os.environ.get("LOCALSTACK_BUILD_VERSION"), + } + + +def resolve_endpoints() -> Dict[str, str]: + result = {} + for endpoint in ENDPOINT_RESOLVE_LIST: + try: + resolved_endpoint = socket.gethostbyname(endpoint) + except Exception as e: + resolved_endpoint = f"unable_to_resolve {e}" + result[endpoint] = resolved_endpoint + return result + + +def get_important_image_hashes() -> Dict[str, str]: + result = {} + for image in DIAGNOSE_IMAGES: + try: + image_version = DOCKER_CLIENT.inspect_image(image, pull=False)["RepoDigests"] + except NoSuchImage: + image_version = "not_present" + except Exception as e: + image_version = f"error: {e}" + result[image] = image_version + return result + + +def get_service_stats() -> Dict[str, str]: + from localstack.services.plugins import SERVICE_PLUGINS + + return {service: state.value for service, state in SERVICE_PLUGINS.get_states().items()} + + +def get_file_tree() -> Dict[str, List[str]]: + return {d: traverse_file_tree(d) for d in INSPECT_DIRECTORIES} + + +def traverse_file_tree(root: str) -> List[str]: + try: + result = [] + if config.in_docker(): + for dirpath, _, _ in os.walk(root): + result.append(dirpath) + return result + except Exception as e: + return ["traversing files failed %s" % e] + + +def get_docker_image_details() -> Dict[str, str]: + try: + image = DOCKER_CLIENT.inspect_container(get_main_container_name())["Config"]["Image"] + except ContainerException: + return {} + # The default bootstrap image detection does not take custom images into account. + # Also, the patches to correctly detect a `-pro` image are only applied on the host, so the detection fails + # at runtime. The bootstrap detection is mostly used for the CLI, so having a different logic here makes sense. + return bootstrap.get_docker_image_details(image_name=image) + + +def get_host_kernel_version() -> str: + return load_file("/proc/version", "failed").strip() + + +def get_usage(): + return MetricRegistry().collect() diff --git a/localstack-core/localstack/utils/docker_utils.py b/localstack-core/localstack/utils/docker_utils.py new file mode 100644 index 0000000000000..9ff5f57134ca6 --- /dev/null +++ b/localstack-core/localstack/utils/docker_utils.py @@ -0,0 +1,268 @@ +import functools +import logging +import platform +import random +from typing import List, Optional, Union + +from localstack import config +from localstack.constants import DEFAULT_VOLUME_DIR, DOCKER_IMAGE_NAME +from localstack.utils.collections import ensure_list +from localstack.utils.container_utils.container_client import ( + ContainerClient, + PortMappings, + VolumeInfo, +) +from localstack.utils.net import IntOrPort, Port, PortNotAvailableException, PortRange +from localstack.utils.objects import singleton_factory +from localstack.utils.strings import to_str + +LOG = logging.getLogger(__name__) + +# port range instance used to reserve Docker container ports +PORT_START = 0 +PORT_END = 65536 +RANDOM_PORT_START = 1024 +RANDOM_PORT_END = 65536 + + +def is_docker_sdk_installed() -> bool: + try: + import docker # noqa: F401 + + return True + except ModuleNotFoundError: + return False + + +def create_docker_client() -> ContainerClient: + # never use the sdk client if it is not installed or not in docker - too risky for wrong version + if config.LEGACY_DOCKER_CLIENT or not is_docker_sdk_installed() or not config.is_in_docker: + from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient + + LOG.debug( + "Using CmdDockerClient. LEGACY_DOCKER_CLIENT: %s, SDK installed: %s", + config.LEGACY_DOCKER_CLIENT, + is_docker_sdk_installed(), + ) + + return CmdDockerClient() + else: + from localstack.utils.container_utils.docker_sdk_client import SdkDockerClient + + LOG.debug( + "Using SdkDockerClient. LEGACY_DOCKER_CLIENT: %s, SDK installed: %s", + config.LEGACY_DOCKER_CLIENT, + is_docker_sdk_installed(), + ) + + return SdkDockerClient() + + +def get_current_container_id() -> str: + """ + Returns the ID of the current container, or raises a ValueError if we're not in docker. + + :return: the ID of the current container + """ + if not config.is_in_docker: + raise ValueError("not in docker") + + container_id = platform.node() + if not container_id: + raise OSError("no hostname returned to use as container id") + + return container_id + + +def inspect_current_container_mounts() -> List[VolumeInfo]: + return DOCKER_CLIENT.inspect_container_volumes(get_current_container_id()) + + +@functools.lru_cache() +def get_default_volume_dir_mount() -> Optional[VolumeInfo]: + """ + Returns the volume information of LocalStack's DEFAULT_VOLUME_DIR (/var/lib/localstack), if mounted, + else it returns None. If we're not currently in docker a VauleError is raised. in a container, a ValueError is + raised. + + :return: the volume info of the default volume dir or None + """ + for volume in inspect_current_container_mounts(): + if volume.destination.rstrip("/") == DEFAULT_VOLUME_DIR: + return volume + + return None + + +def get_host_path_for_path_in_docker(path): + """ + Returns the calculated host location for a given subpath of DEFAULT_VOLUME_DIR inside the localstack container. + The path **has** to be a subdirectory of DEFAULT_VOLUME_DIR (the dir itself *will not* work). + + :param path: Path to be replaced (subpath of DEFAULT_VOLUME_DIR) + :return: Path on the host + """ + if config.is_in_docker and DOCKER_CLIENT.has_docker(): + volume = get_default_volume_dir_mount() + + if volume: + if volume.type != "bind": + raise ValueError( + f"Mount to {DEFAULT_VOLUME_DIR} needs to be a bind mount for mounting to work" + ) + + if not path.startswith(f"{DEFAULT_VOLUME_DIR}/") and path != DEFAULT_VOLUME_DIR: + # We should be able to replace something here. + # if this warning is printed, the usage of this function is probably wrong. + # Please check if the target path is indeed prefixed by /var/lib/localstack + # if this happens, mounts may fail + LOG.warning( + "Error while performing automatic host path replacement for path '%s' to source '%s'", + path, + volume.source, + ) + else: + relative_path = path.removeprefix(DEFAULT_VOLUME_DIR) + result = volume.source + relative_path + return result + else: + raise ValueError(f"No volume mounted to {DEFAULT_VOLUME_DIR}") + + return path + + +def container_ports_can_be_bound( + ports: Union[IntOrPort, List[IntOrPort]], + address: Optional[str] = None, +) -> bool: + """Determine whether a given list of ports can be bound by Docker containers + + :param ports: single port or list of ports to check + :return: True iff all ports can be bound + """ + port_mappings = PortMappings(bind_host=address or "") + ports = ensure_list(ports) + for port in ports: + port = Port.wrap(port) + port_mappings.add(port.port, port.port, protocol=port.protocol) + try: + result = DOCKER_CLIENT.run_container( + _get_ports_check_docker_image(), + entrypoint="sh", + command=["-c", "echo test123"], + ports=port_mappings, + remove=True, + ) + except Exception as e: + if "port is already allocated" not in str(e) and "address already in use" not in str(e): + LOG.warning( + "Unexpected error when attempting to determine container port status", exc_info=e + ) + return False + # TODO(srw): sometimes the command output from the docker container is "None", particularly when this function is + # invoked multiple times consecutively. Work out why. + if to_str(result[0] or "").strip() != "test123": + LOG.warning( + "Unexpected output when attempting to determine container port status: %s", result + ) + return True + + +class _DockerPortRange(PortRange): + """ + PortRange which checks whether the port can be bound on the host instead of inside the container. + """ + + def _port_can_be_bound(self, port: IntOrPort) -> bool: + return container_ports_can_be_bound(port) + + +reserved_docker_ports = _DockerPortRange(PORT_START, PORT_END) + + +def is_port_available_for_containers(port: IntOrPort) -> bool: + """Check whether the given port can be bound by containers and is not currently reserved""" + return not is_container_port_reserved(port) and container_ports_can_be_bound(port) + + +def reserve_container_port(port: IntOrPort, duration: int = None): + """Reserve the given container port for a short period of time""" + reserved_docker_ports.reserve_port(port, duration=duration) + + +def is_container_port_reserved(port: IntOrPort) -> bool: + """Return whether the given container port is currently reserved""" + port = Port.wrap(port) + return reserved_docker_ports.is_port_reserved(port) + + +def reserve_available_container_port( + duration: int = None, + port_start: int = None, + port_end: int = None, + protocol: str = None, +) -> int: + """ + Determine a free port within the given port range that can be bound by a Docker container, and reserve + the port for the given number of seconds + + :param duration: the number of seconds to reserve the port (default: ~6 seconds) + :param port_start: the start of the port range to check (default: 1024) + :param port_end: the end of the port range to check (default: 65536) + :param protocol: the network protocol (default: tcp) + :return: a random port + :raises PortNotAvailableException: if no port is available within the given range + """ + + protocol = protocol or "tcp" + + def _random_port(): + port = None + while not port or reserved_docker_ports.is_port_reserved(port): + port_number = random.randint( + RANDOM_PORT_START if port_start is None else port_start, + RANDOM_PORT_END if port_end is None else port_end, + ) + port = Port(port=port_number, protocol=protocol) + return port + + retries = 10 + for i in range(retries): + port = _random_port() + try: + reserve_container_port(port, duration=duration) + return port.port + except PortNotAvailableException as e: + LOG.debug("Could not bind port %s, trying the next one: %s", port, e) + + raise PortNotAvailableException( + f"Unable to determine available Docker container port after {retries} retries" + ) + + +@singleton_factory +def _get_ports_check_docker_image() -> str: + """ + Determine the Docker image to use for Docker port availability checks. + Uses either PORTS_CHECK_DOCKER_IMAGE (if configured), or otherwise inspects the running container's image. + """ + if config.PORTS_CHECK_DOCKER_IMAGE: + # explicit configuration takes precedence + return config.PORTS_CHECK_DOCKER_IMAGE + if not config.is_in_docker: + # local import to prevent circular imports + from localstack.utils.bootstrap import get_docker_image_to_start + + # Use whatever image the user is trying to run LocalStack with, since they either have + # it already, or need it by definition to start LocalStack. + return get_docker_image_to_start() + try: + # inspect the running container to determine the image + container = DOCKER_CLIENT.inspect_container(get_current_container_id()) + return container["Config"]["Image"] + except Exception: + # fall back to using the default Docker image + return DOCKER_IMAGE_NAME + + +DOCKER_CLIENT: ContainerClient = create_docker_client() diff --git a/localstack-core/localstack/utils/event_matcher.py b/localstack-core/localstack/utils/event_matcher.py new file mode 100644 index 0000000000000..f804a61e33bf8 --- /dev/null +++ b/localstack-core/localstack/utils/event_matcher.py @@ -0,0 +1,61 @@ +from typing import Any + +from localstack.services.events.event_rule_engine import ( + EventPatternCompiler, + EventRuleEngine, + InvalidEventPatternException, +) + +_event_pattern_compiler = EventPatternCompiler() +_event_rule_engine = EventRuleEngine() + + +def matches_event(event_pattern: dict[str, Any] | str | None, event: dict[str, Any] | str) -> bool: + """ + Match events based on configured rule engine. + + Note: Different services handle patterns/events differently: + - EventBridge uses strings + - ESM and Pipes use dicts + + Args: + event_pattern: Event pattern (str for EventBridge, dict for ESM/Pipes) + event: Event to match against pattern (str for EventBridge, dict for ESM/Pipes) + + Returns: + bool: True if event matches pattern, False otherwise + + Examples: + # EventBridge (string-based): + >>> pattern = '{"source": ["aws.ec2"]}' + >>> event = '{"source": "aws.ec2"}' + + # ESM/Pipes (dict-based): + >>> pattern = {"source": ["aws.ec2"]} + >>> event = {"source": "aws.ec2"} + + References: + - EventBridge Patterns: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html + - EventBridge Pipes: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-pipes-event-filtering.html + - Event Source Mappings: https://docs.aws.amazon.com/lambda/latest/dg/invocation-eventfiltering.html + """ + if not event_pattern: + return True + + # Python implementation (default) + compiled_event_pattern = _event_pattern_compiler.compile_event_pattern( + event_pattern=event_pattern + ) + return _event_rule_engine.evaluate_pattern_on_event( + compiled_event_pattern=compiled_event_pattern, + event=event, + ) + + +def validate_event_pattern(event_pattern: dict[str, Any] | str | None) -> bool: + try: + _ = _event_pattern_compiler.compile_event_pattern(event_pattern=event_pattern) + except InvalidEventPatternException: + return False + + return True diff --git a/localstack-core/localstack/utils/files.py b/localstack-core/localstack/utils/files.py new file mode 100644 index 0000000000000..7b71e26ca8664 --- /dev/null +++ b/localstack-core/localstack/utils/files.py @@ -0,0 +1,304 @@ +import configparser +import inspect +import logging +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import Dict + +LOG = logging.getLogger(__name__) +TMP_FILES = [] + + +def parse_config_file(file_or_str: str, single_section: bool = True) -> Dict: + """Parse the given properties config file/string and return a dict of section->key->value. + If the config contains a single section, and 'single_section' is True, returns""" + + config = configparser.RawConfigParser() + + if os.path.exists(file_or_str): + file_or_str = load_file(file_or_str) + + try: + config.read_string(file_or_str) + except configparser.MissingSectionHeaderError: + file_or_str = f"[default]\n{file_or_str}" + config.read_string(file_or_str) + + sections = list(config.sections()) + + result = {sec: dict(config.items(sec)) for sec in sections} + if len(sections) == 1 and single_section: + result = result[sections[0]] + + return result + + +def get_user_cache_dir() -> Path: + """ + Returns the path of the user's cache dir (e.g., ~/.cache on Linux, or ~/Library/Caches on Mac). + + :return: a Path pointing to the platform-specific cache dir of the user + """ + from localstack.utils.platform import is_linux, is_mac_os, is_windows + + if is_windows(): + return Path(os.path.expandvars(r"%LOCALAPPDATA%\cache")) + if is_mac_os(): + return Path.home() / "Library" / "Caches" + if is_linux(): + string_path = os.environ.get("XDG_CACHE_HOME") + if string_path and os.path.isabs(string_path): + return Path(string_path) + # Use the common place to store caches in Linux as a default + return Path.home() / ".cache" + + +def cache_dir() -> Path: + """ + Returns the cache dir for localstack (e.g., ~/.cache/localstack) + + :return: a Path pointing to the localstack cache dir + """ + return get_user_cache_dir() / "localstack" + + +def save_file(file, content, append=False, permissions=None): + mode = "a" if append else "w+" + if not isinstance(content, str): + mode = mode + "b" + + def _opener(path, flags): + return os.open(path, flags, permissions) + + # make sure that the parent dir exists + mkdir(os.path.dirname(file)) + # store file contents + with open(file, mode, opener=_opener if permissions else None) as f: + f.write(content) + f.flush() + + +def load_file(file_path: str, default=None, mode=None): + if not os.path.isfile(file_path): + return default + if not mode: + mode = "r" + with open(file_path, mode) as f: + result = f.read() + return result + + +def get_or_create_file(file_path, content=None, permissions=None): + if os.path.exists(file_path): + return load_file(file_path) + content = "{}" if content is None else content + try: + save_file(file_path, content, permissions=permissions) + return content + except Exception: + pass + + +def replace_in_file(search, replace, file_path): + """Replace all occurrences of `search` with `replace` in the given file (overwrites in place!)""" + content = load_file(file_path) or "" + content_new = content.replace(search, replace) + if content != content_new: + save_file(file_path, content_new) + + +def mkdir(folder: str): + if not os.path.exists(folder): + os.makedirs(folder, exist_ok=True) + + +def is_empty_dir(directory: str, ignore_hidden: bool = False) -> bool: + """Return whether the given directory contains any entries (files/folders), including hidden + entries whose name starts with a dot (.), unless ignore_hidden=True is passed.""" + if not os.path.isdir(directory): + raise Exception(f"Path is not a directory: {directory}") + entries = os.listdir(directory) + if ignore_hidden: + entries = [e for e in entries if not e.startswith(".")] + return not bool(entries) + + +def ensure_readable(file_path: str, default_perms: int = None): + if default_perms is None: + default_perms = 0o644 + try: + with open(file_path, "rb"): + pass + except Exception: + LOG.info("Updating permissions as file is currently not readable: %s", file_path) + os.chmod(file_path, default_perms) + + +def chown_r(path: str, user: str): + """Recursive chown on the given file/directory path.""" + # keep these imports here for Windows compatibility + import grp + import pwd + + uid = pwd.getpwnam(user).pw_uid + gid = grp.getgrnam(user).gr_gid + os.chown(path, uid, gid) + for root, dirs, files in os.walk(path): + for dirname in dirs: + os.chown(os.path.join(root, dirname), uid, gid) + for filename in files: + os.chown(os.path.join(root, filename), uid, gid) + + +def chmod_r(path: str, mode: int): + """ + Recursive chmod + :param path: path to file or directory + :param mode: permission mask as octal integer value + """ + if not os.path.exists(path): + return + idempotent_chmod(path, mode) + for root, dirnames, filenames in os.walk(path): + for dirname in dirnames: + idempotent_chmod(os.path.join(root, dirname), mode) + for filename in filenames: + idempotent_chmod(os.path.join(root, filename), mode) + + +def idempotent_chmod(path: str, mode: int): + """ + Perform idempotent chmod on the given file path (non-recursively). The function attempts to call `os.chmod`, and + will catch and only re-raise exceptions (e.g., PermissionError) if the file does not have the given mode already. + :param path: path to file + :param mode: permission mask as octal integer value + """ + try: + os.chmod(path, mode) + except Exception: + existing_mode = os.stat(path) + if mode in (existing_mode.st_mode, stat.S_IMODE(existing_mode.st_mode)): + # file already has the desired permissions -> return + return + raise + + +def rm_rf(path: str): + """ + Recursively removes a file or directory + """ + from localstack.utils.platform import is_debian + from localstack.utils.run import run + + if not path or not os.path.exists(path): + return + # Running the native command can be an order of magnitude faster in Alpine on Travis-CI + if is_debian(): + try: + return run('rm -rf "%s"' % path) + except Exception: + pass + # Make sure all files are writeable and dirs executable to remove + try: + chmod_r(path, 0o777) + except PermissionError: + pass # todo log + # check if the file is either a normal file, or, e.g., a fifo + exists_but_non_dir = os.path.exists(path) and not os.path.isdir(path) + if os.path.isfile(path) or exists_but_non_dir: + os.remove(path) + else: + shutil.rmtree(path) + + +def cp_r(src: str, dst: str, rm_dest_on_conflict=False, ignore_copystat_errors=False, **kwargs): + """Recursively copies file/directory""" + # attention: this patch is not threadsafe + copystat_orig = shutil.copystat + if ignore_copystat_errors: + + def _copystat(*args, **kwargs): + try: + return copystat_orig(*args, **kwargs) + except Exception: + pass + + shutil.copystat = _copystat + try: + if os.path.isfile(src): + if os.path.isdir(dst): + dst = os.path.join(dst, os.path.basename(src)) + return shutil.copyfile(src, dst) + if "dirs_exist_ok" in inspect.getfullargspec(shutil.copytree).args: + kwargs["dirs_exist_ok"] = True + try: + return shutil.copytree(src, dst, **kwargs) + except FileExistsError: + if rm_dest_on_conflict: + rm_rf(dst) + return shutil.copytree(src, dst, **kwargs) + raise + except Exception as e: + + def _info(_path): + return "%s (file=%s, symlink=%s)" % ( + _path, + os.path.isfile(_path), + os.path.islink(_path), + ) + + LOG.debug("Error copying files from %s to %s: %s", _info(src), _info(dst), e) + raise + finally: + shutil.copystat = copystat_orig + + +def disk_usage(path: str) -> int: + """Return the disk usage of the given file or directory.""" + + if not os.path.exists(path): + return 0 + + if os.path.isfile(path): + return os.path.getsize(path) + + total_size = 0 + for dirpath, dirnames, filenames in os.walk(path): + for f in filenames: + fp = os.path.join(dirpath, f) + # skip if it is symbolic link + if not os.path.islink(fp): + total_size += os.path.getsize(fp) + return total_size + + +def file_exists_not_empty(path: str) -> bool: + """Return whether the given file or directory exists and is non-empty (i.e., >0 bytes content)""" + return path and disk_usage(path) > 0 + + +def cleanup_tmp_files(): + for tmp in TMP_FILES: + try: + rm_rf(tmp) + except Exception: + pass # file likely doesn't exist, or permission denied + del TMP_FILES[:] + + +def new_tmp_file(suffix: str = None, dir: str = None) -> str: + """Return a path to a new temporary file.""" + tmp_file, tmp_path = tempfile.mkstemp(suffix=suffix, dir=dir) + os.close(tmp_file) + TMP_FILES.append(tmp_path) + return tmp_path + + +def new_tmp_dir(dir: str = None): + folder = new_tmp_file(dir=dir) + rm_rf(folder) + mkdir(folder) + return folder diff --git a/localstack-core/localstack/utils/functions.py b/localstack-core/localstack/utils/functions.py new file mode 100644 index 0000000000000..0640a84fea2a0 --- /dev/null +++ b/localstack-core/localstack/utils/functions.py @@ -0,0 +1,91 @@ +"""Higher-order functional tools.""" + +import functools +import inspect +import logging +from typing import Any, Callable, Dict, Optional, Tuple + +LOG = logging.getLogger(__name__) + + +def run_safe(_python_lambda, *args, _default=None, **kwargs): + print_error = kwargs.get("print_error", False) + try: + return _python_lambda(*args, **kwargs) + except Exception as e: + if print_error: + LOG.warning("Unable to execute function: %s", e) + return _default + + +def call_safe( + func: Callable, args: Tuple = None, kwargs: Dict = None, exception_message: str = None +) -> Optional[Any]: + """ + Call the given function with the given arguments, and if it fails, log the given exception_message. + If logging.DEBUG is set for the logger, then we also log the traceback. + + :param func: function to call + :param args: arguments to pass + :param kwargs: keyword arguments to pass + :param exception_message: message to log on exception + :return: whatever the func returns + """ + if exception_message is None: + exception_message = "error calling function %s" % func.__name__ + if args is None: + args = () + if kwargs is None: + kwargs = {} + + try: + return func(*args, **kwargs) + except Exception as e: + if LOG.isEnabledFor(logging.DEBUG): + LOG.exception(exception_message) + else: + LOG.warning("%s: %s", exception_message, e) + + +def prevent_stack_overflow(match_parameters=False): + """Function decorator to protect a function from stack overflows - + raises an exception if a (potential) infinite recursion is detected.""" + + def _decorator(wrapped): + @functools.wraps(wrapped) + def func(*args, **kwargs): + def _matches(frame): + if frame.function != wrapped.__name__: + return False + frame = frame.frame + + if not match_parameters: + return False + + # construct dict of arguments this stack frame has been called with + prev_call_args = { + frame.f_code.co_varnames[i]: frame.f_locals[frame.f_code.co_varnames[i]] + for i in range(frame.f_code.co_argcount) + } + + # construct dict of arguments the original function has been called with + sig = inspect.signature(wrapped) + this_call_args = dict(zip(sig.parameters.keys(), args, strict=False)) + this_call_args.update(kwargs) + + return prev_call_args == this_call_args + + matching_frames = [frame[2] for frame in inspect.stack(context=1) if _matches(frame)] + if matching_frames: + raise RecursionError("(Potential) infinite recursion detected") + return wrapped(*args, **kwargs) + + return func + + return _decorator + + +def empty_context_manager(): + import contextlib + + return contextlib.nullcontext() diff --git a/localstack-core/localstack/utils/http.py b/localstack-core/localstack/utils/http.py new file mode 100644 index 0000000000000..3e012fd71dad3 --- /dev/null +++ b/localstack-core/localstack/utils/http.py @@ -0,0 +1,326 @@ +import logging +import math +import os +import re +from typing import Dict, Optional, Union +from urllib.parse import parse_qs, parse_qsl, urlencode, urlparse, urlunparse + +import requests +from requests.models import CaseInsensitiveDict, Response + +from localstack import config + +from .strings import to_str + +# chunk size for file downloads +DOWNLOAD_CHUNK_SIZE = 1024 * 1024 + +ACCEPT = "accept" +LOG = logging.getLogger(__name__) + + +def uses_chunked_encoding(response): + return response.headers.get("Transfer-Encoding", "").lower() == "chunked" + + +def parse_chunked_data(data): + """Parse the body of an HTTP message transmitted with chunked transfer encoding.""" + data = (data or "").strip() + chunks = [] + while data: + length = re.match(r"^([0-9a-zA-Z]+)\r\n.*", data) + if not length: + break + length = length.group(1).lower() + length = int(length, 16) + data = data.partition("\r\n")[2] + chunks.append(data[:length]) + data = data[length:].strip() + return "".join(chunks) + + +def create_chunked_data(data, chunk_size: int = 80): + dl = len(data) + ret = "" + for i in range(dl // chunk_size): + ret += "%s\r\n" % (hex(chunk_size)[2:]) + ret += "%s\r\n\r\n" % (data[i * chunk_size : (i + 1) * chunk_size]) + + if len(data) % chunk_size != 0: + ret += "%s\r\n" % (hex(len(data) % chunk_size)[2:]) + ret += "%s\r\n" % (data[-(len(data) % chunk_size) :]) + + ret += "0\r\n\r\n" + return ret + + +def canonicalize_headers(headers: Union[Dict, CaseInsensitiveDict]) -> Dict: + if not headers: + return headers + + def _normalize(name): + if name.lower().startswith(ACCEPT): + return name.lower() + return name + + result = {_normalize(k): v for k, v in headers.items()} + return result + + +def add_path_parameters_to_url(uri: str, path_params: list): + url = urlparse(uri) + last_character = ( + "/" if (len(url.path) == 0 or url.path[-1] != "/") and len(path_params) > 0 else "" + ) + new_path = url.path + last_character + "/".join(path_params) + return urlunparse(url._replace(path=new_path)) + + +def add_query_params_to_url(uri: str, query_params: Dict) -> str: + """ + Add query parameters to the uri. + :param uri: the base uri it can contains path arguments and query parameters + :param query_params: new query parameters to be added + :return: the resulting URL + """ + + # parse the incoming uri + url = urlparse(uri) + + # parses the query part, if exists, into a dict + query_dict = dict(parse_qsl(url.query)) + + # updates the dict with new query parameters + query_dict.update(query_params) + + # encodes query parameters + url_query = urlencode(query_dict) + + # replaces the existing query + url_parse = url._replace(query=url_query) + + return urlunparse(url_parse) + + +def make_http_request( + url: str, data: Union[bytes, str] = None, headers: Dict[str, str] = None, method: str = "GET" +) -> Response: + return requests.request( + url=url, method=method, headers=headers, data=data, auth=NetrcBypassAuth(), verify=False + ) + + +class NetrcBypassAuth(requests.auth.AuthBase): + def __call__(self, r): + return r + + +class _RequestsSafe: + """Wrapper around requests library, which can prevent it from verifying + SSL certificates or reading credentials from ~/.netrc file""" + + verify_ssl = True + + def __getattr__(self, name): + method = requests.__dict__.get(name.lower()) + if not method: + return method + + def _wrapper(*args, **kwargs): + if "auth" not in kwargs: + kwargs["auth"] = NetrcBypassAuth() + url = kwargs.get("url") or (args[1] if name == "request" else args[0]) + if not self.verify_ssl and url.startswith("https://") and "verify" not in kwargs: + kwargs["verify"] = False + return method(*args, **kwargs) + + return _wrapper + + +# create safe_requests instance +safe_requests = _RequestsSafe() + + +def parse_request_data(method: str, path: str, data=None, headers=None) -> Dict: + """Extract request data either from query string as well as request body (e.g., for POST).""" + result = {} + headers = headers or {} + content_type = headers.get("Content-Type", "") + + # add query params to result + parsed_path = urlparse(path) + result.update(parse_qs(parsed_path.query)) + + # add params from url-encoded payload + if method in ["POST", "PUT", "PATCH"] and (not content_type or "form-" in content_type): + # content-type could be either "application/x-www-form-urlencoded" or "multipart/form-data" + try: + params = parse_qs(to_str(data or "")) + result.update(params) + except Exception: + pass # probably binary / JSON / non-URL encoded payload - ignore + + # select first elements from result lists (this is assuming we are not using parameter lists!) + result = {k: v[0] for k, v in result.items()} + return result + + +def get_proxies() -> Dict[str, str]: + proxy_map = {} + if config.OUTBOUND_HTTP_PROXY: + proxy_map["http"] = config.OUTBOUND_HTTP_PROXY + if config.OUTBOUND_HTTPS_PROXY: + proxy_map["https"] = config.OUTBOUND_HTTPS_PROXY + return proxy_map + + +def download( + url: str, + path: str, + verify_ssl: bool = True, + timeout: float = None, + request_headers: Optional[dict] = None, + quiet: bool = False, +) -> None: + """Downloads file at url to the given path. Raises TimeoutError if the optional timeout (in secs) is reached. + + If `quiet` is passed, do not log any status messages. Error messages are still logged. + """ + + # make sure we're creating a new session here to enable parallel file downloads + s = requests.Session() + proxies = get_proxies() + if proxies: + s.proxies.update(proxies) + + # Use REQUESTS_CA_BUNDLE path. If it doesn't exist, use the method provided settings. + # Note that a value that is not False, will result to True and will get the bundle file. + _verify = os.getenv("REQUESTS_CA_BUNDLE", verify_ssl) + + r = None + try: + r = s.get(url, stream=True, verify=_verify, timeout=timeout, headers=request_headers) + # check status code before attempting to read body + if not r.ok: + raise Exception("Failed to download %s, response code %s" % (url, r.status_code)) + + total_size = 0 + if r.headers.get("Content-Length"): + total_size = int(r.headers.get("Content-Length")) + + total_downloaded = 0 + if not os.path.exists(os.path.dirname(path)): + os.makedirs(os.path.dirname(path)) + if not quiet: + LOG.debug("Starting download from %s to %s", url, path) + with open(path, "wb") as f: + iter_length = 0 + percentage_limit = next_percentage_record = 10 # print a log line for every 10% + iter_limit = ( + 1000000 # if we can't tell the percentage, print a log line for every 1MB chunk + ) + for chunk in r.iter_content(DOWNLOAD_CHUNK_SIZE): + # explicitly check the raw stream, since the size from the chunk can be bigger than the amount of + # bytes transferred over the wire due to transparent decompression (f.e. GZIP) + new_total_downloaded = r.raw.tell() + iter_length += new_total_downloaded - total_downloaded + total_downloaded = new_total_downloaded + if chunk: # filter out keep-alive new chunks + f.write(chunk) + elif not quiet: + LOG.debug( + "Empty chunk %s (total %dK of %dK) from %s", + chunk, + total_downloaded / 1024, + total_size / 1024, + url, + ) + + if total_size > 0 and ( + (current_percent := total_downloaded / total_size * 100) + >= next_percentage_record + ): + # increment the limit for the next log output (ensure that there is max 1 log message per block) + # f.e. percentage_limit is 10, current percentage is 71: next log is earliest at 80% + next_percentage_record = ( + math.floor(current_percent / percentage_limit) * percentage_limit + + percentage_limit + ) + if not quiet: + LOG.debug( + "Downloaded %d%% (total %dK of %dK) to %s", + current_percent, + total_downloaded / 1024, + total_size / 1024, + path, + ) + iter_length = 0 + elif total_size <= 0 and iter_length >= iter_limit: + if not quiet: + # print log message every x K if the total size is not known + LOG.debug( + "Downloaded %dK (total %dK) to %s", + iter_length / 1024, + total_downloaded / 1024, + path, + ) + iter_length = 0 + f.flush() + os.fsync(f) + if os.path.getsize(path) == 0: + LOG.warning("Zero bytes downloaded from %s, retrying", url) + download(url, path, verify_ssl) + return + if not quiet: + LOG.debug( + "Done downloading %s, response code %s, total %dK", + url, + r.status_code, + total_downloaded / 1024, + ) + except requests.exceptions.ReadTimeout as e: + raise TimeoutError(f"Timeout ({timeout}) reached on download: {url} - {e}") + finally: + if r is not None: + r.close() + s.close() + + +def download_github_artifact(url: str, target_file: str, timeout: int = None): + """Download file from main URL or fallback URL (to avoid firewall errors if github.com is blocked). + Optionally allows to define a timeout in seconds.""" + + def do_download( + download_url: str, request_headers: Optional[dict] = None, print_error: bool = False + ): + try: + download(download_url, target_file, timeout=timeout, request_headers=request_headers) + return True + except Exception as e: + if print_error: + LOG.exception( + "Unable to download Github artifact from %s to %s: %s %s", + url, + target_file, + e, + ) + + # if a GitHub API token is set, use it to avoid rate limiting issues + gh_token = os.environ.get("GITHUB_API_TOKEN") + gh_auth_headers = None + if gh_token: + gh_auth_headers = {"authorization": f"Bearer {gh_token}"} + result = do_download(url, request_headers=gh_auth_headers) + if not result: + # TODO: use regex below to allow different branch names than "master" + url = url.replace("https://github.com", "https://cdn.jsdelivr.net/gh") + # The URL structure is https://cdn.jsdelivr.net/gh/user/repo@branch/file.js + url = url.replace("/raw/master/", "@master/") + # Do not send the GitHub auth token to the CDN + do_download(url, print_error=True) + + +# TODO move to aws_responses.py? +def replace_response_content(response, pattern, replacement): + content = to_str(response.content or "") + response._content = re.sub(pattern, replacement, content) diff --git a/localstack-core/localstack/utils/id_generator.py b/localstack-core/localstack/utils/id_generator.py new file mode 100644 index 0000000000000..67d09aafc9092 --- /dev/null +++ b/localstack-core/localstack/utils/id_generator.py @@ -0,0 +1,61 @@ +import random +import string +from contextlib import contextmanager + +from moto.utilities import id_generator as moto_id_generator +from moto.utilities.id_generator import MotoIdManager, ResourceIdentifier, moto_id +from moto.utilities.id_generator import ResourceIdentifier as MotoResourceIdentifier + +from localstack.utils.strings import long_uid, short_uid + +ExistingIds = list[str] | None +Tags = dict[str, str] | None + + +class LocalstackIdManager(MotoIdManager): + def set_custom_id_by_unique_identifier(self, unique_identifier: str, custom_id: str): + with self._lock: + self._custom_ids[unique_identifier] = custom_id + + @contextmanager + def custom_id(self, resource_identifier: ResourceIdentifier, custom_id: str) -> None: + try: + yield self.set_custom_id(resource_identifier, custom_id) + finally: + self.unset_custom_id(resource_identifier) + + +localstack_id_manager = LocalstackIdManager() +moto_id_generator.moto_id_manager = localstack_id_manager +localstack_id = moto_id + +ResourceIdentifier = MotoResourceIdentifier + + +@localstack_id +def generate_uid( + resource_identifier: ResourceIdentifier, + existing_ids: ExistingIds = None, + tags: Tags = None, + length=36, +) -> str: + return long_uid()[:length] + + +@localstack_id +def generate_short_uid( + resource_identifier: ResourceIdentifier, + existing_ids: ExistingIds = None, + tags: Tags = None, +) -> str: + return short_uid() + + +@localstack_id +def generate_str_id( + resource_identifier: ResourceIdentifier, + existing_ids: ExistingIds = None, + tags: Tags = None, + length=8, +) -> str: + return "".join(random.choice(string.ascii_letters) for _ in range(length)) diff --git a/localstack-core/localstack/utils/iputils.py b/localstack-core/localstack/utils/iputils.py new file mode 100644 index 0000000000000..b28ea2215c3c5 --- /dev/null +++ b/localstack-core/localstack/utils/iputils.py @@ -0,0 +1,86 @@ +""" +IP utils wraps the 'ip' cli command (from iproute2) and creates a pythonic OO interface around +some of its functionality. +""" + +import ipaddress +import json +import subprocess as sp +from typing import Any, Generator, TypedDict + +from cachetools import TTLCache, cached + + +def ip_available() -> bool: + try: + output = sp.run(["ip"], capture_output=True) + return "Usage:" in output.stderr.decode("utf8") + except FileNotFoundError: + return False + + +class Route(TypedDict): + """ + Represents an entry in the routing table. + """ + + dst: str | ipaddress.IPv4Network + dev: str + protocol: str + prefsrc: ipaddress.IPv4Address + gateway: ipaddress.IPv4Address | None + metric: int | None + flags: list[str] + + +# Cache with 10 second expiry for the outputs of the results of running the IP command +IP_RESULTS_CACHE = TTLCache(maxsize=100, ttl=10) + + +def get_routes() -> Generator[Route, None, None]: + """ + Return a generator over the routes. + + :return: a generator over route descriptions + """ + yield from _run_ip_command("route", "show") + + +def get_route(name: str) -> Route: + """ + Get information about a single route. + + :param name: name of the route to get details for + :return: the route definition + """ + return _run_ip_command("route", "show", name)[0] + + +def get_default_route() -> Route: + """ + Get information about the default route. + + :return: the definition of the default route + """ + return get_route("default") + + +def get_default_gateway() -> ipaddress.IPv4Address: + """ + Get the IPv4 address for the default gateway. + + :return: the IPv4 address for the default gateway + """ + return ipaddress.IPv4Address(get_default_route()["gateway"]) + + +# Internal command to run `ip --json ...` +@cached(cache=IP_RESULTS_CACHE) +def _run_ip_command(*args) -> Any: + cmd = ["ip", "--json"] + list(args) + + try: + result = sp.check_output(cmd) + except FileNotFoundError: + raise RuntimeError("could not find ip binary on path") + return json.loads(result.decode("utf8")) diff --git a/localstack-core/localstack/utils/java.py b/localstack-core/localstack/utils/java.py new file mode 100644 index 0000000000000..a72dd54fe35c1 --- /dev/null +++ b/localstack-core/localstack/utils/java.py @@ -0,0 +1,116 @@ +""" +Utilities related to Java runtime. +""" + +import logging +from os import environ +from urllib.parse import urlparse + +from localstack import config +from localstack.utils.files import new_tmp_file, rm_rf +from localstack.utils.run import run + +LOG = logging.getLogger(__name__) + + +# +# Network +# + + +def java_system_properties_proxy() -> dict[str, str]: + """ + Returns Java system properties for network proxy settings as per LocalStack configuration. + + See: https://docs.oracle.com/javase/8/docs/technotes/guides/net/proxies.html + """ + props = {} + + for scheme, default_port, proxy_url in [ + ("http", 80, config.OUTBOUND_HTTP_PROXY), + ("https", 443, config.OUTBOUND_HTTPS_PROXY), + ]: + if proxy_url: + parsed_url = urlparse(proxy_url) + port = parsed_url.port or default_port + + props[f"{scheme}.proxyHost"] = parsed_url.hostname + props[f"{scheme}.proxyPort"] = str(port) + + return props + + +# +# SSL +# + + +def build_trust_store( + keytool_path: str, pem_bundle_path: str, env_vars: dict[str, str], store_passwd: str +) -> str: + """ + Build a TrustStore in JKS format from a PEM certificate bundle. + + :param keytool_path: path to the `keytool` binary. + :param pem_bundle_path: path to the PEM bundle. + :param env_vars: environment variables passed during `keytool` execution. This should contain JAVA_HOME and other relevant variables. + :param store_passwd: store password to use. + :return: path to the truststore file. + """ + store_path = new_tmp_file(suffix=".jks") + rm_rf(store_path) + + LOG.debug("Building JKS trust store for %s at %s", pem_bundle_path, store_path) + cmd = f"{keytool_path} -importcert -trustcacerts -alias localstack -file {pem_bundle_path} -keystore {store_path} -storepass {store_passwd} -noprompt" + run(cmd, env_vars=env_vars) + + return store_path + + +def java_system_properties_ssl(keytool_path: str, env_vars: dict[str, str]) -> dict[str, str]: + """ + Returns Java system properties for SSL settings as per LocalStack configuration. + + See https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/JSSERefGuide.html#CustomizingStores + """ + props = {} + + if ca_bundle := environ.get("REQUESTS_CA_BUNDLE"): + store_passwd = "localstack" + store_path = build_trust_store(keytool_path, ca_bundle, env_vars, store_passwd) + props["javax.net.ssl.trustStore"] = store_path + props["javax.net.ssl.trustStorePassword"] = store_passwd + props["javax.net.ssl.trustStoreType"] = "jks" + + return props + + +# +# Other +# + + +def system_properties_to_cli_args(properties: dict[str, str]) -> list[str]: + """ + Convert a dict of Java system properties to a list of CLI arguments. + + e.g.:: + + { + 'java.sys.foo': 'bar', + 'java.sys.lorem': 'ipsum' + } + + returns:: + + [ + '-Djava.sys.foo=bar', + '-Djava.sys.lorem=ipsum', + ] + """ + args = [] + + for arg_name, arg_value in properties.items(): + args.append(f"-D{arg_name}={arg_value}") + + return args diff --git a/localstack-core/localstack/utils/json.py b/localstack-core/localstack/utils/json.py new file mode 100644 index 0000000000000..e2edcf3b4df35 --- /dev/null +++ b/localstack-core/localstack/utils/json.py @@ -0,0 +1,205 @@ +import base64 +import decimal +import json +import logging +import os +from datetime import date, datetime +from json import JSONDecodeError +from typing import Any, Union + +from localstack.config import HostAndPort + +from .numbers import is_number +from .strings import to_str +from .time import timestamp_millis + +LOG = logging.getLogger(__name__) + + +class CustomEncoder(json.JSONEncoder): + """Helper class to convert JSON documents with datetime, decimals, or bytes.""" + + def default(self, o): + import yaml # leave import here, to avoid breaking our Lambda tests! + + if isinstance(o, HostAndPort): + return str(o) + if isinstance(o, decimal.Decimal): + if o % 1 > 0: + return float(o) + else: + return int(o) + if isinstance(o, (datetime, date)): + return timestamp_millis(o) + if isinstance(o, yaml.ScalarNode): + if o.tag == "tag:yaml.org,2002:int": + return int(o.value) + if o.tag == "tag:yaml.org,2002:float": + return float(o.value) + if o.tag == "tag:yaml.org,2002:bool": + return bool(o.value) + return str(o.value) + try: + if isinstance(o, bytes): + return to_str(o) + return super(CustomEncoder, self).default(o) + except Exception: + return None + + +class BytesEncoder(json.JSONEncoder): + """Specialized JSON encoder that encode bytes into Base64 strings.""" + + def default(self, obj): + if isinstance(obj, bytes): + return to_str(base64.b64encode(obj)) + return super().default(obj) + + +class FileMappedDocument(dict): + """A dictionary that is mapped to a json document on disk. + + When the document is created, an attempt is made to load existing contents from disk. To load changes from + concurrent writes, run load(). To save and overwrite the current document on disk, run save(). + """ + + path: Union[str, os.PathLike] + + def __init__(self, path: Union[str, os.PathLike], mode=0o664): + super().__init__() + self.path = path + self.mode = mode + self.load() + + def load(self): + if not os.path.exists(self.path): + return + + if os.path.isdir(self.path): + raise IsADirectoryError + + with open(self.path, "r") as fd: + self.update(json.load(fd)) + + def save(self): + if os.path.isdir(self.path): + raise IsADirectoryError + + if not os.path.exists(self.path): + os.makedirs(os.path.dirname(self.path), exist_ok=True) + + def opener(path, flags): + _fd = os.open(path, flags, self.mode) + os.chmod(path, mode=self.mode, follow_symlinks=True) + return _fd + + with open(self.path, "w", opener=opener) as fd: + json.dump(self, fd) + + +def clone(item): + return json.loads(json.dumps(item)) + + +def clone_safe(item): + return clone(json_safe(item)) + + +def parse_json_or_yaml(markup: str) -> Any: + import yaml # leave import here, to avoid breaking our Lambda tests! + + try: + return json.loads(markup) + except Exception: + try: + return clone_safe(yaml.safe_load(markup)) + except Exception: + try: + return clone_safe(yaml.load(markup, Loader=yaml.SafeLoader)) + except Exception: + raise + + +def try_json(data: str): + """ + Tries to deserialize the passed json input to an object if possible, otherwise returns the original input. + :param data: string + :return: deserialize version of input + """ + try: + return json.loads(to_str(data or "{}")) + except JSONDecodeError: + LOG.warning("failed serialize to json, fallback to original") + return data + + +def json_safe(item: Any) -> Any: + """Return a copy of the given object (e.g., dict) that is safe for JSON dumping""" + try: + return json.loads(json.dumps(item, cls=CustomEncoder)) + except Exception: + item = fix_json_keys(item) + return json.loads(json.dumps(item, cls=CustomEncoder)) + + +def fix_json_keys(item: Any): + """make sure the keys of a JSON are strings (not binary type or other)""" + item_copy = item + if isinstance(item, list): + item_copy = [] + for i in item: + item_copy.append(fix_json_keys(i)) + if isinstance(item, dict): + item_copy = {} + for k, v in item.items(): + item_copy[to_str(k)] = fix_json_keys(v) + return item_copy + + +def canonical_json(obj): + return json.dumps(obj, sort_keys=True) + + +def extract_jsonpath(value, path): + from jsonpath_rw import parse + + jsonpath_expr = parse(path) + result = [match.value for match in jsonpath_expr.find(value)] + result = result[0] if len(result) == 1 else result + return result + + +def assign_to_path(target, path: str, value, delimiter: str = "."): + parts = path.strip(delimiter).split(delimiter) + path_to_parent = delimiter.join(parts[:-1]) + parent = extract_from_jsonpointer_path(target, path_to_parent, auto_create=True) + if not isinstance(parent, dict): + LOG.debug( + 'Unable to find parent (type %s) for path "%s" in object: %s', + type(parent), + path, + target, + ) + return + path_end = int(parts[-1]) if is_number(parts[-1]) else parts[-1].replace("~1", "/") + parent[path_end] = value + return target + + +def extract_from_jsonpointer_path(target, path: str, delimiter: str = "/", auto_create=False): + parts = path.strip(delimiter).split(delimiter) + for part in parts: + path_part = int(part) if is_number(part) else part + if isinstance(target, list) and not is_number(path_part): + if path_part == "-": + # special case where path is like /path/to/list/- where "/-" means "append to list" + continue + LOG.warning('Attempting to extract non-int index "%s" from list: %s', path_part, target) + return None + target_new = target[path_part] if isinstance(target, list) else target.get(path_part) + if target_new is None: + if not auto_create: + return + target[path_part] = target_new = {} + target = target_new + return target diff --git a/localstack-core/localstack/utils/kinesis/__init__.py b/localstack-core/localstack/utils/kinesis/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/kinesis/java/logging.properties b/localstack-core/localstack/utils/kinesis/java/logging.properties new file mode 100644 index 0000000000000..28d435b7eec02 --- /dev/null +++ b/localstack-core/localstack/utils/kinesis/java/logging.properties @@ -0,0 +1,4 @@ +handlers = java.util.logging.ConsoleHandler +.level = INFO +java.util.logging.ConsoleHandler.level = INFO +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter diff --git a/localstack-core/localstack/utils/kinesis/kclipy_helper.py b/localstack-core/localstack/utils/kinesis/kclipy_helper.py new file mode 100644 index 0000000000000..0f06fe5000aac --- /dev/null +++ b/localstack-core/localstack/utils/kinesis/kclipy_helper.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python + +import os +import sys +from glob import glob + +from amazon_kclpy import kcl + +from localstack.utils.files import save_file + + +def get_dir_of_file(f): + return os.path.dirname(os.path.abspath(f)) + + +def get_kcl_dir(): + return get_dir_of_file(kcl.__file__) + + +def get_kcl_jar_path(): + jars = ":".join(glob(os.path.join(get_kcl_dir(), "jars", "*jar"))) + return jars + + +def get_kcl_classpath(properties=None, paths=None): + """ + Generates a classpath that includes the location of the kcl jars, the + properties file and the optional paths. + + :type properties: str + :param properties: Path to properties file. + + :type paths: list + :param paths: List of strings. The paths that will be prepended to the classpath. + + :rtype: str + :return: A java class path that will allow your properties to be + found and the MultiLangDaemon and its deps and + any custom paths you provided. + """ + if paths is None: + paths = [] + # First make all the user provided paths absolute + paths = [os.path.abspath(p) for p in paths] + # We add our paths after the user provided paths because this permits users to + # potentially inject stuff before our paths (otherwise our stuff would always + # take precedence). + paths.append(get_kcl_jar_path()) + if properties: + # Add the dir that the props file is in + dir_of_file = get_dir_of_file(properties) + paths.append(dir_of_file) + # add path of custom java code + dir_name = os.path.dirname(os.path.realpath(__file__)) + paths.insert(0, os.path.realpath(os.path.join(dir_name, "java"))) + return ":".join([p for p in paths if p != ""]) + + +def get_kcl_app_command(java, multi_lang_daemon_class, properties, paths=None): + """ + Generates a command to run the MultiLangDaemon. + + :type java: str + :param java: Path to java + + :type multi_lang_daemon_class: str + :param multi_lang_daemon_class: Name of multi language daemon class, e.g. + com.amazonaws.services.kinesis.multilang.MultiLangDaemon + + :type properties: str + :param properties: Optional properties file to be included in the classpath. + + :type paths: list + :param paths: List of strings. Additional paths to prepend to the classpath. + + :rtype: str + :return: A command that will run the MultiLangDaemon with your + properties and custom paths and java. + """ + if paths is None: + paths = [] + logging_config = os.path.join(get_dir_of_file(__file__), "java", "logging.properties") + sys_props = f'-Djava.util.logging.config.file="{logging_config}" -Daws.cborEnabled=false' + return f"{java} -cp {get_kcl_classpath(properties, paths)} {sys_props} {multi_lang_daemon_class} {os.path.basename(properties)}" + + +def create_config_file( + config_file, + executableName, + streamName, + applicationName, + region_name, + credentialsProvider=None, + **kwargs, +): + if not credentialsProvider: + credentialsProvider = "DefaultCredentialsProvider" + # TODO properly migrate to v3 of KCL and remove the clientVersionConfig + content = f""" + executableName = {executableName} + streamName = {streamName} + applicationName = {applicationName} + AWSCredentialsProvider = {credentialsProvider} + clientVersionConfig = CLIENT_VERSION_CONFIG_COMPATIBLE_WITH_2x + kinesisCredentialsProvider = {credentialsProvider} + dynamoDBCredentialsProvider = {credentialsProvider} + cloudWatchCredentialsProvider = {credentialsProvider} + processingLanguage = python/{sys.version_info.major}.{sys.version_info.minor} + shardSyncIntervalMillis = 2000 + parentShardPollIntervalMillis = 2000 + idleTimeBetweenReadsInMillis = 1000 + timeoutInSeconds = 60 + regionName = {region_name} + """ + # optional properties + for key, value in kwargs.items(): + content += f"\n{key} = {value}" + content = content.replace(" ", "") + save_file(config_file, content) diff --git a/localstack-core/localstack/utils/kinesis/kinesis_connector.py b/localstack-core/localstack/utils/kinesis/kinesis_connector.py new file mode 100644 index 0000000000000..f68e79a379638 --- /dev/null +++ b/localstack-core/localstack/utils/kinesis/kinesis_connector.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python +import json +import logging +import os +import re +import socket +import tempfile +import threading +from typing import Any, Callable + +from amazon_kclpy import kcl +from amazon_kclpy.v2 import processor + +from localstack import config +from localstack.constants import LOCALSTACK_ROOT_FOLDER, LOCALSTACK_VENV_FOLDER +from localstack.packages.java import java_package +from localstack.utils.aws import arns +from localstack.utils.files import TMP_FILES, chmod_r, save_file +from localstack.utils.kinesis import kclipy_helper +from localstack.utils.run import ShellCommandThread +from localstack.utils.strings import short_uid, truncate +from localstack.utils.sync import retry +from localstack.utils.threads import TMP_THREADS, FuncThread +from localstack.utils.time import now + +DEFAULT_DDB_LEASE_TABLE_SUFFIX = "-kclapp" + +# define Java class names +MULTI_LANG_DAEMON_CLASS = "software.amazon.kinesis.multilang.MultiLangDaemon" + +# set up local logger +LOG = logging.getLogger(__name__) + +INITIALIZATION_REGEX = re.compile(r".*Initialization complete.*") +SUBPROCESS_INITIALIZED_REGEX = re.compile(r".*Received response .* for initialize.*") + +# checkpointing settings +CHECKPOINT_RETRIES = 5 +CHECKPOINT_SLEEP_SECS = 5 +CHECKPOINT_FREQ_SECS = 60 + +ListenerFunction = Callable[[list], Any] + + +class EventFileReaderThread(FuncThread): + def __init__(self, events_file, callback: Callable[[list], Any]): + super().__init__( + self.retrieve_loop, None, name="kinesis-event-file-reader", on_stop=self._on_stop + ) + self.events_file = events_file + self.callback = callback + self.is_ready = threading.Event() + self.sock = None + + def retrieve_loop(self, params): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.bind(self.events_file) + self.sock.listen(1) + self.is_ready.set() + with self.sock as sock: + while self.running: + try: + conn, client_addr = sock.accept() + thread = FuncThread( + self.handle_connection, + conn, + name="kinesis-event-file-reader-connectionhandler", + ) + thread.start() + except Exception as e: + # ignore any errors happening during shutdown + if self.running: + LOG.error( + "Error dispatching client request: %s", + e, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + LOG.debug("Stopping retrieve loop for event file %s", self.events_file) + + def wait_for_ready(self): + self.is_ready.wait() + + def _on_stop(self, *args, **kwargs): + if self.sock: + LOG.debug("Shutting down event file reader for event file %s", self.events_file) + # shutdown is needed to unblock sock.accept. However, it will raise a OSError: [Errno 22] Invalid argument + # in the retrieve loop + # still, the easiest way to shut down the accept call without setting socket timeout for now + self.sock.shutdown(socket.SHUT_RDWR) + self.sock.close() + + def handle_connection(self, conn: socket): + socket_file = conn.makefile() + with socket_file as sock: + while self.running: + line = "" + try: + line = sock.readline() + line = line.strip() + if not line: + # end of socket input stream + break + event = json.loads(line) + records = event["records"] + self.callback(records) + except Exception as e: + LOG.warning( + "Unable to process JSON line: '%s': %s. Callback: %s", + truncate(line), + e, + self.callback, + exc_info=LOG.isEnabledFor(logging.DEBUG), + ) + LOG.debug("Shutting down connection handler for events file %s", self.events_file) + + +# needed by the processor script farther down +class KinesisProcessor(processor.RecordProcessorBase): + def __init__(self, log_file=None, processor_func=None, auto_checkpoint=True): + self.log_file = log_file + self.processor_func = processor_func + self.shard_id = None + self.auto_checkpoint = auto_checkpoint + self.last_checkpoint_time = 0 + self._largest_seq = (None, None) + + def initialize(self, initialize_input): + self.shard_id = initialize_input.shard_id + if self.log_file: + self.log(f"initialize '{self.shard_id}'") + self.shard_id = initialize_input.shard_id + + def process_records(self, process_records_input): + if self.processor_func: + records = process_records_input.records + checkpointer = process_records_input.checkpointer + self.processor_func(records=records, checkpointer=checkpointer, shard_id=self.shard_id) + for record in records: + seq = int(record.sequence_number) + sub_seq = record.sub_sequence_number + if self.should_update_sequence(seq, sub_seq): + self._largest_seq = (seq, sub_seq) + if self.auto_checkpoint: + time_now = now() + if (time_now - CHECKPOINT_FREQ_SECS) > self.last_checkpoint_time: + self.checkpoint(checkpointer, str(self._largest_seq[0]), self._largest_seq[1]) + self.last_checkpoint_time = time_now + + def shutdown_requested(self, shutdown_requested_input): + if self.log_file: + self.log(f"Shutdown processor for shard '{self.shard_id}'") + if shutdown_requested_input.action == "TERMINATE": + self.checkpoint(shutdown_requested_input.checkpointer) + + def checkpoint(self, checkpointer, sequence_number=None, sub_sequence_number=None): + def do_checkpoint(): + checkpointer.checkpoint(sequence_number, sub_sequence_number) + + try: + retry(do_checkpoint, retries=CHECKPOINT_RETRIES, sleep=CHECKPOINT_SLEEP_SECS) + except Exception as e: + LOG.warning("Unable to checkpoint Kinesis after retries: %s", e) + + def should_update_sequence(self, sequence_number, sub_sequence_number): + return ( + self._largest_seq == (None, None) + or sequence_number > self._largest_seq[0] + or ( + sequence_number == self._largest_seq[0] + and sub_sequence_number > self._largest_seq[1] + ) + ) + + def log(self, s): + if self.log_file: + save_file(self.log_file, f"{s}\n", append=True) + + @staticmethod + def run_processor(log_file=None, processor_func=None): + proc = kcl.KCLProcess(KinesisProcessor(log_file, processor_func)) + proc.run() + + +class KinesisProcessorThread(ShellCommandThread): + def __init__( + self, + stream_name: str, + properties_file: str, + env_vars: dict[str, str], + listener_function: ListenerFunction, + events_file: str, + ): + self.initialization_completed = threading.Event() + self.subprocesses_initialized = threading.Event() + self.event_reader = EventFileReaderThread(events_file, listener_function) + self.stream_name = stream_name + cmd = kclipy_helper.get_kcl_app_command("java", MULTI_LANG_DAEMON_CLASS, properties_file) + super().__init__( + cmd, + log_listener=self._startup_listener, + env_vars=env_vars, + quiet=False, + name="kinesis-processor", + ) + + def start(self): + self.event_reader.start() + # Wait until the event reader thread is ready (to avoid 'Connection refused' error on the UNIX socket) + self.event_reader.wait_for_ready() + super().start() + + def stop(self, quiet: bool = False): + if self.stopped: + LOG.debug("Kinesis connector for stream %s already stopped.", self.stream_name) + else: + LOG.debug("Stopping kinesis connector for stream: %s", self.stream_name) + self.event_reader.stop() + super().stop(quiet) + + def _startup_listener(self, line: str, **kwargs): + line = line.strip() + # LOG.debug("KCLPY: %s", line) + if not self.initialization_completed.is_set() and INITIALIZATION_REGEX.match(line): + self.initialization_completed.set() + if not self.subprocesses_initialized.is_set() and SUBPROCESS_INITIALIZED_REGEX.match(line): + self.subprocesses_initialized.set() + + def wait_is_up(self, timeout: int | None = None) -> bool: + return self.initialization_completed.wait(timeout=timeout) + + def wait_subprocesses_initialized(self, timeout: int | None = None) -> bool: + return self.subprocesses_initialized.wait(timeout=timeout) + + +def _start_kcl_client_process( + stream_name: str, + account_id: str, + region_name: str, + listener_function: ListenerFunction, + ddb_lease_table_suffix=None, +): + # make sure to convert stream ARN to stream name + stream_name = arns.kinesis_stream_name(stream_name) + + # install Java + java_installer = java_package.get_installer() + java_installer.install() + java_home = java_installer.get_java_home() + + # disable CBOR protocol, enforce use of plain JSON + # TODO evaluate why? + env_vars = { + "AWS_CBOR_DISABLE": "true", + "AWS_ACCESS_KEY_ID": account_id, + "AWS_SECRET_ACCESS_KEY": account_id, + "JAVA_HOME": java_home, + "PATH": f"{java_home}/bin:{os.getenv('PATH')}", + } + + events_file = os.path.join(tempfile.gettempdir(), f"kclipy.{short_uid()}.fifo") + TMP_FILES.append(events_file) + processor_script = _generate_processor_script(events_file) + + properties_file = os.path.join(tempfile.gettempdir(), f"kclipy.{short_uid()}.properties") + app_name = f"{stream_name}{ddb_lease_table_suffix}" + if not config.DISTRIBUTED_MODE: + kinesis_endpoint = config.internal_service_url(protocol="http") + dynamodb_endpoint = config.internal_service_url(protocol="http") + else: + kinesis_endpoint = config.external_service_url( + protocol="http", subdomains=f"kinesis.{region_name}" + ) + dynamodb_endpoint = config.external_service_url( + protocol="http", subdomains=f"dynamodb.{region_name}" + ) + + # create config file + kclipy_helper.create_config_file( + config_file=properties_file, + executableName=processor_script, + streamName=stream_name, + applicationName=app_name, + region_name=region_name, + metricsLevel="NONE", + initialPositionInStream="LATEST", + # set parameters for local connection + kinesisEndpoint=kinesis_endpoint, + dynamoDBEndpoint=dynamodb_endpoint, + disableCertChecking="true", + ) + TMP_FILES.append(properties_file) + # start stream consumer + kinesis_processor = KinesisProcessorThread( + stream_name=stream_name, + properties_file=properties_file, + env_vars=env_vars, + listener_function=listener_function, + events_file=events_file, + ) + kinesis_processor.start() + TMP_THREADS.append(kinesis_processor) + return kinesis_processor + + +def _generate_processor_script(events_file: str): + script_file = os.path.join(tempfile.gettempdir(), f"kclipy.{short_uid()}.processor.py") + content = f"""#!/usr/bin/env python +import os, sys, glob, json, socket, time, logging, subprocess, tempfile +logging.basicConfig(level=logging.INFO) +for path in glob.glob('{LOCALSTACK_VENV_FOLDER}/lib/python*/site-packages'): + sys.path.insert(0, path) +sys.path.insert(0, '{LOCALSTACK_ROOT_FOLDER}') +from localstack.config import DEFAULT_ENCODING +from localstack.utils.kinesis import kinesis_connector +from localstack.utils.time import timestamp +events_file = '{events_file}' +log_file = None +error_log = os.path.join(tempfile.gettempdir(), 'kclipy.error.log') +if __name__ == '__main__': + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + + num_tries = 3 + sleep_time = 2 + error = None + for i in range(0, num_tries): + try: + sock.connect(events_file) + error = None + break + except Exception as e: + error = e + if i < num_tries: + msg = '%s: Unable to connect to UNIX socket. Retrying.' % timestamp() + subprocess.check_output('echo "%s" >> %s' % (msg, error_log), shell=True) + time.sleep(sleep_time) + if error: + print("WARN: Unable to connect to UNIX socket after retrying: %s" % error) + raise error + + def receive_msg(records, checkpointer, shard_id): + try: + # records is a list of amazon_kclpy.messages.Record objects -> convert to JSON + records_dicts = [j._json_dict for j in records] + message_to_send = {{'shard_id': shard_id, 'records': records_dicts}} + string_to_send = '%s\\n' % json.dumps(message_to_send) + bytes_to_send = string_to_send.encode(DEFAULT_ENCODING) + sock.send(bytes_to_send) + except Exception as e: + msg = "WARN: Unable to forward event: %s" % e + print(msg) + subprocess.check_output('echo "%s" >> %s' % (msg, error_log), shell=True) + kinesis_connector.KinesisProcessor.run_processor(log_file=log_file, processor_func=receive_msg) + """ + save_file(script_file, content) + chmod_r(script_file, 0o755) + TMP_FILES.append(script_file) + return script_file + + +def listen_to_kinesis( + stream_name: str, + account_id: str, + region_name: str, + listener_func: ListenerFunction, + ddb_lease_table_suffix: str | None = None, + wait_until_started: bool = False, +): + """ + High-level function that allows to subscribe to a Kinesis stream + and receive events in a listener function. A KCL client process is + automatically started in the background. + """ + process = _start_kcl_client_process( + stream_name=stream_name, + account_id=account_id, + region_name=region_name, + listener_function=listener_func, + ddb_lease_table_suffix=ddb_lease_table_suffix, + ) + + if wait_until_started: + # Wait at most 90 seconds for initialization. Note that creating the DDB table can take quite a bit + success = process.wait_is_up(timeout=90) + if not success: + raise Exception("Timeout when waiting for KCL initialization.") + # ignore success/failure of wait because a timeout merely means that there is no shard available to take + # waiting here, since otherwise some messages would be ignored even though the connector reports ready + process.wait_subprocesses_initialized(timeout=30) + + return process diff --git a/localstack-core/localstack/utils/lambda_debug_mode/__init__.py b/localstack-core/localstack/utils/lambda_debug_mode/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode.py b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode.py new file mode 100644 index 0000000000000..a4b48e76f5b92 --- /dev/null +++ b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode.py @@ -0,0 +1,41 @@ +from typing import Optional + +from localstack.aws.api.lambda_ import Arn +from localstack.utils.lambda_debug_mode.lambda_debug_mode_config import LambdaDebugModeConfig +from localstack.utils.lambda_debug_mode.lambda_debug_mode_session import LambdaDebugModeSession + +# Specifies the fault timeout value in seconds to be used by time restricted workflows when +# Debug Mode is enabled. The value is set to one hour to ensure eventual termination of +# long-running processes. +DEFAULT_LAMBDA_DEBUG_MODE_TIMEOUT_SECONDS: int = 3_600 + + +def is_lambda_debug_mode() -> bool: + return LambdaDebugModeSession.get().is_lambda_debug_mode() + + +def _lambda_debug_config_for(lambda_arn: Arn) -> Optional[LambdaDebugModeConfig]: + if not is_lambda_debug_mode(): + return None + debug_configuration = LambdaDebugModeSession.get().debug_config_for(lambda_arn=lambda_arn) + return debug_configuration + + +def is_lambda_debug_enabled_for(lambda_arn: Arn) -> bool: + """Returns True if the given lambda arn is subject of an active debugging configuration; False otherwise.""" + debug_configuration = _lambda_debug_config_for(lambda_arn=lambda_arn) + return debug_configuration is not None + + +def lambda_debug_port_for(lambda_arn: Arn) -> Optional[int]: + debug_configuration = _lambda_debug_config_for(lambda_arn=lambda_arn) + if debug_configuration is None: + return None + return debug_configuration.debug_port + + +def is_lambda_debug_timeout_enabled_for(lambda_arn: Arn) -> bool: + debug_configuration = _lambda_debug_config_for(lambda_arn=lambda_arn) + if debug_configuration is None: + return False + return not debug_configuration.enforce_timeouts diff --git a/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_config.py b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_config.py new file mode 100644 index 0000000000000..6021261d88da4 --- /dev/null +++ b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_config.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import logging +from typing import Optional + +import yaml +from pydantic import BaseModel, Field, ValidationError +from yaml import Loader, MappingNode, MarkedYAMLError, SafeLoader + +from localstack.aws.api.lambda_ import Arn + +LOG = logging.getLogger(__name__) + + +class LambdaDebugConfig(BaseModel): + debug_port: Optional[int] = Field(None, alias="debug-port") + enforce_timeouts: bool = Field(False, alias="enforce-timeouts") + + +class LambdaDebugModeConfig(BaseModel): + # Bindings of Lambda function Arn and the respective debugging configuration. + functions: dict[Arn, LambdaDebugConfig] + + +class LambdaDebugModeConfigException(Exception): ... + + +class UnknownLambdaArnFormat(LambdaDebugModeConfigException): + unknown_lambda_arn: str + + def __init__(self, unknown_lambda_arn: str): + self.unknown_lambda_arn = unknown_lambda_arn + + def __str__(self): + return f"UnknownLambdaArnFormat: '{self.unknown_lambda_arn}'" + + +class PortAlreadyInUse(LambdaDebugModeConfigException): + port_number: int + + def __init__(self, port_number: int): + self.port_number = port_number + + def __str__(self): + return f"PortAlreadyInUse: '{self.port_number}'" + + +class DuplicateLambdaDebugConfig(LambdaDebugModeConfigException): + lambda_arn_debug_config_first: str + lambda_arn_debug_config_second: str + + def __init__(self, lambda_arn_debug_config_first: str, lambda_arn_debug_config_second: str): + self.lambda_arn_debug_config_first = lambda_arn_debug_config_first + self.lambda_arn_debug_config_second = lambda_arn_debug_config_second + + def __str__(self): + return ( + f"DuplicateLambdaDebugConfig: Lambda debug configuration in '{self.lambda_arn_debug_config_first}' " + f"is redefined in '{self.lambda_arn_debug_config_second}'" + ) + + +class _LambdaDebugModeConfigPostProcessingState: + ports_used: set[int] + + def __init__(self): + self.ports_used = set() + + +class _SafeLoaderWithDuplicateCheck(SafeLoader): + def __init__(self, stream): + super().__init__(stream) + self.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + self._construct_mappings_with_duplicate_check, + ) + + @staticmethod + def _construct_mappings_with_duplicate_check(loader: Loader, node: MappingNode, deep=False): + # Constructs yaml bindings, whilst checking for duplicate mapping key definitions, raising a + # MarkedYAMLError when one is found. + mapping = dict() + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in mapping: + # Create a MarkedYAMLError to indicate the duplicate key issue + raise MarkedYAMLError( + context="while constructing a mapping", + context_mark=node.start_mark, + problem=f"found duplicate key: {key}", + problem_mark=key_node.start_mark, + ) + value = loader.construct_object(value_node, deep=deep) + mapping[key] = value + return mapping + + +def from_yaml_string(yaml_string: str) -> Optional[LambdaDebugModeConfig]: + try: + data = yaml.load(yaml_string, _SafeLoaderWithDuplicateCheck) + except yaml.YAMLError as yaml_error: + LOG.error( + "Could not parse yaml lambda debug mode configuration file due to: %s", + yaml_error, + ) + data = None + if not data: + return None + config = LambdaDebugModeConfig(**data) + return config + + +def post_process_lambda_debug_mode_config(config: LambdaDebugModeConfig) -> None: + _post_process_lambda_debug_mode_config( + post_processing_state=_LambdaDebugModeConfigPostProcessingState(), config=config + ) + + +def _post_process_lambda_debug_mode_config( + post_processing_state: _LambdaDebugModeConfigPostProcessingState, config: LambdaDebugModeConfig +): + config_functions = config.functions + lambda_arns = list(config_functions.keys()) + for lambda_arn in lambda_arns: + qualified_lambda_arn = _to_qualified_lambda_function_arn(lambda_arn) + if lambda_arn != qualified_lambda_arn: + if qualified_lambda_arn in config_functions: + raise DuplicateLambdaDebugConfig( + lambda_arn_debug_config_first=lambda_arn, + lambda_arn_debug_config_second=qualified_lambda_arn, + ) + config_functions[qualified_lambda_arn] = config_functions.pop(lambda_arn) + + for lambda_arn, lambda_debug_config in config_functions.items(): + _post_process_lambda_debug_config( + post_processing_state=post_processing_state, lambda_debug_config=lambda_debug_config + ) + + +def _to_qualified_lambda_function_arn(lambda_arn: Arn) -> Arn: + """ + Returns the $LATEST qualified version of a structurally unqualified version of a lambda Arn iff this + is detected to be structurally unqualified. Otherwise, it returns the given string. + Example: + - arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST -> + unchanged + + - arn:aws:lambda:eu-central-1:000000000000:function:functionname -> + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST + + - arn:aws:lambda:eu-central-1:000000000000:function:functionname: -> + exception UnknownLambdaArnFormat + + - arn:aws:lambda:eu-central-1:000000000000:function -> + exception UnknownLambdaArnFormat + """ + + if not lambda_arn: + return lambda_arn + lambda_arn_parts = lambda_arn.split(":") + lambda_arn_parts_len = len(lambda_arn_parts) + + # The arn is qualified and with a non-empy qualifier. + is_qualified = lambda_arn_parts_len == 8 + if is_qualified and lambda_arn_parts[-1]: + return lambda_arn + + # Unknown lambda arn format. + is_unqualified = lambda_arn_parts_len == 7 + if not is_unqualified: + raise UnknownLambdaArnFormat(unknown_lambda_arn=lambda_arn) + + # Structure-wise, the arn is missing the qualifier. + qualifier = "$LATEST" + arn_tail = f":{qualifier}" if is_unqualified else qualifier + qualified_lambda_arn = lambda_arn + arn_tail + return qualified_lambda_arn + + +def _post_process_lambda_debug_config( + post_processing_state: _LambdaDebugModeConfigPostProcessingState, + lambda_debug_config: LambdaDebugConfig, +) -> None: + debug_port: Optional[int] = lambda_debug_config.debug_port + if debug_port is None: + return + if debug_port in post_processing_state.ports_used: + raise PortAlreadyInUse(port_number=debug_port) + post_processing_state.ports_used.add(debug_port) + + +def load_lambda_debug_mode_config(yaml_string: str) -> Optional[LambdaDebugModeConfig]: + # Attempt to parse the yaml string. + try: + yaml_data = yaml.load(yaml_string, _SafeLoaderWithDuplicateCheck) + except yaml.YAMLError as yaml_error: + LOG.error( + "Could not parse yaml lambda debug mode configuration file due to: %s", + yaml_error, + ) + yaml_data = None + if not yaml_data: + return None + + # Attempt to build the LambdaDebugModeConfig object from the yaml object. + try: + config = LambdaDebugModeConfig(**yaml_data) + except ValidationError as validation_error: + validation_errors = validation_error.errors() or list() + error_messages = [ + f"When parsing '{err.get('loc', '')}': {err.get('msg', str(err))}" + for err in validation_errors + ] + LOG.error( + "Unable to parse lambda debug mode configuration file due to errors: %s", + error_messages, + ) + return None + + # Attempt to post_process the configuration. + try: + post_process_lambda_debug_mode_config(config) + except LambdaDebugModeConfigException as lambda_debug_mode_error: + LOG.error( + "Invalid lambda debug mode configuration due to: %s", + lambda_debug_mode_error, + ) + config = None + + return config diff --git a/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_session.py b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_session.py new file mode 100644 index 0000000000000..f1155d531fa1e --- /dev/null +++ b/localstack-core/localstack/utils/lambda_debug_mode/lambda_debug_mode_session.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import logging +import os +import time +from threading import Event, Thread +from typing import Optional + +from localstack.aws.api.lambda_ import Arn +from localstack.config import LAMBDA_DEBUG_MODE, LAMBDA_DEBUG_MODE_CONFIG_PATH +from localstack.utils.lambda_debug_mode.lambda_debug_mode_config import ( + LambdaDebugConfig, + LambdaDebugModeConfig, + load_lambda_debug_mode_config, +) +from localstack.utils.objects import singleton_factory + +LOG = logging.getLogger(__name__) + + +class LambdaDebugModeSession: + _is_lambda_debug_mode: bool + + _configuration_file_path: Optional[str] + _watch_thread: Optional[Thread] + _initialised_event: Optional[Event] + _stop_event: Optional[Event] + _config: Optional[LambdaDebugModeConfig] + + def __init__(self): + self._is_lambda_debug_mode = bool(LAMBDA_DEBUG_MODE) + + # Disabled Lambda Debug Mode state initialisation. + self._configuration_file_path = None + self._watch_thread = None + self._initialised_event = None + self._stop_event = None + self._config = None + + # Lambda Debug Mode is not enabled: leave as disabled state and return. + if not self._is_lambda_debug_mode: + return + + # Lambda Debug Mode is enabled. + # Instantiate the configuration requirements if a configuration file is given. + self._configuration_file_path = LAMBDA_DEBUG_MODE_CONFIG_PATH + if not self._configuration_file_path: + return + + # A configuration file path is given: initialised the resources to load and watch the file. + + # Signal and block on first loading to ensure this is enforced from the very first + # invocation, as this module is not loaded at startup. The LambdaDebugModeConfigWatch + # thread will then take care of updating the configuration periodically and asynchronously. + # This may somewhat slow down the first upstream thread loading this module, but not + # future calls. On the other hand, avoiding this mechanism means that first Lambda calls + # occur with no Debug configuration. + self._initialised_event = Event() + + # Signals when a shutdown signal from the application is registered. + self._stop_event = Event() + + self._watch_thread = Thread( + target=self._watch_logic, args=(), daemon=True, name="LambdaDebugModeConfigWatch" + ) + self._watch_thread.start() + + @staticmethod + @singleton_factory + def get() -> LambdaDebugModeSession: + """Returns a singleton instance of the Lambda Debug Mode session.""" + return LambdaDebugModeSession() + + def ensure_running(self) -> None: + # Nothing to start. + if self._watch_thread is None or self._watch_thread.is_alive(): + return + try: + self._watch_thread.start() + except Exception as exception: + exception_str = str(exception) + # The thread was already restarted by another process. + if ( + isinstance(exception, RuntimeError) + and exception_str + and "threads can only be started once" in exception_str + ): + return + LOG.error( + "Lambda Debug Mode could not restart the " + "hot reloading of the configuration file, '%s'", + exception_str, + ) + + def signal_stop(self) -> None: + stop_event = self._stop_event + if stop_event is not None: + stop_event.set() + + def _load_lambda_debug_mode_config(self): + yaml_configuration_string = None + try: + with open(self._configuration_file_path, "r") as df: + yaml_configuration_string = df.read() + except FileNotFoundError: + LOG.error( + "Error: The file lambda debug config file '%s' was not found.", + self._configuration_file_path, + ) + except IsADirectoryError: + LOG.error( + "Error: Expected a lambda debug config file but found a directory at '%s'.", + self._configuration_file_path, + ) + except PermissionError: + LOG.error( + "Error: Permission denied while trying to read the lambda debug config file '%s'.", + self._configuration_file_path, + ) + except Exception as ex: + LOG.error( + "Error: An unexpected error occurred while reading lambda debug config '%s': '%s'", + self._configuration_file_path, + ex, + ) + if not yaml_configuration_string: + return None + + self._config = load_lambda_debug_mode_config(yaml_configuration_string) + if self._config is not None: + LOG.info("Lambda Debug Mode is now enforcing the latest configuration.") + else: + LOG.warning( + "Lambda Debug Mode could not load the latest configuration due to an error, " + "check logs for more details." + ) + + def _config_file_epoch_last_modified_or_now(self) -> int: + try: + modified_time = os.path.getmtime(self._configuration_file_path) + return int(modified_time) + except Exception as e: + LOG.warning("Lambda Debug Mode could not access the configuration file: %s", e) + epoch_now = int(time.time()) + return epoch_now + + def _watch_logic(self) -> None: + # TODO: consider relying on system calls (watchdog lib for cross-platform support) + # instead of monitoring last modified dates. + # Run the first load and signal as initialised. + epoch_last_loaded: int = self._config_file_epoch_last_modified_or_now() + self._load_lambda_debug_mode_config() + self._initialised_event.set() + + # Monitor for file changes whilst the application is running. + while not self._stop_event.is_set(): + time.sleep(1) + epoch_last_modified = self._config_file_epoch_last_modified_or_now() + if epoch_last_modified > epoch_last_loaded: + epoch_last_loaded = epoch_last_modified + self._load_lambda_debug_mode_config() + + def _get_initialised_config(self) -> Optional[LambdaDebugModeConfig]: + # Check the session is not initialising, and if so then wait for initialisation to finish. + # Note: the initialisation event is otherwise left set since after first initialisation has terminated. + if self._initialised_event is not None: + self._initialised_event.wait() + return self._config + + def is_lambda_debug_mode(self) -> bool: + return self._is_lambda_debug_mode + + def debug_config_for(self, lambda_arn: Arn) -> Optional[LambdaDebugConfig]: + config = self._get_initialised_config() + return config.functions.get(lambda_arn) if config else None diff --git a/localstack-core/localstack/utils/net.py b/localstack-core/localstack/utils/net.py new file mode 100644 index 0000000000000..c01c34f900f82 --- /dev/null +++ b/localstack-core/localstack/utils/net.py @@ -0,0 +1,508 @@ +import logging +import random +import re +import socket +import threading +from contextlib import closing +from typing import Any, List, MutableMapping, NamedTuple, Optional, Union +from urllib.parse import urlparse + +import dns.resolver +from dnslib import DNSRecord + +from localstack import config, constants + +from .collections import CustomExpiryTTLCache +from .numbers import is_number +from .objects import singleton_factory +from .sync import retry + +LOG = logging.getLogger(__name__) + +# regular expression for IPv4 addresses +IP_REGEX = ( + r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" +) + +# many linux kernels use 32768-60999, RFC 6335 is 49152-65535, so we use a mix here +DYNAMIC_PORT_RANGE_START = 32768 +DYNAMIC_PORT_RANGE_END = 65536 + +DEFAULT_PORT_RESERVED_SECONDS = 6 +"""Default nuber of seconds a port is reserved in a PortRange.""" + + +class Port(NamedTuple): + """Represents a network port, with port number and protocol (TCP/UDP)""" + + port: int + """the port number""" + protocol: str + """network protocol name (usually 'tcp' or 'udp')""" + + @classmethod + def wrap(cls, port: "IntOrPort") -> "Port": + """Return the given port as a Port object, using 'tcp' as the default protocol.""" + if isinstance(port, Port): + return port + return Port(port=port, protocol="tcp") + + +# simple helper type to encapsulate int/Port argument types +IntOrPort = Union[int, Port] + + +def is_port_open( + port_or_url: Union[int, str], + http_path: str = None, + expect_success: bool = True, + protocols: Optional[Union[str, List[str]]] = None, + quiet: bool = True, +): + from localstack.utils.http import safe_requests + + protocols = protocols or ["tcp"] + port = port_or_url + if is_number(port): + port = int(port) + host = "localhost" + protocol = "http" + protocols = protocols if isinstance(protocols, list) else [protocols] + if isinstance(port, str): + url = urlparse(port_or_url) + port = url.port + host = url.hostname + protocol = url.scheme + nw_protocols = [] + nw_protocols += [socket.SOCK_STREAM] if "tcp" in protocols else [] + nw_protocols += [socket.SOCK_DGRAM] if "udp" in protocols else [] + for nw_protocol in nw_protocols: + with closing( + socket.socket(socket.AF_INET if ":" not in host else socket.AF_INET6, nw_protocol) + ) as sock: + sock.settimeout(1) + if nw_protocol == socket.SOCK_DGRAM: + try: + if port == 53: + dnshost = "127.0.0.1" if host == "localhost" else host + resolver = dns.resolver.Resolver() + resolver.nameservers = [dnshost] + resolver.timeout = 1 + resolver.lifetime = 1 + answers = resolver.query("google.com", "A") + assert len(answers) > 0 + else: + sock.sendto(bytes(), (host, port)) + sock.recvfrom(1024) + except Exception: + if not quiet: + LOG.exception("Error connecting to UDP port %s:%s", host, port) + return False + elif nw_protocol == socket.SOCK_STREAM: + result = sock.connect_ex((host, port)) + if result != 0: + if not quiet: + LOG.warning( + "Error connecting to TCP port %s:%s (result=%s)", host, port, result + ) + return False + if "tcp" not in protocols or not http_path: + return True + host = f"[{host}]" if ":" in host else host + url = f"{protocol}://{host}:{port}{http_path}" + try: + response = safe_requests.get(url, verify=False) + return not expect_success or response.status_code < 400 + except Exception: + return False + + +def wait_for_port_open( + port: int, http_path: str = None, expect_success=True, retries=10, sleep_time=0.5 +): + """Ping the given TCP network port until it becomes available (for a given number of retries). + If 'http_path' is set, make a GET request to this path and assert a non-error response.""" + return wait_for_port_status( + port, + http_path=http_path, + expect_success=expect_success, + retries=retries, + sleep_time=sleep_time, + ) + + +def wait_for_port_closed( + port: int, http_path: str = None, expect_success=True, retries=10, sleep_time=0.5 +): + return wait_for_port_status( + port, + http_path=http_path, + expect_success=expect_success, + retries=retries, + sleep_time=sleep_time, + expect_closed=True, + ) + + +def wait_for_port_status( + port: int, + http_path: str = None, + expect_success=True, + retries=10, + sleep_time=0.5, + expect_closed=False, +): + """Ping the given TCP network port until it becomes (un)available (for a given number of retries).""" + + def check(): + status = is_port_open(port, http_path=http_path, expect_success=expect_success) + if bool(status) != (not expect_closed): + raise Exception( + "Port %s (path: %s) was not %s" + % (port, http_path, "closed" if expect_closed else "open") + ) + + return retry(check, sleep=sleep_time, retries=retries) + + +def port_can_be_bound(port: IntOrPort, address: str = "") -> bool: + """ + Return whether a local port (TCP or UDP) can be bound to. Note that this is a stricter check + than is_port_open(...) above, as is_port_open() may return False if the port is + not accessible (i.e., does not respond), yet cannot be bound to. + """ + try: + port = Port.wrap(port) + if port.protocol == "tcp": + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + elif port.protocol == "udp": + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + else: + LOG.debug("Unsupported network protocol '%s' for port check", port.protocol) + return False + sock.bind((address, port.port)) + return True + except OSError: + # either the port is used or we don't have permission to bind it + return False + except Exception: + LOG.error("cannot bind port %s", port, exc_info=LOG.isEnabledFor(logging.DEBUG)) + return False + + +def get_free_udp_port(blocklist: List[int] = None) -> int: + blocklist = blocklist or [] + for i in range(10): + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp.bind(("", 0)) + addr, port = udp.getsockname() + udp.close() + if port not in blocklist: + return port + raise Exception(f"Unable to determine free UDP port with blocklist {blocklist}") + + +def get_free_tcp_port(blocklist: List[int] = None) -> int: + """ + Tries to bind a socket to port 0 and returns the port that was assigned by the system. If the port is + in the given ``blocklist``, or the port is marked as reserved in ``dynamic_port_range``, the procedure + is repeated for up to 50 times. + + :param blocklist: an optional list of ports that are not allowed as random ports + :return: a free TCP port + """ + blocklist = blocklist or [] + for i in range(50): + tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp.bind(("", 0)) + addr, port = tcp.getsockname() + tcp.close() + if port not in blocklist and not dynamic_port_range.is_port_reserved(port): + try: + dynamic_port_range.mark_reserved(port) + except ValueError: + # depending on the ephemeral port range of the system, the allocated port may be outside what + # we defined as dynamic port range + pass + return port + raise Exception(f"Unable to determine free TCP port with blocklist {blocklist}") + + +def get_free_tcp_port_range(num_ports: int, max_attempts: int = 50) -> "PortRange": + """ + Attempts to get a contiguous range of free ports from the dynamic port range. For instance, + ``get_free_tcp_port_range(4)`` may return the following result: ``PortRange(44000:44004)``. + + :param num_ports: the number of ports in the range + :param max_attempts: the number of times to retry if a contiguous range was not found + :return: a port range of free TCP ports + :raises PortNotAvailableException: if max_attempts was reached to re-try + """ + if num_ports < 2: + raise ValueError(f"invalid number of ports {num_ports}") + + def _is_port_range_free(_range: PortRange): + for _port in _range: + if dynamic_port_range.is_port_reserved(_port) or not port_can_be_bound(_port): + return False + return True + + for _ in range(max_attempts): + # try to find a suitable starting point (leave enough space at the end) + port_range_start = random.randint( + dynamic_port_range.start, dynamic_port_range.end - num_ports - 1 + ) + port_range = PortRange(port_range_start, port_range_start + num_ports - 1) + + # check that each port in the range is available (has not been reserved and can be bound) + # we don't use dynamic_port_range.reserve_port because in case the port range check fails at some port + # all ports up until then would be reserved + if not _is_port_range_free(port_range): + continue + + # port range found! mark them as reserved in the dynamic port range and return + for port in port_range: + dynamic_port_range.mark_reserved(port) + return port_range + + raise PortNotAvailableException("reached max_attempts when trying to find port range") + + +def resolve_hostname(hostname: str) -> Optional[str]: + """Resolve the given hostname and return its IP address, or None if it cannot be resolved.""" + try: + return socket.gethostbyname(hostname) + except socket.error: + return None + + +def is_ip_address(addr: str) -> bool: + try: + socket.inet_aton(addr) + return True + except socket.error: + return False + + +def is_ipv4_address(address: str) -> bool: + """ + Checks if passed string looks like an IPv4 address + :param address: Possible IPv4 address + :return: True if string looks like IPv4 address, False otherwise + """ + return bool(re.match(IP_REGEX, address)) + + +class PortNotAvailableException(Exception): + """Exception which indicates that the PortRange could not reserve a port.""" + + pass + + +class PortRange: + """Manages a range of ports that can be reserved and requested.""" + + def __init__(self, start: int, end: int): + """ + Create a new port range. The port range is inclusive, meaning ``PortRange(5000,5005)`` is 6 ports + including both 5000 and 5005. This is different from ``range`` which is not inclusive, i.e.:: + + PortRange(5000, 5005).as_range() == range(5000, 5005 + 1) + + :param start: the start port (inclusive) + :param end: the end of the range (inclusive). + """ + self.start = start + self.end = end + + # cache for locally available ports (ports are reserved for a short period of a few seconds) + self._ports_cache: MutableMapping[Port, Any] = CustomExpiryTTLCache( + maxsize=len(self), + ttl=DEFAULT_PORT_RESERVED_SECONDS, + ) + self._ports_lock = threading.RLock() + + def subrange(self, start: int = None, end: int = None) -> "PortRange": + """ + Creates a new PortRange object from this range which is a sub-range of this port range. The new + object will use the same port cache and locks of this port range, so you can constrain port + reservations of an existing port range but have reservations synced between them. + + :param start: the start of the subrange + :param end: the end of the subrange + :raises ValueError: if start or end are outside the current port range + :return: a new PortRange object synced to this one + """ + start = start if start is not None else self.start + end = end if end is not None else self.end + + if start < self.start: + raise ValueError(f"start not in range ({start} < {self.start})") + if end > self.end: + raise ValueError(f"end not in range ({end} < {self.end})") + + port_range = PortRange(start, end) + port_range._ports_cache = self._ports_cache + port_range._ports_lock = self._ports_lock + return port_range + + def as_range(self) -> range: + """ + Returns a ``range(start, end+1)`` object representing this port range. + + :return: a range + """ + return range(self.start, self.end + 1) + + def reserve_port(self, port: Optional[IntOrPort] = None, duration: Optional[int] = None) -> int: + """ + Reserves the given port (if it is still free). If the given port is None, it reserves a free port from the + configured port range for external services. If a port is given, it has to be within the configured + range of external services (i.e., in the range [self.start, self.end)). + + :param port: explicit port to check or None if a random port from the configured range should be selected + :param duration: the time in seconds the port is reserved for (defaults to a few seconds) + :return: reserved, free port number (int) + :raises PortNotAvailableException: if the given port is outside the configured range, it is already bound or + reserved, or if the given port is none and there is no free port in the configured service range. + """ + ports_range = self.as_range() + port = Port.wrap(port) if port is not None else port + if port is not None and port.port not in ports_range: + raise PortNotAvailableException( + f"The requested port ({port}) is not in the port range ({ports_range})." + ) + with self._ports_lock: + if port is not None: + return self._try_reserve_port(port, duration=duration) + else: + for port_in_range in ports_range: + try: + return self._try_reserve_port(port_in_range, duration=duration) + except PortNotAvailableException: + # We ignore the fact that this single port is reserved, we just check the next one + pass + raise PortNotAvailableException( + f"No free network ports available in {self!r} (currently reserved: %s)", + list(self._ports_cache.keys()), + ) + + def is_port_reserved(self, port: IntOrPort) -> bool: + """ + Checks whether the port has been reserved in this PortRange. Does not check whether the port can be + bound or not, and does not check whether the port is in range. + + :param port: the port to check + :return: true if the port is reserved within the range + """ + port = Port.wrap(port) + return self._ports_cache.get(port) is not None + + def mark_reserved(self, port: IntOrPort, duration: int = None): + """ + Marks the given port as reserved for the given duration, regardless of whether it is free for not. + + :param port: the port to reserve + :param duration: the duration + :raises ValueError: if the port is not in this port range + """ + port = Port.wrap(port) + + if port.port not in self.as_range(): + raise ValueError(f"port {port} not in {self!r}") + + with self._ports_lock: + # reserve the port for a short period of time + self._ports_cache[port] = "__reserved__" + if duration: + self._ports_cache.set_expiry(port, duration) + + def _try_reserve_port(self, port: IntOrPort, duration: int) -> int: + """Checks if the given port is currently not reserved and can be bound.""" + port = Port.wrap(port) + + if self.is_port_reserved(port): + raise PortNotAvailableException(f"The given port ({port}) is already reserved.") + if not self._port_can_be_bound(port): + raise PortNotAvailableException(f"The given port ({port}) is already in use.") + + self.mark_reserved(port, duration) + return port.port + + def _port_can_be_bound(self, port: IntOrPort) -> bool: + """ + Internal check whether the port can be bound. Will open a socket connection and see if the port is + available. Can be overwritten by subclasses to provide a custom implementation. + + :param port: the port to check + :return: true if the port is free on the system + """ + return port_can_be_bound(port) + + def __len__(self): + return self.end - self.start + 1 + + def __iter__(self): + return self.as_range().__iter__() + + def __repr__(self): + return f"PortRange({self.start}:{self.end})" + + +@singleton_factory +def get_docker_host_from_container() -> str: + """ + Get the hostname/IP to connect to the host from within a Docker container (e.g., Lambda function). + The logic is roughly as follows: + 1. return `host.docker.internal` if we're running in host mode, in a non-Linux OS + 2. return the IP address that `host.docker.internal` (or alternatively `host.containers.internal`) + resolves to, if we're inside Docker + 3. return the Docker bridge IP (config.DOCKER_BRIDGE_IP) as a fallback, if option (2) fails + """ + result = config.DOCKER_BRIDGE_IP + try: + if not config.is_in_docker and not config.is_in_linux: + # If we're running outside Docker (in host mode), and would like the Lambda containers to be able + # to access services running on the local machine, return `host.docker.internal` accordingly + result = "host.docker.internal" + if config.is_in_docker: + try: + result = socket.gethostbyname("host.docker.internal") + except socket.error: + result = socket.gethostbyname("host.containers.internal") + except socket.error: + # TODO if neither host resolves, we might be in linux. We could just use the default gateway then + pass + return result + + +def get_addressable_container_host(default_local_hostname: str = None) -> str: + """ + Return the target host to address endpoints exposed by Docker containers, depending on + the current execution context. + + If we're currently executing within Docker, then return get_docker_host_from_container(); otherwise, return + the value of `LOCALHOST_HOSTNAME`, assuming that container endpoints are exposed and accessible under localhost. + + :param default_local_hostname: local hostname to return, if running outside Docker (defaults to LOCALHOST_HOSTNAME) + """ + default_local_hostname = default_local_hostname or constants.LOCALHOST_HOSTNAME + return get_docker_host_from_container() if config.is_in_docker else default_local_hostname + + +def send_dns_query( + name: str, + port: int = 53, + ip_address: str = "127.0.0.1", + qtype: str = "A", + timeout: float = 1.0, + tcp: bool = False, +) -> DNSRecord: + LOG.debug("querying %s:%d for name %s", ip_address, port, name) + request = DNSRecord.question(qname=name, qtype=qtype) + reply_bytes = request.send(dest=ip_address, port=port, tcp=tcp, timeout=timeout, ipv6=False) + return DNSRecord.parse(reply_bytes) + + +dynamic_port_range = PortRange(DYNAMIC_PORT_RANGE_START, DYNAMIC_PORT_RANGE_END) +"""The dynamic port range.""" diff --git a/localstack-core/localstack/utils/no_exit_argument_parser.py b/localstack-core/localstack/utils/no_exit_argument_parser.py new file mode 100644 index 0000000000000..6455a59b12800 --- /dev/null +++ b/localstack-core/localstack/utils/no_exit_argument_parser.py @@ -0,0 +1,19 @@ +import argparse +import logging +from typing import NoReturn, Optional + +LOG = logging.getLogger(__name__) + + +class NoExitArgumentParser(argparse.ArgumentParser): + """Implements the `exit_on_error=False` behavior introduced in Python 3.9 to support older Python versions + and prevents further SystemExit for other error categories. + * Limitations of error categories: https://stackoverflow.com/a/67891066/6875981 + * ArgumentParser subclassing example: https://stackoverflow.com/a/59072378/6875981 + """ + + def exit(self, status: int = ..., message: Optional[str] = ...) -> NoReturn: + LOG.warning("Error in argument parser but preventing exit: %s", message) + + def error(self, message: str) -> NoReturn: + raise NotImplementedError(f"Unsupported flag by this Docker client: {message}") diff --git a/localstack-core/localstack/utils/numbers.py b/localstack-core/localstack/utils/numbers.py new file mode 100644 index 0000000000000..19f5dbab42014 --- /dev/null +++ b/localstack-core/localstack/utils/numbers.py @@ -0,0 +1,42 @@ +from typing import Any, Union + + +def format_number(number: float, decimals: int = 2): + # Note: interestingly, f"{number:.3g}" seems to yield incorrect results in some cases. + # The logic below seems to be the most stable/reliable. + result = f"{number:.{decimals}f}" + if "." in result: + result = result.rstrip("0").rstrip(".") + return result + + +def is_number(s: Any) -> bool: + try: + float(s) # for int, long and float + return True + except (TypeError, ValueError): + return False + + +def to_number(s: Any) -> Union[int, float]: + """Cast the string representation of the given object to a number (int or float), or raise ValueError.""" + try: + return int(str(s)) + except ValueError: + return float(str(s)) + + +def format_bytes(count: float, default: str = "n/a"): + """Format a bytes number as a human-readable unit, e.g., 1.3GB or 21.53MB""" + if not is_number(count): + return default + cnt = float(count) + if cnt < 0: + return default + units = ("B", "KB", "MB", "GB", "TB") + for unit in units: + if cnt < 1000 or unit == units[-1]: + # FIXME: will return '1e+03TB' for 1000TB + return f"{format_number(cnt, decimals=3)}{unit}" + cnt = cnt / 1000.0 + return count diff --git a/localstack-core/localstack/utils/objects.py b/localstack-core/localstack/utils/objects.py new file mode 100644 index 0000000000000..9e5f5ba283e15 --- /dev/null +++ b/localstack-core/localstack/utils/objects.py @@ -0,0 +1,234 @@ +import functools +import re +import threading +from typing import Any, Callable, Dict, Generic, List, Optional, Set, Type, TypeVar, Union + +from .collections import ensure_list +from .strings import first_char_to_lower, first_char_to_upper + +ComplexType = Union[List, Dict, object] + +_T = TypeVar("_T") + + +class Value(Generic[_T]): + """ + Simple value container. + """ + + value: Optional[_T] + + def __init__(self, value: _T = None) -> None: + self.value = value + + def clear(self): + self.value = None + + def set(self, value: _T): + self.value = value + + def is_set(self) -> bool: + return self.value is not None + + def get(self) -> Optional[_T]: + return self.value + + def __bool__(self): + return True if self.value else False + + +class ArbitraryAccessObj: + """Dummy object that can be arbitrarily accessed - any attributes, as a callable, item assignment, ...""" + + def __init__(self, name=None): + self.name = name + + def __getattr__(self, name, *args, **kwargs): + return ArbitraryAccessObj(name) + + def __call__(self, *args, **kwargs): + if self.name in ["items", "keys", "values"] and not args and not kwargs: + return [] + return ArbitraryAccessObj() + + def __getitem__(self, *args, **kwargs): + return ArbitraryAccessObj() + + def __setitem__(self, *args, **kwargs): + return ArbitraryAccessObj() + + +class Mock: + """Dummy class that can be used for mocking custom attributes.""" + + pass + + +class ObjectIdHashComparator: + """Simple wrapper class that allows us to create a hashset using the object id(..) as the entries' hash value""" + + def __init__(self, obj): + self.obj = obj + self._hash = id(obj) + + def __hash__(self): + return self._hash + + def __eq__(self, other): + # assumption here is that we're comparing only against ObjectIdHash instances! + return self.obj == other.obj + + +class SubtypesInstanceManager: + """Simple instance manager base class that scans the subclasses of a base type for concrete named + implementations, and lazily creates and returns (singleton) instances on demand.""" + + _instances: Dict[str, "SubtypesInstanceManager"] + + @classmethod + def get(cls, subtype_name: str, raise_if_missing: bool = True): + instances = cls.instances() + base_type = cls.get_base_type() + instance = instances.get(subtype_name) + if instance is None: + # lazily load subtype instance (required if new plugins are dynamically loaded at runtime) + for clazz in get_all_subclasses(base_type): + impl_name = clazz.impl_name() + if impl_name not in instances and subtype_name == impl_name: + instances[impl_name] = clazz() + instance = instances.get(subtype_name) + if not instance and raise_if_missing: + raise NotImplementedError( + f"Unable to find implementation named '{subtype_name}' for base type {base_type}" + ) + return instance + + @classmethod + def instances(cls) -> Dict[str, "SubtypesInstanceManager"]: + base_type = cls.get_base_type() + if not hasattr(base_type, "_instances"): + base_type._instances = {} + return base_type._instances + + @staticmethod + def impl_name() -> str: + """Name of this concrete subtype - to be implemented by subclasses.""" + raise NotImplementedError + + @classmethod + def get_base_type(cls) -> Type: + """Get the base class for which instances are being managed - can be customized by subtypes.""" + return cls + + +# this requires that all subclasses have been imported before(!) +def get_all_subclasses(clazz: Type) -> Set[Type]: + """Recursively get all subclasses of the given class.""" + result = set() + subs = clazz.__subclasses__() + for sub in subs: + result.add(sub) + result.update(get_all_subclasses(sub)) + return result + + +def fully_qualified_class_name(klass: Type) -> str: + return f"{klass.__module__}.{klass.__name__}" + + +def not_none_or(value: Optional[Any], alternative: Any) -> Any: + """Return 'value' if it is not None, or 'alternative' otherwise.""" + return value if value is not None else alternative + + +def recurse_object(obj: ComplexType, func: Callable, path: str = "") -> ComplexType: + """Recursively apply `func` to `obj` (might be a list, dict, or other object).""" + obj = func(obj, path=path) + if isinstance(obj, list): + for i in range(len(obj)): + tmp_path = f"{path or '.'}[{i}]" + obj[i] = recurse_object(obj[i], func, tmp_path) + elif isinstance(obj, dict): + for k, v in obj.items(): + tmp_path = f"{f'{path}.' if path else ''}{k}" + obj[k] = recurse_object(v, func, tmp_path) + return obj + + +def keys_to( + obj: ComplexType, op: Callable[[str], str], skip_children_of: List[str] = None +) -> ComplexType: + """Recursively changes all dict keys to apply op. Skip children + of any elements whose names are contained in skip_children_of (e.g., ['Tags'])""" + skip_children_of = ensure_list(skip_children_of or []) + + def fix_keys(o, path="", **kwargs): + if any(re.match(r"(^|.*\.)%s($|[.\[].*)" % k, path) for k in skip_children_of): + return o + if isinstance(o, dict): + for k, v in dict(o).items(): + o.pop(k) + o[op(k)] = v + return o + + result = recurse_object(obj, fix_keys) + return result + + +def keys_to_lower(obj: ComplexType, skip_children_of: List[str] = None) -> ComplexType: + return keys_to(obj, first_char_to_lower, skip_children_of) + + +def keys_to_upper(obj: ComplexType, skip_children_of: List[str] = None) -> ComplexType: + return keys_to(obj, first_char_to_upper, skip_children_of) + + +def singleton_factory(factory: Callable[[], _T]) -> Callable[[], _T]: + """ + Decorator for methods that create a particular value once and then return the same value in a thread safe way. + + :param factory: the method to decorate + :return: a threadsafe singleton factory + """ + lock = threading.RLock() + instance: Value[_T] = Value() + + @functools.wraps(factory) + def _singleton_factory() -> _T: + if instance.is_set(): + return instance.get() + + with lock: + if not instance: + instance.set(factory()) + + return instance.get() + + _singleton_factory.clear = instance.clear + + return _singleton_factory + + +def get_value_from_path(data, path): + keys = path.split(".") + try: + result = functools.reduce(dict.__getitem__, keys, data) + return result + except KeyError: + # Handle the case where the path is not valid for the given dictionary + return None + + +def set_value_at_path(data, path, new_value): + keys = path.split(".") + last_key = keys[-1] + + # Traverse the dictionary to the second-to-last level + nested_dict = functools.reduce(dict.__getitem__, keys[:-1], data) + + try: + # Set the new value + nested_dict[last_key] = new_value + except KeyError: + # Handle the case where the path is not valid for the given dictionary + raise ValueError(f"Invalid path: {path}") diff --git a/localstack-core/localstack/utils/openapi.py b/localstack-core/localstack/utils/openapi.py new file mode 100644 index 0000000000000..a8716ca23d0e2 --- /dev/null +++ b/localstack-core/localstack/utils/openapi.py @@ -0,0 +1,83 @@ +import copy +import logging +import textwrap +from typing import Any + +import yaml +from plux import PluginManager + +from localstack import version + +LOG = logging.getLogger(__name__) + +spec_top_info = textwrap.dedent(""" +openapi: 3.1.0 +info: + contact: + email: info@localstack.cloud + name: LocalStack Support + url: https://www.localstack.cloud/contact + summary: The LocalStack REST API exposes functionality related to diagnostics, health + checks, plugins, initialisation hooks, service introspection, and more. + termsOfService: https://www.localstack.cloud/legal/tos + title: LocalStack REST API + version: 1.0 +externalDocs: + description: LocalStack Documentation + url: https://docs.localstack.cloud +servers: + - url: http://{host}:{port} + variables: + port: + default: '4566' + host: + default: 'localhost.localstack.cloud' +""") + + +def _merge_openapi_specs(specs: list[dict[str, Any]]) -> dict[str, Any]: + """ + Merge a list of OpenAPI specs into a single specification. + :param specs: a list of OpenAPI specs loaded in a dictionary + :return: the dictionary of a merged spec. + """ + merged_spec = {} + for idx, spec in enumerate(specs): + if idx == 0: + merged_spec = copy.deepcopy(spec) + else: + # Merge paths + if "paths" in spec: + merged_spec.setdefault("paths", {}).update(spec.get("paths", {})) + + # Merge components + if "components" in spec: + if "components" not in merged_spec: + merged_spec["components"] = {} + for component_type, component_value in spec["components"].items(): + if component_type not in merged_spec["components"]: + merged_spec["components"][component_type] = component_value + else: + merged_spec["components"][component_type].update(component_value) + + # Update the initial part of the spec, i.e., info and correct LocalStack version + top_content = yaml.safe_load(spec_top_info) + # Set the correct version + top_content["info"]["version"] = version.version + merged_spec.update(top_content) + return merged_spec + + +def get_localstack_openapi_spec() -> dict[str, Any]: + """ + Collects all the declared OpenAPI specs in LocalStack. + Specs are declared by implementing a OASPlugin. + :return: the entire LocalStack OpenAPI spec in a Python dictionary. + """ + specs = PluginManager("localstack.openapi.spec").load_all() + try: + return _merge_openapi_specs([spec.spec for spec in specs]) + except Exception as e: + LOG.debug("An error occurred while trying to merge the collected OpenAPI specs %s", e) + # In case of an error while merging the spec, we return the first collected one. + return specs[0].spec diff --git a/localstack-core/localstack/utils/patch.py b/localstack-core/localstack/utils/patch.py new file mode 100644 index 0000000000000..2fa54e3cf2a39 --- /dev/null +++ b/localstack-core/localstack/utils/patch.py @@ -0,0 +1,253 @@ +import functools +import inspect +import types +from typing import Any, Callable, List, Type + + +def get_defining_object(method): + """Returns either the class or the module that defines the given function/method.""" + # adapted from https://stackoverflow.com/a/25959545/804840 + if inspect.ismethod(method): + return method.__self__ + + if inspect.isfunction(method): + class_name = method.__qualname__.split(".", 1)[0].rsplit(".", 1)[0] + try: + # method is not bound but referenced by a class, like MyClass.mymethod + cls = getattr(inspect.getmodule(method), class_name) + except AttributeError: + cls = method.__globals__.get(class_name) + + if isinstance(cls, type): + return cls + + # method is a module-level function + return inspect.getmodule(method) + + +def to_metadata_string(obj: Any) -> str: + """ + Creates a string that helps humans understand where the given object comes from. Examples:: + + to_metadata_string(func_thread.run) == "function(localstack.utils.threads:FuncThread.run)" + + :param obj: a class, module, method, function or object + :return: a string representing the objects origin + """ + if inspect.isclass(obj): + return f"class({obj.__module__}:{obj.__name__})" + if inspect.ismodule(obj): + return f"module({obj.__name__})" + if inspect.ismethod(obj): + return f"method({obj.__module__}:{obj.__qualname__})" + if inspect.isfunction(obj): + # TODO: distinguish bound method + return f"function({obj.__module__}:{obj.__qualname__})" + if isinstance(obj, object): + return f"object({obj.__module__}:{obj.__class__.__name__})" + return str(obj) + + +def create_patch_proxy(target: Callable, new: Callable): + """ + Creates a proxy that calls `new` but passes as first argument the target. + """ + + @functools.wraps(target) + def proxy(*args, **kwargs): + if _is_bound_method: + # bound object "self" is passed as first argument if this is a bound method + args = args[1:] + return new(target, *args, **kwargs) + + # keep track of the real proxy subject (i.e., the new function that is used as patch) + proxy.__subject__ = new + + _is_bound_method = inspect.ismethod(target) + if _is_bound_method: + proxy = types.MethodType(proxy, target.__self__) + + return proxy + + +class Patch: + applied_patches: List["Patch"] = [] + """Bookkeeping for patches that are applied. You can use this to debug patches. For instance, + you could write something like:: + + for patch in Patch.applied_patches: + print(patch) + + Which will output in a human readable format information about the currently active patches. + """ + + obj: Any + name: str + new: Any + + def __init__(self, obj: Any, name: str, new: Any) -> None: + super().__init__() + self.obj = obj + self.name = name + try: + self.old = getattr(self.obj, name) + except AttributeError: + self.old = None + self.new = new + self.is_applied = False + + def apply(self): + if self.old and self.name == "__getattr__": + raise Exception("You can't patch class types implementing __getattr__") + if not self.old and self.name != "__getattr__": + raise AttributeError(f"`{self.obj.__name__}` object has no attribute `{self.name}`") + setattr(self.obj, self.name, self.new) + self.is_applied = True + Patch.applied_patches.append(self) + + def undo(self): + # If we added a method to a class type, we don't have a self.old. We just delete __getattr__ + setattr(self.obj, self.name, self.old) if self.old else delattr(self.obj, self.name) + self.is_applied = False + Patch.applied_patches.remove(self) + + def __enter__(self): + self.apply() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.undo() + return self + + @staticmethod + def extend_class(target: Type, fn: Callable): + def _getattr(obj, name): + if name != fn.__name__: + raise AttributeError(f"`{target.__name__}` object has no attribute `{name}`") + + return functools.partial(fn, obj) + + return Patch(target, "__getattr__", _getattr) + + @staticmethod + def function(target: Callable, fn: Callable, pass_target: bool = True): + obj = get_defining_object(target) + name = target.__name__ + + is_class_instance = not inspect.isclass(obj) and not inspect.ismodule(obj) + if is_class_instance: + # special case: If the defining object is not a class, but a class instance, + # then we need to bind the patch function to the target object. Also, we need + # to ensure that the final patched method has the same name as the original + # method on the defining object (required for restoring objects with patched + # methods from persistence, to avoid AttributeError). + fn.__name__ = name + fn = types.MethodType(fn, obj) + + if pass_target: + new = create_patch_proxy(target, fn) + else: + new = fn + + return Patch(obj, name, new) + + def __str__(self): + try: + # try to unwrap the original underlying function that is used as patch (basically undoes what + # ``create_patch_proxy`` does) + new = self.new.__subject__ + except AttributeError: + new = self.new + + old = self.old + return f"Patch({to_metadata_string(old)} -> {to_metadata_string(new)}, applied={self.is_applied})" + + +class Patches: + patches: List[Patch] + + def __init__(self, patches: List[Patch] = None) -> None: + super().__init__() + + self.patches = [] + if patches: + self.patches.extend(patches) + + def apply(self): + for p in self.patches: + p.apply() + + def undo(self): + for p in self.patches: + p.undo() + + def __enter__(self): + self.apply() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.undo() + + def add(self, patch: Patch): + self.patches.append(patch) + + def function(self, target: Callable, fn: Callable, pass_target: bool = True): + self.add(Patch.function(target, fn, pass_target)) + + +def patch(target, pass_target=True): + """ + Function decorator to create a patch via Patch.function and immediately apply it. + + Example:: + + def echo(string): + return "echo " + string + + @patch(target=echo) + def echo_uppercase(target, string): + return target(string).upper() + + echo("foo") + # will print "ECHO FOO" + + echo_uppercase.patch.undo() + echo("foo") + # will print "echo foo" + + When you are patching classes, with ``pass_target=True``, the unbound function will be passed as the first + argument before ``self``. + + For example:: + + @patch(target=MyEchoer.do_echo, pass_target=True) + def my_patch(fn, self, *args): + return fn(self, *args) + + @patch(target=MyEchoer.do_echo, pass_target=False) + def my_patch(self, *args): + ... + + This decorator can also patch a class type with a new method. + + For example: + @patch(target=MyEchoer) + def new_echo(self, *args): + ... + + :param target: the function or method to patch + :param pass_target: whether to pass the target to the patching function as first parameter + :returns: the same function, but with a patch created + """ + + @functools.wraps(target) + def wrapper(fn): + fn.patch = ( + Patch.extend_class(target, fn) + if inspect.isclass(target) + else Patch.function(target, fn, pass_target=pass_target) + ) + fn.patch.apply() + return fn + + return wrapper diff --git a/localstack-core/localstack/utils/platform.py b/localstack-core/localstack/utils/platform.py new file mode 100644 index 0000000000000..e91e6f33a8483 --- /dev/null +++ b/localstack-core/localstack/utils/platform.py @@ -0,0 +1,77 @@ +import platform +from functools import lru_cache + + +def is_mac_os() -> bool: + return "darwin" == platform.system().lower() + + +def is_linux() -> bool: + return "linux" == platform.system().lower() + + +def is_windows() -> bool: + return "windows" == platform.system().lower() + + +@lru_cache() +def is_debian() -> bool: + from localstack.utils.files import load_file + + return "Debian" in load_file("/etc/issue", "") + + +@lru_cache() +def is_redhat() -> bool: + from localstack.utils.files import load_file + + return "rhel" in load_file("/etc/os-release", "") + + +class Arch(str): + """LocalStack standardised machine architecture names""" + + amd64 = "amd64" + arm64 = "arm64" + + +def standardized_arch(arch: str): + """ + Returns LocalStack standardised machine architecture name. + """ + if arch == "x86_64": + return Arch.amd64 + if arch == "aarch64": + return Arch.arm64 + return arch + + +def get_arch() -> str: + """ + Returns the current machine architecture. + """ + arch = platform.machine() + return standardized_arch(arch) + + +# TODO: implement proper architecture detection (e.g., test whether an architecture-specific binary actually runs) +# because this naive implementation does not cover cross-architecture emulation +def is_arm_compatible() -> bool: + """Returns true if the current machine is compatible with ARM instructions and false otherwise.""" + return get_arch() == Arch.arm64 + + +def get_os() -> str: + if is_mac_os(): + return "osx" + if is_linux(): + return "linux" + if is_windows(): + return "windows" + raise ValueError("Unable to determine local operating system") + + +def in_docker() -> bool: + from localstack import config + + return config.in_docker() diff --git a/localstack-core/localstack/utils/run.py b/localstack-core/localstack/utils/run.py new file mode 100644 index 0000000000000..2c5aa0b07355e --- /dev/null +++ b/localstack-core/localstack/utils/run.py @@ -0,0 +1,505 @@ +import io +import logging +import os +import re +import select +import subprocess +import sys +import threading +import time +from functools import lru_cache +from queue import Queue +from typing import Any, AnyStr, Callable, Dict, List, Optional, Union + +from localstack import config + +# TODO: remove imports from here (need to update any client code that imports these from utils.common) +from localstack.utils.platform import is_linux, is_mac_os, is_windows # noqa + +from .sync import retry +from .threads import FuncThread, start_worker_thread + +LOG = logging.getLogger(__name__) + + +def run( + cmd: Union[str, List[str]], + print_error=True, + asynchronous=False, + stdin=False, + stderr=subprocess.STDOUT, + outfile=None, + env_vars: Optional[Dict[AnyStr, AnyStr]] = None, + inherit_cwd=False, + inherit_env=True, + tty=False, + shell=True, + cwd: str = None, +) -> Union[str, subprocess.Popen]: + LOG.debug("Executing command: %s", cmd) + env_dict = os.environ.copy() if inherit_env else {} + if env_vars: + env_dict.update(env_vars) + env_dict = {k: to_str(str(v)) for k, v in env_dict.items()} + + if isinstance(cmd, list): + # See docs of subprocess.Popen(...): + # "On POSIX with shell=True, the shell defaults to /bin/sh. If args is a string, + # the string specifies the command to execute through the shell. [...] If args is + # a sequence, the first item specifies the command string, and any additional + # items will be treated as additional arguments to the shell itself." + # Hence, we should *disable* shell mode here to be on the safe side, to prevent + # arguments in the cmd list from leaking into arguments to the shell itself. This will + # effectively allow us to call run(..) with both - str and list - as cmd argument, although + # over time we should move from "cmd: Union[str, List[str]]" to "cmd: List[str]" only. + shell = False + + if tty: + asynchronous = True + stdin = True + + try: + if inherit_cwd and not cwd: + cwd = os.getcwd() + if not asynchronous: + if stdin: + return subprocess.check_output( + cmd, shell=shell, stderr=stderr, env=env_dict, stdin=subprocess.PIPE, cwd=cwd + ) + output = subprocess.check_output(cmd, shell=shell, stderr=stderr, env=env_dict, cwd=cwd) + return output.decode(config.DEFAULT_ENCODING) + + stdin_arg = subprocess.PIPE if stdin else None + stdout_arg = open(outfile, "ab") if isinstance(outfile, str) else outfile + stderr_arg = stderr + if tty: + # Note: leave the "pty" import here (not supported in Windows) + import pty + + master_fd, slave_fd = pty.openpty() + stdin_arg = slave_fd + stdout_arg = stderr_arg = None + + # start the actual sub process + kwargs = {} + if is_linux() or is_mac_os(): + kwargs["start_new_session"] = True + process = subprocess.Popen( + cmd, + shell=shell, + stdin=stdin_arg, + bufsize=-1, + stderr=stderr_arg, + stdout=stdout_arg, + env=env_dict, + cwd=cwd, + **kwargs, + ) + + if tty: + # based on: https://stackoverflow.com/questions/41542960 + def pipe_streams(*args): + while process.poll() is None: + r, w, e = select.select([sys.stdin, master_fd], [], []) + if sys.stdin in r: + d = os.read(sys.stdin.fileno(), 10240) + os.write(master_fd, d) + elif master_fd in r: + o = os.read(master_fd, 10240) + if o: + os.write(sys.stdout.fileno(), o) + + FuncThread(pipe_streams, name="pipe-streams").start() + + return process + except subprocess.CalledProcessError as e: + if print_error: + print("ERROR: '%s': exit code %s; output: %s" % (cmd, e.returncode, e.output)) + sys.stdout.flush() + raise e + + +def run_for_max_seconds(max_secs, _function, *args, **kwargs): + """Run the given function for a maximum of `max_secs` seconds - continue running + in a background thread if the function does not finish in time.""" + + def _worker(*_args): + try: + fn_result = _function(*args, **kwargs) + except Exception as e: + fn_result = e + + fn_result = True if fn_result is None else fn_result + q.put(fn_result) + return fn_result + + start = time.time() + q = Queue() + start_worker_thread(_worker) + for i in range(max_secs * 2): + result = None + try: + result = q.get_nowait() + except Exception: + pass + if result is not None: + if isinstance(result, Exception): + raise result + return result + if time.time() - start >= max_secs: + return + time.sleep(0.5) + + +def run_interactive(command: List[str]): + """ + Run an interactive command in a subprocess. This blocks the current thread and attaches sys.stdin to + the process. Copied from https://stackoverflow.com/a/43012138/804840 + + :param command: the command to pass to subprocess.Popen + """ + subprocess.check_call(command) + + +def is_command_available(cmd: str) -> bool: + try: + run(["which", cmd], print_error=False) + return True + except Exception: + return False + + +def kill_process_tree(parent_pid): + # Note: Do NOT import "psutil" at the root scope + import psutil + + parent_pid = getattr(parent_pid, "pid", None) or parent_pid + parent = psutil.Process(parent_pid) + for child in parent.children(recursive=True): + try: + child.kill() + except Exception: + pass + parent.kill() + + +def wait_for_process_to_be_killed(pid: int, sleep: float = None, retries: int = None): + import psutil + + def _check_pid(): + assert not psutil.pid_exists(pid) + + retry(_check_pid, sleep=sleep, retries=retries) + + +def is_root() -> bool: + return get_os_user() == "root" + + +@lru_cache() +def get_os_user() -> str: + # using getpass.getuser() seems to be reporting a different/invalid user in Docker/macOS + return run("whoami").strip() + + +def to_str(obj: Union[str, bytes], errors="strict"): + return obj.decode(config.DEFAULT_ENCODING, errors) if isinstance(obj, bytes) else obj + + +class ShellCommandThread(FuncThread): + """Helper class to run a shell command in a background thread.""" + + def __init__( + self, + cmd: Union[str, List[str]], + params: Any = None, + outfile: Union[str, int] = None, + env_vars: Dict[str, str] = None, + stdin: bool = False, + auto_restart: bool = False, + quiet: bool = True, + inherit_cwd: bool = False, + inherit_env: bool = True, + log_listener: Callable = None, + stop_listener: Callable = None, + strip_color: bool = False, + name: Optional[str] = None, + cwd: Optional[str] = None, + ): + params = params if params is not None else {} + env_vars = env_vars if env_vars is not None else {} + self.stopped = False + self.cmd = cmd + self.process = None + self.outfile = outfile + self.stdin = stdin + self.env_vars = env_vars + self.inherit_cwd = inherit_cwd + self.inherit_env = inherit_env + self.auto_restart = auto_restart + self.log_listener = log_listener + self.stop_listener = stop_listener + self.strip_color = strip_color + self.started = threading.Event() + self.cwd = cwd + FuncThread.__init__( + self, self.run_cmd, params, quiet=quiet, name=(name or "shell-cmd-thread") + ) + + def run_cmd(self, params): + while True: + self.do_run_cmd() + from localstack.runtime import events + + if ( + events.infra_stopping.is_set() # FIXME: this is the wrong level of abstraction + or not self.auto_restart + or not self.process + or self.process.returncode == 0 + ): + return self.process.returncode if self.process else None + LOG.info( + "Restarting process (received exit code %s): %s", self.process.returncode, self.cmd + ) + + def do_run_cmd(self): + def convert_line(line): + line = to_str(line or "") + if self.strip_color: + # strip color codes + line = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", line) + return "%s\r\n" % line.strip() + + def filter_line(line): + """Return True if this line should be filtered, i.e., not printed""" + return "(Press CTRL+C to quit)" in line + + outfile = self.outfile or os.devnull + if self.log_listener and outfile == os.devnull: + outfile = subprocess.PIPE + try: + self.process = run( + self.cmd, + asynchronous=True, + stdin=self.stdin, + outfile=outfile, + env_vars=self.env_vars, + inherit_cwd=self.inherit_cwd, + inherit_env=self.inherit_env, + cwd=self.cwd, + ) + self.started.set() + if outfile: + if outfile == subprocess.PIPE: + # get stdout/stderr from child process and write to parent output + streams = ( + (self.process.stdout, sys.stdout), + (self.process.stderr, sys.stderr), + ) + for instream, outstream in streams: + if not instream: + continue + for line in iter(instream.readline, None): + # `line` should contain a newline at the end as we're iterating, + # hence we can safely break the loop if `line` is None or empty string + if line in [None, "", b""]: + break + if not (line and line.strip()) and self.is_killed(): + break + line = convert_line(line) + if filter_line(line): + continue + if self.log_listener: + self.log_listener(line, stream=instream) + if self.outfile not in [None, os.devnull]: + outstream.write(line) + outstream.flush() + if self.process: + self.process.wait() + else: + self.process.communicate() + except Exception as e: + self.result_future.set_exception(e) + if self.process and not self.quiet: + LOG.warning('Shell command error "%s": %s', e, self.cmd) + if self.process and not self.quiet and self.process.returncode != 0: + LOG.warning('Shell command exit code "%s": %s', self.process.returncode, self.cmd) + + def is_killed(self): + from localstack.runtime import events + + if not self.process: + return True + if events.infra_stopping.is_set(): # FIXME + return True + # Note: Do NOT import "psutil" at the root scope, as this leads + # to problems when importing this file from our test Lambdas in Docker + # (Error: libc.musl-x86_64.so.1: cannot open shared object file) + import psutil + + return not psutil.pid_exists(self.process.pid) + + def stop(self, quiet=False): + if self.stopped: + return + if not self.process: + LOG.warning("No process found for command '%s'", self.cmd) + return + + parent_pid = self.process.pid + try: + kill_process_tree(parent_pid) + self.process = None + except Exception as e: + if not quiet: + LOG.warning("Unable to kill process with pid %s: %s", parent_pid, e) + try: + self.stop_listener and self.stop_listener(self) + except Exception as e: + if not quiet: + LOG.warning("Unable to run stop handler for shell command thread %s: %s", self, e) + self.stopped = True + + +class CaptureOutput: + """A context manager that captures stdout/stderr of the current thread. Use it as follows: + + with CaptureOutput() as c: + ... + print(c.stdout(), c.stderr()) + """ + + orig_stdout = sys.stdout + orig_stderr = sys.stderr + orig___stdout = sys.__stdout__ + orig___stderr = sys.__stderr__ + CONTEXTS_BY_THREAD = {} + + class LogStreamIO(io.StringIO): + def write(self, s): + if isinstance(s, str) and hasattr(s, "decode"): + s = s.decode("unicode-escape") + return super(CaptureOutput.LogStreamIO, self).write(s) + + def __init__(self): + self._stdout = self.LogStreamIO() + self._stderr = self.LogStreamIO() + + def __enter__(self): + # Note: import werkzeug here (not at top of file) to allow dependency pruning + from werkzeug.local import LocalProxy + + ident = self._ident() + if ident not in self.CONTEXTS_BY_THREAD: + self.CONTEXTS_BY_THREAD[ident] = self + self._set( + LocalProxy(self._proxy(sys.stdout, "stdout")), + LocalProxy(self._proxy(sys.stderr, "stderr")), + LocalProxy(self._proxy(sys.__stdout__, "stdout")), + LocalProxy(self._proxy(sys.__stderr__, "stderr")), + ) + return self + + def __exit__(self, type, value, traceback): + ident = self._ident() + removed = self.CONTEXTS_BY_THREAD.pop(ident, None) + if not self.CONTEXTS_BY_THREAD: + # reset pointers + self._set( + self.orig_stdout, + self.orig_stderr, + self.orig___stdout, + self.orig___stderr, + ) + # get value from streams + removed._stdout.flush() + removed._stderr.flush() + out = removed._stdout.getvalue() + err = removed._stderr.getvalue() + # close handles + removed._stdout.close() + removed._stderr.close() + removed._stdout = out + removed._stderr = err + + def _set(self, out, err, __out, __err): + sys.stdout, sys.stderr, sys.__stdout__, sys.__stderr__ = ( + out, + err, + __out, + __err, + ) + + def _proxy(self, original_stream, type): + def proxy(): + ident = self._ident() + ctx = self.CONTEXTS_BY_THREAD.get(ident) + if ctx: + return ctx._stdout if type == "stdout" else ctx._stderr + return original_stream + + return proxy + + def _ident(self): + return threading.current_thread().ident + + def stdout(self): + return self._stream_value(self._stdout) + + def stderr(self): + return self._stream_value(self._stderr) + + def _stream_value(self, stream): + return stream.getvalue() if hasattr(stream, "getvalue") else stream + + +class CaptureOutputProcess: + """A context manager that captures stdout/stderr of the current process. + + Basically a lightweight version of CaptureOutput without tracking internal thread mapping + + Use it as follows: + + with CaptureOutput() as c: + ... + print(c.stdout(), c.stderr()) + """ + + class LogStreamIO(io.StringIO): + def write(self, s): + if isinstance(s, str) and hasattr(s, "decode"): + s = s.decode("unicode-escape") + return super(CaptureOutputProcess.LogStreamIO, self).write(s) + + def __init__(self): + self.orig_stdout = sys.stdout + self._stdout = self.LogStreamIO() + self.orig_stderr = sys.stderr + self._stderr = self.LogStreamIO() + self.stdout_value = None + self.stderr_value = None + + def __enter__(self): + sys.stdout = self._stdout + sys.stderr = self._stderr + return self + + def __exit__(self, type, value, traceback): + self._stdout.flush() + self._stderr.flush() + + self.stdout_value = self._stdout.getvalue() + self.stderr_value = self._stderr.getvalue() + + # close handles + self._stdout.close() + self._stderr.close() + + sys.stdout = self.orig_stdout + sys.stderr = self.orig_stderr + + def stdout(self): + return self.stdout_value + + def stderr(self): + return self.stderr_value diff --git a/localstack-core/localstack/utils/scheduler.py b/localstack-core/localstack/utils/scheduler.py new file mode 100644 index 0000000000000..c295ef70ced7a --- /dev/null +++ b/localstack-core/localstack/utils/scheduler.py @@ -0,0 +1,200 @@ +import queue +import threading +import time +from concurrent.futures import Executor +from typing import Any, Callable, List, Mapping, Optional, Tuple, Union + + +class ScheduledTask: + """ + Internal representation of a task (a callable) and its scheduling parameters. + """ + + def __init__( + self, + task: Callable, + period: Optional[float] = None, + fixed_rate: bool = True, + start: Optional[float] = None, + on_error: Callable[[Exception], None] = None, + args: Optional[Union[tuple, list]] = None, + kwargs: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__() + self.task = task + self.fixed_rate = fixed_rate + self.period = period + self.start = start + self.on_error = on_error + self.args = args or tuple() + self.kwargs = kwargs or dict() + + self.deadline = None + self.error = None + self._cancelled = False + + @property + def is_periodic(self) -> bool: + return self.period is not None + + @property + def is_cancelled(self) -> bool: + return self._cancelled + + def set_next_deadline(self): + """ + Internal method to update the next deadline of this task based on the period and the current time. + """ + if not self.deadline: + raise ValueError("Deadline was not initialized") + + if self.fixed_rate: + self.deadline = self.deadline + self.period + else: + self.deadline = time.time() + self.period + + def cancel(self): + self._cancelled = True + + def run(self): + """ + Executes the task function. If the function raises and Exception, ``on_error`` is called (if set). + """ + try: + self.task(*self.args, **self.kwargs) + except Exception as e: + if self.on_error: + self.on_error(e) + + +class Scheduler: + """ + An event-loop based task scheduler that can manage multiple scheduled tasks with different periods, + can be parallelized with an executor. + """ + + POISON = (-1, "__POISON__") + + def __init__(self, executor: Optional[Executor] = None) -> None: + """ + Creates a new Scheduler. If an executor is passed, then that executor will be used to run the scheduled tasks + asynchronously, otherwise they will be executed synchronously inside the event loop. Running tasks + asynchronously in an executor means that they will be effectively executed at a fixed rate (scheduling with + ``fixed_rate = False``, will have no effect). + + :param executor: an optional executor that tasks will be submitted to. + """ + super().__init__() + self.executor = executor + + self._queue = queue.PriorityQueue() + self._condition = threading.Condition() + + def schedule( + self, + func: Callable, + period: Optional[float] = None, + fixed_rate: bool = True, + start: Optional[float] = None, + on_error: Callable[[Exception], None] = None, + args: Optional[Union[Tuple, List[Any]]] = None, + kwargs: Optional[Mapping[str, Any]] = None, + ) -> ScheduledTask: + """ + Schedules a given task (function call). + + :param func: the task to schedule + :param period: the period in which to run the task (in seconds). if not set, task will run once + :param fixed_rate: whether the to run at a fixed rate (neglecting execution duration of the task) + :param start: start time + :param on_error: error callback + :param args: additional positional arguments to pass to the function + :param kwargs: additional keyword arguments to pass to the function + :return: a ScheduledTask instance + """ + st = ScheduledTask( + func, + period=period, + fixed_rate=fixed_rate, + start=start, + on_error=on_error, + args=args, + kwargs=kwargs, + ) + self.schedule_task(st) + return st + + def schedule_task(self, task: ScheduledTask) -> None: + """ + Schedules the given task and sets the deadline of the task to either ``task.start`` or the current time. + + :param task: the task to schedule + """ + task.deadline = max(task.start or 0, time.time()) + self.add(task) + + def add(self, task: ScheduledTask) -> None: + """ + Schedules the given task. Requires that the task has a deadline set. It's better to use ``schedule_task``. + + :param task: the task to schedule. + """ + if task.deadline is None: + raise ValueError + + task._cancelled = False + + with self._condition: + self._queue.put((task.deadline, task)) + self._condition.notify() + + def close(self) -> None: + """ + Terminates the run loop. + """ + with self._condition: + self._queue.put(self.POISON) + self._condition.notify() + + def run(self): + q = self._queue + cond = self._condition + executor = self.executor + poison = self.POISON + + task: ScheduledTask + while True: + deadline, task = q.get() + + if (deadline, task) == poison: + break + + if task.is_cancelled: + continue + + # wait until the task should be executed + wait = max(0, deadline - time.time()) + if wait > 0: + with cond: + interrupted = cond.wait(timeout=wait) + if interrupted: + # something with a potentially earlier deadline has arrived while waiting, so we re-queue and + # continue. this could be optimized by checking the deadline of the added element(s) first, + # but that would be fairly involved. the assumption is that `schedule` is not invoked frequently + q.put((task.deadline, task)) + continue + + # run or submit the task + if not task.is_cancelled: + if executor: + executor.submit(task.run) + else: + task.run() + + if task.is_periodic: + try: + task.set_next_deadline() + except ValueError: + # task deadline couldn't be set because it was cancelled + continue + q.put((task.deadline, task)) diff --git a/localstack-core/localstack/utils/server/__init__.py b/localstack-core/localstack/utils/server/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/server/tcp_proxy.py b/localstack-core/localstack/utils/server/tcp_proxy.py new file mode 100644 index 0000000000000..943120307a056 --- /dev/null +++ b/localstack-core/localstack/utils/server/tcp_proxy.py @@ -0,0 +1,108 @@ +import logging +import select +import socket +from concurrent.futures import ThreadPoolExecutor +from typing import Callable + +from localstack.utils.serving import Server + +LOG = logging.getLogger(__name__) + + +class TCPProxy(Server): + """ + Server based TCP proxy abstraction. + This uses a ThreadPoolExecutor, so the maximum number of parallel connections is limited. + """ + + _target_address: str + _target_port: int + _handler: Callable[[bytes], tuple[bytes, bytes]] | None + _buffer_size: int + _thread_pool: ThreadPoolExecutor + _server_socket: socket.socket | None + + def __init__( + self, + target_address: str, + target_port: int, + port: int, + host: str, + handler: Callable[[bytes], tuple[bytes, bytes]] = None, + ) -> None: + super().__init__(port, host) + self._target_address = target_address + self._target_port = target_port + self._handler = handler + self._buffer_size = 1024 + # thread pool limited to 64 workers for now - can be increased or made configurable if this should not suffice + # for certain use cases + self._thread_pool = ThreadPoolExecutor(thread_name_prefix="tcp-proxy", max_workers=64) + self._server_socket = None + + def _handle_request(self, s_src: socket.socket): + try: + s_dst = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + with s_src as s_src, s_dst as s_dst: + s_dst.connect((self._target_address, self._target_port)) + + sockets = [s_src, s_dst] + while not self._stopped.is_set(): + s_read, _, _ = select.select(sockets, [], [], 1) + + for s in s_read: + data = s.recv(self._buffer_size) + if not data: + return + + if s == s_src: + forward, response = data, None + if self._handler: + forward, response = self._handler(data) + if forward is not None: + s_dst.sendall(forward) + elif response is not None: + s_src.sendall(response) + return + elif s == s_dst: + s_src.sendall(data) + except Exception as e: + LOG.error( + "Error while handling request from %s to %s:%s: %s", + s_src.getpeername(), + self._target_address, + self._target_port, + e, + ) + + def do_run(self): + self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_socket.bind((self.host, self.port)) + self._server_socket.listen(1) + self._server_socket.settimeout(10) + LOG.debug( + "Starting TCP proxy bound on %s:%s forwarding to %s:%s", + self.host, + self.port, + self._target_address, + self._target_port, + ) + + with self._server_socket: + while not self._stopped.is_set(): + try: + src_socket, _ = self._server_socket.accept() + self._thread_pool.submit(self._handle_request, src_socket) + except socket.timeout: + pass + except OSError as e: + # avoid creating an error message if OSError is thrown due to socket closing + if not self._stopped.is_set(): + LOG.warning("Error during during TCPProxy socket accept: %s", e) + + def do_shutdown(self): + if self._server_socket: + self._server_socket.shutdown(socket.SHUT_RDWR) + self._server_socket.close() + self._thread_pool.shutdown(cancel_futures=True) + LOG.debug("Shut down TCPProxy on %s:%s", self.host, self.port) diff --git a/localstack-core/localstack/utils/serving.py b/localstack-core/localstack/utils/serving.py new file mode 100644 index 0000000000000..529e380518f88 --- /dev/null +++ b/localstack-core/localstack/utils/serving.py @@ -0,0 +1,188 @@ +import abc +import logging +import threading +from typing import Optional + +from localstack.utils.net import is_port_open +from localstack.utils.sync import poll_condition +from localstack.utils.threads import FuncThread, start_thread + +LOG = logging.getLogger(__name__) + + +class StopServer(Exception): + pass + + +class Server(abc.ABC): + """ + A Server implements the lifecycle of a server running in a thread. + """ + + def __init__(self, port: int, host: str = "localhost") -> None: + super().__init__() + self._thread: Optional[FuncThread] = None + + self._lifecycle_lock = threading.RLock() + self._stopped = threading.Event() + self._started = threading.Event() + + self._host = host + self._port = port + + @property + def host(self): + return self._host + + @property + def port(self): + return self._port + + @property + def protocol(self): + return "http" + + @property + def url(self): + return "%s://%s:%s" % (self.protocol, self.host, self.port) + + def get_error(self) -> Optional[Exception]: + """ + If the thread running the server returned with an Exception, then this function will return that exception. + """ + if not self._started.is_set(): + return None + + future = self._thread.result_future + if future.done(): + return future.exception() + return None + + def wait_is_up(self, timeout: float = None) -> bool: + """ + Waits until the server is started and is_up returns true. + + :param timeout: the time in seconds to wait before returning false. If timeout is None, then wait indefinitely. + :returns: true if the server is up, false if not or the timeout was reached while waiting. + """ + # first wait until the started event was called + self._started.wait(timeout=timeout) + # then poll the health check + return poll_condition(self.is_up, timeout=timeout) + + def is_running(self) -> bool: + """ + Checks whether the thread holding the server is running. The server may be running but not healthy ( + is_running == True, is_up == False). + + :returns: true if the server thread is running + """ + if not self._started.is_set(): + return False + if self._stopped.is_set(): + return False + return self._thread.running + + def is_up(self) -> bool: + """ + Checks whether the server is up by executing the health check function. + + :returns: false if the server has not been started or if the health check failed, true otherwise + """ + if not self._started.is_set(): + return False + + try: + return True if self.health() else False + except Exception: + return False + + def shutdown(self) -> None: + """ + Attempts to shut down the server by calling the internal do_shutdown method. It only does this if the server + has been started. Repeated calls to this function have no effect. + + :raises RuntimeError: shutdown was called before start + """ + with self._lifecycle_lock: + if not self._started.is_set(): + raise RuntimeError("cannot shutdown server before it is started") + if self._stopped.is_set(): + return + + self._thread.stop() + self._stopped.set() + self.do_shutdown() + + def start(self) -> bool: + """ + Starts the server by calling the internal do_run method in a new thread, and then returns True. Repeated + calls to this function have no effect but return False. + + :return: True if the server was started in this call, False if the server was already started previously + """ + with self._lifecycle_lock: + if self._started.is_set(): + return False + + self._thread = self.do_start_thread() + self._started.set() + return True + + def join(self, timeout=None): + """ + Waits for the given amount of time until the thread running the server returns. If the server hasn't started + yet, it first waits for the server to start. + + :params: the time in seconds to wait. If None then wait indefinitely. + :raises TimeoutError: If the server didn't shut down before the given timeout. + """ + if not self._started.is_set(): + raise RuntimeError("cannot join server before it is started") + + if not self._started.wait(timeout): + raise TimeoutError + + try: + self._thread.result_future.result(timeout) + except TimeoutError: + raise + except Exception: + # Future.result() will re-raise the exception that was raised in the thread + return + + def health(self): + """ + Runs a health check on the server. The default implementation performs is_port_open on the server URL. + """ + return is_port_open(self.url) + + def do_start_thread(self) -> FuncThread: + """ + Creates and starts the thread running the server. By default, it calls the do_run method in a FuncThread, but + can be overridden to if the subclass wants to return its own thread. + """ + + def _run(*_): + try: + return self.do_run() + except StopServer: + LOG.debug("stopping server %s", self.url) + finally: + self._stopped.set() + + return start_thread(_run, name=f"server-{self.__class__.__name__}") + + def do_run(self): + """ + Runs the server (blocking method). (Needs to be overridden by subclasses of do_start_thread is not overridden). + + :raises StopServer: can be raised by the subclass to indicate the server should be stopped. + """ + pass + + def do_shutdown(self): + """ + Called when shutdown() is performed. (Should be overridden by subclasses). + """ + pass diff --git a/localstack-core/localstack/utils/ssl.py b/localstack-core/localstack/utils/ssl.py new file mode 100644 index 0000000000000..892399776058a --- /dev/null +++ b/localstack-core/localstack/utils/ssl.py @@ -0,0 +1,71 @@ +import logging +import os + +from localstack import config +from localstack.constants import API_ENDPOINT, ASSETS_ENDPOINT +from localstack.utils.crypto import generate_ssl_cert +from localstack.utils.http import download +from localstack.utils.time import now +from localstack.version import __version__ as version + +LOG = logging.getLogger(__name__) + +# Download URLs +SSL_CERT_URL = f"{ASSETS_ENDPOINT}/local-certs/localstack.cert.key?version={version}" +SSL_CERT_URL_FALLBACK = f"{API_ENDPOINT}/proxy/localstack.cert.key?version={version}" + +# path for test certificate +_SERVER_CERT_PEM_FILE = "server.test.pem" + + +def install_predefined_cert_if_available(): + try: + if config.SKIP_SSL_CERT_DOWNLOAD: + LOG.debug("Skipping download of local SSL cert, as SKIP_SSL_CERT_DOWNLOAD=1") + return + setup_ssl_cert() + except Exception: + pass + + +def setup_ssl_cert() -> None: + target_file = get_cert_pem_file_path() + + # cache file for 6 hours (non-enterprise) or forever (enterprise) + if os.path.exists(target_file): + cache_duration_secs = 24 * 60 * 60 + mod_time = os.path.getmtime(target_file) + if mod_time > (now() - cache_duration_secs): + LOG.debug("Using cached SSL certificate (less than 6hrs since last update).") + return + + # download certificate from GitHub artifacts + LOG.debug("Attempting to download local SSL certificate file") + + # apply timeout (and fall back to using self-signed certs) + timeout = 5 # slightly higher timeout for our proxy + try: + download(SSL_CERT_URL, target_file, timeout=timeout, quiet=True) + LOG.debug("SSL certificate downloaded successfully") + except Exception: + # try fallback URL, directly from our API proxy + try: + download(SSL_CERT_URL_FALLBACK, target_file, timeout=timeout, quiet=True) + LOG.debug("SSL certificate downloaded successfully") + except Exception as e: + LOG.info( + "Unable to download local test SSL certificate from %s to %s (using self-signed cert as fallback): %s", + SSL_CERT_URL_FALLBACK, + target_file, + e, + ) + raise + + +def get_cert_pem_file_path(): + return config.CUSTOM_SSL_CERT_PATH or os.path.join(config.dirs.cache, _SERVER_CERT_PEM_FILE) + + +def create_ssl_cert(serial_number=None): + cert_pem_file = get_cert_pem_file_path() + return generate_ssl_cert(cert_pem_file, serial_number=serial_number) diff --git a/localstack-core/localstack/utils/strings.py b/localstack-core/localstack/utils/strings.py new file mode 100644 index 0000000000000..aead8aaade907 --- /dev/null +++ b/localstack-core/localstack/utils/strings.py @@ -0,0 +1,246 @@ +import base64 +import binascii +import hashlib +import itertools +import random +import re +import string +import uuid +import zlib +from typing import Dict, List, Union + +from localstack.config import DEFAULT_ENCODING + +_unprintables = ( + range(0x00, 0x09), + range(0x0A, 0x0A), + range(0x0B, 0x0D), + range(0x0E, 0x20), + range(0xD800, 0xE000), + range(0xFFFE, 0x10000), +) + +# regular expression for unprintable characters +# Based on https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html +# #x9 | #xA | #xD | #x20 to #xD7FF | #xE000 to #xFFFD | #x10000 to #x10FFFF +REGEX_UNPRINTABLE_CHARS = re.compile( + f"[{re.escape(''.join(map(chr, itertools.chain(*_unprintables))))}]" +) + + +def to_str(obj: Union[str, bytes], encoding: str = DEFAULT_ENCODING, errors="strict") -> str: + """If ``obj`` is an instance of ``binary_type``, return + ``obj.decode(encoding, errors)``, otherwise return ``obj``""" + return obj.decode(encoding, errors) if isinstance(obj, bytes) else obj + + +def to_bytes(obj: Union[str, bytes], encoding: str = DEFAULT_ENCODING, errors="strict") -> bytes: + """If ``obj`` is an instance of ``text_type``, return + ``obj.encode(encoding, errors)``, otherwise return ``obj``""" + return obj.encode(encoding, errors) if isinstance(obj, str) else obj + + +def truncate(data: str, max_length: int = 100) -> str: + data = str(data or "") + return ("%s..." % data[:max_length]) if len(data) > max_length else data + + +def is_string(s, include_unicode=True, exclude_binary=False): + if isinstance(s, bytes) and exclude_binary: + return False + if isinstance(s, str): + return True + if include_unicode and isinstance(s, str): + return True + return False + + +def is_string_or_bytes(s): + return is_string(s) or isinstance(s, str) or isinstance(s, bytes) + + +def is_base64(s): + regex = r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + return is_string(s) and re.match(regex, s) + + +_re_camel_to_snake_case = re.compile("((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))") + + +def camel_to_snake_case(string: str) -> str: + return _re_camel_to_snake_case.sub(r"_\1", string).replace("__", "_").lower() + + +def snake_to_camel_case(string: str, capitalize_first: bool = True) -> str: + components = string.split("_") + start_idx = 0 if capitalize_first else 1 + components = [x.title() for x in components[start_idx:]] + return "".join(components) + + +def hyphen_to_snake_case(string: str) -> str: + return string.replace("-", "_") + + +def canonicalize_bool_to_str(val: bool) -> str: + return "true" if str(val).lower() == "true" else "false" + + +def convert_to_printable_chars(value: Union[List, Dict, str]) -> str: + """Removes all unprintable characters from the given string.""" + from localstack.utils.objects import recurse_object + + if isinstance(value, (dict, list)): + + def _convert(obj, **kwargs): + if isinstance(obj, str): + return convert_to_printable_chars(obj) + return obj + + return recurse_object(value, _convert) + + result = REGEX_UNPRINTABLE_CHARS.sub("", value) + return result + + +def first_char_to_lower(s: str) -> str: + return s and "%s%s" % (s[0].lower(), s[1:]) + + +def first_char_to_upper(s: str) -> str: + return s and "%s%s" % (s[0].upper(), s[1:]) + + +def str_to_bool(value): + """Return the boolean value of the given string, or the verbatim value if it is not a string""" + if isinstance(value, str): + true_strings = ["true", "True"] + return value in true_strings + return value + + +def str_insert(string, index, content): + """Insert a substring into an existing string at a certain index.""" + return "%s%s%s" % (string[:index], content, string[index:]) + + +def str_remove(string, index, end_index=None): + """Remove a substring from an existing string at a certain from-to index range.""" + end_index = end_index or (index + 1) + return "%s%s" % (string[:index], string[end_index:]) + + +def str_startswith_ignore_case(value: str, prefix: str) -> bool: + return value[: len(prefix)].lower() == prefix.lower() + + +def short_uid() -> str: + return str(uuid.uuid4())[0:8] + + +def short_uid_from_seed(seed: str) -> str: + hash = hashlib.sha1(seed.encode("utf-8")).hexdigest() + truncated_hash = hash[:32] + return str(uuid.UUID(truncated_hash))[0:8] + + +def long_uid() -> str: + return str(uuid.uuid4()) + + +def md5(string: Union[str, bytes]) -> str: + m = hashlib.md5() + m.update(to_bytes(string)) + return m.hexdigest() + + +def checksum_crc32(string: Union[str, bytes]) -> str: + bytes = to_bytes(string) + checksum = zlib.crc32(bytes) + return base64.b64encode(checksum.to_bytes(4, "big")).decode() + + +def checksum_crc32c(string: Union[str, bytes]): + # import botocore locally here to avoid a dependency of the CLI to botocore + from botocore.httpchecksum import CrtCrc32cChecksum + + checksum = CrtCrc32cChecksum() + checksum.update(to_bytes(string)) + return base64.b64encode(checksum.digest()).decode() + + +def checksum_crc64nvme(string: Union[str, bytes]): + # import botocore locally here to avoid a dependency of the CLI to botocore + from botocore.httpchecksum import CrtCrc64NvmeChecksum + + checksum = CrtCrc64NvmeChecksum() + checksum.update(to_bytes(string)) + return base64.b64encode(checksum.digest()).decode() + + +def hash_sha1(string: Union[str, bytes]) -> str: + digest = hashlib.sha1(to_bytes(string)).digest() + return base64.b64encode(digest).decode() + + +def hash_sha256(string: Union[str, bytes]) -> str: + digest = hashlib.sha256(to_bytes(string)).digest() + return base64.b64encode(digest).decode() + + +def base64_to_hex(b64_string: str) -> bytes: + return binascii.hexlify(base64.b64decode(b64_string)) + + +def base64_decode(data: Union[str, bytes]) -> bytes: + """Decode base64 data - with optional padding, and able to handle urlsafe encoding (containing -/_).""" + data = to_str(data) + missing_padding = len(data) % 4 + if missing_padding != 0: + data = to_str(data) + "=" * (4 - missing_padding) + if "-" in data or "_" in data: + return base64.urlsafe_b64decode(data) + return base64.b64decode(data) + + +def get_random_hex(length: int) -> str: + return "".join(random.choices(string.hexdigits[:16], k=length)).lower() + + +def remove_leading_extra_slashes(input: str) -> str: + """ + Remove leading extra slashes from the given input string. + Example: '///foo/bar' -> '/foo/bar' + """ + return re.sub(r"^/+", "/", input) + + +def prepend_with_slash(input: str) -> str: + """ + Prepend a slash `/` to a given string if it does not have one already. + """ + if not input.startswith("/"): + return f"/{input}" + return input + + +def key_value_pairs_to_dict(pairs: str, delimiter: str = ",", separator: str = "=") -> dict: + """ + Converts a string of key-value pairs to a dictionary. + + Args: + pairs (str): A string containing key-value pairs separated by a delimiter. + delimiter (str): The delimiter used to separate key-value pairs (default is comma ','). + separator (str): The separator between keys and values (default is '='). + + Returns: + dict: A dictionary containing the parsed key-value pairs. + """ + splits = [split_pair.partition(separator) for split_pair in pairs.split(delimiter)] + return {key.strip(): value.strip() for key, _, value in splits} + + +def token_generator(item: str) -> str: + base64_bytes = base64.b64encode(item.encode("utf-8")) + token = base64_bytes.decode("utf-8") + return token diff --git a/localstack-core/localstack/utils/sync.py b/localstack-core/localstack/utils/sync.py new file mode 100644 index 0000000000000..e5beea032d150 --- /dev/null +++ b/localstack-core/localstack/utils/sync.py @@ -0,0 +1,142 @@ +"""Concurrency synchronization utilities""" + +import functools +import threading +import time +from collections import defaultdict +from typing import Callable, Literal, TypeVar + + +class ShortCircuitWaitException(Exception): + """raise to immediately stop waiting, e.g. when an operation permanently failed""" + + pass + + +def wait_until( + fn: Callable[[], bool], + wait: float = 1.0, + max_retries: int = 10, + strategy: Literal["exponential", "static", "linear"] = "exponential", + _retries: int = 1, + _max_wait: float = 240, +) -> bool: + """waits until a given condition is true, rechecking it periodically""" + assert _retries > 0 + if max_retries < _retries: + return False + try: + completed = fn() + except ShortCircuitWaitException: + return False + except Exception: + completed = False + + if completed: + return True + else: + if wait > _max_wait: + return False + time.sleep(wait) + next_wait = wait # default: static + if strategy == "linear": + next_wait = (wait / _retries) * (_retries + 1) + elif strategy == "exponential": + next_wait = wait * 2 + return wait_until(fn, next_wait, max_retries, strategy, _retries + 1, _max_wait) + + +T = TypeVar("T") + + +def retry(function: Callable[..., T], retries=3, sleep=1.0, sleep_before=0, **kwargs) -> T: + raise_error = None + if sleep_before > 0: + time.sleep(sleep_before) + retries = int(retries) + for i in range(0, retries + 1): + try: + return function(**kwargs) + except Exception as error: + raise_error = error + time.sleep(sleep) + raise raise_error + + +def poll_condition(condition, timeout: float = None, interval: float = 0.5) -> bool: + """ + Poll evaluates the given condition until a truthy value is returned. It does this every `interval` seconds + (0.5 by default), until the timeout (in seconds, if any) is reached. + + Poll returns True once `condition()` returns a truthy value, or False if the timeout is reached. + """ + remaining = 0 + if timeout is not None: + remaining = timeout + + while not condition(): + if timeout is not None: + remaining -= interval + + if remaining <= 0: + return False + + time.sleep(interval) + + return True + + +def synchronized(lock=None): + """ + Synchronization decorator as described in + http://blog.dscpl.com.au/2014/01/the-missing-synchronized-decorator.html. + """ + + def _decorator(wrapped): + @functools.wraps(wrapped) + def _wrapper(*args, **kwargs): + with lock: + return wrapped(*args, **kwargs) + + return _wrapper + + return _decorator + + +def sleep_forever(): + while True: + time.sleep(1) + + +class SynchronizedDefaultDict(defaultdict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._lock = threading.RLock() + + def fromkeys(self, keys, value=None): + with self._lock: + return super().fromkeys(keys, value) + + def __getitem__(self, key): + with self._lock: + return super().__getitem__(key) + + def __setitem__(self, key, value): + with self._lock: + super().__setitem__(key, value) + + def __delitem__(self, key): + with self._lock: + super().__delitem__(key) + + def __iter__(self): + with self._lock: + return super().__iter__() + + def __len__(self): + with self._lock: + return super().__len__() + + def __str__(self): + with self._lock: + return super().__str__() diff --git a/localstack-core/localstack/utils/tagging.py b/localstack-core/localstack/utils/tagging.py new file mode 100644 index 0000000000000..f2ab05160fd2b --- /dev/null +++ b/localstack-core/localstack/utils/tagging.py @@ -0,0 +1,42 @@ +from typing import Dict, List, Optional + + +class TaggingService: + def __init__(self, key_field: str = None, value_field: str = None): + """ + :param key_field: the field name representing the tag key as used by botocore specs + :param value_field: the field name representing the tag value as used by botocore specs + """ + self.key_field = key_field or "Key" + self.value_field = value_field or "Value" + + self.tags = {} + + def list_tags_for_resource(self, arn: str, root_name: Optional[str] = None): + root_name = root_name or "Tags" + + result = [] + if arn in self.tags: + for k, v in self.tags[arn].items(): + result.append({self.key_field: k, self.value_field: v}) + return {root_name: result} + + def tag_resource(self, arn: str, tags: List[Dict[str, str]]): + if not tags: + return + if arn not in self.tags: + self.tags[arn] = {} + for t in tags: + self.tags[arn][t[self.key_field]] = t[self.value_field] + + def untag_resource(self, arn: str, tag_names: List[str]): + tags = self.tags.get(arn, {}) + for name in tag_names: + tags.pop(name, None) + + def del_resource(self, arn: str): + if arn in self.tags: + del self.tags[arn] + + def __delitem__(self, arn: str): + self.del_resource(arn) diff --git a/localstack-core/localstack/utils/testutil.py b/localstack-core/localstack/utils/testutil.py new file mode 100644 index 0000000000000..2701cd7ce23a5 --- /dev/null +++ b/localstack-core/localstack/utils/testutil.py @@ -0,0 +1,671 @@ +import glob +import importlib +import io +import json +import os +import re +import shutil +import tempfile +import time +from typing import Any, Callable, Dict, List, Optional + +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.connect import connect_externally_to, connect_to +from localstack.testing.aws.util import is_aws_cloud +from localstack.utils.aws import arns +from localstack.utils.aws import resources as resource_utils +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.urls import localstack_host + +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +import boto3 +import requests + +from localstack import config +from localstack.constants import ( + LOCALSTACK_ROOT_FOLDER, + LOCALSTACK_VENV_FOLDER, +) +from localstack.services.lambda_.lambda_utils import ( + get_handler_file_from_name, +) +from localstack.testing.config import ( + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_ACCOUNT_ID, + TEST_AWS_REGION_NAME, +) +from localstack.utils.archives import create_zip_file_cli, create_zip_file_python +from localstack.utils.collections import ensure_list +from localstack.utils.files import ( + TMP_FILES, + chmod_r, + cp_r, + is_empty_dir, + load_file, + mkdir, + rm_rf, + save_file, +) +from localstack.utils.platform import is_debian +from localstack.utils.strings import short_uid, to_str + +ARCHIVE_DIR_PREFIX = "lambda.archive." +DEFAULT_GET_LOG_EVENTS_DELAY = 3 +LAMBDA_DEFAULT_HANDLER = "handler.handler" +LAMBDA_DEFAULT_RUNTIME = Runtime.python3_12 +LAMBDA_DEFAULT_STARTING_POSITION = "LATEST" +LAMBDA_TIMEOUT_SEC = 30 +LAMBDA_ASSETS_BUCKET_NAME = "ls-test-lambda-assets-bucket" +LAMBDA_TEST_ROLE = "arn:aws:iam::{account_id}:role/lambda-test-role" +MAX_LAMBDA_ARCHIVE_UPLOAD_SIZE = 50_000_000 + + +def is_local_test_mode(): + return config.is_local_test_mode() + + +def create_lambda_archive( + script: str, + get_content: bool = False, + libs: List[str] = None, + runtime: str = None, + file_name: str = None, + exclude_func: Callable[[str], bool] = None, +): + """Utility method to create a Lambda function archive""" + if libs is None: + libs = [] + runtime = runtime or LAMBDA_DEFAULT_RUNTIME + + with tempfile.TemporaryDirectory(prefix=ARCHIVE_DIR_PREFIX) as tmp_dir: + file_name = file_name or get_handler_file_from_name(LAMBDA_DEFAULT_HANDLER, runtime=runtime) + script_file = os.path.join(tmp_dir, file_name) + if os.path.sep in script_file: + mkdir(os.path.dirname(script_file)) + # create __init__.py files along the path to allow Python imports + path = file_name.split(os.path.sep) + for i in range(1, len(path)): + save_file(os.path.join(tmp_dir, *(path[:i] + ["__init__.py"])), "") + save_file(script_file, script) + chmod_r(script_file, 0o777) + # copy libs + for lib in libs: + paths = [lib, "%s.py" % lib] + try: + module = importlib.import_module(lib) + paths.append(module.__file__) + except Exception: + pass + target_dir = tmp_dir + root_folder = os.path.join(LOCALSTACK_VENV_FOLDER, "lib/python*/site-packages") + if lib == "localstack": + paths = ["localstack/*.py", "localstack/utils"] + root_folder = LOCALSTACK_ROOT_FOLDER + target_dir = os.path.join(tmp_dir, lib) + mkdir(target_dir) + for path in paths: + file_to_copy = path if path.startswith("/") else os.path.join(root_folder, path) + for file_path in glob.glob(file_to_copy): + name = os.path.join(target_dir, file_path.split(os.path.sep)[-1]) + if os.path.isdir(file_path): + cp_r(file_path, name) + else: + shutil.copyfile(file_path, name) + + if exclude_func: + for dirpath, folders, files in os.walk(tmp_dir): + for name in list(folders) + list(files): + full_name = os.path.join(dirpath, name) + relative = os.path.relpath(full_name, start=tmp_dir) + if exclude_func(relative): + rm_rf(full_name) + + # create zip file + result = create_zip_file(tmp_dir, get_content=get_content) + return result + + +def create_zip_file( + file_path: str, + zip_file: str = None, + get_content: bool = False, + content_root: str = None, + mode: Literal["r", "w", "x", "a"] = "w", +): + """ + Creates a zipfile to the designated file_path. + + By default, a new zip file is created but the mode parameter can be used to append to an existing zip file + """ + base_dir = file_path + if not os.path.isdir(file_path): + base_dir = tempfile.mkdtemp(prefix=ARCHIVE_DIR_PREFIX) + shutil.copy(file_path, base_dir) + TMP_FILES.append(base_dir) + tmp_dir = tempfile.mkdtemp(prefix=ARCHIVE_DIR_PREFIX) + full_zip_file = zip_file + if not full_zip_file: + zip_file_name = "archive.zip" + full_zip_file = os.path.join(tmp_dir, zip_file_name) + # special case where target folder is empty -> create empty zip file + if is_empty_dir(base_dir): + # see https://stackoverflow.com/questions/25195495/how-to-create-an-empty-zip-file#25195628 + content = ( + b"PK\x05\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ) + if get_content: + return content + save_file(full_zip_file, content) + return full_zip_file + + # TODO: using a different packaging method here also produces wildly different .zip package sizes + if is_debian() and "PYTEST_CURRENT_TEST" not in os.environ: + # todo: extend CLI with the new parameters + create_zip_file_cli(source_path=file_path, base_dir=base_dir, zip_file=full_zip_file) + else: + create_zip_file_python( + base_dir=base_dir, zip_file=full_zip_file, mode=mode, content_root=content_root + ) + if not get_content: + TMP_FILES.append(tmp_dir) + return full_zip_file + with open(full_zip_file, "rb") as file_obj: + zip_file_content = file_obj.read() + rm_rf(tmp_dir) + return zip_file_content + + +# TODO: make the `client` parameter mandatory to enforce proper xaccount access +def create_lambda_function( + func_name, + zip_file=None, + event_source_arn=None, + handler_file=None, + handler=None, + starting_position=None, + runtime=None, + envvars=None, + tags=None, + libs=None, + delete=False, + layers=None, + client=None, + role=None, + timeout=None, + region_name=None, + s3_client=None, + **kwargs, +): + """Utility method to create a new function via the Lambda API + CAVEAT: Does NOT wait until the function is ready/active. The fixture create_lambda_function waits until ready. + """ + if envvars is None: + envvars = {} + if tags is None: + tags = {} + if libs is None: + libs = [] + + starting_position = starting_position or LAMBDA_DEFAULT_STARTING_POSITION + runtime = runtime or LAMBDA_DEFAULT_RUNTIME + client = client or connect_to(region_name=region_name).lambda_ + + # load zip file content if handler_file is specified + if not zip_file and handler_file: + file_content = load_file(handler_file) if os.path.exists(handler_file) else handler_file + if libs or not handler: + zip_file = create_lambda_archive( + file_content, + libs=libs, + get_content=True, + runtime=runtime or LAMBDA_DEFAULT_RUNTIME, + ) + else: + zip_file = create_zip_file(handler_file, get_content=True) + + handler = handler or LAMBDA_DEFAULT_HANDLER + + if delete: + try: + # Delete function if one already exists + client.delete_function(FunctionName=func_name) + except Exception: + pass + + lambda_code = {"ZipFile": zip_file} + if len(zip_file) > MAX_LAMBDA_ARCHIVE_UPLOAD_SIZE: + s3 = s3_client or connect_externally_to().s3 + resource_utils.get_or_create_bucket(LAMBDA_ASSETS_BUCKET_NAME) + asset_key = f"{short_uid()}.zip" + s3.upload_fileobj( + Fileobj=io.BytesIO(zip_file), Bucket=LAMBDA_ASSETS_BUCKET_NAME, Key=asset_key + ) + lambda_code = {"S3Bucket": LAMBDA_ASSETS_BUCKET_NAME, "S3Key": asset_key} + + # create function + additional_kwargs = kwargs + kwargs = { + "FunctionName": func_name, + "Runtime": runtime, + "Handler": handler, + "Role": role or LAMBDA_TEST_ROLE.format(account_id=TEST_AWS_ACCOUNT_ID), + "Code": lambda_code, + "Timeout": timeout or LAMBDA_TIMEOUT_SEC, + "Environment": dict(Variables=envvars), + "Tags": tags, + } + kwargs.update(additional_kwargs) + if layers: + kwargs["Layers"] = layers + create_func_resp = client.create_function(**kwargs) + + resp = { + "CreateFunctionResponse": create_func_resp, + "CreateEventSourceMappingResponse": None, + } + + # create event source mapping + if event_source_arn: + resp["CreateEventSourceMappingResponse"] = client.create_event_source_mapping( + FunctionName=func_name, + EventSourceArn=event_source_arn, + StartingPosition=starting_position, + ) + + return resp + + +def connect_api_gateway_to_http_with_lambda_proxy( + gateway_name, + target_uri, + stage_name=None, + methods=None, + path=None, + auth_type=None, + auth_creator_func=None, + http_method=None, + client=None, + role_arn: str = None, +): + if methods is None: + methods = [] + if not methods: + methods = ["GET", "POST", "DELETE"] + if not path: + path = "/" + stage_name = stage_name or "test" + resources = {} + resource_path = path.lstrip("/") + resources[resource_path] = [] + + for method in methods: + int_meth = http_method or method + integration = {"type": "AWS_PROXY", "uri": target_uri, "httpMethod": int_meth} + if role_arn: + integration["credentials"] = role_arn + resources[resource_path].append( + { + "httpMethod": method, + "authorizationType": auth_type, + "authorizerId": None, + "integrationHttpMethod": "POST", + "integrations": [integration], + } + ) + return resource_utils.create_api_gateway( + name=gateway_name, + resources=resources, + stage_name=stage_name, + auth_creator_func=auth_creator_func, + client=client, + ) + + +def create_lambda_api_gateway_integration( + gateway_name, + func_name, + handler_file, + lambda_client, + methods=None, + path=None, + runtime=None, + stage_name=None, + auth_type=None, + auth_creator_func=None, + role_arn: str = None, +): + if methods is None: + methods = [] + path = path or "/test" + auth_type = auth_type or "REQUEST" + stage_name = stage_name or "test" + + # create Lambda + zip_file = create_lambda_archive(handler_file, get_content=True, runtime=runtime) + func_arn = create_lambda_function( + func_name=func_name, zip_file=zip_file, runtime=runtime, client=lambda_client + )["CreateFunctionResponse"]["FunctionArn"] + target_arn = arns.apigateway_invocations_arn(func_arn, TEST_AWS_REGION_NAME) + + # connect API GW to Lambda + result = connect_api_gateway_to_http_with_lambda_proxy( + gateway_name, + target_arn, + stage_name=stage_name, + path=path, + methods=methods, + auth_type=auth_type, + auth_creator_func=auth_creator_func, + role_arn=role_arn, + ) + return result + + +def assert_objects(asserts, all_objects): + if type(asserts) is not list: + asserts = [asserts] + for obj in asserts: + assert_object(obj, all_objects) + + +def assert_object(expected_object, all_objects): + # for Python 3 compatibility + dict_values = type({}.values()) + if isinstance(all_objects, dict_values): + all_objects = list(all_objects) + # wrap single item in an array + if type(all_objects) is not list: + all_objects = [all_objects] + found = find_object(expected_object, all_objects) + if not found: + raise Exception("Expected object not found: %s in list %s" % (expected_object, all_objects)) + + +def find_object(expected_object, object_list): + for obj in object_list: + if isinstance(obj, list): + found = find_object(expected_object, obj) + if found: + return found + + all_ok = True + if obj != expected_object: + if not isinstance(expected_object, dict): + all_ok = False + else: + for k, v in expected_object.items(): + if not find_recursive(k, v, obj): + all_ok = False + break + if all_ok: + return obj + return None + + +def find_recursive(key, value, obj): + if isinstance(obj, dict): + for k, v in obj.items(): + if k == key and v == value: + return True + if find_recursive(key, value, v): + return True + elif isinstance(obj, list): + for o in obj: + if find_recursive(key, value, o): + return True + else: + return False + + +def list_all_s3_objects(s3_client): + return map_all_s3_objects(s3_client=s3_client).values() + + +def delete_all_s3_objects(s3_client, buckets: str | List[str]): + buckets = ensure_list(buckets) + for bucket in buckets: + keys = all_s3_object_keys(s3_client, bucket) + deletes = [{"Key": key} for key in keys] + if deletes: + s3_client.delete_objects(Bucket=bucket, Delete={"Objects": deletes}) + + +def download_s3_object(s3_client, bucket, path): + body = s3_client.get_object(Bucket=bucket, Key=path)["Body"] + result = body.read() + try: + result = to_str(result) + except Exception: + pass + return result + + +def all_s3_object_keys(s3_client, bucket: str) -> List[str]: + response = s3_client.list_objects_v2(Bucket=bucket) + keys = [obj["Key"] for obj in response.get("Contents", [])] + return keys + + +def map_all_s3_objects( + s3_client, to_json: bool = True, buckets: str | List[str] = None +) -> Dict[str, Any]: + result = {} + buckets = ensure_list(buckets) + if not buckets: + # get all buckets + response = s3_client.list_buckets() + buckets = [b["Name"] for b in response["Buckets"]] + + for bucket in buckets: + response = s3_client.list_objects_v2(Bucket=bucket) + objects = [obj["Key"] for obj in response.get("Contents", [])] + for key in objects: + value = download_s3_object(s3_client, bucket, key) + try: + if to_json: + value = json.loads(value) + separator = "" if key.startswith("/") else "/" + result[f"{bucket}{separator}{key}"] = value + except Exception: + # skip non-JSON or binary objects + pass + return result + + +def send_describe_dynamodb_ttl_request(table_name): + return send_dynamodb_request("", "DescribeTimeToLive", json.dumps({"TableName": table_name})) + + +def send_update_dynamodb_ttl_request(table_name, ttl_status): + return send_dynamodb_request( + "", + "UpdateTimeToLive", + json.dumps( + { + "TableName": table_name, + "TimeToLiveSpecification": { + "AttributeName": "ExpireItem", + "Enabled": ttl_status, + }, + } + ), + ) + + +def send_dynamodb_request(path, action, request_body): + headers = { + "Host": "dynamodb.amazonaws.com", + "x-amz-target": "DynamoDB_20120810.{}".format(action), + "Authorization": mock_aws_request_headers( + "dynamodb", aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, region_name=TEST_AWS_REGION_NAME + )["Authorization"], + } + url = f"{config.internal_service_url()}/{path}" + return requests.put(url, data=request_body, headers=headers, verify=False) + + +def get_lambda_log_group_name(function_name): + return "/aws/lambda/{}".format(function_name) + + +# TODO: make logs_client mandatory +def check_expected_lambda_log_events_length( + expected_length, function_name, regex_filter=None, logs_client=None +): + events = get_lambda_log_events( + function_name, regex_filter=regex_filter, logs_client=logs_client + ) + events = [line for line in events if line not in ["\x1b[0m", "\\x1b[0m"]] + if len(events) != expected_length: + print( + "Invalid # of Lambda %s log events: %s / %s: %s" + % ( + function_name, + len(events), + expected_length, + [ + event if len(event) < 1000 else f"{event[:1000]}... (truncated)" + for event in events + ], + ) + ) + assert len(events) == expected_length + return events + + +def list_all_log_events(log_group_name: str, logs_client=None) -> List[Dict]: + logs = logs_client or connect_to().logs + return list_all_resources( + lambda kwargs: logs.filter_log_events(logGroupName=log_group_name, **kwargs), + last_token_attr_name="nextToken", + list_attr_name="events", + ) + + +def get_lambda_log_events( + function_name, + delay_time=DEFAULT_GET_LOG_EVENTS_DELAY, + regex_filter: Optional[str] = None, + log_group=None, + logs_client=None, +): + def get_log_events(func_name, delay): + time.sleep(delay) + log_group_name = log_group or get_lambda_log_group_name(func_name) + return list_all_log_events(log_group_name, logs_client) + + try: + events = get_log_events(function_name, delay_time) + except Exception as e: + if "ResourceNotFoundException" in str(e): + return [] + raise + + rs = [] + for event in events: + raw_message = event["message"] + if ( + not raw_message + or raw_message.startswith("INIT_START") + or raw_message.startswith("START") + or raw_message.startswith("END") + or raw_message.startswith( + "REPORT" + ) # necessary until tail is updated in docker images. See this PR: + # http://git.savannah.gnu.org/gitweb/?p=coreutils.git;a=commitdiff;h=v8.24-111-g1118f32 + or "tail: unrecognized file system type" in raw_message + or regex_filter + and not re.search(regex_filter, raw_message) + ): + continue + if raw_message in ["\x1b[0m", "\\x1b[0m"]: + continue + + try: + rs.append(json.loads(raw_message)) + except Exception: + rs.append(raw_message) + + return rs + + +def list_all_resources( + page_function: Callable[[dict], Any], + last_token_attr_name: str, + list_attr_name: str, + next_token_attr_name: Optional[str] = None, +) -> list: + """ + List all available resources by loading all available pages using `page_function`. + + :type page_function: Callable + :param page_function: callable function or lambda that accepts kwargs with next token + and returns the next results page + + :type last_token_attr_name: str + :param last_token_attr_name: where to look for the last evaluated token + + :type list_attr_name: str + :param list_attr_name: where to look for the list of items + + :type next_token_attr_name: Optional[str] + :param next_token_attr_name: name of kwarg with the next token, default is the same as `last_token_attr_name` + + Example usage: + + all_log_groups = list_all_resources( + lambda kwargs: logs.describe_log_groups(**kwargs), + last_token_attr_name="nextToken", + list_attr_name="logGroups" + ) + + all_records = list_all_resources( + lambda kwargs: dynamodb.scan(**{**kwargs, **dynamodb_kwargs}), + last_token_attr_name="LastEvaluatedKey", + next_token_attr_name="ExclusiveStartKey", + list_attr_name="Items" + ) + """ + + if next_token_attr_name is None: + next_token_attr_name = last_token_attr_name + + result = None + collected_items = [] + last_evaluated_token = None + + while not result or last_evaluated_token: + kwargs = {next_token_attr_name: last_evaluated_token} if last_evaluated_token else {} + result = page_function(kwargs) + last_evaluated_token = result.get(last_token_attr_name) + collected_items += result.get(list_attr_name, []) + + return collected_items + + +def response_arn_matches_partition(client, response_arn: str) -> bool: + parsed_arn = arns.parse_arn(response_arn) + return ( + client.meta.partition + == boto3.session.Session().get_partition_for_region(parsed_arn["region"]) + and client.meta.partition == parsed_arn["partition"] + ) + + +def upload_file_to_bucket(s3_client, bucket_name, file_path, file_name=None): + key = file_name or f"file-{short_uid()}" + + s3_client.upload_file( + file_path, + Bucket=bucket_name, + Key=key, + ) + + domain = "amazonaws.com" if is_aws_cloud() else localstack_host().host_and_port() + url = f"https://{bucket_name}.s3.{domain}/{key}" + + return {"Bucket": bucket_name, "Key": key, "Url": url} diff --git a/localstack-core/localstack/utils/threads.py b/localstack-core/localstack/utils/threads.py new file mode 100644 index 0000000000000..a981160177b34 --- /dev/null +++ b/localstack-core/localstack/utils/threads.py @@ -0,0 +1,163 @@ +import concurrent.futures +import inspect +import logging +import threading +import traceback +from concurrent.futures import Future +from multiprocessing.dummy import Pool +from typing import Callable, List, Optional + +LOG = logging.getLogger(__name__) + +# arrays for temporary threads and resources +TMP_THREADS = [] +TMP_PROCESSES = [] + +counter_lock = threading.Lock() +counter = 0 + + +class FuncThread(threading.Thread): + """Helper class to run a Python function in a background thread.""" + + def __init__( + self, + func, + params=None, + quiet=False, + on_stop: Callable[["FuncThread"], None] = None, + name: Optional[str] = None, + daemon=True, + ): + global counter + global counter_lock + + if name: + with counter_lock: + counter += 1 + thread_counter_current = counter + + threading.Thread.__init__( + self, name=f"{name}-functhread{thread_counter_current}", daemon=daemon + ) + else: + threading.Thread.__init__(self, daemon=daemon) + + self.params = params + self.func = func + self.quiet = quiet + self.result_future = Future() + self._stop_event = threading.Event() + self.on_stop = on_stop + + def run(self): + result = None + try: + kwargs = {} + argspec = inspect.getfullargspec(self.func) + if argspec.varkw or "_thread" in (argspec.args or []) + (argspec.kwonlyargs or []): + kwargs["_thread"] = self + result = self.func(self.params, **kwargs) + except Exception as e: + self.result_future.set_exception(e) + result = e + if not self.quiet: + LOG.info( + "Thread run method %s(%s) failed: %s %s", + self.func, + self.params, + e, + traceback.format_exc(), + ) + finally: + try: + self.result_future.set_result(result) + pass + except concurrent.futures.InvalidStateError as e: + # this can happen on shutdown if the task is already canceled + LOG.debug(e) + + @property + def running(self): + return not self._stop_event.is_set() + + def stop(self, quiet: bool = False) -> None: + self._stop_event.set() + + if self.on_stop: + try: + self.on_stop(self) + except Exception as e: + LOG.warning("error while calling on_stop callback: %s", e) + + +def start_thread(method, *args, **kwargs) -> FuncThread: # TODO: find all usages and add names... + """Start the given method in a background thread, and add the thread to the TMP_THREADS shutdown hook""" + _shutdown_hook = kwargs.pop("_shutdown_hook", True) + if not kwargs.get("name"): + LOG.debug( + "start_thread called without providing a custom name" + ) # technically we should add a new level here for *internal* warnings + kwargs.setdefault("name", method.__name__) + thread = FuncThread(method, *args, **kwargs) + thread.start() + if _shutdown_hook: + TMP_THREADS.append(thread) + return thread + + +def start_worker_thread(method, *args, **kwargs): + kwargs.setdefault("name", "start_worker_thread") + return start_thread(method, *args, _shutdown_hook=False, **kwargs) + + +def cleanup_threads_and_processes(quiet=True): + from localstack.utils.run import kill_process_tree + + for thread in TMP_THREADS: + if thread: + try: + if hasattr(thread, "shutdown"): + thread.shutdown() + continue + if hasattr(thread, "kill"): + thread.kill() + continue + thread.stop(quiet=quiet) + except Exception as e: + LOG.debug("[shutdown] Error stopping thread %s: %s", thread, e) + if not thread.daemon: + LOG.warning( + "[shutdown] Non-daemon thread %s may block localstack shutdown", thread + ) + for proc in TMP_PROCESSES: + try: + kill_process_tree(proc.pid) + # proc.terminate() + except Exception as e: + LOG.debug("[shutdown] Error cleaning up process tree %s: %s", proc, e) + # clean up async tasks + try: + import asyncio + + for task in asyncio.all_tasks(): + try: + task.cancel() + except Exception as e: + LOG.debug("[shutdown] Error cancelling asyncio task %s: %s", task, e) + except Exception: + pass + LOG.debug("[shutdown] Done cleaning up threads / processes / tasks") + # clear lists + TMP_THREADS.clear() + TMP_PROCESSES.clear() + + +def parallelize(func: Callable, arr: List, size: int = None): + if not size: + size = len(arr) + if size <= 0: + return None + + with Pool(size) as pool: + return pool.map(func, arr) diff --git a/localstack-core/localstack/utils/time.py b/localstack-core/localstack/utils/time.py new file mode 100644 index 0000000000000..6e59a04a18f98 --- /dev/null +++ b/localstack-core/localstack/utils/time.py @@ -0,0 +1,73 @@ +import time +from datetime import date, datetime, timezone, tzinfo +from typing import Optional + +TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S" +TIMESTAMP_FORMAT_TZ = "%Y-%m-%dT%H:%M:%SZ" +TIMESTAMP_FORMAT_MICROS = "%Y-%m-%dT%H:%M:%S.%fZ" +TIMESTAMP_READABLE_FORMAT = "%d/%b/%Y:%H:%M:%S %z" + + +def isoformat_milliseconds(t) -> str: + try: + return t.isoformat(timespec="milliseconds") + except TypeError: + return t.isoformat()[:-3] + + +def timestamp(time=None, format: str = TIMESTAMP_FORMAT) -> str: + if not time: + time = datetime.utcnow() + if isinstance(time, (int, float)): + time = datetime.fromtimestamp(time) + return time.strftime(format) + + +def timestamp_millis(time=None) -> str: + microsecond_time = timestamp(time=time, format=TIMESTAMP_FORMAT_MICROS) + # truncating microseconds to milliseconds, while leaving the "Z" indicator + return microsecond_time[:-4] + microsecond_time[-1] + + +def iso1806_to_epoch(t: str) -> float: + return datetime.fromisoformat(t).timestamp() + + +def epoch_to_iso1806(ts: int) -> str: + return datetime.utcfromtimestamp(ts).isoformat() + + +def epoch_timestamp() -> float: + return time.time() + + +def parse_timestamp(ts_str: str) -> datetime: + for ts_format in [ + TIMESTAMP_FORMAT, + TIMESTAMP_FORMAT_TZ, + TIMESTAMP_FORMAT_MICROS, + TIMESTAMP_READABLE_FORMAT, + ]: + try: + return datetime.strptime(ts_str, ts_format) + except ValueError: + pass + raise Exception("Unable to parse timestamp string with any known formats: %s" % ts_str) + + +def now(millis: bool = False, tz: Optional[tzinfo] = None) -> int: + return mktime(datetime.now(tz=tz), millis=millis) + + +def now_utc(millis: bool = False) -> int: + return now(millis, timezone.utc) + + +def today_no_time() -> int: + return mktime(datetime.combine(date.today(), datetime.min.time())) + + +def mktime(ts: datetime, millis: bool = False) -> int: + if millis: + return int(ts.timestamp() * 1000) + return int(ts.timestamp()) diff --git a/localstack-core/localstack/utils/urls.py b/localstack-core/localstack/utils/urls.py new file mode 100644 index 0000000000000..97b92af754996 --- /dev/null +++ b/localstack-core/localstack/utils/urls.py @@ -0,0 +1,23 @@ +from typing import Optional + +from localstack import config +from localstack.config import HostAndPort + + +def path_from_url(url: str) -> str: + return f"/{url.partition('://')[2].partition('/')[2]}" if "://" in url else url + + +def hostname_from_url(url: str) -> str: + return url.split("://")[-1].split("/")[0].split(":")[0] + + +def localstack_host(custom_port: Optional[int] = None) -> HostAndPort: + """ + Determine the host and port to return to the user based on: + - the user's configuration (e.g environment variable overrides) + - the defaults of the system + """ + port = custom_port or config.LOCALSTACK_HOST.port + host = config.LOCALSTACK_HOST.host + return HostAndPort(host=host, port=port) diff --git a/localstack-core/localstack/utils/venv.py b/localstack-core/localstack/utils/venv.py new file mode 100644 index 0000000000000..7911110ce54f6 --- /dev/null +++ b/localstack-core/localstack/utils/venv.py @@ -0,0 +1,100 @@ +import io +import os +import sys +from functools import cached_property +from pathlib import Path +from typing import Union + + +class VirtualEnvironment: + """ + Encapsulates methods to operate and navigate on a python virtual environment. + """ + + def __init__(self, venv_dir: Union[str, os.PathLike]): + self._venv_dir = venv_dir + + def create(self) -> None: + """ + Uses the virtualenv cli to create the virtual environment. + :return: + """ + self.venv_dir.mkdir(parents=True, exist_ok=True) + from venv import main + + main([str(self.venv_dir)]) + + @property + def exists(self) -> bool: + """ + Checks whether the virtual environment exists by checking whether the site-package directory of the venv exists. + :return: the if the venv exists + :raises NotADirectoryError: if the venv path exists but is not a directory + """ + try: + return True if self.site_dir else False + except FileNotFoundError: + return False + + @cached_property + def venv_dir(self) -> Path: + """ + Returns the path of the virtual environment directory + :return: the path to the venv + """ + return Path(self._venv_dir).absolute() + + @cached_property + def site_dir(self) -> Path: + """ + Resolves and returns the site-packages directory of the virtual environment. Once resolved successfully the + result is cached. + + :return: the path to the site-packages dir. + :raise FileNotFoundError: if the venv does not exist or the site-packages could not be found, or there are + multiple lib/python* directories. + :raise NotADirectoryError: if the venv is not a directory + """ + venv = self.venv_dir + + if not venv.exists(): + raise FileNotFoundError(f"expected venv directory to exist at {venv}") + + if not venv.is_dir(): + raise NotADirectoryError(f"expected {venv} to be a directory") + + matches = list(venv.glob("lib/python*/site-packages")) + + if not matches: + raise FileNotFoundError(f"could not find site-packages directory in {venv}") + + if len(matches) > 1: + raise FileNotFoundError(f"multiple python versions found in {venv}: {matches}") + + return matches[0] + + def inject_to_sys_path(self) -> None: + path = str(self.site_dir) + if path and path not in sys.path: + sys.path.append(path) + + def add_pth(self, name, path: Union[str, os.PathLike, "VirtualEnvironment"]) -> None: + """ + Add a .pth file into the virtual environment and append the given path to it. Does nothing if the path + is already in the file. + + :param name: the name of the path file (without the .pth extensions) + :param path: the path to be appended + """ + pth_file = self.site_dir / f"{name}.pth" + + if isinstance(path, VirtualEnvironment): + path = path.site_dir + + line = io.text_encoding(str(path)) + "\n" + + if pth_file.exists() and line in pth_file.read_text(): + return + + with open(pth_file, "a") as fd: + fd.write(line) diff --git a/localstack-core/localstack/utils/xml.py b/localstack-core/localstack/utils/xml.py new file mode 100644 index 0000000000000..3e17cb57cc466 --- /dev/null +++ b/localstack-core/localstack/utils/xml.py @@ -0,0 +1,41 @@ +import xml.etree.ElementTree as ET +from typing import Any + + +def obj_to_xml(obj: Any) -> str: + """Return an XML representation of the given object (dict, list, or primitive). + Does NOT add a common root element if the given obj is a list. + Does NOT work for nested dict structures.""" + if isinstance(obj, list): + return "".join([obj_to_xml(o) for o in obj]) + if isinstance(obj, dict): + return "".join(["<{k}>{v}".format(k=k, v=obj_to_xml(v)) for (k, v) in obj.items()]) + return str(obj) + + +def strip_xmlns(obj: Any) -> Any: + """Strip xmlns attributes from a dict returned by xmltodict.parse.""" + if isinstance(obj, list): + return [strip_xmlns(item) for item in obj] + if isinstance(obj, dict): + # Remove xmlns attribute. + obj.pop("@xmlns", None) + if len(obj) == 1 and "#text" in obj: + # If the only remaining key is the #text key, elide the dict + # entirely, to match the structure that xmltodict.parse would have + # returned if the xmlns namespace hadn't been present. + return obj["#text"] + return {k: strip_xmlns(v) for k, v in obj.items()} + return obj + + +def is_valid_xml(xml_string: str) -> bool: + """ + Check if the given string is a valid XML document. + """ + try: + # Attempt to parse the XML string + ET.fromstring(xml_string.encode("utf-8")) + return True + except ET.ParseError: + return False diff --git a/localstack-core/localstack/utils/xray/__init__.py b/localstack-core/localstack/utils/xray/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/localstack-core/localstack/utils/xray/trace_header.py b/localstack-core/localstack/utils/xray/trace_header.py new file mode 100644 index 0000000000000..7a34e7da89ff1 --- /dev/null +++ b/localstack-core/localstack/utils/xray/trace_header.py @@ -0,0 +1,192 @@ +# This file is part of LocalStack. +# It is adapted from aws-xray-sdk-python licensed under the Apache License 2.0. +# You may obtain a copy of the Apache License 2.0 at http://www.apache.org/licenses/LICENSE-2.0 +# Original source: https://github.com/aws/aws-xray-sdk-python/blob/master/aws_xray_sdk/core/models/trace_header.py +# Modifications: +# * Add optional lineage field for https://docs.aws.amazon.com/lambda/latest/dg/invocation-recursion.html +# * Add ensure_root_exists(), ensure_parent_exists(), and ensure_sampled_exists() +# * Add generate_random_id() from https://github.com/aws/aws-xray-sdk-python/blob/d3a202719e659968fe6dcc04fe14c7f3045b53e8/aws_xray_sdk/core/models/entity.py#L308 + +import binascii +import logging +import os + +from localstack.utils.xray.traceid import TraceId + +log = logging.getLogger(__name__) + +ROOT = "Root" +PARENT = "Parent" +SAMPLE = "Sampled" +SELF = "Self" +LINEAGE = "Lineage" + +HEADER_DELIMITER = ";" + + +def generate_random_id(): + """ + Generate a random 16-digit hex str. + This is used for generating segment/subsegment id. + """ + return binascii.b2a_hex(os.urandom(8)).decode("utf-8") + + +class TraceHeader: + """ + The sampling decision and trace ID are added to HTTP requests in + tracing headers named ``X-Amzn-Trace-Id``. The first X-Ray-integrated + service that the request hits adds a tracing header, which is read + by the X-Ray SDK and included in the response. Learn more about + `Tracing Header `_. + """ + + def __init__(self, root=None, parent=None, sampled=None, data=None, lineage=None): + """ + :param str root: trace id + :param str parent: parent id + :param int sampled: 0 means not sampled, 1 means sampled + :param dict data: arbitrary data fields + :param str lineage: lineage + """ + self._root = root + self._parent = parent + self._sampled = None + self._lineage = lineage + self._data = data + + if sampled is not None: + if sampled == "?": + self._sampled = sampled + if sampled is True or sampled == "1" or sampled == 1: + self._sampled = 1 + if sampled is False or sampled == "0" or sampled == 0: + self._sampled = 0 + + @classmethod + def from_header_str(cls, header): + """ + Create a TraceHeader object from a tracing header string + extracted from a http request headers. + """ + if not header: + return cls() + + try: + params = header.strip().split(HEADER_DELIMITER) + header_dict = {} + data = {} + + for param in params: + entry = param.split("=") + key = entry[0] + if key in (ROOT, PARENT, SAMPLE, LINEAGE): + header_dict[key] = entry[1] + # Ignore any "Self=" trace ids injected from ALB. + elif key != SELF: + data[key] = entry[1] + + return cls( + root=header_dict.get(ROOT, None), + parent=header_dict.get(PARENT, None), + sampled=header_dict.get(SAMPLE, None), + lineage=header_dict.get(LINEAGE, None), + data=data, + ) + + except Exception: + log.warning("malformed tracing header %s, ignore.", header) + return cls() + + def to_header_str(self): + """ + Convert to a tracing header string that can be injected to + outgoing http request headers. + """ + h_parts = [] + if self.root: + h_parts.append(ROOT + "=" + self.root) + if self.parent: + h_parts.append(PARENT + "=" + self.parent) + if self.sampled is not None: + h_parts.append(SAMPLE + "=" + str(self.sampled)) + if self.lineage is not None: + h_parts.append(LINEAGE + "=" + str(self.lineage)) + if self.data: + for key in self.data: + h_parts.append(key + "=" + self.data[key]) + + return HEADER_DELIMITER.join(h_parts) + + def ensure_root_exists(self): + """ + Ensures that a root trace id exists by generating one if None. + Return self to allow for chaining. + """ + if self._root is None: + self._root = TraceId().to_id() + return self + + # TODO: remove this hack once LocalStack supports X-Ray integration. + # This hack is only needed because we do not create segment ids in many places, but then expect downstream + # segments to have a valid parent link (e.g., Lambda invocations). + def ensure_parent_exists(self): + """ + Ensures that a parent segment link exists by generating a random one. + Return self to allow for chaining. + """ + if self._parent is None: + self._parent = generate_random_id() + return self + + def ensure_sampled_exists(self, sampled=None): + """ + Ensures that the sampled flag is set. + Return self to allow for chaining. + """ + if sampled is None: + self._sampled = 1 + else: + if sampled == "?": + self._sampled = sampled + if sampled is True or sampled == "1" or sampled == 1: + self._sampled = 1 + if sampled is False or sampled == "0" or sampled == 0: + self._sampled = 0 + return self + + @property + def root(self): + """ + Return trace id of the header + """ + return self._root + + @property + def parent(self): + """ + Return the parent segment id in the header + """ + return self._parent + + @property + def sampled(self): + """ + Return the sampling decision in the header. + It's 0 or 1 or '?'. + """ + return self._sampled + + @property + def lineage(self): + """ + Return the lineage in the header + """ + return self._lineage + + @property + def data(self): + """ + Return the arbitrary fields in the trace header. + """ + return self._data diff --git a/localstack-core/localstack/utils/xray/traceid.py b/localstack-core/localstack/utils/xray/traceid.py new file mode 100644 index 0000000000000..dbc4b0fa7d644 --- /dev/null +++ b/localstack-core/localstack/utils/xray/traceid.py @@ -0,0 +1,38 @@ +# This file is part of LocalStack. +# It is adapted from aws-xray-sdk-python licensed under the Apache License 2.0. +# You may obtain a copy of the Apache License 2.0 at http://www.apache.org/licenses/LICENSE-2.0 +# Original source: https://github.com/aws/aws-xray-sdk-python/blob/master/aws_xray_sdk/core/models/traceid.py + +import binascii +import os +import time + + +class TraceId: + """ + A trace ID tracks the path of a request through your application. + A trace collects all the segments generated by a single request. + A trace ID is required for a segment. + """ + + VERSION = "1" + DELIMITER = "-" + + def __init__(self): + """ + Generate a random trace id. + """ + self.start_time = int(time.time()) + self.__number = binascii.b2a_hex(os.urandom(12)).decode("utf-8") + + def to_id(self): + """ + Convert TraceId object to a string. + """ + return "%s%s%s%s%s" % ( + TraceId.VERSION, + TraceId.DELIMITER, + format(self.start_time, "x"), + TraceId.DELIMITER, + self.__number, + ) diff --git a/localstack-core/mypy.ini b/localstack-core/mypy.ini new file mode 100644 index 0000000000000..5fdadc333f36c --- /dev/null +++ b/localstack-core/mypy.ini @@ -0,0 +1,19 @@ +[mypy] +explicit_package_bases = true +mypy_path=localstack-core +files=localstack/aws/api/core.py,localstack/packages,localstack/services/transcribe,localstack/services/kinesis/packages.py +ignore_missing_imports = False +follow_imports = silent +ignore_errors = False +disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_any_generics = True +disallow_subclassing_any = True +warn_unused_ignores = True + +[mypy-localstack.services.lambda_.invocation.*,localstack.services.lambda_.provider] +ignore_errors = False +disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_any_generics = True +allow_untyped_globals = False diff --git a/localstack/config.py b/localstack/config.py deleted file mode 100644 index c1169098f656a..0000000000000 --- a/localstack/config.py +++ /dev/null @@ -1,168 +0,0 @@ -import re -import os -import socket -import subprocess -import tempfile -from os.path import expanduser -from six import iteritems -from localstack.constants import DEFAULT_SERVICE_PORTS, LOCALHOST, PATH_USER_REQUEST, DEFAULT_PORT_WEB_UI - -# randomly inject faults to Kinesis -KINESIS_ERROR_PROBABILITY = float(os.environ.get('KINESIS_ERROR_PROBABILITY', '').strip() or 0.0) - -# randomly inject faults to DynamoDB -DYNAMODB_ERROR_PROBABILITY = float(os.environ.get('DYNAMODB_ERROR_PROBABILITY', '').strip() or 0.0) - -# expose services on a specific host internally -HOSTNAME = os.environ.get('HOSTNAME', '').strip() or LOCALHOST - -# expose services on a specific host externally -HOSTNAME_EXTERNAL = os.environ.get('HOSTNAME_EXTERNAL', '').strip() or LOCALHOST - -# name of the host under which the LocalStack services are available -LOCALSTACK_HOSTNAME = os.environ.get('LOCALSTACK_HOSTNAME', '').strip() or HOSTNAME - -# whether to remotely copy the lambda or locally mount a volume -LAMBDA_REMOTE_DOCKER = os.environ.get('LAMBDA_REMOTE_DOCKER', '').lower().strip() in ['true', '1'] - -# folder for temporary files and data -TMP_FOLDER = os.path.join(tempfile.gettempdir(), 'localstack') -# fix for Mac OS, to be able to mount /var/folders in Docker -if TMP_FOLDER.startswith('/var/folders/') and os.path.exists('/private%s' % TMP_FOLDER): - TMP_FOLDER = '/private%s' % TMP_FOLDER - -# temporary folder of the host (required when running in Docker). Fall back to local tmp folder if not set -HOST_TMP_FOLDER = os.environ.get('HOST_TMP_FOLDER', TMP_FOLDER) - -# directory for persisting data -DATA_DIR = os.environ.get('DATA_DIR', '').strip() - -# whether to use SSL encryption for the services -USE_SSL = os.environ.get('USE_SSL', '').strip() not in ('0', 'false', '') - -# default encoding used to convert strings to byte arrays (mainly for Python 3 compatibility) -DEFAULT_ENCODING = 'utf-8' - -# path to local Docker UNIX domain socket -DOCKER_SOCK = os.environ.get('DOCKER_SOCK', '').strip() or '/var/run/docker.sock' - -# port of Web UI -PORT_WEB_UI = int(os.environ.get('PORT_WEB_UI', '').strip() or DEFAULT_PORT_WEB_UI) - -# whether to use Lambda functions in a Docker container -LAMBDA_EXECUTOR = os.environ.get('LAMBDA_EXECUTOR', '').strip() -if not LAMBDA_EXECUTOR: - LAMBDA_EXECUTOR = 'local' - try: - if 'Linux' in subprocess.check_output('uname -a'): - LAMBDA_EXECUTOR = 'docker' - except Exception as e: - pass - -# list of environment variable names used for configuration. -# Make sure to keep this in sync with the above! -# Note: do *not* include DATA_DIR in this list, as it is treated separately -CONFIG_ENV_VARS = ['SERVICES', 'HOSTNAME', 'HOSTNAME_EXTERNAL', 'LOCALSTACK_HOSTNAME', - 'LAMBDA_EXECUTOR', 'LAMBDA_REMOTE_DOCKER', 'USE_SSL', 'LICENSE_KEY', 'DEBUG', - 'KINESIS_ERROR_PROBABILITY', 'DYNAMODB_ERROR_PROBABILITY', 'PORT_WEB_UI'] -for key, value in iteritems(DEFAULT_SERVICE_PORTS): - backend_override_var = '%s_BACKEND' % key.upper().replace('-', '_') - if os.environ.get(backend_override_var): - CONFIG_ENV_VARS.append(backend_override_var) - -def in_docker(): - """ Returns: True if running in a docker container, else False """ - if not os.path.exists('/proc/1/cgroup'): - return False - with open('/proc/1/cgroup', 'rt') as ifh: - return 'docker' in ifh.read() - -# determine route to Docker host from container -DOCKER_BRIDGE_IP = '172.17.0.1' -try: - DOCKER_HOST_FROM_CONTAINER = socket.gethostbyname('docker.for.mac.localhost') - # update LOCALSTACK_HOSTNAME if docker.for.mac.localhost is available - if in_docker() and LOCALSTACK_HOSTNAME == DOCKER_BRIDGE_IP: - LOCALSTACK_HOSTNAME = DOCKER_HOST_FROM_CONTAINER -except socket.error: - DOCKER_HOST_FROM_CONTAINER = DOCKER_BRIDGE_IP - -# make sure we default to LAMBDA_REMOTE_DOCKER=true if running in Docker -if in_docker() and not os.environ.get('LAMBDA_REMOTE_DOCKER', '').strip(): - LAMBDA_REMOTE_DOCKER = True - -# local config file path in home directory -CONFIG_FILE_PATH = os.path.join(expanduser("~"), '.localstack') - -# create folders -for folder in [DATA_DIR, TMP_FOLDER]: - if folder and not os.path.exists(folder): - try: - os.makedirs(folder) - except Exception as e: - # this can happen due to a race condition when starting - # multiple processes in parallel. Should be safe to ignore - pass - -# set variables no_proxy, i.e., run internal service calls directly -no_proxy = ','.join(set((LOCALSTACK_HOSTNAME, HOSTNAME, LOCALHOST, '127.0.0.1', '[::1]'))) -if os.environ.get('no_proxy'): - os.environ['no_proxy'] += ',' + no_proxy -elif os.environ.get('NO_PROXY'): - os.environ['NO_PROXY'] += ',' + no_proxy -else: - os.environ['no_proxy'] = no_proxy - -# additional CLI commands, can be set by plugins -CLI_COMMANDS = {} - - -def parse_service_ports(): - """ Parses the environment variable $SERVICE_PORTS with a comma-separated list of services - and (optional) ports they should run on: 'service1:port1,service2,service3:port3' """ - service_ports = os.environ.get('SERVICES', '').strip() - if not service_ports: - return DEFAULT_SERVICE_PORTS - result = {} - for service_port in re.split(r'\s*,\s*', service_ports): - parts = re.split(r'[:=]', service_port) - service = parts[0] - result[service] = int(parts[-1]) if len(parts) > 1 else DEFAULT_SERVICE_PORTS.get(service) - # Fix Elasticsearch port - we have 'es' (AWS ES API) and 'elasticsearch' (actual Elasticsearch API) - if result.get('es') and not result.get('elasticsearch'): - result['elasticsearch'] = DEFAULT_SERVICE_PORTS.get('elasticsearch') - return result - - -def populate_configs(): - global SERVICE_PORTS - - SERVICE_PORTS = parse_service_ports() - - # define service ports and URLs as environment variables - for key, value in iteritems(DEFAULT_SERVICE_PORTS): - key_upper = key.upper().replace('-', '_') - - # define PORT_* variables with actual service ports as per configuration - exec('global PORT_%s; PORT_%s = SERVICE_PORTS.get("%s", 0)' % (key_upper, key_upper, key)) - url = 'http%s://%s:%s' % ('s' if USE_SSL else '', LOCALSTACK_HOSTNAME, SERVICE_PORTS.get(key, 0)) - # define TEST_*_URL variables with mock service endpoints - exec('global TEST_%s_URL; TEST_%s_URL = "%s"' % (key_upper, key_upper, url)) - # expose HOST_*_URL variables as environment variables - os.environ['TEST_%s_URL' % key_upper] = url - - # expose LOCALSTACK_HOSTNAME as env. variable - os.environ['LOCALSTACK_HOSTNAME'] = LOCALSTACK_HOSTNAME - - -def service_port(service_key): - return SERVICE_PORTS.get(service_key, 0) - - -# initialize config values -populate_configs() - - -# set URL pattern of inbound API gateway -INBOUND_GATEWAY_URL_PATTERN = ('%s/restapis/{api_id}/{stage_name}/%s{path}' % - (TEST_APIGATEWAY_URL, PATH_USER_REQUEST)) # flake8: noqa diff --git a/localstack/constants.py b/localstack/constants.py deleted file mode 100644 index 0d442288bc289..0000000000000 --- a/localstack/constants.py +++ /dev/null @@ -1,78 +0,0 @@ -import os -import localstack_client.config - -# LocalStack version -VERSION = '0.8.3' - -# default AWS region -if 'DEFAULT_REGION' not in os.environ: - os.environ['DEFAULT_REGION'] = 'us-east-1' -DEFAULT_REGION = os.environ['DEFAULT_REGION'] - -# constant to represent the "local" region, i.e., local machine -REGION_LOCAL = 'local' - -# dev environment -ENV_DEV = 'dev' - -# backend service ports, for services that are behind a proxy (counting down from 4566) -DEFAULT_PORT_APIGATEWAY_BACKEND = 4566 -DEFAULT_PORT_KINESIS_BACKEND = 4565 -DEFAULT_PORT_DYNAMODB_BACKEND = 4564 -DEFAULT_PORT_S3_BACKEND = 4563 -DEFAULT_PORT_SNS_BACKEND = 4562 -DEFAULT_PORT_SQS_BACKEND = 4561 -DEFAULT_PORT_ELASTICSEARCH_BACKEND = 4560 -DEFAULT_PORT_CLOUDFORMATION_BACKEND = 4559 - -DEFAULT_PORT_WEB_UI = 8080 - -LOCALHOST = 'localhost' - -# version of the Maven dependency with Java utility code -LOCALSTACK_MAVEN_VERSION = '0.1.7' - -# map of default service APIs and ports to be spun up (fetch map from localstack_client) -DEFAULT_SERVICE_PORTS = localstack_client.config.get_service_ports() - -# host to bind to when starting the services -BIND_HOST = '0.0.0.0' - -# AWS user account ID used for tests -TEST_AWS_ACCOUNT_ID = '000000000000' -os.environ['TEST_AWS_ACCOUNT_ID'] = TEST_AWS_ACCOUNT_ID - -# root code folder -LOCALSTACK_ROOT_FOLDER = os.path.realpath(os.path.join(os.path.dirname(os.path.realpath(__file__)), '..')) - -# virtualenv folder -LOCALSTACK_VENV_FOLDER = os.path.join(LOCALSTACK_ROOT_FOLDER, '.venv') -if not os.path.isdir(LOCALSTACK_VENV_FOLDER): - # assuming this package lives here: /lib/pythonX.X/site-packages/localstack/ - LOCALSTACK_VENV_FOLDER = os.path.realpath(os.path.join(LOCALSTACK_ROOT_FOLDER, '..', '..', '..')) - -# API Gateway path to indicate a user request sent to the gateway -PATH_USER_REQUEST = '_user_request_' - -# name of LocalStack Docker image -DOCKER_IMAGE_NAME = 'localstack/localstack' - -# environment variable name to tag local test runs -ENV_INTERNAL_TEST_RUN = 'LOCALSTACK_INTERNAL_TEST_RUN' - -# content types -APPLICATION_AMZ_JSON_1_0 = 'application/x-amz-json-1.0' -APPLICATION_AMZ_JSON_1_1 = 'application/x-amz-json-1.1' -APPLICATION_JSON = 'application/json' - -# Lambda defaults -LAMBDA_TEST_ROLE = 'arn:aws:iam::%s:role/lambda-test-role' % TEST_AWS_ACCOUNT_ID - -# installation constants -ELASTICSEARCH_JAR_URL = 'https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-5.3.0.zip' -DYNAMODB_JAR_URL = 'https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip' -ELASTICMQ_JAR_URL = 'https://s3-eu-west-1.amazonaws.com/softwaremill-public/elasticmq-server-0.13.8.jar' -STS_JAR_URL = 'http://central.maven.org/maven2/com/amazonaws/aws-java-sdk-sts/1.11.14/aws-java-sdk-sts-1.11.14.jar' - -# API endpoint for analytics events -API_ENDPOINT = 'https://api.localstack.cloud/v1' diff --git a/localstack/dashboard/api.py b/localstack/dashboard/api.py deleted file mode 100644 index cd6a1e8abe608..0000000000000 --- a/localstack/dashboard/api.py +++ /dev/null @@ -1,101 +0,0 @@ -import os -import json -from flask import Flask, render_template, jsonify, send_from_directory, request -from flask_swagger import swagger -from localstack.constants import VERSION -from localstack.utils.aws.aws_stack import Environment -from localstack.utils import common -from localstack.dashboard import infra - - -root_path = os.path.dirname(os.path.realpath(__file__)) -web_dir = root_path + '/web/' - -app = Flask('app', template_folder=web_dir) -app.root_path = root_path - - -@app.route('/swagger.json') -def spec(): - swag = swagger(app) - swag['info']['version'] = VERSION - swag['info']['title'] = 'AWS Resources Dashboard' - return jsonify(swag) - - -@app.route('/graph', methods=['POST']) -def get_graph(): - """ Get deployment graph - --- - operationId: 'getGraph' - parameters: - - name: request - in: body - """ - data = get_payload(request) - env = Environment.from_string(data.get('awsEnvironment')) - graph = infra.get_graph(name_filter=data['nameFilter'], env=env) - return jsonify(graph) - - -@app.route('/kinesis///events/latest', methods=['POST']) -def get_kinesis_events(streamName, shardId): - """ Get latest events from Kinesis. - --- - operationId: 'getKinesisEvents' - parameters: - - name: streamName - in: path - - name: shardId - in: path - - name: request - in: body - """ - data = get_payload(request) - env = Environment.from_string(data.get('awsEnvironment')) - result = infra.get_kinesis_events(stream_name=streamName, shard_id=shardId, env=env) - return jsonify(result) - - -@app.route('/lambda//code', methods=['POST']) -def get_lambda_code(functionName): - """ Get source code for Lambda function. - --- - operationId: 'getLambdaCode' - parameters: - - name: functionName - in: path - - name: request - in: body - """ - data = get_payload(request) - env = Environment.from_string(data.get('awsEnvironment')) - result = infra.get_lambda_code(func_name=functionName, env=env) - return jsonify(result) - - -@app.route('/') -def hello(): - return render_template('index.html') - - -@app.route('/') -def send_static(path): - return send_from_directory(web_dir + '/', path) - - -def get_payload(request): - return json.loads(common.to_str(request.data)) - - -def ensure_webapp_installed(): - web_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), 'web')) - node_modules_dir = os.path.join(web_dir, 'node_modules', 'jquery') - if not os.path.exists(node_modules_dir): - print('Initializing installation of Web application (this could take long time, please be patient)') - common.run('cd "%s"; npm install' % web_dir) - - -def serve(port): - ensure_webapp_installed() - app.run(port=int(port), debug=True, threaded=True, host='0.0.0.0') diff --git a/localstack/dashboard/infra.py b/localstack/dashboard/infra.py deleted file mode 100644 index 63fdd6764dd52..0000000000000 --- a/localstack/dashboard/infra.py +++ /dev/null @@ -1,490 +0,0 @@ -import re -import os -import json -import logging -import socket -import tempfile -from localstack.utils.common import (short_uid, parallelize, is_port_open, - rm_rf, unzip, download, clean_cache, mktime, load_file, mkdir, run, md5) -from localstack.utils.aws.aws_models import (ElasticSearch, S3Notification, - EventSource, DynamoDB, DynamoDBStream, FirehoseStream, S3Bucket, SqsQueue, - KinesisShard, KinesisStream, LambdaFunction) -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_str -from localstack.constants import REGION_LOCAL, DEFAULT_REGION -from six import iteritems - - -AWS_CACHE_TIMEOUT = 5 # 5 seconds -AWS_LAMBDA_CODE_CACHE_TIMEOUT = 5 * 60 # 5 minutes -MOCK_OBJ = False -TMP_DOWNLOAD_FILE_PATTERN = os.path.join(tempfile.gettempdir(), 'tmpfile.*') -TMP_DOWNLOAD_CACHE_MAX_AGE = 30 * 60 -last_cache_cleanup_time = {'time': 0} - -# time delta for recent Kinesis events -KINESIS_RECENT_EVENTS_TIME_DIFF_SECS = 60 - -# logger -LOG = logging.getLogger(__name__) - - -def run_cached(cmd, cache_duration_secs=None): - if cache_duration_secs is None: - cache_duration_secs = AWS_CACHE_TIMEOUT - env_vars = os.environ.copy() - env_vars.update({ - 'AWS_ACCESS_KEY_ID': os.environ.get('AWS_ACCESS_KEY_ID') or 'foobar', - 'AWS_SECRET_ACCESS_KEY': os.environ.get('AWS_SECRET_ACCESS_KEY') or 'foobar', - 'AWS_DEFAULT_REGION': os.environ.get('AWS_DEFAULT_REGION') or DEFAULT_REGION, - 'PYTHONWARNINGS': 'ignore:Unverified HTTPS request' - }) - return run(cmd, cache_duration_secs=cache_duration_secs, env_vars=env_vars) - - -def run_aws_cmd(service, cmd_params, env=None, cache_duration_secs=None): - cmd = '%s %s' % (aws_cmd(service, env), cmd_params) - return run_cached(cmd, cache_duration_secs=cache_duration_secs) - - -def cmd_s3api(cmd_params, env): - return run_aws_cmd('s3api', cmd_params, env) - - -def cmd_es(cmd_params, env): - return run_aws_cmd('es', cmd_params, env) - - -def cmd_kinesis(cmd_params, env, cache_duration_secs=None): - return run_aws_cmd('kinesis', cmd_params, env, - cache_duration_secs=cache_duration_secs) - - -def cmd_dynamodb(cmd_params, env): - return run_aws_cmd('dynamodb', cmd_params, env) - - -def cmd_firehose(cmd_params, env): - return run_aws_cmd('firehose', cmd_params, env) - - -def cmd_sqs(cmd_params, env): - return run_aws_cmd('sqs', cmd_params, env) - - -def cmd_lambda(cmd_params, env, cache_duration_secs=None): - return run_aws_cmd('lambda', cmd_params, env, - cache_duration_secs=cache_duration_secs) - - -def aws_cmd(service, env): - # TODO: use boto3 instead of running aws-cli commands here! - - cmd = '{ test `which aws` || . .venv/bin/activate; }; aws' - endpoint_url = None - env = aws_stack.get_environment(env) - if env.region == REGION_LOCAL: - endpoint_url = aws_stack.get_local_service_url(service) - if endpoint_url: - if endpoint_url.startswith('https://'): - cmd += ' --no-verify-ssl' - cmd = '%s --endpoint-url="%s"' % (cmd, endpoint_url) - if not is_port_open(endpoint_url): - raise socket.error() - cmd = '%s %s' % (cmd, service) - return cmd - - -def get_kinesis_streams(filter='.*', pool={}, env=None): - if MOCK_OBJ: - return [] - result = [] - try: - out = cmd_kinesis('list-streams', env) - out = json.loads(out) - for name in out['StreamNames']: - if re.match(filter, name): - details = cmd_kinesis('describe-stream --stream-name %s' % name, env=env) - details = json.loads(details) - arn = details['StreamDescription']['StreamARN'] - stream = KinesisStream(arn) - pool[arn] = stream - stream.shards = get_kinesis_shards(stream_details=details, env=env) - result.append(stream) - except socket.error: - pass - return result - - -def get_kinesis_shards(stream_name=None, stream_details=None, env=None): - if not stream_details: - out = cmd_kinesis('describe-stream --stream-name %s' % stream_name, env) - stream_details = json.loads(out) - shards = stream_details['StreamDescription']['Shards'] - result = [] - for s in shards: - shard = KinesisShard(s['ShardId']) - shard.start_key = s['HashKeyRange']['StartingHashKey'] - shard.end_key = s['HashKeyRange']['EndingHashKey'] - result.append(shard) - return result - - -def get_sqs_queues(filter='.*', pool={}, env=None): - result = [] - try: - out = cmd_sqs('list-queues', env) - if not out.strip(): - return result - queues = json.loads(out)['QueueUrls'] - for q in queues: - name = q.split('/')[-1] - account = q.split('/')[-2] - arn = 'arn:aws:sqs:%s:%s:%s' % (DEFAULT_REGION, account, name) - if re.match(filter, name): - queue = SqsQueue(arn) - result.append(queue) - except socket.error: - pass - return result - - -# TODO move to util -def resolve_string_or_variable(string, code_map): - if re.match(r'^["\'].*["\']$', string): - return string.replace('"', '').replace("'", '') - LOG.warning('Variable resolution not implemented') - return None - - -# TODO move to util -def extract_endpoints(code_map, pool={}): - result = [] - identifiers = [] - for key, code in iteritems(code_map): - # Elasticsearch references - pattern = r'[\'"](.*\.es\.amazonaws\.com)[\'"]' - for es in re.findall(pattern, code): - if es not in identifiers: - identifiers.append(es) - es = EventSource.get(es, pool=pool, type=ElasticSearch) - if es: - result.append(es) - # Elasticsearch references - pattern = r'\.put_record_batch\([^,]+,\s*([^,\s]+)\s*,' - for firehose in re.findall(pattern, code): - if firehose not in identifiers: - identifiers.append(firehose) - firehose = EventSource.get(firehose, pool=pool, type=FirehoseStream) - if firehose: - result.append(firehose) - # DynamoDB references - # TODO fix pattern to be generic - pattern = r'\.(insert|get)_document\s*\([^,]+,\s*([^,\s]+)\s*,' - for (op, dynamo) in re.findall(pattern, code): - dynamo = resolve_string_or_variable(dynamo, code_map) - if dynamo not in identifiers: - identifiers.append(dynamo) - dynamo = EventSource.get(dynamo, pool=pool, type=DynamoDB) - if dynamo: - result.append(dynamo) - # S3 references - pattern = r'\.upload_file\([^,]+,\s*([^,\s]+)\s*,' - for s3 in re.findall(pattern, code): - s3 = resolve_string_or_variable(s3, code_map) - if s3 not in identifiers: - identifiers.append(s3) - s3 = EventSource.get(s3, pool=pool, type=S3Bucket) - if s3: - result.append(s3) - return result - - -def get_lambda_functions(filter='.*', details=False, pool={}, env=None): - if MOCK_OBJ: - return [] - - result = [] - - def handle(func): - func_name = func['FunctionName'] - if re.match(filter, func_name): - arn = func['FunctionArn'] - f = LambdaFunction(arn) - pool[arn] = f - result.append(f) - if details: - sources = get_lambda_event_sources(f.name(), env=env) - for src in sources: - arn = src['EventSourceArn'] - f.event_sources.append(EventSource.get(arn, pool=pool)) - try: - code_map = get_lambda_code(func_name, env=env) - f.targets = extract_endpoints(code_map, pool) - except Exception: - LOG.warning("Unable to get code for lambda '%s'" % func_name) - - try: - out = cmd_lambda('list-functions', env) - out = json.loads(out) - parallelize(handle, out['Functions']) - except socket.error: - pass - return result - - -def get_lambda_event_sources(func_name=None, env=None): - if MOCK_OBJ: - return {} - - cmd = 'list-event-source-mappings' - if func_name: - cmd = '%s --function-name %s' % (cmd, func_name) - out = cmd_lambda(cmd, env=env) - out = json.loads(out) - result = out['EventSourceMappings'] - return result - - -def get_lambda_code(func_name, retries=1, cache_time=None, env=None): - if MOCK_OBJ: - return '' - env = aws_stack.get_environment(env) - if cache_time is None and env.region != REGION_LOCAL: - cache_time = AWS_LAMBDA_CODE_CACHE_TIMEOUT - out = cmd_lambda('get-function --function-name %s' % func_name, env, cache_time) - out = json.loads(out) - loc = out['Code']['Location'] - hash = md5(loc) - folder = TMP_DOWNLOAD_FILE_PATTERN.replace('*', hash) - filename = 'archive.zip' - archive = '%s/%s' % (folder, filename) - try: - mkdir(folder) - if not os.path.isfile(archive): - download(loc, archive, verify_ssl=False) - if len(os.listdir(folder)) <= 1: - zip_path = os.path.join(folder, filename) - unzip(zip_path, folder) - except Exception as e: - print('WARN: %s' % e) - rm_rf(archive) - if retries > 0: - return get_lambda_code(func_name, retries=retries - 1, cache_time=1, env=env) - else: - print('WARNING: Unable to retrieve lambda code: %s' % e) - - # traverse subdirectories and get script sources - result = {} - for root, subdirs, files in os.walk(folder): - for file in files: - prefix = root.split(folder)[-1] - key = '%s/%s' % (prefix, file) - if re.match(r'.+\.py$', key) or re.match(r'.+\.js$', key): - codefile = '%s/%s' % (root, file) - result[key] = load_file(codefile) - - # cleanup cache - clean_cache(file_pattern=TMP_DOWNLOAD_FILE_PATTERN, - last_clean_time=last_cache_cleanup_time, - max_age=TMP_DOWNLOAD_CACHE_MAX_AGE) - # TODO: delete only if cache_time is over - rm_rf(folder) - - return result - - -def get_elasticsearch_domains(filter='.*', pool={}, env=None): - result = [] - try: - out = cmd_es('list-domain-names', env) - out = json.loads(out) - - def handle(domain): - domain = domain['DomainName'] - if re.match(filter, domain): - details = cmd_es('describe-elasticsearch-domain --domain-name %s' % domain, env) - details = json.loads(details)['DomainStatus'] - arn = details['ARN'] - es = ElasticSearch(arn) - es.endpoint = details.get('Endpoint', 'n/a') - result.append(es) - pool[arn] = es - parallelize(handle, out['DomainNames']) - except socket.error: - pass - - return result - - -def get_dynamo_dbs(filter='.*', pool={}, env=None): - result = [] - try: - out = cmd_dynamodb('list-tables', env) - out = json.loads(out) - - def handle(table): - if re.match(filter, table): - details = cmd_dynamodb('describe-table --table-name %s' % table, env) - details = json.loads(details)['Table'] - arn = details['TableArn'] - db = DynamoDB(arn) - db.count = details['ItemCount'] - db.bytes = details['TableSizeBytes'] - db.created_at = details['CreationDateTime'] - result.append(db) - pool[arn] = db - parallelize(handle, out['TableNames']) - except socket.error: - pass - return result - - -def get_s3_buckets(filter='.*', pool={}, details=False, env=None): - result = [] - - def handle(bucket): - bucket_name = bucket['Name'] - if re.match(filter, bucket_name): - arn = 'arn:aws:s3:::%s' % bucket_name - bucket = S3Bucket(arn) - result.append(bucket) - pool[arn] = bucket - if details: - try: - out = cmd_s3api('get-bucket-notification-configuration --bucket %s' % bucket_name, env=env) - if out: - out = json.loads(out) - if 'CloudFunctionConfiguration' in out: - func = out['CloudFunctionConfiguration']['CloudFunction'] - func = EventSource.get(func, pool=pool) - n = S3Notification(func.id) - n.target = func - bucket.notifications.append(n) - except Exception as e: - print('WARNING: Unable to get details for bucket: %s' % e) - - try: - out = cmd_s3api('list-buckets', env) - out = json.loads(out) - parallelize(handle, out['Buckets']) - except socket.error: - pass - return result - - -def get_firehose_streams(filter='.*', pool={}, env=None): - result = [] - try: - out = cmd_firehose('list-delivery-streams', env) - out = json.loads(out) - for stream_name in out['DeliveryStreamNames']: - if re.match(filter, stream_name): - details = cmd_firehose( - 'describe-delivery-stream --delivery-stream-name %s' % stream_name, env) - details = json.loads(details)['DeliveryStreamDescription'] - arn = details['DeliveryStreamARN'] - s = FirehoseStream(arn) - for dest in details['Destinations']: - dest_s3 = dest['S3DestinationDescription']['BucketARN'] - bucket = EventSource.get(dest_s3, pool=pool) - s.destinations.append(bucket) - result.append(s) - except socket.error: - pass - return result - - -def read_kinesis_iterator(shard_iterator, max_results=10, env=None): - data = cmd_kinesis('get-records --shard-iterator %s --limit %s' % - (shard_iterator, max_results), env, cache_duration_secs=0) - data = json.loads(to_str(data)) - result = data - return result - - -def get_kinesis_events(stream_name, shard_id, max_results=10, env=None): - env = aws_stack.get_environment(env) - records = aws_stack.kinesis_get_latest_records(stream_name, shard_id, count=max_results, env=env) - for r in records: - r['ApproximateArrivalTimestamp'] = mktime(r['ApproximateArrivalTimestamp']) - result = { - 'events': records - } - return result - - -def get_graph(name_filter='.*', env=None): - result = { - 'nodes': [], - 'edges': [] - } - - pool = {} - - if True: - result = { - 'nodes': [], - 'edges': [] - } - node_ids = {} - # Make sure we load components in the right order: - # (ES,DynamoDB,S3) -> (Kinesis,Lambda) - domains = get_elasticsearch_domains(name_filter, pool=pool, env=env) - dbs = get_dynamo_dbs(name_filter, pool=pool, env=env) - buckets = get_s3_buckets(name_filter, details=True, pool=pool, env=env) - streams = get_kinesis_streams(name_filter, pool=pool, env=env) - firehoses = get_firehose_streams(name_filter, pool=pool, env=env) - lambdas = get_lambda_functions(name_filter, details=True, pool=pool, env=env) - queues = get_sqs_queues(name_filter, pool=pool, env=env) - - for es in domains: - uid = short_uid() - node_ids[es.id] = uid - result['nodes'].append({'id': uid, 'arn': es.id, 'name': es.name(), 'type': 'es'}) - for b in buckets: - uid = short_uid() - node_ids[b.id] = uid - result['nodes'].append({'id': uid, 'arn': b.id, 'name': b.name(), 'type': 's3'}) - for db in dbs: - uid = short_uid() - node_ids[db.id] = uid - result['nodes'].append({'id': uid, 'arn': db.id, 'name': db.name(), 'type': 'dynamodb'}) - for s in streams: - uid = short_uid() - node_ids[s.id] = uid - result['nodes'].append({'id': uid, 'arn': s.id, 'name': s.name(), 'type': 'kinesis'}) - for shard in s.shards: - uid1 = short_uid() - name = re.sub(r'shardId-0*', '', shard.id) or '0' - result['nodes'].append({'id': uid1, 'arn': shard.id, 'name': name, - 'type': 'kinesis_shard', 'streamName': s.name(), 'parent': uid}) - for f in firehoses: - uid = short_uid() - node_ids[f.id] = uid - result['nodes'].append({'id': uid, 'arn': f.id, 'name': f.name(), 'type': 'firehose'}) - for d in f.destinations: - result['edges'].append({'source': uid, 'target': node_ids[d.id]}) - for q in queues: - uid = short_uid() - node_ids[q.id] = uid - result['nodes'].append({'id': uid, 'arn': q.id, 'name': q.name(), 'type': 'sqs'}) - for l in lambdas: - uid = short_uid() - node_ids[l.id] = uid - result['nodes'].append({'id': uid, 'arn': l.id, 'name': l.name(), 'type': 'lambda'}) - for s in l.event_sources: - lookup_id = s.id - if isinstance(s, DynamoDBStream): - lookup_id = s.table.id - result['edges'].append({'source': node_ids.get(lookup_id), 'target': uid}) - for t in l.targets: - lookup_id = t.id - result['edges'].append({'source': uid, 'target': node_ids.get(lookup_id)}) - for b in buckets: - for n in b.notifications: - src_uid = node_ids[b.id] - tgt_uid = node_ids[n.target.id] - result['edges'].append({'source': src_uid, 'target': tgt_uid}) - - return result diff --git a/localstack/dashboard/web/css/style.css b/localstack/dashboard/web/css/style.css deleted file mode 100644 index 14fcae6385cb6..0000000000000 --- a/localstack/dashboard/web/css/style.css +++ /dev/null @@ -1,212 +0,0 @@ -html { - font-family: Helvetica, Arial, Verdana; - font-size: 13px; -} - -body { - margin: 0px; -} - -#logo { - padding-top: 5px; - padding-bottom: 5px; - padding-left: 20px; -} - -.panel-centered { - width: 100%; - text-align: center; -} - -table.aui-table tr.summary td { - font-weight: bold; -} - -table.aui-table tr.summary { - border-top: 3px solid #cccccc; -} - -h1 { - font-size: 23px; - padding: 8px; - color: #775555; -} - -a.show_hide { - text-decoration: underline; -} - -.fullsize { - width: 100%; - height: 100%; -} - -sidebar div { - padding: 8px; -} - -#infra-panel { - height: auto; - top: 0px; - bottom: 0px; - position: absolute; - margin-top: 83px; -} - -/* elements */ - -/*.aws2 text { - font-weight: bold; -} -.aws2 .body { - fill: #ffffff; - stroke: #31d0c6; - stroke-width: 5px; -} -.aws2 .label { - font-size: 16px; -} -.aws2 .port-body { - stroke: #ffffff; - stroke-width: 3px; - r: 5; - fill: #7c68fc; -} -.aws2 .port-body:hover { - opacity: 1; - fill: #ff7e5d; -} -.aws2 .port-label { - fill: #7c68fc; - display: none; -} -.aws2.Atomic .body { - stroke: #feb663; -} -.aws2.Atomic .label { - fill: #feb663; -} -.link .connection { - stroke: #4B4F6A; - stroke-width: 4px; -} -.link .marker-arrowhead, .link .marker-vertex { - fill: #31D0C6; -} -.link .marker-arrowhead:hover { - fill: #F39C12; -} -.link-tools .tool-remove circle { - fill: #fe854f; -} -.aws2.highlighted-parent .body { - stroke: #fe854f; - transition: stroke 1s; -} -.aws2.highlighted-parent .label { - fill: #fe854f; - transition: fill 1s; - text-decoration: underline; -} -.link-tools { - display: none; -}*/ - -.wordwrap { - word-wrap: break-word; -} - -td.name { - font-weight: bold; -} - -svg { - z-index: 1000; -} -.invisible { - display: none; -} -.plumb { - position: absolute; -} - -a { - cursor: pointer; -} - -div.clear { - clear: both; -} - -div.code-scroller { - max-height: 300px; - overflow: auto; -} - -.code .panel { - border: 1px solid #bbbbbf; - border-radius: 5px; - height: 90%; - overflow: auto; - float: left; - display: block; - padding: 10px; -} -.code .panel-left { - width: 27%; - float: left; -} -.code .panel-left .selected { - font-weight: bold; -} -.code .panel-right { - width: 67%; - float: right; -} -.aui-dialog2-header { - height: 45px; -} -#code-dialog { - width: 70%; - display: block; -} -#code-dialog .code { - height: 100%; - padding-top: 0px; - padding-bottom: 0px; - padding-right: 10px; - padding-left: 10px; -} -#code-dialog .aui-dialog2-footer div { - padding: 0px; -} -#code-dialog .aui-dialog2-content { - padding: 0px; - height: 100%; -} -.plumb * { - word-wrap: break-word; -} - -.layoutnode { - border: 2px solid #bbbbbf; - border-radius: 5px; - padding: 6px; - max-width: 100px; - background: #fafbfe; -} -.layoutnode.selected, .shard.selected { - border: 2px solid #888888; - background: #dddddd; - z-index: 1000; -} -.shard { - position: static; - display: block; - border: 1px solid #aaaaaa; - border-radius: 5px; - padding: 3px; -} -.plumb .title { - font-weight: bold; -} diff --git a/localstack/dashboard/web/img/loading.gif b/localstack/dashboard/web/img/loading.gif deleted file mode 100644 index 99041af0f8ffe..0000000000000 Binary files a/localstack/dashboard/web/img/loading.gif and /dev/null differ diff --git a/localstack/dashboard/web/img/localstack.png b/localstack/dashboard/web/img/localstack.png deleted file mode 100644 index b7f662fabbe80..0000000000000 Binary files a/localstack/dashboard/web/img/localstack.png and /dev/null differ diff --git a/localstack/dashboard/web/img/localstack_icon.png b/localstack/dashboard/web/img/localstack_icon.png deleted file mode 100644 index 350848cb44c30..0000000000000 Binary files a/localstack/dashboard/web/img/localstack_icon.png and /dev/null differ diff --git a/localstack/dashboard/web/img/localstack_small.png b/localstack/dashboard/web/img/localstack_small.png deleted file mode 100644 index 79f0ec81581f5..0000000000000 Binary files a/localstack/dashboard/web/img/localstack_small.png and /dev/null differ diff --git a/localstack/dashboard/web/index.html b/localstack/dashboard/web/index.html deleted file mode 100644 index e76bf4e3a08fa..0000000000000 --- a/localstack/dashboard/web/index.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - Dashboard - - - - - - - - - - - -
- -
-
-
-
-

- - -

-
-
-
-
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/localstack/dashboard/web/js/app.js b/localstack/dashboard/web/js/app.js deleted file mode 100644 index bc5154cb3941a..0000000000000 --- a/localstack/dashboard/web/js/app.js +++ /dev/null @@ -1,41 +0,0 @@ -(function () { - 'use strict'; - - var app = angular.module('app', [ - 'ui.router', - 'ngResource', - 'ngSanitize', - 'angularResizable', - 'tableSort', - 'ui.layout' - ]); - - app.config(function($stateProvider, $urlRouterProvider) { - - $stateProvider. - state('infra', { - url: '/infra', - templateUrl: 'views/infra.html', - controller: 'infraCtrl' - }). - state('infra.graph', { - url: '/graph', - templateUrl: 'views/infra.graph.html', - controller: 'graphCtrl' - }). - state('config', { - url: '/config', - templateUrl: 'views/config.html', - controller: 'configCtrl' - }); - - $urlRouterProvider.otherwise('/infra'); - }); - - app.factory('restClient', function($resource) { - return new SwaggerClient({ - url: "//" + document.location.host + "/swagger.json", - usePromise: true - }); - }); -}()); diff --git a/localstack/dashboard/web/js/joint.defs.js b/localstack/dashboard/web/js/joint.defs.js deleted file mode 100644 index b5aa61d2c656b..0000000000000 --- a/localstack/dashboard/web/js/joint.defs.js +++ /dev/null @@ -1,332 +0,0 @@ -(function () { - - // joint.shapes.aws1 = {}; - - // joint.shapes.aws1.Kinesis = joint.dia.Element.extend({ - - // markup: '', - - // defaults: joint.util.deepSupplement({ - - // type: 'aws1.Kinesis', - // size: { width: 180, height: 70 }, - // attrs: { - - // rect: { width: 170, height: 60 }, - - // '.card': { - // fill: '#FFFFFF', stroke: '#000000', 'stroke-width': 2, - // 'pointer-events': 'visiblePainted', rx: 10, ry: 10 - // }, - - // image: { - // width: 48, height: 48, - // ref: '.card', 'ref-x': 10, 'ref-y': 5 - // }, - - // '.rank': { - // 'text-decoration': 'underline', - // ref: '.card', 'ref-x': 0.9, 'ref-y': 0.2, - // 'font-family': 'Courier New', 'font-size': 14, - // 'text-anchor': 'end' - // }, - - // '.name': { - // 'font-weight': '800', - // ref: '.card', 'ref-x': 0.9, 'ref-y': 0.6, - // 'font-family': 'Courier New', 'font-size': 14, - // 'text-anchor': 'end' - // } - // } - // }, joint.dia.Element.prototype.defaults) - // }); - - // joint.shapes.aws1.KinesisShard = joint.dia.Element.extend({ - - // markup: '', - - // defaults: joint.util.deepSupplement({ - - // type: 'aws1.KinesisShard', - // size: { width: 150, height: 50 }, - // attrs: { - - // rect: { width: 150, height: 50 }, - - // '.card': { - // fill: '#FFFFFF', stroke: '#000000', 'stroke-width': 2, - // 'pointer-events': 'visiblePainted', rx: 10, ry: 10 - // }, - - // '.name': { - // 'font-weight': '800', - // ref: '.card', 'ref-x': 0.9, 'ref-y': 0.6, - // 'font-family': 'Courier New', 'font-size': 14, - // 'text-anchor': 'end' - // } - // } - // }, joint.dia.Element.prototype.defaults) - // }); - - // joint.shapes.aws1.Arrow = joint.dia.Link.extend({ - - // defaults: { - // type: 'aws1.Arrow', - // source: { selector: '.card' }, target: { selector: '.card' }, - // attrs: { '.connection': { stroke: '#585858', 'stroke-width': 3 }}, - // z: -1 - // } - // }); - - - - joint.shapes.aws2 = {}; - - joint.shapes.aws2.Model = joint.shapes.basic.Generic.extend(_.extend({}, joint.shapes.basic.PortsModelInterface, { - - markup: '', - portMarkup: '', - - defaults: joint.util.deepSupplement({ - - type: 'aws2.Model', - size: { width: 1, height: 1 }, - - inPorts: [], - outPorts: [], - - attrs: { - '.': { magnet: false }, - '.body': { - width: 150, - height: 250, - stroke: '#000000' - }, - '.port-body': { - r: 10, - magnet: true, - stroke: '#000000' - }, - text: { - 'pointer-events': 'none' - }, - '.label': { text: 'Model', 'ref-x': .5, 'ref-y': 10, ref: '.body', 'text-anchor': 'middle', fill: '#000000' }, - '.inPorts .port-label': { x:-15, dy: 4, 'text-anchor': 'end', fill: '#000000' }, - '.outPorts .port-label':{ x: 15, dy: 4, fill: '#000000' } - } - - }, joint.shapes.basic.Generic.prototype.defaults), - - getPortAttrs: function(portName, index, total, selector, type) { - - var attrs = {}; - - var portClass = 'port' + index; - var portSelector = selector + '>.' + portClass; - var portLabelSelector = portSelector + '>.port-label'; - var portBodySelector = portSelector + '>.port-body'; - - attrs[portLabelSelector] = { text: portName }; - attrs[portBodySelector] = { port: { id: portName || _.uniqueId(type) , type: type } }; - attrs[portSelector] = { ref: '.body', 'ref-y': (index + 0.5) * (1 / total) }; - - if (selector === '.outPorts') { attrs[portSelector]['ref-dx'] = 0; } - - return attrs; - } - })); - - - joint.shapes.aws2.Atomic = joint.shapes.aws2.Model.extend({ - - defaults: joint.util.deepSupplement({ - - type: 'aws2.Atomic', - size: { width: 80, height: 80 }, - attrs: { - '.body': { fill: 'salmon', r: 10 }, - '.label': { text: 'Atomic' }, - '.inPorts .port-body': { fill: 'PaleGreen' }, - '.outPorts .port-body': { fill: 'Tomato' } - } - - }, joint.shapes.aws2.Model.prototype.defaults) - - }); - - joint.shapes.aws2.Coupled = joint.shapes.aws2.Model.extend({ - - defaults: joint.util.deepSupplement({ - - type: 'aws2.Coupled', - size: { width: 200, height: 300 }, - attrs: { - '.body': { fill: 'seaGreen' }, - '.label': { text: 'Coupled' }, - '.inPorts .port-body': { fill: 'PaleGreen' }, - '.outPorts .port-body': { fill: 'Tomato' } - } - - }, joint.shapes.aws2.Model.prototype.defaults) - }); - - joint.shapes.aws2.Kinesis = joint.shapes.aws2.Coupled; - - joint.shapes.aws2.KinesisShard = joint.shapes.aws2.Atomic; - - joint.shapes.aws2.Link = joint.dia.Link.extend({ - - defaults: { - type: 'aws2.Link', - attrs: { '.connection' : { 'stroke-width' : 2 }} - } - }); - - joint.shapes.aws2.ModelView = joint.dia.ElementView.extend(joint.shapes.basic.PortsViewInterface); - joint.shapes.aws2.AtomicView = joint.shapes.aws2.ModelView; - joint.shapes.aws2.CoupledView = joint.shapes.aws2.ModelView; - - - /* apply extras to the graph */ - - joint.applyExtras = function(graph, paper, params) { - if(!params) params = {}; - - /* returnestrict children to parent's bounding box*/ - graph.on('change:position', function(cell, newPosition, opt) { - var parentId = cell.get('parent'); - if (params.limitChildToParent && parentId) { - var parent = graph.getCell(parentId); - var parentBbox = parent.getBBox(); - var cellBbox = cell.getBBox(); - if (parentBbox.containsPoint(cellBbox.origin()) && - parentBbox.containsPoint(cellBbox.topRight()) && - parentBbox.containsPoint(cellBbox.corner()) && - parentBbox.containsPoint(cellBbox.bottomLeft())) { - return; - } - cell.set('position', cell.previous('position')); - } - if (params.extendParent && !opt.skipParentHandler) { - if (cell.get('embeds') && cell.get('embeds').length) { - // If we're manipulating a parent element, let's store - // it's original position to a special property so that - // we can shrink the parent element back while manipulating - // its children. - cell.set('originalPosition', cell.get('position')); - } - - var parentId = cell.get('parent'); - if (!parentId) return; - - var parent = graph.getCell(parentId); - var parentBbox = parent.getBBox(); - - if (!parent.get('originalPosition')) parent.set('originalPosition', parent.get('position')); - if (!parent.get('originalSize')) parent.set('originalSize', parent.get('size')); - - var originalPosition = parent.get('originalPosition'); - var originalSize = parent.get('originalSize'); - - var newX = originalPosition.x; - var newY = originalPosition.y; - var newCornerX = originalPosition.x + originalSize.width; - var newCornerY = originalPosition.y + originalSize.height; - - _.each(parent.getEmbeddedCells(), function(child) { - - var childBbox = child.getBBox(); - - if (childBbox.x < newX) { newX = childBbox.x; } - if (childBbox.y < newY) { newY = childBbox.y; } - if (childBbox.corner().x > newCornerX) { newCornerX = childBbox.corner().x; } - if (childBbox.corner().y > newCornerY) { newCornerY = childBbox.corner().y; } - }); - - // Note that we also pass a flag so that we know we shouldn't adjust the - // `originalPosition` and `originalSize` in our handlers as a reaction - // on the following `set()` call. - parent.set({ - position: { x: newX, y: newY }, - size: { width: newCornerX - newX, height: newCornerY - newY } - }, { skipParentHandler: true }); - } - }); - - joint.layout.DirectedGraph.layout(graph, { - nodeSep: 50, - edgeSep: 80, - rankDir: "LR", - clusterPadding: { top: 30, left: 10, right: 10, bottom: 10 } - }); - - /* add scrollbars and zooming */ - $(paper.el).on('mousewheel', function(event) { - // console.log(V(paper.viewport).scale()) - // var oldScale = V(paper.viewport).scale().sx; - // var newScale = oldScale + event.originalEvent.deltaY/100; - // console.log(oldScale, newScale) - // var beta = oldScale/newScale; - // //console.log(event); - // var mouseX = event.clientX; - // var mouseY = event.clientY; - // var mouseLocal = V(paper.viewport).toLocalPoint(mouseX, mouseY); - // var p = {x: mouseLocal.x, y: mouseLocal.y}; - // console.log(p); - // ax = p.x - (p.x * beta); - // ay = p.y - (p.y * beta); - // console.log(newScale, newScale, ax, ay) - // paper.scale(newScale, newScale); - - event.preventDefault(); - event = event.originalEvent; - - var delta = Math.max(-1, Math.min(1, (event.wheelDelta || -event.detail))) / 50; - var offsetX = (event.offsetX || event.clientX - $(this).offset().left); // offsetX is not defined in FF - var offsetY = (event.offsetY || event.clientY - $(this).offset().top); // offsetY is not defined in FF - var p = offsetToLocalPoint(offsetX, offsetY); - var newScale = V(paper.viewport).scale().sx + delta; // the current paper scale changed by delta - - if (newScale > 0.4 && newScale < 2) { - //paper.setOrigin(0, 0); // reset the previous viewport translation - paper.scale( - newScale, newScale - //, p.x, p.y - ); - } - }); - function offsetToLocalPoint(x, y) { - return V(paper.viewport).toLocalPoint(x, y); - - var svgPoint = paper.svg.createSVGPoint(); - svgPoint.x = x; - svgPoint.y = y; - // Transform point into the viewport coordinate system. - var pointTransformed = svgPoint.matrixTransform(paper.viewport.getCTM().inverse()); - return pointTransformed; - } - - /* enable paper dragging */ - var dragStartPosition = null; - paper.on('blank:pointerdown', - function(event, x, y) { - dragStartPosition = { x: x, y: y}; - } - ); - paper.on('cell:pointerup blank:pointerup', function(cellView, x, y) { - dragStartPosition = undefined; - delete dragStartPosition; - }); - $(paper.el).mousemove(function(event) { - if (dragStartPosition) { - var dx = event.offsetX - dragStartPosition.x; - var dy = event.offsetY - dragStartPosition.y; - var newX = dx; - var newY = dy; - paper.setOrigin(newX, newY); - } - }); - }; - -})(); - diff --git a/localstack/dashboard/web/js/services.js b/localstack/dashboard/web/js/services.js deleted file mode 100644 index b4c45f0b2817b..0000000000000 --- a/localstack/dashboard/web/js/services.js +++ /dev/null @@ -1,69 +0,0 @@ -(function () { - 'use strict'; - - var app = angular.module('app'); - - app.factory('appConfig', function(restClient) { - var client = restClient; - return { - extractConfigValue: function(key, configs) { - var result = null; - configs.forEach(function(config) { - if(config.key == key) { - result = config.value; - } - }); - return result; - }, - injectConfigValue: function(key, value, configs) { - configs.forEach(function(config) { - if(config.key == key) { - config.value = value; - } - }); - return configs; - }, - getConfigValue: function(key, configs) { - if(configs) - return this.extractConfigValue(key, configs); - var self = this; - return this.getConfig(function(configs) { - var result = self.extractConfigValue(key, configs); - return result; - }); - }, - setConfigValue: function(key, value, configs) { - if(configs) { - this.injectConfigValue(key, value, configs); - return setConfig(configs); - } - var self = this; - return this.getConfig(function(configs) { - self.injectConfigValue(key, value, configs); - return self.setConfig(configs); - }); - }, - getConfig: function(callback) { - return client.then(function(client) { - return client.default.getConfig().then(function(config) { - config = config.obj.config; - if(callback) - return callback(config); - return config; - }); - }); - }, - setConfig: function(config, callback) { - return client.then(function(client) { - return client.default.setConfig({config:config}).then(function(config) { - config = config.obj.config; - if(callback) - return callback(config); - return config; - }); - }); - } - }; - }); - -}()); \ No newline at end of file diff --git a/localstack/dashboard/web/package.json b/localstack/dashboard/web/package.json deleted file mode 100644 index 66285b6d0575d..0000000000000 --- a/localstack/dashboard/web/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "ai-aws-manager", - "author": "Waldemar Hummer, Atlassian ", - "description": "Web app and API to manage AWS resources.", - "version": "0.0.1", - "dependencies": { - "bootstrap": "3.3.7", - "swagger-client": "2.1.32", - "jquery": "2.2.3", - "angular-tablesort": "1.1.2", - "angular": "1.3.12", - "angular-ui-router": "0.2.13", - "angular-resource": "1.3.12", - "angular-sanitize": "1.3.12", - "jsplumb": "2.1.4", - "dagre": "0.7.4", - "angular-resizable": "1.2.0", - "angular-ui-layout": "1.4.2" - } -} diff --git a/localstack/dashboard/web/test/s3_cors_test.html b/localstack/dashboard/web/test/s3_cors_test.html deleted file mode 100644 index 8305fd8e41837..0000000000000 --- a/localstack/dashboard/web/test/s3_cors_test.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - -
test running...
- - - diff --git a/localstack/dashboard/web/views/config.html b/localstack/dashboard/web/views/config.html deleted file mode 100644 index 7f63fae4098fa..0000000000000 --- a/localstack/dashboard/web/views/config.html +++ /dev/null @@ -1,43 +0,0 @@ -
- -
-
-
-

Configuration Settings

-
-
-
- -
-
-
-
-
- - - - - - - - - - - - - - - -
KeyDescriptionValue
{{entry.key}}{{entry.description}}
-
- -
-
-
-
- loading loading data ... -
-
-
-
-
diff --git a/localstack/dashboard/web/views/config.js b/localstack/dashboard/web/views/config.js deleted file mode 100644 index 5225a8b9150b6..0000000000000 --- a/localstack/dashboard/web/views/config.js +++ /dev/null @@ -1,44 +0,0 @@ -(function () { - 'use strict'; - - var app = angular.module('app'); - - app.controller('configCtrl', function($scope, restClient) { - - var client = restClient; - - var setConfigData = function(config) { - $scope.$apply(function(){ - $scope.config = config.config; - }); - }; - - $scope.load = function() { - client.then(function(client) { - $scope.loading = true; - client.default.getConfig().then(function(obj) { - $scope.loading = false; - setConfigData(obj.obj); - }, function(err) { - $scope.loading = false; - console.log(err); - }); - }); - }; - - $scope.save = function() { - client.then(function(client) { - /* load config */ - client.default.setConfig({config:$scope.config}).then(function(obj) { - setConfigData(obj.obj); - }, function(err) { - console.log(err); - }); - }); - }; - - $scope.load(); - - }); - -})(); \ No newline at end of file diff --git a/localstack/dashboard/web/views/infra.details.html b/localstack/dashboard/web/views/infra.details.html deleted file mode 100644 index edf8d627d7820..0000000000000 --- a/localstack/dashboard/web/views/infra.details.html +++ /dev/null @@ -1,100 +0,0 @@ -
- -
-

Selection

- - - - -
Selected Element:{{selection.obj.attrs.name}}
Element Type:{{selection.obj.attrs.type}}
Amazon ARN:{{selection.obj.attrs.arn || 'n/a'}}
-

Element Properties

-
- - - -
Size:{{selection.obj.attrs.instances || 'n/a'}} KB
Number of Objects:{{selection.obj.attrs.instances || 'n/a'}}
-
-
- - -
Number of Instances:{{selection.obj.attrs.instances || 'n/a'}}
- -
-
- -
-
- - -
Indexes:{{selection.obj.attrs.indexes || 0}}
-
-
- - -
Shard ID:{{selection.obj.attrs.arn || 0}}
-

Recent Events

- - - - - - - - - - - - - - - -
TimestampPartition KeyData
{{format_datetime(value.ApproximateArrivalTimestamp * 1000)}}{{value.PartitionKey}}{{trim(value.Data, 100)}}
- -
-
- -
-

Costs

- t.b.a. -
-
- -
-
-
-
-

Lambda Function Source Code

-
-
-

{{state.lambda.dialog.data.text}}

-
-
- Files -
    -
  • - {{file}} -
  • -
-
-
- Code -
-
{{state.lambda.data[selection.obj.attrs.arn][state.lambda.dialog.selectedFile]}}
-              
-
-
-
-
-
-
-
- -
-
-
-
-
-
diff --git a/localstack/dashboard/web/views/infra.details.js b/localstack/dashboard/web/views/infra.details.js deleted file mode 100644 index 462562bfe1780..0000000000000 --- a/localstack/dashboard/web/views/infra.details.js +++ /dev/null @@ -1,93 +0,0 @@ -(function () { - 'use strict' - - var app = angular.module('app'); - - app.controller('infraDetailsCtrl', function($scope, appConfig, restClient) { - - var client = restClient; - $scope.state.kinesis = { - data: {} - }; - $scope.state.lambda = { - data: {} - }; - - var codeDialog = $scope.state.lambda.dialog = function(title, text, callback, cancelCallback) { - codeDialog.title = title; - codeDialog.text = text; - codeDialog.callback = callback; - codeDialog.visible = true; - codeDialog.ok = function() { - codeDialog.visible = false; - if(callback) callback(); - }; - codeDialog.cancel = function() { - codeDialog.visible = false; - if(cancelCallback) cancelCallback(); - }; - }; - - $scope.showLambdaCode = function() { - client.then(function(client) { - $scope.state.lambda.loading = true; - - var attrs = $scope.selection.obj.attrs; - var params = { - functionName: attrs.name, - request: { - awsEnvironment: $scope.settings.localEndpoints ? 'dev' : 'prod' - } - }; - $scope.state.lambda.data[attrs.arn] = []; - - client.default.getLambdaCode(params).then(function(obj) { - $scope.state.lambda.loading = false; - $scope.state.lambda.data[attrs.arn] = obj.obj; - $scope.state.lambda.dialog(); - $scope.$apply(); - }, function(err) { - $scope.state.lambda.loading = false; - $scope.status = "An error has occurred, could not load data from the service."; - $scope.$apply(); - }); - - $scope.$apply(); - }); - }; - - $scope.getKinesisEvents = function() { - - client.then(function(client) { - $scope.state.kinesis.loading = true; - - var attrs = $scope.selection.obj.attrs; - if(!attrs.dataKey) { - attrs.dataKey = attrs.streamName + ":" + attrs.arn - } - var params = { - streamName: attrs.streamName, - shardId: attrs.arn, - request: { - awsEnvironment: $scope.settings.localEndpoints ? 'dev' : 'prod' - } - }; - $scope.state.kinesis.data[attrs.dataKey] = []; - - client.default.getKinesisEvents(params).then(function(obj) { - $scope.state.kinesis.loading = false; - $scope.state.kinesis.data[attrs.dataKey] = obj.obj.events; - $scope.$apply(); - }, function(err) { - $scope.state.kinesis.loading = false; - $scope.status = "An error has occurred, could not load data from the service."; - $scope.$apply(); - }); - - $scope.$apply(); - }); - }; - - }); - -})(); diff --git a/localstack/dashboard/web/views/infra.graph.html b/localstack/dashboard/web/views/infra.graph.html deleted file mode 100644 index 8faca5dccf102..0000000000000 --- a/localstack/dashboard/web/views/infra.graph.html +++ /dev/null @@ -1,9 +0,0 @@ -
- -
- Loading Retrieving deployment model from AWS resources. - This can take a long time, please be patient and do not reload this page... -
-
{{status}}
-
-
diff --git a/localstack/dashboard/web/views/infra.graph.js b/localstack/dashboard/web/views/infra.graph.js deleted file mode 100644 index e51a7374dd4b6..0000000000000 --- a/localstack/dashboard/web/views/infra.graph.js +++ /dev/null @@ -1,245 +0,0 @@ -(function () { - 'use strict' - - var app = angular.module('app'); - - app.controller('graphCtrl', function($scope, $http, appConfig, restClient) { - - var client = restClient; - var paper = null; - var graph = null; - var graphData = null; - - var canvas = $('#graph'); - - var resize = function() { - return; - paper.setDimensions(canvas.width(), canvas.height()); - }; - - var drawGraph = function() { - - if(!graphData) return; - - canvas.html(''); - - jsPlumb.ready(function () { - - var j = jsPlumb.getInstance({Container:canvas, Connector:"StateMachine", Endpoint:["Dot", {radius:3}], Anchor:"Center"}); - - var templates = {}; - $http({ - url: "/views/templates.html" - }).success(function (data, status, headers, config) { - - /* map of elements to render */ - var components = {}; - /* graph margins */ - var marginLeft = 20; - var marginTop = 20; - - - data = $.parseHTML(data); - $(data).children().each(function(i,c) { - var id = $(c).attr('id'); - $(c).attr('id', null); - var src = $('
').append($(c).clone()).html(); - templates[id] = src; - }); - - function render(type, params, nondraggable) { - if(!params['type']) { - params['type'] = type; - } - var src = templates[type]; - if(!src) { - console.log("ERROR: Unable to find template:", type) - } - for(var key in params) { - src = src.replace('{{' + key + '}}', params[key]); - } - var el = $.parseHTML(src)[0]; - el.attrs = params; - if(params['parent']) { - var parent = components[params['parent']]; - $(parent).find('.children').append(el); - } else { - canvas.append(el); - } - if(!nondraggable) { - j.draggable(el); - } - return el; - } - - function connect(el1, el2, invisible) { - j.connect({ - source: el1, target: el2, - anchor:[ "Continuous", { - faces:["top", "bottom", "left", "right"] - }], - overlays: [ - [ "PlainArrow", { location: 1 }, { cssClass: invisible ? "invisible" : "" } ], - [ "Label", { cssClass: "TODO" } ] - ], - cssClass: invisible ? "invisible" : "" - }); - } - - function layout() { - - // construct dagre graph from JsPlumb graph - var g = new dagre.graphlib.Graph(); - g.setGraph({ - 'rankdir': 'LR', - 'nodesep': 30, - 'ranksep': 70 - }); - g.setDefaultEdgeLabel(function() { return {}; }); - var nodes = $(".plumb"); - nodes.each(function(i,n) { - var n = nodes[i]; - var width = $(n).width(); - var height = $(n).height(); - g.setNode(n.id, { width: width, height: height }); - }); - var edges = j.getAllConnections(); - for (var i = 0; i < edges.length; i++) { - var c = edges[i]; - g.setEdge(c.source.id, c.target.id ); - } - dagre.layout(g); - // Applying the calculated layout - g.nodes().forEach(function(v) { - $("#" + v).css("left", marginLeft + (g.node(v).x - ($("#" + v).width() / 2)) + "px"); - $("#" + v).css("top", marginTop + (g.node(v).y - ($("#" + v).height() / 2)) + "px"); - }); - } - - function isConnected(node, edges) { - for(var i = 0; i < edges.length; i ++) { - var edge = edges[i]; - if(edge.target == node.id || edge.source == node.id) { - return true; - } - } - return false; - } - - var hideDisconnected = $scope.settings.hideDisconnected; - graphData.nodes.forEach(function(node) { - if(!hideDisconnected || node.parent || isConnected(node, graphData.edges)) { - var el = render(node.type, node); - components[node.id] = el; - } - }); - graphData.edges.forEach(function(edge) { - var src = components[edge.source]; - var tgt = components[edge.target]; - connect(src, tgt); - }); - - function repaint () { - /* repainting a single time does not seem to work */ - setTimeout(function(){ for(var i = 0; i < 5; i ++) { j.repaintEverything(); } }); - } - - - // var m1 = render('micros', {name: 'Feeder 1'}); - // var k1 = render('kinesis', {'name': 'Kinesis 1'}); - // var ks1 = render('kinesis_shard', {'name': 'Shard 1'}, true); - // var ks2 = render('kinesis_shard', {'name': 'Shard 2'}, true); - // $(k1).select('.shards').append(ks1); - // $(k1).select('.shards').append(ks2); - // var l1 = render('lambda', {'name': 'Lambda (raw)'}); - // var l2 = render('lambda', {'name': 'Lambda (conformed)'}); - // var b1 = render('s3', {'name': 'Raw Bucket'}); - // var b2 = render('s3', {'name': 'Conformed Bucket'}); - // var e1 = render('es', {'name': 'Search Index'}); - - // connect(m1, ks1); - // connect(m1, ks2); - // connect(k1, l1, true); - // connect(ks1, l1); - // connect(ks2, l1); - // connect(l1, b1); - // connect(b1, l2); - // connect(l2, b2); - // connect(l2, e1); - - layout(); - - $scope.selection.obj = null; - $(".selectnode").mousedown(function(e) { - $(".selected").removeClass("selected"); - var node = $(e.target).closest('.layoutnode').get(0); - $(node).addClass("selected"); - var selectionNode = $(e.target).closest('.selectnode').get(0); - $(selectionNode).addClass("selected"); - $scope.selection.obj = selectionNode; - $scope.$parent.$parent.$apply(); - }); - - $("#graph").mousedown(function(e) { - $(".selected").removeClass("selected"); - $scope.selection.obj = null; - $scope.$parent.$parent.$apply(); - }); - - $(".show_hide").click(function(e){ - $(e.target).closest(".layoutnode").find(".children").toggle(); - var val = $(e.target).text(); - if(val.indexOf('show') >= 0) { - val = val.replace('show', 'hide'); - } else { - val = val.replace('hide', 'show'); - } - $(e.target).text(val); - repaint(); - }); - - repaint(); - - }); - - }); - - return; - }; - - $scope.actions.loadGraph = function() { - graphData = null; - client.then(function(client) { - $scope.loading = true; - $scope.status = null; - - var params = { - nameFilter: $scope.settings.nameFilter, - awsEnvironment: $scope.settings.localEndpoints ? 'dev' : 'prod' - }; - client.default.getGraph({request: params}).then(function(obj) { - $scope.loading = false; - graphData = obj.obj - drawGraph(); - $scope.$apply(); - }, function(err) { - $scope.loading = false; - $scope.status = "An error has occurred, could not load data from the service."; - $scope.$apply(); - }); - - $scope.$apply(); - }); - }; - - /* re-draw graph on settings change */ - $scope.$watch('settings.hideDisconnected', function(newValue) { - $scope.selection.obj = null; - drawGraph(); - }); - - $scope.actions.loadGraph(); - - }); - -})(); diff --git a/localstack/dashboard/web/views/infra.html b/localstack/dashboard/web/views/infra.html deleted file mode 100644 index 19aa69914b83e..0000000000000 --- a/localstack/dashboard/web/views/infra.html +++ /dev/null @@ -1,50 +0,0 @@ -
- -
-
-
- -
- - - -
-
-

Settings

- - - - - -
Hide disconnected nodes
Local AWS Endpoints
Name filter
-
-
-
- -
-
-
- - -
-
- -
-
- -
-
-
-
- -
-
-
-
-

Overview of Deployed Resources

-
-
-
-
- -
diff --git a/localstack/dashboard/web/views/infra.js b/localstack/dashboard/web/views/infra.js deleted file mode 100644 index ab9bff714f3a6..0000000000000 --- a/localstack/dashboard/web/views/infra.js +++ /dev/null @@ -1,42 +0,0 @@ -(function () { - 'use strict' - - var app = angular.module('app'); - - app.controller('infraCtrl', function($scope, appConfig, restClient) { - - $scope.selection = {}; - $scope.actions = {}; - $scope.state = {}; - $scope.settings = { - hideDisconnected: false, - localEndpoints: true, - nameFilter: '.*' - }; - - $scope.trim = function(string, maxLength) { - if(typeof maxLength == 'undefined') { - maxLength = 80; - } - return string.length <= maxLength ? - string : string.substring(0, maxLength - 3) + "..."; - } - - $scope.format_datetime = function(ms) { - var a = new Date(parseInt(ms)); - //var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - //var month = months[a.getMonth()]; - var month = a.getMonth() + 1; - month = month < 10 ? '0' + month : month; - var year = a.getFullYear(); - var date = a.getDate() < 10 ? '0' + a.getDate() : a.getDate(); - var hour = a.getHours() < 10 ? '0' + a.getHours() : a.getHours(); - var min = a.getMinutes() < 10 ? '0' + a.getMinutes() : a.getMinutes(); - var sec = a.getSeconds() < 10 ? '0' + a.getSeconds() : a.getSeconds(); - var time = year + '-' + month + '-' + date + ' ' + hour + ':' + min + ':' + sec ; - return time; - }; - - }); - -})(); diff --git a/localstack/dashboard/web/views/templates.html b/localstack/dashboard/web/views/templates.html deleted file mode 100644 index 55c460d9d7857..0000000000000 --- a/localstack/dashboard/web/views/templates.html +++ /dev/null @@ -1,52 +0,0 @@ -
- -
-
Kinesis
-
{{name}}
- show shards -
-
-
- -
-
Shard '{{name}}'
-
- -
-
Kinesis Firehose
-
{{name}}
-
-
-
- -
-
Lambda
-
{{name}}
-
- -
-
S3 Bucket
-
{{name}}
-
- -
-
Elasticsearch
-
{{name}}
-
- -
-
Micros
-
{{name}}
-
- -
-
DynamoDB
-
{{name}}
-
- -
-
SQS
-
{{name}}
-
- -
\ No newline at end of file diff --git a/localstack/ext/java/.dockerignore b/localstack/ext/java/.dockerignore deleted file mode 100644 index 5598e8899a5fb..0000000000000 --- a/localstack/ext/java/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -/target/ -.idea -*.iml -*.ipr -.classpath -.settings -.project \ No newline at end of file diff --git a/localstack/ext/java/.gitignore b/localstack/ext/java/.gitignore deleted file mode 100644 index 5598e8899a5fb..0000000000000 --- a/localstack/ext/java/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/target/ -.idea -*.iml -*.ipr -.classpath -.settings -.project \ No newline at end of file diff --git a/localstack/ext/java/pom.xml b/localstack/ext/java/pom.xml deleted file mode 100644 index 7e41d0ad6f1d6..0000000000000 --- a/localstack/ext/java/pom.xml +++ /dev/null @@ -1,234 +0,0 @@ - - 4.0.0 - - cloud.localstack - localstack-utils - jar - 0.1.7 - localstack-utils - - Java utilities for the LocalStack platform. - http://localstack.cloud - - - whummer - Waldemar Hummer - - - - - Apache License 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - - - - https://github.com/localstack/localstack - - - - 1.8 - 1.8 - - - - - junit - junit - 4.12 - - - org.apache.commons - commons-lang3 - 3.5 - - - commons-io - commons-io - 2.5 - - - - net.java.dev.jna - jna - 4.1.0 - compile - - - org.jvnet.winp - winp - 1.23 - - - - com.amazonaws - aws-java-sdk - 1.11.125 - provided - - - com.amazonaws - aws-lambda-java-core - 1.1.0 - - - com.amazonaws - aws-lambda-java-events - 1.3.0 - - - com.amazonaws - aws-java-sdk-s3 - - - com.amazonaws - aws-java-sdk-sns - - - com.amazonaws - aws-java-sdk-kinesis - - - com.amazonaws - aws-java-sdk-dynamodb - - - - - - - com.amazonaws - amazon-sqs-java-messaging-lib - 1.0.2 - jar - test - - - org.testcontainers - testcontainers - 1.4.2 - test - - - ch.qos.logback - logback-classic - 1.0.13 - test - - - - - - fatjar - - - com.amazonaws - aws-java-sdk - 1.11.125 - - - - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.0.0-M1 - - -Xdoclint:none - - - - org.apache.maven.plugins - maven-shade-plugin - 3.0.0 - - - package - - shade - - - - - true - false - true - fat - - - com.amazonaws:aws-java-sdk-core - com.amazonaws:aws-java-sdk-kinesis - com.amazonaws:aws-java-sdk-lambda - com.amazonaws:aws-lambda-java-events - com.amazonaws:aws-lambda-java-core - commons-*:* - net.*:* - org.*:* - junit:junit - com.fasterxml.*:* - joda-time:* - com.jayway.*:* - software.*:* - - - - - - org.apache.maven.plugins - maven-jar-plugin - 3.0.2 - - - - test-jar - - - - - - org.apache.maven.plugins - maven-source-plugin - 3.0.1 - - - org.apache.maven.plugins - maven-gpg-plugin - 1.5 - - - sign-artifacts - verify - - sign - - - - - - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.7 - true - - ossrh - https://oss.sonatype.org/ - true - - - - - - - - ossrh - https://oss.sonatype.org/content/repositories/snapshots - - - ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2/ - - - diff --git a/localstack/ext/java/src/main/java/cloud/localstack/DockerTestUtils.java b/localstack/ext/java/src/main/java/cloud/localstack/DockerTestUtils.java deleted file mode 100644 index 707eaa64bd0fa..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/DockerTestUtils.java +++ /dev/null @@ -1,65 +0,0 @@ -package cloud.localstack; - -import static cloud.localstack.TestUtils.getCredentialsProvider; -import static cloud.localstack.TestUtils.getEndpointConfiguration; - -import java.util.function.Supplier; - -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; -import com.amazonaws.services.kinesis.AmazonKinesis; -import com.amazonaws.services.kinesis.AmazonKinesisClientBuilder; -import com.amazonaws.services.lambda.AWSLambda; -import com.amazonaws.services.lambda.AWSLambdaClientBuilder; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.sqs.AmazonSQS; -import com.amazonaws.services.sqs.AmazonSQSClientBuilder; - -import cloud.localstack.docker.LocalstackDockerTestRunner; - -public class DockerTestUtils { - - - public static AmazonSQS getClientSQS() { - return AmazonSQSClientBuilder.standard(). - withEndpointConfiguration(createEndpointConfiguration(LocalstackDockerTestRunner::getEndpointSQS)). - withCredentials(getCredentialsProvider()).build(); - } - - - public static AWSLambda getClientLambda() { - return AWSLambdaClientBuilder.standard(). - withEndpointConfiguration(createEndpointConfiguration(LocalstackDockerTestRunner::getEndpointLambda)). - withCredentials(getCredentialsProvider()).build(); - } - - - public static AmazonS3 getClientS3() { - AmazonS3ClientBuilder builder = AmazonS3ClientBuilder.standard() - .withEndpointConfiguration(createEndpointConfiguration(LocalstackDockerTestRunner::getEndpointS3)) - .withCredentials(getCredentialsProvider()); - builder.setPathStyleAccessEnabled(true); - return builder.build(); - } - - - public static AmazonKinesis getClientKinesis() { - return AmazonKinesisClientBuilder.standard() - .withEndpointConfiguration(createEndpointConfiguration(LocalstackDockerTestRunner::getEndpointKinesis)) - .withCredentials(getCredentialsProvider()).build(); - } - - - public static AmazonDynamoDB getClientDynamoDb() { - return AmazonDynamoDBClientBuilder.standard() - .withEndpointConfiguration(createEndpointConfiguration(LocalstackDockerTestRunner::getEndpointDynamoDB)) - .withCredentials(getCredentialsProvider()).build(); - } - - - private static AwsClientBuilder.EndpointConfiguration createEndpointConfiguration(Supplier supplier) { - return getEndpointConfiguration(supplier.get()); - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/LambdaContext.java b/localstack/ext/java/src/main/java/cloud/localstack/LambdaContext.java deleted file mode 100644 index e80fabe6d77d7..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/LambdaContext.java +++ /dev/null @@ -1,73 +0,0 @@ -package cloud.localstack; - -import java.util.logging.Level; -import java.util.logging.Logger; - -import com.amazonaws.services.lambda.runtime.ClientContext; -import com.amazonaws.services.lambda.runtime.CognitoIdentity; -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.LambdaLogger; - -public class LambdaContext implements Context { - - private final Logger LOG = Logger.getLogger(LambdaContext.class.getName()); - - public LambdaLogger getLogger() { - return new LambdaLogger() { - public void log(String msg) { - LOG.log(Level.INFO, msg); - } - }; - } - - public String getAwsRequestId() { - // TODO Auto-generated method stub - return null; - } - - public ClientContext getClientContext() { - // TODO Auto-generated method stub - return null; - } - - public String getFunctionName() { - // TODO Auto-generated method stub - return null; - } - - public String getFunctionVersion() { - // TODO Auto-generated method stub - return null; - } - - public CognitoIdentity getIdentity() { - // TODO Auto-generated method stub - return null; - } - - public String getInvokedFunctionArn() { - // TODO Auto-generated method stub - return null; - } - - public String getLogGroupName() { - // TODO Auto-generated method stub - return null; - } - - public String getLogStreamName() { - // TODO Auto-generated method stub - return null; - } - - public int getMemoryLimitInMB() { - // TODO Auto-generated method stub - return 0; - } - - public int getRemainingTimeInMillis() { - // TODO Auto-generated method stub - return 0; - } - -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/LambdaExecutor.java b/localstack/ext/java/src/main/java/cloud/localstack/LambdaExecutor.java deleted file mode 100644 index 1bf773962d813..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/LambdaExecutor.java +++ /dev/null @@ -1,128 +0,0 @@ -package cloud.localstack; - -import com.amazonaws.services.lambda.runtime.RequestStreamHandler; -import com.amazonaws.services.lambda.runtime.events.SNSEvent; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.OutputStream; -import java.lang.reflect.InvocationTargetException; -import java.nio.ByteBuffer; -import java.util.Base64; -import java.util.Date; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.KinesisEvent; -import com.amazonaws.services.lambda.runtime.events.KinesisEvent.KinesisEventRecord; -import com.amazonaws.services.lambda.runtime.events.KinesisEvent.Record; -import com.amazonaws.util.StringInputStream; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.commons.codec.Charsets; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.StringUtils; -import org.joda.time.DateTime; - -/** - * Simple implementation of a Java Lambda function executor. - * - * @author Waldemar Hummer - */ -public class LambdaExecutor { - - @SuppressWarnings("unchecked") - public static void main(String[] args) throws Exception { - if(args.length < 2) { - System.err.println("Usage: java " + LambdaExecutor.class.getSimpleName() + - " "); - System.exit(1); - } - - String fileContent = readFile(args[1]); - ObjectMapper reader = new ObjectMapper(); - @SuppressWarnings("deprecation") - Map map = reader.reader(Map.class).readValue(fileContent); - - List> records = (List>) get(map, "Records"); - @SuppressWarnings("rawtypes") - Object inputObject = map; - - if (records != null) { - if (records.stream().filter(record -> record.containsKey("Kinesis")).count() > 0) { - KinesisEvent kinesisEvent = new KinesisEvent(); - inputObject = kinesisEvent; - kinesisEvent.setRecords(new LinkedList<>()); - for (Map record : records) { - KinesisEventRecord r = new KinesisEventRecord(); - kinesisEvent.getRecords().add(r); - Record kinesisRecord = new Record(); - Map kinesis = (Map) get(record, "Kinesis"); - String dataString = new String(get(kinesis, "Data").toString().getBytes()); - byte[] decodedData = Base64.getDecoder().decode(dataString); - kinesisRecord.setData(ByteBuffer.wrap(decodedData)); - kinesisRecord.setPartitionKey((String) get(kinesis, "PartitionKey")); - kinesisRecord.setApproximateArrivalTimestamp(new Date()); - r.setKinesis(kinesisRecord); - } - } else if (records.stream().filter(record -> record.containsKey("Sns")).count() > 0) { - SNSEvent snsEvent = new SNSEvent(); - inputObject = snsEvent; - snsEvent.setRecords(new LinkedList<>()); - for (Map record : records) { - SNSEvent.SNSRecord r = new SNSEvent.SNSRecord(); - snsEvent.getRecords().add(r); - SNSEvent.SNS snsRecord = new SNSEvent.SNS(); - Map sns = (Map) get(record, "Sns"); - snsRecord.setMessage((String) get(sns, "Message")); - snsRecord.setMessageAttributes((Map) get(sns, "MessageAttributes")); - snsRecord.setType("Notification"); - snsRecord.setTimestamp(new DateTime()); - r.setSns(snsRecord); - } - } - //TODO: Support other events (S3, SQS...) - } - - Object handler = getHandler(args[0]); - Context ctx = new LambdaContext(); - if (handler instanceof RequestHandler) { - Object result = ((RequestHandler) handler).handleRequest(inputObject, ctx); - // The contract with lambci is to print the result to stdout, whereas logs go to stderr - System.out.println(result); - } else if (handler instanceof RequestStreamHandler) { - OutputStream os = new ByteArrayOutputStream(); - ((RequestStreamHandler) handler).handleRequest( - new StringInputStream(fileContent), os, ctx); - System.out.println(os); - } - } - - private static Object getHandler(String handlerName) throws NoSuchMethodException, IllegalAccessException, - InvocationTargetException, InstantiationException, ClassNotFoundException { - Class clazz = Class.forName(handlerName); - return clazz.getConstructor().newInstance(); - } - - private static T get(Map map, String key) { - T result = map.get(key); - if(result != null) { - return result; - } - key = StringUtils.uncapitalize(key); - result = map.get(key); - if(result != null) { - return result; - } - return map.get(key.toLowerCase()); - } - - private static String readFile(String file) throws Exception { - if(!file.startsWith("/")) { - file = System.getProperty("user.dir") + "/" + file; - } - return FileUtils.readFileToString(new File(file), Charsets.UTF_8); - } - -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/LocalstackTestRunner.java b/localstack/ext/java/src/main/java/cloud/localstack/LocalstackTestRunner.java deleted file mode 100644 index 0d915f0d5f225..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/LocalstackTestRunner.java +++ /dev/null @@ -1,282 +0,0 @@ -package cloud.localstack; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.logging.Logger; - -import org.apache.commons.io.FileUtils; -import org.junit.runner.notification.RunNotifier; -import org.junit.runners.BlockJUnit4ClassRunner; -import org.junit.runners.model.InitializationError; -import org.ow2.proactive.process_tree_killer.ProcessTree; - -import com.amazonaws.util.IOUtils; - -/** - * Simple JUnit test runner that automatically downloads, installs, starts, - * and stops the LocalStack local cloud infrastructure components. - * - * Should work cross-OS, however has been only tested under Unix (Linux/MacOS). - * - * @author Waldemar Hummer - */ -public class LocalstackTestRunner extends BlockJUnit4ClassRunner { - private static final Logger LOG = Logger.getLogger(LocalstackTestRunner.class.getName()); - - private static final AtomicReference INFRA_STARTED = new AtomicReference(); - - private static final String INFRA_READY_MARKER = "Ready."; - private static final String TMP_INSTALL_DIR = System.getProperty("java.io.tmpdir") + - File.separator + "localstack_install_dir"; - private static final String ADDITIONAL_PATH = "/usr/local/bin/"; - private static final String LOCALSTACK_REPO_URL = "https://github.com/localstack/localstack"; - - public static final String ENV_CONFIG_USE_SSL = "USE_SSL"; - private static final String ENV_LOCALSTACK_PROCESS_GROUP = "ENV_LOCALSTACK_PROCESS_GROUP"; - - public LocalstackTestRunner(Class klass) throws InitializationError { - super(klass); - } - - /* SERVICE ENDPOINTS */ - - public static String getEndpointS3() { - String s3Endpoint = ensureInstallationAndGetEndpoint(ServiceName.S3); - /* - * Use the domain name wildcard *.localhost.atlassian.io which maps to 127.0.0.1 - * We need to do this because S3 SDKs attempt to access a domain . - * which by default would result in .localhost, but that name cannot be resolved - * (unless hardcoded in /etc/hosts) - */ - s3Endpoint = s3Endpoint.replace("localhost", "test.localhost.atlassian.io"); - return s3Endpoint; - } - - public static String getEndpointKinesis() { - return ensureInstallationAndGetEndpoint(ServiceName.KINESIS); - } - - public static String getEndpointLambda() { - return ensureInstallationAndGetEndpoint(ServiceName.LAMBDA); - } - - public static String getEndpointDynamoDB() { - return ensureInstallationAndGetEndpoint(ServiceName.DYNAMO); - } - - public static String getEndpointDynamoDBStreams() { - return ensureInstallationAndGetEndpoint(ServiceName.DYNAMO_STREAMS); - } - - public static String getEndpointAPIGateway() { - return ensureInstallationAndGetEndpoint(ServiceName.API_GATEWAY); - } - - public static String getEndpointElasticsearch() { - return ensureInstallationAndGetEndpoint(ServiceName.ELASTICSEARCH); - } - - public static String getEndpointElasticsearchService() { - return ensureInstallationAndGetEndpoint(ServiceName.ELASTICSEARCH_SERVICE); - } - - public static String getEndpointFirehose() { - return ensureInstallationAndGetEndpoint(ServiceName.FIREHOSE); - } - - public static String getEndpointSNS() { - return ensureInstallationAndGetEndpoint(ServiceName.SNS); - } - - public static String getEndpointSQS() { - return ensureInstallationAndGetEndpoint(ServiceName.SQS); - } - - public static String getEndpointRedshift() { - return ensureInstallationAndGetEndpoint(ServiceName.REDSHIFT); - } - - public static String getEndpointSES() { - return ensureInstallationAndGetEndpoint(ServiceName.SES); - } - - public static String getEndpointRoute53() { - return ensureInstallationAndGetEndpoint(ServiceName.ROUTE53); - } - - public static String getEndpointCloudFormation() { - return ensureInstallationAndGetEndpoint(ServiceName.CLOUDFORMATION); - } - - public static String getEndpointCloudWatch() { - return ensureInstallationAndGetEndpoint(ServiceName.CLOUDWATCH); - } - - public static String getEndpointSSM() { - return ensureInstallationAndGetEndpoint(ServiceName.SSM); - } - - @Override - public void run(RunNotifier notifier) { - setupInfrastructure(); - super.run(notifier); - } - - /* UTILITY METHODS */ - - private static void ensureInstallation() { - File dir = new File(TMP_INSTALL_DIR); - File constantsFile = new File(dir, "localstack/constants.py"); - String logMsg = "Installing LocalStack to temporary directory (this may take a while): " + TMP_INSTALL_DIR; - boolean messagePrinted = false; - if(!constantsFile.exists()) { - LOG.info(logMsg); - messagePrinted = true; - try { - FileUtils.deleteDirectory(dir); - } catch (IOException e) { - throw new RuntimeException(e); - } - exec("git clone " + LOCALSTACK_REPO_URL + " " + TMP_INSTALL_DIR); - } - File installationDoneMarker = new File(dir, "localstack/infra/installation.finished.marker"); - if(!installationDoneMarker.exists()) { - if(!messagePrinted) { - LOG.info(logMsg); - } - exec("cd \"" + TMP_INSTALL_DIR + "\"; make install"); - /* create marker file */ - try { - installationDoneMarker.createNewFile(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } - - private static void killProcess(Process p) { - try { - ProcessTree.get().killAll(Collections.singletonMap( - ENV_LOCALSTACK_PROCESS_GROUP, ENV_LOCALSTACK_PROCESS_GROUP)); - } catch (Exception e) { - LOG.warning("Unable to terminate processes: " + e); - } - } - - private static String ensureInstallationAndGetEndpoint(String service) { - ensureInstallation(); - return getEndpoint(service); - } - - public static boolean useSSL() { - return isEnvConfigSet(ENV_CONFIG_USE_SSL); - } - - public static boolean isEnvConfigSet(String configName) { - String value = System.getenv(configName); - return value != null && !Arrays.asList("false", "0", "").contains(value.trim()); - } - - private static String getEndpoint(String service) { - String useSSL = useSSL() ? "USE_SSL=1" : ""; - String cmd = "cd '" + TMP_INSTALL_DIR + "'; " - + ". .venv/bin/activate; " - + useSSL + " python -c 'import localstack_client.config; " - + "print(localstack_client.config.get_service_endpoint(\"" + service + "\"))'"; - Process p = exec(cmd); - try { - return IOUtils.toString(p.getInputStream()).trim(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private static Process exec(String ... cmd) { - return exec(true, cmd); - } - - private static Process exec(boolean wait, String ... cmd) { - try { - if (cmd.length == 1 && !new File(cmd[0]).exists()) { - cmd = new String[]{"bash", "-c", cmd[0]}; - } - Map env = new HashMap<>(System.getenv()); - ProcessBuilder builder = new ProcessBuilder(cmd); - builder.environment().put("PATH", ADDITIONAL_PATH + ":" + env.get("PATH")); - builder.environment().put(ENV_LOCALSTACK_PROCESS_GROUP, ENV_LOCALSTACK_PROCESS_GROUP); - final Process p = builder.start(); - if (wait) { - int code = p.waitFor(); - if(code != 0) { - String stderr = IOUtils.toString(p.getErrorStream()); - String stdout = IOUtils.toString(p.getInputStream()); - throw new IllegalStateException("Failed to run command '" + String.join(" ", cmd) + "', return code " + code + - ".\nSTDOUT: " + stdout + "\nSTDERR: " + stderr); - } - } else { - /* make sure we destroy the process on JVM shutdown */ - Runtime.getRuntime().addShutdownHook(new Thread() { - public void run() { - killProcess(p); - } - }); - } - return p; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private void setupInfrastructure() { - synchronized (INFRA_STARTED) { - // make sure everything is installed locally - ensureInstallation(); - // make sure we avoid any errors related to locally generated SSL certificates - TestUtils.disableSslCertChecking(); - - if(INFRA_STARTED.get() != null) return; - String[] cmd = new String[]{"make", "-C", TMP_INSTALL_DIR, "infra"}; - Process proc; - try { - proc = exec(false, cmd); - BufferedReader r1 = new BufferedReader(new InputStreamReader(proc.getInputStream())); - String line; - LOG.info("Waiting for infrastructure to be spun up"); - boolean ready = false; - String output = ""; - while((line = r1.readLine()) != null) { - output += line + "\n"; - if(INFRA_READY_MARKER.equals(line)) { - ready = true; - break; - } - } - if(!ready) { - throw new RuntimeException("Unable to start local infrastructure. Debug output: " + output); - } - INFRA_STARTED.set(proc); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } - - public static void teardownInfrastructure() { - Process proc = INFRA_STARTED.get(); - if(proc == null) { - return; - } - killProcess(proc); - } - - public static String getDefaultRegion() { - return TestUtils.DEFAULT_REGION; - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/ServiceName.java b/localstack/ext/java/src/main/java/cloud/localstack/ServiceName.java deleted file mode 100644 index d05405d07b3d7..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/ServiceName.java +++ /dev/null @@ -1,21 +0,0 @@ -package cloud.localstack; - -public class ServiceName { - public static final String API_GATEWAY = "apigateway"; - public static final String KINESIS = "kinesis"; - public static final String DYNAMO = "dynamodb"; - public static final String DYNAMO_STREAMS = "dynamodbstreams"; - public static final String ELASTICSEARCH = "elasticsearch"; - public static final String S3 = "s3"; - public static final String FIREHOSE = "firehose"; - public static final String LAMBDA = "lambda"; - public static final String SNS = "sns"; - public static final String SQS = "sqs"; - public static final String REDSHIFT = "redshift"; - public static final String ELASTICSEARCH_SERVICE = "es"; - public static final String SES = "ses"; - public static final String ROUTE53 = "route53"; - public static final String CLOUDFORMATION = "cloudformation"; - public static final String CLOUDWATCH = "cloudwatch"; - public static final String SSM = "ssm"; -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/TestUtils.java b/localstack/ext/java/src/main/java/cloud/localstack/TestUtils.java deleted file mode 100644 index 41f41a56fd312..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/TestUtils.java +++ /dev/null @@ -1,128 +0,0 @@ -package cloud.localstack; - -import com.amazonaws.auth.AWSCredentials; -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.kinesis.AmazonKinesis; -import com.amazonaws.services.kinesis.AmazonKinesisClientBuilder; -import com.amazonaws.services.lambda.AWSLambda; -import com.amazonaws.services.lambda.AWSLambdaClientBuilder; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.sqs.AmazonSQS; -import com.amazonaws.services.sqs.AmazonSQSClient; -import com.amazonaws.services.sqs.AmazonSQSClientBuilder; - -import static cloud.localstack.TestUtils.DEFAULT_REGION; -import static cloud.localstack.TestUtils.TEST_CREDENTIALS; - -import java.lang.reflect.Field; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -@SuppressWarnings("all") -public class TestUtils { - - public static final String DEFAULT_REGION = "us-east-1"; - public static final String TEST_ACCESS_KEY = "test"; - public static final String TEST_SECRET_KEY = "test"; - public static final AWSCredentials TEST_CREDENTIALS = new BasicAWSCredentials(TEST_ACCESS_KEY, TEST_SECRET_KEY); - - public static void setEnv(String key, String value) { - Map newEnv = new HashMap(System.getenv()); - newEnv.put(key, value); - setEnv(newEnv); - } - - public static AmazonSQS getClientSQS() { - return AmazonSQSClientBuilder.standard(). - withEndpointConfiguration(getEndpointConfigurationSQS()). - withCredentials(getCredentialsProvider()).build(); - } - - public static AWSLambda getClientLambda() { - return AWSLambdaClientBuilder.standard(). - withEndpointConfiguration(getEndpointConfigurationLambda()). - withCredentials(getCredentialsProvider()).build(); - } - - public static AmazonS3 getClientS3() { - AmazonS3ClientBuilder builder = AmazonS3ClientBuilder.standard(). - withEndpointConfiguration(getEndpointConfigurationS3()). - withCredentials(getCredentialsProvider()); - builder.setPathStyleAccessEnabled(true); - return builder.build(); - } - - public static AmazonKinesis getClientKinesis() { - return AmazonKinesisClientBuilder.standard(). - withEndpointConfiguration(getEndpointConfigurationKinesis()). - withCredentials(getCredentialsProvider()).build(); - } - - public static AWSCredentialsProvider getCredentialsProvider() { - return new AWSStaticCredentialsProvider(TEST_CREDENTIALS); - } - - protected static AwsClientBuilder.EndpointConfiguration getEndpointConfigurationLambda() { - return getEndpointConfiguration(LocalstackTestRunner.getEndpointLambda()); - } - - protected static AwsClientBuilder.EndpointConfiguration getEndpointConfigurationKinesis() { - return getEndpointConfiguration(LocalstackTestRunner.getEndpointKinesis()); - } - - protected static AwsClientBuilder.EndpointConfiguration getEndpointConfigurationSQS() { - return getEndpointConfiguration(LocalstackTestRunner.getEndpointSQS()); - } - - protected static AwsClientBuilder.EndpointConfiguration getEndpointConfigurationS3() { - return getEndpointConfiguration(LocalstackTestRunner.getEndpointS3()); - } - - protected static AwsClientBuilder.EndpointConfiguration getEndpointConfiguration(String endpointURL) { - return new AwsClientBuilder.EndpointConfiguration(endpointURL, DEFAULT_REGION); - } - - protected static void setEnv(Map newEnv) { - try { - Class processEnvironmentClass = Class.forName("java.lang.ProcessEnvironment"); - Field theEnvironmentField = processEnvironmentClass.getDeclaredField("theEnvironment"); - theEnvironmentField.setAccessible(true); - Map env = (Map) theEnvironmentField.get(null); - env.putAll(newEnv); - Field theCaseInsensitiveEnvironmentField = processEnvironmentClass - .getDeclaredField("theCaseInsensitiveEnvironment"); - theCaseInsensitiveEnvironmentField.setAccessible(true); - Map cienv = (Map) theCaseInsensitiveEnvironmentField.get(null); - cienv.putAll(newEnv); - } catch (NoSuchFieldException e) { - try { - Class[] classes = Collections.class.getDeclaredClasses(); - Map env = System.getenv(); - for (Class cl : classes) { - if ("java.util.Collections$UnmodifiableMap".equals(cl.getName())) { - Field field = cl.getDeclaredField("m"); - field.setAccessible(true); - Object obj = field.get(env); - Map map = (Map) obj; - map.clear(); - map.putAll(newEnv); - } - } - } catch (Exception e2) { - e2.printStackTrace(); - } - } catch (Exception e1) { - e1.printStackTrace(); - } - } - - public static void disableSslCertChecking() { - System.setProperty("com.amazonaws.sdk.disableCertChecking", "true"); - } - -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/Container.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/Container.java deleted file mode 100644 index 056eb34a2c451..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/Container.java +++ /dev/null @@ -1,153 +0,0 @@ -package cloud.localstack.docker; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.util.Collections; -import java.util.List; -import java.util.logging.Logger; -import java.util.regex.Pattern; - -import cloud.localstack.docker.command.ExecCommand; -import cloud.localstack.docker.command.LogCommand; -import cloud.localstack.docker.command.PortCommand; -import cloud.localstack.docker.command.PullCommand; -import cloud.localstack.docker.command.RunCommand; -import cloud.localstack.docker.command.StopCommand; - -/** - * An abstraction of the localstack docker container. Provides port mappings, - * a way to poll the logs until a specified token appears, and the ability to stop the container - */ -public class Container { - - private static final Logger LOG = Logger.getLogger(Container.class.getName()); - - private static final String LOCALSTACK_NAME = "localstack/localstack"; - private static final String LOCALSTACK_PORTS = "4567-4583"; - private static final String LOCALSTACK_EXTERNAL_HOSTNAME = "HOSTNAME_EXTERNAL"; - - private static final int MAX_PORT_CONNECTION_ATTEMPTS = 10; - - private static final int MAX_LOG_COLLECTION_ATTEMPTS = 120; - private static final long POLL_INTERVAL = 1000; - private static final int NUM_LOG_LINES = 10; - - - private final String containerId; - private final List ports; - - - public static Container createLocalstackContainer(String externalHostName) { - LOG.info("Pulling latest image..."); - new PullCommand(LOCALSTACK_NAME).execute(); - - String containerId = new RunCommand(LOCALSTACK_NAME) - .withExposedPorts(LOCALSTACK_PORTS) - .withEnvironmentVariable(LOCALSTACK_EXTERNAL_HOSTNAME, externalHostName) - .execute(); - LOG.info("Started container: " + containerId); - - List portMappings = new PortCommand(containerId).execute(); - return new Container(containerId, portMappings); - } - - - private Container(String containerId, List ports) { - this.containerId = containerId; - this.ports = Collections.unmodifiableList(ports); - } - - - /** - * Given an internal port, retrieve the publicly addressable port that maps to it - */ - public int getExternalPortFor(int internalPort) { - return ports.stream() - .filter(port -> port.getInternalPort() == internalPort) - .map(PortMapping::getExternalPort) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Port: " + internalPort + " does not exist")); - } - - - public void waitForAllPorts(String ip) { - ports.forEach(port -> waitForPort(ip, port)); - } - - - private void waitForPort(String ip, PortMapping port) { - int attempts = 0; - do { - if(isPortOpen(ip, port)) { - return; - } - attempts++; - } - while(attempts < MAX_PORT_CONNECTION_ATTEMPTS); - - throw new IllegalStateException("Could not open port:" + port.getExternalPort() + " on ip:" + port.getIp()); - } - - - private boolean isPortOpen(String ip, PortMapping port) { - try (Socket socket = new Socket()) { - socket.connect(new InetSocketAddress(ip, port.getExternalPort()), 1000); - return true; - } catch (IOException e) { - return false; - } - } - - - /** - * Poll the docker logs until a specific token appears, then return. Primarily used to look - * for the "Ready." token in the localstack logs. - */ - public void waitForLogToken(Pattern pattern) { - int attempts = 0; - do { - if(logContainsPattern(pattern)) { - return; - } - waitForLogs(); - attempts++; - } - while(attempts < MAX_LOG_COLLECTION_ATTEMPTS); - - throw new IllegalStateException("Could not find token: " + pattern.toString() + " in docker logs."); - } - - - private boolean logContainsPattern(Pattern pattern) { - String logs = new LogCommand(containerId).withNumberOfLines(NUM_LOG_LINES).execute(); - return pattern.matcher(logs).find(); - } - - - private void waitForLogs(){ - try { - Thread.sleep(POLL_INTERVAL); - } - catch (InterruptedException ex) { - throw new RuntimeException(ex); - } - } - - - /** - * Stop the container - */ - public void stop(){ - new StopCommand(containerId).execute(); - LOG.info("Stopped container: " + containerId); - } - - - /** - * Run a command on the container via docker exec - */ - public String executeCommand(List command) { - return new ExecCommand(containerId).execute(command); - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/DockerExe.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/DockerExe.java deleted file mode 100644 index 219b94c1d608c..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/DockerExe.java +++ /dev/null @@ -1,79 +0,0 @@ -package cloud.localstack.docker; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.concurrent.Executors.newSingleThreadExecutor; -import static java.util.stream.Collectors.joining; - -import java.io.BufferedReader; -import java.io.File; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; - -/** - * A wrapper around the docker executable process. The DOCKER_LOCATION environment variable - * can be used if docker is not installed in a default location. - */ -public class DockerExe { - - private static final int WAIT_TIMEOUT_MINUTES = 3; - - private static final List POSSIBLE_EXE_LOCATIONS = Arrays.asList( - System.getenv("DOCKER_LOCATION"), - "C:/program files/docker/docker/resources/bin/docker.exe", - "/usr/local/bin/docker", - "/usr/bin/docker"); - - - private final String exeLocation; - - - public DockerExe() { - this.exeLocation = getDockerExeLocation(); - } - - - private String getDockerExeLocation() { - return POSSIBLE_EXE_LOCATIONS.stream() - .filter(Objects::nonNull) - .filter(name -> new File(name).exists()) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Cannot find docker executable.")); - } - - - public String execute(List args) { - try { - List command = new ArrayList<>(); - command.add(exeLocation); - command.addAll(args); - - Process process = new ProcessBuilder() - .command(command) - .redirectErrorStream(true) - .start(); - - ExecutorService exec = newSingleThreadExecutor(); - Future outputFuture = exec.submit(() -> handleOutput(process)); - - String output = outputFuture.get(WAIT_TIMEOUT_MINUTES, TimeUnit.MINUTES); - process.waitFor(WAIT_TIMEOUT_MINUTES, TimeUnit.MINUTES); - exec.shutdown(); - - return output; - } catch (Exception ex) { - throw new RuntimeException("Failed to execute command", ex); - } - } - - - private String handleOutput(Process process) { - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), UTF_8)); - return reader.lines().collect(joining(System.lineSeparator())); - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/LocalstackDockerTestRunner.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/LocalstackDockerTestRunner.java deleted file mode 100644 index e930746bd949d..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/LocalstackDockerTestRunner.java +++ /dev/null @@ -1,223 +0,0 @@ -package cloud.localstack.docker; - -import java.lang.annotation.Annotation; -import java.util.Arrays; -import java.util.Collections; -import java.util.Map; -import java.util.logging.Logger; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import org.apache.commons.lang3.StringUtils; -import org.junit.runner.notification.RunNotifier; -import org.junit.runners.BlockJUnit4ClassRunner; -import org.junit.runners.model.InitializationError; - -import cloud.localstack.ServiceName; -import cloud.localstack.docker.annotation.IHostNameResolver; -import cloud.localstack.docker.annotation.LocalstackDockerProperties; -import cloud.localstack.docker.command.RegexStream; - -/** - * JUnit test runner that automatically pulls and runs the latest localstack docker image - * and then terminates when tests are complete. - * - * Having docker installed is a prerequisite for this test runner to execute. If docker - * is not installed in one of the default locations (C:\program files\docker\docker\resources\bin\, usr/local/bin or usr/bin) - * then use the DOCKER_LOCATION environment variable to specify the location. - * - * Since ports are dynamically allocated, the external port needs to be resolved based on the default localstack port. - * - * The hostname defaults to localhost, but in some environments that is not sufficient, so the HostName can be specified - * by using the LocalstackDockerProperties annotation with an IHostNameResolver. - * - * @author Alan Bevier - */ -public class LocalstackDockerTestRunner extends BlockJUnit4ClassRunner { - - private static final Logger LOG = Logger.getLogger(LocalstackDockerTestRunner.class.getName()); - - private static final String PORT_CONFIG_FILENAME = "/opt/code/localstack/.venv/lib/python2.7/site-packages/localstack_client/config.py"; - - private static final Pattern READY_TOKEN = Pattern.compile("Ready\\."); - - //Regular expression used to parse localstack config to determine default ports for services - private static final Pattern DEFAULT_PORT_PATTERN = Pattern.compile("'(\\w+)'\\Q: '{proto}://{host}:\\E(\\d+)'"); - private static final int SERVICE_NAME_GROUP = 1; - private static final int PORT_GROUP = 2; - - - private static Container localStackContainer; - - public static Container getLocalStackContainer() { - return localStackContainer; - } - - /** - * This is a mapping from service name to internal ports. In order to use them, the - * internal port must be resolved to an external docker port via Container.getExternalPortFor() - */ - private static Map serviceToPortMap; - - private static String externalHostName = "localhost"; - - - public LocalstackDockerTestRunner(Class klass) throws InitializationError { - super(klass); - processAnnotations(klass.getAnnotations()); - } - - - private void processAnnotations(Annotation[] annotations) { - for(Annotation annotation : annotations) { - if(annotation instanceof LocalstackDockerProperties) { - processDockerPropertiesAnnotation((LocalstackDockerProperties)annotation); - } - } - } - - - private void processDockerPropertiesAnnotation(LocalstackDockerProperties properties) { - try { - IHostNameResolver hostNameResolver = properties.hostNameResolver().newInstance(); - String resolvedName = hostNameResolver.getHostName(); - if(StringUtils.isNotBlank(resolvedName)) { - externalHostName = resolvedName; - } - LOG.info("External host name is set to:" + externalHostName); - } - catch(InstantiationException | IllegalAccessException ex) { - throw new IllegalStateException("Unable to resolve hostname", ex); - } - } - - - @Override - public void run(RunNotifier notifier) { - localStackContainer = Container.createLocalstackContainer(externalHostName); - try { - loadServiceToPortMap(); - - LOG.info("Waiting for localstack container to be ready..."); - localStackContainer.waitForLogToken(READY_TOKEN); - - super.run(notifier); - } - finally { - localStackContainer.stop(); - } - } - - - private void loadServiceToPortMap() { - String localStackPortConfig = localStackContainer.executeCommand(Arrays.asList("cat", PORT_CONFIG_FILENAME)); - - Map ports = new RegexStream(DEFAULT_PORT_PATTERN.matcher(localStackPortConfig)).stream() - .collect(Collectors.toMap(match -> match.group(SERVICE_NAME_GROUP), - match -> Integer.parseInt(match.group(PORT_GROUP)))); - - serviceToPortMap = Collections.unmodifiableMap(ports); - } - - - public static String getEndpointS3() { - String s3Endpoint = endpointForService(ServiceName.S3); - /* - * Use the domain name wildcard *.localhost.atlassian.io which maps to 127.0.0.1 - * We need to do this because S3 SDKs attempt to access a domain . - * which by default would result in .localhost, but that name cannot be resolved - * (unless hardcoded in /etc/hosts) - */ - s3Endpoint = s3Endpoint.replace("localhost", "test.localhost.atlassian.io"); - return s3Endpoint; - } - - - public static String getEndpointKinesis() { - return endpointForService(ServiceName.KINESIS); - } - - public static String getEndpointLambda() { - return endpointForService(ServiceName.LAMBDA); - } - - public static String getEndpointDynamoDB() { - return endpointForService(ServiceName.DYNAMO); - } - - public static String getEndpointDynamoDBStreams() { - return endpointForService(ServiceName.DYNAMO_STREAMS); - } - - public static String getEndpointAPIGateway() { - return endpointForService(ServiceName.API_GATEWAY); - } - - public static String getEndpointElasticsearch() { - return endpointForService(ServiceName.ELASTICSEARCH); - } - - public static String getEndpointElasticsearchService() { - return endpointForService(ServiceName.ELASTICSEARCH_SERVICE); - } - - public static String getEndpointFirehose() { - return endpointForService(ServiceName.FIREHOSE); - } - - public static String getEndpointSNS() { - return endpointForService(ServiceName.SNS); - } - - public static String getEndpointSQS() { - return endpointForService(ServiceName.SQS); - } - - public static String getEndpointRedshift() { - return endpointForService(ServiceName.REDSHIFT); - } - - public static String getEndpointSES() { - return endpointForService(ServiceName.SES); - } - - public static String getEndpointRoute53() { - return endpointForService(ServiceName.ROUTE53); - } - - public static String getEndpointCloudFormation() { - return endpointForService(ServiceName.CLOUDFORMATION); - } - - public static String getEndpointCloudWatch() { - return endpointForService(ServiceName.CLOUDWATCH); - } - - public static String getEndpointSSM() { - return endpointForService(ServiceName.SSM); - } - - - public static String endpointForService(String serviceName) { - if(serviceToPortMap == null) { - throw new IllegalStateException("Service to port mapping has not been determined yet."); - } - - if(!serviceToPortMap.containsKey(serviceName)) { - throw new IllegalArgumentException("Unknown port mapping for service"); - } - - int internalPort = serviceToPortMap.get(serviceName); - return endpointForPort(internalPort); - } - - - public static String endpointForPort(int port) { - if (localStackContainer != null) { - int externalPort = localStackContainer.getExternalPortFor(port); - return String.format("http://%s:%s", externalHostName, externalPort); - } - - throw new RuntimeException("Container not started"); - } -} \ No newline at end of file diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/PortMapping.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/PortMapping.java deleted file mode 100644 index 8e32c9c779264..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/PortMapping.java +++ /dev/null @@ -1,33 +0,0 @@ -package cloud.localstack.docker; - -/** - * Keeps track of the external to internal port mapping for a container - */ -public class PortMapping { - private final String ip; - private final int externalPort; - private final int internalPort; - - public PortMapping(String ip, String externalPort, String internalPort) { - this.ip = ip; - this.externalPort = Integer.parseInt(externalPort); - this.internalPort = Integer.parseInt(internalPort); - } - - public String getIp() { - return ip; - } - - public int getExternalPort() { - return externalPort; - } - - public int getInternalPort() { - return internalPort; - } - - @Override - public String toString() { - return String.format("%s:%s -> %s", ip, externalPort, internalPort); - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/EC2HostNameResolver.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/EC2HostNameResolver.java deleted file mode 100644 index 7f3290a899974..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/EC2HostNameResolver.java +++ /dev/null @@ -1,23 +0,0 @@ -package cloud.localstack.docker.annotation; - -import com.amazonaws.util.EC2MetadataUtils; - -/** - * Finds the hostname of the current EC2 instance - * - * This is useful for a CI server that is itself a docker container and which mounts the docker unix socket - * from the host machine. In that case, the server cannot spawn child containers but will instead spawn sibling - * containers which cannot be addressed at "localhost". In order to address the sibling containers you need to resolve - * the hostname of the host machine, which this method will accomplish. - * - * For more information about running docker for CI and mounting the host socket please look here: - * http://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/ - */ -public class EC2HostNameResolver implements IHostNameResolver { - - @Override - public String getHostName() { - return EC2MetadataUtils.getLocalHostName(); - } - -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/IHostNameResolver.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/IHostNameResolver.java deleted file mode 100644 index bdbbffdc59c01..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/IHostNameResolver.java +++ /dev/null @@ -1,5 +0,0 @@ -package cloud.localstack.docker.annotation; - -public interface IHostNameResolver { - String getHostName(); -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/LocalstackDockerProperties.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/LocalstackDockerProperties.java deleted file mode 100644 index c7d8340956502..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/annotation/LocalstackDockerProperties.java +++ /dev/null @@ -1,17 +0,0 @@ -package cloud.localstack.docker.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * An annotation to provide parameters to the LocalstackDockerTestRunner - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -public @interface LocalstackDockerProperties { - - Class hostNameResolver(); -} - diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/Command.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/Command.java deleted file mode 100644 index 0cad3a3a82026..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/Command.java +++ /dev/null @@ -1,18 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import cloud.localstack.docker.DockerExe; - -public abstract class Command { - - protected final DockerExe dockerExe = new DockerExe(); - - protected List options = new ArrayList<>(); - - protected void addOptions(String ...items) { - options.addAll(Arrays.asList(items)); - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/ExecCommand.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/ExecCommand.java deleted file mode 100644 index 595bcb4215c7f..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/ExecCommand.java +++ /dev/null @@ -1,21 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.ArrayList; -import java.util.List; - -public class ExecCommand extends Command { - - private final String containerId; - - public ExecCommand(String containerId) { - this.containerId = containerId; - } - - public String execute(List command) { - List args = new ArrayList<>(); - args.add("exec"); - args.add(containerId); - args.addAll(command); - return dockerExe.execute(args); - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/LogCommand.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/LogCommand.java deleted file mode 100644 index 8a60b3f827487..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/LogCommand.java +++ /dev/null @@ -1,29 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.ArrayList; -import java.util.List; - -public class LogCommand extends Command { - - private final String containerId; - - public LogCommand(String containerId) { - this.containerId = containerId; - } - - - public String execute() { - List args = new ArrayList<>(); - args.add("logs"); - args.addAll(options); - args.add(containerId); - - return dockerExe.execute(args); - } - - - public LogCommand withNumberOfLines(Integer numberOfLines){ - this.addOptions("--tail", numberOfLines.toString()); - return this; - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/PortCommand.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/PortCommand.java deleted file mode 100644 index c1a0909f39437..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/PortCommand.java +++ /dev/null @@ -1,37 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.Arrays; -import java.util.List; -import java.util.function.Function; -import java.util.regex.MatchResult; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import cloud.localstack.docker.PortMapping; - -public class PortCommand extends Command { - - private static final Pattern PORT_MAPPING_PATTERN = Pattern.compile("(\\d+)/tcp -> ((\\d)(\\.(\\d)){3}):(\\d+)"); - private static final int INTERNAL_PORT_GROUP = 1; - private static final int EXTERNAL_PORT_GROUP = 6; - private static final int IP_GROUP = 2; - - private final String containerId; - - public PortCommand(String containerId) { - this.containerId = containerId; - } - - - public List execute() { - String output = dockerExe.execute(Arrays.asList("port", containerId)); - - return new RegexStream(PORT_MAPPING_PATTERN.matcher(output)).stream() - .map(matchToPortMapping) - .collect(Collectors.toList()); - } - - - private Function matchToPortMapping = m -> new PortMapping(m.group(IP_GROUP), m.group(EXTERNAL_PORT_GROUP), m.group(INTERNAL_PORT_GROUP)); - -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/PullCommand.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/PullCommand.java deleted file mode 100644 index ed2e007fd4285..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/PullCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.Arrays; - -public class PullCommand extends Command { - - private static final String LATEST_TAG = "latest"; - - private final String imageName; - - public PullCommand(String imageName) { - this.imageName = imageName; - } - - - public void execute() { - String image = String.format("%s:%s", imageName, LATEST_TAG); - dockerExe.execute(Arrays.asList("pull", image)); - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/RegexStream.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/RegexStream.java deleted file mode 100644 index 1465a61480caa..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/RegexStream.java +++ /dev/null @@ -1,53 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.Spliterator; -import java.util.function.Consumer; -import java.util.regex.Matcher; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; - -public class RegexStream { - - private final MatcherSpliterator matcherSpliterator; - - public RegexStream(Matcher matcher) { - this.matcherSpliterator = new MatcherSpliterator(matcher); - } - - public Stream stream(){ - return StreamSupport.stream(matcherSpliterator, false); - } - - - private class MatcherSpliterator implements Spliterator { - - private final Matcher matcher; - public MatcherSpliterator(Matcher matcher) { - this.matcher = matcher; - } - - @Override - public boolean tryAdvance(Consumer action) { - boolean found = matcher.find(); - if(found) { - action.accept(matcher); - } - return found; - } - - @Override - public Spliterator trySplit() { - return null; - } - - @Override - public long estimateSize() { - return 0; - } - - @Override - public int characteristics() { - return 0; - } - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/RunCommand.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/RunCommand.java deleted file mode 100644 index 2e8a8a96cb7e0..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/RunCommand.java +++ /dev/null @@ -1,33 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.ArrayList; -import java.util.List; - -public class RunCommand extends Command { - - private final String imageName; - - public RunCommand(String imageName) { - this.imageName = imageName; - } - - public String execute() { - List args = new ArrayList<>(); - args.add("run"); - args.add("-d"); - args.addAll(options); - args.add(imageName); - - return dockerExe.execute(args); - } - - public RunCommand withExposedPorts(String portsToExpose) { - addOptions("-p", ":" + portsToExpose); - return this; - } - - public RunCommand withEnvironmentVariable(String name, String value) { - addOptions("-e", String.format("\"%s=%s\"", name, value)); - return this; - } -} diff --git a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/StopCommand.java b/localstack/ext/java/src/main/java/cloud/localstack/docker/command/StopCommand.java deleted file mode 100644 index fd7809abd48df..0000000000000 --- a/localstack/ext/java/src/main/java/cloud/localstack/docker/command/StopCommand.java +++ /dev/null @@ -1,16 +0,0 @@ -package cloud.localstack.docker.command; - -import java.util.Arrays; - -public class StopCommand extends Command { - - private final String containerId; - - public StopCommand(String containerId) { - this.containerId = containerId; - } - - public void execute() { - dockerExe.execute(Arrays.asList("stop", containerId)); - } -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/CaseInsensitiveComparator.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/CaseInsensitiveComparator.java deleted file mode 100644 index f01bfde87e6eb..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/CaseInsensitiveComparator.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.io.Serializable; -import java.util.Comparator; - - -/** - * Case-insensitive string comparator. - * - * @author Kohsuke Kawaguchi - */ -public final class CaseInsensitiveComparator implements Comparator, Serializable { - public static final Comparator INSTANCE = new CaseInsensitiveComparator(); - - private CaseInsensitiveComparator() { - } - - public int compare(String lhs, String rhs) { - return lhs.compareToIgnoreCase(rhs); - } - - private static final long serialVersionUID = 1L; -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/CyclicGraphDetector.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/CyclicGraphDetector.java deleted file mode 100644 index 62bee62933c69..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/CyclicGraphDetector.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.Stack; - - -/** - * Traverses a directed graph and if it contains any cycle, throw an exception. - * - * @author Kohsuke Kawaguchi - */ -@SuppressWarnings("all") -public abstract class CyclicGraphDetector { - private final Set visited = new HashSet(); - - private final Set visiting = new HashSet(); - - private final Stack path = new Stack(); - - private final List topologicalOrder = new ArrayList(); - - public void run(Iterable allNodes) throws CycleDetectedException { - for (N n : allNodes) { - visit(n); - } - } - - /** - * Returns all the nodes in the topologically sorted order. - * That is, if there's an edge a->b, b always come earlier than a. - */ - public List getSorted() { - return topologicalOrder; - } - - /** - * List up edges from the given node (by listing nodes that those edges point to.) - * - * @return - * Never null. - */ - protected abstract Iterable getEdges(N n); - - private void visit(N p) throws CycleDetectedException { - if (!visited.add(p)) - return; - - visiting.add(p); - path.push(p); - for (N q : getEdges(p)) { - if (q == null) - continue; // ignore unresolved references - if (visiting.contains(q)) - detectedCycle(q); - visit(q); - } - visiting.remove(p); - path.pop(); - topologicalOrder.add(p); - } - - private void detectedCycle(N q) throws CycleDetectedException { - int i = path.indexOf(q); - path.push(q); - reactOnCycle(q, path.subList(i, path.size())); - } - - /** - * React on detected cycles - default implementation throws an exception. - * @param q - * @param cycle - * @throws CycleDetectedException - */ - protected void reactOnCycle(N q, List cycle) throws CycleDetectedException { - throw new CycleDetectedException(cycle); - } - - public static final class CycleDetectedException extends Exception { - public final List cycle; - - public CycleDetectedException(List cycle) { - super("Cycle detected: " + Util.join(cycle, " -> ")); - this.cycle = cycle; - } - } -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/EnvVars.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/EnvVars.java deleted file mode 100644 index 42ed5b53aeced..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/EnvVars.java +++ /dev/null @@ -1,441 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.UUID; -import java.util.logging.Logger; - - -/** - * Environment variables. - * - *

- * While all the platforms I tested (Linux 2.6, Solaris, and Windows XP) have the case sensitive - * environment variable table, Windows batch script handles environment variable in the case preserving - * but case insensitive way (that is, cmd.exe can get both FOO and foo as environment variables - * when it's launched, and the "set" command will display it accordingly, but "echo %foo%" results in - * echoing the value of "FOO", not "foo" — this is presumably caused by the behavior of the underlying - * Win32 API GetEnvironmentVariable acting in case insensitive way.) Windows users are also - * used to write environment variable case-insensitively (like %Path% vs %PATH%), and you can see many - * documents on the web that claims Windows environment variables are case insensitive. - * - *

- * So for a consistent cross platform behavior, it creates the least confusion to make the table - * case insensitive but case preserving. - * - *

- * In Jenkins, often we need to build up "environment variable overrides" - * on master, then to execute the process on slaves. This causes a problem - * when working with variables like PATH. So to make this work, - * we introduce a special convention PATH+FOO — all entries - * that starts with PATH+ are merged and prepended to the inherited - * PATH variable, on the process where a new process is executed. - * - * @author Kohsuke Kawaguchi - */ -@SuppressWarnings("all") -public class EnvVars extends TreeMap { - private static Logger LOGGER = Logger.getLogger(EnvVars.class.getName()); - - /** - * If this {@link EnvVars} object represents the whole environment variable set, - * not just a partial list used for overriding later, then we need to know - * the platform for which this env vars are targeted for, or else we won't know - * how to merge variables properly. - * - *

- * So this property remembers that information. - */ - private Platform platform; - - public EnvVars() { - super(CaseInsensitiveComparator.INSTANCE); - } - - public EnvVars(Map m) { - this(); - putAll(m); - - // because of the backward compatibility, some parts of Jenkins passes - // EnvVars as Map so downcasting is safer. - if (m instanceof EnvVars) { - EnvVars lhs = (EnvVars) m; - this.platform = lhs.platform; - } - } - - public EnvVars(EnvVars m) { - // this constructor is so that in future we can get rid of the downcasting. - this((Map) m); - } - - /** - * Builds an environment variables from an array of the form "key","value","key","value"... - */ - public EnvVars(String... keyValuePairs) { - this(); - if (keyValuePairs.length % 2 != 0) - throw new IllegalArgumentException(Arrays.asList(keyValuePairs).toString()); - for (int i = 0; i < keyValuePairs.length; i += 2) - put(keyValuePairs[i], keyValuePairs[i + 1]); - } - - /** - * Overrides the current entry by the given entry. - * - *

- * Handles PATH+XYZ notation. - */ - public void override(String key, String value) { - if (value == null || value.length() == 0) { - remove(key); - return; - } - - int idx = key.indexOf('+'); - if (idx > 0) { - String realKey = key.substring(0, idx); - String v = get(realKey); - if (v == null) - v = value; - else { - // we might be handling environment variables for a slave that can have different path separator - // than the master, so the following is an attempt to get it right. - // it's still more error prone that I'd like. - char ch = platform == null ? File.pathSeparatorChar : platform.pathSeparator; - v = value + ch + v; - } - put(realKey, v); - return; - } - - put(key, value); - } - - /** - * Overrides all values in the map by the given map. - * See {@link #override(String, String)}. - * @return this - */ - public EnvVars overrideAll(Map all) { - for (Map.Entry e : all.entrySet()) { - override(e.getKey(), e.getValue()); - } - return this; - } - - /** - * Calculates the order to override variables. - * - * Sort variables with topological sort with their reference graph. - * - * This is package accessible for testing purpose. - */ - static class OverrideOrderCalculator { - /** - * Extract variables referred directly from a variable. - */ - private static class TraceResolver implements VariableResolver { - private final Comparator comparator; - - public Set referredVariables; - - public TraceResolver(Comparator comparator) { - this.comparator = comparator; - clear(); - } - - public void clear() { - referredVariables = new TreeSet(comparator); - } - - public String resolve(String name) { - referredVariables.add(name); - return ""; - } - } - - private static class VariableReferenceSorter extends CyclicGraphDetector { - // map from a variable to a set of variables that variable refers. - private final Map> refereeSetMap; - - public VariableReferenceSorter(Map> refereeSetMap) { - this.refereeSetMap = refereeSetMap; - } - - @Override - protected Iterable getEdges(String n) { - // return variables referred from the variable. - if (!refereeSetMap.containsKey(n)) { - // there is a case a non-existing variable is referred... - return Collections.emptySet(); - } - return refereeSetMap.get(n); - } - }; - - private final Comparator comparator; - - private final EnvVars target; - - private final Map overrides; - - private Map> refereeSetMap; - - private List orderedVariableNames; - - public OverrideOrderCalculator(EnvVars target, Map overrides) { - comparator = target.comparator(); - this.target = target; - this.overrides = overrides; - scan(); - } - - public List getOrderedVariableNames() { - return orderedVariableNames; - } - - // Cut the reference to the variable in a cycle. - private void cutCycleAt(String referee, List cycle) { - // cycle contains variables in referrer-to-referee order. - // This should not be negative, for the first and last one is same. - int refererIndex = cycle.lastIndexOf(referee) - 1; - - assert (refererIndex >= 0); - String referrer = cycle.get(refererIndex); - boolean removed = refereeSetMap.get(referrer).remove(referee); - assert (removed); - LOGGER.warning(String.format("Cyclic reference detected: %s", Util.join(cycle, " -> "))); - LOGGER.warning(String.format("Cut the reference %s -> %s", referrer, referee)); - } - - // Cut the variable reference in a cycle. - private void cutCycle(List cycle) { - // if an existing variable is contained in that cycle, - // cut the cycle with that variable: - // existing: - // PATH=/usr/bin - // overriding: - // PATH1=/usr/local/bin:${PATH} - // PATH=/opt/something/bin:${PATH1} - // then consider reference PATH1 -> PATH can be ignored. - for (String referee : cycle) { - if (target.containsKey(referee)) { - cutCycleAt(referee, cycle); - return; - } - } - - // if not, cut the reference to the first one. - cutCycleAt(cycle.get(0), cycle); - } - - /** - * Scan all variables and list all referring variables. - */ - public void scan() { - refereeSetMap = new TreeMap>(comparator); - List extendingVariableNames = new ArrayList(); - - TraceResolver resolver = new TraceResolver(comparator); - - for (Map.Entry entry : overrides.entrySet()) { - if (entry.getKey().indexOf('+') > 0) { - // XYZ+AAA variables should be always processed in last. - extendingVariableNames.add(entry.getKey()); - continue; - } - resolver.clear(); - Util.replaceMacro(entry.getValue(), resolver); - - // Variables directly referred from the current scanning variable. - Set refereeSet = resolver.referredVariables; - // Ignore self reference. - refereeSet.remove(entry.getKey()); - refereeSetMap.put(entry.getKey(), refereeSet); - } - - VariableReferenceSorter sorter; - while (true) { - sorter = new VariableReferenceSorter(refereeSetMap); - try { - sorter.run(refereeSetMap.keySet()); - } catch (CyclicGraphDetector.CycleDetectedException e) { - // cyclic reference found. - // cut the cycle and retry. - @SuppressWarnings("unchecked") - List cycle = e.cycle; - cutCycle(cycle); - continue; - } - break; - } - - // When A refers B, the last appearance of B always comes after - // the last appearance of A. - List reversedDuplicatedOrder = new ArrayList(sorter.getSorted()); - Collections.reverse(reversedDuplicatedOrder); - - orderedVariableNames = new ArrayList(overrides.size()); - for (String key : reversedDuplicatedOrder) { - if (overrides.containsKey(key) && !orderedVariableNames.contains(key)) { - orderedVariableNames.add(key); - } - } - Collections.reverse(orderedVariableNames); - orderedVariableNames.addAll(extendingVariableNames); - } - } - - /** - * Overrides all values in the map by the given map. Expressions in values will be expanded. - * See {@link #override(String, String)}. - * @return this - */ - public EnvVars overrideExpandingAll(Map all) { - for (String key : new OverrideOrderCalculator(this, all).getOrderedVariableNames()) { - override(key, expand(all.get(key))); - } - return this; - } - - /** - * Resolves environment variables against each other. - */ - public static void resolve(Map env) { - for (Map.Entry entry : env.entrySet()) { - entry.setValue(Util.replaceMacro(entry.getValue(), env)); - } - } - - /** - * Convenience message - * @since 1.485 - **/ - public String get(String key, String defaultValue) { - String v = get(key); - if (v == null) - v = defaultValue; - return v; - } - - @Override - public String put(String key, String value) { - if (value == null) - throw new IllegalArgumentException("Null value not allowed as an environment variable: " + key); - return super.put(key, value); - } - - /** - * Add a key/value but only if the value is not-null. Otherwise no-op. - * @since 1.556 - */ - public void putIfNotNull(String key, String value) { - if (value != null) - put(key, value); - } - - /** - * Takes a string that looks like "a=b" and adds that to this map. - */ - public void addLine(String line) { - int sep = line.indexOf('='); - if (sep > 0) { - put(line.substring(0, sep), line.substring(sep + 1)); - } - } - - /** - * Expands the variables in the given string by using environment variables represented in 'this'. - */ - public String expand(String s) { - return Util.replaceMacro(s, this); - } - - /** - * Creates a magic cookie that can be used as the model environment variable - * when we later kill the processes. - */ - public static EnvVars createCookie() { - return new EnvVars("HUDSON_COOKIE", UUID.randomUUID().toString()); - } - - // /** - // * Obtains the environment variables of a remote peer. - // * - // * @param channel - // * Can be null, in which case the map indicating "N/A" will be returned. - // * @return - // * A fresh copy that can be owned and modified by the caller. - // */ - // public static EnvVars getRemote(VirtualChannel channel) throws IOException, InterruptedException { - // if(channel==null) - // return new EnvVars("N/A","N/A"); - // return channel.call(new GetEnvVars()); - // } - - // private static final class GetEnvVars extends MasterToSlaveCallable { - // public EnvVars call() { - // return new EnvVars(EnvVars.masterEnvVars); - // } - // private static final long serialVersionUID = 1L; - // } - - /** - * Environmental variables that we've inherited. - * - *

- * Despite what the name might imply, this is the environment variable - * of the current JVM process. And therefore, it is Jenkins master's environment - * variables only when you access this from the master. - * - *

- * If you access this field from slaves, then this is the environment - * variable of the slave agent. - */ - // public static final Map masterEnvVars = initMaster(); - - // private static EnvVars initMaster() { - // EnvVars vars = new EnvVars(System.getenv()); - // vars.platform = Platform.current(); - // if(Main.isUnitTest || Main.isDevelopmentMode) - // // if unit test is launched with maven debug switch, - // // we need to prevent forked Maven processes from seeing it, or else - // // they'll hang - // vars.remove("MAVEN_OPTS"); - // return vars; - // } -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/Platform.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/Platform.java deleted file mode 100644 index 2a090b6762ed6..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/Platform.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.io.File; -import java.util.Locale; - - -/** - * Strategy object that absorbs the platform differences. - * - *

- * Do not switch/case on this enum, or do a comparison, as we may add new constants. - * - * @author Kohsuke Kawaguchi - */ -public enum Platform { - WINDOWS(';'), - UNIX(':'); - - /** - * The character that separates paths in environment variables like PATH and CLASSPATH. - * On Windows ';' and on Unix ':'. - * - * @see File#pathSeparator - */ - public final char pathSeparator; - - private Platform(char pathSeparator) { - this.pathSeparator = pathSeparator; - } - - public static Platform current() { - if (File.pathSeparatorChar == ':') - return UNIX; - return WINDOWS; - } - - public static boolean isDarwin() { - // according to http://developer.apple.com/technotes/tn2002/tn2110.html - return System.getProperty("os.name").toLowerCase(Locale.ENGLISH).startsWith("mac"); - } - - /** - * Returns true if we run on Mac OS X >= 10.6 - */ - public static boolean isSnowLeopardOrLater() { - try { - return isDarwin() && - new VersionNumber(System.getProperty("os.version")).compareTo(new VersionNumber("10.6")) >= 0; - } catch (IllegalArgumentException e) { - // failed to parse the version - return false; - } - } -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessKiller.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessKiller.java deleted file mode 100644 index 18a220440983c..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessKiller.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.io.IOException; -import java.io.Serializable; - - -/** - * Extension point that defines more elaborate way of killing processes, such as - * sudo or pfexec, for {@link ProcessTree}. - * - *

Lifecycle

- *

- * Each implementation of {@link ProcessKiller} is instantiated once on the master. - * Whenever a process needs to be killed, those implementations are serialized and sent over - * to the appropriate slave, then the {@link #kill(ProcessTree.OSProcess)} method is invoked - * to attempt to kill the process. - * - *

- * One of the consequences of this design is that the implementation should be stateless - * and concurrent-safe. That is, the {@link #kill(ProcessTree.OSProcess)} method can be invoked by multiple threads - * concurrently on the single instance. - * - *

- * Another consequence of this design is that if your {@link ProcessKiller} requires configuration, - * it needs to be serializable, and configuration needs to be updated atomically, as another - * thread may be calling into {@link #kill(ProcessTree.OSProcess)} just when you are updating your configuration. - * - * @author jpederzolli - * @author Kohsuke Kawaguchi - * @since 1.362 - */ -public abstract class ProcessKiller implements Serializable { - - /** - * Attempts to kill the given process. - * - * @param process process to be killed. Always a {@linkplain ProcessTree.Local local process}. - * @return - * true if the killing was successful, and Hudson won't try to use other {@link ProcessKiller} - * implementations to kill the process. false if the killing failed or is unattempted, and Hudson will continue - * to use the rest of the {@link ProcessKiller} implementations to try to kill the process. - * @throws IOException - * The caller will log this exception and otherwise treat as if the method returned false, and moves on - * to the next killer. - * @throws InterruptedException - * if the callee performs a time consuming operation and if the thread is canceled, do not catch - * {@link InterruptedException} and just let it thrown from the method. - */ - public abstract boolean kill(ProcessTree.OSProcess process) throws IOException, InterruptedException; - - private static final long serialVersionUID = 1L; -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessTree.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessTree.java deleted file mode 100644 index 16907d39e0e6c..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessTree.java +++ /dev/null @@ -1,1173 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import static com.sun.jna.Pointer.NULL; -import static java.util.logging.Level.FINE; -import static java.util.logging.Level.FINER; -import static java.util.logging.Level.FINEST; -import static org.ow2.proactive.process_tree_killer.jna.GNUCLibrary.LIBC; - -import java.io.BufferedReader; -import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; -import java.io.File; -import java.io.FileFilter; -import java.io.FileReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.RandomAccessFile; -import java.io.Serializable; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.rmi.Remote; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Map.Entry; -import java.util.SortedMap; -import java.util.logging.Level; -import java.util.logging.Logger; - -import org.jvnet.winp.WinProcess; -import org.jvnet.winp.WinpException; - -import com.sun.jna.Memory; -import com.sun.jna.Native; -import com.sun.jna.ptr.IntByReference; - - -/** - * Represents a snapshot of the process tree of the current system. - * - *

- * A {@link ProcessTree} is really conceptually a map from process ID to a {@link OSProcess} object. - * When Hudson runs on platforms that support process introspection, this allows you to introspect - * and do some useful things on processes. On other platforms, the implementation falls back to - * "do nothing" behavior. - * - *

- * {@link ProcessTree} is remotable. - * - * @author Kohsuke Kawaguchi - * @since 1.315 - */ -@SuppressWarnings("all") -public abstract class ProcessTree - implements Iterable, ProcessTreeRemoting.IProcessTree, Serializable { - /** - * To be filled in the constructor of the derived type. - */ - protected final Map processes = new HashMap(); - - // instantiation only allowed for subtypes in this class - private ProcessTree() { - } - - /** - * Gets the process given a specific ID, or null if no such process exists. - */ - public final OSProcess get(int pid) { - return processes.get(pid); - } - - /** - * Lists all the processes in the system. - */ - public final Iterator iterator() { - return processes.values().iterator(); - } - - /** - * Try to convert {@link Process} into this process object - * or null if it fails (for example, maybe the snapshot is taken after - * this process has already finished.) - */ - public abstract OSProcess get(Process proc); - - /** - * Kills all the processes that have matching environment variables. - * - *

- * In this method, the method is given a - * "model environment variables", which is a list of environment variables - * and their values that are characteristic to the launched process. - * The implementation is expected to find processes - * in the system that inherit these environment variables, and kill - * them all. This is suitable for locating daemon processes - * that cannot be tracked by the regular ancestor/descendant relationship. - */ - public abstract void killAll(Map modelEnvVars) throws InterruptedException; - - /** - * Convenience method that does {@link #killAll(Map)} and {@link OSProcess#killRecursively()}. - * This is necessary to reliably kill the process and its descendants, as some OS - * may not implement {@link #killAll(Map)}. - * - * Either of the parameter can be null. - */ - public void killAll(Process proc, Map modelEnvVars) throws InterruptedException { - LOGGER.fine("killAll: process=" + proc + " and envs=" + modelEnvVars); - OSProcess p = get(proc); - if (p != null) - p.killRecursively(); - if (modelEnvVars != null) - killAll(modelEnvVars); - } - - /** - * Represents a process. - */ - public abstract class OSProcess implements ProcessTreeRemoting.IOSProcess, Serializable { - final int pid; - - // instantiation only allowed for subtypes in this class - private OSProcess(int pid) { - this.pid = pid; - } - - public final int getPid() { - return pid; - } - - /** - * Gets the parent process. This method may return null, because - * there's no guarantee that we are getting a consistent snapshot - * of the whole system state. - */ - public abstract OSProcess getParent(); - - /* package */ final ProcessTree getTree() { - return ProcessTree.this; - } - - /** - * Immediate child processes. - */ - public final List getChildren() { - List r = new ArrayList(); - for (OSProcess p : ProcessTree.this) - if (p.getParent() == this) - r.add(p); - return r; - } - - /** - * Kills this process. - */ - public abstract void kill() throws InterruptedException; - - /** - * Kills this process and all the descendants. - *

- * Note that the notion of "descendants" is somewhat vague, - * in the presence of such things like daemons. On platforms - * where the recursive operation is not supported, this just kills - * the current process. - */ - public abstract void killRecursively() throws InterruptedException; - - /** - * Gets the command-line arguments of this process. - * - *

- * On Windows, where the OS models command-line arguments as a single string, this method - * computes the approximated tokenization. - */ - public abstract List getArguments(); - - /** - * Obtains the environment variables of this process. - * - * @return - * empty map if failed (for example because the process is already dead, - * or the permission was denied.) - */ - public abstract EnvVars getEnvironmentVariables(); - - /** - * Given the environment variable of a process and the "model environment variable" that Hudson - * used for launching the build, returns true if there's a match (which means the process should - * be considered a descendant of a build.) - */ - public final boolean hasMatchingEnvVars(Map modelEnvVar) { - if (modelEnvVar.isEmpty()) - // sanity check so that we don't start rampage. - return false; - - SortedMap envs = getEnvironmentVariables(); - for (Entry e : modelEnvVar.entrySet()) { - String v = envs.get(e.getKey()); - if (v == null || !v.equals(e.getValue())) - return false; // no match - } - - return true; - } - - // /** - // * Executes a chunk of code at the same machine where this process resides. - // */ - // public T act(ProcessCallable callable) throws IOException, InterruptedException { - // return callable.invoke(this, FilePath.localChannel); - // } - - Object writeReplace() { - return new SerializedProcess(pid); - } - } - - /** - * Serialized form of {@link OSProcess} is the PID and {@link ProcessTree} - */ - private final class SerializedProcess implements Serializable { - private final int pid; - - private static final long serialVersionUID = 1L; - - private SerializedProcess(int pid) { - this.pid = pid; - } - - Object readResolve() { - return get(pid); - } - } - - // /** - // * Code that gets executed on the machine where the {@link OSProcess} is local. - // * Used to act on {@link OSProcess}. - // * - // * @see OSProcess#act(ProcessCallable) - // */ - // public interface ProcessCallable extends Serializable { - // /** - // * Performs the computational task on the node where the data is located. - // * - // * @param process - // * {@link OSProcess} that represents the local process. - // * @param channel - // * The "back pointer" of the {@link Channel} that represents the communication - // * with the node from where the code was sent. - // */ - // T invoke(OSProcess process, VirtualChannel channel) throws IOException; - // } - - /** - * Gets the {@link ProcessTree} of the current system - * that JVM runs in, or in the worst case return the default one - * that's not capable of killing descendants at all. - */ - public static ProcessTree get() { - if (!enabled) - return DEFAULT; - - try { - if (File.pathSeparatorChar == ';') - return new Windows(); - - String os = fixNull(System.getProperty("os.name")); - if (os.equals("Linux")) - return new Linux(); - if (os.equals("SunOS")) - return new Solaris(); - if (os.equals("Mac OS X")) - return new Darwin(); - } catch (LinkageError e) { - LOGGER.log(Level.WARNING, "Failed to load winp. Reverting to the default", e); - enabled = false; - } - - return DEFAULT; - } - - private static String fixNull(String s) { - if (s == null) - return ""; - else - return s; - } - - // - // - // implementation follows - //------------------------------------------- - // - - /** - * Empty process list as a default value if the platform doesn't support it. - */ - /* package */ static final ProcessTree DEFAULT = new Local() { - public OSProcess get(final Process proc) { - return new OSProcess(-1) { - public OSProcess getParent() { - return null; - } - - public void killRecursively() { - // fall back to a single process killer - proc.destroy(); - } - - public void kill() throws InterruptedException { - proc.destroy(); - } - - public List getArguments() { - return Collections.emptyList(); - } - - public EnvVars getEnvironmentVariables() { - return new EnvVars(); - } - }; - } - - public void killAll(Map modelEnvVars) { - // no-op - } - }; - - private static final class Windows extends Local { - Windows() { - for (final WinProcess p : WinProcess.all()) { - int pid = p.getPid(); - if (pid == 0 || pid == 4) - continue; // skip the System Idle and System processes - super.processes.put(pid, new OSProcess(pid) { - private EnvVars env; - - private List args; - - public OSProcess getParent() { - // windows process doesn't have parent/child relationship - return null; - } - - public void killRecursively() throws InterruptedException { - LOGGER.finer("Killing recursively " + getPid()); - p.killRecursively(); - } - - public void kill() throws InterruptedException { - LOGGER.finer("Killing " + getPid()); - p.kill(); - } - - @Override - public synchronized List getArguments() { - if (args == null) - args = Arrays.asList(QuotedStringTokenizer.tokenize(p.getCommandLine())); - return args; - } - - @Override - public synchronized EnvVars getEnvironmentVariables() { - if (env != null) - return env; - env = new EnvVars(); - - try { - env.putAll(p.getEnvironmentVariables()); - } catch (WinpException e) { - LOGGER.log(FINE, "Failed to get environment variable ", e); - } - return env; - } - }); - - } - } - - @Override - public OSProcess get(Process proc) { - return get(new WinProcess(proc).getPid()); - } - - public void killAll(Map modelEnvVars) throws InterruptedException { - for (OSProcess p : this) { - if (p.getPid() < 10) - continue; // ignore system processes like "idle process" - - LOGGER.finest("Considering to kill " + p.getPid()); - - boolean matched; - try { - matched = p.hasMatchingEnvVars(modelEnvVars); - } catch (WinpException e) { - // likely a missing privilege - LOGGER.log(FINEST, " Failed to check environment variable match", e); - continue; - } - - if (matched) - p.killRecursively(); - else - LOGGER.finest("Environment variable didn't match"); - - } - } - - static { - WinProcess.enableDebugPrivilege(); - } - } - - static abstract class Unix extends Local { - @Override - public OSProcess get(Process proc) { - try { - return get((Integer) UnixReflection.PID_FIELD.get(proc)); - } catch (IllegalAccessException e) { // impossible - IllegalAccessError x = new IllegalAccessError(); - x.initCause(e); - throw x; - } - } - - public void killAll(Map modelEnvVars) throws InterruptedException { - for (OSProcess p : this) - if (p.hasMatchingEnvVars(modelEnvVars)) - p.killRecursively(); - } - } - - /** - * {@link ProcessTree} based on /proc. - */ - static abstract class ProcfsUnix extends Unix { - ProcfsUnix() { - File[] processes = new File("/proc").listFiles(new FileFilter() { - public boolean accept(File f) { - return f.isDirectory(); - } - }); - if (processes == null) { - LOGGER.info("No /proc"); - return; - } - - for (File p : processes) { - int pid; - try { - pid = Integer.parseInt(p.getName()); - } catch (NumberFormatException e) { - // other sub-directories - continue; - } - try { - this.processes.put(pid, createProcess(pid)); - } catch (IOException e) { - // perhaps the process status has changed since we obtained a directory listing - } - } - } - - protected abstract OSProcess createProcess(int pid) throws IOException; - } - - /** - * A process. - */ - public abstract class UnixProcess extends OSProcess { - protected UnixProcess(int pid) { - super(pid); - } - - protected final File getFile(String relativePath) { - return new File(new File("/proc/" + getPid()), relativePath); - } - - /** - * Tries to kill this process. - */ - public void kill() throws InterruptedException { - try { - int pid = getPid(); - LOGGER.fine("Killing pid=" + pid); - UnixReflection.destroy(pid); - } catch (IllegalAccessException e) { - // this is impossible - IllegalAccessError x = new IllegalAccessError(); - x.initCause(e); - throw x; - } catch (InvocationTargetException e) { - // tunnel serious errors - if (e.getTargetException() instanceof Error) - throw (Error) e.getTargetException(); - // otherwise log and let go. I need to see when this happens - LOGGER.log(Level.INFO, "Failed to terminate pid=" + getPid(), e); - } - } - - public void killRecursively() throws InterruptedException { - LOGGER.fine("Recursively killing pid=" + getPid()); - for (OSProcess p : getChildren()) - p.killRecursively(); - kill(); - } - - /** - * Obtains the argument list of this process. - * - * @return - * empty list if failed (for example because the process is already dead, - * or the permission was denied.) - */ - public abstract List getArguments(); - } - - /** - * Reflection used in the Unix support. - */ - private static final class UnixReflection { - /** - * Field to access the PID of the process. - */ - private static final Field PID_FIELD; - - /** - * Method to destroy a process, given pid. - * - * Looking at the JavaSE source code, this is using SIGTERM (15) - */ - private static final Method DESTROY_PROCESS; - - static { - try { - Class clazz = Class.forName("java.lang.UNIXProcess"); - PID_FIELD = clazz.getDeclaredField("pid"); - PID_FIELD.setAccessible(true); - - if (isPreJava8()) { - DESTROY_PROCESS = clazz.getDeclaredMethod("destroyProcess", int.class); - } else { - DESTROY_PROCESS = clazz.getDeclaredMethod("destroyProcess", int.class, boolean.class); - } - DESTROY_PROCESS.setAccessible(true); - } catch (ClassNotFoundException e) { - LinkageError x = new LinkageError(); - x.initCause(e); - throw x; - } catch (NoSuchFieldException e) { - LinkageError x = new LinkageError(); - x.initCause(e); - throw x; - } catch (NoSuchMethodException e) { - LinkageError x = new LinkageError(); - x.initCause(e); - throw x; - } - } - - public static void destroy(int pid) throws IllegalAccessException, InvocationTargetException { - if (isPreJava8()) { - DESTROY_PROCESS.invoke(null, pid); - } else { - DESTROY_PROCESS.invoke(null, pid, false); - } - } - - private static boolean isPreJava8() { - int javaVersionAsAnInteger = Integer.parseInt(System.getProperty("java.version") - .replaceAll("\\.", "") - .replaceAll("_", "") - .substring(0, 2)); - return javaVersionAsAnInteger < 18; - } - } - - static class Linux extends ProcfsUnix { - protected LinuxProcess createProcess(int pid) throws IOException { - return new LinuxProcess(pid); - } - - class LinuxProcess extends UnixProcess { - private int ppid = -1; - - private EnvVars envVars; - - private List arguments; - - LinuxProcess(int pid) throws IOException { - super(pid); - - BufferedReader r = new BufferedReader(new FileReader(getFile("status"))); - try { - String line; - while ((line = r.readLine()) != null) { - line = line.toLowerCase(Locale.ENGLISH); - if (line.startsWith("ppid:")) { - ppid = Integer.parseInt(line.substring(5).trim()); - break; - } - } - } finally { - r.close(); - } - if (ppid == -1) - throw new IOException("Failed to parse PPID from /proc/" + pid + "/status"); - } - - public OSProcess getParent() { - return get(ppid); - } - - public synchronized List getArguments() { - if (arguments != null) - return arguments; - arguments = new ArrayList(); - try { - byte[] cmdline = readFileToByteArray(getFile("cmdline")); - int pos = 0; - for (int i = 0; i < cmdline.length; i++) { - byte b = cmdline[i]; - if (b == 0) { - arguments.add(new String(cmdline, pos, i - pos)); - pos = i + 1; - } - } - } catch (IOException e) { - // failed to read. this can happen under normal circumstances (most notably permission denied) - // so don't report this as an error. - } - arguments = Collections.unmodifiableList(arguments); - return arguments; - } - - public synchronized EnvVars getEnvironmentVariables() { - if (envVars != null) - return envVars; - envVars = new EnvVars(); - try { - byte[] environ = readFileToByteArray(getFile("environ")); - int pos = 0; - for (int i = 0; i < environ.length; i++) { - byte b = environ[i]; - if (b == 0) { - envVars.addLine(new String(environ, pos, i - pos)); - pos = i + 1; - } - } - } catch (IOException e) { - // failed to read. this can happen under normal circumstances (most notably permission denied) - // so don't report this as an error. - } - return envVars; - } - } - - public byte[] readFileToByteArray(File file) throws IOException { - InputStream in = org.apache.commons.io.FileUtils.openInputStream(file); - try { - return org.apache.commons.io.IOUtils.toByteArray(in); - } finally { - in.close(); - } - } - } - - /** - * Implementation for Solaris that uses /proc. - * - * Amazingly, this single code works for both 32bit and 64bit Solaris, despite the fact - * that does a lot of pointer manipulation and what not. - */ - static class Solaris extends ProcfsUnix { - protected OSProcess createProcess(final int pid) throws IOException { - return new SolarisProcess(pid); - } - - private class SolarisProcess extends UnixProcess { - private final int ppid; - - /** - * Address of the environment vector. Even on 64bit Solaris this is still 32bit pointer. - */ - private final int envp; - - /** - * Similarly, address of the arguments vector. - */ - private final int argp; - - private final int argc; - - private EnvVars envVars; - - private List arguments; - - private SolarisProcess(int pid) throws IOException { - super(pid); - - RandomAccessFile psinfo = new RandomAccessFile(getFile("psinfo"), "r"); - try { - // see http://cvs.opensolaris.org/source/xref/onnv/onnv-gate/usr/src/uts/common/sys/procfs.h - //typedef struct psinfo { - // int pr_flag; /* process flags */ - // int pr_nlwp; /* number of lwps in the process */ - // pid_t pr_pid; /* process id */ - // pid_t pr_ppid; /* process id of parent */ - // pid_t pr_pgid; /* process id of process group leader */ - // pid_t pr_sid; /* session id */ - // uid_t pr_uid; /* real user id */ - // uid_t pr_euid; /* effective user id */ - // gid_t pr_gid; /* real group id */ - // gid_t pr_egid; /* effective group id */ - // uintptr_t pr_addr; /* address of process */ - // size_t pr_size; /* size of process image in Kbytes */ - // size_t pr_rssize; /* resident set size in Kbytes */ - // dev_t pr_ttydev; /* controlling tty device (or PRNODEV) */ - // ushort_t pr_pctcpu; /* % of recent cpu time used by all lwps */ - // ushort_t pr_pctmem; /* % of system memory used by process */ - // timestruc_t pr_start; /* process start time, from the epoch */ - // timestruc_t pr_time; /* cpu time for this process */ - // timestruc_t pr_ctime; /* cpu time for reaped children */ - // char pr_fname[PRFNSZ]; /* name of exec'ed file */ - // char pr_psargs[PRARGSZ]; /* initial characters of arg list */ - // int pr_wstat; /* if zombie, the wait() status */ - // int pr_argc; /* initial argument count */ - // uintptr_t pr_argv; /* address of initial argument vector */ - // uintptr_t pr_envp; /* address of initial environment vector */ - // char pr_dmodel; /* data model of the process */ - // lwpsinfo_t pr_lwp; /* information for representative lwp */ - //} psinfo_t; - - // see http://cvs.opensolaris.org/source/xref/onnv/onnv-gate/usr/src/uts/common/sys/types.h - // for the size of the various datatype. - - // see http://cvs.opensolaris.org/source/xref/onnv/onnv-gate/usr/src/cmd/ptools/pargs/pargs.c - // for how to read this information - - psinfo.seek(8); - if (adjust(psinfo.readInt()) != pid) - throw new IOException("psinfo PID mismatch"); // sanity check - ppid = adjust(psinfo.readInt()); - - psinfo.seek(188); // now jump to pr_argc - argc = adjust(psinfo.readInt()); - argp = adjust(psinfo.readInt()); - envp = adjust(psinfo.readInt()); - } finally { - psinfo.close(); - } - if (ppid == -1) - throw new IOException("Failed to parse PPID from /proc/" + pid + "/status"); - - } - - public OSProcess getParent() { - return get(ppid); - } - - public synchronized List getArguments() { - if (arguments != null) - return arguments; - - arguments = new ArrayList(argc); - - try { - RandomAccessFile as = new RandomAccessFile(getFile("as"), "r"); - if (LOGGER.isLoggable(FINER)) - LOGGER.finer("Reading " + getFile("as")); - try { - for (int n = 0; n < argc; n++) { - // read a pointer to one entry - as.seek(to64(argp + n * 4)); - int p = adjust(as.readInt()); - - arguments.add(readLine(as, p, "argv[" + n + "]")); - } - } finally { - as.close(); - } - } catch (IOException e) { - // failed to read. this can happen under normal circumstances (most notably permission denied) - // so don't report this as an error. - } - - arguments = Collections.unmodifiableList(arguments); - return arguments; - } - - public synchronized EnvVars getEnvironmentVariables() { - if (envVars != null) - return envVars; - envVars = new EnvVars(); - - try { - RandomAccessFile as = new RandomAccessFile(getFile("as"), "r"); - if (LOGGER.isLoggable(FINER)) - LOGGER.finer("Reading " + getFile("as")); - try { - for (int n = 0;; n++) { - // read a pointer to one entry - as.seek(to64(envp + n * 4)); - int p = adjust(as.readInt()); - if (p == 0) - break; // completed the walk - - // now read the null-terminated string - envVars.addLine(readLine(as, p, "env[" + n + "]")); - } - } finally { - as.close(); - } - } catch (IOException e) { - // failed to read. this can happen under normal circumstances (most notably permission denied) - // so don't report this as an error. - } - - return envVars; - } - - private String readLine(RandomAccessFile as, int p, String prefix) throws IOException { - if (LOGGER.isLoggable(FINEST)) - LOGGER.finest("Reading " + prefix + " at " + p); - - as.seek(to64(p)); - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - int ch, i = 0; - while ((ch = as.read()) > 0) { - if ((++i) % 100 == 0 && LOGGER.isLoggable(FINEST)) - LOGGER.finest(prefix + " is so far " + buf.toString()); - - buf.write(ch); - } - String line = buf.toString(); - if (LOGGER.isLoggable(FINEST)) - LOGGER.finest(prefix + " was " + line); - return line; - } - } - - /** - * int to long conversion with zero-padding. - */ - private static long to64(int i) { - return i & 0xFFFFFFFFL; - } - - /** - * {@link DataInputStream} reads a value in big-endian, so - * convert it to the correct value on little-endian systems. - */ - private static int adjust(int i) { - if (IS_LITTLE_ENDIAN) - return (i << 24) | ((i << 8) & 0x00FF0000) | ((i >> 8) & 0x0000FF00) | (i >>> 24); - else - return i; - } - - } - - /** - * Implementation for Mac OS X based on sysctl(3). - */ - private static class Darwin extends Unix { - Darwin() { - String arch = System.getProperty("sun.arch.data.model"); - if ("64".equals(arch)) { - sizeOf_kinfo_proc = sizeOf_kinfo_proc_64; - kinfo_proc_pid_offset = kinfo_proc_pid_offset_64; - kinfo_proc_ppid_offset = kinfo_proc_ppid_offset_64; - } else { - sizeOf_kinfo_proc = sizeOf_kinfo_proc_32; - kinfo_proc_pid_offset = kinfo_proc_pid_offset_32; - kinfo_proc_ppid_offset = kinfo_proc_ppid_offset_32; - } - try { - IntByReference underscore = new IntByReference(sizeOfInt); - IntByReference size = new IntByReference(sizeOfInt); - Memory m; - int nRetry = 0; - while (true) { - // find out how much memory we need to do this - if (LIBC.sysctl(MIB_PROC_ALL, 3, NULL, size, NULL, underscore) != 0) - throw new IOException("Failed to obtain memory requirement: " + - LIBC.strerror(Native.getLastError())); - - // now try the real call - m = new Memory(size.getValue()); - if (LIBC.sysctl(MIB_PROC_ALL, 3, m, size, NULL, underscore) != 0) { - if (Native.getLastError() == ENOMEM && nRetry++ < 16) - continue; // retry - throw new IOException("Failed to call kern.proc.all: " + LIBC.strerror(Native.getLastError())); - } - break; - } - - int count = size.getValue() / sizeOf_kinfo_proc; - LOGGER.fine("Found " + count + " processes"); - - for (int base = 0; base < size.getValue(); base += sizeOf_kinfo_proc) { - int pid = m.getInt(base + kinfo_proc_pid_offset); - int ppid = m.getInt(base + kinfo_proc_ppid_offset); - // int effective_uid = m.getInt(base+304); - // byte[] comm = new byte[16]; - // m.read(base+163,comm,0,16); - - super.processes.put(pid, new DarwinProcess(pid, ppid)); - } - } catch (IOException e) { - LOGGER.log(Level.WARNING, "Failed to obtain process list", e); - } - } - - private class DarwinProcess extends UnixProcess { - private final int ppid; - - private EnvVars envVars; - - private List arguments; - - DarwinProcess(int pid, int ppid) { - super(pid); - this.ppid = ppid; - } - - public OSProcess getParent() { - return get(ppid); - } - - public synchronized EnvVars getEnvironmentVariables() { - if (envVars != null) - return envVars; - parse(); - return envVars; - } - - public List getArguments() { - if (arguments != null) - return arguments; - parse(); - return arguments; - } - - private void parse() { - try { - // allocate them first, so that the parse error wil result in empty data - // and avoid retry. - arguments = new ArrayList(); - envVars = new EnvVars(); - - IntByReference underscore = new IntByReference(); - - IntByReference argmaxRef = new IntByReference(0); - IntByReference size = new IntByReference(sizeOfInt); - - // for some reason, I was never able to get sysctlbyname work. - // if(LIBC.sysctlbyname("kern.argmax", argmaxRef.getPointer(), size, NULL, _)!=0) - if (LIBC.sysctl(new int[] { CTL_KERN, KERN_ARGMAX }, 2, argmaxRef.getPointer(), size, NULL, underscore) != 0) - throw new IOException("Failed to get kernl.argmax: " + LIBC.strerror(Native.getLastError())); - - int argmax = argmaxRef.getValue(); - - class StringArrayMemory extends Memory { - private long offset = 0; - - StringArrayMemory(long l) { - super(l); - } - - int readInt() { - int r = getInt(offset); - offset += sizeOfInt; - return r; - } - - byte peek() { - return getByte(offset); - } - - String readString() { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - byte ch; - while ((ch = getByte(offset++)) != '\0') - baos.write(ch); - return baos.toString(); - } - - void skip0() { - // skip padding '\0's - while (getByte(offset) == '\0') - offset++; - } - } - StringArrayMemory m = new StringArrayMemory(argmax); - size.setValue(argmax); - if (LIBC.sysctl(new int[] { CTL_KERN, KERN_PROCARGS2, pid }, 3, m, size, NULL, underscore) != 0) - throw new IOException("Failed to obtain ken.procargs2: " + - LIBC.strerror(Native.getLastError())); - - /* - * Make a sysctl() call to get the raw argument space of the - * process. The layout is documented in start.s, which is part - * of the Csu project. In summary, it looks like: - * - * /---------------\ 0x00000000 - * : : - * : : - * |---------------| - * | argc | - * |---------------| - * | arg[0] | - * |---------------| - * : : - * : : - * |---------------| - * | arg[argc - 1] | - * |---------------| - * | 0 | - * |---------------| - * | env[0] | - * |---------------| - * : : - * : : - * |---------------| - * | env[n] | - * |---------------| - * | 0 | - * |---------------| <-- Beginning of data returned by sysctl() - * | exec_path | is here. - * |:::::::::::::::| - * | | - * | String area. | - * | | - * |---------------| <-- Top of stack. - * : : - * : : - * \---------------/ 0xffffffff - */ - - // I find the Darwin source code of the 'ps' command helpful in understanding how it does this: - // see http://www.opensource.apple.com/source/adv_cmds/adv_cmds-147/ps/print.c - int argc = m.readInt(); - String args0 = m.readString(); // exec path - m.skip0(); - try { - for (int i = 0; i < argc; i++) { - arguments.add(m.readString()); - } - } catch (IndexOutOfBoundsException e) { - throw new IllegalStateException("Failed to parse arguments: pid=" + pid + ", arg0=" + args0 + - ", arguments=" + arguments + ", nargs=" + argc + - ". Please run 'ps e " + pid + - "' and report this to https://issues.jenkins-ci.org/browse/JENKINS-9634", - e); - } - - // read env vars that follow - while (m.peek() != 0) - envVars.addLine(m.readString()); - } catch (IOException e) { - // this happens with insufficient permissions, so just ignore the problem. - } - } - } - - // local constants - private final int sizeOf_kinfo_proc; - - private static final int sizeOf_kinfo_proc_32 = 492; // on 32bit Mac OS X. - - private static final int sizeOf_kinfo_proc_64 = 648; // on 64bit Mac OS X. - - private final int kinfo_proc_pid_offset; - - private static final int kinfo_proc_pid_offset_32 = 24; - - private static final int kinfo_proc_pid_offset_64 = 40; - - private final int kinfo_proc_ppid_offset; - - private static final int kinfo_proc_ppid_offset_32 = 416; - - private static final int kinfo_proc_ppid_offset_64 = 560; - - private static final int sizeOfInt = Native.getNativeSize(int.class); - - private static final int CTL_KERN = 1; - - private static final int KERN_PROC = 14; - - private static final int KERN_PROC_ALL = 0; - - private static final int ENOMEM = 12; - - private static int[] MIB_PROC_ALL = { CTL_KERN, KERN_PROC, KERN_PROC_ALL }; - - private static final int KERN_ARGMAX = 8; - - private static final int KERN_PROCARGS2 = 49; - } - - /** - * Represents a local process tree, where this JVM and the process tree run on the same system. - * (The opposite of {@link Remote}.) - */ - public static abstract class Local extends ProcessTree { - Local() { - } - } - - /* - * On MacOS X, there's no procfs - * instead you'd do it with the sysctl - * - * - * - * There's CLI but that doesn't seem to offer the access to per-process info - * - * - * - * - * On HP-UX, pstat_getcommandline get you command line, but I'm not seeing any environment - * variables. - */ - - private static final boolean IS_LITTLE_ENDIAN = "little".equals(System.getProperty("sun.cpu.endian")); - - private static final Logger LOGGER = Logger.getLogger(ProcessTree.class.getName()); - - /** - * Flag to control this feature. - * - *

- * This feature involves some native code, so we are allowing the user to disable this - * in case there's a fatal problem. - * - *

- * This property supports two names for a compatibility reason. - */ - public static boolean enabled = !Boolean.getBoolean(ProcessTree.class.getName() + ".disable"); -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessTreeRemoting.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessTreeRemoting.java deleted file mode 100644 index 20dfdd78d0d10..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/ProcessTreeRemoting.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.lang.reflect.Proxy; -import java.util.List; -import java.util.Map; - - -/** - * Remoting interfaces of {@link ProcessTree}. - * - * These classes need to be public due to the way {@link Proxy} works. - * - * @author Kohsuke Kawaguchi - */ -public class ProcessTreeRemoting { - public interface IProcessTree { - void killAll(Map modelEnvVars) throws InterruptedException; - } - - public interface IOSProcess { - int getPid(); - - IOSProcess getParent(); - - void kill() throws InterruptedException; - - void killRecursively() throws InterruptedException; - - List getArguments(); - - EnvVars getEnvironmentVariables(); - } -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/QuotedStringTokenizer.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/QuotedStringTokenizer.java deleted file mode 100644 index 5af17adf53d89..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/QuotedStringTokenizer.java +++ /dev/null @@ -1,473 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.util.ArrayList; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.StringTokenizer; - - -/* ------------------------------------------------------------ */ - -/** StringTokenizer with Quoting support. - * - * This class is a copy of the java.util.StringTokenizer API and - * the behaviour is the same, except that single and doulbe quoted - * string values are recognized. - * Delimiters within quotes are not considered delimiters. - * Quotes can be escaped with '\'. - * - * @see StringTokenizer - * @author Greg Wilkins (gregw) - */ -public class QuotedStringTokenizer extends StringTokenizer { - private final static String __delim = " \t\n\r"; - - private String _string; - - private String _delim = __delim; - - private boolean _returnQuotes = false; - - private boolean _returnDelimiters = false; - - private StringBuilder _token; - - private boolean _hasToken = false; - - private int _i = 0; - - private int _lastStart = 0; - - private boolean _double = true; - - private boolean _single = true; - - public static String[] tokenize(String str) { - return new QuotedStringTokenizer(str).toArray(); - } - - public static String[] tokenize(String str, String delimiters) { - return new QuotedStringTokenizer(str, delimiters).toArray(); - } - - /* ------------------------------------------------------------ */ - /** - * - * @param str - * String to tokenize. - * @param delim - * List of delimiter characters as string. Can be null, to default to ' \t\n\r' - * @param returnDelimiters - * If true, {@link #nextToken()} will include the delimiters, not just tokenized - * tokens. - * @param returnQuotes - * If true, {@link #nextToken()} will include the quotation characters when they are present. - */ - public QuotedStringTokenizer(String str, String delim, boolean returnDelimiters, boolean returnQuotes) { - super(""); - _string = str; - if (delim != null) - _delim = delim; - _returnDelimiters = returnDelimiters; - _returnQuotes = returnQuotes; - - if (_delim.indexOf('\'') >= 0 || _delim.indexOf('"') >= 0) - throw new Error("Can't use quotes as delimiters: " + _delim); - - _token = new StringBuilder(_string.length() > 1024 ? 512 : _string.length() / 2); - } - - /* ------------------------------------------------------------ */ - public QuotedStringTokenizer(String str, String delim, boolean returnDelimiters) { - this(str, delim, returnDelimiters, false); - } - - /* ------------------------------------------------------------ */ - public QuotedStringTokenizer(String str, String delim) { - this(str, delim, false, false); - } - - /* ------------------------------------------------------------ */ - public QuotedStringTokenizer(String str) { - this(str, null, false, false); - } - - public String[] toArray() { - List r = new ArrayList(); - while (hasMoreTokens()) - r.add(nextToken()); - return r.toArray(new String[r.size()]); - } - - /* ------------------------------------------------------------ */ - @Override - public boolean hasMoreTokens() { - // Already found a token - if (_hasToken) - return true; - - _lastStart = _i; - - int state = 0; - boolean escape = false; - while (_i < _string.length()) { - char c = _string.charAt(_i++); - - switch (state) { - case 0: // Start - if (_delim.indexOf(c) >= 0) { - if (_returnDelimiters) { - _token.append(c); - return _hasToken = true; - } - } else if (c == '\'' && _single) { - if (_returnQuotes) - _token.append(c); - state = 2; - } else if (c == '\"' && _double) { - if (_returnQuotes) - _token.append(c); - state = 3; - } else { - _token.append(c); - _hasToken = true; - state = 1; - } - continue; - - case 1: // Token - _hasToken = true; - if (escape) { - escape = false; - if (ESCAPABLE_CHARS.indexOf(c) < 0) - _token.append('\\'); - _token.append(c); - } else if (_delim.indexOf(c) >= 0) { - if (_returnDelimiters) - _i--; - return _hasToken; - } else if (c == '\'' && _single) { - if (_returnQuotes) - _token.append(c); - state = 2; - } else if (c == '\"' && _double) { - if (_returnQuotes) - _token.append(c); - state = 3; - } else if (c == '\\') { - escape = true; - } else - _token.append(c); - continue; - - case 2: // Single Quote - _hasToken = true; - if (escape) { - escape = false; - if (ESCAPABLE_CHARS.indexOf(c) < 0) - _token.append('\\'); - _token.append(c); - } else if (c == '\'') { - if (_returnQuotes) - _token.append(c); - state = 1; - } else if (c == '\\') { - if (_returnQuotes) - _token.append(c); - escape = true; - } else - _token.append(c); - continue; - - case 3: // Double Quote - _hasToken = true; - if (escape) { - escape = false; - if (ESCAPABLE_CHARS.indexOf(c) < 0) - _token.append('\\'); - _token.append(c); - } else if (c == '\"') { - if (_returnQuotes) - _token.append(c); - state = 1; - } else if (c == '\\') { - if (_returnQuotes) - _token.append(c); - escape = true; - } else - _token.append(c); - continue; - } - } - - return _hasToken; - } - - /* ------------------------------------------------------------ */ - @Override - public String nextToken() throws NoSuchElementException { - if (!hasMoreTokens() || _token == null) - throw new NoSuchElementException(); - String t = _token.toString(); - _token.setLength(0); - _hasToken = false; - return t; - } - - /* ------------------------------------------------------------ */ - @Override - public String nextToken(String delim) throws NoSuchElementException { - _delim = delim; - _i = _lastStart; - _token.setLength(0); - _hasToken = false; - return nextToken(); - } - - /* ------------------------------------------------------------ */ - @Override - public boolean hasMoreElements() { - return hasMoreTokens(); - } - - /* ------------------------------------------------------------ */ - @Override - public Object nextElement() throws NoSuchElementException { - return nextToken(); - } - - /* ------------------------------------------------------------ */ - /** Not implemented. - */ - @Override - public int countTokens() { - return -1; - } - - /* ------------------------------------------------------------ */ - /** Quote a string. - * The string is quoted only if quoting is required due to - * embeded delimiters, quote characters or the - * empty string. - * @param s The string to quote. - * @return quoted string - */ - public static String quote(String s, String delim) { - if (s == null) - return null; - if (s.length() == 0) - return "\"\""; - - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (c == '\\' || c == '"' || c == '\'' || Character.isWhitespace(c) || delim.indexOf(c) >= 0) { - StringBuffer b = new StringBuffer(s.length() + 8); - quote(b, s); - return b.toString(); - } - } - - return s; - } - - /* ------------------------------------------------------------ */ - /** Quote a string. - * The string is quoted only if quoting is required due to - * embeded delimiters, quote characters or the - * empty string. - * @param s The string to quote. - * @return quoted string - */ - public static String quote(String s) { - if (s == null) - return null; - if (s.length() == 0) - return "\"\""; - - StringBuffer b = new StringBuffer(s.length() + 8); - quote(b, s); - return b.toString(); - - } - - /* ------------------------------------------------------------ */ - /** Quote a string into a StringBuffer. - * The characters ", \, \n, \r, \t, \f and \b are escaped - * @param buf The StringBuffer - * @param s The String to quote. - */ - public static void quote(StringBuffer buf, String s) { - synchronized (buf) { - buf.append('"'); - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - switch (c) { - case '"': - buf.append("\\\""); - continue; - case '\\': - buf.append("\\\\"); - continue; - case '\n': - buf.append("\\n"); - continue; - case '\r': - buf.append("\\r"); - continue; - case '\t': - buf.append("\\t"); - continue; - case '\f': - buf.append("\\f"); - continue; - case '\b': - buf.append("\\b"); - continue; - - default: - buf.append(c); - continue; - } - } - buf.append('"'); - } - } - - /* ------------------------------------------------------------ */ - /** Unquote a string. - * @param s The string to unquote. - * @return quoted string - */ - public static String unquote(String s) { - if (s == null) - return null; - if (s.length() < 2) - return s; - - char first = s.charAt(0); - char last = s.charAt(s.length() - 1); - if (first != last || (first != '"' && first != '\'')) - return s; - - StringBuilder b = new StringBuilder(s.length() - 2); - boolean escape = false; - for (int i = 1; i < s.length() - 1; i++) { - char c = s.charAt(i); - - if (escape) { - escape = false; - switch (c) { - case 'n': - b.append('\n'); - break; - case 'r': - b.append('\r'); - break; - case 't': - b.append('\t'); - break; - case 'f': - b.append('\f'); - break; - case 'b': - b.append('\b'); - break; - case 'u': - b.append((char) ((convertHexDigit((byte) s.charAt(i++)) << 24) + - (convertHexDigit((byte) s.charAt(i++)) << 16) + - (convertHexDigit((byte) s.charAt(i++)) << 8) + - (convertHexDigit((byte) s.charAt(i++))))); - break; - default: - b.append(c); - } - } else if (c == '\\') { - escape = true; - continue; - } else - b.append(c); - } - - return b.toString(); - } - - /* ------------------------------------------------------------ */ - /** - * @return handle double quotes if true - */ - public boolean getDouble() { - return _double; - } - - /* ------------------------------------------------------------ */ - /** - * @param d handle double quotes if true - */ - public void setDouble(boolean d) { - _double = d; - } - - /* ------------------------------------------------------------ */ - /** - * @return handle single quotes if true - */ - public boolean getSingle() { - return _single; - } - - /* ------------------------------------------------------------ */ - /** - * @param single handle single quotes if true - */ - public void setSingle(boolean single) { - _single = single; - } - - /** - * @param b An ASCII encoded character 0-9 a-f A-F - * @return The byte value of the character 0-16. - */ - public static byte convertHexDigit(byte b) { - if ((b >= '0') && (b <= '9')) - return (byte) (b - '0'); - if ((b >= 'a') && (b <= 'f')) - return (byte) (b - 'a' + 10); - if ((b >= 'A') && (b <= 'F')) - return (byte) (b - 'A' + 10); - return 0; - } - - /** - * Characters that can be escaped with \. - * - * Others, like, say, \W will be left alone instead of becoming just W. - * This is important to keep Hudson behave on Windows, which uses '\' as - * the directory separator. - */ - private static final String ESCAPABLE_CHARS = "\\\"' "; -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/Util.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/Util.java deleted file mode 100644 index dd15d58f67d02..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/Util.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.util.Collection; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - - -public class Util { - - /** - * Pattern for capturing variables. Either $xyz, ${xyz} or ${a.b} but not $a.b, while ignoring "$$" - */ - private static final Pattern VARIABLE = Pattern.compile("\\$([A-Za-z0-9_]+|\\{[A-Za-z0-9_.]+\\}|\\$)"); - - /** - * Concatenate multiple strings by inserting a separator. - */ - public static String join(Collection strings, String separator) { - StringBuilder buf = new StringBuilder(); - boolean first = true; - for (Object s : strings) { - if (first) - first = false; - else - buf.append(separator); - buf.append(s); - } - return buf.toString(); - } - - /** - * Replaces the occurrence of '$key' by properties.get('key'). - * - *

- * Unlike shell, undefined variables are left as-is (this behavior is the same as Ant.) - * - */ - - public static String replaceMacro(String s, Map properties) { - return replaceMacro(s, new VariableResolver.ByMap(properties)); - } - - /** - * Replaces the occurrence of '$key' by resolver.get('key'). - * - *

- * Unlike shell, undefined variables are left as-is (this behavior is the same as Ant.) - */ - public static String replaceMacro(String s, VariableResolver resolver) { - if (s == null) { - return null; - } - - int idx = 0; - while (true) { - Matcher m = VARIABLE.matcher(s); - if (!m.find(idx)) - return s; - - String key = m.group().substring(1); - - // escape the dollar sign or get the key to resolve - String value; - if (key.charAt(0) == '$') { - value = "$"; - } else { - if (key.charAt(0) == '{') - key = key.substring(1, key.length() - 1); - value = resolver.resolve(key); - } - - if (value == null) - idx = m.end(); // skip this - else { - s = s.substring(0, m.start()) + value + s.substring(m.end()); - idx = m.start() + value.length(); - } - } - } - -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/VariableResolver.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/VariableResolver.java deleted file mode 100644 index 67601f6310d87..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/VariableResolver.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -import java.util.Collection; -import java.util.Map; - - -/** - * Resolves variables to its value, while encapsulating - * how that resolution happens. - * - * @author Kohsuke Kawaguchi - */ -@SuppressWarnings("all") -public interface VariableResolver { - /** - * Receives a variable name and obtains the value associated with the name. - * - *

- * This can be implemented simply on top of a {@link Map} (see {@link ByMap}), or - * this can be used like an expression evaluator. - * - * @param name - * Name of the variable to be resolved. - * Never null, never empty. The name shouldn't include the syntactic - * marker of an expression. IOW, it should be "foo" but not "${foo}". - * A part of the goal of this design is to abstract away the expression - * marker syntax. - * @return - * Object referenced by the name. - * Null if not found. - */ - V resolve(String name); - - /** - * Empty resolver that always returns null. - */ - VariableResolver NONE = new VariableResolver() { - public Object resolve(String name) { - return null; - } - }; - - /** - * {@link VariableResolver} backed by a {@link Map}. - */ - final class ByMap implements VariableResolver { - private final Map data; - - public ByMap(Map data) { - this.data = data; - } - - public V resolve(String name) { - return data.get(name); - } - } - - /** - * Union of multiple {@link VariableResolver}. - */ - final class Union implements VariableResolver { - private final VariableResolver[] resolvers; - - public Union(VariableResolver... resolvers) { - this.resolvers = resolvers.clone(); - } - - public Union(Collection> resolvers) { - this.resolvers = resolvers.toArray(new VariableResolver[resolvers.size()]); - } - - public V resolve(String name) { - for (VariableResolver r : resolvers) { - V v = r.resolve(name); - if (v != null) - return v; - } - return null; - } - } -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/VersionNumber.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/VersionNumber.java deleted file mode 100644 index ca6f523120c7f..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/VersionNumber.java +++ /dev/null @@ -1,512 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer; - -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is 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 java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.Iterator; -import java.util.List; -import java.util.ListIterator; -import java.util.Locale; -import java.util.Properties; -import java.util.Stack; - - -/** - * Immutable representation of a version number based on the Mercury version numbering scheme. - * - * {@link VersionNumber}s are {@link Comparable}. - * - *

Special tokens

- *

- * We allow a component to be not just a number, but also "ea", "ea1", "ea2". - * "ea" is treated as "ea0", and eaN < M for any M > 0. - * - *

- * '*' is also allowed as a component, and '*' > M for any M > 0. - * - *

- * 'SNAPSHOT' is also allowed as a component, and "N.SNAPSHOT" is interpreted as "N-1.*" - * - *

- * 2.0.* > 2.0.1 > 2.0.1-SNAPSHOT > 2.0.0.99 > 2.0.0 > 2.0.ea > 2.0
- * 
- * - * This class is re-implemented in 1.415. The class was originally introduced in 1.139 - * - * @since 1.139 - * @author Stephen Connolly (stephenc@apache.org) - * @author Kenney Westerhof (kenney@apache.org) - * @author HervΓ© Boutemy (hboutemy@apache.org) - */ -@SuppressWarnings("all") -public class VersionNumber implements Comparable { - private String value; - - private String canonical; - - private ListItem items; - - private interface Item { - public static final int INTEGER_ITEM = 0; - - public static final int STRING_ITEM = 1; - - public static final int LIST_ITEM = 2; - - public static final int WILDCARD_ITEM = 3; - - public int compareTo(Item item); - - public int getType(); - - public boolean isNull(); - } - - /** - * Represents a wild-card item in the version item list. - */ - private static class WildCardItem implements Item { - - public int compareTo(Item item) { - if (item == null) // 1.* ( > 1.99) > 1 - return 1; - switch (item.getType()) { - case INTEGER_ITEM: - case LIST_ITEM: - case STRING_ITEM: - return 1; - case WILDCARD_ITEM: - return 0; - default: - return 1; - } - } - - public int getType() { - return WILDCARD_ITEM; - } - - public boolean isNull() { - return false; - } - - @Override - public String toString() { - return "*"; - } - } - - /** - * Represents a numeric item in the version item list. - */ - private static class IntegerItem implements Item { - private static final BigInteger BigInteger_ZERO = new BigInteger("0"); - - private final BigInteger value; - - public static final IntegerItem ZERO = new IntegerItem(); - - private IntegerItem() { - this.value = BigInteger_ZERO; - } - - public IntegerItem(String str) { - this.value = new BigInteger(str); - } - - public int getType() { - return INTEGER_ITEM; - } - - public boolean isNull() { - return BigInteger_ZERO.equals(value); - } - - public int compareTo(Item item) { - if (item == null) { - return BigInteger_ZERO.equals(value) ? 0 : 1; // 1.0 == 1, 1.1 > 1 - } - - switch (item.getType()) { - case INTEGER_ITEM: - return value.compareTo(((IntegerItem) item).value); - - case STRING_ITEM: - return 1; // 1.1 > 1-sp - - case LIST_ITEM: - return 1; // 1.1 > 1-1 - - case WILDCARD_ITEM: - return 0; - - default: - throw new RuntimeException("invalid item: " + item.getClass()); - } - } - - public String toString() { - return value.toString(); - } - } - - /** - * Represents a string in the version item list, usually a qualifier. - */ - private static class StringItem implements Item { - private final static String[] QUALIFIERS = { "snapshot", "alpha", "beta", "milestone", "rc", "", "sp" }; - - private final static List _QUALIFIERS = Arrays.asList(QUALIFIERS); - - private final static Properties ALIASES = new Properties(); - - static { - ALIASES.put("ga", ""); - ALIASES.put("final", ""); - ALIASES.put("cr", "rc"); - ALIASES.put("ea", "rc"); - } - - /** - * A comparable for the empty-string qualifier. This one is used to determine if a given qualifier makes the - * version older than one without a qualifier, or more recent. - */ - private static String RELEASE_VERSION_INDEX = String.valueOf(_QUALIFIERS.indexOf("")); - - private String value; - - public StringItem(String value, boolean followedByDigit) { - if (followedByDigit && value.length() == 1) { - // a1 = alpha-1, b1 = beta-1, m1 = milestone-1 - switch (value.charAt(0)) { - case 'a': - value = "alpha"; - break; - case 'b': - value = "beta"; - break; - case 'm': - value = "milestone"; - break; - } - } - this.value = ALIASES.getProperty(value, value); - } - - public int getType() { - return STRING_ITEM; - } - - public boolean isNull() { - return (comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX) == 0); - } - - /** - * Returns a comparable for a qualifier. - *

- * This method both takes into account the ordering of known qualifiers as well as lexical ordering for unknown - * qualifiers. - *

- * just returning an Integer with the index here is faster, but requires a lot of if/then/else to check for -1 - * or QUALIFIERS.size and then resort to lexical ordering. Most comparisons are decided by the first character, - * so this is still fast. If more characters are needed then it requires a lexical sort anyway. - * - * @param qualifier - * @return - */ - public static String comparableQualifier(String qualifier) { - int i = _QUALIFIERS.indexOf(qualifier); - - return i == -1 ? _QUALIFIERS.size() + "-" + qualifier : String.valueOf(i); - } - - public int compareTo(Item item) { - if (item == null) { - // 1-rc < 1, 1-ga > 1 - return comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX); - } - switch (item.getType()) { - case INTEGER_ITEM: - return -1; // 1.any < 1.1 ? - - case STRING_ITEM: - return comparableQualifier(value).compareTo(comparableQualifier(((StringItem) item).value)); - - case LIST_ITEM: - return -1; // 1.any < 1-1 - - case WILDCARD_ITEM: - return -1; - - default: - throw new RuntimeException("invalid item: " + item.getClass()); - } - } - - public String toString() { - return value; - } - } - - /** - * Represents a version list item. This class is used both for the global item list and for sub-lists (which start - * with '-(number)' in the version specification). - */ - private static class ListItem extends ArrayList implements Item { - public int getType() { - return LIST_ITEM; - } - - public boolean isNull() { - return (size() == 0); - } - - void normalize() { - for (ListIterator iterator = listIterator(size()); iterator.hasPrevious();) { - Item item = (Item) iterator.previous(); - if (item.isNull()) { - iterator.remove(); // remove null trailing items: 0, "", empty list - } else { - break; - } - } - } - - public int compareTo(Item item) { - if (item == null) { - if (size() == 0) { - return 0; // 1-0 = 1- (normalize) = 1 - } - Item first = (Item) get(0); - return first.compareTo(null); - } - - switch (item.getType()) { - case INTEGER_ITEM: - return -1; // 1-1 < 1.0.x - - case STRING_ITEM: - return 1; // 1-1 > 1-sp - - case LIST_ITEM: - Iterator left = iterator(); - Iterator right = ((ListItem) item).iterator(); - - while (left.hasNext() || right.hasNext()) { - Item l = left.hasNext() ? (Item) left.next() : null; - Item r = right.hasNext() ? (Item) right.next() : null; - - // if this is shorter, then invert the compare and mul with -1 - int result = l == null ? -1 * r.compareTo(l) : l.compareTo(r); - - if (result != 0) { - return result; - } - } - - return 0; - - case WILDCARD_ITEM: - return -1; - - default: - throw new RuntimeException("invalid item: " + item.getClass()); - } - } - - public String toString() { - StringBuilder buffer = new StringBuilder("("); - for (Iterator iter = iterator(); iter.hasNext();) { - buffer.append(iter.next()); - if (iter.hasNext()) { - buffer.append(','); - } - } - buffer.append(')'); - return buffer.toString(); - } - } - - public VersionNumber(String version) { - parseVersion(version); - } - - private void parseVersion(String version) { - this.value = version; - - items = new ListItem(); - - version = version.toLowerCase(Locale.ENGLISH); - - ListItem list = items; - - Stack stack = new Stack(); - stack.push(list); - - boolean isDigit = false; - - int startIndex = 0; - - for (int i = 0; i < version.length(); i++) { - char c = version.charAt(i); - - if (c == '.') { - if (i == startIndex) { - list.add(IntegerItem.ZERO); - } else { - list.add(parseItem(isDigit, version.substring(startIndex, i))); - } - startIndex = i + 1; - } else if (c == '-') { - if (i == startIndex) { - list.add(IntegerItem.ZERO); - } else { - list.add(parseItem(isDigit, version.substring(startIndex, i))); - } - startIndex = i + 1; - - if (isDigit) { - list.normalize(); // 1.0-* = 1-* - - if ((i + 1 < version.length()) && Character.isDigit(version.charAt(i + 1))) { - // new ListItem only if previous were digits and new char is a digit, - // ie need to differentiate only 1.1 from 1-1 - list.add(list = new ListItem()); - - stack.push(list); - } - } - } else if (c == '*') { - list.add(new WildCardItem()); - startIndex = i + 1; - } else if (Character.isDigit(c)) { - if (!isDigit && i > startIndex) { - list.add(new StringItem(version.substring(startIndex, i), true)); - startIndex = i; - } - - isDigit = true; - } else if (Character.isWhitespace(c)) { - if (i > startIndex) { - if (isDigit) { - list.add(parseItem(true, version.substring(startIndex, i))); - } else { - list.add(new StringItem(version.substring(startIndex, i), true)); - } - startIndex = i; - } - - isDigit = false; - } else { - if (isDigit && i > startIndex) { - list.add(parseItem(true, version.substring(startIndex, i))); - startIndex = i; - } - - isDigit = false; - } - } - - if (version.length() > startIndex) { - list.add(parseItem(isDigit, version.substring(startIndex))); - } - - while (!stack.isEmpty()) { - list = (ListItem) stack.pop(); - list.normalize(); - } - - canonical = items.toString(); - } - - private static Item parseItem(boolean isDigit, String buf) { - return isDigit ? (Item) new IntegerItem(buf) : (Item) new StringItem(buf, false); - } - - public int compareTo(VersionNumber o) { - return items.compareTo(o.items); - } - - public String toString() { - return value; - } - - public boolean equals(Object o) { - return (o instanceof VersionNumber) && canonical.equals(((VersionNumber) o).canonical); - } - - public int hashCode() { - return canonical.hashCode(); - } - - public boolean isOlderThan(VersionNumber rhs) { - return compareTo(rhs) < 0; - } - - public boolean isNewerThan(VersionNumber rhs) { - return compareTo(rhs) > 0; - } - - public int digit(int idx) { - Iterator i = items.iterator(); - Item item = (Item) i.next(); - while (idx > 0 && i.hasNext()) { - if (item instanceof IntegerItem) { - idx--; - } - i.next(); - } - return ((IntegerItem) item).value.intValue(); - } - - public static final Comparator DESCENDING = new Comparator() { - public int compare(VersionNumber o1, VersionNumber o2) { - return o2.compareTo(o1); - } - }; -} diff --git a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/jna/GNUCLibrary.java b/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/jna/GNUCLibrary.java deleted file mode 100644 index e9cff8ea95a3c..0000000000000 --- a/localstack/ext/java/src/main/java/org/ow2/proactive/process_tree_killer/jna/GNUCLibrary.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * ProActive Parallel Suite(TM): - * The Open Source library for parallel and distributed - * Workflows & Scheduling, Orchestration, Cloud Automation - * and Big Data Analysis on Enterprise Grids & Clouds. - * - * Copyright (c) 2007 - 2017 ActiveEon - * Contact: contact@activeeon.com - * - * This library is free software: you can redistribute it and/or - * modify it under the terms of the GNU Affero General Public License - * as published by the Free Software Foundation: version 3 of - * the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * If needed, contact us to obtain a release under GPL Version 2 or 3 - * or a different license than the AGPL. - */ -package org.ow2.proactive.process_tree_killer.jna; - -import com.sun.jna.Library; -import com.sun.jna.Memory; -import com.sun.jna.Native; -import com.sun.jna.NativeLong; -import com.sun.jna.Pointer; -import com.sun.jna.StringArray; -import com.sun.jna.ptr.IntByReference; - - -/** - * GNU C library. - * - *

- * Not available on all platforms (such as Linux/PPC, IBM mainframe, etc.), so the caller should recover gracefully - * in case of {@link LinkageError}. See HUDSON-4820. - * @author Kohsuke Kawaguchi - */ -public interface GNUCLibrary extends Library { - int fork(); - - int kill(int pid, int signum); - - int setsid(); - - int umask(int mask); - - int getpid(); - - int geteuid(); - - int getegid(); - - int getppid(); - - int chdir(String dir); - - int getdtablesize(); - - int execv(String path, StringArray args); - - int execvp(String file, StringArray args); - - int setenv(String name, String value, int replace); - - int unsetenv(String name); - - void perror(String msg); - - String strerror(int errno); - - int fcntl(int fd, int command); - - int fcntl(int fd, int command, int flags); - - // obtained from Linux. Needs to be checked if these values are portable. - int F_GETFD = 1; - - int F_SETFD = 2; - - int FD_CLOEXEC = 1; - - int chown(String fileName, int uid, int gid); - - int chmod(String fileName, int i); - - int dup(int old); - - int dup2(int old, int _new); - - int close(int fd); - - // see http://www.gnu.org/s/libc/manual/html_node/Renaming-Files.html - int rename(String oldname, String newname); - - // this is listed in http://developer.apple.com/DOCUMENTATION/Darwin/Reference/ManPages/man3/sysctlbyname.3.html - // but not in http://www.gnu.org/software/libc/manual/html_node/System-Parameters.html#index-sysctl-3493 - // perhaps it is only supported on BSD? - int sysctlbyname(String name, Pointer oldp, IntByReference oldlenp, Pointer newp, IntByReference newlen); - - int sysctl(int[] mib, int nameLen, Pointer oldp, IntByReference oldlenp, Pointer newp, IntByReference newlen); - - int sysctlnametomib(String name, Pointer mibp, IntByReference size); - - /** - * Creates a symlink. - * - * See http://linux.die.net/man/3/symlink - */ - int symlink(String oldname, String newname); - - /** - * Read a symlink. The name will be copied into the specified memory, and returns the number of - * bytes copied. The string is not null-terminated. - * - * @return - * if the return value equals size, the caller needs to retry with a bigger buffer. - * If -1, error. - */ - int readlink(String filename, Memory buffer, NativeLong size); - - GNUCLibrary LIBC = (GNUCLibrary) Native.loadLibrary("c", GNUCLibrary.class); -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/BasicFunctionalityTest.java b/localstack/ext/java/src/test/java/cloud/localstack/BasicFunctionalityTest.java deleted file mode 100644 index ac9a92f191cc2..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/BasicFunctionalityTest.java +++ /dev/null @@ -1,175 +0,0 @@ -package cloud.localstack; - -import static cloud.localstack.TestUtils.TEST_CREDENTIALS; - -import java.io.File; -import java.io.FileOutputStream; -import java.nio.ByteBuffer; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; - -import com.amazonaws.services.kinesis.AmazonKinesis; -import com.amazonaws.services.kinesis.model.ListStreamsResult; -import com.amazonaws.services.kinesis.model.PutRecordRequest; -import com.amazonaws.services.lambda.AWSLambda; -import com.amazonaws.services.lambda.model.CreateEventSourceMappingRequest; -import com.amazonaws.services.lambda.model.CreateFunctionRequest; -import com.amazonaws.services.lambda.model.ListFunctionsResult; -import com.amazonaws.services.lambda.model.Runtime; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.Bucket; -import com.amazonaws.services.sqs.AmazonSQS; -import com.amazonaws.services.sqs.model.CreateQueueRequest; -import com.amazonaws.services.sqs.model.CreateQueueResult; -import com.amazonaws.services.sqs.model.DeleteQueueRequest; -import com.amazonaws.services.sqs.model.ListQueuesResult; -import com.amazonaws.services.sqs.model.ReceiveMessageRequest; -import com.amazonaws.services.sqs.model.ReceiveMessageResult; -import com.amazonaws.services.sqs.model.SendMessageRequest; -import com.amazonaws.services.sqs.model.SendMessageResult; - -import cloud.localstack.sample.KinesisLambdaHandler; -import cloud.localstack.sample.S3Sample; - -/** - * Simple class to test basic functionality and interaction with LocalStack. - * @author Waldemar Hummer - */ -@RunWith(LocalstackTestRunner.class) -public class BasicFunctionalityTest { - - static { - /* - * Need to disable CBOR protocol, see: - * https://github.com/mhart/kinesalite/blob/master/README.md#cbor-protocol-issues-with-the-java-sdk - */ - TestUtils.setEnv("AWS_CBOR_DISABLE", "1"); - /* Disable SSL certificate checks for local testing */ - if (LocalstackTestRunner.useSSL()) { - TestUtils.disableSslCertChecking(); - } - } - - @Test - public void testLocalKinesisAPI() throws InterruptedException { - AmazonKinesis kinesis = TestUtils.getClientKinesis(); - ListStreamsResult streams = kinesis.listStreams(); - Assert.assertNotNull(streams.getStreamNames()); - String streamName = "testStreamJUnit"; - kinesis.createStream(streamName, 1); - // sleep required because of kinesalite - Thread.sleep(500); - PutRecordRequest req = new PutRecordRequest(); - req.setPartitionKey("foobar-key"); - req.setData(ByteBuffer.wrap("{}".getBytes())); - req.setStreamName(streamName); - kinesis.putRecord(req); - final ByteBuffer data = ByteBuffer.wrap("{\"test\":\"test\"}".getBytes()); - kinesis.putRecord(streamName, data, "partition-key"); - } - - @Test - public void testKinesisLambdaIntegration() throws Exception { - AmazonKinesis kinesis = TestUtils.getClientKinesis(); - AWSLambda lambda = TestUtils.getClientLambda(); - String functionName = UUID.randomUUID().toString(); - String streamName = UUID.randomUUID().toString(); - - // create function - CreateFunctionRequest request = new CreateFunctionRequest(); - request.setFunctionName(functionName); - request.setRuntime(Runtime.Java8); - request.setCode(LocalTestUtil.createFunctionCode(KinesisLambdaHandler.class)); - request.setHandler(KinesisLambdaHandler.class.getName()); - lambda.createFunction(request); - - // create stream - kinesis.createStream(streamName, 1); - Thread.sleep(500); - String streamArn = kinesis.describeStream(streamName).getStreamDescription().getStreamARN(); - - // create mapping - CreateEventSourceMappingRequest mapping = new CreateEventSourceMappingRequest(); - mapping.setFunctionName(functionName); - mapping.setEventSourceArn(streamArn); - mapping.setStartingPosition("LATEST"); - lambda.createEventSourceMapping(mapping); - - // push event - kinesis.putRecord(streamName, ByteBuffer.wrap("{\"foo\": \"bar\"}".getBytes()), "partitionKey1"); - // TODO: have Lambda store the record to S3, retrieve it from there, compare result - } - - @Test - public void testLocalS3API() throws Exception { - AmazonS3 s3 = TestUtils.getClientS3(); - List buckets = s3.listBuckets(); - Assert.assertNotNull(buckets); - - // run S3 sample - S3Sample.runTest(TEST_CREDENTIALS); - - // run example with ZIP file upload - String testBucket = UUID.randomUUID().toString(); - s3.createBucket(testBucket); - File file = Files.createTempFile("localstack", "s3").toFile(); - file.deleteOnExit(); - ZipOutputStream zipOutputStream = new ZipOutputStream(new FileOutputStream(file)); - zipOutputStream.putNextEntry(new ZipEntry("Some content")); - zipOutputStream.write("Some text content".getBytes()); - zipOutputStream.closeEntry(); - zipOutputStream.close(); - s3.putObject(testBucket, file.getName(), file); - } - - @Test - public void testLocalLambdaAPI() { - AWSLambda lambda = TestUtils.getClientLambda(); - ListFunctionsResult functions = lambda.listFunctions(); - Assert.assertNotNull(functions.getFunctions()); - } - - @Test - public void testLocalSQSAPI() { - AmazonSQS sqs = TestUtils.getClientSQS(); - ListQueuesResult queues = sqs.listQueues(); - Assert.assertNotNull(queues.getQueueUrls()); - - for (String queueName: Arrays.asList("java_test_queue", "java_test_queue.fifo")) { - // create queue - CreateQueueRequest createQueueRequest = new CreateQueueRequest(); - createQueueRequest.setQueueName(queueName); - CreateQueueResult newQueue = sqs.createQueue(createQueueRequest); - String queueUrl = newQueue.getQueueUrl(); - - // send message - SendMessageRequest send = new SendMessageRequest(queueUrl, "body"); - SendMessageResult sendResult = sqs.sendMessage(send); - Assert.assertNotNull(sendResult.getMD5OfMessageBody()); - - // receive message - ReceiveMessageRequest request = new ReceiveMessageRequest(queueUrl); - request.setWaitTimeSeconds(1); - request.setMaxNumberOfMessages(1); - request.setMessageAttributeNames(Arrays.asList("All")); - request.setAttributeNames(Arrays.asList("All")); - ReceiveMessageResult result = sqs.receiveMessage(request); - Assert.assertNotNull(result.getMessages()); - Assert.assertEquals(result.getMessages().size(), 1); - - // delete queue - DeleteQueueRequest deleteQueue = new DeleteQueueRequest(); - deleteQueue.setQueueUrl(queueUrl); - sqs.deleteQueue(deleteQueue); - } - } - -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/LocalTestUtil.java b/localstack/ext/java/src/test/java/cloud/localstack/LocalTestUtil.java deleted file mode 100644 index 130f1c0c7fc2a..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/LocalTestUtil.java +++ /dev/null @@ -1,63 +0,0 @@ -package cloud.localstack; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.jar.JarEntry; -import java.util.jar.JarOutputStream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import org.apache.commons.io.IOUtils; - -import com.amazonaws.services.kinesis.model.Record; -import com.amazonaws.services.lambda.model.FunctionCode; - -/** - * Utility methods used for the LocalStack unit and integration tests. - * - * @author Waldemar Hummer - */ -public class LocalTestUtil { - - public static FunctionCode createFunctionCode(Class clazz) throws Exception { - FunctionCode code = new FunctionCode(); - ByteArrayOutputStream zipOut = new ByteArrayOutputStream(); - ByteArrayOutputStream jarOut = new ByteArrayOutputStream(); - // create zip file - ZipOutputStream zipStream = new ZipOutputStream(zipOut); - // create jar file - JarOutputStream jarStream = new JarOutputStream(jarOut); - - // write class files into jar stream - addClassToJar(clazz, jarStream); - addClassToJar(Record.class, jarStream); - jarStream.close(); - // write jar into zip stream - ZipEntry zipEntry = new ZipEntry("LambdaCode.jar"); - zipStream.putNextEntry(zipEntry); - zipStream.write(jarOut.toByteArray()); - zipStream.closeEntry(); - - zipStream.close(); - code.setZipFile(ByteBuffer.wrap(zipOut.toByteArray())); - - // TODO tmp - FileOutputStream fos = new FileOutputStream("/tmp/test.zip"); - fos.write(zipOut.toByteArray()); - fos.close(); - - return code; - } - - private static void addClassToJar(Class clazz, JarOutputStream jarStream) throws IOException { - String resource = clazz.getName().replace(".", File.separator) + ".class"; - JarEntry jarEntry = new JarEntry(resource); - jarStream.putNextEntry(jarEntry); - IOUtils.copy(LocalTestUtil.class.getResourceAsStream("/" + resource), jarStream); - jarStream.closeEntry(); - } - -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/S3FeaturesTest.java b/localstack/ext/java/src/test/java/cloud/localstack/S3FeaturesTest.java deleted file mode 100644 index ed85064cbd665..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/S3FeaturesTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package cloud.localstack; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; - -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.s3.model.BucketLifecycleConfiguration; -import com.amazonaws.services.s3.model.GetObjectRequest; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.services.s3.model.PutObjectResult; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.s3.model.SSEAwsKeyManagementParams; -import com.amazonaws.services.s3.model.Tag; -import com.amazonaws.services.s3.model.lifecycle.LifecycleFilter; -import com.amazonaws.services.s3.model.lifecycle.LifecycleTagPredicate; - -@RunWith(LocalstackTestRunner.class) -public class S3FeaturesTest { - - /** - * Test that S3 bucket lifecycle settings can be set and read. - */ - @Test - public void testSetBucketLifecycle() throws Exception { - AmazonS3 client = TestUtils.getClientS3(); - - String bucketName = UUID.randomUUID().toString(); - client.createBucket(bucketName); - - BucketLifecycleConfiguration.Rule rule = new BucketLifecycleConfiguration.Rule() - .withId("expirationRule") - .withFilter(new LifecycleFilter(new LifecycleTagPredicate(new Tag("deleted", "true")))) - .withExpirationInDays(3) - .withStatus(BucketLifecycleConfiguration.ENABLED); - - BucketLifecycleConfiguration bucketLifecycleConfiguration = new BucketLifecycleConfiguration() - .withRules(rule); - - client.setBucketLifecycleConfiguration(bucketName, bucketLifecycleConfiguration); - - bucketLifecycleConfiguration = client.getBucketLifecycleConfiguration(bucketName); - - assertNotNull(bucketLifecycleConfiguration); - assertEquals(bucketLifecycleConfiguration.getRules().get(0).getId(), "expirationRule"); - - client.deleteBucket(bucketName); - } - - /** - * Test HTTPS connections with local S3 service - */ - @Test - public void testHttpsConnection() { - if (!LocalstackTestRunner.useSSL()) { - return; - } - - TestUtils.disableSslCertChecking(); - - String bucketName = "test-bucket-https"; - - AmazonS3 amazonS3Client = AmazonS3ClientBuilder.standard() - .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration( - LocalstackTestRunner.getEndpointS3(), - LocalstackTestRunner.getDefaultRegion())) - .withCredentials(TestUtils.getCredentialsProvider()) - .withChunkedEncodingDisabled(true) - .withPathStyleAccessEnabled(true).build(); - InputStream is = new ByteArrayInputStream("test file content".getBytes()); - amazonS3Client.createBucket(bucketName); - PutObjectRequest putObjectRequest = new PutObjectRequest( - bucketName, "key1", is, new ObjectMetadata()). - withSSEAwsKeyManagementParams(new SSEAwsKeyManagementParams("kmsKeyId")); - PutObjectResult result = amazonS3Client.putObject(putObjectRequest); - Assert.assertNotNull(result); - Assert.assertNotNull(result.getMetadata().getContentType()); - Assert.assertNotNull(result.getMetadata().getETag()); - } - - /** - * Test storing and retrieving of S3 object metadata - */ - @Test - public void testMetadata() { - AmazonS3 s3 = TestUtils.getClientS3(); - - String bucketName = UUID.randomUUID().toString(); - s3.createBucket(bucketName); - - String keyWithUnderscores = "__key1"; - String keyWithDashes = keyWithUnderscores.replace("_", "-"); - - Map originalMetadata = new HashMap(); - originalMetadata.put(keyWithUnderscores, "val1"); - - ObjectMetadata objectMetadata = new ObjectMetadata(); - objectMetadata.setUserMetadata(originalMetadata); - - InputStream is = new ByteArrayInputStream("test-string".getBytes(StandardCharsets.UTF_8)); - s3.putObject(new PutObjectRequest(bucketName, "my-key1", is, objectMetadata)); - - S3Object getObj = s3.getObject(new GetObjectRequest(bucketName, "my-key1")); - ObjectMetadata objectMetadataResponse = getObj.getObjectMetadata(); - - Map receivedMetadata = objectMetadataResponse.getUserMetadata(); - - Map actualResult = new HashMap(); - actualResult.put(keyWithDashes, "val1"); - - // TODO: We currently have a bug that converts underscores in metadata keys to dashes. - // See here for details: https://github.com/localstack/localstack/issues/459 - Assert.assertTrue(receivedMetadata.equals(originalMetadata) || receivedMetadata.equals(actualResult) ); - } - -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/S3UploadTest.java b/localstack/ext/java/src/test/java/cloud/localstack/S3UploadTest.java deleted file mode 100644 index 96aa07a34a936..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/S3UploadTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package cloud.localstack; - -import static org.junit.Assert.assertEquals; - -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.UUID; - -import org.apache.commons.codec.binary.Base64; -import org.apache.commons.codec.digest.DigestUtils; -import org.apache.commons.io.IOUtils; -import org.apache.http.entity.ContentType; -import org.junit.Test; -import org.junit.runner.RunWith; - -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.services.s3.model.S3Object; - -/** - * Test S3 uploads to LocalStack - */ -@RunWith(LocalstackTestRunner.class) -public class S3UploadTest { - - /** - * Test based on https://github.com/localstack/localstack/issues/359 - */ - @Test - public void testTrival() throws Exception { - testUpload("{}"); // Some JSON content, just an example - } - - /** - * Tests greater than 128k uploads - * @throws Exception - */ - @Test - public void testGreaterThan128k() throws Exception { - testUpload(String.join("", Collections.nCopies(13108, "abcdefghij"))); // Just slightly more than 2^17 bytes - } - - /** - * Tests less than 128k uploads - * @throws Exception - */ - @Test - public void testLessThan128k() throws Exception { - testUpload(String.join("", Collections.nCopies(13107, "abcdefghij"))); // Just slightly less than 2^17 bytes - } - - private void testUpload(final String dataString) throws Exception { - AmazonS3 client = TestUtils.getClientS3(); - - String bucketName = UUID.randomUUID().toString(); - String keyName = UUID.randomUUID().toString(); - client.createBucket(bucketName); - - byte[] dataBytes = dataString.getBytes(StandardCharsets.UTF_8); - - ObjectMetadata metaData = new ObjectMetadata(); - metaData.setContentType(ContentType.APPLICATION_JSON.toString()); - metaData.setContentEncoding(StandardCharsets.UTF_8.name()); - metaData.setContentLength(dataBytes.length); - - byte[] resultByte = DigestUtils.md5(dataBytes); - String streamMD5 = new String(Base64.encodeBase64(resultByte)); - metaData.setContentMD5(streamMD5); - - PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, keyName, - new ByteArrayInputStream(dataBytes), metaData); - client.putObject(putObjectRequest); - - S3Object object = client.getObject(bucketName, keyName); - String returnedContent = IOUtils.toString(object.getObjectContent(), "utf-8"); - assertEquals(streamMD5, object.getObjectMetadata().getContentMD5()); - assertEquals(returnedContent, dataString); - - client.deleteObject(bucketName, keyName); - client.deleteBucket(bucketName); - } - -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/SQSMessagingTest.java b/localstack/ext/java/src/test/java/cloud/localstack/SQSMessagingTest.java deleted file mode 100644 index 0a8d660f5a10c..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/SQSMessagingTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package cloud.localstack; - -import java.util.HashMap; -import java.util.Map; - -import javax.jms.JMSException; -import javax.jms.MessageConsumer; -import javax.jms.MessageProducer; -import javax.jms.Queue; -import javax.jms.Session; -import javax.jms.TextMessage; - -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.runner.RunWith; - -import com.amazon.sqs.javamessaging.SQSConnection; -import com.amazon.sqs.javamessaging.SQSConnectionFactory; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.services.sqs.AmazonSQS; -import com.amazonaws.services.sqs.model.CreateQueueRequest; -import com.amazonaws.services.sqs.model.CreateQueueResult; - -import cloud.localstack.LocalstackTestRunner; -import cloud.localstack.TestUtils; - -/** - * Test integration of SQS/JMS messaging with LocalStack - * Based on: https://bitbucket.org/atlassian/localstack/issues/24/not-support-sqs-in-jms - */ -@RunWith(LocalstackTestRunner.class) -public class SQSMessagingTest { - - private static final String QUEUE_NAME = "aws_develop_class_jms"; - - @BeforeClass - public static void setup() { - Map attributeMap = new HashMap<>(); - attributeMap.put("DelaySeconds", "0"); - attributeMap.put("MaximumMessageSize", "262144"); - attributeMap.put("MessageRetentionPeriod", "1209600"); - attributeMap.put("ReceiveMessageWaitTimeSeconds", "20"); - attributeMap.put("VisibilityTimeout", "30"); - - AmazonSQS client = TestUtils.getClientSQS(); - CreateQueueRequest createQueueRequest = new CreateQueueRequest(QUEUE_NAME).withAttributes(attributeMap); - CreateQueueResult result = client.createQueue(createQueueRequest); - Assert.assertNotNull(result); - - /* Disable SSL certificate checks for local testing */ - if (LocalstackTestRunner.useSSL()) { - TestUtils.disableSslCertChecking(); - } - } - - @Test - public void testSendMessage() throws JMSException { - SQSConnectionFactory connectionFactory = SQSConnectionFactory.builder().withEndpoint( - LocalstackTestRunner.getEndpointSQS()).withAWSCredentialsProvider( - new AWSStaticCredentialsProvider(TestUtils.TEST_CREDENTIALS)).build(); - SQSConnection connection = connectionFactory.createConnection(); - connection.start(); - Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); - - Queue queue = session.createQueue(QUEUE_NAME); - - // send message - MessageProducer producer = session.createProducer(queue); - TextMessage message = session.createTextMessage("This is a message!"); - producer.send(message); - Assert.assertNotNull(message.getJMSMessageID()); - - // receive message - MessageConsumer consumer = session.createConsumer(queue); - TextMessage received = (TextMessage) consumer.receive(); - Assert.assertNotNull(received); - } - -} \ No newline at end of file diff --git a/localstack/ext/java/src/test/java/cloud/localstack/docker/BasicDockerFunctionalityTest.java b/localstack/ext/java/src/test/java/cloud/localstack/docker/BasicDockerFunctionalityTest.java deleted file mode 100644 index f41841a9cb197..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/docker/BasicDockerFunctionalityTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package cloud.localstack.docker; - -import static org.hamcrest.CoreMatchers.hasItem; -import static org.hamcrest.core.Is.is; -import static org.junit.Assert.assertThat; - -import java.io.File; -import java.io.FileOutputStream; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import javax.jms.MessageConsumer; -import javax.jms.MessageProducer; -import javax.jms.Queue; -import javax.jms.Session; -import javax.jms.TextMessage; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import com.amazon.sqs.javamessaging.SQSConnection; -import com.amazon.sqs.javamessaging.SQSConnectionFactory; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.model.AttributeDefinition; -import com.amazonaws.services.dynamodbv2.model.CreateTableRequest; -import com.amazonaws.services.dynamodbv2.model.KeySchemaElement; -import com.amazonaws.services.dynamodbv2.model.KeyType; -import com.amazonaws.services.dynamodbv2.model.ListTablesResult; -import com.amazonaws.services.dynamodbv2.model.ProvisionedThroughput; -import com.amazonaws.services.dynamodbv2.model.ScalarAttributeType; -import com.amazonaws.services.kinesis.AmazonKinesis; -import com.amazonaws.services.kinesis.model.CreateStreamRequest; -import com.amazonaws.services.kinesis.model.ListStreamsResult; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.Bucket; -import com.amazonaws.services.s3.model.ObjectListing; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.sqs.AmazonSQS; -import com.amazonaws.services.sqs.model.CreateQueueRequest; -import com.amazonaws.services.sqs.model.ListQueuesResult; -import com.amazonaws.util.IOUtils; - -import cloud.localstack.DockerTestUtils; -import cloud.localstack.TestUtils; -import cloud.localstack.docker.annotation.EC2HostNameResolver; -import cloud.localstack.docker.annotation.LocalstackDockerProperties; - -@RunWith(LocalstackDockerTestRunner.class) -@LocalstackDockerProperties(hostNameResolver = EC2HostNameResolver.class) -public class BasicDockerFunctionalityTest { - - static { - TestUtils.setEnv("AWS_CBOR_DISABLE", "1"); - } - - - @Test - public void testKinesis() throws Exception { - AmazonKinesis kinesis = DockerTestUtils.getClientKinesis(); - - ListStreamsResult streamsResult = kinesis.listStreams(); - assertThat(streamsResult.getStreamNames().size(), is(0)); - - CreateStreamRequest createStreamRequest = new CreateStreamRequest() - .withStreamName("test-stream") - .withShardCount(2); - - kinesis.createStream(createStreamRequest); - - streamsResult = kinesis.listStreams(); - assertThat(streamsResult.getStreamNames(), hasItem("test-stream")); - } - - - @Test - public void testDynamo() throws Exception { - AmazonDynamoDB dynamoDB = DockerTestUtils.getClientDynamoDb(); - - ListTablesResult tablesResult = dynamoDB.listTables(); - assertThat(tablesResult.getTableNames().size(), is(0)); - - CreateTableRequest createTableRequest = new CreateTableRequest() - .withTableName("test.table") - .withKeySchema(new KeySchemaElement("identifier", KeyType.HASH)) - .withAttributeDefinitions(new AttributeDefinition("identifier", ScalarAttributeType.S)) - .withProvisionedThroughput(new ProvisionedThroughput(10L, 10L)); - dynamoDB.createTable(createTableRequest); - - tablesResult = dynamoDB.listTables(); - assertThat(tablesResult.getTableNames(), hasItem("test.table")); - } - - - @Test - public void testS3() throws Exception { - AmazonS3 client = DockerTestUtils.getClientS3(); - - client.createBucket("test-bucket"); - List bucketList = client.listBuckets(); - - assertThat(bucketList.size(), is(1)); - - File file = File.createTempFile("localstack", "s3"); - file.deleteOnExit(); - - try(FileOutputStream stream = new FileOutputStream(file)) { - String content = "HELLO WORLD!"; - stream.write(content.getBytes()); - } - - PutObjectRequest request = new PutObjectRequest("test-bucket", "testData", file); - client.putObject(request); - - ObjectListing listing = client.listObjects("test-bucket"); - assertThat(listing.getObjectSummaries().size(), is(1)); - - S3Object s3Object = client.getObject("test-bucket", "testData"); - String resultContent = IOUtils.toString(s3Object.getObjectContent()); - - assertThat(resultContent, is("HELLO WORLD!")); - } - - - @Test - public void testSQS() throws Exception { - AmazonSQS client = DockerTestUtils.getClientSQS(); - - Map attributeMap = new HashMap<>(); - attributeMap.put("DelaySeconds", "0"); - attributeMap.put("MaximumMessageSize", "262144"); - attributeMap.put("MessageRetentionPeriod", "1209600"); - attributeMap.put("ReceiveMessageWaitTimeSeconds", "20"); - attributeMap.put("VisibilityTimeout", "30"); - - CreateQueueRequest createQueueRequest = new CreateQueueRequest("test-queue").withAttributes(attributeMap); - client.createQueue(createQueueRequest); - - ListQueuesResult listQueuesResult = client.listQueues(); - assertThat(listQueuesResult.getQueueUrls().size(), is(1)); - - SQSConnection connection = createSQSConnection(); - connection.start(); - Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); - - Queue queue = session.createQueue("test-queue"); - - MessageProducer producer = session.createProducer(queue); - TextMessage message = session.createTextMessage("Hello World!"); - producer.send(message); - - MessageConsumer consumer = session.createConsumer(queue); - TextMessage received = (TextMessage) consumer.receive(); - assertThat(received.getText(), is ("Hello World!")); - } - - - private SQSConnection createSQSConnection() throws Exception { - SQSConnectionFactory connectionFactory = SQSConnectionFactory.builder().withEndpoint( - LocalstackDockerTestRunner.getEndpointSQS()).withAWSCredentialsProvider( - new AWSStaticCredentialsProvider(TestUtils.TEST_CREDENTIALS)).build(); - return connectionFactory.createConnection(); - } -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/sample/KinesisLambdaHandler.java b/localstack/ext/java/src/test/java/cloud/localstack/sample/KinesisLambdaHandler.java deleted file mode 100644 index fd3ee0f83e51e..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/sample/KinesisLambdaHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package cloud.localstack.sample; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.KinesisEvent; - -/** - * Test Lambda handler class triggered from a Kinesis event - */ -public class KinesisLambdaHandler implements RequestHandler { - - @Override - public Object handleRequest(KinesisEvent event, Context context) { - for (KinesisEvent.KinesisEventRecord rec : event.getRecords()) { - String msg = new String(rec.getKinesis().getData().array()); - System.err.println("Kinesis record: " + msg); - } - return "{}"; - } - -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/sample/LambdaHandler.java b/localstack/ext/java/src/test/java/cloud/localstack/sample/LambdaHandler.java deleted file mode 100644 index 32232a1cae568..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/sample/LambdaHandler.java +++ /dev/null @@ -1,17 +0,0 @@ -package cloud.localstack.sample; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; - -/** - * Test Lambda handler class - */ -public class LambdaHandler implements RequestHandler { - - @Override - public Object handleRequest(Object event, Context context) { - System.err.println(event); - return event.getClass(); - } - -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/sample/LambdaStreamHandler.java b/localstack/ext/java/src/test/java/cloud/localstack/sample/LambdaStreamHandler.java deleted file mode 100644 index 52fd66972b925..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/sample/LambdaStreamHandler.java +++ /dev/null @@ -1,24 +0,0 @@ -package cloud.localstack.sample; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestStreamHandler; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import org.apache.commons.io.IOUtils; - -/** - * Test Lambda stream handler class - */ -public class LambdaStreamHandler implements RequestStreamHandler { - - @Override - public void handleRequest(InputStream input, OutputStream output, Context context) { - try { - System.err.println(new String(IOUtils.toByteArray(input))); - output.write("{}".getBytes()); - } catch (IOException e) { - e.printStackTrace(); - } - } -} diff --git a/localstack/ext/java/src/test/java/cloud/localstack/sample/S3Sample.java b/localstack/ext/java/src/test/java/cloud/localstack/sample/S3Sample.java deleted file mode 100644 index 9ebb4bad328fd..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/sample/S3Sample.java +++ /dev/null @@ -1,208 +0,0 @@ -package cloud.localstack.sample; -/* - * Copyright 2010-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://aws.amazon.com/apache2.0 - * - * or in the "license" file accompanying this file. This file is 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 java.io.BufferedReader; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.io.Writer; -import java.util.UUID; - -import com.amazonaws.AmazonClientException; -import com.amazonaws.auth.AWSCredentials; -import com.amazonaws.auth.profile.ProfileCredentialsProvider; -import com.amazonaws.regions.Region; -import com.amazonaws.regions.Regions; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3Client; -import com.amazonaws.services.s3.S3ClientOptions; -import com.amazonaws.services.s3.model.Bucket; -import com.amazonaws.services.s3.model.GetObjectRequest; -import com.amazonaws.services.s3.model.ListObjectsRequest; -import com.amazonaws.services.s3.model.ObjectListing; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.s3.model.S3ObjectSummary; - -import cloud.localstack.LocalstackTestRunner; - -/** - * This sample demonstrates how to make basic requests to Amazon S3 using the - * AWS SDK for Java. - * - * Based on: https://github.com/aws/aws-sdk-java/blob/master/src/samples/AmazonS3/S3Sample.java - */ -public class S3Sample { - - public static void main(String[] args) throws IOException { - /* - * The ProfileCredentialsProvider will return your [default] - * credential profile by reading from the credentials file located at - * (~/.aws/credentials). - */ - AWSCredentials credentials = null; - try { - credentials = new ProfileCredentialsProvider().getCredentials(); - } catch (Exception e) { - throw new AmazonClientException( - "Cannot load the credentials from the credential profiles file. " + - "Please make sure that your credentials file is at the correct " + - "location (~/.aws/credentials), and is in valid format.", - e); - } - runTest(credentials); - } - - public static void runTest(AWSCredentials credentials) throws IOException { - - @SuppressWarnings("deprecation") - AmazonS3 s3 = new AmazonS3Client(credentials); - Region usWest2 = Region.getRegion(Regions.US_WEST_2); - s3.setRegion(usWest2); - s3.setEndpoint(LocalstackTestRunner.getEndpointS3()); - s3.setS3ClientOptions(S3ClientOptions.builder().setPathStyleAccess(true) - .disableChunkedEncoding().build()); - - String bucketName = "my-first-s3-bucket-" + UUID.randomUUID(); - String key = "MyObjectKey"; - - /* - * Create a new S3 bucket - Amazon S3 bucket names are globally unique, - * so once a bucket name has been taken by any user, you can't create - * another bucket with that same name. - * - * You can optionally specify a location for your bucket if you want to - * keep your data closer to your applications or users. - */ - System.out.println("Creating bucket " + bucketName); - if (!s3.doesBucketExist(bucketName)) { - s3.createBucket(bucketName); - } - - /* - * List the buckets in your account - */ - System.out.println("Listing buckets"); - for (Bucket bucket : s3.listBuckets()) { - System.out.println(" - " + bucket.getName()); - } - - /* - * Upload an object to your bucket - You can easily upload a file to - * S3, or upload directly an InputStream if you know the length of - * the data in the stream. You can also specify your own metadata - * when uploading to S3, which allows you set a variety of options - * like content-type and content-encoding, plus additional metadata - * specific to your applications. - */ - System.out.println("Uploading a new object to S3 from a file"); - s3.putObject(new PutObjectRequest(bucketName, key, createSampleFile())); - - /* - * Download an object - When you download an object, you get all of - * the object's metadata and a stream from which to read the contents. - * It's important to read the contents of the stream as quickly as - * possibly since the data is streamed directly from Amazon S3 and your - * network connection will remain open until you read all the data or - * close the input stream. - * - * GetObjectRequest also supports several other options, including - * conditional downloading of objects based on modification times, - * ETags, and selectively downloading a range of an object. - */ - System.out.println("Downloading an object"); - S3Object object = s3.getObject(new GetObjectRequest(bucketName, key)); - System.out.println("Content-Type: " + object.getObjectMetadata().getContentType()); - displayTextInputStream(object.getObjectContent()); - - /* - * List objects in your bucket by prefix - There are many options for - * listing the objects in your bucket. Keep in mind that buckets with - * many objects might truncate their results when listing their objects, - * so be sure to check if the returned object listing is truncated, and - * use the AmazonS3.listNextBatchOfObjects(...) operation to retrieve - * additional results. - */ - System.out.println("Listing objects"); - ObjectListing objectListing = s3.listObjects(new ListObjectsRequest() - .withBucketName(bucketName) - .withPrefix("My")); - for (S3ObjectSummary objectSummary : objectListing.getObjectSummaries()) { - System.out.println(" - " + objectSummary.getKey() + " " + - "(size = " + objectSummary.getSize() + ")"); - } - - /* - * Delete an object - Unless versioning has been turned on for your bucket, - * there is no way to undelete an object, so use caution when deleting objects. - */ - System.out.println("Deleting an object"); - s3.deleteObject(bucketName, key); - - /* - * Delete a bucket - A bucket must be completely empty before it can be - * deleted, so remember to delete any objects from your buckets before - * you try to delete them. - */ - System.out.println("Deleting bucket " + bucketName); - s3.deleteBucket(bucketName); - } - - /** - * Creates a temporary file with text data to demonstrate uploading a file - * to Amazon S3 - * - * @return A newly created temporary file with text data. - * - * @throws IOException - */ - private static File createSampleFile() throws IOException { - File file = File.createTempFile("aws-java-sdk-", ".txt"); - file.deleteOnExit(); - - Writer writer = new OutputStreamWriter(new FileOutputStream(file)); - writer.write("abcdefghijklmnopqrstuvwxyz\n"); - writer.write("01234567890112345678901234\n"); - writer.write("!@#$%^&*()-=[]{};':',.<>/?\n"); - writer.write("01234567890112345678901234\n"); - writer.write("abcdefghijklmnopqrstuvwxyz\n"); - writer.close(); - - return file; - } - - /** - * Displays the contents of the specified input stream as text. - * - * @param input - * The input stream to display as text. - * - * @throws IOException - */ - private static void displayTextInputStream(InputStream input) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(input)); - while (true) { - String line = reader.readLine(); - if (line == null) break; - - System.out.println(" " + line); - } - System.out.println(); - } - -} \ No newline at end of file diff --git a/localstack/ext/java/src/test/java/cloud/localstack/testcontainers/TestContainersSqsTest.java b/localstack/ext/java/src/test/java/cloud/localstack/testcontainers/TestContainersSqsTest.java deleted file mode 100644 index 10a215a08eee3..0000000000000 --- a/localstack/ext/java/src/test/java/cloud/localstack/testcontainers/TestContainersSqsTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package cloud.localstack.testcontainers; - -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.sqs.AmazonSQS; -import com.amazonaws.services.sqs.AmazonSQSClientBuilder; -import com.amazonaws.services.sqs.model.CreateQueueResult; -import com.amazonaws.services.sqs.model.Message; -import com.amazonaws.services.sqs.model.ReceiveMessageResult; -import com.amazonaws.services.sqs.model.SendMessageResult; -import org.junit.Before; -import org.junit.Test; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.LogMessageWaitStrategy; - -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.testcontainers.containers.BindMode.READ_WRITE; - -/** - *

- * This test is used to ensure that the bug of #308 is fixed. - *

- *

- * In this test the localstack docker images will be started by the testcontainers framework. - * SQS will then be used to send some messages. - *

- *

- * The goal of this test is to check that the random port mapping of testcontainers is working with localstack. - *

- */ -public class TestContainersSqsTest { - - private static final String DOCKER_IMAGE_NAME = "localstack/localstack:latest"; - - private static final int SQS_PORT = 4576; - - private AmazonSQS amazonSQS; - - private GenericContainer genericContainer; - - @Before - public void before() { - - startDockerImage(); - createSqsClient(); - - } - - private void createSqsClient() { - - /* - * get the randomly generated SQS port - */ - final Integer mappedPort = genericContainer.getMappedPort(SQS_PORT); - - /* - * create the SQS client - */ - final AwsClientBuilder.EndpointConfiguration endpointConfiguration = new AwsClientBuilder.EndpointConfiguration( - "http://localhost:" + mappedPort, - "us-east-1"); - - final AWSStaticCredentialsProvider awsStaticCredentialsProvider = new AWSStaticCredentialsProvider( - new BasicAWSCredentials("accesskey", "secretkey")); - - amazonSQS = AmazonSQSClientBuilder - .standard() - .withEndpointConfiguration(endpointConfiguration) - .withCredentials(awsStaticCredentialsProvider) - .build(); - } - - @Test - public void sendAndReceiveMessageTest() { - - /* - * create the queue - */ - final CreateQueueResult queue = amazonSQS.createQueue("test-queue"); - final String queueUrl = queue.getQueueUrl(); - - /* - * send a message to the queue - */ - final String messageBody = "test-message"; - final SendMessageResult sendMessageResult = amazonSQS.sendMessage(queueUrl, messageBody); - assertNotNull(sendMessageResult); - - final String messageId = sendMessageResult.getMessageId(); - assertNotNull(messageId); - - /* - * receive the message from the queue - */ - final ReceiveMessageResult messageResult = amazonSQS.receiveMessage(queueUrl); - assertNotNull(messageResult); - - /* - * compare results - */ - final List messages = messageResult.getMessages(); - assertNotNull(messages); - assertEquals(1, messages.size()); - - final Message message = messages.get(0); - assertEquals(messageId, message.getMessageId()); - assertEquals(messageBody, message.getBody()); - - } - - private void startDockerImage() { - - genericContainer = new GenericContainer(DOCKER_IMAGE_NAME) - .withExposedPorts(SQS_PORT) - .waitingFor(new LogMessageWaitStrategy().withRegEx(".*Ready\\.\n")); - - genericContainer.start(); - } -} diff --git a/localstack/ext/java/src/test/resources/logback.xml b/localstack/ext/java/src/test/resources/logback.xml deleted file mode 100644 index 85f1443898d6b..0000000000000 --- a/localstack/ext/java/src/test/resources/logback.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/localstack/package.json b/localstack/package.json deleted file mode 100644 index 5f6f5a0016847..0000000000000 --- a/localstack/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "localstack", - "author": "Waldemar Hummer (Atlassian) ", - "description": "Local Cloud Stack and Utilities", - "version": "0.0.1", - "dependencies": { - "kinesalite": "1.11.6", - "leveldown": "1.6.0" - } -} diff --git a/localstack/plugins.py b/localstack/plugins.py deleted file mode 100644 index 73177e302b29d..0000000000000 --- a/localstack/plugins.py +++ /dev/null @@ -1,67 +0,0 @@ -from localstack.services.infra import (register_plugin, Plugin, - start_s3, start_sns, start_ses, start_apigateway, - start_elasticsearch_service, start_lambda, start_redshift, start_firehose, - start_cloudwatch, start_cloudformation, start_dynamodbstreams, start_route53, - start_ssm) -from localstack.services.apigateway import apigateway_listener -from localstack.services.cloudformation import cloudformation_listener -from localstack.services.dynamodb import dynamodb_listener, dynamodb_starter -from localstack.services.kinesis import kinesis_listener, kinesis_starter -from localstack.services.sns import sns_listener -from localstack.services.sqs import sqs_listener, sqs_starter -from localstack.services.s3 import s3_listener, s3_starter -from localstack.services.es import es_starter - - -# register default plugins - -def register_localstack_plugins(): - try: - register_plugin(Plugin('es', - start=start_elasticsearch_service)) - register_plugin(Plugin('elasticsearch', - start=es_starter.start_elasticsearch, - check=es_starter.check_elasticsearch)) - register_plugin(Plugin('s3', - start=start_s3, - check=s3_starter.check_s3, - listener=s3_listener.UPDATE_S3)) - register_plugin(Plugin('sns', - start=start_sns, - listener=sns_listener.UPDATE_SNS)) - register_plugin(Plugin('sqs', - start=sqs_starter.start_sqs, - listener=sqs_listener.UPDATE_SQS)) - register_plugin(Plugin('ses', - start=start_ses)) - register_plugin(Plugin('ssm', - start=start_ssm)) - register_plugin(Plugin('apigateway', - start=start_apigateway, - listener=apigateway_listener.UPDATE_APIGATEWAY)) - register_plugin(Plugin('dynamodb', - start=dynamodb_starter.start_dynamodb, - check=dynamodb_starter.check_dynamodb, - listener=dynamodb_listener.UPDATE_DYNAMODB)) - register_plugin(Plugin('dynamodbstreams', - start=start_dynamodbstreams)) - register_plugin(Plugin('firehose', - start=start_firehose)) - register_plugin(Plugin('lambda', - start=start_lambda)) - register_plugin(Plugin('kinesis', - start=kinesis_starter.start_kinesis, - check=kinesis_starter.check_kinesis, - listener=kinesis_listener.UPDATE_KINESIS)) - register_plugin(Plugin('redshift', - start=start_redshift)) - register_plugin(Plugin('route53', - start=start_route53)) - register_plugin(Plugin('cloudformation', - start=start_cloudformation, - listener=cloudformation_listener.UPDATE_CLOUDFORMATION)) - register_plugin(Plugin('cloudwatch', - start=start_cloudwatch)) - except Exception as e: - print('Unable to register plugins: %s' % e) - raise e diff --git a/localstack/services/apigateway/apigateway_listener.py b/localstack/services/apigateway/apigateway_listener.py deleted file mode 100644 index a2e3a30437c0a..0000000000000 --- a/localstack/services/apigateway/apigateway_listener.py +++ /dev/null @@ -1,274 +0,0 @@ -import re -import logging -import json -import requests -import dateutil.parser -from requests.models import Response -from flask import Response as FlaskResponse -from localstack.constants import APPLICATION_JSON, PATH_USER_REQUEST -from localstack.config import TEST_KINESIS_URL -from localstack.utils import common -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_str, to_bytes -from localstack.services.awslambda import lambda_api -from localstack.services.kinesis import kinesis_listener -from localstack.services.generic_proxy import ProxyListener - -# set up logger -LOGGER = logging.getLogger(__name__) - -# regex path patterns -PATH_REGEX_MAIN = r'^/restapis/([A-Za-z0-9_\-]+)/[a-z]+(\?.*)?' -PATH_REGEX_SUB = r'^/restapis/([A-Za-z0-9_\-]+)/[a-z]+/([A-Za-z0-9_\-]+)/.*' -PATH_REGEX_AUTHORIZERS = r'^/restapis/([A-Za-z0-9_\-]+)/authorizers(\?.*)?' - -# maps API ids to authorizers -AUTHORIZERS = {} - - -def _create_response_object(content, code, headers): - response = Response() - response.status_code = code - response.headers = headers - response._content = content - return response - - -def make_response(message): - return _create_response_object(json.dumps(message), code=200, headers={'Content-Type': APPLICATION_JSON}) - - -def flask_to_requests_response(r): - return _create_response_object(r.data, code=r.status_code, headers=r.headers) - - -def make_error(message, code=400): - response = Response() - response.status_code = code - response._content = json.dumps({'message': message}) - return response - - -def get_api_id_from_path(path): - match = re.match(PATH_REGEX_SUB, path) - if match: - return match.group(1) - return re.match(PATH_REGEX_MAIN, path).group(1) - - -def get_authorizers(path): - result = {'item': []} - api_id = get_api_id_from_path(path) - for key, value in AUTHORIZERS.items(): - auth_api_id = get_api_id_from_path(value['_links']['self']['href']) - if auth_api_id == api_id: - result['item'].append(value) - return result - - -def add_authorizer(path, data): - api_id = get_api_id_from_path(path) - result = common.clone(data) - result['id'] = common.short_uid() - if '_links' not in result: - result['_links'] = {} - result['_links']['self'] = { - 'href': '/restapis/%s/authorizers/%s' % (api_id, result['id']) - } - AUTHORIZERS[result['id']] = result - return result - - -def handle_authorizers(method, path, data, headers): - result = {} - if method == 'GET': - result = get_authorizers(path) - elif method == 'POST': - result = add_authorizer(path, data) - else: - return make_error('Not implemented for API Gateway authorizers: %s' % method, 404) - return make_response(result) - - -def tokenize_path(path): - return path.lstrip('/').split('/') - - -def get_rest_api_paths(rest_api_id): - apigateway = aws_stack.connect_to_service(service_name='apigateway') - resources = apigateway.get_resources(restApiId=rest_api_id, limit=100) - resource_map = {} - for resource in resources['items']: - path = aws_stack.get_apigateway_path_for_resource(rest_api_id, resource['id']) - resource_map[path] = resource - return resource_map - - -def extract_path_params(path, extracted_path): - tokenized_extracted_path = tokenize_path(extracted_path) - # Looks for '{' in the tokenized extracted path - path_params_list = [(i, v) for i, v in enumerate(tokenized_extracted_path) if '{' in v] - tokenized_path = tokenize_path(path) - path_params = {} - for param in path_params_list: - path_param_name = param[1][1:-1].encode('utf-8') - path_param_position = param[0] - if path_param_name.endswith(b'+'): - path_params[path_param_name] = '/'.join(tokenized_path[path_param_position:]) - else: - path_params[path_param_name] = tokenized_path[path_param_position] - path_params = common.json_safe(path_params) - return path_params - - -def get_resource_for_path(path, path_map): - matches = [] - for api_path, details in path_map.items(): - api_path_regex = re.sub(r'\{[^\+]+\+\}', '[^\?#]+', api_path) - api_path_regex = re.sub(r'\{[^\}]+\}', '[^/]+', api_path_regex) - if re.match(r'^%s$' % api_path_regex, path): - matches.append((api_path, details)) - if not matches: - return None - if len(matches) > 1: - # check if we have an exact match - for match in matches: - if match[0] == path: - return match - raise Exception('Ambiguous API path %s - matches found: %s' % (path, matches)) - return matches[0] - - -def get_cors_response(headers): - # TODO: for now we simply return "allow-all" CORS headers, but in the future - # we should implement custom headers for CORS rules, as supported by API Gateway: - # http://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-cors.html - response = Response() - response.status_code = 200 - response.headers['Access-Control-Allow-Origin'] = '*' - response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE' - response.headers['Access-Control-Allow-Headers'] = '*' - response._content = '' - return response - - -class ProxyListenerApiGateway(ProxyListener): - - def forward_request(self, method, path, data, headers): - data = data and json.loads(to_str(data)) - - # Paths to match - regex2 = r'^/restapis/([A-Za-z0-9_\-]+)/([A-Za-z0-9_\-]+)/%s/(.*)$' % PATH_USER_REQUEST - - if re.match(regex2, path): - search_match = re.search(regex2, path) - api_id = search_match.group(1) - relative_path = '/%s' % search_match.group(3) - try: - integration = aws_stack.get_apigateway_integration(api_id, method, path=relative_path) - assert integration - except Exception: - # if we have no exact match, try to find an API resource that contains path parameters - path_map = get_rest_api_paths(rest_api_id=api_id) - try: - extracted_path, resource = get_resource_for_path(path=relative_path, path_map=path_map) - except Exception: - return make_error('Unable to find path %s' % path, 404) - - integrations = resource.get('resourceMethods', {}) - integration = integrations.get(method, {}) - integration = integration.get('methodIntegration') - if not integration: - - if method == 'OPTIONS' and 'Origin' in headers: - # default to returning CORS headers if this is an OPTIONS request - return get_cors_response(headers) - - return make_error('Unable to find integration for path %s' % path, 404) - - uri = integration.get('uri') - if method == 'POST' and integration['type'] == 'AWS': - if uri.endswith('kinesis:action/PutRecords'): - template = integration['requestTemplates'][APPLICATION_JSON] - new_request = aws_stack.render_velocity_template(template, data) - - # forward records to target kinesis stream - headers = aws_stack.mock_aws_request_headers(service='kinesis') - headers['X-Amz-Target'] = kinesis_listener.ACTION_PUT_RECORDS - result = common.make_http_request(url=TEST_KINESIS_URL, - method='POST', data=new_request, headers=headers) - return result - else: - msg = 'API Gateway action uri "%s" not yet implemented' % uri - LOGGER.warning(msg) - return make_error(msg, 404) - - elif integration['type'] == 'AWS_PROXY': - if uri.startswith('arn:aws:apigateway:') and ':lambda:path' in uri: - func_arn = uri.split(':lambda:path')[1].split('functions/')[1].split('/invocations')[0] - data_str = json.dumps(data) if isinstance(data, dict) else data - - try: - path_params = extract_path_params(path=relative_path, extracted_path=extracted_path) - except Exception: - path_params = {} - result = lambda_api.process_apigateway_invocation(func_arn, relative_path, data_str, - headers, path_params=path_params, method=method, resource_path=path) - - if isinstance(result, FlaskResponse): - return flask_to_requests_response(result) - - response = Response() - parsed_result = result if isinstance(result, dict) else json.loads(result) - parsed_result = common.json_safe(parsed_result) - response.status_code = int(parsed_result.get('statusCode', 200)) - response.headers.update(parsed_result.get('headers', {})) - try: - if isinstance(parsed_result['body'], dict): - response._content = json.dumps(parsed_result['body']) - else: - response._content = parsed_result['body'] - except Exception: - response._content = '{}' - return response - else: - msg = 'API Gateway action uri "%s" not yet implemented' % uri - LOGGER.warning(msg) - return make_error(msg, 404) - - elif integration['type'] == 'HTTP': - function = getattr(requests, method.lower()) - if isinstance(data, dict): - data = json.dumps(data) - result = function(integration['uri'], data=data, headers=headers) - return result - - else: - msg = ('API Gateway integration type "%s" for method "%s" not yet implemented' % - (integration['type'], method)) - LOGGER.warning(msg) - return make_error(msg, 404) - - return 200 - - if re.match(PATH_REGEX_AUTHORIZERS, path): - return handle_authorizers(method, path, data, headers) - - return True - - def return_response(self, method, path, data, headers, response): - try: - response_data = json.loads(response.content) - # Fix an upstream issue in Moto API Gateway, where it returns `createdDate` as a string - # instead of as an integer timestamp: - # see https://github.com/localstack/localstack/issues/511 - if 'createdDate' in response_data and not isinstance(response_data['createdDate'], int): - response_data['createdDate'] = int(dateutil.parser.parse(response_data['createdDate']).strftime('%s')) - response._content = to_bytes(json.dumps(response_data)) - response.headers['Content-Length'] = len(response.content) - except Exception: - pass - - -# instantiate listener -UPDATE_APIGATEWAY = ProxyListenerApiGateway() diff --git a/localstack/services/awslambda/lambda_api.py b/localstack/services/awslambda/lambda_api.py deleted file mode 100644 index 76685f79a4d42..0000000000000 --- a/localstack/services/awslambda/lambda_api.py +++ /dev/null @@ -1,778 +0,0 @@ -from __future__ import print_function - -import os -import sys -import json -import uuid -import time -import traceback -import logging -import base64 -import threading -import imp -import glob -from io import BytesIO -from datetime import datetime -from six import iteritems -from six.moves import cStringIO as StringIO -from flask import Flask, Response, jsonify, request, make_response -from localstack import config -from localstack.services import generic_proxy -from localstack.services.awslambda import lambda_executors -from localstack.services.awslambda.lambda_executors import (LAMBDA_RUNTIME_PYTHON27, - LAMBDA_RUNTIME_PYTHON36, LAMBDA_RUNTIME_NODEJS, LAMBDA_RUNTIME_NODEJS610, LAMBDA_RUNTIME_JAVA8) -from localstack.utils.common import (to_str, load_file, save_file, TMP_FILES, - unzip, is_zip_file, run, short_uid, is_jar_archive, timestamp, TIMESTAMP_FORMAT_MILLIS) -from localstack.utils.aws import aws_stack, aws_responses -from localstack.utils.analytics import event_publisher -from localstack.utils.cloudwatch.cloudwatch_util import cloudwatched -from localstack.utils.aws.aws_models import LambdaFunction - -APP_NAME = 'lambda_api' -PATH_ROOT = '/2015-03-31' -ARCHIVE_FILE_PATTERN = '%s/lambda.handler.*.jar' % config.TMP_FOLDER -LAMBDA_SCRIPT_PATTERN = '%s/lambda_script_*.py' % config.TMP_FOLDER - -# List of Lambda runtime names. Keep them in this list, mainly to silence the linter -LAMBDA_RUNTIMES = [LAMBDA_RUNTIME_PYTHON27, LAMBDA_RUNTIME_PYTHON36, - LAMBDA_RUNTIME_NODEJS, LAMBDA_RUNTIME_NODEJS610, LAMBDA_RUNTIME_JAVA8] - -LAMBDA_DEFAULT_HANDLER = 'handler.handler' -LAMBDA_DEFAULT_RUNTIME = LAMBDA_RUNTIME_PYTHON27 -LAMBDA_DEFAULT_STARTING_POSITION = 'LATEST' -LAMBDA_DEFAULT_TIMEOUT = 60 -LAMBDA_ZIP_FILE_NAME = 'original_lambda_archive.zip' - -app = Flask(APP_NAME) - -# map ARN strings to lambda function objects -arn_to_lambda = {} - -# list of event source mappings for the API -event_source_mappings = [] - -# logger -LOG = logging.getLogger(__name__) - -# mutex for access to CWD and ENV -exec_mutex = threading.Semaphore(1) - -# whether to use Docker for execution -DO_USE_DOCKER = None - -# lambda executor instance -LAMBDA_EXECUTOR = lambda_executors.AVAILABLE_EXECUTORS.get(config.LAMBDA_EXECUTOR, lambda_executors.DEFAULT_EXECUTOR) - - -def cleanup(): - global event_source_mappings, arn_to_lambda - arn_to_lambda = {} - event_source_mappings = [] - LAMBDA_EXECUTOR.cleanup() - - -def func_arn(function_name): - return aws_stack.lambda_function_arn(function_name) - - -def add_function_mapping(lambda_name, lambda_handler, lambda_cwd=None): - arn = func_arn(lambda_name) - arn_to_lambda[arn].versions.get('$LATEST')['Function'] = lambda_handler - arn_to_lambda[arn].cwd = lambda_cwd - - -def add_event_source(function_name, source_arn): - mapping = { - 'UUID': str(uuid.uuid4()), - 'StateTransitionReason': 'User action', - 'LastModified': float(time.mktime(datetime.utcnow().timetuple())), - 'BatchSize': 100, - 'State': 'Enabled', - 'FunctionArn': func_arn(function_name), - 'EventSourceArn': source_arn, - 'LastProcessingResult': 'OK', - 'StartingPosition': LAMBDA_DEFAULT_STARTING_POSITION - } - event_source_mappings.append(mapping) - return mapping - - -def update_event_source(uuid_value, function_name, enabled, batch_size): - for m in event_source_mappings: - if uuid_value == m['UUID']: - if function_name: - m['FunctionArn'] = func_arn(function_name) - m['BatchSize'] = batch_size - m['State'] = enabled and 'Enabled' or 'Disabled' - m['LastModified'] = float(time.mktime(datetime.utcnow().timetuple())) - return m - return {} - - -def delete_event_source(uuid_value): - for i, m in enumerate(event_source_mappings): - if uuid_value == m['UUID']: - return event_source_mappings.pop(i) - return {} - - -def use_docker(): - global DO_USE_DOCKER - if DO_USE_DOCKER is None: - DO_USE_DOCKER = False - if 'docker' in config.LAMBDA_EXECUTOR: - try: - run('docker images', print_error=False) - # run('ping -c 1 -t 1 %s' % DOCKER_BRIDGE_IP, print_error=False) - DO_USE_DOCKER = True - except Exception: - pass - return DO_USE_DOCKER - - -def process_apigateway_invocation(func_arn, path, payload, headers={}, - resource_path=None, method=None, path_params={}): - try: - resource_path = resource_path or path - event = { - 'path': path, - 'headers': dict(headers), - 'pathParameters': dict(path_params), - 'body': payload, - 'isBase64Encoded': False, - 'resource': resource_path, - 'httpMethod': method, - 'queryStringParameters': {}, # TODO - 'stageVariables': {} # TODO - } - return run_lambda(event=event, context={}, func_arn=func_arn) - except Exception as e: - LOG.warning('Unable to run Lambda function on API Gateway message: %s %s' % (e, traceback.format_exc())) - - -def process_sns_notification(func_arn, topic_arn, message, subject=''): - try: - event = { - 'Records': [{ - 'Sns': { - 'Type': 'Notification', - 'TopicArn': topic_arn, - 'Subject': subject, - 'Message': message, - 'Timestamp': timestamp(format=TIMESTAMP_FORMAT_MILLIS) - } - }] - } - return run_lambda(event=event, context={}, func_arn=func_arn, async=True) - except Exception as e: - LOG.warning('Unable to run Lambda function on SNS message: %s %s' % (e, traceback.format_exc())) - - -def process_kinesis_records(records, stream_name): - # feed records into listening lambdas - try: - stream_arn = aws_stack.kinesis_stream_arn(stream_name) - sources = get_event_sources(source_arn=stream_arn) - for source in sources: - arn = source['FunctionArn'] - event = { - 'Records': [] - } - for rec in records: - event['Records'].append({ - 'kinesis': rec - }) - run_lambda(event=event, context={}, func_arn=arn) - except Exception as e: - LOG.warning('Unable to run Lambda function on Kinesis records: %s %s' % (e, traceback.format_exc())) - - -def get_event_sources(func_name=None, source_arn=None): - result = [] - for m in event_source_mappings: - if not func_name or m['FunctionArn'] in [func_name, func_arn(func_name)]: - if not source_arn or m['EventSourceArn'].startswith(source_arn): - result.append(m) - return result - - -def get_function_version(arn, version): - func_name = arn.split(':function:')[-1] - return \ - { - 'Version': version, - 'CodeSize': arn_to_lambda.get(arn).get_version(version).get('CodeSize'), - 'FunctionName': func_name, - 'FunctionArn': arn + ':' + str(version), - 'Handler': arn_to_lambda.get(arn).handler, - 'Runtime': arn_to_lambda.get(arn).runtime, - 'Timeout': LAMBDA_DEFAULT_TIMEOUT, - } - - -def publish_new_function_version(arn): - versions = arn_to_lambda.get(arn).versions - if len(versions) == 1: - last_version = 0 - else: - last_version = max([int(key) for key in versions.keys() if key != '$LATEST']) - versions[str(last_version + 1)] = {'CodeSize': versions.get('$LATEST').get('CodeSize'), - 'Function': versions.get('$LATEST').get('Function')} - return get_function_version(arn, str(last_version + 1)) - - -def do_list_versions(arn): - return sorted([get_function_version(arn, version) for version in - arn_to_lambda.get(arn).versions.keys()], key=lambda k: str(k.get('Version'))) - - -def do_update_alias(arn, alias, version, description=None): - new_alias = { - 'AliasArn': arn + ':' + alias, - 'FunctionVersion': version, - 'Name': alias, - 'Description': description or '' - } - arn_to_lambda.get(arn).aliases[alias] = new_alias - return new_alias - - -@cloudwatched('lambda') -def run_lambda(event, context, func_arn, version=None, suppress_output=False, async=False): - if suppress_output: - stdout_ = sys.stdout - stderr_ = sys.stderr - stream = StringIO() - sys.stdout = stream - sys.stderr = stream - try: - func_details = arn_to_lambda.get(func_arn) - result, log_output = LAMBDA_EXECUTOR.execute(func_arn, func_details, - event, context=context, version=version, async=async) - except Exception as e: - return error_response('Error executing Lambda function: %s %s' % (e, traceback.format_exc())) - finally: - if suppress_output: - sys.stdout = stdout_ - sys.stderr = stderr_ - return result - - -def exec_lambda_code(script, handler_function='handler', lambda_cwd=None, lambda_env=None): - if lambda_cwd or lambda_env: - exec_mutex.acquire() - if lambda_cwd: - previous_cwd = os.getcwd() - os.chdir(lambda_cwd) - sys.path = [lambda_cwd] + sys.path - if lambda_env: - previous_env = dict(os.environ) - os.environ.update(lambda_env) - # generate lambda file name - lambda_id = 'l_%s' % short_uid() - lambda_file = LAMBDA_SCRIPT_PATTERN.replace('*', lambda_id) - save_file(lambda_file, script) - # delete temporary .py and .pyc files on exit - TMP_FILES.append(lambda_file) - TMP_FILES.append('%sc' % lambda_file) - try: - handler_module = imp.load_source(lambda_id, lambda_file) - module_vars = handler_module.__dict__ - except Exception as e: - LOG.error('Unable to exec: %s %s' % (script, traceback.format_exc())) - raise e - finally: - if lambda_cwd or lambda_env: - if lambda_cwd: - os.chdir(previous_cwd) - sys.path.pop(0) - if lambda_env: - os.environ = previous_env - exec_mutex.release() - return module_vars[handler_function] - - -def get_handler_file_from_name(handler_name, runtime=LAMBDA_DEFAULT_RUNTIME): - # TODO: support Java Lambdas in the future - file_ext = '.js' if runtime.startswith(LAMBDA_RUNTIME_NODEJS) else '.py' - return '%s%s' % (handler_name.split('.')[0], file_ext) - - -def get_handler_function_from_name(handler_name, runtime=LAMBDA_DEFAULT_RUNTIME): - # TODO: support Java Lambdas in the future - return handler_name.split('.')[-1] - - -def error_response(msg, code=500, error_type='InternalFailure'): - LOG.warning(msg) - return aws_responses.flask_error_response(msg, code=code, error_type=error_type) - - -def set_function_code(code, lambda_name): - - def generic_handler(event, context): - raise Exception(('Unable to find executor for Lambda function "%s". ' + - 'Note that Node.js Lambdas currently require LAMBDA_EXECUTOR=docker') % lambda_name) - - lambda_handler = generic_handler - lambda_cwd = None - arn = func_arn(lambda_name) - runtime = arn_to_lambda[arn].runtime - handler_name = arn_to_lambda.get(arn).handler - lambda_environment = arn_to_lambda.get(arn).envvars - if not handler_name: - handler_name = LAMBDA_DEFAULT_HANDLER - handler_file = get_handler_file_from_name(handler_name, runtime=runtime) - handler_function = get_handler_function_from_name(handler_name, runtime=runtime) - - # Stop/remove any containers that this arn uses. - LAMBDA_EXECUTOR.cleanup(arn) - - if 'S3Bucket' in code: - s3_client = aws_stack.connect_to_service('s3') - bytes_io = BytesIO() - try: - s3_client.download_fileobj(code['S3Bucket'], code['S3Key'], bytes_io) - zip_file_content = bytes_io.getvalue() - except Exception as e: - return error_response('Unable to fetch Lambda archive from S3: %s' % e, 404) - elif 'ZipFile' in code: - zip_file_content = code['ZipFile'] - zip_file_content = base64.b64decode(zip_file_content) - else: - return error_response('No valid Lambda archive specified.', 400) - - # save tmp file - tmp_dir = '%s/zipfile.%s' % (config.TMP_FOLDER, short_uid()) - run('mkdir -p %s' % tmp_dir) - tmp_file = '%s/%s' % (tmp_dir, LAMBDA_ZIP_FILE_NAME) - save_file(tmp_file, zip_file_content) - TMP_FILES.append(tmp_dir) - lambda_cwd = tmp_dir - - # check if this is a ZIP file - is_zip = is_zip_file(zip_file_content) - if is_zip: - unzip(tmp_file, tmp_dir) - main_file = '%s/%s' % (tmp_dir, handler_file) - if not os.path.isfile(main_file): - # check if this is a zip file that contains a single JAR file - jar_files = glob.glob('%s/*.jar' % tmp_dir) - if len(jar_files) == 1: - main_file = jar_files[0] - if os.path.isfile(main_file): - with open(main_file, 'rb') as file_obj: - zip_file_content = file_obj.read() - else: - file_list = run('ls -la %s' % tmp_dir) - LOG.debug('Lambda archive content:\n%s' % file_list) - return error_response('Unable to find handler script in Lambda archive.', 400, error_type='ValidationError') - - # it could be a JAR file (regardless of whether wrapped in a ZIP file or not) - is_jar = is_jar_archive(zip_file_content) - if is_jar: - - def execute(event, context): - result, log_output = lambda_executors.EXECUTOR_LOCAL.execute_java_lambda(event, context, - handler=arn_to_lambda[arn].handler, main_file=main_file) - return result - - lambda_handler = execute - - elif runtime.startswith('python') and not use_docker(): - try: - lambda_handler = exec_lambda_code(zip_file_content, - handler_function=handler_function, lambda_cwd=lambda_cwd, - lambda_env=lambda_environment) - except Exception as e: - raise Exception('Unable to get handler function from lambda code.', e) - - if not is_zip and not is_jar: - raise Exception('Uploaded Lambda code is neither a ZIP nor JAR file.') - - add_function_mapping(lambda_name, lambda_handler, lambda_cwd) - - return {'FunctionName': lambda_name} - - -def do_list_functions(): - funcs = [] - for f_arn, func in iteritems(arn_to_lambda): - func_name = f_arn.split(':function:')[-1] - arn = func_arn(func_name) - funcs.append({ - 'Version': '$LATEST', - 'CodeSize': arn_to_lambda.get(arn).get_version('$LATEST').get('CodeSize'), - 'FunctionName': func_name, - 'FunctionArn': f_arn, - 'Handler': arn_to_lambda.get(arn).handler, - 'Runtime': arn_to_lambda.get(arn).runtime, - 'Timeout': LAMBDA_DEFAULT_TIMEOUT, - # 'Description': '' - # 'MemorySize': 192, - }) - return funcs - - -@app.route('%s/functions' % PATH_ROOT, methods=['POST']) -def create_function(): - """ Create new function - --- - operationId: 'createFunction' - parameters: - - name: 'request' - in: body - """ - arn = 'n/a' - try: - data = json.loads(to_str(request.data)) - lambda_name = data['FunctionName'] - event_publisher.fire_event(event_publisher.EVENT_LAMBDA_CREATE_FUNC, - payload={'n': event_publisher.get_hash(lambda_name)}) - arn = func_arn(lambda_name) - if arn in arn_to_lambda: - return error_response('Function already exist: %s' % - lambda_name, 409, error_type='ResourceConflictException') - arn_to_lambda[arn] = LambdaFunction(arn) - arn_to_lambda[arn].versions = {'$LATEST': {'CodeSize': 50}} - arn_to_lambda[arn].handler = data['Handler'] - arn_to_lambda[arn].runtime = data['Runtime'] - arn_to_lambda[arn].envvars = data.get('Environment', {}).get('Variables', {}) - result = set_function_code(data['Code'], lambda_name) - if isinstance(result, Response): - del arn_to_lambda[arn] - return result - result.update({ - 'DeadLetterConfig': data.get('DeadLetterConfig'), - 'Description': data.get('Description'), - 'Environment': {'Error': {}, 'Variables': arn_to_lambda[arn].envvars}, - 'FunctionArn': arn, - 'FunctionName': lambda_name, - 'Handler': arn_to_lambda[arn].handler, - 'MemorySize': data.get('MemorySize'), - 'Role': data.get('Role'), - 'Runtime': arn_to_lambda[arn].runtime, - 'Timeout': data.get('Timeout'), - 'TracingConfig': {}, - 'VpcConfig': {'SecurityGroupIds': [None], 'SubnetIds': [None], 'VpcId': None} - }) - return jsonify(result or {}) - except Exception as e: - del arn_to_lambda[arn] - return error_response('Unknown error: %s' % e) - - -@app.route('%s/functions/' % PATH_ROOT, methods=['GET']) -def get_function(function): - """ Get details for a single function - --- - operationId: 'getFunction' - parameters: - - name: 'request' - in: body - - name: 'function' - in: path - """ - funcs = do_list_functions() - for func in funcs: - if func['FunctionName'] == function: - result = { - 'Configuration': func, - 'Code': { - 'Location': '%s/code' % request.url - } - } - return jsonify(result) - result = { - 'ResponseMetadata': { - 'HTTPStatusCode': 404 - } - } - return make_response((jsonify(result), 404, {})) - - -@app.route('%s/functions/' % PATH_ROOT, methods=['GET']) -def list_functions(): - """ List functions - --- - operationId: 'listFunctions' - parameters: - - name: 'request' - in: body - """ - funcs = do_list_functions() - result = {} - result['Functions'] = funcs - return jsonify(result) - - -@app.route('%s/functions/' % PATH_ROOT, methods=['DELETE']) -def delete_function(function): - """ Delete an existing function - --- - operationId: 'deleteFunction' - parameters: - - name: 'request' - in: body - """ - arn = func_arn(function) - - # Stop/remove any containers that this arn uses. - LAMBDA_EXECUTOR.cleanup(arn) - - try: - arn_to_lambda.pop(arn) - except KeyError: - return error_response('Function does not exist: %s' % function, 404, error_type='ResourceNotFoundException') - - event_publisher.fire_event(event_publisher.EVENT_LAMBDA_DELETE_FUNC, - payload={'n': event_publisher.get_hash(function)}) - i = 0 - while i < len(event_source_mappings): - mapping = event_source_mappings[i] - if mapping['FunctionArn'] == arn: - del event_source_mappings[i] - i -= 1 - i += 1 - result = {} - return jsonify(result) - - -@app.route('%s/functions//code' % PATH_ROOT, methods=['PUT']) -def update_function_code(function): - """ Update the code of an existing function - --- - operationId: 'updateFunctionCode' - parameters: - - name: 'request' - in: body - """ - data = json.loads(to_str(request.data)) - result = set_function_code(data, function) - return jsonify(result or {}) - - -@app.route('%s/functions//code' % PATH_ROOT, methods=['GET']) -def get_function_code(function): - """ Get the code of an existing function - --- - operationId: 'getFunctionCode' - parameters: - """ - arn = func_arn(function) - lambda_cwd = arn_to_lambda[arn].cwd - tmp_file = '%s/%s' % (lambda_cwd, LAMBDA_ZIP_FILE_NAME) - return Response(load_file(tmp_file, mode='rb'), - mimetype='application/zip', - headers={'Content-Disposition': 'attachment; filename=lambda_archive.zip'}) - - -@app.route('%s/functions//configuration' % PATH_ROOT, methods=['GET']) -def get_function_configuration(function): - """ Get the configuration of an existing function - --- - operationId: 'getFunctionConfiguration' - parameters: - """ - arn = func_arn(function) - lambda_details = arn_to_lambda.get(arn) - if not lambda_details: - return error_response('Function not found: %s' % arn, 404, error_type='ResourceNotFoundException') - result = { - 'Version': '$LATEST', - 'FunctionName': function, - 'FunctionArn': arn, - 'Handler': lambda_details.handler, - 'Runtime': lambda_details.runtime, - 'Timeout': LAMBDA_DEFAULT_TIMEOUT, - 'Environment': lambda_details.envvars - } - return jsonify(result) - - -@app.route('%s/functions//configuration' % PATH_ROOT, methods=['PUT']) -def update_function_configuration(function): - """ Update the configuration of an existing function - --- - operationId: 'updateFunctionConfiguration' - parameters: - - name: 'request' - in: body - """ - data = json.loads(to_str(request.data)) - arn = func_arn(function) - - # Stop/remove any containers that this arn uses. - LAMBDA_EXECUTOR.cleanup(arn) - - if data.get('Handler'): - arn_to_lambda[arn].handler = data['Handler'] - if data.get('Runtime'): - arn_to_lambda[arn].runtime = data['Runtime'] - if data.get('Environment'): - arn_to_lambda[arn].envvars = data.get('Environment', {}).get('Variables', {}) - result = {} - return jsonify(result) - - -@app.route('%s/functions//invocations' % PATH_ROOT, methods=['POST']) -def invoke_function(function): - """ Invoke an existing function - --- - operationId: 'invokeFunction' - parameters: - - name: 'request' - in: body - """ - arn = func_arn(function) - if arn not in arn_to_lambda: - return error_response('Function does not exist: %s' % arn, 404, error_type='ResourceNotFoundException') - qualifier = request.args['Qualifier'] if 'Qualifier' in request.args else '$LATEST' - if not arn_to_lambda.get(arn).qualifier_exists(qualifier): - return error_response('Function does not exist: {0}:{1}'.format(arn, qualifier), 404, - error_type='ResourceNotFoundException') - data = None - if request.data: - try: - data = json.loads(to_str(request.data)) - except Exception: - return error_response('The payload is not JSON', 415, error_type='UnsupportedMediaTypeException') - async = False - if 'HTTP_X_AMZ_INVOCATION_TYPE' in request.environ: - async = request.environ['HTTP_X_AMZ_INVOCATION_TYPE'] == 'Event' - result = run_lambda(async=async, func_arn=arn, event=data, context={}, version=qualifier) - if isinstance(result, dict): - return jsonify(result) - if result: - return result - return make_response('', 200) - - -@app.route('%s/event-source-mappings/' % PATH_ROOT, methods=['GET']) -def list_event_source_mappings(): - """ List event source mappings - --- - operationId: 'listEventSourceMappings' - """ - event_source_arn = request.args.get('EventSourceArn') - function_name = request.args.get('FunctionName') - - mappings = event_source_mappings - if event_source_arn: - mappings = [m for m in mappings if event_source_arn == m.get('EventSourceArn')] - if function_name: - function_arn = func_arn(function_name) - mappings = [m for m in mappings if function_arn == m.get('FunctionArn')] - - response = { - 'EventSourceMappings': mappings - } - return jsonify(response) - - -@app.route('%s/event-source-mappings/' % PATH_ROOT, methods=['POST']) -def create_event_source_mapping(): - """ Create new event source mapping - --- - operationId: 'createEventSourceMapping' - parameters: - - name: 'request' - in: body - """ - data = json.loads(to_str(request.data)) - mapping = add_event_source(data['FunctionName'], data['EventSourceArn']) - return jsonify(mapping) - - -@app.route('%s/event-source-mappings/' % PATH_ROOT, methods=['PUT']) -def update_event_source_mapping(mapping_uuid): - """ Update an existing event source mapping - --- - operationId: 'updateEventSourceMapping' - parameters: - - name: 'request' - in: body - """ - data = json.loads(request.data) - if not mapping_uuid: - return jsonify({}) - function_name = data.get('FunctionName') or '' - enabled = data.get('Enabled') or True - batch_size = data.get('BatchSize') or 100 - mapping = update_event_source(mapping_uuid, function_name, enabled, batch_size) - return jsonify(mapping) - - -@app.route('%s/event-source-mappings/' % PATH_ROOT, methods=['DELETE']) -def delete_event_source_mapping(mapping_uuid): - """ Delete an event source mapping - --- - operationId: 'deleteEventSourceMapping' - """ - if not mapping_uuid: - return jsonify({}) - - mapping = delete_event_source(mapping_uuid) - return jsonify(mapping) - - -@app.route('%s/functions//versions' % PATH_ROOT, methods=['POST']) -def publish_version(function): - arn = func_arn(function) - if arn not in arn_to_lambda: - return error_response('Function not found: %s' % arn, 404, error_type='ResourceNotFoundException') - return jsonify(publish_new_function_version(arn)) - - -@app.route('%s/functions//versions' % PATH_ROOT, methods=['GET']) -def list_versions(function): - arn = func_arn(function) - if arn not in arn_to_lambda: - return error_response('Function not found: %s' % arn, 404, error_type='ResourceNotFoundException') - return jsonify({'Versions': do_list_versions(arn)}) - - -@app.route('%s/functions//aliases' % PATH_ROOT, methods=['POST']) -def create_alias(function): - arn = func_arn(function) - if arn not in arn_to_lambda: - return error_response('Function not found: %s' % arn, 404, error_type='ResourceNotFoundException') - data = json.loads(request.data) - alias = data.get('Name') - if alias in arn_to_lambda.get(arn).aliases: - return error_response('Alias already exists: %s' % arn + ':' + alias, 404, - error_type='ResourceConflictException') - version = data.get('FunctionVersion') - description = data.get('Description') - return jsonify(do_update_alias(arn, alias, version, description)) - - -@app.route('%s/functions//aliases/' % PATH_ROOT, methods=['PUT']) -def update_alias(function, name): - arn = func_arn(function) - if arn not in arn_to_lambda: - return error_response('Function not found: %s' % arn, 404, error_type='ResourceNotFoundException') - if name not in arn_to_lambda.get(arn).aliases: - return error_response('Alias not found: %s' % arn + ':' + name, 404, - error_type='ResourceNotFoundException') - current_alias = arn_to_lambda.get(arn).aliases.get(name) - data = json.loads(request.data) - version = data.get('FunctionVersion') or current_alias.get('FunctionVersion') - description = data.get('Description') or current_alias.get('Description') - return jsonify(do_update_alias(arn, name, version, description)) - - -@app.route('%s/functions//aliases' % PATH_ROOT, methods=['GET']) -def list_aliases(function): - arn = func_arn(function) - if arn not in arn_to_lambda: - return error_response('Function not found: %s' % arn, 404, error_type='ResourceNotFoundException') - return jsonify({'Aliases': sorted(arn_to_lambda.get(arn).aliases.values(), - key=lambda x: x['Name'])}) - - -def serve(port, quiet=True): - # initialize the Lambda executor - LAMBDA_EXECUTOR.startup() - - generic_proxy.serve_flask_app(app=app, port=port, quiet=quiet) diff --git a/localstack/services/awslambda/lambda_executors.py b/localstack/services/awslambda/lambda_executors.py deleted file mode 100644 index 01c660fdce337..0000000000000 --- a/localstack/services/awslambda/lambda_executors.py +++ /dev/null @@ -1,496 +0,0 @@ -import os -import re -import json -import time -import logging -import threading -import subprocess -# from datetime import datetime -from multiprocessing import Process, Queue -try: - from shlex import quote as cmd_quote -except ImportError: - # for Python 2.7 - from pipes import quote as cmd_quote -from localstack import config -from localstack.utils.common import run, TMP_FILES, short_uid, save_file, to_str, cp_r -from localstack.services.install import INSTALL_PATH_LOCALSTACK_FAT_JAR - -# constants -LAMBDA_EXECUTOR_JAR = INSTALL_PATH_LOCALSTACK_FAT_JAR -LAMBDA_EXECUTOR_CLASS = 'cloud.localstack.LambdaExecutor' -EVENT_FILE_PATTERN = '%s/lambda.event.*.json' % config.TMP_FOLDER - -LAMBDA_RUNTIME_PYTHON27 = 'python2.7' -LAMBDA_RUNTIME_PYTHON36 = 'python3.6' -LAMBDA_RUNTIME_NODEJS = 'nodejs' -LAMBDA_RUNTIME_NODEJS610 = 'nodejs6.10' -LAMBDA_RUNTIME_JAVA8 = 'java8' - -LAMBDA_EVENT_FILE = 'event_file.json' - -# logger -LOG = logging.getLogger(__name__) - -# maximum time a pre-allocated container can sit idle before getting killed -MAX_CONTAINER_IDLE_TIME = 600 - - -class LambdaExecutor(object): - """ Base class for Lambda executors. Subclasses must overwrite the execute method """ - - def __init__(self): - pass - - def execute(self, func_arn, func_details, event, context=None, version=None, async=False): - raise Exception('Not implemented.') - - def startup(self): - pass - - def cleanup(self, arn=None): - pass - - def run_lambda_executor(self, cmd, env_vars={}, async=False): - process = run(cmd, async=True, stderr=subprocess.PIPE, outfile=subprocess.PIPE, env_vars=env_vars) - if async: - result = '{"async": "%s"}' % async - log_output = 'Lambda executed asynchronously' - else: - return_code = process.wait() - result = to_str(process.stdout.read()) - log_output = to_str(process.stderr.read()) - - if return_code != 0: - raise Exception('Lambda process returned error status code: %s. Output:\n%s' % - (return_code, log_output)) - return result, log_output - - -# holds information about an existing container. -class ContainerInfo: - """ - Contains basic information about a docker container. - """ - def __init__(self, name, entry_point): - self.name = name - self.entry_point = entry_point - - -class LambdaExecutorContainers(LambdaExecutor): - """ Abstract executor class for executing Lambda functions in Docker containers """ - - def prepare_execution(self, func_arn, env_vars, runtime, command, handler, lambda_cwd): - raise Exception('Not implemented') - - def execute(self, func_arn, func_details, event, context=None, version=None, async=False): - - lambda_cwd = func_details.cwd - runtime = func_details.runtime - handler = func_details.handler - environment = func_details.envvars.copy() - - # configure USE_SSL in environment - if config.USE_SSL: - environment['USE_SSL'] = '1' - - # prepare event body - if not event: - LOG.warning('Empty event body specified for invocation of Lambda "%s"' % func_arn) - event = {} - event_body = json.dumps(event) - event_body_escaped = event_body.replace("'", "\\'") - - docker_host = config.DOCKER_HOST_FROM_CONTAINER - - # amend the environment variables for execution - environment['AWS_LAMBDA_EVENT_BODY'] = event_body_escaped - environment['HOSTNAME'] = docker_host - environment['LOCALSTACK_HOSTNAME'] = docker_host - - # custom command to execute in the container - command = '' - - # if running a Java Lambda, set up classpath arguments - if runtime == LAMBDA_RUNTIME_JAVA8: - # copy executor jar into temp directory - cp_r(LAMBDA_EXECUTOR_JAR, lambda_cwd) - # TODO cleanup once we have custom Java Docker image - taskdir = '/var/task' - save_file(os.path.join(lambda_cwd, LAMBDA_EVENT_FILE), event_body) - command = ("bash -c 'cd %s; java -cp .:`ls *.jar | tr \"\\n\" \":\"` \"%s\" \"%s\" \"%s\"'" % - (taskdir, LAMBDA_EXECUTOR_CLASS, handler, LAMBDA_EVENT_FILE)) - - # determine the command to be executed (implemented by subclasses) - cmd = self.prepare_execution(func_arn, environment, runtime, command, handler, lambda_cwd) - - # lambci writes the Lambda result to stdout and logs to stderr, fetch it from there! - LOG.debug('Running lambda cmd: %s' % cmd) - result, log_output = self.run_lambda_executor(cmd, environment, async) - LOG.debug('Lambda result / log output:\n%s\n%s' % (result, log_output)) - return result, log_output - - -class LambdaExecutorReuseContainers(LambdaExecutorContainers): - """ Executor class for executing Lambda functions in re-usable Docker containers """ - - def __init__(self): - super(LambdaExecutorReuseContainers, self).__init__() - # keeps track of each function arn and the last time it was invoked - self.function_invoke_times = {} - # locking thread for creation/destruction of docker containers. - self.docker_container_lock = threading.RLock() - - def prepare_execution(self, func_arn, env_vars, runtime, command, handler, lambda_cwd): - - # check whether the Lambda has been invoked before - has_been_invoked_before = func_arn in self.function_invoke_times - - # set the invocation time - self.function_invoke_times[func_arn] = time.time() - - # create/verify the docker container is running. - LOG.debug('Priming docker container with runtime "%s" and arn "%s".', runtime, func_arn) - container_info = self.prime_docker_container(runtime, func_arn, env_vars.items(), lambda_cwd) - - # Note: currently "docker exec" does not support --env-file, i.e., environment variables can only be - # passed directly on the command line, using "-e" below. TODO: Update this code once --env-file is - # available for docker exec, to better support very large Lambda events (very long environment values) - exec_env_vars = ' '.join(['-e {}="${}"'.format(k, k) for (k, v) in env_vars.items()]) - - if not command: - command = '%s %s' % (container_info.entry_point, handler) - - # determine files to be copied into the container - copy_command = '' - event_file = os.path.join(lambda_cwd, LAMBDA_EVENT_FILE) - if not has_been_invoked_before: - # if this is the first invocation: copy the entire folder into the container - copy_command = 'docker cp "%s/." "%s:/var/task"; ' % (lambda_cwd, container_info.name) - elif os.path.exists(event_file): - # otherwise, copy only the event file if it exists - copy_command = 'docker cp "%s" "%s:/var/task"; ' % (event_file, container_info.name) - - cmd = ( - '%s' # copy files command - 'docker exec' - ' %s' # env variables - ' %s' # container name - ' %s' # run cmd - ) % (copy_command, exec_env_vars, container_info.name, command) - - return cmd - - def startup(self): - self.cleanup() - # start a process to remove idle containers - self.start_idle_container_destroyer_interval() - - def cleanup(self, arn=None): - if arn: - self.function_invoke_times.pop(arn, None) - return self.destroy_docker_container(arn) - self.function_invoke_times = {} - return self.destroy_existing_docker_containers() - - def prime_docker_container(self, runtime, func_arn, env_vars, lambda_cwd): - """ - Prepares a persistent docker container for a specific function. - :param runtime: Lamda runtime environment. python2.7, nodejs6.10, etc. - :param func_arn: The ARN of the lambda function. - :param env_vars: The environment variables for the lambda. - :param lambda_cwd: The local directory containing the code for the lambda function. - :return: ContainerInfo class containing the container name and default entry point. - """ - with self.docker_container_lock: - # Get the container name and id. - container_name = self.get_container_name(func_arn) - - LOG.debug('Priming docker container: %s' % container_name) - - status = self.get_docker_container_status(func_arn) - # Container is not running or doesn't exist. - if status < 1: - # Make sure the container does not exist in any form/state. - self.destroy_docker_container(func_arn) - - env_vars_str = ' '.join(['-e {}={}'.format(k, cmd_quote(v)) for (k, v) in env_vars]) - - # Create and start the container - LOG.debug('Creating container: %s' % container_name) - cmd = ( - 'docker create' - ' --name "%s"' - ' --entrypoint /bin/bash' # Load bash when it starts. - ' --interactive' # Keeps the container running bash. - ' -e AWS_LAMBDA_EVENT_BODY="$AWS_LAMBDA_EVENT_BODY"' - ' -e HOSTNAME="$HOSTNAME"' - ' -e LOCALSTACK_HOSTNAME="$LOCALSTACK_HOSTNAME"' - ' %s' # env_vars - ' lambci/lambda:%s' - ) % (container_name, env_vars_str, runtime) - LOG.debug(cmd) - run(cmd, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - - LOG.debug('Copying files to container "%s" from "%s".' % (container_name, lambda_cwd)) - cmd = ( - 'docker cp' - ' "%s/." "%s:/var/task"' - ) % (lambda_cwd, container_name) - LOG.debug(cmd) - run(cmd, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - - LOG.debug('Starting container: %s' % container_name) - cmd = 'docker start %s' % (container_name) - LOG.debug(cmd) - run(cmd, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - # give the container some time to start up - time.sleep(1) - - # Get the entry point for the image. - LOG.debug('Getting the entrypoint for image: lambci/lambda:%s' % runtime) - cmd = ( - 'docker image inspect' - ' --format="{{ .ContainerConfig.Entrypoint }}"' - ' lambci/lambda:%s' - ) % (runtime) - - LOG.debug(cmd) - run_result = run(cmd, async=False, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - - entry_point = run_result.strip('[]\n\r ') - - LOG.debug('Using entrypoint "%s" for container "%s".' % (entry_point, container_name)) - return ContainerInfo(container_name, entry_point) - - def destroy_docker_container(self, func_arn): - """ - Stops and/or removes a docker container for a specific lambda function ARN. - :param func_arn: The ARN of the lambda function. - :return: None - """ - with self.docker_container_lock: - status = self.get_docker_container_status(func_arn) - - # Get the container name and id. - container_name = self.get_container_name(func_arn) - - if status == 1: - LOG.debug('Stopping container: %s' % container_name) - cmd = ( - 'docker stop -t0 %s' - ) % (container_name) - - LOG.debug(cmd) - run(cmd, async=False, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - - status = self.get_docker_container_status(func_arn) - - if status == -1: - LOG.debug('Removing container: %s' % container_name) - cmd = ( - 'docker rm %s' - ) % (container_name) - - LOG.debug(cmd) - run(cmd, async=False, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - - def get_all_container_names(self): - """ - Returns a list of container names for lambda containers. - :return: A String[] localstack docker container names for each function. - """ - with self.docker_container_lock: - LOG.debug('Getting all lambda containers names.') - cmd = 'docker ps -a --filter="name=localstack_lambda_*" --format "{{.Names}}"' - LOG.debug(cmd) - cmd_result = run(cmd, async=False, stderr=subprocess.PIPE, outfile=subprocess.PIPE).strip() - - if len(cmd_result) > 0: - container_names = cmd_result.split('\n') - else: - container_names = [] - - return container_names - - def destroy_existing_docker_containers(self): - """ - Stops and/or removes all lambda docker containers for localstack. - :return: None - """ - with self.docker_container_lock: - container_names = self.get_all_container_names() - - LOG.debug('Removing %d containers.' % len(container_names)) - for container_name in container_names: - cmd = 'docker rm -f %s' % container_name - LOG.debug(cmd) - run(cmd, async=False, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - - def get_docker_container_status(self, func_arn): - """ - Determine the status of a docker container. - :param func_arn: The ARN of the lambda function. - :return: 1 If the container is running, - -1 if the container exists but is not running - 0 if the container does not exist. - """ - with self.docker_container_lock: - # Get the container name and id. - container_name = self.get_container_name(func_arn) - - # Check if the container is already running. - LOG.debug('Getting container status: %s' % container_name) - cmd = ( - 'docker ps' - ' -a' - ' --filter name="%s"' - ' --format "{{ .Status }}"' - ) % (container_name) - - LOG.debug(cmd) - cmd_result = run(cmd, async=False, stderr=subprocess.PIPE, outfile=subprocess.PIPE) - - # If the container doesn't exist. Create and start it. - container_status = cmd_result.strip() - - if len(container_status) == 0: - return 0 - - if container_status.lower().startswith('up '): - return 1 - - return -1 - - def idle_container_destroyer(self): - """ - Iterates though all the lambda containers and destroys any container that has - been inactive for longer than MAX_CONTAINER_IDLE_TIME. - :return: None - """ - LOG.info('Checking if there are idle containers.') - current_time = time.time() - for func_arn, last_run_time in self.function_invoke_times.items(): - duration = current_time - last_run_time - - # not enough idle time has passed - if duration < MAX_CONTAINER_IDLE_TIME: - continue - - # container has been idle, destroy it. - self.destroy_docker_container(func_arn) - - def start_idle_container_destroyer_interval(self): - """ - Starts a repeating timer that triggers start_idle_container_destroyer_interval every 60 seconds. - Thus checking for idle containers and destroying them. - :return: None - """ - self.idle_container_destroyer() - threading.Timer(60.0, self.start_idle_container_destroyer_interval).start() - - def get_container_name(self, func_arn): - """ - Given a function ARN, returns a valid docker container name. - :param func_arn: The ARN of the lambda function. - :return: A docker compatible name for the arn. - """ - return 'localstack_lambda_' + re.sub(r'[^a-zA-Z0-9_.-]', '_', func_arn) - - -class LambdaExecutorSeparateContainers(LambdaExecutorContainers): - - def prepare_execution(self, func_arn, env_vars, runtime, command, handler, lambda_cwd): - entrypoint = '' - if command: - entrypoint = ' --entrypoint ""' - else: - command = '"%s"' % handler - - env_vars_string = ' '.join(['-e {}="${}"'.format(k, k) for (k, v) in env_vars.items()]) - - if config.LAMBDA_REMOTE_DOCKER: - cmd = ( - 'CONTAINER_ID="$(docker create' - ' %s' - ' %s' - ' "lambci/lambda:%s" %s' - ')";' - 'docker cp "%s/." "$CONTAINER_ID:/var/task";' - 'docker start -a "$CONTAINER_ID";' - ) % (entrypoint, env_vars_string, runtime, command, lambda_cwd) - else: - lambda_cwd_on_host = self.get_host_path_for_path_in_docker(lambda_cwd) - cmd = ( - 'docker run' - '%s -v "%s":/var/task' - ' %s' - ' --rm' - ' "lambci/lambda:%s" %s' - ) % (entrypoint, lambda_cwd_on_host, env_vars_string, runtime, command) - return cmd - - def get_host_path_for_path_in_docker(self, path): - return re.sub(r'^%s/(.*)$' % config.TMP_FOLDER, - r'%s/\1' % config.HOST_TMP_FOLDER, path) - - -class LambdaExecutorLocal(LambdaExecutor): - - def execute(self, func_arn, func_details, event, context=None, version=None, async=False): - lambda_cwd = func_details.cwd - environment = func_details.envvars.copy() - - # execute the Lambda function in a forked sub-process, sync result via queue - queue = Queue() - - lambda_function = func_details.function(version) - - def do_execute(): - # now we're executing in the child process, safe to change CWD and ENV - if lambda_cwd: - os.chdir(lambda_cwd) - if environment: - os.environ.update(environment) - result = lambda_function(event, context) - queue.put(result) - - process = Process(target=do_execute) - process.run() - result = queue.get() - # TODO capture log output during local execution? - log_output = '' - return result, log_output - - def execute_java_lambda(self, event, context, handler, main_file): - event_file = EVENT_FILE_PATTERN.replace('*', short_uid()) - save_file(event_file, json.dumps(event)) - TMP_FILES.append(event_file) - class_name = handler.split('::')[0] - classpath = '%s:%s' % (LAMBDA_EXECUTOR_JAR, main_file) - cmd = 'java -cp %s %s %s %s' % (classpath, LAMBDA_EXECUTOR_CLASS, class_name, event_file) - async = False - # flip async flag depending on origin - if 'Records' in event: - # TODO: add more event supporting async lambda execution - if 'Sns' in event['Records'][0]: - async = True - result, log_output = self.run_lambda_executor(cmd, async=async) - LOG.info('Lambda output: %s' % log_output.replace('\n', '\n> ')) - return result, log_output - - -# -------------- -# GLOBAL STATE -# -------------- - -EXECUTOR_LOCAL = LambdaExecutorLocal() -EXECUTOR_CONTAINERS_SEPARATE = LambdaExecutorSeparateContainers() -EXECUTOR_CONTAINERS_REUSE = LambdaExecutorReuseContainers() -DEFAULT_EXECUTOR = EXECUTOR_LOCAL -# the keys of AVAILABLE_EXECUTORS map to the LAMBDA_EXECUTOR config variable -AVAILABLE_EXECUTORS = { - 'local': EXECUTOR_LOCAL, - 'docker': EXECUTOR_CONTAINERS_SEPARATE, - 'docker-reuse': EXECUTOR_CONTAINERS_REUSE -} diff --git a/localstack/services/cloudformation/cloudformation_listener.py b/localstack/services/cloudformation/cloudformation_listener.py deleted file mode 100644 index dfcf7542bc058..0000000000000 --- a/localstack/services/cloudformation/cloudformation_listener.py +++ /dev/null @@ -1,187 +0,0 @@ -import re -import uuid -import logging -import xmltodict -import requests -from requests.models import Response, Request -from six.moves.urllib import parse as urlparse -from localstack.constants import DEFAULT_REGION, TEST_AWS_ACCOUNT_ID -from localstack.utils.common import to_str -from localstack.utils.aws import aws_stack -from localstack.utils.cloudformation import template_deployer -from localstack.services.generic_proxy import ProxyListener - -XMLNS_CLOUDFORMATION = 'http://cloudformation.amazonaws.com/doc/2010-05-15/' -LOGGER = logging.getLogger(__name__) - -# maps change set names to change set details -CHANGE_SETS = {} - - -def error_response(message, code=400, error_type='ValidationError'): - response = Response() - response.status_code = code - response.headers['x-amzn-errortype'] = error_type - response._content = """ - - Sender - %s - %s - - %s - """ % (XMLNS_CLOUDFORMATION, error_type, message, uuid.uuid4()) - return response - - -def make_response(operation_name, content='', code=200): - response = Response() - response._content = """<{op_name}Response xmlns="{xmlns}"> - <{op_name}Result> - {content} - - {uid} - """.format(xmlns=XMLNS_CLOUDFORMATION, - op_name=operation_name, uid=uuid.uuid4(), content=content) - response.status_code = code - return response - - -def stack_exists(stack_name): - cloudformation = aws_stack.connect_to_service('cloudformation') - stacks = cloudformation.list_stacks() - for stack in stacks['StackSummaries']: - if stack['StackName'] == stack_name: - return True - return False - - -def create_change_set(req_data): - cs_name = req_data.get('ChangeSetName')[0] - change_set_uuid = uuid.uuid4() - cs_arn = 'arn:aws:cloudformation:%s:%s:changeSet/%s/%s' % ( - DEFAULT_REGION, TEST_AWS_ACCOUNT_ID, cs_name, change_set_uuid) - CHANGE_SETS[cs_arn] = dict(req_data) - response = make_response('CreateChangeSet', '%s' % cs_arn) - return response - - -def describe_change_set(req_data): - cs_arn = req_data.get('ChangeSetName')[0] - cs_details = CHANGE_SETS.get(cs_arn) - if not cs_details: - return error_response('Change Set %s does not exist' % cs_arn, 404, 'ChangeSetNotFound') - stack_name = cs_details.get('StackName')[0] - response_content = """ - %s - %s - CREATE_COMPLETE""" % (stack_name, cs_arn) - response = make_response('DescribeChangeSet', response_content) - return response - - -def execute_change_set(req_data): - cs_arn = req_data.get('ChangeSetName')[0] - stack_name = req_data.get('StackName')[0] - cs_details = CHANGE_SETS.get(cs_arn) - if not cs_details: - return error_response('Change Set %s does not exist' % cs_arn, 404, 'ChangeSetNotFound') - - # convert to JSON (might have been YAML, and update_stack/create_stack seem to only work with JSON) - template = template_deployer.template_to_json(cs_details.get('TemplateBody')[0]) - - # update stack information - cloudformation_service = aws_stack.connect_to_service('cloudformation') - if stack_exists(stack_name): - cloudformation_service.update_stack(StackName=stack_name, - TemplateBody=template) - else: - cloudformation_service.create_stack(StackName=stack_name, - TemplateBody=template) - - # now run the actual deployment - template_deployer.deploy_template(template, stack_name) - - response = make_response('ExecuteChangeSet') - return response - - -def validate_template(req_data): - LOGGER.debug(req_data) - response_content = """ - - - - - - - """ - - try: - template_deployer.template_to_json(req_data.get('TemplateBody')[0]) - response = make_response('ValidateTemplate', response_content) - return response - except Exception as err: - response = error_response('Template Validation Error: %s' % err) - return response - - -class ProxyListenerCloudFormation(ProxyListener): - - def forward_request(self, method, path, data, headers): - req_data = None - if method == 'POST' and path == '/': - req_data = urlparse.parse_qs(to_str(data)) - action = req_data.get('Action')[0] - - if req_data: - if action == 'CreateChangeSet': - return create_change_set(req_data) - elif action == 'DescribeChangeSet': - return describe_change_set(req_data) - elif action == 'ExecuteChangeSet': - return execute_change_set(req_data) - elif action == 'UpdateStack' and req_data.get('TemplateURL'): - # Temporary fix until the moto CF backend can handle TemplateURL (currently fails) - url = re.sub(r'https?://s3\.amazonaws\.com', aws_stack.get_local_service_url('s3'), - req_data.get('TemplateURL')[0]) - req_data['TemplateBody'] = requests.get(url).content - modified_data = urlparse.urlencode(req_data, doseq=True) - return Request(data=modified_data, headers=headers, method=method) - elif action == 'ValidateTemplate': - return validate_template(req_data) - - return True - - def return_response(self, method, path, data, headers, response): - req_data = None - if method == 'POST' and path == '/': - req_data = urlparse.parse_qs(to_str(data)) - action = req_data.get('Action')[0] - - if req_data: - if action == 'DescribeStackResources': - if response.status_code < 300: - response_dict = xmltodict.parse(response.content)['DescribeStackResourcesResponse'] - resources = response_dict['DescribeStackResourcesResult']['StackResources'] - if not resources: - # Check if stack exists - stack_name = req_data.get('StackName')[0] - cloudformation_client = aws_stack.connect_to_service('cloudformation') - try: - cloudformation_client.describe_stacks(StackName=stack_name) - except Exception: - return error_response('Stack with id %s does not exist' % stack_name, code=404) - if action == 'DescribeStackResource': - if response.status_code >= 500: - # fix an error in moto where it fails with 500 if the stack does not exist - return error_response('Stack resource does not exist', code=404) - elif action == 'CreateStack' or action == 'UpdateStack': - if response.status_code >= 400 and response.status_code < 500: - return response - # run the actual deployment - template = template_deployer.template_to_json(req_data.get('TemplateBody')[0]) - template_deployer.deploy_template(template, req_data.get('StackName')[0]) - - -# instantiate listener -UPDATE_CLOUDFORMATION = ProxyListenerCloudFormation() diff --git a/localstack/services/dynamodb/dynamodb_listener.py b/localstack/services/dynamodb/dynamodb_listener.py deleted file mode 100644 index 3b49bb776a65d..0000000000000 --- a/localstack/services/dynamodb/dynamodb_listener.py +++ /dev/null @@ -1,216 +0,0 @@ -import re -import json -import random -import logging -from binascii import crc32 -from requests.models import Response -from localstack import config -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_bytes, to_str, clone -from localstack.utils.analytics import event_publisher -from localstack.constants import DEFAULT_REGION -from localstack.services.awslambda import lambda_api -from localstack.services.dynamodbstreams import dynamodbstreams_api -from localstack.services.generic_proxy import ProxyListener - -# cache table definitions - used for testing -TABLE_DEFINITIONS = {} - -# action header prefix -ACTION_PREFIX = 'DynamoDB_20120810' - -# set up logger -LOGGER = logging.getLogger(__name__) - - -class ProxyListenerDynamoDB(ProxyListener): - - def forward_request(self, method, path, data, headers): - data = json.loads(to_str(data)) - - if random.random() < config.DYNAMODB_ERROR_PROBABILITY: - return error_response_throughput() - return True - - def return_response(self, method, path, data, headers, response): - data = json.loads(to_str(data)) - - # update table definitions - if data and 'TableName' in data and 'KeySchema' in data: - TABLE_DEFINITIONS[data['TableName']] = data - - if response._content: - # fix the table ARN (DynamoDBLocal hardcodes "ddblocal" as the region) - content_replaced = re.sub(r'"TableArn"\s*:\s*"arn:aws:dynamodb:ddblocal:([^"]+)"', - r'"TableArn": "arn:aws:dynamodb:%s:\1"' % aws_stack.get_local_region(), to_str(response._content)) - if content_replaced != response._content: - response._content = content_replaced - fix_headers_for_updated_response(response) - - action = headers.get('X-Amz-Target') - if not action: - return - - record = { - 'eventID': '1', - 'eventVersion': '1.0', - 'dynamodb': { - 'StreamViewType': 'NEW_AND_OLD_IMAGES', - 'SizeBytes': -1 - }, - 'awsRegion': DEFAULT_REGION, - 'eventSource': 'aws:dynamodb' - } - records = [record] - - if action == '%s.UpdateItem' % ACTION_PREFIX: - req = {'TableName': data['TableName'], 'Key': data['Key']} - new_item = aws_stack.dynamodb_get_item_raw(req) - if 'Item' not in new_item: - if 'message' in new_item: - ddb_client = aws_stack.connect_to_service('dynamodb') - table_names = ddb_client.list_tables()['TableNames'] - msg = ('Unable to get item from DynamoDB (existing tables: %s): %s' % - (table_names, new_item['message'])) - LOGGER.warning(msg) - return - record['eventName'] = 'MODIFY' - record['dynamodb']['Keys'] = data['Key'] - record['dynamodb']['NewImage'] = new_item['Item'] - record['dynamodb']['SizeBytes'] = len(json.dumps(new_item['Item'])) - elif action == '%s.BatchWriteItem' % ACTION_PREFIX: - records = [] - for table_name, requests in data['RequestItems'].items(): - for request in requests: - put_request = request.get('PutRequest') - if put_request: - keys = dynamodb_extract_keys(item=put_request['Item'], table_name=table_name) - if isinstance(keys, Response): - return keys - new_record = clone(record) - new_record['eventName'] = 'INSERT' - new_record['dynamodb']['Keys'] = keys - new_record['dynamodb']['NewImage'] = put_request['Item'] - new_record['eventSourceARN'] = aws_stack.dynamodb_table_arn(table_name) - records.append(new_record) - elif action == '%s.PutItem' % ACTION_PREFIX: - record['eventName'] = 'INSERT' - keys = dynamodb_extract_keys(item=data['Item'], table_name=data['TableName']) - if isinstance(keys, Response): - return keys - record['dynamodb']['Keys'] = keys - record['dynamodb']['NewImage'] = data['Item'] - record['dynamodb']['SizeBytes'] = len(json.dumps(data['Item'])) - elif action == '%s.GetItem' % ACTION_PREFIX: - if response.status_code == 200: - content = json.loads(to_str(response.content)) - # make sure we append 'ConsumedCapacity', which is properly - # returned by dynalite, but not by AWS's DynamoDBLocal - if 'ConsumedCapacity' not in content and data.get('ReturnConsumedCapacity') in ('TOTAL', 'INDEXES'): - content['ConsumedCapacity'] = { - 'CapacityUnits': 0.5, # TODO hardcoded - 'TableName': data['TableName'] - } - response._content = json.dumps(content) - fix_headers_for_updated_response(response) - elif action == '%s.DeleteItem' % ACTION_PREFIX: - record['eventName'] = 'REMOVE' - record['dynamodb']['Keys'] = data['Key'] - elif action == '%s.CreateTable' % ACTION_PREFIX: - if 'StreamSpecification' in data: - create_dynamodb_stream(data) - event_publisher.fire_event(event_publisher.EVENT_DYNAMODB_CREATE_TABLE, - payload={'n': event_publisher.get_hash(data['TableName'])}) - return - elif action == '%s.DeleteTable' % ACTION_PREFIX: - event_publisher.fire_event(event_publisher.EVENT_DYNAMODB_DELETE_TABLE, - payload={'n': event_publisher.get_hash(data['TableName'])}) - return - elif action == '%s.UpdateTable' % ACTION_PREFIX: - if 'StreamSpecification' in data: - create_dynamodb_stream(data) - return - else: - # nothing to do - return - - if len(records) > 0 and 'eventName' in records[0]: - if 'TableName' in data: - records[0]['eventSourceARN'] = aws_stack.dynamodb_table_arn(data['TableName']) - forward_to_lambda(records) - forward_to_ddb_stream(records) - - -# instantiate listener -UPDATE_DYNAMODB = ProxyListenerDynamoDB() - - -def fix_headers_for_updated_response(response): - response.headers['content-length'] = len(to_bytes(response.content)) - response.headers['x-amz-crc32'] = calculate_crc32(response) - - -def calculate_crc32(response): - return crc32(to_bytes(response.content)) & 0xffffffff - - -def create_dynamodb_stream(data): - stream = data['StreamSpecification'] - enabled = stream.get('StreamEnabled') - if enabled not in [False, 'False']: - table_name = data['TableName'] - view_type = stream['StreamViewType'] - dynamodbstreams_api.add_dynamodb_stream(table_name=table_name, - view_type=view_type, enabled=enabled) - - -def forward_to_lambda(records): - for record in records: - sources = lambda_api.get_event_sources(source_arn=record['eventSourceARN']) - event = { - 'Records': [record] - } - for src in sources: - lambda_api.run_lambda(event=event, context={}, func_arn=src['FunctionArn']) - - -def forward_to_ddb_stream(records): - dynamodbstreams_api.forward_events(records) - - -def dynamodb_extract_keys(item, table_name): - result = {} - if table_name not in TABLE_DEFINITIONS: - LOGGER.warning('Unknown table: %s not found in %s' % (table_name, TABLE_DEFINITIONS)) - return None - for key in TABLE_DEFINITIONS[table_name]['KeySchema']: - attr_name = key['AttributeName'] - if attr_name not in item: - return error_response(error_type='ValidationException', - message='One of the required keys was not given a value') - result[attr_name] = item[attr_name] - return result - - -def error_response(message=None, error_type=None, code=400): - if not message: - message = 'Unknown error' - if not error_type: - error_type = 'UnknownError' - if 'com.amazonaws.dynamodb' not in error_type: - error_type = 'com.amazonaws.dynamodb.v20120810#%s' % error_type - response = Response() - response.status_code = code - content = { - 'message': message, - '__type': error_type - } - response._content = json.dumps(content) - return response - - -def error_response_throughput(): - message = ('The level of configured provisioned throughput for the table was exceeded. ' + - 'Consider increasing your provisioning level with the UpdateTable API') - error_type = 'ProvisionedThroughputExceededException' - return error_response(message, error_type) diff --git a/localstack/services/dynamodb/dynamodb_starter.py b/localstack/services/dynamodb/dynamodb_starter.py deleted file mode 100644 index 6699d37bd756c..0000000000000 --- a/localstack/services/dynamodb/dynamodb_starter.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging -import traceback -from localstack.config import PORT_DYNAMODB, DATA_DIR -from localstack.constants import DEFAULT_PORT_DYNAMODB_BACKEND -from localstack.utils.aws import aws_stack -from localstack.utils.common import mkdir, wait_for_port_open -from localstack.services import install -from localstack.services.infra import get_service_protocol, start_proxy_for_service, do_run -from localstack.services.install import ROOT_PATH - -LOGGER = logging.getLogger(__name__) - - -def check_dynamodb(expect_shutdown=False, print_error=False): - out = None - try: - # wait for port to be opened - wait_for_port_open(DEFAULT_PORT_DYNAMODB_BACKEND) - # check DynamoDB - out = aws_stack.connect_to_service(service_name='dynamodb').list_tables() - except Exception as e: - if print_error: - LOGGER.error('DynamoDB health check failed: %s %s' % (e, traceback.format_exc())) - if expect_shutdown: - assert out is None - else: - assert isinstance(out['TableNames'], list) - - -def start_dynamodb(port=PORT_DYNAMODB, async=False, update_listener=None): - install.install_dynamodb_local() - backend_port = DEFAULT_PORT_DYNAMODB_BACKEND - ddb_data_dir_param = '-inMemory' - if DATA_DIR: - ddb_data_dir = '%s/dynamodb' % DATA_DIR - mkdir(ddb_data_dir) - ddb_data_dir_param = '-dbPath %s' % ddb_data_dir - cmd = ('cd %s/infra/dynamodb/; java -Djava.library.path=./DynamoDBLocal_lib ' + - '-jar DynamoDBLocal.jar -sharedDb -port %s %s') % (ROOT_PATH, backend_port, ddb_data_dir_param) - print('Starting mock DynamoDB (%s port %s)...' % (get_service_protocol(), port)) - start_proxy_for_service('dynamodb', port, backend_port, update_listener) - return do_run(cmd, async) diff --git a/localstack/services/dynamodbstreams/dynamodbstreams_api.py b/localstack/services/dynamodbstreams/dynamodbstreams_api.py deleted file mode 100644 index b158ddf24f634..0000000000000 --- a/localstack/services/dynamodbstreams/dynamodbstreams_api.py +++ /dev/null @@ -1,149 +0,0 @@ -import json -import uuid -import hashlib -from flask import Flask, jsonify, request, make_response -from localstack.services import generic_proxy -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_str - -APP_NAME = 'ddb_streams_api' - -app = Flask(APP_NAME) - -DDB_STREAMS = {} - -DDB_KINESIS_STREAM_NAME_PREFIX = '__ddb_stream_' - -ACTION_HEADER_PREFIX = 'DynamoDBStreams_20120810' - -SEQUENCE_NUMBER_COUNTER = 1 - - -def add_dynamodb_stream(table_name, view_type='NEW_AND_OLD_IMAGES', enabled=True): - if enabled: - # create kinesis stream as a backend - stream_name = get_kinesis_stream_name(table_name) - aws_stack.create_kinesis_stream(stream_name) - stream = { - 'StreamArn': aws_stack.dynamodb_stream_arn(table_name=table_name), - 'TableName': table_name, - 'StreamLabel': 'TODO', - 'StreamStatus': 'ENABLED', - 'KeySchema': [], - 'Shards': [] - } - table_arn = aws_stack.dynamodb_table_arn(table_name) - DDB_STREAMS[table_arn] = stream - - -def forward_events(records): - global SEQUENCE_NUMBER_COUNTER - kinesis = aws_stack.connect_to_service('kinesis') - for record in records: - if 'SequenceNumber' not in record['dynamodb']: - record['dynamodb']['SequenceNumber'] = str(SEQUENCE_NUMBER_COUNTER) - SEQUENCE_NUMBER_COUNTER += 1 - table_arn = record['eventSourceARN'] - stream = DDB_STREAMS.get(table_arn) - if stream: - table_name = table_name_from_stream_arn(stream['StreamArn']) - stream_name = get_kinesis_stream_name(table_name) - kinesis.put_record(StreamName=stream_name, Data=json.dumps(record), PartitionKey='TODO') - - -@app.route('/', methods=['POST']) -def post_request(): - action = request.headers.get('x-amz-target') - data = json.loads(to_str(request.data)) - result = {} - kinesis = aws_stack.connect_to_service('kinesis') - if action == '%s.ListStreams' % ACTION_HEADER_PREFIX: - result = { - 'Streams': list(DDB_STREAMS.values()), - 'LastEvaluatedStreamArn': 'TODO' - } - elif action == '%s.DescribeStream' % ACTION_HEADER_PREFIX: - for stream in DDB_STREAMS.values(): - if stream['StreamArn'] == data['StreamArn']: - result = { - 'StreamDescription': stream - } - # get stream details - dynamodb = aws_stack.connect_to_service('dynamodb') - table_name = table_name_from_stream_arn(stream['StreamArn']) - stream_name = get_kinesis_stream_name(table_name) - stream_details = kinesis.describe_stream(StreamName=stream_name) - table_details = dynamodb.describe_table(TableName=table_name) - stream['KeySchema'] = table_details['Table']['KeySchema'] - - # Replace Kinesis ShardIDs with ones that mimic actual - # DynamoDBStream ShardIDs. - stream_shards = stream_details['StreamDescription']['Shards'] - for shard in stream_shards: - shard['ShardId'] = shard_id(stream_name, shard['ShardId']) - stream['Shards'] = stream_shards - break - if not result: - return error_response('Requested resource not found', error_type='ResourceNotFoundException') - elif action == '%s.GetShardIterator' % ACTION_HEADER_PREFIX: - # forward request to Kinesis API - stream_name = stream_name_from_stream_arn(data['StreamArn']) - stream_shard_id = kinesis_shard_id(data['ShardId']) - result = kinesis.get_shard_iterator(StreamName=stream_name, - ShardId=stream_shard_id, ShardIteratorType=data['ShardIteratorType']) - elif action == '%s.GetRecords' % ACTION_HEADER_PREFIX: - kinesis_records = kinesis.get_records(**data) - result = {'Records': []} - for record in kinesis_records['Records']: - result['Records'].append(json.loads(to_str(record['Data']))) - else: - print('WARNING: Unknown operation "%s"' % action) - return jsonify(result) - - -# ----------------- -# HELPER FUNCTIONS -# ----------------- - -def error_response(message=None, error_type=None, code=400): - if not message: - message = 'Unknown error' - if not error_type: - error_type = 'UnknownError' - if 'com.amazonaws.dynamodb' not in error_type: - error_type = 'com.amazonaws.dynamodb.v20120810#%s' % error_type - content = { - 'message': message, - '__type': error_type - } - return make_response(jsonify(content), code) - - -def get_kinesis_stream_name(table_name): - return DDB_KINESIS_STREAM_NAME_PREFIX + table_name - - -def table_name_from_stream_arn(stream_arn): - return stream_arn.split(':table/')[1].split('/')[0] - - -def stream_name_from_stream_arn(stream_arn): - table_name = table_name_from_stream_arn(stream_arn) - return get_kinesis_stream_name(table_name) - - -def random_id(stream_arn, kinesis_shard_id): - namespace = uuid.UUID(bytes=hashlib.sha1(stream_arn.encode('utf-8')).digest()[:16]) - return uuid.uuid5(namespace, kinesis_shard_id.encode('utf-8')).hex - - -def shard_id(stream_arn, kinesis_shard_id): - return '-'.join([kinesis_shard_id, random_id(stream_arn, kinesis_shard_id)]) - - -def kinesis_shard_id(dynamodbstream_shard_id): - return dynamodbstream_shard_id.rsplit('-', 1)[0] - - -def serve(port, quiet=True): - generic_proxy.serve_flask_app(app=app, port=port, quiet=quiet) diff --git a/localstack/services/es/es_api.py b/localstack/services/es/es_api.py deleted file mode 100644 index 253fe87eced94..0000000000000 --- a/localstack/services/es/es_api.py +++ /dev/null @@ -1,86 +0,0 @@ -import json -from flask import Flask, jsonify, request, make_response -from localstack.services import generic_proxy -from localstack.constants import TEST_AWS_ACCOUNT_ID, DEFAULT_REGION -from localstack.utils.common import to_str - -APP_NAME = 'es_api' -API_PREFIX = '/2015-01-01' - -ES_DOMAINS = {} - -app = Flask(APP_NAME) - - -def error_response(error_type, code=400, message='Unknown error.'): - if not message: - if error_type == 'ResourceNotFoundException': - message = 'Resource not found.' - elif error_type == 'ResourceAlreadyExistsException': - message = 'Resource already exists.' - response = make_response(jsonify({'error': message})) - response.headers['x-amzn-errortype'] = error_type - return response, code - - -def get_domain_status(domain_name, deleted=False): - return { - 'DomainStatus': { - 'ARN': 'arn:aws:es:%s:%s:domain/%s' % (DEFAULT_REGION, TEST_AWS_ACCOUNT_ID, domain_name), - 'Created': True, - 'Deleted': deleted, - 'DomainId': '%s/%s' % (TEST_AWS_ACCOUNT_ID, domain_name), - 'DomainName': domain_name, - 'ElasticsearchClusterConfig': { - 'DedicatedMasterCount': 1, - 'DedicatedMasterEnabled': True, - 'DedicatedMasterType': 'm3.medium.elasticsearch', - 'InstanceCount': 1, - 'InstanceType': 'm3.medium.elasticsearch', - 'ZoneAwarenessEnabled': True - }, - 'ElasticsearchVersion': '5.3', - 'Endpoint': None, - 'Processing': True - } - } - - -@app.route('%s/domain' % API_PREFIX, methods=['GET']) -def list_domain_names(): - result = { - 'DomainNames': [{'DomainName': name} for name in ES_DOMAINS.keys()] - } - return jsonify(result) - - -@app.route('%s/es/domain' % API_PREFIX, methods=['POST']) -def create_domain(): - data = json.loads(to_str(request.data)) - domain_name = data['DomainName'] - if domain_name in ES_DOMAINS: - return error_response(error_type='ResourceAlreadyExistsException') - ES_DOMAINS[domain_name] = data - result = get_domain_status(domain_name) - return jsonify(result) - - -@app.route('%s/es/domain/' % API_PREFIX, methods=['GET']) -def describe_domain(domain_name): - if domain_name not in ES_DOMAINS: - return error_response(error_type='ResourceNotFoundException') - result = get_domain_status(domain_name) - return jsonify(result) - - -@app.route('%s/es/domain/' % API_PREFIX, methods=['DELETE']) -def delete_domain(domain_name): - if domain_name not in ES_DOMAINS: - return error_response(error_type='ResourceNotFoundException') - result = get_domain_status(domain_name, deleted=True) - ES_DOMAINS.pop(domain_name) - return jsonify(result) - - -def serve(port, quiet=True): - generic_proxy.serve_flask_app(app=app, port=port, quiet=quiet) diff --git a/localstack/services/es/es_starter.py b/localstack/services/es/es_starter.py deleted file mode 100644 index 078a3348b1bff..0000000000000 --- a/localstack/services/es/es_starter.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import six -import logging -import traceback -from localstack.constants import DEFAULT_PORT_ELASTICSEARCH_BACKEND, LOCALSTACK_ROOT_FOLDER -from localstack.config import PORT_ELASTICSEARCH, DATA_DIR -from localstack.services.infra import get_service_protocol, start_proxy_for_service, do_run -from localstack.utils.common import run, is_root -from localstack.utils.aws import aws_stack -from localstack.services import install -from localstack.services.install import ROOT_PATH - -LOGGER = logging.getLogger(__name__) - - -def delete_all_elasticsearch_data(): - """ This function drops ALL data in the local Elasticsearch data folder. Use with caution! """ - data_dir = os.path.join(LOCALSTACK_ROOT_FOLDER, 'infra', 'elasticsearch', 'data', 'elasticsearch', 'nodes') - run('rm -rf "%s"' % data_dir) - - -def start_elasticsearch(port=PORT_ELASTICSEARCH, delete_data=True, async=False, update_listener=None): - # delete Elasticsearch data that may be cached locally from a previous test run - delete_all_elasticsearch_data() - - install.install_elasticsearch() - backend_port = DEFAULT_PORT_ELASTICSEARCH_BACKEND - es_data_dir = '%s/infra/elasticsearch/data' % (ROOT_PATH) - if DATA_DIR: - es_data_dir = '%s/elasticsearch' % DATA_DIR - # Elasticsearch 5.x cannot be bound to 0.0.0.0 in some Docker environments, - # hence we use the default bind address 127.0.0.0 and put a proxy in front of it - cmd = (('ES_JAVA_OPTS=\"$ES_JAVA_OPTS -Xms200m -Xmx500m\" %s/infra/elasticsearch/bin/elasticsearch ' + - '-E http.port=%s -E http.publish_port=%s -E http.compression=false -E path.data=%s') % - (ROOT_PATH, backend_port, backend_port, es_data_dir)) - print('Starting local Elasticsearch (%s port %s)...' % (get_service_protocol(), port)) - if delete_data: - run('rm -rf %s' % es_data_dir) - # fix permissions - run('chmod -R 777 %s/infra/elasticsearch' % ROOT_PATH) - run('mkdir -p "%s"; chmod -R 777 "%s"' % (es_data_dir, es_data_dir)) - # start proxy and ES process - start_proxy_for_service('elasticsearch', port, backend_port, - update_listener, quiet=True, params={'protocol_version': 'HTTP/1.0'}) - if is_root(): - cmd = "su -c '%s' localstack" % cmd - thread = do_run(cmd, async) - return thread - - -def check_elasticsearch(expect_shutdown=False, print_error=False): - out = None - try: - # check Elasticsearch - es = aws_stack.connect_elasticsearch() - out = es.cat.aliases() - except Exception as e: - if print_error: - LOGGER.error('Elasticsearch health check failed (retrying...): %s %s' % (e, traceback.format_exc())) - if expect_shutdown: - assert out is None - else: - assert isinstance(out, six.string_types) diff --git a/localstack/services/firehose/firehose_api.py b/localstack/services/firehose/firehose_api.py deleted file mode 100644 index 2550cd90916b8..0000000000000 --- a/localstack/services/firehose/firehose_api.py +++ /dev/null @@ -1,185 +0,0 @@ -from __future__ import print_function - -import json -import uuid -import time -import logging -import base64 -import traceback -from flask import Flask, jsonify, request -from localstack.constants import TEST_AWS_ACCOUNT_ID -from localstack.services import generic_proxy -from localstack.utils.common import short_uid, to_str -from localstack.utils.aws import aws_responses -from localstack.utils.aws.aws_stack import get_s3_client, firehose_stream_arn -from six import iteritems - -APP_NAME = 'firehose_api' -app = Flask(APP_NAME) -ACTION_HEADER_PREFIX = 'Firehose_20150804' - -# logger -LOG = logging.getLogger(__name__) - -# maps stream names to details -DELIVERY_STREAMS = {} - - -def get_delivery_stream_names(): - names = [] - for name, stream in iteritems(DELIVERY_STREAMS): - names.append(stream['DeliveryStreamName']) - return names - - -def put_record(stream_name, record): - return put_records(stream_name, [record]) - - -def put_records(stream_name, records): - stream = get_stream(stream_name) - for dest in stream['Destinations']: - if 'S3DestinationDescription' in dest: - s3_dest = dest['S3DestinationDescription'] - bucket = bucket_name(s3_dest['BucketARN']) - prefix = s3_dest['Prefix'] - s3 = get_s3_client() - for record in records: - data = base64.b64decode(record['Data']) - obj_name = str(uuid.uuid4()) - obj_path = '%s%s' % (prefix, obj_name) - try: - s3.Object(bucket, obj_path).put(Body=data) - except Exception as e: - LOG.error('Unable to put record to stream: %s %s' % (e, traceback.format_exc())) - raise e - - -def get_destination(stream_name, destination_id): - stream = get_stream(stream_name) - destinations = stream['Destinations'] - for dest in destinations: - if dest['DestinationId'] == destination_id: - return dest - dest = {} - dest['DestinationId'] = destination_id - destinations.append(dest) - return dest - - -def update_destination(stream_name, destination_id, - s3_update=None, elasticsearch_update=None, version_id=None): - dest = get_destination(stream_name, destination_id) - if elasticsearch_update: - LOG.warning('Firehose to Elasticsearch updates not yet implemented!') - if s3_update: - if 'S3DestinationDescription' not in dest: - dest['S3DestinationDescription'] = {} - for k, v in iteritems(s3_update): - dest['S3DestinationDescription'][k] = v - return dest - - -def create_stream(stream_name, s3_destination=None): - stream = { - 'HasMoreDestinations': False, - 'VersionId': '1', - 'CreateTimestamp': time.time(), - 'DeliveryStreamARN': firehose_stream_arn(stream_name), - 'DeliveryStreamStatus': 'ACTIVE', - 'DeliveryStreamName': stream_name, - 'Destinations': [] - } - DELIVERY_STREAMS[stream_name] = stream - if s3_destination: - update_destination(stream_name=stream_name, destination_id=short_uid(), s3_update=s3_destination) - return stream - - -def delete_stream(stream_name): - stream = DELIVERY_STREAMS.pop(stream_name, {}) - if not stream: - return error_not_found(stream_name) - return {} - - -def get_stream(stream_name): - if stream_name not in DELIVERY_STREAMS: - return None - return DELIVERY_STREAMS[stream_name] - - -def bucket_name(bucket_arn): - return bucket_arn.split(':::')[-1] - - -def role_arn(stream_name): - return 'arn:aws:iam::%s:role/%s' % (TEST_AWS_ACCOUNT_ID, stream_name) - - -def error_not_found(stream_name): - msg = 'Firehose %s under account %s not found.' % (stream_name, TEST_AWS_ACCOUNT_ID) - return error_response(msg, code=400, error_type='ResourceNotFoundException') - - -def error_response(msg, code=500, error_type='InternalFailure'): - return aws_responses.flask_error_response(msg, code=code, error_type=error_type) - - -@app.route('/', methods=['POST']) -def post_request(): - action = request.headers.get('x-amz-target') - data = json.loads(to_str(request.data)) - response = None - if action == '%s.ListDeliveryStreams' % ACTION_HEADER_PREFIX: - response = { - 'DeliveryStreamNames': get_delivery_stream_names(), - 'HasMoreDeliveryStreams': False - } - elif action == '%s.CreateDeliveryStream' % ACTION_HEADER_PREFIX: - stream_name = data['DeliveryStreamName'] - response = create_stream(stream_name, s3_destination=data.get('S3DestinationConfiguration')) - elif action == '%s.DeleteDeliveryStream' % ACTION_HEADER_PREFIX: - stream_name = data['DeliveryStreamName'] - response = delete_stream(stream_name) - elif action == '%s.DescribeDeliveryStream' % ACTION_HEADER_PREFIX: - stream_name = data['DeliveryStreamName'] - response = get_stream(stream_name) - if not response: - return error_not_found(stream_name) - response = { - 'DeliveryStreamDescription': response - } - elif action == '%s.PutRecord' % ACTION_HEADER_PREFIX: - stream_name = data['DeliveryStreamName'] - record = data['Record'] - put_record(stream_name, record) - response = { - 'RecordId': str(uuid.uuid4()) - } - elif action == '%s.PutRecordBatch' % ACTION_HEADER_PREFIX: - stream_name = data['DeliveryStreamName'] - records = data['Records'] - put_records(stream_name, records) - response = { - 'FailedPutCount': 0, - 'RequestResponses': [] - } - elif action == '%s.UpdateDestination' % ACTION_HEADER_PREFIX: - stream_name = data['DeliveryStreamName'] - version_id = data['CurrentDeliveryStreamVersionId'] - destination_id = data['DestinationId'] - s3_update = data['S3DestinationUpdate'] if 'S3DestinationUpdate' in data else None - update_destination(stream_name=stream_name, destination_id=destination_id, - s3_update=s3_update, version_id=version_id) - response = {} - else: - response = error_response('Unknown action "%s"' % action, code=400, error_type='InvalidAction') - - if isinstance(response, dict): - response = jsonify(response) - return response - - -def serve(port, quiet=True): - generic_proxy.serve_flask_app(app=app, port=port, quiet=quiet) diff --git a/localstack/services/generic_proxy.py b/localstack/services/generic_proxy.py deleted file mode 100644 index 4175f484c5494..0000000000000 --- a/localstack/services/generic_proxy.py +++ /dev/null @@ -1,321 +0,0 @@ -from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -import requests -import os -import sys -import traceback -import logging -import ssl -import inspect -import socket -from flask_cors import CORS -from requests.structures import CaseInsensitiveDict -from requests.models import Response, Request -from six import iteritems -from six.moves.socketserver import ThreadingMixIn -from six.moves.urllib.parse import urlparse -from localstack.config import TMP_FOLDER, USE_SSL -from localstack.constants import ENV_INTERNAL_TEST_RUN -from localstack.utils.common import FuncThread, generate_ssl_cert, to_bytes - -QUIET = False - -# path for test certificate -SERVER_CERT_PEM_FILE = '%s/server.test.pem' % (TMP_FOLDER) - -# CORS settings -CORS_ALLOWED_HEADERS = ('authorization', 'content-type', 'content-md5', - 'x-amz-content-sha256', 'x-amz-date', 'x-amz-security-token', 'x-amz-user-agent') -CORS_ALLOWED_METHODS = ('HEAD', 'GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'PATCH') - -# set up logger -LOGGER = logging.getLogger(__name__) - - -class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): - """Handle each request in a separate thread.""" - daemon_threads = True - - -class ProxyListener(object): - - def forward_request(self, method, path, data, headers): - """ This interceptor method is called by the proxy when receiving a new request - (*before* forwarding the request to the backend service). It receives details - of the incoming request, and returns either of the following results: - - * True if the request should be forwarded to the backend service as-is (default). - * An integer (e.g., 200) status code to return directly to the client without - calling the backend service. - * An instance of requests.models.Response to return directly to the client without - calling the backend service. - * An instance of requests.models.Request which represents a new/modified request - that will be forwarded to the backend service. - * Any other value, in which case a 503 Bad Gateway is returned to the client - without calling the backend service. - """ - return True - - def return_response(self, method, path, data, headers, response): - """ This interceptor method is called by the proxy when returning a response - (*after* having forwarded the request and received a response from the backend - service). It receives details of the incoming request as well as the response - from the backend service, and returns either of the following results: - - * An instance of requests.models.Response to return to the client instead of the - actual response returned from the backend service. - * Any other value, in which case the response from the backend service is - returned to the client. - """ - return None - - -class GenericProxyHandler(BaseHTTPRequestHandler): - - def __init__(self, request, client_address, server): - self.request = request - self.client_address = client_address - self.server = server - self.proxy = server.my_object - self.data_bytes = None - self.protocol_version = self.proxy.protocol_version - BaseHTTPRequestHandler.__init__(self, request, client_address, server) - - def parse_request(self): - result = BaseHTTPRequestHandler.parse_request(self) - if not result: - return result - if sys.version_info[0] >= 3: - return result - # Required fix for Python 2 (otherwise S3 uploads are hanging), based on the Python 3 code: - # https://sourcecodebrowser.com/python3.2/3.2.3/http_2server_8py_source.html#l00332 - expect = self.headers.get('Expect', '') - if (expect.lower() == '100-continue' and - self.protocol_version >= 'HTTP/1.1' and - self.request_version >= 'HTTP/1.1'): - if self.request_version != 'HTTP/0.9': - self.wfile.write(('%s %d %s\r\n' % - (self.protocol_version, 100, 'Continue')).encode('latin1', 'strict')) - self.end_headers() - return result - - def do_GET(self): - self.method = requests.get - self.read_content() - self.forward('GET') - - def do_PUT(self): - self.method = requests.put - self.read_content() - self.forward('PUT') - - def do_POST(self): - self.method = requests.post - self.read_content() - self.forward('POST') - - def do_DELETE(self): - self.data_bytes = None - self.method = requests.delete - self.forward('DELETE') - - def do_HEAD(self): - self.data_bytes = None - self.method = requests.head - self.forward('HEAD') - - def do_PATCH(self): - self.method = requests.patch - self.read_content() - self.forward('PATCH') - - def do_OPTIONS(self): - self.data_bytes = None - self.method = requests.options - self.forward('OPTIONS') - - def read_content(self): - content_length = self.headers.get('Content-Length') - if content_length: - self.data_bytes = self.rfile.read(int(content_length)) - else: - self.data_bytes = None - if self.method in (requests.post, requests.put): - # If the Content-Length header is missing, try to read - # content from the socket using a socket timeout. - socket_timeout_secs = 0.5 - self.request.settimeout(socket_timeout_secs) - while True: - try: - # TODO find a more efficient way to do this! - tmp = self.rfile.read(1) - if self.data_bytes is None: - self.data_bytes = tmp - else: - self.data_bytes += tmp - except socket.timeout: - break - - def forward(self, method): - path = self.path - if '://' in path: - path = '/' + path.split('://', 1)[1].split('/', 1)[1] - proxy_url = '%s%s' % (self.proxy.forward_url, path) - target_url = self.path - if '://' not in target_url: - target_url = '%s%s' % (self.proxy.forward_url, target_url) - data = self.data_bytes - - forward_headers = CaseInsensitiveDict(self.headers) - # update original "Host" header (moto s3 relies on this behavior) - if not forward_headers.get('Host'): - forward_headers['host'] = urlparse(target_url).netloc - if 'localhost.atlassian.io' in forward_headers.get('Host'): - forward_headers['host'] = 'localhost' - - try: - response = None - modified_request = None - # update listener (pre-invocation) - if self.proxy.update_listener: - listener_result = self.proxy.update_listener.forward_request(method=method, - path=path, data=data, headers=forward_headers) - if isinstance(listener_result, Response): - response = listener_result - elif isinstance(listener_result, Request): - modified_request = listener_result - data = modified_request.data - forward_headers = modified_request.headers - elif listener_result is not True: - # get status code from response, or use Bad Gateway status code - code = listener_result if isinstance(listener_result, int) else 503 - self.send_response(code) - self.end_headers() - return - # perform the actual invocation of the backend service - if response is None: - if modified_request: - response = self.method(proxy_url, data=modified_request.data, - headers=modified_request.headers) - else: - response = self.method(proxy_url, data=self.data_bytes, - headers=forward_headers) - # update listener (post-invocation) - if self.proxy.update_listener: - kwargs = { - 'method': method, - 'path': path, - 'data': data, - 'headers': forward_headers, - 'response': response - } - if 'request_handler' in inspect.getargspec(self.proxy.update_listener.return_response)[0]: - # some listeners (e.g., sqs_listener.py) require additional details like the original - # request port, hence we pass in a reference to this request handler as well. - kwargs['request_handler'] = self - updated_response = self.proxy.update_listener.return_response(**kwargs) - if isinstance(updated_response, Response): - response = updated_response - - # copy headers and return response - self.send_response(response.status_code) - - content_length_sent = False - for header_key, header_value in iteritems(response.headers): - # filter out certain headers that we don't want to transmit - if header_key not in ['Transfer-Encoding']: - self.send_header(header_key, header_value) - content_length_sent = content_length_sent or header_key.lower() == 'content-length' - if not content_length_sent: - self.send_header('Content-Length', '%s' % len(response.content) if response.content else 0) - - # allow pre-flight CORS headers by default - if 'Access-Control-Allow-Origin' not in response.headers: - self.send_header('Access-Control-Allow-Origin', '*') - if 'Access-Control-Allow-Methods' not in response.headers: - self.send_header('Access-Control-Allow-Methods', ','.join(CORS_ALLOWED_METHODS)) - if 'Access-Control-Allow-Headers' not in response.headers: - self.send_header('Access-Control-Allow-Headers', ','.join(CORS_ALLOWED_HEADERS)) - - self.end_headers() - if response.content and len(response.content): - self.wfile.write(to_bytes(response.content)) - self.wfile.flush() - except Exception as e: - trace = str(traceback.format_exc()) - conn_error = 'ConnectionRefusedError' in trace or 'NewConnectionError' in trace - error_msg = 'Error forwarding request: %s %s' % (e, trace) - if not self.proxy.quiet or not conn_error: - LOGGER.error(error_msg) - if os.environ.get(ENV_INTERNAL_TEST_RUN): - # During a test run, we also want to print error messages, because - # log messages are delayed until the entire test run is over, and - # hence we are missing messages if the test hangs for some reason. - print('ERROR: %s' % error_msg) - self.send_response(502) # bad gateway - self.end_headers() - - def log_message(self, format, *args): - return - - -class GenericProxy(FuncThread): - def __init__(self, port, forward_url=None, ssl=False, host=None, update_listener=None, quiet=False, params={}): - FuncThread.__init__(self, self.run_cmd, params, quiet=quiet) - self.httpd = None - self.port = port - self.ssl = ssl - self.quiet = quiet - if forward_url: - if '://' not in forward_url: - forward_url = 'http://%s' % forward_url - forward_url = forward_url.rstrip('/') - self.forward_url = forward_url - self.update_listener = update_listener - self.server_stopped = False - # Required to enable 'Connection: keep-alive' for S3 uploads - self.protocol_version = params.get('protocol_version') or 'HTTP/1.1' - self.listen_host = host or '' - - def run_cmd(self, params): - try: - self.httpd = ThreadedHTTPServer((self.listen_host, self.port), GenericProxyHandler) - if self.ssl: - # make sure we have a cert generated - combined_file, cert_file_name, key_file_name = GenericProxy.create_ssl_cert() - self.httpd.socket = ssl.wrap_socket(self.httpd.socket, - server_side=True, certfile=combined_file) - self.httpd.my_object = self - self.httpd.serve_forever() - except Exception as e: - if not self.quiet or not self.server_stopped: - LOGGER.error('Exception running proxy on port %s: %s %s' % (self.port, e, traceback.format_exc())) - - def stop(self, quiet=False): - self.quiet = quiet - if self.httpd: - self.httpd.server_close() - self.server_stopped = True - - @classmethod - def create_ssl_cert(cls, random=True): - return generate_ssl_cert(SERVER_CERT_PEM_FILE, random=random) - - @classmethod - def get_flask_ssl_context(cls): - if USE_SSL: - combined_file, cert_file_name, key_file_name = cls.create_ssl_cert() - return (cert_file_name, key_file_name) - return None - - -def serve_flask_app(app, port, quiet=True, host=None, cors=True): - if cors: - CORS(app) - if quiet: - log = logging.getLogger('werkzeug') - log.setLevel(logging.ERROR) - if not host: - host = '0.0.0.0' - ssl_context = GenericProxy.get_flask_ssl_context() - app.run(port=int(port), threaded=True, host=host, ssl_context=ssl_context) - return app diff --git a/localstack/services/infra.py b/localstack/services/infra.py deleted file mode 100644 index b869c7265af86..0000000000000 --- a/localstack/services/infra.py +++ /dev/null @@ -1,501 +0,0 @@ -import os -import re -import sys -import time -import signal -import traceback -import logging -import boto3 -import subprocess -import six -import warnings -import pkgutil -from localstack import constants, config -from localstack.constants import (ENV_DEV, DEFAULT_REGION, LOCALSTACK_VENV_FOLDER, - DEFAULT_PORT_S3_BACKEND, DEFAULT_PORT_APIGATEWAY_BACKEND, - DEFAULT_PORT_SNS_BACKEND, DEFAULT_PORT_CLOUDFORMATION_BACKEND) -from localstack.config import (USE_SSL, PORT_ROUTE53, PORT_S3, - PORT_FIREHOSE, PORT_LAMBDA, PORT_SNS, PORT_REDSHIFT, PORT_CLOUDWATCH, - PORT_DYNAMODBSTREAMS, PORT_SES, PORT_ES, PORT_CLOUDFORMATION, PORT_APIGATEWAY, - PORT_SSM) -from localstack.utils import common, persistence -from localstack.utils.common import (run, TMP_THREADS, in_ci, run_cmd_safe, - TIMESTAMP_FORMAT, FuncThread, ShellCommandThread, mkdir) -from localstack.utils.analytics import event_publisher -from localstack.services import generic_proxy, install -from localstack.services.firehose import firehose_api -from localstack.services.awslambda import lambda_api -from localstack.services.dynamodbstreams import dynamodbstreams_api -from localstack.services.es import es_api -from localstack.services.generic_proxy import GenericProxy - -# flag to indicate whether signal handlers have been set up already -SIGNAL_HANDLERS_SETUP = False -# maps plugin scope ("services", "commands") to flags which indicate whether plugins have been loaded -PLUGINS_LOADED = {} -# flag to indicate whether we've received and processed the stop signal -INFRA_STOPPED = False - -# default backend host address -DEFAULT_BACKEND_HOST = '127.0.0.1' - -# set up logger -LOGGER = logging.getLogger(os.path.basename(__file__)) - -# map of service plugins, mapping from service name to plugin details -SERVICE_PLUGINS = {} - -# plugin scopes -PLUGIN_SCOPE_SERVICES = 'services' -PLUGIN_SCOPE_COMMANDS = 'commands' - -# log format strings -LOG_FORMAT = '%(asctime)s:%(levelname)s:%(name)s: %(message)s' -LOG_DATE_FORMAT = TIMESTAMP_FORMAT - - -# ----------------- -# PLUGIN UTILITIES -# ----------------- - - -class Plugin(object): - def __init__(self, name, start, check=None, listener=None): - self.plugin_name = name - self.start_function = start - self.listener = listener - self.check_function = check - - def start(self, async): - kwargs = { - 'async': async - } - if self.listener: - kwargs['update_listener'] = self.listener - return self.start_function(**kwargs) - - def check(self, expect_shutdown=False, print_error=False): - if not self.check_function: - return - return self.check_function(expect_shutdown=expect_shutdown, print_error=print_error) - - def name(self): - return self.plugin_name - - -def register_plugin(plugin): - SERVICE_PLUGINS[plugin.name()] = plugin - - -def load_plugin_from_path(file_path, scope=None): - if os.path.exists(file_path): - module = re.sub(r'(^|.+/)([^/]+)/plugins.py', r'\2', file_path) - method_name = 'register_localstack_plugins' - scope = scope or PLUGIN_SCOPE_SERVICES - if scope == PLUGIN_SCOPE_COMMANDS: - method_name = 'register_localstack_commands' - try: - namespace = {} - exec('from %s.plugins import %s' % (module, method_name), namespace) - method_to_execute = namespace[method_name] - except Exception as e: - return - try: - return method_to_execute() - except Exception as e: - LOGGER.warning('Unable to load plugins from file %s: %s' % (file_path, e)) - - -def load_plugins(scope=None): - scope = scope or PLUGIN_SCOPE_SERVICES - if PLUGINS_LOADED.get(scope, None): - return - - setup_logging() - - loaded_files = [] - result = [] - for module in pkgutil.iter_modules(): - file_path = None - if six.PY3 and not isinstance(module, tuple): - file_path = '%s/%s/plugins.py' % (module.module_finder.path, module.name) - elif six.PY3 or isinstance(module[0], pkgutil.ImpImporter): - if hasattr(module[0], 'path'): - file_path = '%s/%s/plugins.py' % (module[0].path, module[1]) - if file_path and file_path not in loaded_files: - plugin_config = load_plugin_from_path(file_path, scope=scope) - if plugin_config: - result.append(plugin_config) - loaded_files.append(file_path) - # set global flag - PLUGINS_LOADED[scope] = result - return result - - -# ----------------- -# API ENTRY POINTS -# ----------------- - - -def start_apigateway(port=PORT_APIGATEWAY, async=False, update_listener=None): - return start_moto_server('apigateway', port, name='API Gateway', async=async, - backend_port=DEFAULT_PORT_APIGATEWAY_BACKEND, update_listener=update_listener) - - -def start_s3(port=PORT_S3, async=False, update_listener=None): - return start_moto_server('s3', port, name='S3', async=async, - backend_port=DEFAULT_PORT_S3_BACKEND, update_listener=update_listener) - - -def start_sns(port=PORT_SNS, async=False, update_listener=None): - return start_moto_server('sns', port, name='SNS', async=async, - backend_port=DEFAULT_PORT_SNS_BACKEND, update_listener=update_listener) - - -def start_cloudformation(port=PORT_CLOUDFORMATION, async=False, update_listener=None): - return start_moto_server('cloudformation', port, name='CloudFormation', async=async, - backend_port=DEFAULT_PORT_CLOUDFORMATION_BACKEND, update_listener=update_listener) - - -def start_cloudwatch(port=PORT_CLOUDWATCH, async=False): - return start_moto_server('cloudwatch', port, name='CloudWatch', async=async) - - -def start_redshift(port=PORT_REDSHIFT, async=False): - return start_moto_server('redshift', port, name='Redshift', async=async) - - -def start_route53(port=PORT_ROUTE53, async=False): - return start_moto_server('route53', port, name='Route53', async=async) - - -def start_ses(port=PORT_SES, async=False): - return start_moto_server('ses', port, name='SES', async=async) - - -def start_elasticsearch_service(port=PORT_ES, async=False): - return start_local_api('ES', port, method=es_api.serve, async=async) - - -def start_firehose(port=PORT_FIREHOSE, async=False): - return start_local_api('Firehose', port, method=firehose_api.serve, async=async) - - -def start_dynamodbstreams(port=PORT_DYNAMODBSTREAMS, async=False): - return start_local_api('DynamoDB Streams', port, method=dynamodbstreams_api.serve, async=async) - - -def start_lambda(port=PORT_LAMBDA, async=False): - return start_local_api('Lambda', port, method=lambda_api.serve, async=async) - - -def start_ssm(port=PORT_SSM, async=False): - return start_moto_server('ssm', port, name='SSM', async=async) - - -# --------------- -# HELPER METHODS -# --------------- - -def setup_logging(): - # determine and set log level - log_level = logging.DEBUG if is_debug() else logging.INFO - logging.basicConfig(level=log_level, format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT) - # disable some logs and warnings - warnings.filterwarnings('ignore') - logging.captureWarnings(True) - logging.getLogger('urllib3').setLevel(logging.WARNING) - logging.getLogger('requests').setLevel(logging.WARNING) - logging.getLogger('botocore').setLevel(logging.ERROR) - logging.getLogger('elasticsearch').setLevel(logging.ERROR) - - -def get_service_protocol(): - return 'https' if USE_SSL else 'http' - - -def restore_persisted_data(apis): - for api in apis: - persistence.restore_persisted_data(api) - - -def register_signal_handlers(): - global SIGNAL_HANDLERS_SETUP - if SIGNAL_HANDLERS_SETUP: - return - - # register signal handlers - def signal_handler(signal, frame): - stop_infra() - os._exit(0) - signal.signal(signal.SIGTERM, signal_handler) - signal.signal(signal.SIGINT, signal_handler) - SIGNAL_HANDLERS_SETUP = True - - -def is_debug(): - return os.environ.get('DEBUG', '').strip() not in ['', '0', 'false'] - - -def do_run(cmd, async, print_output=False): - sys.stdout.flush() - if async: - if is_debug(): - print_output = True - outfile = subprocess.PIPE if print_output else None - t = ShellCommandThread(cmd, outfile=outfile) - t.start() - TMP_THREADS.append(t) - return t - else: - return run(cmd) - - -def start_proxy_for_service(service_name, port, default_backend_port, update_listener, quiet=False, params={}): - # check if we have a custom backend configured - custom_backend_url = os.environ.get('%s_BACKEND' % service_name.upper()) - backend_url = custom_backend_url or ('http://%s:%s' % (DEFAULT_BACKEND_HOST, default_backend_port)) - return start_proxy(port, backend_url=backend_url, update_listener=update_listener, quiet=quiet, params=params) - - -def start_proxy(port, backend_url, update_listener, quiet=False, params={}): - proxy_thread = GenericProxy(port=port, forward_url=backend_url, - ssl=USE_SSL, update_listener=update_listener, quiet=quiet, params=params) - proxy_thread.start() - TMP_THREADS.append(proxy_thread) - return proxy_thread - - -def start_moto_server(key, port, name=None, backend_port=None, async=False, update_listener=None): - moto_server_cmd = '%s/bin/moto_server' % LOCALSTACK_VENV_FOLDER - if not os.path.exists(moto_server_cmd): - moto_server_cmd = run('which moto_server').strip() - cmd = 'VALIDATE_LAMBDA_S3=0 %s %s -p %s -H %s' % (moto_server_cmd, key, backend_port or port, constants.BIND_HOST) - if not name: - name = key - print('Starting mock %s (%s port %s)...' % (name, get_service_protocol(), port)) - if backend_port: - start_proxy_for_service(key, port, backend_port, update_listener) - elif USE_SSL: - cmd += ' --ssl' - return do_run(cmd, async) - - -def start_local_api(name, port, method, async=False): - print('Starting mock %s service (%s port %s)...' % (name, get_service_protocol(), port)) - if async: - thread = FuncThread(method, port, quiet=True) - thread.start() - TMP_THREADS.append(thread) - return thread - else: - method(port) - - -def stop_infra(): - global INFRA_STOPPED - if INFRA_STOPPED: - return - - event_publisher.fire_event(event_publisher.EVENT_STOP_INFRA) - - generic_proxy.QUIET = True - common.cleanup(files=True, quiet=True) - common.cleanup_resources() - lambda_api.cleanup() - time.sleep(2) - # TODO: optimize this (takes too long currently) - # check_infra(retries=2, expect_shutdown=True) - INFRA_STOPPED = True - - -def check_aws_credentials(): - session = boto3.Session() - credentials = None - try: - credentials = session.get_credentials() - except Exception: - pass - if not credentials: - # set temporary dummy credentials - os.environ['AWS_ACCESS_KEY_ID'] = 'LocalStackDummyAccessKey' - os.environ['AWS_SECRET_ACCESS_KEY'] = 'LocalStackDummySecretKey' - session = boto3.Session() - credentials = session.get_credentials() - assert credentials - - -# ----------------------------- -# INFRASTRUCTURE HEALTH CHECKS -# ----------------------------- - - -def check_infra(retries=8, expect_shutdown=False, apis=None, additional_checks=[]): - try: - print_error = retries <= 0 - - # loop through plugins and check service status - for name, plugin in SERVICE_PLUGINS.items(): - if name in apis: - try: - plugin.check(expect_shutdown=expect_shutdown, print_error=print_error) - except Exception as e: - LOGGER.warning('Service "%s" not yet available, retrying...' % name) - raise e - - for additional in additional_checks: - additional(expect_shutdown=expect_shutdown) - except Exception as e: - if retries <= 0: - LOGGER.error('Error checking state of local environment (after some retries): %s' % traceback.format_exc()) - raise e - time.sleep(3) - check_infra(retries - 1, expect_shutdown=expect_shutdown, apis=apis, additional_checks=additional_checks) - - -# ------------- -# DOCKER STARTUP -# ------------- - - -def start_infra_in_docker(): - # load plugins before starting the docker container - plugin_configs = load_plugins() - plugin_run_params = ' '.join([ - entry.get('docker', {}).get('run_flags', '') for entry in plugin_configs]) - - services = os.environ.get('SERVICES', '') - entrypoint = os.environ.get('ENTRYPOINT', '') - cmd = os.environ.get('CMD', '') - image_name = os.environ.get('IMAGE_NAME', constants.DOCKER_IMAGE_NAME) - service_ports = config.SERVICE_PORTS - - # construct port mappings - ports_list = sorted(service_ports.values()) - start_port = 0 - last_port = 0 - port_ranges = [] - for i in range(0, len(ports_list)): - if not start_port: - start_port = ports_list[i] - if not last_port: - last_port = ports_list[i] - if ports_list[i] > last_port + 1: - port_ranges.append([start_port, last_port]) - start_port = ports_list[i] - elif i >= len(ports_list) - 1: - port_ranges.append([start_port, ports_list[i]]) - last_port = ports_list[i] - port_mappings = ' '.join( - '-p {start}-{end}:{start}-{end}'.format(start=entry[0], end=entry[1]) - if entry[0] < entry[1] else '-p {port}:{port}'.format(port=entry[0]) - for entry in port_ranges) - - if services: - port_mappings = '' - for service, port in service_ports.items(): - port_mappings += ' -p {port}:{port}'.format(port=port) - - env_str = '' - for env_var in config.CONFIG_ENV_VARS: - value = os.environ.get(env_var, None) - if value is not None: - env_str += '-e %s="%s" ' % (env_var, value) - - data_dir_mount = '' - data_dir = os.environ.get('DATA_DIR', None) - if data_dir is not None: - container_data_dir = '/tmp/localstack_data' - data_dir_mount = '-v "%s:%s" ' % (data_dir, container_data_dir) - env_str += '-e DATA_DIR="%s" ' % container_data_dir - - interactive = '-it ' if not in_ci() else '' - - # append space if parameter is set - entrypoint = '%s ' % entrypoint if entrypoint else entrypoint - plugin_run_params = '%s ' % plugin_run_params if plugin_run_params else plugin_run_params - - docker_cmd = ('docker run %s%s%s%s' + - '-p 8080:8080 %s %s' + - '-v "%s:/tmp/localstack" -v "%s:%s" ' + - '-e DOCKER_HOST="unix://%s" ' + - '-e HOST_TMP_FOLDER="%s" "%s" %s') % ( - interactive, entrypoint, env_str, plugin_run_params, port_mappings, data_dir_mount, - config.TMP_FOLDER, config.DOCKER_SOCK, config.DOCKER_SOCK, config.DOCKER_SOCK, - config.HOST_TMP_FOLDER, image_name, cmd - ) - - mkdir(config.TMP_FOLDER) - run_cmd_safe(cmd='chmod -R 777 "%s"' % config.TMP_FOLDER) - - print(docker_cmd) - t = ShellCommandThread(docker_cmd, outfile=subprocess.PIPE) - t.start() - time.sleep(2) - t.process.wait() - sys.exit(t.process.returncode) - - -# ------------- -# MAIN STARTUP -# ------------- - - -def start_infra(async=False, apis=None): - try: - # load plugins - load_plugins() - - event_publisher.fire_event(event_publisher.EVENT_START_INFRA) - - # set up logging - setup_logging() - - if not apis: - apis = list(config.SERVICE_PORTS.keys()) - # set environment - os.environ['AWS_REGION'] = DEFAULT_REGION - os.environ['ENV'] = ENV_DEV - # register signal handlers - register_signal_handlers() - # make sure AWS credentials are configured, otherwise boto3 bails on us - check_aws_credentials() - # install libs if not present - install.install_components(apis) - # Some services take a bit to come up - sleep_time = 3 - # start services - thread = None - - if 'elasticsearch' in apis or 'es' in apis: - sleep_time = max(sleep_time, 8) - - # loop through plugins and start each service - for name, plugin in SERVICE_PLUGINS.items(): - if name in apis: - t1 = plugin.start(async=True) - thread = thread or t1 - - time.sleep(sleep_time) - # check that all infra components are up and running - check_infra(apis=apis) - # restore persisted data - restore_persisted_data(apis=apis) - print('Ready.') - sys.stdout.flush() - if not async and thread: - # this is a bit of an ugly hack, but we need to make sure that we - # stay in the execution context of the main thread, otherwise our - # signal handlers don't work - while True: - time.sleep(1) - return thread - except KeyboardInterrupt as e: - print('Shutdown') - except Exception as e: - print('Error starting infrastructure: %s %s' % (e, traceback.format_exc())) - sys.stdout.flush() - raise e - finally: - if not async: - stop_infra() diff --git a/localstack/services/install.py b/localstack/services/install.py deleted file mode 100755 index 99fab2926cc5f..0000000000000 --- a/localstack/services/install.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python - -import os -import sys -import glob -import shutil -import logging -import tempfile -from localstack.constants import (DEFAULT_SERVICE_PORTS, ELASTICMQ_JAR_URL, STS_JAR_URL, - ELASTICSEARCH_JAR_URL, DYNAMODB_JAR_URL, LOCALSTACK_MAVEN_VERSION) -from localstack.utils.common import download, parallelize, run, mkdir, save_file, unzip, rm_rf - -THIS_PATH = os.path.dirname(os.path.realpath(__file__)) -ROOT_PATH = os.path.realpath(os.path.join(THIS_PATH, '..')) - -INSTALL_DIR_INFRA = '%s/infra' % ROOT_PATH -INSTALL_DIR_NPM = '%s/node_modules' % ROOT_PATH -INSTALL_DIR_ES = '%s/elasticsearch' % INSTALL_DIR_INFRA -INSTALL_DIR_DDB = '%s/dynamodb' % INSTALL_DIR_INFRA -INSTALL_DIR_KCL = '%s/amazon-kinesis-client' % INSTALL_DIR_INFRA -INSTALL_DIR_ELASTICMQ = '%s/elasticmq' % INSTALL_DIR_INFRA -INSTALL_PATH_LOCALSTACK_FAT_JAR = '%s/localstack-utils-fat.jar' % INSTALL_DIR_INFRA -TMP_ARCHIVE_ES = os.path.join(tempfile.gettempdir(), 'localstack.es.zip') -TMP_ARCHIVE_DDB = os.path.join(tempfile.gettempdir(), 'localstack.ddb.zip') -TMP_ARCHIVE_STS = os.path.join(tempfile.gettempdir(), 'aws-java-sdk-sts.jar') -TMP_ARCHIVE_ELASTICMQ = os.path.join(tempfile.gettempdir(), 'elasticmq-server.jar') -URL_LOCALSTACK_FAT_JAR = ('http://central.maven.org/maven2/' + - 'cloud/localstack/localstack-utils/{v}/localstack-utils-{v}-fat.jar').format(v=LOCALSTACK_MAVEN_VERSION) - -# set up logger -LOGGER = logging.getLogger(__name__) - - -def install_elasticsearch(): - if not os.path.exists(INSTALL_DIR_ES): - LOGGER.info('Downloading and installing local Elasticsearch server. This may take some time.') - run('mkdir -p %s' % INSTALL_DIR_INFRA) - # download and extract archive - download_and_extract_with_retry(ELASTICSEARCH_JAR_URL, TMP_ARCHIVE_ES, INSTALL_DIR_INFRA) - run('cd %s && mv elasticsearch* elasticsearch' % (INSTALL_DIR_INFRA)) - - for dir_name in ('data', 'logs', 'modules', 'plugins', 'config/scripts'): - cmd = 'cd %s && mkdir -p %s && chmod -R 777 %s' - run(cmd % (INSTALL_DIR_ES, dir_name, dir_name)) - - -def install_elasticmq(): - if not os.path.exists(INSTALL_DIR_ELASTICMQ): - LOGGER.info('Downloading and installing local ElasticMQ server. This may take some time.') - run('mkdir -p %s' % INSTALL_DIR_ELASTICMQ) - # download archive - if not os.path.exists(TMP_ARCHIVE_ELASTICMQ): - download(ELASTICMQ_JAR_URL, TMP_ARCHIVE_ELASTICMQ) - shutil.copy(TMP_ARCHIVE_ELASTICMQ, INSTALL_DIR_ELASTICMQ) - - -def install_kinesalite(): - target_dir = '%s/kinesalite' % INSTALL_DIR_NPM - if not os.path.exists(target_dir): - LOGGER.info('Downloading and installing local Kinesis server. This may take some time.') - run('cd "%s" && npm install' % ROOT_PATH) - - -def install_dynamodb_local(): - if not os.path.exists(INSTALL_DIR_DDB): - LOGGER.info('Downloading and installing local DynamoDB server. This may take some time.') - mkdir(INSTALL_DIR_DDB) - # download and extract archive - download_and_extract_with_retry(DYNAMODB_JAR_URL, TMP_ARCHIVE_DDB, INSTALL_DIR_DDB) - - # fix for Alpine, otherwise DynamoDBLocal fails with: - # DynamoDBLocal_lib/libsqlite4java-linux-amd64.so: __memcpy_chk: symbol not found - if is_alpine(): - ddb_libs_dir = '%s/DynamoDBLocal_lib' % INSTALL_DIR_DDB - patched_marker = '%s/alpine_fix_applied' % ddb_libs_dir - if not os.path.exists(patched_marker): - patched_lib = ('https://rawgit.com/bhuisgen/docker-alpine/master/alpine-dynamodb/' + - 'rootfs/usr/local/dynamodb/DynamoDBLocal_lib/libsqlite4java-linux-amd64.so') - patched_jar = ('https://rawgit.com/bhuisgen/docker-alpine/master/alpine-dynamodb/' + - 'rootfs/usr/local/dynamodb/DynamoDBLocal_lib/sqlite4java.jar') - run("curl -L -o %s/libsqlite4java-linux-amd64.so '%s'" % (ddb_libs_dir, patched_lib)) - run("curl -L -o %s/sqlite4java.jar '%s'" % (ddb_libs_dir, patched_jar)) - save_file(patched_marker, '') - - -def install_amazon_kinesis_client_libs(): - # install KCL/STS JAR files - if not os.path.exists(INSTALL_DIR_KCL): - mkdir(INSTALL_DIR_KCL) - if not os.path.exists(TMP_ARCHIVE_STS): - download(STS_JAR_URL, TMP_ARCHIVE_STS) - shutil.copy(TMP_ARCHIVE_STS, INSTALL_DIR_KCL) - # Compile Java files - from localstack.utils.kinesis import kclipy_helper - classpath = kclipy_helper.get_kcl_classpath() - java_files = '%s/utils/kinesis/java/com/atlassian/*.java' % ROOT_PATH - class_files = '%s/utils/kinesis/java/com/atlassian/*.class' % ROOT_PATH - if not glob.glob(class_files): - run('javac -cp "%s" %s' % (classpath, java_files)) - - -def install_lambda_java_libs(): - # install LocalStack "fat" JAR file (contains all dependencies) - if not os.path.exists(INSTALL_PATH_LOCALSTACK_FAT_JAR): - download(URL_LOCALSTACK_FAT_JAR, INSTALL_PATH_LOCALSTACK_FAT_JAR) - - -def install_component(name): - if name == 'kinesis': - install_kinesalite() - elif name == 'dynamodb': - install_dynamodb_local() - elif name == 'es': - install_elasticsearch() - elif name == 'sqs': - install_elasticmq() - - -def install_components(names): - parallelize(install_component, names) - install_lambda_java_libs() - - -def install_all_components(): - install_components(DEFAULT_SERVICE_PORTS.keys()) - - -# ----------------- -# HELPER FUNCTIONS -# ----------------- - - -def is_alpine(): - try: - run('cat /etc/issue | grep Alpine', print_error=False) - return True - except Exception: - return False - - -def download_and_extract_with_retry(archive_url, tmp_archive, target_dir): - - def download_and_extract(): - if not os.path.exists(tmp_archive): - download(archive_url, tmp_archive) - unzip(tmp_archive, target_dir) - - try: - download_and_extract() - except Exception: - # try deleting and re-downloading the zip file - LOGGER.info('Unable to extract file, re-downloading ZIP archive: %s' % tmp_archive) - rm_rf(tmp_archive) - download_and_extract() - - -if __name__ == '__main__': - - if len(sys.argv) > 1: - if sys.argv[1] == 'libs': - print('Initializing installation.') - logging.basicConfig(level=logging.INFO) - logging.getLogger('requests').setLevel(logging.WARNING) - install_all_components() - print('Done.') - elif sys.argv[1] == 'testlibs': - # Install additional libraries for testing - install_amazon_kinesis_client_libs() diff --git a/localstack/services/kinesis/kinesis_listener.py b/localstack/services/kinesis/kinesis_listener.py deleted file mode 100644 index 2ec3e367a0803..0000000000000 --- a/localstack/services/kinesis/kinesis_listener.py +++ /dev/null @@ -1,77 +0,0 @@ -import random -import json -from requests.models import Response -from localstack import config -from localstack.utils.common import to_str -from localstack.utils.analytics import event_publisher -from localstack.services.awslambda import lambda_api -from localstack.services.generic_proxy import ProxyListener - -# action headers -ACTION_PREFIX = 'Kinesis_20131202' -ACTION_PUT_RECORD = '%s.PutRecord' % ACTION_PREFIX -ACTION_PUT_RECORDS = '%s.PutRecords' % ACTION_PREFIX -ACTION_CREATE_STREAM = '%s.CreateStream' % ACTION_PREFIX -ACTION_DELETE_STREAM = '%s.DeleteStream' % ACTION_PREFIX - - -class ProxyListenerKinesis(ProxyListener): - - def forward_request(self, method, path, data, headers): - data = json.loads(to_str(data)) - - if random.random() < config.KINESIS_ERROR_PROBABILITY: - return kinesis_error_response(data) - return True - - def return_response(self, method, path, data, headers, response): - action = headers.get('X-Amz-Target') - data = json.loads(to_str(data)) - - records = [] - if action in (ACTION_CREATE_STREAM, ACTION_DELETE_STREAM): - event_type = (event_publisher.EVENT_KINESIS_CREATE_STREAM if action == ACTION_CREATE_STREAM - else event_publisher.EVENT_KINESIS_DELETE_STREAM) - event_publisher.fire_event(event_type, payload={'n': event_publisher.get_hash(data.get('StreamName'))}) - elif action == ACTION_PUT_RECORD: - response_body = json.loads(to_str(response.content)) - event_record = { - 'data': data['Data'], - 'partitionKey': data['PartitionKey'], - 'sequenceNumber': response_body.get('SequenceNumber') - } - event_records = [event_record] - stream_name = data['StreamName'] - lambda_api.process_kinesis_records(event_records, stream_name) - elif action == ACTION_PUT_RECORDS: - event_records = [] - response_body = json.loads(to_str(response.content)) - response_records = response_body['Records'] - records = data['Records'] - for index in range(0, len(records)): - record = records[index] - event_record = { - 'data': record['Data'], - 'partitionKey': record['PartitionKey'], - 'sequenceNumber': response_records[index].get('SequenceNumber') - } - event_records.append(event_record) - stream_name = data['StreamName'] - lambda_api.process_kinesis_records(event_records, stream_name) - - -# instantiate listener -UPDATE_KINESIS = ProxyListenerKinesis() - - -def kinesis_error_response(data): - error_response = Response() - error_response.status_code = 200 - content = {'FailedRecordCount': 1, 'Records': []} - for record in data['Records']: - content['Records'].append({ - 'ErrorCode': 'ProvisionedThroughputExceededException', - 'ErrorMessage': 'Rate exceeded for shard X in stream Y under account Z.' - }) - error_response._content = json.dumps(content) - return error_response diff --git a/localstack/services/kinesis/kinesis_starter.py b/localstack/services/kinesis/kinesis_starter.py deleted file mode 100644 index 93089c9d2f8db..0000000000000 --- a/localstack/services/kinesis/kinesis_starter.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging -import traceback -from localstack.config import PORT_KINESIS, DATA_DIR -from localstack.constants import DEFAULT_PORT_KINESIS_BACKEND -from localstack.utils.aws import aws_stack -from localstack.utils.common import mkdir -from localstack.services import install -from localstack.services.infra import get_service_protocol, start_proxy_for_service, do_run -from localstack.services.install import ROOT_PATH - -LOGGER = logging.getLogger(__name__) - - -def start_kinesis(port=PORT_KINESIS, async=False, shard_limit=100, update_listener=None): - install.install_kinesalite() - backend_port = DEFAULT_PORT_KINESIS_BACKEND - kinesis_data_dir_param = '' - if DATA_DIR: - kinesis_data_dir = '%s/kinesis' % DATA_DIR - mkdir(kinesis_data_dir) - kinesis_data_dir_param = '--path %s' % kinesis_data_dir - cmd = ('%s/node_modules/kinesalite/cli.js --shardLimit %s --port %s %s' % - (ROOT_PATH, shard_limit, backend_port, kinesis_data_dir_param)) - print('Starting mock Kinesis (%s port %s)...' % (get_service_protocol(), port)) - start_proxy_for_service('kinesis', port, backend_port, update_listener) - return do_run(cmd, async) - - -def check_kinesis(expect_shutdown=False, print_error=False): - out = None - try: - # check Kinesis - out = aws_stack.connect_to_service(service_name='kinesis').list_streams() - except Exception as e: - if print_error: - LOGGER.error('Kinesis health check failed: %s %s' % (e, traceback.format_exc())) - if expect_shutdown: - assert out is None - else: - assert isinstance(out['StreamNames'], list) diff --git a/localstack/services/s3/s3_listener.py b/localstack/services/s3/s3_listener.py deleted file mode 100644 index e8dcf2751add1..0000000000000 --- a/localstack/services/s3/s3_listener.py +++ /dev/null @@ -1,530 +0,0 @@ -import re -import logging -import json -import uuid -import xmltodict -import cgi -import email.parser -import collections -import six -from six import iteritems -from six.moves.urllib import parse as urlparse -import botocore.config -from requests.models import Response, Request -from localstack.constants import DEFAULT_REGION -from localstack.utils import persistence -from localstack.utils.aws import aws_stack -from localstack.utils.common import short_uid, timestamp, TIMESTAMP_FORMAT_MILLIS, to_str, to_bytes, clone -from localstack.utils.analytics import event_publisher -from localstack.services.generic_proxy import ProxyListener - -# mappings for S3 bucket notifications -S3_NOTIFICATIONS = {} - -# mappings for bucket CORS settings -BUCKET_CORS = {} - -# mappings for bucket lifecycle settings -BUCKET_LIFECYCLE = {} - -# set up logger -LOGGER = logging.getLogger(__name__) - -# XML namespace constants -XMLNS_S3 = 'http://s3.amazonaws.com/doc/2006-03-01/' - - -def event_type_matches(events, action, api_method): - """ check whether any of the event types in `events` matches the - given `action` and `api_method`, and return the first match. """ - for event in events: - regex = event.replace('*', '[^:]*') - action_string = 's3:%s:%s' % (action, api_method) - match = re.match(regex, action_string) - if match: - return match - return False - - -def filter_rules_match(filters, object_path): - """ check whether the given object path matches all of the given filters """ - filters = filters or {} - s3_filter = _get_s3_filter(filters) - for rule in s3_filter.get('FilterRule', []): - if rule['Name'] == 'prefix': - if not prefix_with_slash(object_path).startswith(prefix_with_slash(rule['Value'])): - return False - elif rule['Name'] == 'suffix': - if not object_path.endswith(rule['Value']): - return False - else: - LOGGER.warning('Unknown filter name: "%s"' % rule['Name']) - return True - - -def _get_s3_filter(filters): - return filters.get('S3Key', filters.get('Key', {})) - - -def prefix_with_slash(s): - return s if s[0] == '/' else '/%s' % s - - -def get_event_message(event_name, bucket_name, file_name='testfile.txt', file_size=1024): - # Based on: http://docs.aws.amazon.com/AmazonS3/latest/dev/notification-content-structure.html - return { - 'Records': [{ - 'eventVersion': '2.0', - 'eventSource': 'aws:s3', - 'awsRegion': DEFAULT_REGION, - 'eventTime': timestamp(format=TIMESTAMP_FORMAT_MILLIS), - 'eventName': event_name, - 'userIdentity': { - 'principalId': 'AIDAJDPLRKLG7UEXAMPLE' - }, - 'requestParameters': { - 'sourceIPAddress': '127.0.0.1' # TODO determine real source IP - }, - 'responseElements': { - 'x-amz-request-id': short_uid(), - 'x-amz-id-2': 'eftixk72aD6Ap51TnqcoF8eFidJG9Z/2' # Amazon S3 host that processed the request - }, - 's3': { - 's3SchemaVersion': '1.0', - 'configurationId': 'testConfigRule', - 'bucket': { - 'name': bucket_name, - 'ownerIdentity': { - 'principalId': 'A3NL1KOZZKExample' - }, - 'arn': 'arn:aws:s3:::%s' % bucket_name - }, - 'object': { - 'key': file_name, - 'size': file_size, - 'eTag': 'd41d8cd98f00b204e9800998ecf8427e', - 'versionId': '096fKKXTRTtl3on89fVO.nfljtsv6qko', - 'sequencer': '0055AED6DCD90281E5' - } - } - }] - } - - -def queue_url_for_arn(queue_arn): - sqs_client = aws_stack.connect_to_service('sqs') - parts = queue_arn.split(':') - return sqs_client.get_queue_url(QueueName=parts[5], - QueueOwnerAWSAccountId=parts[4])['QueueUrl'] - - -def send_notifications(method, bucket_name, object_path): - for bucket, config in iteritems(S3_NOTIFICATIONS): - if bucket == bucket_name: - action = {'PUT': 'ObjectCreated', 'DELETE': 'ObjectRemoved'}[method] - # TODO: support more detailed methods, e.g., DeleteMarkerCreated - # http://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html - api_method = {'PUT': 'Put', 'DELETE': 'Delete'}[method] - event_name = '%s:%s' % (action, api_method) - if (event_type_matches(config['Event'], action, api_method) and - filter_rules_match(config.get('Filter'), object_path)): - # send notification - message = get_event_message( - event_name=event_name, bucket_name=bucket_name, - file_name=urlparse.urlparse(object_path[1:]).path - ) - message = json.dumps(message) - if config.get('Queue'): - sqs_client = aws_stack.connect_to_service('sqs') - try: - queue_url = queue_url_for_arn(config['Queue']) - sqs_client.send_message(QueueUrl=queue_url, MessageBody=message) - except Exception as e: - LOGGER.warning('Unable to send notification for S3 bucket "%s" to SQS queue "%s": %s' % - (bucket_name, config['Queue'], e)) - if config.get('Topic'): - sns_client = aws_stack.connect_to_service('sns') - try: - sns_client.publish(TopicArn=config['Topic'], Message=message) - except Exception as e: - LOGGER.warning('Unable to send notification for S3 bucket "%s" to SNS topic "%s".' % - (bucket_name, config['Topic'])) - if config.get('CloudFunction'): - # make sure we don't run into a socket timeout - connection_config = botocore.config.Config(read_timeout=300) - lambda_client = aws_stack.connect_to_service('lambda', config=connection_config) - try: - lambda_client.invoke(FunctionName=config['CloudFunction'], Payload=message) - except Exception as e: - LOGGER.warning('Unable to send notification for S3 bucket "%s" to Lambda function "%s".' % - (bucket_name, config['CloudFunction'])) - if not filter(lambda x: config.get(x), ('Queue', 'Topic', 'CloudFunction')): - LOGGER.warning('Neither of Queue/Topic/CloudFunction defined for S3 notification.') - - -def get_cors(bucket_name): - response = Response() - cors = BUCKET_CORS.get(bucket_name) - if not cors: - # TODO: check if bucket exists, otherwise return 404-like error - cors = { - 'CORSConfiguration': [] - } - body = xmltodict.unparse(cors) - response._content = body - response.status_code = 200 - return response - - -def set_cors(bucket_name, cors): - # TODO: check if bucket exists, otherwise return 404-like error - if isinstance(cors, six.string_types): - cors = xmltodict.parse(cors) - BUCKET_CORS[bucket_name] = cors - response = Response() - response.status_code = 200 - return response - - -def delete_cors(bucket_name): - # TODO: check if bucket exists, otherwise return 404-like error - BUCKET_CORS.pop(bucket_name, {}) - response = Response() - response.status_code = 200 - return response - - -def append_cors_headers(bucket_name, request_method, request_headers, response): - cors = BUCKET_CORS.get(bucket_name) - if not cors: - return - origin = request_headers.get('Origin', '') - rules = cors['CORSConfiguration']['CORSRule'] - if not isinstance(rules, list): - rules = [rules] - for rule in rules: - allowed_methods = rule.get('AllowedMethod', []) - if request_method in allowed_methods: - allowed_origins = rule.get('AllowedOrigin', []) - for allowed in allowed_origins: - if origin in allowed or re.match(allowed.replace('*', '.*'), origin): - response.headers['Access-Control-Allow-Origin'] = origin - break - - -def get_lifecycle(bucket_name): - response = Response() - lifecycle = BUCKET_LIFECYCLE.get(bucket_name) - if not lifecycle: - # TODO: check if bucket exists, otherwise return 404-like error - lifecycle = { - 'LifecycleConfiguration': [] - } - body = xmltodict.unparse(lifecycle) - response._content = body - response.status_code = 200 - return response - - -def set_lifecycle(bucket_name, lifecycle): - # TODO: check if bucket exists, otherwise return 404-like error - if isinstance(to_str(lifecycle), six.string_types): - lifecycle = xmltodict.parse(lifecycle) - BUCKET_LIFECYCLE[bucket_name] = lifecycle - response = Response() - response.status_code = 200 - return response - - -def strip_chunk_signatures(data): - # For clients that use streaming v4 authentication, the request contains chunk signatures - # in the HTTP body (see example below) which we need to strip as moto cannot handle them - # - # 17;chunk-signature=6e162122ec4962bea0b18bc624025e6ae4e9322bdc632762d909e87793ac5921 - # - # 0;chunk-signature=927ab45acd82fc90a3c210ca7314d59fedc77ce0c914d79095f8cc9563cf2c70 - - data_new = re.sub(b'(\r\n)?[0-9a-fA-F]+;chunk-signature=[0-9a-f]{64}(\r\n){,2}', b'', - data, flags=re.MULTILINE | re.DOTALL) - if data_new != data: - # trim \r (13) or \n (10) - for i in range(0, 2): - if len(data_new) and data_new[0] in (10, 13): - data_new = data_new[1:] - for i in range(0, 6): - if len(data_new) and data_new[-1] in (10, 13): - data_new = data_new[:-1] - return data_new - - -def _iter_multipart_parts(some_bytes, boundary): - """ Generate a stream of dicts and bytes for each message part. - - Content-Disposition is used as a header for a multipart body: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition - """ - try: - parse_data = email.parser.BytesHeaderParser().parsebytes - except AttributeError: - # Fall back in case of Python 2.x - parse_data = email.parser.HeaderParser().parsestr - - while True: - try: - part, some_bytes = some_bytes.split(boundary, 1) - except ValueError: - # Ran off the end, stop. - break - - if b'\r\n\r\n' not in part: - # Real parts have headers and a value separated by '\r\n'. - continue - - part_head, _ = part.split(b'\r\n\r\n', 1) - head_parsed = parse_data(part_head.lstrip(b'\r\n')) - - if 'Content-Disposition' in head_parsed: - _, params = cgi.parse_header(head_parsed['Content-Disposition']) - yield params, part - - -def expand_multipart_filename(data, headers): - """ Replace instance of '${filename}' in key with given file name. - - Data is given as multipart form submission bytes, and file name is - replace according to Amazon S3 documentation for Post uploads: - http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html - """ - _, params = cgi.parse_header(headers.get('Content-Type', '')) - - if 'boundary' not in params: - return data - - boundary = params['boundary'].encode('ascii') - data_bytes = to_bytes(data) - - filename = None - - for (disposition, _) in _iter_multipart_parts(data_bytes, boundary): - if disposition.get('name') == 'file' and 'filename' in disposition: - filename = disposition['filename'] - break - - if filename is None: - # Found nothing, return unaltered - return data - - for (disposition, part) in _iter_multipart_parts(data_bytes, boundary): - if disposition.get('name') == 'key' and b'${filename}' in part: - search = boundary + part - replace = boundary + part.replace(b'${filename}', filename.encode('utf8')) - - if search in data_bytes: - return data_bytes.replace(search, replace) - - return data - - -def find_multipart_redirect_url(data, headers): - """ Return object key and redirect URL if they can be found. - - Data is given as multipart form submission bytes, and redirect is found - in the success_action_redirect field according to Amazon S3 - documentation for Post uploads: - http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html - """ - _, params = cgi.parse_header(headers.get('Content-Type', '')) - key, redirect_url = None, None - - if 'boundary' not in params: - return key, redirect_url - - boundary = params['boundary'].encode('ascii') - data_bytes = to_bytes(data) - - for (disposition, part) in _iter_multipart_parts(data_bytes, boundary): - if disposition.get('name') == 'key': - _, value = part.split(b'\r\n\r\n', 1) - key = value.rstrip(b'\r\n--').decode('utf8') - - if key: - for (disposition, part) in _iter_multipart_parts(data_bytes, boundary): - if disposition.get('name') == 'success_action_redirect': - _, value = part.split(b'\r\n\r\n', 1) - redirect_url = value.rstrip(b'\r\n--').decode('utf8') - - return key, redirect_url - - -def expand_redirect_url(starting_url, key, bucket): - """ Add key and bucket parameters to starting URL query string. """ - parsed = urlparse.urlparse(starting_url) - query = collections.OrderedDict(urlparse.parse_qsl(parsed.query)) - query.update([('key', key), ('bucket', bucket)]) - - redirect_url = urlparse.urlunparse(( - parsed.scheme, parsed.netloc, parsed.path, - parsed.params, urlparse.urlencode(query), None)) - - return redirect_url - - -class ProxyListenerS3(ProxyListener): - - def forward_request(self, method, path, data, headers): - - modified_data = None - - # If this request contains streaming v4 authentication signatures, strip them from the message - # Related isse: https://github.com/localstack/localstack/issues/98 - # TODO we should evaluate whether to replace moto s3 with scality/S3: - # https://github.com/scality/S3/issues/237 - if headers.get('x-amz-content-sha256') == 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD': - modified_data = strip_chunk_signatures(data) - - # POST requests to S3 may include a "${filename}" placeholder in the - # key, which should be replaced with an actual file name before storing. - if method == 'POST': - original_data = modified_data or data - expanded_data = expand_multipart_filename(original_data, headers) - if expanded_data is not original_data: - modified_data = expanded_data - - # persist this API call to disk - persistence.record('s3', method, path, data, headers) - - parsed = urlparse.urlparse(path) - query = parsed.query - path = parsed.path - bucket = path.split('/')[1] - query_map = urlparse.parse_qs(query) - if query == 'notification' or 'notification' in query_map: - response = Response() - response.status_code = 200 - if method == 'GET': - # TODO check if bucket exists - result = '' % XMLNS_S3 - if bucket in S3_NOTIFICATIONS: - notif = S3_NOTIFICATIONS[bucket] - for dest in ['Queue', 'Topic', 'CloudFunction']: - if dest in notif: - dest_dict = { - '%sConfiguration' % dest: { - 'Id': uuid.uuid4(), - dest: notif[dest], - 'Event': notif['Event'], - 'Filter': notif['Filter'] - } - } - result += xmltodict.unparse(dest_dict, full_document=False) - result += '' - response._content = result - - if method == 'PUT': - parsed = xmltodict.parse(data) - notif_config = parsed.get('NotificationConfiguration') - S3_NOTIFICATIONS.pop(bucket, None) - for dest in ['Queue', 'Topic', 'CloudFunction']: - config = notif_config.get('%sConfiguration' % (dest)) - if config: - events = config.get('Event') - if isinstance(events, six.string_types): - events = [events] - event_filter = config.get('Filter', {}) - # make sure FilterRule is an array - s3_filter = _get_s3_filter(event_filter) - if s3_filter and not isinstance(s3_filter.get('FilterRule', []), list): - s3_filter['FilterRule'] = [s3_filter['FilterRule']] - # create final details dict - notification_details = { - 'Id': config.get('Id'), - 'Event': events, - dest: config.get(dest), - 'Filter': event_filter - } - # TODO: what if we have multiple destinations - would we overwrite the config? - S3_NOTIFICATIONS[bucket] = clone(notification_details) - - # return response for ?notification request - return response - - if query == 'cors' or 'cors' in query_map: - if method == 'GET': - return get_cors(bucket) - if method == 'PUT': - return set_cors(bucket, data) - if method == 'DELETE': - return delete_cors(bucket) - - if query == 'lifecycle' or 'lifecycle' in query_map: - if method == 'GET': - return get_lifecycle(bucket) - if method == 'PUT': - return set_lifecycle(bucket, data) - - if modified_data: - return Request(data=modified_data, headers=headers, method=method) - return True - - def return_response(self, method, path, data, headers, response): - - parsed = urlparse.urlparse(path) - # TODO: consider the case of hostname-based (as opposed to path-based) bucket addressing - bucket_name = parsed.path.split('/')[1] - - # POST requests to S3 may include a success_action_redirect field, - # which should be used to redirect a client to a new location. - if method == 'POST': - key, redirect_url = find_multipart_redirect_url(data, headers) - if key and redirect_url: - response.status_code = 303 - response.headers['Location'] = expand_redirect_url(redirect_url, key, bucket_name) - LOGGER.debug('S3 POST {} to {}'.format(response.status_code, response.headers['Location'])) - - # get subscribers and send bucket notifications - if method in ('PUT', 'DELETE') and '/' in path[1:]: - # check if this is an actual put object request, because it could also be - # a put bucket request with a path like this: /bucket_name/ - if len(path[1:].split('/')[1]) > 0: - parts = parsed.path[1:].split('/', 1) - # ignore bucket notification configuration requests - if parsed.query != 'notification' and parsed.query != 'lifecycle': - object_path = parts[1] if parts[1][0] == '/' else '/%s' % parts[1] - send_notifications(method, bucket_name, object_path) - - # publish event for creation/deletion of buckets: - if method in ('PUT', 'DELETE') and ('/' not in path[1:] or len(path[1:].split('/')[1]) <= 0): - event_type = (event_publisher.EVENT_S3_CREATE_BUCKET if method == 'PUT' - else event_publisher.EVENT_S3_DELETE_BUCKET) - event_publisher.fire_event(event_type, payload={'n': event_publisher.get_hash(bucket_name)}) - - # fix an upstream issue in moto S3 (see https://github.com/localstack/localstack/issues/382) - if method == 'PUT' and parsed.query == 'policy': - response._content = '' - response.status_code = 204 - return response - - # append CORS headers to response - if response: - append_cors_headers(bucket_name, request_method=method, request_headers=headers, response=response) - - response_content_str = None - try: - response_content_str = to_str(response._content) - except Exception: - pass - - # we need to un-pretty-print the XML, otherwise we run into this issue with Spark: - # https://github.com/jserver/mock-s3/pull/9/files - # https://github.com/localstack/localstack/issues/183 - # Note: yet, we need to make sure we have a newline after the first line: \n - if response_content_str and response_content_str.startswith('<'): - is_bytes = isinstance(response._content, six.binary_type) - response._content = re.sub(r'([^\?])>\n\s*<', r'\1><', response_content_str, flags=re.MULTILINE) - if is_bytes: - response._content = to_bytes(response._content) - response.headers['content-length'] = len(response._content) - - -# instantiate listener -UPDATE_S3 = ProxyListenerS3() diff --git a/localstack/services/s3/s3_starter.py b/localstack/services/s3/s3_starter.py deleted file mode 100644 index 8fd97c8f4db3a..0000000000000 --- a/localstack/services/s3/s3_starter.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging -import traceback -from localstack.constants import DEFAULT_PORT_S3_BACKEND -from localstack.utils.aws import aws_stack -from localstack.utils.common import wait_for_port_open - -LOGGER = logging.getLogger(__name__) - - -def check_s3(expect_shutdown=False, print_error=False): - out = None - try: - # wait for port to be opened - wait_for_port_open(DEFAULT_PORT_S3_BACKEND) - # check S3 - out = aws_stack.connect_to_service(service_name='s3').list_buckets() - except Exception as e: - if print_error: - LOGGER.error('S3 health check failed: %s %s' % (e, traceback.format_exc())) - if expect_shutdown: - assert out is None - else: - assert isinstance(out['Buckets'], list) diff --git a/localstack/services/sns/sns_listener.py b/localstack/services/sns/sns_listener.py deleted file mode 100644 index a7ba56fc85fab..0000000000000 --- a/localstack/services/sns/sns_listener.py +++ /dev/null @@ -1,217 +0,0 @@ -import json -import logging -import requests -import uuid -import xmltodict -from requests.models import Response -from six.moves.urllib import parse as urlparse -from localstack.utils.aws import aws_stack -from localstack.utils.common import short_uid, to_str -from localstack.services.awslambda import lambda_api -from localstack.services.generic_proxy import ProxyListener - -# mappings for SNS topic subscriptions -SNS_SUBSCRIPTIONS = {} - -# set up logger -LOGGER = logging.getLogger(__name__) - - -class ProxyListenerSNS(ProxyListener): - - def forward_request(self, method, path, data, headers): - - if method == 'POST' and path == '/': - req_data = urlparse.parse_qs(to_str(data)) - req_action = req_data['Action'][0] - topic_arn = req_data.get('TargetArn') or req_data.get('TopicArn') - - if topic_arn: - topic_arn = topic_arn[0] - do_create_topic(topic_arn) - - if req_action == 'SetSubscriptionAttributes': - sub = get_subscription_by_arn(req_data['SubscriptionArn'][0]) - if not sub: - return make_error(message='Unable to find subscription for given ARN', code=400) - attr_name = req_data['AttributeName'][0] - attr_value = req_data['AttributeValue'][0] - sub[attr_name] = attr_value - return make_response(req_action) - elif req_action == 'GetSubscriptionAttributes': - sub = get_subscription_by_arn(req_data['SubscriptionArn'][0]) - if not sub: - return make_error(message='Unable to find subscription for given ARN', code=400) - content = '' - for key, value in sub.items(): - content += '%s%s\n' % (key, value) - content += '' - return make_response(req_action, content=content) - elif req_action == 'Subscribe': - if 'Endpoint' not in req_data: - return make_error(message='Endpoint not specified in subscription', code=400) - elif req_action == 'Unsubscribe': - if 'SubscriptionArn' not in req_data: - return make_error(message='SubscriptionArn not specified in unsubscribe request', code=400) - do_unsubscribe(req_data.get('SubscriptionArn')[0]) - - elif req_action == 'Publish': - message = req_data['Message'][0] - sqs_client = aws_stack.connect_to_service('sqs') - for subscriber in SNS_SUBSCRIPTIONS[topic_arn]: - if subscriber['Protocol'] == 'sqs': - queue_name = subscriber['Endpoint'].split(':')[5] - queue_url = subscriber.get('sqs_queue_url') - if not queue_url: - queue_url = aws_stack.get_sqs_queue_url(queue_name) - subscriber['sqs_queue_url'] = queue_url - sqs_client.send_message(QueueUrl=queue_url, - MessageBody=create_sns_message_body(subscriber, req_data)) - elif subscriber['Protocol'] == 'lambda': - lambda_api.process_sns_notification( - subscriber['Endpoint'], - topic_arn, message, subject=req_data.get('Subject') - ) - elif subscriber['Protocol'] in ['http', 'https']: - requests.post( - subscriber['Endpoint'], - headers={ - 'Content-Type': 'text/plain', - 'x-amz-sns-message-type': 'Notification' - }, - data=create_sns_message_body(subscriber, req_data)) - else: - LOGGER.warning('Unexpected protocol "%s" for SNS subscription' % subscriber['Protocol']) - # return response here because we do not want the request to be forwarded to SNS - return make_response(req_action) - - return True - - def return_response(self, method, path, data, headers, response): - # This method is executed by the proxy after we've already received a - # response from the backend, hence we can utilize the "response" variable here - if method == 'POST' and path == '/': - req_data = urlparse.parse_qs(to_str(data)) - req_action = req_data['Action'][0] - if req_action == 'Subscribe' and response.status_code < 400: - response_data = xmltodict.parse(response.content) - topic_arn = (req_data.get('TargetArn') or req_data.get('TopicArn'))[0] - sub_arn = response_data['SubscribeResponse']['SubscribeResult']['SubscriptionArn'] - do_subscribe(topic_arn, req_data['Endpoint'][0], req_data['Protocol'][0], sub_arn) - - -# instantiate listener -UPDATE_SNS = ProxyListenerSNS() - - -def do_create_topic(topic_arn): - if topic_arn not in SNS_SUBSCRIPTIONS: - SNS_SUBSCRIPTIONS[topic_arn] = [] - - -def do_subscribe(topic_arn, endpoint, protocol, subscription_arn): - subscription = { - # http://docs.aws.amazon.com/cli/latest/reference/sns/get-subscription-attributes.html - 'TopicArn': topic_arn, - 'Endpoint': endpoint, - 'Protocol': protocol, - 'SubscriptionArn': subscription_arn, - 'RawMessageDelivery': 'false' - } - SNS_SUBSCRIPTIONS[topic_arn].append(subscription) - - -def do_unsubscribe(subscription_arn): - for topic_arn in SNS_SUBSCRIPTIONS: - SNS_SUBSCRIPTIONS[topic_arn] = [ - sub for sub in SNS_SUBSCRIPTIONS[topic_arn] - if sub['SubscriptionArn'] != subscription_arn - ] - - -# --------------- -# HELPER METHODS -# --------------- - -def get_topic_by_arn(topic_arn): - if topic_arn in SNS_SUBSCRIPTIONS: - return SNS_SUBSCRIPTIONS[topic_arn] - else: - return None - - -def get_subscription_by_arn(sub_arn): - # TODO maintain separate map instead of traversing all items - for key, subscriptions in SNS_SUBSCRIPTIONS.items(): - for sub in subscriptions: - if sub['SubscriptionArn'] == sub_arn: - return sub - - -def make_response(op_name, content=''): - response = Response() - if not content: - content = '%s' % short_uid() - response._content = """<{op_name}Response xmlns="http://sns.amazonaws.com/doc/2010-03-31/"> - <{op_name}Result> - {content} - - {req_id} - """.format(op_name=op_name, content=content, req_id=short_uid()) - response.status_code = 200 - return response - - -def make_error(message, code=400, code_string='InvalidParameter'): - response = Response() - response._content = """ - Sender - {code_string} - {message} - {req_id} - """.format(message=message, code_string=code_string, req_id=short_uid()) - response.status_code = code - return response - - -def create_sns_message_body(subscriber, req_data): - message = req_data['Message'][0] - subject = req_data.get('Subject', [None])[0] - - if subscriber['RawMessageDelivery'] == 'true': - return message - - data = {} - data['MessageId'] = str(uuid.uuid4()) - data['Type'] = 'Notification' - data['Message'] = message - data['TopicArn'] = subscriber['TopicArn'] - if subject is not None: - data['Subject'] = subject - attributes = get_message_attributes(req_data) - if attributes: - data['MessageAttributes'] = attributes - return json.dumps(data) - - -def get_message_attributes(req_data): - attributes = {} - x = 1 - while True: - name = req_data.get('MessageAttributes.entry.' + str(x) + '.Name', [None])[0] - if name is not None: - attribute = {} - attribute['Type'] = req_data.get('MessageAttributes.entry.' + str(x) + '.Value.DataType', [None])[0] - string_value = req_data.get('MessageAttributes.entry.' + str(x) + '.Value.StringValue', [None])[0] - binary_value = req_data.get('MessageAttributes.entry.' + str(x) + '.Value.BinaryValue', [None])[0] - if string_value is not None: - attribute['Value'] = string_value - elif binary_value is not None: - attribute['Value'] = binary_value - - attributes[name] = attribute - x += 1 - else: - break - - return attributes diff --git a/localstack/services/sqs/sqs_listener.py b/localstack/services/sqs/sqs_listener.py deleted file mode 100644 index 6757f8b978d1c..0000000000000 --- a/localstack/services/sqs/sqs_listener.py +++ /dev/null @@ -1,81 +0,0 @@ -import re -import xmltodict -from six.moves.urllib import parse as urlparse -from six.moves.urllib.parse import urlencode -from requests.models import Request, Response -from localstack import config -from localstack.config import HOSTNAME_EXTERNAL -from localstack.utils.common import to_str -from localstack.utils.analytics import event_publisher -from localstack.services.generic_proxy import ProxyListener - - -class ProxyListenerSQS(ProxyListener): - - def forward_request(self, method, path, data, headers): - - if method == 'POST' and path == '/': - req_data = urlparse.parse_qs(to_str(data)) - if 'QueueName' in req_data: - if '.' in req_data['QueueName'][0]: - # ElasticMQ currently does not support "." in the queue name, e.g., for *.fifo queues - # TODO: remove this once *.fifo queues are supported in ElasticMQ - req_data['QueueName'][0] = req_data['QueueName'][0].replace('.', '_') - modified_data = urlencode(req_data, doseq=True) - request = Request(data=modified_data, headers=headers, method=method) - return request - - return True - - def return_response(self, method, path, data, headers, response, request_handler): - - if method == 'POST' and path == '/': - req_data = urlparse.parse_qs(to_str(data)) - action = req_data.get('Action', [None])[0] - event_type = None - queue_url = None - if action == 'CreateQueue': - event_type = event_publisher.EVENT_SQS_CREATE_QUEUE - response_data = xmltodict.parse(response.content) - if 'CreateQueueResponse' in response_data: - queue_url = response_data['CreateQueueResponse']['CreateQueueResult']['QueueUrl'] - elif action == 'DeleteQueue': - event_type = event_publisher.EVENT_SQS_DELETE_QUEUE - queue_url = req_data.get('QueueUrl', [None])[0] - - if event_type and queue_url: - event_publisher.fire_event(event_type, payload={'u': event_publisher.get_hash(queue_url)}) - - # patch the response and return the correct endpoint URLs - if action in ('CreateQueue', 'GetQueueUrl', 'ListQueues'): - content_str = content_str_original = to_str(response.content) - new_response = Response() - new_response.status_code = response.status_code - new_response.headers = response.headers - if config.USE_SSL and 'http://' in content_str: - # return https://... if we're supposed to use SSL - content_str = re.sub(r'\s*http://', r'https://', content_str) - # expose external hostname:port - external_port = get_external_port(headers, request_handler) - content_str = re.sub(r'\s*([a-z]+)://[^<]*:([0-9]+)/([^<]*)\s*', - r'\1://%s:%s/\3' % (HOSTNAME_EXTERNAL, external_port), content_str) - new_response._content = content_str - if content_str_original != new_response._content: - # if changes have been made, return patched response - new_response.headers['content-length'] = len(new_response._content) - return new_response - - -# extract the external port used by the client to make the request -def get_external_port(headers, request_handler): - host = headers.get('Host', '') - if ':' in host: - return int(host.split(':')[1]) - # If we cannot find the Host header, then fall back to the port of the proxy. - # (note that this could be incorrect, e.g., if running in Docker with a host port that - # is different from the internal container port, but there is not much else we can do.) - return request_handler.proxy.port - - -# instantiate listener -UPDATE_SQS = ProxyListenerSQS() diff --git a/localstack/services/sqs/sqs_starter.py b/localstack/services/sqs/sqs_starter.py deleted file mode 100644 index 834ae8bfcccb9..0000000000000 --- a/localstack/services/sqs/sqs_starter.py +++ /dev/null @@ -1,38 +0,0 @@ -import os -import logging -from localstack.constants import DEFAULT_PORT_SQS_BACKEND -from localstack.config import PORT_SQS, LOCALSTACK_HOSTNAME, TMP_FOLDER -from localstack.utils.common import save_file, short_uid, TMP_FILES -from localstack.services.infra import start_proxy_for_service, get_service_protocol, do_run -from localstack.services.install import INSTALL_DIR_ELASTICMQ, install_elasticmq - -LOGGER = logging.getLogger(__name__) - - -def start_sqs(port=PORT_SQS, async=False, update_listener=None): - install_elasticmq() - backend_port = DEFAULT_PORT_SQS_BACKEND - # create config file - config = """ - include classpath("application.conf") - node-address { - protocol = http - host = "%s" - port = %s - context-path = "" - } - rest-sqs { - enabled = true - bind-port = %s - bind-hostname = "0.0.0.0" - sqs-limits = strict - } - """ % (LOCALSTACK_HOSTNAME, port, backend_port) - config_file = os.path.join(TMP_FOLDER, 'sqs.%s.conf' % short_uid()) - TMP_FILES.append(config_file) - save_file(config_file, config) - # start process - cmd = ('java -Dconfig.file=%s -jar %s/elasticmq-server.jar' % (config_file, INSTALL_DIR_ELASTICMQ)) - print('Starting mock SQS (%s port %s)...' % (get_service_protocol(), port)) - start_proxy_for_service('sqs', port, backend_port, update_listener) - return do_run(cmd, async) diff --git a/localstack/utils/analytics/event_publisher.py b/localstack/utils/analytics/event_publisher.py deleted file mode 100644 index 5d4f8e2807f8a..0000000000000 --- a/localstack/utils/analytics/event_publisher.py +++ /dev/null @@ -1,153 +0,0 @@ -import os -import json -import time -from six.moves import queue -from localstack.config import TMP_FOLDER, CONFIG_FILE_PATH -from localstack.constants import API_ENDPOINT, ENV_INTERNAL_TEST_RUN -from localstack.utils.common import (JsonObject, to_str, - timestamp, short_uid, save_file, FuncThread, load_file) -from localstack.utils.common import safe_requests as requests - -PROCESS_ID = short_uid() -MACHINE_ID = None - -# event type constants -EVENT_START_INFRA = 'inf.up' -EVENT_STOP_INFRA = 'inf.dn' -EVENT_KINESIS_CREATE_STREAM = 'kns.cs' -EVENT_KINESIS_DELETE_STREAM = 'kns.ds' -EVENT_LAMBDA_CREATE_FUNC = 'lmb.cf' -EVENT_LAMBDA_DELETE_FUNC = 'lmb.df' -EVENT_SQS_CREATE_QUEUE = 'sqs.cq' -EVENT_SQS_DELETE_QUEUE = 'sqs.dq' -EVENT_S3_CREATE_BUCKET = 's3.cb' -EVENT_S3_DELETE_BUCKET = 's3.db' -EVENT_DYNAMODB_CREATE_TABLE = 'ddb.ct' -EVENT_DYNAMODB_DELETE_TABLE = 'ddb.dt' - -# sender thread and queue -SENDER_THREAD = None -EVENT_QUEUE = queue.Queue() - - -class AnalyticsEvent(JsonObject): - - def __init__(self, **kwargs): - self.t = kwargs.get('timestamp') or kwargs.get('t') or timestamp() - self.m_id = kwargs.get('machine_id') or kwargs.get('m_id') or get_machine_id() - self.p_id = kwargs.get('process_id') or kwargs.get('p_id') or get_process_id() - self.e_t = kwargs.get('event_type') or kwargs.get('e_t') - self.p = kwargs.get('payload') if kwargs.get('payload') is not None else kwargs.get('p') - - def timestamp(self): - return self.t - - def machine_id(self): - return self.m_id - - def process_id(self): - return self.p_id - - def event_type(self): - return self.e_t - - def payload(self): - return self.p - - -def get_or_create_file(config_file): - if os.path.exists(config_file): - return config_file - try: - save_file(config_file, '{}') - return config_file - except Exception: - pass - - -def get_config_file_homedir(): - return get_or_create_file(CONFIG_FILE_PATH) - - -def get_config_file_tempdir(): - return get_or_create_file(os.path.join(TMP_FOLDER, '.localstack')) - - -def get_machine_id(): - global MACHINE_ID - if MACHINE_ID: - return MACHINE_ID - - # determine MACHINE_ID from config files - configs_map = {} - config_file_tmp = get_config_file_tempdir() - config_file_home = get_config_file_homedir() - for config_file in (config_file_home, config_file_tmp): - if config_file: - local_configs = load_file(config_file) - local_configs = json.loads(to_str(local_configs)) - configs_map[config_file] = local_configs - if 'machine_id' in local_configs: - MACHINE_ID = local_configs['machine_id'] - break - - # if we can neither find NOR create the config files, fall back to process id - if not configs_map: - return PROCESS_ID - - # assign default id if empty - if not MACHINE_ID: - MACHINE_ID = short_uid() - - # update MACHINE_ID in all config files - for config_file, configs in configs_map.items(): - configs['machine_id'] = MACHINE_ID - save_file(config_file, json.dumps(configs)) - - return MACHINE_ID - - -def get_process_id(): - return PROCESS_ID - - -def poll_and_send_messages(params): - while True: - try: - event = EVENT_QUEUE.get(block=True, timeout=None) - event = event.to_dict() - endpoint = '%s/events' % API_ENDPOINT - requests.post(endpoint, json=event) - except Exception: - # silently fail, make collection of usage data as non-intrusive as possible - time.sleep(1) - - -def is_travis(): - return os.environ.get('TRAVIS', '').lower() in ['true', '1'] - - -def get_hash(name): - if not name: - return '0' - max_hash = 10000000000 - hashed = hash(name) % max_hash - hashed = hex(hashed).replace('0x', '') - return hashed - - -def fire_event(event_type, payload=None): - global SENDER_THREAD - if not SENDER_THREAD: - SENDER_THREAD = FuncThread(poll_and_send_messages, {}) - SENDER_THREAD.start() - if payload is None: - payload = {} - if isinstance(payload, dict): - if is_travis(): - payload['travis'] = True - if os.environ.get(ENV_INTERNAL_TEST_RUN): - payload['int'] = True - - event = AnalyticsEvent(event_type=event_type, payload=payload) - EVENT_QUEUE.put_nowait(event) diff --git a/localstack/utils/aws/aws_models.py b/localstack/utils/aws/aws_models.py deleted file mode 100644 index 688d84a74f969..0000000000000 --- a/localstack/utils/aws/aws_models.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import print_function - -import time -import json -import six - -if six.PY3: - long = int - - -class Component(object): - def __init__(self, id, env=None): - self.id = id - self.env = env - self.created_at = None - - def name(self): - return self.id - - def __repr__(self): - return self.__str__() - - def __str__(self): - return '<%s:%s>' % (self.__class__.__name__, self.id) - - -class KinesisStream(Component): - def __init__(self, id, params=None, num_shards=1, connection=None): - super(KinesisStream, self).__init__(id) - params = params or {} - self.shards = [] - self.stream_name = params.get('name', self.name()) - self.num_shards = params.get('shards', num_shards) - self.conn = connection - self.stream_info = params - - def name(self): - return self.id.split(':stream/')[-1] - - def connect(self, connection): - self.conn = connection - - def describe(self): - r = self.conn.describe_stream(StreamName=self.stream_name) - return r.get('StreamDescription') - - def create(self, raise_on_error=False): - try: - self.conn.create_stream(StreamName=self.stream_name, ShardCount=self.num_shards) - except Exception as e: - # TODO catch stream already exists exception, otherwise rethrow - if raise_on_error: - raise e - - def get_status(self): - description = self.describe() - return description.get('StreamStatus') - - def put(self, data, key): - if not isinstance(data, str): - data = json.dumps(data) - return self.conn.put_record(StreamName=self.stream_name, Data=data, PartitionKey=key) - - def read(self, amount=-1, shard='shardId-000000000001'): - if not self.conn: - raise Exception('Please create the Kinesis connection first.') - s_iterator = self.conn.get_shard_iterator(self.stream_name, shard, 'TRIM_HORIZON') - record = self.conn.get_records(s_iterator['ShardIterator']) - while True: - try: - if record['NextShardIterator'] is None: - break - else: - next_entry = self.conn.get_records(record['NextShardIterator']) - if len(next_entry['Records']): - print(next_entry['Records'][0]['Data']) - record = next_entry - except Exception as e: - print('Error reading from Kinesis stream "%s": %s' (self.stream_name, e)) - - def wait_for(self): - GET_STATUS_SLEEP_SECS = 5 - GET_STATUS_RETRIES = 50 - for i in range(0, GET_STATUS_RETRIES): - try: - status = self.get_status() - if status == 'ACTIVE': - return - except Exception: - # swallowing this exception should be ok, as we are in a retry loop - pass - time.sleep(GET_STATUS_SLEEP_SECS) - raise Exception('Failed to get active status for stream "%s", giving up' % self.stream_name) - - def destroy(self): - self.conn.delete_stream(StreamName=self.stream_name) - time.sleep(2) - - -class KinesisShard(Component): - MAX_KEY = '340282366920938463463374607431768211455' - - def __init__(self, id): - super(KinesisShard, self).__init__(id) - self.stream = None - self.start_key = '0' - self.end_key = KinesisShard.MAX_KEY # 128 times '1' binary as decimal - self.child_shards = [] - - def print_tree(self, indent=''): - print('%s%s' % (indent, self)) - for c in self.child_shards: - c.print_tree(indent=indent + ' ') - - def length(self): - return long(self.end_key) - long(self.start_key) - - def percent(self): - return 100.0 * self.length() / float(KinesisShard.MAX_KEY) - - def __str__(self): - return ('Shard(%s, length=%s, percent=%s, start=%s, end=%s)' % - (self.id, self.length(), self.percent(), self.start_key, - self.end_key)) - - @staticmethod - def sort(shards): - def compare(x, y): - s1 = long(x.start_key) - s2 = long(y.start_key) - if s1 < s2: - return -1 - elif s1 > s2: - return 1 - else: - return 0 - return sorted(shards, cmp=compare) - - @staticmethod - def max(shards): - max_shard = None - max_length = long(0) - for s in shards: - if s.length() > max_length: - max_shard = s - max_length = s.length() - return max_shard - - -class FirehoseStream(KinesisStream): - def __init__(self, id): - super(FirehoseStream, self).__init__(id) - self.destinations = [] - - def name(self): - return self.id.split(':deliverystream/')[-1] - - -class LambdaFunction(Component): - def __init__(self, arn): - super(LambdaFunction, self).__init__(arn) - self.event_sources = [] - self.targets = [] - self.versions = {} - self.aliases = {} - self.envvars = {} - self.runtime = None - self.handler = None - self.cwd = None - - def get_version(self, version): - return self.versions.get(version) - - def name(self): - return self.id.split(':function:')[-1] - - def function(self, qualifier=None): - if not qualifier: - qualifier = '$LATEST' - version = qualifier if qualifier in self.versions else \ - self.aliases.get(qualifier).get('FunctionVersion') - return self.versions.get(version).get('Function') - - def qualifier_exists(self, qualifier): - return qualifier in self.aliases or qualifier in self.versions - - def __str__(self): - return '<%s:%s>' % (self.__class__.__name__, self.name()) - - -class DynamoDB(Component): - def __init__(self, id, env=None): - super(DynamoDB, self).__init__(id, env=env) - self.count = -1 - self.bytes = -1 - - def name(self): - return self.id.split(':table/')[-1] - - -class DynamoDBStream(Component): - def __init__(self, id): - super(DynamoDBStream, self).__init__(id) - self.table = None - - -class DynamoDBItem(Component): - def __init__(self, id, table=None, keys=None): - super(DynamoDBItem, self).__init__(id) - self.table = table - self.keys = keys - - def __eq__(self, other): - if not isinstance(other, DynamoDBItem): - return False - return (other.table == self.table and - other.id == self.id and - other.keys == self.keys) - - def __hash__(self): - return hash(self.table) + hash(self.id) + hash(self.keys) - - -class ElasticSearch(Component): - def __init__(self, id): - super(ElasticSearch, self).__init__(id) - self.indexes = [] - self.endpoint = None - - def name(self): - return self.id.split(':domain/')[-1] - - -class SqsQueue(Component): - def __init__(self, id): - super(SqsQueue, self).__init__(id) - - def name(self): - return self.id.split(':')[-1] - - -class S3Bucket(Component): - def __init__(self, id): - super(S3Bucket, self).__init__(id) - self.notifications = [] - - def name(self): - return self.id.split('arn:aws:s3:::')[-1] - - -class S3Notification(Component): - def __init__(self, id): - super(S3Notification, self).__init__(id) - self.target = None - self.trigger = None - - -class EventSource(Component): - def __init__(self, id): - super(EventSource, self).__init__(id) - - @staticmethod - def get(obj, pool=None, type=None): - pool = pool or {} - if not obj: - return None - if isinstance(obj, Component): - obj = obj.id - if obj in pool: - return pool[obj] - inst = None - if obj.startswith('arn:aws:kinesis:'): - inst = KinesisStream(obj) - if obj.startswith('arn:aws:lambda:'): - inst = LambdaFunction(obj) - elif obj.startswith('arn:aws:dynamodb:'): - if '/stream/' in obj: - table_id = obj.split('/stream/')[0] - table = DynamoDB(table_id) - inst = DynamoDBStream(obj) - inst.table = table - else: - inst = DynamoDB(obj) - elif type: - for o in EventSource.filter_type(pool, type): - if o.name() == obj: - return o - if type == ElasticSearch: - if o.endpoint == obj: - return o - else: - print("Unexpected object name: '%s'" % obj) - return inst - - @staticmethod - def filter_type(pool, type): - return [obj for obj in six.itervalues(pool) if isinstance(obj, type)] diff --git a/localstack/utils/aws/aws_responses.py b/localstack/utils/aws/aws_responses.py deleted file mode 100644 index f4456b5a28934..0000000000000 --- a/localstack/utils/aws/aws_responses.py +++ /dev/null @@ -1,14 +0,0 @@ -import json -from flask import Response - - -def flask_error_response(msg, code=500, error_type='InternalFailure'): - result = { - 'Type': 'User' if code < 500 else 'Server', - 'message': msg, - '__type': error_type - } - headers = {'x-amzn-errortype': error_type} - # Note: don't use flask's make_response(..) or jsonify(..) here as they - # can lead to "RuntimeError: working outside of application context". - return Response(json.dumps(result), status=code, headers=headers) diff --git a/localstack/utils/aws/aws_stack.py b/localstack/utils/aws/aws_stack.py deleted file mode 100644 index 8b306bcb5a945..0000000000000 --- a/localstack/utils/aws/aws_stack.py +++ /dev/null @@ -1,529 +0,0 @@ -import os -import re -import boto3 -import json -import base64 -import logging -from six import iteritems -from localstack import config -from localstack.constants import (REGION_LOCAL, DEFAULT_REGION, - ENV_DEV, APPLICATION_AMZ_JSON_1_1, APPLICATION_AMZ_JSON_1_0) -from localstack.utils.common import run_safe, to_str, is_string, make_http_request, timestamp -from localstack.utils.aws.aws_models import KinesisStream - -# AWS environment variable names -ENV_ACCESS_KEY = 'AWS_ACCESS_KEY_ID' -ENV_SECRET_KEY = 'AWS_SECRET_ACCESS_KEY' -ENV_SESSION_TOKEN = 'AWS_SESSION_TOKEN' - -# set up logger -LOGGER = logging.getLogger(__name__) - -# Use this field if you want to provide a custom boto3 session. -# This field takes priority over CREATE_NEW_SESSION_PER_BOTO3_CONNECTION -CUSTOM_BOTO3_SESSION = None -# Use this flag to enable creation of a new session for each boto3 connection. -# This flag will be ignored if CUSTOM_BOTO3_SESSION is specified -CREATE_NEW_SESSION_PER_BOTO3_CONNECTION = False - -# Used in AWS assume role function -INITIAL_BOTO3_SESSION = None - -# Assume role loop seconds -DEFAULT_TIMER_LOOP_SECONDS = 60 * 50 - - -class Environment(object): - def __init__(self, region=None, prefix=None): - # target is the runtime environment to use, e.g., - # 'local' for local mode - self.region = region or get_local_region() - # prefix can be 'prod', 'stg', 'uat-1', etc. - self.prefix = prefix - - def apply_json(self, j): - if isinstance(j, str): - j = json.loads(j) - self.__dict__.update(j) - - @staticmethod - def from_string(s): - parts = s.split(':') - if len(parts) == 1: - if s in PREDEFINED_ENVIRONMENTS: - return PREDEFINED_ENVIRONMENTS[s] - parts = [get_local_region(), s] - if len(parts) > 2: - raise Exception('Invalid environment string "%s"' % s) - region = parts[0] - prefix = parts[1] - return Environment(region=region, prefix=prefix) - - @staticmethod - def from_json(j): - if not isinstance(j, dict): - j = j.to_dict() - result = Environment() - result.apply_json(j) - return result - - def __str__(self): - return '%s:%s' % (self.region, self.prefix) - - -PREDEFINED_ENVIRONMENTS = { - ENV_DEV: Environment(region=REGION_LOCAL, prefix=ENV_DEV) -} - - -def get_environment(env=None, region_name=None): - """ - Return an Environment object based on the input arguments. - - Parameter `env` can be either of: - * None (or empty), in which case the rules below are applied to (env = os.environ['ENV'] or ENV_DEV) - * an Environment object (then this object is returned) - * a string ':', which corresponds to Environment(region='', prefix='') - * the predefined string 'dev' (ENV_DEV), which implies Environment(region='local', prefix='dev') - * a string '', which implies Environment(region=DEFAULT_REGION, prefix='') - - Additionally, parameter `region_name` can be used to override DEFAULT_REGION. - """ - if not env: - if 'ENV' in os.environ: - env = os.environ['ENV'] - else: - env = ENV_DEV - elif not is_string(env) and not isinstance(env, Environment): - raise Exception('Invalid environment: %s' % env) - - if is_string(env): - env = Environment.from_string(env) - if region_name: - env.region = region_name - if not env.region: - raise Exception('Invalid region in environment: "%s"' % env) - return env - - -def connect_to_resource(service_name, env=None, region_name=None, endpoint_url=None): - """ - Generic method to obtain an AWS service resource using boto3, based on environment, region, or custom endpoint_url. - """ - return connect_to_service(service_name, client=False, env=env, region_name=region_name, endpoint_url=endpoint_url) - - -def get_boto3_credentials(): - if CUSTOM_BOTO3_SESSION: - return CUSTOM_BOTO3_SESSION.get_credentials() - return boto3.session.Session().get_credentials() - - -def get_boto3_session(): - if CUSTOM_BOTO3_SESSION: - return CUSTOM_BOTO3_SESSION - if CREATE_NEW_SESSION_PER_BOTO3_CONNECTION: - return boto3.session.Session() - # return default session - return boto3 - - -def get_local_region(): - session = boto3.session.Session() - return session.region_name or DEFAULT_REGION - - -def get_local_service_url(service_name): - if service_name == 's3api': - service_name = 's3' - return os.environ['TEST_%s_URL' % (service_name.upper().replace('-', '_'))] - - -def connect_to_service(service_name, client=True, env=None, region_name=None, endpoint_url=None, config=None): - """ - Generic method to obtain an AWS service client using boto3, based on environment, region, or custom endpoint_url. - """ - env = get_environment(env, region_name=region_name) - my_session = get_boto3_session() - method = my_session.client if client else my_session.resource - verify = True - if not endpoint_url: - if env.region == REGION_LOCAL: - endpoint_url = get_local_service_url(service_name) - verify = False - region = env.region if env.region != REGION_LOCAL else get_local_region() - return method(service_name, region_name=region, endpoint_url=endpoint_url, verify=verify, config=config) - - -class VelocityInput: - """Simple class to mimick the behavior of variable '$input' in AWS API Gateway integration velocity templates. - See: http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html""" - def __init__(self, value): - self.value = value - - def path(self, path): - from jsonpath_rw import parse - value = self.value if isinstance(self.value, dict) else json.loads(self.value) - jsonpath_expr = parse(path) - result = [match.value for match in jsonpath_expr.find(value)] - result = result[0] if len(result) == 1 else result - return result - - def json(self, path): - return json.dumps(self.path(path)) - - -class VelocityUtil: - """Simple class to mimick the behavior of variable '$util' in AWS API Gateway integration velocity templates. - See: http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html""" - def base64Encode(self, s): - if not isinstance(s, str): - s = json.dumps(s) - encoded_str = s.encode(config.DEFAULT_ENCODING) - encoded_b64_str = base64.b64encode(encoded_str) - return encoded_b64_str.decode(config.DEFAULT_ENCODING) - - def base64Decode(self, s): - if not isinstance(s, str): - s = json.dumps(s) - return base64.b64decode(s) - - -def render_velocity_template(template, context, as_json=False): - import airspeed - t = airspeed.Template(template) - variables = { - 'input': VelocityInput(context), - 'util': VelocityUtil() - } - replaced = t.merge(variables) - if as_json: - replaced = json.loads(replaced) - return replaced - - -def get_s3_client(): - return boto3.resource('s3', - endpoint_url=config.TEST_S3_URL, - config=boto3.session.Config( - s3={'addressing_style': 'path'}), - verify=False) - - -def get_account_id(account_id=None, env=None): - if account_id: - return account_id - env = get_environment(env) - if env.region == REGION_LOCAL: - return os.environ['TEST_AWS_ACCOUNT_ID'] - raise Exception('Unable to determine AWS account ID') - - -def role_arn(role_name, account_id=None, env=None): - env = get_environment(env) - account_id = get_account_id(account_id, env=env) - return 'arn:aws:iam::%s:role/%s' % (account_id, role_name) - - -def iam_resource_arn(resource, role=None, env=None): - env = get_environment(env) - if not role: - role = get_iam_role(resource, env=env) - return role_arn(role_name=role, account_id=get_account_id()) - - -def get_iam_role(resource, env=None): - env = get_environment(env) - return 'role-%s' % resource - - -def dynamodb_table_arn(table_name, account_id=None): - account_id = get_account_id(account_id) - return 'arn:aws:dynamodb:%s:%s:table/%s' % (get_local_region(), account_id, table_name) - - -def dynamodb_stream_arn(table_name, account_id=None): - account_id = get_account_id(account_id) - return ('arn:aws:dynamodb:%s:%s:table/%s/stream/%s' % - (get_local_region(), account_id, table_name, timestamp())) - - -def lambda_function_arn(function_name, account_id=None): - pattern = 'arn:aws:lambda:.*:.*:function:.*' - if re.match(pattern, function_name): - return function_name - if ':' in function_name: - raise Exception('Lambda function name should not contain a colon ":"') - account_id = get_account_id(account_id) - return pattern.replace('.*', '%s') % (get_local_region(), account_id, function_name) - - -def cognito_user_pool_arn(user_pool_id, account_id=None): - account_id = get_account_id(account_id) - return 'arn:aws:cognito-idp:%s:%s:userpool/%s' % (get_local_region(), account_id, user_pool_id) - - -def kinesis_stream_arn(stream_name, account_id=None): - account_id = get_account_id(account_id) - return 'arn:aws:kinesis:%s:%s:stream/%s' % (get_local_region(), account_id, stream_name) - - -def firehose_stream_arn(stream_name, account_id=None): - account_id = get_account_id(account_id) - return ('arn:aws:firehose:%s:%s:deliverystream/%s' % (get_local_region(), account_id, stream_name)) - - -def s3_bucket_arn(bucket_name, account_id=None): - return 'arn:aws:s3:::%s' % (bucket_name) - - -def sqs_queue_arn(queue_name, account_id=None): - account_id = get_account_id(account_id) - return ('arn:aws:sqs:%s:%s:%s' % (get_local_region(), account_id, queue_name)) - - -def sns_topic_arn(topic_name, account_id=None): - account_id = get_account_id(account_id) - return ('arn:aws:sns:%s:%s:%s' % (get_local_region(), account_id, topic_name)) - - -def get_sqs_queue_url(queue_name): - client = connect_to_service('sqs') - response = client.get_queue_url(QueueName=queue_name) - return response['QueueUrl'] - - -def dynamodb_get_item_raw(request): - headers = mock_aws_request_headers() - headers['X-Amz-Target'] = 'DynamoDB_20120810.GetItem' - new_item = make_http_request(url=config.TEST_DYNAMODB_URL, - method='POST', data=json.dumps(request), headers=headers) - new_item = json.loads(new_item.text) - return new_item - - -def mock_aws_request_headers(service='dynamodb'): - ctype = APPLICATION_AMZ_JSON_1_0 - if service == 'kinesis': - ctype = APPLICATION_AMZ_JSON_1_1 - access_key = get_boto3_credentials().access_key - headers = { - 'Content-Type': ctype, - 'Accept-Encoding': 'identity', - 'X-Amz-Date': '20160623T103251Z', - 'Authorization': ('AWS4-HMAC-SHA256 ' + - 'Credential=%s/20160623/us-east-1/%s/aws4_request, ' + - 'SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234') % (access_key, service) - } - return headers - - -def get_apigateway_integration(api_id, method, path, env=None): - apigateway = connect_to_service(service_name='apigateway', client=True, env=env) - - resources = apigateway.get_resources(restApiId=api_id, limit=100) - resource_id = None - for r in resources['items']: - if r['path'] == path: - resource_id = r['id'] - if not resource_id: - raise Exception('Unable to find apigateway integration for path "%s"' % path) - - integration = apigateway.get_integration( - restApiId=api_id, resourceId=resource_id, httpMethod=method - ) - return integration - - -def get_apigateway_resource_for_path(api_id, path, parent=None, resources=None): - if resources is None: - apigateway = connect_to_service(service_name='apigateway') - resources = apigateway.get_resources(restApiId=api_id, limit=100) - if not isinstance(path, list): - path = path.split('/') - if not path: - return parent - for resource in resources: - if resource['pathPart'] == path[0] and (not parent or parent['id'] == resource['parentId']): - return get_apigateway_resource_for_path(api_id, path[1:], parent=resource, resources=resources) - return None - - -def get_apigateway_path_for_resource(api_id, resource_id, path_suffix='', resources=None): - if resources is None: - apigateway = connect_to_service(service_name='apigateway') - resources = apigateway.get_resources(restApiId=api_id, limit=100)['items'] - target_resource = list(filter(lambda res: res['id'] == resource_id, resources))[0] - path_part = target_resource.get('pathPart', '') - if path_suffix: - if path_part: - path_suffix = '%s/%s' % (path_part, path_suffix) - else: - path_suffix = path_part - parent_id = target_resource.get('parentId') - if not parent_id: - return '/%s' % path_suffix - return get_apigateway_path_for_resource(api_id, parent_id, path_suffix=path_suffix, resources=resources) - - -def create_api_gateway(name, description=None, resources=None, stage_name=None, - enabled_api_keys=[], env=None, usage_plan_name=None): - client = connect_to_service('apigateway', env=env) - if not resources: - resources = [] - if not stage_name: - stage_name = 'testing' - if not usage_plan_name: - usage_plan_name = 'Basic Usage' - if not description: - description = 'Test description for API "%s"' % name - - LOGGER.info('Creating API resources under API Gateway "%s".' % name) - api = client.create_rest_api(name=name, description=description) - # list resources - api_id = api['id'] - resources_list = client.get_resources(restApiId=api_id) - root_res_id = resources_list['items'][0]['id'] - # add API resources and methods - for path, methods in iteritems(resources): - # create resources recursively - parent_id = root_res_id - for path_part in path.split('/'): - api_resource = client.create_resource(restApiId=api_id, parentId=parent_id, pathPart=path_part) - parent_id = api_resource['id'] - # add methods to the API resource - for method in methods: - client.put_method( - restApiId=api_id, - resourceId=api_resource['id'], - httpMethod=method['httpMethod'], - authorizationType=method.get('authorizationType') or 'NONE', - apiKeyRequired=method.get('apiKeyRequired') or False - ) - # create integrations for this API resource/method - integrations = method['integrations'] - create_api_gateway_integrations(api_id, api_resource['id'], method, integrations, env=env) - # deploy the API gateway - client.create_deployment(restApiId=api_id, stageName=stage_name) - return api - - -def create_api_gateway_integrations(api_id, resource_id, method, integrations=[], env=None): - client = connect_to_service('apigateway', env=env) - for integration in integrations: - req_templates = integration.get('requestTemplates') or {} - res_templates = integration.get('responseTemplates') or {} - success_code = integration.get('successCode') or '200' - client_error_code = integration.get('clientErrorCode') or '400' - server_error_code = integration.get('serverErrorCode') or '500' - # create integration - client.put_integration( - restApiId=api_id, - resourceId=resource_id, - httpMethod=method['httpMethod'], - integrationHttpMethod=method.get('integrationHttpMethod') or method['httpMethod'], - type=integration['type'], - uri=integration['uri'], - requestTemplates=req_templates - ) - response_configs = [ - {'pattern': '^2.*', 'code': success_code, 'res_templates': res_templates}, - {'pattern': '^4.*', 'code': client_error_code, 'res_templates': {}}, - {'pattern': '^5.*', 'code': server_error_code, 'res_templates': {}} - ] - # create response configs - for response_config in response_configs: - # create integration response - client.put_integration_response( - restApiId=api_id, - resourceId=resource_id, - httpMethod=method['httpMethod'], - statusCode=response_config['code'], - responseTemplates=response_config['res_templates'], - selectionPattern=response_config['pattern'] - ) - # create method response - client.put_method_response( - restApiId=api_id, - resourceId=resource_id, - httpMethod=method['httpMethod'], - statusCode=response_config['code'] - ) - - -def get_elasticsearch_endpoint(domain=None, region_name=None): - env = get_environment(region_name=region_name) - if env.region == REGION_LOCAL: - return os.environ['TEST_ELASTICSEARCH_URL'] - # get endpoint from API - es_client = connect_to_service(service_name='es', region_name=env.region) - info = es_client.describe_elasticsearch_domain(DomainName=domain) - endpoint = 'https://%s' % info['DomainStatus']['Endpoint'] - return endpoint - - -def connect_elasticsearch(endpoint=None, domain=None, region_name=None, env=None): - from elasticsearch import Elasticsearch, RequestsHttpConnection - from requests_aws4auth import AWS4Auth - - env = get_environment(env, region_name=region_name) - verify_certs = False - use_ssl = False - if not endpoint and env.region == REGION_LOCAL: - endpoint = os.environ['TEST_ELASTICSEARCH_URL'] - if not endpoint and env.region != REGION_LOCAL and domain: - endpoint = get_elasticsearch_endpoint(domain=domain, region_name=env.region) - # use ssl? - if 'https://' in endpoint: - use_ssl = True - if env.region != REGION_LOCAL: - verify_certs = True - - if CUSTOM_BOTO3_SESSION or (ENV_ACCESS_KEY in os.environ and ENV_SECRET_KEY in os.environ): - access_key = os.environ.get(ENV_ACCESS_KEY) - secret_key = os.environ.get(ENV_SECRET_KEY) - session_token = os.environ.get(ENV_SESSION_TOKEN) - if CUSTOM_BOTO3_SESSION: - credentials = CUSTOM_BOTO3_SESSION.get_credentials() - access_key = credentials.access_key - secret_key = credentials.secret_key - session_token = credentials.token - awsauth = AWS4Auth(access_key, secret_key, env.region, 'es', session_token=session_token) - connection_class = RequestsHttpConnection - return Elasticsearch(hosts=[endpoint], verify_certs=verify_certs, use_ssl=use_ssl, - connection_class=connection_class, http_auth=awsauth) - return Elasticsearch(hosts=[endpoint], verify_certs=verify_certs, use_ssl=use_ssl) - - -def create_kinesis_stream(stream_name, shards=1, env=None, delete=False): - env = get_environment(env) - # stream - stream = KinesisStream(id=stream_name, num_shards=shards) - conn = connect_to_service('kinesis', env=env) - stream.connect(conn) - if delete: - run_safe(lambda: stream.destroy(), print_error=False) - stream.create() - stream.wait_for() - return stream - - -def kinesis_get_latest_records(stream_name, shard_id, count=10, env=None): - kinesis = connect_to_service('kinesis', env=env) - result = [] - response = kinesis.get_shard_iterator(StreamName=stream_name, ShardId=shard_id, - ShardIteratorType='TRIM_HORIZON') - shard_iterator = response['ShardIterator'] - while shard_iterator: - records_response = kinesis.get_records(ShardIterator=shard_iterator) - records = records_response['Records'] - for record in records: - try: - record['Data'] = to_str(record['Data']) - except Exception: - pass - result.extend(records) - shard_iterator = records_response['NextShardIterator'] if records else False - while len(result) > count: - result.pop(0) - return result diff --git a/localstack/utils/cloudformation/template_deployer.py b/localstack/utils/cloudformation/template_deployer.py deleted file mode 100644 index f018d02d781b6..0000000000000 --- a/localstack/utils/cloudformation/template_deployer.py +++ /dev/null @@ -1,547 +0,0 @@ -import re -import json -import yaml -import logging -import traceback -from six import iteritems -from six import string_types -from localstack.utils import common -from localstack.utils.aws import aws_stack -from localstack.constants import DEFAULT_REGION - -ACTION_CREATE = 'create' -PLACEHOLDER_RESOURCE_NAME = '__resource_name__' - -# flag to indicate whether we are currently in the process of deployment -MARKER_DONT_REDEPLOY_STACK = 'markerToIndicateNotToRedeployStack' - -LOGGER = logging.getLogger(__name__) - -RESOURCE_TO_FUNCTION = { - 'S3::Bucket': { - 'create': { - 'boto_client': 'resource', - 'function': 'create_bucket', - 'parameters': { - 'Bucket': ['BucketName', PLACEHOLDER_RESOURCE_NAME], - 'ACL': lambda params: convert_acl_cf_to_s3(params.get('AccessControl', 'PublicRead')) - } - } - }, - 'SQS::Queue': { - 'create': { - 'boto_client': 'resource', - 'function': 'create_queue', - 'parameters': { - 'QueueName': 'QueueName' - } - } - }, - 'Logs::LogGroup': { - # TODO implement - }, - 'Lambda::Function': { - 'create': { - 'boto_client': 'client', - 'function': 'create_function', - 'parameters': { - 'FunctionName': 'FunctionName', - 'Runtime': 'Runtime', - 'Role': 'Role', - 'Handler': 'Handler', - 'Code': 'Code', - 'Description': 'Description' - # TODO add missing fields - }, - 'defaults': { - 'Role': 'test_role' - } - } - }, - 'Lambda::Version': {}, - 'Lambda::Permission': {}, - 'Lambda::EventSourceMapping': { - 'create': { - 'boto_client': 'client', - 'function': 'create_event_source_mapping', - 'parameters': { - 'FunctionName': 'FunctionName', - 'EventSourceArn': 'EventSourceArn', - 'StartingPosition': 'StartingPosition', - 'Enabled': 'Enabled', - 'BatchSize': 'BatchSize', - 'StartingPositionTimestamp': 'StartingPositionTimestamp' - } - } - }, - 'DynamoDB::Table': { - 'create': { - 'boto_client': 'client', - 'function': 'create_table', - 'parameters': { - 'TableName': 'TableName', - 'AttributeDefinitions': 'AttributeDefinitions', - 'KeySchema': 'KeySchema', - 'ProvisionedThroughput': 'ProvisionedThroughput', - 'LocalSecondaryIndexes': 'LocalSecondaryIndexes', - 'GlobalSecondaryIndexes': 'GlobalSecondaryIndexes', - 'StreamSpecification': 'StreamSpecification' - }, - 'defaults': { - 'ProvisionedThroughput': { - 'ReadCapacityUnits': 5, - 'WriteCapacityUnits': 5 - } - } - } - }, - 'IAM::Role': { - # TODO implement - }, - 'ApiGateway::RestApi': { - 'create': { - 'boto_client': 'client', - 'function': 'create_rest_api', - 'parameters': { - 'name': 'Name', - 'description': 'Description' - } - } - }, - 'ApiGateway::Resource': { - 'create': { - 'boto_client': 'client', - 'function': 'create_resource', - 'parameters': { - 'restApiId': 'RestApiId', - 'pathPart': 'PathPart', - 'parentId': 'ParentId' - } - } - }, - 'ApiGateway::Method': { - 'create': { - 'boto_client': 'client', - 'function': 'put_method', - 'parameters': { - 'restApiId': 'RestApiId', - 'resourceId': 'ResourceId', - 'httpMethod': 'HttpMethod', - 'authorizationType': 'AuthorizationType', - 'requestParameters': 'RequestParameters' - } - } - }, - 'ApiGateway::Method::Integration': { - }, - 'ApiGateway::Deployment': { - 'create': { - 'boto_client': 'client', - 'function': 'create_deployment', - 'parameters': { - 'restApiId': 'RestApiId', - 'stageName': 'StageName', - 'stageDescription': 'StageDescription', - 'description': 'Description' - } - } - }, - 'Kinesis::Stream': { - 'create': { - 'boto_client': 'client', - 'function': 'create_stream', - 'parameters': { - 'StreamName': 'Name', - 'ShardCount': 'ShardCount' - }, - 'defaults': { - 'ShardCount': 1 - } - } - } -} - - -# ---------------- -# UTILITY METHODS -# ---------------- - -def convert_acl_cf_to_s3(acl): - """ Convert a CloudFormation ACL string (e.g., 'PublicRead') to an S3 ACL string (e.g., 'public-read') """ - return re.sub('(?= 400: - return publish_lambda_error(time_before, kwargs) - publish_lambda_metric('Invocations', 1, kwargs) - - -# --------------- -# Helper methods -# --------------- - - -# TODO: this is a backdoor based hack until get_metric_statistics becomes available in moto -def get_metric_statistics(Namespace, MetricName, Dimensions, - Period=60, StartTime=None, EndTime=None, Statistics=None): - if not StartTime: - StartTime = datetime.now() - timedelta(minutes=5) - if not EndTime: - EndTime = datetime.now() - if Statistics is None: - Statistics = ['Sum'] - cloudwatch_url = aws_stack.get_local_service_url('cloudwatch') - url = '%s/?Action=GetMetricValues' % cloudwatch_url - all_metrics = make_http_request(url) - assert all_metrics.status_code == 200 - datapoints = [] - for datapoint in json.loads(to_str(all_metrics.content)): - if datapoint['Namespace'] == Namespace and datapoint['Name'] == MetricName: - dp_dimensions = datapoint['Dimensions'] - all_present = all(m in dp_dimensions for m in Dimensions) - no_additional = all(m in Dimensions for m in dp_dimensions) - if all_present and no_additional: - datapoints.append(datapoint) - result = { - 'Label': '%s/%s' % (Namespace, MetricName), - 'Datapoints': datapoints - } - return result - - -def publish_result(ns, time_before, result, kwargs): - if ns == 'lambda': - publish_lambda_result(time_before, result, kwargs) - - -def publish_error(ns, time_before, e, kwargs): - if ns == 'lambda': - publish_lambda_error(time_before, kwargs) - - -def cloudwatched(ns): - """ @cloudwatched(...) decorator for annotating methods to be monitored via CloudWatch """ - def wrapping(func): - def wrapped(*args, **kwargs): - time_before = now_utc() - try: - result = func(*args, **kwargs) - publish_result(ns, time_before, result, kwargs) - except Exception as e: - publish_error(ns, time_before, e, kwargs) - raise e - finally: - # TODO - # time_after = now_utc() - pass - return result - return wrapped - return wrapping diff --git a/localstack/utils/common.py b/localstack/utils/common.py deleted file mode 100644 index b724b4c6abf6b..0000000000000 --- a/localstack/utils/common.py +++ /dev/null @@ -1,715 +0,0 @@ -from __future__ import print_function - -import threading -import traceback -import os -import sys -import hashlib -import uuid -import time -import glob -import base64 -import subprocess -import six -import shutil -import socket -import json -import binascii -import decimal -import logging -import tempfile -import requests -import zipfile -from io import BytesIO -from contextlib import closing -from datetime import datetime -from six.moves.urllib.parse import urlparse -from six.moves import cStringIO as StringIO -from six import with_metaclass -from multiprocessing.dummy import Pool -from localstack.constants import ENV_DEV -from localstack.config import DEFAULT_ENCODING -from localstack import config - -# arrays for temporary files and resources -TMP_FILES = [] -TMP_THREADS = [] - -# cache clean variables -CACHE_CLEAN_TIMEOUT = 60 * 5 -CACHE_MAX_AGE = 60 * 60 -CACHE_FILE_PATTERN = os.path.join(tempfile.gettempdir(), 'cache.*.json') -last_cache_clean_time = {'time': 0} -mutex_clean = threading.Semaphore(1) -mutex_popen = threading.Semaphore(1) - -# misc. constants -TIMESTAMP_FORMAT = '%Y-%m-%dT%H:%M:%S' -TIMESTAMP_FORMAT_MILLIS = '%Y-%m-%dT%H:%M:%S.%fZ' -CODEC_HANDLER_UNDERSCORE = 'underscore' - -# chunk size for file downloads -DOWNLOAD_CHUNK_SIZE = 1024 * 1024 - -# set up logger -LOGGER = logging.getLogger(__name__) - - -# Helper class to convert JSON documents with datetime, decimals, or bytes. -class CustomEncoder(json.JSONEncoder): - def default(self, o): - if isinstance(o, decimal.Decimal): - if o % 1 > 0: - return float(o) - else: - return int(o) - if isinstance(o, datetime): - return str(o) - if isinstance(o, six.binary_type): - return to_str(o) - return super(CustomEncoder, self).default(o) - - -class FuncThread (threading.Thread): - def __init__(self, func, params, quiet=False): - threading.Thread.__init__(self) - self.daemon = True - self.params = params - self.func = func - self.quiet = quiet - - def run(self): - try: - self.func(self.params) - except Exception: - if not self.quiet: - LOGGER.warning('Thread run method %s(%s) failed: %s' % - (self.func, self.params, traceback.format_exc())) - - def stop(self, quiet=False): - if not quiet and not self.quiet: - LOGGER.warning('Not implemented: FuncThread.stop(..)') - - -class ShellCommandThread (FuncThread): - def __init__(self, cmd, params={}, outfile=None, env_vars={}, stdin=False, - quiet=True, inherit_cwd=False): - self.cmd = cmd - self.process = None - self.outfile = outfile or os.devnull - self.stdin = stdin - self.env_vars = env_vars - self.inherit_cwd = inherit_cwd - FuncThread.__init__(self, self.run_cmd, params, quiet=quiet) - - def run_cmd(self, params): - - def convert_line(line): - line = to_str(line) - return line.strip() + '\r\n' - - try: - self.process = run(self.cmd, async=True, stdin=self.stdin, outfile=self.outfile, - env_vars=self.env_vars, inherit_cwd=self.inherit_cwd) - if self.outfile: - if self.outfile == subprocess.PIPE: - # get stdout/stderr from child process and write to parent output - for line in iter(self.process.stdout.readline, ''): - if not (line and line.strip()) and self.is_killed(): - break - line = convert_line(line) - sys.stdout.write(line) - sys.stdout.flush() - for line in iter(self.process.stderr.readline, ''): - if not (line and line.strip()) and self.is_killed(): - break - line = convert_line(line) - sys.stderr.write(line) - sys.stderr.flush() - self.process.wait() - else: - self.process.communicate() - except Exception as e: - if self.process and not self.quiet: - LOGGER.warning('Shell command error "%s": %s' % (e, self.cmd)) - if self.process and not self.quiet and self.process.returncode != 0: - LOGGER.warning('Shell command exit code "%s": %s' % (self.process.returncode, self.cmd)) - - def is_killed(self): - if not self.process: - return True - # Note: Do NOT import "psutil" at the root scope, as this leads - # to problems when importing this file from our test Lambdas in Docker - # (Error: libc.musl-x86_64.so.1: cannot open shared object file) - import psutil - return not psutil.pid_exists(self.process.pid) - - def stop(self, quiet=False): - # Note: Do NOT import "psutil" at the root scope, as this leads - # to problems when importing this file from our test Lambdas in Docker - # (Error: libc.musl-x86_64.so.1: cannot open shared object file) - import psutil - - if not self.process: - LOGGER.warning("No process found for command '%s'" % self.cmd) - return - - parent_pid = self.process.pid - try: - parent = psutil.Process(parent_pid) - for child in parent.children(recursive=True): - child.kill() - parent.kill() - self.process = None - except Exception: - if not quiet: - LOGGER.warning('Unable to kill process with pid %s' % parent_pid) - - -# Generic JSON serializable object for simplified subclassing -class JsonObject(object): - - def to_json(self, indent=None): - return json.dumps(self, - default=lambda o: ((float(o) if o % 1 > 0 else int(o)) - if isinstance(o, decimal.Decimal) else o.__dict__), - sort_keys=True, indent=indent) - - def apply_json(self, j): - if isinstance(j, str): - j = json.loads(j) - self.__dict__.update(j) - - def to_dict(self): - return json.loads(self.to_json()) - - @classmethod - def from_json(cls, j): - j = JsonObject.as_dict(j) - result = cls() - result.apply_json(j) - return result - - @classmethod - def from_json_list(cls, l): - return [cls.from_json(j) for j in l] - - @classmethod - def as_dict(cls, obj): - if isinstance(obj, dict): - return obj - return obj.to_dict() - - def __str__(self): - return self.to_json() - - def __repr__(self): - return self.__str__() - - -# ---------------- -# UTILITY METHODS -# ---------------- - - -def is_string(s, include_unicode=True): - if isinstance(s, str): - return True - if include_unicode and isinstance(s, six.text_type): - return True - return False - - -def md5(string): - m = hashlib.md5() - m.update(to_bytes(string)) - return m.hexdigest() - - -def in_ci(): - """ Whether or not we are running in a CI environment """ - for key in ('CI', 'TRAVIS'): - if os.environ.get(key, '') not in [False, '', '0', 'false']: - return True - return False - - -def in_docker(): - return config.in_docker() - - -def is_port_open(port_or_url): - port = port_or_url - host = '127.0.0.1' - if isinstance(port, six.string_types): - url = urlparse(port_or_url) - port = url.port - host = url.hostname - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - sock.settimeout(1) - result = sock.connect_ex((host, port)) - return result == 0 - - -def wait_for_port_open(port, retries=10, sleep_time=0.5): - for i in range(0, retries): - if is_port_open(port): - break - time.sleep(sleep_time) - - -def timestamp(time=None, format=TIMESTAMP_FORMAT): - if not time: - time = datetime.utcnow() - if isinstance(time, six.integer_types + (float, )): - time = datetime.fromtimestamp(time) - return time.strftime(format) - - -def retry(function, retries=3, sleep=1, sleep_before=0, **kwargs): - raise_error = None - if sleep_before > 0: - time.sleep(sleep_before) - for i in range(0, retries + 1): - try: - return function(**kwargs) - except Exception as error: - raise_error = error - time.sleep(sleep) - raise raise_error - - -def dump_thread_info(): - for t in threading.enumerate(): - print(t) - print(run("ps aux | grep 'node\\|java\\|python'")) - - -def merge_recursive(source, destination): - for key, value in source.items(): - if isinstance(value, dict): - # get node or create one - node = destination.setdefault(key, {}) - merge_recursive(value, node) - else: - if not isinstance(destination, dict): - LOGGER.warning('Destination for merging %s=%s is not dict: %s' % - (key, value, destination)) - destination[key] = value - return destination - - -def base64_to_hex(b64_string): - return binascii.hexlify(base64.b64decode(b64_string)) - - -def now_utc(): - return mktime(datetime.utcnow()) - - -def now(): - return mktime(datetime.now()) - - -def mktime(timestamp): - return time.mktime(timestamp.timetuple()) - - -def mkdir(folder): - if not os.path.exists(folder): - os.makedirs(folder) - - -def chmod_r(path, mode): - """Recursive chmod""" - os.chmod(path, mode) - - for root, dirnames, filenames in os.walk(path): - for dirname in dirnames: - os.chmod(os.path.join(root, dirname), mode) - for filename in filenames: - os.chmod(os.path.join(root, filename), mode) - - -def rm_rf(path): - """Recursively removes file/directory""" - # Make sure all files are writeable and dirs executable to remove - chmod_r(path, 0o777) - if os.path.isfile(path): - os.remove(path) - else: - shutil.rmtree(path) - - -def cp_r(src, dst): - """Recursively copies file/directory""" - if os.path.isfile(src): - shutil.copy(src, dst) - else: - shutil.copytree(src, dst) - - -def download(url, path, verify_ssl=True): - """Downloads file at url to the given path""" - # make sure we're creating a new session here to - # enable parallel file downloads during installation! - s = requests.Session() - r = s.get(url, stream=True, verify=verify_ssl) - total = 0 - try: - if not os.path.exists(os.path.dirname(path)): - os.makedirs(os.path.dirname(path)) - LOGGER.debug('Starting download from %s to %s (%s bytes)' % (url, path, r.headers.get('content-length'))) - with open(path, 'wb') as f: - for chunk in r.iter_content(DOWNLOAD_CHUNK_SIZE): - total += len(chunk) - if chunk: # filter out keep-alive new chunks - f.write(chunk) - LOGGER.debug('Writing %s bytes (total %s) to %s' % (len(chunk), total, path)) - else: - LOGGER.debug('Empty chunk %s (total %s) from %s' % (chunk, total, url)) - f.flush() - os.fsync(f) - finally: - LOGGER.debug('Done downloading %s, response code %s' % (url, r.status_code)) - r.close() - s.close() - - -def short_uid(): - return str(uuid.uuid4())[0:8] - - -def json_safe(item): - """ return a copy of the given object (e.g., dict) that is safe for JSON dumping """ - try: - return json.loads(json.dumps(item, cls=CustomEncoder)) - except Exception: - item = fix_json_keys(item) - return json.loads(json.dumps(item, cls=CustomEncoder)) - - -def fix_json_keys(item): - """ make sure the keys of a JSON are strings (not binary type or other) """ - item_copy = item - if isinstance(item, list): - item_copy = [] - for i in item: - item_copy.append(fix_json_keys(i)) - if isinstance(item, dict): - item_copy = {} - for k, v in item.items(): - item_copy[to_str(k)] = fix_json_keys(v) - return item_copy - - -def save_file(file, content, append=False): - mode = 'a' if append else 'w+' - if not isinstance(content, six.string_types): - mode = mode + 'b' - with open(file, mode) as f: - f.write(content) - f.flush() - - -def load_file(file_path, default=None, mode=None): - if not os.path.isfile(file_path): - return default - if not mode: - mode = 'r' - with open(file_path, mode) as f: - result = f.read() - return result - - -def to_str(obj, encoding=DEFAULT_ENCODING, errors='strict'): - """ If ``obj`` is an instance of ``binary_type``, return - ``obj.decode(encoding, errors)``, otherwise return ``obj`` """ - return obj.decode(encoding, errors) if isinstance(obj, six.binary_type) else obj - - -def to_bytes(obj, encoding=DEFAULT_ENCODING, errors='strict'): - """ If ``obj`` is an instance of ``text_type``, return - ``obj.encode(encoding, errors)``, otherwise return ``obj`` """ - return obj.encode(encoding, errors) if isinstance(obj, six.text_type) else obj - - -def cleanup(files=True, env=ENV_DEV, quiet=True): - if files: - cleanup_tmp_files() - - -def cleanup_threads_and_processes(quiet=True): - for t in TMP_THREADS: - t.stop(quiet=quiet) - # clear list - clear_list(TMP_THREADS) - - -def clear_list(l): - while len(l): - del l[0] - - -def cleanup_tmp_files(): - for tmp in TMP_FILES: - try: - if os.path.isdir(tmp): - run('rm -rf "%s"' % tmp) - else: - os.remove(tmp) - except Exception: - pass # file likely doesn't exist, or permission denied - del TMP_FILES[:] - - -def is_ip_address(addr): - try: - socket.inet_aton(addr) - return True - except socket.error: - return False - - -def is_zip_file(content): - stream = BytesIO(content) - return zipfile.is_zipfile(stream) - - -def unzip(path, target_dir): - try: - zip_ref = zipfile.ZipFile(path, 'r') - except Exception as e: - LOGGER.warning('Unable to open zip file: %s: %s' % (path, e)) - raise e - zip_ref.extractall(target_dir) - zip_ref.close() - - -def is_jar_archive(content): - # TODO Simple stupid heuristic to determine whether a file is a JAR archive - try: - return 'class' in content and 'META-INF' in content - except TypeError: - # in Python 3 we need to use byte strings for byte-based file content - return b'class' in content and b'META-INF' in content - - -def is_root(): - out = run('whoami').strip() - return out == 'root' - - -def cleanup_resources(): - cleanup_tmp_files() - cleanup_threads_and_processes() - - -def generate_ssl_cert(target_file=None, overwrite=False, random=False): - # Note: Do NOT import "OpenSSL" at the root scope - # (Our test Lambdas are importing this file but don't have the module installed) - from OpenSSL import crypto - - if random and target_file: - if '.' in target_file: - target_file = target_file.replace('.', '.%s.' % short_uid(), 1) - else: - target_file = '%s.%s' % (target_file, short_uid()) - if target_file and not overwrite and os.path.exists(target_file): - return - - # create a key pair - k = crypto.PKey() - k.generate_key(crypto.TYPE_RSA, 1024) - - # create a self-signed cert - cert = crypto.X509() - subj = cert.get_subject() - subj.C = 'AU' - subj.ST = 'Some-State' - subj.L = 'Some-Locality' - subj.O = 'LocalStack Org' # noqa - subj.OU = 'Testing' - subj.CN = 'LocalStack' - cert.set_serial_number(1000) - cert.gmtime_adj_notBefore(0) - cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) - cert.set_issuer(cert.get_subject()) - cert.set_pubkey(k) - cert.sign(k, 'sha1') - - cert_file = StringIO() - key_file = StringIO() - cert_file.write(to_str(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))) - key_file.write(to_str(crypto.dump_privatekey(crypto.FILETYPE_PEM, k))) - cert_file_content = cert_file.getvalue().strip() - key_file_content = key_file.getvalue().strip() - file_content = '%s\n%s' % (key_file_content, cert_file_content) - if target_file: - save_file(target_file, file_content) - key_file_name = '%s.key' % target_file - cert_file_name = '%s.crt' % target_file - save_file(key_file_name, key_file_content) - save_file(cert_file_name, cert_file_content) - TMP_FILES.append(target_file) - TMP_FILES.append(key_file_name) - TMP_FILES.append(cert_file_name) - if random: - return target_file, cert_file_name, key_file_name - return file_content - return file_content - - -def run_safe(_python_lambda, print_error=True, **kwargs): - try: - _python_lambda(**kwargs) - except Exception as e: - if print_error: - print('Unable to execute function: %s' % e) - - -def run_cmd_safe(**kwargs): - return run_safe(run, print_error=False, **kwargs) - - -def run(cmd, cache_duration_secs=0, print_error=True, async=False, stdin=False, - stderr=subprocess.STDOUT, outfile=None, env_vars=None, inherit_cwd=False): - # don't use subprocess module as it is not thread-safe - # http://stackoverflow.com/questions/21194380/is-subprocess-popen-not-thread-safe - # import subprocess - if six.PY2: - import subprocess32 as subprocess - else: - import subprocess - - env_dict = os.environ.copy() - if env_vars: - env_dict.update(env_vars) - - def do_run(cmd): - try: - cwd = os.getcwd() if inherit_cwd else None - if not async: - if stdin: - return subprocess.check_output(cmd, shell=True, - stderr=stderr, stdin=subprocess.PIPE, env=env_dict, cwd=cwd) - output = subprocess.check_output(cmd, shell=True, stderr=stderr, env=env_dict, cwd=cwd) - return output.decode(DEFAULT_ENCODING) - # subprocess.Popen is not thread-safe, hence use a mutex here.. - try: - mutex_popen.acquire() - stdin_arg = subprocess.PIPE if stdin else None - stdout_arg = open(outfile, 'wb') if isinstance(outfile, six.string_types) else outfile - process = subprocess.Popen(cmd, shell=True, stdin=stdin_arg, bufsize=-1, - stderr=stderr, stdout=stdout_arg, env=env_dict, cwd=cwd) - return process - finally: - mutex_popen.release() - except subprocess.CalledProcessError as e: - if print_error: - print("ERROR: '%s': %s" % (cmd, e.output)) - raise e - - if cache_duration_secs <= 0: - return do_run(cmd) - hash = md5(cmd) - cache_file = CACHE_FILE_PATTERN.replace('*', hash) - if os.path.isfile(cache_file): - # check file age - mod_time = os.path.getmtime(cache_file) - time_now = now() - if mod_time > (time_now - cache_duration_secs): - f = open(cache_file) - result = f.read() - f.close() - return result - # print("NO CACHED result available for (timeout %s): %s" % (cache_duration_secs,cmd)) - result = do_run(cmd) - f = open(cache_file, 'w+') - f.write(result) - f.close() - clean_cache() - return result - - -def clone(item): - return json.loads(json.dumps(item)) - - -def remove_non_ascii(text): - # text = unicode(text, "utf-8") - text = text.decode('utf-8', CODEC_HANDLER_UNDERSCORE) - # text = unicodedata.normalize('NFKD', text) - text = text.encode('ascii', CODEC_HANDLER_UNDERSCORE) - return text - - -class NetrcBypassAuth(requests.auth.AuthBase): - def __call__(self, r): - return r - - -class _RequestsSafe(type): - """ Wrapper around requests library, which can prevent it from verifying - SSL certificates or reading credentials from ~/.netrc file """ - verify_ssl = True - - def __getattr__(self, name): - method = requests.__dict__.get(name.lower()) - if not method: - return method - - def _wrapper(*args, **kwargs): - if 'auth' not in kwargs: - kwargs['auth'] = NetrcBypassAuth() - if not self.verify_ssl and args[0].startswith('https://') and 'verify' not in kwargs: - kwargs['verify'] = False - return method(*args, **kwargs) - return _wrapper - - -# create class-of-a-class -class safe_requests(with_metaclass(_RequestsSafe)): - pass - - -def make_http_request(url, data=None, headers=None, method='GET'): - - if is_string(method): - method = requests.__dict__[method.lower()] - - return method(url, headers=headers, data=data, auth=NetrcBypassAuth(), verify=False) - - -def clean_cache(file_pattern=CACHE_FILE_PATTERN, - last_clean_time=last_cache_clean_time, max_age=CACHE_MAX_AGE): - - mutex_clean.acquire() - time_now = now() - try: - if last_clean_time['time'] > time_now - CACHE_CLEAN_TIMEOUT: - return - for cache_file in set(glob.glob(file_pattern)): - mod_time = os.path.getmtime(cache_file) - if time_now > mod_time + max_age: - rm_rf(cache_file) - last_clean_time['time'] = time_now - finally: - mutex_clean.release() - return time_now - - -def truncate(data, max_length=100): - return (data[:max_length] + '...') if len(data) > max_length else data - - -def parallelize(func, list, size=None): - if not size: - size = len(list) - if size <= 0: - return None - pool = Pool(size) - result = pool.map(func, list) - pool.close() - pool.join() - return result diff --git a/localstack/utils/kinesis/java/com/atlassian/DefaultSTSAssumeRoleSessionCredentialsProvider.java b/localstack/utils/kinesis/java/com/atlassian/DefaultSTSAssumeRoleSessionCredentialsProvider.java deleted file mode 100644 index c1262d4342fc6..0000000000000 --- a/localstack/utils/kinesis/java/com/atlassian/DefaultSTSAssumeRoleSessionCredentialsProvider.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.atlassian; - -import java.util.Map; - -import com.amazonaws.auth.AWSCredentialsProvider; -import com.amazonaws.auth.BasicSessionCredentials; -import com.amazonaws.auth.InstanceProfileCredentialsProvider; -import com.amazonaws.auth.STSAssumeRoleSessionCredentialsProvider; -import com.amazonaws.auth.AWSStaticCredentialsProvider; - -/** - * Custom session credentials provider that can be configured to assume a given IAM role. - * Configure the role to assume via the following environment variables: - * - AWS_ASSUME_ROLE_ARN : ARN of the role to assume - * - AWS_ASSUME_ROLE_SESSION_NAME : name of the session to be used when calling assume-role - * - * As long lived credentials, this credentials provider attempts to uses the following: - * - an STS token, via environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN - * - instance profile credentials provider (see Google hits for "EC2 instance metadata service") - * - * TODO: Potentially we could simply use the default credentials provider to obtain the long-lived credentials. - * - * @author Waldemar Hummer - */ -public class DefaultSTSAssumeRoleSessionCredentialsProvider extends STSAssumeRoleSessionCredentialsProvider { - - public DefaultSTSAssumeRoleSessionCredentialsProvider() { - super(getLongLivedCredentialsProvider(), getDefaultRoleARN(), getDefaultRoleSessionName()); - } - - private static String getDefaultRoleARN() { - Map env = System.getenv(); - return env.get("AWS_ASSUME_ROLE_ARN"); - } - - private static String getDefaultRoleSessionName() { - Map env = System.getenv(); - return env.get("AWS_ASSUME_ROLE_SESSION_NAME"); - } - - private static AWSCredentialsProvider getLongLivedCredentialsProvider() { - Map env = System.getenv(); - if(env.containsKey("AWS_SESSION_TOKEN")) { - return new AWSStaticCredentialsProvider( - new BasicSessionCredentials( - env.get("AWS_ACCESS_KEY_ID"), - env.get("AWS_SECRET_ACCESS_KEY"), - env.get("AWS_SESSION_TOKEN"))); - } - return new InstanceProfileCredentialsProvider(false); - } - - public static void main(String args[]) throws Exception { - System.out.println(new DefaultSTSAssumeRoleSessionCredentialsProvider().getCredentials()); - } - -} \ No newline at end of file diff --git a/localstack/utils/kinesis/java/com/atlassian/KinesisStarter.java b/localstack/utils/kinesis/java/com/atlassian/KinesisStarter.java deleted file mode 100644 index 058bcd40cc02b..0000000000000 --- a/localstack/utils/kinesis/java/com/atlassian/KinesisStarter.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.atlassian; - -import java.util.Properties; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; - -import com.amazonaws.services.kinesis.clientlibrary.lib.worker.KinesisClientLibConfiguration; -import com.amazonaws.services.kinesis.multilang.MultiLangDaemon; -import com.amazonaws.services.kinesis.multilang.MultiLangDaemonConfig; - -/** - * Custom extensions to MultiLangDaemon class from amazon-kinesis-client - * project, introducing the following additional configuration properties: - * - * - dynamodbEndpoint: endpoint host (hostname:port) for DynamoDB API - * - dynamodbProtocol: protocol for DynamoDB API (http or https) - * - kinesisProtocol: protocol for Kinesis API (http or https) - * - metricsLevel: level of CloudWatch metrics to report (e.g., SUMMARY or NONE) - * - * @author Waldemar Hummer - */ -public class KinesisStarter { - - private static final String PROP_DYNAMODB_ENDPOINT = "dynamodbEndpoint"; - private static final String PROP_DYNAMODB_PROTOCOL = "dynamodbProtocol"; - private static final String PROP_KINESIS_ENDPOINT = "kinesisEndpoint"; - private static final String PROP_KINESIS_PROTOCOL = "kinesisProtocol"; - private static final String PROP_METRICS_LEVEL = "metricsLevel"; - - public static void main(String[] args) throws Exception { - - Properties props = loadProps(args[0]); - - if(props.containsKey("disableCertChecking")) { - System.setProperty("com.amazonaws.sdk.disableCertChecking", "true"); - } - - MultiLangDaemonConfig config = new MultiLangDaemonConfig(args[0]); - - ExecutorService executorService = config.getExecutorService(); - KinesisClientLibConfiguration kinesisConfig = config.getKinesisClientLibConfiguration(); - - if(props.containsKey(PROP_METRICS_LEVEL)) { - String level = props.getProperty(PROP_METRICS_LEVEL); - kinesisConfig = kinesisConfig.withMetricsLevel(level); - } - if(props.containsKey(PROP_DYNAMODB_ENDPOINT)) { - String protocol = "http"; - if(props.containsKey(PROP_DYNAMODB_PROTOCOL)) { - protocol = props.getProperty(PROP_DYNAMODB_PROTOCOL); - } - String endpoint = protocol + "://" + props.getProperty(PROP_DYNAMODB_ENDPOINT); - kinesisConfig.withDynamoDBEndpoint(endpoint); - } - if(props.containsKey(PROP_KINESIS_ENDPOINT)) { - String protocol = "http"; - if(props.containsKey(PROP_KINESIS_PROTOCOL)) { - protocol = props.getProperty(PROP_KINESIS_PROTOCOL); - } - String endpoint = protocol + "://" + props.getProperty(PROP_KINESIS_ENDPOINT); - kinesisConfig.withKinesisEndpoint(endpoint); - } - - MultiLangDaemon daemon = new MultiLangDaemon( - kinesisConfig, - config.getRecordProcessorFactory(), - executorService); - - Future future = executorService.submit(daemon); - System.exit(future.get()); - } - - private static Properties loadProps(String file) throws Exception { - Properties props = new Properties(); - props.load(Thread.currentThread().getContextClassLoader().getResourceAsStream(file)); - return props; - } -} \ No newline at end of file diff --git a/localstack/utils/kinesis/kclipy_helper.py b/localstack/utils/kinesis/kclipy_helper.py deleted file mode 100644 index ed45f1f5e5b82..0000000000000 --- a/localstack/utils/kinesis/kclipy_helper.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python - -from __future__ import print_function -import os -from glob import glob -from six import iteritems -from amazon_kclpy import kcl -from localstack.utils.common import save_file - - -def get_dir_of_file(f): - return os.path.dirname(os.path.abspath(f)) - - -def get_kcl_dir(): - return get_dir_of_file(kcl.__file__) - - -def get_kcl_jar_path(): - jars = ':'.join(glob(os.path.join(get_kcl_dir(), 'jars', '*jar'))) - return jars - - -def get_kcl_classpath(properties=None, paths=[]): - """ - Generates a classpath that includes the location of the kcl jars, the - properties file and the optional paths. - - :type properties: str - :param properties: Path to properties file. - - :type paths: list - :param paths: List of strings. The paths that will be prepended to the classpath. - - :rtype: str - :return: A java class path that will allow your properties to be - found and the MultiLangDaemon and its deps and - any custom paths you provided. - """ - # First make all the user provided paths absolute - paths = [os.path.abspath(p) for p in paths] - # We add our paths after the user provided paths because this permits users to - # potentially inject stuff before our paths (otherwise our stuff would always - # take precedence). - paths.append(get_kcl_jar_path()) - if properties: - # Add the dir that the props file is in - dir_of_file = get_dir_of_file(properties) - paths.append(dir_of_file) - # add path of custom java code - dir_name = os.path.dirname(os.path.realpath(__file__)) - paths.append(os.path.realpath(os.path.join(dir_name, 'java'))) - paths.insert(0, os.path.realpath(os.path.join(dir_name, '..', '..', - 'infra', 'amazon-kinesis-client', 'aws-java-sdk-sts.jar'))) - return ':'.join([p for p in paths if p != '']) - - -def get_kcl_app_command(java, multi_lang_daemon_class, properties, paths=[]): - """ - Generates a command to run the MultiLangDaemon. - - :type java: str - :param java: Path to java - - :type multi_lang_daemon_class: str - :param multi_lang_daemon_class: Name of multi language daemon class, e.g. - com.amazonaws.services.kinesis.multilang.MultiLangDaemon - - :type properties: str - :param properties: Optional properties file to be included in the classpath. - - :type paths: list - :param paths: List of strings. Additional paths to prepend to the classpath. - - :rtype: str - :return: A command that will run the MultiLangDaemon with your - properties and custom paths and java. - """ - return '{java} -cp {cp} {daemon} {props}'.format( - java=java, - cp=get_kcl_classpath(properties, paths), - daemon=multi_lang_daemon_class, - # Just need the basename becasue the path is added to the classpath - props=os.path.basename(properties)) - - -def create_config_file(config_file, executableName, streamName, applicationName, credentialsProvider=None, **kwargs): - if not credentialsProvider: - credentialsProvider = 'DefaultAWSCredentialsProviderChain' - content = """ - executableName = %s - streamName = %s - applicationName = %s - AWSCredentialsProvider = %s - processingLanguage = python/2.7 - regionName = us-east-1 - """ % (executableName, streamName, applicationName, credentialsProvider) - # optional properties - for key, value in iteritems(kwargs): - content += """ - %s = %s""" % (key, value) - content = content.replace(' ', '') - save_file(config_file, content) diff --git a/localstack/utils/kinesis/kinesis_connector.py b/localstack/utils/kinesis/kinesis_connector.py deleted file mode 100644 index 9a0127c091914..0000000000000 --- a/localstack/utils/kinesis/kinesis_connector.py +++ /dev/null @@ -1,443 +0,0 @@ -#!/usr/bin/env python - -import os -import re -import tempfile -import time -import threading -import logging -from six.moves import queue as Queue -from six.moves.urllib.parse import urlparse -from amazon_kclpy import kcl -from localstack.constants import (LOCALSTACK_VENV_FOLDER, LOCALSTACK_ROOT_FOLDER, REGION_LOCAL, DEFAULT_REGION) -from localstack import config -from localstack.config import HOSTNAME, USE_SSL -from localstack.utils.common import run, TMP_THREADS, TMP_FILES, save_file, now, retry, short_uid -from localstack.utils.kinesis import kclipy_helper -from localstack.utils.kinesis.kinesis_util import EventFileReaderThread -from localstack.utils.common import ShellCommandThread, FuncThread -from localstack.utils.aws import aws_stack -from localstack.utils.aws.aws_models import KinesisStream - - -EVENTS_FILE_PATTERN = os.path.join(tempfile.gettempdir(), 'kclipy.*.fifo') -LOG_FILE_PATTERN = os.path.join(tempfile.gettempdir(), 'kclipy.*.log') -DEFAULT_DDB_LEASE_TABLE_SUFFIX = '-kclapp' - -# define Java class names -MULTI_LANG_DAEMON_CLASS = 'com.atlassian.KinesisStarter' - -# set up log levels -logging.SEVERE = 60 -logging.FATAL = 70 -logging.addLevelName(logging.SEVERE, 'SEVERE') -logging.addLevelName(logging.FATAL, 'FATAL') -LOG_LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL, logging.SEVERE] - -# default log level for the KCL log output -DEFAULT_KCL_LOG_LEVEL = logging.WARNING - -# set up local logger -LOGGER = logging.getLogger(__name__) - -# checkpointing settings -CHECKPOINT_RETRIES = 5 -CHECKPOINT_SLEEP_SECS = 5 -CHECKPOINT_FREQ_SECS = 60 - - -class KinesisProcessor(kcl.RecordProcessorBase): - - def __init__(self, log_file=None, processor_func=None, auto_checkpoint=True): - self.log_file = log_file - self.processor_func = processor_func - self.shard_id = None - self.checkpointer = None - self.auto_checkpoint = auto_checkpoint - self.last_checkpoint_time = 0 - self._largest_seq = (None, None) - - def initialize(self, shard_id): - if self.log_file: - self.log("initialize '%s'" % (shard_id)) - self.shard_id = shard_id - - def process_records(self, records, checkpointer): - if self.processor_func: - self.processor_func(records=records, - checkpointer=checkpointer, shard_id=self.shard_id) - for record in records: - seq = int(record.sequence_number) - sub_seq = record.sub_sequence_number - if self.should_update_sequence(seq, sub_seq): - self._largest_seq = (seq, sub_seq) - if self.auto_checkpoint: - time_now = now() - if (time_now - CHECKPOINT_FREQ_SECS) > self.last_checkpoint_time: - self.checkpoint(checkpointer, str(self._largest_seq[0]), self._largest_seq[1]) - self.last_checkpoint_time = time_now - - def shutdown(self, checkpointer, reason): - if self.log_file: - self.log("Shutdown processor for shard '%s'" % self.shard_id) - self.checkpointer = checkpointer - if reason == 'TERMINATE': - self.checkpoint(checkpointer) - - def checkpoint(self, checkpointer, sequence_number=None, sub_sequence_number=None): - def do_checkpoint(): - checkpointer.checkpoint(sequence_number, sub_sequence_number) - - try: - retry(do_checkpoint, retries=CHECKPOINT_RETRIES, sleep=CHECKPOINT_SLEEP_SECS) - except Exception as e: - LOGGER.warning('Unable to checkpoint Kinesis after retries: %s' % e) - - def should_update_sequence(self, sequence_number, sub_sequence_number): - return self._largest_seq == (None, None) or sequence_number > self._largest_seq[0] or \ - (sequence_number == self._largest_seq[0] and sub_sequence_number > self._largest_seq[1]) - - def log(self, s): - s = '%s\n' % s - if self.log_file: - save_file(self.log_file, s, append=True) - - @staticmethod - def run_processor(log_file=None, processor_func=None): - proc = kcl.KCLProcess(KinesisProcessor(log_file, processor_func)) - proc.run() - - -class KinesisProcessorThread(ShellCommandThread): - def __init__(self, params): - props_file = params['properties_file'] - env_vars = params['env_vars'] - cmd = kclipy_helper.get_kcl_app_command('java', - MULTI_LANG_DAEMON_CLASS, props_file) - if not params['log_file']: - params['log_file'] = '%s.log' % props_file - TMP_FILES.append(params['log_file']) - # print(cmd) - env = aws_stack.get_environment() - quiet = env.region == REGION_LOCAL - ShellCommandThread.__init__(self, cmd, outfile=params['log_file'], env_vars=env_vars, quiet=quiet) - - @staticmethod - def start_consumer(kinesis_stream): - thread = KinesisProcessorThread(kinesis_stream.stream_info) - thread.start() - return thread - - -class OutputReaderThread(FuncThread): - def __init__(self, params): - FuncThread.__init__(self, self.start_reading, params) - self.buffer = [] - self.params = params - self._stop_event = threading.Event() - # number of lines that make up a single log entry - self.buffer_size = 2 - # determine log level - self.log_level = params.get('level') - # get log subscribers - self.log_subscribers = params.get('log_subscribers', []) - if self.log_level is None: - self.log_level = DEFAULT_KCL_LOG_LEVEL - if self.log_level > 0: - levels = OutputReaderThread.get_log_level_names(self.log_level) - # regular expression to filter the printed output - self.filter_regex = r'.*(%s):.*' % ('|'.join(levels)) - # create prefix and logger - self.prefix = params.get('log_prefix') or 'LOG' - self.logger = logging.getLogger(self.prefix) - self.logger.severe = self.logger.critical - self.logger.fatal = self.logger.critical - self.logger.setLevel(self.log_level) - - @property - def running(self): - return not self._stop_event.is_set() - - @classmethod - def get_log_level_names(cls, min_level): - return [logging.getLevelName(lvl) for lvl in LOG_LEVELS if lvl >= min_level] - - def get_logger_for_level_in_log_line(self, line): - level = self.log_level - for lvl in LOG_LEVELS: - if lvl >= level: - level_name = logging.getLevelName(lvl) - if re.match(r'.*(%s):.*' % level_name, line): - return getattr(self.logger, level_name.lower()) - return None - - def notify_subscribers(self, line): - for subscriber in self.log_subscribers: - try: - if re.match(subscriber.regex, line): - subscriber.update(line) - except Exception as e: - LOGGER.warning('Unable to notify log subscriber: %s' % e) - - def start_reading(self, params): - for line in self._tail(params['file']): - # notify subscribers - self.notify_subscribers(line) - if self.log_level > 0: - # add line to buffer - self.buffer.append(line) - if len(self.buffer) >= self.buffer_size: - logger_func = None - for line in self.buffer: - if re.match(self.filter_regex, line): - logger_func = self.get_logger_for_level_in_log_line(line) - break - if logger_func: - for buffered_line in self.buffer: - logger_func(buffered_line) - self.buffer = [] - - def _tail(self, file): - with open(file) as f: - while self.running: - line = f.readline() - if line: # empty if at EOF - yield line.replace('\n', '') - else: - time.sleep(0.1) - - def stop(self, quiet=True): - self._stop_event.set() - - -class KclLogListener(object): - def __init__(self, regex='.*'): - self.regex = regex - - def update(self, log_line): - print(log_line) - - -class KclStartedLogListener(KclLogListener): - def __init__(self): - self.regex_init = r'.*Initialization complete.*' - self.regex_take_shard = r'.*Received response .* for initialize.*' - # construct combined regex - regex = r'(%s)|(%s)' % (self.regex_init, self.regex_take_shard) - super(KclStartedLogListener, self).__init__(regex=regex) - # Semaphore.acquire does not provide timeout parameter, so we - # use a Queue here which provides the required functionality - self.sync_init = Queue.Queue(0) - self.sync_take_shard = Queue.Queue(0) - - def update(self, log_line): - if re.match(self.regex_init, log_line): - self.sync_init.put(1, block=False) - if re.match(self.regex_take_shard, log_line): - self.sync_take_shard.put(1, block=False) - - -# construct a stream info hash -def get_stream_info(stream_name, log_file=None, shards=None, env=None, endpoint_url=None, - ddb_lease_table_suffix=None, env_vars={}): - if not ddb_lease_table_suffix: - ddb_lease_table_suffix = DEFAULT_DDB_LEASE_TABLE_SUFFIX - # construct stream info - env = aws_stack.get_environment(env) - props_file = os.path.join(tempfile.gettempdir(), 'kclipy.%s.properties' % short_uid()) - app_name = '%s%s' % (stream_name, ddb_lease_table_suffix) - stream_info = { - 'name': stream_name, - 'region': DEFAULT_REGION, - 'shards': shards, - 'properties_file': props_file, - 'log_file': log_file, - 'app_name': app_name, - 'env_vars': env_vars - } - # set local connection - if env.region == REGION_LOCAL: - stream_info['conn_kwargs'] = { - 'host': HOSTNAME, - 'port': config.PORT_KINESIS, - 'is_secure': bool(USE_SSL) - } - if endpoint_url: - if 'conn_kwargs' not in stream_info: - stream_info['conn_kwargs'] = {} - url = urlparse(endpoint_url) - stream_info['conn_kwargs']['host'] = url.hostname - stream_info['conn_kwargs']['port'] = url.port - stream_info['conn_kwargs']['is_secure'] = url.scheme == 'https' - return stream_info - - -def start_kcl_client_process(stream_name, listener_script, log_file=None, env=None, configs={}, - endpoint_url=None, ddb_lease_table_suffix=None, env_vars={}, - kcl_log_level=DEFAULT_KCL_LOG_LEVEL, log_subscribers=[]): - env = aws_stack.get_environment(env) - # decide which credentials provider to use - credentialsProvider = None - if (('AWS_ASSUME_ROLE_ARN' in os.environ or 'AWS_ASSUME_ROLE_ARN' in env_vars) and - ('AWS_ASSUME_ROLE_SESSION_NAME' in os.environ or 'AWS_ASSUME_ROLE_SESSION_NAME' in env_vars)): - # use special credentials provider that can assume IAM roles and handle temporary STS auth tokens - credentialsProvider = 'com.atlassian.DefaultSTSAssumeRoleSessionCredentialsProvider' - # pass through env variables to child process - for var_name in ['AWS_ASSUME_ROLE_ARN', 'AWS_ASSUME_ROLE_SESSION_NAME', - 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']: - if var_name in os.environ and var_name not in env_vars: - env_vars[var_name] = os.environ[var_name] - if env.region == REGION_LOCAL: - # need to disable CBOR protocol, enforce use of plain JSON, - # see https://github.com/mhart/kinesalite/issues/31 - env_vars['AWS_CBOR_DISABLE'] = 'true' - if kcl_log_level or (len(log_subscribers) > 0): - if not log_file: - log_file = LOG_FILE_PATTERN.replace('*', short_uid()) - TMP_FILES.append(log_file) - run('touch %s' % log_file) - # start log output reader thread which will read the KCL log - # file and print each line to stdout of this process... - reader_thread = OutputReaderThread({'file': log_file, 'level': kcl_log_level, - 'log_prefix': 'KCL', 'log_subscribers': log_subscribers}) - reader_thread.start() - - # construct stream info - stream_info = get_stream_info(stream_name, log_file, env=env, endpoint_url=endpoint_url, - ddb_lease_table_suffix=ddb_lease_table_suffix, env_vars=env_vars) - props_file = stream_info['properties_file'] - # set kcl config options - kwargs = { - 'metricsLevel': 'NONE', - 'initialPositionInStream': 'LATEST' - } - # set parameters for local connection - if env.region == REGION_LOCAL: - kwargs['kinesisEndpoint'] = '%s:%s' % (HOSTNAME, config.PORT_KINESIS) - kwargs['dynamodbEndpoint'] = '%s:%s' % (HOSTNAME, config.PORT_DYNAMODB) - kwargs['kinesisProtocol'] = 'http%s' % ('s' if USE_SSL else '') - kwargs['dynamodbProtocol'] = 'http%s' % ('s' if USE_SSL else '') - kwargs['disableCertChecking'] = 'true' - kwargs.update(configs) - # create config file - kclipy_helper.create_config_file(config_file=props_file, executableName=listener_script, - streamName=stream_name, applicationName=stream_info['app_name'], - credentialsProvider=credentialsProvider, **kwargs) - TMP_FILES.append(props_file) - # start stream consumer - stream = KinesisStream(id=stream_name, params=stream_info) - thread_consumer = KinesisProcessorThread.start_consumer(stream) - TMP_THREADS.append(thread_consumer) - return thread_consumer - - -def generate_processor_script(events_file, log_file=None): - script_file = os.path.join(tempfile.gettempdir(), 'kclipy.%s.processor.py' % short_uid()) - if log_file: - log_file = "'%s'" % log_file - else: - log_file = 'None' - content = """#!/usr/bin/env python -import os, sys, glob, json, socket, time, logging, tempfile -import subprocess32 as subprocess -logging.basicConfig(level=logging.INFO) -for path in glob.glob('%s/lib/python*/site-packages'): - sys.path.insert(0, path) -sys.path.insert(0, '%s') -from localstack.config import DEFAULT_ENCODING -from localstack.utils.kinesis import kinesis_connector -from localstack.utils.common import timestamp -events_file = '%s' -log_file = %s -error_log = os.path.join(tempfile.gettempdir(), 'kclipy.error.log') -if __name__ == '__main__': - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - - num_tries = 3 - sleep_time = 2 - error = None - for i in range(0, num_tries): - try: - sock.connect(events_file) - error = None - break - except Exception as e: - error = e - if i < num_tries: - msg = '%%s: Unable to connect to UNIX socket. Retrying.' %% timestamp() - subprocess.check_output('echo "%%s" >> %%s' %% (msg, error_log), shell=True) - time.sleep(sleep_time) - if error: - print("WARN: Unable to connect to UNIX socket after retrying: %%s" %% error) - raise error - - def receive_msg(records, checkpointer, shard_id): - try: - # records is a list of amazon_kclpy.messages.Record objects -> convert to JSON - records_dicts = [j._json_dict for j in records] - message_to_send = {'shard_id': shard_id, 'records': records_dicts} - string_to_send = '%%s\\n' %% json.dumps(message_to_send) - bytes_to_send = string_to_send.encode(DEFAULT_ENCODING) - sock.send(bytes_to_send) - except Exception as e: - msg = "WARN: Unable to forward event: %%s" %% e - print(msg) - subprocess.check_output('echo "%%s" >> %%s' %% (msg, error_log), shell=True) - kinesis_connector.KinesisProcessor.run_processor(log_file=log_file, processor_func=receive_msg) - """ % (LOCALSTACK_VENV_FOLDER, LOCALSTACK_ROOT_FOLDER, events_file, log_file) - save_file(script_file, content) - run('chmod +x %s' % script_file) - TMP_FILES.append(script_file) - return script_file - - -def listen_to_kinesis(stream_name, listener_func=None, processor_script=None, - events_file=None, endpoint_url=None, log_file=None, configs={}, env=None, - ddb_lease_table_suffix=None, env_vars={}, kcl_log_level=DEFAULT_KCL_LOG_LEVEL, - log_subscribers=[], wait_until_started=False): - """ - High-level function that allows to subscribe to a Kinesis stream - and receive events in a listener function. A KCL client process is - automatically started in the background. - """ - env = aws_stack.get_environment(env) - if not events_file: - events_file = EVENTS_FILE_PATTERN.replace('*', short_uid()) - TMP_FILES.append(events_file) - if not processor_script: - processor_script = generate_processor_script(events_file, log_file=log_file) - - run('rm -f %s' % events_file) - # start event reader thread (this process) - ready_mutex = threading.Semaphore(0) - thread = EventFileReaderThread(events_file, listener_func, ready_mutex=ready_mutex) - thread.start() - # Wait until the event reader thread is ready (to avoid 'Connection refused' error on the UNIX socket) - ready_mutex.acquire() - # start KCL client (background process) - if processor_script[-4:] == '.pyc': - processor_script = processor_script[0:-1] - # add log listener that notifies when KCL is started - if wait_until_started: - listener = KclStartedLogListener() - log_subscribers.append(listener) - - process = start_kcl_client_process(stream_name, processor_script, - endpoint_url=endpoint_url, log_file=log_file, configs=configs, env=env, - ddb_lease_table_suffix=ddb_lease_table_suffix, env_vars=env_vars, kcl_log_level=kcl_log_level, - log_subscribers=log_subscribers) - - if wait_until_started: - # Wait at most 90 seconds for initialization. Note that creating the DDB table can take quite a bit - try: - listener.sync_init.get(block=True, timeout=90) - except Exception: - raise Exception('Timeout when waiting for KCL initialization.') - # wait at most 30 seconds for shard lease notification - try: - listener.sync_take_shard.get(block=True, timeout=30) - except Exception: - # this merely means that there is no shard available to take. Do nothing. - pass - - return process diff --git a/localstack/utils/kinesis/kinesis_util.py b/localstack/utils/kinesis/kinesis_util.py deleted file mode 100644 index 5ee28161e00dd..0000000000000 --- a/localstack/utils/kinesis/kinesis_util.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -import socket -import traceback -import logging -import inspect -from localstack.utils.common import FuncThread, truncate - -# set up local logger -LOGGER = logging.getLogger(__name__) - - -class EventFileReaderThread(FuncThread): - def __init__(self, events_file, callback, ready_mutex=None): - FuncThread.__init__(self, self.retrieve_loop, None) - self.running = True - self.events_file = events_file - self.callback = callback - self.ready_mutex = ready_mutex - - def retrieve_loop(self, params): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.bind(self.events_file) - sock.listen(1) - if self.ready_mutex: - self.ready_mutex.release() - while self.running: - try: - conn, client_addr = sock.accept() - thread = FuncThread(self.handle_connection, conn) - thread.start() - except Exception as e: - LOGGER.error('Error dispatching client request: %s %s' % (e, traceback.format_exc())) - sock.close() - - def handle_connection(self, conn): - socket_file = conn.makefile() - while self.running: - line = socket_file.readline() - line = line[:-1] - if line == '': - # end of socket input stream - break - else: - try: - event = json.loads(line) - records = event['records'] - shard_id = event['shard_id'] - method_args = inspect.getargspec(self.callback)[0] - if len(method_args) > 1: - self.callback(records, shard_id=shard_id) - else: - self.callback(records) - except Exception as e: - LOGGER.warning("Unable to process JSON line: '%s': %s %s. Callback: %s" % - (truncate(line), e, traceback.format_exc(), self.callback)) - conn.close() - - def stop(self, quiet=True): - self.running = False diff --git a/localstack/utils/persistence.py b/localstack/utils/persistence.py deleted file mode 100644 index ad8d00a56aaa0..0000000000000 --- a/localstack/utils/persistence.py +++ /dev/null @@ -1,110 +0,0 @@ -import os -import json -import base64 -import traceback -import requests -import logging -from localstack.config import DATA_DIR -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_bytes, to_str - -API_FILE_PATTERN = '{data_dir}/{api}_api_calls.json' - -# Stack with flags to indicate whether we are currently re-playing API calls. -# (We should not be re-playing and recording at the same time) -CURRENTLY_REPLAYING = [] - -# file paths by API -API_FILE_PATHS = {} - -# set up logger -LOGGER = logging.getLogger(__name__) - - -def should_record(api, method, path, data, headers): - """ Decide whether or not a given API call should be recorded (persisted to disk) """ - if api == 's3': - if method not in ['PUT', 'POST', 'DELETE']: - return False - return True - return False - - -def record(api, method, path, data, headers): - """ Record a given API call to a persistent file on disk """ - file_path = get_file_path(api, create=True) - if CURRENTLY_REPLAYING or not file_path or not should_record(api, method, path, data, headers): - return - entry = None - try: - if isinstance(data, dict): - data = json.dumps(data) - if data: - try: - data = to_bytes(data) - except Exception as e: - LOGGER.warning('Unable to call to_bytes: %s' % e) - data = to_str(base64.b64encode(data)) - entry = { - 'a': api, - 'm': method, - 'p': path, - 'd': data, - 'h': dict(headers) - } - with open(file_path, 'a') as dumpfile: - dumpfile.write('%s\n' % json.dumps(entry)) - except Exception as e: - print('Error recording API call to persistent file: %s %s' % (e, traceback.format_exc())) - - -def replay_command(command): - function = getattr(requests, command['m'].lower()) - data = command['d'] - if data: - data = base64.b64decode(data) - endpoint = aws_stack.get_local_service_url(command['a']) - full_url = (endpoint[:-1] if endpoint.endswith('/') else endpoint) + command['p'] - result = function(full_url, data=data, headers=command['h']) - return result - - -def replay(api): - file_path = get_file_path(api) - if not file_path: - return - CURRENTLY_REPLAYING.append(True) - count = 0 - try: - with open(file_path, 'r') as reader: - for line in reader: - if line.strip(): - count += 1 - command = json.loads(line) - replay_command(command) - finally: - CURRENTLY_REPLAYING.pop(0) - if count: - LOGGER.info('Restored %s API calls from persistent file: %s' % (count, file_path)) - - -def restore_persisted_data(api): - return replay(api) - - -# --------------- -# HELPER METHODS -# --------------- - -def get_file_path(api, create=False): - if api not in API_FILE_PATHS: - API_FILE_PATHS[api] = False - if not DATA_DIR: - return False - file_path = API_FILE_PATTERN.format(data_dir=DATA_DIR, api=api) - if create and not os.path.exists(file_path): - with open(file_path, 'a'): - os.utime(file_path, None) - if os.path.exists(file_path): - API_FILE_PATHS[api] = file_path - return API_FILE_PATHS.get(api) diff --git a/localstack/utils/testutil.py b/localstack/utils/testutil.py deleted file mode 100644 index 149adf1eb1fd4..0000000000000 --- a/localstack/utils/testutil.py +++ /dev/null @@ -1,203 +0,0 @@ -import os -import json -import time -import glob -import tempfile -from six import iteritems -from localstack.constants import LOCALSTACK_ROOT_FOLDER, LOCALSTACK_VENV_FOLDER, LAMBDA_TEST_ROLE -from localstack.services.awslambda.lambda_api import (get_handler_file_from_name, LAMBDA_DEFAULT_HANDLER, - LAMBDA_DEFAULT_RUNTIME, LAMBDA_DEFAULT_STARTING_POSITION, LAMBDA_DEFAULT_TIMEOUT) -from localstack.utils.common import run, mkdir, to_str, save_file, TMP_FILES -from localstack.utils.aws import aws_stack - - -ARCHIVE_DIR_PREFIX = 'lambda.archive.' - - -def create_dynamodb_table(table_name, partition_key, env=None, stream_view_type=None): - """Utility method to create a DynamoDB table""" - - dynamodb = aws_stack.connect_to_service('dynamodb', env=env, client=True) - stream_spec = {'StreamEnabled': False} - key_schema = [{ - 'AttributeName': partition_key, - 'KeyType': 'HASH' - }] - attr_defs = [{ - 'AttributeName': partition_key, - 'AttributeType': 'S' - }] - if stream_view_type is not None: - stream_spec = { - 'StreamEnabled': True, - 'StreamViewType': stream_view_type - } - table = None - try: - table = dynamodb.create_table(TableName=table_name, KeySchema=key_schema, - AttributeDefinitions=attr_defs, ProvisionedThroughput={ - 'ReadCapacityUnits': 10, 'WriteCapacityUnits': 10 - }, - StreamSpecification=stream_spec - ) - except Exception as e: - if 'ResourceInUseException' in str(e): - # Table already exists -> return table reference - return aws_stack.connect_to_resource('dynamodb', env=env).Table(table_name) - time.sleep(2) - return table - - -def create_lambda_archive(script, stream=None, get_content=False, libs=[], runtime=None): - """Utility method to create a Lambda function archive""" - tmp_dir = tempfile.mkdtemp(prefix=ARCHIVE_DIR_PREFIX) - TMP_FILES.append(tmp_dir) - file_name = get_handler_file_from_name(LAMBDA_DEFAULT_HANDLER, runtime=runtime) - script_file = '%s/%s' % (tmp_dir, file_name) - save_file(script_file, script) - # copy libs - for lib in libs: - paths = [lib, '%s.py' % lib] - target_dir = tmp_dir - root_folder = '%s/lib/python*/site-packages' % LOCALSTACK_VENV_FOLDER - if lib == 'localstack': - paths = ['localstack/*.py', 'localstack/utils'] - root_folder = LOCALSTACK_ROOT_FOLDER - target_dir = '%s/%s/' % (tmp_dir, lib) - mkdir(target_dir) - for path in paths: - file_to_copy = '%s/%s' % (root_folder, path) - for file_path in glob.glob(file_to_copy): - run('cp -r %s %s/' % (file_path, target_dir)) - - # create zip file - return create_zip_file(tmp_dir, get_content=True) - - -# TODO: Refactor this method and use built-in file operations instead of shell commands -def create_zip_file(file_path, include='*', get_content=False): - base_dir = file_path - if not os.path.isdir(file_path): - base_dir = tempfile.mkdtemp(prefix=ARCHIVE_DIR_PREFIX) - run('cp "%s" "%s"' % (file_path, base_dir)) - include = os.path.basename(file_path) - TMP_FILES.append(base_dir) - tmp_dir = tempfile.mkdtemp(prefix=ARCHIVE_DIR_PREFIX) - zip_file_name = 'archive.zip' - zip_file = '%s/%s' % (tmp_dir, zip_file_name) - # create zip file - run('cd "%s" && zip -r "%s" %s' % (base_dir, zip_file, include)) - if not get_content: - TMP_FILES.append(tmp_dir) - return zip_file - zip_file_content = None - with open(zip_file, 'rb') as file_obj: - zip_file_content = file_obj.read() - run('rm -r "%s"' % tmp_dir) - return zip_file_content - - -def create_lambda_function(func_name, zip_file, event_source_arn=None, handler=LAMBDA_DEFAULT_HANDLER, - starting_position=LAMBDA_DEFAULT_STARTING_POSITION, runtime=LAMBDA_DEFAULT_RUNTIME, - envvars={}): - """Utility method to create a new function via the Lambda API""" - - client = aws_stack.connect_to_service('lambda') - # create function - client.create_function( - FunctionName=func_name, - Runtime=runtime, - Handler=handler, - Role=LAMBDA_TEST_ROLE, - Code={ - 'ZipFile': zip_file - }, - Timeout=LAMBDA_DEFAULT_TIMEOUT, - Environment=dict(Variables=envvars) - ) - # create event source mapping - if event_source_arn: - client.create_event_source_mapping( - FunctionName=func_name, - EventSourceArn=event_source_arn, - StartingPosition=starting_position - ) - - -def assert_objects(asserts, all_objects): - if type(asserts) is not list: - asserts = [asserts] - for obj in asserts: - assert_object(obj, all_objects) - - -def assert_object(expected_object, all_objects): - # for Python 3 compatibility - dict_values = type({}.values()) - if isinstance(all_objects, dict_values): - all_objects = list(all_objects) - # wrap single item in an array - if type(all_objects) is not list: - all_objects = [all_objects] - found = find_object(expected_object, all_objects) - if not found: - raise Exception('Expected object not found: %s in list %s' % (expected_object, all_objects)) - - -def find_object(expected_object, object_list): - for obj in object_list: - if isinstance(obj, list): - found = find_object(expected_object, obj) - if found: - return found - - all_ok = True - if obj != expected_object: - if not isinstance(expected_object, dict): - all_ok = False - else: - for k, v in iteritems(expected_object): - if not find_recursive(k, v, obj): - all_ok = False - break - if all_ok: - return obj - return None - - -def find_recursive(key, value, obj): - if isinstance(obj, dict): - for k, v in iteritems(obj): - if k == key and v == value: - return True - if find_recursive(key, value, v): - return True - elif isinstance(obj, list): - for o in obj: - if find_recursive(key, value, o): - return True - else: - return False - - -def list_all_s3_objects(): - return map_all_s3_objects().values() - - -def download_s3_object(s3, bucket, path): - with tempfile.SpooledTemporaryFile() as tmpfile: - s3.Bucket(bucket).download_fileobj(path, tmpfile) - tmpfile.seek(0) - return to_str(tmpfile.read()) - - -def map_all_s3_objects(to_json=True): - s3_client = aws_stack.get_s3_client() - result = {} - for bucket in s3_client.buckets.all(): - for key in bucket.objects.all(): - value = download_s3_object(s3_client, key.bucket_name, key.key) - if to_json: - value = json.loads(value) - result['%s/%s' % (key.bucket_name, key.key)] = value - return result diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000..88ffd3dc89913 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,262 @@ +# LocalStack project configuration +[build-system] +requires = ['setuptools>=64', 'wheel', 'plux>=1.12', "setuptools_scm>=8.1"] +build-backend = "setuptools.build_meta" + +[project] +name = "localstack-core" +authors = [ + { name = "LocalStack Contributors", email = "info@localstack.cloud" } +] +description = "The core library and runtime of LocalStack" +license = "Apache-2.0" +requires-python = ">=3.9" +dependencies = [ + "build", + "click>=7.1", + "cachetools>=5.0", + "cryptography", + "dill==0.3.6", + "dnslib>=0.9.10", + "dnspython>=1.16.0", + "plux>=1.10", + "psutil>=5.4.8", + "python-dotenv>=0.19.1", + "pyyaml>=5.1", + "rich>=12.3.0", + "requests>=2.20.0", + "semver>=2.10", + "tailer>=0.4.1", +] +dynamic = ["version"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Topic :: Internet", + "Topic :: Software Development :: Testing", + "Topic :: System :: Emulators", +] + +[tool.setuptools_scm] +version_file = "localstack-core/localstack/version.py" +# pypi does not support local versions: +# https://setuptools-scm.readthedocs.io/en/latest/usage/#default-versioning-scheme +local_scheme = "no-local-version" + +[project.urls] +Homepage = "https://localstack.cloud" +Documentation = "https://docs.localstack.cloud" +Repository = "https://github.com/localstack/localstack.git" +Issues = "https://github.com/localstack/localstack/issues" + +[project.optional-dependencies] +# minimal required to actually run localstack on the host for services natively implemented in python +base-runtime = [ + # pinned / updated by ASF update action + "boto3==1.39.4", + # pinned / updated by ASF update action + "botocore==1.39.4", + "awscrt>=0.13.14,!=0.27.1", + "cbor2>=5.5.0", + "dnspython>=1.16.0", + "docker>=6.1.1", + "jsonpatch>=1.24", + "hypercorn>=0.14.4", + "localstack-twisted>=23.0", + "openapi-core>=0.19.2", + "pyopenssl>=23.0.0", + "readerwriterlock>=1.0.7", + "requests-aws4auth>=1.0", + # explicitly set urllib3 to force its usage / ensure compatibility + "urllib3>=2.0.7", + "Werkzeug>=3.1.3", + "xmltodict>=0.13.0", + "rolo>=0.7", +] + +# required to actually run localstack on the host +runtime = [ + "localstack-core[base-runtime]", + # pinned / updated by ASF update action + "awscli>=1.41.0", + "airspeed-ext>=0.6.3", + # version that has a built wheel + "kclpy-ext>=3.0.0", + # antlr4-python3-runtime: exact pin because antlr4 runtime is tightly coupled to the generated parser code + "antlr4-python3-runtime==4.13.2", + "apispec>=5.1.1", + "aws-sam-translator>=1.15.1", + "crontab>=0.22.6", + "cryptography>=41.0.5", + # allow Python programs full access to Java class libraries. Used for opt-in event ruler. + "jpype1-ext>=0.0.1", + "json5>=0.9.11", + "jsonpath-ng>=1.6.1", + "jsonpath-rw>=1.4.0", + "moto-ext[all]==5.1.6.post2", + "opensearch-py>=2.4.1", + "pymongo>=4.2.0", + "pyopenssl>=23.0.0", +] + +# for running tests and coverage analysis +test = [ + # runtime dependencies are required for running the tests + "localstack-core[runtime]", + "coverage[toml]>=5.5", + "deepdiff>=6.4.1", + "httpx[http2]>=0.25", + "pluggy>=1.3.0", + "pytest>=7.4.2", + "pytest-split>=0.8.0", + "pytest-httpserver>=1.1.2", + "pytest-rerunfailures>=12.0", + "pytest-tinybird>=0.5.0", + "aws-cdk-lib>=2.88.0", + "websocket-client>=1.7.0", + "localstack-snapshot>=0.1.1", +] + +# for developing localstack +dev = [ + # test dependencies are required for developing localstack + "localstack-core[test]", + "coveralls>=3.3.1", + "Cython", + "networkx>=2.8.4", + "openapi-spec-validator>=0.7.1", + "pandoc", + "pre-commit>=3.5.0", + "pypandoc", + "ruff>=0.3.3", + "rstr>=3.2.0", + "mypy", +] + +# not strictly necessary for development, but provides type hint support for a better developer experience +typehint = [ + # typehint is an optional extension of the dev dependencies + "localstack-core[dev]", + # pinned / updated by ASF update action + "boto3-stubs[acm,acm-pca,amplify,apigateway,apigatewayv2,appconfig,appconfigdata,application-autoscaling,appsync,athena,autoscaling,backup,batch,ce,cloudcontrol,cloudformation,cloudfront,cloudtrail,cloudwatch,codebuild,codecommit,codeconnections,codedeploy,codepipeline,codestar-connections,cognito-identity,cognito-idp,dms,docdb,dynamodb,dynamodbstreams,ec2,ecr,ecs,efs,eks,elasticache,elasticbeanstalk,elbv2,emr,emr-serverless,es,events,firehose,fis,glacier,glue,iam,identitystore,iot,iot-data,iotanalytics,iotwireless,kafka,kinesis,kinesisanalytics,kinesisanalyticsv2,kms,lakeformation,lambda,logs,managedblockchain,mediaconvert,mediastore,mq,mwaa,neptune,opensearch,organizations,pi,pipes,pinpoint,qldb,qldb-session,rds,rds-data,redshift,redshift-data,resource-groups,resourcegroupstaggingapi,route53,route53resolver,s3,s3control,sagemaker,sagemaker-runtime,secretsmanager,serverlessrepo,servicediscovery,ses,sesv2,sns,sqs,ssm,sso-admin,stepfunctions,sts,timestream-query,timestream-write,transcribe,verifiedpermissions,wafv2,xray]", +] + +[tool.setuptools] +include-package-data = false +# TODO using this is discouraged by setuptools, `project.scripts` should be used instead +# However, `project.scripts` does not support non-python scripts. +script-files = [ + "bin/localstack", + "bin/localstack.bat", + "bin/localstack-supervisor", +] +package-dir = { "" = "localstack-core" } + +[tool.setuptools.dynamic] +readme = { file = ["README.md"], content-type = "text/markdown" } + +[tool.setuptools.packages.find] +where = ["localstack-core/"] +include = ["localstack*"] +exclude = ["tests*"] + +[tool.setuptools.package-data] +"*" = [ + "*.md", + "Makefile", +] +"localstack" = [ + "aws/**/*.json", + "services/**/*.html", + "services/**/resource_providers/*.schema.json", + "utils/kinesis/java/cloud/localstack/*.*", + "openapi.yaml", + "http/resources/swagger/templates/index.html" +] + +[tool.ruff] +# Generate code compatible with version defined in .python-version +target-version = "py311" +line-length = 100 +src = ["localstack-core", "tests"] +exclude = [ + ".venv*", + "venv*", + "dist", + "build", + "target", + "*.egg-info", + "localstack-core/*.egg-info", + ".filesystem", + "localstack-core/.filesystem", + ".git", + "localstack-core/localstack/services/stepfunctions/asl/antlr/runtime" +] + +[tool.ruff.per-file-target-version] +# Only allow minimum version for code used in the CLI +"localstack-core/localstack/cli/**" = "py39" +"localstack-core/localstack/packages/**" = "py39" +"localstack-core/localstack/config.py" = "py39" +"localstack-core/localstack/constants.py" = "py39" +"localstack-core/localstack/utils/analytics/**" = "py39" +"localstack-core/localstack/utils/bootstrap.py" = "py39" +"localstack-core/localstack/utils/json.py" = "py39" + + +[tool.ruff.lint] +ignore = [ + "B007", # TODO Loop control variable x not used within loop body + "B017", # TODO `pytest.raises(Exception)` should be considered evil + "B019", # TODO Use of `functools.lru_cache` or `functools.cache` on methods can lead to memory leaks + "B022", # TODO No arguments passed to `contextlib.suppress`. No exceptions will be suppressed and therefore this context manager is redundant + "B023", # TODO Function definition does not bind loop variable `server` + "B024", # TODO x is an abstract base class, but it has no abstract methods + "B027", # TODO `Server.do_shutdown` is an empty method in an abstract base class, but has no abstract decorator + "B904", # TODO Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling + "C408", # TODO Unnecessary `list` call (rewrite as a literal) + "C416", # TODO Unnecessary `set` comprehension + "C901", # TODO function is too complex + "E402", # TODO Module level import not at top of file + "E501", # E501 Line too long - handled by black, see https://docs.astral.sh/ruff/faq/#is-ruff-compatible-with-black + "E721", # TODO Do not compare types, use `isinstance()` + "T201", # TODO `print` found + "T203", # TODO `pprint` found +] +select = ["B", "C", "E", "F", "I", "W", "T", "B9", "G"] + +[tool.coverage.run] +relative_files = true +source = [ + "localstack", +] +omit = [ + "*/aws/api/*", + "*/extensions/api/*", + "*/services/stepfunctions/asl/antlr/runtime/*" +] +dynamic_context = "test_function" + +[tool.coverage.paths] +source = [ + "localstack-core" +] + +[tool.coverage.report] +exclude_lines = [ + "if __name__ == .__main__.:", + "raise NotImplemented.", + "return NotImplemented", + "def __repr__", +] + +[tool.pytest.ini_options] +log_cli = false +log_level = "DEBUG" +log_cli_format = "%(asctime)s.%(msecs)03d %(levelname)5s --- [%(threadName)12s] %(name)-26s : %(message)s" +log_cli_date_format = "%Y-%m-%dT%H:%M:%S" + +[tool.pip-tools] +# adding localstack-core itself here because it is referenced in the pyproject.toml for stacking the extras +# pip, setuptools, and distribute are pip-tools defaults which need to be set again here +unsafe-package = ["localstack-core", "pip", "setuptools", "distribute"] # packages that should not be pinned diff --git a/requirements-base-runtime.txt b/requirements-base-runtime.txt new file mode 100644 index 0000000000000..1a63172ea035a --- /dev/null +++ b/requirements-base-runtime.txt @@ -0,0 +1,208 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=base-runtime --output-file=requirements-base-runtime.txt --strip-extras --unsafe-package=distribute --unsafe-package=localstack-core --unsafe-package=pip --unsafe-package=setuptools pyproject.toml +# +attrs==25.3.0 + # via + # jsonschema + # localstack-twisted + # referencing +awscrt==0.27.4 + # via localstack-core (pyproject.toml) +boto3==1.39.4 + # via localstack-core (pyproject.toml) +botocore==1.39.4 + # via + # boto3 + # localstack-core (pyproject.toml) + # s3transfer +build==1.2.2.post1 + # via localstack-core (pyproject.toml) +cachetools==6.1.0 + # via localstack-core (pyproject.toml) +cbor2==5.6.5 + # via localstack-core (pyproject.toml) +certifi==2025.7.14 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via localstack-core (pyproject.toml) +constantly==23.10.4 + # via localstack-twisted +cryptography==45.0.5 + # via + # localstack-core (pyproject.toml) + # pyopenssl +dill==0.3.6 + # via localstack-core (pyproject.toml) +dnslib==0.9.26 + # via localstack-core (pyproject.toml) +dnspython==2.7.0 + # via localstack-core (pyproject.toml) +docker==7.1.0 + # via localstack-core (pyproject.toml) +h11==0.16.0 + # via + # hypercorn + # wsproto +h2==4.2.0 + # via + # hypercorn + # localstack-twisted +hpack==4.1.0 + # via h2 +hypercorn==0.17.3 + # via localstack-core (pyproject.toml) +hyperframe==6.1.0 + # via h2 +hyperlink==21.0.0 + # via localstack-twisted +idna==3.10 + # via + # hyperlink + # localstack-twisted + # requests +incremental==24.7.2 + # via localstack-twisted +isodate==0.7.2 + # via openapi-core +jmespath==1.0.1 + # via + # boto3 + # botocore +jsonpatch==1.33 + # via localstack-core (pyproject.toml) +jsonpointer==3.0.0 + # via jsonpatch +jsonschema==4.24.0 + # via + # openapi-core + # openapi-schema-validator + # openapi-spec-validator +jsonschema-path==0.3.4 + # via + # openapi-core + # openapi-spec-validator +jsonschema-specifications==2025.4.1 + # via + # jsonschema + # openapi-schema-validator +lazy-object-proxy==1.11.0 + # via openapi-spec-validator +localstack-twisted==24.3.0 + # via localstack-core (pyproject.toml) +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via werkzeug +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.7.0 + # via openapi-core +openapi-core==0.19.4 + # via localstack-core (pyproject.toml) +openapi-schema-validator==0.6.3 + # via + # openapi-core + # openapi-spec-validator +openapi-spec-validator==0.7.2 + # via openapi-core +packaging==25.0 + # via build +parse==1.20.2 + # via openapi-core +pathable==0.4.4 + # via jsonschema-path +plux==1.12.1 + # via localstack-core (pyproject.toml) +priority==1.3.0 + # via + # hypercorn + # localstack-twisted +psutil==7.0.0 + # via localstack-core (pyproject.toml) +pycparser==2.22 + # via cffi +pygments==2.19.2 + # via rich +pyopenssl==25.1.0 + # via + # localstack-core (pyproject.toml) + # localstack-twisted +pyproject-hooks==1.2.0 + # via build +python-dateutil==2.9.0.post0 + # via botocore +python-dotenv==1.1.1 + # via localstack-core (pyproject.toml) +pyyaml==6.0.2 + # via + # jsonschema-path + # localstack-core (pyproject.toml) +readerwriterlock==1.0.9 + # via localstack-core (pyproject.toml) +referencing==0.36.2 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications +requests==2.32.4 + # via + # docker + # jsonschema-path + # localstack-core (pyproject.toml) + # requests-aws4auth + # rolo +requests-aws4auth==1.3.1 + # via localstack-core (pyproject.toml) +rfc3339-validator==0.1.4 + # via openapi-schema-validator +rich==14.0.0 + # via localstack-core (pyproject.toml) +rolo==0.7.6 + # via localstack-core (pyproject.toml) +rpds-py==0.26.0 + # via + # jsonschema + # referencing +s3transfer==0.13.0 + # via boto3 +semver==3.0.4 + # via localstack-core (pyproject.toml) +six==1.17.0 + # via + # python-dateutil + # rfc3339-validator +tailer==0.4.1 + # via localstack-core (pyproject.toml) +typing-extensions==4.14.1 + # via + # localstack-twisted + # pyopenssl + # readerwriterlock + # referencing +urllib3==2.5.0 + # via + # botocore + # docker + # localstack-core (pyproject.toml) + # requests +werkzeug==3.1.3 + # via + # localstack-core (pyproject.toml) + # openapi-core + # rolo +wsproto==1.2.0 + # via hypercorn +xmltodict==0.14.2 + # via localstack-core (pyproject.toml) +zope-interface==7.2 + # via localstack-twisted + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements-basic.txt b/requirements-basic.txt new file mode 100644 index 0000000000000..e670f920bf911 --- /dev/null +++ b/requirements-basic.txt @@ -0,0 +1,58 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --output-file=requirements-basic.txt --strip-extras --unsafe-package=distribute --unsafe-package=localstack-core --unsafe-package=pip --unsafe-package=setuptools pyproject.toml +# +build==1.2.2.post1 + # via localstack-core (pyproject.toml) +cachetools==6.1.0 + # via localstack-core (pyproject.toml) +certifi==2025.7.14 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via localstack-core (pyproject.toml) +cryptography==45.0.5 + # via localstack-core (pyproject.toml) +dill==0.3.6 + # via localstack-core (pyproject.toml) +dnslib==0.9.26 + # via localstack-core (pyproject.toml) +dnspython==2.7.0 + # via localstack-core (pyproject.toml) +idna==3.10 + # via requests +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +packaging==25.0 + # via build +plux==1.12.1 + # via localstack-core (pyproject.toml) +psutil==7.0.0 + # via localstack-core (pyproject.toml) +pycparser==2.22 + # via cffi +pygments==2.19.2 + # via rich +pyproject-hooks==1.2.0 + # via build +python-dotenv==1.1.1 + # via localstack-core (pyproject.toml) +pyyaml==6.0.2 + # via localstack-core (pyproject.toml) +requests==2.32.4 + # via localstack-core (pyproject.toml) +rich==14.0.0 + # via localstack-core (pyproject.toml) +semver==3.0.4 + # via localstack-core (pyproject.toml) +tailer==0.4.1 + # via localstack-core (pyproject.toml) +urllib3==2.5.0 + # via requests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000000000..70e47448f694c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,516 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements-dev.txt --strip-extras --unsafe-package=distribute --unsafe-package=localstack-core --unsafe-package=pip --unsafe-package=setuptools pyproject.toml +# +airspeed-ext==0.6.9 + # via localstack-core +annotated-types==0.7.0 + # via pydantic +antlr4-python3-runtime==4.13.2 + # via + # localstack-core + # moto-ext +anyio==4.9.0 + # via httpx +apispec==6.8.2 + # via localstack-core +argparse==1.4.0 + # via kclpy-ext +attrs==25.3.0 + # via + # cattrs + # jsii + # jsonschema + # localstack-twisted + # referencing +aws-cdk-asset-awscli-v1==2.2.242 + # via aws-cdk-lib +aws-cdk-asset-node-proxy-agent-v6==2.1.0 + # via aws-cdk-lib +aws-cdk-cloud-assembly-schema==45.2.0 + # via aws-cdk-lib +aws-cdk-lib==2.204.0 + # via localstack-core +aws-sam-translator==1.99.0 + # via + # cfn-lint + # localstack-core +aws-xray-sdk==2.14.0 + # via moto-ext +awscli==1.41.4 + # via localstack-core +awscrt==0.27.4 + # via localstack-core +boto3==1.39.4 + # via + # aws-sam-translator + # kclpy-ext + # localstack-core + # moto-ext +botocore==1.39.4 + # via + # aws-xray-sdk + # awscli + # boto3 + # localstack-core + # moto-ext + # s3transfer +build==1.2.2.post1 + # via + # localstack-core + # localstack-core (pyproject.toml) +cachetools==6.1.0 + # via + # airspeed-ext + # localstack-core + # localstack-core (pyproject.toml) +cattrs==24.1.3 + # via jsii +cbor2==5.6.5 + # via localstack-core +certifi==2025.7.14 + # via + # httpcore + # httpx + # opensearch-py + # requests +cffi==1.17.1 + # via cryptography +cfgv==3.4.0 + # via pre-commit +cfn-lint==1.38.0 + # via moto-ext +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +colorama==0.4.6 + # via awscli +constantly==23.10.4 + # via localstack-twisted +constructs==10.4.2 + # via aws-cdk-lib +coverage==7.9.2 + # via + # coveralls + # localstack-core +coveralls==4.0.1 + # via localstack-core (pyproject.toml) +crontab==1.0.5 + # via localstack-core +cryptography==45.0.5 + # via + # joserfc + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # pyopenssl +cython==3.1.2 + # via localstack-core (pyproject.toml) +decorator==5.2.1 + # via jsonpath-rw +deepdiff==8.5.0 + # via + # localstack-core + # localstack-snapshot +dill==0.3.6 + # via + # localstack-core + # localstack-core (pyproject.toml) +distlib==0.3.9 + # via virtualenv +dnslib==0.9.26 + # via + # localstack-core + # localstack-core (pyproject.toml) +dnspython==2.7.0 + # via + # localstack-core + # localstack-core (pyproject.toml) + # pymongo +docker==7.1.0 + # via + # localstack-core + # moto-ext +docopt==0.6.2 + # via coveralls +docutils==0.19 + # via awscli +events==0.5 + # via opensearch-py +filelock==3.18.0 + # via virtualenv +graphql-core==3.2.6 + # via moto-ext +h11==0.16.0 + # via + # httpcore + # hypercorn + # wsproto +h2==4.2.0 + # via + # httpx + # hypercorn + # localstack-twisted +hpack==4.1.0 + # via h2 +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via localstack-core +hypercorn==0.17.3 + # via localstack-core +hyperframe==6.1.0 + # via h2 +hyperlink==21.0.0 + # via localstack-twisted +identify==2.6.12 + # via pre-commit +idna==3.10 + # via + # anyio + # httpx + # hyperlink + # localstack-twisted + # requests +importlib-resources==6.5.2 + # via jsii +incremental==24.7.2 + # via localstack-twisted +iniconfig==2.1.0 + # via pytest +isodate==0.7.2 + # via openapi-core +jinja2==3.1.6 + # via moto-ext +jmespath==1.0.1 + # via + # boto3 + # botocore +joserfc==1.2.2 + # via moto-ext +jpype1-ext==0.0.2 + # via localstack-core +jsii==1.112.0 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs +json5==0.12.0 + # via localstack-core +jsonpatch==1.33 + # via + # cfn-lint + # localstack-core +jsonpath-ng==1.7.0 + # via + # localstack-core + # localstack-snapshot + # moto-ext +jsonpath-rw==1.4.0 + # via localstack-core +jsonpointer==3.0.0 + # via jsonpatch +jsonschema==4.24.0 + # via + # aws-sam-translator + # moto-ext + # openapi-core + # openapi-schema-validator + # openapi-spec-validator +jsonschema-path==0.3.4 + # via + # openapi-core + # openapi-spec-validator +jsonschema-specifications==2025.4.1 + # via + # jsonschema + # openapi-schema-validator +kclpy-ext==3.0.5 + # via localstack-core +lazy-object-proxy==1.11.0 + # via openapi-spec-validator +localstack-snapshot==0.3.0 + # via localstack-core +localstack-twisted==24.3.0 + # via localstack-core +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via + # jinja2 + # werkzeug +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.7.0 + # via openapi-core +moto-ext==5.1.6.post2 + # via localstack-core +mpmath==1.3.0 + # via sympy +multipart==1.2.1 + # via moto-ext +mypy==1.17.0 + # via localstack-core (pyproject.toml) +mypy-extensions==1.1.0 + # via mypy +networkx==3.5 + # via + # cfn-lint + # localstack-core (pyproject.toml) +nodeenv==1.9.1 + # via pre-commit +openapi-core==0.19.4 + # via localstack-core +openapi-schema-validator==0.6.3 + # via + # openapi-core + # openapi-spec-validator +openapi-spec-validator==0.7.2 + # via + # localstack-core (pyproject.toml) + # moto-ext + # openapi-core +opensearch-py==3.0.0 + # via localstack-core +orderly-set==5.5.0 + # via deepdiff +packaging==25.0 + # via + # apispec + # build + # jpype1-ext + # pytest + # pytest-rerunfailures +pandoc==2.4 + # via localstack-core (pyproject.toml) +parse==1.20.2 + # via openapi-core +pathable==0.4.4 + # via jsonschema-path +pathspec==0.12.1 + # via mypy +platformdirs==4.3.8 + # via virtualenv +pluggy==1.6.0 + # via + # localstack-core + # pytest +plumbum==1.9.0 + # via pandoc +plux==1.12.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +ply==3.11 + # via + # jsonpath-ng + # jsonpath-rw + # pandoc +pre-commit==4.2.0 + # via localstack-core (pyproject.toml) +priority==1.3.0 + # via + # hypercorn + # localstack-twisted +psutil==7.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +publication==0.0.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs + # jsii +py-partiql-parser==0.6.1 + # via moto-ext +pyasn1==0.6.1 + # via rsa +pycparser==2.22 + # via cffi +pydantic==2.11.7 + # via aws-sam-translator +pydantic-core==2.33.2 + # via pydantic +pygments==2.19.2 + # via + # pytest + # rich +pymongo==4.13.2 + # via localstack-core +pyopenssl==25.1.0 + # via + # localstack-core + # localstack-twisted +pypandoc==1.15 + # via localstack-core (pyproject.toml) +pyparsing==3.2.3 + # via moto-ext +pyproject-hooks==1.2.0 + # via build +pytest==8.4.1 + # via + # localstack-core + # pytest-rerunfailures + # pytest-split + # pytest-tinybird +pytest-httpserver==1.1.3 + # via localstack-core +pytest-rerunfailures==15.1 + # via localstack-core +pytest-split==0.10.0 + # via localstack-core +pytest-tinybird==0.5.0 + # via localstack-core +python-dateutil==2.9.0.post0 + # via + # botocore + # jsii + # moto-ext + # opensearch-py +python-dotenv==1.1.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +pyyaml==6.0.2 + # via + # awscli + # cfn-lint + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # pre-commit + # responses +readerwriterlock==1.0.9 + # via localstack-core +referencing==0.36.2 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications +regex==2024.11.6 + # via cfn-lint +requests==2.32.4 + # via + # coveralls + # docker + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # opensearch-py + # pytest-tinybird + # requests-aws4auth + # responses + # rolo +requests-aws4auth==1.3.1 + # via localstack-core +responses==0.25.7 + # via moto-ext +rfc3339-validator==0.1.4 + # via openapi-schema-validator +rich==14.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +rolo==0.7.6 + # via localstack-core +rpds-py==0.26.0 + # via + # jsonschema + # referencing +rsa==4.7.2 + # via awscli +rstr==3.2.2 + # via localstack-core (pyproject.toml) +ruff==0.12.3 + # via localstack-core (pyproject.toml) +s3transfer==0.13.0 + # via + # awscli + # boto3 +semver==3.0.4 + # via + # localstack-core + # localstack-core (pyproject.toml) +six==1.17.0 + # via + # airspeed-ext + # jsonpath-rw + # python-dateutil + # rfc3339-validator +sniffio==1.3.1 + # via anyio +sympy==1.14.0 + # via cfn-lint +tailer==0.4.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +typeguard==2.13.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs + # jsii +typing-extensions==4.14.1 + # via + # anyio + # aws-sam-translator + # cfn-lint + # jsii + # localstack-twisted + # mypy + # pydantic + # pydantic-core + # pyopenssl + # readerwriterlock + # referencing + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +urllib3==2.5.0 + # via + # botocore + # docker + # localstack-core + # opensearch-py + # requests + # responses +virtualenv==20.31.2 + # via pre-commit +websocket-client==1.8.0 + # via localstack-core +werkzeug==3.1.3 + # via + # localstack-core + # moto-ext + # openapi-core + # pytest-httpserver + # rolo +wrapt==1.17.2 + # via aws-xray-sdk +wsproto==1.2.0 + # via hypercorn +xmltodict==0.14.2 + # via + # localstack-core + # moto-ext +zope-interface==7.2 + # via localstack-twisted + +# The following packages are considered to be unsafe in a requirements file: +# localstack-core +# setuptools diff --git a/requirements-runtime.txt b/requirements-runtime.txt new file mode 100644 index 0000000000000..8bf9f052e73a8 --- /dev/null +++ b/requirements-runtime.txt @@ -0,0 +1,375 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=runtime --output-file=requirements-runtime.txt --strip-extras --unsafe-package=distribute --unsafe-package=localstack-core --unsafe-package=pip --unsafe-package=setuptools pyproject.toml +# +airspeed-ext==0.6.9 + # via localstack-core (pyproject.toml) +annotated-types==0.7.0 + # via pydantic +antlr4-python3-runtime==4.13.2 + # via + # localstack-core (pyproject.toml) + # moto-ext +apispec==6.8.2 + # via localstack-core (pyproject.toml) +argparse==1.4.0 + # via kclpy-ext +attrs==25.3.0 + # via + # jsonschema + # localstack-twisted + # referencing +aws-sam-translator==1.99.0 + # via + # cfn-lint + # localstack-core (pyproject.toml) +aws-xray-sdk==2.14.0 + # via moto-ext +awscli==1.41.4 + # via localstack-core (pyproject.toml) +awscrt==0.27.4 + # via localstack-core +boto3==1.39.4 + # via + # aws-sam-translator + # kclpy-ext + # localstack-core + # moto-ext +botocore==1.39.4 + # via + # aws-xray-sdk + # awscli + # boto3 + # localstack-core + # moto-ext + # s3transfer +build==1.2.2.post1 + # via + # localstack-core + # localstack-core (pyproject.toml) +cachetools==6.1.0 + # via + # airspeed-ext + # localstack-core + # localstack-core (pyproject.toml) +cbor2==5.6.5 + # via localstack-core +certifi==2025.7.14 + # via + # opensearch-py + # requests +cffi==1.17.1 + # via cryptography +cfn-lint==1.38.0 + # via moto-ext +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +colorama==0.4.6 + # via awscli +constantly==23.10.4 + # via localstack-twisted +crontab==1.0.5 + # via localstack-core (pyproject.toml) +cryptography==45.0.5 + # via + # joserfc + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # pyopenssl +decorator==5.2.1 + # via jsonpath-rw +dill==0.3.6 + # via + # localstack-core + # localstack-core (pyproject.toml) +dnslib==0.9.26 + # via + # localstack-core + # localstack-core (pyproject.toml) +dnspython==2.7.0 + # via + # localstack-core + # localstack-core (pyproject.toml) + # pymongo +docker==7.1.0 + # via + # localstack-core + # moto-ext +docutils==0.19 + # via awscli +events==0.5 + # via opensearch-py +graphql-core==3.2.6 + # via moto-ext +h11==0.16.0 + # via + # hypercorn + # wsproto +h2==4.2.0 + # via + # hypercorn + # localstack-twisted +hpack==4.1.0 + # via h2 +hypercorn==0.17.3 + # via localstack-core +hyperframe==6.1.0 + # via h2 +hyperlink==21.0.0 + # via localstack-twisted +idna==3.10 + # via + # hyperlink + # localstack-twisted + # requests +incremental==24.7.2 + # via localstack-twisted +isodate==0.7.2 + # via openapi-core +jinja2==3.1.6 + # via moto-ext +jmespath==1.0.1 + # via + # boto3 + # botocore +joserfc==1.2.2 + # via moto-ext +jpype1-ext==0.0.2 + # via localstack-core (pyproject.toml) +json5==0.12.0 + # via localstack-core (pyproject.toml) +jsonpatch==1.33 + # via + # cfn-lint + # localstack-core +jsonpath-ng==1.7.0 + # via + # localstack-core (pyproject.toml) + # moto-ext +jsonpath-rw==1.4.0 + # via localstack-core (pyproject.toml) +jsonpointer==3.0.0 + # via jsonpatch +jsonschema==4.24.0 + # via + # aws-sam-translator + # moto-ext + # openapi-core + # openapi-schema-validator + # openapi-spec-validator +jsonschema-path==0.3.4 + # via + # openapi-core + # openapi-spec-validator +jsonschema-specifications==2025.4.1 + # via + # jsonschema + # openapi-schema-validator +kclpy-ext==3.0.5 + # via localstack-core (pyproject.toml) +lazy-object-proxy==1.11.0 + # via openapi-spec-validator +localstack-twisted==24.3.0 + # via localstack-core +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via + # jinja2 + # werkzeug +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.7.0 + # via openapi-core +moto-ext==5.1.6.post2 + # via localstack-core (pyproject.toml) +mpmath==1.3.0 + # via sympy +multipart==1.2.1 + # via moto-ext +networkx==3.5 + # via cfn-lint +openapi-core==0.19.4 + # via localstack-core +openapi-schema-validator==0.6.3 + # via + # openapi-core + # openapi-spec-validator +openapi-spec-validator==0.7.2 + # via + # moto-ext + # openapi-core +opensearch-py==3.0.0 + # via localstack-core (pyproject.toml) +packaging==25.0 + # via + # apispec + # build + # jpype1-ext +parse==1.20.2 + # via openapi-core +pathable==0.4.4 + # via jsonschema-path +plux==1.12.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +ply==3.11 + # via + # jsonpath-ng + # jsonpath-rw +priority==1.3.0 + # via + # hypercorn + # localstack-twisted +psutil==7.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +py-partiql-parser==0.6.1 + # via moto-ext +pyasn1==0.6.1 + # via rsa +pycparser==2.22 + # via cffi +pydantic==2.11.7 + # via aws-sam-translator +pydantic-core==2.33.2 + # via pydantic +pygments==2.19.2 + # via rich +pymongo==4.13.2 + # via localstack-core (pyproject.toml) +pyopenssl==25.1.0 + # via + # localstack-core + # localstack-core (pyproject.toml) + # localstack-twisted +pyparsing==3.2.3 + # via moto-ext +pyproject-hooks==1.2.0 + # via build +python-dateutil==2.9.0.post0 + # via + # botocore + # moto-ext + # opensearch-py +python-dotenv==1.1.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +pyyaml==6.0.2 + # via + # awscli + # cfn-lint + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # responses +readerwriterlock==1.0.9 + # via localstack-core +referencing==0.36.2 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications +regex==2024.11.6 + # via cfn-lint +requests==2.32.4 + # via + # docker + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # opensearch-py + # requests-aws4auth + # responses + # rolo +requests-aws4auth==1.3.1 + # via localstack-core +responses==0.25.7 + # via moto-ext +rfc3339-validator==0.1.4 + # via openapi-schema-validator +rich==14.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +rolo==0.7.6 + # via localstack-core +rpds-py==0.26.0 + # via + # jsonschema + # referencing +rsa==4.7.2 + # via awscli +s3transfer==0.13.0 + # via + # awscli + # boto3 +semver==3.0.4 + # via + # localstack-core + # localstack-core (pyproject.toml) +six==1.17.0 + # via + # airspeed-ext + # jsonpath-rw + # python-dateutil + # rfc3339-validator +sympy==1.14.0 + # via cfn-lint +tailer==0.4.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +typing-extensions==4.14.1 + # via + # aws-sam-translator + # cfn-lint + # localstack-twisted + # pydantic + # pydantic-core + # pyopenssl + # readerwriterlock + # referencing + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +urllib3==2.5.0 + # via + # botocore + # docker + # localstack-core + # opensearch-py + # requests + # responses +werkzeug==3.1.3 + # via + # localstack-core + # moto-ext + # openapi-core + # rolo +wrapt==1.17.2 + # via aws-xray-sdk +wsproto==1.2.0 + # via hypercorn +xmltodict==0.14.2 + # via + # localstack-core + # moto-ext +zope-interface==7.2 + # via localstack-twisted + +# The following packages are considered to be unsafe in a requirements file: +# localstack-core +# setuptools diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000000000..bcfaded15e52f --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,469 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=test --output-file=requirements-test.txt --strip-extras --unsafe-package=distribute --unsafe-package=localstack-core --unsafe-package=pip --unsafe-package=setuptools pyproject.toml +# +airspeed-ext==0.6.9 + # via localstack-core +annotated-types==0.7.0 + # via pydantic +antlr4-python3-runtime==4.13.2 + # via + # localstack-core + # moto-ext +anyio==4.9.0 + # via httpx +apispec==6.8.2 + # via localstack-core +argparse==1.4.0 + # via kclpy-ext +attrs==25.3.0 + # via + # cattrs + # jsii + # jsonschema + # localstack-twisted + # referencing +aws-cdk-asset-awscli-v1==2.2.242 + # via aws-cdk-lib +aws-cdk-asset-node-proxy-agent-v6==2.1.0 + # via aws-cdk-lib +aws-cdk-cloud-assembly-schema==45.2.0 + # via aws-cdk-lib +aws-cdk-lib==2.204.0 + # via localstack-core (pyproject.toml) +aws-sam-translator==1.99.0 + # via + # cfn-lint + # localstack-core +aws-xray-sdk==2.14.0 + # via moto-ext +awscli==1.41.4 + # via localstack-core +awscrt==0.27.4 + # via localstack-core +boto3==1.39.4 + # via + # aws-sam-translator + # kclpy-ext + # localstack-core + # moto-ext +botocore==1.39.4 + # via + # aws-xray-sdk + # awscli + # boto3 + # localstack-core + # moto-ext + # s3transfer +build==1.2.2.post1 + # via + # localstack-core + # localstack-core (pyproject.toml) +cachetools==6.1.0 + # via + # airspeed-ext + # localstack-core + # localstack-core (pyproject.toml) +cattrs==24.1.3 + # via jsii +cbor2==5.6.5 + # via localstack-core +certifi==2025.7.14 + # via + # httpcore + # httpx + # opensearch-py + # requests +cffi==1.17.1 + # via cryptography +cfn-lint==1.38.0 + # via moto-ext +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +colorama==0.4.6 + # via awscli +constantly==23.10.4 + # via localstack-twisted +constructs==10.4.2 + # via aws-cdk-lib +coverage==7.9.2 + # via localstack-core (pyproject.toml) +crontab==1.0.5 + # via localstack-core +cryptography==45.0.5 + # via + # joserfc + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # pyopenssl +decorator==5.2.1 + # via jsonpath-rw +deepdiff==8.5.0 + # via + # localstack-core (pyproject.toml) + # localstack-snapshot +dill==0.3.6 + # via + # localstack-core + # localstack-core (pyproject.toml) +dnslib==0.9.26 + # via + # localstack-core + # localstack-core (pyproject.toml) +dnspython==2.7.0 + # via + # localstack-core + # localstack-core (pyproject.toml) + # pymongo +docker==7.1.0 + # via + # localstack-core + # moto-ext +docutils==0.19 + # via awscli +events==0.5 + # via opensearch-py +graphql-core==3.2.6 + # via moto-ext +h11==0.16.0 + # via + # httpcore + # hypercorn + # wsproto +h2==4.2.0 + # via + # httpx + # hypercorn + # localstack-twisted +hpack==4.1.0 + # via h2 +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via localstack-core (pyproject.toml) +hypercorn==0.17.3 + # via localstack-core +hyperframe==6.1.0 + # via h2 +hyperlink==21.0.0 + # via localstack-twisted +idna==3.10 + # via + # anyio + # httpx + # hyperlink + # localstack-twisted + # requests +importlib-resources==6.5.2 + # via jsii +incremental==24.7.2 + # via localstack-twisted +iniconfig==2.1.0 + # via pytest +isodate==0.7.2 + # via openapi-core +jinja2==3.1.6 + # via moto-ext +jmespath==1.0.1 + # via + # boto3 + # botocore +joserfc==1.2.2 + # via moto-ext +jpype1-ext==0.0.2 + # via localstack-core +jsii==1.112.0 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs +json5==0.12.0 + # via localstack-core +jsonpatch==1.33 + # via + # cfn-lint + # localstack-core +jsonpath-ng==1.7.0 + # via + # localstack-core + # localstack-snapshot + # moto-ext +jsonpath-rw==1.4.0 + # via localstack-core +jsonpointer==3.0.0 + # via jsonpatch +jsonschema==4.24.0 + # via + # aws-sam-translator + # moto-ext + # openapi-core + # openapi-schema-validator + # openapi-spec-validator +jsonschema-path==0.3.4 + # via + # openapi-core + # openapi-spec-validator +jsonschema-specifications==2025.4.1 + # via + # jsonschema + # openapi-schema-validator +kclpy-ext==3.0.5 + # via localstack-core +lazy-object-proxy==1.11.0 + # via openapi-spec-validator +localstack-snapshot==0.3.0 + # via localstack-core (pyproject.toml) +localstack-twisted==24.3.0 + # via localstack-core +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via + # jinja2 + # werkzeug +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.7.0 + # via openapi-core +moto-ext==5.1.6.post2 + # via localstack-core +mpmath==1.3.0 + # via sympy +multipart==1.2.1 + # via moto-ext +networkx==3.5 + # via cfn-lint +openapi-core==0.19.4 + # via localstack-core +openapi-schema-validator==0.6.3 + # via + # openapi-core + # openapi-spec-validator +openapi-spec-validator==0.7.2 + # via + # moto-ext + # openapi-core +opensearch-py==3.0.0 + # via localstack-core +orderly-set==5.5.0 + # via deepdiff +packaging==25.0 + # via + # apispec + # build + # jpype1-ext + # pytest + # pytest-rerunfailures +parse==1.20.2 + # via openapi-core +pathable==0.4.4 + # via jsonschema-path +pluggy==1.6.0 + # via + # localstack-core (pyproject.toml) + # pytest +plux==1.12.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +ply==3.11 + # via + # jsonpath-ng + # jsonpath-rw +priority==1.3.0 + # via + # hypercorn + # localstack-twisted +psutil==7.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +publication==0.0.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs + # jsii +py-partiql-parser==0.6.1 + # via moto-ext +pyasn1==0.6.1 + # via rsa +pycparser==2.22 + # via cffi +pydantic==2.11.7 + # via aws-sam-translator +pydantic-core==2.33.2 + # via pydantic +pygments==2.19.2 + # via + # pytest + # rich +pymongo==4.13.2 + # via localstack-core +pyopenssl==25.1.0 + # via + # localstack-core + # localstack-twisted +pyparsing==3.2.3 + # via moto-ext +pyproject-hooks==1.2.0 + # via build +pytest==8.4.1 + # via + # localstack-core (pyproject.toml) + # pytest-rerunfailures + # pytest-split + # pytest-tinybird +pytest-httpserver==1.1.3 + # via localstack-core (pyproject.toml) +pytest-rerunfailures==15.1 + # via localstack-core (pyproject.toml) +pytest-split==0.10.0 + # via localstack-core (pyproject.toml) +pytest-tinybird==0.5.0 + # via localstack-core (pyproject.toml) +python-dateutil==2.9.0.post0 + # via + # botocore + # jsii + # moto-ext + # opensearch-py +python-dotenv==1.1.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +pyyaml==6.0.2 + # via + # awscli + # cfn-lint + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # responses +readerwriterlock==1.0.9 + # via localstack-core +referencing==0.36.2 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications +regex==2024.11.6 + # via cfn-lint +requests==2.32.4 + # via + # docker + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # opensearch-py + # pytest-tinybird + # requests-aws4auth + # responses + # rolo +requests-aws4auth==1.3.1 + # via localstack-core +responses==0.25.7 + # via moto-ext +rfc3339-validator==0.1.4 + # via openapi-schema-validator +rich==14.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +rolo==0.7.6 + # via localstack-core +rpds-py==0.26.0 + # via + # jsonschema + # referencing +rsa==4.7.2 + # via awscli +s3transfer==0.13.0 + # via + # awscli + # boto3 +semver==3.0.4 + # via + # localstack-core + # localstack-core (pyproject.toml) +six==1.17.0 + # via + # airspeed-ext + # jsonpath-rw + # python-dateutil + # rfc3339-validator +sniffio==1.3.1 + # via anyio +sympy==1.14.0 + # via cfn-lint +tailer==0.4.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +typeguard==2.13.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs + # jsii +typing-extensions==4.14.1 + # via + # anyio + # aws-sam-translator + # cfn-lint + # jsii + # localstack-twisted + # pydantic + # pydantic-core + # pyopenssl + # readerwriterlock + # referencing + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +urllib3==2.5.0 + # via + # botocore + # docker + # localstack-core + # opensearch-py + # requests + # responses +websocket-client==1.8.0 + # via localstack-core (pyproject.toml) +werkzeug==3.1.3 + # via + # localstack-core + # moto-ext + # openapi-core + # pytest-httpserver + # rolo +wrapt==1.17.2 + # via aws-xray-sdk +wsproto==1.2.0 + # via hypercorn +xmltodict==0.14.2 + # via + # localstack-core + # moto-ext +zope-interface==7.2 + # via localstack-twisted + +# The following packages are considered to be unsafe in a requirements file: +# localstack-core +# setuptools diff --git a/requirements-typehint.txt b/requirements-typehint.txt new file mode 100644 index 0000000000000..17d89ce33488d --- /dev/null +++ b/requirements-typehint.txt @@ -0,0 +1,834 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --extra=typehint --output-file=requirements-typehint.txt --strip-extras --unsafe-package=distribute --unsafe-package=localstack-core --unsafe-package=pip --unsafe-package=setuptools pyproject.toml +# +airspeed-ext==0.6.9 + # via localstack-core +annotated-types==0.7.0 + # via pydantic +antlr4-python3-runtime==4.13.2 + # via + # localstack-core + # moto-ext +anyio==4.9.0 + # via httpx +apispec==6.8.2 + # via localstack-core +argparse==1.4.0 + # via kclpy-ext +attrs==25.3.0 + # via + # cattrs + # jsii + # jsonschema + # localstack-twisted + # referencing +aws-cdk-asset-awscli-v1==2.2.242 + # via aws-cdk-lib +aws-cdk-asset-node-proxy-agent-v6==2.1.0 + # via aws-cdk-lib +aws-cdk-cloud-assembly-schema==45.2.0 + # via aws-cdk-lib +aws-cdk-lib==2.204.0 + # via localstack-core +aws-sam-translator==1.99.0 + # via + # cfn-lint + # localstack-core +aws-xray-sdk==2.14.0 + # via moto-ext +awscli==1.41.4 + # via localstack-core +awscrt==0.27.4 + # via localstack-core +boto3==1.39.4 + # via + # aws-sam-translator + # kclpy-ext + # localstack-core + # moto-ext +boto3-stubs==1.39.4 + # via localstack-core (pyproject.toml) +botocore==1.39.4 + # via + # aws-xray-sdk + # awscli + # boto3 + # localstack-core + # moto-ext + # s3transfer +botocore-stubs==1.38.46 + # via boto3-stubs +build==1.2.2.post1 + # via + # localstack-core + # localstack-core (pyproject.toml) +cachetools==6.1.0 + # via + # airspeed-ext + # localstack-core + # localstack-core (pyproject.toml) +cattrs==24.1.3 + # via jsii +cbor2==5.6.5 + # via localstack-core +certifi==2025.7.14 + # via + # httpcore + # httpx + # opensearch-py + # requests +cffi==1.17.1 + # via cryptography +cfgv==3.4.0 + # via pre-commit +cfn-lint==1.38.0 + # via moto-ext +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +colorama==0.4.6 + # via awscli +constantly==23.10.4 + # via localstack-twisted +constructs==10.4.2 + # via aws-cdk-lib +coverage==7.9.2 + # via + # coveralls + # localstack-core +coveralls==4.0.1 + # via localstack-core +crontab==1.0.5 + # via localstack-core +cryptography==45.0.5 + # via + # joserfc + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # pyopenssl +cython==3.1.2 + # via localstack-core +decorator==5.2.1 + # via jsonpath-rw +deepdiff==8.5.0 + # via + # localstack-core + # localstack-snapshot +dill==0.3.6 + # via + # localstack-core + # localstack-core (pyproject.toml) +distlib==0.3.9 + # via virtualenv +dnslib==0.9.26 + # via + # localstack-core + # localstack-core (pyproject.toml) +dnspython==2.7.0 + # via + # localstack-core + # localstack-core (pyproject.toml) + # pymongo +docker==7.1.0 + # via + # localstack-core + # moto-ext +docopt==0.6.2 + # via coveralls +docutils==0.19 + # via awscli +events==0.5 + # via opensearch-py +filelock==3.18.0 + # via virtualenv +graphql-core==3.2.6 + # via moto-ext +h11==0.16.0 + # via + # httpcore + # hypercorn + # wsproto +h2==4.2.0 + # via + # httpx + # hypercorn + # localstack-twisted +hpack==4.1.0 + # via h2 +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via localstack-core +hypercorn==0.17.3 + # via localstack-core +hyperframe==6.1.0 + # via h2 +hyperlink==21.0.0 + # via localstack-twisted +identify==2.6.12 + # via pre-commit +idna==3.10 + # via + # anyio + # httpx + # hyperlink + # localstack-twisted + # requests +importlib-resources==6.5.2 + # via jsii +incremental==24.7.2 + # via localstack-twisted +iniconfig==2.1.0 + # via pytest +isodate==0.7.2 + # via openapi-core +jinja2==3.1.6 + # via moto-ext +jmespath==1.0.1 + # via + # boto3 + # botocore +joserfc==1.2.2 + # via moto-ext +jpype1-ext==0.0.2 + # via localstack-core +jsii==1.112.0 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs +json5==0.12.0 + # via localstack-core +jsonpatch==1.33 + # via + # cfn-lint + # localstack-core +jsonpath-ng==1.7.0 + # via + # localstack-core + # localstack-snapshot + # moto-ext +jsonpath-rw==1.4.0 + # via localstack-core +jsonpointer==3.0.0 + # via jsonpatch +jsonschema==4.24.0 + # via + # aws-sam-translator + # moto-ext + # openapi-core + # openapi-schema-validator + # openapi-spec-validator +jsonschema-path==0.3.4 + # via + # openapi-core + # openapi-spec-validator +jsonschema-specifications==2025.4.1 + # via + # jsonschema + # openapi-schema-validator +kclpy-ext==3.0.5 + # via localstack-core +lazy-object-proxy==1.11.0 + # via openapi-spec-validator +localstack-snapshot==0.3.0 + # via localstack-core +localstack-twisted==24.3.0 + # via localstack-core +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.2 + # via + # jinja2 + # werkzeug +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.7.0 + # via openapi-core +moto-ext==5.1.6.post2 + # via localstack-core +mpmath==1.3.0 + # via sympy +multipart==1.2.1 + # via moto-ext +mypy==1.17.0 + # via localstack-core +mypy-boto3-acm==1.39.0 + # via boto3-stubs +mypy-boto3-acm-pca==1.39.0 + # via boto3-stubs +mypy-boto3-amplify==1.39.0 + # via boto3-stubs +mypy-boto3-apigateway==1.39.0 + # via boto3-stubs +mypy-boto3-apigatewayv2==1.39.0 + # via boto3-stubs +mypy-boto3-appconfig==1.39.0 + # via boto3-stubs +mypy-boto3-appconfigdata==1.39.0 + # via boto3-stubs +mypy-boto3-application-autoscaling==1.39.0 + # via boto3-stubs +mypy-boto3-appsync==1.39.0 + # via boto3-stubs +mypy-boto3-athena==1.39.0 + # via boto3-stubs +mypy-boto3-autoscaling==1.39.0 + # via boto3-stubs +mypy-boto3-backup==1.39.0 + # via boto3-stubs +mypy-boto3-batch==1.39.0 + # via boto3-stubs +mypy-boto3-ce==1.39.0 + # via boto3-stubs +mypy-boto3-cloudcontrol==1.39.0 + # via boto3-stubs +mypy-boto3-cloudformation==1.39.0 + # via boto3-stubs +mypy-boto3-cloudfront==1.39.0 + # via boto3-stubs +mypy-boto3-cloudtrail==1.39.0 + # via boto3-stubs +mypy-boto3-cloudwatch==1.39.0 + # via boto3-stubs +mypy-boto3-codebuild==1.39.0 + # via boto3-stubs +mypy-boto3-codecommit==1.39.0 + # via boto3-stubs +mypy-boto3-codeconnections==1.39.0 + # via boto3-stubs +mypy-boto3-codedeploy==1.39.0 + # via boto3-stubs +mypy-boto3-codepipeline==1.39.0 + # via boto3-stubs +mypy-boto3-codestar-connections==1.39.0 + # via boto3-stubs +mypy-boto3-cognito-identity==1.39.0 + # via boto3-stubs +mypy-boto3-cognito-idp==1.39.0 + # via boto3-stubs +mypy-boto3-dms==1.39.0 + # via boto3-stubs +mypy-boto3-docdb==1.39.0 + # via boto3-stubs +mypy-boto3-dynamodb==1.39.0 + # via boto3-stubs +mypy-boto3-dynamodbstreams==1.39.0 + # via boto3-stubs +mypy-boto3-ec2==1.39.4 + # via boto3-stubs +mypy-boto3-ecr==1.39.0 + # via boto3-stubs +mypy-boto3-ecs==1.39.0 + # via boto3-stubs +mypy-boto3-efs==1.39.0 + # via boto3-stubs +mypy-boto3-eks==1.39.0 + # via boto3-stubs +mypy-boto3-elasticache==1.39.0 + # via boto3-stubs +mypy-boto3-elasticbeanstalk==1.39.0 + # via boto3-stubs +mypy-boto3-elbv2==1.39.0 + # via boto3-stubs +mypy-boto3-emr==1.39.0 + # via boto3-stubs +mypy-boto3-emr-serverless==1.39.0 + # via boto3-stubs +mypy-boto3-es==1.39.0 + # via boto3-stubs +mypy-boto3-events==1.39.0 + # via boto3-stubs +mypy-boto3-firehose==1.39.0 + # via boto3-stubs +mypy-boto3-fis==1.39.0 + # via boto3-stubs +mypy-boto3-glacier==1.39.0 + # via boto3-stubs +mypy-boto3-glue==1.39.0 + # via boto3-stubs +mypy-boto3-iam==1.39.0 + # via boto3-stubs +mypy-boto3-identitystore==1.39.0 + # via boto3-stubs +mypy-boto3-iot==1.39.0 + # via boto3-stubs +mypy-boto3-iot-data==1.39.0 + # via boto3-stubs +mypy-boto3-iotanalytics==1.39.0 + # via boto3-stubs +mypy-boto3-iotwireless==1.39.0 + # via boto3-stubs +mypy-boto3-kafka==1.39.0 + # via boto3-stubs +mypy-boto3-kinesis==1.39.0 + # via boto3-stubs +mypy-boto3-kinesisanalytics==1.39.0 + # via boto3-stubs +mypy-boto3-kinesisanalyticsv2==1.39.0 + # via boto3-stubs +mypy-boto3-kms==1.39.0 + # via boto3-stubs +mypy-boto3-lakeformation==1.39.0 + # via boto3-stubs +mypy-boto3-lambda==1.39.0 + # via boto3-stubs +mypy-boto3-logs==1.39.0 + # via boto3-stubs +mypy-boto3-managedblockchain==1.39.0 + # via boto3-stubs +mypy-boto3-mediaconvert==1.39.0 + # via boto3-stubs +mypy-boto3-mediastore==1.39.0 + # via boto3-stubs +mypy-boto3-mq==1.39.0 + # via boto3-stubs +mypy-boto3-mwaa==1.39.0 + # via boto3-stubs +mypy-boto3-neptune==1.39.0 + # via boto3-stubs +mypy-boto3-opensearch==1.39.0 + # via boto3-stubs +mypy-boto3-organizations==1.39.0 + # via boto3-stubs +mypy-boto3-pi==1.39.0 + # via boto3-stubs +mypy-boto3-pinpoint==1.39.0 + # via boto3-stubs +mypy-boto3-pipes==1.39.0 + # via boto3-stubs +mypy-boto3-qldb==1.39.0 + # via boto3-stubs +mypy-boto3-qldb-session==1.39.0 + # via boto3-stubs +mypy-boto3-rds==1.39.1 + # via boto3-stubs +mypy-boto3-rds-data==1.39.0 + # via boto3-stubs +mypy-boto3-redshift==1.39.0 + # via boto3-stubs +mypy-boto3-redshift-data==1.39.0 + # via boto3-stubs +mypy-boto3-resource-groups==1.39.0 + # via boto3-stubs +mypy-boto3-resourcegroupstaggingapi==1.39.0 + # via boto3-stubs +mypy-boto3-route53==1.39.3 + # via boto3-stubs +mypy-boto3-route53resolver==1.39.0 + # via boto3-stubs +mypy-boto3-s3==1.39.2 + # via boto3-stubs +mypy-boto3-s3control==1.39.2 + # via boto3-stubs +mypy-boto3-sagemaker==1.39.3 + # via boto3-stubs +mypy-boto3-sagemaker-runtime==1.39.0 + # via boto3-stubs +mypy-boto3-secretsmanager==1.39.0 + # via boto3-stubs +mypy-boto3-serverlessrepo==1.39.0 + # via boto3-stubs +mypy-boto3-servicediscovery==1.39.0 + # via boto3-stubs +mypy-boto3-ses==1.39.0 + # via boto3-stubs +mypy-boto3-sesv2==1.39.0 + # via boto3-stubs +mypy-boto3-sns==1.39.0 + # via boto3-stubs +mypy-boto3-sqs==1.39.0 + # via boto3-stubs +mypy-boto3-ssm==1.39.0 + # via boto3-stubs +mypy-boto3-sso-admin==1.39.0 + # via boto3-stubs +mypy-boto3-stepfunctions==1.39.0 + # via boto3-stubs +mypy-boto3-sts==1.39.0 + # via boto3-stubs +mypy-boto3-timestream-query==1.39.0 + # via boto3-stubs +mypy-boto3-timestream-write==1.39.0 + # via boto3-stubs +mypy-boto3-transcribe==1.39.0 + # via boto3-stubs +mypy-boto3-verifiedpermissions==1.39.0 + # via boto3-stubs +mypy-boto3-wafv2==1.39.0 + # via boto3-stubs +mypy-boto3-xray==1.39.0 + # via boto3-stubs +mypy-extensions==1.1.0 + # via mypy +networkx==3.5 + # via + # cfn-lint + # localstack-core +nodeenv==1.9.1 + # via pre-commit +openapi-core==0.19.4 + # via localstack-core +openapi-schema-validator==0.6.3 + # via + # openapi-core + # openapi-spec-validator +openapi-spec-validator==0.7.2 + # via + # localstack-core + # moto-ext + # openapi-core +opensearch-py==3.0.0 + # via localstack-core +orderly-set==5.5.0 + # via deepdiff +packaging==25.0 + # via + # apispec + # build + # jpype1-ext + # pytest + # pytest-rerunfailures +pandoc==2.4 + # via localstack-core +parse==1.20.2 + # via openapi-core +pathable==0.4.4 + # via jsonschema-path +pathspec==0.12.1 + # via mypy +platformdirs==4.3.8 + # via virtualenv +pluggy==1.6.0 + # via + # localstack-core + # pytest +plumbum==1.9.0 + # via pandoc +plux==1.12.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +ply==3.11 + # via + # jsonpath-ng + # jsonpath-rw + # pandoc +pre-commit==4.2.0 + # via localstack-core +priority==1.3.0 + # via + # hypercorn + # localstack-twisted +psutil==7.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +publication==0.0.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs + # jsii +py-partiql-parser==0.6.1 + # via moto-ext +pyasn1==0.6.1 + # via rsa +pycparser==2.22 + # via cffi +pydantic==2.11.7 + # via aws-sam-translator +pydantic-core==2.33.2 + # via pydantic +pygments==2.19.2 + # via + # pytest + # rich +pymongo==4.13.2 + # via localstack-core +pyopenssl==25.1.0 + # via + # localstack-core + # localstack-twisted +pypandoc==1.15 + # via localstack-core +pyparsing==3.2.3 + # via moto-ext +pyproject-hooks==1.2.0 + # via build +pytest==8.4.1 + # via + # localstack-core + # pytest-rerunfailures + # pytest-split + # pytest-tinybird +pytest-httpserver==1.1.3 + # via localstack-core +pytest-rerunfailures==15.1 + # via localstack-core +pytest-split==0.10.0 + # via localstack-core +pytest-tinybird==0.5.0 + # via localstack-core +python-dateutil==2.9.0.post0 + # via + # botocore + # jsii + # moto-ext + # opensearch-py +python-dotenv==1.1.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +pyyaml==6.0.2 + # via + # awscli + # cfn-lint + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # pre-commit + # responses +readerwriterlock==1.0.9 + # via localstack-core +referencing==0.36.2 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications +regex==2024.11.6 + # via cfn-lint +requests==2.32.4 + # via + # coveralls + # docker + # jsonschema-path + # localstack-core + # localstack-core (pyproject.toml) + # moto-ext + # opensearch-py + # pytest-tinybird + # requests-aws4auth + # responses + # rolo +requests-aws4auth==1.3.1 + # via localstack-core +responses==0.25.7 + # via moto-ext +rfc3339-validator==0.1.4 + # via openapi-schema-validator +rich==14.0.0 + # via + # localstack-core + # localstack-core (pyproject.toml) +rolo==0.7.6 + # via localstack-core +rpds-py==0.26.0 + # via + # jsonschema + # referencing +rsa==4.7.2 + # via awscli +rstr==3.2.2 + # via localstack-core +ruff==0.12.3 + # via localstack-core +s3transfer==0.13.0 + # via + # awscli + # boto3 +semver==3.0.4 + # via + # localstack-core + # localstack-core (pyproject.toml) +six==1.17.0 + # via + # airspeed-ext + # jsonpath-rw + # python-dateutil + # rfc3339-validator +sniffio==1.3.1 + # via anyio +sympy==1.14.0 + # via cfn-lint +tailer==0.4.1 + # via + # localstack-core + # localstack-core (pyproject.toml) +typeguard==2.13.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # constructs + # jsii +types-awscrt==0.27.4 + # via botocore-stubs +types-s3transfer==0.13.0 + # via boto3-stubs +typing-extensions==4.14.1 + # via + # anyio + # aws-sam-translator + # boto3-stubs + # cfn-lint + # jsii + # localstack-twisted + # mypy + # mypy-boto3-acm + # mypy-boto3-acm-pca + # mypy-boto3-amplify + # mypy-boto3-apigateway + # mypy-boto3-apigatewayv2 + # mypy-boto3-appconfig + # mypy-boto3-appconfigdata + # mypy-boto3-application-autoscaling + # mypy-boto3-appsync + # mypy-boto3-athena + # mypy-boto3-autoscaling + # mypy-boto3-backup + # mypy-boto3-batch + # mypy-boto3-ce + # mypy-boto3-cloudcontrol + # mypy-boto3-cloudformation + # mypy-boto3-cloudfront + # mypy-boto3-cloudtrail + # mypy-boto3-cloudwatch + # mypy-boto3-codebuild + # mypy-boto3-codecommit + # mypy-boto3-codeconnections + # mypy-boto3-codedeploy + # mypy-boto3-codepipeline + # mypy-boto3-codestar-connections + # mypy-boto3-cognito-identity + # mypy-boto3-cognito-idp + # mypy-boto3-dms + # mypy-boto3-docdb + # mypy-boto3-dynamodb + # mypy-boto3-dynamodbstreams + # mypy-boto3-ec2 + # mypy-boto3-ecr + # mypy-boto3-ecs + # mypy-boto3-efs + # mypy-boto3-eks + # mypy-boto3-elasticache + # mypy-boto3-elasticbeanstalk + # mypy-boto3-elbv2 + # mypy-boto3-emr + # mypy-boto3-emr-serverless + # mypy-boto3-es + # mypy-boto3-events + # mypy-boto3-firehose + # mypy-boto3-fis + # mypy-boto3-glacier + # mypy-boto3-glue + # mypy-boto3-iam + # mypy-boto3-identitystore + # mypy-boto3-iot + # mypy-boto3-iot-data + # mypy-boto3-iotanalytics + # mypy-boto3-iotwireless + # mypy-boto3-kafka + # mypy-boto3-kinesis + # mypy-boto3-kinesisanalytics + # mypy-boto3-kinesisanalyticsv2 + # mypy-boto3-kms + # mypy-boto3-lakeformation + # mypy-boto3-lambda + # mypy-boto3-logs + # mypy-boto3-managedblockchain + # mypy-boto3-mediaconvert + # mypy-boto3-mediastore + # mypy-boto3-mq + # mypy-boto3-mwaa + # mypy-boto3-neptune + # mypy-boto3-opensearch + # mypy-boto3-organizations + # mypy-boto3-pi + # mypy-boto3-pinpoint + # mypy-boto3-pipes + # mypy-boto3-qldb + # mypy-boto3-qldb-session + # mypy-boto3-rds + # mypy-boto3-rds-data + # mypy-boto3-redshift + # mypy-boto3-redshift-data + # mypy-boto3-resource-groups + # mypy-boto3-resourcegroupstaggingapi + # mypy-boto3-route53 + # mypy-boto3-route53resolver + # mypy-boto3-s3 + # mypy-boto3-s3control + # mypy-boto3-sagemaker + # mypy-boto3-sagemaker-runtime + # mypy-boto3-secretsmanager + # mypy-boto3-serverlessrepo + # mypy-boto3-servicediscovery + # mypy-boto3-ses + # mypy-boto3-sesv2 + # mypy-boto3-sns + # mypy-boto3-sqs + # mypy-boto3-ssm + # mypy-boto3-sso-admin + # mypy-boto3-stepfunctions + # mypy-boto3-sts + # mypy-boto3-timestream-query + # mypy-boto3-timestream-write + # mypy-boto3-transcribe + # mypy-boto3-verifiedpermissions + # mypy-boto3-wafv2 + # mypy-boto3-xray + # pydantic + # pydantic-core + # pyopenssl + # readerwriterlock + # referencing + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +urllib3==2.5.0 + # via + # botocore + # docker + # localstack-core + # opensearch-py + # requests + # responses +virtualenv==20.31.2 + # via pre-commit +websocket-client==1.8.0 + # via localstack-core +werkzeug==3.1.3 + # via + # localstack-core + # moto-ext + # openapi-core + # pytest-httpserver + # rolo +wrapt==1.17.2 + # via aws-xray-sdk +wsproto==1.2.0 + # via hypercorn +xmltodict==0.14.2 + # via + # localstack-core + # moto-ext +zope-interface==7.2 + # via localstack-twisted + +# The following packages are considered to be unsafe in a requirements file: +# localstack-core +# setuptools diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4afb01aa81a89..0000000000000 --- a/requirements.txt +++ /dev/null @@ -1,30 +0,0 @@ -# Lines annotated with "#extended-lib" are excluded -# from the dependencies when we build the pip package - -airspeed==0.5.5.dev20160812 -amazon-kclpy==1.4.5 #extended-lib -awscli==1.11.125 -boto==2.46.1 -boto3==1.4.4 -coverage==4.0.3 -docopt==0.6.2 -elasticsearch==5.3.0 -flake8>=3.5.0 -flake8-quotes>=0.11.0 -flask==0.10.1 -flask-cors==3.0.3 -flask_swagger==0.2.12 -jsonpath-rw==1.4.0 -localstack-ext -localstack-client==0.4 -moto-ext==1.1.25 -nose==1.3.7 -psutil==5.2.0 -pyOpenSSL==17.0.0 -python-coveralls==2.7.0 -pyYAML==3.12 -requests==2.18.4 -requests-aws4auth==0.9 -six==1.10.0 -subprocess32-ext==3.2.8.2 -xmltodict==0.10.2 diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000000000..343a25cb84882 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,2 @@ +*.csv +*.json diff --git a/scripts/build_common_test_functions.sh b/scripts/build_common_test_functions.sh new file mode 100755 index 0000000000000..159c583aebe37 --- /dev/null +++ b/scripts/build_common_test_functions.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -e + +COMMON_DIR=$1 +cd $COMMON_DIR + +for scenario in */ ; do + [ -L "${scenario%/}" ] && continue + cd "$scenario" + FULL_SCENARIO_PATH=`pwd` + + for runtime in */ ; do + [ -L "${runtime%/}" ] && continue + + BUILD_PATH="$FULL_SCENARIO_PATH/$runtime" + echo -n "Making ${scenario}.${runtime} in ${BUILD_PATH}: " + cd "$BUILD_PATH" + + # skip if the zip file exists, otherwise run the makefile + [ -f "handler.zip" ] && echo "found handler.zip before building => skip building" && continue + echo -n "building ..." + # MAYBE: consider printing build logs only if the build fails (using CircleCI SSH seems easier for now) + make build >/dev/null + + # if no zipfile, package build folder + [ -f "handler.zip" ] && echo "found handler.zip after building => skip packaging" && continue + echo -n "packaging handler.zip ..." + cd ./build && zip -r ../handler.zip . && cd - + echo "DONE ${scenario}.${runtime}" + done + + cd $COMMON_DIR +done diff --git a/scripts/capture_notimplemented_responses.py b/scripts/capture_notimplemented_responses.py new file mode 100644 index 0000000000000..c8747562a1da5 --- /dev/null +++ b/scripts/capture_notimplemented_responses.py @@ -0,0 +1,385 @@ +import csv +import json +import logging +import re +import sys +import time +import traceback +from datetime import timedelta +from pathlib import Path +from typing import Optional, TypedDict + +import botocore.config +import requests +from botocore.exceptions import ( + ClientError, + ConnectTimeoutError, + EndpointConnectionError, + ReadTimeoutError, +) +from botocore.parsers import ResponseParserError +from rich.console import Console + +from localstack.aws.connect import connect_externally_to +from localstack.aws.mocking import Instance, generate_request +from localstack.aws.spec import ServiceCatalog + +logging.basicConfig(level=logging.INFO) +service_models = ServiceCatalog() + +c = Console() + +STATUS_TIMEOUT_ERROR = 901 +STATUS_PARSING_ERROR = 902 +STATUS_CONNECTION_ERROR = 903 +# dict of operations that should be skipped for a service, currently only contains s3.PostObject (which we added for internal use) +PHANTOM_OPERATIONS = {"s3": ["PostObject"]} + +# will only include available services +response = requests.get("http://localhost:4566/_localstack/health").content.decode("utf-8") +latest_services_pro = [k for k in json.loads(response).get("services").keys()] + +exclude_services = {"azure"} +latest_services_pro = [s for s in latest_services_pro if s not in exclude_services] +latest_services_pro.sort() + + +class RowEntry(TypedDict, total=False): + service: str + operation: str + status_code: int + error_code: str + error_message: str + is_implemented: bool + + +def simulate_call(service: str, op: str) -> RowEntry: + """generates a mock request based on the service and operation model and sends it to the API""" + client = connect_externally_to.get_client( + service, + aws_access_key_id="test", + aws_secret_access_key="test", + config=botocore.config.Config( + parameter_validation=False, + retries={"max_attempts": 0, "total_max_attempts": 1}, + connect_timeout=90, + read_timeout=120, + inject_host_prefix=False, + ), + ) + + service_model = service_models.get(service) + op_model = service_model.operation_model(op) + parameters = generate_request(op_model) or {} + result = _make_api_call(client, service, op, parameters) + error_msg = result.get("error_message", "") + + if result.get("error_code", "") == "InternalError": + # some deeper investigation necessary, check for some common errors here and retry + if service == "apigateway" and "Unexpected HTTP method" in error_msg: + # moto currently raises exception in some requests, if the http method is not supported, meaning it is not implemented + result["status_code"] = 501 # reflect that this is not implemented + elif ( + "localstack.aws.protocol.parser.ProtocolParserError: Unable to parse request (not well-formed (invalid token)" + in error_msg + ): + # parsing errors might be due to invalid parameter values + # try to re-create params + logging.debug( + "ProtocolParserError detected, old parameters used: %s\nre-running request %s.%s with new parameters", + parameters, + service, + op, + ) + parameters = generate_request(op_model) or {} + result = _make_api_call(client, service, op, parameters) + elif "TypeError" in error_msg and "got an unexpected keyword argument" in error_msg: + # sometimes LocalStack's autogenerated API is not yet up-to-date, which could cause + # if we see the 'unexpected keyword argument' error, we can re-try after removing the unknown argument + while match := re.search("got an unexpected keyword argument '(.*)'", error_msg): + keyword = match.groups()[0] + # find the corresponding parameter for the unexpected keyword + if argument := next( + ( + arg + for arg in list(parameters.keys()) + if keyword.replace("_", "") == arg.casefold() + ), + None, + ): + logging.warning( + "Got 'TypeError' with unexpected keyword argument: '%s' for %s.%s. " + "Re-trying without keyword ...", + keyword, + service, + op, + ) + # remove the unexpected keyword and try again + parameters.pop(argument) + result = _make_api_call(client, service, op, parameters) + + if result.get("error_code", "") != "InternalError": + break + if argument in parameters: + # sometimes the parameter seem to be automatically added again by boto + # happened eg for ClientToken in ec2.ProvisionIpamPoolCidr + logging.warning( + "unexpected keyword '%s' was added to the parameters again for: %s.%s", + argument, + service, + op, + ) + break + error_msg = result.get("error_message", "") + else: + # keyword argument not found in the parameters + break + elif result.get("error_code", "") == "UnsupportedOperation" and service == "stepfunctions": + # the stepfunction lib returns 500 for not implemented + UnsupportedOperation + result["status_code"] = 501 # reflect that this is not implemented + if result.get("status_code") in [0, 901, 902, 903]: + # something went wrong, we do not know exactly what/why - just try again one more time + logging.debug( + "Detected invalid status code %i for %s.%s. Re-running request with new parameters", + result.get("status_code"), + service, + op, + ) + parameters = generate_request(op_model) or {} # should be generate_parameters I guess + result = _make_api_call(client, service, op, parameters) + return result + + +def _make_api_call(client, service: str, op: str, parameters: Optional[Instance]): + result = RowEntry(service=service, operation=op, status_code=0) + try: + response = client._make_api_call(op, parameters) + result["status_code"] = response["ResponseMetadata"]["HTTPStatusCode"] + except ClientError as ce: + result["status_code"] = ce.response["ResponseMetadata"]["HTTPStatusCode"] + result["error_code"] = ce.response.get("Error", {}).get("Code", "Unknown?") + result["error_message"] = ce.response.get("Error", {}).get("Message", "Unknown?") + except (ReadTimeoutError, ConnectTimeoutError) as e: + logging.warning("Reached timeout for %s.%s. Assuming it is implemented.", service, op) + logging.exception(e) + result["status_code"] = STATUS_TIMEOUT_ERROR + result["error_message"] = traceback.format_exception(e) + except EndpointConnectionError as e: + # TODO: investigate further;for now assuming not implemented + logging.warning("Connection failed for %s.%s. Assuming it is not implemented.", service, op) + logging.exception(e) + result["status_code"] = STATUS_CONNECTION_ERROR + result["error_message"] = traceback.format_exception(e) + except ResponseParserError as e: + # TODO: this is actually a bit tricky and might have to be handled on a service by service basis again + logging.warning("Parsing issue for %s.%s. Assuming it isn't implemented.", service, op) + logging.exception(e) + logging.warning("%s.%s: used parameters %s", service, op, parameters) + result["status_code"] = STATUS_PARSING_ERROR + result["error_message"] = traceback.format_exception(e) + except Exception as e: + logging.warning("Unknown Exception for %s.%s", service, op) + logging.exception(e) + logging.warning("%s.%s: used parameters %s", service, op, parameters) + result["error_message"] = traceback.format_exception(e) + return result + + +def map_to_notimplemented(row: RowEntry) -> bool: + """ + Some simple heuristics to check the API responses and classify them into implemented/notimplemented + + Ideally they all should behave the same way when receiving requests for not yet implemented endpoints + (501 with a "not yet implemented" message) + + :param row: the RowEntry + :return: True if we assume it is not implemented, False otherwise + """ + if row["status_code"] in [STATUS_PARSING_ERROR]: + # parsing issues are nearly always due to something not being implemented or activated + return True + if row["status_code"] in [STATUS_TIMEOUT_ERROR]: + # timeout issue, interpreted as implemented until there's a better heuristic + return False + if row["status_code"] == STATUS_CONNECTION_ERROR: + return True + if ( + row["service"] == "cloudfront" + and row["status_code"] == 500 + and row.get("error_code") == "500" + and row.get("error_message", "").lower() == "internal server error" + ): + return True + if row["service"] == "dynamodb" and row.get("error_code") == "UnknownOperationException": + return True + if row["service"] == "lambda" and row["status_code"] == 404 and row.get("error_code") == "404": + return True + if ( + row["service"] + in [ + "route53", + "s3control", + ] + and row["status_code"] == 404 + and row.get("error_code") == "404" + and row.get("error_message") is not None + and "not found" == row.get("error_message", "").lower() + ): + return True + if ( + row["service"] in ["xray", "batch", "glacier", "resource-groups", "apigateway"] + and row["status_code"] == 404 + and row.get("error_message") is not None + and "The requested URL was not found on the server" in row.get("error_message") + ): + return True + if ( + row["status_code"] == 501 + and row.get("error_message") is not None + and "not yet implemented" in row.get("error_message", "") + ): + return True + if row.get("error_message") is not None and "not yet implemented" in row.get( + "error_message", "" + ): + return True + if row["status_code"] == 501: + return True + if ( + row["status_code"] == 500 + and row.get("error_code") == "500" + and not row.get("error_message") + ): + return True + return False + + +def run_script(services: list[str], path: None): + """send requests against all APIs""" + print( + f"writing results to '{path}implementation_coverage_full.csv' and '{path}implementation_coverage_aggregated.csv'..." + ) + with ( + open(f"{path}implementation_coverage_full.csv", "w") as csvfile, + open(f"{path}implementation_coverage_aggregated.csv", "w") as aggregatefile, + ): + full_w = csv.DictWriter( + csvfile, + fieldnames=[ + "service", + "operation", + "status_code", + "error_code", + "error_message", + "is_implemented", + ], + ) + aggregated_w = csv.DictWriter( + aggregatefile, + fieldnames=["service", "implemented_count", "full_count", "percentage"], + ) + + full_w.writeheader() + aggregated_w.writeheader() + + total_count = 0 + for service_name in services: + service = service_models.get(service_name) + for op_name in service.operation_names: + if op_name in PHANTOM_OPERATIONS.get(service_name, []): + continue + total_count += 1 + + time_start = time.perf_counter_ns() + counter = 0 + responses = {} + for service_name in services: + c.print(f"\n===== {service_name} =====") + service = service_models.get(service_name) + for op_name in service.operation_names: + if op_name in PHANTOM_OPERATIONS.get(service_name, []): + continue + counter += 1 + c.print( + f"{100 * counter / total_count:3.1f}% | Calling endpoint {counter:4.0f}/{total_count}: {service_name}.{op_name}" + ) + + # here's the important part (the actual service call!) + response = simulate_call(service_name, op_name) + + responses.setdefault(service_name, {})[op_name] = response + is_implemented = str(not map_to_notimplemented(response)) + full_w.writerow(response | {"is_implemented": is_implemented}) + + # calculate aggregate for service + all_count = len(responses[service_name].values()) + implemented_count = len( + [r for r in responses[service_name].values() if not map_to_notimplemented(r)] + ) + implemented_percentage = implemented_count / all_count + + aggregated_w.writerow( + { + "service": response["service"], + "implemented_count": implemented_count, + "full_count": all_count, + "percentage": f"{implemented_percentage * 100:.1f}", + } + ) + time_end = time.perf_counter_ns() + delta = timedelta(microseconds=(time_end - time_start) / 1000.0) + c.print(f"\n\nDone.\nTotal time to completion: {delta}") + + +def calculate_percentages(): + aggregate = {} + + implemented_aggregate = {} + aggregate_list = [] + + with open("./output-notimplemented.csv", "r") as fd: + reader = csv.DictReader(fd, fieldnames=["service", "operation", "implemented"]) + for line in reader: + if line["implemented"] == "implemented": + continue + aggregate.setdefault(line["service"], {}).setdefault(line["operation"], line) + + for service in aggregate.keys(): + vals = aggregate[service].values() + all_count = len(vals) + implemented_count = len([v for v in vals if v["implemented"] == "True"]) + implemented_aggregate[service] = implemented_count / all_count + aggregate_list.append( + { + "service": service, + "count": all_count, + "implemented": implemented_count, + "percentage": implemented_count / all_count, + } + ) + + aggregate_list.sort(key=lambda k: k["percentage"]) + + with open("implementation_coverage_aggregated.csv", "w") as csv_fd: + writer = csv.DictWriter( + csv_fd, fieldnames=["service", "percentage", "implemented", "count"] + ) + writer.writeheader() + + for agg in aggregate_list: + agg["percentage"] = f"{agg['percentage'] * 100:.1f}" + writer.writerow(agg) + + +# @click.command() +def main(): + path = "./" + if len(sys.argv) > 1 and Path(sys.argv[1]).is_dir(): + path = sys.argv[1] + if not path.endswith("/"): + path += "/" + run_script(latest_services_pro, path=path) + + +if __name__ == "__main__": + main() diff --git a/scripts/gather_outdated_snapshots.py b/scripts/gather_outdated_snapshots.py new file mode 100644 index 0000000000000..817ab45a4b661 --- /dev/null +++ b/scripts/gather_outdated_snapshots.py @@ -0,0 +1,104 @@ +import datetime +import json +import os + +import click + + +def get_outdated_snapshots_for_directory( + path: str, + date_limit: str, + check_sub_directories: bool = True, + combine_parametrized=True, + show_date=False, +) -> dict: + """ + Fetches all snapshots that were recorded before the given date_limit + :param path: The directory where to look for snapshot files. + :param date_limit: All snapshots whose recorded-date is older than date-limit are considered outdated. + Format of the date-string must be "DD-MM-YYYY". + :param check_sub_directories: Whether to look for snapshots in subdirectories + :param combine_parametrized: Whether to combine versions of the same test and treat them as the same or not + :return: List of test names whose snapshots (if any) are outdated. + """ + + result = {"date": date_limit} + date_limit = datetime.datetime.strptime(date_limit, "%d-%m-%Y").timestamp() + outdated_snapshots = {} + + def do_get_outdated_snapshots(path: str): + if not path.endswith("/"): + path = f"{path}/" + for file in os.listdir(path): + if os.path.isdir(f"{path}{file}") and check_sub_directories: + do_get_outdated_snapshots(f"{path}{file}") + elif file.endswith(".validation.json"): + with open(f"{path}{file}") as f: + json_content: dict = json.load(f) + for name, recorded_snapshot_data in json_content.items(): + recorded_date = recorded_snapshot_data.get("last_validated_date") + date = datetime.datetime.fromisoformat(recorded_date) + if date.timestamp() < date_limit: + outdated_snapshot_data = dict() + if show_date: + outdated_snapshot_data["last_validation_date"] = recorded_date + if combine_parametrized: + # change parametrized tests of the form to just + name = name.split("[")[0] + outdated_snapshots[name] = outdated_snapshot_data + + do_get_outdated_snapshots(path) + result["count"] = len(outdated_snapshots) + result["outdated_tests"] = outdated_snapshots + return result + + +@click.command() +@click.argument("path", type=str, required=True) +@click.argument("date_limit", type=str, required=True) +@click.option( + "--check-sub-dirs", + type=bool, + required=False, + default=True, + help="Whether to check sub directories of PATH too", +) +@click.option( + "--combine-parametrized", + type=bool, + required=False, + default=True, + help="If True, parametrized snapshots are treated as one", +) +@click.option( + "--show-date", + type=bool, + required=False, + default=False, + help="Should tests have their recording date attached?", +) +def get_snapshots(path: str, date_limit: str, check_sub_dirs, combine_parametrized, show_date): + """ + Fetches all snapshots in PATH that were recorded before the given DATE_LIMIT. + Format of the DATE_LIMIT-string must be "DD-MM-YYYY". + + Returns a JSON with the relevant information + + \b + Example usage: + python gather_outdated_snapshots.py ../tests/integration 24-12-2022 | jq . + """ + snapshots = get_outdated_snapshots_for_directory( + path, date_limit, check_sub_dirs, combine_parametrized, show_date + ) + # sorted lists are prettier to read in the console + snapshots["outdated_tests"] = dict(sorted(snapshots["outdated_tests"].items())) + + # turn the list of snapshots into a whitespace separated string usable by pytest + join = " ".join(snapshots["outdated_tests"]) + snapshots["pytest_executable_list"] = join + print(json.dumps(snapshots, default=str)) + + +if __name__ == "__main__": + get_snapshots() diff --git a/scripts/generate_minimal_boto3stubs_install.py b/scripts/generate_minimal_boto3stubs_install.py new file mode 100644 index 0000000000000..176e3b28a9320 --- /dev/null +++ b/scripts/generate_minimal_boto3stubs_install.py @@ -0,0 +1,19 @@ +""" +A simple script to generate a pip install command for all boto3-stubs packages we're currently using in LocalStack +""" + +import os +import re + +if __name__ == "__main__": + with open( + os.path.join( + os.path.dirname(__file__), "../localstack-core/localstack/utils/aws/client_types.py" + ) + ) as fd: + content = fd.read() + result = re.findall(r"\smypy_boto3_([a-z0-9_]+)\s", content) + result = [r.replace("_", "-") for r in set(result)] + result.sort() + + print(f'pip install "boto3-stubs[{",".join(result)}]"', end="") diff --git a/scripts/metrics_coverage/diff_metrics_coverage.py b/scripts/metrics_coverage/diff_metrics_coverage.py new file mode 100644 index 0000000000000..7409582d65471 --- /dev/null +++ b/scripts/metrics_coverage/diff_metrics_coverage.py @@ -0,0 +1,246 @@ +import csv +import os +from pathlib import Path + + +def print_usage(): + print( + """ + Helper script to an output report for the metrics coverage diff. + + Set the env `COVERAGE_DIR_ALL` which points to a folder containing metrics-raw-data reports for the initial tests. + The env `COVERAGE_DIR_ACCEPTANCE` should point to the folder containing metrics-raw-data reports for the acceptance + test suite (usually a subset of the initial tests). + + Use `OUTPUT_DIR` env to set the path where the report will be stored + """ + ) + + +def sort_dict_helper(d): + if isinstance(d, dict): + return {k: sort_dict_helper(v) for k, v in sorted(d.items())} + else: + return d + + +def create_initial_coverage(path_to_initial_metrics: str) -> dict: + """ + Iterates over all csv files in `path_to_initial_metrics` and creates a dict collecting all status_codes that have been + triggered for each service-operation combination: + + { "service_name": + { + "operation_name_1": { "status_code": False}, + "operation_name2": {"status_code_1": False, "status_code_2": False} + }, + "service_name_2": .... + } + :param path_to_initial_metrics: path to the metrics + :returns: Dict + """ + pathlist = Path(path_to_initial_metrics).rglob("*.csv") + coverage = {} + for path in pathlist: + with open(path, "r") as csv_obj: + print(f"Processing integration test coverage metrics: {path}") + csv_dict_reader = csv.DictReader(csv_obj) + for metric in csv_dict_reader: + service = metric.get("service") + operation = metric.get("operation") + response_code = metric.get("response_code") + + service_details = coverage.setdefault(service, {}) + operation_details = service_details.setdefault(operation, {}) + if response_code not in operation_details: + operation_details[response_code] = False + return coverage + + +def mark_coverage_acceptance_test( + path_to_acceptance_metrics: str, coverage_collection: dict +) -> dict: + """ + Iterates over all csv files in `path_to_acceptance_metrics` and updates the information in the `coverage_collection` + dict about which API call was covered by the acceptance metrics + + { "service_name": + { + "operation_name_1": { "status_code": True}, + "operation_name2": {"status_code_1": False, "status_code_2": True} + }, + "service_name_2": .... + } + + If any API calls are identified, that have not been covered with the initial run, those will be collected separately. + Normally, this should never happen, because acceptance tests should be a subset of integrations tests. + Could, however, be useful to identify issues, or when comparing test runs locally. + + :param path_to_acceptance_metrics: path to the metrics + :param coverage_collection: Dict with the coverage collection about the initial test integration run + + :returns: dict with additional recorded coverage, only covered by the acceptance test suite + """ + pathlist = Path(path_to_acceptance_metrics).rglob("*.csv") + additional_tested = {} + add_to_additional = False + for path in pathlist: + with open(path, "r") as csv_obj: + print(f"Processing acceptance test coverage metrics: {path}") + csv_dict_reader = csv.DictReader(csv_obj) + for metric in csv_dict_reader: + service = metric.get("service") + operation = metric.get("operation") + response_code = metric.get("response_code") + + if service not in coverage_collection: + add_to_additional = True + else: + service_details = coverage_collection[service] + if operation not in service_details: + add_to_additional = True + else: + operation_details = service_details.setdefault(operation, {}) + if response_code not in operation_details: + add_to_additional = True + else: + operation_details[response_code] = True + + if add_to_additional: + service_details = additional_tested.setdefault(service, {}) + operation_details = service_details.setdefault(operation, {}) + if response_code not in operation_details: + operation_details[response_code] = True + add_to_additional = False + + return additional_tested + + +def create_readable_report( + coverage_collection: dict, additional_tested_collection: dict, output_dir: str +) -> None: + """ + Helper function to create a very simple HTML view out of the collected metrics. + The file will be named "report_metric_coverage.html" + + :params coverage_collection: the dict with the coverage collection + :params additional_tested_collection: dict with coverage of APIs only for acceptance tests + :params output_dir: the directory where the outcoming html file should be stored to. + """ + service_overview_coverage = """ + + + + + + """ + coverage_details = """ +
ServiceCoverage of Acceptance Tests Suite
+ + + + + + """ + additional_test_details = "" + coverage_collection = sort_dict_helper(coverage_collection) + additional_tested_collection = sort_dict_helper(additional_tested_collection) + for service, operations in coverage_collection.items(): + # count tested operations vs operations that are somehow covered with acceptance + amount_ops = len(operations) + covered_ops = len([op for op, details in operations.items() if any(details.values())]) + percentage_covered = 100 * covered_ops / amount_ops + service_overview_coverage += " \n" + service_overview_coverage += f" \n" + service_overview_coverage += ( + f""" \n""" + ) + service_overview_coverage += " \n" + + for op_name, details in operations.items(): + for response_code, covered in details.items(): + coverage_details += " \n" + coverage_details += f" \n" + coverage_details += f" \n" + coverage_details += f""" \n""" + coverage_details += ( + f""" \n""" + ) + coverage_details += " \n" + if additional_tested_collection: + additional_test_details = """
ServiceOperationReturn CodeCovered By Acceptance Test
{service}{percentage_covered:.2f}%
{service}{op_name}{response_code}{"βœ…" if covered else "❌"}
+ + + + + + """ + for service, operations in additional_tested_collection.items(): + for op_name, details in operations.items(): + for response_code, covered in details.items(): + additional_test_details += " \n" + additional_test_details += f" \n" + additional_test_details += f" \n" + additional_test_details += f" \n" + additional_test_details += f" \n" + additional_test_details += " \n" + additional_test_details += "
ServiceOperationReturn CodeCovered By Acceptance Test
{service}{op_name}{response_code}{'βœ…' if covered else '❌'}

\n" + service_overview_coverage += "
\n" + coverage_details += "
\n" + path = Path(output_dir) + file_name = path.joinpath("report_metric_coverage.html") + with open(file_name, "w") as fd: + fd.write( + """ + + +""" + ) + fd.write("

Diff Report Metrics Coverage

\n") + fd.write("

Service Coverage

\n") + fd.write( + "

Assumption: the initial test suite is considered to have 100% coverage.

\n" + ) + fd.write(f"

{service_overview_coverage}

\n") + fd.write("

Coverage Details

\n") + fd.write(f"
{coverage_details}
") + if additional_test_details: + fd.write("

Additional Test Coverage

\n") + fd.write( + "
Note: this is probalby wrong usage of the script. It includes operations that have been covered with the acceptance tests only" + ) + fd.write(f"

{additional_test_details}

\n") + fd.write("") + + +def main(): + coverage_path_all = os.environ.get("COVERAGE_DIR_ALL") + coverage_path_acceptance = os.environ.get("COVERAGE_DIR_ACCEPTANCE") + output_dir = os.environ.get("OUTPUT_DIR") + + if not coverage_path_all or not coverage_path_acceptance or not output_dir: + print_usage() + return + + print( + f"COVERAGE_DIR_ALL={coverage_path_all}, COVERAGE_DIR_ACCEPTANCE={coverage_path_acceptance}, OUTPUTDIR={output_dir}" + ) + coverage_collection = create_initial_coverage(coverage_path_all) + additional_tested = mark_coverage_acceptance_test(coverage_path_acceptance, coverage_collection) + + if additional_tested: + print( + "WARN: Found tests that are covered by acceptance tests, but haven't been covered by the initial tests" + ) + + create_readable_report(coverage_collection, additional_tested, output_dir) + + +if __name__ == "__main__": + main() diff --git a/scripts/render_marker_report.py b/scripts/render_marker_report.py new file mode 100644 index 0000000000000..2b1e0e1154825 --- /dev/null +++ b/scripts/render_marker_report.py @@ -0,0 +1,141 @@ +""" +This script generates a markdown file with a summary of the current pytest marker usage, +as well as a list of certain markers, their corresponding tests and the CODEOWNERs of these tests. + +It takes a pytest marker report generated by localstack.testing.pytest.marker_report +and extends it with data from the CODEOWNERS file. +The resulting data is processed by using a jinja2 template for the resulting GH issue template. + + +Example on how to run this script manually: + +$ MARKER_REPORT_PATH='./target/marker-report.json' \ + CODEOWNERS_PATH=./CODEOWNERS \ + TEMPLATE_PATH=./.github/bot_templates/MARKER_REPORT_ISSUE.md.j2 \ + OUTPUT_PATH=./target/MARKER_REPORT_ISSUE.md \ + GITHUB_REPO=localstack/localstack \ + COMMIT_SHA=e62e04509d0f950af3027c0f6df4e18c7385c630 \ + python scripts/render_marker_report.py +""" + +import dataclasses +import datetime +import json +import os + +import jinja2 +from codeowners import CodeOwners + + +@dataclasses.dataclass +class EnrichedReportMeta: + timestamp: str + repo_url: str + commit_sha: str + + +@dataclasses.dataclass +class TestEntry: + file_path: str + pytest_node_id: str + owners: list[str] + file_url: str + + +@dataclasses.dataclass +class EnrichedReport: + """an object of this class is passed for template rendering""" + + meta: EnrichedReportMeta + aggregated: dict[str, int] + owners_aws_unknown: list[TestEntry] + owners_aws_needs_fixing: list[TestEntry] + + +def load_file(filepath: str) -> str: + with open(filepath, "r") as fd: + return fd.read() + + +def load_codeowners(codeowners_path): + return CodeOwners(load_file(codeowners_path)) + + +def render_template(*, template: str, enriched_report: EnrichedReport) -> str: + return jinja2.Template(source=template).render(data=enriched_report) + + +def create_test_entry(entry, *, code_owners: CodeOwners, commit_sha: str, github_repo: str): + base_dir = github_repo.split("/")[-1] + + rel_path = entry["file_path"].split(base_dir)[-1].removeprefix("/") + + return TestEntry( + pytest_node_id=entry["node_id"], + file_path=rel_path, + owners=[o[1] for o in code_owners.of(rel_path)] or ["?"], + file_url=f"https://github.com/{github_repo}/blob/{commit_sha}/{rel_path}", + ) + + +def enrich_with_codeowners( + *, input_data: dict, github_repo: str, commit_sha: str, code_owners: CodeOwners +) -> EnrichedReport: + return EnrichedReport( + meta=EnrichedReportMeta( + timestamp=datetime.datetime.utcnow().isoformat(), + repo_url=f"https://github.com/{github_repo}", + commit_sha=commit_sha, + ), + aggregated={ + k: v for k, v in input_data["aggregated_report"].items() if k.startswith("aws_") + }, + owners_aws_unknown=sorted( + [ + create_test_entry( + e, code_owners=code_owners, github_repo=github_repo, commit_sha=commit_sha + ) + for e in input_data["entries"] + if "aws_unknown" in e["markers"] + ], + key=lambda x: x.file_path, + ), + owners_aws_needs_fixing=sorted( + [ + create_test_entry( + e, code_owners=code_owners, github_repo=github_repo, commit_sha=commit_sha + ) + for e in input_data["entries"] + if "aws_needs_fixing" in e["markers"] + ], + key=lambda x: x.file_path, + ), + ) + + +def main(): + marker_report_path = os.environ["MARKER_REPORT_PATH"] + codeowners_path = os.environ["CODEOWNERS_PATH"] + template_path = os.environ["TEMPLATE_PATH"] + output_path = os.environ["OUTPUT_PATH"] + github_repo = os.environ["GITHUB_REPO"] + commit_sha = os.environ["COMMIT_SHA"] + + code_owners = CodeOwners(load_file(codeowners_path)) + marker_report = json.loads(load_file(marker_report_path)) + + enriched_report = enrich_with_codeowners( + input_data=marker_report, + github_repo=github_repo, + commit_sha=commit_sha, + code_owners=code_owners, + ) + rendered_markdown = render_template( + template=load_file(template_path), enriched_report=enriched_report + ) + with open(output_path, "wt") as outfile: + outfile.write(rendered_markdown) + + +if __name__ == "__main__": + main() diff --git a/scripts/tinybird/retrieve_legacy_data_from_circleci.py b/scripts/tinybird/retrieve_legacy_data_from_circleci.py new file mode 100644 index 0000000000000..acaefd0f1e166 --- /dev/null +++ b/scripts/tinybird/retrieve_legacy_data_from_circleci.py @@ -0,0 +1,252 @@ +"""Helper script to retrieve historical data and load into tinybird parity dashboard + +The script is intended to be run locally. It was executed once, to retrieve the data from the past successful master builds +in order to get more data into the parity dashboard for a hackathon project. + +""" + +import datetime +import http.client +import json +import os +import urllib + +from scripts.tinybird.upload_raw_test_metrics_and_coverage import ( + send_implemented_coverage, + send_metric_report, +) + +PROJECT_SLUG = "github/localstack/localstack" +MASTER_BRANCH = "master" + + +def send_request_to_connection(conn, url): + print(f"sending request to url: {url}") + headers = {"accept": "application/json"} # , "Circle-Token": api_token} + conn.request( + "GET", + url=url, + headers=headers, + ) + + res = conn.getresponse() + if res.getcode() == 200: + data = res.read() + return data + else: + print(f"connection failed: {res.getcode}") + return None + + +def extract_artifacts_url_for_path(artifacts, path): + data_url = [item["url"] for item in artifacts["items"] if item["path"].startswith(path)] + if len(data_url) != 1: + print(f"unexpected artifacts count for {path}, unexpected content: {data_url}") + return None + return data_url[0] + + +def collect_workflows_past_30_days(): + """ + Retrieves the workflows run from the past 30 days from circecli on 'master' branch, + and retrieves the artifacts for each successful workflow run, that are collected in the 'report' job. + The artifacts for coverage implementation, and raw-data collection are downloaded, and then processed and sent to + tinybird backend. + """ + try: + conn = http.client.HTTPSConnection("circleci.com") + # api_token = os.getenv("API_TOKEN") + + end = datetime.datetime.utcnow() + start = end - datetime.timedelta(days=30) + + get_workflows_request = f"/api/v2/insights/{PROJECT_SLUG}/workflows/main?&branch={MASTER_BRANCH}&start-date={start.isoformat()}&end-date={end.isoformat()}" + + data = send_request_to_connection(conn, get_workflows_request) + + if not data: + print(f"could not resolve {get_workflows_request}") + return + + # this is just for tracking the current status - we already uploaded data for all of these workflows-ids: + already_sent = [ + "0b4e29e5-b6c2-42b6-8f2d-9bbd3d3bc8aa", + "3780cc96-10a0-4c41-9b5a-98d16b83dd94", + "7ec971e9-4ee2-4269-857e-f3641961ecde", + "3e02b8c5-6c9b-40d0-84df-c4e2d0a7797d", + "015202d7-5071-4773-b223-854ccffe969f", + "c8dd0d5d-b00c-4507-9129-669c3cc9f55a", + "a87bf4f8-3adb-4d0a-b11c-32c0a3318ee9", + "0b1a2ddb-ed17-426c-ba0c-23c4771ecb22", + "97d01dac-15a1-4791-8e90-ce1fed09538d", + "83fb8b2f-dab2-465f-be52-83342820f448", + "2ae81ec5-2d18-48bf-b4ad-6bed8309f281", + "63aa8ee8-4242-43fa-8408-4720c8fdd04b", + "32c09e00-0733-443e-9b3a-9ca7e2ae32eb", + "e244742d-c90b-4301-9d0f-1c6a06e3eec9", + "0821f4ca-640d-4cce-9af8-a593f261aa75", + "b181f475-192c-49c5-9f80-f33201a2d11b", + "90b57b93-4a01-4612-bd92-fe9c4566da64", + "dd8e4e20-2f85-41d3-b664-39304feec01b", + "6122ea91-f0e4-4ea4-aca6-b67feec9d81b", + "c035931f-90b0-4c48-a82c-0b7e343ebf49", + "d8b03fae-b7e2-4871-a480-84edd531bfb9", + "f499c3c1-ac46-403a-8a73-2daaebcf063d", + "a310a406-b37a-4556-89e3-a6475bbb114f", + "bab3f52c-0ed2-4390-b4b4-d34b5cb6e1ad", + "c2245fe6-258f-4248-a296-224fe3f213d1", + "67e8e834-3ab6-497e-b2d3-1e6df4575380", + "3b367c58-f208-4e98-aa92-816cd649094b", + "cc63b1b1-61ff-44f9-b3bf-cc24e23cf54b", + "4eff4f42-770e-414a-ad5d-dde8e49b244f", + "8092d5a8-c9a8-4812-ac22-d620a5e04003", + "d682debe-17d7-4e31-9df1-e2f70758302f", + "b8a3e0ea-25ca-47df-afec-48ac3a0de811", + "450f335f-cd9c-45f3-a69f-1db5f9f16082", + "4467264f-8a57-4a05-ad0d-8d224221ec69", + "9e91a4d6-147b-4a64-bcb6-2d311164c3d8", + "4a0c989a-31e7-4d9d-afdc-dc31c697fd11", + "5b1a604c-12a9-4b9c-ba1e-abd8be05e135", + "a9291b6e-eefe-466f-8802-64083abbfb0f", + "0210fe7b-55a9-4bb0-a496-fbbff2831dd5", + "1d5056aa-4d8c-4435-8a90-b3b48c8849e6", + "1b339b55-fd27-4527-aff3-4a31109297e4", + "f9c79715-ff09-4a1a-acea-ac4acd0eedc4", + "93cddbf6-b48d-4086-b089-869ff2b7af0f", + "f96e2531-cde6-490f-be26-076b3b3deaa4", + "2dec1ba3-c306-4868-95bf-668689c10f4f", + "ce8bedd9-618c-4475-b76e-b429ac49f84b", + "7f2ae078-41cd-4f64-88ec-ef0f45185020", + "271ba76a-3c7d-4b6e-abbd-294050608ebf", + "afa647e9-ad38-467f-9ebc-fa7283586c19", + "2cef06d8-98dc-415e-a8af-758689711c68", + "8c859042-b37a-4447-9d3e-07d1ae160765", + "b5ba1234-1983-4805-a9be-c4ca9c52b799", + "b6614e63-4538-4583-8f9d-0c220db602a8", + "71453fae-a689-4e28-995f-bd6e2c7cadaf", + "53e43bae-3c70-4df5-8490-fe9208fbd952", + "d1776b0e-7ddc-42e0-bd2d-7561ae72ae8b", + "ad88f81e-6526-44f4-9208-ea64efdbde87", + "503226e6-6671-4248-9fba-7b31f4684c0c", + "c8e688aa-b63d-4e11-a14e-4ea1a2ad5257", + "48002330-8ecb-41c5-9acc-95ae260a7a15", + "e5550424-bec4-48a1-9354-0ad1f14510c4", + "304dc6fc-9807-46b6-9665-fe8d6cc2d9b7", + "24fe00ef-6c48-4260-9bca-125e2b16e7b2", + "12e6470d-f923-4358-9fbb-185ff981903c", + "32b53e7f-f0d3-446b-9b56-9cb4cdd5134d", + "fe786b67-dc09-41e0-aba5-33e7aa8dcdf7", + "a7c06a4b-2954-4660-8072-3c10c7d2823b", + "c1dedfce-2619-484b-8a10-bc9b2bda39ff", + "618a7511-e82b-4e7f-9d4a-4b4a4247f6e0", + "00bec0f4-7844-4ad9-8d01-e3833aae9697", + "8cb2fb8f-b840-4f5b-b151-744fb425298c", + "8c2a8d3d-f05a-4c27-9df6-bc7f4f6106b8", + "9dfc79d6-952e-4ae4-9dd8-493ac9a30065", + "edf9a307-0e80-4a80-97f4-f53c78910554", + "3c9c12e5-0fe7-4b1a-b224-7570808f8e19", + ] + # TODO check "next_page_token" + # -> wasn't required for the initial run, as on master everything was on one page for the past 30 days + workflows = json.loads(data.decode("utf-8")) + count = 0 + for item in workflows.get("items"): + if item["status"] == "success": + workflow_id = item["id"] + if workflow_id in already_sent: + continue + print(f"checking workflow_id {workflow_id}") + date_created_at = item["created_at"] + converted_date = datetime.datetime.strptime( + date_created_at, "%Y-%m-%dT%H:%M:%S.%fZ" + ) + # create the same time format we use when uploading data in the cirlce ci + timestamp = converted_date.strftime("%Y-%m-%d %H:%M:%S") + + # get the details for the job (we need the job_number of the report step) + job_request = f"/api/v2/workflow/{workflow_id}/job" + job_data = send_request_to_connection(conn, job_request) + if not job_data: + print("could not retrieve job_data") + return + jobs = json.loads(job_data.decode("utf-8")) + report_job = [item for item in jobs["items"] if item["name"] == "report"] + if len(report_job) != 1: + print(f"report job should be exactly 1, unexpected content: {report_job}") + return + job_number = report_job[0]["job_number"] + + # request artificats for the report job + artifacts_request = ( + f"/api/v2/project/github/localstack/localstack/{job_number}/artifacts" + ) + artifacts_data = send_request_to_connection(conn, artifacts_request) + if not artifacts_data: + print("could not retrieve artifacts data") + return + + artifacts = json.loads(artifacts_data.decode("utf-8")) + + # extract the required urls for metric-data-raw, and coverage data for community/pro + metric_data_url = extract_artifacts_url_for_path( + artifacts=artifacts, path="parity_metrics/metric-report-raw-data-all" + ) + community_cov_url = extract_artifacts_url_for_path( + artifacts=artifacts, path="community/implementation_coverage_full.csv" + ) + pro_cov_url = extract_artifacts_url_for_path( + artifacts=artifacts, path="pro/implementation_coverage_full.csv" + ) + + if not metric_data_url or not community_cov_url or not pro_cov_url: + print("At least one artifact url could not be found. existing..") + return + + # download files locally + metric_report_file_path = "./metric_report_raw.csv" + print(f"trying to download {metric_data_url}") + urllib.request.urlretrieve(metric_data_url, metric_report_file_path) + + community_coverage_file_path = "./community_coverage.csv" + print(f"trying to download {community_cov_url}") + urllib.request.urlretrieve(community_cov_url, community_coverage_file_path) + + pro_coverage_file_path = "./pro_coverage.csv" + print(f"trying to download {pro_cov_url}") + urllib.request.urlretrieve(pro_cov_url, pro_coverage_file_path) + + # update required ENVs with the data from the current workflow/job + os.environ["CIRCLE_BRANCH"] = MASTER_BRANCH + os.environ["CIRCLE_PULL_REQUESTS"] = "" + os.environ["CIRCLE_BUILD_NUM"] = str(job_number) + os.environ["CIRCLE_BUILD_URL"] = "" + os.environ["CIRCLE_WORKFLOW_ID"] = str(workflow_id) + + # trigger the tinybird_upload + send_metric_report( + metric_report_file_path, source_type="community", timestamp=timestamp + ) + send_implemented_coverage( + community_coverage_file_path, timestamp=timestamp, type="community" + ) + send_implemented_coverage(pro_coverage_file_path, timestamp=timestamp, type="pro") + already_sent.append(workflow_id) + count = count + 1 + # print(already_sent) + + finally: + print(already_sent) + if timestamp: + print(f"last timestamp: {timestamp}") + if count: + print(f"sent {count} workflow data to tinybird") + if conn: + conn.close() + + +def main(): + collect_workflows_past_30_days() + + +if __name__ == "__main__": + main() diff --git a/scripts/tinybird/upload_raw_test_metrics_and_coverage.py b/scripts/tinybird/upload_raw_test_metrics_and_coverage.py new file mode 100644 index 0000000000000..3fdaf3878206f --- /dev/null +++ b/scripts/tinybird/upload_raw_test_metrics_and_coverage.py @@ -0,0 +1,323 @@ +"""Uploading to Tinybird of the metrics collected for a CircleCI run + +The upload includes: + * Test Data Raw + * Implemented/Not implemented Coverage (once for community, once for pro) + +## Test Data Raw + +Metric data that is collected during the integration test runs. + +### Data Collection +All api calls are collected, and stored into a csv file. + +- In *CircleCI*, we collect data for the `docker-test-arm64` and `docker-test-amd64` jobs. As both are divided into two test runs, and contain the same tests, we only merge the test results from `docker-test-arm64` runs. The output is stored as an artifact in the `report` step, and is called `parity_metrics/metric-report-raw-data-all-amd64[date-time].csv` +- In *Github*, the action `Integration Tests` collects the same data. The artifacts are stored as `parity-metric-ext-raw.zip` and contain a similar csv file. + +### Data Structure +The following data is collected for each api call: + +- *service:* the name of the service +- *operation*: the name of the operation +- *request_headers*: the headers are send with the request +- *parameters*: any parameters that were used for the operation, comma separated list; the value is not included +- *response_code*: the http response code that followed the request +- *response_data*: any data that was sent with the response +- *exception*: name of the exception, in case one was triggered for this request +- *origin*: indicates how the request was triggered, valid values are: `internal` and `external` . Internal means that the request was made from localstack, external means that it was triggered explicitly during the test. +- *test_node_id*: the id of the test, pattern: +- *xfail*: boolean string, indicates whether the test was marked with `xfail` +- *aws_validated*: boolean string, indicates whether the test was marked with `aws_validated` +- *snapshot*: boolean string, indicates whether the test was marked with `snapshot` +- *snapshot_skipped_paths*: comma separated list of any paths that are skipped for snapshot test + +### Implemented/Not implemented Coverage +In CircleCI, we have two jobs that test for implemented/not implemented APIs for each service. One job is running for LocalStack Community version, the other one for LocalStack Pro. +We use an heuristic approach for the coverage, and the input for the parameters is non-deterministic, meaning that the classification of implemented/not implemented *could* change. +Currently, the coverage is used to generate the [docs coverage page](https://docs.localstack.cloud/references/coverage/), which is updated once a week. +In CircleCI, the `report` job stores the artifacts for `implementation_coverage_full.csv` (community and pro), and `implementation_coverage_aggregated.csv`. + +### Data Structure +- *service:* name of the service +- *operation*: name of the operation +- *status_code*: status code returned for this call +- *error_code*: the error code or exception returned by this call +- *error_message*: detailed error message, if any was returned +- *is_implemented*: boolean, indicating if the operation is assumed to be implemented + +### Relevant Data for Parity Dashboard + +From the above data structure, the following information is sent to the parity dashboard: +*Test Data Raw* (tests_raw.datasource): +service, operation, parameters, response_code, origin, test_node_id, xfail, aws-alidated, snapshot, snapshot_skipped_paths* + +*Coverage Data* (implementation_coverage.datasource): +service, operation, status_code, error_code, is_implemented* + +Additionally, we add the following information: +- *build_id*: the workflow-id (for CircleCI) or run-id (for GitHub) +- *timestamp*: a timestamp as string which will be the same for the CircleCI run +- *ls_source*: β€œcommunity” for the CircleCI run, β€œpro” for the Github action + +In order to get more metadata from the build, we also send some general information to tests_raw_builds.datasource: +- *build_id:* the workflow-id (for CircleCI) or run-id (for GitHub) +- *timestamp:* a timestamp as string which will be the same for the CI run +- *branch:* env value from *`CIRCLE_BRANCH`* or *`GITHUB_HEAD_REF`* (only set for pull_requests) or *`GITHUB_REF_NAME`* +- *build_url:* env value from *`CIRCLE_BUILD_URL`* or *$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID* +- *pull_requests:* env value from *`CIRCLE_PULL_REQUESTS`* or *`GITHUB_REF`* +- *build_num:* env value from *`CIRCLE_BUILD_NUM`* (empty for GitHub, there seems to be no equivalent) +- *workflow_id:* env value from *`CIRCLE_WORKFLOW_ID`* or *`GITHUB_RUN_ID`* +""" + +import csv +import datetime +import json +import os +from pathlib import Path + +import requests + +DATA_TO_KEEP: list[str] = [ + "service", + "operation", + "parameters", + "response_code", + "origin", + "test_node_id", + "xfail", + "aws_validated", + "snapshot", + "snapshot_skipped_paths", +] +CONVERT_TO_BOOL: list[str] = ["xfail", "aws_validated", "snapshot"] + +DATA_SOURCE_RAW_TESTS = "tests_raw__v0" +DATA_SOURCE_RAW_BUILDS = "tests_raw_builds__v0" +DATA_SOURCE_IMPL_COVERAGAGE = "implementation_coverage__v0" + + +def convert_to_bool(input): + return True if input.lower() == "true" else False + + +def send_data_to_tinybird(data: list[str], data_name: str): + # print(f"example data:\n {data[0]}\n") + token = os.environ.get("TINYBIRD_PARITY_ANALYTICS_TOKEN", "") + if not token: + print("missing ENV 'TINYBIRD_PARITY_ANALYTICS_TOKEN', no token defined. cannot send data") + return + + data_to_send = "\n".join(data) + r = requests.post( + "https://api.tinybird.co/v0/events", + params={ + "name": data_name, + "token": token, + }, + data=data_to_send, + ) + print(f"sent data to tinybird, status code: {r.status_code}: {r.text}") + + +def send_metadata_for_build(build_id: str, timestamp: str): + """ + sends the metadata for the build to tinybird + + SCHEMA > + `build_id` String `json:$.build_id`, + `timestamp` DateTime `json:$.timestamp`, + `branch` String `json:$.branch`, + `build_url` String `json:$.build_url`, + `pull_requests` String `json:$.pull_requests`, + `build_num` String `json:$.build_num`, + `workflow_id` String `json:$.workflow_id` + + CircleCI env examples: + CIRCLE_PULL_REQUESTS=https://github.com/localstack/localstack/pull/7324 + CIRCLE_BRANCH=coverage-tinybird + CIRCLE_BUILD_NUM=78206 + CIRCLE_BUILD_URL=https://circleci.com/gh/localstack/localstack/78206 + CIRCLE_WORKFLOW_ID=b86a4bc4-bcd1-4170-94d6-4af66846c1c1 + + GitHub env examples: + GITHUB_REF=ref/heads/master or ref/pull//merge (will be used for 'pull_requests') + GITHUB_HEAD_REF=tinybird_data (used for 'branch', set only for pull_requests) + GITHUB_REF_NAME=feature-branch-1 (will be used for 'branch' if GITHUB_HEAD_REF is not set) + GITHUB_RUN_ID=1658821493 (will be used for 'workflow_id') + + workflow run's URL (will be used for 'build_url'): + $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID + + could not find anything that corresponds to "build_num" (number of current job in CircleCI) + -> leaving it blank for Github + """ + # on GitHub the GITHUB_HEAD_REF is only set for pull_request, else we use the GITHUB_REF_NAME + branch = ( + os.environ.get("CIRCLE_BRANCH", "") + or os.environ.get("GITHUB_HEAD_REF", "") + or os.environ.get("GITHUB_REF_NAME", "") + ) + workflow_id = os.environ.get("CIRCLE_WORKFLOW_ID", "") or os.environ.get("GITHUB_RUN_ID", "") + + build_url = os.environ.get("CIRCLE_BUILD_URL", "") + if not build_url and os.environ.get("GITHUB_SERVER_URL"): + # construct the build-url for Github + server = os.environ.get("GITHUB_SERVER_URL", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + build_url = f"{server}/{repo}/actions/runs/{workflow_id}" + + pull_requests = os.environ.get("CIRCLE_PULL_REQUESTS", "") or os.environ.get("GITHUB_REF", "") + build_num = os.environ.get( + "CIRCLE_BUILD_NUM", "" + ) # TODO could not find equivalent job-id ENV in github + + data = { + "build_id": build_id, + "timestamp": timestamp, + "branch": branch, + "build_url": build_url, + "pull_requests": pull_requests, + "build_num": build_num, + "workflow_id": workflow_id, + } + data_to_send = [json.dumps(data)] + send_data_to_tinybird(data_to_send, data_name=DATA_SOURCE_RAW_BUILDS) + + +def send_metric_report(metric_path: str, source_type: str, timestamp: str): + """ + + SCHEMA > + `timestamp` DateTime `json:$.timestamp`, + `ls_source` String `json:$.ls_source`, + `test_node_id` String `json:$.test_node_id`, + `operation` String `json:$.operation`, + `origin` String `json:$.origin`, + `parameters` String `json:$.parameters`, + `response_code` String `json:$.response_code`, + `service` String `json:$.service`, + `snapshot` UInt8 `json:$.snapshot`, + `snapshot_skipped_paths` String `json:$.snapshot_skipped_paths`, + `aws_validated` UInt8 `json:$.aws_validated`, + `xfail` UInt8 `json:$.xfail`, + `build_id` String `json:$.build_id` + """ + tmp: list[str] = [] + count: int = 0 + build_id = os.environ.get("CIRCLE_WORKFLOW_ID", "") or os.environ.get("GITHUB_RUN_ID", "") + send_metadata_for_build(build_id, timestamp) + + pathlist = Path(metric_path).rglob("metric-report-raw-data-*.csv") + for path in pathlist: + print(f"checking {str(path)}") + with open(path, "r") as csv_obj: + reader_obj = csv.DictReader(csv_obj) + data_to_remove = [field for field in reader_obj.fieldnames if field not in DATA_TO_KEEP] + for row in reader_obj: + count = count + 1 + + # add timestamp, build_id, ls_source + row["timestamp"] = timestamp + row["build_id"] = build_id + row["ls_source"] = source_type + + # remove data we are currently not interested in + for field in data_to_remove: + row.pop(field, None) + + # convert boolean values + for convert in CONVERT_TO_BOOL: + row[convert] = convert_to_bool(row[convert]) + + tmp.append(json.dumps(row)) + if len(tmp) == 500: + # send data in batches + send_data_to_tinybird(tmp, data_name=DATA_SOURCE_RAW_TESTS) + tmp.clear() + + if tmp: + # send last batch + send_data_to_tinybird(tmp, data_name=DATA_SOURCE_RAW_TESTS) + tmp.clear() + + print(f"---> processed {count} rows from community test coverage {metric_path}") + + +def send_implemented_coverage(file: str, timestamp: str, type: str): + """ + SCHEMA > + `build_id` String `json:$.build_id`, + `timestamp` DateTime `json:$.timestamp`, + `ls_source` String `json:$.ls_source`, + `operation` String `json:$.operation`, + `service` String `json:$.service`, + `status_code` Int32 `json:$.status_code`, + `error_code` String `json:$.error_code`, + `is_implemented` UInt8 `json:$.is_implemented` + """ + tmp: list[str] = [] + count: int = 0 + + build_id = os.environ.get("CIRCLE_WORKFLOW_ID", "") or os.environ.get("GITHUB_RUN_ID", "") + with open(file, "r") as csv_obj: + reader_obj = csv.DictReader(csv_obj) + for row in reader_obj: + count = count + 1 + # remove unnecessary data + row.pop("error_message") + + # convert types + row["is_implemented"] = convert_to_bool(row["is_implemented"]) + row["status_code"] = int(row["status_code"]) + + # add timestamp and source + row["timestamp"] = timestamp + row["ls_source"] = type + row["build_id"] = build_id + + tmp.append(json.dumps(row)) + if len(tmp) == 500: + # send data in batches + send_data_to_tinybird(tmp, data_name=DATA_SOURCE_IMPL_COVERAGAGE) + tmp.clear() + + if tmp: + # send last batch + send_data_to_tinybird(tmp, data_name=DATA_SOURCE_IMPL_COVERAGAGE) + print(f"---> processed {count} rows from {file} ({type})") + + +def main(): + token = os.environ.get("TINYBIRD_PARITY_ANALYTICS_TOKEN", "") + metric_report_dir = os.environ.get("METRIC_REPORT_DIR_PATH", "") + impl_coverage_file = os.environ.get("IMPLEMENTATION_COVERAGE_FILE", "") + source_type = os.environ.get("SOURCE_TYPE", "") + missing_info = ( + "missing data, please check the available ENVs that are required to run the script" + ) + print( + f"METRIC_REPORT_DIR_PATH={metric_report_dir}, IMPLEMENTATION_COVERAGE_FILE={impl_coverage_file}, " + f"SOURCE_TYPE={source_type}" + ) + if not metric_report_dir: + print(missing_info) + raise Exception("missing METRIC_REPORT_DIR_PATH") + if not impl_coverage_file: + print(missing_info) + raise Exception("missing IMPLEMENTATION_COVERAGE_FILE") + if not source_type: + print(missing_info) + raise Exception("missing SOURCE_TYPE") + if not token: + print(missing_info) + raise Exception("missing TINYBIRD_PARITY_ANALYTICS_TOKEN") + + # create one timestamp that will be used for all the data sent + timestamp: str = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + send_metric_report(metric_report_dir, source_type=source_type, timestamp=timestamp) + send_implemented_coverage(impl_coverage_file, timestamp=timestamp, type=source_type) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py deleted file mode 100755 index af96404cc16df..0000000000000 --- a/setup.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python - -from __future__ import print_function - -import re -from setuptools import find_packages, setup - -# marker for extended/ignored libs in requirements.txt -IGNORED_LIB_MARKER = '#extended-lib' - -# parameter variables -install_requires = [] -dependency_links = [] -package_data = {} - - -# determine version -with open('localstack/constants.py') as f: - constants = f.read() -version = re.search(r'^\s*VERSION\s*=\s*[\'"](.+)[\'"]\s*$', constants, re.MULTILINE).group(1) - - -# determine requirements -with open('requirements.txt') as f: - requirements = f.read() -for line in re.split('\n', requirements): - if line and line[0] == '#' and '#egg=' in line: - line = re.search(r'#\s*(.*)', line).group(1) - if line and line[0] != '#': - if '://' not in line and IGNORED_LIB_MARKER not in line: - install_requires.append(line) - - -package_data = { - '': ['Makefile', '*.md'], - 'localstack': [ - 'package.json', - 'dashboard/web/*.*', - 'dashboard/web/css/*', - 'dashboard/web/img/*', - 'dashboard/web/js/*', - 'dashboard/web/views/*', - 'ext/java/*.*', - 'ext/java/src/main/java/com/atlassian/localstack/*.*', - 'utils/kinesis/java/com/atlassian/*.*' - ]} - - -if __name__ == '__main__': - - setup( - name='localstack', - version=version, - description='An easy-to-use test/mocking framework for developing Cloud applications', - author='Waldemar Hummer (Atlassian)', - author_email='waldemar.hummer@gmail.com', - url='https://github.com/localstack/localstack', - scripts=['bin/localstack'], - packages=find_packages(exclude=('tests', 'tests.*')), - package_data=package_data, - install_requires=install_requires, - dependency_links=dependency_links, - test_suite='tests', - license='Apache License 2.0', - zip_safe=False, - classifiers=[ - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.6', - 'License :: OSI Approved :: Apache Software License', - 'Topic :: Software Development :: Testing', - ] - ) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29bb2d1d..73c77ddcb8e62 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +name = "tests" diff --git a/tests/aws/__init__.py b/tests/aws/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/cdk_templates/APIGWtest/ApiGatewayStack.json b/tests/aws/cdk_templates/APIGWtest/ApiGatewayStack.json new file mode 100644 index 0000000000000..dbe00df18fb7b --- /dev/null +++ b/tests/aws/cdk_templates/APIGWtest/ApiGatewayStack.json @@ -0,0 +1,329 @@ +{ + "Resources": { + "restapi39D779F7": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "rest-api" + } + }, + "restapiCloudWatchRole2D9E2F10": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "restapiAccountC2304339": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "restapiCloudWatchRole2D9E2F10", + "Arn" + ] + } + }, + "DependsOn": [ + "restapi39D779F7" + ], + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "restapiDeploymentD3722A4C7fa862ab267efa33d754561c3a45f9f4": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "Description": "Automatically created by the RestApi construct", + "RestApiId": { + "Ref": "restapi39D779F7" + } + }, + "DependsOn": [ + "restapidefault4xxresponse7CC079A3", + "restapidefault5xxresponseF0B821D8", + "restapiv1GET285EA5B5", + "restapiv1A12D7CAF" + ] + }, + "restapiDeploymentStageprod0335F613": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "DeploymentId": { + "Ref": "restapiDeploymentD3722A4C7fa862ab267efa33d754561c3a45f9f4" + }, + "RestApiId": { + "Ref": "restapi39D779F7" + }, + "StageName": "prod" + }, + "DependsOn": [ + "restapiAccountC2304339" + ] + }, + "restapiv1A12D7CAF": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "restapi39D779F7", + "RootResourceId" + ] + }, + "PathPart": "v1", + "RestApiId": { + "Ref": "restapi39D779F7" + } + } + }, + "restapiv1GETApiPermissionApiGatewayStackrestapi8187B2CCGETv1E8B7485D": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "backendCBA98286", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "restapi39D779F7" + }, + "/", + { + "Ref": "restapiDeploymentStageprod0335F613" + }, + "/GET/v1" + ] + ] + } + } + }, + "restapiv1GETApiPermissionTestApiGatewayStackrestapi8187B2CCGETv1A64BCFC2": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "backendCBA98286", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "restapi39D779F7" + }, + "/test-invoke-stage/GET/v1" + ] + ] + } + } + }, + "restapiv1GET285EA5B5": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "backendCBA98286", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "ResourceId": { + "Ref": "restapiv1A12D7CAF" + }, + "RestApiId": { + "Ref": "restapi39D779F7" + } + } + }, + "restapidefault4xxresponse7CC079A3": { + "Type": "AWS::ApiGateway::GatewayResponse", + "Properties": { + "ResponseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'*'" + }, + "ResponseType": "DEFAULT_4XX", + "RestApiId": { + "Ref": "restapi39D779F7" + } + } + }, + "restapidefault5xxresponseF0B821D8": { + "Type": "AWS::ApiGateway::GatewayResponse", + "Properties": { + "ResponseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'*'" + }, + "ResponseType": "DEFAULT_5XX", + "RestApiId": { + "Ref": "restapi39D779F7" + } + } + }, + "backendServiceRole77A15DC8": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "backendCBA98286": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nimport json\ndef handler(event, context):\n return {\n \"statusCode\": 200,\n \"body\": json.dumps({\n \"message\": \"Hello World!\"\n })\n }\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "backendServiceRole77A15DC8", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "backendServiceRole77A15DC8" + ] + } + }, + "Outputs": { + "restapiEndpointC67DEFEA": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "restapi39D779F7" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "restapiDeploymentStageprod0335F613" + }, + "/" + ] + ] + } + }, + "ApiId": { + "Value": { + "Ref": "restapi39D779F7" + } + } + } +} diff --git a/tests/aws/cdk_templates/Bookstore/BookstoreStack.json b/tests/aws/cdk_templates/Bookstore/BookstoreStack.json new file mode 100644 index 0000000000000..e1486e5fcd76c --- /dev/null +++ b/tests/aws/cdk_templates/Bookstore/BookstoreStack.json @@ -0,0 +1,492 @@ +{ + "Resources": { + "BooksApiLambdaRole6305A178": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AmazonS3FullAccess" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AmazonDynamoDBFullAccess" + ] + ] + } + ] + } + }, + "BooksApiLambdaRoleDefaultPolicyCB8FFCFD": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:ListStreams", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "BooksTable9DF4AE31", + "StreamArn" + ] + } + }, + { + "Action": [ + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:DescribeTable" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "BooksTable9DF4AE31", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "BooksTable9DF4AE31", + "Arn" + ] + }, + "/index/*" + ] + ] + } + ] + }, + { + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:Query", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:ConditionCheckItem", + "dynamodb:DescribeTable" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "BooksTable9DF4AE31", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "BooksTable9DF4AE31", + "Arn" + ] + }, + "/index/*" + ] + ] + } + ] + }, + { + "Action": [ + "es:ESHttpGet", + "es:ESHttpHead", + "es:ESHttpDelete", + "es:ESHttpPost", + "es:ESHttpPut", + "es:ESHttpPatch" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "Domain66AC69E0", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "Domain66AC69E0", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "BooksApiLambdaRoleDefaultPolicyCB8FFCFD", + "Roles": [ + { + "Ref": "BooksApiLambdaRole6305A178" + } + ] + } + }, + "Domain66AC69E0": { + "Type": "AWS::OpenSearchService::Domain", + "Properties": { + "AdvancedOptions": { + "rest.action.multi.allow_explicit_index": "false" + }, + "ClusterConfig": { + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.search", + "ZoneAwarenessEnabled": false + }, + "DomainEndpointOptions": { + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "EngineVersion": "OpenSearch_2.11", + "LogPublishingOptions": {}, + "NodeToNodeEncryptionOptions": { + "Enabled": false + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "BooksTable9DF4AE31": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + }, + { + "AttributeName": "category", + "AttributeType": "S" + } + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "category-index", + "KeySchema": [ + { + "AttributeName": "category", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + } + } + ], + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "LoadBooksLambda89A2744D": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "// source: https://github.com/aws-samples/aws-bookstore-demo-app/blob/master/functions/setup/uploadBooks.js\n\n\"use strict\";\n\nconst https = require(\"https\");\nconst url = require(\"url\");\nconst AWS = require(\"aws-sdk\");\n\nvar config = {\n 's3ForcePathStyle': true,\n};\nif (process.env.AWS_ENDPOINT_URL) {\n config.endpoint = process.env.AWS_ENDPOINT_URL;\n}\n\nlet documentClient = new AWS.DynamoDB.DocumentClient(config);\nlet s3Client = new AWS.S3(config);\n\n// UploadBooks - Upload sample set of books to DynamoDB\nexports.handler = function(event, context, callback) {\n getBooksData().then(function(data) {\n var booksString = data.Body.toString(\"utf-8\");\n console.log(\"received booksString\");\n var booksList = JSON.parse(booksString);\n console.log(\"parsing bookslist\");\n uploadBooksData(booksList);\n console.log(\"uploaded books\");\n }).catch(function(err) {\n console.log(err);\n var responseData = { Error: \"Upload books failed\" };\n console.log(responseData.Error);\n });\n\n return;\n};\nfunction uploadBooksData(book_items) {\n var items_array = [];\n for (var i in book_items) {\n var book = book_items[i];\n console.log(book.id)\n var item = {\n PutRequest: {\n Item: book\n }\n };\n items_array.push(item);\n }\n\n // Batch items into arrays of 25 for BatchWriteItem limit\n var split_arrays = [], size = 25;\n while (items_array.length > 0) {\n split_arrays.push(items_array.splice(0, size));\n }\n\n split_arrays.forEach( function(item_data) {\n putItem(item_data)\n });\n}\n\n// Retrieve sample books from aws-bookstore-demo S3 Bucket\nfunction getBooksData() {\n var params = {\n Bucket: process.env.S3_BUCKET, // aws-bookstore-demo\n Key: process.env.FILE_NAME // data/books.json\n };\n return s3Client.getObject(params).promise();\n}\n\n\nfunction putItem(items_array) {\n var tableName = process.env.TABLE_NAME;\n var params = {\n RequestItems: {\n [tableName]: items_array\n }\n };\n documentClient.batchWrite(params, function(err, data) {\n if (err) console.log(err);\n else console.log(data);\n });\n}\n" + }, + "Environment": { + "Variables": { + "TABLE_NAME": { + "Ref": "BooksTable9DF4AE31" + }, + "S3_BUCKET": "book-init-data-store-scenario-test", + "FILE_NAME": "books.json" + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BooksApiLambdaRole6305A178", + "Arn" + ] + }, + "Runtime": "nodejs16.x" + }, + "DependsOn": [ + "BooksApiLambdaRoleDefaultPolicyCB8FFCFD", + "BooksApiLambdaRole6305A178" + ] + }, + "GetBookLambda6188143D": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "// source adapted from https://github.com/aws-samples/aws-bookstore-demo-app\n\n\"use strict\";\n\nconst AWS = require(\"aws-sdk\");\n\nvar config = {};\nif (process.env.AWS_ENDPOINT_URL) {\n config.endpoint = process.env.AWS_ENDPOINT_URL;\n}\n\nlet dynamoDb = new AWS.DynamoDB.DocumentClient(config);\n\n// GetBook - Get book informaton for a given book id\nexports.handler = (event, context, callback) => {\n\n // Return immediately if being called by warmer\n if (event.source === \"warmer\") {\n return callback(null, \"Lambda is warm\");\n }\n\n const params = {\n TableName: process.env.TABLE_NAME, // [ProjectName]-Books\n // 'Key' defines the partition key of the item to be retrieved\n // - 'id': a unique identifier for the book (uuid)\n Key: {\n id: event.pathParameters.id\n }\n };\n dynamoDb.get(params, (error, data) => {\n // Set response headers to enable CORS (Cross-Origin Resource Sharing)\n const headers = {\n \"Access-Control-Allow-Origin\": \"*\",\n \"Access-Control-Allow-Credentials\" : true\n };\n\n // Return status code 500 on error\n if (error) {\n const response = {\n statusCode: 500,\n headers: headers,\n body: error\n };\n callback(null, response);\n return;\n }\n\n // Return status code 200 and the retrieved item on success\n const response = {\n statusCode: 200,\n headers: headers,\n body: JSON.stringify(data.Item)\n };\n callback(null, response);\n });\n}\n" + }, + "Environment": { + "Variables": { + "TABLE_NAME": { + "Ref": "BooksTable9DF4AE31" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BooksApiLambdaRole6305A178", + "Arn" + ] + }, + "Runtime": "nodejs16.x" + }, + "DependsOn": [ + "BooksApiLambdaRoleDefaultPolicyCB8FFCFD", + "BooksApiLambdaRole6305A178" + ] + }, + "ListBooksLambda272803E4": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "// source adapted from https://github.com/aws-samples/aws-bookstore-demo-app\n\n\"use strict\";\n\nconst AWS = require(\"aws-sdk\");\n\nvar config = {};\nif (process.env.AWS_ENDPOINT_URL) {\n config.endpoint = process.env.AWS_ENDPOINT_URL;\n}\n\nlet dynamoDb = new AWS.DynamoDB.DocumentClient(config);\n\n// ListBooks - List all books or list all books in a particular category\nexports.handler = (event, context, callback) => {\n\n // Return immediately if being called by warmer\n if (event.source === \"warmer\") {\n return callback(null, \"Lambda is warm\");\n }\n\n // Set response headers to enable CORS (Cross-Origin Resource Sharing)\n const headers = {\n \"Access-Control-Allow-Origin\": \"*\",\n \"Access-Control-Allow-Credentials\" : true\n };\n\n // Query books for a particular category\n if (event.queryStringParameters) {\n const params = {\n TableName: process.env.TABLE_NAME, // [ProjectName]-Books\n IndexName: \"category-index\",\n // 'KeyConditionExpression' defines the condition for the query\n // - 'category = :category': only return items with matching 'category' index\n // 'ExpressionAttributeValues' defines the value in the condition\n // - ':category': defines 'category' to be the query string parameter\n KeyConditionExpression: \"category = :category\",\n ExpressionAttributeValues: {\n \":category\": event.queryStringParameters.category\n }\n };\n dynamoDb.query(params, (error, data) => {\n // Return status code 500 on error\n if (error) {\n const response = {\n statusCode: 500,\n headers: headers,\n body: error\n };\n callback(null, response);\n return;\n }\n\n // Return status code 200 and the retrieved items on success\n const response = {\n statusCode: 200,\n headers: headers,\n body: JSON.stringify(data.Items)\n };\n callback(null, response);\n });\n }\n\n // List all books in bookstore\n else {\n const params = {\n TableName: process.env.TABLE_NAME // [ProjectName]-Books\n };\n\n dynamoDb.scan(params, (error, data) => {\n // Return status code 500 on error\n if (error) {\n const response = {\n statusCode: 500,\n headers: headers,\n body: error\n };\n callback(null, response);\n return;\n }\n\n // Return status code 200 and the retrieved items on success\n const response = {\n statusCode: 200,\n headers: headers,\n body: JSON.stringify(data.Items)\n };\n callback(null, response);\n });\n }\n}\n" + }, + "Environment": { + "Variables": { + "TABLE_NAME": { + "Ref": "BooksTable9DF4AE31" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BooksApiLambdaRole6305A178", + "Arn" + ] + }, + "Runtime": "nodejs16.x" + }, + "DependsOn": [ + "BooksApiLambdaRoleDefaultPolicyCB8FFCFD", + "BooksApiLambdaRole6305A178" + ] + }, + "SearchBookLambdaC4A03CAC": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Join": [ + "-", + [ + "localstack-testing", + { + "Ref": "AWS::AccountId" + }, + { + "Ref": "AWS::Region" + } + ] + ] + }, + "S3Key": "search.zip" + }, + "Environment": { + "Variables": { + "ESENDPOINT": { + "Fn::GetAtt": [ + "Domain66AC69E0", + "DomainEndpoint" + ] + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BooksApiLambdaRole6305A178", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "BooksApiLambdaRoleDefaultPolicyCB8FFCFD", + "BooksApiLambdaRole6305A178" + ] + }, + "UpdateSearchLambda87F5CBC4": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Join": [ + "-", + [ + "localstack-testing", + { + "Ref": "AWS::AccountId" + }, + { + "Ref": "AWS::Region" + } + ] + ] + }, + "S3Key": "search_update.zip" + }, + "Environment": { + "Variables": { + "ESENDPOINT": { + "Fn::GetAtt": [ + "Domain66AC69E0", + "DomainEndpoint" + ] + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BooksApiLambdaRole6305A178", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "BooksApiLambdaRoleDefaultPolicyCB8FFCFD", + "BooksApiLambdaRole6305A178" + ] + }, + "UpdateSearchLambdaDynamoDBEventSourceBookstoreStackBooksTable5CD0B9B677451E3B": { + "Type": "AWS::Lambda::EventSourceMapping", + "Properties": { + "BatchSize": 1, + "Enabled": true, + "EventSourceArn": { + "Fn::GetAtt": [ + "BooksTable9DF4AE31", + "StreamArn" + ] + }, + "FunctionName": { + "Ref": "UpdateSearchLambda87F5CBC4" + }, + "MaximumRetryAttempts": 10, + "StartingPosition": "TRIM_HORIZON" + } + } + }, + "Outputs": { + "BooksTableName": { + "Value": { + "Ref": "BooksTable9DF4AE31" + } + }, + "SearchDomain": { + "Value": { + "Fn::GetAtt": [ + "Domain66AC69E0", + "DomainEndpoint" + ] + } + }, + "SearchDomainName": { + "Value": { + "Ref": "Domain66AC69E0" + } + }, + "GetBooksFn": { + "Value": { + "Ref": "GetBookLambda6188143D" + } + }, + "ListBooksFn": { + "Value": { + "Ref": "ListBooksLambda272803E4" + } + }, + "InitBooksTableFn": { + "Value": { + "Ref": "LoadBooksLambda89A2744D" + } + }, + "SearchForBooksFn": { + "Value": { + "Ref": "SearchBookLambdaC4A03CAC" + } + } + } +} diff --git a/tests/aws/cdk_templates/DDBTableTTL/DDBStackTTL.json b/tests/aws/cdk_templates/DDBTableTTL/DDBStackTTL.json new file mode 100644 index 0000000000000..45eeb4842a52a --- /dev/null +++ b/tests/aws/cdk_templates/DDBTableTTL/DDBStackTTL.json @@ -0,0 +1,35 @@ +{ + "Resources": { + "TableCD117FA1": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "TimeToLiveSpecification": { + "AttributeName": "expire_at", + "Enabled": true + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + } + }, + "Outputs": { + "TableName": { + "Value": { + "Ref": "TableCD117FA1" + } + } + } +} diff --git a/tests/aws/cdk_templates/EventsTests/stack-events-target-stepfunctions.json b/tests/aws/cdk_templates/EventsTests/stack-events-target-stepfunctions.json new file mode 100644 index 0000000000000..d1dc353b07170 --- /dev/null +++ b/tests/aws/cdk_templates/EventsTests/stack-events-target-stepfunctions.json @@ -0,0 +1,175 @@ +{ + "Resources": { + "MyEventBus251E60F8": { + "Type": "AWS::Events::EventBus", + "Properties": { + "Name": "MyEventBus" + } + }, + "MyQueueE6CA6235": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "MyQueue" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "MyStateMachineRoleD59FFEBC": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "states.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyStateMachineRoleDefaultPolicyE468EB18": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sqs:SendMessage", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "MyQueueE6CA6235", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyStateMachineRoleDefaultPolicyE468EB18", + "Roles": [ + { + "Ref": "MyStateMachineRoleD59FFEBC" + } + ] + } + }, + "MyStateMachine6C968CA5": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"SendToQueue\",\"States\":{\"SendToQueue\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::sqs:sendMessage\",\"Parameters\":{\"QueueUrl\":\"", + { + "Ref": "MyQueueE6CA6235" + }, + "\",\"MessageBody\":{\"message.$\":\"$\"}}}}}" + ] + ] + }, + "RoleArn": { + "Fn::GetAtt": [ + "MyStateMachineRoleD59FFEBC", + "Arn" + ] + }, + "StateMachineName": "MyStateMachine" + }, + "DependsOn": [ + "MyStateMachineRoleDefaultPolicyE468EB18", + "MyStateMachineRoleD59FFEBC" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "MyStateMachineEventsRole7C46BBB5": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyStateMachineEventsRoleDefaultPolicy6422AE18": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "states:StartExecution", + "Effect": "Allow", + "Resource": { + "Ref": "MyStateMachine6C968CA5" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyStateMachineEventsRoleDefaultPolicy6422AE18", + "Roles": [ + { + "Ref": "MyStateMachineEventsRole7C46BBB5" + } + ] + } + }, + "MyRuleA44AB831": { + "Type": "AWS::Events::Rule", + "Properties": { + "EventBusName": { + "Ref": "MyEventBus251E60F8" + }, + "EventPattern": { + "detail-type": [ + "myDetailType" + ] + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Ref": "MyStateMachine6C968CA5" + }, + "Id": "Target0", + "RoleArn": { + "Fn::GetAtt": [ + "MyStateMachineEventsRole7C46BBB5", + "Arn" + ] + } + } + ] + } + } + }, + "Outputs": { + "MachineArn": { + "Value": { + "Ref": "MyStateMachine6C968CA5" + } + }, + "QueueUrl": { + "Value": { + "Ref": "MyQueueE6CA6235" + } + } + } +} diff --git a/tests/aws/cdk_templates/FirehoseScenario/FirehoseStack.json b/tests/aws/cdk_templates/FirehoseScenario/FirehoseStack.json new file mode 100644 index 0000000000000..b642f5b7fa6dd --- /dev/null +++ b/tests/aws/cdk_templates/FirehoseScenario/FirehoseStack.json @@ -0,0 +1,271 @@ +{ + "Resources": { + "KinesisStream46752A3E": { + "Type": "AWS::Kinesis::Stream", + "Properties": { + "Name": "kinesis-stream", + "RetentionPeriodHours": 24, + "ShardCount": 1, + "StreamEncryption": { + "Fn::If": [ + "AwsCdkKinesisEncryptedStreamsUnsupportedRegions", + { + "Ref": "AWS::NoValue" + }, + { + "EncryptionType": "KMS", + "KeyId": "alias/aws/kinesis" + } + ] + }, + "StreamModeDetails": { + "StreamMode": "PROVISIONED" + } + } + }, + "S3Bucket07682993": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "firehose-raw-data" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "FirehoseKinesisRole0AD86762": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "firehose.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "RoleName": "firehose-kinesis-role" + } + }, + "FirehoseKinesisPolicy67670C20": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kinesis:DescribeStream", + "kinesis:GetShardIterator", + "kinesis:GetRecords", + "kinesis:ListShards" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "KinesisStream46752A3E", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "firehose-kinesis-policy", + "Roles": [ + { + "Ref": "FirehoseKinesisRole0AD86762" + } + ] + } + }, + "FirehoseLogGroup1B45149B": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": "firehose-s3-log-group", + "RetentionInDays": 731 + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "FirehoseLogGroupfirehoses3logstream5C74CF37": { + "Type": "AWS::Logs::LogStream", + "Properties": { + "LogGroupName": { + "Ref": "FirehoseLogGroup1B45149B" + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "FirehoseS3Role226C92CC": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "firehose.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "RoleName": "firehose-s3-role" + } + }, + "FirehoseS3Policy3A414B80": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:PutObject" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "S3Bucket07682993", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "S3Bucket07682993", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + }, + { + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "FirehoseLogGroup1B45149B", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "firehose-s3-policy", + "Roles": [ + { + "Ref": "FirehoseS3Role226C92CC" + } + ] + } + }, + "FirehoseDeliveryStream": { + "Type": "AWS::KinesisFirehose::DeliveryStream", + "Properties": { + "DeliveryStreamName": "firehose-deliverystream", + "DeliveryStreamType": "KinesisStreamAsSource", + "ExtendedS3DestinationConfiguration": { + "BucketARN": { + "Fn::GetAtt": [ + "S3Bucket07682993", + "Arn" + ] + }, + "BufferingHints": { + "IntervalInSeconds": 1, + "SizeInMBs": 1 + }, + "CloudWatchLoggingOptions": { + "Enabled": true, + "LogGroupName": "firehose-s3-log-group", + "LogStreamName": "firehose-s3-log-stream" + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "ErrorOutputPrefix": "firehose-raw-data/errors/", + "Prefix": "firehose-raw-data/", + "RoleARN": { + "Fn::GetAtt": [ + "FirehoseS3Role226C92CC", + "Arn" + ] + }, + "S3BackupMode": "Disabled" + }, + "KinesisStreamSourceConfiguration": { + "KinesisStreamARN": { + "Fn::GetAtt": [ + "KinesisStream46752A3E", + "Arn" + ] + }, + "RoleARN": { + "Fn::GetAtt": [ + "FirehoseKinesisRole0AD86762", + "Arn" + ] + } + } + } + } + }, + "Conditions": { + "AwsCdkKinesisEncryptedStreamsUnsupportedRegions": { + "Fn::Or": [ + { + "Fn::Equals": [ + { + "Ref": "AWS::Region" + }, + "cn-north-1" + ] + }, + { + "Fn::Equals": [ + { + "Ref": "AWS::Region" + }, + "cn-northwest-1" + ] + } + ] + } + }, + "Outputs": { + "KinesisStreamName": { + "Value": { + "Ref": "KinesisStream46752A3E" + } + }, + "FirehoseDeliveryStreamName": { + "Value": "firehose-deliverystream" + }, + "BucketName": { + "Value": { + "Ref": "S3Bucket07682993" + } + } + } +} diff --git a/tests/aws/cdk_templates/LambdaDestinationEventbridge/EventbridgeStack.json b/tests/aws/cdk_templates/LambdaDestinationEventbridge/EventbridgeStack.json new file mode 100644 index 0000000000000..27e4c74adba22 --- /dev/null +++ b/tests/aws/cdk_templates/LambdaDestinationEventbridge/EventbridgeStack.json @@ -0,0 +1,227 @@ +{ + "Resources": { + "CustomEventBusEC0C3CB8": { + "Type": "AWS::Events::EventBus", + "Properties": { + "Name": "EventbridgeStackCustomEventBus7DA4065F" + } + }, + "InputLambdaServiceRole4E05AD7C": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "InputLambdaServiceRoleDefaultPolicy9708E6F3": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "events:PutEvents", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "CustomEventBusEC0C3CB8", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "InputLambdaServiceRoleDefaultPolicy9708E6F3", + "Roles": [ + { + "Ref": "InputLambdaServiceRole4E05AD7C" + } + ] + } + }, + "InputLambda695C9911": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\ndef handler(event, context):\n if event.get(\"mode\") == \"failure\":\n raise Exception(\"intentional failure!\")\n else:\n return {\n \"hello\": \"world\",\n \"test\": \"abc\",\n \"val\": 5,\n \"success\": True\n }\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "InputLambdaServiceRole4E05AD7C", + "Arn" + ] + }, + "Runtime": "python3.12" + }, + "DependsOn": [ + "InputLambdaServiceRoleDefaultPolicy9708E6F3", + "InputLambdaServiceRole4E05AD7C" + ] + }, + "InputLambdaEventInvokeConfig580A3D5F": { + "Type": "AWS::Lambda::EventInvokeConfig", + "Properties": { + "DestinationConfig": { + "OnFailure": { + "Destination": { + "Fn::GetAtt": [ + "CustomEventBusEC0C3CB8", + "Arn" + ] + } + }, + "OnSuccess": { + "Destination": { + "Fn::GetAtt": [ + "CustomEventBusEC0C3CB8", + "Arn" + ] + } + } + }, + "FunctionName": { + "Ref": "InputLambda695C9911" + }, + "MaximumRetryAttempts": 0, + "Qualifier": "$LATEST" + } + }, + "TriggeredLambdaServiceRoleBB080110": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "TriggeredLambdaBE2D8BDA": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nimport json\n\ndef handler(event, context):\n print(json.dumps(event))\n return {\"invocation\": True}\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "TriggeredLambdaServiceRoleBB080110", + "Arn" + ] + }, + "Runtime": "python3.12" + }, + "DependsOn": [ + "TriggeredLambdaServiceRoleBB080110" + ] + }, + "EmptyFilterRule6627F20C": { + "Type": "AWS::Events::Rule", + "Properties": { + "EventBusName": { + "Ref": "CustomEventBusEC0C3CB8" + }, + "EventPattern": { + "version": [ + "0" + ] + }, + "Name": "CustomRule", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "TriggeredLambdaBE2D8BDA", + "Arn" + ] + }, + "Id": "Target0" + } + ] + } + }, + "EmptyFilterRuleAllowEventRuleEventbridgeStackTriggeredLambda3DD76C6517715217": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "TriggeredLambdaBE2D8BDA", + "Arn" + ] + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "EmptyFilterRule6627F20C", + "Arn" + ] + } + } + } + }, + "Outputs": { + "InputFunc": { + "Value": { + "Ref": "InputLambda695C9911" + } + }, + "TriggeredFunc": { + "Value": { + "Ref": "TriggeredLambdaBE2D8BDA" + } + }, + "EventBusName": { + "Value": { + "Ref": "CustomEventBusEC0C3CB8" + } + } + } +} diff --git a/tests/aws/cdk_templates/LambdaDestinationScenario/LambdaTestStack.json b/tests/aws/cdk_templates/LambdaDestinationScenario/LambdaTestStack.json new file mode 100644 index 0000000000000..1fbe10711da44 --- /dev/null +++ b/tests/aws/cdk_templates/LambdaDestinationScenario/LambdaTestStack.json @@ -0,0 +1,223 @@ +{ + "Resources": { + "CollectFnServiceRoleF762C82B": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "CollectFn65CC4EC9": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\nimport json\n\ndef handler(event, context):\n print(json.dumps(event))\n return {\"hello\": \"world\"} # the return value here doesn't really matter\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "CollectFnServiceRoleF762C82B", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "CollectFnServiceRoleF762C82B" + ] + }, + "CollectFnInvokePAhlyjIG3CP8l8Cgnn54SqfC1My75Eml5oYjNQSJnA1560504D": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "CollectFn65CC4EC9", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com" + } + }, + "CollectFnAllowInvokeLambdaTestStackDestinationTopicE64F5D1B3DE13B23": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "CollectFn65CC4EC9", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Ref": "DestinationTopicBA438545" + } + } + }, + "CollectFnDestinationTopicE5523079": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Fn::GetAtt": [ + "CollectFn65CC4EC9", + "Arn" + ] + }, + "Protocol": "lambda", + "TopicArn": { + "Ref": "DestinationTopicBA438545" + } + } + }, + "DestinationTopicBA438545": { + "Type": "AWS::SNS::Topic" + }, + "DestinationFnServiceRole45C6FEAB": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "DestinationFnServiceRoleDefaultPolicy2D36D4CF": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "DestinationTopicBA438545" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "DestinationFnServiceRoleDefaultPolicy2D36D4CF", + "Roles": [ + { + "Ref": "DestinationFnServiceRole45C6FEAB" + } + ] + } + }, + "DestinationFn4A629F49": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\ndef handler(event, context):\n should_fail = event.get(\"should_fail\", \"0\") == \"1\"\n message = event.get(\"message\", \"no message received\")\n\n if should_fail:\n raise Exception(\"Failing per design.\")\n\n return {\"lstest_message\": message}\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "DestinationFnServiceRole45C6FEAB", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "DestinationFnServiceRoleDefaultPolicy2D36D4CF", + "DestinationFnServiceRole45C6FEAB" + ] + }, + "TopicEicA2461C61": { + "Type": "AWS::Lambda::EventInvokeConfig", + "Properties": { + "DestinationConfig": { + "OnFailure": { + "Destination": { + "Ref": "DestinationTopicBA438545" + } + }, + "OnSuccess": { + "Destination": { + "Ref": "DestinationTopicBA438545" + } + } + }, + "FunctionName": { + "Ref": "DestinationFn4A629F49" + }, + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 0, + "Qualifier": "$LATEST" + } + } + }, + "Outputs": { + "CollectFunctionName": { + "Value": { + "Ref": "CollectFn65CC4EC9" + } + }, + "DestinationTopicName": { + "Value": { + "Fn::GetAtt": [ + "DestinationTopicBA438545", + "TopicName" + ] + } + }, + "DestinationTopicArn": { + "Value": { + "Ref": "DestinationTopicBA438545" + } + }, + "DestinationFunctionName": { + "Value": { + "Ref": "DestinationFn4A629F49" + } + } + } +} diff --git a/tests/aws/cdk_templates/LoanBroaker/LoanBroker-RecipientList.json b/tests/aws/cdk_templates/LoanBroaker/LoanBroker-RecipientList.json new file mode 100644 index 0000000000000..87828a0c3be28 --- /dev/null +++ b/tests/aws/cdk_templates/LoanBroaker/LoanBroker-RecipientList.json @@ -0,0 +1,766 @@ +{ + "Resources": { + "CreditBureauLambdaServiceRole5747B731": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + } + }, + "CreditBureauLambda41F5F0B1": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "const getRandomInt = (min, max) => {\n return min + Math.floor(Math.random() * (max - min));\n};\nconst min_score = 300;\nconst max_score = 900;\n\nconst getHistoryForSSN = (ssn) => {\n // here should be the logic to retrieve the history of the customer\n if (ssn.startsWith(\"123\")) {\n return 10;\n } else {\n return 13;\n }\n};\n\nconst getScoreForSSN = (ssn) => {\n // here should be the logic to retrieve the score of the customer\n if (ssn.startsWith(\"123\")) {\n return max_score;\n } else {\n return min_score;\n }\n};\n\nexports.handler = async (event) => {\n\n var ssn_regex = new RegExp(\"^\\\\d{3}-\\\\d{2}-\\\\d{4}$\");\n\n\n console.log(\"received event \" + JSON.stringify(event))\n if (ssn_regex.test(event.SSN)) {\n console.log(\"ssn matches pattern\")\n return {\n statusCode: 200,\n request_id: event.RequestId,\n body: {\n SSN: event.SSN,\n score: getScoreForSSN(event.SSN),\n history: getHistoryForSSN(event.SSN),\n },\n };\n } else {\n console.log(\"ssn not matching pattern\")\n return {\n statusCode: 400,\n request_id: event.RequestId,\n body: {\n SSN: event.SSN,\n },\n };\n }\n};\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "CreditBureauLambdaServiceRole5747B731", + "Arn" + ] + }, + "Runtime": "nodejs18.x", + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + }, + "DependsOn": [ + "CreditBureauLambdaServiceRole5747B731" + ] + }, + "LoanBrokerBanksTableB671E6A0": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "Type", + "AttributeType": "S" + } + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": [ + { + "AttributeName": "Type", + "KeyType": "HASH" + } + ], + "TableName": "LoanBrokerBanksTable", + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "LoanBrokerLogGroup0AC7392D": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "RetentionInDays": 731, + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "LoanBrokerRole70979761": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::FindInMap": [ + "ServiceprincipalMap", + { + "Ref": "AWS::Region" + }, + "states" + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + } + }, + "LoanBrokerRoleDefaultPolicy4FA71B4B": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogDelivery", + "logs:GetLogDelivery", + "logs:UpdateLogDelivery", + "logs:DeleteLogDelivery", + "logs:ListLogDeliveries", + "logs:PutResourcePolicy", + "logs:DescribeResourcePolicies", + "logs:DescribeLogGroups" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords", + "xray:GetSamplingRules", + "xray:GetSamplingTargets" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "CreditBureauLambda41F5F0B1", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "CreditBureauLambda41F5F0B1", + "Arn" + ] + }, + ":*" + ] + ] + } + ] + }, + { + "Action": "dynamodb:GetItem", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":dynamodb:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":table/", + { + "Ref": "LoanBrokerBanksTableB671E6A0" + } + ] + ] + } + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "BankRecipientPawnShop487E3BD8", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "BankRecipientPawnShop487E3BD8", + "Arn" + ] + }, + ":*" + ] + ] + } + ] + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "BankRecipientUniversal8F27A740", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "BankRecipientUniversal8F27A740", + "Arn" + ] + }, + ":*" + ] + ] + } + ] + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "BankRecipientPremium36050019", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "BankRecipientPremium36050019", + "Arn" + ] + }, + ":*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "LoanBrokerRoleDefaultPolicy4FA71B4B", + "Roles": [ + { + "Ref": "LoanBrokerRole70979761" + } + ] + } + }, + "LoanBroker641FC9A8": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"Get Credit Score from credit bureau\",\"States\":{\"Get Credit Score from credit bureau\":{\"Next\":\"Fetch Bank Addresses from database\",\"Type\":\"Task\",\"ResultPath\":\"$.Credit\",\"ResultSelector\":{\"Score.$\":\"$.Payload.body.score\",\"History.$\":\"$.Payload.body.history\"},\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::lambda:invoke\",\"Parameters\":{\"FunctionName\":\"", + { + "Fn::GetAtt": [ + "CreditBureauLambda41F5F0B1", + "Arn" + ] + }, + "\",\"Payload\":{\"SSN.$\":\"$.SSN\",\"RequestId.$\":\"$$.Execution.Id\"}}},\"Fetch Bank Addresses from database\":{\"Next\":\"Get all bank quotes\",\"Type\":\"Task\",\"ResultPath\":\"$.Banks\",\"ResultSelector\":{\"BankAddress.$\":\"$.Item.BankAddress.L[*].S\"},\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::dynamodb:getItem\",\"Parameters\":{\"Key\":{\"Type\":{\"S\":\"Home\"}},\"TableName\":\"", + { + "Ref": "LoanBrokerBanksTableB671E6A0" + }, + "\",\"ConsistentRead\":false}},\"Get all bank quotes\":{\"Type\":\"Map\",\"ResultPath\":\"$.Quotes\",\"End\":true,\"Parameters\":{\"function.$\":\"$$.Map.Item.Value\",\"SSN.$\":\"$.SSN\",\"Amount.$\":\"$.Amount\",\"Term.$\":\"$.Term\",\"Credit.$\":\"$.Credit\"},\"Iterator\":{\"StartAt\":\"Get individual bank quotes\",\"States\":{\"Get individual bank quotes\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"arn:aws:states:::lambda:invoke\",\"Parameters\":{\"FunctionName.$\":\"$.function\",\"Payload\":{\"SSN.$\":\"$.SSN\",\"Amount.$\":\"$.Amount\",\"Term.$\":\"$.Term\",\"Credit.$\":\"$.Credit\"}},\"ResultSelector\":{\"Quote.$\":\"$.Payload\"}}}},\"ItemsPath\":\"$.Banks.BankAddress\"}},\"TimeoutSeconds\":300}" + ] + ] + }, + "LoggingConfiguration": { + "Destinations": [ + { + "CloudWatchLogsLogGroup": { + "LogGroupArn": { + "Fn::GetAtt": [ + "LoanBrokerLogGroup0AC7392D", + "Arn" + ] + } + } + } + ], + "IncludeExecutionData": true, + "Level": "ALL" + }, + "RoleArn": { + "Fn::GetAtt": [ + "LoanBrokerRole70979761", + "Arn" + ] + }, + "StateMachineType": "STANDARD", + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ], + "TracingConfiguration": { + "Enabled": true + } + }, + "DependsOn": [ + "LoanBrokerRoleDefaultPolicy4FA71B4B", + "LoanBrokerRole70979761" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "BankRecipientPawnShopServiceRoleF9C28899": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + } + }, + "BankRecipientPawnShop487E3BD8": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "/**\n Each bank will vary its behavior by the following parameters:\n\n MIN_CREDIT_SCORE - the customer's minimum credit score required to receive a quote from this bank.\n MAX_LOAN_AMOUNT - the maximum amount the bank is willing to lend to a customer.\n BASE_RATE - the minimum rate the bank might give. The actual rate increases for a lower credit score and some randomness.\n BANK_ID - as the loan broker processes multiple responses, knowing which bank supplied the quote will be handy.\n */\n\nfunction calcRate(amount, term, score, history) {\n if (amount <= process.env.MAX_LOAN_AMOUNT && score >= process.env.MIN_CREDIT_SCORE) {\n return parseFloat(process.env.BASE_RATE) + history * ((1000 - score) / 100.0);\n }\n}\n\nexports.handler = async (event) => {\n console.log(\"Received request for %s\", process.env.BANK_ID);\n console.log(\"Received event:\", JSON.stringify(event));\n\n const amount = event.Amount;\n const term = event.Term;\n const score = event.Credit.Score;\n const history = event.Credit.History;\n\n const bankId = process.env.BANK_ID;\n\n console.log(\"Loan Request over %d at credit score %d\", amount, score);\n console.log(\"Received term: %d, history: %d\", term, history);\n const rate = calcRate(amount, term, score, history);\n if (rate) {\n const response = { rate: rate, bankId: bankId };\n console.log(response);\n return response;\n } else {\n console.log(\"Rejecting Loan\");\n }\n};\n" + }, + "Environment": { + "Variables": { + "BANK_ID": "PawnShop", + "BASE_RATE": "5", + "MAX_LOAN_AMOUNT": "500000", + "MIN_CREDIT_SCORE": "400" + } + }, + "FunctionName": "BankRecipientPawnShop", + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BankRecipientPawnShopServiceRoleF9C28899", + "Arn" + ] + }, + "Runtime": "nodejs18.x", + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + }, + "DependsOn": [ + "BankRecipientPawnShopServiceRoleF9C28899" + ] + }, + "BankRecipientUniversalServiceRoleD0A4869D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + } + }, + "BankRecipientUniversal8F27A740": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "/**\n Each bank will vary its behavior by the following parameters:\n\n MIN_CREDIT_SCORE - the customer's minimum credit score required to receive a quote from this bank.\n MAX_LOAN_AMOUNT - the maximum amount the bank is willing to lend to a customer.\n BASE_RATE - the minimum rate the bank might give. The actual rate increases for a lower credit score and some randomness.\n BANK_ID - as the loan broker processes multiple responses, knowing which bank supplied the quote will be handy.\n */\n\nfunction calcRate(amount, term, score, history) {\n if (amount <= process.env.MAX_LOAN_AMOUNT && score >= process.env.MIN_CREDIT_SCORE) {\n return parseFloat(process.env.BASE_RATE) + history * ((1000 - score) / 100.0);\n }\n}\n\nexports.handler = async (event) => {\n console.log(\"Received request for %s\", process.env.BANK_ID);\n console.log(\"Received event:\", JSON.stringify(event));\n\n const amount = event.Amount;\n const term = event.Term;\n const score = event.Credit.Score;\n const history = event.Credit.History;\n\n const bankId = process.env.BANK_ID;\n\n console.log(\"Loan Request over %d at credit score %d\", amount, score);\n console.log(\"Received term: %d, history: %d\", term, history);\n const rate = calcRate(amount, term, score, history);\n if (rate) {\n const response = { rate: rate, bankId: bankId };\n console.log(response);\n return response;\n } else {\n console.log(\"Rejecting Loan\");\n }\n};\n" + }, + "Environment": { + "Variables": { + "BANK_ID": "Universal", + "BASE_RATE": "4", + "MAX_LOAN_AMOUNT": "700000", + "MIN_CREDIT_SCORE": "500" + } + }, + "FunctionName": "BankRecipientUniversal", + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BankRecipientUniversalServiceRoleD0A4869D", + "Arn" + ] + }, + "Runtime": "nodejs18.x", + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + }, + "DependsOn": [ + "BankRecipientUniversalServiceRoleD0A4869D" + ] + }, + "BankRecipientPremiumServiceRole4E49E640": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + } + }, + "BankRecipientPremium36050019": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "/**\n Each bank will vary its behavior by the following parameters:\n\n MIN_CREDIT_SCORE - the customer's minimum credit score required to receive a quote from this bank.\n MAX_LOAN_AMOUNT - the maximum amount the bank is willing to lend to a customer.\n BASE_RATE - the minimum rate the bank might give. The actual rate increases for a lower credit score and some randomness.\n BANK_ID - as the loan broker processes multiple responses, knowing which bank supplied the quote will be handy.\n */\n\nfunction calcRate(amount, term, score, history) {\n if (amount <= process.env.MAX_LOAN_AMOUNT && score >= process.env.MIN_CREDIT_SCORE) {\n return parseFloat(process.env.BASE_RATE) + history * ((1000 - score) / 100.0);\n }\n}\n\nexports.handler = async (event) => {\n console.log(\"Received request for %s\", process.env.BANK_ID);\n console.log(\"Received event:\", JSON.stringify(event));\n\n const amount = event.Amount;\n const term = event.Term;\n const score = event.Credit.Score;\n const history = event.Credit.History;\n\n const bankId = process.env.BANK_ID;\n\n console.log(\"Loan Request over %d at credit score %d\", amount, score);\n console.log(\"Received term: %d, history: %d\", term, history);\n const rate = calcRate(amount, term, score, history);\n if (rate) {\n const response = { rate: rate, bankId: bankId };\n console.log(response);\n return response;\n } else {\n console.log(\"Rejecting Loan\");\n }\n};\n" + }, + "Environment": { + "Variables": { + "BANK_ID": "Premium", + "BASE_RATE": "3", + "MAX_LOAN_AMOUNT": "900000", + "MIN_CREDIT_SCORE": "600" + } + }, + "FunctionName": "BankRecipientPremium", + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "BankRecipientPremiumServiceRole4E49E640", + "Arn" + ] + }, + "Runtime": "nodejs18.x", + "Tags": [ + { + "Key": "Project", + "Value": "CDK Loan Broker" + }, + { + "Key": "Stackname", + "Value": "LoanBroker-RecipientList" + } + ] + }, + "DependsOn": [ + "BankRecipientPremiumServiceRole4E49E640" + ] + } + }, + "Outputs": { + "LoanBrokerArn": { + "Value": { + "Ref": "LoanBroker641FC9A8" + } + }, + "LogGroupName": { + "Value": { + "Ref": "LoanBrokerLogGroup0AC7392D" + } + }, + "TableName": { + "Value": { + "Ref": "LoanBrokerBanksTableB671E6A0" + } + } + }, + "Mappings": { + "ServiceprincipalMap": { + "af-south-1": { + "states": "states.af-south-1.amazonaws.com" + }, + "ap-east-1": { + "states": "states.ap-east-1.amazonaws.com" + }, + "ap-northeast-1": { + "states": "states.ap-northeast-1.amazonaws.com" + }, + "ap-northeast-2": { + "states": "states.ap-northeast-2.amazonaws.com" + }, + "ap-northeast-3": { + "states": "states.ap-northeast-3.amazonaws.com" + }, + "ap-south-1": { + "states": "states.ap-south-1.amazonaws.com" + }, + "ap-south-2": { + "states": "states.ap-south-2.amazonaws.com" + }, + "ap-southeast-1": { + "states": "states.ap-southeast-1.amazonaws.com" + }, + "ap-southeast-2": { + "states": "states.ap-southeast-2.amazonaws.com" + }, + "ap-southeast-3": { + "states": "states.ap-southeast-3.amazonaws.com" + }, + "ap-southeast-4": { + "states": "states.ap-southeast-4.amazonaws.com" + }, + "ca-central-1": { + "states": "states.ca-central-1.amazonaws.com" + }, + "cn-north-1": { + "states": "states.cn-north-1.amazonaws.com" + }, + "cn-northwest-1": { + "states": "states.cn-northwest-1.amazonaws.com" + }, + "eu-central-1": { + "states": "states.eu-central-1.amazonaws.com" + }, + "eu-central-2": { + "states": "states.eu-central-2.amazonaws.com" + }, + "eu-north-1": { + "states": "states.eu-north-1.amazonaws.com" + }, + "eu-south-1": { + "states": "states.eu-south-1.amazonaws.com" + }, + "eu-south-2": { + "states": "states.eu-south-2.amazonaws.com" + }, + "eu-west-1": { + "states": "states.eu-west-1.amazonaws.com" + }, + "eu-west-2": { + "states": "states.eu-west-2.amazonaws.com" + }, + "eu-west-3": { + "states": "states.eu-west-3.amazonaws.com" + }, + "il-central-1": { + "states": "states.il-central-1.amazonaws.com" + }, + "me-central-1": { + "states": "states.me-central-1.amazonaws.com" + }, + "me-south-1": { + "states": "states.me-south-1.amazonaws.com" + }, + "sa-east-1": { + "states": "states.sa-east-1.amazonaws.com" + }, + "us-east-1": { + "states": "states.us-east-1.amazonaws.com" + }, + "us-east-2": { + "states": "states.us-east-2.amazonaws.com" + }, + "us-gov-east-1": { + "states": "states.us-gov-east-1.amazonaws.com" + }, + "us-gov-west-1": { + "states": "states.us-gov-west-1.amazonaws.com" + }, + "us-iso-east-1": { + "states": "states.amazonaws.com" + }, + "us-iso-west-1": { + "states": "states.amazonaws.com" + }, + "us-isob-east-1": { + "states": "states.amazonaws.com" + }, + "us-west-1": { + "states": "states.us-west-1.amazonaws.com" + }, + "us-west-2": { + "states": "states.us-west-2.amazonaws.com" + } + } + } +} diff --git a/tests/aws/cdk_templates/MythicalMisfits/MythicalMisfitsStack.json b/tests/aws/cdk_templates/MythicalMisfits/MythicalMisfitsStack.json new file mode 100644 index 0000000000000..098544bffe4d9 --- /dev/null +++ b/tests/aws/cdk_templates/MythicalMisfits/MythicalMisfitsStack.json @@ -0,0 +1,797 @@ +{ + "Resources": { + "MysfitsTable54ADD99F": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "MysfitId", + "AttributeType": "S" + }, + { + "AttributeName": "LawChaos", + "AttributeType": "S" + }, + { + "AttributeName": "GoodEvil", + "AttributeType": "S" + } + ], + "GlobalSecondaryIndexes": [ + { + "IndexName": "LawChaosIndex", + "KeySchema": [ + { + "AttributeName": "LawChaos", + "KeyType": "HASH" + }, + { + "AttributeName": "MysfitId", + "KeyType": "RANGE" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + }, + { + "IndexName": "GoodEvilIndex", + "KeySchema": [ + { + "AttributeName": "GoodEvil", + "KeyType": "HASH" + }, + { + "AttributeName": "MysfitId", + "KeyType": "RANGE" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + } + ], + "KeySchema": [ + { + "AttributeName": "MysfitId", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableName": "MysfitsTable" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserClicksServiceClicksBucketDestinationDF882499": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-mythical-mysfits-b79e2d99" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserClicksServiceStreamProcessorFunctionServiceRole4E9C7918": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "UserClicksServiceStreamProcessorFunctionServiceRoleDefaultPolicy558EF0E6": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:GetItem", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "MysfitsTable54ADD99F", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "UserClicksServiceStreamProcessorFunctionServiceRoleDefaultPolicy558EF0E6", + "Roles": [ + { + "Ref": "UserClicksServiceStreamProcessorFunctionServiceRole4E9C7918" + } + ] + } + }, + "UserClicksServiceStreamProcessorFunctionDC65B364": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "# Source adapted from: https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk\n# The code to be used as an AWS Lambda function for processing real-time\n# user click records from Kinesis Firehose and adding additional attributes\n# to them before they are stored in Amazon S3.\nfrom __future__ import print_function\n\nimport base64\nimport json\n\n# TODO: when properly integrating Fargate into the sample, re-add this code fetching the data through the microservices\n# beware, it will need packaging then because of `requests`\n# import requests\nimport os\n\nimport boto3\n\n# Send a request to the Mysfits Service API that we have created in previous\n# modules to retrieve all of the attributes for the included MysfitId.\n# def retrieveMysfit(mysfitId):\n# apiEndpoint = os.environ['MYSFITS_API_URL'] + '/mysfits/' + str(mysfitId) # eg: 'https://ljqomqjzbf.execute-api.us-east-1.amazonaws.com/prod/'\n# mysfit = requests.get(apiEndpoint).json()\n# return mysfit\n\n\nclient = boto3.client(\"dynamodb\")\n\n\n# Directly fetch the data from the DynamoDB table as we don't have the Mysfits microservice yet\n# source adapted from https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk/module-5/app/service/mysfitsTableClient.py\ndef retrieveMysfit(mysfitId):\n # use the DynamoDB API GetItem, which gives you the ability to retrieve\n # a single item from a DynamoDB table using its unique key with super low latency.\n response = client.get_item(\n TableName=os.environ[\"MYSFITS_TABLE_NAME\"], Key={\"MysfitId\": {\"S\": mysfitId}}\n )\n\n item = response[\"Item\"]\n\n mysfit = {\n \"mysfitId\": item[\"MysfitId\"][\"S\"],\n \"name\": item[\"Name\"][\"S\"],\n \"age\": int(item[\"Age\"][\"N\"]),\n \"goodevil\": item[\"GoodEvil\"][\"S\"],\n \"lawchaos\": item[\"LawChaos\"][\"S\"],\n \"species\": item[\"Species\"][\"S\"],\n \"thumbImageUri\": item[\"ThumbImageUri\"][\"S\"],\n \"profileImageUri\": item[\"ProfileImageUri\"][\"S\"],\n \"likes\": item[\"Likes\"][\"N\"],\n \"adopted\": item[\"Adopted\"][\"BOOL\"],\n }\n\n return mysfit\n\n\n# The below method will serve as the \"handler\" for the Lambda function. The\n# handler is the method that AWS Lambda will invoke with events, which in this\n# case will include records from the Kinesis Firehose Delivery Stream.\ndef processRecord(event, context):\n output = []\n\n # retrieve the list of records included with the event and loop through\n # them to retrieve the full list of mysfit attributes and add the additional\n # attributes that a hypothetical BI/Analyitcs team would like to analyze.\n for record in event[\"records\"]:\n print(\"Processing record: \" + record[\"recordId\"])\n # kinesis firehose expects record payloads to be sent as encoded strings,\n # so we must decode the data first to retrieve the click record.\n click = json.loads(base64.b64decode(record[\"data\"]))\n\n mysfitId = click[\"mysfitId\"]\n mysfit = retrieveMysfit(mysfitId)\n\n enrichedClick = {\n \"userId\": click[\"userId\"],\n \"mysfitId\": mysfitId,\n \"goodevil\": mysfit[\"goodevil\"],\n \"lawchaos\": mysfit[\"lawchaos\"],\n \"species\": mysfit[\"species\"],\n }\n\n # create the output record that Kinesis Firehose will store in S3.\n output_record = {\n \"recordId\": record[\"recordId\"],\n \"result\": \"Ok\",\n \"data\": base64.b64encode(json.dumps(enrichedClick).encode(\"utf-8\") + b\"\\n\").decode(\n \"utf-8\"\n ),\n }\n output.append(output_record)\n\n print(\"Successfully processed {} records.\".format(len(event[\"records\"])))\n\n # return the enriched records to Kiesis Firehose.\n return {\"records\": output}\n" + }, + "Description": "An Amazon Kinesis Firehose stream processor that enriches click records to not just include a mysfitId, but also other attributes that can be analyzed later.", + "Environment": { + "Variables": { + "MYSFITS_TABLE_NAME": { + "Ref": "MysfitsTable54ADD99F" + } + } + }, + "Handler": "index.processRecord", + "MemorySize": 128, + "Role": { + "Fn::GetAtt": [ + "UserClicksServiceStreamProcessorFunctionServiceRole4E9C7918", + "Arn" + ] + }, + "Runtime": "python3.10", + "Timeout": 30 + }, + "DependsOn": [ + "UserClicksServiceStreamProcessorFunctionServiceRoleDefaultPolicy558EF0E6", + "UserClicksServiceStreamProcessorFunctionServiceRole4E9C7918" + ] + }, + "UserClicksServiceStreamProcessorFunctionLambdaPermission332D5CE1": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "UserClicksServiceStreamProcessorFunctionDC65B364", + "Arn" + ] + }, + "Principal": "firehose.amazonaws.com", + "SourceAccount": { + "Ref": "AWS::AccountId" + }, + "SourceArn": { + "Fn::GetAtt": [ + "UserClicksServiceDeliveryStream80EEBD2E", + "Arn" + ] + } + } + }, + "UserClicksServiceFirehoseDeliveryRole29B27061": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": { + "Ref": "AWS::AccountId" + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "firehose.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "RoleName": "FirehoseDeliveryRole" + } + }, + "UserClicksServiceFirehoseDeliveryRoleDefaultPolicy7FD18B56": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:PutObject" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "UserClicksServiceClicksBucketDestinationDF882499", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "UserClicksServiceClicksBucketDestinationDF882499", + "Arn" + ] + }, + "/*" + ] + ] + } + ] + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "UserClicksServiceStreamProcessorFunctionDC65B364", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "UserClicksServiceFirehoseDeliveryRoleDefaultPolicy7FD18B56", + "Roles": [ + { + "Ref": "UserClicksServiceFirehoseDeliveryRole29B27061" + } + ] + } + }, + "UserClicksServiceDeliveryStream80EEBD2E": { + "Type": "AWS::KinesisFirehose::DeliveryStream", + "Properties": { + "ExtendedS3DestinationConfiguration": { + "BucketARN": { + "Fn::GetAtt": [ + "UserClicksServiceClicksBucketDestinationDF882499", + "Arn" + ] + }, + "BufferingHints": { + "IntervalInSeconds": 60, + "SizeInMBs": 50 + }, + "CompressionFormat": "UNCOMPRESSED", + "Prefix": "firehose/", + "ProcessingConfiguration": { + "Enabled": true, + "Processors": [ + { + "Parameters": [ + { + "ParameterName": "LambdaArn", + "ParameterValue": { + "Fn::GetAtt": [ + "UserClicksServiceStreamProcessorFunctionDC65B364", + "Arn" + ] + } + } + ], + "Type": "Lambda" + } + ] + }, + "RoleARN": { + "Fn::GetAtt": [ + "UserClicksServiceFirehoseDeliveryRole29B27061", + "Arn" + ] + } + } + } + }, + "UserClicksServiceClickProcessingApiRole2A07FAA0": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "UserClicksServiceClickProcessingApiPolicy57123348": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "firehose:PutRecord", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "UserClicksServiceDeliveryStream80EEBD2E", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "api_gateway_firehose_proxy_role", + "Roles": [ + { + "Ref": "UserClicksServiceClickProcessingApiRole2A07FAA0" + } + ] + } + }, + "UserClicksServiceAPIEndpointD3A13D1A": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + }, + "Name": "ClickProcessing API Service" + } + }, + "UserClicksServiceAPIEndpointCloudWatchRoleFD30C647": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserClicksServiceAPIEndpointAccount4686F39F": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "UserClicksServiceAPIEndpointCloudWatchRoleFD30C647", + "Arn" + ] + } + }, + "DependsOn": [ + "UserClicksServiceAPIEndpointD3A13D1A" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserClicksServiceAPIEndpointDeployment618310CCa3df9648bfddb878e364218850fa6d41": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "Description": "Automatically created by the RestApi construct", + "RestApiId": { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + } + }, + "DependsOn": [ + "UserClicksServiceAPIEndpointclicksOPTIONSDA545868", + "UserClicksServiceAPIEndpointclicksPUT2F36C684", + "UserClicksServiceAPIEndpointclicks4E4FCA5C" + ] + }, + "UserClicksServiceAPIEndpointDeploymentStageprod45DCCB13": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "DeploymentId": { + "Ref": "UserClicksServiceAPIEndpointDeployment618310CCa3df9648bfddb878e364218850fa6d41" + }, + "RestApiId": { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + }, + "StageName": "prod" + }, + "DependsOn": [ + "UserClicksServiceAPIEndpointAccount4686F39F" + ] + }, + "UserClicksServiceAPIEndpointclicks4E4FCA5C": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "UserClicksServiceAPIEndpointD3A13D1A", + "RootResourceId" + ] + }, + "PathPart": "clicks", + "RestApiId": { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + } + } + }, + "UserClicksServiceAPIEndpointclicksPUT2F36C684": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "PUT", + "Integration": { + "ConnectionType": "INTERNET", + "Credentials": { + "Fn::GetAtt": [ + "UserClicksServiceClickProcessingApiRole2A07FAA0", + "Arn" + ] + }, + "IntegrationHttpMethod": "POST", + "IntegrationResponses": [ + { + "ResponseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,PUT'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "ResponseTemplates": { + "application/json": "{\"status\":\"OK\"}" + }, + "StatusCode": "200" + } + ], + "RequestParameters": { + "integration.request.header.Content-Type": "'application/x-amz-json-1.1'" + }, + "RequestTemplates": { + "application/json": { + "Fn::Join": [ + "", + [ + "{\"DeliveryStreamName\": \"", + { + "Ref": "UserClicksServiceDeliveryStream80EEBD2E" + }, + "\", \"Record\": {\"Data\": \"$util.base64Encode($input.json('$'))\"}}" + ] + ] + } + }, + "Type": "AWS", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":firehose:action/PutRecord" + ] + ] + } + }, + "MethodResponses": [ + { + "ResponseParameters": { + "method.response.header.Access-Control-Allow-Headers": true, + "method.response.header.Access-Control-Allow-Methods": true, + "method.response.header.Access-Control-Allow-Origin": true + }, + "StatusCode": "200" + } + ], + "ResourceId": { + "Ref": "UserClicksServiceAPIEndpointclicks4E4FCA5C" + }, + "RestApiId": { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + } + } + }, + "UserClicksServiceAPIEndpointclicksOPTIONSDA545868": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "OPTIONS", + "Integration": { + "IntegrationResponses": [ + { + "ResponseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", + "method.response.header.Access-Control-Allow-Origin": "'*'", + "method.response.header.Access-Control-Allow-Credentials": "'false'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,GET,PUT,POST,DELETE'" + }, + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "ResponseParameters": { + "method.response.header.Access-Control-Allow-Headers": true, + "method.response.header.Access-Control-Allow-Methods": true, + "method.response.header.Access-Control-Allow-Credentials": true, + "method.response.header.Access-Control-Allow-Origin": true + }, + "StatusCode": "200" + } + ], + "ResourceId": { + "Ref": "UserClicksServiceAPIEndpointclicks4E4FCA5C" + }, + "RestApiId": { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + } + } + }, + "PopulateDbFnServiceRoleB4CF4518": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "PopulateDbFnServiceRoleDefaultPolicy8B14EE23": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:Query", + "dynamodb:GetItem", + "dynamodb:Scan", + "dynamodb:ConditionCheckItem", + "dynamodb:BatchWriteItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:DescribeTable" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "MysfitsTable54ADD99F", + "Arn" + ] + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "MysfitsTable54ADD99F", + "Arn" + ] + }, + "/index/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "PopulateDbFnServiceRoleDefaultPolicy8B14EE23", + "Roles": [ + { + "Ref": "PopulateDbFnServiceRoleB4CF4518" + } + ] + } + }, + "PopulateDbFnECBC3588": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "# Source adapted from: https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk\nfrom __future__ import print_function\n\nimport os\n\nimport boto3\n\nclient = boto3.client(\"dynamodb\")\n\ninit_data = [\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"4e53920c-505a-4a90-a694-b9300791f0ae\"},\n \"Name\": {\"S\": \"Evangeline\"},\n \"Species\": {\"S\": \"Chimera\"},\n \"Description\": {\n \"S\": \"Evangeline is the global sophisticate of the mythical world. You\u2019d be hard pressed to find a more seductive, charming, and mysterious companion with a love for neoclassical architecture, and a degree in medieval studies. Don\u2019t let her beauty and brains distract you. While her mane may always be perfectly coifed, her tail is ever-coiled and ready to strike. Careful not to let your guard down, or you may just find yourself spiraling into a dazzling downfall of dizzying dimensions.\"\n },\n \"Age\": {\"N\": \"43\"},\n \"GoodEvil\": {\"S\": \"Evil\"},\n \"LawChaos\": {\"S\": \"Lawful\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/chimera_thumb.png\"},\n \"ProfileImageUri\": {\n \"S\": \"https://www.mythicalmysfits.com/images/chimera_hover.png\"\n },\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"2b473002-36f8-4b87-954e-9a377e0ccbec\"},\n \"Name\": {\"S\": \"Pauly\"},\n \"Species\": {\"S\": \"Cyclops\"},\n \"Description\": {\n \"S\": \"Naturally needy and tyrannically temperamental, Pauly the infant cyclops is searching for a parental figure to call friend. Like raising any precocious tot, there may be occasional tantrums of thunder, lightning, and 100 decibel shrieking. Sooth him with some Mandrake root and you\u2019ll soon wonder why people even bother having human children. Gaze into his precious eye and fall in love with this adorable tyke.\"\n },\n \"Age\": {\"N\": \"2\"},\n \"GoodEvil\": {\"S\": \"Neutral\"},\n \"LawChaos\": {\"S\": \"Lawful\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/cyclops_thumb.png\"},\n \"ProfileImageUri\": {\n \"S\": \"https://www.mythicalmysfits.com/images/cyclops_hover.png\"\n },\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"0e37d916-f960-4772-a25a-01b762b5c1bd\"},\n \"Name\": {\"S\": \"CoCo\"},\n \"Species\": {\"S\": \"Dragon\"},\n \"Description\": {\n \"S\": \"CoCo wears sunglasses at night. His hobbies include dressing up for casual nights out, accumulating debt, and taking his friends on his back for a terrifying ride through the mesosphere after a long night of revelry, where you pick up the bill, of course. For all his swagger, CoCo has a heart of gold. His loyalty knows no bounds, and once bonded, you\u2019ve got a wingman (literally) for life.\"\n },\n \"Age\": {\"N\": \"501\"},\n \"GoodEvil\": {\"S\": \"Good\"},\n \"LawChaos\": {\"S\": \"Chaotic\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/dragon_thumb.png\"},\n \"ProfileImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/dragon_hover.png\"},\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"da5303ae-5aba-495c-b5d6-eb5c4a66b941\"},\n \"Name\": {\"S\": \"Gretta\"},\n \"Species\": {\"S\": \"Gorgon\"},\n \"Description\": {\n \"S\": \"Young, fun, and perfectly mischievous, Gorgon is mostly tail. She's currently growing her horns and hoping for wings like those of her high-flying counterparts. In the meantime, she dons an umbrella and waits for gusts of wind to transport her across space-time. She likes to tell jokes in fluent Parseltongue, read the evening news, and shoot fireworks across celestial lines. If you like high-risk, high-reward challenges, Gorgon will be the best pet you never knew you wanted.\"\n },\n \"Age\": {\"N\": \"31\"},\n \"GoodEvil\": {\"S\": \"Evil\"},\n \"LawChaos\": {\"S\": \"Neutral\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/gorgon_thumb.png\"},\n \"ProfileImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/gorgon_hover.png\"},\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"a901bb08-1985-42f5-bb77-27439ac14300\"},\n \"Name\": {\"S\": \"Hasla\"},\n \"Species\": {\"S\": \"Haetae\"},\n \"Description\": {\n \"S\": \"Hasla's presence warms every room. For the last 2 billion years, she's made visitors from far-away lands and the galaxy next door feel immediately at ease. Usually it's because of her big heart, but sometimes it's because of the fire she breathes\u2014especially after eating garlic and starlight. Hasla loves togetherness, board games, and asking philosophical questions that leave people pondering the meaning of life as they fall asleep at night.\"\n },\n \"Age\": {\"N\": \"2000000000\"},\n \"GoodEvil\": {\"S\": \"Good\"},\n \"LawChaos\": {\"S\": \"Neutral\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/haetae_thumb.png\"},\n \"ProfileImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/haetae_hover.png\"},\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"b41ff031-141e-4a8d-bb56-158a22bea0b3\"},\n \"Name\": {\"S\": \"Snowflake\"},\n \"Species\": {\"S\": \"Yeti\"},\n \"Description\": {\n \"S\": \"While Snowflake is a snowman, the only abomination is that he hasn\u2019t been adopted yet. Snowflake is curious, playful, and loves to bound around in the snow. He likes winter hikes, hide and go seek, and all things Christmas. He can get a bit agitated when being scolded or having his picture taken and can occasionally cause devastating avalanches, so we don\u2019t recommend him for beginning pet owners. However, with love, care, and a lot of ice, Snowflake will make a wonderful companion.\"\n },\n \"Age\": {\"N\": \"13\"},\n \"GoodEvil\": {\"S\": \"Evil\"},\n \"LawChaos\": {\"S\": \"Neutral\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/yeti_thumb.png\"},\n \"ProfileImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/yeti_hover.png\"},\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"3f0f196c-4a7b-43af-9e29-6522a715342d\"},\n \"Name\": {\"S\": \"Gary\"},\n \"Species\": {\"S\": \"Kraken\"},\n \"Description\": {\n \"S\": \"Gary loves to have a good time. His motto? \u201cI just want to dance.\u201d Give Gary a disco ball, a DJ, and a hat that slightly obscures the vision from his top eye, and Gary will dance the year away which, at his age, is like one night in humanoid time. If you're looking for a low-maintenance, high-energy creature companion that never sheds and always shreds, Gary is just the kraken for you.\"\n },\n \"Age\": {\"N\": \"2709\"},\n \"GoodEvil\": {\"S\": \"Neutral\"},\n \"LawChaos\": {\"S\": \"Chaotic\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/kraken_thumb.png\"},\n \"ProfileImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/kraken_hover.png\"},\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"a68db521-c031-44c7-b5ef-bfa4c0850e2a\"},\n \"Name\": {\"S\": \"Nessi\"},\n \"Species\": {\"S\": \"Plesiosaurus\"},\n \"Description\": {\n \"S\": \"Nessi is a fun-loving and playful girl who will quickly lock on to your love and nestle into your heart. While shy at first, Nessi is energetic and loves to play with toys such as fishing boats, large sharks, frisbees, errant swimmers, and wand toys. As an aquatic animal, Nessi will need deep water to swim in; at least 15 feet though she prefers 750. Nessi would be a wonderful companion for anyone seeking a loving, 1 ton ball of joy.\"\n },\n \"Age\": {\"N\": \"75000000\"},\n \"GoodEvil\": {\"S\": \"Neutral\"},\n \"LawChaos\": {\"S\": \"Neutral\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/nessie_thumb.png\"},\n \"ProfileImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/nessie_hover.png\"},\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"c0684344-1eb7-40e7-b334-06d25ac9268c\"},\n \"Name\": {\"S\": \"Atlantis\"},\n \"Species\": {\"S\": \"Mandrake\"},\n \"Description\": {\n \"S\": \"Do you like long naps in the dirt, vegetable-like appendages, mind-distorting screaming, and a unmatched humanoid-like root system? Look no further, Atlantis is the perfect companion to accompany you down the rabbit hole! Atlantis is rooted in habitual power napping and can unleash a terse warning when awakened. Like all of us, at the end of a long nap, all Atlantis needs is a soothing milk or blood bath to take the edge off. If you're looking to take a trip, this mandrake is your ideal travel companion.\"\n },\n \"Age\": {\"N\": \"100\"},\n \"GoodEvil\": {\"S\": \"Neutral\"},\n \"LawChaos\": {\"S\": \"Neutral\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/mandrake_thumb.png\"},\n \"ProfileImageUri\": {\n \"S\": \"https://www.mythicalmysfits.com/images/mandrake_hover.png\"\n },\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"ac3e95f3-eb40-4e4e-a605-9fdd0224877c\"},\n \"Name\": {\"S\": \"Twilight Glitter\"},\n \"Species\": {\"S\": \"Pegasus\"},\n \"Description\": {\n \"S\": \"Twilight\u2019s personality sparkles like the night sky and is looking for a forever home with a Greek hero or God. While on the smaller side at 14 hands, he is quite adept at accepting riders and can fly to 15,000 feet. Twilight needs a large area to run around in and will need to be registered with the FAA if you plan to fly him above 500 feet. His favorite activities include playing with chimeras, going on epic adventures into battle, and playing with a large inflatable ball around the paddock. If you bring him home, he\u2019ll quickly become your favorite little Pegasus.\"\n },\n \"Age\": {\"N\": \"6\"},\n \"GoodEvil\": {\"S\": \"Good\"},\n \"LawChaos\": {\"S\": \"Lawful\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/pegasus_thumb.png\"},\n \"ProfileImageUri\": {\n \"S\": \"https://www.mythicalmysfits.com/images/pegasus_hover.png\"\n },\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"33e1fbd4-2fd8-45fb-a42f-f92551694506\"},\n \"Name\": {\"S\": \"Cole\"},\n \"Species\": {\"S\": \"Phoenix\"},\n \"Description\": {\n \"S\": \"Cole is a loving companion and the perfect starter pet for kids. Cole\u2019s tears can fix almost any boo-boo your children may receive (up to partial limb amputation). You never have to worry about your kids accidentally killing him as he\u2019ll just be reborn in a fun burst of flame if they do. Even better, Cole has the uncanny ability to force all those around him to tell the truth, so say goodbye to fibs about not eating a cookie before dinner or where your teenager actually went that night. Adopt him today and find out how he will be the perfect family member for you, your children, their children, their children\u2019s children, and so on.\"\n },\n \"Age\": {\"N\": \"1393\"},\n \"GoodEvil\": {\"S\": \"Good\"},\n \"LawChaos\": {\"S\": \"Chaotic\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/phoenix_thumb.png\"},\n \"ProfileImageUri\": {\n \"S\": \"https://www.mythicalmysfits.com/images/phoenix_hover.png\"\n },\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n {\n \"PutRequest\": {\n \"Item\": {\n \"MysfitId\": {\"S\": \"b6d16e02-6aeb-413c-b457-321151bb403d\"},\n \"Name\": {\"S\": \"Rujin\"},\n \"Species\": {\"S\": \"Troll\"},\n \"Description\": {\n \"S\": \"Are you looking for a strong companion for raids, someone to throw lightning during a pillage, or just a cuddly buddy who can light a campfire from 200 meters? Look no further than Rujin, a troll mage just coming into his teenage years. Rujin is a loyal companion who loves adventure, camping, and long walks through burning villages. He is great with kids and makes a wonderful guard-troll, especially if you have a couple bridges on your property. Rujin has a bit of a soft spot for gold, so you\u2019ll need to keep yours well hidden from him. Since he does keep a hoard on our property, we\u2019re waiving the adoption fee!\"\n },\n \"Age\": {\"N\": \"221\"},\n \"GoodEvil\": {\"S\": \"Evil\"},\n \"LawChaos\": {\"S\": \"Chaotic\"},\n \"ThumbImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/troll_thumb.png\"},\n \"ProfileImageUri\": {\"S\": \"https://www.mythicalmysfits.com/images/troll_hover.png\"},\n \"Likes\": {\"N\": \"0\"},\n \"Adopted\": {\"BOOL\": False},\n }\n }\n },\n]\ntable_name = os.environ[\"mysfitsTable\"]\n\n\n# source adapted from https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk/module-3/data/populate-dynamodb.json\ndef insertMysfits(event, context):\n response = client.batch_write_item(\n RequestItems={\n table_name: init_data,\n }\n )\n print(response)\n" + }, + "Environment": { + "Variables": { + "mysfitsTable": { + "Ref": "MysfitsTable54ADD99F" + } + } + }, + "Handler": "index.insertMysfits", + "Role": { + "Fn::GetAtt": [ + "PopulateDbFnServiceRoleB4CF4518", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "PopulateDbFnServiceRoleDefaultPolicy8B14EE23", + "PopulateDbFnServiceRoleB4CF4518" + ] + } + }, + "Outputs": { + "UserClicksServiceAPIEndpoint1DA4E100": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "UserClicksServiceAPIEndpointDeploymentStageprod45DCCB13" + }, + "/" + ] + ] + } + }, + "ClicksBucketDestinationName": { + "Value": { + "Ref": "UserClicksServiceClicksBucketDestinationDF882499" + } + }, + "DeliveryStreamArn": { + "Value": { + "Fn::GetAtt": [ + "UserClicksServiceDeliveryStream80EEBD2E", + "Arn" + ] + } + }, + "DeliveryStreamName": { + "Value": { + "Ref": "UserClicksServiceDeliveryStream80EEBD2E" + } + }, + "StreamProcessorFunctionName": { + "Value": { + "Ref": "UserClicksServiceStreamProcessorFunctionDC65B364" + } + }, + "PopulateDbFunctionName": { + "Value": { + "Ref": "PopulateDbFnECBC3588" + } + }, + "MysfitsTableName": { + "Value": { + "Ref": "MysfitsTable54ADD99F" + } + }, + "UserClicksServiceAPIEndpoint": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "UserClicksServiceAPIEndpointDeploymentStageprod45DCCB13" + }, + "/" + ] + ] + } + }, + "UserClicksServiceAPIId": { + "Value": { + "Ref": "UserClicksServiceAPIEndpointD3A13D1A" + } + } + } +} diff --git a/tests/aws/cdk_templates/NoteTaking/NoteTakingStack.json b/tests/aws/cdk_templates/NoteTaking/NoteTakingStack.json new file mode 100644 index 0000000000000..54146b1afbbbe --- /dev/null +++ b/tests/aws/cdk_templates/NoteTaking/NoteTakingStack.json @@ -0,0 +1,1347 @@ +{ + "Resources": { + "notesAF81B09D": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "noteId", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "noteId", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "endpointE7B9679B": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "endpoint" + } + }, + "endpointCloudWatchRole52213BC3": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "endpointAccount6DA1D142": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "endpointCloudWatchRole52213BC3", + "Arn" + ] + } + }, + "DependsOn": [ + "endpointE7B9679B" + ], + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "endpointDeployment457D977Dcec5da4f84c2fadf4fb0f4a20c82b818": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "Description": "Automatically created by the RestApi construct", + "RestApiId": { + "Ref": "endpointE7B9679B" + } + }, + "DependsOn": [ + "endpointnotesidDELETEAD0C5CAD", + "endpointnotesidGETDA67C376", + "endpointnotesidOPTIONS6939D830", + "endpointnotesidPUTA29F9F3F", + "endpointnotesid284FFD82", + "endpointnotesGETFA0BD3D8", + "endpointnotesPOST22F9725C", + "endpointnotesF7F33EB6" + ] + }, + "endpointDeploymentStageprod2CD5F9C4": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "DeploymentId": { + "Ref": "endpointDeployment457D977Dcec5da4f84c2fadf4fb0f4a20c82b818" + }, + "RestApiId": { + "Ref": "endpointE7B9679B" + }, + "StageName": "prod" + }, + "DependsOn": [ + "endpointAccount6DA1D142" + ] + }, + "endpointnotesF7F33EB6": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "endpointE7B9679B", + "RootResourceId" + ] + }, + "PathPart": "notes", + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "endpointnotesGETApiPermissionNoteTakingStackendpoint7B805353GETnotes39B76FB0": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "listNoteshandlerDC187D34", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/", + { + "Ref": "endpointDeploymentStageprod2CD5F9C4" + }, + "/GET/notes" + ] + ] + } + } + }, + "endpointnotesGETApiPermissionTestNoteTakingStackendpoint7B805353GETnotes8D8B589D": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "listNoteshandlerDC187D34", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/test-invoke-stage/GET/notes" + ] + ] + } + } + }, + "endpointnotesGETFA0BD3D8": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "listNoteshandlerDC187D34", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "ResourceId": { + "Ref": "endpointnotesF7F33EB6" + }, + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "endpointnotesPOSTApiPermissionNoteTakingStackendpoint7B805353POSTnotesCBF01E91": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "createNotehandlerBA768AA5", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/", + { + "Ref": "endpointDeploymentStageprod2CD5F9C4" + }, + "/POST/notes" + ] + ] + } + } + }, + "endpointnotesPOSTApiPermissionTestNoteTakingStackendpoint7B805353POSTnotesBA343362": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "createNotehandlerBA768AA5", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/test-invoke-stage/POST/notes" + ] + ] + } + } + }, + "endpointnotesPOST22F9725C": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "POST", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "createNotehandlerBA768AA5", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "ResourceId": { + "Ref": "endpointnotesF7F33EB6" + }, + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "endpointnotesid284FFD82": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Ref": "endpointnotesF7F33EB6" + }, + "PathPart": "{id}", + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "endpointnotesidOPTIONS6939D830": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "HttpMethod": "OPTIONS", + "Integration": { + "IntegrationResponses": [ + { + "ResponseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", + "method.response.header.Access-Control-Allow-Origin": "'*'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD'" + }, + "StatusCode": "204" + } + ], + "RequestTemplates": { + "application/json": "{ statusCode: 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "ResponseParameters": { + "method.response.header.Access-Control-Allow-Headers": true, + "method.response.header.Access-Control-Allow-Origin": true, + "method.response.header.Access-Control-Allow-Methods": true + }, + "StatusCode": "204" + } + ], + "ResourceId": { + "Ref": "endpointnotesid284FFD82" + }, + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "endpointnotesidGETApiPermissionNoteTakingStackendpoint7B805353GETnotesid84EE0B60": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "getNotehandler167C5FCF", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/", + { + "Ref": "endpointDeploymentStageprod2CD5F9C4" + }, + "/GET/notes/*" + ] + ] + } + } + }, + "endpointnotesidGETApiPermissionTestNoteTakingStackendpoint7B805353GETnotesidAB290F46": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "getNotehandler167C5FCF", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/test-invoke-stage/GET/notes/*" + ] + ] + } + } + }, + "endpointnotesidGETDA67C376": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "GET", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "getNotehandler167C5FCF", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "ResourceId": { + "Ref": "endpointnotesid284FFD82" + }, + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "endpointnotesidPUTApiPermissionNoteTakingStackendpoint7B805353PUTnotesid1848C2A1": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "updateNotehandler6A5A41DF", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/", + { + "Ref": "endpointDeploymentStageprod2CD5F9C4" + }, + "/PUT/notes/*" + ] + ] + } + } + }, + "endpointnotesidPUTApiPermissionTestNoteTakingStackendpoint7B805353PUTnotesidE645D506": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "updateNotehandler6A5A41DF", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/test-invoke-stage/PUT/notes/*" + ] + ] + } + } + }, + "endpointnotesidPUTA29F9F3F": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "PUT", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "updateNotehandler6A5A41DF", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "ResourceId": { + "Ref": "endpointnotesid284FFD82" + }, + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "endpointnotesidDELETEApiPermissionNoteTakingStackendpoint7B805353DELETEnotesid993C0373": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "deleteNotehandlerC903D399", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/", + { + "Ref": "endpointDeploymentStageprod2CD5F9C4" + }, + "/DELETE/notes/*" + ] + ] + } + } + }, + "endpointnotesidDELETEApiPermissionTestNoteTakingStackendpoint7B805353DELETEnotesid2095ABA3": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "deleteNotehandlerC903D399", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "endpointE7B9679B" + }, + "/test-invoke-stage/DELETE/notes/*" + ] + ] + } + } + }, + "endpointnotesidDELETEAD0C5CAD": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "DELETE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "deleteNotehandlerC903D399", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "ResourceId": { + "Ref": "endpointnotesid284FFD82" + }, + "RestApiId": { + "Ref": "endpointE7B9679B" + } + } + }, + "listNoteshandlerServiceRole334A15FF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "listNoteshandlerServiceRoleDefaultPolicy247BA603": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:Scan", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "notesAF81B09D", + "Arn" + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "listNoteshandlerServiceRoleDefaultPolicy247BA603", + "Roles": [ + { + "Ref": "listNoteshandlerServiceRole334A15FF" + } + ] + } + }, + "listNoteshandlerDC187D34": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Join": [ + "-", + [ + "localstack-testing", + { + "Ref": "AWS::AccountId" + }, + { + "Ref": "AWS::Region" + } + ] + ] + }, + "S3Key": "listNotes.zip" + }, + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": { + "Ref": "notesAF81B09D" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "listNoteshandlerServiceRole334A15FF", + "Arn" + ] + }, + "Runtime": "nodejs18.x" + }, + "DependsOn": [ + "listNoteshandlerServiceRoleDefaultPolicy247BA603", + "listNoteshandlerServiceRole334A15FF" + ] + }, + "createNotehandlerServiceRole8AC82B45": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "createNotehandlerServiceRoleDefaultPolicyFCFF3979": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:PutItem", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "notesAF81B09D", + "Arn" + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "createNotehandlerServiceRoleDefaultPolicyFCFF3979", + "Roles": [ + { + "Ref": "createNotehandlerServiceRole8AC82B45" + } + ] + } + }, + "createNotehandlerBA768AA5": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Join": [ + "-", + [ + "localstack-testing", + { + "Ref": "AWS::AccountId" + }, + { + "Ref": "AWS::Region" + } + ] + ] + }, + "S3Key": "createNote.zip" + }, + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": { + "Ref": "notesAF81B09D" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "createNotehandlerServiceRole8AC82B45", + "Arn" + ] + }, + "Runtime": "nodejs18.x" + }, + "DependsOn": [ + "createNotehandlerServiceRoleDefaultPolicyFCFF3979", + "createNotehandlerServiceRole8AC82B45" + ] + }, + "getNotehandlerServiceRole08D68F24": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "getNotehandlerServiceRoleDefaultPolicy8920DE10": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:GetItem", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "notesAF81B09D", + "Arn" + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "getNotehandlerServiceRoleDefaultPolicy8920DE10", + "Roles": [ + { + "Ref": "getNotehandlerServiceRole08D68F24" + } + ] + } + }, + "getNotehandler167C5FCF": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Join": [ + "-", + [ + "localstack-testing", + { + "Ref": "AWS::AccountId" + }, + { + "Ref": "AWS::Region" + } + ] + ] + }, + "S3Key": "getNote.zip" + }, + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": { + "Ref": "notesAF81B09D" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "getNotehandlerServiceRole08D68F24", + "Arn" + ] + }, + "Runtime": "nodejs18.x" + }, + "DependsOn": [ + "getNotehandlerServiceRoleDefaultPolicy8920DE10", + "getNotehandlerServiceRole08D68F24" + ] + }, + "updateNotehandlerServiceRole9959F133": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "updateNotehandlerServiceRoleDefaultPolicyD405276B": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:UpdateItem", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "notesAF81B09D", + "Arn" + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "updateNotehandlerServiceRoleDefaultPolicyD405276B", + "Roles": [ + { + "Ref": "updateNotehandlerServiceRole9959F133" + } + ] + } + }, + "updateNotehandler6A5A41DF": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Join": [ + "-", + [ + "localstack-testing", + { + "Ref": "AWS::AccountId" + }, + { + "Ref": "AWS::Region" + } + ] + ] + }, + "S3Key": "updateNote.zip" + }, + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": { + "Ref": "notesAF81B09D" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "updateNotehandlerServiceRole9959F133", + "Arn" + ] + }, + "Runtime": "nodejs18.x" + }, + "DependsOn": [ + "updateNotehandlerServiceRoleDefaultPolicyD405276B", + "updateNotehandlerServiceRole9959F133" + ] + }, + "deleteNotehandlerServiceRoleAD0655B4": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "deleteNotehandlerServiceRoleDefaultPolicy82C8BF80": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:DeleteItem", + "Effect": "Allow", + "Resource": [ + { + "Fn::GetAtt": [ + "notesAF81B09D", + "Arn" + ] + }, + { + "Ref": "AWS::NoValue" + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "deleteNotehandlerServiceRoleDefaultPolicy82C8BF80", + "Roles": [ + { + "Ref": "deleteNotehandlerServiceRoleAD0655B4" + } + ] + } + }, + "deleteNotehandlerC903D399": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Join": [ + "-", + [ + "localstack-testing", + { + "Ref": "AWS::AccountId" + }, + { + "Ref": "AWS::Region" + } + ] + ] + }, + "S3Key": "deleteNote.zip" + }, + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": { + "Ref": "notesAF81B09D" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "deleteNotehandlerServiceRoleAD0655B4", + "Arn" + ] + }, + "Runtime": "nodejs18.x" + }, + "DependsOn": [ + "deleteNotehandlerServiceRoleDefaultPolicy82C8BF80", + "deleteNotehandlerServiceRoleAD0655B4" + ] + } + }, + "Outputs": { + "endpointEndpoint5E1E9134": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "endpointE7B9679B" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "endpointDeploymentStageprod2CD5F9C4" + }, + "/" + ] + ] + } + }, + "GatewayUrl": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "endpointE7B9679B" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "endpointDeploymentStageprod2CD5F9C4" + }, + "/" + ] + ] + } + }, + "Region": { + "Value": { + "Ref": "AWS::Region" + } + } + } +} diff --git a/tests/aws/cdk_templates/StepFunctionsEcsTask/StepFunctionsEcsTaskStack.json b/tests/aws/cdk_templates/StepFunctionsEcsTask/StepFunctionsEcsTaskStack.json new file mode 100644 index 0000000000000..69c853fb2831c --- /dev/null +++ b/tests/aws/cdk_templates/StepFunctionsEcsTask/StepFunctionsEcsTaskStack.json @@ -0,0 +1,975 @@ +{ + "Resources": { + "cluster611F8AFF": { + "Type": "AWS::ECS::Cluster" + }, + "clusterVpc91107A71": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc" + } + ] + } + }, + "clusterVpcPublicSubnet1Subnet3948EFCA": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "AvailabilityZone": { + "Fn::Select": [ + 0, + { + "Fn::GetAZs": "" + } + ] + }, + "CidrBlock": "10.0.0.0/18", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet1" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPublicSubnet1RouteTable85A91E8C": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet1" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPublicSubnet1RouteTableAssociationABF3C3B6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "clusterVpcPublicSubnet1RouteTable85A91E8C" + }, + "SubnetId": { + "Ref": "clusterVpcPublicSubnet1Subnet3948EFCA" + } + } + }, + "clusterVpcPublicSubnet1DefaultRouteF41D2737": { + "Type": "AWS::EC2::Route", + "Properties": { + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "clusterVpcIGW64BABB17" + }, + "RouteTableId": { + "Ref": "clusterVpcPublicSubnet1RouteTable85A91E8C" + } + }, + "DependsOn": [ + "clusterVpcVPCGW9E9B1FA8" + ] + }, + "clusterVpcPublicSubnet1EIP0E24289A": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet1" + } + ] + } + }, + "clusterVpcPublicSubnet1NATGateway278CA43C": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "clusterVpcPublicSubnet1EIP0E24289A", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "clusterVpcPublicSubnet1Subnet3948EFCA" + }, + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet1" + } + ] + }, + "DependsOn": [ + "clusterVpcPublicSubnet1DefaultRouteF41D2737", + "clusterVpcPublicSubnet1RouteTableAssociationABF3C3B6" + ] + }, + "clusterVpcPublicSubnet2SubnetEFB8E71C": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "AvailabilityZone": { + "Fn::Select": [ + 1, + { + "Fn::GetAZs": "" + } + ] + }, + "CidrBlock": "10.0.64.0/18", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet2" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPublicSubnet2RouteTable21948248": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet2" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPublicSubnet2RouteTableAssociation21B4DF05": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "clusterVpcPublicSubnet2RouteTable21948248" + }, + "SubnetId": { + "Ref": "clusterVpcPublicSubnet2SubnetEFB8E71C" + } + } + }, + "clusterVpcPublicSubnet2DefaultRouteC71B8373": { + "Type": "AWS::EC2::Route", + "Properties": { + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "clusterVpcIGW64BABB17" + }, + "RouteTableId": { + "Ref": "clusterVpcPublicSubnet2RouteTable21948248" + } + }, + "DependsOn": [ + "clusterVpcVPCGW9E9B1FA8" + ] + }, + "clusterVpcPublicSubnet2EIP6FF291E5": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet2" + } + ] + } + }, + "clusterVpcPublicSubnet2NATGatewayD971ED50": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "clusterVpcPublicSubnet2EIP6FF291E5", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "clusterVpcPublicSubnet2SubnetEFB8E71C" + }, + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PublicSubnet2" + } + ] + }, + "DependsOn": [ + "clusterVpcPublicSubnet2DefaultRouteC71B8373", + "clusterVpcPublicSubnet2RouteTableAssociation21B4DF05" + ] + }, + "clusterVpcPrivateSubnet1Subnet4D445D11": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "AvailabilityZone": { + "Fn::Select": [ + 0, + { + "Fn::GetAZs": "" + } + ] + }, + "CidrBlock": "10.0.128.0/18", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PrivateSubnet1" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPrivateSubnet1RouteTable6B7B6A77": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PrivateSubnet1" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPrivateSubnet1RouteTableAssociationDE66313E": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "clusterVpcPrivateSubnet1RouteTable6B7B6A77" + }, + "SubnetId": { + "Ref": "clusterVpcPrivateSubnet1Subnet4D445D11" + } + } + }, + "clusterVpcPrivateSubnet1DefaultRoute6A776454": { + "Type": "AWS::EC2::Route", + "Properties": { + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "clusterVpcPublicSubnet1NATGateway278CA43C" + }, + "RouteTableId": { + "Ref": "clusterVpcPrivateSubnet1RouteTable6B7B6A77" + } + } + }, + "clusterVpcPrivateSubnet2Subnet6DFF6572": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "AvailabilityZone": { + "Fn::Select": [ + 1, + { + "Fn::GetAZs": "" + } + ] + }, + "CidrBlock": "10.0.192.0/18", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PrivateSubnet2" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPrivateSubnet2RouteTable0D967850": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc/PrivateSubnet2" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "clusterVpcPrivateSubnet2RouteTableAssociation0E224256": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "clusterVpcPrivateSubnet2RouteTable0D967850" + }, + "SubnetId": { + "Ref": "clusterVpcPrivateSubnet2Subnet6DFF6572" + } + } + }, + "clusterVpcPrivateSubnet2DefaultRouteF3B52CD2": { + "Type": "AWS::EC2::Route", + "Properties": { + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "clusterVpcPublicSubnet2NATGatewayD971ED50" + }, + "RouteTableId": { + "Ref": "clusterVpcPrivateSubnet2RouteTable0D967850" + } + } + }, + "clusterVpcIGW64BABB17": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "StepFunctionsEcsTaskStack/cluster/Vpc" + } + ] + } + }, + "clusterVpcVPCGW9E9B1FA8": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "InternetGatewayId": { + "Ref": "clusterVpcIGW64BABB17" + }, + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "taskdefTaskRole1E652319": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "taskdef8C9C43DE": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "EntryPoint": [ + "echo", + "hello" + ], + "Essential": true, + "Image": "busybox", + "Name": "maincontainer" + } + ], + "Cpu": "256", + "Family": "StepFunctionsEcsTaskStacktaskdefA6894239", + "Memory": "512", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "FARGATE" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "taskdefTaskRole1E652319", + "Arn" + ] + } + } + }, + "ecstaskSecurityGroup2F44C80F": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "StepFunctionsEcsTaskStack/ecstask/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "VpcId": { + "Ref": "clusterVpc91107A71" + } + } + }, + "statemachineRole52044F93": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::FindInMap": [ + "ServiceprincipalMap", + { + "Ref": "AWS::Region" + }, + "states" + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "statemachineRoleDefaultPolicy9AE064E2": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "ecs:RunTask", + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "/", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + } + ] + } + ] + }, + "/", + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "/", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + } + ] + } + ] + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 2, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + }, + ":", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "/", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + } + ] + } + ] + }, + "/", + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "/", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + ":", + { + "Ref": "taskdef8C9C43DE" + } + ] + } + ] + } + ] + } + ] + }, + ":*" + ] + ] + } + ] + }, + { + "Action": [ + "ecs:StopTask", + "ecs:DescribeTasks" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "taskdefTaskRole1E652319", + "Arn" + ] + } + }, + { + "Action": [ + "events:PutTargets", + "events:PutRule", + "events:DescribeRule" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":events:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":rule/StepFunctionsGetEventsForECSTaskRule" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "statemachineRoleDefaultPolicy9AE064E2", + "Roles": [ + { + "Ref": "statemachineRole52044F93" + } + ] + } + }, + "statemachineC5962F3E": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"ecstask\",\"States\":{\"ecstask\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::ecs:runTask.sync\",\"Parameters\":{\"Cluster\":\"", + { + "Fn::GetAtt": [ + "cluster611F8AFF", + "Arn" + ] + }, + "\",\"TaskDefinition\":\"StepFunctionsEcsTaskStacktaskdefA6894239\",\"NetworkConfiguration\":{\"AwsvpcConfiguration\":{\"Subnets\":[\"", + { + "Ref": "clusterVpcPrivateSubnet1Subnet4D445D11" + }, + "\",\"", + { + "Ref": "clusterVpcPrivateSubnet2Subnet6DFF6572" + }, + "\"],\"SecurityGroups\":[\"", + { + "Fn::GetAtt": [ + "ecstaskSecurityGroup2F44C80F", + "GroupId" + ] + }, + "\"]}},\"LaunchType\":\"FARGATE\",\"PlatformVersion\":\"1.4.0\"}}}}" + ] + ] + }, + "RoleArn": { + "Fn::GetAtt": [ + "statemachineRole52044F93", + "Arn" + ] + } + }, + "DependsOn": [ + "statemachineRoleDefaultPolicy9AE064E2", + "statemachineRole52044F93" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + }, + "Outputs": { + "TaskDefinitionArn": { + "Value": { + "Ref": "taskdef8C9C43DE" + } + }, + "ClusterArn": { + "Value": { + "Fn::GetAtt": [ + "cluster611F8AFF", + "Arn" + ] + } + }, + "StateMachineArn": { + "Value": { + "Ref": "statemachineC5962F3E" + } + }, + "ClusterName": { + "Value": { + "Ref": "cluster611F8AFF" + } + }, + "StateMachineRoleArn": { + "Value": { + "Fn::GetAtt": [ + "statemachineRole52044F93", + "Arn" + ] + } + }, + "TaskDefinitionFamily": { + "Value": "StepFunctionsEcsTaskStacktaskdefA6894239" + }, + "TaskDefinitionContainerName": { + "Value": "maincontainer" + } + }, + "Mappings": { + "ServiceprincipalMap": { + "af-south-1": { + "states": "states.af-south-1.amazonaws.com" + }, + "ap-east-1": { + "states": "states.ap-east-1.amazonaws.com" + }, + "ap-northeast-1": { + "states": "states.ap-northeast-1.amazonaws.com" + }, + "ap-northeast-2": { + "states": "states.ap-northeast-2.amazonaws.com" + }, + "ap-northeast-3": { + "states": "states.ap-northeast-3.amazonaws.com" + }, + "ap-south-1": { + "states": "states.ap-south-1.amazonaws.com" + }, + "ap-south-2": { + "states": "states.ap-south-2.amazonaws.com" + }, + "ap-southeast-1": { + "states": "states.ap-southeast-1.amazonaws.com" + }, + "ap-southeast-2": { + "states": "states.ap-southeast-2.amazonaws.com" + }, + "ap-southeast-3": { + "states": "states.ap-southeast-3.amazonaws.com" + }, + "ap-southeast-4": { + "states": "states.ap-southeast-4.amazonaws.com" + }, + "ca-central-1": { + "states": "states.ca-central-1.amazonaws.com" + }, + "cn-north-1": { + "states": "states.cn-north-1.amazonaws.com" + }, + "cn-northwest-1": { + "states": "states.cn-northwest-1.amazonaws.com" + }, + "eu-central-1": { + "states": "states.eu-central-1.amazonaws.com" + }, + "eu-central-2": { + "states": "states.eu-central-2.amazonaws.com" + }, + "eu-north-1": { + "states": "states.eu-north-1.amazonaws.com" + }, + "eu-south-1": { + "states": "states.eu-south-1.amazonaws.com" + }, + "eu-south-2": { + "states": "states.eu-south-2.amazonaws.com" + }, + "eu-west-1": { + "states": "states.eu-west-1.amazonaws.com" + }, + "eu-west-2": { + "states": "states.eu-west-2.amazonaws.com" + }, + "eu-west-3": { + "states": "states.eu-west-3.amazonaws.com" + }, + "il-central-1": { + "states": "states.il-central-1.amazonaws.com" + }, + "me-central-1": { + "states": "states.me-central-1.amazonaws.com" + }, + "me-south-1": { + "states": "states.me-south-1.amazonaws.com" + }, + "sa-east-1": { + "states": "states.sa-east-1.amazonaws.com" + }, + "us-east-1": { + "states": "states.us-east-1.amazonaws.com" + }, + "us-east-2": { + "states": "states.us-east-2.amazonaws.com" + }, + "us-gov-east-1": { + "states": "states.us-gov-east-1.amazonaws.com" + }, + "us-gov-west-1": { + "states": "states.us-gov-west-1.amazonaws.com" + }, + "us-iso-east-1": { + "states": "states.amazonaws.com" + }, + "us-iso-west-1": { + "states": "states.amazonaws.com" + }, + "us-isob-east-1": { + "states": "states.amazonaws.com" + }, + "us-west-1": { + "states": "states.us-west-1.amazonaws.com" + }, + "us-west-2": { + "states": "states.us-west-2.amazonaws.com" + } + } + } +} diff --git a/tests/aws/cdk_templates/TestTableV2Stream/TableV2StreamStack.json b/tests/aws/cdk_templates/TestTableV2Stream/TableV2StreamStack.json new file mode 100644 index 0000000000000..223f3bd0b3fcd --- /dev/null +++ b/tests/aws/cdk_templates/TestTableV2Stream/TableV2StreamStack.json @@ -0,0 +1,41 @@ +{ + "Resources": { + "v2table6C40CC77": { + "Type": "AWS::DynamoDB::GlobalTable", + "Properties": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "Replicas": [ + { + "Region": { + "Ref": "AWS::Region" + } + } + ], + "StreamSpecification": { + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + }, + "Outputs": { + "tableName": { + "Value": { + "Ref": "v2table6C40CC77" + } + } + } +} diff --git a/tests/aws/conftest.py b/tests/aws/conftest.py new file mode 100644 index 0000000000000..9ee30b5b925a0 --- /dev/null +++ b/tests/aws/conftest.py @@ -0,0 +1,156 @@ +import os +from typing import Optional + +import pytest +from _pytest.config import Config +from localstack_snapshot.snapshots import SnapshotSession +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack import config as localstack_config +from localstack import constants +from localstack.testing.snapshots.transformer_utility import ( + SNAPSHOT_BASIC_TRANSFORMER, + SNAPSHOT_BASIC_TRANSFORMER_NEW, + TransformerUtility, +) +from localstack.utils.aws.arns import get_partition +from tests.aws.test_terraform import TestTerraform + + +def pytest_configure(config: Config): + # FIXME: note that this should be the same as in tests/integration/conftest.py since both are currently + # run in the same CI test step, but only one localstack instance is started for both. + config.option.start_localstack = True + localstack_config.FORCE_SHUTDOWN = False + localstack_config.GATEWAY_LISTEN = localstack_config.UniqueHostAndPortList( + [localstack_config.HostAndPort(host="0.0.0.0", port=constants.DEFAULT_PORT_EDGE)] + ) + + +def pytest_runtestloop(session): + # second pytest lifecycle hook (before test runner starts) + test_init_functions = set() + + # collect test classes + test_classes = set() + for item in session.items: + if item.parent and item.parent.cls: + test_classes.add(item.parent.cls) + # OpenSearch/Elasticsearch are pytests, not unit test classes, so we check based on the item parent's name. + # Any pytests that rely on opensearch/elasticsearch must be special-cased by adding them to the list below + parent_name = str(item.parent).lower() + if any(opensearch_test in parent_name for opensearch_test in ["opensearch", "firehose"]): + from tests.aws.services.opensearch.test_opensearch import ( + install_async as opensearch_install_async, + ) + + test_init_functions.add(opensearch_install_async) + + if any(es_test in parent_name for es_test in ["elasticsearch", "firehose"]): + from tests.aws.services.es.test_es import install_async as es_install_async + + test_init_functions.add(es_install_async) + + if "transcribe" in parent_name: + from tests.aws.services.transcribe.test_transcribe import ( + install_async as transcribe_install_async, + ) + + test_init_functions.add(transcribe_install_async) + + # add init functions for certain tests that download/install things + for test_class in test_classes: + # set flag that terraform will be used + if TestTerraform is test_class: + test_init_functions.add(TestTerraform.init_async) + continue + + if not session.items: + return + + if session.config.option.collectonly: + return + + for fn in test_init_functions: + fn() + + +# Note: Don't move this into testing lib +@pytest.fixture(scope="session") +def cdk_template_path(): + return os.path.abspath(os.path.join(os.path.dirname(__file__), "cdk_templates")) + + +# Note: Don't move this into testing lib +@pytest.fixture(scope="session") +def infrastructure_setup(cdk_template_path, aws_client): + # Note: import needs to be local to avoid CDK import on every test run, which takes quite some time + from localstack.testing.scenario.provisioning import InfraProvisioner + + def _infrastructure_setup( + namespace: str, force_synth: Optional[bool] = False + ) -> InfraProvisioner: + """ + :param namespace: repo-unique identifier for this CDK app. + A directory with this name will be created at `tests/aws/cdk_templates//` + :param force_synth: set to True to always re-synth the CDK app + :return: an instantiated CDK InfraProvisioner which can be used to deploy a CDK app + """ + return InfraProvisioner( + base_path=cdk_template_path, + aws_client=aws_client, + namespace=namespace, + force_synth=force_synth, + persist_output=True, + ) + + return _infrastructure_setup + + +@pytest.fixture(scope="function") +def snapshot(request, _snapshot_session: SnapshotSession, account_id, region_name): + # Overwrite utility with our own => Will be refactored in the future + _snapshot_session.transform = TransformerUtility + + _snapshot_session.add_transformer(RegexTransformer(account_id, "1" * 12), priority=2) + _snapshot_session.add_transformer(RegexTransformer(region_name, ""), priority=2) + _snapshot_session.add_transformer( + RegexTransformer(f"arn:{get_partition(region_name)}:", "arn::"), priority=2 + ) + + # Removes the 'x-localstack' header from all responses + _snapshot_session.add_transformer(_snapshot_session.transform.remove_key("x-localstack")) + + # TODO: temporary to migrate to new default transformers. + # remove this after all exemptions are gone + exemptions = [ + "tests/aws/services/acm", + "tests/aws/services/apigateway", + "tests/aws/services/cloudwatch", + "tests/aws/services/cloudformation", + "tests/aws/services/dynamodb", + "tests/aws/services/events", + "tests/aws/services/kinesis", + "tests/aws/services/kms", + "tests/aws/services/lambda_", + "tests/aws/services/logs", + "tests/aws/services/route53", + "tests/aws/services/route53resolver", + "tests/aws/services/s3", + "tests/aws/services/secretsmanager", + "tests/aws/services/ses", + "tests/aws/services/sns", + "tests/aws/services/stepfunctions", + "tests/aws/services/sqs", + "tests/aws/services/transcribe", + "tests/aws/scenario/bookstore", + "tests/aws/scenario/note_taking", + "tests/aws/scenario/lambda_destination", + "tests/aws/scenario/loan_broker", + ] + if any(e in request.fspath.dirname for e in exemptions): + _snapshot_session.add_transformer(SNAPSHOT_BASIC_TRANSFORMER, priority=2) + else: + _snapshot_session.add_transformer(SNAPSHOT_BASIC_TRANSFORMER_NEW, priority=2) + + return _snapshot_session diff --git a/tests/aws/files/api_definition.yaml b/tests/aws/files/api_definition.yaml new file mode 100644 index 0000000000000..7a745de07365e --- /dev/null +++ b/tests/aws/files/api_definition.yaml @@ -0,0 +1,76 @@ +openapi: 3.0.1 +info: + title: wait-for-seconds + description: Test API for asynchronous Lambda invocation + version: 2022-04-12T15:36:55Z +paths: + "/echo/{data}": + x-amazon-apigateway-any-method: + responses: + "200": + description: 200 response + content: + application/json: + schema: + $ref: "#/components/schemas/Empty" + x-amazon-apigateway-integration: + type: mock + responses: + default: + statusCode: "200" + responseTemplates: + application/json: > + {"data": $input.params(\"data\"), "response": "mocked"} + requestTemplates: + application/json: '{"data", $input.params(\"data\"), "statusCode": 200}' + passthroughBehavior: when_no_templates + "/wait/{seconds}": + x-amazon-apigateway-any-method: + parameters: + - name: seconds + in: path + required: true + schema: + type: string + responses: + "200": + description: 200 response + content: + application/json: + schema: + $ref: "#/components/schemas/Empty" + x-amazon-apigateway-integration: + type: aws + httpMethod: POST + uri: ${lambda_invocation_arn} + responses: + default: + statusCode: "200" + requestParameters: + integration.request.header.X-Amz-Invocation-Type: "'Event'" + requestTemplates: + application/json: > + #set($allParams = $input.params()) + + { + "params" : { + #foreach($type in $allParams.keySet()) + #set($params = $allParams.get($type)) + "$type" : { + #foreach($paramName in $params.keySet()) + "$paramName" : "$util.escapeJavaScript($params.get($paramName))" + #if($foreach.hasNext),#end + #end + } + #if($foreach.hasNext),#end + #end + } + } + passthroughBehavior: when_no_templates + contentHandling: CONVERT_TO_TEXT + credentials: ${credentials} +components: + schemas: + Empty: + title: Empty Schema + type: object diff --git a/tests/aws/files/en-gb.amr b/tests/aws/files/en-gb.amr new file mode 100644 index 0000000000000..8858f6a4e5fdb Binary files /dev/null and b/tests/aws/files/en-gb.amr differ diff --git a/tests/aws/files/en-gb.flac b/tests/aws/files/en-gb.flac new file mode 100644 index 0000000000000..99c368dd8f7e7 Binary files /dev/null and b/tests/aws/files/en-gb.flac differ diff --git a/tests/aws/files/en-gb.mp3 b/tests/aws/files/en-gb.mp3 new file mode 100644 index 0000000000000..05fc2df33c6ab Binary files /dev/null and b/tests/aws/files/en-gb.mp3 differ diff --git a/tests/aws/files/en-gb.mp4 b/tests/aws/files/en-gb.mp4 new file mode 100644 index 0000000000000..bf451b5932395 Binary files /dev/null and b/tests/aws/files/en-gb.mp4 differ diff --git a/tests/aws/files/en-gb.ogg b/tests/aws/files/en-gb.ogg new file mode 100644 index 0000000000000..a330f3f4bf0ed Binary files /dev/null and b/tests/aws/files/en-gb.ogg differ diff --git a/tests/aws/files/en-gb.wav b/tests/aws/files/en-gb.wav new file mode 100644 index 0000000000000..6717c1b287b33 Binary files /dev/null and b/tests/aws/files/en-gb.wav differ diff --git a/tests/aws/files/en-gb.webm b/tests/aws/files/en-gb.webm new file mode 100644 index 0000000000000..cc49c95650ee3 Binary files /dev/null and b/tests/aws/files/en-gb.webm differ diff --git a/tests/aws/files/en-us_video.mkv b/tests/aws/files/en-us_video.mkv new file mode 100644 index 0000000000000..7c6ff07b073ec Binary files /dev/null and b/tests/aws/files/en-us_video.mkv differ diff --git a/tests/aws/files/en-us_video.mp4 b/tests/aws/files/en-us_video.mp4 new file mode 100644 index 0000000000000..6d93b3a2a5583 Binary files /dev/null and b/tests/aws/files/en-us_video.mp4 differ diff --git a/tests/aws/files/lambda_simple.js b/tests/aws/files/lambda_simple.js new file mode 100644 index 0000000000000..662498bd7bc76 --- /dev/null +++ b/tests/aws/files/lambda_simple.js @@ -0,0 +1,7 @@ +exports.handler = async function(event, context) { + console.info('EVENT ' + JSON.stringify(event, null, 2)); + return { + statusCode: 200, + body: 'I am a %s API!', + }; +} \ No newline at end of file diff --git a/tests/aws/files/multi-speaker.wav b/tests/aws/files/multi-speaker.wav new file mode 100644 index 0000000000000..20675a7c00dec Binary files /dev/null and b/tests/aws/files/multi-speaker.wav differ diff --git a/tests/aws/files/oas30_documentation_parts.json b/tests/aws/files/oas30_documentation_parts.json new file mode 100644 index 0000000000000..b993d3b092e49 --- /dev/null +++ b/tests/aws/files/oas30_documentation_parts.json @@ -0,0 +1,85 @@ +{ + "openapi": "3.0.0", + "info": { + "description": "description", + "version": "1", + "title": "doc" + }, + "paths": { + "/": { + "get": { + "description": "Method description.", + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + } + }, + "x-amazon-apigateway-documentation": { + "version": "1.0.3", + "documentationParts": [ + { + "location": { + "type": "API" + }, + "properties": { + "description": "API description", + "info": { + "description": "API info description 4", + "version": "API info version 3" + } + } + }, + { + "location": { + "type": "METHOD", + "method": "GET" + }, + "properties": { + "description": "Method description." + } + }, + { + "location": { + "type": "MODEL", + "name": "Empty" + }, + "properties": { + "title": "Empty Schema" + } + }, + { + "location": { + "type": "RESPONSE", + "method": "GET", + "statusCode": "200" + }, + "properties": { + "description": "200 response" + } + } + ] + }, + "servers": [ + { + "url": "/" + } + ], + "components": { + "schemas": { + "Empty": { + "type": "object", + "title": "Empty Schema" + } + } + } +} diff --git a/tests/aws/files/openapi-basepath-server-variable.yaml b/tests/aws/files/openapi-basepath-server-variable.yaml new file mode 100644 index 0000000000000..bd99c4bff5991 --- /dev/null +++ b/tests/aws/files/openapi-basepath-server-variable.yaml @@ -0,0 +1,71 @@ +openapi: 3.0.1 +info: + title: test-import-oas + version: '2.0' +# If the API contains only one basePath variable, the Import API feature uses it as the base path, even if it's not referenced in the server.url. +servers: + - url: "https://testdomain.com" + variables: + basePath: + default: "/base-var" +paths: + "/test": + get: + responses: + '200': + description: 200 response + headers: + Access-Control-Allow-Origin: + schema: + type: string + content: + application/json: + schema: + "$ref": "#/components/schemas/Empty" + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Origin: "'*'" + requestParameters: + integration.request.header.X-Amz-Invocation-Type: "'Event'" + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock + options: + responses: + '200': + description: 200 response + headers: + Access-Control-Allow-Origin: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Headers: + schema: + type: string + content: + application/json: + schema: + "$ref": "#/components/schemas/Empty" + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'" + method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" + method.response.header.Access-Control-Allow-Origin: "'*'" + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock +components: + schemas: + Empty: + title: Empty Schema + type: object diff --git a/tests/aws/files/openapi-basepath-url.yaml b/tests/aws/files/openapi-basepath-url.yaml new file mode 100644 index 0000000000000..ddb067d889f0a --- /dev/null +++ b/tests/aws/files/openapi-basepath-url.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.1 +info: + title: test-import-oas + version: '2.0' +servers: + - url: https://testdomain.com/base-url/part/ + description: Test Base path +paths: + "/test": + get: + responses: + '200': + description: 200 response + headers: + Access-Control-Allow-Origin: + schema: + type: string + content: + application/json: + schema: + "$ref": "#/components/schemas/Empty" + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Origin: "'*'" + requestParameters: + integration.request.header.X-Amz-Invocation-Type: "'Event'" + integration.request.header.double-single: "'True'" + integration.request.header.nothing: true + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock + options: + responses: + '200': + description: 200 response + headers: + Access-Control-Allow-Origin: + schema: + type: string + Access-Control-Allow-Methods: + schema: + type: string + Access-Control-Allow-Headers: + schema: + type: string + content: + application/json: + schema: + "$ref": "#/components/schemas/Empty" + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Methods: "'GET,OPTIONS'" + method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" + method.response.header.Access-Control-Allow-Origin: "'*'" + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock +components: + schemas: + Empty: + title: Empty Schema + type: object diff --git a/tests/aws/files/openapi-http-method-integration.json b/tests/aws/files/openapi-http-method-integration.json new file mode 100644 index 0000000000000..c492298af7b5a --- /dev/null +++ b/tests/aws/files/openapi-http-method-integration.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "test-http-method", + "description": "Test httpMethod for AWS integration", + "version": "2022-04-12T15:36:55Z" + }, + "paths": { + "/": { + "get": { + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "aws", + "httpMethod": "POST", + "uri": "${lambda_invocation_arn}", + "responses": { + "default": { + "statusCode": "200" + } + }, + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": "#set($allParams = $input.params())\n{\n \"params\" : {\n #foreach($type in $allParams.keySet())\n #set($params = $allParams.get($type))\n \"$type\" : {\n #foreach($paramName in $params.keySet())\n \"$paramName\" : \"$util.escapeJavaScript($params.get($paramName))\"\n #if($foreach.hasNext),#end\n #end\n }\n #if($foreach.hasNext),#end\n #end\n }\n}\n" + }, + "passthroughBehavior": "when_no_templates", + "contentHandling": "CONVERT_TO_TEXT" + } + }, + "options": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + }, + "description": "200 response", + "headers": { + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "passthroughBehavior": "when_no_match", + "httpMethod": "POST", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "responses": { + "default": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'" + }, + "statusCode": "200" + } + }, + "type": "mock" + } + } + } + }, + "components": { + "schemas": { + "Empty": { + "title": "Empty Schema", + "type": "object" + } + } + } +} diff --git a/tests/aws/files/openapi-method-int.spec.yaml b/tests/aws/files/openapi-method-int.spec.yaml new file mode 100644 index 0000000000000..359f3fed95cd4 --- /dev/null +++ b/tests/aws/files/openapi-method-int.spec.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.1 +info: + title: test-import-oas-int + version: '2.0' +paths: + "/test": + get: + responses: + # the responses are grouped under the 200 integer status code. In the JSON format, this has to be a string.101: + # AWS accepts integer status code in YAML. + 200: + description: 200 response + headers: + Access-Control-Allow-Origin: + schema: + type: string + content: + application/json: + schema: + "$ref": "#/components/schemas/Empty" + x-amazon-apigateway-integration: + responses: + default: + # this represents the IntegrationResponse status code. In the YAML format, AWS accepts an integer, but the + # "official" type is string and should be cast as such. + statusCode: 200 + responseParameters: + method.response.header.Access-Control-Allow-Origin: "'*'" + requestParameters: + integration.request.header.X-Amz-Invocation-Type: "'Event'" + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock + +components: + schemas: + Empty: + title: Empty Schema + type: object diff --git a/tests/aws/files/openapi-mock.json b/tests/aws/files/openapi-mock.json new file mode 100644 index 0000000000000..73fc49768e022 --- /dev/null +++ b/tests/aws/files/openapi-mock.json @@ -0,0 +1,49 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "wait-for-seconds", + "description": "Test API for asynchronous Lambda invocation", + "version": "2022-04-12T15:36:55Z" + }, + "paths": { + "/echo/{data}": { + "x-amazon-apigateway-any-method": { + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseTemplates": { + "application/json": "{\"echo\": \"$input.params('data')\", \"response\": \"mocked\"}" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_templates" + } + } + } + }, + "components": { + "schemas": { + "Empty": { + "title": "Empty Schema", + "type": "object" + } + } + } +} diff --git a/tests/aws/files/openapi.cognito-auth.json b/tests/aws/files/openapi.cognito-auth.json new file mode 100644 index 0000000000000..416bf3f274aef --- /dev/null +++ b/tests/aws/files/openapi.cognito-auth.json @@ -0,0 +1,179 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Example Pet Store", + "description": "A Pet Store API.", + "version": "1.0" + }, + "paths": { + "/default-no-scope": { + "get": { + "security": [ + {"cognito-test-identity-source": []} + ], + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200" + } + }, + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + } + }, + "/default-scope-override": { + "get": { + "security": [ + {"cognito-test-identity-source": ["openid"]} + ], + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200" + } + }, + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + } + }, + "/non-default-authorizer": { + "get": { + "security": [ + {"extra-test-identity-source": ["email", "openid"]} + ], + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200" + } + }, + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + } + }, + "/pets": { + "get": { + "operationId": "GET HTTP", + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "HTTP_PROXY", + "httpMethod": "GET", + "uri": "http://petstore.execute-api.us-west-1.amazonaws.com/petstore/pets", + "payloadFormatVersion": 1.0 + } + } + } + }, + "components": { + "securitySchemes": { + "cognito-test-identity-source": { + "type": "apiKey", + "name": "TestHeaderAuth", + "in": "header", + "x-amazon-apigateway-authtype": "cognito_user_pools", + "x-amazon-apigateway-authorizer": { + "type": "cognito_user_pools", + "providerARNs": [ + "${cognito_pool_arn}" + ] + } + }, + "extra-test-identity-source": { + "type": "apiKey", + "name": "TestHeaderAuth", + "in": "header", + "x-amazon-apigateway-authtype": "cognito_user_pools", + "x-amazon-apigateway-authorizer": { + "type": "cognito_user_pools", + "providerARNs": [ + "${cognito_pool_arn}" + ] + } + } + }, + "schemas": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Empty": { + "type": "object" + }, + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + } + } + }, + "security": [{"cognito-test-identity-source": ["email"]}] +} diff --git a/tests/aws/files/openapi.spec.circular-ref-with-request-body.json b/tests/aws/files/openapi.spec.circular-ref-with-request-body.json new file mode 100644 index 0000000000000..c09712f7a7d7a --- /dev/null +++ b/tests/aws/files/openapi.spec.circular-ref-with-request-body.json @@ -0,0 +1,103 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Circular model reference", + "version": "1.0" + }, + "x-amazon-apigateway-request-validators" : { + "basic": { + "validateRequestBody": true, + "validateRequestParameters": true + } + }, + "x-amazon-apigateway-request-validator" : "basic", + "paths": { + "/person": { + "post": { + "description": "Create a Person", + "operationId": "CreatePerson", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Empty", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_templates" + } + } + } + }, + "components": { + "schemas": { + "Person": { + "type": "object", + "description": "Random person schema.", + "properties": { + "name": { + "type": "string", + "description": "Random property" + }, + "b": { + "type": "number" + }, + "house": { + "$ref": "#/components/schemas/House" + } + }, + "required": ["name", "b"] + }, + "House": { + "type": "object", + "description": "Where a Person can live", + "properties": { + "randomProperty": { + "type": "string", + "description": "Random property", + "format": "byte" + }, + "contains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + }, + "minItems": 1, + "description": "The information of who is living in the house" + } + } + }, + "Empty": { + "title": "Empty Schema", + "type": "object" + } + } + } +} diff --git a/tests/aws/files/openapi.spec.circular-ref.json b/tests/aws/files/openapi.spec.circular-ref.json new file mode 100644 index 0000000000000..5e2a01291a4d2 --- /dev/null +++ b/tests/aws/files/openapi.spec.circular-ref.json @@ -0,0 +1,96 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Circular model reference", + "version": "1.0" + }, + "paths": { + "/person": { + "post": { + "description": "Create a Person", + "operationId": "CreatePerson", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Empty", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_templates" + } + } + } + }, + "components": { + "schemas": { + "Person": { + "type": "object", + "description": "Random person schema.", + "properties": { + "name": { + "type": "string", + "description": "Random property" + }, + "b": { + "type": "number" + }, + "house": { + "$ref": "#/components/schemas/House" + } + }, + "required": ["name", "b"] + }, + "House": { + "type": "object", + "description": "Where a Person can live", + "properties": { + "randomProperty": { + "type": "string", + "description": "Random property", + "format": "byte" + }, + "contains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + }, + "minItems": 1, + "description": "The information of who is living in the house" + } + } + }, + "Empty": { + "title": "Empty Schema", + "type": "object" + } + } + } +} diff --git a/tests/aws/files/openapi.spec.global-auth.json b/tests/aws/files/openapi.spec.global-auth.json new file mode 100644 index 0000000000000..ae9fe8a073041 --- /dev/null +++ b/tests/aws/files/openapi.spec.global-auth.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Test Global Authorizer", + "version": "1.0" + }, + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200" + } + }, + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + } + }, + "/echo/{data}": { + "x-amazon-apigateway-any-method": { + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseTemplates": { + "application/json": "{\"echo\": \"$input.params('data')\", \"response\": \"mocked\"}" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_templates" + } + } + } + }, + "components": { + "schemas": { + "Empty": { + "title": "Empty Schema", + "type": "object" + } + }, + "securitySchemes": { + "GatewayAuthorizer": { + "type": "apiKey", + "description": "API key authentication via the 'X-Api-Key' header", + "name": "X-Api-Key", + "in": "header", + "x-amazon-apigateway-authorizer": { + "type": "request", + "authorizerUri": "${authorizer_lambda_invocation_arn}", + "identitySource": "method.request.header.Custom-Authorization", + "authorizerResultTtlInSeconds": 300 + }, + "x-amazon-apigateway-authtype": "custom" + } + } + }, + "security": [ + { + "GatewayAuthorizer": [] + } + ], + "x-amazon-apigateway-api-key-source": "AUTHORIZER" +} diff --git a/tests/aws/files/openapi.spec.json b/tests/aws/files/openapi.spec.json new file mode 100644 index 0000000000000..4e3f5699f7827 --- /dev/null +++ b/tests/aws/files/openapi.spec.json @@ -0,0 +1,234 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Example Pet Store", + "description": "A Pet Store API.", + "version": "1.0" + }, + "paths": { + "/pets": { + "get": { + "operationId": "GET HTTP", + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "HTTP_PROXY", + "httpMethod": "GET", + "uri": "http://petstore.execute-api.us-west-1.amazonaws.com/petstore/pets", + "payloadFormatVersion": 1.0 + } + }, + "post": { + "operationId": "Create Pet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPetResponse" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "$ref": "#/components/x-amazon-apigateway-integrations/NewPet" + } + } + }, + "/pets/{petId}": { + "get": { + "operationId": "Get Pet", + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "security" : [{ + "myAuthorizer": [] + }], + "x-amazon-apigateway-integration": { + "type": "HTTP_PROXY", + "httpMethod": "GET", + "uri": "http://petstore.execute-api.us-west-1.amazonaws.com/petstore/pets/{petId}", + "payloadFormatVersion": 1.0 + } + } + } + }, + "x-amazon-apigateway-cors": { + "allowOrigins": [ + "*" + ], + "allowMethods": [ + "GET", + "OPTIONS", + "POST" + ], + "allowHeaders": [ + "x-amzm-header", + "x-apigateway-header", + "x-api-key", + "authorization", + "x-amz-date", + "content-type" + ] + }, + "components": { + "securitySchemes": { + "myAuthorizer": { + "type": "apiKey", + "name": "my-auth-gw-imported", + "in": "header", + "x-amazon-apigateway-authorizer": { + "identitySource": "method.request.header.Authorization", + "authorizerUri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:function-name/invocations", + "authorizerResultTtlInSeconds": 300, + "type": "request", + "enableSimpleResponses": false + }, + "x-amazon-apigateway-authtype": "Custom scheme with corporate claims" + } + }, + "schemas": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Empty": { + "type": "object" + }, + "NewPetResponse": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Pet" + }, + "message": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "NewPet": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/PetType" + }, + "price": { + "type": "number" + } + } + }, + "PetType": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + }, + "x-amazon-apigateway-integrations":{ + "NewPet": { + "type": "HTTP_PROXY", + "httpMethod": "POST", + "uri": "http://petstore.execute-api.us-west-1.amazonaws.com/petstore/pets", + "payloadFormatVersion": 1.0 + } + } + } +} diff --git a/tests/aws/files/openapi.spec.pulumi.json b/tests/aws/files/openapi.spec.pulumi.json new file mode 100644 index 0000000000000..e15718c59fac8 --- /dev/null +++ b/tests/aws/files/openapi.spec.pulumi.json @@ -0,0 +1,150 @@ +{ + "swagger": "2.0", + "info": { + "title": "apiGateway", + "version": "1.0" + }, + "paths": { + "/{proxy+}": { + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "type": "object" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": 200, + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "responseTemplates": { + "application/json": "" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match", + "type": "mock" + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/just_do_it": { + "get": { + "x-amazon-apigateway-integration": { + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:000000000000:function:s3OnObjectCreatedLambda-60c92b6/invocations", + "passthroughBehavior": "when_no_match", + "httpMethod": "POST", + "type": "aws_proxy" + }, + "security": [ + { + "Auth0": [] + }, + { + "api_key": [] + } + ] + } + } + }, + "x-amazon-apigateway-binary-media-types": [ + "*/*" + ], + "x-amazon-apigateway-gateway-responses": { + "UNAUTHORIZED": { + "statusCode": 401, + "responseParameters": { + "gatewayresponse.header.method.response.header.Access-Control-Allow-Headers": "'Content-Type, Authorization, X-Amz-Date, X-Api-Key, X-Amz-Security-Token, Origin, X-Requested-With, Accept'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Methods": "'GET, POST, OPTIONS, PUT, PATCH, DELETE'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + } + }, + "ACCESS_DENIED": { + "statusCode": 401, + "responseParameters": { + "gatewayresponse.header.method.response.header.Access-Control-Allow-Headers": "'Content-Type, Authorization, X-Amz-Date, X-Api-Key, X-Amz-Security-Token, Origin, X-Requested-With, Accept'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Methods": "'GET, POST, OPTIONS, PUT, PATCH, DELETE'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + } + }, + "MISSING_AUTHENTICATION_TOKEN": { + "statusCode": 401, + "responseParameters": { + "gatewayresponse.header.method.response.header.Access-Control-Allow-Headers": "'Content-Type, Authorization, X-Amz-Date, X-Api-Key, X-Amz-Security-Token, Origin, X-Requested-With, Accept'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Methods": "'GET, POST, OPTIONS, PUT, PATCH, DELETE'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + } + }, + "EXPIRED_TOKEN": { + "statusCode": 403, + "responseParameters": { + "gatewayresponse.header.method.response.header.Access-Control-Allow-Headers": "'Content-Type, Authorization, X-Amz-Date, X-Api-Key, X-Amz-Security-Token, Origin, X-Requested-With, Accept'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Methods": "'GET, POST, OPTIONS, PUT, PATCH, DELETE'", + "gatewayresponse.header.method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + } + } + }, + "x-amazon-apigateway-api-key-source": "HEADER", + "securityDefinitions": { + "Auth0": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "oauth2", + "x-amazon-apigateway-authorizer": { + "type": "token", + "authorizerUri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:000000000000:function:apiGwAuthorizerLambda-2152989/invocations", + "authorizerCredentials": "", + "identitySource": "" + } + }, + "api_key": { + "type": "apiKey", + "name": "x-api-key", + "in": "header" + } + } +} diff --git a/tests/aws/files/openapi.spec.stage-variables.json b/tests/aws/files/openapi.spec.stage-variables.json new file mode 100644 index 0000000000000..718495f466d19 --- /dev/null +++ b/tests/aws/files/openapi.spec.stage-variables.json @@ -0,0 +1,24 @@ +{ + "openapi":"3.0.1", + "info":{ + "title":"example", + "version":"1.0" + }, + "paths":{ + "/path1":{ + "get":{ + "x-amazon-apigateway-integration":{ + "httpMethod":"POST", + "payloadFormatVersion":"1.0", + "type":"HTTP_PROXY", + "uri": "https://${stageVariables.TestHost}/${stageVariables.testPath}?${stageVariables.querystring}" + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} diff --git a/tests/aws/files/openapi.spec.tf.json b/tests/aws/files/openapi.spec.tf.json new file mode 100644 index 0000000000000..afebb4df83671 --- /dev/null +++ b/tests/aws/files/openapi.spec.tf.json @@ -0,0 +1,115 @@ +{ + "components": { + "schemas": { + "Empty": { + "title": "Empty Schema", + "type": "object" + } + }, + "securitySchemes": { + "api_key": { + "in": "header", + "name": "x-api-key", + "type": "apiKey" + } + } + }, + "info": { + "title": "local-api", + "version": 1 + }, + "openapi": "3.0.1", + "paths": { + "/": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + }, + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + } + } + }, + "security": [ + { + "api_key": [] + } + ], + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "httpMethod": "POST", + "passthroughBehavior": "when_no_match", + "responses": { + "default": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:000000000000:function:s3OnObjectCreatedLambda-60c92b6/invocations" + } + }, + "options": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + }, + "description": "200 response", + "headers": { + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "passthroughBehavior": "when_no_match", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "responses": { + "default": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "type": "mock" + } + } + } + } +} diff --git a/tests/aws/files/pets.json b/tests/aws/files/pets.json new file mode 100644 index 0000000000000..0e4f769ea277c --- /dev/null +++ b/tests/aws/files/pets.json @@ -0,0 +1,67 @@ +{ + "swagger": "2.0", + "info": { + "title": "Simple PetStore (Swagger)", + "version": "1.0.0" + }, + "schemes": [ + "https" + ], + "x-amazon-apigateway-binary-media-types": [ + "image/png", + "image/jpg" + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200" + } + }, + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + } + }, + "/pets/{petId}": { + "get": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200" + } + }, + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + } + } + } +} diff --git a/tests/aws/files/petstore-authorizer.swagger.json b/tests/aws/files/petstore-authorizer.swagger.json new file mode 100644 index 0000000000000..98656222ababc --- /dev/null +++ b/tests/aws/files/petstore-authorizer.swagger.json @@ -0,0 +1,579 @@ +{ + "swagger": "2.0", + "info": { + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "title": "PetStore", + "version": "1.0" + }, + "schemes": [ + "https" + ], + "paths": { + "/": { + "get": { + "tags": [ + "pets" + ], + "description": "PetStore HTML web page containing API usage information", + "consumes": [ + "application/json" + ], + "produces": [ + "text/html" + ], + "responses": { + "200": { + "description": "Successful operation", + "headers": { + "Content-Type": { + "type": "string", + "description": "Media type of request" + } + } + } + }, + "security": [ + { + "test-authorizer": [] + } + ], + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Content-Type": "'text/html'" + }, + "responseTemplates": { + "text/html": "\n \n \n \n \n

Welcome to your Pet Store API

\n

\n You have successfully deployed your first API. You are seeing this HTML page because the GET method to the root resource of your API returns this content as a Mock integration.\n

\n

\n The Pet Store API contains the /pets and /pets/{petId} resources. By making a GET request to /pets you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a GET request to /pets/1.\n

\n

\n You can use a REST client such as Postman to test the POST methods in your API to create a new pet. Use the sample body below to send the POST request:\n

\n
\n{\n    \"type\" : \"cat\",\n    \"price\" : 123.11\n}\n        
\n \n" + } + } + }, + "passthroughBehavior": "when_no_match", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "type": "mock" + } + } + }, + "/pets": { + "get": { + "tags": [ + "pets" + ], + "summary": "List all pets", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "type", + "in": "query", + "description": "The type of pet to retrieve", + "required": false, + "type": "string" + }, + { + "name": "page", + "in": "query", + "description": "Page number of results to return.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Pets" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + } + } + } + }, + "security": [ + { + "test-authorizer": [] + } + ], + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.querystring.page": "method.request.querystring.page", + "integration.request.querystring.type": "method.request.querystring.type" + }, + "uri": "${uri}", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + }, + "post": { + "tags": [ + "pets" + ], + "operationId": "CreatePet", + "summary": "Create a pet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "NewPet", + "required": true, + "schema": { + "$ref": "#/definitions/NewPet" + } + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/NewPetResponse" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "uri": "${uri}", + "passthroughBehavior": "when_no_match", + "httpMethod": "POST", + "type": "http" + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + }, + "Access-Control-Allow-Methods": { + "type": "string", + "description": "Method or methods allowed when accessing the resource" + }, + "Access-Control-Allow-Headers": { + "type": "string", + "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the request." + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "passthroughBehavior": "when_no_match", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "type": "mock" + } + } + }, + "/pets/{petId}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Info for a specific pet", + "operationId": "GetPet", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "The id of the pet to retrieve", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Pet" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.path.petId": "method.request.path.petId" + }, + "uri": "${uri}/{petId}", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "The id of the pet to retrieve", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + }, + "Access-Control-Allow-Methods": { + "type": "string", + "description": "Method or methods allowed when accessing the resource" + }, + "Access-Control-Allow-Headers": { + "type": "string", + "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the request." + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "passthroughBehavior": "when_no_match", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "type": "mock" + } + } + } + }, + "securityDefinitions": { + "test-authorizer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "oauth2", + "x-amazon-apigateway-authorizer": { + "type": "token", + "authorizerUri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:function-name/invocations", + "authorizerCredentials": "arn:aws:iam::account-id:role", + "identityValidationExpression": "^x-[a-z]+", + "authorizerResultTtlInSeconds": 60 + } + }, + "test-authorizer-not-used": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "oauth2", + "x-amazon-apigateway-authorizer": { + "type": "token", + "authorizerUri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:function-name/invocations", + "authorizerCredentials": "arn:aws:iam::account-id:role", + "identityValidationExpression": "^x-[a-z]+" + } + } + }, + "definitions": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + }, + "Empty": { + "type": "object" + }, + "NewPetResponse": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/definitions/Pet" + }, + "message": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "properties": { + "petid": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "NewPet": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/PetType" + }, + "price": { + "type": "number" + } + } + }, + "PetType": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + }, + "x-amazon-apigateway-documentation": { + "version": "v2.1", + "createdDate": "2016-11-17T07:03:59Z", + "documentationParts": [ + { + "location": { + "type": "API" + }, + "properties": { + "info": { + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints" + } + } + }, + { + "location": { + "type": "METHOD", + "method": "GET" + }, + "properties": { + "tags": [ + "pets" + ], + "description": "PetStore HTML web page containing API usage information" + } + }, + { + "location": { + "type": "METHOD", + "path": "/pets/{petId}", + "method": "GET" + }, + "properties": { + "tags": [ + "pets" + ], + "summary": "Info for a specific pet" + } + }, + { + "location": { + "type": "METHOD", + "path": "/pets", + "method": "GET" + }, + "properties": { + "tags": [ + "pets" + ], + "summary": "List all pets" + } + }, + { + "location": { + "type": "METHOD", + "path": "/pets", + "method": "POST" + }, + "properties": { + "tags": [ + "pets" + ], + "summary": "Create a pet" + } + }, + { + "location": { + "type": "PATH_PARAMETER", + "path": "/pets/{petId}", + "method": "*", + "name": "petId" + }, + "properties": { + "description": "The id of the pet to retrieve" + } + }, + { + "location": { + "type": "QUERY_PARAMETER", + "path": "/pets", + "method": "GET", + "name": "page" + }, + "properties": { + "description": "Page number of results to return." + } + }, + { + "location": { + "type": "QUERY_PARAMETER", + "path": "/pets", + "method": "GET", + "name": "type" + }, + "properties": { + "description": "The type of pet to retrieve" + } + }, + { + "location": { + "type": "REQUEST_BODY", + "path": "/pets", + "method": "POST" + }, + "properties": { + "description": "Pet object that needs to be added to the store" + } + }, + { + "location": { + "type": "RESPONSE", + "method": "*", + "statusCode": "200" + }, + "properties": { + "description": "Successful operation" + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "OPTIONS", + "statusCode": "200", + "name": "Access-Control-Allow-Headers" + }, + "properties": { + "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the request." + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "OPTIONS", + "statusCode": "200", + "name": "Access-Control-Allow-Methods" + }, + "properties": { + "description": "Method or methods allowed when accessing the resource" + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "*", + "statusCode": "200", + "name": "Access-Control-Allow-Origin" + }, + "properties": { + "description": "URI that may access the resource" + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "GET", + "statusCode": "200", + "name": "Content-Type" + }, + "properties": { + "description": "Media type of request" + } + } + ] + } +} diff --git a/tests/aws/files/petstore-swagger.json b/tests/aws/files/petstore-swagger.json new file mode 100644 index 0000000000000..fc02bfc667efe --- /dev/null +++ b/tests/aws/files/petstore-swagger.json @@ -0,0 +1,541 @@ +{ + "swagger": "2.0", + "info": { + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "title": "PetStore" + }, + "schemes": [ + "https" + ], + "paths": { + "/": { + "get": { + "tags": [ + "pets" + ], + "description": "PetStore HTML web page containing API usage information", + "consumes": [ + "application/json" + ], + "produces": [ + "text/html" + ], + "responses": { + "200": { + "description": "Successful operation", + "headers": { + "Content-Type": { + "type": "string", + "description": "Media type of request" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Content-Type": "'text/html'" + }, + "responseTemplates": { + "text/html": "\n \n \n \n \n

Welcome to your Pet Store API

\n

\n You have successfully deployed your first API. You are seeing this HTML page because the GET method to the root resource of your API returns this content as a Mock integration.\n

\n

\n The Pet Store API contains the /pets and /pets/{petId} resources. By making a GET request to /pets you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a GET request to /pets/1.\n

\n

\n You can use a REST client such as Postman to test the POST methods in your API to create a new pet. Use the sample body below to send the POST request:\n

\n
\n{\n    \"type\" : \"cat\",\n    \"price\" : 123.11\n}\n        
\n \n" + } + } + }, + "passthroughBehavior": "when_no_match", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "type": "mock" + } + } + }, + "/pets": { + "get": { + "tags": [ + "pets" + ], + "summary": "List all pets", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "type", + "in": "query", + "description": "The type of pet to retrieve", + "required": false, + "type": "string" + }, + { + "name": "page", + "in": "query", + "description": "Page number of results to return.", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Pets" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.querystring.page": "method.request.querystring.page", + "integration.request.querystring.type": "method.request.querystring.type" + }, + "uri": "${uri}", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + }, + "post": { + "tags": [ + "pets" + ], + "operationId": "CreatePet", + "summary": "Create a pet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "NewPet", + "required": true, + "schema": { + "$ref": "#/definitions/NewPet" + } + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/NewPetResponse" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "uri": "${uri}", + "passthroughBehavior": "when_no_match", + "httpMethod": "POST", + "type": "http" + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + }, + "Access-Control-Allow-Methods": { + "type": "string", + "description": "Method or methods allowed when accessing the resource" + }, + "Access-Control-Allow-Headers": { + "type": "string", + "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the request." + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "passthroughBehavior": "when_no_match", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "type": "mock" + } + } + }, + "/pets/{petId}": { + "get": { + "tags": [ + "pets" + ], + "summary": "Info for a specific pet", + "operationId": "GetPet", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "The id of the pet to retrieve", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Pet" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.path.petId": "method.request.path.petId" + }, + "uri": "${uri}/{petId}", + "passthroughBehavior": "when_no_match", + "httpMethod": "GET", + "type": "http" + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "The id of the pet to retrieve", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Successful operation", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string", + "description": "URI that may access the resource" + }, + "Access-Control-Allow-Methods": { + "type": "string", + "description": "Method or methods allowed when accessing the resource" + }, + "Access-Control-Allow-Headers": { + "type": "string", + "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the request." + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "passthroughBehavior": "when_no_match", + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "type": "mock" + } + } + } + }, + "definitions": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + }, + "Empty": { + "type": "object" + }, + "NewPetResponse": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/definitions/Pet" + }, + "message": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "NewPet": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/PetType" + }, + "price": { + "type": "number" + } + } + }, + "PetType": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + }, + "x-amazon-apigateway-documentation": { + "version": "v2.1", + "createdDate": "2016-11-17T07:03:59Z", + "documentationParts": [ + { + "location": { + "type": "API" + }, + "properties": { + "info": { + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints" + } + } + }, + { + "location": { + "type": "METHOD", + "method": "GET" + }, + "properties": { + "tags": [ + "pets" + ], + "description": "PetStore HTML web page containing API usage information" + } + }, + { + "location": { + "type": "METHOD", + "path": "/pets/{petId}", + "method": "GET" + }, + "properties": { + "tags": [ + "pets" + ], + "summary": "Info for a specific pet" + } + }, + { + "location": { + "type": "METHOD", + "path": "/pets", + "method": "GET" + }, + "properties": { + "tags": [ + "pets" + ], + "summary": "List all pets" + } + }, + { + "location": { + "type": "METHOD", + "path": "/pets", + "method": "POST" + }, + "properties": { + "tags": [ + "pets" + ], + "summary": "Create a pet" + } + }, + { + "location": { + "type": "PATH_PARAMETER", + "path": "/pets/{petId}", + "method": "*", + "name": "petId" + }, + "properties": { + "description": "The id of the pet to retrieve" + } + }, + { + "location": { + "type": "QUERY_PARAMETER", + "path": "/pets", + "method": "GET", + "name": "page" + }, + "properties": { + "description": "Page number of results to return." + } + }, + { + "location": { + "type": "QUERY_PARAMETER", + "path": "/pets", + "method": "GET", + "name": "type" + }, + "properties": { + "description": "The type of pet to retrieve" + } + }, + { + "location": { + "type": "REQUEST_BODY", + "path": "/pets", + "method": "POST" + }, + "properties": { + "description": "Pet object that needs to be added to the store" + } + }, + { + "location": { + "type": "RESPONSE", + "method": "*", + "statusCode": "200" + }, + "properties": { + "description": "Successful operation" + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "OPTIONS", + "statusCode": "200", + "name": "Access-Control-Allow-Headers" + }, + "properties": { + "description": "Used in response to a preflight request to indicate which HTTP headers can be used when making the request." + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "OPTIONS", + "statusCode": "200", + "name": "Access-Control-Allow-Methods" + }, + "properties": { + "description": "Method or methods allowed when accessing the resource" + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "*", + "statusCode": "200", + "name": "Access-Control-Allow-Origin" + }, + "properties": { + "description": "URI that may access the resource" + } + }, + { + "location": { + "type": "RESPONSE_HEADER", + "method": "GET", + "statusCode": "200", + "name": "Content-Type" + }, + "properties": { + "description": "Media type of request" + } + } + ] + } +} diff --git a/tests/aws/files/request-template.vm b/tests/aws/files/request-template.vm new file mode 100644 index 0000000000000..53ea25dadb5c9 --- /dev/null +++ b/tests/aws/files/request-template.vm @@ -0,0 +1,40 @@ +#set($allParams = $input.params()) +#set($jsonBody = $input.json('$')) +#set($path = $allParams.get('path')) +#set($querystring = $allParams.get('querystring')) +#set($header = $allParams.get('header')) +#set($stage = $context.stage) +{ +"apiContext": { +"apiId": "$context.apiId", +"method": "$context.httpMethod", +"sourceIp": "$context.identity.sourceIp", +"userAgent": "$context.identity.userAgent", +"path": "$context.path", +"protocol": "$context.protocol", +"requestId": "$context.requestId", +"stage": "$stage" +}, +"path": { +"parameterMap": { +#foreach($paramName in $path.keySet()) +"$paramName": "$util.escapeJavaScript($path.get($paramName))"#if($foreach.hasNext),#end +#end +} +}, +"querystring": { +"parameterMap":{ +#foreach($paramName in $querystring.keySet()) +"$paramName": "$util.escapeJavaScript($querystring.get($paramName))"#if($foreach.hasNext),#end +#end +} +}, +"header": { +"parameterMap": { +#foreach($paramName in $header.keySet()) +"$paramName": "$util.escapeJavaScript($header.get($paramName))"#if($foreach.hasNext),#end +#end +} +}, +"body": $jsonBody +} diff --git a/tests/aws/files/response-template.vm b/tests/aws/files/response-template.vm new file mode 100644 index 0000000000000..9192d5adc65dc --- /dev/null +++ b/tests/aws/files/response-template.vm @@ -0,0 +1,7 @@ +#set($inputRoot = $input.path('$')) +$input.json("$") +#if($inputRoot.toString().contains("customerror")) + #set($context.responseOverride.status = 400) +#else + #set($context.responseOverride.status = 202) +#end diff --git a/tests/aws/files/s3.requests.txt b/tests/aws/files/s3.requests.txt new file mode 100644 index 0000000000000..9f64b6b97d350 --- /dev/null +++ b/tests/aws/files/s3.requests.txt @@ -0,0 +1,84 @@ +PUT /my-bucket1 HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Content-Length: 0 +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-acl;x-amz-content-sha256;x-amz-date, Signature=9c77d3e18671d2a8822c3991f05be22f454b553db07edf1dd15d8b073d571d67 +X-Amz-Acl: private +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip + +--- +PUT /my-bucket1?lifecycle= HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Content-Length: 630 +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=content-length;content-md5;host;x-amz-content-sha256;x-amz-date, Signature=a15568e48d4ecfed7eaaf13d2835fbd243077676f8aa35862a8a98ce765b718e +Content-Md5: xBH78LMVM8QIMl+Lsqr65A== +X-Amz-Content-Sha256: c307a764adc9d632ade1deaf0eaca0bc461f9cefc5d24c0547fde1dd1dfa86dc +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip + +90log/rulelogautocleantruelogEnabled30STANDARD_IA60GLACIERtmp/tmpEnabled2016-01-12T00:00:00Z + +--- +HEAD /my-bucket1 HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=49e1a182f6ad51da6bf1679f35c56946bb0a86cf51ddf107ad71efd60ac72e62 +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z + +--- +GET /my-bucket1?acl= HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=89444441a7bc8265e5c60ebdeed26650f8063b8c9346745d967c66171be45d15 +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip + +--- +GET /my-bucket1?versioning= HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=8bb5397c1c8aa5aa896ac8feaa1b7915517fd89a40bfd20b005753a1aa903b8a +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip + +--- +GET /my-bucket1?accelerate= HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=b0e8dbba2aad46b7f3cece7327e95c902e20a9b7b922415414300a907d1880a8 +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip + +--- +GET /my-bucket1?requestPayment= HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ab3be1500e4bd36b071d1a5385664558d0819f4364bd3524b8fa4437b509e214 +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip + +--- +GET /my-bucket1?logging= HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=89c78ecbb898f8088850b0f0eb7aa34fd6659c14d05092733202d0969c6cf524 +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip + +--- +GET /my-bucket1?lifecycle= HTTP/1.1 +Host: localhost:4566 +User-Agent: aws-sdk-go/1.37.0 (go1.15.5; darwin; amd64) APN/1.0 HashiCorp/1.0 Terraform/0.13.5 (+https://www.terraform.io) +Authorization: AWS4-HMAC-SHA256 Credential=mock_access_key/20210202/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ce9347b0372072036e0dd11752faba54a6251f89c3588bb838a6a19dee13cff7 +X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +X-Amz-Date: 20210202T193629Z +Accept-Encoding: gzip diff --git a/tests/aws/files/swagger-mock-cors.json b/tests/aws/files/swagger-mock-cors.json new file mode 100644 index 0000000000000..9585d0e0d499c --- /dev/null +++ b/tests/aws/files/swagger-mock-cors.json @@ -0,0 +1,117 @@ +{ + "info": { + "version": "1.0", + "title": "aws-serverless-shopping-cart-product-mock" + }, + "paths": { + "/product/{product_id}": { + "options": { + "x-amazon-apigateway-integration": { + "type": "mock", + "requestTemplates": { + "application/json": "{\n \"statusCode\" : 200\n}\n" + }, + "responses": { + "default": { + "statusCode": "200", + "responseTemplates": { + "application/json": "{}\n" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'" + } + } + } + }, + "consumes": [ + "application/json" + ], + "summary": "CORS support", + "responses": { + "200": { + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + } + }, + "description": "Default response for CORS method" + } + }, + "produces": [ + "application/json" + ] + }, + "get": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:aws-serverless-shopping-cart-produc-GetProductFunction-28378339:live/invocations" + }, + "responses": {} + } + }, + "/product": { + "options": { + "x-amazon-apigateway-integration": { + "type": "mock", + "requestTemplates": { + "application/json": "{\n \"statusCode\" : 200\n}\n" + }, + "responses": { + "default": { + "statusCode": "200", + "responseTemplates": { + "application/json": "{}\n" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'" + } + } + } + }, + "consumes": [ + "application/json" + ], + "summary": "CORS support", + "responses": { + "200": { + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + } + }, + "description": "Default response for CORS method" + } + }, + "produces": [ + "application/json" + ] + }, + "get": { + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:aws-serverless-shopping-cart-produ-GetProductsFunction-c1359550:live/invocations" + }, + "responses": {} + } + } + }, + "swagger": "2.0" +} diff --git a/tests/aws/files/swagger.json b/tests/aws/files/swagger.json new file mode 100644 index 0000000000000..aff58b31a2603 --- /dev/null +++ b/tests/aws/files/swagger.json @@ -0,0 +1,104 @@ +{ + "swagger": "2.0", + "info": { + "title": "Import swagger JSON", + "version": "2" + }, + "basePath": "/base", + "paths": { + "/test": { + "get": { + "security": [ + { + "myapi-authorizer-0": [] + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match", + "type": "mock" + } + }, + "options": { + "security": [ + { + "myapi-authorizer-0": [] + } + ], + "responses": { + "200": { + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + } + }, + "description": "" + } + }, + "x-amazon-apigateway-integration": { + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match", + "type": "mock" + } + } + } + }, + "definitions": { + "Empty": { + "title": "Empty Schema", + "type": "object" + } + }, + "securityDefinitions": { + "myapi-authorizer-0": { + "in": "query", + "name": "auth", + "type": "apiKey", + "x-amazon-apigateway-authorizer": { + "authorizerCredentials": "arn:aws:iam::000000000000:role/myapi-authorizer-0-authorizer-role-3bd761a", + "authorizerUri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:myapi-authorizer-0-22ad13b/invocations", + "identitySource": "method.request.querystring.auth", + "type": "request" + }, + "x-amazon-apigateway-authtype": "custom" + } + } +} diff --git a/tests/aws/scenario/__init__.py b/tests/aws/scenario/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/bookstore/__init__.py b/tests/aws/scenario/bookstore/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/bookstore/functions/getBook.js b/tests/aws/scenario/bookstore/functions/getBook.js new file mode 100644 index 0000000000000..b84fd50f4f98f --- /dev/null +++ b/tests/aws/scenario/bookstore/functions/getBook.js @@ -0,0 +1,56 @@ +// source adapted from https://github.com/aws-samples/aws-bookstore-demo-app + +"use strict"; + +const AWS = require("aws-sdk"); + +var config = {}; +if (process.env.AWS_ENDPOINT_URL) { + config.endpoint = process.env.AWS_ENDPOINT_URL; +} + +let dynamoDb = new AWS.DynamoDB.DocumentClient(config); + +// GetBook - Get book informaton for a given book id +exports.handler = (event, context, callback) => { + + // Return immediately if being called by warmer + if (event.source === "warmer") { + return callback(null, "Lambda is warm"); + } + + const params = { + TableName: process.env.TABLE_NAME, // [ProjectName]-Books + // 'Key' defines the partition key of the item to be retrieved + // - 'id': a unique identifier for the book (uuid) + Key: { + id: event.pathParameters.id + } + }; + dynamoDb.get(params, (error, data) => { + // Set response headers to enable CORS (Cross-Origin Resource Sharing) + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials" : true + }; + + // Return status code 500 on error + if (error) { + const response = { + statusCode: 500, + headers: headers, + body: error + }; + callback(null, response); + return; + } + + // Return status code 200 and the retrieved item on success + const response = { + statusCode: 200, + headers: headers, + body: JSON.stringify(data.Item) + }; + callback(null, response); + }); +} diff --git a/tests/aws/scenario/bookstore/functions/listBooks.js b/tests/aws/scenario/bookstore/functions/listBooks.js new file mode 100644 index 0000000000000..5321612388ea6 --- /dev/null +++ b/tests/aws/scenario/bookstore/functions/listBooks.js @@ -0,0 +1,91 @@ +// source adapted from https://github.com/aws-samples/aws-bookstore-demo-app + +"use strict"; + +const AWS = require("aws-sdk"); + +var config = {}; +if (process.env.AWS_ENDPOINT_URL) { + config.endpoint = process.env.AWS_ENDPOINT_URL; +} + +let dynamoDb = new AWS.DynamoDB.DocumentClient(config); + +// ListBooks - List all books or list all books in a particular category +exports.handler = (event, context, callback) => { + + // Return immediately if being called by warmer + if (event.source === "warmer") { + return callback(null, "Lambda is warm"); + } + + // Set response headers to enable CORS (Cross-Origin Resource Sharing) + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials" : true + }; + + // Query books for a particular category + if (event.queryStringParameters) { + const params = { + TableName: process.env.TABLE_NAME, // [ProjectName]-Books + IndexName: "category-index", + // 'KeyConditionExpression' defines the condition for the query + // - 'category = :category': only return items with matching 'category' index + // 'ExpressionAttributeValues' defines the value in the condition + // - ':category': defines 'category' to be the query string parameter + KeyConditionExpression: "category = :category", + ExpressionAttributeValues: { + ":category": event.queryStringParameters.category + } + }; + dynamoDb.query(params, (error, data) => { + // Return status code 500 on error + if (error) { + const response = { + statusCode: 500, + headers: headers, + body: error + }; + callback(null, response); + return; + } + + // Return status code 200 and the retrieved items on success + const response = { + statusCode: 200, + headers: headers, + body: JSON.stringify(data.Items) + }; + callback(null, response); + }); + } + + // List all books in bookstore + else { + const params = { + TableName: process.env.TABLE_NAME // [ProjectName]-Books + }; + + dynamoDb.scan(params, (error, data) => { + // Return status code 500 on error + if (error) { + const response = { + statusCode: 500, + headers: headers, + body: error + }; + callback(null, response); + return; + } + + // Return status code 200 and the retrieved items on success + const response = { + statusCode: 200, + headers: headers, + body: JSON.stringify(data.Items) + }; + callback(null, response); + }); + } +} diff --git a/tests/aws/scenario/bookstore/functions/loadBooksHelper.js b/tests/aws/scenario/bookstore/functions/loadBooksHelper.js new file mode 100644 index 0000000000000..98bb6d485c8ed --- /dev/null +++ b/tests/aws/scenario/bookstore/functions/loadBooksHelper.js @@ -0,0 +1,81 @@ +// source: https://github.com/aws-samples/aws-bookstore-demo-app/blob/master/functions/setup/uploadBooks.js + +"use strict"; + +const https = require("https"); +const url = require("url"); +const AWS = require("aws-sdk"); + +var config = { + 's3ForcePathStyle': true, +}; +if (process.env.AWS_ENDPOINT_URL) { + config.endpoint = process.env.AWS_ENDPOINT_URL; +} + +let documentClient = new AWS.DynamoDB.DocumentClient(config); +let s3Client = new AWS.S3(config); + +// UploadBooks - Upload sample set of books to DynamoDB +exports.handler = function(event, context, callback) { + getBooksData().then(function(data) { + var booksString = data.Body.toString("utf-8"); + console.log("received booksString"); + var booksList = JSON.parse(booksString); + console.log("parsing bookslist"); + uploadBooksData(booksList); + console.log("uploaded books"); + }).catch(function(err) { + console.log(err); + var responseData = { Error: "Upload books failed" }; + console.log(responseData.Error); + }); + + return; +}; +function uploadBooksData(book_items) { + var items_array = []; + for (var i in book_items) { + var book = book_items[i]; + console.log(book.id) + var item = { + PutRequest: { + Item: book + } + }; + items_array.push(item); + } + + // Batch items into arrays of 25 for BatchWriteItem limit + var split_arrays = [], size = 25; + while (items_array.length > 0) { + split_arrays.push(items_array.splice(0, size)); + } + + split_arrays.forEach( function(item_data) { + putItem(item_data) + }); +} + +// Retrieve sample books from aws-bookstore-demo S3 Bucket +function getBooksData() { + var params = { + Bucket: process.env.S3_BUCKET, // aws-bookstore-demo + Key: process.env.FILE_NAME // data/books.json + }; + return s3Client.getObject(params).promise(); +} + + +function putItem(items_array) { + var tableName = process.env.TABLE_NAME; + var params = { + RequestItems: { + [tableName]: items_array + } + }; + documentClient.batchWrite(params, function(err, data) { + if (err) console.log(err); + else console.log(data); + }); +} diff --git a/tests/aws/scenario/bookstore/functions/search.py b/tests/aws/scenario/bookstore/functions/search.py new file mode 100644 index 0000000000000..8ab02291d7aaf --- /dev/null +++ b/tests/aws/scenario/bookstore/functions/search.py @@ -0,0 +1,56 @@ +# source adapted from https://github.com/aws-samples/aws-bookstore-demo-app +import json +import os + +import boto3 +import requests +from requests_aws4auth import AWS4Auth + +region = os.environ["AWS_REGION"] +service = "es" +credentials = boto3.Session().get_credentials() +awsauth = AWS4Auth( + credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token +) + +index = "lambda-index" +type = "lambda-type" +if os.getenv("LOCALSTACK_HOSTNAME"): + url = "http://" + os.environ["ESENDPOINT"] + "/_search" # TODO ssl error for localstack + url = url.replace("localhost.localstack.cloud", os.getenv("LOCALSTACK_HOSTNAME")) +else: + url = ( + "https://" + os.environ["ESENDPOINT"] + "/_search" + ) # the Amazon ElaticSearch domain, with https:// + + +# Search - Search for books across book names, authors, and categories +def handler(event, context): + # Put the user query into the query DSL for more accurate search results. + query = { + "size": 25, + "query": { + "multi_match": { + "query": event["queryStringParameters"]["q"], + "fields": ["name.S", "author.S", "category.S"], + } + }, + } + print(query) + + # ES 6.x requires an explicit Content-Type header + headers = {"Content-Type": "application/json"} + + # Make the signed HTTP request + r = requests.get(url, auth=awsauth, headers=headers, data=json.dumps(query)) + + # Create the response and add some extra content to support CORS + response = { + "statusCode": r.status_code, + "headers": {"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": True}, + "body": r.text, + } + + # Add the search results to the response + print(response) + return response diff --git a/tests/aws/scenario/bookstore/functions/update_search_cluster.py b/tests/aws/scenario/bookstore/functions/update_search_cluster.py new file mode 100644 index 0000000000000..923d09bc556a1 --- /dev/null +++ b/tests/aws/scenario/bookstore/functions/update_search_cluster.py @@ -0,0 +1,46 @@ +# source adapted from https://github.com/aws-samples/aws-bookstore-demo-app + +import os + +import boto3 +import requests +from requests_aws4auth import AWS4Auth + +region = os.environ["AWS_REGION"] +service = "es" +credentials = boto3.Session().get_credentials() +awsauth = AWS4Auth( + credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token +) +if os.getenv("LOCALSTACK_HOSTNAME"): + host = "http://" + os.environ["ESENDPOINT"] # TODO ssl error for localstack + host = host.replace("localhost.localstack.cloud", os.getenv("LOCALSTACK_HOSTNAME")) +else: + host = "https://" + os.environ["ESENDPOINT"] # the Amazon ElasticSearch domain, with https:// + +index = "lambda-index" +type = "_doc" + +url = host + "/" + index + "/" + type + "/" + +headers = {"Content-Type": "application/json"} + + +# UpdateSearchCluster - Updates Elasticsearch when new books are added to the store +def handler(event, context): + count = 0 + for record in event["Records"]: + # Get the primary key for use as the Elasticsearch ID + id = record["dynamodb"]["Keys"]["id"]["S"] + print("bookId " + id) + + if record["eventName"] == "REMOVE": + requests.delete(url + id, auth=awsauth) + else: + document = record["dynamodb"]["NewImage"] + print(document) + r = requests.put(url + id, auth=awsauth, json=document, headers=headers) + print(r.content) + count += 1 + print(f"processed {count} records.") + return str(count) + " records processed." diff --git a/tests/aws/scenario/bookstore/resources/initial_books.json b/tests/aws/scenario/bookstore/resources/initial_books.json new file mode 100644 index 0000000000000..11290e1bde6f5 --- /dev/null +++ b/tests/aws/scenario/bookstore/resources/initial_books.json @@ -0,0 +1,506 @@ +[ + { + "id": "084s9grl-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Richard Labadie", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Spaghetti.png", + "name": "Spaghetti", + "price": 20.99, + "rating": 5 + }, + { + "id": "0b7ruzew-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Stuart Hessel", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/butter_%26_flour.png", + "name": "Butter & Flour", + "price": 15.98, + "rating": 4 + }, + { + "id": "0ld0qvru-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Key-value.png", + "name": "Key-value", + "price": 23.95, + "rating": 5 + }, + { + "id": "0o6r76vt-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Databases.png", + "name": "Databases", + "price": 15.99, + "rating": 5 + }, + { + "id": "0u96q42u-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Search.png", + "name": "Search", + "price": 20.99, + "rating": 5 + }, + { + "id": "0vld6p1u-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Science Fiction", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/3140.png", + "name": "3140", + "price": 15.99, + "rating": 5 + }, + { + "id": "2k769fhx-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Stuart Hessel", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/matcha.png", + "name": "Matcha", + "price": 22.96, + "rating": 5 + }, + { + "id": "2rb37qw5-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Cars", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Behind+the+symbol.png", + "name": "Behind The Symbol", + "price": 22.96, + "rating": 3 + }, + { + "id": "2shdmlp0-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Jaylen Anderson", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/the_perfect_roast.png", + "name": "The Perfect Roast", + "price": 15.99, + "rating": 4 + }, + { + "id": "2vxvmruf-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Chef Maple", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/The_Joys_of_Pancakes.png", + "name": "The Joys of Pancakes", + "price": 21.98, + "rating": 5 + }, + { + "id": "33s6hqam-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Richard Labadie", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/101_burgers.png", + "name": "101 Burgers", + "price": 18.99, + "rating": 2 + }, + { + "id": "3k9zka2c-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Drill.png", + "name": "Drill", + "price": 15.98, + "rating": 5 + }, + { + "id": "3sbvndpe-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Graph.png", + "name": "Graph", + "price": 22.96, + "rating": 5 + }, + { + "id": "56ysbni4-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Jaylen Anderson", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/levantine_hummus.png", + "name": "Levantine Hummus", + "price": 23.95, + "rating": 4 + }, + { + "id": "5oekg7gl-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Floor.png", + "name": "Floor", + "price": 21.98, + "rating": 5 + }, + { + "id": "5ymiu6mo-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Deondre Toy", + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Bathrooms.png", + "name": "Bathrooms", + "price": 21.98, + "rating": 5 + }, + { + "id": "623gsnj1-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Master+admin.png", + "name": "Master admin", + "price": 18.99, + "rating": 5 + }, + { + "id": "6d32snj1-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Storage+servers.png", + "name": "Storage Servers", + "price": 18.99, + "rating": 5 + }, + { + "id": "6dyqsnj1-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/In-memory.png", + "name": "In-memory", + "price": 18.99, + "rating": 5 + }, + { + "id": "7vfydos1-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Laura Nader", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/spice.png", + "name": "Spice", + "price": 15.98, + "rating": 5 + }, + { + "id": "8g3ymswp-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Science Fiction", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/The+first.png", + "name": "The First", + "price": 23.95, + "rating": 3 + }, + { + "id": "8u6lpj3e-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Aubree Konopelski", + "category": "Fairy Tales", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Just+follow.png", + "name": "Just Follow", + "price": 17.99, + "rating": 5 + }, + { + "id": "917h7iji-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Cars", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/T.png", + "name": "T", + "price": 18.99, + "rating": 4 + }, + { + "id": "9vp96t5a-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Aubree Konopelski", + "category": "Fairy Tales", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Belle.png", + "name": "Belle", + "price": 19.99, + "rating": 5 + }, + { + "id": "a7zyln40-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Ellen Kuvalis", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/carbs.png", + "name": "Carbs", + "price": 21.98, + "rating": 4 + }, + { + "id": "bbih080x-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Henry Wunsch", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Ten_dozen_cupcakes.png", + "name": "Ten Dozen Cupcakes", + "price": 15.99, + "rating": 3 + }, + { + "id": "bm00o9jj-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Richard Labadie", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/scream_ice_cream.png", + "name": "Scream Ice Cream", + "price": 20.99, + "rating": 4 + }, + { + "id": "bsx7u3xv-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Aubree Konopelski", + "category": "Fairy Tales", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Duckling.png", + "name": "Duckling", + "price": 18.99, + "rating": 4 + }, + { + "id": "cpw3nosp-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Richard Labadie", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/fondue.png", + "name": "Fondue", + "price": 15.98, + "rating": 3 + }, + { + "id": "ep003yyx-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Rodirck Torp", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/SOJU.png", + "name": "Soju", + "price": 15.98, + "rating": 4 + }, + { + "id": "f1vs8qjw-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Chef Susan", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/noodles_everyday.png", + "name": "Noodles Everyday", + "price": 19.99, + "rating": 3 + }, + { + "id": "g8zuo3j8-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Laura Ray", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Pineapple+dreams.png", + "name": "Pineapple Dreams", + "price": 23.95, + "rating": 5 + }, + { + "id": "i0bka1vt-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Morissette", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/SIMPLY_ITALIAN.png", + "name": "Simply Italian", + "price": 19.99, + "rating": 5 + }, + { + "id": "iee1rx9x-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Jaylen Anderson", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/caffe_latte_art.png", + "name": "Caffe Latte Art", + "price": 17.99, + "rating": 5 + }, + { + "id": "io6l0iyw-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Jaylen Anderson", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/french_press.png", + "name": "French Press", + "price": 18.99, + "rating": 4 + }, + { + "id": "l1dcnrsi-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Document.png", + "name": "Document", + "price": 23.95, + "rating": 5 + }, + { + "id": "lk38ejv5-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Deondre Toy", + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Kitchens.png", + "name": "Kitchens", + "price": 17.96, + "rating": 3 + }, + { + "id": "nhco678y-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Surviving+your+UX+career.png", + "name": "Surviving Your UX Career", + "price": 17.99, + "rating": 4 + }, + { + "id": "nrubyjwh-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Wilfredo Davis", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/bakers_dozen.png", + "name": "Bakers Dozen", + "price": 19.99, + "rating": 4 + }, + { + "id": "nuklcm5b-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Miles Way", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Chasing_umami.png", + "name": "Chasing Umami", + "price": 15.98, + "rating": 3 + }, + { + "id": "o3ahe30e-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Cars", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/classic.png", + "name": "Classic", + "price": 17.96, + "rating": 2 + }, + { + "id": "q8qfaonc-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Chisel.png", + "name": "Chisel", + "price": 17.99, + "rating": 4 + }, + { + "id": "r0vs97kp-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Deondre Toy", + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Smart+homes.png", + "name": "Smart Homes", + "price": 17.96, + "rating": 3 + }, + { + "id": "rbfjp603-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Richard Labadie", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/macaroons.png", + "name": "Macaroons", + "price": 21.98, + "rating": 4 + }, + { + "id": "rkz1ljyg-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Laura Nader", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Dairy.png", + "name": "Dairy", + "price": 17.96, + "rating": 1 + }, + { + "id": "sif184ws-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Stuart Hessel", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/tea.png", + "name": "Tea", + "price": 18.99, + "rating": 4 + }, + { + "id": "tj8mc0yd-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Aubree Konopelski", + "category": "Fairy Tales", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Little+red.png", + "name": "Little Red", + "price": 15.98, + "rating": 3 + }, + { + "id": "tyqnawj4-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Deondre Toy", + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Home+office.png", + "name": "Home Office", + "price": 19.99, + "rating": 4 + }, + { + "id": "udkp3cea-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Science Fiction", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/esc.png", + "name": "ESC", + "price": 20.99, + "rating": 5 + }, + { + "id": "v1hjdhtk-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Science Fiction", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Humanty+now.png", + "name": "Humanity Now", + "price": 15.98, + "rating": 5 + }, + { + "id": "wh9yiu5w-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Aubree Konopelski", + "category": "Fairy Tales", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/HG.png", + "name": "HG", + "price": 22.96, + "rating": 4 + }, + { + "id": "xfpwqd2u-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Neal Wisozk", + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/saw.png", + "name": "Saw", + "price": 17.99, + "rating": 4 + }, + { + "id": "xt4u83kb-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Richard Labadie", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/say_cheese.png", + "name": "Say Cheese", + "price": 17.96, + "rating": 5 + }, + { + "id": "y8vnbpvn-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Brady Fisher", + "category": "Database", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/UX+software.png", + "name": "Classic Software", + "price": 22.96, + "rating": 4 + }, + { + "id": "yrqzhlal-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Jaylen Anderson", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/home_brew_guide.png", + "name": "Home Brew Guide", + "price": 15.99, + "rating": 5 + }, + { + "id": "zceo3fdn-d93b-11e8-9f8b-f2801f1b9fd1", + "author": "Jake Jakubowski", + "category": "Cookbooks", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Lamb_grilling_guide.png", + "name": "Lamb Grilling Guide", + "price": 20.99, + "rating": 4 + } +] diff --git a/tests/aws/scenario/bookstore/test_bookstore.py b/tests/aws/scenario/bookstore/test_bookstore.py new file mode 100644 index 0000000000000..149a7872a1fee --- /dev/null +++ b/tests/aws/scenario/bookstore/test_bookstore.py @@ -0,0 +1,521 @@ +import json +import os +from operator import itemgetter + +import aws_cdk as cdk +import aws_cdk.aws_dynamodb as dynamodb +import aws_cdk.aws_ec2 as ec2 +import aws_cdk.aws_iam as iam +import aws_cdk.aws_lambda as awslambda +import aws_cdk.aws_opensearchservice as opensearch +import pytest +from aws_cdk.aws_lambda_event_sources import DynamoEventSource +from botocore.exceptions import ClientError +from constructs import Construct +from localstack_snapshot.snapshots.transformer import GenericTransformer, KeyValueBasedTransformer + +from localstack.testing.pytest import markers +from localstack.testing.scenario.cdk_lambda_helper import load_python_lambda_to_s3 +from localstack.testing.scenario.provisioning import InfraProvisioner, cleanup_s3_bucket +from localstack.utils.aws.resources import create_s3_bucket +from localstack.utils.files import load_file +from localstack.utils.strings import to_bytes +from localstack.utils.sync import retry + +""" + +This scenario is based on https://github.com/aws-samples/aws-bookstore-demo-app + +Currently includes: +- DynamoDB +- OpenSearch +- EventSourceMapping +- several Lambdas for pre-filling + querying dynamodb + opensearch + +Scenarios: +* First tests calls a Lambda that pre-fills the dynamoDB + * EventSourceMapping: will retrieve stream from DynamoDB and send PUT requests to opensearch cluster +* get/list Lambdas that query dynamodb +* search Lambda that sends query to opensearch (for category, name, title of books) +""" + +S3_BUCKET_BOOKS_INIT = "book-init-data-store-scenario-test" +S3_KEY_BOOKS_INIT = "books.json" +SEARCH_KEY = "search.zip" +SEARCH_UPDATE_KEY = "search_update.zip" + + +@markers.acceptance_test +class TestBookstoreApplication: + @pytest.fixture(scope="class") + def patch_opensearch_strategy(self): + """patching the endpoint strategy for opensearch to path, to make the endpoint resolution in the lambda easier""" + from _pytest.monkeypatch import MonkeyPatch + + from localstack import config + + mpatch = MonkeyPatch() + mpatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "path") + yield mpatch + mpatch.undo() + + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, aws_client, infrastructure_setup, patch_opensearch_strategy): + infra = infrastructure_setup("Bookstore") + + search_book_fn_path = os.path.join(os.path.dirname(__file__), "functions/search.py") + search_update_fn_path = os.path.join( + os.path.dirname(__file__), "functions/update_search_cluster.py" + ) + # custom provisioning + additional_packages = ["requests", "requests-aws4auth", "urllib3==1.26.6"] + asset_bucket = infra.get_asset_bucket() + infra.add_custom_setup_provisioning_step( + lambda: load_python_lambda_to_s3( + aws_client.s3, + bucket_name=asset_bucket, + key_name=SEARCH_KEY, + code_path=search_book_fn_path, + additional_python_packages=additional_packages, + ) + ) + infra.add_custom_setup_provisioning_step( + lambda: load_python_lambda_to_s3( + aws_client.s3, + bucket_name=asset_bucket, + key_name=SEARCH_UPDATE_KEY, + code_path=search_update_fn_path, + additional_python_packages=additional_packages, + ) + ) + + # CDK-based provisioning + stack = cdk.Stack(infra.cdk_app, "BookstoreStack") + books_api = BooksApi( + stack, + "BooksApi", + search_key=SEARCH_KEY, + search_update_key=SEARCH_UPDATE_KEY, + ) + + cdk.CfnOutput(stack, "BooksTableName", value=books_api.books_table.table_name) + cdk.CfnOutput(stack, "SearchDomain", value=books_api.opensearch_domain.domain_endpoint) + cdk.CfnOutput(stack, "SearchDomainName", value=books_api.opensearch_domain.domain_name) + cdk.CfnOutput(stack, "GetBooksFn", value=books_api.get_book_fn.function_name) + cdk.CfnOutput(stack, "ListBooksFn", value=books_api.list_books_fn.function_name) + cdk.CfnOutput(stack, "InitBooksTableFn", value=books_api.load_books_helper_fn.function_name) + cdk.CfnOutput(stack, "SearchForBooksFn", value=books_api.search_book_fn.function_name) + + # set skip_teardown=True to prevent the stack to be deleted + with infra.provisioner(skip_teardown=False) as prov: + yield prov + + @markers.aws.validated + def test_setup(self, aws_client, infrastructure, snapshot, cleanups): + outputs = infrastructure.get_stack_outputs("BookstoreStack") + load_books_helper_fn = outputs.get("InitBooksTableFn") + + # pre-fill dynamodb + # json-data is from https://aws-bookstore-demo.s3.amazonaws.com/data/books.json + try: + create_s3_bucket(bucket_name=S3_BUCKET_BOOKS_INIT, s3_client=aws_client.s3) + except ClientError as exc: + if exc.response["Error"]["Code"] != "BucketAlreadyOwnedByYou": + raise exc + cleanups.append( + lambda: cleanup_s3_bucket( + aws_client.s3, bucket_name=S3_BUCKET_BOOKS_INIT, delete_bucket=True + ) + ) + + file_name = os.path.join(os.path.dirname(__file__), "./resources/initial_books.json") + aws_client.s3.upload_file( + Filename=file_name, + Bucket=S3_BUCKET_BOOKS_INIT, + Key=S3_KEY_BOOKS_INIT, + ) + + aws_client.lambda_.invoke(FunctionName=load_books_helper_fn) + + # after invoking the dynamodb should be filled + table_name = outputs.get("BooksTableName") + + # wait until everything is filled, we should get 56 items in the table + def _verify_dynamodb_count(): + res = aws_client.dynamodb.scan(TableName=table_name, Select="COUNT") + assert res["Count"] == 56 + + retry(_verify_dynamodb_count, retries=20, sleep=1) + + item_count = aws_client.dynamodb.scan(TableName=table_name, Select="COUNT") + snapshot.match("scan_count", item_count) + + # chose one id from the initial_books.json + result = aws_client.dynamodb.get_item( + TableName=table_name, Key={"id": {"S": "nuklcm5b-d93b-11e8-9f8b-f2801f1b9fd1"}} + ) + snapshot.match("get-item", result) + + @markers.aws.validated + def test_lambda_dynamodb(self, aws_client, infrastructure, snapshot): + snapshot.add_transformer(snapshot.transform.lambda_api()) + + def _convert_payload_body_to_json(snapshot_content: dict, *args) -> dict: + """converts the "body" payload into a comparable json""" + for k, v in snapshot_content.items(): + if isinstance(v, dict) and "Payload" in v: + v = v["Payload"] + if isinstance(v, dict) and "body" in v: + v["body"] = json.loads(v["body"]) + if isinstance(v["body"], list): + v["body"].sort(key=itemgetter("id")) + return snapshot_content + + snapshot.add_transformer(GenericTransformer(_convert_payload_body_to_json)) + + outputs = infrastructure.get_stack_outputs("BookstoreStack") + get_books_fn = outputs.get("GetBooksFn") + list_books_fn = outputs.get("ListBooksFn") + + result = aws_client.lambda_.invoke( + FunctionName=get_books_fn, + Payload=to_bytes( + json.dumps({"pathParameters": {"id": "0vld6p1u-d93b-11e8-9f8b-f2801f1b9fd1"}}) + ), + ) + snapshot.match("get_books_fn", result) + payload_category = {"queryStringParameters": {"category": "Woodwork"}} + + result = aws_client.lambda_.invoke( + FunctionName=list_books_fn, + Payload=to_bytes(json.dumps(payload_category)), + ) + result = json.load(result["Payload"]) + snapshot.match("list_books_cat_woodwork", result) + + # test another category + payload_category["queryStringParameters"]["category"] = "Home Improvement" + result = aws_client.lambda_.invoke( + FunctionName=list_books_fn, + Payload=to_bytes(json.dumps(payload_category)), + ) + result = json.load(result["Payload"]) + snapshot.match("list_books_cat_home", result) + + # without category it should return all books + result = aws_client.lambda_.invoke(FunctionName=list_books_fn) + result = json.load(result["Payload"]) + assert len(json.loads(result["body"])) == 56 + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$.._shards.successful", "$.._shards.total"]) + def test_search_books(self, aws_client, infrastructure, snapshot): + def _sort_hits(snapshot_content: dict, *args) -> dict: + """sort "hits" list by id""" + for k, v in snapshot_content.items(): + if "hits" in v and "hits" in v["hits"]: + v["hits"]["hits"].sort(key=itemgetter("_id")) + return snapshot_content + + snapshot.add_transformer(GenericTransformer(_sort_hits)) + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: v + if k in ("took", "max_score", "_score") + and (isinstance(v, float) or isinstance(v, int)) + else None, + replacement="", + replace_reference=False, + ) + ) + outputs = infrastructure.get_stack_outputs("BookstoreStack") + search_fn = outputs.get("SearchForBooksFn") + + def _verify_search(category: str, expected_amount: int): + res = aws_client.lambda_.invoke( + FunctionName=search_fn, + Payload=to_bytes(json.dumps({"queryStringParameters": {"q": category}})), + ) + res = json.load(res["Payload"]) + search_res = json.loads(res["body"])["hits"]["total"]["value"] + # compare total hits with expected results, total hits are not bound by the size limit of the query + assert search_res == expected_amount + return res + + # it might take a little until the search is fully functional + # because we have an event source mapping + retry(lambda: _verify_search("cookbooks", 26), retries=100, sleep=1) + + # search for book with title "Spaghetti" + search_payload = {"queryStringParameters": {"q": "Spaghetti"}} + + result = aws_client.lambda_.invoke( + FunctionName=search_fn, + Payload=to_bytes(json.dumps(search_payload)), + ) + result = json.load(result["Payload"]) + search_result = json.loads(result["body"]) + snapshot.match("search_name_spaghetti", search_result) + + # we witnessed a flaky test in CI where some search queries did not return the expected result + # assuming some entries might need longer to be indexed + # search for author + result = retry(lambda: _verify_search("aubree", 5), retries=20, sleep=1) + search_result = json.loads(result["body"]) + snapshot.match("search_author_aubree", search_result) + + # search for category + result = retry(lambda: _verify_search("Home Impro", 5), retries=20, sleep=1) + search_result = json.loads(result["body"]) + snapshot.match("search_cat_home_impro", search_result) + + # search for a non-existent string (should return 0 results) + search_payload["queryStringParameters"]["q"] = "Something" + result = aws_client.lambda_.invoke( + FunctionName=search_fn, + Payload=to_bytes(json.dumps(search_payload)), + ) + result = json.load(result["Payload"]) + search_result = json.loads(result["body"]) + snapshot.match("search_no_result", search_result) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ClusterConfig.DedicatedMasterCount", # added in LS + "$..ClusterConfig.DedicatedMasterEnabled", # added in LS + "$..ClusterConfig.DedicatedMasterType", # added in LS + "$..ClusterConfig.Options.DedicatedMasterCount", # added in LS + "$..ClusterConfig.Options.DedicatedMasterType", # added in LS + "$..DomainStatusList..EBSOptions.Iops", # added in LS + "$..DomainStatusList..IPAddressType", # missing + "$..DomainStatusList..DomainProcessingStatus", # missing + "$..DomainStatusList..ModifyingProperties", # missing + "$..SoftwareUpdateOptions", # missing + "$..OffPeakWindowOptions", # missing + "$..ChangeProgressDetails", # missing + "$..AutoTuneOptions.UseOffPeakWindow", # missing + "$..AutoTuneOptions.Options.UseOffPeakWindow", # missing + "$..ClusterConfig.MultiAZWithStandbyEnabled", # missing + "$..AdvancedSecurityOptions.AnonymousAuthEnabled", # missing + "$..AdvancedSecurityOptions.Options.AnonymousAuthEnabled", # missing + "$..DomainConfig.ClusterConfig.Options.WarmEnabled", # missing + "$..DomainConfig.IPAddressType", # missing + "$..DomainConfig.ModifyingProperties", # missing + "$..ClusterConfig.Options.ColdStorageOptions", # missing + "$..ClusterConfig.Options.MultiAZWithStandbyEnabled", # missing + # TODO different values: + "$..Processing", + "$..ServiceSoftwareOptions.CurrentVersion", + "$..ClusterConfig.DedicatedMasterEnabled", + "$..ClusterConfig.InstanceType", + "$..SnapshotOptions.Options.AutomatedSnapshotStartHour", + "$..ClusterConfig.Options.DedicatedMasterEnabled", + "$..ClusterConfig.Options.InstanceType", + "$..AutoTuneOptions.State", + "$..EBSOptions.Options.VolumeSize", + '$..AdvancedOptions."rest.action.multi.allow_explicit_index"', + '$..AdvancedOptions.Options."rest.action.multi.allow_explicit_index"', + # TODO currently no support for ElasticSearch 2.3 + 1.5 + "$..Versions", + ] + ) + def test_opensearch_crud(self, aws_client, infrastructure, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("DomainId")) + snapshot.add_transformer(snapshot.transform.key_value("DomainName")) + snapshot.add_transformer(snapshot.transform.key_value("ChangeId")) + snapshot.add_transformer(snapshot.transform.key_value("Endpoint"), priority=-1) + # UpdateVersion seems to change with almost every execution + snapshot.add_transformer( + snapshot.transform.key_value("UpdateVersion", reference_replacement=False) + ) + outputs = infrastructure.get_stack_outputs("BookstoreStack") + opensearch_domain_name = outputs.get("SearchDomainName") + + describe_domains = aws_client.opensearch.describe_domains( + DomainNames=[opensearch_domain_name] + ) + snapshot.match("describe_domains", describe_domains) + arn = describe_domains["DomainStatusList"][0]["ARN"] + domain_names = aws_client.opensearch.list_domain_names() + + snapshot.match("list_domain_names", domain_names) + + domain_config = aws_client.opensearch.describe_domain_config( + DomainName=opensearch_domain_name + ) + snapshot.match("describe_domain_config", domain_config) + + # add tags + aws_client.opensearch.add_tags( + ARN=arn, + TagList=[ + {"Key": "scenario/test", "Value": "bookstore"}, + {"Key": "bookstore", "Value": "search"}, + ], + ) + # list tags + tags = aws_client.opensearch.list_tags(ARN=arn) + tags["TagList"].sort(key=itemgetter("Key")) + snapshot.match("list_tags", tags) + # remove tags + aws_client.opensearch.remove_tags(ARN=arn, TagKeys=["bookstore"]) + tags = aws_client.opensearch.list_tags(ARN=arn) + tags["TagList"].sort(key=itemgetter("Key")) + snapshot.match("list_tags_after_remove", tags) + + compatible_versions = aws_client.opensearch.get_compatible_versions( + DomainName=opensearch_domain_name + ) + snapshot.match("get_compatible_versions", compatible_versions) + + list_versions = aws_client.opensearch.list_versions() + snapshot.match("list_versions", list_versions) + + +class BooksApi(Construct): + load_books_helper_fn: awslambda.Function + get_book_fn: awslambda.Function + list_books_fn: awslambda.Function + search_book_fn: awslambda.Function + update_search_cluster_fn: awslambda.Function + books_table: dynamodb.Table + opensearch_domain: opensearch.Domain + + LOAD_BOOKS_HELPER_PATH = os.path.join(os.path.dirname(__file__), "functions/loadBooksHelper.js") + GET_BOOK_PATH = os.path.join(os.path.dirname(__file__), "functions/getBook.js") + LIST_BOOKS_PATH = os.path.join(os.path.dirname(__file__), "functions/listBooks.js") + + def __init__( + self, + stack: cdk.Stack, + id: str, + *, + search_key: str, + search_update_key: str, + ): + super().__init__(stack, id) + # opensearch + self.opensearch_domain = opensearch.Domain( + stack, + "Domain", + version=opensearch.EngineVersion.OPENSEARCH_2_5, + ebs=opensearch.EbsOptions(volume_size=10, volume_type=ec2.EbsDeviceVolumeType.GP2), + advanced_options={"rest.action.multi.allow_explicit_index": "false"}, + removal_policy=cdk.RemovalPolicy.DESTROY, + ) + + # dynamodb table to store book details + self.books_table = dynamodb.Table( + stack, + "BooksTable", + partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING), + removal_policy=cdk.RemovalPolicy.DESTROY, + billing_mode=dynamodb.BillingMode.PROVISIONED, + stream=dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + ) + self.books_table.add_global_secondary_index( + index_name="category-index", + partition_key=dynamodb.Attribute(name="category", type=dynamodb.AttributeType.STRING), + read_capacity=1, + write_capacity=1, + projection_type=dynamodb.ProjectionType.ALL, + ) + + self.lambda_role = iam.Role( + self, "LambdaRole", assumed_by=iam.ServicePrincipal("lambda.amazonaws.com") + ) + self.lambda_role.add_managed_policy( + iam.ManagedPolicy.from_aws_managed_policy_name("AmazonS3FullAccess") + ) + self.lambda_role.add_managed_policy( + iam.ManagedPolicy.from_aws_managed_policy_name("AmazonDynamoDBFullAccess") + ) + # TODO before updating to Node 20 we need to update function code + # since aws-sdk which comes with it is newer version than one bundled with Node 16 + # lambda for pre-filling the dynamodb + self.load_books_helper_fn = awslambda.Function( + stack, + "LoadBooksLambda", + handler="index.handler", + code=awslambda.InlineCode(code=load_file(self.LOAD_BOOKS_HELPER_PATH)), + runtime=awslambda.Runtime.NODEJS_16_X, + environment={ + "TABLE_NAME": self.books_table.table_name, + "S3_BUCKET": S3_BUCKET_BOOKS_INIT, + "FILE_NAME": S3_KEY_BOOKS_INIT, + }, + role=self.lambda_role, + ) + + # lambdas to get and list books + self.get_book_fn = awslambda.Function( + stack, + "GetBookLambda", + handler="index.handler", + code=awslambda.InlineCode(code=load_file(self.GET_BOOK_PATH)), + runtime=awslambda.Runtime.NODEJS_16_X, + environment={ + "TABLE_NAME": self.books_table.table_name, + }, + role=self.lambda_role, + ) + + self.list_books_fn = awslambda.Function( + stack, + "ListBooksLambda", + handler="index.handler", + code=awslambda.InlineCode(code=load_file(self.LIST_BOOKS_PATH)), + runtime=awslambda.Runtime.NODEJS_16_X, + environment={ + "TABLE_NAME": self.books_table.table_name, + }, + role=self.lambda_role, + ) + + # lambda to search for book + bucket = cdk.aws_s3.Bucket.from_bucket_name( + stack, + "bucket_name", + bucket_name=InfraProvisioner.get_asset_bucket_cdk(stack), + ) + self.search_book_fn = awslambda.Function( + stack, + "SearchBookLambda", + handler="index.handler", + code=awslambda.S3Code(bucket=bucket, key=search_key), + runtime=awslambda.Runtime.PYTHON_3_12, + environment={ + "ESENDPOINT": self.opensearch_domain.domain_endpoint, + }, + role=self.lambda_role, + ) + + # lambda to update search cluster + self.update_search_cluster_fn = awslambda.Function( + stack, + "UpdateSearchLambda", + handler="index.handler", + code=awslambda.S3Code(bucket=bucket, key=search_update_key), + runtime=awslambda.Runtime.PYTHON_3_12, + environment={ + "ESENDPOINT": self.opensearch_domain.domain_endpoint, + }, + role=self.lambda_role, + ) + + event_source = DynamoEventSource( + table=self.books_table, + starting_position=awslambda.StartingPosition.TRIM_HORIZON, + enabled=True, + batch_size=1, + retry_attempts=10, + ) + self.update_search_cluster_fn.add_event_source(event_source) + + self.books_table.grant_write_data(self.load_books_helper_fn) + self.books_table.grant_read_data(self.get_book_fn) + self.books_table.grant_read_data(self.list_books_fn) + + self.opensearch_domain.grant_read_write(self.search_book_fn) + self.opensearch_domain.grant_read_write(self.update_search_cluster_fn) diff --git a/tests/aws/scenario/bookstore/test_bookstore.snapshot.json b/tests/aws/scenario/bookstore/test_bookstore.snapshot.json new file mode 100644 index 0000000000000..4fdc081fcaf06 --- /dev/null +++ b/tests/aws/scenario/bookstore/test_bookstore.snapshot.json @@ -0,0 +1,979 @@ +{ + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_setup": { + "recorded-date": "15-07-2024, 12:54:12", + "recorded-content": { + "scan_count": { + "Count": 56, + "ScannedCount": 56, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-item": { + "Item": { + "author": { + "S": "Miles Way" + }, + "category": { + "S": "Cookbooks" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Chasing_umami.png" + }, + "id": { + "S": "nuklcm5b-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Chasing Umami" + }, + "price": { + "N": "15.98" + }, + "rating": { + "N": "3" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_lambda_dynamodb": { + "recorded-date": "15-07-2024, 12:54:17", + "recorded-content": { + "get_books_fn": { + "ExecutedVersion": "$LATEST", + "Payload": { + "statusCode": 200, + "headers": { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": true + }, + "body": { + "rating": 5, + "category": "Science Fiction", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/3140.png", + "price": 15.99, + "id": "0vld6p1u-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "3140", + "author": "Neal Wisozk" + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_books_cat_woodwork": { + "body": [ + { + "rating": 5, + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Drill.png", + "price": 15.98, + "id": "3k9zka2c-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Drill", + "author": "Neal Wisozk" + }, + { + "rating": 5, + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Floor.png", + "price": 21.98, + "id": "5oekg7gl-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Floor", + "author": "Neal Wisozk" + }, + { + "rating": 4, + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Chisel.png", + "price": 17.99, + "id": "q8qfaonc-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Chisel", + "author": "Neal Wisozk" + }, + { + "rating": 4, + "category": "Woodwork", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/saw.png", + "price": 17.99, + "id": "xfpwqd2u-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Saw", + "author": "Neal Wisozk" + } + ], + "headers": { + "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Origin": "*" + }, + "statusCode": 200 + }, + "list_books_cat_home": { + "body": [ + { + "rating": 5, + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Bathrooms.png", + "price": 21.98, + "id": "5ymiu6mo-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Bathrooms", + "author": "Deondre Toy" + }, + { + "rating": 3, + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Kitchens.png", + "price": 17.96, + "id": "lk38ejv5-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Kitchens", + "author": "Deondre Toy" + }, + { + "rating": 3, + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Smart+homes.png", + "price": 17.96, + "id": "r0vs97kp-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Smart Homes", + "author": "Deondre Toy" + }, + { + "rating": 4, + "category": "Home Improvement", + "cover": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Home+office.png", + "price": 19.99, + "id": "tyqnawj4-d93b-11e8-9f8b-f2801f1b9fd1", + "name": "Home Office", + "author": "Deondre Toy" + } + ], + "headers": { + "Access-Control-Allow-Credentials": true, + "Access-Control-Allow-Origin": "*" + }, + "statusCode": 200 + } + } + }, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_search_books": { + "recorded-date": "15-07-2024, 12:55:25", + "recorded-content": { + "search_name_spaghetti": { + "_shards": { + "failed": 0, + "skipped": 0, + "successful": 12, + "total": 12 + }, + "hits": { + "hits": [ + { + "_id": "084s9grl-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Richard Labadie" + }, + "category": { + "S": "Cookbooks" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Spaghetti.png" + }, + "id": { + "S": "084s9grl-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Spaghetti" + }, + "price": { + "N": "20.99" + }, + "rating": { + "N": "5" + } + } + } + ], + "max_score": "", + "total": { + "relation": "eq", + "value": 1 + } + }, + "timed_out": false, + "took": "" + }, + "search_author_aubree": { + "_shards": { + "failed": 0, + "skipped": 0, + "successful": 12, + "total": 12 + }, + "hits": { + "hits": [ + { + "_id": "8u6lpj3e-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Aubree Konopelski" + }, + "category": { + "S": "Fairy Tales" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Just+follow.png" + }, + "id": { + "S": "8u6lpj3e-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Just Follow" + }, + "price": { + "N": "17.99" + }, + "rating": { + "N": "5" + } + } + }, + { + "_id": "9vp96t5a-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Aubree Konopelski" + }, + "category": { + "S": "Fairy Tales" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Belle.png" + }, + "id": { + "S": "9vp96t5a-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Belle" + }, + "price": { + "N": "19.99" + }, + "rating": { + "N": "5" + } + } + }, + { + "_id": "bsx7u3xv-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Aubree Konopelski" + }, + "category": { + "S": "Fairy Tales" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Duckling.png" + }, + "id": { + "S": "bsx7u3xv-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Duckling" + }, + "price": { + "N": "18.99" + }, + "rating": { + "N": "4" + } + } + }, + { + "_id": "tj8mc0yd-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Aubree Konopelski" + }, + "category": { + "S": "Fairy Tales" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Little+red.png" + }, + "id": { + "S": "tj8mc0yd-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Little Red" + }, + "price": { + "N": "15.98" + }, + "rating": { + "N": "3" + } + } + }, + { + "_id": "wh9yiu5w-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Aubree Konopelski" + }, + "category": { + "S": "Fairy Tales" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/HG.png" + }, + "id": { + "S": "wh9yiu5w-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "HG" + }, + "price": { + "N": "22.96" + }, + "rating": { + "N": "4" + } + } + } + ], + "max_score": "", + "total": { + "relation": "eq", + "value": 5 + } + }, + "timed_out": false, + "took": "" + }, + "search_cat_home_impro": { + "_shards": { + "failed": 0, + "skipped": 0, + "successful": 12, + "total": 12 + }, + "hits": { + "hits": [ + { + "_id": "5ymiu6mo-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Deondre Toy" + }, + "category": { + "S": "Home Improvement" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Bathrooms.png" + }, + "id": { + "S": "5ymiu6mo-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Bathrooms" + }, + "price": { + "N": "21.98" + }, + "rating": { + "N": "5" + } + } + }, + { + "_id": "lk38ejv5-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Deondre Toy" + }, + "category": { + "S": "Home Improvement" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Kitchens.png" + }, + "id": { + "S": "lk38ejv5-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Kitchens" + }, + "price": { + "N": "17.96" + }, + "rating": { + "N": "3" + } + } + }, + { + "_id": "r0vs97kp-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Deondre Toy" + }, + "category": { + "S": "Home Improvement" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Smart+homes.png" + }, + "id": { + "S": "r0vs97kp-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Smart Homes" + }, + "price": { + "N": "17.96" + }, + "rating": { + "N": "3" + } + } + }, + { + "_id": "tyqnawj4-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Deondre Toy" + }, + "category": { + "S": "Home Improvement" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/Home+office.png" + }, + "id": { + "S": "tyqnawj4-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Home Office" + }, + "price": { + "N": "19.99" + }, + "rating": { + "N": "4" + } + } + }, + { + "_id": "yrqzhlal-d93b-11e8-9f8b-f2801f1b9fd1", + "_index": "lambda-index", + "_score": "", + "_source": { + "author": { + "S": "Jaylen Anderson" + }, + "category": { + "S": "Cookbooks" + }, + "cover": { + "S": "https://d2z6cj5wcte8g7.cloudfront.net/book-covers/home_brew_guide.png" + }, + "id": { + "S": "yrqzhlal-d93b-11e8-9f8b-f2801f1b9fd1" + }, + "name": { + "S": "Home Brew Guide" + }, + "price": { + "N": "15.99" + }, + "rating": { + "N": "5" + } + } + } + ], + "max_score": "", + "total": { + "relation": "eq", + "value": 5 + } + }, + "timed_out": false, + "took": "" + }, + "search_no_result": { + "_shards": { + "failed": 0, + "skipped": 0, + "successful": 12, + "total": 12 + }, + "hits": { + "hits": [], + "max_score": null, + "total": { + "relation": "eq", + "value": 0 + } + }, + "timed_out": false, + "took": "" + } + } + }, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_opensearch_crud": { + "recorded-date": "15-07-2024, 12:55:29", + "recorded-content": { + "describe_domains": { + "DomainStatusList": [ + { + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": "", + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "false" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "ENABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "Completed", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "datetime", + "StartTime": "datetime" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "DomainId": "", + "DomainName": "", + "DomainProcessingStatus": "Active", + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "", + "EngineVersion": "OpenSearch_2.11", + "IPAddressType": "ipv4", + "ModifyingProperties": [], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "datetime", + "Cancellable": false, + "CurrentVersion": "OpenSearch_2_11_R20240502-P2", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_domain_names": { + "DomainNames": [ + { + "DomainName": "", + "EngineType": "OpenSearch" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_domain_config": { + "DomainConfig": { + "AccessPolicies": { + "Options": "", + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "AdvancedOptions": { + "Options": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "false" + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "AdvancedSecurityOptions": { + "Options": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "AutoTuneOptions": { + "Options": { + "DesiredState": "ENABLED", + "MaintenanceSchedules": [], + "RollbackOnDisable": "NO_ROLLBACK", + "UseOffPeakWindow": false + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "ENABLED", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "Completed", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "datetime", + "StartTime": "datetime" + }, + "ClusterConfig": { + "Options": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "CognitoOptions": { + "Options": { + "Enabled": false + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "DomainEndpointOptions": { + "Options": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "EBSOptions": { + "Options": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "EncryptionAtRestOptions": { + "Options": { + "Enabled": false + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "EngineVersion": { + "Options": "OpenSearch_2.11", + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "IPAddressType": { + "Options": "ipv4", + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "LogPublishingOptions": { + "Options": {}, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "ModifyingProperties": [], + "NodeToNodeEncryptionOptions": { + "Options": { + "Enabled": false + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "OffPeakWindowOptions": { + "Options": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "SnapshotOptions": { + "Options": { + "AutomatedSnapshotStartHour": 0 + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "SoftwareUpdateOptions": { + "Options": { + "AutoSoftwareUpdateEnabled": false + }, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + }, + "VPCOptions": { + "Options": {}, + "Status": { + "CreationDate": "datetime", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "datetime", + "UpdateVersion": "update-version" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags": { + "TagList": [ + { + "Key": "bookstore", + "Value": "search" + }, + { + "Key": "scenario/test", + "Value": "bookstore" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_after_remove": { + "TagList": [ + { + "Key": "scenario/test", + "Value": "bookstore" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_compatible_versions": { + "CompatibleVersions": [ + { + "SourceVersion": "OpenSearch_2.11", + "TargetVersions": [ + "OpenSearch_2.13" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions": { + "Versions": [ + "OpenSearch_2.13", + "OpenSearch_2.11", + "OpenSearch_2.9", + "OpenSearch_2.7", + "OpenSearch_2.5", + "OpenSearch_2.3", + "OpenSearch_1.3", + "OpenSearch_1.2", + "OpenSearch_1.1", + "OpenSearch_1.0", + "Elasticsearch_7.10", + "Elasticsearch_7.9", + "Elasticsearch_7.8", + "Elasticsearch_7.7", + "Elasticsearch_7.4", + "Elasticsearch_7.1", + "Elasticsearch_6.8", + "Elasticsearch_6.7", + "Elasticsearch_6.5", + "Elasticsearch_6.4", + "Elasticsearch_6.3", + "Elasticsearch_6.2", + "Elasticsearch_6.0", + "Elasticsearch_5.6", + "Elasticsearch_5.5", + "Elasticsearch_5.3", + "Elasticsearch_5.1", + "Elasticsearch_2.3", + "Elasticsearch_1.5" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/scenario/bookstore/test_bookstore.validation.json b/tests/aws/scenario/bookstore/test_bookstore.validation.json new file mode 100644 index 0000000000000..3b64f0fc25b2f --- /dev/null +++ b/tests/aws/scenario/bookstore/test_bookstore.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_lambda_dynamodb": { + "last_validated_date": "2024-07-15T12:54:17+00:00" + }, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_opensearch_crud": { + "last_validated_date": "2024-07-15T12:55:29+00:00" + }, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_search_books": { + "last_validated_date": "2024-07-15T12:55:25+00:00" + }, + "tests/aws/scenario/bookstore/test_bookstore.py::TestBookstoreApplication::test_setup": { + "last_validated_date": "2024-07-15T12:54:12+00:00" + } +} diff --git a/tests/aws/scenario/kinesis_firehose/__init__.py b/tests/aws/scenario/kinesis_firehose/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/kinesis_firehose/conftest.py b/tests/aws/scenario/kinesis_firehose/conftest.py new file mode 100644 index 0000000000000..1facb91c31de9 --- /dev/null +++ b/tests/aws/scenario/kinesis_firehose/conftest.py @@ -0,0 +1,45 @@ +import json +import logging + +from localstack.utils.sync import retry + +LOG = logging.getLogger(__name__) + + +def read_s3_data(aws_client, bucket_name: str) -> dict[str, str]: + response = aws_client.s3.list_objects(Bucket=bucket_name) + if response.get("Contents") is None: + raise Exception("No data in bucket yet") + + keys = [obj.get("Key") for obj in response.get("Contents")] + + bucket_data = dict() + for key in keys: + response = aws_client.s3.get_object(Bucket=bucket_name, Key=key) + data = response["Body"].read().decode("utf-8") + bucket_data[key] = data + return bucket_data + + +def get_all_expected_messages_from_s3( + aws_client, + bucket_name: str, + sleep: int = 5, + retries: int = 3, + expected_message_count: int | None = None, +) -> list[str]: + def get_all_messages(): + bucket_data = read_s3_data(aws_client, bucket_name) + messages = [] + for input_string in bucket_data.values(): + json_array_string = "[" + input_string.replace("}{", "},{") + "]" + message = json.loads(json_array_string) + LOG.debug("Received messages: %s", message) + messages.extend(message) + if expected_message_count is not None and len(messages) != expected_message_count: + raise Exception(f"Failed to receive all sent messages: {messages}") + else: + return messages + + all_messages = retry(get_all_messages, sleep=sleep, retries=retries) + return all_messages diff --git a/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.py b/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.py new file mode 100644 index 0000000000000..dc02e3925ded3 --- /dev/null +++ b/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.py @@ -0,0 +1,195 @@ +import json + +import aws_cdk as cdk +import aws_cdk.aws_iam as iam +import aws_cdk.aws_kinesis as kinesis +import aws_cdk.aws_kinesisfirehose as firehose +import aws_cdk.aws_logs as logs +import aws_cdk.aws_s3 as s3 +import pytest + +from localstack.testing.pytest import markers +from tests.aws.scenario.kinesis_firehose.conftest import get_all_expected_messages_from_s3 + +STACK_NAME = "FirehoseStack" +TEST_MESSAGE = "Test-message-2948294kdlsie" + + +class TestKinesisFirehoseScenario: + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, infrastructure_setup): + infra = infrastructure_setup("FirehoseScenario") + stack = cdk.Stack(infra.cdk_app, STACK_NAME) + # create kinesis stream + kinesis_stream = kinesis.Stream( + stack, + "KinesisStream", + stream_name="kinesis-stream", + shard_count=1, + stream_mode=kinesis.StreamMode("PROVISIONED"), + ) + + # s3 bucket + bucket = s3.Bucket( + stack, + "S3Bucket", + bucket_name="firehose-raw-data", + removal_policy=cdk.RemovalPolicy.DESTROY, # required since default value is RETAIN + # auto_delete_objects=True, # required to delete the not empty bucket + # auto_delete requires lambda therefore not supported currently by LocalStack + ) + + # create firehose delivery stream + role_firehose_kinesis = iam.Role( + stack, + "FirehoseKinesisRole", + role_name="firehose-kinesis-role", + assumed_by=iam.ServicePrincipal("firehose.amazonaws.com"), + ) + policy_firehose_kinesis = iam.Policy( + stack, + "FirehoseKinesisPolicy", + policy_name="firehose-kinesis-policy", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "kinesis:DescribeStream", + "kinesis:GetShardIterator", + "kinesis:GetRecords", + "kinesis:ListShards", + ], + resources=[kinesis_stream.stream_arn], + ), + ], + ) + role_firehose_kinesis.attach_inline_policy(policy_firehose_kinesis) + + kinesis_stream_source_configuration = ( + firehose.CfnDeliveryStream.KinesisStreamSourceConfigurationProperty( + kinesis_stream_arn=kinesis_stream.stream_arn, + role_arn=role_firehose_kinesis.role_arn, + ) + ) + + # cloud watch logging group and stream for firehose s3 error logging + firehose_s3_log_group_name = "firehose-s3-log-group" + firehose_s3_log_stream_name = "firehose-s3-log-stream" + firehose_s3_log_group = logs.LogGroup( + stack, + "FirehoseLogGroup", + log_group_name=firehose_s3_log_group_name, + removal_policy=cdk.RemovalPolicy.DESTROY, # required since default value is RETAIN + ) + firehose_s3_log_group.add_stream( + "FirehoseLogStream", log_stream_name=firehose_s3_log_stream_name + ) + + # s3 access role for firehose + role_firehose_s3 = iam.Role( + stack, + "FirehoseS3Role", + role_name="firehose-s3-role", + assumed_by=iam.ServicePrincipal("firehose.amazonaws.com"), + ) + policy_firehose_s3 = iam.Policy( + stack, + "FirehoseS3Policy", + policy_name="firehose-s3-policy", + statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:PutObject", + ], + resources=[bucket.bucket_arn, f"{bucket.bucket_arn}/*"], + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["logs:PutLogEvents", "logs:CreateLogStream"], + resources=[firehose_s3_log_group.log_group_arn], + ), + ], + ) + role_firehose_s3.attach_inline_policy(policy_firehose_s3) + + extended_s3_destination_configuration = firehose.CfnDeliveryStream.ExtendedS3DestinationConfigurationProperty( + bucket_arn=bucket.bucket_arn, + role_arn=role_firehose_s3.role_arn, + prefix="firehose-raw-data/", + error_output_prefix="firehose-raw-data/errors/", + compression_format="UNCOMPRESSED", + s3_backup_mode="Disabled", + buffering_hints=firehose.CfnDeliveryStream.BufferingHintsProperty( + interval_in_seconds=1, size_in_m_bs=1 + ), + encryption_configuration=firehose.CfnDeliveryStream.EncryptionConfigurationProperty( + no_encryption_config="NoEncryption" + ), + cloud_watch_logging_options=firehose.CfnDeliveryStream.CloudWatchLoggingOptionsProperty( + enabled=True, + log_group_name=firehose_s3_log_group_name, + log_stream_name=firehose_s3_log_stream_name, + ), + ) + + firehose_stream = firehose.CfnDeliveryStream( + stack, + "FirehoseDeliveryStream", + delivery_stream_name="firehose-deliverystream", + delivery_stream_type="KinesisStreamAsSource", + kinesis_stream_source_configuration=kinesis_stream_source_configuration, + extended_s3_destination_configuration=extended_s3_destination_configuration, + ) + + # specify resource outputs + cdk.CfnOutput(stack, "KinesisStreamName", value=kinesis_stream.stream_name) + cdk.CfnOutput( + stack, "FirehoseDeliveryStreamName", value=firehose_stream.delivery_stream_name + ) + cdk.CfnOutput(stack, "BucketName", value=bucket.bucket_name) + + with infra.provisioner() as prov: + yield prov + + @markers.aws.validated + @pytest.mark.skip(reason="flaky") + def test_kinesis_firehose_s3( + self, + infrastructure, + cleanups, + s3_empty_bucket, + aws_client, + snapshot, + ): + outputs = infrastructure.get_stack_outputs(STACK_NAME) + kinesis_stream_name = outputs["KinesisStreamName"] + bucket_name = outputs["BucketName"] + + # put message to kinesis stream + message_count = 10 + for message_id in range(message_count): + aws_client.kinesis.put_record( + StreamName=kinesis_stream_name, + Data=json.dumps( + { + "Id": f"message_id_{message_id}", + "Data": TEST_MESSAGE, + } + ), + PartitionKey="1", + ) + # delete messages from bucket after read + cleanups.append(lambda: s3_empty_bucket(bucket_name)) + + bucket_data = get_all_expected_messages_from_s3( + aws_client, + bucket_name, + expected_message_count=message_count, + ) + snapshot.match("s3", bucket_data) diff --git a/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.snapshot.json b/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.snapshot.json new file mode 100644 index 0000000000000..ffd6700fb9c3f --- /dev/null +++ b/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.snapshot.json @@ -0,0 +1,49 @@ +{ + "tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.py::TestKinesisFirehoseScenario::test_kinesis_firehose_s3": { + "recorded-date": "15-02-2024, 19:31:48", + "recorded-content": { + "s3": [ + { + "Id": "message_id_0", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_1", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_2", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_3", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_4", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_5", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_6", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_7", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_8", + "Data": "Test-message-2948294kdlsie" + }, + { + "Id": "message_id_9", + "Data": "Test-message-2948294kdlsie" + } + ] + } + } +} diff --git a/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.validation.json b/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.validation.json new file mode 100644 index 0000000000000..09a685fc63772 --- /dev/null +++ b/tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/scenario/kinesis_firehose/test_kinesis_firehose.py::TestKinesisFirehoseScenario::test_kinesis_firehose_s3": { + "last_validated_date": "2024-02-14T15:02:06+00:00" + } +} diff --git a/tests/aws/scenario/lambda_destination/__init__.py b/tests/aws/scenario/lambda_destination/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py b/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py new file mode 100644 index 0000000000000..e1110928c39d2 --- /dev/null +++ b/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py @@ -0,0 +1,183 @@ +import json + +import aws_cdk as cdk +import aws_cdk.aws_lambda as awslambda +import aws_cdk.aws_sns as sns +import pytest + +from localstack.aws.api.lambda_ import InvocationType +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid, to_bytes +from localstack.utils.sync import wait_until + +MAIN_FN_CODE = """ +def handler(event, context): + should_fail = event.get("should_fail", "0") == "1" + message = event.get("message", "no message received") + + if should_fail: + raise Exception("Failing per design.") + + return {"lstest_message": message} +""" + +COLLECT_FN_CODE = """ +import json + +def handler(event, context): + print(json.dumps(event)) + return {"hello": "world"} # the return value here doesn't really matter +""" + + +class TestLambdaDestinationScenario: + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="LambdaDestinationScenario") + + stack = cdk.Stack(infra.cdk_app, "LambdaTestStack") + + collect_fn = awslambda.Function( + stack, + "CollectFn", + code=awslambda.InlineCode(COLLECT_FN_CODE), + handler="index.handler", + runtime=awslambda.Runtime.PYTHON_3_10, # noqa + ) + + # event_bus = events.EventBus(stack, "DestinationBus") + # queue = sqs.Queue(stack, "DestinationQueue") + topic = sns.Topic(stack, "DestinationTopic") + fn = awslambda.Function( + stack, + "DestinationFn", + code=awslambda.InlineCode(MAIN_FN_CODE), + handler="index.handler", + runtime=awslambda.Runtime.PYTHON_3_10, # noqa + ) + awslambda.EventInvokeConfig( + stack, + "TopicEic", + function=fn, + on_success=cdk.aws_lambda_destinations.SnsDestination(topic=topic), + on_failure=cdk.aws_lambda_destinations.SnsDestination(topic=topic), + retry_attempts=0, + max_event_age=cdk.Duration.minutes(1), + ) + topic.grant_publish(fn) + collect_fn.grant_invoke(cdk.aws_iam.ServicePrincipal("sns.amazonaws.com")) + collect_fn.add_event_source(cdk.aws_lambda_event_sources.SnsEventSource(topic)) + + cdk.CfnOutput(stack, "CollectFunctionName", value=collect_fn.function_name) + cdk.CfnOutput(stack, "DestinationTopicName", value=topic.topic_name) + cdk.CfnOutput(stack, "DestinationTopicArn", value=topic.topic_arn) + cdk.CfnOutput(stack, "DestinationFunctionName", value=fn.function_name) + + # provisioning + with infra.provisioner(skip_teardown=False) as prov: + yield prov + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Tags", + "$..Attributes.DeliveryPolicy", + "$..Attributes.EffectiveDeliveryPolicy.defaultHealthyRetryPolicy", + "$..Attributes.EffectiveDeliveryPolicy.guaranteed", + "$..Attributes.EffectiveDeliveryPolicy.http", + "$..Attributes.EffectiveDeliveryPolicy.sicklyRetryPolicy", + "$..Attributes.EffectiveDeliveryPolicy.throttlePolicy", + "$..Attributes.Policy.Statement..Action", + "$..Attributes.SubscriptionsConfirmed", + ] + ) + def test_infra(self, infrastructure, aws_client, snapshot): + outputs = infrastructure.get_stack_outputs("LambdaTestStack") + collect_fn_name = outputs["CollectFunctionName"] + main_fn_name = outputs["DestinationFunctionName"] + topic_arn = outputs["DestinationTopicArn"] + + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + snapshot.transform.key_value( + "CodeSha256", "", reference_replacement=False + ) + ) + snapshot.add_transformer( + snapshot.transform.key_value( + "aws:cloudformation:logical-id", "replaced-value", reference_replacement=False + ), + priority=-1, + ) + snapshot.add_transformer( + snapshot.transform.key_value( + "aws:cloudformation:stack-id", "replaced-value", reference_replacement=False + ), + priority=-1, + ) + + fn_1 = aws_client.lambda_.get_function(FunctionName=main_fn_name) + fn_2 = aws_client.lambda_.get_function(FunctionName=collect_fn_name) + + snapshot.match("get_fn_1", fn_1) + snapshot.match("get_fn_2", fn_2) + + eic = aws_client.lambda_.get_function_event_invoke_config(FunctionName=main_fn_name) + assert eic["MaximumRetryAttempts"] == 0 + assert eic["DestinationConfig"]["OnSuccess"]["Destination"] == topic_arn + assert eic["DestinationConfig"]["OnFailure"]["Destination"] == topic_arn + + snapshot.match("event_invoke_config", eic) + + topic_attr = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("topic_attributes", topic_attr) + + @markers.aws.validated + def test_destination_sns(self, infrastructure, aws_client, snapshot): + outputs = infrastructure.get_stack_outputs("LambdaTestStack") + invoke_fn_name = outputs["DestinationFunctionName"] + collect_fn_name = outputs["CollectFunctionName"] + topic_arn = outputs["DestinationTopicArn"] + + msg = f"message-{short_uid()}" + + if is_aws_cloud(): + # TODO: needs to be fixed in SNS provider. SubscriptionsConfirmed is currently always "0" + # wait until the subscription to SNS is actually active + def _wait_atts(): + return ( + aws_client.sns.get_topic_attributes(TopicArn=topic_arn)["Attributes"][ + "SubscriptionsConfirmed" + ] + == "1" + ) + + assert wait_until(_wait_atts, strategy="static", wait=5, max_retries=60) + + # Success case + response = aws_client.lambda_.invoke( + FunctionName=invoke_fn_name, + Payload=to_bytes(json.dumps({"message": msg, "should_fail": "0"})), + InvocationType=InvocationType.Event, + ) + snapshot.match("successful_invoke", response) + + # Failure case + response = aws_client.lambda_.invoke( + FunctionName=invoke_fn_name, + Payload=to_bytes(json.dumps({"message": msg, "should_fail": "1"})), + InvocationType=InvocationType.Event, + ) + snapshot.match("unsuccessful_invoke", response) + + def wait_for_logs(): + events = ( + aws_client.logs.get_paginator("filter_log_events") + .paginate(logGroupName=f"/aws/lambda/{collect_fn_name}") + .build_full_result()["events"] + ) + message_events = [e["message"] for e in events if msg in e["message"]] + return len(message_events) >= 2 + + assert wait_until(wait_for_logs, strategy="static", max_retries=10, wait=5) diff --git a/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.snapshot.json b/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.snapshot.json new file mode 100644 index 0000000000000..a0f7546339471 --- /dev/null +++ b/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.snapshot.json @@ -0,0 +1,212 @@ +{ + "tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py::TestLambdaDestinationScenario::test_infra": { + "recorded-date": "17-04-2024, 07:04:49", + "recorded-content": { + "get_fn_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "replaced-value", + "aws:cloudformation:stack-id": "replaced-value", + "aws:cloudformation:stack-name": "LambdaTestStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_2": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "replaced-value", + "aws:cloudformation:stack-id": "replaced-value", + "aws:cloudformation:stack-name": "LambdaTestStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "event_invoke_config": { + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sns::111111111111:" + }, + "OnSuccess": { + "Destination": "arn::sns::111111111111:" + } + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "topic_attributes": { + "Attributes": { + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py::TestLambdaDestinationScenario::test_destination_sns": { + "recorded-date": "04-10-2023, 16:05:34", + "recorded-content": { + "successful_invoke": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "unsuccessful_invoke": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + } +} diff --git a/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.validation.json b/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.validation.json new file mode 100644 index 0000000000000..319c8dc4b1d18 --- /dev/null +++ b/tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py::TestLambdaDestinationScenario::test_destination_sns": { + "last_validated_date": "2023-10-04T14:05:34+00:00" + }, + "tests/aws/scenario/lambda_destination/test_lambda_destination_scenario.py::TestLambdaDestinationScenario::test_infra": { + "last_validated_date": "2024-04-17T07:04:49+00:00" + } +} diff --git a/tests/aws/scenario/loan_broker/__init__.py b/tests/aws/scenario/loan_broker/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/loan_broker/functions/bank_app.js b/tests/aws/scenario/loan_broker/functions/bank_app.js new file mode 100644 index 0000000000000..11891436befeb --- /dev/null +++ b/tests/aws/scenario/loan_broker/functions/bank_app.js @@ -0,0 +1,37 @@ +/** + Each bank will vary its behavior by the following parameters: + + MIN_CREDIT_SCORE - the customer's minimum credit score required to receive a quote from this bank. + MAX_LOAN_AMOUNT - the maximum amount the bank is willing to lend to a customer. + BASE_RATE - the minimum rate the bank might give. The actual rate increases for a lower credit score and some randomness. + BANK_ID - as the loan broker processes multiple responses, knowing which bank supplied the quote will be handy. + */ + +function calcRate(amount, term, score, history) { + if (amount <= process.env.MAX_LOAN_AMOUNT && score >= process.env.MIN_CREDIT_SCORE) { + return parseFloat(process.env.BASE_RATE) + history * ((1000 - score) / 100.0); + } +} + +exports.handler = async (event) => { + console.log("Received request for %s", process.env.BANK_ID); + console.log("Received event:", JSON.stringify(event)); + + const amount = event.Amount; + const term = event.Term; + const score = event.Credit.Score; + const history = event.Credit.History; + + const bankId = process.env.BANK_ID; + + console.log("Loan Request over %d at credit score %d", amount, score); + console.log("Received term: %d, history: %d", term, history); + const rate = calcRate(amount, term, score, history); + if (rate) { + const response = { rate: rate, bankId: bankId }; + console.log(response); + return response; + } else { + console.log("Rejecting Loan"); + } +}; diff --git a/tests/aws/scenario/loan_broker/functions/bank_app_credit_bureau.js b/tests/aws/scenario/loan_broker/functions/bank_app_credit_bureau.js new file mode 100644 index 0000000000000..7d0f0d5c08fd0 --- /dev/null +++ b/tests/aws/scenario/loan_broker/functions/bank_app_credit_bureau.js @@ -0,0 +1,52 @@ +const getRandomInt = (min, max) => { + return min + Math.floor(Math.random() * (max - min)); +}; +const min_score = 300; +const max_score = 900; + +const getHistoryForSSN = (ssn) => { + // here should be the logic to retrieve the history of the customer + if (ssn.startsWith("123")) { + return 10; + } else { + return 13; + } +}; + +const getScoreForSSN = (ssn) => { + // here should be the logic to retrieve the score of the customer + if (ssn.startsWith("123")) { + return max_score; + } else { + return min_score; + } +}; + +exports.handler = async (event) => { + + var ssn_regex = new RegExp("^\\d{3}-\\d{2}-\\d{4}$"); + + + console.log("received event " + JSON.stringify(event)) + if (ssn_regex.test(event.SSN)) { + console.log("ssn matches pattern") + return { + statusCode: 200, + request_id: event.RequestId, + body: { + SSN: event.SSN, + score: getScoreForSSN(event.SSN), + history: getHistoryForSSN(event.SSN), + }, + }; + } else { + console.log("ssn not matching pattern") + return { + statusCode: 400, + request_id: event.RequestId, + body: { + SSN: event.SSN, + }, + }; + } +}; diff --git a/tests/aws/scenario/loan_broker/test_loan_broker.py b/tests/aws/scenario/loan_broker/test_loan_broker.py new file mode 100644 index 0000000000000..4eb105c440398 --- /dev/null +++ b/tests/aws/scenario/loan_broker/test_loan_broker.py @@ -0,0 +1,315 @@ +""" +This scenario test is taken from https://github.com/localstack-samples/sample-loan-broker-stepfunctions-lambda +which in turn is based on https://www.enterpriseintegrationpatterns.com/ramblings/loanbroker_stepfunctions.html +""" + +import json +import os +from dataclasses import dataclass + +import aws_cdk +import aws_cdk as cdk +import aws_cdk.aws_dynamodb as dynamodb +import aws_cdk.aws_lambda as awslambda +import aws_cdk.aws_logs as logs +import aws_cdk.aws_stepfunctions as sfn +import aws_cdk.aws_stepfunctions_tasks as tasks +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import await_execution_terminated +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +RECIPIENT_LIST_STACK_NAME = "LoanBroker-RecipientList" +PROJECT_NAME = "CDK Loan Broker" +OUTPUT_LOAN_BROKER_STATE_MACHINE_ARN = "LoanBrokerArn" +OUTPUT_LOAN_BROKER_LOG_GROUP_NAME = "LogGroupName" +OUTPUT_LOAN_BROKER_TABLE = "TableName" +LOAN_BROKER_TABLE = "LoanBrokerBanksTable" + +CREDIT_BUREAU_JS = "./functions/bank_app_credit_bureau.js" +BANK_APP_JS = "./functions/bank_app.js" + + +def _read_file_as_string(filename: str) -> str: + file_path = os.path.join(os.path.dirname(__file__), filename) + return load_file(file_path) + + +@dataclass +class Bank: + bank_id: str + base_rate: str + max_loan: str + min_credit_score: str + + def get_env(self) -> dict: + return { + "BANK_ID": self.bank_id, + "BASE_RATE": self.base_rate, + "MAX_LOAN_AMOUNT": self.max_loan, + "MIN_CREDIT_SCORE": self.min_credit_score, + } + + +class TestLoanBrokerScenario: + BANKS = { + "BankRecipientPawnShop": Bank( + bank_id="PawnShop", base_rate="5", max_loan="500000", min_credit_score="400" + ), + "BankRecipientUniversal": Bank( + bank_id="Universal", base_rate="4", max_loan="700000", min_credit_score="500" + ), + "BankRecipientPremium": Bank( + bank_id="Premium", base_rate="3", max_loan="900000", min_credit_score="600" + ), + } + + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="LoanBroaker") + recipient_stack = cdk.Stack(infra.cdk_app, RECIPIENT_LIST_STACK_NAME) + cdk.Tags.of(recipient_stack).add("Project", PROJECT_NAME) + cdk.Tags.of(recipient_stack).add("Stackname", RECIPIENT_LIST_STACK_NAME) + self.setup_recipient_list_stack(recipient_stack) + + # set skip_teardown=True to prevent the stack to be deleted + with infra.provisioner(skip_teardown=False) as prov: + yield prov + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.DeletionProtectionEnabled", + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + ] + ) + def test_prefill_dynamodb_table(self, aws_client, infrastructure, snapshot): + """setups the dynamodb for the following tests, + additionally tests some typical dynamodb APIs + """ + outputs = infrastructure.get_stack_outputs(RECIPIENT_LIST_STACK_NAME) + table_name = outputs.get(OUTPUT_LOAN_BROKER_TABLE) + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + describe_table = aws_client.dynamodb.describe_table(TableName=table_name) + snapshot.match("describe_table", describe_table) + + result = aws_client.dynamodb.put_item( + TableName=table_name, + Item={"Type": {"S": "Home"}, "BankAddress": {"L": [{"S": "will be replaced"}]}}, + ) + snapshot.match("put_item", result) + + result = aws_client.dynamodb.put_item( + TableName=table_name, + Item={"Type": {"S": "Test"}, "Hello": {"S": "something"}}, + ) + snapshot.match("put_item_2", result) + + scan_result = aws_client.dynamodb.scan(TableName=table_name) + + # the order for scan is not guarnateed, but we want to compare it + scan_result["Items"].sort(key=lambda x: x["Type"]["S"], reverse=True) + snapshot.match("scan", scan_result) + + item = aws_client.dynamodb.get_item(TableName=table_name, Key={"Type": {"S": "Home"}}) + snapshot.match("get_item", item) + + bank_addresses = [{"S": bank_name} for bank_name in self.BANKS.keys()] + + # this entry will be required for the upcoming tests + result = aws_client.dynamodb.update_item( + TableName=table_name, + Key={"Type": {"S": "Home"}}, + UpdateExpression="SET BankAddress=:v", + ExpressionAttributeValues={":v": {"L": bank_addresses}}, + ) + snapshot.match("update_item", result) + + item = aws_client.dynamodb.get_item(TableName=table_name, Key={"Type": {"S": "Home"}}) + snapshot.match("get_item2", item) + + # delete item + delete_item = aws_client.dynamodb.delete_item( + TableName=table_name, Key={"Type": {"S": "Test"}} + ) + snapshot.match("delete_item", delete_item) + + scan_result = aws_client.dynamodb.scan(TableName=table_name) + snapshot.match("scan_2", scan_result) + + # TODO could further test dynamodb if required + + @pytest.mark.parametrize( + "step_function_input,expected_result", + [ + # score linked to this SSN will receive quotes + ({"SSN": "123-45-6789", "Amount": 5000, "Term": 30}, "SUCCEEDED"), + # score linked to this SSN will not receive quotes, but step function call succeeds + ({"SSN": "458-45-6789", "Amount": 5000, "Term": 30}, "SUCCEEDED"), + ({"SSN": "inv-45-6789", "Amount": 5000, "Term": 30}, "FAILED"), + ({"unexpected": "234-45-6789"}, "FAILED"), + pytest.param( + {"SSN": "234-45-6789"}, + "FAILED", + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="stays in RUNNING on LS, but should be FAILED", + ), + ), # FIXME + ], + ) + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..traceHeader", "$..cause"] + ) # TODO add missing properties + def test_stepfunctions_input_recipient_list( + self, aws_client, infrastructure, step_function_input, expected_result, snapshot + ): + snapshot.add_transformer(snapshot.transform.stepfunctions_api()) + snapshot.add_transformer(snapshot.transform.key_value("executionArn")) + snapshot.add_transformer(snapshot.transform.key_value("stateMachineArn")) + snapshot.add_transformer(snapshot.transform.key_value("traceHeader")) + snapshot.add_transformer(snapshot.transform.key_value("name")) + + outputs = infrastructure.get_stack_outputs(RECIPIENT_LIST_STACK_NAME) + state_machine_arn = outputs.get(OUTPUT_LOAN_BROKER_STATE_MACHINE_ARN) + execution_name = f"my-test-{short_uid()}" + + result = aws_client.stepfunctions.start_execution( + name=execution_name, + stateMachineArn=state_machine_arn, + input=json.dumps(step_function_input), + ) + execution_arn = result["executionArn"] + + await_execution_terminated(aws_client.stepfunctions, execution_arn) + + result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + + snapshot.match("describe-execution-finished", result) + + # TODO verify logs in LogGroup -> not working yet for LS + + def setup_recipient_list_stack(self, stack: cdk.Stack): + # https://www.enterpriseintegrationpatterns.com/ramblings/loanbroker_stepfunctions_recipient_list.html + credit_bureau_lambda = awslambda.Function( + stack, + "CreditBureauLambda", + handler="index.handler", + code=awslambda.InlineCode(code=_read_file_as_string(CREDIT_BUREAU_JS)), + runtime=awslambda.Runtime.NODEJS_18_X, + ) + + get_credit_score_form_credit_bureau = tasks.LambdaInvoke( + stack, + "Get Credit Score from credit bureau", + lambda_function=credit_bureau_lambda, + payload=sfn.TaskInput.from_object({"SSN.$": "$.SSN", "RequestId.$": "$$.Execution.Id"}), + result_path="$.Credit", + result_selector={ + "Score.$": "$.Payload.body.score", + "History.$": "$.Payload.body.history", + }, + retry_on_service_exceptions=False, + ) + + bank_table = dynamodb.Table( + stack, + "LoanBrokerBanksTable", + partition_key={"name": "Type", "type": dynamodb.AttributeType.STRING}, + billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, + table_name=LOAN_BROKER_TABLE, + removal_policy=cdk.RemovalPolicy.DESTROY, + ) + + fetch_bank_address_from_database = tasks.DynamoGetItem( + stack, + "Fetch Bank Addresses from database", + table=bank_table, + key={"Type": tasks.DynamoAttributeValue.from_string("Home")}, + result_path="$.Banks", + result_selector={"BankAddress.$": "$.Item.BankAddress.L[*].S"}, + ) + + get_individual_bank_quotes = sfn.CustomState( + stack, + "Get individual bank quotes", + state_json={ + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.function", + "Payload": { + "SSN.$": "$.SSN", + "Amount.$": "$.Amount", + "Term.$": "$.Term", + "Credit.$": "$.Credit", + }, + }, + "ResultSelector": {"Quote.$": "$.Payload"}, + }, + ) + + get_all_bank_quotes = sfn.Map( + stack, + "Get all bank quotes", + items_path="$.Banks.BankAddress", + parameters={ + "function.$": "$$.Map.Item.Value", + "SSN.$": "$.SSN", + "Amount.$": "$.Amount", + "Term.$": "$.Term", + "Credit.$": "$.Credit", + }, + result_path="$.Quotes", + ) + + loan_broker_definition = get_credit_score_form_credit_bureau.next( + fetch_bank_address_from_database + ).next(get_all_bank_quotes.iterator(get_individual_bank_quotes)) + + loan_broker_log_group = logs.LogGroup( + stack, "LoanBrokerLogGroup", removal_policy=aws_cdk.RemovalPolicy.DESTROY + ) + loan_broker = sfn.StateMachine( + stack, + "LoanBroker", + definition=loan_broker_definition, + state_machine_type=sfn.StateMachineType.STANDARD, + timeout=cdk.Duration.minutes(5), + logs={ + "destination": loan_broker_log_group, + "level": sfn.LogLevel.ALL, + "include_execution_data": True, + }, + tracing_enabled=True, + ) + + for bank_name, bank_env in self.BANKS.items(): + bank_function = awslambda.Function( + stack, + bank_name, + runtime=awslambda.Runtime.NODEJS_18_X, + handler="index.handler", + code=awslambda.InlineCode(code=_read_file_as_string(BANK_APP_JS)), + function_name=bank_name, + environment=bank_env.get_env(), + ) + + bank_function.grant_invoke(loan_broker) + + cdk.CfnOutput( + stack, OUTPUT_LOAN_BROKER_STATE_MACHINE_ARN, value=loan_broker.state_machine_arn + ) + + cdk.CfnOutput( + stack, OUTPUT_LOAN_BROKER_LOG_GROUP_NAME, value=loan_broker_log_group.log_group_name + ) + + cdk.CfnOutput(stack, OUTPUT_LOAN_BROKER_TABLE, value=bank_table.table_name) diff --git a/tests/aws/scenario/loan_broker/test_loan_broker.snapshot.json b/tests/aws/scenario/loan_broker/test_loan_broker.snapshot.json new file mode 100644 index 0000000000000..ad5837f072b94 --- /dev/null +++ b/tests/aws/scenario/loan_broker/test_loan_broker.snapshot.json @@ -0,0 +1,369 @@ +{ + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input0-SUCCEEDED]": { + "recorded-date": "02-08-2023, 11:58:05", + "recorded-content": { + "describe-execution-finished": { + "executionArn": "", + "input": { + "SSN": "123-45-6789", + "Amount": 5000, + "Term": 30 + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "SSN": "123-45-6789", + "Amount": 5000, + "Term": 30, + "Credit": { + "Score": 900, + "History": 10 + }, + "Banks": { + "BankAddress": [ + "BankRecipientPawnShop", + "BankRecipientUniversal", + "BankRecipientPremium" + ] + }, + "Quotes": [ + { + "Quote": { + "rate": 15, + "bankId": "PawnShop" + } + }, + { + "Quote": { + "rate": 14, + "bankId": "Universal" + } + }, + { + "Quote": { + "rate": 13, + "bankId": "Premium" + } + } + ] + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "", + "status": "SUCCEEDED", + "stopDate": "datetime", + "traceHeader": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input1-SUCCEEDED]": { + "recorded-date": "02-08-2023, 11:58:07", + "recorded-content": { + "describe-execution-finished": { + "executionArn": "", + "input": { + "SSN": "458-45-6789", + "Amount": 5000, + "Term": 30 + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "SSN": "458-45-6789", + "Amount": 5000, + "Term": 30, + "Credit": { + "Score": 300, + "History": 13 + }, + "Banks": { + "BankAddress": [ + "BankRecipientPawnShop", + "BankRecipientUniversal", + "BankRecipientPremium" + ] + }, + "Quotes": [ + { + "Quote": null + }, + { + "Quote": null + }, + { + "Quote": null + } + ] + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "", + "status": "SUCCEEDED", + "stopDate": "datetime", + "traceHeader": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input2-FAILED]": { + "recorded-date": "02-08-2023, 11:58:10", + "recorded-content": { + "describe-execution-finished": { + "cause": "An error occurred while executing the state 'Get Credit Score from credit bureau' (entered at the event id #2). The JSONPath '$.Payload.body.score' specified for the field 'Score.$' could not be found in the input ''", + "error": "States.Runtime", + "executionArn": "", + "input": { + "SSN": "inv-45-6789", + "Amount": 5000, + "Term": 30 + }, + "inputDetails": { + "included": true + }, + "name": "", + "startDate": "datetime", + "stateMachineArn": "", + "status": "FAILED", + "stopDate": "datetime", + "traceHeader": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input3-FAILED]": { + "recorded-date": "02-08-2023, 11:58:10", + "recorded-content": { + "describe-execution-finished": { + "cause": "An error occurred while executing the state 'Get Credit Score from credit bureau' (entered at the event id #2). The JSONPath '$.SSN' specified for the field 'SSN.$' could not be found in the input ''", + "error": "States.Runtime", + "executionArn": "", + "input": { + "unexpected": "234-45-6789" + }, + "inputDetails": { + "included": true + }, + "name": "", + "startDate": "datetime", + "stateMachineArn": "", + "status": "FAILED", + "stopDate": "datetime", + "traceHeader": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input4-FAILED]": { + "recorded-date": "02-08-2023, 11:58:13", + "recorded-content": { + "describe-execution-finished": { + "cause": "An error occurred while executing the state 'Get all bank quotes' (entered at the event id #12). The JSONPath '$.Amount' specified for the field 'Amount.$' could not be found in the input ''", + "error": "States.Runtime", + "executionArn": "", + "input": { + "SSN": "234-45-6789" + }, + "inputDetails": { + "included": true + }, + "name": "", + "startDate": "datetime", + "stateMachineArn": "", + "status": "FAILED", + "stopDate": "datetime", + "traceHeader": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_prefill_dynamodb_table": { + "recorded-date": "24-08-2023, 16:33:21", + "recorded-content": { + "describe_table": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "Type", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "Type", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/LoanBrokerBanksTable", + "TableId": "", + "TableName": "LoanBrokerBanksTable", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_item": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_item_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "scan": { + "Count": 2, + "Items": [ + { + "Hello": { + "S": "something" + }, + "Type": { + "S": "Test" + } + }, + { + "BankAddress": { + "L": [ + { + "S": "will be replaced" + } + ] + }, + "Type": { + "S": "Home" + } + } + ], + "ScannedCount": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_item": { + "Item": { + "BankAddress": { + "L": [ + { + "S": "will be replaced" + } + ] + }, + "Type": { + "S": "Home" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_item": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_item2": { + "Item": { + "BankAddress": { + "L": [ + { + "S": "BankRecipientPawnShop" + }, + { + "S": "BankRecipientUniversal" + }, + { + "S": "BankRecipientPremium" + } + ] + }, + "Type": { + "S": "Home" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_item": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "scan_2": { + "Count": 1, + "Items": [ + { + "BankAddress": { + "L": [ + { + "S": "BankRecipientPawnShop" + }, + { + "S": "BankRecipientUniversal" + }, + { + "S": "BankRecipientPremium" + } + ] + }, + "Type": { + "S": "Home" + } + } + ], + "ScannedCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/scenario/loan_broker/test_loan_broker.validation.json b/tests/aws/scenario/loan_broker/test_loan_broker.validation.json new file mode 100644 index 0000000000000..21a07e5f891df --- /dev/null +++ b/tests/aws/scenario/loan_broker/test_loan_broker.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_prefill_dynamodb_table": { + "last_validated_date": "2023-08-24T14:33:21+00:00" + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input0-SUCCEEDED]": { + "last_validated_date": "2023-08-02T09:58:05+00:00" + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input1-SUCCEEDED]": { + "last_validated_date": "2023-08-02T09:58:07+00:00" + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input2-FAILED]": { + "last_validated_date": "2023-08-02T09:58:10+00:00" + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input3-FAILED]": { + "last_validated_date": "2023-08-02T09:58:10+00:00" + }, + "tests/aws/scenario/loan_broker/test_loan_broker.py::TestLoanBrokerScenario::test_stepfunctions_input_recipient_list[step_function_input4-FAILED]": { + "last_validated_date": "2023-08-02T09:58:13+00:00" + } +} diff --git a/tests/aws/scenario/mythical_mysfits/__init__.py b/tests/aws/scenario/mythical_mysfits/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/mythical_mysfits/artefacts/functions/populate_db.py b/tests/aws/scenario/mythical_mysfits/artefacts/functions/populate_db.py new file mode 100644 index 0000000000000..faebcdbaa8b5f --- /dev/null +++ b/tests/aws/scenario/mythical_mysfits/artefacts/functions/populate_db.py @@ -0,0 +1,260 @@ +# Source adapted from: https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk +from __future__ import print_function + +import os + +import boto3 + +client = boto3.client("dynamodb") + +init_data = [ + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "4e53920c-505a-4a90-a694-b9300791f0ae"}, + "Name": {"S": "Evangeline"}, + "Species": {"S": "Chimera"}, + "Description": { + "S": "Evangeline is the global sophisticate of the mythical world. You’d be hard pressed to find a more seductive, charming, and mysterious companion with a love for neoclassical architecture, and a degree in medieval studies. Don’t let her beauty and brains distract you. While her mane may always be perfectly coifed, her tail is ever-coiled and ready to strike. Careful not to let your guard down, or you may just find yourself spiraling into a dazzling downfall of dizzying dimensions." + }, + "Age": {"N": "43"}, + "GoodEvil": {"S": "Evil"}, + "LawChaos": {"S": "Lawful"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/chimera_thumb.png"}, + "ProfileImageUri": { + "S": "https://www.mythicalmysfits.com/images/chimera_hover.png" + }, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "2b473002-36f8-4b87-954e-9a377e0ccbec"}, + "Name": {"S": "Pauly"}, + "Species": {"S": "Cyclops"}, + "Description": { + "S": "Naturally needy and tyrannically temperamental, Pauly the infant cyclops is searching for a parental figure to call friend. Like raising any precocious tot, there may be occasional tantrums of thunder, lightning, and 100 decibel shrieking. Sooth him with some Mandrake root and you’ll soon wonder why people even bother having human children. Gaze into his precious eye and fall in love with this adorable tyke." + }, + "Age": {"N": "2"}, + "GoodEvil": {"S": "Neutral"}, + "LawChaos": {"S": "Lawful"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/cyclops_thumb.png"}, + "ProfileImageUri": { + "S": "https://www.mythicalmysfits.com/images/cyclops_hover.png" + }, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "0e37d916-f960-4772-a25a-01b762b5c1bd"}, + "Name": {"S": "CoCo"}, + "Species": {"S": "Dragon"}, + "Description": { + "S": "CoCo wears sunglasses at night. His hobbies include dressing up for casual nights out, accumulating debt, and taking his friends on his back for a terrifying ride through the mesosphere after a long night of revelry, where you pick up the bill, of course. For all his swagger, CoCo has a heart of gold. His loyalty knows no bounds, and once bonded, you’ve got a wingman (literally) for life." + }, + "Age": {"N": "501"}, + "GoodEvil": {"S": "Good"}, + "LawChaos": {"S": "Chaotic"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/dragon_thumb.png"}, + "ProfileImageUri": {"S": "https://www.mythicalmysfits.com/images/dragon_hover.png"}, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "da5303ae-5aba-495c-b5d6-eb5c4a66b941"}, + "Name": {"S": "Gretta"}, + "Species": {"S": "Gorgon"}, + "Description": { + "S": "Young, fun, and perfectly mischievous, Gorgon is mostly tail. She's currently growing her horns and hoping for wings like those of her high-flying counterparts. In the meantime, she dons an umbrella and waits for gusts of wind to transport her across space-time. She likes to tell jokes in fluent Parseltongue, read the evening news, and shoot fireworks across celestial lines. If you like high-risk, high-reward challenges, Gorgon will be the best pet you never knew you wanted." + }, + "Age": {"N": "31"}, + "GoodEvil": {"S": "Evil"}, + "LawChaos": {"S": "Neutral"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/gorgon_thumb.png"}, + "ProfileImageUri": {"S": "https://www.mythicalmysfits.com/images/gorgon_hover.png"}, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "a901bb08-1985-42f5-bb77-27439ac14300"}, + "Name": {"S": "Hasla"}, + "Species": {"S": "Haetae"}, + "Description": { + "S": "Hasla's presence warms every room. For the last 2 billion years, she's made visitors from far-away lands and the galaxy next door feel immediately at ease. Usually it's because of her big heart, but sometimes it's because of the fire she breathesβ€”especially after eating garlic and starlight. Hasla loves togetherness, board games, and asking philosophical questions that leave people pondering the meaning of life as they fall asleep at night." + }, + "Age": {"N": "2000000000"}, + "GoodEvil": {"S": "Good"}, + "LawChaos": {"S": "Neutral"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/haetae_thumb.png"}, + "ProfileImageUri": {"S": "https://www.mythicalmysfits.com/images/haetae_hover.png"}, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "b41ff031-141e-4a8d-bb56-158a22bea0b3"}, + "Name": {"S": "Snowflake"}, + "Species": {"S": "Yeti"}, + "Description": { + "S": "While Snowflake is a snowman, the only abomination is that he hasn’t been adopted yet. Snowflake is curious, playful, and loves to bound around in the snow. He likes winter hikes, hide and go seek, and all things Christmas. He can get a bit agitated when being scolded or having his picture taken and can occasionally cause devastating avalanches, so we don’t recommend him for beginning pet owners. However, with love, care, and a lot of ice, Snowflake will make a wonderful companion." + }, + "Age": {"N": "13"}, + "GoodEvil": {"S": "Evil"}, + "LawChaos": {"S": "Neutral"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/yeti_thumb.png"}, + "ProfileImageUri": {"S": "https://www.mythicalmysfits.com/images/yeti_hover.png"}, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "3f0f196c-4a7b-43af-9e29-6522a715342d"}, + "Name": {"S": "Gary"}, + "Species": {"S": "Kraken"}, + "Description": { + "S": "Gary loves to have a good time. His motto? β€œI just want to dance.” Give Gary a disco ball, a DJ, and a hat that slightly obscures the vision from his top eye, and Gary will dance the year away which, at his age, is like one night in humanoid time. If you're looking for a low-maintenance, high-energy creature companion that never sheds and always shreds, Gary is just the kraken for you." + }, + "Age": {"N": "2709"}, + "GoodEvil": {"S": "Neutral"}, + "LawChaos": {"S": "Chaotic"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/kraken_thumb.png"}, + "ProfileImageUri": {"S": "https://www.mythicalmysfits.com/images/kraken_hover.png"}, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "a68db521-c031-44c7-b5ef-bfa4c0850e2a"}, + "Name": {"S": "Nessi"}, + "Species": {"S": "Plesiosaurus"}, + "Description": { + "S": "Nessi is a fun-loving and playful girl who will quickly lock on to your love and nestle into your heart. While shy at first, Nessi is energetic and loves to play with toys such as fishing boats, large sharks, frisbees, errant swimmers, and wand toys. As an aquatic animal, Nessi will need deep water to swim in; at least 15 feet though she prefers 750. Nessi would be a wonderful companion for anyone seeking a loving, 1 ton ball of joy." + }, + "Age": {"N": "75000000"}, + "GoodEvil": {"S": "Neutral"}, + "LawChaos": {"S": "Neutral"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/nessie_thumb.png"}, + "ProfileImageUri": {"S": "https://www.mythicalmysfits.com/images/nessie_hover.png"}, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "c0684344-1eb7-40e7-b334-06d25ac9268c"}, + "Name": {"S": "Atlantis"}, + "Species": {"S": "Mandrake"}, + "Description": { + "S": "Do you like long naps in the dirt, vegetable-like appendages, mind-distorting screaming, and a unmatched humanoid-like root system? Look no further, Atlantis is the perfect companion to accompany you down the rabbit hole! Atlantis is rooted in habitual power napping and can unleash a terse warning when awakened. Like all of us, at the end of a long nap, all Atlantis needs is a soothing milk or blood bath to take the edge off. If you're looking to take a trip, this mandrake is your ideal travel companion." + }, + "Age": {"N": "100"}, + "GoodEvil": {"S": "Neutral"}, + "LawChaos": {"S": "Neutral"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/mandrake_thumb.png"}, + "ProfileImageUri": { + "S": "https://www.mythicalmysfits.com/images/mandrake_hover.png" + }, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "ac3e95f3-eb40-4e4e-a605-9fdd0224877c"}, + "Name": {"S": "Twilight Glitter"}, + "Species": {"S": "Pegasus"}, + "Description": { + "S": "Twilight’s personality sparkles like the night sky and is looking for a forever home with a Greek hero or God. While on the smaller side at 14 hands, he is quite adept at accepting riders and can fly to 15,000 feet. Twilight needs a large area to run around in and will need to be registered with the FAA if you plan to fly him above 500 feet. His favorite activities include playing with chimeras, going on epic adventures into battle, and playing with a large inflatable ball around the paddock. If you bring him home, he’ll quickly become your favorite little Pegasus." + }, + "Age": {"N": "6"}, + "GoodEvil": {"S": "Good"}, + "LawChaos": {"S": "Lawful"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/pegasus_thumb.png"}, + "ProfileImageUri": { + "S": "https://www.mythicalmysfits.com/images/pegasus_hover.png" + }, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "33e1fbd4-2fd8-45fb-a42f-f92551694506"}, + "Name": {"S": "Cole"}, + "Species": {"S": "Phoenix"}, + "Description": { + "S": "Cole is a loving companion and the perfect starter pet for kids. Cole’s tears can fix almost any boo-boo your children may receive (up to partial limb amputation). You never have to worry about your kids accidentally killing him as he’ll just be reborn in a fun burst of flame if they do. Even better, Cole has the uncanny ability to force all those around him to tell the truth, so say goodbye to fibs about not eating a cookie before dinner or where your teenager actually went that night. Adopt him today and find out how he will be the perfect family member for you, your children, their children, their children’s children, and so on." + }, + "Age": {"N": "1393"}, + "GoodEvil": {"S": "Good"}, + "LawChaos": {"S": "Chaotic"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/phoenix_thumb.png"}, + "ProfileImageUri": { + "S": "https://www.mythicalmysfits.com/images/phoenix_hover.png" + }, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, + { + "PutRequest": { + "Item": { + "MysfitId": {"S": "b6d16e02-6aeb-413c-b457-321151bb403d"}, + "Name": {"S": "Rujin"}, + "Species": {"S": "Troll"}, + "Description": { + "S": "Are you looking for a strong companion for raids, someone to throw lightning during a pillage, or just a cuddly buddy who can light a campfire from 200 meters? Look no further than Rujin, a troll mage just coming into his teenage years. Rujin is a loyal companion who loves adventure, camping, and long walks through burning villages. He is great with kids and makes a wonderful guard-troll, especially if you have a couple bridges on your property. Rujin has a bit of a soft spot for gold, so you’ll need to keep yours well hidden from him. Since he does keep a hoard on our property, we’re waiving the adoption fee!" + }, + "Age": {"N": "221"}, + "GoodEvil": {"S": "Evil"}, + "LawChaos": {"S": "Chaotic"}, + "ThumbImageUri": {"S": "https://www.mythicalmysfits.com/images/troll_thumb.png"}, + "ProfileImageUri": {"S": "https://www.mythicalmysfits.com/images/troll_hover.png"}, + "Likes": {"N": "0"}, + "Adopted": {"BOOL": False}, + } + } + }, +] +table_name = os.environ["mysfitsTable"] + + +# source adapted from https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk/module-3/data/populate-dynamodb.json +def insertMysfits(event, context): + response = client.batch_write_item( + RequestItems={ + table_name: init_data, + } + ) + print(response) diff --git a/tests/aws/scenario/mythical_mysfits/artefacts/functions/stream_processor.py b/tests/aws/scenario/mythical_mysfits/artefacts/functions/stream_processor.py new file mode 100644 index 0000000000000..2d58be787fc83 --- /dev/null +++ b/tests/aws/scenario/mythical_mysfits/artefacts/functions/stream_processor.py @@ -0,0 +1,94 @@ +# Source adapted from: https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk +# The code to be used as an AWS Lambda function for processing real-time +# user click records from Kinesis Firehose and adding additional attributes +# to them before they are stored in Amazon S3. +from __future__ import print_function + +import base64 +import json + +# TODO: when properly integrating Fargate into the sample, re-add this code fetching the data through the microservices +# beware, it will need packaging then because of `requests` +# import requests +import os + +import boto3 + +# Send a request to the Mysfits Service API that we have created in previous +# modules to retrieve all of the attributes for the included MysfitId. +# def retrieveMysfit(mysfitId): +# apiEndpoint = os.environ['MYSFITS_API_URL'] + '/mysfits/' + str(mysfitId) # eg: 'https://ljqomqjzbf.execute-api.us-east-1.amazonaws.com/prod/' +# mysfit = requests.get(apiEndpoint).json() +# return mysfit + + +client = boto3.client("dynamodb") + + +# Directly fetch the data from the DynamoDB table as we don't have the Mysfits microservice yet +# source adapted from https://github.com/aws-samples/aws-modern-application-workshop/blob/python-cdk/module-5/app/service/mysfitsTableClient.py +def retrieveMysfit(mysfitId): + # use the DynamoDB API GetItem, which gives you the ability to retrieve + # a single item from a DynamoDB table using its unique key with super low latency. + response = client.get_item( + TableName=os.environ["MYSFITS_TABLE_NAME"], Key={"MysfitId": {"S": mysfitId}} + ) + + item = response["Item"] + + mysfit = { + "mysfitId": item["MysfitId"]["S"], + "name": item["Name"]["S"], + "age": int(item["Age"]["N"]), + "goodevil": item["GoodEvil"]["S"], + "lawchaos": item["LawChaos"]["S"], + "species": item["Species"]["S"], + "thumbImageUri": item["ThumbImageUri"]["S"], + "profileImageUri": item["ProfileImageUri"]["S"], + "likes": item["Likes"]["N"], + "adopted": item["Adopted"]["BOOL"], + } + + return mysfit + + +# The below method will serve as the "handler" for the Lambda function. The +# handler is the method that AWS Lambda will invoke with events, which in this +# case will include records from the Kinesis Firehose Delivery Stream. +def processRecord(event, context): + output = [] + + # retrieve the list of records included with the event and loop through + # them to retrieve the full list of mysfit attributes and add the additional + # attributes that a hypothetical BI/Analyitcs team would like to analyze. + for record in event["records"]: + print("Processing record: " + record["recordId"]) + # kinesis firehose expects record payloads to be sent as encoded strings, + # so we must decode the data first to retrieve the click record. + click = json.loads(base64.b64decode(record["data"])) + + mysfitId = click["mysfitId"] + mysfit = retrieveMysfit(mysfitId) + + enrichedClick = { + "userId": click["userId"], + "mysfitId": mysfitId, + "goodevil": mysfit["goodevil"], + "lawchaos": mysfit["lawchaos"], + "species": mysfit["species"], + } + + # create the output record that Kinesis Firehose will store in S3. + output_record = { + "recordId": record["recordId"], + "result": "Ok", + "data": base64.b64encode(json.dumps(enrichedClick).encode("utf-8") + b"\n").decode( + "utf-8" + ), + } + output.append(output_record) + + print("Successfully processed {} records.".format(len(event["records"]))) + + # return the enriched records to Kiesis Firehose. + return {"records": output} diff --git a/tests/aws/scenario/mythical_mysfits/constructs/__init__.py b/tests/aws/scenario/mythical_mysfits/constructs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/mythical_mysfits/constructs/user_clicks_service.py b/tests/aws/scenario/mythical_mysfits/constructs/user_clicks_service.py new file mode 100644 index 0000000000000..2567e0b388bf2 --- /dev/null +++ b/tests/aws/scenario/mythical_mysfits/constructs/user_clicks_service.py @@ -0,0 +1,223 @@ +import json +import os + +import aws_cdk as cdk +import aws_cdk.aws_apigateway as apigw +import aws_cdk.aws_dynamodb as dynamodb +import aws_cdk.aws_iam as iam +import aws_cdk.aws_lambda as lambda_ +import aws_cdk.aws_s3 as s3 +import constructs + +from localstack.utils.files import load_file + + +class UserClicksService(constructs.Construct): + def __init__( + self, + scope: constructs.Construct, + id: str, + *, + account_id: str, + mysfits_table: dynamodb.Table, + ): + super().__init__(scope, id) + # TODO: the bucket is versioned in the sample, but not sure why? + self.clicks_destination_bucket = s3.Bucket( + self, + "ClicksBucketDestination", + # versioned=True, # in the sample the bucket is versioned but it seems just trickier to clean up for no real gain? + removal_policy=cdk.RemovalPolicy.DESTROY, + ) + + # We could use the table to .grant_read_data instead but that's what the sample does + lambda_function_policy = iam.PolicyStatement() + lambda_function_policy.add_actions("dynamodb:GetItem") + lambda_function_policy.add_resources(mysfits_table.table_arn) + + mysfits_clicks_processor_fn_handler = load_file( + os.path.join(os.path.dirname(__file__), "../artefacts/functions/stream_processor.py") + ) + self.mysfits_clicks_processor_fn = lambda_.Function( + self, + "StreamProcessorFunction", + handler="index.processRecord", + runtime=lambda_.Runtime.PYTHON_3_10, + description="An Amazon Kinesis Firehose stream processor that enriches click records to not just include a mysfitId, but also other attributes that can be analyzed later.", + memory_size=128, + code=lambda_.Code.from_inline(code=mysfits_clicks_processor_fn_handler), + initial_policy=[lambda_function_policy], + environment={ + "MYSFITS_TABLE_NAME": mysfits_table.table_name, + }, + timeout=cdk.Duration.seconds(30), + ) + + firehose_delivery_role = iam.Role( + self, + "FirehoseDeliveryRole", + role_name="FirehoseDeliveryRole", + assumed_by=iam.ServicePrincipal("firehose.amazonaws.com"), + external_ids=[account_id], + ) + + firehose_delivery_policy_s3_stmt = iam.PolicyStatement() + firehose_delivery_policy_s3_stmt.add_actions( + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:PutObject", + ) + firehose_delivery_policy_s3_stmt.add_resources(self.clicks_destination_bucket.bucket_arn) + firehose_delivery_policy_s3_stmt.add_resources( + self.clicks_destination_bucket.arn_for_objects("*") + ) + + firehose_delivery_policy_lambda_stmt = iam.PolicyStatement() + firehose_delivery_policy_lambda_stmt.add_actions("lambda:InvokeFunction") + firehose_delivery_policy_lambda_stmt.add_resources( + self.mysfits_clicks_processor_fn.function_arn + ) + + firehose_delivery_role.add_to_policy(firehose_delivery_policy_s3_stmt) + firehose_delivery_role.add_to_policy(firehose_delivery_policy_lambda_stmt) + + # TODO: check the alpha library for KinesisFirehose DeliveryStream + self.mysfits_firehose_to_s3 = cdk.aws_kinesisfirehose.CfnDeliveryStream( + self, + "DeliveryStream", + extended_s3_destination_configuration={ + "bucketArn": self.clicks_destination_bucket.bucket_arn, + "bufferingHints": { + "intervalInSeconds": 60, + "sizeInMBs": 50, + }, + "compressionFormat": "UNCOMPRESSED", + "prefix": "firehose/", + "roleArn": firehose_delivery_role.role_arn, + "processingConfiguration": { + "enabled": True, + "processors": [ + { + "parameters": [ + { + "parameterName": "LambdaArn", + "parameterValue": self.mysfits_clicks_processor_fn.function_arn, + } + ], + "type": "Lambda", + } + ], + }, + }, + ) + self.mysfits_clicks_processor_fn.add_permission( + "LambdaPermission", + action="lambda:InvokeFunction", + principal=iam.ServicePrincipal("firehose.amazonaws.com"), + source_account=account_id, + source_arn=self.mysfits_firehose_to_s3.attr_arn, + ) + + click_processing_api_role = iam.Role( + self, + "ClickProcessingApiRole", + assumed_by=iam.ServicePrincipal("apigateway.amazonaws.com"), + ) + api_policy = iam.PolicyStatement() + api_policy.add_actions("firehose:PutRecord") + api_policy.add_resources(self.mysfits_firehose_to_s3.attr_arn) + iam.Policy( + self, + "ClickProcessingApiPolicy", + policy_name="api_gateway_firehose_proxy_role", + statements=[api_policy], + roles=[click_processing_api_role], + ) + + self.api = apigw.RestApi( + self, + "APIEndpoint", + rest_api_name="ClickProcessing API Service", + endpoint_types=[apigw.EndpointType.REGIONAL], + cloud_watch_role_removal_policy=cdk.RemovalPolicy.DESTROY, + ) + + clicks = self.api.root.add_resource("clicks") + + clicks.add_method( + "PUT", + integration=apigw.AwsIntegration( + service="firehose", + integration_http_method="POST", + action="PutRecord", + options=apigw.IntegrationOptions( + connection_type=apigw.ConnectionType.INTERNET, + credentials_role=click_processing_api_role, + integration_responses=[ + apigw.IntegrationResponse( + status_code="200", + response_templates={"application/json": '{"status":"OK"}'}, + response_parameters={ + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,PUT'", + "method.response.header.Access-Control-Allow-Origin": "'*'", + }, + ) + ], + request_parameters={ + "integration.request.header.Content-Type": "'application/x-amz-json-1.1'" + }, + request_templates={ + "application/json": json.dumps( + { + "DeliveryStreamName": self.mysfits_firehose_to_s3.ref, + "Record": {"Data": "$util.base64Encode($input.json('$'))"}, + } + ) + }, + ), + ), + method_responses=[ + apigw.MethodResponse( + status_code="200", + response_parameters={ + "method.response.header.Access-Control-Allow-Headers": True, + "method.response.header.Access-Control-Allow-Methods": True, + "method.response.header.Access-Control-Allow-Origin": True, + }, + ) + ], + ) + + clicks.add_method( + "OPTIONS", + integration=apigw.MockIntegration( + integration_responses=[ + apigw.IntegrationResponse( + status_code="200", + response_parameters={ + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'", + "method.response.header.Access-Control-Allow-Origin": "'*'", + "method.response.header.Access-Control-Allow-Credentials": "'false'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,GET,PUT,POST,DELETE'", + }, + ) + ], + passthrough_behavior=apigw.PassthroughBehavior.NEVER, + request_templates={"application/json": '{"statusCode": 200}'}, + ), + method_responses=[ + apigw.MethodResponse( + status_code="200", + response_parameters={ + "method.response.header.Access-Control-Allow-Headers": True, + "method.response.header.Access-Control-Allow-Methods": True, + "method.response.header.Access-Control-Allow-Credentials": True, + "method.response.header.Access-Control-Allow-Origin": True, + }, + ) + ], + ) diff --git a/tests/aws/scenario/mythical_mysfits/stacks/__init__.py b/tests/aws/scenario/mythical_mysfits/stacks/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/mythical_mysfits/stacks/mysfits_core_stack.py b/tests/aws/scenario/mythical_mysfits/stacks/mysfits_core_stack.py new file mode 100644 index 0000000000000..9cb239b2ae8fc --- /dev/null +++ b/tests/aws/scenario/mythical_mysfits/stacks/mysfits_core_stack.py @@ -0,0 +1,102 @@ +import os + +import aws_cdk as cdk +import constructs + +from localstack.utils.files import load_file +from tests.aws.scenario.mythical_mysfits.constructs.user_clicks_service import UserClicksService + + +class MythicalMysfitsCoreStack(cdk.Stack): + def __init__(self, scope: constructs.Construct, id: str, **kwargs): + super().__init__(scope, id, **kwargs) + + # TODO: full Mysfits microservice with Fargate + NLB + mysfits_table = cdk.aws_dynamodb.Table( + self, + "MysfitsTable", + table_name="MysfitsTable", + partition_key=cdk.aws_dynamodb.Attribute( + name="MysfitId", type=cdk.aws_dynamodb.AttributeType.STRING + ), + removal_policy=cdk.RemovalPolicy.DESTROY, + ) + mysfits_table.add_global_secondary_index( + index_name="LawChaosIndex", + partition_key=cdk.aws_dynamodb.Attribute( + name="LawChaos", type=cdk.aws_dynamodb.AttributeType.STRING + ), + sort_key=cdk.aws_dynamodb.Attribute( + name="MysfitId", type=cdk.aws_dynamodb.AttributeType.STRING + ), + read_capacity=5, + write_capacity=5, + projection_type=cdk.aws_dynamodb.ProjectionType.ALL, + ) + mysfits_table.add_global_secondary_index( + index_name="GoodEvilIndex", + partition_key=cdk.aws_dynamodb.Attribute( + name="GoodEvil", type=cdk.aws_dynamodb.AttributeType.STRING + ), + sort_key=cdk.aws_dynamodb.Attribute( + name="MysfitId", type=cdk.aws_dynamodb.AttributeType.STRING + ), + read_capacity=5, + write_capacity=5, + projection_type=cdk.aws_dynamodb.ProjectionType.ALL, + ) + + user_clicks_service = UserClicksService( + self, + "UserClicksService", + account_id=self.account, + mysfits_table=mysfits_table, + ) + + # ================================================================================================ + # initial seed data + # ================================================================================================ + # TODO: put the data inside an S3 bucket instead of a JSON string inside the lambda code + populate_db_fn_handler = load_file( + os.path.join(os.path.dirname(__file__), "../artefacts/functions/populate_db.py") + ) + populate_db_fn = cdk.aws_lambda.Function( + self, + "PopulateDbFn", + runtime=cdk.aws_lambda.Runtime.PYTHON_3_10, + handler="index.insertMysfits", + code=cdk.aws_lambda.Code.from_inline(code=populate_db_fn_handler), + environment={ + "mysfitsTable": mysfits_table.table_name, + }, + ) + mysfits_table.grant_read_write_data(populate_db_fn) + + # ================================================================================================ + # OUTPUTS + # ================================================================================================ + + cdk.CfnOutput( + self, + "ClicksBucketDestinationName", + value=user_clicks_service.clicks_destination_bucket.bucket_name, + ) + cdk.CfnOutput( + self, + "DeliveryStreamArn", + value=user_clicks_service.mysfits_firehose_to_s3.attr_arn, + ) + cdk.CfnOutput( + self, + "DeliveryStreamName", + value=user_clicks_service.mysfits_firehose_to_s3.ref, + ) + cdk.CfnOutput( + self, + "StreamProcessorFunctionName", + value=user_clicks_service.mysfits_clicks_processor_fn.function_name, + ) + cdk.CfnOutput(self, "PopulateDbFunctionName", value=populate_db_fn.function_name) + cdk.CfnOutput(self, "MysfitsTableName", value=mysfits_table.table_name) + cdk.CfnOutput(self, "UserClicksServiceAPIEndpoint", value=user_clicks_service.api.url) + cdk.CfnOutput(self, "UserClicksServiceAPIId", value=user_clicks_service.api.rest_api_id) diff --git a/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py b/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py new file mode 100644 index 0000000000000..4d343410f61c1 --- /dev/null +++ b/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py @@ -0,0 +1,183 @@ +""" +This scenario setup is based on the official AWS Modern Application Workshop sample available at +https://github.com/aws-samples/aws-modern-application-workshop/tree/python-cdk + +It's originally written via TypeScript CDK but has been adapted here into a Python-based CDK application. +""" + +import base64 +import json + +import pytest +import requests + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.scenario.provisioning import InfraProvisioner +from localstack.utils.strings import to_str +from localstack.utils.sync import retry +from tests.aws.scenario.mythical_mysfits.stacks.mysfits_core_stack import MythicalMysfitsCoreStack + +STACK_NAME = "MythicalMisfitsStack" + + +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="not working in too many places") +class TestMythicalMisfitsScenario: + """ + Components: + The Mysfits microservice - Uses an Amazon Fargate container behind an NLB storing data into a DynamoDB table. + The Mysfits API - Provides an API with APIGateway which exposes the Mysfits microservice, as well as the Comments microservice. Uses Cognito authorizer. + The Comments microservice - Provides an API to update/get comments with DynamoDB, Lambda and SNS, traced with X-Ray. + The Users Clicks API - Pushes Click events to Kinesis Data Firehose through API Gateway to a Lambda storing enriched events in an S3 bucket via Kinesis. + The Recommendation API - Provides an API to give recommendations from SageMaker. + """ + + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="MythicalMisfits") + MythicalMysfitsCoreStack(infra.cdk_app, STACK_NAME) + with infra.provisioner(skip_teardown=False) as prov: + yield prov + + def _clean_table(self, aws_client, table_name: str): + items = aws_client.dynamodb.scan(TableName=table_name, ConsistentRead=True)["Items"] + for item in items: + aws_client.dynamodb.delete_item( + TableName=table_name, Key={"MysfitId": {"S": item["MysfitId"]["S"]}} + ) + + @markers.aws.validated + def test_deployed_infra_state(self, aws_client, infrastructure, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("DeliveryStreamName"), + snapshot.transform.key_value("ClicksBucketDestinationName"), + snapshot.transform.key_value("PopulateDbFunctionName"), + snapshot.transform.key_value("StreamProcessorFunctionName"), + snapshot.transform.key_value("UserClicksServiceAPIId"), + snapshot.transform.key_value("StackId"), + snapshot.transform.key_value("LogicalResourceId"), + snapshot.transform.key_value("PhysicalResourceId"), + snapshot.transform.key_value("RuntimeVersionArn"), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value( + "CodeSize", value_replacement="", reference_replacement=False + ), + snapshot.transform.jsonpath( + jsonpath="$..Code.Location", + value_replacement="", + reference_replacement=False, + ), + ] + ) + outputs = infrastructure.get_stack_outputs(stack_name=STACK_NAME) + # TODO: UserClicksServiceAPIEndpoint from output will be different in AWS and LocalStack + snapshot.match("outputs", outputs) + describe_stack = aws_client.cloudformation.describe_stacks(StackName=STACK_NAME)["Stacks"][ + 0 + ] + snapshot.match("describe_stack", describe_stack) + describe_stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=STACK_NAME + ) + snapshot.match("describe_stack_resources", describe_stack_resources) + + # collect service level describe calls + service_resources = {} + resource_count = {} + for stack_resource in describe_stack_resources["StackResources"]: + resource_type = stack_resource["ResourceType"] + r_count = resource_count.setdefault(resource_type, 0) + 1 + resource_count[resource_type] = r_count + r_key = f"{resource_type}-{r_count}" + + match resource_type: + case "AWS::Lambda::Function": + service_resources[r_key] = aws_client.lambda_.get_function( + FunctionName=stack_resource["PhysicalResourceId"] + ) + case "AWS::KinesisFirehose::DeliveryStream": + service_resources[r_key] = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=stack_resource["PhysicalResourceId"] + ) + case "AWS::DynamoDB::Table": + service_resources[r_key] = aws_client.dynamodb.describe_table( + TableName=stack_resource["PhysicalResourceId"] + ) + # TODO: RestApi/Resource/Method-x2 + sub resources from the Method? + snapshot.match("resources", service_resources) + + @markers.aws.validated + def test_populate_data(self, aws_client, infrastructure: "InfraProvisioner"): + """populate dynamodb table with data""" + outputs = infrastructure.get_stack_outputs(stack_name=STACK_NAME) + mysfits_table_name = outputs["MysfitsTableName"] + populate_data_fn = outputs["PopulateDbFunctionName"] + + self._clean_table(aws_client, mysfits_table_name) + + objs = aws_client.dynamodb.scan(TableName=mysfits_table_name) + assert objs["Count"] == 0 + + # populate the data now (sync) + result = aws_client.lambda_.invoke( + FunctionName=populate_data_fn, InvocationType="RequestResponse", LogType="Tail" + ) + logs = to_str(base64.b64decode(result["LogResult"])) + assert "'UnprocessedItems': {}" in logs + + objs = aws_client.dynamodb.scan(TableName=mysfits_table_name) + assert objs["Count"] > 0 + + @markers.aws.validated + def test_user_clicks_are_stored(self, aws_client, infrastructure: "InfraProvisioner", snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("x-amz-apigw-id"), + snapshot.transform.key_value("x-amzn-RequestId"), + snapshot.transform.key_value("Date"), + snapshot.transform.key_value("Key"), + snapshot.transform.key_value("Name"), + ] + ) + outputs = infrastructure.get_stack_outputs(stack_name=STACK_NAME) + bucket_name = outputs["ClicksBucketDestinationName"] + # replace with UserClicksServiceAPIEndpoint + mysfits_api_base_url = outputs["UserClicksServiceAPIEndpoint1DA4E100"] + misfits_api_url = f"{mysfits_api_base_url}/clicks" + + # test the MOCK integration that returns CORS headers + cors_req = requests.options(misfits_api_url, headers={"Origin": "test.domain.com"}) + assert cors_req.ok + assert cors_req.content == b"" + snapshot.match("cors-req-headers", dict(cors_req.headers)) + + # test the AWS firehose integration, taken from the web app part + user_click = { + "userId": "randomuser", + "mysfitId": "b6d16e02-6aeb-413c-b457-321151bb403d", # need to use a proper mysfitId + } + + click_req = requests.put(misfits_api_url, json=user_click) + assert click_req.ok + assert click_req.content == b'{"status":"OK"}' + # TODO: snapshot headers? + + # TODO: instead of polling S3, maybe we could set up S3 notifications to SQS and poll a Queue? + def _poll_s3_for_firehose(expected_objects: int): + resp = aws_client.s3.list_objects_v2(Bucket=bucket_name, Prefix="firehose/") + assert resp["KeyCount"] == expected_objects + return resp + + response = retry(_poll_s3_for_firehose, retries=60, sleep=10, expected_objects=1) + snapshot.match("list-objects", response) + + s3_object_key = response["Contents"][0]["Key"] + get_obj = aws_client.s3.get_object(Bucket=bucket_name, Key=s3_object_key) + firehose_event = json.loads(get_obj["Body"].read()) + snapshot.match("get-first-click", firehose_event) + assert firehose_event["mysfitId"] == user_click["mysfitId"] + # assert that the event has been enriched by the Lambda + assert firehose_event["species"] == "Troll" + + aws_client.s3.delete_object(Bucket=bucket_name, Key=s3_object_key) diff --git a/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.snapshot.json b/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.snapshot.json new file mode 100644 index 0000000000000..c721e87f60864 --- /dev/null +++ b/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.snapshot.json @@ -0,0 +1,683 @@ +{ + "tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py::TestMythicalMisfitsScenario::test_deployed_infra_state": { + "recorded-date": "17-10-2023, 20:07:08", + "recorded-content": { + "outputs": { + "ClicksBucketDestinationName": "", + "DeliveryStreamArn": "arn::firehose::111111111111:deliverystream/", + "DeliveryStreamName": "", + "Name": "", + "PopulateDbFunctionName": "", + "StreamProcessorFunctionName": "", + "UserClicksServiceAPIEndpoint": "https://.execute-api..amazonaws.com//", + "UserClicksServiceAPIEndpoint1DA4E100": "https://.execute-api..amazonaws.com//", + "UserClicksServiceAPIId": "" + }, + "describe_stack": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/test-cs-ca7a7051/3d2a3934-f24e-4fd3-955f-9579c69b55ba", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "OutputKey": "StreamProcessorFunctionName", + "OutputValue": "" + }, + { + "OutputKey": "UserClicksServiceAPIEndpoint", + "OutputValue": "https://.execute-api..amazonaws.com//" + }, + { + "OutputKey": "UserClicksServiceAPIId", + "OutputValue": "" + }, + { + "OutputKey": "ClicksBucketDestinationName", + "OutputValue": "" + }, + { + "OutputKey": "PopulateDbFunctionName", + "OutputValue": "" + }, + { + "OutputKey": "DeliveryStreamArn", + "OutputValue": "arn::firehose::111111111111:deliverystream/" + }, + { + "OutputKey": "DeliveryStreamName", + "OutputValue": "" + }, + { + "OutputKey": "UserClicksServiceAPIEndpoint1DA4E100", + "OutputValue": "https://.execute-api..amazonaws.com//" + }, + { + "OutputKey": "Name", + "OutputValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "", + "StackName": "MythicalMisfitsStack", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "describe_stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::DynamoDB::Table", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Account", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::RestApi", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Deployment", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Stage", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Resource", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::KinesisFirehose::DeliveryStream", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "MythicalMisfitsStack--z6K0tP6e6kbE", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "", + "StackName": "MythicalMisfitsStack", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "AWS::DynamoDB::Table-1": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "GoodEvil", + "AttributeType": "S" + }, + { + "AttributeName": "LawChaos", + "AttributeType": "S" + }, + { + "AttributeName": "MysfitId", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "GlobalSecondaryIndexes": [ + { + "IndexArn": "arn::dynamodb::111111111111:table//index/LawChaosIndex", + "IndexName": "LawChaosIndex", + "IndexSizeBytes": 0, + "IndexStatus": "ACTIVE", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "LawChaos", + "KeyType": "HASH" + }, + { + "AttributeName": "MysfitId", + "KeyType": "RANGE" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + }, + { + "IndexArn": "arn::dynamodb::111111111111:table//index/GoodEvilIndex", + "IndexName": "GoodEvilIndex", + "IndexSizeBytes": 0, + "IndexStatus": "ACTIVE", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "GoodEvil", + "KeyType": "HASH" + }, + { + "AttributeName": "MysfitId", + "KeyType": "RANGE" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + } + } + ], + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "MysfitId", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "AWS::KinesisFirehose::DeliveryStream-1": { + "DeliveryStreamDescription": { + "CreateTimestamp": "timestamp", + "DeliveryStreamARN": "arn::firehose::111111111111:deliverystream/", + "DeliveryStreamEncryptionConfiguration": { + "Status": "DISABLED" + }, + "DeliveryStreamName": "", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamType": "DirectPut", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "ExtendedS3DestinationDescription": { + "BucketARN": "arn::s3:::", + "BufferingHints": { + "IntervalInSeconds": 60, + "SizeInMBs": 50 + }, + "CloudWatchLoggingOptions": { + "Enabled": false + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "Prefix": "firehose/", + "ProcessingConfiguration": { + "Enabled": true, + "Processors": [ + { + "Parameters": [ + { + "ParameterName": "LambdaArn", + "ParameterValue": "arn::lambda::111111111111:function:" + }, + { + "ParameterName": "NumberOfRetries", + "ParameterValue": "3" + }, + { + "ParameterName": "RoleArn", + "ParameterValue": "arn::iam::111111111111:role/" + }, + { + "ParameterName": "BufferSizeInMBs", + "ParameterValue": "1" + }, + { + "ParameterName": "BufferIntervalInSeconds", + "ParameterValue": "60" + } + ], + "Type": "Lambda" + } + ] + }, + "RoleARN": "arn::iam::111111111111:role/", + "S3BackupMode": "Disabled" + }, + "S3DestinationDescription": { + "BucketARN": "arn::s3:::", + "BufferingHints": { + "IntervalInSeconds": 60, + "SizeInMBs": 50 + }, + "CloudWatchLoggingOptions": { + "Enabled": false + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "Prefix": "firehose/", + "RoleARN": "arn::iam::111111111111:role/" + } + } + ], + "HasMoreDestinations": false, + "VersionId": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "AWS::Lambda::Function-1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "La3zLdqIsk/cKmJeuZNs23JiacBxEf3rYPe3mqPK3o0=", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "mysfitsTable": "" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.insertMysfits", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "", + "aws:cloudformation:stack-name": "MythicalMisfitsStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "AWS::Lambda::Function-2": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "CrubTvLPal4InFdsTyMWcbfFxsSdPgBKKkp7on4dVRQ=", + "CodeSize": "", + "Description": "An Amazon Kinesis Firehose stream processor that enriches click records to not just include a mysfitId, but also other attributes that can be analyzed later.", + "Environment": { + "Variables": { + "MYSFITS_TABLE_NAME": "" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.processRecord", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "", + "aws:cloudformation:stack-name": "MythicalMisfitsStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } + }, + "tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py::TestMythicalMisfitsScenario::test_user_clicks_are_stored": { + "recorded-date": "17-10-2023, 20:12:30", + "recorded-content": { + "cors-req-headers": { + "Access-Control-Allow-Credentials": "false", + "Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent", + "Access-Control-Allow-Methods": "OPTIONS,GET,PUT,POST,DELETE", + "Access-Control-Allow-Origin": "*", + "Connection": "keep-alive", + "Content-Length": "0", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + }, + "list-objects": { + "Contents": [ + { + "ETag": "\"aba09441934e92fa1d8e444d79def8fe\"", + "Key": "", + "LastModified": "datetime", + "Size": 140, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "firehose/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-first-click": { + "goodevil": "Evil", + "lawchaos": "Chaotic", + "mysfitId": "", + "species": "Troll", + "userId": "randomuser" + } + } + } +} diff --git a/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.validation.json b/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.validation.json new file mode 100644 index 0000000000000..1c638920498cb --- /dev/null +++ b/tests/aws/scenario/mythical_mysfits/test_mythical_misfits.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py::TestMythicalMisfitsScenario::test_deployed_infra_state": { + "last_validated_date": "2023-10-17T18:07:08+00:00" + }, + "tests/aws/scenario/mythical_mysfits/test_mythical_misfits.py::TestMythicalMisfitsScenario::test_user_clicks_are_stored": { + "last_validated_date": "2023-10-17T18:12:30+00:00" + } +} diff --git a/tests/aws/scenario/note_taking/__init__.py b/tests/aws/scenario/note_taking/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/scenario/note_taking/functions/createNote.js b/tests/aws/scenario/note_taking/functions/createNote.js new file mode 100644 index 0000000000000..0d9eaee22b94f --- /dev/null +++ b/tests/aws/scenario/note_taking/functions/createNote.js @@ -0,0 +1,40 @@ +const crypto = require("crypto"); +const { DynamoDBClient, PutItemCommand } = require("@aws-sdk/client-dynamodb"); +const { marshall, unmarshall } = require("@aws-sdk/util-dynamodb"); +const { success, failure } = require("./libs/response"); + +// eslint-disable-next-line no-unused-vars +// In Node.js, you don't need to import types like in TypeScript, so the import for APIGatewayEvent can be removed. + +exports.handler = async (event) => { + const data = JSON.parse(event.body || "{}"); + const params = { + TableName: process.env.NOTES_TABLE_NAME || "", + Item: marshall({ + noteId: crypto.randomBytes(20).toString("hex"), + content: data.content, + createdAt: Date.now().toString(), + ...(data.attachment && { attachment: data.attachment }), + }), + }; + + + try { + let client; + if (process.env.AWS_ENDPOINT_URL) { + const localStackConfig = { + endpoint: process.env.AWS_ENDPOINT_URL, + region: process.env.AWS_REGION || "us-east-1", + }; + client = new DynamoDBClient(localStackConfig); + } else { + // Use the default AWS configuration + client = new DynamoDBClient({}); + } + await client.send(new PutItemCommand(params)); + return success(unmarshall(params.Item)); + } catch (e) { + console.log(e); + return failure({ status: false }); + } +}; diff --git a/tests/aws/scenario/note_taking/functions/deleteNote.js b/tests/aws/scenario/note_taking/functions/deleteNote.js new file mode 100644 index 0000000000000..958d9ed4818e1 --- /dev/null +++ b/tests/aws/scenario/note_taking/functions/deleteNote.js @@ -0,0 +1,34 @@ +const { DynamoDBClient, DeleteItemCommand } = require("@aws-sdk/client-dynamodb"); +const { marshall } = require("@aws-sdk/util-dynamodb"); +const { success, failure } = require("./libs/response"); + +// eslint-disable-next-line no-unused-vars +// In Node.js, you don't need to import types like in TypeScript, so the import for APIGatewayEvent can be removed. + +exports.handler = async (event) => { + const params = { + TableName: process.env.NOTES_TABLE_NAME || "", + // 'Key' defines the partition key and sort key of the item to be removed + // - 'noteId': path parameter + Key: marshall({ noteId: event.pathParameters?.id }), + }; + + try { + let client; + if (process.env.AWS_ENDPOINT_URL) { + const localStackConfig = { + endpoint: process.env.AWS_ENDPOINT_URL, + region: process.env.AWS_REGION || "us-east-1", + }; + client = new DynamoDBClient(localStackConfig); + } else { + // Use the default AWS configuration + client = new DynamoDBClient({}); + } + await client.send(new DeleteItemCommand(params)); + return success({ status: true }); + } catch (e) { + console.log(e); + return failure({ status: false }); + } +}; diff --git a/tests/aws/scenario/note_taking/functions/getNote.js b/tests/aws/scenario/note_taking/functions/getNote.js new file mode 100644 index 0000000000000..dc6b6ec935a8d --- /dev/null +++ b/tests/aws/scenario/note_taking/functions/getNote.js @@ -0,0 +1,39 @@ +const { DynamoDBClient, GetItemCommand } = require("@aws-sdk/client-dynamodb"); +const { marshall, unmarshall } = require("@aws-sdk/util-dynamodb"); +const { success, failure, not_found } = require("./libs/response"); + +// eslint-disable-next-line no-unused-vars +// In Node.js, you don't need to import types like in TypeScript, so the import for APIGatewayEvent can be removed. + +exports.handler = async (event) => { + const params = { + TableName: process.env.NOTES_TABLE_NAME || "", + // 'Key' defines the partition key and sort key of the item to be retrieved + // - 'noteId': path parameter + Key: marshall({ noteId: event.pathParameters?.id }), + }; + + try { + let client; + if (process.env.AWS_ENDPOINT_URL) { + const localStackConfig = { + endpoint: process.env.AWS_ENDPOINT_URL, + region: process.env.AWS_REGION || "us-east-1", + }; + client = new DynamoDBClient(localStackConfig); + } else { + // Use the default AWS configuration + client = new DynamoDBClient({}); + } + const result = await client.send(new GetItemCommand(params)); + if (result.Item) { + // Return the retrieved item + return success(unmarshall(result.Item)); + } else { + return not_found({ status: false, error: "Item not found." }); + } + } catch (e) { + console.log(e); + return failure({ status: false }); + } +}; diff --git a/tests/aws/scenario/note_taking/functions/libs/response.js b/tests/aws/scenario/note_taking/functions/libs/response.js new file mode 100644 index 0000000000000..d2c1dd2aa4a69 --- /dev/null +++ b/tests/aws/scenario/note_taking/functions/libs/response.js @@ -0,0 +1,22 @@ +const success = (body) => { + return buildResponse(200, body); +}; + +const failure = (body) => { + return buildResponse(500, body); +}; + +const not_found = (body) => { + return buildResponse(404, body); +}; + +const buildResponse = (statusCode, body) => ({ + statusCode: statusCode, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": true, + }, + body: JSON.stringify(body), +}); + +module.exports = { success, failure, not_found }; diff --git a/tests/aws/scenario/note_taking/functions/listNotes.js b/tests/aws/scenario/note_taking/functions/listNotes.js new file mode 100644 index 0000000000000..7c3c7f4c940ba --- /dev/null +++ b/tests/aws/scenario/note_taking/functions/listNotes.js @@ -0,0 +1,30 @@ +const { DynamoDBClient, ScanCommand } = require("@aws-sdk/client-dynamodb"); +const { unmarshall } = require("@aws-sdk/util-dynamodb"); + +const { success, failure } = require("./libs/response"); + +exports.handler = async () => { + const params = { + TableName: process.env.NOTES_TABLE_NAME || "", + }; + + try { + let client; + if (process.env.AWS_ENDPOINT_URL) { + const localStackConfig = { + endpoint: process.env.AWS_ENDPOINT_URL, + region: process.env.AWS_REGION || "us-east-1", + }; + client = new DynamoDBClient(localStackConfig); + } else { + // Use the default AWS configuration + client = new DynamoDBClient({}); + } + const result = await client.send(new ScanCommand(params)); + // Return the matching list of items in response body + return success(result.Items.map((Item) => unmarshall(Item))); + } catch (e) { + console.log(e); + return failure({ status: false }); + } +}; diff --git a/tests/aws/scenario/note_taking/functions/updateNote.js b/tests/aws/scenario/note_taking/functions/updateNote.js new file mode 100644 index 0000000000000..8f91d511d8027 --- /dev/null +++ b/tests/aws/scenario/note_taking/functions/updateNote.js @@ -0,0 +1,43 @@ +const { DynamoDBClient, UpdateItemCommand } = require("@aws-sdk/client-dynamodb"); +const { marshall } = require("@aws-sdk/util-dynamodb"); +const { success, failure } = require("./libs/response"); + +// eslint-disable-next-line no-unused-vars +// In Node.js, you don't need to import types like in TypeScript, so the import for APIGatewayEvent can be removed. + +exports.handler = async (event) => { + const data = JSON.parse(event.body || "{}"); + const params = { + TableName: process.env.NOTES_TABLE_NAME || "", + // 'Key' defines the partition key and sort key of the item to be updated + // - 'noteId': path parameter + Key: marshall({ noteId: event.pathParameters?.id }), + // 'UpdateExpression' defines the attributes to be updated + // 'ExpressionAttributeValues' defines the value in the update expression + UpdateExpression: "SET content = :content", + ExpressionAttributeValues: marshall({ ":content": data.content }), + // 'ReturnValues' specifies if and how to return the item's attributes, + // where ALL_NEW returns all attributes of the item after the update; you + // can inspect 'result' below to see how it works with different settings + ReturnValues: "ALL_NEW", + }; + + try { + let client; + if (process.env.AWS_ENDPOINT_URL) { + const localStackConfig = { + endpoint: process.env.AWS_ENDPOINT_URL, + region: process.env.AWS_REGION || "us-east-1", + }; + client = new DynamoDBClient(localStackConfig); + } else { + // Use the default AWS configuration + client = new DynamoDBClient({}); + } + await client.send(new UpdateItemCommand(params)); + return success({ status: true }); + } catch (e) { + console.log(e); + return failure({ status: false }); + } +}; diff --git a/tests/aws/scenario/note_taking/test_note_taking.py b/tests/aws/scenario/note_taking/test_note_taking.py new file mode 100644 index 0000000000000..492e3c1aa0946 --- /dev/null +++ b/tests/aws/scenario/note_taking/test_note_taking.py @@ -0,0 +1,299 @@ +""" +This scenario tests is based on the aws-sample aws-sdk-js-notes app (https://github.com/aws-samples/aws-sdk-js-notes-app), +which was adapted to work with LocalStack https://github.com/localstack-samples/sample-notes-app-dynamodb-lambda-apigateway. +""" + +import copy +import json +import logging +import os +from dataclasses import dataclass +from operator import itemgetter + +import aws_cdk as cdk +import aws_cdk.aws_apigateway as apigw +import aws_cdk.aws_dynamodb as dynamodb +import aws_cdk.aws_lambda as awslambda +import pytest +import requests +from constructs import Construct + +from localstack.testing.pytest import markers +from localstack.testing.scenario.cdk_lambda_helper import load_nodejs_lambda_to_s3 +from localstack.testing.scenario.provisioning import InfraProvisioner + +LOG = logging.getLogger(__name__) + + +class NotesApi(Construct): + handler: awslambda.Function + + def __init__( + self, + scope: Construct, + id: str, + *, + bucket_name: str, + table: dynamodb.Table, + grant_actions: list[str], + ): + super().__init__(scope, id) + bucket = cdk.aws_s3.Bucket.from_bucket_name(self, "notes", bucket_name=bucket_name) + self.handler = awslambda.Function( + self, + "handler", + code=awslambda.S3Code(bucket=bucket, key=f"{id}.zip"), + handler="index.handler", + runtime=awslambda.Runtime.NODEJS_18_X, # noqa + environment={"NOTES_TABLE_NAME": table.table_name}, + ) + table.grant(self.handler, *grant_actions) + + +@dataclass +class Endpoint: + http_method: str + endpoint_id: str + grant_actions: str + + +def _add_endpoints( + resource: apigw.Resource, + stack: cdk.Stack, + bucket_name: str, + table: dynamodb.Table, + endpoints: list[Endpoint], +): + for endpoint in endpoints: + resource.add_method( + http_method=endpoint.http_method, + integration=apigw.LambdaIntegration( + handler=NotesApi( + stack, + endpoint.endpoint_id, + bucket_name=bucket_name, + table=table, + grant_actions=[endpoint.grant_actions], + ).handler + ), + ) + + +class TestNoteTakingScenario: + STACK_NAME = "NoteTakingStack" + + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="NoteTaking") + stack = cdk.Stack(infra.cdk_app, self.STACK_NAME) + + # manually upload lambda to s3 + def _create_lambdas(): + lambda_notes = ["createNote", "deleteNote", "getNote", "listNotes", "updateNote"] + additional_resources = [os.path.join(os.path.dirname(__file__), "./functions/libs")] + for note in lambda_notes: + code_path = os.path.join(os.path.dirname(__file__), f"./functions/{note}.js") + load_nodejs_lambda_to_s3( + aws_client.s3, + infra.get_asset_bucket(), + key_name=f"{note}.zip", + code_path=code_path, + additional_resources=additional_resources, + ) + + infra.add_custom_setup_provisioning_step(_create_lambdas) + + table = dynamodb.Table( + stack, + "notes", + partition_key=dynamodb.Attribute(name="noteId", type=dynamodb.AttributeType.STRING), + removal_policy=cdk.RemovalPolicy.DESTROY, + ) + api = apigw.RestApi(stack, "endpoint") + notes_endpoint = api.root.add_resource("notes") + _add_endpoints( + resource=notes_endpoint, + stack=stack, + bucket_name=InfraProvisioner.get_asset_bucket_cdk(stack), + table=table, + endpoints=[ + Endpoint(http_method="GET", endpoint_id="listNotes", grant_actions="dynamodb:Scan"), + Endpoint( + http_method="POST", endpoint_id="createNote", grant_actions="dynamodb:PutItem" + ), + ], + ) + single_note_endpoint = notes_endpoint.add_resource( + path_part="{id}", + default_cors_preflight_options={ + "allow_origins": apigw.Cors.ALL_ORIGINS, + }, + ) + _add_endpoints( + resource=single_note_endpoint, + stack=stack, + bucket_name=InfraProvisioner.get_asset_bucket_cdk(stack), + table=table, + endpoints=[ + Endpoint( + http_method="GET", endpoint_id="getNote", grant_actions="dynamodb:GetItem" + ), + Endpoint( + http_method="PUT", endpoint_id="updateNote", grant_actions="dynamodb:UpdateItem" + ), + Endpoint( + http_method="DELETE", + endpoint_id="deleteNote", + grant_actions="dynamodb:DeleteItem", + ), + ], + ) + + # TODO could enhance app by using audio upload and transcribe feature, sign-up, etc + + cdk.CfnOutput(stack, "GatewayUrl", value=api.url) + cdk.CfnOutput(stack, "Region", value=stack.region) + + with infra.provisioner() as prov: + yield prov + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Tags", + "$..get_resources.items", # TODO apigateway.get-resources + "$..Table.DeletionProtectionEnabled", + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + "$..Table.WarmThroughput", + ] + ) + def test_validate_infra_setup(self, aws_client, infrastructure, snapshot): + describe_stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=self.STACK_NAME + ) + snapshot.add_transformer(snapshot.transform.cfn_stack_resource()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.key_value("TableName")) + snapshot.add_transformer( + snapshot.transform.key_value( + "CodeSha256", value_replacement="code-sha-256", reference_replacement=False + ) + ) + snapshot.add_transformer(snapshot.transform.key_value("FunctionName"), priority=-1) + snapshot.add_transformer( + snapshot.transform.key_value( + "Location", value_replacement="location", reference_replacement=False + ) + ) + snapshot.add_transformer( + snapshot.transform.key_value("parentId", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.key_value("id", reference_replacement=False)) + snapshot.add_transformer( + snapshot.transform.key_value("rootResourceId", reference_replacement=False) + ) + + describe_stack_resources["StackResources"].sort(key=itemgetter("ResourceType")) + snapshot.match("describe_stack_resources", describe_stack_resources) + + service_resources_fn = {} + rest_api_id = None + for stack_resource in describe_stack_resources["StackResources"]: + match stack_resource["ResourceType"]: + case "AWS::Lambda::Function": + service_resources_fn[stack_resource["LogicalResourceId"]] = ( + aws_client.lambda_.get_function( + FunctionName=stack_resource["PhysicalResourceId"] + ) + ) + case "AWS::DynamoDB::Table": + # we only have one table + snapshot.match( + "resource_table", + aws_client.dynamodb.describe_table( + TableName=stack_resource["PhysicalResourceId"] + ), + ) + + case "AWS::ApiGateway::RestApi": + rest_api_id = stack_resource["PhysicalResourceId"] + + ctn = 0 + for k in sorted(service_resources_fn.keys()): + v = service_resources_fn.get(k) + # introduce a new label, as the resource-id would be replaced as key-identifier, + # messing up the transformers + snapshot.match(f"fn_{ctn}", v) + ctn += 1 + + snapshot.match("get_rest_api", aws_client.apigateway.get_rest_api(restApiId=rest_api_id)) + resources = aws_client.apigateway.get_resources(restApiId=rest_api_id) + resources["items"].sort(key=itemgetter("path")) + snapshot.match("get_resources", resources) + + @markers.aws.validated + def test_notes_rest_api(self, infrastructure): + outputs = infrastructure.get_stack_outputs(self.STACK_NAME) + gateway_url = outputs["GatewayUrl"] + base_url = f"{gateway_url}notes" + + response = requests.get(base_url) + assert response.status_code == 200 + assert json.loads(response.text) == [] + + # add some notes + response = requests.post(base_url, json={"content": "hello world, this is my note"}) + assert response.status_code == 200 + note_1 = json.loads(response.text) + + response = requests.post(base_url, json={"content": "testing is fun :)"}) + assert response.status_code == 200 + note_2 = json.loads(response.text) + + response = requests.post( + base_url, json={"content": "we will modify and later on remove this note"} + ) + assert response.status_code == 200 + note_3 = json.loads(response.text) + + # check the notes are returned by the endpoint + expected = sorted([note_1, note_2, note_3], key=lambda e: e["createdAt"]) + + response = requests.get(base_url) + assert sorted(json.loads(response.text), key=lambda e: e["createdAt"]) == expected + + # retrieve a single note + response = requests.get(f"{base_url}/{note_1['noteId']}") + assert response.status_code == 200 + assert json.loads(response.text) == note_1 + + # modify a note + new_content = "this is now new and modified" + response = requests.put(f"{base_url}/{note_3['noteId']}", json={"content": new_content}) + assert response.status_code == 200 + + # retrieve notes + expected_note_3 = copy.deepcopy(note_3) + expected_note_3["content"] = new_content + + response = requests.get(base_url) + assert sorted(json.loads(response.text), key=lambda e: e["createdAt"]) == sorted( + [note_1, note_2, expected_note_3], key=lambda e: e["createdAt"] + ) + + # delete note + response = requests.delete(f"{base_url}/{note_2['noteId']}") + assert response.status_code == 200 + + # verify note was deleted + response = requests.get(base_url) + assert sorted(json.loads(response.text), key=lambda e: e["createdAt"]) == sorted( + [note_1, expected_note_3], key=lambda e: e["createdAt"] + ) + + # assert deleted note cannot be retrieved + response = requests.get(f"{base_url}/{note_2['noteId']}") + assert response.status_code == 404 + assert json.loads(response.text) == {"status": False, "error": "Item not found."} diff --git a/tests/aws/scenario/note_taking/test_note_taking.snapshot.json b/tests/aws/scenario/note_taking/test_note_taking.snapshot.json new file mode 100644 index 0000000000000..6c31b8de4d18a --- /dev/null +++ b/tests/aws/scenario/note_taking/test_note_taking.snapshot.json @@ -0,0 +1,868 @@ +{ + "tests/aws/scenario/note_taking/test_note_taking.py::TestNoteTakingScenario::test_validate_infra_setup": { + "recorded-date": "15-07-2025, 19:26:03", + "recorded-content": { + "describe_stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Account", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Deployment", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Method", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Resource", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Resource", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::RestApi", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::Stage", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::DynamoDB::Table", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "StackName": "NoteTakingStack", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resource_table": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "noteId", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "noteId", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE", + "WarmThroughput": { + "ReadUnitsPerSecond": 5, + "Status": "ACTIVE", + "WriteUnitsPerSecond": 5 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "fn_0": { + "Code": { + "Location": "location", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha-256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": "" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "aws:cloudformation:stack-name": "NoteTakingStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "fn_1": { + "Code": { + "Location": "location", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha-256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": "" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "aws:cloudformation:stack-name": "NoteTakingStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "fn_2": { + "Code": { + "Location": "location", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha-256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": "" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "aws:cloudformation:stack-name": "NoteTakingStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "fn_3": { + "Code": { + "Location": "location", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha-256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": "" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "aws:cloudformation:stack-name": "NoteTakingStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "fn_4": { + "Code": { + "Location": "location", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha-256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "NOTES_TABLE_NAME": "" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "aws:cloudformation:stack-name": "NoteTakingStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_rest_api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "id", + "name": "endpoint", + "rootResourceId": "root-resource-id", + "tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/NoteTakingStack/", + "aws:cloudformation:stack-name": "NoteTakingStack" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_resources": { + "items": [ + { + "id": "id", + "path": "/" + }, + { + "id": "id", + "parentId": "parent-id", + "path": "/notes", + "pathPart": "notes", + "resourceMethods": { + "GET": {}, + "POST": {} + } + }, + { + "id": "id", + "parentId": "parent-id", + "path": "/notes/{id}", + "pathPart": "{id}", + "resourceMethods": { + "DELETE": {}, + "GET": {}, + "OPTIONS": {}, + "PUT": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/scenario/note_taking/test_note_taking.validation.json b/tests/aws/scenario/note_taking/test_note_taking.validation.json new file mode 100644 index 0000000000000..87b41fd504e6e --- /dev/null +++ b/tests/aws/scenario/note_taking/test_note_taking.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/scenario/note_taking/test_note_taking.py::TestNoteTakingScenario::test_notes_rest_api": { + "last_validated_date": "2025-07-15T19:26:45+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 12.05, + "teardown": 30.29, + "total": 42.34 + } + }, + "tests/aws/scenario/note_taking/test_note_taking.py::TestNoteTakingScenario::test_validate_infra_setup": { + "last_validated_date": "2025-07-15T19:26:03+00:00", + "durations_in_seconds": { + "setup": 72.68, + "call": 2.59, + "teardown": 0.0, + "total": 75.27 + } + } +} diff --git a/tests/aws/serverless/handler.js b/tests/aws/serverless/handler.js new file mode 100644 index 0000000000000..1ee68a9a2993f --- /dev/null +++ b/tests/aws/serverless/handler.js @@ -0,0 +1,19 @@ +module.exports.test = function(event) { + console.log('!!!test', JSON.stringify(event)); +}; + +module.exports.tests = function(event) { + console.log('!!!tests', JSON.stringify(event)); +}; + +module.exports.processKinesis = function(event) { + console.log('!!!processKinesis', JSON.stringify(event)); +}; + +module.exports.createQueue = function(event) { + console.log('!!!createQueue', JSON.stringify(event)); +}; + +module.exports.createHttpRouter = function(event) { + console.log('!!!createHttpRouter', JSON.stringify(event)); +}; diff --git a/tests/aws/serverless/handler.py b/tests/aws/serverless/handler.py new file mode 100644 index 0000000000000..507f46d69bb1b --- /dev/null +++ b/tests/aws/serverless/handler.py @@ -0,0 +1,5 @@ +import json + + +def processKinesis(event, *args): + print("!processKinesis", json.dumps(event)) diff --git a/tests/aws/serverless/package.json b/tests/aws/serverless/package.json new file mode 100644 index 0000000000000..e1a26f9169b56 --- /dev/null +++ b/tests/aws/serverless/package.json @@ -0,0 +1,16 @@ +{ + "name": "sls-localstack-test", + "version": "0.0.1", + "description": "Simple Serverless tests for LocalStack", + "scripts": { + "deploy": "serverless deploy --stage local", + "undeploy": "serverless remove --stage local", + "deploy-aws": "serverless deploy --stage dev", + "undeploy-aws": "serverless remove --stage dev", + "version": "serverless --version" + }, + "devDependencies": { + "serverless": "2.48.0", + "serverless-localstack": "^0.4.30" + } +} diff --git a/tests/aws/serverless/serverless.yml b/tests/aws/serverless/serverless.yml new file mode 100644 index 0000000000000..c29a61b4effb8 --- /dev/null +++ b/tests/aws/serverless/serverless.yml @@ -0,0 +1,233 @@ +service: sls-test + +provider: + stage: "${opt:stage, self:provider.environment.stage}" + name: "aws" + memorySize: 384 + versionFunctions: false + timeout: 900 + runtime: "nodejs16.x" + apiGateway: + minimumCompressionSize: 1024 + shouldStartNameWithService: true + iam: + role: + statements: + - Effect: 'Allow' + Action: + - 'sqs:ReceiveMessage' + - 'sqs:DeleteMessage' + Resource: + - "arn:aws:sqs:::${self:service}-${opt:stage}-CreateQueue" + - Effect: 'Allow' + Action: + - 'sqs:SendMessage' + Resource: '*' + - Effect: 'Allow' + Action: + - 'dynamodb:DeleteItem' + - 'dynamodb:PutItem' + - 'dynamodb:Query' + - 'dynamodb:Scan' + Resource: + - "arn:aws:dynamodb:::jizo.${opt:stage}.loginsTable" + - "arn:aws:dynamodb:::jizo.${opt:stage}.authAuditTrailTable" + - Effect: 'Allow' + Action: + - 'lambda:InvokeFunction' + - 'lambda:InvokeAsync' + Resource: + - "arn:aws:lambda:::function:jizo-accts-prod-userGet:*" + - Effect: 'Allow' + Action: + - 'sns:Subscribe' + Resource: '*' + eventBridge: + useCloudFormation: true + +resources: + Resources: + TestTable: + Type: AWS::DynamoDB::Table + DeletionPolicy: Delete + Properties: + TableName: 'Test' + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + + KinesisStream: + Type: AWS::Kinesis::Stream + DeletionPolicy: Delete + Properties: + Name: KinesisTestStream + ShardCount: 1 + KinesisStreamConsumer: + Type: AWS::Kinesis::StreamConsumer + DeletionPolicy: Delete + Properties: + ConsumerName: stream-consumer1 + StreamARN: !GetAtt 'KinesisStream.Arn' + + # DynamoDB configuration + loginsTable: + Type: AWS::DynamoDB::Table + DeletionPolicy: Delete + Properties: + TableName: "jizo.${opt:stage}.loginsTable" + AttributeDefinitions: + - AttributeName: object_id + AttributeType: S + KeySchema: + - AttributeName: object_id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + authAuditTrailTable: + Type: AWS::DynamoDB::Table + DeletionPolicy: Delete + Properties: + TableName: "jizo.${opt:stage}.authAuditTrailTable" + AttributeDefinitions: + - AttributeName: object_id + AttributeType: S + KeySchema: + - AttributeName: object_id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + + # incoming SQS configuration + CreateQueue: + Type: AWS::SQS::Queue + DeletionPolicy: Delete + Properties: + QueueName: "${self:service}-${opt:stage}-CreateQueue" + VisibilityTimeout: 1080 + MessageRetentionPeriod: 2160 + RedrivePolicy: + deadLetterTargetArn: + Fn::GetAtt: + - CreateBackupQueue + - Arn + maxReceiveCount: 3 + CreateBackupQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: "${self:service}-${opt:stage}-CreateBackupQueue" + + TestBucket: + Type: AWS::S3::Bucket + DeletionPolicy: Delete + Properties: + BucketName: + !Sub "testing-bucket-${AWS::StackName}-${AWS::Region}" + +functions: + tests: + handler: "handler.tests" + maximumEventAge: 7200 + maximumRetryAttempts: 2 + test: + handler: "handler.test" + maximumEventAge: 7200 + maximumRetryAttempts: 2 + events: + - cloudwatchEvent: + name: sls-test-cf-event + event: + source: + - aws.cloudformation + detail-type: + - "AWS API Call from CloudFormation" + detail: + eventName: + - CreateStack + - UpdateStack + - http: + path: /test/v1 + method: get + integration: lambda-proxy + + - eventBridge: + eventBus: customBus + pattern: + source: + - "customSource" + + dynamodbStreamHandler: + handler: handler.processItem + events: + - stream: + type: dynamodb + arn: + Fn::GetAtt: + - TestTable + - StreamArn + batchSize: 10 + startingPosition: TRIM_HORIZON + kinesisStreamHandler: + handler: handler.processKinesis + events: + - stream: + type: kinesis + arn: !GetAtt 'KinesisStream.Arn' + batchSize: 10 + startingPosition: TRIM_HORIZON + kinesisConsumerHandler: + handler: handler.processKinesis + runtime: python3.9 + events: + - stream: + type: kinesis + arn: !GetAtt 'KinesisStream.Arn' + batchWindow: 10 + parallelizationFactor: 2 + maximumRetryAttempts: 2 + consumer: + Fn::GetAtt: + - KinesisStreamConsumer + - StreamARN + maximumRecordAgeInSeconds: 120 + startingPosition: TRIM_HORIZON + enabled: true + queueHandler: + handler: handler.createQueue + description: "To handle create new login" + events: + - sqs: + arn: + Fn::GetAtt: + - CreateQueue + - Arn + router: + handler: handler.createHttpRouter + description: 'primary REST related handlers for this service' + events: + - http: + path: foo/bar + method: post + - http: + path: foo/bar + method: put + - http: + path: foo/bar + method: delete + +plugins: + - serverless-localstack + +custom: + localstack: + host: http://localhost.localstack.cloud + stages: local diff --git a/tests/aws/services/__init__.py b/tests/aws/services/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/acm/__init__.py b/tests/aws/services/acm/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/acm/test_acm.py b/tests/aws/services/acm/test_acm.py new file mode 100644 index 0000000000000..616d6c46108ee --- /dev/null +++ b/tests/aws/services/acm/test_acm.py @@ -0,0 +1,208 @@ +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer +from moto import settings as moto_settings + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.crypto import generate_ssl_cert +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + + +class TestACM: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Certificate.CreatedAt", + "$..Certificate.DomainValidationOptions", + "$..Certificate.ExtendedKeyUsages", + "$..Certificate.ExtendedKeyUsages..Name", + "$..Certificate.ExtendedKeyUsages..OID", + "$..Certificate.Issuer", + "$..Certificate.KeyUsages", + "$..Certificate.KeyUsages..Name", + "$..Certificate.Options.CertificateTransparencyLoggingPreference", + "$..Certificate.Serial", + "$..Certificate.Subject", + ] + ) + def test_import_certificate(self, tmp_path, aws_client, cleanups, snapshot): + with pytest.raises(Exception) as exc_info: + aws_client.acm.import_certificate(Certificate=b"CERT123", PrivateKey=b"KEY123") + assert exc_info.value.response["Error"]["Code"] == "ValidationException" + + _, cert_file, key_file = generate_ssl_cert(target_file=str(tmp_path / "cert")) + with open(key_file, "rb") as infile: + private_key_bytes = infile.read() + with open(cert_file, "rb") as infile: + certificate_bytes = infile.read() + result = aws_client.acm.import_certificate( + Certificate=certificate_bytes, PrivateKey=private_key_bytes + ) + certificate_arn = result["CertificateArn"] + cert_id = certificate_arn.split("certificate/")[-1] + snapshot.add_transformer(snapshot.transform.regex(cert_id, "")) + + cleanups.append(lambda: aws_client.acm.delete_certificate(CertificateArn=certificate_arn)) + snapshot.match("import-certificate-response", result) + + def _certificate_present(): + return aws_client.acm.describe_certificate(CertificateArn=certificate_arn) + + describe_res = retry(_certificate_present) + + snapshot.add_transformer( + SortingTransformer("DomainValidationOptions", lambda o: o["DomainName"]) + ) + snapshot.add_transformer(SortingTransformer("SubjectAlternativeNames")) + + snapshot.match("describe-certificate-response", describe_res) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ResourceRecord", # Added by LS but not in aws response + "$..ValidationEmails", # Not in LS response + ] + ) + @markers.aws.validated + def test_domain_validation(self, acm_request_certificate, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("CertificateArn")) + snapshot.add_transformer(snapshot.transform.key_value("DomainName")) + snapshot.add_transformer(snapshot.transform.key_value("ValidationMethod")) + snapshot.add_transformer(snapshot.transform.key_value("SignatureAlgorithm")) + + certificate_arn = acm_request_certificate()["CertificateArn"] + result = aws_client.acm.describe_certificate(CertificateArn=certificate_arn) + snapshot.match("describe-certificate", result) + + @markers.aws.needs_fixing + def test_boto_wait_for_certificate_validation( + self, acm_request_certificate, aws_client, monkeypatch + ): + monkeypatch.setattr(moto_settings, "ACM_VALIDATION_WAIT", 1) + certificate_arn = acm_request_certificate()["CertificateArn"] + waiter = aws_client.acm.get_waiter("certificate_validated") + waiter.wait(CertificateArn=certificate_arn, WaiterConfig={"Delay": 0.5, "MaxAttempts": 3}) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Certificate.SignatureAlgorithm"]) + def test_certificate_for_subdomain_wildcard( + self, acm_request_certificate, aws_client, snapshot, monkeypatch + ): + snapshot.add_transformer(snapshot.transform.key_value("OID")) + snapshot.add_transformer(snapshot.transform.key_value("Serial")) + monkeypatch.setattr(moto_settings, "ACM_VALIDATION_WAIT", 2) + + # request certificate for subdomain + domain_name = f"test-domain-{short_uid()}.localhost.localstack.cloud" + subdomain_pattern = f"*.{domain_name}" + create_response = acm_request_certificate( + ValidationMethod="DNS", DomainName=subdomain_pattern + ) + cert_arn = create_response["CertificateArn"] + + snapshot.add_transformer(snapshot.transform.regex(domain_name, "")) + cert_id = cert_arn.split("certificate/")[-1] + snapshot.add_transformer(snapshot.transform.regex(cert_id, "")) + snapshot.match("request-cert", create_response) + + def _get_cert_with_records(): + response = aws_client.acm.describe_certificate(CertificateArn=cert_arn) + assert response["Certificate"]["DomainValidationOptions"][0]["ResourceRecord"] + return response + + # wait for cert with ResourceRecord CNAME entry + response = retry(_get_cert_with_records, sleep=1, retries=30) + dns_options = response["Certificate"]["DomainValidationOptions"][0]["ResourceRecord"] + snapshot.add_transformer( + snapshot.transform.regex(dns_options["Name"].split(".")[0], "") + ) + snapshot.add_transformer(snapshot.transform.regex(dns_options["Value"], "")) + snapshot.match("describe-cert", response) + + if is_aws_cloud(): + # Wait until DNS entry has been added (needs to be done manually!) + # Note: When running parity tests against AWS, we need to add the CNAME record to our DNS + # server (currently with gandi.net), to enable validation of the certificate. + prompt = ( + f"Please add the following CNAME entry to the LocalStack DNS server, then hit [ENTER] once " + f"the certificate has been validated in AWS: {dns_options['Name']} = {dns_options['Value']}" + ) + input(prompt) + + def _get_cert_issued(): + response = aws_client.acm.describe_certificate(CertificateArn=cert_arn) + assert response["Certificate"]["Status"] == "ISSUED" + return response + + # get cert again after validation + response = retry(_get_cert_issued, sleep=1, retries=30) + snapshot.match("describe-cert-2", response) + + # also snapshot response of cert summaries via list_certificates + response = aws_client.acm.list_certificates() + summaries = response.get("CertificateSummaryList") or [] + matching = [cert for cert in summaries if cert["CertificateArn"] == cert_arn] + snapshot.match("list-cert", matching) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ExtendedKeyUsages", + "$..IssuedAt", + "$..KeyUsages", + "$..NotAfter", + "$..NotBefore", + "$..Status", + "$..DomainValidationOptions..ValidationMethod", + "$..DomainValidationOptions..ValidationEmails", + "$..DomainValidationOptions..ValidationStatus", + "$..FailureReason", + "$..ResourceRecord", + "$..SignatureAlgorithm", + "$..Serial", + ] + ) + def test_create_certificate_for_multiple_alternative_domains( + self, acm_request_certificate, aws_client, snapshot + ): + domain_name = "test.example.com" + subject_alternative_names = [ + "test.example.com", + "another.domain.com", + "yet-another.domain.com", + "*.test.example.com", + ] + + create_response = acm_request_certificate( + DomainName=domain_name, SubjectAlternativeNames=subject_alternative_names + ) + + cert_arn = create_response["CertificateArn"] + + def _certificate_ready(): + response = aws_client.acm.describe_certificate(CertificateArn=cert_arn) + # expecting FAILED on aws due to not requesting a valid certificate + # expecting ISSUED as default response from moto + if response["Certificate"]["Status"] not in ["FAILED", "ISSUED"]: + raise Exception("Certificate not yet ready") + + retry(_certificate_ready, sleep=1, retries=30) + + cert_list_response = aws_client.acm.list_certificates() + cert_summaries = cert_list_response["CertificateSummaryList"] + cert = next((cert for cert in cert_summaries if cert["CertificateArn"] == cert_arn), None) + # Order of sns is not guaranteed therefor we sort them + cert["SubjectAlternativeNameSummaries"].sort() + cert_id = cert_arn.split("certificate/")[-1] + snapshot.add_transformer(snapshot.transform.regex(cert_id, "")) + snapshot.match("list-cert-summary-list", cert) + + cert_describe_response = aws_client.acm.describe_certificate(CertificateArn=cert_arn) + cert_description = cert_describe_response["Certificate"] + # Order of sns is not guaranteed therefor we sort them + cert_description["SubjectAlternativeNames"].sort() + cert_description["DomainValidationOptions"] = sorted( + cert_description["DomainValidationOptions"], key=lambda x: x["DomainName"] + ) + snapshot.match("describe-cert", cert_description) diff --git a/tests/aws/services/acm/test_acm.snapshot.json b/tests/aws/services/acm/test_acm.snapshot.json new file mode 100644 index 0000000000000..7b68ad84f0ef6 --- /dev/null +++ b/tests/aws/services/acm/test_acm.snapshot.json @@ -0,0 +1,336 @@ +{ + "tests/aws/services/acm/test_acm.py::TestACM::test_certificate_for_subdomain_wildcard": { + "recorded-date": "18-04-2023, 19:01:27", + "recorded-content": { + "request-cert": { + "CertificateArn": "arn::acm::111111111111:certificate/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-cert": { + "Certificate": { + "CertificateArn": "arn::acm::111111111111:certificate/", + "CreatedAt": "datetime", + "DomainName": "*.", + "DomainValidationOptions": [ + { + "DomainName": "*.", + "ResourceRecord": { + "Name": "..", + "Type": "CNAME", + "Value": "" + }, + "ValidationDomain": "*.", + "ValidationMethod": "DNS", + "ValidationStatus": "PENDING_VALIDATION" + } + ], + "ExtendedKeyUsages": [], + "InUseBy": [], + "Issuer": "Amazon", + "KeyAlgorithm": "RSA-2048", + "KeyUsages": [], + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + }, + "RenewalEligibility": "INELIGIBLE", + "SignatureAlgorithm": "SHA256WITHRSA", + "Status": "PENDING_VALIDATION", + "Subject": "CN=*.", + "SubjectAlternativeNames": [ + "*." + ], + "Type": "AMAZON_ISSUED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-cert-2": { + "Certificate": { + "CertificateArn": "arn::acm::111111111111:certificate/", + "CreatedAt": "datetime", + "DomainName": "*.", + "DomainValidationOptions": [ + { + "DomainName": "*.", + "ResourceRecord": { + "Name": "..", + "Type": "CNAME", + "Value": "" + }, + "ValidationDomain": "*.", + "ValidationMethod": "DNS", + "ValidationStatus": "SUCCESS" + } + ], + "ExtendedKeyUsages": [ + { + "Name": "TLS_WEB_SERVER_AUTHENTICATION", + "OID": "" + }, + { + "Name": "TLS_WEB_CLIENT_AUTHENTICATION", + "OID": "" + } + ], + "InUseBy": [], + "IssuedAt": "datetime", + "Issuer": "Amazon", + "KeyAlgorithm": "RSA-2048", + "KeyUsages": [ + { + "Name": "DIGITAL_SIGNATURE" + }, + { + "Name": "KEY_ENCIPHERMENT" + } + ], + "NotAfter": "datetime", + "NotBefore": "datetime", + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + }, + "RenewalEligibility": "INELIGIBLE", + "Serial": "", + "SignatureAlgorithm": "SHA256WITHRSA", + "Status": "ISSUED", + "Subject": "CN=*.", + "SubjectAlternativeNames": [ + "*." + ], + "Type": "AMAZON_ISSUED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-cert": [ + { + "CertificateArn": "arn::acm::111111111111:certificate/", + "DomainName": "*.", + "SubjectAlternativeNameSummaries": [ + "*." + ], + "HasAdditionalSubjectAlternativeNames": false, + "Status": "ISSUED", + "Type": "AMAZON_ISSUED", + "KeyAlgorithm": "RSA-2048", + "KeyUsages": [ + "DIGITAL_SIGNATURE", + "KEY_ENCIPHERMENT" + ], + "ExtendedKeyUsages": [ + "TLS_WEB_SERVER_AUTHENTICATION", + "TLS_WEB_CLIENT_AUTHENTICATION" + ], + "InUse": false, + "RenewalEligibility": "INELIGIBLE", + "NotBefore": "datetime", + "NotAfter": "datetime", + "CreatedAt": "datetime", + "IssuedAt": "datetime" + } + ] + } + }, + "tests/aws/services/acm/test_acm.py::TestACM::test_create_certificate_for_multiple_alternative_domains": { + "recorded-date": "09-01-2024, 14:58:14", + "recorded-content": { + "list-cert-summary-list": { + "CertificateArn": "arn::acm::111111111111:certificate/", + "CreatedAt": "datetime", + "DomainName": "test.example.com", + "ExtendedKeyUsages": [], + "HasAdditionalSubjectAlternativeNames": false, + "InUse": false, + "KeyAlgorithm": "RSA-2048", + "KeyUsages": [], + "RenewalEligibility": "INELIGIBLE", + "Status": "FAILED", + "SubjectAlternativeNameSummaries": [ + "*.test.example.com", + "another.domain.com", + "test.example.com", + "yet-another.domain.com" + ], + "Type": "AMAZON_ISSUED" + }, + "describe-cert": { + "CertificateArn": "arn::acm::111111111111:certificate/", + "CreatedAt": "datetime", + "DomainName": "test.example.com", + "DomainValidationOptions": [ + { + "DomainName": "*.test.example.com", + "ValidationDomain": "*.test.example.com", + "ValidationEmails": [], + "ValidationMethod": "EMAIL", + "ValidationStatus": "FAILED" + }, + { + "DomainName": "another.domain.com", + "ValidationDomain": "another.domain.com", + "ValidationEmails": [], + "ValidationMethod": "EMAIL", + "ValidationStatus": "PENDING_VALIDATION" + }, + { + "DomainName": "test.example.com", + "ValidationDomain": "test.example.com", + "ValidationEmails": [], + "ValidationMethod": "EMAIL", + "ValidationStatus": "PENDING_VALIDATION" + }, + { + "DomainName": "yet-another.domain.com", + "ValidationDomain": "yet-another.domain.com", + "ValidationEmails": [], + "ValidationMethod": "EMAIL", + "ValidationStatus": "PENDING_VALIDATION" + } + ], + "ExtendedKeyUsages": [], + "FailureReason": "ADDITIONAL_VERIFICATION_REQUIRED", + "InUseBy": [], + "Issuer": "Amazon", + "KeyAlgorithm": "RSA-2048", + "KeyUsages": [], + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + }, + "RenewalEligibility": "INELIGIBLE", + "SignatureAlgorithm": "SHA256WITHRSA", + "Status": "FAILED", + "Subject": "CN=test.example.com", + "SubjectAlternativeNames": [ + "*.test.example.com", + "another.domain.com", + "test.example.com", + "yet-another.domain.com" + ], + "Type": "AMAZON_ISSUED" + } + } + }, + "tests/aws/services/acm/test_acm.py::TestACM::test_import_certificate": { + "recorded-date": "22-02-2024, 17:41:15", + "recorded-content": { + "import-certificate-response": { + "CertificateArn": "arn::acm::111111111111:certificate/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-certificate-response": { + "Certificate": { + "CertificateArn": "arn::acm::111111111111:certificate/", + "CreatedAt": "datetime", + "DomainName": "localhost", + "DomainValidationOptions": [ + { + "DomainName": "localhost" + }, + { + "DomainName": "localhost.localstack.cloud" + }, + { + "DomainName": "localhost.localstack.cloudIP:127.0.0.1" + }, + { + "DomainName": "test.localhost.atlassian.io" + } + ], + "ExtendedKeyUsages": [ + { + "Name": "TLS_WEB_SERVER_AUTHENTICATION", + "OID": "1.3.6.1.5.5.7.3.1" + } + ], + "ImportedAt": "datetime", + "InUseBy": [], + "Issuer": "LocalStack Org", + "KeyAlgorithm": "RSA-2048", + "KeyUsages": [ + { + "Name": "DIGITAL_SIGNATURE" + }, + { + "Name": "NON_REPUDIATION" + }, + { + "Name": "KEY_ENCIPHERMENT" + } + ], + "NotAfter": "datetime", + "NotBefore": "datetime", + "Options": { + "CertificateTransparencyLoggingPreference": "DISABLED" + }, + "RenewalEligibility": "INELIGIBLE", + "Serial": "03:e9", + "SignatureAlgorithm": "SHA256WITHRSA", + "Status": "ISSUED", + "Subject": "C=AU,ST=Some-State,L=Some-Locality,O=LocalStack Org,OU=Testing,CN=localhost", + "SubjectAlternativeNames": [ + "localhost", + "localhost.localstack.cloud", + "localhost.localstack.cloudIP:127.0.0.1", + "test.localhost.atlassian.io" + ], + "Type": "IMPORTED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/acm/test_acm.py::TestACM::test_domain_validation": { + "recorded-date": "12-04-2024, 15:36:37", + "recorded-content": { + "describe-certificate": { + "Certificate": { + "CertificateArn": "", + "CreatedAt": "datetime", + "DomainName": "", + "DomainValidationOptions": [ + { + "DomainName": "", + "ValidationDomain": "", + "ValidationEmails": [], + "ValidationMethod": "", + "ValidationStatus": "PENDING_VALIDATION" + } + ], + "ExtendedKeyUsages": [], + "InUseBy": [], + "Issuer": "Amazon", + "KeyAlgorithm": "RSA-2048", + "KeyUsages": [], + "Options": { + "CertificateTransparencyLoggingPreference": "ENABLED" + }, + "RenewalEligibility": "INELIGIBLE", + "SignatureAlgorithm": "", + "Status": "PENDING_VALIDATION", + "Subject": "CN=", + "SubjectAlternativeNames": [ + "" + ], + "Type": "AMAZON_ISSUED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/acm/test_acm.validation.json b/tests/aws/services/acm/test_acm.validation.json new file mode 100644 index 0000000000000..8201f405c3e71 --- /dev/null +++ b/tests/aws/services/acm/test_acm.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/acm/test_acm.py::TestACM::test_certificate_for_subdomain_wildcard": { + "last_validated_date": "2023-04-18T17:01:27+00:00" + }, + "tests/aws/services/acm/test_acm.py::TestACM::test_create_certificate_for_multiple_alternative_domains": { + "last_validated_date": "2024-01-09T14:58:14+00:00" + }, + "tests/aws/services/acm/test_acm.py::TestACM::test_domain_validation": { + "last_validated_date": "2024-04-12T15:36:37+00:00" + }, + "tests/aws/services/acm/test_acm.py::TestACM::test_import_certificate": { + "last_validated_date": "2024-02-22T17:41:15+00:00" + } +} diff --git a/tests/aws/services/apigateway/__init__.py b/tests/aws/services/apigateway/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/apigateway/apigateway_fixtures.py b/tests/aws/services/apigateway/apigateway_fixtures.py new file mode 100644 index 0000000000000..e7d58b40c5ba2 --- /dev/null +++ b/tests/aws/services/apigateway/apigateway_fixtures.py @@ -0,0 +1,121 @@ +from enum import Enum +from typing import Dict + +from localstack.services.apigateway.helpers import ( + host_based_url, + localstack_path_based_url, + path_based_url, +) +from localstack.testing.aws.util import is_aws_cloud +from localstack.utils.aws import aws_stack + +# TODO convert the test util functions in this file to pytest fixtures + + +def assert_response_status(response: Dict, status: int): + assert response.get("ResponseMetadata").get("HTTPStatusCode") == status + + +def assert_response_is_200(response: Dict) -> bool: + assert_response_status(response, 200) + return True + + +def assert_response_is_201(response: Dict) -> bool: + assert_response_status(response, 201) + return True + + +def import_rest_api(apigateway_client, **kwargs): + response = apigateway_client.import_rest_api(**kwargs) + assert_response_is_201(response) + resources = apigateway_client.get_resources(restApiId=response.get("id")) + root_id = next(item for item in resources["items"] if item["path"] == "/")["id"] + + return response, root_id + + +def create_rest_resource(apigateway_client, **kwargs): + response = apigateway_client.create_resource(**kwargs) + assert_response_is_201(response) + return response.get("id"), response.get("parentId") + + +def create_rest_resource_method(apigateway_client, **kwargs): + response = apigateway_client.put_method(**kwargs) + assert_response_is_201(response) + return response.get("httpMethod"), response.get("authorizerId") + + +def create_rest_api_integration(apigateway_client, **kwargs): + response = apigateway_client.put_integration(**kwargs) + assert_response_is_201(response) + return response.get("uri"), response.get("type") + + +def create_rest_api_method_response(apigateway_client, **kwargs): + response = apigateway_client.put_method_response(**kwargs) + assert_response_is_201(response) + return response.get("statusCode") + + +def create_rest_api_integration_response(apigateway_client, **kwargs): + response = apigateway_client.put_integration_response(**kwargs) + assert_response_is_201(response) + return response.get("statusCode") + + +def create_rest_api_deployment(apigateway_client, **kwargs): + response = apigateway_client.create_deployment(**kwargs) + assert_response_is_201(response) + return response.get("id"), response.get("createdDate") + + +def update_rest_api_deployment(apigateway_client, **kwargs): + response = apigateway_client.update_deployment(**kwargs) + assert_response_is_200(response) + return response + + +def create_rest_api_stage(apigateway_client, **kwargs): + response = apigateway_client.create_stage(**kwargs) + assert_response_is_201(response) + return response.get("stageName") + + +def update_rest_api_stage(apigateway_client, **kwargs): + response = apigateway_client.update_stage(**kwargs) + assert_response_is_200(response) + return response.get("stageName") + + +# +# Common utilities +# + + +class UrlType(Enum): + HOST_BASED = 0 + PATH_BASED = 1 + LS_PATH_BASED = 2 + + +def api_invoke_url( + api_id: str, + stage: str = "", + path: str = "/", + url_type: UrlType = UrlType.HOST_BASED, + region: str = "", +): + if is_aws_cloud(): + if not region: + region = aws_stack.get_boto3_region() + stage = f"/{stage}" if stage else "" + return f"https://{api_id}.execute-api.{region}.amazonaws.com{stage}{path}" + + if url_type == UrlType.HOST_BASED: + return host_based_url(api_id, stage_name=stage, path=path) + elif url_type == UrlType.PATH_BASED: + return path_based_url(api_id, stage_name=stage, path=path) + else: + return localstack_path_based_url(api_id, stage_name=stage, path=path) diff --git a/tests/aws/services/apigateway/conftest.py b/tests/aws/services/apigateway/conftest.py new file mode 100644 index 0000000000000..88ac5575de221 --- /dev/null +++ b/tests/aws/services/apigateway/conftest.py @@ -0,0 +1,240 @@ +import pytest +from botocore.config import Config + +from localstack import config +from localstack.constants import APPLICATION_JSON +from localstack.testing.aws.util import is_aws_cloud +from localstack.utils.strings import short_uid +from tests.aws.services.apigateway.apigateway_fixtures import ( + create_rest_api_deployment, + create_rest_api_integration, + create_rest_api_integration_response, + create_rest_api_method_response, + create_rest_api_stage, + create_rest_resource, + create_rest_resource_method, + import_rest_api, +) +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_PYTHON_ECHO_STATUS_CODE + +# default name used for created REST API stages +DEFAULT_STAGE_NAME = "dev" + +STEPFUNCTIONS_ASSUME_ROLE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "states.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} + +APIGATEWAY_STEPFUNCTIONS_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "states:*", "Resource": "*"}], +} + +APIGATEWAY_KINESIS_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "kinesis:*", "Resource": "*"}], +} + +APIGATEWAY_LAMBDA_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "lambda:*", "Resource": "*"}], +} + +APIGATEWAY_S3_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "*"}], +} + +APIGATEWAY_DYNAMODB_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:*", "Resource": "*"}], +} + +APIGATEWAY_ASSUME_ROLE_POLICY = { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": {"Service": "apigateway.amazonaws.com"}, + "Action": "sts:AssumeRole", + } +} + + +def is_next_gen_api(): + return config.APIGW_NEXT_GEN_PROVIDER and not is_aws_cloud() + + +@pytest.fixture +def create_rest_api_with_integration( + create_rest_apigw, wait_for_stream_ready, create_iam_role_with_policy, aws_client +): + def _factory( + integration_uri, + path_part="test", + req_parameters=None, + req_templates=None, + res_templates=None, + integration_type=None, + stage=DEFAULT_STAGE_NAME, + resource_method: str = "POST", + integration_method: str = "POST", + ): + name = f"test-apigw-{short_uid()}" + api_id, name, root_id = create_rest_apigw( + name=name, endpointConfiguration={"types": ["REGIONAL"]} + ) + + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root_id, pathPart=path_part + ) + + if req_parameters is None: + req_parameters = {} + + method, _ = create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=resource_method, + authorizationType="NONE", + apiKeyRequired=False, + requestParameters=dict.fromkeys(req_parameters.values(), True), + ) + + # set AWS policy to give API GW access to backend resources + if ":dynamodb:" in integration_uri: + policy = APIGATEWAY_DYNAMODB_POLICY + elif ":kinesis:" in integration_uri: + policy = APIGATEWAY_KINESIS_POLICY + elif integration_type in ("HTTP", "HTTP_PROXY"): + policy = None + else: + raise Exception(f"Unexpected integration URI: {integration_uri}") + assume_role_arn = "" + if policy: + assume_role_arn = create_iam_role_with_policy( + RoleName=f"role-apigw-{short_uid()}", + PolicyName=f"policy-apigw-{short_uid()}", + RoleDefinition=APIGATEWAY_ASSUME_ROLE_POLICY, + PolicyDefinition=policy, + ) + + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=method, + integrationHttpMethod=integration_method, + type=integration_type or "AWS", + credentials=assume_role_arn, + uri=integration_uri, + requestTemplates=req_templates or {}, + requestParameters=req_parameters, + ) + + create_rest_api_method_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=resource_method, + statusCode="200", + ) + + res_templates = res_templates or {APPLICATION_JSON: "$input.json('$')"} + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=resource_method, + statusCode="200", + responseTemplates=res_templates, + ) + + deployment_id, _ = create_rest_api_deployment(aws_client.apigateway, restApiId=api_id) + create_rest_api_stage( + aws_client.apigateway, restApiId=api_id, stageName=stage, deploymentId=deployment_id + ) + + return api_id + + yield _factory + + +@pytest.fixture +def create_status_code_echo_server(aws_client, create_lambda_function): + lambda_client = aws_client.lambda_ + + def _create_status_code_echo_server(): + function_name = f"lambda_fn_echo_status_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO_STATUS_CODE, + ) + create_url_response = lambda_client.create_function_url_config( + FunctionName=function_name, AuthType="NONE", InvokeMode="BUFFERED" + ) + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + return create_url_response["FunctionUrl"] + + return _create_status_code_echo_server + + +@pytest.fixture +def apigw_redeploy_api(aws_client): + def _factory(rest_api_id: str, stage_name: str): + deployment_id = aws_client.apigateway.create_deployment(restApiId=rest_api_id)["id"] + + aws_client.apigateway.update_stage( + restApiId=rest_api_id, + stageName=stage_name, + patchOperations=[{"op": "replace", "path": "/deploymentId", "value": deployment_id}], + ) + + return _factory + + +@pytest.fixture +def import_apigw(aws_client, aws_client_factory): + rest_api_ids = [] + + if is_aws_cloud(): + client_config = ( + Config( + # Api gateway can throttle requests pretty heavily. Leading to potentially undeleted apis + retries={"max_attempts": 10, "mode": "adaptive"} + ) + if is_aws_cloud() + else None + ) + + apigateway_client = aws_client_factory(config=client_config).apigateway + else: + apigateway_client = aws_client.apigateway + + def _import_apigateway_function(*args, **kwargs): + response, root_id = import_rest_api(apigateway_client, **kwargs) + rest_api_ids.append(response.get("id")) + return response, root_id + + yield _import_apigateway_function + + for rest_api_id in rest_api_ids: + apigateway_client.delete_rest_api(restApiId=rest_api_id) + + +@pytest.fixture +def apigw_add_transformers(snapshot): + snapshot.add_transformer(snapshot.transform.jsonpath("$..items..id", "id")) + snapshot.add_transformer(snapshot.transform.key_value("deploymentId")) diff --git a/tests/aws/services/apigateway/test_apigateway_api.py b/tests/aws/services/apigateway/test_apigateway_api.py new file mode 100644 index 0000000000000..2e2a8c523dffd --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_api.py @@ -0,0 +1,3814 @@ +import json +import logging +import os.path +import time +from operator import itemgetter + +import pytest +from botocore.config import Config +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer, SortingTransformer + +from localstack.aws.api.apigateway import PutMode +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import ( + create_rest_api_integration, + create_rest_api_integration_response, + create_rest_api_method_response, + create_rest_resource, + create_rest_resource_method, +) +from tests.aws.services.apigateway.conftest import is_next_gen_api + +LOG = logging.getLogger(__name__) + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +OAS_30_DOCUMENTATION_PARTS = os.path.join(THIS_DIR, "../../files/oas30_documentation_parts.json") + + +@pytest.fixture(autouse=True) +def apigw_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + + +@pytest.fixture(scope="class", autouse=True) +def apigw_cleanup_before_run(aws_client): + # TODO: remove this once all tests are properly cleaning up and using fixtures + rest_apis = aws_client.apigateway.get_rest_apis() + for rest_api in rest_apis["items"]: + delete_rest_api_retry(aws_client.apigateway, rest_api["id"]) + + +def delete_rest_api_retry(client, rest_api_id: str): + try: + if is_aws_cloud(): + # This is ugly but API GW returns 429 very quickly, and we want to be sure to clean up properly + cleaned = False + while not cleaned: + try: + client.delete_rest_api(restApiId=rest_api_id) + cleaned = True + except ClientError as e: + error_message = str(e) + if "TooManyRequestsException" in error_message: + time.sleep(10) + elif "NotFoundException" in error_message: + break + else: + raise + else: + client.delete_rest_api(restApiId=rest_api_id) + + except Exception as e: + LOG.debug("Error cleaning up rest API: %s, %s", rest_api_id, e) + + +@pytest.fixture +def apigw_create_rest_api(aws_client, aws_client_factory): + if is_aws_cloud(): + client_config = ( + Config( + # Api gateway can throttle requests pretty heavily. Leading to potentially undeleted apis + retries={"max_attempts": 10, "mode": "adaptive"} + ) + if is_aws_cloud() + else None + ) + + apigateway_client = aws_client_factory(config=client_config).apigateway + else: + apigateway_client = aws_client.apigateway + + rest_apis = [] + + def _factory(*args, **kwargs): + if "name" not in kwargs: + kwargs["name"] = f"test-api-{short_uid()}" + response = apigateway_client.create_rest_api(*args, **kwargs) + rest_apis.append(response["id"]) + return response + + yield _factory + + for rest_api_id in rest_apis: + delete_rest_api_retry(apigateway_client, rest_api_id) + + +class TestApiGatewayApiRestApi: + @markers.aws.validated + def test_list_and_delete_apis(self, apigw_create_rest_api, snapshot, aws_client): + api_name1 = f"test-list-and-delete-apis-{short_uid()}" + api_name2 = f"test-list-and-delete-apis-{short_uid()}" + + response = apigw_create_rest_api(name=api_name1, description="this is my api") + snapshot.match("create-rest-api-1", response) + api_id = response["id"] + + response_2 = apigw_create_rest_api(name=api_name2, description="this is my api2") + snapshot.match("create-rest-api-2", response_2) + + response = aws_client.apigateway.get_rest_apis() + # sort the response by creation date, to ensure order for snapshot matching + response["items"].sort(key=itemgetter("createdDate")) + snapshot.match("get-rest-api-before-delete", response) + + response = aws_client.apigateway.delete_rest_api(restApiId=api_id) + snapshot.match("delete-rest-api", response) + + response = aws_client.apigateway.get_rest_apis() + snapshot.match("get-rest-api-after-delete", response) + + @markers.aws.validated + @pytest.mark.skip(reason="rest apis are case insensitive for now because of custom id tags") + def test_get_api_case_insensitive(self, apigw_create_rest_api, snapshot, aws_client): + api_name1 = f"test-case-sensitive-apis-{short_uid()}" + + response = apigw_create_rest_api(name=api_name1, description="lower case api") + snapshot.match("create-rest-api", response) + api_id = response["id"] + + snapshot.add_transformer(snapshot.transform.regex(api_id.upper(), "")) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_rest_api(restApiId=api_id.upper()) + snapshot.match("get-api-upper-case", e.value.response) + + @markers.aws.validated + def test_create_rest_api_with_optional_params(self, apigw_create_rest_api, snapshot): + # create only with mandatory name + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + ) + snapshot.match("create-only-name", response) + + # create with empty description + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="", + ) + snapshot.match("create-empty-desc", e.value.response) + + # create with random version + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + version="v1", + ) + snapshot.match("create-with-version", response) + + # create with empty binaryMediaTypes + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + binaryMediaTypes=[], + ) + snapshot.match("create-with-empty-binary-media", response) + + # create with negative minimumCompressionSize + with pytest.raises(ClientError) as e: + apigw_create_rest_api(name=f"test-api-{short_uid()}", minimumCompressionSize=-1) + snapshot.match("string-compression-size", e.value.response) + + @markers.aws.validated + def test_create_rest_api_with_binary_media_types(self, apigw_create_rest_api, snapshot): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + binaryMediaTypes=["image/png"], + ) + snapshot.match("create-with-binary-media", response) + + @markers.aws.validated + def test_create_rest_api_with_tags(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="this is my api", + tags={"MY_TAG1": "MY_VALUE1"}, + ) + snapshot.match("create-rest-api-w-tags", response) + api_id = response["id"] + + response = aws_client.apigateway.get_rest_api(restApiId=api_id) + snapshot.match("get-rest-api-w-tags", response) + + assert "tags" in response + assert response["tags"] == {"MY_TAG1": "MY_VALUE1"} + + response = aws_client.apigateway.get_rest_apis() + snapshot.match("get-rest-apis-w-tags", response) + + @markers.aws.validated + def test_update_rest_api_operation_add_remove( + self, apigw_create_rest_api, snapshot, aws_client + ): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="this is my api" + ) + api_id = response["id"] + # binaryMediaTypes is an array but is modified like an object + patch_operations = [ + {"op": "add", "path": "/binaryMediaTypes/image~1png"}, + {"op": "add", "path": "/binaryMediaTypes/image~1jpeg"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-add", response) + assert response["binaryMediaTypes"] == ["image/png", "image/jpeg"] + assert response["description"] == "this is my api" + + patch_operations = [ + {"op": "replace", "path": "/binaryMediaTypes/image~1png", "value": "image/gif"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-replace", response) + assert response["binaryMediaTypes"] == ["image/jpeg", "image/gif"] + + patch_operations = [ + {"op": "remove", "path": "/binaryMediaTypes/image~1gif"}, + {"op": "remove", "path": "/description"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-remove", response) + assert response["binaryMediaTypes"] == ["image/jpeg"] + assert "description" not in response + + @markers.aws.validated + def test_update_rest_api_compression(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="this is my api" + ) + api_id = response["id"] + + # we can enable compression by setting a non-negative integer between 0 and 10485760 + patch_operations_enable = [ + {"op": "replace", "path": "/minimumCompressionSize", "value": "10"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations_enable + ) + snapshot.match("enable-compression", response) + + # check that listing is not exploding after update, null -> 10 + response = aws_client.apigateway.get_rest_api(restApiId=api_id) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # from the docs: to disable compression, apply a replace operation with the value property set to null or + # omit the value property. + # it seems an empty string is accepted as well + patch_operations = [ + {"op": "replace", "path": "/minimumCompressionSize", "value": ""}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("disable-compression", response) + + # check that listing is not exploding after update, 10 -> null + response = aws_client.apigateway.get_rest_api(restApiId=api_id) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + patch_operations = [ + {"op": "replace", "path": "/minimumCompressionSize", "value": "0"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("set-compression-zero", response) + + # check that listing is not exploding after update, null -> 0 + response = aws_client.apigateway.get_rest_api(restApiId=api_id) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "replace", "path": "/minimumCompressionSize", "value": "-1"}, + ] + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("set-negative-compression", e.value.response) + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "replace", "path": "/minimumCompressionSize", "value": "test"}, + ] + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("set-string-compression", e.value.response) + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "add", "path": "/minimumCompressionSize", "value": "10"}, + ] + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("unsupported-operation", e.value.response) + + @markers.aws.validated + def test_update_rest_api_behaviour(self, apigw_create_rest_api, snapshot, aws_client): + # TODO: add more negative testing + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="this is my api" + ) + api_id = response["id"] + # binaryMediaTypes is an array but is modified like an object, if you try accessing like an array, it will + # lead to weird behaviour + patch_operations = [ + {"op": "add", "path": "/binaryMediaTypes/-", "value": "image/png"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-array", response) + assert response["binaryMediaTypes"] == ["-"] + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "add", "path": "/binaryMediaTypes", "value": "image/gif"}, + ] + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-add-base-path", e.value.response) + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "replace", "path": "/binaryMediaTypes", "value": "image/gif"}, + ] + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-replace-base-path", e.value.response) + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "remove", "path": "/binaryMediaTypes"}, + ] + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-remove-base-path", e.value.response) + + @markers.aws.validated + def test_update_rest_api_invalid_api_id(self, snapshot, aws_client): + patch_operations = [{"op": "replace", "path": "/apiKeySource", "value": "AUTHORIZER"}] + with pytest.raises(ClientError) as ex: + aws_client.apigateway.update_rest_api( + restApiId="api_id", patchOperations=patch_operations + ) + snapshot.match("not-found-update-rest-api", ex.value.response) + assert ex.value.response["Error"]["Code"] == "NotFoundException" + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Validation behavior not yet implemented" + ) + def test_update_rest_api_concatenation_of_errors( + self, apigw_create_rest_api, snapshot, aws_client + ): + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + + patch_operations = [ + {"op": "wrong", "path": "/endpointConfiguration/ipAddressType", "value": "dualstack"}, + {"op": "wrong", "path": "/endpointConfiguration/ipAddressType", "value": "dualstack"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-wrong-operations-on-ipAddressType", e.value.response) + + patch_operations = [ + {"op": "wrong", "path": "/endpointConfiguration/types/0", "value": "EDGE"}, + {"op": "wrong", "path": "/endpointConfiguration/types/0", "value": "EDGE"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-wrong-operations-on-type", e.value.response) + + patch_operations = [ + {"op": "wrong", "path": "/endpointConfiguration/ipAddressType", "value": "dualstack"}, + {"op": "wrong", "path": "/endpointConfiguration/types/0", "value": "EDGE"}, + {"op": "wrong", "path": "/binaryMediaTypes", "value": "image/gif"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match( + "update-rest-api-wrong-operations-on-type-and-on-ip-address-type", e.value.response + ) + + @markers.aws.validated + def test_update_rest_api_ip_address_type(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + + patch_operations = [ + {"op": "replace", "path": "/endpointConfiguration/types/0", "value": "EDGE"}, + {"op": "replace", "path": "/endpointConfiguration/types/0", "value": "REGIONAL"}, + {"op": "replace", "path": "/endpointConfiguration/types/0", "value": "PRIVATE"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-replace-type", response) + + patch_operations = [ + {"op": "replace", "path": "/endpointConfiguration/ipAddressType", "value": "ipv4"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-replace-to-ipv4-within-private-type", e.value.response) + + patch_operations = [ + {"op": "replace", "path": "/endpointConfiguration/ipAddressType", "value": "wrong"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-wrong-ipAddressType", e.value.response) + + patch_operations = [ + {"op": "replace", "path": "/endpointConfiguration/types/0", "value": "wrong"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-wrong-type", e.value.response) + + patch_operations = [ + {"op": "remove", "path": "/endpointConfiguration/ipAddressType", "value": "dualstack"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-invalid-operation-on-ipAddressType", e.value.response) + + patch_operations = [ + {"op": "remove", "path": "/endpointConfiguration/types/0", "value": "EDGE"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-invalid-operation-on-type", e.value.response) + + patch_operations = [ + {"op": "replace", "path": "/endpointConfiguration/ipAddressType", "value": "dualstack"}, + ] + response = aws_client.apigateway.update_rest_api( + restApiId=api_id, patchOperations=patch_operations + ) + snapshot.match("update-rest-api-replace-ip-address-type", response) + + @markers.aws.validated + def test_create_rest_api_with_endpoint_configuration(self, apigw_create_rest_api, snapshot): + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "types": [], + }, + ) + snapshot.match("create-with-empty-types", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "ipAddressType": "wrong", + "types": ["EDGE"], + }, + ) + snapshot.match("create-with-invalid-ip-address-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "ipAddressType": "ipv4", + "types": ["wrong"], + }, + ) + snapshot.match("create-with-invalid-types", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "ipAddressType": "wrong", + "types": ["wrong"], + }, + ) + snapshot.match("create-with-invalid-ip-address-type-and-types", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "types": ["wrong"], + "ipAddressType": "wrong", + }, + ) + snapshot.match("create-with-invalid-types-and-ip-address-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="", + minimumCompressionSize=-1, + endpointConfiguration={ + "ipAddressType": "wrong", + "types": ["wrong"], + }, + ) + snapshot.match("create-with-multiple-errors-1", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="", + minimumCompressionSize=-1, + endpointConfiguration={ + "ipAddressType": "ipv4", + "types": ["EDGE"], + }, + ) + snapshot.match("create-with-multiple-errors-2", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "types": ["EDGE", "REGIONAL"], + }, + ) + snapshot.match("create-with-two-types", e.value.response) + + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "ipAddressType": "dualstack", + "types": ["EDGE"], + }, + ) + snapshot.match("create-with-endpoint-config-dualstack", response) + + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "ipAddressType": "ipv4", + "types": ["REGIONAL"], + }, + ) + snapshot.match("create-with-endpoint-config-regional", response) + + @markers.aws.validated + def test_create_rest_api_private_type(self, apigw_create_rest_api, snapshot): + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "ipAddressType": "ipv4", + "types": ["PRIVATE"], + }, + ) + snapshot.match("create-with-private-type-and-ipv4-ip-address-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "ipAddressType": "wrong", + "types": ["PRIVATE"], + }, + ) + snapshot.match("create-with-private-type-and-wrong-ip-address-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="", + endpointConfiguration={ + "ipAddressType": "ipv4", + "types": ["PRIVATE"], + }, + ) + snapshot.match( + "create-with-private-type-and-ipv4-ip-address-type-and-empty-description", + e.value.response, + ) + + @markers.aws.validated + def test_create_rest_api_verify_defaults(self, apigw_create_rest_api, snapshot): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "types": ["EDGE"], + }, + ) + snapshot.match("create-with-edge-default", response) + assert response["endpointConfiguration"]["ipAddressType"] == "ipv4" + + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "types": ["REGIONAL"], + }, + ) + snapshot.match("create-with-regional-default", response) + assert response["endpointConfiguration"]["ipAddressType"] == "ipv4" + + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + endpointConfiguration={ + "types": ["PRIVATE"], + }, + ) + snapshot.match("create-with-private-default", response) + assert response["endpointConfiguration"]["ipAddressType"] == "dualstack" + + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + ) + snapshot.match("create-with-empty-default", response) + assert response["endpointConfiguration"]["types"] == ["EDGE"] + assert response["endpointConfiguration"]["ipAddressType"] == "ipv4" + + with pytest.raises(ClientError) as e: + apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="", + endpointConfiguration={ + "types": ["wrong"], + }, + ) + snapshot.match("create-with-empty-ip-address-type-and-wrong-type", e.value.response) + + +class TestApiGatewayApiResource: + @markers.aws.validated + def test_resource_lifecycle(self, apigw_create_rest_api, snapshot, aws_client): + snapshot.add_transformer(SortingTransformer("items", lambda x: x["path"])) + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource lifecycle" + ) + api_id = response["id"] + + root_rest_api_resource = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("rest-api-root-resource", root_rest_api_resource) + + root_id = root_rest_api_resource["items"][0]["id"] + + resource_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="pets" + ) + resource_id = resource_response["id"] + + snapshot.match("create-resource", resource_response) + + rest_api_resources = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("rest-api-resources-after-create", rest_api_resources) + + # create subresource + subresource_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=resource_id, pathPart="subpets" + ) + snapshot.match("create-subresource", subresource_response) + + rest_api_resources = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("rest-api-resources-after-create-sub", rest_api_resources) + + # only supported path are /parentId and /pathPart with operation `replace` + patch_operations = [ + {"op": "replace", "path": "/pathPart", "value": "dogs"}, + ] + + update_response = aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=resource_id, patchOperations=patch_operations + ) + snapshot.match("update-path-part", update_response) + + get_resource_response = aws_client.apigateway.get_resource( + restApiId=api_id, resourceId=resource_id + ) + snapshot.match("get-resp-after-update-path-part", get_resource_response) + + delete_resource_response = aws_client.apigateway.delete_resource( + restApiId=api_id, resourceId=resource_id + ) + snapshot.match("del-resource", delete_resource_response) + + rest_api_resources = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("rest-api-resources-after-delete", rest_api_resources) + + @markers.aws.validated + def test_update_resource_behaviour(self, apigw_create_rest_api, snapshot, aws_client): + snapshot.add_transformer(SortingTransformer("items", lambda x: x["path"])) + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource behaviour" + ) + api_id = response["id"] + + root_rest_api_resource = aws_client.apigateway.get_resources(restApiId=api_id) + root_id = root_rest_api_resource["items"][0]["id"] + + resource_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="pets" + ) + resource_id = resource_response["id"] + + # try updating a non-existent resource + patch_operations = [ + {"op": "replace", "path": "/pathPart", "value": "dogs"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId="fake-resource", patchOperations=patch_operations + ) + snapshot.match("nonexistent-resource", e.value.response) + + # only supported path are /parentId and /pathPart with operation `replace` + patch_operations = [ + {"op": "replace", "path": "/invalid", "value": "dogs"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=resource_id, patchOperations=patch_operations + ) + snapshot.match("invalid-path-part", e.value.response) + + # try updating a resource with a non-existent parentId + patch_operations = [ + {"op": "replace", "path": "/parentId", "value": "fake-parent-id"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=resource_id, patchOperations=patch_operations + ) + snapshot.match("invalid-parent-id", e.value.response) + + # create subresource `subpets` under `/pets` + subresource_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=resource_id, pathPart="subpets" + ) + snapshot.match("create-subresource", subresource_response) + subresource_id = subresource_response["id"] + + # create subresource `pets` under `/pets/subpets` + subresource_child_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=subresource_id, pathPart="pets" + ) + snapshot.match("create-subresource-child", subresource_child_response) + subresource_child_id = subresource_child_response["id"] + + # try moving a subresource under the root id but with the same name as an existing future sibling + # move last resource of `pets/subpets/pets` to `/pets`, already exists + patch_operations = [ + {"op": "replace", "path": "/parentId", "value": root_id}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=subresource_child_id, patchOperations=patch_operations + ) + snapshot.match("existing-future-sibling-path", e.value.response) + # clean up that for the rest of the test + aws_client.apigateway.delete_resource(restApiId=api_id, resourceId=subresource_child_id) + + # try setting the parent id of the pets to its own subresource? + patch_operations = [ + {"op": "replace", "path": "/parentId", "value": subresource_id}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=resource_id, patchOperations=patch_operations + ) + snapshot.match("update-parent-id-to-subresource-id", e.value.response) + + # move the subresource to be under the root id + # we had root -> resource -> subresource - /pets/subpets + # we now have root -> resource and root -> subresource -> /pets and /subpets + patch_operations = [ + {"op": "replace", "path": "/parentId", "value": root_id}, + ] + update_parent_id_to_root = aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=subresource_id, patchOperations=patch_operations + ) + + snapshot.match("update-parent-id-to-root-id", update_parent_id_to_root) + + # try changing `/subpets` to `/pets`, but it already exists under `root` + patch_operations = [ + {"op": "replace", "path": "/pathPart", "value": "pets"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=subresource_id, patchOperations=patch_operations + ) + snapshot.match("update-path-already-exists", e.value.response) + + # test deleting the resource `/pets`, its old child (`/subpets`) should not be deleted + aws_client.apigateway.delete_resource(restApiId=api_id, resourceId=resource_id) + api_resources = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("resources-after-deletion", api_resources) + + # try using a non-supported operation `remove` + patch_operations = [ + {"op": "remove", "path": "/pathPart"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=subresource_id, patchOperations=patch_operations + ) + snapshot.match("remove-unsupported", e.value.response) + + # try using a non-supported operation `add` + patch_operations = [ + {"op": "add", "path": "/pathPart", "value": "added-pets"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_resource( + restApiId=api_id, resourceId=subresource_id, patchOperations=patch_operations + ) + snapshot.match("add-unsupported", e.value.response) + + @markers.aws.validated + def test_delete_resource(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource behaviour" + ) + api_id = response["id"] + + root_rest_api_resource = aws_client.apigateway.get_resources(restApiId=api_id) + root_id = root_rest_api_resource["items"][0]["id"] + + resource_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="pets" + ) + resource_id = resource_response["id"] + + # create subresource + subresource_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=resource_id, pathPart="subpets" + ) + subresource_id = subresource_response["id"] + + delete_resource_response = aws_client.apigateway.delete_resource( + restApiId=api_id, resourceId=resource_id + ) + snapshot.match("delete-resource", delete_resource_response) + + api_resources = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("get-resources", api_resources) + + # try deleting already deleted subresource + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_resource(restApiId=api_id, resourceId=subresource_id) + snapshot.match("delete-subresource", e.value.response) + + @markers.aws.validated + def test_create_resource_parent_invalid(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource parent" + ) + api_id = response["id"] + + # create subresource with wrong parent + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_resource( + restApiId=api_id, parentId="fake-resource-id", pathPart="subpets" + ) + snapshot.match("wrong-resource-parent-id", e.value.response) + + @markers.aws.validated + def test_create_proxy_resource(self, apigw_create_rest_api, snapshot, aws_client): + # test following docs + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-settings-method-request.html#api-gateway-proxy-resource + snapshot.add_transformer(SortingTransformer("items", lambda x: x["path"])) + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource proxy" + ) + api_id = response["id"] + root_rest_api_resource = aws_client.apigateway.get_resources(restApiId=api_id) + root_id = root_rest_api_resource["items"][0]["id"] + + # creating `/{proxy+}` resource + base_proxy_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{proxy+}" + ) + snapshot.match("create-base-proxy-resource", base_proxy_response) + + # creating `/parent` resource, sibling to `/{proxy+}` + proxy_sibling_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="parent" + ) + proxy_sibling_id = proxy_sibling_response["id"] + snapshot.match("create-proxy-sibling-resource", proxy_sibling_id) + + # creating `/parent/{proxy+}` resource + proxy_sibling_proxy_child_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=proxy_sibling_id, pathPart="{proxy+}" + ) + proxy_child_id = proxy_sibling_proxy_child_response["id"] + snapshot.match( + "create-proxy-sibling-proxy-child-resource", proxy_sibling_proxy_child_response + ) + + # creating `/parent/child` resource, sibling to `/parent/{proxy+}` + proxy_sibling_static_child_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=proxy_sibling_id, pathPart="child" + ) + dynamic_child_id = proxy_sibling_static_child_response["id"] + snapshot.match( + "create-proxy-sibling-static-child-resource", proxy_sibling_static_child_response + ) + + # creating `/parent/child/{proxy+}` resource + dynamic_child_proxy_child_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=dynamic_child_id, pathPart="{proxy+}" + ) + snapshot.match("create-static-child-proxy-resource", dynamic_child_proxy_child_response) + + # list all resources + result_api_resource = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("all-resources", result_api_resource) + + # to allow nested route testing, we will delete `/parent/{proxy+}` to allow creation of a dynamic {child} + aws_client.apigateway.delete_resource(restApiId=api_id, resourceId=proxy_child_id) + + # creating `/parent/{child}` resource, as its sibling `/parent/{proxy+}` is now deleted + proxy_sibling_dynamic_child_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=proxy_sibling_id, pathPart="{child}" + ) + dynamic_child_id = proxy_sibling_dynamic_child_response["id"] + snapshot.match( + "create-proxy-sibling-dynamic-child-resource", proxy_sibling_dynamic_child_response + ) + + # creating `/parent/{child}/{proxy+}` resource + dynamic_child_proxy_child_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=dynamic_child_id, pathPart="{proxy+}" + ) + snapshot.match("create-dynamic-child-proxy-resource", dynamic_child_proxy_child_response) + + result_api_resource = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("all-resources-2", result_api_resource) + + @markers.aws.validated + def test_create_proxy_resource_validation(self, apigw_create_rest_api, snapshot, aws_client): + # test following docs + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-method-settings-method-request.html#api-gateway-proxy-resource + snapshot.add_transformer(SortingTransformer("items", lambda x: x["path"])) + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource proxy" + ) + api_id = response["id"] + root_rest_api_resource = aws_client.apigateway.get_resources(restApiId=api_id) + root_id = root_rest_api_resource["items"][0]["id"] + + # creating `/{proxy+}` resource + base_proxy_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{proxy+}" + ) + base_proxy_id = base_proxy_response["id"] + snapshot.match("create-base-proxy-resource", base_proxy_response) + + # try creating `/{dynamic}` resource, sibling to `/{proxy+}` + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{dynamic}" + ) + snapshot.match("create-proxy-dynamic-sibling-resource", e.value.response) + + # try creating `/{proxy+}/child` resource, child to `/{proxy+}` + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_resource( + restApiId=api_id, parentId=base_proxy_id, pathPart="child" + ) + snapshot.match("create-proxy-static-child-resource", e.value.response) + + # try creating `/{proxy+}/{child}` resource, dynamic child to `/{proxy+}` + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_resource( + restApiId=api_id, parentId=base_proxy_id, pathPart="{child}" + ) + snapshot.match("create-proxy-dynamic-child-resource", e.value.response) + + # creating `/parent` static resource + parent_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="parent" + ) + parent_id = parent_response["id"] + + # create `/parent/{child+}` resource, dynamic greedy child to `/parent` + greedy_child_response = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=parent_id, pathPart="{child+}" + ) + snapshot.match("create-greedy-child-resource", greedy_child_response) + + +class TestApiGatewayApiAuthorizer: + @markers.aws.validated + def test_authorizer_crud_no_api(self, snapshot, aws_client): + # maybe move this test to a full lifecycle one + # AWS validates the format of the authorizerUri before the restApi existence + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_authorizer( + restApiId="test-fake-rest-id", + name="fake-auth-name", + type="TOKEN", + authorizerUri="arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:myApiAuthorizer/invocations", + identitySource="method.request.header.Authorization", + ) + snapshot.match("wrong-rest-api-id-create-authorizer", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_authorizers(restApiId="test-fake-rest-id") + snapshot.match("wrong-rest-api-id-get-authorizers", e.value.response) + + +class TestApiGatewayApiMethod: + @markers.aws.validated + def test_method_lifecycle(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource method lifecycle" + ) + api_id = response["id"] + root_id = response["rootResourceId"] + + put_base_method_response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + ) + snapshot.match("put-base-method-response", put_base_method_response) + + get_base_method_response = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=root_id, httpMethod="ANY" + ) + snapshot.match("get-base-method-response", get_base_method_response) + + del_base_method_response = aws_client.apigateway.delete_method( + restApiId=api_id, resourceId=root_id, httpMethod="ANY" + ) + snapshot.match("del-base-method-response", del_base_method_response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_method(restApiId=api_id, resourceId=root_id, httpMethod="ANY") + snapshot.match("get-deleted-method-response", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_method( + restApiId=api_id, resourceId=root_id, httpMethod="ANY" + ) + snapshot.match("delete-deleted-method-response", e.value.response) + + @markers.aws.validated + def test_method_request_parameters(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource method request params" + ) + api_id = response["id"] + root_id = response["rootResourceId"] + + put_method_response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + requestParameters={ + "method.request.querystring.q_optional": False, + "method.request.querystring.q_required": True, + "method.request.header.h_optional": False, + "method.request.header.h_required": True, + }, + ) + snapshot.match("put-method-request-params-response", put_method_response) + + get_method_response = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=root_id, httpMethod="ANY" + ) + snapshot.match("get-method-request-params-response", get_method_response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + authorizationType="NONE", + requestParameters={ + "method.request.querystring.optional": False, + "method.request.header.optional": False, + }, + ) + + snapshot.match("req-params-same-name", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.delete-model-used-by-2-method.Error.Message", + "$.delete-model-used-by-2-method.message", # we can't guarantee the last method will be the same as AWS + ] + ) + def test_put_method_model(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource method model" + ) + api_id = response["id"] + root_id = response["rootResourceId"] + + create_model = aws_client.apigateway.create_model( + name="MySchema", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchema", "type": "object"}), + ) + snapshot.match("create-model", create_model) + + create_model_2 = aws_client.apigateway.create_model( + name="MySchemaTwo", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchemaTwo", "type": "object"}), + ) + snapshot.match("create-model-2", create_model_2) + + put_method_response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + requestModels={"application/json": "MySchema"}, + ) + snapshot.match("put-method-request-models", put_method_response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_model(restApiId=api_id, modelName="MySchema") + snapshot.match("delete-model-used", e.value.response) + + patch_operations = [ + {"op": "replace", "path": "/requestModels/application~1json", "value": "MySchemaTwo"}, + ] + + update_method_model = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations, + ) + snapshot.match("update-method-model", update_method_model) + + delete_model = aws_client.apigateway.delete_model(restApiId=api_id, modelName="MySchema") + snapshot.match("delete-model-unused", delete_model) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-used-2", e.value.response) + + # create a subresource using MySchemaTwo + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + put_method_response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="ANY", + authorizationType="NONE", + requestModels={"application/json": "MySchemaTwo"}, + ) + snapshot.match("put-method-2-request-models", put_method_response) + + # assert that the error raised gives the path of the subresource + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-used-by-2-method", e.value.response) + + patch_operations = [ + {"op": "remove", "path": "/requestModels/application~1json", "value": "MySchemaTwo"}, + ] + + # remove the Model from the subresource + update_method_model = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=resource["id"], + httpMethod="ANY", + patchOperations=patch_operations, + ) + snapshot.match("update-method-model-2", update_method_model) + + if is_aws_cloud(): + # just to be sure the change is properly set in AWS + time.sleep(3) + + # assert that the error raised gives the path of the resource now + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-used-by-method-1", e.value.response) + + # delete the Method using MySchemaTwo + delete_method = aws_client.apigateway.delete_method( + restApiId=api_id, resourceId=root_id, httpMethod="ANY" + ) + snapshot.match("delete-method-using-model-2", delete_method) + + # assert we can now delete MySchemaTwo + delete_model = aws_client.apigateway.delete_model(restApiId=api_id, modelName="MySchemaTwo") + snapshot.match("delete-model-unused-2", delete_model) + + @markers.aws.validated + def test_put_method_validation(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource method request params" + ) + api_id = response["id"] + root_id = response["rootResourceId"] + + # wrong RestApiId + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_method( + restApiId="fake-api", + resourceId=root_id, + httpMethod="WRONG", + authorizationType="NONE", + ) + snapshot.match("wrong-api", e.value.response) + + # wrong resourceId + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId="fake-resource-id", + httpMethod="WRONG", + authorizationType="NONE", + ) + snapshot.match("wrong-resource", e.value.response) + + # wrong httpMethod + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="WRONG", + authorizationType="NONE", + ) + snapshot.match("wrong-method", e.value.response) + + # missing AuthorizerId when setting authorizationType="CUSTOM" + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="CUSTOM", + ) + snapshot.match("missing-authorizer-id", e.value.response) + + # invalid RequestValidatorId + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + requestValidatorId="fake-validator", + ) + snapshot.match("invalid-request-validator", e.value.response) + + # invalid Model id + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + requestModels={"application/json": "petModel"}, + ) + snapshot.match("invalid-model-name", e.value.response) + + # TODO: validate authorizationScopes? + # TODO: add more validation on methods once its subresources are tested + # Authorizer, RequestValidator, Model + + @markers.aws.validated + def test_update_method(self, apigw_create_rest_api, snapshot, aws_client): + # see https://www.linkedin.com/pulse/updating-aws-cli-patch-operations-rest-api-yitzchak-meirovich/ + # for patch path + snapshot.add_transformer(snapshot.transform.key_value("authorizerId")) + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing update method" + ) + api_id = response["id"] + root_id = response["rootResourceId"] + + put_method_response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + ) + snapshot.match("put-method-response", put_method_response) + + patch_operations_add = [ + { + "op": "add", + "path": "/requestParameters/method.request.querystring.optional", + "value": "true", + }, + {"op": "add", "path": "/requestModels/application~1json", "value": "Empty"}, + ] + + update_method_response_add = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("update-method-add", update_method_response_add) + + patch_operations_replace = [ + {"op": "replace", "path": "/operationName", "value": "ReplacedOperationName"}, + {"op": "replace", "path": "/apiKeyRequired", "value": "true"}, + {"op": "replace", "path": "/authorizationType", "value": "AWS_IAM"}, + { + "op": "replace", + "path": "/requestParameters/method.request.querystring.optional", + "value": "false", + }, + ] + + update_method_response_replace = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_replace, + ) + snapshot.match("update-method-replace", update_method_response_replace) + + authorizer = aws_client.apigateway.create_authorizer( + restApiId=api_id, + name="authorizer-test", + type="TOKEN", + authorizerUri="arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:myApiAuthorizer/invocations", + identitySource="method.request.header.Authorization", + ) + + patch_operations_replace_auth = [ + {"op": "replace", "path": "/authorizerId", "value": authorizer["id"]}, + {"op": "replace", "path": "/authorizationType", "value": "CUSTOM"}, + ] + + update_method_response_replace_auth = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_replace_auth, + ) + snapshot.match("update-method-replace-authorizer", update_method_response_replace_auth) + + patch_operations_remove = [ + { + "op": "remove", + "path": "/requestParameters/method.request.querystring.optional", + "value": "true", + }, + {"op": "remove", "path": "/requestModels/application~1json", "value": "Empty"}, + ] + + update_method_response_remove = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_remove, + ) + snapshot.match("update-method-remove", update_method_response_remove) + + @markers.aws.validated + def test_update_method_validation(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource method request params" + ) + api_id = response["id"] + root_id = response["rootResourceId"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_method( + restApiId="fake-api", + resourceId=root_id, + httpMethod="ANY", + patchOperations=[], + ) + snapshot.match("wrong-rest-api", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId="fake-resource-id", + httpMethod="ANY", + patchOperations=[], + ) + snapshot.match("wrong-resource-id", e.value.response) + + # method is not set for the resource? + with pytest.raises(ClientError) as e: + patch_operations_add = [ + {"op": "replace", "path": "/operationName", "value": "methodDoesNotExist"}, + ] + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + patchOperations=patch_operations_add, + ) + snapshot.match("method-does-not-exist", e.value.response) + + put_method_response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + authorizationType="NONE", + apiKeyRequired=True, + ) + snapshot.match("put-method-response", put_method_response) + + # unsupported operation + patch_operations_add = [ + {"op": "add", "path": "/operationName", "value": "operationName"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("unsupported-operation", e.value.response) + + # unsupported operation + patch_operations_add_2 = [ + {"op": "add", "path": "/requestValidatorId", "value": "wrong-id"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add_2, + ) + snapshot.match("unsupported-operation-2", e.value.response) + + # unsupported path + with pytest.raises(ClientError) as e: + patch_operations_add = [ + {"op": "add", "path": "/httpMethod", "value": "PUT"}, + ] + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("unsupported-path", e.value.response) + + # wrong path for requestParameters + with pytest.raises(ClientError) as e: + patch_operations_add = [ + { + "op": "replace", + "path": "/requestParameters", + "value": "method.request.querystring.optional=false", + }, + ] + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("wrong-path-request-parameters", e.value.response) + + # wrong path for requestModels + with pytest.raises(ClientError) as e: + patch_operations_add = [ + {"op": "add", "path": "/requestModels/application/json", "value": "Empty"}, + ] + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("wrong-path-request-models", e.value.response) + + # wrong value type + patch_operations_add = [ + {"op": "replace", "path": "/apiKeyRequired", "value": "whatever"}, + ] + wrong_value_type_resp = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("wrong-value-type", wrong_value_type_resp) + + # add auth type without authorizer? + with pytest.raises(ClientError) as e: + patch_operations_add = [ + {"op": "replace", "path": "/authorizationType", "value": "CUSTOM"}, + ] + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("wrong-auth-type", e.value.response) + + # add auth id when method has NONE, AWS will ignore it + patch_operations_add = [ + {"op": "replace", "path": "/authorizerId", "value": "abc123"}, + ] + response = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("skip-auth-id-with-wrong-type", response) + + # add auth type without real authorizer id? + with pytest.raises(ClientError) as e: + patch_operations_add = [ + {"op": "replace", "path": "/authorizationType", "value": "CUSTOM"}, + {"op": "replace", "path": "/authorizerId", "value": "abc123"}, + ] + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("wrong-auth-id", e.value.response) + + # replace wrong validator id + with pytest.raises(ClientError) as e: + patch_operations_add = [ + {"op": "replace", "path": "/requestValidatorId", "value": "fake-id"}, + ] + aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="ANY", + patchOperations=patch_operations_add, + ) + snapshot.match("wrong-req-validator-id", e.value.response) + + +class TestApiGatewayApiModels: + @markers.aws.validated + def test_model_lifecycle(self, apigw_create_rest_api, snapshot, aws_client): + # taken from https://docs.aws.amazon.com/apigateway/latest/api/API_CreateModel.html#API_CreateModel_Examples + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource model lifecycle" + ) + api_id = response["id"] + + create_model_response = aws_client.apigateway.create_model( + name="CalcOutput", + restApiId=api_id, + contentType="application/json", + description="Calc output model", + schema='{\n\t"title": "Calc output",\n\t"type": "object",\n\t"properties": {\n\t\t"a": {\n\t\t\t"type": "number"\n\t\t},\n\t\t"b": {\n\t\t\t"type": "number"\n\t\t},\n\t\t"op": {\n\t\t\t"description": "operation of +, -, * or /",\n\t\t\t"type": "string"\n\t\t},\n\t\t"c": {\n\t\t "type": "number"\n\t\t}\n\t},\n\t"required": ["a", "b", "op"]\n}\n', + ) + snapshot.match("create-model", create_model_response) + + get_models_response = aws_client.apigateway.get_models(restApiId=api_id) + get_models_response["items"].sort(key=lambda x: x["name"]) + snapshot.match("get-models", get_models_response) + + # manually assert the presence of 2 default models, Error and Empty, as snapshots will replace names + model_names = [model["name"] for model in get_models_response["items"]] + assert "Error" in model_names + assert "Empty" in model_names + + get_model_response = aws_client.apigateway.get_model( + restApiId=api_id, modelName="CalcOutput" + ) + snapshot.match("get-model", get_model_response) + + del_model_response = aws_client.apigateway.delete_model( + restApiId=api_id, modelName="CalcOutput" + ) + snapshot.match("del-model", del_model_response) + + @markers.aws.validated + def test_model_validation(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing resource model lifecycle" + ) + api_id = response["id"] + + fake_api_id = "abcde0" + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_model( + name="MySchema", + restApiId=fake_api_id, + contentType="application/json", + description="Test model", + schema=json.dumps({"title": "MySchema", "type": "object"}), + ) + + snapshot.match("create-model-wrong-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_models(restApiId=fake_api_id) + snapshot.match("get-models-wrong-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_model(restApiId=fake_api_id, modelName="MySchema") + snapshot.match("get-model-wrong-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_model(restApiId=fake_api_id, modelName="MySchema") + snapshot.match("del-model-wrong-id", e.value.response) + + # assert that creating a model with an empty description works + response = aws_client.apigateway.create_model( + name="MySchema", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchema", "type": "object"}), + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 201 + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_model( + name="MySchema", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchema", "type": "object"}), + ) + snapshot.match("create-model-already-exists", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_model( + name="", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchema", "type": "object"}), + ) + snapshot.match("create-model-empty-name", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_model( + name="MyEmptySchema", + restApiId=api_id, + contentType="application/json", + description="", + schema="", + ) + + snapshot.match("create-model-empty-schema", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_model( + name="MyEmptySchema", + restApiId=api_id, + contentType="application/json", + description="", + ) + + snapshot.match("create-model-no-schema-json", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_model( + name="MyEmptySchemaXml", + restApiId=api_id, + contentType="application/xml", + description="", + ) + + snapshot.match("create-model-no-schema-xml", e.value.response) + + @markers.aws.validated + def test_update_model(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing update resource model" + ) + api_id = response["id"] + + fake_api_id = "abcde0" + updated_schema = json.dumps({"title": "Updated schema", "type": "object"}) + patch_operations = [ + {"op": "replace", "path": "/schema", "value": updated_schema}, + {"op": "replace", "path": "/description", "value": ""}, + ] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_model( + restApiId=fake_api_id, + modelName="mySchema", + patchOperations=patch_operations, + ) + + snapshot.match("update-model-wrong-id", e.value.response) + + response = aws_client.apigateway.create_model( + name="MySchema", + restApiId=api_id, + contentType="application/json", + description="", + schema=json.dumps({"title": "MySchema", "type": "object"}), + ) + snapshot.match("create-model", response) + + response = aws_client.apigateway.update_model( + restApiId=api_id, + modelName="MySchema", + patchOperations=patch_operations, + ) + snapshot.match("update-model", response) + + with pytest.raises(ClientError) as e: + patch_operations = [{"op": "add", "path": "/wrong-path", "value": "not supported op"}] + aws_client.apigateway.update_model( + restApiId=api_id, + modelName="MySchema", + patchOperations=patch_operations, + ) + + snapshot.match("update-model-invalid-op", e.value.response) + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "replace", "path": "/name", "value": "invalid"}, + ] + aws_client.apigateway.update_model( + restApiId=api_id, + modelName="MySchema", + patchOperations=patch_operations, + ) + + snapshot.match("update-model-invalid-path", e.value.response) + + with pytest.raises(ClientError) as e: + patch_operations = [ + {"op": "replace", "path": "/schema", "value": ""}, + ] + aws_client.apigateway.update_model( + restApiId=api_id, + modelName="MySchema", + patchOperations=patch_operations, + ) + snapshot.match("update-model-empty-schema", e.value.response) + + +class TestApiGatewayApiRequestValidator: + @markers.aws.validated + def test_validators_crud_no_api(self, snapshot, aws_client): + # maybe move this test to a full lifecycle one + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_request_validator( + restApiId="test-fake-rest-id", + name="test-validator", + validateRequestBody=True, + validateRequestParameters=False, + ) + snapshot.match("wrong-rest-api-id-create-validator", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_request_validators(restApiId="test-fake-rest-id") + snapshot.match("wrong-rest-api-id-get-validators", e.value.response) + + @markers.aws.validated + def test_request_validator_lifecycle(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="my api", + ) + snapshot.match("create-rest-api", response) + api_id = response["id"] + + # create a request validator for an API + response = aws_client.apigateway.create_request_validator( + restApiId=api_id, name=f"test-validator-{short_uid()}" + ) + snapshot.match("create-request-validator", response) + validator_id = response["id"] + + # get detail of a specific request validator corresponding to an API + response = aws_client.apigateway.get_request_validator( + restApiId=api_id, requestValidatorId=validator_id + ) + snapshot.match("get-request-validator", response) + + # get list of all request validators in the API + response = aws_client.apigateway.get_request_validators(restApiId=api_id) + snapshot.match("get-request-validators", response) + + # update request validators with different set of patch operations + patch_operations = [ + {"op": "replace", "path": "/validateRequestBody", "value": "true"}, + ] + response = aws_client.apigateway.update_request_validator( + restApiId=api_id, requestValidatorId=validator_id, patchOperations=patch_operations + ) + snapshot.match("update-request-validator-with-value", response) + + patch_operations = [ + {"op": "replace", "path": "/validateRequestBody"}, + ] + response = aws_client.apigateway.update_request_validator( + restApiId=api_id, requestValidatorId=validator_id, patchOperations=patch_operations + ) + snapshot.match("update-request-validator-without-value", response) + + response = aws_client.apigateway.get_request_validator( + restApiId=api_id, requestValidatorId=validator_id + ) + snapshot.match("get-request-validators-after-update-operation", response) + + # delete request validator + response = aws_client.apigateway.delete_request_validator( + restApiId=api_id, requestValidatorId=validator_id + ) + snapshot.match("delete-request-validator", response) + + # try fetching details of the deleted request validator + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_request_validator( + restApiId=api_id, requestValidatorId=validator_id + ) + snapshot.match("get-deleted-request-validator", e.value.response) + + # check list of all request validators in the API + response = aws_client.apigateway.get_request_validators(restApiId=api_id) + snapshot.match("get-request-validators-after-delete", response) + + @markers.aws.validated + def test_invalid_get_request_validator(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="my api", + ) + api_id = response["id"] + + response = aws_client.apigateway.create_request_validator( + restApiId=api_id, name=f"test-validator-{short_uid()}" + ) + validator_id = response["id"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_request_validator( + restApiId="api_id", requestValidatorId=validator_id + ) + snapshot.match("get-request-validators-invalid-api-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_request_validator( + restApiId=api_id, requestValidatorId="validator_id" + ) + snapshot.match("get-request-validators-invalid-validator-id", e.value.response) + + @markers.aws.validated + def test_invalid_get_request_validators(self, apigw_create_rest_api, snapshot, aws_client): + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_request_validators(restApiId="api_id") + snapshot.match("get-invalid-request-validators", e.value.response) + + @markers.aws.validated + def test_invalid_delete_request_validator(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="my api", + ) + api_id = response["id"] + + response = aws_client.apigateway.create_request_validator( + restApiId=api_id, name=f"test-validator-{short_uid()}" + ) + validator_id = response["id"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_request_validator( + restApiId="api_id", requestValidatorId=validator_id + ) + snapshot.match("delete-request-validator-invalid-api-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_request_validator( + restApiId=api_id, requestValidatorId="validator_id" + ) + snapshot.match("delete-request-validator-invalid-validator-id", e.value.response) + + @markers.aws.validated + def test_create_request_validator_invalid_api_id( + self, apigw_create_rest_api, snapshot, aws_client + ): + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_request_validator( + restApiId="api_id", name=f"test-validator-{short_uid()}" + ) + snapshot.match("invalid-create-request-validator", e.value.response) + + @markers.aws.validated + def test_invalid_update_request_validator_operations( + self, apigw_create_rest_api, snapshot, aws_client + ): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="my api", + ) + snapshot.match("create-rest-api", response) + api_id = response["id"] + + response = aws_client.apigateway.create_request_validator( + restApiId=api_id, name=f"test-validator-{short_uid()}" + ) + snapshot.match("create-request-validator", response) + validator_id = response["id"] + + patch_operations = [ + {"op": "add", "path": "/validateRequestBody", "value": "true"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_request_validator( + restApiId=api_id, requestValidatorId=validator_id, patchOperations=patch_operations + ) + snapshot.match("update-request-validator-invalid-add-operation", e.value.response) + + patch_operations = [ + {"op": "remove", "path": "/validateRequestBody", "value": "true"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_request_validator( + restApiId=api_id, requestValidatorId=validator_id, patchOperations=patch_operations + ) + snapshot.match("update-request-validator-invalid-remove-operation", e.value.response) + + patch_operations = [ + {"op": "replace", "path": "/invalidPath", "value": "true"}, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_request_validator( + restApiId=api_id, requestValidatorId=validator_id, patchOperations=patch_operations + ) + snapshot.match("update-request-validator-invalid-path", e.value.response) + + patch_operations = [ + {"op": "replace", "path": "/name"}, + ] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_request_validator( + restApiId=api_id, requestValidatorId=validator_id, patchOperations=patch_operations + ) + snapshot.match("update-request-validator-empty-name-value", e.value.response) + + +class TestApiGatewayApiDocumentationPart: + @markers.aws.validated + def test_doc_parts_crud_no_api(self, snapshot, aws_client): + # maybe move this test to a full lifecycle one + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_documentation_part( + restApiId="test-fake-rest-id", + location={"type": "API"}, + properties='{\n\t"info": {\n\t\t"description" : "Your first API with Amazon API Gateway."\n\t}\n}', + ) + snapshot.match("wrong-rest-api-id-create-doc-part", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_documentation_parts(restApiId="test-fake-rest-id") + snapshot.match("wrong-rest-api-id-get-doc-parts", e.value.response) + + @markers.aws.validated + def test_documentation_part_lifecycle(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + api_id = response["id"] + + # create documentation part + response = aws_client.apigateway.create_documentation_part( + restApiId=api_id, + location={"type": "API"}, + properties='{ "description": "Sample API description" }', + ) + snapshot.match("create-documentation-part", response) + documentation_part_id = response["id"] + + # get detail of a specific documentation part corresponding to an API + response = aws_client.apigateway.get_documentation_part( + restApiId=api_id, documentationPartId=documentation_part_id + ) + snapshot.match("get-documentation-part", response) + + # get list of all documentation parts in an API + response = aws_client.apigateway.get_documentation_parts( + restApiId=api_id, + ) + snapshot.match("get-documentation-parts", response) + + # update documentation part + patch_operations = [ + { + "op": "replace", + "path": "/properties", + "value": '{ "description": "Updated Sample API description" }', + }, + ] + response = aws_client.apigateway.update_documentation_part( + restApiId=api_id, + documentationPartId=documentation_part_id, + patchOperations=patch_operations, + ) + snapshot.match("update-documentation-part", response) + + # get detail of documentation part after update + response = aws_client.apigateway.get_documentation_part( + restApiId=api_id, documentationPartId=documentation_part_id + ) + snapshot.match("get-documentation-part-after-update", response) + + # delete documentation part + response = aws_client.apigateway.delete_documentation_part( + restApiId=api_id, documentationPartId=documentation_part_id + ) + snapshot.match("delete_documentation_part", response) + + @markers.aws.validated + def test_invalid_get_documentation_part(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + api_id = response["id"] + + response = aws_client.apigateway.create_documentation_part( + restApiId=api_id, + location={"type": "API"}, + properties='{ "description": "Sample API description" }', + ) + documentation_part_id = response["id"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_documentation_part( + restApiId="api_id", documentationPartId=documentation_part_id + ) + snapshot.match("get-documentation-part-invalid-api-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_documentation_part( + restApiId=api_id, documentationPartId="documentation_part_id" + ) + snapshot.match("get-documentation-part-invalid-doc-id", e.value.response) + + @markers.aws.validated + def test_invalid_get_documentation_parts(self, snapshot, aws_client): + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_documentation_parts( + restApiId="api_id", + ) + snapshot.match("get-inavlid-documentation-parts", e.value.response) + + @markers.aws.validated + def test_invalid_update_documentation_part(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + api_id = response["id"] + + response = aws_client.apigateway.create_documentation_part( + restApiId=api_id, + location={"type": "API"}, + properties='{ "description": "Sample API description" }', + ) + documentation_part_id = response["id"] + + patch_operations = [ + { + "op": "replace", + "path": "/properties", + "value": '{ "description": "Updated Sample API description" }', + }, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_documentation_part( + restApiId="api_id", + documentationPartId=documentation_part_id, + patchOperations=patch_operations, + ) + snapshot.match("update-documentation-part-invalid-api-id", e.value.response) + + patch_operations = [ + { + "op": "add", + "path": "/properties", + "value": '{ "description": "Updated Sample API description" }', + }, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_documentation_part( + restApiId=api_id, + documentationPartId=documentation_part_id, + patchOperations=patch_operations, + ) + snapshot.match("update-documentation-part-invalid-add-operation", e.value.response) + + patch_operations = [ + { + "op": "replace", + "path": "/invalidPath", + "value": '{ "description": "Updated Sample API description" }', + }, + ] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_documentation_part( + restApiId=api_id, + documentationPartId=documentation_part_id, + patchOperations=patch_operations, + ) + snapshot.match("update-documentation-part-invalid-path", e.value.response) + + @markers.aws.validated + def test_invalid_create_documentation_part_operations( + self, apigw_create_rest_api, snapshot, aws_client + ): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + api_id = response["id"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_documentation_part( + restApiId="api_id", + location={"type": "API"}, + properties='{ "description": "Sample API description" }', + ) + snapshot.match("create_documentation_part_invalid_api_id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_documentation_part( + restApiId=api_id, + location={"type": "INVALID"}, + properties='{ "description": "Sample API description" }', + ) + snapshot.match("create_documentation_part_invalid_location_type", e.value.response) + + @markers.aws.validated + def test_invalid_delete_documentation_part(self, apigw_create_rest_api, snapshot, aws_client): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + api_id = response["id"] + + response = aws_client.apigateway.create_documentation_part( + restApiId=api_id, + location={"type": "API"}, + properties='{ "description": "Sample API description" }', + ) + documentation_part_id = response["id"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_documentation_part( + restApiId="api_id", + documentationPartId=documentation_part_id, + ) + snapshot.match("delete_documentation_part_wrong_api_id", e.value.response) + + response = aws_client.apigateway.delete_documentation_part( + restApiId=api_id, + documentationPartId=documentation_part_id, + ) + snapshot.match("delete_documentation_part", response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_documentation_part( + restApiId=api_id, + documentationPartId=documentation_part_id, + ) + snapshot.match("delete_already_deleted_documentation_part", e.value.response) + + @markers.aws.validated + def test_import_documentation_parts(self, aws_client, import_apigw, snapshot): + # snapshot array "ids" + snapshot.add_transformer(snapshot.transform.jsonpath("$..ids[*]", "id")) + # create api with documentation imports + spec_file = load_file(OAS_30_DOCUMENTATION_PARTS) + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + rest_api_id = response["id"] + + # get documentation parts to make sure import worked + response = aws_client.apigateway.get_documentation_parts(restApiId=rest_api_id) + snapshot.match("create-import-documentations_parts", response["items"]) + + # delete documentation parts + for doc_part_item in response["items"]: + response = aws_client.apigateway.delete_documentation_part( + restApiId=rest_api_id, + documentationPartId=doc_part_item["id"], + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 202 + + # make sure delete parts are gone + response = aws_client.apigateway.get_documentation_parts(restApiId=rest_api_id) + assert len(response["items"]) == 0 + + # import documentation parts using import documentation parts api + response = aws_client.apigateway.import_documentation_parts( + restApiId=rest_api_id, + mode=PutMode.overwrite, + body=spec_file, + ) + snapshot.match("import-documentation-parts", response) + + +class TestApiGatewayGatewayResponse: + @markers.aws.validated + def test_gateway_response_crud(self, aws_client, apigw_create_rest_api, snapshot): + snapshot.add_transformer( + SortingTransformer(key="items", sorting_fn=itemgetter("responseType")) + ) + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test GatewayResponse", + ) + api_id = response["id"] + + response = aws_client.apigateway.get_gateway_response( + restApiId=api_id, responseType="MISSING_AUTHENTICATION_TOKEN" + ) + snapshot.match("get-gateway-response-default", response) + + # example from https://docs.aws.amazon.com/apigateway/latest/api/API_PutGatewayResponse.html + response = aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="MISSING_AUTHENTICATION_TOKEN", + statusCode="404", + responseParameters={ + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-query": "method.request.querystring.q", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + }, + responseTemplates={ + "application/json": '{\n "message": $context.error.messageString,\n "type": "$context.error.responseType",\n "stage": "$context.stage",\n "resourcePath": "$context.resourcePath",\n "stageVariables.a": "$stageVariables.a",\n "statusCode": "\'404\'"\n}' + }, + ) + snapshot.match("put-gateway-response", response) + + response = aws_client.apigateway.get_gateway_responses(restApiId=api_id) + snapshot.match("get-gateway-responses", response) + + response = aws_client.apigateway.get_gateway_response( + restApiId=api_id, responseType="MISSING_AUTHENTICATION_TOKEN" + ) + snapshot.match("get-gateway-response", response) + + response = aws_client.apigateway.delete_gateway_response( + restApiId=api_id, responseType="MISSING_AUTHENTICATION_TOKEN" + ) + snapshot.match("delete-gateway-response", response) + + response = aws_client.apigateway.get_gateway_response( + restApiId=api_id, responseType="MISSING_AUTHENTICATION_TOKEN" + ) + + snapshot.match("get-deleted-gw-response", response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_next_gen_api(), reason="Behaviour only present in next gen api" + ) + def test_gateway_response_put(self, aws_client, apigw_create_rest_api, snapshot): + snapshot.add_transformer( + SortingTransformer(key="items", sorting_fn=itemgetter("responseType")) + ) + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test GatewayResponse", + ) + api_id = response["id"] + + # Put all values + response = aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="MISSING_AUTHENTICATION_TOKEN", + statusCode="404", + responseParameters={ + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-query": "method.request.querystring.q", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + }, + responseTemplates={ + "application/json": '{\n "message": $context.error.messageString,\n "type": "$context.error.responseType",\n "stage": "$context.stage",\n "resourcePath": "$context.resourcePath",\n "stageVariables.a": "$stageVariables.a",\n "statusCode": "\'404\'"\n}' + }, + ) + snapshot.match("put-gateway-response-all-value", response) + + # Put only status code + response = aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="MISSING_AUTHENTICATION_TOKEN", + statusCode="404", + ) + snapshot.match("put-gateway-response-status-only", response) + + # Put only response parameters + response = aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="MISSING_AUTHENTICATION_TOKEN", + responseParameters={ + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + ) + snapshot.match("put-gateway-response-response-parameters-only", response) + + # Put only response templates + response = aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="MISSING_AUTHENTICATION_TOKEN", + responseTemplates={ + "application/json": '{\n "message": $context.error.messageString,\n "type": "$context.error.responseType",\n "stage": "$context.stage",\n "resourcePath": "$context.resourcePath",\n "stageVariables.a": "$stageVariables.a",\n "statusCode": "\'404\'"\n}' + }, + ) + snapshot.match("put-gateway-response-response-templates-only", response) + + # Put default response + response = aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="DEFAULT_5XX", + statusCode="599", + responseParameters={ + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + responseTemplates={ + "application/json": '{\n "message": $context.error.messageString,\n "type": "$context.error.responseType",\n "stage": "$context.stage",\n "resourcePath": "$context.resourcePath",\n "stageVariables.a": "$stageVariables.a",\n "statusCode": "\'404\'"\n}' + }, + ) + snapshot.match("put-gateway-response-default-5xx", response) + + # Put 500 after default set + response = aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="AUTHORIZER_FAILURE", + responseParameters={"gatewayresponse.header.foo": "'bar'"}, + ) + snapshot.match("put-gateway-response-default-ignored", response) + + # Get all, default should affect all 500 + response = aws_client.apigateway.get_gateway_responses(restApiId=api_id) + snapshot.match("get-gateway-responses", response) + + @markers.aws.validated + def test_gateway_response_validation(self, aws_client_factory, apigw_create_rest_api, snapshot): + apigw_client = aws_client_factory(config=Config(parameter_validation=False)).apigateway + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test GatewayResponse", + ) + api_id = response["id"] + fake_id = f"apiid123{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(fake_id, "fake-api-id")) + + with pytest.raises(ClientError) as e: + apigw_client.get_gateway_responses(restApiId=fake_id) + snapshot.match("get-gateway-responses-no-api", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.get_gateway_response(restApiId=fake_id, responseType="DEFAULT_4XX") + snapshot.match("get-gateway-response-no-api", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.delete_gateway_response(restApiId=fake_id, responseType="DEFAULT_4XX") + snapshot.match("delete-gateway-response-no-api", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=fake_id, responseType="DEFAULT_4XX", patchOperations=[] + ) + snapshot.match("update-gateway-response-no-api", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.delete_gateway_response(restApiId=api_id, responseType="DEFAULT_4XX") + snapshot.match("delete-gateway-response-not-set", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.get_gateway_response(restApiId=api_id, responseType="FAKE_RESPONSE_TYPE") + snapshot.match("get-gateway-response-wrong-response-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.delete_gateway_response( + restApiId=api_id, responseType="FAKE_RESPONSE_TYPE" + ) + snapshot.match("delete-gateway-response-wrong-response-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, responseType="FAKE_RESPONSE_TYPE", patchOperations=[] + ) + snapshot.match("update-gateway-response-wrong-response-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.put_gateway_response( + restApiId=api_id, + responseType="FAKE_RESPONSE_TYPE", + statusCode="404", + responseParameters={}, + responseTemplates={}, + ) + snapshot.match("put-gateway-response-wrong-response-type", e.value.response) + + @markers.aws.validated + def test_update_gateway_response( + self, aws_client, aws_client_factory, apigw_create_rest_api, snapshot + ): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test GatewayResponse", + ) + api_id = response["id"] + apigw_client = aws_client_factory(config=Config(parameter_validation=False)).apigateway + + response = apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[{"op": "replace", "path": "/statusCode", "value": "444"}], + ) + snapshot.match("update-gateway-response-not-set", response) + + response = apigw_client.get_gateway_response(restApiId=api_id, responseType="DEFAULT_4XX") + snapshot.match("default-get-gateway-response", response) + + response = apigw_client.put_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + statusCode="404", + responseParameters={ + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-query": "method.request.querystring.q", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + }, + responseTemplates={ + "application/json": json.dumps( + {"application/json": '{"message":$context.error.messageString}'} + ) + }, + ) + snapshot.match("put-gateway-response", response) + + response = apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[ + {"op": "replace", "path": "/statusCode", "value": "444"}, + { + "op": "replace", + "path": "/responseParameters/gatewayresponse.header.Access-Control-Allow-Origin", + "value": "'example.com'", + }, + { + "op": "add", + "path": "/responseTemplates/application~1xml", + "value": "$context.error.messageString$context.error.responseType", + }, + ], + ) + snapshot.match("update-gateway-response", response) + + response = apigw_client.get_gateway_response(restApiId=api_id, responseType="DEFAULT_4XX") + snapshot.match("get-gateway-response", response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[{"op": "add", "path": "/statusCode", "value": "444"}], + ) + + snapshot.match("update-gateway-add-status-code", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[ + { + "op": "remove", + "path": "/statusCode", + } + ], + ) + + snapshot.match("update-gateway-remove-status-code", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_5XX", + patchOperations=[ + { + "op": "replace", + "path": "/responseParameters/gatewayresponse.header.Access-Control-Allow-Origin", + "value": "'example.com'", + } + ], + ) + + snapshot.match("update-gateway-replace-invalid-parameter", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[{"op": "add", "value": "'example.com'"}], + ) + + snapshot.match("update-gateway-no-path", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[ + {"op": "wrong-op", "path": "/statusCode", "value": "'example.com'"} + ], + ) + + snapshot.match("update-gateway-wrong-op", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[{"op": "add", "path": "/wrongPath", "value": "'example.com'"}], + ) + + snapshot.match("update-gateway-wrong-path", e.value.response) + + for index, path in enumerate( + ( + "/responseTemplates/application~1xml", + "/responseParameters/gatewayresponse.header.Access-Control-Allow-Origin", + ) + ): + with pytest.raises(ClientError) as e: + apigw_client.update_gateway_response( + restApiId=api_id, + responseType="DEFAULT_4XX", + patchOperations=[{"op": "replace", "path": path, "value": None}], + ) + + snapshot.match( + f"update-gateway-replace-invalid-parameter-{index}-none", e.value.response + ) + + +class TestApigatewayTestInvoke: + @markers.aws.validated + def test_invoke_test_method(self, create_rest_apigw, snapshot, aws_client): + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "latency" else None, "latency", replace_reference=False + ) + ) + # TODO: maybe transformer `log` better + snapshot.add_transformer( + snapshot.transform.key_value("log", "log", reference_replacement=False) + ) + + api_id, _, root = create_rest_apigw(name="aws lambda api") + + # Create the /pets resource + root_resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="pets" + ) + # Create the /pets/{petId} resource + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root_resource_id, pathPart="{petId}" + ) + # Create the GET method for /pets/{petId} + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + requestParameters={ + "method.request.path.petId": True, + }, + ) + # Create the POST method for /pets/{petId} + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + requestParameters={ + "method.request.path.petId": True, + }, + ) + # Create the response for method GET /pets/{petId} + create_rest_api_method_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + ) + # Create the response for method POST /pets/{petId} + create_rest_api_method_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + # Create the integration to connect GET /pets/{petId} to a backend + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + type="MOCK", + integrationHttpMethod="GET", + requestParameters={ + "integration.request.path.id": "method.request.path.petId", + }, + requestTemplates={"application/json": json.dumps({"statusCode": 200})}, + ) + # Create the integration to connect POST /pets/{petId} to a backend + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="MOCK", + integrationHttpMethod="POST", + requestParameters={ + "integration.request.path.id": "method.request.path.petId", + }, + requestTemplates={"application/json": json.dumps({"statusCode": 200})}, + ) + # Create the 200 integration response for GET /pets/{petId} + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + responseTemplates={"application/json": json.dumps({"petId": "$input.params('petId')"})}, + ) + # Create the 200 integration response for POST /pets/{petId} + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseTemplates={"application/json": json.dumps({"petId": "$input.params('petId')"})}, + ) + + def invoke_method(api_id, resource_id, path_with_query_string, method, body=""): + res = aws_client.apigateway.test_invoke_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod=method, + pathWithQueryString=path_with_query_string, + body=body, + ) + assert 200 == res.get("status") + return res + + response = retry( + invoke_method, + retries=10, + sleep=5, + api_id=api_id, + resource_id=resource_id, + path_with_query_string="/pets/123", + method="GET", + ) + assert "HTTP Method: GET, Resource Path: /pets/123" in response["log"] + snapshot.match("test-invoke-method-get", response) + + response = retry( + invoke_method, + retries=10, + sleep=5, + api_id=api_id, + resource_id=resource_id, + path_with_query_string="/pets/123?foo=bar", + method="GET", + ) + snapshot.match("test-invoke-method-get-with-qs", response) + + response = retry( + invoke_method, + retries=10, + sleep=5, + api_id=api_id, + resource_id=resource_id, + path_with_query_string="/pets/123", + method="POST", + body=json.dumps({"foo": "bar"}), + ) + assert "HTTP Method: POST, Resource Path: /pets/123" in response["log"] + snapshot.match("test-invoke-method-post-with-body", response) + + # assert resource and rest api doesn't exist + with pytest.raises(ClientError) as ex: + aws_client.apigateway.test_invoke_method( + restApiId=api_id, + resourceId="invalid_res", + httpMethod="POST", + pathWithQueryString="/pets/123", + body=json.dumps({"foo": "bar"}), + ) + snapshot.match("resource-id-not-found", ex.value.response) + assert ex.value.response["Error"]["Code"] == "NotFoundException" + + with pytest.raises(ClientError) as ex: + aws_client.apigateway.test_invoke_method( + restApiId=api_id, + resourceId="invalid_res", + httpMethod="POST", + pathWithQueryString="/pets/123", + body=json.dumps({"foo": "bar"}), + ) + snapshot.match("rest-api-not-found", ex.value.response) + assert ex.value.response["Error"]["Code"] == "NotFoundException" + + +class TestApigatewayIntegration: + @markers.aws.validated + def test_put_integration_wrong_type( + self, aws_client, apigw_create_rest_api, aws_client_factory, snapshot + ): + apigw_client = aws_client_factory(config=Config(parameter_validation=False)).apigateway + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test PutIntegration Types", + ) + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + with pytest.raises(ClientError) as e: + apigw_client.put_integration( + restApiId=api_id, resourceId=root_resource_id, httpMethod="GET", type="HTTPS_PROXY" + ) + snapshot.match("put-integration-wrong-type", e.value.response) + + @markers.aws.validated + def test_put_integration_response_validation( + self, aws_client, apigw_create_rest_api, aws_client_factory, snapshot + ): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", description="testing PutIntegrationResponse method exc" + ) + api_id = response["id"] + root_id = response["rootResourceId"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="POST", + authorizationType="NONE", + ) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + integrationHttpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + snapshot.match("put-integration-wrong-method", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId="badresource", + httpMethod="GET", + integrationHttpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + snapshot.match("put-integration-wrong-resource", e.value.response) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="POST", + integrationHttpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_id, + # put the integrationHttpMethod instead of the `httpMethod` should result in an error + httpMethod="GET", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": json.dumps({})}, + ) + + snapshot.match("put-integration-response-wrong-method", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId="badresource", + # put the integrationHttpMethod instead of the `httpMethod` should result in an error + httpMethod="GET", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": json.dumps({})}, + ) + + snapshot.match("put-integration-response-wrong-resource", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Validation behavior not yet implemented" + ) + def test_put_integration_request_parameter_bool_type( + self, aws_client, apigw_create_rest_api, aws_client_factory, snapshot + ): + apigw_client = aws_client_factory(config=Config(parameter_validation=False)).apigateway + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test PutIntegration RequestParam", + ) + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + bool_method = apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + requestParameters={ + "method.request.path.testPath": True, + }, + ) + snapshot.match("bool-method", bool_method) + + with pytest.raises(ClientError) as e: + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="POST", + authorizationType="NONE", + requestParameters={ + "method.request.path.testPath": True, + True: True, + }, + ) + snapshot.match("put-method-request-param-wrong-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="HTTP_PROXY", + requestParameters={ + True: True, + }, + ) + snapshot.match("put-integration-request-param-wrong-type", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="HTTP_PROXY", + requestParameters={ + "integration.request.path.testPath": True, + }, + ) + snapshot.match("put-integration-request-param-bool-value", e.value.response) + + @markers.aws.validated + def test_integration_response_wrong_api(self, aws_client, apigw_create_rest_api, snapshot): + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration_response( + restApiId="wrong-api", resourceId="dummy-value", httpMethod="GET", statusCode="200" + ) + snapshot.match("put-integration-response-wrong-api", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_integration_response( + restApiId="wrong-api", resourceId="dummy-value", httpMethod="GET", statusCode="200" + ) + snapshot.match("get-integration-response-wrong-api", e.value.response) + + @markers.aws.validated + def test_integration_response_wrong_resource(self, aws_client, apigw_create_rest_api, snapshot): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test Integration Resource", + ) + api_id = response["id"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration_response( + restApiId=api_id, resourceId="wrong-resource", httpMethod="GET", statusCode="200" + ) + snapshot.match("put-integration-response-wrong-resource", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_integration_response( + restApiId=api_id, resourceId="wrong-resource", httpMethod="GET", statusCode="200" + ) + snapshot.match("get-integration-response-wrong-resource", e.value.response) + + @markers.aws.validated + def test_integration_response_wrong_method(self, aws_client, apigw_create_rest_api, snapshot): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test Integration Method", + ) + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="wrong-method", + statusCode="200", + ) + snapshot.match("put-integration-response-wrong-method", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="wrong-method", + statusCode="200", + ) + snapshot.match("get-integration-response-wrong-method", e.value.response) + + @markers.aws.validated + def test_integration_response_invalid_statuscode( + self, aws_client, apigw_create_rest_api, snapshot + ): + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test Integration Invalid StatusCode", + ) + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="wrong", + responseTemplates={"application/json": '"created"'}, + selectionPattern="", + ) + snapshot.match("put-integration-response-invalid-statusCode", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.get_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="wrong", + ) + snapshot.match("get-integration-response-invalid-statusCode", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Validation not yet implemented in LocalStack" + ) + def test_integration_response_invalid_responsetemplates( + self, aws_client, aws_client_factory, apigw_create_rest_api, snapshot + ): + apigw_client = aws_client_factory(config=Config(parameter_validation=False)).apigateway + response = apigw_create_rest_api( + name=f"test-api-{short_uid()}", + description="APIGW test wrong api", + ) + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + integrationHttpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + with pytest.raises(ClientError) as e: + apigw_client.put_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": 123}, + ) + # TODO: AWS returns a SerializationException + # But LocalStack currently doesn't raise any Error + snapshot.match("put-integration-response-invalid-responseTemplates-1", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.put_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="", + responseTemplates={123: '{"statusCode": 200}'}, + ) + # TODO: AWS returns a BadRequestException + # But LocalStack currently doesn't raise any Error + snapshot.match("put-integration-response-invalid-responseTemplates-2", e.value.response) + + @markers.aws.validated + def test_integration_response_invalid_integration( + self, aws_client, apigw_create_rest_api, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + + with pytest.raises(ClientError) as e: + apigw_client.get_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + ) + snapshot.match("get-integration-response-without-integration", e.value.response) + + @markers.aws.validated + def test_integration_response_wrong_status_code( + self, aws_client, apigw_create_rest_api, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + with pytest.raises(ClientError) as e: + apigw_client.get_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="201", + ) + snapshot.match("get-integration-response-wrong-status-code", e.value.response) + + @markers.aws.validated + def test_lifecycle_integration_response(self, aws_client, apigw_create_rest_api, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + put_response = apigw_client.put_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + responseTemplates={"application/json": '"created"'}, + selectionPattern="", + ) + snapshot.match("put-integration-response", put_response) + + get_response = apigw_client.get_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + ) + snapshot.match("get-integration-response", get_response) + + update_response = apigw_client.update_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/selectionPattern", + "value": "updated-pattern", + } + ], + ) + snapshot.match("update-integration-response", update_response) + + overwrite_response = apigw_client.put_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + responseTemplates={"application/json": "overwrite"}, + selectionPattern="overwrite-pattern", + ) + snapshot.match("overwrite-integration-response", overwrite_response) + + get_method = apigw_client.get_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + ) + snapshot.match("get-method", get_method) + + delete_response = apigw_client.delete_integration_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + ) + snapshot.match("delete-integration-response", delete_response) + + @markers.aws.validated + def test_update_method_wrong_param_names(self, aws_client, apigw_create_rest_api, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + apigw_client.put_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + responseParameters={"method.response.header.my-header": False}, + responseModels={"application/json": "Empty"}, + ) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/responseParameters/wrong", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-operation-with-wrong-param-name-1", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "remove", + "path": "/responseParameters/wrong", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-operation-with-wrong-param-name-2", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/responseModels/wrong", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-response-operation-with-wrong-param-name-3", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "remove", + "path": "/responseModels/wrong", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-response-operation-with-wrong-param-name-4", e.value.response) + + @markers.aws.validated + def test_update_method_lack_response_parameters_and_models( + self, aws_client, apigw_create_rest_api, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + apigw_client.put_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + ) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "remove", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match( + "update-method-response-operation-without-response-parameters", e.value.response + ) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "remove", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-response-operation-without-response-models", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "remove", + "path": "/wrong/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-operation-with-wrong-path", e.value.response) + + @markers.aws.validated + def test_update_method_response_negative_tests( + self, aws_client, apigw_create_rest_api, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + apigw_client.put_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + responseParameters={"method.response.header.my-header": False}, + responseModels={"application/json": "Empty"}, + ) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="201", + patchOperations=[ + { + "op": "replace", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-wrong-statuscode", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="wrong", + patchOperations=[ + { + "op": "replace", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-invalid-statuscode", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="wrong", + patchOperations=[ + { + "op": "wrong_op", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-invalid-statuscode-and-wrong-op", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId="wrong", + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-wrong-resource", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="POST", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-wrong-method", e.value.response) + + @markers.aws.validated + def test_update_method_response_wrong_operations( + self, aws_client, apigw_create_rest_api, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + apigw_client.put_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + responseParameters={"method.response.header.my-header": False}, + responseModels={"application/json": "Empty"}, + ) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "wrong_op", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + ], + ) + snapshot.match("update-method-response-wrong-operation-1", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "wrong_op", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-response-wrong-operation-2", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "wrong_op", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "wrong_op", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-response-wrong-operation-3", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "wrong_op", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "wrong_op", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + { + "op": "wrong_op", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "wrong_op", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-response-wrong-operation-4", e.value.response) + + with pytest.raises(ClientError) as e: + apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "wrong_op", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "remove", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + { + "op": "wrong_op", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-response-wrong-operation-5", e.value.response) + + @markers.aws.validated + def test_update_method_response(self, aws_client, apigw_create_rest_api, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + apigw_client.put_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + responseParameters={"method.response.header.my-header": False}, + responseModels={"application/json": "Empty"}, + ) + + remove_update_response = apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "remove", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "remove", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("remove-update-method-response", remove_update_response) + + add_update_response = apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "add", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "add", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("add-update-method-response", add_update_response) + + @markers.aws.validated + def test_lifecycle_method_response(self, aws_client, apigw_create_rest_api, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + apigw_client = aws_client.apigateway + response = apigw_create_rest_api(name=f"test-api-{short_uid()}") + api_id = response["id"] + root_resource_id = response["rootResourceId"] + + apigw_client.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + put_method_response = apigw_client.put_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + responseParameters={}, + responseModels={}, + ) + snapshot.match("put-method-response", put_method_response) + + get_response = apigw_client.get_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + ) + snapshot.match("get-integration-response", get_response) + + add_responses = apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "add", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "add", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("add-method-responses", add_responses) + + update_responses = apigw_client.update_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/responseParameters/method.response.header.my-header", + "value": "true", + }, + { + "op": "replace", + "path": "/responseModels/application~1json", + "value": "Empty", + }, + ], + ) + snapshot.match("update-method-responses", update_responses) + + get_method = apigw_client.get_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + ) + snapshot.match("get-method", get_method) + + delete_response = apigw_client.delete_method_response( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="GET", + statusCode="200", + ) + snapshot.match("delete-method-response", delete_response) diff --git a/tests/aws/services/apigateway/test_apigateway_api.snapshot.json b/tests/aws/services/apigateway/test_apigateway_api.snapshot.json new file mode 100644 index 0000000000000..c47cd7a9b880b --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_api.snapshot.json @@ -0,0 +1,4596 @@ +{ + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_list_and_delete_apis": { + "recorded-date": "02-07-2025, 13:13:23", + "recorded-content": { + "create-rest-api-1": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-rest-api-2": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api2", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-rest-api-before-delete": { + "items": [ + { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "" + }, + { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api2", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-rest-api": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get-rest-api-after-delete": { + "items": [ + { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api2", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_get_api_case_insensitive": { + "recorded-date": "15-04-2024, 15:10:18", + "recorded-content": { + "create-rest-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "lower case api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-api-upper-case": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:" + }, + "message": "Invalid API identifier specified 111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_optional_params": { + "recorded-date": "02-07-2025, 13:25:33", + "recorded-content": { + "create-only-name": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-empty-desc": { + "Error": { + "Code": "BadRequestException", + "Message": "Description cannot be an empty string" + }, + "message": "Description cannot be an empty string", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-version": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "version": "v1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-with-empty-binary-media": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "string-compression-size": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid minimum compression size, must be between 0 and 10485760" + }, + "message": "Invalid minimum compression size, must be between 0 and 10485760", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_tags": { + "recorded-date": "02-07-2025, 13:17:34", + "recorded-content": { + "create-rest-api-w-tags": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "MY_TAG1": "MY_VALUE1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-rest-api-w-tags": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "MY_TAG1": "MY_VALUE1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-rest-apis-w-tags": { + "items": [ + { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "MY_TAG1": "MY_VALUE1" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_operation_add_remove": { + "recorded-date": "02-07-2025, 13:18:05", + "recorded-content": { + "update-rest-api-add": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/png", + "image/jpeg" + ], + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-rest-api-replace": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/jpeg", + "image/gif" + ], + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-rest-api-remove": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/jpeg" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_compression": { + "recorded-date": "02-07-2025, 13:18:40", + "recorded-content": { + "enable-compression": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "minimumCompressionSize": 10, + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "disable-compression": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-compression-zero": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "minimumCompressionSize": 0, + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-negative-compression": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid minimum compression size, must be between 0 and 10485760" + }, + "message": "Invalid minimum compression size, must be between 0 and 10485760", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "set-string-compression": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid minimum compression size, must be between 0 and 10485760" + }, + "message": "Invalid minimum compression size, must be between 0 and 10485760", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "unsupported-operation": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch operation specified. Must be one of: [replace]" + }, + "message": "Invalid patch operation specified. Must be one of: [replace]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_behaviour": { + "recorded-date": "02-07-2025, 13:18:55", + "recorded-content": { + "update-rest-api-array": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "-" + ], + "createdDate": "datetime", + "description": "this is my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-rest-api-add-base-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /binaryMediaTypes" + }, + "message": "Invalid patch path /binaryMediaTypes", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-replace-base-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /binaryMediaTypes" + }, + "message": "Invalid patch path /binaryMediaTypes", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-remove-base-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /binaryMediaTypes" + }, + "message": "Invalid patch path /binaryMediaTypes", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_invalid_api_id": { + "recorded-date": "02-07-2025, 13:19:07", + "recorded-content": { + "not-found-update-rest-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:api_id" + }, + "message": "Invalid API identifier specified 111111111111:api_id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_resource_lifecycle": { + "recorded-date": "15-04-2024, 17:29:03", + "recorded-content": { + "rest-api-root-resource": { + "items": [ + { + "id": "", + "path": "/" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-resource": { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "rest-api-resources-after-create": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-subresource": { + "id": "", + "parentId": "", + "path": "/pets/subpets", + "pathPart": "subpets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "rest-api-resources-after-create-sub": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets" + }, + { + "id": "", + "parentId": "", + "path": "/pets/subpets", + "pathPart": "subpets" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-path-part": { + "id": "", + "parentId": "", + "path": "/dogs", + "pathPart": "dogs", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resp-after-update-path-part": { + "id": "", + "parentId": "", + "path": "/dogs", + "pathPart": "dogs", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "rest-api-resources-after-delete": { + "items": [ + { + "id": "", + "path": "/" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_update_resource_behaviour": { + "recorded-date": "15-04-2024, 17:29:39", + "recorded-content": { + "nonexistent-resource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "invalid-path-part": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/invalid' specified for op 'replace'. Must be one of: [/parentId, /pathPart]" + }, + "message": "Invalid patch path '/invalid' specified for op 'replace'. Must be one of: [/parentId, /pathPart]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-parent-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create-subresource": { + "id": "", + "parentId": "", + "path": "/pets/subpets", + "pathPart": "subpets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-subresource-child": { + "id": "", + "parentId": "", + "path": "/pets/subpets/pets", + "pathPart": "pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "existing-future-sibling-path": { + "Error": { + "Code": "ConflictException", + "Message": "Another resource with the same parent already has this name: pets" + }, + "message": "Another resource with the same parent already has this name: pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "update-parent-id-to-subresource-id": { + "Error": { + "Code": "BadRequestException", + "Message": "Resources cannot be cyclical." + }, + "message": "Resources cannot be cyclical.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-parent-id-to-root-id": { + "id": "", + "parentId": "", + "path": "/subpets", + "pathPart": "subpets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-path-already-exists": { + "Error": { + "Code": "ConflictException", + "Message": "Another resource with the same parent already has this name: pets" + }, + "message": "Another resource with the same parent already has this name: pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "resources-after-deletion": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/subpets", + "pathPart": "subpets" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-unsupported": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/pathPart' specified for op 'remove'. Please choose supported operations" + }, + "message": "Invalid patch path '/pathPart' specified for op 'remove'. Please choose supported operations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add-unsupported": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/pathPart' specified for op 'add'. Please choose supported operations" + }, + "message": "Invalid patch path '/pathPart' specified for op 'add'. Please choose supported operations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_delete_resource": { + "recorded-date": "15-04-2024, 17:30:24", + "recorded-content": { + "delete-resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get-resources": { + "items": [ + { + "id": "", + "path": "/" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-subresource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_resource_parent_invalid": { + "recorded-date": "15-04-2024, 17:31:16", + "recorded-content": { + "wrong-resource-parent-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_proxy_resource": { + "recorded-date": "15-04-2024, 17:31:31", + "recorded-content": { + "create-base-proxy-resource": { + "id": "", + "parentId": "", + "path": "/{proxy+}", + "pathPart": "{proxy+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-proxy-sibling-resource": "", + "create-proxy-sibling-proxy-child-resource": { + "id": "", + "parentId": "", + "path": "/parent/{proxy+}", + "pathPart": "{proxy+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-proxy-sibling-static-child-resource": { + "id": "", + "parentId": "", + "path": "/parent/child", + "pathPart": "child", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-static-child-proxy-resource": { + "id": "", + "parentId": "", + "path": "/parent/child/{proxy+}", + "pathPart": "{proxy+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "all-resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/parent", + "pathPart": "parent" + }, + { + "id": "", + "parentId": "", + "path": "/parent/child", + "pathPart": "child" + }, + { + "id": "", + "parentId": "", + "path": "/parent/child/{proxy+}", + "pathPart": "{proxy+}" + }, + { + "id": "", + "parentId": "", + "path": "/parent/{proxy+}", + "pathPart": "{proxy+}" + }, + { + "id": "", + "parentId": "", + "path": "/{proxy+}", + "pathPart": "{proxy+}" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-proxy-sibling-dynamic-child-resource": { + "id": "", + "parentId": "", + "path": "/parent/{child}", + "pathPart": "{child}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-dynamic-child-proxy-resource": { + "id": "", + "parentId": "", + "path": "/parent/{child}/{proxy+}", + "pathPart": "{proxy+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "all-resources-2": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/parent", + "pathPart": "parent" + }, + { + "id": "", + "parentId": "", + "path": "/parent/child", + "pathPart": "child" + }, + { + "id": "", + "parentId": "", + "path": "/parent/child/{proxy+}", + "pathPart": "{proxy+}" + }, + { + "id": "", + "parentId": "", + "path": "/parent/{child}", + "pathPart": "{child}" + }, + { + "id": "", + "parentId": "", + "path": "/parent/{child}/{proxy+}", + "pathPart": "{proxy+}" + }, + { + "id": "", + "parentId": "", + "path": "/{proxy+}", + "pathPart": "{proxy+}" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_proxy_resource_validation": { + "recorded-date": "15-04-2024, 17:32:33", + "recorded-content": { + "create-base-proxy-resource": { + "id": "", + "parentId": "", + "path": "/{proxy+}", + "pathPart": "{proxy+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-proxy-dynamic-sibling-resource": { + "Error": { + "Code": "BadRequestException", + "Message": "A sibling ({proxy+}) of this resource already has a variable path part -- only one is allowed" + }, + "message": "A sibling ({proxy+}) of this resource already has a variable path part -- only one is allowed", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-proxy-static-child-resource": { + "Error": { + "Code": "BadRequestException", + "Message": "Cannot create a child of a resource with a greedy path variable: {proxy+}" + }, + "message": "Cannot create a child of a resource with a greedy path variable: {proxy+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-proxy-dynamic-child-resource": { + "Error": { + "Code": "BadRequestException", + "Message": "Cannot create a child of a resource with a greedy path variable: {proxy+}" + }, + "message": "Cannot create a child of a resource with a greedy path variable: {proxy+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-greedy-child-resource": { + "id": "", + "parentId": "", + "path": "/parent/{child+}", + "pathPart": "{child+}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiAuthorizer::test_authorizer_crud_no_api": { + "recorded-date": "15-04-2024, 18:43:45", + "recorded-content": { + "wrong-rest-api-id-create-authorizer": { + "Error": { + "Code": "ConflictException", + "Message": "Unable to complete operation due to concurrent modification. Please try again later." + }, + "message": "Unable to complete operation due to concurrent modification. Please try again later.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "wrong-rest-api-id-get-authorizers": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:test-fake-rest-id" + }, + "message": "Invalid API identifier specified 111111111111:test-fake-rest-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_method_lifecycle": { + "recorded-date": "15-04-2024, 21:22:46", + "recorded-content": { + "put-base-method-response": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-base-method-response": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-base-method-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-deleted-method-response": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-deleted-method-response": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_method_request_parameters": { + "recorded-date": "15-04-2024, 21:23:23", + "recorded-content": { + "put-method-request-params-response": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestParameters": { + "method.request.header.h_optional": false, + "method.request.header.h_required": true, + "method.request.querystring.q_optional": false, + "method.request.querystring.q_required": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-method-request-params-response": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestParameters": { + "method.request.header.h_optional": false, + "method.request.header.h_required": true, + "method.request.querystring.q_optional": false, + "method.request.querystring.q_required": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "req-params-same-name": { + "Error": { + "Code": "BadRequestException", + "Message": "Parameter names must be unique across querystring, header and path" + }, + "message": "Parameter names must be unique across querystring, header and path", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_put_method_model": { + "recorded-date": "15-04-2024, 21:23:48", + "recorded-content": { + "create-model": { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "title": "", + "type": "object" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-model-2": { + "contentType": "application/json", + "id": "", + "name": "Two", + "schema": { + "title": "Two", + "type": "object" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-request-models": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestModels": { + "application/json": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "delete-model-used": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model '', is referenced in method request: //ANY" + }, + "message": "Cannot delete model '', is referenced in method request: //ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "update-method-model": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestModels": { + "application/json": "Two" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-model-unused": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "delete-model-used-2": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model 'Two', is referenced in method request: //ANY" + }, + "message": "Cannot delete model 'Two', is referenced in method request: //ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "put-method-2-request-models": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestModels": { + "application/json": "Two" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "delete-model-used-by-2-method": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model 'Two', is referenced in method request: /test/ANY" + }, + "message": "Cannot delete model 'Two', is referenced in method request: /test/ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "update-method-model-2": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-model-used-by-method-1": { + "Error": { + "Code": "ConflictException", + "Message": "Cannot delete model 'Two', is referenced in method request: //ANY" + }, + "message": "Cannot delete model 'Two', is referenced in method request: //ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "delete-method-using-model-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-model-unused-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_put_method_validation": { + "recorded-date": "15-04-2024, 21:24:40", + "recorded-content": { + "wrong-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "wrong-resource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "wrong-method": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid HttpMethod specified. Valid options are GET,PUT,POST,DELETE,PATCH,OPTIONS,HEAD,ANY" + }, + "message": "Invalid HttpMethod specified. Valid options are GET,PUT,POST,DELETE,PATCH,OPTIONS,HEAD,ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing-authorizer-id": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid authorizer ID specified. Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer." + }, + "message": "Invalid authorizer ID specified. Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-request-validator": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid Request Validator identifier specified" + }, + "message": "Invalid Request Validator identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-model-name": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid model identifier specified: petModel" + }, + "message": "Invalid model identifier specified: petModel", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_update_method": { + "recorded-date": "15-04-2024, 21:26:26", + "recorded-content": { + "put-method-response": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-method-add": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "requestModels": { + "application/json": "Empty" + }, + "requestParameters": { + "method.request.querystring.optional": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-method-replace": { + "apiKeyRequired": true, + "authorizationType": "AWS_IAM", + "httpMethod": "ANY", + "operationName": "ReplacedOperationName", + "requestModels": { + "application/json": "Empty" + }, + "requestParameters": { + "method.request.querystring.optional": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-method-replace-authorizer": { + "apiKeyRequired": true, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "ANY", + "operationName": "ReplacedOperationName", + "requestModels": { + "application/json": "Empty" + }, + "requestParameters": { + "method.request.querystring.optional": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-method-remove": { + "apiKeyRequired": true, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "ANY", + "operationName": "ReplacedOperationName", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_update_method_validation": { + "recorded-date": "15-04-2024, 21:26:43", + "recorded-content": { + "wrong-rest-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "wrong-resource-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "method-does-not-exist": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-method-response": { + "apiKeyRequired": true, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "unsupported-operation": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch operation specified. Must be one of: [replace]" + }, + "message": "Invalid patch operation specified. Must be one of: [replace]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "unsupported-operation-2": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch operation specified. Must be one of: [replace]" + }, + "message": "Invalid patch operation specified. Must be one of: [replace]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "unsupported-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /httpMethod" + }, + "message": "Invalid patch path /httpMethod", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-path-request-parameters": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /requestParameters" + }, + "message": "Invalid patch path /requestParameters", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-path-request-models": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /requestModels/application/json" + }, + "message": "Invalid patch path /requestModels/application/json", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-value-type": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "wrong-auth-type": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid authorizer ID specified. Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer." + }, + "message": "Invalid authorizer ID specified. Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "skip-auth-id-with-wrong-type": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "wrong-auth-id": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid authorizer ID specified. Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer." + }, + "message": "Invalid authorizer ID specified. Setting the authorization type to CUSTOM or COGNITO_USER_POOLS requires a valid authorizer.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-req-validator-id": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid Request Validator identifier specified" + }, + "message": "Invalid Request Validator identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_model_lifecycle": { + "recorded-date": "15-04-2024, 21:02:34", + "recorded-content": { + "create-model": { + "contentType": "application/json", + "description": "Calc output model", + "id": "", + "name": "", + "schema": { + "title": "Calc output", + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + }, + "op": { + "description": "operation of +, -, * or /", + "type": "string" + }, + "c": { + "type": "number" + } + }, + "required": [ + "a", + "b", + "op" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "description": "Calc output model", + "id": "", + "name": "", + "schema": { + "title": "Calc output", + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + }, + "op": { + "description": "operation of +, -, * or /", + "type": "string" + }, + "c": { + "type": "number" + } + }, + "required": [ + "a", + "b", + "op" + ] + } + }, + { + "contentType": "application/json", + "description": "This is a default empty schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "This is a default error schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-model": { + "contentType": "application/json", + "description": "Calc output model", + "id": "", + "name": "", + "schema": { + "title": "Calc output", + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + }, + "op": { + "description": "operation of +, -, * or /", + "type": "string" + }, + "c": { + "type": "number" + } + }, + "required": [ + "a", + "b", + "op" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-model": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_model_validation": { + "recorded-date": "15-04-2024, 20:33:53", + "recorded-content": { + "create-model-wrong-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:abcde0" + }, + "message": "Invalid API identifier specified 111111111111:abcde0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-models-wrong-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:abcde0" + }, + "message": "Invalid API identifier specified 111111111111:abcde0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-model-wrong-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid model name specified: MySchema" + }, + "message": "Invalid model name specified: MySchema", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "del-model-wrong-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid model name specified: MySchema" + }, + "message": "Invalid model name specified: MySchema", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create-model-already-exists": { + "Error": { + "Code": "ConflictException", + "Message": "Model name already exists for this REST API" + }, + "message": "Model name already exists for this REST API", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "create-model-empty-name": { + "Error": { + "Code": "BadRequestException", + "Message": "Model name must be non-empty" + }, + "message": "Model name must be non-empty", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-model-empty-schema": { + "Error": { + "Code": "BadRequestException", + "Message": "Model schema must have at least 1 property or array items defined" + }, + "message": "Model schema must have at least 1 property or array items defined", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-model-no-schema-json": { + "Error": { + "Code": "BadRequestException", + "Message": "Model schema must have at least 1 property or array items defined" + }, + "message": "Model schema must have at least 1 property or array items defined", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-model-no-schema-xml": { + "Error": { + "Code": "BadRequestException", + "Message": "Model schema must have at least 1 property or array items defined" + }, + "message": "Model schema must have at least 1 property or array items defined", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_update_model": { + "recorded-date": "15-04-2024, 20:34:09", + "recorded-content": { + "update-model-wrong-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid model name specified: mySchema" + }, + "message": "Invalid model name specified: mySchema", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create-model": { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "title": "", + "type": "object" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-model": { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "title": "Updated schema", + "type": "object" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-model-invalid-op": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/wrong-path' specified for op 'add'. Please choose supported operations" + }, + "message": "Invalid patch path '/wrong-path' specified for op 'add'. Please choose supported operations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-model-invalid-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/name' specified for op 'replace'. Must be one of: [/description, /schema]" + }, + "message": "Invalid patch path '/name' specified for op 'replace'. Must be one of: [/description, /schema]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-model-empty-schema": { + "Error": { + "Code": "BadRequestException", + "Message": "Model schema must have at least 1 property or array items defined" + }, + "message": "Model schema must have at least 1 property or array items defined", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_validators_crud_no_api": { + "recorded-date": "15-04-2024, 20:44:19", + "recorded-content": { + "wrong-rest-api-id-create-validator": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid REST API identifier specified" + }, + "message": "Invalid REST API identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-rest-api-id-get-validators": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:test-fake-rest-id" + }, + "message": "Invalid API identifier specified 111111111111:test-fake-rest-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_request_validator_lifecycle": { + "recorded-date": "02-07-2025, 15:14:17", + "recorded-content": { + "create-rest-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-request-validator": { + "id": "", + "name": "", + "validateRequestBody": false, + "validateRequestParameters": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-request-validator": { + "id": "", + "name": "", + "validateRequestBody": false, + "validateRequestParameters": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-request-validators": { + "items": [ + { + "id": "", + "name": "", + "validateRequestBody": false, + "validateRequestParameters": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-request-validator-with-value": { + "id": "", + "name": "", + "validateRequestBody": true, + "validateRequestParameters": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-request-validator-without-value": { + "id": "", + "name": "", + "validateRequestBody": false, + "validateRequestParameters": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-request-validators-after-update-operation": { + "id": "", + "name": "", + "validateRequestBody": false, + "validateRequestParameters": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-request-validator": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get-deleted-request-validator": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Request Validator identifier specified" + }, + "message": "Invalid Request Validator identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-request-validators-after-delete": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_get_request_validator": { + "recorded-date": "15-04-2024, 20:44:55", + "recorded-content": { + "get-request-validators-invalid-api-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Request Validator identifier specified" + }, + "message": "Invalid Request Validator identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-request-validators-invalid-validator-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Request Validator identifier specified" + }, + "message": "Invalid Request Validator identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_get_request_validators": { + "recorded-date": "15-04-2024, 20:44:55", + "recorded-content": { + "get-invalid-request-validators": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:api_id" + }, + "message": "Invalid API identifier specified 111111111111:api_id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_delete_request_validator": { + "recorded-date": "15-04-2024, 20:45:24", + "recorded-content": { + "delete-request-validator-invalid-api-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Request Validator identifier specified" + }, + "message": "Invalid Request Validator identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-request-validator-invalid-validator-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Request Validator identifier specified" + }, + "message": "Invalid Request Validator identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_create_request_validator_invalid_api_id": { + "recorded-date": "15-04-2024, 20:45:24", + "recorded-content": { + "invalid-create-request-validator": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid REST API identifier specified" + }, + "message": "Invalid REST API identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_update_request_validator_operations": { + "recorded-date": "02-07-2025, 15:15:40", + "recorded-content": { + "create-rest-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "my api", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-request-validator": { + "id": "", + "name": "", + "validateRequestBody": false, + "validateRequestParameters": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-request-validator-invalid-add-operation": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/validateRequestBody' specified for op 'add'. Please choose supported operations" + }, + "message": "Invalid patch path '/validateRequestBody' specified for op 'add'. Please choose supported operations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-request-validator-invalid-remove-operation": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/validateRequestBody' specified for op 'remove'. Please choose supported operations" + }, + "message": "Invalid patch path '/validateRequestBody' specified for op 'remove'. Please choose supported operations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-request-validator-invalid-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/invalidPath' specified for op 'replace'. Must be one of: [/name, /validateRequestParameters, /validateRequestBody]" + }, + "message": "Invalid patch path '/invalidPath' specified for op 'replace'. Must be one of: [/name, /validateRequestParameters, /validateRequestBody]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-request-validator-empty-name-value": { + "Error": { + "Code": "BadRequestException", + "Message": "Request Validator name cannot be blank" + }, + "message": "Request Validator name cannot be blank", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_crud": { + "recorded-date": "15-04-2024, 20:46:20", + "recorded-content": { + "get-gateway-response-default": { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "403", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-gateway-response": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.x-request-query": "method.request.querystring.q" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "404", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-gateway-responses": { + "items": [ + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "ACCESS_DENIED", + "statusCode": "403" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "API_CONFIGURATION_ERROR", + "statusCode": "500" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "AUTHORIZER_CONFIGURATION_ERROR", + "statusCode": "500" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "AUTHORIZER_FAILURE", + "statusCode": "500" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "BAD_REQUEST_BODY", + "statusCode": "400" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "BAD_REQUEST_PARAMETERS", + "statusCode": "400" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "DEFAULT_4XX" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "DEFAULT_5XX" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "EXPIRED_TOKEN", + "statusCode": "403" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "INTEGRATION_FAILURE", + "statusCode": "504" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "INTEGRATION_TIMEOUT", + "statusCode": "504" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "INVALID_API_KEY", + "statusCode": "403" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "INVALID_SIGNATURE", + "statusCode": "403" + }, + { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.x-request-query": "method.request.querystring.q" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "404" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "QUOTA_EXCEEDED", + "statusCode": "429" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "REQUEST_TOO_LARGE", + "statusCode": "413" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "RESOURCE_NOT_FOUND", + "statusCode": "404" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "THROTTLED", + "statusCode": "429" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "UNAUTHORIZED", + "statusCode": "401" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "UNSUPPORTED_MEDIA_TYPE", + "statusCode": "415" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "WAF_FILTERED", + "statusCode": "403" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-gateway-response": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.x-request-query": "method.request.querystring.q" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "404", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-gateway-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get-deleted-gw-response": { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "403", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_validation": { + "recorded-date": "15-04-2024, 20:47:08", + "recorded-content": { + "get-gateway-responses-no-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:fake-api-id" + }, + "message": "Invalid API identifier specified 111111111111:fake-api-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-gateway-response-no-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:fake-api-id" + }, + "message": "Invalid API identifier specified 111111111111:fake-api-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-gateway-response-no-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:fake-api-id" + }, + "message": "Invalid API identifier specified 111111111111:fake-api-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-gateway-response-no-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:fake-api-id" + }, + "message": "Invalid API identifier specified 111111111111:fake-api-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-gateway-response-not-set": { + "Error": { + "Code": "NotFoundException", + "Message": "Gateway response type not defined on api" + }, + "message": "Gateway response type not defined on api", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-gateway-response-wrong-response-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'FAKE_RESPONSE_TYPE' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [REQUEST_TOO_LARGE, RESOURCE_NOT_FOUND, AUTHORIZER_CONFIGURATION_ERROR, MISSING_AUTHENTICATION_TOKEN, BAD_REQUEST_BODY, INVALID_SIGNATURE, INVALID_API_KEY, BAD_REQUEST_PARAMETERS, AUTHORIZER_FAILURE, UNAUTHORIZED, INTEGRATION_TIMEOUT, ACCESS_DENIED, DEFAULT_4XX, DEFAULT_5XX, WAF_FILTERED, QUOTA_EXCEEDED, THROTTLED, API_CONFIGURATION_ERROR, UNSUPPORTED_MEDIA_TYPE, INTEGRATION_FAILURE, EXPIRED_TOKEN]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-gateway-response-wrong-response-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'FAKE_RESPONSE_TYPE' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [REQUEST_TOO_LARGE, RESOURCE_NOT_FOUND, AUTHORIZER_CONFIGURATION_ERROR, MISSING_AUTHENTICATION_TOKEN, BAD_REQUEST_BODY, INVALID_SIGNATURE, INVALID_API_KEY, BAD_REQUEST_PARAMETERS, AUTHORIZER_FAILURE, UNAUTHORIZED, INTEGRATION_TIMEOUT, ACCESS_DENIED, DEFAULT_4XX, DEFAULT_5XX, WAF_FILTERED, QUOTA_EXCEEDED, THROTTLED, API_CONFIGURATION_ERROR, UNSUPPORTED_MEDIA_TYPE, INTEGRATION_FAILURE, EXPIRED_TOKEN]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-gateway-response-wrong-response-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'FAKE_RESPONSE_TYPE' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [REQUEST_TOO_LARGE, RESOURCE_NOT_FOUND, AUTHORIZER_CONFIGURATION_ERROR, MISSING_AUTHENTICATION_TOKEN, BAD_REQUEST_BODY, INVALID_SIGNATURE, INVALID_API_KEY, BAD_REQUEST_PARAMETERS, AUTHORIZER_FAILURE, UNAUTHORIZED, INTEGRATION_TIMEOUT, ACCESS_DENIED, DEFAULT_4XX, DEFAULT_5XX, WAF_FILTERED, QUOTA_EXCEEDED, THROTTLED, API_CONFIGURATION_ERROR, UNSUPPORTED_MEDIA_TYPE, INTEGRATION_FAILURE, EXPIRED_TOKEN]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-gateway-response-wrong-response-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'FAKE_RESPONSE_TYPE' at 'responseType' failed to satisfy constraint: Member must satisfy enum value set: [REQUEST_TOO_LARGE, RESOURCE_NOT_FOUND, AUTHORIZER_CONFIGURATION_ERROR, MISSING_AUTHENTICATION_TOKEN, BAD_REQUEST_BODY, INVALID_SIGNATURE, INVALID_API_KEY, BAD_REQUEST_PARAMETERS, AUTHORIZER_FAILURE, UNAUTHORIZED, INTEGRATION_TIMEOUT, ACCESS_DENIED, DEFAULT_4XX, DEFAULT_5XX, WAF_FILTERED, QUOTA_EXCEEDED, THROTTLED, API_CONFIGURATION_ERROR, UNSUPPORTED_MEDIA_TYPE, INTEGRATION_FAILURE, EXPIRED_TOKEN]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_update_gateway_response": { + "recorded-date": "15-04-2024, 20:47:51", + "recorded-content": { + "update-gateway-response-not-set": { + "defaultResponse": false, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "DEFAULT_4XX", + "statusCode": "444", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "default-get-gateway-response": { + "defaultResponse": false, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "DEFAULT_4XX", + "statusCode": "444", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-gateway-response": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.x-request-query": "method.request.querystring.q" + }, + "responseTemplates": { + "application/json": { + "application/json": "{\"message\":$context.error.messageString}" + } + }, + "responseType": "DEFAULT_4XX", + "statusCode": "404", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-gateway-response": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'example.com'", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.x-request-query": "method.request.querystring.q" + }, + "responseTemplates": { + "application/json": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "application/xml": "$context.error.messageString$context.error.responseType" + }, + "responseType": "DEFAULT_4XX", + "statusCode": "444", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-gateway-response": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'example.com'", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.x-request-query": "method.request.querystring.q" + }, + "responseTemplates": { + "application/json": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "application/xml": "$context.error.messageString$context.error.responseType" + }, + "responseType": "DEFAULT_4XX", + "statusCode": "444", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-gateway-add-status-code": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /statusCode" + }, + "message": "Invalid patch path /statusCode", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-gateway-remove-status-code": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /statusCode" + }, + "message": "Invalid patch path /statusCode", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-gateway-replace-invalid-parameter": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid parameter name specified" + }, + "message": "Invalid parameter name specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-gateway-no-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path null" + }, + "message": "Invalid patch path null", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-gateway-wrong-op": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong-op' at 'updateGatewayResponseInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-gateway-wrong-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /wrongPath" + }, + "message": "Invalid patch path /wrongPath", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-gateway-replace-invalid-parameter-0-none": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid null or empty value in responseTemplates" + }, + "message": "Invalid null or empty value in responseTemplates", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-gateway-replace-invalid-parameter-1-none": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid null or empty value in responseParameters" + }, + "message": "Invalid null or empty value in responseParameters", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayTestInvoke::test_invoke_test_method": { + "recorded-date": "15-04-2024, 20:48:35", + "recorded-content": { + "test-invoke-method-get": { + "body": { + "petId": "123" + }, + "headers": { + "Content-Type": "application/json" + }, + "latency": "latency", + "log": "log", + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ] + }, + "status": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "test-invoke-method-get-with-qs": { + "body": { + "petId": "123" + }, + "headers": { + "Content-Type": "application/json" + }, + "latency": "latency", + "log": "log", + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ] + }, + "status": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "test-invoke-method-post-with-body": { + "body": { + "petId": "123" + }, + "headers": { + "Content-Type": "application/json" + }, + "latency": "latency", + "log": "log", + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ] + }, + "status": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resource-id-not-found": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "rest-api-not-found": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_wrong_type": { + "recorded-date": "15-04-2024, 20:49:25", + "recorded-content": { + "put-integration-wrong-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'HTTPS_PROXY' at 'putIntegrationInput.type' failed to satisfy constraint: Member must satisfy enum value set: [HTTP, MOCK, AWS_PROXY, HTTP_PROXY, AWS]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_doc_parts_crud_no_api": { + "recorded-date": "15-04-2024, 20:52:33", + "recorded-content": { + "wrong-rest-api-id-create-doc-part": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:test-fake-rest-id" + }, + "message": "Invalid API identifier specified 111111111111:test-fake-rest-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "wrong-rest-api-id-get-doc-parts": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:test-fake-rest-id" + }, + "message": "Invalid API identifier specified 111111111111:test-fake-rest-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_documentation_part_lifecycle": { + "recorded-date": "15-04-2024, 20:52:35", + "recorded-content": { + "create-documentation-part": { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "description": "Sample API description" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-documentation-part": { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "description": "Sample API description" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-documentation-parts": { + "items": [ + { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "description": "Sample API description" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-documentation-part": { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "description": "Updated Sample API description" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-documentation-part-after-update": { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "description": "Updated Sample API description" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_documentation_part": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_get_documentation_part": { + "recorded-date": "15-04-2024, 20:53:15", + "recorded-content": { + "get-documentation-part-invalid-api-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Documentation part identifier specified" + }, + "message": "Invalid Documentation part identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-documentation-part-invalid-doc-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Documentation part identifier specified" + }, + "message": "Invalid Documentation part identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_get_documentation_parts": { + "recorded-date": "15-04-2024, 20:53:15", + "recorded-content": { + "get-inavlid-documentation-parts": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:api_id" + }, + "message": "Invalid API identifier specified 111111111111:api_id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_update_documentation_part": { + "recorded-date": "15-04-2024, 20:53:47", + "recorded-content": { + "update-documentation-part-invalid-api-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Documentation part identifier specified" + }, + "message": "Invalid Documentation part identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-documentation-part-invalid-add-operation": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/properties' specified for op 'add'. Please choose supported operations" + }, + "message": "Invalid patch path '/properties' specified for op 'add'. Please choose supported operations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-documentation-part-invalid-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path '/invalidPath' specified for op 'replace'. Must be one of: [/properties]" + }, + "message": "Invalid patch path '/invalidPath' specified for op 'replace'. Must be one of: [/properties]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_create_documentation_part_operations": { + "recorded-date": "15-04-2024, 20:54:19", + "recorded-content": { + "create_documentation_part_invalid_api_id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:api_id" + }, + "message": "Invalid API identifier specified 111111111111:api_id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create_documentation_part_invalid_location_type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'INVALID' at 'createDocumentationPartInput.location.type' failed to satisfy constraint: Member must satisfy enum value set: [RESPONSE_BODY, RESPONSE, METHOD, MODEL, AUTHORIZER, RESPONSE_HEADER, RESOURCE, PATH_PARAMETER, REQUEST_BODY, QUERY_PARAMETER, API, REQUEST_HEADER]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_delete_documentation_part": { + "recorded-date": "15-04-2024, 20:54:32", + "recorded-content": { + "delete_documentation_part_wrong_api_id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API identifier specified 111111111111:api_id" + }, + "message": "Invalid API identifier specified 111111111111:api_id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_documentation_part": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "delete_already_deleted_documentation_part": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Documentation part identifier specified" + }, + "message": "Invalid Documentation part identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_import_documentation_parts": { + "recorded-date": "15-04-2024, 20:57:15", + "recorded-content": { + "create-import-documentations_parts": [ + { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "description": "API description", + "info": { + "description": "API info description 4", + "version": "API info version 3" + } + } + }, + { + "id": "", + "location": { + "type": "METHOD", + "path": "/", + "method": "GET" + }, + "properties": { + "description": "Method description." + } + }, + { + "id": "", + "location": { + "type": "MODEL", + "name": "" + }, + "properties": { + "title": " Schema" + } + }, + { + "id": "", + "location": { + "type": "RESPONSE", + "path": "/", + "method": "GET", + "statusCode": "200" + }, + "properties": { + "description": "200 response" + } + } + ], + "import-documentation-parts": { + "ids": [ + "", + "", + "", + "" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_put": { + "recorded-date": "20-06-2024, 22:01:45", + "recorded-content": { + "put-gateway-response-all-value": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.Access-Control-Allow-Origin": "'a.b.c'", + "gatewayresponse.header.x-request-header": "method.request.header.Accept", + "gatewayresponse.header.x-request-path": "method.request.path.petId", + "gatewayresponse.header.x-request-query": "method.request.querystring.q" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "404", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-gateway-response-status-only": { + "defaultResponse": false, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "404", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-gateway-response-response-parameters-only": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "403", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-gateway-response-response-templates-only": { + "defaultResponse": false, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "403", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-gateway-response-default-5xx": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "DEFAULT_5XX", + "statusCode": "599", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-gateway-response-default-ignored": { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.foo": "'bar'" + }, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "AUTHORIZER_FAILURE", + "statusCode": "500", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-gateway-responses": { + "items": [ + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "ACCESS_DENIED", + "statusCode": "403" + }, + { + "defaultResponse": true, + "responseParameters": { + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "API_CONFIGURATION_ERROR", + "statusCode": "599" + }, + { + "defaultResponse": true, + "responseParameters": { + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "AUTHORIZER_CONFIGURATION_ERROR", + "statusCode": "599" + }, + { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.foo": "'bar'" + }, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "AUTHORIZER_FAILURE", + "statusCode": "500" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "BAD_REQUEST_BODY", + "statusCode": "400" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "BAD_REQUEST_PARAMETERS", + "statusCode": "400" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "DEFAULT_4XX" + }, + { + "defaultResponse": false, + "responseParameters": { + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "DEFAULT_5XX", + "statusCode": "599" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "EXPIRED_TOKEN", + "statusCode": "403" + }, + { + "defaultResponse": true, + "responseParameters": { + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "INTEGRATION_FAILURE", + "statusCode": "599" + }, + { + "defaultResponse": true, + "responseParameters": { + "gatewayresponse.header.x-request-header": "method.request.header.Accept" + }, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "INTEGRATION_TIMEOUT", + "statusCode": "599" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "INVALID_API_KEY", + "statusCode": "403" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "INVALID_SIGNATURE", + "statusCode": "403" + }, + { + "defaultResponse": false, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\n \"message\": $context.error.messageString,\n \"type\": \"$context.error.responseType\",\n \"stage\": \"$context.stage\",\n \"resourcePath\": \"$context.resourcePath\",\n \"stageVariables.a\": \"$stageVariables.a\",\n \"statusCode\": \"'404'\"\n}" + }, + "responseType": "MISSING_AUTHENTICATION_TOKEN", + "statusCode": "403" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "QUOTA_EXCEEDED", + "statusCode": "429" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "REQUEST_TOO_LARGE", + "statusCode": "413" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "RESOURCE_NOT_FOUND", + "statusCode": "404" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "THROTTLED", + "statusCode": "429" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "UNAUTHORIZED", + "statusCode": "401" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "UNSUPPORTED_MEDIA_TYPE", + "statusCode": "415" + }, + { + "defaultResponse": true, + "responseParameters": {}, + "responseTemplates": { + "application/json": "{\"message\":$context.error.messageString}" + }, + "responseType": "WAF_FILTERED", + "statusCode": "403" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_response_validation": { + "recorded-date": "03-03-2025, 14:27:24", + "recorded-content": { + "put-integration-wrong-method": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-integration-wrong-resource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-integration-response-wrong-method": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-integration-response-wrong-resource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_request_parameter_bool_type": { + "recorded-date": "12-12-2024, 10:46:41", + "recorded-content": { + "bool-method": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "requestParameters": { + "method.request.path.testPath": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-request-param-wrong-type": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid mapping expression specified: Validation Result: warnings : [], errors : [Invalid mapping expression specified: true]" + }, + "message": "Invalid mapping expression specified: Validation Result: warnings : [], errors : [Invalid mapping expression specified: true]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-integration-request-param-wrong-type": { + "Error": { + "Code": "SerializationException", + "Message": "class java.lang.Boolean can not be converted to an String" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-integration-request-param-bool-value": { + "Error": { + "Code": "SerializationException", + "Message": "class java.lang.Boolean can not be converted to an String" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_binary_media_types": { + "recorded-date": "02-07-2025, 13:16:59", + "recorded-content": { + "create-with-binary-media": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/png" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_lifecycle_integration_response": { + "recorded-date": "11-06-2025, 09:12:54", + "recorded-content": { + "put-integration-response": { + "responseTemplates": { + "application/json": "\"created\"" + }, + "selectionPattern": "", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-integration-response": { + "responseTemplates": { + "application/json": "\"created\"" + }, + "selectionPattern": "", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-integration-response": { + "responseTemplates": { + "application/json": "\"created\"" + }, + "selectionPattern": "updated-pattern", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "overwrite-integration-response": { + "responseTemplates": { + "application/json": "overwrite" + }, + "selectionPattern": "overwrite-pattern", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-method": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseTemplates": { + "application/json": "overwrite" + }, + "selectionPattern": "overwrite-pattern", + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-integration-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_api": { + "recorded-date": "18-06-2025, 12:28:55", + "recorded-content": { + "put-integration-response-wrong-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-integration-response-wrong-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_resource": { + "recorded-date": "18-06-2025, 12:28:25", + "recorded-content": { + "put-integration-response-wrong-resource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-integration-response-wrong-resource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_method": { + "recorded-date": "18-06-2025, 11:47:00", + "recorded-content": { + "put-integration-response-wrong-method": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-integration-response-wrong-method": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_statuscode": { + "recorded-date": "18-06-2025, 11:51:51", + "recorded-content": { + "put-integration-response-invalid-statusCode": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong' at 'statusCode' failed to satisfy constraint: Member must satisfy regular expression pattern: [1-5]\\d\\d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-integration-response-invalid-statusCode": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong' at 'statusCode' failed to satisfy constraint: Member must satisfy regular expression pattern: [1-5]\\d\\d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_responsetemplates": { + "recorded-date": "18-06-2025, 13:03:29", + "recorded-content": { + "put-integration-response-invalid-responseTemplates-1": { + "Error": { + "Code": "SerializationException", + "Message": "class com.amazon.coral.value.json.numbers.TruncatingBigNumber can not be converted to an String" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-integration-response-invalid-responseTemplates-2": { + "Error": { + "Code": "BadRequestException", + "Message": "Validation Result: warnings : [], errors : [Invalid content type specified: 123]" + }, + "message": "Validation Result: warnings : [], errors : [Invalid content type specified: 123]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_integration": { + "recorded-date": "26-06-2025, 11:21:05", + "recorded-content": { + "get-integration-response-without-integration": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Integration identifier specified" + }, + "message": "Invalid Integration identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_status_code": { + "recorded-date": "26-06-2025, 11:21:43", + "recorded-content": { + "get-integration-response-wrong-status-code": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Response status code specified" + }, + "message": "Invalid Response status code specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_lifecycle_method_response": { + "recorded-date": "01-07-2025, 15:48:02", + "recorded-content": { + "put-method-response": { + "responseModels": {}, + "responseParameters": {}, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-integration-response": { + "responseModels": {}, + "responseParameters": {}, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "add-method-responses": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.my-header": true + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-method-responses": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.my-header": true + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-method": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.my-header": true + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-method-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response": { + "recorded-date": "30-06-2025, 12:42:31", + "recorded-content": { + "remove-update-method-response": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add-update-method-response": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.my-header": true + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response_wrong_operations": { + "recorded-date": "30-06-2025, 13:54:57", + "recorded-content": { + "update-method-response-wrong-operation-1": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-method-response-wrong-operation-2": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-method-response-wrong-operation-3": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.2.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-method-response-wrong-operation-4": { + "Error": { + "Code": "ValidationException", + "Message": "4 validation errors detected: Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.2.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.3.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.4.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-method-response-wrong-operation-5": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.3.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response_negative_tests": { + "recorded-date": "30-06-2025, 15:24:43", + "recorded-content": { + "update-method-response-wrong-statuscode": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Response status code specified" + }, + "message": "Invalid Response status code specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-method-response-invalid-statuscode": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong' at 'statusCode' failed to satisfy constraint: Member must satisfy regular expression pattern: [1-5]\\d\\d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-method-response-invalid-statuscode-and-wrong-op": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value 'wrong_op' at 'updateMethodResponseInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong' at 'statusCode' failed to satisfy constraint: Member must satisfy regular expression pattern: [1-5]\\d\\d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-method-response-wrong-resource": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Resource identifier specified" + }, + "message": "Invalid Resource identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-method-response-wrong-method": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Method identifier specified" + }, + "message": "Invalid Method identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_lack_response_parameters_and_models": { + "recorded-date": "01-07-2025, 15:48:38", + "recorded-content": { + "update-method-response-operation-without-response-parameters": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid parameter name specified" + }, + "message": "Invalid parameter name specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-method-response-operation-without-response-models": { + "Error": { + "Code": "NotFoundException", + "Message": "Content-Type specified was not found" + }, + "message": "Content-Type specified was not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-method-response-operation-with-wrong-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch path /wrong/method.response.header.my-header" + }, + "message": "Invalid patch path /wrong/method.response.header.my-header", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_wrong_param_names": { + "recorded-date": "01-07-2025, 15:49:16", + "recorded-content": { + "update-method-response-operation-with-wrong-param-name-1": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid parameter name specified" + }, + "message": "Invalid parameter name specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-method-response-operation-with-wrong-param-name-2": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid parameter name specified" + }, + "message": "Invalid parameter name specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-method-response-operation-with-wrong-param-name-3": { + "Error": { + "Code": "NotFoundException", + "Message": "Content-Type specified was not found" + }, + "message": "Content-Type specified was not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-method-response-operation-with-wrong-param-name-4": { + "Error": { + "Code": "NotFoundException", + "Message": "Content-Type specified was not found" + }, + "message": "Content-Type specified was not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_endpoint_configuration": { + "recorded-date": "11-07-2025, 18:28:50", + "recorded-content": { + "create-with-empty-types": { + "Error": { + "Code": "BadRequestException", + "Message": "REGIONAL Configuration and EDGE Configuration cannot be both DISABLED." + }, + "message": "REGIONAL Configuration and EDGE Configuration cannot be both DISABLED.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-invalid-ip-address-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong' at 'createRestApiInput.endpointConfiguration.ipAddressType' failed to satisfy constraint: Member must satisfy enum value set: [ipv4, dualstack]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-invalid-types": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[wrong]' at 'createRestApiInput.endpointConfiguration.types' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [PRIVATE, EDGE, REGIONAL]]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-invalid-ip-address-type-and-types": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value '[wrong]' at 'createRestApiInput.endpointConfiguration.types' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [PRIVATE, EDGE, REGIONAL]]; Value 'wrong' at 'createRestApiInput.endpointConfiguration.ipAddressType' failed to satisfy constraint: Member must satisfy enum value set: [ipv4, dualstack]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-invalid-types-and-ip-address-type": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value '[wrong]' at 'createRestApiInput.endpointConfiguration.types' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [PRIVATE, EDGE, REGIONAL]]; Value 'wrong' at 'createRestApiInput.endpointConfiguration.ipAddressType' failed to satisfy constraint: Member must satisfy enum value set: [ipv4, dualstack]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-multiple-errors-1": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value '[wrong]' at 'createRestApiInput.endpointConfiguration.types' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [PRIVATE, EDGE, REGIONAL]]; Value 'wrong' at 'createRestApiInput.endpointConfiguration.ipAddressType' failed to satisfy constraint: Member must satisfy enum value set: [ipv4, dualstack]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-multiple-errors-2": { + "Error": { + "Code": "BadRequestException", + "Message": "Description cannot be an empty string" + }, + "message": "Description cannot be an empty string", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-two-types": { + "Error": { + "Code": "BadRequestException", + "Message": "Cannot create an api with multiple Endpoint Types." + }, + "message": "Cannot create an api with multiple Endpoint Types.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-endpoint-config-dualstack": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "dualstack", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-with-endpoint-config-regional": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "REGIONAL" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_private_type": { + "recorded-date": "03-07-2025, 18:17:19", + "recorded-content": { + "create-with-private-type-and-ipv4-ip-address-type": { + "Error": { + "Code": "BadRequestException", + "Message": "Only dualstack ipAddressType is supported for Private APIs." + }, + "message": "Only dualstack ipAddressType is supported for Private APIs.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-private-type-and-wrong-ip-address-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'wrong' at 'createRestApiInput.endpointConfiguration.ipAddressType' failed to satisfy constraint: Member must satisfy enum value set: [ipv4, dualstack]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-with-private-type-and-ipv4-ip-address-type-and-empty-description": { + "Error": { + "Code": "BadRequestException", + "Message": "Description cannot be an empty string" + }, + "message": "Description cannot be an empty string", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_verify_defaults": { + "recorded-date": "04-07-2025, 09:55:21", + "recorded-content": { + "create-with-edge-default": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-with-regional-default": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "REGIONAL" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-with-private-default": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "dualstack", + "types": [ + "PRIVATE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-with-empty-default": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-with-empty-ip-address-type-and-wrong-type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[wrong]' at 'createRestApiInput.endpointConfiguration.types' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [PRIVATE, EDGE, REGIONAL]]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_ip_address_type": { + "recorded-date": "07-07-2025, 12:54:02", + "recorded-content": { + "update-rest-api-replace-type": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "dualstack", + "types": [ + "PRIVATE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-rest-api-replace-to-ipv4-within-private-type": { + "Error": { + "Code": "BadRequestException", + "Message": "Only dualstack ipAddressType is supported for Private APIs." + }, + "message": "Only dualstack ipAddressType is supported for Private APIs.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-wrong-ipAddressType": { + "Error": { + "Code": "BadRequestException", + "Message": "ipAddressType must be either ipv4 or dualstack." + }, + "message": "ipAddressType must be either ipv4 or dualstack.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-wrong-type": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid EndpointTypes specified. Valid options are REGIONAL,EDGE,PRIVATE" + }, + "message": "Invalid EndpointTypes specified. Valid options are REGIONAL,EDGE,PRIVATE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-invalid-operation-on-ipAddressType": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch operation specified. Must be one of: [replace]" + }, + "message": "Invalid patch operation specified. Must be one of: [replace]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-invalid-operation-on-type": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid patch operation specified. Must be 'add'|'remove'|'replace'" + }, + "message": "Invalid patch operation specified. Must be 'add'|'remove'|'replace'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-replace-ip-address-type": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "dualstack", + "types": [ + "PRIVATE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_concatenation_of_errors": { + "recorded-date": "07-07-2025, 12:54:34", + "recorded-content": { + "update-rest-api-wrong-operations-on-ipAddressType": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value 'wrong' at 'updateRestApiInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong' at 'updateRestApiInput.patchOperations.2.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-wrong-operations-on-type": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value 'wrong' at 'updateRestApiInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong' at 'updateRestApiInput.patchOperations.2.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-rest-api-wrong-operations-on-type-and-on-ip-address-type": { + "Error": { + "Code": "ValidationException", + "Message": "3 validation errors detected: Value 'wrong' at 'updateRestApiInput.patchOperations.1.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong' at 'updateRestApiInput.patchOperations.2.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]; Value 'wrong' at 'updateRestApiInput.patchOperations.3.member.op' failed to satisfy constraint: Member must satisfy enum value set: [add, remove, move, test, replace, copy]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_api.validation.json b/tests/aws/services/apigateway/test_apigateway_api.validation.json new file mode 100644 index 0000000000000..a2b0702a74ced --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_api.validation.json @@ -0,0 +1,377 @@ +{ + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiAuthorizer::test_authorizer_crud_no_api": { + "last_validated_date": "2024-04-15T18:43:45+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_doc_parts_crud_no_api": { + "last_validated_date": "2024-04-15T20:52:33+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_documentation_part_lifecycle": { + "last_validated_date": "2024-04-15T20:52:34+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_import_documentation_parts": { + "last_validated_date": "2024-04-15T20:56:45+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_create_documentation_part_operations": { + "last_validated_date": "2024-04-15T20:53:48+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_delete_documentation_part": { + "last_validated_date": "2024-04-15T20:54:20+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_get_documentation_part": { + "last_validated_date": "2024-04-15T20:52:39+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_get_documentation_parts": { + "last_validated_date": "2024-04-15T20:53:15+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiDocumentationPart::test_invalid_update_documentation_part": { + "last_validated_date": "2024-04-15T20:53:16+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_method_lifecycle": { + "last_validated_date": "2024-04-15T21:22:46+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_method_request_parameters": { + "last_validated_date": "2024-04-15T21:22:51+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_put_method_model": { + "last_validated_date": "2024-04-15T21:23:29+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_put_method_validation": { + "last_validated_date": "2024-04-15T21:23:49+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_update_method": { + "last_validated_date": "2024-04-15T21:24:42+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiMethod::test_update_method_validation": { + "last_validated_date": "2024-04-15T21:26:29+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_model_lifecycle": { + "last_validated_date": "2024-04-15T21:02:18+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_model_validation": { + "last_validated_date": "2024-04-15T20:33:07+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiModels::test_update_model": { + "last_validated_date": "2024-04-15T20:33:55+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_create_request_validator_invalid_api_id": { + "last_validated_date": "2024-04-15T20:45:24+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_delete_request_validator": { + "last_validated_date": "2024-04-15T20:44:56+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_get_request_validator": { + "last_validated_date": "2024-04-15T20:44:24+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_get_request_validators": { + "last_validated_date": "2024-04-15T20:44:55+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_invalid_update_request_validator_operations": { + "last_validated_date": "2025-07-02T15:15:40+00:00", + "durations_in_seconds": { + "setup": 1.34, + "call": 1.74, + "teardown": 0.41, + "total": 3.49 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_request_validator_lifecycle": { + "last_validated_date": "2025-07-02T15:14:17+00:00", + "durations_in_seconds": { + "setup": 1.35, + "call": 2.56, + "teardown": 0.41, + "total": 4.32 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRequestValidator::test_validators_crud_no_api": { + "last_validated_date": "2024-04-15T20:44:19+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_proxy_resource": { + "last_validated_date": "2024-04-15T17:31:19+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_proxy_resource_validation": { + "last_validated_date": "2024-04-15T17:31:32+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_create_resource_parent_invalid": { + "last_validated_date": "2024-04-15T17:30:24+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_delete_resource": { + "last_validated_date": "2024-04-15T17:29:41+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_resource_lifecycle": { + "last_validated_date": "2024-04-15T17:29:03+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiResource::test_update_resource_behaviour": { + "last_validated_date": "2024-04-15T17:29:08+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_private_type": { + "last_validated_date": "2025-07-04T16:09:35+00:00", + "durations_in_seconds": { + "setup": 0.01, + "call": 0.95, + "teardown": 0.0, + "total": 0.96 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_verify_defaults": { + "last_validated_date": "2025-07-04T16:11:39+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 21.27, + "teardown": 103.58, + "total": 124.85 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_binary_media_types": { + "last_validated_date": "2025-07-04T16:06:30+00:00", + "durations_in_seconds": { + "setup": 0.01, + "call": 0.76, + "teardown": 36.41, + "total": 37.18 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_endpoint_configuration": { + "last_validated_date": "2025-07-11T18:28:50+00:00", + "durations_in_seconds": { + "setup": 1.38, + "call": 46.71, + "teardown": 34.6, + "total": 82.69 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_optional_params": { + "last_validated_date": "2025-07-04T16:05:53+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.17, + "teardown": 84.41, + "total": 86.58 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_create_rest_api_with_tags": { + "last_validated_date": "2025-07-04T16:07:06+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 1.27, + "teardown": 34.81, + "total": 36.08 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_get_api_case_insensitive": { + "last_validated_date": "2024-04-15T15:09:49+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_list_and_delete_apis": { + "last_validated_date": "2025-07-04T16:04:26+00:00", + "durations_in_seconds": { + "setup": 1.32, + "call": 7.7, + "teardown": 60.02, + "total": 69.04 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_behaviour": { + "last_validated_date": "2025-07-04T16:08:33+00:00", + "durations_in_seconds": { + "setup": 0.01, + "call": 1.52, + "teardown": 64.95, + "total": 66.48 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_compression": { + "last_validated_date": "2025-07-04T16:07:26+00:00", + "durations_in_seconds": { + "setup": 0.02, + "call": 2.71, + "teardown": 10.84, + "total": 13.57 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_concatenation_of_errors": { + "last_validated_date": "2025-07-07T12:54:34+00:00", + "durations_in_seconds": { + "setup": 1.67, + "call": 1.61, + "teardown": 5.2, + "total": 8.48 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_invalid_api_id": { + "last_validated_date": "2025-07-04T16:08:33+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 0.15, + "teardown": 0.0, + "total": 0.15 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_ip_address_type": { + "last_validated_date": "2025-07-07T12:54:02+00:00", + "durations_in_seconds": { + "setup": 1.83, + "call": 2.59, + "teardown": 0.48, + "total": 4.9 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayApiRestApi::test_update_rest_api_operation_add_remove": { + "last_validated_date": "2025-07-04T16:07:13+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 1.44, + "teardown": 5.36, + "total": 6.8 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_crud": { + "last_validated_date": "2024-04-15T20:46:20+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_put": { + "last_validated_date": "2024-06-20T22:01:44+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_gateway_response_validation": { + "last_validated_date": "2024-04-15T20:46:24+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApiGatewayGatewayResponse::test_update_gateway_response": { + "last_validated_date": "2024-04-15T20:47:11+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_integration": { + "last_validated_date": "2025-06-26T11:21:05+00:00", + "durations_in_seconds": { + "setup": 1.63, + "call": 1.17, + "teardown": 0.4, + "total": 3.2 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_responsetemplates": { + "last_validated_date": "2025-06-18T13:03:29+00:00", + "durations_in_seconds": { + "setup": 1.43, + "call": 2.04, + "teardown": 0.33, + "total": 3.8 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_invalid_statuscode": { + "last_validated_date": "2025-06-18T11:51:51+00:00", + "durations_in_seconds": { + "setup": 1.61, + "call": 1.31, + "teardown": 0.41, + "total": 3.33 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_api": { + "last_validated_date": "2025-06-18T12:28:55+00:00", + "durations_in_seconds": { + "setup": 1.08, + "call": 0.32, + "teardown": 0.0, + "total": 1.4 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_method": { + "last_validated_date": "2025-06-18T11:47:00+00:00", + "durations_in_seconds": { + "setup": 1.42, + "call": 1.17, + "teardown": 0.37, + "total": 2.96 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_resource": { + "last_validated_date": "2025-06-18T12:28:25+00:00", + "durations_in_seconds": { + "setup": 1.35, + "call": 1.22, + "teardown": 0.41, + "total": 2.98 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_integration_response_wrong_status_code": { + "last_validated_date": "2025-06-26T11:21:43+00:00", + "durations_in_seconds": { + "setup": 1.58, + "call": 1.41, + "teardown": 0.41, + "total": 3.4 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_lifecycle_integration_response": { + "last_validated_date": "2025-06-11T09:12:54+00:00", + "durations_in_seconds": { + "setup": 1.49, + "call": 2.35, + "teardown": 0.37, + "total": 4.21 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_lifecycle_method_response": { + "last_validated_date": "2025-07-01T15:48:02+00:00", + "durations_in_seconds": { + "setup": 1.38, + "call": 2.76, + "teardown": 0.41, + "total": 4.55 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_request_parameter_bool_type": { + "last_validated_date": "2024-12-12T10:46:41+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_response_validation": { + "last_validated_date": "2025-03-03T14:27:24+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_put_integration_wrong_type": { + "last_validated_date": "2024-04-15T20:48:47+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_lack_response_parameters_and_models": { + "last_validated_date": "2025-07-01T15:48:38+00:00", + "durations_in_seconds": { + "setup": 1.4, + "call": 1.95, + "teardown": 0.42, + "total": 3.77 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response": { + "last_validated_date": "2025-06-30T12:42:31+00:00", + "durations_in_seconds": { + "setup": 1.26, + "call": 1.63, + "teardown": 0.35, + "total": 3.24 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response_negative_tests": { + "last_validated_date": "2025-06-30T15:24:43+00:00", + "durations_in_seconds": { + "setup": 1.53, + "call": 2.45, + "teardown": 0.43, + "total": 4.41 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_response_wrong_operations": { + "last_validated_date": "2025-06-30T13:54:57+00:00", + "durations_in_seconds": { + "setup": 1.36, + "call": 2.35, + "teardown": 0.42, + "total": 4.13 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayIntegration::test_update_method_wrong_param_names": { + "last_validated_date": "2025-07-01T15:49:16+00:00", + "durations_in_seconds": { + "setup": 1.44, + "call": 2.13, + "teardown": 0.41, + "total": 3.98 + } + }, + "tests/aws/services/apigateway/test_apigateway_api.py::TestApigatewayTestInvoke::test_invoke_test_method": { + "last_validated_date": "2024-04-15T20:48:35+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_basic.py b/tests/aws/services/apigateway/test_apigateway_basic.py new file mode 100644 index 0000000000000..ec03c2b1612bb --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_basic.py @@ -0,0 +1,1862 @@ +import base64 +import itertools +import json +import os +import re +from collections import namedtuple +from typing import Callable, Optional + +import botocore +import pytest +import xmltodict +from botocore.exceptions import ClientError +from jsonpatch import apply_patch +from requests.structures import CaseInsensitiveDict + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.handlers import cors +from localstack.constants import TAG_KEY_CUSTOM_ID +from localstack.services.apigateway.helpers import ( + host_based_url, + localstack_path_based_url, + path_based_url, +) +from localstack.services.apigateway.legacy.helpers import ( + get_resource_for_path, + get_rest_api_paths, +) +from localstack.testing.aws.util import in_default_partition +from localstack.testing.config import ( + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_ACCOUNT_ID, + TEST_AWS_REGION_NAME, +) +from localstack.testing.pytest import markers +from localstack.utils import testutil +from localstack.utils.aws import arns +from localstack.utils.aws import resources as resource_util +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.collections import select_attributes +from localstack.utils.files import load_file +from localstack.utils.http import safe_requests as requests +from localstack.utils.json import clone +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import retry +from localstack.utils.urls import localstack_host +from tests.aws.services.apigateway.apigateway_fixtures import ( + UrlType, + api_invoke_url, + create_rest_api_deployment, + create_rest_api_integration, + create_rest_api_integration_response, + create_rest_api_method_response, + create_rest_api_stage, + create_rest_resource, + create_rest_resource_method, + update_rest_api_deployment, + update_rest_api_stage, +) +from tests.aws.services.apigateway.conftest import ( + APIGATEWAY_ASSUME_ROLE_POLICY, + APIGATEWAY_DYNAMODB_POLICY, + APIGATEWAY_KINESIS_POLICY, + APIGATEWAY_LAMBDA_POLICY, + is_next_gen_api, +) +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_NODEJS, + TEST_LAMBDA_NODEJS_APIGW_INTEGRATION, + TEST_LAMBDA_PYTHON, + TEST_LAMBDA_PYTHON_ECHO, +) + +# TODO: split up the tests in this file into more specific test sub-modules + +TEST_STAGE_NAME = "testing" + +THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +TEST_SWAGGER_FILE_JSON = os.path.join(THIS_FOLDER, "../../files/swagger.json") +TEST_SWAGGER_FILE_YAML = os.path.join(THIS_FOLDER, "../../files/swagger.yaml") +TEST_IMPORT_MOCK_INTEGRATION = os.path.join(THIS_FOLDER, "../../files/openapi-mock.json") +TEST_IMPORT_REST_API_ASYNC_LAMBDA = os.path.join(THIS_FOLDER, "../../files/api_definition.yaml") + +ApiGatewayLambdaProxyIntegrationTestResult = namedtuple( + "ApiGatewayLambdaProxyIntegrationTestResult", + [ + "data", + "resource", + "result", + "url", + "path_with_replace", + ], +) + +API_PATH_LAMBDA_PROXY_BACKEND = "/lambda/foo1" +API_PATH_LAMBDA_PROXY_BACKEND_WITH_PATH_PARAM = "/lambda/{test_param1}" +API_PATH_LAMBDA_PROXY_BACKEND_ANY_METHOD = "/lambda-any-method/foo1" +API_PATH_LAMBDA_PROXY_BACKEND_ANY_METHOD_WITH_PATH_PARAM = "/lambda-any-method/{test_param1}" +API_PATH_LAMBDA_PROXY_BACKEND_WITH_IS_BASE64 = "/lambda-is-base64/foo1" + + +@pytest.fixture +def integration_lambda(create_lambda_function): + function_name = f"apigw-int-{short_uid()}" + create_lambda_function(handler_file=TEST_LAMBDA_PYTHON, func_name=function_name) + return function_name + + +class TestAPIGateway: + # endpoint paths + + TEST_API_GATEWAY_AUTHORIZER = { + "name": "test", + "type": "TOKEN", + "providerARNs": ["arn:aws:cognito-idp:us-east-1:123412341234:userpool/us-east-1_123412341"], + "authType": "custom", + "authorizerUri": "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/" + + "arn:aws:lambda:us-east-1:123456789012:function:myApiAuthorizer" + "/invocations", + "authorizerCredentials": "arn:aws:iam::123456789012:role/apigAwsProxyRole", + "identitySource": "method.request.header.Authorization", + "identityValidationExpression": ".*", + "authorizerResultTtlInSeconds": 300, + } + TEST_API_GATEWAY_AUTHORIZER_OPS = [{"op": "replace", "path": "/name", "value": "test1"}] + + @markers.aws.validated + def test_delete_rest_api_with_invalid_id(self, aws_client): + with pytest.raises(ClientError) as e: + aws_client.apigateway.delete_rest_api(restApiId="foobar") + + assert e.value.response["Error"]["Code"] == "NotFoundException" + assert "Invalid API identifier specified" in e.value.response["Error"]["Message"] + assert "foobar" in e.value.response["Error"]["Message"] + + @pytest.mark.parametrize( + "url_function", [path_based_url, host_based_url, localstack_path_based_url] + ) + @markers.aws.only_localstack + # This is not a possible feature on aws. + def test_create_rest_api_with_custom_id(self, create_rest_apigw, url_function, aws_client): + if not is_next_gen_api() and url_function == localstack_path_based_url: + pytest.skip("This URL type is not supported in the legacy implementation") + apigw_name = f"gw-{short_uid()}" + test_id = "testId123" + api_id, name, _ = create_rest_apigw(name=apigw_name, tags={TAG_KEY_CUSTOM_ID: test_id}) + assert test_id == api_id + assert apigw_name == name + response = aws_client.apigateway.get_rest_api(restApiId=test_id) + assert response["name"] == apigw_name + + spec_file = load_file(TEST_IMPORT_MOCK_INTEGRATION) + aws_client.apigateway.put_rest_api(restApiId=test_id, body=spec_file, mode="overwrite") + + aws_client.apigateway.create_deployment(restApiId=test_id, stageName="latest") + + url = url_function(test_id, stage_name="latest", path="/echo/foobar") + response = requests.get(url) + + assert response.ok + assert response._content == b'{"echo": "foobar", "response": "mocked"}' + + @markers.aws.validated + def test_update_rest_api_deployment(self, create_rest_apigw, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("id")) + + api_id, _, root = create_rest_apigw(name="test_gateway5") + + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=root, + httpMethod="GET", + authorizationType="none", + ) + + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=root, + httpMethod="GET", + type="HTTP", + uri="http://httpbin.org/robots.txt", + integrationHttpMethod="POST", + ) + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=root, + httpMethod="GET", + statusCode="200", + selectionPattern="foobar", + responseTemplates={}, + ) + + deployment_id, _ = create_rest_api_deployment( + aws_client.apigateway, restApiId=api_id, description="my deployment" + ) + patch_operations = [{"op": "replace", "path": "/description", "value": "new-description"}] + deployment = update_rest_api_deployment( + aws_client.apigateway, + restApiId=api_id, + deploymentId=deployment_id, + patchOperations=patch_operations, + ) + snapshot.match("after-update", deployment) + + @markers.aws.validated + def test_api_gateway_lambda_integration_aws_type( + self, create_lambda_function, create_rest_apigw, aws_client + ): + region_name = aws_client.apigateway._client_config.region_name + fn_name = f"test-{short_uid()}" + create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_9, + ) + lambda_arn = aws_client.lambda_.get_function(FunctionName=fn_name)["Configuration"][ + "FunctionArn" + ] + + api_id, _, root = create_rest_apigw(name="aws lambda api") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="test" + ) + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + integrationHttpMethod="POST", + type="AWS", + uri=f"arn:aws:apigateway:{region_name}:lambda:path//2015-03-31/functions/" + f"{lambda_arn}/invocations", + requestTemplates={ + "application/json": '#set($allParams = $input.params())\n{\n"body-json" : $input.json("$"),\n"params" : {\n#foreach($type in $allParams.keySet())\n #set($params = $allParams.get($type))\n"$type" : {\n #foreach($paramName in $params.keySet())\n "$paramName" : "$util.escapeJavaScript($params.get($paramName))"\n #if($foreach.hasNext),#end\n #end\n}\n #if($foreach.hasNext),#end\n#end\n},\n"stage-variables" : {\n#foreach($key in $stageVariables.keySet())\n"$key" : "$util.escapeJavaScript($stageVariables.get($key))"\n #if($foreach.hasNext),#end\n#end\n},\n"context" : {\n "api-id" : "$context.apiId",\n "api-key" : "$context.identity.apiKey",\n "http-method" : "$context.httpMethod",\n "stage" : "$context.stage",\n "source-ip" : "$context.identity.sourceIp",\n "user-agent" : "$context.identity.userAgent",\n "request-id" : "$context.requestId",\n "resource-id" : "$context.resourceId",\n "resource-path" : "$context.resourcePath"\n }\n}\n' + }, + ) + create_rest_api_method_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseParameters={ + "method.response.header.Content-Type": False, + "method.response.header.Access-Control-Allow-Origin": False, + }, + ) + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseTemplates={"text/html": "$input.path('$')"}, + responseParameters={ + "method.response.header.Access-Control-Allow-Origin": "'*'", + "method.response.header.Content-Type": "'text/html'", + }, + ) + deployment_id, _ = create_rest_api_deployment(aws_client.apigateway, restApiId=api_id) + stage = create_rest_api_stage( + aws_client.apigateway, restApiId=api_id, stageName="local", deploymentId=deployment_id + ) + + update_rest_api_stage( + aws_client.apigateway, + restApiId=api_id, + stageName="local", + patchOperations=[{"op": "replace", "path": "/cacheClusterEnabled", "value": "true"}], + ) + aws_account_id = aws_client.sts.get_caller_identity()["Account"] + source_arn = f"arn:aws:execute-api:{region_name}:{aws_account_id}:{api_id}/*/*/test" + + aws_client.lambda_.add_permission( + FunctionName=lambda_arn, + StatementId=str(short_uid()), + Action="lambda:InvokeFunction", + Principal="apigateway.amazonaws.com", + SourceArn=source_arn, + ) + + url = api_invoke_url(api_id, stage=stage, path="/test") + response = requests.post(url, json={"test": "test"}) + + assert response.headers["Content-Type"] == "text/html" + assert response.headers["Access-Control-Allow-Origin"] == "*" + + @pytest.mark.parametrize( + "url_type", [UrlType.HOST_BASED, UrlType.PATH_BASED, UrlType.LS_PATH_BASED] + ) + @pytest.mark.parametrize("disable_custom_cors", [True, False]) + @pytest.mark.parametrize("origin", ["http://allowed", "http://denied"]) + @markers.aws.only_localstack + def test_invoke_endpoint_cors_headers( + self, url_type, disable_custom_cors, origin, monkeypatch, aws_client + ): + if not is_next_gen_api() and url_type == UrlType.LS_PATH_BASED: + pytest.skip("This URL type is not supported with the legacy implementation") + + monkeypatch.setattr(config, "DISABLE_CUSTOM_CORS_APIGATEWAY", disable_custom_cors) + monkeypatch.setattr( + cors, "ALLOWED_CORS_ORIGINS", cors.ALLOWED_CORS_ORIGINS + ["http://allowed"] + ) + + responses = [ + { + "statusCode": "200", + "httpMethod": "OPTIONS", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'http://test.com'", + "method.response.header.Vary": "'Origin'", + }, + } + ] + api_id = self.create_api_gateway_and_deploy( + aws_client.apigateway, + aws_client.dynamodb, + integration_type="MOCK", + integration_responses=responses, + stage_name=TEST_STAGE_NAME, + request_templates={"application/json": json.dumps({"statusCode": 200})}, + ) + + # invoke endpoint with Origin header + endpoint = api_invoke_url(api_id, stage=TEST_STAGE_NAME, path="/", url_type=url_type) + response = requests.options(endpoint, headers={"Origin": origin}) + + # assert response codes and CORS headers + if disable_custom_cors: + if origin == "http://allowed": + assert response.status_code == 204 + assert "http://allowed" in response.headers["Access-Control-Allow-Origin"] + else: + assert response.status_code == 403 + else: + assert response.status_code == 200 + assert "http://test.com" in response.headers["Access-Control-Allow-Origin"] + + # This test fails as it tries to create a lambda locally? + # It then leaves some resources behind, apigateway and policies + @markers.aws.needs_fixing + @pytest.mark.parametrize( + "api_path", [API_PATH_LAMBDA_PROXY_BACKEND, API_PATH_LAMBDA_PROXY_BACKEND_WITH_PATH_PARAM] + ) + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_api_gateway_lambda_proxy_integration( + self, api_path, integration_lambda, aws_client, create_iam_role_with_policy + ): + role_arn = create_iam_role_with_policy( + RoleName=f"role-apigw-lambda-{short_uid()}", + PolicyName=f"policy-apigw-lambda-{short_uid()}", + RoleDefinition=APIGATEWAY_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_KINESIS_POLICY, + ) + + self._test_api_gateway_lambda_proxy_integration( + integration_lambda, + api_path, + role_arn, + aws_client.apigateway, + ) + + # This test fails as it tries to create a lambda locally? + # It then leaves some resources behind, apigateway and policies + @markers.aws.needs_fixing + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_api_gateway_lambda_proxy_integration_with_is_base_64_encoded( + self, integration_lambda, aws_client, create_iam_role_with_policy + ): + # Test the case where `isBase64Encoded` is enabled. + content = b"hello, please base64 encode me" + + def _mutate_data(data) -> None: + data["return_is_base_64_encoded"] = True + data["return_raw_body"] = base64.b64encode(content).decode("utf8") + + role_arn = create_iam_role_with_policy( + RoleName=f"role-apigw-lambda-{short_uid()}", + PolicyName=f"policy-apigw-lambda-{short_uid()}", + RoleDefinition=APIGATEWAY_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_LAMBDA_POLICY, + ) + + test_result = self._test_api_gateway_lambda_proxy_integration_no_asserts( + integration_lambda, + API_PATH_LAMBDA_PROXY_BACKEND_WITH_IS_BASE64, + role_arn, + aws_client.apigateway, + data_mutator_fn=_mutate_data, + ) + + # Ensure that `invoke_rest_api_integration_backend` correctly decodes the base64 content + assert test_result.result.status_code == 203 + assert test_result.result.content == content + + def _test_api_gateway_lambda_proxy_integration_no_asserts( + self, + fn_name: str, + path: str, + role_arn: str, + apigw_client, + data_mutator_fn: Optional[Callable] = None, + ) -> ApiGatewayLambdaProxyIntegrationTestResult: + """ + Perform the setup needed to do a POST against a Lambda Proxy Integration; + then execute the POST. + + :param data_mutator_fn: a Callable[[Dict], None] that lets us mutate the + data dictionary before sending it off to the lambda. + """ + # create API Gateway and connect it to the Lambda proxy backend + lambda_uri = arns.lambda_function_arn(fn_name, TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME) + invocation_uri = "arn:aws:apigateway:%s:lambda:path/2015-03-31/functions/%s/invocations" + target_uri = invocation_uri % (TEST_AWS_REGION_NAME, lambda_uri) + + result = testutil.connect_api_gateway_to_http_with_lambda_proxy( + "test_gateway2", + target_uri, + path=path, + stage_name=TEST_STAGE_NAME, + client=apigw_client, + role_arn=role_arn, + ) + + api_id = result["id"] + path_map = get_rest_api_paths( + account_id=TEST_AWS_ACCOUNT_ID, region_name=TEST_AWS_REGION_NAME, rest_api_id=api_id + ) + _, resource = get_resource_for_path(path, method="POST", path_map=path_map) + + # make test request to gateway and check response + path_with_replace = path.replace("{test_param1}", "foo1") + path_with_params = path_with_replace + "?foo=foo&bar=bar&bar=baz" + + url = path_based_url(api_id=api_id, stage_name=TEST_STAGE_NAME, path=path_with_params) + + # These values get read in `lambda_integration.py` + data = {"return_status_code": 203, "return_headers": {"foo": "bar123"}} + if data_mutator_fn: + assert callable(data_mutator_fn) + data_mutator_fn(data) + result = requests.post( + url, + data=json.dumps(data), + headers={"User-Agent": "python-requests/testing"}, + ) + + return ApiGatewayLambdaProxyIntegrationTestResult( + data=data, + resource=resource, + result=result, + url=url, + path_with_replace=path_with_replace, + ) + + def _test_api_gateway_lambda_proxy_integration( + self, + fn_name: str, + path: str, + role_arn: str, + apigw_client, + ) -> None: + test_result = self._test_api_gateway_lambda_proxy_integration_no_asserts( + fn_name, path, role_arn, apigw_client + ) + data, resource, result, url, path_with_replace = test_result + + assert result.status_code == 203 + assert result.headers.get("foo") == "bar123" + assert "set-cookie" in result.headers + + try: + parsed_body = json.loads(to_str(result.content)) + except json.decoder.JSONDecodeError as e: + raise Exception( + "Couldn't json-decode content: {}".format(to_str(result.content)) + ) from e + assert parsed_body.get("return_status_code") == 203 + assert parsed_body.get("return_headers") == {"foo": "bar123"} + assert parsed_body.get("queryStringParameters") == {"foo": "foo", "bar": "baz"} + + request_context = parsed_body.get("requestContext") + source_ip = request_context["identity"].pop("sourceIp") + + assert re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", source_ip) + + expected_path = f"/{TEST_STAGE_NAME}/lambda/foo1" + assert expected_path == request_context["path"] + assert request_context.get("stageVariables") is None + assert TEST_AWS_ACCOUNT_ID == request_context["accountId"] + assert resource.get("id") == request_context["resourceId"] + assert request_context["stage"] == TEST_STAGE_NAME + assert "python-requests/testing" == request_context["identity"]["userAgent"] + assert "POST" == request_context["httpMethod"] + assert "HTTP/1.1" == request_context["protocol"] + assert "requestTimeEpoch" in request_context + assert "requestTime" in request_context + assert "requestId" in request_context + + # assert that header keys are lowercase (as in AWS) + headers = parsed_body.get("headers") or {} + header_names = list(headers.keys()) + assert "Host" in header_names + assert "Content-Length" in header_names + assert "User-Agent" in header_names + + result = requests.delete(url, data=json.dumps(data)) + assert 204 == result.status_code + + # send message with non-ASCII chars + body_msg = "πŸ™€ - ε‚γ‚ˆ" + result = requests.post(url, data=json.dumps({"return_raw_body": body_msg})) + assert body_msg == to_str(result.content) + + # send message with binary data + binary_msg = b"\xff \xaa \x11" + result = requests.post(url, data=binary_msg) + result_content = json.loads(to_str(result.content)) + assert "/yCqIBE=" == result_content["body"] + assert ["isBase64Encoded"] + + # This test fails as it tries to create a lambda locally? + # It then leaves some resources behind, apigateway and policies + @markers.aws.needs_fixing + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_api_gateway_lambda_proxy_integration_any_method(self, integration_lambda): + self._test_api_gateway_lambda_proxy_integration_any_method( + integration_lambda, API_PATH_LAMBDA_PROXY_BACKEND_ANY_METHOD + ) + + # This test fails as it tries to create a lambda locally? + # It then leaves some resources behind, apigateway and policies + @markers.aws.needs_fixing + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_api_gateway_lambda_proxy_integration_any_method_with_path_param( + self, integration_lambda + ): + self._test_api_gateway_lambda_proxy_integration_any_method( + integration_lambda, + API_PATH_LAMBDA_PROXY_BACKEND_ANY_METHOD_WITH_PATH_PARAM, + ) + + @markers.aws.validated + def test_api_gateway_lambda_asynchronous_invocation( + self, create_rest_apigw, create_lambda_function, aws_client, create_role_with_policy + ): + api_gateway_name = f"api_gateway_{short_uid()}" + stage_name = "test" + rest_api_id, _, _ = create_rest_apigw(name=api_gateway_name) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + fn_name = f"test-{short_uid()}" + lambda_arn = create_lambda_function( + handler_file=TEST_LAMBDA_NODEJS, func_name=fn_name, runtime=Runtime.nodejs16_x + )["CreateFunctionResponse"]["FunctionArn"] + + spec_file = load_file(TEST_IMPORT_REST_API_ASYNC_LAMBDA) + spec_file = spec_file.replace( + "${lambda_invocation_arn}", + f"arn:aws:apigateway:{aws_client.apigateway.meta.region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + ) + spec_file = spec_file.replace("${credentials}", role_arn) + + aws_client.apigateway.put_rest_api(restApiId=rest_api_id, body=spec_file, mode="overwrite") + aws_client.apigateway.create_deployment(restApiId=rest_api_id, stageName=stage_name) + url = api_invoke_url(api_id=rest_api_id, stage=stage_name, path="/wait/3") + + def invoke(url): + invoke_response = requests.get(url) + assert invoke_response.status_code == 200 + return invoke_response + + result = retry(invoke, sleep=2, retries=10, url=url) + assert result.content == b"" + + @markers.aws.validated + def test_api_gateway_mock_integration(self, create_rest_apigw, aws_client, snapshot): + rest_api_name = f"apigw-{short_uid()}" + stage_name = "test" + rest_api_id, _, _ = create_rest_apigw(name=rest_api_name) + + spec_file = load_file(TEST_IMPORT_MOCK_INTEGRATION) + aws_client.apigateway.put_rest_api(restApiId=rest_api_id, body=spec_file, mode="overwrite") + aws_client.apigateway.create_deployment(restApiId=rest_api_id, stageName=stage_name) + + url = api_invoke_url(api_id=rest_api_id, stage=stage_name, path="/echo/foobar") + response = requests.get(url) + snapshot.match("mocked-response", response.json()) + + @pytest.mark.skip(reason="Behaviour is not AWS compliant, need to recreate this test") + @markers.aws.needs_fixing + # TODO rework or remove this test + def test_api_gateway_authorizer_crud(self, aws_client): + get_api_gateway_id = "fugvjdxtri" + + authorizer = aws_client.apigateway.create_authorizer( + restApiId=get_api_gateway_id, **self.TEST_API_GATEWAY_AUTHORIZER + ) + + authorizer_id = authorizer.get("id") + + create_result = aws_client.apigateway.get_authorizer( + restApiId=get_api_gateway_id, authorizerId=authorizer_id + ) + + # ignore boto3 stuff + del create_result["ResponseMetadata"] + + create_expected = clone(self.TEST_API_GATEWAY_AUTHORIZER) + create_expected["id"] = authorizer_id + + assert create_expected == create_result + + aws_client.apigateway.update_authorizer( + restApiId=get_api_gateway_id, + authorizerId=authorizer_id, + patchOperations=self.TEST_API_GATEWAY_AUTHORIZER_OPS, + ) + + update_result = aws_client.apigateway.get_authorizer( + restApiId=get_api_gateway_id, authorizerId=authorizer_id + ) + + # ignore boto3 stuff + del update_result["ResponseMetadata"] + + update_expected = apply_patch(create_expected, self.TEST_API_GATEWAY_AUTHORIZER_OPS) + + assert update_expected == update_result + + aws_client.apigateway.delete_authorizer( + restApiId=get_api_gateway_id, authorizerId=authorizer_id + ) + + with pytest.raises(Exception): + aws_client.apigateway.get_authorizer( + restApiId=get_api_gateway_id, authorizerId=authorizer_id + ) + + # Missing certificate creation to create a domain + # this might end up being a bigger issue to fix until we have a validated certificate we can use + @markers.aws.needs_fixing + def test_api_gateway_handle_domain_name(self, aws_client): + domain_name = f"{short_uid()}.example.com" + apigw_client = aws_client.apigateway + rs = apigw_client.create_domain_name(domainName=domain_name) + assert 201 == rs["ResponseMetadata"]["HTTPStatusCode"] + rs = apigw_client.get_domain_name(domainName=domain_name) + assert 200 == rs["ResponseMetadata"]["HTTPStatusCode"] + assert domain_name == rs["domainName"] + apigw_client.delete_domain_name(domainName=domain_name) + + def _test_api_gateway_lambda_proxy_integration_any_method(self, fn_name, path): + # create API Gateway and connect it to the Lambda proxy backend + lambda_uri = arns.lambda_function_arn(fn_name, TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME) + target_uri = arns.apigateway_invocations_arn(lambda_uri, TEST_AWS_REGION_NAME) + + result = testutil.connect_api_gateway_to_http_with_lambda_proxy( + "test_gateway3", + target_uri, + methods=["ANY"], + path=path, + stage_name=TEST_STAGE_NAME, + ) + + # make test request to gateway and check response + path = path.replace("{test_param1}", "foo1") + url = path_based_url(api_id=result["id"], stage_name=TEST_STAGE_NAME, path=path) + data = {} + + for method in ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"): + body = json.dumps(data) if method in ("POST", "PUT", "PATCH") else None + result = getattr(requests, method.lower())(url, data=body) + if method != "DELETE": + assert 200 == result.status_code + parsed_body = json.loads(to_str(result.content)) + assert method == parsed_body.get("httpMethod") + else: + assert 204 == result.status_code + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..authType", # Not added by LS + "$..authorizerResultTtlInSeconds", # Exists in LS but not in AWS + ] + ) + @markers.aws.validated + def test_apigateway_with_custom_authorization_method( + self, create_rest_apigw, aws_client, account_id, region_name, integration_lambda, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("api_id")) + snapshot.add_transformer(snapshot.transform.key_value("authorizerUri")) + snapshot.add_transformer(snapshot.transform.key_value("id")) + # create Lambda function + lambda_uri = arns.lambda_function_arn(integration_lambda, account_id, region_name) + + # create REST API + api_id, _, _ = create_rest_apigw(name="test-api") + snapshot.match("api-id", {"api_id": api_id}) + root_res_id = aws_client.apigateway.get_resources(restApiId=api_id)["items"][0]["id"] + + # create authorizer at root resource + authorizer = aws_client.apigateway.create_authorizer( + restApiId=api_id, + name="lambda_authorizer", + type="TOKEN", + authorizerUri="arn:aws:apigateway:us-east-1:lambda:path/ \ + 2015-03-31/functions/{}/invocations".format(lambda_uri), + identitySource="method.request.header.Auth", + ) + snapshot.match("authorizer", authorizer) + + # create method with custom authorizer + is_api_key_required = True + method_response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_res_id, + httpMethod="GET", + authorizationType="CUSTOM", + authorizerId=authorizer["id"], + apiKeyRequired=is_api_key_required, + ) + snapshot.match("put-method-response", method_response) + + @markers.aws.only_localstack + def test_base_path_mapping(self, create_rest_apigw, aws_client): + rest_api_id, _, _ = create_rest_apigw(name="my_api", description="this is my api") + + # CREATE + domain_name = "domain1.example.com" + aws_client.apigateway.create_domain_name(domainName=domain_name) + root_res_id = aws_client.apigateway.get_resources(restApiId=rest_api_id)["items"][0]["id"] + res_id = aws_client.apigateway.create_resource( + restApiId=rest_api_id, parentId=root_res_id, pathPart="path" + )["id"] + aws_client.apigateway.put_method( + restApiId=rest_api_id, resourceId=res_id, httpMethod="GET", authorizationType="NONE" + ) + aws_client.apigateway.put_integration( + restApiId=rest_api_id, resourceId=res_id, httpMethod="GET", type="MOCK" + ) + depl_id = aws_client.apigateway.create_deployment(restApiId=rest_api_id)["id"] + aws_client.apigateway.create_stage( + restApiId=rest_api_id, deploymentId=depl_id, stageName="dev" + ) + base_path = "foo" + result = aws_client.apigateway.create_base_path_mapping( + domainName=domain_name, + basePath=base_path, + restApiId=rest_api_id, + stage="dev", + ) + assert result["ResponseMetadata"]["HTTPStatusCode"] in [200, 201] + + # LIST + result = aws_client.apigateway.get_base_path_mappings(domainName=domain_name) + assert 200 == result["ResponseMetadata"]["HTTPStatusCode"] + expected = {"basePath": base_path, "restApiId": rest_api_id, "stage": "dev"} + assert [expected] == result["items"] + + # GET + result = aws_client.apigateway.get_base_path_mapping( + domainName=domain_name, basePath=base_path + ) + assert 200 == result["ResponseMetadata"]["HTTPStatusCode"] + assert expected == select_attributes(result, ["basePath", "restApiId", "stage"]) + + # UPDATE + result = aws_client.apigateway.update_base_path_mapping( + domainName=domain_name, basePath=base_path, patchOperations=[] + ) + assert 200 == result["ResponseMetadata"]["HTTPStatusCode"] + + # DELETE + aws_client.apigateway.delete_base_path_mapping(domainName=domain_name, basePath=base_path) + with pytest.raises(Exception): + aws_client.apigateway.get_base_path_mapping(domainName=domain_name, basePath=base_path) + with pytest.raises(Exception): + aws_client.apigateway.delete_base_path_mapping( + domainName=domain_name, basePath=base_path + ) + + @markers.aws.only_localstack + def test_base_path_mapping_root(self, aws_client): + client = aws_client.apigateway + response = client.create_rest_api(name="my_api2", description="this is my api") + rest_api_id = response["id"] + + # CREATE + domain_name = "domain2.example.com" + client.create_domain_name(domainName=domain_name) + root_res_id = client.get_resources(restApiId=rest_api_id)["items"][0]["id"] + res_id = client.create_resource( + restApiId=rest_api_id, parentId=root_res_id, pathPart="path" + )["id"] + client.put_method( + restApiId=rest_api_id, resourceId=res_id, httpMethod="GET", authorizationType="NONE" + ) + client.put_integration( + restApiId=rest_api_id, resourceId=res_id, httpMethod="GET", type="MOCK" + ) + depl_id = client.create_deployment(restApiId=rest_api_id)["id"] + client.create_stage(restApiId=rest_api_id, deploymentId=depl_id, stageName="dev") + result = client.create_base_path_mapping( + domainName=domain_name, + basePath="", + restApiId=rest_api_id, + stage="dev", + ) + assert result["ResponseMetadata"]["HTTPStatusCode"] in [200, 201] + + base_path = "(none)" + # LIST + result = client.get_base_path_mappings(domainName=domain_name) + assert 200 == result["ResponseMetadata"]["HTTPStatusCode"] + expected = {"basePath": "(none)", "restApiId": rest_api_id, "stage": "dev"} + assert [expected] == result["items"] + + # GET + result = client.get_base_path_mapping(domainName=domain_name, basePath=base_path) + assert 200 == result["ResponseMetadata"]["HTTPStatusCode"] + assert expected == select_attributes(result, ["basePath", "restApiId", "stage"]) + + # UPDATE + result = client.update_base_path_mapping( + domainName=domain_name, basePath=base_path, patchOperations=[] + ) + assert 200 == result["ResponseMetadata"]["HTTPStatusCode"] + + # DELETE + client.delete_base_path_mapping(domainName=domain_name, basePath=base_path) + with pytest.raises(Exception): + client.get_base_path_mapping(domainName=domain_name, basePath=base_path) + with pytest.raises(Exception): + client.delete_base_path_mapping(domainName=domain_name, basePath=base_path) + + @markers.aws.needs_fixing + # invalid operation on aws + def test_api_account(self, create_rest_apigw, aws_client): + rest_api_id, _, _ = create_rest_apigw(name="my_api", description="test 123") + + result = aws_client.apigateway.get_account() + assert "UsagePlans" in result["features"] + result = aws_client.apigateway.update_account( + patchOperations=[{"op": "add", "path": "/features/-", "value": "foobar"}] + ) + assert "foobar" in result["features"] + + @markers.aws.needs_fixing + # Missing role, proper url and doesn't clean up after itself. Should be move to dynamodb test file and use fixtures that clean their resources + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_put_integration_dynamodb_proxy_validation_without_request_template(self, aws_client): + api_id = self.create_api_gateway_and_deploy(aws_client.apigateway, aws_client.dynamodb) + url = path_based_url(api_id=api_id, stage_name="staging", path="/") + response = requests.put( + url, + json.dumps({"id": "id1", "data": "foobar123"}), + ) + + assert 400 == response.status_code + + @markers.aws.needs_fixing + # Missing role, proper url and doesn't clean up after itself. Should be move to dynamodb test file and use fixtures that clean their resources + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_put_integration_dynamodb_proxy_validation_with_request_template( + self, + aws_client, + dynamodb_create_table, + create_iam_role_with_policy, + ): + table = dynamodb_create_table() + table_name = table["TableDescription"]["TableName"] + + role_arn = create_iam_role_with_policy( + RoleName=f"role-apigw-dynamodb-{short_uid()}", + PolicyName=f"policy-apigw-dynamodb-{short_uid()}", + RoleDefinition=APIGATEWAY_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_DYNAMODB_POLICY, + ) + + # create API GW with DynamoDB integration + request_templates = { + "application/json": json.dumps( + { + "TableName": table_name, + "Item": { + "id": {"S": "$input.path('id')"}, + "data": {"S": "$input.path('data')"}, + }, + } + ) + } + api_id = self.create_api_gateway_and_deploy( + aws_client.apigateway, + aws_client.dynamodb, + request_templates=request_templates, + role_arn=role_arn, + ) + url = path_based_url(api_id=api_id, stage_name="staging", path="/") + + # add item to table via API GW endpoint + response = requests.put( + url, + json.dumps({"id": "id1", "data": "foobar123"}), + ) + assert response.ok + + # assert that the item has been added to the table + dynamo_client = aws_client.dynamodb + result = dynamo_client.get_item(TableName=table_name, Key={"id": {"S": "id1"}}) + assert result["Item"]["data"] == {"S": "foobar123"} + + @markers.aws.needs_fixing + # Doesn't use a fixture that cleans up after itself, and most likely missing roles. Should be moved to common + def test_multiple_api_keys_validate(self, aws_client, create_iam_role_with_policy, cleanups): + request_templates = { + "application/json": json.dumps( + { + "TableName": "MusicCollection", + "Item": { + "id": {"S": "$input.path('id')"}, + "data": {"S": "$input.path('data')"}, + }, + } + ) + } + + role_arn = create_iam_role_with_policy( + RoleName=f"role-apigw-dynamodb-{short_uid()}", + PolicyName=f"policy-apigw-dynamodb-{short_uid()}", + RoleDefinition=APIGATEWAY_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_DYNAMODB_POLICY, + ) + + api_id = self.create_api_gateway_and_deploy( + aws_client.apigateway, + aws_client.dynamodb, + request_templates=request_templates, + is_api_key_required=True, + role_arn=role_arn, + ) + url = path_based_url(api_id=api_id, stage_name="staging", path="/") + + # Create multiple usage plans + usage_plan_ids = [] + for i in range(2): + payload = { + "name": f"APIKEYTEST-PLAN-{i}", + "description": "Description", + "quota": {"limit": 10, "period": "DAY", "offset": 0}, + "throttle": {"rateLimit": 2, "burstLimit": 1}, + "apiStages": [{"apiId": api_id, "stage": "staging"}], + "tags": {"tag_key": "tag_value"}, + } + usage_plan_ids.append(aws_client.apigateway.create_usage_plan(**payload)["id"]) + + api_keys = [] + key_type = "API_KEY" + # Create multiple API Keys in each usage plan + for usage_plan_id, i in itertools.product(usage_plan_ids, range(2)): + api_key = aws_client.apigateway.create_api_key( + name=f"testMultipleApiKeys{i}", enabled=True + ) + payload = { + "usagePlanId": usage_plan_id, + "keyId": api_key["id"], + "keyType": key_type, + } + aws_client.apigateway.create_usage_plan_key(**payload) + api_keys.append(api_key["value"]) + cleanups.append(lambda: aws_client.apigateway.delete_api_key(apiKey=api_key["id"])) + + response = requests.put( + url, + json.dumps({"id": "id1", "data": "foobar123"}), + ) + # when the api key is not passed as part of the header + assert 403 == response.status_code + + # check that all API keys work + for key in api_keys: + response = requests.put( + url, + json.dumps({"id": "id1", "data": "foobar123"}), + headers={"X-API-Key": key}, + ) + # when the api key is passed as part of the header + assert 200 == response.status_code + + for usage_plan_id in usage_plan_ids: + aws_client.apigateway.delete_usage_plan(usagePlanId=usage_plan_id) + + @markers.aws.needs_fixing + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_api_gateway_http_integration_with_path_request_parameter( + self, create_rest_apigw, echo_http_server, aws_client + ): + # start test HTTP backend + backend_base_url = echo_http_server + backend_url = backend_base_url + "/person/{id}" + + # create rest api + api_id, _, _ = create_rest_apigw(name="test") + parent_response = aws_client.apigateway.get_resources(restApiId=api_id) + parent_id = parent_response["items"][0]["id"] + resource_1 = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=parent_id, pathPart="person" + ) + resource_1_id = resource_1["id"] + resource_2 = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=resource_1_id, pathPart="{id}" + ) + resource_2_id = resource_2["id"] + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_2_id, + httpMethod="GET", + authorizationType="NONE", + apiKeyRequired=False, + requestParameters={"method.request.path.id": True}, + ) + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_2_id, + httpMethod="GET", + integrationHttpMethod="GET", + type="HTTP", + uri=backend_url, + timeoutInMillis=3000, + contentHandling="CONVERT_TO_BINARY", + requestParameters={"integration.request.path.id": "method.request.path.id"}, + ) + aws_client.apigateway.create_deployment(restApiId=api_id, stageName="test") + + def _test_invoke(url): + result = requests.get(url) + content = json.loads(to_str(result.content)) + assert 200 == result.status_code + assert re.search( + "http://.*localhost.*/person/123", + content["url"], + ) + + for use_hostname in [True, False]: + for use_ssl in [True, False] if use_hostname else [False]: + url = self._get_invoke_endpoint( + api_id, + stage="test", + path="/person/123", + use_hostname=use_hostname, + use_ssl=use_ssl, + ) + _test_invoke(url) + + def _get_invoke_endpoint( + self, api_id, stage="test", path="/", use_hostname=False, use_ssl=False + ): + path = path or "/" + path = path if path.startswith(path) else f"/{path}" + if use_hostname: + host = f"{api_id}.execute-api.{localstack_host().host}" + return f"{config.external_service_url(host=host)}/{stage}{path}" + return f"{config.internal_service_url()}/restapis/{api_id}/{stage}/_user_request_{path}" + + @markers.aws.needs_fixing + # Doesn't use fixture that cleans up after itself. Should be moved to common + @pytest.mark.skipif(condition=is_next_gen_api(), reason="Failing and not validated") + def test_api_mock_integration_response_params(self, aws_client): + resps = [ + { + "statusCode": "204", + "httpMethod": "OPTIONS", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'POST,OPTIONS'", + "method.response.header.Vary": "'Origin'", + }, + } + ] + api_id = self.create_api_gateway_and_deploy( + aws_client.apigateway, + aws_client.dynamodb, + integration_type="MOCK", + integration_responses=resps, + stage_name=TEST_STAGE_NAME, + ) + + url = path_based_url(api_id=api_id, stage_name=TEST_STAGE_NAME, path="/") + result = requests.options(url) + assert result.ok + assert "Origin" == result.headers.get("vary") + assert "POST,OPTIONS" == result.headers.get("Access-Control-Allow-Methods") + + @markers.aws.validated + def test_response_headers_invocation_with_apigw( + self, aws_client, create_rest_apigw, create_lambda_function, create_role_with_policy + ): + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + function_name = f"test_lambda_{short_uid()}" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_NODEJS_APIGW_INTEGRATION, + handler="apigw_integration.handler", + runtime=Runtime.nodejs18_x, + ) + + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + target_uri = arns.apigateway_invocations_arn( + lambda_arn, aws_client.apigateway.meta.region_name + ) + + api_id, _, root = create_rest_apigw(name=f"test-api-{short_uid()}") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="{proxy+}" + ) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="AWS_PROXY", + uri=target_uri, + credentials=role_arn, + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="400", + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="500", + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="^2.*", + ) + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="400", + selectionPattern="^4.*", + ) + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="500", + selectionPattern="^5.*", + ) + aws_client.apigateway.create_deployment(restApiId=api_id, stageName="api") + + def invoke_api(): + url = api_invoke_url( + api_id=api_id, + stage="api", + path="/hello/world", + ) + result = requests.get(url) + return result + + response = retry(invoke_api, retries=15, sleep=0.8) + assert response.status_code == 300 + assert response.headers["Content-Type"] == "application/xml" + body = xmltodict.parse(response.content) + assert body.get("message") == "completed" + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # the Endpoint URI is wrong for AWS_PROXY because AWS resolves it to the Lambda HTTP endpoint and we keep + # the ARN + "$..log.line07", + "$..log.line10", + # AWS is returning the AWS_PROXY invoke response headers even though they are not considered at all (only + # the lambda payload headers are considered, so this is unhelpful) + "$..log.line12", + # LocalStack does not setup headers the same way when invoking the lambda (Token, additional headers...) + "$..log.line08", + ] + ) + @markers.snapshot.skip_snapshot_verify( + condition=lambda: not is_next_gen_api(), + paths=[ + "$..headers.Content-Length", + "$..headers.Content-Type", + "$..headers.X-Amzn-Trace-Id", + "$..latency", + "$..log", + "$..multiValueHeaders.Content-Length", + "$..multiValueHeaders.Content-Type", + "$..multiValueHeaders.X-Amzn-Trace-Id", + ], + ) + def test_apigw_test_invoke_method_api( + self, + create_rest_apigw, + create_lambda_function, + aws_client, + create_role_with_policy, + region_name, + snapshot, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value( + "latency", value_replacement="", reference_replacement=False + ), + snapshot.transform.jsonpath( + "$..headers.X-Amzn-Trace-Id", value_replacement="x-amz-trace-id" + ), + snapshot.transform.regex( + r"URI: https:\/\/.*?\/2015-03-31", "URI: https:///2015-03-31" + ), + snapshot.transform.regex( + r"Integration latency: \d*? ms", "Integration latency: ms" + ), + snapshot.transform.regex( + r"Date=[a-zA-Z]{3},\s\d{2}\s[a-zA-Z]{3}\s\d{4}\s\d{2}:\d{2}:\d{2}\sGMT", + "Date=Day, dd MMM yyyy hh:mm:ss GMT", + ), + snapshot.transform.regex( + r"x-amzn-RequestId=[a-f0-9-]{36}", "x-amzn-RequestId=" + ), + snapshot.transform.regex( + r"[a-zA-Z]{3}\s[a-zA-Z]{3}\s\d{2}\s\d{2}:\d{2}:\d{2}\sUTC\s\d{4} :", + "DDD MMM dd hh:mm:ss UTC yyyy :", + ), + snapshot.transform.regex( + r"Authorization=.*?,", "Authorization=," + ), + snapshot.transform.regex( + r"X-Amz-Security-Token=.*?\s\[", "X-Amz-Security-Token= [" + ), + snapshot.transform.regex(r"\d{8}T\d{6}Z", ""), + ] + ) + + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + # create test Lambda + function_name = f"test-{short_uid()}" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_NODEJS, + handler="lambda_handler.handler", + runtime=Runtime.nodejs18_x, + ) + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + target_uri = arns.apigateway_invocations_arn(lambda_arn, region_name) + + # create REST API and test resource + rest_api_id, _, root = create_rest_apigw(name=f"test-{short_uid()}") + snapshot.add_transformer(snapshot.transform.regex(rest_api_id, "")) + resource = aws_client.apigateway.create_resource( + restApiId=rest_api_id, parentId=root, pathPart="foo" + ) + resource_id = resource["id"] + + # create method and integration + aws_client.apigateway.put_method( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + aws_client.apigateway.put_integration( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="AWS", + uri=target_uri, + credentials=role_arn, + ) + aws_client.apigateway.put_method_response( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + ) + aws_client.apigateway.put_integration_response( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="", + ) + aws_client.apigateway.create_deployment(restApiId=rest_api_id, stageName="local") + + # run test_invoke_method API #1 + def _test_invoke_call( + path_with_qs: str, body: str | None = None, headers: dict | None = None + ): + kwargs = {} + if body: + kwargs["body"] = body + if headers: + kwargs["headers"] = headers + _response = aws_client.apigateway.test_invoke_method( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod="GET", + pathWithQueryString=path_with_qs, + **kwargs, + ) + assert _response.get("status") == 200 + assert "response from" in json.loads(_response.get("body")).get("body") + return _response + + invoke_simple = retry(_test_invoke_call, retries=15, sleep=1, path_with_qs="/foo") + + def _transform_log(_log: str) -> dict[str, str]: + return {f"line{index:02d}": line for index, line in enumerate(_log.split("\n"))} + + # we want to do very precise matching on the log, and splitting on new lines will help in case the snapshot + # fails + # the snapshot library does not allow to ignore an array index as the last node, so we need to put it in a dict + invoke_simple["log"] = _transform_log(invoke_simple["log"]) + request_id_1 = invoke_simple["log"]["line00"].split(" ")[-1] + snapshot.add_transformer( + snapshot.transform.regex(request_id_1, ""), priority=-1 + ) + snapshot.match("test_invoke_method_response", invoke_simple) + + # run test_invoke_method API #2 + invoke_with_parameters = retry( + _test_invoke_call, + retries=15, + sleep=1, + path_with_qs="/foo?queryTest=value", + body='{"test": "val123"}', + headers={"content-type": "application/json"}, + ) + response_body = json.loads(invoke_with_parameters.get("body")).get("body") + assert "response from" in response_body + assert "val123" in response_body + invoke_with_parameters["log"] = _transform_log(invoke_with_parameters["log"]) + request_id_2 = invoke_with_parameters["log"]["line00"].split(" ")[-1] + snapshot.add_transformer( + snapshot.transform.regex(request_id_2, ""), priority=-1 + ) + snapshot.match("test_invoke_method_response_with_body", invoke_with_parameters) + + @markers.aws.validated + @pytest.mark.parametrize("stage_name", ["local", "dev"]) + def test_apigw_stage_variables( + self, create_lambda_function, create_rest_apigw, stage_name, aws_client + ): + aws_account_id = aws_client.sts.get_caller_identity()["Account"] + region_name = aws_client.apigateway._client_config.region_name + api_id, _, root = create_rest_apigw(name="aws lambda api") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="test" + ) + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + + fn_name = f"test-{short_uid()}" + response = create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_9, + ) + lambda_arn = response["CreateFunctionResponse"]["FunctionArn"] + + if stage_name == "dev": + uri = f"arn:aws:apigateway:{region_name}:lambda:path/2015-03-31/functions/arn:aws:lambda:{region_name}:{aws_account_id}:function:${{stageVariables.lambdaFunction}}/invocations" + else: + uri = f"arn:aws:apigateway:{region_name}:lambda:path/2015-03-31/functions/arn:aws:lambda:{region_name}:{aws_account_id}:function:{fn_name}/invocations" + + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + integrationHttpMethod="POST", + type="AWS", + uri=uri, + requestTemplates={"application/json": '{ "version": "$stageVariables.version" }'}, + ) + create_rest_api_method_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseParameters={ + "method.response.header.Content-Type": False, + "method.response.header.Access-Control-Allow-Origin": False, + }, + ) + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + deployment_id, _ = create_rest_api_deployment(aws_client.apigateway, restApiId=api_id) + + stage_variables = ( + {"lambdaFunction": fn_name, "version": "1.0"} if stage_name == "dev" else {} + ) + create_rest_api_stage( + aws_client.apigateway, + restApiId=api_id, + stageName=stage_name, + deploymentId=deployment_id, + variables=stage_variables, + ) + + source_arn = f"arn:aws:execute-api:{region_name}:{aws_account_id}:{api_id}/*/*/test" + aws_client.lambda_.add_permission( + FunctionName=lambda_arn, + StatementId=str(short_uid()), + Action="lambda:InvokeFunction", + Principal="apigateway.amazonaws.com", + SourceArn=source_arn, + ) + + url = api_invoke_url(api_id, stage=stage_name, path="/test") + response = requests.post(url, json={"test": "test"}) + + if stage_name == "local": + assert response.json() == {"version": ""} + else: + assert response.json() == {"version": "1.0"} + + # TODO replace with fixtures in test_apigateway_integrations + @staticmethod + def create_api_gateway_and_deploy( + apigw_client, + dynamodb_client, + request_templates=None, + response_templates=None, + is_api_key_required=False, + integration_type=None, + integration_responses=None, + stage_name="staging", + role_arn: str = None, + ): + response_templates = response_templates or {} + request_templates = request_templates or {} + integration_type = integration_type or "AWS" + response = apigw_client.create_rest_api(name="my_api", description="this is my api") + api_id = response["id"] + resources = apigw_client.get_resources(restApiId=api_id) + root_resources = [resource for resource in resources["items"] if resource["path"] == "/"] + root_id = root_resources[0]["id"] + + kwargs = {} + if integration_type == "AWS": + resource_util.create_dynamodb_table( + "MusicCollection", partition_key="id", client=dynamodb_client + ) + kwargs["uri"] = ( + f"arn:aws:apigateway:{apigw_client.meta.region_name}:dynamodb:action/PutItem&Table=MusicCollection" + ) + + if role_arn: + kwargs["credentials"] = role_arn + + if not integration_responses: + integration_responses = [{"httpMethod": "PUT", "statusCode": "200"}] + + for resp_details in integration_responses: + apigw_client.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod=resp_details["httpMethod"], + authorizationType="NONE", + apiKeyRequired=is_api_key_required, + ) + + apigw_client.put_method_response( + restApiId=api_id, + resourceId=root_id, + httpMethod=resp_details["httpMethod"], + statusCode="200", + ) + + apigw_client.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod=resp_details["httpMethod"], + integrationHttpMethod=resp_details["httpMethod"], + type=integration_type, + requestTemplates=request_templates, + **kwargs, + ) + + apigw_client.put_integration_response( + restApiId=api_id, + resourceId=root_id, + selectionPattern="", + responseTemplates=response_templates, + **resp_details, + ) + + apigw_client.create_deployment(restApiId=api_id, stageName=stage_name) + return api_id + + +class TestTagging: + @markers.aws.only_localstack + def test_tag_api(self, create_rest_apigw, aws_client, account_id, region_name): + api_name = f"api-{short_uid()}" + tags = {"foo": "bar"} + + # add resource tags + api_id, _, _ = create_rest_apigw(name=api_name, tags={TAG_KEY_CUSTOM_ID: "c0stIOm1d"}) + assert api_id == "c0stIOm1d" + + api_arn = arns.apigateway_restapi_arn(api_id, account_id, region_name) + aws_client.apigateway.tag_resource(resourceArn=api_arn, tags=tags) + + # receive and assert tags + tags_saved = aws_client.apigateway.get_tags(resourceArn=api_arn)["tags"] + assert tags == tags_saved + + +@markers.aws.needs_fixing +def test_apigw_call_api_with_aws_endpoint_url(aws_client, region_name): + headers = mock_aws_request_headers("apigateway", TEST_AWS_ACCESS_KEY_ID, region_name) + headers["Host"] = "apigateway.us-east-2.amazonaws.com:4566" + url = f"{config.internal_service_url()}/apikeys?includeValues=true&name=test%40example.org" + response = requests.get(url, headers=headers) + assert response.ok + content = json.loads(to_str(response.content)) + assert isinstance(content.get("item"), list) + + +@pytest.mark.skipif( + not in_default_partition(), reason="Test not applicable in non-default partitions" +) +@pytest.mark.parametrize("method", ["GET", "ANY"]) +@pytest.mark.parametrize("url_type", [path_based_url, UrlType.HOST_BASED]) +@markers.aws.validated +# TODO clean up the client instances. We might not need 4. +# We might also be ok not parametrizing the method, as it doesn't seem to be what we are testing here +def test_rest_api_multi_region( + method, + url_type, + create_rest_apigw, + aws_client, + aws_client_factory, + create_lambda_function, + create_role_with_policy, +): + stage_name = "test" + client_config = botocore.config.Config( + # Api gateway can throttle requests pretty heavily. Leading to potentially undeleted apis + retries={"max_attempts": 10, "mode": "adaptive"} + ) + apigateway_client_eu = aws_client_factory( + region_name="eu-west-1", config=client_config + ).apigateway + apigateway_client_us = aws_client_factory( + region_name="us-west-1", config=client_config + ).apigateway + + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + api_eu_id, _, root_resource_eu_id = create_rest_apigw( + name="test-eu-region", region_name="eu-west-1" + ) + api_us_id, _, root_resource_us_id = create_rest_apigw( + name="test-us-region", region_name="us-west-1" + ) + + resource_eu_id, _ = create_rest_resource( + apigateway_client_eu, restApiId=api_eu_id, parentId=root_resource_eu_id, pathPart="demo" + ) + resource_us_id, _ = create_rest_resource( + apigateway_client_us, restApiId=api_us_id, parentId=root_resource_us_id, pathPart="demo" + ) + + create_rest_resource_method( + apigateway_client_eu, + restApiId=api_eu_id, + resourceId=resource_eu_id, + httpMethod=method, + authorizationType="None", + ) + create_rest_resource_method( + apigateway_client_us, + restApiId=api_us_id, + resourceId=resource_us_id, + httpMethod=method, + authorizationType="None", + ) + + lambda_name = f"lambda-{short_uid()}" + lambda_eu_west_1_client = aws_client_factory(region_name="eu-west-1").lambda_ + lambda_us_west_1_client = aws_client_factory(region_name="us-west-1").lambda_ + lambda_eu_arn = create_lambda_function( + handler_file=TEST_LAMBDA_NODEJS, + func_name=lambda_name, + runtime=Runtime.nodejs20_x, + region_name="eu-west-1", + client=lambda_eu_west_1_client, + )["CreateFunctionResponse"]["FunctionArn"] + + lambda_us_arn = create_lambda_function( + handler_file=TEST_LAMBDA_NODEJS, + func_name=lambda_name, + runtime=Runtime.nodejs20_x, + region_name="us-west-1", + client=lambda_us_west_1_client, + )["CreateFunctionResponse"]["FunctionArn"] + + lambda_eu_west_1_client.get_waiter("function_active_v2").wait(FunctionName=lambda_name) + lambda_us_west_1_client.get_waiter("function_active_v2").wait(FunctionName=lambda_name) + + uri_eu = arns.apigateway_invocations_arn(lambda_eu_arn, region_name="eu-west-1") + integration_uri, _ = create_rest_api_integration( + apigateway_client_eu, + restApiId=api_eu_id, + resourceId=resource_eu_id, + httpMethod=method, + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=uri_eu, + credentials=role_arn, + ) + apigateway_client_eu.create_deployment(restApiId=api_eu_id, stageName=stage_name) + + uri_us = arns.apigateway_invocations_arn(lambda_us_arn, region_name="us-west-1") + integration_uri, _ = create_rest_api_integration( + apigateway_client_us, + restApiId=api_us_id, + resourceId=resource_us_id, + httpMethod=method, + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=uri_us, + credentials=role_arn, + ) + apigateway_client_us.create_deployment(restApiId=api_us_id, stageName=stage_name) + + def _invoke_url(url): + invoke_response = requests.get(url) + assert invoke_response.status_code == 200 + + endpoint = api_invoke_url( + api_eu_id, stage=stage_name, path="/demo", region="eu-west-1", url_type=url_type + ) + retry(_invoke_url, retries=20, sleep=2, url=endpoint) + endpoint = api_invoke_url( + api_us_id, stage=stage_name, path="/demo", region="us-west-1", url_type=url_type + ) + retry(_invoke_url, retries=20, sleep=2, url=endpoint) + apigateway_client_eu.delete_rest_api(restApiId=api_eu_id) + apigateway_client_us.delete_rest_api(restApiId=api_us_id) + + +class TestIntegrations: + @pytest.mark.parametrize("method", ["GET", "POST"]) + @pytest.mark.parametrize("url_type", [UrlType.PATH_BASED, UrlType.HOST_BASED]) + @pytest.mark.parametrize( + "passthrough_behaviour", ["WHEN_NO_MATCH", "NEVER", "WHEN_NO_TEMPLATES"] + ) + @markers.aws.validated + # TODO What are we testing with the 2 methods, could we cut in half this test by testing only one method? + # Also, we are parametrizing `passthrough_behaviour` but we don't appear to be testing it's behaviour + def test_mock_integration_response( + self, method, url_type, passthrough_behaviour, create_rest_apigw, aws_client, snapshot + ): + stage_name = "test" + api_id, _, root_resource_id = create_rest_apigw(name="mock-api") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root_resource_id, pathPart="{id}" + ) + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=method, + authorizationType="NONE", + ) + integration_uri, _ = create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=method, + type="MOCK", + integrationHttpMethod=method, + passthroughBehavior=passthrough_behaviour, + requestTemplates={"application/json": '{"statusCode":200}'}, + ) + status_code = create_rest_api_method_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=method, + statusCode="200", + responseModels={"application/json": "Empty"}, + ) + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod=method, + statusCode=status_code, + responseTemplates={ + "application/json": '{"statusCode": 200, "id": $input.params().path.id}' + }, + ) + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + endpoint = api_invoke_url(api_id, stage=stage_name, path="/42", url_type=url_type) + + def _invoke_api(url, method): + invoke_response = requests.request( + method, + url, + headers={"Content-Type": "application/json"}, + # verify=False, + ) + assert invoke_response.status_code == 200 + return invoke_response + + result = retry(_invoke_api, retries=20, sleep=2, url=endpoint, method=method) + snapshot.match("mock-response", result.json()) + + @pytest.mark.parametrize("int_type", ["custom", "proxy"]) + @markers.aws.needs_fixing + @pytest.mark.skipif( + condition=is_next_gen_api(), reason="Failing and not validated, not deploying" + ) + # TODO replace with fixtures that will clean resources and replave the `echo_http_server` with `create_echo_http_server`. + # Also has hardcoded localhost endpoint in helper functions + def test_api_gateway_http_integrations( + self, int_type, echo_http_server, monkeypatch, aws_client + ): + monkeypatch.setattr(config, "DISABLE_CUSTOM_CORS_APIGATEWAY", False) + + api_path_backend = "/hello_world" + backend_base_url = echo_http_server + backend_url = f"{backend_base_url}/{api_path_backend}" + + # create API Gateway and connect it to the HTTP_PROXY/HTTP backend + result = self.connect_api_gateway_to_http( + int_type, "test_gateway2", backend_url, path=api_path_backend + ) + + url = path_based_url( + api_id=result["id"], + stage_name=TEST_STAGE_NAME, + path=api_path_backend, + ) + + # make sure CORS headers are present + origin = "localhost" + result = requests.options(url, headers={"origin": origin}) + assert result.status_code == 200 + assert re.match(result.headers["Access-Control-Allow-Origin"].replace("*", ".*"), origin) + assert "POST" in result.headers["Access-Control-Allow-Methods"] + assert "PATCH" in result.headers["Access-Control-Allow-Methods"] + + custom_result = json.dumps({"foo": "bar"}) + + # make test GET request to gateway + result = requests.get(url) + assert 200 == result.status_code + expected = custom_result if int_type == "custom" else "{}" + assert expected == json.loads(to_str(result.content))["data"] + + # make test POST request to gateway + data = json.dumps({"data": 123}) + result = requests.post(url, data=data) + assert 200 == result.status_code + expected = custom_result if int_type == "custom" else data + assert expected == json.loads(to_str(result.content))["data"] + + # make test POST request with non-JSON content type + data = "test=123" + ctype = "application/x-www-form-urlencoded" + result = requests.post(url, data=data, headers={"content-type": ctype}) + assert 200 == result.status_code + content = json.loads(to_str(result.content)) + headers = CaseInsensitiveDict(content["headers"]) + expected = custom_result if int_type == "custom" else data + assert expected == content["data"] + assert ctype == headers["content-type"] + + def connect_api_gateway_to_http( + self, int_type, gateway_name, target_url, methods=None, path=None + ): + if methods is None: + methods = [] + if not methods: + methods = ["GET", "POST"] + if not path: + path = "/" + resources = {} + resource_path = path.replace("/", "") + req_templates = ( + {"application/json": json.dumps({"foo": "bar"})} if int_type == "custom" else {} + ) + resources[resource_path] = [ + { + "httpMethod": method, + "integrations": [ + { + "type": "HTTP" if int_type == "custom" else "HTTP_PROXY", + "uri": target_url, + "requestTemplates": req_templates, + "responseTemplates": {}, + } + ], + } + for method in methods + ] + return resource_util.create_api_gateway( + name=gateway_name, resources=resources, stage_name=TEST_STAGE_NAME + ) diff --git a/tests/aws/services/apigateway/test_apigateway_basic.snapshot.json b/tests/aws/services/apigateway/test_apigateway_basic.snapshot.json new file mode 100644 index 0000000000000..4cdbcb8e1e311 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_basic.snapshot.json @@ -0,0 +1,262 @@ +{ + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigw_test_invoke_method_api": { + "recorded-date": "11-04-2025, 18:02:16", + "recorded-content": { + "test_invoke_method_response": { + "body": { + "statusCode": 200, + "body": "\"response from localstack lambda: {}\"", + "isBase64Encoded": false, + "headers": {} + }, + "headers": { + "Content-Type": "application/json", + "X-Amzn-Trace-Id": "" + }, + "latency": "", + "log": { + "line00": "Execution log for request ", + "line01": "DDD MMM dd hh:mm:ss UTC yyyy : Starting execution for request: ", + "line02": "DDD MMM dd hh:mm:ss UTC yyyy : HTTP Method: GET, Resource Path: /foo", + "line03": "DDD MMM dd hh:mm:ss UTC yyyy : Method request path: {}", + "line04": "DDD MMM dd hh:mm:ss UTC yyyy : Method request query string: {}", + "line05": "DDD MMM dd hh:mm:ss UTC yyyy : Method request headers: {}", + "line06": "DDD MMM dd hh:mm:ss UTC yyyy : Method request body before transformations: ", + "line07": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint request URI: https:///2015-03-31/functions/arn::lambda::111111111111:function:/invocations", + "line08": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint request headers: {x-amzn-lambda-integration-tag=, Authorization=, X-Amz-Date=, x-amzn-apigateway-api-id=, Accept=application/json, User-Agent=AmazonAPIGateway_, X-Amz-Security-Token= [TRUNCATED]", + "line09": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint request body after transformations: ", + "line10": "DDD MMM dd hh:mm:ss UTC yyyy : Sending request to https://lambda..amazonaws.com/2015-03-31/functions/arn::lambda::111111111111:function:/invocations", + "line11": "DDD MMM dd hh:mm:ss UTC yyyy : Received response. Status: 200, Integration latency: ms", + "line12": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint response headers: {Date=Day, dd MMM yyyy hh:mm:ss GMT, Content-Type=application/json, Content-Length=104, Connection=keep-alive, x-amzn-RequestId=, x-amzn-Remapped-Content-Length=0, X-Amz-Executed-Version=$LATEST, X-Amzn-Trace-Id=}", + "line13": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint response body before transformations: {\"statusCode\":200,\"body\":\"\\\"response from localstack lambda: {}\\\"\",\"isBase64Encoded\":false,\"headers\":{}}", + "line14": "DDD MMM dd hh:mm:ss UTC yyyy : Method response body after transformations: {\"statusCode\":200,\"body\":\"\\\"response from localstack lambda: {}\\\"\",\"isBase64Encoded\":false,\"headers\":{}}", + "line15": "DDD MMM dd hh:mm:ss UTC yyyy : Method response headers: {X-Amzn-Trace-Id=, Content-Type=application/json}", + "line16": "DDD MMM dd hh:mm:ss UTC yyyy : Successfully completed execution", + "line17": "DDD MMM dd hh:mm:ss UTC yyyy : Method completed with status: 200", + "line18": "" + }, + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ], + "X-Amzn-Trace-Id": [ + "" + ] + }, + "status": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "test_invoke_method_response_with_body": { + "body": { + "statusCode": 200, + "body": "\"response from localstack lambda: {\\\"test\\\":\\\"val123\\\"}\"", + "isBase64Encoded": false, + "headers": {} + }, + "headers": { + "Content-Type": "application/json", + "X-Amzn-Trace-Id": "" + }, + "latency": "", + "log": { + "line00": "Execution log for request ", + "line01": "DDD MMM dd hh:mm:ss UTC yyyy : Starting execution for request: ", + "line02": "DDD MMM dd hh:mm:ss UTC yyyy : HTTP Method: GET, Resource Path: /foo", + "line03": "DDD MMM dd hh:mm:ss UTC yyyy : Method request path: {}", + "line04": "DDD MMM dd hh:mm:ss UTC yyyy : Method request query string: {queryTest=value}", + "line05": "DDD MMM dd hh:mm:ss UTC yyyy : Method request headers: {content-type=application/json}", + "line06": "DDD MMM dd hh:mm:ss UTC yyyy : Method request body before transformations: {\"test\": \"val123\"}", + "line07": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint request URI: https:///2015-03-31/functions/arn::lambda::111111111111:function:/invocations", + "line08": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint request headers: {x-amzn-lambda-integration-tag=, Authorization=, X-Amz-Date=, x-amzn-apigateway-api-id=, Accept=application/json, User-Agent=AmazonAPIGateway_, X-Amz-Security-Token= [TRUNCATED]", + "line09": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint request body after transformations: {\"test\": \"val123\"}", + "line10": "DDD MMM dd hh:mm:ss UTC yyyy : Sending request to https://lambda..amazonaws.com/2015-03-31/functions/arn::lambda::111111111111:function:/invocations", + "line11": "DDD MMM dd hh:mm:ss UTC yyyy : Received response. Status: 200, Integration latency: ms", + "line12": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint response headers: {Date=Day, dd MMM yyyy hh:mm:ss GMT, Content-Type=application/json, Content-Length=131, Connection=keep-alive, x-amzn-RequestId=, x-amzn-Remapped-Content-Length=0, X-Amz-Executed-Version=$LATEST, X-Amzn-Trace-Id=}", + "line13": "DDD MMM dd hh:mm:ss UTC yyyy : Endpoint response body before transformations: {\"statusCode\":200,\"body\":\"\\\"response from localstack lambda: {\\\\\\\"test\\\\\\\":\\\\\\\"val123\\\\\\\"}\\\"\",\"isBase64Encoded\":false,\"headers\":{}}", + "line14": "DDD MMM dd hh:mm:ss UTC yyyy : Method response body after transformations: {\"statusCode\":200,\"body\":\"\\\"response from localstack lambda: {\\\\\\\"test\\\\\\\":\\\\\\\"val123\\\\\\\"}\\\"\",\"isBase64Encoded\":false,\"headers\":{}}", + "line15": "DDD MMM dd hh:mm:ss UTC yyyy : Method response headers: {X-Amzn-Trace-Id=, Content-Type=application/json}", + "line16": "DDD MMM dd hh:mm:ss UTC yyyy : Successfully completed execution", + "line17": "DDD MMM dd hh:mm:ss UTC yyyy : Method completed with status: 200", + "line18": "" + }, + "multiValueHeaders": { + "Content-Type": [ + "application/json" + ], + "X-Amzn-Trace-Id": [ + "" + ] + }, + "status": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_update_rest_api_deployment": { + "recorded-date": "12-04-2024, 21:24:49", + "recorded-content": { + "after-update": { + "createdDate": "datetime", + "description": "new-description", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigateway_with_custom_authorization_method": { + "recorded-date": "12-04-2024, 22:31:50", + "recorded-content": { + "api-id": { + "api_id": "" + }, + "authorizer": { + "authType": "custom", + "authorizerUri": "", + "id": "", + "identitySource": "method.request.header.Auth", + "name": "lambda_authorizer", + "type": "TOKEN", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-response": { + "apiKeyRequired": true, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "GET", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_mock_integration": { + "recorded-date": "29-05-2024, 20:06:17", + "recorded-content": { + "mocked-response": { + "echo": "foobar", + "response": "mocked" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.PATH_BASED-GET]": { + "recorded-date": "30-05-2024, 00:09:54", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.PATH_BASED-POST]": { + "recorded-date": "30-05-2024, 00:10:00", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.HOST_BASED-GET]": { + "recorded-date": "30-05-2024, 00:10:09", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.HOST_BASED-POST]": { + "recorded-date": "30-05-2024, 00:10:20", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.PATH_BASED-GET]": { + "recorded-date": "30-05-2024, 00:10:28", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.PATH_BASED-POST]": { + "recorded-date": "30-05-2024, 00:10:42", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.HOST_BASED-GET]": { + "recorded-date": "30-05-2024, 00:10:53", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.HOST_BASED-POST]": { + "recorded-date": "30-05-2024, 00:11:00", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.PATH_BASED-GET]": { + "recorded-date": "30-05-2024, 00:11:09", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.PATH_BASED-POST]": { + "recorded-date": "30-05-2024, 00:11:14", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.HOST_BASED-GET]": { + "recorded-date": "30-05-2024, 00:11:22", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.HOST_BASED-POST]": { + "recorded-date": "30-05-2024, 00:11:32", + "recorded-content": { + "mock-response": { + "id": 42, + "statusCode": 200 + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_basic.validation.json b/tests/aws/services/apigateway/test_apigateway_basic.validation.json new file mode 100644 index 0000000000000..43de03144651a --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_basic.validation.json @@ -0,0 +1,71 @@ +{ + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_lambda_asynchronous_invocation": { + "last_validated_date": "2024-05-29T19:36:34+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_api_gateway_mock_integration": { + "last_validated_date": "2024-05-29T20:06:17+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigateway_with_custom_authorization_method": { + "last_validated_date": "2024-04-12T22:31:50+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigw_stage_variables[dev]": { + "last_validated_date": "2024-07-12T20:04:21+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigw_stage_variables[local]": { + "last_validated_date": "2024-07-12T20:04:15+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_apigw_test_invoke_method_api": { + "last_validated_date": "2025-04-11T18:03:13+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestAPIGateway::test_update_rest_api_deployment": { + "last_validated_date": "2024-04-12T21:24:49+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.HOST_BASED-GET]": { + "last_validated_date": "2024-05-30T00:10:53+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.HOST_BASED-POST]": { + "last_validated_date": "2024-05-30T00:11:00+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.PATH_BASED-GET]": { + "last_validated_date": "2024-05-30T00:10:28+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[NEVER-UrlType.PATH_BASED-POST]": { + "last_validated_date": "2024-05-30T00:10:42+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.HOST_BASED-GET]": { + "last_validated_date": "2024-05-30T00:10:09+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.HOST_BASED-POST]": { + "last_validated_date": "2024-05-30T00:10:20+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.PATH_BASED-GET]": { + "last_validated_date": "2024-05-30T00:09:54+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_MATCH-UrlType.PATH_BASED-POST]": { + "last_validated_date": "2024-05-30T00:10:00+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.HOST_BASED-GET]": { + "last_validated_date": "2024-05-30T00:11:22+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.HOST_BASED-POST]": { + "last_validated_date": "2024-05-30T00:11:32+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.PATH_BASED-GET]": { + "last_validated_date": "2024-05-30T00:11:09+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::TestIntegrations::test_mock_integration_response[WHEN_NO_TEMPLATES-UrlType.PATH_BASED-POST]": { + "last_validated_date": "2024-05-30T00:11:14+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[UrlType.HOST_BASED-ANY]": { + "last_validated_date": "2024-05-29T23:33:41+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[UrlType.HOST_BASED-GET]": { + "last_validated_date": "2024-05-29T23:33:12+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[path_based_url-ANY]": { + "last_validated_date": "2024-05-29T23:32:43+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_basic.py::test_rest_api_multi_region[path_based_url-GET]": { + "last_validated_date": "2024-05-29T23:32:08+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_canary.py b/tests/aws/services/apigateway/test_apigateway_canary.py new file mode 100644 index 0000000000000..23c2ae075ed16 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_canary.py @@ -0,0 +1,686 @@ +import json + +import pytest +import requests +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url + + +@pytest.fixture +def create_api_for_deployment(aws_client, create_rest_apigw): + def _create(response_template=None): + # create API, method, integration, deployment + api_id, _, root_id = create_rest_apigw() + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + authorizationType="NONE", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + statusCode="200", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + response_template = response_template or { + "statusCode": 200, + "message": "default deployment", + } + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": json.dumps(response_template)}, + ) + + return api_id, root_id + + return _create + + +class TestStageCrudCanary: + @markers.aws.validated + def test_create_update_stages( + self, create_api_for_deployment, aws_client, create_rest_apigw, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("id"), + ] + ) + api_id, resource_id = create_api_for_deployment() + + create_deployment_1 = aws_client.apigateway.create_deployment(restApiId=api_id) + snapshot.match("create-deployment-1", create_deployment_1) + deployment_id = create_deployment_1["id"] + + aws_client.apigateway.update_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/responseTemplates/application~1json", + "value": json.dumps({"statusCode": 200, "message": "second deployment"}), + } + ], + ) + + create_deployment_2 = aws_client.apigateway.create_deployment(restApiId=api_id) + snapshot.match("create-deployment-2", create_deployment_2) + deployment_id_2 = create_deployment_2["id"] + + stage_name = "dev" + create_stage = aws_client.apigateway.create_stage( + restApiId=api_id, + stageName=stage_name, + deploymentId=deployment_id, + description="dev stage", + variables={ + "testVar": "default", + }, + canarySettings={ + "deploymentId": deployment_id_2, + "percentTraffic": 50, + "stageVariableOverrides": { + "testVar": "canary", + }, + }, + ) + snapshot.match("create-stage", create_stage) + + get_stage = aws_client.apigateway.get_stage( + restApiId=api_id, + stageName=stage_name, + ) + snapshot.match("get-stage", get_stage) + + update_stage = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + { + "op": "replace", + "path": "/canarySettings/stageVariableOverrides/testVar", + "value": "updated", + }, + ], + ) + snapshot.match("update-stage-canary-settings-overrides", update_stage) + + # remove canary settings + update_stage = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "remove", "path": "/canarySettings"}, + ], + ) + snapshot.match("update-stage-remove-canary-settings", update_stage) + + get_stage = aws_client.apigateway.get_stage( + restApiId=api_id, + stageName=stage_name, + ) + snapshot.match("get-stage-after-remove", get_stage) + + @markers.aws.validated + def test_create_canary_deployment_with_stage( + self, create_api_for_deployment, aws_client, create_rest_apigw, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("id"), + ] + ) + api_id, resource_id = create_api_for_deployment() + + create_deployment = aws_client.apigateway.create_deployment(restApiId=api_id) + snapshot.match("create-deployment", create_deployment) + deployment_id = create_deployment["id"] + + stage_name = "dev" + create_stage = aws_client.apigateway.create_stage( + restApiId=api_id, + stageName=stage_name, + deploymentId=deployment_id, + description="dev stage", + variables={ + "testVar": "default", + }, + ) + snapshot.match("create-stage", create_stage) + + create_canary_deployment = aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name, + canarySettings={ + "percentTraffic": 50, + "stageVariableOverrides": { + "testVar": "canary", + }, + }, + ) + snapshot.match("create-canary-deployment", create_canary_deployment) + + get_stage = aws_client.apigateway.get_stage( + restApiId=api_id, + stageName=stage_name, + ) + snapshot.match("get-stage", get_stage) + + @markers.aws.validated + def test_create_canary_deployment( + self, create_api_for_deployment, aws_client, create_rest_apigw, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("id"), + ] + ) + api_id, resource_id = create_api_for_deployment() + + create_deployment = aws_client.apigateway.create_deployment(restApiId=api_id) + snapshot.match("create-deployment", create_deployment) + deployment_id = create_deployment["id"] + + stage_name_1 = "dev1" + create_stage = aws_client.apigateway.create_stage( + restApiId=api_id, + stageName=stage_name_1, + deploymentId=deployment_id, + description="dev stage", + variables={ + "testVar": "default", + }, + canarySettings={ + "deploymentId": deployment_id, + "percentTraffic": 40, + "stageVariableOverrides": { + "testVar": "canary1", + }, + }, + ) + snapshot.match("create-stage", create_stage) + + create_canary_deployment = aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name_1, + canarySettings={ + "percentTraffic": 50, + "stageVariableOverrides": { + "testVar": "canary2", + }, + }, + ) + snapshot.match("create-canary-deployment", create_canary_deployment) + canary_deployment_id = create_canary_deployment["id"] + + get_stage_1 = aws_client.apigateway.get_stage( + restApiId=api_id, + stageName=stage_name_1, + ) + snapshot.match("get-stage-1", get_stage_1) + + stage_name_2 = "dev2" + create_stage_2 = aws_client.apigateway.create_stage( + restApiId=api_id, + stageName=stage_name_2, + deploymentId=deployment_id, + description="dev stage", + variables={ + "testVar": "default", + }, + canarySettings={ + "deploymentId": canary_deployment_id, + "percentTraffic": 60, + "stageVariableOverrides": { + "testVar": "canary-overridden", + }, + }, + ) + snapshot.match("create-stage-2", create_stage_2) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_stage( + restApiId=api_id, + stageName="dev3", + deploymentId=deployment_id, + description="dev stage", + canarySettings={ + "deploymentId": "deploy", + }, + ) + snapshot.match("bad-canary-deployment-id", e.value.response) + + @markers.aws.validated + def test_create_canary_deployment_by_stage_update( + self, create_api_for_deployment, aws_client, create_rest_apigw, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("id"), + ] + ) + api_id, resource_id = create_api_for_deployment() + + create_deployment = aws_client.apigateway.create_deployment(restApiId=api_id) + snapshot.match("create-deployment", create_deployment) + deployment_id = create_deployment["id"] + + create_deployment_2 = aws_client.apigateway.create_deployment(restApiId=api_id) + snapshot.match("create-deployment-2", create_deployment_2) + deployment_id_2 = create_deployment_2["id"] + + stage_name = "dev" + create_stage = aws_client.apigateway.create_stage( + restApiId=api_id, + stageName=stage_name, + deploymentId=deployment_id, + description="dev stage", + variables={ + "testVar": "default", + }, + ) + snapshot.match("create-stage", create_stage) + + update_stage = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + { + "op": "replace", + "path": "/canarySettings/deploymentId", + "value": deployment_id_2, + }, + ], + ) + snapshot.match("update-stage-with-deployment", update_stage) + + update_stage = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + { + "op": "remove", + "path": "/canarySettings", + }, + ], + ) + snapshot.match("remove-stage-canary", update_stage) + + update_stage = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "replace", "path": "/canarySettings/percentTraffic", "value": "50"} + ], + ) + snapshot.match("update-stage-with-percent", update_stage) + + get_stage = aws_client.apigateway.get_stage( + restApiId=api_id, + stageName=stage_name, + ) + snapshot.match("get-stage", get_stage) + + @markers.aws.validated + def test_create_canary_deployment_validation( + self, create_api_for_deployment, aws_client, create_rest_apigw, snapshot + ): + api_id, resource_id = create_api_for_deployment() + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_deployment( + restApiId=api_id, + canarySettings={ + "percentTraffic": 50, + "stageVariableOverrides": { + "testVar": "canary", + }, + }, + ) + snapshot.match("create-canary-deployment-no-stage", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName="", + canarySettings={ + "percentTraffic": 50, + "stageVariableOverrides": { + "testVar": "canary", + }, + }, + ) + snapshot.match("create-canary-deployment-empty-stage", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName="non-existing", + canarySettings={ + "percentTraffic": 50, + "stageVariableOverrides": { + "testVar": "canary", + }, + }, + ) + snapshot.match("create-canary-deployment-non-existing-stage", e.value.response) + + @markers.aws.validated + def test_update_stage_canary_deployment_validation( + self, create_api_for_deployment, aws_client, create_rest_apigw, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("deploymentId")) + api_id, resource_id = create_api_for_deployment() + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + get_stage = aws_client.apigateway.get_stage( + restApiId=api_id, + stageName=stage_name, + ) + snapshot.match("get-stage", get_stage) + + aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name, + canarySettings={ + "percentTraffic": 50, + "stageVariableOverrides": { + "testVar": "canary", + }, + }, + ) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "remove", "path": "/canarySettings/stageVariableOverrides"}, + ], + ) + snapshot.match("update-stage-canary-settings-remove-overrides", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "remove", "path": "/canarySettings/badPath"}, + ], + ) + snapshot.match("update-stage-canary-settings-bad-path", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "replace", "path": "/canarySettings", "value": "test"}, + ], + ) + snapshot.match("update-stage-canary-settings-bad-path-2", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "replace", "path": "/canarySettings/badPath", "value": "badPath"}, + ], + ) + snapshot.match("update-stage-canary-settings-replace-bad-path", e.value.response) + + # create deployment and stage with no canary settings + stage_no_canary = "dev2" + deployment_2 = aws_client.apigateway.create_deployment( + restApiId=api_id, stageName=stage_no_canary + ) + deployment_2_id = deployment_2["id"] + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_no_canary, + patchOperations=[ + # you need to use replace for every canarySettings, `add` is not supported + {"op": "add", "path": "/canarySettings/deploymentId", "value": deployment_2_id}, + ], + ) + snapshot.match("update-stage-add-deployment", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_no_canary, + patchOperations=[ + {"op": "replace", "path": "/canarySettings/deploymentId", "value": "deploy"}, + ], + ) + snapshot.match("update-stage-no-deployment", e.value.response) + + @markers.aws.validated + def test_update_stage_with_copy_ops( + self, create_api_for_deployment, aws_client, create_rest_apigw, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("id"), + ] + ) + api_id, resource_id = create_api_for_deployment() + + stage_name = "dev" + deployment_1 = aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name, + variables={ + "testVar": "test", + "testVar2": "test2", + }, + ) + snapshot.match("deployment-1", deployment_1) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + { + "op": "copy", + "path": "/canarySettings/stageVariableOverrides", + "from": "/variables", + }, + {"op": "copy", "path": "/canarySettings/deploymentId", "from": "/deploymentId"}, + ], + ) + snapshot.match("copy-with-bad-statement", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + { + "op": "copy", + "from": "/canarySettings/stageVariableOverrides", + "path": "/variables", + }, + {"op": "copy", "from": "/canarySettings/deploymentId", "path": "/deploymentId"}, + ], + ) + snapshot.match("copy-with-no-replace", e.value.response) + + update_stage = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "replace", "value": "0.0", "path": "/canarySettings/percentTraffic"}, + # the example in the docs is misleading, the copy op only works from a canary to promote it to default + {"op": "copy", "from": "/canarySettings/deploymentId", "path": "/deploymentId"}, + { + "op": "copy", + "from": "/canarySettings/stageVariableOverrides", + "path": "/variables", + }, + ], + ) + snapshot.match("update-stage-with-copy", update_stage) + + deployment_canary = aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name, + canarySettings={ + "percentTraffic": 50, + "stageVariableOverrides": {"testVar": "override"}, + }, + ) + snapshot.match("deployment-canary", deployment_canary) + + get_stage = aws_client.apigateway.get_stage( + restApiId=api_id, + stageName=stage_name, + ) + snapshot.match("get-stage", get_stage) + + update_stage_2 = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "replace", "value": "0.0", "path": "/canarySettings/percentTraffic"}, + # copy is said to be unsupported, but it is partially. It actually doesn't copy, just apply the first + # call above, create the canary with default params and ignore what's under + # https://docs.aws.amazon.com/apigateway/latest/api/patch-operations.html#UpdateStage-Patch + {"op": "copy", "from": "/canarySettings/deploymentId", "path": "/deploymentId"}, + { + "op": "copy", + "from": "/canarySettings/stageVariableOverrides", + "path": "/variables", + }, + ], + ) + snapshot.match("update-stage-with-copy-2", update_stage_2) + + +class TestCanaryDeployments: + @markers.aws.validated + def test_invoking_canary_deployment(self, aws_client, create_api_for_deployment, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("id"), + ] + ) + api_id, resource_id = create_api_for_deployment( + response_template={ + "statusCode": 200, + "message": "default deployment", + "variable": "$stageVariables.testVar", + "nonExistingDefault": "$stageVariables.noStageVar", + "nonOverridden": "$stageVariables.defaultVar", + "isCanary": "$context.isCanaryRequest", + } + ) + + stage_name = "dev" + create_deployment_1 = aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name, + variables={ + "testVar": "default", + "defaultVar": "default", + }, + ) + snapshot.match("create-deployment-1", create_deployment_1) + + aws_client.apigateway.update_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/responseTemplates/application~1json", + "value": json.dumps( + { + "statusCode": 200, + "message": "canary deployment", + "variable": "$stageVariables.testVar", + "nonExistingDefault": "$stageVariables.noStageVar", + "nonOverridden": "$stageVariables.defaultVar", + "isCanary": "$context.isCanaryRequest", + } + ), + } + ], + ) + + create_deployment_2 = aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name, + canarySettings={ + "percentTraffic": 0, + "stageVariableOverrides": { + "testVar": "canary", + "noStageVar": "canary", + }, + }, + ) + snapshot.match("create-deployment-2", create_deployment_2) + + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name, path="/") + + def invoke_api(url: str, expected: str) -> dict: + _response = requests.get(url, verify=False) + assert _response.ok + response_content = _response.json() + assert expected in response_content["message"] + return response_content + + response_data = retry( + invoke_api, sleep=2, retries=10, url=invocation_url, expected="default" + ) + snapshot.match("response-deployment-1", response_data) + + # update stage to always redirect to canary + update_stage = aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=stage_name, + patchOperations=[ + {"op": "replace", "path": "/canarySettings/percentTraffic", "value": "100.0"}, + ], + ) + snapshot.match("update-stage", update_stage) + + response_data = retry( + invoke_api, sleep=2, retries=10, url=invocation_url, expected="canary" + ) + snapshot.match("response-canary-deployment", response_data) diff --git a/tests/aws/services/apigateway/test_apigateway_canary.snapshot.json b/tests/aws/services/apigateway/test_apigateway_canary.snapshot.json new file mode 100644 index 0000000000000..9015ef1d1fcb6 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_canary.snapshot.json @@ -0,0 +1,743 @@ +{ + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_update_stages": { + "recorded-date": "30-05-2025, 16:53:20", + "recorded-content": { + "create-deployment-1": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-deployment-2": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "stageVariableOverrides": { + "testVar": "canary" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "stageVariableOverrides": { + "testVar": "canary" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-stage-canary-settings-overrides": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "stageVariableOverrides": { + "testVar": "updated" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-stage-remove-canary-settings": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stage-after-remove": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_with_stage": { + "recorded-date": "30-05-2025, 16:54:10", + "recorded-content": { + "create-deployment": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-canary-deployment": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "stageVariableOverrides": { + "testVar": "canary" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment": { + "recorded-date": "30-05-2025, 19:27:57", + "recorded-content": { + "create-deployment": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 40.0, + "stageVariableOverrides": { + "testVar": "canary1" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev1", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-canary-deployment": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-stage-1": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "stageVariableOverrides": { + "testVar": "canary2" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev1", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-stage-2": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 60.0, + "stageVariableOverrides": { + "testVar": "canary-overridden" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev2", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "bad-canary-deployment-id": { + "Error": { + "Code": "BadRequestException", + "Message": "Deployment id does not exist" + }, + "message": "Deployment id does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_by_stage_update": { + "recorded-date": "30-05-2025, 21:04:43", + "recorded-content": { + "create-deployment": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-deployment-2": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-stage-with-deployment": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 0.0, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-stage-canary": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-stage-with-percent": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "description": "dev stage", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_validation": { + "recorded-date": "30-05-2025, 19:06:19", + "recorded-content": { + "create-canary-deployment-no-stage": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid deployment content specified.Non null and non empty stageName must be provided for canary deployment. Provided value is null" + }, + "message": "Invalid deployment content specified.Non null and non empty stageName must be provided for canary deployment. Provided value is null", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-canary-deployment-empty-stage": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid deployment content specified.Non null and non empty stageName must be provided for canary deployment. Provided value is " + }, + "message": "Invalid deployment content specified.Non null and non empty stageName must be provided for canary deployment. Provided value is ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-canary-deployment-non-existing-stage": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid deployment content specified.Stage non-existing must already be created before making a canary release deployment" + }, + "message": "Invalid deployment content specified.Stage non-existing must already be created before making a canary release deployment", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_update_stage_canary_deployment_validation": { + "recorded-date": "30-05-2025, 22:27:14", + "recorded-content": { + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-stage-canary-settings-remove-overrides": { + "Error": { + "Code": "BadRequestException", + "Message": "Cannot remove method setting canarySettings/stageVariableOverrides because there is no method setting for this method " + }, + "message": "Cannot remove method setting canarySettings/stageVariableOverrides because there is no method setting for this method ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage-canary-settings-bad-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Cannot remove method setting canarySettings/badPath because there is no method setting for this method " + }, + "message": "Cannot remove method setting canarySettings/badPath because there is no method setting for this method ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage-canary-settings-bad-path-2": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid method setting path: /canarySettings. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]" + }, + "message": "Invalid method setting path: /canarySettings. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage-canary-settings-replace-bad-path": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid method setting path: /canarySettings/badPath. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]" + }, + "message": "Invalid method setting path: /canarySettings/badPath. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage-add-deployment": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid add operation with path: /canarySettings/deploymentId" + }, + "message": "Invalid add operation with path: /canarySettings/deploymentId", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage-no-deployment": { + "Error": { + "Code": "BadRequestException", + "Message": "Deployment id does not exist" + }, + "message": "Deployment id does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestCanaryDeployments::test_invoking_canary_deployment": { + "recorded-date": "30-05-2025, 17:06:30", + "recorded-content": { + "create-deployment-1": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-deployment-2": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "response-deployment-1": { + "isCanary": "false", + "message": "default deployment", + "nonExistingDefault": "", + "nonOverridden": "default", + "statusCode": 200, + "variable": "default" + }, + "update-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 100.0, + "stageVariableOverrides": { + "noStageVar": "canary", + "testVar": "canary" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "defaultVar": "default", + "testVar": "default" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-canary-deployment": { + "isCanary": "true", + "message": "canary deployment", + "nonExistingDefault": "canary", + "nonOverridden": "default", + "statusCode": 200, + "variable": "canary" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_update_stage_with_copy_ops": { + "recorded-date": "30-05-2025, 21:21:21", + "recorded-content": { + "deployment-1": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "copy-with-bad-statement": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid copy operation with path: /canarySettings/stageVariableOverrides and from /variables. Valid copy:path are [/deploymentId, /variables] and valid copy:from are [/canarySettings/deploymentId, /canarySettings/stageVariableOverrides]" + }, + "message": "Invalid copy operation with path: /canarySettings/stageVariableOverrides and from /variables. Valid copy:path are [/deploymentId, /variables] and valid copy:from are [/canarySettings/deploymentId, /canarySettings/stageVariableOverrides]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-with-no-replace": { + "Error": { + "Code": "BadRequestException", + "Message": "Promotion not available. Canary does not exist." + }, + "message": "Promotion not available. Canary does not exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage-with-copy": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 0.0, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "test", + "testVar2": "test2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deployment-canary": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 50.0, + "stageVariableOverrides": { + "testVar": "override" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "test", + "testVar2": "test2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-stage-with-copy-2": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "canarySettings": { + "deploymentId": "", + "percentTraffic": 0.0, + "stageVariableOverrides": { + "testVar": "override" + }, + "useStageCache": false + }, + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tracingEnabled": false, + "variables": { + "testVar": "override", + "testVar2": "test2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_canary.validation.json b/tests/aws/services/apigateway/test_apigateway_canary.validation.json new file mode 100644 index 0000000000000..11fe0f8d00ad0 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_canary.validation.json @@ -0,0 +1,26 @@ +{ + "tests/aws/services/apigateway/test_apigateway_canary.py::TestCanaryDeployments::test_invoking_canary_deployment": { + "last_validated_date": "2025-05-30T17:06:30+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment": { + "last_validated_date": "2025-05-30T19:27:57+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_by_stage_update": { + "last_validated_date": "2025-05-30T21:04:43+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_validation": { + "last_validated_date": "2025-05-30T19:06:19+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_canary_deployment_with_stage": { + "last_validated_date": "2025-05-30T16:54:10+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_create_update_stages": { + "last_validated_date": "2025-05-30T16:53:20+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_update_stage_canary_deployment_validation": { + "last_validated_date": "2025-05-30T22:27:14+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_canary.py::TestStageCrudCanary::test_update_stage_with_copy_ops": { + "last_validated_date": "2025-05-30T21:21:21+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_common.py b/tests/aws/services/apigateway/test_apigateway_common.py new file mode 100644 index 0000000000000..50d032e0d2245 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_common.py @@ -0,0 +1,1840 @@ +import json +import textwrap +import time +from operator import itemgetter + +import pytest +import requests +from botocore.exceptions import ClientError + +from localstack.aws.api.lambda_ import Runtime +from localstack.constants import TAG_KEY_CUSTOM_ID +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import get_partition, parse_arn +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import ( + api_invoke_url, + create_rest_api_deployment, + create_rest_api_integration, + create_rest_api_stage, + create_rest_resource_method, +) +from tests.aws.services.apigateway.conftest import APIGATEWAY_ASSUME_ROLE_POLICY, is_next_gen_api +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_AWS_PROXY + + +def _create_mock_integration_with_200_response_template( + aws_client, api_id: str, resource_id: str, http_method: str, response_template: dict +): + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + authorizationType="NONE", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": json.dumps(response_template)}, + ) + + +class TestApiGatewayCommon: + """ + In this class we won't test individual CRUD API calls but how those will affect the integrations and + requests/responses from the API. + """ + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.invalid-request-body.Type", + "$.missing-required-qs-request-params-get.Type", + "$.missing-required-headers-request-params-get.Type", + "$.missing-all-required-request-params-post.Type", + ] + ) + def test_api_gateway_request_validator( + self, create_lambda_function, create_rest_apigw, apigw_redeploy_api, snapshot, aws_client + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("requestValidatorId"), + snapshot.transform.key_value("cacheNamespace"), + snapshot.transform.key_value("id"), # deployment id + snapshot.transform.key_value("fn_name"), # lambda name + snapshot.transform.key_value("fn_arn"), # lambda arn + ] + ) + + fn_name = f"test-{short_uid()}" + create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_AWS_PROXY, + runtime=Runtime.python3_12, + ) + lambda_arn = aws_client.lambda_.get_function(FunctionName=fn_name)["Configuration"][ + "FunctionArn" + ] + # matching on lambda id for reference replacement in snapshots + snapshot.match("register-lambda", {"fn_name": fn_name, "fn_arn": lambda_arn}) + + parsed_arn = parse_arn(lambda_arn) + region = parsed_arn["region"] + account_id = parsed_arn["account"] + + api_id, _, root = create_rest_apigw(name="aws lambda api") + + resource_1 = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="nested" + )["id"] + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=resource_1, pathPart="{test}" + )["id"] + + validator_id = aws_client.apigateway.create_request_validator( + restApiId=api_id, + name="test-validator", + validateRequestParameters=True, + validateRequestBody=True, + )["id"] + + # create Model schema to validate body + aws_client.apigateway.create_model( + restApiId=api_id, + name="testSchema", + contentType="application/json", + schema=json.dumps( + { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "testSchema", + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + } + ), + ) + + for http_method in ("GET", "POST"): + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + authorizationType="NONE", + requestValidatorId=validator_id, + requestParameters={ + # the path parameter is most often used to generate SDK from the REST API + "method.request.path.test": True, + "method.request.querystring.qs1": True, + "method.request.header.x-header-param": True, + }, + requestModels={"application/json": "testSchema"}, + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + integrationHttpMethod="POST", + type="AWS_PROXY", + uri=f"arn:{get_partition(region)}:apigateway:{region}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + ) + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + ) + + stage_name = "local" + deploy_1 = aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + snapshot.match("deploy-1", deploy_1) + + source_arn = ( + f"arn:{get_partition(region)}:execute-api:{region}:{account_id}:{api_id}/*/*/nested/*" + ) + + aws_client.lambda_.add_permission( + FunctionName=lambda_arn, + StatementId=str(short_uid()), + Action="lambda:InvokeFunction", + Principal="apigateway.amazonaws.com", + SourceArn=source_arn, + ) + + url = api_invoke_url(api_id, stage=stage_name, path="/nested/value") + # test that with every request parameters and a valid body, it passes + response = requests.post( + url, + json={"a": 1, "b": 2}, + headers={"x-header-param": "test"}, + params={"qs1": "test"}, + ) + assert response.ok + assert json.loads(response.json()["body"]) == {"a": 1, "b": 2} + + # GET request with no body + response_get = requests.get( + url, + headers={"x-header-param": "test"}, + params={"qs1": "test"}, + ) + assert response_get.status_code == 400 + + # replace the POST method requestParameters to require a non-existing {issuer} path part + response = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + patchOperations=[ + { + "op": "add", + "path": "/requestParameters/method.request.path.issuer", + "value": "true", + }, + { + "op": "remove", + "path": "/requestParameters/method.request.path.test", + "value": "true", + }, + ], + ) + snapshot.match("change-request-path-names", response) + apigw_redeploy_api(rest_api_id=api_id, stage_name=stage_name) + + response = requests.post(url, json={"test": "test"}) + assert response.status_code == 400 + snapshot.match("missing-all-required-request-params-post", response.json()) + + response = requests.get(url, params={"qs1": "test"}) + assert response.status_code == 400 + snapshot.match("missing-required-headers-request-params-get", response.json()) + + response = requests.get(url, headers={"x-header-param": "test"}) + assert response.status_code == 400 + snapshot.match("missing-required-qs-request-params-get", response.json()) + + # revert the path validation for POST method + response = aws_client.apigateway.update_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + patchOperations=[ + { + "op": "add", + "path": "/requestParameters/method.request.path.test", + "value": "true", + }, + { + "op": "remove", + "path": "/requestParameters/method.request.path.issuer", + "value": "true", + }, + ], + ) + snapshot.match("revert-request-path-names", response) + apigw_redeploy_api(rest_api_id=api_id, stage_name=stage_name) + retries = 10 if is_aws_cloud() else 3 + sleep_time = 10 if is_aws_cloud() else 1 + + def _wrong_path_removed(): + # the validator should work with a valid object + _response = requests.post( + url, + json={"a": 1, "b": 2}, + headers={"x-header-param": "test"}, + params={"qs1": "test"}, + ) + assert _response.status_code == 200 + + retry(_wrong_path_removed, retries=retries, sleep=sleep_time) + + def _invalid_body(): + # the validator should fail with this message not respecting the schema + _response = requests.post( + url, + json={"test": "test"}, + headers={"x-header-param": "test"}, + params={"qs1": "test"}, + ) + assert _response.status_code == 400 + content = _response.json() + assert content["message"] == "Invalid request body" + return content + + response_content = retry(_invalid_body, retries=retries, sleep=sleep_time) + snapshot.match("invalid-request-body", response_content) + + # GET request with an empty body + response_get = requests.get( + url, + headers={"x-header-param": "test"}, + params={"qs1": "test"}, + ) + assert response_get.status_code == 400 + assert response_get.json()["message"] == "Invalid request body" + + # GET request with an empty body, content type JSON + response_get = requests.get( + url, + headers={"Content-Type": "application/json", "x-header-param": "test"}, + params={"qs1": "test"}, + ) + assert response_get.status_code == 400 + + # update request validator to disable validation + patch_operations = [ + {"op": "replace", "path": "/validateRequestBody", "value": "false"}, + {"op": "replace", "path": "/validateRequestParameters", "value": "false"}, + ] + response = aws_client.apigateway.update_request_validator( + restApiId=api_id, requestValidatorId=validator_id, patchOperations=patch_operations + ) + snapshot.match("disable-request-validator", response) + apigw_redeploy_api(rest_api_id=api_id, stage_name=stage_name) + + def _disabled_validation(): + _response = requests.post(url, json={"test": "test"}) + assert _response.ok + return _response.json() + + response = retry(_disabled_validation, retries=retries, sleep=sleep_time) + assert json.loads(response["body"]) == {"test": "test"} + + # GET request with an empty body + response_get = requests.get(url) + assert response_get.ok + + @markers.aws.validated + def test_api_gateway_request_validator_with_ref_models( + self, create_rest_apigw, apigw_redeploy_api, snapshot, aws_client + ): + api_id, _, root = create_rest_apigw(name="test ref models") + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("id"), + snapshot.transform.regex(api_id, ""), + ] + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="path" + )["id"] + + validator_id = aws_client.apigateway.create_request_validator( + restApiId=api_id, + name="test-validator", + validateRequestParameters=True, + validateRequestBody=True, + )["id"] + + # create nested Model schema to validate body + aws_client.apigateway.create_model( + restApiId=api_id, + name="testSchema", + contentType="application/json", + schema=json.dumps( + { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "testSchema", + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["a", "b"], + } + ), + ) + + aws_client.apigateway.create_model( + restApiId=api_id, + name="testSchemaList", + contentType="application/json", + schema=json.dumps( + { + "type": "array", + "items": { + # hardcoded URL to AWS + "$ref": f"https://apigateway.amazonaws.com/restapis/{api_id}/models/testSchema" + }, + } + ), + ) + + get_models = aws_client.apigateway.get_models(restApiId=api_id) + get_models["items"] = sorted(get_models["items"], key=itemgetter("name")) + snapshot.match("get-models-with-ref", get_models) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + requestValidatorId=validator_id, + requestModels={"application/json": "testSchemaList"}, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": json.dumps({"data": "ok"})}, + ) + + stage_name = "local" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + url = api_invoke_url(api_id, stage=stage_name, path="/path") + + def invoke_api(_data: dict) -> dict: + _response = requests.post(url, verify=False, json=_data) + assert _response.ok + content = _response.json() + return content + + # test that with every request parameters and a valid body, it passes + response = retry( + invoke_api, retries=10 if is_aws_cloud() else 3, sleep=1, _data=[{"a": 1, "b": 2}] + ) + snapshot.match("successful", response) + + response_post_no_body = requests.post(url) + assert response_post_no_body.status_code == 400 + snapshot.match("failed-validation", response_post_no_body.json()) + + @markers.aws.validated + def test_api_gateway_request_validator_with_ref_one_ofmodels( + self, create_rest_apigw, apigw_redeploy_api, snapshot, aws_client + ): + api_id, _, root = create_rest_apigw(name="test oneOf ref models") + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("id"), + snapshot.transform.regex(api_id, ""), + ] + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="path" + )["id"] + + validator_id = aws_client.apigateway.create_request_validator( + restApiId=api_id, + name="test-validator", + validateRequestParameters=True, + validateRequestBody=True, + )["id"] + + aws_client.apigateway.create_model( + restApiId=api_id, + name="StatusModel", + contentType="application/json", + schema=json.dumps( + { + "type": "object", + "properties": {"Status": {"type": "string"}, "Order": {"type": "integer"}}, + "required": [ + "Status", + "Order", + ], + } + ), + ) + + aws_client.apigateway.create_model( + restApiId=api_id, + name="TestModel", + contentType="application/json", + schema=json.dumps( + { + "type": "object", + "properties": { + "status": { + "oneOf": [ + {"type": "null"}, + { + "$ref": f"https://apigateway.amazonaws.com/restapis/{api_id}/models/StatusModel" + }, + ] + }, + }, + "required": ["status"], + } + ), + ) + + get_models = aws_client.apigateway.get_models(restApiId=api_id) + get_models["items"] = sorted(get_models["items"], key=itemgetter("name")) + snapshot.match("get-models-with-ref", get_models) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + requestValidatorId=validator_id, + requestModels={"application/json": "TestModel"}, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": json.dumps({"data": "ok"})}, + ) + + stage_name = "local" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + url = api_invoke_url(api_id, stage=stage_name, path="/path") + + def invoke_api(_data: dict) -> dict: + _response = requests.post(url, verify=False, json=_data) + assert _response.ok + content = _response.json() + return content + + # test that with every request parameters and a valid body, it passes + response = retry( + invoke_api, retries=10 if is_aws_cloud() else 3, sleep=1, _data={"status": None} + ) + snapshot.match("successful", response) + + response = invoke_api({"status": {"Status": "works", "Order": 1}}) + snapshot.match("successful-with-data", response) + + response_post_no_body = requests.post(url) + assert response_post_no_body.status_code == 400 + snapshot.match("failed-validation-no-data", response_post_no_body.json()) + + response_post_bad_body = requests.post(url, json={"badFormat": "bla"}) + assert response_post_bad_body.status_code == 400 + snapshot.match("failed-validation-bad-data", response_post_bad_body.json()) + + @markers.aws.validated + def test_integration_request_parameters_mapping( + self, create_rest_apigw, aws_client, echo_http_server_post + ): + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=root, + httpMethod="GET", + authorizationType="none", + requestParameters={ + "method.request.header.customHeader": False, + }, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=root, httpMethod="GET", statusCode="200" + ) + + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=root, + httpMethod="GET", + integrationHttpMethod="POST", + type="HTTP", + uri=echo_http_server_post, + requestParameters={ + "integration.request.header.testHeader": "method.request.header.customHeader", + "integration.request.header.contextHeader": "context.resourceId", + }, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root, + httpMethod="GET", + statusCode="200", + selectionPattern="2\\d{2}", + responseTemplates={}, + ) + + deployment_id, _ = create_rest_api_deployment(aws_client.apigateway, restApiId=api_id) + create_rest_api_stage( + aws_client.apigateway, restApiId=api_id, stageName="dev", deploymentId=deployment_id + ) + + invocation_url = api_invoke_url(api_id=api_id, stage="dev", path="/") + + def invoke_api(url): + _response = requests.get(url, verify=False, headers={"customHeader": "test"}) + assert _response.ok + content = _response.json() + return content + + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + lower_case_headers = {k.lower(): v for k, v in response_data["headers"].items()} + assert lower_case_headers["contextheader"] == root + assert lower_case_headers["testheader"] == "test" + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_next_gen_api() and not is_aws_cloud(), + reason="Wrong behavior in legacy implementation", + ) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..server", + "$..via", + "$..x-amz-cf-id", + "$..x-amz-cf-pop", + "$..x-cache", + ] + ) + def test_invocation_trace_id( + self, + aws_client, + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + region_name, + snapshot, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("via"), + snapshot.transform.key_value("x-amz-cf-id"), + snapshot.transform.key_value("x-amz-cf-pop"), + snapshot.transform.key_value("x-amz-apigw-id"), + snapshot.transform.key_value("x-amzn-trace-id"), + snapshot.transform.key_value("FunctionName"), + snapshot.transform.key_value("FunctionArn"), + snapshot.transform.key_value("date", reference_replacement=False), + snapshot.transform.key_value("content-length", reference_replacement=False), + ] + ) + api_id, _, root_id = create_rest_apigw(name="test trace id") + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="path" + ) + hardcoded_resource_id = resource["id"] + + response_template_get = {"statusCode": 200} + _create_mock_integration_with_200_response_template( + aws_client, api_id, hardcoded_resource_id, "GET", response_template_get + ) + + fn_name = f"test-trace-id-{short_uid()}" + # create lambda + create_function_response = create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_AWS_PROXY, + handler="lambda_aws_proxy.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + # matching on lambda id for reference replacement in snapshots + snapshot.match("register-lambda", {"FunctionName": fn_name, "FunctionArn": lambda_arn}) + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{proxy+}" + ) + proxy_resource_id = resource["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=proxy_resource_id, + httpMethod="ANY", + authorizationType="NONE", + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=proxy_resource_id, + httpMethod="ANY", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + credentials=role_arn, + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + def _invoke_api(path: str, headers: dict[str, str]) -> dict[str, str]: + url = api_invoke_url(api_id=api_id, stage=stage_name, path=path) + _response = requests.get(url, headers=headers) + assert _response.ok + lower_case_headers = {k.lower(): v for k, v in _response.headers.items()} + return lower_case_headers + + retries = 10 if is_aws_cloud() else 3 + sleep = 3 if is_aws_cloud() else 1 + resp_headers = retry( + _invoke_api, + retries=retries, + sleep=sleep, + headers={}, + path="/path", + ) + + snapshot.match("normal-req-headers-MOCK", resp_headers) + assert "x-amzn-trace-id" not in resp_headers + + full_trace = "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1" + trace_id = "Root=1-3152b799-8954dae64eda91bc9a23a7e8" + hardcoded_parent = "Parent=7fa8c0f79203be72" + + resp_headers_with_trace_id = _invoke_api( + path="/path", headers={"x-amzn-trace-id": full_trace} + ) + snapshot.match("trace-id-req-headers-MOCK", resp_headers_with_trace_id) + + resp_proxy_headers = retry( + _invoke_api, + retries=retries, + sleep=sleep, + headers={}, + path="/proxy-value", + ) + snapshot.match("normal-req-headers-AWS_PROXY", resp_proxy_headers) + + resp_headers_with_trace_id = _invoke_api( + path="/proxy-value", headers={"x-amzn-trace-id": full_trace} + ) + snapshot.match("trace-id-req-headers-AWS_PROXY", resp_headers_with_trace_id) + assert full_trace in resp_headers_with_trace_id["x-amzn-trace-id"] + split_trace = resp_headers_with_trace_id["x-amzn-trace-id"].split(";") + assert split_trace[1] == hardcoded_parent + + small_trace = trace_id + resp_headers_with_trace_id = _invoke_api( + path="/proxy-value", headers={"x-amzn-trace-id": small_trace} + ) + snapshot.match("trace-id-small-req-headers-AWS_PROXY", resp_headers_with_trace_id) + assert small_trace in resp_headers_with_trace_id["x-amzn-trace-id"] + split_trace = resp_headers_with_trace_id["x-amzn-trace-id"].split(";") + # assert that AWS populated the parent part of the trace with a generated one + assert split_trace[1] != hardcoded_parent + + @markers.aws.validated + def test_input_path_template_formatting( + self, aws_client, create_rest_apigw, echo_http_server_post, snapshot + ): + api_id, _, root_id = create_rest_apigw() + + def _create_route(path: str, response_templates): + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart=path + )["id"] + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + apiKeyRequired=False, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + integrationHttpMethod="POST", + type="HTTP", + uri=echo_http_server_post, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": response_templates}, + ) + + _create_route("path", '#set($result = $input.path("$.json"))$result') + _create_route("nested", '#set($result = $input.path("$.json"))$result.nested') + _create_route("list", '#set($result = $input.path("$.json"))$result[0]') + _create_route("to-string", '#set($result = $input.path("$.json"))$result.toString()') + _create_route( + "invalid-path", + '#set($result = $input.path("$.nonExisting")){"body": $result, "nested": $result.nested, "isNull": #if( $result == $null )"true"#else"false"#end, "isEmptyString": #if( $result == "" )"true"#else"false"#end}', + ) + _create_route( + "nested-list", + '#set($result = $input.path("$.json.listValue")){"body": $result, "nested": $result.nested, "isNull": #if( $result == $null )"true"#else"false"#end, "isEmptyString": #if( $result == "" )"true"#else"false"#end}', + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + url = api_invoke_url(api_id=api_id, stage=stage_name, path="/") + path_url = url + "path" + nested_url = url + "nested" + list_url = url + "list" + to_string = url + "to-string" + invalid_path = url + "invalid-path" + nested_list = url + "nested-list" + + response = requests.post(path_url, json={"foo": "bar"}) + snapshot.match("dict-response", response.text) + + response = requests.post(path_url, json=[{"foo": "bar"}]) + snapshot.match("json-list", response.text) + + response = requests.post(nested_url, json={"nested": {"foo": "bar"}}) + snapshot.match("nested-dict", response.text) + + response = requests.post(nested_url, json={"nested": [{"foo": "bar"}]}) + snapshot.match("nested-list", response.text) + + response = requests.post(list_url, json=[{"foo": "bar"}]) + snapshot.match("dict-in-list", response.text) + + response = requests.post(list_url, json=[[{"foo": "bar"}]]) + snapshot.match("list-with-nested-list", response.text) + + response = requests.post(path_url, json={"foo": [{"nested": "bar"}]}) + snapshot.match("dict-with-nested-list", response.text) + + response = requests.post( + path_url, json={"bigger": "dict", "to": "test", "with": "separators"} + ) + snapshot.match("bigger-dict", response.text) + + response = requests.post(to_string, json={"foo": "bar"}) + snapshot.match("to-string", response.text) + + response = requests.post(to_string, json={"list": [{"foo": "bar"}]}) + snapshot.match("list-to-string", response.text) + + response = requests.post(invalid_path) + snapshot.match("empty-body", response.text) + + response = requests.post(nested_list, json={"listValue": []}) + snapshot.match("nested-empty-list", response.text) + + response = requests.post(nested_list, json={"listValue": None}) + snapshot.match("nested-null-list", response.text) + + @markers.aws.validated + def test_input_body_formatting( + self, aws_client, create_lambda_function, create_rest_apigw, snapshot + ): + api_id, _, root_id = create_rest_apigw() + + # create a special lambda URL returning exactly what it got as a body + handler_code = handler_code = textwrap.dedent(""" + def handler(event, context): + return event.get("body", "") + """) + func_name = f"echo-http-{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=handler_code, + runtime=Runtime.python3_12, + ) + url_response = aws_client.lambda_.create_function_url_config( + FunctionName=func_name, AuthType="NONE" + ) + aws_client.lambda_.add_permission( + FunctionName=func_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + echo_endpoint_url = url_response["FunctionUrl"] + + def _create_route(path: str, request_template: str, response_template: str): + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart=path + )["id"] + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + apiKeyRequired=False, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + integrationHttpMethod="POST", + type="HTTP", + uri=echo_endpoint_url, + requestTemplates={"application/json": request_template}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + selectionPattern="", + responseTemplates={"application/json": response_template}, + ) + + raw_body = "#set($result = $input.body)$result" + body_in_str = "Action=SendMessage&MessageBody=$input.body" + input_body_attr_access = "#set($result = $input.body.testAccess)$result" + url_encode_body = "EncodedBody=$util.urlEncode($input.body)&EncodedBodyAccess=$util.urlEncode($input.body.testAccess)" + _create_route( + "raw-body", + request_template=raw_body, + response_template=raw_body, + ) + _create_route( + "str-body-input", + request_template=body_in_str, + response_template=raw_body, + ) + _create_route( + "str-body-output", + request_template=raw_body, + response_template=body_in_str, + ) + _create_route( + "str-body-all", + request_template=body_in_str, + response_template=body_in_str, + ) + _create_route( + "body-attr-access", + request_template=input_body_attr_access, + response_template=raw_body, + ) + _create_route( + "url-encode", + request_template=url_encode_body, + response_template=raw_body, + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + url = api_invoke_url(api_id=api_id, stage=stage_name, path="/") + + route_types = [ + "raw-body", + "str-body-input", + "str-body-output", + "str-body-all", + "body-attr-access", + "url-encode", + ] + for route_type in route_types: + route_url = url + route_type + # we are using `response.content` on purpose in snapshot to have text response prefixed with `b''` to avoid + # auto decoding of the possible JSON responses + # TODO: remove headers parameter, this is due to issue in our Lambda URL parity, it B64 encodes data when + # AWS does not + + empty_body_response = requests.post( + route_url, headers={"Content-Type": "application/json"} + ) + json_body_response = requests.post(route_url, json={"some": "value"}) + str_body_response = requests.post( + route_url, data=b"some raw data", headers={"Content-Type": "application/json"} + ) + + # keep the snapshot in one object to group related tests together + snapshot.match( + f"response-{route_type}", + { + "empty-body": empty_body_response.content, + "json-body": json_body_response.content, + "str-body": str_body_response.content, + }, + ) + + +class TestUsagePlans: + @markers.aws.validated + def test_api_key_required_for_methods( + self, + aws_client, + snapshot, + create_rest_apigw, + apigw_redeploy_api, + cleanups, + ): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("apiId"), + snapshot.transform.key_value("value"), + ] + ) + + # Create a REST API with the apiKeySource set to "HEADER" + api_id, _, root_id = create_rest_apigw(name="test API key", apiKeySource="HEADER") + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + + resource_id = resource["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + apiKeyRequired=True, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="GET", + type="MOCK", + requestTemplates={"application/json": '{"statusCode": 200}'}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="", + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + usage_plan_response = aws_client.apigateway.create_usage_plan( + name=f"test-plan-{short_uid()}", + description="Test Usage Plan for API key", + quota={"limit": 10, "period": "DAY", "offset": 0}, + throttle={"rateLimit": 2, "burstLimit": 1}, + apiStages=[{"apiId": api_id, "stage": stage_name}], + tags={"tag_key": "tag_value"}, + ) + snapshot.match("create-usage-plan", usage_plan_response) + + usage_plan_id = usage_plan_response["id"] + + key_name = f"testApiKey-{short_uid()}" + api_key_response = aws_client.apigateway.create_api_key( + name=key_name, + enabled=True, + ) + snapshot.match("create-api-key", api_key_response) + api_key_id = api_key_response["id"] + cleanups.append(lambda: aws_client.apigateway.delete_api_key(apiKey=api_key_id)) + + create_usage_plan_key_resp = aws_client.apigateway.create_usage_plan_key( + usagePlanId=usage_plan_id, + keyId=api_key_id, + keyType="API_KEY", + ) + snapshot.match("create-usage-plan-key", create_usage_plan_key_resp) + + url = api_invoke_url(api_id=api_id, stage=stage_name, path="/test") + response = requests.get(url) + # when the api key is not passed as part of the header + assert response.status_code == 403 + + def _assert_with_key(expected_status_code: int): + _response = requests.get(url, headers={"x-api-key": api_key_response["value"]}) + assert _response.status_code == expected_status_code + + # AWS takes a very, very long time to make the key enabled + retries = 10 if is_aws_cloud() else 3 + sleep = 12 if is_aws_cloud() else 1 + retry(_assert_with_key, retries=retries, sleep=sleep, expected_status_code=200) + + # now disable the key to verify that we should not be able to access the api + patch_operations = [ + {"op": "replace", "path": "/enabled", "value": "false"}, + ] + response = aws_client.apigateway.update_api_key( + apiKey=api_key_id, patchOperations=patch_operations + ) + snapshot.match("update-api-key-disabled", response) + + retry(_assert_with_key, retries=retries, sleep=sleep, expected_status_code=403) + + @markers.aws.validated + def test_usage_plan_crud(self, create_rest_apigw, snapshot, aws_client, echo_http_server_post): + snapshot.add_transformer(snapshot.transform.key_value("id", reference_replacement=True)) + snapshot.add_transformer(snapshot.transform.key_value("name")) + snapshot.add_transformer(snapshot.transform.key_value("description")) + snapshot.add_transformer(snapshot.transform.key_value("apiId", reference_replacement=True)) + + # clean up any existing usage plans + old_usage_plans = aws_client.apigateway.get_usage_plans().get("items", []) + for usage_plan in old_usage_plans: + aws_client.apigateway.delete_usage_plan(usagePlanId=usage_plan["id"]) + + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=root, + httpMethod="GET", + authorizationType="none", + ) + + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=root, + httpMethod="GET", + integrationHttpMethod="POST", + type="HTTP", + uri=echo_http_server_post, + ) + + deployment_id, _ = create_rest_api_deployment(aws_client.apigateway, restApiId=api_id) + stage = create_rest_api_stage( + aws_client.apigateway, restApiId=api_id, stageName="dev", deploymentId=deployment_id + ) + + # create usage plan + response = aws_client.apigateway.create_usage_plan( + name=f"test-usage-plan-{short_uid()}", + description="this is my usage plan", + apiStages=[ + {"apiId": api_id, "stage": stage}, + ], + ) + snapshot.match("create-usage-plan", response) + usage_plan_id = response["id"] + + # get usage plan + response = aws_client.apigateway.get_usage_plan(usagePlanId=usage_plan_id) + snapshot.match("get-usage-plan", response) + + # get usage plans + response = aws_client.apigateway.get_usage_plans() + snapshot.match("get-usage-plans", response) + + # update usage plan + response = aws_client.apigateway.update_usage_plan( + usagePlanId=usage_plan_id, + patchOperations=[ + {"op": "replace", "path": "/throttle/burstLimit", "value": "100"}, + {"op": "replace", "path": "/throttle/rateLimit", "value": "200"}, + {"op": "replace", "path": "/quota/period", "value": "MONTH"}, + {"op": "replace", "path": "/quota/limit", "value": "5000"}, + ], + ) + snapshot.match("update-usage-plan", response) + + if is_aws_cloud(): + # avoid TooManyRequests + time.sleep(10) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_usage_plan( + usagePlanId=usage_plan_id + "1", # wrong ID + patchOperations=[ + {"op": "replace", "path": "/throttle/burstLimit", "value": "100"}, + {"op": "replace", "path": "/throttle/rateLimit", "value": "200"}, + ], + ) + snapshot.match("update-wrong-id", e.value.response) + + if is_aws_cloud(): + # avoid TooManyRequests + time.sleep(10) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_usage_plan( + usagePlanId=usage_plan_id, + patchOperations=[ + {"op": "remove", "path": "/apiStages"}, + ], + ) + snapshot.match("update-wrong-api-stages-no-value", e.value.response) + + if is_aws_cloud(): + # avoid TooManyRequests + time.sleep(10) + + with pytest.raises(ClientError) as e: + wrong_api_id = api_id + "b" + aws_client.apigateway.update_usage_plan( + usagePlanId=usage_plan_id, + patchOperations=[ + {"op": "remove", "path": "/apiStages", "value": f"{wrong_api_id}:{stage}"}, + ], + ) + snapshot.match("update-wrong-api-stages-wrong-api", e.value.response) + + if is_aws_cloud(): + # avoid TooManyRequests + time.sleep(10) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_usage_plan( + usagePlanId=usage_plan_id, + patchOperations=[ + {"op": "remove", "path": "/apiStages", "value": f"{api_id}:fakestagename"}, + ], + ) + snapshot.match("update-wrong-api-stages-wrong-stage", e.value.response) + + if is_aws_cloud(): + # avoid TooManyRequests + time.sleep(10) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.update_usage_plan( + usagePlanId=usage_plan_id, + patchOperations=[ + {"op": "remove", "path": "/apiStages", "value": "fakevalue"}, + ], + ) + snapshot.match("update-wrong-api-stages-wrong-value", e.value.response) + + # get usage plan after update + response = aws_client.apigateway.get_usage_plan(usagePlanId=usage_plan_id) + snapshot.match("get-usage-plan-after-update", response) + + # get usage plans after update + response = aws_client.apigateway.get_usage_plans() + snapshot.match("get-usage-plans-after-update", response) + + +class TestDocumentations: + @markers.aws.validated + def test_documentation_parts_and_versions( + self, aws_client, create_rest_apigw, apigw_add_transformers, snapshot + ): + client = aws_client.apigateway + + # create API + api_id, api_name, root_id = create_rest_apigw() + + # create documentation part + response = client.create_documentation_part( + restApiId=api_id, + location={"type": "API"}, + properties=json.dumps({"foo": "bar"}), + ) + snapshot.match("create-part-response", response) + + response = client.get_documentation_parts(restApiId=api_id) + snapshot.match("get-parts-response", response) + + # create/update/get documentation version + + response = client.create_documentation_version( + restApiId=api_id, documentationVersion="v123" + ) + snapshot.match("create-version-response", response) + + response = client.update_documentation_version( + restApiId=api_id, + documentationVersion="v123", + patchOperations=[{"op": "replace", "path": "/description", "value": "doc version new"}], + ) + snapshot.match("update-version-response", response) + + response = client.get_documentation_version(restApiId=api_id, documentationVersion="v123") + snapshot.match("get-version-response", response) + + +class TestStages: + @pytest.fixture + def _create_api_with_stage( + self, aws_client, create_rest_apigw, apigw_add_transformers, snapshot + ): + client = aws_client.apigateway + + def _create(): + # create API, method, integration, deployment + api_id, api_name, root_id = create_rest_apigw() + client.put_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET", authorizationType="NONE" + ) + client.put_integration( + restApiId=api_id, resourceId=root_id, httpMethod="GET", type="MOCK" + ) + response = client.create_deployment(restApiId=api_id) + deployment_id = response["id"] + + # create documentation + client.create_documentation_part( + restApiId=api_id, + location={"type": "API"}, + properties=json.dumps({"foo": "bar"}), + ) + client.create_documentation_version(restApiId=api_id, documentationVersion="v123") + + # create stage + response = client.create_stage( + restApiId=api_id, + stageName="s1", + deploymentId=deployment_id, + description="my stage", + documentationVersion="v123", + ) + snapshot.match("create-stage", response) + + return api_id + + return _create + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..createdDate", "$..lastUpdatedDate"]) + def test_create_update_stages( + self, _create_api_with_stage, aws_client, create_rest_apigw, snapshot + ): + client = aws_client.apigateway + api_id = _create_api_with_stage() + + # negative tests for immutable/non-updateable attributes + + with pytest.raises(ClientError) as ctx: + client.update_stage( + restApiId=api_id, + stageName="s1", + patchOperations=[ + {"op": "replace", "path": "/documentation_version", "value": "123"} + ], + ) + snapshot.match("error-update-doc-version", ctx.value.response) + + with pytest.raises(ClientError) as ctx: + client.update_stage( + restApiId=api_id, + stageName="s1", + patchOperations=[ + {"op": "replace", "path": "/tags/tag1", "value": "value1"}, + ], + ) + snapshot.match("error-update-tags", ctx.value.response) + + # update & get stage + response = client.update_stage( + restApiId=api_id, + stageName="s1", + patchOperations=[ + {"op": "replace", "path": "/description", "value": "stage new"}, + {"op": "replace", "path": "/variables/var1", "value": "test"}, + {"op": "replace", "path": "/variables/var2", "value": "test2"}, + {"op": "replace", "path": "/*/*/throttling/burstLimit", "value": "123"}, + {"op": "replace", "path": "/*/*/caching/enabled", "value": "true"}, + {"op": "replace", "path": "/tracingEnabled", "value": "true"}, + {"op": "replace", "path": "/test/GET/throttling/burstLimit", "value": "124"}, + ], + ) + snapshot.match("update-stage", response) + + response = client.get_stage(restApiId=api_id, stageName="s1") + snapshot.match("get-stage", response) + + # show that updating */* does not override previously set values, only + # provides default values then like shown above + response = client.update_stage( + restApiId=api_id, + stageName="s1", + patchOperations=[ + {"op": "replace", "path": "/*/*/throttling/burstLimit", "value": "100"}, + ], + ) + snapshot.match("update-stage-override", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..createdDate", "$..lastUpdatedDate"]) + def test_update_stage_remove_wildcard(self, aws_client, _create_api_with_stage, snapshot): + client = aws_client.apigateway + api_id = _create_api_with_stage() + + response = client.get_stage(restApiId=api_id, stageName="s1") + snapshot.match("get-stage", response) + + def _delete_wildcard(): + # remove all attributes at path */* (this is an operation Terraform executes when deleting APIs) + response = client.update_stage( + restApiId=api_id, + stageName="s1", + patchOperations=[ + {"op": "remove", "path": "/*/*"}, + ], + ) + snapshot.match("update-stage-reset", response) + + # attempt to delete wildcard method settings (should initially fail) + with pytest.raises(ClientError) as exc: + _delete_wildcard() + snapshot.match("delete-error", exc.value.response) + + # run a patch operation that creates a method mapping for */* + response = client.update_stage( + restApiId=api_id, + stageName="s1", + patchOperations=[ + {"op": "replace", "path": "/*/*/caching/enabled", "value": "true"}, + ], + ) + snapshot.match("update-stage", response) + + # delete wildcard method settings (should now succeed) + _delete_wildcard() + + # assert the content of the stage after the update + response = client.get_stage(restApiId=api_id, stageName="s1") + snapshot.match("get-stage-after-reset", response) + + +class TestDeployments: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..createdDate", "$..lastUpdatedDate"]) + @pytest.mark.parametrize("create_stage_manually", [True, False]) + def test_create_delete_deployments( + self, create_stage_manually, aws_client, create_rest_apigw, apigw_add_transformers, snapshot + ): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + client = aws_client.apigateway + + # create API, method, integration, deployment + api_id, _, root_id = create_rest_apigw() + client.put_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET", authorizationType="NONE" + ) + client.put_integration(restApiId=api_id, resourceId=root_id, httpMethod="GET", type="MOCK") + + # create deployment - stage can be passed as parameter, or created separately below + kwargs = {} if create_stage_manually else {"stageName": "s1"} + response = client.create_deployment(restApiId=api_id, **kwargs) + deployment_id = response["id"] + + # create stage + if create_stage_manually: + client.create_stage(restApiId=api_id, stageName="s1", deploymentId=deployment_id) + + # get deployment and stages + response = client.get_deployment(restApiId=api_id, deploymentId=deployment_id) + snapshot.match("get-deployment", response) + response = client.get_stages(restApiId=api_id) + snapshot.match("get-stages", response) + + for i in range(3): + # asset that deleting the deployment fails if stage exists + with pytest.raises(ClientError) as ctx: + client.delete_deployment(restApiId=api_id, deploymentId=deployment_id) + snapshot.match(f"delete-deployment-error-{i}", ctx.value.response) + + # delete stage and deployment + client.delete_stage(restApiId=api_id, stageName="s1") + client.delete_deployment(restApiId=api_id, deploymentId=deployment_id) + + # re-create stage and deployment + response = client.create_deployment(restApiId=api_id, **kwargs) + deployment_id = response["id"] + if create_stage_manually: + client.create_stage(restApiId=api_id, stageName="s1", deploymentId=deployment_id) + + # list deployments and stages again + response = client.get_deployments(restApiId=api_id) + snapshot.match(f"get-deployments-{i}", response) + response = client.get_stages(restApiId=api_id) + snapshot.match(f"get-stages-{i}", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..createdDate", "$..lastUpdatedDate"]) + def test_create_update_deployments( + self, aws_client, create_rest_apigw, apigw_add_transformers, snapshot + ): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + client = aws_client.apigateway + + # create API, method, integration, deployment + api_id, _, root_id = create_rest_apigw() + client.put_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET", authorizationType="NONE" + ) + client.put_integration(restApiId=api_id, resourceId=root_id, httpMethod="GET", type="MOCK") + + # create deployment - stage can be passed as parameter, or created separately below + response = client.create_deployment(restApiId=api_id) + deployment_id_1 = response["id"] + + # create stage + client.create_stage(restApiId=api_id, stageName="s1", deploymentId=deployment_id_1) + + # get deployment and stages + response = client.get_deployment(restApiId=api_id, deploymentId=deployment_id_1) + snapshot.match("get-deployment-1", response) + response = client.get_stages(restApiId=api_id) + snapshot.match("get-stages", response) + + # asset that deleting the deployment fails if stage exists + with pytest.raises(ClientError) as ctx: + client.delete_deployment(restApiId=api_id, deploymentId=deployment_id_1) + snapshot.match("delete-deployment-error", ctx.value.response) + + # create another deployment with the previous stage, which should update the stage + response = client.create_deployment(restApiId=api_id, stageName="s1") + deployment_id_2 = response["id"] + + # get deployments and stages + response = client.get_deployment(restApiId=api_id, deploymentId=deployment_id_1) + snapshot.match("get-deployment-1-after-update", response) + response = client.get_deployment(restApiId=api_id, deploymentId=deployment_id_2) + snapshot.match("get-deployment-2", response) + response = client.get_stages(restApiId=api_id) + snapshot.match("get-stages-after-update", response) + + response = client.delete_deployment(restApiId=api_id, deploymentId=deployment_id_1) + snapshot.match("delete-deployment-1", response) + + # asset that deleting the deployment fails if stage exists + with pytest.raises(ClientError) as ctx: + client.delete_deployment(restApiId=api_id, deploymentId=deployment_id_2) + snapshot.match("delete-deployment-2-error", ctx.value.response) + + +class TestApigatewayRouting: + @markers.aws.validated + def test_proxy_routing_with_hardcoded_resource_sibling(self, aws_client, create_rest_apigw): + api_id, _, root_id = create_rest_apigw(name="test proxy routing") + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + ) + hardcoded_resource_id = resource["id"] + + response_template_post = {"statusCode": 200, "message": "POST request"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, hardcoded_resource_id, "POST", response_template_post + ) + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=hardcoded_resource_id, pathPart="any" + ) + any_resource_id = resource["id"] + + response_template_any = {"statusCode": 200, "message": "ANY request"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, any_resource_id, "ANY", response_template_any + ) + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{proxy+}" + ) + proxy_resource_id = resource["id"] + response_template_options = {"statusCode": 200, "message": "OPTIONS request"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, proxy_resource_id, "OPTIONS", response_template_options + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + url = api_invoke_url(api_id=api_id, stage=stage_name, path="/test") + + def _invoke_api(req_url: str, http_method: str, expected_type: str): + _response = requests.request(http_method.upper(), req_url) + assert _response.ok + assert _response.json()["message"] == f"{expected_type} request" + + retries = 10 if is_aws_cloud() else 3 + sleep = 3 if is_aws_cloud() else 1 + retry( + _invoke_api, + retries=retries, + sleep=sleep, + req_url=url, + http_method="OPTIONS", + expected_type="OPTIONS", + ) + retry( + _invoke_api, + retries=retries, + sleep=sleep, + req_url=url, + http_method="POST", + expected_type="POST", + ) + any_url = api_invoke_url(api_id=api_id, stage=stage_name, path="/test/any") + retry( + _invoke_api, + retries=retries, + sleep=sleep, + req_url=any_url, + http_method="OPTIONS", + expected_type="ANY", + ) + retry( + _invoke_api, + retries=retries, + sleep=sleep, + req_url=any_url, + http_method="GET", + expected_type="ANY", + ) + + @markers.aws.validated + def test_routing_with_hardcoded_resource_sibling_order(self, aws_client, create_rest_apigw): + api_id, _, root_id = create_rest_apigw(name="test parameter routing") + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="part1" + ) + hardcoded_resource_id = resource["id"] + + response_template_get = {"statusCode": 200, "message": "part1"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, hardcoded_resource_id, "GET", response_template_get + ) + + # define the proxy before so that it would come up as the first resource iterated over + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{param+}" + ) + proxy_resource_id = resource["id"] + response_template_get = {"statusCode": 200, "message": "proxy"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, proxy_resource_id, "GET", response_template_get + ) + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=hardcoded_resource_id, pathPart="hardcoded-value" + ) + any_resource_id = resource["id"] + + response_template_get = {"statusCode": 200, "message": "hardcoded-value"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, any_resource_id, "GET", response_template_get + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + def _invoke_api(path: str, expected_response: str): + url = api_invoke_url(api_id=api_id, stage=stage_name, path=path) + _response = requests.get(url) + assert _response.ok + assert _response.json()["message"] == expected_response + + retries = 10 if is_aws_cloud() else 3 + sleep = 3 if is_aws_cloud() else 1 + retry( + _invoke_api, + retries=retries, + sleep=sleep, + path="/part1", + expected_response="part1", + ) + retry( + _invoke_api, + retries=retries, + sleep=sleep, + path="/part1/hardcoded-value", + expected_response="hardcoded-value", + ) + + retry( + _invoke_api, + retries=retries, + sleep=sleep, + path="/part1/random-value", + expected_response="proxy", + ) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_next_gen_api() and not is_aws_cloud(), + reason="Wrong behavior in legacy implementation", + ) + def test_routing_not_found(self, aws_client, create_rest_apigw, snapshot): + api_id, _, root_id = create_rest_apigw(name=f"test-notfound-{short_uid()}") + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="existing" + ) + hardcoded_resource_id = resource["id"] + + response_template_get = {"statusCode": 200, "message": "exists"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, hardcoded_resource_id, "GET", response_template_get + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + def _invoke_api(path: str, method: str, works: bool): + url = api_invoke_url(api_id=api_id, stage=stage_name, path=path) + _response = requests.request(method, url) + assert _response.ok == works + return _response + + retry_args = {"retries": 10 if is_aws_cloud() else 3, "sleep": 3 if is_aws_cloud() else 1} + response = retry(_invoke_api, method="GET", path="/existing", works=True, **retry_args) + snapshot.match("working-route", response.json()) + + response = retry( + _invoke_api, method="GET", path="/random-non-existing", works=False, **retry_args + ) + resp = { + "content": response.json(), + "errorType": response.headers.get("x-amzn-ErrorType"), + } + snapshot.match("not-found", resp) + + response = retry(_invoke_api, method="POST", path="/existing", works=False, **retry_args) + resp = { + "content": response.json(), + "errorType": response.headers.get("x-amzn-ErrorType"), + } + snapshot.match("wrong-method", resp) + + @markers.aws.only_localstack + def test_api_not_existing(self, aws_client, create_rest_apigw, snapshot): + """ + This cannot be tested against AWS, as this is the format: `https://.execute-api..amazonaws.com` + So if the API does not exist, the DNS subdomain is not created and is not reachable. + This test document the expected behavior for LocalStack. + """ + aws_client.apigateway.get_rest_apis() + endpoint_url = api_invoke_url(api_id="404api", stage="dev", path="/test-path") + + _response = requests.get(endpoint_url) + + assert _response.status_code == 404 + if not is_next_gen_api(): + assert not _response.content + + else: + assert _response.json() == { + "message": "The API id '404api' does not correspond to a deployed API Gateway API" + } + + @markers.aws.only_localstack + def test_routing_with_custom_api_id(self, aws_client, create_rest_apigw): + custom_id = "custom-api-id" + api_id, _, root_id = create_rest_apigw( + name="test custom id routing", tags={TAG_KEY_CUSTOM_ID: custom_id} + ) + + resource = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="part1" + ) + hardcoded_resource_id = resource["id"] + + response_template_get = {"statusCode": 200, "message": "routing ok"} + _create_mock_integration_with_200_response_template( + aws_client, api_id, hardcoded_resource_id, "GET", response_template_get + ) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + url = api_invoke_url(api_id=api_id, stage=stage_name, path="/part1") + response = requests.get(url) + assert response.ok + assert response.json()["message"] == "routing ok" + + # Validated test living here: `test_create_execute_api_vpc_endpoint` + vpce_url = url.replace(custom_id, f"{custom_id}-vpce-aabbaabbaabbaabba") + response = requests.get(vpce_url) + assert response.ok + assert response.json()["message"] == "routing ok" diff --git a/tests/aws/services/apigateway/test_apigateway_common.snapshot.json b/tests/aws/services/apigateway/test_apigateway_common.snapshot.json new file mode 100644 index 0000000000000..fd306b34e47b9 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_common.snapshot.json @@ -0,0 +1,1433 @@ +{ + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator": { + "recorded-date": "10-01-2024, 00:06:10", + "recorded-content": { + "register-lambda": { + "fn_arn": "arn::lambda::111111111111:function:", + "fn_name": "" + }, + "deploy-1": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "change-request-path-names": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::111111111111:function:/invocations" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "requestModels": { + "application/json": "testSchema" + }, + "requestParameters": { + "method.request.header.x-header-param": true, + "method.request.path.issuer": true, + "method.request.querystring.qs1": true + }, + "requestValidatorId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "missing-all-required-request-params-post": { + "message": "Missing required request parameters: [x-header-param, issuer, qs1]" + }, + "missing-required-headers-request-params-get": { + "message": "Missing required request parameters: [x-header-param]" + }, + "missing-required-qs-request-params-get": { + "message": "Missing required request parameters: [qs1]" + }, + "revert-request-path-names": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::111111111111:function:/invocations" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "requestModels": { + "application/json": "testSchema" + }, + "requestParameters": { + "method.request.header.x-header-param": true, + "method.request.path.test": true, + "method.request.querystring.qs1": true + }, + "requestValidatorId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalid-request-body": { + "message": "Invalid request body" + }, + "disable-request-validator": { + "id": "", + "name": "test-validator", + "validateRequestBody": false, + "validateRequestParameters": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestUsagePlans::test_api_key_required_for_methods": { + "recorded-date": "27-07-2023, 17:57:20", + "recorded-content": { + "create-usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "dev" + } + ], + "description": "Test Usage Plan for API key", + "id": "", + "name": "", + "quota": { + "limit": 10, + "offset": 0, + "period": "DAY" + }, + "tags": { + "tag_key": "tag_value" + }, + "throttle": { + "burstLimit": 1, + "rateLimit": 2.0 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-api-key": { + "createdDate": "datetime", + "enabled": true, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [], + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-usage-plan-key": { + "id": "", + "name": "", + "type": "API_KEY", + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-api-key-disabled": { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [], + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestUsagePlans::test_usage_plan_crud": { + "recorded-date": "20-03-2024, 22:49:50", + "recorded-content": { + "create-usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "dev" + } + ], + "description": "", + "id": "", + "name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "dev" + } + ], + "description": "", + "id": "", + "name": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-usage-plans": { + "items": [ + { + "apiStages": [ + { + "apiId": "", + "stage": "dev" + } + ], + "description": "", + "id": "", + "name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "dev" + } + ], + "description": "", + "id": "", + "name": "", + "quota": { + "limit": 5000, + "offset": 0, + "period": "MONTH" + }, + "tags": {}, + "throttle": { + "burstLimit": 100, + "rateLimit": 200.0 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-wrong-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Usage Plan ID specified" + }, + "message": "Invalid Usage Plan ID specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-wrong-api-stages-no-value": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid API Stage specified" + }, + "message": "Invalid API Stage specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-wrong-api-stages-wrong-api": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API Stage {api: b, stage: dev} specified for usageplan " + }, + "message": "Invalid API Stage {api: b, stage: dev} specified for usageplan ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-wrong-api-stages-wrong-stage": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid API Stage {api: , stage: fakestagename} specified for usageplan " + }, + "message": "Invalid API Stage {api: , stage: fakestagename} specified for usageplan ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update-wrong-api-stages-wrong-value": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid API Stage specified" + }, + "message": "Invalid API Stage specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-usage-plan-after-update": { + "apiStages": [ + { + "apiId": "", + "stage": "dev" + } + ], + "description": "", + "id": "", + "name": "", + "quota": { + "limit": 5000, + "offset": 0, + "period": "MONTH" + }, + "tags": {}, + "throttle": { + "burstLimit": 100, + "rateLimit": 200.0 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-usage-plans-after-update": { + "items": [ + { + "apiStages": [ + { + "apiId": "", + "stage": "dev" + } + ], + "description": "", + "id": "", + "name": "", + "quota": { + "limit": 5000, + "offset": 0, + "period": "MONTH" + }, + "throttle": { + "burstLimit": 100, + "rateLimit": 200.0 + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestStages::test_create_update_stages": { + "recorded-date": "05-03-2024, 18:54:23", + "recorded-content": { + "create-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "my stage", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error-update-doc-version": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid method setting path: /documentation_version. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]" + }, + "message": "Invalid method setting path: /documentation_version. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-update-tags": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid method setting path: /tags/tag1. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]" + }, + "message": "Invalid method setting path: /tags/tag1. Must be one of: [/deploymentId, /description, /cacheClusterEnabled, /cacheClusterSize, /clientCertificateId, /accessLogSettings, /accessLogSettings/destinationArn, /accessLogSettings/format, /{resourcePath}/{httpMethod}/metrics/enabled, /{resourcePath}/{httpMethod}/logging/dataTrace, /{resourcePath}/{httpMethod}/logging/loglevel, /{resourcePath}/{httpMethod}/throttling/burstLimit/{resourcePath}/{httpMethod}/throttling/rateLimit/{resourcePath}/{httpMethod}/caching/ttlInSeconds, /{resourcePath}/{httpMethod}/caching/enabled, /{resourcePath}/{httpMethod}/caching/dataEncrypted, /{resourcePath}/{httpMethod}/caching/requireAuthorizationForCacheControl, /{resourcePath}/{httpMethod}/caching/unauthorizedCacheControlHeaderStrategy, /*/*/metrics/enabled, /*/*/logging/dataTrace, /*/*/logging/loglevel, /*/*/throttling/burstLimit /*/*/throttling/rateLimit /*/*/caching/ttlInSeconds, /*/*/caching/enabled, /*/*/caching/dataEncrypted, /*/*/caching/requireAuthorizationForCacheControl, /*/*/caching/unauthorizedCacheControlHeaderStrategy, /variables/{variable_name}, /tracingEnabled]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "stage new", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": { + "*/*": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": true, + "dataTraceEnabled": false, + "metricsEnabled": false, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 123, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + }, + "test/GET": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": true, + "dataTraceEnabled": false, + "metricsEnabled": false, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 124, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "stageName": "s1", + "tracingEnabled": true, + "variables": { + "var1": "test", + "var2": "test2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "stage new", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": { + "*/*": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": true, + "dataTraceEnabled": false, + "metricsEnabled": false, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 123, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + }, + "test/GET": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": true, + "dataTraceEnabled": false, + "metricsEnabled": false, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 124, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "stageName": "s1", + "tracingEnabled": true, + "variables": { + "var1": "test", + "var2": "test2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-stage-override": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "stage new", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": { + "*/*": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": true, + "dataTraceEnabled": false, + "metricsEnabled": false, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 100, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + }, + "test/GET": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": true, + "dataTraceEnabled": false, + "metricsEnabled": false, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 124, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "stageName": "s1", + "tracingEnabled": true, + "variables": { + "var1": "test", + "var2": "test2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDocumentations::test_documentation_parts_and_versions": { + "recorded-date": "07-08-2023, 22:02:13", + "recorded-content": { + "create-part-response": { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "foo": "bar" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-parts-response": { + "items": [ + { + "id": "", + "location": { + "type": "API" + }, + "properties": { + "foo": "bar" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-version-response": { + "createdDate": "datetime", + "version": "v123", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-version-response": { + "createdDate": "datetime", + "description": "doc version new", + "version": "v123", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-version-response": { + "createdDate": "datetime", + "description": "doc version new", + "version": "v123", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_delete_deployments[True]": { + "recorded-date": "07-09-2023, 19:27:48", + "recorded-content": { + "get-deployment": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-error-0": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-deployments-0": { + "items": [ + { + "createdDate": "datetime", + "id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages-0": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-error-1": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-deployments-1": { + "items": [ + { + "createdDate": "datetime", + "id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages-1": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-error-2": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-deployments-2": { + "items": [ + { + "createdDate": "datetime", + "id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages-2": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_delete_deployments[False]": { + "recorded-date": "07-09-2023, 19:20:47", + "recorded-content": { + "get-deployment": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-error-0": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-deployments-0": { + "items": [ + { + "createdDate": "datetime", + "id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages-0": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-error-1": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-deployments-1": { + "items": [ + { + "createdDate": "datetime", + "id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages-1": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-error-2": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-deployments-2": { + "items": [ + { + "createdDate": "datetime", + "id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages-2": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_update_deployments": { + "recorded-date": "11-09-2023, 12:12:50", + "recorded-content": { + "get-deployment-1": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-error": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-deployment-1-after-update": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-deployment-2": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stages-after-update": { + "item": [ + { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-deployment-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "delete-deployment-2-error": { + "Error": { + "Code": "BadRequestException", + "Message": "Active stages pointing to this deployment must be moved or deleted" + }, + "message": "Active stages pointing to this deployment must be moved or deleted", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestStages::test_update_stage_remove_wildcard": { + "recorded-date": "05-03-2024, 21:10:56", + "recorded-content": { + "create-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "my stage", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "my stage", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-error": { + "Error": { + "Code": "BadRequestException", + "Message": "Cannot remove method setting */* because there is no method setting for this method " + }, + "message": "Cannot remove method setting */* because there is no method setting for this method ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "my stage", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": { + "*/*": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": true, + "dataTraceEnabled": false, + "metricsEnabled": false, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "stageName": "s1", + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-stage-reset": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "my stage", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stage-after-reset": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "my stage", + "documentationVersion": "v123", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "s1", + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_routing_not_found": { + "recorded-date": "23-07-2024, 17:41:58", + "recorded-content": { + "working-route": { + "message": "exists", + "statusCode": 200 + }, + "not-found": { + "content": { + "message": "Missing Authentication Token" + }, + "errorType": "MissingAuthenticationTokenException" + }, + "wrong-method": { + "content": { + "message": "Missing Authentication Token" + }, + "errorType": "MissingAuthenticationTokenException" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_invocation_trace_id": { + "recorded-date": "07-08-2024, 20:24:17", + "recorded-content": { + "register-lambda": { + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "" + }, + "normal-req-headers-MOCK": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "via": "", + "x-amz-apigw-id": "", + "x-amz-cf-id": "", + "x-amz-cf-pop": "", + "x-amzn-requestid": "", + "x-cache": "Miss from cloudfront" + }, + "trace-id-req-headers-MOCK": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "via": "", + "x-amz-apigw-id": "", + "x-amz-cf-id": "", + "x-amz-cf-pop": "", + "x-amzn-requestid": "", + "x-cache": "Miss from cloudfront" + }, + "normal-req-headers-AWS_PROXY": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "via": "", + "x-amz-apigw-id": "", + "x-amz-cf-id": "", + "x-amz-cf-pop": "", + "x-amzn-requestid": "", + "x-amzn-trace-id": "", + "x-cache": "Miss from cloudfront" + }, + "trace-id-req-headers-AWS_PROXY": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "via": "", + "x-amz-apigw-id": "", + "x-amz-cf-id": "", + "x-amz-cf-pop": "", + "x-amzn-requestid": "", + "x-amzn-trace-id": "", + "x-cache": "Miss from cloudfront" + }, + "trace-id-small-req-headers-AWS_PROXY": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "via": "", + "x-amz-apigw-id": "", + "x-amz-cf-id": "", + "x-amz-cf-pop": "", + "x-amzn-requestid": "", + "x-amzn-trace-id": "", + "x-cache": "Miss from cloudfront" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator_with_ref_models": { + "recorded-date": "28-10-2024, 17:37:06", + "recorded-content": { + "get-models-with-ref": { + "items": [ + { + "contentType": "application/json", + "description": "This is a default empty schema model", + "id": "", + "name": "Empty", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Empty Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "This is a default error schema model", + "id": "", + "name": "Error", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Error Schema", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "testSchema", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "testSchema", + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + }, + { + "contentType": "application/json", + "id": "", + "name": "testSchemaList", + "schema": { + "type": "array", + "items": { + "$ref": "https://apigateway.amazonaws.com/restapis//models/testSchema" + } + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "successful": { + "data": "ok" + }, + "failed-validation": { + "message": "Invalid request body" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator_with_ref_one_ofmodels": { + "recorded-date": "28-10-2024, 23:12:21", + "recorded-content": { + "get-models-with-ref": { + "items": [ + { + "contentType": "application/json", + "description": "This is a default empty schema model", + "id": "", + "name": "Empty", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Empty Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "This is a default error schema model", + "id": "", + "name": "Error", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Error Schema", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "StatusModel", + "schema": { + "type": "object", + "properties": { + "Status": { + "type": "string" + }, + "Order": { + "type": "integer" + } + }, + "required": [ + "Status", + "Order" + ] + } + }, + { + "contentType": "application/json", + "id": "", + "name": "TestModel", + "schema": { + "type": "object", + "properties": { + "status": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "https://apigateway.amazonaws.com/restapis//models/StatusModel" + } + ] + } + }, + "required": [ + "status" + ] + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "successful": { + "data": "ok" + }, + "successful-with-data": { + "data": "ok" + }, + "failed-validation-no-data": { + "message": "Invalid request body" + }, + "failed-validation-bad-data": { + "message": "Invalid request body" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_path_template_formatting": { + "recorded-date": "18-06-2025, 17:28:59", + "recorded-content": { + "dict-response": "{foo=bar}", + "json-list": "[{\"foo\":\"bar\"}]", + "nested-dict": "{foo=bar}", + "nested-list": "[{\"foo\":\"bar\"}]", + "dict-in-list": "{foo=bar}", + "list-with-nested-list": "[{\"foo\":\"bar\"}]", + "dict-with-nested-list": "{foo=[{\"nested\":\"bar\"}]}", + "bigger-dict": "{bigger=dict, to=test, with=separators}", + "to-string": "{foo=bar}", + "list-to-string": "{list=[{\"foo\":\"bar\"}]}", + "empty-body": "{\"body\": , \"nested\": , \"isNull\": \"true\", \"isEmptyString\": \"true\"}", + "nested-empty-list": "{\"body\": [], \"nested\": , \"isNull\": \"false\", \"isEmptyString\": \"false\"}", + "nested-null-list": "{\"body\": , \"nested\": , \"isNull\": \"true\", \"isEmptyString\": \"true\"}" + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_body_formatting": { + "recorded-date": "19-03-2025, 17:03:40", + "recorded-content": { + "response-raw-body": { + "empty-body": "b'{}'", + "json-body": "b'{\"some\": \"value\"}'", + "str-body": "b'some raw data'" + }, + "response-str-body-input": { + "empty-body": "b'Action=SendMessage&MessageBody={}'", + "json-body": "b'Action=SendMessage&MessageBody={\"some\": \"value\"}'", + "str-body": "b'Action=SendMessage&MessageBody=some raw data'" + }, + "response-str-body-output": { + "empty-body": "b'Action=SendMessage&MessageBody={}'", + "json-body": "b'Action=SendMessage&MessageBody={\"some\": \"value\"}'", + "str-body": "b'Action=SendMessage&MessageBody=some raw data'" + }, + "response-str-body-all": { + "empty-body": "b'Action=SendMessage&MessageBody=Action=SendMessage&MessageBody={}'", + "json-body": "b'Action=SendMessage&MessageBody=Action=SendMessage&MessageBody={\"some\": \"value\"}'", + "str-body": "b'Action=SendMessage&MessageBody=Action=SendMessage&MessageBody=some raw data'" + }, + "response-body-attr-access": { + "empty-body": "b'{}'", + "json-body": "b'{}'", + "str-body": "b'{}'" + }, + "response-url-encode": { + "empty-body": "b'EncodedBody=%7B%7D&EncodedBodyAccess='", + "json-body": "b'EncodedBody=%7B%22some%22%3A+%22value%22%7D&EncodedBodyAccess='", + "str-body": "b'EncodedBody=some+raw+data&EncodedBodyAccess='" + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_common.validation.json b/tests/aws/services/apigateway/test_apigateway_common.validation.json new file mode 100644 index 0000000000000..9cbc496d24987 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_common.validation.json @@ -0,0 +1,62 @@ +{ + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator": { + "last_validated_date": "2024-01-10T00:06:10+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator_with_ref_models": { + "last_validated_date": "2024-10-28T17:37:06+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_api_gateway_request_validator_with_ref_one_ofmodels": { + "last_validated_date": "2024-10-28T23:12:21+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_body_formatting": { + "last_validated_date": "2025-03-19T17:03:40+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_input_path_template_formatting": { + "last_validated_date": "2025-06-18T17:29:00+00:00", + "durations_in_seconds": { + "setup": 0.48, + "call": 42.72, + "teardown": 0.86, + "total": 44.06 + } + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_integration_request_parameters_mapping": { + "last_validated_date": "2024-02-05T19:37:03+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApiGatewayCommon::test_invocation_trace_id": { + "last_validated_date": "2024-08-07T20:24:17+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_proxy_routing_with_hardcoded_resource_sibling": { + "last_validated_date": "2024-07-23T17:41:36+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_routing_not_found": { + "last_validated_date": "2024-07-23T17:41:58+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestApigatewayRouting::test_routing_with_hardcoded_resource_sibling_order": { + "last_validated_date": "2024-07-23T17:41:43+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_delete_deployments[False]": { + "last_validated_date": "2023-09-07T17:20:47+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_delete_deployments[True]": { + "last_validated_date": "2023-09-07T17:27:48+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDeployments::test_create_update_deployments": { + "last_validated_date": "2023-09-11T10:12:50+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestDocumentations::test_documentation_parts_and_versions": { + "last_validated_date": "2023-08-07T20:02:13+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestStages::test_create_update_stages": { + "last_validated_date": "2024-03-05T18:54:23+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestStages::test_update_stage_remove_wildcard": { + "last_validated_date": "2024-03-05T21:10:56+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestUsagePlans::test_api_key_required_for_methods": { + "last_validated_date": "2023-07-27T15:57:20+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_common.py::TestUsagePlans::test_usage_plan_crud": { + "last_validated_date": "2024-03-20T22:49:50+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_custom_ids.py b/tests/aws/services/apigateway/test_apigateway_custom_ids.py new file mode 100644 index 0000000000000..0accdcfdfd103 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_custom_ids.py @@ -0,0 +1,61 @@ +from moto.apigateway.utils import ( + ApigwApiKeyIdentifier, + ApigwResourceIdentifier, + ApigwRestApiIdentifier, +) + +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid, short_uid + +API_ID = "ApiId" +ROOT_RESOURCE_ID = "RootId" +PET_1_RESOURCE_ID = "Pet1Id" +PET_2_RESOURCE_ID = "Pet2Id" +API_KEY_ID = "ApiKeyId" + + +# Custom ids can't be set on aws. +@markers.aws.only_localstack +def test_apigateway_custom_ids( + aws_client, set_resource_custom_id, create_rest_apigw, account_id, region_name, cleanups +): + rest_api_name = f"apigw-{short_uid()}" + api_key_value = long_uid() + + set_resource_custom_id(ApigwRestApiIdentifier(account_id, region_name, rest_api_name), API_ID) + set_resource_custom_id( + ApigwResourceIdentifier(account_id, region_name, path_name="/"), ROOT_RESOURCE_ID + ) + set_resource_custom_id( + ApigwResourceIdentifier( + account_id, region_name, parent_id=ROOT_RESOURCE_ID, path_name="pet" + ), + PET_1_RESOURCE_ID, + ) + set_resource_custom_id( + ApigwResourceIdentifier( + account_id, region_name, parent_id=PET_1_RESOURCE_ID, path_name="pet" + ), + PET_2_RESOURCE_ID, + ) + set_resource_custom_id( + ApigwApiKeyIdentifier(account_id, region_name, value=api_key_value), API_KEY_ID + ) + + api_id, name, root_id = create_rest_apigw(name=rest_api_name) + pet_resource_1 = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=ROOT_RESOURCE_ID, pathPart="pet" + ) + # we create a second resource with the same path part to ensure we can pass different ids + pet_resource_2 = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=PET_1_RESOURCE_ID, pathPart="pet" + ) + api_key = aws_client.apigateway.create_api_key(name="api-key", value=api_key_value) + cleanups.append(lambda: aws_client.apigateway.delete_api_key(apiKey=api_key["id"])) + + assert api_id == API_ID + assert name == rest_api_name + assert root_id == ROOT_RESOURCE_ID + assert pet_resource_1["id"] == PET_1_RESOURCE_ID + assert pet_resource_2["id"] == PET_2_RESOURCE_ID + assert api_key["id"] == API_KEY_ID diff --git a/tests/aws/services/apigateway/test_apigateway_dynamodb.py b/tests/aws/services/apigateway/test_apigateway_dynamodb.py new file mode 100644 index 0000000000000..977d03d5ab72e --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_dynamodb.py @@ -0,0 +1,143 @@ +import json + +import pytest +from botocore.exceptions import ClientError + +from localstack.constants import APPLICATION_JSON +from localstack.testing.pytest import markers +from localstack.utils.http import safe_requests as requests +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import ( + api_invoke_url, + create_rest_api_integration, +) +from tests.aws.services.apigateway.conftest import DEFAULT_STAGE_NAME, is_next_gen_api + + +@markers.aws.validated +@pytest.mark.parametrize("ddb_action", ["PutItem", "Query", "Scan"]) +@markers.snapshot.skip_snapshot_verify(paths=["$..headers.server"]) +@markers.snapshot.skip_snapshot_verify( + condition=lambda: not is_next_gen_api(), + paths=[ + "$..headers.connection", + "$..headers.x-amz-apigw-id", + "$..headers.x-amzn-requestid", + "$..headers.x-amzn-trace-id", + ], +) +def test_rest_api_to_dynamodb_integration( + ddb_action, + dynamodb_create_table, + create_rest_api_with_integration, + snapshot, + aws_client, +): + snapshot.add_transformer(snapshot.transform.key_value("date", reference_replacement=False)) + snapshot.add_transformer(snapshot.transform.key_value("x-amzn-trace-id")) + snapshot.add_transformer( + snapshot.transform.key_value("content-length", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("x-amz-apigw-id", reference_replacement=False) + ) + + # create table + table = dynamodb_create_table()["TableDescription"] + table_name = table["TableName"] + + # insert items + item_ids = ("test", "test2", "test 3") + for item_id in item_ids: + aws_client.dynamodb.put_item(TableName=table_name, Item={"id": {"S": item_id}}) + + # construct request mapping template + if ddb_action == "PutItem": + template = json.dumps( + { + "TableName": table_name, + "Item": {"id": {"S": "$input.params('id')"}}, + } + ) + elif ddb_action == "Query": + template = json.dumps( + { + "TableName": table_name, + "KeyConditionExpression": "id = :id", + "ExpressionAttributeValues": {":id": {"S": "$input.params('id')"}}, + } + ) + elif ddb_action == "Scan": + template = json.dumps({"TableName": table_name}) + request_templates = {APPLICATION_JSON: template} + + # deploy REST API with integration + region_name = aws_client.apigateway.meta.region_name + integration_uri = f"arn:aws:apigateway:{region_name}:dynamodb:action/{ddb_action}" + api_id = create_rest_api_with_integration( + integration_uri=integration_uri, + req_templates=request_templates, + integration_type="AWS", + ) + + def _invoke_endpoint(id_param=None): + url = api_invoke_url(api_id, stage=DEFAULT_STAGE_NAME, path=f"/test?id={id_param}") + response = requests.post(url) + assert response.status_code == 200 + return { + "status_code": response.status_code, + "content": response.json(), + "headers": {k.lower(): v for k, v in dict(response.headers).items()}, + } + + def _invoke_with_retries(id_param=None): + return retry(lambda: _invoke_endpoint(id_param), retries=15, sleep=2) + + # run assertions + + if ddb_action == "PutItem": + result = _invoke_with_retries("test-new") + snapshot.match("result-put-item", result) + result = aws_client.dynamodb.scan(TableName=table_name) + result["Items"] = sorted(result["Items"], key=lambda x: x["id"]["S"]) + snapshot.match("result-scan", result) + + elif ddb_action == "Query": + # retrieve valid item IDs + for item_id in item_ids: + result = _invoke_with_retries(item_id) + snapshot.match(f"result-{item_id}", result) + # retrieve invalid item ID + result = _invoke_with_retries("test-invalid") + snapshot.match("result-invalid", result) + + elif ddb_action == "Scan": + result = _invoke_with_retries() + result["content"]["Items"] = sorted(result["content"]["Items"], key=lambda x: x["id"]["S"]) + snapshot.match("result-scan", result) + + +@markers.aws.validated +def test_error_aws_proxy_not_supported(create_rest_api_with_integration, snapshot, aws_client): + region_name = aws_client.apigateway.meta.region_name + integration_uri = f"arn:aws:apigateway:{region_name}:dynamodb:action/Query" + + api_id = create_rest_api_with_integration( + integration_uri=integration_uri, + integration_type="AWS", + ) + + # assert error - AWS_PROXY not supported for DDB integrations (AWS parity) + resources = aws_client.apigateway.get_resources(restApiId=api_id)["items"] + child_resource = [res for res in resources if res.get("parentId")][0] + with pytest.raises(ClientError) as exc: + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=child_resource["id"], + httpMethod="POST", + integrationHttpMethod="POST", + type="AWS_PROXY", + uri=integration_uri, + ) + snapshot.match("create-integration-error", exc.value.response) diff --git a/tests/aws/services/apigateway/test_apigateway_dynamodb.snapshot.json b/tests/aws/services/apigateway/test_apigateway_dynamodb.snapshot.json new file mode 100644 index 0000000000000..c4f3d8663a162 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_dynamodb.snapshot.json @@ -0,0 +1,195 @@ +{ + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_error_aws_proxy_not_supported": { + "recorded-date": "26-02-2023, 12:45:17", + "recorded-content": { + "create-integration-error": { + "Error": { + "Code": "BadRequestException", + "Message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations." + }, + "message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[PutItem]": { + "recorded-date": "19-07-2024, 19:53:50", + "recorded-content": { + "result-put-item": { + "content": {}, + "headers": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "x-amz-apigw-id": "x-amz-apigw-id", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + }, + "result-scan": { + "Count": 4, + "Items": [ + { + "id": { + "S": "test" + } + }, + { + "id": { + "S": "test 3" + } + }, + { + "id": { + "S": "test-new" + } + }, + { + "id": { + "S": "test2" + } + } + ], + "ScannedCount": 4, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[Query]": { + "recorded-date": "19-07-2024, 19:54:08", + "recorded-content": { + "result-test": { + "content": { + "Count": 1, + "Items": [ + { + "id": { + "S": "test" + } + } + ], + "ScannedCount": 1 + }, + "headers": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "x-amz-apigw-id": "x-amz-apigw-id", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + }, + "result-test2": { + "content": { + "Count": 1, + "Items": [ + { + "id": { + "S": "test2" + } + } + ], + "ScannedCount": 1 + }, + "headers": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "x-amz-apigw-id": "x-amz-apigw-id", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + }, + "result-test 3": { + "content": { + "Count": 1, + "Items": [ + { + "id": { + "S": "test 3" + } + } + ], + "ScannedCount": 1 + }, + "headers": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "x-amz-apigw-id": "x-amz-apigw-id", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + }, + "result-invalid": { + "content": { + "Count": 0, + "Items": [], + "ScannedCount": 0 + }, + "headers": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "x-amz-apigw-id": "x-amz-apigw-id", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[Scan]": { + "recorded-date": "19-07-2024, 19:54:29", + "recorded-content": { + "result-scan": { + "content": { + "Count": 3, + "Items": [ + { + "id": { + "S": "test" + } + }, + { + "id": { + "S": "test 3" + } + }, + { + "id": { + "S": "test2" + } + } + ], + "ScannedCount": 3 + }, + "headers": { + "connection": "keep-alive", + "content-length": "content-length", + "content-type": "application/json", + "date": "date", + "x-amz-apigw-id": "x-amz-apigw-id", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_dynamodb.validation.json b/tests/aws/services/apigateway/test_apigateway_dynamodb.validation.json new file mode 100644 index 0000000000000..6a0adf07c1fb8 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_dynamodb.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_error_aws_proxy_not_supported": { + "last_validated_date": "2023-02-26T11:45:17+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[PutItem]": { + "last_validated_date": "2024-07-19T19:53:50+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[Query]": { + "last_validated_date": "2024-07-19T19:54:08+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_dynamodb.py::test_rest_api_to_dynamodb_integration[Scan]": { + "last_validated_date": "2024-07-19T19:54:29+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_eventbridge.py b/tests/aws/services/apigateway/test_apigateway_eventbridge.py new file mode 100644 index 0000000000000..ef8227ca8b8b0 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_eventbridge.py @@ -0,0 +1,135 @@ +import json + +import requests + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import ( + api_invoke_url, + create_rest_api_deployment, + create_rest_api_integration, + create_rest_api_integration_response, + create_rest_api_method_response, + create_rest_resource, + create_rest_resource_method, +) +from tests.aws.services.apigateway.conftest import APIGATEWAY_ASSUME_ROLE_POLICY + + +@markers.aws.validated +def test_apigateway_to_eventbridge( + aws_client, create_rest_apigw, create_role_with_policy, region_name, account_id, snapshot +): + api_id, _, root = create_rest_apigw(name=f"{short_uid()}-eventbridge") + + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="event" + ) + + create_rest_resource_method( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + requestParameters={ + "method.request.header.X-Amz-Target": False, + "method.request.header.Content-Type": False, + }, + ) + + _, role_arn = create_role_with_policy( + "Allow", "events:PutEvents", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + create_rest_api_integration( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + integrationHttpMethod="POST", + type="AWS", + uri=f"arn:aws:apigateway:{region_name}:events:action/PutEvents", + passthroughBehavior="WHEN_NO_TEMPLATES", + credentials=role_arn, + requestParameters={}, + requestTemplates={ + "application/json": """ + #set($context.requestOverride.header.X-Amz-Target = "AWSEvents.PutEvents") + #set($context.requestOverride.header.Content-Type = "application/x-amz-json-1.1") + #set($inputRoot = $input.path('$')) + { + "Entries": [ + #foreach($elem in $inputRoot.items) + { + "Detail": "$util.escapeJavaScript($elem.Detail).replaceAll("\\'","'")", + "DetailType": "$elem.DetailType", + "Source":"$elem.Source" + }#if($foreach.hasNext),#end + #end + ] + } + """ + }, + ) + + create_rest_api_method_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseModels={"application/json": "Empty"}, + responseParameters={}, + ) + + create_rest_api_integration_response( + aws_client.apigateway, + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseTemplates={"application/json": "#set($inputRoot = $input.json('$'))\n$inputRoot"}, + ) + + create_rest_api_deployment(aws_client.apigateway, restApiId=api_id, stageName="dev") + + # invoke rest api + invocation_url = api_invoke_url( + api_id=api_id, + stage="dev", + path="/event", + ) + + def invoke_api(url) -> dict: + resp = requests.post( + url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + data=json.dumps( + { + "items": [ + { + "Detail": '{"data":"Order is created"}', + "DetailType": "Test", + "Source": "order", + } + ] + } + ), + verify=False, + ) + assert resp.ok + + json_resp = resp.json() + assert "Entries" in json_resp + return json_resp + + if is_aws_cloud(): + # retry is necessary against AWS, probably IAM permission delay + response = retry(invoke_api, sleep=1, retries=10, url=invocation_url) + else: + response = invoke_api(invocation_url) + + snapshot.match("eventbridge-put-events-response", response) diff --git a/tests/aws/services/apigateway/test_apigateway_eventbridge.snapshot.json b/tests/aws/services/apigateway/test_apigateway_eventbridge.snapshot.json new file mode 100644 index 0000000000000..d9e7b02dec0a9 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_eventbridge.snapshot.json @@ -0,0 +1,15 @@ +{ + "tests/aws/services/apigateway/test_apigateway_eventbridge.py::test_apigateway_to_eventbridge": { + "recorded-date": "12-07-2024, 17:42:38", + "recorded-content": { + "eventbridge-put-events-response": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0 + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_eventbridge.validation.json b/tests/aws/services/apigateway/test_apigateway_eventbridge.validation.json new file mode 100644 index 0000000000000..59f0a27a3007c --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_eventbridge.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/apigateway/test_apigateway_eventbridge.py::test_apigateway_to_eventbridge": { + "last_validated_date": "2024-11-14T22:12:09+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_extended.py b/tests/aws/services/apigateway/test_apigateway_extended.py new file mode 100644 index 0000000000000..c95965db241c1 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_extended.py @@ -0,0 +1,334 @@ +# TODO: find a more meaningful name for this file, further refactor tests into different functional areas +import logging +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +LOG = logging.getLogger(__name__) + + +THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +TEST_IMPORT_PETSTORE_SWAGGER = os.path.join(THIS_FOLDER, "../../files/petstore-swagger.json") +TEST_IMPORT_PETS = os.path.join(THIS_FOLDER, "../../files/pets.json") + + +@pytest.fixture +def apigw_create_api_key(aws_client): + api_keys = [] + + def _create(**kwargs): + response = aws_client.apigateway.create_api_key(**kwargs) + api_keys.append(response["id"]) + return response + + yield _create + + for api_key_id in api_keys: + try: + aws_client.apigateway.delete_api_key(apiKey=api_key_id) + except aws_client.apigateway.exceptions.NotFoundException: + pass + except Exception as e: + LOG.warning("Error while cleaning up APIGW API Key %s: %s", api_key_id, e) + + +@markers.aws.validated +@pytest.mark.parametrize( + "import_file", + [TEST_IMPORT_PETSTORE_SWAGGER, TEST_IMPORT_PETS], + ids=["TEST_IMPORT_PETSTORE_SWAGGER", "TEST_IMPORT_PETS"], +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..body.host", + # TODO: not returned by LS + "$..endpointConfiguration.ipAddressType", + ] +) +def test_export_swagger_openapi(aws_client, snapshot, import_apigw, import_file, region_name): + snapshot.add_transformer( + [ + snapshot.transform.jsonpath("$.import-api.id", value_replacement="api-id"), + snapshot.transform.key_value("rootResourceId"), + ] + ) + spec_file = load_file(import_file) + spec_file = spec_file.replace( + "${uri}", f"http://petstore.execute-api.{region_name}.amazonaws.com/petstore/pets" + ) + + response, _ = import_apigw(body=spec_file, failOnWarnings=True) + snapshot.match("import-api", response) + api_id = response["id"] + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName="local") + + response = aws_client.apigateway.get_export( + restApiId=api_id, stageName="local", exportType="swagger" + ) + snapshot.match("get-export", response) + + response = aws_client.apigateway.get_export( + restApiId=api_id, + stageName="local", + exportType="swagger", + parameters={"extensions": "apigateway"}, + ) + snapshot.match("get-export-with-extensions", response) + + +@markers.aws.validated +@pytest.mark.parametrize( + "import_file", + [TEST_IMPORT_PETSTORE_SWAGGER, TEST_IMPORT_PETS], + ids=["TEST_IMPORT_PETSTORE_SWAGGER", "TEST_IMPORT_PETS"], +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..body.servers..url", + # TODO: not returned by LS + "$..endpointConfiguration.ipAddressType", + ] +) +def test_export_oas30_openapi(aws_client, snapshot, import_apigw, region_name, import_file): + snapshot.add_transformer( + [ + snapshot.transform.jsonpath("$.import-api.id", value_replacement="api-id"), + snapshot.transform.key_value("rootResourceId"), + ] + ) + + spec_file = load_file(import_file) + spec_file = spec_file.replace( + "${uri}", f"http://petstore.execute-api.{region_name}.amazonaws.com/petstore/pets" + ) + + response, _ = import_apigw(body=spec_file, failOnWarnings=True) + snapshot.match("import-api", response) + api_id = response["id"] + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName="local") + + response = aws_client.apigateway.get_export( + restApiId=api_id, stageName="local", exportType="oas30" + ) + snapshot.match("get-export", response) + + response = aws_client.apigateway.get_export( + restApiId=api_id, + stageName="local", + exportType="oas30", + parameters={"extensions": "apigateway"}, + ) + snapshot.match("get-export-with-extensions", response) + + +@markers.aws.needs_fixing +def test_create_domain_names(aws_client): + domain_name = f"{short_uid()}-testDomain" + test_certificate_name = "test.certificate" + test_certificate_private_key = "testPrivateKey" + # success case with valid params + response = aws_client.apigateway.create_domain_name( + domainName=domain_name, + certificateName=test_certificate_name, + certificatePrivateKey=test_certificate_private_key, + ) + assert response["domainName"] == domain_name + assert response["certificateName"] == test_certificate_name + # without domain name it should throw BadRequestException + with pytest.raises(ClientError) as ex: + aws_client.apigateway.create_domain_name(domainName="") + + assert ex.value.response["Error"]["Message"] == "No Domain Name specified" + assert ex.value.response["Error"]["Code"] == "BadRequestException" + + +@markers.aws.needs_fixing +def test_get_domain_names(aws_client): + # create domain name + domain_name = f"domain-{short_uid()}" + test_certificate_name = "test.certificate" + response = aws_client.apigateway.create_domain_name( + domainName=domain_name, certificateName=test_certificate_name + ) + assert response["domainName"] == domain_name + assert response["certificateName"] == test_certificate_name + assert response["domainNameStatus"] == "AVAILABLE" + + # get new domain name + result = aws_client.apigateway.get_domain_names() + added = [dom for dom in result["items"] if dom["domainName"] == domain_name] + assert added + assert added[0]["domainName"] == domain_name + assert added[0]["certificateName"] == test_certificate_name + assert added[0]["domainNameStatus"] == "AVAILABLE" + + +@markers.aws.needs_fixing +def test_get_domain_name(aws_client): + domain_name = f"{short_uid()}-testDomain" + # adding a domain name + aws_client.apigateway.create_domain_name(domainName=domain_name) + # retrieving the data of added domain name. + result = aws_client.apigateway.get_domain_name(domainName=domain_name) + assert result["domainName"] == domain_name + assert result["domainNameStatus"] == "AVAILABLE" + + +class TestApigatewayApiKeysCrud: + @pytest.fixture(scope="class", autouse=True) + def cleanup_api_keys(self, aws_client): + for api_key in aws_client.apigateway.get_api_keys()["items"]: + aws_client.apigateway.delete_api_key(apiKey=api_key["id"]) + + @markers.aws.validated + def test_get_api_keys(self, aws_client, apigw_create_api_key, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("id"), + snapshot.transform.key_value("value"), + snapshot.transform.key_value("name"), + snapshot.transform.key_value("position"), + ] + ) + api_key_name = f"test-key-{short_uid()}" + api_key_name_2 = f"test-key-{short_uid()}" + + get_api_keys = aws_client.apigateway.get_api_keys() + snapshot.match("get-api-keys", get_api_keys) + + create_api_key = apigw_create_api_key(name=api_key_name) + snapshot.match("create-api-key", create_api_key) + + get_api_keys_after_create = aws_client.apigateway.get_api_keys() + snapshot.match("get-api-keys-after-create-1", get_api_keys_after_create) + + # test not created api key + api_keys_wrong_name = aws_client.apigateway.get_api_keys(nameQuery=api_key_name_2) + snapshot.match("get-api-keys-wrong-name-query", api_keys_wrong_name) + + # test prefix + api_keys_prefix = aws_client.apigateway.get_api_keys(nameQuery=api_key_name[:8]) + snapshot.match("get-api-keys-prefix-name-query", api_keys_prefix) + + # test prefix cased + api_keys_prefix_cased = aws_client.apigateway.get_api_keys( + nameQuery=api_key_name[:8].upper() + ) + snapshot.match("get-api-keys-prefix-name-query-cased", api_keys_prefix_cased) + + # test postfix + api_keys_postfix = aws_client.apigateway.get_api_keys(nameQuery=api_key_name[2:]) + snapshot.match("get-api-keys-postfix-name-query", api_keys_postfix) + + # test infix + api_keys_infix = aws_client.apigateway.get_api_keys(nameQuery=api_key_name[2:8]) + snapshot.match("get-api-keys-infix-name-query", api_keys_infix) + + create_api_key_2 = apigw_create_api_key(name=api_key_name_2) + snapshot.match("create-api-key-2", create_api_key_2) + + get_api_keys_after_create_2 = aws_client.apigateway.get_api_keys() + snapshot.match("get-api-keys-after-create-2", get_api_keys_after_create_2) + + api_keys_full_name_2 = aws_client.apigateway.get_api_keys(nameQuery=api_key_name_2) + snapshot.match("get-api-keys-name-query", api_keys_full_name_2) + + # the 2 keys share the same prefix + api_keys_prefix = aws_client.apigateway.get_api_keys(nameQuery=api_key_name[:8]) + snapshot.match("get-api-keys-prefix-name-query-2", api_keys_prefix) + + # some minor paging testing + api_keys_page = aws_client.apigateway.get_api_keys(limit=1) + snapshot.match("get-apis-keys-pagination", api_keys_page) + + api_keys_page_2 = aws_client.apigateway.get_api_keys( + limit=1, position=api_keys_page["position"] + ) + snapshot.match("get-apis-keys-pagination-2", api_keys_page_2) + + @markers.aws.validated + def test_get_usage_plan_api_keys(self, aws_client, apigw_create_api_key, snapshot, cleanups): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("id"), + snapshot.transform.key_value("value"), + snapshot.transform.key_value("name"), + ] + ) + api_key_name = f"test-key-{short_uid()}" + api_key_name_2 = f"test-key-{short_uid()}" + + get_api_keys = aws_client.apigateway.get_api_keys() + snapshot.match("get-api-keys", get_api_keys) + + create_api_key = apigw_create_api_key(name=api_key_name) + snapshot.match("create-api-key", create_api_key) + + create_api_key_2 = apigw_create_api_key(name=api_key_name_2) + snapshot.match("create-api-key-2", create_api_key) + + get_api_keys_after_create = aws_client.apigateway.get_api_keys() + snapshot.match("get-api-keys-after-create-1", get_api_keys_after_create) + + create_usage_plan = aws_client.apigateway.create_usage_plan( + name=f"usage-plan-{short_uid()}" + ) + usage_plan_id = create_usage_plan["id"] + cleanups.append(lambda: aws_client.apigateway.delete_usage_plan(usagePlanId=usage_plan_id)) + snapshot.match("create-usage-plan", create_usage_plan) + + get_up_keys_before_create = aws_client.apigateway.get_usage_plan_keys( + usagePlanId=usage_plan_id + ) + snapshot.match("get-up-keys-before-create", get_up_keys_before_create) + + create_up_key = aws_client.apigateway.create_usage_plan_key( + usagePlanId=usage_plan_id, keyId=create_api_key["id"], keyType="API_KEY" + ) + snapshot.match("create-up-key", create_up_key) + + create_up_key_2 = aws_client.apigateway.create_usage_plan_key( + usagePlanId=usage_plan_id, keyId=create_api_key_2["id"], keyType="API_KEY" + ) + snapshot.match("create-up-key-2", create_up_key_2) + + get_up_keys = aws_client.apigateway.get_usage_plan_keys(usagePlanId=usage_plan_id) + snapshot.match("get-up-keys", get_up_keys) + + get_up_keys_query = aws_client.apigateway.get_usage_plan_keys( + usagePlanId=usage_plan_id, nameQuery="test-key" + ) + snapshot.match("get-up-keys-name-query", get_up_keys_query) + + get_up_keys_query_cased = aws_client.apigateway.get_usage_plan_keys( + usagePlanId=usage_plan_id, nameQuery="TEST-key" + ) + snapshot.match("get-up-keys-name-query-cased", get_up_keys_query_cased) + + get_up_keys_query_name = aws_client.apigateway.get_usage_plan_keys( + usagePlanId=usage_plan_id, nameQuery=api_key_name + ) + snapshot.match("get-up-keys-name-query-key-name", get_up_keys_query_name) + + get_up_keys_bad_query = aws_client.apigateway.get_usage_plan_keys( + usagePlanId=usage_plan_id, nameQuery="nothing" + ) + snapshot.match("get-up-keys-bad-query", get_up_keys_bad_query) + + aws_client.apigateway.delete_api_key(apiKey=create_api_key["id"]) + aws_client.apigateway.delete_api_key(apiKey=create_api_key_2["id"]) + + get_up_keys_after_delete = aws_client.apigateway.get_usage_plan_keys( + usagePlanId=usage_plan_id + ) + snapshot.match("get-up-keys-after-delete", get_up_keys_after_delete) + + get_up_keys_bad_d = aws_client.apigateway.get_usage_plan_keys(usagePlanId="bad-id") + snapshot.match("get-up-keys-bad-usage-plan", get_up_keys_bad_d) diff --git a/tests/aws/services/apigateway/test_apigateway_extended.snapshot.json b/tests/aws/services/apigateway/test_apigateway_extended.snapshot.json new file mode 100644 index 0000000000000..76db5eff4a01b --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_extended.snapshot.json @@ -0,0 +1,2029 @@ +{ + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_swagger_openapi[TEST_IMPORT_PETSTORE_SWAGGER]": { + "recorded-date": "06-05-2025, 18:20:26", + "recorded-content": { + "import-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "PetStore", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-export": { + "body": { + "swagger": "2.0", + "info": { + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "version": "date", + "title": "PetStore" + }, + "host": ".execute-api..amazonaws.com", + "basePath": "/local", + "schemes": [ + "https" + ], + "paths": { + "/": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "text/html" + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Content-Type": { + "type": "string" + } + } + } + } + } + }, + "/pets": { + "get": { + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "type", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "page", + "in": "query", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Pets" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + } + } + } + } + }, + "post": { + "operationId": "CreatePet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "NewPet", + "required": true, + "schema": { + "$ref": "#/definitions/NewPet" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/NewPetResponse" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + } + } + } + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "operationId": "GetPet", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Pet" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + } + } + } + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + }, + "Empty": { + "type": "object" + }, + "NewPetResponse": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/definitions/Pet" + }, + "message": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "NewPet": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/PetType" + }, + "price": { + "type": "number" + } + } + }, + "PetType": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + } + }, + "contentDisposition": "attachment; filename=\"swagger_date.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-export-with-extensions": { + "body": { + "swagger": "2.0", + "info": { + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "version": "date", + "title": "PetStore" + }, + "host": ".execute-api..amazonaws.com", + "basePath": "/local", + "schemes": [ + "https" + ], + "paths": { + "/": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "text/html" + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Content-Type": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Content-Type": "'text/html'" + }, + "responseTemplates": { + "text/html": "\n \n \n \n \n

Welcome to your Pet Store API

\n

\n You have successfully deployed your first API. You are seeing this HTML page because the GET method to the root resource of your API returns this content as a Mock integration.\n

\n

\n The Pet Store API contains the /pets and /pets/{petId} resources. By making a GET request to /pets you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a GET request to /pets/1.\n

\n

\n You can use a REST client such as Postman to test the POST methods in your API to create a new pet. Use the sample body below to send the POST request:\n

\n
\n{\n    \"type\" : \"cat\",\n    \"price\" : 123.11\n}\n        
\n \n" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match" + } + } + }, + "/pets": { + "get": { + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "type", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "page", + "in": "query", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Pets" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "GET", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.querystring.page": "method.request.querystring.page", + "integration.request.querystring.type": "method.request.querystring.type" + }, + "passthroughBehavior": "when_no_match" + } + }, + "post": { + "operationId": "CreatePet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "NewPet", + "required": true, + "schema": { + "$ref": "#/definitions/NewPet" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/NewPetResponse" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "POST", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "passthroughBehavior": "when_no_match" + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match" + } + } + }, + "/pets/{petId}": { + "get": { + "operationId": "GetPet", + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Pet" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "GET", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets/{petId}", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.path.petId": "method.request.path.petId" + }, + "passthroughBehavior": "when_no_match" + } + }, + "options": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response", + "schema": { + "$ref": "#/definitions/Empty" + }, + "headers": { + "Access-Control-Allow-Origin": { + "type": "string" + }, + "Access-Control-Allow-Methods": { + "type": "string" + }, + "Access-Control-Allow-Headers": { + "type": "string" + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match" + } + } + } + }, + "definitions": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + }, + "Empty": { + "type": "object" + }, + "NewPetResponse": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/definitions/Pet" + }, + "message": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "NewPet": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/PetType" + }, + "price": { + "type": "number" + } + } + }, + "PetType": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + } + }, + "contentDisposition": "attachment; filename=\"swagger_date.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_swagger_openapi[TEST_IMPORT_PETS]": { + "recorded-date": "06-05-2025, 18:21:08", + "recorded-content": { + "import-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/png", + "image/jpg" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "Simple PetStore (Swagger)", + "rootResourceId": "", + "version": "1.0.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-export": { + "body": { + "swagger": "2.0", + "info": { + "version": "1.0.0", + "title": "Simple PetStore (Swagger)" + }, + "host": ".execute-api..amazonaws.com", + "basePath": "/local", + "schemes": [ + "https" + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "200 response" + } + } + } + }, + "/pets/{petId}": { + "get": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response" + } + } + } + } + } + }, + "contentDisposition": "attachment; filename=\"swagger_1.0.0.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-export-with-extensions": { + "body": { + "swagger": "2.0", + "info": { + "version": "1.0.0", + "title": "Simple PetStore (Swagger)" + }, + "host": ".execute-api..amazonaws.com", + "basePath": "/local", + "schemes": [ + "https" + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "GET", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "responses": { + "default": { + "statusCode": "200" + } + }, + "passthroughBehavior": "when_no_match" + } + } + }, + "/pets/{petId}": { + "get": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "200 response" + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "GET", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}", + "responses": { + "default": { + "statusCode": "200" + } + }, + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "passthroughBehavior": "when_no_match" + } + } + } + }, + "x-amazon-apigateway-binary-media-types": [ + "image/png", + "image/jpg" + ] + }, + "contentDisposition": "attachment; filename=\"swagger_1.0.0.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_oas30_openapi[TEST_IMPORT_PETSTORE_SWAGGER]": { + "recorded-date": "06-05-2025, 18:34:11", + "recorded-content": { + "import-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "PetStore", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-export": { + "body": { + "openapi": "3.0.1", + "info": { + "title": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "version": "date" + }, + "servers": [ + { + "url": "https://.execute-api..amazonaws.com/{basePath}", + "variables": { + "basePath": { + "default": "local" + } + } + } + ], + "paths": { + "/pets": { + "get": { + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + } + } + }, + "post": { + "operationId": "CreatePet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPetResponse" + } + } + } + } + } + }, + "options": { + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "operationId": "GetPet", + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + }, + "options": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + } + } + }, + "/": { + "get": { + "responses": { + "200": { + "description": "200 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + }, + "content": {} + } + } + } + } + }, + "components": { + "schemas": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Empty": { + "type": "object" + }, + "NewPetResponse": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Pet" + }, + "message": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "NewPet": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/PetType" + }, + "price": { + "type": "number" + } + } + }, + "PetType": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + } + } + }, + "contentDisposition": "attachment; filename=\"oas30_date.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-export-with-extensions": { + "body": { + "openapi": "3.0.1", + "info": { + "title": "PetStore", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "version": "date" + }, + "servers": [ + { + "url": "https://.execute-api..amazonaws.com/{basePath}", + "variables": { + "basePath": { + "default": "local" + } + } + } + ], + "paths": { + "/pets": { + "get": { + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "GET", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.querystring.page": "method.request.querystring.page", + "integration.request.querystring.type": "method.request.querystring.type" + }, + "passthroughBehavior": "when_no_match" + } + }, + "post": { + "operationId": "CreatePet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPetResponse" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "POST", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "passthroughBehavior": "when_no_match" + } + }, + "options": { + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match" + } + } + }, + "/pets/{petId}": { + "get": { + "operationId": "GetPet", + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "http", + "httpMethod": "GET", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets/{petId}", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestParameters": { + "integration.request.path.petId": "method.request.path.petId" + }, + "passthroughBehavior": "when_no_match" + } + }, + "options": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Empty" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match" + } + } + }, + "/": { + "get": { + "responses": { + "200": { + "description": "200 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + }, + "content": {} + } + }, + "x-amazon-apigateway-integration": { + "type": "mock", + "responses": { + "default": { + "statusCode": "200", + "responseParameters": { + "method.response.header.Content-Type": "'text/html'" + }, + "responseTemplates": { + "text/html": "\n \n \n \n \n

Welcome to your Pet Store API

\n

\n You have successfully deployed your first API. You are seeing this HTML page because the GET method to the root resource of your API returns this content as a Mock integration.\n

\n

\n The Pet Store API contains the /pets and /pets/{petId} resources. By making a GET request to /pets you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a GET request to /pets/1.\n

\n

\n You can use a REST client such as Postman to test the POST methods in your API to create a new pet. Use the sample body below to send the POST request:\n

\n
\n{\n    \"type\" : \"cat\",\n    \"price\" : 123.11\n}\n        
\n \n" + } + } + }, + "requestTemplates": { + "application/json": "{\"statusCode\": 200}" + }, + "passthroughBehavior": "when_no_match" + } + } + } + }, + "components": { + "schemas": { + "Pets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Empty": { + "type": "object" + }, + "NewPetResponse": { + "type": "object", + "properties": { + "pet": { + "$ref": "#/components/schemas/Pet" + }, + "message": { + "type": "string" + } + } + }, + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + }, + "NewPet": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/PetType" + }, + "price": { + "type": "number" + } + } + }, + "PetType": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + } + } + }, + "contentDisposition": "attachment; filename=\"oas30_date.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_oas30_openapi[TEST_IMPORT_PETS]": { + "recorded-date": "06-05-2025, 18:34:49", + "recorded-content": { + "import-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/png", + "image/jpg" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "Simple PetStore (Swagger)", + "rootResourceId": "", + "version": "1.0.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-export": { + "body": { + "openapi": "3.0.1", + "info": { + "title": "Simple PetStore (Swagger)", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://.execute-api..amazonaws.com/{basePath}", + "variables": { + "basePath": { + "default": "local" + } + } + } + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "200 response", + "content": {} + } + } + } + }, + "/pets/{petId}": { + "get": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": {} + } + } + } + } + }, + "components": {} + }, + "contentDisposition": "attachment; filename=\"oas30_1.0.0.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-export-with-extensions": { + "body": { + "openapi": "3.0.1", + "info": { + "title": "Simple PetStore (Swagger)", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://.execute-api..amazonaws.com/{basePath}", + "variables": { + "basePath": { + "default": "local" + } + } + } + ], + "paths": { + "/pets": { + "get": { + "responses": { + "200": { + "description": "200 response", + "content": {} + } + }, + "x-amazon-apigateway-integration": { + "httpMethod": "GET", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "responses": { + "default": { + "statusCode": "200" + } + }, + "passthroughBehavior": "when_no_match", + "type": "http" + } + } + }, + "/pets/{petId}": { + "get": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": {} + } + }, + "x-amazon-apigateway-integration": { + "httpMethod": "GET", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}", + "responses": { + "default": { + "statusCode": "200" + } + }, + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "passthroughBehavior": "when_no_match", + "type": "http" + } + } + } + }, + "components": {}, + "x-amazon-apigateway-binary-media-types": [ + "image/png", + "image/jpg" + ] + }, + "contentDisposition": "attachment; filename=\"oas30_1.0.0.json\"", + "contentType": "application/octet-stream", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::TestApigatewayApiKeysCrud::test_get_api_keys": { + "recorded-date": "10-10-2024, 18:53:36", + "recorded-content": { + "get-api-keys": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-api-key": { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [], + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-api-keys-after-create-1": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-api-keys-wrong-name-query": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-api-keys-prefix-name-query": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-api-keys-prefix-name-query-cased": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-api-keys-postfix-name-query": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-api-keys-infix-name-query": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-api-key-2": { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [], + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-api-keys-after-create-2": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + }, + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-api-keys-name-query": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-api-keys-prefix-name-query-2": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + }, + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-apis-keys-pagination": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "position": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-apis-keys-pagination-2": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::TestApigatewayApiKeysCrud::test_get_usage_plan_api_keys": { + "recorded-date": "10-10-2024, 18:54:42", + "recorded-content": { + "get-api-keys": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-api-key": { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [], + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-api-key-2": { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [], + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-api-keys-after-create-1": { + "items": [ + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + }, + { + "createdDate": "datetime", + "enabled": false, + "id": "", + "lastUpdatedDate": "datetime", + "name": "", + "stageKeys": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-usage-plan": { + "apiStages": [], + "id": "", + "name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-up-keys-before-create": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-up-key": { + "id": "", + "name": "", + "type": "API_KEY", + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create-up-key-2": { + "id": "", + "name": "", + "type": "API_KEY", + "value": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-up-keys": { + "items": [ + { + "id": "", + "name": "", + "type": "API_KEY", + "value": "" + }, + { + "id": "", + "name": "", + "type": "API_KEY", + "value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-up-keys-name-query": { + "items": [ + { + "id": "", + "name": "", + "type": "API_KEY", + "value": "" + }, + { + "id": "", + "name": "", + "type": "API_KEY", + "value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-up-keys-name-query-cased": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-up-keys-name-query-key-name": { + "items": [ + { + "id": "", + "name": "", + "type": "API_KEY", + "value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-up-keys-bad-query": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-up-keys-after-delete": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-up-keys-bad-usage-plan": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_extended.validation.json b/tests/aws/services/apigateway/test_apigateway_extended.validation.json new file mode 100644 index 0000000000000..1486731f72d07 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_extended.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/apigateway/test_apigateway_extended.py::TestApigatewayApiKeysCrud::test_get_api_keys": { + "last_validated_date": "2024-10-10T18:53:36+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::TestApigatewayApiKeysCrud::test_get_usage_plan_api_keys": { + "last_validated_date": "2024-10-10T18:54:41+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_oas30_openapi[TEST_IMPORT_PETSTORE_SWAGGER]": { + "last_validated_date": "2025-05-06T18:34:11+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_oas30_openapi[TEST_IMPORT_PETS]": { + "last_validated_date": "2025-05-06T18:34:17+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_swagger_openapi[TEST_IMPORT_PETSTORE_SWAGGER]": { + "last_validated_date": "2025-05-06T18:20:25+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_extended.py::test_export_swagger_openapi[TEST_IMPORT_PETS]": { + "last_validated_date": "2025-05-06T18:20:36+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_http.py b/tests/aws/services/apigateway/test_apigateway_http.py new file mode 100644 index 0000000000000..5d81a181e82bd --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_http.py @@ -0,0 +1,316 @@ +import json +from http import HTTPMethod + +import pytest +import requests + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url +from tests.aws.services.apigateway.conftest import is_next_gen_api + + +@pytest.fixture +def add_http_integration_transformers(snapshot): + key_value_transform = [ + "date", + "domain", + "host", + "origin", + "rest_api_id", + "x-amz-apigw-id", + "x-amzn-tls-cipher-suite", + "x-amzn-tls-version", + "x-amzn-requestid", + "x-amzn-trace-id", + "x-forwarded-for", + "x-forwarded-port", + "x-forwarded-proto", + ] + for key in key_value_transform: + snapshot.add_transformer(snapshot.transform.key_value(key)) + + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$.*.headers.content-length", + reference_replacement=True, + value_replacement="content_length", + ), + priority=2, + ) + # remove the reference replacement, as sometimes we can have a difference with `date` + snapshot.add_transformer( + snapshot.transform.key_value( + "x-amzn-remapped-date", + value_replacement="", + reference_replacement=False, + ), + priority=-1, + ) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: shared between HTTP & HTTP_PROXY + "$..content.headers.x-forwarded-for", + "$..content.origin", + "$..headers.server", + # TODO: for HTTP integration only: requests (urllib3) automatically adds `Accept-Encoding` when sending the + # request, seems like we cannot remove it + "$..headers.accept-encoding", + # TODO: for HTTP integration, Lambda URL do not add the Self= to its incoming headers + "$..headers.x-amzn-trace-id", + # TODO: only missing for HTTP_PROXY, Must be coming from the lambda url + "$..headers.x-amzn-remapped-x-amzn-requestid", + # TODO AWS doesn't seems to add Server to lambda invocation for lambda url + "$..headers.x-amzn-remapped-server", + ] +) +@markers.snapshot.skip_snapshot_verify( + condition=lambda: not is_next_gen_api(), + paths=[ + "$..content.headers.x-amzn-trace-id", + "$..headers.x-amz-apigw-id", + "$..headers.x-amzn-requestid", + "$..content.headers.user-agent", # TODO: We have to properly set that header on non proxied requests. + "$..content.headers.accept", # legacy does not properly manage accept header + # TODO: x-forwarded-for header is actually set when the request is sent to `requests.request`. + # Custom servers receive the header, but lambda execution code receives an empty string. + "$..content.headers.x-localstack-edge", + # TODO the remapped headers are currently not added to apigateway response + "$..headers.x-amzn-remapped-connection", + "$..headers.x-amzn-remapped-content-length", + "$..headers.x-amzn-remapped-date", + "$..headers.x-amzn-remapped-x-amzn-requestid", + ], +) +@pytest.mark.parametrize("integration_type", ["HTTP", "HTTP_PROXY"]) +def test_http_integration_with_lambda( + integration_type, + create_echo_http_server, + create_rest_api_with_integration, + snapshot, + add_http_integration_transformers, +): + echo_server_url = create_echo_http_server(trim_x_headers=False) + # create api gateway + stage_name = "test" + api_id = create_rest_api_with_integration( + integration_uri=echo_server_url, integration_type=integration_type, stage=stage_name + ) + snapshot.match("api_id", {"rest_api_id": api_id}) + invocation_url = api_invoke_url( + api_id=api_id, + stage=stage_name, + path="/test", + ) + + def invoke_api(url): + response = requests.post( + url, + data=json.dumps({"message": "hello world"}), + headers={ + "Content-Type": "application/json", + "accept": "application/xml", + "user-Agent": "test/integration", + }, + verify=False, + ) + assert response.status_code == 200 + return { + "content": response.json(), + "headers": {k.lower(): v for k, v in dict(response.headers).items()}, + "status_code": response.status_code, + } + + # retry is necessary against AWS, probably IAM permission delay + invoke_response = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("http-invocation-lambda-url", invoke_response) + + +@markers.aws.validated +@pytest.mark.parametrize("integration_type", ["HTTP", "HTTP_PROXY"]) +def test_http_integration_invoke_status_code_passthrough( + aws_client, + create_status_code_echo_server, + create_rest_api_with_integration, + snapshot, + integration_type, +): + # Create echo serve + echo_server_url = create_status_code_echo_server() + # Create apigw + stage_name = "test" + apigw_id = create_rest_api_with_integration( + integration_uri=f"{echo_server_url}{{map}}", + integration_type=integration_type, + path_part="{map+}", + req_parameters={ + "integration.request.path.map": "method.request.path.map", + }, + stage=stage_name, + ) + + def invoke_api(url: str, method: HTTPMethod = HTTPMethod.POST): + response = requests.request(url=url, method=method) + status_code = response.status_code + assert status_code != 403 + return {"body": response.json(), "status_code": status_code} + + invocation_url = api_invoke_url( + api_id=apigw_id, + stage=stage_name, + path="/status", + ) + + # Invoke with matching response code + invoke_response = retry(invoke_api, sleep=2, retries=10, url=f"{invocation_url}/200") + snapshot.match("matching-response", invoke_response) + + # invoke non matching response code + invoke_response = retry(invoke_api, sleep=2, retries=10, url=f"{invocation_url}/400") + snapshot.match("non-matching-response", invoke_response) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: shared between HTTP & HTTP_PROXY + "$..origin", + # TODO: for HTTP integration only: requests (urllib3) automatically adds `Accept-Encoding` when sending the + # request, seems like we cannot remove it + "$..accept-encoding", + ] +) +@markers.snapshot.skip_snapshot_verify( + condition=lambda: not is_next_gen_api(), + paths=[ + "$..headers.user-agent", # TODO: We have to properly set that header on non proxied requests. + "$..headers.x-localstack-edge", + ], +) +@pytest.mark.parametrize("integration_type", ["HTTP", "HTTP_PROXY"]) +def test_http_integration_method( + integration_type, + create_echo_http_server, + create_rest_api_with_integration, + snapshot, + add_http_integration_transformers, +): + echo_server_url = create_echo_http_server(trim_x_headers=True) + # create api gateway + stage_name = "test" + api_id = create_rest_api_with_integration( + integration_uri=echo_server_url, + integration_type=integration_type, + stage=stage_name, + resource_method="ANY", + integration_method="POST", + ) + snapshot.match("api_id", {"rest_api_id": api_id}) + invocation_url = api_invoke_url( + api_id=api_id, + stage=stage_name, + path="/test", + ) + + def invoke_api(url: str, method: str) -> dict: + response = requests.request( + method=method, + url=url, + data=json.dumps({"message": "hello world"}), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "user-Agent": "test/integration", + }, + verify=False, + ) + assert response.status_code == 200 + return response.json() + + # retry is necessary against AWS, probably IAM permission delay + for http_method in ("POST", "PUT", "GET"): + invoke_response = retry(invoke_api, sleep=2, retries=10, url=invocation_url, method="POST") + snapshot.match(f"http-invocation-lambda-url-{http_method.lower()}", invoke_response) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..content.origin", + "$..headers.server", + "$..headers.x-amzn-remapped-x-amzn-requestid", + # TODO AWS doesn't seems to add Server to lambda invocation for lambda url + "$..headers.x-amzn-remapped-server", + ] +) +@pytest.mark.skipif( + condition=not is_next_gen_api() and not is_aws_cloud(), + reason="Wrong behavior in legacy implementation", +) +def test_http_proxy_integration_request_data_mappings( + create_echo_http_server, + create_rest_api_with_integration, + snapshot, + add_http_integration_transformers, +): + echo_server_url = create_echo_http_server(trim_x_headers=True) + # create api gateway + stage_name = "test" + req_parameters = { + "integration.request.header.headerVar": "method.request.header.foobar", + "integration.request.path.qsVar": "method.request.querystring.testVar", + "integration.request.path.pathVar": "method.request.path.pathVariable", + "integration.request.querystring.queryString": "method.request.querystring.testQueryString", + "integration.request.querystring.testQs": "method.request.querystring.testQueryString", + "integration.request.querystring.testEmptyQs": "method.request.header.emptyheader", + } + + # Note: you cannot use path parameters directly, if you set `testValue={pathVariable}` it will fail + integration_uri = f"{echo_server_url}?testVar={{pathVar}}&testQs={{qsVar}}" + + api_id = create_rest_api_with_integration( + integration_uri=integration_uri, + integration_type="HTTP_PROXY", + stage=stage_name, + path_part="{pathVariable}", + req_parameters=req_parameters, + ) + + snapshot.match("api_id", {"rest_api_id": api_id}) + invocation_url = api_invoke_url( + api_id=api_id, + stage=stage_name, + path="/foobar", + ) + + def invoke_api(url): + response = requests.post( + url, + data=json.dumps({"message": "hello world"}), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "foobar": "mapped-value", + "user-Agent": "test/integration", + "headerVar": "request-value", + }, + params={ + "testQueryString": "foo", + "testVar": "bar", + }, + verify=False, + ) + assert response.status_code == 200 + return { + "content": response.json(), + "headers": {k.lower(): v for k, v in dict(response.headers).items()}, + "status_code": response.status_code, + } + + # retry is necessary against AWS, probably IAM permission delay + invoke_response = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("http-proxy-invocation", invoke_response) diff --git a/tests/aws/services/apigateway/test_apigateway_http.snapshot.json b/tests/aws/services/apigateway/test_apigateway_http.snapshot.json new file mode 100644 index 0000000000000..7240f2176dda6 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_http.snapshot.json @@ -0,0 +1,303 @@ +{ + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_with_lambda[HTTP]": { + "recorded-date": "07-08-2024, 18:37:12", + "recorded-content": { + "api_id": { + "rest_api_id": "" + }, + "http-invocation-lambda-url": { + "content": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "content-length": "26", + "content-type": "application/json", + "host": "", + "user-agent": "AmazonAPIGateway_", + "x-amzn-apigateway-api-id": "", + "x-amzn-tls-cipher-suite": "", + "x-amzn-tls-version": "", + "x-amzn-trace-id": "", + "x-forwarded-for": "", + "x-forwarded-port": "", + "x-forwarded-proto": "" + }, + "method": "POST", + "origin": "", + "path": "/" + }, + "headers": { + "connection": "keep-alive", + "content-length": "", + "content-type": "application/json", + "date": "", + "x-amz-apigw-id": "", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_with_lambda[HTTP_PROXY]": { + "recorded-date": "07-08-2024, 18:37:25", + "recorded-content": { + "api_id": { + "rest_api_id": "" + }, + "http-invocation-lambda-url": { + "content": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/xml", + "accept-encoding": "gzip, deflate", + "content-length": "26", + "content-type": "application/json", + "host": "", + "user-agent": "test/integration", + "x-amzn-apigateway-api-id": "", + "x-amzn-tls-cipher-suite": "", + "x-amzn-tls-version": "", + "x-amzn-trace-id": "", + "x-forwarded-for": "", + "x-forwarded-port": "", + "x-forwarded-proto": "" + }, + "method": "POST", + "origin": "", + "path": "/" + }, + "headers": { + "connection": "keep-alive", + "content-length": "", + "content-type": "application/json", + "date": "", + "x-amz-apigw-id": "", + "x-amzn-remapped-connection": "keep-alive", + "x-amzn-remapped-content-length": "", + "x-amzn-remapped-date": "", + "x-amzn-remapped-x-amzn-requestid": "", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_invoke_status_code_passthrough[HTTP]": { + "recorded-date": "01-04-2024, 21:02:52", + "recorded-content": { + "matching-response": { + "body": { + "message": "", + "status_code": 200 + }, + "status_code": 200 + }, + "non-matching-response": { + "body": { + "message": "", + "status_code": 400 + }, + "status_code": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_invoke_status_code_passthrough[HTTP_PROXY]": { + "recorded-date": "01-04-2024, 20:44:46", + "recorded-content": { + "matching-response": { + "body": { + "message": "", + "status_code": 200 + }, + "status_code": 200 + }, + "non-matching-response": { + "body": { + "message": "", + "status_code": 400 + }, + "status_code": 400 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_method[HTTP]": { + "recorded-date": "05-07-2024, 17:00:39", + "recorded-content": { + "api_id": { + "rest_api_id": "" + }, + "http-invocation-lambda-url-post": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "content-length": "", + "content-type": "application/json", + "host": "", + "user-agent": "AmazonAPIGateway_" + }, + "method": "POST", + "origin": "", + "path": "/" + }, + "http-invocation-lambda-url-put": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "content-length": "", + "content-type": "application/json", + "host": "", + "user-agent": "AmazonAPIGateway_" + }, + "method": "POST", + "origin": "", + "path": "/" + }, + "http-invocation-lambda-url-get": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "content-length": "", + "content-type": "application/json", + "host": "", + "user-agent": "AmazonAPIGateway_" + }, + "method": "POST", + "origin": "", + "path": "/" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_method[HTTP_PROXY]": { + "recorded-date": "05-07-2024, 17:00:46", + "recorded-content": { + "api_id": { + "rest_api_id": "" + }, + "http-invocation-lambda-url-post": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "accept-encoding": "gzip, deflate", + "content-length": "", + "content-type": "application/json", + "host": "", + "user-agent": "test/integration" + }, + "method": "POST", + "origin": "", + "path": "/" + }, + "http-invocation-lambda-url-put": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "accept-encoding": "gzip, deflate", + "content-length": "", + "content-type": "application/json", + "host": "", + "user-agent": "test/integration" + }, + "method": "POST", + "origin": "", + "path": "/" + }, + "http-invocation-lambda-url-get": { + "args": {}, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "accept-encoding": "gzip, deflate", + "content-length": "", + "content-type": "application/json", + "host": "", + "user-agent": "test/integration" + }, + "method": "POST", + "origin": "", + "path": "/" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_proxy_integration_request_data_mappings": { + "recorded-date": "18-07-2024, 13:59:19", + "recorded-content": { + "api_id": { + "rest_api_id": "" + }, + "http-proxy-invocation": { + "content": { + "args": { + "queryString": "foo", + "testQs": "bar,foo", + "testQueryString": "foo", + "testVar": "foobar,bar" + }, + "data": { + "message": "hello world" + }, + "domain": "", + "headers": { + "accept": "application/json", + "accept-encoding": "gzip, deflate", + "content-length": "26", + "content-type": "application/json", + "foobar": "mapped-value", + "headervar": "mapped-value", + "host": "", + "user-agent": "test/integration" + }, + "method": "POST", + "origin": "", + "path": "/" + }, + "headers": { + "connection": "keep-alive", + "content-length": "", + "content-type": "application/json", + "date": "", + "x-amz-apigw-id": "", + "x-amzn-remapped-connection": "keep-alive", + "x-amzn-remapped-content-length": "", + "x-amzn-remapped-date": "", + "x-amzn-remapped-x-amzn-requestid": "", + "x-amzn-requestid": "", + "x-amzn-trace-id": "" + }, + "status_code": 200 + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_http.validation.json b/tests/aws/services/apigateway/test_apigateway_http.validation.json new file mode 100644 index 0000000000000..0de541ec8dfac --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_http.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_invoke_status_code_passthrough[HTTP]": { + "last_validated_date": "2024-04-01T21:45:48+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_invoke_status_code_passthrough[HTTP_PROXY]": { + "last_validated_date": "2024-04-01T21:46:23+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_method[HTTP]": { + "last_validated_date": "2024-07-05T17:00:39+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_method[HTTP_PROXY]": { + "last_validated_date": "2024-07-05T17:00:46+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_with_lambda[HTTP]": { + "last_validated_date": "2024-08-07T18:37:12+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_integration_with_lambda[HTTP_PROXY]": { + "last_validated_date": "2024-08-07T18:37:25+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_http.py::test_http_proxy_integration_request_data_mappings": { + "last_validated_date": "2024-07-18T13:59:19+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_import.py b/tests/aws/services/apigateway/test_apigateway_import.py new file mode 100644 index 0000000000000..6ed23dcc3c1ba --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_import.py @@ -0,0 +1,967 @@ +import logging +import os +import re +import time +from operator import itemgetter + +import pytest +import requests +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack import config +from localstack.aws.api.apigateway import Resources +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry, wait_until +from localstack.utils.testutil import create_lambda_archive +from localstack.utils.urls import localstack_host +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url +from tests.aws.services.apigateway.conftest import is_next_gen_api + +LOG = logging.getLogger(__name__) + +# parent directory of this file +PARENT_DIR = os.path.dirname(os.path.abspath(__file__)) +OPENAPI_SPEC_PULUMI_JSON = os.path.join(PARENT_DIR, "../../files/openapi.spec.pulumi.json") +OPENAPI_SPEC_TF_JSON = os.path.join(PARENT_DIR, "../../files/openapi.spec.tf.json") +SWAGGER_MOCK_CORS_JSON = os.path.join(PARENT_DIR, "../../files/swagger-mock-cors.json") +PETSTORE_SWAGGER_JSON = os.path.join(PARENT_DIR, "../../files/petstore-authorizer.swagger.json") +TEST_SWAGGER_FILE_JSON = os.path.join(PARENT_DIR, "../../files/swagger.json") +TEST_OPENAPI_COGNITO_AUTH = os.path.join(PARENT_DIR, "../../files/openapi.cognito-auth.json") +TEST_OAS30_BASE_PATH_SERVER_VAR_FILE_YAML = os.path.join( + PARENT_DIR, "../../files/openapi-basepath-server-variable.yaml" +) +TEST_OAS30_BASE_PATH_SERVER_URL_FILE_YAML = os.path.join( + PARENT_DIR, "../../files/openapi-basepath-url.yaml" +) +TEST_IMPORT_REST_API_FILE = os.path.join(PARENT_DIR, "../../files/pets.json") +TEST_IMPORT_OPEN_API_GLOBAL_API_KEY_AUTHORIZER = os.path.join( + PARENT_DIR, "../../files/openapi.spec.global-auth.json" +) +OAS_30_CIRCULAR_REF = os.path.join(PARENT_DIR, "../../files/openapi.spec.circular-ref.json") +OAS_30_CIRCULAR_REF_WITH_REQUEST_BODY = os.path.join( + PARENT_DIR, "../../files/openapi.spec.circular-ref-with-request-body.json" +) +OAS_30_STAGE_VARIABLES = os.path.join(PARENT_DIR, "../../files/openapi.spec.stage-variables.json") +OAS30_HTTP_METHOD_INT = os.path.join(PARENT_DIR, "../../files/openapi-http-method-integration.json") +OAS30_HTTP_STATUS_INT = os.path.join(PARENT_DIR, "../../files/openapi-method-int.spec.yaml") +TEST_LAMBDA_PYTHON_ECHO = os.path.join(PARENT_DIR, "../lambda_/functions/lambda_echo.py") + + +@pytest.fixture +def apigw_snapshot_imported_resources(snapshot, aws_client): + def _get_resources_and_snapshot( + rest_api_id: str, resources: Resources, snapshot_prefix: str = "" + ): + """ + + :param rest_api_id: The RestAPI ID + :param resources: the response from GetResources + :param snapshot_prefix: optional snapshot prefix for every snapshot + :return: + """ + for resource in resources["items"]: + for http_method in resource.get("resourceMethods", []): + snapshot_http_key = f"{resource['path'][1:] if resource['path'] != '/' else 'root'}-{http_method.lower()}" + resource_id = resource["id"] + try: + response = aws_client.apigateway.get_method( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod=http_method, + ) + snapshot.match(f"{snapshot_prefix}method-{snapshot_http_key}", response) + except ClientError as e: + snapshot.match(f"{snapshot_prefix}method-{snapshot_http_key}", e.response) + + try: + response = aws_client.apigateway.get_method_response( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + ) + snapshot.match( + f"{snapshot_prefix}method-response-{snapshot_http_key}", response + ) + except ClientError as e: + snapshot.match( + f"{snapshot_prefix}method-response-{snapshot_http_key}", e.response + ) + + try: + response = aws_client.apigateway.get_integration( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod=http_method, + ) + snapshot.match(f"{snapshot_prefix}integration-{snapshot_http_key}", response) + except ClientError as e: + snapshot.match(f"{snapshot_prefix}integration-{snapshot_http_key}", e.response) + + try: + response = aws_client.apigateway.get_integration_response( + restApiId=rest_api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + ) + snapshot.match( + f"{snapshot_prefix}integration-response-{snapshot_http_key}", response + ) + except ClientError as e: + snapshot.match( + f"{snapshot_prefix}integration-response-{snapshot_http_key}", e.response + ) + + return _get_resources_and_snapshot + + +@pytest.fixture(autouse=True) +def apigw_snapshot_transformer(request, snapshot): + if is_aws_cloud(): + model_base_url = "https://apigateway.amazonaws.com" + else: + host_definition = localstack_host() + model_base_url = f"{config.get_protocol()}://apigateway.{host_definition.host_and_port()}" + + snapshot.add_transformer(snapshot.transform.regex(model_base_url, "")) + + if "no_apigw_snap_transformers" in request.keywords: + return + + snapshot.add_transformer(snapshot.transform.apigateway_api()) + + +def delete_rest_api_retry(client, rest_api_id: str): + try: + if is_aws_cloud(): + # This is ugly but API GW returns 429 very quickly, and we want to be sure to clean up properly + cleaned = False + while not cleaned: + try: + client.delete_rest_api(restApiId=rest_api_id) + cleaned = True + except ClientError as e: + error_message = str(e) + if "TooManyRequestsException" in error_message: + time.sleep(10) + elif "NotFoundException" in error_message: + break + else: + raise + else: + client.delete_rest_api(restApiId=rest_api_id) + + except Exception as e: + LOG.debug("Error cleaning up rest API: %s, %s", rest_api_id, e) + + +@pytest.fixture +def apigw_create_rest_api(aws_client): + rest_apis = [] + + def _factory(*args, **kwargs): + if "name" not in kwargs: + kwargs["name"] = f"test-api-{short_uid()}" + response = aws_client.apigateway.create_rest_api(*args, **kwargs) + rest_apis.append(response["id"]) + return response + + yield _factory + + for rest_api_id in rest_apis: + delete_rest_api_retry(aws_client.apigateway, rest_api_id) + + +@pytest.fixture(scope="class") +def apigateway_placeholder_authorizer_lambda_invocation_arn( + aws_client, region_name, lambda_su_role +): + """ + Using this fixture to create only one lambda in AWS to be used for every test, as we need a real lambda ARN + to be able to import an API. We need a class scoped fixture here, so the code is pulled from + `create_lambda_function_aws` + + LocalStack does not validate the ARN here, so we can simply return a placeholder + """ + if not is_aws_cloud(): + yield "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:function-name/invocations" + + else: + lambda_arns = [] + + def _create_function(): + zip_file = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + + # create_response is the original create call response, even though the fixture waits until it's not pending + create_response = aws_client.lambda_.create_function( + FunctionName=f"test-authorizer-import-{short_uid()}", + Runtime=Runtime.python3_12, + Handler="handler.handler", + Role=lambda_su_role, + Code={"ZipFile": zip_file}, + MemorySize=256, + Timeout=5, + ) + lambda_arns.append(create_response["FunctionArn"]) + + def _is_not_pending(): + try: + result = ( + aws_client.lambda_.get_function( + FunctionName=create_response["FunctionName"] + )["Configuration"]["State"] + != "Pending" + ) + return result + except Exception as e: + LOG.error(e) + raise + + wait_until(_is_not_pending) + return create_response + + # @AWS, takes about 10s until the role/policy is "active", until then it will fail + # localstack should normally not require the retries and will just continue here + response = retry(_create_function, retries=3, sleep=4) + + lambda_invocation_arn = arns.apigateway_invocations_arn( + response["FunctionArn"], region_name + ) + + yield lambda_invocation_arn + + for arn in lambda_arns: + try: + aws_client.lambda_.delete_function(FunctionName=arn) + except Exception: + LOG.debug("Unable to delete function %s in cleanup", arn) + + +@pytest.fixture +def apigw_deploy_rest_api(aws_client): + # AWS returns 429 sometimes (TooManyRequests) + def _deploy(rest_api_id, stage_name): + response = retry( + lambda: aws_client.apigateway.create_deployment( + restApiId=rest_api_id, + stageName=stage_name, + ), + sleep=10, + ) + return response + + return _deploy + + +class TestApiGatewayImportRestApi: + @markers.aws.validated + def test_import_rest_api(self, import_apigw, snapshot): + spec_file = load_file(OPENAPI_SPEC_PULUMI_JSON) + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + + snapshot.match("import_rest_api", response) + + @markers.aws.validated + @pytest.mark.no_apigw_snap_transformers # not using the API Gateway default transformers + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", # TODO: this is really weird, after importing, AWS returns them empty? + "$.resources.items..resourceMethods.OPTIONS", + "$.resources.items..resourceMethods.POST", + "$.get-authorizers.items[1].authorizerResultTtlInSeconds", + ] + ) + def test_import_swagger_api( + self, + region_name, + import_apigw, + snapshot, + aws_client, + apigateway_placeholder_authorizer_lambda_invocation_arn, + lambda_su_role, + apigw_snapshot_imported_resources, + ): + # manually add all transformers, as the default will mess up Model names and such + snapshot.add_transformers_list( + [ + snapshot.transform.jsonpath("$.import-swagger.id", value_replacement="rest-id"), + snapshot.transform.jsonpath( + "$.get-authorizers.items..id", value_replacement="authorizer-id" + ), + snapshot.transform.key_value("authorizerCredentials"), + snapshot.transform.key_value("authorizerUri"), + snapshot.transform.jsonpath( + "$.resources.items..id", value_replacement="resource-id" + ), + snapshot.transform.jsonpath("$.get-models.items..id", value_replacement="model-id"), + ] + ) + spec_file = load_file(PETSTORE_SWAGGER_JSON) + spec_file = spec_file.replace( + "${uri}", f"http://petstore.execute-api.{region_name}.amazonaws.com/petstore/pets" + ) + + spec_file = spec_file.replace( + "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:function-name/invocations", + apigateway_placeholder_authorizer_lambda_invocation_arn, + ).replace("arn:aws:iam::account-id:role", lambda_su_role) # we just need a placeholder role + + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + + snapshot.match("import-swagger", response) + + rest_api_id = response["id"] + + # assert that are no multiple authorizers + authorizers = aws_client.apigateway.get_authorizers(restApiId=rest_api_id) + authorizers["items"] = sorted(authorizers["items"], key=itemgetter("name")) + snapshot.match("get-authorizers", authorizers) + + models = aws_client.apigateway.get_models(restApiId=rest_api_id) + models["items"] = sorted(models["items"], key=itemgetter("name")) + + snapshot.match("get-models", models) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", # TODO: this is really weird, after importing, AWS returns them empty? + "$.resources.items..resourceMethods.OPTIONS", + "$..uri", # TODO: investigate snapshot pattern matching with account id? + ] + ) + @pytest.mark.parametrize( + "import_file", + [OPENAPI_SPEC_TF_JSON, SWAGGER_MOCK_CORS_JSON], + ids=lambda x: x.rsplit("/", maxsplit=1)[-1], + ) + def test_import_and_validate_rest_api( + self, + import_apigw, + snapshot, + aws_client, + import_file, + apigw_snapshot_imported_resources, + ): + # OPENAPI_SPEC_TF_JSON was used from a Terraform example with a JSON file directly used by Terraform + # SWAGGER_MOCK_CORS_JSON is a synthesized Swagger file created by AWS SAM in an AWS sample + spec_file = load_file(import_file) + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + + snapshot.match("import_tf_rest_api", response) + rest_api_id = response["id"] + + models = aws_client.apigateway.get_models(restApiId=rest_api_id) + models["items"] = sorted(models["items"], key=itemgetter("name")) + snapshot.match("get-models", models) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + if is_aws_cloud(): + # waiting before cleaning up to avoid TooManyRequests, as we create multiple REST APIs + time.sleep(15) + + @markers.aws.validated + @pytest.mark.parametrize("base_path_type", ["ignore", "prepend", "split"]) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-resources-swagger-json.items..resourceMethods.GET", # TODO: this is really weird, after importing, AWS returns them empty? + "$.get-resources-swagger-json.items..resourceMethods.OPTIONS", + "$.get-resources-no-base-path-swagger.items..resourceMethods.GET", + "$.get-resources-no-base-path-swagger.items..resourceMethods.OPTIONS", + # TODO: not returned by LS + "$..endpointConfiguration.ipAddressType", + ] + ) + def test_import_rest_apis_with_base_path_swagger( + self, + base_path_type, + apigw_create_rest_api, + import_apigw, + aws_client, + snapshot, + apigateway_placeholder_authorizer_lambda_invocation_arn, + lambda_su_role, + apigw_snapshot_imported_resources, + ): + snapshot.add_transformers_list([snapshot.transform.key_value("authorizerId")]) + + rest_api_name = f"restapi-{short_uid()}" + response = apigw_create_rest_api(name=rest_api_name) + rest_api_id = response["id"] + + spec_file = load_file(TEST_SWAGGER_FILE_JSON) + spec_file = spec_file.replace( + "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:myapi-authorizer-0-22ad13b/invocations", + apigateway_placeholder_authorizer_lambda_invocation_arn, + ).replace( + "arn:aws:iam::000000000000:role/myapi-authorizer-0-authorizer-role-3bd761a", + lambda_su_role, + ) # we just need a placeholder role + + api_params = {"basepath": base_path_type} + + if is_aws_cloud(): + # to avoid TooManyRequests, as we are creating and importing many RestAPI and AWS is very strict on + # API rate limiting + time.sleep(10) + + response = aws_client.apigateway.put_rest_api( + restApiId=rest_api_id, + body=spec_file, + mode="overwrite", + parameters=api_params, + ) + snapshot.match("put-rest-api-swagger-json", response) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("get-resources-swagger-json", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + if is_aws_cloud(): + # to avoid TooManyRequests + time.sleep(10) + + # This file does not have a `base_path` defined + spec_file = load_file(TEST_IMPORT_REST_API_FILE) + response, _ = import_apigw(body=spec_file, parameters=api_params) + rest_api_id_2 = response["id"] + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id_2) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("get-resources-no-base-path-swagger", response) + + apigw_snapshot_imported_resources(rest_api_id=rest_api_id_2, resources=response) + + if is_aws_cloud(): + # to avoid TooManyRequests for parametrized test + # then you realize LocalStack is needed! + time.sleep(20) + + @markers.aws.validated + @pytest.mark.parametrize("base_path_type", ["ignore", "prepend", "split"]) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-resources-oas30-srv-var.items..resourceMethods.GET", # TODO: this is really weird, after importing, AWS returns them empty? + "$.get-resources-oas30-srv-var.items..resourceMethods.OPTIONS", + "$.get-resources-oas30-srv-url.items..resourceMethods.GET", + "$.get-resources-oas30-srv-url.items..resourceMethods.OPTIONS", + "$..cacheNamespace", # TODO: investigate why it's different + "$.get-resources-oas30-srv-url.items..id", # TODO: even in overwrite, APIGW keeps the same ID if same path + "$.get-resources-oas30-srv-url.items..parentId", # TODO: even in overwrite, APIGW keeps the same ID if same path + "$.put-rest-api-oas30-srv-url..rootResourceId", # TODO: because APIGW keeps the same above, id counting is different + ] + ) + def test_import_rest_api_with_base_path_oas30( + self, + base_path_type, + apigw_create_rest_api, + aws_client, + snapshot, + apigw_snapshot_imported_resources, + apigw_deploy_rest_api, + ): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + # test for https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-import-api-basePath.html + # having either the basePath as in the server URL path or as a variable + rest_api_name = f"restapi-{short_uid()}" + response = apigw_create_rest_api(name=rest_api_name) + rest_api_id = response["id"] + + api_params = {"basepath": base_path_type} + + if is_aws_cloud(): + # to avoid TooManyRequests, as we are creating and importing many RestAPI and AWS is very strict on + # API rate limiting + time.sleep(10) + + spec_file = load_file(TEST_OAS30_BASE_PATH_SERVER_VAR_FILE_YAML) + + response = aws_client.apigateway.put_rest_api( + restApiId=rest_api_id, + body=spec_file, + mode="overwrite", + parameters=api_params, + ) + snapshot.match("put-rest-api-oas30-srv-var", response) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("get-resources-oas30-srv-var", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + + apigw_snapshot_imported_resources( + rest_api_id=rest_api_id, resources=response, snapshot_prefix="srv-var-" + ) + + stage_name = "dev" + + # the basePath for this OpenAPI file is "/base-var" + resource_path = "/test" if base_path_type != "prepend" else "/base-var/test" + + # AWS raises 429 sometimes + apigw_deploy_rest_api(rest_api_id, stage_name) + + def assert_request_ok(request_url: str) -> requests.Response: + _response = requests.get(url) + assert _response.ok + return _response + + url = api_invoke_url(rest_api_id, stage=stage_name, path=resource_path) + retry(assert_request_ok, retries=10, sleep=2, request_url=url) + + spec_file = load_file(TEST_OAS30_BASE_PATH_SERVER_URL_FILE_YAML) + + response = aws_client.apigateway.put_rest_api( + restApiId=rest_api_id, + body=spec_file, + mode="overwrite", + parameters=api_params, + ) + snapshot.match("put-rest-api-oas30-srv-url", response) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("get-resources-oas30-srv-url", response) + + apigw_snapshot_imported_resources( + rest_api_id=rest_api_id, resources=response, snapshot_prefix="srv-url-" + ) + + apigw_deploy_rest_api(rest_api_id=rest_api_id, stage_name=stage_name) + + # the basePath for this OpenAPI file is "/base-url/part/" + resource_path = "" + match base_path_type: + case "ignore": + resource_path = "/test" + case "prepend": + resource_path = "/base-url/part/test" + case "split": + # split removes the top most path part of the basePath + resource_path = "/part/test" + + url = api_invoke_url(rest_api_id, stage=stage_name, path=resource_path) + retry(assert_request_ok, retries=10, sleep=2, request_url=url) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", # AWS does not show them after import + "$.resources.items..resourceMethods.ANY", + ] + ) + def test_import_with_global_api_key_authorizer( + self, + import_apigw, + aws_client, + snapshot, + apigateway_placeholder_authorizer_lambda_invocation_arn, + apigw_snapshot_imported_resources, + ): + snapshot.add_transformer(snapshot.transform.key_value("authorizerUri")) + + spec_file = load_file(TEST_IMPORT_OPEN_API_GLOBAL_API_KEY_AUTHORIZER) + spec_file = spec_file.replace( + "${authorizer_lambda_invocation_arn}", + apigateway_placeholder_authorizer_lambda_invocation_arn, + ) + + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + + snapshot.match("import-swagger", response) + + rest_api_id = response["id"] + + authorizers = aws_client.apigateway.get_authorizers(restApiId=rest_api_id) + snapshot.match("get-authorizers", authorizers) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + @markers.aws.validated + @pytest.mark.no_apigw_snap_transformers # not using the API Gateway default transformers + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.POST", # TODO: this is really weird, after importing, AWS returns them empty? + ] + ) + def test_import_with_circular_models( + self, import_apigw, apigw_snapshot_imported_resources, aws_client, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.jsonpath("$.import-api.id", value_replacement="rest-id"), + snapshot.transform.jsonpath( + "$.resources.items..id", value_replacement="resource-id" + ), + snapshot.transform.jsonpath("$.get-models.items..id", value_replacement="model-id"), + SortingTransformer("required"), + ] + ) + spec_file = load_file(OAS_30_CIRCULAR_REF) + + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + + snapshot.match("import-api", response) + rest_api_id = response["id"] + + models = aws_client.apigateway.get_models(restApiId=rest_api_id) + models["items"] = sorted(models["items"], key=itemgetter("name")) + + snapshot.match("get-models", models) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + @pytest.mark.no_apigw_snap_transformers # not using the API Gateway default transformers + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.POST", + # TODO: this is really weird, after importing, AWS returns them empty? + ] + ) + @markers.aws.validated + def test_import_with_circular_models_and_request_validation( + self, import_apigw, apigw_snapshot_imported_resources, aws_client, snapshot + ): + # manually add all transformers, as the default will mess up Model names and such + snapshot.add_transformers_list( + [ + snapshot.transform.jsonpath("$.import-api.id", value_replacement="rest-id"), + snapshot.transform.jsonpath( + "$.resources.items..id", value_replacement="resource-id" + ), + snapshot.transform.jsonpath("$.get-models.items..id", value_replacement="model-id"), + snapshot.transform.jsonpath( + "$.request-validators.items..id", value_replacement="request-validator-id" + ), + SortingTransformer("required"), + ] + ) + spec_file = load_file(OAS_30_CIRCULAR_REF_WITH_REQUEST_BODY) + + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + + snapshot.match("import-api", response) + rest_api_id = response["id"] + + models = aws_client.apigateway.get_models(restApiId=rest_api_id) + models["items"] = sorted(models["items"], key=itemgetter("name")) + + snapshot.match("get-models", models) + + response = aws_client.apigateway.get_request_validators(restApiId=rest_api_id) + snapshot.match("request-validators", response) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=rest_api_id, stageName=stage_name) + + url = api_invoke_url(api_id=rest_api_id, stage=stage_name, path="/person") + + request_data = { + "name": "Person1", + "b": 2, + "house": { + "randomProperty": "this is random", + "contains": [{"name": "Person2", "b": 3}], + }, + } + if is_aws_cloud(): + time.sleep(5) + + request = requests.post(url, json=request_data) + assert request.ok + # we cannot make the body passthrough, because MOCK integrations don't allow to pass the body from the + # request to the response: https://stackoverflow.com/a/47945574/6998584 + # the MOCK integration requestTemplate returns {"statusCode": 200}, but AWS does not pass it to $input.json('$') + # TODO: get parity with the MOCK integration + + wrong_request = {"random": "blabla"} + + request = requests.post(url, json=wrong_request) + assert request.status_code == 400 + assert request.json().get("message") == "Invalid request body" + + wrong_request_schema = { + "name": "Person1", + "b": 2, + "house": { + "randomProperty": "this is random, but I follow House schema except for contains", + "contains": [{"randomObject": "I am not following Person schema"}], + }, + } + request = requests.post(url, json=wrong_request_schema) + assert request.status_code == 400 + assert request.json().get("message") == "Invalid request body" + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..origin"]) + @markers.snapshot.skip_snapshot_verify( + condition=lambda: not is_next_gen_api(), paths=["$..method"] + ) + def test_import_with_stage_variables( + self, import_apigw, aws_client, create_echo_http_server, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("domain"), + snapshot.transform.key_value("origin"), + ] + ) + spec_file = load_file(OAS_30_STAGE_VARIABLES) + if not is_aws_cloud(): + # to make sure we return the endpoint without https to avoid cert issues + spec_file = spec_file.replace("https://", "http://") + + import_resp, root_id = import_apigw(body=spec_file, failOnWarnings=True) + rest_api_id = import_resp["id"] + echo_server_url = create_echo_http_server(trim_x_headers=True) + endpoint = re.sub(r"https?://", "", echo_server_url) + + response = aws_client.apigateway.create_deployment(restApiId=rest_api_id) + deployment_id = response["id"] + # workaround to remove the fixture scheme prefix. AWS won't allow stage variables + # on the OpenAPI uri without the scheme. So we let the scheme on the spec, "https://{stageVariables.url}", + # and remove it from the fixture + aws_client.apigateway.create_stage( + restApiId=rest_api_id, + stageName="v1", + variables={ + "TestHost": endpoint, + "testPath": "test-path", + "querystring": "qs_key=qs_value", + }, + deploymentId=deployment_id, + ) + + url_success = api_invoke_url(api_id=rest_api_id, stage="v1", path="/path1") + + def call_api(): + res = requests.get(url_success) + assert res.ok + return res.json() + + resp = retry(call_api, retries=5, sleep=2) + # we remove the headers from the response, not really needed for this test + resp.pop("headers", None) + snapshot.match("get-resp-from-http", resp) + + if is_next_gen_api() or is_aws_cloud(): + # assert that we properly raise an integration failure error if the endpoint is bad + aws_client.apigateway.create_stage( + restApiId=rest_api_id, + stageName="v2", + deploymentId=deployment_id, + ) + + url_error = api_invoke_url(api_id=rest_api_id, stage="v2", path="/path1") + + def call_api_error(): + res = requests.get(url_error) + assert res.status_code == 500 + return res.json() + + resp = retry(call_api_error, retries=5, sleep=2) + # we remove the headers from the response, not really needed for this test + resp.pop("headers", None) + snapshot.match("get-error-resp-from-http", resp) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", + "$.resources.items..resourceMethods.OPTIONS", + ] + ) + def test_import_with_http_method_integration( + self, + import_apigw, + aws_client, + apigw_snapshot_imported_resources, + apigateway_placeholder_authorizer_lambda_invocation_arn, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.key_value("uri")) + spec_file = load_file(OAS30_HTTP_METHOD_INT) + spec_file = spec_file.replace( + "${lambda_invocation_arn}", apigateway_placeholder_authorizer_lambda_invocation_arn + ) + import_resp, root_id = import_apigw(body=spec_file, failOnWarnings=True) + rest_api_id = import_resp["id"] + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + @pytest.mark.no_apigw_snap_transformers + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", # AWS does not show them after import + ] + ) + @markers.aws.validated + def test_import_with_cognito_auth_identity_source( + self, + region_name, + account_id, + import_apigw, + snapshot, + aws_client, + apigw_snapshot_imported_resources, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.jsonpath("$.import-swagger.id", value_replacement="rest-id"), + snapshot.transform.jsonpath( + "$.resources.items..id", value_replacement="resource-id" + ), + snapshot.transform.jsonpath( + "$.get-authorizers..id", value_replacement="authorizer-id" + ), + ] + ) + snapshot.add_transformer( + snapshot.transform.regex( + regex="petstore.execute-api.us-west-1", + replacement="", + ), + priority=-10, + ) + spec_file = load_file(TEST_OPENAPI_COGNITO_AUTH) + # the authorizer does not need to exist in AWS + spec_file = spec_file.replace( + "${cognito_pool_arn}", + f"arn:aws:cognito-idp:{region_name}:{account_id}:userpool/{region_name}_ABC123", + ) + response, root_id = import_apigw(body=spec_file, failOnWarnings=True) + snapshot.match("import-swagger", response) + + rest_api_id = response["id"] + + authorizers = aws_client.apigateway.get_authorizers(restApiId=rest_api_id) + snapshot.match("get-authorizers", sorted(authorizers["items"], key=lambda x: x["name"])) + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", + ] + ) + def test_import_with_integer_http_status_code( + self, + import_apigw, + aws_client, + apigw_snapshot_imported_resources, + snapshot, + ): + # the following YAML file contains integer status code for the Method and IntegrationResponse + # when importing the API, we need to properly cast them into string to avoid any typing issue when serializing + # responses. Most typed languages would fail when parsing. + snapshot.add_transformer(snapshot.transform.key_value("uri")) + spec_file = load_file(OAS30_HTTP_STATUS_INT) + import_resp, root_id = import_apigw(body=spec_file, failOnWarnings=True) + rest_api_id = import_resp["id"] + + response = aws_client.apigateway.get_resources(restApiId=rest_api_id) + response["items"] = sorted(response["items"], key=itemgetter("path")) + snapshot.match("resources", response) + + # this fixture will iterate over every resource and match its method, methodResponse, integration and + # integrationResponse + apigw_snapshot_imported_resources(rest_api_id=rest_api_id, resources=response) + + @markers.aws.validated + @pytest.mark.parametrize( + "put_mode", + ["merge", "overwrite"], + ) + @markers.snapshot.skip_snapshot_verify( + paths=[ + # not yet implemented + "$..endpointConfiguration.ipAddressType", + # issue because we create a new API internally, so we recreate names and resources + "$..name", + "$..rootResourceId", + # not returned even if empty in LocalStack + "$.get-rest-api.tags", + ] + ) + def test_put_rest_api_mode_binary_media_types( + self, aws_client, apigw_create_rest_api, snapshot, put_mode + ): + base_api = apigw_create_rest_api(binaryMediaTypes=["image/heif"]) + rest_api_id = base_api["id"] + snapshot.match("create-rest-api", base_api) + + get_api = aws_client.apigateway.get_rest_api(restApiId=rest_api_id) + snapshot.match("get-rest-api", get_api) + + spec_file = load_file(TEST_IMPORT_REST_API_FILE) + put_api = aws_client.apigateway.put_rest_api( + restApiId=rest_api_id, + body=spec_file, + mode=put_mode, + ) + snapshot.match("put-api", put_api) + + if is_aws_cloud(): + # waiting before cleaning up to avoid TooManyRequests, as we create multiple REST APIs + time.sleep(15) diff --git a/tests/aws/services/apigateway/test_apigateway_import.snapshot.json b/tests/aws/services/apigateway/test_apigateway_import.snapshot.json new file mode 100644 index 0000000000000..b1b91697846da --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_import.snapshot.json @@ -0,0 +1,5475 @@ +{ + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api": { + "recorded-date": "02-07-2025, 16:22:33", + "recorded-content": { + "import_rest_api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "*/*" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_swagger_api": { + "recorded-date": "03-07-2025, 15:44:09", + "recorded-content": { + "import-swagger": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "Your first API with Amazon API Gateway. This is a sample API that integrates via HTTP with our demo Pet Store endpoints", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "PetStore", + "rootResourceId": "", + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-authorizers": { + "items": [ + { + "authType": "oauth2", + "authorizerCredentials": "", + "authorizerResultTtlInSeconds": 60, + "authorizerUri": "", + "id": "", + "identitySource": "method.request.header.Authorization", + "identityValidationExpression": "^x-[a-z]+", + "name": "test-authorizer", + "type": "TOKEN" + }, + { + "authType": "oauth2", + "authorizerCredentials": "", + "authorizerUri": "", + "id": "", + "identitySource": "method.request.header.Authorization", + "identityValidationExpression": "^x-[a-z]+", + "name": "test-authorizer-not-used", + "type": "TOKEN" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "id": "", + "name": "Empty", + "schema": { + "type": "object" + } + }, + { + "contentType": "application/json", + "id": "", + "name": "NewPet", + "schema": { + "type": "object", + "properties": { + "type": { + "$ref": "/restapis//models/PetType" + }, + "price": { + "type": "number" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "NewPetResponse", + "schema": { + "type": "object", + "properties": { + "pet": { + "$ref": "/restapis//models/Pet" + }, + "message": { + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "Pet", + "schema": { + "type": "object", + "properties": { + "petid": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "price": { + "type": "number" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "PetType", + "schema": { + "type": "string", + "enum": [ + "dog", + "cat", + "fish", + "bird", + "gecko" + ] + } + }, + { + "contentType": "application/json", + "id": "", + "name": "Pets", + "schema": { + "type": "array", + "items": { + "$ref": "/restapis//models/Pet" + } + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {}, + "OPTIONS": {}, + "POST": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets/{petId}", + "pathPart": "{petId}", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-root-get": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Content-Type": "'text/html'" + }, + "responseTemplates": { + "text/html": "\n \n \n \n \n

Welcome to your Pet Store API

\n

\n You have successfully deployed your first API. You are seeing this HTML page because the GET method to the root resource of your API returns this content as a Mock integration.\n

\n

\n The Pet Store API contains the /pets and /pets/{petId} resources. By making a GET request to /pets you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a GET request to /pets/1.\n

\n

\n You can use a REST client such as Postman to test the POST methods in your API to create a new pet. Use the sample body below to send the POST request:\n

\n
\n{\n    \"type\" : \"cat\",\n    \"price\" : 123.11\n}\n        
\n \n" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Content-Type": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-root-get": { + "responseParameters": { + "method.response.header.Content-Type": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-root-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Content-Type": "'text/html'" + }, + "responseTemplates": { + "text/html": "\n \n \n \n \n

Welcome to your Pet Store API

\n

\n You have successfully deployed your first API. You are seeing this HTML page because the GET method to the root resource of your API returns this content as a Mock integration.\n

\n

\n The Pet Store API contains the /pets and /pets/{petId} resources. By making a GET request to /pets you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a GET request to /pets/1.\n

\n

\n You can use a REST client such as Postman to test the POST methods in your API to create a new pet. Use the sample body below to send the POST request:\n

\n
\n{\n    \"type\" : \"cat\",\n    \"price\" : 123.11\n}\n        
\n \n" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-root-get": { + "responseParameters": { + "method.response.header.Content-Type": "'text/html'" + }, + "responseTemplates": { + "text/html": "\n \n \n \n \n

Welcome to your Pet Store API

\n

\n You have successfully deployed your first API. You are seeing this HTML page because the GET method to the root resource of your API returns this content as a Mock integration.\n

\n

\n The Pet Store API contains the /pets and /pets/{petId} resources. By making a GET request to /pets you can retrieve a list of Pets in your API. If you are looking for a specific pet, for example the pet with ID 1, you can make a GET request to /pets/1.\n

\n

\n You can use a REST client such as Postman to test the POST methods in your API to create a new pet. Use the sample body below to send the POST request:\n

\n
\n{\n    \"type\" : \"cat\",\n    \"price\" : 123.11\n}\n        
\n \n" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-get": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.querystring.page": "method.request.querystring.page", + "integration.request.querystring.type": "method.request.querystring.type" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Pets" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "requestParameters": { + "method.request.querystring.page": false, + "method.request.querystring.type": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-get": { + "responseModels": { + "application/json": "Pets" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.querystring.page": "method.request.querystring.page", + "integration.request.querystring.type": "method.request.querystring.type" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-post": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "NewPetResponse" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "operationName": "CreatePet", + "requestModels": { + "application/json": "NewPet" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-post": { + "responseModels": { + "application/json": "NewPetResponse" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-post": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-post": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets/{petId}-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.petId": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets/{petId}" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Pet" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "operationName": "GetPet", + "requestParameters": { + "method.request.path.petId": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets/{petId}-get": { + "responseModels": { + "application/json": "Pet" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets/{petId}-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.petId": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore.execute-api..amazonaws.com/petstore/pets/{petId}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets/{petId}-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets/{petId}-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "requestParameters": { + "method.request.path.petId": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets/{petId}-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets/{petId}-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets/{petId}-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_and_validate_rest_api[openapi.spec.tf.json]": { + "recorded-date": "02-07-2025, 16:23:41", + "recorded-content": { + "import_tf_rest_api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "title": " Schema", + "type": "object" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-root-get": { + "apiKeyRequired": true, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_TEXT", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway:us-west-2:lambda:path/2015-03-31/functions/arn::lambda:us-west-2:000000000000:function:s3OnObjectCreatedLambda-60c92b6/invocations" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-root-get": { + "responseModels": { + "application/json": "" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-root-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_TEXT", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway:us-west-2:lambda:path/2015-03-31/functions/arn::lambda:us-west-2:000000000000:function:s3OnObjectCreatedLambda-60c92b6/invocations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-root-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-root-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-root-options": { + "responseModels": { + "application/json": "" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-root-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-root-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_and_validate_rest_api[swagger-mock-cors.json]": { + "recorded-date": "02-07-2025, 16:24:04", + "recorded-content": { + "import_tf_rest_api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-models": { + "items": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/product", + "pathPart": "product", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/product/{product_id}", + "pathPart": "{product_id}", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-product-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::000000000000:function:aws-serverless-shopping-cart-produ-GetProductsFunction-c1359550:live/invocations" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-product-get": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Response status code specified" + }, + "message": "Invalid Response status code specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "integration-product-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::000000000000:function:aws-serverless-shopping-cart-produ-GetProductsFunction-c1359550:live/invocations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-product-get": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Response status code specified" + }, + "message": "Invalid Response status code specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "method-product-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-product-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-product-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-product-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-product/{product_id}-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::000000000000:function:aws-serverless-shopping-cart-produc-GetProductFunction-28378339:live/invocations" + }, + "requestParameters": { + "method.request.path.product_id": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-product/{product_id}-get": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Response status code specified" + }, + "message": "Invalid Response status code specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "integration-product/{product_id}-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::000000000000:function:aws-serverless-shopping-cart-produc-GetProductFunction-28378339:live/invocations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-product/{product_id}-get": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Response status code specified" + }, + "message": "Invalid Response status code specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "method-product/{product_id}-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "requestParameters": { + "method.request.path.product_id": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-product/{product_id}-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-product/{product_id}-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-product/{product_id}-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8080'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[ignore]": { + "recorded-date": "02-07-2025, 16:24:51", + "recorded-content": { + "put-rest-api-swagger-json": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-swagger-json": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-test-get": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-test-options": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-no-base-path-swagger": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets/{petId}", + "pathPart": "{petId}", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets/{petId}-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "requestParameters": { + "method.request.path.petId": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets/{petId}-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets/{petId}-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets/{petId}-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[prepend]": { + "recorded-date": "02-07-2025, 16:26:15", + "recorded-content": { + "put-rest-api-swagger-json": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-swagger-json": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/base", + "pathPart": "base" + }, + { + "id": "", + "parentId": "", + "path": "/base/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-base/test-get": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-base/test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-base/test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-base/test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-base/test-options": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-base/test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-base/test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-base/test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-no-base-path-swagger": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets/{petId}", + "pathPart": "{petId}", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets/{petId}-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "requestParameters": { + "method.request.path.petId": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets/{petId}-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets/{petId}-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets/{petId}-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[split]": { + "recorded-date": "02-07-2025, 16:27:02", + "recorded-content": { + "put-rest-api-swagger-json": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-swagger-json": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-test-get": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-test-options": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-no-base-path-swagger": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets/{petId}", + "pathPart": "{petId}", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets/{petId}-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "requestParameters": { + "method.request.path.petId": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets/{petId}-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets/{petId}-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.id": "method.request.path.petId" + }, + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets/{id}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets/{petId}-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[prepend]": { + "recorded-date": "02-07-2025, 16:28:07", + "recorded-content": { + "put-rest-api-oas30-srv-var": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-oas30-srv-var": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/base-var", + "pathPart": "base-var" + }, + { + "id": "", + "parentId": "", + "path": "/base-var/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-base-var/test-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-response-base-var/test-get": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-base-var/test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-response-base-var/test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-base-var/test-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-response-base-var/test-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-base-var/test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-response-base-var/test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-rest-api-oas30-srv-url": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2.0", + "warnings": [ + "Invalid format for 'requestParameters'. Expected type string for property 'integration.request.header.nothing' of resource '/base-url/part/test' and method 'GET' but got 'true'" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-oas30-srv-url": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/base-url", + "pathPart": "base-url" + }, + { + "id": "", + "parentId": "", + "path": "/base-url/part", + "pathPart": "part" + }, + { + "id": "", + "parentId": "", + "path": "/base-url/part/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-base-url/part/test-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'", + "integration.request.header.double-single": "'True'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-response-base-url/part/test-get": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-base-url/part/test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'", + "integration.request.header.double-single": "'True'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-response-base-url/part/test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-base-url/part/test-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-response-base-url/part/test-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-base-url/part/test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-response-base-url/part/test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[split]": { + "recorded-date": "02-07-2025, 16:28:36", + "recorded-content": { + "put-rest-api-oas30-srv-var": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-oas30-srv-var": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-test-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-response-test-get": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-test-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-response-test-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-response-test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-rest-api-oas30-srv-url": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2.0", + "warnings": [ + "Invalid format for 'requestParameters'. Expected type string for property 'integration.request.header.nothing' of resource '/part/test' and method 'GET' but got 'true'" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-oas30-srv-url": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/part", + "pathPart": "part" + }, + { + "id": "", + "parentId": "", + "path": "/part/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-part/test-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'", + "integration.request.header.double-single": "'True'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-response-part/test-get": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-part/test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'", + "integration.request.header.double-single": "'True'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-response-part/test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-part/test-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-response-part/test-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-part/test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-response-part/test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[ignore]": { + "recorded-date": "02-07-2025, 16:27:32", + "recorded-content": { + "put-rest-api-oas30-srv-var": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-oas30-srv-var": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-test-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-response-test-get": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-test-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-method-response-test-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-var-integration-response-test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-rest-api-oas30-srv-url": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "2.0", + "warnings": [ + "Invalid format for 'requestParameters'. Expected type string for property 'integration.request.header.nothing' of resource '/test' and method 'GET' but got 'true'" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-resources-oas30-srv-url": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/test", + "pathPart": "test", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-test-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'", + "integration.request.header.double-single": "'True'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-response-test-get": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'", + "integration.request.header.double-single": "'True'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-test-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-method-response-test-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-test-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "srv-url-integration-response-test-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + "method.response.header.Access-Control-Allow-Methods": "'GET,OPTIONS'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_global_api_key_authorizer": { + "recorded-date": "02-07-2025, 16:29:08", + "recorded-content": { + "import-swagger": { + "apiKeySource": "AUTHORIZER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-authorizers": { + "items": [ + { + "authType": "custom", + "authorizerResultTtlInSeconds": 300, + "authorizerUri": "", + "id": "", + "identitySource": "method.request.header.Custom-Authorization", + "name": "", + "type": "REQUEST" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/echo", + "pathPart": "echo" + }, + { + "id": "", + "parentId": "", + "path": "/echo/{data}", + "pathPart": "{data}", + "resourceMethods": { + "ANY": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-echo/{data}-any": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "ANY", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseTemplates": { + "application/json": { + "echo": "$input.params('data')", + "response": "mocked" + } + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200" + } + }, + "requestParameters": { + "method.request.path.data": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-echo/{data}-any": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-echo/{data}-any": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseTemplates": { + "application/json": { + "echo": "$input.params('data')", + "response": "mocked" + } + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-echo/{data}-any": { + "responseTemplates": { + "application/json": { + "echo": "$input.params('data')", + "response": "mocked" + } + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-get": { + "apiKeyRequired": false, + "authorizationType": "CUSTOM", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_circular_models": { + "recorded-date": "02-07-2025, 16:29:44", + "recorded-content": { + "import-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "Circular model reference", + "rootResourceId": "", + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "id": "", + "name": "Empty", + "schema": { + "title": "Empty Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "Where a Person can live", + "id": "", + "name": "House", + "schema": { + "type": "object", + "properties": { + "randomProperty": { + "type": "string", + "description": "Random property", + "format": "byte" + }, + "contains": { + "minItems": 1, + "type": "array", + "description": "The information of who is living in the house", + "items": { + "$ref": "/restapis//models/Person" + } + } + }, + "description": "Where a Person can live" + } + }, + { + "contentType": "application/json", + "description": "Random person schema.", + "id": "", + "name": "Person", + "schema": { + "required": [ + "b", + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Random property" + }, + "b": { + "type": "number" + }, + "house": { + "$ref": "/restapis//models/House" + } + }, + "description": "Random person schema." + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/person", + "pathPart": "person", + "resourceMethods": { + "POST": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-person-post": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200" + } + }, + "operationName": "CreatePerson", + "requestModels": { + "application/json": "Person" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-person-post": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-person-post": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-person-post": { + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_circular_models_and_request_validation": { + "recorded-date": "02-07-2025, 16:30:34", + "recorded-content": { + "import-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "Circular model reference", + "rootResourceId": "", + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "id": "", + "name": "Empty", + "schema": { + "title": "Empty Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "Where a Person can live", + "id": "", + "name": "House", + "schema": { + "type": "object", + "properties": { + "randomProperty": { + "type": "string", + "description": "Random property", + "format": "byte" + }, + "contains": { + "minItems": 1, + "type": "array", + "description": "The information of who is living in the house", + "items": { + "$ref": "/restapis//models/Person" + } + } + }, + "description": "Where a Person can live" + } + }, + { + "contentType": "application/json", + "description": "Random person schema.", + "id": "", + "name": "Person", + "schema": { + "required": [ + "b", + "name" + ], + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Random property" + }, + "b": { + "type": "number" + }, + "house": { + "$ref": "/restapis//models/House" + } + }, + "description": "Random person schema." + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "request-validators": { + "items": [ + { + "id": "", + "name": "basic", + "validateRequestBody": true, + "validateRequestParameters": true + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/person", + "pathPart": "person", + "resourceMethods": { + "POST": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-person-post": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200" + } + }, + "operationName": "CreatePerson", + "requestModels": { + "application/json": "Person" + }, + "requestValidatorId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-person-post": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-person-post": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-person-post": { + "responseTemplates": { + "application/json": "{\"echo\": $input.json('$'), \"response\": \"mocked\"}" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_http_method_integration": { + "recorded-date": "02-07-2025, 16:31:53", + "recorded-content": { + "resources": { + "items": [ + { + "id": "", + "path": "/", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-root-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_TEXT", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": "#set($allParams = $input.params())\n{\n \"params\" : {\n #foreach($type in $allParams.keySet())\n #set($params = $allParams.get($type))\n \"$type\" : {\n #foreach($paramName in $params.keySet())\n \"$paramName\" : \"$util.escapeJavaScript($params.get($paramName))\"\n #if($foreach.hasNext),#end\n #end\n }\n #if($foreach.hasNext),#end\n #end\n }\n}\n" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-root-get": { + "responseModels": { + "application/json": "Empty" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-root-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_TEXT", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": "#set($allParams = $input.params())\n{\n \"params\" : {\n #foreach($type in $allParams.keySet())\n #set($params = $allParams.get($type))\n \"$type\" : {\n #foreach($paramName in $params.keySet())\n \"$paramName\" : \"$util.escapeJavaScript($params.get($paramName))\"\n #if($foreach.hasNext),#end\n #end\n }\n #if($foreach.hasNext),#end\n #end\n }\n}\n" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-root-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-root-options": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-root-options": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-root-options": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-root-options": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_stage_variables": { + "recorded-date": "02-07-2025, 16:31:50", + "recorded-content": { + "get-resp-from-http": { + "args": { + "qs_key": "qs_value" + }, + "data": "", + "domain": "", + "method": "POST", + "origin": "", + "path": "/test-path" + }, + "get-error-resp-from-http": { + "message": "Internal server error" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_cognito_auth_identity_source": { + "recorded-date": "02-07-2025, 16:32:13", + "recorded-content": { + "import-swagger": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "description": "A Pet Store API.", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "Example Pet Store", + "rootResourceId": "", + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-authorizers": [ + { + "id": "", + "name": "cognito-test-identity-source", + "type": "COGNITO_USER_POOLS", + "providerARNs": [ + "arn::cognito-idp::111111111111:userpool/_ABC123" + ], + "authType": "cognito_user_pools", + "identitySource": "method.request.header.TestHeaderAuth" + }, + { + "id": "", + "name": "extra-test-identity-source", + "type": "COGNITO_USER_POOLS", + "providerARNs": [ + "arn::cognito-idp::111111111111:userpool/_ABC123" + ], + "authType": "cognito_user_pools", + "identitySource": "method.request.header.TestHeaderAuth" + } + ], + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/default-no-scope", + "pathPart": "default-no-scope", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/default-scope-override", + "pathPart": "default-scope-override", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/non-default-authorizer", + "pathPart": "non-default-authorizer", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-default-no-scope-get": { + "apiKeyRequired": false, + "authorizationType": "COGNITO_USER_POOLS", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-default-no-scope-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-default-no-scope-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-default-no-scope-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-default-scope-override-get": { + "apiKeyRequired": false, + "authorizationScopes": [ + "openid" + ], + "authorizationType": "COGNITO_USER_POOLS", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-default-scope-override-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-default-scope-override-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-default-scope-override-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-non-default-authorizer-get": { + "apiKeyRequired": false, + "authorizationScopes": [ + "email", + "openid" + ], + "authorizationType": "COGNITO_USER_POOLS", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-non-default-authorizer-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-non-default-authorizer-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://petstore-demo-endpoint.execute-api.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-non-default-authorizer-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-pets-get": { + "apiKeyRequired": false, + "authorizationScopes": [ + "email" + ], + "authorizationType": "COGNITO_USER_POOLS", + "authorizerId": "", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP_PROXY", + "uri": "http://.amazonaws.com/petstore/pets" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Pets" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "operationName": "GET HTTP", + "requestParameters": { + "method.request.querystring.page": false, + "method.request.querystring.type": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-pets-get": { + "responseModels": { + "application/json": "Pets" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-pets-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP_PROXY", + "uri": "http://.amazonaws.com/petstore/pets", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-pets-get": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Response status code specified" + }, + "message": "Invalid Response status code specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_integer_http_status_code": { + "recorded-date": "02-07-2025, 16:32:51", + "recorded-content": { + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/test", + "pathPart": "test", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-test-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method-response-test-get": { + "responseModels": { + "application/json": "Empty" + }, + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-test-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.X-Amz-Invocation-Type": "'Event'" + }, + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "integration-response-test-get": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_put_rest_api_mode_binary_media_types[merge]": { + "recorded-date": "02-07-2025, 16:33:26", + "recorded-content": { + "create-rest-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/heif" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-rest-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/heif" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/heif", + "image/png", + "image/jpg" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "1.0.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_put_rest_api_mode_binary_media_types[overwrite]": { + "recorded-date": "02-07-2025, 16:34:23", + "recorded-content": { + "create-rest-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/heif" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-rest-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/heif" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/png", + "image/jpg" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": {}, + "version": "1.0.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_import.validation.json b/tests/aws/services/apigateway/test_apigateway_import.validation.json new file mode 100644 index 0000000000000..5f21b963b11b7 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_import.validation.json @@ -0,0 +1,173 @@ +{ + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_and_validate_rest_api[openapi.spec.tf.json]": { + "last_validated_date": "2025-07-02T16:23:41+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 18.27, + "teardown": 0.33, + "total": 18.6 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_and_validate_rest_api[swagger-mock-cors.json]": { + "last_validated_date": "2025-07-02T16:24:04+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 19.64, + "teardown": 3.34, + "total": 22.98 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api": { + "last_validated_date": "2025-07-02T16:22:33+00:00", + "durations_in_seconds": { + "setup": 0.81, + "call": 1.63, + "teardown": 0.37, + "total": 2.81 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[ignore]": { + "last_validated_date": "2025-07-02T16:27:32+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 20.79, + "teardown": 9.73, + "total": 30.52 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[prepend]": { + "last_validated_date": "2025-07-02T16:28:07+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 16.56, + "teardown": 18.28, + "total": 34.84 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_api_with_base_path_oas30[split]": { + "last_validated_date": "2025-07-02T16:28:36+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 17.81, + "teardown": 11.65, + "total": 29.46 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[ignore]": { + "last_validated_date": "2025-07-02T16:24:51+00:00", + "durations_in_seconds": { + "setup": 0.01, + "call": 45.49, + "teardown": 0.98, + "total": 46.48 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[prepend]": { + "last_validated_date": "2025-07-02T16:26:15+00:00", + "durations_in_seconds": { + "setup": 0.01, + "call": 45.68, + "teardown": 38.47, + "total": 84.16 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_rest_apis_with_base_path_swagger[split]": { + "last_validated_date": "2025-07-02T16:27:02+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 45.7, + "teardown": 0.89, + "total": 46.59 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_swagger_api": { + "last_validated_date": "2025-07-03T15:44:10+00:00", + "durations_in_seconds": { + "setup": 14.48, + "call": 7.05, + "teardown": 1.74, + "total": 23.27 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_circular_models": { + "last_validated_date": "2025-07-02T16:29:44+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.38, + "teardown": 33.35, + "total": 35.73 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_circular_models_and_request_validation": { + "last_validated_date": "2025-07-02T16:30:34+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 8.99, + "teardown": 41.28, + "total": 50.27 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_cognito_auth_identity_source": { + "last_validated_date": "2025-07-02T16:32:13+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 6.62, + "teardown": 13.45, + "total": 20.07 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_global_api_key_authorizer": { + "last_validated_date": "2025-07-02T16:29:08+00:00", + "durations_in_seconds": { + "setup": 0.01, + "call": 2.91, + "teardown": 29.13, + "total": 32.05 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_http_method_integration": { + "last_validated_date": "2025-07-02T16:31:53+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.59, + "teardown": 0.41, + "total": 3.0 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_integer_http_status_code": { + "last_validated_date": "2025-07-02T16:32:51+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.15, + "teardown": 35.52, + "total": 37.67 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_import_with_stage_variables": { + "last_validated_date": "2025-07-02T16:31:50+00:00", + "durations_in_seconds": { + "setup": 0.11, + "call": 5.83, + "teardown": 69.73, + "total": 75.67 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_put_rest_api_mode_binary_media_types[merge]": { + "last_validated_date": "2025-07-02T16:33:26+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 16.12, + "teardown": 18.69, + "total": 34.81 + } + }, + "tests/aws/services/apigateway/test_apigateway_import.py::TestApiGatewayImportRestApi::test_put_rest_api_mode_binary_media_types[overwrite]": { + "last_validated_date": "2025-07-02T16:34:24+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 16.13, + "teardown": 42.52, + "total": 58.65 + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_integrations.py b/tests/aws/services/apigateway/test_apigateway_integrations.py new file mode 100644 index 0000000000000..1b3c93c9367cc --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_integrations.py @@ -0,0 +1,1611 @@ +import base64 +import contextlib +import copy +import json +import textwrap +from typing import TypedDict +from urllib.parse import urlparse + +import pytest +import requests +from botocore.exceptions import ClientError +from pytest_httpserver import HTTPServer +from werkzeug import Request, Response + +from localstack import config +from localstack.aws.api.apigateway import IntegrationType +from localstack.aws.api.lambda_ import Runtime +from localstack.constants import APPLICATION_JSON +from localstack.services.lambda_.networking import get_main_endpoint_from_container +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import PUBLIC_HTTP_ECHO_SERVER_URL +from localstack.utils.aws import arns +from localstack.utils.json import json_safe +from localstack.utils.strings import short_uid, to_bytes, to_str +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import ( + api_invoke_url, + create_rest_api_deployment, +) +from tests.aws.services.apigateway.conftest import ( + APIGATEWAY_ASSUME_ROLE_POLICY, + DEFAULT_STAGE_NAME, + is_next_gen_api, +) +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_AWS_PROXY, + TEST_LAMBDA_LIBS, +) + +REQUEST_PARAMETERS = { + # Passthrough from the integration request to the invocation request + "integration.request.header.Accept": "'text/html'", + "integration.request.header.Accept-Charset": "'UTF-16'", + "integration.request.header.Accept-Encoding": "'zstd'", + "integration.request.header.Age": "'request_params_age'", + "integration.request.header.Authorization": "'request_params_authorization'", + "integration.request.header.Content-Encoding": "'compress'", + "integration.request.header.Content-Length": "'0'", + "integration.request.header.Content-MD5": "'request_params_Content-MD5'", + "integration.request.header.Content-Type": "'application/json'", + "integration.request.header.Date": "'request_params_Date'", + "integration.request.header.Expect": "'200-ok'", + "integration.request.header.Host": "method.request.header.Host", + "integration.request.header.Max-Forwards": "'2'", + "integration.request.header.Pragma": "'no-cache'", + "integration.request.header.Range": "'bytes=0-499'", + "integration.request.header.Referer": "'https://example.com/page'", + "integration.request.header.Server": "'https://example.com/page'", + "integration.request.header.Trailer": "'user-agent'", + "integration.request.header.Transfer-Encoding": "'deflate'", + "integration.request.header.Upgrade": "'HTTP/2.0'", + "integration.request.header.User-Agent": "'Override-Agent'", + "integration.request.header.Warn": "'110 anderson/1.3.37 \"Response is stale\"'", + "integration.request.header.WWW-Authenticate": "'Basic YWxhZGRpbjpvcGVuc2VzYW1l'", + # Dropped from the integration to the invocation request + "integration.request.header.Connection": "'keep-alive'", + "integration.request.header.Proxy-Authenticate": "'Basic realm=\"Access to the internal site\"'", + "integration.request.header.TE": "'gzip'", + "integration.request.header.Via": "'othersite.net'", +} + +HEADERS = [ + "Accept", + "Accept-Charset", + "Accept-Encoding", + "Age", + "Authorization", + "Connection", + "Content-Encoding", + "Content-Length", + "Content-MD5", + "Content-Type", + "Date", + "Expect", + "Host", + "Max-Forwards", + "Pragma", + "Proxy-Authenticate", + "Range", + "Referer", + "Server", + "TE", + "Transfer-Encoding", + "Trailer", + "Upgrade", + "User-Agent", + "Via", + "Warn", + "WWW-Authenticate", +] + + +class RequestParameterRoute(TypedDict, total=False): + path: str + request_parameter: str + parameter_mapping: str + resource_id: str + + +@pytest.fixture +def status_code_http_server(httpserver: HTTPServer): + """Spins up a local HTTP echo server and returns the endpoint URL""" + if is_aws_cloud(): + return f"{PUBLIC_HTTP_ECHO_SERVER_URL}/" + + def _echo(request: Request) -> Response: + result = { + "data": request.data or "{}", + "headers": dict(request.headers), + "url": request.url, + "method": request.method, + } + status_code = request.url.rpartition("/")[2] + response_body = json.dumps(result) + return Response(response_body, status=int(status_code)) + + httpserver.expect_request("").respond_with_handler(_echo) + http_endpoint = httpserver.url_for("/") + return http_endpoint + + +@pytest.fixture +def apigw_echo_http_server(httpserver: HTTPServer): + """Spins up a local HTTP echo server and returns the endpoint URL + Aims at emulating more closely the output of httpbin.org that is used to create the + snapshots + TODO tests the behavior and outputs of all fields""" + + def _echo(request: Request) -> Response: + headers = dict(request.headers) + headers.pop("Connection", None) + try: + json_body = json.loads(request.data) + except json.JSONDecodeError: + json_body = None + + result = { + "args": request.args, + "data": request.data, + "files": request.files, + "form": request.form, + "headers": headers, + "json": json_body, + "origin": request.origin, + "url": request.url, + } + response_body = json.dumps(json_safe(result)) + return Response( + response_body, + status=200, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Content-Type": "application/json", + }, + ) + + httpserver.expect_request("").respond_with_handler(_echo) + http_endpoint = httpserver.url_for("/") + + return http_endpoint + + +@pytest.fixture +def apigw_echo_http_server_post(apigw_echo_http_server): + """ + Returns an HTTP echo server URL for POST requests that work both locally and for parity tests (against real AWS) + """ + if is_aws_cloud(): + return f"{PUBLIC_HTTP_ECHO_SERVER_URL}/post" + + return f"{apigw_echo_http_server}/post" + + +@markers.aws.validated +def test_http_integration_status_code_selection( + create_rest_apigw, aws_client, status_code_http_server +): + api_id, _, root_id = create_rest_apigw(name="my_api", description="this is my api") + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{status}" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="none", + requestParameters={"method.request.path.status": True}, + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + type="HTTP", + uri=f"{status_code_http_server}status/{{status}}", + requestParameters={"integration.request.path.status": "method.request.path.status"}, + integrationHttpMethod="GET", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=resource_id, statusCode="200", httpMethod="GET" + ) + aws_client.apigateway.put_integration_response( + restApiId=api_id, resourceId=resource_id, statusCode="200", httpMethod="GET" + ) + # forward 4xx errors to 400, so the assertions of the test fixtures hold + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=resource_id, statusCode="400", httpMethod="GET" + ) + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + statusCode="400", + httpMethod="GET", + selectionPattern=r"4\d{2}", + ) + + stage_name = "test" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + invocation_url = api_invoke_url( + api_id=api_id, + stage=stage_name, + path="/", + ) + + def invoke_api(url, requested_response_code: int, expected_response_code: int): + apigw_response = requests.get( + f"{url}{requested_response_code}", + headers={"User-Agent": "python-requests/testing"}, + verify=False, + ) + assert expected_response_code == apigw_response.status_code + return apigw_response + + # retry is necessary against AWS + retry( + invoke_api, + sleep=2, + retries=10, + url=invocation_url, + expected_response_code=400, + requested_response_code=404, + ) + retry( + invoke_api, + sleep=2, + retries=10, + url=invocation_url, + expected_response_code=200, + requested_response_code=201, + ) + + +@markers.aws.validated +def test_put_integration_responses(create_rest_apigw, aws_client, echo_http_server_post, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("cacheNamespace"), + snapshot.transform.key_value("uri"), + snapshot.transform.key_value("id"), + ] + ) + api_id, _, root_id = create_rest_apigw(name="my_api", description="this is my api") + + response = aws_client.apigateway.put_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET", authorizationType="NONE" + ) + snapshot.match("put-method-get", response) + + response = aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + snapshot.match("put-method-response-get", response) + + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type="HTTP", + uri=echo_http_server_post, + integrationHttpMethod="POST", + ) + snapshot.match("put-integration-get", response) + + response = aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + statusCode="200", + selectionPattern="2\\d{2}", + responseTemplates={}, + ) + snapshot.match("put-integration-response-get", response) + + response = aws_client.apigateway.get_integration_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + snapshot.match("get-integration-response-get", response) + + response = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET" + ) + snapshot.match("get-method-get", response) + + stage_name = "local" + response = aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + snapshot.match("deploy", response) + + url = api_invoke_url(api_id, stage=stage_name, path="/") + response = requests.get(url) + assert response.ok + + response = aws_client.apigateway.delete_integration_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + snapshot.match("delete-integration-response-get", response) + + response = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET" + ) + snapshot.match("get-method-get-after-int-resp-delete", response) + + # adding a new method and performing put integration with contentHandling as CONVERT_TO_BINARY + response = aws_client.apigateway.put_method( + restApiId=api_id, resourceId=root_id, httpMethod="PUT", authorizationType="none" + ) + snapshot.match("put-method-put", response) + + response = aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=root_id, httpMethod="PUT", statusCode="200" + ) + snapshot.match("put-method-response-put", response) + + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="PUT", + type="HTTP", + uri=echo_http_server_post, + integrationHttpMethod="POST", + ) + snapshot.match("put-integration-put", response) + + response = aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_id, + httpMethod="PUT", + statusCode="200", + selectionPattern="2\\d{2}", + contentHandling="CONVERT_TO_BINARY", + ) + snapshot.match("put-integration-response-put", response) + + response = aws_client.apigateway.get_integration_response( + restApiId=api_id, resourceId=root_id, httpMethod="PUT", statusCode="200" + ) + snapshot.match("get-integration-response-put", response) + + +@markers.aws.validated +def test_put_integration_response_with_response_template( + aws_client, create_rest_apigw, create_echo_http_server, snapshot +): + echo_server_url = create_echo_http_server(trim_x_headers=True) + api_id, _, root_id = create_rest_apigw(name="test-apigw") + + aws_client.apigateway.put_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET", authorizationType="NONE" + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type="HTTP", + uri=echo_server_url, + integrationHttpMethod="POST", + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + statusCode="200", + selectionPattern="foobar", + responseTemplates={"application/json": json.dumps({"data": "test"})}, + ) + + response = aws_client.apigateway.get_integration_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + + snapshot.match("get-integration-response", response) + + +@markers.aws.validated +def test_put_integration_validation( + aws_client, account_id, region_name, create_rest_apigw, snapshot, partition +): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("cacheNamespace"), + ] + ) + + api_id, _, root_id = create_rest_apigw(name="test-apigw") + + aws_client.apigateway.put_method( + restApiId=api_id, resourceId=root_id, httpMethod="GET", authorizationType="NONE" + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + + http_types = ["HTTP", "HTTP_PROXY"] + aws_types = ["AWS", "AWS_PROXY"] + types_requiring_integration_method = http_types + ["AWS"] + types_not_requiring_integration_method = ["MOCK"] + + for _type in types_requiring_integration_method: + # Ensure that integrations of these types fail if no integrationHttpMethod is provided + with pytest.raises(ClientError) as ex: + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type=_type, + uri="http://example.com", + ) + snapshot.match(f"required-integration-method-{_type}", ex.value.response) + + for _type in types_not_requiring_integration_method: + # Ensure that integrations of these types do not need the integrationHttpMethod + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type=_type, + uri="http://example.com", + ) + snapshot.match(f"not-required-integration-method-{_type}", response) + + for _type in http_types: + # Ensure that it works fine when providing the integrationHttpMethod-argument + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type=_type, + uri="http://example.com", + integrationHttpMethod="POST", + ) + snapshot.match(f"http-method-{_type}", response) + + for _type in ["AWS"]: + # Ensure that it works fine when providing the integrationHttpMethod + credentials + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + credentials=f"arn:{partition}:iam::{account_id}:role/service-role/testfunction-role-oe783psq", + httpMethod="GET", + type=_type, + uri=f"arn:{partition}:apigateway:{region_name}:s3:path/b/k", + integrationHttpMethod="POST", + ) + snapshot.match(f"aws-integration-{_type}", response) + + for _type in aws_types: + # Ensure that credentials are not required when URI points to a Lambda stream + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type=_type, + uri=f"arn:{partition}:apigateway:{region_name}:lambda:path/2015-03-31/functions/arn:{partition}:lambda:{region_name}:{account_id}:function:MyLambda/invocations", + integrationHttpMethod="POST", + ) + snapshot.match(f"aws-integration-type-{_type}", response) + + for _type in ["AWS_PROXY"]: + # Ensure that aws_proxy does not support S3 + with pytest.raises(ClientError) as ex: + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + credentials=f"arn:{partition}:iam::{account_id}:role/service-role/testfunction-role-oe783psq", + httpMethod="GET", + type=_type, + uri=f"arn:{partition}:apigateway:{region_name}:s3:path/b/k", + integrationHttpMethod="POST", + ) + snapshot.match(f"no-s3-support-{_type}", ex.value.response) + + for _type in http_types: + # Ensure that the URI is valid HTTP + with pytest.raises(ClientError) as ex: + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type=_type, + uri="non-valid-http", + integrationHttpMethod="POST", + ) + snapshot.match(f"invalid-uri-{_type}", ex.value.response) + + # Ensure that the URI is an ARN + with pytest.raises(ClientError) as ex: + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type="AWS", + uri="non-valid-arn", + integrationHttpMethod="POST", + ) + snapshot.match("invalid-uri-not-an-arn", ex.value.response) + + # Ensure that the URI is a valid ARN + with pytest.raises(ClientError) as ex: + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + type="AWS", + uri=f"arn:{partition}:iam::0000000000:role/service-role/asdf", + integrationHttpMethod="POST", + ) + snapshot.match("invalid-uri-invalid-arn", ex.value.response) + + +@markers.aws.validated +@pytest.mark.skipif( + condition=not is_next_gen_api() and not is_aws_cloud(), + reason="Behavior is properly implemented in Legacy, it returns the MOCK response", +) +def test_integration_mock_with_path_param(create_rest_apigw, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + + rest_resource = aws_client.apigateway.create_resource( + restApiId=api_id, + parentId=root, + pathPart="{testPath}", + ) + resource_id = rest_resource["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="none", + requestParameters={ + "method.request.path.testPath": True, + }, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=resource_id, httpMethod="GET", statusCode="200" + ) + + # you don't have to pass URI for Mock integration as it's not used anyway + # when exporting an API in AWS, apparently you can get integration path parameters even if not used + integration = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="MOCK", + requestParameters={ + "integration.request.path.integrationPath": "method.request.path.testPath", + }, + # This template was modified to validate a cdk issue where it creates this template part + # of some L2 construct for CORS handling. This isn't valid JSON but accepted by aws. + requestTemplates={"application/json": "{statusCode: 200}"}, + ) + snapshot.match("integration", integration) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="2\\d{2}", + responseTemplates={}, + ) + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name, path="/test-path") + + def invoke_api(url) -> requests.Response: + _response = requests.get(url, verify=False) + assert _response.ok + return _response + + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + assert response_data.content == b"" + assert response_data.status_code == 200 + + +@markers.aws.validated +@pytest.mark.skipif( + condition=not is_next_gen_api() and not is_aws_cloud(), + reason="Behavior is properly implemented in Legacy, it returns the MOCK response", +) +def test_integration_mock_with_request_overrides_in_response_template( + create_rest_apigw, aws_client, snapshot +): + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + + rest_resource = aws_client.apigateway.create_resource( + restApiId=api_id, + parentId=root, + pathPart="{testPath}", + ) + resource_id = rest_resource["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + requestParameters={ + "method.request.path.testPath": True, + }, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=resource_id, httpMethod="GET", statusCode="200" + ) + + # this should only work for MOCK integration, as they don't use the .path at all. This seems to be a derivative + # way to pass data from the integration request to integration response with MOCK integration + request_template = textwrap.dedent("""#set($body = $util.base64Decode($input.params('testPath'))) + #set($context.requestOverride.path.body = $body) + { + "statusCode": 200 + } + """) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="MOCK", + requestParameters={}, + requestTemplates={"application/json": request_template}, + ) + response_template = textwrap.dedent(""" + #set($body = $util.parseJson($context.requestOverride.path.body)) + #set($inputBody = $body.message) + #if($inputBody == "path1") + { + "response": "path was path one" + } + #elseif($inputBody == "path2") + { + "response": "path was path two" + } + #else + { + "response": "this is the else clause" + } + #end + """) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="2\\d{2}", + responseTemplates={"application/json": response_template}, + ) + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + path_data = to_str(base64.b64encode(to_bytes(json.dumps({"message": "path1"})))) + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name, path="/" + path_data) + + def invoke_api(url) -> requests.Response: + _response = requests.get(url, verify=False) + assert _response.ok + return _response + + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("invoke-path1", response_data.json()) + + path_data_2 = to_str(base64.b64encode(to_bytes(json.dumps({"message": "path2"})))) + invocation_url_2 = api_invoke_url(api_id=api_id, stage=stage_name, path="/" + path_data_2) + + response_data_2 = invoke_api(url=invocation_url_2) + snapshot.match("invoke-path2", response_data_2.json()) + + path_data_3 = to_str(base64.b64encode(to_bytes(json.dumps({"message": "whatever"})))) + invocation_url_3 = api_invoke_url(api_id=api_id, stage=stage_name, path="/" + path_data_3) + + response_data_3 = invoke_api(url=invocation_url_3) + snapshot.match("invoke-path-else", response_data_3.json()) + + +@markers.aws.validated +@pytest.mark.parametrize("create_response_template", [True, False]) +def test_integration_mock_with_response_override_in_request_template( + create_rest_apigw, aws_client, snapshot, create_response_template +): + expected_status = 444 + api_id, _, root_id = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + authorizationType="NONE", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + + request_template = textwrap.dedent(f""" + #set($context.responseOverride.status = {expected_status}) + #set($context.responseOverride.header.foo = "bar") + #set($context.responseOverride.custom = "is also passed around") + {{ + "statusCode": 200 + }} + """) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="MOCK", + requestParameters={}, + requestTemplates={"application/json": request_template}, + ) + response_template = textwrap.dedent(""" + #set($statusOverride = $context.responseOverride.status) + #set($fooHeader = $context.responseOverride.header.foo) + #set($custom = $context.responseOverride.custom) + { + "statusOverride": "$statusOverride", + "fooHeader": "$fooHeader", + "custom": "$custom" + } + """) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + statusCode="200", + selectionPattern="2\\d{2}", + responseTemplates={"application/json": response_template} + if create_response_template + else {}, + ) + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name) + + def invoke_api(url) -> requests.Response: + _response = requests.get(url, verify=False) + assert _response.status_code == expected_status + return _response + + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + assert response_data.headers["foo"] == "bar" + snapshot.match( + "response", + { + "body": response_data.json() if create_response_template else response_data.content, + "status_code": response_data.status_code, + }, + ) + + +@markers.aws.validated +def test_integration_mock_with_vtl_map_assignation(create_rest_apigw, aws_client, snapshot): + api_id, _, root_id = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="this is my api", + ) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + authorizationType="NONE", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=root_id, httpMethod="GET", statusCode="200" + ) + + request_template = textwrap.dedent(""" + #set($paramName = "foo") + #set($context.requestOverride.querystring[$paramName] = "bar") + #set($paramPutName = "putfoo") + $context.requestOverride.querystring.put($paramPutName, "putBar") + #set($context["requestOverride"].querystring["nestedfoo"] = "nestedFoo") + { + "statusCode": 200 + } + """) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="MOCK", + requestParameters={}, + requestTemplates={"application/json": request_template}, + ) + response_template = textwrap.dedent(""" + #set($value = $context.requestOverride.querystring["foo"]) + #set($value2 = $context.requestOverride.querystring["putfoo"]) + #set($value3 = $context.requestOverride.querystring["nestedfoo"]) + { + "value": "$value", + "value2": "$value2", + "value3": "$value3" + } + """) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root_id, + httpMethod="GET", + statusCode="200", + selectionPattern="2\\d{2}", + responseTemplates={"application/json": response_template}, + ) + stage_name = "dev" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name) + + def invoke_api(url) -> requests.Response: + _response = requests.get(url, verify=False) + assert _response.status_code == 200 + return _response + + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match( + "response", + { + "body": response_data.json(), + "status_code": response_data.status_code, + }, + ) + + +@pytest.fixture +def default_vpc(aws_client): + vpcs = aws_client.ec2.describe_vpcs() + for vpc in vpcs["Vpcs"]: + if vpc.get("IsDefault"): + return vpc + raise Exception("Default VPC not found") + + +@pytest.fixture +def create_vpc_endpoint(default_vpc, aws_client): + endpoints = [] + + def _create(**kwargs): + kwargs.setdefault("VpcId", default_vpc["VpcId"]) + result = aws_client.ec2.create_vpc_endpoint(**kwargs) + endpoints.append(result["VpcEndpoint"]["VpcEndpointId"]) + return result["VpcEndpoint"] + + yield _create + + for endpoint in endpoints: + with contextlib.suppress(Exception): + aws_client.ec2.delete_vpc_endpoints(VpcEndpointIds=[endpoint]) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..endpointConfiguration.types", + "$..policy.Statement..Resource", + "$..endpointConfiguration.ipAddressType", + ] +) +@markers.aws.validated +def test_create_execute_api_vpc_endpoint( + create_rest_api_with_integration, + dynamodb_create_table, + create_vpc_endpoint, + default_vpc, + create_lambda_function, + ec2_create_security_group, + snapshot, + aws_client, +): + poll_sleep = 5 if is_aws_cloud() else 1 + # TODO: create a re-usable ec2_api() transformer + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("DnsName"), + snapshot.transform.key_value("GroupId"), + snapshot.transform.key_value("GroupName"), + snapshot.transform.key_value("SubnetIds"), + snapshot.transform.key_value("VpcId"), + snapshot.transform.key_value("VpcEndpointId"), + snapshot.transform.key_value("HostedZoneId"), + *snapshot.transform.apigateway_api(), + ] + ) + + # create table + table = dynamodb_create_table()["TableDescription"] + table_name = table["TableName"] + + # insert items + item_ids = ("test", "test2", "test 3") + for item_id in item_ids: + aws_client.dynamodb.put_item(TableName=table_name, Item={"id": {"S": item_id}}) + + # construct request mapping template + request_templates = {APPLICATION_JSON: json.dumps({"TableName": table_name})} + + # deploy REST API with integration + region_name = aws_client.apigateway.meta.region_name + integration_uri = f"arn:aws:apigateway:{region_name}:dynamodb:action/Scan" + api_id = create_rest_api_with_integration( + integration_uri=integration_uri, + req_templates=request_templates, + integration_type="AWS", + ) + + # get service names + service_name = f"com.amazonaws.{region_name}.execute-api" + service_names = aws_client.ec2.describe_vpc_endpoint_services()["ServiceNames"] + assert service_name in service_names + + # create security group + vpc_id = default_vpc["VpcId"] + security_group = ec2_create_security_group( + VpcId=vpc_id, Description="Test SG for API GW", ports=[443] + ) + security_group = security_group["GroupId"] + subnets = aws_client.ec2.describe_subnets(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]) + subnets = [sub["SubnetId"] for sub in subnets["Subnets"]] + + # get or create execute-api VPC endpoint + endpoints = aws_client.ec2.describe_vpc_endpoints(MaxResults=1000)["VpcEndpoints"] + matching = [ep for ep in endpoints if ep["ServiceName"] == service_name] + if matching: + endpoint_id = matching[0]["VpcEndpointId"] + else: + result = create_vpc_endpoint( + ServiceName=service_name, + VpcEndpointType="Interface", + SubnetIds=subnets, + SecurityGroupIds=[security_group], + ) + endpoint_id = result["VpcEndpointId"] + + # wait until VPC endpoint is in state "available" + def _check_available(): + result = aws_client.ec2.describe_vpc_endpoints(VpcEndpointIds=[endpoint_id]) + endpoint_details = result["VpcEndpoints"][0] + # may have multiple entries in AWS + endpoint_details["DnsEntries"] = endpoint_details["DnsEntries"][:1] + endpoint_details.pop("SubnetIds", None) + endpoint_details.pop("NetworkInterfaceIds", None) + assert endpoint_details["State"] == "available" + snapshot.match("endpoint-details", endpoint_details) + + retry(_check_available, retries=30, sleep=poll_sleep) + + # update API with VPC endpoint + patches = [ + {"op": "replace", "path": "/endpointConfiguration/types/EDGE", "value": "PRIVATE"}, + {"op": "add", "path": "/endpointConfiguration/vpcEndpointIds", "value": endpoint_id}, + ] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patches) + + # create Lambda that invokes API via VPC endpoint (required as the endpoint is only accessible within the VPC) + subdomain = f"{api_id}-{endpoint_id}" + endpoint = api_invoke_url(subdomain, stage=DEFAULT_STAGE_NAME, path="/test") + host_header = urlparse(endpoint).netloc + + # create Lambda function that invokes the API GW (private VPC endpoint not accessible from outside of AWS) + if not is_aws_cloud(): + api_host = get_main_endpoint_from_container() + endpoint = endpoint.replace(host_header, f"{api_host}:{config.GATEWAY_LISTEN[0].port}") + lambda_code = textwrap.dedent( + f""" + def handler(event, context): + import requests + headers = {{"content-type": "application/json", "host": "{host_header}"}} + result = requests.post("{endpoint}", headers=headers) + return {{"content": result.content.decode("utf-8"), "code": result.status_code}} + """ + ) + func_name = f"test-{short_uid()}" + vpc_config = { + "SubnetIds": subnets, + "SecurityGroupIds": [security_group], + } + create_lambda_function( + func_name=func_name, + handler_file=lambda_code, + libs=TEST_LAMBDA_LIBS, + timeout=10, + VpcConfig=vpc_config, + ) + + # create resource policy + statement = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "execute-api:Invoke", + "Resource": ["execute-api:/*"], + } + ], + } + patches = [{"op": "replace", "path": "/policy", "value": json.dumps(statement)}] + result = aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patches) + result["policy"] = json.loads(to_bytes(result["policy"]).decode("unicode_escape")) + snapshot.match("api-details", result) + + # re-deploy API + create_rest_api_deployment( + aws_client.apigateway, restApiId=api_id, stageName=DEFAULT_STAGE_NAME + ) + + def _invoke_api(): + invoke_response = aws_client.lambda_.invoke(FunctionName=func_name, Payload="{}") + payload = json.load(invoke_response["Payload"]) + items = json.loads(payload["content"])["Items"] + assert len(items) == len(item_ids) + + # invoke Lambda and assert result + retry(_invoke_api, retries=15, sleep=poll_sleep) + + +@pytest.mark.skipif( + condition=not is_next_gen_api() and not is_aws_cloud(), reason="Not implemented in legacy" +) +class TestApiGatewayHeaderRemapping: + @pytest.fixture + def create_apigateway_with_header_remapping(self, aws_client, create_rest_apigw): + def _factory( + integration: IntegrationType, + integration_uri: str, + role_arn: str, + special_cases: list[RequestParameterRoute], + ): + request_parameters = copy.deepcopy(REQUEST_PARAMETERS) + + stage = "test" + # Creating as a regional endpoint to prevent the cloudfront header from modifying the apigw headers + # TODO test with a "EDGE" configuration + apigw, _, root_id = create_rest_apigw(endpointConfiguration={"types": ["REGIONAL"]}) + + # Base test with no parameter mapping + no_param_resource = aws_client.apigateway.create_resource( + restApiId=apigw, parentId=root_id, pathPart="no-param" + )["id"] + # Full test with all the mentioned headers mapped except for the special cases below + full_resource = aws_client.apigateway.create_resource( + restApiId=apigw, parentId=root_id, pathPart="full" + )["id"] + + for special_case in special_cases: + resource = aws_client.apigateway.create_resource( + restApiId=apigw, parentId=root_id, pathPart=special_case["path"] + ) + special_case["resource_id"] = resource["id"] + special_case["parameter_mapping"] = request_parameters.pop( + special_case["request_parameter"], "''" + ) + + for resource_id in [ + no_param_resource, + full_resource, + *[special_case["resource_id"] for special_case in special_cases], + ]: + aws_client.apigateway.put_method( + restApiId=apigw, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + requestParameters={ + f"method.request.header.{header}": False for header in HEADERS + }, + ) + aws_client.apigateway.put_method_response( + restApiId=apigw, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + responseParameters={ + f"method.response.header.{header}": True for header in HEADERS + }, + ) + + # No param resource + aws_client.apigateway.put_integration( + restApiId=apigw, + resourceId=no_param_resource, + httpMethod="GET", + type=integration, + uri=integration_uri, + integrationHttpMethod="POST", + credentials=role_arn, + ) + aws_client.apigateway.put_integration_response( + restApiId=apigw, resourceId=no_param_resource, httpMethod="GET", statusCode="200" + ) + + # Full mapping + request_template = ( + "{" + + ",".join([f'"{header}": "$input.params(\'{header}\')"' for header in HEADERS]) + + "}" + ) + aws_client.apigateway.put_integration( + restApiId=apigw, + resourceId=full_resource, + httpMethod="GET", + type=integration, + integrationHttpMethod="POST", + uri=integration_uri, + credentials=role_arn, + requestParameters=request_parameters, + requestTemplates={APPLICATION_JSON: request_template}, + ) + aws_client.apigateway.put_integration_response( + restApiId=apigw, + resourceId=full_resource, + httpMethod="GET", + statusCode="200", + responseParameters={ + f"method.response.header.{header}": f"'response_param_{header}'" + for header in HEADERS + }, + ) + for special_case in special_cases: + aws_client.apigateway.put_integration( + restApiId=apigw, + resourceId=special_case["resource_id"], + httpMethod="GET", + type=integration, + integrationHttpMethod="POST", + uri=integration_uri, + credentials=role_arn, + requestParameters={ + special_case["request_parameter"]: special_case["parameter_mapping"] + }, + ) + aws_client.apigateway.put_integration_response( + restApiId=apigw, + resourceId=special_case["resource_id"], + httpMethod="GET", + statusCode="200", + ) + + aws_client.apigateway.create_deployment(restApiId=apigw, stageName=stage) + invoke_url = api_invoke_url(api_id=apigw, stage=stage, path="") + + return apigw, invoke_url + + return _factory + + def invoke_api(self, invoke_url: str, path: str, expected_status: int): + def _invoke_api(): + response = requests.get( + f"{invoke_url}/{path}", + headers={ + "Accept": "application/json", + "Accept-Charset": "UTF-8", + "Accept-Encoding": "br", + "Age": "request_Age", + "Authorization": "Unauthorized", + "Connection": "close", + "Content-Encoding": "deflate", + "Content-MD5": "request_Content-MD5", + "Content-Type": "application/json", + "Date": "request_Date", + "Expect": "100-continue", + "Max-Forwards": "2", + "Pragma": "cache", + "Proxy-Authenticate": "Basic", + "Range": "bytes=500-999", + "Referer": "https://example.com/", + "Server": "https://example.com/", + "TE": "deflate", + "Trailer": "Expires", + "Transfer-Encoding": "chunked", + "Upgrade": "HTTP/2.0", + "User-Agent": "localStack/0.0", + "Via": "p.example.net", + "Warn": "299 localStack/0.0", + "WWW-Authenticate": "Basic YWxhZGRpbjpvcGVuc2VzYW1l", + }, + ) + assert response.status_code == expected_status + return response + + return retry(_invoke_api, retries=1, sleep=5 if is_aws_cloud() else 1) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # requests is adding these and can't be removed + "$..headers.Accept-Encoding", + # Server will be different + "$..response-headers.Server", + "$..response-headers.x-amzn-Remapped-Server", + # Content length is different, seems to be due to the Host mapping in the body + "$.full-integration.headers.Content-Length", + "$..response-headers.Content-Length", + "$..response-headers.x-amzn-Remapped-Content-Length", + # HttpServer doesn't allow for changing the value of Connection + "$..response-headers.x-amzn-Remapped-Connection", + ] + ) + @pytest.mark.parametrize("integration", [IntegrationType.HTTP, IntegrationType.HTTP_PROXY]) + @markers.aws.validated + def test_apigateway_header_remapping_http( + self, + snapshot, + integration, + apigw_echo_http_server_post, + create_apigateway_with_header_remapping, + ): + snapshot.add_transformer(snapshot.transform.key_value("Host"), priority=-1) + snapshot.add_transformers_list(snapshot.transform.apigateway_invocation_headers()) + + integration_uri = apigw_echo_http_server_post + + apigw, invoke_url = create_apigateway_with_header_remapping( + integration, + integration_uri, + "", + special_cases=[ + RequestParameterRoute( + path="content-length", + request_parameter="integration.request.header.Content-Length", + ), + RequestParameterRoute( + path="transfer-encoding", + request_parameter="integration.request.header.Transfer-Encoding", + ), + ], + ) + snapshot.match("apigw-id", apigw) + + # no param mapping request + invoke_response = self.invoke_api(invoke_url, "no-param", 200) + json_response = invoke_response.json() + snapshot.match( + "no-param-integration", + { + "headers": json_response.get("headers"), + "body": json_response["data"], + "response-headers": dict(invoke_response.headers), + }, + ) + + # full request + invoke_response = self.invoke_api(invoke_url, "full", 200) + json_response = invoke_response.json() + snapshot.match( + "full-integration", + { + "headers": json_response.get("headers"), + "body": json_response["data"], + "response-headers": dict(invoke_response.headers), + }, + ) + + # content-length request + invoke_response = self.invoke_api(invoke_url, "content-length", 500) + snapshot.match( + "content-length", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + + # transfer-encoding request + invoke_response = self.invoke_api(invoke_url, "transfer-encoding", 500) + snapshot.match( + "transfer-encoding", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + + @markers.snapshot.skip_snapshot_verify( + paths=["$..response-headers.Server", "$..response-headers.Content-Length"] + ) + @pytest.mark.parametrize("integration", [IntegrationType.AWS, IntegrationType.AWS_PROXY]) + @markers.aws.validated + def test_apigateway_header_remapping_aws( + self, + snapshot, + integration, + create_lambda_function, + region_name, + create_lambda_function_aws, + create_role_with_policy, + create_apigateway_with_header_remapping, + ): + snapshot.add_transformer(snapshot.transform.key_value("Host"), priority=-1) + snapshot.add_transformers_list(snapshot.transform.apigateway_invocation_headers()) + + lambda_fn = create_lambda_function( + func_name=f"test-{short_uid()}", + handler_file=TEST_LAMBDA_AWS_PROXY, + handler="lambda_aws_proxy.handler", + runtime=Runtime.python3_12, + ) + lambda_arn = lambda_fn["CreateFunctionResponse"]["FunctionArn"] + integration_uri = arns.apigateway_invocations_arn(lambda_arn, region_name) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + apigw, invoke_url = create_apigateway_with_header_remapping( + integration, + integration_uri, + role_arn, + special_cases=[ + RequestParameterRoute( + path="content-length", + request_parameter="integration.request.header.Content-Length", + ), + RequestParameterRoute( + path="transfer-encoding", + request_parameter="integration.request.header.Transfer-Encoding", + ), + RequestParameterRoute( + path="authorization", + request_parameter="integration.request.header.Authorization", + ), + RequestParameterRoute( + path="connection", request_parameter="integration.request.header.Connection" + ), + RequestParameterRoute( + path="expect", request_parameter="integration.request.header.Expect" + ), + RequestParameterRoute( + path="proxy-authenticate", + request_parameter="integration.request.header.Proxy-Authenticate", + ), + RequestParameterRoute(path="te", request_parameter="integration.request.header.TE"), + ], + ) + snapshot.match("apigw-id", apigw) + + # no param mapping request + invoke_response = self.invoke_api(invoke_url, "no-param", 200) + json_response = invoke_response.json() + snapshot.match( + "no-param-integration", + { + "headers": json_response.get("headers"), + "body": json_response["body"], + "response-headers": dict(invoke_response.headers), + }, + ) + + # full request + invoke_response = self.invoke_api(invoke_url, "full", 200) + json_response = invoke_response.json() + snapshot.match( + "full-integration", + { + "headers": json_response.get("headers"), + "body": json_response["body"], + "response-headers": dict(invoke_response.headers), + }, + ) + + # content-length request + invoke_response = self.invoke_api(invoke_url, "content-length", 500) + snapshot.match( + "content-length", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + + # transfer-encoding request + invoke_response = self.invoke_api(invoke_url, "transfer-encoding", 500) + snapshot.match( + "transfer-encoding", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + invoke_response = self.invoke_api(invoke_url, "authorization", 500) + snapshot.match( + "authorization", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + invoke_response = self.invoke_api(invoke_url, "connection", 500) + snapshot.match( + "connection", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + invoke_response = self.invoke_api(invoke_url, "expect", 500) + snapshot.match( + "expect", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + invoke_response = self.invoke_api(invoke_url, "proxy-authenticate", 500) + snapshot.match( + "proxy-authenticate", + {"response-headers": dict(invoke_response.headers), "body": invoke_response.text}, + ) + invoke_response = self.invoke_api(invoke_url, "te", 500) + snapshot.match( + "te", {"response-headers": dict(invoke_response.headers), "body": invoke_response.text} + ) + + +# TODO - remove the code below? +# +# def test_aws_integration_dynamodb(apigateway_client): +# if settings.TEST_SERVER_MODE: +# raise SkipTest("Cannot test mock of execute-api.apigateway in ServerMode") +# +# client = boto3.client("apigateway", region_name="us-west-2") +# dynamodb = boto3.client("dynamodb", region_name="us-west-2") +# table_name = "test_1" +# integration_action = "arn:aws:apigateway:us-west-2:dynamodb:action/PutItem" +# stage_name = "staging" +# +# create_table(dynamodb, table_name) +# api_id, _ = create_integration_test_api(client, integration_action) +# +# client.create_deployment(restApiId=api_id, stageName=stage_name) +# +# res = requests.put( +# f"https://{api_id}.execute-api.us-west-2.amazonaws.com/{stage_name}", +# json={"TableName": table_name, "Item": {"name": {"S": "the-key"}}}, +# ) +# res.status_code.should.equal(200) +# res.content.should.equal(b"{}") +# +# +# def test_aws_integration_dynamodb_multiple_stages(apigateway_client): +# if settings.TEST_SERVER_MODE: +# raise SkipTest("Cannot test mock of execute-api.apigateway in ServerMode") +# +# client = boto3.client("apigateway", region_name="us-west-2") +# dynamodb = boto3.client("dynamodb", region_name="us-west-2") +# table_name = "test_1" +# integration_action = "arn:aws:apigateway:us-west-2:dynamodb:action/PutItem" +# +# create_table(dynamodb, table_name) +# api_id, _ = create_integration_test_api(client, integration_action) +# +# client.create_deployment(restApiId=api_id, stageName="dev") +# client.create_deployment(restApiId=api_id, stageName="staging") +# +# res = requests.put( +# f"https://{api_id}.execute-api.us-west-2.amazonaws.com/dev", +# json={"TableName": table_name, "Item": {"name": {"S": "the-key"}}}, +# ) +# res.status_code.should.equal(200) +# +# res = requests.put( +# f"https://{api_id}.execute-api.us-west-2.amazonaws.com/staging", +# json={"TableName": table_name, "Item": {"name": {"S": "the-key"}}}, +# ) +# res.status_code.should.equal(200) +# +# # We haven't pushed to prod yet +# res = requests.put( +# f"https://{api_id}.execute-api.us-west-2.amazonaws.com/prod", +# json={"TableName": table_name, "Item": {"name": {"S": "the-key"}}}, +# ) +# res.status_code.should.equal(400) +# +# +# @mock_apigateway +# @mock_dynamodb +# def test_aws_integration_dynamodb_multiple_resources(): +# if settings.TEST_SERVER_MODE: +# raise SkipTest("Cannot test mock of execute-api.apigateway in ServerMode") +# +# client = boto3.client("apigateway", region_name="us-west-2") +# dynamodb = boto3.client("dynamodb", region_name="us-west-2") +# table_name = "test_1" +# create_table(dynamodb, table_name) +# +# # Create API integration to PutItem +# integration_action = "arn:aws:apigateway:us-west-2:dynamodb:action/PutItem" +# api_id, root_id = create_integration_test_api(client, integration_action) +# +# # Create API integration to GetItem +# res = client.create_resource(restApiId=api_id, parentId=root_id, pathPart="item") +# parent_id = res["id"] +# integration_action = "arn:aws:apigateway:us-west-2:dynamodb:action/GetItem" +# api_id, root_id = create_integration_test_api( +# client, +# integration_action, +# api_id=api_id, +# parent_id=parent_id, +# http_method="GET", +# ) +# +# client.create_deployment(restApiId=api_id, stageName="dev") +# +# # Put item at the root resource +# res = requests.put( +# f"https://{api_id}.execute-api.us-west-2.amazonaws.com/dev", +# json={ +# "TableName": table_name, +# "Item": {"name": {"S": "the-key"}, "attr2": {"S": "sth"}}, +# }, +# ) +# res.status_code.should.equal(200) +# +# # Get item from child resource +# res = requests.get( +# f"https://{api_id}.execute-api.us-west-2.amazonaws.com/dev/item", +# json={"TableName": table_name, "Key": {"name": {"S": "the-key"}}}, +# ) +# res.status_code.should.equal(200) +# json.loads(res.content).should.equal( +# {"Item": {"name": {"S": "the-key"}, "attr2": {"S": "sth"}}} +# ) +# +# +# def create_table(dynamodb, table_name): +# # Create DynamoDB table +# dynamodb.create_table( +# TableName=table_name, +# KeySchema=[{"AttributeName": "name", "KeyType": "HASH"}], +# AttributeDefinitions=[{"AttributeName": "name", "AttributeType": "S"}], +# BillingMode="PAY_PER_REQUEST", +# ) +# +# +# def create_integration_test_api( +# client, integration_action, api_id=None, parent_id=None, http_method="PUT" +# ): +# if not api_id: +# # We do not have a root yet - create the API first +# response = client.create_rest_api(name="my_api", description="this is my api") +# api_id = response["id"] +# if not parent_id: +# resources = client.get_resources(restApiId=api_id) +# parent_id = [ +# resource for resource in resources["items"] if resource["path"] == "/" +# ][0]["id"] +# +# client.put_method( +# restApiId=api_id, +# resourceId=parent_id, +# httpMethod=http_method, +# authorizationType="NONE", +# ) +# client.put_method_response( +# restApiId=api_id, resourceId=parent_id, httpMethod=http_method, statusCode="200" +# ) +# client.put_integration( +# restApiId=api_id, +# resourceId=parent_id, +# httpMethod=http_method, +# type="AWS", +# uri=integration_action, +# integrationHttpMethod=http_method, +# ) +# client.put_integration_response( +# restApiId=api_id, +# resourceId=parent_id, +# httpMethod=http_method, +# statusCode="200", +# selectionPattern="", +# responseTemplates={"application/json": "{}"}, +# ) +# return api_id, parent_id diff --git a/tests/aws/services/apigateway/test_apigateway_integrations.snapshot.json b/tests/aws/services/apigateway/test_apigateway_integrations.snapshot.json new file mode 100644 index 0000000000000..3b4a1be1aebdf --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_integrations.snapshot.json @@ -0,0 +1,1117 @@ +{ + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_create_execute_api_vpc_endpoint": { + "recorded-date": "15-04-2024, 23:07:07", + "recorded-content": { + "endpoint-details": { + "CreationTimestamp": "timestamp", + "DnsEntries": [ + { + "DnsName": "", + "HostedZoneId": "" + } + ], + "DnsOptions": { + "DnsRecordIpType": "ipv4" + }, + "Groups": [ + { + "GroupId": "", + "GroupName": "" + } + ], + "IpAddressType": "ipv4", + "OwnerId": "111111111111", + "PolicyDocument": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Principal": "*", + "Resource": "*" + } + ] + }, + "PrivateDnsEnabled": true, + "RequesterManaged": false, + "RouteTableIds": [], + "ServiceName": "com.amazonaws..execute-api", + "State": "available", + "Tags": [], + "VpcEndpointId": "", + "VpcEndpointType": "Interface", + "VpcId": "" + }, + "api-details": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "types": [ + "PRIVATE" + ], + "vpcEndpointIds": [ + "" + ] + }, + "id": "", + "name": "", + "policy": { + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Principal": "*", + "Resource": "arn::execute-api::111111111111:/*" + } + ], + "Version": "2012-10-17" + }, + "rootResourceId": "", + "tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_responses": { + "recorded-date": "26-05-2023, 19:44:45", + "recorded-content": { + "put-method-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-response-get": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration-get": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration-response-get": { + "responseTemplates": {}, + "selectionPattern": "2\\d{2}", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-integration-response-get": { + "responseTemplates": {}, + "selectionPattern": "2\\d{2}", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-method-get": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "integrationResponses": { + "200": { + "responseTemplates": {}, + "selectionPattern": "2\\d{2}", + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deploy": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "delete-integration-response-get": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-method-get-after-int-resp-delete": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-method-put": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "PUT", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-response-put": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration-put": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration-response-put": { + "contentHandling": "CONVERT_TO_BINARY", + "selectionPattern": "2\\d{2}", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-integration-response-put": { + "contentHandling": "CONVERT_TO_BINARY", + "selectionPattern": "2\\d{2}", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_response_with_response_template": { + "recorded-date": "30-05-2024, 16:15:58", + "recorded-content": { + "get-integration-response": { + "responseTemplates": { + "application/json": { + "data": "test" + } + }, + "selectionPattern": "foobar", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_validation": { + "recorded-date": "06-06-2024, 12:23:04", + "recorded-content": { + "required-integration-method-HTTP": { + "Error": { + "Code": "BadRequestException", + "Message": "Enumeration value for HttpMethod must be non-empty" + }, + "message": "Enumeration value for HttpMethod must be non-empty", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "required-integration-method-HTTP_PROXY": { + "Error": { + "Code": "BadRequestException", + "Message": "Enumeration value for HttpMethod must be non-empty" + }, + "message": "Enumeration value for HttpMethod must be non-empty", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "required-integration-method-AWS": { + "Error": { + "Code": "BadRequestException", + "Message": "Enumeration value for HttpMethod must be non-empty" + }, + "message": "Enumeration value for HttpMethod must be non-empty", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "not-required-integration-method-MOCK": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "http-method-HTTP": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP", + "uri": "http://example.com", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "http-method-HTTP_PROXY": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP_PROXY", + "uri": "http://example.com", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "aws-integration-AWS": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "arn::iam::111111111111:role/service-role/testfunction-role-oe783psq", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path/b/k", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "aws-integration-type-AWS": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::111111111111:function:MyLambda/invocations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "aws-integration-type-AWS_PROXY": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn::apigateway::lambda:path/2015-03-31/functions/arn::lambda::111111111111:function:MyLambda/invocations", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "no-s3-support-AWS_PROXY": { + "Error": { + "Code": "BadRequestException", + "Message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations." + }, + "message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-uri-HTTP": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid HTTP endpoint specified for URI" + }, + "message": "Invalid HTTP endpoint specified for URI", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-uri-HTTP_PROXY": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid HTTP endpoint specified for URI" + }, + "message": "Invalid HTTP endpoint specified for URI", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-uri-not-an-arn": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid ARN specified in the request" + }, + "message": "Invalid ARN specified in the request", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-uri-invalid-arn": { + "Error": { + "Code": "BadRequestException", + "Message": "AWS ARN for integration must contain path or action" + }, + "message": "AWS ARN for integration must contain path or action", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_http[HTTP]": { + "recorded-date": "11-12-2024, 15:28:47", + "recorded-content": { + "apigw-id": "", + "no-param-integration": { + "body": "", + "headers": { + "Accept": "application/json", + "Content-Length": "0", + "Host": "", + "User-Agent": "AmazonAPIGateway_", + "X-Amzn-Apigateway-Api-Id": "", + "X-Amzn-Trace-Id": "" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "462", + "Content-Type": "application/json", + "Date": "", + "X-Amzn-Trace-Id": "", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "full-integration": { + "body": { + "Accept": "application/json", + "Accept-Charset": "UTF-8", + "Accept-Encoding": "br", + "Age": "request_Age", + "Authorization": "Unauthorized", + "Connection": "", + "Content-Encoding": "deflate", + "Content-Length": "", + "Content-MD5": "", + "Content-Type": "application/json", + "Date": "", + "Expect": "", + "Host": "", + "Max-Forwards": "", + "Pragma": "cache", + "Proxy-Authenticate": "", + "Range": "bytes=500-999", + "Referer": "https://example.com/", + "Server": "", + "TE": "", + "Transfer-Encoding": "", + "Trailer": "", + "Upgrade": "", + "User-Agent": "localStack/0.0", + "Via": "", + "Warn": "299 localStack/0.0", + "WWW-Authenticate": "" + }, + "headers": { + "Accept": "text/html", + "Accept-Charset": "UTF-16", + "Accept-Encoding": "zstd", + "Age": "request_params_age", + "Authorization": "request_params_authorization", + "Content-Encoding": "compress", + "Content-Length": "648", + "Content-Md5": "request_params_Content-MD5", + "Content-Type": "application/json", + "Date": "", + "Host": "", + "Max-Forwards": "2", + "Pragma": "no-cache", + "Range": "bytes=0-499", + "Referer": "https://example.com/page", + "Server": "https://example.com/page", + "Trailer": "user-agent", + "Upgrade": "HTTP/2.0", + "User-Agent": "Override-Agent", + "Warn": "110 anderson/1.3.37 \"Response is stale\"", + "Www-Authenticate": "Basic YWxhZGRpbjpvcGVuc2VzYW1l", + "X-Amzn-Apigateway-Api-Id": "", + "X-Amzn-Trace-Id": "" + }, + "response-headers": { + "Accept": "response_param_Accept", + "Accept-Charset": "response_param_Accept-Charset", + "Accept-Encoding": "response_param_Accept-Encoding", + "Age": "response_param_Age", + "Connection": "close", + "Content-Encoding": "response_param_Content-Encoding", + "Content-Length": "2739", + "Content-Type": "response_param_Content-Type", + "Date": "", + "Pragma": "response_param_Pragma", + "Range": "response_param_Range", + "Referer": "response_param_Referer", + "TE": "response_param_TE", + "Via": "", + "Warn": "response_param_Warn", + "X-Amzn-Trace-Id": "", + "x-amz-apigw-id": "", + "x-amzn-Remapped-Authorization": "response_param_Authorization", + "x-amzn-Remapped-Connection": "response_param_Connection", + "x-amzn-Remapped-Content-Length": "response_param_Content-Length", + "x-amzn-Remapped-Content-MD5": "response_param_Content-MD5", + "x-amzn-Remapped-Date": "", + "x-amzn-Remapped-Expect": "response_param_Expect", + "x-amzn-Remapped-Host": "response_param_Host", + "x-amzn-Remapped-Max-Forwards": "response_param_Max-Forwards", + "x-amzn-Remapped-Proxy-Authenticate": "response_param_Proxy-Authenticate", + "x-amzn-Remapped-Server": "response_param_Server", + "x-amzn-Remapped-Trailer": "response_param_Trailer", + "x-amzn-Remapped-Upgrade": "response_param_Upgrade", + "x-amzn-Remapped-User-Agent": "response_param_User-Agent", + "x-amzn-Remapped-WWW-Authenticate": "response_param_WWW-Authenticate", + "x-amzn-RequestId": "" + } + }, + "content-length": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "transfer-encoding": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_http[HTTP_PROXY]": { + "recorded-date": "11-12-2024, 15:29:02", + "recorded-content": { + "apigw-id": "", + "no-param-integration": { + "body": "", + "headers": { + "Accept": "application/json", + "Accept-Charset": "UTF-8", + "Accept-Encoding": "br", + "Age": "request_Age", + "Authorization": "Unauthorized", + "Content-Length": "0", + "Content-Type": "application/json", + "Date": "", + "Host": "", + "Pragma": "cache", + "Range": "bytes=500-999", + "Referer": "https://example.com/", + "User-Agent": "localStack/0.0", + "Warn": "299 localStack/0.0", + "X-Amzn-Apigateway-Api-Id": "", + "X-Amzn-Trace-Id": "" + }, + "response-headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "*", + "Connection": "close", + "Content-Length": "790", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-Remapped-Connection": "keep-alive", + "x-amzn-Remapped-Content-Length": "790", + "x-amzn-Remapped-Date": "", + "x-amzn-Remapped-Server": "gunicorn/19.9.0", + "x-amzn-RequestId": "" + } + }, + "full-integration": { + "body": "", + "headers": { + "Accept": "text/html", + "Accept-Charset": "UTF-16", + "Accept-Encoding": "zstd", + "Age": "request_params_age", + "Authorization": "request_params_authorization", + "Content-Encoding": "compress", + "Content-Length": "0", + "Content-Md5": "request_params_Content-MD5", + "Content-Type": "application/json", + "Date": "", + "Host": "", + "Max-Forwards": "2", + "Pragma": "no-cache", + "Range": "bytes=0-499", + "Referer": "https://example.com/page", + "Server": "https://example.com/page", + "Trailer": "user-agent", + "Upgrade": "HTTP/2.0", + "User-Agent": "Override-Agent", + "Warn": "110 anderson/1.3.37 \"Response is stale\"", + "Www-Authenticate": "Basic YWxhZGRpbjpvcGVuc2VzYW1l", + "X-Amzn-Apigateway-Api-Id": "", + "X-Amzn-Trace-Id": "" + }, + "response-headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "*", + "Connection": "close", + "Content-Length": "1188", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-Remapped-Connection": "keep-alive", + "x-amzn-Remapped-Content-Length": "1188", + "x-amzn-Remapped-Date": "", + "x-amzn-Remapped-Server": "gunicorn/19.9.0", + "x-amzn-RequestId": "" + } + }, + "content-length": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "transfer-encoding": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_aws[AWS]": { + "recorded-date": "11-12-2024, 15:29:40", + "recorded-content": { + "apigw-id": "", + "no-param-integration": { + "body": {}, + "headers": null, + "response-headers": { + "Connection": "close", + "Content-Length": "59", + "Content-Type": "application/json", + "Date": "", + "X-Amzn-Trace-Id": "", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "full-integration": { + "body": { + "Accept": "application/json", + "Accept-Charset": "UTF-8", + "Accept-Encoding": "br", + "Age": "request_Age", + "Authorization": "Unauthorized", + "Connection": "", + "Content-Encoding": "deflate", + "Content-Length": "", + "Content-MD5": "", + "Content-Type": "application/json", + "Date": "", + "Expect": "", + "Host": "", + "Max-Forwards": "", + "Pragma": "cache", + "Proxy-Authenticate": "", + "Range": "bytes=500-999", + "Referer": "https://example.com/", + "Server": "", + "TE": "", + "Transfer-Encoding": "", + "Trailer": "", + "Upgrade": "", + "User-Agent": "localStack/0.0", + "Via": "", + "Warn": "299 localStack/0.0", + "WWW-Authenticate": "" + }, + "headers": null, + "response-headers": { + "Accept": "response_param_Accept", + "Accept-Charset": "response_param_Accept-Charset", + "Accept-Encoding": "response_param_Accept-Encoding", + "Age": "response_param_Age", + "Connection": "close", + "Content-Encoding": "response_param_Content-Encoding", + "Content-Length": "839", + "Content-Type": "response_param_Content-Type", + "Date": "", + "Pragma": "response_param_Pragma", + "Range": "response_param_Range", + "Referer": "response_param_Referer", + "TE": "response_param_TE", + "Via": "", + "Warn": "response_param_Warn", + "X-Amzn-Trace-Id": "", + "x-amz-apigw-id": "", + "x-amzn-Remapped-Authorization": "response_param_Authorization", + "x-amzn-Remapped-Connection": "response_param_Connection", + "x-amzn-Remapped-Content-Length": "response_param_Content-Length", + "x-amzn-Remapped-Content-MD5": "response_param_Content-MD5", + "x-amzn-Remapped-Date": "", + "x-amzn-Remapped-Expect": "response_param_Expect", + "x-amzn-Remapped-Host": "response_param_Host", + "x-amzn-Remapped-Max-Forwards": "response_param_Max-Forwards", + "x-amzn-Remapped-Proxy-Authenticate": "response_param_Proxy-Authenticate", + "x-amzn-Remapped-Server": "response_param_Server", + "x-amzn-Remapped-Trailer": "response_param_Trailer", + "x-amzn-Remapped-Upgrade": "response_param_Upgrade", + "x-amzn-Remapped-User-Agent": "response_param_User-Agent", + "x-amzn-Remapped-WWW-Authenticate": "response_param_WWW-Authenticate", + "x-amzn-RequestId": "" + } + }, + "content-length": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "transfer-encoding": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "authorization": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "connection": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "expect": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "proxy-authenticate": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "te": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_aws[AWS_PROXY]": { + "recorded-date": "11-12-2024, 15:29:56", + "recorded-content": { + "apigw-id": "", + "no-param-integration": { + "body": null, + "headers": { + "Accept": "application/json", + "Accept-Charset": "UTF-8", + "Accept-Encoding": "br", + "Age": "request_Age", + "Authorization": "Unauthorized", + "Content-Encoding": "deflate", + "Content-Type": "application/json", + "Date": "", + "Host": "", + "Pragma": "cache", + "Range": "bytes=500-999", + "Referer": "https://example.com/", + "User-Agent": "localStack/0.0", + "Via": "", + "Warn": "299 localStack/0.0", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "2336", + "Content-Type": "application/json", + "Date": "", + "X-Amzn-Trace-Id": "", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "full-integration": { + "body": null, + "headers": { + "Accept": "application/json", + "Accept-Charset": "UTF-8", + "Accept-Encoding": "br", + "Age": "request_Age", + "Authorization": "Unauthorized", + "Content-Encoding": "deflate", + "Content-Type": "application/json", + "Date": "", + "Host": "", + "Pragma": "cache", + "Range": "bytes=500-999", + "Referer": "https://example.com/", + "User-Agent": "localStack/0.0", + "Via": "", + "Warn": "299 localStack/0.0", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "2320", + "Content-Type": "application/json", + "Date": "", + "X-Amzn-Trace-Id": "", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "content-length": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "transfer-encoding": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "authorization": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "connection": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "expect": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "proxy-authenticate": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + }, + "te": { + "body": { + "message": "Internal server error" + }, + "response-headers": { + "Connection": "close", + "Content-Length": "36", + "Content-Type": "application/json", + "Date": "", + "x-amz-apigw-id": "", + "x-amzn-ErrorType": "InternalServerErrorException", + "x-amzn-RequestId": "" + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_request_overrides_in_response_template": { + "recorded-date": "06-11-2024, 23:09:04", + "recorded-content": { + "invoke-path1": { + "response": "path was path one" + }, + "invoke-path2": { + "response": "path was path two" + }, + "invoke-path-else": { + "response": "this is the else clause" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_path_param": { + "recorded-date": "29-11-2024, 19:27:54", + "recorded-content": { + "integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.path.integrationPath": "method.request.path.testPath" + }, + "requestTemplates": { + "application/json": "{statusCode: 200}" + }, + "timeoutInMillis": 29000, + "type": "MOCK", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_response_override_in_request_template[True]": { + "recorded-date": "16-05-2025, 10:22:21", + "recorded-content": { + "response": { + "body": { + "custom": "is also passed around", + "fooHeader": "bar", + "statusOverride": "444" + }, + "status_code": 444 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_response_override_in_request_template[False]": { + "recorded-date": "16-05-2025, 10:22:27", + "recorded-content": { + "response": { + "body": "b''", + "status_code": 444 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_vtl_map_assignation": { + "recorded-date": "29-05-2025, 15:49:45", + "recorded-content": { + "response": { + "body": { + "value": "bar", + "value2": "putBar", + "value3": "nestedFoo" + }, + "status_code": 200 + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_integrations.validation.json b/tests/aws/services/apigateway/test_apigateway_integrations.validation.json new file mode 100644 index 0000000000000..93c003bd54660 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_integrations.validation.json @@ -0,0 +1,41 @@ +{ + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_aws[AWS]": { + "last_validated_date": "2024-12-11T15:29:38+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_aws[AWS_PROXY]": { + "last_validated_date": "2024-12-11T15:29:54+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_http[HTTP]": { + "last_validated_date": "2024-12-11T15:28:46+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::TestApiGatewayHeaderRemapping::test_apigateway_header_remapping_http[HTTP_PROXY]": { + "last_validated_date": "2024-12-11T15:28:54+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_create_execute_api_vpc_endpoint": { + "last_validated_date": "2024-04-15T23:07:07+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_path_param": { + "last_validated_date": "2024-11-29T19:27:54+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_request_overrides_in_response_template": { + "last_validated_date": "2024-11-06T23:09:04+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_response_override_in_request_template[False]": { + "last_validated_date": "2025-05-16T10:22:27+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_response_override_in_request_template[True]": { + "last_validated_date": "2025-05-16T10:22:21+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_integration_mock_with_vtl_map_assignation": { + "last_validated_date": "2025-05-29T15:49:45+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_response_with_response_template": { + "last_validated_date": "2024-05-30T16:15:58+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_responses": { + "last_validated_date": "2023-05-26T17:44:45+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_integrations.py::test_put_integration_validation": { + "last_validated_date": "2024-06-06T12:23:04+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_kinesis.py b/tests/aws/services/apigateway/test_apigateway_kinesis.py new file mode 100644 index 0000000000000..d5bda8c82c5ae --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_kinesis.py @@ -0,0 +1,105 @@ +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.http import safe_requests as requests +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url +from tests.aws.services.apigateway.conftest import DEFAULT_STAGE_NAME + +KINESIS_PUT_RECORDS_INTEGRATION = """{ + "StreamName": "%s", + "Records": [ + #set( $numRecords = $input.path('$.records').size() ) + #if($numRecords > 0) + #set( $maxIndex = $numRecords - 1 ) + #foreach( $idx in [0..$maxIndex] ) + #set( $elem = $input.path("$.records[${idx}]") ) + #set( $elemJsonB64 = $util.base64Encode($elem.data) ) + { + "Data": "$elemJsonB64", + "PartitionKey": #if( $foo.bar.stuff != '')"$elem.partitionKey"#else"$elemJsonB64.length()"#end + }#if($foreach.hasNext),#end + #end + #end + ] +}""" + +KINESIS_PUT_RECORD_INTEGRATION = """ +{ + "StreamName": "%s", + "Data": "$util.base64Encode($input.body)", + "PartitionKey": "test" +}""" + + +# PutRecord does not return EncryptionType, but it's documented as such. +# xxx requires further investigation +@pytest.mark.parametrize("action", ("PutRecord", "PutRecords")) +@markers.snapshot.skip_snapshot_verify(paths=["$..EncryptionType", "$..ChildShards"]) +@markers.aws.validated +def test_apigateway_to_kinesis( + kinesis_create_stream, + wait_for_stream_ready, + create_rest_api_with_integration, + snapshot, + region_name, + aws_client, + action, +): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.kinesis_api()) + + if action == "PutRecord": + template = KINESIS_PUT_RECORD_INTEGRATION + payload = {"kinesis": "snapshot"} + expected_key = "SequenceNumber" + else: + template = KINESIS_PUT_RECORDS_INTEGRATION + payload = { + "records": [ + {"data": '{"foo": "bar1"}'}, + {"data": '{"foo": "bar2"}'}, + {"data": '{"foo": "bar3"}'}, + ] + } + expected_key = "Records" + + # create stream + stream_name = f"kinesis-stream-{short_uid()}" + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + first_stream_shard_data = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["Shards"][0] + shard_id = first_stream_shard_data["ShardId"] + + # create REST API with Kinesis integration + integration_uri = f"arn:aws:apigateway:{region_name}:kinesis:action/{action}" + request_templates = {"application/json": template % stream_name} + api_id = create_rest_api_with_integration( + integration_uri=integration_uri, + req_templates=request_templates, + integration_type="AWS", + ) + + def _invoke_apigw_to_kinesis() -> dict: + url = api_invoke_url(api_id, stage=DEFAULT_STAGE_NAME, path="/test") + _response = requests.post(url, json=payload) + assert _response.ok + json_resp = _response.json() + assert expected_key in json_resp + return json_resp + + # push events to Kinesis via API + shard_iterator = aws_client.kinesis.get_shard_iterator( + StreamName=stream_name, ShardIteratorType="LATEST", ShardId=shard_id + )["ShardIterator"] + response = retry(_invoke_apigw_to_kinesis, retries=15, sleep=1) + snapshot.match("apigateway_response", response) + + # get records from stream + get_records_response = aws_client.kinesis.get_records(ShardIterator=shard_iterator) + snapshot.match("kinesis_records", get_records_response) diff --git a/tests/aws/services/apigateway/test_apigateway_kinesis.snapshot.json b/tests/aws/services/apigateway/test_apigateway_kinesis.snapshot.json new file mode 100644 index 0000000000000..4727b0774241e --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_kinesis.snapshot.json @@ -0,0 +1,77 @@ +{ + "tests/aws/services/apigateway/test_apigateway_kinesis.py::test_apigateway_to_kinesis[PutRecord]": { + "recorded-date": "20-11-2024, 05:29:53", + "recorded-content": { + "apigateway_response": { + "SequenceNumber": "", + "ShardId": "" + }, + "kinesis_records": { + "MillisBehindLatest": 0, + "NextShardIterator": "", + "Records": [ + { + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'{\"kinesis\": \"snapshot\"}'", + "PartitionKey": "test", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_kinesis.py::test_apigateway_to_kinesis[PutRecords]": { + "recorded-date": "20-11-2024, 06:33:51", + "recorded-content": { + "apigateway_response": { + "FailedRecordCount": 0, + "Records": [ + { + "SequenceNumber": "", + "ShardId": "" + }, + { + "SequenceNumber": "", + "ShardId": "" + }, + { + "SequenceNumber": "", + "ShardId": "" + } + ] + }, + "kinesis_records": { + "MillisBehindLatest": 0, + "NextShardIterator": "", + "Records": [ + { + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'{\"foo\": \"bar1\"}'", + "PartitionKey": "20", + "SequenceNumber": "" + }, + { + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'{\"foo\": \"bar2\"}'", + "PartitionKey": "20", + "SequenceNumber": "" + }, + { + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'{\"foo\": \"bar3\"}'", + "PartitionKey": "20", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_kinesis.validation.json b/tests/aws/services/apigateway/test_apigateway_kinesis.validation.json new file mode 100644 index 0000000000000..d6e6bf9c6f0cb --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_kinesis.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/apigateway/test_apigateway_kinesis.py::test_apigateway_to_kinesis[PutRecord]": { + "last_validated_date": "2024-11-20T05:29:53+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_kinesis.py::test_apigateway_to_kinesis[PutRecords]": { + "last_validated_date": "2024-11-20T06:33:51+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_lambda.py b/tests/aws/services/apigateway/test_apigateway_lambda.py new file mode 100644 index 0000000000000..8aa53aaca9890 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_lambda.py @@ -0,0 +1,1613 @@ +import base64 +import json +import os +import time + +import pytest +import requests +from botocore.exceptions import ClientError + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import poll_condition, retry +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url, create_rest_resource +from tests.aws.services.apigateway.conftest import ( + APIGATEWAY_ASSUME_ROLE_POLICY, + APIGATEWAY_LAMBDA_POLICY, + is_next_gen_api, +) +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_AWS_PROXY, + TEST_LAMBDA_AWS_PROXY_FORMAT, + TEST_LAMBDA_HTTP_RUST, + TEST_LAMBDA_MAPPING_RESPONSES, + TEST_LAMBDA_PYTHON_ECHO, + TEST_LAMBDA_PYTHON_SELECT_PATTERN, +) + +THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +REQUEST_TEMPLATE_VM = os.path.join(THIS_FOLDER, "../../files/request-template.vm") +RESPONSE_TEMPLATE_VM = os.path.join(THIS_FOLDER, "../../files/response-template.vm") + +CLOUDFRONT_SKIP_HEADERS = [ + "$..Via", + "$..X-Amz-Cf-Id", + "$..X-Amz-Cf-Pop", + "$..X-Cache", + "$..CloudFront-Forwarded-Proto", + "$..CloudFront-Is-Desktop-Viewer", + "$..CloudFront-Is-Mobile-Viewer", + "$..CloudFront-Is-SmartTV-Viewer", + "$..CloudFront-Is-Tablet-Viewer", + "$..CloudFront-Viewer-ASN", + "$..CloudFront-Viewer-Country", +] + +LAMBDA_RESPONSE_FROM_BODY = """ +import json +import base64 +def handler(event, context, *args): + body = event["body"] + if event.get("isBase64Encoded"): + body = base64.b64decode(body) + return json.loads(body) +""" + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=CLOUDFRONT_SKIP_HEADERS) +@markers.snapshot.skip_snapshot_verify( + condition=lambda: not is_next_gen_api(), + paths=[ + "$..body", + "$..Accept", + "$..accept", + "$..Content-Length", + "$..Accept-Encoding", + "$..Connection", + "$..accept-encoding", + "$..x-localstack-edge", + "$..pathParameters", + "$..requestContext.authorizer", + "$..requestContext.deploymentId", + "$..requestContext.domainName", + "$..requestContext.extendedRequestId", + "$..requestContext.identity.accessKey", + "$..requestContext.identity.accountId", + "$..requestContext.identity.caller", + "$..requestContext.identity.cognitoAuthenticationProvider", + "$..requestContext.identity.cognitoAuthenticationType", + "$..requestContext.identity.cognitoIdentityId", + "$..requestContext.identity.cognitoIdentityPoolId", + "$..requestContext.identity.principalOrgId", + "$..requestContext.identity.user", + "$..requestContext.identity.userArn", + "$..stageVariables", + "$..X-Amzn-Trace-Id", + "$..X-Forwarded-For", + "$..X-Forwarded-Port", + "$..X-Forwarded-Proto", + ], +) +def test_lambda_aws_proxy_integration( + create_rest_apigw, create_lambda_function, create_role_with_policy, snapshot, aws_client +): + function_name = f"test-function-{short_uid()}" + stage_name = "stage" + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.apigateway_proxy_event()) + # TODO: update global transformers, but we will need to regenerate all snapshots at once + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.jsonpath("$..headers.Host", value_replacement="host"), + snapshot.transform.jsonpath("$..multiValueHeaders.Host[0]", value_replacement="host"), + snapshot.transform.key_value( + "X-Forwarded-For", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value( + "X-Forwarded-Port", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value( + "X-Forwarded-Proto", + value_replacement="", + reference_replacement=False, + ), + ], + priority=-1, + ) + + # create lambda + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_AWS_PROXY, + handler="lambda_aws_proxy.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + # create rest api + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Integration test API", + ) + # use a regex transform as create_rest_apigw fixture does not return the original response + snapshot.add_transformer(snapshot.transform.regex(api_id, replacement=""), priority=-1) + resource_id_proxy = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="{proxy+}" + )["id"] + resource_id_hardcoded = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="hardcoded" + )["id"] + for resource_id in (resource_id_proxy, resource_id_hardcoded): + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{aws_client.apigateway.meta.region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + credentials=role_arn, + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + def get_invoke_url(path: str) -> str: + return api_invoke_url( + api_id=api_id, + stage=stage_name, + path=path, + ) + + def invoke_api(url): + # use test header with different casing to check if it is preserved in the proxy payload + # authorization is a weird case, it will get Pascal cased by default + _response = requests.get( + url, + headers={ + "User-Agent": "python-requests/testing", + "tEsT-HEADeR": "aValUE", + "authorization": "random-value", + }, + verify=False, + ) + if not _response.ok: + print(f"{_response.content=}") + assert _response.status_code == 200 + return _response + + invocation_url = get_invoke_url(path="/proxy-value") + # retry is necessary against AWS, probably IAM permission delay + response = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("invocation-payload-without-trailing-slash", response.json()) + + # invoke rest api with trailing slash + invocation_url = get_invoke_url(path="/proxy-value/") + response_trailing_slash = invoke_api(url=invocation_url) + snapshot.match("invocation-payload-with-trailing-slash", response_trailing_slash.json()) + + # invoke rest api with double slash in proxy param + invocation_url = get_invoke_url(path="/proxy-value//double-slash") + response_double_slash = invoke_api(url=invocation_url) + snapshot.match("invocation-payload-with-double-slash", response_double_slash.json()) + + # invoke rest api with prepended slash to the stage (///) + invocation_url = get_invoke_url(path="/proxy-value") + double_slash_before_stage = invocation_url.replace(f"/{stage_name}/", f"//{stage_name}/") + response_prepend_slash = invoke_api(url=double_slash_before_stage) + snapshot.match( + "invocation-payload-with-prepended-slash-to-stage", response_prepend_slash.json() + ) + + # invoke rest api with prepended slash + slash_between_stage_and_path = get_invoke_url(path="//proxy-value") + response_prepend_slash = invoke_api(url=slash_between_stage_and_path) + snapshot.match("invocation-payload-with-prepended-slash", response_prepend_slash.json()) + + response_no_trailing_slash = invoke_api(url=f"{invocation_url}?urlparam=test") + snapshot.match( + "invocation-payload-without-trailing-slash-and-query-params", + response_no_trailing_slash.json(), + ) + + response_trailing_slash_param = invoke_api(url=f"{invocation_url}/?urlparam=test") + snapshot.match( + "invocation-payload-with-trailing-slash-and-query-params", + response_trailing_slash_param.json(), + ) + + # invoke rest api with encoded information in URL path + path_encoded_emails = "user/test%2Balias@gmail.com/plus/test+alias@gmail.com" + response_path_encoding = invoke_api(url=f"{invocation_url}/api/{path_encoded_emails}") + snapshot.match( + "invocation-payload-with-path-encoded-email", + response_path_encoding.json(), + ) + + # invoke rest api with encoded information in URL params + url_params = "&".join( + [ + "dateTimeOffset=2023-06-12T18:05:10.123456+00:00", + "email=test%2Balias@gmail.com", + "plus=test+alias@gmail.com", + "url=https://www.google.com/", + "whitespace=foo bar", + "zhash=abort/#", + "ignored=this-does-not-appear-after-the-hash", + ] + ) + response_params_encoding = invoke_api(url=f"{invocation_url}/api?{url_params}") + snapshot.match( + "invocation-payload-with-params-encoding", + response_params_encoding.json(), + ) + + def invoke_api_with_multi_value_header(url): + headers = { + "Content-Type": "application/json;charset=utf-8", + "aUThorization": "Bearer token123;API key456", # test the casing of the Authorization header + "User-Agent": "python-requests/testing", + } + + params = {"category": ["electronics", "books"], "price": ["10", "20", "30"]} + response = requests.post( + url, + data=json.dumps({"message": "hello world"}), + headers=headers, + params=params, + verify=False, + ) + assert response.ok + return response + + responses = retry(invoke_api_with_multi_value_header, sleep=2, retries=10, url=invocation_url) + snapshot.match("invocation-payload-with-params-encoding-multi", responses.json()) + + # invoke the hardcoded path with prepended slashes + invocation_url_hardcoded = api_invoke_url( + api_id=api_id, + stage=stage_name, + path="//hardcoded", + ) + response_hardcoded = retry(invoke_api, sleep=2, retries=10, url=invocation_url_hardcoded) + snapshot.match("invocation-hardcoded", response_hardcoded.json()) + + +@markers.aws.validated +def test_put_integration_aws_proxy_uri( + aws_client, + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + snapshot, + region_name, +): + api_id, _, root_resource_id = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="APIGW test PutIntegration AWS_PROXY URI", + ) + function_name = f"function-{short_uid()}" + + # create lambda + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_AWS_PROXY, + handler="lambda_aws_proxy.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root_resource_id, + httpMethod="ANY", + authorizationType="NONE", + ) + + default_params = { + "restApiId": api_id, + "resourceId": root_resource_id, + "httpMethod": "ANY", + "type": "AWS_PROXY", + "integrationHttpMethod": "POST", + "credentials": role_arn, + } + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration( + **default_params, + uri=lambda_arn, + ) + snapshot.match("put-integration-lambda-uri", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration( + **default_params, + uri=f"bad-arn:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + ) + snapshot.match("put-integration-wrong-arn", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration( + **default_params, + uri=f"arn:aws:apigateway:{region_name}:lambda:test/2015-03-31/functions/{lambda_arn}/invocations", + ) + snapshot.match("put-integration-wrong-type", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration( + **default_params, + uri=f"arn:aws:apigateway:{region_name}:firehose:path/2015-03-31/functions/{lambda_arn}/invocations", + ) + snapshot.match("put-integration-wrong-firehose", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.apigateway.put_integration( + **default_params, + uri=f"arn:aws:apigateway:{region_name}:lambda:path/random/value/{lambda_arn}/invocations", + ) + snapshot.match("put-integration-bad-lambda-arn", e.value.response) + + +@markers.aws.validated +def test_lambda_aws_proxy_integration_non_post_method( + create_rest_apigw, create_lambda_function, create_role_with_policy, snapshot, aws_client +): + function_name = f"test-function-{short_uid()}" + stage_name = "test" + + # create lambda + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_AWS_PROXY, + handler="lambda_aws_proxy.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + # create rest api + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Integration test API", + ) + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="{proxy+}" + )["id"] + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + type="AWS_PROXY", + integrationHttpMethod="GET", # GET is not allowed. We expect this to fail + uri=f"arn:aws:apigateway:{aws_client.apigateway.meta.region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + credentials=role_arn, + ) + + # Note: we are adding a GatewayResponse here to test a weird AWS bug: when the AWS_PROXY integration fails, it + # internally raises an IntegrationFailure error. + # However, in the documentation, it is written than this error should return 504. But like this test shows, when the + # user does not update the status code, it returns 500, unlike what the documentation and APIGW returns when calling + # `GetGatewayResponse`. + # TODO: in the future, write a specific test for this behavior + aws_client.apigateway.put_gateway_response( + restApiId=api_id, + responseType="INTEGRATION_FAILURE", + responseParameters={}, + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + # invoke rest api + invocation_url = api_invoke_url( + api_id=api_id, + stage=stage_name, + path="/test-path", + ) + + def invoke_api(url): + invoke_response = requests.get( + url, + headers={ + "User-Agent": "python-requests/testing", + }, + verify=False, + ) + assert invoke_response.status_code == 500 + return invoke_response + + # retry is necessary against AWS, probably IAM permission delay + response = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("invocation-payload-with-get-proxy-method", response.json()) + + +@markers.aws.validated +def test_lambda_aws_integration( + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + snapshot, + aws_client, + region_name, +): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("cacheNamespace"), + snapshot.transform.key_value("credentials"), + snapshot.transform.key_value("uri"), + ] + ) + function_name = f"test-{short_uid()}" + stage_name = "api" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + target_uri = arns.apigateway_invocations_arn(lambda_arn, region_name) + + api_id, _, root = create_rest_apigw(name=f"test-api-{short_uid()}") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="test" + ) + + response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + snapshot.match("put-method", response) + + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="AWS", + integrationHttpMethod="POST", + uri=target_uri, + credentials=role_arn, + ) + snapshot.match("put-integration", response) + + response = aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + snapshot.match("put-integration-response", response) + + response = aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + snapshot.match("put-method-response", response) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name, path="/test") + + def invoke_api(url): + _response = requests.post(url, data=json.dumps({"message": "hello world"}), verify=False) + assert _response.ok + response_content = _response.json() + assert response_content == {"message": "hello world"} + return response_content + + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("lambda-aws-integration", response_data) + + +@markers.aws.validated +def test_lambda_aws_integration_with_request_template( + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + snapshot, + aws_client, + region_name, +): + # this test almost follow + # https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-custom-integrations.html + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("cacheNamespace"), + snapshot.transform.key_value("credentials"), + snapshot.transform.key_value("uri"), + ] + ) + function_name = f"test-{short_uid()}" + stage_name = "api" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + target_uri = arns.apigateway_invocations_arn(lambda_arn, region_name) + + api_id, _, root = create_rest_apigw(name=f"test-api-{short_uid()}") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="test" + ) + + response = aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + requestParameters={ + "method.request.querystring.param1": False, + }, + ) + snapshot.match("put-method", response) + + response = aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + ) + snapshot.match("put-method-response", response) + + response = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="AWS", + uri=target_uri, + credentials=role_arn, + requestTemplates={"application/json": '{"param1": "$input.params(\'param1\')"}'}, + ) + snapshot.match("put-integration", response) + + response = aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="", + ) + snapshot.match("put-integration-response", response) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name, path="/test") + + def invoke_api(url): + _response = requests.get(url, verify=False) + assert _response.ok + content = _response.json() + assert content == {"param1": "foobar"} + return content + + invoke_param_1 = f"{invocation_url}?param1=foobar" + response_data = retry(invoke_api, sleep=2, retries=10, url=invoke_param_1) + snapshot.match("lambda-aws-integration-1", response_data) + + # additional checks from https://github.com/localstack/localstack/issues/5041 + # pass Signature param + invoke_param_2 = f"{invocation_url}?param1=foobar&Signature=1" + response_data = retry(invoke_api, sleep=2, retries=10, url=invoke_param_2) + snapshot.match("lambda-aws-integration-2", response_data) + + response = aws_client.apigateway.delete_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + ) + snapshot.match("delete-integration", response) + + with pytest.raises(ClientError) as e: + # This call should not be successful as the integration is deleted + aws_client.apigateway.get_integration( + restApiId=api_id, resourceId=resource_id, httpMethod="GET" + ) + snapshot.match("get-integration-after-delete", e.value.response) + + +@markers.aws.validated +def test_lambda_aws_integration_response_with_mapping_templates( + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + snapshot, + aws_client, + region_name, +): + function_name = f"test-{short_uid()}" + stage_name = "api" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_MAPPING_RESPONSES, + handler="lambda_mapping_responses.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + target_uri = arns.apigateway_invocations_arn(lambda_arn, region_name) + + api_id, _, root = create_rest_apigw(name=f"test-api-{short_uid()}") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="test" + ) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="400", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + integrationHttpMethod="POST", + type="AWS", + uri=target_uri, + credentials=role_arn, + requestTemplates={ + "application/json": load_file(REQUEST_TEMPLATE_VM), + }, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + selectionPattern="", + responseTemplates={ + "application/json": load_file(RESPONSE_TEMPLATE_VM), + }, + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + invocation_url = api_invoke_url(api_id=api_id, stage=stage_name, path="/test") + + def invoke_api(url, body, status_code): + _response = requests.post( + url, data=json.dumps(body), verify=False, headers={"Content-Type": "application/json"} + ) + content = _response.json() + + assert _response.status_code == status_code + return {"statusCode": _response.status_code, "body": content} + + response = retry( + invoke_api, + sleep=2, + retries=10, + url=invocation_url, + body={"httpStatus": "200"}, + status_code=202, + ) + snapshot.match("response-template-202", response) + response = retry( + invoke_api, + sleep=2, + retries=10, + url=invocation_url, + body={"httpStatus": "400", "errorMessage": "Test Bad request"}, + status_code=400, + ) + snapshot.match("response-template-400", response) + + +@markers.aws.validated +def test_lambda_selection_patterns( + aws_client, create_rest_apigw, create_lambda_function, create_role_with_policy, snapshot +): + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + function_name = f"test-{short_uid()}" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_SELECT_PATTERN, + handler="lambda_select_pattern.handler", + runtime=Runtime.python3_12, + ) + + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + target_uri = arns.apigateway_invocations_arn(lambda_arn, aws_client.apigateway.meta.region_name) + + api_id, _, root = create_rest_apigw(name=f"test-api-{short_uid()}") + resource_id, _ = create_rest_resource( + aws_client.apigateway, restApiId=api_id, parentId=root, pathPart="{statusCode}" + ) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="AWS", + uri=target_uri, + credentials=role_arn, + requestTemplates={"application/json": '{"statusCode": "$input.params(\'statusCode\')"}'}, + ) + + # apigw 200 response + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + ) + + # apigw 405 response + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="405", + ) + + # apigw 502 response + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="502", + ) + + # this is where selection patterns come into play + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + selectionPattern="", + ) + # 4xx + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="405", + selectionPattern=".*four hundred.*", + ) + + # 5xx + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="502", + selectionPattern=".+", + ) + + # assert that this does not get matched even though it's the status code returned by the Lambda, showing that + # AWS does match on the status code for this specific integration + # https://docs.aws.amazon.com/apigateway/latest/api/API_IntegrationResponse.html + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="504", + selectionPattern="200", + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName="dev") + + expected_codes = { + 200: 200, + 400: 405, + 500: 502, + } + + def invoke_api(status_code): + url = api_invoke_url( + api_id=api_id, + stage="dev", + path=f"/{status_code}", + ) + resp = requests.get(url, verify=False) + assert resp.status_code == expected_codes[status_code] + return resp + + # retry is necessary against AWS, probably IAM permission delay + status_codes = [200, 400, 500] + for status_code in status_codes: + response = retry(invoke_api, sleep=2, retries=10, status_code=status_code) + snapshot.match(f"lambda-selection-pattern-{status_code}", response.json()) + + +@markers.aws.validated +def test_lambda_aws_proxy_response_format( + create_rest_apigw, create_lambda_function, create_role_with_policy, aws_client +): + stage_name = "test" + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + # create 2 lambdas + function_name = f"test-function-{short_uid()}" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_AWS_PROXY_FORMAT, + handler="lambda_aws_proxy_format.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + + # create rest api + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Integration test API", + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="{proxy+}" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{aws_client.apigateway.meta.region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + credentials=role_arn, + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + format_types = [ + "no-body", + "only-headers", + "wrong-format", + "empty-response", + ] + # TODO: refactor the test to use a lambda that returns whatever we pass it to instead of pre-defined responses + for lambda_format_type in format_types: + # invoke rest api + invocation_url = api_invoke_url( + api_id=api_id, + stage=stage_name, + path=f"/{lambda_format_type}", + ) + + def invoke_api(url): + # use test header with different casing to check if it is preserved in the proxy payload + response = requests.get( + url, + headers={"User-Agent": "python-requests/testing"}, + verify=False, + ) + if lambda_format_type == "wrong-format": + assert response.status_code == 502 + else: + assert response.status_code == 200 + return response + + # retry is necessary against AWS, probably IAM permission delay + response = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + + if lambda_format_type in ("no-body", "only-headers", "empty-response"): + assert response.content == b"" + if lambda_format_type == "only-headers": + assert response.headers["test-header"] == "value" + + elif lambda_format_type == "wrong-format": + assert response.status_code == 502 + assert response.json() == {"message": "Internal server error"} + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + *CLOUDFRONT_SKIP_HEADERS, + # returned by LocalStack by default + "$..headers.Server", + ] +) +@markers.aws.validated +def test_aws_proxy_response_payload_format_validation( + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + aws_client, + region_name, + snapshot, +): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Via"), + snapshot.transform.key_value("X-Cache"), + snapshot.transform.key_value("x-amz-apigw-id"), + snapshot.transform.key_value("X-Amz-Cf-Pop"), + snapshot.transform.key_value("X-Amz-Cf-Id"), + snapshot.transform.key_value("X-Amzn-Trace-Id"), + snapshot.transform.key_value( + "Date", reference_replacement=False, value_replacement="" + ), + ] + ) + snapshot.add_transformers_list( + [ + snapshot.transform.jsonpath("$..headers.Host", value_replacement="host"), + snapshot.transform.jsonpath("$..multiValueHeaders.Host[0]", value_replacement="host"), + snapshot.transform.key_value( + "X-Forwarded-For", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value( + "X-Forwarded-Port", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value( + "X-Forwarded-Proto", + value_replacement="", + reference_replacement=False, + ), + ], + priority=-1, + ) + + stage_name = "test" + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + function_name = f"response-format-apigw-{short_uid()}" + create_function_response = create_lambda_function( + handler_file=LAMBDA_RESPONSE_FROM_BODY, + func_name=function_name, + runtime=Runtime.python3_12, + ) + # create invocation role + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + + # create rest api + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Integration test API", + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="{proxy+}" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + credentials=role_arn, + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + endpoint = api_invoke_url(api_id=api_id, path="/test", stage=stage_name) + + def _invoke( + body: dict | str, expected_status_code: int = 200, return_headers: bool = False + ) -> dict: + kwargs = {} + if body: + kwargs["json"] = body + + _response = requests.post( + url=endpoint, + headers={"User-Agent": "python/test"}, + verify=False, + **kwargs, + ) + + assert _response.status_code == expected_status_code + + try: + content = _response.json() + except json.JSONDecodeError: + content = _response.content.decode() + + dict_resp = {"content": content} + if return_headers: + dict_resp["headers"] = dict(_response.headers) + + return dict_resp + + response = retry(_invoke, sleep=1, retries=10, body={"statusCode": 200}) + snapshot.match("invoke-api-no-body", response) + + response = _invoke( + body={"statusCode": 200, "headers": {"test-header": "value", "header-bool": True}}, + return_headers=True, + ) + snapshot.match("invoke-api-with-headers", response) + + response = _invoke( + body={"statusCode": 200, "headers": None}, + return_headers=True, + ) + snapshot.match("invoke-api-with-headers-null", response) + + response = _invoke(body={"statusCode": 200, "wrongValue": "value"}, expected_status_code=502) + snapshot.match("invoke-api-wrong-format", response) + + response = _invoke(body={}, expected_status_code=502) + snapshot.match("invoke-api-empty-response", response) + + response = _invoke( + body={ + "statusCode": 200, + "body": base64.b64encode(b"test-data").decode(), + "isBase64Encoded": True, + } + ) + snapshot.match("invoke-api-b64-encoded-true", response) + + response = _invoke(body={"statusCode": 200, "body": base64.b64encode(b"test-data").decode()}) + snapshot.match("invoke-api-b64-encoded-false", response) + + response = _invoke( + body={"statusCode": 200, "multiValueHeaders": {"test-multi": ["value1", "value2"]}}, + return_headers=True, + ) + snapshot.match("invoke-api-multi-headers-valid", response) + + response = _invoke( + body={ + "statusCode": 200, + "multiValueHeaders": {"test-multi": ["value-multi"]}, + "headers": {"test-multi": "value-solo"}, + }, + return_headers=True, + ) + snapshot.match("invoke-api-multi-headers-overwrite", response) + + response = _invoke( + body={ + "statusCode": 200, + "multiValueHeaders": {"tesT-Multi": ["value-multi"]}, + "headers": {"test-multi": "value-solo"}, + }, + return_headers=True, + ) + snapshot.match("invoke-api-multi-headers-overwrite-casing", response) + + response = _invoke( + body={"statusCode": 200, "multiValueHeaders": {"test-multi-invalid": "value1"}}, + expected_status_code=502, + ) + snapshot.match("invoke-api-multi-headers-invalid", response) + + response = _invoke(body={"statusCode": "test"}, expected_status_code=502) + snapshot.match("invoke-api-invalid-status-code", response) + + response = _invoke(body={"statusCode": "201"}, expected_status_code=201) + snapshot.match("invoke-api-status-code-str", response) + + response = _invoke(body="justAString", expected_status_code=502) + snapshot.match("invoke-api-just-string", response) + + response = _invoke(body={"headers": {"test-header": "value"}}, expected_status_code=200) + snapshot.match("invoke-api-only-headers", response) + + +# Testing the integration with Rust to prevent future regression with strongly typed language integration +# TODO make the test compatible for ARM +@markers.aws.validated +@markers.only_on_amd64 +def test_lambda_rust_proxy_integration( + create_rest_apigw, create_lambda_function, create_iam_role_with_policy, aws_client, snapshot +): + function_name = f"test-rust-function-{short_uid()}" + api_gateway_name = f"api_gateway_{short_uid()}" + role_name = f"test_apigateway_role_{short_uid()}" + policy_name = f"test_apigateway_policy_{short_uid()}" + stage_name = "test" + first_name = f"test_name_{short_uid()}" + lambda_create_response = create_lambda_function( + func_name=function_name, + zip_file=load_file(TEST_LAMBDA_HTTP_RUST, mode="rb"), + handler="bootstrap.is.the.handler", + runtime="provided.al2", + ) + role_arn = create_iam_role_with_policy( + RoleName=role_name, + PolicyName=policy_name, + RoleDefinition=APIGATEWAY_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_LAMBDA_POLICY, + ) + lambda_arn = lambda_create_response["CreateFunctionResponse"]["FunctionArn"] + rest_api_id, _, _ = create_rest_apigw(name=api_gateway_name) + + root_resource_id = aws_client.apigateway.get_resources(restApiId=rest_api_id)["items"][0]["id"] + aws_client.apigateway.put_method( + restApiId=rest_api_id, + resourceId=root_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + aws_client.apigateway.put_method_response( + restApiId=rest_api_id, resourceId=root_resource_id, httpMethod="GET", statusCode="200" + ) + lambda_target_uri = arns.apigateway_invocations_arn( + lambda_uri=lambda_arn, region_name=aws_client.apigateway.meta.region_name + ) + aws_client.apigateway.put_integration( + restApiId=rest_api_id, + resourceId=root_resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="AWS_PROXY", + uri=lambda_target_uri, + credentials=role_arn, + ) + aws_client.apigateway.create_deployment(restApiId=rest_api_id, stageName=stage_name) + url = api_invoke_url(api_id=rest_api_id, stage=stage_name, path=f"/?first_name={first_name}") + + def _invoke_url(url): + invoker_response = requests.get(url) + assert invoker_response.status_code == 200 + return invoker_response + + result = retry(_invoke_url, retries=20, sleep=2, url=url) + assert result.text == f"Hello, {first_name}!" + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=CLOUDFRONT_SKIP_HEADERS) +@markers.snapshot.skip_snapshot_verify( + condition=lambda: not is_next_gen_api(), + paths=[ + "$..body", + "$..accept", + "$..Accept", + "$..accept-encoding", + "$..Accept-Encoding", + "$..Content-Length", + "$..Connection", + "$..user-Agent", + "$..User-Agent", + "$..x-localstack-edge", + "$..pathParameters", + "$..requestContext.authorizer", + "$..requestContext.deploymentId", + "$..requestContext.domainName", + "$..requestContext.extendedRequestId", + "$..requestContext.identity.accessKey", + "$..requestContext.identity.accountId", + "$..requestContext.identity.caller", + "$..requestContext.identity.cognitoAuthenticationProvider", + "$..requestContext.identity.cognitoAuthenticationType", + "$..requestContext.identity.cognitoIdentityId", + "$..requestContext.identity.cognitoIdentityPoolId", + "$..requestContext.identity.principalOrgId", + "$..requestContext.identity.user", + "$..requestContext.identity.userArn", + "$..stageVariables", + "$..X-Amzn-Trace-Id", + "$..X-Forwarded-For", + "$..X-Forwarded-Port", + "$..X-Forwarded-Proto", + ], +) +def test_lambda_aws_proxy_integration_request_data_mapping( + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + snapshot, + aws_client, + create_rest_api_with_integration, +): + function_name = f"test-function-{short_uid()}" + stage_name = "test" + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.apigateway_proxy_event()) + # TODO: update global transformers, but we will need to regenerate all snapshots at once + snapshot.add_transformer(snapshot.transform.key_value("rest_api_id")) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.jsonpath("$..headers.Host", value_replacement="host"), + snapshot.transform.jsonpath("$..multiValueHeaders.Host[0]", value_replacement="host"), + snapshot.transform.key_value( + "X-Forwarded-For", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value( + "X-Forwarded-Port", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value( + "X-Forwarded-Proto", + value_replacement="", + reference_replacement=False, + ), + ], + priority=-1, + ) + + # create lambda + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_AWS_PROXY, + handler="lambda_aws_proxy.handler", + runtime=Runtime.python3_12, + ) + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Integration test API", + ) + + snapshot.match("api_id", {"rest_api_id": api_id}) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="{pathVariable}" + )["id"] + + # This test is there to verify that AWS_PROXY does not use the requestParameters + req_parameters = { + "integration.request.header.headerVar": "method.request.header.foobar", + "integration.request.path.qsVar": "method.request.querystring.testVar", + "integration.request.path.pathVar": "method.request.path.pathVariable", + "integration.request.querystring.queryString": "method.request.querystring.testQueryString", + "integration.request.querystring.testQs": "method.request.querystring.testQueryString", + "integration.request.querystring.testEmptyQs": "method.request.header.emptyheader", + } + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + requestParameters=dict.fromkeys(req_parameters.values(), True), + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{aws_client.apigateway.meta.region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + credentials=role_arn, + requestParameters=req_parameters, + ) + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + stage_name = "test" + + invocation_url = api_invoke_url( + api_id=api_id, + stage=stage_name, + path="/foobar", + ) + + def invoke_api(url): + response = requests.post( + url, + data=json.dumps({"message": "hello world"}), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "foobar": "mapped-value", + "user-Agent": "test/integration", + "headerVar": "request-value", + }, + params={ + "testQueryString": "foo", + "testVar": "bar", + }, + verify=False, + ) + assert response.status_code == 200 + return { + "content": response.json(), + "status_code": response.status_code, + } + + # retry is necessary against AWS, probably IAM permission delay + invoke_response = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("http-proxy-invocation-data-mapping", invoke_response) + + +@markers.aws.validated +def test_aws_proxy_binary_response( + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + aws_client, + region_name, +): + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + timeout = 30 if is_aws_cloud() else 3 + + function_name = f"response-format-apigw-{short_uid()}" + create_function_response = create_lambda_function( + handler_file=LAMBDA_RESPONSE_FROM_BODY, + func_name=function_name, + runtime=Runtime.python3_12, + ) + # create invocation role + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + + # create rest api + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Integration test API", + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root, pathPart="{proxy+}" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + credentials=role_arn, + ) + + # this deployment does not have any `binaryMediaTypes` configured, so it should not return any binary data + stage_1 = "test" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_1) + endpoint = api_invoke_url(api_id=api_id, path="/test", stage=stage_1) + # Base64-encoded PNG image (example: 1x1 pixel transparent PNG) + image_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wIAAgkBAyMAlYwAAAAASUVORK5CYII=" + binary_data = base64.b64decode(image_base64) + + decoded_response = { + "statusCode": 200, + "body": image_base64, + "isBase64Encoded": True, + "headers": { + "Content-Type": "image/png", + "Cache-Control": "no-cache", + }, + } + + def _assert_invoke(accept: str | None, expect_binary: bool) -> bool: + headers = {"User-Agent": "python/test"} + if accept: + headers["Accept"] = accept + + _response = requests.post( + url=endpoint, + data=json.dumps(decoded_response), + headers=headers, + ) + if not _response.status_code == 200: + return False + + if expect_binary: + return _response.content == binary_data + else: + return _response.text == image_base64 + + # we poll that the API is returning the right data after deployment + poll_condition( + lambda: _assert_invoke(accept="image/png", expect_binary=False), timeout=timeout, interval=1 + ) + if is_aws_cloud(): + time.sleep(5) + + # we did not configure binaryMedias so the API is not returning binary data even if all conditions are met + assert _assert_invoke(accept="image/png", expect_binary=False) + + patch_operations = [ + {"op": "add", "path": "/binaryMediaTypes/image~1png"}, + # seems like wildcard with star on the left is not supported + {"op": "add", "path": "/binaryMediaTypes/*~1test"}, + ] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + # this deployment has `binaryMediaTypes` configured, so it should now return binary data if the client sends the + # right `Accept` header and the lambda returns the Content-Type + if is_aws_cloud(): + time.sleep(10) + stage_2 = "test2" + endpoint = api_invoke_url(api_id=api_id, path="/test", stage=stage_2) + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_2) + + # we poll that the API is returning the right data after deployment + poll_condition( + lambda: _assert_invoke(accept="image/png", expect_binary=True), timeout=timeout, interval=1 + ) + if is_aws_cloud(): + time.sleep(10) + + # all conditions are met + assert _assert_invoke(accept="image/png", expect_binary=True) + + # client is sending the wrong accept, so the API returns the base64 data + assert _assert_invoke(accept="image/jpg", expect_binary=False) + + # client is sending the wrong accept (wildcard), so the API returns the base64 data + assert _assert_invoke(accept="image/*", expect_binary=False) + + # wildcard on the left is not supported + assert _assert_invoke(accept="*/test", expect_binary=False) + + # client is sending an accept that matches the wildcard, but it does not work + assert _assert_invoke(accept="random/test", expect_binary=False) + + # Accept has to exactly match what is configured + assert _assert_invoke(accept="*/*", expect_binary=False) + + # client is sending a multiple accept, but AWS only checks the first one + assert _assert_invoke(accept="image/webp,image/png,*/*;q=0.8", expect_binary=False) + + # client is sending a multiple accept, but AWS only checks the first one, which is right + assert _assert_invoke(accept="image/png,image/*,*/*;q=0.8", expect_binary=True) + + # lambda is returning that the payload is not b64 encoded + decoded_response["isBase64Encoded"] = False + assert _assert_invoke(accept="image/png", expect_binary=False) + + patch_operations = [ + {"op": "add", "path": "/binaryMediaTypes/application~1*"}, + {"op": "add", "path": "/binaryMediaTypes/image~1jpg"}, + {"op": "remove", "path": "/binaryMediaTypes/*~1test"}, + ] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + if is_aws_cloud(): + # AWS starts returning 200, but then fails again with 403. Wait a bit for it to be stable + time.sleep(10) + + # this deployment has `binaryMediaTypes` configured, so it should now return binary data if the client sends the + # right `Accept` header + stage_3 = "test3" + endpoint = api_invoke_url(api_id=api_id, path="/test", stage=stage_3) + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_3) + decoded_response["isBase64Encoded"] = True + + # we poll that the API is returning the right data after deployment + poll_condition( + lambda: _assert_invoke(accept="image/png", expect_binary=True), timeout=timeout, interval=1 + ) + if is_aws_cloud(): + time.sleep(10) + + # different scenario with right side wildcard, all working + decoded_response["headers"]["Content-Type"] = "application/test" + assert _assert_invoke(accept="application/whatever", expect_binary=True) + assert _assert_invoke(accept="application/test", expect_binary=True) + assert _assert_invoke(accept="application/*", expect_binary=True) + + # lambda is returning a content-type that matches one binaryMediaType, but Accept matches another binaryMediaType + # it seems it does not matter, only Accept is checked + decoded_response["headers"]["Content-Type"] = "image/png" + assert _assert_invoke(accept="image/jpg", expect_binary=True) + + # lambda is returning a content-type that matches the wildcard, but Accept matches another binaryMediaType + decoded_response["headers"]["Content-Type"] = "application/whatever" + assert _assert_invoke(accept="image/png", expect_binary=True) + + # ContentType does not matter at all + decoded_response["headers"].pop("Content-Type") + assert _assert_invoke(accept="image/png", expect_binary=True) + + # bad Accept + assert _assert_invoke(accept="application", expect_binary=False) + + # no Accept + assert _assert_invoke(accept=None, expect_binary=False) + + # bad base64 + decoded_response["body"] = "èé+à)(" + bad_b64_response = requests.post( + url=endpoint, + data=json.dumps(decoded_response), + headers={"User-Agent": "python/test", "Accept": "image/png"}, + ) + assert bad_b64_response.status_code == 500 diff --git a/tests/aws/services/apigateway/test_apigateway_lambda.snapshot.json b/tests/aws/services/apigateway/test_apigateway_lambda.snapshot.json new file mode 100644 index 0000000000000..6cdf03ea63e3f --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_lambda.snapshot.json @@ -0,0 +1,1859 @@ +{ + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration": { + "recorded-date": "02-08-2024, 23:34:43", + "recorded-content": { + "invocation-payload-without-trailing-slash": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/proxy-value", + "pathParameters": { + "proxy": "proxy-value" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-trailing-slash": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/proxy-value/", + "pathParameters": { + "proxy": "proxy-value" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value/", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-double-slash": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/proxy-value//double-slash", + "pathParameters": { + "proxy": "proxy-value//double-slash" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value//double-slash", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-prepended-slash-to-stage": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/proxy-value", + "pathParameters": { + "proxy": "proxy-value" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-prepended-slash": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/proxy-value", + "pathParameters": { + "proxy": "proxy-value" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage//proxy-value", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-without-trailing-slash-and-query-params": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": { + "urlparam": [ + "test" + ] + }, + "path": "/proxy-value", + "pathParameters": { + "proxy": "proxy-value" + }, + "queryStringParameters": { + "urlparam": "test" + }, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-trailing-slash-and-query-params": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": { + "urlparam": [ + "test" + ] + }, + "path": "/proxy-value/", + "pathParameters": { + "proxy": "proxy-value" + }, + "queryStringParameters": { + "urlparam": "test" + }, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value/", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-path-encoded-email": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/proxy-value/api/user/test%2Balias@gmail.com/plus/test+alias@gmail.com", + "pathParameters": { + "proxy": "proxy-value/api/user/test%2Balias@gmail.com/plus/test+alias@gmail.com" + }, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value/api/user/test%2Balias@gmail.com/plus/test+alias@gmail.com", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-params-encoding": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": { + "dateTimeOffset": [ + "2023-06-12T18:05:10.123456 00:00" + ], + "email": [ + "test+alias@gmail.com" + ], + "plus": [ + "test alias@gmail.com" + ], + "url": [ + "https://www.google.com/" + ], + "whitespace": [ + "foo bar" + ], + "zhash": [ + "abort/" + ] + }, + "path": "/proxy-value/api", + "pathParameters": { + "proxy": "proxy-value/api" + }, + "queryStringParameters": { + "dateTimeOffset": "2023-06-12T18:05:10.123456 00:00", + "email": "test+alias@gmail.com", + "plus": "test alias@gmail.com", + "url": "https://www.google.com/", + "whitespace": "foo bar", + "zhash": "abort/" + }, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value/api", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-payload-with-params-encoding-multi": { + "body": { + "message": "hello world" + }, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "Bearer token123;API key456", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Content-Type": "application/json;charset=utf-8", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "" + }, + "httpMethod": "POST", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "Bearer token123;API key456" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Content-Type": [ + "application/json;charset=utf-8" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "" + }, + "multiValueQueryStringParameters": { + "category": [ + "electronics", + "books" + ], + "price": [ + "10", + "20", + "30" + ] + }, + "path": "/proxy-value", + "pathParameters": { + "proxy": "proxy-value" + }, + "queryStringParameters": { + "category": "books", + "price": "30" + }, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "POST", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage/proxy-value", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{proxy+}", + "stage": "stage" + }, + "resource": "/{proxy+}", + "stageVariables": null + }, + "invocation-hardcoded": { + "body": null, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Authorization": "random-value", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Host": "", + "User-Agent": "python-requests/testing", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": "aValUE" + }, + "httpMethod": "GET", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "Authorization": [ + "random-value" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Host": [ + "" + ], + "User-Agent": [ + "python-requests/testing" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "tEsT-HEADeR": [ + "aValUE" + ] + }, + "multiValueQueryStringParameters": null, + "path": "/hardcoded", + "pathParameters": null, + "queryStringParameters": null, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "GET", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "python-requests/testing", + "userArn": null + }, + "path": "/stage//hardcoded", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/hardcoded", + "stage": "stage" + }, + "resource": "/hardcoded", + "stageVariables": null + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration": { + "recorded-date": "31-05-2023, 23:11:42", + "recorded-content": { + "put-method": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration-response": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-response": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda-aws-integration": { + "message": "hello world" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration_with_request_template": { + "recorded-date": "31-05-2023, 23:09:38", + "recorded-content": { + "put-method": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "requestParameters": { + "method.request.querystring.param1": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-method-response": { + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "param1": "$input.params('param1')" + } + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-integration-response": { + "selectionPattern": "", + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda-aws-integration-1": { + "param1": "foobar" + }, + "lambda-aws-integration-2": { + "param1": "foobar" + }, + "delete-integration": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-integration-after-delete": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid Integration identifier specified" + }, + "message": "Invalid Integration identifier specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_selection_patterns": { + "recorded-date": "05-05-2025, 14:10:11", + "recorded-content": { + "lambda-selection-pattern-200": "Pass", + "lambda-selection-pattern-400": { + "errorMessage": "Error: Raising four hundred from within the Lambda function", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/lambda_select_pattern.py\", line 7, in handler\n raise Exception(\"Error: Raising four hundred from within the Lambda function\")\n" + ] + }, + "lambda-selection-pattern-500": { + "errorMessage": "Error: Raising five hundred from within the Lambda function", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/lambda_select_pattern.py\", line 9, in handler\n raise Exception(\"Error: Raising five hundred from within the Lambda function\")\n" + ] + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration_response_with_mapping_templates": { + "recorded-date": "17-09-2023, 09:11:22", + "recorded-content": { + "response-template-202": { + "body": { + "body": "noerror", + "statusCode": 200 + }, + "statusCode": 202 + }, + "response-template-400": { + "body": { + "body": "customerror", + "statusCode": 200 + }, + "statusCode": 400 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration_non_post_method": { + "recorded-date": "20-03-2024, 22:16:43", + "recorded-content": { + "invocation-payload-with-get-proxy-method": { + "message": "Internal server error" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration_request_data_mapping": { + "recorded-date": "22-07-2024, 22:52:30", + "recorded-content": { + "api_id": { + "rest_api_id": "" + }, + "http-proxy-invocation-data-mapping": { + "content": { + "body": { + "message": "hello world" + }, + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Content-Type": "application/json", + "Host": "", + "User-Agent": "test/integration", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "foobar": "mapped-value", + "headerVar": "request-value" + }, + "httpMethod": "POST", + "isBase64Encoded": false, + "multiValueHeaders": { + "Accept": [ + "application/json" + ], + "Accept-Encoding": [ + "gzip, deflate" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "" + ], + "CloudFront-Viewer-Country": [ + "" + ], + "Content-Type": [ + "application/json" + ], + "Host": [ + "" + ], + "User-Agent": [ + "test/integration" + ], + "Via": [ + "" + ], + "X-Amz-Cf-Id": [ + "" + ], + "X-Amzn-Trace-Id": [ + "" + ], + "X-Forwarded-For": "", + "X-Forwarded-Port": "", + "X-Forwarded-Proto": "", + "foobar": [ + "mapped-value" + ], + "headerVar": [ + "request-value" + ] + }, + "multiValueQueryStringParameters": { + "testQueryString": [ + "foo" + ], + "testVar": [ + "bar" + ] + }, + "path": "/foobar", + "pathParameters": { + "pathVariable": "foobar" + }, + "queryStringParameters": { + "testQueryString": "foo", + "testVar": "bar" + }, + "requestContext": { + "accountId": "111111111111", + "apiId": "", + "deploymentId": "", + "domainName": "", + "domainPrefix": "", + "extendedRequestId": "", + "httpMethod": "POST", + "identity": { + "accessKey": null, + "accountId": null, + "caller": null, + "cognitoAuthenticationProvider": null, + "cognitoAuthenticationType": null, + "cognitoIdentityId": null, + "cognitoIdentityPoolId": null, + "principalOrgId": null, + "sourceIp": "", + "user": null, + "userAgent": "test/integration", + "userArn": null + }, + "path": "/test/foobar", + "protocol": "HTTP/1.1", + "requestId": "", + "requestTime": "", + "requestTimeEpoch": "", + "resourceId": "", + "resourcePath": "/{pathVariable}", + "stage": "test" + }, + "resource": "/{pathVariable}", + "stageVariables": null + }, + "status_code": 200 + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_aws_proxy_response_payload_format_validation": { + "recorded-date": "15-11-2024, 17:48:06", + "recorded-content": { + "invoke-api-no-body": { + "content": "" + }, + "invoke-api-with-headers": { + "content": "", + "headers": { + "Connection": "keep-alive", + "Content-Length": "0", + "Content-Type": "application/json", + "Date": "", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "X-Amzn-Trace-Id": "", + "X-Cache": "", + "header-bool": "true", + "test-header": "value", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "invoke-api-with-headers-null": { + "content": "", + "headers": { + "Connection": "keep-alive", + "Content-Length": "0", + "Content-Type": "application/json", + "Date": "", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "X-Amzn-Trace-Id": "", + "X-Cache": "", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "invoke-api-wrong-format": { + "content": { + "message": "Internal server error" + } + }, + "invoke-api-empty-response": { + "content": { + "message": "Internal server error" + } + }, + "invoke-api-b64-encoded-true": { + "content": "dGVzdC1kYXRh" + }, + "invoke-api-b64-encoded-false": { + "content": "dGVzdC1kYXRh" + }, + "invoke-api-multi-headers-valid": { + "content": "", + "headers": { + "Connection": "keep-alive", + "Content-Length": "0", + "Content-Type": "application/json", + "Date": "", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "X-Amzn-Trace-Id": "", + "X-Cache": "", + "test-multi": "value1, value2", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "invoke-api-multi-headers-overwrite": { + "content": "", + "headers": { + "Connection": "keep-alive", + "Content-Length": "0", + "Content-Type": "application/json", + "Date": "", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "X-Amzn-Trace-Id": "", + "X-Cache": "", + "test-multi": "value-multi, value-solo", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "invoke-api-multi-headers-overwrite-casing": { + "content": "", + "headers": { + "Connection": "keep-alive", + "Content-Length": "0", + "Content-Type": "application/json", + "Date": "", + "Via": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "X-Amzn-Trace-Id": "", + "X-Cache": "", + "tesT-Multi": "value-multi, value-solo", + "x-amz-apigw-id": "", + "x-amzn-RequestId": "" + } + }, + "invoke-api-multi-headers-invalid": { + "content": { + "message": "Internal server error" + } + }, + "invoke-api-invalid-status-code": { + "content": { + "message": "Internal server error" + } + }, + "invoke-api-status-code-str": { + "content": "" + }, + "invoke-api-just-string": { + "content": { + "message": "Internal server error" + } + }, + "invoke-api-only-headers": { + "content": "" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_put_integration_aws_proxy_uri": { + "recorded-date": "03-03-2025, 12:58:39", + "recorded-content": { + "put-integration-lambda-uri": { + "Error": { + "Code": "BadRequestException", + "Message": "AWS ARN for integration must contain path or action" + }, + "message": "AWS ARN for integration must contain path or action", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-integration-wrong-arn": { + "Error": { + "Code": "BadRequestException", + "Message": "Invalid ARN specified in the request" + }, + "message": "Invalid ARN specified in the request", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-integration-wrong-type": { + "Error": { + "Code": "BadRequestException", + "Message": "AWS ARN for integration must contain path or action" + }, + "message": "AWS ARN for integration must contain path or action", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-integration-wrong-firehose": { + "Error": { + "Code": "BadRequestException", + "Message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations." + }, + "message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-integration-bad-lambda-arn": { + "Error": { + "Code": "BadRequestException", + "Message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations." + }, + "message": "Integrations of type 'AWS_PROXY' currently only supports Lambda function and Firehose stream invocations.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_lambda.validation.json b/tests/aws/services/apigateway/test_apigateway_lambda.validation.json new file mode 100644 index 0000000000000..c2a311dd64e4e --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_lambda.validation.json @@ -0,0 +1,38 @@ +{ + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_aws_proxy_binary_response": { + "last_validated_date": "2025-01-29T00:14:36+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_aws_proxy_response_payload_format_validation": { + "last_validated_date": "2024-11-15T17:48:06+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration": { + "last_validated_date": "2023-05-31T21:11:42+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration_response_with_mapping_templates": { + "last_validated_date": "2023-09-17T07:11:22+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_integration_with_request_template": { + "last_validated_date": "2023-05-31T21:09:38+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration": { + "last_validated_date": "2024-08-02T23:34:43+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration_non_post_method": { + "last_validated_date": "2024-07-10T15:43:36+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_integration_request_data_mapping": { + "last_validated_date": "2024-07-22T22:52:30+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_aws_proxy_response_format": { + "last_validated_date": "2024-02-23T18:39:48+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_rust_proxy_integration": { + "last_validated_date": "2024-05-31T19:17:51+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_lambda_selection_patterns": { + "last_validated_date": "2025-05-05T14:10:11+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_lambda.py::test_put_integration_aws_proxy_uri": { + "last_validated_date": "2025-03-03T12:58:39+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_lambda_cfn.py b/tests/aws/services/apigateway/test_apigateway_lambda_cfn.py new file mode 100644 index 0000000000000..f47524debf9c6 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_lambda_cfn.py @@ -0,0 +1,80 @@ +import aws_cdk as cdk +import aws_cdk.aws_apigateway as apigateway +import aws_cdk.aws_lambda as awslambda +import pytest + +from localstack.testing.pytest import markers + +FN_CODE = """ +import json +def handler(event, context): + return { + "statusCode": 200, + "body": json.dumps({ + "message": "Hello World!" + }) + } +""" + + +@markers.acceptance_test +class TestApigatewayLambdaIntegration: + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="APIGWtest") + + stack = cdk.Stack(infra.cdk_app, "ApiGatewayStack") + api = apigateway.RestApi(stack, "rest-api") + backend = awslambda.Function( + stack, + "backend", + runtime=awslambda.Runtime.PYTHON_3_10, + code=cdk.aws_lambda.Code.from_inline(FN_CODE), + handler="index.handler", + ) + resource = api.root.add_resource("v1") + resource.add_method("GET", apigateway.LambdaIntegration(backend)) + api.add_gateway_response( + "default-4xx-response", + type=apigateway.ResponseType.DEFAULT_4_XX, + response_headers={ + "Access-Control-Allow-Origin": "'*'", + }, + ) + + api.add_gateway_response( + "default-5xx-response", + type=apigateway.ResponseType.DEFAULT_5_XX, + response_headers={ + "Access-Control-Allow-Origin": "'*'", + }, + ) + + cdk.CfnOutput(stack, "ApiId", value=api.rest_api_id) + + with infra.provisioner() as prov: + yield prov + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..restapiEndpointC67DEFEA", + ] + ) + def test_scenario_validate_infra(self, aws_client, infrastructure, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("ApiId")) + outputs = infrastructure.get_stack_outputs(stack_name="ApiGatewayStack") + api_id = outputs["ApiId"] + apis = aws_client.apigateway.get_rest_api(restApiId=api_id) + assert apis["id"] == api_id + + resources = infrastructure.get_stack_outputs(stack_name="ApiGatewayStack") + snapshot.match("resources", resources) + + # makes sure we have a physical resource + resources = aws_client.cloudformation.describe_stack_resources(StackName="ApiGatewayStack")[ + "StackResources" + ] + for r in resources: + if r["ResourceType"] == "AWS::ApiGateway::GatewayResponse": + assert r["PhysicalResourceId"] diff --git a/tests/aws/services/apigateway/test_apigateway_lambda_cfn.snapshot.json b/tests/aws/services/apigateway/test_apigateway_lambda_cfn.snapshot.json new file mode 100644 index 0000000000000..9c808a5c2d905 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_lambda_cfn.snapshot.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/apigateway/test_apigateway_lambda_cfn.py::TestApigatewayLambdaIntegration::test_scenario_validate_infra": { + "recorded-date": "17-08-2023, 08:43:11", + "recorded-content": { + "resources": { + "ApiId": "", + "restapiEndpointC67DEFEA": "https://.execute-api..amazonaws.com/prod/" + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_lambda_cfn.validation.json b/tests/aws/services/apigateway/test_apigateway_lambda_cfn.validation.json new file mode 100644 index 0000000000000..35cf201efeebb --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_lambda_cfn.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/apigateway/test_apigateway_lambda_cfn.py::TestApigatewayLambdaIntegration::test_scenario_validate_infra": { + "last_validated_date": "2023-08-17T06:43:11+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_s3.py b/tests/aws/services/apigateway/test_apigateway_s3.py new file mode 100644 index 0000000000000..3cdd87be10f6f --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_s3.py @@ -0,0 +1,1216 @@ +import base64 +import gzip +import json +import time + +import pytest +import requests +import xmltodict +from botocore.exceptions import ClientError + +from localstack.aws.api.apigateway import ContentHandlingStrategy +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url +from tests.aws.services.apigateway.conftest import APIGATEWAY_ASSUME_ROLE_POLICY + + +@markers.aws.validated +# TODO: S3 does not return the HostId in the exception +@markers.snapshot.skip_snapshot_verify(paths=["$..Error.HostId"]) +def test_apigateway_s3_any( + aws_client, create_rest_apigw, s3_bucket, region_name, create_role_with_policy, snapshot +): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("RequestId"), + snapshot.transform.key_value( + "HostId", reference_replacement=False, value_replacement="" + ), + ] + ) + api_id, api_name, root_id = create_rest_apigw() + stage_name = "test" + object_name = "test.json" + + _, role_arn = create_role_with_policy( + "Allow", "s3:*", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{object_path+}" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + requestParameters={ + "method.request.path.object_path": True, + "method.request.header.Content-Type": False, + }, + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=resource_id, httpMethod="ANY", statusCode="200" + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + integrationHttpMethod="ANY", + type="AWS", + uri=f"arn:aws:apigateway:{region_name}:s3:path/{s3_bucket}/{{object_path}}", + requestParameters={ + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.header.Content-Type": "method.request.header.Content-Type", + }, + credentials=role_arn, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, resourceId=resource_id, httpMethod="ANY", statusCode="200" + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + invoke_url = api_invoke_url(api_id, stage_name, path="/" + object_name) + + def _get_object(assert_json: bool = False): + _response = requests.get(url=invoke_url) + assert _response.status_code == 200 + if assert_json: + _response.json() + return _response + + def _put_object(data: dict): + _response = requests.put( + url=invoke_url, json=data, headers={"Content-Type": "application/json"} + ) + assert _response.status_code == 200 + + # # Try to get an object that doesn't exist + response = retry(_get_object, retries=10, sleep=2) + snapshot.match("get-object-empty", xmltodict.parse(response.content)) + + # Put a new object + retry(lambda: _put_object({"put_id": 1}), retries=10, sleep=2) + response = retry(lambda: _get_object(assert_json=True), retries=10, sleep=2) + snapshot.match("get-object-1", response.text) + + # updated an object + retry(lambda: _put_object({"put_id": 2}), retries=10, sleep=2) + response = retry(lambda: _get_object(assert_json=True), retries=10, sleep=2) + snapshot.match("get-object-2", response.text) + + # Delete an object + requests.delete(invoke_url) + response = retry(_get_object, retries=10, sleep=2) + snapshot.match("get-object-deleted", xmltodict.parse(response.content)) + + with pytest.raises(ClientError) as exc_info: + aws_client.s3.get_object(Bucket=s3_bucket, Key=object_name) + snapshot.match("get-object-s3", exc_info.value.response) + + # Make a POST request + # TODO AWS return a 200 with a message from s3 in xml format stating that POST is invalid + # response = requests.post(invoke_url, headers={"Content-Type": "application/json"}, json={"put_id": 3}) + # snapshot.match("post-object", xmltodict.parse(response.content)) + + +@markers.aws.validated +# TODO: S3 does not return the HostId in the exception +@markers.snapshot.skip_snapshot_verify(paths=["$.get-deleted-object.Error.HostId"]) +def test_apigateway_s3_method_mapping( + aws_client, create_rest_apigw, s3_bucket, region_name, create_role_with_policy, snapshot +): + snapshot.add_transformers_list( + [snapshot.transform.key_value("HostId"), snapshot.transform.key_value("RequestId")] + ) + + api_id, api_name, root_id = create_rest_apigw() + stage_name = "test" + object_name = "test.json" + + _, role_arn = create_role_with_policy( + "Allow", "s3:*", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + get_resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="get" + )["id"] + put_resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="put" + )["id"] + delete_resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="delete" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=get_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=put_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=delete_resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=delete_resource_id, httpMethod="GET", statusCode="200" + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=put_resource_id, httpMethod="GET", statusCode="200" + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, resourceId=get_resource_id, httpMethod="GET", statusCode="200" + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=get_resource_id, + httpMethod="GET", + integrationHttpMethod="GET", + type="AWS", + uri=f"arn:aws:apigateway:{region_name}:s3:path/{s3_bucket}/{object_name}", + credentials=role_arn, + ) + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=put_resource_id, + httpMethod="GET", + integrationHttpMethod="PUT", + type="AWS", + uri=f"arn:aws:apigateway:{region_name}:s3:path/{s3_bucket}/{object_name}", + requestParameters={ + "integration.request.header.Content-Type": "'application/json'", + }, + requestTemplates={"application/json": '{"message": "great success!"}'}, + credentials=role_arn, + ) + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=delete_resource_id, + httpMethod="GET", + integrationHttpMethod="DELETE", + type="AWS", + uri=f"arn:aws:apigateway:{region_name}:s3:path/{s3_bucket}/{object_name}", + credentials=role_arn, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, resourceId=get_resource_id, httpMethod="GET", statusCode="200" + ) + aws_client.apigateway.put_integration_response( + restApiId=api_id, resourceId=put_resource_id, httpMethod="GET", statusCode="200" + ) + aws_client.apigateway.put_integration_response( + restApiId=api_id, resourceId=delete_resource_id, httpMethod="GET", statusCode="200" + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + get_invoke_url = api_invoke_url(api_id, stage_name, path="/get") + put_invoke_url = api_invoke_url(api_id, stage_name, path="/put") + delete_invoke_url = api_invoke_url(api_id, stage_name, path="/delete") + + def _invoke(url, get_json: bool = False, get_xml: bool = False): + response = requests.get(url=url) + assert response.status_code == 200 + if get_json: + response = response.json() + elif get_xml: + response = xmltodict.parse(response.text) + return response + + retry(lambda: _invoke(put_invoke_url), retries=10, sleep=2) + get_object = retry(lambda: _invoke(get_invoke_url, get_json=True), retries=10, sleep=3) + snapshot.match("get-object", get_object) + _invoke(delete_invoke_url) + + get_object = retry(lambda: _invoke(get_invoke_url, get_xml=True), retries=10, sleep=2) + snapshot.match("get-deleted-object", get_object) + + +class TestApiGatewayS3BinarySupport: + """ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings.html + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html + """ + + @pytest.fixture + def setup_s3_apigateway( + self, + aws_client, + s3_bucket, + create_rest_apigw, + create_role_with_policy, + region_name, + snapshot, + ): + def _setup( + request_content_handling: ContentHandlingStrategy | None = None, + response_content_handling: ContentHandlingStrategy | None = None, + deploy: bool = True, + ): + api_id, api_name, root_id = create_rest_apigw() + stage_name = "test" + + _, role_arn = create_role_with_policy( + "Allow", "s3:*", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="{object_path+}" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + authorizationType="NONE", + requestParameters={ + "method.request.path.object_path": True, + "method.request.header.Content-Type": False, + "method.request.header.response-content-type": False, + }, + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + statusCode="200", + responseParameters={ + "method.response.header.ETag": False, + }, + ) + + req_kwargs = {} + if request_content_handling: + req_kwargs["contentHandling"] = request_content_handling + + put_integration = aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + integrationHttpMethod="ANY", + type="AWS", + uri=f"arn:aws:apigateway:{region_name}:s3:path/{s3_bucket}/{{object_path}}", + requestParameters={ + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type", + }, + credentials=role_arn, + **req_kwargs, + ) + snapshot.match("put-integration", put_integration) + + resp_kwargs = {} + if response_content_handling: + resp_kwargs["contentHandling"] = response_content_handling + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + statusCode="200", + responseParameters={ + "method.response.header.ETag": "integration.response.header.ETag", + }, + **resp_kwargs, + ) + + if deploy: + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("cacheNamespace"), + snapshot.transform.key_value("credentials"), + snapshot.transform.regex(s3_bucket, replacement=""), + ] + ) + + return api_id, resource_id, stage_name + + return _setup + + @markers.aws.validated + @pytest.mark.parametrize("content_handling", [None, ContentHandlingStrategy.CONVERT_TO_TEXT]) + def test_apigw_s3_binary_support_request( + self, + aws_client, + s3_bucket, + setup_s3_apigateway, + snapshot, + content_handling, + ): + # the current API does not have any `binaryMediaTypes` configured + api_id, _, stage_name = setup_s3_apigateway( + request_content_handling=content_handling, + ) + object_body_raw = gzip.compress( + b"compressed data, should be invalid UTF-8 string", mtime=1676569620 + ) + with pytest.raises(ValueError): + object_body_raw.decode() + + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, Key="test-raw-key-etag", Body=object_body_raw + ) + snapshot.match("put-obj-raw", put_obj) + + object_body_encoded = base64.b64encode(object_body_raw) + object_body_text = "this is a UTF8 text typed object" + + object_key_raw = "binary-raw" + object_key_encoded = "binary-encoded" + object_key_text = "text" + keys = [object_key_raw, object_key_encoded, object_key_text] + + def _invoke(url, body: bytes | str, content_type: str, expected_code: int = 200): + _response = requests.put(url=url, data=body, headers={"Content-Type": content_type}) + assert _response.status_code == expected_code + # sometimes S3 will respond 200, but will have a permission error + assert not _response.content + + return _response + + invoke_url_raw = api_invoke_url(api_id, stage_name, path="/" + object_key_raw) + retry( + _invoke, retries=10, url=invoke_url_raw, body=object_body_raw, content_type="image/png" + ) + + invoke_url_encoded = api_invoke_url(api_id, stage_name, path="/" + object_key_encoded) + retry(_invoke, url=invoke_url_encoded, body=object_body_encoded, content_type="image/png") + + invoke_url_text = api_invoke_url(api_id, stage_name, path="/" + object_key_text) + retry(_invoke, url=invoke_url_text, body=object_body_text, content_type="image/png") + + for key in keys: + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match(f"get-obj-no-binary-media-{key}", get_obj) + + # we now add a `binaryMediaTypes` + patch_operations = [{"op": "add", "path": "/binaryMediaTypes/image~1png"}] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + + if is_aws_cloud(): + time.sleep(10) + + stage_2 = "test2" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_2) + + invoke_url_raw_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_raw) + invoke_url_encoded_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_encoded) + invoke_url_text_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_text) + + # test with a ContentType that matches the binaryMediaTypes + retry( + _invoke, + retries=10, + url=invoke_url_raw_2, + body=object_body_raw, + content_type="image/png", + ) + + retry(_invoke, url=invoke_url_encoded_2, body=object_body_encoded, content_type="image/png") + retry(_invoke, url=invoke_url_text_2, body=object_body_text, content_type="image/png") + + for key in keys: + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + get_obj["Body"] = get_obj["Body"].read() + snapshot.match(f"get-obj-binary-type-{key}", get_obj) + + # test with a ContentType that does not match the binaryMediaTypes + retry(_invoke, url=invoke_url_raw_2, body=object_body_raw, content_type="text/plain") + retry( + _invoke, url=invoke_url_encoded_2, body=object_body_encoded, content_type="text/plain" + ) + retry(_invoke, url=invoke_url_text_2, body=object_body_text, content_type="text/plain") + + for key in keys: + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + get_obj["Body"] = get_obj["Body"].read() + snapshot.match(f"get-obj-text-type-{key}", get_obj) + + @markers.aws.validated + def test_apigw_s3_binary_support_request_convert_to_binary( + self, + aws_client, + s3_bucket, + setup_s3_apigateway, + snapshot, + ): + # the current API does not have any `binaryMediaTypes` configured + api_id, _, stage_name = setup_s3_apigateway( + request_content_handling=ContentHandlingStrategy.CONVERT_TO_BINARY, + ) + object_body_raw = gzip.compress( + b"compressed data, should be invalid UTF-8 string", mtime=1676569620 + ) + with pytest.raises(ValueError): + object_body_raw.decode() + + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, Key="test-raw-key-etag", Body=object_body_raw + ) + snapshot.match("put-obj-raw", put_obj) + + object_body_encoded = base64.b64encode(object_body_raw) + object_body_text = "this is a UTF8 text typed object" + + object_key_raw = "binary-raw" + object_key_encoded = "binary-encoded" + object_key_text = "text" + keys = [object_key_raw, object_key_encoded, object_key_text] + + def _invoke(url, body: bytes | str, content_type: str, expected_code: int = 200): + _response = requests.put(url=url, data=body, headers={"Content-Type": content_type}) + assert _response.status_code == expected_code + # sometimes S3 will respond 200, but will have a permission error + if expected_code == 200: + assert not _response.content + + return _response + + # we start with Encoded here, because `raw` will trigger 500, which is also the error returned when the API + # is not ready yet... + invoke_url_encoded = api_invoke_url(api_id, stage_name, path="/" + object_key_encoded) + retry( + _invoke, + url=invoke_url_encoded, + body=object_body_encoded, + content_type="image/png", + retries=10, + ) + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key_encoded) + get_obj["Body"] = get_obj["Body"].read() + snapshot.match(f"get-obj-no-binary-media-{object_key_encoded}", get_obj) + + invoke_url_raw = api_invoke_url(api_id, stage_name, path="/" + object_key_raw) + retry( + _invoke, + url=invoke_url_raw, + body=object_body_raw, + content_type="image/png", + expected_code=500, + ) + + invoke_url_text = api_invoke_url(api_id, stage_name, path="/" + object_key_text) + retry( + _invoke, + url=invoke_url_text, + body=object_body_text, + content_type="text/plain", + expected_code=500, + ) + + for key in [object_key_raw, object_key_text]: + with pytest.raises(aws_client.s3.exceptions.NoSuchKey): + aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + + # we now add a `binaryMediaTypes` + patch_operations = [{"op": "add", "path": "/binaryMediaTypes/image~1png"}] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + + if is_aws_cloud(): + time.sleep(10) + + stage_2 = "test2" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_2) + + invoke_url_raw_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_raw) + invoke_url_encoded_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_encoded) + invoke_url_text_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_text) + + # test with a ContentType that matches the binaryMediaTypes + retry( + _invoke, + retries=10, + url=invoke_url_raw_2, + body=object_body_raw, + content_type="image/png", + ) + retry(_invoke, url=invoke_url_encoded_2, body=object_body_encoded, content_type="image/png") + retry(_invoke, url=invoke_url_text_2, body=object_body_text, content_type="image/png") + + for key in keys: + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + get_obj["Body"] = get_obj["Body"].read() + snapshot.match(f"get-obj-binary-media-{key}", get_obj) + + # test with a ContentType that does not match the binaryMediaTypes + retry( + _invoke, + url=invoke_url_raw_2, + body=object_body_raw, + content_type="text/plain", + expected_code=500, + ) + retry( + _invoke, + url=invoke_url_text_2, + body=object_body_text, + content_type="text/plain", + expected_code=500, + ) + + retry( + _invoke, url=invoke_url_encoded_2, body=object_body_encoded, content_type="text/plain" + ) + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key_encoded) + get_obj["Body"] = get_obj["Body"].read() + snapshot.match(f"get-obj-text-type-{object_key_encoded}", get_obj) + + @markers.aws.validated + def test_apigw_s3_binary_support_request_convert_to_binary_with_request_template( + self, + aws_client, + s3_bucket, + setup_s3_apigateway, + snapshot, + ): + # the current API does not have any `binaryMediaTypes` configured + api_id, resource_id, stage_name = setup_s3_apigateway( + request_content_handling=ContentHandlingStrategy.CONVERT_TO_BINARY, + deploy=False, + ) + + # set up the VTL requestTemplate + aws_client.apigateway.update_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + patchOperations=[ + { + "op": "add", + "path": "/requestTemplates/application~1json", + "value": json.dumps({"data": "$input.body"}), + } + ], + ) + + get_integration = aws_client.apigateway.get_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + ) + snapshot.match("get-integration", get_integration) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + object_body_raw = gzip.compress( + b"compressed data, should be invalid UTF-8 string", mtime=1676569620 + ) + with pytest.raises(ValueError): + object_body_raw.decode() + + object_body_encoded = base64.b64encode(object_body_raw) + object_key_encoded = "binary-encoded" + + def _invoke(url, body: bytes | str, content_type: str, expected_code: int = 200): + _response = requests.put(url=url, data=body, headers={"Content-Type": content_type}) + assert _response.status_code == expected_code + # sometimes S3 will respond 200, but will have a permission error + if expected_code == 200: + assert not _response.content + + return _response + + # this request does not match the requestTemplates + invoke_url_encoded = api_invoke_url(api_id, stage_name, path="/" + object_key_encoded) + retry( + _invoke, + url=invoke_url_encoded, + body=object_body_encoded, + content_type="image/png", + retries=10, + ) + + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key_encoded) + get_obj["Body"] = get_obj["Body"].read() + snapshot.match("get-obj-encoded", get_obj) + + # this request matches the requestTemplates (application/json) + # it fails because we cannot pass binary data that hasn't been sanitized to VTL templates + retry( + _invoke, + url=invoke_url_encoded, + body=object_body_encoded, + content_type="application/json", + expected_code=500, + ) + + @markers.aws.validated + def test_apigw_s3_binary_support_response_no_content_handling( + self, + aws_client, + s3_bucket, + setup_s3_apigateway, + snapshot, + ): + # the current API does not have any `binaryMediaTypes` configured + api_id, _, stage_name = setup_s3_apigateway( + request_content_handling=None, + response_content_handling=None, + ) + object_body_raw = gzip.compress( + b"compressed data, should be invalid UTF-8 string", mtime=1676569620 + ) + with pytest.raises(ValueError): + object_body_raw.decode() + + object_body_encoded = base64.b64encode(object_body_raw) + object_body_text = "this is a UTF8 text typed object" + + object_key_raw = "binary-raw" + object_key_encoded = "binary-encoded" + object_key_text = "text" + keys_to_body = { + object_key_raw: object_body_raw, + object_key_encoded: object_body_encoded, + object_key_text: object_body_text, + } + + for key, obj_body in keys_to_body.items(): + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=obj_body) + snapshot.match(f"put-obj-{key}", put_obj) + + def _invoke( + url, accept: str, r_content_type: str = "binary/octet-stream", expected_code: int = 200 + ): + _response = requests.get( + url=url, headers={"Accept": accept, "response-content-type": r_content_type} + ) + assert _response.status_code == expected_code + if expected_code == 200: + assert _response.headers.get("ETag") + + return _response + + invoke_url_text = api_invoke_url(api_id, stage_name, path="/" + object_key_text) + obj = retry(_invoke, url=invoke_url_text, accept="text/plain", retries=10) + snapshot.match("text-no-media", {"content": obj.content, "etag": obj.headers.get("ETag")}) + + invoke_url_raw = api_invoke_url(api_id, stage_name, path="/" + object_key_raw) + obj = retry(_invoke, url=invoke_url_raw, accept="image/png") + snapshot.match("raw-no-media", {"content": obj.content, "etag": obj.headers.get("ETag")}) + + invoke_url_encoded = api_invoke_url(api_id, stage_name, path="/" + object_key_encoded) + obj = retry(_invoke, url=invoke_url_encoded, accept="image/png") + snapshot.match( + "encoded-no-media", {"content": obj.content, "etag": obj.headers.get("ETag")} + ) + + # we now add a `binaryMediaTypes` + patch_operations = [{"op": "add", "path": "/binaryMediaTypes/image~1png"}] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + + if is_aws_cloud(): + time.sleep(10) + + stage_2 = "test2" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_2) + + invoke_url_encoded_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_encoded) + invoke_url_raw_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_raw) + invoke_url_text_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_text) + + # test with Accept binary types (`Accept` that matches the binaryMediaTypes) + # and Text Payload (Payload `Content-Type` that does not match the binaryMediaTypes) + obj = retry(_invoke, url=invoke_url_encoded_2, accept="image/png", retries=20) + snapshot.match( + "encoded-payload-text-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # those 2 fails because we are in the text payload/binary accept -> Base64-decoded blob + retry(_invoke, url=invoke_url_raw_2, accept="image/png", expected_code=500) + retry(_invoke, url=invoke_url_text_2, accept="image/png", expected_code=500) + + # test with Accept binary types (`Accept` that matches the binaryMediaTypes) + # and binary Payload (Payload `Content-Type` that matches the binaryMediaTypes) + # those work because we're in the binary payload / binary accept -> Binary data + obj = retry( + _invoke, url=invoke_url_encoded_2, accept="image/png", r_content_type="image/png" + ) + snapshot.match( + "encoded-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="image/png", r_content_type="image/png") + snapshot.match( + "raw-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="image/png", r_content_type="image/png") + snapshot.match( + "text-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # test with Accept text types (`Accept` that does not match the binaryMediaTypes) + # and Text Payload (Payload `Content-Type` that does not match the binaryMediaTypes) + obj = retry(_invoke, url=invoke_url_encoded_2, accept="text/plain") + snapshot.match( + "encoded-payload-text-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="text/plain") + snapshot.match( + "raw-payload-text-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="text/plain") + snapshot.match( + "text-payload-text-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # test with Accept text types (`Accept` that does not match the binaryMediaTypes) + # and binary Payload (Payload `Content-Type` that matches the binaryMediaTypes) + obj = retry( + _invoke, url=invoke_url_encoded_2, accept="text/plain", r_content_type="image/png" + ) + snapshot.match( + "encoded-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="text/plain", r_content_type="image/png") + snapshot.match( + "raw-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="text/plain", r_content_type="image/png") + snapshot.match( + "text-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + @markers.aws.validated + def test_apigw_s3_binary_support_response_convert_to_text( + self, + aws_client, + s3_bucket, + setup_s3_apigateway, + snapshot, + ): + # the current API does not have any `binaryMediaTypes` configured + api_id, _, stage_name = setup_s3_apigateway( + request_content_handling=None, + response_content_handling=ContentHandlingStrategy.CONVERT_TO_TEXT, + ) + object_body_raw = gzip.compress( + b"compressed data, should be invalid UTF-8 string", mtime=1676569620 + ) + with pytest.raises(ValueError): + object_body_raw.decode() + + object_body_encoded = base64.b64encode(object_body_raw) + object_body_text = "this is a UTF8 text typed object" + + object_key_raw = "binary-raw" + object_key_encoded = "binary-encoded" + object_key_text = "text" + keys_to_body = { + object_key_raw: object_body_raw, + object_key_encoded: object_body_encoded, + object_key_text: object_body_text, + } + + for key, obj_body in keys_to_body.items(): + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=obj_body) + snapshot.match(f"put-obj-{key}", put_obj) + + def _invoke( + url, accept: str, r_content_type: str = "binary/octet-stream", expected_code: int = 200 + ): + _response = requests.get( + url=url, headers={"Accept": accept, "response-content-type": r_content_type} + ) + assert _response.status_code == expected_code + if expected_code == 200: + assert _response.headers.get("ETag") + + return _response + + invoke_url_text = api_invoke_url(api_id, stage_name, path="/" + object_key_text) + obj = retry(_invoke, url=invoke_url_text, accept="text/plain", retries=10) + snapshot.match("text-no-media", {"content": obj.content, "etag": obj.headers.get("ETag")}) + + # it tries to decode the object as UTF8 and fails, hence 500 + invoke_url_raw = api_invoke_url(api_id, stage_name, path="/" + object_key_raw) + obj = retry(_invoke, url=invoke_url_raw, accept="image/png") + snapshot.match("raw-no-media", {"content": obj.content, "etag": obj.headers.get("ETag")}) + + invoke_url_encoded = api_invoke_url(api_id, stage_name, path="/" + object_key_encoded) + obj = retry(_invoke, url=invoke_url_encoded, accept="image/png") + snapshot.match( + "encoded-no-media", {"content": obj.content, "etag": obj.headers.get("ETag")} + ) + + # we now add a `binaryMediaTypes` + patch_operations = [{"op": "add", "path": "/binaryMediaTypes/image~1png"}] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + + if is_aws_cloud(): + time.sleep(10) + + stage_2 = "test2" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_2) + + invoke_url_encoded_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_encoded) + invoke_url_raw_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_raw) + invoke_url_text_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_text) + + # test with Accept binary types (`Accept` that matches the binaryMediaTypes) + # and Text Payload (Payload `Content-Type` that does not match the binaryMediaTypes) + obj = retry(_invoke, url=invoke_url_encoded_2, accept="image/png", retries=20) + snapshot.match( + "encoded-payload-text-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="image/png") + snapshot.match( + "raw-payload-text-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="image/png") + snapshot.match( + "text-payload-text-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # test with Accept binary types (`Accept` that matches the binaryMediaTypes) + # and binary Payload (Payload `Content-Type` that matches the binaryMediaTypes) + obj = retry( + _invoke, url=invoke_url_encoded_2, accept="image/png", r_content_type="image/png" + ) + snapshot.match( + "encoded-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="image/png", r_content_type="image/png") + snapshot.match( + "raw-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="image/png", r_content_type="image/png") + snapshot.match( + "text-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # test with Accept text types (`Accept` that does not match the binaryMediaTypes) + # and Text Payload (Payload `Content-Type` that does not match the binaryMediaTypes) + obj = retry(_invoke, url=invoke_url_encoded_2, accept="text/plain") + snapshot.match( + "encoded-payload-text-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="text/plain") + snapshot.match( + "raw-payload-text-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="text/plain") + snapshot.match( + "text-payload-text-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # test with Accept text types (`Accept` that does not match the binaryMediaTypes) + # and binary Payload (Payload `Content-Type` that matches the binaryMediaTypes) + obj = retry( + _invoke, url=invoke_url_encoded_2, accept="text/plain", r_content_type="image/png" + ) + snapshot.match( + "encoded-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="text/plain", r_content_type="image/png") + snapshot.match( + "raw-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="text/plain", r_content_type="image/png") + snapshot.match( + "text-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + @markers.aws.validated + def test_apigw_s3_binary_support_response_convert_to_binary( + self, + aws_client, + s3_bucket, + setup_s3_apigateway, + snapshot, + ): + # the current API does not have any `binaryMediaTypes` configured + api_id, _, stage_name = setup_s3_apigateway( + request_content_handling=None, + response_content_handling=ContentHandlingStrategy.CONVERT_TO_BINARY, + ) + object_body_raw = gzip.compress( + b"compressed data, should be invalid UTF-8 string", mtime=1676569620 + ) + with pytest.raises(ValueError): + object_body_raw.decode() + + object_body_encoded = base64.b64encode(object_body_raw) + object_body_text = "this is a UTF8 text typed object" + + object_key_raw = "binary-raw" + object_key_encoded = "binary-encoded" + object_key_text = "text" + keys_to_body = { + object_key_raw: object_body_raw, + object_key_encoded: object_body_encoded, + object_key_text: object_body_text, + } + + for key, obj_body in keys_to_body.items(): + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=obj_body) + snapshot.match(f"put-obj-{key}", put_obj) + + def _invoke( + url, accept: str, r_content_type: str = "binary/octet-stream", expected_code: int = 200 + ): + _response = requests.get( + url=url, headers={"Accept": accept, "response-content-type": r_content_type} + ) + assert _response.status_code == expected_code + if expected_code == 200: + assert _response.headers.get("ETag") + + return _response + + invoke_url_encoded = api_invoke_url(api_id, stage_name, path="/" + object_key_encoded) + obj = retry(_invoke, url=invoke_url_encoded, accept="image/png", retries=10) + snapshot.match( + "encoded-no-media", {"content": obj.content, "etag": obj.headers.get("ETag")} + ) + + # it tries to base64-decode the object and fails, hence 500 + invoke_url_raw = api_invoke_url(api_id, stage_name, path="/" + object_key_raw) + retry(_invoke, url=invoke_url_raw, accept="image/png", expected_code=500) + + invoke_url_text = api_invoke_url(api_id, stage_name, path="/" + object_key_text) + retry(_invoke, url=invoke_url_text, accept="text/plain", expected_code=500) + + # we now add a `binaryMediaTypes` + patch_operations = [{"op": "add", "path": "/binaryMediaTypes/image~1png"}] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + + if is_aws_cloud(): + time.sleep(10) + + stage_2 = "test2" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_2) + + invoke_url_encoded_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_encoded) + invoke_url_raw_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_raw) + invoke_url_text_2 = api_invoke_url(api_id, stage_2, path="/" + object_key_text) + + # test with Accept binary types (`Accept` that matches the binaryMediaTypes) + # and Text Payload (Payload `Content-Type` that does not match the binaryMediaTypes) + obj = retry(_invoke, url=invoke_url_encoded_2, accept="image/png", retries=20) + snapshot.match( + "encoded-payload-text-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + retry(_invoke, url=invoke_url_raw_2, accept="image/png", expected_code=500) + retry(_invoke, url=invoke_url_text_2, accept="image/png", expected_code=500) + + # test with Accept binary types (`Accept` that matches the binaryMediaTypes) + # and binary Payload (Payload `Content-Type` that matches the binaryMediaTypes) + obj = retry( + _invoke, url=invoke_url_encoded_2, accept="image/png", r_content_type="image/png" + ) + snapshot.match( + "encoded-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="image/png", r_content_type="image/png") + snapshot.match( + "raw-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="image/png", r_content_type="image/png") + snapshot.match( + "text-payload-binary-accept-binary", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # test with Accept text types (`Accept` that does not match the binaryMediaTypes) + # and Text Payload (Payload `Content-Type` that does not match the binaryMediaTypes) + obj = retry(_invoke, url=invoke_url_encoded_2, accept="text/plain") + snapshot.match( + "encoded-payload-text-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + retry(_invoke, url=invoke_url_raw_2, accept="text/plain", expected_code=500) + retry(_invoke, url=invoke_url_text_2, accept="text/plain", expected_code=500) + + # test with Accept text types (`Accept` that does not match the binaryMediaTypes) + # and binary Payload (Payload `Content-Type` that matches the binaryMediaTypes) + obj = retry( + _invoke, url=invoke_url_encoded_2, accept="text/plain", r_content_type="image/png" + ) + snapshot.match( + "encoded-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_raw_2, accept="text/plain", r_content_type="image/png") + snapshot.match( + "raw-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + obj = retry(_invoke, url=invoke_url_text_2, accept="text/plain", r_content_type="image/png") + snapshot.match( + "text-payload-binary-accept-text", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + @markers.aws.validated + def test_apigw_s3_binary_support_response_convert_to_binary_with_request_template( + self, + aws_client, + s3_bucket, + setup_s3_apigateway, + snapshot, + ): + api_id, resource_id, stage_name = setup_s3_apigateway( + request_content_handling=None, + response_content_handling=ContentHandlingStrategy.CONVERT_TO_TEXT, + deploy=False, + ) + + patch_operations = [{"op": "add", "path": "/binaryMediaTypes/image~1png"}] + aws_client.apigateway.update_rest_api(restApiId=api_id, patchOperations=patch_operations) + + # set up the VTL requestTemplate + aws_client.apigateway.update_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + statusCode="200", + patchOperations=[ + { + "op": "add", + "path": "/responseTemplates/application~1json", + "value": json.dumps({"data": "$input.body"}), + } + ], + ) + + get_integration = aws_client.apigateway.get_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + statusCode="200", + ) + snapshot.match("get-integration-response", get_integration) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + object_body_raw = gzip.compress( + b"compressed data, should be invalid UTF-8 string", mtime=1676569620 + ) + with pytest.raises(ValueError): + object_body_raw.decode() + + object_body_encoded = base64.b64encode(object_body_raw) + object_key_encoded = "binary-encoded" + + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key_encoded, Body=object_body_encoded + ) + snapshot.match("put-obj-encoded", put_obj) + + def _invoke( + url, accept: str, r_content_type: str = "binary/octet-stream", expected_code: int = 200 + ): + _response = requests.get( + url=url, headers={"Accept": accept, "response-content-type": r_content_type} + ) + assert _response.status_code == expected_code + if expected_code == 200: + assert _response.headers.get("ETag") + + return _response + + # as we are in CONVERT_TO_TEXT, we always get back UTF8 strings back to the template + invoke_url_encoded = api_invoke_url(api_id, stage_name, path="/" + object_key_encoded) + obj = retry(_invoke, url=invoke_url_encoded, accept="image/png", retries=20) + snapshot.match( + "encoded-text-payload-binary-accept", + {"content": obj.content, "etag": obj.headers.get("ETag")}, + ) + + # it seems responseTemplates are not auto-transforming in UTF8 string and are failing if the payload is in bytes + # set up the VTL requestTemplate + aws_client.apigateway.update_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + statusCode="200", + patchOperations=[ + { + "op": "replace", + "path": "/contentHandling", + "value": ContentHandlingStrategy.CONVERT_TO_BINARY, + } + ], + ) + get_integration = aws_client.apigateway.get_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="ANY", + statusCode="200", + ) + snapshot.match("get-integration-response-update", get_integration) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + if is_aws_cloud(): + # we need to sleep here, because we can't really assert that the error is the default deploy error, or just + # that it is failing + time.sleep(20) + # this actually returns the base64 file (so a UTF8 encoded string, but in bytes, raw from S3) + retry(_invoke, url=invoke_url_encoded, accept="image/png", expected_code=500) diff --git a/tests/aws/services/apigateway/test_apigateway_s3.snapshot.json b/tests/aws/services/apigateway/test_apigateway_s3.snapshot.json new file mode 100644 index 0000000000000..a8125cd96837c --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_s3.snapshot.json @@ -0,0 +1,985 @@ +{ + "tests/aws/services/apigateway/test_apigateway_s3.py::test_apigateway_s3_any": { + "recorded-date": "31-01-2025, 19:00:37", + "recorded-content": { + "get-object-empty": { + "Error": { + "Code": "NoSuchKey", + "HostId": "", + "Key": "test.json", + "Message": "The specified key does not exist.", + "RequestId": "" + } + }, + "get-object-1": { + "put_id": 1 + }, + "get-object-2": { + "put_id": 2 + }, + "get-object-deleted": { + "Error": { + "Code": "NoSuchKey", + "HostId": "", + "Key": "test.json", + "Message": "The specified key does not exist.", + "RequestId": "" + } + }, + "get-object-s3": { + "Error": { + "Code": "NoSuchKey", + "Key": "test.json", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::test_apigateway_s3_method_mapping": { + "recorded-date": "14-06-2024, 16:12:27", + "recorded-content": { + "get-object": { + "message": "great success!" + }, + "get-deleted-object": { + "Error": { + "Code": "NoSuchKey", + "HostId": "", + "Key": "test.json", + "Message": "The specified key does not exist.", + "RequestId": "" + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request[None]": { + "recorded-date": "17-03-2025, 20:09:05", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-obj-raw": { + "ChecksumCRC32": "MjWu6g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-binary-media-binary-raw": { + "AcceptRanges": "bytes", + "Body": "\u001f\ufffd\b\u0000\u0014l\ufffdc\u0002\ufffdK\ufffd\ufffd-(J-.NMQHI,I\ufffdQ(\ufffd\ufffd/\ufffdIQHJU\ufffd\ufffd+K\ufffd\ufffdLQ\b\rq\u04f5P(.)\ufffd\ufffdK\u0007\u0000\ufffd9\u0010W/\u0000\u0000\u0000", + "ChecksumCRC64NVME": "aFeROtBfStk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 99, + "ContentType": "image/png", + "ETag": "\"7301f0a0e8fc87c6f03320bf795ffde7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-binary-media-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA==", + "ChecksumCRC64NVME": "G1eWyXOlSSo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 92, + "ContentType": "image/png", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-binary-media-text": { + "AcceptRanges": "bytes", + "Body": "this is a UTF8 text typed object", + "ChecksumCRC64NVME": "+N2eX1bs1YE=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 32, + "ContentType": "image/png", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-type-binary-raw": { + "AcceptRanges": "bytes", + "Body": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "ChecksumCRC64NVME": "ZewzxBwEwiY=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 67, + "ContentType": "image/png", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-type-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "ChecksumCRC64NVME": "G1eWyXOlSSo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 92, + "ContentType": "image/png", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-type-text": { + "AcceptRanges": "bytes", + "Body": "b'this is a UTF8 text typed object'", + "ChecksumCRC64NVME": "+N2eX1bs1YE=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 32, + "ContentType": "image/png", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-text-type-binary-raw": { + "AcceptRanges": "bytes", + "Body": "b'\\x1f\\xef\\xbf\\xbd\\x08\\x00\\x14l\\xef\\xbf\\xbdc\\x02\\xef\\xbf\\xbdK\\xef\\xbf\\xbd\\xef\\xbf\\xbd-(J-.NMQHI,I\\xef\\xbf\\xbdQ(\\xef\\xbf\\xbd\\xef\\xbf\\xbd/\\xef\\xbf\\xbdIQHJU\\xef\\xbf\\xbd\\xef\\xbf\\xbd+K\\xef\\xbf\\xbd\\xef\\xbf\\xbdLQ\\x08\\rq\\xd3\\xb5P(.)\\xef\\xbf\\xbd\\xef\\xbf\\xbdK\\x07\\x00\\xef\\xbf\\xbd9\\x10W/\\x00\\x00\\x00'", + "ChecksumCRC64NVME": "aFeROtBfStk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 99, + "ContentType": "text/plain", + "ETag": "\"7301f0a0e8fc87c6f03320bf795ffde7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-text-type-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "ChecksumCRC64NVME": "G1eWyXOlSSo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 92, + "ContentType": "text/plain", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-text-type-text": { + "AcceptRanges": "bytes", + "Body": "b'this is a UTF8 text typed object'", + "ChecksumCRC64NVME": "+N2eX1bs1YE=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 32, + "ContentType": "text/plain", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request[CONVERT_TO_TEXT]": { + "recorded-date": "17-03-2025, 20:09:38", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-obj-raw": { + "ChecksumCRC32": "MjWu6g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-binary-media-binary-raw": { + "AcceptRanges": "bytes", + "Body": "\u001f\ufffd\b\u0000\u0014l\ufffdc\u0002\ufffdK\ufffd\ufffd-(J-.NMQHI,I\ufffdQ(\ufffd\ufffd/\ufffdIQHJU\ufffd\ufffd+K\ufffd\ufffdLQ\b\rq\u04f5P(.)\ufffd\ufffdK\u0007\u0000\ufffd9\u0010W/\u0000\u0000\u0000", + "ChecksumCRC64NVME": "aFeROtBfStk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 99, + "ContentType": "image/png", + "ETag": "\"7301f0a0e8fc87c6f03320bf795ffde7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-binary-media-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA==", + "ChecksumCRC64NVME": "G1eWyXOlSSo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 92, + "ContentType": "image/png", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-binary-media-text": { + "AcceptRanges": "bytes", + "Body": "this is a UTF8 text typed object", + "ChecksumCRC64NVME": "+N2eX1bs1YE=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 32, + "ContentType": "image/png", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-type-binary-raw": { + "AcceptRanges": "bytes", + "Body": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "ChecksumCRC64NVME": "G1eWyXOlSSo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 92, + "ContentType": "image/png", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-type-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "b'SDRzSUFCUnM3bU1DLzB2T3p5MG9TaTB1VGsxUlNFa3NTZFJSS003SUw4MUpVVWhLVmNqTUswdk15VXhSQ0ExeDA3VlFLQzRweXN4TEJ3QzRPUkJYTHdBQUFBPT0='", + "ChecksumCRC64NVME": "N9mFwtUSj/Y=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 124, + "ContentType": "image/png", + "ETag": "\"835317c6c047dd2a13bb05117594a71a\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-type-text": { + "AcceptRanges": "bytes", + "Body": "b'dGhpcyBpcyBhIFVURjggdGV4dCB0eXBlZCBvYmplY3Q='", + "ChecksumCRC64NVME": "32RnczoRaNI=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 44, + "ContentType": "image/png", + "ETag": "\"1a39ff3d9eff87f24107669698573f35\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-text-type-binary-raw": { + "AcceptRanges": "bytes", + "Body": "b'\\x1f\\xef\\xbf\\xbd\\x08\\x00\\x14l\\xef\\xbf\\xbdc\\x02\\xef\\xbf\\xbdK\\xef\\xbf\\xbd\\xef\\xbf\\xbd-(J-.NMQHI,I\\xef\\xbf\\xbdQ(\\xef\\xbf\\xbd\\xef\\xbf\\xbd/\\xef\\xbf\\xbdIQHJU\\xef\\xbf\\xbd\\xef\\xbf\\xbd+K\\xef\\xbf\\xbd\\xef\\xbf\\xbdLQ\\x08\\rq\\xd3\\xb5P(.)\\xef\\xbf\\xbd\\xef\\xbf\\xbdK\\x07\\x00\\xef\\xbf\\xbd9\\x10W/\\x00\\x00\\x00'", + "ChecksumCRC64NVME": "aFeROtBfStk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 99, + "ContentType": "text/plain", + "ETag": "\"7301f0a0e8fc87c6f03320bf795ffde7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-text-type-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "ChecksumCRC64NVME": "G1eWyXOlSSo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 92, + "ContentType": "text/plain", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-text-type-text": { + "AcceptRanges": "bytes", + "Body": "b'this is a UTF8 text typed object'", + "ChecksumCRC64NVME": "+N2eX1bs1YE=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 32, + "ContentType": "text/plain", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request_convert_to_binary": { + "recorded-date": "17-03-2025, 20:10:12", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_BINARY", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-obj-raw": { + "ChecksumCRC32": "MjWu6g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-binary-media-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "ChecksumCRC64NVME": "ZewzxBwEwiY=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 67, + "ContentType": "image/png", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-media-binary-raw": { + "AcceptRanges": "bytes", + "Body": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "ChecksumCRC64NVME": "ZewzxBwEwiY=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 67, + "ContentType": "image/png", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-media-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "ChecksumCRC64NVME": "G1eWyXOlSSo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 92, + "ContentType": "image/png", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-binary-media-text": { + "AcceptRanges": "bytes", + "Body": "b'this is a UTF8 text typed object'", + "ChecksumCRC64NVME": "+N2eX1bs1YE=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 32, + "ContentType": "image/png", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-text-type-binary-encoded": { + "AcceptRanges": "bytes", + "Body": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "ChecksumCRC64NVME": "ZewzxBwEwiY=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 67, + "ContentType": "text/plain", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request_convert_to_binary_with_request_template": { + "recorded-date": "17-03-2025, 20:10:23", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_BINARY", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "contentHandling": "CONVERT_TO_BINARY", + "credentials": "", + "httpMethod": "ANY", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.ETag": "integration.response.header.ETag" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "requestTemplates": { + "application/json": { + "data": "$input.body" + } + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-encoded": { + "AcceptRanges": "bytes", + "Body": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "ChecksumCRC64NVME": "ZewzxBwEwiY=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 67, + "ContentType": "image/png", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_no_content_handling": { + "recorded-date": "17-03-2025, 20:11:02", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-obj-binary-raw": { + "ChecksumCRC32": "MjWu6g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-binary-encoded": { + "ChecksumCRC32": "P/3olw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-text": { + "ChecksumCRC32": "DzmB0Q==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "text-no-media": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "raw-no-media": { + "content": "b'\\x1f\\xef\\xbf\\xbd\\x08\\x00\\x14l\\xef\\xbf\\xbdc\\x02\\xef\\xbf\\xbdK\\xef\\xbf\\xbd\\xef\\xbf\\xbd-(J-.NMQHI,I\\xef\\xbf\\xbdQ(\\xef\\xbf\\xbd\\xef\\xbf\\xbd/\\xef\\xbf\\xbdIQHJU\\xef\\xbf\\xbd\\xef\\xbf\\xbd+K\\xef\\xbf\\xbd\\xef\\xbf\\xbdLQ\\x08\\rq\\xd3\\xb5P(.)\\xef\\xbf\\xbd\\xef\\xbf\\xbdK\\x07\\x00\\xef\\xbf\\xbd9\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "encoded-no-media": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "encoded-payload-text-accept-binary": { + "content": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "encoded-payload-binary-accept-binary": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-binary-accept-binary": { + "content": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-binary-accept-binary": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "encoded-payload-text-accept-text": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-text-accept-text": { + "content": "b'\\x1f\\xef\\xbf\\xbd\\x08\\x00\\x14l\\xef\\xbf\\xbdc\\x02\\xef\\xbf\\xbdK\\xef\\xbf\\xbd\\xef\\xbf\\xbd-(J-.NMQHI,I\\xef\\xbf\\xbdQ(\\xef\\xbf\\xbd\\xef\\xbf\\xbd/\\xef\\xbf\\xbdIQHJU\\xef\\xbf\\xbd\\xef\\xbf\\xbd+K\\xef\\xbf\\xbd\\xef\\xbf\\xbdLQ\\x08\\rq\\xd3\\xb5P(.)\\xef\\xbf\\xbd\\xef\\xbf\\xbdK\\x07\\x00\\xef\\xbf\\xbd9\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-text-accept-text": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "encoded-payload-binary-accept-text": { + "content": "b'SDRzSUFCUnM3bU1DLzB2T3p5MG9TaTB1VGsxUlNFa3NTZFJSS003SUw4MUpVVWhLVmNqTUswdk15VXhSQ0ExeDA3VlFLQzRweXN4TEJ3QzRPUkJYTHdBQUFBPT0='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-binary-accept-text": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-binary-accept-text": { + "content": "b'dGhpcyBpcyBhIFVURjggdGV4dCB0eXBlZCBvYmplY3Q='", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_text": { + "recorded-date": "17-03-2025, 20:11:38", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-obj-binary-raw": { + "ChecksumCRC32": "MjWu6g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-binary-encoded": { + "ChecksumCRC32": "P/3olw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-text": { + "ChecksumCRC32": "DzmB0Q==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "text-no-media": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "raw-no-media": { + "content": "b'\\x1f\\xef\\xbf\\xbd\\x08\\x00\\x14l\\xef\\xbf\\xbdc\\x02\\xef\\xbf\\xbdK\\xef\\xbf\\xbd\\xef\\xbf\\xbd-(J-.NMQHI,I\\xef\\xbf\\xbdQ(\\xef\\xbf\\xbd\\xef\\xbf\\xbd/\\xef\\xbf\\xbdIQHJU\\xef\\xbf\\xbd\\xef\\xbf\\xbd+K\\xef\\xbf\\xbd\\xef\\xbf\\xbdLQ\\x08\\rq\\xd3\\xb5P(.)\\xef\\xbf\\xbd\\xef\\xbf\\xbdK\\x07\\x00\\xef\\xbf\\xbd9\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "encoded-no-media": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "encoded-payload-text-accept-binary": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-text-accept-binary": { + "content": "b'\\x1f\\xef\\xbf\\xbd\\x08\\x00\\x14l\\xef\\xbf\\xbdc\\x02\\xef\\xbf\\xbdK\\xef\\xbf\\xbd\\xef\\xbf\\xbd-(J-.NMQHI,I\\xef\\xbf\\xbdQ(\\xef\\xbf\\xbd\\xef\\xbf\\xbd/\\xef\\xbf\\xbdIQHJU\\xef\\xbf\\xbd\\xef\\xbf\\xbd+K\\xef\\xbf\\xbd\\xef\\xbf\\xbdLQ\\x08\\rq\\xd3\\xb5P(.)\\xef\\xbf\\xbd\\xef\\xbf\\xbdK\\x07\\x00\\xef\\xbf\\xbd9\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-text-accept-binary": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "encoded-payload-binary-accept-binary": { + "content": "b'SDRzSUFCUnM3bU1DLzB2T3p5MG9TaTB1VGsxUlNFa3NTZFJSS003SUw4MUpVVWhLVmNqTUswdk15VXhSQ0ExeDA3VlFLQzRweXN4TEJ3QzRPUkJYTHdBQUFBPT0='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-binary-accept-binary": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-binary-accept-binary": { + "content": "b'dGhpcyBpcyBhIFVURjggdGV4dCB0eXBlZCBvYmplY3Q='", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "encoded-payload-text-accept-text": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-text-accept-text": { + "content": "b'\\x1f\\xef\\xbf\\xbd\\x08\\x00\\x14l\\xef\\xbf\\xbdc\\x02\\xef\\xbf\\xbdK\\xef\\xbf\\xbd\\xef\\xbf\\xbd-(J-.NMQHI,I\\xef\\xbf\\xbdQ(\\xef\\xbf\\xbd\\xef\\xbf\\xbd/\\xef\\xbf\\xbdIQHJU\\xef\\xbf\\xbd\\xef\\xbf\\xbd+K\\xef\\xbf\\xbd\\xef\\xbf\\xbdLQ\\x08\\rq\\xd3\\xb5P(.)\\xef\\xbf\\xbd\\xef\\xbf\\xbdK\\x07\\x00\\xef\\xbf\\xbd9\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-text-accept-text": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "encoded-payload-binary-accept-text": { + "content": "b'SDRzSUFCUnM3bU1DLzB2T3p5MG9TaTB1VGsxUlNFa3NTZFJSS003SUw4MUpVVWhLVmNqTUswdk15VXhSQ0ExeDA3VlFLQzRweXN4TEJ3QzRPUkJYTHdBQUFBPT0='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-binary-accept-text": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-binary-accept-text": { + "content": "b'dGhpcyBpcyBhIFVURjggdGV4dCB0eXBlZCBvYmplY3Q='", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_binary": { + "recorded-date": "17-03-2025, 20:12:11", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put-obj-binary-raw": { + "ChecksumCRC32": "MjWu6g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1e5a1bfed5308938e5549848bab02ac6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-binary-encoded": { + "ChecksumCRC32": "P/3olw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-text": { + "ChecksumCRC32": "DzmB0Q==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"322a648674040849d29154aa1dce24a5\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "encoded-no-media": { + "content": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "encoded-payload-text-accept-binary": { + "content": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "encoded-payload-binary-accept-binary": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-binary-accept-binary": { + "content": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-binary-accept-binary": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + }, + "encoded-payload-text-accept-text": { + "content": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "encoded-payload-binary-accept-text": { + "content": "b'H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA=='", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "raw-payload-binary-accept-text": { + "content": "b'\\x1f\\x8b\\x08\\x00\\x14l\\xeec\\x02\\xffK\\xce\\xcf-(J-.NMQHI,I\\xd4Q(\\xce\\xc8/\\xcdIQHJU\\xc8\\xcc+K\\xcc\\xc9LQ\\x08\\rq\\xd3\\xb5P(.)\\xca\\xccK\\x07\\x00\\xb89\\x10W/\\x00\\x00\\x00'", + "etag": "\"1e5a1bfed5308938e5549848bab02ac6\"" + }, + "text-payload-binary-accept-text": { + "content": "b'this is a UTF8 text typed object'", + "etag": "\"322a648674040849d29154aa1dce24a5\"" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_binary_with_request_template": { + "recorded-date": "17-03-2025, 20:12:45", + "recorded-content": { + "put-integration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "credentials": "", + "httpMethod": "ANY", + "passthroughBehavior": "WHEN_NO_MATCH", + "requestParameters": { + "integration.request.header.Content-Type": "method.request.header.Content-Type", + "integration.request.path.object_path": "method.request.path.object_path", + "integration.request.querystring.response-content-type": "method.request.header.response-content-type" + }, + "timeoutInMillis": 29000, + "type": "AWS", + "uri": "arn::apigateway::s3:path//{object_path}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-integration-response": { + "contentHandling": "CONVERT_TO_TEXT", + "responseParameters": { + "method.response.header.ETag": "integration.response.header.ETag" + }, + "responseTemplates": { + "application/json": { + "data": "$input.body" + } + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-encoded": { + "ChecksumCRC32": "P/3olw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"76020056aa8e57ba307f9264167a34e4\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "encoded-text-payload-binary-accept": { + "content": "b'{\"data\": \"H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA==\"}'", + "etag": "\"76020056aa8e57ba307f9264167a34e4\"" + }, + "get-integration-response-update": { + "contentHandling": "CONVERT_TO_BINARY", + "responseParameters": { + "method.response.header.ETag": "integration.response.header.ETag" + }, + "responseTemplates": { + "application/json": { + "data": "$input.body" + } + }, + "statusCode": "200", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_s3.validation.json b/tests/aws/services/apigateway/test_apigateway_s3.validation.json new file mode 100644 index 0000000000000..31c3c7bf084d1 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_s3.validation.json @@ -0,0 +1,32 @@ +{ + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request[CONVERT_TO_TEXT]": { + "last_validated_date": "2025-03-17T20:09:38+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request[None]": { + "last_validated_date": "2025-03-17T20:09:05+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request_convert_to_binary": { + "last_validated_date": "2025-03-17T20:10:12+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_request_convert_to_binary_with_request_template": { + "last_validated_date": "2025-03-17T20:10:23+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_binary": { + "last_validated_date": "2025-03-17T20:12:11+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_binary_with_request_template": { + "last_validated_date": "2025-03-17T20:12:45+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_convert_to_text": { + "last_validated_date": "2025-03-17T20:11:38+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::TestApiGatewayS3BinarySupport::test_apigw_s3_binary_support_response_no_content_handling": { + "last_validated_date": "2025-03-17T20:11:02+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::test_apigateway_s3_any": { + "last_validated_date": "2025-01-31T19:00:37+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_s3.py::test_apigateway_s3_method_mapping": { + "last_validated_date": "2024-06-14T16:12:27+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_sqs.py b/tests/aws/services/apigateway/test_apigateway_sqs.py new file mode 100644 index 0000000000000..4d05268621006 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_sqs.py @@ -0,0 +1,494 @@ +import json +import re +import textwrap + +import pytest +import requests + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import retry +from localstack.utils.xml import is_valid_xml +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url +from tests.aws.services.apigateway.conftest import APIGATEWAY_ASSUME_ROLE_POLICY +from tests.aws.services.apigateway.test_apigateway_basic import TEST_STAGE_NAME + + +@markers.aws.validated +def test_sqs_aws_integration( + create_rest_apigw, + sqs_create_queue, + aws_client, + create_role_with_policy, + region_name, + account_id, + snapshot, + sqs_collect_messages, +): + snapshot.add_transformer(snapshot.transform.sqs_api()) + # create target SQS stream + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "sqs:SendMessage", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + api_id, _, root = create_rest_apigw( + name=f"test-api-${short_uid()}", + description="Test Integration with SQS", + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, + parentId=root, + pathPart="sqs", + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="AWS", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:sqs:path/{account_id}/{queue_name}", + credentials=role_arn, + requestParameters={ + "integration.request.header.Content-Type": "'application/x-www-form-urlencoded'" + }, + requestTemplates={"application/json": "Action=SendMessage&MessageBody=$input.body"}, + passthroughBehavior="NEVER", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseModels={"application/json": "Empty"}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseTemplates={"application/json": '{"message": "great success!"}'}, + ) + + response = aws_client.apigateway.create_deployment(restApiId=api_id) + deployment_id = response["id"] + + aws_client.apigateway.create_stage( + restApiId=api_id, + stageName=TEST_STAGE_NAME, + deploymentId=deployment_id, + ) + + invocation_url = api_invoke_url(api_id=api_id, stage=TEST_STAGE_NAME, path="/sqs") + + def invoke_api(url, payload): + kwargs = {"json": payload} if payload is not None else {} + _response = requests.post(url, **kwargs) + assert _response.ok + content = _response.json() + assert content == {"message": "great success!"} + return content + + response_data = retry( + invoke_api, sleep=2, retries=10, url=invocation_url, payload={"foo": "bar"} + ) + snapshot.match("sqs-aws-integration-with-payload", response_data) + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url, payload=None) + snapshot.match("sqs-aws-integration-without-payload", response_data) + + messages = sqs_collect_messages(queue_url=queue_url, expected=2, timeout=10) + snapshot.match("sqs-messages", messages) + + +@markers.aws.validated +def test_sqs_request_and_response_xml_templates_integration( + create_rest_apigw, + sqs_create_queue, + aws_client, + create_role_with_policy, + region_name, + account_id, + snapshot, +): + queue_name = f"queue-{short_uid()}" + sqs_create_queue(QueueName=queue_name) + + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "sqs:SendMessage", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + api_id, _, root = create_rest_apigw( + name=f"test-api-${short_uid()}", + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, + parentId=root, + pathPart="sqs", + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="AWS", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:sqs:path/{account_id}/{queue_name}", + credentials=role_arn, + requestParameters={ + "integration.request.header.Content-Type": "'application/x-www-form-urlencoded'" + }, + requestTemplates={"application/json": "Action=SendMessage&MessageBody=$input.body"}, + passthroughBehavior="NEVER", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseModels={"application/json": "Empty"}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseTemplates={ + "application/json": """ + #set($responseBody = $input.path('$.SendMessageResponse')) + #set($requestId = $input.path('$.SendMessageResponse.ResponseMetadata.RequestId')) + #set($messageId = $responseBody.SendMessageResult.MessageId) + { + "requestId": "$requestId", + "messageId": "$messageId" + } + """ + }, + ) + + response = aws_client.apigateway.create_deployment( + restApiId=api_id, + ) + deployment_id = response["id"] + + aws_client.apigateway.create_stage( + restApiId=api_id, stageName=TEST_STAGE_NAME, deploymentId=deployment_id + ) + + invocation_url = api_invoke_url(api_id=api_id, stage=TEST_STAGE_NAME, path="/sqs") + + def invoke_api(url, validate_xml=None): + _response = requests.post(url, data="Hello World", verify=False) + if validate_xml: + assert is_valid_xml(_response.content.decode("utf-8")) + return _response + + assert _response.ok + return _response + + response_data = retry(invoke_api, sleep=2, retries=10, url=invocation_url) + snapshot.match("sqs-json-response", response_data.json()) + + # patch integration request parameters to use Accept header with "application/xml" + # and remove response templates + aws_client.apigateway.update_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + patchOperations=[ + { + "op": "add", + "path": "/requestParameters/integration.request.header.Accept", + "value": "'application/xml'", + } + ], + ) + + aws_client.apigateway.update_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + patchOperations=[ + { + "op": "remove", + "path": "/responseTemplates/application~1json", + "value": "application/json", + } + ], + ) + + # create deployment and update stage for re-deployment + deployment = aws_client.apigateway.create_deployment( + restApiId=api_id, + ) + + aws_client.apigateway.update_stage( + restApiId=api_id, + stageName=TEST_STAGE_NAME, + patchOperations=[{"op": "replace", "path": "/deploymentId", "value": deployment["id"]}], + ) + + response = retry(invoke_api, sleep=2, retries=10, url=invocation_url, validate_xml=True) + + xml_body = to_str(response.content) + # snapshotting would be great, but the response differs from AWS on the XML on the element order + assert re.search(".*", xml_body) + assert re.search(".*", xml_body) + assert re.search(".*", xml_body) + + +@pytest.mark.parametrize("message_attribute", ["MessageAttribute", "MessageAttributes"]) +@markers.aws.validated +def test_sqs_aws_integration_with_message_attribute( + create_rest_apigw, + sqs_create_queue, + aws_client, + create_role_with_policy, + region_name, + account_id, + snapshot, + message_attribute, +): + # create target SQS stream + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "sqs:SendMessage", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + api_id, _, root = create_rest_apigw( + name=f"test-api-${short_uid()}", + description="Test Integration with SQS", + ) + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=root, + httpMethod="POST", + authorizationType="NONE", + ) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=root, + httpMethod="POST", + type="AWS", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:sqs:path/{account_id}/{queue_name}", + credentials=role_arn, + requestParameters={ + "integration.request.header.Content-Type": "'application/x-www-form-urlencoded'" + }, + requestTemplates={ + "application/json": ( + "Action=SendMessage&MessageBody=$input.body&" + f"{message_attribute}.1.Name=user-agent&" + f"{message_attribute}.1.Value.DataType=String&" + f"{message_attribute}.1.Value.StringValue=$input.params('HeaderFoo')" + ) + }, + passthroughBehavior="NEVER", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=root, + httpMethod="POST", + statusCode="200", + responseModels={"application/json": "Empty"}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=root, + httpMethod="POST", + statusCode="200", + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=TEST_STAGE_NAME) + invocation_url = api_invoke_url(api_id=api_id, stage=TEST_STAGE_NAME, path="/") + + def invoke_api(url): + _response = requests.post(url, json={"foo": "bar"}, headers={"HeaderFoo": "BAR-Header"}) + assert _response.ok + + retry(invoke_api, sleep=2, retries=10, url=invocation_url) + + def get_sqs_message(): + messages = aws_client.sqs.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ).get("Messages", []) + assert 1 == len(messages) + return messages[0] + + message = retry(get_sqs_message, sleep=2, retries=10) + snapshot.match("sqs-message-body", message["Body"]) + snapshot.match("sqs-message-attributes", message["MessageAttributes"]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # FIXME: those are minor parity gap in how we handle printing out VTL Map when they are nested inside bigger maps + "$..context.identity", + "$..context.requestOverride", + "$..context.responseOverride", + "$..requestOverride.header", + "$..requestOverride.path", + "$..requestOverride.querystring", + "$..responseOverride.header", + "$..responseOverride.path", + "$..responseOverride.status", + ] +) +def test_sqs_amz_json_protocol( + create_rest_apigw, + sqs_create_queue, + aws_client, + create_role_with_policy, + region_name, + account_id, + snapshot, + sqs_collect_messages, +): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("resourceId"), + snapshot.transform.key_value("extendedRequestId"), + snapshot.transform.key_value("requestTime"), + snapshot.transform.key_value("requestTimeEpoch", reference_replacement=False), + snapshot.transform.key_value("domainName"), + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("apiId"), + snapshot.transform.key_value("sourceIp"), + ] + ) + + # create target SQS stream + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "sqs:SendMessage", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + api_id, _, root = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Test Integration with SQS", + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, + parentId=root, + pathPart="sqs", + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + + # we need to inline the JSON object because VTL does not handle newlines very well :/ + context_template = textwrap.dedent(f""" + {{ + "QueueUrl": "{queue_url}", + "MessageBody": "{{\\"context\\": {{#foreach( $key in $context.keySet() )\\"$key\\": \\"$context.get($key)\\"#if($foreach.hasNext),#end#end}},\\"identity\\": {{#foreach( $key in $context.identity.keySet() )\\"$key\\": \\"$context.identity.get($key)\\"#if($foreach.hasNext),#end#end}},\\"requestOverride\\": {{#foreach( $key in $context.requestOverride.keySet() )\\"$key\\": \\"$context.requestOverride.get($key)\\"#if($foreach.hasNext),#end#end}},\\"responseOverride\\": {{#foreach( $key in $context.responseOverride.keySet() )\\"$key\\": \\"$context.responseOverride.get($key)\\"#if($foreach.hasNext),#end#end}},\\"authorizer_keys\\": {{#foreach( $key in $context.authorizer.keySet() )\\"$key\\": \\"$util.escapeJavaScript($context.authorizer.get($key))\\"#if($foreach.hasNext),#end#end}}}}"}} + """) + + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="AWS", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:sqs:path/{account_id}/{queue_name}", + credentials=role_arn, + requestParameters={ + "integration.request.header.Content-Type": "'application/x-amz-json-1.0'", + "integration.request.header.X-Amz-Target": "'AmazonSQS.SendMessage'", + }, + requestTemplates={"application/json": context_template}, + passthroughBehavior="NEVER", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseModels={"application/json": "Empty"}, + ) + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="400", + responseModels={"application/json": "Empty"}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="200", + responseTemplates={"application/json": '{"message": "great success!"}'}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + statusCode="400", + responseTemplates={"application/json": '{"message": "failure :("}'}, + selectionPattern="400", + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=TEST_STAGE_NAME) + + invocation_url = api_invoke_url(api_id=api_id, stage=TEST_STAGE_NAME, path="/sqs") + + def invoke_api(url): + _response = requests.post(url, headers={"User-Agent": "python/requests/tests"}) + assert _response.ok + content = _response.json() + assert content == {"message": "great success!"} + return content + + retry(invoke_api, sleep=2, retries=10, url=invocation_url) + + messages = sqs_collect_messages( + queue_url=queue_url, expected=1, timeout=10, wait_time_seconds=5 + ) + snapshot.match("sqs-messages", messages) diff --git a/tests/aws/services/apigateway/test_apigateway_sqs.snapshot.json b/tests/aws/services/apigateway/test_apigateway_sqs.snapshot.json new file mode 100644 index 0000000000000..ef8e4017519ad --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_sqs.snapshot.json @@ -0,0 +1,123 @@ +{ + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_request_and_response_xml_templates_integration": { + "recorded-date": "12-07-2024, 16:27:03", + "recorded-content": { + "sqs-json-response": { + "messageId": "", + "requestId": "" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration_with_message_attribute[MessageAttribute]": { + "recorded-date": "06-06-2024, 00:38:25", + "recorded-content": { + "sqs-message-body": { + "foo": "bar" + }, + "sqs-message-attributes": { + "user-agent": { + "DataType": "String", + "StringValue": "BAR-Header" + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration_with_message_attribute[MessageAttributes]": { + "recorded-date": "06-06-2024, 00:38:34", + "recorded-content": { + "sqs-message-body": { + "foo": "bar" + }, + "sqs-message-attributes": { + "user-agent": { + "DataType": "String", + "StringValue": "BAR-Header" + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration": { + "recorded-date": "19-03-2025, 13:27:52", + "recorded-content": { + "sqs-aws-integration-with-payload": { + "message": "great success!" + }, + "sqs-aws-integration-without-payload": { + "message": "great success!" + }, + "sqs-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "Body": { + "foo": "bar" + } + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "99914b932bd37a50b983c5e7c90ae93b", + "Body": {} + } + ] + } + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_amz_json_protocol": { + "recorded-date": "20-05-2025, 15:07:32", + "recorded-content": { + "sqs-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "context": { + "resourceId": "", + "resourcePath": "/sqs", + "httpMethod": "POST", + "extendedRequestId": "", + "requestTime": "", + "path": "/testing/sqs", + "accountId": "111111111111", + "protocol": "HTTP/1.1", + "requestOverride": "", + "stage": "testing", + "domainPrefix": "", + "requestTimeEpoch": "request-time-epoch", + "requestId": "", + "identity": "", + "domainName": "", + "deploymentId": "", + "responseOverride": "", + "apiId": "" + }, + "identity": { + "cognitoIdentityPoolId": "", + "accountId": "", + "cognitoIdentityId": "", + "caller": "", + "sourceIp": "", + "principalOrgId": "", + "accessKey": "", + "cognitoAuthenticationType": "", + "cognitoAuthenticationProvider": "", + "userArn": "", + "userAgent": "python/requests/tests", + "user": "" + }, + "requestOverride": { + "path": "", + "header": "", + "querystring": "" + }, + "responseOverride": { + "header": "" + }, + "authorizer_keys": {} + } + } + ] + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_sqs.validation.json b/tests/aws/services/apigateway/test_apigateway_sqs.validation.json new file mode 100644 index 0000000000000..084035d99c099 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_sqs.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_amz_json_protocol": { + "last_validated_date": "2025-05-20T15:07:32+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration": { + "last_validated_date": "2025-03-19T13:27:52+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration[None]": { + "last_validated_date": "2025-03-19T13:11:24+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration[payload0]": { + "last_validated_date": "2025-03-19T13:11:14+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration_with_message_attribute[MessageAttribute]": { + "last_validated_date": "2024-06-06T00:38:25+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_aws_integration_with_message_attribute[MessageAttributes]": { + "last_validated_date": "2024-06-06T00:38:34+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_sqs.py::test_sqs_request_and_response_xml_templates_integration": { + "last_validated_date": "2024-07-12T16:27:03+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_ssm.py b/tests/aws/services/apigateway/test_apigateway_ssm.py new file mode 100644 index 0000000000000..e428c26520998 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_ssm.py @@ -0,0 +1,158 @@ +import json + +import pytest +import xmltodict +from botocore.auth import SigV4Auth + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.http import safe_requests as requests +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url +from tests.aws.services.apigateway.conftest import APIGATEWAY_ASSUME_ROLE_POLICY, is_next_gen_api + + +@markers.aws.validated +@pytest.mark.skipif(condition=not is_next_gen_api(), reason="Not implemented in default APIGW") +@markers.snapshot.skip_snapshot_verify( + # seems like LocalStack is not returning the field + path=["$..Tier"], +) +def test_ssm_aws_integration( + aws_client, + create_parameter, + create_rest_apigw, + create_role_with_policy, + region_name, + snapshot, +): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value( + "LastModifiedDate", reference_replacement=False, value_replacement="" + ) + ] + ) + param_name = "param-test-123" + put_param = create_parameter( + Name=param_name, + Description="test", + Value="123", + Type="String", + ) + snapshot.match("put-param", put_param) + api_id, _, root = create_rest_apigw(name="aws ssm parameter api") + + # create invocation role + _, role_arn = create_role_with_policy( + "Allow", "ssm:GetParameter", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, + parentId=root, + pathPart="ssm", + )["id"] + + # create method and integration + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + authorizationType="NONE", + ) + + uri = f"arn:aws:apigateway:{region_name}:ssm:action/GetParameter" + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + integrationHttpMethod="POST", + type="AWS", + credentials=role_arn, + uri=uri, + passthroughBehavior="WHEN_NO_TEMPLATES", + requestParameters={"integration.request.querystring.Name": f"'{param_name}'"}, + ) + + aws_client.apigateway.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + ) + + aws_client.apigateway.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod="GET", + statusCode="200", + responseModels={"application/json": "Empty"}, + ) + + aws_client.apigateway.create_deployment(restApiId=api_id, stageName="test") + + url = api_invoke_url(api_id=api_id, stage="test", path="/ssm") + + def invoke_api() -> requests.Response: + _response = requests.get(url) + assert _response.ok + return _response + + response = retry(invoke_api, sleep=2, retries=10) + body = response.json()["GetParameterResponse"] + body["ResponseMetadata"]["HTTPHeaders"] = response.headers + snapshot.match("ssm-aws-integration", body) + + +@markers.aws.validated +@pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="Legacy protocol, just to confirm AWS behavior", +) +def test_get_parameter_query_protocol( + create_parameter, aws_client, aws_http_client_factory, region_name, snapshot +): + """ + This test is written to confirm the behavior from AWS. It seems that by default, AWS will target the legacy + Query protocol version of SSM. + """ + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Name"), + snapshot.transform.key_value( + "LastModifiedDate", reference_replacement=False, value_replacement="" + ), + ] + ) + param_name = f"param-{short_uid()}" + create_parameter( + Name=param_name, + Description="test", + Value="123", + Type="String", + ) + + ssm_http_client = aws_http_client_factory("ssm", signer_factory=SigV4Auth) + + endpoint_url = f"https://ssm.{region_name}.amazonaws.com" + parameters = { + "Action": "GetParameter", + "Name": param_name, + } + + resp = ssm_http_client.post( + url=endpoint_url, + params=parameters, + ) + response_json = xmltodict.parse(resp.content)["GetParameterResponse"] + response_json["ResponseMetadata"]["HTTPHeaders"] = resp.headers + snapshot.match("get-parameter-query-default", response_json) + + resp = ssm_http_client.post( + url=endpoint_url, params=parameters, headers={"Accept": "application/json"} + ) + response_json = resp.json()["GetParameterResponse"] + response_json["ResponseMetadata"]["HTTPHeaders"] = resp.headers + snapshot.match("get-parameter-query-json", response_json) diff --git a/tests/aws/services/apigateway/test_apigateway_ssm.snapshot.json b/tests/aws/services/apigateway/test_apigateway_ssm.snapshot.json new file mode 100644 index 0000000000000..3b94eb6293865 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_ssm.snapshot.json @@ -0,0 +1,73 @@ +{ + "tests/aws/services/apigateway/test_apigateway_ssm.py::test_ssm_aws_integration": { + "recorded-date": "12-07-2024, 19:13:50", + "recorded-content": { + "put-param": { + "Tier": "Standard", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "ssm-aws-integration": { + "GetParameterResult": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter/param-test-123", + "DataType": "text", + "LastModifiedDate": "", + "Name": "param-test-123", + "Selector": null, + "SourceResult": null, + "Type": "String", + "Value": "123", + "Version": 1 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {} + } + } + } + }, + "tests/aws/services/apigateway/test_apigateway_ssm.py::test_get_parameter_query_protocol": { + "recorded-date": "12-07-2024, 19:14:34", + "recorded-content": { + "get-parameter-query-default": { + "@xmlns": "http://ssm.amazonaws.com/doc/2014-11-06/", + "GetParameterResult": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "", + "Name": "", + "Type": "String", + "Value": "123", + "Version": "1" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {} + } + }, + "get-parameter-query-json": { + "GetParameterResult": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "", + "Name": "", + "Selector": null, + "SourceResult": null, + "Type": "String", + "Value": "123", + "Version": 1 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {} + } + } + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_ssm.validation.json b/tests/aws/services/apigateway/test_apigateway_ssm.validation.json new file mode 100644 index 0000000000000..9201f76c03f75 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_ssm.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/apigateway/test_apigateway_ssm.py::test_get_parameter_query_protocol": { + "last_validated_date": "2024-07-12T19:14:34+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_ssm.py::test_ssm_aws_integration": { + "last_validated_date": "2024-07-12T19:13:50+00:00" + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_stepfunctions.py b/tests/aws/services/apigateway/test_apigateway_stepfunctions.py new file mode 100644 index 0000000000000..985219697aaf8 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_stepfunctions.py @@ -0,0 +1,188 @@ +import json + +import pytest +import requests + +from localstack.aws.api.lambda_ import Runtime +from localstack.constants import ( + APPLICATION_JSON, +) +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import ( + api_invoke_url, + create_rest_api_method_response, +) +from tests.aws.services.apigateway.conftest import ( + APIGATEWAY_ASSUME_ROLE_POLICY, + APIGATEWAY_LAMBDA_POLICY, + APIGATEWAY_STEPFUNCTIONS_POLICY, + STEPFUNCTIONS_ASSUME_ROLE_POLICY, +) +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_PYTHON_ECHO + + +class TestApigatewayStepfunctions: + @markers.aws.validated + @pytest.mark.parametrize("action", ["StartExecution", "DeleteStateMachine"]) + def test_apigateway_with_step_function_integration( + self, + action, + create_lambda_function, + create_rest_apigw, + create_iam_role_with_policy, + aws_client, + account_id, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.key_value("executionArn", "executionArn")) + snapshot.add_transformer( + snapshot.transform.jsonpath( + jsonpath="$..startDate", + value_replacement="", + reference_replacement=False, + ) + ) + + region_name = aws_client.apigateway._client_config.region_name + + # create lambda + fn_name = f"lambda-sfn-apigw-{short_uid()}" + lambda_arn = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=fn_name, + runtime=Runtime.python3_9, + )["CreateFunctionResponse"]["FunctionArn"] + + # create state machine and permissions for step function to invoke lambda + role_arn = create_iam_role_with_policy( + RoleName=f"sfn_role-{short_uid()}", + PolicyName=f"sfn-role-policy-{short_uid()}", + RoleDefinition=STEPFUNCTIONS_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_LAMBDA_POLICY, + ) + + state_machine_name = f"test-{short_uid()}" + state_machine_def = { + "Comment": "Hello World example", + "StartAt": "step1", + "States": { + "step1": {"Type": "Task", "Resource": "__tbd__", "End": True}, + }, + } + state_machine_def["States"]["step1"]["Resource"] = lambda_arn + result = aws_client.stepfunctions.create_state_machine( + name=state_machine_name, + definition=json.dumps(state_machine_def), + roleArn=role_arn, + type="EXPRESS", + ) + sm_arn = result["stateMachineArn"] + + # create REST API with integrations + rest_api, _, root_id = create_rest_apigw( + name=f"test-{short_uid()}", description="test-step-function-integration" + ) + aws_client.apigateway.put_method( + restApiId=rest_api, + resourceId=root_id, + httpMethod="POST", + authorizationType="NONE", + ) + create_rest_api_method_response( + aws_client.apigateway, + restApiId=rest_api, + resourceId=root_id, + httpMethod="POST", + statusCode="200", + ) + + # give permission to api gateway to invoke step function + uri = f"arn:aws:apigateway:{region_name}:states:action/{action}" + assume_role_arn = create_iam_role_with_policy( + RoleName=f"role-apigw-{short_uid()}", + PolicyName=f"policy-apigw-{short_uid()}", + RoleDefinition=APIGATEWAY_ASSUME_ROLE_POLICY, + PolicyDefinition=APIGATEWAY_STEPFUNCTIONS_POLICY, + ) + + def _prepare_integration(request_template=None, response_template=None): + aws_client.apigateway.put_integration( + restApiId=rest_api, + resourceId=root_id, + httpMethod="POST", + integrationHttpMethod="POST", + type="AWS", + uri=uri, + credentials=assume_role_arn, + requestTemplates=request_template, + ) + + aws_client.apigateway.put_integration_response( + restApiId=rest_api, + resourceId=root_id, + selectionPattern="", + responseTemplates=response_template, + httpMethod="POST", + statusCode="200", + ) + + test_data = {"test": "test-value"} + url = api_invoke_url(api_id=rest_api, stage="dev", path="/") + + req_template = { + "application/json": """ + { + "input": "$util.escapeJavaScript($input.json('$'))", + "stateMachineArn": "%s" + } + """ + % sm_arn + } + match action: + case "StartExecution": + _prepare_integration(req_template, response_template={}) + aws_client.apigateway.create_deployment(restApiId=rest_api, stageName="dev") + + # invoke stepfunction via API GW, assert results + def _invoke_start_step_function(): + resp = requests.post(url, data=json.dumps(test_data)) + assert resp.ok + content = json.loads(resp.content) + assert "executionArn" in content + assert "startDate" in content + return content + + body = retry(_invoke_start_step_function, retries=15, sleep=0.8) + snapshot.match("start_execution_response", body) + + case "StartSyncExecution": + resp_template = {APPLICATION_JSON: "$input.path('$.output')"} + _prepare_integration(req_template, resp_template) + aws_client.apigateway.create_deployment(restApiId=rest_api, stageName="dev") + input_data = {"input": json.dumps(test_data), "name": "MyExecution"} + + def _invoke_start_sync_step_function(): + input_data["name"] += "1" + resp = requests.post(url, data=json.dumps(input_data)) + assert resp.ok + body = json.loads(resp.content) + assert test_data == body + return body + + body = retry(_invoke_start_sync_step_function, retries=15, sleep=0.8) + snapshot.match("start_sync_response", body) + + case "DeleteStateMachine": + _prepare_integration({}, {}) + aws_client.apigateway.create_deployment(restApiId=rest_api, stageName="dev") + + def _invoke_step_function(): + resp = requests.post(url, data=json.dumps({"stateMachineArn": sm_arn})) + # If the action is successful, the service sends back an HTTP 200 response with an empty HTTP body. + assert resp.ok + return json.loads(resp.content) + + body = retry(_invoke_step_function, retries=15, sleep=1) + snapshot.match("delete_state_machine_response", body) diff --git a/tests/aws/services/apigateway/test_apigateway_stepfunctions.snapshot.json b/tests/aws/services/apigateway/test_apigateway_stepfunctions.snapshot.json new file mode 100644 index 0000000000000..0143a44762ef5 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_stepfunctions.snapshot.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/apigateway/test_apigateway_stepfunctions.py::TestApigatewayStepfunctions::test_apigateway_with_step_function_integration[StartExecution]": { + "recorded-date": "12-07-2024, 19:51:49", + "recorded-content": { + "start_execution_response": { + "executionArn": "", + "startDate": "" + } + } + }, + "tests/aws/services/apigateway/test_apigateway_stepfunctions.py::TestApigatewayStepfunctions::test_apigateway_with_step_function_integration[DeleteStateMachine]": { + "recorded-date": "12-07-2024, 19:52:03", + "recorded-content": { + "delete_state_machine_response": {} + } + } +} diff --git a/tests/aws/services/apigateway/test_apigateway_stepfunctions.validation.json b/tests/aws/services/apigateway/test_apigateway_stepfunctions.validation.json new file mode 100644 index 0000000000000..4dbbed4abba13 --- /dev/null +++ b/tests/aws/services/apigateway/test_apigateway_stepfunctions.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/apigateway/test_apigateway_stepfunctions.py::TestApigatewayStepfunctions::test_apigateway_with_step_function_integration[DeleteStateMachine]": { + "last_validated_date": "2024-07-12T19:52:03+00:00" + }, + "tests/aws/services/apigateway/test_apigateway_stepfunctions.py::TestApigatewayStepfunctions::test_apigateway_with_step_function_integration[StartExecution]": { + "last_validated_date": "2024-07-12T19:51:49+00:00" + } +} diff --git a/tests/aws/services/cloudcontrol/__init__.py b/tests/aws/services/cloudcontrol/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudcontrol/test_cloudcontrol_api.py b/tests/aws/services/cloudcontrol/test_cloudcontrol_api.py new file mode 100644 index 0000000000000..9bf6a05ff96ea --- /dev/null +++ b/tests/aws/services/cloudcontrol/test_cloudcontrol_api.py @@ -0,0 +1,606 @@ +import json +import logging +from typing import Callable, ParamSpec, TypeVar + +import jsonpatch +import pytest +from botocore.exceptions import WaiterError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.aws.api.cloudcontrol import Operation, OperationStatus +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import PATTERN_UUID +from localstack.utils.strings import long_uid, short_uid +from localstack.utils.sync import ShortCircuitWaitException, wait_until + +LOG = logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def cc_snapshot(snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Identifier")) + snapshot.add_transformer(snapshot.transform.key_value("RequestToken")) + snapshot.add_transformer(snapshot.transform.key_value("NextToken")) + snapshot.add_transformer(SortingTransformer("ResourceDescriptions", lambda x: x["Identifier"])) + + +T = TypeVar("T") +P = ParamSpec("P") + + +@pytest.fixture +def create_resource(aws_client): + resource_requests = [] + + def _create(_: Callable[P, T]) -> Callable[P, T]: + def _inner_create(*args: P.args, **kwargs: P.kwargs) -> T: + try: + result = aws_client.cloudcontrol.create_resource(*args, **kwargs) + resource_requests.append(result["ProgressEvent"]["RequestToken"]) + return result + except Exception: + raise # TODO + + return _inner_create + + yield _create(aws_client.cloudcontrol.create_resource) + + # cleanup + for rr in resource_requests: + try: + progress_event = aws_client.cloudcontrol.get_resource_request_status(RequestToken=rr)[ + "ProgressEvent" + ] + if progress_event["OperationStatus"] in [ + OperationStatus.IN_PROGRESS, + OperationStatus.PENDING, + ]: + aws_client.cloudcontrol.get_waiter("resource_request_success").wait(RequestToken=rr) + + delete_request = aws_client.cloudcontrol.delete_resource( + TypeName=progress_event["TypeName"], Identifier=progress_event["Identifier"] + ) + aws_client.cloudcontrol.get_waiter("resource_request_success").wait( + RequestToken=delete_request["ProgressEvent"]["RequestToken"] + ) + except Exception: + LOG.warning("Failed to delete resource with request token %s", rr) + + +@pytest.mark.skip("Not Implemented yet") +class TestCloudControlResourceApi: + @markers.aws.validated + def test_lifecycle(self, snapshot, create_resource, aws_client): + """simple create/delete lifecycle for a resource""" + + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "uuid")) + waiter = aws_client.cloudcontrol.get_waiter("resource_request_success") + + request_token = long_uid() + bucket_name = f"cc-test-bucket-{short_uid()}" + create_response = create_resource( + TypeName="AWS::S3::Bucket", + DesiredState=json.dumps({"BucketName": bucket_name}), + ClientToken=request_token, + # RoleArn="asdf", # optional, by default uses client session + # TypeVersionId="asdf", # optional, without it should use the default + ) + snapshot.match("create_response", create_response) + + waiter.wait(RequestToken=create_response["ProgressEvent"]["RequestToken"]) + + # stabilized state + get_status_response = aws_client.cloudcontrol.get_resource_request_status( + RequestToken=create_response["ProgressEvent"]["RequestToken"] + ) + snapshot.match("get_status_response", get_status_response) + assert get_status_response["ProgressEvent"]["OperationStatus"] == "SUCCESS" + get_response = aws_client.cloudcontrol.get_resource( + TypeName="AWS::S3::Bucket", + Identifier=get_status_response["ProgressEvent"]["Identifier"], + ) + snapshot.match("get_response", get_response) + + # delete the resource again + delete_response = aws_client.cloudcontrol.delete_resource( + TypeName="AWS::S3::Bucket", Identifier=bucket_name + ) + snapshot.match("delete_response", delete_response) + waiter.wait(RequestToken=delete_response["ProgressEvent"]["RequestToken"]) + + get_request_status_response_postdelete = ( + aws_client.cloudcontrol.get_resource_request_status( + RequestToken=delete_response["ProgressEvent"]["RequestToken"] + ) + ) + snapshot.match( + "get_request_status_response_postdelete", get_request_status_response_postdelete + ) + + # verify bucket is not here anymore + with pytest.raises( + aws_client.cloudcontrol.exceptions.ResourceNotFoundException + ) as res_not_found_exc: + aws_client.cloudcontrol.get_resource(TypeName="AWS::S3::Bucket", Identifier=bucket_name) + snapshot.match("res_not_found_exc", res_not_found_exc.value.response) + + # also verify with s3 API + with pytest.raises(aws_client.s3.exceptions.ClientError): + aws_client.s3.head_bucket(Bucket=bucket_name) + + @markers.aws.validated + def test_api_exceptions(self, snapshot, aws_client): + """ + Test a few edge cases in the API which do not need the creating of resources + + Learnings: + - all operations care if the type name exists + - delete_resource does not care if the identifier doesn't exist (!) + - update handler seems to be written in java and first deserializes the patch document before checking anything else + + """ + nonexisting_identifier = f"localstack-doesnotexist-{short_uid()}" + + # create + with pytest.raises(aws_client.cloudcontrol.exceptions.TypeNotFoundException) as e: + aws_client.cloudcontrol.create_resource( + TypeName="AWS::LocalStack::DoesNotExist", DesiredState=json.dumps({}) + ) + snapshot.match("create_nonexistingtype", e.value.response) + + # delete + with pytest.raises(aws_client.cloudcontrol.exceptions.TypeNotFoundException) as e: + aws_client.cloudcontrol.delete_resource( + TypeName="AWS::LocalStack::DoesNotExist", Identifier=nonexisting_identifier + ) + snapshot.match("delete_nonexistingtype", e.value.response) + + delete_nonexistingresource = aws_client.cloudcontrol.delete_resource( + TypeName="AWS::S3::Bucket", Identifier=nonexisting_identifier + ) + snapshot.match("delete_nonexistingresource", delete_nonexistingresource) + + # get + with pytest.raises(aws_client.cloudcontrol.exceptions.TypeNotFoundException) as e: + aws_client.cloudcontrol.get_resource( + TypeName="AWS::LocalStack::DoesNotExist", Identifier=nonexisting_identifier + ) + snapshot.match("get_nonexistingtype", e.value.response) + + with pytest.raises(aws_client.cloudcontrol.exceptions.ResourceNotFoundException) as e: + aws_client.cloudcontrol.get_resource( + TypeName="AWS::S3::Bucket", Identifier=nonexisting_identifier + ) + # TODO + # snapshot.match("get_nonexisting", e.value.response) + + # update + with pytest.raises(aws_client.cloudcontrol.exceptions.TypeNotFoundException) as e: + aws_client.cloudcontrol.update_resource( + TypeName="AWS::LocalStack::DoesNotExist", + Identifier=nonexisting_identifier, + PatchDocument=json.dumps([{"op": "replace", "path": "/something", "value": 30}]), + ) + snapshot.match("update_nonexistingtype", e.value.response) + + with pytest.raises(aws_client.cloudcontrol.exceptions.ClientError) as e: + aws_client.cloudcontrol.update_resource( + TypeName="AWS::LocalStack::DoesNotExist", + Identifier=nonexisting_identifier, + PatchDocument=json.dumps([]), + ) + snapshot.match("update_invalidpatchdocument", e.value.response) + + with pytest.raises(aws_client.cloudcontrol.exceptions.ResourceNotFoundException) as e: + aws_client.cloudcontrol.update_resource( + TypeName="AWS::S3::Bucket", + Identifier=nonexisting_identifier, + PatchDocument=json.dumps([{"op": "replace", "path": "/something", "value": 30}]), + ) + # TODO + # snapshot.match("update_nonexisting", e.value.response) + + # list + with pytest.raises(aws_client.cloudcontrol.exceptions.TypeNotFoundException) as e: + aws_client.cloudcontrol.list_resources(TypeName="AWS::LocalStack::DoesNotExist") + snapshot.match("list_nonexistingtype", e.value.response) + + @markers.aws.validated + def test_list_resources(self, create_resource, snapshot, aws_client): + # TODO: test if only "terminal" states are included in lists (blocked by cfn registry) + # TODO: test with custom type-version-id (blocked by cfn registry) + # TODO: test empty (blocked by cfn registry) + + bucket_name_prefix = f"cc-test-bucket-{short_uid()}" + bucket_name_1 = f"{bucket_name_prefix}-1" + bucket_name_2 = f"{bucket_name_prefix}-2" + waiter = aws_client.cloudcontrol.get_waiter("resource_request_success") + + create_bucket_1 = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({"BucketName": bucket_name_1}) + ) + create_bucket_2 = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({"BucketName": bucket_name_2}) + ) + waiter.wait(RequestToken=create_bucket_1["ProgressEvent"]["RequestToken"]) + waiter.wait(RequestToken=create_bucket_2["ProgressEvent"]["RequestToken"]) + + # test pagination + paginator = aws_client.cloudcontrol.get_paginator("list_resources") + + list_paginated_first = paginator.paginate( + TypeName="AWS::S3::Bucket", PaginationConfig={"MaxItems": 1} + ).build_full_result() + list_paginated_second = paginator.paginate( + TypeName="AWS::S3::Bucket", + PaginationConfig={"MaxItems": 1, "StartingToken": list_paginated_first["NextToken"]}, + ).build_full_result() + list_paginated_all = paginator.paginate(TypeName="AWS::S3::Bucket").build_full_result() + + # verify that MaxItems works as expected + assert len(list_paginated_first["ResourceDescriptions"]) == 1 + assert len(list_paginated_second["ResourceDescriptions"]) == 1 + # verify that using the NextToken, we actually received a different resource + assert ( + list_paginated_first["ResourceDescriptions"][0]["Identifier"] + != list_paginated_second["ResourceDescriptions"][0]["Identifier"] + ) + # verify that when getting *all* of them, we at least get both + assert len(list_paginated_all["ResourceDescriptions"]) >= 2 + + list_paginated_all["ResourceDescriptions"] = [ + rd + for rd in list_paginated_all["ResourceDescriptions"] + if rd["Identifier"] + in [ + bucket_name_1, + bucket_name_2, + ] # need to filter here since there are probably other buckets in the account as well + ] + snapshot.match("list_paginated_all_filtered", list_paginated_all) + + with pytest.raises(aws_client.cloudcontrol.exceptions.TypeNotFoundException) as e: + aws_client.cloudcontrol.list_resources(TypeName="AWS::DoesNot::Exist") + snapshot.match("list_typenotfound_exc", e.value.response) + + @pytest.mark.skip(reason="advanced feature, will be added later") + @markers.aws.validated + def test_list_resources_with_resource_model(self, create_resource, snapshot, aws_client): + """ + See: https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/resource-operations-list.html + """ + with pytest.raises(aws_client.cloudcontrol.exceptions.InvalidRequestException) as e: + aws_client.cloudcontrol.list_resources(TypeName="AWS::ApiGateway::Stage") + snapshot.match("missing_resource_model_exc", e.value.response) + + # TODO: actually set up rest API and AWS::ApiGateway::Stage for a positive sample + + @markers.aws.validated + def test_double_create_with_client_token(self, create_resource, snapshot, aws_client): + """ + ClientToken is used to deduplicate requests + """ + bucket_name_prefix = f"cc-test-bucket-clienttoken-{short_uid()}" + client_token = long_uid() + snapshot.add_transformer(snapshot.transform.regex(client_token, " request fails (even though it's a new desired state now!) + with pytest.raises(aws_client.cloudcontrol.exceptions.ClientTokenConflictException) as e: + create_resource( + TypeName="AWS::S3::Bucket", + DesiredState=json.dumps({"BucketName": f"{bucket_name_prefix}-2"}), + ClientToken=client_token, + ) + snapshot.match("create_response_duplicate_exc", e.value.response) + + @markers.aws.validated + def test_create_exceptions(self, create_resource, snapshot, aws_client): + """ + learnings: + - the create call basically always passes, independent of desired state. The failure only shows up by checking the status + - the exception to this is when specifying something that isn't included at all in the schema. (extra keys) + """ + bucket_name = f"localstack-testing-{short_uid()}-1" + waiter = aws_client.cloudcontrol.get_waiter("resource_request_success") + + create_bucket_response = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({"BucketName": bucket_name}) + ) + snapshot.match("create_response", create_bucket_response) + waiter.wait(RequestToken=create_bucket_response["ProgressEvent"]["RequestToken"]) + + # 1. duplicate identifier + create_duplicate_response = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({"BucketName": bucket_name}) + ) + snapshot.match("create_duplicate_response", create_duplicate_response) + with pytest.raises(WaiterError): + waiter.wait(RequestToken=create_duplicate_response["ProgressEvent"]["RequestToken"]) + + post_wait_response = aws_client.cloudcontrol.get_resource_request_status( + RequestToken=create_duplicate_response["ProgressEvent"]["RequestToken"] + ) + snapshot.match("duplicate_post_wait_response", post_wait_response) + assert post_wait_response["ProgressEvent"]["OperationStatus"] == OperationStatus.FAILED + + # 2. missing required properties + + # in this case only the BucketName has to be provided + create_missingproperty_response = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({}) + ) + snapshot.match("create_missingproperty_response", create_missingproperty_response) + waiter.wait(RequestToken=create_missingproperty_response["ProgressEvent"]["RequestToken"]) + missing_post_wait_response = aws_client.cloudcontrol.get_resource_request_status( + RequestToken=create_missingproperty_response["ProgressEvent"]["RequestToken"] + ) + snapshot.match("missing_post_wait_response", missing_post_wait_response) + + # 3. additional properties not in spec + with pytest.raises(aws_client.cloudcontrol.exceptions.ClientError) as e: + create_resource( + TypeName="AWS::S3::Bucket", + DesiredState=json.dumps({"BucketName": bucket_name, "BucketSomething": "hello"}), + ) + snapshot.match("create_extra_property_exc", e.value.response) + + @markers.aws.validated + def test_create_invalid_desiredstate(self, snapshot, aws_client): + with pytest.raises(aws_client.cloudcontrol.exceptions.ClientError) as e: + aws_client.cloudcontrol.create_resource( + TypeName="AWS::S3::Bucket", + DesiredState=json.dumps({"DOESNOTEXIST": "invalidvalue"}), + ) + snapshot.match("create_invalid_state_exc_invalid_field", e.value.response) + + with pytest.raises(aws_client.cloudcontrol.exceptions.ClientError) as e: + aws_client.cloudcontrol.create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({"BucketName": True}) + ) + snapshot.match("create_invalid_state_exc_invalid_type", e.value.response) + + # TODO: updates + @markers.aws.validated + def test_update(self, create_resource, snapshot, aws_client): + bucket_name = f"localstack-testing-cc-{short_uid()}" + initial_state = {"BucketName": bucket_name} + create_response = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps(initial_state) + ) + waiter = aws_client.cloudcontrol.get_waiter("resource_request_success") + waiter.wait(RequestToken=create_response["ProgressEvent"]["RequestToken"]) + + # add a property that didn't exist before + second_state = {"BucketName": bucket_name, "Tags": [{"Key": "a", "Value": "123"}]} + patch = jsonpatch.make_patch(initial_state, second_state).patch + update_response = aws_client.cloudcontrol.update_resource( + TypeName="AWS::S3::Bucket", + Identifier=create_response["ProgressEvent"]["Identifier"], + PatchDocument=json.dumps(patch), + ) + waiter.wait(RequestToken=update_response["ProgressEvent"]["RequestToken"]) + + # update something that doesn't require a replacement + third_state = {"BucketName": bucket_name, "Tags": [{"Key": "b", "Value": "234"}]} + patch = jsonpatch.make_patch(second_state, third_state).patch + update_response = aws_client.cloudcontrol.update_resource( + TypeName="AWS::S3::Bucket", + Identifier=create_response["ProgressEvent"]["Identifier"], + PatchDocument=json.dumps(patch), + ) + waiter.wait(RequestToken=update_response["ProgressEvent"]["RequestToken"]) + + # try to update something that *DOES* require a replacement + # this leads to an NotUpdatableException while on cloudformation this would cause a replacement + final_state = {"BucketName": f"{bucket_name}plus", "Tags": [{"Key": "b", "Value": "234"}]} + patch = jsonpatch.make_patch(third_state, final_state).patch + with pytest.raises(aws_client.cloudcontrol.exceptions.NotUpdatableException) as e: + aws_client.cloudcontrol.update_resource( + TypeName="AWS::S3::Bucket", + Identifier=create_response["ProgressEvent"]["Identifier"], + PatchDocument=json.dumps(patch), + ) + snapshot.match("update_createonlyproperty_exc", e.value.response) + + +@pytest.mark.skip("Not Implemented yet") +class TestCloudControlResourceRequestApi: + @markers.aws.validated + def test_invalid_request_token_exc(self, snapshot, aws_client): + """Test behavior of methods when invoked with non-existing RequestToken""" + with pytest.raises(aws_client.cloudcontrol.exceptions.RequestTokenNotFoundException) as e1: + aws_client.cloudcontrol.get_resource_request_status(RequestToken="DOESNOTEXIST") + snapshot.match("get_token_not_found", e1.value.response) + + with pytest.raises(aws_client.cloudcontrol.exceptions.RequestTokenNotFoundException) as e2: + aws_client.cloudcontrol.cancel_resource_request(RequestToken="DOESNOTEXIST") + snapshot.match("cancel_token_not_found", e2.value.response) + + @markers.aws.validated + def test_list_request_status(self, snapshot, create_resource, aws_client): + """ + This is a bit tricky to test against AWS because these lists are not manually "clearable" and instead are cleared after some time (7 days?) + To accommodate for this we manually filter the resources here before snapshotting the response list. + Even with this though we run into issues when paging. So at some point when testing this too much we'll have way too many resource requests in the account. :thisisfine: + + Interesting observation: + * Some resource requests can have an OperationStatus of 'FAILED', + even though the resource type doesn't even exist and they do *NOT* have an 'Operation' field for some reason. + This means when we add a Filter for Operation, even though we have all Fields active, we won't see these entries. + + TODO: test pagination + TODO: more control over resource states (otherwise this test might turn out to be too flaky) + """ + + bucket_name = f"cc-test-list-bucket-{short_uid()}" + + def filter_response_by_request_token(response, request_tokens): + """this method mutates the response (!)""" + response["ResourceRequestStatusSummaries"] = [ + s + for s in response["ResourceRequestStatusSummaries"] + if s["RequestToken"] in request_tokens + ] + + create_bucket_resource = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({"BucketName": bucket_name}) + ) + bucket_request_token = create_bucket_resource["ProgressEvent"]["RequestToken"] + snapshot.match("create_bucket_resource", create_bucket_resource) + + # by default no filter should be equal to specifying all OperationStatuses + paginator = aws_client.cloudcontrol.get_paginator("list_resource_requests") + list_requests_response_default = paginator.paginate().build_full_result() + list_requests_response_all = paginator.paginate( + ResourceRequestStatusFilter={ + "OperationStatuses": [ + OperationStatus.PENDING, + OperationStatus.IN_PROGRESS, + OperationStatus.SUCCESS, + OperationStatus.FAILED, + OperationStatus.CANCEL_IN_PROGRESS, + OperationStatus.CANCEL_COMPLETE, + ], + } + ).build_full_result() + assert len(list_requests_response_default["ResourceRequestStatusSummaries"]) == len( + list_requests_response_all["ResourceRequestStatusSummaries"] + ) + + list_requests_response_filtered = paginator.paginate( + ResourceRequestStatusFilter={ + "Operations": [ + Operation.CREATE, + ] + } + ).build_full_result() + filter_response_by_request_token(list_requests_response_filtered, [bucket_request_token]) + snapshot.match("list_requests_response_filtered", list_requests_response_filtered) + + # doing the same request but filtering for a different operation, should not find the create bucket operation + list_requests_response_filtered_update = paginator.paginate( + ResourceRequestStatusFilter={ + "Operations": [ + Operation.UPDATE, + ] + } + ).build_full_result() + filter_response_by_request_token( + list_requests_response_filtered_update, [bucket_request_token] + ) + snapshot.match( + "list_requests_response_filtered_update", list_requests_response_filtered_update + ) + + @pytest.mark.skip(reason="needs a more complicated test setup") + @markers.aws.validated + def test_get_request_status(self, snapshot, aws_client): + """ + Tries to trigger all states ("CANCEL_COMPLETE", "CANCEL_IN_PROGRESS", "FAILED", "IN_PROGRESS", "PENDING", "SUCCESS") + + TODO: write a custom resource that can be controlled for this purpose + For now we just assume some things on AWS to get a coarse understanding + """ + # 1. PENDING (this makes use of the fact that only one operation for a resource can run at the same time) + # 2. IN_PROGRESS (just some resource that is fairly slow to create (even buckets take a while...) + # 3. SUCCESS - should be clear + # 4. CANCEL_IN_PROGRESS - same as 2 + # 5. CANCEL_COMPLETE - same as 2 + # 6. FAILED - ? not sure yet ? + pass + + @markers.aws.validated + def test_cancel_request(self, snapshot, create_resource, aws_client): + """ + Creates a resource & immediately cancels the create request + + Observation: + * Even though the status is "CANCEL_COMPLETE", the bucket might still have been created! + * There is no rollback, a cancel simply stops the handler from continuing but will not cause it to revert what it did so far. + * cancel_resource_request is "idempotent" and will not fail when it has already been canceled + + TODO: make this more reliable via custom resource that waits for an event to change state + would allow us to have finer control over it and properly test non-terminal states + """ + bucket_name = f"cc-test-bucket-cancel-{short_uid()}" + create_response = create_resource( + TypeName="AWS::S3::Bucket", DesiredState=json.dumps({"BucketName": bucket_name}) + ) + snapshot.match("create_response", create_response) + + # this is not 100% reliable, depending on how fast the request above is processed. + cancel_response = aws_client.cloudcontrol.cancel_resource_request( + RequestToken=create_response["ProgressEvent"]["RequestToken"] + ) + assert cancel_response["ProgressEvent"]["OperationStatus"] in [ + OperationStatus.CANCEL_IN_PROGRESS, + OperationStatus.CANCEL_COMPLETE, + ] + + def wait_for_cc_canceled(request_token): + def _wait_for_canceled(): + resp = aws_client.cloudcontrol.get_resource_request_status( + RequestToken=request_token + ) + op_status = resp["ProgressEvent"]["OperationStatus"] + if op_status in [OperationStatus.FAILED, OperationStatus.SUCCESS]: + raise ShortCircuitWaitException() + return op_status == OperationStatus.CANCEL_COMPLETE + + return _wait_for_canceled + + assert wait_until(wait_for_cc_canceled(cancel_response["ProgressEvent"]["RequestToken"])) + snapshot.match( + "cancel_request_status", + aws_client.cloudcontrol.get_resource_request_status( + RequestToken=cancel_response["ProgressEvent"]["RequestToken"] + ), + ) + + cancel_again_response = aws_client.cloudcontrol.cancel_resource_request( + RequestToken=create_response["ProgressEvent"]["RequestToken"] + ) + snapshot.match("cancel_again_response", cancel_again_response) + assert wait_until( + wait_for_cc_canceled(cancel_again_response["ProgressEvent"]["RequestToken"]) + ) + + @pytest.mark.parametrize( + "desired_state", + [json.dumps({"BucketName": ""}), json.dumps({})], + ids=["SUCCESS", "FAIL"], + ) + @markers.aws.validated + def test_cancel_edge_cases(self, create_resource, snapshot, desired_state, aws_client): + """tests canceling a resource request that is in a SUCCESS or FAILED terminal state""" + + # success + bucket_name = f"cc-test-bucket-cancel-{short_uid()}" + create_response = create_resource( + TypeName="AWS::S3::Bucket", + DesiredState=desired_state.replace("", bucket_name), + ) + snapshot.add_transformer( + snapshot.transform.regex( + create_response["ProgressEvent"]["RequestToken"], "" + ) + ) + snapshot.match("create_response", create_response) + try: + aws_client.cloudcontrol.get_waiter("resource_request_success").wait( + RequestToken=create_response["ProgressEvent"]["RequestToken"] + ) + except Exception: + pass # just want to make sure it's in a terminal state here + + with pytest.raises(aws_client.cloudcontrol.exceptions.ClientError) as e: + aws_client.cloudcontrol.cancel_resource_request( + RequestToken=create_response["ProgressEvent"]["RequestToken"] + ) + snapshot.match("cancel_in_success_exc", e.value.response) diff --git a/tests/aws/services/cloudcontrol/test_cloudcontrol_api.snapshot.json b/tests/aws/services/cloudcontrol/test_cloudcontrol_api.snapshot.json new file mode 100644 index 0000000000000..23d4b810adf0e --- /dev/null +++ b/tests/aws/services/cloudcontrol/test_cloudcontrol_api.snapshot.json @@ -0,0 +1,562 @@ +{ + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_edge_cases": { + "recorded-date": "01-02-2023, 08:26:30", + "recorded-content": { + "create_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel_in_success_exc": { + "Error": { + "Code": "ValidationException", + "Message": "Request is already in status SUCCESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_edge_cases[SUCCESS]": { + "recorded-date": "01-02-2023, 09:01:58", + "recorded-content": { + "create_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel_in_success_exc": { + "Error": { + "Code": "ValidationException", + "Message": "Request is already in status SUCCESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_edge_cases[FAIL]": { + "recorded-date": "01-02-2023, 09:02:30", + "recorded-content": { + "create_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel_in_success_exc": { + "Error": { + "Code": "ValidationException", + "Message": "Request is already in status SUCCESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_list_request_status": { + "recorded-date": "01-02-2023, 09:01:18", + "recorded-content": { + "create_bucket_resource": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_requests_response_filtered": { + "ResourceRequestStatusSummaries": [ + { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "RetryAfter": "datetime", + "TypeName": "AWS::S3::Bucket" + } + ] + }, + "list_requests_response_filtered_update": { + "ResourceRequestStatusSummaries": [] + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_invalid_request_token_exc": { + "recorded-date": "01-02-2023, 09:00:47", + "recorded-content": { + "get_token_not_found": { + "Error": { + "Code": "RequestTokenNotFoundException", + "Message": "Request with token DOESNOTEXIST was not found" + }, + "Message": "Request with token DOESNOTEXIST was not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "cancel_token_not_found": { + "Error": { + "Code": "RequestTokenNotFoundException", + "Message": "Request with token DOESNOTEXIST was not found" + }, + "Message": "Request with token DOESNOTEXIST was not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_request": { + "recorded-date": "01-02-2023, 09:01:26", + "recorded-content": { + "create_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel_request_status": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "CANCEL_COMPLETE", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel_again_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "CANCEL_COMPLETE", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_lifecycle": { + "recorded-date": "01-02-2023, 09:20:37", + "recorded-content": { + "create_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_status_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "SUCCESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_response": { + "ResourceDescription": { + "Identifier": "", + "Properties": { + "BucketName": "", + "RegionalDomainName": ".s3..amazonaws.com", + "DomainName": ".s3.amazonaws.com", + "WebsiteURL": "http://.s3-website-.amazonaws.com", + "DualStackDomainName": ".s3.dualstack..amazonaws.com", + "Arn": "arn::s3:::" + } + }, + "TypeName": "AWS::S3::Bucket", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "DELETE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_request_status_response_postdelete": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "DELETE", + "OperationStatus": "SUCCESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "res_not_found_exc": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "AWS::S3::Bucket Handler returned status FAILED: Bucket not found (HandlerErrorCode: NotFound, RequestToken: uuid)" + }, + "Message": "AWS::S3::Bucket Handler returned status FAILED: Bucket not found (HandlerErrorCode: NotFound, RequestToken: uuid)", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_api_exceptions": { + "recorded-date": "01-02-2023, 09:20:39", + "recorded-content": { + "create_nonexistingtype": { + "Error": { + "Code": "TypeNotFoundException", + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found." + }, + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_nonexistingtype": { + "Error": { + "Code": "TypeNotFoundException", + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found." + }, + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_nonexistingresource": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "DELETE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_nonexistingtype": { + "Error": { + "Code": "TypeNotFoundException", + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found." + }, + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update_nonexistingtype": { + "Error": { + "Code": "TypeNotFoundException", + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found." + }, + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update_invalidpatchdocument": { + "Error": { + "Code": "TypeNotFoundException", + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found." + }, + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_nonexistingtype": { + "Error": { + "Code": "TypeNotFoundException", + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found." + }, + "Message": "The type 'AWS::LocalStack::DoesNotExist' cannot be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_list_resources": { + "recorded-date": "01-02-2023, 09:21:18", + "recorded-content": { + "list_paginated_all_filtered": { + "ResourceDescriptions": [ + { + "Identifier": "", + "Properties": { + "BucketName": "" + } + }, + { + "Identifier": "", + "Properties": { + "BucketName": "" + } + } + ], + "TypeName": "AWS::S3::Bucket" + }, + "list_typenotfound_exc": { + "Error": { + "Code": "TypeNotFoundException", + "Message": "The type 'AWS::DoesNot::Exist' cannot be found." + }, + "Message": "The type 'AWS::DoesNot::Exist' cannot be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_double_create_with_client_token": { + "recorded-date": "01-02-2023, 09:21:50", + "recorded-content": { + "create_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_response_duplicate_exc": { + "Error": { + "Code": "ClientTokenConflictException", + "Message": "ClientToken " + }, + "Message": "ClientToken ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_create_exceptions": { + "recorded-date": "01-02-2023, 09:23:04", + "recorded-content": { + "create_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_duplicate_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "duplicate_post_wait_response": { + "ProgressEvent": { + "ErrorCode": "AlreadyExists", + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "FAILED", + "RequestToken": "", + "StatusMessage": "The bucket Already Exists", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_missingproperty_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Operation": "CREATE", + "OperationStatus": "IN_PROGRESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "missing_post_wait_response": { + "ProgressEvent": { + "EventTime": "datetime", + "Identifier": "", + "Operation": "CREATE", + "OperationStatus": "SUCCESS", + "RequestToken": "", + "TypeName": "AWS::S3::Bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_extra_property_exc": { + "Error": { + "Code": "ValidationException", + "Message": "Model validation failed (#: extraneous key [BucketSomething] is not permitted)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_create_invalid_desiredstate": { + "recorded-date": "01-02-2023, 09:23:05", + "recorded-content": { + "create_invalid_state_exc_invalid_field": { + "Error": { + "Code": "ValidationException", + "Message": "Model validation failed (#: extraneous key [DOESNOTEXIST] is not permitted)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_invalid_state_exc_invalid_type": { + "Error": { + "Code": "ValidationException", + "Message": "Model validation failed (#/BucketName: expected type: String, found: Boolean)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_update": { + "recorded-date": "01-02-2023, 09:24:31", + "recorded-content": { + "update_createonlyproperty_exc": { + "Error": { + "Code": "NotUpdatableException", + "Message": "Invalid patch update: createOnlyProperties [/properties/BucketName] cannot be updated" + }, + "Message": "Invalid patch update: createOnlyProperties [/properties/BucketName] cannot be updated", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudcontrol/test_cloudcontrol_api.validation.json b/tests/aws/services/cloudcontrol/test_cloudcontrol_api.validation.json new file mode 100644 index 0000000000000..6207c27842129 --- /dev/null +++ b/tests/aws/services/cloudcontrol/test_cloudcontrol_api.validation.json @@ -0,0 +1,38 @@ +{ + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_api_exceptions": { + "last_validated_date": "2023-02-01T08:20:39+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_create_exceptions": { + "last_validated_date": "2023-02-01T08:23:04+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_create_invalid_desiredstate": { + "last_validated_date": "2023-02-01T08:23:05+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_double_create_with_client_token": { + "last_validated_date": "2023-02-01T08:21:50+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_lifecycle": { + "last_validated_date": "2023-02-01T08:20:37+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_list_resources": { + "last_validated_date": "2023-02-01T08:21:18+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceApi::test_update": { + "last_validated_date": "2023-02-01T08:24:31+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_edge_cases[FAIL]": { + "last_validated_date": "2023-02-01T08:02:30+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_edge_cases[SUCCESS]": { + "last_validated_date": "2023-02-01T08:01:58+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_cancel_request": { + "last_validated_date": "2023-02-01T08:01:26+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_invalid_request_token_exc": { + "last_validated_date": "2023-02-01T08:00:47+00:00" + }, + "tests/aws/services/cloudcontrol/test_cloudcontrol_api.py::TestCloudControlResourceRequestApi::test_list_request_status": { + "last_validated_date": "2023-02-01T08:01:18+00:00" + } +} diff --git a/tests/aws/services/cloudformation/__init__.py b/tests/aws/services/cloudformation/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/api/__init__.py b/tests/aws/services/cloudformation/api/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/api/test_changesets.py b/tests/aws/services/cloudformation/api/test_changesets.py new file mode 100644 index 0000000000000..1f397310f5d21 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_changesets.py @@ -0,0 +1,1214 @@ +import copy +import json +import os.path + +import pytest +from botocore.exceptions import ClientError + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.cloudformation_utils import ( + load_template_file, + load_template_raw, + render_template, +) +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import ShortCircuitWaitException, poll_condition, wait_until +from tests.aws.services.cloudformation.api.test_stacks import ( + MINIMAL_TEMPLATE, +) + + +class TestUpdates: + @markers.aws.validated + def test_simple_update_single_resource( + self, aws_client: ServiceLevelClientFactory, deploy_cfn_template + ): + value1 = "foo" + value2 = "bar" + stack_name = f"stack-{short_uid()}" + + t1 = { + "Resources": { + "MyParameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": value1, + }, + }, + }, + "Outputs": { + "ParameterName": { + "Value": {"Ref": "MyParameter"}, + }, + }, + } + + res = deploy_cfn_template(stack_name=stack_name, template=json.dumps(t1), is_update=False) + parameter_name = res.outputs["ParameterName"] + + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value1 + + t2 = copy.deepcopy(t1) + t2["Resources"]["MyParameter"]["Properties"]["Value"] = value2 + + deploy_cfn_template(stack_name=stack_name, template=json.dumps(t2), is_update=True) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value2 + + res.destroy() + + @pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Not working in v2 yet" + ) + @markers.aws.validated + def test_simple_update_two_resources( + self, aws_client: ServiceLevelClientFactory, deploy_cfn_template + ): + parameter_name = "my-parameter" + value1 = "foo" + value2 = "bar" + stack_name = f"stack-{short_uid()}" + + t1 = { + "Resources": { + "MyParameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": value1, + }, + }, + "MyParameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": parameter_name, + "Type": "String", + "Value": {"Fn::GetAtt": ["MyParameter1", "Value"]}, + }, + }, + }, + } + + res = deploy_cfn_template(stack_name=stack_name, template=json.dumps(t1), is_update=False) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value1 + + t2 = copy.deepcopy(t1) + t2["Resources"]["MyParameter1"]["Properties"]["Value"] = value2 + + deploy_cfn_template(stack_name=stack_name, template=json.dumps(t2), is_update=True) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value2 + + res.destroy() + + @markers.aws.validated + # TODO: the error response is incorrect, however the test is otherwise validated and raises + # an error because the SSM parameter has been deleted (removed from the stack). + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + @pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Test fails with the old engine" + ) + def test_deleting_resource( + self, aws_client: ServiceLevelClientFactory, deploy_cfn_template, snapshot + ): + parameter_name = "my-parameter" + value1 = "foo" + + t1 = { + "Resources": { + "MyParameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": value1, + }, + }, + "MyParameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": parameter_name, + "Type": "String", + "Value": {"Fn::GetAtt": ["MyParameter1", "Value"]}, + }, + }, + }, + } + + stack = deploy_cfn_template(template=json.dumps(t1)) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value1 + + t2 = copy.deepcopy(t1) + del t2["Resources"]["MyParameter2"] + + deploy_cfn_template(stack_name=stack.stack_name, template=json.dumps(t2), is_update=True) + with pytest.raises(ClientError) as exc_info: + aws_client.ssm.get_parameter(Name=parameter_name) + + snapshot.match("get-parameter-error", exc_info.value.response) + + +@markers.aws.validated +def test_create_change_set_without_parameters( + cleanup_stacks, cleanup_changesets, is_change_set_created_and_available, aws_client +): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + # make sure the change set wasn't executed (which would create a topic) + topics = aws_client.sns.list_topics() + topic_arns = [x["TopicArn"] for x in topics["Topics"]] + assert not any("sns-topic-simple" in arn for arn in topic_arns) + # stack is initially in REVIEW_IN_PROGRESS state. only after executing the change_set will it change its status + stack_response = aws_client.cloudformation.describe_stacks(StackName=stack_id) + assert stack_response["Stacks"][0]["StackStatus"] == "REVIEW_IN_PROGRESS" + + # Change set can now either be already created/available or it is pending/unavailable + wait_until( + is_change_set_created_and_available(change_set_id), 2, 10, strategy="exponential" + ) + describe_response = aws_client.cloudformation.describe_change_set( + ChangeSetName=change_set_id + ) + + assert describe_response["ChangeSetName"] == change_set_name + assert describe_response["ChangeSetId"] == change_set_id + assert describe_response["StackId"] == stack_id + assert describe_response["StackName"] == stack_name + assert describe_response["ExecutionStatus"] == "AVAILABLE" + assert describe_response["Status"] == "CREATE_COMPLETE" + changes = describe_response["Changes"] + assert len(changes) == 1 + assert changes[0]["Type"] == "Resource" + assert changes[0]["ResourceChange"]["Action"] == "Add" + assert changes[0]["ResourceChange"]["ResourceType"] == "AWS::SNS::Topic" + assert changes[0]["ResourceChange"]["LogicalResourceId"] == "topic123" + finally: + cleanup_stacks([stack_id]) + cleanup_changesets([change_set_id]) + + +# TODO: implement +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="Not properly implemented") +@markers.aws.validated +def test_create_change_set_update_without_parameters( + cleanup_stacks, + cleanup_changesets, + is_change_set_created_and_available, + is_change_set_finished, + snapshot, + aws_client, +): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + """after creating a stack via a CREATE change set we send an UPDATE change set changing the SNS topic name""" + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + change_set_name2 = f"change-set-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + snapshot.match("create_change_set", response) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + # Change set can now either be already created/available or it is pending/unavailable + wait_until(is_change_set_created_and_available(change_set_id)) + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + wait_until(is_change_set_finished(change_set_id)) + template = load_template_raw(template_path) + + update_response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name2, + TemplateBody=template.replace("sns-topic-simple", "sns-topic-simple-2"), + ChangeSetType="UPDATE", + ) + assert wait_until(is_change_set_created_and_available(update_response["Id"])) + snapshot.match( + "describe_change_set", + aws_client.cloudformation.describe_change_set(ChangeSetName=update_response["Id"]), + ) + snapshot.match( + "list_change_set", aws_client.cloudformation.list_change_sets(StackName=stack_name) + ) + + describe_response = aws_client.cloudformation.describe_change_set( + ChangeSetName=update_response["Id"] + ) + changes = describe_response["Changes"] + assert len(changes) == 1 + assert changes[0]["Type"] == "Resource" + change = changes[0]["ResourceChange"] + assert change["Action"] == "Modify" + assert change["ResourceType"] == "AWS::SNS::Topic" + assert change["LogicalResourceId"] == "topic123" + assert "sns-topic-simple" in change["PhysicalResourceId"] + assert change["Replacement"] == "True" + assert "Properties" in change["Scope"] + assert len(change["Details"]) == 1 + assert change["Details"][0]["Target"]["Name"] == "TopicName" + assert change["Details"][0]["Target"]["RequiresRecreation"] == "Always" + finally: + cleanup_changesets(changesets=[change_set_id]) + cleanup_stacks(stacks=[stack_id]) + + +# def test_create_change_set_with_template_url(): +# pass + + +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="change set type not implemented") +@markers.aws.validated +def test_create_change_set_create_existing(cleanup_changesets, cleanup_stacks, aws_client): + """tries to create an already existing stack""" + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=change_set_id + ) + stack_id = response["StackId"] + assert change_set_id + assert stack_id + try: + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_id) + + with pytest.raises(Exception) as ex: + change_set_name2 = f"change-set-{short_uid()}" + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name2, + TemplateBody=load_template_raw("sns_topic_simple.yaml"), + ChangeSetType="CREATE", + ) + assert ex is not None + finally: + cleanup_changesets([change_set_id]) + cleanup_stacks([stack_id]) + + +@markers.aws.validated +def test_create_change_set_update_nonexisting(aws_client): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + + with pytest.raises(Exception) as ex: + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="UPDATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + err = ex.value.response["Error"] + assert err["Code"] == "ValidationError" + assert "does not exist" in err["Message"] + + +@markers.aws.validated +def test_create_change_set_invalid_params(aws_client): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + with pytest.raises(ClientError) as ex: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="INVALID", + ) + err = ex.value.response["Error"] + assert err["Code"] == "ValidationError" + + +@markers.aws.validated +def test_create_change_set_missing_stackname(aws_client): + """in this case boto doesn't even let us send the request""" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + with pytest.raises(Exception): + aws_client.cloudformation.create_change_set( + StackName="", + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + + +@markers.aws.validated +def test_create_change_set_with_ssm_parameter( + cleanup_changesets, + cleanup_stacks, + is_change_set_created_and_available, + is_stack_created, + aws_client, +): + """References a simple stack parameter""" + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + parameter_name = f"ls-param-{short_uid()}" + parameter_value = f"ls-param-value-{short_uid()}" + sns_topic_logical_id = "topic123" + parameter_logical_id = "parameter123" + + aws_client.ssm.put_parameter(Name=parameter_name, Value=parameter_value, Type="String") + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/dynamicparameter_ssm_string.yaml" + ) + template_rendered = render_template( + load_template_raw(template_path), parameter_name=parameter_name + ) + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_rendered, + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + # make sure the change set wasn't executed (which would create a new topic) + list_topics_response = aws_client.sns.list_topics() + matching_topics = [ + t for t in list_topics_response["Topics"] if parameter_value in t["TopicArn"] + ] + assert matching_topics == [] + + # stack is initially in REVIEW_IN_PROGRESS state. only after executing the change_set will it change its status + stack_response = aws_client.cloudformation.describe_stacks(StackName=stack_id) + assert stack_response["Stacks"][0]["StackStatus"] == "REVIEW_IN_PROGRESS" + + # Change set can now either be already created/available or it is pending/unavailable + wait_until(is_change_set_created_and_available(change_set_id)) + describe_response = aws_client.cloudformation.describe_change_set( + ChangeSetName=change_set_id + ) + + assert describe_response["ChangeSetName"] == change_set_name + assert describe_response["ChangeSetId"] == change_set_id + assert describe_response["StackId"] == stack_id + assert describe_response["StackName"] == stack_name + assert describe_response["ExecutionStatus"] == "AVAILABLE" + assert describe_response["Status"] == "CREATE_COMPLETE" + changes = describe_response["Changes"] + assert len(changes) == 1 + assert changes[0]["Type"] == "Resource" + assert changes[0]["ResourceChange"]["Action"] == "Add" + assert changes[0]["ResourceChange"]["ResourceType"] == "AWS::SNS::Topic" + assert changes[0]["ResourceChange"]["LogicalResourceId"] == sns_topic_logical_id + + parameters = describe_response["Parameters"] + assert len(parameters) == 1 + assert parameters[0]["ParameterKey"] == parameter_logical_id + assert parameters[0]["ParameterValue"] == parameter_name + assert parameters[0]["ResolvedValue"] == parameter_value # the important part + + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + wait_until(is_stack_created(stack_id)) + + topics = aws_client.sns.list_topics() + topic_arns = [x["TopicArn"] for x in topics["Topics"]] + assert any((parameter_value in t) for t in topic_arns) + finally: + cleanup_changesets([change_set_id]) + cleanup_stacks([stack_id]) + + +@markers.aws.validated +def test_describe_change_set_nonexisting(snapshot, aws_client): + with pytest.raises(Exception) as ex: + aws_client.cloudformation.describe_change_set( + StackName="somestack", ChangeSetName="DoesNotExist" + ) + snapshot.match("exception", ex.value) + + +@pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="fails because of the properties mutation in the result_handler", +) +@markers.aws.validated +def test_execute_change_set( + is_change_set_finished, + is_change_set_created_and_available, + is_change_set_failed_and_unavailable, + cleanup_changesets, + cleanup_stacks, + aws_client, +): + """check if executing a change set succeeds in creating/modifying the resources in changed""" + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + template_body = load_template_raw(template_path) + + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + assert wait_until(is_change_set_created_and_available(change_set_id=change_set_id)) + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + assert wait_until(is_change_set_finished(change_set_id)) + # check if stack resource was created + topics = aws_client.sns.list_topics() + topic_arns = [x["TopicArn"] for x in topics["Topics"]] + assert any(("sns-topic-simple" in t) for t in topic_arns) + + # new change set name + change_set_name = f"change-set-{short_uid()}" + # check if update with identical stack leads to correct behavior + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="UPDATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert wait_until(is_change_set_failed_and_unavailable(change_set_id=change_set_id)) + describe_failed_change_set_result = aws_client.cloudformation.describe_change_set( + ChangeSetName=change_set_id + ) + assert describe_failed_change_set_result["ChangeSetName"] == change_set_name + assert ( + describe_failed_change_set_result["StatusReason"] + == "The submitted information didn't contain changes. Submit different information to create a change set." + ) + with pytest.raises(ClientError) as e: + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + e.match("InvalidChangeSetStatus") + e.match( + rf"ChangeSet \[{change_set_id}\] cannot be executed in its current status of \[FAILED\]" + ) + finally: + cleanup_changesets([change_set_id]) + cleanup_stacks([stack_id]) + + +@markers.aws.validated +def test_delete_change_set_exception(snapshot, aws_client): + """test error cases when trying to delete a change set""" + with pytest.raises(ClientError) as e1: + aws_client.cloudformation.delete_change_set( + StackName="nostack", ChangeSetName="DoesNotExist" + ) + snapshot.match("e1", e1.value.response) + + with pytest.raises(ClientError) as e2: + aws_client.cloudformation.delete_change_set(ChangeSetName="DoesNotExist") + snapshot.match("e2", e2.value.response) + + +@markers.aws.validated +def test_create_delete_create(aws_client, cleanups, deploy_cfn_template): + """test the re-use of a changeset name with a re-used stack name""" + stack_name = f"stack-{short_uid()}" + change_set_name = f"cs-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + with open(template_path) as infile: + template = infile.read() + + # custom cloudformation deploy process since our `deploy_cfn_template` is too smart and uses IDs, unlike the CDK + def deploy(): + client = aws_client.cloudformation + client.create_change_set( + StackName=stack_name, + TemplateBody=template, + ChangeSetName=change_set_name, + ChangeSetType="CREATE", + ) + client.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=change_set_name + ) + + client.execute_change_set(StackName=stack_name, ChangeSetName=change_set_name) + client.get_waiter("stack_create_complete").wait( + StackName=stack_name, + ) + + def delete(suppress_exception: bool = False): + try: + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + except Exception: + if not suppress_exception: + raise + + deploy() + cleanups.append(lambda: delete(suppress_exception=True)) + delete() + deploy() + + +@markers.aws.validated +def test_create_and_then_remove_non_supported_resource_change_set(deploy_cfn_template): + # first deploy cfn with a CodeArtifact resource that is not actually supported + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/code_artifact_template.yaml" + ) + template_body = load_template_raw(template_path) + stack = deploy_cfn_template( + template=template_body, + parameters={"CADomainName": f"domainname-{short_uid()}"}, + ) + + # removal of CodeArtifact should not throw exception + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/code_artifact_remove_template.yaml" + ) + template_body = load_template_raw(template_path) + deploy_cfn_template( + is_update=True, + template=template_body, + stack_name=stack.stack_name, + ) + + +@markers.aws.validated +def test_create_and_then_update_refreshes_template_metadata( + aws_client, + cleanup_changesets, + cleanup_stacks, + is_change_set_finished, + is_change_set_created_and_available, +): + stacks_to_cleanup = set() + changesets_to_cleanup = set() + + try: + stack_name = f"stack-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + + template_body = load_template_raw(template_path) + + create_response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=f"change-set-{short_uid()}", + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + + stacks_to_cleanup.add(create_response["StackId"]) + changesets_to_cleanup.add(create_response["Id"]) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=create_response["Id"] + ) + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=create_response["Id"] + ) + + wait_until(is_change_set_finished(create_response["Id"])) + + # Note the metadata alone won't change if there are no changes to resources + # TODO: find a better way to make a replacement in yaml template + template_body = template_body.replace( + "TopicName: sns-topic-simple", + "TopicName: sns-topic-simple-updated", + ) + + update_response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=f"change-set-{short_uid()}", + TemplateBody=template_body, + ChangeSetType="UPDATE", + ) + + stacks_to_cleanup.add(update_response["StackId"]) + changesets_to_cleanup.add(update_response["Id"]) + + wait_until(is_change_set_created_and_available(update_response["Id"])) + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=update_response["Id"] + ) + + wait_until(is_change_set_finished(update_response["Id"])) + + summary = aws_client.cloudformation.get_template_summary(StackName=stack_name) + + assert "TopicName" in summary["Metadata"] + assert "sns-topic-simple-updated" in summary["Metadata"] + finally: + cleanup_stacks(list(stacks_to_cleanup)) + cleanup_changesets(list(changesets_to_cleanup)) + + +# TODO: the intention of this test is not particularly clear. The resource isn't removed, it'll just generate a new bucket with a new default name +# TODO: rework this to a conditional instead of two templates + parameter usage instead of templating +@markers.aws.validated +def test_create_and_then_remove_supported_resource_change_set(deploy_cfn_template, aws_client): + first_bucket_name = f"test-bucket-1-{short_uid()}" + second_bucket_name = f"test-bucket-2-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/for_removal_setup.yaml" + ) + template_body = load_template_raw(template_path) + + stack = deploy_cfn_template( + template=template_body, + template_mapping={ + "first_bucket_name": first_bucket_name, + "second_bucket_name": second_bucket_name, + }, + ) + assert first_bucket_name in stack.outputs["FirstBucket"] + assert second_bucket_name in stack.outputs["SecondBucket"] + + available_buckets = aws_client.s3.list_buckets() + bucket_names = [bucket["Name"] for bucket in available_buckets["Buckets"]] + assert first_bucket_name in bucket_names + assert second_bucket_name in bucket_names + + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/for_removal_remove.yaml" + ) + template_body = load_template_raw(template_path) + stack_updated = deploy_cfn_template( + is_update=True, + template=template_body, + template_mapping={"first_bucket_name": first_bucket_name}, + stack_name=stack.stack_name, + ) + + assert first_bucket_name in stack_updated.outputs["FirstBucket"] + + def assert_bucket_gone(): + available_buckets = aws_client.s3.list_buckets() + bucket_names = [bucket["Name"] for bucket in available_buckets["Buckets"]] + return first_bucket_name in bucket_names and second_bucket_name not in bucket_names + + poll_condition(condition=assert_bucket_gone, timeout=20, interval=5) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Parameters", + ] +) +@markers.aws.validated +def test_empty_changeset(snapshot, cleanups, aws_client): + """ + Creates a change set that doesn't actually update any resources and then tries to execute it + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + change_set_name_nochange = f"change-set-nochange-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + template_path = os.path.join(os.path.dirname(__file__), "../../../templates/cdkmetadata.yaml") + template = load_template_file(template_path) + + # 1. create change set and execute + + first_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ChangeSetType="CREATE", + ) + snapshot.match("first_changeset", first_changeset) + + def _check_changeset_available(): + status = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + )["Status"] + if status == "FAILED": + raise ShortCircuitWaitException("Change set in unrecoverable status") + return status == "CREATE_COMPLETE" + + assert wait_until(_check_changeset_available) + + describe_first_cs = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + ) + snapshot.match("describe_first_cs", describe_first_cs) + assert describe_first_cs["ExecutionStatus"] == "AVAILABLE" + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + ) + + def _check_changeset_success(): + status = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + )["ExecutionStatus"] + if status in ["EXECUTE_FAILED", "UNAVAILABLE", "OBSOLETE"]: + raise ShortCircuitWaitException("Change set in unrecoverable status") + return status == "EXECUTE_COMPLETE" + + assert wait_until(_check_changeset_success) + + # 2. create a new change set without changes + nochange_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name_nochange, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ChangeSetType="UPDATE", + ) + snapshot.match("nochange_changeset", nochange_changeset) + + describe_nochange = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=nochange_changeset["Id"] + ) + snapshot.match("describe_nochange", describe_nochange) + assert describe_nochange["ExecutionStatus"] == "UNAVAILABLE" + + # 3. try to execute the unavailable change set + with pytest.raises(aws_client.cloudformation.exceptions.InvalidChangeSetStatusException) as e: + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=nochange_changeset["Id"] + ) + snapshot.match("error_execute_failed", e.value) + + +@markers.aws.validated +def test_deleted_changeset(snapshot, cleanups, aws_client): + """simple case verifying that proper exception is thrown when trying to get a deleted changeset""" + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + changeset_name = f"changeset-{short_uid()}" + stack_name = f"stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + snapshot.add_transformer(snapshot.transform.regex(stack_name, "")) + + template_path = os.path.join(os.path.dirname(__file__), "../../../templates/cdkmetadata.yaml") + template = load_template_file(template_path) + + # 1. create change set + create = aws_client.cloudformation.create_change_set( + ChangeSetName=changeset_name, + StackName=stack_name, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ChangeSetType="CREATE", + ) + snapshot.match("create", create) + + changeset_id = create["Id"] + + def _check_changeset_available(): + status = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=changeset_id + )["Status"] + if status == "FAILED": + raise ShortCircuitWaitException("Change set in unrecoverable status") + return status == "CREATE_COMPLETE" + + assert wait_until(_check_changeset_available) + + # 2. delete change set + aws_client.cloudformation.delete_change_set(ChangeSetName=changeset_id, StackName=stack_name) + + with pytest.raises(aws_client.cloudformation.exceptions.ChangeSetNotFoundException) as e: + aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=changeset_id + ) + snapshot.match("postdelete_changeset_notfound", e.value) + + +@markers.aws.validated +def test_autoexpand_capability_requirement(cleanups, aws_client): + stack_name = f"test-stack-{short_uid()}" + changeset_name = f"test-changeset-{short_uid()}" + queue_name = f"test-queue-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + template_body = load_template_raw( + os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_macro_languageextensions.yaml" + ) + ) + + with pytest.raises(aws_client.cloudformation.exceptions.InsufficientCapabilitiesException): + # requires the capability + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template_body, + Parameters=[ + {"ParameterKey": "QueueList", "ParameterValue": "faa,fbb,fcc"}, + {"ParameterKey": "QueueNameParam", "ParameterValue": queue_name}, + ], + ) + + # does not require the capability + create_changeset_result = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "QueueList", "ParameterValue": "faa,fbb,fcc"}, + {"ParameterKey": "QueueNameParam", "ParameterValue": queue_name}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=create_changeset_result["Id"] + ) + + +# FIXME: a CreateStack operation should work with an existing stack if its in REVIEW_IN_PROGRESS +@pytest.mark.skip(reason="not implemented correctly yet") +@markers.aws.validated +def test_create_while_in_review(aws_client, snapshot, cleanups): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"stack-{short_uid()}" + changeset_name = f"changeset-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + stack_id = changeset["StackId"] + changeset_id = changeset["Id"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=changeset_name + ) + + # I would have actually expected this to throw, but it doesn't + create_stack_while_in_review = aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE + ) + snapshot.match("create_stack_while_in_review", create_stack_while_in_review) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # describe change set and stack (change set is now obsolete) + describe_stack = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_stack", describe_stack) + describe_change_set = aws_client.cloudformation.describe_change_set(ChangeSetName=changeset_id) + snapshot.match("describe_change_set", describe_change_set) + + +@markers.snapshot.skip_snapshot_verify( + paths=["$..Capabilities", "$..IncludeNestedStacks", "$..NotificationARNs", "$..Parameters"] +) +@markers.aws.validated +def test_multiple_create_changeset(aws_client, snapshot, cleanups): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"repeated-stack-{short_uid()}" + initial_changeset_name = f"initial-changeset-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + snapshot.match( + "initial_changeset", + aws_client.cloudformation.describe_change_set(ChangeSetName=initial_changeset["Id"]), + ) + + # multiple change sets can exist for a given stack + additional_changeset_name = f"additionalchangeset-{short_uid()}" + additional_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=additional_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + snapshot.match("additional_changeset", additional_changeset) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=additional_changeset_name + ) + + +@markers.snapshot.skip_snapshot_verify(paths=["$..LastUpdatedTime", "$..StackStatusReason"]) +@markers.aws.validated +def test_create_changeset_with_stack_id(aws_client, snapshot, cleanups): + """ + The test answers the question if the `StackName` parameter in `CreateChangeSet` can also be a full Stack ID (ARN). + This can make sense in two cases: + 1. a `CREATE` change set type while the stack is in `REVIEW_IN_PROGRESS` (otherwise it would fail) => covered by this test + 2. an `UPDATE` change set type when the stack has been deployed before already + + On an initial `CREATE` we can't actually know the stack ID yet since the `CREATE` will first create the stack. + + Error case: using `CREATE` with a stack ID from a stack that is in `DELETE_COMPLETE` state. + => A single stack instance identified by a unique ID can never leave its `DELETE_COMPLETE` state + => `DELETE_COMPLETE` is the only *real* terminal state of a Stack + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"repeated-stack-{short_uid()}" + initial_changeset_name = "initial-changeset" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + # create initial change set + initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + initial_stack_id = initial_changeset["StackId"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + + # new CREATE change set on stack that is in REVIEW_IN_PROGRESS state + additional_create_changeset_name = "additional-create" + additional_create_changeset = aws_client.cloudformation.create_change_set( + StackName=initial_stack_id, + ChangeSetName=additional_create_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=additional_create_changeset["Id"] + ) + + describe_stack = aws_client.cloudformation.describe_stacks(StackName=initial_stack_id) + snapshot.match("describe_stack", describe_stack) + + # delete and try to revive the stack with the same ID (won't work) + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + assert ( + aws_client.cloudformation.describe_stacks(StackName=initial_stack_id)["Stacks"][0][ + "StackStatus" + ] + == "DELETE_COMPLETE" + ) + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=initial_stack_id, + ChangeSetName="revived-stack-changeset", + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + snapshot.match("recreate_deleted_with_id_exception", e.value.response) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # gotta skip quite a lot unfortunately + # FIXME: tackle this when fixing API parity of CloudFormation + "$..EnableTerminationProtection", + "$..LastUpdatedTime", + "$..Capabilities", + "$..ChangeSetId", + "$..IncludeNestedStacks", + "$..NotificationARNs", + "$..Parameters", + "$..StackId", + "$..StatusReason", + "$..StackStatusReason", + ] +) +@markers.aws.validated +def test_name_conflicts(aws_client, snapshot, cleanups): + """ + changeset-based equivalent to tests.aws.services.cloudformation.api.test_stacks.test_name_conflicts + + Tests behavior of creating a stack and changeset with the same names of ones that were previously deleted + + 1. Create ChangeSet + 2. Create another ChangeSet + 3. Execute ChangeSet / Create Stack + 4. Creating a new ChangeSet (CREATE) for this stack should fail since it already exists & is running/active + 5. Delete Stack + 6. Create ChangeSet / re-use ChangeSet and Stack names from 1. + + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"repeated-stack-{short_uid()}" + initial_changeset_name = f"initial-changeset-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + initial_stack_id = initial_changeset["StackId"] + initial_changeset_id = initial_changeset["Id"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + + # actually create the stack + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # creating should now fail (stack is created & active) + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + snapshot.match("create_changeset_existingstack_exc", e.value.response) + + # delete stack + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + # creating for stack name with same name should work again + # re-using the changset name should also not matter :) + second_initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + second_initial_stack_id = second_initial_changeset["StackId"] + second_initial_changeset_id = second_initial_changeset["Id"] + assert second_initial_changeset_id != initial_changeset_id + assert initial_stack_id != second_initial_stack_id + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=second_initial_changeset_id + ) + + # only one should be active, and this one is in review state right now + new_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("new_stack_desc", new_stack_desc) + assert len(new_stack_desc["Stacks"]) == 1 + assert new_stack_desc["Stacks"][0]["StackId"] == second_initial_stack_id + + # can still access both by using the ARN (stack id) + # and they should be different from each other + stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=initial_stack_id) + new_stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=second_initial_stack_id) + snapshot.match("stack_id_desc", stack_id_desc) + snapshot.match("new_stack_id_desc", new_stack_id_desc) + + # can still access all change sets by their ID + initial_changeset_id_desc = aws_client.cloudformation.describe_change_set( + ChangeSetName=initial_changeset_id + ) + snapshot.match("initial_changeset_id_desc", initial_changeset_id_desc) + second_initial_changeset_id_desc = aws_client.cloudformation.describe_change_set( + ChangeSetName=second_initial_changeset_id + ) + snapshot.match("second_initial_changeset_id_desc", second_initial_changeset_id_desc) + + +@markers.aws.validated +def test_describe_change_set_with_similarly_named_stacks(deploy_cfn_template, aws_client): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + + # create a changeset + template_path = os.path.join(os.path.dirname(__file__), "../../../templates/ec2_keypair.yml") + template_body = load_template_raw(template_path) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + + # delete the stack + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + # create a new changeset with the same name + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + + # ensure that the correct changeset is returned when requested by stack name + assert ( + aws_client.cloudformation.describe_change_set( + ChangeSetName=response["Id"], StackName=stack_name + )["ChangeSetId"] + == response["Id"] + ) diff --git a/tests/aws/services/cloudformation/api/test_changesets.snapshot.json b/tests/aws/services/cloudformation/api/test_changesets.snapshot.json new file mode 100644 index 0000000000000..b3b80db8dd4fa --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_changesets.snapshot.json @@ -0,0 +1,502 @@ +{ + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_update_without_parameters": { + "recorded-date": "31-05-2022, 09:32:02", + "recorded-content": { + "create_change_set": { + "Id": "arn::cloudformation::111111111111:changeSet//", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPStatusCode": 200, + "HTTPHeaders": {} + } + }, + "describe_change_set": { + "ChangeSetName": "", + "ChangeSetId": "arn::cloudformation::111111111111:changeSet//", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "Status": "CREATE_COMPLETE", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "Capabilities": [], + "Changes": [ + { + "Type": "Resource", + "ResourceChange": { + "Action": "Modify", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceType": "AWS::SNS::Topic", + "Replacement": "True", + "Scope": [ + "Properties" + ], + "Details": [ + { + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + }, + "Evaluation": "Static", + "ChangeSource": "DirectModification" + } + ] + } + } + ], + "IncludeNestedStacks": false, + "ResponseMetadata": { + "HTTPStatusCode": 200, + "HTTPHeaders": {} + } + }, + "list_change_set": { + "Summaries": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "ChangeSetId": "arn::cloudformation::111111111111:changeSet//", + "ChangeSetName": "", + "ExecutionStatus": "AVAILABLE", + "Status": "CREATE_COMPLETE", + "CreationTime": "datetime", + "IncludeNestedStacks": false + } + ], + "ResponseMetadata": { + "HTTPStatusCode": 200, + "HTTPHeaders": {} + } + } + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_empty_changeset": { + "recorded-date": "10-08-2022, 10:52:55", + "recorded-content": { + "first_changeset": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackId": "arn::cloudformation::111111111111:stack//" + }, + "describe_first_cs": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "CDKMetadata", + "ResourceType": "AWS::CDK::Metadata", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE" + }, + "nochange_changeset": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackId": "arn::cloudformation::111111111111:stack//" + }, + "describe_nochange": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [], + "CreationTime": "datetime", + "ExecutionStatus": "UNAVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "FAILED", + "StatusReason": "The submitted information didn't contain changes. Submit different information to create a change set." + }, + "error_execute_failed": "An error occurred (InvalidChangeSetStatus) when calling the ExecuteChangeSet operation: ChangeSet [arn::cloudformation::111111111111:changeSet/] cannot be executed in its current status of [FAILED]" + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_deleted_changeset": { + "recorded-date": "11-08-2022, 11:11:47", + "recorded-content": { + "create": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackId": "arn::cloudformation::111111111111:stack//" + }, + "postdelete_changeset_notfound": "An error occurred (ChangeSetNotFound) when calling the DescribeChangeSet operation: ChangeSet [arn::cloudformation::111111111111:changeSet/] does not exist" + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_describe_change_set_nonexisting": { + "recorded-date": "11-03-2025, 19:12:57", + "recorded-content": { + "exception": "An error occurred (ValidationError) when calling the DescribeChangeSet operation: Stack [somestack] does not exist" + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_delete_change_set_exception": { + "recorded-date": "12-03-2025, 10:14:25", + "recorded-content": { + "e1": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [nostack] does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "e2": { + "Error": { + "Code": "ValidationError", + "Message": "StackName must be specified if ChangeSetName is not specified as an ARN.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_name_conflicts": { + "recorded-date": "22-11-2023, 10:58:04", + "recorded-content": { + "create_changeset_existingstack_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [] already exists and cannot be created again with the changeSet [].", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "new_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "REVIEW_IN_PROGRESS", + "StackStatusReason": "User Initiated", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new_stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "REVIEW_IN_PROGRESS", + "StackStatusReason": "User Initiated", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "initial_changeset_id_desc": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "EXECUTE_COMPLETE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_initial_changeset_id_desc": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_while_in_review": { + "recorded-date": "22-11-2023, 08:49:15", + "recorded-content": { + "create_stack_while_in_review": { + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_change_set": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "OBSOLETE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_template_rendering_with_list": { + "recorded-date": "23-11-2023, 09:23:26", + "recorded-content": { + "resolved-template": { + "d": [ + { + "userid": 1 + }, + 1, + "string" + ] + } + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_changeset_with_stack_id": { + "recorded-date": "28-11-2023, 07:48:23", + "recorded-content": { + "describe_stack": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "REVIEW_IN_PROGRESS", + "StackStatusReason": "User Initiated", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recreate_deleted_with_id_exception": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [arn::cloudformation::111111111111:stack//] already exists and cannot be created again with the changeSet [revived-stack-changeset].", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_multiple_create_changeset": { + "recorded-date": "28-11-2023, 07:38:49", + "recorded-content": { + "initial_changeset": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "additional_changeset": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_changesets.validation.json b/tests/aws/services/cloudformation/api/test_changesets.validation.json new file mode 100644 index 0000000000000..3c3b7ffa3c6c3 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_changesets.validation.json @@ -0,0 +1,83 @@ +{ + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_dynamic]": { + "last_validated_date": "2025-04-03T07:11:44+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_parameter_for_condition_create_resource]": { + "last_validated_date": "2025-04-03T07:13:00+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property]": { + "last_validated_date": "2025-04-03T07:12:11+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property_not_create_only]": { + "last_validated_date": "2025-04-03T07:12:37+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_base_mapping_scenarios[update_string_referencing_resource]": { + "last_validated_date": "2025-04-03T07:23:48+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_conditions": { + "last_validated_date": "2025-04-01T14:34:35+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_direct_update": { + "last_validated_date": "2025-04-01T08:32:30+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_dynamic_update": { + "last_validated_date": "2025-04-01T12:30:53+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_execute_with_ref": { + "last_validated_date": "2025-04-11T14:34:09+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_mappings_with_parameter_lookup": { + "last_validated_date": "2025-04-01T13:31:33+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_mappings_with_static_fields": { + "last_validated_date": "2025-04-01T13:20:50+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_parameter_changes": { + "last_validated_date": "2025-04-01T12:43:36+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_unrelated_changes_requires_replacement": { + "last_validated_date": "2025-04-01T16:46:22+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestCaptureUpdateProcess::test_unrelated_changes_update_propagation": { + "last_validated_date": "2025-04-01T16:40:03+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestUpdates::test_deleting_resource": { + "last_validated_date": "2025-04-15T15:07:18+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::TestUpdates::test_simple_update_two_resources": { + "last_validated_date": "2025-04-02T10:05:26+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_change_set_update_without_parameters": { + "last_validated_date": "2022-05-31T07:32:02+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_changeset_with_stack_id": { + "last_validated_date": "2023-11-28T06:48:23+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_delete_create": { + "last_validated_date": "2024-08-13T10:46:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_create_while_in_review": { + "last_validated_date": "2023-11-22T07:49:15+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_delete_change_set_exception": { + "last_validated_date": "2025-03-12T10:14:25+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_deleted_changeset": { + "last_validated_date": "2022-08-11T09:11:47+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_describe_change_set_nonexisting": { + "last_validated_date": "2025-03-11T19:12:57+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_describe_change_set_with_similarly_named_stacks": { + "last_validated_date": "2024-03-06T13:56:47+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_empty_changeset": { + "last_validated_date": "2022-08-10T08:52:55+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_multiple_create_changeset": { + "last_validated_date": "2023-11-28T06:38:49+00:00" + }, + "tests/aws/services/cloudformation/api/test_changesets.py::test_name_conflicts": { + "last_validated_date": "2023-11-22T09:58:04+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_drift_detection.py b/tests/aws/services/cloudformation/api/test_drift_detection.py new file mode 100644 index 0000000000000..7b39f20ec10ba --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_drift_detection.py @@ -0,0 +1,29 @@ +import os + +import pytest + +from localstack.testing.pytest import markers + + +@pytest.mark.skip(reason="Not implemented") +@markers.aws.validated +def test_drift_detection_on_lambda(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_simple.yml" + ) + ) + + aws_client.lambda_.update_function_configuration( + FunctionName=stack.outputs["LambdaName"], + Runtime="python3.8", + Description="different description", + Environment={"Variables": {"ENDPOINT_URL": "localhost.localstack.cloud"}}, + ) + + drift_detection = aws_client.cloudformation.detect_stack_resource_drift( + StackName=stack.stack_name, LogicalResourceId="Function" + ) + + snapshot.match("drift_detection", drift_detection) diff --git a/tests/aws/services/cloudformation/api/test_drift_detection.snapshot.json b/tests/aws/services/cloudformation/api/test_drift_detection.snapshot.json new file mode 100644 index 0000000000000..a408332ebbc26 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_drift_detection.snapshot.json @@ -0,0 +1,63 @@ +{ + "tests/aws/services/cloudformation/api/test_drift_detection.py::test_drift_detection_on_lambda": { + "recorded-date": "11-11-2022, 08:44:20", + "recorded-content": { + "drift_detection": { + "StackResourceDrift": { + "ActualProperties": { + "Description": "different description", + "Environment": { + "Variables": { + "ENDPOINT_URL": "localhost.localstack.cloud" + } + }, + "Handler": "index.handler", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8" + }, + "ExpectedProperties": { + "Description": "function to test lambda function url", + "Environment": { + "Variables": { + "ENDPOINT_URL": "aws.amazon.com" + } + }, + "Handler": "index.handler", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9" + }, + "LogicalResourceId": "Function", + "PhysicalResourceId": "stack-0d03b713-Function-ijoJmdBJP4re", + "PropertyDifferences": [ + { + "ActualValue": "different description", + "DifferenceType": "NOT_EQUAL", + "ExpectedValue": "function to test lambda function url", + "PropertyPath": "/Description" + }, + { + "ActualValue": "localhost.localstack.cloud", + "DifferenceType": "NOT_EQUAL", + "ExpectedValue": "aws.amazon.com", + "PropertyPath": "/Environment/Variables/ENDPOINT_URL" + }, + { + "ActualValue": "python3.8", + "DifferenceType": "NOT_EQUAL", + "ExpectedValue": "python3.9", + "PropertyPath": "/Runtime" + } + ], + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack/stack-0d03b713/", + "StackResourceDriftStatus": "MODIFIED", + "Timestamp": "timestamp" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_drift_detection.validation.json b/tests/aws/services/cloudformation/api/test_drift_detection.validation.json new file mode 100644 index 0000000000000..959bc18699b36 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_drift_detection.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/api/test_drift_detection.py::test_drift_detection_on_lambda": { + "last_validated_date": "2022-11-11T07:44:20+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_api.py b/tests/aws/services/cloudformation/api/test_extensions_api.py new file mode 100644 index 0000000000000..1c7b83ced3049 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_api.py @@ -0,0 +1,244 @@ +import json +import os +import re + +import botocore +import botocore.errorfactory +import botocore.exceptions +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +class TestExtensionsApi: + @pytest.mark.skip(reason="feature not implemented") + @pytest.mark.parametrize( + "extension_type, extension_name, artifact", + [ + ( + "RESOURCE", + "LocalStack::Testing::TestResource", + "resourcetypes/localstack-testing-testresource.zip", + ), + ( + "MODULE", + "LocalStack::Testing::TestModule::MODULE", + "modules/localstack-testing-testmodule-module.zip", + ), + ("HOOK", "LocalStack::Testing::TestHook", "hooks/localstack-testing-testhook.zip"), + ], + ) + @markers.aws.validated + def test_crud_extension( + self, + deploy_cfn_template, + s3_bucket, + snapshot, + extension_name, + extension_type, + artifact, + aws_client, + ): + bucket_name = s3_bucket + artifact_path = os.path.join( + os.path.dirname(__file__), "../artifacts/extensions/", artifact + ) + key_name = f"key-{short_uid()}" + aws_client.s3.upload_file(artifact_path, bucket_name, key_name) + + register_response = aws_client.cloudformation.register_type( + Type=extension_type, + TypeName=extension_name, + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + + snapshot.add_transformer( + snapshot.transform.key_value("RegistrationToken", "registration-token") + ) + snapshot.add_transformer( + snapshot.transform.key_value("DefaultVersionId", "default-version-id") + ) + snapshot.add_transformer(snapshot.transform.key_value("LogRoleArn", "log-role-arn")) + snapshot.add_transformer(snapshot.transform.key_value("LogGroupName", "log-group-name")) + snapshot.add_transformer( + snapshot.transform.key_value("ExecutionRoleArn", "execution-role-arn") + ) + snapshot.match("register_response", register_response) + + describe_type_response = aws_client.cloudformation.describe_type_registration( + RegistrationToken=register_response["RegistrationToken"] + ) + snapshot.match("describe_type_response", describe_type_response) + + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + + describe_response = aws_client.cloudformation.describe_type( + Arn=describe_type_response["TypeArn"], + ) + snapshot.match("describe_response", describe_response) + + list_response = aws_client.cloudformation.list_type_registrations( + TypeName=extension_name, + ) + snapshot.match("list_response", list_response) + + deregister_response = aws_client.cloudformation.deregister_type( + Arn=describe_type_response["TypeArn"] + ) + snapshot.match("deregister_response", deregister_response) + + @pytest.mark.skip(reason="test not completed") + @markers.aws.validated + def test_extension_versioning(self, s3_bucket, snapshot, aws_client): + """ + This tests validates some api behaviours and errors resulting of creating and deleting versions of extensions. + The process of this test: + - register twice the same extension to have multiple versions + - set the last one as a default one. + - try to delete the whole extension. + - try to delete a version of the extension that doesn't exist. + - delete the first version of the extension. + - try to delete the last available version using the version arn. + - delete the whole extension. + """ + bucket_name = s3_bucket + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/modules/localstack-testing-testmodule-module.zip", + ) + key_name = f"key-{short_uid()}" + aws_client.s3.upload_file(artifact_path, bucket_name, key_name) + + register_response = aws_client.cloudformation.register_type( + Type="MODULE", + TypeName="LocalStack::Testing::TestModule::MODULE", + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + + register_response = aws_client.cloudformation.register_type( + Type="MODULE", + TypeName="LocalStack::Testing::TestModule::MODULE", + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + + versions_response = aws_client.cloudformation.list_type_versions( + TypeName="LocalStack::Testing::TestModule::MODULE", Type="MODULE" + ) + snapshot.match("versions", versions_response) + + set_default_response = aws_client.cloudformation.set_type_default_version( + Arn=versions_response["TypeVersionSummaries"][1]["Arn"] + ) + snapshot.match("set_default_response", set_default_response) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.deregister_type( + Type="MODULE", TypeName="LocalStack::Testing::TestModule::MODULE" + ) + snapshot.match("multiple_versions_error", e.value.response) + + arn = versions_response["TypeVersionSummaries"][1]["Arn"] + with pytest.raises(botocore.errorfactory.ClientError) as e: + arn = re.sub(r"/\d{8}", "99999999", arn) + aws_client.cloudformation.deregister_type(Arn=arn) + snapshot.match("version_not_found_error", e.value.response) + + delete_first_version_response = aws_client.cloudformation.deregister_type( + Arn=versions_response["TypeVersionSummaries"][0]["Arn"] + ) + snapshot.match("delete_unused_version_response", delete_first_version_response) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.deregister_type( + Arn=versions_response["TypeVersionSummaries"][1]["Arn"] + ) + snapshot.match("error_for_deleting_default_with_arn", e.value.response) + + delete_default_response = aws_client.cloudformation.deregister_type( + Type="MODULE", TypeName="LocalStack::Testing::TestModule::MODULE" + ) + snapshot.match("deleting_default_response", delete_default_response) + + @pytest.mark.skip(reason="feature not implemented") + @markers.aws.validated + def test_extension_not_complete(self, s3_bucket, snapshot, aws_client): + """ + This tests validates the error of Extension not found using the describe_type operation when the registration + of the extension is still in progress. + """ + bucket_name = s3_bucket + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/hooks/localstack-testing-testhook.zip", + ) + key_name = f"key-{short_uid()}" + aws_client.s3.upload_file(artifact_path, bucket_name, key_name) + + register_response = aws_client.cloudformation.register_type( + Type="HOOK", + TypeName="LocalStack::Testing::TestHook", + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.describe_type( + Type="HOOK", TypeName="LocalStack::Testing::TestHook" + ) + snapshot.match("not_found_error", e.value) + + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + aws_client.cloudformation.deregister_type( + Type="HOOK", + TypeName="LocalStack::Testing::TestHook", + ) + + @pytest.mark.skip(reason="feature not implemented") + @markers.aws.validated + def test_extension_type_configuration(self, register_extension, snapshot, aws_client): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/hooks/localstack-testing-deployablehook.zip", + ) + extension = register_extension( + extension_type="HOOK", + extension_name="LocalStack::Testing::DeployableHook", + artifact_path=artifact_path, + ) + + extension_configuration = json.dumps( + { + "CloudFormationConfiguration": { + "HookConfiguration": {"TargetStacks": "ALL", "FailureMode": "FAIL"} + } + } + ) + response_set_configuration = aws_client.cloudformation.set_type_configuration( + TypeArn=extension["TypeArn"], Configuration=extension_configuration + ) + snapshot.match("set_type_configuration_response", response_set_configuration) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.batch_describe_type_configurations( + TypeConfigurationIdentifiers=[{}] + ) + snapshot.match("batch_describe_configurations_errors", e.value) + + describe = aws_client.cloudformation.batch_describe_type_configurations( + TypeConfigurationIdentifiers=[ + { + "TypeArn": extension["TypeArn"], + }, + ] + ) + snapshot.match("batch_describe_configurations", describe) diff --git a/tests/aws/services/cloudformation/api/test_extensions_api.snapshot.json b/tests/aws/services/cloudformation/api/test_extensions_api.snapshot.json new file mode 100644 index 0000000000000..181aaefec507c --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_api.snapshot.json @@ -0,0 +1,687 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[RESOURCE-LocalStack::Testing::TestResource-resourcetypes/localstack-testing-testresource.zip]": { + "recorded-date": "02-03-2023, 16:11:19", + "recorded-content": { + "register_response": { + "RegistrationToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_type_response": { + "ProgressStatus": "IN_PROGRESS", + "TypeArn": "arn::cloudformation::111111111111:type/resource/LocalStack-Testing-TestResource", + "TypeVersionArn": "arn::cloudformation::111111111111:type/resource/LocalStack-Testing-TestResource/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_response": { + "Arn": "arn::cloudformation::111111111111:type/resource/LocalStack-Testing-TestResource/", + "DefaultVersionId": "", + "DeprecatedStatus": "LIVE", + "Description": "An example resource schema demonstrating some basic constructs and validation rules.", + "ExecutionRoleArn": "", + "IsDefaultVersion": true, + "LastUpdated": "datetime", + "ProvisioningType": "FULLY_MUTABLE", + "Schema": { + "typeName": "LocalStack::Testing::TestResource", + "description": "An example resource schema demonstrating some basic constructs and validation rules.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": {}, + "properties": { + "Name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "Name" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "handlers": { + "create": { + "permissions": [] + }, + "read": { + "permissions": [] + }, + "update": { + "permissions": [] + }, + "delete": { + "permissions": [] + }, + "list": { + "permissions": [] + } + } + }, + "SourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "TimeCreated": "datetime", + "Type": "RESOURCE", + "TypeName": "LocalStack::Testing::TestResource", + "Visibility": "PRIVATE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_response": { + "RegistrationTokenList": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deregister_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[MODULE-LocalStack::Testing::TestModule::MODULE-modules/localstack-testing-testmodule-module.zip]": { + "recorded-date": "02-03-2023, 16:11:53", + "recorded-content": { + "register_response": { + "RegistrationToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_type_response": { + "ProgressStatus": "IN_PROGRESS", + "TypeArn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE", + "TypeVersionArn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_response": { + "Arn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/", + "DefaultVersionId": "", + "DeprecatedStatus": "LIVE", + "Description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "IsDefaultVersion": true, + "LastUpdated": "datetime", + "Schema": { + "typeName": "LocalStack::Testing::TestModule::MODULE", + "description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "properties": { + "Parameters": { + "type": "object", + "properties": { + "BucketName": { + "type": "object", + "properties": { + "Type": { + "type": "string" + }, + "Description": { + "type": "string" + } + }, + "required": [ + "Type", + "Description" + ], + "description": "Name for the bucket" + } + } + }, + "Resources": { + "properties": { + "S3Bucket": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "const": "AWS::S3::Bucket" + }, + "Properties": { + "type": "object" + } + } + } + }, + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": true + }, + "TimeCreated": "datetime", + "Type": "MODULE", + "TypeName": "LocalStack::Testing::TestModule::MODULE", + "Visibility": "PRIVATE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_response": { + "RegistrationTokenList": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deregister_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[HOOK-LocalStack::Testing::TestHook-hooks/localstack-testing-testhook.zip]": { + "recorded-date": "02-03-2023, 16:12:56", + "recorded-content": { + "register_response": { + "RegistrationToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_type_response": { + "ProgressStatus": "IN_PROGRESS", + "TypeArn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-TestHook", + "TypeVersionArn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-TestHook/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_response": { + "Arn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-TestHook/", + "ConfigurationSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "examples": [ + { + "CloudFormationConfiguration": { + "HookConfiguration": { + "TargetStacks": "ALL", + "Properties": {}, + "FailureMode": "FAIL" + } + } + } + ], + "description": "This schema validates the CFN hook type configuration that could be set by customers", + "additionalProperties": false, + "title": "CloudFormation Hook Type Configuration Schema", + "type": "object", + "definitions": { + "InvocationPoint": { + "description": "Invocation points are the point in provisioning workflow where hooks will be executed.", + "type": "string", + "enum": [ + "PRE_PROVISION" + ] + }, + "HookTarget": { + "description": "Hook targets are the destination where hooks will be invoked against.", + "additionalProperties": false, + "type": "object", + "properties": { + "InvocationPoint": { + "$ref": "#/definitions/InvocationPoint" + }, + "Action": { + "$ref": "#/definitions/Action" + }, + "TargetName": { + "$ref": "#/definitions/TargetName" + } + }, + "required": [ + "TargetName", + "Action", + "InvocationPoint" + ] + }, + "StackRole": { + "pattern": "arn:.+:iam::[0-9]{12}:role/.+", + "description": "The Amazon Resource Name (ARN) of the IAM execution role to use to perform stack operations", + "type": "string", + "maxLength": 256 + }, + "Action": { + "description": "Target actions are the type of operation hooks will be executed at.", + "type": "string", + "enum": [ + "CREATE", + "UPDATE", + "DELETE" + ] + }, + "TargetName": { + "minLength": 1, + "pattern": "^(?!.*\\*\\?).*$", + "description": "Type name of hook target. Hook targets are the destination where hooks will be invoked against.", + "type": "string", + "maxLength": 256 + }, + "StackName": { + "pattern": "^[a-zA-Z][-a-zA-Z0-9]*$", + "description": "CloudFormation Stack name", + "type": "string", + "maxLength": 128 + } + }, + "properties": { + "CloudFormationConfiguration": { + "additionalProperties": false, + "properties": { + "HookConfiguration": { + "additionalProperties": false, + "type": "object", + "properties": { + "TargetStacks": { + "default": "NONE", + "description": "Attribute to specify which stacks this hook applies to or should get invoked for", + "type": "string", + "enum": [ + "ALL", + "NONE" + ] + }, + "StackFilters": { + "description": "Filters to allow hooks to target specific stack attributes", + "additionalProperties": false, + "type": "object", + "properties": { + "FilteringCriteria": { + "default": "ALL", + "description": "Attribute to specify the filtering behavior. ANY will make the Hook pass if one filter matches. ALL will make the Hook pass if all filters match", + "type": "string", + "enum": [ + "ALL", + "ANY" + ] + }, + "StackNames": { + "description": "List of stack names as filters", + "additionalProperties": false, + "type": "object", + "properties": { + "Exclude": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack names that the hook is going to be excluded from", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackName" + } + }, + "Include": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack names that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackName" + } + } + }, + "minProperties": 1 + }, + "StackRoles": { + "description": "List of stack roles that are performing the stack operations.", + "additionalProperties": false, + "type": "object", + "properties": { + "Exclude": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack roles that the hook is going to be excluded from", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackRole" + } + }, + "Include": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack roles that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackRole" + } + } + }, + "minProperties": 1 + } + }, + "required": [ + "FilteringCriteria" + ] + }, + "TargetFilters": { + "oneOf": [ + { + "additionalProperties": false, + "type": "object", + "properties": { + "Actions": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of actions that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/Action" + } + }, + "TargetNames": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of type names that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/TargetName" + } + }, + "InvocationPoints": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of invocation points that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/InvocationPoint" + } + } + }, + "minProperties": 1 + }, + { + "additionalProperties": false, + "type": "object", + "properties": { + "Targets": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of hook targets", + "type": "array", + "items": { + "$ref": "#/definitions/HookTarget" + } + } + }, + "required": [ + "Targets" + ] + } + ], + "description": "Attribute to specify which targets should invoke the hook", + "type": "object" + }, + "Properties": { + "typeName": "LocalStack::Testing::TestHook", + "description": "Hook runtime properties", + "additionalProperties": false, + "type": "object", + "definitions": {}, + "properties": { + "EncryptionAlgorithm": { + "default": "AES256", + "description": "Encryption algorithm for SSE", + "type": "string" + } + } + }, + "FailureMode": { + "default": "WARN", + "description": "Attribute to specify CloudFormation behavior on hook failure.", + "type": "string", + "enum": [ + "FAIL", + "WARN" + ] + } + }, + "required": [ + "TargetStacks", + "FailureMode" + ] + } + }, + "required": [ + "HookConfiguration" + ] + } + }, + "required": [ + "CloudFormationConfiguration" + ], + "$id": "https://schema.cloudformation..amazonaws.com/cloudformation.hook.configuration.schema.v1.json" + }, + "DefaultVersionId": "", + "DeprecatedStatus": "LIVE", + "Description": "Example resource SSE (Server Side Encryption) verification hook", + "DocumentationUrl": "https://github.com/aws-cloudformation/example-sse-hook/blob/master/README.md", + "IsDefaultVersion": true, + "LastUpdated": "datetime", + "Schema": { + "typeName": "LocalStack::Testing::TestHook", + "description": "Example resource SSE (Server Side Encryption) verification hook", + "sourceUrl": "https://github.com/aws-cloudformation/example-sse-hook", + "documentationUrl": "https://github.com/aws-cloudformation/example-sse-hook/blob/master/README.md", + "typeConfiguration": { + "properties": { + "EncryptionAlgorithm": { + "description": "Encryption algorithm for SSE", + "default": "AES256", + "type": "string" + } + }, + "additionalProperties": false + }, + "required": [], + "handlers": { + "preCreate": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [] + }, + "preUpdate": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [] + }, + "preDelete": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [] + } + }, + "additionalProperties": false + }, + "SourceUrl": "https://github.com/aws-cloudformation/example-sse-hook", + "TimeCreated": "datetime", + "Type": "HOOK", + "TypeName": "LocalStack::Testing::TestHook", + "Visibility": "PRIVATE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_response": { + "RegistrationTokenList": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deregister_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_versioning": { + "recorded-date": "02-03-2023, 16:14:12", + "recorded-content": { + "versions": { + "TypeVersionSummaries": [ + { + "Arn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/00000050", + "Description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "IsDefaultVersion": true, + "TimeCreated": "datetime", + "Type": "MODULE", + "TypeName": "LocalStack::Testing::TestModule::MODULE", + "VersionId": "00000050" + }, + { + "Arn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/00000051", + "Description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "IsDefaultVersion": false, + "TimeCreated": "datetime", + "Type": "MODULE", + "TypeName": "LocalStack::Testing::TestModule::MODULE", + "VersionId": "00000051" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set_default_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multiple_versions_error": { + "Error": { + "Code": "CFNRegistryException", + "Message": "This type has more than one active version. Please deregister non-default active versions before attempting to deregister the type.", + "Type": "Sender" + }, + "Message": "This type has more than one active version. Please deregister non-default active versions before attempting to deregister the type.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "version_not_found_error": { + "Error": { + "Code": "CFNRegistryException", + "Message": "TypeName is invalid", + "Type": "Sender" + }, + "Message": "TypeName is invalid", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_unused_version_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error_for_deleting_default_with_arn": { + "Error": { + "Code": "CFNRegistryException", + "Message": "Version '00000051' is the default version and cannot be deregistered. Deregister the resource type 'LocalStack::Testing::TestModule::MODULE' instead.", + "Type": "Sender" + }, + "Message": "Version '00000051' is the default version and cannot be deregistered. Deregister the resource type 'LocalStack::Testing::TestModule::MODULE' instead.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleting_default_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_not_complete": { + "recorded-date": "02-03-2023, 16:15:26", + "recorded-content": { + "not_found_error": "An error occurred (TypeNotFoundException) when calling the DescribeType operation: The type 'LocalStack::Testing::TestHook' cannot be found." + } + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_type_configuration": { + "recorded-date": "06-03-2023, 15:33:33", + "recorded-content": { + "set_type_configuration_response": { + "ConfigurationArn": "arn::cloudformation::111111111111:type-configuration/hook/LocalStack-Testing-DeployableHook/default", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "batch_describe_configurations_errors": "An error occurred (ValidationError) when calling the BatchDescribeTypeConfigurations operation: 1 validation error detected: Value null at 'typeConfigurationIdentifiers' failed to satisfy constraint: Member must not be null", + "batch_describe_configurations": { + "Errors": [], + "TypeConfigurations": [ + { + "Alias": "default", + "Arn": "arn::cloudformation::111111111111:type-configuration/hook/LocalStack-Testing-DeployableHook/default", + "Configuration": { + "CloudFormationConfiguration": { + "HookConfiguration": { + "TargetStacks": "ALL", + "FailureMode": "FAIL" + } + } + }, + "LastUpdated": "datetime", + "TypeArn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-DeployableHook" + } + ], + "UnprocessedTypeConfigurations": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_api.validation.json b/tests/aws/services/cloudformation/api/test_extensions_api.validation.json new file mode 100644 index 0000000000000..f16f7e3cf3263 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_api.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[HOOK-LocalStack::Testing::TestHook-hooks/localstack-testing-testhook.zip]": { + "last_validated_date": "2023-03-02T15:12:56+00:00" + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[MODULE-LocalStack::Testing::TestModule::MODULE-modules/localstack-testing-testmodule-module.zip]": { + "last_validated_date": "2023-03-02T15:11:53+00:00" + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[RESOURCE-LocalStack::Testing::TestResource-resourcetypes/localstack-testing-testresource.zip]": { + "last_validated_date": "2023-03-02T15:11:19+00:00" + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_not_complete": { + "last_validated_date": "2023-03-02T15:15:26+00:00" + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_type_configuration": { + "last_validated_date": "2023-03-06T14:33:33+00:00" + }, + "tests/aws/services/cloudformation/api/test_extensions_api.py::TestExtensionsApi::test_extension_versioning": { + "last_validated_date": "2023-03-02T15:14:12+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_hooks.py b/tests/aws/services/cloudformation/api/test_extensions_hooks.py new file mode 100644 index 0000000000000..3dcd67999ea4c --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_hooks.py @@ -0,0 +1,74 @@ +import json +import os + +import botocore.exceptions +import pytest + +from localstack.testing.aws.cloudformation_utils import load_template_file +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +class TestExtensionsHooks: + @pytest.mark.skip(reason="feature not implemented") + @pytest.mark.parametrize("failure_mode", ["FAIL", "WARN"]) + @markers.aws.validated + def test_hook_deployment( + self, failure_mode, register_extension, snapshot, cleanups, aws_client + ): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/hooks/localstack-testing-deployablehook.zip", + ) + extension = register_extension( + extension_type="HOOK", + extension_name="LocalStack::Testing::DeployableHook", + artifact_path=artifact_path, + ) + + extension_configuration = json.dumps( + { + "CloudFormationConfiguration": { + "HookConfiguration": {"TargetStacks": "ALL", "FailureMode": failure_mode} + } + } + ) + aws_client.cloudformation.set_type_configuration( + TypeArn=extension["TypeArn"], Configuration=extension_configuration + ) + + template = load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../../templates/s3_bucket_name.yml", + ) + ) + + stack_name = f"stack-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template, + Parameters=[{"ParameterKey": "Name", "ParameterValue": f"bucket-{short_uid()}"}], + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + if failure_mode == "WARN": + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + else: + with pytest.raises(botocore.exceptions.WaiterError): + aws_client.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack_name + ) + + events = aws_client.cloudformation.describe_stack_events(StackName=stack_name)[ + "StackEvents" + ] + + failed_events = [e for e in events if "HookStatusReason" in e] + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer( + snapshot.transform.key_value( + "EventId", value_replacement="", reference_replacement=False + ) + ) + snapshot.match("event_error", failed_events[0]) diff --git a/tests/aws/services/cloudformation/api/test_extensions_hooks.snapshot.json b/tests/aws/services/cloudformation/api/test_extensions_hooks.snapshot.json new file mode 100644 index 0000000000000..2caf1d26fd960 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_hooks.snapshot.json @@ -0,0 +1,42 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[FAIL]": { + "recorded-date": "06-03-2023, 15:00:08", + "recorded-content": { + "event_error": { + "EventId": "", + "HookFailureMode": "FAIL", + "HookInvocationPoint": "PRE_PROVISION", + "HookStatus": "HOOK_COMPLETE_FAILED", + "HookStatusReason": "Hook failed with message: Intentional fail", + "HookType": "LocalStack::Testing::DeployableHook", + "LogicalResourceId": "myb3B4550BC", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[WARN]": { + "recorded-date": "06-03-2023, 15:01:59", + "recorded-content": { + "event_error": { + "EventId": "", + "HookFailureMode": "WARN", + "HookInvocationPoint": "PRE_PROVISION", + "HookStatus": "HOOK_COMPLETE_FAILED", + "HookStatusReason": "Hook failed with message: Intentional fail. Failure was ignored under WARN mode.", + "HookType": "LocalStack::Testing::DeployableHook", + "LogicalResourceId": "myb3B4550BC", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_hooks.validation.json b/tests/aws/services/cloudformation/api/test_extensions_hooks.validation.json new file mode 100644 index 0000000000000..a55ddf3b6d1f0 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_hooks.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[FAIL]": { + "last_validated_date": "2023-03-06T14:00:08+00:00" + }, + "tests/aws/services/cloudformation/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[WARN]": { + "last_validated_date": "2023-03-06T14:01:59+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_modules.py b/tests/aws/services/cloudformation/api/test_extensions_modules.py new file mode 100644 index 0000000000000..755f2d66accd7 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_modules.py @@ -0,0 +1,40 @@ +import os + +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +class TestExtensionsModules: + @pytest.mark.skip(reason="feature not supported") + @markers.aws.validated + def test_module_usage(self, deploy_cfn_template, register_extension, snapshot, aws_client): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/modules/localstack-testing-testmodule-module.zip", + ) + register_extension( + extension_type="MODULE", + extension_name="LocalStack::Testing::TestModule::MODULE", + artifact_path=artifact_path, + ) + + template_path = os.path.join( + os.path.dirname(__file__), + "../../../templates/registry/module.yml", + ) + + module_bucket_name = f"bucket-module-{short_uid()}" + stack = deploy_cfn_template( + template_path=template_path, + parameters={"BucketName": module_bucket_name}, + max_wait=300, + ) + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name)[ + "StackResources" + ] + + snapshot.add_transformer(snapshot.transform.regex(module_bucket_name, "bucket-name-")) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("resource_description", resources[0]) diff --git a/tests/aws/services/cloudformation/api/test_extensions_modules.snapshot.json b/tests/aws/services/cloudformation/api/test_extensions_modules.snapshot.json new file mode 100644 index 0000000000000..d2ca3f5f325d0 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_modules.snapshot.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_modules.py::TestExtensionsModules::test_module_usage": { + "recorded-date": "27-02-2023, 16:06:45", + "recorded-content": { + "resource_description": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "BucketModuleS3Bucket", + "ModuleInfo": { + "LogicalIdHierarchy": "BucketModule", + "TypeHierarchy": "LocalStack::Testing::TestModule::MODULE" + }, + "PhysicalResourceId": "bucket-name-hello", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_modules.validation.json b/tests/aws/services/cloudformation/api/test_extensions_modules.validation.json new file mode 100644 index 0000000000000..94edde89f8a62 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_modules.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_modules.py::TestExtensionsModules::test_module_usage": { + "last_validated_date": "2023-02-27T15:06:45+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.py b/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.py new file mode 100644 index 0000000000000..5f9bb51952efe --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.py @@ -0,0 +1,44 @@ +import os + +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +class TestExtensionsResourceTypes: + @pytest.mark.skip(reason="feature not implemented") + @markers.aws.validated + def test_deploy_resource_type( + self, deploy_cfn_template, register_extension, snapshot, aws_client + ): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/resourcetypes/localstack-testing-deployableresource.zip", + ) + + register_extension( + extension_type="RESOURCE", + extension_name="LocalStack::Testing::DeployableResource", + artifact_path=artifact_path, + ) + + template_path = os.path.join( + os.path.dirname(__file__), + "../../../templates/registry/resource-provider.yml", + ) + + resource_name = f"name-{short_uid()}" + stack = deploy_cfn_template( + template_path=template_path, parameters={"Name": resource_name}, max_wait=900 + ) + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name)[ + "StackResources" + ] + + snapshot.add_transformer(snapshot.transform.regex(resource_name, "resource-name")) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("resource_description", resources[0]) + + # Make sure to destroy the stack before unregistration + stack.destroy() diff --git a/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.snapshot.json b/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.snapshot.json new file mode 100644 index 0000000000000..b56704b014d14 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.snapshot.json @@ -0,0 +1,19 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_resourcetypes.py::TestExtensionsResourceTypes::test_deploy_resource_type": { + "recorded-date": "28-02-2023, 12:48:27", + "recorded-content": { + "resource_description": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyCustomResource", + "PhysicalResourceId": "Test", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "LocalStack::Testing::DeployableResource", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.validation.json b/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.validation.json new file mode 100644 index 0000000000000..c1893c5a9a21d --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_extensions_resourcetypes.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/api/test_extensions_resourcetypes.py::TestExtensionsResourceTypes::test_deploy_resource_type": { + "last_validated_date": "2023-02-28T11:48:27+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_nested_stacks.py b/tests/aws/services/cloudformation/api/test_nested_stacks.py new file mode 100644 index 0000000000000..f6b622bc65fd0 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_nested_stacks.py @@ -0,0 +1,353 @@ +import os + +import pytest +from botocore.exceptions import ClientError, WaiterError + +from localstack import config +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + + +@markers.aws.needs_fixing +def test_nested_stack(deploy_cfn_template, s3_create_bucket, aws_client): + # upload template to S3 + artifacts_bucket = f"cf-artifacts-{short_uid()}" + artifacts_path = "stack.yaml" + s3_create_bucket(Bucket=artifacts_bucket, ACL="public-read") + aws_client.s3.put_object( + Bucket=artifacts_bucket, + Key=artifacts_path, + Body=load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/template5.yaml") + ), + ) + + # deploy template + param_value = short_uid() + stack_bucket_name = f"test-{param_value}" # this is the bucket name generated by template5 + + deploy_cfn_template( + template=load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/template6.yaml") + ) + % (artifacts_bucket, artifacts_path), + parameters={"GlobalParam": param_value}, + ) + + # assert that nested resources have been created + def assert_bucket_exists(): + response = aws_client.s3.head_bucket(Bucket=stack_bucket_name) + assert 200 == response["ResponseMetadata"]["HTTPStatusCode"] + + retry(assert_bucket_exists) + + +@markers.aws.validated +def test_nested_stack_output_refs(deploy_cfn_template, s3_create_bucket, aws_client): + """test output handling of nested stacks incl. referencing the nested output in the parent stack""" + bucket_name = s3_create_bucket() + nested_bucket_name = f"test-bucket-nested-{short_uid()}" + key = f"test-key-{short_uid()}" + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-output-refs.nested.yaml" + ), + Bucket=bucket_name, + Key=key, + ) + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-output-refs.yaml" + ), + template_mapping={ + "s3_bucket_url": f"/{bucket_name}/{key}", + "nested_bucket_name": nested_bucket_name, + }, + max_wait=120, # test is flaky, so we need to wait a bit longer + ) + + nested_stack_id = result.outputs["CustomNestedStackId"] + nested_stack_details = aws_client.cloudformation.describe_stacks(StackName=nested_stack_id) + nested_stack_outputs = nested_stack_details["Stacks"][0]["Outputs"] + assert "InnerCustomOutput" not in result.outputs + assert ( + nested_bucket_name + == [ + o["OutputValue"] for o in nested_stack_outputs if o["OutputKey"] == "InnerCustomOutput" + ][0] + ) + assert f"{nested_bucket_name}-suffix" == result.outputs["CustomOutput"] + + +@markers.aws.validated +def test_nested_with_nested_stack(deploy_cfn_template, s3_create_bucket, aws_client): + bucket_name = s3_create_bucket() + bucket_to_create_name = f"test-bucket-{short_uid()}" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + nested_stacks = ["nested_child.yml", "nested_parent.yml"] + urls = [] + + for nested_stack in nested_stacks: + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../templates/", nested_stack), + Bucket=bucket_name, + Key=nested_stack, + ) + + urls.append(f"https://{bucket_name}.s3.{domain}/{nested_stack}") + + outputs = deploy_cfn_template( + max_wait=120 if is_aws_cloud() else None, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/nested_grand_parent.yml" + ), + parameters={ + "ChildStackURL": urls[0], + "ParentStackURL": urls[1], + "BucketToCreate": bucket_to_create_name, + }, + ).outputs + + assert f"arn:aws:s3:::{bucket_to_create_name}" == outputs["parameterValue"] + + +@markers.aws.validated +@pytest.mark.skip(reason="UPDATE isn't working on nested stacks") +def test_lifecycle_nested_stack(deploy_cfn_template, s3_create_bucket, aws_client): + bucket_name = s3_create_bucket() + nested_bucket_name = f"test-bucket-nested-{short_uid()}" + altered_nested_bucket_name = f"test-bucket-nested-{short_uid()}" + key = f"test-key-{short_uid()}" + + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-output-refs.nested.yaml" + ), + Bucket=bucket_name, + Key=key, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-output-refs.yaml" + ), + template_mapping={ + "s3_bucket_url": f"/{bucket_name}/{key}", + "nested_bucket_name": nested_bucket_name, + }, + ) + assert aws_client.s3.head_bucket(Bucket=nested_bucket_name) + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-output-refs.yaml" + ), + template_mapping={ + "s3_bucket_url": f"/{bucket_name}/{key}", + "nested_bucket_name": altered_nested_bucket_name, + }, + max_wait=120 if is_aws_cloud() else None, + ) + + assert aws_client.s3.head_bucket(Bucket=altered_nested_bucket_name) + + stack.destroy() + + def _assert_bucket_is_deleted(): + try: + aws_client.s3.head_bucket(Bucket=altered_nested_bucket_name) + return False + except ClientError: + return True + + retry(_assert_bucket_is_deleted, retries=5, sleep=2, sleep_before=2) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..Role.AssumeRolePolicyDocument..Action", + "$..Role.Tags", # Moto returns an empty list for no tags + ] +) +@markers.aws.validated +def test_nested_output_in_params(deploy_cfn_template, s3_create_bucket, snapshot, aws_client): + """ + Deploys a Stack with two nested stacks (sub1 and sub2) with a dependency between each other sub2 depends on sub1. + The `sub2` stack uses an output parameter of `sub1` as an input parameter. + + Resources: + - Stack + - 2x Nested Stack + - SNS Topic + - IAM role with policy (sns:Publish) + + """ + # upload template to S3 for nested stacks + template_bucket = f"cfn-root-{short_uid()}" + sub1_path = "sub1.yaml" + sub2_path = "sub2.yaml" + s3_create_bucket(Bucket=template_bucket, ACL="public-read") + aws_client.s3.put_object( + Bucket=template_bucket, + Key=sub1_path, + Body=load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-outputref/sub1.yaml" + ) + ), + ) + aws_client.s3.put_object( + Bucket=template_bucket, + Key=sub2_path, + Body=load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-outputref/sub2.yaml" + ) + ), + ) + topic_name = f"test-topic-{short_uid()}" + role_name = f"test-role-{short_uid()}" + + if is_aws_cloud(): + base_path = "https://s3.amazonaws.com" + else: + base_path = "http://localhost:4566" + + deploy_cfn_template( + template=load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-outputref/root.yaml" + ) + ), + parameters={ + "Sub1TemplateUrl": f"{base_path}/{template_bucket}/{sub1_path}", + "Sub2TemplateUrl": f"{base_path}/{template_bucket}/{sub2_path}", + "TopicName": topic_name, + "RoleName": role_name, + }, + ) + # validations + snapshot.add_transformer(snapshot.transform.key_value("RoleId", "role-id")) + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + snapshot.add_transformer(snapshot.transform.regex(role_name, "")) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + get_role_response = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("get_role_response", get_role_response) + role_policies = aws_client.iam.list_role_policies(RoleName=role_name) + snapshot.match("role_policies", role_policies) + policy_name = role_policies["PolicyNames"][0] + actual_policy = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=policy_name) + snapshot.match("actual_policy", actual_policy) + + sns_pager = aws_client.sns.get_paginator("list_topics") + topics = sns_pager.paginate().build_full_result()["Topics"] + filtered_topics = [t["TopicArn"] for t in topics if topic_name in t["TopicArn"]] + assert len(filtered_topics) == 1 + + +@markers.aws.validated +def test_nested_stacks_conditions(deploy_cfn_template, s3_create_bucket, aws_client): + """ + see: TestCloudFormationConditions.test_condition_on_outputs + + equivalent to the condition test but for a nested stack + """ + bucket_name = s3_create_bucket() + nested_bucket_name = f"test-bucket-nested-{short_uid()}" + key = f"test-key-{short_uid()}" + + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-conditions.nested.yaml" + ), + Bucket=bucket_name, + Key=key, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-conditions.yaml" + ), + parameters={ + "S3BucketPath": f"/{bucket_name}/{key}", + "S3BucketName": nested_bucket_name, + }, + ) + + assert stack.outputs["ProdBucket"] == f"{nested_bucket_name}-prod" + assert aws_client.s3.head_bucket(Bucket=stack.outputs["ProdBucket"]) + + # Ensure that nested stack names are correctly generated + nested_stack = aws_client.cloudformation.describe_stacks( + StackName=stack.outputs["NestedStackArn"] + ) + assert ":" not in nested_stack["Stacks"][0]["StackName"] + + +@markers.aws.validated +def test_deletion_of_failed_nested_stack(s3_create_bucket, aws_client, region_name, snapshot): + """ + This test confirms that after deleting a stack parent with a failed nested stack. The nested stack is also deleted + """ + + bucket_name = s3_create_bucket() + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_failed_nested_stack_child.yml" + ), + Bucket=bucket_name, + Key="child.yml", + ) + + stack_name = f"stack-{short_uid()}" + child_template_url = ( + f"https://{bucket_name}.s3.{config.LOCALSTACK_HOST.host_and_port()}/child.yml" + ) + if is_aws_cloud(): + child_template_url = f"https://{bucket_name}.s3.{region_name}.amazonaws.com/child.yml" + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_failed_nested_stack_parent.yml" + ), + ), + Parameters=[ + {"ParameterKey": "TemplateUri", "ParameterValue": child_template_url}, + ], + OnFailure="DO_NOTHING", + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + + with pytest.raises(WaiterError): + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + stack_status = aws_client.cloudformation.describe_stacks(StackName=stack_name)["Stacks"][0][ + "StackStatus" + ] + assert stack_status == "CREATE_FAILED" + + stacks = aws_client.cloudformation.describe_stacks()["Stacks"] + nested_stack_name = [ + stack for stack in stacks if f"{stack_name}-ChildStack-" in stack["StackName"] + ][0]["StackName"] + + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + with pytest.raises(ClientError) as ex: + aws_client.cloudformation.describe_stacks(StackName=nested_stack_name) + + snapshot.match("error", ex.value.response) + snapshot.add_transformer(snapshot.transform.regex(nested_stack_name, "")) diff --git a/tests/aws/services/cloudformation/api/test_nested_stacks.snapshot.json b/tests/aws/services/cloudformation/api/test_nested_stacks.snapshot.json new file mode 100644 index 0000000000000..fbd1c318a283b --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_nested_stacks.snapshot.json @@ -0,0 +1,83 @@ +{ + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_nested_output_in_params": { + "recorded-date": "07-02-2023, 10:57:47", + "recorded-content": { + "get_role_response": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "IsTruncated": false, + "PolicyNames": [ + "PolicyA" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "actual_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "sns:Publish" + ], + "Effect": "Allow", + "Resource": [ + "arn::sns::111111111111:" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "PolicyA", + "RoleName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_deletion_of_failed_nested_stack": { + "recorded-date": "17-09-2024, 20:09:36", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Stack with id does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_nested_stacks.validation.json b/tests/aws/services/cloudformation/api/test_nested_stacks.validation.json new file mode 100644 index 0000000000000..f5936f2e379e7 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_nested_stacks.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_deletion_of_failed_nested_stack": { + "last_validated_date": "2024-09-17T20:09:36+00:00" + }, + "tests/aws/services/cloudformation/api/test_nested_stacks.py::test_nested_output_in_params": { + "last_validated_date": "2023-02-07T09:57:47+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_reference_resolving.py b/tests/aws/services/cloudformation/api/test_reference_resolving.py new file mode 100644 index 0000000000000..a5a8fbf10b129 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_reference_resolving.py @@ -0,0 +1,105 @@ +import os + +import pytest + +from localstack.services.cloudformation.engine.template_deployer import MOCK_REFERENCE +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +@pytest.mark.parametrize("attribute_name", ["TopicName", "TopicArn"]) +@markers.aws.validated +def test_nested_getatt_ref(deploy_cfn_template, aws_client, attribute_name, snapshot): + topic_name = f"test-topic-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_getatt_ref.yaml" + ), + parameters={"MyParam": topic_name, "CustomOutputName": attribute_name}, + ) + snapshot.match("outputs", deployment.outputs) + topic_arn = deployment.outputs["MyTopicArn"] + + # Verify the nested GetAtt Ref resolved correctly + custom_ref = deployment.outputs["MyTopicCustom"] + if attribute_name == "TopicName": + assert custom_ref == topic_name + + if attribute_name == "TopicArn": + assert custom_ref == topic_arn + + # Verify resource was created + topic_arns = [t["TopicArn"] for t in aws_client.sns.list_topics()["Topics"]] + assert topic_arn in topic_arns + + +@markers.aws.validated +def test_sub_resolving(deploy_cfn_template, aws_client, snapshot): + """ + Tests different cases for Fn::Sub resolving + + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html + + + TODO: cover all supported functions for VarName / VarValue: + Fn::Base64 + Fn::FindInMap + Fn::GetAtt + Fn::GetAZs + Fn::If + Fn::ImportValue + Fn::Join + Fn::Select + Ref + + """ + topic_name = f"test-topic-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_sub_resovling.yaml" + ), + parameters={"MyParam": topic_name}, + ) + snapshot.match("outputs", deployment.outputs) + topic_arn = deployment.outputs["MyTopicArn"] + + # Verify the parts in the Fn::Sub string are resolved correctly. + sub_output = deployment.outputs["MyTopicSub"] + param, ref, getatt_topicname, getatt_topicarn = sub_output.split("|") + assert param == topic_name + assert ref == topic_arn + assert getatt_topicname == topic_name + assert getatt_topicarn == topic_arn + + map_sub_output = deployment.outputs["MyTopicSubWithMap"] + att_in_map, ref_in_map, static_in_map = map_sub_output.split("|") + assert att_in_map == topic_name + assert ref_in_map == topic_arn + assert static_in_map == "something" + + # Verify resource was created + topic_arns = [t["TopicArn"] for t in aws_client.sns.list_topics()["Topics"]] + assert topic_arn in topic_arns + + +@markers.aws.only_localstack +def test_reference_unsupported_resource(deploy_cfn_template, aws_client): + """ + This test verifies that templates can be deployed even when unsupported resources are references + Make sure to update the template as coverage of resources increases. + """ + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_ref_unsupported.yml" + ), + ) + + ref_of_unsupported = deployment.outputs["reference"] + value_of_unsupported = deployment.outputs["parameter"] + assert ref_of_unsupported == MOCK_REFERENCE + assert value_of_unsupported == f"The value of the attribute is: {MOCK_REFERENCE}" diff --git a/tests/aws/services/cloudformation/api/test_reference_resolving.snapshot.json b/tests/aws/services/cloudformation/api/test_reference_resolving.snapshot.json new file mode 100644 index 0000000000000..2aebe631514be --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_reference_resolving.snapshot.json @@ -0,0 +1,36 @@ +{ + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_nested_getatt_ref[TopicName]": { + "recorded-date": "11-05-2023, 13:43:51", + "recorded-content": { + "outputs": { + "MyTopicArn": "arn::sns::111111111111:", + "MyTopicCustom": "", + "MyTopicName": "", + "MyTopicRef": "arn::sns::111111111111:" + } + } + }, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_nested_getatt_ref[TopicArn]": { + "recorded-date": "11-05-2023, 13:44:18", + "recorded-content": { + "outputs": { + "MyTopicArn": "arn::sns::111111111111:", + "MyTopicCustom": "arn::sns::111111111111:", + "MyTopicName": "", + "MyTopicRef": "arn::sns::111111111111:" + } + } + }, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_sub_resolving": { + "recorded-date": "12-05-2023, 07:51:06", + "recorded-content": { + "outputs": { + "MyTopicArn": "arn::sns::111111111111:", + "MyTopicName": "", + "MyTopicRef": "arn::sns::111111111111:", + "MyTopicSub": "|arn::sns::111111111111:||arn::sns::111111111111:", + "MyTopicSubWithMap": "|arn::sns::111111111111:|something" + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_reference_resolving.validation.json b/tests/aws/services/cloudformation/api/test_reference_resolving.validation.json new file mode 100644 index 0000000000000..5422a01c739f0 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_reference_resolving.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_nested_getatt_ref[TopicArn]": { + "last_validated_date": "2023-05-11T11:44:18+00:00" + }, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_nested_getatt_ref[TopicName]": { + "last_validated_date": "2023-05-11T11:43:51+00:00" + }, + "tests/aws/services/cloudformation/api/test_reference_resolving.py::test_sub_resolving": { + "last_validated_date": "2023-05-12T05:51:06+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_stack_policies.py b/tests/aws/services/cloudformation/api/test_stack_policies.py new file mode 100644 index 0000000000000..2a33fc462c06f --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_stack_policies.py @@ -0,0 +1,786 @@ +import json +import os + +import botocore.exceptions +import pytest +import yaml + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + + +def get_events_canceled_by_policy(cfn_client, stack_name): + events = cfn_client.describe_stack_events(StackName=stack_name)["StackEvents"] + + failed_events_by_policy = [ + event + for event in events + if "ResourceStatusReason" in event + and ( + "Action denied by stack policy" in event["ResourceStatusReason"] + or "Action not allowed by stack policy" in event["ResourceStatusReason"] + or "Resource update cancelled" in event["ResourceStatusReason"] + ) + ] + + return failed_events_by_policy + + +def delete_stack_after_process(cfn_client, stack_name): + progress_is_finished = False + while not progress_is_finished: + status = cfn_client.describe_stacks(StackName=stack_name)["Stacks"][0]["StackStatus"] + progress_is_finished = "PROGRESS" not in status + cfn_client.delete_stack(StackName=stack_name) + + +class TestStackPolicy: + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_policy_lifecycle(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ), + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("initial_policy", obtained_policy) + + policy = { + "Statement": [ + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"} + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"} + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_updated", obtained_policy) + + policy = {} + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_deleted", obtained_policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_policy_with_url(self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client): + """Test to validate the setting of a Stack Policy through an URL""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ), + ) + bucket_name = s3_create_bucket() + key = "policy.json" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_policy.json"), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + aws_client.cloudformation.set_stack_policy(StackName=stack.stack_name, StackPolicyURL=url) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_invalid_policy_with_url( + self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client + ): + """Test to validate the error response resulting of setting an invalid Stack Policy through an URL""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ), + ) + bucket_name = s3_create_bucket() + key = "policy.json" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../templates/invalid_stack_policy.json"), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyURL=url + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_empty_policy_with_url( + self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client + ): + """Test to validate the setting of an empty Stack Policy through an URL""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ), + ) + bucket_name = s3_create_bucket() + key = "policy.json" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../templates/empty_policy.json"), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + aws_client.cloudformation.set_stack_policy(StackName=stack.stack_name, StackPolicyURL=url) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_policy_both_policy_and_url( + self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client + ): + """Test to validate the API behavior when trying to set a Stack policy using both the body and the URL""" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ), + ) + + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + bucket_name = s3_create_bucket() + key = "policy.json" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_policy.json"), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + policy = { + "Statement": [ + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"} + ] + } + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy), StackPolicyURL=url + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_empty_policy(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/stack_policy_test.yaml" + ), + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = {} + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_not_json_policy(self, deploy_cfn_template, snapshot, aws_client): + """Test to validate the error response when setting and Invalid Policy""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/stack_policy_test.yaml" + ), + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=short_uid() + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_different_principal_attribute(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": short_uid(), + "Resource": "*", + } + ] + } + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + error_response = ex.value.response["Error"] + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_different_action_attribute(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Delete:*", + "Principal": short_uid(), + "Resource": "*", + } + ] + } + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + @pytest.mark.parametrize("resource_type", ["AWS::S3::Bucket", "AWS::SNS::Topic"]) + def test_prevent_update(self, resource_type, deploy_cfn_template, aws_client): + """ + Test to validate the correct behavior of the update operation on a Stack with a Policy that prevents an update + for a specific resource type + """ + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_policy_test.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*", + "Condition": {"StringEquals": {"ResourceType": [resource_type]}}, + }, + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + {"ParameterKey": "BucketName", "ParameterValue": f"new-bucket-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + # if the policy prevents one resource to update the whole update fails + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=5, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + @pytest.mark.parametrize( + "resource", + [ + {"id": "bucket123", "type": "AWS::S3::Bucket"}, + {"id": "topic123", "type": "AWS::SNS::Topic"}, + ], + ) + def test_prevent_deletion(self, resource, deploy_cfn_template, aws_client): + """ + Test to validate that CFn won't delete resources during an update operation that are protected by the Stack + Policy + """ + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_policy_test.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:Delete", + "Principal": "*", + "Resource": "*", + "Condition": {"StringEquals": {"ResourceType": [resource["type"]]}}, + } + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + template_dict = yaml.load(template) + del template_dict["Resources"][resource["id"]] + template = yaml.dump(template_dict) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + {"ParameterKey": "BucketName", "ParameterValue": f"new-bucket-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=6, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_prevent_modifying_with_policy_specifying_resource_id( + self, deploy_cfn_template, aws_client + ): + """ + Test to validate that CFn won't modify a resource protected by a stack policy that specifies the resource + using the logical Resource Id + """ + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/simple_api.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"ApiName": f"api-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:Modify", + "Principal": "*", + "Resource": "LogicalResourceId/Api", + } + ] + } + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + TemplateBody=template, + StackName=stack.stack_name, + Parameters=[ + {"ParameterKey": "ApiName", "ParameterValue": f"new-api-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=6, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_prevent_replacement(self, deploy_cfn_template, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:Replace", + "Principal": "*", + "Resource": "*", + } + ] + } + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"bucket-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=6, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_update_with_policy(self, deploy_cfn_template, aws_client): + """ + Test to validate the completion of a stack update that is allowed by the Stack Policy + """ + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_policy_test.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*", + "Condition": {"StringEquals": {"ResourceType": ["AWS::EC2::Subnet"]}}, + }, + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_update_with_empty_policy(self, deploy_cfn_template, is_stack_updated, aws_client): + """ + Test to validate the behavior of a stack update that has an empty Stack Policy + """ + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_policy_test.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + aws_client.cloudformation.set_stack_policy(StackName=stack.stack_name, StackPolicyBody="{}") + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + {"ParameterKey": "BucketName", "ParameterValue": f"new-bucket-{short_uid()}"}, + ], + ) + + def _assert_stack_is_updated(): + assert is_stack_updated(stack.stack_name) + + retry(_assert_stack_is_updated, retries=5, sleep=2, sleep_before=1) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + @pytest.mark.parametrize("reverse_statements", [False, True]) + def test_update_with_overlapping_policies( + self, reverse_statements, deploy_cfn_template, is_stack_updated, aws_client + ): + """ + This test validates the behaviour when two statements in policy contradict each other. + According to the AWS triage, the last statement is the one that is followed. + """ + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + statements = [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + + if reverse_statements: + statements.reverse() + + policy = {"Statement": statements} + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + ], + ) + + def _assert_stack_is_updated(): + assert is_stack_updated(stack.stack_name) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + retry( + _assert_stack_is_updated if not reverse_statements else _assert_failing_update_state, + retries=5, + sleep=2, + sleep_before=2, + ) + + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_create_stack_with_policy(self, snapshot, cleanup_stacks, aws_client): + stack_name = f"stack-{short_uid()}" + + policy = { + "Statement": [ + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + aws_client.cloudformation.create_stack( + StackName=stack_name, + StackPolicyBody=json.dumps(policy), + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"} + ], + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack_name) + snapshot.match("policy", obtained_policy) + cleanup_stacks([stack_name]) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_policy_with_update_operation( + self, deploy_cfn_template, is_stack_updated, snapshot, cleanup_stacks, aws_client + ): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/simple_api.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"ApiName": f"api-{short_uid()}"}, + ) + + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "ApiName", "ParameterValue": f"api-{short_uid()}"}, + ], + StackPolicyBody=json.dumps(policy), + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + # This part makes sure that the policy being set during the last update doesn't affect the requested changes + def _assert_stack_is_updated(): + assert is_stack_updated(stack.stack_name) + + retry(_assert_stack_is_updated, retries=5, sleep=2, sleep_before=1) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_after_update", obtained_policy) + + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_policy_during_update( + self, deploy_cfn_template, is_stack_updated, snapshot, cleanup_stacks, aws_client + ): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/simple_api.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"ApiName": f"api-{short_uid()}"}, + ) + + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "ApiName", "ParameterValue": f"api-{short_uid()}"}, + ], + StackPolicyDuringUpdateBody=json.dumps(policy), + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_during_update", obtained_policy) + + def _assert_update_failed(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + retry(_assert_update_failed, retries=5, sleep=2, sleep_before=1) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_after_update", obtained_policy) + + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="feature not implemented") + def test_prevent_stack_update(self, deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + stack = deploy_cfn_template( + template=template, parameters={"TopicName": f"topic-{short_uid()}"} + ) + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"} + ], + ) + + def _assert_failing_update_state(): + events = aws_client.cloudformation.describe_stack_events(StackName=stack.stack_name)[ + "StackEvents" + ] + failed_event_update = [ + event for event in events if event["ResourceStatus"] == "UPDATE_FAILED" + ] + assert failed_event_update + assert "Action denied by stack policy" in failed_event_update[0]["ResourceStatusReason"] + + try: + retry(_assert_failing_update_state, retries=5, sleep=2, sleep_before=2) + finally: + progress_is_finished = False + while not progress_is_finished: + status = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)[ + "Stacks" + ][0]["StackStatus"] + progress_is_finished = "PROGRESS" not in status + aws_client.cloudformation.delete_stack(StackName=stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="feature not implemented") + def test_prevent_resource_deletion(self, deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + template = template.replace("DeletionPolicy: Delete", "DeletionPolicy: Retain") + stack = deploy_cfn_template( + template=template, parameters={"TopicName": f"topic-{short_uid()}"} + ) + aws_client.cloudformation.delete_stack(StackName=stack.stack_name) + + aws_client.sns.get_topic_attributes(TopicArn=stack.outputs["TopicArn"]) diff --git a/tests/aws/services/cloudformation/api/test_stack_policies.snapshot.json b/tests/aws/services/cloudformation/api/test_stack_policies.snapshot.json new file mode 100644 index 0000000000000..b3e7b7adc660f --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_stack_policies.snapshot.json @@ -0,0 +1,254 @@ +{ + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_empty_policy": { + "recorded-date": "10-11-2022, 12:40:34", + "recorded-content": { + "policy": { + "StackPolicyBody": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_invalid_policy": { + "recorded-date": "14-11-2022, 15:13:18", + "recorded-content": { + "error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_policy_lifecycle": { + "recorded-date": "15-11-2022, 16:02:20", + "recorded-content": { + "initial_policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Allow", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_updated": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_deleted": { + "StackPolicyBody": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_url": { + "recorded-date": "11-11-2022, 13:58:17", + "recorded-content": { + "policy": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Allow", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_invalid_policy_with_url": { + "recorded-date": "11-11-2022, 14:07:44", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_both_policy_and_url": { + "recorded-date": "11-11-2022, 14:19:19", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "You cannot specify both StackPolicyURL and StackPolicyBody", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_empty_policy_with_url": { + "recorded-date": "11-11-2022, 14:25:18", + "recorded-content": { + "policy": { + "StackPolicyBody": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_not_json_policy": { + "recorded-date": "21-11-2022, 15:48:27", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_different_principal_attribute": { + "recorded-date": "16-11-2022, 11:01:36", + "recorded-content": { + "error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_different_action_attribute": { + "recorded-date": "21-11-2022, 15:44:16", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_create_stack_with_policy": { + "recorded-date": "16-11-2022, 15:42:23", + "recorded-content": { + "policy": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Allow", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_update_operation": { + "recorded-date": "17-11-2022, 11:04:31", + "recorded-content": { + "policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_after_update": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_policy_during_update": { + "recorded-date": "17-11-2022, 11:09:28", + "recorded-content": { + "policy_during_update": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_after_update": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_stack_update": { + "recorded-date": "28-10-2022, 12:10:42", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_resource_deletion": { + "recorded-date": "28-10-2022, 12:29:11", + "recorded-content": {} + } +} diff --git a/tests/aws/services/cloudformation/api/test_stack_policies.validation.json b/tests/aws/services/cloudformation/api/test_stack_policies.validation.json new file mode 100644 index 0000000000000..40f51c9e5668a --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_stack_policies.validation.json @@ -0,0 +1,44 @@ +{ + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_create_stack_with_policy": { + "last_validated_date": "2022-11-16T14:42:23+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_different_action_attribute": { + "last_validated_date": "2022-11-21T14:44:16+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_different_principal_attribute": { + "last_validated_date": "2022-11-16T10:01:36+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_empty_policy": { + "last_validated_date": "2022-11-10T11:40:34+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_not_json_policy": { + "last_validated_date": "2022-11-21T14:48:27+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_policy_during_update": { + "last_validated_date": "2022-11-17T10:09:28+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_policy_lifecycle": { + "last_validated_date": "2022-11-15T15:02:20+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_resource_deletion": { + "last_validated_date": "2022-10-28T10:29:11+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_prevent_stack_update": { + "last_validated_date": "2022-10-28T10:10:42+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_empty_policy_with_url": { + "last_validated_date": "2022-11-11T13:25:18+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_invalid_policy_with_url": { + "last_validated_date": "2022-11-11T13:07:44+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_both_policy_and_url": { + "last_validated_date": "2022-11-11T13:19:19+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_update_operation": { + "last_validated_date": "2022-11-17T10:04:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_url": { + "last_validated_date": "2022-11-11T12:58:17+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_stacks.py b/tests/aws/services/cloudformation/api/test_stacks.py new file mode 100644 index 0000000000000..cfcf8adf8b881 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_stacks.py @@ -0,0 +1,1071 @@ +import json +import os +from collections import OrderedDict +from itertools import permutations + +import botocore.exceptions +import pytest +import yaml +from botocore.exceptions import WaiterError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.aws.api.cloudformation import Capability +from localstack.services.cloudformation.engine.entities import StackIdentifier +from localstack.services.cloudformation.engine.yaml_parser import parse_yaml +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry, wait_until + + +class TestStacksApi: + @markers.snapshot.skip_snapshot_verify( + paths=["$..ChangeSetId", "$..EnableTerminationProtection"] + ) + @markers.aws.validated + def test_stack_lifecycle(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("ParameterValue", "parameter-value")) + api_name = f"test_{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/simple_api.yaml" + ) + + deployed = deploy_cfn_template( + template_path=template_path, + parameters={"ApiName": api_name}, + ) + stack_name = deployed.stack_name + creation_description = aws_client.cloudformation.describe_stacks(StackName=stack_name)[ + "Stacks" + ][0] + snapshot.match("creation", creation_description) + + api_name = f"test_{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=deployed.stack_name, + template_path=template_path, + parameters={"ApiName": api_name}, + ) + update_description = aws_client.cloudformation.describe_stacks(StackName=stack_name)[ + "Stacks" + ][0] + snapshot.match("update", update_description) + + aws_client.cloudformation.delete_stack( + StackName=stack_name, + ) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("describe_deleted_by_name_exc", e.value.response) + + deleted = aws_client.cloudformation.describe_stacks(StackName=deployed.stack_id)["Stacks"][ + 0 + ] + assert "DeletionTime" in deleted + snapshot.match("deleted", deleted) + + @markers.aws.validated + def test_stack_description_special_chars(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "test .test.net", + "Resources": { + "TestResource": { + "Type": "AWS::EC2::VPC", + "Properties": {"CidrBlock": "100.30.20.0/20"}, + } + }, + } + deployed = deploy_cfn_template(template=json.dumps(template)) + response = aws_client.cloudformation.describe_stacks(StackName=deployed.stack_id)["Stacks"][ + 0 + ] + snapshot.match("describe_stack", response) + + @markers.aws.validated + def test_stack_name_creation(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"*@{short_uid()}_$" + + with pytest.raises(Exception) as e: + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_template.yaml" + ), + stack_name=stack_name, + ) + + snapshot.match("stack_response", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize("fileformat", ["yaml", "json"]) + def test_get_template_using_create_stack(self, snapshot, fileformat, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"stack-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), f"../../../templates/sns_topic_template.{fileformat}" + ) + ), + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + template_original = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Original" + ) + snapshot.match("template_original", template_original) + + template_processed = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.match("template_processed", template_processed) + + @markers.aws.validated + @pytest.mark.parametrize("fileformat", ["yaml", "json"]) + def test_get_template_using_changesets( + self, deploy_cfn_template, snapshot, fileformat, aws_client + ): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), f"../../../templates/sns_topic_template.{fileformat}" + ) + ) + + template_original = aws_client.cloudformation.get_template( + StackName=stack.stack_id, TemplateStage="Original" + ) + snapshot.match("template_original", template_original) + + template_processed = aws_client.cloudformation.get_template( + StackName=stack.stack_id, TemplateStage="Processed" + ) + snapshot.match("template_processed", template_processed) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..ParameterValue", "$..PhysicalResourceId", "$..Capabilities"] + ) + def test_stack_update_resources( + self, + deploy_cfn_template, + is_change_set_finished, + is_change_set_created_and_available, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("PhysicalResourceId")) + + api_name = f"test_{short_uid()}" + + # create stack + deployed = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/simple_api.yaml" + ), + parameters={"ApiName": api_name}, + ) + stack_name = deployed.stack_name + stack_id = deployed.stack_id + + # assert snapshot of created stack + snapshot.match( + "stack_created", + aws_client.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][0], + ) + + # update stack, with one additional resource + api_name = f"test_{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=deployed.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/simple_api.update.yaml" + ), + parameters={"ApiName": api_name}, + ) + + # assert snapshot of updated stack + snapshot.match( + "stack_updated", + aws_client.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][0], + ) + + # describe stack resources + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name) + snapshot.match("stack_resources", resources) + + @markers.aws.needs_fixing + def test_list_stack_resources_for_removed_resource(self, deploy_cfn_template, aws_client): + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/eventbridge_policy.yaml" + ) + event_bus_name = f"bus-{short_uid()}" + stack = deploy_cfn_template( + template_path=template_path, + parameters={"EventBusName": event_bus_name}, + ) + + resources = aws_client.cloudformation.list_stack_resources(StackName=stack.stack_name)[ + "StackResourceSummaries" + ] + resources_before = len(resources) + assert resources_before == 3 + statuses = {res["ResourceStatus"] for res in resources} + assert statuses == {"CREATE_COMPLETE"} + + # remove one resource from the template, then update stack (via change set) + template_dict = parse_yaml(load_file(template_path)) + template_dict["Resources"].pop("eventPolicy2") + template2 = yaml.dump(template_dict) + + deploy_cfn_template( + stack_name=stack.stack_name, + is_update=True, + template=template2, + parameters={"EventBusName": event_bus_name}, + ) + + # get list of stack resources, again - make sure that deleted resource is not contained in result + resources = aws_client.cloudformation.list_stack_resources(StackName=stack.stack_name)[ + "StackResourceSummaries" + ] + assert len(resources) == resources_before - 1 + statuses = {res["ResourceStatus"] for res in resources} + assert statuses == {"UPDATE_COMPLETE"} + + @markers.aws.validated + def test_update_stack_with_same_template_withoutchange( + self, deploy_cfn_template, aws_client, snapshot + ): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/simple_no_change.yaml") + ) + stack = deploy_cfn_template(template=template) + + with pytest.raises(Exception) as ctx: # TODO: capture proper exception + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, TemplateBody=template + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack.stack_name + ) + + snapshot.match("no_change_exception", ctx.value.response) + + @markers.aws.validated + def test_update_stack_with_same_template_withoutchange_transformation( + self, deploy_cfn_template, aws_client + ): + template = load_file( + os.path.join( + os.path.dirname(__file__), + "../../../templates/simple_no_change_with_transformation.yaml", + ) + ) + stack = deploy_cfn_template(template=template) + + # transformations will always work even if there's no change in the template! + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack.stack_name + ) + + @markers.aws.validated + def test_update_stack_actual_update(self, deploy_cfn_template, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sqs_queue_update.yml") + ) + queue_name = f"test-queue-{short_uid()}" + stack = deploy_cfn_template( + template=template, parameters={"QueueName": queue_name}, max_wait=360 + ) + + queue_arn_1 = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueUrl"], AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + assert queue_arn_1 + + stack2 = deploy_cfn_template( + template=template, + stack_name=stack.stack_name, + parameters={"QueueName": f"{queue_name}-new"}, + is_update=True, + max_wait=360, + ) + + queue_arn_2 = aws_client.sqs.get_queue_attributes( + QueueUrl=stack2.outputs["QueueUrl"], AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + assert queue_arn_2 + + assert queue_arn_1 != queue_arn_2 + + @markers.snapshot.skip_snapshot_verify(paths=["$..StackEvents"]) + @markers.aws.validated + def test_list_events_after_deployment(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(SortingTransformer("StackEvents", lambda x: x["Timestamp"])) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ) + ) + response = aws_client.cloudformation.describe_stack_events(StackName=stack.stack_name) + snapshot.match("events", response) + + @markers.aws.validated + @pytest.mark.skip(reason="disable rollback not supported") + @pytest.mark.parametrize("rollback_disabled, length_expected", [(False, 0), (True, 1)]) + def test_failure_options_for_stack_creation( + self, rollback_disabled, length_expected, aws_client + ): + template_with_error = open( + os.path.join(os.path.dirname(__file__), "../../../templates/multiple_bucket.yaml"), "r" + ).read() + + stack_name = f"stack-{short_uid()}" + bucket_1_name = f"bucket-{short_uid()}" + bucket_2_name = f"bucket!#${short_uid()}" + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template_with_error, + DisableRollback=rollback_disabled, + Parameters=[ + {"ParameterKey": "BucketName1", "ParameterValue": bucket_1_name}, + {"ParameterKey": "BucketName2", "ParameterValue": bucket_2_name}, + ], + ) + + assert wait_until( + lambda _: stack_process_is_finished(aws_client.cloudformation, stack_name), + wait=10, + strategy="exponential", + ) + + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name)[ + "StackResources" + ] + created_resources = [ + resource for resource in resources if "CREATE_COMPLETE" in resource["ResourceStatus"] + ] + assert len(created_resources) == length_expected + + aws_client.cloudformation.delete_stack(StackName=stack_name) + + @markers.aws.validated + @pytest.mark.skipif(reason="disable rollback not enabled", condition=not is_aws_cloud()) + @pytest.mark.parametrize("rollback_disabled, length_expected", [(False, 2), (True, 1)]) + def test_failure_options_for_stack_update( + self, rollback_disabled, length_expected, aws_client, cleanups + ): + stack_name = f"stack-{short_uid()}" + template = open( + os.path.join( + os.path.dirname(__file__), "../../../templates/multiple_bucket_update.yaml" + ), + "r", + ).read() + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template, + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + def _assert_stack_process_finished(): + return stack_process_is_finished(aws_client.cloudformation, stack_name) + + assert wait_until(_assert_stack_process_finished) + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name)[ + "StackResources" + ] + created_resources = [ + resource for resource in resources if "CREATE_COMPLETE" in resource["ResourceStatus"] + ] + assert len(created_resources) == 2 + + aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template, + DisableRollback=rollback_disabled, + Parameters=[ + {"ParameterKey": "Days", "ParameterValue": "-1"}, + ], + ) + + assert wait_until(_assert_stack_process_finished) + + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name)[ + "StackResources" + ] + updated_resources = [ + resource + for resource in resources + if resource["ResourceStatus"] in ["CREATE_COMPLETE", "UPDATE_COMPLETE"] + ] + assert len(updated_resources) == length_expected + + @markers.aws.only_localstack + def test_create_stack_with_custom_id( + self, aws_client, cleanups, account_id, region_name, set_resource_custom_id + ): + stack_name = f"stack-{short_uid()}" + custom_id = short_uid() + + set_resource_custom_id( + StackIdentifier(account_id, region_name, stack_name), custom_id=custom_id + ) + template = open( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml"), + "r", + ).read() + + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template, + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + assert stack["StackId"].split("/")[-1] == custom_id + + # We need to wait until the stack is created otherwise we can end up in a scenario + # where we try to delete the stack before creating its resources, failing the test + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + +def stack_process_is_finished(cfn_client, stack_name): + return ( + "PROGRESS" + not in cfn_client.describe_stacks(StackName=stack_name)["Stacks"][0]["StackStatus"] + ) + + +@markers.aws.validated +@pytest.mark.skip(reason="Not Implemented") +def test_linting_error_during_creation(snapshot, aws_client): + stack_name = f"stack-{short_uid()}" + bad_template = {"Resources": "", "Outputs": ""} + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=json.dumps(bad_template) + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + +@markers.aws.validated +@pytest.mark.skip(reason="feature not implemented") +def test_notifications( + deploy_cfn_template, + sns_create_topic, + is_stack_created, + is_stack_updated, + sqs_create_queue, + sns_create_sqs_subscription, + cleanup_stacks, + aws_client, +): + stack_name = f"stack-{short_uid()}" + topic_arn = sns_create_topic()["TopicArn"] + sqs_url = sqs_create_queue() + sns_create_sqs_subscription(topic_arn, sqs_url) + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + aws_client.cloudformation.create_stack( + StackName=stack_name, + NotificationARNs=[topic_arn], + TemplateBody=template, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + cleanup_stacks([stack_name]) + + assert wait_until(is_stack_created(stack_name)) + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}, + ], + ) + assert wait_until(is_stack_updated(stack_name)) + + messages = {} + + def _assert_messages(): + sqs_messages = aws_client.sqs.receive_message(QueueUrl=sqs_url)["Messages"] + for sqs_message in sqs_messages: + sns_message = json.loads(sqs_message["Body"]) + messages.update({sns_message["MessageId"]: sns_message}) + + # Assert notifications of resources created + assert [message for message in messages.values() if "CREATE_" in message["Message"]] + + # Assert notifications of resources deleted + assert [message for message in messages.values() if "UPDATE_" in message["Message"]] + + # Assert notifications of resources deleted + assert [message for message in messages.values() if "DELETE_" in message["Message"]] + + retry(_assert_messages, retries=10, sleep=2) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # parameters may be out of order + "$..Stacks..Parameters", + ] +) +def test_updating_an_updated_stack_sets_status(deploy_cfn_template, snapshot, aws_client): + """ + The status of a stack that has been updated twice should be "UPDATE_COMPLETE" + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + # need multiple templates to support updates to the stack + template_1 = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_update_1.yaml") + ) + template_2 = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_update_2.yaml") + ) + template_3 = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/stack_update_3.yaml") + ) + + topic_1_name = f"topic-1-{short_uid()}" + topic_2_name = f"topic-2-{short_uid()}" + topic_3_name = f"topic-3-{short_uid()}" + snapshot.add_transformers_list( + [ + snapshot.transform.regex(topic_1_name, "topic-1"), + snapshot.transform.regex(topic_2_name, "topic-2"), + snapshot.transform.regex(topic_3_name, "topic-3"), + ] + ) + + parameters = { + "Topic1Name": topic_1_name, + "Topic2Name": topic_2_name, + "Topic3Name": topic_3_name, + } + + def wait_for(waiter_type: str) -> None: + aws_client.cloudformation.get_waiter(waiter_type).wait( + StackName=stack.stack_name, + WaiterConfig={ + "Delay": 5, + "MaxAttempts": 5, + }, + ) + + stack = deploy_cfn_template(template=template_1, parameters=parameters) + wait_for("stack_create_complete") + + # update the stack + deploy_cfn_template( + template=template_2, + is_update=True, + stack_name=stack.stack_name, + parameters=parameters, + ) + wait_for("stack_update_complete") + + # update the stack again + deploy_cfn_template( + template=template_3, + is_update=True, + stack_name=stack.stack_name, + parameters=parameters, + ) + wait_for("stack_update_complete") + + res = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + snapshot.match("describe-result", res) + + +@markers.aws.validated +def test_update_termination_protection(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("ParameterValue", "parameter-value")) + + # create stack + api_name = f"test_{short_uid()}" + template_path = os.path.join(os.path.dirname(__file__), "../../../templates/simple_api.yaml") + stack = deploy_cfn_template(template_path=template_path, parameters={"ApiName": api_name}) + + # update termination protection (true) + aws_client.cloudformation.update_termination_protection( + EnableTerminationProtection=True, StackName=stack.stack_name + ) + res = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + snapshot.match("describe-stack-1", res) + + # update termination protection (false) + aws_client.cloudformation.update_termination_protection( + EnableTerminationProtection=False, StackName=stack.stack_name + ) + res = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + snapshot.match("describe-stack-2", res) + + +@markers.aws.validated +def test_events_resource_types(deploy_cfn_template, snapshot, aws_client): + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_cdk_sample_app.yaml" + ) + stack = deploy_cfn_template(template_path=template_path, max_wait=500) + events = aws_client.cloudformation.describe_stack_events(StackName=stack.stack_name)[ + "StackEvents" + ] + + resource_types = list({event["ResourceType"] for event in events}) + resource_types.sort() + snapshot.match("resource_types", resource_types) + + +@markers.aws.validated +def test_list_parameter_type(aws_client, deploy_cfn_template, cleanups): + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_parameter_list_type.yaml" + ), + parameters={ + "ParamsList": "foo,bar", + }, + ) + + assert stack.outputs["ParamValue"] == "foo|bar" + + +@markers.aws.validated +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="rollback not implemented") +def test_blocked_stack_deletion(aws_client, cleanups, snapshot): + """ + uses AWS::IAM::Policy for demonstrating this behavior + + 1. create fails + 2. rollback fails even though create didn't even provision anything + 3. trying to delete the stack afterwards also doesn't work + 4. deleting the stack with retain resources works + """ + cfn = aws_client.cloudformation + stack_name = f"test-stacks-blocked-{short_uid()}" + policy_name = f"test-broken-policy-{short_uid()}" + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.regex(policy_name, "")) + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/iam_policy_invalid.yaml") + ) + waiter_config = {"Delay": 1, "MaxAttempts": 20} + + snapshot.add_transformer(snapshot.transform.key_value("PhysicalResourceId")) + snapshot.add_transformer( + snapshot.transform.key_value("ResourceStatusReason", reference_replacement=False) + ) + + stack = cfn.create_stack( + StackName=stack_name, + TemplateBody=template_body, + Parameters=[{"ParameterKey": "Name", "ParameterValue": policy_name}], + Capabilities=[Capability.CAPABILITY_NAMED_IAM], + ) + stack_id = stack["StackId"] + cleanups.append(lambda: cfn.delete_stack(StackName=stack_id, RetainResources=["BrokenPolicy"])) + with pytest.raises(WaiterError): + cfn.get_waiter("stack_create_complete").wait(StackName=stack_id, WaiterConfig=waiter_config) + stack_post_create = cfn.describe_stacks(StackName=stack_id) + snapshot.match("stack_post_create", stack_post_create) + + cfn.delete_stack(StackName=stack_id) + with pytest.raises(WaiterError): + cfn.get_waiter("stack_delete_complete").wait(StackName=stack_id, WaiterConfig=waiter_config) + stack_post_fail_delete = cfn.describe_stacks(StackName=stack_id) + snapshot.match("stack_post_fail_delete", stack_post_fail_delete) + + cfn.delete_stack(StackName=stack_id, RetainResources=["BrokenPolicy"]) + cfn.get_waiter("stack_delete_complete").wait(StackName=stack_id, WaiterConfig=waiter_config) + stack_post_success_delete = cfn.describe_stacks(StackName=stack_id) + snapshot.match("stack_post_success_delete", stack_post_success_delete) + stack_events = cfn.describe_stack_events(StackName=stack_id) + snapshot.match("stack_events", stack_events) + + +MINIMAL_TEMPLATE = """ +Resources: + SimpleParam: + Type: AWS::SSM::Parameter + Properties: + Value: test + Type: String +""" + + +@markers.snapshot.skip_snapshot_verify( + paths=["$..EnableTerminationProtection", "$..LastUpdatedTime"] +) +@markers.aws.validated +def test_name_conflicts(aws_client, snapshot, cleanups): + """ + Tests behavior of creating a stack with the same name of one that was previously deleted + + 1. Create Stack + 2. Delete Stack + 3. Create Stack with same name as in 1. + + Step 3 should be successful because you can re-use StackNames, + but only one stack for a given stack name can be `ACTIVE` at one time. + + We didn't exhaustively test yet what is considered as Active by CloudFormation + For now the assumption is that anything != "DELETE_COMPLETED" is considered "ACTIVE" + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"repeated-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE + ) + stack_id = stack["StackId"] + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # only one can be active at a time + with pytest.raises(aws_client.cloudformation.exceptions.AlreadyExistsException) as e: + aws_client.cloudformation.create_stack(StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE) + snapshot.match("create_stack_already_exists_exc", e.value.response) + + created_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_name)["Stacks"][ + 0 + ]["StackStatus"] + snapshot.match("created_stack_desc", created_stack_desc) + + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + # describe with name fails + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("deleted_stack_not_found_exc", e.value.response) + + # describe events with name fails + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stack_events(StackName=stack_name) + snapshot.match("deleted_stack_events_not_found_by_name", e.value.response) + + # describe with stack id (ARN) succeeds + deleted_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("deleted_stack_desc", deleted_stack_desc) + + # creating a new stack with the same name as the previously deleted one should work + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE + ) + # should issue a new unique stack ID/ARN + new_stack_id = stack["StackId"] + assert stack_id != new_stack_id + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + new_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("new_stack_desc", new_stack_desc) + assert len(new_stack_desc["Stacks"]) == 1 + assert new_stack_desc["Stacks"][0]["StackId"] == new_stack_id + + # can still access both by using the ARN (stack id) + # and they should be different from each other + stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=stack_id) + new_stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=new_stack_id) + snapshot.match("stack_id_desc", stack_id_desc) + snapshot.match("new_stack_id_desc", new_stack_id_desc) + + # check if the describing the stack events return the right stack + stack_events = aws_client.cloudformation.describe_stack_events(StackName=stack_name)[ + "StackEvents" + ] + assert all(stack_event["StackId"] == new_stack_id for stack_event in stack_events) + # describing events by the old stack id should still yield the old events + stack_events = aws_client.cloudformation.describe_stack_events(StackName=stack_id)[ + "StackEvents" + ] + assert all(stack_event["StackId"] == stack_id for stack_event in stack_events) + + # deleting the stack by name should delete the new, not already deleted stack + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + # describe with stack id returns stack deleted + deleted_stack_desc = aws_client.cloudformation.describe_stacks(StackName=new_stack_id) + snapshot.match("deleted_second_stack_desc", deleted_stack_desc) + + +@markers.aws.validated +def test_describe_stack_events_errors(aws_client, snapshot): + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stack_events() + snapshot.match("describe_stack_events_no_stack_name", e.value.response) + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stack_events(StackName="does-not-exist") + snapshot.match("describe_stack_events_stack_not_found", e.value.response) + + +TEMPLATE_ORDER_CASES = list(permutations(["A", "B", "C"])) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..StackId", + # TODO + "$..PhysicalResourceId", + # TODO + "$..ResourceProperties", + ] +) +@pytest.mark.parametrize( + "deploy_order", TEMPLATE_ORDER_CASES, ids=["-".join(vals) for vals in TEMPLATE_ORDER_CASES] +) +def test_stack_deploy_order(deploy_cfn_template, aws_client, snapshot, deploy_order: tuple[str]): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("EventId")) + resources = { + "A": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": "root", + }, + }, + "B": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "A", + }, + }, + }, + "C": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "B", + }, + }, + }, + } + + resources = OrderedDict( + [ + (logical_resource_id, resources[logical_resource_id]) + for logical_resource_id in deploy_order + ] + ) + assert len(resources) == 3 + + stack = deploy_cfn_template( + template=json.dumps( + { + "Resources": resources, + } + ) + ) + + stack.destroy() + + events = aws_client.cloudformation.describe_stack_events( + StackName=stack.stack_id, + )["StackEvents"] + + filtered_events = [] + for event in events: + # only the resources we care about + if event["LogicalResourceId"] not in deploy_order: + continue + + # only _COMPLETE events + if not event["ResourceStatus"].endswith("_COMPLETE"): + continue + + filtered_events.append(event) + + # sort by event time + filtered_events.sort(key=lambda e: e["Timestamp"]) + + snapshot.match("events", filtered_events) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: this property is present in the response from LocalStack when + # there is an active changeset, however it is not present on AWS + # because the change set has not been executed. + "$..Stacks..ChangeSetId", + # FIXME: tackle this when fixing API parity of CloudFormation + "$..Capabilities", + "$..IncludeNestedStacks", + "$..LastUpdatedTime", + "$..NotificationARNs", + "$..ResourceChange", + "$..StackResourceDetail.Metadata", + ] +) +@markers.aws.validated +def test_no_echo_parameter(snapshot, aws_client, deploy_cfn_template): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(SortingTransformer("Parameters", lambda x: x.get("ParameterKey", ""))) + + template_path = os.path.join(os.path.dirname(__file__), "../../../templates/cfn_no_echo.yml") + template = open(template_path, "r").read() + + deployment = deploy_cfn_template( + template=template, + parameters={"SecretParameter": "SecretValue"}, + ) + stack_id = deployment.stack_id + stack_name = deployment.stack_name + + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_stacks", describe_stacks) + + # Check Resource Metadata. + describe_stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=stack_id + ) + for resource in describe_stack_resources["StackResources"]: + resource_logical_id = resource["LogicalResourceId"] + + # Get detailed information about the resource + describe_stack_resource_details = aws_client.cloudformation.describe_stack_resource( + StackName=stack_name, LogicalResourceId=resource_logical_id + ) + snapshot.match( + f"describe_stack_resource_details_{resource_logical_id}", + describe_stack_resource_details, + ) + + # Update stack via update_stack (and change the value of SecretParameter) + aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue1"}, + ], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack_name) + update_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks", update_stacks) + + # Update stack via create_change_set (and change the value of SecretParameter) + change_set_name = f"UpdateSecretParameterValue-{short_uid()}" + aws_client.cloudformation.create_change_set( + StackName=stack_name, + TemplateBody=template, + ChangeSetName=change_set_name, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue2"}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, + ChangeSetName=change_set_name, + ) + change_sets = aws_client.cloudformation.describe_change_set( + StackName=stack_id, + ChangeSetName=change_set_name, + ) + snapshot.match("describe_updated_change_set", change_sets) + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks_change_set", describe_stacks) + + # Change `NoEcho` of a parameter from true to false and update stack via create_change_set. + change_set_name = f"UpdateSecretParameterNoEchoToFalse-{short_uid()}" + template_dict = parse_yaml(load_file(template_path)) + template_dict["Parameters"]["SecretParameter"]["NoEcho"] = False + template_no_echo_false = yaml.dump(template_dict) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + TemplateBody=template_no_echo_false, + ChangeSetName=change_set_name, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue2"}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, + ChangeSetName=change_set_name, + ) + change_sets = aws_client.cloudformation.describe_change_set( + StackName=stack_id, + ChangeSetName=change_set_name, + ) + snapshot.match("describe_updated_change_set_no_echo_true", change_sets) + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks_no_echo_true", describe_stacks) + + # Change `NoEcho` of a parameter back from false to true and update stack via create_change_set. + change_set_name = f"UpdateSecretParameterNoEchoToTrue-{short_uid()}" + aws_client.cloudformation.create_change_set( + StackName=stack_name, + TemplateBody=template, + ChangeSetName=change_set_name, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue2"}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, + ChangeSetName=change_set_name, + ) + change_sets = aws_client.cloudformation.describe_change_set( + StackName=stack_id, + ChangeSetName=change_set_name, + ) + snapshot.match("describe_updated_change_set_no_echo_false", change_sets) + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks_no_echo_false", describe_stacks) + + +@markers.aws.validated +def test_stack_resource_not_found(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_simple.yaml" + ), + parameters={"TopicName": f"topic{short_uid()}"}, + ) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="NonExistentResource" + ) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + snapshot.match("Error", ex.value.response) diff --git a/tests/aws/services/cloudformation/api/test_stacks.snapshot.json b/tests/aws/services/cloudformation/api/test_stacks.snapshot.json new file mode 100644 index 0000000000000..9b4c3fe01f8b1 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_stacks.snapshot.json @@ -0,0 +1,2290 @@ +{ + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_description_special_chars": { + "recorded-date": "05-08-2022, 13:03:43", + "recorded-content": { + "describe_stack": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "Description": "test .test.net", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_update_resources": { + "recorded-date": "30-08-2022, 00:13:26", + "recorded-content": { + "stack_created": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "test_12395eb4" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "stack_updated": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "test_5a3df175" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "stack_resources": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Api", + "PhysicalResourceId": "", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::RestApi", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "-bucket-10xf2vf1pqap8", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_list_events_after_deployment": { + "recorded-date": "05-10-2022, 13:33:55", + "recorded-content": { + "events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "topic123-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "topic123-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "TopicName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "topic123-CREATE_COMPLETE-date", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "TopicName": "" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_lifecycle": { + "recorded-date": "28-11-2023, 13:24:40", + "recorded-content": { + "creation": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "update": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "describe_deleted_by_name_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Stack with id does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleted": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_linting_error_during_creation": { + "recorded-date": "11-11-2022, 08:10:14", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Any Resources member must be an object.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_updating_an_updated_stack_sets_status": { + "recorded-date": "02-12-2022, 11:19:41", + "recorded-content": { + "describe-result": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Topic2Name", + "ParameterValue": "topic-2" + }, + { + "ParameterKey": "Topic1Name", + "ParameterValue": "topic-1" + }, + { + "ParameterKey": "Topic3Name", + "ParameterValue": "topic-3" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_update_termination_protection": { + "recorded-date": "04-01-2023, 16:23:22", + "recorded-content": { + "describe-stack-1": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": true, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-stack-2": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_events_resource_types": { + "recorded-date": "15-02-2023, 10:46:53", + "recorded-content": { + "resource_types": [ + "AWS::CloudFormation::Stack", + "AWS::SNS::Subscription", + "AWS::SNS::Topic", + "AWS::SQS::Queue", + "AWS::SQS::QueuePolicy" + ] + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_name_creation": { + "recorded-date": "19-04-2023, 12:44:47", + "recorded-content": { + "stack_response": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value '*@da591fa3_$' at 'stackName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*|arn:[-a-zA-Z0-9:/._+]*", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_blocked_stack_deletion": { + "recorded-date": "06-09-2023, 11:01:18", + "recorded-content": { + "stack_post_create": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Name", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "ROLLBACK_FAILED", + "StackStatusReason": "The following resource(s) failed to delete: [BrokenPolicy]. ", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_post_fail_delete": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Name", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_FAILED", + "StackStatusReason": "The following resource(s) failed to delete: [BrokenPolicy]. ", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_post_success_delete": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Name", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_SKIPPED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_SKIPPED", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_FAILED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_FAILED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-CREATE_FAILED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "CREATE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_name_conflicts": { + "recorded-date": "26-03-2024, 17:59:43", + "recorded-content": { + "create_stack_already_exists_exc": { + "Error": { + "Code": "AlreadyExistsException", + "Message": "Stack [] already exists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "created_stack_desc": "CREATE_COMPLETE", + "deleted_stack_not_found_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Stack with id does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleted_stack_events_not_found_by_name": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [] does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleted_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new_stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deleted_second_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_describe_stack_events_errors": { + "recorded-date": "26-03-2024, 17:54:41", + "recorded-content": { + "describe_stack_events_no_stack_name": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value null at 'stackName' failed to satisfy constraint: Member must not be null", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe_stack_events_stack_not_found": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [does-not-exist] does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange": { + "recorded-date": "07-05-2024, 08:34:18", + "recorded-content": { + "no_change_exception": { + "Error": { + "Code": "ValidationError", + "Message": "No updates are to be performed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[A-B-C]": { + "recorded-date": "29-05-2024, 11:44:14", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xvqPt7CmcHKX", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-FCaKHvMgdicm", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xvqPt7CmcHKX" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-Xr56esN3SasR", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-FCaKHvMgdicm" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-Xr56esN3SasR", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-FCaKHvMgdicm" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-FCaKHvMgdicm", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xvqPt7CmcHKX" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xvqPt7CmcHKX", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[A-C-B]": { + "recorded-date": "29-05-2024, 11:44:32", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-4tNP69dd8iSL", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-d81WSIsD2X3i", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-4tNP69dd8iSL" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-kStA2w3izJOh", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-d81WSIsD2X3i" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-kStA2w3izJOh", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-d81WSIsD2X3i" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-d81WSIsD2X3i", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-4tNP69dd8iSL" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-4tNP69dd8iSL", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[B-A-C]": { + "recorded-date": "29-05-2024, 11:44:51", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-a0yQkOAYKMk5", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-RvqPXWdIGzrt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-a0yQkOAYKMk5" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-iPNi3cV9jXAt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-RvqPXWdIGzrt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-iPNi3cV9jXAt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-RvqPXWdIGzrt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-RvqPXWdIGzrt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-a0yQkOAYKMk5" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-a0yQkOAYKMk5", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[B-C-A]": { + "recorded-date": "29-05-2024, 11:45:12", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xNtQNbQrdc1T", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-UY120OHcpDMZ", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xNtQNbQrdc1T" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-GOhk98pWaTFw", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-UY120OHcpDMZ" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-GOhk98pWaTFw", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-UY120OHcpDMZ" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-UY120OHcpDMZ", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xNtQNbQrdc1T" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xNtQNbQrdc1T", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[C-A-B]": { + "recorded-date": "29-05-2024, 11:45:31", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-BFvOY1qz1Osv", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-qCiX6NdW4hEt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-BFvOY1qz1Osv" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-ki0TLXKJfPgN", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-qCiX6NdW4hEt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-ki0TLXKJfPgN", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-qCiX6NdW4hEt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-qCiX6NdW4hEt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-BFvOY1qz1Osv" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-BFvOY1qz1Osv", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[C-B-A]": { + "recorded-date": "29-05-2024, 11:45:50", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-LQadBXOC2eGc", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-p6Hy6dxQCfjl", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-LQadBXOC2eGc" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-YYmzIb8agve7", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-p6Hy6dxQCfjl" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-YYmzIb8agve7", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-p6Hy6dxQCfjl" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-p6Hy6dxQCfjl", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-LQadBXOC2eGc" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-LQadBXOC2eGc", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_no_echo_parameter": { + "recorded-date": "19-12-2024, 11:35:19", + "recorded-content": { + "describe_stacks": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "SecretValue" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_resource_details_LocalBucket": { + "StackResourceDetail": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "LocalBucket", + "Metadata": { + "SensitiveData": "SecretValue" + }, + "PhysicalResourceId": "cfn-noecho-bucket", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_change_set": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "LocalBucket", + "PhysicalResourceId": "cfn-noecho-bucket", + "Replacement": "False", + "ResourceType": "AWS::S3::Bucket", + "Scope": [ + "Metadata", + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks_change_set": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_change_set_no_echo_true": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "LocalBucket", + "PhysicalResourceId": "cfn-noecho-bucket", + "Replacement": "False", + "ResourceType": "AWS::S3::Bucket", + "Scope": [ + "Metadata", + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "NewSecretValue2" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks_no_echo_true": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_change_set_no_echo_false": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "LocalBucket", + "PhysicalResourceId": "cfn-noecho-bucket", + "Replacement": "False", + "ResourceType": "AWS::S3::Bucket", + "Scope": [ + "Metadata", + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks_no_echo_false": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[yaml]": { + "recorded-date": "02-01-2025, 19:08:41", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[json]": { + "recorded-date": "02-01-2025, 19:09:40", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[yaml]": { + "recorded-date": "02-01-2025, 19:11:14", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[json]": { + "recorded-date": "02-01-2025, 19:11:20", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_resource_not_found": { + "recorded-date": "29-01-2025, 09:08:15", + "recorded-content": { + "Error": { + "Error": { + "Code": "ValidationError", + "Message": "Resource NonExistentResource does not exist for stack ", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_stacks.validation.json b/tests/aws/services/cloudformation/api/test_stacks.validation.json new file mode 100644 index 0000000000000..b1275f20421e5 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_stacks.validation.json @@ -0,0 +1,131 @@ +{ + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[False-2]": { + "last_validated_date": "2024-06-25T17:21:51+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[True-1]": { + "last_validated_date": "2024-06-25T17:22:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template[json]": { + "last_validated_date": "2022-08-11T08:55:35+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template[yaml]": { + "last_validated_date": "2022-08-11T08:55:10+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[json]": { + "last_validated_date": "2025-01-02T19:09:40+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[yaml]": { + "last_validated_date": "2025-01-02T19:08:41+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[json]": { + "last_validated_date": "2025-01-02T19:11:20+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[yaml]": { + "last_validated_date": "2025-01-02T19:11:14+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_list_events_after_deployment": { + "last_validated_date": "2022-10-05T11:33:55+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_description_special_chars": { + "last_validated_date": "2022-08-05T11:03:43+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_lifecycle": { + "last_validated_date": "2023-11-28T12:24:40+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_name_creation": { + "last_validated_date": "2023-04-19T10:44:47+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_stack_update_resources": { + "last_validated_date": "2022-08-29T22:13:26+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange": { + "last_validated_date": "2024-05-07T08:35:29+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange_transformation": { + "last_validated_date": "2024-05-07T09:26:39+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_blocked_stack_deletion": { + "last_validated_date": "2023-09-06T09:01:18+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_describe_stack_events_errors": { + "last_validated_date": "2024-03-26T17:54:41+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_events_resource_types": { + "last_validated_date": "2023-02-15T09:46:53+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_linting_error_during_creation": { + "last_validated_date": "2022-11-11T07:10:14+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_name_conflicts": { + "last_validated_date": "2024-03-26T17:59:43+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_no_echo_parameter": { + "last_validated_date": "2024-12-19T11:35:15+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2": { + "last_validated_date": "2024-05-21T09:48:14+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[A-B-C]": { + "last_validated_date": "2024-05-21T10:00:44+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[A-C-B]": { + "last_validated_date": "2024-05-21T10:01:07+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[B-A-C]": { + "last_validated_date": "2024-05-21T10:01:29+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[B-C-A]": { + "last_validated_date": "2024-05-21T10:01:50+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[C-A-B]": { + "last_validated_date": "2024-05-21T10:02:11+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[C-B-A]": { + "last_validated_date": "2024-05-21T10:02:33+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[deploy_order0]": { + "last_validated_date": "2024-05-21T09:49:59+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[deploy_order1]": { + "last_validated_date": "2024-05-21T09:50:22+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[deploy_order2]": { + "last_validated_date": "2024-05-21T09:50:44+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[deploy_order3]": { + "last_validated_date": "2024-05-21T09:51:07+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[deploy_order4]": { + "last_validated_date": "2024-05-21T09:51:28+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order2[deploy_order5]": { + "last_validated_date": "2024-05-21T09:51:51+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[A-B-C]": { + "last_validated_date": "2024-05-29T11:44:14+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[A-C-B]": { + "last_validated_date": "2024-05-29T11:44:32+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[B-A-C]": { + "last_validated_date": "2024-05-29T11:44:51+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[B-C-A]": { + "last_validated_date": "2024-05-29T11:45:12+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[C-A-B]": { + "last_validated_date": "2024-05-29T11:45:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_deploy_order[C-B-A]": { + "last_validated_date": "2024-05-29T11:45:50+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_stack_resource_not_found": { + "last_validated_date": "2025-01-29T09:08:15+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_update_termination_protection": { + "last_validated_date": "2023-01-04T15:23:22+00:00" + }, + "tests/aws/services/cloudformation/api/test_stacks.py::test_updating_an_updated_stack_sets_status": { + "last_validated_date": "2022-12-02T10:19:41+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_templates.py b/tests/aws/services/cloudformation/api/test_templates.py new file mode 100644 index 0000000000000..07cd69d03276a --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_templates.py @@ -0,0 +1,117 @@ +import contextlib +import os +import textwrap + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.common import load_file +from localstack.utils.strings import short_uid, to_bytes + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..ResourceIdentifierSummaries..ResourceIdentifiers", "$..Parameters"] +) +def test_get_template_summary(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + + deployment = deploy_cfn_template( + template_path=os.path.join( + # This template has no parameters, and so shows the issue + os.path.dirname(__file__), + "../../../templates/sns_topic_simple.yaml", + ) + ) + + res = aws_client.cloudformation.get_template_summary(StackName=deployment.stack_name) + + snapshot.match("template-summary", res) + + +@markers.aws.validated +@pytest.mark.parametrize("url_style", ["s3_url", "http_path", "http_host", "http_invalid"]) +def test_create_stack_from_s3_template_url( + url_style, snapshot, s3_create_bucket, aws_client, cleanups +): + topic_name = f"topic-{short_uid()}" + bucket_name = s3_create_bucket() + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + snapshot.add_transformer(snapshot.transform.regex(bucket_name, "")) + + stack_name = f"s-{short_uid()}" + template = textwrap.dedent( + """ + AWSTemplateFormatVersion: '2010-09-09' + Parameters: + TopicName: + Type: String + Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + """ + ) + + aws_client.s3.put_object(Bucket=bucket_name, Key="test/template.yml", Body=to_bytes(template)) + + match url_style: + case "s3_url": + template_url = f"s3://{bucket_name}/test/template.yml" + case "http_path": + template_url = f"https://s3.amazonaws.com/{bucket_name}/test/template.yml" + case "http_host": + template_url = f"https://{bucket_name}.s3.amazonaws.com/test/template.yml" + case "http_invalid": + # note: using an invalid (non-existing) URL here, but in fact all non-S3 HTTP URLs are invalid in real AWS + template_url = "https://example.com/dummy.yml" + case _: + raise Exception(f"Unexpected `url_style` parameter: {url_style}") + + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + # deploy stack + error_expected = url_style in ["s3_url", "http_invalid"] + context_manager = pytest.raises(ClientError) if error_expected else contextlib.nullcontext() + with context_manager as ctx: + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateURL=template_url, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": topic_name}], + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # assert that either error was raised, or topic has been created + if error_expected: + snapshot.match("create-error", ctx.value.response) + else: + results = list(aws_client.sns.get_paginator("list_topics").paginate()) + matching = [ + t for res in results for t in res["Topics"] if t["TopicArn"].endswith(topic_name) + ] + snapshot.match("matching-topic", matching) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Parameters..DefaultValue"]) +def test_validate_template(aws_client, snapshot): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/valid_template.json") + ) + + resp = aws_client.cloudformation.validate_template(TemplateBody=template) + snapshot.match("validate-template", resp) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Error..Message"]) +def test_validate_invalid_json_template_should_fail(aws_client, snapshot): + invalid_json = '{"this is invalid JSON"="bobbins"}' + + with pytest.raises(ClientError) as ctx: + aws_client.cloudformation.validate_template(TemplateBody=invalid_json) + + snapshot.match("validate-invalid-json", ctx.value.response) diff --git a/tests/aws/services/cloudformation/api/test_templates.snapshot.json b/tests/aws/services/cloudformation/api/test_templates.snapshot.json new file mode 100644 index 0000000000000..be4f7159eae50 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_templates.snapshot.json @@ -0,0 +1,113 @@ +{ + "tests/aws/services/cloudformation/api/test_templates.py::test_get_template_summary": { + "recorded-date": "24-05-2023, 15:05:00", + "recorded-content": { + "template-summary": { + "Metadata": "{'TopicName': 'sns-topic-simple'}", + "Parameters": [], + "ResourceIdentifierSummaries": [ + { + "LogicalResourceIds": [ + "topic123" + ], + "ResourceType": "AWS::SNS::Topic" + } + ], + "ResourceTypes": [ + "AWS::SNS::Topic" + ], + "Version": "2010-09-09", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[s3_url]": { + "recorded-date": "11-10-2023, 00:03:44", + "recorded-content": { + "create-error": { + "Error": { + "Code": "ValidationError", + "Message": "S3 error: Domain name specified in is not a valid S3 domain", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_path]": { + "recorded-date": "11-10-2023, 00:03:53", + "recorded-content": { + "matching-topic": [ + { + "TopicArn": "arn::sns::111111111111:" + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_host]": { + "recorded-date": "11-10-2023, 00:04:02", + "recorded-content": { + "matching-topic": [ + { + "TopicArn": "arn::sns::111111111111:" + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_invalid]": { + "recorded-date": "11-10-2023, 00:04:04", + "recorded-content": { + "create-error": { + "Error": { + "Code": "ValidationError", + "Message": "TemplateURL must be a supported URL.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_validate_template": { + "recorded-date": "18-06-2024, 17:23:30", + "recorded-content": { + "validate-template": { + "Parameters": [ + { + "Description": "The EC2 Key Pair to allow SSH access to the instance", + "NoEcho": false, + "ParameterKey": "KeyExample" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_validate_invalid_json_template_should_fail": { + "recorded-date": "18-06-2024, 17:25:49", + "recorded-content": { + "validate-invalid-json": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: JSON not well-formed. (line 1, column 25)", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_templates.validation.json b/tests/aws/services/cloudformation/api/test_templates.validation.json new file mode 100644 index 0000000000000..68f2a82074387 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_templates.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_host]": { + "last_validated_date": "2023-10-10T22:04:02+00:00" + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_invalid]": { + "last_validated_date": "2023-10-10T22:04:04+00:00" + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[http_path]": { + "last_validated_date": "2023-10-10T22:03:53+00:00" + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_create_stack_from_s3_template_url[s3_url]": { + "last_validated_date": "2023-10-10T22:03:44+00:00" + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_get_template_summary": { + "last_validated_date": "2023-05-24T13:05:00+00:00" + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_validate_invalid_json_template_should_fail": { + "last_validated_date": "2024-06-18T17:25:49+00:00" + }, + "tests/aws/services/cloudformation/api/test_templates.py::test_validate_template": { + "last_validated_date": "2024-06-18T17:23:30+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_transformers.py b/tests/aws/services/cloudformation/api/test_transformers.py new file mode 100644 index 0000000000000..568b260c407f5 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_transformers.py @@ -0,0 +1,360 @@ +import json +import os +import textwrap +from dataclasses import dataclass + +import pytest +from botocore.exceptions import WaiterError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.testing.pytest import markers +from localstack.utils.functions import call_safe +from localstack.utils.strings import short_uid, to_bytes + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..tags"]) +def test_duplicate_resources(deploy_cfn_template, s3_bucket, snapshot, aws_client): + snapshot.add_transformers_list( + [ + *snapshot.transform.apigateway_api(), + snapshot.transform.key_value("aws:cloudformation:stack-id"), + snapshot.transform.key_value("aws:cloudformation:stack-name"), + ] + ) + + # put API spec to S3 + api_spec = """ + swagger: 2.0 + info: + version: "1.2.3" + title: "Test API" + basePath: /base + """ + aws_client.s3.put_object(Bucket=s3_bucket, Key="api.yaml", Body=to_bytes(api_spec)) + + # deploy template + template = """ + Parameters: + ApiName: + Type: String + BucketName: + Type: String + Resources: + RestApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Ref ApiName + Body: + 'Fn::Transform': + Name: 'AWS::Include' + Parameters: + Location: !Sub "s3://${BucketName}/api.yaml" + Outputs: + RestApiId: + Value: !Ref RestApi + """ + + api_name = f"api-{short_uid()}" + result = deploy_cfn_template( + template=template, parameters={"ApiName": api_name, "BucketName": s3_bucket} + ) + + # assert REST API is created properly + api_id = result.outputs.get("RestApiId") + result = aws_client.apigateway.get_rest_api(restApiId=api_id) + assert result + snapshot.match("api-details", result) + + resources = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("api-resources", resources) + + +@markers.aws.validated +def test_transformer_property_level(deploy_cfn_template, s3_bucket, aws_client, snapshot): + api_spec = textwrap.dedent(""" + Value: from_transformation + """) + aws_client.s3.put_object(Bucket=s3_bucket, Key="data.yaml", Body=to_bytes(api_spec)) + + # deploy template + template = textwrap.dedent(""" + Parameters: + BucketName: + Type: String + Resources: + MyParameter: + Type: AWS::SSM::Parameter + Properties: + Description: hello + Type: String + "Fn::Transform": + Name: "AWS::Include" + Parameters: + Location: !Sub "s3://${BucketName}/data.yaml" + Outputs: + ParameterName: + Value: !Ref MyParameter + """) + + result = deploy_cfn_template(template=template, parameters={"BucketName": s3_bucket}) + param_name = result.outputs["ParameterName"] + param = aws_client.ssm.get_parameter(Name=param_name) + assert ( + param["Parameter"]["Value"] == "from_transformation" + ) # value coming from the transformation + describe_result = ( + aws_client.ssm.get_paginator("describe_parameters") + .paginate(Filters=[{"Key": "Name", "Values": [param_name]}]) + .build_full_result() + ) + assert ( + describe_result["Parameters"][0]["Description"] == "hello" + ) # value from a property on the same level as the transformation + + original_template = aws_client.cloudformation.get_template( + StackName=result.stack_id, TemplateStage="Original" + ) + snapshot.match("original_template", original_template) + processed_template = aws_client.cloudformation.get_template( + StackName=result.stack_id, TemplateStage="Processed" + ) + snapshot.match("processed_template", processed_template) + + +@markers.aws.validated +def test_transformer_individual_resource_level(deploy_cfn_template, s3_bucket, aws_client): + api_spec = textwrap.dedent(""" + Type: AWS::SNS::Topic + """) + aws_client.s3.put_object(Bucket=s3_bucket, Key="data.yaml", Body=to_bytes(api_spec)) + + # deploy template + template = textwrap.dedent(""" + Parameters: + BucketName: + Type: String + Resources: + MyResource: + "Fn::Transform": + Name: "AWS::Include" + Parameters: + Location: !Sub "s3://${BucketName}/data.yaml" + Outputs: + ResourceRef: + Value: !Ref MyResource + """) + + result = deploy_cfn_template(template=template, parameters={"BucketName": s3_bucket}) + resource_ref = result.outputs["ResourceRef"] + # just checking that this doens't fail, i.e. the topic exists + aws_client.sns.get_topic_attributes(TopicArn=resource_ref) + + +@dataclass +class TransformResult: + stack_id: str + template: dict + + +@pytest.fixture +def transform_template(aws_client: ServiceLevelClientFactory, snapshot, cleanups): + stack_ids: list[str] = [] + + def transform(template: str, parameters: dict[str, str] | None = None) -> TransformResult: + stack_name = f"stack-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(stack_name, "")) + + parameters = [ + {"ParameterKey": key, "ParameterValue": value} + for key, value in (parameters or {}).items() + ] + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + Parameters=parameters, + ) + stack_id = stack["StackId"] + stack_ids.append(stack_id) + try: + aws_client.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack_id, + ) + except WaiterError as e: + events = aws_client.cloudformation.describe_stack_events(StackName=stack_id)[ + "StackEvents" + ] + relevant_fields = [ + { + key: event.get(key) + for key in [ + "LogicalResourceId", + "ResourceType", + "ResourceStatus", + "ResourceStatusReason", + ] + } + for event in events + ] + raise RuntimeError(json.dumps(relevant_fields, indent=2, default=repr)) from e + + stack_resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_id) + snapshot.match("resources", stack_resources) + + template = aws_client.cloudformation.get_template( + StackName=stack_id, TemplateStage="Processed" + )["TemplateBody"] + return TransformResult(template=template, stack_id=stack_id) + + yield transform + + for stack_id in stack_ids: + call_safe(lambda: aws_client.cloudformation.delete_stack(StackName=stack_id)) + + +class TestLanguageExtensionsTransform: + """ + Manual testing of the language extensions trasnform + """ + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..PhysicalResourceId", "$..StackId"]) + def test_transform_length(self, transform_template, snapshot): + with open( + os.path.realpath( + os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_languageextensions_length.yml", + ) + ) + ) as infile: + parameters = {"QueueList": "a,b,c"} + transformed_template_result = transform_template(infile.read(), parameters) + + snapshot.match("transformed", transformed_template_result.template) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..PhysicalResourceId", "$..StackId"]) + def test_transform_foreach(self, transform_template, snapshot): + topic_names = [ + f"mytopic1{short_uid()}", + f"mytopic2{short_uid()}", + f"mytopic3{short_uid()}", + ] + for i, name in enumerate(topic_names): + snapshot.add_transformer(snapshot.transform.regex(name, f"")) + + with open( + os.path.realpath( + os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_languageextensions_foreach.yml", + ) + ) + ) as infile: + transform_result = transform_template( + infile.read(), + parameters={ + "pRepoARNs": ",".join(topic_names), + }, + ) + snapshot.match("transformed", transform_result.template) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..StackResources..PhysicalResourceId", "$..StackResources..StackId"] + ) + def test_transform_foreach_multiple_resources(self, transform_template, snapshot): + snapshot.add_transformer( + SortingTransformer("StackResources", lambda resource: resource["LogicalResourceId"]) + ) + with open( + os.path.realpath( + os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_languageextensions_foreach_multiple_resources.yml", + ) + ) + ) as infile: + transform_result = transform_template(infile.read()) + snapshot.match("transformed", transform_result.template) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DependsOn", + # skipped due to a big in the provider not rendering the template correctly + "$..Resources.GraphQLApi.Properties.Name", + "$..OutputValue", + "$..StackResources..PhysicalResourceId", + "$..StackResources..StackId", + ] + ) + def test_transform_foreach_use_case(self, aws_client, transform_template, snapshot): + snapshot.add_transformer( + SortingTransformer("StackResources", lambda resource: resource["LogicalResourceId"]) + ) + event_names = ["Event1", "Event2"] + server_event_names = ["ServerEvent1", "ServerEvent2"] + for i, name in enumerate(event_names + server_event_names): + snapshot.add_transformer(snapshot.transform.regex(name, f"")) + + parameters = { + "AppSyncSubscriptionFilterNames": ",".join(event_names), + "AppSyncServerEventNames": ",".join(server_event_names), + } + with open( + os.path.realpath( + os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_languageextensions_ryanair.yml", + ) + ) + ) as infile: + transform_result = transform_template( + infile.read(), + parameters=parameters, + ) + snapshot.match("transformed", transform_result.template) + + # check that the resources have been created correctly + outputs = aws_client.cloudformation.describe_stacks(StackName=transform_result.stack_id)[ + "Stacks" + ][0]["Outputs"] + snapshot.match("stack-outputs", outputs) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..StackResources..PhysicalResourceId", + "$..StackResources..StackId", + ] + ) + def test_transform_to_json_string(self, aws_client, transform_template, snapshot): + snapshot.add_transformer( + SortingTransformer("StackResources", lambda resource: resource["LogicalResourceId"]) + ) + with open( + os.path.realpath( + os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_languageextensions_tojsonstring.yml", + ) + ) + ) as infile: + transform_result = transform_template(infile.read()) + snapshot.match("transformed", transform_result.template) + + outputs = aws_client.cloudformation.describe_stacks(StackName=transform_result.stack_id)[ + "Stacks" + ][0]["Outputs"] + outputs = {every["OutputKey"]: every["OutputValue"] for every in outputs} + + object_value = aws_client.ssm.get_parameter(Name=outputs["ObjectName"])["Parameter"][ + "Value" + ] + snapshot.match("object-value", object_value) + array_value = aws_client.ssm.get_parameter(Name=outputs["ArrayName"])["Parameter"]["Value"] + snapshot.match("array-value", array_value) diff --git a/tests/aws/services/cloudformation/api/test_transformers.snapshot.json b/tests/aws/services/cloudformation/api/test_transformers.snapshot.json new file mode 100644 index 0000000000000..05c710c033043 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_transformers.snapshot.json @@ -0,0 +1,729 @@ +{ + "tests/aws/services/cloudformation/api/test_transformers.py::test_duplicate_resources": { + "recorded-date": "15-07-2025, 19:27:40", + "recorded-content": { + "api-details": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "RestApi", + "aws:cloudformation:stack-id": "", + "aws:cloudformation:stack-name": "" + }, + "version": "1.2.3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "api-resources": { + "items": [ + { + "id": "", + "path": "/" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::test_transformer_property_level": { + "recorded-date": "06-06-2024, 10:37:03", + "recorded-content": { + "original_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "\nParameters:\n BucketName:\n Type: String\nResources:\n MyParameter:\n Type: AWS::SSM::Parameter\n Properties:\n Description: hello\n Type: String\n \"Fn::Transform\":\n Name: \"AWS::Include\"\n Parameters:\n Location: !Sub \"s3://${BucketName}/data.yaml\"\nOutputs:\n ParameterName:\n Value: !Ref MyParameter\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "ParameterName": { + "Value": { + "Ref": "MyParameter" + } + } + }, + "Parameters": { + "BucketName": { + "Type": "String" + } + }, + "Resources": { + "MyParameter": { + "Properties": { + "Description": "hello", + "Type": "String", + "Value": "from_transformation" + }, + "Type": "AWS::SSM::Parameter" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::test_language_extensions": { + "recorded-date": "27-06-2025, 16:00:24", + "recorded-content": { + "parameter-value": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "3", + "Version": 1 + } + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_length": { + "recorded-date": "04-07-2025, 13:29:38", + "recorded-content": { + "resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyParameter", + "PhysicalResourceId": "CFN-MyParameter-XDIklGDTsx0d", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//ea360910-58da-11f0-9610-0a867e99d789", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transformed": { + "Outputs": { + "ParameterName": { + "Value": { + "Ref": "MyParameter" + } + } + }, + "Parameters": { + "QueueList": { + "Type": "CommaDelimitedList" + } + }, + "Resources": { + "MyParameter": { + "Properties": { + "Type": "String", + "Value": 3 + }, + "Type": "AWS::SSM::Parameter" + } + } + } + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach": { + "recorded-date": "02-07-2025, 20:00:41", + "recorded-content": { + "resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "SnsTopic", + "PhysicalResourceId": "arn::sns::111111111111:.fifo", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//3727e570-577f-11f0-84c2-027a12c5d007", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "SnsTopic", + "PhysicalResourceId": "arn::sns::111111111111:.fifo", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//3727e570-577f-11f0-84c2-027a12c5d007", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "SnsTopic", + "PhysicalResourceId": "arn::sns::111111111111:.fifo", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//3727e570-577f-11f0-84c2-027a12c5d007", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transformed": { + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "pRepoARNs": { + "Description": "ARN of SSO instance", + "Type": "CommaDelimitedList" + } + }, + "Resources": { + "SnsTopic": { + "Properties": { + "FifoTopic": true, + "TopicName": ".fifo" + }, + "Type": "AWS::SNS::Topic" + }, + "SnsTopic": { + "Properties": { + "FifoTopic": true, + "TopicName": ".fifo" + }, + "Type": "AWS::SNS::Topic" + }, + "SnsTopic": { + "Properties": { + "FifoTopic": true, + "TopicName": ".fifo" + }, + "Type": "AWS::SNS::Topic" + } + } + } + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach_use_case": { + "recorded-date": "03-07-2025, 15:19:42", + "recorded-content": { + "resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "GraphQLApi", + "PhysicalResourceId": "arn::appsync::111111111111:apis/kzqecnrvvjhqvlhc7ab7g5fgvq", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::AppSync::GraphQLApi", + "StackId": "arn::cloudformation::111111111111:stack//1c00b9a0-5821-11f0-8e5a-024091034385", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "GraphQLApiSchema", + "PhysicalResourceId": "kzqecnrvvjhqvlhc7ab7g5fgvqGraphQLSchema", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::AppSync::GraphQLSchema", + "StackId": "arn::cloudformation::111111111111:stack//1c00b9a0-5821-11f0-8e5a-024091034385", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "GraphQLNoneDataSource", + "PhysicalResourceId": "arn::appsync::111111111111:apis/kzqecnrvvjhqvlhc7ab7g5fgvq/datasources/noneds", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::AppSync::DataSource", + "StackId": "arn::cloudformation::111111111111:stack//1c00b9a0-5821-11f0-8e5a-024091034385", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "GraphQLResolverPublishSubscription", + "PhysicalResourceId": "arn::appsync::111111111111:apis/kzqecnrvvjhqvlhc7ab7g5fgvq/types/Subscription/resolvers/on", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::AppSync::Resolver", + "StackId": "arn::cloudformation::111111111111:stack//1c00b9a0-5821-11f0-8e5a-024091034385", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "GraphQLResolverPublishSubscription", + "PhysicalResourceId": "arn::appsync::111111111111:apis/kzqecnrvvjhqvlhc7ab7g5fgvq/types/Subscription/resolvers/on", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::AppSync::Resolver", + "StackId": "arn::cloudformation::111111111111:stack//1c00b9a0-5821-11f0-8e5a-024091034385", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "GraphQLResolverPublishServerMutation", + "PhysicalResourceId": "arn::appsync::111111111111:apis/kzqecnrvvjhqvlhc7ab7g5fgvq/types/Mutation/resolvers/publishServer", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::AppSync::Resolver", + "StackId": "arn::cloudformation::111111111111:stack//1c00b9a0-5821-11f0-8e5a-024091034385", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "GraphQLResolverPublishServerMutation", + "PhysicalResourceId": "arn::appsync::111111111111:apis/kzqecnrvvjhqvlhc7ab7g5fgvq/types/Mutation/resolvers/publishServer", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::AppSync::Resolver", + "StackId": "arn::cloudformation::111111111111:stack//1c00b9a0-5821-11f0-8e5a-024091034385", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transformed": { + "Outputs": { + "GraphQLApiArn": { + "Value": { + "Ref": "GraphQLApi" + } + } + }, + "Parameters": { + "AppSyncServerEventNames": { + "Type": "CommaDelimitedList" + }, + "AppSyncSubscriptionFilterNames": { + "Type": "CommaDelimitedList" + } + }, + "Resources": { + "GraphQLApi": { + "Properties": { + "AuthenticationType": "API_KEY", + "Name": "_api" + }, + "Type": "AWS::AppSync::GraphQLApi" + }, + "GraphQLApiSchema": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLApi", + "ApiId" + ] + }, + "Definition": "\ninput PublishServerInput {\n value: String!\n}\n\ninput PublishServerInput {\n value: String!\n}\n\ntype Query {\n _empty: String\n}\n\ntype Subscription {\n on: String\n @aws_subscribe(mutations: [\"publishServer\"])\n on: String\n @aws_subscribe(mutations: [\"publishServer\"])\n}\n\ntype Mutation {\n publishServer(input: PublishServerInput!): String\n publishServer(input: PublishServerInput!): String\n}\n\nschema {\n query: Query\n mutation: Mutation\n subscription: Subscription\n}\n" + }, + "Type": "AWS::AppSync::GraphQLSchema" + }, + "GraphQLNoneDataSource": { + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLApi", + "ApiId" + ] + }, + "Name": "noneds", + "Type": "NONE" + }, + "Type": "AWS::AppSync::DataSource" + }, + "GraphQLResolverPublishSubscription": { + "DependsOn": "GraphQLApiSchema", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLApi", + "ApiId" + ] + }, + "Code": "export function request(ctx) {}\n\nexport function response(ctx) {}\n", + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQLNoneDataSource", + "Name" + ] + }, + "FieldName": "on", + "Runtime": { + "Name": "APPSYNC_JS", + "RuntimeVersion": "1.0.0" + }, + "TypeName": "Subscription" + }, + "Type": "AWS::AppSync::Resolver" + }, + "GraphQLResolverPublishSubscription": { + "DependsOn": "GraphQLApiSchema", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLApi", + "ApiId" + ] + }, + "Code": "export function request(ctx) {}\n\nexport function response(ctx) {}\n", + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQLNoneDataSource", + "Name" + ] + }, + "FieldName": "on", + "Runtime": { + "Name": "APPSYNC_JS", + "RuntimeVersion": "1.0.0" + }, + "TypeName": "Subscription" + }, + "Type": "AWS::AppSync::Resolver" + }, + "GraphQLResolverPublishServerMutation": { + "DependsOn": "GraphQLApiSchema", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLApi", + "ApiId" + ] + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQLNoneDataSource", + "Name" + ] + }, + "FieldName": "publishServer", + "RequestMappingTemplate": "{\n \"version\": \"2017-02-28\",\n \"payload\": $util.toJson($context.arguments)\n}\n", + "ResponseMappingTemplate": "$util.toJson($context.result)\n", + "TypeName": "Mutation" + }, + "Type": "AWS::AppSync::Resolver" + }, + "GraphQLResolverPublishServerMutation": { + "DependsOn": "GraphQLApiSchema", + "Properties": { + "ApiId": { + "Fn::GetAtt": [ + "GraphQLApi", + "ApiId" + ] + }, + "DataSourceName": { + "Fn::GetAtt": [ + "GraphQLNoneDataSource", + "Name" + ] + }, + "FieldName": "publishServer", + "RequestMappingTemplate": "{\n \"version\": \"2017-02-28\",\n \"payload\": $util.toJson($context.arguments)\n}\n", + "ResponseMappingTemplate": "$util.toJson($context.result)\n", + "TypeName": "Mutation" + }, + "Type": "AWS::AppSync::Resolver" + } + } + }, + "stack-outputs": [ + { + "OutputKey": "GraphQLApiArn", + "OutputValue": "arn::appsync::111111111111:apis/kzqecnrvvjhqvlhc7ab7g5fgvq" + } + ] + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach_multiple_resources": { + "recorded-date": "03-07-2025, 14:50:59", + "recorded-content": { + "resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyBarParameter", + "PhysicalResourceId": "CFN-MyBarParameter-UGxZerFWAtvy", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyBarParameterA", + "PhysicalResourceId": "CFN-MyBarParameterA-H8ii3J290vpI", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyBarParameterB", + "PhysicalResourceId": "CFN-MyBarParameterB-tec4UnkRx5EU", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyBarParameterC", + "PhysicalResourceId": "CFN-MyBarParameterC-Qct61rWqCu9n", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyFooParameter", + "PhysicalResourceId": "CFN-MyFooParameter-Ncmc7NblH1Zp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyFooParameterA", + "PhysicalResourceId": "CFN-MyFooParameterA-7tfOJrOnxkoj", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyFooParameterB", + "PhysicalResourceId": "CFN-MyFooParameterB-OMiqdOrIQNlX", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyFooParameterC", + "PhysicalResourceId": "CFN-MyFooParameterC-vAy5ofwsYt03", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//1b971f30-581d-11f0-9b2f-024852676f1d", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transformed": { + "AWSTemplateFormatVersion": "2010-09-09", + "Resources": { + "MyBarParameter": { + "Properties": { + "Type": "String", + "Value": "my value Bar" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyBarParameterA": { + "Properties": { + "Type": "String", + "Value": "my value A" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyBarParameterB": { + "Properties": { + "Type": "String", + "Value": "my value B" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyBarParameterC": { + "Properties": { + "Type": "String", + "Value": "my value C" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyFooParameter": { + "Properties": { + "Type": "String", + "Value": "my value Foo" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyFooParameterA": { + "Properties": { + "Type": "String", + "Value": "my value A" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyFooParameterB": { + "Properties": { + "Type": "String", + "Value": "my value B" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyFooParameterC": { + "Properties": { + "Type": "String", + "Value": "my value C" + }, + "Type": "AWS::SSM::Parameter" + } + } + } + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_to_json_string": { + "recorded-date": "04-07-2025, 21:24:07", + "recorded-content": { + "resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyArrayParameter", + "PhysicalResourceId": "CFN-MyArrayParameter-eqEzcW394ZEQ", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//33b24490-591d-11f0-ab22-02a204c1bf21", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyObjectParameter", + "PhysicalResourceId": "CFN-MyObjectParameter-L5EAPcOD2MfJ", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//33b24490-591d-11f0-ab22-02a204c1bf21", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transformed": { + "Outputs": { + "ArrayName": { + "Value": { + "Ref": "MyArrayParameter" + } + }, + "ObjectName": { + "Value": { + "Ref": "MyObjectParameter" + } + } + }, + "Resources": { + "MyArrayParameter": { + "Properties": { + "Type": "String", + "Value": "[\"a\",\"b\",\"c\"]" + }, + "Type": "AWS::SSM::Parameter" + }, + "MyObjectParameter": { + "Properties": { + "Type": "String", + "Value": { + "a": "foo", + "b": "bar" + } + }, + "Type": "AWS::SSM::Parameter" + } + } + }, + "object-value": { + "a": "foo", + "b": "bar" + }, + "array-value": "[\"a\",\"b\",\"c\"]" + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_transformers.validation.json b/tests/aws/services/cloudformation/api/test_transformers.validation.json new file mode 100644 index 0000000000000..3c2aa5bed07df --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_transformers.validation.json @@ -0,0 +1,71 @@ +{ + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach": { + "last_validated_date": "2025-07-02T20:00:41+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 6.78, + "teardown": 0.18, + "total": 6.96 + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach_multiple_resources": { + "last_validated_date": "2025-07-03T14:50:59+00:00", + "durations_in_seconds": { + "setup": 1.32, + "call": 10.31, + "teardown": 0.21, + "total": 11.84 + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_foreach_use_case": { + "last_validated_date": "2025-07-03T15:19:42+00:00", + "durations_in_seconds": { + "setup": 1.21, + "call": 14.58, + "teardown": 0.22, + "total": 16.01 + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_length": { + "last_validated_date": "2025-07-04T13:29:38+00:00", + "durations_in_seconds": { + "setup": 1.79, + "call": 8.02, + "teardown": 0.19, + "total": 10.0 + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::TestLanguageExtensionsTransform::test_transform_to_json_string": { + "last_validated_date": "2025-07-04T21:24:07+00:00", + "durations_in_seconds": { + "setup": 1.2, + "call": 7.44, + "teardown": 0.17, + "total": 8.81 + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::test_duplicate_resources": { + "last_validated_date": "2025-07-15T19:27:45+00:00", + "durations_in_seconds": { + "setup": 1.03, + "call": 13.37, + "teardown": 5.79, + "total": 20.19 + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::test_language_extensions": { + "last_validated_date": "2025-06-27T16:00:28+00:00", + "durations_in_seconds": { + "setup": 1.59, + "call": 18.28, + "teardown": 4.76, + "total": 24.63 + } + }, + "tests/aws/services/cloudformation/api/test_transformers.py::test_transformer_individual_resource_level": { + "last_validated_date": "2024-06-13T06:43:21+00:00" + }, + "tests/aws/services/cloudformation/api/test_transformers.py::test_transformer_property_level": { + "last_validated_date": "2024-06-06T10:38:33+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_update_stack.py b/tests/aws/services/cloudformation/api/test_update_stack.py new file mode 100644 index 0000000000000..fedd7e30516c6 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_update_stack.py @@ -0,0 +1,454 @@ +import json +import os +import textwrap + +import botocore.errorfactory +import botocore.exceptions +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.testutil import upload_file_to_bucket + + +@markers.aws.validated +def test_basic_update(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + response = aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ), + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + snapshot.add_transformer(snapshot.transform.key_value("StackId", "stack-id")) + snapshot.match("update_response", response) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +def test_update_using_template_url(deploy_cfn_template, s3_create_bucket, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + file_url = upload_file_to_bucket( + aws_client.s3, + s3_create_bucket(), + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml"), + )["Url"] + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateURL=file_url, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="Not supported") +def test_update_with_previous_template(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.needs_fixing +@pytest.mark.skip(reason="templates are not partially not valid => re-evaluate") +@pytest.mark.parametrize( + "capability", + [ + {"value": "CAPABILITY_IAM", "template": "iam_policy.yml"}, + {"value": "CAPABILITY_NAMED_IAM", "template": "iam_role_policy.yaml"}, + ], +) +# The AUTO_EXPAND option is used for macros +def test_update_with_capabilities(capability, deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/", capability["template"]) + ) + + parameter_key = "RoleName" if capability["value"] == "CAPABILITY_NAMED_IAM" else "Name" + + with pytest.raises(botocore.errorfactory.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[{"ParameterKey": parameter_key, "ParameterValue": f"{short_uid()}"}], + ) + + snapshot.match("error", ex.value.response) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Capabilities=[capability["value"]], + Parameters=[{"ParameterKey": parameter_key, "ParameterValue": f"{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="Not raising the correct error") +def test_update_with_resource_types(deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + # Test with invalid type + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + ResourceTypes=["AWS::EC2:*"], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + snapshot.match("invalid_type_error", ex.value.response) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + ResourceTypes=["AWS::EC2::*"], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + snapshot.match("resource_not_allowed", ex.value.response) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + ResourceTypes=["AWS::SNS::Topic"], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="Update value not being applied") +def test_set_notification_arn_with_update(deploy_cfn_template, sns_create_topic, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + topic_arn = sns_create_topic()["TopicArn"] + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + NotificationARNs=[topic_arn], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + description = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)["Stacks"][0] + assert topic_arn in description["NotificationARNs"] + + +@markers.aws.validated +@pytest.mark.skip(reason="Update value not being applied") +def test_update_tags(deploy_cfn_template, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + key = f"key-{short_uid()}" + value = f"value-{short_uid()}" + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + Tags=[{"Key": key, "Value": value}], + TemplateBody=template, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + tags = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)["Stacks"][0][ + "Tags" + ] + assert tags[0]["Key"] == key + assert tags[0]["Value"] == value + + +@markers.aws.validated +@pytest.mark.skip(reason="The correct error is not being raised") +def test_no_template_error(deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack(StackName=stack.stack_name) + + snapshot.match("error", ex.value.response) + + +@markers.aws.validated +def test_no_parameters_update(deploy_cfn_template, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack(StackName=stack.stack_name, TemplateBody=template) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +def test_update_with_previous_parameter_value(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_parameter.update.yml" + ) + ), + Parameters=[{"ParameterKey": "TopicName", "UsePreviousValue": True}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="The correct error is not being raised") +def test_update_with_role_without_permissions( + deploy_cfn_template, snapshot, create_role, aws_client +): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + account_arn = aws_client.sts.get_caller_identity()["Arn"] + assume_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"AWS": account_arn}, + "Effect": "Deny", + } + ], + } + + role_arn = create_role(AssumeRolePolicyDocument=json.dumps(assume_policy_doc))["Role"]["Arn"] + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + RoleARN=role_arn, + ) + + snapshot.match("error", ex.value.response) + + +@markers.aws.validated +@pytest.mark.skip(reason="The correct error is not being raised") +def test_update_with_invalid_rollback_configuration_errors( + deploy_cfn_template, snapshot, aws_client +): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + # Test invalid alarm type + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + RollbackConfiguration={"RollbackTriggers": [{"Arn": short_uid(), "Type": "Another"}]}, + ) + snapshot.match("type_error", ex.value.response) + + # Test invalid alarm arn + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + RollbackConfiguration={ + "RollbackTriggers": [ + { + "Arn": "arn:aws:cloudwatch:us-east-1:123456789012:example-name", + "Type": "AWS::CloudWatch::Alarm", + } + ] + }, + ) + + snapshot.match("arn_error", ex.value.response) + + +@markers.aws.validated +@pytest.mark.skip(reason="The update value is not being applied") +def test_update_with_rollback_configuration(deploy_cfn_template, aws_client): + aws_client.cloudwatch.put_metric_alarm( + AlarmName="HighResourceUsage", + ComparisonOperator="GreaterThanThreshold", + EvaluationPeriods=1, + MetricName="CPUUsage", + Namespace="CustomNamespace", + Period=60, + Statistic="Average", + Threshold=70, + TreatMissingData="notBreaching", + ) + + alarms = aws_client.cloudwatch.describe_alarms(AlarmNames=["HighResourceUsage"]) + alarm_arn = alarms["MetricAlarms"][0]["AlarmArn"] + + rollback_configuration = { + "RollbackTriggers": [ + {"Arn": alarm_arn, "Type": "AWS::CloudWatch::Alarm"}, + ], + "MonitoringTimeInMinutes": 123, + } + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[{"ParameterKey": "TopicName", "UsePreviousValue": True}], + RollbackConfiguration=rollback_configuration, + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + config = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)["Stacks"][0][ + "RollbackConfiguration" + ] + assert config == rollback_configuration + + # cleanup + aws_client.cloudwatch.delete_alarms(AlarmNames=["HighResourceUsage"]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(["$..Stacks..ChangeSetId"]) +def test_diff_after_update(deploy_cfn_template, aws_client, snapshot): + template_1 = textwrap.dedent(""" + Resources: + SimpleParam: + Type: AWS::SSM::Parameter + Properties: + Value: before-stack-update + Type: String + """) + template_2 = textwrap.dedent(""" + Resources: + SimpleParam1: + Type: AWS::SSM::Parameter + Properties: + Value: after-stack-update + Type: String + """) + + stack = deploy_cfn_template( + template=template_1, + ) + + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack.stack_name) + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template_2, + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + get_template_response = aws_client.cloudformation.get_template(StackName=stack.stack_name) + snapshot.match("get-template-response", get_template_response) + + with pytest.raises(botocore.exceptions.ClientError) as exc_info: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template_2, + ) + snapshot.match("update-error", exc_info.value.response) + + describe_stack_response = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + assert describe_stack_response["Stacks"][0]["StackStatus"] == "UPDATE_COMPLETE" diff --git a/tests/aws/services/cloudformation/api/test_update_stack.snapshot.json b/tests/aws/services/cloudformation/api/test_update_stack.snapshot.json new file mode 100644 index 0000000000000..bf9201665b6d3 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_update_stack.snapshot.json @@ -0,0 +1,135 @@ +{ + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_resource_types": { + "recorded-date": "19-11-2022, 14:34:18", + "recorded-content": { + "invalid_type_error": { + "Error": { + "Code": "ValidationError", + "Message": "Resource type AWS::SNS::Topic is not allowed by parameter ResourceTypes [AWS::EC2:*]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "resource_not_allowed": { + "Error": { + "Code": "ValidationError", + "Message": "Resource type AWS::SNS::Topic is not allowed by parameter ResourceTypes [AWS::EC2::*]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_basic_update": { + "recorded-date": "21-11-2022, 08:27:37", + "recorded-content": { + "update_response": { + "StackId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_no_template_error": { + "recorded-date": "21-11-2022, 08:57:45", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Either Template URL or Template Body must be specified.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_no_parameters_error_update": { + "recorded-date": "21-11-2022, 09:45:22", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_previous_parameter_value": { + "recorded-date": "21-11-2022, 10:38:33", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_role_without_permissions": { + "recorded-date": "21-11-2022, 14:14:52", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Role arn::iam::111111111111:role/role-fb405076 is invalid or cannot be assumed", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_invalid_rollback_configuration_errors": { + "recorded-date": "21-11-2022, 15:36:32", + "recorded-content": { + "type_error": { + "Error": { + "Code": "ValidationError", + "Message": "Rollback Trigger Type not supported", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "arn_error": { + "Error": { + "Code": "ValidationError", + "Message": "RelativeId of a Rollback Trigger's ARN is incorrect", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_diff_after_update": { + "recorded-date": "09-04-2024, 06:19:23", + "recorded-content": { + "get-template-response": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "\nResources:\n SimpleParam1:\n Type: AWS::SSM::Parameter\n Properties:\n Value: after-stack-update\n Type: String\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-error": { + "Error": { + "Code": "ValidationError", + "Message": "No updates are to be performed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_update_stack.validation.json b/tests/aws/services/cloudformation/api/test_update_stack.validation.json new file mode 100644 index 0000000000000..3821105abaa2a --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_update_stack.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/api/test_update_stack.py::test_basic_update": { + "last_validated_date": "2022-11-21T07:27:37+00:00" + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_diff_after_update": { + "last_validated_date": "2024-04-09T06:19:23+00:00" + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_no_template_error": { + "last_validated_date": "2022-11-21T07:57:45+00:00" + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_invalid_rollback_configuration_errors": { + "last_validated_date": "2022-11-21T14:36:32+00:00" + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_previous_parameter_value": { + "last_validated_date": "2022-11-21T09:38:33+00:00" + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_resource_types": { + "last_validated_date": "2022-11-19T13:34:18+00:00" + }, + "tests/aws/services/cloudformation/api/test_update_stack.py::test_update_with_role_without_permissions": { + "last_validated_date": "2022-11-21T13:14:52+00:00" + } +} diff --git a/tests/aws/services/cloudformation/api/test_validations.py b/tests/aws/services/cloudformation/api/test_validations.py new file mode 100644 index 0000000000000..86485f95bca7f --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_validations.py @@ -0,0 +1,76 @@ +import json + +import pytest + +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skip("Validations are currently disabled") + + +@markers.aws.validated +@pytest.mark.parametrize( + "outputs", + [ + { + "MyOutput": { + "Value": None, + }, + }, + { + "MyOutput": { + "Value": None, + "AnotherValue": None, + }, + }, + { + "MyOutput": {}, + }, + ], + ids=["none-value", "missing-def", "multiple-nones"], +) +def test_invalid_output_structure(deploy_cfn_template, snapshot, aws_client, outputs): + template = { + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + }, + }, + "Outputs": outputs, + } + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template(template=json.dumps(template)) + + snapshot.match("validation-error", e.value.response) + + +@markers.aws.validated +def test_missing_resources_block(deploy_cfn_template, snapshot, aws_client): + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template(template=json.dumps({})) + + snapshot.match("validation-error", e.value.response) + + +@markers.aws.validated +@pytest.mark.parametrize( + "properties", + [ + { + "Properties": {}, + }, + { + "Type": "AWS::SNS::Topic", + "Invalid": 10, + }, + ], + ids=[ + "missing-type", + "invalid-key", + ], +) +def test_resources_blocks(deploy_cfn_template, snapshot, aws_client, properties): + template = {"Resources": {"A": properties}} + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template(template=json.dumps(template)) + + snapshot.match("validation-error", e.value.response) diff --git a/tests/aws/services/cloudformation/api/test_validations.snapshot.json b/tests/aws/services/cloudformation/api/test_validations.snapshot.json new file mode 100644 index 0000000000000..05a072589fb97 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_validations.snapshot.json @@ -0,0 +1,98 @@ +{ + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[none-value]": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "[/Outputs/MyOutput/Value] 'null' values are not allowed in templates", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[missing-def]": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "[/Outputs/MyOutput/Value] 'null' values are not allowed in templates", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[multiple-nones]": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Outputs member must contain a Value object", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_missing_resources_block": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: At least one Resources member must be defined.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_resources_blocks[missing-type]": { + "recorded-date": "31-05-2024, 14:53:32", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: [/Resources/A] Every Resources object must contain a Type member.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_resources_blocks[invalid-key]": { + "recorded-date": "31-05-2024, 14:53:32", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Invalid template resource property 'Invalid'", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/api/test_validations.validation.json b/tests/aws/services/cloudformation/api/test_validations.validation.json new file mode 100644 index 0000000000000..cda401f1d5e47 --- /dev/null +++ b/tests/aws/services/cloudformation/api/test_validations.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[missing-def]": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[multiple-nones]": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_invalid_output_structure[none-value]": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_missing_resources_block": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_resources_blocks[invalid-key]": { + "last_validated_date": "2024-05-31T14:53:32+00:00" + }, + "tests/aws/services/cloudformation/api/test_validations.py::test_resources_blocks[missing-type]": { + "last_validated_date": "2024-05-31T14:53:32+00:00" + } +} diff --git a/tests/aws/services/cloudformation/artifacts/extensions/hooks/localstack-testing-deployablehook.zip b/tests/aws/services/cloudformation/artifacts/extensions/hooks/localstack-testing-deployablehook.zip new file mode 100644 index 0000000000000..01e657ddabd2b Binary files /dev/null and b/tests/aws/services/cloudformation/artifacts/extensions/hooks/localstack-testing-deployablehook.zip differ diff --git a/tests/aws/services/cloudformation/artifacts/extensions/hooks/localstack-testing-testhook.zip b/tests/aws/services/cloudformation/artifacts/extensions/hooks/localstack-testing-testhook.zip new file mode 100644 index 0000000000000..ef337fcab09c7 Binary files /dev/null and b/tests/aws/services/cloudformation/artifacts/extensions/hooks/localstack-testing-testhook.zip differ diff --git a/tests/aws/services/cloudformation/artifacts/extensions/modules/localstack-testing-testmodule-module.zip b/tests/aws/services/cloudformation/artifacts/extensions/modules/localstack-testing-testmodule-module.zip new file mode 100644 index 0000000000000..d4282b53a3a72 Binary files /dev/null and b/tests/aws/services/cloudformation/artifacts/extensions/modules/localstack-testing-testmodule-module.zip differ diff --git a/tests/aws/services/cloudformation/artifacts/extensions/resourcetypes/localstack-testing-deployableresource.zip b/tests/aws/services/cloudformation/artifacts/extensions/resourcetypes/localstack-testing-deployableresource.zip new file mode 100644 index 0000000000000..8ed123e00b3d1 Binary files /dev/null and b/tests/aws/services/cloudformation/artifacts/extensions/resourcetypes/localstack-testing-deployableresource.zip differ diff --git a/tests/aws/services/cloudformation/artifacts/extensions/resourcetypes/localstack-testing-testresource.zip b/tests/aws/services/cloudformation/artifacts/extensions/resourcetypes/localstack-testing-testresource.zip new file mode 100644 index 0000000000000..d710038ab1199 Binary files /dev/null and b/tests/aws/services/cloudformation/artifacts/extensions/resourcetypes/localstack-testing-testresource.zip differ diff --git a/tests/aws/services/cloudformation/engine/__init__.py b/tests/aws/services/cloudformation/engine/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/engine/test_attributes.py b/tests/aws/services/cloudformation/engine/test_attributes.py new file mode 100644 index 0000000000000..24898e41b33c8 --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_attributes.py @@ -0,0 +1,42 @@ +import os + +import pytest + +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError + + +class TestResourceAttributes: + @pytest.mark.skip(reason="failing on unresolved attributes is not enabled yet") + @markers.snapshot.skip_snapshot_verify + @markers.aws.validated + def test_invalid_getatt_fails(self, aws_client, deploy_cfn_template, snapshot): + """ + Check how CloudFormation behaves on invalid attribute names for resources in a Fn::GetAtt + + Not yet completely correct yet since this should actually initiate a rollback and the stack resource status should be set accordingly + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/engine/cfn_invalid_getatt.yaml" + ) + ) + stack_events = exc_info.value.events + snapshot.match("stack_events", {"events": stack_events}) + + @markers.aws.validated + def test_dependency_on_attribute_with_dot_notation( + self, deploy_cfn_template, aws_client, snapshot + ): + """ + Test that a resource can depend on another resource's attribute with dot notation + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/engine/cfn_getatt_dot_dependency.yml" + ) + ) + snapshot.match("outputs", deployment.outputs) diff --git a/tests/aws/services/cloudformation/engine/test_attributes.snapshot.json b/tests/aws/services/cloudformation/engine/test_attributes.snapshot.json new file mode 100644 index 0000000000000..effd19cf9034f --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_attributes.snapshot.json @@ -0,0 +1,62 @@ +{ + "tests/aws/services/cloudformation/engine/test_attributes.py::TestResourceAttributes::test_invalid_getatt_fails": { + "recorded-date": "01-08-2023, 11:54:31", + "recorded-content": { + "stack_events": { + "events": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "[Error] /Outputs/InvalidOutput/Value/Fn::GetAtt: Resource type AWS::SSM::Parameter does not support attribute {Invalid}. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/engine/test_attributes.py::TestResourceAttributes::test_dependency_on_attribute_with_dot_notation": { + "recorded-date": "21-03-2024, 21:10:29", + "recorded-content": { + "outputs": { + "DeadArn": "arn::sqs::111111111111:" + } + } + } +} diff --git a/tests/aws/services/cloudformation/engine/test_attributes.validation.json b/tests/aws/services/cloudformation/engine/test_attributes.validation.json new file mode 100644 index 0000000000000..c55d60a6c917a --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_attributes.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/engine/test_attributes.py::TestResourceAttributes::test_dependency_on_attribute_with_dot_notation": { + "last_validated_date": "2024-03-21T21:10:29+00:00" + }, + "tests/aws/services/cloudformation/engine/test_attributes.py::TestResourceAttributes::test_invalid_getatt_fails": { + "last_validated_date": "2023-08-01T09:54:31+00:00" + } +} diff --git a/tests/aws/services/cloudformation/engine/test_conditions.py b/tests/aws/services/cloudformation/engine/test_conditions.py new file mode 100644 index 0000000000000..3bd8990172946 --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_conditions.py @@ -0,0 +1,483 @@ +import os.path + +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +THIS_DIR = os.path.dirname(__file__) + + +class TestCloudFormationConditions: + @markers.aws.validated + def test_simple_condition_evaluation_deploys_resource( + self, aws_client, deploy_cfn_template, cleanups + ): + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/conditions/simple-condition.yaml" + ), + parameters={"OptionParameter": "option-a", "TopicName": topic_name}, + ) + # verify that CloudFormation includes the resource + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + assert stack_resources["StackResources"] + + # verify actual resource deployment + assert [ + t + for t in aws_client.sns.get_paginator("list_topics") + .paginate() + .build_full_result()["Topics"] + if topic_name in t["TopicArn"] + ] + + @markers.aws.validated + def test_simple_condition_evaluation_doesnt_deploy_resource( + self, aws_client, deploy_cfn_template, cleanups + ): + """Note: Conditions allow us to deploy stacks that won't actually contain any deployed resources""" + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/conditions/simple-condition.yaml" + ), + parameters={"OptionParameter": "option-b", "TopicName": topic_name}, + ) + # verify that CloudFormation ignores the resource + aws_client.cloudformation.describe_stack_resources(StackName=deployment.stack_id) + + # FIXME: currently broken in localstack + # assert stack_resources['StackResources'] == [] + + # verify actual resource deployment + assert [ + t for t in aws_client.sns.list_topics()["Topics"] if topic_name in t["TopicArn"] + ] == [] + + @pytest.mark.parametrize( + "should_set_custom_name", + ["yep", "nope"], + ) + @markers.aws.validated + def test_simple_intrinsic_fn_condition_evaluation( + self, aws_client, deploy_cfn_template, should_set_custom_name + ): + """ + Tests a simple Fn::If condition evaluation + + The conditional ShouldSetCustomName (yep | nope) switches between an autogenerated and a predefined name for the topic + + FIXME: this should also work with the simple-intrinsic-condition-name-conflict.yaml template where the ID of the condition and the ID of the parameter are the same(!). + It is currently broken in LocalStack though + """ + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/conditions/simple-intrinsic-condition.yaml" + ), + parameters={ + "TopicName": topic_name, + "ShouldSetCustomName": should_set_custom_name, + }, + ) + # verify that the topic has the correct name + topic_arn = deployment.outputs["TopicArn"] + if should_set_custom_name == "yep": + assert topic_name in topic_arn + else: + assert topic_name not in topic_arn + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + def test_dependent_ref(self, aws_client, snapshot): + """ + Tests behavior of a stack with 2 resources where one depends on the other. + The referenced resource won't be deployed due to its condition evaluating to false, so the ref can't be resolved. + + This immediately leads to an error. + """ + topic_name = f"test-topic-{short_uid()}" + ssm_param_name = f"test-param-{short_uid()}" + + stack_name = f"test-condition-ref-stack-{short_uid()}" + changeset_name = "initial" + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + ChangeSetType="CREATE", + TemplateBody=load_file( + os.path.join(THIS_DIR, "../../../templates/conditions/ref-condition.yaml") + ), + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "SsmParamName", "ParameterValue": ssm_param_name}, + {"ParameterKey": "OptionParameter", "ParameterValue": "option-b"}, + ], + ) + snapshot.match("dependent_ref_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + def test_dependent_ref_intrinsic_fn_condition(self, aws_client, deploy_cfn_template): + """ + Checks behavior of un-refable resources + """ + topic_name = f"test-topic-{short_uid()}" + ssm_param_name = f"test-param-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/conditions/ref-condition-intrinsic-condition.yaml" + ), + parameters={ + "TopicName": topic_name, + "SsmParamName": ssm_param_name, + "OptionParameter": "option-b", + }, + ) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + def test_dependent_ref_with_macro( + self, aws_client, deploy_cfn_template, lambda_su_role, cleanups + ): + """ + specifying option-b would normally lead to an error without the macro because of the unresolved ref. + Because the macro replaced the resources though, the test passes. + We've therefore shown that conditions aren't fully evaluated before the transformations + + Related findings: + * macros are not allowed to transform Parameters (macro invocation by CFn will fail in this case) + + """ + + log_group_name = f"test-log-group-{short_uid()}" + aws_client.logs.create_log_group(logGroupName=log_group_name) + + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/conditions/ref-condition-macro-def.yaml" + ), + parameters={ + "FnRole": lambda_su_role, + "LogGroupName": log_group_name, + "LogRoleARN": lambda_su_role, + }, + ) + + topic_name = f"test-topic-{short_uid()}" + ssm_param_name = f"test-param-{short_uid()}" + stack_name = f"test-condition-ref-macro-stack-{short_uid()}" + changeset_name = "initial" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + ChangeSetType="CREATE", + TemplateBody=load_file( + os.path.join(THIS_DIR, "../../../templates/conditions/ref-condition-macro.yaml") + ), + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "SsmParamName", "ParameterValue": ssm_param_name}, + {"ParameterKey": "OptionParameter", "ParameterValue": "option-b"}, + ], + ) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=changeset_name, StackName=stack_name + ) + + @pytest.mark.parametrize( + ["env_type", "should_create_bucket", "should_create_policy"], + [ + ("test", False, False), + ("test", True, False), + ("prod", False, False), + ("prod", True, True), + ], + ids=[ + "test-nobucket-nopolicy", + "test-bucket-nopolicy", + "prod-nobucket-nopolicy", + "prod-bucket-policy", + ], + ) + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + @markers.aws.validated + def test_nested_conditions( + self, + aws_client, + deploy_cfn_template, + cleanups, + env_type, + should_create_bucket, + should_create_policy, + snapshot, + ): + """ + Tests the case where a condition references another condition + + EnvType == "prod" && BucketName != "" ==> creates bucket + policy + EnvType == "test" && BucketName != "" ==> creates bucket only + EnvType == "test" && BucketName == "" ==> no resource created + EnvType == "prod" && BucketName == "" ==> no resource created + """ + bucket_name = f"ls-test-bucket-{short_uid()}" if should_create_bucket else "" + stack_name = f"condition-test-stack-{short_uid()}" + changeset_name = "initial" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + if bucket_name: + snapshot.add_transformer(snapshot.transform.regex(bucket_name, "")) + snapshot.add_transformer(snapshot.transform.regex(stack_name, "")) + + template = load_file( + os.path.join(THIS_DIR, "../../../templates/conditions/nested-conditions.yaml") + ) + create_cs_result = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + TemplateBody=template, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "EnvType", "ParameterValue": env_type}, + {"ParameterKey": "BucketName", "ParameterValue": bucket_name}, + ], + ) + snapshot.match("create_cs_result", create_cs_result) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=changeset_name, StackName=stack_name + ) + + describe_changeset_result = aws_client.cloudformation.describe_change_set( + ChangeSetName=changeset_name, StackName=stack_name + ) + snapshot.match("describe_changeset_result", describe_changeset_result) + aws_client.cloudformation.execute_change_set( + ChangeSetName=changeset_name, StackName=stack_name + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + stack_resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name) + if should_create_policy: + stack_policy = [ + sr + for sr in stack_resources["StackResources"] + if sr["ResourceType"] == "AWS::S3::BucketPolicy" + ][0] + snapshot.add_transformer( + snapshot.transform.regex(stack_policy["PhysicalResourceId"], ""), + priority=-1, + ) + + snapshot.match("stack_resources", stack_resources) + stack_events = aws_client.cloudformation.describe_stack_events(StackName=stack_name) + snapshot.match("stack_events", stack_events) + describe_stack_result = aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("describe_stack_result", describe_stack_result) + + # manual assertions + + # check that bucket exists + try: + aws_client.s3.head_bucket(Bucket=bucket_name) + bucket_exists = True + except Exception: + bucket_exists = False + + assert bucket_exists == should_create_bucket + + if bucket_exists: + # check if a policy exists on the bucket + try: + aws_client.s3.get_bucket_policy(Bucket=bucket_name) + bucket_policy_exists = True + except Exception: + bucket_policy_exists = False + + assert bucket_policy_exists == should_create_policy + + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + @markers.aws.validated + def test_output_reference_to_skipped_resource(self, deploy_cfn_template, aws_client, snapshot): + """test what happens to outputs that reference a resource that isn't deployed due to a falsy condition""" + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/conditions/ref-condition-output.yaml" + ), + parameters={ + "OptionParameter": "option-b", + }, + ) + snapshot.match("unresolved_resource_reference_exception", e.value.response) + + @pytest.mark.aws_validated + @pytest.mark.parametrize("create_parameter", ("true", "false"), ids=("create", "no-create")) + def test_conditional_att_to_conditional_resources(self, deploy_cfn_template, create_parameter): + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_if_attribute_none.yml" + ) + + deployed = deploy_cfn_template( + template_path=template_path, + parameters={"CreateParameter": create_parameter}, + ) + + if create_parameter == "false": + assert deployed.outputs["Result"] == "Value1" + else: + assert deployed.outputs["Result"] == "Value2" + + # def test_updating_only_conditions_during_stack_update(self): + # ... + + # def test_condition_with_unsupported_intrinsic_functions(self): + # ... + + @pytest.mark.parametrize( + ["should_use_fallback", "match_value"], + [ + (None, "FallbackParamValue"), + ("true", "FallbackParamValue"), + ("false", "DefaultParamValue"), + ], + ) + @markers.aws.validated + def test_dependency_in_non_evaluated_if_branch( + self, deploy_cfn_template, aws_client, should_use_fallback, match_value + ): + parameters = ( + {"ShouldUseFallbackParameter": should_use_fallback} if should_use_fallback else {} + ) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/engine/cfn_if_conditional_reference.yaml", + ), + parameters=parameters, + ) + param = aws_client.ssm.get_parameter(Name=stack.outputs["ParameterName"]) + assert param["Parameter"]["Value"] == match_value + + @markers.aws.validated + def test_sub_in_conditions(self, deploy_cfn_template, aws_client): + region = aws_client.cloudformation.meta.region_name + topic_prefix = f"test-topic-{short_uid()}" + suffix = short_uid() + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/conditions/intrinsic-functions-in-conditions.yaml", + ), + parameters={ + "TopicName": f"{topic_prefix}-{region}", + "TopicPrefix": topic_prefix, + "TopicNameWithSuffix": f"{topic_prefix}-{region}-{suffix}", + "TopicNameSuffix": suffix, + }, + ) + + topic_arn = stack.outputs["TopicRef"] + aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + assert topic_arn.split(":")[-1] == f"{topic_prefix}-{region}" + + topic_arn_with_suffix = stack.outputs["TopicWithSuffixRef"] + aws_client.sns.get_topic_attributes(TopicArn=topic_arn_with_suffix) + assert topic_arn_with_suffix.split(":")[-1] == f"{topic_prefix}-{region}-{suffix}" + + @markers.aws.validated + @pytest.mark.parametrize("env,region", [("dev", "us-west-2"), ("production", "us-east-1")]) + def test_conditional_in_conditional(self, env, region, deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/conditions/conditional-in-conditional.yml", + ), + parameters={ + "SelectedRegion": region, + "Environment": env, + }, + ) + + if env == "production" and region == "us-east-1": + assert stack.outputs["Result"] == "true" + else: + assert stack.outputs["Result"] == "false" + + @markers.aws.validated + def test_conditional_with_select(self, deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/conditions/conditional-with-select.yml", + ), + ) + + managed_policy_arn = stack.outputs["PolicyArn"] + assert aws_client.iam.get_policy(PolicyArn=managed_policy_arn) + + @markers.aws.validated + def test_condition_on_outputs(self, deploy_cfn_template, aws_client): + """ + The stack has 2 outputs. + Each is gated by a different condition value ("test" vs. "prod"). + Only one of them should be returned for the stack outputs + """ + nested_bucket_name = f"test-bucket-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/nested-stack-conditions.nested.yaml" + ), + parameters={ + "BucketBaseName": nested_bucket_name, + "Mode": "prod", + }, + ) + assert "TestBucket" not in stack.outputs + assert stack.outputs["ProdBucket"] == f"{nested_bucket_name}-prod" + assert aws_client.s3.head_bucket(Bucket=stack.outputs["ProdBucket"]) + + @markers.aws.validated + def test_update_conditions(self, deploy_cfn_template, aws_client): + original_bucket_name = f"test-bucket-{short_uid()}" + stack_name = f"test-update-conditions-{short_uid()}" + deploy_cfn_template( + stack_name=stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_condition_update_1.yml" + ), + parameters={"OriginalBucketName": original_bucket_name}, + ) + assert aws_client.s3.head_bucket(Bucket=original_bucket_name) + + bucket_1 = f"test-bucket-1-{short_uid()}" + bucket_2 = f"test-bucket-2-{short_uid()}" + + deploy_cfn_template( + stack_name=stack_name, + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_condition_update_2.yml" + ), + parameters={ + "OriginalBucketName": original_bucket_name, + "FirstBucket": bucket_1, + "SecondBucket": bucket_2, + }, + ) + + assert aws_client.s3.head_bucket(Bucket=original_bucket_name) + assert aws_client.s3.head_bucket(Bucket=bucket_1) + with pytest.raises(aws_client.s3.exceptions.ClientError): + aws_client.s3.head_bucket(Bucket=bucket_2) diff --git a/tests/aws/services/cloudformation/engine/test_conditions.snapshot.json b/tests/aws/services/cloudformation/engine/test_conditions.snapshot.json new file mode 100644 index 0000000000000..4d8bc281ae75c --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_conditions.snapshot.json @@ -0,0 +1,763 @@ +{ + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-nobucket-nopolicy]": { + "recorded-date": "26-06-2023, 14:20:49", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-bucket-nopolicy]": { + "recorded-date": "26-06-2023, 14:21:54", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Bucket", + "ResourceType": "AWS::S3::Bucket", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_COMPLETE-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-nobucket-nopolicy]": { + "recorded-date": "26-06-2023, 14:22:58", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-bucket-policy]": { + "recorded-date": "26-06-2023, 14:24:03", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Bucket", + "ResourceType": "AWS::S3::Bucket", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Policy", + "ResourceType": "AWS::S3::BucketPolicy", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Policy-CREATE_COMPLETE-date", + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceProperties": { + "Bucket": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Resource": [ + "arn::s3:::/*" + ], + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + } + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Policy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceProperties": { + "Bucket": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Resource": [ + "arn::s3:::/*" + ], + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Policy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceProperties": { + "Bucket": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Resource": [ + "arn::s3:::/*" + ], + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_COMPLETE-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref": { + "recorded-date": "26-06-2023, 14:18:26", + "recorded-content": { + "dependent_ref_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Unresolved resource dependencies [MyTopic] in the Resources block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_output_reference_to_skipped_resource": { + "recorded-date": "27-06-2023, 00:43:18", + "recorded-content": { + "unresolved_resource_reference_exception": { + "Error": { + "Code": "ValidationError", + "Message": "Unresolved resource dependencies [MyTopic] in the Outputs block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/engine/test_conditions.validation.json b/tests/aws/services/cloudformation/engine/test_conditions.validation.json new file mode 100644 index 0000000000000..a2579fb8517c8 --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_conditions.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref": { + "last_validated_date": "2023-06-26T12:18:26+00:00" + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-bucket-policy]": { + "last_validated_date": "2023-06-26T12:24:03+00:00" + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-nobucket-nopolicy]": { + "last_validated_date": "2023-06-26T12:22:58+00:00" + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-bucket-nopolicy]": { + "last_validated_date": "2023-06-26T12:21:54+00:00" + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-nobucket-nopolicy]": { + "last_validated_date": "2023-06-26T12:20:49+00:00" + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_output_reference_to_skipped_resource": { + "last_validated_date": "2023-06-26T22:43:18+00:00" + }, + "tests/aws/services/cloudformation/engine/test_conditions.py::TestCloudFormationConditions::test_update_conditions": { + "last_validated_date": "2024-06-18T19:43:43+00:00" + } +} diff --git a/tests/aws/services/cloudformation/engine/test_mappings.py b/tests/aws/services/cloudformation/engine/test_mappings.py new file mode 100644 index 0000000000000..cb854d39c38d9 --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_mappings.py @@ -0,0 +1,249 @@ +import os + +import pytest + +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +THIS_DIR = os.path.dirname(__file__) + + +@markers.snapshot.skip_snapshot_verify +class TestCloudFormationMappings: + @markers.aws.validated + def test_simple_mapping_working(self, aws_client, deploy_cfn_template): + """ + A very simple test to deploy a resource with a name depending on a value that needs to be looked up from the mapping + """ + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join(THIS_DIR, "../../../templates/mappings/simple-mapping.yaml"), + parameters={ + "TopicName": topic_name, + "TopicNameSuffixSelector": "A", + }, + ) + # verify that CloudFormation includes the resource + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + assert stack_resources["StackResources"] + + expected_topic_name = f"{topic_name}-suffix-a" + + # verify actual resource deployment + assert [ + t + for t in aws_client.sns.get_paginator("list_topics") + .paginate() + .build_full_result()["Topics"] + if expected_topic_name in t["TopicArn"] + ] + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_with_nonexisting_key(self, aws_client, cleanups, snapshot): + """ + Tries to deploy a resource with a dependency on a mapping key + which is not included in the Mappings section and thus can't be resolved + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join(THIS_DIR, "../../../templates/mappings/simple-mapping.yaml") + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "C"}, + ], + ) + snapshot.match("mapping_nonexisting_key_exc", e.value.response) + + @markers.aws.only_localstack + def test_async_mapping_error_first_level(self, deploy_cfn_template): + """ + We don't (yet) support validating mappings synchronously in `create_changeset` like AWS does, however + we don't fail with a good error message at all. This test ensures that the deployment fails with a + nicer error message than a Python traceback about "`None` has no attribute `get`". + """ + topic_name = f"test-topic-{short_uid()}" + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, + "../../../templates/mappings/simple-mapping.yaml", + ), + parameters={ + "TopicName": topic_name, + "TopicNameSuffixSelector": "C", + }, + ) + + assert "Cannot find map key 'C' in mapping 'TopicSuffixMap'" in str(exc_info.value) + + @markers.aws.only_localstack + def test_async_mapping_error_second_level(self, deploy_cfn_template): + """ + Similar to the `test_async_mapping_error_first_level` test above, but + checking the second level of mapping lookup + """ + topic_name = f"test-topic-{short_uid()}" + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, + "../../../templates/mappings/simple-mapping.yaml", + ), + parameters={ + "TopicName": topic_name, + "TopicNameSuffixSelector": "A", + "TopicAttributeSelector": "NotValid", + }, + ) + + assert "Cannot find map key 'NotValid' in mapping 'TopicSuffixMap' under key 'A'" in str( + exc_info.value + ) + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_with_invalid_refs(self, aws_client, deploy_cfn_template, cleanups, snapshot): + """ + The Mappings section can only include static elements (strings and lists). + In this test one value is instead a `Ref` which should be rejected by the service + + Also note the overlap with the `test_mapping_with_nonexisting_key` case here. + Even though we specify a non-existing key here again (`C`), the returned error is for the invalid structure. + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join(THIS_DIR, "../../../templates/mappings/simple-mapping-invalid-ref.yaml") + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "C"}, + {"ParameterKey": "TopicNameSuffix", "ParameterValue": "suffix-c"}, + ], + ) + snapshot.match("mapping_invalid_ref_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_maximum_nesting_depth(self, aws_client, cleanups, snapshot): + """ + Tries to deploy a template containing a mapping with a nesting depth of 3. + The maximum depth is 2 so it should fail + + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join(THIS_DIR, "../../../templates/mappings/simple-mapping-nesting-depth.yaml") + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "A"}, + ], + ) + snapshot.match("mapping_maximum_level_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_minimum_nesting_depth(self, aws_client, cleanups, snapshot): + """ + Tries to deploy a template containing a mapping with a nesting depth of 1. + The required depth is 2, so it should fail for a single level + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join(THIS_DIR, "../../../templates/mappings/simple-mapping-single-level.yaml") + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "A"}, + ], + ) + snapshot.match("mapping_minimum_level_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "map_key,should_error", + [ + ("A", False), + ("B", True), + ], + ids=["should-deploy", "should-not-deploy"], + ) + def test_mapping_ref_map_key(self, deploy_cfn_template, aws_client, map_key, should_error): + topic_name = f"topic-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/mappings/mapping-ref-map-key.yaml" + ), + parameters={ + "MapName": "MyMap", + "MapKey": map_key, + "TopicName": topic_name, + }, + ) + + topic_arn = stack.outputs.get("TopicArn") + if should_error: + assert topic_arn is None + else: + assert topic_arn is not None + + aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + + @markers.aws.validated + def test_aws_refs_in_mappings(self, deploy_cfn_template, account_id): + """ + This test asserts that Pseudo references aka "AWS::" are supported inside a mapping inside a Conditional. + It's worth remembering that even with references being supported, AWS rejects names that are not alphanumeric + in Mapping name or the second level key. + """ + stack_name = f"Stack{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../templates/mappings/mapping-aws-ref-map-key.yaml" + ), + stack_name=stack_name, + template_mapping={"StackName": stack_name}, + ) + assert stack.outputs.get("TopicArn") diff --git a/tests/aws/services/cloudformation/engine/test_mappings.snapshot.json b/tests/aws/services/cloudformation/engine/test_mappings.snapshot.json new file mode 100644 index 0000000000000..c0287ad3e85fe --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_mappings.snapshot.json @@ -0,0 +1,66 @@ +{ + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_nonexisting_key": { + "recorded-date": "12-06-2023, 16:47:23", + "recorded-content": { + "mapping_nonexisting_key_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template error: Unable to get mapping for TopicSuffixMap::C::Suffix", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_invalid_refs": { + "recorded-date": "12-06-2023, 16:47:24", + "recorded-content": { + "mapping_invalid_ref_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Mappings attribute must be a String or a List.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_maximum_nesting_depth": { + "recorded-date": "12-06-2023, 16:47:24", + "recorded-content": { + "mapping_maximum_level_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Mappings attribute must be a String or a List.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_minimum_nesting_depth": { + "recorded-date": "12-06-2023, 16:47:25", + "recorded-content": { + "mapping_minimum_level_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Mappings member A must be a map", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/engine/test_mappings.validation.json b/tests/aws/services/cloudformation/engine/test_mappings.validation.json new file mode 100644 index 0000000000000..d59232a7b10f5 --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_mappings.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_aws_refs_in_mappings": { + "last_validated_date": "2024-10-15T17:22:43+00:00" + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_maximum_nesting_depth": { + "last_validated_date": "2023-06-12T14:47:24+00:00" + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_minimum_nesting_depth": { + "last_validated_date": "2023-06-12T14:47:25+00:00" + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-deploy]": { + "last_validated_date": "2024-10-17T22:40:44+00:00" + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-not-deploy]": { + "last_validated_date": "2024-10-17T22:41:45+00:00" + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_invalid_refs": { + "last_validated_date": "2023-06-12T14:47:24+00:00" + }, + "tests/aws/services/cloudformation/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_nonexisting_key": { + "last_validated_date": "2023-06-12T14:47:23+00:00" + } +} diff --git a/tests/aws/services/cloudformation/engine/test_references.py b/tests/aws/services/cloudformation/engine/test_references.py new file mode 100644 index 0000000000000..ced32e1e92a27 --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_references.py @@ -0,0 +1,124 @@ +import json +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + + +class TestDependsOn: + @pytest.mark.skip(reason="not supported yet") + @markers.aws.validated + def test_depends_on_with_missing_reference( + self, deploy_cfn_template, aws_client, cleanups, snapshot + ): + stack_name = f"test-stack-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), + "../../../templates/engine/cfn_dependson_nonexisting_resource.yaml", + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="init", + ChangeSetType="CREATE", + TemplateBody=load_file(template_path), + ) + snapshot.match("depends_on_nonexisting_exception", e.value.response) + + +class TestFnSub: + # TODO: add test for list sub without a second argument (i.e. the list) + # => Template error: One or more Fn::Sub intrinsic functions don't specify expected arguments. Specify a string as first argument, and an optional second argument to specify a mapping of values to replace in the string + + @markers.aws.validated + def test_fn_sub_cases(self, deploy_cfn_template, aws_client, snapshot): + ssm_parameter_name = f"test-param-{short_uid()}" + snapshot.add_transformer( + snapshot.transform.regex(ssm_parameter_name, "") + ) + snapshot.add_transformer( + snapshot.transform.key_value( + "UrlSuffixPseudoParam", "", reference_replacement=False + ) + ) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/engine/cfn_fn_sub.yaml" + ), + parameters={"ParameterName": ssm_parameter_name}, + ) + + snapshot.match("outputs", deployment.outputs) + + @markers.aws.validated + def test_non_string_parameter_in_sub(self, deploy_cfn_template, aws_client, snapshot): + ssm_parameter_name = f"test-param-{short_uid()}" + snapshot.add_transformer( + snapshot.transform.regex(ssm_parameter_name, "") + ) + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_number_in_sub.yml" + ), + parameters={"ParameterName": ssm_parameter_name}, + ) + + get_param_res = aws_client.ssm.get_parameter(Name=ssm_parameter_name)["Parameter"] + snapshot.match("get-parameter-result", get_param_res) + + +@markers.aws.validated +def test_useful_error_when_invalid_ref(deploy_cfn_template, snapshot): + """ + When trying to resolve a non-existent !Ref, make sure the error message includes the name of the !Ref + to clarify which !Ref cannot be resolved. + """ + logical_resource_id = "Topic" + ref_name = "InvalidRef" + + template = json.dumps( + { + "Resources": { + logical_resource_id: { + "Type": "AWS::SNS::Topic", + "Properties": { + "Name": { + "Ref": ref_name, + }, + }, + } + } + } + ) + + with pytest.raises(ClientError) as exc_info: + deploy_cfn_template(template=template) + + snapshot.match("validation_error", exc_info.value.response) + + +@markers.aws.validated +def test_resolve_transitive_placeholders_in_strings(deploy_cfn_template, aws_client, snapshot): + queue_name = f"q-{short_uid()}" + parameter_ver = f"v{short_uid()}" + stack_name = f"stack-{short_uid()}" + stack = deploy_cfn_template( + stack_name=stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/legacy_transitive_ref.yaml" + ), + max_wait=300 if is_aws_cloud() else 10, + parameters={"QueueName": queue_name, "Qualifier": parameter_ver}, + ) + tags = aws_client.sqs.list_queue_tags(QueueUrl=stack.outputs["QueueURL"]) + snapshot.add_transformer( + snapshot.transform.regex(r"/cdk-bootstrap/(\w+)/", "/cdk-bootstrap/.../") + ) + snapshot.match("tags", tags) diff --git a/tests/aws/services/cloudformation/engine/test_references.snapshot.json b/tests/aws/services/cloudformation/engine/test_references.snapshot.json new file mode 100644 index 0000000000000..9e6600aaaa00f --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_references.snapshot.json @@ -0,0 +1,84 @@ +{ + "tests/aws/services/cloudformation/engine/test_references.py::TestDependsOn::test_depends_on_with_missing_reference": { + "recorded-date": "10-07-2023, 15:22:26", + "recorded-content": { + "depends_on_nonexisting_exception": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Unresolved resource dependencies [NonExistingResource] in the Resources block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_references.py::TestFnSub::test_fn_sub_cases": { + "recorded-date": "23-08-2023, 20:41:02", + "recorded-content": { + "outputs": { + "ListRefGetAtt": "unimportant", + "ListRefGetAttMapping": "unimportant", + "ListRefMultipleMix": "Param1Value--Param1Value", + "ListRefParam": "Param1Value", + "ListRefPseudoParam": "", + "ListRefResourceDirect": "Param1Value", + "ListRefResourceMappingRef": "Param1Value", + "ListStatic": "this is a static string", + "StringRefGetAtt": "unimportant", + "StringRefMultiple": "Param1Value - Param1Value", + "StringRefParam": "Param1Value", + "StringRefPseudoParam": "", + "StringRefResource": "Param1Value", + "StringStatic": "this is a static string", + "UrlSuffixPseudoParam": "" + } + } + }, + "tests/aws/services/cloudformation/engine/test_references.py::test_useful_error_when_invalid_ref": { + "recorded-date": "28-05-2024, 11:42:58", + "recorded-content": { + "validation_error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Unresolved resource dependencies [InvalidRef] in the Resources block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_references.py::test_resolve_transitive_placeholders_in_strings": { + "recorded-date": "18-06-2024, 19:55:48", + "recorded-content": { + "tags": { + "Tags": { + "test": "arn::ssm::111111111111:parameter/cdk-bootstrap/.../version" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/engine/test_references.py::TestFnSub::test_non_string_parameter_in_sub": { + "recorded-date": "17-10-2024, 22:49:56", + "recorded-content": { + "get-parameter-result": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "my number is 3", + "Version": 1 + } + } + } +} diff --git a/tests/aws/services/cloudformation/engine/test_references.validation.json b/tests/aws/services/cloudformation/engine/test_references.validation.json new file mode 100644 index 0000000000000..40ae38a56d1f9 --- /dev/null +++ b/tests/aws/services/cloudformation/engine/test_references.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/engine/test_references.py::TestDependsOn::test_depends_on_with_missing_reference": { + "last_validated_date": "2023-07-10T13:22:26+00:00" + }, + "tests/aws/services/cloudformation/engine/test_references.py::TestFnSub::test_fn_sub_cases": { + "last_validated_date": "2023-08-23T18:41:02+00:00" + }, + "tests/aws/services/cloudformation/engine/test_references.py::TestFnSub::test_non_string_parameter_in_sub": { + "last_validated_date": "2024-10-17T22:49:56+00:00" + }, + "tests/aws/services/cloudformation/engine/test_references.py::test_resolve_transitive_placeholders_in_strings": { + "last_validated_date": "2024-06-18T19:55:48+00:00" + }, + "tests/aws/services/cloudformation/engine/test_references.py::test_useful_error_when_invalid_ref": { + "last_validated_date": "2024-05-28T11:42:58+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/__init__.py b/tests/aws/services/cloudformation/resource_providers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/__init__.py b/tests/aws/services/cloudformation/resource_providers/ec2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/__init__.py b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/templates/basic.yml b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/templates/basic.yml new file mode 100644 index 0000000000000..4e5d4f32df505 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/templates/basic.yml @@ -0,0 +1,15 @@ +Resources: + VPC: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + NetworkAcl: + Type: AWS::EC2::NetworkAcl + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: MyNetworkAcl +Outputs: + NetworkAclId: + Value: !Ref NetworkAcl \ No newline at end of file diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.py b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.py new file mode 100644 index 0000000000000..e3b5d173887e5 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.py @@ -0,0 +1,49 @@ +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers + + +class TestBasicCRD: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..NetworkAcls..Entries", + "$..NetworkAcls..Tags", + "$..NetworkAcls..Tags..Key", + "$..NetworkAcls..Tags..Value", + "$..NetworkAcls..VpcId", + ] + ) + def test_black_box(self, deploy_cfn_template, aws_client, snapshot): + """ + Simple test that + - deploys a stack containing the resource + - verifies that the resource has been created correctly by querying the service directly + - deletes the stack ensuring that the delete operation has been implemented correctly + - verifies that the resource no longer exists by querying the service directly + """ + snapshot.add_transformer(snapshot.transform.key_value("NetworkAclId")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/basic.yml", + ), + ) + snapshot.match("stack-outputs", stack.outputs) + + network_acl_id = stack.outputs["NetworkAclId"] + snapshot.match( + "describe-resource", + aws_client.ec2.describe_network_acls(NetworkAclIds=[network_acl_id]), + ) + + # verify that the delete operation works + stack.destroy() + + # fetch the resource again and assert that it no longer exists + with pytest.raises(ClientError): + aws_client.ec2.describe_network_acls(NetworkAclIds=[network_acl_id]) diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.snapshot.json b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.snapshot.json new file mode 100644 index 0000000000000..e6c3956dc7631 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.snapshot.json @@ -0,0 +1,59 @@ +{ + "tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.py::TestBasicCRD::test_black_box": { + "recorded-date": "31-10-2023, 13:22:34", + "recorded-content": { + "stack-outputs": { + "NetworkAclId": "" + }, + "describe-resource": { + "NetworkAcls": [ + { + "Associations": [], + "Entries": [ + { + "CidrBlock": "0.0.0.0/0", + "Egress": true, + "Protocol": "-1", + "RuleAction": "deny", + "RuleNumber": 32767 + }, + { + "CidrBlock": "0.0.0.0/0", + "Egress": false, + "Protocol": "-1", + "RuleAction": "deny", + "RuleNumber": 32767 + } + ], + "IsDefault": false, + "NetworkAclId": "", + "OwnerId": "111111111111", + "Tags": [ + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-a3136b58" + }, + { + "Key": "Name", + "Value": "MyNetworkAcl" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-a3136b58/5678bc60-781a-11ee-a5d9-12b01e58fcd9" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "NetworkAcl" + } + ], + "VpcId": "vpc-0a70974840d8af0ba" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.validation.json b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.validation.json new file mode 100644 index 0000000000000..49827aedb9e39 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resource_providers/ec2/aws_ec2_networkacl/test_basic.py::TestBasicCRD::test_black_box": { + "last_validated_date": "2023-10-31T12:22:34+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py b/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py new file mode 100644 index 0000000000000..27ab4343845d0 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py @@ -0,0 +1,115 @@ +import os + +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.testing.pytest import markers + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..KeyPairs..KeyType", + "$..KeyPairs..Tags", + "$..Error..Message", + ] +) +def test_deploy_instance_with_key_pair(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("KeyName")) + snapshot.add_transformer(snapshot.transform.key_value("KeyPairId")) + snapshot.add_transformer(snapshot.transform.key_value("KeyFingerprint")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/ec2_keypair.yml" + ) + ) + + key_name = stack.outputs["KeyPairName"] + + response = aws_client.ec2.describe_key_pairs(KeyNames=[key_name]) + snapshot.match("key_pair", response) + + stack.destroy() + + with pytest.raises(ClientError) as e: + aws_client.ec2.describe_key_pairs(KeyNames=[key_name]) + snapshot.match("key_pair_deleted", e.value.response) + + +@markers.aws.validated +def test_deploy_prefix_list(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/ec2_prefixlist.yml" + ) + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + description = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name) + snapshot.match("resource-description", description) + + prefix_id = stack.outputs["PrefixRef"] + prefix_list = aws_client.ec2.describe_managed_prefix_lists(PrefixListIds=[prefix_id]) + snapshot.match("prefix-list", prefix_list) + snapshot.add_transformer(snapshot.transform.key_value("PrefixListId")) + + +@markers.aws.validated +def test_deploy_security_group_with_tags(deploy_cfn_template, aws_client, snapshot): + """Create security group in default VPC with tags.""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/ec2_security_group_with_tags.yml" + ) + ) + + snapshot.add_transformer(snapshot.transform.key_value("GroupId")) + snapshot.add_transformer(snapshot.transform.key_value("GroupName")) + snapshot.add_transformer(snapshot.transform.key_value("VpcId")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + snapshot.add_transformer(SortingTransformer("Tags", lambda tag: tag["Key"])) + response = aws_client.ec2.describe_security_groups(GroupIds=[stack.outputs["SecurityGroupId"]]) + security_group = response["SecurityGroups"][0] + + snapshot.match("security-group", security_group) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DnsEntries", + "$..Groups", + "$..NetworkInterfaceIds", + "$..SubnetIds", + ] +) +def test_deploy_vpc_endpoint(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/ec2_vpc_endpoint.yml" + ) + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + description = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name) + snapshot.match("resource-description", description) + + endpoint_id = stack.outputs["EndpointRef"] + endpoint = aws_client.ec2.describe_vpc_endpoints(VpcEndpointIds=[endpoint_id]) + snapshot.match("endpoint", endpoint) + + snapshot.add_transformer(snapshot.transform.key_value("VpcEndpointId")) + snapshot.add_transformer(snapshot.transform.key_value("DnsName")) + snapshot.add_transformer(snapshot.transform.key_value("HostedZoneId")) + snapshot.add_transformer(snapshot.transform.key_value("GroupId")) + snapshot.add_transformer(snapshot.transform.key_value("GroupName")) + snapshot.add_transformer(snapshot.transform.regex(stack.outputs["VpcId"], "vpc-id")) + snapshot.add_transformer(snapshot.transform.regex(stack.outputs["SubnetBId"], "subnet-b-id")) + snapshot.add_transformer(snapshot.transform.regex(stack.outputs["SubnetAId"], "subnet-a-id")) diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.snapshot.json b/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.snapshot.json new file mode 100644 index 0000000000000..f0dc276e6ccff --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.snapshot.json @@ -0,0 +1,275 @@ +{ + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_instance_with_key_pair": { + "recorded-date": "30-01-2024, 21:09:52", + "recorded-content": { + "key_pair": { + "KeyPairs": [ + { + "CreateTime": "datetime", + "KeyFingerprint": "", + "KeyName": "", + "KeyPairId": "", + "KeyType": "rsa", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "key_pair_deleted": { + "Error": { + "Code": "InvalidKeyPair.NotFound", + "Message": "The key pair '' does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_prefix_list": { + "recorded-date": "30-04-2024, 19:32:40", + "recorded-content": { + "resource-description": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "NewPrefixList", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::PrefixList", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "prefix-list": { + "PrefixLists": [ + { + "AddressFamily": "IPv4", + "MaxEntries": 10, + "OwnerId": "111111111111", + "PrefixListArn": "arn::ec2::111111111111:prefix-list/", + "PrefixListId": "", + "PrefixListName": "vpc-1-servers", + "State": "create-complete", + "Tags": [ + { + "Key": "Name", + "Value": "VPC-1-Servers" + } + ], + "Version": 1 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_vpc_endpoint": { + "recorded-date": "30-04-2024, 20:01:19", + "recorded-content": { + "resource-description": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "CWLInterfaceEndpoint", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::VPCEndpoint", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "mySecurityGroup", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::SecurityGroup", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "myVPC", + "PhysicalResourceId": "vpc-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::VPC", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "subnetA", + "PhysicalResourceId": "subnet-a-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::Subnet", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "subnetB", + "PhysicalResourceId": "subnet-b-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::Subnet", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "endpoint": { + "VpcEndpoints": [ + { + "CreationTimestamp": "timestamp", + "DnsEntries": [ + { + "DnsName": "-g5hws96k.logs..vpce.amazonaws.com", + "HostedZoneId": "" + }, + { + "DnsName": "-g5hws96k-b.logs..vpce.amazonaws.com", + "HostedZoneId": "" + }, + { + "DnsName": "-g5hws96k-a.logs..vpce.amazonaws.com", + "HostedZoneId": "" + }, + { + "DnsName": "", + "HostedZoneId": "" + }, + { + "DnsName": "", + "HostedZoneId": "" + } + ], + "DnsOptions": { + "DnsRecordIpType": "ipv4" + }, + "Groups": [ + { + "GroupId": "", + "GroupName": "-mySecurityGroup-RWU3KD7UZFAy" + } + ], + "IpAddressType": "ipv4", + "NetworkInterfaceIds": [ + "eni-0b89833f2bf9a89c0", + "eni-05151d42b885fbd35" + ], + "OwnerId": "111111111111", + "PolicyDocument": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Principal": "*", + "Resource": "*" + } + ] + }, + "PrivateDnsEnabled": true, + "RequesterManaged": false, + "RouteTableIds": [], + "ServiceName": "com.amazonaws..logs", + "State": "available", + "SubnetIds": [ + "subnet-a-id", + "subnet-b-id" + ], + "Tags": [], + "VpcEndpointId": "", + "VpcEndpointType": "Interface", + "VpcId": "vpc-id" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_security_group_with_tags": { + "recorded-date": "02-01-2025, 10:30:57", + "recorded-content": { + "security-group": { + "Description": "Security Group", + "GroupId": "", + "GroupName": "", + "IpPermissions": [], + "IpPermissionsEgress": [ + { + "IpProtocol": "-1", + "IpRanges": [ + { + "CidrIp": "0.0.0.0/0" + } + ], + "Ipv6Ranges": [], + "PrefixListIds": [], + "UserIdGroupPairs": [] + } + ], + "OwnerId": "111111111111", + "SecurityGroupArn": "arn::ec2::111111111111:security-group/", + "Tags": [ + { + "Key": "aws:cloudformation:logical-id", + "Value": "SecurityGroup" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "" + }, + { + "Key": "aws:cloudformation:stack-name", + "Value": "" + }, + { + "Key": "key1", + "Value": "value1" + }, + { + "Key": "key2", + "Value": "value2" + } + ], + "VpcId": "" + } + } + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.validation.json b/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.validation.json new file mode 100644 index 0000000000000..b7d406afb4803 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_instance_with_key_pair": { + "last_validated_date": "2024-01-30T21:09:52+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_prefix_list": { + "last_validated_date": "2024-04-26T16:18:18+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_security_group_with_tags": { + "last_validated_date": "2025-01-02T10:30:57+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/ec2/test_ec2.py::test_deploy_vpc_endpoint": { + "last_validated_date": "2024-04-30T20:01:19+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/iam/__init__.py b/tests/aws/services/cloudformation/resource_providers/iam/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/__init__.py b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_basic.yaml b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_basic.yaml new file mode 100644 index 0000000000000..c4a7d4f94a3eb --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_basic.yaml @@ -0,0 +1,14 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise create and delete operations for AWS::IAM::User +Parameters: + CustomUserName: + Type: String +Resources: + MyResource: + Type: AWS::IAM::User + Properties: + UserName: !Ref CustomUserName +Outputs: + MyRef: + Value: + Ref: MyResource diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_basic_autogenerated.yaml b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_basic_autogenerated.yaml new file mode 100644 index 0000000000000..9a24a93d3f2d2 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_basic_autogenerated.yaml @@ -0,0 +1,9 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise updating autogenerated properties of AWS::IAM::User +Resources: + MyResource: + Type: AWS::IAM::User +Outputs: + MyRef: + Value: + Ref: MyResource diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_full.yaml b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_full.yaml new file mode 100644 index 0000000000000..c05178db6ba07 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_full.yaml @@ -0,0 +1,17 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise create and delete operations for AWS::IAM::User +Parameters: + CustomUserName: + Type: String + CustomGroups: + Type: CommaDelimitedList +Resources: + MyResource: + Type: AWS::IAM::User + Properties: + UserName: !Ref CustomUserName + Groups: !Ref CustomGroups +Outputs: + MyRef: + Value: + Ref: MyResource diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_getatt.yaml b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_getatt.yaml new file mode 100644 index 0000000000000..47ef3a967d281 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_getatt.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise create and delete operations for AWS::IAM::User +Parameters: + CustomUserName: + Type: String +Resources: + MyResource: + Type: AWS::IAM::User + Properties: + UserName: !Ref CustomUserName +Outputs: + MyRef: + Value: + Ref: MyResource + GetAttArn: + Value: !GetAtt MyResource.Arn diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_getatt_exploration.yaml b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_getatt_exploration.yaml new file mode 100644 index 0000000000000..5dc38d17e9123 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_getatt_exploration.yaml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise getting attributes of AWS::IAM::User +Parameters: + AttributeName: + Type: String + Description: Name of the attribute to fetch from the resource +Resources: + MyResource: + Type: AWS::IAM::User + Properties: {} +Outputs: + MyRef: + Value: + Ref: MyResource + MyOutput: + Value: + Fn::GetAtt: + - MyResource + - Ref: AttributeName diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_update.yaml b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_update.yaml new file mode 100644 index 0000000000000..70746356783a4 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/templates/user_update.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise updating AWS::IAM::User +Parameters: + AttributeValue: + Type: String + Description: Value of property to change to force an update +Resources: + MyResource: + Type: AWS::IAM::User + Properties: + UserName: !Ref AttributeValue +Outputs: + MyRef: + Value: + Ref: MyResource diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py new file mode 100644 index 0000000000000..05f6f94543bb6 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py @@ -0,0 +1,146 @@ +# LocalStack Resource Provider Scaffolding v1 +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +class TestBasicCRD: + @markers.aws.validated + def test_black_box(self, deploy_cfn_template, aws_client, snapshot): + """ + Simple test that + - deploys a stack containing the resource + - verifies that the resource has been created correctly by querying the service directly + - deletes the stack ensuring that the delete operation has been implemented correctly + - verifies that the resource no longer exists by querying the service directly + """ + user_name = f"test-user-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(user_name, "")) + snapshot.add_transformer(snapshot.transform.key_value("UserId", "user-id")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/user_basic.yaml", + ), + parameters={"CustomUserName": user_name}, + ) + snapshot.match("stack-outputs", stack.outputs) + snapshot.match("describe-resource", aws_client.iam.get_user(UserName=user_name)) + + # verify that the delete operation works + stack.destroy() + + # fetch the resource again and assert that it no longer exists + with pytest.raises(ClientError): + aws_client.iam.get_user(UserName=user_name) + + @markers.aws.validated + def test_autogenerated_values(self, aws_client, deploy_cfn_template, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/user_basic_autogenerated.yaml", + ), + ) + snapshot.match("stack_outputs", stack.outputs) + user_name = stack.outputs[ + "MyRef" + ] # Captured example: 'stack-9126a688-MyResource-123WYN8UFFGZM' + snapshot.add_transformer(snapshot.transform.regex(user_name, "")) + snapshot.add_transformer(snapshot.transform.key_value("UserId", "user-id")) + + # verify resource has been correctly deployed with the autogenerated field + response = aws_client.iam.get_user(UserName=user_name) + snapshot.match("autogenerated-get-user", response) + + # check the auto-generated pattern + assert stack.stack_name in user_name + assert "MyResource" in user_name + + @pytest.mark.skip_snapshot_verify + @markers.aws.validated + def test_getatt(self, snapshot, deploy_cfn_template, aws_client): + user_name = f"test-user-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(user_name, "")) + snapshot.add_transformer(snapshot.transform.key_value("UserId", "user-id")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/user_getatt.yaml", + ), + parameters={"CustomUserName": user_name}, + ) + snapshot.match("stack-outputs", stack.outputs) + user_details = aws_client.iam.get_user(UserName=user_name) + snapshot.match("describe-resource", user_details) + + stack_resource = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="MyResource" + ) + snapshot.match("stack_resource", stack_resource) + + assert user_details["User"]["Arn"] + assert user_details["User"]["Arn"] == stack.outputs["GetAttArn"] + + # verify that the delete operation works + stack.destroy() + + # fetch the resource again and assert that it no longer exists + with pytest.raises(ClientError): + aws_client.iam.get_user(UserName=user_name) + + +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="Not working yet") +class TestUpdates: + @markers.aws.validated + def test_update_without_replacement(self, deploy_cfn_template, aws_client, snapshot): + """ + Test an UPDATE of a simple property that does not require replacing the entire resource. + Check out the official resource documentation at https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html to see if a property needs replacement + """ + user_name = f"test-user-{short_uid()}" + updated_user_name = f"{user_name}-updated" + snapshot.add_transformer(snapshot.transform.regex(user_name, "")) + snapshot.add_transformer(snapshot.transform.key_value("UserId", "user-id")) + + # create stack + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/user_update.yaml", + ), + parameters={"AttributeValue": user_name}, + ) + snapshot.match("stack-outputs-before-update", stack.outputs) + + # verify actual resource deployment + response = aws_client.iam.get_user(UserName=user_name) + with pytest.raises(ClientError): + aws_client.iam.get_user(UserName=updated_user_name) + snapshot.match("describe-resource-before-update", response) + + # update the stack + stack_updated = deploy_cfn_template( + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), + "templates/user_update.yaml", + ), + parameters={"AttributeValue": updated_user_name}, + is_update=True, + ) + + # verify updated resource deployment + response = aws_client.iam.get_user(UserName=updated_user_name) + with pytest.raises(ClientError): + aws_client.iam.get_user(UserName=user_name) + + snapshot.match("describe-resource-after-update", response) + snapshot.match("stack-outputs-after-update", stack_updated.outputs) diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.snapshot.json b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.snapshot.json new file mode 100644 index 0000000000000..5c5363b50efb3 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.snapshot.json @@ -0,0 +1,122 @@ +{ + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_black_box": { + "recorded-date": "28-06-2023, 22:01:50", + "recorded-content": { + "stack-outputs": { + "MyRef": "" + }, + "describe-resource": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "datetime", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestUpdates::test_update_without_replacement": { + "recorded-date": "28-06-2023, 22:31:43", + "recorded-content": { + "stack-outputs-before-update": { + "MyRef": "" + }, + "describe-resource-before-update": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "datetime", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-resource-after-update": { + "User": { + "Arn": "arn::iam::111111111111:user/-updated", + "CreateDate": "datetime", + "Path": "/", + "UserId": "", + "UserName": "-updated" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack-outputs-after-update": { + "MyRef": "-updated" + } + } + }, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_autogenerated_values": { + "recorded-date": "28-06-2023, 22:54:57", + "recorded-content": { + "stack_outputs": { + "MyRef": "" + }, + "autogenerated-get-user": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "datetime", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_getatt": { + "recorded-date": "05-07-2023, 14:15:12", + "recorded-content": { + "stack-outputs": { + "GetAttArn": "arn::iam::111111111111:user/", + "Ref": "" + }, + "describe-resource": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "datetime", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resource": { + "StackResourceDetail": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "MyResource", + "Metadata": {}, + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::User", + "StackId": "arn::cloudformation::111111111111:stack/stack-fd6b2c74/666b7260-1b2d-11ee-8c20-12a3d6611181", + "StackName": "stack-fd6b2c74" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.validation.json b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.validation.json new file mode 100644 index 0000000000000..99625ca69c742 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_autogenerated_values": { + "last_validated_date": "2023-06-28T20:54:57+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_black_box": { + "last_validated_date": "2023-06-28T20:01:50+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestBasicCRD::test_getatt": { + "last_validated_date": "2023-07-05T12:15:12+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_basic.py::TestUpdates::test_update_without_replacement": { + "last_validated_date": "2023-06-28T20:31:43+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py new file mode 100644 index 0000000000000..ecab7f8c62865 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_exploration.py @@ -0,0 +1,42 @@ +# LocalStack Resource Provider Scaffolding v1 +import os + +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +RESOURCE_GETATT_TARGETS = ["Path", "UserName", "Id", "Arn", "PermissionsBoundary"] + + +class TestAttributeAccess: + @pytest.mark.parametrize("attribute", RESOURCE_GETATT_TARGETS) + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Exploratory test only") + @markers.aws.validated + def test_getatt( + self, + aws_client, + deploy_cfn_template, + attribute, + snapshot, + ): + """ + Use this test to find out which properties support GetAtt access + + Fn::GetAtt documentation: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html + """ + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/user_getatt_exploration.yaml", + ), + parameters={"AttributeName": attribute}, + ) + snapshot.match("stack_outputs", stack.outputs) + + # check physical resource id + res = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="MyResource" + )["StackResourceDetail"] + snapshot.match("physical_resource_id", res.get("PhysicalResourceId")) diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.py b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.py new file mode 100644 index 0000000000000..a9c467e6f7c3c --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.py @@ -0,0 +1,76 @@ +# ruff: noqa +# LocalStack Resource Provider Scaffolding v1 +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +class TestParity: + """ + Pro-active parity-focused tests that go into more detailed than the basic test skeleton + + TODO: add more focused detailed tests for updates, different combinations, etc. + Use snapshots here to capture detailed parity with AWS + + Other ideas for tests in here: + - Negative test: invalid combination of properties + - Negative test: missing required properties + """ + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..IsTruncated"] + ) # Moto always returns this pagination-related field, AWS only when true + def test_create_with_full_properties(self, aws_client, deploy_cfn_template, snapshot, cleanups): + """A sort of smoke test that simply covers as many properties as possible""" + # TODO: keep extending this test with more properties for higher parity with the official resource on AWS + user_name = f"test-user-{short_uid()}" + group_name_1 = f"test-group-{short_uid()}" + group_name_2 = f"test-group-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(user_name, "")) + snapshot.add_transformer(snapshot.transform.regex(group_name_1, "")) + snapshot.add_transformer(snapshot.transform.regex(group_name_2, "")) + snapshot.add_transformer(snapshot.transform.key_value("UserId", "user-id")) + snapshot.add_transformer(snapshot.transform.key_value("GroupId", "group-id")) + + # it is up to you if you want to "inject" existing groups here by using a parameter + # alternatively you can also just add another resource to the template creating a group and referencing it directly + cleanups.append(lambda: aws_client.iam.delete_group(GroupName=group_name_1)) + cleanups.append(lambda: aws_client.iam.delete_group(GroupName=group_name_2)) + aws_client.iam.create_group(GroupName=group_name_1) + aws_client.iam.create_group(GroupName=group_name_2) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/user_full.yaml", + ), + parameters={ + "CustomUserName": user_name, + "CustomGroups": ",".join([group_name_1, group_name_2]), + }, + ) + snapshot.match("stack-outputs", stack.outputs) + snapshot.match("describe-user-resource", aws_client.iam.get_user(UserName=user_name)) + snapshot.match( + "describe-user-group-association", + aws_client.iam.list_groups_for_user(UserName=user_name), + ) + + # verify that the delete operation works + stack.destroy() + + # fetch the resource again and assert that it no longer exists + with pytest.raises(ClientError): + aws_client.iam.get_user(UserName=user_name) + + +@pytest.mark.skip(reason="TODO") +class TestSamples: + """User-provided samples and other reactively added scenarios (e.g. reported and reproduced GitHub issues)""" + + ... diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.snapshot.json b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.snapshot.json new file mode 100644 index 0000000000000..cc28d998b40b0 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.snapshot.json @@ -0,0 +1,46 @@ +{ + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.py::TestParity::test_create_with_full_properties": { + "recorded-date": "29-06-2023, 13:59:27", + "recorded-content": { + "stack-outputs": { + "MyRef": "" + }, + "describe-user-resource": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "datetime", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-user-group-association": { + "Groups": [ + { + "Arn": "arn::iam::111111111111:group/", + "CreateDate": "datetime", + "GroupId": "", + "GroupName": "", + "Path": "/" + }, + { + "Arn": "arn::iam::111111111111:group/", + "CreateDate": "datetime", + "GroupId": "", + "GroupName": "", + "Path": "/" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.validation.json b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.validation.json new file mode 100644 index 0000000000000..a0b1ff9f59556 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resource_providers/iam/aws_iam_user/test_parity.py::TestParity::test_create_with_full_properties": { + "last_validated_date": "2023-06-29T11:59:27+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/iam/test_iam.py b/tests/aws/services/cloudformation/resource_providers/iam/test_iam.py new file mode 100644 index 0000000000000..9edac28396e11 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/test_iam.py @@ -0,0 +1,335 @@ +import json +import os + +import pytest + +from localstack.services.iam.provider import SERVICE_LINKED_ROLE_PATH_PREFIX +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + + +@markers.aws.validated +def test_delete_role_detaches_role_policy(deploy_cfn_template, aws_client): + role_name = f"LsRole{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_role_policy.yaml" + ), + parameters={"RoleName": role_name}, + ) + attached_policies = aws_client.iam.list_attached_role_policies(RoleName=role_name)[ + "AttachedPolicies" + ] + assert len(attached_policies) > 0 + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_role_policy.yaml" + ), + parameters={"RoleName": f"role-{short_uid()}"}, + ) + + with pytest.raises(Exception) as e: + aws_client.iam.list_attached_role_policies(RoleName=role_name) + assert e.value.response.get("Error").get("Code") == "NoSuchEntity" + + +@markers.aws.validated +def test_policy_attachments(deploy_cfn_template, aws_client): + role_name = f"role-{short_uid()}" + group_name = f"group-{short_uid()}" + user_name = f"user-{short_uid()}" + policy_name = f"policy-{short_uid()}" + + linked_role_id = short_uid() + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_policy_attachments.yaml" + ), + template_mapping={ + "role_name": role_name, + "policy_name": policy_name, + "user_name": user_name, + "group_name": group_name, + "service_linked_role_id": linked_role_id, + }, + ) + + # check inline policies + role_inline_policies = aws_client.iam.list_role_policies(RoleName=role_name) + user_inline_policies = aws_client.iam.list_user_policies(UserName=user_name) + group_inline_policies = aws_client.iam.list_group_policies(GroupName=group_name) + assert len(role_inline_policies["PolicyNames"]) == 2 + assert len(user_inline_policies["PolicyNames"]) == 1 + assert len(group_inline_policies["PolicyNames"]) == 1 + + # check managed/attached policies + role_attached_policies = aws_client.iam.list_attached_role_policies(RoleName=role_name) + user_attached_policies = aws_client.iam.list_attached_user_policies(UserName=user_name) + group_attached_policies = aws_client.iam.list_attached_group_policies(GroupName=group_name) + assert len(role_attached_policies["AttachedPolicies"]) == 1 + assert len(user_attached_policies["AttachedPolicies"]) == 1 + assert len(group_attached_policies["AttachedPolicies"]) == 1 + + # check service linked roles + roles = aws_client.iam.list_roles(PathPrefix=SERVICE_LINKED_ROLE_PATH_PREFIX)["Roles"] + matching = [r for r in roles if r.get("Description") == f"service linked role {linked_role_id}"] + assert matching + policy = matching[0]["AssumeRolePolicyDocument"] + policy = json.loads(policy) if isinstance(policy, str) else policy + assert policy["Statement"][0]["Principal"] == {"Service": "elasticbeanstalk.amazonaws.com"} + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..User.Tags"]) +def test_iam_username_defaultname(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.iam_api()) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + template = json.dumps( + { + "Resources": { + "DefaultNameUser": { + "Type": "AWS::IAM::User", + } + }, + "Outputs": {"DefaultNameUserOutput": {"Value": {"Ref": "DefaultNameUser"}}}, + } + ) + stack = deploy_cfn_template(template=template) + user_name = stack.outputs["DefaultNameUserOutput"] + assert user_name + + get_iam_user = aws_client.iam.get_user(UserName=user_name) + snapshot.match("get_iam_user", get_iam_user) + + +@markers.aws.validated +def test_iam_user_access_key(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("AccessKeyId", "key-id"), + snapshot.transform.key_value("UserName", "user-name"), + snapshot.transform.key_value("SecretAccessKey", "secret-access-key"), + ] + ) + + user_name = f"user-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_access_key.yaml" + ), + parameters={"UserName": user_name}, + ) + + snapshot.match("key_outputs", stack.outputs) + key = aws_client.iam.list_access_keys(UserName=user_name)["AccessKeyMetadata"][0] + snapshot.match("access_key", key) + + # Update Status + stack2 = deploy_cfn_template( + stack_name=stack.stack_name, + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_access_key.yaml" + ), + parameters={"UserName": user_name, "Status": "Inactive", "Serial": "2"}, + ) + keys = aws_client.iam.list_access_keys(UserName=user_name)["AccessKeyMetadata"] + updated_key = [k for k in keys if k["AccessKeyId"] == stack2.outputs["AccessKeyId"]][0] + # IAM just being IAM. First key takes a bit to delete and in the meantime might still be visible here + snapshot.match("access_key_updated", updated_key) + assert stack2.outputs["AccessKeyId"] != stack.outputs["AccessKeyId"] + assert stack2.outputs["SecretAccessKey"] != stack.outputs["SecretAccessKey"] + + +@markers.aws.validated +def test_update_inline_policy(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.iam_api()) + snapshot.add_transformer(snapshot.transform.key_value("PolicyName", "policy-name")) + snapshot.add_transformer(snapshot.transform.key_value("RoleName", "role-name")) + snapshot.add_transformer(snapshot.transform.key_value("UserName", "user-name")) + + policy_name = f"policy-{short_uid()}" + user_name = f"user-{short_uid()}" + role_name = f"role-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_policy_role.yaml" + ), + parameters={ + "PolicyName": policy_name, + "UserName": user_name, + "RoleName": role_name, + }, + ) + + user_inline_policy_response = aws_client.iam.get_user_policy( + UserName=user_name, PolicyName=policy_name + ) + role_inline_policy_resource = aws_client.iam.get_role_policy( + RoleName=role_name, PolicyName=policy_name + ) + + snapshot.match("user_inline_policy", user_inline_policy_response) + snapshot.match("role_inline_policy", role_inline_policy_resource) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_policy_role_updated.yaml" + ), + parameters={ + "PolicyName": policy_name, + "UserName": user_name, + "RoleName": role_name, + }, + stack_name=stack.stack_name, + is_update=True, + ) + + user_updated_inline_policy_response = aws_client.iam.get_user_policy( + UserName=user_name, PolicyName=policy_name + ) + role_updated_inline_policy_resource = aws_client.iam.get_role_policy( + RoleName=role_name, PolicyName=policy_name + ) + + snapshot.match("user_updated_inline_policy", user_updated_inline_policy_response) + snapshot.match("role_updated_inline_policy", role_updated_inline_policy_resource) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Policy.Description", + "$..Policy.IsAttachable", + "$..Policy.PermissionsBoundaryUsageCount", + "$..Policy.Tags", + ] +) +def test_managed_policy_with_empty_resource(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer( + snapshot.transform.iam_api(), + ) + snapshot.add_transformers_list( + [snapshot.transform.resource_name(), snapshot.transform.key_value("PolicyId", "policy-id")] + ) + + parameters = { + "tableName": f"table-{short_uid()}", + "policyName": f"managed-policy-{short_uid()}", + } + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/dynamodb_iam.yaml" + ) + + stack = deploy_cfn_template(template_path=template_path, parameters=parameters) + + snapshot.match("outputs", stack.outputs) + + policy_arn = stack.outputs["PolicyArn"] + policy = aws_client.iam.get_policy(PolicyArn=policy_arn) + snapshot.match("managed_policy", policy) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ServerCertificate.Tags", + ] +) +def test_server_certificate(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_server_certificate.yaml" + ), + parameters={"certificateName": f"server-certificate-{short_uid()}"}, + ) + snapshot.match("outputs", stack.outputs) + + certificate = aws_client.iam.get_server_certificate( + ServerCertificateName=stack.outputs["ServerCertificateName"] + ) + snapshot.match("certificate", certificate) + + stack.destroy() + with pytest.raises(Exception) as e: + aws_client.iam.get_server_certificate( + ServerCertificateName=stack.outputs["ServerCertificateName"] + ) + snapshot.match("get_server_certificate_error", e.value.response) + + snapshot.add_transformer( + snapshot.transform.key_value("ServerCertificateName", "server-certificate-name") + ) + snapshot.add_transformer( + snapshot.transform.key_value("ServerCertificateId", "server-certificate-id") + ) + + +@markers.aws.validated +def test_cfn_handle_iam_role_resource_no_role_name(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/iam_role_defaults.yml" + ) + ) + role_path_prefix = "/test-role-prefix/" + + rs = aws_client.iam.list_roles(PathPrefix=role_path_prefix) + assert len(rs["Roles"]) == 1 + + stack.destroy() + + rs = aws_client.iam.list_roles(PathPrefix=role_path_prefix) + assert not rs["Roles"] + + +@markers.aws.validated +def test_updating_stack_with_iam_role(deploy_cfn_template, aws_client): + lambda_role_name = f"lambda-role-{short_uid()}" + lambda_function_name = f"lambda-function-{short_uid()}" + + # Create stack and wait for 'CREATE_COMPLETE' status of the stack + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/template7.json" + ), + parameters={ + "LambdaRoleName": lambda_role_name, + "LambdaFunctionName": lambda_function_name, + }, + ) + + function_description = aws_client.lambda_.get_function(FunctionName=lambda_function_name) + assert stack.outputs["TestStackRoleName"] in function_description.get("Configuration").get( + "Role" + ) + assert stack.outputs["TestStackRoleName"] == lambda_role_name + + # Generate new names for lambda and IAM Role + lambda_role_name_new = f"lambda-role-new-{short_uid()}" + lambda_function_name_new = f"lambda-function-new-{short_uid()}" + + # Update stack and wait for 'UPDATE_COMPLETE' status of the stack + stack = deploy_cfn_template( + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/template7.json" + ), + stack_name=stack.stack_name, + parameters={ + "LambdaRoleName": lambda_role_name_new, + "LambdaFunctionName": lambda_function_name_new, + }, + ) + + function_description = aws_client.lambda_.get_function(FunctionName=lambda_function_name_new) + assert stack.outputs["TestStackRoleName"] in function_description.get("Configuration").get( + "Role" + ) + assert stack.outputs["TestStackRoleName"] == lambda_role_name_new diff --git a/tests/aws/services/cloudformation/resource_providers/iam/test_iam.snapshot.json b/tests/aws/services/cloudformation/resource_providers/iam/test_iam.snapshot.json new file mode 100644 index 0000000000000..044291d4e7cf1 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/test_iam.snapshot.json @@ -0,0 +1,196 @@ +{ + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_username_defaultname": { + "recorded-date": "31-05-2022, 11:29:45", + "recorded-content": { + "get_iam_user": { + "User": { + "Path": "/", + "UserName": "", + "UserId": "", + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "datetime" + }, + "ResponseMetadata": { + "HTTPStatusCode": 200, + "HTTPHeaders": {} + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_managed_policy_with_empty_resource": { + "recorded-date": "11-07-2023, 18:10:41", + "recorded-content": { + "outputs": { + "PolicyArn": "arn::iam::111111111111:policy/", + "StreamARN": "arn::dynamodb::111111111111:table//stream/", + "TableARN": "arn::dynamodb::111111111111:table/", + "TableName": "" + }, + "managed_policy": { + "Policy": { + "Arn": "arn::iam::111111111111:policy/", + "AttachmentCount": 0, + "CreateDate": "datetime", + "DefaultVersionId": "v1", + "IsAttachable": true, + "Path": "/", + "PermissionsBoundaryUsageCount": 0, + "PolicyId": "", + "PolicyName": "", + "Tags": [], + "UpdateDate": "datetime" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_user_access_key": { + "recorded-date": "11-07-2023, 08:23:54", + "recorded-content": { + "key_outputs": { + "AccessKeyId": "", + "SecretAccessKey": "" + }, + "access_key": { + "AccessKeyId": "", + "CreateDate": "datetime", + "Status": "Active", + "UserName": "" + }, + "access_key_updated": { + "AccessKeyId": "", + "CreateDate": "datetime", + "Status": "Inactive", + "UserName": "" + } + } + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_update_inline_policy": { + "recorded-date": "05-04-2023, 11:55:22", + "recorded-content": { + "user_inline_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:PutObject", + "s3:ListBucket" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "", + "UserName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_inline_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "", + "RoleName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "user_updated_inline_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:PutObject" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "", + "UserName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_updated_inline_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:ListBucket" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "", + "RoleName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_server_certificate": { + "recorded-date": "13-03-2024, 20:20:07", + "recorded-content": { + "outputs": { + "Arn": "arn::iam::111111111111:server-certificate/", + "ServerCertificateName": "" + }, + "certificate": { + "ServerCertificate": { + "CertificateBody": "-----BEGIN CERTIFICATE-----\nMIIEHTCCAwWgAwIBAgIDAJojMA0GCSqGSIb3DQEBCwUAMIGLMQswCQYDVQQGEwJV\nUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEX\nMBUGA1UECgwOTXlPcmdhbml6YXRpb24xHTAbBgNVBAsMFE15T3JnYW5pemF0aW9u\nYWxVbml0MRcwFQYDVQQDDA5NeSBvd24gUm9vdCBDQTAeFw0yMTAzMTExNTAwNDla\nFw0zMDAzMDkxNTAwNDlaMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZv\ncm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEXMBUGA1UECgwOTXlPcmdhbml6\nYXRpb24xHTAbBgNVBAsMFE15T3JnYW5pemF0aW9uYWxVbml0MRQwEgYDVQQDDAtl\neGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnKQhQG\npRuxcO5RF8VMyAmWe4rs4XWeodVQflYtJVY+mCg/JidmgYe1EYXvE2Qqf1Xzi2O2\noEJJSAs/s+Wb91yzunnoHVR/5uTHdjN2e6HRhEmUFlJuconjlmBxVKe1LG4Ra8yr\nJA+E0tS2kzrGCLNcFpghQ982GJjuvRWm9nAAsCJPm7N8a/Gm1opMdUkiH1b/3d47\n0wugisz6fYRHQ61UIYfjNUWlg/tV1thGOScAB2RyusQJdTB422BQAlpD4TTX8uj8\nWd0GhYjpM8DWWpSUOFsoYOHBc3bPr7ctpOoIG8gZcs56zDwZi9CVda4viS/8HPnC\nr8jXaQW1pqwP8ekCAwEAAaOBijCBhzAJBgNVHRMEAjAAMB0GA1UdDgQWBBTaOaPu\nXmtLDTJVv++VYBiQr9gHCTAfBgNVHSMEGDAWgBTaOaPuXmtLDTJVv++VYBiQr9gH\nCTATBgNVHSUEDDAKBggrBgEFBQcDATALBgNVHQ8EBAMCB4AwGAYDVR0RBBEwD4IN\nKi5leGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAWIZu4sma7MmWTXSMwKSP\nstQDWdIvcwthD8ozHkLsNdl5eKqOEndAc0wb7mSk1z8rRkSsd0D0T2zaKyduCYrs\neBAMhS2+NnHWcXxhn0VOkmXhw5kO8Un14KIptRH0y8FIqHMJ8LrSiK9g9fWCRlI9\ng7eBipu43hzGyMiBP3K0EQ4m49QXlIEwG3OIWak5hdR29h3cD6xXMXaUtlOswsAN\n3PDG/gcjZWZpkwPlaVzwjV8MRsYLmQIYdHPr/qF1FWddYPvK89T0nzpgiuFdBOTY\nW6I1TeTAXFXG2Qf4trXsh5vsFNAisxlRF3mkpixYP5OmVXTOyN7cCOSPOUh6Uctv\neg==\n-----END CERTIFICATE-----", + "ServerCertificateMetadata": { + "Arn": "arn::iam::111111111111:server-certificate/", + "Expiration": "datetime", + "Path": "/", + "ServerCertificateId": "", + "ServerCertificateName": "", + "UploadDate": "datetime" + }, + "Tags": [] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_server_certificate_error": { + "Error": { + "Code": "NoSuchEntity", + "Message": "The Server Certificate with name cannot be found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/iam/test_iam.validation.json b/tests/aws/services/cloudformation/resource_providers/iam/test_iam.validation.json new file mode 100644 index 0000000000000..9052daa434c63 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/iam/test_iam.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_cfn_handle_iam_role_resource_no_role_name": { + "last_validated_date": "2024-06-18T20:29:57+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_user_access_key": { + "last_validated_date": "2023-07-11T06:23:54+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_iam_username_defaultname": { + "last_validated_date": "2022-05-31T09:29:45+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_managed_policy_with_empty_resource": { + "last_validated_date": "2023-07-11T16:10:41+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_server_certificate": { + "last_validated_date": "2024-03-13T20:20:07+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_update_inline_policy": { + "last_validated_date": "2023-04-05T09:55:22+00:00" + }, + "tests/aws/services/cloudformation/resource_providers/iam/test_iam.py::test_updating_stack_with_iam_role": { + "last_validated_date": "2024-06-18T21:02:59+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/opensearch/__init__.py b/tests/aws/services/cloudformation/resource_providers/opensearch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/opensearch/templates/domain.yaml b/tests/aws/services/cloudformation/resource_providers/opensearch/templates/domain.yaml new file mode 100644 index 0000000000000..963c6a36de194 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/opensearch/templates/domain.yaml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise AWS::OpenSearchService::Domain +Parameters: + AttributeName: + Type: String + Description: Name of the attribute to fetch from the resource +Resources: + MyResource: + Type: AWS::OpenSearchService::Domain + Properties: {} +Outputs: + MyRef: + Value: + Ref: MyResource + MyOutput: + Value: + Fn::GetAtt: + - MyResource + - Ref: AttributeName diff --git a/tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py b/tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py new file mode 100644 index 0000000000000..0c1ca159e6287 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/opensearch/test_domain.py @@ -0,0 +1,52 @@ +import os + +import pytest + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError + +RESOURCE_GETATT_TARGETS = [ + "DomainName", + "EngineVersion", + "DomainEndpoint", + "Id", + "Arn", + "DomainArn", +] + + +class TestAttributeAccess: + @pytest.mark.parametrize("attribute", RESOURCE_GETATT_TARGETS) + @pytest.mark.skip( + reason="Some tests are expected to fail, since they try to access invalid CFn attributes", + raises=StackDeployError, + ) + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Exploratory test only") + @markers.aws.validated + def test_getattr( + self, + aws_client: ServiceLevelClientFactory, + deploy_cfn_template, + attribute, + snapshot, + ): + """ + Capture the behaviour of getting all available attributes of the model + """ + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/domain.yaml", + ), + parameters={"AttributeName": attribute}, + ) + snapshot.match("stack_outputs", stack.outputs) + + # check physical resource id + res = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="MyResource" + )["StackResourceDetail"] + snapshot.match("physical_resource_id", res.get("PhysicalResourceId")) diff --git a/tests/aws/services/cloudformation/resource_providers/scheduler/templates/__init__.py b/tests/aws/services/cloudformation/resource_providers/scheduler/templates/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/scheduler/templates/schedule.yml b/tests/aws/services/cloudformation/resource_providers/scheduler/templates/schedule.yml new file mode 100644 index 0000000000000..8ba269d7b9d77 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/scheduler/templates/schedule.yml @@ -0,0 +1,46 @@ +Resources: + MyQueue: + Type: AWS::SQS::Queue + + ScheduleRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - scheduler.amazonaws.com + Action: + - sts:AssumeRole + Policies: + - PolicyName: AllowSQS + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - sqs:SendMessage + Resource: !GetAtt MyQueue.Arn + + MySchedule: + Type: AWS::Scheduler::Schedule + Properties: + GroupName: !Ref MyScheduleGroup + FlexibleTimeWindow: + MaximumWindowInMinutes: 60 + Mode: FLEXIBLE + Target: + Arn: !GetAtt MyQueue.Arn + RoleArn: !GetAtt ScheduleRole.Arn + ScheduleExpression: cron(0 0 * * ? *) + + MyScheduleGroup: + Type: AWS::Scheduler::ScheduleGroup + +Outputs: + ScheduleName: + Value: !Ref MySchedule + ScheduleGroupName: + Value: !Ref MyScheduleGroup diff --git a/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.py b/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.py new file mode 100644 index 0000000000000..3b3e36a130eb5 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.py @@ -0,0 +1,46 @@ +import os + +import pytest + +from localstack.testing.aws.util import in_default_partition +from localstack.testing.pytest import markers + + +@pytest.mark.skipif( + not in_default_partition(), reason="Test not applicable in non-default partitions" +) +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DriftInformation", + "$..Metadata", + "$..ActionAfterCompletion", + "$..ScheduleExpressionTimezone", + ] +) +def test_schedule_and_group(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "templates/schedule.yml") + ) + + snapshot.add_transformer( + snapshot.transform.key_value("PhysicalResourceId", "physical_resource_id") + ) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + schedule = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="MySchedule" + )["StackResourceDetail"] + snapshot.match("Schedule", schedule) + + group = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="MyScheduleGroup" + )["StackResourceDetail"] + snapshot.match("Group", group) + + schedule = aws_client.scheduler.get_schedule( + Name=stack.outputs["ScheduleName"], GroupName=stack.outputs["ScheduleGroupName"] + ) + snapshot.match("ScheduleDesc", schedule) + + group = aws_client.scheduler.get_schedule_group(Name=stack.outputs["ScheduleGroupName"]) + snapshot.match("GroupDesc", group) diff --git a/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.snapshot.json b/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.snapshot.json new file mode 100644 index 0000000000000..c9ce2aacfb380 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.snapshot.json @@ -0,0 +1,71 @@ +{ + "tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.py::test_schedule_and_group": { + "recorded-date": "21-09-2023, 10:21:19", + "recorded-content": { + "Schedule": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "MySchedule", + "Metadata": {}, + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Scheduler::Schedule", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + }, + "Group": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "MyScheduleGroup", + "Metadata": {}, + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Scheduler::ScheduleGroup", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + }, + "ScheduleDesc": { + "ActionAfterCompletion": "NONE", + "Arn": "arn::scheduler::111111111111:schedule//", + "CreationDate": "datetime", + "FlexibleTimeWindow": { + "MaximumWindowInMinutes": 60, + "Mode": "FLEXIBLE" + }, + "GroupName": "", + "LastModificationDate": "datetime", + "Name": "", + "ScheduleExpression": "cron(0 0 * * ? *)", + "ScheduleExpressionTimezone": "UTC", + "State": "ENABLED", + "Target": { + "Arn": "arn::sqs::111111111111:", + "RetryPolicy": { + "MaximumEventAgeInSeconds": 86400, + "MaximumRetryAttempts": 185 + }, + "RoleArn": "arn::iam::111111111111:role/" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "GroupDesc": { + "Arn": "arn::scheduler::111111111111:schedule-group/", + "CreationDate": "datetime", + "LastModificationDate": "datetime", + "Name": "", + "State": "ACTIVE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.validation.json b/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.validation.json new file mode 100644 index 0000000000000..3e3f55d98fb8e --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resource_providers/scheduler/test_scheduler.py::test_schedule_and_group": { + "last_validated_date": "2023-09-21T08:21:19+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/__init__.py b/tests/aws/services/cloudformation/resource_providers/ssm/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_getatt_exploration.yaml b/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_getatt_exploration.yaml new file mode 100644 index 0000000000000..257e0e95c858b --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_getatt_exploration.yaml @@ -0,0 +1,21 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise AWS::SSM::Parameter +Parameters: + AttributeName: + Type: String + Description: Name of the attribute to fetch from the resource +Resources: + MyResource: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: abc123 +Outputs: + MyRef: + Value: + Ref: MyResource + MyOutput: + Value: + Fn::GetAtt: + - MyResource + - Ref: AttributeName diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_minimal.yaml b/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_minimal.yaml new file mode 100644 index 0000000000000..a3b406c2ceef3 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_minimal.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Minimal template to exercise AWS::SSM::Parameter +Resources: + MyResource: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: abc123 +Outputs: + MyRef: + Value: !Ref MyResource diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_update_without_replacement.yaml b/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_update_without_replacement.yaml new file mode 100644 index 0000000000000..94674104a3e26 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/templates/aws_ssm_parameter_update_without_replacement.yaml @@ -0,0 +1,18 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise AWS::SSM::Parameter +Parameters: + AttributeValue: + Type: String + Description: Value of the parameter to be changed +Resources: + MyResource: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Ref AttributeValue +Outputs: + MyRef: + Value: + Ref: MyResource + MyOutput: + Value: !GetAtt MyResource.Value diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/templates/parameter.yaml b/tests/aws/services/cloudformation/resource_providers/ssm/templates/parameter.yaml new file mode 100644 index 0000000000000..257e0e95c858b --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/templates/parameter.yaml @@ -0,0 +1,21 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise AWS::SSM::Parameter +Parameters: + AttributeName: + Type: String + Description: Name of the attribute to fetch from the resource +Resources: + MyResource: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: abc123 +Outputs: + MyRef: + Value: + Ref: MyResource + MyOutput: + Value: + Fn::GetAtt: + - MyResource + - Ref: AttributeName diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py new file mode 100644 index 0000000000000..33e15e5204633 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py @@ -0,0 +1,67 @@ +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.testing.pytest import markers + + +class TestBasicCRD: + @pytest.mark.skip(reason="re-enable after fixing schema extraction") + @markers.snapshot.skip_snapshot_verify(paths=["$..error-message"]) + @markers.aws.validated + def test_black_box(self, deploy_cfn_template, aws_client: ServiceLevelClientFactory, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/aws_ssm_parameter_minimal.yaml", + ), + ) + + # TODO: implement fetching the resource and performing any required validations here + parameter_name = stack.outputs["MyRef"] + snapshot.add_transformer(snapshot.transform.regex(parameter_name, "")) + + res = aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + snapshot.match("describe-resource", res) + + stack.destroy() + + # TODO: fetch the resource again and assert that it no longer exists + with pytest.raises(ClientError) as exc_info: + aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + + snapshot.match("deleted-resource", {"error-message": str(exc_info.value)}) + + +class TestUpdates: + @pytest.mark.skip(reason="TODO") + @markers.aws.validated + def test_update_without_replacement(self, deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/aws_ssm_parameter_update_without_replacement.yaml", + ), + parameters={"AttributeValue": "first"}, + ) + + # TODO: implement fetching the resource and performing any required validations here + res = aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + snapshot.match("describe-resource-before-update", res) + + # TODO: update the stack + deploy_cfn_template( + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), + "templates/aws_ssm_parameter_update_without_replacement.yaml", + ), + parameters={"AttributeValue": "second"}, + is_update=True, + ) + + # TODO: check the value has changed + res = aws_client.ssm.get_parameter(Name=stack.outputs["MyRef"]) + snapshot.match("describe-resource-after-update", res) diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.snapshot.json b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.snapshot.json new file mode 100644 index 0000000000000..10ac8faec468f --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.snapshot.json @@ -0,0 +1,73 @@ +{ + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[Type]": { + "recorded-date": "19-05-2023, 16:57:19", + "recorded-content": { + "stack_outputs": { + "MyOutput": "String", + "MyRef": "CFN-MyResource-ywXNmT4uRH69" + }, + "physical_resource_id": "CFN-MyResource-ywXNmT4uRH69" + } + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[Description]": { + "recorded-date": "19-05-2023, 16:57:31", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[Policies]": { + "recorded-date": "19-05-2023, 16:57:43", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[AllowedPattern]": { + "recorded-date": "19-05-2023, 16:57:55", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[Tier]": { + "recorded-date": "19-05-2023, 16:58:06", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[Value]": { + "recorded-date": "19-05-2023, 16:58:18", + "recorded-content": { + "stack_outputs": { + "MyOutput": "abc123", + "MyRef": "CFN-MyResource-FzHcjgYfVinF" + }, + "physical_resource_id": "CFN-MyResource-FzHcjgYfVinF" + } + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[DataType]": { + "recorded-date": "19-05-2023, 16:58:29", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[Id]": { + "recorded-date": "19-05-2023, 16:58:41", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestAttributeAccess::test_getattr[Name]": { + "recorded-date": "19-05-2023, 16:58:53", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestBasicCRD::test_black_box": { + "recorded-date": "21-06-2023, 16:57:04", + "recorded-content": { + "describe-resource": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "abc123", + "Version": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deleted-resource": { + "error-message": "An error occurred (ParameterNotFound) when calling the GetParameter operation: " + } + } + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.validation.json b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.validation.json new file mode 100644 index 0000000000000..8af06a67eb04b --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resource_providers/ssm/test_parameter.py::TestBasicCRD::test_black_box": { + "last_validated_date": "2023-06-21T14:57:04+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py new file mode 100644 index 0000000000000..a28b023ac8253 --- /dev/null +++ b/tests/aws/services/cloudformation/resource_providers/ssm/test_parameter_getatt_exploration.py @@ -0,0 +1,49 @@ +import os + +import pytest + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +RESOURCE_GETATT_TARGETS = [ + "Type", + "Description", + "Policies", + "AllowedPattern", + "Tier", + "Value", + "DataType", + "Id", + "Name", +] + + +class TestAttributeAccess: + @pytest.mark.parametrize("attribute", RESOURCE_GETATT_TARGETS) + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Exploratory test only") + @markers.aws.validated + def test_getattr( + self, + aws_client: ServiceLevelClientFactory, + deploy_cfn_template, + attribute, + snapshot, + ): + """ + Capture the behaviour of getting all available attributes of the model + """ + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "templates/aws_ssm_parameter_getatt_exploration.yaml", + ), + parameter={"AttributeName": attribute}, + ) + snapshot.match("stack_outputs", stack.outputs) + + # check physical resource id + res = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="MyResource" + )["StackResourceDetail"] + snapshot.match("physical_resource_id", res.get("PhysicalResourceId")) diff --git a/tests/aws/services/cloudformation/resources/__init__.py b/tests/aws/services/cloudformation/resources/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/resources/handlers/handler1/api.zip b/tests/aws/services/cloudformation/resources/handlers/handler1/api.zip new file mode 100644 index 0000000000000..8f8c0f78f6257 Binary files /dev/null and b/tests/aws/services/cloudformation/resources/handlers/handler1/api.zip differ diff --git a/tests/aws/services/cloudformation/resources/handlers/handler2/api.zip b/tests/aws/services/cloudformation/resources/handlers/handler2/api.zip new file mode 100644 index 0000000000000..f45beec4a069f Binary files /dev/null and b/tests/aws/services/cloudformation/resources/handlers/handler2/api.zip differ diff --git a/tests/aws/services/cloudformation/resources/test_acm.py b/tests/aws/services/cloudformation/resources/test_acm.py new file mode 100644 index 0000000000000..4d5ea08b7358d --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_acm.py @@ -0,0 +1,27 @@ +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + +TEST_TEMPLATE = """ +Resources: + cert1: + Type: "AWS::CertificateManager::Certificate" + Properties: + DomainName: "{{domain}}" + DomainValidationOptions: + - DomainName: "{{domain}}" + HostedZoneId: zone123 # using dummy ID for now + ValidationMethod: DNS +Outputs: + Cert: + Value: !Ref cert1 +""" + + +@markers.aws.only_localstack +def test_cfn_acm_certificate(deploy_cfn_template, aws_client): + domain = f"domain-{short_uid()}.com" + deploy_cfn_template(template=TEST_TEMPLATE, template_mapping={"domain": domain}) + + result = aws_client.acm.list_certificates()["CertificateSummaryList"] + result = [cert for cert in result if cert["DomainName"] == domain] + assert result diff --git a/tests/aws/services/cloudformation/resources/test_apigateway.py b/tests/aws/services/cloudformation/resources/test_apigateway.py new file mode 100644 index 0000000000000..7f6f74a95923a --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_apigateway.py @@ -0,0 +1,726 @@ +import json +import os.path +from operator import itemgetter + +import requests +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack import constants +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid +from localstack.utils.files import load_file +from localstack.utils.run import to_str +from localstack.utils.strings import to_bytes +from localstack.utils.sync import retry +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url + +PARENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEST_LAMBDA_PYTHON_ECHO = os.path.join(PARENT_DIR, "lambda_/functions/lambda_echo.py") + +TEST_TEMPLATE_1 = """ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Parameters: + ApiName: + Type: String + IntegrationUri: + Type: String +Resources: + Api: + Type: AWS::Serverless::Api + Properties: + StageName: dev + Name: !Ref ApiName + DefinitionBody: + swagger: 2.0 + info: + version: "1.0" + title: "Public API" + basePath: /base + schemes: + - "https" + x-amazon-apigateway-binary-media-types: + - "*/*" + paths: + /test: + post: + responses: {} + x-amazon-apigateway-integration: + uri: !Ref IntegrationUri + httpMethod: "POST" + type: "http_proxy" +""" + + +# this is an `only_localstack` test because it makes use of _custom_id_ tag +@markers.aws.only_localstack +def test_cfn_apigateway_aws_integration(deploy_cfn_template, aws_client): + api_name = f"rest-api-{short_uid()}" + custom_id = short_uid() + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/apigw-awsintegration-request-parameters.yaml", + ), + parameters={ + "ApiName": api_name, + "CustomTagKey": "_custom_id_", + "CustomTagValue": custom_id, + }, + ) + + # check resources creation + apis = [ + api for api in aws_client.apigateway.get_rest_apis()["items"] if api["name"] == api_name + ] + assert len(apis) == 1 + api_id = apis[0]["id"] + + # check resources creation + resources = aws_client.apigateway.get_resources(restApiId=api_id)["items"] + assert ( + resources[0]["resourceMethods"]["GET"]["requestParameters"]["method.request.path.id"] + is False + ) + assert ( + resources[0]["resourceMethods"]["GET"]["methodIntegration"]["requestParameters"][ + "integration.request.path.object" + ] + == "method.request.path.id" + ) + + # check domains creation + domain_names = [ + domain["domainName"] for domain in aws_client.apigateway.get_domain_names()["items"] + ] + expected_domain = "cfn5632.localstack.cloud" # hardcoded value from template yaml file + assert expected_domain in domain_names + + # check basepath mappings creation + mappings = [ + mapping["basePath"] + for mapping in aws_client.apigateway.get_base_path_mappings(domainName=expected_domain)[ + "items" + ] + ] + assert len(mappings) == 1 + assert mappings[0] == "(none)" + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: not returned by LS + "$..endpointConfiguration.ipAddressType", + ] +) +def test_cfn_apigateway_swagger_import( + deploy_cfn_template, echo_http_server_post, aws_client, snapshot +): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("aws:cloudformation:stack-name"), + snapshot.transform.resource_name(), + snapshot.transform.key_value("id"), + snapshot.transform.key_value("name"), + snapshot.transform.key_value("rootResourceId"), + ] + ) + api_name = f"rest-api-{short_uid()}" + deploy_cfn_template( + template=TEST_TEMPLATE_1, + parameters={"ApiName": api_name, "IntegrationUri": echo_http_server_post}, + ) + + # get API details + apis = [ + api for api in aws_client.apigateway.get_rest_apis()["items"] if api["name"] == api_name + ] + assert len(apis) == 1 + api_id = apis[0]["id"] + snapshot.match("imported-api", apis[0]) + + # construct API endpoint URL + url = api_invoke_url(api_id, stage="dev", path="/test") + + # invoke API endpoint, assert results + def _invoke(): + _result = requests.post(url, data="test 123") + assert _result.ok + return _result + + if is_aws_cloud(): + sleep = 2 + retries = 20 + else: + sleep = 0.1 + retries = 3 + + result = retry(_invoke, sleep=sleep, retries=retries) + content = json.loads(to_str(result.content)) + assert content["data"] == "test 123" + assert content["url"].endswith("/post") + + +@markers.aws.only_localstack +def test_url_output(httpserver, deploy_cfn_template): + httpserver.expect_request("").respond_with_data(b"", 200) + api_name = f"rest-api-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway-url-output.yaml" + ), + template_mapping={ + "api_name": api_name, + "integration_uri": httpserver.url_for("/{proxy}"), + }, + ) + + assert len(stack.outputs) == 2 + api_id = stack.outputs["ApiV1IdOutput"] + api_url = stack.outputs["ApiV1UrlOutput"] + assert api_id + assert api_url + assert api_id in api_url + + assert f"https://{api_id}.execute-api.{constants.LOCALHOST_HOSTNAME}:4566" in api_url + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-method-post.methodIntegration.connectionType", # TODO: maybe because this is a MOCK integration + ] +) +def test_cfn_with_apigateway_resources(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/template35.yaml") + ) + apis = [ + api + for api in aws_client.apigateway.get_rest_apis()["items"] + if api["name"] == "celeste-Gateway-local" + ] + assert len(apis) == 1 + api_id = apis[0]["id"] + + resources = [ + res + for res in aws_client.apigateway.get_resources(restApiId=api_id)["items"] + if res.get("pathPart") == "account" + ] + + assert len(resources) == 1 + + resp = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=resources[0]["id"], httpMethod="POST" + ) + snapshot.match("get-method-post", resp) + + models = aws_client.apigateway.get_models(restApiId=api_id) + models["items"].sort(key=itemgetter("name")) + snapshot.match("get-models", models) + + schemas = [model["schema"] for model in models["items"]] + for schema in schemas: + # assert that we can JSON load the schema, and that the schema is a valid JSON + assert isinstance(json.loads(schema), dict) + + stack.destroy() + + apis = [ + api + for api in aws_client.apigateway.get_rest_apis()["items"] + if api["name"] == "celeste-Gateway-local" + ] + assert not apis + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-resources.items..resourceMethods.ANY", # TODO: empty in AWS + ] +) +def test_cfn_deploy_apigateway_models(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway_models.json" + ) + ) + + api_id = stack.outputs["RestApiId"] + + resources = aws_client.apigateway.get_resources(restApiId=api_id) + resources["items"].sort(key=itemgetter("path")) + snapshot.match("get-resources", resources) + + models = aws_client.apigateway.get_models(restApiId=api_id) + models["items"].sort(key=itemgetter("name")) + snapshot.match("get-models", models) + + request_validators = aws_client.apigateway.get_request_validators(restApiId=api_id) + snapshot.match("get-request-validators", request_validators) + + for resource in resources["items"]: + if resource["path"] == "/validated": + resp = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=resource["id"], httpMethod="ANY" + ) + snapshot.match("get-method-any", resp) + + # construct API endpoint URL + url = api_invoke_url(api_id, stage="local", path="/validated") + + # invoke API endpoint, assert results + valid_data = {"string_field": "string", "integer_field": 123456789} + + result = requests.post(url, json=valid_data) + assert result.ok + + # invoke API endpoint, assert results + invalid_data = {"string_field": "string"} + + result = requests.post(url, json=invalid_data) + assert result.status_code == 400 + + result = requests.get(url) + assert result.status_code == 400 + + +@markers.aws.validated +def test_cfn_deploy_apigateway_integration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway_integration_no_authorizer.yml" + ), + max_wait=120, + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "stack-name")) + + rest_api_id = stack.outputs["RestApiId"] + rest_api = aws_client.apigateway.get_rest_api(restApiId=rest_api_id) + snapshot.match("rest_api", rest_api) + snapshot.add_transformer(snapshot.transform.key_value("rootResourceId")) + + resource_id = stack.outputs["ResourceId"] + method = aws_client.apigateway.get_method( + restApiId=rest_api_id, resourceId=resource_id, httpMethod="GET" + ) + snapshot.match("method", method) + # TODO: snapshot the authorizer too? it's not attached to the REST API + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", # TODO: after importing, AWS returns them empty? + # TODO: missing from LS response + "$.get-stage.methodSettings", + "$.get-stage.tags", + ] +) +def test_cfn_deploy_apigateway_from_s3_swagger( + deploy_cfn_template, snapshot, aws_client, s3_bucket +): + snapshot.add_transformer(snapshot.transform.key_value("deploymentId")) + # FIXME: we need to sort the binaryMediaTypes as we don't return it in the same order as AWS, but this does not have + # behavior incidence + snapshot.add_transformer(SortingTransformer("binaryMediaTypes")) + # put the swagger file in S3 + swagger_template = load_file( + os.path.join(os.path.dirname(__file__), "../../../files/pets.json") + ) + key_name = "swagger-template-pets.json" + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body=swagger_template) + object_etag = response["ETag"] + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway_integration_from_s3.yml" + ), + parameters={ + "S3BodyBucket": s3_bucket, + "S3BodyKey": key_name, + "S3BodyETag": object_etag, + }, + max_wait=120, + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "stack-name")) + + rest_api_id = stack.outputs["RestApiId"] + rest_api = aws_client.apigateway.get_rest_api(restApiId=rest_api_id) + snapshot.match("rest-api", rest_api) + + resources = aws_client.apigateway.get_resources(restApiId=rest_api_id) + resources["items"] = sorted(resources["items"], key=itemgetter("path")) + snapshot.match("resources", resources) + + get_stage = aws_client.apigateway.get_stage(restApiId=rest_api_id, stageName="local") + snapshot.match("get-stage", get_stage) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..endpointConfiguration.ipAddressType"], +) +def test_cfn_apigateway_rest_api(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("aws:cloudformation:logical-id"), + snapshot.transform.key_value("aws:cloudformation:stack-name"), + snapshot.transform.resource_name(), + snapshot.transform.key_value("id"), + snapshot.transform.key_value("rootResourceId"), + ] + ) + + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/apigateway.json") + ) + + rs = aws_client.apigateway.get_rest_apis() + apis = [item for item in rs["items"] if item["name"] == "DemoApi_dev"] + assert not apis + + stack.destroy() + + stack_2 = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/apigateway.json"), + parameters={"Create": "True"}, + ) + rs = aws_client.apigateway.get_rest_apis() + apis = [item for item in rs["items"] if item["name"] == "DemoApi_dev"] + assert len(apis) == 1 + snapshot.match("rest-api", apis[0]) + + rs = aws_client.apigateway.get_models(restApiId=apis[0]["id"]) + assert len(rs["items"]) == 3 + + stack_2.destroy() + + rs = aws_client.apigateway.get_rest_apis() + apis = [item for item in rs["items"] if item["name"] == "DemoApi_dev"] + assert not apis + + +@markers.aws.validated +def test_account(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway_account.yml" + ) + ) + + account_info = aws_client.apigateway.get_account() + assert account_info["cloudwatchRoleArn"] == stack.outputs["RoleArn"] + + # Assert that after deletion of stack, the apigw account is not updated + stack.destroy() + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack.stack_name) + account_info = aws_client.apigateway.get_account() + assert account_info["cloudwatchRoleArn"] == stack.outputs["RoleArn"] + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..tags.'aws:cloudformation:logical-id'", + "$..tags.'aws:cloudformation:stack-id'", + "$..tags.'aws:cloudformation:stack-name'", + ] +) +def test_update_usage_plan(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("apiId"), + snapshot.transform.key_value("stage"), + snapshot.transform.key_value("id"), + snapshot.transform.key_value("name"), + snapshot.transform.key_value("aws:cloudformation:stack-name"), + snapshot.transform.resource_name(), + ] + ) + rest_api_name = f"api-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway_usage_plan.yml" + ), + parameters={"QuotaLimit": "5000", "RestApiName": rest_api_name, "TagValue": "value1"}, + ) + + usage_plan = aws_client.apigateway.get_usage_plan(usagePlanId=stack.outputs["UsagePlanId"]) + snapshot.match("usage-plan", usage_plan) + assert usage_plan["quota"]["limit"] == 5000 + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template=load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/apigateway_usage_plan.yml") + ), + parameters={ + "QuotaLimit": "7000", + "RestApiName": rest_api_name, + "TagValue": "value-updated", + }, + ) + + usage_plan = aws_client.apigateway.get_usage_plan(usagePlanId=stack.outputs["UsagePlanId"]) + snapshot.match("updated-usage-plan", usage_plan) + assert usage_plan["quota"]["limit"] == 7000 + + +@markers.snapshot.skip_snapshot_verify( + paths=["$..createdDate", "$..description", "$..lastUpdatedDate", "$..tags"] +) +@markers.aws.validated +def test_update_apigateway_stage(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("aws:cloudformation:stack-name"), + snapshot.transform.resource_name(), + ] + ) + + api_name = f"api-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway_update_stage.yml" + ), + parameters={"RestApiName": api_name}, + ) + api_id = stack.outputs["RestApiId"] + stage = aws_client.apigateway.get_stage(stageName="dev", restApiId=api_id) + snapshot.match("created-stage", stage) + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/apigateway_update_stage.yml" + ), + parameters={ + "Description": "updated-description", + "Method": "POST", + "RestApiName": api_name, + }, + ) + # Changes to the stage or one of the methods it depends on does not trigger a redeployment + stage = aws_client.apigateway.get_stage(stageName="dev", restApiId=api_id) + snapshot.match("updated-stage", stage) + + +@markers.aws.validated +def test_api_gateway_with_policy_as_dict(deploy_cfn_template, snapshot, aws_client): + template = """ + Parameters: + RestApiName: + Type: String + Resources: + MyApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Ref RestApiName + Policy: + Version: "2012-10-17" + Statement: + - Sid: AllowInvokeAPI + Action: "*" + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Outputs: + MyApiId: + Value: !Ref MyApi + """ + + rest_api_name = f"api-{short_uid()}" + stack = deploy_cfn_template( + template=template, + parameters={"RestApiName": rest_api_name}, + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "stack-name")) + + rest_api = aws_client.apigateway.get_rest_api(restApiId=stack.outputs.get("MyApiId")) + + # note: API Gateway seems to perform double-escaping of the policy document for REST APIs, if specified as dict + policy = to_bytes(rest_api["policy"]).decode("unicode_escape") + rest_api["policy"] = json.loads(policy) + + snapshot.match("rest-api", rest_api) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.put-ssm-param.Tier", + "$.get-resources.items..resourceMethods.GET", + "$.get-resources.items..resourceMethods.OPTIONS", + "$..methodIntegration.cacheNamespace", + "$.get-authorizers.items..authorizerResultTtlInSeconds", + ] +) +def test_rest_api_serverless_ref_resolving( + deploy_cfn_template, snapshot, aws_client, create_parameter, create_lambda_function +): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.resource_name(), + snapshot.transform.key_value("cacheNamespace"), + snapshot.transform.key_value("uri"), + snapshot.transform.key_value("authorizerUri"), + ] + ) + create_parameter(Name="/test-stack/testssm/random-value", Value="x-test-header", Type="String") + + fn_name = f"test-{short_uid()}" + lambda_authorizer = create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + ) + + create_parameter( + Name="/test-stack/testssm/lambda-arn", + Value=lambda_authorizer["CreateFunctionResponse"]["FunctionArn"], + Type="String", + ) + + stack = deploy_cfn_template( + template=load_file( + os.path.join( + os.path.dirname(__file__), + "../../../templates/apigateway_serverless_api_resolving.yml", + ) + ), + parameters={"AllowedOrigin": "http://localhost:8000"}, + ) + rest_api_id = stack.outputs.get("ApiGatewayApiId") + + resources = aws_client.apigateway.get_resources(restApiId=rest_api_id) + snapshot.match("get-resources", resources) + + authorizers = aws_client.apigateway.get_authorizers(restApiId=rest_api_id) + snapshot.match("get-authorizers", authorizers) + + root_resource = resources["items"][0] + + for http_method in root_resource["resourceMethods"]: + method = aws_client.apigateway.get_method( + restApiId=rest_api_id, resourceId=root_resource["id"], httpMethod=http_method + ) + snapshot.match(f"get-method-{http_method}", method) + + +class TestServerlessApigwLambda: + @markers.aws.validated + def test_serverless_like_deployment_with_update( + self, deploy_cfn_template, aws_client, cleanups + ): + """ + Regression test for serverless. Since adding a delete handler for the "AWS::ApiGateway::Deployment" resource, + the update was failing due to the delete raising an Exception because of a still connected Stage. + + This test recreates a simple recreated deployment procedure as done by "serverless" where + `serverless deploy` actually both creates a stack and then immediately updates it. + The second UpdateStack is then caused by another `serverless deploy`, e.g. when changing the lambda configuration + """ + + # 1. deploy create + template_content = load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/serverless-apigw-lambda.create.json" + ) + ) + stack_name = f"slsstack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template_content, + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack["StackId"] + ) + + # 2. update first + # get deployed bucket name + outputs = aws_client.cloudformation.describe_stacks(StackName=stack["StackId"])["Stacks"][ + 0 + ]["Outputs"] + outputs = {k["OutputKey"]: k["OutputValue"] for k in outputs} + bucket_name = outputs["ServerlessDeploymentBucketName"] + + # upload zip file to s3 bucket + # "serverless/test-service/local/1708076358388-2024-02-16T09:39:18.388Z/api.zip" + handler1_filename = os.path.join(os.path.dirname(__file__), "handlers/handler1/api.zip") + aws_client.s3.upload_file( + Filename=handler1_filename, + Bucket=bucket_name, + Key="serverless/test-service/local/1708076358388-2024-02-16T09:39:18.388Z/api.zip", + ) + + template_content = load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/serverless-apigw-lambda.update.json" + ) + ) + stack = aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template_content, + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack["StackId"] + ) + + get_fn_1 = aws_client.lambda_.get_function(FunctionName="test-service-local-api") + assert get_fn_1["Configuration"]["Handler"] == "index.handler" + + # # 3. update second + # # upload zip file to s3 bucket + handler2_filename = os.path.join(os.path.dirname(__file__), "handlers/handler2/api.zip") + aws_client.s3.upload_file( + Filename=handler2_filename, + Bucket=bucket_name, + Key="serverless/test-service/local/1708076568092-2024-02-16T09:42:48.092Z/api.zip", + ) + + template_content = load_file( + os.path.join( + os.path.dirname(__file__), "../../../templates/serverless-apigw-lambda.update2.json" + ) + ) + stack = aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template_content, + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack["StackId"] + ) + get_fn_2 = aws_client.lambda_.get_function(FunctionName="test-service-local-api") + assert get_fn_2["Configuration"]["Handler"] == "index.handler2" diff --git a/tests/aws/services/cloudformation/resources/test_apigateway.snapshot.json b/tests/aws/services/cloudformation/resources/test_apigateway.snapshot.json new file mode 100644 index 0000000000000..7eb23ef4bd8b7 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_apigateway.snapshot.json @@ -0,0 +1,738 @@ +{ + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_integration": { + "recorded-date": "15-07-2025, 19:28:45", + "recorded-content": { + "rest_api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent,X-Amzn-Trace-Id'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,GET,POST'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP_PROXY", + "uri": "http://www.example.com" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": true, + "method.response.header.Access-Control-Allow-Methods": true, + "method.response.header.Access-Control-Allow-Origin": true + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_api_gateway_with_policy_as_dict": { + "recorded-date": "15-07-2025, 19:30:54", + "recorded-content": { + "rest-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "policy": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Resource": "*", + "Sid": "AllowInvokeAPI" + } + ], + "Version": "2012-10-17" + }, + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "MyApi", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_from_s3_swagger": { + "recorded-date": "15-07-2025, 20:30:26", + "recorded-content": { + "rest-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "application/pdf", + "image/gif", + "image/jpg", + "image/png" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "REGIONAL" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "ApiGatewayRestApi", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "version": "1.0.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets/{petId}", + "pathPart": "{petId}", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "Test Stage 123", + "lastUpdatedDate": "datetime", + "methodSettings": { + "*/*": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": false, + "dataTraceEnabled": true, + "loggingLevel": "ERROR", + "metricsEnabled": true, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "stageName": "local", + "tags": { + "aws:cloudformation:logical-id": "ApiGWStage", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "tracingEnabled": true, + "variables": { + "TestCasing": "myvar", + "testCasingTwo": "myvar2", + "testlowcasing": "myvar3" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_models": { + "recorded-date": "21-06-2024, 00:09:05", + "recorded-content": { + "get-resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/validated", + "pathPart": "validated", + "resourceMethods": { + "ANY": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "description": "This is a default empty schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "This is a default error schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "", + "type": "object", + "properties": { + "integer_field": { + "type": "number" + }, + "string_field": { + "type": "string" + } + }, + "required": [ + "string_field", + "integer_field" + ] + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-request-validators": { + "items": [ + { + "id": "", + "name": "", + "validateRequestBody": true, + "validateRequestParameters": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-method-any": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "NEVER", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "requestModels": { + "application/json": "" + }, + "requestValidatorId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_with_apigateway_resources": { + "recorded-date": "20-06-2024, 23:54:26", + "recorded-content": { + "get-method-post": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "202": { + "responseTemplates": { + "application/json": { + "operation": "celeste_account_create", + "data": { + "key": "123e4567-e89b-12d3-a456-426614174000", + "secret": "123e4567-e89b-12d3-a456-426614174000" + } + } + }, + "selectionPattern": "2\\d{2}", + "statusCode": "202" + }, + "404": { + "responseTemplates": { + "application/json": { + "message": "Not Found" + } + }, + "selectionPattern": "404", + "statusCode": "404" + }, + "500": { + "responseTemplates": { + "application/json": { + "message": "Unknown " + } + }, + "selectionPattern": "5\\d{2}", + "statusCode": "500" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": "" + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "202": { + "responseModels": { + "application/json": "" + }, + "statusCode": "202" + }, + "500": { + "responseModels": { + "application/json": "" + }, + "statusCode": "500" + } + }, + "operationName": "create_account", + "requestParameters": { + "method.request.path.account": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "description": "This is a default empty schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "This is a default error schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "AccountCreate", + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "", + "schema": {} + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_rest_api_serverless_ref_resolving": { + "recorded-date": "06-07-2023, 21:01:08", + "recorded-content": { + "get-resources": { + "items": [ + { + "id": "", + "path": "/", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-authorizers": { + "items": [ + { + "authType": "custom", + "authorizerUri": "", + "id": "", + "identitySource": "method.request.header.Authorization", + "name": "", + "type": "TOKEN" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-method-GET": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-method-OPTIONS": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Credentials": "'true'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,Authorization,x-test-header'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET,PUT'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8000'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Credentials": false, + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_update_usage_plan": { + "recorded-date": "13-09-2024, 09:57:21", + "recorded-content": { + "usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "" + } + ], + "id": "", + "name": "", + "quota": { + "limit": 5000, + "offset": 0, + "period": "MONTH" + }, + "tags": { + "aws:cloudformation:logical-id": "UsagePlan", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "test": "value1", + "test2": "hardcoded" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated-usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "" + } + ], + "id": "", + "name": "", + "quota": { + "limit": 7000, + "offset": 0, + "period": "MONTH" + }, + "tags": { + "aws:cloudformation:logical-id": "UsagePlan", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "test": "value-updated", + "test2": "hardcoded" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_update_apigateway_stage": { + "recorded-date": "07-11-2024, 05:35:20", + "recorded-content": { + "created-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tags": { + "aws:cloudformation:logical-id": "Stage", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tags": { + "aws:cloudformation:logical-id": "Stage", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_apigateway_swagger_import": { + "recorded-date": "05-05-2025, 14:23:13", + "recorded-content": { + "imported-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "*/*" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "Api", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "version": "1.0" + } + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_apigateway_rest_api": { + "recorded-date": "05-05-2025, 14:50:14", + "recorded-content": { + "rest-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/jpg", + "image/png" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "DemoApi_dev", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_apigateway.validation.json b/tests/aws/services/cloudformation/resources/test_apigateway.validation.json new file mode 100644 index 0000000000000..1391ba7db52ff --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_apigateway.validation.json @@ -0,0 +1,53 @@ +{ + "tests/aws/services/cloudformation/resources/test_apigateway.py::TestServerlessApigwLambda::test_serverless_like_deployment_with_update": { + "last_validated_date": "2024-02-19T08:55:12+00:00" + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_api_gateway_with_policy_as_dict": { + "last_validated_date": "2025-07-15T19:30:59+00:00", + "durations_in_seconds": { + "setup": 0.47, + "call": 11.81, + "teardown": 4.71, + "total": 16.99 + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_apigateway_rest_api": { + "last_validated_date": "2025-05-05T14:50:14+00:00" + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_apigateway_swagger_import": { + "last_validated_date": "2025-05-05T14:23:13+00:00" + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_from_s3_swagger": { + "last_validated_date": "2025-07-15T20:30:33+00:00", + "durations_in_seconds": { + "setup": 1.02, + "call": 18.79, + "teardown": 8.1, + "total": 27.91 + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_integration": { + "last_validated_date": "2025-07-15T19:28:58+00:00", + "durations_in_seconds": { + "setup": 0.46, + "call": 26.77, + "teardown": 13.21, + "total": 40.44 + } + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_deploy_apigateway_models": { + "last_validated_date": "2024-06-21T00:09:05+00:00" + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_cfn_with_apigateway_resources": { + "last_validated_date": "2024-06-20T23:54:26+00:00" + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_rest_api_serverless_ref_resolving": { + "last_validated_date": "2023-07-06T19:01:08+00:00" + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_update_apigateway_stage": { + "last_validated_date": "2024-11-07T05:35:20+00:00" + }, + "tests/aws/services/cloudformation/resources/test_apigateway.py::test_update_usage_plan": { + "last_validated_date": "2024-09-13T09:57:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_cdk.py b/tests/aws/services/cloudformation/resources/test_cdk.py new file mode 100644 index 0000000000000..c4213a43be04d --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cdk.py @@ -0,0 +1,137 @@ +import os + +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + + +class TestCdkInit: + @pytest.mark.parametrize("bootstrap_version", ["10", "11", "12"]) + @markers.aws.validated + def test_cdk_bootstrap(self, deploy_cfn_template, bootstrap_version, aws_client): + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + f"../../../templates/cdk_bootstrap_v{bootstrap_version}.yaml", + ), + parameters={"FileAssetsBucketName": f"cdk-bootstrap-{short_uid()}"}, + ) + init_stack_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cdk_init_template.yaml" + ) + ) + assert init_stack_result.outputs["BootstrapVersionOutput"] == bootstrap_version + stack_res = aws_client.cloudformation.describe_stack_resources( + StackName=init_stack_result.stack_id, LogicalResourceId="CDKMetadata" + ) + assert len(stack_res["StackResources"]) == 1 + assert stack_res["StackResources"][0]["LogicalResourceId"] == "CDKMetadata" + + @markers.aws.validated + def test_cdk_bootstrap_redeploy(self, aws_client, cleanup_stacks, cleanup_changesets, cleanups): + """Test that simulates a sequence of commands executed by CDK when running 'cdk bootstrap' twice""" + + stack_name = f"CDKToolkit-{short_uid()}" + change_set_name = f"cdk-deploy-change-set-{short_uid()}" + + def clean_resources(): + cleanup_stacks([stack_name]) + cleanup_changesets([change_set_name]) + + cleanups.append(clean_resources) + + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/cdk_bootstrap.yml") + ) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + Capabilities=["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"], + Description="CDK Changeset for execution 731ed7da-8b2d-49c6-bca3-4698b6875954", + Parameters=[ + { + "ParameterKey": "BootstrapVariant", + "ParameterValue": "AWS CDK: Default Resources", + }, + {"ParameterKey": "TrustedAccounts", "ParameterValue": ""}, + {"ParameterKey": "TrustedAccountsForLookup", "ParameterValue": ""}, + {"ParameterKey": "CloudFormationExecutionPolicies", "ParameterValue": ""}, + {"ParameterKey": "FileAssetsBucketKmsKeyId", "ParameterValue": "AWS_MANAGED_KEY"}, + {"ParameterKey": "PublicAccessBlockConfiguration", "ParameterValue": "true"}, + {"ParameterKey": "Qualifier", "ParameterValue": "hnb659fds"}, + {"ParameterKey": "UseExamplePermissionsBoundary", "ParameterValue": "false"}, + ], + ) + aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=change_set_name + ) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=change_set_name + ) + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=change_set_name + ) + + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + aws_client.cloudformation.describe_stacks(StackName=stack_name) + + # When CDK toolstrap command is executed again it just confirms that the template is the same + aws_client.sts.get_caller_identity() + aws_client.cloudformation.get_template(StackName=stack_name, TemplateStage="Original") + + # TODO: create scenario where the template is different to catch cdk behavior + + +class TestCdkSampleApp: + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.Policy.Statement..Condition", + "$..Attributes.Policy.Statement..Resource", + "$..StackResourceSummaries..PhysicalResourceId", + ] + ) + @markers.aws.validated + def test_cdk_sample(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer( + SortingTransformer("StackResourceSummaries", lambda x: x["LogicalResourceId"]), + priority=-1, + ) + + deploy = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_cdk_sample_app.yaml" + ), + max_wait=120, + ) + + queue_url = deploy.outputs["QueueUrl"] + + queue_attr_policy = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["Policy"] + ) + snapshot.match("queue_attr_policy", queue_attr_policy) + stack_resources = aws_client.cloudformation.list_stack_resources(StackName=deploy.stack_id) + snapshot.match("stack_resources", stack_resources) + + # physical resource id of the queue policy AWS::SQS::QueuePolicy + queue_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deploy.stack_id, LogicalResourceId="CdksampleQueuePolicyFA91005A" + ) + snapshot.add_transformer( + snapshot.transform.regex( + queue_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + # TODO: make sure phys id of the resource conforms to this format: stack-d98dcad5-CdksampleQueuePolicyFA91005A-1WYVV4PMCWOYI diff --git a/tests/aws/services/cloudformation/resources/test_cdk.snapshot.json b/tests/aws/services/cloudformation/resources/test_cdk.snapshot.json new file mode 100644 index 0000000000000..cbc013cee54ce --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cdk.snapshot.json @@ -0,0 +1,81 @@ +{ + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkSampleApp::test_cdk_sample": { + "recorded-date": "04-11-2022, 15:15:44", + "recorded-content": { + "queue_attr_policy": { + "Attributes": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Action": "sqs:SendMessage", + "Resource": "arn::sqs::111111111111:", + "Condition": { + "ArnEquals": { + "aws:SourceArn": "arn::sns::111111111111:" + } + } + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResourceSummaries": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleQueue3139C8CD", + "PhysicalResourceId": "https://sqs..amazonaws.com/111111111111/", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SQS::Queue" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleQueueCdksampleStackCdksampleTopicCB3FDFDDC0BCF47C", + "PhysicalResourceId": "arn::sns::111111111111::", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Subscription" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleQueuePolicyFA91005A", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SQS::QueuePolicy" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleTopic7AD235A4", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_cdk.validation.json b/tests/aws/services/cloudformation/resources/test_cdk.validation.json new file mode 100644 index 0000000000000..b627e80340018 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cdk.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[10]": { + "last_validated_date": "2024-06-25T18:37:34+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[11]": { + "last_validated_date": "2024-06-25T18:40:57+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[12]": { + "last_validated_date": "2024-06-25T18:44:21+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkSampleApp::test_cdk_sample": { + "last_validated_date": "2022-11-04T14:15:44+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_cloudformation.py b/tests/aws/services/cloudformation/resources/test_cloudformation.py new file mode 100644 index 0000000000000..3d5a63d06ee7f --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cloudformation.py @@ -0,0 +1,127 @@ +import logging +import os +import textwrap +import time +import uuid +from threading import Thread +from typing import TYPE_CHECKING + +import requests + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +if TYPE_CHECKING: + try: + from mypy_boto3_ssm import SSMClient + except ImportError: + pass + +LOG = logging.getLogger(__name__) + +PARAMETER_NAME = "wait-handle-url" + + +class SignalSuccess(Thread): + def __init__(self, client: "SSMClient"): + Thread.__init__(self) + self.client = client + self.session = requests.Session() + self.should_break = False + + def run(self): + while not self.should_break: + try: + LOG.debug("fetching parameter") + res = self.client.get_parameter(Name=PARAMETER_NAME) + url = res["Parameter"]["Value"] + LOG.info("signalling url %s", url) + + payload = { + "Status": "SUCCESS", + "Reason": "Wait condition reached", + "UniqueId": str(uuid.uuid4()), + "Data": "Application has completed configuration.", + } + r = self.session.put(url, json=payload) + LOG.debug("status from signalling: %s", r.status_code) + r.raise_for_status() + LOG.debug("status signalled") + break + except self.client.exceptions.ParameterNotFound: + LOG.warning("parameter not available, trying again") + time.sleep(5) + except Exception: + LOG.exception("got python exception") + raise + + def stop(self): + self.should_break = True + + +@markers.snapshot.skip_snapshot_verify(paths=["$..WaitConditionName"]) +@markers.aws.validated +def test_waitcondition(deploy_cfn_template, snapshot, aws_client): + """ + Complicated test, since we have a wait condition that must signal + a successful value to before the stack finishes. We use the + fact that CFn will deploy the SSM parameter before moving on + to the wait condition itself, so in a background thread we + try to set the value to success so that the stack will + deploy correctly. + """ + signal_thread = SignalSuccess(aws_client.ssm) + signal_thread.daemon = True + signal_thread.start() + + try: + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_waitcondition.yaml" + ), + parameters={"ParameterName": PARAMETER_NAME}, + ) + finally: + signal_thread.stop() + + wait_handle_id = stack.outputs["WaitHandleId"] + wait_condition_name = stack.outputs["WaitConditionRef"] + + # TODO: more stringent tests + assert wait_handle_id is not None + # snapshot.match("waithandle_ref", wait_handle_id) + snapshot.match("waitcondition_ref", {"WaitConditionName": wait_condition_name}) + + +@markers.aws.validated +def test_create_macro(deploy_cfn_template, create_lambda_function, snapshot, aws_client): + macro_name = f"macro-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(macro_name, "")) + + function_name = f"macro_lambda_{short_uid()}" + + handler_code = textwrap.dedent( + """ + def handler(event, context): + pass + """ + ) + + create_lambda_function( + func_name=function_name, + handler_file=handler_code, + runtime=Runtime.python3_12, + ) + + template_path = os.path.join(os.path.dirname(__file__), "../../../templates/macro_resource.yml") + assert os.path.isfile(template_path) + stack = deploy_cfn_template( + template_path=template_path, + parameters={ + "FunctionName": function_name, + "MacroName": macro_name, + }, + ) + + snapshot.match("stack-outputs", stack.outputs) diff --git a/tests/aws/services/cloudformation/resources/test_cloudformation.snapshot.json b/tests/aws/services/cloudformation/resources/test_cloudformation.snapshot.json new file mode 100644 index 0000000000000..5e016c7448b6f --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cloudformation.snapshot.json @@ -0,0 +1,24 @@ +{ + "tests/aws/services/cloudformation/resources/test_cloudformation.py::test_waitconditionhandle": { + "recorded-date": "17-05-2023, 15:55:08", + "recorded-content": { + "waithandle_ref": "https://cloudformation-waitcondition-.s3..amazonaws.com/arn%3Aaws%3Acloudformation%3A%3A111111111111%3Astack/stack-03ad7786/c7b3de40-f4c2-11ed-b84b-0a57ddc705d2/WaitHandle?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230517T145504Z&X-Amz-SignedHeaders=host&X-Amz-Expires=86399&X-Amz-Credential=AKIAYYGVRKE7CKDBHLUS%2F20230517%2F%2Fs3%2Faws4_request&X-Amz-Signature=3c79384f6647bd2c655ac78e6811ea0fff9b3a52a9bd751005d35f2a04f6533c" + } + }, + "tests/aws/services/cloudformation/resources/test_cloudformation.py::test_waitcondition": { + "recorded-date": "18-05-2023, 11:09:21", + "recorded-content": { + "waitcondition_ref": { + "WaitConditionName": "arn::cloudformation::111111111111:stack/stack-6cc1b50e/f9764ac0-f563-11ed-82f7-061d4a7b8a1e/WaitHandle" + } + } + }, + "tests/aws/services/cloudformation/resources/test_cloudformation.py::test_create_macro": { + "recorded-date": "09-06-2023, 14:30:11", + "recorded-content": { + "stack-outputs": { + "MacroRef": "" + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_cloudformation.validation.json b/tests/aws/services/cloudformation/resources/test_cloudformation.validation.json new file mode 100644 index 0000000000000..98c92c30904ca --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cloudformation.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/resources/test_cloudformation.py::test_create_macro": { + "last_validated_date": "2023-06-09T12:30:11+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cloudformation.py::test_waitcondition": { + "last_validated_date": "2023-05-18T09:09:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_cloudwatch.py b/tests/aws/services/cloudformation/resources/test_cloudwatch.py new file mode 100644 index 0000000000000..f445f1310dc93 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cloudwatch.py @@ -0,0 +1,110 @@ +import json +import os +import re + +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer + +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import PATTERN_ARN +from localstack.utils.strings import short_uid + + +@markers.aws.validated +def test_alarm_creation(deploy_cfn_template, snapshot): + snapshot.add_transformer(snapshot.transform.resource_name()) + alarm_name = f"alarm-{short_uid()}" + + template = json.dumps( + { + "Resources": { + "Alarm": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "AlarmName": alarm_name, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 1, + "MetricName": "Errors", + "Namespace": "AWS/Lambda", + "Period": 300, + "Statistic": "Average", + "Threshold": 1, + }, + } + }, + "Outputs": { + "AlarmName": {"Value": {"Ref": "Alarm"}}, + "AlarmArnFromAtt": {"Value": {"Fn::GetAtt": "Alarm.Arn"}}, + }, + } + ) + + outputs = deploy_cfn_template(template=template).outputs + snapshot.match("alarm_outputs", outputs) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..StateReason", + "$..StateReasonData", + "$..StateValue", + ] +) +def test_composite_alarm_creation(aws_client, deploy_cfn_template, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Region", "region-name-full")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_cw_composite_alarm.yml" + ), + ) + composite_alarm_name = stack.outputs["CompositeAlarmName"] + + def alarm_action_name_transformer(key: str, val: str): + if key == "AlarmActions" and isinstance(val, list) and len(val) == 1: + # we expect only one item in the list + value = val[0] + match = re.match(PATTERN_ARN, value) + if match: + res = match.groups()[-1] + if ":" in res: + return res.split(":")[-1] + return res + return None + + snapshot.add_transformer( + KeyValueBasedTransformer(alarm_action_name_transformer, "alarm-action-name"), + ) + response = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + snapshot.match("composite_alarm", response["CompositeAlarms"]) + + metric_alarm_name = stack.outputs["MetricAlarmName"] + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[metric_alarm_name]) + snapshot.match("metric_alarm", response["MetricAlarms"]) + + stack.destroy() + response = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + assert not response["CompositeAlarms"] + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[metric_alarm_name]) + assert not response["MetricAlarms"] + + +@markers.aws.validated +def test_alarm_ext_statistic(aws_client, deploy_cfn_template, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_cw_simple_alarm.yml" + ), + ) + alarm_name = stack.outputs["MetricAlarmName"] + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("simple_alarm", response["MetricAlarms"]) + + stack.destroy() + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + assert not response["MetricAlarms"] diff --git a/tests/aws/services/cloudformation/resources/test_cloudwatch.snapshot.json b/tests/aws/services/cloudformation/resources/test_cloudwatch.snapshot.json new file mode 100644 index 0000000000000..8a0c497877d0e --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cloudwatch.snapshot.json @@ -0,0 +1,119 @@ +{ + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_alarm_creation": { + "recorded-date": "25-09-2023, 10:28:42", + "recorded-content": { + "alarm_outputs": { + "AlarmArnFromAtt": "arn::cloudwatch::111111111111:alarm:", + "AlarmName": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_composite_alarm_creation": { + "recorded-date": "16-07-2024, 10:41:22", + "recorded-content": { + "composite_alarm": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:HighResourceUsage", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "Indicates that the system resource usage is high while no known deployment is in progress", + "AlarmName": "HighResourceUsage", + "AlarmRule": "(ALARM(HighCPUUsage) OR ALARM(HighMemoryUsage))", + "InsufficientDataActions": [], + "OKActions": [], + "StateReason": "arn::cloudwatch::111111111111:alarm:HighResourceUsage was created and its alarm rule evaluates to OK", + "StateReasonData": { + "triggeringAlarms": [ + { + "arn": "arn::cloudwatch::111111111111:alarm:HighCPUUsage", + "state": { + "value": "INSUFFICIENT_DATA", + "timestamp": "date" + } + }, + { + "arn": "arn::cloudwatch::111111111111:alarm:HighMemoryUsage", + "state": { + "value": "INSUFFICIENT_DATA", + "timestamp": "date" + } + } + ] + }, + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK", + "StateTransitionedTimestamp": "timestamp" + } + ], + "metric_alarm": [ + { + "AlarmName": "HighMemoryUsage", + "AlarmArn": "arn::cloudwatch::111111111111:alarm:HighMemoryUsage", + "AlarmDescription": "Memory usage is high", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "ActionsEnabled": true, + "OKActions": [], + "AlarmActions": [], + "InsufficientDataActions": [], + "StateValue": "INSUFFICIENT_DATA", + "StateReason": "Unchecked: Initial alarm creation", + "StateUpdatedTimestamp": "timestamp", + "MetricName": "MemoryUsage", + "Namespace": "CustomNamespace", + "Statistic": "Average", + "Dimensions": [], + "Period": 60, + "EvaluationPeriods": 1, + "Threshold": 65.0, + "ComparisonOperator": "GreaterThanThreshold", + "TreatMissingData": "breaching", + "StateTransitionedTimestamp": "timestamp" + } + ] + } + }, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_alarm_no_statistic": { + "recorded-date": "27-11-2023, 10:08:09", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_alarm_ext_statistic": { + "recorded-date": "27-11-2023, 10:09:46", + "recorded-content": { + "simple_alarm": [ + { + "AlarmName": "", + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmDescription": "uses extended statistic", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "ActionsEnabled": true, + "OKActions": [], + "AlarmActions": [], + "InsufficientDataActions": [], + "StateValue": "INSUFFICIENT_DATA", + "StateReason": "Unchecked: Initial alarm creation", + "StateUpdatedTimestamp": "timestamp", + "MetricName": "Duration", + "Namespace": "", + "ExtendedStatistic": "p99", + "Dimensions": [ + { + "Name": "FunctionName", + "Value": "my-function" + } + ], + "Period": 300, + "Unit": "Count", + "EvaluationPeriods": 3, + "DatapointsToAlarm": 3, + "Threshold": 10.0, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "ignore", + "StateTransitionedTimestamp": "timestamp" + } + ] + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_cloudwatch.validation.json b/tests/aws/services/cloudformation/resources/test_cloudwatch.validation.json new file mode 100644 index 0000000000000..73801e4f3c748 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_cloudwatch.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_alarm_creation": { + "last_validated_date": "2023-09-25T08:28:42+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_alarm_ext_statistic": { + "last_validated_date": "2023-11-27T09:09:46+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cloudwatch.py::test_composite_alarm_creation": { + "last_validated_date": "2024-07-16T10:43:30+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_dynamodb.py b/tests/aws/services/cloudformation/resources/test_dynamodb.py new file mode 100644 index 0000000000000..8aa572c62bf08 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_dynamodb.py @@ -0,0 +1,210 @@ +import os + +import aws_cdk as cdk +import pytest +from aws_cdk import aws_dynamodb as dynamodb +from aws_cdk.aws_dynamodb import BillingMode + +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import get_partition +from localstack.utils.strings import short_uid + + +@markers.aws.validated +def test_deploy_stack_with_dynamodb_table(deploy_cfn_template, aws_client, region_name): + env = "Staging" + ddb_table_name_prefix = f"ddb-table-{short_uid()}" + ddb_table_name = f"{ddb_table_name_prefix}-{env}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/deploy_template_3.yaml" + ), + parameters={"tableName": ddb_table_name_prefix, "env": env}, + ) + + assert stack.outputs["Arn"].startswith(f"arn:{get_partition(region_name)}:dynamodb") + assert f"table/{ddb_table_name}" in stack.outputs["Arn"] + assert stack.outputs["Name"] == ddb_table_name + + rs = aws_client.dynamodb.list_tables() + assert ddb_table_name in rs["TableNames"] + + stack.destroy() + + rs = aws_client.dynamodb.list_tables() + assert ddb_table_name not in rs["TableNames"] + + +@markers.aws.validated +def test_globalindex_read_write_provisioned_throughput_dynamodb_table( + deploy_cfn_template, aws_client +): + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/deploy_template_3.yaml" + ), + parameters={"tableName": "dynamodb", "env": "test"}, + ) + + response = aws_client.dynamodb.describe_table(TableName="dynamodb-test") + + if response["Table"]["ProvisionedThroughput"]: + throughput = response["Table"]["ProvisionedThroughput"] + assert isinstance(throughput["ReadCapacityUnits"], int) + assert isinstance(throughput["WriteCapacityUnits"], int) + + for global_index in response["Table"]["GlobalSecondaryIndexes"]: + index_provisioned = global_index["ProvisionedThroughput"] + test_read_capacity = index_provisioned["ReadCapacityUnits"] + test_write_capacity = index_provisioned["WriteCapacityUnits"] + assert isinstance(test_read_capacity, int) + assert isinstance(test_write_capacity, int) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + "$..Table.DeletionProtectionEnabled", + ] +) +def test_default_name_for_table(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/dynamodb_table_defaults.yml" + ), + ) + + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + list_tags = aws_client.dynamodb.list_tags_of_resource(ResourceArn=stack.outputs["TableArn"]) + snapshot.match("list_tags_of_resource", list_tags) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + "$..Table.DeletionProtectionEnabled", + ] +) +@pytest.mark.parametrize("billing_mode", ["PROVISIONED", "PAY_PER_REQUEST"]) +def test_billing_mode_as_conditional(deploy_cfn_template, snapshot, aws_client, billing_mode): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + snapshot.add_transformer( + snapshot.transform.key_value("LatestStreamLabel", "latest-stream-label") + ) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/dynamodb_billing_conditional.yml" + ), + parameters={"BillingModeParameter": billing_mode}, + ) + + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.DeletionProtectionEnabled", + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + ] +) +def test_global_table(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/dynamodb_global_table.yml" + ), + ) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + stack.destroy() + + with pytest.raises(Exception) as ex: + aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + + error_code = ex.value.response["Error"]["Code"] + assert "ResourceNotFoundException" == error_code + + +@markers.aws.validated +def test_ttl_cdk(aws_client, snapshot, infrastructure_setup): + infra = infrastructure_setup(namespace="DDBTableTTL") + stack = cdk.Stack(infra.cdk_app, "DDBStackTTL") + + table = dynamodb.Table( + stack, + id="Table", + billing_mode=BillingMode.PAY_PER_REQUEST, + partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING), + removal_policy=cdk.RemovalPolicy.RETAIN, + time_to_live_attribute="expire_at", + ) + + cdk.CfnOutput(stack, "TableName", value=table.table_name) + + with infra.provisioner() as prov: + outputs = prov.get_stack_outputs(stack_name="DDBStackTTL") + table_name = outputs["TableName"] + table = aws_client.dynamodb.describe_time_to_live(TableName=table_name) + snapshot.match("table", table) + + +@markers.aws.validated +# We return field bellow, while AWS doesn't return them +@markers.snapshot.skip_snapshot_verify( + [ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + ] +) +def test_table_with_ttl_and_sse(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/dynamodb_table_sse_enabled.yml" + ), + ) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + snapshot.add_transformer(snapshot.transform.key_value("KMSMasterKeyArn", "kms-arn")) + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + +@markers.aws.validated +# We return field bellow, while AWS doesn't return them +@markers.snapshot.skip_snapshot_verify( + [ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + ] +) +def test_global_table_with_ttl_and_sse(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/dynamodb_global_table_sse_enabled.yml" + ), + ) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + snapshot.add_transformer(snapshot.transform.key_value("KMSMasterKeyArn", "kms-arn")) + + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) diff --git a/tests/aws/services/cloudformation/resources/test_dynamodb.snapshot.json b/tests/aws/services/cloudformation/resources/test_dynamodb.snapshot.json new file mode 100644 index 0000000000000..3f6efc6628fb0 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_dynamodb.snapshot.json @@ -0,0 +1,349 @@ +{ + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_default_name_for_table": { + "recorded-date": "28-08-2023, 12:34:19", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "keyName", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "keyName", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_of_resource": { + "Tags": [ + { + "Key": "TagKey1", + "Value": "TagValue1" + }, + { + "Key": "TagKey2", + "Value": "TagValue2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_billing_mode_as_conditional[PROVISIONED]": { + "recorded-date": "28-08-2023, 12:34:41", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_billing_mode_as_conditional[PAY_PER_REQUEST]": { + "recorded-date": "28-08-2023, 12:35:02", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_global_table": { + "recorded-date": "01-12-2023, 12:54:13", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "keyName", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "keyName", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_ttl_cdk": { + "recorded-date": "14-02-2024, 13:29:07", + "recorded-content": { + "table": { + "TimeToLiveDescription": { + "AttributeName": "expire_at", + "TimeToLiveStatus": "ENABLED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_table_with_ttl_and_sse": { + "recorded-date": "12-03-2024, 15:42:18", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "pk", + "AttributeType": "S" + }, + { + "AttributeName": "sk", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "pk", + "KeyType": "HASH" + }, + { + "AttributeName": "sk", + "KeyType": "RANGE" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + }, + "SSEDescription": { + "KMSMasterKeyArn": "", + "SSEType": "KMS", + "Status": "ENABLED" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_global_table_with_ttl_and_sse": { + "recorded-date": "12-03-2024, 15:44:36", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "gsi1pk", + "AttributeType": "S" + }, + { + "AttributeName": "gsi1sk", + "AttributeType": "S" + }, + { + "AttributeName": "pk", + "AttributeType": "S" + }, + { + "AttributeName": "sk", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "GlobalSecondaryIndexes": [ + { + "IndexArn": "arn::dynamodb::111111111111:table//index/GSI1", + "IndexName": "GSI1", + "IndexSizeBytes": 0, + "IndexStatus": "ACTIVE", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "gsi1pk", + "KeyType": "HASH" + }, + { + "AttributeName": "gsi1sk", + "KeyType": "RANGE" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + } + } + ], + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "pk", + "KeyType": "HASH" + }, + { + "AttributeName": "sk", + "KeyType": "RANGE" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "SSEDescription": { + "KMSMasterKeyArn": "", + "SSEType": "KMS", + "Status": "ENABLED" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableClassSummary": { + "TableClass": "STANDARD" + }, + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_dynamodb.validation.json b/tests/aws/services/cloudformation/resources/test_dynamodb.validation.json new file mode 100644 index 0000000000000..fc40777d4d842 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_dynamodb.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_billing_mode_as_conditional[PAY_PER_REQUEST]": { + "last_validated_date": "2023-08-28T10:35:02+00:00" + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_billing_mode_as_conditional[PROVISIONED]": { + "last_validated_date": "2023-08-28T10:34:41+00:00" + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_default_name_for_table": { + "last_validated_date": "2023-08-28T10:34:19+00:00" + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_global_table": { + "last_validated_date": "2023-12-01T11:54:13+00:00" + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_global_table_with_ttl_and_sse": { + "last_validated_date": "2024-03-12T15:44:36+00:00" + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_table_with_ttl_and_sse": { + "last_validated_date": "2024-03-12T15:42:18+00:00" + }, + "tests/aws/services/cloudformation/resources/test_dynamodb.py::test_ttl_cdk": { + "last_validated_date": "2024-02-14T13:29:07+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_ec2.py b/tests/aws/services/cloudformation/resources/test_ec2.py new file mode 100644 index 0000000000000..84928dc37c21b --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_ec2.py @@ -0,0 +1,359 @@ +import os + +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +THIS_FOLDER = os.path.dirname(__file__) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..PropagatingVgws"]) +def test_simple_route_table_creation_without_vpc(deploy_cfn_template, aws_client, snapshot): + ec2 = aws_client.ec2 + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/ec2_route_table_isolated.yaml"), + ) + + route_table_id = stack.outputs["RouteTableId"] + route_table = ec2.describe_route_tables(RouteTableIds=[route_table_id])["RouteTables"][0] + + tags = route_table.pop("Tags") + tags_dict = {tag["Key"]: tag["Value"] for tag in tags if "aws:cloudformation" not in tag["Key"]} + snapshot.match("tags", tags_dict) + + snapshot.match("route_table", route_table) + snapshot.add_transformer(snapshot.transform.key_value("VpcId", "vpc-id")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId", "vpc-id")) + + stack.destroy() + with pytest.raises(ec2.exceptions.ClientError): + ec2.describe_route_tables(RouteTableIds=[route_table_id]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..PropagatingVgws"]) +def test_simple_route_table_creation(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/ec2_route_table_simple.yaml") + ) + + route_table_id = stack.outputs["RouteTableId"] + ec2 = aws_client.ec2 + route_table = ec2.describe_route_tables(RouteTableIds=[route_table_id])["RouteTables"][0] + + tags = route_table.pop("Tags") + tags_dict = {tag["Key"]: tag["Value"] for tag in tags if "aws:cloudformation" not in tag["Key"]} + snapshot.match("tags", tags_dict) + + snapshot.match("route_table", route_table) + snapshot.add_transformer(snapshot.transform.key_value("VpcId", "vpc-id")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId", "vpc-id")) + + stack.destroy() + with pytest.raises(ec2.exceptions.ClientError): + ec2.describe_route_tables(RouteTableIds=[route_table_id]) + + +@markers.aws.validated +def test_vpc_creates_default_sg(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/ec2_vpc_default_sg.yaml") + ) + + vpc_id = result.outputs.get("VpcId") + default_sg = result.outputs.get("VpcDefaultSG") + default_acl = result.outputs.get("VpcDefaultAcl") + + assert vpc_id + assert default_sg + assert default_acl + + security_groups = aws_client.ec2.describe_security_groups(GroupIds=[default_sg])[ + "SecurityGroups" + ] + assert security_groups[0]["VpcId"] == vpc_id + + acls = aws_client.ec2.describe_network_acls(NetworkAclIds=[default_acl])["NetworkAcls"] + assert acls[0]["VpcId"] == vpc_id + + +@markers.aws.validated +def test_cfn_with_multiple_route_tables(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/template36.yaml"), + max_wait=180, + ) + vpc_id = result.outputs["VPC"] + + resp = aws_client.ec2.describe_route_tables(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]) + + # 4 route tables being created (validated against AWS): 3 in template + 1 default = 4 + assert len(resp["RouteTables"]) == 4 + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..PropagatingVgws", "$..Tags", "$..Tags..Key", "$..Tags..Value"] +) +def test_cfn_with_multiple_route_table_associations(deploy_cfn_template, aws_client, snapshot): + # TODO: stack does not deploy to AWS + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/template37.yaml") + ) + route_table_id = stack.outputs["RouteTable"] + route_table = aws_client.ec2.describe_route_tables( + Filters=[{"Name": "route-table-id", "Values": [route_table_id]}] + )["RouteTables"][0] + + snapshot.match("route_table", route_table) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableAssociationId")) + snapshot.add_transformer(snapshot.transform.key_value("SubnetId")) + snapshot.add_transformer(snapshot.transform.key_value("VpcId")) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..DriftInformation", "$..Metadata"]) +def test_internet_gateway_ref_and_attr(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/internet_gateway.yml") + ) + + response = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="Gateway" + ) + + snapshot.add_transformer(snapshot.transform.key_value("RefAttachment", "internet-gateway-ref")) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + snapshot.match("outputs", stack.outputs) + snapshot.match("description", response["StackResourceDetail"]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..OwnerId"]) +def test_dhcp_options(aws_client, deploy_cfn_template, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/dhcp_options.yml") + ) + + response = aws_client.ec2.describe_dhcp_options( + DhcpOptionsIds=[stack.outputs["RefDhcpOptions"]] + ) + snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId", "dhcp-options-id")) + snapshot.add_transformer(SortingTransformer("DhcpConfigurations", lambda x: x["Key"])) + snapshot.match("description", response["DhcpOptions"][0]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Tags", + "$..Options.AssociationDefaultRouteTableId", + "$..Options.PropagationDefaultRouteTableId", + "$..Options.TransitGatewayCidrBlocks", # an empty list returned by Moto but not by AWS + "$..Options.SecurityGroupReferencingSupport", # not supported by Moto + ] +) +def test_transit_gateway_attachment(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../templates/transit_gateway_attachment.yml") + ) + + gateway_description = aws_client.ec2.describe_transit_gateways( + TransitGatewayIds=[stack.outputs["TransitGateway"]] + ) + attachment_description = aws_client.ec2.describe_transit_gateway_attachments( + TransitGatewayAttachmentIds=[stack.outputs["Attachment"]] + ) + + snapshot.add_transformer(snapshot.transform.key_value("TransitGatewayRouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("AssociationDefaultRouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("PropagatioDefaultRouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("ResourceId")) + snapshot.add_transformer(snapshot.transform.key_value("TransitGatewayAttachmentId")) + snapshot.add_transformer(snapshot.transform.key_value("TransitGatewayId")) + + snapshot.match("attachment", attachment_description["TransitGatewayAttachments"][0]) + snapshot.match("gateway", gateway_description["TransitGateways"][0]) + + stack.destroy() + + descriptions = aws_client.ec2.describe_transit_gateways( + TransitGatewayIds=[stack.outputs["TransitGateway"]] + ) + if is_aws_cloud(): + # aws changes the state to deleted + descriptions = descriptions["TransitGateways"][0] + assert descriptions["State"] == "deleted" + else: + # moto directly deletes the transit gateway + transit_gateways_ids = [ + tgateway["TransitGatewayId"] for tgateway in descriptions["TransitGateways"] + ] + assert stack.outputs["TransitGateway"] not in transit_gateways_ids + + attachment_description = aws_client.ec2.describe_transit_gateway_attachments( + TransitGatewayAttachmentIds=[stack.outputs["Attachment"]] + )["TransitGatewayAttachments"] + assert attachment_description[0]["State"] == "deleted" + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..RouteTables..PropagatingVgws", "$..RouteTables..Tags"] +) +def test_vpc_with_route_table(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/template33.yaml") + ) + + route_id = stack.outputs["RouteTableId"] + response = aws_client.ec2.describe_route_tables(RouteTableIds=[route_id]) + + # Convert tags to dictionary for easier comparison + response["RouteTables"][0]["Tags"] = { + tag["Key"]: tag["Value"] for tag in response["RouteTables"][0]["Tags"] + } + + snapshot.match("route_table", response) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("VpcId")) + + stack.destroy() + + with pytest.raises(aws_client.ec2.exceptions.ClientError): + aws_client.ec2.describe_route_tables(RouteTableIds=[route_id]) + + +@pytest.mark.skip(reason="update doesn't change value for instancetype") +@markers.aws.validated +def test_cfn_update_ec2_instance_type(deploy_cfn_template, aws_client, cleanups): + if aws_client.cloudformation.meta.region_name not in [ + "ap-northeast-1", + "eu-central-1", + "eu-south-1", + "eu-west-1", + "eu-west-2", + "us-east-1", + ]: + pytest.skip() + + key_name = f"testkey-{short_uid()}" + aws_client.ec2.create_key_pair(KeyName=key_name) + cleanups.append(lambda: aws_client.ec2.delete_key_pair(KeyName=key_name)) + + # get alpine image id + if is_aws_cloud(): + images = aws_client.ec2.describe_images( + Filters=[ + {"Name": "name", "Values": ["alpine-3.19.0-x86_64-bios-*"]}, + {"Name": "state", "Values": ["available"]}, + ] + )["Images"] + image_id = images[0]["ImageId"] + else: + image_id = "ami-0a63f96a6a8d4d2c5" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ec2_instance.yml" + ), + parameters={"KeyName": key_name, "InstanceType": "t2.nano", "ImageId": image_id}, + ) + + instance_id = stack.outputs["InstanceId"] + instance = aws_client.ec2.describe_instances(InstanceIds=[instance_id])["Reservations"][0][ + "Instances" + ][0] + assert instance["InstanceType"] == "t2.nano" + + deploy_cfn_template( + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ec2_instance.yml" + ), + parameters={"KeyName": key_name, "InstanceType": "t2.medium", "ImageId": image_id}, + is_update=True, + ) + + instance = aws_client.ec2.describe_instances(InstanceIds=[instance_id])["Reservations"][0][ + "Instances" + ][0] + assert instance["InstanceType"] == "t2.medium" + + +@markers.aws.validated +def test_ec2_security_group_id_with_vpc(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ec2_vpc_securitygroup.yml" + ), + ) + + ec2_client = aws_client.ec2 + with_vpcid_sg_group_id = ec2_client.describe_security_groups( + Filters=[ + { + "Name": "group-id", + "Values": [stack.outputs["SGWithVpcIdGroupId"]], + }, + ] + )["SecurityGroups"][0] + without_vpcid_sg_group_id = ec2_client.describe_security_groups( + Filters=[ + { + "Name": "group-id", + "Values": [stack.outputs["SGWithoutVpcIdGroupId"]], + }, + ] + )["SecurityGroups"][0] + + snapshot.add_transformer( + snapshot.transform.regex(with_vpcid_sg_group_id["GroupId"], "") + ) + snapshot.add_transformer( + snapshot.transform.regex(without_vpcid_sg_group_id["GroupId"], "") + ) + snapshot.add_transformer( + snapshot.transform.regex( + without_vpcid_sg_group_id["GroupName"], "" + ) + ) + snapshot.match("references", stack.outputs) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # fingerprint algorithm is different but presence is ensured by CFn output implementation + "$..ImportedKeyPairFingerprint", + ], +) +def test_keypair_create_import(deploy_cfn_template, snapshot, aws_client): + imported_key_name = f"imported-key-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(imported_key_name, "")) + generated_key_name = f"generated-key-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(generated_key_name, "")) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ec2_import_keypair.yaml" + ), + parameters={"ImportedKeyName": imported_key_name, "GeneratedKeyName": generated_key_name}, + ) + + outputs = stack.outputs + # for the generated key pair, use the EC2 API to get the fingerprint and snapshot the value + key_res = aws_client.ec2.describe_key_pairs(KeyNames=[outputs["GeneratedKeyPairName"]])[ + "KeyPairs" + ][0] + snapshot.add_transformer(snapshot.transform.regex(key_res["KeyFingerprint"], "")) + + snapshot.match("outputs", outputs) diff --git a/tests/aws/services/cloudformation/resources/test_ec2.snapshot.json b/tests/aws/services/cloudformation/resources/test_ec2.snapshot.json new file mode 100644 index 0000000000000..0f42548858457 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_ec2.snapshot.json @@ -0,0 +1,303 @@ +{ + "tests/aws/services/cloudformation/resources/test_ec2.py::test_internet_gateway_ref_and_attr": { + "recorded-date": "13-02-2023, 17:13:41", + "recorded-content": { + "outputs": { + "IdAttachment": "", + "RefAttachment": "" + }, + "description": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "Gateway", + "Metadata": {}, + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::InternetGateway", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_dhcp_options": { + "recorded-date": "19-10-2023, 14:51:28", + "recorded-content": { + "description": { + "DhcpConfigurations": [ + { + "Key": "domain-name", + "Values": [ + { + "Value": "example.com" + } + ] + }, + { + "Key": "domain-name-servers", + "Values": [ + { + "Value": "AmazonProvidedDNS" + } + ] + }, + { + "Key": "netbios-name-servers", + "Values": [ + { + "Value": "10.2.5.1" + } + ] + }, + { + "Key": "netbios-node-type", + "Values": [ + { + "Value": "2" + } + ] + }, + { + "Key": "ntp-servers", + "Values": [ + { + "Value": "10.2.5.1" + } + ] + } + ], + "DhcpOptionsId": "", + "OwnerId": "111111111111", + "Tags": [ + { + "Key": "project", + "Value": "123" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "myDhcpOptions" + }, + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-698b113f" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-698b113f/d892a0f0-6eb8-11ee-ab19-0a5372e03565" + } + ] + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_transit_gateway_attachment": { + "recorded-date": "08-04-2025, 10:51:02", + "recorded-content": { + "attachment": { + "Association": { + "State": "associated", + "TransitGatewayRouteTableId": "" + }, + "CreationTime": "datetime", + "ResourceId": "", + "ResourceOwnerId": "111111111111", + "ResourceType": "vpc", + "State": "available", + "Tags": [ + { + "Key": "Name", + "Value": "example-tag" + } + ], + "TransitGatewayAttachmentId": "", + "TransitGatewayId": "", + "TransitGatewayOwnerId": "111111111111" + }, + "gateway": { + "CreationTime": "datetime", + "Description": "TGW Route Integration Test", + "Options": { + "AmazonSideAsn": 65000, + "AssociationDefaultRouteTableId": "", + "AutoAcceptSharedAttachments": "disable", + "DefaultRouteTableAssociation": "enable", + "DefaultRouteTablePropagation": "enable", + "DnsSupport": "enable", + "MulticastSupport": "disable", + "PropagationDefaultRouteTableId": "", + "SecurityGroupReferencingSupport": "disable", + "VpnEcmpSupport": "enable" + }, + "OwnerId": "111111111111", + "State": "available", + "Tags": [ + { + "Key": "Application", + "Value": "arn::cloudformation::111111111111:stack/stack-31597705/521e4e40-ecce-11ee-806c-0affc1ff51e7" + } + ], + "TransitGatewayArn": "arn::ec2::111111111111:transit-gateway/", + "TransitGatewayId": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_vpc_with_route_table": { + "recorded-date": "19-06-2024, 16:48:31", + "recorded-content": { + "route_table": { + "RouteTables": [ + { + "Associations": [], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "100.0.0.0/20", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": { + "aws:cloudformation:logical-id": "RouteTable", + "aws:cloudformation:stack-id": "", + "aws:cloudformation:stack-name": "", + "env": "production" + }, + "VpcId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_simple_route_table_creation_without_vpc": { + "recorded-date": "01-07-2024, 20:10:52", + "recorded-content": { + "tags": { + "Name": "Suspicious Route Table" + }, + "route_table": { + "Associations": [], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "10.0.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "VpcId": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_simple_route_table_creation": { + "recorded-date": "01-07-2024, 20:13:48", + "recorded-content": { + "tags": { + "Name": "Suspicious Route table" + }, + "route_table": { + "Associations": [], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "10.0.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "VpcId": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_cfn_with_multiple_route_table_associations": { + "recorded-date": "02-07-2024, 15:29:41", + "recorded-content": { + "route_table": { + "Associations": [ + { + "AssociationState": { + "State": "associated" + }, + "Main": false, + "RouteTableAssociationId": "", + "RouteTableId": "", + "SubnetId": "" + }, + { + "AssociationState": { + "State": "associated" + }, + "Main": false, + "RouteTableAssociationId": "", + "RouteTableId": "", + "SubnetId": "" + } + ], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "100.0.0.0/20", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": [ + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-2264231d/d12f4090-3887-11ef-ba9f-0e78e2279133" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "RouteTable" + }, + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-2264231d" + }, + { + "Key": "env", + "Value": "production" + } + ], + "VpcId": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_ec2_security_group_id_with_vpc": { + "recorded-date": "19-07-2024, 15:53:16", + "recorded-content": { + "references": { + "SGWithVpcIdGroupId": "", + "SGWithVpcIdRef": "", + "SGWithoutVpcIdGroupId": "", + "SGWithoutVpcIdRef": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_keypair_create_import": { + "recorded-date": "12-08-2024, 21:51:36", + "recorded-content": { + "outputs": { + "GeneratedKeyPairFingerprint": "", + "GeneratedKeyPairName": "", + "ImportedKeyPairFingerprint": "4LmcYnyBOqlloHZ5TKAxfa8BgMK2wL6WeOOTvXVdhmw=", + "ImportedKeyPairName": "" + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_ec2.validation.json b/tests/aws/services/cloudformation/resources/test_ec2.validation.json new file mode 100644 index 0000000000000..6eb9f2caf3324 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_ec2.validation.json @@ -0,0 +1,35 @@ +{ + "tests/aws/services/cloudformation/resources/test_ec2.py::test_cfn_update_ec2_instance_type": { + "last_validated_date": "2024-06-19T19:56:42+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_cfn_with_multiple_route_table_associations": { + "last_validated_date": "2024-07-02T15:29:41+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_dhcp_options": { + "last_validated_date": "2023-10-19T12:51:28+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_ec2_security_group_id_with_vpc": { + "last_validated_date": "2024-07-19T15:53:16+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_internet_gateway_ref_and_attr": { + "last_validated_date": "2023-02-13T16:13:41+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_keypair_create_import": { + "last_validated_date": "2024-08-12T21:51:36+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_simple_route_table_creation": { + "last_validated_date": "2024-07-01T20:13:48+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_simple_route_table_creation_without_vpc": { + "last_validated_date": "2024-07-01T20:10:52+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_transit_gateway_attachment": { + "last_validated_date": "2025-04-08T10:51:02+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_vpc_creates_default_sg": { + "last_validated_date": "2024-04-01T11:21:54+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ec2.py::test_vpc_with_route_table": { + "last_validated_date": "2024-06-19T16:48:31+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_elasticsearch.py b/tests/aws/services/cloudformation/resources/test_elasticsearch.py new file mode 100644 index 0000000000000..e88424fe31ae2 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_elasticsearch.py @@ -0,0 +1,45 @@ +import os + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +@markers.skip_offline +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DomainStatus.AdvancedSecurityOptions.AnonymousAuthEnabled", + "$..DomainStatus.AutoTuneOptions.State", + "$..DomainStatus.ChangeProgressDetails", + "$..DomainStatus.DomainProcessingStatus", + "$..DomainStatus.EBSOptions.VolumeSize", + "$..DomainStatus.ElasticsearchClusterConfig.DedicatedMasterCount", + "$..DomainStatus.ElasticsearchClusterConfig.InstanceCount", + "$..DomainStatus.ElasticsearchClusterConfig.ZoneAwarenessConfig", + "$..DomainStatus.ElasticsearchClusterConfig.ZoneAwarenessEnabled", + "$..DomainStatus.Endpoint", + "$..DomainStatus.ModifyingProperties", + "$..DomainStatus.Processing", + "$..DomainStatus.ServiceSoftwareOptions.CurrentVersion", + ] +) +def test_cfn_handle_elasticsearch_domain(deploy_cfn_template, aws_client, snapshot): + domain_name = f"es-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/elasticsearch_domain.yml" + ) + + deploy_cfn_template(template_path=template_path, parameters={"DomainName": domain_name}) + + rs = aws_client.es.describe_elasticsearch_domain(DomainName=domain_name) + status = rs["DomainStatus"] + snapshot.match("domain", rs) + + tags = aws_client.es.list_tags(ARN=status["ARN"])["TagList"] + snapshot.match("tags", tags) + + snapshot.add_transformer(snapshot.transform.key_value("DomainName")) + snapshot.add_transformer(snapshot.transform.key_value("Endpoint")) + snapshot.add_transformer(snapshot.transform.key_value("TLSSecurityPolicy")) + snapshot.add_transformer(snapshot.transform.key_value("CurrentVersion")) + snapshot.add_transformer(snapshot.transform.key_value("Description")) diff --git a/tests/aws/services/cloudformation/resources/test_elasticsearch.snapshot.json b/tests/aws/services/cloudformation/resources/test_elasticsearch.snapshot.json new file mode 100644 index 0000000000000..68c60ae22ea86 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_elasticsearch.snapshot.json @@ -0,0 +1,312 @@ +{ + "tests/aws/services/cloudformation/resources/test_elasticsearch.py::test_cfn_handle_elasticsearch_domain": { + "recorded-date": "02-07-2024, 17:30:21", + "recorded-content": { + "domain": { + "DomainStatus": { + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": "", + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "ENABLED" + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "ApplyingChanges", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "datetime", + "StartTime": "datetime" + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "" + }, + "DomainId": "111111111111/", + "DomainName": "", + "DomainProcessingStatus": "Creating", + "EBSOptions": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 20, + "VolumeType": "gp2" + }, + "ElasticsearchClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterCount": 3, + "DedicatedMasterEnabled": true, + "DedicatedMasterType": "m3.medium.elasticsearch", + "InstanceCount": 2, + "InstanceType": "m3.medium.elasticsearch", + "WarmEnabled": false, + "ZoneAwarenessConfig": { + "AvailabilityZoneCount": 2 + }, + "ZoneAwarenessEnabled": true + }, + "ElasticsearchVersion": "7.10", + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "search--4kyrgtn4a3gwrja6k4o7nvcrha..es.amazonaws.com", + "ModifyingProperties": [ + { + "ActiveValue": "", + "Name": "AdvancedOptions", + "PendingValue": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.AnonymousAuthDisableDate", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.AnonymousAuthEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.InternalUserDatabaseEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.JWTOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.MasterUserOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.SAMLOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.ColdStorageOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.DedicatedMasterCount", + "PendingValue": "3", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.DedicatedMasterEnabled", + "PendingValue": "true", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.DedicatedMasterType", + "PendingValue": "m3.medium.elasticsearch", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.InstanceCount", + "PendingValue": "2", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.InstanceType", + "PendingValue": "m3.medium.elasticsearch", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.MultiAZWithStandbyEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmCount", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmStorage", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmType", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.ZoneAwarenessEnabled", + "PendingValue": "true", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchVersion", + "PendingValue": "7.10", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "IPAddressType", + "PendingValue": "ipv4", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "TAGS", + "PendingValue": { + "k1": "v1", + "k2": "v2" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "DomainEndpointOptions", + "PendingValue": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "EBSOptions", + "PendingValue": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 20, + "VolumeType": "gp2" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "EncryptionAtRestOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "NodeToNodeEncryptionOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "OffPeakWindowOptions", + "PendingValue": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "SnapshotOptions", + "PendingValue": { + "AutomatedSnapshotStartHour": 0 + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "SoftwareUpdateOptions", + "PendingValue": { + "AutoSoftwareUpdateEnabled": false + }, + "ValueType": "STRINGIFIED_JSON" + } + ], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "datetime", + "Cancellable": false, + "CurrentVersion": "", + "Description": "", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "UpgradeProcessing": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tags": [ + { + "Key": "k1", + "Value": "v1" + }, + { + "Key": "k2", + "Value": "v2" + } + ] + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_elasticsearch.validation.json b/tests/aws/services/cloudformation/resources/test_elasticsearch.validation.json new file mode 100644 index 0000000000000..9d7316454a5d3 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_elasticsearch.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resources/test_elasticsearch.py::test_cfn_handle_elasticsearch_domain": { + "last_validated_date": "2024-07-02T17:30:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_events.py b/tests/aws/services/cloudformation/resources/test_events.py new file mode 100644 index 0000000000000..e8eb95e232c1f --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_events.py @@ -0,0 +1,231 @@ +import json +import logging +import os + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + +LOG = logging.getLogger(__name__) + + +@markers.aws.validated +def test_cfn_event_api_destination_resource(deploy_cfn_template, region_name, aws_client): + def _assert(expected_len): + rs = aws_client.events.list_event_buses() + event_buses = [eb for eb in rs["EventBuses"] if eb["Name"] == "my-test-bus"] + assert len(event_buses) == expected_len + rs = aws_client.events.list_connections() + connections = [con for con in rs["Connections"] if con["Name"] == "my-test-conn"] + assert len(connections) == expected_len + rs = aws_client.events.list_api_destinations() + api_destinations = [ + ad for ad in rs["ApiDestinations"] if ad["Name"] == "my-test-destination" + ] + assert len(api_destinations) == expected_len + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/events_apidestination.yml" + ), + parameters={ + "Region": region_name, + }, + ) + _assert(1) + + stack.destroy() + _assert(0) + + +@markers.aws.validated +def test_eventbus_policies(deploy_cfn_template, aws_client): + event_bus_name = f"event-bus-{short_uid()}" + + stack_response = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/eventbridge_policy.yaml" + ), + parameters={"EventBusName": event_bus_name}, + ) + + describe_response = aws_client.events.describe_event_bus(Name=event_bus_name) + policy = json.loads(describe_response["Policy"]) + assert len(policy["Statement"]) == 2 + + # verify physical resource ID creation + pol1_description = aws_client.cloudformation.describe_stack_resource( + StackName=stack_response.stack_name, LogicalResourceId="eventPolicy" + ) + pol2_description = aws_client.cloudformation.describe_stack_resource( + StackName=stack_response.stack_name, LogicalResourceId="eventPolicy2" + ) + assert ( + pol1_description["StackResourceDetail"]["PhysicalResourceId"] + != pol2_description["StackResourceDetail"]["PhysicalResourceId"] + ) + + deploy_cfn_template( + is_update=True, + stack_name=stack_response.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/eventbridge_policy_singlepolicy.yaml" + ), + parameters={"EventBusName": event_bus_name}, + ) + + describe_response = aws_client.events.describe_event_bus(Name=event_bus_name) + policy = json.loads(describe_response["Policy"]) + assert len(policy["Statement"]) == 1 + + +@markers.aws.validated +def test_eventbus_policy_statement(deploy_cfn_template, aws_client): + event_bus_name = f"event-bus-{short_uid()}" + statement_id = f"statement-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/eventbridge_policy_statement.yaml" + ), + parameters={"EventBusName": event_bus_name, "StatementId": statement_id}, + ) + + describe_response = aws_client.events.describe_event_bus(Name=event_bus_name) + policy = json.loads(describe_response["Policy"]) + assert policy["Version"] == "2012-10-17" + assert len(policy["Statement"]) == 1 + statement = policy["Statement"][0] + assert statement["Sid"] == statement_id + assert statement["Action"] == "events:PutEvents" + assert statement["Principal"] == "*" + assert statement["Effect"] == "Allow" + assert event_bus_name in statement["Resource"] + + +@markers.aws.validated +def test_event_rule_to_logs(deploy_cfn_template, aws_client): + event_rule_name = f"event-rule-{short_uid()}" + log_group_name = f"log-group-{short_uid()}" + event_bus_name = f"bus-{short_uid()}" + resource_policy_name = f"policy-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/events_loggroup.yaml" + ), + parameters={ + "EventRuleName": event_rule_name, + "LogGroupName": log_group_name, + "EventBusName": event_bus_name, + "PolicyName": resource_policy_name, + }, + ) + + log_groups = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)["logGroups"] + log_group_names = [lg["logGroupName"] for lg in log_groups] + assert log_group_name in log_group_names + + message_token = f"test-message-{short_uid()}" + resp = aws_client.events.put_events( + Entries=[ + { + "Source": "unittest", + "Resources": [], + "DetailType": "ls-detail-type", + "Detail": json.dumps({"messagetoken": message_token}), + "EventBusName": event_bus_name, + } + ] + ) + assert len(resp["Entries"]) == 1 + + wait_until( + lambda: len(aws_client.logs.describe_log_streams(logGroupName=log_group_name)["logStreams"]) + > 0, + 1.0, + 5, + "linear", + ) + log_streams = aws_client.logs.describe_log_streams(logGroupName=log_group_name)["logStreams"] + log_events = aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_streams[0]["logStreamName"] + ) + assert message_token in log_events["events"][0]["message"] + + +@markers.aws.validated +def test_event_rule_creation_without_target(deploy_cfn_template, aws_client, snapshot): + event_rule_name = f"event-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(event_rule_name, "event-rule-name")) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/events_rule_without_targets.yaml" + ), + parameters={"EventRuleName": event_rule_name}, + ) + + response = aws_client.events.describe_rule( + Name=event_rule_name, + ) + snapshot.match("describe_rule", response) + + +@markers.aws.validated +def test_cfn_event_bus_resource(deploy_cfn_template, aws_client): + def _assert(expected_len): + rs = aws_client.events.list_event_buses() + event_buses = [eb for eb in rs["EventBuses"] if eb["Name"] == "my-test-bus"] + assert len(event_buses) == expected_len + rs = aws_client.events.list_connections() + connections = [con for con in rs["Connections"] if con["Name"] == "my-test-conn"] + assert len(connections) == expected_len + + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/template31.yaml") + ) + _assert(1) + + stack.destroy() + _assert(0) + + +@markers.aws.validated +def test_rule_properties(deploy_cfn_template, aws_client, snapshot): + event_bus_name = f"events-{short_uid()}" + rule_name = f"rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(event_bus_name, "")) + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/events_rule_properties.yaml" + ), + parameters={"EventBusName": event_bus_name, "RuleName": rule_name}, + ) + + rule_id = stack.outputs["RuleWithoutNameArn"].rsplit("/")[-1] + snapshot.add_transformer(snapshot.transform.regex(rule_id, "")) + + without_bus_id = stack.outputs["RuleWithoutBusArn"].rsplit("/")[-1] + snapshot.add_transformer(snapshot.transform.regex(without_bus_id, "")) + + snapshot.match("outputs", stack.outputs) + + +@markers.aws.validated +def test_rule_pattern_transformation(aws_client, deploy_cfn_template, snapshot): + """ + The CFn provider for a rule applies a transformation to some properties. Extend this test as more properties or + situations arise. + """ + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/events_rule_pattern.yml" + ), + ) + + rule = aws_client.events.describe_rule(Name=stack.outputs["RuleName"]) + snapshot.match("rule", rule) + snapshot.add_transformer(snapshot.transform.key_value("Name")) diff --git a/tests/aws/services/cloudformation/resources/test_events.snapshot.json b/tests/aws/services/cloudformation/resources/test_events.snapshot.json new file mode 100644 index 0000000000000..5d8d88bbf3277 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_events.snapshot.json @@ -0,0 +1,70 @@ +{ + "tests/aws/services/cloudformation/resources/test_events.py::test_rule_properties": { + "recorded-date": "01-12-2023, 15:03:52", + "recorded-content": { + "outputs": { + "RuleWithNameArn": "arn::events::111111111111:rule//", + "RuleWithNameRef": "|", + "RuleWithoutBusArn": "arn::events::111111111111:rule/", + "RuleWithoutBusRef": "", + "RuleWithoutNameArn": "arn::events::111111111111:rule//", + "RuleWithoutNameRef": "|" + } + } + }, + "tests/aws/services/cloudformation/resources/test_events.py::test_rule_pattern_transformation": { + "recorded-date": "08-11-2024, 15:49:06", + "recorded-content": { + "rule": { + "Arn": "arn::events::111111111111:rule/", + "CreatedBy": "111111111111", + "EventBusName": "default", + "EventPattern": { + "detail-type": [ + "Object Created" + ], + "source": [ + "aws.s3" + ], + "detail": { + "bucket": { + "name": [ + "test-s3-bucket" + ] + }, + "object": { + "key": [ + { + "suffix": "/test.json" + } + ] + } + } + }, + "Name": "", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_events.py::test_event_rule_creation_without_target": { + "recorded-date": "22-01-2025, 14:15:04", + "recorded-content": { + "describe_rule": { + "Arn": "arn::events::111111111111:rule/event-rule-name", + "CreatedBy": "111111111111", + "EventBusName": "default", + "Name": "event-rule-name", + "ScheduleExpression": "cron(0 1 * * ? *)", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_events.validation.json b/tests/aws/services/cloudformation/resources/test_events.validation.json new file mode 100644 index 0000000000000..522c90d761786 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_events.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/resources/test_events.py::test_cfn_event_api_destination_resource": { + "last_validated_date": "2024-04-16T06:36:56+00:00" + }, + "tests/aws/services/cloudformation/resources/test_events.py::test_event_rule_creation_without_target": { + "last_validated_date": "2025-01-22T14:15:04+00:00" + }, + "tests/aws/services/cloudformation/resources/test_events.py::test_eventbus_policy_statement": { + "last_validated_date": "2024-11-14T21:46:23+00:00" + }, + "tests/aws/services/cloudformation/resources/test_events.py::test_rule_pattern_transformation": { + "last_validated_date": "2024-11-08T15:49:06+00:00" + }, + "tests/aws/services/cloudformation/resources/test_events.py::test_rule_properties": { + "last_validated_date": "2023-12-01T14:03:52+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_firehose.py b/tests/aws/services/cloudformation/resources/test_firehose.py new file mode 100644 index 0000000000000..8d48bb3851e7e --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_firehose.py @@ -0,0 +1,40 @@ +import os.path + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Destinations"]) +def test_firehose_stack_with_kinesis_as_source(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + bucket_name = f"bucket-{short_uid()}" + stream_name = f"stream-{short_uid()}" + delivery_stream_name = f"delivery-stream-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/firehose_kinesis_as_source.yaml" + ), + parameters={ + "BucketName": bucket_name, + "StreamName": stream_name, + "DeliveryStreamName": delivery_stream_name, + }, + max_wait=150, + ) + snapshot.match("outputs", stack.outputs) + + def _assert_stream_available(): + status = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=delivery_stream_name + ) + assert status["DeliveryStreamDescription"]["DeliveryStreamStatus"] == "ACTIVE" + + retry(_assert_stream_available, sleep=2, retries=15) + + response = aws_client.firehose.describe_delivery_stream(DeliveryStreamName=delivery_stream_name) + assert delivery_stream_name == response["DeliveryStreamDescription"]["DeliveryStreamName"] + snapshot.match("delivery_stream", response) diff --git a/tests/aws/services/cloudformation/resources/test_firehose.snapshot.json b/tests/aws/services/cloudformation/resources/test_firehose.snapshot.json new file mode 100644 index 0000000000000..60c18238d4a2e --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_firehose.snapshot.json @@ -0,0 +1,99 @@ +{ + "tests/aws/services/cloudformation/resources/test_firehose.py::test_firehose_stack_with_kinesis_as_source": { + "recorded-date": "14-09-2022, 11:19:29", + "recorded-content": { + "outputs": { + "deliveryStreamRef": "" + }, + "delivery_stream": { + "DeliveryStreamDescription": { + "CreateTimestamp": "timestamp", + "DeliveryStreamARN": "arn::firehose::111111111111:deliverystream/", + "DeliveryStreamName": "", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamType": "KinesisStreamAsSource", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "ExtendedS3DestinationDescription": { + "BucketARN": "arn::s3:::", + "BufferingHints": { + "IntervalInSeconds": 60, + "SizeInMBs": 64 + }, + "CloudWatchLoggingOptions": { + "Enabled": false + }, + "CompressionFormat": "UNCOMPRESSED", + "DataFormatConversionConfiguration": { + "Enabled": false + }, + "DynamicPartitioningConfiguration": { + "Enabled": true, + "RetryOptions": { + "DurationInSeconds": 300 + } + }, + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "ErrorOutputPrefix": "firehoseTest-errors/!{firehose:error-output-type}/", + "Prefix": "firehoseTest/!{partitionKeyFromQuery:s3Prefix}", + "ProcessingConfiguration": { + "Enabled": true, + "Processors": [ + { + "Parameters": [ + { + "ParameterName": "MetadataExtractionQuery", + "ParameterValue": "{s3Prefix: .tableName}" + }, + { + "ParameterName": "JsonParsingEngine", + "ParameterValue": "JQ-1.6" + } + ], + "Type": "MetadataExtraction" + } + ] + }, + "RoleARN": "arn::iam::111111111111:role/", + "S3BackupMode": "Disabled" + }, + "S3DestinationDescription": { + "BucketARN": "arn::s3:::", + "BufferingHints": { + "IntervalInSeconds": 60, + "SizeInMBs": 64 + }, + "CloudWatchLoggingOptions": { + "Enabled": false + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "ErrorOutputPrefix": "firehoseTest-errors/!{firehose:error-output-type}/", + "Prefix": "firehoseTest/!{partitionKeyFromQuery:s3Prefix}", + "RoleARN": "arn::iam::111111111111:role/" + } + } + ], + "HasMoreDestinations": false, + "Source": { + "KinesisStreamSourceDescription": { + "DeliveryStartTimestamp": "timestamp", + "KinesisStreamARN": "arn::kinesis::111111111111:stream/", + "RoleARN": "arn::iam::111111111111:role/" + } + }, + "VersionId": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_firehose.validation.json b/tests/aws/services/cloudformation/resources/test_firehose.validation.json new file mode 100644 index 0000000000000..00fb017ef9f16 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_firehose.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resources/test_firehose.py::test_firehose_stack_with_kinesis_as_source": { + "last_validated_date": "2022-09-14T09:19:29+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_integration.py b/tests/aws/services/cloudformation/resources/test_integration.py new file mode 100644 index 0000000000000..74385fbd4dc8c --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_integration.py @@ -0,0 +1,84 @@ +import json +import os + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + + +@markers.aws.validated +def test_events_sqs_sns_lambda(deploy_cfn_template, aws_client): + function_name = f"function-{short_uid()}" + queue_name = f"queue-{short_uid()}" + topic_name = f"topic-{short_uid()}" + bus_name = f"bus-{short_uid()}" + rule_name = f"function-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/integration_events_sns_sqs_lambda.yaml" + ), + parameters={ + "FunctionName": function_name, + "QueueName": queue_name, + "TopicName": topic_name, + "BusName": bus_name, + "RuleName": rule_name, + }, + ) + + assert len(stack.outputs) == 7 + lambda_name = stack.outputs["FnName"] + bus_name = stack.outputs["EventBusName"] + + topic_arn = stack.outputs["TopicArn"] + result = aws_client.sns.get_topic_attributes(TopicArn=topic_arn)["Attributes"] + assert json.loads(result.get("Policy")) == { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Resource": topic_arn, + "Sid": "0", + } + ], + "Version": "2012-10-17", + } + + # put events + aws_client.events.put_events( + Entries=[ + { + "DetailType": "test-detail-type", + "Detail": '{"app": "localstack"}', + "Source": "test-source", + "EventBusName": bus_name, + }, + ] + ) + + def _check_lambda_invocations(): + groups = aws_client.logs.describe_log_groups( + logGroupNamePrefix=f"/aws/lambda/{lambda_name}" + ) + streams = aws_client.logs.describe_log_streams( + logGroupName=groups["logGroups"][0]["logGroupName"] + ) + assert ( + 0 < len(streams) <= 2 + ) # should be 1 or 2 because of the two potentially simultaneous calls + + all_events = [] + for s in streams["logStreams"]: + events = aws_client.logs.get_log_events( + logGroupName=groups["logGroups"][0]["logGroupName"], + logStreamName=s["logStreamName"], + )["events"] + all_events.extend(events) + + assert [e for e in all_events if topic_name in e["message"]] + assert [e for e in all_events if queue_name in e["message"]] + return True + + assert wait_until(_check_lambda_invocations) diff --git a/tests/aws/services/cloudformation/resources/test_integration.validation.json b/tests/aws/services/cloudformation/resources/test_integration.validation.json new file mode 100644 index 0000000000000..f336efeb6220f --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_integration.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resources/test_integration.py::test_events_sqs_sns_lambda": { + "last_validated_date": "2024-07-02T18:43:06+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_kinesis.py b/tests/aws/services/cloudformation/resources/test_kinesis.py new file mode 100644 index 0000000000000..dc62d71b46b6f --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_kinesis.py @@ -0,0 +1,175 @@ +import json +import os + +import pytest + +from localstack import config +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..StreamDescription.StreamModeDetails"]) +def test_stream_creation(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.resource_name()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("StreamName", "stream-name"), + snapshot.transform.key_value("ShardId", "shard-id", reference_replacement=False), + snapshot.transform.key_value("EndingHashKey", "ending-hash-key"), + snapshot.transform.key_value("StartingSequenceNumber", "sequence-number"), + ] + ) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + template = json.dumps( + { + "Resources": { + "TestStream": { + "Type": "AWS::Kinesis::Stream", + "Properties": {"ShardCount": 1}, + }, + }, + "Outputs": { + "StreamNameFromRef": {"Value": {"Ref": "TestStream"}}, + "StreamArnFromAtt": {"Value": {"Fn::GetAtt": "TestStream.Arn"}}, + }, + } + ) + + stack = deploy_cfn_template(template=template) + snapshot.match("stack_output", stack.outputs) + + description = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name) + snapshot.match("resource_description", description) + + stream_name = stack.outputs.get("StreamNameFromRef") + description = aws_client.kinesis.describe_stream(StreamName=stream_name) + snapshot.match("stream_description", description) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..StreamDescription.StreamModeDetails"]) +def test_default_parameters_kinesis(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/kinesis_default.yaml" + ) + ) + + stream_name = stack.outputs["KinesisStreamName"] + rs = aws_client.kinesis.describe_stream(StreamName=stream_name) + snapshot.match("describe_stream", rs) + + snapshot.add_transformer(snapshot.transform.key_value("StreamName")) + snapshot.add_transformer(snapshot.transform.key_value("ShardId")) + snapshot.add_transformer(snapshot.transform.key_value("StartingSequenceNumber")) + + +@markers.aws.validated +def test_cfn_handle_kinesis_firehose_resources(deploy_cfn_template, aws_client): + kinesis_stream_name = f"kinesis-stream-{short_uid()}" + firehose_role_name = f"firehose-role-{short_uid()}" + firehose_stream_name = f"firehose-stream-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_kinesis_stream.yaml" + ), + parameters={ + "KinesisStreamName": kinesis_stream_name, + "DeliveryStreamName": firehose_stream_name, + "KinesisRoleName": firehose_role_name, + }, + ) + + assert len(stack.outputs) == 1 + + rs = aws_client.firehose.describe_delivery_stream(DeliveryStreamName=firehose_stream_name) + assert rs["DeliveryStreamDescription"]["DeliveryStreamARN"] == stack.outputs["MyStreamArn"] + assert rs["DeliveryStreamDescription"]["DeliveryStreamName"] == firehose_stream_name + + rs = aws_client.kinesis.describe_stream(StreamName=kinesis_stream_name) + assert rs["StreamDescription"]["StreamName"] == kinesis_stream_name + + # clean up + stack.destroy() + + rs = aws_client.kinesis.list_streams() + assert kinesis_stream_name not in rs["StreamNames"] + rs = aws_client.firehose.list_delivery_streams() + assert firehose_stream_name not in rs["DeliveryStreamNames"] + + +# TODO: use a different template and move this test to a more generic API level test suite +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify # nothing really works here right now +def test_describe_template(s3_create_bucket, aws_client, cleanups, snapshot): + bucket_name = f"b-{short_uid()}" + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/cfn_kinesis_stream.yaml") + ) + s3_create_bucket(Bucket=bucket_name) + aws_client.s3.put_object(Bucket=bucket_name, Key="template.yml", Body=template_body) + + if is_aws_cloud(): + template_url = ( + f"https://{bucket_name}.s3.{aws_client.s3.meta.region_name}.amazonaws.com/template.yml" + ) + else: + template_url = f"{config.internal_service_url()}/{bucket_name}/template.yml" + + # get summary by template URL + get_template_summary_by_url = aws_client.cloudformation.get_template_summary( + TemplateURL=template_url + ) + snapshot.match("get_template_summary_by_url", get_template_summary_by_url) + + param_keys = {p["ParameterKey"] for p in get_template_summary_by_url["Parameters"]} + assert param_keys == {"KinesisStreamName", "DeliveryStreamName", "KinesisRoleName"} + + # get summary by template body + get_template_summary_by_body = aws_client.cloudformation.get_template_summary( + TemplateBody=template_body + ) + snapshot.match("get_template_summary_by_body", get_template_summary_by_body) + param_keys = {p["ParameterKey"] for p in get_template_summary_by_url["Parameters"]} + assert param_keys == {"KinesisStreamName", "DeliveryStreamName", "KinesisRoleName"} + + +@pytest.mark.skipif( + condition=not is_aws_cloud() and config.DDB_STREAMS_PROVIDER_V2, + reason="Not yet implemented in DDB Streams V2", +) +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..KinesisDataStreamDestinations..DestinationStatusDescription"] +) +def test_dynamodb_stream_response_with_cf(deploy_cfn_template, aws_client, snapshot): + table_name = f"table-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_kinesis_dynamodb.yml" + ), + parameters={"TableName": table_name}, + ) + + response = aws_client.dynamodb.describe_kinesis_streaming_destination(TableName=table_name) + snapshot.match("describe_kinesis_streaming_destination", response) + snapshot.add_transformer(snapshot.transform.key_value("TableName")) + + +@markers.aws.validated +def test_kinesis_stream_consumer_creations(deploy_cfn_template, aws_client): + consumer_name = f"{short_uid()}" + stack = deploy_cfn_template( + parameters={"TestConsumerName": consumer_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/kinesis_stream_consumer.yaml" + ), + ) + consumer_arn = stack.outputs["KinesisSConsumerARN"] + response = aws_client.kinesis.describe_stream_consumer(ConsumerARN=consumer_arn) + assert response["ConsumerDescription"]["ConsumerStatus"] == "ACTIVE" diff --git a/tests/aws/services/cloudformation/resources/test_kinesis.snapshot.json b/tests/aws/services/cloudformation/resources/test_kinesis.snapshot.json new file mode 100644 index 0000000000000..9c1a3369a5d51 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_kinesis.snapshot.json @@ -0,0 +1,279 @@ +{ + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_stream_creation": { + "recorded-date": "12-09-2022, 14:11:29", + "recorded-content": { + "stack_output": { + "StreamArnFromAtt": "arn::kinesis::111111111111:stream/", + "StreamNameFromRef": "" + }, + "resource_description": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "TestStream", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Kinesis::Stream", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stream_description": { + "StreamDescription": { + "EncryptionType": "NONE", + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "HasMoreShards": false, + "RetentionPeriodHours": 24, + "Shards": [ + { + "HashKeyRange": { + "EndingHashKey": "", + "StartingHashKey": "0" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + }, + "ShardId": "shard-id" + } + ], + "StreamARN": "arn::kinesis::111111111111:stream/", + "StreamCreationTimestamp": "timestamp", + "StreamModeDetails": { + "StreamMode": "PROVISIONED" + }, + "StreamName": "", + "StreamStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_describe_template": { + "recorded-date": "22-05-2023, 09:25:32", + "recorded-content": { + "get_template_summary_by_url": { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CapabilitiesReason": "The following resource(s) require capabilities: [AWS::IAM::Role]", + "Parameters": [ + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisRoleName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "DeliveryStreamName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisStreamName", + "ParameterType": "String" + } + ], + "ResourceIdentifierSummaries": [ + { + "LogicalResourceIds": [ + "MyBucket" + ], + "ResourceIdentifiers": [ + "BucketName" + ], + "ResourceType": "AWS::S3::Bucket" + }, + { + "LogicalResourceIds": [ + "MyRole" + ], + "ResourceIdentifiers": [ + "RoleName" + ], + "ResourceType": "AWS::IAM::Role" + }, + { + "LogicalResourceIds": [ + "KinesisStream" + ], + "ResourceIdentifiers": [ + "Name" + ], + "ResourceType": "AWS::Kinesis::Stream" + }, + { + "LogicalResourceIds": [ + "DeliveryStream" + ], + "ResourceIdentifiers": [ + "DeliveryStreamName" + ], + "ResourceType": "AWS::KinesisFirehose::DeliveryStream" + } + ], + "ResourceTypes": [ + "AWS::Kinesis::Stream", + "AWS::IAM::Role", + "AWS::S3::Bucket", + "AWS::KinesisFirehose::DeliveryStream" + ], + "Version": "2010-09-09", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_template_summary_by_body": { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CapabilitiesReason": "The following resource(s) require capabilities: [AWS::IAM::Role]", + "Parameters": [ + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisRoleName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "DeliveryStreamName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisStreamName", + "ParameterType": "String" + } + ], + "ResourceIdentifierSummaries": [ + { + "LogicalResourceIds": [ + "MyBucket" + ], + "ResourceIdentifiers": [ + "BucketName" + ], + "ResourceType": "AWS::S3::Bucket" + }, + { + "LogicalResourceIds": [ + "MyRole" + ], + "ResourceIdentifiers": [ + "RoleName" + ], + "ResourceType": "AWS::IAM::Role" + }, + { + "LogicalResourceIds": [ + "KinesisStream" + ], + "ResourceIdentifiers": [ + "Name" + ], + "ResourceType": "AWS::Kinesis::Stream" + }, + { + "LogicalResourceIds": [ + "DeliveryStream" + ], + "ResourceIdentifiers": [ + "DeliveryStreamName" + ], + "ResourceType": "AWS::KinesisFirehose::DeliveryStream" + } + ], + "ResourceTypes": [ + "AWS::Kinesis::Stream", + "AWS::IAM::Role", + "AWS::S3::Bucket", + "AWS::KinesisFirehose::DeliveryStream" + ], + "Version": "2010-09-09", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_default_parameters_kinesis": { + "recorded-date": "02-07-2024, 18:59:10", + "recorded-content": { + "describe_stream": { + "StreamDescription": { + "EncryptionType": "NONE", + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "HasMoreShards": false, + "RetentionPeriodHours": 24, + "Shards": [ + { + "HashKeyRange": { + "EndingHashKey": "340282366920938463463374607431768211455", + "StartingHashKey": "0" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + }, + "ShardId": "" + } + ], + "StreamARN": "arn::kinesis::111111111111:stream/", + "StreamCreationTimestamp": "timestamp", + "StreamModeDetails": { + "StreamMode": "PROVISIONED" + }, + "StreamName": "", + "StreamStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_dynamodb_stream_response_with_cf": { + "recorded-date": "02-07-2024, 19:48:27", + "recorded-content": { + "describe_kinesis_streaming_destination": { + "KinesisDataStreamDestinations": [ + { + "DestinationStatus": "ACTIVE", + "StreamArn": "arn::kinesis::111111111111:stream/EventStream" + } + ], + "TableName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_kinesis.validation.json b/tests/aws/services/cloudformation/resources/test_kinesis.validation.json new file mode 100644 index 0000000000000..d776667387072 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_kinesis.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_cfn_handle_kinesis_firehose_resources": { + "last_validated_date": "2024-07-02T19:10:35+00:00" + }, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_default_parameters_kinesis": { + "last_validated_date": "2024-07-02T18:59:10+00:00" + }, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_describe_template": { + "last_validated_date": "2023-05-22T07:25:32+00:00" + }, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_dynamodb_stream_response_with_cf": { + "last_validated_date": "2024-07-02T19:48:27+00:00" + }, + "tests/aws/services/cloudformation/resources/test_kinesis.py::test_stream_creation": { + "last_validated_date": "2022-09-12T12:11:29+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_kms.py b/tests/aws/services/cloudformation/resources/test_kms.py new file mode 100644 index 0000000000000..43eac10a6fc18 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_kms.py @@ -0,0 +1,63 @@ +import os.path + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + + +@markers.aws.validated +def test_kms_key_disabled(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/kms_key_disabled.yaml" + ) + ) + + key_id = stack.outputs["KeyIdOutput"] + assert key_id + my_key = aws_client.kms.describe_key(KeyId=key_id) + assert not my_key["KeyMetadata"]["Enabled"] + + +@markers.aws.validated +def test_cfn_with_kms_resources(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("KeyAlias")) + + alias_name = f"alias/sample-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/template34.yaml"), + parameters={"AliasName": alias_name}, + max_wait=300, + ) + snapshot.match("stack-outputs", stack.outputs) + + assert stack.outputs.get("KeyAlias") == alias_name + + def _get_matching_aliases(): + aliases = aws_client.kms.list_aliases()["Aliases"] + return [alias for alias in aliases if alias["AliasName"] == alias_name] + + assert len(_get_matching_aliases()) == 1 + + stack.destroy() + + assert not _get_matching_aliases() + + +@markers.aws.validated +def test_deploy_stack_with_kms(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/cfn_kms_key.yml"), + ) + + key_id = stack.outputs["KeyId"] + + stack.destroy() + + def assert_key_deleted(): + resp = aws_client.kms.describe_key(KeyId=key_id)["KeyMetadata"] + assert resp["KeyState"] == "PendingDeletion" + + retry(assert_key_deleted, retries=5, sleep=5) diff --git a/tests/aws/services/cloudformation/resources/test_kms.snapshot.json b/tests/aws/services/cloudformation/resources/test_kms.snapshot.json new file mode 100644 index 0000000000000..02881db4ddbaa --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_kms.snapshot.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/resources/test_kms.py::test_cfn_with_kms_resources": { + "recorded-date": "29-05-2023, 15:45:17", + "recorded-content": { + "stack-outputs": { + "KeyAlias": "", + "KeyArn": "arn::kms::111111111111:key/" + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_kms.validation.json b/tests/aws/services/cloudformation/resources/test_kms.validation.json new file mode 100644 index 0000000000000..b15d0a6ab48fc --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_kms.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/resources/test_kms.py::test_cfn_with_kms_resources": { + "last_validated_date": "2023-05-29T13:45:17+00:00" + }, + "tests/aws/services/cloudformation/resources/test_kms.py::test_deploy_stack_with_kms": { + "last_validated_date": "2024-07-02T20:23:47+00:00" + }, + "tests/aws/services/cloudformation/resources/test_kms.py::test_kms_key_disabled": { + "last_validated_date": "2024-07-02T20:12:46+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_lambda.py b/tests/aws/services/cloudformation/resources/test_lambda.py new file mode 100644 index 0000000000000..f40489799615b --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_lambda.py @@ -0,0 +1,1365 @@ +import base64 +import json +import os +from io import BytesIO + +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack import config +from localstack.aws.api.lambda_ import InvocationType, Runtime, State +from localstack.testing.aws.util import in_default_partition +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import get_partition +from localstack.utils.common import short_uid +from localstack.utils.files import load_file +from localstack.utils.http import safe_requests +from localstack.utils.strings import to_bytes, to_str +from localstack.utils.sync import retry, wait_until +from localstack.utils.testutil import create_lambda_archive, get_lambda_log_events + + +@markers.aws.validated +def test_lambda_w_dynamodb_event_filter(deploy_cfn_template, aws_client): + function_name = f"test-fn-{short_uid()}" + table_name = f"ddb-tbl-{short_uid()}" + item_to_put = {"id": {"S": "test123"}, "id2": {"S": "test42"}} + item_to_put2 = {"id": {"S": "test123"}, "id2": {"S": "test67"}} + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_dynamodb_filtering.yaml" + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"eventName": ["MODIFY"]}', + }, + ) + + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put) + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put2) + + def _assert_single_lambda_call(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + assert len(events) == 1 + msg = events[0] + if not isinstance(msg, str): + msg = json.dumps(msg) + assert "MODIFY" in msg and "INSERT" not in msg + + retry(_assert_single_lambda_call, retries=30) + + +@markers.snapshot.skip_snapshot_verify( + [ + # TODO: Fix flaky ESM state mismatch upon update in LocalStack (expected Enabled, actual Disabled) + # This might be a parity issue if AWS does rolling updates (i.e., never disables the ESM upon update). + "$..EventSourceMappings..State", + ] +) +@markers.aws.validated +def test_lambda_w_dynamodb_event_filter_update(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + function_name = f"test-fn-{short_uid()}" + table_name = f"ddb-tbl-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(table_name, "")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_dynamodb_filtering.yaml" + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"eventName": ["DELETE"]}', + }, + ) + source_mappings = aws_client.lambda_.list_event_source_mappings(FunctionName=function_name) + snapshot.match("source_mappings", source_mappings) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_dynamodb_filtering.yaml" + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"eventName": ["MODIFY"]}', + }, + stack_name=stack.stack_name, + is_update=True, + ) + + source_mappings = aws_client.lambda_.list_event_source_mappings(FunctionName=function_name) + snapshot.match("updated_source_mappings", source_mappings) + + +@markers.aws.validated +def test_update_lambda_function(s3_create_bucket, deploy_cfn_template, aws_client): + function_name = f"lambda-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_function_update.yml" + ), + parameters={"Environment": "ORIGINAL", "FunctionName": function_name}, + ) + + response = aws_client.lambda_.get_function(FunctionName=function_name) + assert response["Configuration"]["Environment"]["Variables"]["TEST"] == "ORIGINAL" + + deploy_cfn_template( + stack_name=stack.stack_name, + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_function_update.yml" + ), + parameters={"Environment": "UPDATED", "FunctionName": function_name}, + ) + + response = aws_client.lambda_.get_function(FunctionName=function_name) + assert response["Configuration"]["Environment"]["Variables"]["TEST"] == "UPDATED" + + +@markers.aws.validated +def test_update_lambda_function_name(s3_create_bucket, deploy_cfn_template, aws_client): + function_name_1 = f"lambda-{short_uid()}" + function_name_2 = f"lambda-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_function_update.yml" + ), + parameters={"FunctionName": function_name_1}, + ) + + function_name = stack.outputs["LambdaName"] + response = aws_client.lambda_.get_function(FunctionName=function_name_1) + assert response["Configuration"]["Environment"]["Variables"]["TEST"] == "ORIGINAL" + + deploy_cfn_template( + stack_name=stack.stack_name, + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_function_update.yml" + ), + parameters={"FunctionName": function_name_2}, + ) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_function(FunctionName=function_name) + + aws_client.lambda_.get_function(FunctionName=function_name_2) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Metadata", + "$..DriftInformation", + "$..Type", + "$..Message", + "$..access-control-allow-headers", + "$..access-control-allow-methods", + "$..access-control-allow-origin", + "$..access-control-expose-headers", + "$..server", + "$..content-length", + "$..InvokeMode", + ] +) +@markers.aws.validated +def test_cfn_function_url(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + + deploy = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/lambda_url.yaml") + ) + + url_logical_resource_id = "UrlD4FAABD0" + snapshot.add_transformer( + snapshot.transform.regex(url_logical_resource_id, "") + ) + snapshot.add_transformer( + snapshot.transform.key_value( + "FunctionUrl", + ) + ) + snapshot.add_transformer( + snapshot.transform.key_value("x-amzn-trace-id", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.key_value("date", reference_replacement=False)) + + url_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deploy.stack_name, LogicalResourceId=url_logical_resource_id + ) + snapshot.match("url_resource", url_resource) + + url_config = aws_client.lambda_.get_function_url_config( + FunctionName=deploy.outputs["LambdaName"] + ) + snapshot.match("url_config", url_config) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_function_url_config( + FunctionName=deploy.outputs["LambdaName"], Qualifier="unknownalias" + ) + + snapshot.match("exception_url_config_nonexistent_version", e.value.response) + + url_config_arn = aws_client.lambda_.get_function_url_config( + FunctionName=deploy.outputs["LambdaArn"] + ) + snapshot.match("url_config_arn", url_config_arn) + + response = safe_requests.get(deploy.outputs["LambdaUrl"]) + assert response.ok + assert response.json() == {"hello": "world"} + + lowered_headers = {k.lower(): v for k, v in response.headers.items()} + snapshot.match("response_headers", lowered_headers) + + +@markers.aws.validated +def test_lambda_alias(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda x: x["LogicalResourceId"]), priority=-1 + ) + + function_name = f"function{short_uid()}" + alias_name = f"alias{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(alias_name, "")) + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_alias.yml" + ), + parameters={"FunctionName": function_name, "AliasName": alias_name}, + ) + + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=alias_name, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + + role_arn = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"]["Role"] + snapshot.add_transformer( + snapshot.transform.regex(role_arn.partition("role/")[-1], ""), priority=-1 + ) + + description = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_name + ) + snapshot.match("stack_resource_descriptions", description) + + alias = aws_client.lambda_.get_alias(FunctionName=function_name, Name=alias_name) + snapshot.match("Alias", alias) + + provisioned_concurrency_config = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=alias_name, + ) + snapshot.match("provisioned_concurrency_config", provisioned_concurrency_config) + + +@markers.aws.validated +def test_lambda_logging_config(deploy_cfn_template, snapshot, aws_client): + function_name = f"function{short_uid()}" + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(SortingTransformer("StackResources", lambda x: x["LogicalResourceId"])) + snapshot.add_transformer( + snapshot.transform.key_value("LogicalResourceId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PhysicalResourceId", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_logging_config.yaml" + ), + parameters={"FunctionName": function_name}, + ) + + description = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_name + ) + snapshot.match("stack_resource_descriptions", description) + + logging_config = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"][ + "LoggingConfig" + ] + snapshot.match("logging_config", logging_config) + + +@pytest.mark.skipif( + not in_default_partition(), reason="Test not applicable in non-default partitions" +) +@markers.aws.validated +def test_lambda_code_signing_config(deploy_cfn_template, snapshot, account_id, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(SortingTransformer("StackResources", lambda x: x["LogicalResourceId"])) + + signer_arn = f"arn:{get_partition(aws_client.lambda_.meta.region_name)}:signer:{aws_client.lambda_.meta.region_name}:{account_id}:/signing-profiles/test" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_code_signing_config.yml" + ), + parameters={"SignerArn": signer_arn}, + ) + + description = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name) + snapshot.match("stack_resource_descriptions", description) + + snapshot.match( + "config", + aws_client.lambda_.get_code_signing_config(CodeSigningConfigArn=stack.outputs["Arn"]), + ) + + +@markers.aws.validated +def test_event_invoke_config(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_event_invoke_config.yml" + ), + max_wait=180, + ) + + event_invoke_config = aws_client.lambda_.get_function_event_invoke_config( + FunctionName=stack.outputs["FunctionName"], + Qualifier=stack.outputs["FunctionQualifier"], + ) + + snapshot.match("event_invoke_config", event_invoke_config) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda ZIP flaky in CI + "$..CodeSize", + ] +) +@markers.aws.validated +def test_lambda_version(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]) + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_version.yaml" + ), + max_wait=180, + ) + function_name = deployment.outputs["FunctionName"] + function_version = deployment.outputs["FunctionVersion"] + + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=function_version, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + snapshot.match("stack_resources", stack_resources) + + versions_by_fn = aws_client.lambda_.list_versions_by_function(FunctionName=function_name) + get_function_version = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=function_version + ) + + snapshot.match("versions_by_fn", versions_by_fn) + snapshot.match("get_function_version", get_function_version) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda ZIP flaky in CI + "$..CodeSize", + ] +) +@markers.aws.validated +def test_lambda_version_provisioned_concurrency(deploy_cfn_template, snapshot, aws_client): + """Provisioned concurrency slows down the test case considerably (~2min 40s on AWS) + because CloudFormation waits until the provisioned Lambda functions are ready. + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]) + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_lambda_version_provisioned_concurrency.yaml", + ), + max_wait=240, + ) + function_name = deployment.outputs["FunctionName"] + function_version = deployment.outputs["FunctionVersion"] + + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=function_version, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + snapshot.match("stack_resources", stack_resources) + + versions_by_fn = aws_client.lambda_.list_versions_by_function(FunctionName=function_name) + get_function_version = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=function_version + ) + + snapshot.match("versions_by_fn", versions_by_fn) + snapshot.match("get_function_version", get_function_version) + + provisioned_concurrency_config = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ) + snapshot.match("provisioned_concurrency_config", provisioned_concurrency_config) + + +@markers.aws.validated +def test_lambda_cfn_run(deploy_cfn_template, aws_client): + """ + simply deploys a lambda and immediately invokes it + """ + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_simple.yaml" + ), + max_wait=120, + ) + fn_name = deployment.outputs["FunctionName"] + assert ( + aws_client.lambda_.get_function(FunctionName=fn_name)["Configuration"]["State"] + == State.Active + ) + aws_client.lambda_.invoke(FunctionName=fn_name, LogType="Tail", Payload=b"{}") + + +@markers.aws.only_localstack(reason="This is functionality specific to Localstack") +def test_lambda_cfn_run_with_empty_string_replacement_deny_list( + deploy_cfn_template, aws_client, monkeypatch +): + """ + deploys the same lambda with an empty CFN string deny list, testing that it behaves as expected + (i.e. the URLs in the deny list are modified) + """ + monkeypatch.setattr(config, "CFN_STRING_REPLACEMENT_DENY_LIST", []) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_lambda_with_external_api_paths_in_env_vars.yaml", + ), + max_wait=120, + ) + function = aws_client.lambda_.get_function(FunctionName=deployment.outputs["FunctionName"]) + function_env_variables = function["Configuration"]["Environment"]["Variables"] + # URLs that match regex to capture AWS URLs gets Localstack port appended - non-matching URLs remain unchanged. + assert function_env_variables["API_URL_1"] == "https://api.example.com" + assert ( + function_env_variables["API_URL_2"] + == "https://storage.execute-api.amazonaws.com:4566/test-resource" + ) + assert ( + function_env_variables["API_URL_3"] + == "https://reporting.execute-api.amazonaws.com:4566/test-resource" + ) + assert ( + function_env_variables["API_URL_4"] + == "https://blockchain.execute-api.amazonaws.com:4566/test-resource" + ) + + +@markers.aws.only_localstack(reason="This is functionality specific to Localstack") +def test_lambda_cfn_run_with_non_empty_string_replacement_deny_list( + deploy_cfn_template, aws_client, monkeypatch +): + """ + deploys the same lambda with a non-empty CFN string deny list configurations, testing that it behaves as expected + (i.e. the URLs in the deny list are not modified) + """ + monkeypatch.setattr( + config, + "CFN_STRING_REPLACEMENT_DENY_LIST", + [ + "https://storage.execute-api.us-east-2.amazonaws.com/test-resource", + "https://reporting.execute-api.us-east-1.amazonaws.com/test-resource", + ], + ) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_lambda_with_external_api_paths_in_env_vars.yaml", + ), + max_wait=120, + ) + function = aws_client.lambda_.get_function(FunctionName=deployment.outputs["FunctionName"]) + function_env_variables = function["Configuration"]["Environment"]["Variables"] + # URLs that match regex to capture AWS URLs but are explicitly in the deny list, don't get modified - + # non-matching URLs remain unchanged. + assert function_env_variables["API_URL_1"] == "https://api.example.com" + assert ( + function_env_variables["API_URL_2"] + == "https://storage.execute-api.us-east-2.amazonaws.com/test-resource" + ) + assert ( + function_env_variables["API_URL_3"] + == "https://reporting.execute-api.us-east-1.amazonaws.com/test-resource" + ) + assert ( + function_env_variables["API_URL_4"] + == "https://blockchain.execute-api.amazonaws.com:4566/test-resource" + ) + + +@pytest.mark.skip(reason="broken/notimplemented") +@markers.aws.validated +def test_lambda_vpc(deploy_cfn_template, aws_client): + """ + this test showcases a very long-running deployment of a fairly straight forward lambda function + cloudformation will poll get_function until the active state has been reached + """ + fn_name = f"vpc-lambda-fn-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_vpc.yaml" + ), + parameters={ + "FunctionNameParam": fn_name, + }, + max_wait=600, + ) + assert ( + aws_client.lambda_.get_function(FunctionName=fn_name)["Configuration"]["State"] + == State.Active + ) + aws_client.lambda_.invoke(FunctionName=fn_name, LogType="Tail", Payload=b"{}") + + +@markers.aws.validated +def test_update_lambda_permissions(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_permission.yml" + ) + ) + + new_principal = aws_client.sts.get_caller_identity()["Account"] + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + parameters={"PrincipalForPermission": new_principal}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_permission.yml" + ), + ) + + policy = aws_client.lambda_.get_policy(FunctionName=stack.outputs["FunctionName"]) + + # The behaviour of thi principal acocunt setting changes with aws or lambda providers + principal = json.loads(policy["Policy"])["Statement"][0]["Principal"] + if isinstance(principal, dict): + principal = principal.get("AWS") or principal.get("Service", "") + + assert new_principal in principal + + +@markers.aws.validated +def test_multiple_lambda_permissions_for_singlefn(deploy_cfn_template, snapshot, aws_client): + deploy = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_permission_multiple.yaml" + ), + max_wait=240, + ) + fn_name = deploy.outputs["LambdaName"] + p1_sid = deploy.outputs["PermissionLambda"] + p2_sid = deploy.outputs["PermissionStates"] + + snapshot.add_transformer(snapshot.transform.regex(p1_sid, "")) + snapshot.add_transformer(snapshot.transform.regex(p2_sid, "")) + snapshot.add_transformer(snapshot.transform.regex(fn_name, "")) + snapshot.add_transformer(SortingTransformer("Statement", lambda s: s["Sid"])) + + policy = aws_client.lambda_.get_policy(FunctionName=fn_name) + # load the policy json, so we can properly snapshot it + policy["Policy"] = json.loads(policy["Policy"]) + snapshot.match("policy", policy) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Added by CloudFormation + "$..Tags.'aws:cloudformation:logical-id'", + "$..Tags.'aws:cloudformation:stack-id'", + "$..Tags.'aws:cloudformation:stack-name'", + ] +) +def test_lambda_function_tags(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + function_name = f"fn-{short_uid()}" + environment = f"dev-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(environment, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/cfn_lambda_with_tags.yml", + ), + parameters={ + "FunctionName": function_name, + "Environment": environment, + }, + ) + snapshot.add_transformer(snapshot.transform.regex(deployment.stack_name, "")) + + get_function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_result", get_function_result) + + +class TestCfnLambdaIntegrations: + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.EffectiveDeliveryPolicy", # broken in sns right now. needs to be wrapped within an http key + "$..Attributes.DeliveryPolicy", # shouldn't be there + "$..Attributes.Policy", # missing SNS:Receive + "$..CodeSize", + "$..Configuration.Layers", + "$..Tags", # missing cloudformation automatic resource tags for the lambda function + ] + ) + @markers.aws.validated + def test_cfn_lambda_permissions(self, deploy_cfn_template, snapshot, aws_client): + """ + * Lambda Function + * Lambda Permission + * SNS Topic + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer( + snapshot.transform.key_value("Sid"), priority=-1 + ) # TODO: need a better snapshot construct here + # Sid format: e.g. `-6JTUCQQ17UXN` + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_sns_permissions.yaml" + ), + max_wait=240, + ) + + # verify by checking APIs + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + snapshot.match("stack_resources", stack_resources) + + fn_name = deployment.outputs["FunctionName"] + topic_arn = deployment.outputs["TopicArn"] + + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_topic_attributes_result = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + get_policy_result = aws_client.lambda_.get_policy(FunctionName=fn_name) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_topic_attributes_result", get_topic_attributes_result) + snapshot.match("get_policy_result", get_policy_result) + + # check that lambda is invoked + + msg = f"msg-verification-{short_uid()}" + aws_client.sns.publish(Message=msg, TopicArn=topic_arn) + + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda + "$..Tags", + "$..Configuration.CodeSize", # Lambda ZIP flaky in CI + # SQS + "$..Attributes.SqsManagedSseEnabled", + # IAM + "$..PolicyNames", + "$..PolicyName", + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..StackResources..PhysicalResourceId", # TODO: compatibility between AWS URL and localstack URL + ] + ) + @markers.aws.validated + def test_cfn_lambda_sqs_source(self, deploy_cfn_template, snapshot, aws_client): + """ + Resources: + * Lambda Function + * SQS Queue + * EventSourceMapping + * IAM Roles/Policies (e.g. sqs:ReceiveMessage for lambda service to poll SQS) + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer(snapshot.transform.key_value("RoleId")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_sqs_source.yaml" + ), + max_wait=240, + ) + fn_name = deployment.outputs["FunctionName"] + queue_url = deployment.outputs["QueueUrl"] + esm_id = deployment.outputs["ESMId"] + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + + # IAM::Policy seems to have a pretty weird physical resource ID (e.g. stack-fnSe-3OZPF82JL41D) + iam_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deployment.stack_id, LogicalResourceId="fnServiceRoleDefaultPolicy0ED5D3E5" + ) + snapshot.add_transformer( + snapshot.transform.regex( + iam_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + + snapshot.match("stack_resources", stack_resources) + + # query service APIs for resource states + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_esm_result = aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + get_queue_atts_result = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["All"] + ) + role_arn = get_function_result["Configuration"]["Role"] + role_name = role_arn.partition("role/")[-1] + get_role_result = aws_client.iam.get_role(RoleName=role_name) + list_attached_role_policies_result = aws_client.iam.list_attached_role_policies( + RoleName=role_name + ) + list_inline_role_policies_result = aws_client.iam.list_role_policies(RoleName=role_name) + policies = [] + for rp in list_inline_role_policies_result["PolicyNames"]: + get_rp_result = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=rp) + policies.append(get_rp_result) + + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..policies..ResponseMetadata", "", reference_replacement=False + ) + ) + + snapshot.match("role_policies", {"policies": policies}) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_esm_result", get_esm_result) + snapshot.match("get_queue_atts_result", get_queue_atts_result) + snapshot.match("get_role_result", get_role_result) + snapshot.match("list_attached_role_policies_result", list_attached_role_policies_result) + snapshot.match("list_inline_role_policies_result", list_inline_role_policies_result) + + # TODO: extract + # TODO: is this even necessary? should the cloudformation deployment guarantee that this is enabled already? + def wait_esm_active(): + try: + return ( + aws_client.lambda_.get_event_source_mapping(UUID=esm_id)["State"] == "Enabled" + ) + except Exception as e: + print(e) + + assert wait_until(wait_esm_active) + + msg = f"msg-verification-{short_uid()}" + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody=msg) + + # TODO: extract + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + deployment.destroy() + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + # TODO: consider moving into the dedicated DynamoDB => Lambda tests because it tests the filtering functionality rather than CloudFormation (just using CF to deploy resources) + # tests.aws.services.lambda_.test_lambda_integration_dynamodbstreams.TestDynamoDBEventSourceMapping.test_dynamodb_event_filter + @markers.aws.validated + def test_lambda_dynamodb_event_filter( + self, dynamodb_wait_for_table_active, deploy_cfn_template, aws_client, monkeypatch + ): + function_name = f"test-fn-{short_uid()}" + table_name = f"ddb-tbl-{short_uid()}" + + item_to_put = { + "PK": {"S": "person1"}, + "SK": {"S": "details"}, + "name": {"S": "John Doe"}, + } + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_dynamodb_event_filter.yaml" + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"dynamodb": {"NewImage": {"homemade": {"S": [{"exists": false}]}}}}', + }, + ) + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put) + + def _send_events(): + log_events = aws_client.logs.filter_log_events( + logGroupName=f"/aws/lambda/{function_name}" + )["events"] + return any("Hello world!" in e["message"] for e in log_events) + + sleep = 10 if os.getenv("TEST_TARGET") == "AWS_CLOUD" else 1 + assert wait_until(_send_events, wait=sleep, max_retries=50) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda + "$..Tags", + "$..Configuration.CodeSize", # Lambda ZIP flaky in CI + # IAM + "$..PolicyNames", + "$..policies..PolicyName", + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..StackResources..LogicalResourceId", + "$..StackResources..PhysicalResourceId", + # dynamodb describe_table + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + # stream result + "$..StreamDescription.CreationRequestDateTime", + ] + ) + @markers.aws.validated + def test_cfn_lambda_dynamodb_source(self, deploy_cfn_template, snapshot, aws_client): + """ + Resources: + * Lambda Function + * DynamoDB Table + Stream + * EventSourceMapping + * IAM Roles/Policies (e.g. dynamodb:GetRecords for lambda service to poll dynamodb) + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer(snapshot.transform.key_value("RoleId")) + snapshot.add_transformer( + snapshot.transform.key_value("ShardId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("StartingSequenceNumber", reference_replacement=False) + ) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_dynamodb_source.yaml" + ), + max_wait=240, + ) + fn_name = deployment.outputs["FunctionName"] + table_name = deployment.outputs["TableName"] + stream_arn = deployment.outputs["StreamArn"] + esm_id = deployment.outputs["ESMId"] + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + + # IAM::Policy seems to have a pretty weird physical resource ID (e.g. stack-fnSe-3OZPF82JL41D) + iam_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deployment.stack_id, LogicalResourceId="fnServiceRoleDefaultPolicy0ED5D3E5" + ) + snapshot.add_transformer( + snapshot.transform.regex( + iam_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + + snapshot.match("stack_resources", stack_resources) + + # query service APIs for resource states + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_esm_result = aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + describe_table_result = aws_client.dynamodb.describe_table(TableName=table_name) + describe_stream_result = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn) + role_arn = get_function_result["Configuration"]["Role"] + role_name = role_arn.partition("role/")[-1] + get_role_result = aws_client.iam.get_role(RoleName=role_name) + list_attached_role_policies_result = aws_client.iam.list_attached_role_policies( + RoleName=role_name + ) + list_inline_role_policies_result = aws_client.iam.list_role_policies(RoleName=role_name) + policies = [] + for rp in list_inline_role_policies_result["PolicyNames"]: + get_rp_result = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=rp) + policies.append(get_rp_result) + + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..policies..ResponseMetadata", "", reference_replacement=False + ) + ) + + snapshot.match("role_policies", {"policies": policies}) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_esm_result", get_esm_result) + snapshot.match("describe_table_result", describe_table_result) + snapshot.match("describe_stream_result", describe_stream_result) + snapshot.match("get_role_result", get_role_result) + snapshot.match("list_attached_role_policies_result", list_attached_role_policies_result) + snapshot.match("list_inline_role_policies_result", list_inline_role_policies_result) + + # TODO: extract + # TODO: is this even necessary? should the cloudformation deployment guarantee that this is enabled already? + def wait_esm_active(): + try: + return ( + aws_client.lambda_.get_event_source_mapping(UUID=esm_id)["State"] == "Enabled" + ) + except Exception as e: + print(e) + + assert wait_until(wait_esm_active) + + msg = f"msg-verification-{short_uid()}" + aws_client.dynamodb.put_item( + TableName=table_name, Item={"id": {"S": "test"}, "msg": {"S": msg}} + ) + + # TODO: extract + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + deployment.destroy() + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..Configuration.CodeSize", + "$..Tags", + # TODO: wait for ESM to become active in CloudFormation to mitigate these flaky fields + "$..Configuration.LastUpdateStatus", + "$..Configuration.State", + "$..Configuration.StateReason", + "$..Configuration.StateReasonCode", + ], + ) + @markers.aws.validated + def test_cfn_lambda_kinesis_source(self, deploy_cfn_template, snapshot, aws_client): + """ + Resources: + * Lambda Function + * Kinesis Stream + * EventSourceMapping + * IAM Roles/Policies (e.g. kinesis:GetRecords for lambda service to poll kinesis) + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.kinesis_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer(snapshot.transform.key_value("RoleId")) + snapshot.add_transformer( + snapshot.transform.key_value("ShardId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("StartingSequenceNumber", reference_replacement=False) + ) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_kinesis_source.yaml" + ), + max_wait=240, + ) + fn_name = deployment.outputs["FunctionName"] + stream_name = deployment.outputs["StreamName"] + # stream_arn = deployment.outputs["StreamArn"] + esm_id = deployment.outputs["ESMId"] + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + + # IAM::Policy seems to have a pretty weird physical resource ID (e.g. stack-fnSe-3OZPF82JL41D) + iam_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deployment.stack_id, LogicalResourceId="fnServiceRoleDefaultPolicy0ED5D3E5" + ) + snapshot.add_transformer( + snapshot.transform.regex( + iam_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + + snapshot.match("stack_resources", stack_resources) + + # query service APIs for resource states + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_esm_result = aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + describe_stream_result = aws_client.kinesis.describe_stream(StreamName=stream_name) + role_arn = get_function_result["Configuration"]["Role"] + role_name = role_arn.partition("role/")[-1] + get_role_result = aws_client.iam.get_role(RoleName=role_name) + list_attached_role_policies_result = aws_client.iam.list_attached_role_policies( + RoleName=role_name + ) + list_inline_role_policies_result = aws_client.iam.list_role_policies(RoleName=role_name) + policies = [] + for rp in list_inline_role_policies_result["PolicyNames"]: + get_rp_result = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=rp) + policies.append(get_rp_result) + + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..policies..ResponseMetadata", "", reference_replacement=False + ) + ) + + snapshot.match("role_policies", {"policies": policies}) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_esm_result", get_esm_result) + snapshot.match("describe_stream_result", describe_stream_result) + snapshot.match("get_role_result", get_role_result) + snapshot.match("list_attached_role_policies_result", list_attached_role_policies_result) + snapshot.match("list_inline_role_policies_result", list_inline_role_policies_result) + + # TODO: extract + # TODO: is this even necessary? should the cloudformation deployment guarantee that this is enabled already? + def wait_esm_active(): + try: + return ( + aws_client.lambda_.get_event_source_mapping(UUID=esm_id)["State"] == "Enabled" + ) + except Exception as e: + print(e) + + assert wait_until(wait_esm_active) + + msg = f"msg-verification-{short_uid()}" + data_msg = to_str(base64.b64encode(to_bytes(msg))) + aws_client.kinesis.put_record( + StreamName=stream_name, Data=msg, PartitionKey="samplepartitionkey" + ) + + # TODO: extract + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(data_msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + deployment.destroy() + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + +class TestCfnLambdaDestinations: + """ + generic cases + 1. verify payload + + - [ ] SNS destination success + - [ ] SNS destination failure + - [ ] SQS destination success + - [ ] SQS destination failure + - [ ] Lambda destination success + - [ ] Lambda destination failure + - [ ] EventBridge destination success + - [ ] EventBridge destination failure + + meta cases + * test max event age + * test retry count + * qualifier issues + * reserved concurrency set to 0 => should immediately go to failure destination / dlq + * combination with DLQ + * test with a very long queue (reserved concurrency 1, high function duration, low max event age) + + edge cases + - [ ] Chaining async lambdas + + doc: + "If the function doesn't have enough concurrency available to process all events, additional requests are throttled. + For throttling errors (429) and system errors (500-series), Lambda returns the event to the queue and attempts to run the function again for up to 6 hours. + The retry interval increases exponentially from 1 second after the first attempt to a maximum of 5 minutes. + If the queue contains many entries, Lambda increases the retry interval and reduces the rate at which it reads events from the queue." + + """ + + @pytest.mark.parametrize( + ["on_success", "on_failure"], + [ + ("sqs", "sqs"), + # TODO: test needs further work + # ("sns", "sns"), + # ("lambda", "lambda"), + # ("eventbridge", "eventbridge") + ], + ) + @markers.aws.validated + def test_generic_destination_routing( + self, deploy_cfn_template, on_success, on_failure, aws_client + ): + """ + This fairly simple template lets us choose between the 4 different destinations for both OnSuccess as well as OnFailure. + The template chooses between one of 4 ARNs via indexed access according to this mapping: + + 0: SQS + 1: SNS + 2: Lambda + 3: EventBridge + + All of them are connected downstream to another Lambda function. + This function can be used to verify that the payload has propagated through the hole scenario. + It also allows us to verify the specific payload format depending on the service integration. + + β”‚ + β–Ό + Lambda + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό β–Ό + (direct) SQS SNS EventBridge + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + Lambda + + # TODO: fix eventbridge name (reuse?) + """ + + name_to_index_map = {"sqs": "0", "sns": "1", "lambda": "2", "eventbridge": "3"} + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_destinations.yaml" + ), + parameters={ + # "RetryParam": "", + # "MaxEventAgeSecondsParam": "", + # "QualifierParameter": "", + "OnSuccessSwitch": name_to_index_map[on_success], + "OnFailureSwitch": name_to_index_map[on_failure], + }, + max_wait=600, + ) + + invoke_fn_name = deployment.outputs["LambdaName"] + collect_fn_name = deployment.outputs["CollectLambdaName"] + + msg = f"message-{short_uid()}" + + # Success case + aws_client.lambda_.invoke( + FunctionName=invoke_fn_name, + Payload=to_bytes(json.dumps({"message": msg, "should_fail": "0"})), + InvocationType=InvocationType.Event, + ) + + # Failure case + aws_client.lambda_.invoke( + FunctionName=invoke_fn_name, + Payload=to_bytes(json.dumps({"message": msg, "should_fail": "1"})), + InvocationType=InvocationType.Event, + ) + + def wait_for_logs(): + events = aws_client.logs.filter_log_events( + logGroupName=f"/aws/lambda/{collect_fn_name}" + )["events"] + message_events = [e["message"] for e in events if msg in e["message"]] + return len(message_events) >= 2 + # return len(events) >= 6 # note: each invoke comes with at least 3 events even without printing + + wait_until(wait_for_logs) + + +@markers.aws.validated +def test_python_lambda_code_deployed_via_s3(deploy_cfn_template, aws_client, s3_bucket): + bucket_key = "handler.zip" + zip_file = create_lambda_archive( + load_file( + os.path.join(os.path.dirname(__file__), "../../lambda_/functions/lambda_echo.py") + ), + get_content=True, + runtime=Runtime.python3_12, + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_s3_code.yaml" + ), + parameters={ + "LambdaCodeBucket": s3_bucket, + "LambdaRuntime": "python3.10", + "LambdaHandler": "handler.handler", + }, + ) + + function_name = deployment.outputs["LambdaName"] + invocation_result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=json.dumps({"hello": "world"}) + ) + payload = json.load(invocation_result["Payload"]) + assert payload == {"hello": "world"} + assert invocation_result["StatusCode"] == 200 + + +@markers.aws.validated +def test_lambda_cfn_dead_letter_config_async_invocation( + deploy_cfn_template, aws_client, s3_create_bucket, snapshot +): + # invoke intentionally failing lambda async, which then forwards to the DLQ as configured. + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + + # cfn template was generated via serverless, but modified to work with pure cloudformation + s3_bucket = s3_create_bucket() + bucket_key = "serverless/dlq/local/1701682216701-2023-12-04T09:30:16.701Z/dlq.zip" + + zip_file = create_lambda_archive( + load_file( + os.path.join( + os.path.dirname(__file__), "../../lambda_/functions/lambda_handler_error.py" + ) + ), + get_content=True, + runtime=Runtime.python3_12, + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_lambda_serverless.yml" + ), + parameters={"LambdaCodeBucket": s3_bucket}, + ) + function_name = deployment.outputs["LambdaName"] + + # async invocation + aws_client.lambda_.invoke(FunctionName=function_name, InvocationType="Event") + dlq_queue = deployment.outputs["DLQName"] + response = {} + + def check_dlq_message(response: dict): + response.update(aws_client.sqs.receive_message(QueueUrl=dlq_queue, VisibilityTimeout=0)) + assert response.get("Messages") + + retry(check_dlq_message, response=response, retries=5, sleep=2.5) + snapshot.match("failed-async-lambda", response) + + +@markers.aws.validated +def test_lambda_layer_crud(deploy_cfn_template, aws_client, s3_bucket, snapshot): + snapshot.add_transformers_list( + [snapshot.transform.key_value("LambdaName"), snapshot.transform.key_value("layer-name")] + ) + + layer_name = f"layer-{short_uid()}" + snapshot.match("layer-name", layer_name) + + bucket_key = "layer.zip" + zip_file = create_lambda_archive( + "hello", + get_content=True, + runtime=Runtime.python3_12, + file_name="hello.txt", + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/lambda_layer_version.yml" + ), + parameters={"LayerBucket": s3_bucket, "LayerName": layer_name}, + ) + snapshot.match("cfn-output", deployment.outputs) diff --git a/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json b/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json new file mode 100644 index 0000000000000..484f94d6b4898 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_lambda.snapshot.json @@ -0,0 +1,1892 @@ +{ + "tests/aws/services/cloudformation/resources/test_lambda.py::test_cfn_function_url": { + "recorded-date": "16-04-2024, 08:16:02", + "recorded-content": { + "url_resource": { + "StackResourceDetail": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "", + "Metadata": {}, + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Url", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exception_url_config_nonexistent_version": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "url_config_arn": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response_headers": { + "connection": "keep-alive", + "content-length": "17", + "content-type": "application/json", + "date": "date", + "x-amzn-requestid": "", + "x-amzn-trace-id": "x-amzn-trace-id" + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_alias": { + "recorded-date": "07-05-2025, 15:39:26", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "function_version": "1", + "initialization_type": null + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "FunctionAlias", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Alias", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "LambdaFunction", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyFnServiceRole", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Version", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Alias": { + "AliasArn": "arn::lambda::111111111111:function:", + "Description": "", + "FunctionVersion": "1", + "Name": "", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "provisioned_concurrency_config": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_permissions": { + "recorded-date": "09-04-2024, 07:26:03", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnAllowInvokeLambdaPermissionsStacktopicF723B1A748672DB5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fntopic09ED913A", + "PhysicalResourceId": "arn::sns::111111111111::", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Subscription", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "topic69831491", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_topic_attributes_result": { + "Attributes": { + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_result": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::sns::111111111111:" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_sqs_source": { + "recorded-date": "30-10-2024, 14:48:16", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRoleDefaultPolicy0ED5D3E5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnSqsEventSourceLambdaSqsSourceStackq2097017B53C3FF8C", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::EventSourceMapping", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "q14836DC8", + "PhysicalResourceId": "https://sqs..amazonaws.com/111111111111/", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SQS::Queue", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "sqs:ReceiveMessage", + "sqs:ChangeMessageVisibility", + "sqs:GetQueueUrl", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Effect": "Allow", + "Resource": "arn::sqs::111111111111:" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "fnServiceRoleDefaultPolicy0ED5D3E5", + "RoleName": "", + "ResponseMetadata": "" + } + ] + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_esm_result": { + "BatchSize": 1, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Enabled", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_queue_atts_result": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "CreatedTimestamp": "timestamp", + "DelaySeconds": "0", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_attached_role_policies_result": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "PolicyName": "AWSLambdaBasicExecutionRole" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_inline_role_policies_result": { + "IsTruncated": false, + "PolicyNames": [ + "fnServiceRoleDefaultPolicy0ED5D3E5" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_code_signing_config": { + "recorded-date": "09-04-2024, 07:19:51", + "recorded-content": { + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "CodeSigningConfig", + "PhysicalResourceId": "arn::lambda::111111111111:code-signing-config:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::CodeSigningConfig", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "config": { + "CodeSigningConfig": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Enforce" + }, + "Description": "Code Signing", + "LastModified": "date" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_event_invoke_config": { + "recorded-date": "09-04-2024, 07:20:36", + "recorded-content": { + "event_invoke_config": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 300, + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version": { + "recorded-date": "07-05-2025, 13:19:10", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "function_version": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnVersion7BF8AE5A", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "versions_by_fn": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_version": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_dynamodb_source": { + "recorded-date": "12-10-2024, 10:46:17", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnDynamoDBEventSourceLambdaDynamodbSourceStacktable153BBA79064FDF1D", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::EventSourceMapping", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRoleDefaultPolicy0ED5D3E5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "table8235A42E", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::DynamoDB::Table", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:ListStreams", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator" + ], + "Effect": "Allow", + "Resource": "arn::dynamodb::111111111111:table//stream/" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "fnServiceRoleDefaultPolicy0ED5D3E5", + "RoleName": "", + "ResponseMetadata": "" + } + ] + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_esm_result": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_table_result": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stream_result": { + "StreamDescription": { + "CreationRequestDateTime": "datetime", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "Shards": [ + { + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "shard-id" + } + ], + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "StreamStatus": "ENABLED", + "StreamViewType": "NEW_AND_OLD_IMAGES", + "TableName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_attached_role_policies_result": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "PolicyName": "AWSLambdaBasicExecutionRole" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_inline_role_policies_result": { + "IsTruncated": false, + "PolicyNames": [ + "fnServiceRoleDefaultPolicy0ED5D3E5" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_kinesis_source": { + "recorded-date": "12-10-2024, 10:52:28", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnKinesisEventSourceLambdaKinesisSourceStackstream996A3395ED86A30E", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::EventSourceMapping", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRoleDefaultPolicy0ED5D3E5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "stream19075594", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Kinesis::Stream", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kinesis:DescribeStreamSummary", + "kinesis:GetRecords", + "kinesis:GetShardIterator", + "kinesis:ListShards", + "kinesis:SubscribeToShard", + "kinesis:DescribeStream", + "kinesis:ListStreams" + ], + "Effect": "Allow", + "Resource": "arn::kinesis::111111111111:stream/" + }, + { + "Action": "kinesis:DescribeStream", + "Effect": "Allow", + "Resource": "arn::kinesis::111111111111:stream/" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "fnServiceRoleDefaultPolicy0ED5D3E5", + "RoleName": "", + "ResponseMetadata": "" + } + ] + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_esm_result": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 10, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stream_result": { + "StreamDescription": { + "EncryptionType": "NONE", + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "HasMoreShards": false, + "RetentionPeriodHours": 24, + "Shards": [ + { + "HashKeyRange": { + "EndingHashKey": "ending_hash", + "StartingHashKey": "starting_hash" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "shard-id" + } + ], + "StreamARN": "arn::kinesis::111111111111:stream/", + "StreamCreationTimestamp": "timestamp", + "StreamModeDetails": { + "StreamMode": "PROVISIONED" + }, + "StreamName": "", + "StreamStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_attached_role_policies_result": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "PolicyName": "AWSLambdaBasicExecutionRole" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_inline_role_policies_result": { + "IsTruncated": false, + "PolicyNames": [ + "fnServiceRoleDefaultPolicy0ED5D3E5" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_multiple_lambda_permissions_for_singlefn": { + "recorded-date": "09-04-2024, 07:25:05", + "recorded-content": { + "policy": { + "Policy": { + "Id": "default", + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Resource": "arn::lambda::111111111111:function:", + "Sid": "" + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Principal": { + "Service": "states.amazonaws.com" + }, + "Resource": "arn::lambda::111111111111:function:", + "Sid": "" + } + ], + "Version": "2012-10-17" + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_dead_letter_config_async_invocation": { + "recorded-date": "09-04-2024, 07:39:50", + "recorded-content": { + "failed-async-lambda": { + "Messages": [ + { + "Body": {}, + "MD5OfBody": "99914b932bd37a50b983c5e7c90ae93b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter_update": { + "recorded-date": "12-10-2024, 10:42:00", + "recorded-content": { + "source_mappings": { + "EventSourceMappings": [ + { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "eventName": [ + "DELETE" + ] + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_source_mappings": { + "EventSourceMappings": [ + { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "eventName": [ + "MODIFY" + ] + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_function_tags": { + "recorded-date": "01-10-2024, 12:52:51", + "recorded-content": { + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "Environment": "", + "aws:cloudformation:logical-id": "TestFunction", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "lambda:createdBy": "SAM" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_layer_crud": { + "recorded-date": "20-12-2024, 18:23:31", + "recorded-content": { + "layer-name": "", + "cfn-output": { + "LambdaArn": "arn::lambda::111111111111:function:", + "LambdaName": "", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "LayerVersionRef": "arn::lambda::111111111111:layer::1" + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_logging_config": { + "recorded-date": "08-04-2025, 12:10:56", + "recorded-content": { + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "logical-resource-id", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "logical-resource-id", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "logical-resource-id", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logging_config": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + } + } + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": { + "recorded-date": "07-05-2025, 13:23:25", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "initialization_type": "provisioned-concurrency" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnVersion7BF8AE5A", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "versions_by_fn": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_version": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "provisioned_concurrency_config": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_lambda.validation.json b/tests/aws/services/cloudformation/resources/test_lambda.validation.json new file mode 100644 index 0000000000000..e603d1df5aa41 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_lambda.validation.json @@ -0,0 +1,71 @@ +{ + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaDestinations::test_generic_destination_routing[sqs-sqs]": { + "last_validated_date": "2024-12-10T16:48:04+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_dynamodb_source": { + "last_validated_date": "2024-10-12T10:46:17+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_kinesis_source": { + "last_validated_date": "2024-10-12T10:52:28+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_permissions": { + "last_validated_date": "2024-04-09T07:26:03+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_sqs_source": { + "last_validated_date": "2024-10-30T14:48:16+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::TestCfnLambdaIntegrations::test_lambda_dynamodb_event_filter": { + "last_validated_date": "2024-04-09T07:31:17+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_cfn_function_url": { + "last_validated_date": "2024-04-16T08:16:02+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_event_invoke_config": { + "last_validated_date": "2024-04-09T07:20:36+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_alias": { + "last_validated_date": "2025-05-07T15:39:26+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_dead_letter_config_async_invocation": { + "last_validated_date": "2024-04-09T07:39:50+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_cfn_run": { + "last_validated_date": "2024-04-09T07:22:32+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_code_signing_config": { + "last_validated_date": "2024-04-09T07:19:51+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_function_tags": { + "last_validated_date": "2024-10-01T12:52:51+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_layer_crud": { + "last_validated_date": "2024-12-20T18:23:31+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_logging_config": { + "last_validated_date": "2025-04-08T12:12:01+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version": { + "last_validated_date": "2025-05-07T13:19:10+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": { + "last_validated_date": "2025-05-07T13:23:25+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter_update": { + "last_validated_date": "2024-12-11T09:03:52+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_multiple_lambda_permissions_for_singlefn": { + "last_validated_date": "2024-04-09T07:25:05+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_python_lambda_code_deployed_via_s3": { + "last_validated_date": "2024-04-09T07:38:32+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_update_lambda_function": { + "last_validated_date": "2024-11-07T03:16:40+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_update_lambda_function_name": { + "last_validated_date": "2024-11-07T03:10:48+00:00" + }, + "tests/aws/services/cloudformation/resources/test_lambda.py::test_update_lambda_permissions": { + "last_validated_date": "2024-04-09T07:23:41+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_logs.py b/tests/aws/services/cloudformation/resources/test_logs.py new file mode 100644 index 0000000000000..6813283c5b505 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_logs.py @@ -0,0 +1,49 @@ +import os.path + +from localstack.testing.pytest import markers + + +@markers.aws.validated +def test_logstream(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/logs_group_and_stream.yaml" + ) + ) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("LogGroupNameOutput")) + + group_name = stack.outputs["LogGroupNameOutput"] + stream_name = stack.outputs["LogStreamNameOutput"] + + snapshot.match("outputs", stack.outputs) + + streams = aws_client.logs.describe_log_streams( + logGroupName=group_name, logStreamNamePrefix=stream_name + )["logStreams"] + assert aws_client.logs.meta.partition == streams[0]["arn"].split(":")[1] + snapshot.match("describe_log_streams", streams) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..logGroups..logGroupArn", + "$..logGroups..logGroupClass", + "$..logGroups..retentionInDays", + ] +) +def test_cfn_handle_log_group_resource(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/logs_group.yml") + ) + + log_group_prefix = stack.outputs["LogGroupNameOutput"] + + response = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_prefix) + snapshot.match("describe_log_groups", response) + snapshot.add_transformer(snapshot.transform.key_value("logGroupName")) + + stack.destroy() + response = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_prefix) + assert len(response["logGroups"]) == 0 diff --git a/tests/aws/services/cloudformation/resources/test_logs.snapshot.json b/tests/aws/services/cloudformation/resources/test_logs.snapshot.json new file mode 100644 index 0000000000000..8ad8af97a9ca5 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_logs.snapshot.json @@ -0,0 +1,42 @@ +{ + "tests/aws/services/cloudformation/resources/test_logs.py::test_logstream": { + "recorded-date": "29-07-2022, 13:22:53", + "recorded-content": { + "outputs": { + "LogStreamNameOutput": "", + "LogGroupNameOutput": "" + }, + "describe_log_streams": [ + { + "logStreamName": "", + "creationTime": "timestamp", + "arn": "arn::logs::111111111111:log-group::log-stream:", + "storedBytes": 0 + } + ] + } + }, + "tests/aws/services/cloudformation/resources/test_logs.py::test_cfn_handle_log_group_resource": { + "recorded-date": "20-06-2024, 16:15:47", + "recorded-content": { + "describe_log_groups": { + "logGroups": [ + { + "arn": "arn::logs::111111111111:log-group::*", + "creationTime": "timestamp", + "logGroupArn": "arn::logs::111111111111:log-group:", + "logGroupClass": "STANDARD", + "logGroupName": "", + "metricFilterCount": 0, + "retentionInDays": 731, + "storedBytes": 0 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_logs.validation.json b/tests/aws/services/cloudformation/resources/test_logs.validation.json new file mode 100644 index 0000000000000..fce835093de2a --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_logs.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/resources/test_logs.py::test_cfn_handle_log_group_resource": { + "last_validated_date": "2024-06-20T16:15:47+00:00" + }, + "tests/aws/services/cloudformation/resources/test_logs.py::test_logstream": { + "last_validated_date": "2022-07-29T11:22:53+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_opensearch.py b/tests/aws/services/cloudformation/resources/test_opensearch.py new file mode 100644 index 0000000000000..152e574aecffa --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_opensearch.py @@ -0,0 +1,87 @@ +import os +from operator import itemgetter + +import pytest + +from localstack.testing.pytest import markers + + +@pytest.mark.skip(reason="flaky") +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ClusterConfig.DedicatedMasterCount", # added in LS + "$..ClusterConfig.DedicatedMasterEnabled", # added in LS + "$..ClusterConfig.DedicatedMasterType", # added in LS + "$..SoftwareUpdateOptions", # missing + "$..OffPeakWindowOptions", # missing + "$..ChangeProgressDetails", # missing + "$..AutoTuneOptions.UseOffPeakWindow", # missing + "$..ClusterConfig.MultiAZWithStandbyEnabled", # missing + "$..AdvancedSecurityOptions.AnonymousAuthEnabled", # missing + # TODO different values: + "$..Processing", + "$..ServiceSoftwareOptions.CurrentVersion", + "$..ClusterConfig.DedicatedMasterEnabled", + "$..ClusterConfig.InstanceType", # TODO the type was set in cfn + "$..AutoTuneOptions.State", + '$..AdvancedOptions."rest.action.multi.allow_explicit_index"', # TODO this was set to false in cfn + ] +) +def test_domain(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("DomainId")) + snapshot.add_transformer(snapshot.transform.key_value("DomainName")) + snapshot.add_transformer(snapshot.transform.key_value("ChangeId")) + snapshot.add_transformer(snapshot.transform.key_value("Endpoint"), priority=-1) + template_path = os.path.join( + os.path.dirname(__file__), "../../../templates/opensearch_domain.yml" + ) + result = deploy_cfn_template(template_path=template_path) + domain_endpoint = result.outputs["SearchDomainEndpoint"] + assert domain_endpoint + domain_arn = result.outputs["SearchDomainArn"] + assert domain_arn + domain_name = result.outputs["SearchDomain"] + + domain = aws_client.opensearch.describe_domain(DomainName=domain_name) + assert domain["DomainStatus"] + snapshot.match("describe_domain", domain) + + assert domain_arn == domain["DomainStatus"]["ARN"] + tags_result = aws_client.opensearch.list_tags(ARN=domain_arn) + tags_result["TagList"].sort(key=itemgetter("Key")) + snapshot.match("list_tags", tags_result) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DomainStatus.AIMLOptions", + "$..DomainStatus.AdvancedSecurityOptions.AnonymousAuthEnabled", + "$..DomainStatus.AutoTuneOptions.State", + "$..DomainStatus.AutoTuneOptions.UseOffPeakWindow", + "$..DomainStatus.ChangeProgressDetails", + "$..DomainStatus.ClusterConfig.MultiAZWithStandbyEnabled", + "$..DomainStatus.ClusterConfig.ZoneAwarenessConfig", + "$..DomainStatus.DomainEndpointOptions.TLSSecurityPolicy", + "$..DomainStatus.IPAddressType", + "$..DomainStatus.IdentityCenterOptions", + "$..DomainStatus.ModifyingProperties", + "$..DomainStatus.OffPeakWindowOptions", + "$..DomainStatus.ServiceSoftwareOptions.CurrentVersion", + "$..DomainStatus.SoftwareUpdateOptions", + ] +) +def test_domain_with_alternative_types(deploy_cfn_template, aws_client, snapshot): + """ + Test that the alternative types for the OpenSearch domain are accepted using the resource documentation example + """ + snapshot.add_transformer(snapshot.transform.key_value("Endpoint")) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/opensearch_domain_alternative_types.yml" + ) + ) + domain_name = stack.outputs["SearchDomain"] + domain = aws_client.opensearch.describe_domain(DomainName=domain_name) + snapshot.match("describe_domain", domain) diff --git a/tests/aws/services/cloudformation/resources/test_opensearch.snapshot.json b/tests/aws/services/cloudformation/resources/test_opensearch.snapshot.json new file mode 100644 index 0000000000000..b34b9f39259d9 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_opensearch.snapshot.json @@ -0,0 +1,239 @@ +{ + "tests/aws/services/cloudformation/resources/test_opensearch.py::test_domain": { + "recorded-date": "31-08-2023, 17:42:29", + "recorded-content": { + "describe_domain": { + "DomainStatus": { + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": "", + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "false" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "ENABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "DomainId": "", + "DomainName": "", + "EBSOptions": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "", + "EngineVersion": "OpenSearch_2.5", + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "datetime", + "Cancellable": false, + "CurrentVersion": "OpenSearch_2_5_R20230308-P4", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags": { + "TagList": [ + { + "Key": "anotherkey", + "Value": "hello" + }, + { + "Key": "foo", + "Value": "bar" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_opensearch.py::test_domain_with_alternative_types": { + "recorded-date": "08-07-2025, 02:30:47", + "recorded-content": { + "describe_domain": { + "DomainStatus": { + "AIMLOptions": { + "NaturalLanguageQueryGenerationOptions": { + "CurrentState": "NOT_ENABLED", + "DesiredState": "DISABLED" + } + }, + "ARN": "arn::es::111111111111:domain/test-opensearch-domain", + "AccessPolicies": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:root" + }, + "Action": "es:*", + "Resource": "arn::es::111111111111:domain/test-opensearch-domain/*" + } + ] + }, + "AdvancedOptions": { + "override_main_response_version": "true", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "DISABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "Completed", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "datetime", + "StartTime": "datetime" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterCount": 3, + "DedicatedMasterEnabled": true, + "DedicatedMasterType": "t3.small.search", + "InstanceCount": 2, + "InstanceType": "t3.small.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessConfig": { + "AvailabilityZoneCount": 2 + }, + "ZoneAwarenessEnabled": true + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-2-2019-07" + }, + "DomainId": "111111111111/test-opensearch-domain", + "DomainName": "test-opensearch-domain", + "DomainProcessingStatus": "Active", + "EBSOptions": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 20, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "", + "EngineVersion": "OpenSearch_1.0", + "IPAddressType": "ipv4", + "IdentityCenterOptions": {}, + "ModifyingProperties": [], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "datetime", + "Cancellable": false, + "CurrentVersion": "OpenSearch_1_0_R20250625", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_opensearch.validation.json b/tests/aws/services/cloudformation/resources/test_opensearch.validation.json new file mode 100644 index 0000000000000..b0eeb4668caf4 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_opensearch.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/resources/test_opensearch.py::test_domain": { + "last_validated_date": "2023-08-31T15:42:29+00:00" + }, + "tests/aws/services/cloudformation/resources/test_opensearch.py::test_domain_with_alternative_types": { + "last_validated_date": "2025-07-08T02:45:56+00:00", + "durations_in_seconds": { + "setup": 0.59, + "call": 746.75, + "teardown": 908.35, + "total": 1655.69 + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_redshift.py b/tests/aws/services/cloudformation/resources/test_redshift.py new file mode 100644 index 0000000000000..14603ff226a03 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_redshift.py @@ -0,0 +1,19 @@ +import os + +from localstack.testing.pytest import markers + + +# only runs in Docker when run against Pro (since it needs postgres on the system) +@markers.only_in_docker +@markers.aws.validated +def test_redshift_cluster(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/cfn_redshift.yaml" + ) + ) + + # very basic test to check the cluster deploys + assert stack.outputs["ClusterRef"] + assert stack.outputs["ClusterAttEndpointPort"] + assert stack.outputs["ClusterAttEndpointAddress"] diff --git a/tests/aws/services/cloudformation/resources/test_redshift.validation.json b/tests/aws/services/cloudformation/resources/test_redshift.validation.json new file mode 100644 index 0000000000000..85f6c8b23f3fd --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_redshift.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resources/test_redshift.py::test_redshift_cluster": { + "last_validated_date": "2024-02-28T12:42:35+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_resource_groups.py b/tests/aws/services/cloudformation/resources/test_resource_groups.py new file mode 100644 index 0000000000000..43fae2635f0fb --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_resource_groups.py @@ -0,0 +1,16 @@ +import os + +from localstack.testing.pytest import markers + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Group.Description", "$..Group.GroupArn"]) +def test_group_defaults(aws_client, deploy_cfn_template, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/resource_group_defaults.yml" + ), + ) + + resource_group = aws_client.resource_groups.get_group(GroupName=stack.outputs["ResourceGroup"]) + snapshot.match("resource-group", resource_group) diff --git a/tests/aws/services/cloudformation/resources/test_resource_groups.snapshot.json b/tests/aws/services/cloudformation/resources/test_resource_groups.snapshot.json new file mode 100644 index 0000000000000..573102ae41068 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_resource_groups.snapshot.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/resources/test_resource_groups.py::test_group_defaults": { + "recorded-date": "16-07-2024, 15:15:11", + "recorded-content": { + "resource-group": { + "Group": { + "GroupArn": "arn::resource-groups::111111111111:group/testgroup", + "Name": "testgroup" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_resource_groups.validation.json b/tests/aws/services/cloudformation/resources/test_resource_groups.validation.json new file mode 100644 index 0000000000000..af3ad56458b0f --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_resource_groups.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resources/test_resource_groups.py::test_group_defaults": { + "last_validated_date": "2024-07-16T15:15:11+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_route53.py b/tests/aws/services/cloudformation/resources/test_route53.py new file mode 100644 index 0000000000000..5b5ff47e6ea04 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_route53.py @@ -0,0 +1,66 @@ +import os + +from localstack.testing.pytest import markers + + +@markers.aws.validated +def test_create_record_set_via_id(route53_hosted_zone, deploy_cfn_template): + create_zone_response = route53_hosted_zone() + hosted_zone_id = create_zone_response["HostedZone"]["Id"] + route53_name = create_zone_response["HostedZone"]["Name"] + parameters = {"HostedZoneId": hosted_zone_id, "Name": route53_name} + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/route53_hostedzoneid_template.yaml" + ), + parameters=parameters, + max_wait=300, + ) + + +@markers.aws.validated +def test_create_record_set_via_name(deploy_cfn_template, route53_hosted_zone): + create_zone_response = route53_hosted_zone() + route53_name = create_zone_response["HostedZone"]["Name"] + parameters = {"HostedZoneName": route53_name, "Name": route53_name} + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/route53_hostedzonename_template.yaml" + ), + parameters=parameters, + ) + + +@markers.aws.validated +def test_create_record_set_without_resource_record(deploy_cfn_template, route53_hosted_zone): + create_zone_response = route53_hosted_zone() + hosted_zone_id = create_zone_response["HostedZone"]["Id"] + route53_name = create_zone_response["HostedZone"]["Name"] + parameters = {"HostedZoneId": hosted_zone_id, "Name": route53_name} + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/route53_recordset_without_resource_records.yaml", + ), + parameters=parameters, + ) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..HealthCheckConfig.EnableSNI", "$..HealthCheckVersion"] +) +def test_create_health_check(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/route53_healthcheck.yml", + ), + ) + health_check_id = stack.outputs["HealthCheckId"] + print(health_check_id) + health_check = aws_client.route53.get_health_check(HealthCheckId=health_check_id) + + snapshot.add_transformer(snapshot.transform.key_value("Id", "id")) + snapshot.add_transformer(snapshot.transform.key_value("CallerReference", "caller-reference")) + snapshot.match("HealthCheck", health_check["HealthCheck"]) diff --git a/tests/aws/services/cloudformation/resources/test_route53.snapshot.json b/tests/aws/services/cloudformation/resources/test_route53.snapshot.json new file mode 100644 index 0000000000000..78372c10e3b32 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_route53.snapshot.json @@ -0,0 +1,25 @@ +{ + "tests/aws/services/cloudformation/resources/test_route53.py::test_create_health_check": { + "recorded-date": "22-09-2023, 13:50:49", + "recorded-content": { + "HealthCheck": { + "CallerReference": "", + "HealthCheckConfig": { + "Disabled": false, + "EnableSNI": false, + "FailureThreshold": 3, + "FullyQualifiedDomainName": "localstacktest.com", + "IPAddress": "1.1.1.1", + "Inverted": false, + "MeasureLatency": false, + "Port": 80, + "RequestInterval": 30, + "ResourcePath": "/health", + "Type": "HTTP" + }, + "HealthCheckVersion": 1, + "Id": "" + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_route53.validation.json b/tests/aws/services/cloudformation/resources/test_route53.validation.json new file mode 100644 index 0000000000000..8b56e5dacfa51 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_route53.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resources/test_route53.py::test_create_health_check": { + "last_validated_date": "2023-09-22T11:50:49+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_s3.py b/tests/aws/services/cloudformation/resources/test_s3.py new file mode 100644 index 0000000000000..a2374796fe3ed --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_s3.py @@ -0,0 +1,148 @@ +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + + +@markers.aws.validated +def test_bucketpolicy(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + bucket_name = f"ls-bucket-{short_uid()}" + snapshot.match("bucket", {"BucketName": bucket_name}) + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_bucketpolicy.yaml" + ), + parameters={"BucketName": bucket_name}, + template_mapping={"include_policy": True}, + ) + response = aws_client.s3.get_bucket_policy(Bucket=bucket_name)["Policy"] + snapshot.match("get-policy-true", response) + + deploy_cfn_template( + is_update=True, + stack_name=deploy_result.stack_id, + parameters={"BucketName": bucket_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_bucketpolicy.yaml" + ), + template_mapping={"include_policy": False}, + ) + with pytest.raises(ClientError) as err: + aws_client.s3.get_bucket_policy(Bucket=bucket_name) + snapshot.match("no-policy", err.value.response) + + +@markers.aws.validated +def test_bucket_autoname(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_bucket_autoname.yaml" + ) + ) + descr_response = aws_client.cloudformation.describe_stacks(StackName=result.stack_id) + output = descr_response["Stacks"][0]["Outputs"][0] + assert output["OutputKey"] == "BucketNameOutput" + assert result.stack_name.lower() in output["OutputValue"] + + +@markers.aws.validated +def test_bucket_versioning(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_versioned_bucket.yaml" + ) + ) + assert "BucketName" in result.outputs + bucket_name = result.outputs["BucketName"] + bucket_version = aws_client.s3.get_bucket_versioning(Bucket=bucket_name) + assert bucket_version["Status"] == "Enabled" + + +@markers.aws.validated +def test_website_configuration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + bucket_name_generated = f"ls-bucket-{short_uid()}" + + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_bucket_website_config.yaml" + ), + parameters={"BucketName": bucket_name_generated}, + ) + + bucket_name = result.outputs["BucketNameOutput"] + assert bucket_name_generated == bucket_name + website_url = result.outputs["WebsiteURL"] + assert website_url.startswith(f"http://{bucket_name}.s3-website") + response = aws_client.s3.get_bucket_website(Bucket=bucket_name) + + snapshot.match("get_bucket_website", response) + + +@markers.aws.validated +def test_cors_configuration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_cors_bucket.yaml" + ), + ) + bucket_name_optional = result.outputs["BucketNameAllParameters"] + cors_info = aws_client.s3.get_bucket_cors(Bucket=bucket_name_optional) + snapshot.match("cors-info-optional", cors_info) + + bucket_name_required = result.outputs["BucketNameOnlyRequired"] + cors_info = aws_client.s3.get_bucket_cors(Bucket=bucket_name_required) + snapshot.match("cors-info-only-required", cors_info) + + +@markers.aws.validated +def test_object_lock_configuration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_object_lock_config.yaml" + ), + ) + bucket_name_optional = result.outputs["LockConfigAllParameters"] + cors_info = aws_client.s3.get_object_lock_configuration(Bucket=bucket_name_optional) + snapshot.match("object-lock-info-with-configuration", cors_info) + + bucket_name_required = result.outputs["LockConfigOnlyRequired"] + cors_info = aws_client.s3.get_object_lock_configuration(Bucket=bucket_name_required) + snapshot.match("object-lock-info-only-enabled", cors_info) + + +@markers.aws.validated +def test_cfn_handle_s3_notification_configuration( + aws_client, + deploy_cfn_template, + snapshot, +): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/s3_notification_sqs.yml" + ), + ) + rs = aws_client.s3.get_bucket_notification_configuration(Bucket=stack.outputs["BucketName"]) + snapshot.match("get_bucket_notification_configuration", rs) + + stack.destroy() + + with pytest.raises(ClientError) as ctx: + aws_client.s3.get_bucket_notification_configuration(Bucket=stack.outputs["BucketName"]) + snapshot.match("get_bucket_notification_configuration_error", ctx.value.response) + + snapshot.add_transformer(snapshot.transform.key_value("Id")) + snapshot.add_transformer(snapshot.transform.key_value("QueueArn")) + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) diff --git a/tests/aws/services/cloudformation/resources/test_s3.snapshot.json b/tests/aws/services/cloudformation/resources/test_s3.snapshot.json new file mode 100644 index 0000000000000..d39c2900a9cef --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_s3.snapshot.json @@ -0,0 +1,175 @@ +{ + "tests/aws/services/cloudformation/resources/test_s3.py::test_cors_configuration": { + "recorded-date": "20-04-2023, 20:17:17", + "recorded-content": { + "cors-info-optional": { + "CORSRules": [ + { + "AllowedHeaders": [ + "*", + "x-amz-*" + ], + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ], + "ExposeHeaders": [ + "Date" + ], + "ID": "test-cors-id", + "MaxAgeSeconds": 3600 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cors-info-only-required": { + "CORSRules": [ + { + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_website_configuration": { + "recorded-date": "02-06-2023, 18:24:39", + "recorded-content": { + "get_bucket_website": { + "ErrorDocument": { + "Key": "error.html" + }, + "IndexDocument": { + "Suffix": "index.html" + }, + "RoutingRules": [ + { + "Condition": { + "HttpErrorCodeReturnedEquals": "404", + "KeyPrefixEquals": "out1/" + }, + "Redirect": { + "ReplaceKeyWith": "redirected.html" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_object_lock_configuration": { + "recorded-date": "15-01-2024, 02:31:58", + "recorded-content": { + "object-lock-info-with-configuration": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Days": 2, + "Mode": "GOVERNANCE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-lock-info-only-enabled": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_bucketpolicy": { + "recorded-date": "31-05-2024, 13:41:44", + "recorded-content": { + "bucket": { + "BucketName": "" + }, + "get-policy-true": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Resource": [ + "arn::s3:::", + "arn::s3:::/*" + ] + } + ] + }, + "no-policy": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucketPolicy", + "Message": "The bucket policy does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_cfn_handle_s3_notification_configuration": { + "recorded-date": "20-06-2024, 16:57:13", + "recorded-content": { + "get_bucket_notification_configuration": { + "QueueConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Id": "", + "QueueArn": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_bucket_notification_configuration_error": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_s3.validation.json b/tests/aws/services/cloudformation/resources/test_s3.validation.json new file mode 100644 index 0000000000000..4bea4f6890cbe --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_s3.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/resources/test_s3.py::test_bucket_versioning": { + "last_validated_date": "2024-05-31T13:44:37+00:00" + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_bucketpolicy": { + "last_validated_date": "2024-05-31T13:41:44+00:00" + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_cfn_handle_s3_notification_configuration": { + "last_validated_date": "2024-06-20T16:57:13+00:00" + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_cors_configuration": { + "last_validated_date": "2023-04-20T18:17:17+00:00" + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_object_lock_configuration": { + "last_validated_date": "2024-01-15T02:31:58+00:00" + }, + "tests/aws/services/cloudformation/resources/test_s3.py::test_website_configuration": { + "last_validated_date": "2023-06-02T16:24:39+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_sam.py b/tests/aws/services/cloudformation/resources/test_sam.py new file mode 100644 index 0000000000000..e92b6afcf81a9 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sam.py @@ -0,0 +1,83 @@ +import json +import os +import os.path + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + + +@markers.aws.validated +def test_sam_policies(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.iam_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sam_function-policies.yaml" + ) + ) + role_name = stack.outputs["HelloWorldFunctionIamRoleName"] + + roles = aws_client.iam.list_attached_role_policies(RoleName=role_name) + assert "AmazonSNSFullAccess" in [p["PolicyName"] for p in roles["AttachedPolicies"]] + snapshot.match("list_attached_role_policies", roles) + + +@markers.aws.validated +def test_sam_template(deploy_cfn_template, aws_client): + # deploy template + func_name = f"test-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/template4.yaml"), + parameters={"FunctionName": func_name}, + ) + + # run Lambda test invocation + result = aws_client.lambda_.invoke(FunctionName=func_name) + result = json.load(result["Payload"]) + assert result == {"hello": "world"} + + +@markers.aws.validated +def test_sam_sqs_event(deploy_cfn_template, aws_client): + result_key = f"event-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sam_sqs_template.yml" + ), + parameters={"ResultKey": result_key}, + ) + + queue_url = stack.outputs["QueueUrl"] + bucket_name = stack.outputs["BucketName"] + + message_body = "test" + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody=message_body) + + def get_object(): + return json.loads( + aws_client.s3.get_object(Bucket=bucket_name, Key=result_key)["Body"].read().decode() + )["Records"][0]["body"] + + body = retry(get_object, retries=10, sleep=5.0) + + assert body == message_body + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..tags", "$..Configuration.CodeSha256"]) +def test_cfn_handle_serverless_api_resource(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../../templates/sam_api.yml"), + ) + + response = aws_client.apigateway.get_rest_api(restApiId=stack.outputs["ApiId"]) + snapshot.match("get_rest_api", response) + + response = aws_client.lambda_.get_function(FunctionName=stack.outputs["LambdaFunction"]) + snapshot.match("get_function", response) + + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) diff --git a/tests/aws/services/cloudformation/resources/test_sam.snapshot.json b/tests/aws/services/cloudformation/resources/test_sam.snapshot.json new file mode 100644 index 0000000000000..7acf32c30322a --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sam.snapshot.json @@ -0,0 +1,107 @@ +{ + "tests/aws/services/cloudformation/resources/test_sam.py::test_sam_policies": { + "recorded-date": "11-07-2023, 18:08:53", + "recorded-content": { + "list_attached_role_policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/", + "PolicyName": "" + }, + { + "PolicyArn": "arn::iam::aws:policy/", + "PolicyName": "" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_sam.py::test_cfn_handle_serverless_api_resource": { + "recorded-date": "15-07-2025, 19:31:46", + "recorded-content": { + "get_rest_api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "Api", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "EvPuzuBz5Tmw0kKjgaQva4dsYcd10oxkSwFlAElJESw=", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "Lambda", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "lambda:createdBy": "SAM" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_sam.validation.json b/tests/aws/services/cloudformation/resources/test_sam.validation.json new file mode 100644 index 0000000000000..b618d7e04ffbb --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sam.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/resources/test_sam.py::test_cfn_handle_serverless_api_resource": { + "last_validated_date": "2025-07-15T19:32:08+00:00", + "durations_in_seconds": { + "setup": 0.46, + "call": 41.1, + "teardown": 21.72, + "total": 63.28 + } + }, + "tests/aws/services/cloudformation/resources/test_sam.py::test_sam_policies": { + "last_validated_date": "2023-07-11T16:08:53+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sam.py::test_sam_sqs_event": { + "last_validated_date": "2024-04-19T19:45:49+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_secretsmanager.py b/tests/aws/services/cloudformation/resources/test_secretsmanager.py new file mode 100644 index 0000000000000..8166fba755aee --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_secretsmanager.py @@ -0,0 +1,107 @@ +import json +import os + +import aws_cdk as cdk +import botocore.exceptions +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..VersionIdsToStages"]) +def test_cfn_secretsmanager_gen_secret(deploy_cfn_template, aws_client, snapshot): + secret_name = f"dev/db/pass-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/secretsmanager_secret.yml" + ), + parameters={"SecretName": secret_name}, + ) + + secret = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + snapshot.match("secret", secret) + snapshot.add_transformer(snapshot.transform.regex(rf"{secret_name}-\w+", "")) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + + # assert that secret has been generated and added to the result template JSON + secret_value = aws_client.secretsmanager.get_secret_value(SecretId=secret_name)["SecretString"] + secret_json = json.loads(secret_value) + assert "password" in secret_json + assert len(secret_json["password"]) == 30 + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..VersionIdsToStages"]) +def test_cfn_handle_secretsmanager_secret(deploy_cfn_template, aws_client, snapshot): + secret_name = f"secret-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/secretsmanager_secret.yml" + ), + parameters={"SecretName": secret_name}, + ) + + rs = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + snapshot.match("secret", rs) + snapshot.add_transformer(snapshot.transform.regex(rf"{secret_name}-\w+", "")) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + + stack.destroy() + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.secretsmanager.describe_secret(SecretId=secret_name) + + snapshot.match("exception", ex.value.response) + + +@markers.aws.validated +@pytest.mark.parametrize("block_public_policy", ["true", "default"]) +def test_cfn_secret_policy(deploy_cfn_template, block_public_policy, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/secretsmanager_secret_policy.yml" + ), + parameters={"BlockPublicPolicy": block_public_policy}, + ) + secret_id = stack.outputs["SecretId"] + + snapshot.match("outputs", stack.outputs) + secret_name = stack.outputs["SecretId"].split(":")[-1] + snapshot.add_transformer(snapshot.transform.regex(secret_name, "")) + + res = aws_client.secretsmanager.get_resource_policy(SecretId=secret_id) + snapshot.match("resource_policy", res) + snapshot.add_transformer(snapshot.transform.key_value("Name", "policy-name")) + + +@markers.aws.validated +def test_cdk_deployment_generates_secret_value_if_no_value_is_provided( + aws_client, snapshot, infrastructure_setup +): + infra = infrastructure_setup(namespace="SecretGeneration") + stack_name = f"SecretGeneration{short_uid()}" + stack = cdk.Stack(infra.cdk_app, stack_name=stack_name) + + secret_name = f"my_secret{short_uid()}" + secret = cdk.aws_secretsmanager.Secret(stack, id=secret_name, secret_name=secret_name) + + cdk.CfnOutput(stack, "SecretName", value=secret.secret_name) + cdk.CfnOutput(stack, "SecretARN", value=secret.secret_arn) + + with infra.provisioner() as prov: + outputs = prov.get_stack_outputs(stack_name=stack_name) + + secret_name = outputs["SecretName"] + secret_arn = outputs["SecretARN"] + + response = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + + snapshot.add_transformer( + snapshot.transform.key_value("SecretString", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.regex(secret_arn, "")) + snapshot.add_transformer(snapshot.transform.regex(secret_name, "")) + + snapshot.match("generated_key", response) diff --git a/tests/aws/services/cloudformation/resources/test_secretsmanager.snapshot.json b/tests/aws/services/cloudformation/resources/test_secretsmanager.snapshot.json new file mode 100644 index 0000000000000..bce81e41e19ca --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_secretsmanager.snapshot.json @@ -0,0 +1,162 @@ +{ + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secret_policy[true]": { + "recorded-date": "03-07-2024, 18:51:39", + "recorded-content": { + "outputs": { + "SecretId": "arn::secretsmanager::111111111111:secret:", + "SecretPolicyArn": "arn::secretsmanager::111111111111:secret:" + }, + "resource_policy": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResourcePolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:root" + }, + "Action": "secretsmanager:ReplicateSecretToRegions", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secret_policy[default]": { + "recorded-date": "03-07-2024, 18:52:05", + "recorded-content": { + "outputs": { + "SecretId": "arn::secretsmanager::111111111111:secret:", + "SecretPolicyArn": "arn::secretsmanager::111111111111:secret:" + }, + "resource_policy": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResourcePolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:root" + }, + "Action": "secretsmanager:ReplicateSecretToRegions", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cdk_deployment_generates_secret_value_if_no_value_is_provided": { + "recorded-date": "23-05-2024, 17:15:31", + "recorded-content": { + "generated_key": { + "ARN": "", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "secret-string", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secretsmanager_gen_secret": { + "recorded-date": "03-07-2024, 15:39:56", + "recorded-content": { + "secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Aurora Password", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-63e3fdc5" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "Secret" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-63e3fdc5/79663e60-3952-11ef-809b-0affeb5ce635" + } + ], + "VersionIdsToStages": { + "2b1f1af7-47ee-aee1-5609-991d4352ae14": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_handle_secretsmanager_secret": { + "recorded-date": "11-10-2024, 17:00:31", + "recorded-content": { + "secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Aurora Password", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-ab33fda4" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "Secret" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-ab33fda4/47ecee80-87f2-11ef-8f16-0a113fcea55f" + } + ], + "VersionIdsToStages": { + "c80fca61-0302-7921-4b9b-c2c16bc6f457": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Secrets Manager can't find the specified secret." + }, + "Message": "Secrets Manager can't find the specified secret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_secretsmanager.validation.json b/tests/aws/services/cloudformation/resources/test_secretsmanager.validation.json new file mode 100644 index 0000000000000..53e71f633e0d3 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_secretsmanager.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cdk_deployment_generates_secret_value_if_no_value_is_provided": { + "last_validated_date": "2024-05-23T17:15:31+00:00" + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_handle_secretsmanager_secret": { + "last_validated_date": "2024-10-11T17:00:31+00:00" + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secret_policy[default]": { + "last_validated_date": "2024-08-01T12:22:53+00:00" + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secret_policy[true]": { + "last_validated_date": "2024-08-01T12:22:32+00:00" + }, + "tests/aws/services/cloudformation/resources/test_secretsmanager.py::test_cfn_secretsmanager_gen_secret": { + "last_validated_date": "2024-07-03T15:39:56+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_sns.py b/tests/aws/services/cloudformation/resources/test_sns.py new file mode 100644 index 0000000000000..340804f122261 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sns.py @@ -0,0 +1,413 @@ +import os.path + +import aws_cdk as cdk +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import parse_arn +from localstack.utils.common import short_uid + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.DeliveryPolicy", + "$..Attributes.EffectiveDeliveryPolicy", + "$..Attributes.Policy.Statement..Action", # SNS:Receive is added by moto but not returned in AWS + ] +) +def test_sns_topic_fifo_with_deduplication(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("TopicArn")) + topic_name = f"topic-{short_uid()}.fifo" + + deploy_cfn_template( + parameters={"TopicName": topic_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_fifo_dedup.yaml" + ), + ) + + topics = aws_client.sns.list_topics()["Topics"] + topic_arns = [t["TopicArn"] for t in topics] + + filtered_topics = [t for t in topic_arns if topic_name in t] + assert len(filtered_topics) == 1 + + # assert that the topic is properly created as Fifo + topic_attrs = aws_client.sns.get_topic_attributes(TopicArn=filtered_topics[0]) + snapshot.match("get-topic-attrs", topic_attrs) + + +@markers.aws.needs_fixing +def test_sns_topic_fifo_without_suffix_fails(deploy_cfn_template, aws_client): + stack_name = f"stack-{short_uid()}" + topic_name = f"topic-{short_uid()}" + path = os.path.join( + os.path.dirname(__file__), + "../../../templates/sns_topic_fifo_dedup.yaml", + ) + + with pytest.raises(Exception) as ex: + deploy_cfn_template( + stack_name=stack_name, template_path=path, parameters={"TopicName": topic_name} + ) + assert ex.typename == "StackDeployError" + + stack = aws_client.cloudformation.describe_stacks(StackName=stack_name)["Stacks"][0] + if is_aws_cloud(): + assert stack.get("StackStatus") in ["ROLLBACK_COMPLETED", "ROLLBACK_IN_PROGRESS"] + else: + assert stack.get("StackStatus") == "CREATE_FAILED" + + +@markers.aws.validated +def test_sns_subscription(deploy_cfn_template, aws_client): + topic_name = f"topic-{short_uid()}" + queue_name = f"topic-{short_uid()}" + stack = deploy_cfn_template( + parameters={"TopicName": topic_name, "QueueName": queue_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_subscription.yaml" + ), + ) + + topic_arn = stack.outputs["TopicArnOutput"] + assert topic_arn is not None + + subscriptions = aws_client.sns.list_subscriptions_by_topic(TopicArn=topic_arn) + assert len(subscriptions["Subscriptions"]) > 0 + + +@markers.aws.validated +def test_deploy_stack_with_sns_topic(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/deploy_template_2.yaml" + ), + parameters={"CompanyName": "MyCompany", "MyEmail1": "my@email.com"}, + ) + assert len(stack.outputs) == 3 + + topic_arn = stack.outputs["MyTopic"] + rs = aws_client.sns.list_topics() + + # Topic resource created + topics = [tp for tp in rs["Topics"] if tp["TopicArn"] == topic_arn] + assert len(topics) == 1 + + stack.destroy() + + # assert topic resource removed + rs = aws_client.sns.list_topics() + topics = [tp for tp in rs["Topics"] if tp["TopicArn"] == topic_arn] + assert not topics + + +@markers.aws.validated +def test_update_subscription(snapshot, deploy_cfn_template, aws_client, sqs_queue, sns_topic): + topic_arn = sns_topic["Attributes"]["TopicArn"] + queue_url = sqs_queue + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + stack = deploy_cfn_template( + parameters={"TopicArn": topic_arn, "QueueArn": queue_arn}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_subscription.yml" + ), + ) + sub_arn = stack.outputs["SubscriptionArn"] + subscription = aws_client.sns.get_subscription_attributes(SubscriptionArn=sub_arn) + snapshot.match("subscription-1", subscription) + + deploy_cfn_template( + parameters={"TopicArn": topic_arn, "QueueArn": queue_arn}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_subscription_update.yml" + ), + stack_name=stack.stack_name, + is_update=True, + ) + subscription_updated = aws_client.sns.get_subscription_attributes(SubscriptionArn=sub_arn) + snapshot.match("subscription-2", subscription_updated) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + +@markers.aws.validated +def test_sns_topic_with_attributes(infrastructure_setup, aws_client, snapshot): + infra = infrastructure_setup(namespace="SnsTests") + stack_name = f"stack-{short_uid()}" + stack = cdk.Stack(infra.cdk_app, stack_name=stack_name) + + # Add more configurations here conform they are needed to be tested + topic = cdk.aws_sns.Topic(stack, id="Topic", fifo=True, message_retention_period_in_days=30) + + cdk.CfnOutput(stack, "TopicArn", value=topic.topic_arn) + with infra.provisioner() as prov: + outputs = prov.get_stack_outputs(stack_name=stack_name) + response = aws_client.sns.get_topic_attributes( + TopicArn=outputs["TopicArn"], + ) + snapshot.match("topic-archive-policy", response["Attributes"]["ArchivePolicy"]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Statement..Action", # TODO: see https://github.com/getmoto/moto/pull/9041 + ] +) +def test_sns_topic_policy_resets_to_default( + sns_topic, infrastructure_setup, aws_client, snapshot, account_id +): + """Tests the delete statement of a ``AWS::SNS::TopicPolicy`` resource, which should reset the topic's policy to the + default policy.""" + # simulate a pre-existing topic + existing_topic_arn = sns_topic["Attributes"]["TopicArn"] + existing_topic_name = parse_arn(existing_topic_arn)["resource"] + snapshot.add_transformer(snapshot.transform.regex(existing_topic_name, "")) + + # create the stack + stack_name = "SnsTopicPolicyStack" + infra = infrastructure_setup(namespace="SnsTests") + # persisting the stack means persisting the existing_topic_arn reference, but that changes every test run + infra.persist_output = False + stack = cdk.Stack(infra.cdk_app, stack_name=stack_name) + + # get the existing topic + topic = cdk.aws_sns.Topic.from_topic_arn(stack, "Topic", existing_topic_arn) + + # add the topic policy resource + topic_policy = cdk.aws_sns.TopicPolicy(stack, "CustomTopicPolicy", topics=[topic]) + topic_policy.document.add_statements( + cdk.aws_iam.PolicyStatement( + effect=cdk.aws_iam.Effect.ALLOW, + principals=[cdk.aws_iam.AnyPrincipal()], + actions=["sns:Publish"], + resources=[topic.topic_arn], + conditions={"StringEquals": {"aws:SourceAccount": account_id}}, + ) + ) + + # snapshot its policy + default = aws_client.sns.get_topic_attributes(TopicArn=existing_topic_arn) + snapshot.match("default-topic-attributes", default["Attributes"]["Policy"]) + + # deploy the stack + cdk.CfnOutput(stack, "TopicArn", value=topic.topic_arn) + with infra.provisioner() as prov: + assert prov.get_stack_outputs(stack_name=stack_name)["TopicArn"] == existing_topic_arn + + modified = aws_client.sns.get_topic_attributes(TopicArn=existing_topic_arn) + snapshot.match("modified-topic-attributes", modified["Attributes"]["Policy"]) + + # now that it's destroyed, get the topic attributes again + reverted = aws_client.sns.get_topic_attributes(TopicArn=existing_topic_arn) + snapshot.match("reverted-topic-attributes", reverted["Attributes"]["Policy"]) + + +@markers.aws.validated +def test_sns_subscription_region( + snapshot, + deploy_cfn_template, + aws_client, + sqs_queue, + aws_client_factory, + region_name, + secondary_region_name, + cleanups, +): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.regex(secondary_region_name, "")) + topic_name = f"topic-{short_uid()}" + # we create a topic in a secondary region, different from the stack + sns_client = aws_client_factory(region_name=secondary_region_name).sns + topic_arn = sns_client.create_topic(Name=topic_name)["TopicArn"] + cleanups.append(lambda: sns_client.delete_topic(TopicArn=topic_arn)) + + queue_url = sqs_queue + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + # we want to deploy the Stack in a different region than the Topic, to see how CloudFormation properly does the + # `Subscribe` call in the `Region` parameter of the Subscription resource + stack = deploy_cfn_template( + parameters={ + "TopicArn": topic_arn, + "QueueArn": queue_arn, + "TopicRegion": secondary_region_name, + }, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_subscription_cross_region.yml" + ), + ) + sub_arn = stack.outputs["SubscriptionArn"] + subscription = sns_client.get_subscription_attributes(SubscriptionArn=sub_arn) + snapshot.match("subscription-1", subscription) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.DeliveryPolicy", + "$..Attributes.EffectiveDeliveryPolicy", + "$..Attributes.Policy.Statement..Action", # SNS:Receive is added by moto but not returned in AWS + ] +) +def test_sns_topic_update_attributes(deploy_cfn_template, aws_client, snapshot): + """Test updating SNS Topic DisplayName and Tags.""" + snapshot.add_transformer(snapshot.transform.key_value("TopicArn")) + snapshot.add_transformer( + snapshot.transform.key_value( + "SubscriptionArn", "PendingConfirmation", reference_replacement=False + ), + ) + + topic_name = f"test-topic-{short_uid()}" + + stack = deploy_cfn_template( + parameters={ + "TopicName": topic_name, + "DisplayName": "Initial Display Name", + "Environment": "test", # tag + "Project": "localstack", # tag + }, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_update.yaml" + ), + ) + + topic_arn = stack.outputs["TopicArn"] + + initial_attrs = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("initial-topic-attributes", initial_attrs) + + initial_tags = aws_client.sns.list_tags_for_resource(ResourceArn=topic_arn) + tag_dict = {tag["Key"]: tag["Value"] for tag in initial_tags["Tags"]} + assert tag_dict["Environment"] == "test" + assert tag_dict["Project"] == "localstack" + + initial_subscriptions = aws_client.sns.list_subscriptions_by_topic(TopicArn=topic_arn) + snapshot.match("initial-subscriptions", initial_subscriptions) + + deploy_cfn_template( + parameters={ + "TopicName": topic_name, + "DisplayName": "Updated Display Name", + "Environment": "production", # tag + "Project": "backend", # tag + }, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_update.yaml" + ), + stack_name=stack.stack_name, + is_update=True, + ) + + updated_attrs = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("updated-topic-attributes", updated_attrs) + + updated_tags = aws_client.sns.list_tags_for_resource(ResourceArn=topic_arn) + updated_tag_dict = {tag["Key"]: tag["Value"] for tag in updated_tags["Tags"]} + assert updated_tag_dict["Environment"] == "production" + assert updated_tag_dict["Project"] == "backend" + + # Subscriptions should be preserved + new_subscriptions = aws_client.sns.list_subscriptions_by_topic(TopicArn=topic_arn) + snapshot.match("new-subscriptions", new_subscriptions) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.DeliveryPolicy", + "$..Attributes.EffectiveDeliveryPolicy", + "$..Attributes.Policy.Statement..Action", + ] +) +def test_sns_topic_update_name(deploy_cfn_template, aws_client, snapshot): + """Test updating SNS Topic with TopicName change (requires resource replacement).""" + snapshot.add_transformer(snapshot.transform.key_value("TopicArn")) + snapshot.add_transformer( + snapshot.transform.key_value( + "SubscriptionArn", "PendingConfirmation", reference_replacement=False + ), + ) + + initial_topic_name = f"test-topic-{short_uid()}" + + stack = deploy_cfn_template( + parameters={ + "TopicName": initial_topic_name, + "DisplayName": "Initial Display Name", + "Environment": "test", # tag + "Project": "localstack", # tag + }, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_update.yaml" + ), + ) + + initial_topic_arn = stack.outputs["TopicArn"] + + initial_attrs = aws_client.sns.get_topic_attributes(TopicArn=initial_topic_arn) + snapshot.match("initial-topic-attributes", initial_attrs) + + # Store initial tags to verify they are preserved + initial_tags = aws_client.sns.list_tags_for_resource(ResourceArn=initial_topic_arn) + initial_tag_dict = {tag["Key"]: tag["Value"] for tag in initial_tags["Tags"]} + assert initial_tag_dict["Environment"] == "test" + assert initial_tag_dict["Project"] == "localstack" + + # Get initial subscriptions + initial_subscriptions = aws_client.sns.list_subscriptions_by_topic(TopicArn=initial_topic_arn) + snapshot.match("initial-subscriptions", initial_subscriptions) + + new_topic_name = f"test-topic-new-{short_uid()}" + + # Update the stack with new TopicName + updated_stack = deploy_cfn_template( + parameters={ + "TopicName": new_topic_name, + "DisplayName": "Updated Display Name", + "Environment": "production", # tag + "Project": "localstack", # tag + }, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_topic_update.yaml" + ), + stack_name=stack.stack_name, + is_update=True, + ) + + new_topic_arn = updated_stack.outputs["TopicArn"] + assert new_topic_arn != initial_topic_arn # Confirm topic was replaced + + # Verify new topic state + new_attrs = aws_client.sns.get_topic_attributes(TopicArn=new_topic_arn) + snapshot.match("new-topic-attributes", new_attrs) + + # Verify tags were preserved and updated + new_tags = aws_client.sns.list_tags_for_resource(ResourceArn=new_topic_arn) + new_tag_dict = {tag["Key"]: tag["Value"] for tag in new_tags["Tags"]} + + # Assert tags were preserved (Project tag should still exist) + assert "Project" in new_tag_dict + assert new_tag_dict["Project"] == initial_tag_dict["Project"] # Should be "localstack" + # Assert Environment tag was updated + assert new_tag_dict["Environment"] == "production" + + # Verify subscriptions were preserved + new_subscriptions = aws_client.sns.list_subscriptions_by_topic(TopicArn=new_topic_arn) + snapshot.match("new-subscriptions", new_subscriptions) + + # Verify old topic was deleted + try: + aws_client.sns.get_topic_attributes(TopicArn=initial_topic_arn) + raise AssertionError("Old topic should have been deleted") + except aws_client.sns.exceptions.NotFoundException: + # Expected - old topic should be deleted + pass diff --git a/tests/aws/services/cloudformation/resources/test_sns.snapshot.json b/tests/aws/services/cloudformation/resources/test_sns.snapshot.json new file mode 100644 index 0000000000000..a2c5c8ca6e2d7 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sns.snapshot.json @@ -0,0 +1,530 @@ +{ + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": { + "recorded-date": "27-11-2023, 21:27:29", + "recorded-content": { + "get-topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "true", + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_update_subscription": { + "recorded-date": "29-03-2024, 21:16:26", + "recorded-content": { + "subscription-1": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-2": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_with_attributes": { + "recorded-date": "16-08-2024, 15:44:50", + "recorded-content": { + "topic-archive-policy": { + "MessageRetentionPeriod": "30" + } + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_subscription_region": { + "recorded-date": "28-05-2025, 10:47:01", + "recorded-content": { + "subscription-1": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_update_attributes": { + "recorded-date": "03-07-2025, 17:18:54", + "recorded-content": { + "initial-topic-attributes": { + "Attributes": { + "DisplayName": "Initial Display Name", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "initial-subscriptions": { + "Subscriptions": [ + { + "Endpoint": "test@example.com", + "Owner": "111111111111", + "Protocol": "email", + "SubscriptionArn": "PendingConfirmation", + "TopicArn": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated-topic-attributes": { + "Attributes": { + "DisplayName": "Updated Display Name", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new-subscriptions": { + "Subscriptions": [ + { + "Endpoint": "test@example.com", + "Owner": "111111111111", + "Protocol": "email", + "SubscriptionArn": "PendingConfirmation", + "TopicArn": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_update_name": { + "recorded-date": "03-07-2025, 17:17:06", + "recorded-content": { + "initial-topic-attributes": { + "Attributes": { + "DisplayName": "Initial Display Name", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "initial-subscriptions": { + "Subscriptions": [ + { + "Endpoint": "test@example.com", + "Owner": "111111111111", + "Protocol": "email", + "SubscriptionArn": "PendingConfirmation", + "TopicArn": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new-topic-attributes": { + "Attributes": { + "DisplayName": "Updated Display Name", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new-subscriptions": { + "Subscriptions": [ + { + "Endpoint": "test@example.com", + "Owner": "111111111111", + "Protocol": "email", + "SubscriptionArn": "PendingConfirmation", + "TopicArn": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_policy_resets_to_default": { + "recorded-date": "04-07-2025, 00:04:32", + "recorded-content": { + "default-topic-attributes": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "modified-topic-attributes": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "0", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": "sns:Publish", + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "aws:SourceAccount": "111111111111" + } + } + } + ] + }, + "reverted-topic-attributes": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_sns.validation.json b/tests/aws/services/cloudformation/resources/test_sns.validation.json new file mode 100644 index 0000000000000..52731d78dd633 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sns.validation.json @@ -0,0 +1,47 @@ +{ + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_subscription_region": { + "last_validated_date": "2025-05-28T10:46:56+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": { + "last_validated_date": "2023-11-27T20:27:29+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_update_attributes": { + "last_validated_date": "2025-07-03T17:19:44+00:00", + "durations_in_seconds": { + "setup": 0.59, + "call": 41.07, + "teardown": 50.12, + "total": 91.78 + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_update_name": { + "last_validated_date": "2025-07-03T17:17:56+00:00", + "durations_in_seconds": { + "setup": 0.54, + "call": 73.16, + "teardown": 50.36, + "total": 124.06 + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_policy_resets_to_default": { + "last_validated_date": "2025-07-04T00:04:32+00:00", + "durations_in_seconds": { + "setup": 1.08, + "call": 22.27, + "teardown": 0.09, + "total": 23.44 + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_with_attributes": { + "last_validated_date": "2025-07-03T23:32:29+00:00", + "durations_in_seconds": { + "setup": 0.8, + "call": 21.33, + "teardown": 0.0, + "total": 22.13 + } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_update_subscription": { + "last_validated_date": "2024-03-29T21:16:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_sqs.py b/tests/aws/services/cloudformation/resources/test_sqs.py new file mode 100644 index 0000000000000..d054c3f82a55d --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sqs.py @@ -0,0 +1,143 @@ +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + + +@markers.aws.validated +def test_sqs_queue_policy(deploy_cfn_template, aws_client, snapshot): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_with_queuepolicy.yaml" + ) + ) + queue_url = result.outputs["QueueUrlOutput"] + resp = aws_client.sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["Policy"]) + snapshot.match("policy", resp) + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + + +@markers.aws.validated +def test_sqs_fifo_queue_generates_valid_name(deploy_cfn_template): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_fifo_autogenerate_name.yaml" + ), + parameters={"IsFifo": "true"}, + max_wait=240, + ) + assert ".fifo" in result.outputs["FooQueueName"] + + +@markers.aws.validated +def test_sqs_non_fifo_queue_generates_valid_name(deploy_cfn_template): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_fifo_autogenerate_name.yaml" + ), + parameters={"IsFifo": "false"}, + max_wait=240, + ) + assert ".fifo" not in result.outputs["FooQueueName"] + + +@markers.aws.validated +def test_cfn_handle_sqs_resource(deploy_cfn_template, aws_client, snapshot): + queue_name = f"queue-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_fifo_queue.yml" + ), + parameters={"QueueName": queue_name}, + ) + + rs = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueURL"], AttributeNames=["All"] + ) + snapshot.match("queue", rs) + snapshot.add_transformer(snapshot.transform.regex(queue_name, "")) + + # clean up + stack.destroy() + + with pytest.raises(ClientError) as ctx: + aws_client.sqs.get_queue_url(QueueName=f"{queue_name}.fifo") + snapshot.match("error", ctx.value.response) + + +@markers.aws.validated +def test_update_queue_no_change(deploy_cfn_template, aws_client, snapshot): + bucket_name = f"bucket-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_queue_update_no_change.yml" + ), + parameters={ + "AddBucket": "false", + "BucketName": bucket_name, + }, + ) + queue_url = stack.outputs["QueueUrl"] + queue_arn = stack.outputs["QueueArn"] + snapshot.add_transformer(snapshot.transform.regex(queue_url, "")) + snapshot.add_transformer(snapshot.transform.regex(queue_arn, "")) + + snapshot.match("outputs-1", stack.outputs) + + # deploy a second time with no change to the SQS queue + updated_stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_queue_update_no_change.yml" + ), + is_update=True, + stack_name=stack.stack_name, + parameters={ + "AddBucket": "true", + "BucketName": bucket_name, + }, + ) + snapshot.match("outputs-2", updated_stack.outputs) + + +@markers.aws.validated +def test_update_sqs_queuepolicy(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_with_queuepolicy.yaml" + ) + ) + + policy = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueUrlOutput"], AttributeNames=["Policy"] + ) + snapshot.match("policy1", policy["Attributes"]["Policy"]) + + updated_stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sqs_with_queuepolicy_updated.yaml" + ), + is_update=True, + stack_name=stack.stack_name, + ) + + def check_policy_updated(): + policy_updated = aws_client.sqs.get_queue_attributes( + QueueUrl=updated_stack.outputs["QueueUrlOutput"], AttributeNames=["Policy"] + ) + assert policy_updated["Attributes"]["Policy"] != policy["Attributes"]["Policy"] + return policy_updated + + wait_until(check_policy_updated) + + policy = aws_client.sqs.get_queue_attributes( + QueueUrl=updated_stack.outputs["QueueUrlOutput"], AttributeNames=["Policy"] + ) + + snapshot.match("policy2", policy["Attributes"]["Policy"]) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) diff --git a/tests/aws/services/cloudformation/resources/test_sqs.snapshot.json b/tests/aws/services/cloudformation/resources/test_sqs.snapshot.json new file mode 100644 index 0000000000000..118cc86349d2d --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sqs.snapshot.json @@ -0,0 +1,119 @@ +{ + "tests/aws/services/cloudformation/resources/test_sqs.py::test_update_queue_no_change": { + "recorded-date": "08-12-2023, 21:11:26", + "recorded-content": { + "outputs-1": { + "QueueArn": "", + "QueueUrl": "" + }, + "outputs-2": { + "QueueArn": "", + "QueueUrl": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_update_sqs_queuepolicy": { + "recorded-date": "27-03-2024, 20:30:24", + "recorded-content": { + "policy1": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ], + "Resource": "arn::sqs::111111111111:" + } + ] + }, + "policy2": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Principal": "*", + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ], + "Resource": "arn::sqs::111111111111:" + } + ] + } + } + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_sqs_queue_policy": { + "recorded-date": "03-07-2024, 19:49:04", + "recorded-content": { + "policy": { + "Attributes": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ], + "Resource": "" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_cfn_handle_sqs_resource": { + "recorded-date": "03-07-2024, 20:03:51", + "recorded-content": { + "queue": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:.fifo", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": { + "Error": { + "Code": "AWS.SimpleQueueService.NonExistentQueue", + "Message": "The specified queue does not exist.", + "QueryErrorCode": "QueueDoesNotExist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_sqs.validation.json b/tests/aws/services/cloudformation/resources/test_sqs.validation.json new file mode 100644 index 0000000000000..c28ce1e66b2ee --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_sqs.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/resources/test_sqs.py::test_cfn_handle_sqs_resource": { + "last_validated_date": "2024-07-03T20:03:51+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_sqs_fifo_queue_generates_valid_name": { + "last_validated_date": "2024-05-15T02:01:00+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_sqs_non_fifo_queue_generates_valid_name": { + "last_validated_date": "2024-05-15T01:59:34+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_sqs_queue_policy": { + "last_validated_date": "2024-07-03T19:49:04+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_update_queue_no_change": { + "last_validated_date": "2023-12-08T20:11:26+00:00" + }, + "tests/aws/services/cloudformation/resources/test_sqs.py::test_update_sqs_queuepolicy": { + "last_validated_date": "2024-03-27T20:30:23+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_ssm.py b/tests/aws/services/cloudformation/resources/test_ssm.py new file mode 100644 index 0000000000000..4d4c7616d6378 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_ssm.py @@ -0,0 +1,155 @@ +import os.path + +import botocore.exceptions +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) +def test_parameter_defaults(deploy_cfn_template, aws_client, snapshot): + ssm_parameter_value = f"custom-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ssm_parameter_defaultname.yaml" + ), + parameters={"Input": ssm_parameter_value}, + ) + + parameter_name = stack.outputs["CustomParameterOutput"] + param = aws_client.ssm.get_parameter(Name=parameter_name) + snapshot.match("ssm_parameter", param) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + snapshot.add_transformer(snapshot.transform.key_value("Value")) + + stack.destroy() + + with pytest.raises(botocore.exceptions.ClientError) as ctx: + aws_client.ssm.get_parameter(Name=parameter_name) + snapshot.match("ssm_parameter_not_found", ctx.value.response) + + +@markers.aws.validated +def test_update_ssm_parameters(deploy_cfn_template, aws_client): + ssm_parameter_value = f"custom-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ssm_parameter_defaultname.yaml" + ), + parameters={"Input": ssm_parameter_value}, + ) + + ssm_parameter_value = f"new-custom-{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ssm_parameter_defaultname.yaml" + ), + parameters={"Input": ssm_parameter_value}, + ) + + parameter_name = stack.outputs["CustomParameterOutput"] + param = aws_client.ssm.get_parameter(Name=parameter_name) + assert param["Parameter"]["Value"] == ssm_parameter_value + + +@markers.aws.validated +def test_update_ssm_parameter_tag(deploy_cfn_template, aws_client): + ssm_parameter_value = f"custom-{short_uid()}" + tag_value = f"tag-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ssm_parameter_defaultname_withtags.yaml" + ), + parameters={ + "Input": ssm_parameter_value, + "TagValue": tag_value, + }, + ) + parameter_name = stack.outputs["CustomParameterOutput"] + ssm_tags = aws_client.ssm.list_tags_for_resource( + ResourceType="Parameter", ResourceId=parameter_name + )["TagList"] + tags_pre_update = {tag["Key"]: tag["Value"] for tag in ssm_tags} + assert tags_pre_update["A"] == tag_value + + tag_value_new = f"tag-{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ssm_parameter_defaultname_withtags.yaml" + ), + parameters={ + "Input": ssm_parameter_value, + "TagValue": tag_value_new, + }, + ) + + ssm_tags = aws_client.ssm.list_tags_for_resource( + ResourceType="Parameter", ResourceId=parameter_name + )["TagList"] + tags_post_update = {tag["Key"]: tag["Value"] for tag in ssm_tags} + assert tags_post_update["A"] == tag_value_new + + # TODO: re-enable after fixing updates in general + # deploy_cfn_template( + # is_update=True, + # stack_name=stack.stack_name, + # template_path=os.path.join( + # os.path.dirname(__file__), "../../templates/ssm_parameter_defaultname.yaml" + # ), + # parameters={ + # "Input": ssm_parameter_value, + # }, + # ) + # + # ssm_tags = aws_client.ssm.list_tags_for_resource(ResourceType="Parameter", ResourceId=parameter_name)['TagList'] + # assert ssm_tags == [] + + +@markers.snapshot.skip_snapshot_verify(paths=["$..DriftInformation", "$..Metadata"]) +@markers.aws.validated +def test_deploy_patch_baseline(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ssm_patch_baseline.yml" + ), + ) + + describe_resource = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="myPatchBaseline" + )["StackResourceDetail"] + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer( + snapshot.transform.key_value("PhysicalResourceId", "physical_resource_id") + ) + snapshot.match("patch_baseline", describe_resource) + + +@markers.aws.validated +def test_maintenance_window(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/ssm_maintenance_window.yml" + ), + ) + + describe_resource = aws_client.cloudformation.describe_stack_resources( + StackName=stack.stack_name + )["StackResources"] + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer( + snapshot.transform.key_value("PhysicalResourceId", "physical_resource_id") + ) + snapshot.add_transformer( + SortingTransformer("MaintenanceWindow", lambda x: x["LogicalResourceId"]), priority=-1 + ) + snapshot.match("MaintenanceWindow", describe_resource) diff --git a/tests/aws/services/cloudformation/resources/test_ssm.snapshot.json b/tests/aws/services/cloudformation/resources/test_ssm.snapshot.json new file mode 100644 index 0000000000000..965db77613394 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_ssm.snapshot.json @@ -0,0 +1,117 @@ +{ + "tests/aws/services/cloudformation/resources/test_ssm.py::test_deploy_patch_baseline": { + "recorded-date": "05-07-2023, 10:13:24", + "recorded-content": { + "patch_baseline": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "myPatchBaseline", + "Metadata": {}, + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::PatchBaseline", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + } + } + }, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_maintenance_window": { + "recorded-date": "14-07-2023, 14:06:23", + "recorded-content": { + "MaintenanceWindow": [ + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchBaselineAML", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::PatchBaseline", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchBaselineAML2", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::PatchBaseline", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchServerMaintenanceWindow", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::MaintenanceWindow", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchServerMaintenanceWindowTarget", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::MaintenanceWindowTarget", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchServerTask", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::MaintenanceWindowTask", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + } + ] + } + }, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_parameter_defaults": { + "recorded-date": "03-07-2024, 20:30:04", + "recorded-content": { + "ssm_parameter": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "", + "Version": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "ssm_parameter_not_found": { + "Error": { + "Code": "ParameterNotFound", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_ssm.validation.json b/tests/aws/services/cloudformation/resources/test_ssm.validation.json new file mode 100644 index 0000000000000..7c58a30d142f5 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_ssm.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/resources/test_ssm.py::test_deploy_patch_baseline": { + "last_validated_date": "2023-07-05T08:13:24+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_maintenance_window": { + "last_validated_date": "2023-07-14T12:06:23+00:00" + }, + "tests/aws/services/cloudformation/resources/test_ssm.py::test_parameter_defaults": { + "last_validated_date": "2024-07-03T20:30:04+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_stack_sets.py b/tests/aws/services/cloudformation/resources/test_stack_sets.py new file mode 100644 index 0000000000000..f35fc30023d91 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_stack_sets.py @@ -0,0 +1,79 @@ +import os + +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + + +@pytest.fixture +def wait_stack_set_operation(aws_client): + def waiter(stack_set_name: str, operation_id: str): + def _operation_is_ready(): + operation = aws_client.cloudformation.describe_stack_set_operation( + StackSetName=stack_set_name, + OperationId=operation_id, + ) + return operation["StackSetOperation"]["Status"] not in ["RUNNING", "STOPPING"] + + wait_until(_operation_is_ready) + + return waiter + + +@markers.aws.validated +def test_create_stack_set_with_stack_instances( + account_id, + region_name, + aws_client, + snapshot, + wait_stack_set_operation, +): + snapshot.add_transformer(snapshot.transform.key_value("StackSetId", "stack-set-id")) + + stack_set_name = f"StackSet-{short_uid()}" + + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../templates/s3_cors_bucket.yaml") + ) + + result = aws_client.cloudformation.create_stack_set( + StackSetName=stack_set_name, + TemplateBody=template_body, + ) + + snapshot.match("create_stack_set", result) + + create_instances_result = aws_client.cloudformation.create_stack_instances( + StackSetName=stack_set_name, + Accounts=[account_id], + Regions=[region_name], + ) + + snapshot.match("create_stack_instances", create_instances_result) + + wait_stack_set_operation(stack_set_name, create_instances_result["OperationId"]) + + # make sure additional calls do not result in errors + # even the stack already exists, but returns operation id instead + create_instances_result = aws_client.cloudformation.create_stack_instances( + StackSetName=stack_set_name, + Accounts=[account_id], + Regions=[region_name], + ) + + assert "OperationId" in create_instances_result + + wait_stack_set_operation(stack_set_name, create_instances_result["OperationId"]) + + delete_instances_result = aws_client.cloudformation.delete_stack_instances( + StackSetName=stack_set_name, + Accounts=[account_id], + Regions=[region_name], + RetainStacks=False, + ) + wait_stack_set_operation(stack_set_name, delete_instances_result["OperationId"]) + + aws_client.cloudformation.delete_stack_set(StackSetName=stack_set_name) diff --git a/tests/aws/services/cloudformation/resources/test_stack_sets.snapshot.json b/tests/aws/services/cloudformation/resources/test_stack_sets.snapshot.json new file mode 100644 index 0000000000000..3585f6e07d3c7 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_stack_sets.snapshot.json @@ -0,0 +1,21 @@ +{ + "tests/aws/services/cloudformation/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": { + "recorded-date": "24-05-2023, 15:32:47", + "recorded-content": { + "create_stack_set": { + "StackSetId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_stack_instances": { + "OperationId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_stack_sets.validation.json b/tests/aws/services/cloudformation/resources/test_stack_sets.validation.json new file mode 100644 index 0000000000000..f7406f8c55f29 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_stack_sets.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": { + "last_validated_date": "2023-05-24T13:32:47+00:00" + } +} diff --git a/tests/aws/services/cloudformation/resources/test_stepfunctions.py b/tests/aws/services/cloudformation/resources/test_stepfunctions.py new file mode 100644 index 0000000000000..36b807157c367 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_stepfunctions.py @@ -0,0 +1,382 @@ +import json +import os +import urllib.parse + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer + +from localstack import config +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import await_execution_terminated +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + + +@markers.aws.validated +def test_statemachine_definitionsubstitution(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/stepfunctions_statemachine_substitutions.yaml", + ) + ) + + assert len(stack.outputs) == 1 + statemachine_arn = stack.outputs["StateMachineArnOutput"] + + # execute statemachine + ex_result = aws_client.stepfunctions.start_execution(stateMachineArn=statemachine_arn) + + def _is_executed(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=ex_result["executionArn"])[ + "status" + ] + != "RUNNING" + ) + + wait_until(_is_executed) + execution_desc = aws_client.stepfunctions.describe_execution( + executionArn=ex_result["executionArn"] + ) + assert execution_desc["status"] == "SUCCEEDED" + # sync execution is currently not supported since botocore adds a "sync-" prefix + # ex_result = stepfunctions_client.start_sync_execution(stateMachineArn=statemachine_arn) + + assert "hello from statemachine" in execution_desc["output"] + + +@markers.aws.validated +def test_nested_statemachine_with_sync2(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sfn_nested_sync2.json" + ) + ) + + parent_arn = stack.outputs["ParentStateMachineArnOutput"] + assert parent_arn + + ex_result = aws_client.stepfunctions.start_execution( + stateMachineArn=parent_arn, input='{"Value": 1}' + ) + + def _is_executed(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=ex_result["executionArn"])[ + "status" + ] + != "RUNNING" + ) + + wait_until(_is_executed) + execution_desc = aws_client.stepfunctions.describe_execution( + executionArn=ex_result["executionArn"] + ) + assert execution_desc["status"] == "SUCCEEDED" + output = json.loads(execution_desc["output"]) + assert output["Value"] == 3 + + +@markers.aws.needs_fixing +def test_apigateway_invoke(deploy_cfn_template, aws_client): + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sfn_apigateway.yaml" + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello from stepfunctions" in execution_result["output"] + + +@markers.aws.validated +def test_apigateway_invoke_with_path(deploy_cfn_template, aws_client): + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sfn_apigateway_two_integrations.yaml" + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello_with_path from stepfunctions" in execution_result["output"] + + +@markers.aws.only_localstack +def test_apigateway_invoke_localhost(deploy_cfn_template, aws_client): + """tests the same as above but with the "generic" localhost version of invoking the apigateway""" + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sfn_apigateway.yaml" + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + api_url = deploy_result.outputs["LsApiEndpointA06D37E8"] + + # instead of changing the template, we're just mapping the endpoint here to the more generic path-based version + state_def = aws_client.stepfunctions.describe_state_machine(stateMachineArn=state_machine_arn)[ + "definition" + ] + parsed = urllib.parse.urlparse(api_url) + api_id = parsed.hostname.split(".")[0] + state = json.loads(state_def) + stage = state["States"]["LsCallApi"]["Parameters"]["Stage"] + state["States"]["LsCallApi"]["Parameters"]["ApiEndpoint"] = ( + f"{config.internal_service_url()}/restapis/{api_id}" + ) + state["States"]["LsCallApi"]["Parameters"]["Stage"] = stage + + aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(state) + ) + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello from stepfunctions" in execution_result["output"] + + +@markers.aws.only_localstack +def test_apigateway_invoke_localhost_with_path(deploy_cfn_template, aws_client): + """tests the same as above but with the "generic" localhost version of invoking the apigateway""" + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sfn_apigateway_two_integrations.yaml" + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + api_url = deploy_result.outputs["LsApiEndpointA06D37E8"] + + # instead of changing the template, we're just mapping the endpoint here to the more generic path-based version + state_def = aws_client.stepfunctions.describe_state_machine(stateMachineArn=state_machine_arn)[ + "definition" + ] + parsed = urllib.parse.urlparse(api_url) + api_id = parsed.hostname.split(".")[0] + state = json.loads(state_def) + stage = state["States"]["LsCallApi"]["Parameters"]["Stage"] + state["States"]["LsCallApi"]["Parameters"]["ApiEndpoint"] = ( + f"{config.internal_service_url()}/restapis/{api_id}" + ) + state["States"]["LsCallApi"]["Parameters"]["Stage"] = stage + + aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(state) + ) + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello_with_path from stepfunctions" in execution_result["output"] + + +@pytest.mark.skip("Terminates with FAILED on cloud; convert to SFN v2 snapshot lambda test.") +@markers.aws.needs_fixing +def test_retry_and_catch(deploy_cfn_template, aws_client): + """ + Scenario: + + Lambda invoke (incl. 3 retries) + => catch (Send SQS message with body "Fail") + => next (Send SQS message with body "Success") + + The Lambda function simply raises an Exception, so it will always fail. + It should fail all 4 attempts (1x invoke + 3x retries) which should then trigger the catch path + and send a "Fail" message to the queue. + """ + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sfn_retry_catch.yaml" + ) + ) + queue_url = stack.outputs["queueUrlOutput"] + statemachine_arn = stack.outputs["smArnOutput"] + assert statemachine_arn + + execution = aws_client.stepfunctions.start_execution(stateMachineArn=statemachine_arn) + execution_arn = execution["executionArn"] + + await_execution_terminated(aws_client.stepfunctions, execution_arn) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + + receive_result = aws_client.sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) + assert receive_result["Messages"][0]["Body"] == "Fail" + + +@markers.aws.validated +def test_cfn_statemachine_with_dependencies(deploy_cfn_template, aws_client): + sm_name = f"sm_{short_uid()}" + activity_name = f"act_{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/statemachine_machine_with_activity.yml" + ), + max_wait=150, + parameters={"StateMachineName": sm_name, "ActivityName": activity_name}, + ) + + rs = aws_client.stepfunctions.list_state_machines() + statemachines = [sm for sm in rs["stateMachines"] if sm_name in sm["name"]] + assert len(statemachines) == 1 + + rs = aws_client.stepfunctions.list_activities() + activities = [act for act in rs["activities"] if activity_name in act["name"]] + assert len(activities) == 1 + + stack.destroy() + + rs = aws_client.stepfunctions.list_state_machines() + statemachines = [sm for sm in rs["stateMachines"] if sm_name in sm["name"]] + + assert not statemachines + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..encryptionConfiguration", "$..tracingConfiguration"] +) +def test_cfn_statemachine_default_s3_location( + s3_create_bucket, deploy_cfn_template, aws_client, sfn_snapshot +): + sfn_snapshot.add_transformers_list( + [ + JsonpathTransformer("$..roleArn", "role-arn"), + JsonpathTransformer("$..stateMachineArn", "state-machine-arn"), + JsonpathTransformer("$..name", "state-machine-name"), + ] + ) + cfn_template_path = os.path.join( + os.path.dirname(__file__), + "../../../templates/statemachine_machine_default_s3_location.yml", + ) + + stack_name = f"test-cfn-statemachine-default-s3-location-{short_uid()}" + + file_key = f"file-key-{short_uid()}.json" + bucket_name = s3_create_bucket() + state_machine_template = { + "Comment": "step: on create", + "StartAt": "S0", + "States": {"S0": {"Type": "Succeed"}}, + } + + aws_client.s3.put_object( + Bucket=bucket_name, Key=file_key, Body=json.dumps(state_machine_template) + ) + + stack = deploy_cfn_template( + stack_name=stack_name, + template_path=cfn_template_path, + max_wait=150, + parameters={"BucketName": bucket_name, "ObjectKey": file_key}, + ) + + stack_outputs = stack.outputs + statemachine_arn = stack_outputs["StateMachineArnOutput"] + + describe_state_machine_output_on_create = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match( + "describe_state_machine_output_on_create", describe_state_machine_output_on_create + ) + + file_key = f"2-{file_key}" + state_machine_template["Comment"] = "step: on update" + aws_client.s3.put_object( + Bucket=bucket_name, Key=file_key, Body=json.dumps(state_machine_template) + ) + deploy_cfn_template( + stack_name=stack_name, + template_path=cfn_template_path, + is_update=True, + parameters={"BucketName": bucket_name, "ObjectKey": file_key}, + ) + + describe_state_machine_output_on_update = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match( + "describe_state_machine_output_on_update", describe_state_machine_output_on_update + ) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..encryptionConfiguration", "$..tracingConfiguration"] +) +def test_statemachine_create_with_logging_configuration( + deploy_cfn_template, aws_client, sfn_snapshot +): + sfn_snapshot.add_transformers_list( + [ + JsonpathTransformer("$..roleArn", "role-arn"), + JsonpathTransformer("$..stateMachineArn", "state-machine-arn"), + JsonpathTransformer("$..name", "state-machine-name"), + JsonpathTransformer("$..logGroupArn", "log-group-arn"), + ] + ) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../templates/statemachine_machine_logging_configuration.yml", + ) + ) + statemachine_arn = stack.outputs["StateMachineArnOutput"] + describe_state_machine_result = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match("describe_state_machine_result", describe_state_machine_result) diff --git a/tests/aws/services/cloudformation/resources/test_stepfunctions.snapshot.json b/tests/aws/services/cloudformation/resources/test_stepfunctions.snapshot.json new file mode 100644 index 0000000000000..0a71d3489d9ce --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_stepfunctions.snapshot.json @@ -0,0 +1,113 @@ +{ + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_cfn_statemachine_default_s3_location": { + "recorded-date": "17-12-2024, 16:06:46", + "recorded-content": { + "describe_state_machine_output_on_create": { + "creationDate": "datetime", + "definition": { + "Comment": "step: on create", + "StartAt": "S0", + "States": { + "S0": { + "Type": "Succeed" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "", + "stateMachineArn": "", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_state_machine_output_on_update": { + "creationDate": "datetime", + "definition": { + "Comment": "step: on update", + "StartAt": "S0", + "States": { + "S0": { + "Type": "Succeed" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "", + "stateMachineArn": "", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_statemachine_create_with_logging_configuration": { + "recorded-date": "24-03-2025, 21:58:55", + "recorded-content": { + "describe_state_machine_result": { + "creationDate": "datetime", + "definition": { + "StartAt": "S0", + "States": { + "S0": { + "Type": "Pass", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "" + } + } + ], + "includeExecutionData": true, + "level": "ALL" + }, + "name": "", + "roleArn": "", + "stateMachineArn": "", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/resources/test_stepfunctions.validation.json b/tests/aws/services/cloudformation/resources/test_stepfunctions.validation.json new file mode 100644 index 0000000000000..7c3fd62726991 --- /dev/null +++ b/tests/aws/services/cloudformation/resources/test_stepfunctions.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_cfn_statemachine_default_s3_location": { + "last_validated_date": "2024-12-17T16:06:46+00:00" + }, + "tests/aws/services/cloudformation/resources/test_stepfunctions.py::test_statemachine_create_with_logging_configuration": { + "last_validated_date": "2025-03-24T21:58:55+00:00" + } +} diff --git a/tests/aws/services/cloudformation/test_cloudformation_ui.py b/tests/aws/services/cloudformation/test_cloudformation_ui.py new file mode 100644 index 0000000000000..9974d55dbf9ab --- /dev/null +++ b/tests/aws/services/cloudformation/test_cloudformation_ui.py @@ -0,0 +1,22 @@ +import requests + +from localstack import config +from localstack.testing.pytest import markers + +CLOUDFORMATION_UI_PATH = "/_localstack/cloudformation/deploy" + + +class TestCloudFormationUi: + @markers.aws.only_localstack + def test_get_cloudformation_ui(self): + # note: we get the external service url here because the UI is hosted on the external + # URL, however if `LOCALSTACK_HOST` is set to a hostname that does not resolve to + # `127.0.0.1` this test will fail. + cfn_ui_url = config.external_service_url() + CLOUDFORMATION_UI_PATH + response = requests.get(cfn_ui_url) + + # we simply test that the UI is available at the right path and that it returns HTML. + assert response.ok + assert "content-type" in response.headers + # this is a bit fragile but assert that the file returned contains at least something related to the UI + assert b"LocalStack" in response.content diff --git a/tests/aws/services/cloudformation/test_cloudtrail_trace.py b/tests/aws/services/cloudformation/test_cloudtrail_trace.py new file mode 100644 index 0000000000000..ffe25541a5e23 --- /dev/null +++ b/tests/aws/services/cloudformation/test_cloudtrail_trace.py @@ -0,0 +1,39 @@ +import json + +import pytest + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + + +@pytest.mark.skipif(not is_aws_cloud(), reason="Test only works on AWS") +@markers.aws.validated +def test_cloudtrail_trace_example( + cfn_store_events_role_arn, aws_client: ServiceLevelClientFactory, deploy_cfn_template +): + """ + Example test to demonstrate capturing CloudFormation events using CloudTrail. + """ + template = json.dumps( + { + "Resources": { + "MyTopic": { + "Type": "AWS::SNS::Topic", + }, + }, + "Outputs": { + "TopicArn": { + "Value": { + "Fn::GetAtt": ["MyTopic", "TopicArn"], + } + } + }, + } + ) + + stack = deploy_cfn_template(template=template, role_arn=cfn_store_events_role_arn) + + # perform normal test assertions here + # no exception means the test succeeded + aws_client.sns.get_topic_attributes(TopicArn=stack.outputs["TopicArn"]) diff --git a/tests/aws/services/cloudformation/test_cloudtrail_trace.validation.json b/tests/aws/services/cloudformation/test_cloudtrail_trace.validation.json new file mode 100644 index 0000000000000..fa0b2e782c02a --- /dev/null +++ b/tests/aws/services/cloudformation/test_cloudtrail_trace.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/test_cloudtrail_trace.py::test_cloudtrail_trace_example": { + "last_validated_date": "2024-07-04T13:57:13+00:00" + } +} diff --git a/tests/aws/services/cloudformation/test_template_engine.py b/tests/aws/services/cloudformation/test_template_engine.py new file mode 100644 index 0000000000000..d039307ef5101 --- /dev/null +++ b/tests/aws/services/cloudformation/test_template_engine.py @@ -0,0 +1,1258 @@ +import base64 +import json +import os +import re +from copy import deepcopy + +import botocore.exceptions +import pytest +import yaml + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.cloudformation.engine.yaml_parser import parse_yaml +from localstack.testing.aws.cloudformation_utils import load_template_file, load_template_raw +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError +from localstack.utils.common import short_uid +from localstack.utils.files import load_file +from localstack.utils.sync import wait_until + + +def create_macro( + macro_name, function_path, deploy_cfn_template, create_lambda_function, lambda_client +): + macro_function_path = function_path + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=lambda_client, + timeout=1, + ) + + return deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../templates/macro_resource.yml"), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + +class TestTypes: + @markers.aws.validated + def test_implicit_type_conversion(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.sqs_api()) + stack = deploy_cfn_template( + max_wait=180, + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/engine/implicit_type_conversion.yml" + ), + ) + queue = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueUrl"], AttributeNames=["All"] + ) + snapshot.match("queue", queue) + + +class TestIntrinsicFunctions: + @pytest.mark.parametrize( + ("intrinsic_fn", "parameter_1", "parameter_2", "expected_bucket_created"), + [ + ("Fn::And", "0", "0", False), + ("Fn::And", "0", "1", False), + ("Fn::And", "1", "0", False), + ("Fn::And", "1", "1", True), + ("Fn::Or", "0", "0", False), + ("Fn::Or", "0", "1", True), + ("Fn::Or", "1", "0", True), + ("Fn::Or", "1", "1", True), + ], + ) + @markers.aws.validated + def test_and_or_functions( + self, + intrinsic_fn, + parameter_1, + parameter_2, + expected_bucket_created, + deploy_cfn_template, + aws_client, + ): + bucket_name = f"ls-bucket-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/cfn_intrinsic_functions.yaml" + ), + parameters={ + "Param1": parameter_1, + "Param2": parameter_2, + "BucketName": bucket_name, + }, + template_mapping={ + "intrinsic_fn": intrinsic_fn, + }, + ) + + buckets = aws_client.s3.list_buckets() + bucket_names = [b["Name"] for b in buckets["Buckets"]] + assert (bucket_name in bucket_names) == expected_bucket_created + + @markers.aws.validated + def test_base64_sub_and_getatt_functions(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/functions_getatt_sub_base64.yml" + ) + original_string = f"string-{short_uid()}" + deployed = deploy_cfn_template( + template_path=template_path, parameters={"OriginalString": original_string} + ) + + converted_string = base64.b64encode(bytes(original_string, "utf-8")).decode("utf-8") + assert converted_string == deployed.outputs["Encoded"] + + @markers.aws.validated + def test_split_length_and_join_functions(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/functions_select_split_join.yml" + ) + + first_value = f"string-{short_uid()}" + second_value = f"string-{short_uid()}" + deployed = deploy_cfn_template( + template_path=template_path, + parameters={ + "MultipleValues": f"{first_value};{second_value}", + "Value1": first_value, + "Value2": second_value, + }, + ) + + assert first_value == deployed.outputs["SplitResult"] + assert f"{first_value}_{second_value}" == deployed.outputs["JoinResult"] + + # TODO support join+split and length operations + # assert f"{first_value}_{second_value}" == deployed.outputs["SplitJoin"] + # assert 2 == deployed.outputs["LengthResult"] + + @markers.aws.validated + @pytest.mark.skip(reason="functions not currently supported") + def test_to_json_functions(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/function_to_json_string.yml" + ) + + first_value = f"string-{short_uid()}" + second_value = f"string-{short_uid()}" + deployed = deploy_cfn_template( + template_path=template_path, + parameters={ + "Value1": first_value, + "Value2": second_value, + }, + ) + + json_result = json.loads(deployed.outputs["Result"]) + + assert json_result["key1"] == first_value + assert json_result["key2"] == second_value + assert "value1" == deployed.outputs["Result2"] + + @markers.aws.validated + def test_find_map_function(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/function_find_in_map.yml" + ) + + deployed = deploy_cfn_template( + template_path=template_path, + ) + + assert deployed.outputs["Result"] == "us-east-1" + + @markers.aws.validated + @pytest.mark.skip(reason="function not currently supported") + def test_cidr_function(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/functions_cidr.yml" + ) + + # TODO parametrize parameters and result + deployed = deploy_cfn_template( + template_path=template_path, + parameters={"IpBlock": "10.0.0.0/16", "Count": "1", "CidrBits": "8", "Select": "0"}, + ) + + assert deployed.outputs["Address"] == "10.0.0.0/24" + + @pytest.mark.parametrize( + "region", + [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-2", + "ap-northeast-1", + "eu-central-1", + "eu-west-1", + ], + ) + @markers.aws.validated + def test_get_azs_function(self, deploy_cfn_template, region, aws_client_factory): + """ + TODO parametrize this test. + For that we need to be able to parametrize the client region. The docs show the we should be + able to put any region in the parameters but it doesn't work. It only accepts the same region from the client config + if you put anything else it just returns an empty list. + """ + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/functions_get_azs.yml" + ) + + aws_client = aws_client_factory(region_name=region) + deployed = deploy_cfn_template( + template_path=template_path, + custom_aws_client=aws_client, + parameters={"DeployRegion": region}, + ) + + azs = deployed.outputs["Zones"].split(";") + assert len(azs) > 0 + assert all(re.match(f"{region}[a-f]", az) for az in azs) + + @markers.aws.validated + def test_sub_not_ready(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/sub_dependencies.yaml" + ) + deploy_cfn_template( + template_path=template_path, + max_wait=120, + ) + + @markers.aws.validated + def test_cfn_template_with_short_form_fn_sub(self, deploy_cfn_template): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/engine/cfn_short_sub.yml" + ), + ) + + result = stack.outputs["Result"] + assert result == "test" + + @markers.aws.validated + def test_sub_number_type(self, deploy_cfn_template): + alarm_name_prefix = "alarm-test-latency-preemptive" + threshold = "1000.0" + period = "60" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/sub_number_type.yml" + ), + parameters={ + "ResourceNamePrefix": alarm_name_prefix, + "RestLatencyPreemptiveAlarmThreshold": threshold, + "RestLatencyPreemptiveAlarmPeriod": period, + }, + ) + + assert stack.outputs["AlarmName"] == f"{alarm_name_prefix}-{period}" + assert stack.outputs["Threshold"] == threshold + assert stack.outputs["Period"] == period + + @markers.aws.validated + def test_join_no_value_construct(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/engine/join_no_value.yml" + ) + ) + + snapshot.match("join-output", stack.outputs) + + +class TestImports: + @markers.aws.validated + def test_stack_imports(self, deploy_cfn_template, aws_client): + queue_name1 = f"q-{short_uid()}" + queue_name2 = f"q-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../templates/sqs_export.yml"), + parameters={"QueueName": queue_name1}, + ) + stack2 = deploy_cfn_template( + template_path=os.path.join(os.path.dirname(__file__), "../../templates/sqs_import.yml"), + parameters={"QueueName": queue_name2}, + ) + queue_url1 = aws_client.sqs.get_queue_url(QueueName=queue_name1)["QueueUrl"] + queue_url2 = aws_client.sqs.get_queue_url(QueueName=queue_name2)["QueueUrl"] + + queue_arn1 = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url1, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + queue_arn2 = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url2, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + assert stack2.outputs["MessageQueueArn1"] == queue_arn1 + assert stack2.outputs["MessageQueueArn2"] == queue_arn2 + + +class TestSsmParameters: + @markers.aws.validated + def test_create_stack_with_ssm_parameters( + self, create_parameter, deploy_cfn_template, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("ParameterValue")) + snapshot.add_transformer(snapshot.transform.key_value("ResolvedValue")) + + parameter_name = f"ls-param-{short_uid()}" + parameter_value = f"ls-param-value-{short_uid()}" + create_parameter(Name=parameter_name, Value=parameter_value, Type="String") + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/dynamicparameter_ssm_string.yaml" + ), + template_mapping={"parameter_name": parameter_name}, + ) + + stack_description = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)[ + "Stacks" + ][0] + snapshot.match("stack-details", stack_description) + + topics = aws_client.sns.list_topics() + topic_arns = [t["TopicArn"] for t in topics["Topics"]] + + matching = [arn for arn in topic_arns if parameter_value in arn] + assert len(matching) == 1 + + tags = aws_client.sns.list_tags_for_resource(ResourceArn=matching[0]) + snapshot.match("topic-tags", tags) + + @markers.aws.validated + def test_resolve_ssm(self, create_parameter, deploy_cfn_template): + parameter_key = f"param-key-{short_uid()}" + parameter_value = f"param-value-{short_uid()}" + create_parameter(Name=parameter_key, Value=parameter_value, Type="String") + + result = deploy_cfn_template( + parameters={"DynamicParameter": parameter_key}, + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/resolve_ssm.yaml" + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value + + @markers.aws.validated + def test_resolve_ssm_with_version(self, create_parameter, deploy_cfn_template, aws_client): + parameter_key = f"param-key-{short_uid()}" + parameter_value_v0 = f"param-value-{short_uid()}" + parameter_value_v1 = f"param-value-{short_uid()}" + parameter_value_v2 = f"param-value-{short_uid()}" + + create_parameter(Name=parameter_key, Type="String", Value=parameter_value_v0) + + v1 = aws_client.ssm.put_parameter( + Name=parameter_key, Overwrite=True, Type="String", Value=parameter_value_v1 + ) + aws_client.ssm.put_parameter( + Name=parameter_key, Overwrite=True, Type="String", Value=parameter_value_v2 + ) + + result = deploy_cfn_template( + parameters={"DynamicParameter": f"{parameter_key}:{v1['Version']}"}, + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/resolve_ssm.yaml" + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value_v1 + + @markers.aws.needs_fixing + def test_resolve_ssm_secure(self, create_parameter, deploy_cfn_template): + parameter_key = f"param-key-{short_uid()}" + parameter_value = f"param-value-{short_uid()}" + + create_parameter(Name=parameter_key, Value=parameter_value, Type="SecureString") + + result = deploy_cfn_template( + parameters={"DynamicParameter": f"{parameter_key}"}, + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/resolve_ssm_secure.yaml" + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value + + @markers.aws.validated + def test_ssm_nested_with_nested_stack(self, s3_create_bucket, deploy_cfn_template, aws_client): + """ + When resolving the references in the cloudformation template for 'Fn::GetAtt' we need to consider the attribute subname. + Eg: In "Fn::GetAtt": "ChildParam.Outputs.Value", where attribute reference is ChildParam.Outputs.Value the: + resource logical id is ChildParam and attribute name is Outputs we need to fetch the Value attribute from the resource properties + of the model instance. + """ + + bucket_name = s3_create_bucket() + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../templates/nested_child_ssm.yaml"), + Bucket=bucket_name, + Key="nested_child_ssm.yaml", + ) + + key_value = "child-2-param-name" + + deploy_cfn_template( + max_wait=120 if is_aws_cloud() else 20, + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/nested_parent_ssm.yaml" + ), + parameters={ + "ChildStackURL": f"https://{bucket_name}.s3.{domain}/nested_child_ssm.yaml", + "KeyValue": key_value, + }, + ) + + ssm_parameter = aws_client.ssm.get_parameter(Name="test-param")["Parameter"]["Value"] + + assert ssm_parameter == key_value + + @markers.aws.validated + def test_create_change_set_with_ssm_parameter_list( + self, deploy_cfn_template, aws_client, region_name, account_id, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value(key="role-name")) + + parameter_logical_id = "parameter123" + parameter_name = f"ls-param-{short_uid()}" + role_name = f"ls-role-{short_uid()}" + parameter_value = ",".join( + [ + f"arn:aws:ssm:{region_name}:{account_id}:parameter/some/params", + f"arn:aws:ssm:{region_name}:{account_id}:parameter/some/other/params", + ] + ) + snapshot.match("role-name", role_name) + + aws_client.ssm.put_parameter(Name=parameter_name, Value=parameter_value, Type="StringList") + + deploy_cfn_template( + max_wait=120 if is_aws_cloud() else 20, + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/dynamicparameter_ssm_list.yaml" + ), + template_mapping={"role_name": role_name}, + parameters={parameter_logical_id: parameter_name}, + ) + role_policy = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName="policy-123") + snapshot.match("iam_role_policy", role_policy) + + +class TestSecretsManagerParameters: + @pytest.mark.parametrize( + "template_name", + [ + "resolve_secretsmanager_full.yaml", + "resolve_secretsmanager_partial.yaml", + "resolve_secretsmanager.yaml", + ], + ) + @markers.aws.validated + def test_resolve_secretsmanager(self, create_secret, deploy_cfn_template, template_name): + parameter_key = f"param-key-{short_uid()}" + parameter_value = f"param-value-{short_uid()}" + + create_secret(Name=parameter_key, SecretString=parameter_value) + + result = deploy_cfn_template( + parameters={"DynamicParameter": f"{parameter_key}"}, + template_path=os.path.join( + os.path.dirname(__file__), + "../../templates", + template_name, + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value + + +class TestPreviousValues: + @pytest.mark.skip(reason="outputs don't behave well in combination with conditions") + @markers.aws.validated + def test_parameter_usepreviousvalue_behavior( + self, deploy_cfn_template, is_stack_updated, aws_client + ): + template_path = os.path.join( + os.path.dirname(__file__), "../../templates/cfn_reuse_param.yaml" + ) + + # 1. create with overridden default value. Due to the condition this should neither create the optional topic, + # nor the corresponding output + stack = deploy_cfn_template(template_path=template_path, parameters={"DeployParam": "no"}) + + stack_describe_response = aws_client.cloudformation.describe_stacks( + StackName=stack.stack_name + )["Stacks"][0] + assert len(stack_describe_response["Outputs"]) == 1 + + # 2. update using UsePreviousValue. DeployParam should still be "no", still overriding the default and the only + # change should be the changed tag on the required topic + aws_client.cloudformation.update_stack( + StackName=stack.stack_namestack_name, + TemplateBody=load_template_raw(template_path), + Parameters=[ + {"ParameterKey": "CustomTag", "ParameterValue": "trigger-change"}, + {"ParameterKey": "DeployParam", "UsePreviousValue": True}, + ], + ) + wait_until(is_stack_updated(stack.stack_id)) + stack_describe_response = aws_client.cloudformation.describe_stacks( + StackName=stack.stack_name + )["Stacks"][0] + assert len(stack_describe_response["Outputs"]) == 1 + + # 3. update with setting the deployparam to "yes" not. The condition will evaluate to true and thus create the + # topic + output note: for an even trickier challenge for the cloudformation engine, remove the second parameter + # key. Behavior should stay the same. + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=load_template_raw(template_path), + Parameters=[ + {"ParameterKey": "CustomTag", "ParameterValue": "trigger-change-2"}, + {"ParameterKey": "DeployParam", "ParameterValue": "yes"}, + ], + ) + assert is_stack_updated(stack.stack_id) + stack_describe_response = aws_client.cloudformation.describe_stacks( + StackName=stack.stack_id + )["Stacks"][0] + assert len(stack_describe_response["Outputs"]) == 2 + + +class TestImportValues: + @markers.aws.validated + def test_cfn_with_exports(self, deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/engine/cfn_exports.yml" + ) + ) + + exports = aws_client.cloudformation.list_exports()["Exports"] + filtered = [exp for exp in exports if exp["ExportingStackId"] == stack.stack_id] + filtered.sort(key=lambda x: x["Name"]) + + snapshot.match("exports", filtered) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + + @markers.aws.validated + def test_import_values_across_stacks(self, deploy_cfn_template, aws_client): + export_name = f"b-{short_uid()}" + + # create stack #1 + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/cfn_function_export.yml" + ), + parameters={"BucketExportName": export_name}, + ) + bucket_name1 = result.outputs.get("BucketName1") + assert bucket_name1 + + # create stack #2 + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/cfn_function_import.yml" + ), + parameters={"BucketExportName": export_name}, + ) + bucket_name2 = result.outputs.get("BucketName2") + assert bucket_name2 + + # assert that correct bucket tags have been created + tagging = aws_client.s3.get_bucket_tagging(Bucket=bucket_name2) + test_tag = [tag for tag in tagging["TagSet"] if tag["Key"] == "test"] + assert test_tag + assert test_tag[0]["Value"] == bucket_name1 + + # TODO support this method + # assert cfn_client.list_imports(ExportName=export_name)["Imports"] + + +class TestMacros: + @markers.aws.validated + def test_macro_deployment( + self, deploy_cfn_template, create_lambda_function, snapshot, aws_client + ): + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/format_template.py" + ) + macro_name = "SubstitutionMacro" + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + stack_with_macro = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + description = aws_client.cloudformation.describe_stack_resources( + StackName=stack_with_macro.stack_name + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("stack_outputs", stack_with_macro.outputs) + snapshot.match("stack_resource_descriptions", description) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TemplateBody.Resources.Parameter.LogicalResourceId", + "$..TemplateBody.Conditions", + "$..TemplateBody.Mappings", + "$..TemplateBody.StackId", + "$..TemplateBody.StackName", + "$..TemplateBody.Transform", + ] + ) + def test_global_scope( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + This test validates the behaviour of a template deployment that includes a global transformation + """ + + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/format_template.py" + ) + macro_name = "SubstitutionMacro" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + new_value = f"new-value-{short_uid()}" + stack_name = f"stake-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + TemplateBody=load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../templates/transformation_global_parameter.yml", + ) + ), + Parameters=[{"ParameterKey": "Substitution", "ParameterValue": new_value}], + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.add_transformer(snapshot.transform.regex(new_value, "new-value")) + snapshot.match("processed_template", processed_template) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_to_transform", + ["transformation_snippet_topic.yml", "transformation_snippet_topic.json"], + ) + def test_snipped_scope( + self, + deploy_cfn_template, + create_lambda_function, + snapshot, + template_to_transform, + aws_client, + ): + """ + This test validates the behaviour of a template deployment that includes a snipped transformation also the + responses from the get_template with different template formats. + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/add_standard_attributes.py" + ) + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + macro_name = "ConvertTopicToFifo" + stack_name = f"stake-macro-{short_uid()}" + deploy_cfn_template( + stack_name=stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + topic_name = f"topic-{short_uid()}.fifo" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../templates", + template_to_transform, + ), + parameters={"TopicName": topic_name}, + ) + original_template = aws_client.cloudformation.get_template( + StackName=stack.stack_name, TemplateStage="Original" + ) + processed_template = aws_client.cloudformation.get_template( + StackName=stack.stack_name, TemplateStage="Processed" + ) + snapshot.add_transformer(snapshot.transform.regex(topic_name, "topic-name")) + + snapshot.match("original_template", original_template) + snapshot.match("processed_template", processed_template) + + @markers.aws.validated + def test_attribute_uses_macro(self, deploy_cfn_template, create_lambda_function, aws_client): + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/return_random_string.py" + ) + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + macro_name = "GenerateRandom" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../templates", + "transformation_resource_att.yml", + ), + parameters={"Input": "test"}, + ) + + resulting_value = stack.outputs["Parameter"] + assert "test-" in resulting_value + + @markers.aws.validated + @pytest.mark.skip(reason="Fn::Transform does not support array of transformations") + def test_scope_order_and_parameters( + self, deploy_cfn_template, create_lambda_function, snapshot, aws_client + ): + """ + The test validates the order of execution of transformations and also asserts that any type of + transformation can receive inputs. + """ + + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/replace_string.py" + ) + macro_name = "ReplaceString" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../templates/transformation_multiple_scope_parameter.yml", + ), + ) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack.stack_name, TemplateStage="Processed" + ) + snapshot.match("processed_template", processed_template) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TemplateBody.Resources.Parameter.LogicalResourceId", + "$..TemplateBody.Conditions", + "$..TemplateBody.Mappings", + "$..TemplateBody.Parameters", + "$..TemplateBody.StackId", + "$..TemplateBody.StackName", + "$..TemplateBody.Transform", + "$..TemplateBody.Resources.Role.LogicalResourceId", + ] + ) + def test_capabilities_requirements( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + The test validates that AWS will return an error about missing CAPABILITY_AUTOEXPAND when adding a + resource during the transformation, and it will ask for CAPABILITY_NAMED_IAM when the new resource is a + IAM role + """ + + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/add_role.py" + ) + macro_name = "AddRole" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack_name = f"stack-{short_uid()}" + args = { + "StackName": stack_name, + "TemplateBody": load_file( + os.path.join( + os.path.dirname(__file__), + "../../templates/transformation_add_role.yml", + ) + ), + } + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack(**args) + snapshot.match("error", ex.value.response) + + args["Capabilities"] = [ + "CAPABILITY_AUTO_EXPAND", # Required to allow macro to add a role to template + "CAPABILITY_NAMED_IAM", # Required to allow CFn create added role + ] + aws_client.cloudformation.create_stack(**args) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.add_transformer(snapshot.transform.key_value("RoleName", "role-name")) + snapshot.match("processed_template", processed_template) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Event.fragment.Conditions", + "$..Event.fragment.Mappings", + "$..Event.fragment.Outputs", + "$..Event.fragment.Resources.Parameter.LogicalResourceId", + "$..Event.fragment.StackId", + "$..Event.fragment.StackName", + "$..Event.fragment.Transform", + ] + ) + def test_validate_lambda_internals( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + The test validates the content of the event pass into the macro lambda + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/print_internals.py" + ) + + macro_name = "PrintInternals" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack_name = f"stake-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + TemplateBody=load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../templates/transformation_print_internals.yml", + ) + ), + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.match( + "event", + processed_template["TemplateBody"]["Resources"]["Parameter"]["Properties"]["Value"], + ) + + @markers.aws.validated + def test_to_validate_template_limit_for_macro( + self, deploy_cfn_template, create_lambda_function, snapshot, aws_client + ): + """ + The test validates the max size of a template that can be passed into the macro function + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/format_template.py" + ) + macro_name = "FormatTemplate" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + template_dict = parse_yaml( + load_file( + os.path.join( + os.path.dirname(__file__), "../../templates/transformation_global_parameter.yml" + ) + ) + ) + for n in range(0, 1000): + template_dict["Resources"][f"Parameter{n}"] = deepcopy( + template_dict["Resources"]["Parameter"] + ) + + template = yaml.dump(template_dict) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack( + StackName=f"stack-{short_uid()}", TemplateBody=template + ) + + response = ex.value.response + response["Error"]["Message"] = response["Error"]["Message"].replace( + template, "" + ) + snapshot.match("error_response", response) + + @markers.aws.validated + def test_error_pass_macro_as_reference(self, snapshot, aws_client): + """ + This test shows that the CFn will reject any transformation name that has been specified as reference, for + example, a parameter. + """ + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack( + StackName=f"stack-{short_uid()}", + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), + "../../templates/transformation_macro_as_reference.yml", + ) + ), + Capabilities=["CAPABILITY_AUTO_EXPAND"], + Parameters=[{"ParameterKey": "MacroName", "ParameterValue": "NonExistent"}], + ) + snapshot.match("error", ex.value.response) + + @markers.aws.validated + def test_functions_and_references_during_transformation( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + This tests shows the state of instrinsic functions during the execution of the macro + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/print_references.py" + ) + macro_name = "PrintReferences" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack_name = f"stake-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + TemplateBody=load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../templates/transformation_macro_params_as_reference.yml", + ) + ), + Parameters=[{"ParameterKey": "MacroInput", "ParameterValue": "CreateStackInput"}], + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.match( + "event", + processed_template["TemplateBody"]["Resources"]["Parameter"]["Properties"]["Value"], + ) + + @pytest.mark.parametrize( + "macro_function", + [ + "return_unsuccessful_with_message.py", + "return_unsuccessful_without_message.py", + "return_invalid_template.py", + "raise_error.py", + ], + ) + @markers.aws.validated + def test_failed_state( + self, + deploy_cfn_template, + create_lambda_function, + snapshot, + cleanups, + macro_function, + aws_client, + ): + """ + This test shows the error responses for different negative responses from the macro lambda + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../templates/macros/", macro_function + ) + + macro_name = "Unsuccessful" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + template = load_file( + os.path.join( + os.path.dirname(__file__), + "../../templates/transformation_unsuccessful.yml", + ) + ) + + stack_name = f"stack-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, Capabilities=["CAPABILITY_AUTO_EXPAND"], TemplateBody=template + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + with pytest.raises(botocore.exceptions.WaiterError): + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + events = aws_client.cloudformation.describe_stack_events(StackName=stack_name)[ + "StackEvents" + ] + + failed_events_by_policy = [ + event + for event in events + if "ResourceStatusReason" in event and event["ResourceStatus"] == "ROLLBACK_IN_PROGRESS" + ] + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("failed_description", failed_events_by_policy[0]) + + @markers.aws.validated + def test_pyplate_param_type_list(self, deploy_cfn_template, aws_client, snapshot): + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/pyplate_deploy_template.yml" + ), + ) + + tags = "Env=Prod,Application=MyApp,BU=ModernisationTeam" + param_tags = {pair.split("=")[0]: pair.split("=")[1] for pair in tags.split(",")} + + stack_with_macro = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/pyplate_example.yml" + ), + parameters={"Tags": tags}, + ) + + bucket_name_output = stack_with_macro.outputs["BucketName"] + assert bucket_name_output + + tagging = aws_client.s3.get_bucket_tagging(Bucket=bucket_name_output) + tags_s3 = [tag for tag in tagging["TagSet"]] + + resp = [] + for tag in tags_s3: + if tag["Key"] in param_tags: + assert tag["Value"] == param_tags[tag["Key"]] + resp.append([tag["Key"], tag["Value"]]) + assert len(tags_s3) >= len(param_tags) + snapshot.match("tags", sorted(resp)) + + +class TestStackEvents: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..EventId", + "$..PhysicalResourceId", + "$..ResourceProperties", + # TODO: we do not maintain parity here, just that the property exists + "$..ResourceStatusReason", + ] + ) + def test_invalid_stack_deploy(self, deploy_cfn_template, aws_client, snapshot): + logical_resource_id = "MyParameter" + template = { + "Resources": { + logical_resource_id: { + "Type": "AWS::SSM::Parameter", + "Properties": { + # invalid: missing required property _type_ + "Value": "abc123", + }, + }, + }, + } + + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template(template=json.dumps(template)) + + stack_events = exc_info.value.events + # filter out only the single create event that failed + failed_events = [ + every + for every in stack_events + if every["ResourceStatus"] == "CREATE_FAILED" + and every["LogicalResourceId"] == logical_resource_id + ] + assert len(failed_events) == 1 + failed_event = failed_events[0] + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("failed_event", failed_event) + assert "ResourceStatusReason" in failed_event + + +class TestPseudoParameters: + @markers.aws.validated + def test_stack_id(self, deploy_cfn_template, snapshot): + template = { + "Resources": { + "MyParameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "AWS::StackId", + }, + }, + }, + }, + "Outputs": { + "StackId": { + "Value": { + "Fn::GetAtt": [ + "MyParameter", + "Value", + ], + }, + }, + }, + } + + stack = deploy_cfn_template(template=json.dumps(template)) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + + snapshot.match("StackId", stack.outputs["StackId"]) diff --git a/tests/aws/services/cloudformation/test_template_engine.snapshot.json b/tests/aws/services/cloudformation/test_template_engine.snapshot.json new file mode 100644 index 0000000000000..da52914bdd544 --- /dev/null +++ b/tests/aws/services/cloudformation/test_template_engine.snapshot.json @@ -0,0 +1,687 @@ +{ + "tests/aws/services/cloudformation/test_template_engine.py::TestTypes::test_implicit_type_conversion": { + "recorded-date": "29-08-2023, 15:21:22", + "recorded-content": { + "queue": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "2", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_global_scope": { + "recorded-date": "30-01-2023, 20:14:48", + "recorded-content": { + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "ParameterName": { + "Value": { + "Ref": "Parameter" + } + } + }, + "Parameters": { + "Substitution": { + "Default": "SubstitutionDefault", + "Type": "String" + } + }, + "Resources": { + "Parameter": { + "Properties": { + "Type": "String", + "Value": "new-value" + }, + "Type": "AWS::SSM::Parameter" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_snipped_scope": { + "recorded-date": "06-12-2022, 09:44:49", + "recorded-content": { + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "ContentBasedDeduplication": true, + "FifoTopic": true, + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_scope_order_and_parameters": { + "recorded-date": "07-12-2022, 09:08:26", + "recorded-content": { + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Resources": { + "Parameter": { + "Properties": { + "Type": "String", + "Value": "snippet-transform second-snippet-transform global-transform second-global-transform " + }, + "Type": "AWS::SSM::Parameter" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.yml]": { + "recorded-date": "08-12-2022, 16:24:58", + "recorded-content": { + "original_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Parameters:\n TopicName:\n Type: String\n\nResources:\n Topic:\n Type: AWS::SNS::Topic\n Properties:\n TopicName:\n Ref: TopicName\n Fn::Transform: ConvertTopicToFifo\n\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - Topic\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "ContentBasedDeduplication": true, + "FifoTopic": true, + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.json]": { + "recorded-date": "08-12-2022, 16:25:43", + "recorded-content": { + "original_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "Fn::Transform": "ConvertTopicToFifo", + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "ContentBasedDeduplication": true, + "FifoTopic": true, + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_capabilities_requirements": { + "recorded-date": "30-01-2023, 20:15:46", + "recorded-content": { + "error": { + "Error": { + "Code": "InsufficientCapabilitiesException", + "Message": "Requires capabilities : [CAPABILITY_AUTO_EXPAND]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "ParameterName": { + "Value": { + "Ref": "Parameter" + } + } + }, + "Resources": { + "Parameter": { + "Properties": { + "Type": "String", + "Value": "not-important" + }, + "Type": "AWS::SSM::Parameter" + }, + "Role": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": "*" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AdministratorAccess" + ] + ] + } + ], + "RoleName": "" + }, + "Type": "AWS::IAM::Role" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_validate_lambda_internals": { + "recorded-date": "30-01-2023, 20:16:45", + "recorded-content": { + "event": { + "Event": { + "accountId": "111111111111", + "fragment": { + "Parameters": { + "ExampleParameter": { + "Type": "String", + "Default": "example-value" + } + }, + "Resources": { + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Value": "", + "Type": "String" + } + } + } + }, + "transformId": "111111111111::PrintInternals", + "requestId": "", + "region": "", + "params": { + "Input": "test-input" + }, + "templateParameterValues": { + "ExampleParameter": "example-value" + } + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_to_validate_template_limit_for_macro": { + "recorded-date": "30-01-2023, 20:17:04", + "recorded-content": { + "error_response": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value '' at 'templateBody' failed to satisfy constraint: Member must have length less than or equal to 51200", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_error_pass_macro_as_reference": { + "recorded-date": "30-01-2023, 20:17:05", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Key Name of transform definition must be a string.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_error_macro_param_as_reference": { + "recorded-date": "08-12-2022, 11:50:49", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_functions_and_references_during_transformation": { + "recorded-date": "30-01-2023, 20:17:55", + "recorded-content": { + "event": { + "Params": { + "Input": "CreateStackInput" + }, + "FunctionValue": { + "Fn::Join": [ + " ", + [ + "Hello", + "World" + ] + ] + }, + "ValueOfRef": { + "Ref": "Substitution" + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_with_message.py]": { + "recorded-date": "30-01-2023, 20:18:45", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Transform 111111111111::Unsuccessful failed with: failed because it is a test. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_without_message.py]": { + "recorded-date": "30-01-2023, 20:19:35", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Transform 111111111111::Unsuccessful failed without an error message.. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_invalid_template.py]": { + "recorded-date": "30-01-2023, 20:20:30", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Template format error: unsupported structure.. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[raise_error.py]": { + "recorded-date": "30-01-2023, 20:21:20", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Received malformed response from transform 111111111111::Unsuccessful. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_create_stack_with_ssm_parameters": { + "recorded-date": "15-01-2023, 17:54:23", + "recorded-content": { + "stack-details": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "parameter123", + "ParameterValue": "", + "ResolvedValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "topic-tags": { + "Tags": [ + { + "Key": "param-value", + "Value": "param " + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_macro_deployment": { + "recorded-date": "30-01-2023, 20:13:58", + "recorded-content": { + "stack_outputs": { + "MacroRef": "SubstitutionMacro" + }, + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Macro", + "PhysicalResourceId": "SubstitutionMacro", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Macro", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestStackEvents::test_invalid_stack_deploy": { + "recorded-date": "12-06-2023, 17:08:47", + "recorded-content": { + "failed_event": { + "EventId": "MyParameter-CREATE_FAILED-date", + "LogicalResourceId": "MyParameter", + "PhysicalResourceId": "", + "ResourceProperties": { + "Value": "abc123" + }, + "ResourceStatus": "CREATE_FAILED", + "ResourceStatusReason": "Property validation failure: [The property {/Type} is required]", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_pyplate_param_type_list": { + "recorded-date": "17-05-2024, 06:19:03", + "recorded-content": { + "tags": [ + [ + "Application", + "MyApp" + ], + [ + "BU", + "ModernisationTeam" + ], + [ + "Env", + "Prod" + ] + ] + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestImportValues::test_cfn_with_exports": { + "recorded-date": "21-06-2024, 18:37:15", + "recorded-content": { + "exports": [ + { + "ExportingStackId": "", + "Name": "-TestExport-0", + "Value": "test" + }, + { + "ExportingStackId": "", + "Name": "-TestExport-1", + "Value": "test" + }, + { + "ExportingStackId": "", + "Name": "-TestExport-2", + "Value": "test" + } + ] + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestPseudoParameters::test_stack_id": { + "recorded-date": "18-07-2024, 08:56:47", + "recorded-content": { + "StackId": "" + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_create_change_set_with_ssm_parameter_list": { + "recorded-date": "08-08-2024, 21:21:23", + "recorded-content": { + "role-name": "", + "iam_role_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Resource": [ + "arn::ssm::111111111111:parameter/some/params", + "arn::ssm::111111111111:parameter/some/other/params" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "policy-123", + "RoleName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_join_no_value_construct": { + "recorded-date": "22-01-2025, 14:01:46", + "recorded-content": { + "join-output": { + "JoinConditionalNoValue": "", + "JoinOnlyNoValue": "", + "JoinWithNoValue": "Sample" + } + } + } +} diff --git a/tests/aws/services/cloudformation/test_template_engine.validation.json b/tests/aws/services/cloudformation/test_template_engine.validation.json new file mode 100644 index 0000000000000..e0bbb0be7e342 --- /dev/null +++ b/tests/aws/services/cloudformation/test_template_engine.validation.json @@ -0,0 +1,107 @@ +{ + "tests/aws/services/cloudformation/test_template_engine.py::TestImportValues::test_cfn_with_exports": { + "last_validated_date": "2024-06-21T18:37:15+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestImports::test_stack_imports": { + "last_validated_date": "2024-07-04T14:19:31+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_cfn_template_with_short_form_fn_sub": { + "last_validated_date": "2024-06-20T20:41:15+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function": { + "last_validated_date": "2024-04-03T07:12:29+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-northeast-1]": { + "last_validated_date": "2024-05-09T08:34:23+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-southeast-2]": { + "last_validated_date": "2024-05-09T08:34:02+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-central-1]": { + "last_validated_date": "2024-05-09T08:34:39+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-west-1]": { + "last_validated_date": "2024-05-09T08:34:56+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-1]": { + "last_validated_date": "2024-05-09T08:32:56+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-2]": { + "last_validated_date": "2024-05-09T08:33:12+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-1]": { + "last_validated_date": "2024-05-09T08:33:29+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-2]": { + "last_validated_date": "2024-05-09T08:33:45+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_join_no_value_construct": { + "last_validated_date": "2025-01-22T14:01:46+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestIntrinsicFunctions::test_sub_number_type": { + "last_validated_date": "2024-08-09T06:55:16+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_capabilities_requirements": { + "last_validated_date": "2023-01-30T19:15:46+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_error_pass_macro_as_reference": { + "last_validated_date": "2023-01-30T19:17:05+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[raise_error.py]": { + "last_validated_date": "2023-01-30T19:21:20+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_invalid_template.py]": { + "last_validated_date": "2023-01-30T19:20:30+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_with_message.py]": { + "last_validated_date": "2023-01-30T19:18:45+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_without_message.py]": { + "last_validated_date": "2023-01-30T19:19:35+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_functions_and_references_during_transformation": { + "last_validated_date": "2023-01-30T19:17:55+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_global_scope": { + "last_validated_date": "2023-01-30T19:14:48+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_macro_deployment": { + "last_validated_date": "2023-01-30T19:13:58+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_pyplate_param_type_list": { + "last_validated_date": "2024-05-17T06:19:03+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_scope_order_and_parameters": { + "last_validated_date": "2022-12-07T08:08:26+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.json]": { + "last_validated_date": "2022-12-08T15:25:43+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.yml]": { + "last_validated_date": "2022-12-08T15:24:58+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_to_validate_template_limit_for_macro": { + "last_validated_date": "2023-01-30T19:17:04+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestMacros::test_validate_lambda_internals": { + "last_validated_date": "2023-01-30T19:16:45+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestPseudoParameters::test_stack_id": { + "last_validated_date": "2024-07-18T08:56:47+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_create_change_set_with_ssm_parameter_list": { + "last_validated_date": "2024-08-08T21:21:23+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_create_stack_with_ssm_parameters": { + "last_validated_date": "2023-01-15T16:54:23+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_ssm_nested_with_nested_stack": { + "last_validated_date": "2024-07-16T16:38:43+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestStackEvents::test_invalid_stack_deploy": { + "last_validated_date": "2023-06-12T15:08:47+00:00" + }, + "tests/aws/services/cloudformation/test_template_engine.py::TestTypes::test_implicit_type_conversion": { + "last_validated_date": "2023-08-29T13:21:22+00:00" + } +} diff --git a/tests/aws/services/cloudformation/test_unsupported.py b/tests/aws/services/cloudformation/test_unsupported.py new file mode 100644 index 0000000000000..ea2bf979958df --- /dev/null +++ b/tests/aws/services/cloudformation/test_unsupported.py @@ -0,0 +1,15 @@ +import os + +from localstack.testing.pytest import markers + + +@markers.aws.validated +def test_unsupported(deploy_cfn_template): + """ + Exercise the unsupported usage counters + """ + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/cfn_unsupported.yaml" + ) + ) diff --git a/tests/aws/services/cloudformation/test_unsupported.validation.json b/tests/aws/services/cloudformation/test_unsupported.validation.json new file mode 100644 index 0000000000000..5964ddf5501ca --- /dev/null +++ b/tests/aws/services/cloudformation/test_unsupported.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/test_unsupported.py::test_unsupported": { + "last_validated_date": "2024-07-04T14:33:07+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/__init__.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py new file mode 100644 index 0000000000000..466a3165ab6cd --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py @@ -0,0 +1,1235 @@ +import copy +import json +import os.path + +import pytest +from botocore.exceptions import ClientError +from tests.aws.services.cloudformation.api.test_stacks import ( + MINIMAL_TEMPLATE, +) + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.cloudformation_utils import ( + load_template_file, + load_template_raw, + render_template, +) +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import ShortCircuitWaitException, poll_condition, wait_until + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestUpdates: + @markers.aws.validated + def test_simple_update_single_resource( + self, aws_client: ServiceLevelClientFactory, deploy_cfn_template + ): + value1 = "foo" + value2 = "bar" + stack_name = f"stack-{short_uid()}" + + t1 = { + "Resources": { + "MyParameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": value1, + }, + }, + }, + "Outputs": { + "ParameterName": { + "Value": {"Ref": "MyParameter"}, + }, + }, + } + + res = deploy_cfn_template(stack_name=stack_name, template=json.dumps(t1), is_update=False) + parameter_name = res.outputs["ParameterName"] + + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value1 + + t2 = copy.deepcopy(t1) + t2["Resources"]["MyParameter"]["Properties"]["Value"] = value2 + + deploy_cfn_template(stack_name=stack_name, template=json.dumps(t2), is_update=True) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value2 + + res.destroy() + + @pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Not working in v2 yet" + ) + @markers.aws.validated + def test_simple_update_two_resources( + self, aws_client: ServiceLevelClientFactory, deploy_cfn_template + ): + parameter_name = "my-parameter" + value1 = "foo" + value2 = "bar" + stack_name = f"stack-{short_uid()}" + + t1 = { + "Resources": { + "MyParameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": value1, + }, + }, + "MyParameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": parameter_name, + "Type": "String", + "Value": {"Fn::GetAtt": ["MyParameter1", "Value"]}, + }, + }, + }, + } + + res = deploy_cfn_template(stack_name=stack_name, template=json.dumps(t1), is_update=False) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value1 + + t2 = copy.deepcopy(t1) + t2["Resources"]["MyParameter1"]["Properties"]["Value"] = value2 + + deploy_cfn_template(stack_name=stack_name, template=json.dumps(t2), is_update=True) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value2 + + res.destroy() + + @markers.aws.validated + # TODO: the error response is incorrect, however the test is otherwise validated and raises + # an error because the SSM parameter has been deleted (removed from the stack). + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + @pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Test fails with the old engine" + ) + def test_deleting_resource( + self, aws_client: ServiceLevelClientFactory, deploy_cfn_template, snapshot + ): + parameter_name = "my-parameter" + value1 = "foo" + + t1 = { + "Resources": { + "MyParameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": value1, + }, + }, + "MyParameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": parameter_name, + "Type": "String", + "Value": {"Fn::GetAtt": ["MyParameter1", "Value"]}, + }, + }, + }, + } + + stack = deploy_cfn_template(template=json.dumps(t1)) + found_value = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"]["Value"] + assert found_value == value1 + + t2 = copy.deepcopy(t1) + del t2["Resources"]["MyParameter2"] + + deploy_cfn_template(stack_name=stack.stack_name, template=json.dumps(t2), is_update=True) + with pytest.raises(ClientError) as exc_info: + aws_client.ssm.get_parameter(Name=parameter_name) + + snapshot.match("get-parameter-error", exc_info.value.response) + + +@markers.aws.validated +def test_create_change_set_without_parameters( + cleanup_stacks, cleanup_changesets, is_change_set_created_and_available, aws_client +): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + # make sure the change set wasn't executed (which would create a topic) + topics = aws_client.sns.list_topics() + topic_arns = [x["TopicArn"] for x in topics["Topics"]] + assert not any("sns-topic-simple" in arn for arn in topic_arns) + # stack is initially in REVIEW_IN_PROGRESS state. only after executing the change_set will it change its status + stack_response = aws_client.cloudformation.describe_stacks(StackName=stack_id) + assert stack_response["Stacks"][0]["StackStatus"] == "REVIEW_IN_PROGRESS" + + # Change set can now either be already created/available or it is pending/unavailable + wait_until( + is_change_set_created_and_available(change_set_id), 2, 10, strategy="exponential" + ) + describe_response = aws_client.cloudformation.describe_change_set( + ChangeSetName=change_set_id + ) + + assert describe_response["ChangeSetName"] == change_set_name + assert describe_response["ChangeSetId"] == change_set_id + assert describe_response["StackId"] == stack_id + assert describe_response["StackName"] == stack_name + assert describe_response["ExecutionStatus"] == "AVAILABLE" + assert describe_response["Status"] == "CREATE_COMPLETE" + changes = describe_response["Changes"] + assert len(changes) == 1 + assert changes[0]["Type"] == "Resource" + assert changes[0]["ResourceChange"]["Action"] == "Add" + assert changes[0]["ResourceChange"]["ResourceType"] == "AWS::SNS::Topic" + assert changes[0]["ResourceChange"]["LogicalResourceId"] == "topic123" + finally: + cleanup_stacks([stack_id]) + cleanup_changesets([change_set_id]) + + +# TODO: implement +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="Not properly implemented") +@markers.aws.validated +def test_create_change_set_update_without_parameters( + cleanup_stacks, + cleanup_changesets, + is_change_set_created_and_available, + is_change_set_finished, + snapshot, + aws_client, +): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + """after creating a stack via a CREATE change set we send an UPDATE change set changing the SNS topic name""" + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + change_set_name2 = f"change-set-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + snapshot.match("create_change_set", response) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + # Change set can now either be already created/available or it is pending/unavailable + wait_until(is_change_set_created_and_available(change_set_id)) + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + wait_until(is_change_set_finished(change_set_id)) + template = load_template_raw(template_path) + + update_response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name2, + TemplateBody=template.replace("sns-topic-simple", "sns-topic-simple-2"), + ChangeSetType="UPDATE", + ) + assert wait_until(is_change_set_created_and_available(update_response["Id"])) + snapshot.match( + "describe_change_set", + aws_client.cloudformation.describe_change_set(ChangeSetName=update_response["Id"]), + ) + snapshot.match( + "list_change_set", aws_client.cloudformation.list_change_sets(StackName=stack_name) + ) + + describe_response = aws_client.cloudformation.describe_change_set( + ChangeSetName=update_response["Id"] + ) + changes = describe_response["Changes"] + assert len(changes) == 1 + assert changes[0]["Type"] == "Resource" + change = changes[0]["ResourceChange"] + assert change["Action"] == "Modify" + assert change["ResourceType"] == "AWS::SNS::Topic" + assert change["LogicalResourceId"] == "topic123" + assert "sns-topic-simple" in change["PhysicalResourceId"] + assert change["Replacement"] == "True" + assert "Properties" in change["Scope"] + assert len(change["Details"]) == 1 + assert change["Details"][0]["Target"]["Name"] == "TopicName" + assert change["Details"][0]["Target"]["RequiresRecreation"] == "Always" + finally: + cleanup_changesets(changesets=[change_set_id]) + cleanup_stacks(stacks=[stack_id]) + + +# def test_create_change_set_with_template_url(): +# pass + + +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="change set type not implemented") +@markers.aws.validated +def test_create_change_set_create_existing(cleanup_changesets, cleanup_stacks, aws_client): + """tries to create an already existing stack""" + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=change_set_id + ) + stack_id = response["StackId"] + assert change_set_id + assert stack_id + try: + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_id) + + with pytest.raises(Exception) as ex: + change_set_name2 = f"change-set-{short_uid()}" + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name2, + TemplateBody=load_template_raw("sns_topic_simple.yaml"), + ChangeSetType="CREATE", + ) + assert ex is not None + finally: + cleanup_changesets([change_set_id]) + cleanup_stacks([stack_id]) + + +@markers.aws.validated +def test_create_change_set_update_nonexisting(aws_client): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + + with pytest.raises(Exception) as ex: + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="UPDATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + err = ex.value.response["Error"] + assert err["Code"] == "ValidationError" + assert "does not exist" in err["Message"] + + +@markers.aws.validated +def test_create_change_set_invalid_params(aws_client): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + with pytest.raises(ClientError) as ex: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="INVALID", + ) + err = ex.value.response["Error"] + assert err["Code"] == "ValidationError" + + +@markers.aws.validated +def test_create_change_set_missing_stackname(aws_client): + """in this case boto doesn't even let us send the request""" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + with pytest.raises(Exception): + aws_client.cloudformation.create_change_set( + StackName="", + ChangeSetName=change_set_name, + TemplateBody=load_template_raw(template_path), + ChangeSetType="CREATE", + ) + + +@pytest.mark.skip("CFNV2:Other") +@markers.aws.validated +def test_create_change_set_with_ssm_parameter( + cleanup_changesets, + cleanup_stacks, + is_change_set_created_and_available, + is_stack_created, + aws_client, +): + """References a simple stack parameter""" + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + parameter_name = f"ls-param-{short_uid()}" + parameter_value = f"ls-param-value-{short_uid()}" + sns_topic_logical_id = "topic123" + parameter_logical_id = "parameter123" + + aws_client.ssm.put_parameter(Name=parameter_name, Value=parameter_value, Type="String") + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/dynamicparameter_ssm_string.yaml" + ) + template_rendered = render_template( + load_template_raw(template_path), parameter_name=parameter_name + ) + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_rendered, + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + # make sure the change set wasn't executed (which would create a new topic) + list_topics_response = aws_client.sns.list_topics() + matching_topics = [ + t for t in list_topics_response["Topics"] if parameter_value in t["TopicArn"] + ] + assert matching_topics == [] + + # stack is initially in REVIEW_IN_PROGRESS state. only after executing the change_set will it change its status + stack_response = aws_client.cloudformation.describe_stacks(StackName=stack_id) + assert stack_response["Stacks"][0]["StackStatus"] == "REVIEW_IN_PROGRESS" + + # Change set can now either be already created/available or it is pending/unavailable + wait_until(is_change_set_created_and_available(change_set_id)) + describe_response = aws_client.cloudformation.describe_change_set( + ChangeSetName=change_set_id + ) + + assert describe_response["ChangeSetName"] == change_set_name + assert describe_response["ChangeSetId"] == change_set_id + assert describe_response["StackId"] == stack_id + assert describe_response["StackName"] == stack_name + assert describe_response["ExecutionStatus"] == "AVAILABLE" + assert describe_response["Status"] == "CREATE_COMPLETE" + changes = describe_response["Changes"] + assert len(changes) == 1 + assert changes[0]["Type"] == "Resource" + assert changes[0]["ResourceChange"]["Action"] == "Add" + assert changes[0]["ResourceChange"]["ResourceType"] == "AWS::SNS::Topic" + assert changes[0]["ResourceChange"]["LogicalResourceId"] == sns_topic_logical_id + + parameters = describe_response["Parameters"] + assert len(parameters) == 1 + assert parameters[0]["ParameterKey"] == parameter_logical_id + assert parameters[0]["ParameterValue"] == parameter_name + assert parameters[0]["ResolvedValue"] == parameter_value # the important part + + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + wait_until(is_stack_created(stack_id)) + + topics = aws_client.sns.list_topics() + topic_arns = [x["TopicArn"] for x in topics["Topics"]] + assert any((parameter_value in t) for t in topic_arns) + finally: + cleanup_changesets([change_set_id]) + cleanup_stacks([stack_id]) + + +@pytest.mark.skip("CFNV2:Validation") +@markers.aws.validated +def test_describe_change_set_nonexisting(snapshot, aws_client): + with pytest.raises(Exception) as ex: + aws_client.cloudformation.describe_change_set( + StackName="somestack", ChangeSetName="DoesNotExist" + ) + snapshot.match("exception", ex.value) + + +@pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="fails because of the properties mutation in the result_handler", +) +@markers.aws.validated +def test_execute_change_set( + is_change_set_finished, + is_change_set_created_and_available, + is_change_set_failed_and_unavailable, + cleanup_changesets, + cleanup_stacks, + aws_client, +): + """check if executing a change set succeeds in creating/modifying the resources in changed""" + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + template_body = load_template_raw(template_path) + + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert change_set_id + assert stack_id + + try: + assert wait_until(is_change_set_created_and_available(change_set_id=change_set_id)) + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + assert wait_until(is_change_set_finished(change_set_id)) + # check if stack resource was created + topics = aws_client.sns.list_topics() + topic_arns = [x["TopicArn"] for x in topics["Topics"]] + assert any(("sns-topic-simple" in t) for t in topic_arns) + + # new change set name + change_set_name = f"change-set-{short_uid()}" + # check if update with identical stack leads to correct behavior + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="UPDATE", + ) + change_set_id = response["Id"] + stack_id = response["StackId"] + assert wait_until(is_change_set_failed_and_unavailable(change_set_id=change_set_id)) + describe_failed_change_set_result = aws_client.cloudformation.describe_change_set( + ChangeSetName=change_set_id + ) + assert describe_failed_change_set_result["ChangeSetName"] == change_set_name + assert ( + describe_failed_change_set_result["StatusReason"] + == "The submitted information didn't contain changes. Submit different information to create a change set." + ) + with pytest.raises(ClientError) as e: + aws_client.cloudformation.execute_change_set(ChangeSetName=change_set_id) + e.match("InvalidChangeSetStatus") + e.match( + rf"ChangeSet \[{change_set_id}\] cannot be executed in its current status of \[FAILED\]" + ) + finally: + cleanup_changesets([change_set_id]) + cleanup_stacks([stack_id]) + + +@markers.aws.validated +def test_delete_change_set_exception(snapshot, aws_client): + """test error cases when trying to delete a change set""" + with pytest.raises(ClientError) as e1: + aws_client.cloudformation.delete_change_set( + StackName="nostack", ChangeSetName="DoesNotExist" + ) + snapshot.match("e1", e1.value.response) + + with pytest.raises(ClientError) as e2: + aws_client.cloudformation.delete_change_set(ChangeSetName="DoesNotExist") + snapshot.match("e2", e2.value.response) + + +@pytest.mark.skip("CFNV2:Other") +@markers.aws.validated +def test_create_delete_create(aws_client, cleanups, deploy_cfn_template): + """test the re-use of a changeset name with a re-used stack name""" + stack_name = f"stack-{short_uid()}" + change_set_name = f"cs-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + with open(template_path) as infile: + template = infile.read() + + # custom cloudformation deploy process since our `deploy_cfn_template` is too smart and uses IDs, unlike the CDK + def deploy(): + client = aws_client.cloudformation + client.create_change_set( + StackName=stack_name, + TemplateBody=template, + ChangeSetName=change_set_name, + ChangeSetType="CREATE", + ) + client.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=change_set_name + ) + + client.execute_change_set(StackName=stack_name, ChangeSetName=change_set_name) + client.get_waiter("stack_create_complete").wait( + StackName=stack_name, + ) + + def delete(suppress_exception: bool = False): + try: + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + except Exception: + if not suppress_exception: + raise + + deploy() + cleanups.append(lambda: delete(suppress_exception=True)) + delete() + deploy() + + +@pytest.mark.skip(reason="CFNV2:Metadata, CFNV2:Other") +@markers.aws.validated +def test_create_and_then_remove_non_supported_resource_change_set(deploy_cfn_template): + # first deploy cfn with a CodeArtifact resource that is not actually supported + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/code_artifact_template.yaml" + ) + template_body = load_template_raw(template_path) + stack = deploy_cfn_template( + template=template_body, + parameters={"CADomainName": f"domainname-{short_uid()}"}, + ) + + # removal of CodeArtifact should not throw exception + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/code_artifact_remove_template.yaml" + ) + template_body = load_template_raw(template_path) + deploy_cfn_template( + is_update=True, + template=template_body, + stack_name=stack.stack_name, + ) + + +@markers.aws.validated +def test_create_and_then_update_refreshes_template_metadata( + aws_client, + cleanup_changesets, + cleanup_stacks, + is_change_set_finished, + is_change_set_created_and_available, +): + stacks_to_cleanup = set() + changesets_to_cleanup = set() + + try: + stack_name = f"stack-{short_uid()}" + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + + template_body = load_template_raw(template_path) + + create_response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=f"change-set-{short_uid()}", + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + + stacks_to_cleanup.add(create_response["StackId"]) + changesets_to_cleanup.add(create_response["Id"]) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=create_response["Id"] + ) + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=create_response["Id"] + ) + + wait_until(is_change_set_finished(create_response["Id"])) + + # Note the metadata alone won't change if there are no changes to resources + # TODO: find a better way to make a replacement in yaml template + template_body = template_body.replace( + "TopicName: sns-topic-simple", + "TopicName: sns-topic-simple-updated", + ) + + update_response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=f"change-set-{short_uid()}", + TemplateBody=template_body, + ChangeSetType="UPDATE", + ) + + stacks_to_cleanup.add(update_response["StackId"]) + changesets_to_cleanup.add(update_response["Id"]) + + wait_until(is_change_set_created_and_available(update_response["Id"])) + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=update_response["Id"] + ) + + wait_until(is_change_set_finished(update_response["Id"])) + + summary = aws_client.cloudformation.get_template_summary(StackName=stack_name) + + assert "TopicName" in summary["Metadata"] + assert "sns-topic-simple-updated" in summary["Metadata"] + finally: + cleanup_stacks(list(stacks_to_cleanup)) + cleanup_changesets(list(changesets_to_cleanup)) + + +# TODO: the intention of this test is not particularly clear. The resource isn't removed, it'll just generate a new bucket with a new default name +# TODO: rework this to a conditional instead of two templates + parameter usage instead of templating +@markers.aws.validated +def test_create_and_then_remove_supported_resource_change_set(deploy_cfn_template, aws_client): + first_bucket_name = f"test-bucket-1-{short_uid()}" + second_bucket_name = f"test-bucket-2-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/for_removal_setup.yaml" + ) + template_body = load_template_raw(template_path) + + stack = deploy_cfn_template( + template=template_body, + template_mapping={ + "first_bucket_name": first_bucket_name, + "second_bucket_name": second_bucket_name, + }, + ) + assert first_bucket_name in stack.outputs["FirstBucket"] + assert second_bucket_name in stack.outputs["SecondBucket"] + + available_buckets = aws_client.s3.list_buckets() + bucket_names = [bucket["Name"] for bucket in available_buckets["Buckets"]] + assert first_bucket_name in bucket_names + assert second_bucket_name in bucket_names + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/for_removal_remove.yaml" + ) + template_body = load_template_raw(template_path) + stack_updated = deploy_cfn_template( + is_update=True, + template=template_body, + template_mapping={"first_bucket_name": first_bucket_name}, + stack_name=stack.stack_name, + ) + + assert first_bucket_name in stack_updated.outputs["FirstBucket"] + + def assert_bucket_gone(): + available_buckets = aws_client.s3.list_buckets() + bucket_names = [bucket["Name"] for bucket in available_buckets["Buckets"]] + return first_bucket_name in bucket_names and second_bucket_name not in bucket_names + + poll_condition(condition=assert_bucket_gone, timeout=20, interval=5) + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Parameters", + ] +) +@markers.aws.validated +def test_empty_changeset(snapshot, cleanups, aws_client): + """ + Creates a change set that doesn't actually update any resources and then tries to execute it + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + change_set_name_nochange = f"change-set-nochange-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/cdkmetadata.yaml" + ) + template = load_template_file(template_path) + + # 1. create change set and execute + + first_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ChangeSetType="CREATE", + ) + snapshot.match("first_changeset", first_changeset) + + def _check_changeset_available(): + status = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + )["Status"] + if status == "FAILED": + raise ShortCircuitWaitException("Change set in unrecoverable status") + return status == "CREATE_COMPLETE" + + assert wait_until(_check_changeset_available) + + describe_first_cs = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + ) + snapshot.match("describe_first_cs", describe_first_cs) + assert describe_first_cs["ExecutionStatus"] == "AVAILABLE" + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + ) + + def _check_changeset_success(): + status = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=first_changeset["Id"] + )["ExecutionStatus"] + if status in ["EXECUTE_FAILED", "UNAVAILABLE", "OBSOLETE"]: + raise ShortCircuitWaitException("Change set in unrecoverable status") + return status == "EXECUTE_COMPLETE" + + assert wait_until(_check_changeset_success) + + # 2. create a new change set without changes + nochange_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name_nochange, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ChangeSetType="UPDATE", + ) + snapshot.match("nochange_changeset", nochange_changeset) + + describe_nochange = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=nochange_changeset["Id"] + ) + snapshot.match("describe_nochange", describe_nochange) + assert describe_nochange["ExecutionStatus"] == "UNAVAILABLE" + + # 3. try to execute the unavailable change set + with pytest.raises(aws_client.cloudformation.exceptions.InvalidChangeSetStatusException) as e: + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=nochange_changeset["Id"] + ) + snapshot.match("error_execute_failed", e.value) + + +@pytest.mark.skip(reason="CFNV2:Other delete change set not implemented yet") +@markers.aws.validated +def test_deleted_changeset(snapshot, cleanups, aws_client): + """simple case verifying that proper exception is thrown when trying to get a deleted changeset""" + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + changeset_name = f"changeset-{short_uid()}" + stack_name = f"stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + snapshot.add_transformer(snapshot.transform.regex(stack_name, "")) + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/cdkmetadata.yaml" + ) + template = load_template_file(template_path) + + # 1. create change set + create = aws_client.cloudformation.create_change_set( + ChangeSetName=changeset_name, + StackName=stack_name, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND", "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], + ChangeSetType="CREATE", + ) + snapshot.match("create", create) + + changeset_id = create["Id"] + + def _check_changeset_available(): + status = aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=changeset_id + )["Status"] + if status == "FAILED": + raise ShortCircuitWaitException("Change set in unrecoverable status") + return status == "CREATE_COMPLETE" + + assert wait_until(_check_changeset_available) + + # 2. delete change set + aws_client.cloudformation.delete_change_set(ChangeSetName=changeset_id, StackName=stack_name) + + with pytest.raises(aws_client.cloudformation.exceptions.ChangeSetNotFoundException) as e: + aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=changeset_id + ) + snapshot.match("postdelete_changeset_notfound", e.value) + + +@pytest.mark.skip("CFNV2:Capabilities") +@markers.aws.validated +def test_autoexpand_capability_requirement(cleanups, aws_client): + stack_name = f"test-stack-{short_uid()}" + changeset_name = f"test-changeset-{short_uid()}" + queue_name = f"test-queue-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + template_body = load_template_raw( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_macro_languageextensions.yaml" + ) + ) + + with pytest.raises(aws_client.cloudformation.exceptions.InsufficientCapabilitiesException): + # requires the capability + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template_body, + Parameters=[ + {"ParameterKey": "QueueList", "ParameterValue": "faa,fbb,fcc"}, + {"ParameterKey": "QueueNameParam", "ParameterValue": queue_name}, + ], + ) + + # does not require the capability + create_changeset_result = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "QueueList", "ParameterValue": "faa,fbb,fcc"}, + {"ParameterKey": "QueueNameParam", "ParameterValue": queue_name}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=create_changeset_result["Id"] + ) + + +# FIXME: a CreateStack operation should work with an existing stack if its in REVIEW_IN_PROGRESS +@pytest.mark.skip(reason="not implemented correctly yet") +@markers.aws.validated +def test_create_while_in_review(aws_client, snapshot, cleanups): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"stack-{short_uid()}" + changeset_name = f"changeset-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + stack_id = changeset["StackId"] + changeset_id = changeset["Id"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=changeset_name + ) + + # I would have actually expected this to throw, but it doesn't + create_stack_while_in_review = aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE + ) + snapshot.match("create_stack_while_in_review", create_stack_while_in_review) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # describe change set and stack (change set is now obsolete) + describe_stack = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_stack", describe_stack) + describe_change_set = aws_client.cloudformation.describe_change_set(ChangeSetName=changeset_id) + snapshot.match("describe_change_set", describe_change_set) + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.snapshot.skip_snapshot_verify( + paths=["$..Capabilities", "$..IncludeNestedStacks", "$..NotificationARNs", "$..Parameters"] +) +@markers.aws.validated +def test_multiple_create_changeset(aws_client, snapshot, cleanups): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"repeated-stack-{short_uid()}" + initial_changeset_name = f"initial-changeset-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + snapshot.match( + "initial_changeset", + aws_client.cloudformation.describe_change_set(ChangeSetName=initial_changeset["Id"]), + ) + + # multiple change sets can exist for a given stack + additional_changeset_name = f"additionalchangeset-{short_uid()}" + additional_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=additional_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + snapshot.match("additional_changeset", additional_changeset) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=additional_changeset_name + ) + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.snapshot.skip_snapshot_verify(paths=["$..LastUpdatedTime", "$..StackStatusReason"]) +@markers.aws.validated +def test_create_changeset_with_stack_id(aws_client, snapshot, cleanups): + """ + The test answers the question if the `StackName` parameter in `CreateChangeSet` can also be a full Stack ID (ARN). + This can make sense in two cases: + 1. a `CREATE` change set type while the stack is in `REVIEW_IN_PROGRESS` (otherwise it would fail) => covered by this test + 2. an `UPDATE` change set type when the stack has been deployed before already + + On an initial `CREATE` we can't actually know the stack ID yet since the `CREATE` will first create the stack. + + Error case: using `CREATE` with a stack ID from a stack that is in `DELETE_COMPLETE` state. + => A single stack instance identified by a unique ID can never leave its `DELETE_COMPLETE` state + => `DELETE_COMPLETE` is the only *real* terminal state of a Stack + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"repeated-stack-{short_uid()}" + initial_changeset_name = "initial-changeset" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + # create initial change set + initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + initial_stack_id = initial_changeset["StackId"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + + # new CREATE change set on stack that is in REVIEW_IN_PROGRESS state + additional_create_changeset_name = "additional-create" + additional_create_changeset = aws_client.cloudformation.create_change_set( + StackName=initial_stack_id, + ChangeSetName=additional_create_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=additional_create_changeset["Id"] + ) + + describe_stack = aws_client.cloudformation.describe_stacks(StackName=initial_stack_id) + snapshot.match("describe_stack", describe_stack) + + # delete and try to revive the stack with the same ID (won't work) + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + assert ( + aws_client.cloudformation.describe_stacks(StackName=initial_stack_id)["Stacks"][0][ + "StackStatus" + ] + == "DELETE_COMPLETE" + ) + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=initial_stack_id, + ChangeSetName="revived-stack-changeset", + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + snapshot.match("recreate_deleted_with_id_exception", e.value.response) + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.snapshot.skip_snapshot_verify( + paths=[ + # gotta skip quite a lot unfortunately + # FIXME: tackle this when fixing API parity of CloudFormation + "$..EnableTerminationProtection", + "$..LastUpdatedTime", + "$..Capabilities", + "$..ChangeSetId", + "$..IncludeNestedStacks", + "$..NotificationARNs", + "$..Parameters", + "$..StackId", + "$..StatusReason", + "$..StackStatusReason", + ] +) +@markers.aws.validated +def test_name_conflicts(aws_client, snapshot, cleanups): + """ + changeset-based equivalent to tests.aws.services.cloudformation.api.test_stacks.test_name_conflicts + + Tests behavior of creating a stack and changeset with the same names of ones that were previously deleted + + 1. Create ChangeSet + 2. Create another ChangeSet + 3. Execute ChangeSet / Create Stack + 4. Creating a new ChangeSet (CREATE) for this stack should fail since it already exists & is running/active + 5. Delete Stack + 6. Create ChangeSet / re-use ChangeSet and Stack names from 1. + + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack_name = f"repeated-stack-{short_uid()}" + initial_changeset_name = f"initial-changeset-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + initial_stack_id = initial_changeset["StackId"] + initial_changeset_id = initial_changeset["Id"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + + # actually create the stack + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=initial_changeset_name + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # creating should now fail (stack is created & active) + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + snapshot.match("create_changeset_existingstack_exc", e.value.response) + + # delete stack + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + # creating for stack name with same name should work again + # re-using the changset name should also not matter :) + second_initial_changeset = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=initial_changeset_name, + ChangeSetType="CREATE", + TemplateBody=MINIMAL_TEMPLATE, + ) + second_initial_stack_id = second_initial_changeset["StackId"] + second_initial_changeset_id = second_initial_changeset["Id"] + assert second_initial_changeset_id != initial_changeset_id + assert initial_stack_id != second_initial_stack_id + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=second_initial_changeset_id + ) + + # only one should be active, and this one is in review state right now + new_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("new_stack_desc", new_stack_desc) + assert len(new_stack_desc["Stacks"]) == 1 + assert new_stack_desc["Stacks"][0]["StackId"] == second_initial_stack_id + + # can still access both by using the ARN (stack id) + # and they should be different from each other + stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=initial_stack_id) + new_stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=second_initial_stack_id) + snapshot.match("stack_id_desc", stack_id_desc) + snapshot.match("new_stack_id_desc", new_stack_id_desc) + + # can still access all change sets by their ID + initial_changeset_id_desc = aws_client.cloudformation.describe_change_set( + ChangeSetName=initial_changeset_id + ) + snapshot.match("initial_changeset_id_desc", initial_changeset_id_desc) + second_initial_changeset_id_desc = aws_client.cloudformation.describe_change_set( + ChangeSetName=second_initial_changeset_id + ) + snapshot.match("second_initial_changeset_id_desc", second_initial_changeset_id_desc) + + +@markers.aws.validated +def test_describe_change_set_with_similarly_named_stacks(deploy_cfn_template, aws_client): + stack_name = f"stack-{short_uid()}" + change_set_name = f"change-set-{short_uid()}" + + # create a changeset + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/ec2_keypair.yml" + ) + template_body = load_template_raw(template_path) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + + # delete the stack + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + # create a new changeset with the same name + response = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + ) + + # ensure that the correct changeset is returned when requested by stack name + assert ( + aws_client.cloudformation.describe_change_set( + ChangeSetName=response["Id"], StackName=stack_name + )["ChangeSetId"] + == response["Id"] + ) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.snapshot.json new file mode 100644 index 0000000000000..930b1ff1e8b93 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.snapshot.json @@ -0,0 +1,517 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_update_without_parameters": { + "recorded-date": "31-05-2022, 09:32:02", + "recorded-content": { + "create_change_set": { + "Id": "arn::cloudformation::111111111111:changeSet//", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPStatusCode": 200, + "HTTPHeaders": {} + } + }, + "describe_change_set": { + "ChangeSetName": "", + "ChangeSetId": "arn::cloudformation::111111111111:changeSet//", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "Status": "CREATE_COMPLETE", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "Capabilities": [], + "Changes": [ + { + "Type": "Resource", + "ResourceChange": { + "Action": "Modify", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceType": "AWS::SNS::Topic", + "Replacement": "True", + "Scope": [ + "Properties" + ], + "Details": [ + { + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + }, + "Evaluation": "Static", + "ChangeSource": "DirectModification" + } + ] + } + } + ], + "IncludeNestedStacks": false, + "ResponseMetadata": { + "HTTPStatusCode": 200, + "HTTPHeaders": {} + } + }, + "list_change_set": { + "Summaries": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "ChangeSetId": "arn::cloudformation::111111111111:changeSet//", + "ChangeSetName": "", + "ExecutionStatus": "AVAILABLE", + "Status": "CREATE_COMPLETE", + "CreationTime": "datetime", + "IncludeNestedStacks": false + } + ], + "ResponseMetadata": { + "HTTPStatusCode": 200, + "HTTPHeaders": {} + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_empty_changeset": { + "recorded-date": "10-08-2022, 10:52:55", + "recorded-content": { + "first_changeset": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackId": "arn::cloudformation::111111111111:stack//" + }, + "describe_first_cs": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "CDKMetadata", + "ResourceType": "AWS::CDK::Metadata", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE" + }, + "nochange_changeset": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackId": "arn::cloudformation::111111111111:stack//" + }, + "describe_nochange": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [], + "CreationTime": "datetime", + "ExecutionStatus": "UNAVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "FAILED", + "StatusReason": "The submitted information didn't contain changes. Submit different information to create a change set." + }, + "error_execute_failed": "An error occurred (InvalidChangeSetStatus) when calling the ExecuteChangeSet operation: ChangeSet [arn::cloudformation::111111111111:changeSet/] cannot be executed in its current status of [FAILED]" + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_deleted_changeset": { + "recorded-date": "11-08-2022, 11:11:47", + "recorded-content": { + "create": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackId": "arn::cloudformation::111111111111:stack//" + }, + "postdelete_changeset_notfound": "An error occurred (ChangeSetNotFound) when calling the DescribeChangeSet operation: ChangeSet [arn::cloudformation::111111111111:changeSet/] does not exist" + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_describe_change_set_nonexisting": { + "recorded-date": "11-03-2025, 19:12:57", + "recorded-content": { + "exception": "An error occurred (ValidationError) when calling the DescribeChangeSet operation: Stack [somestack] does not exist" + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_delete_change_set_exception": { + "recorded-date": "12-03-2025, 10:14:25", + "recorded-content": { + "e1": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [nostack] does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "e2": { + "Error": { + "Code": "ValidationError", + "Message": "StackName must be specified if ChangeSetName is not specified as an ARN.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_name_conflicts": { + "recorded-date": "22-11-2023, 10:58:04", + "recorded-content": { + "create_changeset_existingstack_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [] already exists and cannot be created again with the changeSet [].", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "new_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "REVIEW_IN_PROGRESS", + "StackStatusReason": "User Initiated", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new_stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "REVIEW_IN_PROGRESS", + "StackStatusReason": "User Initiated", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "initial_changeset_id_desc": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "EXECUTE_COMPLETE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_initial_changeset_id_desc": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_while_in_review": { + "recorded-date": "22-11-2023, 08:49:15", + "recorded-content": { + "create_stack_while_in_review": { + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_change_set": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "OBSOLETE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_template_rendering_with_list": { + "recorded-date": "23-11-2023, 09:23:26", + "recorded-content": { + "resolved-template": { + "d": [ + { + "userid": 1 + }, + 1, + "string" + ] + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_changeset_with_stack_id": { + "recorded-date": "28-11-2023, 07:48:23", + "recorded-content": { + "describe_stack": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "REVIEW_IN_PROGRESS", + "StackStatusReason": "User Initiated", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recreate_deleted_with_id_exception": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [arn::cloudformation::111111111111:stack//] already exists and cannot be created again with the changeSet [revived-stack-changeset].", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_multiple_create_changeset": { + "recorded-date": "28-11-2023, 07:38:49", + "recorded-content": { + "initial_changeset": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SimpleParam", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "additional_changeset": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_deleting_resource": { + "recorded-date": "02-06-2025, 10:29:41", + "recorded-content": { + "get-parameter-error": { + "Error": { + "Code": "ParameterNotFound", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.validation.json new file mode 100644 index 0000000000000..fe83ba323389a --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.validation.json @@ -0,0 +1,89 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_dynamic]": { + "last_validated_date": "2025-04-03T07:11:44+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_parameter_for_condition_create_resource]": { + "last_validated_date": "2025-04-03T07:13:00+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property]": { + "last_validated_date": "2025-04-03T07:12:11+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property_not_create_only]": { + "last_validated_date": "2025-04-03T07:12:37+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_base_mapping_scenarios[update_string_referencing_resource]": { + "last_validated_date": "2025-04-03T07:23:48+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_conditions": { + "last_validated_date": "2025-04-01T14:34:35+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_direct_update": { + "last_validated_date": "2025-04-01T08:32:30+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_dynamic_update": { + "last_validated_date": "2025-04-01T12:30:53+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_execute_with_ref": { + "last_validated_date": "2025-04-11T14:34:09+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_mappings_with_parameter_lookup": { + "last_validated_date": "2025-04-01T13:31:33+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_mappings_with_static_fields": { + "last_validated_date": "2025-04-01T13:20:50+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_parameter_changes": { + "last_validated_date": "2025-04-01T12:43:36+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_unrelated_changes_requires_replacement": { + "last_validated_date": "2025-04-01T16:46:22+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestCaptureUpdateProcess::test_unrelated_changes_update_propagation": { + "last_validated_date": "2025-04-01T16:40:03+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_deleting_resource": { + "last_validated_date": "2025-06-02T10:29:46+00:00", + "durations_in_seconds": { + "setup": 1.06, + "call": 20.61, + "teardown": 4.46, + "total": 26.13 + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::TestUpdates::test_simple_update_two_resources": { + "last_validated_date": "2025-04-02T10:05:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_change_set_update_without_parameters": { + "last_validated_date": "2022-05-31T07:32:02+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_changeset_with_stack_id": { + "last_validated_date": "2023-11-28T06:48:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_delete_create": { + "last_validated_date": "2024-08-13T10:46:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_create_while_in_review": { + "last_validated_date": "2023-11-22T07:49:15+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_delete_change_set_exception": { + "last_validated_date": "2025-03-12T10:14:25+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_deleted_changeset": { + "last_validated_date": "2022-08-11T09:11:47+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_describe_change_set_nonexisting": { + "last_validated_date": "2025-03-11T19:12:57+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_describe_change_set_with_similarly_named_stacks": { + "last_validated_date": "2024-03-06T13:56:47+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_empty_changeset": { + "last_validated_date": "2022-08-10T08:52:55+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_multiple_create_changeset": { + "last_validated_date": "2023-11-28T06:38:49+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_changesets.py::test_name_conflicts": { + "last_validated_date": "2023-11-22T09:58:04+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.py new file mode 100644 index 0000000000000..483b46808e6a7 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.py @@ -0,0 +1,36 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@pytest.mark.skip(reason="Not implemented") +@markers.aws.validated +def test_drift_detection_on_lambda(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_simple.yml" + ) + ) + + aws_client.lambda_.update_function_configuration( + FunctionName=stack.outputs["LambdaName"], + Runtime="python3.8", + Description="different description", + Environment={"Variables": {"ENDPOINT_URL": "localhost.localstack.cloud"}}, + ) + + drift_detection = aws_client.cloudformation.detect_stack_resource_drift( + StackName=stack.stack_name, LogicalResourceId="Function" + ) + + snapshot.match("drift_detection", drift_detection) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.snapshot.json new file mode 100644 index 0000000000000..8584f783fa4ff --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.snapshot.json @@ -0,0 +1,63 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.py::test_drift_detection_on_lambda": { + "recorded-date": "11-11-2022, 08:44:20", + "recorded-content": { + "drift_detection": { + "StackResourceDrift": { + "ActualProperties": { + "Description": "different description", + "Environment": { + "Variables": { + "ENDPOINT_URL": "localhost.localstack.cloud" + } + }, + "Handler": "index.handler", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8" + }, + "ExpectedProperties": { + "Description": "function to test lambda function url", + "Environment": { + "Variables": { + "ENDPOINT_URL": "aws.amazon.com" + } + }, + "Handler": "index.handler", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9" + }, + "LogicalResourceId": "Function", + "PhysicalResourceId": "stack-0d03b713-Function-ijoJmdBJP4re", + "PropertyDifferences": [ + { + "ActualValue": "different description", + "DifferenceType": "NOT_EQUAL", + "ExpectedValue": "function to test lambda function url", + "PropertyPath": "/Description" + }, + { + "ActualValue": "localhost.localstack.cloud", + "DifferenceType": "NOT_EQUAL", + "ExpectedValue": "aws.amazon.com", + "PropertyPath": "/Environment/Variables/ENDPOINT_URL" + }, + { + "ActualValue": "python3.8", + "DifferenceType": "NOT_EQUAL", + "ExpectedValue": "python3.9", + "PropertyPath": "/Runtime" + } + ], + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack/stack-0d03b713/", + "StackResourceDriftStatus": "MODIFIED", + "Timestamp": "timestamp" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.validation.json new file mode 100644 index 0000000000000..65b14bd8a839d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_drift_detection.py::test_drift_detection_on_lambda": { + "last_validated_date": "2022-11-11T07:44:20+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py new file mode 100644 index 0000000000000..8e5e475341e9a --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py @@ -0,0 +1,251 @@ +import json +import os +import re + +import botocore +import botocore.errorfactory +import botocore.exceptions +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestExtensionsApi: + @pytest.mark.skip(reason="feature not implemented") + @pytest.mark.parametrize( + "extension_type, extension_name, artifact", + [ + ( + "RESOURCE", + "LocalStack::Testing::TestResource", + "resourcetypes/localstack-testing-testresource.zip", + ), + ( + "MODULE", + "LocalStack::Testing::TestModule::MODULE", + "modules/localstack-testing-testmodule-module.zip", + ), + ("HOOK", "LocalStack::Testing::TestHook", "hooks/localstack-testing-testhook.zip"), + ], + ) + @markers.aws.validated + def test_crud_extension( + self, + deploy_cfn_template, + s3_bucket, + snapshot, + extension_name, + extension_type, + artifact, + aws_client, + ): + bucket_name = s3_bucket + artifact_path = os.path.join( + os.path.dirname(__file__), "../artifacts/extensions/", artifact + ) + key_name = f"key-{short_uid()}" + aws_client.s3.upload_file(artifact_path, bucket_name, key_name) + + register_response = aws_client.cloudformation.register_type( + Type=extension_type, + TypeName=extension_name, + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + + snapshot.add_transformer( + snapshot.transform.key_value("RegistrationToken", "registration-token") + ) + snapshot.add_transformer( + snapshot.transform.key_value("DefaultVersionId", "default-version-id") + ) + snapshot.add_transformer(snapshot.transform.key_value("LogRoleArn", "log-role-arn")) + snapshot.add_transformer(snapshot.transform.key_value("LogGroupName", "log-group-name")) + snapshot.add_transformer( + snapshot.transform.key_value("ExecutionRoleArn", "execution-role-arn") + ) + snapshot.match("register_response", register_response) + + describe_type_response = aws_client.cloudformation.describe_type_registration( + RegistrationToken=register_response["RegistrationToken"] + ) + snapshot.match("describe_type_response", describe_type_response) + + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + + describe_response = aws_client.cloudformation.describe_type( + Arn=describe_type_response["TypeArn"], + ) + snapshot.match("describe_response", describe_response) + + list_response = aws_client.cloudformation.list_type_registrations( + TypeName=extension_name, + ) + snapshot.match("list_response", list_response) + + deregister_response = aws_client.cloudformation.deregister_type( + Arn=describe_type_response["TypeArn"] + ) + snapshot.match("deregister_response", deregister_response) + + @pytest.mark.skip(reason="test not completed") + @markers.aws.validated + def test_extension_versioning(self, s3_bucket, snapshot, aws_client): + """ + This tests validates some api behaviours and errors resulting of creating and deleting versions of extensions. + The process of this test: + - register twice the same extension to have multiple versions + - set the last one as a default one. + - try to delete the whole extension. + - try to delete a version of the extension that doesn't exist. + - delete the first version of the extension. + - try to delete the last available version using the version arn. + - delete the whole extension. + """ + bucket_name = s3_bucket + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/modules/localstack-testing-testmodule-module.zip", + ) + key_name = f"key-{short_uid()}" + aws_client.s3.upload_file(artifact_path, bucket_name, key_name) + + register_response = aws_client.cloudformation.register_type( + Type="MODULE", + TypeName="LocalStack::Testing::TestModule::MODULE", + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + + register_response = aws_client.cloudformation.register_type( + Type="MODULE", + TypeName="LocalStack::Testing::TestModule::MODULE", + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + + versions_response = aws_client.cloudformation.list_type_versions( + TypeName="LocalStack::Testing::TestModule::MODULE", Type="MODULE" + ) + snapshot.match("versions", versions_response) + + set_default_response = aws_client.cloudformation.set_type_default_version( + Arn=versions_response["TypeVersionSummaries"][1]["Arn"] + ) + snapshot.match("set_default_response", set_default_response) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.deregister_type( + Type="MODULE", TypeName="LocalStack::Testing::TestModule::MODULE" + ) + snapshot.match("multiple_versions_error", e.value.response) + + arn = versions_response["TypeVersionSummaries"][1]["Arn"] + with pytest.raises(botocore.errorfactory.ClientError) as e: + arn = re.sub(r"/\d{8}", "99999999", arn) + aws_client.cloudformation.deregister_type(Arn=arn) + snapshot.match("version_not_found_error", e.value.response) + + delete_first_version_response = aws_client.cloudformation.deregister_type( + Arn=versions_response["TypeVersionSummaries"][0]["Arn"] + ) + snapshot.match("delete_unused_version_response", delete_first_version_response) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.deregister_type( + Arn=versions_response["TypeVersionSummaries"][1]["Arn"] + ) + snapshot.match("error_for_deleting_default_with_arn", e.value.response) + + delete_default_response = aws_client.cloudformation.deregister_type( + Type="MODULE", TypeName="LocalStack::Testing::TestModule::MODULE" + ) + snapshot.match("deleting_default_response", delete_default_response) + + @pytest.mark.skip(reason="feature not implemented") + @markers.aws.validated + def test_extension_not_complete(self, s3_bucket, snapshot, aws_client): + """ + This tests validates the error of Extension not found using the describe_type operation when the registration + of the extension is still in progress. + """ + bucket_name = s3_bucket + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/hooks/localstack-testing-testhook.zip", + ) + key_name = f"key-{short_uid()}" + aws_client.s3.upload_file(artifact_path, bucket_name, key_name) + + register_response = aws_client.cloudformation.register_type( + Type="HOOK", + TypeName="LocalStack::Testing::TestHook", + SchemaHandlerPackage=f"s3://{bucket_name}/{key_name}", + ) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.describe_type( + Type="HOOK", TypeName="LocalStack::Testing::TestHook" + ) + snapshot.match("not_found_error", e.value) + + aws_client.cloudformation.get_waiter("type_registration_complete").wait( + RegistrationToken=register_response["RegistrationToken"] + ) + aws_client.cloudformation.deregister_type( + Type="HOOK", + TypeName="LocalStack::Testing::TestHook", + ) + + @pytest.mark.skip(reason="feature not implemented") + @markers.aws.validated + def test_extension_type_configuration(self, register_extension, snapshot, aws_client): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/hooks/localstack-testing-deployablehook.zip", + ) + extension = register_extension( + extension_type="HOOK", + extension_name="LocalStack::Testing::DeployableHook", + artifact_path=artifact_path, + ) + + extension_configuration = json.dumps( + { + "CloudFormationConfiguration": { + "HookConfiguration": {"TargetStacks": "ALL", "FailureMode": "FAIL"} + } + } + ) + response_set_configuration = aws_client.cloudformation.set_type_configuration( + TypeArn=extension["TypeArn"], Configuration=extension_configuration + ) + snapshot.match("set_type_configuration_response", response_set_configuration) + + with pytest.raises(botocore.errorfactory.ClientError) as e: + aws_client.cloudformation.batch_describe_type_configurations( + TypeConfigurationIdentifiers=[{}] + ) + snapshot.match("batch_describe_configurations_errors", e.value) + + describe = aws_client.cloudformation.batch_describe_type_configurations( + TypeConfigurationIdentifiers=[ + { + "TypeArn": extension["TypeArn"], + }, + ] + ) + snapshot.match("batch_describe_configurations", describe) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.snapshot.json new file mode 100644 index 0000000000000..9b165272441a9 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.snapshot.json @@ -0,0 +1,687 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[RESOURCE-LocalStack::Testing::TestResource-resourcetypes/localstack-testing-testresource.zip]": { + "recorded-date": "02-03-2023, 16:11:19", + "recorded-content": { + "register_response": { + "RegistrationToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_type_response": { + "ProgressStatus": "IN_PROGRESS", + "TypeArn": "arn::cloudformation::111111111111:type/resource/LocalStack-Testing-TestResource", + "TypeVersionArn": "arn::cloudformation::111111111111:type/resource/LocalStack-Testing-TestResource/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_response": { + "Arn": "arn::cloudformation::111111111111:type/resource/LocalStack-Testing-TestResource/", + "DefaultVersionId": "", + "DeprecatedStatus": "LIVE", + "Description": "An example resource schema demonstrating some basic constructs and validation rules.", + "ExecutionRoleArn": "", + "IsDefaultVersion": true, + "LastUpdated": "datetime", + "ProvisioningType": "FULLY_MUTABLE", + "Schema": { + "typeName": "LocalStack::Testing::TestResource", + "description": "An example resource schema demonstrating some basic constructs and validation rules.", + "sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "definitions": {}, + "properties": { + "Name": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "Name" + ], + "createOnlyProperties": [ + "/properties/Name" + ], + "primaryIdentifier": [ + "/properties/Name" + ], + "handlers": { + "create": { + "permissions": [] + }, + "read": { + "permissions": [] + }, + "update": { + "permissions": [] + }, + "delete": { + "permissions": [] + }, + "list": { + "permissions": [] + } + } + }, + "SourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-rpdk.git", + "TimeCreated": "datetime", + "Type": "RESOURCE", + "TypeName": "LocalStack::Testing::TestResource", + "Visibility": "PRIVATE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_response": { + "RegistrationTokenList": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deregister_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[MODULE-LocalStack::Testing::TestModule::MODULE-modules/localstack-testing-testmodule-module.zip]": { + "recorded-date": "02-03-2023, 16:11:53", + "recorded-content": { + "register_response": { + "RegistrationToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_type_response": { + "ProgressStatus": "IN_PROGRESS", + "TypeArn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE", + "TypeVersionArn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_response": { + "Arn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/", + "DefaultVersionId": "", + "DeprecatedStatus": "LIVE", + "Description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "IsDefaultVersion": true, + "LastUpdated": "datetime", + "Schema": { + "typeName": "LocalStack::Testing::TestModule::MODULE", + "description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "properties": { + "Parameters": { + "type": "object", + "properties": { + "BucketName": { + "type": "object", + "properties": { + "Type": { + "type": "string" + }, + "Description": { + "type": "string" + } + }, + "required": [ + "Type", + "Description" + ], + "description": "Name for the bucket" + } + } + }, + "Resources": { + "properties": { + "S3Bucket": { + "type": "object", + "properties": { + "Type": { + "type": "string", + "const": "AWS::S3::Bucket" + }, + "Properties": { + "type": "object" + } + } + } + }, + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": true + }, + "TimeCreated": "datetime", + "Type": "MODULE", + "TypeName": "LocalStack::Testing::TestModule::MODULE", + "Visibility": "PRIVATE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_response": { + "RegistrationTokenList": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deregister_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[HOOK-LocalStack::Testing::TestHook-hooks/localstack-testing-testhook.zip]": { + "recorded-date": "02-03-2023, 16:12:56", + "recorded-content": { + "register_response": { + "RegistrationToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_type_response": { + "ProgressStatus": "IN_PROGRESS", + "TypeArn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-TestHook", + "TypeVersionArn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-TestHook/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_response": { + "Arn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-TestHook/", + "ConfigurationSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "examples": [ + { + "CloudFormationConfiguration": { + "HookConfiguration": { + "TargetStacks": "ALL", + "Properties": {}, + "FailureMode": "FAIL" + } + } + } + ], + "description": "This schema validates the CFN hook type configuration that could be set by customers", + "additionalProperties": false, + "title": "CloudFormation Hook Type Configuration Schema", + "type": "object", + "definitions": { + "InvocationPoint": { + "description": "Invocation points are the point in provisioning workflow where hooks will be executed.", + "type": "string", + "enum": [ + "PRE_PROVISION" + ] + }, + "HookTarget": { + "description": "Hook targets are the destination where hooks will be invoked against.", + "additionalProperties": false, + "type": "object", + "properties": { + "InvocationPoint": { + "$ref": "#/definitions/InvocationPoint" + }, + "Action": { + "$ref": "#/definitions/Action" + }, + "TargetName": { + "$ref": "#/definitions/TargetName" + } + }, + "required": [ + "TargetName", + "Action", + "InvocationPoint" + ] + }, + "StackRole": { + "pattern": "arn:.+:iam::[0-9]{12}:role/.+", + "description": "The Amazon Resource Name (ARN) of the IAM execution role to use to perform stack operations", + "type": "string", + "maxLength": 256 + }, + "Action": { + "description": "Target actions are the type of operation hooks will be executed at.", + "type": "string", + "enum": [ + "CREATE", + "UPDATE", + "DELETE" + ] + }, + "TargetName": { + "minLength": 1, + "pattern": "^(?!.*\\*\\?).*$", + "description": "Type name of hook target. Hook targets are the destination where hooks will be invoked against.", + "type": "string", + "maxLength": 256 + }, + "StackName": { + "pattern": "^[a-zA-Z][-a-zA-Z0-9]*$", + "description": "CloudFormation Stack name", + "type": "string", + "maxLength": 128 + } + }, + "properties": { + "CloudFormationConfiguration": { + "additionalProperties": false, + "properties": { + "HookConfiguration": { + "additionalProperties": false, + "type": "object", + "properties": { + "TargetStacks": { + "default": "NONE", + "description": "Attribute to specify which stacks this hook applies to or should get invoked for", + "type": "string", + "enum": [ + "ALL", + "NONE" + ] + }, + "StackFilters": { + "description": "Filters to allow hooks to target specific stack attributes", + "additionalProperties": false, + "type": "object", + "properties": { + "FilteringCriteria": { + "default": "ALL", + "description": "Attribute to specify the filtering behavior. ANY will make the Hook pass if one filter matches. ALL will make the Hook pass if all filters match", + "type": "string", + "enum": [ + "ALL", + "ANY" + ] + }, + "StackNames": { + "description": "List of stack names as filters", + "additionalProperties": false, + "type": "object", + "properties": { + "Exclude": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack names that the hook is going to be excluded from", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackName" + } + }, + "Include": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack names that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackName" + } + } + }, + "minProperties": 1 + }, + "StackRoles": { + "description": "List of stack roles that are performing the stack operations.", + "additionalProperties": false, + "type": "object", + "properties": { + "Exclude": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack roles that the hook is going to be excluded from", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackRole" + } + }, + "Include": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "description": "List of stack roles that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/StackRole" + } + } + }, + "minProperties": 1 + } + }, + "required": [ + "FilteringCriteria" + ] + }, + "TargetFilters": { + "oneOf": [ + { + "additionalProperties": false, + "type": "object", + "properties": { + "Actions": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of actions that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/Action" + } + }, + "TargetNames": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of type names that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/TargetName" + } + }, + "InvocationPoints": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of invocation points that the hook is going to target", + "insertionOrder": false, + "type": "array", + "items": { + "$ref": "#/definitions/InvocationPoint" + } + } + }, + "minProperties": 1 + }, + { + "additionalProperties": false, + "type": "object", + "properties": { + "Targets": { + "minItems": 1, + "maxItems": 50, + "uniqueItems": true, + "additionalItems": false, + "description": "List of hook targets", + "type": "array", + "items": { + "$ref": "#/definitions/HookTarget" + } + } + }, + "required": [ + "Targets" + ] + } + ], + "description": "Attribute to specify which targets should invoke the hook", + "type": "object" + }, + "Properties": { + "typeName": "LocalStack::Testing::TestHook", + "description": "Hook runtime properties", + "additionalProperties": false, + "type": "object", + "definitions": {}, + "properties": { + "EncryptionAlgorithm": { + "default": "AES256", + "description": "Encryption algorithm for SSE", + "type": "string" + } + } + }, + "FailureMode": { + "default": "WARN", + "description": "Attribute to specify CloudFormation behavior on hook failure.", + "type": "string", + "enum": [ + "FAIL", + "WARN" + ] + } + }, + "required": [ + "TargetStacks", + "FailureMode" + ] + } + }, + "required": [ + "HookConfiguration" + ] + } + }, + "required": [ + "CloudFormationConfiguration" + ], + "$id": "https://schema.cloudformation..amazonaws.com/cloudformation.hook.configuration.schema.v1.json" + }, + "DefaultVersionId": "", + "DeprecatedStatus": "LIVE", + "Description": "Example resource SSE (Server Side Encryption) verification hook", + "DocumentationUrl": "https://github.com/aws-cloudformation/example-sse-hook/blob/master/README.md", + "IsDefaultVersion": true, + "LastUpdated": "datetime", + "Schema": { + "typeName": "LocalStack::Testing::TestHook", + "description": "Example resource SSE (Server Side Encryption) verification hook", + "sourceUrl": "https://github.com/aws-cloudformation/example-sse-hook", + "documentationUrl": "https://github.com/aws-cloudformation/example-sse-hook/blob/master/README.md", + "typeConfiguration": { + "properties": { + "EncryptionAlgorithm": { + "description": "Encryption algorithm for SSE", + "default": "AES256", + "type": "string" + } + }, + "additionalProperties": false + }, + "required": [], + "handlers": { + "preCreate": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [] + }, + "preUpdate": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [] + }, + "preDelete": { + "targetNames": [ + "AWS::S3::Bucket" + ], + "permissions": [] + } + }, + "additionalProperties": false + }, + "SourceUrl": "https://github.com/aws-cloudformation/example-sse-hook", + "TimeCreated": "datetime", + "Type": "HOOK", + "TypeName": "LocalStack::Testing::TestHook", + "Visibility": "PRIVATE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_response": { + "RegistrationTokenList": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deregister_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_versioning": { + "recorded-date": "02-03-2023, 16:14:12", + "recorded-content": { + "versions": { + "TypeVersionSummaries": [ + { + "Arn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/00000050", + "Description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "IsDefaultVersion": true, + "TimeCreated": "datetime", + "Type": "MODULE", + "TypeName": "LocalStack::Testing::TestModule::MODULE", + "VersionId": "00000050" + }, + { + "Arn": "arn::cloudformation::111111111111:type/module/LocalStack-Testing-TestModule-MODULE/00000051", + "Description": "Schema for Module Fragment of type LocalStack::Testing::TestModule::MODULE", + "IsDefaultVersion": false, + "TimeCreated": "datetime", + "Type": "MODULE", + "TypeName": "LocalStack::Testing::TestModule::MODULE", + "VersionId": "00000051" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set_default_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multiple_versions_error": { + "Error": { + "Code": "CFNRegistryException", + "Message": "This type has more than one active version. Please deregister non-default active versions before attempting to deregister the type.", + "Type": "Sender" + }, + "Message": "This type has more than one active version. Please deregister non-default active versions before attempting to deregister the type.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "version_not_found_error": { + "Error": { + "Code": "CFNRegistryException", + "Message": "TypeName is invalid", + "Type": "Sender" + }, + "Message": "TypeName is invalid", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_unused_version_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error_for_deleting_default_with_arn": { + "Error": { + "Code": "CFNRegistryException", + "Message": "Version '00000051' is the default version and cannot be deregistered. Deregister the resource type 'LocalStack::Testing::TestModule::MODULE' instead.", + "Type": "Sender" + }, + "Message": "Version '00000051' is the default version and cannot be deregistered. Deregister the resource type 'LocalStack::Testing::TestModule::MODULE' instead.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleting_default_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_not_complete": { + "recorded-date": "02-03-2023, 16:15:26", + "recorded-content": { + "not_found_error": "An error occurred (TypeNotFoundException) when calling the DescribeType operation: The type 'LocalStack::Testing::TestHook' cannot be found." + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_type_configuration": { + "recorded-date": "06-03-2023, 15:33:33", + "recorded-content": { + "set_type_configuration_response": { + "ConfigurationArn": "arn::cloudformation::111111111111:type-configuration/hook/LocalStack-Testing-DeployableHook/default", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "batch_describe_configurations_errors": "An error occurred (ValidationError) when calling the BatchDescribeTypeConfigurations operation: 1 validation error detected: Value null at 'typeConfigurationIdentifiers' failed to satisfy constraint: Member must not be null", + "batch_describe_configurations": { + "Errors": [], + "TypeConfigurations": [ + { + "Alias": "default", + "Arn": "arn::cloudformation::111111111111:type-configuration/hook/LocalStack-Testing-DeployableHook/default", + "Configuration": { + "CloudFormationConfiguration": { + "HookConfiguration": { + "TargetStacks": "ALL", + "FailureMode": "FAIL" + } + } + }, + "LastUpdated": "datetime", + "TypeArn": "arn::cloudformation::111111111111:type/hook/LocalStack-Testing-DeployableHook" + } + ], + "UnprocessedTypeConfigurations": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.validation.json new file mode 100644 index 0000000000000..4687c7c2e5103 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[HOOK-LocalStack::Testing::TestHook-hooks/localstack-testing-testhook.zip]": { + "last_validated_date": "2023-03-02T15:12:56+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[MODULE-LocalStack::Testing::TestModule::MODULE-modules/localstack-testing-testmodule-module.zip]": { + "last_validated_date": "2023-03-02T15:11:53+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_crud_extension[RESOURCE-LocalStack::Testing::TestResource-resourcetypes/localstack-testing-testresource.zip]": { + "last_validated_date": "2023-03-02T15:11:19+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_not_complete": { + "last_validated_date": "2023-03-02T15:15:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_type_configuration": { + "last_validated_date": "2023-03-06T14:33:33+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_api.py::TestExtensionsApi::test_extension_versioning": { + "last_validated_date": "2023-03-02T15:14:12+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py new file mode 100644 index 0000000000000..7f3375678845d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py @@ -0,0 +1,81 @@ +import json +import os + +import botocore.exceptions +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.cloudformation_utils import load_template_file +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestExtensionsHooks: + @pytest.mark.skip(reason="feature not implemented") + @pytest.mark.parametrize("failure_mode", ["FAIL", "WARN"]) + @markers.aws.validated + def test_hook_deployment( + self, failure_mode, register_extension, snapshot, cleanups, aws_client + ): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/hooks/localstack-testing-deployablehook.zip", + ) + extension = register_extension( + extension_type="HOOK", + extension_name="LocalStack::Testing::DeployableHook", + artifact_path=artifact_path, + ) + + extension_configuration = json.dumps( + { + "CloudFormationConfiguration": { + "HookConfiguration": {"TargetStacks": "ALL", "FailureMode": failure_mode} + } + } + ) + aws_client.cloudformation.set_type_configuration( + TypeArn=extension["TypeArn"], Configuration=extension_configuration + ) + + template = load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/s3_bucket_name.yml", + ) + ) + + stack_name = f"stack-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template, + Parameters=[{"ParameterKey": "Name", "ParameterValue": f"bucket-{short_uid()}"}], + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + if failure_mode == "WARN": + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + else: + with pytest.raises(botocore.exceptions.WaiterError): + aws_client.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack_name + ) + + events = aws_client.cloudformation.describe_stack_events(StackName=stack_name)[ + "StackEvents" + ] + + failed_events = [e for e in events if "HookStatusReason" in e] + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer( + snapshot.transform.key_value( + "EventId", value_replacement="", reference_replacement=False + ) + ) + snapshot.match("event_error", failed_events[0]) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.snapshot.json new file mode 100644 index 0000000000000..c75998e8991f9 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.snapshot.json @@ -0,0 +1,42 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[FAIL]": { + "recorded-date": "06-03-2023, 15:00:08", + "recorded-content": { + "event_error": { + "EventId": "", + "HookFailureMode": "FAIL", + "HookInvocationPoint": "PRE_PROVISION", + "HookStatus": "HOOK_COMPLETE_FAILED", + "HookStatusReason": "Hook failed with message: Intentional fail", + "HookType": "LocalStack::Testing::DeployableHook", + "LogicalResourceId": "myb3B4550BC", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[WARN]": { + "recorded-date": "06-03-2023, 15:01:59", + "recorded-content": { + "event_error": { + "EventId": "", + "HookFailureMode": "WARN", + "HookInvocationPoint": "PRE_PROVISION", + "HookStatus": "HOOK_COMPLETE_FAILED", + "HookStatusReason": "Hook failed with message: Intentional fail. Failure was ignored under WARN mode.", + "HookType": "LocalStack::Testing::DeployableHook", + "LogicalResourceId": "myb3B4550BC", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.validation.json new file mode 100644 index 0000000000000..f20a821925dd1 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[FAIL]": { + "last_validated_date": "2023-03-06T14:00:08+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_hooks.py::TestExtensionsHooks::test_hook_deployment[WARN]": { + "last_validated_date": "2023-03-06T14:01:59+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.py new file mode 100644 index 0000000000000..73bc059d62288 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.py @@ -0,0 +1,47 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestExtensionsModules: + @pytest.mark.skip(reason="feature not supported") + @markers.aws.validated + def test_module_usage(self, deploy_cfn_template, register_extension, snapshot, aws_client): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/modules/localstack-testing-testmodule-module.zip", + ) + register_extension( + extension_type="MODULE", + extension_name="LocalStack::Testing::TestModule::MODULE", + artifact_path=artifact_path, + ) + + template_path = os.path.join( + os.path.dirname(__file__), + "../../../../../templates/registry/module.yml", + ) + + module_bucket_name = f"bucket-module-{short_uid()}" + stack = deploy_cfn_template( + template_path=template_path, + parameters={"BucketName": module_bucket_name}, + max_wait=300, + ) + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name)[ + "StackResources" + ] + + snapshot.add_transformer(snapshot.transform.regex(module_bucket_name, "bucket-name-")) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("resource_description", resources[0]) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.snapshot.json new file mode 100644 index 0000000000000..8696dae584507 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.snapshot.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.py::TestExtensionsModules::test_module_usage": { + "recorded-date": "27-02-2023, 16:06:45", + "recorded-content": { + "resource_description": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "BucketModuleS3Bucket", + "ModuleInfo": { + "LogicalIdHierarchy": "BucketModule", + "TypeHierarchy": "LocalStack::Testing::TestModule::MODULE" + }, + "PhysicalResourceId": "bucket-name-hello", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.validation.json new file mode 100644 index 0000000000000..8c17cae314b38 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_modules.py::TestExtensionsModules::test_module_usage": { + "last_validated_date": "2023-02-27T15:06:45+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.py new file mode 100644 index 0000000000000..c311980ea441e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.py @@ -0,0 +1,51 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestExtensionsResourceTypes: + @pytest.mark.skip(reason="feature not implemented") + @markers.aws.validated + def test_deploy_resource_type( + self, deploy_cfn_template, register_extension, snapshot, aws_client + ): + artifact_path = os.path.join( + os.path.dirname(__file__), + "../artifacts/extensions/resourcetypes/localstack-testing-deployableresource.zip", + ) + + register_extension( + extension_type="RESOURCE", + extension_name="LocalStack::Testing::DeployableResource", + artifact_path=artifact_path, + ) + + template_path = os.path.join( + os.path.dirname(__file__), + "../../../../../templates/registry/resource-provider.yml", + ) + + resource_name = f"name-{short_uid()}" + stack = deploy_cfn_template( + template_path=template_path, parameters={"Name": resource_name}, max_wait=900 + ) + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name)[ + "StackResources" + ] + + snapshot.add_transformer(snapshot.transform.regex(resource_name, "resource-name")) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("resource_description", resources[0]) + + # Make sure to destroy the stack before unregistration + stack.destroy() diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.snapshot.json new file mode 100644 index 0000000000000..57898783864f7 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.snapshot.json @@ -0,0 +1,19 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.py::TestExtensionsResourceTypes::test_deploy_resource_type": { + "recorded-date": "28-02-2023, 12:48:27", + "recorded-content": { + "resource_description": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyCustomResource", + "PhysicalResourceId": "Test", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "LocalStack::Testing::DeployableResource", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.validation.json new file mode 100644 index 0000000000000..51a7ddf2e5932 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_extensions_resourcetypes.py::TestExtensionsResourceTypes::test_deploy_resource_type": { + "last_validated_date": "2023-02-28T11:48:27+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py new file mode 100644 index 0000000000000..ad163a709f4db --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py @@ -0,0 +1,366 @@ +import os + +import pytest +from botocore.exceptions import ClientError, WaiterError + +from localstack import config +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +# pytestmark = pytest.mark.skipif( +# condition=not is_v2_engine() and not is_aws_cloud(), +# reason="Only targeting the new engine", +# ) + +pytestmark = pytest.mark.skip(reason="CFNV2:NestedStack") + + +@markers.aws.needs_fixing +def test_nested_stack(deploy_cfn_template, s3_create_bucket, aws_client): + # upload template to S3 + artifacts_bucket = f"cf-artifacts-{short_uid()}" + artifacts_path = "stack.yaml" + s3_create_bucket(Bucket=artifacts_bucket, ACL="public-read") + aws_client.s3.put_object( + Bucket=artifacts_bucket, + Key=artifacts_path, + Body=load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/template5.yaml") + ), + ) + + # deploy template + param_value = short_uid() + stack_bucket_name = f"test-{param_value}" # this is the bucket name generated by template5 + + deploy_cfn_template( + template=load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/template6.yaml") + ) + % (artifacts_bucket, artifacts_path), + parameters={"GlobalParam": param_value}, + ) + + # assert that nested resources have been created + def assert_bucket_exists(): + response = aws_client.s3.head_bucket(Bucket=stack_bucket_name) + assert 200 == response["ResponseMetadata"]["HTTPStatusCode"] + + retry(assert_bucket_exists) + + +@markers.aws.validated +def test_nested_stack_output_refs(deploy_cfn_template, s3_create_bucket, aws_client): + """test output handling of nested stacks incl. referencing the nested output in the parent stack""" + bucket_name = s3_create_bucket() + nested_bucket_name = f"test-bucket-nested-{short_uid()}" + key = f"test-key-{short_uid()}" + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/nested-stack-output-refs.nested.yaml", + ), + Bucket=bucket_name, + Key=key, + ) + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/nested-stack-output-refs.yaml" + ), + template_mapping={ + "s3_bucket_url": f"/{bucket_name}/{key}", + "nested_bucket_name": nested_bucket_name, + }, + max_wait=120, # test is flaky, so we need to wait a bit longer + ) + + nested_stack_id = result.outputs["CustomNestedStackId"] + nested_stack_details = aws_client.cloudformation.describe_stacks(StackName=nested_stack_id) + nested_stack_outputs = nested_stack_details["Stacks"][0]["Outputs"] + assert "InnerCustomOutput" not in result.outputs + assert ( + nested_bucket_name + == [ + o["OutputValue"] for o in nested_stack_outputs if o["OutputKey"] == "InnerCustomOutput" + ][0] + ) + assert f"{nested_bucket_name}-suffix" == result.outputs["CustomOutput"] + + +@markers.aws.validated +def test_nested_with_nested_stack(deploy_cfn_template, s3_create_bucket, aws_client): + bucket_name = s3_create_bucket() + bucket_to_create_name = f"test-bucket-{short_uid()}" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + nested_stacks = ["nested_child.yml", "nested_parent.yml"] + urls = [] + + for nested_stack in nested_stacks: + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/", nested_stack), + Bucket=bucket_name, + Key=nested_stack, + ) + + urls.append(f"https://{bucket_name}.s3.{domain}/{nested_stack}") + + outputs = deploy_cfn_template( + max_wait=120 if is_aws_cloud() else None, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/nested_grand_parent.yml" + ), + parameters={ + "ChildStackURL": urls[0], + "ParentStackURL": urls[1], + "BucketToCreate": bucket_to_create_name, + }, + ).outputs + + assert f"arn:aws:s3:::{bucket_to_create_name}" == outputs["parameterValue"] + + +@markers.aws.validated +@pytest.mark.skip(reason="UPDATE isn't working on nested stacks") +def test_lifecycle_nested_stack(deploy_cfn_template, s3_create_bucket, aws_client): + bucket_name = s3_create_bucket() + nested_bucket_name = f"test-bucket-nested-{short_uid()}" + altered_nested_bucket_name = f"test-bucket-nested-{short_uid()}" + key = f"test-key-{short_uid()}" + + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/nested-stack-output-refs.nested.yaml", + ), + Bucket=bucket_name, + Key=key, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/nested-stack-output-refs.yaml" + ), + template_mapping={ + "s3_bucket_url": f"/{bucket_name}/{key}", + "nested_bucket_name": nested_bucket_name, + }, + ) + assert aws_client.s3.head_bucket(Bucket=nested_bucket_name) + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/nested-stack-output-refs.yaml" + ), + template_mapping={ + "s3_bucket_url": f"/{bucket_name}/{key}", + "nested_bucket_name": altered_nested_bucket_name, + }, + max_wait=120 if is_aws_cloud() else None, + ) + + assert aws_client.s3.head_bucket(Bucket=altered_nested_bucket_name) + + stack.destroy() + + def _assert_bucket_is_deleted(): + try: + aws_client.s3.head_bucket(Bucket=altered_nested_bucket_name) + return False + except ClientError: + return True + + retry(_assert_bucket_is_deleted, retries=5, sleep=2, sleep_before=2) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..Role.AssumeRolePolicyDocument..Action", + ] +) +@markers.aws.validated +def test_nested_output_in_params(deploy_cfn_template, s3_create_bucket, snapshot, aws_client): + """ + Deploys a Stack with two nested stacks (sub1 and sub2) with a dependency between each other sub2 depends on sub1. + The `sub2` stack uses an output parameter of `sub1` as an input parameter. + + Resources: + - Stack + - 2x Nested Stack + - SNS Topic + - IAM role with policy (sns:Publish) + + """ + # upload template to S3 for nested stacks + template_bucket = f"cfn-root-{short_uid()}" + sub1_path = "sub1.yaml" + sub2_path = "sub2.yaml" + s3_create_bucket(Bucket=template_bucket, ACL="public-read") + aws_client.s3.put_object( + Bucket=template_bucket, + Key=sub1_path, + Body=load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/nested-stack-outputref/sub1.yaml", + ) + ), + ) + aws_client.s3.put_object( + Bucket=template_bucket, + Key=sub2_path, + Body=load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/nested-stack-outputref/sub2.yaml", + ) + ), + ) + topic_name = f"test-topic-{short_uid()}" + role_name = f"test-role-{short_uid()}" + + if is_aws_cloud(): + base_path = "https://s3.amazonaws.com" + else: + base_path = "http://localhost:4566" + + deploy_cfn_template( + template=load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/nested-stack-outputref/root.yaml", + ) + ), + parameters={ + "Sub1TemplateUrl": f"{base_path}/{template_bucket}/{sub1_path}", + "Sub2TemplateUrl": f"{base_path}/{template_bucket}/{sub2_path}", + "TopicName": topic_name, + "RoleName": role_name, + }, + ) + # validations + snapshot.add_transformer(snapshot.transform.key_value("RoleId", "role-id")) + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + snapshot.add_transformer(snapshot.transform.regex(role_name, "")) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + get_role_response = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("get_role_response", get_role_response) + role_policies = aws_client.iam.list_role_policies(RoleName=role_name) + snapshot.match("role_policies", role_policies) + policy_name = role_policies["PolicyNames"][0] + actual_policy = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=policy_name) + snapshot.match("actual_policy", actual_policy) + + sns_pager = aws_client.sns.get_paginator("list_topics") + topics = sns_pager.paginate().build_full_result()["Topics"] + filtered_topics = [t["TopicArn"] for t in topics if topic_name in t["TopicArn"]] + assert len(filtered_topics) == 1 + + +@markers.aws.validated +def test_nested_stacks_conditions(deploy_cfn_template, s3_create_bucket, aws_client): + """ + see: TestCloudFormationConditions.test_condition_on_outputs + + equivalent to the condition test but for a nested stack + """ + bucket_name = s3_create_bucket() + nested_bucket_name = f"test-bucket-nested-{short_uid()}" + key = f"test-key-{short_uid()}" + + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/nested-stack-conditions.nested.yaml", + ), + Bucket=bucket_name, + Key=key, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/nested-stack-conditions.yaml" + ), + parameters={ + "S3BucketPath": f"/{bucket_name}/{key}", + "S3BucketName": nested_bucket_name, + }, + ) + + assert stack.outputs["ProdBucket"] == f"{nested_bucket_name}-prod" + assert aws_client.s3.head_bucket(Bucket=stack.outputs["ProdBucket"]) + + # Ensure that nested stack names are correctly generated + nested_stack = aws_client.cloudformation.describe_stacks( + StackName=stack.outputs["NestedStackArn"] + ) + assert ":" not in nested_stack["Stacks"][0]["StackName"] + + +@markers.aws.validated +def test_deletion_of_failed_nested_stack(s3_create_bucket, aws_client, region_name, snapshot): + """ + This test confirms that after deleting a stack parent with a failed nested stack. The nested stack is also deleted + """ + + bucket_name = s3_create_bucket() + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_failed_nested_stack_child.yml" + ), + Bucket=bucket_name, + Key="child.yml", + ) + + stack_name = f"stack-{short_uid()}" + child_template_url = ( + f"https://{bucket_name}.s3.{config.LOCALSTACK_HOST.host_and_port()}/child.yml" + ) + if is_aws_cloud(): + child_template_url = f"https://{bucket_name}.s3.{region_name}.amazonaws.com/child.yml" + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_failed_nested_stack_parent.yml", + ), + ), + Parameters=[ + {"ParameterKey": "TemplateUri", "ParameterValue": child_template_url}, + ], + OnFailure="DO_NOTHING", + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + + with pytest.raises(WaiterError): + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + stack_status = aws_client.cloudformation.describe_stacks(StackName=stack_name)["Stacks"][0][ + "StackStatus" + ] + assert stack_status == "CREATE_FAILED" + + stacks = aws_client.cloudformation.describe_stacks()["Stacks"] + nested_stack_name = [ + stack for stack in stacks if f"{stack_name}-ChildStack-" in stack["StackName"] + ][0]["StackName"] + + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + with pytest.raises(ClientError) as ex: + aws_client.cloudformation.describe_stacks(StackName=nested_stack_name) + + snapshot.match("error", ex.value.response) + snapshot.add_transformer(snapshot.transform.regex(nested_stack_name, "")) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.snapshot.json new file mode 100644 index 0000000000000..d343aff512da3 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.snapshot.json @@ -0,0 +1,83 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_nested_output_in_params": { + "recorded-date": "07-02-2023, 10:57:47", + "recorded-content": { + "get_role_response": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "IsTruncated": false, + "PolicyNames": [ + "PolicyA" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "actual_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "sns:Publish" + ], + "Effect": "Allow", + "Resource": [ + "arn::sns::111111111111:" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "PolicyA", + "RoleName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_deletion_of_failed_nested_stack": { + "recorded-date": "17-09-2024, 20:09:36", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Stack with id does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.validation.json new file mode 100644 index 0000000000000..26a6749598c8d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_deletion_of_failed_nested_stack": { + "last_validated_date": "2024-09-17T20:09:36+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_nested_stacks.py::test_nested_output_in_params": { + "last_validated_date": "2023-02-07T09:57:47+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py new file mode 100644 index 0000000000000..b6013fc8dbbcc --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py @@ -0,0 +1,113 @@ +import os + +import pytest + +from localstack.services.cloudformation.engine.template_deployer import MOCK_REFERENCE +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@pytest.mark.parametrize("attribute_name", ["TopicName", "TopicArn"]) +@markers.aws.validated +def test_nested_getatt_ref(deploy_cfn_template, aws_client, attribute_name, snapshot): + topic_name = f"test-topic-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_getatt_ref.yaml" + ), + parameters={"MyParam": topic_name, "CustomOutputName": attribute_name}, + ) + snapshot.match("outputs", deployment.outputs) + topic_arn = deployment.outputs["MyTopicArn"] + + # Verify the nested GetAtt Ref resolved correctly + custom_ref = deployment.outputs["MyTopicCustom"] + if attribute_name == "TopicName": + assert custom_ref == topic_name + + if attribute_name == "TopicArn": + assert custom_ref == topic_arn + + # Verify resource was created + topic_arns = [t["TopicArn"] for t in aws_client.sns.list_topics()["Topics"]] + assert topic_arn in topic_arns + + +@markers.aws.validated +def test_sub_resolving(deploy_cfn_template, aws_client, snapshot): + """ + Tests different cases for Fn::Sub resolving + + https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html + + + TODO: cover all supported functions for VarName / VarValue: + Fn::Base64 + Fn::FindInMap + Fn::GetAtt + Fn::GetAZs + Fn::If + Fn::ImportValue + Fn::Join + Fn::Select + Ref + + """ + topic_name = f"test-topic-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_sub_resovling.yaml" + ), + parameters={"MyParam": topic_name}, + ) + snapshot.match("outputs", deployment.outputs) + topic_arn = deployment.outputs["MyTopicArn"] + + # Verify the parts in the Fn::Sub string are resolved correctly. + sub_output = deployment.outputs["MyTopicSub"] + param, ref, getatt_topicname, getatt_topicarn = sub_output.split("|") + assert param == topic_name + assert ref == topic_arn + assert getatt_topicname == topic_name + assert getatt_topicarn == topic_arn + + map_sub_output = deployment.outputs["MyTopicSubWithMap"] + att_in_map, ref_in_map, static_in_map = map_sub_output.split("|") + assert att_in_map == topic_name + assert ref_in_map == topic_arn + assert static_in_map == "something" + + # Verify resource was created + topic_arns = [t["TopicArn"] for t in aws_client.sns.list_topics()["Topics"]] + assert topic_arn in topic_arns + + +@pytest.mark.skip(reason="CFNV2:Validation") +@markers.aws.only_localstack +def test_reference_unsupported_resource(deploy_cfn_template, aws_client): + """ + This test verifies that templates can be deployed even when unsupported resources are references + Make sure to update the template as coverage of resources increases. + """ + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_ref_unsupported.yml" + ), + ) + + ref_of_unsupported = deployment.outputs["reference"] + value_of_unsupported = deployment.outputs["parameter"] + assert ref_of_unsupported == MOCK_REFERENCE + assert value_of_unsupported == f"The value of the attribute is: {MOCK_REFERENCE}" diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.snapshot.json new file mode 100644 index 0000000000000..0c364dca777b8 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.snapshot.json @@ -0,0 +1,36 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_nested_getatt_ref[TopicName]": { + "recorded-date": "11-05-2023, 13:43:51", + "recorded-content": { + "outputs": { + "MyTopicArn": "arn::sns::111111111111:", + "MyTopicCustom": "", + "MyTopicName": "", + "MyTopicRef": "arn::sns::111111111111:" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_nested_getatt_ref[TopicArn]": { + "recorded-date": "11-05-2023, 13:44:18", + "recorded-content": { + "outputs": { + "MyTopicArn": "arn::sns::111111111111:", + "MyTopicCustom": "arn::sns::111111111111:", + "MyTopicName": "", + "MyTopicRef": "arn::sns::111111111111:" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_sub_resolving": { + "recorded-date": "12-05-2023, 07:51:06", + "recorded-content": { + "outputs": { + "MyTopicArn": "arn::sns::111111111111:", + "MyTopicName": "", + "MyTopicRef": "arn::sns::111111111111:", + "MyTopicSub": "|arn::sns::111111111111:||arn::sns::111111111111:", + "MyTopicSubWithMap": "|arn::sns::111111111111:|something" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.validation.json new file mode 100644 index 0000000000000..eb277de08d538 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_nested_getatt_ref[TopicArn]": { + "last_validated_date": "2023-05-11T11:44:18+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_nested_getatt_ref[TopicName]": { + "last_validated_date": "2023-05-11T11:43:51+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_reference_resolving.py::test_sub_resolving": { + "last_validated_date": "2023-05-12T05:51:06+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py new file mode 100644 index 0000000000000..e3cda139c5118 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py @@ -0,0 +1,812 @@ +import json +import os + +import botocore.exceptions +import pytest +import yaml + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +def get_events_canceled_by_policy(cfn_client, stack_name): + events = cfn_client.describe_stack_events(StackName=stack_name)["StackEvents"] + + failed_events_by_policy = [ + event + for event in events + if "ResourceStatusReason" in event + and ( + "Action denied by stack policy" in event["ResourceStatusReason"] + or "Action not allowed by stack policy" in event["ResourceStatusReason"] + or "Resource update cancelled" in event["ResourceStatusReason"] + ) + ] + + return failed_events_by_policy + + +def delete_stack_after_process(cfn_client, stack_name): + progress_is_finished = False + while not progress_is_finished: + status = cfn_client.describe_stacks(StackName=stack_name)["Stacks"][0]["StackStatus"] + progress_is_finished = "PROGRESS" not in status + cfn_client.delete_stack(StackName=stack_name) + + +class TestStackPolicy: + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_policy_lifecycle(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ), + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("initial_policy", obtained_policy) + + policy = { + "Statement": [ + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"} + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"} + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_updated", obtained_policy) + + policy = {} + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_deleted", obtained_policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_policy_with_url(self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client): + """Test to validate the setting of a Stack Policy through an URL""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ), + ) + bucket_name = s3_create_bucket() + key = "policy.json" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/stack_policy.json"), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + aws_client.cloudformation.set_stack_policy(StackName=stack.stack_name, StackPolicyURL=url) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_invalid_policy_with_url( + self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client + ): + """Test to validate the error response resulting of setting an invalid Stack Policy through an URL""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ), + ) + bucket_name = s3_create_bucket() + key = "policy.json" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/invalid_stack_policy.json" + ), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyURL=url + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_empty_policy_with_url( + self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client + ): + """Test to validate the setting of an empty Stack Policy through an URL""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ), + ) + bucket_name = s3_create_bucket() + key = "policy.json" + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/empty_policy.json"), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + aws_client.cloudformation.set_stack_policy(StackName=stack.stack_name, StackPolicyURL=url) + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_policy_both_policy_and_url( + self, deploy_cfn_template, s3_create_bucket, snapshot, aws_client + ): + """Test to validate the API behavior when trying to set a Stack policy using both the body and the URL""" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ), + ) + + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + bucket_name = s3_create_bucket() + key = "policy.json" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/stack_policy.json"), + Bucket=bucket_name, + Key=key, + ) + + url = f"https://{bucket_name}.s3.{domain}/{key}" + + policy = { + "Statement": [ + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"} + ] + } + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy), StackPolicyURL=url + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_empty_policy(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/stack_policy_test.yaml" + ), + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = {} + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", policy) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_not_json_policy(self, deploy_cfn_template, snapshot, aws_client): + """Test to validate the error response when setting and Invalid Policy""" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/stack_policy_test.yaml" + ), + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=short_uid() + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_different_principal_attribute(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": short_uid(), + "Resource": "*", + } + ] + } + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + error_response = ex.value.response["Error"] + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_different_action_attribute(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Delete:*", + "Principal": short_uid(), + "Resource": "*", + } + ] + } + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + @pytest.mark.parametrize("resource_type", ["AWS::S3::Bucket", "AWS::SNS::Topic"]) + def test_prevent_update(self, resource_type, deploy_cfn_template, aws_client): + """ + Test to validate the correct behavior of the update operation on a Stack with a Policy that prevents an update + for a specific resource type + """ + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/stack_policy_test.yaml" + ) + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*", + "Condition": {"StringEquals": {"ResourceType": [resource_type]}}, + }, + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + {"ParameterKey": "BucketName", "ParameterValue": f"new-bucket-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + # if the policy prevents one resource to update the whole update fails + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=5, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + @pytest.mark.parametrize( + "resource", + [ + {"id": "bucket123", "type": "AWS::S3::Bucket"}, + {"id": "topic123", "type": "AWS::SNS::Topic"}, + ], + ) + def test_prevent_deletion(self, resource, deploy_cfn_template, aws_client): + """ + Test to validate that CFn won't delete resources during an update operation that are protected by the Stack + Policy + """ + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/stack_policy_test.yaml" + ) + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:Delete", + "Principal": "*", + "Resource": "*", + "Condition": {"StringEquals": {"ResourceType": [resource["type"]]}}, + } + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + template_dict = yaml.load(template) + del template_dict["Resources"][resource["id"]] + template = yaml.dump(template_dict) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + {"ParameterKey": "BucketName", "ParameterValue": f"new-bucket-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=6, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_prevent_modifying_with_policy_specifying_resource_id( + self, deploy_cfn_template, aws_client + ): + """ + Test to validate that CFn won't modify a resource protected by a stack policy that specifies the resource + using the logical Resource Id + """ + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/simple_api.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"ApiName": f"api-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:Modify", + "Principal": "*", + "Resource": "LogicalResourceId/Api", + } + ] + } + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + TemplateBody=template, + StackName=stack.stack_name, + Parameters=[ + {"ParameterKey": "ApiName", "ParameterValue": f"new-api-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=6, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_prevent_replacement(self, deploy_cfn_template, aws_client): + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ) + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:Replace", + "Principal": "*", + "Resource": "*", + } + ] + } + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"bucket-{short_uid()}"}, + ], + ) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + try: + retry(_assert_failing_update_state, retries=6, sleep=2, sleep_before=2) + finally: + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_update_with_policy(self, deploy_cfn_template, aws_client): + """ + Test to validate the completion of a stack update that is allowed by the Stack Policy + """ + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/stack_policy_test.yaml" + ) + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*", + "Condition": {"StringEquals": {"ResourceType": ["AWS::EC2::Subnet"]}}, + }, + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_update_with_empty_policy(self, deploy_cfn_template, is_stack_updated, aws_client): + """ + Test to validate the behavior of a stack update that has an empty Stack Policy + """ + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/stack_policy_test.yaml" + ) + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}", "BucketName": f"bucket-{short_uid()}"}, + ) + aws_client.cloudformation.set_stack_policy(StackName=stack.stack_name, StackPolicyBody="{}") + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + {"ParameterKey": "BucketName", "ParameterValue": f"new-bucket-{short_uid()}"}, + ], + ) + + def _assert_stack_is_updated(): + assert is_stack_updated(stack.stack_name) + + retry(_assert_stack_is_updated, retries=5, sleep=2, sleep_before=1) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + @pytest.mark.parametrize("reverse_statements", [False, True]) + def test_update_with_overlapping_policies( + self, reverse_statements, deploy_cfn_template, is_stack_updated, aws_client + ): + """ + This test validates the behaviour when two statements in policy contradict each other. + According to the AWS triage, the last statement is the one that is followed. + """ + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ) + ) + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + statements = [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + + if reverse_statements: + statements.reverse() + + policy = {"Statement": statements} + + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"}, + ], + ) + + def _assert_stack_is_updated(): + assert is_stack_updated(stack.stack_name) + + def _assert_failing_update_state(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + retry( + _assert_stack_is_updated if not reverse_statements else _assert_failing_update_state, + retries=5, + sleep=2, + sleep_before=2, + ) + + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_create_stack_with_policy(self, snapshot, cleanup_stacks, aws_client): + stack_name = f"stack-{short_uid()}" + + policy = { + "Statement": [ + {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ) + ) + + aws_client.cloudformation.create_stack( + StackName=stack_name, + StackPolicyBody=json.dumps(policy), + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"} + ], + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack_name) + snapshot.match("policy", obtained_policy) + cleanup_stacks([stack_name]) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_set_policy_with_update_operation( + self, deploy_cfn_template, is_stack_updated, snapshot, cleanup_stacks, aws_client + ): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/simple_api.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"ApiName": f"api-{short_uid()}"}, + ) + + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "ApiName", "ParameterValue": f"api-{short_uid()}"}, + ], + StackPolicyBody=json.dumps(policy), + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy", obtained_policy) + + # This part makes sure that the policy being set during the last update doesn't affect the requested changes + def _assert_stack_is_updated(): + assert is_stack_updated(stack.stack_name) + + retry(_assert_stack_is_updated, retries=5, sleep=2, sleep_before=1) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_after_update", obtained_policy) + + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="Not implemented") + def test_policy_during_update( + self, deploy_cfn_template, is_stack_updated, snapshot, cleanup_stacks, aws_client + ): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/simple_api.yaml") + ) + stack = deploy_cfn_template( + template=template, + parameters={"ApiName": f"api-{short_uid()}"}, + ) + + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "ApiName", "ParameterValue": f"api-{short_uid()}"}, + ], + StackPolicyDuringUpdateBody=json.dumps(policy), + ) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_during_update", obtained_policy) + + def _assert_update_failed(): + assert get_events_canceled_by_policy(aws_client.cloudformation, stack.stack_name) + + retry(_assert_update_failed, retries=5, sleep=2, sleep_before=1) + + obtained_policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + snapshot.match("policy_after_update", obtained_policy) + + delete_stack_after_process(aws_client.cloudformation, stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="feature not implemented") + def test_prevent_stack_update(self, deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ) + ) + stack = deploy_cfn_template( + template=template, parameters={"TopicName": f"topic-{short_uid()}"} + ) + policy = { + "Statement": [ + {"Effect": "Deny", "Action": "Update:*", "Principal": "*", "Resource": "*"}, + ] + } + aws_client.cloudformation.set_stack_policy( + StackName=stack.stack_name, StackPolicyBody=json.dumps(policy) + ) + + policy = aws_client.cloudformation.get_stack_policy(StackName=stack.stack_name) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"new-topic-{short_uid()}"} + ], + ) + + def _assert_failing_update_state(): + events = aws_client.cloudformation.describe_stack_events(StackName=stack.stack_name)[ + "StackEvents" + ] + failed_event_update = [ + event for event in events if event["ResourceStatus"] == "UPDATE_FAILED" + ] + assert failed_event_update + assert "Action denied by stack policy" in failed_event_update[0]["ResourceStatusReason"] + + try: + retry(_assert_failing_update_state, retries=5, sleep=2, sleep_before=2) + finally: + progress_is_finished = False + while not progress_is_finished: + status = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)[ + "Stacks" + ][0]["StackStatus"] + progress_is_finished = "PROGRESS" not in status + aws_client.cloudformation.delete_stack(StackName=stack.stack_name) + + @markers.aws.validated + @pytest.mark.skip(reason="feature not implemented") + def test_prevent_resource_deletion(self, deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ) + ) + + template = template.replace("DeletionPolicy: Delete", "DeletionPolicy: Retain") + stack = deploy_cfn_template( + template=template, parameters={"TopicName": f"topic-{short_uid()}"} + ) + aws_client.cloudformation.delete_stack(StackName=stack.stack_name) + + aws_client.sns.get_topic_attributes(TopicArn=stack.outputs["TopicArn"]) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.snapshot.json new file mode 100644 index 0000000000000..46160d7841335 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.snapshot.json @@ -0,0 +1,254 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_empty_policy": { + "recorded-date": "10-11-2022, 12:40:34", + "recorded-content": { + "policy": { + "StackPolicyBody": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_invalid_policy": { + "recorded-date": "14-11-2022, 15:13:18", + "recorded-content": { + "error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_policy_lifecycle": { + "recorded-date": "15-11-2022, 16:02:20", + "recorded-content": { + "initial_policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Allow", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_updated": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_deleted": { + "StackPolicyBody": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_url": { + "recorded-date": "11-11-2022, 13:58:17", + "recorded-content": { + "policy": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Allow", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_invalid_policy_with_url": { + "recorded-date": "11-11-2022, 14:07:44", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_both_policy_and_url": { + "recorded-date": "11-11-2022, 14:19:19", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "You cannot specify both StackPolicyURL and StackPolicyBody", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_empty_policy_with_url": { + "recorded-date": "11-11-2022, 14:25:18", + "recorded-content": { + "policy": { + "StackPolicyBody": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_not_json_policy": { + "recorded-date": "21-11-2022, 15:48:27", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_different_principal_attribute": { + "recorded-date": "16-11-2022, 11:01:36", + "recorded-content": { + "error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_different_action_attribute": { + "recorded-date": "21-11-2022, 15:44:16", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Error validating stack policy: Invalid stack policy", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_create_stack_with_policy": { + "recorded-date": "16-11-2022, 15:42:23", + "recorded-content": { + "policy": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Allow", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_update_operation": { + "recorded-date": "17-11-2022, 11:04:31", + "recorded-content": { + "policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_after_update": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_policy_during_update": { + "recorded-date": "17-11-2022, 11:09:28", + "recorded-content": { + "policy_during_update": { + "StackPolicyBody": { + "Statement": [ + { + "Effect": "Deny", + "Action": "Update:*", + "Principal": "*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_after_update": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_stack_update": { + "recorded-date": "28-10-2022, 12:10:42", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_resource_deletion": { + "recorded-date": "28-10-2022, 12:29:11", + "recorded-content": {} + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.validation.json new file mode 100644 index 0000000000000..3b728f9fbb277 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.validation.json @@ -0,0 +1,44 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_create_stack_with_policy": { + "last_validated_date": "2022-11-16T14:42:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_different_action_attribute": { + "last_validated_date": "2022-11-21T14:44:16+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_different_principal_attribute": { + "last_validated_date": "2022-11-16T10:01:36+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_empty_policy": { + "last_validated_date": "2022-11-10T11:40:34+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_not_json_policy": { + "last_validated_date": "2022-11-21T14:48:27+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_policy_during_update": { + "last_validated_date": "2022-11-17T10:09:28+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_policy_lifecycle": { + "last_validated_date": "2022-11-15T15:02:20+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_resource_deletion": { + "last_validated_date": "2022-10-28T10:29:11+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_prevent_stack_update": { + "last_validated_date": "2022-10-28T10:10:42+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_empty_policy_with_url": { + "last_validated_date": "2022-11-11T13:25:18+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_invalid_policy_with_url": { + "last_validated_date": "2022-11-11T13:07:44+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_both_policy_and_url": { + "last_validated_date": "2022-11-11T13:19:19+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_update_operation": { + "last_validated_date": "2022-11-17T10:04:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stack_policies.py::TestStackPolicy::test_set_policy_with_url": { + "last_validated_date": "2022-11-11T12:58:17+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py new file mode 100644 index 0000000000000..a2d262a76e8e0 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py @@ -0,0 +1,1107 @@ +import json +import os +from collections import OrderedDict +from itertools import permutations + +import botocore.exceptions +import pytest +import yaml +from botocore.exceptions import WaiterError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.aws.api.cloudformation import Capability +from localstack.services.cloudformation.engine.entities import StackIdentifier +from localstack.services.cloudformation.engine.yaml_parser import parse_yaml +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry, wait_until + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestStacksApi: + @pytest.mark.skip(reason="CFNV2:Other") + @markers.snapshot.skip_snapshot_verify( + paths=["$..ChangeSetId", "$..EnableTerminationProtection"] + ) + @markers.aws.validated + def test_stack_lifecycle(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("ParameterValue", "parameter-value")) + api_name = f"test_{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/simple_api.yaml" + ) + + deployed = deploy_cfn_template( + template_path=template_path, + parameters={"ApiName": api_name}, + ) + stack_name = deployed.stack_name + creation_description = aws_client.cloudformation.describe_stacks(StackName=stack_name)[ + "Stacks" + ][0] + snapshot.match("creation", creation_description) + + api_name = f"test_{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=deployed.stack_name, + template_path=template_path, + parameters={"ApiName": api_name}, + ) + update_description = aws_client.cloudformation.describe_stacks(StackName=stack_name)[ + "Stacks" + ][0] + snapshot.match("update", update_description) + + aws_client.cloudformation.delete_stack( + StackName=stack_name, + ) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("describe_deleted_by_name_exc", e.value.response) + + deleted = aws_client.cloudformation.describe_stacks(StackName=deployed.stack_id)["Stacks"][ + 0 + ] + assert "DeletionTime" in deleted + snapshot.match("deleted", deleted) + + @pytest.mark.skip(reason="CFNV2:DescribeStacks") + @markers.aws.validated + def test_stack_description_special_chars(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "test .test.net", + "Resources": { + "TestResource": { + "Type": "AWS::EC2::VPC", + "Properties": {"CidrBlock": "100.30.20.0/20"}, + } + }, + } + deployed = deploy_cfn_template(template=json.dumps(template)) + response = aws_client.cloudformation.describe_stacks(StackName=deployed.stack_id)["Stacks"][ + 0 + ] + snapshot.match("describe_stack", response) + + @markers.aws.validated + def test_stack_name_creation(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"*@{short_uid()}_$" + + with pytest.raises(Exception) as e: + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_template.yaml" + ), + stack_name=stack_name, + ) + + snapshot.match("stack_response", e.value.response) + + @pytest.mark.skip(reason="CFNV2:Other") + @markers.aws.validated + @pytest.mark.parametrize("fileformat", ["yaml", "json"]) + def test_get_template_using_create_stack(self, snapshot, fileformat, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"stack-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), + f"../../../../../templates/sns_topic_template.{fileformat}", + ) + ), + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + template_original = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Original" + ) + snapshot.match("template_original", template_original) + + template_processed = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.match("template_processed", template_processed) + + @pytest.mark.skip(reason="CFNV2:Other") + @markers.aws.validated + @pytest.mark.parametrize("fileformat", ["yaml", "json"]) + def test_get_template_using_changesets( + self, deploy_cfn_template, snapshot, fileformat, aws_client + ): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + f"../../../../../templates/sns_topic_template.{fileformat}", + ) + ) + + template_original = aws_client.cloudformation.get_template( + StackName=stack.stack_id, TemplateStage="Original" + ) + snapshot.match("template_original", template_original) + + template_processed = aws_client.cloudformation.get_template( + StackName=stack.stack_id, TemplateStage="Processed" + ) + snapshot.match("template_processed", template_processed) + + @pytest.mark.skip(reason="CFNV2:Other, CFNV2:DescribeStack") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..ParameterValue", "$..PhysicalResourceId", "$..Capabilities"] + ) + def test_stack_update_resources( + self, + deploy_cfn_template, + is_change_set_finished, + is_change_set_created_and_available, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("PhysicalResourceId")) + + api_name = f"test_{short_uid()}" + + # create stack + deployed = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/simple_api.yaml" + ), + parameters={"ApiName": api_name}, + ) + stack_name = deployed.stack_name + stack_id = deployed.stack_id + + # assert snapshot of created stack + snapshot.match( + "stack_created", + aws_client.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][0], + ) + + # update stack, with one additional resource + api_name = f"test_{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=deployed.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/simple_api.update.yaml" + ), + parameters={"ApiName": api_name}, + ) + + # assert snapshot of updated stack + snapshot.match( + "stack_updated", + aws_client.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][0], + ) + + # describe stack resources + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name) + snapshot.match("stack_resources", resources) + + @pytest.mark.skip(reason="CFNV2:Other, CFNV2:DescribeStack") + @markers.aws.needs_fixing + def test_list_stack_resources_for_removed_resource(self, deploy_cfn_template, aws_client): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/eventbridge_policy.yaml" + ) + event_bus_name = f"bus-{short_uid()}" + stack = deploy_cfn_template( + template_path=template_path, + parameters={"EventBusName": event_bus_name}, + ) + + resources = aws_client.cloudformation.list_stack_resources(StackName=stack.stack_name)[ + "StackResourceSummaries" + ] + resources_before = len(resources) + assert resources_before == 3 + statuses = {res["ResourceStatus"] for res in resources} + assert statuses == {"CREATE_COMPLETE"} + + # remove one resource from the template, then update stack (via change set) + template_dict = parse_yaml(load_file(template_path)) + template_dict["Resources"].pop("eventPolicy2") + template2 = yaml.dump(template_dict) + + deploy_cfn_template( + stack_name=stack.stack_name, + is_update=True, + template=template2, + parameters={"EventBusName": event_bus_name}, + ) + + # get list of stack resources, again - make sure that deleted resource is not contained in result + resources = aws_client.cloudformation.list_stack_resources(StackName=stack.stack_name)[ + "StackResourceSummaries" + ] + assert len(resources) == resources_before - 1 + statuses = {res["ResourceStatus"] for res in resources} + assert statuses == {"UPDATE_COMPLETE"} + + @markers.aws.validated + def test_update_stack_with_same_template_withoutchange( + self, deploy_cfn_template, aws_client, snapshot + ): + template = load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/simple_no_change.yaml" + ) + ) + stack = deploy_cfn_template(template=template) + + with pytest.raises(Exception) as ctx: # TODO: capture proper exception + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, TemplateBody=template + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack.stack_name + ) + + snapshot.match("no_change_exception", ctx.value.response) + + @pytest.mark.skip(reason="CFNV2:Validation") + @markers.aws.validated + def test_update_stack_with_same_template_withoutchange_transformation( + self, deploy_cfn_template, aws_client + ): + template = load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/simple_no_change_with_transformation.yaml", + ) + ) + stack = deploy_cfn_template(template=template) + + # transformations will always work even if there's no change in the template! + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack.stack_name + ) + + @markers.aws.validated + def test_update_stack_actual_update(self, deploy_cfn_template, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sqs_queue_update.yml") + ) + queue_name = f"test-queue-{short_uid()}" + stack = deploy_cfn_template( + template=template, parameters={"QueueName": queue_name}, max_wait=360 + ) + + queue_arn_1 = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueUrl"], AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + assert queue_arn_1 + + stack2 = deploy_cfn_template( + template=template, + stack_name=stack.stack_name, + parameters={"QueueName": f"{queue_name}-new"}, + is_update=True, + max_wait=360, + ) + + queue_arn_2 = aws_client.sqs.get_queue_attributes( + QueueUrl=stack2.outputs["QueueUrl"], AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + assert queue_arn_2 + + assert queue_arn_1 != queue_arn_2 + + @markers.snapshot.skip_snapshot_verify(paths=["$..StackEvents"]) + @markers.aws.validated + def test_list_events_after_deployment(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(SortingTransformer("StackEvents", lambda x: x["Timestamp"])) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ) + ) + response = aws_client.cloudformation.describe_stack_events(StackName=stack.stack_name) + snapshot.match("events", response) + + @markers.aws.validated + @pytest.mark.skip(reason="disable rollback not supported") + @pytest.mark.parametrize("rollback_disabled, length_expected", [(False, 0), (True, 1)]) + def test_failure_options_for_stack_creation( + self, rollback_disabled, length_expected, aws_client + ): + template_with_error = open( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/multiple_bucket.yaml" + ), + "r", + ).read() + + stack_name = f"stack-{short_uid()}" + bucket_1_name = f"bucket-{short_uid()}" + bucket_2_name = f"bucket!#${short_uid()}" + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template_with_error, + DisableRollback=rollback_disabled, + Parameters=[ + {"ParameterKey": "BucketName1", "ParameterValue": bucket_1_name}, + {"ParameterKey": "BucketName2", "ParameterValue": bucket_2_name}, + ], + ) + + assert wait_until( + lambda _: stack_process_is_finished(aws_client.cloudformation, stack_name), + wait=10, + strategy="exponential", + ) + + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name)[ + "StackResources" + ] + created_resources = [ + resource for resource in resources if "CREATE_COMPLETE" in resource["ResourceStatus"] + ] + assert len(created_resources) == length_expected + + aws_client.cloudformation.delete_stack(StackName=stack_name) + + @markers.aws.validated + @pytest.mark.skipif(reason="disable rollback not enabled", condition=not is_aws_cloud()) + @pytest.mark.parametrize("rollback_disabled, length_expected", [(False, 2), (True, 1)]) + def test_failure_options_for_stack_update( + self, rollback_disabled, length_expected, aws_client, cleanups + ): + stack_name = f"stack-{short_uid()}" + template = open( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/multiple_bucket_update.yaml" + ), + "r", + ).read() + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template, + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + def _assert_stack_process_finished(): + return stack_process_is_finished(aws_client.cloudformation, stack_name) + + assert wait_until(_assert_stack_process_finished) + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name)[ + "StackResources" + ] + created_resources = [ + resource for resource in resources if "CREATE_COMPLETE" in resource["ResourceStatus"] + ] + assert len(created_resources) == 2 + + aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template, + DisableRollback=rollback_disabled, + Parameters=[ + {"ParameterKey": "Days", "ParameterValue": "-1"}, + ], + ) + + assert wait_until(_assert_stack_process_finished) + + resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name)[ + "StackResources" + ] + updated_resources = [ + resource + for resource in resources + if resource["ResourceStatus"] in ["CREATE_COMPLETE", "UPDATE_COMPLETE"] + ] + assert len(updated_resources) == length_expected + + @pytest.mark.skip(reason="CFNV2:Other") + @markers.aws.only_localstack + def test_create_stack_with_custom_id( + self, aws_client, cleanups, account_id, region_name, set_resource_custom_id + ): + stack_name = f"stack-{short_uid()}" + custom_id = short_uid() + + set_resource_custom_id( + StackIdentifier(account_id, region_name, stack_name), custom_id=custom_id + ) + template = open( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ), + "r", + ).read() + + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template, + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + assert stack["StackId"].split("/")[-1] == custom_id + + # We need to wait until the stack is created otherwise we can end up in a scenario + # where we try to delete the stack before creating its resources, failing the test + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + +def stack_process_is_finished(cfn_client, stack_name): + return ( + "PROGRESS" + not in cfn_client.describe_stacks(StackName=stack_name)["Stacks"][0]["StackStatus"] + ) + + +@markers.aws.validated +@pytest.mark.skip(reason="Not Implemented") +def test_linting_error_during_creation(snapshot, aws_client): + stack_name = f"stack-{short_uid()}" + bad_template = {"Resources": "", "Outputs": ""} + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=json.dumps(bad_template) + ) + + error_response = ex.value.response + snapshot.match("error", error_response) + + +@markers.aws.validated +@pytest.mark.skip(reason="feature not implemented") +def test_notifications( + deploy_cfn_template, + sns_create_topic, + is_stack_created, + is_stack_updated, + sqs_create_queue, + sns_create_sqs_subscription, + cleanup_stacks, + aws_client, +): + stack_name = f"stack-{short_uid()}" + topic_arn = sns_create_topic()["TopicArn"] + sqs_url = sqs_create_queue() + sns_create_sqs_subscription(topic_arn, sqs_url) + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + aws_client.cloudformation.create_stack( + StackName=stack_name, + NotificationARNs=[topic_arn], + TemplateBody=template, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + cleanup_stacks([stack_name]) + + assert wait_until(is_stack_created(stack_name)) + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}, + ], + ) + assert wait_until(is_stack_updated(stack_name)) + + messages = {} + + def _assert_messages(): + sqs_messages = aws_client.sqs.receive_message(QueueUrl=sqs_url)["Messages"] + for sqs_message in sqs_messages: + sns_message = json.loads(sqs_message["Body"]) + messages.update({sns_message["MessageId"]: sns_message}) + + # Assert notifications of resources created + assert [message for message in messages.values() if "CREATE_" in message["Message"]] + + # Assert notifications of resources deleted + assert [message for message in messages.values() if "UPDATE_" in message["Message"]] + + # Assert notifications of resources deleted + assert [message for message in messages.values() if "DELETE_" in message["Message"]] + + retry(_assert_messages, retries=10, sleep=2) + + +@pytest.mark.skip(reason="CFNV2:Other, CFNV2:Describe") +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # parameters may be out of order + "$..Stacks..Parameters", + ] +) +def test_updating_an_updated_stack_sets_status(deploy_cfn_template, snapshot, aws_client): + """ + The status of a stack that has been updated twice should be "UPDATE_COMPLETE" + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + # need multiple templates to support updates to the stack + template_1 = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/stack_update_1.yaml") + ) + template_2 = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/stack_update_2.yaml") + ) + template_3 = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/stack_update_3.yaml") + ) + + topic_1_name = f"topic-1-{short_uid()}" + topic_2_name = f"topic-2-{short_uid()}" + topic_3_name = f"topic-3-{short_uid()}" + snapshot.add_transformers_list( + [ + snapshot.transform.regex(topic_1_name, "topic-1"), + snapshot.transform.regex(topic_2_name, "topic-2"), + snapshot.transform.regex(topic_3_name, "topic-3"), + ] + ) + + parameters = { + "Topic1Name": topic_1_name, + "Topic2Name": topic_2_name, + "Topic3Name": topic_3_name, + } + + def wait_for(waiter_type: str) -> None: + aws_client.cloudformation.get_waiter(waiter_type).wait( + StackName=stack.stack_name, + WaiterConfig={ + "Delay": 5, + "MaxAttempts": 5, + }, + ) + + stack = deploy_cfn_template(template=template_1, parameters=parameters) + wait_for("stack_create_complete") + + # update the stack + deploy_cfn_template( + template=template_2, + is_update=True, + stack_name=stack.stack_name, + parameters=parameters, + ) + wait_for("stack_update_complete") + + # update the stack again + deploy_cfn_template( + template=template_3, + is_update=True, + stack_name=stack.stack_name, + parameters=parameters, + ) + wait_for("stack_update_complete") + + res = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + snapshot.match("describe-result", res) + + +@pytest.mark.skip(reason="CFNV2:Other, CFNV2:Describe") +@markers.aws.validated +def test_update_termination_protection(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("ParameterValue", "parameter-value")) + + # create stack + api_name = f"test_{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/simple_api.yaml" + ) + stack = deploy_cfn_template(template_path=template_path, parameters={"ApiName": api_name}) + + # update termination protection (true) + aws_client.cloudformation.update_termination_protection( + EnableTerminationProtection=True, StackName=stack.stack_name + ) + res = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + snapshot.match("describe-stack-1", res) + + # update termination protection (false) + aws_client.cloudformation.update_termination_protection( + EnableTerminationProtection=False, StackName=stack.stack_name + ) + res = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + snapshot.match("describe-stack-2", res) + + +@pytest.mark.skip(reason="CFNV2:Other, CFNV2:Describe") +@markers.aws.validated +def test_events_resource_types(deploy_cfn_template, snapshot, aws_client): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_cdk_sample_app.yaml" + ) + stack = deploy_cfn_template(template_path=template_path, max_wait=500) + events = aws_client.cloudformation.describe_stack_events(StackName=stack.stack_name)[ + "StackEvents" + ] + + resource_types = list({event["ResourceType"] for event in events}) + resource_types.sort() + snapshot.match("resource_types", resource_types) + + +@pytest.mark.skip(reason="CFNV2:Deletion") +@markers.aws.validated +def test_list_parameter_type(aws_client, deploy_cfn_template, cleanups): + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_parameter_list_type.yaml" + ), + parameters={ + "ParamsList": "foo,bar", + }, + ) + + assert stack.outputs["ParamValue"] == "foo|bar" + + +@markers.aws.validated +@pytest.mark.skipif(condition=not is_aws_cloud(), reason="rollback not implemented") +def test_blocked_stack_deletion(aws_client, cleanups, snapshot): + """ + uses AWS::IAM::Policy for demonstrating this behavior + + 1. create fails + 2. rollback fails even though create didn't even provision anything + 3. trying to delete the stack afterwards also doesn't work + 4. deleting the stack with retain resources works + """ + cfn = aws_client.cloudformation + stack_name = f"test-stacks-blocked-{short_uid()}" + policy_name = f"test-broken-policy-{short_uid()}" + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.regex(policy_name, "")) + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/iam_policy_invalid.yaml") + ) + waiter_config = {"Delay": 1, "MaxAttempts": 20} + + snapshot.add_transformer(snapshot.transform.key_value("PhysicalResourceId")) + snapshot.add_transformer( + snapshot.transform.key_value("ResourceStatusReason", reference_replacement=False) + ) + + stack = cfn.create_stack( + StackName=stack_name, + TemplateBody=template_body, + Parameters=[{"ParameterKey": "Name", "ParameterValue": policy_name}], + Capabilities=[Capability.CAPABILITY_NAMED_IAM], + ) + stack_id = stack["StackId"] + cleanups.append(lambda: cfn.delete_stack(StackName=stack_id, RetainResources=["BrokenPolicy"])) + with pytest.raises(WaiterError): + cfn.get_waiter("stack_create_complete").wait(StackName=stack_id, WaiterConfig=waiter_config) + stack_post_create = cfn.describe_stacks(StackName=stack_id) + snapshot.match("stack_post_create", stack_post_create) + + cfn.delete_stack(StackName=stack_id) + with pytest.raises(WaiterError): + cfn.get_waiter("stack_delete_complete").wait(StackName=stack_id, WaiterConfig=waiter_config) + stack_post_fail_delete = cfn.describe_stacks(StackName=stack_id) + snapshot.match("stack_post_fail_delete", stack_post_fail_delete) + + cfn.delete_stack(StackName=stack_id, RetainResources=["BrokenPolicy"]) + cfn.get_waiter("stack_delete_complete").wait(StackName=stack_id, WaiterConfig=waiter_config) + stack_post_success_delete = cfn.describe_stacks(StackName=stack_id) + snapshot.match("stack_post_success_delete", stack_post_success_delete) + stack_events = cfn.describe_stack_events(StackName=stack_id) + snapshot.match("stack_events", stack_events) + + +MINIMAL_TEMPLATE = """ +Resources: + SimpleParam: + Type: AWS::SSM::Parameter + Properties: + Value: test + Type: String +""" + + +@pytest.mark.skip(reason="CFNV2:Validation") +@markers.snapshot.skip_snapshot_verify( + paths=["$..EnableTerminationProtection", "$..LastUpdatedTime"] +) +@markers.aws.validated +def test_name_conflicts(aws_client, snapshot, cleanups): + """ + Tests behavior of creating a stack with the same name of one that was previously deleted + + 1. Create Stack + 2. Delete Stack + 3. Create Stack with same name as in 1. + + Step 3 should be successful because you can re-use StackNames, + but only one stack for a given stack name can be `ACTIVE` at one time. + + We didn't exhaustively test yet what is considered as Active by CloudFormation + For now the assumption is that anything != "DELETE_COMPLETED" is considered "ACTIVE" + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + stack_name = f"repeated-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE + ) + stack_id = stack["StackId"] + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # only one can be active at a time + with pytest.raises(aws_client.cloudformation.exceptions.AlreadyExistsException) as e: + aws_client.cloudformation.create_stack(StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE) + snapshot.match("create_stack_already_exists_exc", e.value.response) + + created_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_name)["Stacks"][ + 0 + ]["StackStatus"] + snapshot.match("created_stack_desc", created_stack_desc) + + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + # describe with name fails + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("deleted_stack_not_found_exc", e.value.response) + + # describe events with name fails + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stack_events(StackName=stack_name) + snapshot.match("deleted_stack_events_not_found_by_name", e.value.response) + + # describe with stack id (ARN) succeeds + deleted_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("deleted_stack_desc", deleted_stack_desc) + + # creating a new stack with the same name as the previously deleted one should work + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, TemplateBody=MINIMAL_TEMPLATE + ) + # should issue a new unique stack ID/ARN + new_stack_id = stack["StackId"] + assert stack_id != new_stack_id + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + new_stack_desc = aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("new_stack_desc", new_stack_desc) + assert len(new_stack_desc["Stacks"]) == 1 + assert new_stack_desc["Stacks"][0]["StackId"] == new_stack_id + + # can still access both by using the ARN (stack id) + # and they should be different from each other + stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=stack_id) + new_stack_id_desc = aws_client.cloudformation.describe_stacks(StackName=new_stack_id) + snapshot.match("stack_id_desc", stack_id_desc) + snapshot.match("new_stack_id_desc", new_stack_id_desc) + + # check if the describing the stack events return the right stack + stack_events = aws_client.cloudformation.describe_stack_events(StackName=stack_name)[ + "StackEvents" + ] + assert all(stack_event["StackId"] == new_stack_id for stack_event in stack_events) + # describing events by the old stack id should still yield the old events + stack_events = aws_client.cloudformation.describe_stack_events(StackName=stack_id)[ + "StackEvents" + ] + assert all(stack_event["StackId"] == stack_id for stack_event in stack_events) + + # deleting the stack by name should delete the new, not already deleted stack + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + # describe with stack id returns stack deleted + deleted_stack_desc = aws_client.cloudformation.describe_stacks(StackName=new_stack_id) + snapshot.match("deleted_second_stack_desc", deleted_stack_desc) + + +@pytest.mark.skip(reason="CFNV2:Validation") +@markers.aws.validated +def test_describe_stack_events_errors(aws_client, snapshot): + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stack_events() + snapshot.match("describe_stack_events_no_stack_name", e.value.response) + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.describe_stack_events(StackName="does-not-exist") + snapshot.match("describe_stack_events_stack_not_found", e.value.response) + + +TEMPLATE_ORDER_CASES = list(permutations(["A", "B", "C"])) + + +@pytest.mark.skip(reason="CFNV2:Other stack events") +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..StackId", + # TODO + "$..PhysicalResourceId", + # TODO + "$..ResourceProperties", + ] +) +@pytest.mark.parametrize( + "deploy_order", TEMPLATE_ORDER_CASES, ids=["-".join(vals) for vals in TEMPLATE_ORDER_CASES] +) +def test_stack_deploy_order(deploy_cfn_template, aws_client, snapshot, deploy_order: tuple[str]): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("EventId")) + resources = { + "A": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": "root", + }, + }, + "B": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "A", + }, + }, + }, + "C": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "B", + }, + }, + }, + } + + resources = OrderedDict( + [ + (logical_resource_id, resources[logical_resource_id]) + for logical_resource_id in deploy_order + ] + ) + assert len(resources) == 3 + + stack = deploy_cfn_template( + template=json.dumps( + { + "Resources": resources, + } + ) + ) + + stack.destroy() + + events = aws_client.cloudformation.describe_stack_events( + StackName=stack.stack_id, + )["StackEvents"] + + filtered_events = [] + for event in events: + # only the resources we care about + if event["LogicalResourceId"] not in deploy_order: + continue + + # only _COMPLETE events + if not event["ResourceStatus"].endswith("_COMPLETE"): + continue + + filtered_events.append(event) + + # sort by event time + filtered_events.sort(key=lambda e: e["Timestamp"]) + + snapshot.match("events", filtered_events) + + +@pytest.mark.skip(reason="CFNV2:DescribeStack") +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: this property is present in the response from LocalStack when + # there is an active changeset, however it is not present on AWS + # because the change set has not been executed. + "$..Stacks..ChangeSetId", + # FIXME: tackle this when fixing API parity of CloudFormation + "$..Capabilities", + "$..IncludeNestedStacks", + "$..LastUpdatedTime", + "$..NotificationARNs", + "$..ResourceChange", + "$..StackResourceDetail.Metadata", + ] +) +@markers.aws.validated +def test_no_echo_parameter(snapshot, aws_client, deploy_cfn_template): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(SortingTransformer("Parameters", lambda x: x.get("ParameterKey", ""))) + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_no_echo.yml" + ) + template = open(template_path, "r").read() + + deployment = deploy_cfn_template( + template=template, + parameters={"SecretParameter": "SecretValue"}, + ) + stack_id = deployment.stack_id + stack_name = deployment.stack_name + + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_stacks", describe_stacks) + + # Check Resource Metadata. + describe_stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=stack_id + ) + for resource in describe_stack_resources["StackResources"]: + resource_logical_id = resource["LogicalResourceId"] + + # Get detailed information about the resource + describe_stack_resource_details = aws_client.cloudformation.describe_stack_resource( + StackName=stack_name, LogicalResourceId=resource_logical_id + ) + snapshot.match( + f"describe_stack_resource_details_{resource_logical_id}", + describe_stack_resource_details, + ) + + # Update stack via update_stack (and change the value of SecretParameter) + aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue1"}, + ], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack_name) + update_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks", update_stacks) + + # Update stack via create_change_set (and change the value of SecretParameter) + change_set_name = f"UpdateSecretParameterValue-{short_uid()}" + aws_client.cloudformation.create_change_set( + StackName=stack_name, + TemplateBody=template, + ChangeSetName=change_set_name, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue2"}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, + ChangeSetName=change_set_name, + ) + change_sets = aws_client.cloudformation.describe_change_set( + StackName=stack_id, + ChangeSetName=change_set_name, + ) + snapshot.match("describe_updated_change_set", change_sets) + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks_change_set", describe_stacks) + + # Change `NoEcho` of a parameter from true to false and update stack via create_change_set. + change_set_name = f"UpdateSecretParameterNoEchoToFalse-{short_uid()}" + template_dict = parse_yaml(load_file(template_path)) + template_dict["Parameters"]["SecretParameter"]["NoEcho"] = False + template_no_echo_false = yaml.dump(template_dict) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + TemplateBody=template_no_echo_false, + ChangeSetName=change_set_name, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue2"}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, + ChangeSetName=change_set_name, + ) + change_sets = aws_client.cloudformation.describe_change_set( + StackName=stack_id, + ChangeSetName=change_set_name, + ) + snapshot.match("describe_updated_change_set_no_echo_true", change_sets) + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks_no_echo_true", describe_stacks) + + # Change `NoEcho` of a parameter back from false to true and update stack via create_change_set. + change_set_name = f"UpdateSecretParameterNoEchoToTrue-{short_uid()}" + aws_client.cloudformation.create_change_set( + StackName=stack_name, + TemplateBody=template, + ChangeSetName=change_set_name, + Parameters=[ + {"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue2"}, + ], + ) + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, + ChangeSetName=change_set_name, + ) + change_sets = aws_client.cloudformation.describe_change_set( + StackName=stack_id, + ChangeSetName=change_set_name, + ) + snapshot.match("describe_updated_change_set_no_echo_false", change_sets) + describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id) + snapshot.match("describe_updated_stacks_no_echo_false", describe_stacks) + + +@pytest.mark.skip(reason="CFNV2:Validation") +@markers.aws.validated +def test_stack_resource_not_found(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_simple.yaml" + ), + parameters={"TopicName": f"topic{short_uid()}"}, + ) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="NonExistentResource" + ) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + snapshot.match("Error", ex.value.response) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.snapshot.json new file mode 100644 index 0000000000000..979af0c8a9573 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.snapshot.json @@ -0,0 +1,2290 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_description_special_chars": { + "recorded-date": "05-08-2022, 13:03:43", + "recorded-content": { + "describe_stack": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "Description": "test .test.net", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_update_resources": { + "recorded-date": "30-08-2022, 00:13:26", + "recorded-content": { + "stack_created": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "test_12395eb4" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "stack_updated": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "test_5a3df175" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "stack_resources": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + }, + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Api", + "PhysicalResourceId": "", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::ApiGateway::RestApi", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "-bucket-10xf2vf1pqap8", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_list_events_after_deployment": { + "recorded-date": "05-10-2022, 13:33:55", + "recorded-content": { + "events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "topic123-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "topic123-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "TopicName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "topic123-CREATE_COMPLETE-date", + "LogicalResourceId": "topic123", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "TopicName": "" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_lifecycle": { + "recorded-date": "28-11-2023, 13:24:40", + "recorded-content": { + "creation": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "update": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "describe_deleted_by_name_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Stack with id does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleted": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_linting_error_during_creation": { + "recorded-date": "11-11-2022, 08:10:14", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Any Resources member must be an object.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_updating_an_updated_stack_sets_status": { + "recorded-date": "02-12-2022, 11:19:41", + "recorded-content": { + "describe-result": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Topic2Name", + "ParameterValue": "topic-2" + }, + { + "ParameterKey": "Topic1Name", + "ParameterValue": "topic-1" + }, + { + "ParameterKey": "Topic3Name", + "ParameterValue": "topic-3" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_update_termination_protection": { + "recorded-date": "04-01-2023, 16:23:22", + "recorded-content": { + "describe-stack-1": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": true, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-stack-2": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ApiName", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_events_resource_types": { + "recorded-date": "15-02-2023, 10:46:53", + "recorded-content": { + "resource_types": [ + "AWS::CloudFormation::Stack", + "AWS::SNS::Subscription", + "AWS::SNS::Topic", + "AWS::SQS::Queue", + "AWS::SQS::QueuePolicy" + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_name_creation": { + "recorded-date": "19-04-2023, 12:44:47", + "recorded-content": { + "stack_response": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value '*@da591fa3_$' at 'stackName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*|arn:[-a-zA-Z0-9:/._+]*", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_blocked_stack_deletion": { + "recorded-date": "06-09-2023, 11:01:18", + "recorded-content": { + "stack_post_create": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Name", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "ROLLBACK_FAILED", + "StackStatusReason": "The following resource(s) failed to delete: [BrokenPolicy]. ", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_post_fail_delete": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Name", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_FAILED", + "StackStatusReason": "The following resource(s) failed to delete: [BrokenPolicy]. ", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_post_success_delete": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Name", + "ParameterValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_SKIPPED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_SKIPPED", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_FAILED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_FAILED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-DELETE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-CREATE_FAILED-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "CREATE_FAILED", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "BrokenPolicy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "BrokenPolicy", + "PhysicalResourceId": "", + "ResourceProperties": { + "PolicyName": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "resource-status-reason", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_name_conflicts": { + "recorded-date": "26-03-2024, 17:59:43", + "recorded-content": { + "create_stack_already_exists_exc": { + "Error": { + "Code": "AlreadyExistsException", + "Message": "Stack [] already exists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "created_stack_desc": "CREATE_COMPLETE", + "deleted_stack_not_found_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Stack with id does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleted_stack_events_not_found_by_name": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [] does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deleted_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new_stack_id_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deleted_second_stack_desc": { + "Stacks": [ + { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_describe_stack_events_errors": { + "recorded-date": "26-03-2024, 17:54:41", + "recorded-content": { + "describe_stack_events_no_stack_name": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value null at 'stackName' failed to satisfy constraint: Member must not be null", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe_stack_events_stack_not_found": { + "Error": { + "Code": "ValidationError", + "Message": "Stack [does-not-exist] does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange": { + "recorded-date": "07-05-2024, 08:34:18", + "recorded-content": { + "no_change_exception": { + "Error": { + "Code": "ValidationError", + "Message": "No updates are to be performed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[A-B-C]": { + "recorded-date": "29-05-2024, 11:44:14", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xvqPt7CmcHKX", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-FCaKHvMgdicm", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xvqPt7CmcHKX" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-Xr56esN3SasR", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-FCaKHvMgdicm" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-Xr56esN3SasR", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-FCaKHvMgdicm" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-FCaKHvMgdicm", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xvqPt7CmcHKX" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xvqPt7CmcHKX", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[A-C-B]": { + "recorded-date": "29-05-2024, 11:44:32", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-4tNP69dd8iSL", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-d81WSIsD2X3i", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-4tNP69dd8iSL" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-kStA2w3izJOh", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-d81WSIsD2X3i" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-kStA2w3izJOh", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-d81WSIsD2X3i" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-d81WSIsD2X3i", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-4tNP69dd8iSL" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-4tNP69dd8iSL", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[B-A-C]": { + "recorded-date": "29-05-2024, 11:44:51", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-a0yQkOAYKMk5", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-RvqPXWdIGzrt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-a0yQkOAYKMk5" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-iPNi3cV9jXAt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-RvqPXWdIGzrt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-iPNi3cV9jXAt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-RvqPXWdIGzrt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-RvqPXWdIGzrt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-a0yQkOAYKMk5" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-a0yQkOAYKMk5", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[B-C-A]": { + "recorded-date": "29-05-2024, 11:45:12", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xNtQNbQrdc1T", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-UY120OHcpDMZ", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xNtQNbQrdc1T" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-GOhk98pWaTFw", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-UY120OHcpDMZ" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-GOhk98pWaTFw", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-UY120OHcpDMZ" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-UY120OHcpDMZ", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-xNtQNbQrdc1T" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-xNtQNbQrdc1T", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[C-A-B]": { + "recorded-date": "29-05-2024, 11:45:31", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-BFvOY1qz1Osv", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-qCiX6NdW4hEt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-BFvOY1qz1Osv" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-ki0TLXKJfPgN", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-qCiX6NdW4hEt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-ki0TLXKJfPgN", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-qCiX6NdW4hEt" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-qCiX6NdW4hEt", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-BFvOY1qz1Osv" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-BFvOY1qz1Osv", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[C-B-A]": { + "recorded-date": "29-05-2024, 11:45:50", + "recorded-content": { + "events": [ + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-LQadBXOC2eGc", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-p6Hy6dxQCfjl", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-LQadBXOC2eGc" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-YYmzIb8agve7", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-p6Hy6dxQCfjl" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "C", + "PhysicalResourceId": "CFN-C-YYmzIb8agve7", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-B-p6Hy6dxQCfjl" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "B", + "PhysicalResourceId": "CFN-B-p6Hy6dxQCfjl", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "CFN-A-LQadBXOC2eGc" + } + }, + { + "StackId": "arn::cloudformation::111111111111:stack//", + "EventId": "", + "StackName": "", + "LogicalResourceId": "A", + "PhysicalResourceId": "CFN-A-LQadBXOC2eGc", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceProperties": { + "Type": "String", + "Value": "root" + } + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_no_echo_parameter": { + "recorded-date": "19-12-2024, 11:35:19", + "recorded-content": { + "describe_stacks": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "SecretValue" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_resource_details_LocalBucket": { + "StackResourceDetail": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "LocalBucket", + "Metadata": { + "SensitiveData": "SecretValue" + }, + "PhysicalResourceId": "cfn-noecho-bucket", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_change_set": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "LocalBucket", + "PhysicalResourceId": "cfn-noecho-bucket", + "Replacement": "False", + "ResourceType": "AWS::S3::Bucket", + "Scope": [ + "Metadata", + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks_change_set": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_change_set_no_echo_true": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "LocalBucket", + "PhysicalResourceId": "cfn-noecho-bucket", + "Replacement": "False", + "ResourceType": "AWS::S3::Bucket", + "Scope": [ + "Metadata", + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "NewSecretValue2" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks_no_echo_true": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_change_set_no_echo_false": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "SecretParameter", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Metadata", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "Tags", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "LocalBucket", + "PhysicalResourceId": "cfn-noecho-bucket", + "Replacement": "False", + "ResourceType": "AWS::S3::Bucket", + "Scope": [ + "Metadata", + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_updated_stacks_no_echo_false": { + "Stacks": [ + { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "Description": "Secret value from parameter", + "OutputKey": "SecretValue", + "OutputValue": "NewSecretValue1" + } + ], + "Parameters": [ + { + "ParameterKey": "NormalParameter", + "ParameterValue": "Some default value here" + }, + { + "ParameterKey": "SecretParameter", + "ParameterValue": "****" + }, + { + "ParameterKey": "SecretParameterWithDefault", + "ParameterValue": "****" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[yaml]": { + "recorded-date": "02-01-2025, 19:08:41", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[json]": { + "recorded-date": "02-01-2025, 19:09:40", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[yaml]": { + "recorded-date": "02-01-2025, 19:11:14", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Resources:\n topic69831491:\n Type: AWS::SNS::Topic\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - topic69831491\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[json]": { + "recorded-date": "02-01-2025, 19:11:20", + "recorded-content": { + "template_original": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "template_processed": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + }, + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_resource_not_found": { + "recorded-date": "29-01-2025, 09:08:15", + "recorded-content": { + "Error": { + "Error": { + "Code": "ValidationError", + "Message": "Resource NonExistentResource does not exist for stack ", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.validation.json new file mode 100644 index 0000000000000..005063a3a34ee --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.validation.json @@ -0,0 +1,131 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[False-2]": { + "last_validated_date": "2024-06-25T17:21:51+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_failure_options_for_stack_update[True-1]": { + "last_validated_date": "2024-06-25T17:22:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template[json]": { + "last_validated_date": "2022-08-11T08:55:35+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template[yaml]": { + "last_validated_date": "2022-08-11T08:55:10+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[json]": { + "last_validated_date": "2025-01-02T19:09:40+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_changesets[yaml]": { + "last_validated_date": "2025-01-02T19:08:41+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[json]": { + "last_validated_date": "2025-01-02T19:11:20+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_get_template_using_create_stack[yaml]": { + "last_validated_date": "2025-01-02T19:11:14+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_list_events_after_deployment": { + "last_validated_date": "2022-10-05T11:33:55+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_description_special_chars": { + "last_validated_date": "2022-08-05T11:03:43+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_lifecycle": { + "last_validated_date": "2023-11-28T12:24:40+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_name_creation": { + "last_validated_date": "2023-04-19T10:44:47+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_stack_update_resources": { + "last_validated_date": "2022-08-29T22:13:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange": { + "last_validated_date": "2024-05-07T08:35:29+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::TestStacksApi::test_update_stack_with_same_template_withoutchange_transformation": { + "last_validated_date": "2024-05-07T09:26:39+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_blocked_stack_deletion": { + "last_validated_date": "2023-09-06T09:01:18+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_describe_stack_events_errors": { + "last_validated_date": "2024-03-26T17:54:41+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_events_resource_types": { + "last_validated_date": "2023-02-15T09:46:53+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_linting_error_during_creation": { + "last_validated_date": "2022-11-11T07:10:14+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_name_conflicts": { + "last_validated_date": "2024-03-26T17:59:43+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_no_echo_parameter": { + "last_validated_date": "2024-12-19T11:35:15+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2": { + "last_validated_date": "2024-05-21T09:48:14+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[A-B-C]": { + "last_validated_date": "2024-05-21T10:00:44+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[A-C-B]": { + "last_validated_date": "2024-05-21T10:01:07+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[B-A-C]": { + "last_validated_date": "2024-05-21T10:01:29+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[B-C-A]": { + "last_validated_date": "2024-05-21T10:01:50+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[C-A-B]": { + "last_validated_date": "2024-05-21T10:02:11+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[C-B-A]": { + "last_validated_date": "2024-05-21T10:02:33+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[deploy_order0]": { + "last_validated_date": "2024-05-21T09:49:59+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[deploy_order1]": { + "last_validated_date": "2024-05-21T09:50:22+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[deploy_order2]": { + "last_validated_date": "2024-05-21T09:50:44+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[deploy_order3]": { + "last_validated_date": "2024-05-21T09:51:07+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[deploy_order4]": { + "last_validated_date": "2024-05-21T09:51:28+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order2[deploy_order5]": { + "last_validated_date": "2024-05-21T09:51:51+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[A-B-C]": { + "last_validated_date": "2024-05-29T11:44:14+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[A-C-B]": { + "last_validated_date": "2024-05-29T11:44:32+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[B-A-C]": { + "last_validated_date": "2024-05-29T11:44:51+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[B-C-A]": { + "last_validated_date": "2024-05-29T11:45:12+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[C-A-B]": { + "last_validated_date": "2024-05-29T11:45:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_deploy_order[C-B-A]": { + "last_validated_date": "2024-05-29T11:45:50+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_stack_resource_not_found": { + "last_validated_date": "2025-01-29T09:08:15+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_update_termination_protection": { + "last_validated_date": "2023-01-04T15:23:22+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_stacks.py::test_updating_an_updated_stack_sets_status": { + "last_validated_date": "2022-12-02T10:19:41+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py new file mode 100644 index 0000000000000..fbfe9d191a009 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py @@ -0,0 +1,124 @@ +import contextlib +import os +import textwrap + +import pytest +from botocore.exceptions import ClientError + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import load_file +from localstack.utils.strings import short_uid, to_bytes + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..ResourceIdentifierSummaries..ResourceIdentifiers", "$..Parameters"] +) +def test_get_template_summary(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + + deployment = deploy_cfn_template( + template_path=os.path.join( + # This template has no parameters, and so shows the issue + os.path.dirname(__file__), + "../../../../../templates/sns_topic_simple.yaml", + ) + ) + + res = aws_client.cloudformation.get_template_summary(StackName=deployment.stack_name) + + snapshot.match("template-summary", res) + + +@markers.aws.validated +@pytest.mark.parametrize("url_style", ["s3_url", "http_path", "http_host", "http_invalid"]) +def test_create_stack_from_s3_template_url( + url_style, snapshot, s3_create_bucket, aws_client, cleanups +): + topic_name = f"topic-{short_uid()}" + bucket_name = s3_create_bucket() + snapshot.add_transformer(snapshot.transform.regex(topic_name, "")) + snapshot.add_transformer(snapshot.transform.regex(bucket_name, "")) + + stack_name = f"s-{short_uid()}" + template = textwrap.dedent( + """ + AWSTemplateFormatVersion: '2010-09-09' + Parameters: + TopicName: + Type: String + Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + """ + ) + + aws_client.s3.put_object(Bucket=bucket_name, Key="test/template.yml", Body=to_bytes(template)) + + match url_style: + case "s3_url": + template_url = f"s3://{bucket_name}/test/template.yml" + case "http_path": + template_url = f"https://s3.amazonaws.com/{bucket_name}/test/template.yml" + case "http_host": + template_url = f"https://{bucket_name}.s3.amazonaws.com/test/template.yml" + case "http_invalid": + # note: using an invalid (non-existing) URL here, but in fact all non-S3 HTTP URLs are invalid in real AWS + template_url = "https://example.com/dummy.yml" + case _: + raise Exception(f"Unexpected `url_style` parameter: {url_style}") + + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + # deploy stack + error_expected = url_style in ["s3_url", "http_invalid"] + context_manager = pytest.raises(ClientError) if error_expected else contextlib.nullcontext() + with context_manager as ctx: + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateURL=template_url, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": topic_name}], + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + # assert that either error was raised, or topic has been created + if error_expected: + snapshot.match("create-error", ctx.value.response) + else: + results = list(aws_client.sns.get_paginator("list_topics").paginate()) + matching = [ + t for res in results for t in res["Topics"] if t["TopicArn"].endswith(topic_name) + ] + snapshot.match("matching-topic", matching) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Parameters..DefaultValue"]) +def test_validate_template(aws_client, snapshot): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/valid_template.json") + ) + + resp = aws_client.cloudformation.validate_template(TemplateBody=template) + snapshot.match("validate-template", resp) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Error..Message"]) +def test_validate_invalid_json_template_should_fail(aws_client, snapshot): + invalid_json = '{"this is invalid JSON"="bobbins"}' + + with pytest.raises(ClientError) as ctx: + aws_client.cloudformation.validate_template(TemplateBody=invalid_json) + + snapshot.match("validate-invalid-json", ctx.value.response) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.snapshot.json new file mode 100644 index 0000000000000..66cd35eaffec3 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.snapshot.json @@ -0,0 +1,113 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_get_template_summary": { + "recorded-date": "24-05-2023, 15:05:00", + "recorded-content": { + "template-summary": { + "Metadata": "{'TopicName': 'sns-topic-simple'}", + "Parameters": [], + "ResourceIdentifierSummaries": [ + { + "LogicalResourceIds": [ + "topic123" + ], + "ResourceType": "AWS::SNS::Topic" + } + ], + "ResourceTypes": [ + "AWS::SNS::Topic" + ], + "Version": "2010-09-09", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[s3_url]": { + "recorded-date": "11-10-2023, 00:03:44", + "recorded-content": { + "create-error": { + "Error": { + "Code": "ValidationError", + "Message": "S3 error: Domain name specified in is not a valid S3 domain", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_path]": { + "recorded-date": "11-10-2023, 00:03:53", + "recorded-content": { + "matching-topic": [ + { + "TopicArn": "arn::sns::111111111111:" + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_host]": { + "recorded-date": "11-10-2023, 00:04:02", + "recorded-content": { + "matching-topic": [ + { + "TopicArn": "arn::sns::111111111111:" + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_invalid]": { + "recorded-date": "11-10-2023, 00:04:04", + "recorded-content": { + "create-error": { + "Error": { + "Code": "ValidationError", + "Message": "TemplateURL must be a supported URL.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_validate_template": { + "recorded-date": "18-06-2024, 17:23:30", + "recorded-content": { + "validate-template": { + "Parameters": [ + { + "Description": "The EC2 Key Pair to allow SSH access to the instance", + "NoEcho": false, + "ParameterKey": "KeyExample" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_validate_invalid_json_template_should_fail": { + "recorded-date": "18-06-2024, 17:25:49", + "recorded-content": { + "validate-invalid-json": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: JSON not well-formed. (line 1, column 25)", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.validation.json new file mode 100644 index 0000000000000..77965368c70b2 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_host]": { + "last_validated_date": "2023-10-10T22:04:02+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_invalid]": { + "last_validated_date": "2023-10-10T22:04:04+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[http_path]": { + "last_validated_date": "2023-10-10T22:03:53+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_create_stack_from_s3_template_url[s3_url]": { + "last_validated_date": "2023-10-10T22:03:44+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_get_template_summary": { + "last_validated_date": "2023-05-24T13:05:00+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_validate_invalid_json_template_should_fail": { + "last_validated_date": "2024-06-18T17:25:49+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_templates.py::test_validate_template": { + "last_validated_date": "2024-06-18T17:23:30+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py new file mode 100644 index 0000000000000..ecb2d8a625d83 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py @@ -0,0 +1,164 @@ +import textwrap + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid, to_bytes + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..tags"]) +def test_duplicate_resources(deploy_cfn_template, s3_bucket, snapshot, aws_client): + snapshot.add_transformers_list( + [ + *snapshot.transform.apigateway_api(), + snapshot.transform.key_value("aws:cloudformation:stack-id"), + snapshot.transform.key_value("aws:cloudformation:stack-name"), + ] + ) + + # put API spec to S3 + api_spec = """ + swagger: 2.0 + info: + version: "1.2.3" + title: "Test API" + basePath: /base + """ + aws_client.s3.put_object(Bucket=s3_bucket, Key="api.yaml", Body=to_bytes(api_spec)) + + # deploy template + template = """ + Parameters: + ApiName: + Type: String + BucketName: + Type: String + Resources: + RestApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Ref ApiName + Body: + 'Fn::Transform': + Name: 'AWS::Include' + Parameters: + Location: !Sub "s3://${BucketName}/api.yaml" + Outputs: + RestApiId: + Value: !Ref RestApi + """ + + api_name = f"api-{short_uid()}" + result = deploy_cfn_template( + template=template, parameters={"ApiName": api_name, "BucketName": s3_bucket} + ) + + # assert REST API is created properly + api_id = result.outputs.get("RestApiId") + result = aws_client.apigateway.get_rest_api(restApiId=api_id) + assert result + snapshot.match("api-details", result) + + resources = aws_client.apigateway.get_resources(restApiId=api_id) + snapshot.match("api-resources", resources) + + +@pytest.mark.skip( + reason=( + "CFNV2:AWS::Include the transformation is run however the " + "physical resource id for the resource is not available" + ) +) +@markers.aws.validated +def test_transformer_property_level(deploy_cfn_template, s3_bucket, aws_client, snapshot): + api_spec = textwrap.dedent(""" + Value: from_transformation + """) + aws_client.s3.put_object(Bucket=s3_bucket, Key="data.yaml", Body=to_bytes(api_spec)) + + # deploy template + template = textwrap.dedent(""" + Parameters: + BucketName: + Type: String + Resources: + MyParameter: + Type: AWS::SSM::Parameter + Properties: + Description: hello + Type: String + "Fn::Transform": + Name: "AWS::Include" + Parameters: + Location: !Sub "s3://${BucketName}/data.yaml" + Outputs: + ParameterName: + Value: !Ref MyParameter + """) + + result = deploy_cfn_template(template=template, parameters={"BucketName": s3_bucket}) + param_name = result.outputs["ParameterName"] + param = aws_client.ssm.get_parameter(Name=param_name) + assert ( + param["Parameter"]["Value"] == "from_transformation" + ) # value coming from the transformation + describe_result = ( + aws_client.ssm.get_paginator("describe_parameters") + .paginate(Filters=[{"Key": "Name", "Values": [param_name]}]) + .build_full_result() + ) + assert ( + describe_result["Parameters"][0]["Description"] == "hello" + ) # value from a property on the same level as the transformation + + original_template = aws_client.cloudformation.get_template( + StackName=result.stack_id, TemplateStage="Original" + ) + snapshot.match("original_template", original_template) + processed_template = aws_client.cloudformation.get_template( + StackName=result.stack_id, TemplateStage="Processed" + ) + snapshot.match("processed_template", processed_template) + + +@pytest.mark.skip( + reason=( + "CFNV2:AWS::Include the transformation is run however the " + "physical resource id for the resource is not available" + ) +) +@markers.aws.validated +def test_transformer_individual_resource_level(deploy_cfn_template, s3_bucket, aws_client): + api_spec = textwrap.dedent(""" + Type: AWS::SNS::Topic + """) + aws_client.s3.put_object(Bucket=s3_bucket, Key="data.yaml", Body=to_bytes(api_spec)) + + # deploy template + template = textwrap.dedent(""" + Parameters: + BucketName: + Type: String + Resources: + MyResource: + "Fn::Transform": + Name: "AWS::Include" + Parameters: + Location: !Sub "s3://${BucketName}/data.yaml" + Outputs: + ResourceRef: + Value: !Ref MyResource + """) + + result = deploy_cfn_template(template=template, parameters={"BucketName": s3_bucket}) + resource_ref = result.outputs["ResourceRef"] + # just checking that this doens't fail, i.e. the topic exists + aws_client.sns.get_topic_attributes(TopicArn=resource_ref) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.snapshot.json new file mode 100644 index 0000000000000..cd79d06b34d9e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.snapshot.json @@ -0,0 +1,93 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_duplicate_resources": { + "recorded-date": "15-07-2025, 19:28:05", + "recorded-content": { + "api-details": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "RestApi", + "aws:cloudformation:stack-id": "", + "aws:cloudformation:stack-name": "" + }, + "version": "1.2.3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "api-resources": { + "items": [ + { + "id": "", + "path": "/" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_transformer_property_level": { + "recorded-date": "06-06-2024, 10:37:03", + "recorded-content": { + "original_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "\nParameters:\n BucketName:\n Type: String\nResources:\n MyParameter:\n Type: AWS::SSM::Parameter\n Properties:\n Description: hello\n Type: String\n \"Fn::Transform\":\n Name: \"AWS::Include\"\n Parameters:\n Location: !Sub \"s3://${BucketName}/data.yaml\"\nOutputs:\n ParameterName:\n Value: !Ref MyParameter\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "ParameterName": { + "Value": { + "Ref": "MyParameter" + } + } + }, + "Parameters": { + "BucketName": { + "Type": "String" + } + }, + "Resources": { + "MyParameter": { + "Properties": { + "Description": "hello", + "Type": "String", + "Value": "from_transformation" + }, + "Type": "AWS::SSM::Parameter" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.validation.json new file mode 100644 index 0000000000000..ac2a6ccf07d7d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_duplicate_resources": { + "last_validated_date": "2025-07-15T19:28:15+00:00", + "durations_in_seconds": { + "setup": 1.05, + "call": 13.13, + "teardown": 10.12, + "total": 24.3 + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_transformer_individual_resource_level": { + "last_validated_date": "2024-06-13T06:43:21+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_transformers.py::test_transformer_property_level": { + "last_validated_date": "2024-06-06T10:38:33+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py new file mode 100644 index 0000000000000..c8d04ddeab95e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py @@ -0,0 +1,468 @@ +import json +import os +import textwrap + +import botocore.errorfactory +import botocore.exceptions +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.testutil import upload_file_to_bucket + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@pytest.mark.skip(reason="CFNV2:UpdateStack") +@markers.aws.validated +def test_basic_update(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + response = aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ) + ), + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + snapshot.add_transformer(snapshot.transform.key_value("StackId", "stack-id")) + snapshot.match("update_response", response) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@pytest.mark.skip(reason="CFNV2:UpdateStack") +@markers.aws.validated +def test_update_using_template_url(deploy_cfn_template, s3_create_bucket, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + file_url = upload_file_to_bucket( + aws_client.s3, + s3_create_bucket(), + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml"), + )["Url"] + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateURL=file_url, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="Not supported") +def test_update_with_previous_template(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.needs_fixing +@pytest.mark.skip(reason="templates are not partially not valid => re-evaluate") +@pytest.mark.parametrize( + "capability", + [ + {"value": "CAPABILITY_IAM", "template": "iam_policy.yml"}, + {"value": "CAPABILITY_NAMED_IAM", "template": "iam_role_policy.yaml"}, + ], +) +# The AUTO_EXPAND option is used for macros +def test_update_with_capabilities(capability, deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/", capability["template"]) + ) + + parameter_key = "RoleName" if capability["value"] == "CAPABILITY_NAMED_IAM" else "Name" + + with pytest.raises(botocore.errorfactory.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[{"ParameterKey": parameter_key, "ParameterValue": f"{short_uid()}"}], + ) + + snapshot.match("error", ex.value.response) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Capabilities=[capability["value"]], + Parameters=[{"ParameterKey": parameter_key, "ParameterValue": f"{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="Not raising the correct error") +def test_update_with_resource_types(deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + # Test with invalid type + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + ResourceTypes=["AWS::EC2:*"], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + snapshot.match("invalid_type_error", ex.value.response) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + ResourceTypes=["AWS::EC2::*"], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + snapshot.match("resource_not_allowed", ex.value.response) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + ResourceTypes=["AWS::SNS::Topic"], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="Update value not being applied") +def test_set_notification_arn_with_update(deploy_cfn_template, sns_create_topic, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + topic_arn = sns_create_topic()["TopicArn"] + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + NotificationARNs=[topic_arn], + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + description = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)["Stacks"][0] + assert topic_arn in description["NotificationARNs"] + + +@markers.aws.validated +@pytest.mark.skip(reason="Update value not being applied") +def test_update_tags(deploy_cfn_template, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + key = f"key-{short_uid()}" + value = f"value-{short_uid()}" + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + Tags=[{"Key": key, "Value": value}], + TemplateBody=template, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + ) + + tags = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)["Stacks"][0][ + "Tags" + ] + assert tags[0]["Key"] == key + assert tags[0]["Value"] == value + + +@markers.aws.validated +@pytest.mark.skip(reason="The correct error is not being raised") +def test_no_template_error(deploy_cfn_template, snapshot, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack(StackName=stack.stack_name) + + snapshot.match("error", ex.value.response) + + +@pytest.mark.skip(reason="CFNV2:UpdateStack") +@markers.aws.validated +def test_no_parameters_update(deploy_cfn_template, aws_client): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack(StackName=stack.stack_name, TemplateBody=template) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@pytest.mark.skip(reason="CFNV2:UpdateStack") +@markers.aws.validated +def test_update_with_previous_parameter_value(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml" + ), + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.update.yml" + ) + ), + Parameters=[{"ParameterKey": "TopicName", "UsePreviousValue": True}], + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + +@markers.aws.validated +@pytest.mark.skip(reason="The correct error is not being raised") +def test_update_with_role_without_permissions( + deploy_cfn_template, snapshot, create_role, aws_client +): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + account_arn = aws_client.sts.get_caller_identity()["Arn"] + assume_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"AWS": account_arn}, + "Effect": "Deny", + } + ], + } + + role_arn = create_role(AssumeRolePolicyDocument=json.dumps(assume_policy_doc))["Role"]["Arn"] + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + RoleARN=role_arn, + ) + + snapshot.match("error", ex.value.response) + + +@markers.aws.validated +@pytest.mark.skip(reason="The correct error is not being raised") +def test_update_with_invalid_rollback_configuration_errors( + deploy_cfn_template, snapshot, aws_client +): + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + # Test invalid alarm type + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + RollbackConfiguration={"RollbackTriggers": [{"Arn": short_uid(), "Type": "Another"}]}, + ) + snapshot.match("type_error", ex.value.response) + + # Test invalid alarm arn + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + UsePreviousTemplate=True, + Parameters=[{"ParameterKey": "TopicName", "ParameterValue": f"topic-{short_uid()}"}], + RollbackConfiguration={ + "RollbackTriggers": [ + { + "Arn": "arn:aws:cloudwatch:us-east-1:123456789012:example-name", + "Type": "AWS::CloudWatch::Alarm", + } + ] + }, + ) + + snapshot.match("arn_error", ex.value.response) + + +@markers.aws.validated +@pytest.mark.skip(reason="The update value is not being applied") +def test_update_with_rollback_configuration(deploy_cfn_template, aws_client): + aws_client.cloudwatch.put_metric_alarm( + AlarmName="HighResourceUsage", + ComparisonOperator="GreaterThanThreshold", + EvaluationPeriods=1, + MetricName="CPUUsage", + Namespace="CustomNamespace", + Period=60, + Statistic="Average", + Threshold=70, + TreatMissingData="notBreaching", + ) + + alarms = aws_client.cloudwatch.describe_alarms(AlarmNames=["HighResourceUsage"]) + alarm_arn = alarms["MetricAlarms"][0]["AlarmArn"] + + rollback_configuration = { + "RollbackTriggers": [ + {"Arn": alarm_arn, "Type": "AWS::CloudWatch::Alarm"}, + ], + "MonitoringTimeInMinutes": 123, + } + + template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/sns_topic_parameter.yml") + ) + + stack = deploy_cfn_template( + template=template, + parameters={"TopicName": f"topic-{short_uid()}"}, + ) + + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template, + Parameters=[{"ParameterKey": "TopicName", "UsePreviousValue": True}], + RollbackConfiguration=rollback_configuration, + ) + + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + + config = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)["Stacks"][0][ + "RollbackConfiguration" + ] + assert config == rollback_configuration + + # cleanup + aws_client.cloudwatch.delete_alarms(AlarmNames=["HighResourceUsage"]) + + +@pytest.mark.skip(reason="CFNV2:UpdateStack") +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(["$..Stacks..ChangeSetId"]) +def test_diff_after_update(deploy_cfn_template, aws_client, snapshot): + template_1 = textwrap.dedent(""" + Resources: + SimpleParam: + Type: AWS::SSM::Parameter + Properties: + Value: before-stack-update + Type: String + """) + template_2 = textwrap.dedent(""" + Resources: + SimpleParam1: + Type: AWS::SSM::Parameter + Properties: + Value: after-stack-update + Type: String + """) + + stack = deploy_cfn_template( + template=template_1, + ) + + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack.stack_name) + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template_2, + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack.stack_name) + get_template_response = aws_client.cloudformation.get_template(StackName=stack.stack_name) + snapshot.match("get-template-response", get_template_response) + + with pytest.raises(botocore.exceptions.ClientError) as exc_info: + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=template_2, + ) + snapshot.match("update-error", exc_info.value.response) + + describe_stack_response = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name) + assert describe_stack_response["Stacks"][0]["StackStatus"] == "UPDATE_COMPLETE" diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.snapshot.json new file mode 100644 index 0000000000000..1b15733a652eb --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.snapshot.json @@ -0,0 +1,135 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_resource_types": { + "recorded-date": "19-11-2022, 14:34:18", + "recorded-content": { + "invalid_type_error": { + "Error": { + "Code": "ValidationError", + "Message": "Resource type AWS::SNS::Topic is not allowed by parameter ResourceTypes [AWS::EC2:*]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "resource_not_allowed": { + "Error": { + "Code": "ValidationError", + "Message": "Resource type AWS::SNS::Topic is not allowed by parameter ResourceTypes [AWS::EC2::*]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_basic_update": { + "recorded-date": "21-11-2022, 08:27:37", + "recorded-content": { + "update_response": { + "StackId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_no_template_error": { + "recorded-date": "21-11-2022, 08:57:45", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Either Template URL or Template Body must be specified.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_no_parameters_error_update": { + "recorded-date": "21-11-2022, 09:45:22", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_previous_parameter_value": { + "recorded-date": "21-11-2022, 10:38:33", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_role_without_permissions": { + "recorded-date": "21-11-2022, 14:14:52", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Role arn::iam::111111111111:role/role-fb405076 is invalid or cannot be assumed", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_invalid_rollback_configuration_errors": { + "recorded-date": "21-11-2022, 15:36:32", + "recorded-content": { + "type_error": { + "Error": { + "Code": "ValidationError", + "Message": "Rollback Trigger Type not supported", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "arn_error": { + "Error": { + "Code": "ValidationError", + "Message": "RelativeId of a Rollback Trigger's ARN is incorrect", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_diff_after_update": { + "recorded-date": "09-04-2024, 06:19:23", + "recorded-content": { + "get-template-response": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "\nResources:\n SimpleParam1:\n Type: AWS::SSM::Parameter\n Properties:\n Value: after-stack-update\n Type: String\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-error": { + "Error": { + "Code": "ValidationError", + "Message": "No updates are to be performed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.validation.json new file mode 100644 index 0000000000000..4723c7f6aae06 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_basic_update": { + "last_validated_date": "2022-11-21T07:27:37+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_diff_after_update": { + "last_validated_date": "2024-04-09T06:19:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_no_template_error": { + "last_validated_date": "2022-11-21T07:57:45+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_invalid_rollback_configuration_errors": { + "last_validated_date": "2022-11-21T14:36:32+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_previous_parameter_value": { + "last_validated_date": "2022-11-21T09:38:33+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_resource_types": { + "last_validated_date": "2022-11-19T13:34:18+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_update_stack.py::test_update_with_role_without_permissions": { + "last_validated_date": "2022-11-21T13:14:52+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py new file mode 100644 index 0000000000000..724cb12eb98f5 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py @@ -0,0 +1,83 @@ +import json + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + +pytestmark = pytest.mark.skip("CFNV2:Validation") + + +@markers.aws.validated +@pytest.mark.parametrize( + "outputs", + [ + { + "MyOutput": { + "Value": None, + }, + }, + { + "MyOutput": { + "Value": None, + "AnotherValue": None, + }, + }, + { + "MyOutput": {}, + }, + ], + ids=["none-value", "missing-def", "multiple-nones"], +) +def test_invalid_output_structure(deploy_cfn_template, snapshot, aws_client, outputs): + template = { + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + }, + }, + "Outputs": outputs, + } + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template(template=json.dumps(template)) + + snapshot.match("validation-error", e.value.response) + + +@markers.aws.validated +def test_missing_resources_block(deploy_cfn_template, snapshot, aws_client): + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template(template=json.dumps({})) + + snapshot.match("validation-error", e.value.response) + + +@markers.aws.validated +@pytest.mark.parametrize( + "properties", + [ + { + "Properties": {}, + }, + { + "Type": "AWS::SNS::Topic", + "Invalid": 10, + }, + ], + ids=[ + "missing-type", + "invalid-key", + ], +) +def test_resources_blocks(deploy_cfn_template, snapshot, aws_client, properties): + template = {"Resources": {"A": properties}} + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template(template=json.dumps(template)) + + snapshot.match("validation-error", e.value.response) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.snapshot.json new file mode 100644 index 0000000000000..3a5eeb52ded32 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.snapshot.json @@ -0,0 +1,98 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[none-value]": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "[/Outputs/MyOutput/Value] 'null' values are not allowed in templates", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[missing-def]": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "[/Outputs/MyOutput/Value] 'null' values are not allowed in templates", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[multiple-nones]": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Outputs member must contain a Value object", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_missing_resources_block": { + "recorded-date": "31-05-2024, 14:53:31", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: At least one Resources member must be defined.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_resources_blocks[missing-type]": { + "recorded-date": "31-05-2024, 14:53:32", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: [/Resources/A] Every Resources object must contain a Type member.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_resources_blocks[invalid-key]": { + "recorded-date": "31-05-2024, 14:53:32", + "recorded-content": { + "validation-error": { + "Error": { + "Code": "ValidationError", + "Message": "Invalid template resource property 'Invalid'", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.validation.json new file mode 100644 index 0000000000000..e2041c42e47d1 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[missing-def]": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[multiple-nones]": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_invalid_output_structure[none-value]": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_missing_resources_block": { + "last_validated_date": "2024-05-31T14:53:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_resources_blocks[invalid-key]": { + "last_validated_date": "2024-05-31T14:53:32+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/api/test_validations.py::test_resources_blocks[missing-type]": { + "last_validated_date": "2024-05-31T14:53:32+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/__init__.py b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py new file mode 100644 index 0000000000000..403c7c0b08baf --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py @@ -0,0 +1,51 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestResourceAttributes: + @pytest.mark.skip(reason="failing on unresolved attributes is not enabled yet") + @markers.snapshot.skip_snapshot_verify + @markers.aws.validated + def test_invalid_getatt_fails(self, aws_client, deploy_cfn_template, snapshot): + """ + Check how CloudFormation behaves on invalid attribute names for resources in a Fn::GetAtt + + Not yet completely correct yet since this should actually initiate a rollback and the stack resource status should be set accordingly + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/engine/cfn_invalid_getatt.yaml", + ) + ) + stack_events = exc_info.value.events + snapshot.match("stack_events", {"events": stack_events}) + + @markers.aws.validated + def test_dependency_on_attribute_with_dot_notation( + self, deploy_cfn_template, aws_client, snapshot + ): + """ + Test that a resource can depend on another resource's attribute with dot notation + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/engine/cfn_getatt_dot_dependency.yml", + ) + ) + snapshot.match("outputs", deployment.outputs) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.snapshot.json new file mode 100644 index 0000000000000..8e699f7013c15 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.snapshot.json @@ -0,0 +1,62 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py::TestResourceAttributes::test_invalid_getatt_fails": { + "recorded-date": "01-08-2023, 11:54:31", + "recorded-content": { + "stack_events": { + "events": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "[Error] /Outputs/InvalidOutput/Value/Fn::GetAtt: Resource type AWS::SSM::Parameter does not support attribute {Invalid}. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py::TestResourceAttributes::test_dependency_on_attribute_with_dot_notation": { + "recorded-date": "21-03-2024, 21:10:29", + "recorded-content": { + "outputs": { + "DeadArn": "arn::sqs::111111111111:" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.validation.json new file mode 100644 index 0000000000000..6a74c8a6ddc2d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py::TestResourceAttributes::test_dependency_on_attribute_with_dot_notation": { + "last_validated_date": "2024-03-21T21:10:29+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_attributes.py::TestResourceAttributes::test_invalid_getatt_fails": { + "last_validated_date": "2023-08-01T09:54:31+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py new file mode 100644 index 0000000000000..21d8af81371bc --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py @@ -0,0 +1,494 @@ +import os.path + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +THIS_DIR = os.path.dirname(__file__) + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestCloudFormationConditions: + @markers.aws.validated + def test_simple_condition_evaluation_deploys_resource( + self, aws_client, deploy_cfn_template, cleanups + ): + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/conditions/simple-condition.yaml" + ), + parameters={"OptionParameter": "option-a", "TopicName": topic_name}, + ) + # verify that CloudFormation includes the resource + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + assert stack_resources["StackResources"] + + # verify actual resource deployment + assert [ + t + for t in aws_client.sns.get_paginator("list_topics") + .paginate() + .build_full_result()["Topics"] + if topic_name in t["TopicArn"] + ] + + @markers.aws.validated + def test_simple_condition_evaluation_doesnt_deploy_resource( + self, aws_client, deploy_cfn_template, cleanups + ): + """Note: Conditions allow us to deploy stacks that won't actually contain any deployed resources""" + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/conditions/simple-condition.yaml" + ), + parameters={"OptionParameter": "option-b", "TopicName": topic_name}, + ) + # verify that CloudFormation ignores the resource + aws_client.cloudformation.describe_stack_resources(StackName=deployment.stack_id) + + # FIXME: currently broken in localstack + # assert stack_resources['StackResources'] == [] + + # verify actual resource deployment + assert [ + t for t in aws_client.sns.list_topics()["Topics"] if topic_name in t["TopicArn"] + ] == [] + + @pytest.mark.parametrize( + "should_set_custom_name", + ["yep", "nope"], + ) + @markers.aws.validated + def test_simple_intrinsic_fn_condition_evaluation( + self, aws_client, deploy_cfn_template, should_set_custom_name + ): + """ + Tests a simple Fn::If condition evaluation + + The conditional ShouldSetCustomName (yep | nope) switches between an autogenerated and a predefined name for the topic + + FIXME: this should also work with the simple-intrinsic-condition-name-conflict.yaml template where the ID of the condition and the ID of the parameter are the same(!). + It is currently broken in LocalStack though + """ + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/conditions/simple-intrinsic-condition.yaml" + ), + parameters={ + "TopicName": topic_name, + "ShouldSetCustomName": should_set_custom_name, + }, + ) + # verify that the topic has the correct name + topic_arn = deployment.outputs["TopicArn"] + if should_set_custom_name == "yep": + assert topic_name in topic_arn + else: + assert topic_name not in topic_arn + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + def test_dependent_ref(self, aws_client, snapshot): + """ + Tests behavior of a stack with 2 resources where one depends on the other. + The referenced resource won't be deployed due to its condition evaluating to false, so the ref can't be resolved. + + This immediately leads to an error. + """ + topic_name = f"test-topic-{short_uid()}" + ssm_param_name = f"test-param-{short_uid()}" + + stack_name = f"test-condition-ref-stack-{short_uid()}" + changeset_name = "initial" + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + ChangeSetType="CREATE", + TemplateBody=load_file( + os.path.join(THIS_DIR, "../../../../../templates/conditions/ref-condition.yaml") + ), + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "SsmParamName", "ParameterValue": ssm_param_name}, + {"ParameterKey": "OptionParameter", "ParameterValue": "option-b"}, + ], + ) + snapshot.match("dependent_ref_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + def test_dependent_ref_intrinsic_fn_condition(self, aws_client, deploy_cfn_template): + """ + Checks behavior of un-refable resources + """ + topic_name = f"test-topic-{short_uid()}" + ssm_param_name = f"test-param-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, + "../../../../../templates/conditions/ref-condition-intrinsic-condition.yaml", + ), + parameters={ + "TopicName": topic_name, + "SsmParamName": ssm_param_name, + "OptionParameter": "option-b", + }, + ) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + def test_dependent_ref_with_macro( + self, aws_client, deploy_cfn_template, lambda_su_role, cleanups + ): + """ + specifying option-b would normally lead to an error without the macro because of the unresolved ref. + Because the macro replaced the resources though, the test passes. + We've therefore shown that conditions aren't fully evaluated before the transformations + + Related findings: + * macros are not allowed to transform Parameters (macro invocation by CFn will fail in this case) + + """ + + log_group_name = f"test-log-group-{short_uid()}" + aws_client.logs.create_log_group(logGroupName=log_group_name) + + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/conditions/ref-condition-macro-def.yaml" + ), + parameters={ + "FnRole": lambda_su_role, + "LogGroupName": log_group_name, + "LogRoleARN": lambda_su_role, + }, + ) + + topic_name = f"test-topic-{short_uid()}" + ssm_param_name = f"test-param-{short_uid()}" + stack_name = f"test-condition-ref-macro-stack-{short_uid()}" + changeset_name = "initial" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + ChangeSetType="CREATE", + TemplateBody=load_file( + os.path.join( + THIS_DIR, "../../../../../templates/conditions/ref-condition-macro.yaml" + ) + ), + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "SsmParamName", "ParameterValue": ssm_param_name}, + {"ParameterKey": "OptionParameter", "ParameterValue": "option-b"}, + ], + ) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=changeset_name, StackName=stack_name + ) + + @pytest.mark.parametrize( + ["env_type", "should_create_bucket", "should_create_policy"], + [ + ("test", False, False), + ("test", True, False), + ("prod", False, False), + ("prod", True, True), + ], + ids=[ + "test-nobucket-nopolicy", + "test-bucket-nopolicy", + "prod-nobucket-nopolicy", + "prod-bucket-policy", + ], + ) + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + @markers.aws.validated + def test_nested_conditions( + self, + aws_client, + deploy_cfn_template, + cleanups, + env_type, + should_create_bucket, + should_create_policy, + snapshot, + ): + """ + Tests the case where a condition references another condition + + EnvType == "prod" && BucketName != "" ==> creates bucket + policy + EnvType == "test" && BucketName != "" ==> creates bucket only + EnvType == "test" && BucketName == "" ==> no resource created + EnvType == "prod" && BucketName == "" ==> no resource created + """ + bucket_name = f"ls-test-bucket-{short_uid()}" if should_create_bucket else "" + stack_name = f"condition-test-stack-{short_uid()}" + changeset_name = "initial" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + if bucket_name: + snapshot.add_transformer(snapshot.transform.regex(bucket_name, "")) + snapshot.add_transformer(snapshot.transform.regex(stack_name, "")) + + template = load_file( + os.path.join(THIS_DIR, "../../../../../templates/conditions/nested-conditions.yaml") + ) + create_cs_result = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=changeset_name, + TemplateBody=template, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "EnvType", "ParameterValue": env_type}, + {"ParameterKey": "BucketName", "ParameterValue": bucket_name}, + ], + ) + snapshot.match("create_cs_result", create_cs_result) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=changeset_name, StackName=stack_name + ) + + describe_changeset_result = aws_client.cloudformation.describe_change_set( + ChangeSetName=changeset_name, StackName=stack_name + ) + snapshot.match("describe_changeset_result", describe_changeset_result) + aws_client.cloudformation.execute_change_set( + ChangeSetName=changeset_name, StackName=stack_name + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + stack_resources = aws_client.cloudformation.describe_stack_resources(StackName=stack_name) + if should_create_policy: + stack_policy = [ + sr + for sr in stack_resources["StackResources"] + if sr["ResourceType"] == "AWS::S3::BucketPolicy" + ][0] + snapshot.add_transformer( + snapshot.transform.regex(stack_policy["PhysicalResourceId"], ""), + priority=-1, + ) + + snapshot.match("stack_resources", stack_resources) + stack_events = aws_client.cloudformation.describe_stack_events(StackName=stack_name) + snapshot.match("stack_events", stack_events) + describe_stack_result = aws_client.cloudformation.describe_stacks(StackName=stack_name) + snapshot.match("describe_stack_result", describe_stack_result) + + # manual assertions + + # check that bucket exists + try: + aws_client.s3.head_bucket(Bucket=bucket_name) + bucket_exists = True + except Exception: + bucket_exists = False + + assert bucket_exists == should_create_bucket + + if bucket_exists: + # check if a policy exists on the bucket + try: + aws_client.s3.get_bucket_policy(Bucket=bucket_name) + bucket_policy_exists = True + except Exception: + bucket_policy_exists = False + + assert bucket_policy_exists == should_create_policy + + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not supported yet") + @markers.aws.validated + def test_output_reference_to_skipped_resource(self, deploy_cfn_template, aws_client, snapshot): + """test what happens to outputs that reference a resource that isn't deployed due to a falsy condition""" + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/conditions/ref-condition-output.yaml" + ), + parameters={ + "OptionParameter": "option-b", + }, + ) + snapshot.match("unresolved_resource_reference_exception", e.value.response) + + @pytest.mark.aws_validated + @pytest.mark.parametrize("create_parameter", ("true", "false"), ids=("create", "no-create")) + def test_conditional_att_to_conditional_resources(self, deploy_cfn_template, create_parameter): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_if_attribute_none.yml" + ) + + deployed = deploy_cfn_template( + template_path=template_path, + parameters={"CreateParameter": create_parameter}, + ) + + if create_parameter == "false": + assert deployed.outputs["Result"] == "Value1" + else: + assert deployed.outputs["Result"] == "Value2" + + # def test_updating_only_conditions_during_stack_update(self): + # ... + + # def test_condition_with_unsupported_intrinsic_functions(self): + # ... + + @pytest.mark.parametrize( + ["should_use_fallback", "match_value"], + [ + (None, "FallbackParamValue"), + ("false", "DefaultParamValue"), + # CFNV2:Other + # ("true", "FallbackParamValue"), + ], + ) + @markers.aws.validated + def test_dependency_in_non_evaluated_if_branch( + self, deploy_cfn_template, aws_client, should_use_fallback, match_value + ): + parameters = ( + {"ShouldUseFallbackParameter": should_use_fallback} if should_use_fallback else {} + ) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/engine/cfn_if_conditional_reference.yaml", + ), + parameters=parameters, + ) + param = aws_client.ssm.get_parameter(Name=stack.outputs["ParameterName"]) + assert param["Parameter"]["Value"] == match_value + + @markers.aws.validated + def test_sub_in_conditions(self, deploy_cfn_template, aws_client): + region = aws_client.cloudformation.meta.region_name + topic_prefix = f"test-topic-{short_uid()}" + suffix = short_uid() + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/conditions/intrinsic-functions-in-conditions.yaml", + ), + parameters={ + "TopicName": f"{topic_prefix}-{region}", + "TopicPrefix": topic_prefix, + "TopicNameWithSuffix": f"{topic_prefix}-{region}-{suffix}", + "TopicNameSuffix": suffix, + }, + ) + + topic_arn = stack.outputs["TopicRef"] + aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + assert topic_arn.split(":")[-1] == f"{topic_prefix}-{region}" + + topic_arn_with_suffix = stack.outputs["TopicWithSuffixRef"] + aws_client.sns.get_topic_attributes(TopicArn=topic_arn_with_suffix) + assert topic_arn_with_suffix.split(":")[-1] == f"{topic_prefix}-{region}-{suffix}" + + @markers.aws.validated + @pytest.mark.parametrize("env,region", [("dev", "us-west-2"), ("production", "us-east-1")]) + def test_conditional_in_conditional(self, env, region, deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/conditions/conditional-in-conditional.yml", + ), + parameters={ + "SelectedRegion": region, + "Environment": env, + }, + ) + + if env == "production" and region == "us-east-1": + assert stack.outputs["Result"] == "true" + else: + assert stack.outputs["Result"] == "false" + + @markers.aws.validated + def test_conditional_with_select(self, deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/conditions/conditional-with-select.yml", + ), + ) + + managed_policy_arn = stack.outputs["PolicyArn"] + assert aws_client.iam.get_policy(PolicyArn=managed_policy_arn) + + @markers.aws.validated + def test_condition_on_outputs(self, deploy_cfn_template, aws_client): + """ + The stack has 2 outputs. + Each is gated by a different condition value ("test" vs. "prod"). + Only one of them should be returned for the stack outputs + """ + nested_bucket_name = f"test-bucket-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/nested-stack-conditions.nested.yaml", + ), + parameters={ + "BucketBaseName": nested_bucket_name, + "Mode": "prod", + }, + ) + assert "TestBucket" not in stack.outputs + assert stack.outputs["ProdBucket"] == f"{nested_bucket_name}-prod" + assert aws_client.s3.head_bucket(Bucket=stack.outputs["ProdBucket"]) + + @markers.aws.validated + def test_update_conditions(self, deploy_cfn_template, aws_client): + original_bucket_name = f"test-bucket-{short_uid()}" + stack_name = f"test-update-conditions-{short_uid()}" + deploy_cfn_template( + stack_name=stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_condition_update_1.yml" + ), + parameters={"OriginalBucketName": original_bucket_name}, + ) + assert aws_client.s3.head_bucket(Bucket=original_bucket_name) + + bucket_1 = f"test-bucket-1-{short_uid()}" + bucket_2 = f"test-bucket-2-{short_uid()}" + + deploy_cfn_template( + stack_name=stack_name, + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_condition_update_2.yml" + ), + parameters={ + "OriginalBucketName": original_bucket_name, + "FirstBucket": bucket_1, + "SecondBucket": bucket_2, + }, + ) + + assert aws_client.s3.head_bucket(Bucket=original_bucket_name) + assert aws_client.s3.head_bucket(Bucket=bucket_1) + with pytest.raises(aws_client.s3.exceptions.ClientError): + aws_client.s3.head_bucket(Bucket=bucket_2) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.snapshot.json new file mode 100644 index 0000000000000..358e26e2e16a7 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.snapshot.json @@ -0,0 +1,763 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-nobucket-nopolicy]": { + "recorded-date": "26-06-2023, 14:20:49", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-bucket-nopolicy]": { + "recorded-date": "26-06-2023, 14:21:54", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Bucket", + "ResourceType": "AWS::S3::Bucket", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_COMPLETE-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "test" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-nobucket-nopolicy]": { + "recorded-date": "26-06-2023, 14:22:58", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-bucket-policy]": { + "recorded-date": "26-06-2023, 14:24:03", + "recorded-content": { + "create_cs_result": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_changeset_result": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Bucket", + "ResourceType": "AWS::S3::Bucket", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Policy", + "ResourceType": "AWS::S3::BucketPolicy", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_events": { + "StackEvents": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Policy-CREATE_COMPLETE-date", + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceProperties": { + "Bucket": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Resource": [ + "arn::s3:::/*" + ], + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + } + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Policy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceProperties": { + "Bucket": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Resource": [ + "arn::s3:::/*" + ], + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Policy-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Policy", + "PhysicalResourceId": "", + "ResourceProperties": { + "Bucket": "", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Resource": [ + "arn::s3:::/*" + ], + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + } + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::BucketPolicy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_COMPLETE-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Bucket-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceProperties": { + "BucketName": "" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stack_result": { + "Stacks": [ + { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "BucketName", + "ParameterValue": "" + }, + { + "ParameterKey": "EnvType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref": { + "recorded-date": "26-06-2023, 14:18:26", + "recorded-content": { + "dependent_ref_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Unresolved resource dependencies [MyTopic] in the Resources block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_output_reference_to_skipped_resource": { + "recorded-date": "27-06-2023, 00:43:18", + "recorded-content": { + "unresolved_resource_reference_exception": { + "Error": { + "Code": "ValidationError", + "Message": "Unresolved resource dependencies [MyTopic] in the Outputs block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.validation.json new file mode 100644 index 0000000000000..e285748924d8a --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_dependent_ref": { + "last_validated_date": "2023-06-26T12:18:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-bucket-policy]": { + "last_validated_date": "2023-06-26T12:24:03+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[prod-nobucket-nopolicy]": { + "last_validated_date": "2023-06-26T12:22:58+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-bucket-nopolicy]": { + "last_validated_date": "2023-06-26T12:21:54+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_nested_conditions[test-nobucket-nopolicy]": { + "last_validated_date": "2023-06-26T12:20:49+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_output_reference_to_skipped_resource": { + "last_validated_date": "2023-06-26T22:43:18+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_conditions.py::TestCloudFormationConditions::test_update_conditions": { + "last_validated_date": "2024-06-18T19:43:43+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py new file mode 100644 index 0000000000000..a088355fd966a --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py @@ -0,0 +1,266 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +THIS_DIR = os.path.dirname(__file__) + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.snapshot.skip_snapshot_verify +class TestCloudFormationMappings: + @markers.aws.validated + def test_simple_mapping_working(self, aws_client, deploy_cfn_template): + """ + A very simple test to deploy a resource with a name depending on a value that needs to be looked up from the mapping + """ + topic_name = f"test-topic-{short_uid()}" + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/mappings/simple-mapping.yaml" + ), + parameters={ + "TopicName": topic_name, + "TopicNameSuffixSelector": "A", + }, + ) + # verify that CloudFormation includes the resource + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + assert stack_resources["StackResources"] + + expected_topic_name = f"{topic_name}-suffix-a" + + # verify actual resource deployment + assert [ + t + for t in aws_client.sns.get_paginator("list_topics") + .paginate() + .build_full_result()["Topics"] + if expected_topic_name in t["TopicArn"] + ] + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_with_nonexisting_key(self, aws_client, cleanups, snapshot): + """ + Tries to deploy a resource with a dependency on a mapping key + which is not included in the Mappings section and thus can't be resolved + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join(THIS_DIR, "../../../../../templates/mappings/simple-mapping.yaml") + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "C"}, + ], + ) + snapshot.match("mapping_nonexisting_key_exc", e.value.response) + + @pytest.mark.skip(reason="CFNV2:Validation") + @markers.aws.only_localstack + def test_async_mapping_error_first_level(self, deploy_cfn_template): + """ + We don't (yet) support validating mappings synchronously in `create_changeset` like AWS does, however + we don't fail with a good error message at all. This test ensures that the deployment fails with a + nicer error message than a Python traceback about "`None` has no attribute `get`". + """ + topic_name = f"test-topic-{short_uid()}" + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, + "../../../../../templates/mappings/simple-mapping.yaml", + ), + parameters={ + "TopicName": topic_name, + "TopicNameSuffixSelector": "C", + }, + ) + + assert "Cannot find map key 'C' in mapping 'TopicSuffixMap'" in str(exc_info.value) + + @pytest.mark.skip(reason="CFNV2:Validation") + @markers.aws.only_localstack + def test_async_mapping_error_second_level(self, deploy_cfn_template): + """ + Similar to the `test_async_mapping_error_first_level` test above, but + checking the second level of mapping lookup + """ + topic_name = f"test-topic-{short_uid()}" + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, + "../../../../../templates/mappings/simple-mapping.yaml", + ), + parameters={ + "TopicName": topic_name, + "TopicNameSuffixSelector": "A", + "TopicAttributeSelector": "NotValid", + }, + ) + + assert "Cannot find map key 'NotValid' in mapping 'TopicSuffixMap' under key 'A'" in str( + exc_info.value + ) + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_with_invalid_refs(self, aws_client, deploy_cfn_template, cleanups, snapshot): + """ + The Mappings section can only include static elements (strings and lists). + In this test one value is instead a `Ref` which should be rejected by the service + + Also note the overlap with the `test_mapping_with_nonexisting_key` case here. + Even though we specify a non-existing key here again (`C`), the returned error is for the invalid structure. + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join( + THIS_DIR, "../../../../../templates/mappings/simple-mapping-invalid-ref.yaml" + ) + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "C"}, + {"ParameterKey": "TopicNameSuffix", "ParameterValue": "suffix-c"}, + ], + ) + snapshot.match("mapping_invalid_ref_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_maximum_nesting_depth(self, aws_client, cleanups, snapshot): + """ + Tries to deploy a template containing a mapping with a nesting depth of 3. + The maximum depth is 2 so it should fail + + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join( + THIS_DIR, "../../../../../templates/mappings/simple-mapping-nesting-depth.yaml" + ) + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "A"}, + ], + ) + snapshot.match("mapping_maximum_level_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented") + def test_mapping_minimum_nesting_depth(self, aws_client, cleanups, snapshot): + """ + Tries to deploy a template containing a mapping with a nesting depth of 1. + The required depth is 2, so it should fail for a single level + """ + topic_name = f"test-topic-{short_uid()}" + stack_name = f"test-stack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + template_body = load_file( + os.path.join( + THIS_DIR, "../../../../../templates/mappings/simple-mapping-single-level.yaml" + ) + ) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="initial", + TemplateBody=template_body, + ChangeSetType="CREATE", + Parameters=[ + {"ParameterKey": "TopicName", "ParameterValue": topic_name}, + {"ParameterKey": "TopicNameSuffixSelector", "ParameterValue": "A"}, + ], + ) + snapshot.match("mapping_minimum_level_exc", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "map_key,should_error", + [ + ("A", False), + ("B", True), + ], + ids=["should-deploy", "should-not-deploy"], + ) + def test_mapping_ref_map_key(self, deploy_cfn_template, aws_client, map_key, should_error): + topic_name = f"topic-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/mappings/mapping-ref-map-key.yaml" + ), + parameters={ + "MapName": "MyMap", + "MapKey": map_key, + "TopicName": topic_name, + }, + ) + + topic_arn = stack.outputs.get("TopicArn") + if should_error: + assert topic_arn is None + else: + assert topic_arn is not None + + aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + + @markers.aws.validated + def test_aws_refs_in_mappings(self, deploy_cfn_template, account_id): + """ + This test asserts that Pseudo references aka "AWS::" are supported inside a mapping inside a Conditional. + It's worth remembering that even with references being supported, AWS rejects names that are not alphanumeric + in Mapping name or the second level key. + """ + stack_name = f"Stack{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + THIS_DIR, "../../../../../templates/mappings/mapping-aws-ref-map-key.yaml" + ), + stack_name=stack_name, + template_mapping={"StackName": stack_name}, + ) + assert stack.outputs.get("TopicArn") diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.snapshot.json new file mode 100644 index 0000000000000..b5ecf4d26a841 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.snapshot.json @@ -0,0 +1,66 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_nonexisting_key": { + "recorded-date": "12-06-2023, 16:47:23", + "recorded-content": { + "mapping_nonexisting_key_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template error: Unable to get mapping for TopicSuffixMap::C::Suffix", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_invalid_refs": { + "recorded-date": "12-06-2023, 16:47:24", + "recorded-content": { + "mapping_invalid_ref_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Mappings attribute must be a String or a List.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_maximum_nesting_depth": { + "recorded-date": "12-06-2023, 16:47:24", + "recorded-content": { + "mapping_maximum_level_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Mappings attribute must be a String or a List.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_minimum_nesting_depth": { + "recorded-date": "12-06-2023, 16:47:25", + "recorded-content": { + "mapping_minimum_level_exc": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Every Mappings member A must be a map", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.validation.json new file mode 100644 index 0000000000000..b66abfb0050a0 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_aws_refs_in_mappings": { + "last_validated_date": "2024-10-15T17:22:43+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_maximum_nesting_depth": { + "last_validated_date": "2023-06-12T14:47:24+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_minimum_nesting_depth": { + "last_validated_date": "2023-06-12T14:47:25+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-deploy]": { + "last_validated_date": "2024-10-17T22:40:44+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_ref_map_key[should-not-deploy]": { + "last_validated_date": "2024-10-17T22:41:45+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_invalid_refs": { + "last_validated_date": "2023-06-12T14:47:24+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_mappings.py::TestCloudFormationMappings::test_mapping_with_nonexisting_key": { + "last_validated_date": "2023-06-12T14:47:23+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py new file mode 100644 index 0000000000000..d89ae634ae003 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py @@ -0,0 +1,131 @@ +import json +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestDependsOn: + @pytest.mark.skip(reason="not supported yet") + @markers.aws.validated + def test_depends_on_with_missing_reference( + self, deploy_cfn_template, aws_client, cleanups, snapshot + ): + stack_name = f"test-stack-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), + "../../../../../templates/engine/cfn_dependson_nonexisting_resource.yaml", + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + with pytest.raises(aws_client.cloudformation.exceptions.ClientError) as e: + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName="init", + ChangeSetType="CREATE", + TemplateBody=load_file(template_path), + ) + snapshot.match("depends_on_nonexisting_exception", e.value.response) + + +class TestFnSub: + # TODO: add test for list sub without a second argument (i.e. the list) + # => Template error: One or more Fn::Sub intrinsic functions don't specify expected arguments. Specify a string as first argument, and an optional second argument to specify a mapping of values to replace in the string + + @markers.aws.validated + def test_fn_sub_cases(self, deploy_cfn_template, aws_client, snapshot): + ssm_parameter_name = f"test-param-{short_uid()}" + snapshot.add_transformer( + snapshot.transform.regex(ssm_parameter_name, "") + ) + snapshot.add_transformer( + snapshot.transform.key_value( + "UrlSuffixPseudoParam", "", reference_replacement=False + ) + ) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/engine/cfn_fn_sub.yaml" + ), + parameters={"ParameterName": ssm_parameter_name}, + ) + + snapshot.match("outputs", deployment.outputs) + + @markers.aws.validated + def test_non_string_parameter_in_sub(self, deploy_cfn_template, aws_client, snapshot): + ssm_parameter_name = f"test-param-{short_uid()}" + snapshot.add_transformer( + snapshot.transform.regex(ssm_parameter_name, "") + ) + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_number_in_sub.yml" + ), + parameters={"ParameterName": ssm_parameter_name}, + ) + + get_param_res = aws_client.ssm.get_parameter(Name=ssm_parameter_name)["Parameter"] + snapshot.match("get-parameter-result", get_param_res) + + +@pytest.mark.skip(reason="CFNV2:Validation") +@markers.aws.validated +def test_useful_error_when_invalid_ref(deploy_cfn_template, snapshot): + """ + When trying to resolve a non-existent !Ref, make sure the error message includes the name of the !Ref + to clarify which !Ref cannot be resolved. + """ + logical_resource_id = "Topic" + ref_name = "InvalidRef" + + template = json.dumps( + { + "Resources": { + logical_resource_id: { + "Type": "AWS::SNS::Topic", + "Properties": { + "Name": { + "Ref": ref_name, + }, + }, + } + } + } + ) + + with pytest.raises(ClientError) as exc_info: + deploy_cfn_template(template=template) + + snapshot.match("validation_error", exc_info.value.response) + + +@markers.aws.validated +def test_resolve_transitive_placeholders_in_strings(deploy_cfn_template, aws_client, snapshot): + queue_name = f"q-{short_uid()}" + parameter_ver = f"v{short_uid()}" + stack_name = f"stack-{short_uid()}" + stack = deploy_cfn_template( + stack_name=stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/legacy_transitive_ref.yaml" + ), + max_wait=300 if is_aws_cloud() else 10, + parameters={"QueueName": queue_name, "Qualifier": parameter_ver}, + ) + tags = aws_client.sqs.list_queue_tags(QueueUrl=stack.outputs["QueueURL"]) + snapshot.add_transformer( + snapshot.transform.regex(r"/cdk-bootstrap/(\w+)/", "/cdk-bootstrap/.../") + ) + snapshot.match("tags", tags) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.snapshot.json new file mode 100644 index 0000000000000..c17fb974377b0 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.snapshot.json @@ -0,0 +1,84 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestDependsOn::test_depends_on_with_missing_reference": { + "recorded-date": "10-07-2023, 15:22:26", + "recorded-content": { + "depends_on_nonexisting_exception": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Unresolved resource dependencies [NonExistingResource] in the Resources block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestFnSub::test_fn_sub_cases": { + "recorded-date": "23-08-2023, 20:41:02", + "recorded-content": { + "outputs": { + "ListRefGetAtt": "unimportant", + "ListRefGetAttMapping": "unimportant", + "ListRefMultipleMix": "Param1Value--Param1Value", + "ListRefParam": "Param1Value", + "ListRefPseudoParam": "", + "ListRefResourceDirect": "Param1Value", + "ListRefResourceMappingRef": "Param1Value", + "ListStatic": "this is a static string", + "StringRefGetAtt": "unimportant", + "StringRefMultiple": "Param1Value - Param1Value", + "StringRefParam": "Param1Value", + "StringRefPseudoParam": "", + "StringRefResource": "Param1Value", + "StringStatic": "this is a static string", + "UrlSuffixPseudoParam": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::test_useful_error_when_invalid_ref": { + "recorded-date": "28-05-2024, 11:42:58", + "recorded-content": { + "validation_error": { + "Error": { + "Code": "ValidationError", + "Message": "Template format error: Unresolved resource dependencies [InvalidRef] in the Resources block of the template", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::test_resolve_transitive_placeholders_in_strings": { + "recorded-date": "18-06-2024, 19:55:48", + "recorded-content": { + "tags": { + "Tags": { + "test": "arn::ssm::111111111111:parameter/cdk-bootstrap/.../version" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestFnSub::test_non_string_parameter_in_sub": { + "recorded-date": "17-10-2024, 22:49:56", + "recorded-content": { + "get-parameter-result": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "my number is 3", + "Version": 1 + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.validation.json new file mode 100644 index 0000000000000..b2edacb2b077b --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestDependsOn::test_depends_on_with_missing_reference": { + "last_validated_date": "2023-07-10T13:22:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestFnSub::test_fn_sub_cases": { + "last_validated_date": "2023-08-23T18:41:02+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::TestFnSub::test_non_string_parameter_in_sub": { + "last_validated_date": "2024-10-17T22:49:56+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::test_resolve_transitive_placeholders_in_strings": { + "last_validated_date": "2024-06-18T19:55:48+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/engine/test_references.py::test_useful_error_when_invalid_ref": { + "last_validated_date": "2024-05-28T11:42:58+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/__init__.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/handlers/handler1/api.zip b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/handlers/handler1/api.zip new file mode 100644 index 0000000000000..8f8c0f78f6257 Binary files /dev/null and b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/handlers/handler1/api.zip differ diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/handlers/handler2/api.zip b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/handlers/handler2/api.zip new file mode 100644 index 0000000000000..f45beec4a069f Binary files /dev/null and b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/handlers/handler2/api.zip differ diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_acm.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_acm.py new file mode 100644 index 0000000000000..5e215533958e9 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_acm.py @@ -0,0 +1,36 @@ +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + +TEST_TEMPLATE = """ +Resources: + cert1: + Type: "AWS::CertificateManager::Certificate" + Properties: + DomainName: "{{domain}}" + DomainValidationOptions: + - DomainName: "{{domain}}" + HostedZoneId: zone123 # using dummy ID for now + ValidationMethod: DNS +Outputs: + Cert: + Value: !Ref cert1 +""" + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.only_localstack +def test_cfn_acm_certificate(deploy_cfn_template, aws_client): + domain = f"domain-{short_uid()}.com" + deploy_cfn_template(template=TEST_TEMPLATE, template_mapping={"domain": domain}) + + result = aws_client.acm.list_certificates()["CertificateSummaryList"] + result = [cert for cert in result if cert["DomainName"] == domain] + assert result diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py new file mode 100644 index 0000000000000..9d1b99b86b976 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py @@ -0,0 +1,716 @@ +import json +import os.path +from operator import itemgetter + +import pytest +import requests +from tests.aws.services.apigateway.apigateway_fixtures import api_invoke_url + +from localstack import constants +from localstack.aws.api.lambda_ import Runtime +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid +from localstack.utils.files import load_file +from localstack.utils.run import to_str +from localstack.utils.strings import to_bytes + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + +PARENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEST_LAMBDA_PYTHON_ECHO = os.path.join(PARENT_DIR, "lambda_/functions/lambda_echo.py") + +TEST_TEMPLATE_1 = """ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Parameters: + ApiName: + Type: String + IntegrationUri: + Type: String +Resources: + Api: + Type: AWS::Serverless::Api + Properties: + StageName: dev + Name: !Ref ApiName + DefinitionBody: + swagger: 2.0 + info: + version: "1.0" + title: "Public API" + basePath: /base + schemes: + - "https" + x-amazon-apigateway-binary-media-types: + - "*/*" + paths: + /test: + post: + responses: {} + x-amazon-apigateway-integration: + uri: !Ref IntegrationUri + httpMethod: "POST" + type: "http_proxy" +""" + + +# this is an `only_localstack` test because it makes use of _custom_id_ tag +@markers.aws.only_localstack +def test_cfn_apigateway_aws_integration(deploy_cfn_template, aws_client): + api_name = f"rest-api-{short_uid()}" + custom_id = short_uid() + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/apigw-awsintegration-request-parameters.yaml", + ), + parameters={ + "ApiName": api_name, + "CustomTagKey": "_custom_id_", + "CustomTagValue": custom_id, + }, + ) + + # check resources creation + apis = [ + api for api in aws_client.apigateway.get_rest_apis()["items"] if api["name"] == api_name + ] + assert len(apis) == 1 + api_id = apis[0]["id"] + + # check resources creation + resources = aws_client.apigateway.get_resources(restApiId=api_id)["items"] + assert ( + resources[0]["resourceMethods"]["GET"]["requestParameters"]["method.request.path.id"] + is False + ) + assert ( + resources[0]["resourceMethods"]["GET"]["methodIntegration"]["requestParameters"][ + "integration.request.path.object" + ] + == "method.request.path.id" + ) + + # check domains creation + domain_names = [ + domain["domainName"] for domain in aws_client.apigateway.get_domain_names()["items"] + ] + expected_domain = "cfn5632.localstack.cloud" # hardcoded value from template yaml file + assert expected_domain in domain_names + + # check basepath mappings creation + mappings = [ + mapping["basePath"] + for mapping in aws_client.apigateway.get_base_path_mappings(domainName=expected_domain)[ + "items" + ] + ] + assert len(mappings) == 1 + assert mappings[0] == "(none)" + + +@markers.aws.validated +def test_cfn_apigateway_swagger_import(deploy_cfn_template, echo_http_server_post, aws_client): + api_name = f"rest-api-{short_uid()}" + deploy_cfn_template( + template=TEST_TEMPLATE_1, + parameters={"ApiName": api_name, "IntegrationUri": echo_http_server_post}, + ) + + # get API details + apis = [ + api for api in aws_client.apigateway.get_rest_apis()["items"] if api["name"] == api_name + ] + assert len(apis) == 1 + api_id = apis[0]["id"] + + # construct API endpoint URL + url = api_invoke_url(api_id, stage="dev", path="/test") + + # invoke API endpoint, assert results + result = requests.post(url, data="test 123") + assert result.ok + content = json.loads(to_str(result.content)) + assert content["data"] == "test 123" + assert content["url"].endswith("/post") + + +@pytest.mark.skip( + reason="The v2 provider appears to instead return the correct url: " + "https://e1i3grfiws.execute-api.us-east-1.localhost.localstack.cloud/prod/" +) +@markers.aws.only_localstack +def test_url_output(httpserver, deploy_cfn_template): + httpserver.expect_request("").respond_with_data(b"", 200) + api_name = f"rest-api-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway-url-output.yaml" + ), + template_mapping={ + "api_name": api_name, + "integration_uri": httpserver.url_for("/{proxy}"), + }, + ) + + assert len(stack.outputs) == 2 + api_id = stack.outputs["ApiV1IdOutput"] + api_url = stack.outputs["ApiV1UrlOutput"] + assert api_id + assert api_url + assert api_id in api_url + + assert f"https://{api_id}.execute-api.{constants.LOCALHOST_HOSTNAME}:4566" in api_url + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-method-post.methodIntegration.connectionType", # TODO: maybe because this is a MOCK integration + ] +) +def test_cfn_with_apigateway_resources(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/template35.yaml" + ) + ) + apis = [ + api + for api in aws_client.apigateway.get_rest_apis()["items"] + if api["name"] == "celeste-Gateway-local" + ] + assert len(apis) == 1 + api_id = apis[0]["id"] + + resources = [ + res + for res in aws_client.apigateway.get_resources(restApiId=api_id)["items"] + if res.get("pathPart") == "account" + ] + + assert len(resources) == 1 + + resp = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=resources[0]["id"], httpMethod="POST" + ) + snapshot.match("get-method-post", resp) + + models = aws_client.apigateway.get_models(restApiId=api_id) + models["items"].sort(key=itemgetter("name")) + snapshot.match("get-models", models) + + schemas = [model["schema"] for model in models["items"]] + for schema in schemas: + # assert that we can JSON load the schema, and that the schema is a valid JSON + assert isinstance(json.loads(schema), dict) + + stack.destroy() + + # TODO: Resolve limitations with stack.destroy in v2 engine. + # apis = [ + # api + # for api in aws_client.apigateway.get_rest_apis()["items"] + # if api["name"] == "celeste-Gateway-local" + # ] + # assert not apis + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-resources.items..resourceMethods.ANY", # TODO: empty in AWS + ] +) +def test_cfn_deploy_apigateway_models(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway_models.json" + ) + ) + + api_id = stack.outputs["RestApiId"] + + resources = aws_client.apigateway.get_resources(restApiId=api_id) + resources["items"].sort(key=itemgetter("path")) + snapshot.match("get-resources", resources) + + models = aws_client.apigateway.get_models(restApiId=api_id) + models["items"].sort(key=itemgetter("name")) + snapshot.match("get-models", models) + + request_validators = aws_client.apigateway.get_request_validators(restApiId=api_id) + snapshot.match("get-request-validators", request_validators) + + for resource in resources["items"]: + if resource["path"] == "/validated": + resp = aws_client.apigateway.get_method( + restApiId=api_id, resourceId=resource["id"], httpMethod="ANY" + ) + snapshot.match("get-method-any", resp) + + # construct API endpoint URL + url = api_invoke_url(api_id, stage="local", path="/validated") + + # invoke API endpoint, assert results + valid_data = {"string_field": "string", "integer_field": 123456789} + + result = requests.post(url, json=valid_data) + assert result.ok + + # invoke API endpoint, assert results + invalid_data = {"string_field": "string"} + + result = requests.post(url, json=invalid_data) + assert result.status_code == 400 + + result = requests.get(url) + assert result.status_code == 400 + + +@markers.aws.validated +def test_cfn_deploy_apigateway_integration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("cacheNamespace")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/apigateway_integration_no_authorizer.yml", + ), + max_wait=120, + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "stack-name")) + + rest_api_id = stack.outputs["RestApiId"] + rest_api = aws_client.apigateway.get_rest_api(restApiId=rest_api_id) + snapshot.match("rest_api", rest_api) + snapshot.add_transformer(snapshot.transform.key_value("rootResourceId")) + + resource_id = stack.outputs["ResourceId"] + method = aws_client.apigateway.get_method( + restApiId=rest_api_id, resourceId=resource_id, httpMethod="GET" + ) + snapshot.match("method", method) + # TODO: snapshot the authorizer too? it's not attached to the REST API + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.resources.items..resourceMethods.GET", # TODO: after importing, AWS returns them empty? + # TODO: missing from LS response + "$.get-stage.methodSettings", + "$.get-stage.tags", + "$..binaryMediaTypes", + ] +) +def test_cfn_deploy_apigateway_from_s3_swagger( + deploy_cfn_template, snapshot, aws_client, s3_bucket +): + snapshot.add_transformer(snapshot.transform.key_value("deploymentId")) + # put the swagger file in S3 + swagger_template = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../files/pets.json") + ) + key_name = "swagger-template-pets.json" + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body=swagger_template) + object_etag = response["ETag"] + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway_integration_from_s3.yml" + ), + parameters={ + "S3BodyBucket": s3_bucket, + "S3BodyKey": key_name, + "S3BodyETag": object_etag, + }, + max_wait=120, + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "stack-name")) + + rest_api_id = stack.outputs["RestApiId"] + rest_api = aws_client.apigateway.get_rest_api(restApiId=rest_api_id) + snapshot.match("rest-api", rest_api) + + resources = aws_client.apigateway.get_resources(restApiId=rest_api_id) + resources["items"] = sorted(resources["items"], key=itemgetter("path")) + snapshot.match("resources", resources) + + get_stage = aws_client.apigateway.get_stage(restApiId=rest_api_id, stageName="local") + snapshot.match("get-stage", get_stage) + + +@markers.aws.validated +def test_cfn_apigateway_rest_api(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway.json" + ) + ) + + rs = aws_client.apigateway.get_rest_apis() + apis = [item for item in rs["items"] if item["name"] == "DemoApi_dev"] + assert not apis + + stack.destroy() + + stack_2 = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway.json" + ), + parameters={"Create": "True"}, + ) + rs = aws_client.apigateway.get_rest_apis() + apis = [item for item in rs["items"] if item["name"] == "DemoApi_dev"] + assert len(apis) == 1 + + rs = aws_client.apigateway.get_models(restApiId=apis[0]["id"]) + assert len(rs["items"]) == 3 + + stack_2.destroy() + + # TODO: Resolve limitations with stack.destroy in v2 engine. + # rs = aws_client.apigateway.get_rest_apis() + # apis = [item for item in rs["items"] if item["name"] == "DemoApi_dev"] + # assert not apis + + +@markers.aws.validated +def test_account(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway_account.yml" + ) + ) + + account_info = aws_client.apigateway.get_account() + assert account_info["cloudwatchRoleArn"] == stack.outputs["RoleArn"] + + # Assert that after deletion of stack, the apigw account is not updated + stack.destroy() + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack.stack_name) + account_info = aws_client.apigateway.get_account() + assert account_info["cloudwatchRoleArn"] == stack.outputs["RoleArn"] + + +@markers.aws.validated +@pytest.mark.skip( + reason="CFNV2:Other ApiDeployment creation fails due to the REST API not having a method set" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..tags.'aws:cloudformation:logical-id'", + "$..tags.'aws:cloudformation:stack-id'", + "$..tags.'aws:cloudformation:stack-name'", + ] +) +def test_update_usage_plan(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("apiId"), + snapshot.transform.key_value("stage"), + snapshot.transform.key_value("id"), + snapshot.transform.key_value("name"), + snapshot.transform.key_value("aws:cloudformation:stack-name"), + snapshot.transform.resource_name(), + ] + ) + rest_api_name = f"api-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway_usage_plan.yml" + ), + parameters={"QuotaLimit": "5000", "RestApiName": rest_api_name, "TagValue": "value1"}, + ) + + usage_plan = aws_client.apigateway.get_usage_plan(usagePlanId=stack.outputs["UsagePlanId"]) + snapshot.match("usage-plan", usage_plan) + assert usage_plan["quota"]["limit"] == 5000 + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template=load_file( + os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway_usage_plan.yml" + ) + ), + parameters={ + "QuotaLimit": "7000", + "RestApiName": rest_api_name, + "TagValue": "value-updated", + }, + ) + + usage_plan = aws_client.apigateway.get_usage_plan(usagePlanId=stack.outputs["UsagePlanId"]) + snapshot.match("updated-usage-plan", usage_plan) + assert usage_plan["quota"]["limit"] == 7000 + + +@pytest.mark.skip( + reason="CFNV2:Other ApiDeployment creation fails due to the REST API not having a method set" +) +@markers.snapshot.skip_snapshot_verify( + paths=["$..createdDate", "$..description", "$..lastUpdatedDate", "$..tags"] +) +@markers.aws.validated +def test_update_apigateway_stage(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("deploymentId"), + snapshot.transform.key_value("aws:cloudformation:stack-name"), + snapshot.transform.resource_name(), + ] + ) + + api_name = f"api-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway_update_stage.yml" + ), + parameters={"RestApiName": api_name}, + ) + api_id = stack.outputs["RestApiId"] + stage = aws_client.apigateway.get_stage(stageName="dev", restApiId=api_id) + snapshot.match("created-stage", stage) + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/apigateway_update_stage.yml" + ), + parameters={ + "Description": "updated-description", + "Method": "POST", + "RestApiName": api_name, + }, + ) + # Changes to the stage or one of the methods it depends on does not trigger a redeployment + stage = aws_client.apigateway.get_stage(stageName="dev", restApiId=api_id) + snapshot.match("updated-stage", stage) + + +@markers.aws.validated +def test_api_gateway_with_policy_as_dict(deploy_cfn_template, snapshot, aws_client): + template = """ + Parameters: + RestApiName: + Type: String + Resources: + MyApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Ref RestApiName + Policy: + Version: "2012-10-17" + Statement: + - Sid: AllowInvokeAPI + Action: "*" + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Outputs: + MyApiId: + Value: !Ref MyApi + """ + + rest_api_name = f"api-{short_uid()}" + stack = deploy_cfn_template( + template=template, + parameters={"RestApiName": rest_api_name}, + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "stack-name")) + + rest_api = aws_client.apigateway.get_rest_api(restApiId=stack.outputs.get("MyApiId")) + + # note: API Gateway seems to perform double-escaping of the policy document for REST APIs, if specified as dict + policy = to_bytes(rest_api["policy"]).decode("unicode_escape") + rest_api["policy"] = json.loads(policy) + + snapshot.match("rest-api", rest_api) + + +@pytest.mark.skip( + reason="CFNV2:Other lambda function fails on creation due to invalid function name" +) +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$.put-ssm-param.Tier", + "$.get-resources.items..resourceMethods.GET", + "$.get-resources.items..resourceMethods.OPTIONS", + "$..methodIntegration.cacheNamespace", + "$.get-authorizers.items..authorizerResultTtlInSeconds", + ] +) +def test_rest_api_serverless_ref_resolving( + deploy_cfn_template, snapshot, aws_client, create_parameter, create_lambda_function +): + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.resource_name(), + snapshot.transform.key_value("cacheNamespace"), + snapshot.transform.key_value("uri"), + snapshot.transform.key_value("authorizerUri"), + ] + ) + create_parameter(Name="/test-stack/testssm/random-value", Value="x-test-header", Type="String") + + fn_name = f"test-{short_uid()}" + lambda_authorizer = create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + ) + + create_parameter( + Name="/test-stack/testssm/lambda-arn", + Value=lambda_authorizer["CreateFunctionResponse"]["FunctionArn"], + Type="String", + ) + + stack = deploy_cfn_template( + template=load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/apigateway_serverless_api_resolving.yml", + ) + ), + parameters={"AllowedOrigin": "http://localhost:8000"}, + ) + rest_api_id = stack.outputs.get("ApiGatewayApiId") + + resources = aws_client.apigateway.get_resources(restApiId=rest_api_id) + snapshot.match("get-resources", resources) + + authorizers = aws_client.apigateway.get_authorizers(restApiId=rest_api_id) + snapshot.match("get-authorizers", authorizers) + + root_resource = resources["items"][0] + + for http_method in root_resource["resourceMethods"]: + method = aws_client.apigateway.get_method( + restApiId=rest_api_id, resourceId=root_resource["id"], httpMethod=http_method + ) + snapshot.match(f"get-method-{http_method}", method) + + +class TestServerlessApigwLambda: + @pytest.mark.skip( + reason="Requires investigation into the stack not being available in the v2 provider" + ) + @markers.aws.validated + def test_serverless_like_deployment_with_update( + self, deploy_cfn_template, aws_client, cleanups + ): + """ + Regression test for serverless. Since adding a delete handler for the "AWS::ApiGateway::Deployment" resource, + the update was failing due to the delete raising an Exception because of a still connected Stage. + + This test recreates a simple recreated deployment procedure as done by "serverless" where + `serverless deploy` actually both creates a stack and then immediately updates it. + The second UpdateStack is then caused by another `serverless deploy`, e.g. when changing the lambda configuration + """ + + # 1. deploy create + template_content = load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/serverless-apigw-lambda.create.json", + ) + ) + stack_name = f"slsstack-{short_uid()}" + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + stack = aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=template_content, + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack["StackId"] + ) + + # 2. update first + # get deployed bucket name + outputs = aws_client.cloudformation.describe_stacks(StackName=stack["StackId"])["Stacks"][ + 0 + ]["Outputs"] + outputs = {k["OutputKey"]: k["OutputValue"] for k in outputs} + bucket_name = outputs["ServerlessDeploymentBucketName"] + + # upload zip file to s3 bucket + # "serverless/test-service/local/1708076358388-2024-02-16T09:39:18.388Z/api.zip" + handler1_filename = os.path.join(os.path.dirname(__file__), "handlers/handler1/api.zip") + aws_client.s3.upload_file( + Filename=handler1_filename, + Bucket=bucket_name, + Key="serverless/test-service/local/1708076358388-2024-02-16T09:39:18.388Z/api.zip", + ) + + template_content = load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/serverless-apigw-lambda.update.json", + ) + ) + stack = aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template_content, + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack["StackId"] + ) + + get_fn_1 = aws_client.lambda_.get_function(FunctionName="test-service-local-api") + assert get_fn_1["Configuration"]["Handler"] == "index.handler" + + # # 3. update second + # # upload zip file to s3 bucket + handler2_filename = os.path.join(os.path.dirname(__file__), "handlers/handler2/api.zip") + aws_client.s3.upload_file( + Filename=handler2_filename, + Bucket=bucket_name, + Key="serverless/test-service/local/1708076568092-2024-02-16T09:42:48.092Z/api.zip", + ) + + template_content = load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../../templates/serverless-apigw-lambda.update2.json", + ) + ) + stack = aws_client.cloudformation.update_stack( + StackName=stack_name, + TemplateBody=template_content, + Capabilities=["CAPABILITY_NAMED_IAM"], + ) + aws_client.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack["StackId"] + ) + get_fn_2 = aws_client.lambda_.get_function(FunctionName="test-service-local-api") + assert get_fn_2["Configuration"]["Handler"] == "index.handler2" diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.snapshot.json new file mode 100644 index 0000000000000..bffa8bf5ed3af --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.snapshot.json @@ -0,0 +1,682 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_integration": { + "recorded-date": "15-07-2025, 19:29:28", + "recorded-content": { + "rest_api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "method": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "connectionType": "INTERNET", + "httpMethod": "GET", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent,X-Amzn-Trace-Id'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,GET,POST'", + "method.response.header.Access-Control-Allow-Origin": "'*'" + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "HTTP_PROXY", + "uri": "http://www.example.com" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Headers": true, + "method.response.header.Access-Control-Allow-Methods": true, + "method.response.header.Access-Control-Allow-Origin": true + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_api_gateway_with_policy_as_dict": { + "recorded-date": "15-07-2025, 19:29:58", + "recorded-content": { + "rest-api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "policy": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Resource": "*", + "Sid": "AllowInvokeAPI" + } + ], + "Version": "2012-10-17" + }, + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "MyApi", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_from_s3_swagger": { + "recorded-date": "15-07-2025, 20:32:03", + "recorded-content": { + "rest-api": { + "apiKeySource": "HEADER", + "binaryMediaTypes": [ + "image/png", + "image/jpg", + "image/gif", + "application/pdf" + ], + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "REGIONAL" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "ApiGatewayRestApi", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "version": "1.0.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/pets", + "pathPart": "pets", + "resourceMethods": { + "GET": {} + } + }, + { + "id": "", + "parentId": "", + "path": "/pets/{petId}", + "pathPart": "{petId}", + "resourceMethods": { + "GET": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "description": "Test Stage 123", + "lastUpdatedDate": "datetime", + "methodSettings": { + "*/*": { + "cacheDataEncrypted": false, + "cacheTtlInSeconds": 300, + "cachingEnabled": false, + "dataTraceEnabled": true, + "loggingLevel": "ERROR", + "metricsEnabled": true, + "requireAuthorizationForCacheControl": true, + "throttlingBurstLimit": 5000, + "throttlingRateLimit": 10000.0, + "unauthorizedCacheControlHeaderStrategy": "SUCCEED_WITH_RESPONSE_HEADER" + } + }, + "stageName": "local", + "tags": { + "aws:cloudformation:logical-id": "ApiGWStage", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack/stack-name/", + "aws:cloudformation:stack-name": "stack-name" + }, + "tracingEnabled": true, + "variables": { + "TestCasing": "myvar", + "testCasingTwo": "myvar2", + "testlowcasing": "myvar3" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_models": { + "recorded-date": "21-06-2024, 00:09:05", + "recorded-content": { + "get-resources": { + "items": [ + { + "id": "", + "path": "/" + }, + { + "id": "", + "parentId": "", + "path": "/validated", + "pathPart": "validated", + "resourceMethods": { + "ANY": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "description": "This is a default empty schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "This is a default error schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "", + "type": "object", + "properties": { + "integer_field": { + "type": "number" + }, + "string_field": { + "type": "string" + } + }, + "required": [ + "string_field", + "integer_field" + ] + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-request-validators": { + "items": [ + { + "id": "", + "name": "", + "validateRequestBody": true, + "validateRequestParameters": false + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-method-any": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "ANY", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "statusCode": "200" + } + }, + "passthroughBehavior": "NEVER", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "statusCode": "200" + } + }, + "requestModels": { + "application/json": "" + }, + "requestValidatorId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_with_apigateway_resources": { + "recorded-date": "20-06-2024, 23:54:26", + "recorded-content": { + "get-method-post": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "POST", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "202": { + "responseTemplates": { + "application/json": { + "operation": "celeste_account_create", + "data": { + "key": "123e4567-e89b-12d3-a456-426614174000", + "secret": "123e4567-e89b-12d3-a456-426614174000" + } + } + }, + "selectionPattern": "2\\d{2}", + "statusCode": "202" + }, + "404": { + "responseTemplates": { + "application/json": { + "message": "Not Found" + } + }, + "selectionPattern": "404", + "statusCode": "404" + }, + "500": { + "responseTemplates": { + "application/json": { + "message": "Unknown " + } + }, + "selectionPattern": "5\\d{2}", + "statusCode": "500" + } + }, + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "requestTemplates": { + "application/json": "" + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "202": { + "responseModels": { + "application/json": "" + }, + "statusCode": "202" + }, + "500": { + "responseModels": { + "application/json": "" + }, + "statusCode": "500" + } + }, + "operationName": "create_account", + "requestParameters": { + "method.request.path.account": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-models": { + "items": [ + { + "contentType": "application/json", + "description": "This is a default empty schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object" + } + }, + { + "contentType": "application/json", + "description": "This is a default error schema model", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": " Schema", + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "", + "schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "AccountCreate", + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "email": { + "format": "email", + "type": "string" + } + } + } + }, + { + "contentType": "application/json", + "id": "", + "name": "", + "schema": {} + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_rest_api_serverless_ref_resolving": { + "recorded-date": "06-07-2023, 21:01:08", + "recorded-content": { + "get-resources": { + "items": [ + { + "id": "", + "path": "/", + "resourceMethods": { + "GET": {}, + "OPTIONS": {} + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-authorizers": { + "items": [ + { + "authType": "custom", + "authorizerUri": "", + "id": "", + "identitySource": "method.request.header.Authorization", + "name": "", + "type": "TOKEN" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-method-GET": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "GET", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-method-OPTIONS": { + "apiKeyRequired": false, + "authorizationType": "NONE", + "httpMethod": "OPTIONS", + "methodIntegration": { + "cacheKeyParameters": [], + "cacheNamespace": "", + "integrationResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Credentials": "'true'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,Authorization,x-test-header'", + "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,POST,GET,PUT'", + "method.response.header.Access-Control-Allow-Origin": "'http://localhost:8000'" + }, + "responseTemplates": { + "application/json": {} + }, + "statusCode": "200" + } + }, + "passthroughBehavior": "WHEN_NO_MATCH", + "requestTemplates": { + "application/json": { + "statusCode": 200 + } + }, + "timeoutInMillis": 29000, + "type": "MOCK" + }, + "methodResponses": { + "200": { + "responseParameters": { + "method.response.header.Access-Control-Allow-Credentials": false, + "method.response.header.Access-Control-Allow-Headers": false, + "method.response.header.Access-Control-Allow-Methods": false, + "method.response.header.Access-Control-Allow-Origin": false + }, + "statusCode": "200" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_update_usage_plan": { + "recorded-date": "13-09-2024, 09:57:21", + "recorded-content": { + "usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "" + } + ], + "id": "", + "name": "", + "quota": { + "limit": 5000, + "offset": 0, + "period": "MONTH" + }, + "tags": { + "aws:cloudformation:logical-id": "UsagePlan", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "test": "value1", + "test2": "hardcoded" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated-usage-plan": { + "apiStages": [ + { + "apiId": "", + "stage": "" + } + ], + "id": "", + "name": "", + "quota": { + "limit": 7000, + "offset": 0, + "period": "MONTH" + }, + "tags": { + "aws:cloudformation:logical-id": "UsagePlan", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "test": "value-updated", + "test2": "hardcoded" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_update_apigateway_stage": { + "recorded-date": "07-11-2024, 05:35:20", + "recorded-content": { + "created-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tags": { + "aws:cloudformation:logical-id": "Stage", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated-stage": { + "cacheClusterEnabled": false, + "cacheClusterStatus": "NOT_AVAILABLE", + "createdDate": "datetime", + "deploymentId": "", + "lastUpdatedDate": "datetime", + "methodSettings": {}, + "stageName": "dev", + "tags": { + "aws:cloudformation:logical-id": "Stage", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "tracingEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.validation.json new file mode 100644 index 0000000000000..43ad31fc92767 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.validation.json @@ -0,0 +1,50 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::TestServerlessApigwLambda::test_serverless_like_deployment_with_update": { + "last_validated_date": "2024-02-19T08:55:12+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_api_gateway_with_policy_as_dict": { + "last_validated_date": "2025-07-15T19:30:16+00:00", + "durations_in_seconds": { + "setup": 0.5, + "call": 11.81, + "teardown": 17.53, + "total": 29.84 + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_apigateway_rest_api": { + "last_validated_date": "2024-06-25T18:12:55+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_from_s3_swagger": { + "last_validated_date": "2025-07-16T00:25:05+00:00", + "durations_in_seconds": { + "setup": 1.15, + "call": 18.86, + "teardown": 8.08, + "total": 28.09 + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_integration": { + "last_validated_date": "2025-07-15T19:29:44+00:00", + "durations_in_seconds": { + "setup": 0.57, + "call": 26.97, + "teardown": 15.37, + "total": 42.91 + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_deploy_apigateway_models": { + "last_validated_date": "2024-06-21T00:09:05+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_cfn_with_apigateway_resources": { + "last_validated_date": "2024-06-20T23:54:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_rest_api_serverless_ref_resolving": { + "last_validated_date": "2023-07-06T19:01:08+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_update_apigateway_stage": { + "last_validated_date": "2024-11-07T05:35:20+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py::test_update_usage_plan": { + "last_validated_date": "2024-09-13T09:57:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py new file mode 100644 index 0000000000000..89e176d0f1cde --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py @@ -0,0 +1,149 @@ +import os + +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +class TestCdkInit: + @pytest.mark.skip( + reason="CFNV2:Destroy each test passes individually but because we don't delete resources, running all parameterized options fails" + ) + @pytest.mark.parametrize("bootstrap_version", ["10", "11", "12"]) + @markers.aws.validated + def test_cdk_bootstrap(self, deploy_cfn_template, bootstrap_version, aws_client): + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + f"../../../../../templates/cdk_bootstrap_v{bootstrap_version}.yaml", + ), + parameters={"FileAssetsBucketName": f"cdk-bootstrap-{short_uid()}"}, + ) + init_stack_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cdk_init_template.yaml" + ) + ) + assert init_stack_result.outputs["BootstrapVersionOutput"] == bootstrap_version + stack_res = aws_client.cloudformation.describe_stack_resources( + StackName=init_stack_result.stack_id, LogicalResourceId="CDKMetadata" + ) + assert len(stack_res["StackResources"]) == 1 + assert stack_res["StackResources"][0]["LogicalResourceId"] == "CDKMetadata" + + @pytest.mark.skip(reason="CFNV2:Provider") + @markers.aws.validated + def test_cdk_bootstrap_redeploy(self, aws_client, cleanup_stacks, cleanup_changesets, cleanups): + """Test that simulates a sequence of commands executed by CDK when running 'cdk bootstrap' twice""" + + stack_name = f"CDKToolkit-{short_uid()}" + change_set_name = f"cdk-deploy-change-set-{short_uid()}" + + def clean_resources(): + cleanup_stacks([stack_name]) + cleanup_changesets([change_set_name]) + + cleanups.append(clean_resources) + + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/cdk_bootstrap.yml") + ) + aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=template_body, + ChangeSetType="CREATE", + Capabilities=["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"], + Description="CDK Changeset for execution 731ed7da-8b2d-49c6-bca3-4698b6875954", + Parameters=[ + { + "ParameterKey": "BootstrapVariant", + "ParameterValue": "AWS CDK: Default Resources", + }, + {"ParameterKey": "TrustedAccounts", "ParameterValue": ""}, + {"ParameterKey": "TrustedAccountsForLookup", "ParameterValue": ""}, + {"ParameterKey": "CloudFormationExecutionPolicies", "ParameterValue": ""}, + {"ParameterKey": "FileAssetsBucketKmsKeyId", "ParameterValue": "AWS_MANAGED_KEY"}, + {"ParameterKey": "PublicAccessBlockConfiguration", "ParameterValue": "true"}, + {"ParameterKey": "Qualifier", "ParameterValue": "hnb659fds"}, + {"ParameterKey": "UseExamplePermissionsBoundary", "ParameterValue": "false"}, + ], + ) + aws_client.cloudformation.describe_change_set( + StackName=stack_name, ChangeSetName=change_set_name + ) + + aws_client.cloudformation.get_waiter("change_set_create_complete").wait( + StackName=stack_name, ChangeSetName=change_set_name + ) + + aws_client.cloudformation.execute_change_set( + StackName=stack_name, ChangeSetName=change_set_name + ) + + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + aws_client.cloudformation.describe_stacks(StackName=stack_name) + + # When CDK toolstrap command is executed again it just confirms that the template is the same + aws_client.sts.get_caller_identity() + aws_client.cloudformation.get_template(StackName=stack_name, TemplateStage="Original") + + # TODO: create scenario where the template is different to catch cdk behavior + + +class TestCdkSampleApp: + @pytest.mark.skip(reason="CFNV2:Provider") + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.Policy.Statement..Condition", + "$..Attributes.Policy.Statement..Resource", + "$..StackResourceSummaries..PhysicalResourceId", + ] + ) + @markers.aws.validated + def test_cdk_sample(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer( + SortingTransformer("StackResourceSummaries", lambda x: x["LogicalResourceId"]), + priority=-1, + ) + + deploy = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_cdk_sample_app.yaml" + ), + max_wait=120, + ) + + queue_url = deploy.outputs["QueueUrl"] + + queue_attr_policy = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["Policy"] + ) + snapshot.match("queue_attr_policy", queue_attr_policy) + stack_resources = aws_client.cloudformation.list_stack_resources(StackName=deploy.stack_id) + snapshot.match("stack_resources", stack_resources) + + # physical resource id of the queue policy AWS::SQS::QueuePolicy + queue_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deploy.stack_id, LogicalResourceId="CdksampleQueuePolicyFA91005A" + ) + snapshot.add_transformer( + snapshot.transform.regex( + queue_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + # TODO: make sure phys id of the resource conforms to this format: stack-d98dcad5-CdksampleQueuePolicyFA91005A-1WYVV4PMCWOYI diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.snapshot.json new file mode 100644 index 0000000000000..2068d98220c4a --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.snapshot.json @@ -0,0 +1,81 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.py::TestCdkSampleApp::test_cdk_sample": { + "recorded-date": "04-11-2022, 15:15:44", + "recorded-content": { + "queue_attr_policy": { + "Attributes": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Action": "sqs:SendMessage", + "Resource": "arn::sqs::111111111111:", + "Condition": { + "ArnEquals": { + "aws:SourceArn": "arn::sns::111111111111:" + } + } + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResourceSummaries": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleQueue3139C8CD", + "PhysicalResourceId": "https://sqs..amazonaws.com/111111111111/", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SQS::Queue" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleQueueCdksampleStackCdksampleTopicCB3FDFDDC0BCF47C", + "PhysicalResourceId": "arn::sns::111111111111::", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Subscription" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleQueuePolicyFA91005A", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SQS::QueuePolicy" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "CdksampleTopic7AD235A4", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.validation.json new file mode 100644 index 0000000000000..b627e80340018 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cdk.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[10]": { + "last_validated_date": "2024-06-25T18:37:34+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[11]": { + "last_validated_date": "2024-06-25T18:40:57+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkInit::test_cdk_bootstrap[12]": { + "last_validated_date": "2024-06-25T18:44:21+00:00" + }, + "tests/aws/services/cloudformation/resources/test_cdk.py::TestCdkSampleApp::test_cdk_sample": { + "last_validated_date": "2022-11-04T14:15:44+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py new file mode 100644 index 0000000000000..65f79e38e23a2 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py @@ -0,0 +1,137 @@ +import logging +import os +import textwrap +import time +import uuid +from threading import Thread +from typing import TYPE_CHECKING + +import pytest +import requests + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +if TYPE_CHECKING: + try: + from mypy_boto3_ssm import SSMClient + except ImportError: + pass + +LOG = logging.getLogger(__name__) + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + +PARAMETER_NAME = "wait-handle-url" + + +class SignalSuccess(Thread): + def __init__(self, client: "SSMClient"): + Thread.__init__(self) + self.client = client + self.session = requests.Session() + self.should_break = False + + def run(self): + while not self.should_break: + try: + LOG.debug("fetching parameter") + res = self.client.get_parameter(Name=PARAMETER_NAME) + url = res["Parameter"]["Value"] + LOG.info("signalling url %s", url) + + payload = { + "Status": "SUCCESS", + "Reason": "Wait condition reached", + "UniqueId": str(uuid.uuid4()), + "Data": "Application has completed configuration.", + } + r = self.session.put(url, json=payload) + LOG.debug("status from signalling: %s", r.status_code) + r.raise_for_status() + LOG.debug("status signalled") + break + except self.client.exceptions.ParameterNotFound: + LOG.warning("parameter not available, trying again") + time.sleep(5) + except Exception: + LOG.exception("got python exception") + raise + + def stop(self): + self.should_break = True + + +@markers.snapshot.skip_snapshot_verify(paths=["$..WaitConditionName"]) +@markers.aws.validated +def test_waitcondition(deploy_cfn_template, snapshot, aws_client): + """ + Complicated test, since we have a wait condition that must signal + a successful value to before the stack finishes. We use the + fact that CFn will deploy the SSM parameter before moving on + to the wait condition itself, so in a background thread we + try to set the value to success so that the stack will + deploy correctly. + """ + signal_thread = SignalSuccess(aws_client.ssm) + signal_thread.daemon = True + signal_thread.start() + + try: + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_waitcondition.yaml" + ), + parameters={"ParameterName": PARAMETER_NAME}, + ) + finally: + signal_thread.stop() + + wait_handle_id = stack.outputs["WaitHandleId"] + wait_condition_name = stack.outputs["WaitConditionRef"] + + # TODO: more stringent tests + assert wait_handle_id is not None + # snapshot.match("waithandle_ref", wait_handle_id) + snapshot.match("waitcondition_ref", {"WaitConditionName": wait_condition_name}) + + +@markers.aws.validated +def test_create_macro(deploy_cfn_template, create_lambda_function, snapshot, aws_client): + macro_name = f"macro-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(macro_name, "")) + + function_name = f"macro_lambda_{short_uid()}" + + handler_code = textwrap.dedent( + """ + def handler(event, context): + pass + """ + ) + + create_lambda_function( + func_name=function_name, + handler_file=handler_code, + runtime=Runtime.python3_12, + ) + + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/macro_resource.yml" + ) + assert os.path.isfile(template_path) + stack = deploy_cfn_template( + template_path=template_path, + parameters={ + "FunctionName": function_name, + "MacroName": macro_name, + }, + ) + + snapshot.match("stack-outputs", stack.outputs) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.snapshot.json new file mode 100644 index 0000000000000..3c607af7f69ec --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.snapshot.json @@ -0,0 +1,24 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py::test_waitconditionhandle": { + "recorded-date": "17-05-2023, 15:55:08", + "recorded-content": { + "waithandle_ref": "https://cloudformation-waitcondition-.s3..amazonaws.com/arn%3Aaws%3Acloudformation%3A%3A111111111111%3Astack/stack-03ad7786/c7b3de40-f4c2-11ed-b84b-0a57ddc705d2/WaitHandle?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20230517T145504Z&X-Amz-SignedHeaders=host&X-Amz-Expires=86399&X-Amz-Credential=AKIAYYGVRKE7CKDBHLUS%2F20230517%2F%2Fs3%2Faws4_request&X-Amz-Signature=3c79384f6647bd2c655ac78e6811ea0fff9b3a52a9bd751005d35f2a04f6533c" + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py::test_waitcondition": { + "recorded-date": "18-05-2023, 11:09:21", + "recorded-content": { + "waitcondition_ref": { + "WaitConditionName": "arn::cloudformation::111111111111:stack/stack-6cc1b50e/f9764ac0-f563-11ed-82f7-061d4a7b8a1e/WaitHandle" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py::test_create_macro": { + "recorded-date": "09-06-2023, 14:30:11", + "recorded-content": { + "stack-outputs": { + "MacroRef": "" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.validation.json new file mode 100644 index 0000000000000..0aeaeefb84d2e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py::test_create_macro": { + "last_validated_date": "2023-06-09T12:30:11+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudformation.py::test_waitcondition": { + "last_validated_date": "2023-05-18T09:09:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py new file mode 100644 index 0000000000000..d1acf12c8a064 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py @@ -0,0 +1,118 @@ +import json +import os +import re + +import pytest +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import PATTERN_ARN +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_alarm_creation(deploy_cfn_template, snapshot): + snapshot.add_transformer(snapshot.transform.resource_name()) + alarm_name = f"alarm-{short_uid()}" + + template = json.dumps( + { + "Resources": { + "Alarm": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "AlarmName": alarm_name, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "EvaluationPeriods": 1, + "MetricName": "Errors", + "Namespace": "AWS/Lambda", + "Period": 300, + "Statistic": "Average", + "Threshold": 1, + }, + } + }, + "Outputs": { + "AlarmName": {"Value": {"Ref": "Alarm"}}, + "AlarmArnFromAtt": {"Value": {"Fn::GetAtt": "Alarm.Arn"}}, + }, + } + ) + + outputs = deploy_cfn_template(template=template).outputs + snapshot.match("alarm_outputs", outputs) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..StateReason", + "$..StateReasonData", + "$..StateValue", + ] +) +def test_composite_alarm_creation(aws_client, deploy_cfn_template, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Region", "region-name-full")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_cw_composite_alarm.yml" + ), + ) + composite_alarm_name = stack.outputs["CompositeAlarmName"] + + def alarm_action_name_transformer(key: str, val: str): + if key == "AlarmActions" and isinstance(val, list) and len(val) == 1: + # we expect only one item in the list + value = val[0] + match = re.match(PATTERN_ARN, value) + if match: + res = match.groups()[-1] + if ":" in res: + return res.split(":")[-1] + return res + return None + + snapshot.add_transformer( + KeyValueBasedTransformer(alarm_action_name_transformer, "alarm-action-name"), + ) + response = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + snapshot.match("composite_alarm", response["CompositeAlarms"]) + + metric_alarm_name = stack.outputs["MetricAlarmName"] + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[metric_alarm_name]) + snapshot.match("metric_alarm", response["MetricAlarms"]) + + stack.destroy() + response = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + assert not response["CompositeAlarms"] + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[metric_alarm_name]) + assert not response["MetricAlarms"] + + +@markers.aws.validated +def test_alarm_ext_statistic(aws_client, deploy_cfn_template, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_cw_simple_alarm.yml" + ), + ) + alarm_name = stack.outputs["MetricAlarmName"] + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("simple_alarm", response["MetricAlarms"]) + + stack.destroy() + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + assert not response["MetricAlarms"] diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.snapshot.json new file mode 100644 index 0000000000000..171d60de6e8ac --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.snapshot.json @@ -0,0 +1,119 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_alarm_creation": { + "recorded-date": "25-09-2023, 10:28:42", + "recorded-content": { + "alarm_outputs": { + "AlarmArnFromAtt": "arn::cloudwatch::111111111111:alarm:", + "AlarmName": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_composite_alarm_creation": { + "recorded-date": "16-07-2024, 10:41:22", + "recorded-content": { + "composite_alarm": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:HighResourceUsage", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "Indicates that the system resource usage is high while no known deployment is in progress", + "AlarmName": "HighResourceUsage", + "AlarmRule": "(ALARM(HighCPUUsage) OR ALARM(HighMemoryUsage))", + "InsufficientDataActions": [], + "OKActions": [], + "StateReason": "arn::cloudwatch::111111111111:alarm:HighResourceUsage was created and its alarm rule evaluates to OK", + "StateReasonData": { + "triggeringAlarms": [ + { + "arn": "arn::cloudwatch::111111111111:alarm:HighCPUUsage", + "state": { + "value": "INSUFFICIENT_DATA", + "timestamp": "date" + } + }, + { + "arn": "arn::cloudwatch::111111111111:alarm:HighMemoryUsage", + "state": { + "value": "INSUFFICIENT_DATA", + "timestamp": "date" + } + } + ] + }, + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK", + "StateTransitionedTimestamp": "timestamp" + } + ], + "metric_alarm": [ + { + "AlarmName": "HighMemoryUsage", + "AlarmArn": "arn::cloudwatch::111111111111:alarm:HighMemoryUsage", + "AlarmDescription": "Memory usage is high", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "ActionsEnabled": true, + "OKActions": [], + "AlarmActions": [], + "InsufficientDataActions": [], + "StateValue": "INSUFFICIENT_DATA", + "StateReason": "Unchecked: Initial alarm creation", + "StateUpdatedTimestamp": "timestamp", + "MetricName": "MemoryUsage", + "Namespace": "CustomNamespace", + "Statistic": "Average", + "Dimensions": [], + "Period": 60, + "EvaluationPeriods": 1, + "Threshold": 65.0, + "ComparisonOperator": "GreaterThanThreshold", + "TreatMissingData": "breaching", + "StateTransitionedTimestamp": "timestamp" + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_alarm_no_statistic": { + "recorded-date": "27-11-2023, 10:08:09", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_alarm_ext_statistic": { + "recorded-date": "27-11-2023, 10:09:46", + "recorded-content": { + "simple_alarm": [ + { + "AlarmName": "", + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmDescription": "uses extended statistic", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "ActionsEnabled": true, + "OKActions": [], + "AlarmActions": [], + "InsufficientDataActions": [], + "StateValue": "INSUFFICIENT_DATA", + "StateReason": "Unchecked: Initial alarm creation", + "StateUpdatedTimestamp": "timestamp", + "MetricName": "Duration", + "Namespace": "", + "ExtendedStatistic": "p99", + "Dimensions": [ + { + "Name": "FunctionName", + "Value": "my-function" + } + ], + "Period": 300, + "Unit": "Count", + "EvaluationPeriods": 3, + "DatapointsToAlarm": 3, + "Threshold": 10.0, + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "TreatMissingData": "ignore", + "StateTransitionedTimestamp": "timestamp" + } + ] + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.validation.json new file mode 100644 index 0000000000000..9888ffd954a05 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_alarm_creation": { + "last_validated_date": "2023-09-25T08:28:42+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_alarm_ext_statistic": { + "last_validated_date": "2023-11-27T09:09:46+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_cloudwatch.py::test_composite_alarm_creation": { + "last_validated_date": "2024-07-16T10:43:30+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py new file mode 100644 index 0000000000000..4a0b900772ef6 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py @@ -0,0 +1,217 @@ +import os + +import aws_cdk as cdk +import pytest +from aws_cdk import aws_dynamodb as dynamodb +from aws_cdk.aws_dynamodb import BillingMode + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import get_partition +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_deploy_stack_with_dynamodb_table(deploy_cfn_template, aws_client, region_name): + env = "Staging" + ddb_table_name_prefix = f"ddb-table-{short_uid()}" + ddb_table_name = f"{ddb_table_name_prefix}-{env}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/deploy_template_3.yaml" + ), + parameters={"tableName": ddb_table_name_prefix, "env": env}, + ) + + assert stack.outputs["Arn"].startswith(f"arn:{get_partition(region_name)}:dynamodb") + assert f"table/{ddb_table_name}" in stack.outputs["Arn"] + assert stack.outputs["Name"] == ddb_table_name + + rs = aws_client.dynamodb.list_tables() + assert ddb_table_name in rs["TableNames"] + + stack.destroy() + rs = aws_client.dynamodb.list_tables() + assert ddb_table_name not in rs["TableNames"] + + +@markers.aws.validated +def test_globalindex_read_write_provisioned_throughput_dynamodb_table( + deploy_cfn_template, aws_client +): + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/deploy_template_3.yaml" + ), + parameters={"tableName": "dynamodb", "env": "test"}, + ) + + response = aws_client.dynamodb.describe_table(TableName="dynamodb-test") + + if response["Table"]["ProvisionedThroughput"]: + throughput = response["Table"]["ProvisionedThroughput"] + assert isinstance(throughput["ReadCapacityUnits"], int) + assert isinstance(throughput["WriteCapacityUnits"], int) + + for global_index in response["Table"]["GlobalSecondaryIndexes"]: + index_provisioned = global_index["ProvisionedThroughput"] + test_read_capacity = index_provisioned["ReadCapacityUnits"] + test_write_capacity = index_provisioned["WriteCapacityUnits"] + assert isinstance(test_read_capacity, int) + assert isinstance(test_write_capacity, int) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + "$..Table.DeletionProtectionEnabled", + ] +) +def test_default_name_for_table(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/dynamodb_table_defaults.yml" + ), + ) + + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + list_tags = aws_client.dynamodb.list_tags_of_resource(ResourceArn=stack.outputs["TableArn"]) + snapshot.match("list_tags_of_resource", list_tags) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + "$..Table.DeletionProtectionEnabled", + ] +) +@pytest.mark.parametrize("billing_mode", ["PROVISIONED", "PAY_PER_REQUEST"]) +def test_billing_mode_as_conditional(deploy_cfn_template, snapshot, aws_client, billing_mode): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + snapshot.add_transformer( + snapshot.transform.key_value("LatestStreamLabel", "latest-stream-label") + ) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/dynamodb_billing_conditional.yml" + ), + parameters={"BillingModeParameter": billing_mode}, + ) + + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.DeletionProtectionEnabled", + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + ] +) +def test_global_table(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/dynamodb_global_table.yml" + ), + ) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + stack.destroy() + + with pytest.raises(Exception) as ex: + aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + + error_code = ex.value.response["Error"]["Code"] + assert "ResourceNotFoundException" == error_code + + +@markers.aws.validated +def test_ttl_cdk(aws_client, snapshot, infrastructure_setup): + infra = infrastructure_setup(namespace="DDBTableTTL") + stack = cdk.Stack(infra.cdk_app, "DDBStackTTL") + + table = dynamodb.Table( + stack, + id="Table", + billing_mode=BillingMode.PAY_PER_REQUEST, + partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING), + removal_policy=cdk.RemovalPolicy.RETAIN, + time_to_live_attribute="expire_at", + ) + + cdk.CfnOutput(stack, "TableName", value=table.table_name) + + with infra.provisioner() as prov: + outputs = prov.get_stack_outputs(stack_name="DDBStackTTL") + table_name = outputs["TableName"] + table = aws_client.dynamodb.describe_time_to_live(TableName=table_name) + snapshot.match("table", table) + + +@markers.aws.validated +# We return field bellow, while AWS doesn't return them +@markers.snapshot.skip_snapshot_verify( + [ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + ] +) +def test_table_with_ttl_and_sse(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/dynamodb_table_sse_enabled.yml" + ), + ) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + snapshot.add_transformer(snapshot.transform.key_value("KMSMasterKeyArn", "kms-arn")) + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) + + +@markers.aws.validated +# We return the fields bellow, while AWS doesn't return them +@markers.snapshot.skip_snapshot_verify( + [ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + ] +) +def test_global_table_with_ttl_and_sse(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/dynamodb_global_table_sse_enabled.yml", + ), + ) + snapshot.add_transformer(snapshot.transform.key_value("TableName", "table-name")) + snapshot.add_transformer(snapshot.transform.key_value("KMSMasterKeyArn", "kms-arn")) + + response = aws_client.dynamodb.describe_table(TableName=stack.outputs["TableName"]) + snapshot.match("table_description", response) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.snapshot.json new file mode 100644 index 0000000000000..88af39a8953e1 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.snapshot.json @@ -0,0 +1,349 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_default_name_for_table": { + "recorded-date": "28-08-2023, 12:34:19", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "keyName", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "keyName", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_of_resource": { + "Tags": [ + { + "Key": "TagKey1", + "Value": "TagValue1" + }, + { + "Key": "TagKey2", + "Value": "TagValue2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_billing_mode_as_conditional[PROVISIONED]": { + "recorded-date": "28-08-2023, 12:34:41", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_billing_mode_as_conditional[PAY_PER_REQUEST]": { + "recorded-date": "28-08-2023, 12:35:02", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_global_table": { + "recorded-date": "01-12-2023, 12:54:13", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "keyName", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "keyName", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_ttl_cdk": { + "recorded-date": "14-02-2024, 13:29:07", + "recorded-content": { + "table": { + "TimeToLiveDescription": { + "AttributeName": "expire_at", + "TimeToLiveStatus": "ENABLED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_table_with_ttl_and_sse": { + "recorded-date": "12-03-2024, 15:42:18", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "pk", + "AttributeType": "S" + }, + { + "AttributeName": "sk", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "pk", + "KeyType": "HASH" + }, + { + "AttributeName": "sk", + "KeyType": "RANGE" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + }, + "SSEDescription": { + "KMSMasterKeyArn": "", + "SSEType": "KMS", + "Status": "ENABLED" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_global_table_with_ttl_and_sse": { + "recorded-date": "12-03-2024, 15:44:36", + "recorded-content": { + "table_description": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "gsi1pk", + "AttributeType": "S" + }, + { + "AttributeName": "gsi1sk", + "AttributeType": "S" + }, + { + "AttributeName": "pk", + "AttributeType": "S" + }, + { + "AttributeName": "sk", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "GlobalSecondaryIndexes": [ + { + "IndexArn": "arn::dynamodb::111111111111:table//index/GSI1", + "IndexName": "GSI1", + "IndexSizeBytes": 0, + "IndexStatus": "ACTIVE", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "gsi1pk", + "KeyType": "HASH" + }, + { + "AttributeName": "gsi1sk", + "KeyType": "RANGE" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + } + } + ], + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "pk", + "KeyType": "HASH" + }, + { + "AttributeName": "sk", + "KeyType": "RANGE" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "SSEDescription": { + "KMSMasterKeyArn": "", + "SSEType": "KMS", + "Status": "ENABLED" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableClassSummary": { + "TableClass": "STANDARD" + }, + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.validation.json new file mode 100644 index 0000000000000..a93ac64a42317 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_billing_mode_as_conditional[PAY_PER_REQUEST]": { + "last_validated_date": "2023-08-28T10:35:02+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_billing_mode_as_conditional[PROVISIONED]": { + "last_validated_date": "2023-08-28T10:34:41+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_default_name_for_table": { + "last_validated_date": "2023-08-28T10:34:19+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_global_table": { + "last_validated_date": "2023-12-01T11:54:13+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_global_table_with_ttl_and_sse": { + "last_validated_date": "2024-03-12T15:44:36+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_table_with_ttl_and_sse": { + "last_validated_date": "2024-03-12T15:42:18+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_dynamodb.py::test_ttl_cdk": { + "last_validated_date": "2024-02-14T13:29:07+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py new file mode 100644 index 0000000000000..a31bf40d39240 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py @@ -0,0 +1,376 @@ +import os + +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + +THIS_FOLDER = os.path.dirname(__file__) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..PropagatingVgws"]) +def test_simple_route_table_creation_without_vpc(deploy_cfn_template, aws_client, snapshot): + ec2 = aws_client.ec2 + stack = deploy_cfn_template( + template_path=os.path.join( + THIS_FOLDER, "../../../../../templates/ec2_route_table_isolated.yaml" + ), + ) + + route_table_id = stack.outputs["RouteTableId"] + route_table = ec2.describe_route_tables(RouteTableIds=[route_table_id])["RouteTables"][0] + + tags = route_table.pop("Tags") + tags_dict = {tag["Key"]: tag["Value"] for tag in tags if "aws:cloudformation" not in tag["Key"]} + snapshot.match("tags", tags_dict) + + snapshot.match("route_table", route_table) + snapshot.add_transformer(snapshot.transform.key_value("VpcId", "vpc-id")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId", "vpc-id")) + + stack.destroy() + with pytest.raises(ec2.exceptions.ClientError): + ec2.describe_route_tables(RouteTableIds=[route_table_id]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..PropagatingVgws"]) +def test_simple_route_table_creation(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + THIS_FOLDER, "../../../../../templates/ec2_route_table_simple.yaml" + ) + ) + + route_table_id = stack.outputs["RouteTableId"] + ec2 = aws_client.ec2 + route_table = ec2.describe_route_tables(RouteTableIds=[route_table_id])["RouteTables"][0] + + tags = route_table.pop("Tags") + tags_dict = {tag["Key"]: tag["Value"] for tag in tags if "aws:cloudformation" not in tag["Key"]} + snapshot.match("tags", tags_dict) + + snapshot.match("route_table", route_table) + snapshot.add_transformer(snapshot.transform.key_value("VpcId", "vpc-id")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId", "vpc-id")) + + stack.destroy() + with pytest.raises(ec2.exceptions.ClientError): + ec2.describe_route_tables(RouteTableIds=[route_table_id]) + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.aws.validated +def test_vpc_creates_default_sg(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../../../templates/ec2_vpc_default_sg.yaml") + ) + + vpc_id = result.outputs.get("VpcId") + default_sg = result.outputs.get("VpcDefaultSG") + default_acl = result.outputs.get("VpcDefaultAcl") + + assert vpc_id + assert default_sg + assert default_acl + + security_groups = aws_client.ec2.describe_security_groups(GroupIds=[default_sg])[ + "SecurityGroups" + ] + assert security_groups[0]["VpcId"] == vpc_id + + acls = aws_client.ec2.describe_network_acls(NetworkAclIds=[default_acl])["NetworkAcls"] + assert acls[0]["VpcId"] == vpc_id + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.aws.validated +def test_cfn_with_multiple_route_tables(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../../../templates/template36.yaml"), + max_wait=180, + ) + vpc_id = result.outputs["VPC"] + + resp = aws_client.ec2.describe_route_tables(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]) + + # 4 route tables being created (validated against AWS): 3 in template + 1 default = 4 + assert len(resp["RouteTables"]) == 4 + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..PropagatingVgws", "$..Tags", "$..Tags..Key", "$..Tags..Value"] +) +def test_cfn_with_multiple_route_table_associations(deploy_cfn_template, aws_client, snapshot): + # TODO: stack does not deploy to AWS + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../../../templates/template37.yaml") + ) + route_table_id = stack.outputs["RouteTable"] + route_table = aws_client.ec2.describe_route_tables( + Filters=[{"Name": "route-table-id", "Values": [route_table_id]}] + )["RouteTables"][0] + + snapshot.match("route_table", route_table) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableAssociationId")) + snapshot.add_transformer(snapshot.transform.key_value("SubnetId")) + snapshot.add_transformer(snapshot.transform.key_value("VpcId")) + + +@pytest.mark.skip(reason="CFNV2:Describe") +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..DriftInformation", "$..Metadata"]) +def test_internet_gateway_ref_and_attr(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../../../templates/internet_gateway.yml") + ) + + response = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="Gateway" + ) + + snapshot.add_transformer(snapshot.transform.key_value("RefAttachment", "internet-gateway-ref")) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + snapshot.match("outputs", stack.outputs) + snapshot.match("description", response["StackResourceDetail"]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..OwnerId"]) +def test_dhcp_options(aws_client, deploy_cfn_template, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "../../../../../templates/dhcp_options.yml") + ) + + response = aws_client.ec2.describe_dhcp_options( + DhcpOptionsIds=[stack.outputs["RefDhcpOptions"]] + ) + snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId", "dhcp-options-id")) + snapshot.add_transformer(SortingTransformer("DhcpConfigurations", lambda x: x["Key"])) + snapshot.match("description", response["DhcpOptions"][0]) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Tags", + "$..Options.AssociationDefaultRouteTableId", + "$..Options.PropagationDefaultRouteTableId", + "$..Options.TransitGatewayCidrBlocks", # an empty list returned by Moto but not by AWS + "$..Options.SecurityGroupReferencingSupport", # not supported by Moto + ] +) +def test_transit_gateway_attachment(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + THIS_FOLDER, "../../../../../templates/transit_gateway_attachment.yml" + ) + ) + + gateway_description = aws_client.ec2.describe_transit_gateways( + TransitGatewayIds=[stack.outputs["TransitGateway"]] + ) + attachment_description = aws_client.ec2.describe_transit_gateway_attachments( + TransitGatewayAttachmentIds=[stack.outputs["Attachment"]] + ) + + snapshot.add_transformer(snapshot.transform.key_value("TransitGatewayRouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("AssociationDefaultRouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("PropagatioDefaultRouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("ResourceId")) + snapshot.add_transformer(snapshot.transform.key_value("TransitGatewayAttachmentId")) + snapshot.add_transformer(snapshot.transform.key_value("TransitGatewayId")) + + snapshot.match("attachment", attachment_description["TransitGatewayAttachments"][0]) + snapshot.match("gateway", gateway_description["TransitGateways"][0]) + + stack.destroy() + + descriptions = aws_client.ec2.describe_transit_gateways( + TransitGatewayIds=[stack.outputs["TransitGateway"]] + ) + if is_aws_cloud(): + # aws changes the state to deleted + descriptions = descriptions["TransitGateways"][0] + assert descriptions["State"] == "deleted" + else: + # moto directly deletes the transit gateway + transit_gateways_ids = [ + tgateway["TransitGatewayId"] for tgateway in descriptions["TransitGateways"] + ] + assert stack.outputs["TransitGateway"] not in transit_gateways_ids + + attachment_description = aws_client.ec2.describe_transit_gateway_attachments( + TransitGatewayAttachmentIds=[stack.outputs["Attachment"]] + )["TransitGatewayAttachments"] + assert attachment_description[0]["State"] == "deleted" + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..RouteTables..PropagatingVgws", "$..RouteTables..Tags"] +) +def test_vpc_with_route_table(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/template33.yaml" + ) + ) + + route_id = stack.outputs["RouteTableId"] + response = aws_client.ec2.describe_route_tables(RouteTableIds=[route_id]) + + # Convert tags to dictionary for easier comparison + response["RouteTables"][0]["Tags"] = { + tag["Key"]: tag["Value"] for tag in response["RouteTables"][0]["Tags"] + } + + snapshot.match("route_table", response) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + snapshot.add_transformer(snapshot.transform.key_value("RouteTableId")) + snapshot.add_transformer(snapshot.transform.key_value("VpcId")) + + stack.destroy() + + with pytest.raises(aws_client.ec2.exceptions.ClientError): + aws_client.ec2.describe_route_tables(RouteTableIds=[route_id]) + + +@pytest.mark.skip(reason="update doesn't change value for instancetype") +@markers.aws.validated +def test_cfn_update_ec2_instance_type(deploy_cfn_template, aws_client, cleanups): + if aws_client.cloudformation.meta.region_name not in [ + "ap-northeast-1", + "eu-central-1", + "eu-south-1", + "eu-west-1", + "eu-west-2", + "us-east-1", + ]: + pytest.skip() + + key_name = f"testkey-{short_uid()}" + aws_client.ec2.create_key_pair(KeyName=key_name) + cleanups.append(lambda: aws_client.ec2.delete_key_pair(KeyName=key_name)) + + # get alpine image id + if is_aws_cloud(): + images = aws_client.ec2.describe_images( + Filters=[ + {"Name": "name", "Values": ["alpine-3.19.0-x86_64-bios-*"]}, + {"Name": "state", "Values": ["available"]}, + ] + )["Images"] + image_id = images[0]["ImageId"] + else: + image_id = "ami-0a63f96a6a8d4d2c5" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ec2_instance.yml" + ), + parameters={"KeyName": key_name, "InstanceType": "t2.nano", "ImageId": image_id}, + ) + + instance_id = stack.outputs["InstanceId"] + instance = aws_client.ec2.describe_instances(InstanceIds=[instance_id])["Reservations"][0][ + "Instances" + ][0] + assert instance["InstanceType"] == "t2.nano" + + deploy_cfn_template( + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ec2_instance.yml" + ), + parameters={"KeyName": key_name, "InstanceType": "t2.medium", "ImageId": image_id}, + is_update=True, + ) + + instance = aws_client.ec2.describe_instances(InstanceIds=[instance_id])["Reservations"][0][ + "Instances" + ][0] + assert instance["InstanceType"] == "t2.medium" + + +@markers.aws.validated +def test_ec2_security_group_id_with_vpc(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ec2_vpc_securitygroup.yml" + ), + ) + + ec2_client = aws_client.ec2 + with_vpcid_sg_group_id = ec2_client.describe_security_groups( + Filters=[ + { + "Name": "group-id", + "Values": [stack.outputs["SGWithVpcIdGroupId"]], + }, + ] + )["SecurityGroups"][0] + without_vpcid_sg_group_id = ec2_client.describe_security_groups( + Filters=[ + { + "Name": "group-id", + "Values": [stack.outputs["SGWithoutVpcIdGroupId"]], + }, + ] + )["SecurityGroups"][0] + + snapshot.add_transformer( + snapshot.transform.regex(with_vpcid_sg_group_id["GroupId"], "") + ) + snapshot.add_transformer( + snapshot.transform.regex(without_vpcid_sg_group_id["GroupId"], "") + ) + snapshot.add_transformer( + snapshot.transform.regex( + without_vpcid_sg_group_id["GroupName"], "" + ) + ) + snapshot.match("references", stack.outputs) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # fingerprint algorithm is different but presence is ensured by CFn output implementation + "$..ImportedKeyPairFingerprint", + ], +) +def test_keypair_create_import(deploy_cfn_template, snapshot, aws_client): + imported_key_name = f"imported-key-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(imported_key_name, "")) + generated_key_name = f"generated-key-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(generated_key_name, "")) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ec2_import_keypair.yaml" + ), + parameters={"ImportedKeyName": imported_key_name, "GeneratedKeyName": generated_key_name}, + ) + + outputs = stack.outputs + # for the generated key pair, use the EC2 API to get the fingerprint and snapshot the value + key_res = aws_client.ec2.describe_key_pairs(KeyNames=[outputs["GeneratedKeyPairName"]])[ + "KeyPairs" + ][0] + snapshot.add_transformer(snapshot.transform.regex(key_res["KeyFingerprint"], "")) + + snapshot.match("outputs", outputs) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.snapshot.json new file mode 100644 index 0000000000000..4b71ac67803dc --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.snapshot.json @@ -0,0 +1,303 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_internet_gateway_ref_and_attr": { + "recorded-date": "13-02-2023, 17:13:41", + "recorded-content": { + "outputs": { + "IdAttachment": "", + "RefAttachment": "" + }, + "description": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "Gateway", + "Metadata": {}, + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::EC2::InternetGateway", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_dhcp_options": { + "recorded-date": "19-10-2023, 14:51:28", + "recorded-content": { + "description": { + "DhcpConfigurations": [ + { + "Key": "domain-name", + "Values": [ + { + "Value": "example.com" + } + ] + }, + { + "Key": "domain-name-servers", + "Values": [ + { + "Value": "AmazonProvidedDNS" + } + ] + }, + { + "Key": "netbios-name-servers", + "Values": [ + { + "Value": "10.2.5.1" + } + ] + }, + { + "Key": "netbios-node-type", + "Values": [ + { + "Value": "2" + } + ] + }, + { + "Key": "ntp-servers", + "Values": [ + { + "Value": "10.2.5.1" + } + ] + } + ], + "DhcpOptionsId": "", + "OwnerId": "111111111111", + "Tags": [ + { + "Key": "project", + "Value": "123" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "myDhcpOptions" + }, + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-698b113f" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-698b113f/d892a0f0-6eb8-11ee-ab19-0a5372e03565" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_transit_gateway_attachment": { + "recorded-date": "08-04-2025, 10:51:02", + "recorded-content": { + "attachment": { + "Association": { + "State": "associated", + "TransitGatewayRouteTableId": "" + }, + "CreationTime": "datetime", + "ResourceId": "", + "ResourceOwnerId": "111111111111", + "ResourceType": "vpc", + "State": "available", + "Tags": [ + { + "Key": "Name", + "Value": "example-tag" + } + ], + "TransitGatewayAttachmentId": "", + "TransitGatewayId": "", + "TransitGatewayOwnerId": "111111111111" + }, + "gateway": { + "CreationTime": "datetime", + "Description": "TGW Route Integration Test", + "Options": { + "AmazonSideAsn": 65000, + "AssociationDefaultRouteTableId": "", + "AutoAcceptSharedAttachments": "disable", + "DefaultRouteTableAssociation": "enable", + "DefaultRouteTablePropagation": "enable", + "DnsSupport": "enable", + "MulticastSupport": "disable", + "PropagationDefaultRouteTableId": "", + "SecurityGroupReferencingSupport": "disable", + "VpnEcmpSupport": "enable" + }, + "OwnerId": "111111111111", + "State": "available", + "Tags": [ + { + "Key": "Application", + "Value": "arn::cloudformation::111111111111:stack/stack-31597705/521e4e40-ecce-11ee-806c-0affc1ff51e7" + } + ], + "TransitGatewayArn": "arn::ec2::111111111111:transit-gateway/", + "TransitGatewayId": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_vpc_with_route_table": { + "recorded-date": "19-06-2024, 16:48:31", + "recorded-content": { + "route_table": { + "RouteTables": [ + { + "Associations": [], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "100.0.0.0/20", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": { + "aws:cloudformation:logical-id": "RouteTable", + "aws:cloudformation:stack-id": "", + "aws:cloudformation:stack-name": "", + "env": "production" + }, + "VpcId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_simple_route_table_creation_without_vpc": { + "recorded-date": "01-07-2024, 20:10:52", + "recorded-content": { + "tags": { + "Name": "Suspicious Route Table" + }, + "route_table": { + "Associations": [], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "10.0.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "VpcId": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_simple_route_table_creation": { + "recorded-date": "01-07-2024, 20:13:48", + "recorded-content": { + "tags": { + "Name": "Suspicious Route table" + }, + "route_table": { + "Associations": [], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "10.0.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "VpcId": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_cfn_with_multiple_route_table_associations": { + "recorded-date": "02-07-2024, 15:29:41", + "recorded-content": { + "route_table": { + "Associations": [ + { + "AssociationState": { + "State": "associated" + }, + "Main": false, + "RouteTableAssociationId": "", + "RouteTableId": "", + "SubnetId": "" + }, + { + "AssociationState": { + "State": "associated" + }, + "Main": false, + "RouteTableAssociationId": "", + "RouteTableId": "", + "SubnetId": "" + } + ], + "OwnerId": "111111111111", + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "100.0.0.0/20", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": [ + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-2264231d/d12f4090-3887-11ef-ba9f-0e78e2279133" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "RouteTable" + }, + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-2264231d" + }, + { + "Key": "env", + "Value": "production" + } + ], + "VpcId": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_ec2_security_group_id_with_vpc": { + "recorded-date": "19-07-2024, 15:53:16", + "recorded-content": { + "references": { + "SGWithVpcIdGroupId": "", + "SGWithVpcIdRef": "", + "SGWithoutVpcIdGroupId": "", + "SGWithoutVpcIdRef": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_keypair_create_import": { + "recorded-date": "12-08-2024, 21:51:36", + "recorded-content": { + "outputs": { + "GeneratedKeyPairFingerprint": "", + "GeneratedKeyPairName": "", + "ImportedKeyPairFingerprint": "4LmcYnyBOqlloHZ5TKAxfa8BgMK2wL6WeOOTvXVdhmw=", + "ImportedKeyPairName": "" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.validation.json new file mode 100644 index 0000000000000..9c06cf509f1a5 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.validation.json @@ -0,0 +1,35 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_cfn_update_ec2_instance_type": { + "last_validated_date": "2024-06-19T19:56:42+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_cfn_with_multiple_route_table_associations": { + "last_validated_date": "2024-07-02T15:29:41+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_dhcp_options": { + "last_validated_date": "2023-10-19T12:51:28+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_ec2_security_group_id_with_vpc": { + "last_validated_date": "2024-07-19T15:53:16+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_internet_gateway_ref_and_attr": { + "last_validated_date": "2023-02-13T16:13:41+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_keypair_create_import": { + "last_validated_date": "2024-08-12T21:51:36+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_simple_route_table_creation": { + "last_validated_date": "2024-07-01T20:13:48+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_simple_route_table_creation_without_vpc": { + "last_validated_date": "2024-07-01T20:10:52+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_transit_gateway_attachment": { + "last_validated_date": "2025-04-08T10:51:02+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_vpc_creates_default_sg": { + "last_validated_date": "2024-04-01T11:21:54+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ec2.py::test_vpc_with_route_table": { + "last_validated_date": "2024-06-19T16:48:31+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.py new file mode 100644 index 0000000000000..a3619407f9ea5 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.py @@ -0,0 +1,54 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.skip_offline +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DomainStatus.AdvancedSecurityOptions.AnonymousAuthEnabled", + "$..DomainStatus.AutoTuneOptions.State", + "$..DomainStatus.ChangeProgressDetails", + "$..DomainStatus.DomainProcessingStatus", + "$..DomainStatus.EBSOptions.VolumeSize", + "$..DomainStatus.ElasticsearchClusterConfig.DedicatedMasterCount", + "$..DomainStatus.ElasticsearchClusterConfig.InstanceCount", + "$..DomainStatus.ElasticsearchClusterConfig.ZoneAwarenessConfig", + "$..DomainStatus.ElasticsearchClusterConfig.ZoneAwarenessEnabled", + "$..DomainStatus.Endpoint", + "$..DomainStatus.ModifyingProperties", + "$..DomainStatus.Processing", + "$..DomainStatus.ServiceSoftwareOptions.CurrentVersion", + ] +) +def test_cfn_handle_elasticsearch_domain(deploy_cfn_template, aws_client, snapshot): + domain_name = f"es-{short_uid()}" + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/elasticsearch_domain.yml" + ) + + deploy_cfn_template(template_path=template_path, parameters={"DomainName": domain_name}) + + rs = aws_client.es.describe_elasticsearch_domain(DomainName=domain_name) + status = rs["DomainStatus"] + snapshot.match("domain", rs) + + tags = aws_client.es.list_tags(ARN=status["ARN"])["TagList"] + snapshot.match("tags", tags) + + snapshot.add_transformer(snapshot.transform.key_value("DomainName")) + snapshot.add_transformer(snapshot.transform.key_value("Endpoint")) + snapshot.add_transformer(snapshot.transform.key_value("TLSSecurityPolicy")) + snapshot.add_transformer(snapshot.transform.key_value("CurrentVersion")) + snapshot.add_transformer(snapshot.transform.key_value("Description")) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.snapshot.json new file mode 100644 index 0000000000000..427b5a9768e3c --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.snapshot.json @@ -0,0 +1,312 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.py::test_cfn_handle_elasticsearch_domain": { + "recorded-date": "02-07-2024, 17:30:21", + "recorded-content": { + "domain": { + "DomainStatus": { + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": "", + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "ENABLED" + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "ApplyingChanges", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "datetime", + "StartTime": "datetime" + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "" + }, + "DomainId": "111111111111/", + "DomainName": "", + "DomainProcessingStatus": "Creating", + "EBSOptions": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 20, + "VolumeType": "gp2" + }, + "ElasticsearchClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterCount": 3, + "DedicatedMasterEnabled": true, + "DedicatedMasterType": "m3.medium.elasticsearch", + "InstanceCount": 2, + "InstanceType": "m3.medium.elasticsearch", + "WarmEnabled": false, + "ZoneAwarenessConfig": { + "AvailabilityZoneCount": 2 + }, + "ZoneAwarenessEnabled": true + }, + "ElasticsearchVersion": "7.10", + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "search--4kyrgtn4a3gwrja6k4o7nvcrha..es.amazonaws.com", + "ModifyingProperties": [ + { + "ActiveValue": "", + "Name": "AdvancedOptions", + "PendingValue": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.AnonymousAuthDisableDate", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.AnonymousAuthEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.InternalUserDatabaseEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.JWTOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.MasterUserOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.SAMLOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.ColdStorageOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.DedicatedMasterCount", + "PendingValue": "3", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.DedicatedMasterEnabled", + "PendingValue": "true", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.DedicatedMasterType", + "PendingValue": "m3.medium.elasticsearch", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.InstanceCount", + "PendingValue": "2", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.InstanceType", + "PendingValue": "m3.medium.elasticsearch", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.MultiAZWithStandbyEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmCount", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmStorage", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.WarmType", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchClusterConfig.ZoneAwarenessEnabled", + "PendingValue": "true", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ElasticsearchVersion", + "PendingValue": "7.10", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "IPAddressType", + "PendingValue": "ipv4", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "TAGS", + "PendingValue": { + "k1": "v1", + "k2": "v2" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "DomainEndpointOptions", + "PendingValue": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "EBSOptions", + "PendingValue": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 20, + "VolumeType": "gp2" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "EncryptionAtRestOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "NodeToNodeEncryptionOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "OffPeakWindowOptions", + "PendingValue": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "SnapshotOptions", + "PendingValue": { + "AutomatedSnapshotStartHour": 0 + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "SoftwareUpdateOptions", + "PendingValue": { + "AutoSoftwareUpdateEnabled": false + }, + "ValueType": "STRINGIFIED_JSON" + } + ], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "datetime", + "Cancellable": false, + "CurrentVersion": "", + "Description": "", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "UpgradeProcessing": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tags": [ + { + "Key": "k1", + "Value": "v1" + }, + { + "Key": "k2", + "Value": "v2" + } + ] + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.validation.json new file mode 100644 index 0000000000000..879e604d1082c --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_elasticsearch.py::test_cfn_handle_elasticsearch_domain": { + "last_validated_date": "2024-07-02T17:30:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py new file mode 100644 index 0000000000000..59f63ff949f12 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py @@ -0,0 +1,248 @@ +import json +import logging +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + +LOG = logging.getLogger(__name__) + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@pytest.mark.skip( + reason="CFNV2:Destroy resource name conflict with another test case resource in this suite" +) +@markers.aws.validated +def test_cfn_event_api_destination_resource(deploy_cfn_template, region_name, aws_client): + def _assert(expected_len): + rs = aws_client.events.list_event_buses() + event_buses = [eb for eb in rs["EventBuses"] if eb["Name"] == "my-test-bus"] + assert len(event_buses) == expected_len + rs = aws_client.events.list_connections() + connections = [con for con in rs["Connections"] if con["Name"] == "my-test-conn"] + assert len(connections) == expected_len + rs = aws_client.events.list_api_destinations() + api_destinations = [ + ad for ad in rs["ApiDestinations"] if ad["Name"] == "my-test-destination" + ] + assert len(api_destinations) == expected_len + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/events_apidestination.yml" + ), + parameters={ + "Region": region_name, + }, + ) + _assert(1) + + stack.destroy() + _assert(0) + + +@pytest.mark.skip(reason="CFNV2:Describe") +@markers.aws.validated +def test_eventbus_policies(deploy_cfn_template, aws_client): + event_bus_name = f"event-bus-{short_uid()}" + + stack_response = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/eventbridge_policy.yaml" + ), + parameters={"EventBusName": event_bus_name}, + ) + + describe_response = aws_client.events.describe_event_bus(Name=event_bus_name) + policy = json.loads(describe_response["Policy"]) + assert len(policy["Statement"]) == 2 + + # verify physical resource ID creation + pol1_description = aws_client.cloudformation.describe_stack_resource( + StackName=stack_response.stack_name, LogicalResourceId="eventPolicy" + ) + pol2_description = aws_client.cloudformation.describe_stack_resource( + StackName=stack_response.stack_name, LogicalResourceId="eventPolicy2" + ) + assert ( + pol1_description["StackResourceDetail"]["PhysicalResourceId"] + != pol2_description["StackResourceDetail"]["PhysicalResourceId"] + ) + + deploy_cfn_template( + is_update=True, + stack_name=stack_response.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/eventbridge_policy_singlepolicy.yaml", + ), + parameters={"EventBusName": event_bus_name}, + ) + + describe_response = aws_client.events.describe_event_bus(Name=event_bus_name) + policy = json.loads(describe_response["Policy"]) + assert len(policy["Statement"]) == 1 + + +@markers.aws.validated +def test_eventbus_policy_statement(deploy_cfn_template, aws_client): + event_bus_name = f"event-bus-{short_uid()}" + statement_id = f"statement-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/eventbridge_policy_statement.yaml" + ), + parameters={"EventBusName": event_bus_name, "StatementId": statement_id}, + ) + + describe_response = aws_client.events.describe_event_bus(Name=event_bus_name) + policy = json.loads(describe_response["Policy"]) + assert policy["Version"] == "2012-10-17" + assert len(policy["Statement"]) == 1 + statement = policy["Statement"][0] + assert statement["Sid"] == statement_id + assert statement["Action"] == "events:PutEvents" + assert statement["Principal"] == "*" + assert statement["Effect"] == "Allow" + assert event_bus_name in statement["Resource"] + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.aws.validated +def test_event_rule_to_logs(deploy_cfn_template, aws_client): + event_rule_name = f"event-rule-{short_uid()}" + log_group_name = f"log-group-{short_uid()}" + event_bus_name = f"bus-{short_uid()}" + resource_policy_name = f"policy-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/events_loggroup.yaml" + ), + parameters={ + "EventRuleName": event_rule_name, + "LogGroupName": log_group_name, + "EventBusName": event_bus_name, + "PolicyName": resource_policy_name, + }, + ) + + log_groups = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)["logGroups"] + log_group_names = [lg["logGroupName"] for lg in log_groups] + assert log_group_name in log_group_names + + message_token = f"test-message-{short_uid()}" + resp = aws_client.events.put_events( + Entries=[ + { + "Source": "unittest", + "Resources": [], + "DetailType": "ls-detail-type", + "Detail": json.dumps({"messagetoken": message_token}), + "EventBusName": event_bus_name, + } + ] + ) + assert len(resp["Entries"]) == 1 + + wait_until( + lambda: len(aws_client.logs.describe_log_streams(logGroupName=log_group_name)["logStreams"]) + > 0, + 1.0, + 5, + "linear", + ) + log_streams = aws_client.logs.describe_log_streams(logGroupName=log_group_name)["logStreams"] + log_events = aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_streams[0]["logStreamName"] + ) + assert message_token in log_events["events"][0]["message"] + + +@markers.aws.validated +def test_event_rule_creation_without_target(deploy_cfn_template, aws_client, snapshot): + event_rule_name = f"event-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(event_rule_name, "event-rule-name")) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/events_rule_without_targets.yaml" + ), + parameters={"EventRuleName": event_rule_name}, + ) + + response = aws_client.events.describe_rule( + Name=event_rule_name, + ) + snapshot.match("describe_rule", response) + + +@markers.aws.validated +def test_cfn_event_bus_resource(deploy_cfn_template, aws_client): + def _assert(expected_len): + rs = aws_client.events.list_event_buses() + event_buses = [eb for eb in rs["EventBuses"] if eb["Name"] == "my-test-bus"] + assert len(event_buses) == expected_len + rs = aws_client.events.list_connections() + connections = [con for con in rs["Connections"] if con["Name"] == "my-test-conn"] + assert len(connections) == expected_len + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/template31.yaml" + ) + ) + _assert(1) + + stack.destroy() + _assert(0) + + +@markers.aws.validated +def test_rule_properties(deploy_cfn_template, aws_client, snapshot): + event_bus_name = f"events-{short_uid()}" + rule_name = f"rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(event_bus_name, "")) + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/events_rule_properties.yaml" + ), + parameters={"EventBusName": event_bus_name, "RuleName": rule_name}, + ) + + rule_id = stack.outputs["RuleWithoutNameArn"].rsplit("/")[-1] + snapshot.add_transformer(snapshot.transform.regex(rule_id, "")) + + without_bus_id = stack.outputs["RuleWithoutBusArn"].rsplit("/")[-1] + snapshot.add_transformer(snapshot.transform.regex(without_bus_id, "")) + + snapshot.match("outputs", stack.outputs) + + +@markers.aws.validated +def test_rule_pattern_transformation(aws_client, deploy_cfn_template, snapshot): + """ + The CFn provider for a rule applies a transformation to some properties. Extend this test as more properties or + situations arise. + """ + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/events_rule_pattern.yml" + ), + ) + + rule = aws_client.events.describe_rule(Name=stack.outputs["RuleName"]) + snapshot.match("rule", rule) + snapshot.add_transformer(snapshot.transform.key_value("Name")) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.snapshot.json new file mode 100644 index 0000000000000..9d0f00f3548f7 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.snapshot.json @@ -0,0 +1,70 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_rule_properties": { + "recorded-date": "01-12-2023, 15:03:52", + "recorded-content": { + "outputs": { + "RuleWithNameArn": "arn::events::111111111111:rule//", + "RuleWithNameRef": "|", + "RuleWithoutBusArn": "arn::events::111111111111:rule/", + "RuleWithoutBusRef": "", + "RuleWithoutNameArn": "arn::events::111111111111:rule//", + "RuleWithoutNameRef": "|" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_rule_pattern_transformation": { + "recorded-date": "08-11-2024, 15:49:06", + "recorded-content": { + "rule": { + "Arn": "arn::events::111111111111:rule/", + "CreatedBy": "111111111111", + "EventBusName": "default", + "EventPattern": { + "detail-type": [ + "Object Created" + ], + "source": [ + "aws.s3" + ], + "detail": { + "bucket": { + "name": [ + "test-s3-bucket" + ] + }, + "object": { + "key": [ + { + "suffix": "/test.json" + } + ] + } + } + }, + "Name": "", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_event_rule_creation_without_target": { + "recorded-date": "22-01-2025, 14:15:04", + "recorded-content": { + "describe_rule": { + "Arn": "arn::events::111111111111:rule/event-rule-name", + "CreatedBy": "111111111111", + "EventBusName": "default", + "Name": "event-rule-name", + "ScheduleExpression": "cron(0 1 * * ? *)", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.validation.json new file mode 100644 index 0000000000000..f9456ffe87bad --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_cfn_event_api_destination_resource": { + "last_validated_date": "2024-04-16T06:36:56+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_event_rule_creation_without_target": { + "last_validated_date": "2025-01-22T14:15:04+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_eventbus_policy_statement": { + "last_validated_date": "2024-11-14T21:46:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_rule_pattern_transformation": { + "last_validated_date": "2024-11-08T15:49:06+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_events.py::test_rule_properties": { + "last_validated_date": "2023-12-01T14:03:52+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.py new file mode 100644 index 0000000000000..bf3d5a79f2931 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.py @@ -0,0 +1,49 @@ +import os.path + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Destinations"]) +def test_firehose_stack_with_kinesis_as_source(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + bucket_name = f"bucket-{short_uid()}" + stream_name = f"stream-{short_uid()}" + delivery_stream_name = f"delivery-stream-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/firehose_kinesis_as_source.yaml" + ), + parameters={ + "BucketName": bucket_name, + "StreamName": stream_name, + "DeliveryStreamName": delivery_stream_name, + }, + max_wait=150, + ) + snapshot.match("outputs", stack.outputs) + + def _assert_stream_available(): + status = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=delivery_stream_name + ) + assert status["DeliveryStreamDescription"]["DeliveryStreamStatus"] == "ACTIVE" + + retry(_assert_stream_available, sleep=2, retries=15) + + response = aws_client.firehose.describe_delivery_stream(DeliveryStreamName=delivery_stream_name) + assert delivery_stream_name == response["DeliveryStreamDescription"]["DeliveryStreamName"] + snapshot.match("delivery_stream", response) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.snapshot.json new file mode 100644 index 0000000000000..6bc7b63f87e77 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.snapshot.json @@ -0,0 +1,99 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.py::test_firehose_stack_with_kinesis_as_source": { + "recorded-date": "14-09-2022, 11:19:29", + "recorded-content": { + "outputs": { + "deliveryStreamRef": "" + }, + "delivery_stream": { + "DeliveryStreamDescription": { + "CreateTimestamp": "timestamp", + "DeliveryStreamARN": "arn::firehose::111111111111:deliverystream/", + "DeliveryStreamName": "", + "DeliveryStreamStatus": "ACTIVE", + "DeliveryStreamType": "KinesisStreamAsSource", + "Destinations": [ + { + "DestinationId": "destinationId-000000000001", + "ExtendedS3DestinationDescription": { + "BucketARN": "arn::s3:::", + "BufferingHints": { + "IntervalInSeconds": 60, + "SizeInMBs": 64 + }, + "CloudWatchLoggingOptions": { + "Enabled": false + }, + "CompressionFormat": "UNCOMPRESSED", + "DataFormatConversionConfiguration": { + "Enabled": false + }, + "DynamicPartitioningConfiguration": { + "Enabled": true, + "RetryOptions": { + "DurationInSeconds": 300 + } + }, + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "ErrorOutputPrefix": "firehoseTest-errors/!{firehose:error-output-type}/", + "Prefix": "firehoseTest/!{partitionKeyFromQuery:s3Prefix}", + "ProcessingConfiguration": { + "Enabled": true, + "Processors": [ + { + "Parameters": [ + { + "ParameterName": "MetadataExtractionQuery", + "ParameterValue": "{s3Prefix: .tableName}" + }, + { + "ParameterName": "JsonParsingEngine", + "ParameterValue": "JQ-1.6" + } + ], + "Type": "MetadataExtraction" + } + ] + }, + "RoleARN": "arn::iam::111111111111:role/", + "S3BackupMode": "Disabled" + }, + "S3DestinationDescription": { + "BucketARN": "arn::s3:::", + "BufferingHints": { + "IntervalInSeconds": 60, + "SizeInMBs": 64 + }, + "CloudWatchLoggingOptions": { + "Enabled": false + }, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": { + "NoEncryptionConfig": "NoEncryption" + }, + "ErrorOutputPrefix": "firehoseTest-errors/!{firehose:error-output-type}/", + "Prefix": "firehoseTest/!{partitionKeyFromQuery:s3Prefix}", + "RoleARN": "arn::iam::111111111111:role/" + } + } + ], + "HasMoreDestinations": false, + "Source": { + "KinesisStreamSourceDescription": { + "DeliveryStartTimestamp": "timestamp", + "KinesisStreamARN": "arn::kinesis::111111111111:stream/", + "RoleARN": "arn::iam::111111111111:role/" + } + }, + "VersionId": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.validation.json new file mode 100644 index 0000000000000..e12e5185d82f1 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_firehose.py::test_firehose_stack_with_kinesis_as_source": { + "last_validated_date": "2022-09-14T09:19:29+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.py new file mode 100644 index 0000000000000..bb48345710803 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.py @@ -0,0 +1,94 @@ +import json +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_events_sqs_sns_lambda(deploy_cfn_template, aws_client): + function_name = f"function-{short_uid()}" + queue_name = f"queue-{short_uid()}" + topic_name = f"topic-{short_uid()}" + bus_name = f"bus-{short_uid()}" + rule_name = f"function-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/integration_events_sns_sqs_lambda.yaml", + ), + parameters={ + "FunctionName": function_name, + "QueueName": queue_name, + "TopicName": topic_name, + "BusName": bus_name, + "RuleName": rule_name, + }, + ) + + assert len(stack.outputs) == 7 + lambda_name = stack.outputs["FnName"] + bus_name = stack.outputs["EventBusName"] + + topic_arn = stack.outputs["TopicArn"] + result = aws_client.sns.get_topic_attributes(TopicArn=topic_arn)["Attributes"] + assert json.loads(result.get("Policy")) == { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Resource": topic_arn, + "Sid": "0", + } + ], + "Version": "2012-10-17", + } + + # put events + aws_client.events.put_events( + Entries=[ + { + "DetailType": "test-detail-type", + "Detail": '{"app": "localstack"}', + "Source": "test-source", + "EventBusName": bus_name, + }, + ] + ) + + def _check_lambda_invocations(): + groups = aws_client.logs.describe_log_groups( + logGroupNamePrefix=f"/aws/lambda/{lambda_name}" + ) + streams = aws_client.logs.describe_log_streams( + logGroupName=groups["logGroups"][0]["logGroupName"] + ) + assert ( + 0 < len(streams) <= 2 + ) # should be 1 or 2 because of the two potentially simultaneous calls + + all_events = [] + for s in streams["logStreams"]: + events = aws_client.logs.get_log_events( + logGroupName=groups["logGroups"][0]["logGroupName"], + logStreamName=s["logStreamName"], + )["events"] + all_events.extend(events) + + assert [e for e in all_events if topic_name in e["message"]] + assert [e for e in all_events if queue_name in e["message"]] + return True + + assert wait_until(_check_lambda_invocations) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.validation.json new file mode 100644 index 0000000000000..4213db8d36bbf --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_integration.py::test_events_sqs_sns_lambda": { + "last_validated_date": "2024-07-02T18:43:06+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py new file mode 100644 index 0000000000000..6cf7220a835c3 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py @@ -0,0 +1,184 @@ +import json +import os + +import pytest + +from localstack import config +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..StreamDescription.StreamModeDetails"]) +def test_stream_creation(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.resource_name()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("StreamName", "stream-name"), + snapshot.transform.key_value("ShardId", "shard-id", reference_replacement=False), + snapshot.transform.key_value("EndingHashKey", "ending-hash-key"), + snapshot.transform.key_value("StartingSequenceNumber", "sequence-number"), + ] + ) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + template = json.dumps( + { + "Resources": { + "TestStream": { + "Type": "AWS::Kinesis::Stream", + "Properties": {"ShardCount": 1}, + }, + }, + "Outputs": { + "StreamNameFromRef": {"Value": {"Ref": "TestStream"}}, + "StreamArnFromAtt": {"Value": {"Fn::GetAtt": "TestStream.Arn"}}, + }, + } + ) + + stack = deploy_cfn_template(template=template) + snapshot.match("stack_output", stack.outputs) + + description = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name) + snapshot.match("resource_description", description) + + stream_name = stack.outputs.get("StreamNameFromRef") + description = aws_client.kinesis.describe_stream(StreamName=stream_name) + snapshot.match("stream_description", description) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..StreamDescription.StreamModeDetails"]) +def test_default_parameters_kinesis(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/kinesis_default.yaml" + ) + ) + + stream_name = stack.outputs["KinesisStreamName"] + rs = aws_client.kinesis.describe_stream(StreamName=stream_name) + snapshot.match("describe_stream", rs) + + snapshot.add_transformer(snapshot.transform.key_value("StreamName")) + snapshot.add_transformer(snapshot.transform.key_value("ShardId")) + snapshot.add_transformer(snapshot.transform.key_value("StartingSequenceNumber")) + + +@markers.aws.validated +def test_cfn_handle_kinesis_firehose_resources(deploy_cfn_template, aws_client): + kinesis_stream_name = f"kinesis-stream-{short_uid()}" + firehose_role_name = f"firehose-role-{short_uid()}" + firehose_stream_name = f"firehose-stream-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_kinesis_stream.yaml" + ), + parameters={ + "KinesisStreamName": kinesis_stream_name, + "DeliveryStreamName": firehose_stream_name, + "KinesisRoleName": firehose_role_name, + }, + ) + + assert len(stack.outputs) == 1 + + rs = aws_client.firehose.describe_delivery_stream(DeliveryStreamName=firehose_stream_name) + assert rs["DeliveryStreamDescription"]["DeliveryStreamARN"] == stack.outputs["MyStreamArn"] + assert rs["DeliveryStreamDescription"]["DeliveryStreamName"] == firehose_stream_name + + rs = aws_client.kinesis.describe_stream(StreamName=kinesis_stream_name) + assert rs["StreamDescription"]["StreamName"] == kinesis_stream_name + + # clean up + stack.destroy() + + rs = aws_client.kinesis.list_streams() + assert kinesis_stream_name not in rs["StreamNames"] + rs = aws_client.firehose.list_delivery_streams() + assert firehose_stream_name not in rs["DeliveryStreamNames"] + + +# TODO: use a different template and move this test to a more generic API level test suite +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify # nothing really works here right now +def test_describe_template(s3_create_bucket, aws_client, cleanups, snapshot): + bucket_name = f"b-{short_uid()}" + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/cfn_kinesis_stream.yaml") + ) + s3_create_bucket(Bucket=bucket_name) + aws_client.s3.put_object(Bucket=bucket_name, Key="template.yml", Body=template_body) + + if is_aws_cloud(): + template_url = ( + f"https://{bucket_name}.s3.{aws_client.s3.meta.region_name}.amazonaws.com/template.yml" + ) + else: + template_url = f"{config.internal_service_url()}/{bucket_name}/template.yml" + + # get summary by template URL + get_template_summary_by_url = aws_client.cloudformation.get_template_summary( + TemplateURL=template_url + ) + snapshot.match("get_template_summary_by_url", get_template_summary_by_url) + + param_keys = {p["ParameterKey"] for p in get_template_summary_by_url["Parameters"]} + assert param_keys == {"KinesisStreamName", "DeliveryStreamName", "KinesisRoleName"} + + # get summary by template body + get_template_summary_by_body = aws_client.cloudformation.get_template_summary( + TemplateBody=template_body + ) + snapshot.match("get_template_summary_by_body", get_template_summary_by_body) + param_keys = {p["ParameterKey"] for p in get_template_summary_by_url["Parameters"]} + assert param_keys == {"KinesisStreamName", "DeliveryStreamName", "KinesisRoleName"} + + +@pytest.mark.skipif( + condition=not is_aws_cloud() and config.DDB_STREAMS_PROVIDER_V2, + reason="Not yet implemented in DDB Streams V2", +) +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..KinesisDataStreamDestinations..DestinationStatusDescription"] +) +def test_dynamodb_stream_response_with_cf(deploy_cfn_template, aws_client, snapshot): + table_name = f"table-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_kinesis_dynamodb.yml" + ), + parameters={"TableName": table_name}, + ) + + response = aws_client.dynamodb.describe_kinesis_streaming_destination(TableName=table_name) + snapshot.match("describe_kinesis_streaming_destination", response) + snapshot.add_transformer(snapshot.transform.key_value("TableName")) + + +@pytest.mark.skip( + reason="CFNV2:Other resource provider returns NULL physical resource id for StreamConsumer thus later references to this resource fail to compute" +) +@markers.aws.validated +def test_kinesis_stream_consumer_creations(deploy_cfn_template, aws_client): + consumer_name = f"{short_uid()}" + stack = deploy_cfn_template( + parameters={"TestConsumerName": consumer_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/kinesis_stream_consumer.yaml" + ), + ) + consumer_arn = stack.outputs["KinesisSConsumerARN"] + response = aws_client.kinesis.describe_stream_consumer(ConsumerARN=consumer_arn) + assert response["ConsumerDescription"]["ConsumerStatus"] == "ACTIVE" diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.snapshot.json new file mode 100644 index 0000000000000..84936b7b55f43 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.snapshot.json @@ -0,0 +1,279 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_stream_creation": { + "recorded-date": "12-09-2022, 14:11:29", + "recorded-content": { + "stack_output": { + "StreamArnFromAtt": "arn::kinesis::111111111111:stream/", + "StreamNameFromRef": "" + }, + "resource_description": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "TestStream", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Kinesis::Stream", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stream_description": { + "StreamDescription": { + "EncryptionType": "NONE", + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "HasMoreShards": false, + "RetentionPeriodHours": 24, + "Shards": [ + { + "HashKeyRange": { + "EndingHashKey": "", + "StartingHashKey": "0" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + }, + "ShardId": "shard-id" + } + ], + "StreamARN": "arn::kinesis::111111111111:stream/", + "StreamCreationTimestamp": "timestamp", + "StreamModeDetails": { + "StreamMode": "PROVISIONED" + }, + "StreamName": "", + "StreamStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_describe_template": { + "recorded-date": "22-05-2023, 09:25:32", + "recorded-content": { + "get_template_summary_by_url": { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CapabilitiesReason": "The following resource(s) require capabilities: [AWS::IAM::Role]", + "Parameters": [ + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisRoleName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "DeliveryStreamName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisStreamName", + "ParameterType": "String" + } + ], + "ResourceIdentifierSummaries": [ + { + "LogicalResourceIds": [ + "MyBucket" + ], + "ResourceIdentifiers": [ + "BucketName" + ], + "ResourceType": "AWS::S3::Bucket" + }, + { + "LogicalResourceIds": [ + "MyRole" + ], + "ResourceIdentifiers": [ + "RoleName" + ], + "ResourceType": "AWS::IAM::Role" + }, + { + "LogicalResourceIds": [ + "KinesisStream" + ], + "ResourceIdentifiers": [ + "Name" + ], + "ResourceType": "AWS::Kinesis::Stream" + }, + { + "LogicalResourceIds": [ + "DeliveryStream" + ], + "ResourceIdentifiers": [ + "DeliveryStreamName" + ], + "ResourceType": "AWS::KinesisFirehose::DeliveryStream" + } + ], + "ResourceTypes": [ + "AWS::Kinesis::Stream", + "AWS::IAM::Role", + "AWS::S3::Bucket", + "AWS::KinesisFirehose::DeliveryStream" + ], + "Version": "2010-09-09", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_template_summary_by_body": { + "Capabilities": [ + "CAPABILITY_NAMED_IAM" + ], + "CapabilitiesReason": "The following resource(s) require capabilities: [AWS::IAM::Role]", + "Parameters": [ + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisRoleName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "DeliveryStreamName", + "ParameterType": "String" + }, + { + "NoEcho": false, + "ParameterConstraints": {}, + "ParameterKey": "KinesisStreamName", + "ParameterType": "String" + } + ], + "ResourceIdentifierSummaries": [ + { + "LogicalResourceIds": [ + "MyBucket" + ], + "ResourceIdentifiers": [ + "BucketName" + ], + "ResourceType": "AWS::S3::Bucket" + }, + { + "LogicalResourceIds": [ + "MyRole" + ], + "ResourceIdentifiers": [ + "RoleName" + ], + "ResourceType": "AWS::IAM::Role" + }, + { + "LogicalResourceIds": [ + "KinesisStream" + ], + "ResourceIdentifiers": [ + "Name" + ], + "ResourceType": "AWS::Kinesis::Stream" + }, + { + "LogicalResourceIds": [ + "DeliveryStream" + ], + "ResourceIdentifiers": [ + "DeliveryStreamName" + ], + "ResourceType": "AWS::KinesisFirehose::DeliveryStream" + } + ], + "ResourceTypes": [ + "AWS::Kinesis::Stream", + "AWS::IAM::Role", + "AWS::S3::Bucket", + "AWS::KinesisFirehose::DeliveryStream" + ], + "Version": "2010-09-09", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_default_parameters_kinesis": { + "recorded-date": "02-07-2024, 18:59:10", + "recorded-content": { + "describe_stream": { + "StreamDescription": { + "EncryptionType": "NONE", + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "HasMoreShards": false, + "RetentionPeriodHours": 24, + "Shards": [ + { + "HashKeyRange": { + "EndingHashKey": "340282366920938463463374607431768211455", + "StartingHashKey": "0" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + }, + "ShardId": "" + } + ], + "StreamARN": "arn::kinesis::111111111111:stream/", + "StreamCreationTimestamp": "timestamp", + "StreamModeDetails": { + "StreamMode": "PROVISIONED" + }, + "StreamName": "", + "StreamStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_dynamodb_stream_response_with_cf": { + "recorded-date": "02-07-2024, 19:48:27", + "recorded-content": { + "describe_kinesis_streaming_destination": { + "KinesisDataStreamDestinations": [ + { + "DestinationStatus": "ACTIVE", + "StreamArn": "arn::kinesis::111111111111:stream/EventStream" + } + ], + "TableName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.validation.json new file mode 100644 index 0000000000000..70bbffa38d0ee --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_cfn_handle_kinesis_firehose_resources": { + "last_validated_date": "2024-07-02T19:10:35+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_default_parameters_kinesis": { + "last_validated_date": "2024-07-02T18:59:10+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_describe_template": { + "last_validated_date": "2023-05-22T07:25:32+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_dynamodb_stream_response_with_cf": { + "last_validated_date": "2024-07-02T19:48:27+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kinesis.py::test_stream_creation": { + "last_validated_date": "2022-09-12T12:11:29+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py new file mode 100644 index 0000000000000..6625e3086df75 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py @@ -0,0 +1,77 @@ +import os.path + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_kms_key_disabled(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/kms_key_disabled.yaml" + ) + ) + + key_id = stack.outputs["KeyIdOutput"] + assert key_id + my_key = aws_client.kms.describe_key(KeyId=key_id) + assert not my_key["KeyMetadata"]["Enabled"] + + +@markers.aws.validated +def test_cfn_with_kms_resources(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("KeyAlias")) + + alias_name = f"alias/sample-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/template34.yaml" + ), + parameters={"AliasName": alias_name}, + max_wait=300, + ) + snapshot.match("stack-outputs", stack.outputs) + + assert stack.outputs.get("KeyAlias") == alias_name + + def _get_matching_aliases(): + aliases = aws_client.kms.list_aliases()["Aliases"] + return [alias for alias in aliases if alias["AliasName"] == alias_name] + + assert len(_get_matching_aliases()) == 1 + + stack.destroy() + assert not _get_matching_aliases() + + +@markers.aws.validated +def test_deploy_stack_with_kms(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_kms_key.yml" + ), + ) + + assert "KeyId" in stack.outputs + + key_id = stack.outputs["KeyId"] + + stack.destroy() + + def assert_key_deleted(): + resp = aws_client.kms.describe_key(KeyId=key_id)["KeyMetadata"] + assert resp["KeyState"] == "PendingDeletion" + + retry(assert_key_deleted, retries=5, sleep=5) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.snapshot.json new file mode 100644 index 0000000000000..6b059512e8448 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.snapshot.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py::test_cfn_with_kms_resources": { + "recorded-date": "29-05-2023, 15:45:17", + "recorded-content": { + "stack-outputs": { + "KeyAlias": "", + "KeyArn": "arn::kms::111111111111:key/" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.validation.json new file mode 100644 index 0000000000000..38f9f4302bd86 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py::test_cfn_with_kms_resources": { + "last_validated_date": "2023-05-29T13:45:17+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py::test_deploy_stack_with_kms": { + "last_validated_date": "2024-07-02T20:23:47+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_kms.py::test_kms_key_disabled": { + "last_validated_date": "2024-07-02T20:12:46+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py new file mode 100644 index 0000000000000..67f11739b6e46 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py @@ -0,0 +1,1384 @@ +import base64 +import json +import os +from io import BytesIO + +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack import config +from localstack.aws.api.lambda_ import InvocationType, Runtime, State +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import in_default_partition, is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import get_partition +from localstack.utils.common import short_uid +from localstack.utils.files import load_file +from localstack.utils.http import safe_requests +from localstack.utils.strings import to_bytes, to_str +from localstack.utils.sync import retry, wait_until +from localstack.utils.testutil import create_lambda_archive, get_lambda_log_events + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_lambda_w_dynamodb_event_filter(deploy_cfn_template, aws_client): + function_name = f"test-fn-{short_uid()}" + table_name = f"ddb-tbl-{short_uid()}" + item_to_put = {"id": {"S": "test123"}, "id2": {"S": "test42"}} + item_to_put2 = {"id": {"S": "test123"}, "id2": {"S": "test67"}} + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_dynamodb_filtering.yaml" + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"eventName": ["MODIFY"]}', + }, + ) + + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put) + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put2) + + def _assert_single_lambda_call(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + assert len(events) == 1 + msg = events[0] + if not isinstance(msg, str): + msg = json.dumps(msg) + assert "MODIFY" in msg and "INSERT" not in msg + + retry(_assert_single_lambda_call, retries=30) + + +@markers.snapshot.skip_snapshot_verify( + [ + # TODO: Fix flaky ESM state mismatch upon update in LocalStack (expected Enabled, actual Disabled) + # This might be a parity issue if AWS does rolling updates (i.e., never disables the ESM upon update). + "$..EventSourceMappings..State", + ] +) +@markers.aws.validated +def test_lambda_w_dynamodb_event_filter_update(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + function_name = f"test-fn-{short_uid()}" + table_name = f"ddb-tbl-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(table_name, "")) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_dynamodb_filtering.yaml" + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"eventName": ["DELETE"]}', + }, + ) + source_mappings = aws_client.lambda_.list_event_source_mappings(FunctionName=function_name) + snapshot.match("source_mappings", source_mappings) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_dynamodb_filtering.yaml" + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"eventName": ["MODIFY"]}', + }, + stack_name=stack.stack_name, + is_update=True, + ) + + source_mappings = aws_client.lambda_.list_event_source_mappings(FunctionName=function_name) + snapshot.match("updated_source_mappings", source_mappings) + + +@markers.aws.validated +def test_update_lambda_function(s3_create_bucket, deploy_cfn_template, aws_client): + function_name = f"lambda-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_function_update.yml" + ), + parameters={"Environment": "ORIGINAL", "FunctionName": function_name}, + ) + + response = aws_client.lambda_.get_function(FunctionName=function_name) + assert response["Configuration"]["Environment"]["Variables"]["TEST"] == "ORIGINAL" + + deploy_cfn_template( + stack_name=stack.stack_name, + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_function_update.yml" + ), + parameters={"Environment": "UPDATED", "FunctionName": function_name}, + ) + + response = aws_client.lambda_.get_function(FunctionName=function_name) + assert response["Configuration"]["Environment"]["Variables"]["TEST"] == "UPDATED" + + +@markers.aws.validated +def test_update_lambda_function_name(s3_create_bucket, deploy_cfn_template, aws_client): + function_name_1 = f"lambda-{short_uid()}" + function_name_2 = f"lambda-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_function_update.yml" + ), + parameters={"FunctionName": function_name_1}, + ) + + function_name = stack.outputs["LambdaName"] + response = aws_client.lambda_.get_function(FunctionName=function_name_1) + assert response["Configuration"]["Environment"]["Variables"]["TEST"] == "ORIGINAL" + + deploy_cfn_template( + stack_name=stack.stack_name, + is_update=True, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_function_update.yml" + ), + parameters={"FunctionName": function_name_2}, + ) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_function(FunctionName=function_name) + + aws_client.lambda_.get_function(FunctionName=function_name_2) + + +@pytest.mark.skip(reason="CFNV2:Describe") +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Metadata", + "$..DriftInformation", + "$..Type", + "$..Message", + "$..access-control-allow-headers", + "$..access-control-allow-methods", + "$..access-control-allow-origin", + "$..access-control-expose-headers", + "$..server", + "$..content-length", + "$..InvokeMode", + ] +) +@markers.aws.validated +def test_cfn_function_url(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + + deploy = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_url.yaml" + ) + ) + + url_logical_resource_id = "UrlD4FAABD0" + snapshot.add_transformer( + snapshot.transform.regex(url_logical_resource_id, "") + ) + snapshot.add_transformer( + snapshot.transform.key_value( + "FunctionUrl", + ) + ) + snapshot.add_transformer( + snapshot.transform.key_value("x-amzn-trace-id", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.key_value("date", reference_replacement=False)) + + url_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deploy.stack_name, LogicalResourceId=url_logical_resource_id + ) + snapshot.match("url_resource", url_resource) + + url_config = aws_client.lambda_.get_function_url_config( + FunctionName=deploy.outputs["LambdaName"] + ) + snapshot.match("url_config", url_config) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_function_url_config( + FunctionName=deploy.outputs["LambdaName"], Qualifier="unknownalias" + ) + + snapshot.match("exception_url_config_nonexistent_version", e.value.response) + + url_config_arn = aws_client.lambda_.get_function_url_config( + FunctionName=deploy.outputs["LambdaArn"] + ) + snapshot.match("url_config_arn", url_config_arn) + + response = safe_requests.get(deploy.outputs["LambdaUrl"]) + assert response.ok + assert response.json() == {"hello": "world"} + + lowered_headers = {k.lower(): v for k, v in response.headers.items()} + snapshot.match("response_headers", lowered_headers) + + +@pytest.mark.skip(reason="CFNV2:Other Function already exists error") +@markers.aws.validated +def test_lambda_alias(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda x: x["LogicalResourceId"]), priority=-1 + ) + + function_name = f"function{short_uid()}" + alias_name = f"alias{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(alias_name, "")) + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_alias.yml" + ), + parameters={"FunctionName": function_name, "AliasName": alias_name}, + ) + + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=alias_name, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + + role_arn = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"]["Role"] + snapshot.add_transformer( + snapshot.transform.regex(role_arn.partition("role/")[-1], ""), priority=-1 + ) + + description = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_name + ) + snapshot.match("stack_resource_descriptions", description) + + alias = aws_client.lambda_.get_alias(FunctionName=function_name, Name=alias_name) + snapshot.match("Alias", alias) + + provisioned_concurrency_config = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=alias_name, + ) + snapshot.match("provisioned_concurrency_config", provisioned_concurrency_config) + + +@markers.aws.validated +def test_lambda_logging_config(deploy_cfn_template, snapshot, aws_client): + function_name = f"function{short_uid()}" + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(SortingTransformer("StackResources", lambda x: x["LogicalResourceId"])) + snapshot.add_transformer( + snapshot.transform.key_value("LogicalResourceId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PhysicalResourceId", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_logging_config.yaml" + ), + parameters={"FunctionName": function_name}, + ) + + description = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_name + ) + snapshot.match("stack_resource_descriptions", description) + + logging_config = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"][ + "LoggingConfig" + ] + snapshot.match("logging_config", logging_config) + + +@pytest.mark.skipif( + not in_default_partition(), reason="Test not applicable in non-default partitions" +) +@markers.aws.validated +def test_lambda_code_signing_config(deploy_cfn_template, snapshot, account_id, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(SortingTransformer("StackResources", lambda x: x["LogicalResourceId"])) + + signer_arn = f"arn:{get_partition(aws_client.lambda_.meta.region_name)}:signer:{aws_client.lambda_.meta.region_name}:{account_id}:/signing-profiles/test" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_code_signing_config.yml" + ), + parameters={"SignerArn": signer_arn}, + ) + + description = aws_client.cloudformation.describe_stack_resources(StackName=stack.stack_name) + snapshot.match("stack_resource_descriptions", description) + + snapshot.match( + "config", + aws_client.lambda_.get_code_signing_config(CodeSigningConfigArn=stack.outputs["Arn"]), + ) + + +@markers.aws.validated +def test_event_invoke_config(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_event_invoke_config.yml" + ), + max_wait=180, + ) + + event_invoke_config = aws_client.lambda_.get_function_event_invoke_config( + FunctionName=stack.outputs["FunctionName"], + Qualifier=stack.outputs["FunctionQualifier"], + ) + + snapshot.match("event_invoke_config", event_invoke_config) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda ZIP flaky in CI + "$..CodeSize", + ] +) +@markers.aws.validated +def test_lambda_version(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]) + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_version.yaml" + ), + max_wait=180, + ) + function_name = deployment.outputs["FunctionName"] + function_version = deployment.outputs["FunctionVersion"] + + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=function_version, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + snapshot.match("stack_resources", stack_resources) + + versions_by_fn = aws_client.lambda_.list_versions_by_function(FunctionName=function_name) + get_function_version = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=function_version + ) + + snapshot.match("versions_by_fn", versions_by_fn) + snapshot.match("get_function_version", get_function_version) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda ZIP flaky in CI + "$..CodeSize", + ] +) +@markers.aws.validated +def test_lambda_version_provisioned_concurrency(deploy_cfn_template, snapshot, aws_client): + """Provisioned concurrency slows down the test case considerably (~2min 40s on AWS) + because CloudFormation waits until the provisioned Lambda functions are ready. + """ + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]) + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_lambda_version_provisioned_concurrency.yaml", + ), + max_wait=240, + ) + function_name = deployment.outputs["FunctionName"] + function_version = deployment.outputs["FunctionVersion"] + + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=function_version, Payload=b"{}" + ) + assert "FunctionError" not in invoke_result + snapshot.match("invoke_result", invoke_result) + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + snapshot.match("stack_resources", stack_resources) + + versions_by_fn = aws_client.lambda_.list_versions_by_function(FunctionName=function_name) + get_function_version = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=function_version + ) + + snapshot.match("versions_by_fn", versions_by_fn) + snapshot.match("get_function_version", get_function_version) + + provisioned_concurrency_config = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ) + snapshot.match("provisioned_concurrency_config", provisioned_concurrency_config) + + +@markers.aws.validated +def test_lambda_cfn_run(deploy_cfn_template, aws_client): + """ + simply deploys a lambda and immediately invokes it + """ + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_simple.yaml" + ), + max_wait=120, + ) + fn_name = deployment.outputs["FunctionName"] + assert ( + aws_client.lambda_.get_function(FunctionName=fn_name)["Configuration"]["State"] + == State.Active + ) + aws_client.lambda_.invoke(FunctionName=fn_name, LogType="Tail", Payload=b"{}") + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.aws.only_localstack(reason="This is functionality specific to Localstack") +def test_lambda_cfn_run_with_empty_string_replacement_deny_list( + deploy_cfn_template, aws_client, monkeypatch +): + """ + deploys the same lambda with an empty CFN string deny list, testing that it behaves as expected + (i.e. the URLs in the deny list are modified) + """ + monkeypatch.setattr(config, "CFN_STRING_REPLACEMENT_DENY_LIST", []) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_lambda_with_external_api_paths_in_env_vars.yaml", + ), + max_wait=120, + ) + function = aws_client.lambda_.get_function(FunctionName=deployment.outputs["FunctionName"]) + function_env_variables = function["Configuration"]["Environment"]["Variables"] + # URLs that match regex to capture AWS URLs gets Localstack port appended - non-matching URLs remain unchanged. + assert function_env_variables["API_URL_1"] == "https://api.example.com" + assert ( + function_env_variables["API_URL_2"] + == "https://storage.execute-api.amazonaws.com:4566/test-resource" + ) + assert ( + function_env_variables["API_URL_3"] + == "https://reporting.execute-api.amazonaws.com:4566/test-resource" + ) + assert ( + function_env_variables["API_URL_4"] + == "https://blockchain.execute-api.amazonaws.com:4566/test-resource" + ) + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.aws.only_localstack(reason="This is functionality specific to Localstack") +def test_lambda_cfn_run_with_non_empty_string_replacement_deny_list( + deploy_cfn_template, aws_client, monkeypatch +): + """ + deploys the same lambda with a non-empty CFN string deny list configurations, testing that it behaves as expected + (i.e. the URLs in the deny list are not modified) + """ + monkeypatch.setattr( + config, + "CFN_STRING_REPLACEMENT_DENY_LIST", + [ + "https://storage.execute-api.us-east-2.amazonaws.com/test-resource", + "https://reporting.execute-api.us-east-1.amazonaws.com/test-resource", + ], + ) + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_lambda_with_external_api_paths_in_env_vars.yaml", + ), + max_wait=120, + ) + function = aws_client.lambda_.get_function(FunctionName=deployment.outputs["FunctionName"]) + function_env_variables = function["Configuration"]["Environment"]["Variables"] + # URLs that match regex to capture AWS URLs but are explicitly in the deny list, don't get modified - + # non-matching URLs remain unchanged. + assert function_env_variables["API_URL_1"] == "https://api.example.com" + assert ( + function_env_variables["API_URL_2"] + == "https://storage.execute-api.us-east-2.amazonaws.com/test-resource" + ) + assert ( + function_env_variables["API_URL_3"] + == "https://reporting.execute-api.us-east-1.amazonaws.com/test-resource" + ) + assert ( + function_env_variables["API_URL_4"] + == "https://blockchain.execute-api.amazonaws.com:4566/test-resource" + ) + + +@pytest.mark.skip(reason="broken/notimplemented") +@markers.aws.validated +def test_lambda_vpc(deploy_cfn_template, aws_client): + """ + this test showcases a very long-running deployment of a fairly straight forward lambda function + cloudformation will poll get_function until the active state has been reached + """ + fn_name = f"vpc-lambda-fn-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_vpc.yaml" + ), + parameters={ + "FunctionNameParam": fn_name, + }, + max_wait=600, + ) + assert ( + aws_client.lambda_.get_function(FunctionName=fn_name)["Configuration"]["State"] + == State.Active + ) + aws_client.lambda_.invoke(FunctionName=fn_name, LogType="Tail", Payload=b"{}") + + +@markers.aws.validated +def test_update_lambda_permissions(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_permission.yml" + ) + ) + + new_principal = aws_client.sts.get_caller_identity()["Account"] + + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + parameters={"PrincipalForPermission": new_principal}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_permission.yml" + ), + ) + + policy = aws_client.lambda_.get_policy(FunctionName=stack.outputs["FunctionName"]) + + # The behaviour of thi principal acocunt setting changes with aws or lambda providers + principal = json.loads(policy["Policy"])["Statement"][0]["Principal"] + if isinstance(principal, dict): + principal = principal.get("AWS") or principal.get("Service", "") + + assert new_principal in principal + + +@markers.aws.validated +def test_multiple_lambda_permissions_for_singlefn(deploy_cfn_template, snapshot, aws_client): + deploy = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_lambda_permission_multiple.yaml", + ), + max_wait=240, + ) + fn_name = deploy.outputs["LambdaName"] + p1_sid = deploy.outputs["PermissionLambda"] + p2_sid = deploy.outputs["PermissionStates"] + + snapshot.add_transformer(snapshot.transform.regex(p1_sid, "")) + snapshot.add_transformer(snapshot.transform.regex(p2_sid, "")) + snapshot.add_transformer(snapshot.transform.regex(fn_name, "")) + snapshot.add_transformer(SortingTransformer("Statement", lambda s: s["Sid"])) + + policy = aws_client.lambda_.get_policy(FunctionName=fn_name) + # load the policy json, so we can properly snapshot it + policy["Policy"] = json.loads(policy["Policy"]) + snapshot.match("policy", policy) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Added by CloudFormation + "$..Tags.'aws:cloudformation:logical-id'", + "$..Tags.'aws:cloudformation:stack-id'", + "$..Tags.'aws:cloudformation:stack-name'", + ] +) +def test_lambda_function_tags(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + function_name = f"fn-{short_uid()}" + environment = f"dev-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(environment, "")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_lambda_with_tags.yml", + ), + parameters={ + "FunctionName": function_name, + "Environment": environment, + }, + ) + snapshot.add_transformer(snapshot.transform.regex(deployment.stack_name, "")) + + get_function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_result", get_function_result) + + +class TestCfnLambdaIntegrations: + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.EffectiveDeliveryPolicy", # broken in sns right now. needs to be wrapped within an http key + "$..Attributes.DeliveryPolicy", # shouldn't be there + "$..Attributes.Policy", # missing SNS:Receive + "$..CodeSize", + "$..Configuration.Layers", + "$..Tags", # missing cloudformation automatic resource tags for the lambda function + ] + ) + @markers.aws.validated + def test_cfn_lambda_permissions(self, deploy_cfn_template, snapshot, aws_client): + """ + * Lambda Function + * Lambda Permission + * SNS Topic + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer( + snapshot.transform.key_value("Sid"), priority=-1 + ) # TODO: need a better snapshot construct here + # Sid format: e.g. `-6JTUCQQ17UXN` + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_lambda_sns_permissions.yaml", + ), + max_wait=240, + ) + + # verify by checking APIs + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + snapshot.match("stack_resources", stack_resources) + + fn_name = deployment.outputs["FunctionName"] + topic_arn = deployment.outputs["TopicArn"] + + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_topic_attributes_result = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + get_policy_result = aws_client.lambda_.get_policy(FunctionName=fn_name) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_topic_attributes_result", get_topic_attributes_result) + snapshot.match("get_policy_result", get_policy_result) + + # check that lambda is invoked + + msg = f"msg-verification-{short_uid()}" + aws_client.sns.publish(Message=msg, TopicArn=topic_arn) + + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + @pytest.mark.skip(reason="CFNV2:Other") + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda + "$..Tags", + "$..Configuration.CodeSize", # Lambda ZIP flaky in CI + # SQS + "$..Attributes.SqsManagedSseEnabled", + # IAM + "$..PolicyNames", + "$..PolicyName", + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..StackResources..PhysicalResourceId", # TODO: compatibility between AWS URL and localstack URL + ] + ) + @markers.aws.validated + def test_cfn_lambda_sqs_source(self, deploy_cfn_template, snapshot, aws_client): + """ + Resources: + * Lambda Function + * SQS Queue + * EventSourceMapping + * IAM Roles/Policies (e.g. sqs:ReceiveMessage for lambda service to poll SQS) + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer(snapshot.transform.key_value("RoleId")) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_sqs_source.yaml" + ), + max_wait=240, + ) + fn_name = deployment.outputs["FunctionName"] + queue_url = deployment.outputs["QueueUrl"] + esm_id = deployment.outputs["ESMId"] + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + + # IAM::Policy seems to have a pretty weird physical resource ID (e.g. stack-fnSe-3OZPF82JL41D) + iam_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deployment.stack_id, LogicalResourceId="fnServiceRoleDefaultPolicy0ED5D3E5" + ) + snapshot.add_transformer( + snapshot.transform.regex( + iam_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + + snapshot.match("stack_resources", stack_resources) + + # query service APIs for resource states + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_esm_result = aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + get_queue_atts_result = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["All"] + ) + role_arn = get_function_result["Configuration"]["Role"] + role_name = role_arn.partition("role/")[-1] + get_role_result = aws_client.iam.get_role(RoleName=role_name) + list_attached_role_policies_result = aws_client.iam.list_attached_role_policies( + RoleName=role_name + ) + list_inline_role_policies_result = aws_client.iam.list_role_policies(RoleName=role_name) + policies = [] + for rp in list_inline_role_policies_result["PolicyNames"]: + get_rp_result = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=rp) + policies.append(get_rp_result) + + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..policies..ResponseMetadata", "", reference_replacement=False + ) + ) + + snapshot.match("role_policies", {"policies": policies}) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_esm_result", get_esm_result) + snapshot.match("get_queue_atts_result", get_queue_atts_result) + snapshot.match("get_role_result", get_role_result) + snapshot.match("list_attached_role_policies_result", list_attached_role_policies_result) + snapshot.match("list_inline_role_policies_result", list_inline_role_policies_result) + + # TODO: extract + # TODO: is this even necessary? should the cloudformation deployment guarantee that this is enabled already? + def wait_esm_active(): + try: + return ( + aws_client.lambda_.get_event_source_mapping(UUID=esm_id)["State"] == "Enabled" + ) + except Exception as e: + print(e) + + assert wait_until(wait_esm_active) + + msg = f"msg-verification-{short_uid()}" + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody=msg) + + # TODO: extract + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + deployment.destroy() + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + # TODO: consider moving into the dedicated DynamoDB => Lambda tests because it tests the filtering functionality rather than CloudFormation (just using CF to deploy resources) + # tests.aws.services.lambda_.test_lambda_integration_dynamodbstreams.TestDynamoDBEventSourceMapping.test_dynamodb_event_filter + @markers.aws.validated + def test_lambda_dynamodb_event_filter( + self, dynamodb_wait_for_table_active, deploy_cfn_template, aws_client, monkeypatch + ): + function_name = f"test-fn-{short_uid()}" + table_name = f"ddb-tbl-{short_uid()}" + + item_to_put = { + "PK": {"S": "person1"}, + "SK": {"S": "details"}, + "name": {"S": "John Doe"}, + } + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/lambda_dynamodb_event_filter.yaml", + ), + parameters={ + "FunctionName": function_name, + "TableName": table_name, + "Filter": '{"dynamodb": {"NewImage": {"homemade": {"S": [{"exists": false}]}}}}', + }, + ) + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put) + + def _send_events(): + log_events = aws_client.logs.filter_log_events( + logGroupName=f"/aws/lambda/{function_name}" + )["events"] + return any("Hello world!" in e["message"] for e in log_events) + + sleep = 10 if os.getenv("TEST_TARGET") == "AWS_CLOUD" else 1 + assert wait_until(_send_events, wait=sleep, max_retries=50) + + @pytest.mark.skip(reason="CFNV2:Describe") + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Lambda + "$..Tags", + "$..Configuration.CodeSize", # Lambda ZIP flaky in CI + # IAM + "$..PolicyNames", + "$..policies..PolicyName", + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..StackResources..LogicalResourceId", + "$..StackResources..PhysicalResourceId", + # dynamodb describe_table + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + # stream result + "$..StreamDescription.CreationRequestDateTime", + ] + ) + @markers.aws.validated + def test_cfn_lambda_dynamodb_source(self, deploy_cfn_template, snapshot, aws_client): + """ + Resources: + * Lambda Function + * DynamoDB Table + Stream + * EventSourceMapping + * IAM Roles/Policies (e.g. dynamodb:GetRecords for lambda service to poll dynamodb) + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer(snapshot.transform.key_value("RoleId")) + snapshot.add_transformer( + snapshot.transform.key_value("ShardId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("StartingSequenceNumber", reference_replacement=False) + ) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/cfn_lambda_dynamodb_source.yaml", + ), + max_wait=240, + ) + fn_name = deployment.outputs["FunctionName"] + table_name = deployment.outputs["TableName"] + stream_arn = deployment.outputs["StreamArn"] + esm_id = deployment.outputs["ESMId"] + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + + # IAM::Policy seems to have a pretty weird physical resource ID (e.g. stack-fnSe-3OZPF82JL41D) + iam_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deployment.stack_id, LogicalResourceId="fnServiceRoleDefaultPolicy0ED5D3E5" + ) + snapshot.add_transformer( + snapshot.transform.regex( + iam_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + + snapshot.match("stack_resources", stack_resources) + + # query service APIs for resource states + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_esm_result = aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + describe_table_result = aws_client.dynamodb.describe_table(TableName=table_name) + describe_stream_result = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn) + role_arn = get_function_result["Configuration"]["Role"] + role_name = role_arn.partition("role/")[-1] + get_role_result = aws_client.iam.get_role(RoleName=role_name) + list_attached_role_policies_result = aws_client.iam.list_attached_role_policies( + RoleName=role_name + ) + list_inline_role_policies_result = aws_client.iam.list_role_policies(RoleName=role_name) + policies = [] + for rp in list_inline_role_policies_result["PolicyNames"]: + get_rp_result = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=rp) + policies.append(get_rp_result) + + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..policies..ResponseMetadata", "", reference_replacement=False + ) + ) + + snapshot.match("role_policies", {"policies": policies}) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_esm_result", get_esm_result) + snapshot.match("describe_table_result", describe_table_result) + snapshot.match("describe_stream_result", describe_stream_result) + snapshot.match("get_role_result", get_role_result) + snapshot.match("list_attached_role_policies_result", list_attached_role_policies_result) + snapshot.match("list_inline_role_policies_result", list_inline_role_policies_result) + + # TODO: extract + # TODO: is this even necessary? should the cloudformation deployment guarantee that this is enabled already? + def wait_esm_active(): + try: + return ( + aws_client.lambda_.get_event_source_mapping(UUID=esm_id)["State"] == "Enabled" + ) + except Exception as e: + print(e) + + assert wait_until(wait_esm_active) + + msg = f"msg-verification-{short_uid()}" + aws_client.dynamodb.put_item( + TableName=table_name, Item={"id": {"S": "test"}, "msg": {"S": msg}} + ) + + # TODO: extract + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + deployment.destroy() + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + @pytest.mark.skip(reason="CFNV2:Describe") + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Role.Description", + "$..Role.MaxSessionDuration", + "$..Configuration.CodeSize", + "$..Tags", + # TODO: wait for ESM to become active in CloudFormation to mitigate these flaky fields + "$..Configuration.LastUpdateStatus", + "$..Configuration.State", + "$..Configuration.StateReason", + "$..Configuration.StateReasonCode", + ], + ) + @markers.aws.validated + def test_cfn_lambda_kinesis_source(self, deploy_cfn_template, snapshot, aws_client): + """ + Resources: + * Lambda Function + * Kinesis Stream + * EventSourceMapping + * IAM Roles/Policies (e.g. kinesis:GetRecords for lambda service to poll kinesis) + """ + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.kinesis_api()) + snapshot.add_transformer( + SortingTransformer("StackResources", lambda sr: sr["LogicalResourceId"]), priority=-1 + ) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer(snapshot.transform.key_value("RoleId")) + snapshot.add_transformer( + snapshot.transform.key_value("ShardId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("StartingSequenceNumber", reference_replacement=False) + ) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_kinesis_source.yaml" + ), + max_wait=240, + ) + fn_name = deployment.outputs["FunctionName"] + stream_name = deployment.outputs["StreamName"] + # stream_arn = deployment.outputs["StreamArn"] + esm_id = deployment.outputs["ESMId"] + + stack_resources = aws_client.cloudformation.describe_stack_resources( + StackName=deployment.stack_id + ) + + # IAM::Policy seems to have a pretty weird physical resource ID (e.g. stack-fnSe-3OZPF82JL41D) + iam_policy_resource = aws_client.cloudformation.describe_stack_resource( + StackName=deployment.stack_id, LogicalResourceId="fnServiceRoleDefaultPolicy0ED5D3E5" + ) + snapshot.add_transformer( + snapshot.transform.regex( + iam_policy_resource["StackResourceDetail"]["PhysicalResourceId"], + "", + ) + ) + + snapshot.match("stack_resources", stack_resources) + + # query service APIs for resource states + get_function_result = aws_client.lambda_.get_function(FunctionName=fn_name) + get_esm_result = aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + describe_stream_result = aws_client.kinesis.describe_stream(StreamName=stream_name) + role_arn = get_function_result["Configuration"]["Role"] + role_name = role_arn.partition("role/")[-1] + get_role_result = aws_client.iam.get_role(RoleName=role_name) + list_attached_role_policies_result = aws_client.iam.list_attached_role_policies( + RoleName=role_name + ) + list_inline_role_policies_result = aws_client.iam.list_role_policies(RoleName=role_name) + policies = [] + for rp in list_inline_role_policies_result["PolicyNames"]: + get_rp_result = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName=rp) + policies.append(get_rp_result) + + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..policies..ResponseMetadata", "", reference_replacement=False + ) + ) + + snapshot.match("role_policies", {"policies": policies}) + snapshot.match("get_function_result", get_function_result) + snapshot.match("get_esm_result", get_esm_result) + snapshot.match("describe_stream_result", describe_stream_result) + snapshot.match("get_role_result", get_role_result) + snapshot.match("list_attached_role_policies_result", list_attached_role_policies_result) + snapshot.match("list_inline_role_policies_result", list_inline_role_policies_result) + + # TODO: extract + # TODO: is this even necessary? should the cloudformation deployment guarantee that this is enabled already? + def wait_esm_active(): + try: + return ( + aws_client.lambda_.get_event_source_mapping(UUID=esm_id)["State"] == "Enabled" + ) + except Exception as e: + print(e) + + assert wait_until(wait_esm_active) + + msg = f"msg-verification-{short_uid()}" + data_msg = to_str(base64.b64encode(to_bytes(msg))) + aws_client.kinesis.put_record( + StreamName=stream_name, Data=msg, PartitionKey="samplepartitionkey" + ) + + # TODO: extract + def wait_logs(): + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + return any(data_msg in e["message"] for e in log_events) + + assert wait_until(wait_logs) + + deployment.destroy() + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_event_source_mapping(UUID=esm_id) + + +class TestCfnLambdaDestinations: + """ + generic cases + 1. verify payload + + - [ ] SNS destination success + - [ ] SNS destination failure + - [ ] SQS destination success + - [ ] SQS destination failure + - [ ] Lambda destination success + - [ ] Lambda destination failure + - [ ] EventBridge destination success + - [ ] EventBridge destination failure + + meta cases + * test max event age + * test retry count + * qualifier issues + * reserved concurrency set to 0 => should immediately go to failure destination / dlq + * combination with DLQ + * test with a very long queue (reserved concurrency 1, high function duration, low max event age) + + edge cases + - [ ] Chaining async lambdas + + doc: + "If the function doesn't have enough concurrency available to process all events, additional requests are throttled. + For throttling errors (429) and system errors (500-series), Lambda returns the event to the queue and attempts to run the function again for up to 6 hours. + The retry interval increases exponentially from 1 second after the first attempt to a maximum of 5 minutes. + If the queue contains many entries, Lambda increases the retry interval and reduces the rate at which it reads events from the queue." + + """ + + @pytest.mark.parametrize( + ["on_success", "on_failure"], + [ + ("sqs", "sqs"), + # TODO: test needs further work + # ("sns", "sns"), + # ("lambda", "lambda"), + # ("eventbridge", "eventbridge") + ], + ) + @markers.aws.validated + def test_generic_destination_routing( + self, deploy_cfn_template, on_success, on_failure, aws_client + ): + """ + This fairly simple template lets us choose between the 4 different destinations for both OnSuccess as well as OnFailure. + The template chooses between one of 4 ARNs via indexed access according to this mapping: + + 0: SQS + 1: SNS + 2: Lambda + 3: EventBridge + + All of them are connected downstream to another Lambda function. + This function can be used to verify that the payload has propagated through the hole scenario. + It also allows us to verify the specific payload format depending on the service integration. + + β”‚ + β–Ό + Lambda + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό β–Ό + (direct) SQS SNS EventBridge + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”¬β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + Lambda + + # TODO: fix eventbridge name (reuse?) + """ + + name_to_index_map = {"sqs": "0", "sns": "1", "lambda": "2", "eventbridge": "3"} + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_destinations.yaml" + ), + parameters={ + # "RetryParam": "", + # "MaxEventAgeSecondsParam": "", + # "QualifierParameter": "", + "OnSuccessSwitch": name_to_index_map[on_success], + "OnFailureSwitch": name_to_index_map[on_failure], + }, + max_wait=600, + ) + + invoke_fn_name = deployment.outputs["LambdaName"] + collect_fn_name = deployment.outputs["CollectLambdaName"] + + msg = f"message-{short_uid()}" + + # Success case + aws_client.lambda_.invoke( + FunctionName=invoke_fn_name, + Payload=to_bytes(json.dumps({"message": msg, "should_fail": "0"})), + InvocationType=InvocationType.Event, + ) + + # Failure case + aws_client.lambda_.invoke( + FunctionName=invoke_fn_name, + Payload=to_bytes(json.dumps({"message": msg, "should_fail": "1"})), + InvocationType=InvocationType.Event, + ) + + def wait_for_logs(): + events = aws_client.logs.filter_log_events( + logGroupName=f"/aws/lambda/{collect_fn_name}" + )["events"] + message_events = [e["message"] for e in events if msg in e["message"]] + return len(message_events) >= 2 + # return len(events) >= 6 # note: each invoke comes with at least 3 events even without printing + + wait_until(wait_for_logs) + + +@markers.aws.validated +def test_python_lambda_code_deployed_via_s3(deploy_cfn_template, aws_client, s3_bucket): + bucket_key = "handler.zip" + zip_file = create_lambda_archive( + load_file( + os.path.join(os.path.dirname(__file__), "../../../../lambda_/functions/lambda_echo.py") + ), + get_content=True, + runtime=Runtime.python3_12, + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_s3_code.yaml" + ), + parameters={ + "LambdaCodeBucket": s3_bucket, + "LambdaRuntime": "python3.10", + "LambdaHandler": "handler.handler", + }, + ) + + function_name = deployment.outputs["LambdaName"] + invocation_result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=json.dumps({"hello": "world"}) + ) + payload = json.load(invocation_result["Payload"]) + assert payload == {"hello": "world"} + assert invocation_result["StatusCode"] == 200 + + +@markers.aws.validated +def test_lambda_cfn_dead_letter_config_async_invocation( + deploy_cfn_template, aws_client, s3_create_bucket, snapshot +): + # invoke intentionally failing lambda async, which then forwards to the DLQ as configured. + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + + # cfn template was generated via serverless, but modified to work with pure cloudformation + s3_bucket = s3_create_bucket() + bucket_key = "serverless/dlq/local/1701682216701-2023-12-04T09:30:16.701Z/dlq.zip" + + zip_file = create_lambda_archive( + load_file( + os.path.join( + os.path.dirname(__file__), "../../../../lambda_/functions/lambda_handler_error.py" + ) + ), + get_content=True, + runtime=Runtime.python3_12, + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_lambda_serverless.yml" + ), + parameters={"LambdaCodeBucket": s3_bucket}, + ) + function_name = deployment.outputs["LambdaName"] + + # async invocation + aws_client.lambda_.invoke(FunctionName=function_name, InvocationType="Event") + dlq_queue = deployment.outputs["DLQName"] + response = {} + + def check_dlq_message(response: dict): + response.update(aws_client.sqs.receive_message(QueueUrl=dlq_queue, VisibilityTimeout=0)) + assert response.get("Messages") + + retry(check_dlq_message, response=response, retries=5, sleep=2.5) + snapshot.match("failed-async-lambda", response) + + +@markers.aws.validated +def test_lambda_layer_crud(deploy_cfn_template, aws_client, s3_bucket, snapshot): + snapshot.add_transformers_list( + [snapshot.transform.key_value("LambdaName"), snapshot.transform.key_value("layer-name")] + ) + + layer_name = f"layer-{short_uid()}" + snapshot.match("layer-name", layer_name) + + bucket_key = "layer.zip" + zip_file = create_lambda_archive( + "hello", + get_content=True, + runtime=Runtime.python3_12, + file_name="hello.txt", + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + deployment = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/lambda_layer_version.yml" + ), + parameters={"LayerBucket": s3_bucket, "LayerName": layer_name}, + ) + snapshot.match("cfn-output", deployment.outputs) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.snapshot.json new file mode 100644 index 0000000000000..f5743e2e003e4 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.snapshot.json @@ -0,0 +1,1892 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_cfn_function_url": { + "recorded-date": "16-04-2024, 08:16:02", + "recorded-content": { + "url_resource": { + "StackResourceDetail": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "", + "Metadata": {}, + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Url", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exception_url_config_nonexistent_version": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "url_config_arn": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response_headers": { + "connection": "keep-alive", + "content-length": "17", + "content-type": "application/json", + "date": "date", + "x-amzn-requestid": "", + "x-amzn-trace-id": "x-amzn-trace-id" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_alias": { + "recorded-date": "07-05-2025, 15:39:26", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "function_version": "1", + "initialization_type": null + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "FunctionAlias", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Alias", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "LambdaFunction", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "MyFnServiceRole", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Version", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Alias": { + "AliasArn": "arn::lambda::111111111111:function:", + "Description": "", + "FunctionVersion": "1", + "Name": "", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "provisioned_concurrency_config": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_permissions": { + "recorded-date": "09-04-2024, 07:26:03", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnAllowInvokeLambdaPermissionsStacktopicF723B1A748672DB5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Permission", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fntopic09ED913A", + "PhysicalResourceId": "arn::sns::111111111111::", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Subscription", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "topic69831491", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_topic_attributes_result": { + "Attributes": { + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_result": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::sns::111111111111:" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_sqs_source": { + "recorded-date": "30-10-2024, 14:48:16", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRoleDefaultPolicy0ED5D3E5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnSqsEventSourceLambdaSqsSourceStackq2097017B53C3FF8C", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::EventSourceMapping", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "q14836DC8", + "PhysicalResourceId": "https://sqs..amazonaws.com/111111111111/", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SQS::Queue", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "sqs:ReceiveMessage", + "sqs:ChangeMessageVisibility", + "sqs:GetQueueUrl", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Effect": "Allow", + "Resource": "arn::sqs::111111111111:" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "fnServiceRoleDefaultPolicy0ED5D3E5", + "RoleName": "", + "ResponseMetadata": "" + } + ] + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_esm_result": { + "BatchSize": 1, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Enabled", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_queue_atts_result": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "CreatedTimestamp": "timestamp", + "DelaySeconds": "0", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_attached_role_policies_result": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "PolicyName": "AWSLambdaBasicExecutionRole" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_inline_role_policies_result": { + "IsTruncated": false, + "PolicyNames": [ + "fnServiceRoleDefaultPolicy0ED5D3E5" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_code_signing_config": { + "recorded-date": "09-04-2024, 07:19:51", + "recorded-content": { + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "CodeSigningConfig", + "PhysicalResourceId": "arn::lambda::111111111111:code-signing-config:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::CodeSigningConfig", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "config": { + "CodeSigningConfig": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Enforce" + }, + "Description": "Code Signing", + "LastModified": "date" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_event_invoke_config": { + "recorded-date": "09-04-2024, 07:20:36", + "recorded-content": { + "event_invoke_config": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 300, + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_version": { + "recorded-date": "07-05-2025, 13:19:10", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "function_version": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnVersion7BF8AE5A", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "versions_by_fn": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_version": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_dynamodb_source": { + "recorded-date": "12-10-2024, 10:46:17", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnDynamoDBEventSourceLambdaDynamodbSourceStacktable153BBA79064FDF1D", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::EventSourceMapping", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRoleDefaultPolicy0ED5D3E5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "table8235A42E", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::DynamoDB::Table", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "dynamodb:ListStreams", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator" + ], + "Effect": "Allow", + "Resource": "arn::dynamodb::111111111111:table//stream/" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "fnServiceRoleDefaultPolicy0ED5D3E5", + "RoleName": "", + "ResponseMetadata": "" + } + ] + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_esm_result": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_table_result": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stream_result": { + "StreamDescription": { + "CreationRequestDateTime": "datetime", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "Shards": [ + { + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "shard-id" + } + ], + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "StreamStatus": "ENABLED", + "StreamViewType": "NEW_AND_OLD_IMAGES", + "TableName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_attached_role_policies_result": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "PolicyName": "AWSLambdaBasicExecutionRole" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_inline_role_policies_result": { + "IsTruncated": false, + "PolicyNames": [ + "fnServiceRoleDefaultPolicy0ED5D3E5" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_kinesis_source": { + "recorded-date": "12-10-2024, 10:52:28", + "recorded-content": { + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnKinesisEventSourceLambdaKinesisSourceStackstream996A3395ED86A30E", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::EventSourceMapping", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRoleDefaultPolicy0ED5D3E5", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Policy", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "stream19075594", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Kinesis::Stream", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role_policies": { + "policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kinesis:DescribeStreamSummary", + "kinesis:GetRecords", + "kinesis:GetShardIterator", + "kinesis:ListShards", + "kinesis:SubscribeToShard", + "kinesis:DescribeStream", + "kinesis:ListStreams" + ], + "Effect": "Allow", + "Resource": "arn::kinesis::111111111111:stream/" + }, + { + "Action": "kinesis:DescribeStream", + "Effect": "Allow", + "Resource": "arn::kinesis::111111111111:stream/" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "fnServiceRoleDefaultPolicy0ED5D3E5", + "RoleName": "", + "ResponseMetadata": "" + } + ] + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "fn5FF616E3", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_esm_result": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 10, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_stream_result": { + "StreamDescription": { + "EncryptionType": "NONE", + "EnhancedMonitoring": [ + { + "ShardLevelMetrics": [] + } + ], + "HasMoreShards": false, + "RetentionPeriodHours": 24, + "Shards": [ + { + "HashKeyRange": { + "EndingHashKey": "ending_hash", + "StartingHashKey": "starting_hash" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "shard-id" + } + ], + "StreamARN": "arn::kinesis::111111111111:stream/", + "StreamCreationTimestamp": "timestamp", + "StreamModeDetails": { + "StreamMode": "PROVISIONED" + }, + "StreamName": "", + "StreamStatus": "ACTIVE" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "datetime", + "Description": "", + "MaxSessionDuration": 3600, + "Path": "/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_attached_role_policies_result": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "PolicyName": "AWSLambdaBasicExecutionRole" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_inline_role_policies_result": { + "IsTruncated": false, + "PolicyNames": [ + "fnServiceRoleDefaultPolicy0ED5D3E5" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_multiple_lambda_permissions_for_singlefn": { + "recorded-date": "09-04-2024, 07:25:05", + "recorded-content": { + "policy": { + "Policy": { + "Id": "default", + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Resource": "arn::lambda::111111111111:function:", + "Sid": "" + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Principal": { + "Service": "states.amazonaws.com" + }, + "Resource": "arn::lambda::111111111111:function:", + "Sid": "" + } + ], + "Version": "2012-10-17" + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_cfn_dead_letter_config_async_invocation": { + "recorded-date": "09-04-2024, 07:39:50", + "recorded-content": { + "failed-async-lambda": { + "Messages": [ + { + "Body": {}, + "MD5OfBody": "99914b932bd37a50b983c5e7c90ae93b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter_update": { + "recorded-date": "12-10-2024, 10:42:00", + "recorded-content": { + "source_mappings": { + "EventSourceMappings": [ + { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "eventName": [ + "DELETE" + ] + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_source_mappings": { + "EventSourceMappings": [ + { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "eventName": [ + "MODIFY" + ] + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_function_tags": { + "recorded-date": "01-10-2024, 12:52:51", + "recorded-content": { + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "Environment": "", + "aws:cloudformation:logical-id": "TestFunction", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "lambda:createdBy": "SAM" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_layer_crud": { + "recorded-date": "20-12-2024, 18:23:31", + "recorded-content": { + "layer-name": "", + "cfn-output": { + "LambdaArn": "arn::lambda::111111111111:function:", + "LambdaName": "", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "LayerVersionRef": "arn::lambda::111111111111:layer::1" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_logging_config": { + "recorded-date": "08-04-2025, 12:10:56", + "recorded-content": { + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "logical-resource-id", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "logical-resource-id", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "logical-resource-id", + "PhysicalResourceId": "physical-resource-id", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logging_config": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": { + "recorded-date": "07-05-2025, 13:23:25", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "1", + "Payload": { + "initialization_type": "provisioned-concurrency" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stack_resources": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fn5FF616E3", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Function", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnServiceRole5D180AFD", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::IAM::Role", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "fnVersion7BF8AE5A", + "PhysicalResourceId": "arn::lambda::111111111111:function:", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Lambda::Version", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "versions_by_fn": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_version": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "test description", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "provisioned_concurrency_config": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.validation.json new file mode 100644 index 0000000000000..759e47d6a6561 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.validation.json @@ -0,0 +1,71 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaDestinations::test_generic_destination_routing[sqs-sqs]": { + "last_validated_date": "2024-12-10T16:48:04+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_dynamodb_source": { + "last_validated_date": "2024-10-12T10:46:17+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_kinesis_source": { + "last_validated_date": "2024-10-12T10:52:28+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_permissions": { + "last_validated_date": "2024-04-09T07:26:03+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_cfn_lambda_sqs_source": { + "last_validated_date": "2024-10-30T14:48:16+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::TestCfnLambdaIntegrations::test_lambda_dynamodb_event_filter": { + "last_validated_date": "2024-04-09T07:31:17+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_cfn_function_url": { + "last_validated_date": "2024-04-16T08:16:02+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_event_invoke_config": { + "last_validated_date": "2024-04-09T07:20:36+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_alias": { + "last_validated_date": "2025-05-07T15:39:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_cfn_dead_letter_config_async_invocation": { + "last_validated_date": "2024-04-09T07:39:50+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_cfn_run": { + "last_validated_date": "2024-04-09T07:22:32+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_code_signing_config": { + "last_validated_date": "2024-04-09T07:19:51+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_function_tags": { + "last_validated_date": "2024-10-01T12:52:51+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_layer_crud": { + "last_validated_date": "2024-12-20T18:23:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_logging_config": { + "last_validated_date": "2025-04-08T12:12:01+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_version": { + "last_validated_date": "2025-05-07T13:19:10+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_version_provisioned_concurrency": { + "last_validated_date": "2025-05-07T13:23:25+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_lambda_w_dynamodb_event_filter_update": { + "last_validated_date": "2024-12-11T09:03:52+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_multiple_lambda_permissions_for_singlefn": { + "last_validated_date": "2024-04-09T07:25:05+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_python_lambda_code_deployed_via_s3": { + "last_validated_date": "2024-04-09T07:38:32+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_update_lambda_function": { + "last_validated_date": "2024-11-07T03:16:40+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_update_lambda_function_name": { + "last_validated_date": "2024-11-07T03:10:48+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_lambda.py::test_update_lambda_permissions": { + "last_validated_date": "2024-04-09T07:23:41+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.py new file mode 100644 index 0000000000000..75afa2549b354 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.py @@ -0,0 +1,60 @@ +import os.path + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_logstream(deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/logs_group_and_stream.yaml" + ) + ) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("LogGroupNameOutput")) + + group_name = stack.outputs["LogGroupNameOutput"] + stream_name = stack.outputs["LogStreamNameOutput"] + + snapshot.match("outputs", stack.outputs) + + streams = aws_client.logs.describe_log_streams( + logGroupName=group_name, logStreamNamePrefix=stream_name + )["logStreams"] + assert aws_client.logs.meta.partition == streams[0]["arn"].split(":")[1] + snapshot.match("describe_log_streams", streams) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..logGroups..logGroupArn", + "$..logGroups..logGroupClass", + "$..logGroups..retentionInDays", + ] +) +def test_cfn_handle_log_group_resource(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/logs_group.yml" + ) + ) + + log_group_prefix = stack.outputs["LogGroupNameOutput"] + + response = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_prefix) + snapshot.match("describe_log_groups", response) + snapshot.add_transformer(snapshot.transform.key_value("logGroupName")) + + stack.destroy() + response = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_prefix) + assert len(response["logGroups"]) == 0 diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.snapshot.json new file mode 100644 index 0000000000000..29964de53c6a8 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.snapshot.json @@ -0,0 +1,42 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.py::test_logstream": { + "recorded-date": "29-07-2022, 13:22:53", + "recorded-content": { + "outputs": { + "LogStreamNameOutput": "", + "LogGroupNameOutput": "" + }, + "describe_log_streams": [ + { + "logStreamName": "", + "creationTime": "timestamp", + "arn": "arn::logs::111111111111:log-group::log-stream:", + "storedBytes": 0 + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.py::test_cfn_handle_log_group_resource": { + "recorded-date": "20-06-2024, 16:15:47", + "recorded-content": { + "describe_log_groups": { + "logGroups": [ + { + "arn": "arn::logs::111111111111:log-group::*", + "creationTime": "timestamp", + "logGroupArn": "arn::logs::111111111111:log-group:", + "logGroupClass": "STANDARD", + "logGroupName": "", + "metricFilterCount": 0, + "retentionInDays": 731, + "storedBytes": 0 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.validation.json new file mode 100644 index 0000000000000..fce835093de2a --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_logs.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/resources/test_logs.py::test_cfn_handle_log_group_resource": { + "last_validated_date": "2024-06-20T16:15:47+00:00" + }, + "tests/aws/services/cloudformation/resources/test_logs.py::test_logstream": { + "last_validated_date": "2022-07-29T11:22:53+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py new file mode 100644 index 0000000000000..8cb3ad8dbe6d3 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py @@ -0,0 +1,97 @@ +import os +from operator import itemgetter + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@pytest.mark.skip(reason="flaky") +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ClusterConfig.DedicatedMasterCount", # added in LS + "$..ClusterConfig.DedicatedMasterEnabled", # added in LS + "$..ClusterConfig.DedicatedMasterType", # added in LS + "$..SoftwareUpdateOptions", # missing + "$..OffPeakWindowOptions", # missing + "$..ChangeProgressDetails", # missing + "$..AutoTuneOptions.UseOffPeakWindow", # missing + "$..ClusterConfig.MultiAZWithStandbyEnabled", # missing + "$..AdvancedSecurityOptions.AnonymousAuthEnabled", # missing + # TODO different values: + "$..Processing", + "$..ServiceSoftwareOptions.CurrentVersion", + "$..ClusterConfig.DedicatedMasterEnabled", + "$..ClusterConfig.InstanceType", # TODO the type was set in cfn + "$..AutoTuneOptions.State", + '$..AdvancedOptions."rest.action.multi.allow_explicit_index"', # TODO this was set to false in cfn + ] +) +def test_domain(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("DomainId")) + snapshot.add_transformer(snapshot.transform.key_value("DomainName")) + snapshot.add_transformer(snapshot.transform.key_value("ChangeId")) + snapshot.add_transformer(snapshot.transform.key_value("Endpoint"), priority=-1) + template_path = os.path.join( + os.path.dirname(__file__), "../../../../../templates/opensearch_domain.yml" + ) + result = deploy_cfn_template(template_path=template_path) + domain_endpoint = result.outputs["SearchDomainEndpoint"] + assert domain_endpoint + domain_arn = result.outputs["SearchDomainArn"] + assert domain_arn + domain_name = result.outputs["SearchDomain"] + + domain = aws_client.opensearch.describe_domain(DomainName=domain_name) + assert domain["DomainStatus"] + snapshot.match("describe_domain", domain) + + assert domain_arn == domain["DomainStatus"]["ARN"] + tags_result = aws_client.opensearch.list_tags(ARN=domain_arn) + tags_result["TagList"].sort(key=itemgetter("Key")) + snapshot.match("list_tags", tags_result) + + +@pytest.mark.skip(reason="CFNV2:AdvancedOptions unsupported") +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DomainStatus.AccessPolicies", + "$..DomainStatus.AdvancedOptions.override_main_response_version", + "$..DomainStatus.AdvancedSecurityOptions.AnonymousAuthEnabled", + "$..DomainStatus.AutoTuneOptions.State", + "$..DomainStatus.AutoTuneOptions.UseOffPeakWindow", + "$..DomainStatus.ChangeProgressDetails", + "$..DomainStatus.ClusterConfig.DedicatedMasterCount", + "$..DomainStatus.ClusterConfig.InstanceCount", + "$..DomainStatus.ClusterConfig.MultiAZWithStandbyEnabled", + "$..DomainStatus.ClusterConfig.ZoneAwarenessConfig", + "$..DomainStatus.ClusterConfig.ZoneAwarenessEnabled", + "$..DomainStatus.EBSOptions.VolumeSize", + "$..DomainStatus.Endpoint", + "$..DomainStatus.OffPeakWindowOptions", + "$..DomainStatus.ServiceSoftwareOptions.CurrentVersion", + "$..DomainStatus.SoftwareUpdateOptions", + ] +) +def test_domain_with_alternative_types(deploy_cfn_template, aws_client, snapshot): + """ + Test that the alternative types for the OpenSearch domain are accepted using the resource documentation example + """ + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/opensearch_domain_alternative_types.yml", + ) + ) + domain_name = stack.outputs["SearchDomain"] + domain = aws_client.opensearch.describe_domain(DomainName=domain_name) + snapshot.match("describe_domain", domain) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.snapshot.json new file mode 100644 index 0000000000000..8d0498795db31 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.snapshot.json @@ -0,0 +1,225 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py::test_domain": { + "recorded-date": "31-08-2023, 17:42:29", + "recorded-content": { + "describe_domain": { + "DomainStatus": { + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": "", + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "false" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "ENABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "DomainId": "", + "DomainName": "", + "EBSOptions": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "", + "EngineVersion": "OpenSearch_2.5", + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "datetime", + "Cancellable": false, + "CurrentVersion": "OpenSearch_2_5_R20230308-P4", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags": { + "TagList": [ + { + "Key": "anotherkey", + "Value": "hello" + }, + { + "Key": "foo", + "Value": "bar" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py::test_domain_with_alternative_types": { + "recorded-date": "05-10-2023, 11:07:39", + "recorded-content": { + "describe_domain": { + "DomainStatus": { + "ARN": "arn::es::111111111111:domain/test-opensearch-domain", + "AccessPolicies": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:root" + }, + "Action": "es:*", + "Resource": "arn::es::111111111111:domain/test-opensearch-domain/*" + } + ] + }, + "AdvancedOptions": { + "override_main_response_version": "true", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "ENABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterCount": 3, + "DedicatedMasterEnabled": true, + "DedicatedMasterType": "m3.medium.search", + "InstanceCount": 2, + "InstanceType": "m3.medium.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessConfig": { + "AvailabilityZoneCount": 2 + }, + "ZoneAwarenessEnabled": true + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "DomainId": "111111111111/test-opensearch-domain", + "DomainName": "test-opensearch-domain", + "EBSOptions": { + "EBSEnabled": true, + "Iops": 0, + "VolumeSize": 20, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "search-test-opensearch-domain-lwnlbu3h4beauepbhlq5emyh3m..es.amazonaws.com", + "EngineVersion": "OpenSearch_1.0", + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "datetime", + "Cancellable": false, + "CurrentVersion": "OpenSearch_1_0_R20230928", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.validation.json new file mode 100644 index 0000000000000..1769b2a88f224 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py::test_domain": { + "last_validated_date": "2023-08-31T15:42:29+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_opensearch.py::test_domain_with_alternative_types": { + "last_validated_date": "2023-10-05T09:07:39+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.py new file mode 100644 index 0000000000000..b0c4f0b91b6a3 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.py @@ -0,0 +1,28 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +# only runs in Docker when run against Pro (since it needs postgres on the system) +@markers.only_in_docker +@markers.aws.validated +def test_redshift_cluster(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/cfn_redshift.yaml" + ) + ) + + # very basic test to check the cluster deploys + assert stack.outputs["ClusterRef"] + assert stack.outputs["ClusterAttEndpointPort"] + assert stack.outputs["ClusterAttEndpointAddress"] diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.validation.json new file mode 100644 index 0000000000000..69f04be2accfe --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_redshift.py::test_redshift_cluster": { + "last_validated_date": "2024-02-28T12:42:35+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.py new file mode 100644 index 0000000000000..db32df5683969 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.py @@ -0,0 +1,25 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Group.Description", "$..Group.GroupArn"]) +def test_group_defaults(aws_client, deploy_cfn_template, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/resource_group_defaults.yml" + ), + ) + + resource_group = aws_client.resource_groups.get_group(GroupName=stack.outputs["ResourceGroup"]) + snapshot.match("resource-group", resource_group) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.snapshot.json new file mode 100644 index 0000000000000..a3f11aeabdeed --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.snapshot.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.py::test_group_defaults": { + "recorded-date": "16-07-2024, 15:15:11", + "recorded-content": { + "resource-group": { + "Group": { + "GroupArn": "arn::resource-groups::111111111111:group/testgroup", + "Name": "testgroup" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.validation.json new file mode 100644 index 0000000000000..33b1cf0308598 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_resource_groups.py::test_group_defaults": { + "last_validated_date": "2024-07-16T15:15:11+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py new file mode 100644 index 0000000000000..06cc700e4b077 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py @@ -0,0 +1,76 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_create_record_set_via_id(route53_hosted_zone, deploy_cfn_template): + create_zone_response = route53_hosted_zone() + hosted_zone_id = create_zone_response["HostedZone"]["Id"] + route53_name = create_zone_response["HostedZone"]["Name"] + parameters = {"HostedZoneId": hosted_zone_id, "Name": route53_name} + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/route53_hostedzoneid_template.yaml" + ), + parameters=parameters, + max_wait=300, + ) + + +@markers.aws.validated +def test_create_record_set_via_name(deploy_cfn_template, route53_hosted_zone): + create_zone_response = route53_hosted_zone() + route53_name = create_zone_response["HostedZone"]["Name"] + parameters = {"HostedZoneName": route53_name, "Name": route53_name} + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/route53_hostedzonename_template.yaml", + ), + parameters=parameters, + ) + + +@markers.aws.validated +def test_create_record_set_without_resource_record(deploy_cfn_template, route53_hosted_zone): + create_zone_response = route53_hosted_zone() + hosted_zone_id = create_zone_response["HostedZone"]["Id"] + route53_name = create_zone_response["HostedZone"]["Name"] + parameters = {"HostedZoneId": hosted_zone_id, "Name": route53_name} + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/route53_recordset_without_resource_records.yaml", + ), + parameters=parameters, + ) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..HealthCheckConfig.EnableSNI", "$..HealthCheckVersion"] +) +def test_create_health_check(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/route53_healthcheck.yml", + ), + ) + health_check_id = stack.outputs["HealthCheckId"] + print(health_check_id) + health_check = aws_client.route53.get_health_check(HealthCheckId=health_check_id) + + snapshot.add_transformer(snapshot.transform.key_value("Id", "id")) + snapshot.add_transformer(snapshot.transform.key_value("CallerReference", "caller-reference")) + snapshot.match("HealthCheck", health_check["HealthCheck"]) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.snapshot.json new file mode 100644 index 0000000000000..46eb1e650d88c --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.snapshot.json @@ -0,0 +1,25 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py::test_create_health_check": { + "recorded-date": "22-09-2023, 13:50:49", + "recorded-content": { + "HealthCheck": { + "CallerReference": "", + "HealthCheckConfig": { + "Disabled": false, + "EnableSNI": false, + "FailureThreshold": 3, + "FullyQualifiedDomainName": "localstacktest.com", + "IPAddress": "1.1.1.1", + "Inverted": false, + "MeasureLatency": false, + "Port": 80, + "RequestInterval": 30, + "ResourcePath": "/health", + "Type": "HTTP" + }, + "HealthCheckVersion": 1, + "Id": "" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.validation.json new file mode 100644 index 0000000000000..856faff5c112c --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_route53.py::test_create_health_check": { + "last_validated_date": "2023-09-22T11:50:49+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py new file mode 100644 index 0000000000000..da1be1a4a16d2 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py @@ -0,0 +1,155 @@ +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_bucketpolicy(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + bucket_name = f"ls-bucket-{short_uid()}" + snapshot.match("bucket", {"BucketName": bucket_name}) + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_bucketpolicy.yaml" + ), + parameters={"BucketName": bucket_name}, + template_mapping={"include_policy": True}, + ) + response = aws_client.s3.get_bucket_policy(Bucket=bucket_name)["Policy"] + snapshot.match("get-policy-true", response) + + deploy_cfn_template( + is_update=True, + stack_name=deploy_result.stack_id, + parameters={"BucketName": bucket_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_bucketpolicy.yaml" + ), + template_mapping={"include_policy": False}, + ) + with pytest.raises(ClientError) as err: + aws_client.s3.get_bucket_policy(Bucket=bucket_name) + snapshot.match("no-policy", err.value.response) + + +@markers.aws.validated +def test_bucket_autoname(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_bucket_autoname.yaml" + ) + ) + descr_response = aws_client.cloudformation.describe_stacks(StackName=result.stack_id) + output = descr_response["Stacks"][0]["Outputs"][0] + assert output["OutputKey"] == "BucketNameOutput" + assert result.stack_name.lower() in output["OutputValue"] + + +@markers.aws.validated +def test_bucket_versioning(deploy_cfn_template, aws_client): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_versioned_bucket.yaml" + ) + ) + assert "BucketName" in result.outputs + bucket_name = result.outputs["BucketName"] + bucket_version = aws_client.s3.get_bucket_versioning(Bucket=bucket_name) + assert bucket_version["Status"] == "Enabled" + + +@markers.aws.validated +def test_website_configuration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + bucket_name_generated = f"ls-bucket-{short_uid()}" + + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_bucket_website_config.yaml" + ), + parameters={"BucketName": bucket_name_generated}, + ) + + bucket_name = result.outputs["BucketNameOutput"] + assert bucket_name_generated == bucket_name + website_url = result.outputs["WebsiteURL"] + assert website_url.startswith(f"http://{bucket_name}.s3-website") + response = aws_client.s3.get_bucket_website(Bucket=bucket_name) + + snapshot.match("get_bucket_website", response) + + +@markers.aws.validated +def test_cors_configuration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_cors_bucket.yaml" + ), + ) + bucket_name_optional = result.outputs["BucketNameAllParameters"] + cors_info = aws_client.s3.get_bucket_cors(Bucket=bucket_name_optional) + snapshot.match("cors-info-optional", cors_info) + + bucket_name_required = result.outputs["BucketNameOnlyRequired"] + cors_info = aws_client.s3.get_bucket_cors(Bucket=bucket_name_required) + snapshot.match("cors-info-only-required", cors_info) + + +@markers.aws.validated +def test_object_lock_configuration(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_object_lock_config.yaml" + ), + ) + bucket_name_optional = result.outputs["LockConfigAllParameters"] + cors_info = aws_client.s3.get_object_lock_configuration(Bucket=bucket_name_optional) + snapshot.match("object-lock-info-with-configuration", cors_info) + + bucket_name_required = result.outputs["LockConfigOnlyRequired"] + cors_info = aws_client.s3.get_object_lock_configuration(Bucket=bucket_name_required) + snapshot.match("object-lock-info-only-enabled", cors_info) + + +@markers.aws.validated +def test_cfn_handle_s3_notification_configuration( + aws_client, + deploy_cfn_template, + snapshot, +): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/s3_notification_sqs.yml" + ), + ) + rs = aws_client.s3.get_bucket_notification_configuration(Bucket=stack.outputs["BucketName"]) + snapshot.match("get_bucket_notification_configuration", rs) + + stack.destroy() + + with pytest.raises(ClientError) as ctx: + aws_client.s3.get_bucket_notification_configuration(Bucket=stack.outputs["BucketName"]) + snapshot.match("get_bucket_notification_configuration_error", ctx.value.response) + + snapshot.add_transformer(snapshot.transform.key_value("Id")) + snapshot.add_transformer(snapshot.transform.key_value("QueueArn")) + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.snapshot.json new file mode 100644 index 0000000000000..de27f0ba24420 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.snapshot.json @@ -0,0 +1,175 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_cors_configuration": { + "recorded-date": "20-04-2023, 20:17:17", + "recorded-content": { + "cors-info-optional": { + "CORSRules": [ + { + "AllowedHeaders": [ + "*", + "x-amz-*" + ], + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ], + "ExposeHeaders": [ + "Date" + ], + "ID": "test-cors-id", + "MaxAgeSeconds": 3600 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cors-info-only-required": { + "CORSRules": [ + { + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_website_configuration": { + "recorded-date": "02-06-2023, 18:24:39", + "recorded-content": { + "get_bucket_website": { + "ErrorDocument": { + "Key": "error.html" + }, + "IndexDocument": { + "Suffix": "index.html" + }, + "RoutingRules": [ + { + "Condition": { + "HttpErrorCodeReturnedEquals": "404", + "KeyPrefixEquals": "out1/" + }, + "Redirect": { + "ReplaceKeyWith": "redirected.html" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_object_lock_configuration": { + "recorded-date": "15-01-2024, 02:31:58", + "recorded-content": { + "object-lock-info-with-configuration": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Days": 2, + "Mode": "GOVERNANCE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-lock-info-only-enabled": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_bucketpolicy": { + "recorded-date": "31-05-2024, 13:41:44", + "recorded-content": { + "bucket": { + "BucketName": "" + }, + "get-policy-true": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Resource": [ + "arn::s3:::", + "arn::s3:::/*" + ] + } + ] + }, + "no-policy": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucketPolicy", + "Message": "The bucket policy does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_cfn_handle_s3_notification_configuration": { + "recorded-date": "20-06-2024, 16:57:13", + "recorded-content": { + "get_bucket_notification_configuration": { + "QueueConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Id": "", + "QueueArn": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_bucket_notification_configuration_error": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.validation.json new file mode 100644 index 0000000000000..2b756e7a7e871 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_bucket_versioning": { + "last_validated_date": "2024-05-31T13:44:37+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_bucketpolicy": { + "last_validated_date": "2024-05-31T13:41:44+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_cfn_handle_s3_notification_configuration": { + "last_validated_date": "2024-06-20T16:57:13+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_cors_configuration": { + "last_validated_date": "2023-04-20T18:17:17+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_object_lock_configuration": { + "last_validated_date": "2024-01-15T02:31:58+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_s3.py::test_website_configuration": { + "last_validated_date": "2023-06-02T16:24:39+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py new file mode 100644 index 0000000000000..6c039975b679e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py @@ -0,0 +1,96 @@ +import json +import os +import os.path + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_sam_policies(deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.iam_api()) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sam_function-policies.yaml" + ) + ) + role_name = stack.outputs["HelloWorldFunctionIamRoleName"] + + roles = aws_client.iam.list_attached_role_policies(RoleName=role_name) + assert "AmazonSNSFullAccess" in [p["PolicyName"] for p in roles["AttachedPolicies"]] + snapshot.match("list_attached_role_policies", roles) + + +@markers.aws.validated +def test_sam_template(deploy_cfn_template, aws_client): + # deploy template + func_name = f"test-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/template4.yaml" + ), + parameters={"FunctionName": func_name}, + ) + + # run Lambda test invocation + result = aws_client.lambda_.invoke(FunctionName=func_name) + result = json.load(result["Payload"]) + assert result == {"hello": "world"} + + +@markers.aws.validated +def test_sam_sqs_event(deploy_cfn_template, aws_client): + result_key = f"event-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sam_sqs_template.yml" + ), + parameters={"ResultKey": result_key}, + ) + + queue_url = stack.outputs["QueueUrl"] + bucket_name = stack.outputs["BucketName"] + + message_body = "test" + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody=message_body) + + def get_object(): + return json.loads( + aws_client.s3.get_object(Bucket=bucket_name, Key=result_key)["Body"].read().decode() + )["Records"][0]["body"] + + body = retry(get_object, retries=10, sleep=5.0) + + assert body == message_body + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..tags", "$..Configuration.CodeSha256"]) +def test_cfn_handle_serverless_api_resource(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sam_api.yml" + ), + ) + + response = aws_client.apigateway.get_rest_api(restApiId=stack.outputs["ApiId"]) + snapshot.match("get_rest_api", response) + + response = aws_client.lambda_.get_function(FunctionName=stack.outputs["LambdaFunction"]) + snapshot.match("get_function", response) + + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.apigateway_api()) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.snapshot.json new file mode 100644 index 0000000000000..fe0c314aae224 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.snapshot.json @@ -0,0 +1,107 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_sam_policies": { + "recorded-date": "11-07-2023, 18:08:53", + "recorded-content": { + "list_attached_role_policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/service-role/", + "PolicyName": "" + }, + { + "PolicyArn": "arn::iam::aws:policy/", + "PolicyName": "" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_cfn_handle_serverless_api_resource": { + "recorded-date": "15-07-2025, 19:33:25", + "recorded-content": { + "get_rest_api": { + "apiKeySource": "HEADER", + "createdDate": "datetime", + "disableExecuteApiEndpoint": false, + "endpointConfiguration": { + "ipAddressType": "ipv4", + "types": [ + "EDGE" + ] + }, + "id": "", + "name": "", + "rootResourceId": "", + "tags": { + "aws:cloudformation:logical-id": "Api", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "" + }, + "version": "1.0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "W479VjWcFpBg+yx255glPq1ZLEq5WjlmjJi7CmxLFio=", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "aws:cloudformation:logical-id": "Lambda", + "aws:cloudformation:stack-id": "arn::cloudformation::111111111111:stack//", + "aws:cloudformation:stack-name": "", + "lambda:createdBy": "SAM" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.validation.json new file mode 100644 index 0000000000000..3b1eb14e1cce4 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_cfn_handle_serverless_api_resource": { + "last_validated_date": "2025-07-15T19:33:44+00:00", + "durations_in_seconds": { + "setup": 0.46, + "call": 40.88, + "teardown": 19.65, + "total": 60.99 + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_sam_policies": { + "last_validated_date": "2023-07-11T16:08:53+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sam.py::test_sam_sqs_event": { + "last_validated_date": "2024-04-19T19:45:49+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py new file mode 100644 index 0000000000000..5388d26b94a29 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py @@ -0,0 +1,115 @@ +import json +import os + +import aws_cdk as cdk +import botocore.exceptions +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..VersionIdsToStages"]) +def test_cfn_secretsmanager_gen_secret(deploy_cfn_template, aws_client, snapshot): + secret_name = f"dev/db/pass-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/secretsmanager_secret.yml" + ), + parameters={"SecretName": secret_name}, + ) + + secret = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + snapshot.match("secret", secret) + snapshot.add_transformer(snapshot.transform.regex(rf"{secret_name}-\w+", "")) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + + # assert that secret has been generated and added to the result template JSON + secret_value = aws_client.secretsmanager.get_secret_value(SecretId=secret_name)["SecretString"] + secret_json = json.loads(secret_value) + assert "password" in secret_json + assert len(secret_json["password"]) == 30 + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Tags", "$..VersionIdsToStages"]) +def test_cfn_handle_secretsmanager_secret(deploy_cfn_template, aws_client, snapshot): + secret_name = f"secret-{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/secretsmanager_secret.yml" + ), + parameters={"SecretName": secret_name}, + ) + + rs = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + snapshot.match("secret", rs) + snapshot.add_transformer(snapshot.transform.regex(rf"{secret_name}-\w+", "")) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + + stack.destroy() + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.secretsmanager.describe_secret(SecretId=secret_name) + + snapshot.match("exception", ex.value.response) + + +@markers.aws.validated +@pytest.mark.parametrize("block_public_policy", ["true", "default"]) +def test_cfn_secret_policy(deploy_cfn_template, block_public_policy, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/secretsmanager_secret_policy.yml" + ), + parameters={"BlockPublicPolicy": block_public_policy}, + ) + secret_id = stack.outputs["SecretId"] + + snapshot.match("outputs", stack.outputs) + secret_name = stack.outputs["SecretId"].split(":")[-1] + snapshot.add_transformer(snapshot.transform.regex(secret_name, "")) + + res = aws_client.secretsmanager.get_resource_policy(SecretId=secret_id) + snapshot.match("resource_policy", res) + snapshot.add_transformer(snapshot.transform.key_value("Name", "policy-name")) + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.aws.validated +def test_cdk_deployment_generates_secret_value_if_no_value_is_provided( + aws_client, snapshot, infrastructure_setup +): + infra = infrastructure_setup(namespace="SecretGeneration") + stack_name = f"SecretGeneration{short_uid()}" + stack = cdk.Stack(infra.cdk_app, stack_name=stack_name) + + secret_name = f"my_secret{short_uid()}" + secret = cdk.aws_secretsmanager.Secret(stack, id=secret_name, secret_name=secret_name) + + cdk.CfnOutput(stack, "SecretName", value=secret.secret_name) + cdk.CfnOutput(stack, "SecretARN", value=secret.secret_arn) + + with infra.provisioner() as prov: + outputs = prov.get_stack_outputs(stack_name=stack_name) + + secret_name = outputs["SecretName"] + secret_arn = outputs["SecretARN"] + + response = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + + snapshot.add_transformer( + snapshot.transform.key_value("SecretString", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.regex(secret_arn, "")) + snapshot.add_transformer(snapshot.transform.regex(secret_name, "")) + + snapshot.match("generated_key", response) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.snapshot.json new file mode 100644 index 0000000000000..fcf5840b4d1b7 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.snapshot.json @@ -0,0 +1,162 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secret_policy[true]": { + "recorded-date": "03-07-2024, 18:51:39", + "recorded-content": { + "outputs": { + "SecretId": "arn::secretsmanager::111111111111:secret:", + "SecretPolicyArn": "arn::secretsmanager::111111111111:secret:" + }, + "resource_policy": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResourcePolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:root" + }, + "Action": "secretsmanager:ReplicateSecretToRegions", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secret_policy[default]": { + "recorded-date": "03-07-2024, 18:52:05", + "recorded-content": { + "outputs": { + "SecretId": "arn::secretsmanager::111111111111:secret:", + "SecretPolicyArn": "arn::secretsmanager::111111111111:secret:" + }, + "resource_policy": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResourcePolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:root" + }, + "Action": "secretsmanager:ReplicateSecretToRegions", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cdk_deployment_generates_secret_value_if_no_value_is_provided": { + "recorded-date": "23-05-2024, 17:15:31", + "recorded-content": { + "generated_key": { + "ARN": "", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "secret-string", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secretsmanager_gen_secret": { + "recorded-date": "03-07-2024, 15:39:56", + "recorded-content": { + "secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Aurora Password", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-63e3fdc5" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "Secret" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-63e3fdc5/79663e60-3952-11ef-809b-0affeb5ce635" + } + ], + "VersionIdsToStages": { + "2b1f1af7-47ee-aee1-5609-991d4352ae14": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_handle_secretsmanager_secret": { + "recorded-date": "11-10-2024, 17:00:31", + "recorded-content": { + "secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Aurora Password", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "aws:cloudformation:stack-name", + "Value": "stack-ab33fda4" + }, + { + "Key": "aws:cloudformation:logical-id", + "Value": "Secret" + }, + { + "Key": "aws:cloudformation:stack-id", + "Value": "arn::cloudformation::111111111111:stack/stack-ab33fda4/47ecee80-87f2-11ef-8f16-0a113fcea55f" + } + ], + "VersionIdsToStages": { + "c80fca61-0302-7921-4b9b-c2c16bc6f457": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Secrets Manager can't find the specified secret." + }, + "Message": "Secrets Manager can't find the specified secret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.validation.json new file mode 100644 index 0000000000000..62afa75a4bedc --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cdk_deployment_generates_secret_value_if_no_value_is_provided": { + "last_validated_date": "2024-05-23T17:15:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_handle_secretsmanager_secret": { + "last_validated_date": "2024-10-11T17:00:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secret_policy[default]": { + "last_validated_date": "2024-08-01T12:22:53+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secret_policy[true]": { + "last_validated_date": "2024-08-01T12:22:32+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_secretsmanager.py::test_cfn_secretsmanager_gen_secret": { + "last_validated_date": "2024-07-03T15:39:56+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py new file mode 100644 index 0000000000000..865248c9b80dd --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py @@ -0,0 +1,159 @@ +import os.path + +import aws_cdk as cdk +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.DeliveryPolicy", + "$..Attributes.EffectiveDeliveryPolicy", + "$..Attributes.Policy.Statement..Action", # SNS:Receive is added by moto but not returned in AWS + ] +) +def test_sns_topic_fifo_with_deduplication(deploy_cfn_template, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("TopicArn")) + topic_name = f"topic-{short_uid()}.fifo" + + deploy_cfn_template( + parameters={"TopicName": topic_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_fifo_dedup.yaml" + ), + ) + + topics = aws_client.sns.list_topics()["Topics"] + topic_arns = [t["TopicArn"] for t in topics] + + filtered_topics = [t for t in topic_arns if topic_name in t] + assert len(filtered_topics) == 1 + + # assert that the topic is properly created as Fifo + topic_attrs = aws_client.sns.get_topic_attributes(TopicArn=filtered_topics[0]) + snapshot.match("get-topic-attrs", topic_attrs) + + +@markers.aws.needs_fixing +def test_sns_topic_fifo_without_suffix_fails(deploy_cfn_template, aws_client): + stack_name = f"stack-{short_uid()}" + topic_name = f"topic-{short_uid()}" + path = os.path.join( + os.path.dirname(__file__), + "../../../../../templates/sns_topic_fifo_dedup.yaml", + ) + + with pytest.raises(Exception) as ex: + deploy_cfn_template( + stack_name=stack_name, template_path=path, parameters={"TopicName": topic_name} + ) + assert ex.typename == "StackDeployError" + + stack = aws_client.cloudformation.describe_stacks(StackName=stack_name)["Stacks"][0] + if is_aws_cloud(): + assert stack.get("StackStatus") in ["ROLLBACK_COMPLETED", "ROLLBACK_IN_PROGRESS"] + else: + assert stack.get("StackStatus") == "CREATE_FAILED" + + +@markers.aws.validated +def test_sns_subscription(deploy_cfn_template, aws_client): + topic_name = f"topic-{short_uid()}" + queue_name = f"topic-{short_uid()}" + stack = deploy_cfn_template( + parameters={"TopicName": topic_name, "QueueName": queue_name}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_topic_subscription.yaml" + ), + ) + + topic_arn = stack.outputs["TopicArnOutput"] + assert topic_arn is not None + + subscriptions = aws_client.sns.list_subscriptions_by_topic(TopicArn=topic_arn) + assert len(subscriptions["Subscriptions"]) > 0 + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.aws.validated +def test_deploy_stack_with_sns_topic(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/deploy_template_2.yaml" + ), + parameters={"CompanyName": "MyCompany", "MyEmail1": "my@email.com"}, + ) + assert len(stack.outputs) == 3 + + topic_arn = stack.outputs["MyTopic"] + rs = aws_client.sns.list_topics() + + # Topic resource created + topics = [tp for tp in rs["Topics"] if tp["TopicArn"] == topic_arn] + assert len(topics) == 1 + + stack.destroy() + + # assert topic resource removed + rs = aws_client.sns.list_topics() + topics = [tp for tp in rs["Topics"] if tp["TopicArn"] == topic_arn] + assert not topics + + +@markers.aws.validated +def test_update_subscription(snapshot, deploy_cfn_template, aws_client, sqs_queue, sns_topic): + topic_arn = sns_topic["Attributes"]["TopicArn"] + queue_url = sqs_queue + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + stack = deploy_cfn_template( + parameters={"TopicArn": topic_arn, "QueueArn": queue_arn}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_subscription.yml" + ), + ) + sub_arn = stack.outputs["SubscriptionArn"] + subscription = aws_client.sns.get_subscription_attributes(SubscriptionArn=sub_arn) + snapshot.match("subscription-1", subscription) + + deploy_cfn_template( + parameters={"TopicArn": topic_arn, "QueueArn": queue_arn}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sns_subscription_update.yml" + ), + stack_name=stack.stack_name, + is_update=True, + ) + subscription_updated = aws_client.sns.get_subscription_attributes(SubscriptionArn=sub_arn) + snapshot.match("subscription-2", subscription_updated) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + + +@markers.aws.validated +def test_sns_topic_with_attributes(infrastructure_setup, aws_client, snapshot): + infra = infrastructure_setup(namespace="SnsTests") + stack_name = f"stack-{short_uid()}" + stack = cdk.Stack(infra.cdk_app, stack_name=stack_name) + + # Add more configurations here conform they are needed to be tested + topic = cdk.aws_sns.Topic(stack, id="Topic", fifo=True, message_retention_period_in_days=30) + + cdk.CfnOutput(stack, "TopicArn", value=topic.topic_arn) + with infra.provisioner() as prov: + outputs = prov.get_stack_outputs(stack_name=stack_name) + response = aws_client.sns.get_topic_attributes( + TopicArn=outputs["TopicArn"], + ) + snapshot.match("topic-archive-policy", response["Attributes"]["ArchivePolicy"]) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.snapshot.json new file mode 100644 index 0000000000000..274530a669eed --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.snapshot.json @@ -0,0 +1,116 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": { + "recorded-date": "27-11-2023, 21:27:29", + "recorded-content": { + "get-topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "true", + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_update_subscription": { + "recorded-date": "29-03-2024, 21:16:26", + "recorded-content": { + "subscription-1": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-2": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_topic_with_attributes": { + "recorded-date": "16-08-2024, 15:44:50", + "recorded-content": { + "topic-archive-policy": { + "MessageRetentionPeriod": "30" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.validation.json new file mode 100644 index 0000000000000..a25c4e80b86b8 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": { + "last_validated_date": "2023-11-27T20:27:29+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_sns_topic_with_attributes": { + "last_validated_date": "2024-08-16T15:44:50+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sns.py::test_update_subscription": { + "last_validated_date": "2024-03-29T21:16:21+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py new file mode 100644 index 0000000000000..2599e2bb1f520 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py @@ -0,0 +1,150 @@ +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +def test_sqs_queue_policy(deploy_cfn_template, aws_client, snapshot): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_with_queuepolicy.yaml" + ) + ) + queue_url = result.outputs["QueueUrlOutput"] + resp = aws_client.sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["Policy"]) + snapshot.match("policy", resp) + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + + +@markers.aws.validated +def test_sqs_fifo_queue_generates_valid_name(deploy_cfn_template): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_fifo_autogenerate_name.yaml" + ), + parameters={"IsFifo": "true"}, + max_wait=240, + ) + assert ".fifo" in result.outputs["FooQueueName"] + + +@markers.aws.validated +def test_sqs_non_fifo_queue_generates_valid_name(deploy_cfn_template): + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_fifo_autogenerate_name.yaml" + ), + parameters={"IsFifo": "false"}, + max_wait=240, + ) + assert ".fifo" not in result.outputs["FooQueueName"] + + +@markers.aws.validated +def test_cfn_handle_sqs_resource(deploy_cfn_template, aws_client, snapshot): + queue_name = f"queue-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_fifo_queue.yml" + ), + parameters={"QueueName": queue_name}, + ) + + rs = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueURL"], AttributeNames=["All"] + ) + snapshot.match("queue", rs) + snapshot.add_transformer(snapshot.transform.regex(queue_name, "")) + + # clean up + stack.destroy() + + with pytest.raises(ClientError) as ctx: + aws_client.sqs.get_queue_url(QueueName=f"{queue_name}.fifo") + snapshot.match("error", ctx.value.response) + + +@markers.aws.validated +def test_update_queue_no_change(deploy_cfn_template, aws_client, snapshot): + bucket_name = f"bucket-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_queue_update_no_change.yml" + ), + parameters={ + "AddBucket": "false", + "BucketName": bucket_name, + }, + ) + queue_url = stack.outputs["QueueUrl"] + queue_arn = stack.outputs["QueueArn"] + snapshot.add_transformer(snapshot.transform.regex(queue_url, "")) + snapshot.add_transformer(snapshot.transform.regex(queue_arn, "")) + + snapshot.match("outputs-1", stack.outputs) + + # deploy a second time with no change to the SQS queue + updated_stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_queue_update_no_change.yml" + ), + is_update=True, + stack_name=stack.stack_name, + parameters={ + "AddBucket": "true", + "BucketName": bucket_name, + }, + ) + snapshot.match("outputs-2", updated_stack.outputs) + + +@markers.aws.validated +def test_update_sqs_queuepolicy(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_with_queuepolicy.yaml" + ) + ) + + policy = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueUrlOutput"], AttributeNames=["Policy"] + ) + snapshot.match("policy1", policy["Attributes"]["Policy"]) + + updated_stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sqs_with_queuepolicy_updated.yaml" + ), + is_update=True, + stack_name=stack.stack_name, + ) + + def check_policy_updated(): + policy_updated = aws_client.sqs.get_queue_attributes( + QueueUrl=updated_stack.outputs["QueueUrlOutput"], AttributeNames=["Policy"] + ) + assert policy_updated["Attributes"]["Policy"] != policy["Attributes"]["Policy"] + return policy_updated + + wait_until(check_policy_updated) + + policy = aws_client.sqs.get_queue_attributes( + QueueUrl=updated_stack.outputs["QueueUrlOutput"], AttributeNames=["Policy"] + ) + + snapshot.match("policy2", policy["Attributes"]["Policy"]) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.snapshot.json new file mode 100644 index 0000000000000..860864e9c0b2e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.snapshot.json @@ -0,0 +1,119 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_update_queue_no_change": { + "recorded-date": "08-12-2023, 21:11:26", + "recorded-content": { + "outputs-1": { + "QueueArn": "", + "QueueUrl": "" + }, + "outputs-2": { + "QueueArn": "", + "QueueUrl": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_update_sqs_queuepolicy": { + "recorded-date": "27-03-2024, 20:30:24", + "recorded-content": { + "policy1": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ], + "Resource": "arn::sqs::111111111111:" + } + ] + }, + "policy2": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Principal": "*", + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ], + "Resource": "arn::sqs::111111111111:" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_sqs_queue_policy": { + "recorded-date": "03-07-2024, 19:49:04", + "recorded-content": { + "policy": { + "Attributes": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": [ + "sqs:SendMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl" + ], + "Resource": "" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_cfn_handle_sqs_resource": { + "recorded-date": "03-07-2024, 20:03:51", + "recorded-content": { + "queue": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:.fifo", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": { + "Error": { + "Code": "AWS.SimpleQueueService.NonExistentQueue", + "Message": "The specified queue does not exist.", + "QueryErrorCode": "QueueDoesNotExist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.validation.json new file mode 100644 index 0000000000000..18d7ae6c4fd05 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_cfn_handle_sqs_resource": { + "last_validated_date": "2024-07-03T20:03:51+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_sqs_fifo_queue_generates_valid_name": { + "last_validated_date": "2024-05-15T02:01:00+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_sqs_non_fifo_queue_generates_valid_name": { + "last_validated_date": "2024-05-15T01:59:34+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_sqs_queue_policy": { + "last_validated_date": "2024-07-03T19:49:04+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_update_queue_no_change": { + "last_validated_date": "2023-12-08T20:11:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_sqs.py::test_update_sqs_queuepolicy": { + "last_validated_date": "2024-03-27T20:30:23+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py new file mode 100644 index 0000000000000..1d9922d481668 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py @@ -0,0 +1,165 @@ +import os.path + +import botocore.exceptions +import pytest +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) +def test_parameter_defaults(deploy_cfn_template, aws_client, snapshot): + ssm_parameter_value = f"custom-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml" + ), + parameters={"Input": ssm_parameter_value}, + ) + + parameter_name = stack.outputs["CustomParameterOutput"] + param = aws_client.ssm.get_parameter(Name=parameter_name) + snapshot.match("ssm_parameter", param) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + snapshot.add_transformer(snapshot.transform.key_value("Value")) + + stack.destroy() + + with pytest.raises(botocore.exceptions.ClientError) as ctx: + aws_client.ssm.get_parameter(Name=parameter_name) + snapshot.match("ssm_parameter_not_found", ctx.value.response) + + +@markers.aws.validated +def test_update_ssm_parameters(deploy_cfn_template, aws_client): + ssm_parameter_value = f"custom-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml" + ), + parameters={"Input": ssm_parameter_value}, + ) + + ssm_parameter_value = f"new-custom-{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ssm_parameter_defaultname.yaml" + ), + parameters={"Input": ssm_parameter_value}, + ) + + parameter_name = stack.outputs["CustomParameterOutput"] + param = aws_client.ssm.get_parameter(Name=parameter_name) + assert param["Parameter"]["Value"] == ssm_parameter_value + + +@markers.aws.validated +def test_update_ssm_parameter_tag(deploy_cfn_template, aws_client): + ssm_parameter_value = f"custom-{short_uid()}" + tag_value = f"tag-{short_uid()}" + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/ssm_parameter_defaultname_withtags.yaml", + ), + parameters={ + "Input": ssm_parameter_value, + "TagValue": tag_value, + }, + ) + parameter_name = stack.outputs["CustomParameterOutput"] + ssm_tags = aws_client.ssm.list_tags_for_resource( + ResourceType="Parameter", ResourceId=parameter_name + )["TagList"] + tags_pre_update = {tag["Key"]: tag["Value"] for tag in ssm_tags} + assert tags_pre_update["A"] == tag_value + + tag_value_new = f"tag-{short_uid()}" + deploy_cfn_template( + is_update=True, + stack_name=stack.stack_name, + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/ssm_parameter_defaultname_withtags.yaml", + ), + parameters={ + "Input": ssm_parameter_value, + "TagValue": tag_value_new, + }, + ) + + ssm_tags = aws_client.ssm.list_tags_for_resource( + ResourceType="Parameter", ResourceId=parameter_name + )["TagList"] + tags_post_update = {tag["Key"]: tag["Value"] for tag in ssm_tags} + assert tags_post_update["A"] == tag_value_new + + # TODO: re-enable after fixing updates in general + # deploy_cfn_template( + # is_update=True, + # stack_name=stack.stack_name, + # template_path=os.path.join( + # os.path.dirname(__file__), "../../templates/ssm_parameter_defaultname.yaml" + # ), + # parameters={ + # "Input": ssm_parameter_value, + # }, + # ) + # + # ssm_tags = aws_client.ssm.list_tags_for_resource(ResourceType="Parameter", ResourceId=parameter_name)['TagList'] + # assert ssm_tags == [] + + +@pytest.mark.skip(reason="CFNV2:Other") +@markers.snapshot.skip_snapshot_verify(paths=["$..DriftInformation", "$..Metadata"]) +@markers.aws.validated +def test_deploy_patch_baseline(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ssm_patch_baseline.yml" + ), + ) + + describe_resource = aws_client.cloudformation.describe_stack_resource( + StackName=stack.stack_name, LogicalResourceId="myPatchBaseline" + )["StackResourceDetail"] + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer( + snapshot.transform.key_value("PhysicalResourceId", "physical_resource_id") + ) + snapshot.match("patch_baseline", describe_resource) + + +@markers.aws.validated +def test_maintenance_window(deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/ssm_maintenance_window.yml" + ), + ) + + describe_resource = aws_client.cloudformation.describe_stack_resources( + StackName=stack.stack_name + )["StackResources"] + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer( + snapshot.transform.key_value("PhysicalResourceId", "physical_resource_id") + ) + snapshot.add_transformer( + SortingTransformer("MaintenanceWindow", lambda x: x["LogicalResourceId"]), priority=-1 + ) + snapshot.match("MaintenanceWindow", describe_resource) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.snapshot.json new file mode 100644 index 0000000000000..b20140c4c46e1 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.snapshot.json @@ -0,0 +1,117 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_deploy_patch_baseline": { + "recorded-date": "05-07-2023, 10:13:24", + "recorded-content": { + "patch_baseline": { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTimestamp": "timestamp", + "LogicalResourceId": "myPatchBaseline", + "Metadata": {}, + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::PatchBaseline", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_maintenance_window": { + "recorded-date": "14-07-2023, 14:06:23", + "recorded-content": { + "MaintenanceWindow": [ + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchBaselineAML", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::PatchBaseline", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchBaselineAML2", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::PatchBaseline", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchServerMaintenanceWindow", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::MaintenanceWindow", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchServerMaintenanceWindowTarget", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::MaintenanceWindowTarget", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + }, + { + "StackName": "", + "StackId": "arn::cloudformation::111111111111:stack//", + "LogicalResourceId": "PatchServerTask", + "PhysicalResourceId": "", + "ResourceType": "AWS::SSM::MaintenanceWindowTask", + "Timestamp": "timestamp", + "ResourceStatus": "CREATE_COMPLETE", + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + } + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_parameter_defaults": { + "recorded-date": "03-07-2024, 20:30:04", + "recorded-content": { + "ssm_parameter": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "", + "Version": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "ssm_parameter_not_found": { + "Error": { + "Code": "ParameterNotFound", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.validation.json new file mode 100644 index 0000000000000..3406bb65e62ee --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_deploy_patch_baseline": { + "last_validated_date": "2023-07-05T08:13:24+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_maintenance_window": { + "last_validated_date": "2023-07-14T12:06:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_ssm.py::test_parameter_defaults": { + "last_validated_date": "2024-07-03T20:30:04+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py new file mode 100644 index 0000000000000..bae95c05ec516 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py @@ -0,0 +1,87 @@ +import os + +import pytest + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@pytest.fixture +def wait_stack_set_operation(aws_client): + def waiter(stack_set_name: str, operation_id: str): + def _operation_is_ready(): + operation = aws_client.cloudformation.describe_stack_set_operation( + StackSetName=stack_set_name, + OperationId=operation_id, + ) + return operation["StackSetOperation"]["Status"] not in ["RUNNING", "STOPPING"] + + wait_until(_operation_is_ready) + + return waiter + + +@pytest.mark.skip("CFNV2:StackSets") +@markers.aws.validated +def test_create_stack_set_with_stack_instances( + account_id, + region_name, + aws_client, + snapshot, + wait_stack_set_operation, +): + snapshot.add_transformer(snapshot.transform.key_value("StackSetId", "stack-set-id")) + + stack_set_name = f"StackSet-{short_uid()}" + + template_body = load_file( + os.path.join(os.path.dirname(__file__), "../../../../../templates/s3_cors_bucket.yaml") + ) + + result = aws_client.cloudformation.create_stack_set( + StackSetName=stack_set_name, + TemplateBody=template_body, + ) + + snapshot.match("create_stack_set", result) + + create_instances_result = aws_client.cloudformation.create_stack_instances( + StackSetName=stack_set_name, + Accounts=[account_id], + Regions=[region_name], + ) + + snapshot.match("create_stack_instances", create_instances_result) + + wait_stack_set_operation(stack_set_name, create_instances_result["OperationId"]) + + # make sure additional calls do not result in errors + # even the stack already exists, but returns operation id instead + create_instances_result = aws_client.cloudformation.create_stack_instances( + StackSetName=stack_set_name, + Accounts=[account_id], + Regions=[region_name], + ) + + assert "OperationId" in create_instances_result + + wait_stack_set_operation(stack_set_name, create_instances_result["OperationId"]) + + delete_instances_result = aws_client.cloudformation.delete_stack_instances( + StackSetName=stack_set_name, + Accounts=[account_id], + Regions=[region_name], + RetainStacks=False, + ) + wait_stack_set_operation(stack_set_name, delete_instances_result["OperationId"]) + + aws_client.cloudformation.delete_stack_set(StackSetName=stack_set_name) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.snapshot.json new file mode 100644 index 0000000000000..ef518e6eb430c --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.snapshot.json @@ -0,0 +1,21 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": { + "recorded-date": "24-05-2023, 15:32:47", + "recorded-content": { + "create_stack_set": { + "StackSetId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_stack_instances": { + "OperationId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.validation.json new file mode 100644 index 0000000000000..157a4655b2589 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stack_sets.py::test_create_stack_set_with_stack_instances": { + "last_validated_date": "2023-05-24T13:32:47+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py new file mode 100644 index 0000000000000..8bb3c96039211 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py @@ -0,0 +1,388 @@ +import json +import os +import urllib.parse + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer + +from localstack import config +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import await_execution_terminated +from localstack.utils.strings import short_uid +from localstack.utils.sync import wait_until + + +@markers.aws.validated +def test_statemachine_definitionsubstitution(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/stepfunctions_statemachine_substitutions.yaml", + ) + ) + + assert len(stack.outputs) == 1 + statemachine_arn = stack.outputs["StateMachineArnOutput"] + + # execute statemachine + ex_result = aws_client.stepfunctions.start_execution(stateMachineArn=statemachine_arn) + + def _is_executed(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=ex_result["executionArn"])[ + "status" + ] + != "RUNNING" + ) + + wait_until(_is_executed) + execution_desc = aws_client.stepfunctions.describe_execution( + executionArn=ex_result["executionArn"] + ) + assert execution_desc["status"] == "SUCCEEDED" + # sync execution is currently not supported since botocore adds a "sync-" prefix + # ex_result = stepfunctions_client.start_sync_execution(stateMachineArn=statemachine_arn) + + assert "hello from statemachine" in execution_desc["output"] + + +@pytest.mark.skip( + reason="CFNV2:Other During change set describe the a Ref to a not yet deployed resource returns null which is an invalid input for Fn::Split" +) +@markers.aws.validated +def test_nested_statemachine_with_sync2(deploy_cfn_template, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sfn_nested_sync2.json" + ) + ) + + parent_arn = stack.outputs["ParentStateMachineArnOutput"] + assert parent_arn + + ex_result = aws_client.stepfunctions.start_execution( + stateMachineArn=parent_arn, input='{"Value": 1}' + ) + + def _is_executed(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=ex_result["executionArn"])[ + "status" + ] + != "RUNNING" + ) + + wait_until(_is_executed) + execution_desc = aws_client.stepfunctions.describe_execution( + executionArn=ex_result["executionArn"] + ) + assert execution_desc["status"] == "SUCCEEDED" + output = json.loads(execution_desc["output"]) + assert output["Value"] == 3 + + +@markers.aws.needs_fixing +def test_apigateway_invoke(deploy_cfn_template, aws_client): + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sfn_apigateway.yaml" + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello from stepfunctions" in execution_result["output"] + + +@markers.aws.validated +def test_apigateway_invoke_with_path(deploy_cfn_template, aws_client): + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/sfn_apigateway_two_integrations.yaml", + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello_with_path from stepfunctions" in execution_result["output"] + + +@markers.aws.only_localstack +def test_apigateway_invoke_localhost(deploy_cfn_template, aws_client): + """tests the same as above but with the "generic" localhost version of invoking the apigateway""" + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sfn_apigateway.yaml" + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + api_url = deploy_result.outputs["LsApiEndpointA06D37E8"] + + # instead of changing the template, we're just mapping the endpoint here to the more generic path-based version + state_def = aws_client.stepfunctions.describe_state_machine(stateMachineArn=state_machine_arn)[ + "definition" + ] + parsed = urllib.parse.urlparse(api_url) + api_id = parsed.hostname.split(".")[0] + state = json.loads(state_def) + stage = state["States"]["LsCallApi"]["Parameters"]["Stage"] + state["States"]["LsCallApi"]["Parameters"]["ApiEndpoint"] = ( + f"{config.internal_service_url()}/restapis/{api_id}" + ) + state["States"]["LsCallApi"]["Parameters"]["Stage"] = stage + + aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(state) + ) + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello from stepfunctions" in execution_result["output"] + + +@markers.aws.only_localstack +def test_apigateway_invoke_localhost_with_path(deploy_cfn_template, aws_client): + """tests the same as above but with the "generic" localhost version of invoking the apigateway""" + deploy_result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/sfn_apigateway_two_integrations.yaml", + ) + ) + state_machine_arn = deploy_result.outputs["statemachineOutput"] + api_url = deploy_result.outputs["LsApiEndpointA06D37E8"] + + # instead of changing the template, we're just mapping the endpoint here to the more generic path-based version + state_def = aws_client.stepfunctions.describe_state_machine(stateMachineArn=state_machine_arn)[ + "definition" + ] + parsed = urllib.parse.urlparse(api_url) + api_id = parsed.hostname.split(".")[0] + state = json.loads(state_def) + stage = state["States"]["LsCallApi"]["Parameters"]["Stage"] + state["States"]["LsCallApi"]["Parameters"]["ApiEndpoint"] = ( + f"{config.internal_service_url()}/restapis/{api_id}" + ) + state["States"]["LsCallApi"]["Parameters"]["Stage"] = stage + + aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(state) + ) + + execution_arn = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn)[ + "executionArn" + ] + + def _sfn_finished_running(): + return ( + aws_client.stepfunctions.describe_execution(executionArn=execution_arn)["status"] + != "RUNNING" + ) + + wait_until(_sfn_finished_running) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + assert "hello_with_path from stepfunctions" in execution_result["output"] + + +@pytest.mark.skip("Terminates with FAILED on cloud; convert to SFN v2 snapshot lambda test.") +@markers.aws.needs_fixing +def test_retry_and_catch(deploy_cfn_template, aws_client): + """ + Scenario: + + Lambda invoke (incl. 3 retries) + => catch (Send SQS message with body "Fail") + => next (Send SQS message with body "Success") + + The Lambda function simply raises an Exception, so it will always fail. + It should fail all 4 attempts (1x invoke + 3x retries) which should then trigger the catch path + and send a "Fail" message to the queue. + """ + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../../templates/sfn_retry_catch.yaml" + ) + ) + queue_url = stack.outputs["queueUrlOutput"] + statemachine_arn = stack.outputs["smArnOutput"] + assert statemachine_arn + + execution = aws_client.stepfunctions.start_execution(stateMachineArn=statemachine_arn) + execution_arn = execution["executionArn"] + + await_execution_terminated(aws_client.stepfunctions, execution_arn) + + execution_result = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + assert execution_result["status"] == "SUCCEEDED" + + receive_result = aws_client.sqs.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) + assert receive_result["Messages"][0]["Body"] == "Fail" + + +@markers.aws.validated +def test_cfn_statemachine_with_dependencies(deploy_cfn_template, aws_client): + sm_name = f"sm_{short_uid()}" + activity_name = f"act_{short_uid()}" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/statemachine_machine_with_activity.yml", + ), + max_wait=150, + parameters={"StateMachineName": sm_name, "ActivityName": activity_name}, + ) + + rs = aws_client.stepfunctions.list_state_machines() + statemachines = [sm for sm in rs["stateMachines"] if sm_name in sm["name"]] + assert len(statemachines) == 1 + + rs = aws_client.stepfunctions.list_activities() + activities = [act for act in rs["activities"] if activity_name in act["name"]] + assert len(activities) == 1 + + stack.destroy() + + rs = aws_client.stepfunctions.list_state_machines() + statemachines = [sm for sm in rs["stateMachines"] if sm_name in sm["name"]] + + assert not statemachines + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..encryptionConfiguration", "$..tracingConfiguration"] +) +def test_cfn_statemachine_default_s3_location( + s3_create_bucket, deploy_cfn_template, aws_client, sfn_snapshot +): + sfn_snapshot.add_transformers_list( + [ + JsonpathTransformer("$..roleArn", "role-arn"), + JsonpathTransformer("$..stateMachineArn", "state-machine-arn"), + JsonpathTransformer("$..name", "state-machine-name"), + ] + ) + cfn_template_path = os.path.join( + os.path.dirname(__file__), + "../../../../../templates/statemachine_machine_default_s3_location.yml", + ) + + stack_name = f"test-cfn-statemachine-default-s3-location-{short_uid()}" + + file_key = f"file-key-{short_uid()}.json" + bucket_name = s3_create_bucket() + state_machine_template = { + "Comment": "step: on create", + "StartAt": "S0", + "States": {"S0": {"Type": "Succeed"}}, + } + + aws_client.s3.put_object( + Bucket=bucket_name, Key=file_key, Body=json.dumps(state_machine_template) + ) + + stack = deploy_cfn_template( + stack_name=stack_name, + template_path=cfn_template_path, + max_wait=150, + parameters={"BucketName": bucket_name, "ObjectKey": file_key}, + ) + + stack_outputs = stack.outputs + statemachine_arn = stack_outputs["StateMachineArnOutput"] + + describe_state_machine_output_on_create = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match( + "describe_state_machine_output_on_create", describe_state_machine_output_on_create + ) + + file_key = f"2-{file_key}" + state_machine_template["Comment"] = "step: on update" + aws_client.s3.put_object( + Bucket=bucket_name, Key=file_key, Body=json.dumps(state_machine_template) + ) + deploy_cfn_template( + stack_name=stack_name, + template_path=cfn_template_path, + is_update=True, + parameters={"BucketName": bucket_name, "ObjectKey": file_key}, + ) + + describe_state_machine_output_on_update = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match( + "describe_state_machine_output_on_update", describe_state_machine_output_on_update + ) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=["$..encryptionConfiguration", "$..tracingConfiguration"] +) +def test_statemachine_create_with_logging_configuration( + deploy_cfn_template, aws_client, sfn_snapshot +): + sfn_snapshot.add_transformers_list( + [ + JsonpathTransformer("$..roleArn", "role-arn"), + JsonpathTransformer("$..stateMachineArn", "state-machine-arn"), + JsonpathTransformer("$..name", "state-machine-name"), + JsonpathTransformer("$..logGroupArn", "log-group-arn"), + ] + ) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../../templates/statemachine_machine_logging_configuration.yml", + ) + ) + statemachine_arn = stack.outputs["StateMachineArnOutput"] + describe_state_machine_result = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match("describe_state_machine_result", describe_state_machine_result) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.snapshot.json new file mode 100644 index 0000000000000..d0fc2a3e304de --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.snapshot.json @@ -0,0 +1,113 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_cfn_statemachine_default_s3_location": { + "recorded-date": "17-12-2024, 16:06:46", + "recorded-content": { + "describe_state_machine_output_on_create": { + "creationDate": "datetime", + "definition": { + "Comment": "step: on create", + "StartAt": "S0", + "States": { + "S0": { + "Type": "Succeed" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "", + "stateMachineArn": "", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_state_machine_output_on_update": { + "creationDate": "datetime", + "definition": { + "Comment": "step: on update", + "StartAt": "S0", + "States": { + "S0": { + "Type": "Succeed" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "", + "stateMachineArn": "", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_statemachine_create_with_logging_configuration": { + "recorded-date": "24-03-2025, 21:58:55", + "recorded-content": { + "describe_state_machine_result": { + "creationDate": "datetime", + "definition": { + "StartAt": "S0", + "States": { + "S0": { + "Type": "Pass", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "" + } + } + ], + "includeExecutionData": true, + "level": "ALL" + }, + "name": "", + "roleArn": "", + "stateMachineArn": "", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.validation.json new file mode 100644 index 0000000000000..267fe6634138d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_cfn_statemachine_default_s3_location": { + "last_validated_date": "2024-12-17T16:06:46+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_stepfunctions.py::test_statemachine_create_with_logging_configuration": { + "last_validated_date": "2025-03-24T21:58:55+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py b/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py new file mode 100644 index 0000000000000..542d28c39a52f --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py @@ -0,0 +1,1288 @@ +import base64 +import json +import os +import re +from copy import deepcopy + +import botocore.exceptions +import pytest +import yaml + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.cloudformation.engine.yaml_parser import parse_yaml +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.cloudformation_utils import load_template_file, load_template_raw +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.fixtures import StackDeployError +from localstack.utils.common import short_uid +from localstack.utils.files import load_file +from localstack.utils.sync import wait_until + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +def create_macro( + macro_name, function_path, deploy_cfn_template, create_lambda_function, lambda_client +): + macro_function_path = function_path + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=lambda_client, + timeout=1, + ) + + return deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + +class TestTypes: + @markers.aws.validated + def test_implicit_type_conversion(self, deploy_cfn_template, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.sqs_api()) + stack = deploy_cfn_template( + max_wait=180, + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../templates/engine/implicit_type_conversion.yml", + ), + ) + queue = aws_client.sqs.get_queue_attributes( + QueueUrl=stack.outputs["QueueUrl"], AttributeNames=["All"] + ) + snapshot.match("queue", queue) + + +class TestIntrinsicFunctions: + @pytest.mark.parametrize( + ("intrinsic_fn", "parameter_1", "parameter_2", "expected_bucket_created"), + [ + ("Fn::And", "0", "0", False), + ("Fn::And", "0", "1", False), + ("Fn::And", "1", "0", False), + ("Fn::And", "1", "1", True), + ("Fn::Or", "0", "0", False), + ("Fn::Or", "0", "1", True), + ("Fn::Or", "1", "0", True), + ("Fn::Or", "1", "1", True), + ], + ) + @markers.aws.validated + def test_and_or_functions( + self, + intrinsic_fn, + parameter_1, + parameter_2, + expected_bucket_created, + deploy_cfn_template, + aws_client, + ): + bucket_name = f"ls-bucket-{short_uid()}" + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/cfn_intrinsic_functions.yaml" + ), + parameters={ + "Param1": parameter_1, + "Param2": parameter_2, + "BucketName": bucket_name, + }, + template_mapping={ + "intrinsic_fn": intrinsic_fn, + }, + ) + + buckets = aws_client.s3.list_buckets() + bucket_names = [b["Name"] for b in buckets["Buckets"]] + assert (bucket_name in bucket_names) == expected_bucket_created + + @markers.aws.validated + def test_base64_sub_and_getatt_functions(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/functions_getatt_sub_base64.yml" + ) + original_string = f"string-{short_uid()}" + deployed = deploy_cfn_template( + template_path=template_path, parameters={"OriginalString": original_string} + ) + + converted_string = base64.b64encode(bytes(original_string, "utf-8")).decode("utf-8") + assert converted_string == deployed.outputs["Encoded"] + + @pytest.mark.skip(reason="CFNV2:LanguageExtensions") + @markers.aws.validated + def test_split_length_and_join_functions(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/functions_select_split_join.yml" + ) + + first_value = f"string-{short_uid()}" + second_value = f"string-{short_uid()}" + deployed = deploy_cfn_template( + template_path=template_path, + parameters={ + "MultipleValues": f"{first_value};{second_value}", + "Value1": first_value, + "Value2": second_value, + }, + ) + + assert first_value == deployed.outputs["SplitResult"] + assert f"{first_value}_{second_value}" == deployed.outputs["JoinResult"] + + # TODO support join+split and length operations + # assert f"{first_value}_{second_value}" == deployed.outputs["SplitJoin"] + # assert 2 == deployed.outputs["LengthResult"] + + @markers.aws.validated + @pytest.mark.skip(reason="functions not currently supported") + def test_to_json_functions(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/function_to_json_string.yml" + ) + + first_value = f"string-{short_uid()}" + second_value = f"string-{short_uid()}" + deployed = deploy_cfn_template( + template_path=template_path, + parameters={ + "Value1": first_value, + "Value2": second_value, + }, + ) + + json_result = json.loads(deployed.outputs["Result"]) + + assert json_result["key1"] == first_value + assert json_result["key2"] == second_value + assert "value1" == deployed.outputs["Result2"] + + @markers.aws.validated + def test_find_map_function(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/function_find_in_map.yml" + ) + + deployed = deploy_cfn_template( + template_path=template_path, + ) + + assert deployed.outputs["Result"] == "us-east-1" + + @markers.aws.validated + @pytest.mark.skip(reason="function not currently supported") + def test_cidr_function(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/functions_cidr.yml" + ) + + # TODO parametrize parameters and result + deployed = deploy_cfn_template( + template_path=template_path, + parameters={"IpBlock": "10.0.0.0/16", "Count": "1", "CidrBits": "8", "Select": "0"}, + ) + + assert deployed.outputs["Address"] == "10.0.0.0/24" + + @pytest.mark.parametrize( + "region", + [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-2", + "ap-northeast-1", + "eu-central-1", + "eu-west-1", + ], + ) + @markers.aws.validated + def test_get_azs_function(self, deploy_cfn_template, region, aws_client_factory): + """ + TODO parametrize this test. + For that we need to be able to parametrize the client region. The docs show the we should be + able to put any region in the parameters but it doesn't work. It only accepts the same region from the client config + if you put anything else it just returns an empty list. + """ + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/functions_get_azs.yml" + ) + + aws_client = aws_client_factory(region_name=region) + deployed = deploy_cfn_template( + template_path=template_path, + custom_aws_client=aws_client, + parameters={"DeployRegion": region}, + ) + + azs = deployed.outputs["Zones"].split(";") + assert len(azs) > 0 + assert all(re.match(f"{region}[a-f]", az) for az in azs) + + @markers.aws.validated + def test_sub_not_ready(self, deploy_cfn_template): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/sub_dependencies.yaml" + ) + deploy_cfn_template( + template_path=template_path, + max_wait=120, + ) + + @markers.aws.validated + def test_cfn_template_with_short_form_fn_sub(self, deploy_cfn_template): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/engine/cfn_short_sub.yml" + ), + ) + + result = stack.outputs["Result"] + assert result == "test" + + @markers.aws.validated + def test_sub_number_type(self, deploy_cfn_template): + alarm_name_prefix = "alarm-test-latency-preemptive" + threshold = "1000.0" + period = "60" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/sub_number_type.yml" + ), + parameters={ + "ResourceNamePrefix": alarm_name_prefix, + "RestLatencyPreemptiveAlarmThreshold": threshold, + "RestLatencyPreemptiveAlarmPeriod": period, + }, + ) + + assert stack.outputs["AlarmName"] == f"{alarm_name_prefix}-{period}" + assert stack.outputs["Threshold"] == threshold + assert stack.outputs["Period"] == period + + @markers.aws.validated + def test_join_no_value_construct(self, deploy_cfn_template, snapshot, aws_client): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/engine/join_no_value.yml" + ) + ) + + snapshot.match("join-output", stack.outputs) + + +@pytest.mark.skip(reason="CFNV2:Imports unsupported") +class TestImports: + @markers.aws.validated + def test_stack_imports(self, deploy_cfn_template, aws_client): + queue_name1 = f"q-{short_uid()}" + queue_name2 = f"q-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/sqs_export.yml" + ), + parameters={"QueueName": queue_name1}, + ) + stack2 = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/sqs_import.yml" + ), + parameters={"QueueName": queue_name2}, + ) + queue_url1 = aws_client.sqs.get_queue_url(QueueName=queue_name1)["QueueUrl"] + queue_url2 = aws_client.sqs.get_queue_url(QueueName=queue_name2)["QueueUrl"] + + queue_arn1 = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url1, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + queue_arn2 = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url2, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + assert stack2.outputs["MessageQueueArn1"] == queue_arn1 + assert stack2.outputs["MessageQueueArn2"] == queue_arn2 + + +@pytest.mark.skip(reason="CFNV2:resolve") +class TestSsmParameters: + @markers.aws.validated + def test_create_stack_with_ssm_parameters( + self, create_parameter, deploy_cfn_template, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.key_value("ParameterValue")) + snapshot.add_transformer(snapshot.transform.key_value("ResolvedValue")) + + parameter_name = f"ls-param-{short_uid()}" + parameter_value = f"ls-param-value-{short_uid()}" + create_parameter(Name=parameter_name, Value=parameter_value, Type="String") + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/dynamicparameter_ssm_string.yaml" + ), + template_mapping={"parameter_name": parameter_name}, + ) + + stack_description = aws_client.cloudformation.describe_stacks(StackName=stack.stack_name)[ + "Stacks" + ][0] + snapshot.match("stack-details", stack_description) + + topics = aws_client.sns.list_topics() + topic_arns = [t["TopicArn"] for t in topics["Topics"]] + + matching = [arn for arn in topic_arns if parameter_value in arn] + assert len(matching) == 1 + + tags = aws_client.sns.list_tags_for_resource(ResourceArn=matching[0]) + snapshot.match("topic-tags", tags) + + @markers.aws.validated + def test_resolve_ssm(self, create_parameter, deploy_cfn_template): + parameter_key = f"param-key-{short_uid()}" + parameter_value = f"param-value-{short_uid()}" + create_parameter(Name=parameter_key, Value=parameter_value, Type="String") + + result = deploy_cfn_template( + parameters={"DynamicParameter": parameter_key}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/resolve_ssm.yaml" + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value + + @markers.aws.validated + def test_resolve_ssm_with_version(self, create_parameter, deploy_cfn_template, aws_client): + parameter_key = f"param-key-{short_uid()}" + parameter_value_v0 = f"param-value-{short_uid()}" + parameter_value_v1 = f"param-value-{short_uid()}" + parameter_value_v2 = f"param-value-{short_uid()}" + + create_parameter(Name=parameter_key, Type="String", Value=parameter_value_v0) + + v1 = aws_client.ssm.put_parameter( + Name=parameter_key, Overwrite=True, Type="String", Value=parameter_value_v1 + ) + aws_client.ssm.put_parameter( + Name=parameter_key, Overwrite=True, Type="String", Value=parameter_value_v2 + ) + + result = deploy_cfn_template( + parameters={"DynamicParameter": f"{parameter_key}:{v1['Version']}"}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/resolve_ssm.yaml" + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value_v1 + + @markers.aws.needs_fixing + def test_resolve_ssm_secure(self, create_parameter, deploy_cfn_template): + parameter_key = f"param-key-{short_uid()}" + parameter_value = f"param-value-{short_uid()}" + + create_parameter(Name=parameter_key, Value=parameter_value, Type="SecureString") + + result = deploy_cfn_template( + parameters={"DynamicParameter": f"{parameter_key}"}, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/resolve_ssm_secure.yaml" + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value + + @markers.aws.validated + def test_ssm_nested_with_nested_stack(self, s3_create_bucket, deploy_cfn_template, aws_client): + """ + When resolving the references in the cloudformation template for 'Fn::GetAtt' we need to consider the attribute subname. + Eg: In "Fn::GetAtt": "ChildParam.Outputs.Value", where attribute reference is ChildParam.Outputs.Value the: + resource logical id is ChildParam and attribute name is Outputs we need to fetch the Value attribute from the resource properties + of the model instance. + """ + + bucket_name = s3_create_bucket() + domain = "amazonaws.com" if is_aws_cloud() else "localhost.localstack.cloud:4566" + + aws_client.s3.upload_file( + os.path.join(os.path.dirname(__file__), "../../../../templates/nested_child_ssm.yaml"), + Bucket=bucket_name, + Key="nested_child_ssm.yaml", + ) + + key_value = "child-2-param-name" + + deploy_cfn_template( + max_wait=120 if is_aws_cloud() else 20, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/nested_parent_ssm.yaml" + ), + parameters={ + "ChildStackURL": f"https://{bucket_name}.s3.{domain}/nested_child_ssm.yaml", + "KeyValue": key_value, + }, + ) + + ssm_parameter = aws_client.ssm.get_parameter(Name="test-param")["Parameter"]["Value"] + + assert ssm_parameter == key_value + + @markers.aws.validated + def test_create_change_set_with_ssm_parameter_list( + self, deploy_cfn_template, aws_client, region_name, account_id, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value(key="role-name")) + + parameter_logical_id = "parameter123" + parameter_name = f"ls-param-{short_uid()}" + role_name = f"ls-role-{short_uid()}" + parameter_value = ",".join( + [ + f"arn:aws:ssm:{region_name}:{account_id}:parameter/some/params", + f"arn:aws:ssm:{region_name}:{account_id}:parameter/some/other/params", + ] + ) + snapshot.match("role-name", role_name) + + aws_client.ssm.put_parameter(Name=parameter_name, Value=parameter_value, Type="StringList") + + deploy_cfn_template( + max_wait=120 if is_aws_cloud() else 20, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/dynamicparameter_ssm_list.yaml" + ), + template_mapping={"role_name": role_name}, + parameters={parameter_logical_id: parameter_name}, + ) + role_policy = aws_client.iam.get_role_policy(RoleName=role_name, PolicyName="policy-123") + snapshot.match("iam_role_policy", role_policy) + + +class TestSecretsManagerParameters: + @pytest.mark.skip(reason="CFNV2:resolve") + @pytest.mark.parametrize( + "template_name", + [ + "resolve_secretsmanager_full.yaml", + "resolve_secretsmanager_partial.yaml", + "resolve_secretsmanager.yaml", + ], + ) + @markers.aws.validated + def test_resolve_secretsmanager(self, create_secret, deploy_cfn_template, template_name): + parameter_key = f"param-key-{short_uid()}" + parameter_value = f"param-value-{short_uid()}" + + create_secret(Name=parameter_key, SecretString=parameter_value) + + result = deploy_cfn_template( + parameters={"DynamicParameter": f"{parameter_key}"}, + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../templates", + template_name, + ), + ) + + topic_name = result.outputs["TopicName"] + assert topic_name == parameter_value + + +class TestPreviousValues: + @pytest.mark.skip(reason="outputs don't behave well in combination with conditions") + @markers.aws.validated + def test_parameter_usepreviousvalue_behavior( + self, deploy_cfn_template, is_stack_updated, aws_client + ): + template_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/cfn_reuse_param.yaml" + ) + + # 1. create with overridden default value. Due to the condition this should neither create the optional topic, + # nor the corresponding output + stack = deploy_cfn_template(template_path=template_path, parameters={"DeployParam": "no"}) + + stack_describe_response = aws_client.cloudformation.describe_stacks( + StackName=stack.stack_name + )["Stacks"][0] + assert len(stack_describe_response["Outputs"]) == 1 + + # 2. update using UsePreviousValue. DeployParam should still be "no", still overriding the default and the only + # change should be the changed tag on the required topic + aws_client.cloudformation.update_stack( + StackName=stack.stack_namestack_name, + TemplateBody=load_template_raw(template_path), + Parameters=[ + {"ParameterKey": "CustomTag", "ParameterValue": "trigger-change"}, + {"ParameterKey": "DeployParam", "UsePreviousValue": True}, + ], + ) + wait_until(is_stack_updated(stack.stack_id)) + stack_describe_response = aws_client.cloudformation.describe_stacks( + StackName=stack.stack_name + )["Stacks"][0] + assert len(stack_describe_response["Outputs"]) == 1 + + # 3. update with setting the deployparam to "yes" not. The condition will evaluate to true and thus create the + # topic + output note: for an even trickier challenge for the cloudformation engine, remove the second parameter + # key. Behavior should stay the same. + aws_client.cloudformation.update_stack( + StackName=stack.stack_name, + TemplateBody=load_template_raw(template_path), + Parameters=[ + {"ParameterKey": "CustomTag", "ParameterValue": "trigger-change-2"}, + {"ParameterKey": "DeployParam", "ParameterValue": "yes"}, + ], + ) + assert is_stack_updated(stack.stack_id) + stack_describe_response = aws_client.cloudformation.describe_stacks( + StackName=stack.stack_id + )["Stacks"][0] + assert len(stack_describe_response["Outputs"]) == 2 + + +@pytest.mark.skip(reason="CFNV2:Imports unsupported") +class TestImportValues: + @markers.aws.validated + def test_cfn_with_exports(self, deploy_cfn_template, aws_client, snapshot): + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/engine/cfn_exports.yml" + ) + ) + + exports = aws_client.cloudformation.list_exports()["Exports"] + filtered = [exp for exp in exports if exp["ExportingStackId"] == stack.stack_id] + filtered.sort(key=lambda x: x["Name"]) + + snapshot.match("exports", filtered) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + + @markers.aws.validated + def test_import_values_across_stacks(self, deploy_cfn_template, aws_client): + export_name = f"b-{short_uid()}" + + # create stack #1 + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/cfn_function_export.yml" + ), + parameters={"BucketExportName": export_name}, + ) + bucket_name1 = result.outputs.get("BucketName1") + assert bucket_name1 + + # create stack #2 + result = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/cfn_function_import.yml" + ), + parameters={"BucketExportName": export_name}, + ) + bucket_name2 = result.outputs.get("BucketName2") + assert bucket_name2 + + # assert that correct bucket tags have been created + tagging = aws_client.s3.get_bucket_tagging(Bucket=bucket_name2) + test_tag = [tag for tag in tagging["TagSet"] if tag["Key"] == "test"] + assert test_tag + assert test_tag[0]["Value"] == bucket_name1 + + # TODO support this method + # assert cfn_client.list_imports(ExportName=export_name)["Imports"] + + +class TestMacros: + @markers.aws.validated + def test_macro_deployment( + self, deploy_cfn_template, create_lambda_function, snapshot, aws_client + ): + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/format_template.py" + ) + macro_name = "SubstitutionMacro" + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + stack_with_macro = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + description = aws_client.cloudformation.describe_stack_resources( + StackName=stack_with_macro.stack_name + ) + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("stack_outputs", stack_with_macro.outputs) + snapshot.match("stack_resource_descriptions", description) + + @pytest.mark.skip("CFNV2:GetTemplate") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TemplateBody.Resources.Parameter.LogicalResourceId", + "$..TemplateBody.Conditions", + "$..TemplateBody.Mappings", + "$..TemplateBody.StackId", + "$..TemplateBody.StackName", + "$..TemplateBody.Transform", + ] + ) + def test_global_scope( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + This test validates the behaviour of a template deployment that includes a global transformation + """ + + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/format_template.py" + ) + macro_name = "SubstitutionMacro" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + new_value = f"new-value-{short_uid()}" + stack_name = f"stake-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + TemplateBody=load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_global_parameter.yml", + ) + ), + Parameters=[{"ParameterKey": "Substitution", "ParameterValue": new_value}], + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.add_transformer(snapshot.transform.regex(new_value, "new-value")) + snapshot.match("processed_template", processed_template) + + @pytest.mark.skip( + reason="CFNV2:Fn::Transform as resource property with missing Name and Parameters fields." + ) + @markers.aws.validated + @pytest.mark.parametrize( + "template_to_transform", + ["transformation_snippet_topic.yml", "transformation_snippet_topic.json"], + ) + def test_snipped_scope( + self, + deploy_cfn_template, + create_lambda_function, + snapshot, + template_to_transform, + aws_client, + ): + """ + This test validates the behaviour of a template deployment that includes a snipped transformation also the + responses from the get_template with different template formats. + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/add_standard_attributes.py" + ) + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + macro_name = "ConvertTopicToFifo" + stack_name = f"stake-macro-{short_uid()}" + deploy_cfn_template( + stack_name=stack_name, + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + topic_name = f"topic-{short_uid()}.fifo" + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../templates", + template_to_transform, + ), + parameters={"TopicName": topic_name}, + ) + original_template = aws_client.cloudformation.get_template( + StackName=stack.stack_name, TemplateStage="Original" + ) + processed_template = aws_client.cloudformation.get_template( + StackName=stack.stack_name, TemplateStage="Processed" + ) + snapshot.add_transformer(snapshot.transform.regex(topic_name, "topic-name")) + + snapshot.match("original_template", original_template) + snapshot.match("processed_template", processed_template) + + @markers.aws.validated + def test_attribute_uses_macro(self, deploy_cfn_template, create_lambda_function, aws_client): + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/return_random_string.py" + ) + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + macro_name = "GenerateRandom" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../templates", + "transformation_resource_att.yml", + ), + parameters={"Input": "test"}, + ) + + resulting_value = stack.outputs["Parameter"] + assert "test-" in resulting_value + + @markers.aws.validated + @pytest.mark.skip(reason="Fn::Transform does not support array of transformations") + def test_scope_order_and_parameters( + self, deploy_cfn_template, create_lambda_function, snapshot, aws_client + ): + """ + The test validates the order of execution of transformations and also asserts that any type of + transformation can receive inputs. + """ + + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/replace_string.py" + ) + macro_name = "ReplaceString" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_multiple_scope_parameter.yml", + ), + ) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack.stack_name, TemplateStage="Processed" + ) + snapshot.match("processed_template", processed_template) + + @pytest.mark.skip(reason="CFNV2:Validation") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TemplateBody.Resources.Parameter.LogicalResourceId", + "$..TemplateBody.Conditions", + "$..TemplateBody.Mappings", + "$..TemplateBody.Parameters", + "$..TemplateBody.StackId", + "$..TemplateBody.StackName", + "$..TemplateBody.Transform", + "$..TemplateBody.Resources.Role.LogicalResourceId", + ] + ) + def test_capabilities_requirements( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + The test validates that AWS will return an error about missing CAPABILITY_AUTOEXPAND when adding a + resource during the transformation, and it will ask for CAPABILITY_NAMED_IAM when the new resource is a + IAM role + """ + + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/add_role.py" + ) + macro_name = "AddRole" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack_name = f"stack-{short_uid()}" + args = { + "StackName": stack_name, + "TemplateBody": load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_add_role.yml", + ) + ), + } + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack(**args) + snapshot.match("error", ex.value.response) + + args["Capabilities"] = [ + "CAPABILITY_AUTO_EXPAND", # Required to allow macro to add a role to template + "CAPABILITY_NAMED_IAM", # Required to allow CFn create added role + ] + aws_client.cloudformation.create_stack(**args) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.add_transformer(snapshot.transform.key_value("RoleName", "role-name")) + snapshot.match("processed_template", processed_template) + + @pytest.mark.skip("CFNV2:GetTemplate") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Event.fragment.Conditions", + "$..Event.fragment.Mappings", + "$..Event.fragment.Outputs", + "$..Event.fragment.Resources.Parameter.LogicalResourceId", + "$..Event.fragment.StackId", + "$..Event.fragment.StackName", + "$..Event.fragment.Transform", + ] + ) + def test_validate_lambda_internals( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + The test validates the content of the event pass into the macro lambda + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/print_internals.py" + ) + + macro_name = "PrintInternals" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack_name = f"stake-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + TemplateBody=load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_print_internals.yml", + ) + ), + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.match( + "event", + processed_template["TemplateBody"]["Resources"]["Parameter"]["Properties"]["Value"], + ) + + @pytest.mark.skip("CFNV2:Validation") + @markers.aws.validated + def test_to_validate_template_limit_for_macro( + self, deploy_cfn_template, create_lambda_function, snapshot, aws_client + ): + """ + The test validates the max size of a template that can be passed into the macro function + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/format_template.py" + ) + macro_name = "FormatTemplate" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + template_dict = parse_yaml( + load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_global_parameter.yml", + ) + ) + ) + for n in range(0, 1000): + template_dict["Resources"][f"Parameter{n}"] = deepcopy( + template_dict["Resources"]["Parameter"] + ) + + template = yaml.dump(template_dict) + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack( + StackName=f"stack-{short_uid()}", TemplateBody=template + ) + + response = ex.value.response + response["Error"]["Message"] = response["Error"]["Message"].replace( + template, "" + ) + snapshot.match("error_response", response) + + @pytest.mark.skip("CFNV2:Validation") + @markers.aws.validated + def test_error_pass_macro_as_reference(self, snapshot, aws_client): + """ + This test shows that the CFn will reject any transformation name that has been specified as reference, for + example, a parameter. + """ + + with pytest.raises(botocore.exceptions.ClientError) as ex: + aws_client.cloudformation.create_stack( + StackName=f"stack-{short_uid()}", + TemplateBody=load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_macro_as_reference.yml", + ) + ), + Capabilities=["CAPABILITY_AUTO_EXPAND"], + Parameters=[{"ParameterKey": "MacroName", "ParameterValue": "NonExistent"}], + ) + snapshot.match("error", ex.value.response) + + @pytest.mark.skip("CFNV2:GetTemplate") + @markers.aws.validated + def test_functions_and_references_during_transformation( + self, deploy_cfn_template, create_lambda_function, snapshot, cleanups, aws_client + ): + """ + This tests shows the state of intrinsic functions during the execution of the macro + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/print_references.py" + ) + macro_name = "PrintReferences" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + stack_name = f"stake-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, + Capabilities=["CAPABILITY_AUTO_EXPAND"], + TemplateBody=load_template_file( + os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_macro_params_as_reference.yml", + ) + ), + Parameters=[{"ParameterKey": "MacroInput", "ParameterValue": "CreateStackInput"}], + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + processed_template = aws_client.cloudformation.get_template( + StackName=stack_name, TemplateStage="Processed" + ) + snapshot.match( + "event", + processed_template["TemplateBody"]["Resources"]["Parameter"]["Properties"]["Value"], + ) + + @pytest.mark.skip(reason="CFNV2:Validation") + @pytest.mark.parametrize( + "macro_function", + [ + "return_unsuccessful_with_message.py", + "return_unsuccessful_without_message.py", + "return_invalid_template.py", + "raise_error.py", + ], + ) + @markers.aws.validated + def test_failed_state( + self, + deploy_cfn_template, + create_lambda_function, + snapshot, + cleanups, + macro_function, + aws_client, + ): + """ + This test shows the error responses for different negative responses from the macro lambda + """ + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../../templates/macros/", macro_function + ) + + macro_name = "Unsuccessful" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + template = load_file( + os.path.join( + os.path.dirname(__file__), + "../../../../templates/transformation_unsuccessful.yml", + ) + ) + + stack_name = f"stack-{short_uid()}" + aws_client.cloudformation.create_stack( + StackName=stack_name, Capabilities=["CAPABILITY_AUTO_EXPAND"], TemplateBody=template + ) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_name)) + + with pytest.raises(botocore.exceptions.WaiterError): + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + events = aws_client.cloudformation.describe_stack_events(StackName=stack_name)[ + "StackEvents" + ] + + failed_events_by_policy = [ + event + for event in events + if "ResourceStatusReason" in event and event["ResourceStatus"] == "ROLLBACK_IN_PROGRESS" + ] + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("failed_description", failed_events_by_policy[0]) + + @markers.aws.validated + def test_pyplate_param_type_list(self, deploy_cfn_template, aws_client, snapshot): + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/pyplate_deploy_template.yml" + ), + ) + + tags = "Env=Prod,Application=MyApp,BU=ModernisationTeam" + param_tags = {pair.split("=")[0]: pair.split("=")[1] for pair in tags.split(",")} + + stack_with_macro = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../../templates/pyplate_example.yml" + ), + parameters={"Tags": tags}, + ) + + bucket_name_output = stack_with_macro.outputs["BucketName"] + assert bucket_name_output + + tagging = aws_client.s3.get_bucket_tagging(Bucket=bucket_name_output) + tags_s3 = [tag for tag in tagging["TagSet"]] + + resp = [] + for tag in tags_s3: + if tag["Key"] in param_tags: + assert tag["Value"] == param_tags[tag["Key"]] + resp.append([tag["Key"], tag["Value"]]) + assert len(tags_s3) >= len(param_tags) + snapshot.match("tags", sorted(resp)) + + +class TestStackEvents: + @pytest.mark.skip(reason="CFNV2:Validation") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..EventId", + "$..PhysicalResourceId", + "$..ResourceProperties", + # TODO: we do not maintain parity here, just that the property exists + "$..ResourceStatusReason", + ] + ) + def test_invalid_stack_deploy(self, deploy_cfn_template, aws_client, snapshot): + logical_resource_id = "MyParameter" + template = { + "Resources": { + logical_resource_id: { + "Type": "AWS::SSM::Parameter", + "Properties": { + # invalid: missing required property _type_ + "Value": "abc123", + }, + }, + }, + } + + with pytest.raises(StackDeployError) as exc_info: + deploy_cfn_template(template=json.dumps(template)) + + stack_events = exc_info.value.events + # filter out only the single create event that failed + failed_events = [ + every + for every in stack_events + if every["ResourceStatus"] == "CREATE_FAILED" + and every["LogicalResourceId"] == logical_resource_id + ] + assert len(failed_events) == 1 + failed_event = failed_events[0] + + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.match("failed_event", failed_event) + assert "ResourceStatusReason" in failed_event + + +class TestPseudoParameters: + @markers.aws.validated + def test_stack_id(self, deploy_cfn_template, snapshot): + template = { + "Resources": { + "MyParameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "AWS::StackId", + }, + }, + }, + }, + "Outputs": { + "StackId": { + "Value": { + "Fn::GetAtt": [ + "MyParameter", + "Value", + ], + }, + }, + }, + } + + stack = deploy_cfn_template(template=json.dumps(template)) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + + snapshot.match("StackId", stack.outputs["StackId"]) diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.snapshot.json b/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.snapshot.json new file mode 100644 index 0000000000000..bcc4ddf05b2c7 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.snapshot.json @@ -0,0 +1,687 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestTypes::test_implicit_type_conversion": { + "recorded-date": "29-08-2023, 15:21:22", + "recorded-content": { + "queue": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "2", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_global_scope": { + "recorded-date": "30-01-2023, 20:14:48", + "recorded-content": { + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "ParameterName": { + "Value": { + "Ref": "Parameter" + } + } + }, + "Parameters": { + "Substitution": { + "Default": "SubstitutionDefault", + "Type": "String" + } + }, + "Resources": { + "Parameter": { + "Properties": { + "Type": "String", + "Value": "new-value" + }, + "Type": "AWS::SSM::Parameter" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_snipped_scope": { + "recorded-date": "06-12-2022, 09:44:49", + "recorded-content": { + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "ContentBasedDeduplication": true, + "FifoTopic": true, + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_scope_order_and_parameters": { + "recorded-date": "07-12-2022, 09:08:26", + "recorded-content": { + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Resources": { + "Parameter": { + "Properties": { + "Type": "String", + "Value": "snippet-transform second-snippet-transform global-transform second-global-transform " + }, + "Type": "AWS::SSM::Parameter" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.yml]": { + "recorded-date": "08-12-2022, 16:24:58", + "recorded-content": { + "original_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": "Parameters:\n TopicName:\n Type: String\n\nResources:\n Topic:\n Type: AWS::SNS::Topic\n Properties:\n TopicName:\n Ref: TopicName\n Fn::Transform: ConvertTopicToFifo\n\nOutputs:\n TopicName:\n Value:\n Fn::GetAtt:\n - Topic\n - TopicName\n", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "ContentBasedDeduplication": true, + "FifoTopic": true, + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.json]": { + "recorded-date": "08-12-2022, 16:25:43", + "recorded-content": { + "original_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "Fn::Transform": "ConvertTopicToFifo", + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + }, + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Properties": { + "ContentBasedDeduplication": true, + "FifoTopic": true, + "TopicName": { + "Ref": "TopicName" + } + }, + "Type": "AWS::SNS::Topic" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_capabilities_requirements": { + "recorded-date": "30-01-2023, 20:15:46", + "recorded-content": { + "error": { + "Error": { + "Code": "InsufficientCapabilitiesException", + "Message": "Requires capabilities : [CAPABILITY_AUTO_EXPAND]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "processed_template": { + "StagesAvailable": [ + "Original", + "Processed" + ], + "TemplateBody": { + "Outputs": { + "ParameterName": { + "Value": { + "Ref": "Parameter" + } + } + }, + "Resources": { + "Parameter": { + "Properties": { + "Type": "String", + "Value": "not-important" + }, + "Type": "AWS::SSM::Parameter" + }, + "Role": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": "*" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AdministratorAccess" + ] + ] + } + ], + "RoleName": "" + }, + "Type": "AWS::IAM::Role" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_validate_lambda_internals": { + "recorded-date": "30-01-2023, 20:16:45", + "recorded-content": { + "event": { + "Event": { + "accountId": "111111111111", + "fragment": { + "Parameters": { + "ExampleParameter": { + "Type": "String", + "Default": "example-value" + } + }, + "Resources": { + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Value": "", + "Type": "String" + } + } + } + }, + "transformId": "111111111111::PrintInternals", + "requestId": "", + "region": "", + "params": { + "Input": "test-input" + }, + "templateParameterValues": { + "ExampleParameter": "example-value" + } + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_to_validate_template_limit_for_macro": { + "recorded-date": "30-01-2023, 20:17:04", + "recorded-content": { + "error_response": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value '' at 'templateBody' failed to satisfy constraint: Member must have length less than or equal to 51200", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_error_pass_macro_as_reference": { + "recorded-date": "30-01-2023, 20:17:05", + "recorded-content": { + "error": { + "Error": { + "Code": "ValidationError", + "Message": "Key Name of transform definition must be a string.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_error_macro_param_as_reference": { + "recorded-date": "08-12-2022, 11:50:49", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_functions_and_references_during_transformation": { + "recorded-date": "30-01-2023, 20:17:55", + "recorded-content": { + "event": { + "Params": { + "Input": "CreateStackInput" + }, + "FunctionValue": { + "Fn::Join": [ + " ", + [ + "Hello", + "World" + ] + ] + }, + "ValueOfRef": { + "Ref": "Substitution" + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_with_message.py]": { + "recorded-date": "30-01-2023, 20:18:45", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Transform 111111111111::Unsuccessful failed with: failed because it is a test. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_without_message.py]": { + "recorded-date": "30-01-2023, 20:19:35", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Transform 111111111111::Unsuccessful failed without an error message.. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_invalid_template.py]": { + "recorded-date": "30-01-2023, 20:20:30", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Template format error: unsupported structure.. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[raise_error.py]": { + "recorded-date": "30-01-2023, 20:21:20", + "recorded-content": { + "failed_description": { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "ROLLBACK_IN_PROGRESS", + "ResourceStatusReason": "Received malformed response from transform 111111111111::Unsuccessful. Rollback requested by user.", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_create_stack_with_ssm_parameters": { + "recorded-date": "15-01-2023, 17:54:23", + "recorded-content": { + "stack-details": { + "Capabilities": [ + "CAPABILITY_AUTO_EXPAND", + "CAPABILITY_IAM", + "CAPABILITY_NAMED_IAM" + ], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "parameter123", + "ParameterValue": "", + "ResolvedValue": "" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "topic-tags": { + "Tags": [ + { + "Key": "param-value", + "Value": "param " + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_macro_deployment": { + "recorded-date": "30-01-2023, 20:13:58", + "recorded-content": { + "stack_outputs": { + "MacroRef": "SubstitutionMacro" + }, + "stack_resource_descriptions": { + "StackResources": [ + { + "DriftInformation": { + "StackResourceDriftStatus": "NOT_CHECKED" + }, + "LogicalResourceId": "Macro", + "PhysicalResourceId": "SubstitutionMacro", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Macro", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestStackEvents::test_invalid_stack_deploy": { + "recorded-date": "12-06-2023, 17:08:47", + "recorded-content": { + "failed_event": { + "EventId": "MyParameter-CREATE_FAILED-date", + "LogicalResourceId": "MyParameter", + "PhysicalResourceId": "", + "ResourceProperties": { + "Value": "abc123" + }, + "ResourceStatus": "CREATE_FAILED", + "ResourceStatusReason": "Property validation failure: [The property {/Type} is required]", + "ResourceType": "AWS::SSM::Parameter", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_pyplate_param_type_list": { + "recorded-date": "17-05-2024, 06:19:03", + "recorded-content": { + "tags": [ + [ + "Application", + "MyApp" + ], + [ + "BU", + "ModernisationTeam" + ], + [ + "Env", + "Prod" + ] + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestImportValues::test_cfn_with_exports": { + "recorded-date": "21-06-2024, 18:37:15", + "recorded-content": { + "exports": [ + { + "ExportingStackId": "", + "Name": "-TestExport-0", + "Value": "test" + }, + { + "ExportingStackId": "", + "Name": "-TestExport-1", + "Value": "test" + }, + { + "ExportingStackId": "", + "Name": "-TestExport-2", + "Value": "test" + } + ] + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestPseudoParameters::test_stack_id": { + "recorded-date": "18-07-2024, 08:56:47", + "recorded-content": { + "StackId": "" + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_create_change_set_with_ssm_parameter_list": { + "recorded-date": "08-08-2024, 21:21:23", + "recorded-content": { + "role-name": "", + "iam_role_policy": { + "PolicyDocument": { + "Statement": [ + { + "Action": "*", + "Effect": "Allow", + "Resource": [ + "arn::ssm::111111111111:parameter/some/params", + "arn::ssm::111111111111:parameter/some/other/params" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "policy-123", + "RoleName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_join_no_value_construct": { + "recorded-date": "22-01-2025, 14:01:46", + "recorded-content": { + "join-output": { + "JoinConditionalNoValue": "", + "JoinOnlyNoValue": "", + "JoinWithNoValue": "Sample" + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.validation.json b/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.validation.json new file mode 100644 index 0000000000000..408d1213a84b5 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.validation.json @@ -0,0 +1,107 @@ +{ + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestImportValues::test_cfn_with_exports": { + "last_validated_date": "2024-06-21T18:37:15+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestImports::test_stack_imports": { + "last_validated_date": "2024-07-04T14:19:31+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_cfn_template_with_short_form_fn_sub": { + "last_validated_date": "2024-06-20T20:41:15+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function": { + "last_validated_date": "2024-04-03T07:12:29+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-northeast-1]": { + "last_validated_date": "2024-05-09T08:34:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[ap-southeast-2]": { + "last_validated_date": "2024-05-09T08:34:02+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-central-1]": { + "last_validated_date": "2024-05-09T08:34:39+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[eu-west-1]": { + "last_validated_date": "2024-05-09T08:34:56+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-1]": { + "last_validated_date": "2024-05-09T08:32:56+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-east-2]": { + "last_validated_date": "2024-05-09T08:33:12+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-1]": { + "last_validated_date": "2024-05-09T08:33:29+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_get_azs_function[us-west-2]": { + "last_validated_date": "2024-05-09T08:33:45+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_join_no_value_construct": { + "last_validated_date": "2025-01-22T14:01:46+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestIntrinsicFunctions::test_sub_number_type": { + "last_validated_date": "2024-08-09T06:55:16+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_capabilities_requirements": { + "last_validated_date": "2023-01-30T19:15:46+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_error_pass_macro_as_reference": { + "last_validated_date": "2023-01-30T19:17:05+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[raise_error.py]": { + "last_validated_date": "2023-01-30T19:21:20+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_invalid_template.py]": { + "last_validated_date": "2023-01-30T19:20:30+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_with_message.py]": { + "last_validated_date": "2023-01-30T19:18:45+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_failed_state[return_unsuccessful_without_message.py]": { + "last_validated_date": "2023-01-30T19:19:35+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_functions_and_references_during_transformation": { + "last_validated_date": "2023-01-30T19:17:55+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_global_scope": { + "last_validated_date": "2023-01-30T19:14:48+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_macro_deployment": { + "last_validated_date": "2023-01-30T19:13:58+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_pyplate_param_type_list": { + "last_validated_date": "2024-05-17T06:19:03+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_scope_order_and_parameters": { + "last_validated_date": "2022-12-07T08:08:26+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.json]": { + "last_validated_date": "2022-12-08T15:25:43+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_snipped_scope[transformation_snippet_topic.yml]": { + "last_validated_date": "2022-12-08T15:24:58+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_to_validate_template_limit_for_macro": { + "last_validated_date": "2023-01-30T19:17:04+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestMacros::test_validate_lambda_internals": { + "last_validated_date": "2023-01-30T19:16:45+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestPseudoParameters::test_stack_id": { + "last_validated_date": "2024-07-18T08:56:47+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_create_change_set_with_ssm_parameter_list": { + "last_validated_date": "2024-08-08T21:21:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_create_stack_with_ssm_parameters": { + "last_validated_date": "2023-01-15T16:54:23+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestSsmParameters::test_ssm_nested_with_nested_stack": { + "last_validated_date": "2024-07-16T16:38:43+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestStackEvents::test_invalid_stack_deploy": { + "last_validated_date": "2023-06-12T15:08:47+00:00" + }, + "tests/aws/services/cloudformation/v2/ported_from_v1/test_template_engine.py::TestTypes::test_implicit_type_conversion": { + "last_validated_date": "2023-08-29T13:21:22+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_conditions.py b/tests/aws/services/cloudformation/v2/test_change_set_conditions.py new file mode 100644 index 0000000000000..f6b5661736f37 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_conditions.py @@ -0,0 +1,183 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetConditions: + @markers.aws.validated + @pytest.mark.skip( + reason=( + "The inclusion of response parameters in executor is in progress, " + "currently it cannot delete due to missing topic arn in the request" + ) + ) + def test_condition_update_removes_resource( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Conditions": {"CreateTopic": {"Fn::Equals": ["true", "true"]}}, + "Resources": { + "SNSTopic": { + "Type": "AWS::SNS::Topic", + "Condition": "CreateTopic", + "Properties": {"TopicName": name1}, + } + }, + } + template_2 = { + "Conditions": {"CreateTopic": {"Fn::Equals": ["true", "false"]}}, + "Resources": { + "SNSTopic": { + "Type": "AWS::SNS::Topic", + "Condition": "CreateTopic", + "Properties": {"TopicName": name1}, + }, + "TopicPlaceholder": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2}, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_condition_update_adds_resource( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Conditions": {"CreateTopic": {"Fn::Equals": ["true", "false"]}}, + "Resources": { + "SNSTopic": { + "Type": "AWS::SNS::Topic", + "Condition": "CreateTopic", + "Properties": {"TopicName": name1}, + }, + "TopicPlaceholder": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2}, + }, + }, + } + template_2 = { + "Conditions": {"CreateTopic": {"Fn::Equals": ["true", "true"]}}, + "Resources": { + "SNSTopic": { + "Type": "AWS::SNS::Topic", + "Condition": "CreateTopic", + "Properties": {"TopicName": name1}, + }, + "TopicPlaceholder": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2}, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + @pytest.mark.skip( + reason="The inclusion of response parameters in executor is in progress, " + "currently it cannot delete due to missing topic arn in the request" + ) + def test_condition_add_new_negative_condition_to_existent_resource( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "SNSTopic": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1}, + }, + }, + } + template_2 = { + "Conditions": {"CreateTopic": {"Fn::Equals": ["true", "false"]}}, + "Resources": { + "SNSTopic": { + "Type": "AWS::SNS::Topic", + "Condition": "CreateTopic", + "Properties": {"TopicName": name1}, + }, + "TopicPlaceholder": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2}, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_condition_add_new_positive_condition_to_existent_resource( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "SNSTopic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1}, + }, + }, + } + template_2 = { + "Conditions": {"CreateTopic": {"Fn::Equals": ["true", "true"]}}, + "Resources": { + "SNSTopic1": { + "Type": "AWS::SNS::Topic", + "Condition": "CreateTopic", + "Properties": {"TopicName": name1}, + }, + "SNSTopic2": { + "Type": "AWS::SNS::Topic", + "Condition": "CreateTopic", + "Properties": {"TopicName": name2}, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_conditions.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_conditions.snapshot.json new file mode 100644 index 0000000000000..147c4f2eae447 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_conditions.snapshot.json @@ -0,0 +1,1536 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_update_removes_resource": { + "recorded-date": "15-04-2025, 13:51:50", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "SNSTopic", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SNSTopic", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "TopicPlaceholder", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "Details": [], + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "TopicPlaceholder", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "SNSTopic": [ + { + "EventId": "SNSTopic-c494ee19-3e85-4cf7-b823-5b706137c086", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-f1a45cee-c917-4856-9b04-fdfa3d210cf3", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_COMPLETE-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "TopicPlaceholder": [ + { + "EventId": "TopicPlaceholder-CREATE_COMPLETE-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "TopicPlaceholder-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "TopicPlaceholder-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_update_adds_resource": { + "recorded-date": "15-04-2025, 14:31:36", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "TopicPlaceholder", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "TopicPlaceholder", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "SNSTopic", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SNSTopic", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "SNSTopic": [ + { + "EventId": "SNSTopic-CREATE_COMPLETE-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "TopicPlaceholder": [ + { + "EventId": "TopicPlaceholder-CREATE_COMPLETE-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "TopicPlaceholder-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "TopicPlaceholder-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_add_new_negative_condition_to_existent_resource": { + "recorded-date": "15-04-2025, 15:11:48", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "SNSTopic", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SNSTopic", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "TopicPlaceholder", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "Details": [], + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "TopicPlaceholder", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "SNSTopic": [ + { + "EventId": "SNSTopic-c5786633-a3d3-43cc-8c5d-f504661d0578", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-fb082f5d-2aee-49f6-9eb3-613c40aafad9", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_COMPLETE-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "TopicPlaceholder": [ + { + "EventId": "TopicPlaceholder-CREATE_COMPLETE-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "TopicPlaceholder-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "TopicPlaceholder-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "TopicPlaceholder", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_add_new_positive_condition_to_existent_resource": { + "recorded-date": "15-04-2025, 16:00:40", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "SNSTopic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SNSTopic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "SNSTopic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SNSTopic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "SNSTopic1": [ + { + "EventId": "SNSTopic1-CREATE_COMPLETE-date", + "LogicalResourceId": "SNSTopic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "SNSTopic2": [ + { + "EventId": "SNSTopic2-CREATE_COMPLETE-date", + "LogicalResourceId": "SNSTopic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "SNSTopic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "SNSTopic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_conditions.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_conditions.validation.json new file mode 100644 index 0000000000000..daba45fdabc59 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_conditions.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_add_new_negative_condition_to_existent_resource": { + "last_validated_date": "2025-04-15T15:11:48+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_add_new_positive_condition_to_existent_resource": { + "last_validated_date": "2025-04-15T16:00:39+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_update_adds_resource": { + "last_validated_date": "2025-04-15T14:31:36+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_conditions.py::TestChangeSetConditions::test_condition_update_removes_resource": { + "last_validated_date": "2025-04-15T13:51:50+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_depends_on.py b/tests/aws/services/cloudformation/v2/test_change_set_depends_on.py new file mode 100644 index 0000000000000..e4f7545a5667d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_depends_on.py @@ -0,0 +1,192 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetDependsOn: + @markers.aws.validated + def test_update_depended_resource( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2, "DisplayName": "display-value-2"}, + "DependsOn": "Topic1", + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1-updated"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2, "DisplayName": "display-value-2"}, + "DependsOn": "Topic1", + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_update_depended_resource_list( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2, "DisplayName": "display-value-2"}, + "DependsOn": ["Topic1"], + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1-updated"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2, "DisplayName": "display-value-2"}, + "DependsOn": ["Topic1"], + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_multiple_dependencies_addition( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + namen = f"topic-name-n-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + snapshot.add_transformer(RegexTransformer(namen, "topic-name-n")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topicn": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": namen, "DisplayName": "display-value-n"}, + "DependsOn": ["Topic1"], + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topicn": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": namen, "DisplayName": "display-value-n"}, + "DependsOn": ["Topic1", "Topic2"], + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2, "DisplayName": "display-value-2"}, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_multiple_dependencies_deletion( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + namen = f"topic-name-n-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + snapshot.add_transformer(RegexTransformer(namen, "topic-name-n")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name2, "DisplayName": "display-value-2"}, + }, + "Topicn": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": namen, "DisplayName": "display-value-n"}, + "DependsOn": ["Topic1", "Topic2"], + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topicn": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": namen, "DisplayName": "display-value-n"}, + "DependsOn": ["Topic1"], + }, + } + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_depends_on.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_depends_on.snapshot.json new file mode 100644 index 0000000000000..1c31c72649fa4 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_depends_on.snapshot.json @@ -0,0 +1,1838 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_update_depended_resource": { + "recorded-date": "19-05-2025, 12:55:10", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1-updated", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-1-updated", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1-updated", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1-updated", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_update_depended_resource_list": { + "recorded-date": "19-05-2025, 13:01:35", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1-updated", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-1-updated", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1-updated", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1-updated", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_multiple_dependencies_addition": { + "recorded-date": "19-05-2025, 18:10:11", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + } + }, + "Details": [], + "LogicalResourceId": "Topicn", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topicn", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topicn": [ + { + "EventId": "Topicn-CREATE_COMPLETE-date", + "LogicalResourceId": "Topicn", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-n", + "ResourceProperties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topicn-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topicn", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-n", + "ResourceProperties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topicn-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topicn", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_multiple_dependencies_deletion": { + "recorded-date": "19-05-2025, 18:13:11", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + } + }, + "Details": [], + "LogicalResourceId": "Topicn", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topicn", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "Details": [], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-ff127104-011d-4af1-9ed0-52ed22dff1b7", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-4b69478e-eeb4-4f9b-8a8a-e6e94164ec5a", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topicn": [ + { + "EventId": "Topicn-CREATE_COMPLETE-date", + "LogicalResourceId": "Topicn", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-n", + "ResourceProperties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topicn-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topicn", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-n", + "ResourceProperties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topicn-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topicn", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-n", + "TopicName": "topic-name-n" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_depends_on.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_depends_on.validation.json new file mode 100644 index 0000000000000..6d50b4297ea1d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_depends_on.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_multiple_dependencies_addition": { + "last_validated_date": "2025-05-19T18:10:11+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_multiple_dependencies_deletion": { + "last_validated_date": "2025-05-19T18:13:11+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_update_depended_resource": { + "last_validated_date": "2025-05-19T12:55:09+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_depends_on.py::TestChangeSetDependsOn::test_update_depended_resource_list": { + "last_validated_date": "2025-05-19T13:01:34+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py b/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py new file mode 100644 index 0000000000000..b8593dad92bd7 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py @@ -0,0 +1,97 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + ] +) +class TestChangeSetFnBase64: + @markers.aws.validated + def test_fn_base64_add_to_static_property( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": name1}} + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Base64": "HelloWorld"}}, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_base64_remove_from_static_property( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Base64": "HelloWorld"}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": name1}} + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_base64_change_input_string( + self, + snapshot, + capture_update_process, + ): + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Base64": "OldValue"}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Base64": "NewValue"}}, + } + } + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.snapshot.json new file mode 100644 index 0000000000000..bfec63bc4521b --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.snapshot.json @@ -0,0 +1,1136 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_add_to_static_property": { + "recorded-date": "02-06-2025, 17:27:21", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "SGVsbG9Xb3JsZA==", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_remove_from_static_property": { + "recorded-date": "02-06-2025, 17:28:46", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "SGVsbG9Xb3JsZA==", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "SGVsbG9Xb3JsZA==" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_change_input_string": { + "recorded-date": "02-06-2025, 17:30:12", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "T2xkVmFsdWU=" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "TmV3VmFsdWU=" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "T2xkVmFsdWU=" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "TmV3VmFsdWU=", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "T2xkVmFsdWU=", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "TmV3VmFsdWU=" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "TmV3VmFsdWU=" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "T2xkVmFsdWU=" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "T2xkVmFsdWU=" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "T2xkVmFsdWU=" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.validation.json new file mode 100644 index 0000000000000..b29b77f2c4405 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_base64.validation.json @@ -0,0 +1,29 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_add_to_static_property": { + "last_validated_date": "2025-06-02T17:27:21+00:00", + "durations_in_seconds": { + "setup": 0.81, + "call": 83.7, + "teardown": 0.1, + "total": 84.61 + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_change_input_string": { + "last_validated_date": "2025-06-02T17:30:12+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 85.51, + "teardown": 0.1, + "total": 85.61 + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_base64.py::TestChangeSetFnBase64::test_fn_base64_remove_from_static_property": { + "last_validated_date": "2025-06-02T17:28:46+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 84.58, + "teardown": 0.1, + "total": 84.68 + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py b/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py new file mode 100644 index 0000000000000..5255ff0704736 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py @@ -0,0 +1,314 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetFnGetAttr: + @markers.aws.validated + def test_resource_addition( + self, + snapshot, + capture_update_process, + ): + # Modify the Value property of a resource to a different literal + # while keeping the dependency via Fn::GetAtt intact. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @pytest.mark.skip(reason="See FIXME in aws_sns_provider::delete") + @markers.aws.validated + def test_resource_deletion( + self, + snapshot, + capture_update_process, + ): + # Modify the Value property of a resource to a different literal + # while keeping the dependency via Fn::GetAtt intact. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: AWS incorrectly does not list the second topic as + # needing modifying, however it needs to + "describe-change-set-2-prop-values..Changes", + ] + ) + @markers.aws.validated + def test_direct_attribute_value_change( + self, + snapshot, + capture_update_process, + ): + # Modify the Value property of a resource to a different literal + # while keeping the dependency via Fn::GetAtt intact. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-2"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: AWS incorrectly does not list the second and third topic as + # needing modifying, however it needs to + "describe-change-set-2-prop-values..Changes", + ] + ) + @markers.aws.validated + def test_direct_attribute_value_change_in_get_attr_chain( + self, + snapshot, + capture_update_process, + ): + # Modify the Value property of a resource to a different literal + # while keeping the dependency via Fn::GetAtt intact. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + name3 = f"topic-name-3-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + snapshot.add_transformer(RegexTransformer(name3, "topic-name-3")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name3, + "DisplayName": {"Fn::GetAtt": ["Topic2", "DisplayName"]}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-2"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name3, + "DisplayName": {"Fn::GetAtt": ["Topic2", "DisplayName"]}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: AWS appears to incorrectly evaluate the new resource's DisplayName property + # to the old value of the resource being referenced. The describer instead masks + # this value with KNOWN_AFTER_APPLY. The update graph would be able to compute the + # correct new value, however in an effort to match the general behaviour of AWS CFN + # this is being masked as it is updated. + "$..Changes..ResourceChange.AfterContext.Properties.DisplayName", + ] + ) + @markers.aws.validated + def test_direct_attribute_value_change_with_dependent_addition( + self, + snapshot, + capture_update_process, + ): + # Modify the Value property of a resource to a different literal + # while keeping the dependency via Fn::GetAtt intact. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-2"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_immutable_property_update_causes_resource_replacement( + self, + snapshot, + capture_update_process, + ): + # Changing TopicName in Topic1 from represents an immutable property update. + # This should force the resource to be replaced, rather than updated in place. + name1 = f"topic-name-1-{long_uid()}" + name1_update = f"updated-topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name1_update, "updated-topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "value"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1_update, "DisplayName": "new_value"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::GetAtt": ["Topic1", "DisplayName"]}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.snapshot.json new file mode 100644 index 0000000000000..c9a382f83c5d3 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.snapshot.json @@ -0,0 +1,3020 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change": { + "recorded-date": "08-04-2025, 11:24:14", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_immutable_property_update_causes_resource_replacement": { + "recorded-date": "08-04-2025, 12:17:00", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "new_value", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "value", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "updated-topic-name-1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "value", + "TopicName": "topic-name-2" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "value", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "value", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-7f5fe7ea-9367-43f1-8b98-aa0cef118b00", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-21abbdc1-8335-4dd0-ad4a-f8900e5d49df", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:updated-topic-name-1", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:updated-topic-name-1", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Requested update requires the creation of a new physical resource; hence creating one.", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change_with_dependent_addition": { + "recorded-date": "08-04-2025, 12:20:19", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_resource_addition": { + "recorded-date": "08-04-2025, 12:33:53", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_resource_deletion": { + "recorded-date": "08-04-2025, 12:36:41", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "BeforeContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "Details": [], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-0996d20a-f076-4df0-9fd0-ca5dfcfc0321", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-dfb75ba6-f05f-4970-818e-7e3127cef7d2", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change_in_get_attr_chain": { + "recorded-date": "08-04-2025, 14:46:11", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-3" + } + }, + "Details": [], + "LogicalResourceId": "Topic3", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic3", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic2.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-3", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic3": [ + { + "EventId": "Topic3-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-3", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic3-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-3", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic3-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-3", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic3-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-3", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic3-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.validation.json new file mode 100644 index 0000000000000..b134dc47b4ce5 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change": { + "last_validated_date": "2025-04-08T11:24:14+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change_in_get_attr_chain": { + "last_validated_date": "2025-04-08T14:46:11+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_direct_attribute_value_change_with_dependent_addition": { + "last_validated_date": "2025-04-08T12:20:18+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_immutable_property_update_causes_resource_replacement": { + "last_validated_date": "2025-04-08T12:17:00+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_resource_addition": { + "last_validated_date": "2025-04-08T12:33:53+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_get_attr.py::TestChangeSetFnGetAttr::test_resource_deletion": { + "last_validated_date": "2025-04-08T12:36:40+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_join.py b/tests/aws/services/cloudformation/v2/test_change_set_fn_join.py new file mode 100644 index 0000000000000..718f1a1181043 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_join.py @@ -0,0 +1,272 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetFnJoin: + # TODO: Test behaviour with different argument types. + + @markers.aws.validated + def test_update_string_literal_delimiter_empty( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["", ["v1", "test"]]}, + }, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["-", ["v1", "test"]]}, + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: aws appears to not display the "DisplayName" as + # previously having an empty name during the update. + "describe-change-set-2-prop-values..Changes..ResourceChange.BeforeContext.Properties.DisplayName" + ] + ) + def test_update_string_literal_arguments_empty( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": {"Fn::Join": ["", []]}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["", ["v1", "test"]]}, + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_update_string_literal_argument( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["-", ["v1", "test"]]}, + }, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["-", ["v2", "test"]]}, + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_update_string_literal_delimiter( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["-", ["v1", "test"]]}, + }, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["_", ["v2", "test"]]}, + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: AWS appears to not detect the changed DisplayName field during update. + "describe-change-set-2-prop-values..Changes", + ] + ) + def test_update_refence_argument( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-name-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": { + "Fn::Join": ["-", ["prefix", {"Fn::GetAtt": ["Topic1", "DisplayName"]}]] + }, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-name-2"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": { + "Fn::Join": ["-", ["prefix", {"Fn::GetAtt": ["Topic1", "DisplayName"]}]] + }, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: AWS appears to not detect the changed DisplayName field during update. + "describe-change-set-2-prop-values..Changes", + ] + ) + def test_indirect_update_refence_argument( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["-", ["display", "name", "1"]]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": { + "Fn::Join": ["-", ["prefix", {"Fn::GetAtt": ["Topic1", "DisplayName"]}]] + }, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Join": ["-", ["display", "name", "2"]]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": { + "Fn::Join": ["-", ["prefix", {"Fn::GetAtt": ["Topic1", "DisplayName"]}]] + }, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_join.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_join.snapshot.json new file mode 100644 index 0000000000000..ab448456fa342 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_join.snapshot.json @@ -0,0 +1,2574 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_argument": { + "recorded-date": "05-05-2025, 13:10:55", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "v2-test", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "v2-test", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "v1-test", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v2-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v2-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_delimiter": { + "recorded-date": "05-05-2025, 13:15:58", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "v2_test", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "v2_test", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "v1-test", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v2_test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v2_test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_refence_argument": { + "recorded-date": "05-05-2025, 13:24:03", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "prefix-display-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_indirect_update_refence_argument": { + "recorded-date": "05-05-2025, 13:31:26", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-name-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "prefix-display-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "prefix-display-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_delimiter_empty": { + "recorded-date": "05-05-2025, 13:37:54", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "v1-test", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "v1test", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1-test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_arguments_empty": { + "recorded-date": "05-05-2025, 13:42:26", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "v1test", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "v1test", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_join.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_join.validation.json new file mode 100644 index 0000000000000..b8cd37a40d981 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_join.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_indirect_update_refence_argument": { + "last_validated_date": "2025-05-05T13:31:26+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_refence_argument": { + "last_validated_date": "2025-05-05T13:24:03+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_argument": { + "last_validated_date": "2025-05-05T13:10:54+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_arguments_empty": { + "last_validated_date": "2025-05-05T13:42:26+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_delimiter": { + "last_validated_date": "2025-05-05T13:15:57+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_join.py::TestChangeSetFnJoin::test_update_string_literal_delimiter_empty": { + "last_validated_date": "2025-05-05T13:37:54+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_select.py b/tests/aws/services/cloudformation/v2/test_change_set_fn_select.py new file mode 100644 index 0000000000000..16b5dee524632 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_select.py @@ -0,0 +1,203 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetFnSelect: + @markers.aws.validated + def test_fn_select_add_to_static_property( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": name1}} + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [1, ["1st", "2nd", "3rd"]]}}, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_select_remove_from_static_property( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [1, ["1st", "2nd", "3rd"]]}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": name1}} + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_select_change_in_selection_list( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [1, ["1st", "2nd"]]}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [1, ["1st", "new-2nd", "3rd"]]}}, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_select_change_in_selection_index_only( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [1, ["1st", "2nd"]]}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [0, ["1st", "2nd"]]}}, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_select_change_in_selected_element_type_ref( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [0, ["1st"]]}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Select": [0, [{"Ref": "AWS::StackName"}]]}}, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: AWS incorrectly does not list the second and third topic as + # needing modifying, however it needs to + "describe-change-set-2-prop-values..Changes", + ] + ) + @markers.aws.validated + def test_fn_select_change_get_att_reference( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": name1}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Select": [0, [{"Fn::GetAtt": ["Topic1", "DisplayName"]}]] + } + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": name2}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Select": [0, [{"Fn::GetAtt": ["Topic1", "DisplayName"]}]] + } + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_select.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_select.snapshot.json new file mode 100644 index 0000000000000..3e286c96554e9 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_select.snapshot.json @@ -0,0 +1,2392 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_add_to_static_property": { + "recorded-date": "28-05-2025, 13:14:01", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "2nd" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "2nd", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_remove_from_static_property": { + "recorded-date": "28-05-2025, 13:17:47", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "2nd" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "2nd" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "2nd", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selection_list": { + "recorded-date": "28-05-2025, 13:21:34", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "2nd" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "new-2nd" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "2nd" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "new-2nd", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "2nd", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "new-2nd" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "new-2nd" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selection_index_only": { + "recorded-date": "28-05-2025, 13:23:46", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "2nd" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "1st" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "2nd" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "1st", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "2nd", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "1st" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "1st" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "2nd" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selected_element_type_ref": { + "recorded-date": "28-05-2025, 13:32:24", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "1st" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "1st" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "1st", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "1st" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "1st" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "1st" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_index_select_from_parameter_list": { + "recorded-date": "28-05-2025, 13:56:52", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_get_att_reference": { + "recorded-date": "28-05-2025, 14:44:47", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_select.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_select.validation.json new file mode 100644 index 0000000000000..49ee9ee8fcdc4 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_select.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_add_to_static_property": { + "last_validated_date": "2025-05-28T13:14:01+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_get_att_reference": { + "last_validated_date": "2025-05-28T14:44:47+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selected_element_type_ref": { + "last_validated_date": "2025-05-28T13:32:24+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selection_index_only": { + "last_validated_date": "2025-05-28T13:23:46+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_change_in_selection_list": { + "last_validated_date": "2025-05-28T13:21:34+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_select.py::TestChangeSetFnSelect::test_fn_select_remove_from_static_property": { + "last_validated_date": "2025-05-28T13:17:47+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_split.py b/tests/aws/services/cloudformation/v2/test_change_set_fn_split.py new file mode 100644 index 0000000000000..fd85f7a61011c --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_split.py @@ -0,0 +1,243 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + "$..StatusReason", + ] +) +class TestChangeSetFnSplit: + @markers.aws.validated + def test_fn_split_add_to_static_property( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name1.replace("-", "_"), "topic_name_1")) + template_1 = { + "Resources": { + "Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": name1}} + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Join": [ + "_", + {"Fn::Split": ["-", "part1-part2-part3"]}, + ] + } + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_split_remove_from_static_property( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name1.replace("-", "_"), "topic_name_1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Join": [ + "_", + {"Fn::Split": ["-", "part1-part2-part3"]}, + ] + } + }, + } + } + } + template_2 = { + "Resources": { + "Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"DisplayName": name1}} + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_split_change_delimiter( + self, + snapshot, + capture_update_process, + ): + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": {"Fn::Join": ["_", {"Fn::Split": ["-", "a-b--c::d"]}]} + }, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": {"Fn::Join": ["_", {"Fn::Split": [":", "a-b--c::d"]}]} + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_split_change_source_string_only( + self, + snapshot, + capture_update_process, + ): + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": {"Fn::Join": ["_", {"Fn::Split": ["-", "a-b"]}]}}, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": {"Fn::Join": ["_", {"Fn::Split": ["-", "x-y-z"]}]} + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_split_with_ref_as_string_source( + self, + snapshot, + capture_update_process, + ): + param_name = "DelimiterParam" + template_1 = { + "Parameters": {param_name: {"Type": "String", "Default": "hello-world"}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Join": ["_", {"Fn::Split": ["-", {"Ref": param_name}]}] + } + }, + } + }, + } + template_2 = { + "Parameters": {param_name: {"Type": "String", "Default": "foo-bar-baz"}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Join": ["_", {"Fn::Split": ["-", {"Ref": param_name}]}] + } + }, + } + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: AWS incorrectly does not list the second and third topic as + # needing modifying, however it needs to + "describe-change-set-2-prop-values..Changes", + ] + ) + @markers.aws.validated + def test_fn_split_with_get_att( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name1.replace("-", "_"), "topic_name_1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + snapshot.add_transformer(RegexTransformer(name2.replace("-", "_"), "topic_name_2")) + + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": name1}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Join": [ + "_", + {"Fn::Split": ["-", {"Fn::GetAtt": ["Topic1", "DisplayName"]}]}, + ] + } + }, + }, + } + } + + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"DisplayName": name2}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": { + "Fn::Join": [ + "_", + {"Fn::Split": ["-", {"Fn::GetAtt": ["Topic1", "DisplayName"]}]}, + ] + } + }, + }, + } + } + + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_split.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_split.snapshot.json new file mode 100644 index 0000000000000..b31381319abae --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_split.snapshot.json @@ -0,0 +1,2455 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_add_to_static_property": { + "recorded-date": "02-06-2025, 11:19:05", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "part1_part2_part3" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "part1_part2_part3", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "part1_part2_part3" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "part1_part2_part3" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_remove_from_static_property": { + "recorded-date": "02-06-2025, 11:20:30", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "part1_part2_part3" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "part1_part2_part3" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "part1_part2_part3", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "part1_part2_part3" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "part1_part2_part3" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "part1_part2_part3" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_change_source_string_only": { + "recorded-date": "02-06-2025, 11:22:03", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "a_b" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "x_y_z" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "a_b" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "x_y_z", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "a_b", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "x_y_z" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "x_y_z" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "a_b" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "a_b" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "a_b" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_with_ref_as_string_source": { + "recorded-date": "02-06-2025, 11:23:28", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "hello_world" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "DelimiterParam", + "ParameterValue": "hello-world" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "DelimiterParam", + "ParameterValue": "hello-world" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "DelimiterParam", + "ParameterValue": "hello-world" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "foo_bar_baz" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "hello_world" + } + }, + "Details": [ + { + "CausingEntity": "DelimiterParam", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "foo_bar_baz", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "hello_world", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "foo_bar_baz", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "hello_world", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "DelimiterParam", + "ParameterValue": "foo-bar-baz" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "DelimiterParam", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "DelimiterParam", + "ParameterValue": "foo-bar-baz" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "DelimiterParam", + "ParameterValue": "foo-bar-baz" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "foo_bar_baz" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "foo_bar_baz" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "hello_world" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "hello_world" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "hello_world" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "DelimiterParam", + "ParameterValue": "foo-bar-baz" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_with_get_att": { + "recorded-date": "02-06-2025, 11:26:00", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "StatusReason": "[WARN] --include-property-values option can return incomplete ChangeSet data because: ChangeSet creation failed for resource [Topic2] because: Template error: every Fn::Join object requires two parameters, (1) a string delimiter and (2) a list of strings to be joined or a function that returns a list of strings (such as Fn::GetAZs) to be joined.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1.DisplayName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic_name_2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic_name_2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic_name_1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "topic_name_1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "topic_name_1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_change_delimiter": { + "recorded-date": "02-06-2025, 12:30:32", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "a_b__c::d" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "a-b--c__d" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "a_b__c::d" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "a-b--c__d", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "a_b__c::d", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "a-b--c__d" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "a-b--c__d" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "a_b__c::d" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:", + "ResourceProperties": { + "DisplayName": "a_b__c::d" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "a_b__c::d" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_split.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_split.validation.json new file mode 100644 index 0000000000000..a85de241f5b9d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_split.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_add_to_static_property": { + "last_validated_date": "2025-06-02T11:19:05+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_change_delimiter": { + "last_validated_date": "2025-06-02T12:30:32+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_change_source_string_only": { + "last_validated_date": "2025-06-02T11:22:03+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_remove_from_static_property": { + "last_validated_date": "2025-06-02T11:20:29+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_with_get_att": { + "last_validated_date": "2025-06-02T11:26:00+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_split.py::TestChangeSetFnSplit::test_fn_split_with_ref_as_string_source": { + "last_validated_date": "2025-06-02T11:23:28+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py b/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py new file mode 100644 index 0000000000000..82984d02da21e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py @@ -0,0 +1,355 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetFnSub: + @markers.aws.validated + def test_fn_sub_addition_string_pseudo( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Sub": "The stack name is ${AWS::StackName}"}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_update_string_pseudo( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Sub": "The stack name is ${AWS::StackName}"}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Sub": "The region name is ${AWS::Region}"}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_delete_string_pseudo( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::Sub": "The stack name is ${AWS::StackName}"}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-name-2"}, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_addition_parameter_literal( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name}", + {"var_name": "var_value"}, + ] + }, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_update_parameter_literal( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name_1}", + {"var_name_1": "var_value_1"}, + ] + }, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name_1}, ${var_name_2}", + {"var_name_1": "var_value_1", "var_name_2": "var_value_2"}, + ] + }, + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_addition_parameter( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Parameters": { + "ParameterDisplayName": {"Type": "String", "Default": "display-value-parameter"} + }, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": "Parameter interpolation: ${ParameterDisplayName}", + }, + }, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_delete_parameter_literal( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name_1}, ${var_name_2}", + {"var_name_1": "var_value_1", "var_name_2": "var_value_2"}, + ] + }, + }, + } + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name_1}", + { + "var_name_1": "var_value_1", + }, + ] + }, + }, + } + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_addition_parameter_ref( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Parameters": { + "ParameterDisplayName": {"Type": "String", "Default": "display-value-parameter"} + }, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name}", + {"var_name": {"Ref": "ParameterDisplayName"}}, + ] + }, + }, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_fn_sub_update_parameter_type( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-1"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name}", + {"var_name": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}}, + ] + }, + }, + }, + }, + } + template_2 = { + "Parameters": { + "ParameterDisplayName": {"Type": "String", "Default": "display-value-parameter"} + }, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": { + "Fn::Sub": [ + "Parameter interpolation: ${var_name}", + {"var_name": {"Ref": "ParameterDisplayName"}}, + ] + }, + }, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.snapshot.json new file mode 100644 index 0000000000000..d11042ed00882 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.snapshot.json @@ -0,0 +1,3620 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_string_pseudo": { + "recorded-date": "20-05-2025, 09:54:49", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "The stack name is ", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_string_pseudo": { + "recorded-date": "20-05-2025, 09:59:44", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "The region name is ", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "The region name is ", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "The stack name is ", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The region name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The region name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_delete_string_pseudo": { + "recorded-date": "20-05-2025, 11:29:16", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "The stack name is ", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-name-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "The stack name is ", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter_literal": { + "recorded-date": "20-05-2025, 11:54:12", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: var_value", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "Parameter interpolation: var_value", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_parameter_literal": { + "recorded-date": "20-05-2025, 12:01:36", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "Parameter interpolation: var_value_1, var_value_2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "Parameter interpolation: var_value_1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_delete_parameter_literal": { + "recorded-date": "20-05-2025, 12:05:00", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "Parameter interpolation: var_value_1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "Parameter interpolation: var_value_1, var_value_2", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: var_value_1, var_value_2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter_ref": { + "recorded-date": "20-05-2025, 15:08:40", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "CausingEntity": "ParameterDisplayName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "Parameter interpolation: display-value-parameter", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "Parameter interpolation: display-value-parameter", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "ParameterDisplayName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_parameter_type": { + "recorded-date": "20-05-2025, 15:10:16", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "Parameter interpolation: display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "Parameter interpolation: display-value-parameter", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "Parameter interpolation: display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "ParameterDisplayName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "Parameter interpolation: display-value-parameter", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "Parameter interpolation: display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "ParameterDisplayName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter": { + "recorded-date": "20-05-2025, 15:26:13", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "CausingEntity": "ParameterDisplayName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "Parameter interpolation: display-value-parameter", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "Parameter interpolation: display-value-parameter", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "ParameterDisplayName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "Parameter interpolation: display-value-parameter", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterDisplayName", + "ParameterValue": "display-value-parameter" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.validation.json new file mode 100644 index 0000000000000..cd0626345c30e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_fn_sub.validation.json @@ -0,0 +1,29 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter": { + "last_validated_date": "2025-05-20T15:26:12+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter_literal": { + "last_validated_date": "2025-05-20T11:54:12+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_parameter_ref": { + "last_validated_date": "2025-05-20T15:08:40+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_addition_string_pseudo": { + "last_validated_date": "2025-05-20T09:54:49+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_delete_parameter_literal": { + "last_validated_date": "2025-05-20T12:05:00+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_delete_string_pseudo": { + "last_validated_date": "2025-05-20T11:29:16+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_parameter_literal": { + "last_validated_date": "2025-05-20T12:01:36+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_parameter_type": { + "last_validated_date": "2025-05-20T15:10:15+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_fn_sub.py::TestChangeSetFnSub::test_fn_sub_update_string_pseudo": { + "last_validated_date": "2025-05-20T09:59:44+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_global_macros.py b/tests/aws/services/cloudformation/v2/test_change_set_global_macros.py new file mode 100644 index 0000000000000..ef31281f2096e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_global_macros.py @@ -0,0 +1,276 @@ +import json +import os + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.cloudformation.fixtures import _normalise_describe_change_set_output +from localstack.utils.functions import call_safe +from localstack.utils.strings import short_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetGlobalMacros: + @markers.aws.validated + def test_base_global_macro( + self, + aws_client, + cleanups, + snapshot, + deploy_cfn_template, + create_lambda_function, + capture_update_process, + ): + snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..Outputs..OutputValue", + replacement="output-value", + replace_reference=True, + ) + ) + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../templates/macros/format_template.py" + ) + macro_name = "SubstitutionMacro" + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name}, + ) + + template_1 = { + "Transform": "SubstitutionMacro", + "Parameters": {"Substitution": {"Type": "String", "Default": "SubstitutionDefault"}}, + "Resources": { + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Value": "{Substitution}", "Type": "String"}, + } + }, + "Outputs": {"ParameterName": {"Value": {"Ref": "Parameter"}}}, + } + template_2 = { + "Transform": "SubstitutionMacro", + "Parameters": {"Substitution": {"Type": "String", "Default": "SubstitutionDefault"}}, + "Resources": { + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Value": "{Substitution}", "Type": "String"}, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Value": "{Substitution}", "Type": "String"}, + }, + }, + "Outputs": { + "Parameter2Name": {"Value": {"Ref": "Parameter2"}}, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_update_after_macro_for_before_version_is_deleted( + self, + aws_client, + aws_client_no_retry, + cleanups, + snapshot, + deploy_cfn_template, + create_lambda_function, + capture_update_process, + ): + snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..Outputs..OutputValue", + replacement="output-value", + replace_reference=True, + ) + ) + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + macro_function_path = os.path.join( + os.path.dirname(__file__), "../../../templates/macros/format_template.py" + ) + + # Create the macro to be used in the first version of the template. + macro_name_first = f"SubstitutionMacroFirst-{short_uid()}" + snapshot.add_transformer(RegexTransformer(macro_name_first, "macro-name-first")) + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + macro_stack_first = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name_first}, + ) + + # Create the first version of the stack. + template_1 = { + "Transform": macro_name_first, + "Parameters": {"Substitution": {"Type": "String", "Default": "SubstitutionDefault"}}, + "Resources": { + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Value": "{Substitution}", "Type": "String"}, + } + }, + "Outputs": {"ParameterName": {"Value": {"Ref": "Parameter"}}}, + } + # Create + stack_name = f"stack-{short_uid()}" + change_set_name = f"cs-{short_uid()}" + change_set_details = aws_client_no_retry.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=json.dumps(template_1), + ChangeSetType="CREATE", + Parameters=list(), + ) + stack_id = change_set_details["StackId"] + change_set_id = change_set_details["Id"] + aws_client_no_retry.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=change_set_id + ) + cleanups.append( + lambda: call_safe( + aws_client_no_retry.cloudformation.delete_change_set, + kwargs=dict(ChangeSetName=change_set_id), + ) + ) + # Describe + describe_change_set_with_prop_values = ( + aws_client_no_retry.cloudformation.describe_change_set( + ChangeSetName=change_set_id, IncludePropertyValues=True + ) + ) + _normalise_describe_change_set_output(describe_change_set_with_prop_values) + snapshot.match("describe-change-set-1-prop-values", describe_change_set_with_prop_values) + # Execute. + execute_results = aws_client_no_retry.cloudformation.execute_change_set( + ChangeSetName=change_set_id + ) + snapshot.match("execute-change-set-1", execute_results) + aws_client_no_retry.cloudformation.get_waiter("stack_create_complete").wait( + StackName=stack_id + ) + # ensure stack deletion + cleanups.append( + lambda: call_safe( + aws_client_no_retry.cloudformation.delete_stack, kwargs=dict(StackName=stack_id) + ) + ) + describe = aws_client_no_retry.cloudformation.describe_stacks(StackName=stack_id)["Stacks"][ + 0 + ] + snapshot.match("post-create-1-describe", describe) + + # Delete the macro used in the v1 template. + macro_stack_first.destroy() + + # Create the macro to be used in the second version of the template. + macro_name_second = f"SubstitutionMacroSecond-{short_uid()}" + snapshot.add_transformer(RegexTransformer(macro_name_second, "macro-name-second")) + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=macro_function_path, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + macro_stack_second = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/macro_resource.yml" + ), + parameters={"FunctionName": func_name, "MacroName": macro_name_second}, + ) + + # Update + template_2 = { + "Transform": macro_name_second, + "Parameters": {"Substitution": {"Type": "String", "Default": "SubstitutionDefault"}}, + "Resources": { + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Value": "{Substitution}", "Type": "String"}, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": {"Value": "{Substitution}", "Type": "String"}, + }, + }, + "Outputs": { + "Parameter2Name": {"Value": {"Ref": "Parameter2"}}, + }, + } + change_set_details = aws_client_no_retry.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=json.dumps(template_2), + ChangeSetType="UPDATE", + Parameters=list(), + ) + stack_id = change_set_details["StackId"] + change_set_id = change_set_details["Id"] + aws_client_no_retry.cloudformation.get_waiter("change_set_create_complete").wait( + ChangeSetName=change_set_id + ) + # Describe + describe_change_set_with_prop_values = ( + aws_client_no_retry.cloudformation.describe_change_set( + ChangeSetName=change_set_id, IncludePropertyValues=True + ) + ) + _normalise_describe_change_set_output(describe_change_set_with_prop_values) + snapshot.match("describe-change-set-2-prop-values", describe_change_set_with_prop_values) + # Execute + execute_results = aws_client_no_retry.cloudformation.execute_change_set( + ChangeSetName=change_set_id + ) + snapshot.match("execute-change-set-2", execute_results) + aws_client_no_retry.cloudformation.get_waiter("stack_update_complete").wait( + StackName=stack_id + ) + + # delete stacks + macro_stack_second.destroy() + aws_client_no_retry.cloudformation.delete_stack(StackName=stack_id) + aws_client_no_retry.cloudformation.get_waiter("stack_delete_complete").wait( + StackName=stack_id + ) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_global_macros.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_global_macros.snapshot.json new file mode 100644 index 0000000000000..686fa970b25f9 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_global_macros.snapshot.json @@ -0,0 +1,468 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_global_macros.py::TestChangeSetGlobalMacros::test_base_global_macro": { + "recorded-date": "24-06-2025, 15:16:22", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "SubstitutionDefault", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "OutputKey": "ParameterName", + "OutputValue": "" + } + ], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "SubstitutionDefault", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter2", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter2", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "OutputKey": "Parameter2Name", + "OutputValue": "" + } + ], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "OutputKey": "Parameter2Name", + "OutputValue": "" + } + ], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Parameter": [ + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Parameter2": [ + { + "LogicalResourceId": "Parameter2", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter2", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_global_macros.py::TestChangeSetGlobalMacros::test_update_after_macro_for_before_version_is_deleted": { + "recorded-date": "26-06-2025, 11:59:06", + "recorded-content": { + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "SubstitutionDefault", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Outputs": [ + { + "OutputKey": "ParameterName", + "OutputValue": "" + } + ], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "SubstitutionDefault", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter2", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "Substitution", + "ParameterValue": "SubstitutionDefault" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_global_macros.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_global_macros.validation.json new file mode 100644 index 0000000000000..d88f126525af3 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_global_macros.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_global_macros.py::TestChangeSetGlobalMacros::test_base_global_macro": { + "last_validated_date": "2025-06-24T15:16:22+00:00", + "durations_in_seconds": { + "setup": 11.62, + "call": 34.97, + "teardown": 5.44, + "total": 52.03 + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_global_macros.py::TestChangeSetGlobalMacros::test_update_after_macro_for_before_version_is_deleted": { + "last_validated_date": "2025-06-26T11:59:07+00:00", + "durations_in_seconds": { + "setup": 11.46, + "call": 52.25, + "teardown": 1.78, + "total": 65.49 + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_mappings.py b/tests/aws/services/cloudformation/v2/test_change_set_mappings.py new file mode 100644 index 0000000000000..05fa11a2cce80 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_mappings.py @@ -0,0 +1,303 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetMappings: + @markers.aws.validated + def test_mapping_leaf_update( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-1"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + } + }, + } + template_2 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-2"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + } + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_mapping_key_update( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + template_1 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-1"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + } + }, + } + template_2 = { + "Mappings": {"SNSMapping": {"KeyNew": {"Val": "display-value-2"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "KeyNew", "Val"]}, + }, + } + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_mapping_addition_with_resource( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-1"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + } + }, + } + template_2 = { + "Mappings": { + "SNSMapping": {"Key1": {"Val": "display-value-1", "ValNew": "display-value-new"}} + }, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "ValNew"]}, + }, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_mapping_key_addition_with_resource( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-1"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + } + }, + } + template_2 = { + "Mappings": { + "SNSMapping": { + "Key1": { + "Val": "display-value-1", + }, + "Key2": { + "Val": "display-value-1", + "ValNew": "display-value-new", + }, + } + }, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key2", "Val"]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key2", "ValNew"]}, + }, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_mapping_deletion_with_resource_remap( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Mappings": { + "SNSMapping": {"Key1": {"Val": "display-value-1", "ValNew": "display-value-new"}} + }, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "ValNew"]}, + }, + }, + }, + } + template_2 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-1"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_mapping_key_deletion_with_resource_remap( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Mappings": { + "SNSMapping": { + "Key1": { + "Val": "display-value-1", + }, + "Key2": {"Val": "display-value-2"}, + } + }, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key2", "Val"]}, + }, + }, + }, + } + template_2 = { + "Mappings": {"SNSMapping": {"Key1": {"Val": "display-value-1"}}}, + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Fn::FindInMap": ["SNSMapping", "Key1", "Val"]}, + }, + }, + }, + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_mappings.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_mappings.snapshot.json new file mode 100644 index 0000000000000..58882da07da49 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_mappings.snapshot.json @@ -0,0 +1,2428 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_leaf_update": { + "recorded-date": "15-04-2025, 13:03:18", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_update": { + "recorded-date": "15-04-2025, 13:04:44", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_addition_with_resource": { + "recorded-date": "15-04-2025, 13:05:52", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_addition_with_resource": { + "recorded-date": "15-04-2025, 13:07:01", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_deletion_with_resource_remap": { + "recorded-date": "15-04-2025, 13:08:27", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-new", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-new", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_deletion_with_resource_remap": { + "recorded-date": "15-04-2025, 13:15:54", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-2", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_mappings.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_mappings.validation.json new file mode 100644 index 0000000000000..32d3348a4a4d6 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_mappings.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_addition_with_resource": { + "last_validated_date": "2025-04-15T13:05:52+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_deletion_with_resource_remap": { + "last_validated_date": "2025-04-15T13:08:27+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_addition_with_resource": { + "last_validated_date": "2025-04-15T13:07:01+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_deletion_with_resource_remap": { + "last_validated_date": "2025-04-15T13:15:54+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_key_update": { + "last_validated_date": "2025-04-15T13:04:43+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_mappings.py::TestChangeSetMappings::test_mapping_leaf_update": { + "last_validated_date": "2025-04-15T13:03:18+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_parameters.py b/tests/aws/services/cloudformation/v2/test_change_set_parameters.py new file mode 100644 index 0000000000000..ac04661b2ba8d --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_parameters.py @@ -0,0 +1,130 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetParameters: + @markers.aws.validated + def test_update_parameter_default_value( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Parameters": {"TopicName": {"Type": "String", "Default": name1}}, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": {"Ref": "TopicName"}}, + }, + }, + } + template_2 = { + "Parameters": {"TopicName": {"Type": "String", "Default": name2}}, + "Resources": template_1["Resources"], + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_update_parameter_with_added_default_value( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Parameters": {"TopicName": {"Type": "String"}}, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": {"Ref": "TopicName"}}, + }, + }, + } + template_2 = { + "Parameters": {"TopicName": {"Type": "String", "Default": name2}}, + "Resources": template_1["Resources"], + } + capture_update_process(snapshot, template_1, template_2, p1={"TopicName": name1}) + + @markers.aws.validated + def test_update_parameter_with_removed_default_value( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Parameters": {"TopicName": {"Type": "String", "Default": name1}}, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": {"Ref": "TopicName"}}, + }, + }, + } + template_2 = { + "Parameters": {"TopicName": {"Type": "String"}}, + "Resources": template_1["Resources"], + } + capture_update_process(snapshot, template_1, template_2, p2={"TopicName": name2}) + + @markers.aws.validated + def test_update_parameter_default_value_with_dynamic_overrides( + self, + snapshot, + capture_update_process, + ): + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Parameters": {"TopicName": {"Type": "String", "Default": "default-value-1"}}, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": {"Ref": "TopicName"}}, + }, + }, + } + template_2 = { + "Parameters": {"TopicName": {"Type": "String", "Default": "default-value-2"}}, + "Resources": template_1["Resources"], + } + capture_update_process( + snapshot, template_1, template_2, p1={"TopicName": name1}, p2={"TopicName": name2} + ) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_parameters.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_parameters.snapshot.json new file mode 100644 index 0000000000000..4d0c44f81f248 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_parameters.snapshot.json @@ -0,0 +1,1930 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_default_value": { + "recorded-date": "17-04-2025, 15:35:43", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + }, + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "EventId": "Foo-6d79defd-40ea-4793-bbcc-fbcf6dcb6eb4", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-804ab46c-bf2c-477a-9da2-629781f29597", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Requested update requires the creation of a new physical resource; hence creating one.", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_with_added_default_value": { + "recorded-date": "17-04-2025, 15:39:55", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + }, + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "EventId": "Foo-449f3796-5bc0-4441-a8e6-0b21e4a99416", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-a81a99de-0236-4beb-9be3-e32fa1cd7282", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Requested update requires the creation of a new physical resource; hence creating one.", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_with_removed_default_value": { + "recorded-date": "17-04-2025, 15:44:25", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "EventId": "Foo-26b9d263-5cf0-43f9-a362-8beefe1eccfb", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-b9d5ed41-3eba-434b-99f4-76d25a3a5252", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Requested update requires the creation of a new physical resource; hence creating one.", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_default_value_with_dynamic_overrides": { + "recorded-date": "17-04-2025, 15:46:46", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "EventId": "Foo-1c67b504-9b23-4cc3-8643-140d32564baa", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-13dc7d23-bc33-4e8f-a1bb-00c2675dbae1", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Requested update requires the creation of a new physical resource; hence creating one.", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_COMPLETE-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Foo-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-name-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_parameters.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_parameters.validation.json new file mode 100644 index 0000000000000..05e1a75cbd323 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_parameters.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_default_value": { + "last_validated_date": "2025-04-17T15:35:43+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_default_value_with_dynamic_overrides": { + "last_validated_date": "2025-04-17T15:46:46+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_with_added_default_value": { + "last_validated_date": "2025-04-17T15:39:55+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_parameters.py::TestChangeSetParameters::test_update_parameter_with_removed_default_value": { + "last_validated_date": "2025-04-17T15:44:24+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_ref.py b/tests/aws/services/cloudformation/v2/test_change_set_ref.py new file mode 100644 index 0000000000000..3785e861094f2 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_ref.py @@ -0,0 +1,346 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetRef: + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: preproc is not able to resolve references to deployed resources' physical id + "$..Changes..ResourceChange.AfterContext.Properties.DisplayName" + ] + ) + @markers.aws.validated + def test_resource_addition( + self, + snapshot, + capture_update_process, + ): + # Add a new resource (Topic2) that uses Ref to reference Topic1. + # For SNS topics, Ref typically returns the Topic ARN. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: preproc is not able to resolve references to deployed resources' physical id + "$..Changes..ResourceChange.AfterContext.Properties.DisplayName" + ] + ) + @markers.aws.validated + def test_direct_attribute_value_change( + self, + snapshot, + capture_update_process, + ): + # Modify the DisplayName of Topic1 from "display-value-1" to "display-value-2" + # while Topic2 references Topic1 using Ref. This verifies that the update process + # correctly reflects the change when using Ref-based dependency resolution. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": "display-value-1", + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": "display-value-2", + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: preproc is not able to resolve references to deployed resources' physical id + "$..Changes..ResourceChange.AfterContext.Properties.DisplayName" + ] + ) + @markers.aws.validated + def test_direct_attribute_value_change_in_ref_chain( + self, + snapshot, + capture_update_process, + ): + # Modify the DisplayName of Topic1 from "display-value-1" to "display-value-2" + # while ensuring that chained references via Ref update appropriately. + # Topic2 references Topic1 using Ref, and Topic3 references Topic2. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + name3 = f"topic-name-3-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + snapshot.add_transformer(RegexTransformer(name3, "topic-name-3")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name3, + "DisplayName": {"Ref": "Topic2"}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": "display-value-2", # Updated value triggers change along the chain + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + "Topic3": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name3, + "DisplayName": {"Ref": "Topic2"}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: preproc is not able to resolve references to deployed resources' physical id + "$..Changes..ResourceChange.AfterContext.Properties.DisplayName" + ] + ) + @markers.aws.validated + def test_direct_attribute_value_change_with_dependent_addition( + self, + snapshot, + capture_update_process, + ): + # Modify the DisplayName property of Topic1 while adding Topic2 that + # uses Ref to reference Topic1. + # Initially, only Topic1 exists with DisplayName "display-value-1". + # In the update, Topic1 is updated to "display-value-2" and Topic2 is added, + # referencing Topic1 via Ref. + name1 = f"topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-1"}, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": {"TopicName": name1, "DisplayName": "display-value-2"}, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: preproc is not able to resolve references to deployed resources' physical id + "$..Changes..ResourceChange.AfterContext.Properties.DisplayName", + # Reason: the preprocessor currently appears to mask the change to the resource as the + # physical id is equal to the logical id. Adding support for physical id resolution + # should address this limitation + "describe-change-set-2..Changes", + "describe-change-set-2-prop-values..Changes", + ] + ) + @markers.aws.validated + def test_immutable_property_update_causes_resource_replacement( + self, + snapshot, + capture_update_process, + ): + # Changing TopicName in Topic1 from an initial value to an updated value + # represents an immutable property update. This forces the replacement of Topic1. + # Topic2 references Topic1 using Ref. After replacement, Topic2's Ref resolution + # should pick up the new Topic1 attributes without error. + name1 = f"topic-name-1-{long_uid()}" + name1_update = f"updated-topic-name-1-{long_uid()}" + name2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name1_update, "updated-topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + template_1 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + "DisplayName": "value", + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + } + } + template_2 = { + "Resources": { + "Topic1": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1_update, + "DisplayName": "new_value", + }, + }, + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + "DisplayName": {"Ref": "Topic1"}, + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) + + @markers.aws.validated + def test_supported_pseudo_parameter( + self, + snapshot, + capture_update_process, + ): + topic_name_1 = f"topic-name-1-{long_uid()}" + snapshot.add_transformer(RegexTransformer(topic_name_1, "topic_name_1")) + topic_name_2 = f"topic-name-2-{long_uid()}" + snapshot.add_transformer(RegexTransformer(topic_name_2, "topic_name_2")) + snapshot.add_transformer(RegexTransformer("amazonaws.com", "url_suffix")) + snapshot.add_transformer(RegexTransformer("localhost.localstack.cloud", "url_suffix")) + template_1 = { + "Resources": { + "Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": topic_name_1}}, + } + } + template_2 = { + "Resources": { + "Topic2": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": topic_name_2, + "Tags": [ + {"Key": "Partition", "Value": {"Ref": "AWS::Partition"}}, + {"Key": "AccountId", "Value": {"Ref": "AWS::AccountId"}}, + {"Key": "Region", "Value": {"Ref": "AWS::Region"}}, + {"Key": "StackName", "Value": {"Ref": "AWS::StackName"}}, + {"Key": "StackId", "Value": {"Ref": "AWS::StackId"}}, + {"Key": "URLSuffix", "Value": {"Ref": "AWS::URLSuffix"}}, + ], + }, + }, + } + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_ref.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_ref.snapshot.json new file mode 100644 index 0000000000000..d6aac38ddd772 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_ref.snapshot.json @@ -0,0 +1,2954 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_resource_addition": { + "recorded-date": "08-04-2025, 15:22:38", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change": { + "recorded-date": "08-04-2025, 15:36:44", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change_in_ref_chain": { + "recorded-date": "08-04-2025, 15:45:54", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-3" + } + }, + "Details": [], + "LogicalResourceId": "Topic3", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic3", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic3": [ + { + "EventId": "Topic3-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-3", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-2", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic3-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-3", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-2", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic3-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic3", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-2", + "TopicName": "topic-name-3" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change_with_dependent_addition": { + "recorded-date": "08-04-2025, 15:51:05", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "display-value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "display-value-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-2", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "display-value-1", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_immutable_property_update_causes_resource_replacement": { + "recorded-date": "08-04-2025, 16:00:20", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "new_value", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "value", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "updated-topic-name-1", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "DisplayName": "{{changeSet:KNOWN_AFTER_APPLY}}", + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "arn::sns::111111111111:topic-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "Topic1", + "ChangeSource": "ResourceReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "arn::sns::111111111111:topic-name-1", + "Name": "DisplayName", + "Path": "/Properties/DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Topic1", + "ChangeSource": "ResourceReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "DisplayName", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "Replacement": "False", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-ae91de11-e3e2-4f87-bc72-efe640626413", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-e8338adc-674a-4af1-8430-15ddd3fd7765", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:updated-topic-name-1", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:updated-topic-name-1", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "new_value", + "TopicName": "updated-topic-name-1" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "Requested update requires the creation of a new physical resource; hence creating one.", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "value", + "TopicName": "topic-name-1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-UPDATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:updated-topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-UPDATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:updated-topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "DisplayName": "arn::sns::111111111111:topic-name-1", + "TopicName": "topic-name-2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_supported_pseudo_parameter": { + "recorded-date": "19-05-2025, 10:22:18", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic_name_1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic1", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "BeforeContext": { + "Properties": { + "TopicName": "topic_name_1" + } + }, + "Details": [], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_1", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Tags": [ + { + "Value": "aws", + "Key": "Partition" + }, + { + "Value": "111111111111", + "Key": "AccountId" + }, + { + "Value": "", + "Key": "Region" + }, + { + "Value": "", + "Key": "StackName" + }, + { + "Value": "arn::cloudformation::111111111111:stack//", + "Key": "StackId" + }, + { + "Value": "url_suffix", + "Key": "URLSuffix" + } + ], + "TopicName": "topic_name_2" + } + }, + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "Details": [], + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_1", + "PolicyAction": "Delete", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic2", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Topic1": [ + { + "EventId": "Topic1-388a0db5-23ea-4093-b725-5ad4b7b70281", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_1", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-10fea0c1-3d62-4fef-966e-6367dc235129", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_1", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_1", + "ResourceProperties": { + "TopicName": "topic_name_1" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_1", + "ResourceProperties": { + "TopicName": "topic_name_1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic1-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic1", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "topic_name_1" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic2": [ + { + "EventId": "Topic2-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_2", + "ResourceProperties": { + "Tags": [ + { + "Value": "aws", + "Key": "Partition" + }, + { + "Value": "111111111111", + "Key": "AccountId" + }, + { + "Value": "", + "Key": "Region" + }, + { + "Value": "", + "Key": "StackName" + }, + { + "Value": "arn::cloudformation::111111111111:stack//", + "Key": "StackId" + }, + { + "Value": "url_suffix", + "Key": "URLSuffix" + } + ], + "TopicName": "topic_name_2" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "arn::sns::111111111111:topic_name_2", + "ResourceProperties": { + "Tags": [ + { + "Value": "aws", + "Key": "Partition" + }, + { + "Value": "111111111111", + "Key": "AccountId" + }, + { + "Value": "", + "Key": "Region" + }, + { + "Value": "", + "Key": "StackName" + }, + { + "Value": "arn::cloudformation::111111111111:stack//", + "Key": "StackId" + }, + { + "Value": "url_suffix", + "Key": "URLSuffix" + } + ], + "TopicName": "topic_name_2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic2-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic2", + "PhysicalResourceId": "", + "ResourceProperties": { + "Tags": [ + { + "Value": "aws", + "Key": "Partition" + }, + { + "Value": "111111111111", + "Key": "AccountId" + }, + { + "Value": "", + "Key": "Region" + }, + { + "Value": "", + "Key": "StackName" + }, + { + "Value": "arn::cloudformation::111111111111:stack//", + "Key": "StackId" + }, + { + "Value": "url_suffix", + "Key": "URLSuffix" + } + ], + "TopicName": "topic_name_2" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_ref.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_ref.validation.json new file mode 100644 index 0000000000000..1667558f83add --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_ref.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change": { + "last_validated_date": "2025-04-08T15:36:44+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change_in_ref_chain": { + "last_validated_date": "2025-04-08T15:45:54+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_direct_attribute_value_change_with_dependent_addition": { + "last_validated_date": "2025-04-08T15:51:05+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_immutable_property_update_causes_resource_replacement": { + "last_validated_date": "2025-04-08T16:00:20+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_resource_addition": { + "last_validated_date": "2025-04-08T15:22:37+00:00" + }, + "tests/aws/services/cloudformation/v2/test_change_set_ref.py::TestChangeSetRef::test_supported_pseudo_parameter": { + "last_validated_date": "2025-05-19T10:22:18+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_values.py b/tests/aws/services/cloudformation/v2/test_change_set_values.py new file mode 100644 index 0000000000000..90084441dd4cb --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_values.py @@ -0,0 +1,68 @@ +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "per-resource-events..*", + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + ] +) +class TestChangeSetValues: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: on deletion the LogGroupName being deleted is known, + # however AWS is describing it as known-after-apply. + # more evidence on this masking approach is needed + # for implementing a generalisable solution. + # Nevertheless, the output being served by the engine + # now is not incorrect as it lists the correct name. + "describe-change-set-2-prop-values..Changes..ResourceChange.BeforeContext.Properties.LogGroupName" + ] + ) + def test_property_empy_list( + self, + snapshot, + capture_update_process, + ): + test_name = f"test-name-{long_uid()}" + snapshot.add_transformer(RegexTransformer(test_name, "test-name")) + template_1 = { + "Resources": { + "Role": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + # To ensure Tags is marked as "created" and not "unchanged", the use of GetAttr forces + # the access of a previously unavailable resource. + "LogGroupName": {"Fn::GetAtt": ["Topic", "TopicName"]}, + "Tags": [], + }, + }, + "Topic": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": test_name}}, + } + } + template_2 = { + "Resources": { + "Topic": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": test_name}}, + } + } + capture_update_process(snapshot, template_1, template_2) diff --git a/tests/aws/services/cloudformation/v2/test_change_set_values.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_set_values.snapshot.json new file mode 100644 index 0000000000000..c2b398a920fc4 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_values.snapshot.json @@ -0,0 +1,413 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_values.py::TestChangeSetValues::test_property_empy_list": { + "recorded-date": "23-05-2025, 17:56:06", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Tags": [], + "LogGroupName": "{{changeSet:KNOWN_AFTER_APPLY}}" + } + }, + "Details": [], + "LogicalResourceId": "Role", + "ResourceType": "AWS::Logs::LogGroup", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "test-name" + } + }, + "Details": [], + "LogicalResourceId": "Topic", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Role", + "ResourceType": "AWS::Logs::LogGroup", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Topic", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "BeforeContext": { + "Properties": { + "Tags": [], + "LogGroupName": "{{changeSet:KNOWN_AFTER_APPLY}}" + } + }, + "Details": [], + "LogicalResourceId": "Role", + "PhysicalResourceId": "test-name", + "PolicyAction": "Delete", + "ResourceType": "AWS::Logs::LogGroup", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Remove", + "Details": [], + "LogicalResourceId": "Role", + "PhysicalResourceId": "test-name", + "PolicyAction": "Delete", + "ResourceType": "AWS::Logs::LogGroup", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Role": [ + { + "EventId": "Role-75252f50-c30e-438a-a31f-671c38789f0e", + "LogicalResourceId": "Role", + "PhysicalResourceId": "test-name", + "ResourceStatus": "DELETE_COMPLETE", + "ResourceType": "AWS::Logs::LogGroup", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Role-b0bd92dc-5bcc-44e3-8628-8eebb1e8d16d", + "LogicalResourceId": "Role", + "PhysicalResourceId": "test-name", + "ResourceStatus": "DELETE_IN_PROGRESS", + "ResourceType": "AWS::Logs::LogGroup", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Role-CREATE_COMPLETE-date", + "LogicalResourceId": "Role", + "PhysicalResourceId": "test-name", + "ResourceProperties": { + "LogGroupName": "test-name", + "Tags": [] + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::Logs::LogGroup", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Role-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Role", + "PhysicalResourceId": "test-name", + "ResourceProperties": { + "LogGroupName": "test-name", + "Tags": [] + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::Logs::LogGroup", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Role-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Role", + "PhysicalResourceId": "", + "ResourceProperties": { + "LogGroupName": "test-name", + "Tags": [] + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::Logs::LogGroup", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "Topic": [ + { + "EventId": "Topic-CREATE_COMPLETE-date", + "LogicalResourceId": "Topic", + "PhysicalResourceId": "arn::sns::111111111111:test-name", + "ResourceProperties": { + "TopicName": "test-name" + }, + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic", + "PhysicalResourceId": "arn::sns::111111111111:test-name", + "ResourceProperties": { + "TopicName": "test-name" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "Resource creation Initiated", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "Topic-CREATE_IN_PROGRESS-date", + "LogicalResourceId": "Topic", + "PhysicalResourceId": "", + "ResourceProperties": { + "TopicName": "test-name" + }, + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ], + "": [ + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + }, + { + "EventId": "", + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceStatusReason": "User Initiated", + "ResourceType": "AWS::CloudFormation::Stack", + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Timestamp": "timestamp" + } + ] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_set_values.validation.json b/tests/aws/services/cloudformation/v2/test_change_set_values.validation.json new file mode 100644 index 0000000000000..1e1fbea183682 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_set_values.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_set_values.py::TestChangeSetValues::test_property_empy_list": { + "last_validated_date": "2025-05-23T17:56:06+00:00" + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_sets.py b/tests/aws/services/cloudformation/v2/test_change_sets.py new file mode 100644 index 0000000000000..20ef3e331d59e --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_sets.py @@ -0,0 +1,801 @@ +import copy +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.services.cloudformation.v2.utils import is_v2_engine +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +pytestmark = pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), + reason="Only targeting the new engine", +) + + +@pytest.mark.skipif( + condition=not is_v2_engine() and not is_aws_cloud(), reason="Requires the V2 engine" +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + "delete-describe..*", + # + # Before/After Context + "$..Capabilities", + "$..NotificationARNs", + "$..IncludeNestedStacks", + "$..Scope", + "$..Details", + "$..Parameters", + "$..Replacement", + "$..PolicyAction", + "$..PhysicalResourceId", + ] +) +class TestCaptureUpdateProcess: + @markers.aws.validated + def test_direct_update( + self, + snapshot, + capture_update_process, + ): + """ + Update a stack with a static change (i.e. in the text of the template). + + Conclusions: + - A static change in the template that's not invoking an intrinsic function + (`Ref`, `Fn::GetAtt` etc.) is resolved by the deployment engine synchronously + during the `create_change_set` invocation + """ + name1 = f"topic-1-{short_uid()}" + name2 = f"topic-2-{short_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-2")) + t1 = { + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + }, + }, + }, + } + t2 = { + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + }, + }, + }, + } + capture_update_process(snapshot, t1, t2) + + @markers.aws.validated + def test_dynamic_update( + self, + snapshot, + capture_update_process, + ): + """ + Update a stack with two resources: + - A is changed statically + - B refers to the changed value of A via an intrinsic function + + Conclusions: + - The value of B on creation is "known after apply" even though the resolved + property value is known statically + - The nature of the change to B is "known after apply" + - The CloudFormation engine does not resolve intrinsic function calls when determining the + nature of the update + """ + name1 = f"topic-1-{short_uid()}" + name2 = f"topic-2-{short_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-2")) + t1 = { + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name1, + }, + }, + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::GetAtt": ["Foo", "TopicName"], + }, + }, + }, + }, + } + t2 = { + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": name2, + }, + }, + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::GetAtt": ["Foo", "TopicName"], + }, + }, + }, + }, + } + capture_update_process(snapshot, t1, t2) + + @markers.aws.validated + def test_parameter_changes( + self, + snapshot, + capture_update_process, + ): + """ + Update a stack with two resources: + - A is changed via a template parameter + - B refers to the changed value of A via an intrinsic function + + Conclusions: + - The value of B on creation is "known after apply" even though the resolved + property value is known statically + - The nature of the change to B is "known after apply" + - The CloudFormation engine does not resolve intrinsic function calls when determining the + nature of the update + """ + name1 = f"topic-1-{short_uid()}" + name2 = f"topic-2-{short_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-2")) + t1 = { + "Parameters": { + "TopicName": { + "Type": "String", + }, + }, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": {"Ref": "TopicName"}, + }, + }, + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::GetAtt": ["Foo", "TopicName"], + }, + }, + }, + }, + } + capture_update_process(snapshot, t1, t1, p1={"TopicName": name1}, p2={"TopicName": name2}) + + @markers.aws.validated + def test_mappings_with_static_fields( + self, + snapshot, + capture_update_process, + ): + """ + Update a stack with two resources: + - A is changed via looking up a static value in a mapping + - B refers to the changed value of A via an intrinsic function + + Conclusions: + - On first deploy the contents of the map is resolved completely + - The nature of the change to B is "known after apply" + - The CloudFormation engine does not resolve intrinsic function calls when determining the + nature of the update + """ + name1 = f"topic-1-{short_uid()}" + name2 = f"topic-2-{short_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + t1 = { + "Mappings": { + "MyMap": { + "MyKey": {"key1": name1, "key2": name2}, + }, + }, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "MyMap", + "MyKey", + "key1", + ], + }, + }, + }, + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::GetAtt": ["Foo", "TopicName"], + }, + }, + }, + }, + } + t2 = { + "Mappings": { + "MyMap": { + "MyKey": { + "key1": name1, + "key2": name2, + }, + }, + }, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "MyMap", + "MyKey", + "key2", + ], + }, + }, + }, + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::GetAtt": ["Foo", "TopicName"], + }, + }, + }, + }, + } + capture_update_process(snapshot, t1, t2) + + @markers.aws.validated + def test_mappings_with_parameter_lookup( + self, + snapshot, + capture_update_process, + ): + """ + Update a stack with two resources: + - A is changed via looking up a static value in a mapping but the key comes from + a template parameter + - B refers to the changed value of A via an intrinsic function + + Conclusions: + - The same conclusions as `test_mappings_with_static_fields` + """ + name1 = f"topic-1-{short_uid()}" + name2 = f"topic-2-{short_uid()}" + snapshot.add_transformer(RegexTransformer(name1, "topic-name-1")) + snapshot.add_transformer(RegexTransformer(name2, "topic-name-2")) + t1 = { + "Parameters": { + "TopicName": { + "Type": "String", + }, + }, + "Mappings": { + "MyMap": { + "MyKey": {"key1": name1, "key2": name2}, + }, + }, + "Resources": { + "Foo": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Fn::FindInMap": [ + "MyMap", + "MyKey", + { + "Ref": "TopicName", + }, + ], + }, + }, + }, + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::GetAtt": ["Foo", "TopicName"], + }, + }, + }, + }, + } + capture_update_process(snapshot, t1, t1, p1={"TopicName": "key1"}, p2={"TopicName": "key2"}) + + @markers.aws.validated + def test_conditions( + self, + snapshot, + capture_update_process, + ): + """ + Toggle a resource from present to not present via a condition + + Conclusions: + - Adding the second resource creates an `Add` resource change + """ + t1 = { + "Parameters": { + "EnvironmentType": { + "Type": "String", + } + }, + "Conditions": { + "IsProduction": { + "Fn::Equals": [ + {"Ref": "EnvironmentType"}, + "prod", + ], + } + }, + "Resources": { + "Bucket": { + "Type": "AWS::S3::Bucket", + }, + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": "test", + }, + "Condition": "IsProduction", + }, + }, + } + + capture_update_process( + snapshot, t1, t1, p1={"EnvironmentType": "not-prod"}, p2={"EnvironmentType": "prod"} + ) + + @markers.aws.validated + @pytest.mark.skip( + "Unlike AWS CFN, the update graph understands the dependent resource does not " + "need modification also when the IncludePropertyValues flag is off." + # TODO: we may achieve the same limitation by pruning the resolution of traversals. + ) + def test_unrelated_changes_update_propagation( + self, + snapshot, + capture_update_process, + ): + """ + - Resource B depends on resource A which is updated, but the referenced parameter does not + change + + Conclusions: + - No update to resource B + """ + topic_name = f"MyTopic{short_uid()}" + snapshot.add_transformer(RegexTransformer(topic_name, "topic-name")) + t1 = { + "Resources": { + "Parameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": topic_name, + "Description": "original", + }, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Fn::GetAtt": ["Parameter1", "Value"]}, + }, + }, + }, + } + t2 = { + "Resources": { + "Parameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": topic_name, + "Description": "changed", + }, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Fn::GetAtt": ["Parameter1", "Value"]}, + }, + }, + }, + } + capture_update_process(snapshot, t1, t2) + + @markers.aws.validated + @pytest.mark.skip( + "Deployment now succeeds but our describer incorrectly does not assign a change for Parameter2" + ) + def test_unrelated_changes_requires_replacement( + self, + snapshot, + capture_update_process, + ): + """ + - Resource B depends on resource A which is updated, but the referenced parameter does not + change, however resource A requires replacement + + Conclusions: + - Resource B is updated + """ + parameter_name_1 = f"MyParameter{short_uid()}" + parameter_name_2 = f"MyParameter{short_uid()}" + snapshot.add_transformer(RegexTransformer(parameter_name_1, "parameter-1-name")) + snapshot.add_transformer(RegexTransformer(parameter_name_2, "parameter-2-name")) + t1 = { + "Resources": { + "Parameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": parameter_name_1, + "Type": "String", + "Value": "value", + }, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Fn::GetAtt": ["Parameter1", "Value"]}, + }, + }, + }, + } + t2 = { + "Resources": { + "Parameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": parameter_name_2, + "Type": "String", + "Value": "value", + }, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Fn::GetAtt": ["Parameter1", "Value"]}, + }, + }, + }, + } + capture_update_process(snapshot, t1, t2) + + @markers.aws.validated + @pytest.mark.parametrize( + "template", + [ + pytest.param( + { + "Parameters": { + "ParameterValue": { + "Type": "String", + }, + }, + "Resources": { + "Parameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Ref": "ParameterValue"}, + }, + } + }, + }, + id="change_dynamic", + ), + pytest.param( + { + "Parameters": { + "ParameterValue": { + "Type": "String", + }, + }, + "Resources": { + "Parameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": "param-name", + "Type": "String", + "Value": {"Ref": "ParameterValue"}, + }, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Fn::GetAtt": ["Parameter1", "Name"]}, + }, + }, + }, + }, + id="change_unrelated_property", + ), + pytest.param( + { + "Parameters": { + "ParameterValue": { + "Type": "String", + }, + }, + "Resources": { + "Parameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Ref": "ParameterValue"}, + }, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": {"Fn::GetAtt": ["Parameter1", "Type"]}, + }, + }, + }, + }, + id="change_unrelated_property_not_create_only", + ), + pytest.param( + { + "Parameters": { + "ParameterValue": { + "Type": "String", + "Default": "value-1", + "AllowedValues": ["value-1", "value-2"], + } + }, + "Conditions": { + "ShouldCreateParameter": { + "Fn::Equals": [{"Ref": "ParameterValue"}, "value-2"] + } + }, + "Resources": { + "SSMParameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": "first", + }, + }, + "SSMParameter2": { + "Type": "AWS::SSM::Parameter", + "Condition": "ShouldCreateParameter", + "Properties": { + "Type": "String", + "Value": "first", + }, + }, + }, + }, + id="change_parameter_for_condition_create_resource", + ), + ], + ) + def test_base_dynamic_parameter_scenarios( + self, snapshot, capture_update_process, template, request + ): + if request.node.callspec.id in { + "change_unrelated_property", + "change_unrelated_property_not_create_only", + }: + pytest.skip( + reason="AWS appears to incorrectly mark the dependent resource as needing update when describe " + "changeset is invoked without the inclusion of property values." + ) + capture_update_process( + snapshot, + template, + template, + {"ParameterValue": "value-1"}, + {"ParameterValue": "value-2"}, + ) + + @markers.aws.validated + def test_execute_with_ref(self, snapshot, aws_client, deploy_cfn_template): + name1 = f"param-1-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(name1, "")) + name2 = f"param-2-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(name2, "")) + value = "my-value" + param2_name = f"output-param-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(param2_name, "")) + + t1 = { + "Resources": { + "Parameter1": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": name1, + "Type": "String", + "Value": value, + }, + }, + "Parameter2": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": param2_name, + "Type": "String", + "Value": {"Ref": "Parameter1"}, + }, + }, + } + } + t2 = copy.deepcopy(t1) + t2["Resources"]["Parameter1"]["Properties"]["Name"] = name2 + + stack = deploy_cfn_template(template=json.dumps(t1)) + stack_id = stack.stack_id + + before_value = aws_client.ssm.get_parameter(Name=param2_name)["Parameter"]["Value"] + snapshot.match("before-value", before_value) + + deploy_cfn_template(stack_name=stack_id, template=json.dumps(t2), is_update=True) + + after_value = aws_client.ssm.get_parameter(Name=param2_name)["Parameter"]["Value"] + snapshot.match("after-value", after_value) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_1, template_2", + [ + ( + { + "Mappings": {"GenericMapping": {"EnvironmentA": {"ParameterValue": "value-1"}}}, + "Resources": { + "MySSMParameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::FindInMap": [ + "GenericMapping", + "EnvironmentA", + "ParameterValue", + ] + }, + }, + } + }, + }, + { + "Mappings": {"GenericMapping": {"EnvironmentA": {"ParameterValue": "value-2"}}}, + "Resources": { + "MySSMParameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Fn::FindInMap": [ + "GenericMapping", + "EnvironmentA", + "ParameterValue", + ] + }, + }, + } + }, + }, + ) + ], + ids=["update_string_referencing_resource"], + ) + def test_base_mapping_scenarios( + self, + snapshot, + capture_update_process, + template_1, + template_2, + ): + capture_update_process(snapshot, template_1, template_2) + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Capabilities", + "$..IncludeNestedStacks", + "$..NotificationARNs", + "$..Parameters", + "$..Changes..ResourceChange.Details", + "$..Changes..ResourceChange.Scope", + "$..Changes..ResourceChange.PhysicalResourceId", + "$..Changes..ResourceChange.Replacement", + ] +) +def test_single_resource_static_update(aws_client: ServiceLevelClientFactory, snapshot, cleanups): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + parameter_name = f"parameter-{short_uid()}" + value1 = "foo" + value2 = "bar" + + t1 = { + "Resources": { + "MyParameter": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Name": parameter_name, + "Type": "String", + "Value": value1, + }, + }, + }, + } + + stack_name = f"stack-{short_uid()}" + change_set_name = f"cs-{short_uid()}" + cs_result = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=json.dumps(t1), + ChangeSetType="CREATE", + ) + cs_id = cs_result["Id"] + stack_id = cs_result["StackId"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait(ChangeSetName=cs_id) + cleanups.append(lambda: aws_client.cloudformation.delete_stack(StackName=stack_id)) + + describe_result = aws_client.cloudformation.describe_change_set(ChangeSetName=cs_id) + snapshot.match("describe-1", describe_result) + + aws_client.cloudformation.execute_change_set(ChangeSetName=cs_id) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_id) + + parameter = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"] + snapshot.match("parameter-1", parameter) + + t2 = copy.deepcopy(t1) + t2["Resources"]["MyParameter"]["Properties"]["Value"] = value2 + + change_set_name = f"cs-{short_uid()}" + cs_result = aws_client.cloudformation.create_change_set( + StackName=stack_name, + ChangeSetName=change_set_name, + TemplateBody=json.dumps(t2), + ) + cs_id = cs_result["Id"] + aws_client.cloudformation.get_waiter("change_set_create_complete").wait(ChangeSetName=cs_id) + + describe_result = aws_client.cloudformation.describe_change_set(ChangeSetName=cs_id) + snapshot.match("describe-2", describe_result) + + aws_client.cloudformation.execute_change_set(ChangeSetName=cs_id) + aws_client.cloudformation.get_waiter("stack_update_complete").wait(StackName=stack_id) + + parameter = aws_client.ssm.get_parameter(Name=parameter_name)["Parameter"] + snapshot.match("parameter-2", parameter) diff --git a/tests/aws/services/cloudformation/v2/test_change_sets.snapshot.json b/tests/aws/services/cloudformation/v2/test_change_sets.snapshot.json new file mode 100644 index 0000000000000..66b1117810662 --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_sets.snapshot.json @@ -0,0 +1,3689 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_sets.py::test_single_resource_static_update": { + "recorded-date": "18-03-2025, 16:52:36", + "recorded-content": { + "describe-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "MyParameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "parameter-1": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "foo", + "Version": 1 + }, + "describe-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "MyParameter", + "PhysicalResourceId": "", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "parameter-2": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "datetime", + "Name": "", + "Type": "String", + "Value": "bar", + "Version": 2 + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_direct_update": { + "recorded-date": "18-06-2025, 19:04:55", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-2", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_dynamic_update": { + "recorded-date": "18-06-2025, 19:06:59", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "BeforeContext": { + "Properties": { + "Value": "topic-1", + "Type": "String" + } + }, + "Details": [ + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-OkuGHMW4ltfZ", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-OkuGHMW4ltfZ", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-2", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + } + ], + "Parameter": [ + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-OkuGHMW4ltfZ", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-OkuGHMW4ltfZ", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-OkuGHMW4ltfZ", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_parameter_changes": { + "recorded-date": "18-06-2025, 19:09:04", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "topic-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + }, + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "BeforeContext": { + "Properties": { + "Value": "topic-1", + "Type": "String" + } + }, + "Details": [ + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-lZ25tyPMdFIo", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-lZ25tyPMdFIo", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "topic-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-1", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-2", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + } + ], + "Parameter": [ + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-lZ25tyPMdFIo", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-lZ25tyPMdFIo", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-lZ25tyPMdFIo", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_mappings_with_static_fields": { + "recorded-date": "18-06-2025, 19:11:09", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "BeforeContext": { + "Properties": { + "Value": "topic-name-1", + "Type": "String" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + }, + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-QY7XaFoB4kQc", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-QY7XaFoB4kQc", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + } + ], + "Parameter": [ + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-QY7XaFoB4kQc", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-QY7XaFoB4kQc", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-QY7XaFoB4kQc", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_mappings_with_parameter_lookup": { + "recorded-date": "18-06-2025, 19:13:17", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "key1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Foo", + "ResourceType": "AWS::SNS::Topic", + "Scope": [] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "key1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "key1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "TopicName": "topic-name-2" + } + }, + "BeforeContext": { + "Properties": { + "TopicName": "topic-name-1" + } + }, + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "topic-name-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "TopicName", + "Path": "/Properties/TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "Value": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Type": "String" + } + }, + "BeforeContext": { + "Properties": { + "Value": "topic-name-1", + "Type": "String" + } + }, + "Details": [ + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "{{changeSet:KNOWN_AFTER_APPLY}}", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "topic-name-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-tGkdmdoGLN1m", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "key2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "TopicName", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "TopicName", + "RequiresRecreation": "Always" + } + } + ], + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "PolicyAction": "ReplaceAndDelete", + "Replacement": "True", + "ResourceType": "AWS::SNS::Topic", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + }, + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "Foo.TopicName", + "ChangeSource": "ResourceAttribute", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-tGkdmdoGLN1m", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "key2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "key2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "TopicName", + "ParameterValue": "key2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Foo": [ + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-1", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Foo", + "PhysicalResourceId": "arn::sns::111111111111:topic-name-2", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SNS::Topic", + "Timestamp": "timestamp" + } + ], + "Parameter": [ + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-tGkdmdoGLN1m", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-tGkdmdoGLN1m", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-tGkdmdoGLN1m", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_conditions": { + "recorded-date": "18-06-2025, 19:13:55", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": {} + }, + "Details": [], + "LogicalResourceId": "Bucket", + "ResourceType": "AWS::S3::Bucket", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "EnvironmentType", + "ParameterValue": "not-prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Bucket", + "ResourceType": "AWS::S3::Bucket", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "EnvironmentType", + "ParameterValue": "not-prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "EnvironmentType", + "ParameterValue": "not-prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "test", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "EnvironmentType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "EnvironmentType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "EnvironmentType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "EnvironmentType", + "ParameterValue": "prod" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Bucket": [ + { + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::S3::Bucket", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Bucket", + "PhysicalResourceId": "-bucket-rvkyycxytnfz", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::S3::Bucket", + "Timestamp": "timestamp" + } + ], + "Parameter": [ + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-ytEGT7JWBrkx", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_dynamic]": { + "recorded-date": "18-06-2025, 19:14:21", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "value-1", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "Parameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "Value": "value-2", + "Type": "String" + } + }, + "BeforeContext": { + "Properties": { + "Value": "value-1", + "Type": "String" + } + }, + "Details": [ + { + "CausingEntity": "ParameterValue", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "AfterValue": "value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "value-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "AfterValue": "value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "value-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-BNuHBis1ysn1", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "CausingEntity": "ParameterValue", + "ChangeSource": "ParameterReference", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + }, + { + "ChangeSource": "DirectModification", + "Evaluation": "Dynamic", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-BNuHBis1ysn1", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "Parameter": [ + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-BNuHBis1ysn1", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-BNuHBis1ysn1", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "Parameter", + "PhysicalResourceId": "CFN-Parameter-BNuHBis1ysn1", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property]": { + "recorded-date": "18-06-2025, 19:14:21", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_unrelated_property_not_create_only]": { + "recorded-date": "18-06-2025, 19:14:21", + "recorded-content": {} + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_parameter_for_condition_create_resource]": { + "recorded-date": "18-06-2025, 19:14:47", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "first", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "SSMParameter1", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SSMParameter1", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-1" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "first", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "SSMParameter2", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "SSMParameter2", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "Parameters": [ + { + "ParameterKey": "ParameterValue", + "ParameterValue": "value-2" + } + ], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "SSMParameter1": [ + { + "LogicalResourceId": "SSMParameter1", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "SSMParameter1", + "PhysicalResourceId": "CFN-SSMParameter1-YEPpTp1eTqmV", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "SSMParameter2": [ + { + "LogicalResourceId": "SSMParameter2", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "SSMParameter2", + "PhysicalResourceId": "CFN-SSMParameter2-Cy9JferYSQvx", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_execute_with_ref": { + "recorded-date": "18-06-2025, 19:15:20", + "recorded-content": { + "before-value": "", + "after-value": "" + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_mapping_scenarios[update_string_referencing_resource]": { + "recorded-date": "18-06-2025, 19:15:45", + "recorded-content": { + "create-change-set-1": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "AfterContext": { + "Properties": { + "Value": "value-1", + "Type": "String" + } + }, + "Details": [], + "LogicalResourceId": "MySSMParameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-1": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Add", + "Details": [], + "LogicalResourceId": "MySSMParameter", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-1-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "CREATE_COMPLETE", + "Tags": [] + }, + "create-change-set-2": { + "Id": "arn::cloudformation::111111111111:changeSet/", + "StackId": "arn::cloudformation::111111111111:stack//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2-prop-values": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "AfterContext": { + "Properties": { + "Value": "value-2", + "Type": "String" + } + }, + "BeforeContext": { + "Properties": { + "Value": "value-1", + "Type": "String" + } + }, + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "AfterValue": "value-2", + "Attribute": "Properties", + "AttributeChangeType": "Modify", + "BeforeValue": "value-1", + "Name": "Value", + "Path": "/Properties/Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "MySSMParameter", + "PhysicalResourceId": "CFN-MySSMParameter-yMAYpjhjWvEz", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-change-set-2": { + "Capabilities": [], + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "ChangeSetName": "", + "Changes": [ + { + "ResourceChange": { + "Action": "Modify", + "Details": [ + { + "ChangeSource": "DirectModification", + "Evaluation": "Static", + "Target": { + "Attribute": "Properties", + "Name": "Value", + "RequiresRecreation": "Never" + } + } + ], + "LogicalResourceId": "MySSMParameter", + "PhysicalResourceId": "CFN-MySSMParameter-yMAYpjhjWvEz", + "Replacement": "False", + "ResourceType": "AWS::SSM::Parameter", + "Scope": [ + "Properties" + ] + }, + "Type": "Resource" + } + ], + "CreationTime": "datetime", + "ExecutionStatus": "AVAILABLE", + "IncludeNestedStacks": false, + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "Status": "CREATE_COMPLETE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execute-change-set-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "post-create-2-describe": { + "ChangeSetId": "arn::cloudformation::111111111111:changeSet/", + "CreationTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "EnableTerminationProtection": false, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "UPDATE_COMPLETE", + "Tags": [] + }, + "delete-describe": { + "CreationTime": "datetime", + "DeletionTime": "datetime", + "DisableRollback": false, + "DriftInformation": { + "StackDriftStatus": "NOT_CHECKED" + }, + "LastUpdatedTime": "datetime", + "NotificationARNs": [], + "RollbackConfiguration": {}, + "StackId": "arn::cloudformation::111111111111:stack//", + "StackName": "", + "StackStatus": "DELETE_COMPLETE", + "Tags": [] + }, + "per-resource-events": { + "MySSMParameter": [ + { + "LogicalResourceId": "MySSMParameter", + "PhysicalResourceId": "", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "MySSMParameter", + "PhysicalResourceId": "CFN-MySSMParameter-yMAYpjhjWvEz", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "MySSMParameter", + "PhysicalResourceId": "CFN-MySSMParameter-yMAYpjhjWvEz", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "MySSMParameter", + "PhysicalResourceId": "CFN-MySSMParameter-yMAYpjhjWvEz", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::SSM::Parameter", + "Timestamp": "timestamp" + } + ], + "Stack": [ + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "REVIEW_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "CREATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_IN_PROGRESS", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + }, + { + "LogicalResourceId": "", + "PhysicalResourceId": "arn::cloudformation::111111111111:stack//", + "ResourceStatus": "UPDATE_COMPLETE", + "ResourceType": "AWS::CloudFormation::Stack", + "Timestamp": "timestamp" + } + ] + } + } + } +} diff --git a/tests/aws/services/cloudformation/v2/test_change_sets.validation.json b/tests/aws/services/cloudformation/v2/test_change_sets.validation.json new file mode 100644 index 0000000000000..f31398e53fe2f --- /dev/null +++ b/tests/aws/services/cloudformation/v2/test_change_sets.validation.json @@ -0,0 +1,95 @@ +{ + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_dynamic]": { + "last_validated_date": "2025-06-18T19:14:21+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 25.11, + "teardown": 0.14, + "total": 25.25 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_dynamic_parameter_scenarios[change_parameter_for_condition_create_resource]": { + "last_validated_date": "2025-06-18T19:14:47+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 26.23, + "teardown": 0.14, + "total": 26.37 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_base_mapping_scenarios[update_string_referencing_resource]": { + "last_validated_date": "2025-06-18T19:15:45+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 25.01, + "teardown": 0.15, + "total": 25.16 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_conditions": { + "last_validated_date": "2025-06-18T19:13:55+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 37.82, + "teardown": 0.16, + "total": 37.98 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_direct_update": { + "last_validated_date": "2025-06-18T19:04:55+00:00", + "durations_in_seconds": { + "setup": 0.26, + "call": 116.94, + "teardown": 0.15, + "total": 117.35 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_dynamic_update": { + "last_validated_date": "2025-06-18T19:06:59+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 124.05, + "teardown": 0.16, + "total": 124.21 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_execute_with_ref": { + "last_validated_date": "2025-06-18T19:15:20+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 26.07, + "teardown": 6.64, + "total": 32.71 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_mappings_with_parameter_lookup": { + "last_validated_date": "2025-06-18T19:13:18+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 128.68, + "teardown": 0.14, + "total": 128.82 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_mappings_with_static_fields": { + "last_validated_date": "2025-06-18T19:11:09+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 124.56, + "teardown": 0.14, + "total": 124.7 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::TestCaptureUpdateProcess::test_parameter_changes": { + "last_validated_date": "2025-06-18T19:09:04+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 124.46, + "teardown": 0.14, + "total": 124.6 + } + }, + "tests/aws/services/cloudformation/v2/test_change_sets.py::test_single_resource_static_update": { + "last_validated_date": "2025-03-18T16:52:35+00:00" + } +} diff --git a/tests/aws/services/cloudwatch/__init__.py b/tests/aws/services/cloudwatch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/cloudwatch/test_cloudwatch.py b/tests/aws/services/cloudwatch/test_cloudwatch.py new file mode 100644 index 0000000000000..3cb2fbb9b73a5 --- /dev/null +++ b/tests/aws/services/cloudwatch/test_cloudwatch.py @@ -0,0 +1,3053 @@ +import copy +import gzip +import json +import logging +import os +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING +from urllib.request import Request, urlopen + +import pytest +import requests +from botocore.exceptions import ClientError + +from localstack import config +from localstack.services.cloudwatch.provider import PATH_GET_RAW_METRICS +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import TransformerUtility +from localstack.utils.aws import arns +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.common import retry, short_uid, to_str +from localstack.utils.sync import poll_condition, wait_until + +if TYPE_CHECKING: + from mypy_boto3_logs import CloudWatchLogsClient +PUBLICATION_RETRIES = 5 + +LOG = logging.getLogger(__name__) + + +def is_old_provider(): + return os.environ.get("PROVIDER_OVERRIDE_CLOUDWATCH") == "v1" and not is_aws_cloud() + + +class TestCloudwatch: + @markers.aws.validated + def test_put_metric_data_values_list(self, snapshot, aws_client): + metric_name = "test-metric" + namespace = f"ns-{short_uid()}" + utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) + snapshot.add_transformer( + snapshot.transform.key_value("Timestamp", reference_replacement=False) + ) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Timestamp": utc_now, + "Values": [1.0, 10.0], + "Counts": [2, 4], + "Unit": "Count", + } + ], + ) + + def get_stats() -> int: + global stats + stats = aws_client.cloudwatch.get_metric_statistics( + Namespace=namespace, + MetricName=metric_name, + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + Period=60, + Statistics=["SampleCount", "Sum", "Maximum"], + ) + datapoints = stats["Datapoints"] + return len(datapoints) + + assert poll_condition(lambda: get_stats() >= 1, timeout=10) + snapshot.match("get_metric_statistics", stats) + + @markers.aws.only_localstack + def test_put_metric_data_gzip(self, aws_client, region_name): + metric_name = "test-metric" + namespace = "namespace" + data = ( + "Action=PutMetricData&MetricData.member.1." + "MetricName=%s&MetricData.member.1.Value=1&" + "Namespace=%s&Version=2010-08-01" % (metric_name, namespace) + ) + bytes_data = bytes(data, encoding="utf-8") + encoded_data = gzip.compress(bytes_data) + + headers = mock_aws_request_headers( + "cloudwatch", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + internal=True, + ) + authorization = mock_aws_request_headers( + "monitoring", aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, region_name=region_name + )["Authorization"] + + headers.update( + { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + "Content-Length": len(encoded_data), + "Content-Encoding": "GZIP", + "User-Agent": "aws-sdk-nodejs/2.819.0 linux/v12.18.2 callback", + "Authorization": authorization, + } + ) + url = config.external_service_url() + request = Request(url, encoded_data, headers, method="POST") + urlopen(request) + + rs = aws_client.cloudwatch.list_metrics(Namespace=namespace, MetricName=metric_name) + assert 1 == len(rs["Metrics"]) + assert namespace == rs["Metrics"][0]["Namespace"] + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_put_metric_data_validation(self, aws_client): + namespace = f"ns-{short_uid()}" + utc_now = datetime.utcnow().replace(tzinfo=timezone.utc) + + # test invalid due to having both Values and Value + with pytest.raises(Exception) as ex: + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "mymetric", + "Timestamp": utc_now, + "Value": 1.5, + "Values": [1.0, 10.0], + "Unit": "Count", + } + ], + ) + err = ex.value.response["Error"] + assert err["Code"] == "InvalidParameterCombination" + assert ( + err["Message"] + == "The parameters MetricData.member.1.Value and MetricData.member.1.Values are mutually exclusive and you have specified both." + ) + + # test invalid due to data can not have and values mismatched_counts + with pytest.raises(Exception) as ex: + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "mymetric", + "Timestamp": utc_now, + "Values": [1.0, 10.0], + "Counts": [2, 4, 5], + "Unit": "Count", + } + ], + ) + err = ex.value.response["Error"] + assert err["Code"] == "InvalidParameterValue" + assert ( + err["Message"] + == "The parameters MetricData.member.1.Values and MetricData.member.1.Counts must be of the same size." + ) + + # test invalid due to inserting both value and statistic values + with pytest.raises(Exception) as ex: + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "mymetric", + "Timestamp": utc_now, + "Value": 1.5, + "StatisticValues": { + "SampleCount": 10, + "Sum": 55, + "Minimum": 1, + "Maximum": 10, + }, + "Unit": "Count", + } + ], + ) + err = ex.value.response["Error"] + assert err["Code"] == "InvalidParameterCombination" + assert ( + err["Message"] + == "The parameters MetricData.member.1.Value and MetricData.member.1.StatisticValues are mutually exclusive and you have specified both." + ) + + # For some strange reason the AWS implementation allows this + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "mymetric", + "Timestamp": utc_now, + "Values": [1.0, 10.0], + "StatisticValues": { + "SampleCount": 10, + "Sum": 55, + "Minimum": 1, + "Maximum": 10, + }, + "Unit": "Count", + } + ], + ) + + @markers.aws.validated + def test_get_metric_data(self, aws_client): + namespace1 = f"test/{short_uid()}" + namespace2 = f"test/{short_uid()}" + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace1, MetricData=[dict(MetricName="someMetric", Value=23)] + ) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace1, MetricData=[dict(MetricName="someMetric", Value=18)] + ) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace2, MetricData=[dict(MetricName="ug", Value=23)] + ) + + now = datetime.utcnow().replace(microsecond=0) + start_time = now - timedelta(minutes=10) + end_time = now + timedelta(minutes=5) + + def _get_metric_data_sum(): + # filtering metric data with current time interval + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "some", + "MetricStat": { + "Metric": { + "Namespace": namespace1, + "MetricName": "someMetric", + }, + "Period": 60, + "Stat": "Sum", + }, + }, + { + "Id": "part", + "MetricStat": { + "Metric": {"Namespace": namespace2, "MetricName": "ug"}, + "Period": 60, + "Stat": "Sum", + }, + }, + ], + StartTime=start_time, + EndTime=end_time, + ) + assert 2 == len(response["MetricDataResults"]) + + for data_metric in response["MetricDataResults"]: + # TODO: there's an issue in the implementation of the service here. + # The returned timestamps should have the seconds set to 0 + if data_metric["Id"] == "some": + assert 41.0 == sum( + data_metric["Values"] + ) # might fall under different 60s "buckets" + if data_metric["Id"] == "part": + assert 23.0 == sum(data_metric["Values"]) + + # need to retry because the might most likely not be ingested immediately (it's fairly quick though) + retry(_get_metric_data_sum, retries=10, sleep_before=2) + + # filtering metric data with current time interval + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "some", + "MetricStat": { + "Metric": { + "Namespace": namespace1, + "MetricName": "someMetric", + }, + "Period": 60, + "Stat": "Sum", + }, + }, + { + "Id": "part", + "MetricStat": { + "Metric": {"Namespace": namespace2, "MetricName": "ug"}, + "Period": 60, + "Stat": "Sum", + }, + }, + ], + StartTime=datetime.utcnow() + timedelta(hours=1), + EndTime=datetime.utcnow() + timedelta(hours=2), + ) + + for data_metric in response["MetricDataResults"]: + if data_metric["Id"] == "some": + assert len(data_metric["Values"]) == 0 + if data_metric["Id"] == "part": + assert len(data_metric["Values"]) == 0 + + @markers.aws.validated + def test_get_metric_data_for_multiple_metrics(self, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + utc_now = datetime.now(tz=timezone.utc) + namespace = f"test/{short_uid()}" + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric1", + "Value": 50, + "Unit": "Seconds", + "Timestamp": utc_now, + } + ], + ) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric2", + "Value": 25, + "Unit": "Seconds", + "Timestamp": utc_now, + } + ], + ) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric3", + "StatisticValues": { + "SampleCount": 10, + "Sum": 55, + "Minimum": 1, + "Maximum": 10, + }, + "Unit": "Seconds", + "Timestamp": utc_now, + } + ], + ) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "result1", + "MetricStat": { + "Metric": {"Namespace": namespace, "MetricName": "metric1"}, + "Period": 60, + "Stat": "Sum", + }, + }, + { + "Id": "result2", + "MetricStat": { + "Metric": {"Namespace": namespace, "MetricName": "metric2"}, + "Period": 60, + "Stat": "Sum", + }, + }, + { + "Id": "result3", + "MetricStat": { + "Metric": {"Namespace": namespace, "MetricName": "metric3"}, + "Period": 60, + "Stat": "Sum", + }, + }, + ], + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + ) + + assert len(response["MetricDataResults"][0]["Values"]) > 0 + snapshot.match("get_metric_data", response) + + retry(assert_results, retries=10, sleep_before=1) + + @markers.aws.validated + @pytest.mark.parametrize( + "stat", + ["Sum", "SampleCount", "Minimum", "Maximum", "Average"], + ) + def test_get_metric_data_stats(self, aws_client, snapshot, stat): + utc_now = datetime.now(tz=timezone.utc) + namespace = f"test/{short_uid()}" + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric1", + "Value": 11, + "Unit": "Seconds", + "Timestamp": utc_now, + } + ], + ) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric1", + "StatisticValues": { + "SampleCount": 10, + "Sum": 55, + "Minimum": 1, + "Maximum": 10, + }, + "Unit": "Seconds", + "Timestamp": utc_now, + } + ], + ) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "result1", + "MetricStat": { + "Metric": {"Namespace": namespace, "MetricName": "metric1"}, + "Period": 60, + "Stat": stat, + }, + } + ], + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + ) + + assert len(response["MetricDataResults"][0]["Values"]) > 0 + snapshot.match("get_metric_data", response) + + sleep_before = 2 if is_aws_cloud() else 0 + retry(assert_results, retries=10, sleep_before=sleep_before) + + @markers.aws.validated + def test_get_metric_data_with_dimensions(self, aws_client, snapshot): + utc_now = datetime.now(tz=timezone.utc) + namespace = f"test/{short_uid()}" + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric1", + "Value": 11, + "Unit": "Seconds", + "Dimensions": [{"Name": "InstanceId", "Value": "one"}], + "Timestamp": utc_now, + } + ], + ) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric1", + "Value": 11, + "Unit": "Seconds", + "Dimensions": [{"Name": "InstanceId", "Value": "two"}], + "Timestamp": utc_now, + } + ], + ) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "metric1", + "StatisticValues": { + "SampleCount": 10, + "Sum": 55, + "Minimum": 1, + "Maximum": 10, + }, + "Unit": "Seconds", + "Timestamp": utc_now, + } + ], + ) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "result1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": "metric1", + "Dimensions": [ + {"Name": "InstanceId", "Value": "one"}, + ], + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + ) + + assert len(response["MetricDataResults"][0]["Values"]) > 0 + snapshot.match("get_metric_data", response) + + retries = 10 if is_aws_cloud() else 1 + sleep_before = 2 if is_aws_cloud() else 0 + retry(assert_results, retries=retries, sleep_before=sleep_before) + + @markers.aws.only_localstack + # this feature was a customer request and added with https://github.com/localstack/localstack/pull/3535 + def test_raw_metric_data(self, aws_client, region_name): + """ + tests internal endpoint at "/_aws/cloudwatch/metrics/raw" + """ + namespace1 = f"test/{short_uid()}" + aws_client.cloudwatch.put_metric_data( + Namespace=namespace1, MetricData=[dict(MetricName="someMetric", Value=23)] + ) + # the new v2 provider doesn't need the headers, will return results for all accounts/regions + headers = mock_aws_request_headers( + "cloudwatch", aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, region_name=region_name + ) + url = f"{config.external_service_url()}{PATH_GET_RAW_METRICS}" + result = requests.get(url, headers=headers) + assert 200 == result.status_code + result = json.loads(to_str(result.content)) + metrics = result["metrics"] + metrics_with_ns = [m for m in metrics if m.get("ns") == namespace1] + assert len(metrics_with_ns) == 1 + + @markers.aws.validated + def test_multiple_dimensions(self, aws_client): + namespaces = [ + f"ns1-{short_uid()}", + f"ns2-{short_uid()}", + f"ns3-{short_uid()}", + ] + num_dimensions = 2 + for ns in namespaces: + for i in range(3): + rs = aws_client.cloudwatch.put_metric_data( + Namespace=ns, + MetricData=[ + { + "MetricName": "someMetric", + "Value": 123, + "Dimensions": [ + { + "Name": "foo", + "Value": f"bar-{i % num_dimensions}", + } + ], + } + ], + ) + assert 200 == rs["ResponseMetadata"]["HTTPStatusCode"] + + def _check_metrics(): + rs = aws_client.cloudwatch.get_paginator("list_metrics").paginate().build_full_result() + metrics = [m for m in rs["Metrics"] if m.get("Namespace") in namespaces] + assert metrics + assert len(metrics) == len(namespaces) * num_dimensions + + retry(_check_metrics, sleep=2, retries=10, sleep_before=2) + + @markers.aws.validated + def test_describe_alarms_converts_date_format_correctly(self, aws_client, cleanups): + alarm_name = f"a-{short_uid()}:test" + metric_name = f"test-metric-{short_uid()}" + namespace = f"test-ns-{short_uid()}" + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + Namespace=namespace, + MetricName=metric_name, + EvaluationPeriods=1, + ComparisonOperator="GreaterThanThreshold", + Period=60, + Statistic="Sum", + Threshold=30, + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + result = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + alarm = result["MetricAlarms"][0] + assert isinstance(alarm["AlarmConfigurationUpdatedTimestamp"], datetime) + assert isinstance(alarm["StateUpdatedTimestamp"], datetime) + + @markers.aws.validated + def test_put_composite_alarm_describe_alarms(self, aws_client, cleanups): + composite_alarm_name = f"composite-a-{short_uid()}" + alarm_name = f"a-{short_uid()}" + metric_name = "something" + namespace = f"test-ns-{short_uid()}" + alarm_rule = f'ALARM("{alarm_name}")' + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + Namespace=namespace, + MetricName=metric_name, + EvaluationPeriods=1, + ComparisonOperator="GreaterThanThreshold", + Period=60, + Statistic="Sum", + Threshold=30, + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + aws_client.cloudwatch.put_composite_alarm( + AlarmName=composite_alarm_name, + AlarmRule=alarm_rule, + ) + cleanups.append( + lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[composite_alarm_name]) + ) + result = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + alarm = result["CompositeAlarms"][0] + assert alarm["AlarmName"] == composite_alarm_name + assert alarm["AlarmRule"] == alarm_rule + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + condition=is_old_provider, + paths=["$..MetricAlarms..AlarmDescription", "$..MetricAlarms..StateTransitionedTimestamp"], + ) + def test_store_tags(self, aws_client, cleanups, snapshot): + alarm_name = f"a-{short_uid()}" + metric_name = "store_tags" + namespace = f"test-ns-{short_uid()}" + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + put_metric_alarm = aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + Namespace=namespace, + MetricName=metric_name, + EvaluationPeriods=1, + ComparisonOperator="GreaterThanThreshold", + Period=60, + Statistic="Sum", + Threshold=30, + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + snapshot.match("put_metric_alarm", put_metric_alarm) + + describe_alarms = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("describe_alarms", describe_alarms) + alarm = describe_alarms["MetricAlarms"][0] + alarm_arn = alarm["AlarmArn"] + list_tags_for_resource = aws_client.cloudwatch.list_tags_for_resource(ResourceARN=alarm_arn) + snapshot.match("list_tags_for_resource_empty ", list_tags_for_resource) + + # add tags + tags = [{"Key": "tag1", "Value": "foo"}, {"Key": "tag2", "Value": "bar"}] + response = aws_client.cloudwatch.tag_resource(ResourceARN=alarm_arn, Tags=tags) + assert 200 == response["ResponseMetadata"]["HTTPStatusCode"] + list_tags_for_resource = aws_client.cloudwatch.list_tags_for_resource(ResourceARN=alarm_arn) + snapshot.match("list_tags_for_resource", list_tags_for_resource) + response = aws_client.cloudwatch.untag_resource(ResourceARN=alarm_arn, TagKeys=["tag1"]) + assert 200 == response["ResponseMetadata"]["HTTPStatusCode"] + list_tags_for_resource_post_untag = aws_client.cloudwatch.list_tags_for_resource( + ResourceARN=alarm_arn + ) + snapshot.match("list_tags_for_resource_post_untag", list_tags_for_resource_post_untag) + + @markers.aws.validated + def test_list_metrics_uniqueness(self, aws_client): + """ + This can take quite a while on AWS unfortunately + From the AWS docs: + After you create a metric, allow up to 15 minutes for the metric to appear. + To see metric statistics sooner, use GetMetricData or GetMetricStatistics. + """ + # create metrics with same namespace and dimensions but different metric names + namespace = f"test/{short_uid()}" + sleep_seconds = 10 if is_aws_cloud() else 1 + retries = 100 if is_aws_cloud() else 10 + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "CPUUtilization", + "Dimensions": [{"Name": "InstanceId", "Value": "i-46cdcd06a11207ab3"}], + "Value": 15, + } + ], + ) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "MemoryUtilization", + "Dimensions": [{"Name": "InstanceId", "Value": "i-46cdcd06a11207ab3"}], + "Value": 30, + } + ], + ) + + # duplicating existing metric + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "MemoryUtilization", + "Dimensions": [{"Name": "InstanceId", "Value": "i-46cdcd06a11207ab3"}], + "Value": 15, + } + ], + ) + + def _count_single_metrics(): + results = aws_client.cloudwatch.list_metrics(Namespace=namespace)["Metrics"] + assert len(results) == 2 + + # asserting only unique values are returned + retry(_count_single_metrics, retries=retries, sleep_before=sleep_seconds) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "DiskReadOps", + "StatisticValues": { + "Maximum": 1.0, + "Minimum": 1.0, + "SampleCount": 1.0, + "Sum": 1.0, + }, + "Dimensions": [{"Name": "InstanceId", "Value": "i-46cdcd06a11207ab3"}], + } + ], + ) + + def _count_aggregated_metrics(): + results = aws_client.cloudwatch.list_metrics(Namespace=namespace)["Metrics"] + assert len(results) == 3 + + retry(_count_aggregated_metrics, retries=retries, sleep_before=sleep_seconds) + + @markers.aws.validated + def test_list_metrics_with_filters(self, aws_client): + namespace = f"test/{short_uid()}" + sleep_seconds = 10 if is_aws_cloud() else 1 + retries = 100 if is_aws_cloud() else 10 + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "CPUUtilization", + "Value": 15, + } + ], + ) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "MemoryUtilization", + "Dimensions": [{"Name": "InstanceId", "Value": "one"}], + "Value": 30, + } + ], + ) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "DiskReadOps", + "Dimensions": [{"Name": "InstanceId", "Value": "two"}], + "Value": 15, + } + ], + ) + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": "DiskWriteOps", + "Dimensions": [{"Name": "InstanceId", "Value": "two"}], + "StatisticValues": { + "Maximum": 1.0, + "Minimum": 1.0, + "SampleCount": 1.0, + "Sum": 1.0, + }, + } + ], + ) + + def _count_all_metrics_in_namespace(): + results = aws_client.cloudwatch.list_metrics(Namespace=namespace)["Metrics"] + assert len(results) == 4 + + retry(_count_all_metrics_in_namespace, retries=retries, sleep_before=sleep_seconds) + + def _count_specific_metric_in_namespace(): + results = aws_client.cloudwatch.list_metrics( + Namespace=namespace, MetricName="CPUUtilization" + )["Metrics"] + assert len(results) == 1 + + retry(_count_specific_metric_in_namespace, retries=retries, sleep_before=sleep_seconds) + + def _count_metrics_in_namespace_with_dimension(): + results = aws_client.cloudwatch.list_metrics( + Namespace=namespace, Dimensions=[{"Name": "InstanceId"}] + )["Metrics"] + assert len(results) == 3 + + retry( + _count_metrics_in_namespace_with_dimension, retries=retries, sleep_before=sleep_seconds + ) + + def _count_metrics_in_namespace_with_dimension_value(): + results = aws_client.cloudwatch.list_metrics( + Namespace=namespace, Dimensions=[{"Name": "InstanceId", "Value": "two"}] + )["Metrics"] + assert len(results) == 2 + + retry( + _count_metrics_in_namespace_with_dimension_value, + retries=retries, + sleep_before=sleep_seconds, + ) + + @markers.aws.validated + def test_put_metric_alarm_escape_character(self, cleanups, aws_client): + aws_client.cloudwatch.put_metric_alarm( + AlarmName="cpu-mon", + AlarmDescription="<", + MetricName="CPUUtilization-2", + Namespace="AWS/EC2", + Statistic="Sum", + Period=600, + Threshold=1, + ComparisonOperator="GreaterThanThreshold", + EvaluationPeriods=1, + AlarmActions=["arn:aws:sns:us-east-1:111122223333:MyTopic"], + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=["cpu-mon"])) + + result = aws_client.cloudwatch.describe_alarms(AlarmNames=["cpu-mon"]) + assert result.get("MetricAlarms")[0]["AlarmDescription"] == "<" + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + condition=is_old_provider, paths=["$..MetricAlarms..StateTransitionedTimestamp"] + ) + def test_set_alarm(self, sns_create_topic, sqs_create_queue, aws_client, cleanups, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + # create topics for state 'ALARM' and 'OK' + topic_name_alarm = f"topic-{short_uid()}" + topic_name_ok = f"topic-{short_uid()}" + + sns_topic_alarm = sns_create_topic(Name=topic_name_alarm) + topic_arn_alarm = sns_topic_alarm["TopicArn"] + sns_topic_ok = sns_create_topic(Name=topic_name_ok) + topic_arn_ok = sns_topic_ok["TopicArn"] + snapshot.add_transformer(snapshot.transform.regex(topic_name_alarm, "")) + snapshot.add_transformer(snapshot.transform.regex(topic_arn_ok, "")) + + # create queues for 'ALARM' and 'OK' (will receive sns messages) + uid = short_uid() + queue_url_alarm = sqs_create_queue(QueueName=f"AlarmQueue-{uid}") + queue_url_ok = sqs_create_queue(QueueName=f"OKQueue-{uid}") + + arn_queue_alarm = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url_alarm, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + arn_queue_ok = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url_ok, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url_alarm, + Attributes={"Policy": get_sqs_policy(arn_queue_alarm, topic_arn_alarm)}, + ) + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url_ok, Attributes={"Policy": get_sqs_policy(arn_queue_ok, topic_arn_ok)} + ) + + alarm_name = "test-alarm" + alarm_description = "Test Alarm when CPU exceeds 50 percent" + + expected_trigger = { + "MetricName": "CPUUtilization-3", + "Namespace": "AWS/EC2", + "Unit": "Percent", + "Period": 300, + "EvaluationPeriods": 2, + "ComparisonOperator": "GreaterThanThreshold", + "Threshold": 50.0, + "TreatMissingData": "ignore", + "EvaluateLowSampleCountPercentile": "", + "Dimensions": [{"value": "i-0317828c84edbe100", "name": "InstanceId"}], + "StatisticType": "Statistic", + "Statistic": "AVERAGE", + } + # subscribe to SQS + subscription_alarm = aws_client.sns.subscribe( + TopicArn=topic_arn_alarm, Protocol="sqs", Endpoint=arn_queue_alarm + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe( + SubscriptionArn=subscription_alarm["SubscriptionArn"] + ) + ) + subscription_ok = aws_client.sns.subscribe( + TopicArn=topic_arn_ok, Protocol="sqs", Endpoint=arn_queue_ok + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription_ok["SubscriptionArn"]) + ) + + # create alarm with actions for "OK" and "ALARM" + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription=alarm_description, + MetricName=expected_trigger["MetricName"], + Namespace=expected_trigger["Namespace"], + ActionsEnabled=True, + Period=expected_trigger["Period"], + Threshold=expected_trigger["Threshold"], + Dimensions=[{"Name": "InstanceId", "Value": "i-0317828c84edbe100"}], + Unit=expected_trigger["Unit"], + Statistic=expected_trigger["Statistic"].capitalize(), + OKActions=[topic_arn_ok], + AlarmActions=[topic_arn_alarm], + EvaluationPeriods=expected_trigger["EvaluationPeriods"], + ComparisonOperator=expected_trigger["ComparisonOperator"], + TreatMissingData=expected_trigger["TreatMissingData"], + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + + # trigger alarm + state_value = "ALARM" + state_reason = "testing alarm" + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_name, StateReason=state_reason, StateValue=state_value + ) + + retry( + check_message, + retries=PUBLICATION_RETRIES, + sleep_before=1, + sqs_client=aws_client.sqs, + expected_queue_url=queue_url_alarm, + expected_topic_arn=topic_arn_alarm, + expected_new=state_value, + expected_reason=state_reason, + alarm_name=alarm_name, + alarm_description=alarm_description, + expected_trigger=expected_trigger, + ) + describe_alarm = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("triggered-alarm", describe_alarm) + # trigger OK + state_value = "OK" + state_reason = "resetting alarm" + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_name, StateReason=state_reason, StateValue=state_value + ) + + retry( + check_message, + retries=PUBLICATION_RETRIES, + sleep_before=1, + sqs_client=aws_client.sqs, + expected_queue_url=queue_url_ok, + expected_topic_arn=topic_arn_ok, + expected_new=state_value, + expected_reason=state_reason, + alarm_name=alarm_name, + alarm_description=alarm_description, + expected_trigger=expected_trigger, + ) + describe_alarm = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("reset-alarm", describe_alarm) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="New test for v2 provider") + def test_trigger_composite_alarm( + self, sns_create_topic, sqs_create_queue, aws_client, cleanups, snapshot + ): + # create topics for state 'ALARM' and 'OK' of the composite alarm + topic_name_alarm = f"topic-alarm-{short_uid()}" + topic_name_ok = f"topic-ok-{short_uid()}" + + sns_topic_alarm = sns_create_topic(Name=topic_name_alarm) + topic_arn_alarm = sns_topic_alarm["TopicArn"] + sns_topic_ok = sns_create_topic(Name=topic_name_ok) + topic_arn_ok = sns_topic_ok["TopicArn"] + + # TODO extract SNS-to-SQS into a fixture + # create queues for 'ALARM' and 'OK' of the composite alarm (will receive sns messages) + queue_url_alarm = sqs_create_queue(QueueName=f"AlarmQueue-{short_uid()}") + queue_url_ok = sqs_create_queue(QueueName=f"OKQueue-{short_uid()}") + + arn_queue_alarm = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url_alarm, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + arn_queue_ok = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url_ok, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url_alarm, + Attributes={"Policy": get_sqs_policy(arn_queue_alarm, topic_arn_alarm)}, + ) + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url_ok, Attributes={"Policy": get_sqs_policy(arn_queue_ok, topic_arn_ok)} + ) + + # subscribe to SQS + subscription_alarm = aws_client.sns.subscribe( + TopicArn=topic_arn_alarm, Protocol="sqs", Endpoint=arn_queue_alarm + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe( + SubscriptionArn=subscription_alarm["SubscriptionArn"] + ) + ) + subscription_ok = aws_client.sns.subscribe( + TopicArn=topic_arn_ok, Protocol="sqs", Endpoint=arn_queue_ok + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription_ok["SubscriptionArn"]) + ) + + # put metric alarms that would be parts of a composite one + # TODO extract put metric alarm and associated cleanups into a fixture + def _put_metric_alarm(alarm_name: str): + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + MetricName="CPUUtilization", + Namespace="AWS/EC2", + EvaluationPeriods=1, + Period=10, + Statistic="Sum", + ComparisonOperator="GreaterThanThreshold", + Threshold=30, + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + + alarm_1_name = f"simple-alarm-1-{short_uid()}" + alarm_2_name = f"simple-alarm-2-{short_uid()}" + + _put_metric_alarm(alarm_1_name) + _put_metric_alarm(alarm_2_name) + + alarm_1_arn = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_1_name])[ + "MetricAlarms" + ][0]["AlarmArn"] + alarm_2_arn = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_2_name])[ + "MetricAlarms" + ][0]["AlarmArn"] + + # put composite alarm that is triggered when either of metric alarms is triggered. + composite_alarm_name = f"composite-alarm-{short_uid()}" + composite_alarm_description = "composite alarm description" + + composite_alarm_rule = f'ALARM("{alarm_1_arn}") OR ALARM("{alarm_2_arn}")' + + put_composite_alarm_response = aws_client.cloudwatch.put_composite_alarm( + AlarmName=composite_alarm_name, + AlarmDescription=composite_alarm_description, + AlarmRule=composite_alarm_rule, + OKActions=[topic_arn_ok], + AlarmActions=[topic_arn_alarm], + ) + cleanups.append( + lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[composite_alarm_name]) + ) + snapshot.match("put-composite-alarm", put_composite_alarm_response) + + composite_alarms_list = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + composite_alarm = composite_alarms_list["CompositeAlarms"][0] + # TODO snapshot.match("describe-composite-alarm", composite_alarm) instead of asserts + # right now the lack of parity for initial composite alarm evaluation prevents from checking snapshot. + # Namely, for initial evaluation after alarm creation all child alarms + # should be included as triggering alarms + assert composite_alarm["AlarmName"] == composite_alarm_name + assert composite_alarm["AlarmRule"] == composite_alarm_rule + + # add necessary transformers for the snapshot + + # StateReason is a text with formatted dates inside it. For now stubbing it out fully because + # composite alarm reason can be checked via StateReasonData property which is simpler to check + # as its properties reference ARN and state of individual alarms without putting them all into a piece of text. + snapshot.add_transformer(snapshot.transform.key_value("StateReason")) + snapshot.add_transformer( + snapshot.transform.regex(composite_alarm_name, "") + ) + snapshot.add_transformer(snapshot.transform.regex(alarm_1_name, "")) + snapshot.add_transformer(snapshot.transform.regex(alarm_2_name, "")) + snapshot.add_transformer(snapshot.transform.regex(topic_name_alarm, "")) + snapshot.add_transformer(snapshot.transform.regex(topic_name_ok, "")) + + # helper methods to verify that correct message landed in correct SQS queue + # for ALARM and OK state changes respectively + + def _check_composite_alarm_alarm_message( + expected_triggering_child_arn, + expected_triggering_child_state, + ): + retry( + check_composite_alarm_message, + retries=PUBLICATION_RETRIES, + sleep_before=1, + sqs_client=aws_client.sqs, + queue_url=queue_url_alarm, + expected_topic_arn=topic_arn_alarm, + alarm_name=composite_alarm_name, + alarm_description=composite_alarm_description, + expected_state="ALARM", + expected_triggering_child_arn=expected_triggering_child_arn, + expected_triggering_child_state=expected_triggering_child_state, + ) + + def _check_composite_alarm_ok_message( + expected_triggering_child_arn, + expected_triggering_child_state, + ): + retry( + check_composite_alarm_message, + retries=PUBLICATION_RETRIES, + sleep_before=1, + sqs_client=aws_client.sqs, + queue_url=queue_url_ok, + expected_topic_arn=topic_arn_ok, + alarm_name=composite_alarm_name, + alarm_description=composite_alarm_description, + expected_state="OK", + expected_triggering_child_arn=expected_triggering_child_arn, + expected_triggering_child_state=expected_triggering_child_state, + ) + + # trigger alarm 1 - composite one should also go into ALARM state + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_1_name, StateValue="ALARM", StateReason="trigger alarm 1" + ) + + _check_composite_alarm_alarm_message( + expected_triggering_child_arn=alarm_1_arn, + expected_triggering_child_state="ALARM", + ) + + composite_alarms_list = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + composite_alarm_in_alarm_when_alarm_1_in_alarm = composite_alarms_list["CompositeAlarms"][0] + snapshot.match( + "composite-alarm-in-alarm-when-alarm-1-is-in-alarm", + composite_alarm_in_alarm_when_alarm_1_in_alarm, + ) + + # trigger OK for alarm 1 - composite one should also go back to OK + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_1_name, StateValue="OK", StateReason="resetting alarm 1" + ) + + _check_composite_alarm_ok_message( + expected_triggering_child_arn=alarm_1_arn, + expected_triggering_child_state="OK", + ) + + composite_alarms_list = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + composite_alarm_in_ok_when_alarm_1_back_to_ok = composite_alarms_list["CompositeAlarms"][0] + snapshot.match( + "composite-alarm-in-ok-when-alarm-1-is-back-to-ok", + composite_alarm_in_ok_when_alarm_1_back_to_ok, + ) + + # trigger alarm 2 - composite one should go again into ALARM state + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_2_name, StateValue="ALARM", StateReason="trigger alarm 2" + ) + + _check_composite_alarm_alarm_message( + expected_triggering_child_arn=alarm_2_arn, + expected_triggering_child_state="ALARM", + ) + + composite_alarms_list = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + composite_alarm_in_alarm_when_alarm_2_in_alarm = composite_alarms_list["CompositeAlarms"][0] + snapshot.match( + "composite-alarm-in-alarm-when-alarm-2-is-in-alarm", + composite_alarm_in_alarm_when_alarm_2_in_alarm, + ) + + # trigger OK for alarm 2 - composite one should also go back to OK + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_2_name, StateValue="OK", StateReason="resetting alarm 2" + ) + + _check_composite_alarm_ok_message( + expected_triggering_child_arn=alarm_2_arn, + expected_triggering_child_state="OK", + ) + + composite_alarms_list = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + composite_alarm_in_ok_when_alarm_2_back_to_ok = composite_alarms_list["CompositeAlarms"][0] + snapshot.match( + "composite-alarm-in-ok-when-alarm-2-is-back-to-ok", + composite_alarm_in_ok_when_alarm_2_back_to_ok, + ) + + # trigger alarm 2 while alarm 1 is triggered - composite one shouldn't change + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_1_name, StateValue="ALARM", StateReason="trigger alarm 1" + ) + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_2_name, StateValue="ALARM", StateReason="trigger alarm 2" + ) + + composite_alarms_list = aws_client.cloudwatch.describe_alarms( + AlarmNames=[composite_alarm_name], AlarmTypes=["CompositeAlarm"] + ) + composite_alarm_is_triggered_by_alarm_1_and_then_not_changed_by_alarm_2 = ( + composite_alarms_list["CompositeAlarms"][0] + ) + snapshot.match( + "composite-alarm-is-triggered-by-alarm-1-and-then-unchanged-by-alarm-2", + composite_alarm_is_triggered_by_alarm_1_and_then_not_changed_by_alarm_2, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..AlarmHistoryItems..HistoryData.newState.stateReason", + "$..AlarmHistoryItems..HistoryData.newState.stateReasonData.evaluatedDatapoints", + "$..NewStateReason", + "$..describe-alarms-for-metric..StateReason", # reason contains datapoint + date + "$..describe-alarms-for-metric..StateReasonData.evaluatedDatapoints", + ] + ) + @pytest.mark.skipif( + condition=is_old_provider(), reason="DescribeAlarmHistory is not implemented" + ) + def test_put_metric_alarm( + self, sns_create_topic, sqs_create_queue, snapshot, aws_client, cleanups + ): + sns_topic_alarm = sns_create_topic() + topic_arn_alarm = sns_topic_alarm["TopicArn"] + + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + snapshot.add_transformer( + snapshot.transform.regex(topic_arn_alarm.split(":")[-1], ""), priority=2 + ) + snapshot.add_transformer( + # regex to transform date-pattern, e.g. (03/01/24 11:36:00) + snapshot.transform.regex( + r"\(\d{2}\/\d{2}\/\d{2}\ \d{2}:\d{2}:\d{2}\)", "(MM/DD/YY HH:MM:SS)" + ) + ) + # as we add metrics, we use a unique namespace to ensure the test runs on AWS + namespace = f"test-nsp-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(namespace, "")) + + sqs_queue = sqs_create_queue() + arn_queue = aws_client.sqs.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + # required for AWS: + aws_client.sqs.set_queue_attributes( + QueueUrl=sqs_queue, + Attributes={"Policy": get_sqs_policy(arn_queue, topic_arn_alarm)}, + ) + metric_name = "my-metric1" + dimension = [{"Name": "InstanceId", "Value": "abc"}] + alarm_name = f"test-alarm-{short_uid()}" + + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn_alarm, Protocol="sqs", Endpoint=arn_queue + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) + ) + data = [ + { + "MetricName": metric_name, + "Dimensions": dimension, + "Value": 21, + "Timestamp": datetime.utcnow().replace(tzinfo=timezone.utc), + "Unit": "Seconds", + }, + { + "MetricName": metric_name, + "Dimensions": dimension, + "Value": 22, + "Timestamp": datetime.utcnow().replace(tzinfo=timezone.utc), + "Unit": "Seconds", + }, + ] + + # create alarm with action for "ALARM" + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription="testing cloudwatch alarms", + MetricName=metric_name, + Namespace=namespace, + ActionsEnabled=True, + Period=10, + Threshold=21, + Dimensions=dimension, + Unit="Seconds", + Statistic="Average", + OKActions=[topic_arn_alarm], + AlarmActions=[topic_arn_alarm], + EvaluationPeriods=1, + ComparisonOperator="GreaterThanThreshold", + TreatMissingData="ignore", + # notBreaching had some downsides, as depending on the alarm evaluation interval it would first go into OK + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("describe-alarm", response) + + aws_client.cloudwatch.put_metric_data(Namespace=namespace, MetricData=data) + retry( + _sqs_messages_snapshot, + retries=60, + sleep=3 if is_aws_cloud() else 1, + sleep_before=5 if is_aws_cloud() else 0, + expected_state="ALARM", + sqs_client=aws_client.sqs, + sqs_queue=sqs_queue, + snapshot=snapshot, + identifier="alarm-triggered", + ) + + # describe alarm history + history = aws_client.cloudwatch.describe_alarm_history( + AlarmName=alarm_name, HistoryItemType="StateUpdate" + ) + snapshot.match("describe-alarm-history", history) + + # describe alarms for metric + alarms = aws_client.cloudwatch.describe_alarms_for_metric( + MetricName=metric_name, + Namespace=namespace, + Dimensions=dimension, + Statistic="Average", + ) + snapshot.match("describe-alarms-for-metric", alarms) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + condition=is_old_provider, + paths=[ + "$..MetricAlarms..StateTransitionedTimestamp", + ], + ) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..MetricAlarms..StateReasonData.evaluatedDatapoints", + "$..MetricAlarms..StateReasonData.startDate", + ] + ) + def test_breaching_alarm_actions( + self, sns_create_topic, sqs_create_queue, snapshot, aws_client, cleanups + ): + sns_topic_alarm = sns_create_topic() + topic_arn_alarm = sns_topic_alarm["TopicArn"] + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + snapshot.add_transformer( + snapshot.transform.regex(topic_arn_alarm.split(":")[-1], ""), priority=2 + ) + + sqs_queue = sqs_create_queue() + arn_queue = aws_client.sqs.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + # required for AWS: + aws_client.sqs.set_queue_attributes( + QueueUrl=sqs_queue, + Attributes={"Policy": get_sqs_policy(arn_queue, topic_arn_alarm)}, + ) + metric_name = "my-metric101" + dimension = [{"Name": "InstanceId", "Value": "abc"}] + namespace = "test/breaching-alarm" + alarm_name = f"test-alarm-{short_uid()}" + + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn_alarm, Protocol="sqs", Endpoint=arn_queue + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) + ) + + snapshot.match("cloudwatch_sns_subscription", subscription) + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription="testing cloudwatch alarms", + MetricName=metric_name, + Namespace=namespace, + Period=10, + Threshold=2, + Dimensions=dimension, + Unit="Seconds", + Statistic="Average", + OKActions=[topic_arn_alarm], + AlarmActions=[topic_arn_alarm], + EvaluationPeriods=2, + ComparisonOperator="GreaterThanThreshold", + TreatMissingData="breaching", + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + assert response["MetricAlarms"][0]["ActionsEnabled"] + + retry( + _sqs_messages_snapshot, + retries=80, + sleep=3.0, + sleep_before=5, + expected_state="ALARM", + sqs_client=aws_client.sqs, + sqs_queue=sqs_queue, + snapshot=snapshot, + identifier="alarm-1", + ) + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("alarm-1-describe", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + condition=is_old_provider, paths=["$..MetricAlarms..StateTransitionedTimestamp"] + ) + def test_enable_disable_alarm_actions( + self, sns_create_topic, sqs_create_queue, snapshot, aws_client, cleanups + ): + sns_topic_alarm = sns_create_topic() + topic_arn_alarm = sns_topic_alarm["TopicArn"] + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + snapshot.add_transformer( + snapshot.transform.regex(topic_arn_alarm.split(":")[-1], ""), priority=2 + ) + + sqs_queue = sqs_create_queue() + arn_queue = aws_client.sqs.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + # required for AWS: + aws_client.sqs.set_queue_attributes( + QueueUrl=sqs_queue, + Attributes={"Policy": get_sqs_policy(arn_queue, topic_arn_alarm)}, + ) + metric_name = "my-metric101" + dimension = [{"Name": "InstanceId", "Value": "abc"}] + namespace = f"test/enable-{short_uid()}" + alarm_name = f"test-alarm-{short_uid()}" + + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn_alarm, Protocol="sqs", Endpoint=arn_queue + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) + ) + snapshot.match("cloudwatch_sns_subscription", subscription) + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription="testing cloudwatch alarms", + MetricName=metric_name, + Namespace=namespace, + Period=10, + Threshold=2, + Dimensions=dimension, + Unit="Seconds", + Statistic="Average", + OKActions=[topic_arn_alarm], + AlarmActions=[topic_arn_alarm], + EvaluationPeriods=2, + ComparisonOperator="GreaterThanThreshold", + TreatMissingData="ignore", + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + assert response["MetricAlarms"][0]["ActionsEnabled"] + snapshot.match("describe_alarm", response) + + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_name, StateValue="ALARM", StateReason="testing alarm" + ) + retry( + _sqs_messages_snapshot, + retries=80, + sleep=3.0, + sleep_before=5, + expected_state="ALARM", + sqs_client=aws_client.sqs, + sqs_queue=sqs_queue, + snapshot=snapshot, + identifier="alarm-state", + ) + describe_alarm = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("alarm-state-describe", describe_alarm) + + # disable alarm action + aws_client.cloudwatch.disable_alarm_actions(AlarmNames=[alarm_name]) + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_name, StateValue="OK", StateReason="testing OK state" + ) + + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("describe_alarm_disabled", response) + assert response["MetricAlarms"][0]["StateValue"] == "OK" + assert not response["MetricAlarms"][0]["ActionsEnabled"] + retry( + _check_alarm_triggered, + retries=80, + sleep=3.0, + sleep_before=5, + expected_state="OK", + alarm_name=alarm_name, + cloudwatch_client=aws_client.cloudwatch, + snapshot=snapshot, + identifier="ok-state-action-disabled", + ) + + # enable alarm action + aws_client.cloudwatch.enable_alarm_actions(AlarmNames=[alarm_name]) + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("describe_alarm_enabled", response) + assert response["MetricAlarms"][0]["ActionsEnabled"] + + @markers.aws.validated + def test_aws_sqs_metrics_created(self, sqs_create_queue, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + sqs_url = sqs_create_queue() + sqs_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=sqs_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + queue_name = arns.sqs_queue_name(sqs_arn) + # this should trigger the metric "NumberOfEmptyReceives" + aws_client.sqs.receive_message(QueueUrl=sqs_url) + + aws_client.sqs.send_message(QueueUrl=sqs_url, MessageBody="Hello") + dimensions = [{"Name": "QueueName", "Value": queue_name}] + + metric_default = { + "MetricStat": { + "Metric": { + "Namespace": "AWS/SQS", + "Dimensions": dimensions, + }, + "Period": 60, + "Stat": "Sum", + }, + } + sent = {"Id": "sent"} + sent.update(copy.deepcopy(metric_default)) + sent["MetricStat"]["Metric"]["MetricName"] = "NumberOfMessagesSent" + + sent_size = {"Id": "sent_size"} + sent_size.update(copy.deepcopy(metric_default)) + sent_size["MetricStat"]["Metric"]["MetricName"] = "SentMessageSize" + + empty = {"Id": "empty_receives"} + empty.update(copy.deepcopy(metric_default)) + empty["MetricStat"]["Metric"]["MetricName"] = "NumberOfEmptyReceives" + + def contains_sent_messages_metrics() -> int: + res = aws_client.cloudwatch.list_metrics(Dimensions=dimensions) + metrics = [metric["MetricName"] for metric in res["Metrics"]] + if all( + m in metrics + for m in ["NumberOfMessagesSent", "SentMessageSize", "NumberOfEmptyReceives"] + ): + res = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[sent, sent_size, empty], + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + ) + # add check for values, because AWS is sometimes a bit slower to fill those values up... + if ( + res["MetricDataResults"][0]["Values"] + and res["MetricDataResults"][1]["Values"] + and res["MetricDataResults"][2]["Values"] + ): + return True + return False + + assert poll_condition(lambda: contains_sent_messages_metrics(), interval=1, timeout=120) + + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[sent, sent_size, empty], + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + ) + + snapshot.match("get_metric_data", response) + + # receive + delete message + sqs_messages = aws_client.sqs.receive_message(QueueUrl=sqs_url)["Messages"] + assert len(sqs_messages) == 1 + receipt_handle = sqs_messages[0]["ReceiptHandle"] + aws_client.sqs.delete_message(QueueUrl=sqs_url, ReceiptHandle=receipt_handle) + + msg_received = {"Id": "num_msg_received"} + msg_received.update(copy.deepcopy(metric_default)) + msg_received["MetricStat"]["Metric"]["MetricName"] = "NumberOfMessagesReceived" + + msg_deleted = {"Id": "num_msg_deleted"} + msg_deleted.update(copy.deepcopy(metric_default)) + msg_deleted["MetricStat"]["Metric"]["MetricName"] = "NumberOfMessagesDeleted" + + def contains_receive_delete_metrics() -> int: + res = aws_client.cloudwatch.list_metrics(Dimensions=dimensions) + metrics = [metric["MetricName"] for metric in res["Metrics"]] + if all(m in metrics for m in ["NumberOfMessagesReceived", "NumberOfMessagesDeleted"]): + res = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[msg_received, msg_deleted], + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + ) + # add check for values, because AWS is sometimes a bit slower to fill those values up... + if res["MetricDataResults"][0]["Values"] and res["MetricDataResults"][1]["Values"]: + return True + return False + + assert poll_condition(lambda: contains_receive_delete_metrics(), interval=1, timeout=120) + + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[msg_received, msg_deleted], + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + ) + + snapshot.match("get_metric_data_2", response) + + @markers.aws.validated + @pytest.mark.skipif(condition=is_old_provider(), reason="Old provider is not raising exception") + def test_invalid_dashboard_name(self, aws_client, region_name, snapshot): + dashboard_name = f"test-{short_uid()}:invalid" + dashboard_body = { + "widgets": [ + { + "type": "metric", + "x": 0, + "y": 0, + "width": 6, + "height": 6, + "properties": { + "metrics": [["AWS/EC2", "CPUUtilization", "InstanceId", "i-12345678"]], + "region": region_name, + "view": "timeSeries", + "stacked": False, + }, + } + ] + } + + with pytest.raises(Exception) as ex: + aws_client.cloudwatch.put_dashboard( + DashboardName=dashboard_name, DashboardBody=json.dumps(dashboard_body) + ) + + snapshot.match("error-invalid-dashboardname", ex.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + condition=is_old_provider, + paths=[ + "$..DashboardArn", # ARN has a typo in moto + ], + ) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DashboardEntries..Size", # need to be skipped because size changes if the region name length is longer + ] + ) + def test_dashboard_lifecycle(self, aws_client, region_name, snapshot): + dashboard_name = f"test-{short_uid()}" + dashboard_body = { + "widgets": [ + { + "type": "metric", + "x": 0, + "y": 0, + "width": 6, + "height": 6, + "properties": { + "metrics": [["AWS/EC2", "CPUUtilization", "InstanceId", "i-12345678"]], + "region": region_name, + "view": "timeSeries", + "stacked": False, + }, + } + ] + } + aws_client.cloudwatch.put_dashboard( + DashboardName=dashboard_name, DashboardBody=json.dumps(dashboard_body) + ) + response = aws_client.cloudwatch.get_dashboard(DashboardName=dashboard_name) + snapshot.add_transformer(snapshot.transform.key_value("DashboardName")) + snapshot.match("get_dashboard", response) + + dashboards_list = aws_client.cloudwatch.list_dashboards() + snapshot.match("list_dashboards", dashboards_list) + + # assert prefix filtering working + dashboards_list = aws_client.cloudwatch.list_dashboards(DashboardNamePrefix="no-valid") + snapshot.match("list_dashboards_prefix_empty", dashboards_list) + dashboards_list = aws_client.cloudwatch.list_dashboards(DashboardNamePrefix="test") + snapshot.match("list_dashboards_prefix", dashboards_list) + + aws_client.cloudwatch.delete_dashboards(DashboardNames=[dashboard_name]) + dashboards_list = aws_client.cloudwatch.list_dashboards() + snapshot.match("list_dashboards_empty", dashboards_list) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Operations not supported") + def test_create_metric_stream( + self, + aws_client, + firehose_create_delivery_stream, + s3_create_bucket, + create_role_with_policy, + snapshot, + ): + bucket_name = f"test-bucket-{short_uid()}" + s3_create_bucket(Bucket=bucket_name) + + _, subscription_role_arn = create_role_with_policy( + "Allow", + "s3:*", + json.dumps( + { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": {"Service": "firehose.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + } + ), + "*", + ) + + if is_aws_cloud(): + time.sleep(15) + + stream_name = f"MyStream-{short_uid()}" + stream_arn = firehose_create_delivery_stream( + DeliveryStreamName=stream_name, + DeliveryStreamType="DirectPut", + S3DestinationConfiguration={ + "RoleARN": subscription_role_arn, + "BucketARN": f"arn:aws:s3:::{bucket_name}", + "BufferingHints": {"SizeInMBs": 1, "IntervalInSeconds": 60}, + }, + )["DeliveryStreamARN"] + + _, role_arn = create_role_with_policy( + "Allow", + "firehose:*", + json.dumps( + { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": {"Service": "cloudwatch.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + } + ), + stream_arn, + ) + + if is_aws_cloud(): + time.sleep(15) + + metric_stream_name = f"MyMetricStream-{short_uid()}" + response_create = aws_client.cloudwatch.put_metric_stream( + Name=metric_stream_name, + FirehoseArn=stream_arn, + RoleArn=role_arn, + OutputFormat="json", + ) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + snapshot.add_transformer(snapshot.transform.key_value("FirehoseArn")) + snapshot.add_transformer(snapshot.transform.key_value("RoleArn")) + + snapshot.match("create_metric_stream", response_create) + + get_response = aws_client.cloudwatch.get_metric_stream(Name=metric_stream_name) + snapshot.match("get_metric_stream", get_response) + + response_list = aws_client.cloudwatch.list_metric_streams() + metric_streams = response_list.get("Entries", []) + metric_streams_names = [metric_stream["Name"] for metric_stream in metric_streams] + assert metric_stream_name in metric_streams_names + + start_response = aws_client.cloudwatch.start_metric_streams(Names=[metric_stream_name]) + snapshot.match("start_metric_stream", start_response) + + stop_response = aws_client.cloudwatch.stop_metric_streams(Names=[metric_stream_name]) + snapshot.match("stop_metric_stream", stop_response) + + response_delete = aws_client.cloudwatch.delete_metric_stream(Name=metric_stream_name) + snapshot.match("delete_metric_stream", response_delete) + response_list = aws_client.cloudwatch.list_metric_streams() + metric_streams = response_list.get("Entries", []) + metric_streams_names = [metric_stream["Name"] for metric_stream in metric_streams] + assert metric_stream_name not in metric_streams_names + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Operations not supported") + def test_insight_rule(self, aws_client, snapshot): + insight_rule_name = f"MyInsightRule-{short_uid()}" + response_create = aws_client.cloudwatch.put_insight_rule( + RuleName=insight_rule_name, + RuleState="ENABLED", + RuleDefinition=json.dumps( + { + "Schema": {"Name": "CloudWatchLogRule", "Version": 1}, + "LogGroupNames": ["API-Gateway-Access-Logs*"], + "LogFormat": "CLF", + "Fields": {"4": "IpAddress", "7": "StatusCode"}, + "Contribution": { + "Keys": ["IpAddress"], + "Filters": [{"Match": "StatusCode", "EqualTo": 200}], + }, + "AggregateOn": "Count", + } + ), + ) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + snapshot.match("create_insight_rule", response_create) + + response_describe = aws_client.cloudwatch.describe_insight_rules() + snapshot.match("describe_insight_rule", response_describe) + + response_disable = aws_client.cloudwatch.disable_insight_rules( + RuleNames=[insight_rule_name] + ) + snapshot.match("disable_insight_rule", response_disable) + + response_enable = aws_client.cloudwatch.enable_insight_rules(RuleNames=[insight_rule_name]) + snapshot.match("enable_insight_rule", response_enable) + + insight_rule_report = aws_client.cloudwatch.get_insight_rule_report( + RuleName=insight_rule_name, + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + Period=300, + MaxContributorCount=10, + Metrics=["UniqueContributors"], + ) + snapshot.match("get_insight_rule_report", insight_rule_report) + + response_list = aws_client.cloudwatch.describe_insight_rules() + insight_rules_names = [ + insight_rule["Name"] for insight_rule in response_list["InsightRules"] + ] + assert insight_rule_name in insight_rules_names + + response_delete = aws_client.cloudwatch.delete_insight_rules(RuleNames=[insight_rule_name]) + snapshot.match("delete_insight_rule", response_delete) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Operations not supported") + def test_anomaly_detector_lifecycle(self, aws_client, snapshot): + namespace = "MyNamespace" + metric_name = "MyMetric" + + response_create = aws_client.cloudwatch.put_anomaly_detector( + MetricName=metric_name, + Namespace=namespace, + Stat="Sum", + Configuration={}, + Dimensions=[{"Name": "DimensionName", "Value": "DimensionValue"}], + ) + snapshot.match("create_anomaly_detector", response_create) + + response_list = aws_client.cloudwatch.describe_anomaly_detectors() + snapshot.match("describe_anomaly_detector", response_list) + + response_delete = aws_client.cloudwatch.delete_anomaly_detector( + MetricName=metric_name, + Namespace=namespace, + Stat="Sum", + Dimensions=[{"Name": "DimensionName", "Value": "DimensionValue"}], + ) + snapshot.match("delete_anomaly_detector", response_delete) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Operations not supported") + def test_metric_widget(self, aws_client): + metric_name = f"test-metric-{short_uid()}" + namespace = f"ns-{short_uid()}" + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Timestamp": datetime.utcnow().replace(tzinfo=timezone.utc), + "Values": [1.0, 10.0], + "Counts": [2, 4], + "Unit": "Count", + } + ], + ) + + response = aws_client.cloudwatch.get_metric_widget_image( + MetricWidget=json.dumps( + { + "metrics": [ + [ + namespace, + metric_name, + {"stat": "Sum", "id": "m1"}, + ] + ], + "view": "timeSeries", + "stacked": False, + "region": "us-east-1", + "title": "test", + "width": 600, + "height": 400, + "start": "-PT3H", + "end": "P0D", + } + ) + ) + + assert isinstance(response["MetricWidgetImage"], bytes) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="New test for v2 provider") + def test_describe_minimal_metric_alarm(self, snapshot, aws_client, cleanups): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + alarm_name = f"a-{short_uid()}" + metric_name = f"m-{short_uid()}" + name_space = f"n-sp-{short_uid()}" + + snapshot.add_transformer(TransformerUtility.key_value("MetricName")) + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + MetricName=metric_name, + Namespace=name_space, + EvaluationPeriods=1, + Period=10, + Statistic="Sum", + ComparisonOperator="GreaterThanThreshold", + Threshold=30, + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + response = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("describe_minimal_metric_alarm", response) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="New test for v2 provider") + def test_set_alarm_invalid_input(self, aws_client, snapshot, cleanups): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + alarm_name = f"a-{short_uid()}" + metric_name = f"m-{short_uid()}" + name_space = f"n-sp-{short_uid()}" + + snapshot.add_transformer(TransformerUtility.key_value("MetricName")) + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + MetricName=metric_name, + Namespace=name_space, + EvaluationPeriods=1, + Period=10, + Statistic="Sum", + ComparisonOperator="GreaterThanThreshold", + Threshold=30, + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + with pytest.raises(Exception) as ex: + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_name, StateValue="INVALID", StateReason="test" + ) + + snapshot.match("error-invalid-state", ex.value.response) + + with pytest.raises(Exception) as ex: + aws_client.cloudwatch.set_alarm_state( + AlarmName=f"{alarm_name}-nonexistent", StateValue="OK", StateReason="test" + ) + + snapshot.match("error-resource-not-found", ex.value.response) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_get_metric_data_with_zero_and_labels(self, aws_client, snapshot): + utc_now = datetime.now(tz=timezone.utc) + + namespace1 = f"test/{short_uid()}" + # put metric data + values = [0, 2, 4, 3.5, 7, 100] + aws_client.cloudwatch.put_metric_data( + Namespace=namespace1, + MetricData=[ + {"MetricName": "metric1", "Value": val, "Unit": "Seconds"} for val in values + ], + ) + # get_metric_data + stats = ["Average", "Sum", "Minimum", "Maximum"] + + def _get_metric_data(): + return aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "result_" + stat, + "MetricStat": { + "Metric": {"Namespace": namespace1, "MetricName": "metric1"}, + "Period": 60, + "Stat": stat, + }, + } + for stat in stats + ], + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + ) + + def _match_results(): + response = _get_metric_data() + # keep one assert to avoid storing incorrect values + avg = [res for res in response["MetricDataResults"] if res["Id"] == "result_Average"][0] + assert [int(val) for val in avg["Values"]] == [19] + snapshot.match("get_metric_data_with_zero_and_labels", response) + + retry(_match_results, retries=10, sleep=1.0) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Datapoints..Unit"]) + def test_get_metric_statistics(self, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + utc_now = datetime.now(tz=timezone.utc) + namespace = f"test/{short_uid()}" + + for i in range(10): + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + dict(MetricName="metric", Value=i, Timestamp=utc_now + timedelta(seconds=1)) + ], + ) + + def assert_results(): + stats_responce = aws_client.cloudwatch.get_metric_statistics( + Namespace=namespace, + MetricName="metric", + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + Period=60, + Statistics=["Average", "Sum", "Minimum", "Maximum", "SampleCount"], + ) + + assert len(stats_responce["Datapoints"]) == 1 + snapshot.match("get_metric_statistics", stats_responce) + + sleep_before = 2 if is_aws_cloud() else 0.0 + retry(assert_results, retries=10, sleep=1.0, sleep_before=sleep_before) + + @markers.aws.validated + def test_list_metrics_pagination(self, aws_client): + namespace = f"n-sp-{short_uid()}" + metric_name = f"m-{short_uid()}" + max_metrics = 500 # max metrics per page according to AWS docs + for i in range(0, max_metrics + 1): + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": f"{metric_name}-{i}", + "Value": 21, + "Unit": "Seconds", + } + ], + ) + + def assert_metrics_count(): + response = aws_client.cloudwatch.list_metrics(Namespace=namespace) + assert len(response["Metrics"]) == max_metrics and response.get("NextToken") is not None + + retry(assert_metrics_count, retries=10, sleep=1.0, sleep_before=1.0) + + @markers.aws.validated + @pytest.mark.skipif(condition=is_old_provider(), reason="not supported by the old provider") + def test_get_metric_data_pagination(self, aws_client): + namespace = f"n-sp-{short_uid()}" + metric_name = f"m-{short_uid()}" + max_data_points = 10 # default is 100,800 according to AWS docs + now = datetime.utcnow().replace(tzinfo=timezone.utc) + for i in range(0, max_data_points * 2): + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Timestamp": now + timedelta(seconds=(i * 60)), + "Value": i, + "Unit": "Seconds", + } + ], + ) + + def assert_data_points_count(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=now, + EndTime=now + timedelta(seconds=(max_data_points * 60 * 2)), + MaxDatapoints=max_data_points, + ) + assert (len(response["MetricDataResults"][0]["Values"]) == 10) and ( + response.get("NextToken") is not None + ) + + retry(assert_data_points_count, retries=10, sleep=1.0, sleep_before=2.0) + + @markers.aws.validated + def test_put_metric_uses_utc(self, aws_client): + namespace = f"n-sp-{short_uid()}" + metric_name = f"m-{short_uid()}" + now_local = datetime.now(timezone(timedelta(hours=-5), "America/Cancun")).replace( + tzinfo=None + ) # Remove the tz info to avoid boto converting it to UTC + now_utc = datetime.utcnow() + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Value": 1, + "Unit": "Seconds", + } + ], + ) + + def assert_found_in_utc(): + response = aws_client.cloudwatch.get_metric_statistics( + Namespace=namespace, + MetricName=metric_name, + StartTime=now_local - timedelta(seconds=60), + EndTime=now_local + timedelta(seconds=60), + Period=60, + Statistics=["Average"], + ) + assert len(response["Datapoints"]) == 0 + + response = aws_client.cloudwatch.get_metric_statistics( + Namespace=namespace, + MetricName=metric_name, + StartTime=now_utc - timedelta(seconds=60), + EndTime=now_utc + timedelta(seconds=60), + Period=60, + Statistics=["Average"], + ) + assert len(response["Datapoints"]) == 1 + + retry(assert_found_in_utc, retries=10, sleep=1.0) + + @markers.aws.validated + def test_default_ordering(self, aws_client): + namespace = f"n-sp-{short_uid()}" + metric_name = f"m-{short_uid()}" + now = datetime.utcnow().replace(tzinfo=timezone.utc) + for i in range(0, 10): + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Timestamp": now + timedelta(seconds=(i * 60)), + "Value": i, + "Unit": "Seconds", + } + ], + ) + + def assert_ordering(): + default_ordering = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=now, + EndTime=now + timedelta(seconds=(10 * 60)), + MaxDatapoints=10, + ) + + ascending_ordering = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=now, + EndTime=now + timedelta(seconds=(10 * 60)), + MaxDatapoints=10, + ScanBy="TimestampAscending", + ) + + descening_ordering = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=now, + EndTime=now + timedelta(seconds=(10 * 60)), + MaxDatapoints=10, + ScanBy="TimestampDescending", + ) + + default_ordering_datapoints = default_ordering["MetricDataResults"][0]["Timestamps"] + ascending_ordering_datapoints = ascending_ordering["MetricDataResults"][0]["Timestamps"] + descening_ordering_datapoints = descening_ordering["MetricDataResults"][0]["Timestamps"] + + # The default ordering is TimestampDescending + assert default_ordering_datapoints == descening_ordering_datapoints + assert default_ordering_datapoints == ascending_ordering_datapoints[::-1] + + retry(assert_ordering, retries=10, sleep=1.0) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_handle_different_units(self, aws_client, snapshot): + namespace = f"n-sp-{short_uid()}" + metric_name = "m-test" + now = datetime.utcnow().replace(tzinfo=timezone.utc) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Timestamp": now, + "Value": 1, + "Unit": "Seconds", + }, + { + "MetricName": metric_name, + "Timestamp": now, + "Value": 5, + "Unit": "Count", + }, + { + "MetricName": metric_name, + "Timestamp": now, + "Value": 10, + }, + ], + ) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_statistics( + Namespace=namespace, + MetricName=metric_name, + StartTime=now - timedelta(seconds=60), + EndTime=now + timedelta(seconds=60), + Period=60, + Statistics=["Average"], + ) + assert len(response["Datapoints"]) == 3 + response["Datapoints"].sort(key=lambda x: x["Average"], reverse=True) + snapshot.match("get_metric_statistics_with_different_units", response) + + retries = 10 if is_aws_cloud() else 1 + sleep_before = 2 if is_aws_cloud() else 0.0 + retry(assert_results, retries=retries, sleep=1.0, sleep_before=sleep_before) + + @markers.aws.validated + def test_get_metric_data_with_different_units(self, aws_client, snapshot): + namespace = f"n-sp-{short_uid()}" + metric_name = "m-test" + now = datetime.utcnow().replace(tzinfo=timezone.utc) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Timestamp": now, + "Value": 1, + "Unit": "Seconds", + }, + { + "MetricName": metric_name, + "Timestamp": now, + "Value": 1, + "Unit": "Count", + }, + ], + ) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + }, + "Period": 60, + "Stat": "Sum", + "Unit": "Seconds", + }, + } + ], + StartTime=now, + EndTime=now + timedelta(seconds=60), + MaxDatapoints=10, + ) + snapshot.match("get_metric_data_with_different_units", response) + + retries = 10 if is_aws_cloud() else 1 + sleep_before = 2 if is_aws_cloud() else 0.0 + retry(assert_results, retries=retries, sleep=1.0, sleep_before=sleep_before) + + base_metric_data = [ + { + "MetricName": "", + "Timestamp": "", + "Value": 60000, + "Unit": "Milliseconds", + }, + { + "MetricName": "", + "Timestamp": "", + "Value": 60, + "Unit": "Seconds", + }, + ] + count_metric = { + "MetricName": "", + "Timestamp": "", + "Value": 5, + "Unit": "Count", + } + + @pytest.mark.parametrize( + "metric_data", + [ + base_metric_data, + base_metric_data + base_metric_data, + base_metric_data + base_metric_data + [count_metric], + ], + ) + @markers.aws.needs_fixing + @pytest.mark.skip(reason="Not supported in either provider, needs to be fixed in new one") + def test_get_metric_data_different_units_no_unit_in_query( + self, aws_client, snapshot, metric_data + ): + # From the docs: + """ + In a Get operation, if you omit Unit then all data that was collected with any unit is returned, along with the + corresponding units that were specified when the data was reported to CloudWatch. If you specify a unit, the + operation returns only data that was collected with that unit specified. If you specify a unit that does not + match the data collected, the results of the operation are null. CloudWatch does not perform unit conversions. + """ + # TODO: Check if this part of the docs hold -> this seems to be impossible. When provided with a statistic, + # it simply picks the first unit out of the list of allowed units, then returns the statistic based exclusively + # on the values that have this particular unit. And there seems to be no way to not provide a statistic. + + # The list of allowed units seems to be: + # [Megabits, Terabits, Gigabits, Count, Bytes, Gigabytes, Gigabytes / Second, Kilobytes, Kilobits / Second, + # Terabytes, Terabits/Second, Bytes/Second, Percent, Megabytes, Megabits/Second, Milliseconds, Microseconds, + # Kilobytes/Second, Gigabits/Second, Megabytes/Second, Bits, Bits/Second, Count/Second, Seconds, Kilobits, + # Terabytes/Second, None ]. + + namespace = f"n-sp-{short_uid()}" + metric_name = "m-test" + now = datetime.utcnow().replace(tzinfo=timezone.utc) + + for m in metric_data: + m["MetricName"] = metric_name + m["Timestamp"] = now + aws_client.cloudwatch.put_metric_data(Namespace=namespace, MetricData=metric_data) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=now, + EndTime=now + timedelta(seconds=60), + MaxDatapoints=10, + ) + snapshot.match("get_metric_data_with_no_unit_specified", response) + + retries = 10 if is_aws_cloud() else 1 + sleep_before = 2 if is_aws_cloud() else 0.0 + retry(assert_results, retries=retries, sleep=1.0, sleep_before=sleep_before) + + @pytest.mark.parametrize( + "input_pairs", + [ + [("Sum", 60, "Seconds"), ("Minimum", 30, "Seconds")], + [("Sum", 60, "Seconds"), ("Minimum", 60, "Seconds")], + [("Sum", 60, "Seconds"), ("Sum", 30, "Seconds")], + [("Sum", 60, "Seconds"), ("Minimum", 30, "Milliseconds")], + [("Sum", 60, "Seconds"), ("Minimum", 60, "Milliseconds")], + [("Sum", 60, "Seconds"), ("Sum", 30, "Milliseconds")], + [("Sum", 60, "Seconds"), ("Sum", 60, "Milliseconds")], + ], + ) + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_label_generation(self, aws_client, snapshot, input_pairs): + # Whenever values differ for a statistic type or period, that value is added to the label + utc_now = datetime.now(tz=timezone.utc) + + namespace1 = f"test/{short_uid()}" + # put metric data + values = [0, 2, 7, 100] + aws_client.cloudwatch.put_metric_data( + Namespace=namespace1, + MetricData=[ + {"MetricName": "metric1", "Value": val, "Unit": "Seconds"} for val in values + ], + ) + + # get_metric_data + + def _get_metric_data(): + return aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": f"result_{stat}_{str(period)}_{unit}", + "MetricStat": { + "Metric": {"Namespace": namespace1, "MetricName": "metric1"}, + "Period": period, + "Stat": stat, + "Unit": unit, + }, + } + for (stat, period, unit) in input_pairs + ], + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + ) + + def _match_results(): + response = _get_metric_data() + # keep one assert to avoid storing incorrect values + sum = [ + res for res in response["MetricDataResults"] if res["Id"].startswith("result_Sum") + ][0] + assert [int(val) for val in sum["Values"]] == [109] + snapshot.match("label_generation", response) + + retry(_match_results, retries=10, sleep=1.0) + + @markers.aws.validated + def test_get_metric_with_null_dimensions(self, aws_client, snapshot): + """ + This test validates the behaviour when there is metric data with dimensions and the get_metric_data call + has no dimensions specified. The expected behaviour is that the call should return the metric data with + no dimensions, which in this test, there is no such data, so the total sum should equal 0. And since the + Sum equals 0, the response will have no values. + """ + snapshot.add_transformer(snapshot.transform.key_value("Id")) + snapshot.add_transformer(snapshot.transform.key_value("Label")) + namespace = f"n-{short_uid()}" + metric_name = "m-test" + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Value": 1, + "Unit": "Seconds", + "Dimensions": [ + { + "Name": "foo", + "Value": "bar", + } + ], + } + ], + ) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + "Dimensions": [], + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + ) + assert len(response["MetricDataResults"][0]["Values"]) == 0 + snapshot.match("get_metric_with_null_dimensions", response) + + retry(assert_results, retries=10, sleep=1.0, sleep_before=2 if is_aws_cloud() else 0.0) + + @markers.aws.validated + def test_alarm_lambda_target( + self, aws_client, create_lambda_function, cleanups, account_id, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("alarmName")) + snapshot.add_transformer( + snapshot.transform.key_value("namespace", reference_replacement=False) + ) + fn_name = f"fn-cw-{short_uid()}" + response = create_lambda_function( + func_name=fn_name, + handler_file=ACTION_LAMBDA, + runtime="python3.11", + ) + function_arn = response["CreateFunctionResponse"]["FunctionArn"] + alarm_name = f"alarm-{short_uid()}" + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription="testing lambda alarm action", + MetricName="metric1", + Namespace=f"ns-{short_uid()}", + Period=10, + Threshold=2, + Statistic="Average", + OKActions=[], + AlarmActions=[function_arn], + EvaluationPeriods=2, + ComparisonOperator="GreaterThanThreshold", + TreatMissingData="ignore", + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + alarm_arn = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name])["MetricAlarms"][ + 0 + ]["AlarmArn"] + # allow cloudwatch to trigger the lambda + aws_client.lambda_.add_permission( + FunctionName=fn_name, + StatementId="AlarmAction", + Action="lambda:InvokeFunction", + Principal="lambda.alarms.cloudwatch.amazonaws.com", + SourceAccount=account_id, + SourceArn=alarm_arn, + ) + aws_client.cloudwatch.set_alarm_state( + AlarmName=alarm_name, StateValue="ALARM", StateReason="testing alarm" + ) + + # wait for lambda invocation + def log_group_exists(): + return ( + len( + aws_client.logs.describe_log_groups( + logGroupNamePrefix=f"/aws/lambda/{fn_name}" + )["logGroups"] + ) + == 1 + ) + + wait_until(log_group_exists, max_retries=30 if is_aws_cloud() else 10) + + invocation_res = retry( + lambda: _get_lambda_logs(aws_client.logs, fn_name=fn_name), + retries=200 if is_aws_cloud() else 20, + sleep=10 if is_aws_cloud() else 1, + ) + snapshot.match("lambda-alarm-invocations", invocation_res) + + @markers.aws.validated + def test_get_metric_with_no_results(self, snapshot, aws_client): + utc_now = datetime.now(tz=timezone.utc) + namespace = f"n-{short_uid()}" + metric = f"m-{short_uid()}" + + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric, + "Value": 1, + } + ], + ) + + def assert_metric_ready(): + list_of_metrics = aws_client.cloudwatch.list_metrics( + Namespace=namespace, MetricName=metric + ) + assert len(list_of_metrics["Metrics"]) == 1 + + retry(assert_metric_ready, sleep=1, retries=10) + + data = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "result", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric, + "Dimensions": [ + { + "Name": "foo", + "Value": "bar", + } + ], + }, + "Period": 60, + "Stat": "Sum", + }, + } + ], + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + ) + snapshot.add_transformer(snapshot.transform.key_value("Label")) + snapshot.match("result", data) + + @markers.aws.only_localstack + @pytest.mark.skipif(is_old_provider(), reason="old provider has known concurrency issues") + # test some basic concurrency tasks + def test_parallel_put_metric_data_list_metrics(self, aws_client): + num_threads = 20 + create_barrier = threading.Barrier(num_threads) + namespace = f"namespace-{short_uid()}" + exception_caught = False + + def _put_metric_get_metric_data(runner: int): + nonlocal create_barrier + nonlocal namespace + nonlocal exception_caught + create_barrier.wait() + try: + if runner % 2: + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": f"metric-{runner}-1", + "Value": 25, + "Unit": "Seconds", + }, + { + "MetricName": f"metric-{runner}-2", + "Value": runner + 1, + "Unit": "Seconds", + }, + ], + ) + else: + now = datetime.utcnow().replace(microsecond=0) + start_time = now - timedelta(minutes=10) + end_time = now + timedelta(minutes=5) + aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "some", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": f"metric-{runner - 1}-1", + }, + "Period": 60, + "Stat": "Sum", + }, + }, + { + "Id": "part", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": f"metric-{runner - 1}-2", + }, + "Period": 60, + "Stat": "Sum", + }, + }, + ], + StartTime=start_time, + EndTime=end_time, + ) + except Exception as e: + LOG.exception("runner %s failed: %s", runner, e) + exception_caught = True + + thread_list = [] + for i in range(1, num_threads + 1): + thread = threading.Thread(target=_put_metric_get_metric_data, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + + assert not exception_caught + metrics = aws_client.cloudwatch.list_metrics(Namespace=namespace)["Metrics"] + assert 20 == len(metrics) # every second thread inserted two metrics + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + condition=is_old_provider, + paths=[ + "$..describe-alarm.MetricAlarms..AlarmDescription", + "$..describe-alarm.MetricAlarms..StateTransitionedTimestamp", + ], + ) + def test_delete_alarm(self, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + + alarm_name = "test-alarm" + aws_client.cloudwatch.put_metric_alarm( + AlarmName="test-alarm", + Namespace=f"my-namespace-{short_uid()}", + MetricName="metric1", + EvaluationPeriods=1, + ComparisonOperator="GreaterThanThreshold", + Period=60, + Statistic="Sum", + Threshold=30, + ) + result = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("describe-alarm", result) + + delete_result = aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name]) + snapshot.match("delete-alarm", delete_result) + + describe_alarm = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name]) + snapshot.match("describe-after-delete", describe_alarm) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + condition=is_old_provider, + paths=[ + "$..list-metrics..Metrics", + ], + ) + def test_multiple_dimensions_statistics(self, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + + utc_now = datetime.now(tz=timezone.utc) + namespace = f"test/{short_uid()}" + metric_name = "http.server.requests.count" + dimensions = [ + {"Name": "error", "Value": "none"}, + {"Name": "exception", "Value": "none"}, + {"Name": "method", "Value": "GET"}, + {"Name": "outcome", "Value": "SUCCESS"}, + {"Name": "uri", "Value": "/greetings"}, + {"Name": "status", "Value": "200"}, + ] + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Value": 0.0, + "Unit": "Count", + "StorageResolution": 1, + "Dimensions": dimensions, + "Timestamp": datetime.now(tz=timezone.utc), + } + ], + ) + aws_client.cloudwatch.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Value": 5.0, + "Unit": "Count", + "StorageResolution": 1, + "Dimensions": dimensions, + "Timestamp": datetime.now(tz=timezone.utc), + } + ], + ) + + def assert_results(): + response = aws_client.cloudwatch.get_metric_data( + MetricDataQueries=[ + { + "Id": "result1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + "Dimensions": dimensions, + }, + "Period": 10, + "Stat": "Maximum", + "Unit": "Count", + }, + } + ], + StartTime=utc_now - timedelta(seconds=60), + EndTime=utc_now + timedelta(seconds=60), + ) + + assert len(response["MetricDataResults"][0]["Values"]) > 0 + snapshot.match("get-metric-stats-max", response) + + retries = 10 if is_aws_cloud() else 1 + sleep_before = 2 if is_aws_cloud() else 0 + retry(assert_results, retries=retries, sleep_before=sleep_before) + + def list_metrics(): + res = aws_client.cloudwatch.list_metrics( + Namespace=namespace, MetricName=metric_name, Dimensions=dimensions + ) + assert len(res["Metrics"]) > 0 + return res + + retries = 10 if is_aws_cloud() else 1 + sleep_before = 2 if is_aws_cloud() else 0 + list_metrics_res = retry(list_metrics, retries=retries, sleep_before=sleep_before) + + # Function to sort the dimensions by "Name" + def sort_dimensions(data: dict): + for metric in data["Metrics"]: + metric["Dimensions"] = sorted(metric["Dimensions"], key=lambda x: x["Name"]) + + sort_dimensions(list_metrics_res) + snapshot.match("list-metrics", list_metrics_res) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="New test for v2 provider") + def test_invalid_amount_of_datapoints(self, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + utc_now = datetime.now(tz=timezone.utc) + with pytest.raises(ClientError) as ex: + aws_client.cloudwatch.get_metric_statistics( + Namespace="namespace", + MetricName="metric_name", + StartTime=utc_now, + EndTime=utc_now + timedelta(days=1), + Period=1, + Statistics=["SampleCount"], + ) + + snapshot.match("error-invalid-amount-datapoints", ex.value.response) + with pytest.raises(ClientError) as ex: + aws_client.cloudwatch.get_metric_statistics( + Namespace="namespace", + MetricName="metric_name", + StartTime=utc_now, + EndTime=utc_now, + Period=1, + Statistics=["SampleCount"], + ) + + snapshot.match("error-invalid-time-frame", ex.value.response) + + response = aws_client.cloudwatch.get_metric_statistics( + Namespace=f"namespace_{short_uid()}", + MetricName="metric_name", + StartTime=utc_now, + EndTime=utc_now + timedelta(days=1), + Period=60, + Statistics=["SampleCount"], + ) + + snapshot.match("get-metric-statitics", response) + + +def _get_lambda_logs(logs_client: "CloudWatchLogsClient", fn_name: str): + log_events = logs_client.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")["events"] + filtered_logs = [event for event in log_events if event["message"].startswith("{")] + assert len(filtered_logs) >= 1 + filtered_logs.sort(key=lambda e: e["timestamp"], reverse=True) + return filtered_logs[0]["message"] + + +def _check_alarm_triggered( + expected_state, + alarm_name, + cloudwatch_client, + snapshot=None, + identifier=None, +): + response = cloudwatch_client.describe_alarms(AlarmNames=[alarm_name]) + assert response["MetricAlarms"][0]["StateValue"] == expected_state + if snapshot: + snapshot.match(f"{identifier}-describe", response) + + +def _sqs_messages_snapshot(expected_state, sqs_client, sqs_queue, snapshot, identifier): + result = sqs_client.receive_message(QueueUrl=sqs_queue, WaitTimeSeconds=2, VisibilityTimeout=0) + found_msg = None + receipt_handle = None + for msg in result["Messages"]: + body = json.loads(msg["Body"]) + message = json.loads(body["Message"]) + if message["NewStateValue"] == expected_state: + found_msg = message + receipt_handle = msg["ReceiptHandle"] + break + assert found_msg, ( + f"no message found for {expected_state}. Got {len(result['Messages'])} messages.\n{json.dumps(result)}" + ) + sqs_client.delete_message(QueueUrl=sqs_queue, ReceiptHandle=receipt_handle) + snapshot.match(f"{identifier}-sqs-msg", found_msg) + + +def check_composite_alarm_message( + sqs_client, + queue_url, + expected_topic_arn, + alarm_name, + alarm_description, + expected_state, + expected_triggering_child_arn, + expected_triggering_child_state, +): + receive_result = sqs_client.receive_message(QueueUrl=queue_url) + message = None + for msg in receive_result["Messages"]: + body = json.loads(msg["Body"]) + if body["TopicArn"] == expected_topic_arn: + message = json.loads(body["Message"]) + receipt_handle = msg["ReceiptHandle"] + sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + break + assert message["NewStateValue"] == expected_state + assert message["AlarmName"] == alarm_name + assert message["AlarmDescription"] == alarm_description + triggering_child_alarm = message["TriggeringChildren"][0] + assert triggering_child_alarm["Arn"] == expected_triggering_child_arn + assert triggering_child_alarm["State"]["Value"] == expected_triggering_child_state + return message + + +def check_message( + sqs_client, + expected_queue_url, + expected_topic_arn, + expected_new, + expected_reason, + alarm_name, + alarm_description, + expected_trigger, +): + receive_result = sqs_client.receive_message(QueueUrl=expected_queue_url) + message = None + for msg in receive_result["Messages"]: + body = json.loads(msg["Body"]) + if body["TopicArn"] == expected_topic_arn: + message = json.loads(body["Message"]) + receipt_handle = msg["ReceiptHandle"] + sqs_client.delete_message(QueueUrl=expected_queue_url, ReceiptHandle=receipt_handle) + break + assert message["NewStateValue"] == expected_new + assert message["NewStateReason"] == expected_reason + assert message["AlarmName"] == alarm_name + assert message["AlarmDescription"] == alarm_description + assert message["Trigger"] == expected_trigger + return message + + +def get_sqs_policy(sqs_queue_arn, sns_topic_arn): + return f""" +{{ + "Version":"2012-10-17", + "Statement":[ + {{ + "Effect": "Allow", + "Principal": {{ "AWS": "*" }}, + "Action": "sqs:SendMessage", + "Resource": "{sqs_queue_arn}", + "Condition":{{ + "ArnEquals":{{ + "aws:SourceArn":"{sns_topic_arn}" + }} + }} + }} + ] +}} +""" + + +ACTION_LAMBDA = """ +def handler(event, context): + import json + print(json.dumps(event)) + return {"triggered": True} +""" diff --git a/tests/aws/services/cloudwatch/test_cloudwatch.snapshot.json b/tests/aws/services/cloudwatch/test_cloudwatch.snapshot.json new file mode 100644 index 0000000000000..87abfc826b4a8 --- /dev/null +++ b/tests/aws/services/cloudwatch/test_cloudwatch.snapshot.json @@ -0,0 +1,2268 @@ +{ + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_enable_disable_alarm_actions": { + "recorded-date": "12-09-2023, 12:00:45", + "recorded-content": { + "cloudwatch_sns_subscription": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_alarm": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "my-metric101", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "Unchecked: Initial alarm creation", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "INSUFFICIENT_DATA", + "Statistic": "Average", + "Threshold": 2.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "alarm-state-describe": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "my-metric101", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "testing alarm", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM", + "Statistic": "Average", + "Threshold": 2.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "alarm-state-sqs-msg": { + "AWSAccountId": "111111111111", + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "date", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "InsufficientDataActions": [], + "NewStateReason": "testing alarm", + "NewStateValue": "ALARM", + "OKActions": [ + "arn::sns::111111111111:" + ], + "OldStateValue": "INSUFFICIENT_DATA", + "Region": "", + "StateChangeTime": "date", + "Trigger": { + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "name": "InstanceId", + "value": "abc" + } + ], + "EvaluateLowSampleCountPercentile": "", + "EvaluationPeriods": 2, + "MetricName": "my-metric101", + "Namespace": "", + "Period": 10, + "Statistic": "AVERAGE", + "StatisticType": "Statistic", + "Threshold": 2.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + }, + "describe_alarm_disabled": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": false, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "my-metric101", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "testing OK state", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK", + "Statistic": "Average", + "Threshold": 2.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "ok-state-action-disabled-describe": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": false, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "my-metric101", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "testing OK state", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK", + "Statistic": "Average", + "Threshold": 2.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_alarm_enabled": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "my-metric101", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "testing OK state", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK", + "Statistic": "Average", + "Threshold": 2.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_breaching_alarm_actions": { + "recorded-date": "12-09-2023, 11:56:52", + "recorded-content": { + "cloudwatch_sns_subscription": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "alarm-1-describe": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "my-metric101", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "Threshold Crossed: no datapoints were received for 2 periods and 2 missing datapoints were treated as [Breaching].", + "StateReasonData": { + "version": "1.0", + "queryDate": "date", + "unit": "Seconds", + "statistic": "Average", + "period": 10, + "recentDatapoints": [], + "threshold": 2.0, + "evaluatedDatapoints": [ + { + "timestamp": "date" + }, + { + "timestamp": "date" + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM", + "Statistic": "Average", + "Threshold": 2.0, + "TreatMissingData": "breaching", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "alarm-1-sqs-msg": { + "AWSAccountId": "111111111111", + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "date", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "InsufficientDataActions": [], + "NewStateReason": "Threshold Crossed: no datapoints were received for 2 periods and 2 missing datapoints were treated as [Breaching].", + "NewStateValue": "ALARM", + "OKActions": [ + "arn::sns::111111111111:" + ], + "OldStateValue": "INSUFFICIENT_DATA", + "Region": "", + "StateChangeTime": "date", + "Trigger": { + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "name": "InstanceId", + "value": "abc" + } + ], + "EvaluateLowSampleCountPercentile": "", + "EvaluationPeriods": 2, + "MetricName": "my-metric101", + "Namespace": "", + "Period": 10, + "Statistic": "AVERAGE", + "StatisticType": "Statistic", + "Threshold": 2.0, + "TreatMissingData": "breaching", + "Unit": "Seconds" + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_alarm": { + "recorded-date": "12-05-2025, 16:20:57", + "recorded-content": { + "describe-alarm": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 1, + "InsufficientDataActions": [], + "MetricName": "my-metric1", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "Unchecked: Initial alarm creation", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "INSUFFICIENT_DATA", + "Statistic": "Average", + "Threshold": 21.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "alarm-triggered-sqs-msg": { + "AWSAccountId": "111111111111", + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "date", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "InsufficientDataActions": [], + "NewStateReason": "Threshold Crossed: 1 datapoint [21.5 (MM/DD/YY HH:MM:SS)] was greater than the threshold (21.0).", + "NewStateValue": "ALARM", + "OKActions": [ + "arn::sns::111111111111:" + ], + "OldStateValue": "INSUFFICIENT_DATA", + "Region": "", + "StateChangeTime": "date", + "Trigger": { + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "name": "InstanceId", + "value": "abc" + } + ], + "EvaluateLowSampleCountPercentile": "", + "EvaluationPeriods": 1, + "MetricName": "my-metric1", + "Namespace": "", + "Period": 10, + "Statistic": "AVERAGE", + "StatisticType": "Statistic", + "Threshold": 21.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + }, + "describe-alarm-history": { + "AlarmHistoryItems": [ + { + "AlarmName": "", + "AlarmType": "MetricAlarm", + "HistoryData": { + "version": "1.0", + "oldState": { + "stateValue": "INSUFFICIENT_DATA", + "stateReason": "Unchecked: Initial alarm creation" + }, + "newState": { + "stateValue": "ALARM", + "stateReason": "Threshold Crossed: 1 datapoint [21.5 (MM/DD/YY HH:MM:SS)] was greater than the threshold (21.0).", + "stateReasonData": { + "version": "1.0", + "queryDate": "date", + "startDate": "date", + "unit": "Seconds", + "statistic": "Average", + "period": 10, + "recentDatapoints": [ + 21.5 + ], + "threshold": 21.0, + "evaluatedDatapoints": [ + { + "timestamp": "date", + "sampleCount": 2.0, + "value": 21.5 + } + ] + } + } + }, + "HistoryItemType": "StateUpdate", + "HistorySummary": "Alarm updated from INSUFFICIENT_DATA to ALARM", + "Timestamp": "timestamp" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-alarms-for-metric": { + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "testing cloudwatch alarms", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "abc" + } + ], + "EvaluationPeriods": 1, + "InsufficientDataActions": [], + "MetricName": "my-metric1", + "Namespace": "", + "OKActions": [ + "arn::sns::111111111111:" + ], + "Period": 10, + "StateReason": "Threshold Crossed: 1 datapoint [21.5 (MM/DD/YY HH:MM:SS)] was greater than the threshold (21.0).", + "StateReasonData": { + "version": "1.0", + "queryDate": "date", + "startDate": "date", + "unit": "Seconds", + "statistic": "Average", + "period": 10, + "recentDatapoints": [ + 21.5 + ], + "threshold": 21.0, + "evaluatedDatapoints": [ + { + "timestamp": "date", + "sampleCount": 2.0, + "value": 21.5 + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM", + "Statistic": "Average", + "Threshold": 21.0, + "TreatMissingData": "ignore", + "Unit": "Seconds" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_aws_sqs_metrics_created": { + "recorded-date": "25-09-2023, 10:25:29", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "sent", + "Label": "NumberOfMessagesSent", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + }, + { + "Id": "sent_size", + "Label": "SentMessageSize", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 5.0 + ] + }, + { + "Id": "empty_receives", + "Label": "NumberOfEmptyReceives", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_metric_data_2": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "num_msg_received", + "Label": "NumberOfMessagesReceived", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + }, + { + "Id": "num_msg_deleted", + "Label": "NumberOfMessagesDeleted", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_data_values_list": { + "recorded-date": "25-09-2023, 10:26:17", + "recorded-content": { + "get_metric_statistics": { + "Datapoints": [ + { + "Maximum": 10.0, + "SampleCount": 6.0, + "Sum": 42.0, + "Timestamp": "timestamp", + "Unit": "Count" + } + ], + "Label": "test-metric", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_store_tags": { + "recorded-date": "02-09-2024, 14:03:31", + "recorded-content": { + "put_metric_alarm": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_alarms": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [], + "EvaluationPeriods": 1, + "InsufficientDataActions": [], + "MetricName": "store_tags", + "Namespace": "", + "OKActions": [], + "Period": 60, + "StateReason": "Unchecked: Initial alarm creation", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "INSUFFICIENT_DATA", + "Statistic": "Sum", + "Threshold": 30.0 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_empty ": { + "Tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "foo" + }, + { + "Key": "tag2", + "Value": "bar" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_post_untag": { + "Tags": [ + { + "Key": "tag2", + "Value": "bar" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_dashboard_lifecycle": { + "recorded-date": "21-11-2023, 13:38:11", + "recorded-content": { + "get_dashboard": { + "DashboardArn": "arn::cloudwatch::111111111111:dashboard/", + "DashboardBody": { + "widgets": [ + { + "type": "metric", + "x": 0, + "y": 0, + "width": 6, + "height": 6, + "properties": { + "metrics": [ + [ + "AWS/EC2", + "CPUUtilization", + "InstanceId", + "i-12345678" + ] + ], + "region": "", + "view": "timeSeries", + "stacked": false + } + } + ] + }, + "DashboardName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_dashboards": { + "DashboardEntries": [ + { + "DashboardArn": "arn::cloudwatch::111111111111:dashboard/", + "DashboardName": "", + "LastModified": "datetime", + "Size": 225 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_dashboards_prefix_empty": { + "DashboardEntries": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_dashboards_prefix": { + "DashboardEntries": [ + { + "DashboardArn": "arn::cloudwatch::111111111111:dashboard/", + "DashboardName": "", + "LastModified": "datetime", + "Size": 225 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_dashboards_empty": { + "DashboardEntries": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_create_metric_stream": { + "recorded-date": "26-10-2023, 09:12:10", + "recorded-content": { + "create_metric_stream": { + "Arn": "arn::cloudwatch::111111111111:metric-stream/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_metric_stream": { + "Arn": "arn::cloudwatch::111111111111:metric-stream/", + "CreationDate": "datetime", + "FirehoseArn": "", + "IncludeLinkedAccountsMetrics": false, + "LastUpdateDate": "datetime", + "Name": "", + "OutputFormat": "json", + "RoleArn": "", + "State": "running", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_metric_stream": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stop_metric_stream": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_metric_stream": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_insight_rule": { + "recorded-date": "26-10-2023, 10:07:59", + "recorded-content": { + "create_insight_rule": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_insight_rule": { + "InsightRules": [ + { + "Definition": { + "Schema": { + "Name": "", + "Version": 1 + }, + "LogGroupNames": [ + "API-Gateway-Access-Logs*" + ], + "LogFormat": "CLF", + "Fields": { + "4": "IpAddress", + "7": "StatusCode" + }, + "Contribution": { + "Keys": [ + "IpAddress" + ], + "Filters": [ + { + "Match": "StatusCode", + "EqualTo": 200 + } + ] + }, + "AggregateOn": "Count" + }, + "ManagedRule": false, + "Name": "", + "Schema": "/1", + "State": "ENABLED" + }, + { + "Definition": { + "Schema": { + "Name": "", + "Version": 1 + }, + "LogGroupNames": [ + "API-Gateway-Access-Logs*" + ], + "LogFormat": "CLF", + "Fields": { + "4": "IpAddress", + "7": "StatusCode" + }, + "Contribution": { + "Keys": [ + "IpAddress" + ], + "Filters": [ + { + "Match": "StatusCode", + "EqualTo": 200 + } + ] + }, + "AggregateOn": "Count" + }, + "ManagedRule": false, + "Name": "", + "Schema": "/1", + "State": "ENABLED" + }, + { + "Definition": { + "Schema": { + "Name": "", + "Version": 1 + }, + "LogGroupNames": [ + "API-Gateway-Access-Logs*" + ], + "LogFormat": "CLF", + "Fields": { + "4": "IpAddress", + "7": "StatusCode" + }, + "Contribution": { + "Keys": [ + "IpAddress" + ], + "Filters": [ + { + "Match": "StatusCode", + "EqualTo": 200 + } + ] + }, + "AggregateOn": "Count" + }, + "ManagedRule": false, + "Name": "", + "Schema": "/1", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "disable_insight_rule": { + "Failures": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "enable_insight_rule": { + "Failures": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_insight_rule_report": { + "AggregateValue": 0.0, + "AggregationStatistic": "SampleCount", + "ApproximateUniqueCount": 0, + "Contributors": [], + "KeyLabels": [ + "IpAddress" + ], + "MetricDatapoints": [ + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + }, + { + "Timestamp": "timestamp", + "UniqueContributors": 0.0 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_insight_rule": { + "Failures": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_anomaly_detector_lifecycle": { + "recorded-date": "26-10-2023, 10:42:43", + "recorded-content": { + "create_anomaly_detector": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_anomaly_detector": { + "AnomalyDetectors": [ + { + "Configuration": { + "ExcludedTimeRanges": [] + }, + "Dimensions": [ + { + "Name": "DimensionName", + "Value": "DimensionValue" + } + ], + "MetricName": "MyMetric", + "Namespace": "MyNamespace", + "SingleMetricAnomalyDetector": { + "Dimensions": [ + { + "Name": "DimensionName", + "Value": "DimensionValue" + } + ], + "MetricName": "MyMetric", + "Namespace": "MyNamespace", + "Stat": "Sum" + }, + "Stat": "Sum", + "StateValue": "PENDING_TRAINING" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_anomaly_detector": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_alarm_lambda_target": { + "recorded-date": "03-01-2024, 17:30:00", + "recorded-content": { + "lambda-alarm-invocations": { + "source": "aws.cloudwatch", + "alarmArn": "arn::cloudwatch::111111111111:alarm:", + "accountId": "111111111111", + "time": "date", + "region": "", + "alarmData": { + "alarmName": "", + "state": { + "value": "ALARM", + "reason": "testing alarm", + "timestamp": "date" + }, + "previousState": { + "value": "INSUFFICIENT_DATA", + "reason": "Unchecked: Initial alarm creation", + "timestamp": "date" + }, + "configuration": { + "description": "testing lambda alarm action", + "metrics": [ + { + "id": "", + "metricStat": { + "metric": { + "namespace": "namespace", + "name": "metric1", + "dimensions": {} + }, + "period": 10, + "stat": "Average" + }, + "returnData": true + } + ] + } + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_describe_minimal_metric_alarm": { + "recorded-date": "25-10-2023, 17:17:06", + "recorded-content": { + "describe_minimal_metric_alarm": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [], + "EvaluationPeriods": 1, + "InsufficientDataActions": [], + "MetricName": "", + "Namespace": "", + "OKActions": [], + "Period": 10, + "StateReason": "Unchecked: Initial alarm creation", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "INSUFFICIENT_DATA", + "Statistic": "Sum", + "Threshold": 30.0 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_set_alarm_invalid_input": { + "recorded-date": "24-11-2023, 12:23:16", + "recorded-content": { + "error-invalid-state": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value 'INVALID' at 'stateValue' failed to satisfy constraint: Member must satisfy enum value set: [INSUFFICIENT_DATA, ALARM, OK]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-resource-not-found": { + "Error": { + "Code": "ResourceNotFound", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_for_multiple_metrics": { + "recorded-date": "23-11-2023, 14:39:07", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 50.0 + ] + }, + { + "Id": "result2", + "Label": "metric2", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 25.0 + ] + }, + { + "Id": "result3", + "Label": "metric3", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 55.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Sum]": { + "recorded-date": "04-12-2023, 12:22:53", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 66.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[SampleCount]": { + "recorded-date": "04-12-2023, 12:22:55", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 11.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Minimum]": { + "recorded-date": "04-12-2023, 12:22:58", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Maximum]": { + "recorded-date": "04-12-2023, 12:23:00", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 11.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_stats[Average]": { + "recorded-date": "04-12-2023, 12:23:02", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 6.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_with_dimensions": { + "recorded-date": "23-11-2023, 15:10:11", + "recorded-content": { + "get_metric_data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 11.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_statistics": { + "recorded-date": "04-12-2023, 14:13:08", + "recorded-content": { + "get_metric_statistics": { + "Datapoints": [ + { + "Average": 4.5, + "Maximum": 9.0, + "Minimum": 0.0, + "SampleCount": 10.0, + "Sum": 45.0, + "Timestamp": "timestamp", + "Unit": "None" + } + ], + "Label": "metric", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_with_zero_and_labels": { + "recorded-date": "04-12-2023, 09:13:59", + "recorded-content": { + "get_metric_data_with_zero_and_labels": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Average", + "Label": "metric1 Average", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 19.416666666666668 + ] + }, + { + "Id": "result_Sum", + "Label": "metric1 Sum", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 116.5 + ] + }, + { + "Id": "result_Minimum", + "Label": "metric1 Minimum", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 0.0 + ] + }, + { + "Id": "result_Maximum", + "Label": "metric1 Maximum", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 100.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_handle_different_units": { + "recorded-date": "05-01-2024, 16:15:39", + "recorded-content": { + "get_metric_statistics_with_different_units": { + "Datapoints": [ + { + "Average": 10.0, + "Timestamp": "timestamp", + "Unit": "None" + }, + { + "Average": 5.0, + "Timestamp": "timestamp", + "Unit": "Count" + }, + { + "Average": 1.0, + "Timestamp": "timestamp", + "Unit": "Seconds" + } + ], + "Label": "m-test", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_with_different_units": { + "recorded-date": "07-12-2023, 14:04:49", + "recorded-content": { + "get_metric_data_with_different_units": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "m1", + "Label": "m-test", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data0]": { + "recorded-date": "15-12-2023, 08:58:39", + "recorded-content": { + "get_metric_data_with_no_unit_specified": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "m1", + "Label": "m-test", + "Messages": [ + { + "Code": "MultipleUnits", + "Value": "Multiple units returned: '[Milliseconds, Seconds]'" + } + ], + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 60000.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs0]": { + "recorded-date": "15-12-2023, 11:27:23", + "recorded-content": { + "label_generation": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Sum_60_Seconds", + "Label": "metric1 Sum 60", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + }, + { + "Id": "result_Minimum_30_Seconds", + "Label": "metric1 Minimum 30", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 0.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs1]": { + "recorded-date": "15-12-2023, 11:27:25", + "recorded-content": { + "label_generation": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Sum_60_Seconds", + "Label": "metric1 Sum", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + }, + { + "Id": "result_Minimum_60_Seconds", + "Label": "metric1 Minimum", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 0.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data1]": { + "recorded-date": "15-12-2023, 08:58:42", + "recorded-content": { + "get_metric_data_with_no_unit_specified": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "m1", + "Label": "m-test", + "Messages": [ + { + "Code": "MultipleUnits", + "Value": "Multiple units returned: '[Milliseconds, Seconds]'" + } + ], + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 120000.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs2]": { + "recorded-date": "15-12-2023, 11:27:27", + "recorded-content": { + "label_generation": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Sum_60_Seconds", + "Label": "metric1 60", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + }, + { + "Id": "result_Sum_30_Seconds", + "Label": "metric1 30", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data2]": { + "recorded-date": "15-12-2023, 08:58:44", + "recorded-content": { + "get_metric_data_with_no_unit_specified": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "m1", + "Label": "m-test", + "Messages": [ + { + "Code": "MultipleUnits", + "Value": "Multiple units returned: '[Count, Milliseconds, Seconds]'" + } + ], + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 5.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs3]": { + "recorded-date": "15-12-2023, 11:27:29", + "recorded-content": { + "label_generation": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Sum_60_Seconds", + "Label": "metric1 Sum 60", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + }, + { + "Id": "result_Minimum_30_Milliseconds", + "Label": "metric1 Minimum 30", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs4]": { + "recorded-date": "15-12-2023, 11:27:31", + "recorded-content": { + "label_generation": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Sum_60_Seconds", + "Label": "metric1 Sum", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + }, + { + "Id": "result_Minimum_60_Milliseconds", + "Label": "metric1 Minimum", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs5]": { + "recorded-date": "15-12-2023, 11:27:33", + "recorded-content": { + "label_generation": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Sum_60_Seconds", + "Label": "metric1 60", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + }, + { + "Id": "result_Sum_30_Milliseconds", + "Label": "metric1 30", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_label_generation[input_pairs6]": { + "recorded-date": "15-12-2023, 11:27:35", + "recorded-content": { + "label_generation": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result_Sum_60_Seconds", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 109.0 + ] + }, + { + "Id": "result_Sum_60_Milliseconds", + "Label": "metric1", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_with_no_results": { + "recorded-date": "10-01-2024, 15:29:50", + "recorded-content": { + "result": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result", + "Label": "", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_with_null_dimensions": { + "recorded-date": "09-01-2024, 20:13:11", + "recorded-content": { + "get_metric_with_null_dimensions": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "", + "Label": "", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_set_alarm": { + "recorded-date": "19-01-2024, 15:29:42", + "recorded-content": { + "triggered-alarm": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "Test Alarm when CPU exceeds 50 percent", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "i-0317828c84edbe100" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "CPUUtilization-3", + "Namespace": "", + "OKActions": [ + "" + ], + "Period": 300, + "StateReason": "testing alarm", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM", + "Statistic": "Average", + "Threshold": 50.0, + "TreatMissingData": "ignore", + "Unit": "Percent" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "reset-alarm": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "Test Alarm when CPU exceeds 50 percent", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "InstanceId", + "Value": "i-0317828c84edbe100" + } + ], + "EvaluationPeriods": 2, + "InsufficientDataActions": [], + "MetricName": "CPUUtilization-3", + "Namespace": "", + "OKActions": [ + "" + ], + "Period": 300, + "StateReason": "resetting alarm", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK", + "Statistic": "Average", + "Threshold": 50.0, + "TreatMissingData": "ignore", + "Unit": "Percent" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_delete_alarm": { + "recorded-date": "12-01-2024, 14:06:14", + "recorded-content": { + "describe-alarm": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [], + "EvaluationPeriods": 1, + "InsufficientDataActions": [], + "MetricName": "metric1", + "Namespace": "", + "OKActions": [], + "Period": 60, + "StateReason": "Unchecked: Initial alarm creation", + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "INSUFFICIENT_DATA", + "Statistic": "Sum", + "Threshold": 30.0 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-alarm": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-after-delete": { + "CompositeAlarms": [], + "MetricAlarms": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_multiple_dimensions_statistics": { + "recorded-date": "26-07-2024, 15:38:56", + "recorded-content": { + "get-metric-stats-max": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "result1", + "Label": "http.server.requests.count", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 5.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-metrics": { + "Metrics": [ + { + "Dimensions": [ + { + "Name": "error", + "Value": "none" + }, + { + "Name": "exception", + "Value": "none" + }, + { + "Name": "method", + "Value": "GET" + }, + { + "Name": "outcome", + "Value": "SUCCESS" + }, + { + "Name": "status", + "Value": "200" + }, + { + "Name": "uri", + "Value": "/greetings" + } + ], + "MetricName": "http.server.requests.count", + "Namespace": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_invalid_amount_of_datapoints": { + "recorded-date": "23-08-2024, 14:14:54", + "recorded-content": { + "error-invalid-amount-datapoints": { + "Error": { + "Code": "InvalidParameterCombination", + "Message": "You have requested up to 86400 datapoints, which exceeds the limit of 1440. You may reduce the datapoints requested by increasing Period, or decreasing the time range.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-invalid-time-frame": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The parameter StartTime must be less than the parameter EndTime.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-metric-statitics": { + "Datapoints": [], + "Label": "metric_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_invalid_dashboard_name": { + "recorded-date": "04-09-2024, 16:28:05", + "recorded-content": { + "error-invalid-dashboardname": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The value for field DashboardName contains invalid characters. It can only contain alphanumerics, dash (-) and underscore (_).\n", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_trigger_composite_alarm": { + "recorded-date": "14-11-2024, 14:25:30", + "recorded-content": { + "put-composite-alarm": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "composite-alarm-in-alarm-when-alarm-1-is-in-alarm": { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "composite alarm description", + "AlarmName": "", + "AlarmRule": "ALARM(\"arn::cloudwatch::111111111111:alarm:\") OR ALARM(\"arn::cloudwatch::111111111111:alarm:\")", + "InsufficientDataActions": [], + "OKActions": [ + "arn::sns::111111111111:" + ], + "StateReason": "", + "StateReasonData": { + "triggeringAlarms": [ + { + "arn": "arn::cloudwatch::111111111111:alarm:", + "state": { + "value": "ALARM", + "timestamp": "date" + } + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM" + }, + "composite-alarm-in-ok-when-alarm-1-is-back-to-ok": { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "composite alarm description", + "AlarmName": "", + "AlarmRule": "ALARM(\"arn::cloudwatch::111111111111:alarm:\") OR ALARM(\"arn::cloudwatch::111111111111:alarm:\")", + "InsufficientDataActions": [], + "OKActions": [ + "arn::sns::111111111111:" + ], + "StateReason": "", + "StateReasonData": { + "triggeringAlarms": [ + { + "arn": "arn::cloudwatch::111111111111:alarm:", + "state": { + "value": "OK", + "timestamp": "date" + } + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK" + }, + "composite-alarm-in-alarm-when-alarm-2-is-in-alarm": { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "composite alarm description", + "AlarmName": "", + "AlarmRule": "ALARM(\"arn::cloudwatch::111111111111:alarm:\") OR ALARM(\"arn::cloudwatch::111111111111:alarm:\")", + "InsufficientDataActions": [], + "OKActions": [ + "arn::sns::111111111111:" + ], + "StateReason": "", + "StateReasonData": { + "triggeringAlarms": [ + { + "arn": "arn::cloudwatch::111111111111:alarm:", + "state": { + "value": "ALARM", + "timestamp": "date" + } + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM" + }, + "composite-alarm-in-ok-when-alarm-2-is-back-to-ok": { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "composite alarm description", + "AlarmName": "", + "AlarmRule": "ALARM(\"arn::cloudwatch::111111111111:alarm:\") OR ALARM(\"arn::cloudwatch::111111111111:alarm:\")", + "InsufficientDataActions": [], + "OKActions": [ + "arn::sns::111111111111:" + ], + "StateReason": "", + "StateReasonData": { + "triggeringAlarms": [ + { + "arn": "arn::cloudwatch::111111111111:alarm:", + "state": { + "value": "OK", + "timestamp": "date" + } + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "OK" + }, + "composite-alarm-is-triggered-by-alarm-1-and-then-unchanged-by-alarm-2": { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "composite alarm description", + "AlarmName": "", + "AlarmRule": "ALARM(\"arn::cloudwatch::111111111111:alarm:\") OR ALARM(\"arn::cloudwatch::111111111111:alarm:\")", + "InsufficientDataActions": [], + "OKActions": [ + "arn::sns::111111111111:" + ], + "StateReason": "", + "StateReasonData": { + "triggeringAlarms": [ + { + "arn": "arn::cloudwatch::111111111111:alarm:", + "state": { + "value": "ALARM", + "timestamp": "date" + } + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM" + } + } + } +} diff --git a/tests/aws/services/cloudwatch/test_cloudwatch.validation.json b/tests/aws/services/cloudwatch/test_cloudwatch.validation.json new file mode 100644 index 0000000000000..428f5d6c84b8f --- /dev/null +++ b/tests/aws/services/cloudwatch/test_cloudwatch.validation.json @@ -0,0 +1,74 @@ +{ + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_alarm_lambda_target": { + "last_validated_date": "2024-01-03T17:30:00+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_anomaly_detector_lifecycle": { + "last_validated_date": "2023-10-26T08:42:43+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_aws_sqs_metrics_created": { + "last_validated_date": "2023-09-25T08:25:29+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_breaching_alarm_actions": { + "last_validated_date": "2024-01-19T15:05:50+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_create_metric_stream": { + "last_validated_date": "2023-10-26T07:12:10+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_dashboard_lifecycle": { + "last_validated_date": "2023-10-25T11:16:20+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_delete_alarm": { + "last_validated_date": "2024-01-12T14:06:42+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_describe_alarms_converts_date_format_correctly": { + "last_validated_date": "2024-09-04T15:59:17+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_enable_disable_alarm_actions": { + "last_validated_date": "2023-09-12T10:00:45+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data0]": { + "last_validated_date": "2024-01-11T11:07:38+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data1]": { + "last_validated_date": "2024-01-11T11:07:41+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_data_different_units_no_unit_in_query[metric_data2]": { + "last_validated_date": "2024-01-11T11:07:43+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_with_no_results": { + "last_validated_date": "2024-01-10T15:29:50+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_get_metric_with_null_dimensions": { + "last_validated_date": "2024-03-05T14:34:47+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_handle_different_units": { + "last_validated_date": "2024-01-05T16:15:39+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_insight_rule": { + "last_validated_date": "2023-10-26T08:07:59+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_invalid_amount_of_datapoints": { + "last_validated_date": "2024-08-23T14:16:44+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_invalid_dashboard_name": { + "last_validated_date": "2024-09-04T16:28:05+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_multiple_dimensions_statistics": { + "last_validated_date": "2024-07-29T07:56:05+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_alarm": { + "last_validated_date": "2025-05-12T16:20:56+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_put_metric_data_values_list": { + "last_validated_date": "2023-09-25T08:26:17+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_set_alarm": { + "last_validated_date": "2024-01-19T15:30:05+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_store_tags": { + "last_validated_date": "2024-09-02T14:03:31+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch.py::TestCloudwatch::test_trigger_composite_alarm": { + "last_validated_date": "2024-11-14T14:25:30+00:00" + } +} diff --git a/tests/aws/services/cloudwatch/test_cloudwatch_metrics.py b/tests/aws/services/cloudwatch/test_cloudwatch_metrics.py new file mode 100644 index 0000000000000..1cf93b4f4cc10 --- /dev/null +++ b/tests/aws/services/cloudwatch/test_cloudwatch_metrics.py @@ -0,0 +1,273 @@ +import json +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import extract_resource_from_arn +from localstack.utils.strings import short_uid +from tests.aws.services.cloudwatch.test_cloudwatch import get_sqs_policy + +if TYPE_CHECKING: + from mypy_boto3_cloudwatch import CloudWatchClient + from mypy_boto3_sqs import SQSClient +from localstack.utils.sync import retry + +TEST_SUCCESSFUL_LAMBDA = """ +def handler(event, context): + return {"success": "ok"} +""" + +TEST_FAILING_LAMBDA = """ +def handler(event, context): + raise Exception('fail on purpose') +""" + + +class TestCloudWatchLambdaMetrics: + """ + Tests for metrics that are reported automatically by Lambda + see also https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics.html + """ + + @markers.aws.validated + def test_lambda_invoke_successful(self, aws_client, create_lambda_function, snapshot): + """ + successful invocation of lambda should report "Invocations" metric + """ + fn_name = f"fn-cw-{short_uid()}" + create_lambda_function( + func_name=fn_name, + handler_file=TEST_SUCCESSFUL_LAMBDA, + runtime="python3.9", + ) + result = aws_client.lambda_.invoke(FunctionName=fn_name) + assert result["StatusCode"] == 200 + snapshot.match("invoke", result) + + # wait for metrics + result = retry( + lambda: self._wait_for_lambda_metric( + aws_client.cloudwatch, + fn_name=fn_name, + metric_name="Invocations", + expected_return=[1.0], + ), + retries=200 if is_aws_cloud() else 20, + sleep=10 if is_aws_cloud() else 1, + ) + snapshot.match("get-metric-data", result) + + @markers.aws.validated + def test_lambda_invoke_error(self, aws_client, create_lambda_function, snapshot): + """ + Unsuccessful Invocation -> resulting in error, should report + "Errors" and "Invocations" metrics + """ + fn_name = f"fn-cw-{short_uid()}" + create_lambda_function( + func_name=fn_name, + handler_file=TEST_FAILING_LAMBDA, + runtime="python3.9", + ) + result = aws_client.lambda_.invoke(FunctionName=fn_name) + snapshot.match("invoke", result) + + # wait for metrics + invocation_res = retry( + lambda: self._wait_for_lambda_metric( + aws_client.cloudwatch, + fn_name=fn_name, + metric_name="Invocations", + expected_return=[1.0], + ), + retries=200 if is_aws_cloud() else 20, + sleep=10 if is_aws_cloud() else 1, + ) + snapshot.match("get-metric-data-invocations", invocation_res) + + # wait for "Errors" + error_res = retry( + lambda: self._wait_for_lambda_metric( + aws_client.cloudwatch, + fn_name=fn_name, + metric_name="Errors", + expected_return=[1.0], + ), + retries=200 if is_aws_cloud() else 20, + sleep=10 if is_aws_cloud() else 1, + ) + snapshot.match("get-metric-data-errors", error_res) + + def _wait_for_lambda_metric( + self, + cloudwatch_client: "CloudWatchClient", + fn_name: str, + metric_name: str, + expected_return: list[float], + ): + namespace = "AWS/Lambda" + dimension = [{"Name": "FunctionName", "Value": fn_name}] + metric_query = { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": namespace, + "MetricName": metric_name, + "Dimensions": dimension, + }, + "Period": 3600, + "Stat": "Sum", + }, + } + res = cloudwatch_client.get_metric_data( + MetricDataQueries=[metric_query], + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + ) + assert res["MetricDataResults"][0]["Values"] == expected_return + return res + + +class TestSqsApproximateMetrics: + @markers.aws.validated + def test_sqs_approximate_metrics(self, aws_client, sqs_create_queue): + queue_names = [] + for _ in range(0, 10): + q_name = f"my-test-queue-{short_uid()}" + sqs_create_queue(QueueName=q_name) + queue_names.append(q_name) + + for queue in queue_names: + retry( + lambda: self._assert_approximate_metrics_for_queue( + aws_client.cloudwatch, queue_name=queue + ), + retries=70, # should be reported every 60 seconds on LS + sleep=10 if is_aws_cloud() else 1, + ) + + def _assert_approximate_metrics_for_queue( + self, + cloudwatch_client: "CloudWatchClient", + queue_name: str, + ): + namespace = "AWS/SQS" + dimension = [{"Name": "QueueName", "Value": queue_name}] + + res = cloudwatch_client.list_metrics(Namespace=namespace, Dimensions=dimension) + metric_names = [m["MetricName"] for m in res["Metrics"]] + assert "ApproximateNumberOfMessagesVisible" in metric_names + assert "ApproximateNumberOfMessagesNotVisible" in metric_names + assert "ApproximateNumberOfMessagesDelayed" in metric_names + return res + + +class TestSQSMetrics: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..MetricAlarms..StateReason", + "$..MetricAlarms..StateReasonData.evaluatedDatapoints", + "$..MetricAlarms..StateReasonData.startDate", + "$..MetricAlarms..StateTransitionedTimestamp", + "$..NewStateReason", + ] + ) + def test_alarm_number_of_messages_sent( + self, aws_client, sns_create_topic, sqs_create_queue, cleanups, snapshot + ): + snapshot.add_transformer(snapshot.transform.cloudwatch_api()) + # transform the date, that is part of the StateReason + # eg. "Threshold Crossed: 1 datapoint [1.0 (03/01/24 11:36:00)] was greater than the threshold (0.0).", + snapshot.add_transformer( + # regex to transform date-pattern, e.g. (03/01/24 11:36:00) + snapshot.transform.regex( + r"\(\d{2}\/\d{2}\/\d{2}\ \d{2}:\d{2}:\d{2}\)", "(MM/DD/YY HH:MM:SS)" + ) + ) + # sns topic -> will be notified by alarm + sns_topic_alarm = sns_create_topic() + topic_arn_alarm = sns_topic_alarm["TopicArn"] + snapshot.add_transformer( + snapshot.transform.regex(extract_resource_from_arn(topic_arn_alarm), "") + ) + + # sqs queue will subscribe to sns topic + # -> so we can check the alarm action was triggered + sqs_url_alarm_triggered_check = sqs_create_queue() + sqs_arn_alarm_triggered = aws_client.sqs.get_queue_attributes( + QueueUrl=sqs_url_alarm_triggered_check, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + # set policy - required for AWS: + aws_client.sqs.set_queue_attributes( + QueueUrl=sqs_url_alarm_triggered_check, + Attributes={"Policy": get_sqs_policy(sqs_arn_alarm_triggered, topic_arn_alarm)}, + ) + # add subscription + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn_alarm, Protocol="sqs", Endpoint=sqs_arn_alarm_triggered + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) + ) + queue_name = f"queue-to-watch-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(queue_name, "")) + + alarm_name = f"check_sqs_messages_{short_uid()}" + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription="test messages sent", + MetricName="NumberOfMessagesSent", + Namespace="AWS/SQS", + ActionsEnabled=True, + Period=60, + Threshold=0, + Dimensions=[{"Name": "QueueName", "Value": queue_name}], + Statistic="SampleCount", + OKActions=[], + AlarmActions=[topic_arn_alarm], + EvaluationPeriods=1, + ComparisonOperator="GreaterThanThreshold", + TreatMissingData="missing", + ) + cleanups.append(lambda: aws_client.cloudwatch.delete_alarms(AlarmNames=[alarm_name])) + + # create the sqs test queue, where we will manually send a message to + # and will set an alarm specific for that queue + # it should automatically report the metric "NumberOfMessagesSent" + sqs_test_queue_url = sqs_create_queue(QueueName=queue_name) + aws_client.sqs.send_message(QueueUrl=sqs_test_queue_url, MessageBody="new message") + + retry( + self._verify_alarm_triggered, + retries=60, + sleep=3 if is_aws_cloud() else 1, + cloudwatch_client=aws_client.cloudwatch, + sqs_client=aws_client.sqs, + alarm_name=alarm_name, + sqs_queue_url=sqs_url_alarm_triggered_check, + identifier="NumberOfMessagesSent", + snapshot=snapshot, + ) + + def _verify_alarm_triggered( + self, + cloudwatch_client: "CloudWatchClient", + sqs_client: "SQSClient", + alarm_name: str, + sqs_queue_url: str, + identifier: str, + snapshot, + ): + response = cloudwatch_client.describe_alarms(AlarmNames=[alarm_name]) + assert response["MetricAlarms"][0]["StateValue"] == "ALARM" + + result = sqs_client.receive_message(QueueUrl=sqs_queue_url, VisibilityTimeout=0) + msg = result["Messages"][0] + body = json.loads(msg["Body"]) + message = json.loads(body["Message"]) + sqs_client.delete_message(QueueUrl=sqs_queue_url, ReceiptHandle=msg["ReceiptHandle"]) + assert message["NewStateValue"] == "ALARM" + snapshot.match(f"{identifier}-describe", response) + snapshot.match(f"{identifier}-sqs-msg", message) diff --git a/tests/aws/services/cloudwatch/test_cloudwatch_metrics.snapshot.json b/tests/aws/services/cloudwatch/test_cloudwatch_metrics.snapshot.json new file mode 100644 index 0000000000000..0044df29c00bb --- /dev/null +++ b/tests/aws/services/cloudwatch/test_cloudwatch_metrics.snapshot.json @@ -0,0 +1,192 @@ +{ + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestCloudWatchLambdaMetrics::test_lambda_invoke_successful": { + "recorded-date": "15-11-2023, 19:46:04", + "recorded-content": { + "invoke": { + "ExecutedVersion": "$LATEST", + "Payload": { + "success": "ok" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-metric-data": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "m1", + "Label": "Invocations", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestCloudWatchLambdaMetrics::test_lambda_invoke_error": { + "recorded-date": "15-11-2023, 19:49:06", + "recorded-content": { + "invoke": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "fail on purpose", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 3, in handler\n raise Exception('fail on purpose')\n" + ] + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-metric-data-invocations": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "m1", + "Label": "Invocations", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-metric-data-errors": { + "Messages": [], + "MetricDataResults": [ + { + "Id": "m1", + "Label": "Errors", + "StatusCode": "Complete", + "Timestamps": "timestamp", + "Values": [ + 1.0 + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestSQSMetrics::test_alarm_number_of_messages_sent": { + "recorded-date": "03-01-2024, 11:59:08", + "recorded-content": { + "NumberOfMessagesSent-describe": { + "CompositeAlarms": [], + "MetricAlarms": [ + { + "ActionsEnabled": true, + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "timestamp", + "AlarmDescription": "test messages sent", + "AlarmName": "", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "QueueName", + "Value": "" + } + ], + "EvaluationPeriods": 1, + "InsufficientDataActions": [], + "MetricName": "NumberOfMessagesSent", + "Namespace": "", + "OKActions": [], + "Period": 60, + "StateReason": "Threshold Crossed: 1 datapoint [1.0 (MM/DD/YY HH:MM:SS)] was greater than the threshold (0.0).", + "StateReasonData": { + "version": "1.0", + "queryDate": "date", + "startDate": "date", + "statistic": "SampleCount", + "period": 60, + "recentDatapoints": [ + 1.0 + ], + "threshold": 0.0, + "evaluatedDatapoints": [ + { + "timestamp": "date", + "sampleCount": 1.0, + "value": 1.0 + } + ] + }, + "StateTransitionedTimestamp": "timestamp", + "StateUpdatedTimestamp": "timestamp", + "StateValue": "ALARM", + "Statistic": "SampleCount", + "Threshold": 0.0, + "TreatMissingData": "missing" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "NumberOfMessagesSent-sqs-msg": { + "AWSAccountId": "111111111111", + "AlarmActions": [ + "arn::sns::111111111111:" + ], + "AlarmArn": "arn::cloudwatch::111111111111:alarm:", + "AlarmConfigurationUpdatedTimestamp": "date", + "AlarmDescription": "test messages sent", + "AlarmName": "", + "InsufficientDataActions": [], + "NewStateReason": "Threshold Crossed: 1 datapoint [1.0 (MM/DD/YY HH:MM:SS)] was greater than the threshold (0.0).", + "NewStateValue": "ALARM", + "OKActions": [], + "OldStateValue": "INSUFFICIENT_DATA", + "Region": "", + "StateChangeTime": "date", + "Trigger": { + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "name": "QueueName", + "value": "" + } + ], + "EvaluateLowSampleCountPercentile": "", + "EvaluationPeriods": 1, + "MetricName": "NumberOfMessagesSent", + "Namespace": "", + "Period": 60, + "Statistic": "SAMPLE_COUNT", + "StatisticType": "Statistic", + "Threshold": 0.0, + "TreatMissingData": "missing", + "Unit": null + } + } + } + } +} diff --git a/tests/aws/services/cloudwatch/test_cloudwatch_metrics.validation.json b/tests/aws/services/cloudwatch/test_cloudwatch_metrics.validation.json new file mode 100644 index 0000000000000..b348347123a77 --- /dev/null +++ b/tests/aws/services/cloudwatch/test_cloudwatch_metrics.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestCloudWatchLambdaMetrics::test_lambda_invoke_error": { + "last_validated_date": "2023-11-15T18:49:06+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestCloudWatchLambdaMetrics::test_lambda_invoke_successful": { + "last_validated_date": "2023-11-15T18:46:04+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestSqsApproximateMetrics::test_sqs_approximate_metrics": { + "last_validated_date": "2024-01-02T11:33:18+00:00" + }, + "tests/aws/services/cloudwatch/test_cloudwatch_metrics.py::TestSQSMetrics::test_alarm_number_of_messages_sent": { + "last_validated_date": "2024-01-03T12:05:01+00:00" + } +} diff --git a/tests/aws/services/cloudwatch/test_cloudwatch_performance.py b/tests/aws/services/cloudwatch/test_cloudwatch_performance.py new file mode 100644 index 0000000000000..065a8fecccfd7 --- /dev/null +++ b/tests/aws/services/cloudwatch/test_cloudwatch_performance.py @@ -0,0 +1,251 @@ +import logging +import threading +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +import pytest +from botocore.config import Config + +from localstack.config import is_env_true +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +if TYPE_CHECKING: + from mypy_boto3_cloudwatch import CloudWatchClient + +# reusing the same ENV as for test_lambda_performance +if not is_env_true("TEST_PERFORMANCE"): + pytest.skip("Skip slow and resource-intensive tests", allow_module_level=True) + + +LOG = logging.getLogger(__name__) + +CUSTOM_CLIENT_CONFIG_RETRY = Config( + connect_timeout=60, + read_timeout=60, + retries={"max_attempts": 3}, # increase retries in case LS cannot accept connections anymore + max_pool_connections=3000, +) + +ACTION_LAMBDA = """ +def handler(event, context): + import json + print(json.dumps(event)) + return {"success": True} +""" + + +def _delete_alarms(cloudwatch_client: "CloudWatchClient"): + response = cloudwatch_client.describe_alarms() + metric_alarms = [m["AlarmName"] for m in response["MetricAlarms"]] + while next_token := response.get("NextToken"): + response = cloudwatch_client.describe_alarms(NextToken=next_token) + metric_alarms += [m["AlarmName"] for m in response["MetricAlarms"]] + + cloudwatch_client.delete_alarms(AlarmNames=metric_alarms) + + +class TestCloudWatchPerformance: + @markers.aws.only_localstack + def test_parallel_put_metric_data_list_metrics(self, aws_client, aws_client_factory): + num_threads = 1200 + create_barrier = threading.Barrier(num_threads) + error_counter = Counter() + namespace = f"namespace-{short_uid()}" + + def _put_metric_list_metrics(runner: int): + nonlocal error_counter + nonlocal create_barrier + nonlocal namespace + create_barrier.wait() + try: + cw_client = aws_client_factory(config=CUSTOM_CLIENT_CONFIG_RETRY).cloudwatch + if runner % 2: + cw_client.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": f"metric-{runner}-1", + "Value": 25, + "Unit": "Seconds", + }, + { + "MetricName": f"metric-{runner}-2", + "Value": runner + 1, + "Unit": "Seconds", + }, + ], + ) + else: + cw_client.list_metrics() + except Exception as e: + LOG.exception("runner %s failed: %s", runner, e) + error_counter.increment() + + start_time = datetime.utcnow() + thread_list = [] + for i in range(1, num_threads + 1): + thread = threading.Thread(target=_put_metric_list_metrics, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + + end_time = datetime.utcnow() + diff = end_time - start_time + LOG.info("N=%s took %s seconds", num_threads, diff.total_seconds()) + + assert error_counter.get_value() == 0 + metrics = [] + result = aws_client.cloudwatch.list_metrics(Namespace=namespace) + metrics += result["Metrics"] + + while next_token := result.get("NextToken"): + result = aws_client.cloudwatch.list_metrics(NextToken=next_token, Namespace=namespace) + metrics += result["Metrics"] + + assert 1200 == len(metrics) # every second thread inserted two metrics + + @markers.aws.only_localstack + def test_run_100_alarms( + self, aws_client, aws_client_factory, create_lambda_function, cleanups, account_id + ): + # create 100 alarms then add metrics + # alarms should trigger + fn_name = f"fn-cw-{short_uid()}" + response = create_lambda_function( + func_name=fn_name, + handler_file=ACTION_LAMBDA, + runtime="python3.11", + ) + function_arn = response["CreateFunctionResponse"]["FunctionArn"] + cleanups.append(lambda: _delete_alarms(aws_client.cloudwatch)) + random_id = short_uid() + namespace = f"ns-{random_id}" + for i in range(0, 100): + # add 100 alarms (we can do this sequentially, they will start checking for matches in the background) + alarm_name = f"alarm-{random_id}-{i}" + aws_client.cloudwatch.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription="testing lambda alarm action", + MetricName=f"metric-{i}", + Namespace=namespace, + Period=10, + Threshold=2, + Statistic="Average", + OKActions=[], + AlarmActions=[function_arn], + EvaluationPeriods=1, + ComparisonOperator="GreaterThanThreshold", + TreatMissingData="ignore", + ) + alarm_arn = aws_client.cloudwatch.describe_alarms(AlarmNames=[alarm_name])[ + "MetricAlarms" + ][0]["AlarmArn"] + + # allow cloudwatch to trigger the lambda + aws_client.lambda_.add_permission( + FunctionName=fn_name, + StatementId=f"AlarmAction-{i}", + Action="lambda:InvokeFunction", + Principal="lambda.alarms.cloudwatch.amazonaws.com", + SourceAccount=account_id, + SourceArn=alarm_arn, + ) + # add metrics in parallel + num_threads = 300 + create_barrier = threading.Barrier(num_threads) + error_counter = Counter() + + def _put_metric_data(runner: int): + nonlocal error_counter + nonlocal create_barrier + nonlocal namespace + create_barrier.wait() + try: + metric_name = f"metric-{runner % 100}" + cw_client = aws_client_factory(config=CUSTOM_CLIENT_CONFIG_RETRY).cloudwatch + cw_client.put_metric_data( + Namespace=namespace, + MetricData=[ + { + "MetricName": metric_name, + "Value": 25, + }, + { + "MetricName": metric_name, + "Value": 20 + runner, + }, + ], + ) + except Exception as e: + LOG.exception("runner %s failed: %s", runner, e) + error_counter.increment() + + start_time = datetime.utcnow() + thread_list = [] + for i in range(0, num_threads): + thread = threading.Thread(target=_put_metric_data, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + + end_time = datetime.utcnow() + diff = end_time - start_time + LOG.info("N=%s took %s seconds", num_threads, diff.total_seconds()) + + assert error_counter.get_value() == 0 + metrics = [] + result = aws_client.cloudwatch.list_metrics(Namespace=namespace) + metrics += result["Metrics"] + + while next_token := result.get("NextToken"): + result = aws_client.cloudwatch.list_metrics(NextToken=next_token, Namespace=namespace) + metrics += result["Metrics"] + + assert 100 == len(metrics) + + def _assert_lambda_invocation(): + metric_query_params = { + "Namespace": "AWS/Lambda", + "MetricName": "Invocations", + "Dimensions": [{"Name": "FunctionName", "Value": fn_name}], + "StartTime": start_time, + "EndTime": end_time + timedelta(minutes=20), + "Period": 3600, # in seconds + "Statistics": ["Sum"], + } + response = aws_client.cloudwatch.get_metric_statistics(**metric_query_params) + num_invocations_metric = 0 + for datapoint in response["Datapoints"]: + num_invocations_metric += int(datapoint["Sum"]) + # assert num_invocations_metric == num_invocations + assert num_invocations_metric == 100 + + retry( + lambda: _assert_lambda_invocation(), + retries=200, + sleep=5, + ) + + @markers.aws.only_localstack + def test_sqs_queue_integration(self, aws_client, aws_client_factory): + pass + + +class Counter: + def __init__(self): + self.value = 0 + self.lock = threading.Lock() + + def increment(self): + with self.lock: + self.value += 1 + + def get_value(self): + with self.lock: + return self.value diff --git a/tests/aws/services/config/__init__.py b/tests/aws/services/config/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/dynamodb/__init__.py b/tests/aws/services/dynamodb/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/dynamodb/test_dynamodb.py b/tests/aws/services/dynamodb/test_dynamodb.py new file mode 100644 index 0000000000000..2c0ab3e50b42f --- /dev/null +++ b/tests/aws/services/dynamodb/test_dynamodb.py @@ -0,0 +1,2560 @@ +import json +import re +import time +from datetime import datetime +from time import sleep +from typing import Dict + +import botocore.exceptions +import pytest +import requests +from boto3.dynamodb.types import STRING +from botocore.config import Config +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack import config +from localstack.aws.api.dynamodb import ( + PointInTimeRecoverySpecification, + StreamSpecification, + StreamViewType, +) +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.services.dynamodbstreams.dynamodbstreams_api import get_kinesis_stream_name +from localstack.testing.aws.lambda_utils import _await_dynamodb_table_active +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils import testutil +from localstack.utils.aws import arns, queries, resources +from localstack.utils.aws.resources import create_dynamodb_table +from localstack.utils.common import json_safe, long_uid, retry, short_uid +from localstack.utils.sync import poll_condition, wait_until +from tests.aws.services.kinesis.test_kinesis import get_shard_iterator + +PARTITION_KEY = "id" + +TEST_DDB_TAGS = [ + {"Key": "Name", "Value": "test-table"}, + {"Key": "TestKey", "Value": "true"}, +] + +WAIT_SEC = 10 if is_aws_cloud() else 1 + + +@pytest.fixture(autouse=True) +def dynamodb_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + +@pytest.fixture +def dynamodbstreams_snapshot_transformers(snapshot): + snapshot.add_transformer(snapshot.transform.dynamodb_streams_api()) + snapshot.add_transformer(snapshot.transform.key_value("NextShardIterator"), priority=-1) + snapshot.add_transformer(snapshot.transform.key_value("ShardIterator"), priority=-1) + + +class TestDynamoDB: + @pytest.fixture + def ddb_test_table(self, aws_client) -> str: + """ + This fixture returns a DynamoDB table for testing. + """ + table_name = f"ddb-test-table-{short_uid()}" + resources.create_dynamodb_table( + table_name, partition_key=PARTITION_KEY, client=aws_client.dynamodb + ) + + yield table_name + + aws_client.dynamodb.delete_table(TableName=table_name) + + @markers.aws.only_localstack + def test_non_ascii_chars(self, aws_client, ddb_test_table): + # write some items containing non-ASCII characters + items = { + "id1": {PARTITION_KEY: {"S": "id1"}, "data": {"S": "foobar123 βœ“"}}, + "id2": {PARTITION_KEY: {"S": "id2"}, "data": {"S": "foobar123 Β£"}}, + "id3": {PARTITION_KEY: {"S": "id3"}, "data": {"S": "foobar123 Β’"}}, + } + for _, item in items.items(): + aws_client.dynamodb.put_item(TableName=ddb_test_table, Item=item) + + for item_id in items.keys(): + item = aws_client.dynamodb.get_item( + TableName=ddb_test_table, Key={PARTITION_KEY: {"S": item_id}} + )["Item"] + + # need to fix up the JSON and convert str to unicode for Python 2 + item1 = json_safe(item) + item2 = json_safe(items[item_id]) + assert item1 == item2 + + @markers.aws.only_localstack + def test_large_data_download(self, aws_client, ddb_test_table): + # Create a large amount of items + num_items = 20 + for i in range(0, num_items): + item = {PARTITION_KEY: {"S": "id%s" % i}, "data1": {"S": "foobar123 " * 1000}} + aws_client.dynamodb.put_item(TableName=ddb_test_table, Item=item) + + # Retrieve the items. The data will be transmitted to the client with chunked transfer encoding + result = aws_client.dynamodb.scan(TableName=ddb_test_table) + assert len(result["Items"]) == num_items + + @markers.aws.only_localstack + def test_time_to_live_deletion(self, aws_client, ddb_test_table, cleanups): + table_name = ddb_test_table + # Note: we use a reserved keyboard (ttl) as an attribute name for the time to live specification to make sure + # that the deletion logic works also in this case. + aws_client.dynamodb.update_time_to_live( + TableName=table_name, + TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}, + ) + aws_client.dynamodb.describe_time_to_live(TableName=table_name) + + exp = int(time.time()) - 10 # expired + items = [ + {PARTITION_KEY: {"S": "expired"}, "ttl": {"N": str(exp)}}, + {PARTITION_KEY: {"S": "not-expired"}, "ttl": {"N": str(exp + 120)}}, + ] + for item in items: + aws_client.dynamodb.put_item(TableName=table_name, Item=item) + + url = f"{config.internal_service_url()}/_aws/dynamodb/expired" + response = requests.delete(url) + assert response.status_code == 200 + assert response.json() == {"ExpiredItems": 1} + + result = aws_client.dynamodb.get_item( + TableName=table_name, Key={PARTITION_KEY: {"S": "not-expired"}} + ) + assert result.get("Item") + result = aws_client.dynamodb.get_item( + TableName=table_name, Key={PARTITION_KEY: {"S": "expired"}} + ) + assert not result.get("Item") + + # create a table with a range key + table_with_range_key = f"test-table-{short_uid()}" + aws_client.dynamodb.create_table( + TableName=table_with_range_key, + KeySchema=[ + {"AttributeName": "id", "KeyType": "HASH"}, + {"AttributeName": "range", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + {"AttributeName": "range", "AttributeType": "S"}, + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + cleanups.append(lambda: aws_client.dynamodb.delete_table(TableName=table_with_range_key)) + aws_client.dynamodb.update_time_to_live( + TableName=table_with_range_key, + TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}, + ) + exp = int(time.time()) - 10 # expired + items = [ + { + PARTITION_KEY: {"S": "expired"}, + "range": {"S": "range_one"}, + "ttl": {"N": str(exp)}, + }, + { + PARTITION_KEY: {"S": "not-expired"}, + "range": {"S": "range_two"}, + "ttl": {"N": str(exp + 120)}, + }, + ] + for item in items: + aws_client.dynamodb.put_item(TableName=table_with_range_key, Item=item) + + url = f"{config.internal_service_url()}/_aws/dynamodb/expired" + response = requests.delete(url) + assert response.status_code == 200 + assert response.json() == {"ExpiredItems": 1} + + result = aws_client.dynamodb.get_item( + TableName=table_with_range_key, + Key={PARTITION_KEY: {"S": "not-expired"}, "range": {"S": "range_two"}}, + ) + assert result.get("Item") + result = aws_client.dynamodb.get_item( + TableName=table_with_range_key, + Key={PARTITION_KEY: {"S": "expired"}, "range": {"S": "range_one"}}, + ) + assert not result.get("Item") + + @markers.aws.only_localstack + def test_time_to_live(self, aws_client, ddb_test_table): + # check response for nonexistent table + response = testutil.send_describe_dynamodb_ttl_request("test") + assert json.loads(response._content)["__type"] == "ResourceNotFoundException" + assert response.status_code == 400 + + response = testutil.send_update_dynamodb_ttl_request("test", True) + assert json.loads(response._content)["__type"] == "ResourceNotFoundException" + assert response.status_code == 400 + + # Insert some items to the table + items = { + "id1": {PARTITION_KEY: {"S": "id1"}, "data": {"S": "IT IS"}}, + "id2": {PARTITION_KEY: {"S": "id2"}, "data": {"S": "TIME"}}, + "id3": {PARTITION_KEY: {"S": "id3"}, "data": {"S": "TO LIVE!"}}, + } + + for _, item in items.items(): + aws_client.dynamodb.put_item(TableName=ddb_test_table, Item=item) + + # Describe TTL when still unset + response = testutil.send_describe_dynamodb_ttl_request(ddb_test_table) + assert response.status_code == 200 + assert ( + json.loads(response._content)["TimeToLiveDescription"]["TimeToLiveStatus"] == "DISABLED" + ) + + # Enable TTL for given table + response = testutil.send_update_dynamodb_ttl_request(ddb_test_table, True) + assert response.status_code == 200 + assert json.loads(response._content)["TimeToLiveSpecification"]["Enabled"] + + # Describe TTL status after being enabled. + response = testutil.send_describe_dynamodb_ttl_request(ddb_test_table) + assert response.status_code == 200 + assert ( + json.loads(response._content)["TimeToLiveDescription"]["TimeToLiveStatus"] == "ENABLED" + ) + + # Disable TTL for given table + response = testutil.send_update_dynamodb_ttl_request(ddb_test_table, False) + assert response.status_code == 200 + assert not json.loads(response._content)["TimeToLiveSpecification"]["Enabled"] + + # Describe TTL status after being disabled. + response = testutil.send_describe_dynamodb_ttl_request(ddb_test_table) + assert response.status_code == 200 + assert ( + json.loads(response._content)["TimeToLiveDescription"]["TimeToLiveStatus"] == "DISABLED" + ) + + # Enable TTL for given table again + response = testutil.send_update_dynamodb_ttl_request(ddb_test_table, True) + assert response.status_code == 200 + assert json.loads(response._content)["TimeToLiveSpecification"]["Enabled"] + + # Describe TTL status after being enabled again. + response = testutil.send_describe_dynamodb_ttl_request(ddb_test_table) + assert response.status_code == 200 + assert ( + json.loads(response._content)["TimeToLiveDescription"]["TimeToLiveStatus"] == "ENABLED" + ) + + @markers.aws.only_localstack + def test_list_tags_of_resource(self, aws_client): + table_name = "ddb-table-%s" % short_uid() + + rs = aws_client.dynamodb.create_table( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + ) + table_arn = rs["TableDescription"]["TableArn"] + + rs = aws_client.dynamodb.list_tags_of_resource(ResourceArn=table_arn) + + assert rs["Tags"] == TEST_DDB_TAGS + + aws_client.dynamodb.tag_resource( + ResourceArn=table_arn, Tags=[{"Key": "NewKey", "Value": "TestValue"}] + ) + + rs = aws_client.dynamodb.list_tags_of_resource(ResourceArn=table_arn) + + assert len(rs["Tags"]) == len(TEST_DDB_TAGS) + 1 + + tags = {tag["Key"]: tag["Value"] for tag in rs["Tags"]} + assert "NewKey" in tags + assert tags["NewKey"] == "TestValue" + + aws_client.dynamodb.untag_resource(ResourceArn=table_arn, TagKeys=["Name", "NewKey"]) + + rs = aws_client.dynamodb.list_tags_of_resource(ResourceArn=table_arn) + tags = {tag["Key"]: tag["Value"] for tag in rs["Tags"]} + assert "Name" not in tags.keys() + assert "NewKey" not in tags.keys() + + aws_client.dynamodb.delete_table(TableName=table_name) + + @markers.aws.only_localstack + def test_multiple_update_expressions(self, aws_client, ddb_test_table): + item_id = short_uid() + aws_client.dynamodb.put_item( + TableName=ddb_test_table, + Item={PARTITION_KEY: {"S": item_id}, "data": {"S": "foobar123 βœ“"}}, + ) + response = aws_client.dynamodb.update_item( + TableName=ddb_test_table, + Key={PARTITION_KEY: {"S": item_id}}, + UpdateExpression="SET attr1 = :v1, attr2 = :v2", + ExpressionAttributeValues={":v1": {"S": "value1"}, ":v2": {"S": "value2"}}, + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + item = aws_client.dynamodb.get_item( + TableName=ddb_test_table, Key={PARTITION_KEY: {"S": item_id}} + )["Item"] + assert item["attr1"] == {"S": "value1"} + assert item["attr2"] == {"S": "value2"} + attributes = [{"AttributeName": "id", "AttributeType": STRING}] + + user_id_idx = [ + { + "Create": { + "IndexName": "id-index", + "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}], + "Projection": { + "ProjectionType": "INCLUDE", + "NonKeyAttributes": ["data"], + }, + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + }, + } + }, + ] + + # for each index + aws_client.dynamodb.update_table( + TableName=ddb_test_table, + AttributeDefinitions=attributes, + GlobalSecondaryIndexUpdates=user_id_idx, + ) + + with pytest.raises(Exception) as ctx: + aws_client.dynamodb.query( + TableName=ddb_test_table, + IndexName="id-index", + KeyConditionExpression=f"{PARTITION_KEY} = :item", + ExpressionAttributeValues={":item": {"S": item_id}}, + Select="ALL_ATTRIBUTES", + ) + assert ctx.match("ValidationException") + + @markers.aws.only_localstack + def test_invalid_query_index(self, aws_client): + """Raises an exception when a query requests ALL_ATTRIBUTES, + but the index does not have a ProjectionType of ALL""" + table_name = f"test-table-{short_uid()}" + aws_client.dynamodb.create_table( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + {"AttributeName": "field_a", "AttributeType": "S"}, + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + GlobalSecondaryIndexes=[ + { + "IndexName": "field_a_index", + "KeySchema": [{"AttributeName": "field_a", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "KEYS_ONLY"}, + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1, + }, + }, + ], + ) + + with pytest.raises(Exception) as ctx: + aws_client.dynamodb.query( + TableName=table_name, + IndexName="field_a_index", + KeyConditionExpression="field_a = :field_value", + ExpressionAttributeValues={":field_value": {"S": "xyz"}}, + Select="ALL_ATTRIBUTES", + ) + assert ctx.match("ValidationException") + + # clean up + aws_client.dynamodb.delete_table(TableName=table_name) + + @markers.aws.only_localstack + def test_valid_query_index(self, aws_client): + """Query requests ALL_ATTRIBUTES and the named index has a ProjectionType of ALL, + no exception should be raised.""" + table_name = f"test-table-{short_uid()}" + aws_client.dynamodb.create_table( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + {"AttributeName": "field_a", "AttributeType": "S"}, + {"AttributeName": "field_b", "AttributeType": "S"}, + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + GlobalSecondaryIndexes=[ + { + "IndexName": "field_a_index", + "KeySchema": [{"AttributeName": "field_a", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "KEYS_ONLY"}, + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1, + }, + }, + { + "IndexName": "field_b_index", + "KeySchema": [{"AttributeName": "field_b", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1, + }, + }, + ], + ) + + aws_client.dynamodb.query( + TableName=table_name, + IndexName="field_b_index", + KeyConditionExpression="field_b = :field_value", + ExpressionAttributeValues={":field_value": {"S": "xyz"}}, + Select="ALL_ATTRIBUTES", + ) + + # clean up + aws_client.dynamodb.delete_table(TableName=table_name) + + @markers.aws.validated + def test_valid_local_secondary_index( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"test-table-{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "LSI1SK", "AttributeType": "N"}, + ], + LocalSecondaryIndexes=[ + { + "IndexName": "LSI1", + "KeySchema": [ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "LSI1SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + } + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + ) + + item = {"SK": {"S": "hello"}, "LSI1SK": {"N": "123"}, "PK": {"S": "test one"}} + + aws_client.dynamodb.put_item(TableName=table_name, Item=item) + result = aws_client.dynamodb.query( + TableName=table_name, + IndexName="LSI1", + KeyConditionExpression="PK = :v1", + ExpressionAttributeValues={":v1": {"S": "test one"}}, + Select="ALL_ATTRIBUTES", + ) + transformed_dict = SortingTransformer("Items", lambda x: x).transform(result) + snapshot.match("Items", transformed_dict) + + @markers.aws.only_localstack(reason="AWS has a 20 GSI limit") + def test_more_than_20_global_secondary_indexes( + self, dynamodb_create_table_with_parameters, aws_client + ): + table_name = f"test-table-{short_uid()}" + num_gsis = 25 + attrs = [{"AttributeName": f"a{i}", "AttributeType": "S"} for i in range(num_gsis)] + gsis = [ + { + "IndexName": f"gsi_{i}", + "KeySchema": [{"AttributeName": f"a{i}", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + for i in range(num_gsis) + ] + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}, *attrs], + GlobalSecondaryIndexes=gsis, + BillingMode="PAY_PER_REQUEST", + ) + + table = aws_client.dynamodb.describe_table(TableName=table_name) + assert len(table["Table"]["GlobalSecondaryIndexes"]) == num_gsis + + @markers.aws.validated + def test_return_values_in_put_item(self, snapshot, aws_client, ddb_test_table): + # items which are being used to put in the table + item1 = {PARTITION_KEY: {"S": "id1"}, "data": {"S": "foobar"}} + item1b = {PARTITION_KEY: {"S": "id1"}, "data": {"S": "barfoo"}} + item2 = {PARTITION_KEY: {"S": "id1"}, "data": {"S": "foobar"}} + + # there is no data present in the table already so even if return values + # is set to 'ALL_OLD' as there is no data it will not return any data. + response = aws_client.dynamodb.put_item( + TableName=ddb_test_table, Item=item1, ReturnValues="ALL_OLD" + ) + snapshot.match("PutFirstItem", response) + + # now the same data is present so when we pass return values as 'ALL_OLD' + # it should give us attributes + response = aws_client.dynamodb.put_item( + TableName=ddb_test_table, Item=item1, ReturnValues="ALL_OLD" + ) + snapshot.match("PutFirstItemOLD", response) + + # now a previous version of data is present, so when we pass return + # values as 'ALL_OLD' it should give us the old attributes + response = aws_client.dynamodb.put_item( + TableName=ddb_test_table, Item=item1b, ReturnValues="ALL_OLD" + ) + snapshot.match("PutFirstItemB", response) + + # we do not have any same item as item2 already so when we add this by default + # return values is set to None so no Attribute values should be returned + response = aws_client.dynamodb.put_item(TableName=ddb_test_table, Item=item2) + snapshot.match("PutSecondItem", response) + + # in this case we already have item2 in the table so on this request + # it should not return any data as return values is set to None so no + # Attribute values should be returned + response = aws_client.dynamodb.put_item(TableName=ddb_test_table, Item=item2) + snapshot.match("PutSecondItemReturnNone", response) + + @markers.aws.validated + def test_empty_and_binary_values(self, snapshot, aws_client): + table_name = f"table-{short_uid()}" + resources.create_dynamodb_table( + table_name=table_name, partition_key=PARTITION_KEY, client=aws_client.dynamodb + ) + + # items which are being used to put in the table + item1 = {PARTITION_KEY: {"S": "id1"}, "data": {"S": ""}} + item2 = {PARTITION_KEY: {"S": "id2"}, "data": {"B": b"\x90"}} + + response = aws_client.dynamodb.put_item(TableName=table_name, Item=item1) + snapshot.match("PutFirstItem", response) + + response = aws_client.dynamodb.put_item(TableName=table_name, Item=item2) + snapshot.match("PutSecondItem", response) + + # clean up + aws_client.dynamodb.delete_table(TableName=table_name) + + @markers.aws.validated + def test_batch_write_binary(self, dynamodb_create_table_with_parameters, snapshot, aws_client): + table_name = f"table_batch_binary_{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + aws_client.dynamodb.put_item( + TableName=table_name, + Item={"PK": {"S": "hello"}, "SK": {"S": "user"}, "data": {"B": b"test"}}, + ) + + item = { + "Item": { + "PK": {"S": "hello-1"}, + "SK": {"S": "user-1"}, + "data": {"B": b"test-1"}, + } + } + item_non_decodable = { + "Item": { + "PK": {"S": "hello-2"}, + "SK": {"S": "user-2"}, + "data": {"B": b"test \xc0 \xed"}, + } + } + response = aws_client.dynamodb.batch_write_item( + RequestItems={table_name: [{"PutRequest": item}, {"PutRequest": item_non_decodable}]} + ) + snapshot.match("Response", response) + + @markers.aws.only_localstack + @pytest.mark.skipif( + condition=config.DDB_STREAMS_PROVIDER_V2, + reason="Logic is tied with Kinesis", + ) + def test_binary_data_with_stream( + self, wait_for_stream_ready, dynamodb_create_table_with_parameters, aws_client + ): + table_name = f"table-{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + StreamSpecification={ + "StreamEnabled": True, + "StreamViewType": "NEW_AND_OLD_IMAGES", + }, + ) + stream_name = get_kinesis_stream_name(table_name) + wait_for_stream_ready(stream_name) + response = aws_client.dynamodb.put_item( + TableName=table_name, Item={"id": {"S": "id1"}, "data": {"B": b"\x90"}} + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + iterator = get_shard_iterator(stream_name, aws_client.kinesis) + response = aws_client.kinesis.get_records(ShardIterator=iterator) + json_records = response.get("Records") + assert 1 == len(json_records) + assert "Data" in json_records[0] + + @markers.aws.only_localstack + def test_dynamodb_stream_shard_iterator( + self, aws_client, wait_for_dynamodb_stream_ready, dynamodb_create_table_with_parameters + ): + ddbstreams = aws_client.dynamodbstreams + + table_name = f"table_with_stream-{short_uid()}" + table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + StreamSpecification={ + "StreamEnabled": True, + "StreamViewType": "NEW_IMAGE", + }, + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + stream_arn = table["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn=stream_arn) + + stream_arn = table["TableDescription"]["LatestStreamArn"] + result = ddbstreams.describe_stream(StreamArn=stream_arn) + + response = ddbstreams.get_shard_iterator( + StreamArn=stream_arn, + ShardId=result["StreamDescription"]["Shards"][0]["ShardId"], + ShardIteratorType="LATEST", + ) + assert "ShardIterator" in response + response = ddbstreams.get_shard_iterator( + StreamArn=stream_arn, + ShardId=result["StreamDescription"]["Shards"][0]["ShardId"], + ShardIteratorType="AT_SEQUENCE_NUMBER", + SequenceNumber=result["StreamDescription"]["Shards"][0] + .get("SequenceNumberRange") + .get("StartingSequenceNumber"), + ) + assert "ShardIterator" in response + + @markers.aws.only_localstack + def test_dynamodb_create_table_with_class( + self, dynamodb_create_table_with_parameters, aws_client + ): + table_name = f"table_with_class_{short_uid()}" + # create table + result = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + TableClass="STANDARD", + ) + assert result["TableDescription"]["TableClassSummary"]["TableClass"] == "STANDARD" + result = aws_client.dynamodb.describe_table(TableName=table_name) + assert result["Table"]["TableClassSummary"]["TableClass"] == "STANDARD" + result = aws_client.dynamodb.update_table( + TableName=table_name, TableClass="STANDARD_INFREQUENT_ACCESS" + ) + assert ( + result["TableDescription"]["TableClassSummary"]["TableClass"] + == "STANDARD_INFREQUENT_ACCESS" + ) + result = aws_client.dynamodb.describe_table(TableName=table_name) + assert result["Table"]["TableClassSummary"]["TableClass"] == "STANDARD_INFREQUENT_ACCESS" + + @markers.aws.validated + def test_dynamodb_execute_transaction( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"table_{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + statements = [ + {"Statement": f"INSERT INTO {table_name} VALUE {{'Username': 'user01'}}"}, + {"Statement": f"INSERT INTO {table_name} VALUE {{'Username': 'user02'}}"}, + ] + result = aws_client.dynamodb.execute_transaction(TransactStatements=statements) + snapshot.match("ExecutedTransaction", result) + + result = aws_client.dynamodb.scan(TableName=table_name) + transformed_dict = SortingTransformer("Items", lambda x: x["Username"]["S"]).transform( + result + ) + snapshot.match("TableScan", transformed_dict) + + @markers.aws.validated + def test_dynamodb_batch_execute_statement( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"test_table_{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + aws_client.dynamodb.put_item(TableName=table_name, Item={"Username": {"S": "user02"}}) + statements = [ + {"Statement": f"INSERT INTO {table_name} VALUE {{'Username': 'user01'}}"}, + {"Statement": f"UPDATE {table_name} SET Age=20 WHERE Username='user02'"}, + ] + result = aws_client.dynamodb.batch_execute_statement(Statements=statements) + # actions always succeeds + sorted_result = SortingTransformer("Responses", lambda x: x["TableName"]).transform(result) + snapshot.match("ExecutedStatement", sorted_result) + + item = aws_client.dynamodb.get_item( + TableName=table_name, Key={"Username": {"S": "user02"}} + )["Item"] + snapshot.match("ItemUser2", item) + + item = aws_client.dynamodb.get_item( + TableName=table_name, Key={"Username": {"S": "user01"}} + )["Item"] + snapshot.match("ItemUser1", item) + + aws_client.dynamodb.delete_table(TableName=table_name) + + @markers.aws.validated + def test_dynamodb_execute_statement_empy_parameter( + self, dynamodb_create_table_with_parameters, snapshot, aws_client_factory + ): + ddb_client = aws_client_factory(config=Config(parameter_validation=False)).dynamodb + table_name = f"test_table_{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[ + {"AttributeName": "Artist", "KeyType": "HASH"}, + {"AttributeName": "SongTitle", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "Artist", "AttributeType": "S"}, + {"AttributeName": "SongTitle", "AttributeType": "S"}, + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + ddb_client.put_item( + TableName=table_name, + Item={"Artist": {"S": "The Queen"}, "SongTitle": {"S": "Bohemian Rhapsody"}}, + ) + + statement = f"SELECT * FROM {table_name}" + with pytest.raises(ClientError) as e: + ddb_client.execute_statement(Statement=statement, Parameters=[]) + snapshot.match("invalid-param-error", e.value.response) + + @markers.aws.validated + def test_dynamodb_partiql_missing( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"table_with_stream_{short_uid()}" + + # create table + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + # create items with FirstName attribute + aws_client.dynamodb.execute_statement( + Statement=f"INSERT INTO {table_name} VALUE {{'Username': 'Alice123', 'FirstName':'Alice'}}" + ) + items = aws_client.dynamodb.execute_statement( + Statement=f"SELECT * FROM {table_name} WHERE FirstName IS NOT MISSING" + )["Items"] + snapshot.match("FirstNameNotMissing", items) + + items = aws_client.dynamodb.execute_statement( + Statement=f"SELECT * FROM {table_name} WHERE FirstName IS MISSING" + )["Items"] + assert len(items) == 0 + aws_client.dynamodb.delete_table(TableName=table_name) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SizeBytes", + "$..ProvisionedThroughput.NumberOfDecreasesToday", + "$..StreamDescription.CreationRequestDateTime", + ] + ) + def test_dynamodb_stream_stream_view_type( + self, + aws_client, + dynamodb_create_table_with_parameters, + wait_for_dynamodb_stream_ready, + snapshot, + dynamodbstreams_snapshot_transformers, + ): + dynamodb = aws_client.dynamodb + ddbstreams = aws_client.dynamodbstreams + table_name = f"table_with_stream_{short_uid()}" + + # create table + table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + StreamSpecification={ + "StreamEnabled": True, + "StreamViewType": "KEYS_ONLY", + }, + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + stream_arn = table["TableDescription"]["LatestStreamArn"] + snapshot.match("create-table", table) + wait_for_dynamodb_stream_ready(stream_arn=stream_arn) + + # put item in table - INSERT event + dynamodb.put_item(TableName=table_name, Item={"Username": {"S": "Fred"}}) + # put item again in table - no event as it is the same value + dynamodb.put_item(TableName=table_name, Item={"Username": {"S": "Fred"}}) + + # update item in table - MODIFY event + dynamodb.update_item( + TableName=table_name, + Key={"Username": {"S": "Fred"}}, + UpdateExpression="set S=:r", + ExpressionAttributeValues={":r": {"S": "Fred_Modified"}}, + ReturnValues="UPDATED_NEW", + ) + # delete item in table - REMOVE event + dynamodb.delete_item(TableName=table_name, Key={"Username": {"S": "Fred"}}) + + result = ddbstreams.describe_stream(StreamArn=stream_arn) + snapshot.match("describe-stream", result) + + # assert stream_view_type of the table + assert result["StreamDescription"]["StreamViewType"] == "KEYS_ONLY" + + # add item via PartiQL query - INSERT event + dynamodb.execute_statement( + Statement=f"INSERT INTO {table_name} VALUE {{'Username': 'Alice'}}" + ) + # run update via PartiQL query - MODIFY event + dynamodb.execute_statement( + Statement=f"UPDATE {table_name} SET partiql=1 WHERE Username='Alice'" + ) + # run update via PartiQL query - REMOVE event + dynamodb.execute_statement(Statement=f"DELETE FROM {table_name} WHERE Username='Alice'") + + # get shard iterator + response = ddbstreams.get_shard_iterator( + StreamArn=stream_arn, + ShardId=result["StreamDescription"]["Shards"][0]["ShardId"], + ShardIteratorType="AT_SEQUENCE_NUMBER", + SequenceNumber=result["StreamDescription"]["Shards"][0] + .get("SequenceNumberRange") + .get("StartingSequenceNumber"), + ) + snapshot.match("get-shard-iterator", response) + + shard_iterator = response["ShardIterator"] + # get stream records + records = [] + + def _get_records_amount(record_amount: int): + nonlocal shard_iterator + if len(records) < record_amount: + _resp = aws_client.dynamodbstreams.get_records(ShardIterator=shard_iterator) + records.extend(_resp["Records"]) + if next_shard_iterator := _resp.get("NextShardIterator"): + shard_iterator = next_shard_iterator + + assert len(records) >= record_amount + + retry(lambda: _get_records_amount(6), sleep=1, retries=3) + snapshot.match("get-records", {"Records": records}) + + @markers.aws.only_localstack + @pytest.mark.skipif( + condition=not is_aws_cloud() and config.DDB_STREAMS_PROVIDER_V2, + reason="Not yet implemented in DDB Streams V2", + ) + def test_dynamodb_with_kinesis_stream(self, aws_client): + dynamodb = aws_client.dynamodb + # Kinesis streams can only be in the same account and region as the table. See + # https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/kds.html#kds_howitworks.enabling + kinesis = aws_client.kinesis + + # create kinesis datastream + stream_name = f"kinesis_dest_stream_{short_uid()}" + kinesis.create_stream(StreamName=stream_name, ShardCount=1) + # wait for the stream to be created + sleep(1) + # Get stream description + stream_description = kinesis.describe_stream(StreamName=stream_name)["StreamDescription"] + table_name = f"table_with_kinesis_stream-{short_uid()}" + # create table + dynamodb.create_table( + TableName=table_name, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + # Enable kinesis destination for the table + dynamodb.enable_kinesis_streaming_destination( + TableName=table_name, StreamArn=stream_description["StreamARN"] + ) + + # put item into table + dynamodb.put_item(TableName=table_name, Item={"Username": {"S": "Fred"}}) + + # update item in table + dynamodb.update_item( + TableName=table_name, + Key={"Username": {"S": "Fred"}}, + UpdateExpression="set S=:r", + ExpressionAttributeValues={":r": {"S": "Fred_Modified"}}, + ReturnValues="UPDATED_NEW", + ) + + # delete item in table + dynamodb.delete_item(TableName=table_name, Key={"Username": {"S": "Fred"}}) + + def _fetch_records(): + records = queries.kinesis_get_latest_records( + stream_name, + shard_id=stream_description["Shards"][0]["ShardId"], + client=kinesis, + ) + assert len(records) == 3 + return records + + # get records from the stream + records = retry(_fetch_records) + + for record in records: + record = json.loads(record["Data"]) + assert record["tableName"] == table_name + # check eventSourceARN not exists in the stream record + assert "eventSourceARN" not in record + if record["eventName"] == "INSERT": + assert "OldImage" not in record["dynamodb"] + assert "NewImage" in record["dynamodb"] + elif record["eventName"] == "MODIFY": + assert "NewImage" in record["dynamodb"] + assert "OldImage" in record["dynamodb"] + elif record["eventName"] == "REMOVE": + assert "NewImage" not in record["dynamodb"] + assert "OldImage" in record["dynamodb"] + # describe kinesis streaming destination of the table + destinations = dynamodb.describe_kinesis_streaming_destination(TableName=table_name) + destination = destinations["KinesisDataStreamDestinations"][0] + + # assert kinesis streaming destination status + assert stream_description["StreamARN"] == destination["StreamArn"] + assert destination["DestinationStatus"] == "ACTIVE" + + # Disable kinesis destination + dynamodb.disable_kinesis_streaming_destination( + TableName=table_name, StreamArn=stream_description["StreamARN"] + ) + + # describe kinesis streaming destination of the table + result = dynamodb.describe_kinesis_streaming_destination(TableName=table_name) + destination = result["KinesisDataStreamDestinations"][0] + + # assert kinesis streaming destination status + assert stream_description["StreamARN"] == destination["StreamArn"] + assert destination["DestinationStatus"] == "DISABLED" + + # clean up + dynamodb.delete_table(TableName=table_name) + kinesis.delete_stream(StreamName=stream_name) + + @markers.aws.only_localstack + def test_global_tables_version_2019( + self, aws_client, aws_client_factory, cleanups, dynamodb_wait_for_table_active + ): + # Following https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/V2globaltables.tutorial.html + + # Create clients + dynamodb_us_east_1 = aws_client_factory(region_name="us-east-1").dynamodb + dynamodb_eu_west_1 = aws_client_factory(region_name="eu-west-1").dynamodb + dynamodb_ap_south_1 = aws_client_factory(region_name="ap-south-1").dynamodb + dynamodb_sa_east_1 = aws_client_factory(region_name="sa-east-1").dynamodb + + # Create table in AP + table_name = f"table-{short_uid()}" + dynamodb_ap_south_1.create_table( + TableName=table_name, + KeySchema=[ + {"AttributeName": "Artist", "KeyType": "HASH"}, + {"AttributeName": "SongTitle", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "Artist", "AttributeType": "S"}, + {"AttributeName": "SongTitle", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + cleanups.append(lambda: dynamodb_ap_south_1.delete_table(TableName=table_name)) + dynamodb_wait_for_table_active(table_name=table_name, client=dynamodb_ap_south_1) + + # Replicate table in US and EU + dynamodb_ap_south_1.update_table( + TableName=table_name, + ReplicaUpdates=[{"Create": {"RegionName": "us-east-1", "KMSMasterKeyId": "foo"}}], + ) + dynamodb_ap_south_1.update_table( + TableName=table_name, + ReplicaUpdates=[{"Create": {"RegionName": "eu-west-1", "KMSMasterKeyId": "bar"}}], + ) + + # Ensure all replicas can be described + response = dynamodb_ap_south_1.describe_table(TableName=table_name) + assert len(response["Table"]["Replicas"]) == 2 + response = dynamodb_us_east_1.describe_table(TableName=table_name) + assert len(response["Table"]["Replicas"]) == 2 + assert "bar" in [replica.get("KMSMasterKeyId") for replica in response["Table"]["Replicas"]] + response = dynamodb_eu_west_1.describe_table(TableName=table_name) + assert len(response["Table"]["Replicas"]) == 2 + assert "foo" in [replica.get("KMSMasterKeyId") for replica in response["Table"]["Replicas"]] + with pytest.raises(Exception) as exc: + dynamodb_sa_east_1.describe_table(TableName=table_name) + exc.match("ResourceNotFoundException") + + # Ensure replicas can be listed everywhere + response = dynamodb_ap_south_1.list_tables() + assert table_name in response["TableNames"] + response = dynamodb_us_east_1.list_tables() + assert table_name in response["TableNames"] + response = dynamodb_eu_west_1.list_tables() + assert table_name in response["TableNames"] + response = dynamodb_sa_east_1.list_tables() + assert table_name not in response["TableNames"] + + # Put item in AP + dynamodb_ap_south_1.put_item( + TableName=table_name, + Item={"Artist": {"S": "item_1"}, "SongTitle": {"S": "Song Value 1"}}, + ) + + # Ensure GetItem in US and EU + item_us_east = dynamodb_us_east_1.get_item( + TableName=table_name, + Key={"Artist": {"S": "item_1"}, "SongTitle": {"S": "Song Value 1"}}, + )["Item"] + assert item_us_east + item_eu_west = dynamodb_eu_west_1.get_item( + TableName=table_name, + Key={"Artist": {"S": "item_1"}, "SongTitle": {"S": "Song Value 1"}}, + )["Item"] + assert item_eu_west + + # Ensure Scan in US and EU + scan_us_east = dynamodb_us_east_1.scan(TableName=table_name) + assert scan_us_east["Items"] + scan_eu_west = dynamodb_eu_west_1.scan(TableName=table_name) + assert scan_eu_west["Items"] + + # Ensure Query in US and EU + query_us_east = dynamodb_us_east_1.query( + TableName=table_name, + KeyConditionExpression="Artist = :artist", + ExpressionAttributeValues={":artist": {"S": "item_1"}}, + ) + assert query_us_east["Items"] + query_eu_west = dynamodb_eu_west_1.query( + TableName=table_name, + KeyConditionExpression="Artist = :artist", + ExpressionAttributeValues={":artist": {"S": "item_1"}}, + ) + assert query_eu_west["Items"] + + # Delete EU replica + dynamodb_ap_south_1.update_table( + TableName=table_name, ReplicaUpdates=[{"Delete": {"RegionName": "eu-west-1"}}] + ) + with pytest.raises(Exception) as ctx: + dynamodb_eu_west_1.get_item( + TableName=table_name, + Key={"Artist": {"S": "item_1"}, "SongTitle": {"S": "Song Value 1"}}, + ) + ctx.match("ResourceNotFoundException") + + # Ensure deleting a non-existent replica raises + with pytest.raises(Exception) as exc: + dynamodb_ap_south_1.update_table( + TableName=table_name, ReplicaUpdates=[{"Delete": {"RegionName": "eu-west-1"}}] + ) + exc.match( + "Update global table operation failed because one or more replicas were not part of the global table" + ) + + # Ensure replica details are updated in other regions + response = dynamodb_us_east_1.describe_table(TableName=table_name) + assert len(response["Table"]["Replicas"]) == 1 + response = dynamodb_ap_south_1.describe_table(TableName=table_name) + assert len(response["Table"]["Replicas"]) == 1 + + # Ensure removing the last replica disables global table + dynamodb_us_east_1.update_table( + TableName=table_name, ReplicaUpdates=[{"Delete": {"RegionName": "us-east-1"}}] + ) + response = dynamodb_ap_south_1.describe_table(TableName=table_name) + assert "Replicas" not in response["Table"] + + @markers.aws.validated + # An ARN stream has a stream label as suffix. In AWS, such a label differs between the stream of the original table + # and the ones of the replicas. In LocalStack, it does not differ. The only difference in the stream ARNs is the + # region. Therefore, we skip the following paths from the snapshots. + # However, we run plain assertions to make sure that the region changes in the ARNs, i.e., the replica have their + # own stream. + @markers.snapshot.skip_snapshot_verify( + paths=["$..Streams..StreamArn", "$..Streams..StreamLabel"] + ) + def test_streams_on_global_tables( + self, + aws_client_factory, + wait_for_dynamodb_stream_ready, + cleanups, + snapshot, + region_name, + secondary_region_name, + dynamodbstreams_snapshot_transformers, + ): + """ + This test exposes an issue in LocalStack with Global tables and streams. In AWS, each regional replica should + get a separate DynamoDB Stream. This does not happen in LocalStack since DynamoDB Stream does not have any + redirect logic towards the original region (unlike DDB). + """ + region_1_factory = aws_client_factory(region_name=region_name) + region_2_factory = aws_client_factory(region_name=secondary_region_name) + snapshot.add_transformer(snapshot.transform.regex(secondary_region_name, "")) + + # Create table in the original region + table_name = f"table-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(table_name, "")) + region_1_factory.dynamodb.create_table( + TableName=table_name, + KeySchema=[ + {"AttributeName": "Artist", "KeyType": "HASH"}, + {"AttributeName": "SongTitle", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "Artist", "AttributeType": "S"}, + {"AttributeName": "SongTitle", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + StreamSpecification=StreamSpecification( + StreamEnabled=True, StreamViewType=StreamViewType.NEW_AND_OLD_IMAGES + ), + ) + cleanups.append(lambda: region_1_factory.dynamodb.delete_table(TableName=table_name)) + # Note: we might be unable to delete tables that act as source region immediately on AWS + waiter = region_1_factory.dynamodb.get_waiter("table_exists") + waiter.wait(TableName=table_name, WaiterConfig={"Delay": WAIT_SEC, "MaxAttempts": 20}) + # Update the Table by adding a replica + region_1_factory.dynamodb.update_table( + TableName=table_name, + ReplicaUpdates=[{"Create": {"RegionName": secondary_region_name}}], + ) + cleanups.append(lambda: region_2_factory.dynamodb.delete_table(TableName=table_name)) + waiter = region_2_factory.dynamodb.get_waiter("table_exists") + waiter.wait(TableName=table_name, WaiterConfig={"Delay": WAIT_SEC, "MaxAttempts": 20}) + + stream_arn_region = region_1_factory.dynamodb.describe_table(TableName=table_name)["Table"][ + "LatestStreamArn" + ] + assert region_name in stream_arn_region + wait_for_dynamodb_stream_ready(stream_arn_region) + stream_arn_secondary_region = region_2_factory.dynamodb.describe_table( + TableName=table_name + )["Table"]["LatestStreamArn"] + assert secondary_region_name in stream_arn_secondary_region + wait_for_dynamodb_stream_ready( + stream_arn_secondary_region, region_2_factory.dynamodbstreams + ) + + # Verify that we can list streams on both regions + streams_region_1 = region_1_factory.dynamodbstreams.list_streams(TableName=table_name) + snapshot.match("region-streams", streams_region_1) + assert region_name in streams_region_1["Streams"][0]["StreamArn"] + streams_region_2 = region_2_factory.dynamodbstreams.list_streams(TableName=table_name) + snapshot.match("secondary-region-streams", streams_region_2) + assert secondary_region_name in streams_region_2["Streams"][0]["StreamArn"] + + region_1_factory.dynamodb.batch_write_item( + RequestItems={ + table_name: [ + { + "PutRequest": { + "Item": { + "Artist": {"S": "The Queen"}, + "SongTitle": {"S": "Bohemian Rhapsody"}, + } + } + }, + { + "PutRequest": { + "Item": {"Artist": {"S": "Oasis"}, "SongTitle": {"S": "Live Forever"}} + } + }, + ] + } + ) + + def _read_records_from_shards(_stream_arn, _expected_record_count, _client) -> int: + describe_stream_result = _client.describe_stream(StreamArn=_stream_arn) + shard_id_to_iterator: dict[str, str] = {} + fetched_records = [] + # Records can be spread over multiple shards. We need to read all over them + for stream_info in describe_stream_result["StreamDescription"]["Shards"]: + _shard_id = stream_info["ShardId"] + shard_iterator = _client.get_shard_iterator( + StreamArn=_stream_arn, ShardId=_shard_id, ShardIteratorType="TRIM_HORIZON" + )["ShardIterator"] + shard_id_to_iterator[_shard_id] = shard_iterator + + while len(fetched_records) < _expected_record_count and shard_id_to_iterator: + for _shard_id, _shard_iterator in list(shard_id_to_iterator.items()): + _resp = _client.get_records(ShardIterator=_shard_iterator) + fetched_records.extend(_resp["Records"]) + if next_shard_iterator := _resp.get("NextShardIterator"): + shard_id_to_iterator[_shard_id] = next_shard_iterator + continue + shard_id_to_iterator.pop(_shard_id, None) + return fetched_records + + def _assert_records(_stream_arn, _expected_count, _client) -> None: + records = _read_records_from_shards( + _stream_arn, + _expected_count, + _client, + ) + assert len(records) == _expected_count, ( + f"Expected {_expected_count} records, got {len(records)}" + ) + + retry( + _assert_records, + sleep=WAIT_SEC, + retries=20, + _stream_arn=stream_arn_region, + _expected_count=2, + _client=region_1_factory.dynamodbstreams, + ) + + retry( + _assert_records, + sleep=WAIT_SEC, + retries=20, + _stream_arn=stream_arn_secondary_region, + _expected_count=2, + _client=region_2_factory.dynamodbstreams, + ) + + @markers.aws.only_localstack + def test_global_tables(self, aws_client, ddb_test_table): + dynamodb = aws_client.dynamodb + + # create global table + regions = [ + {"RegionName": "us-east-1"}, + {"RegionName": "us-west-1"}, + {"RegionName": "eu-central-1"}, + ] + response = dynamodb.create_global_table( + GlobalTableName=ddb_test_table, ReplicationGroup=regions + )["GlobalTableDescription"] + assert "ReplicationGroup" in response + assert len(response["ReplicationGroup"]) == len(regions) + + # describe global table + response = dynamodb.describe_global_table(GlobalTableName=ddb_test_table)[ + "GlobalTableDescription" + ] + assert "ReplicationGroup" in response + assert len(regions) == len(response["ReplicationGroup"]) + + # update global table + updates = [ + {"Create": {"RegionName": "us-east-2"}}, + {"Create": {"RegionName": "us-west-2"}}, + {"Delete": {"RegionName": "us-west-1"}}, + ] + response = dynamodb.update_global_table( + GlobalTableName=ddb_test_table, ReplicaUpdates=updates + )["GlobalTableDescription"] + assert "ReplicationGroup" in response + assert len(response["ReplicationGroup"]) == len(regions) + 1 + + # assert exceptions for invalid requests + with pytest.raises(Exception) as ctx: + dynamodb.create_global_table(GlobalTableName=ddb_test_table, ReplicationGroup=regions) + assert ctx.match("GlobalTableAlreadyExistsException") + with pytest.raises(Exception) as ctx: + dynamodb.describe_global_table(GlobalTableName="invalid-table-name") + assert ctx.match("GlobalTableNotFoundException") + + @markers.aws.validated + def test_create_duplicate_table(self, dynamodb_create_table_with_parameters, snapshot): + table_name = f"test_table_{short_uid()}" + + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + ) + + with pytest.raises(Exception) as ctx: + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + ) + snapshot.match("Error", ctx.value) + + @markers.aws.only_localstack( + reason="timing issues - needs a check to see if table is successfully deleted" + ) + def test_delete_table(self, dynamodb_create_table, aws_client): + table_name = f"test-ddb-table-{short_uid()}" + + tables_before = len(aws_client.dynamodb.list_tables()["TableNames"]) + + dynamodb_create_table( + table_name=table_name, + partition_key=PARTITION_KEY, + ) + + table_list = aws_client.dynamodb.list_tables() + # TODO: fix assertion, to enable parallel test execution! + assert tables_before + 1 == len(table_list["TableNames"]) + assert table_name in table_list["TableNames"] + + aws_client.dynamodb.delete_table(TableName=table_name) + + table_list = aws_client.dynamodb.list_tables() + assert tables_before == len(table_list["TableNames"]) + + with pytest.raises(Exception) as ctx: + aws_client.dynamodb.delete_table(TableName=table_name) + assert ctx.match("ResourceNotFoundException") + + @markers.aws.validated + def test_transaction_write_items( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"test-ddb-table-{short_uid()}" + + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + ) + + response = aws_client.dynamodb.transact_write_items( + TransactItems=[ + { + "ConditionCheck": { + "TableName": table_name, + "ConditionExpression": "attribute_not_exists(id)", + "Key": {"id": {"S": "test1"}}, + } + }, + {"Put": {"TableName": table_name, "Item": {"id": {"S": "test2"}}}}, + { + "Update": { + "TableName": table_name, + "Key": {"id": {"S": "test3"}}, + "UpdateExpression": "SET attr1 = :v1, attr2 = :v2", + "ExpressionAttributeValues": { + ":v1": {"S": "value1"}, + ":v2": {"S": "value2"}, + }, + } + }, + {"Delete": {"TableName": table_name, "Key": {"id": {"S": "test4"}}}}, + ] + ) + snapshot.match("Response", response) + + @markers.aws.validated + def test_transaction_write_canceled( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"table_{short_uid()}" + + # create table + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + # put item in table - INSERT event + aws_client.dynamodb.put_item(TableName=table_name, Item={"Username": {"S": "Fred"}}) + + # provoke a TransactionCanceledException by adding a condition which is not met + with pytest.raises(Exception) as ctx: + aws_client.dynamodb.transact_write_items( + TransactItems=[ + { + "ConditionCheck": { + "TableName": table_name, + "ConditionExpression": "attribute_not_exists(Username)", + "Key": {"Username": {"S": "Fred"}}, + } + }, + {"Delete": {"TableName": table_name, "Key": {"Username": {"S": "Bert"}}}}, + ] + ) + snapshot.match("Error", ctx.value) + + @markers.aws.validated + def test_transaction_write_binary_data( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"test-ddb-table-{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + ) + binary_item = {"B": b"foobar"} + response = aws_client.dynamodb.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": table_name, + "Item": { + "id": {"S": "someUser"}, + "binaryData": binary_item, + }, + } + } + ] + ) + snapshot.match("WriteResponse", response) + + item = aws_client.dynamodb.get_item(TableName=table_name, Key={"id": {"S": "someUser"}})[ + "Item" + ] + snapshot.match("GetItem", item) + + @markers.aws.validated + def test_transact_get_items(self, dynamodb_create_table, snapshot, aws_client): + table_name = f"test-ddb-table-{short_uid()}" + dynamodb_create_table( + table_name=table_name, + partition_key=PARTITION_KEY, + ) + aws_client.dynamodb.put_item(TableName=table_name, Item={"id": {"S": "John"}}) + result = aws_client.dynamodb.transact_get_items( + TransactItems=[{"Get": {"Key": {"id": {"S": "John"}}, "TableName": table_name}}] + ) + snapshot.match("TransactGetItems", result) + + @markers.aws.validated + def test_batch_write_items(self, dynamodb_create_table_with_parameters, snapshot, aws_client): + table_name = f"test-ddb-table-{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + Tags=TEST_DDB_TAGS, + ) + aws_client.dynamodb.put_item(TableName=table_name, Item={"id": {"S": "Fred"}}) + response = aws_client.dynamodb.batch_write_item( + RequestItems={ + table_name: [ + {"DeleteRequest": {"Key": {"id": {"S": "Fred"}}}}, + {"PutRequest": {"Item": {"id": {"S": "Bob"}}}}, + ] + } + ) + snapshot.match("BatchWriteResponse", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SizeBytes", + "$..ProvisionedThroughput.NumberOfDecreasesToday", + "$..StreamDescription.CreationRequestDateTime", + ] + ) + @markers.snapshot.skip_snapshot_verify( + # it seems DDB-local has the wrong ordering when executing BatchWriteItem + condition=lambda: config.DDB_STREAMS_PROVIDER_V2, + paths=[ + "$.get-records..Records[2].dynamodb", + "$.get-records..Records[2].eventName", + "$.get-records..Records[3].dynamodb", + "$.get-records..Records[3].eventName", + ], + ) + def test_batch_write_items_streaming( + self, + dynamodb_create_table_with_parameters, + wait_for_dynamodb_stream_ready, + snapshot, + aws_client, + dynamodbstreams_snapshot_transformers, + ): + # TODO: add a test with both Kinesis and DDBStreams destinations + table_name = f"test-ddb-table-{short_uid()}" + create_table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + snapshot.match("create-table", create_table) + stream_arn = create_table["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn=stream_arn) + + describe_stream_result = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn) + snapshot.match("describe-stream", describe_stream_result) + + shard_id = describe_stream_result["StreamDescription"]["Shards"][0]["ShardId"] + shard_iterator = aws_client.dynamodbstreams.get_shard_iterator( + StreamArn=stream_arn, ShardId=shard_id, ShardIteratorType="TRIM_HORIZON" + )["ShardIterator"] + + # because LocalStack is multithreaded, it's not guaranteed those requests are going to be executed in order + + resp = aws_client.dynamodb.put_item(TableName=table_name, Item={"id": {"S": "Fred"}}) + snapshot.match("put-item-1", resp) + + # Overwrite the key, show that no event are sent for this one + response = aws_client.dynamodb.batch_write_item( + RequestItems={ + table_name: [ + {"PutRequest": {"Item": {"id": {"S": "Fred"}}}}, + {"PutRequest": {"Item": {"id": {"S": "NewKey"}}}}, + ] + } + ) + snapshot.match("batch-write-response-overwrite-item-1", response) + + # delete the key + response = aws_client.dynamodb.batch_write_item( + RequestItems={ + table_name: [ + {"DeleteRequest": {"Key": {"id": {"S": "NewKey"}}}}, + {"PutRequest": {"Item": {"id": {"S": "Fred"}, "name": {"S": "Fred"}}}}, + ] + } + ) + snapshot.match("batch-write-response-delete", response) + + # Total amount of records should be 4: + # - PutItem + # - BatchWriteItem on NewKey insert + # - BatchWriteItem on NewKey delete + # - BatchWriteItem on Fred modify + # don't send an event when Fred is overwritten with the same value + # get all records: + records = [] + + def _get_records_amount(record_amount: int): + nonlocal shard_iterator + if len(records) < record_amount: + _resp = aws_client.dynamodbstreams.get_records(ShardIterator=shard_iterator) + records.extend(_resp["Records"]) + if next_shard_iterator := _resp.get("NextShardIterator"): + shard_iterator = next_shard_iterator + + assert len(records) >= record_amount + + retry(lambda: _get_records_amount(4), sleep=1, retries=3) + snapshot.match("get-records", {"Records": records}) + + @pytest.mark.skip(reason="Flaky in CI") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SizeBytes", + "$..ProvisionedThroughput.NumberOfDecreasesToday", + "$..StreamDescription.CreationRequestDateTime", + ] + ) + def test_dynamodb_stream_records_with_update_item( + self, + aws_client, + dynamodb_create_table_with_parameters, + wait_for_dynamodb_stream_ready, + snapshot, + dynamodbstreams_snapshot_transformers, + ): + table_name = f"test-ddb-table-{short_uid()}" + + create_table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": PARTITION_KEY, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": PARTITION_KEY, "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + snapshot.match("create-table", create_table) + stream_arn = create_table["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn=stream_arn) + + response = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn) + snapshot.match("describe-stream", response) + shard_id = response["StreamDescription"]["Shards"][0]["ShardId"] + starting_sequence_number = int( + response["StreamDescription"]["Shards"][0] + .get("SequenceNumberRange") + .get("StartingSequenceNumber") + ) + + response = aws_client.dynamodbstreams.get_shard_iterator( + StreamArn=stream_arn, + ShardId=shard_id, + ShardIteratorType="TRIM_HORIZON", + ) + snapshot.match("get-shard-iterator", response) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert "ShardIterator" in response + shard_iterator = response["ShardIterator"] + + item_id = "my-item-id" + # assert that when we insert/update the record with the same value, no event is sent + + aws_client.dynamodb.update_item( + TableName=table_name, + Key={PARTITION_KEY: {"S": item_id}}, + UpdateExpression="SET attr1 = :v1, attr2 = :v2", + ExpressionAttributeValues={ + ":v1": {"S": "value1"}, + ":v2": {"S": "value2"}, + }, + ) + + def _get_item(): + res = aws_client.dynamodb.get_item( + TableName=table_name, Key={PARTITION_KEY: {"S": item_id}} + ) + assert res["Item"]["attr1"] == {"S": "value1"} + assert res["Item"]["attr2"] == {"S": "value2"} + + # we need this retry to make sure the item is properly existing in DynamoDB before trying to overwrite it + # with the same value, thus not sending the event again + retry(_get_item, retries=3, sleep=0.1) + + # send the same update, this should not publish an event to the stream + aws_client.dynamodb.update_item( + TableName=table_name, + Key={PARTITION_KEY: {"S": item_id}}, + UpdateExpression="SET attr1 = :v1, attr2 = :v2", + ExpressionAttributeValues={ + ":v1": {"S": "value1"}, + ":v2": {"S": "value2"}, + }, + ) + # send a different update, this will trigger an `MODIFY` event + aws_client.dynamodb.update_item( + TableName=table_name, + Key={PARTITION_KEY: {"S": item_id}}, + UpdateExpression="SET attr1 = :v1, attr2 = :v2", + ExpressionAttributeValues={ + ":v1": {"S": "value2"}, + ":v2": {"S": "value3"}, + }, + ) + + def _get_records_amount(record_amount: int): + nonlocal shard_iterator + + all_records = [] + while shard_iterator is not None: + res = aws_client.dynamodbstreams.get_records(ShardIterator=shard_iterator) + shard_iterator = res["NextShardIterator"] + all_records.extend(res["Records"]) + if len(all_records) >= record_amount: + break + + return all_records + + records = retry(lambda: _get_records_amount(2), sleep=1, retries=3) + snapshot.match("get-records", {"Records": records}) + + assert len(records) == 2 + event_insert, event_update = records + assert isinstance( + event_insert["dynamodb"]["ApproximateCreationDateTime"], + datetime, + ) + assert event_insert["dynamodb"]["ApproximateCreationDateTime"].microsecond == 0 + insert_seq_number = int(event_insert["dynamodb"]["SequenceNumber"]) + # TODO: maybe fix sequence number, seems something related to Kinesis + if is_aws_cloud(): + assert insert_seq_number > starting_sequence_number + else: + assert insert_seq_number >= starting_sequence_number + assert isinstance( + event_update["dynamodb"]["ApproximateCreationDateTime"], + datetime, + ) + assert event_update["dynamodb"]["ApproximateCreationDateTime"].microsecond == 0 + assert int(event_update["dynamodb"]["SequenceNumber"]) > starting_sequence_number + + @markers.aws.only_localstack + def test_query_on_deleted_resource(self, dynamodb_create_table, aws_client): + table_name = f"ddb-table-{short_uid()}" + partition_key = "username" + + dynamodb_create_table(table_name=table_name, partition_key=partition_key) + + rs = aws_client.dynamodb.query( + TableName=table_name, + KeyConditionExpression="{} = :username".format(partition_key), + ExpressionAttributeValues={":username": {"S": "test"}}, + ) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + + aws_client.dynamodb.delete_table(TableName=table_name) + + with pytest.raises(Exception) as ctx: + aws_client.dynamodb.query( + TableName=table_name, + KeyConditionExpression="{} = :username".format(partition_key), + ExpressionAttributeValues={":username": {"S": "test"}}, + ) + assert ctx.match("ResourceNotFoundException") + + @markers.aws.validated + def test_dynamodb_pay_per_request(self, dynamodb_create_table_with_parameters, snapshot): + table_name = f"ddb-table-{short_uid()}" + + with pytest.raises(Exception) as e: + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": PARTITION_KEY, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": PARTITION_KEY, "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + BillingMode="PAY_PER_REQUEST", + ) + snapshot.match("Error", e.value) + + @markers.aws.only_localstack + def test_dynamodb_create_table_with_sse_specification( + self, dynamodb_create_table_with_parameters, account_id, region_name + ): + table_name = f"ddb-table-{short_uid()}" + + kms_master_key_id = long_uid() + sse_specification = {"Enabled": True, "SSEType": "KMS", "KMSMasterKeyId": kms_master_key_id} + kms_master_key_arn = arns.kms_key_arn( + kms_master_key_id, account_id=account_id, region_name=region_name + ) + + result = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": PARTITION_KEY, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": PARTITION_KEY, "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + SSESpecification=sse_specification, + Tags=TEST_DDB_TAGS, + ) + + assert result["TableDescription"]["SSEDescription"] + assert result["TableDescription"]["SSEDescription"]["Status"] == "ENABLED" + assert result["TableDescription"]["SSEDescription"]["KMSMasterKeyArn"] == kms_master_key_arn + + @markers.aws.validated + def test_dynamodb_create_table_with_partial_sse_specification( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"test_table_{short_uid()}" + sse_specification = {"Enabled": True} + + result = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": PARTITION_KEY, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": PARTITION_KEY, "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + SSESpecification=sse_specification, + Tags=TEST_DDB_TAGS, + ) + + snapshot.match("SSEDescription", result["TableDescription"]["SSEDescription"]) + + kms_master_key_arn = result["TableDescription"]["SSEDescription"]["KMSMasterKeyArn"] + result = aws_client.kms.describe_key(KeyId=kms_master_key_arn) + snapshot.match("KMSDescription", result) + + result = aws_client.dynamodb.update_table( + TableName=table_name, SSESpecification={"Enabled": False} + ) + snapshot.match( + "update-table-disable-sse-spec", result["TableDescription"]["SSEDescription"] + ) + + result = aws_client.dynamodb.describe_table(TableName=table_name) + assert "SSESpecification" not in result["Table"] + + @markers.aws.validated + def test_dynamodb_update_table_without_sse_specification_change( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"test_table_{short_uid()}" + + sse_specification = {"Enabled": True} + + result = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": PARTITION_KEY, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": PARTITION_KEY, "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + SSESpecification=sse_specification, + Tags=TEST_DDB_TAGS, + ) + snapshot.match("create_table_sse_description", result["TableDescription"]["SSEDescription"]) + + kms_master_key_arn = result["TableDescription"]["SSEDescription"]["KMSMasterKeyArn"] + result = aws_client.kms.describe_key(KeyId=kms_master_key_arn) + snapshot.match("describe_kms_key", result) + + result = aws_client.dynamodb.update_table( + TableName=table_name, BillingMode="PAY_PER_REQUEST" + ) + snapshot.match("update_table_sse_description", result["TableDescription"]["SSEDescription"]) + + # Verify that SSEDescription exists and remains unchanged after update_table + assert result["TableDescription"]["SSEDescription"]["Status"] == "ENABLED" + assert result["TableDescription"]["SSEDescription"]["SSEType"] == "KMS" + assert result["TableDescription"]["SSEDescription"]["KMSMasterKeyArn"] == kms_master_key_arn + + @markers.aws.validated + def test_dynamodb_get_batch_items( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"test_table_{short_uid()}" + + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "PK", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "PK", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 1, "WriteCapacityUnits": 1}, + ) + + result = aws_client.dynamodb.batch_get_item( + RequestItems={table_name: {"Keys": [{"PK": {"S": "test-key"}}]}} + ) + snapshot.match("Response", result) + + @markers.aws.validated + def test_dynamodb_streams_describe_with_exclusive_start_shard_id( + self, + aws_client, + dynamodb_create_table_with_parameters, + wait_for_dynamodb_stream_ready, + ): + # not using snapshots here as AWS will often return 4 Shards where we return only one + table_name = f"test-ddb-table-{short_uid()}" + ddbstreams = aws_client.dynamodbstreams + + create_table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": PARTITION_KEY, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": PARTITION_KEY, "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + stream_arn = create_table["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn=stream_arn) + + table = aws_client.dynamodb.describe_table(TableName=table_name) + + response = ddbstreams.describe_stream(StreamArn=table["Table"]["LatestStreamArn"]) + + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert len(response["StreamDescription"]["Shards"]) >= 1 + shard_id = response["StreamDescription"]["Shards"][0]["ShardId"] + + # assert that the excluded shard it not in the response + response = ddbstreams.describe_stream( + StreamArn=table["Table"]["LatestStreamArn"], ExclusiveStartShardId=shard_id + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert not any( + shard_id == shard["ShardId"] for shard in response["StreamDescription"]["Shards"] + ) + + @markers.aws.validated + def test_dynamodb_streams_shard_iterator_format( + self, + dynamodb_create_table, + wait_for_dynamodb_stream_ready, + aws_client, + ): + """Test the dynamodb stream iterators starting with the stream arn followed by ||""" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + + dynamodb_create_table(table_name=table_name, partition_key=partition_key) + + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + stream_arn = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + )["TableDescription"]["LatestStreamArn"] + assert wait_for_dynamodb_stream_ready(stream_arn) + + describe_stream_result = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn)[ + "StreamDescription" + ] + shard_id = describe_stream_result["Shards"][0]["ShardId"] + + shard_iterator = aws_client.dynamodbstreams.get_shard_iterator( + StreamArn=stream_arn, ShardId=shard_id, ShardIteratorType="TRIM_HORIZON" + )["ShardIterator"] + + def _matches(iterator: str) -> bool: + if is_aws_cloud() or not config.DDB_STREAMS_PROVIDER_V2: + pattern = rf"^{stream_arn}\|\d\|.+$" + else: + # DynamoDB-Local has 3 digits instead of only one + pattern = rf"^{stream_arn}\|\d\+|.+$" + + return bool(re.match(pattern, iterator)) + + assert _matches(shard_iterator) + + get_records_result = aws_client.dynamodbstreams.get_records(ShardIterator=shard_iterator) + shard_iterator = get_records_result["NextShardIterator"] + assert _matches(shard_iterator) + assert not get_records_result["Records"] + + @markers.aws.validated + def test_dynamodb_idempotent_writing( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"ddb-table-{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[ + {"AttributeName": "id", "KeyType": "HASH"}, + {"AttributeName": "name", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + {"AttributeName": "name", "AttributeType": "S"}, + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + + def _transact_write(_d: Dict): + return aws_client.dynamodb.transact_write_items( + ClientRequestToken="dedupe_token", + TransactItems=[ + { + "Put": { + "TableName": table_name, + "Item": _d, + } + }, + ], + ) + + response = _transact_write({"id": {"S": "id1"}, "name": {"S": "name1"}}) + snapshot.match("Response1", response) + response = _transact_write({"name": {"S": "name1"}, "id": {"S": "id1"}}) + snapshot.match("Response2", response) + + @markers.aws.validated + def test_batch_write_not_matching_schema( + self, + dynamodb_create_table_with_parameters, + dynamodb_wait_for_table_active, + snapshot, + aws_client, + ): + table_name = f"ddb-table-{short_uid()}" + + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[ + {"AttributeName": "id", "KeyType": "HASH"}, + {"AttributeName": "sortKey", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + {"AttributeName": "sortKey", "AttributeType": "S"}, + ], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + dynamodb_wait_for_table_active(table_name) + + faulty_item = {"Item": {"nonKey": {"S": "hello"}}} + with pytest.raises(Exception) as ctx: + aws_client.dynamodb.batch_write_item( + RequestItems={table_name: [{"PutRequest": faulty_item}]} + ) + snapshot.match("ValidationException", ctx.value) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Error.Message", "$..message"], # error message is not right + ) + def test_batch_write_not_existing_table(self, aws_client, snapshot): + with pytest.raises(ClientError) as e: + aws_client.dynamodb.transact_write_items( + TransactItems=[{"Put": {"TableName": "non-existing-table", "Item": {}}}] + ) + snapshot.match("exc-not-found-transact-write-items", e.value.response) + + @markers.aws.only_localstack + def test_nosql_workbench_localhost_region(self, dynamodb_create_table, aws_client_factory): + """ + Test for AWS NoSQL Workbench, which sends "localhost" as region in header. + LocalStack must assume "us-east-1" region in such cases. + """ + table_name = f"t-{short_uid()}" + + # Create a table in the `us-east-1` region + client = aws_client_factory(region_name=AWS_REGION_US_EAST_1).dynamodb + create_dynamodb_table(table_name, PARTITION_KEY, client=client) + table = client.describe_table(TableName=table_name) + assert table.get("Table") + + # Ensure the `localhost` region points to `us-east-1` + client = aws_client_factory(region_name="localhost").dynamodb + table = client.describe_table(TableName=table_name) + assert table.get("Table") + + @markers.aws.validated + def test_data_encoding_consistency( + self, + dynamodb_create_table_with_parameters, + snapshot, + aws_client, + wait_for_dynamodb_stream_ready, + ): + table_name = f"table-{short_uid()}" + table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + StreamSpecification={ + "StreamEnabled": True, + "StreamViewType": "NEW_AND_OLD_IMAGES", + }, + ) + stream_arn = table["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn) + + # put item + aws_client.dynamodb.put_item( + TableName=table_name, + Item={PARTITION_KEY: {"S": "id1"}, "version": {"N": "1"}, "data": {"B": b"\x90"}}, + ) + + # get item + item = aws_client.dynamodb.get_item( + TableName=table_name, Key={PARTITION_KEY: {"S": "id1"}} + )["Item"] + snapshot.match("GetItem", item) + + # get stream records + result = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn)[ + "StreamDescription" + ] + + response = aws_client.dynamodbstreams.get_shard_iterator( + StreamArn=stream_arn, + ShardId=result["Shards"][0]["ShardId"], + ShardIteratorType="AT_SEQUENCE_NUMBER", + SequenceNumber=result["Shards"][0] + .get("SequenceNumberRange") + .get("StartingSequenceNumber"), + ) + records = aws_client.dynamodbstreams.get_records(ShardIterator=response["ShardIterator"])[ + "Records" + ] + + snapshot.match("GetRecords", records[0]["dynamodb"]["NewImage"]) + + # update item + aws_client.dynamodb.update_item( + TableName=table_name, + Key={PARTITION_KEY: {"S": "id1"}}, + UpdateExpression="SET version=:v", + ExpressionAttributeValues={":v": {"N": "2"}}, + ) + + # get item and get_records again to check for consistency + item = aws_client.dynamodb.get_item( + TableName=table_name, Key={PARTITION_KEY: {"S": "id1"}} + )["Item"] + snapshot.match("GetItemAfterUpdate", item) + + records = aws_client.dynamodbstreams.get_records(ShardIterator=response["ShardIterator"])[ + "Records" + ] + snapshot.match("GetRecordsAfterUpdate", records[1]["dynamodb"]["NewImage"]) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..PointInTimeRecoveryDescription..EarliestRestorableDateTime", + "$..PointInTimeRecoveryDescription..LatestRestorableDateTime", + ] + ) + @markers.aws.validated + def test_continuous_backup_update(self, dynamodb_create_table, snapshot, aws_client): + table_name = f"table-{short_uid()}" + dynamodb_create_table( + table_name=table_name, + partition_key=PARTITION_KEY, + ) + + def wait_for_continuous_backend(): + try: + aws_client.dynamodb.update_continuous_backups( + TableName=table_name, + PointInTimeRecoverySpecification=PointInTimeRecoverySpecification( + PointInTimeRecoveryEnabled=True + ), + ) + return True + except Exception: # noqa + return False + + assert poll_condition( + wait_for_continuous_backend, + timeout=50 if is_aws_cloud() else 10, + interval=1 if is_aws_cloud() else 0.5, + ) + + response = aws_client.dynamodb.update_continuous_backups( + TableName=table_name, + PointInTimeRecoverySpecification=PointInTimeRecoverySpecification( + PointInTimeRecoveryEnabled=True + ), + ) + + snapshot.match("update-continuous-backup", response) + + response = aws_client.dynamodb.describe_continuous_backups(TableName=table_name) + snapshot.match("describe-continuous-backup", response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud() and config.DDB_STREAMS_PROVIDER_V2, + reason="Not yet implemented in DDB Streams V2", + ) + def test_stream_destination_records( + self, + aws_client, + dynamodb_create_table_with_parameters, + kinesis_create_stream, + wait_for_stream_ready, + ): + table_name = f"table-{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + # get stream arn + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + aws_client.dynamodb.enable_kinesis_streaming_destination( + TableName=table_name, + StreamArn=stream_arn, + ) + + def check_destination_status(): + response = aws_client.dynamodb.describe_kinesis_streaming_destination( + TableName=table_name, + ) + return response["KinesisDataStreamDestinations"][0]["DestinationStatus"] == "ACTIVE" + + wait = 30 if is_aws_cloud() else 3 + max_retries = 10 if is_aws_cloud() else 2 + wait_until(check_destination_status(), wait=wait, max_retries=max_retries) + + iterator = get_shard_iterator(stream_name, aws_client.kinesis) + + def assert_records(): + # put item could not trigger the event at the beginning so it's best to try to put it again + aws_client.dynamodb.put_item( + TableName=table_name, + Item={ + PARTITION_KEY: {"S": f"id{short_uid()}"}, + "version": {"N": "1"}, + "data": {"B": b"\x90"}, + }, + ) + + # get stream records + response = aws_client.kinesis.get_records( + ShardIterator=iterator, + ) + records = response["Records"] + assert len(records) > 0 + + sleep_secs = 2 + retries = 10 + if is_aws_cloud(): + sleep_secs = 5 + retries = 30 + + retry( + assert_records, + retries=retries, + sleep=sleep_secs, + sleep_before=2, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..message"]) + def test_return_values_on_conditions_check_failure( + self, dynamodb_create_table_with_parameters, snapshot, aws_client + ): + table_name = f"table-{short_uid()}" + dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + item = { + PARTITION_KEY: {"S": "456"}, + "price": {"N": "650"}, + "product": {"S": "sporting goods"}, + } + aws_client.dynamodb.put_item(TableName=table_name, Item=item) + try: + aws_client.dynamodb.delete_item( + TableName=table_name, + Key={PARTITION_KEY: {"S": "456"}}, + ConditionExpression="price between :lo and :hi", + ExpressionAttributeValues={":lo": {"N": "500"}, ":hi": {"N": "600"}}, + ReturnValuesOnConditionCheckFailure="ALL_OLD", + ) + except botocore.exceptions.ClientError as error: + snapshot.match("items", error.response) # noqa + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SizeBytes", + "$..ProvisionedThroughput.NumberOfDecreasesToday", + "$..StreamDescription.CreationRequestDateTime", + ] + ) + def test_transact_write_items_streaming( + self, + dynamodb_create_table_with_parameters, + wait_for_dynamodb_stream_ready, + snapshot, + aws_client, + dynamodbstreams_snapshot_transformers, + ): + # TODO: add a test with both Kinesis and DDBStreams destinations + table_name = f"test-ddb-table-{short_uid()}" + create_table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + snapshot.match("create-table", create_table) + stream_arn = create_table["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn=stream_arn) + + describe_stream_result = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn) + snapshot.match("describe-stream", describe_stream_result) + + shard_id = describe_stream_result["StreamDescription"]["Shards"][0]["ShardId"] + shard_iterator = aws_client.dynamodbstreams.get_shard_iterator( + StreamArn=stream_arn, ShardId=shard_id, ShardIteratorType="TRIM_HORIZON" + )["ShardIterator"] + + resp = aws_client.dynamodb.put_item(TableName=table_name, Item={"id": {"S": "Fred"}}) + snapshot.match("put-item-1", resp) + + # Overwrite the key with the same content first, show that no event are sent for this one + response = aws_client.dynamodb.transact_write_items( + TransactItems=[ + {"Put": {"TableName": table_name, "Item": {"id": {"S": "Fred"}}}}, + {"Put": {"TableName": table_name, "Item": {"id": {"S": "NewKey"}}}}, + ] + ) + snapshot.match("transact-write-response-overwrite", response) + + # update NewKey to see the event shape + response = aws_client.dynamodb.transact_write_items( + TransactItems=[ + { + "Update": { + "TableName": table_name, + "Key": {"id": {"S": "NewKey"}}, + "UpdateExpression": "SET attr1 = :v1, attr2 = :v2", + "ExpressionAttributeValues": { + ":v1": {"S": "value1"}, + ":v2": {"S": "value2"}, + }, + } + }, + ] + ) + snapshot.match("transact-write-response-update", response) + + # use Update to write a new key + response = aws_client.dynamodb.transact_write_items( + TransactItems=[ + { + "Update": { + "TableName": table_name, + "Key": {"id": {"S": "NonExistentKey"}}, + "UpdateExpression": "SET attr1 = :v1", + "ExpressionAttributeValues": { + ":v1": {"S": "value1"}, + }, + } + }, + ] + ) + snapshot.match("transact-write-update-insert", response) + + # delete the key + response = aws_client.dynamodb.transact_write_items( + TransactItems=[ + {"Delete": {"TableName": table_name, "Key": {"id": {"S": "NewKey"}}}}, + { + "Put": { + "TableName": table_name, + "Item": {"id": {"S": "Fred"}, "name": {"S": "Fred"}}, + } + }, + { + "Update": { + "TableName": table_name, + "Key": {"id": {"S": "NonExistentKey"}}, + "UpdateExpression": "SET attr1 = :v1", + "ExpressionAttributeValues": { + ":v1": {"S": "value1"}, + }, + } + }, + ] + ) + snapshot.match("transact-write-response-delete", response) + + # Total amount of records should be 5: + # - PutItem + # - TransactWriteItem on NewKey insert + # - TransactWriteItem on NewKey update + # - TransactWriteItem on NonExistentKey insert + # - TransactWriteItem on NewKey delete + # - TransactWriteItem on Fred modify via Put + # don't send an event when Fred is overwritten with the same value with Put + # or when NonExistentKey is overwritte with Update + # get all records: + records = [] + + def _get_records_amount(record_amount: int): + nonlocal shard_iterator + if len(records) < record_amount: + _resp = aws_client.dynamodbstreams.get_records(ShardIterator=shard_iterator) + records.extend(_resp["Records"]) + if next_shard_iterator := _resp.get("NextShardIterator"): + shard_iterator = next_shard_iterator + + assert len(records) >= record_amount + + retry(lambda: _get_records_amount(6), sleep=1, retries=3) + snapshot.match("get-records", {"Records": records}) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SizeBytes", + "$..ProvisionedThroughput.NumberOfDecreasesToday", + "$..StreamDescription.CreationRequestDateTime", + ] + ) + def test_transact_write_items_streaming_for_different_tables( + self, + dynamodb_create_table_with_parameters, + wait_for_dynamodb_stream_ready, + snapshot, + aws_client, + dynamodbstreams_snapshot_transformers, + ): + # TODO: add a test with both Kinesis and DDBStreams destinations + table_name_stream = f"test-ddb-table-{short_uid()}" + table_name_no_stream = f"test-ddb-table-{short_uid()}" + create_table_stream = dynamodb_create_table_with_parameters( + TableName=table_name_stream, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + snapshot.match("create-table-stream", create_table_stream) + + create_table_no_stream = dynamodb_create_table_with_parameters( + TableName=table_name_no_stream, + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + snapshot.match("create-table-no-stream", create_table_no_stream) + + stream_arn = create_table_stream["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn=stream_arn) + + describe_stream_result = aws_client.dynamodbstreams.describe_stream(StreamArn=stream_arn) + snapshot.match("describe-stream", describe_stream_result) + + # Call TransactWriteItems on the 2 different tables at once + response = aws_client.dynamodb.transact_write_items( + TransactItems=[ + {"Put": {"TableName": table_name_no_stream, "Item": {"id": {"S": "Fred"}}}}, + {"Put": {"TableName": table_name_stream, "Item": {"id": {"S": "Fred"}}}}, + ] + ) + snapshot.match("transact-write-two-tables", response) + + # Total amount of records should be 1: + # - TransactWriteItem on Fred insert for TableStream + records = [] + shard_id = describe_stream_result["StreamDescription"]["Shards"][0]["ShardId"] + shard_iterator = aws_client.dynamodbstreams.get_shard_iterator( + StreamArn=stream_arn, ShardId=shard_id, ShardIteratorType="TRIM_HORIZON" + )["ShardIterator"] + + def _get_records_amount(record_amount: int): + nonlocal shard_iterator + if len(records) < record_amount: + _resp = aws_client.dynamodbstreams.get_records(ShardIterator=shard_iterator) + records.extend(_resp["Records"]) + if next_shard_iterator := _resp.get("NextShardIterator"): + shard_iterator = next_shard_iterator + + assert len(records) >= record_amount + + retry(lambda: _get_records_amount(1), sleep=1, retries=3) + snapshot.match("get-records", {"Records": records}) + + @markers.aws.validated + @pytest.mark.parametrize("billing_mode", ["PAY_PER_REQUEST", "PROVISIONED"]) + @markers.snapshot.skip_snapshot_verify( + paths=[ + # LS returns those and not AWS, probably because no changes happened there yet + "$..ProvisionedThroughput.LastDecreaseDateTime", + "$..ProvisionedThroughput.LastIncreaseDateTime", + "$..TableDescription.BillingModeSummary.LastUpdateToPayPerRequestDateTime", + ] + ) + def test_gsi_with_billing_mode( + self, aws_client, dynamodb_create_table_with_parameters, snapshot, billing_mode + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("TableName"), + snapshot.transform.key_value( + "TableStatus", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value( + "IndexStatus", reference_replacement=False, value_replacement="" + ), + ] + ) + + table_name = f"test-table-{short_uid()}" + create_table_kwargs = {} + global_secondary_index = { + "IndexName": "TransactionRecordID", + "KeySchema": [ + {"AttributeName": "TRID", "KeyType": "HASH"}, + ], + "Projection": {"ProjectionType": "ALL"}, + } + + if billing_mode == "PROVISIONED": + create_table_kwargs["ProvisionedThroughput"] = { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5, + } + global_secondary_index["ProvisionedThroughput"] = { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1, + } + + create_table = dynamodb_create_table_with_parameters( + TableName=table_name, + KeySchema=[ + {"AttributeName": "TID", "KeyType": "HASH"}, + ], + AttributeDefinitions=[ + {"AttributeName": "TID", "AttributeType": "S"}, + {"AttributeName": "TRID", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[global_secondary_index], + BillingMode=billing_mode, + **create_table_kwargs, + ) + snapshot.match("create-table-with-gsi", create_table) + + describe_table = aws_client.dynamodb.describe_table(TableName=table_name) + snapshot.match("describe-table", describe_table) diff --git a/tests/aws/services/dynamodb/test_dynamodb.snapshot.json b/tests/aws/services/dynamodb/test_dynamodb.snapshot.json new file mode 100644 index 0000000000000..4842ef3f2406b --- /dev/null +++ b/tests/aws/services/dynamodb/test_dynamodb.snapshot.json @@ -0,0 +1,1763 @@ +{ + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_valid_local_secondary_index": { + "recorded-date": "23-08-2023, 16:32:11", + "recorded-content": { + "Items": { + "Count": 1, + "Items": [ + { + "LSI1SK": { + "N": "123" + }, + "PK": { + "S": "test one" + }, + "SK": { + "S": "hello" + } + } + ], + "ScannedCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_return_values_in_put_item": { + "recorded-date": "23-08-2023, 16:32:21", + "recorded-content": { + "PutFirstItem": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "PutFirstItemOLD": { + "Attributes": { + "data": { + "S": "foobar" + }, + "id": { + "S": "id1" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "PutFirstItemB": { + "Attributes": { + "data": { + "S": "foobar" + }, + "id": { + "S": "id1" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "PutSecondItem": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "PutSecondItemReturnNone": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_empty_and_binary_values": { + "recorded-date": "23-08-2023, 16:32:29", + "recorded-content": { + "PutFirstItem": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "PutSecondItem": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_binary": { + "recorded-date": "23-08-2023, 16:32:35", + "recorded-content": { + "Response": { + "UnprocessedItems": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_execute_transaction": { + "recorded-date": "23-08-2023, 16:32:44", + "recorded-content": { + "ExecutedTransaction": { + "Responses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "TableScan": { + "Count": 2, + "Items": [ + { + "Username": { + "S": "user01" + } + }, + { + "Username": { + "S": "user02" + } + } + ], + "ScannedCount": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_batch_execute_statement": { + "recorded-date": "23-08-2023, 16:32:51", + "recorded-content": { + "ExecutedStatement": { + "Responses": [ + { + "TableName": "" + }, + { + "TableName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "ItemUser2": { + "Age": { + "N": "20" + }, + "Username": { + "S": "user02" + } + }, + "ItemUser1": { + "Username": { + "S": "user01" + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_partiql_missing": { + "recorded-date": "23-08-2023, 16:33:00", + "recorded-content": { + "FirstNameNotMissing": [ + { + "FirstName": { + "S": "Alice" + }, + "Username": { + "S": "Alice123" + } + } + ] + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_create_duplicate_table": { + "recorded-date": "23-08-2023, 16:33:08", + "recorded-content": { + "Error": "An error occurred (ResourceInUseException) when calling the CreateTable operation: Table already exists: " + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_items": { + "recorded-date": "23-08-2023, 16:33:16", + "recorded-content": { + "Response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_canceled": { + "recorded-date": "23-08-2023, 16:33:24", + "recorded-content": { + "Error": "An error occurred (TransactionCanceledException) when calling the TransactWriteItems operation: Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]" + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_binary_data": { + "recorded-date": "23-08-2023, 16:33:31", + "recorded-content": { + "WriteResponse": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "GetItem": { + "binaryData": { + "B": "b'foobar'" + }, + "id": { + "S": "someUser" + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_get_items": { + "recorded-date": "23-08-2023, 16:33:37", + "recorded-content": { + "TransactGetItems": { + "Responses": [ + { + "Item": { + "id": { + "S": "John" + } + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_items": { + "recorded-date": "23-08-2023, 16:33:43", + "recorded-content": { + "BatchWriteResponse": { + "UnprocessedItems": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_pay_per_request": { + "recorded-date": "23-08-2023, 16:33:43", + "recorded-content": { + "Error": "An error occurred (ValidationException) when calling the CreateTable operation: One or more parameter values were invalid: Neither ReadCapacityUnits nor WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST" + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_create_table_with_partial_sse_specification": { + "recorded-date": "10-01-2024, 12:59:51", + "recorded-content": { + "SSEDescription": { + "KMSMasterKeyArn": "arn::kms::111111111111:key/", + "SSEType": "KMS", + "Status": "ENABLED" + }, + "KMSDescription": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "Default key that protects my DynamoDB data when no other key is defined", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "AWS", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-table-disable-sse-spec": { + "KMSMasterKeyArn": "arn::kms::111111111111:key/", + "SSEType": "KMS", + "Status": "UPDATING" + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_get_batch_items": { + "recorded-date": "23-08-2023, 16:33:58", + "recorded-content": { + "Response": { + "Responses": { + "": [] + }, + "UnprocessedKeys": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_idempotent_writing": { + "recorded-date": "23-08-2023, 16:39:54", + "recorded-content": { + "Response1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Response2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_not_matching_schema": { + "recorded-date": "23-08-2023, 16:34:26", + "recorded-content": { + "ValidationException": "An error occurred (ValidationException) when calling the BatchWriteItem operation: The provided key element does not match the schema" + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_data_encoding_consistency": { + "recorded-date": "24-08-2023, 10:56:37", + "recorded-content": { + "GetItem": { + "data": { + "B": "b'\\x90'" + }, + "id": { + "S": "id1" + }, + "version": { + "N": "1" + } + }, + "GetRecords": { + "data": { + "B": "b'\\x90'" + }, + "id": { + "S": "id1" + }, + "version": { + "N": "1" + } + }, + "GetItemAfterUpdate": { + "data": { + "B": "b'\\x90'" + }, + "id": { + "S": "id1" + }, + "version": { + "N": "2" + } + }, + "GetRecordsAfterUpdate": { + "data": { + "B": "b'\\x90'" + }, + "id": { + "S": "id1" + }, + "version": { + "N": "2" + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_continuous_backup_update": { + "recorded-date": "23-08-2023, 16:34:39", + "recorded-content": { + "update-continuous-backup": { + "ContinuousBackupsDescription": { + "ContinuousBackupsStatus": "ENABLED", + "PointInTimeRecoveryDescription": { + "EarliestRestorableDateTime": "datetime", + "LatestRestorableDateTime": "datetime", + "PointInTimeRecoveryStatus": "ENABLED" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-continuous-backup": { + "ContinuousBackupsDescription": { + "ContinuousBackupsStatus": "ENABLED", + "PointInTimeRecoveryDescription": { + "EarliestRestorableDateTime": "datetime", + "LatestRestorableDateTime": "datetime", + "PointInTimeRecoveryStatus": "ENABLED" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_not_existing_table": { + "recorded-date": "22-10-2023, 20:45:30", + "recorded-content": { + "exc-not-found-transact-write-items": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Requested resource not found" + }, + "message": "Requested resource not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_items_streaming": { + "recorded-date": "22-10-2023, 23:23:09", + "recorded-content": { + "create-table": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-stream": { + "StreamDescription": { + "CreationRequestDateTime": "datetime", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "Shards": [ + { + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "" + } + ], + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "StreamStatus": "ENABLED", + "StreamViewType": "NEW_AND_OLD_IMAGES", + "TableName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-item-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "batch-write-response-overwrite-item-1": { + "UnprocessedItems": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "batch-write-response-delete": { + "UnprocessedItems": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-records": { + "Records": [ + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "Fred" + } + }, + "NewImage": { + "id": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 12, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "NewKey" + } + }, + "NewImage": { + "id": { + "S": "NewKey" + } + }, + "SequenceNumber": "", + "SizeBytes": 16, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "NewKey" + } + }, + "OldImage": { + "id": { + "S": "NewKey" + } + }, + "SequenceNumber": "", + "SizeBytes": 16, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "REMOVE", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "Fred" + } + }, + "NewImage": { + "id": { + "S": "Fred" + }, + "name": { + "S": "Fred" + } + }, + "OldImage": { + "id": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 26, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "MODIFY", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + } + ] + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_streams_describe_with_exclusive_start_shard_id": { + "recorded-date": "22-10-2023, 22:27:28", + "recorded-content": {} + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_stream_stream_view_type": { + "recorded-date": "31-05-2024, 14:49:57", + "recorded-content": { + "create-table": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "Username", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "Username", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "KEYS_ONLY" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-stream": { + "StreamDescription": { + "CreationRequestDateTime": "datetime", + "KeySchema": [ + { + "AttributeName": "Username", + "KeyType": "HASH" + } + ], + "Shards": [ + { + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "" + } + ], + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "StreamStatus": "ENABLED", + "StreamViewType": "KEYS_ONLY", + "TableName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-shard-iterator": { + "ShardIterator": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-records": { + "Records": [ + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "Username": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 12, + "StreamViewType": "KEYS_ONLY" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "Username": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 12, + "StreamViewType": "KEYS_ONLY" + }, + "eventID": "", + "eventName": "MODIFY", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "Username": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 12, + "StreamViewType": "KEYS_ONLY" + }, + "eventID": "", + "eventName": "REMOVE", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "Username": { + "S": "Alice" + } + }, + "SequenceNumber": "", + "SizeBytes": 13, + "StreamViewType": "KEYS_ONLY" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "Username": { + "S": "Alice" + } + }, + "SequenceNumber": "", + "SizeBytes": 13, + "StreamViewType": "KEYS_ONLY" + }, + "eventID": "", + "eventName": "MODIFY", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "Username": { + "S": "Alice" + } + }, + "SequenceNumber": "", + "SizeBytes": 13, + "StreamViewType": "KEYS_ONLY" + }, + "eventID": "", + "eventName": "REMOVE", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + } + ] + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_return_values_on_conditions_check_failure": { + "recorded-date": "03-01-2024, 17:52:20", + "recorded-content": { + "items": { + "Error": { + "Code": "ConditionalCheckFailedException", + "Message": "The conditional request failed" + }, + "Item": { + "id": { + "S": "456" + }, + "price": { + "N": "650" + }, + "product": { + "S": "sporting goods" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_write_items_streaming": { + "recorded-date": "31-05-2024, 14:47:04", + "recorded-content": { + "create-table": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-stream": { + "StreamDescription": { + "CreationRequestDateTime": "datetime", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "Shards": [ + { + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "" + } + ], + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "StreamStatus": "ENABLED", + "StreamViewType": "NEW_AND_OLD_IMAGES", + "TableName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-item-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transact-write-response-overwrite": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transact-write-response-update": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transact-write-update-insert": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transact-write-response-delete": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-records": { + "Records": [ + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "Fred" + } + }, + "NewImage": { + "id": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 12, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "NewKey" + } + }, + "NewImage": { + "id": { + "S": "NewKey" + } + }, + "SequenceNumber": "", + "SizeBytes": 16, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "NewKey" + } + }, + "NewImage": { + "attr1": { + "S": "value1" + }, + "attr2": { + "S": "value2" + }, + "id": { + "S": "NewKey" + } + }, + "OldImage": { + "id": { + "S": "NewKey" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "MODIFY", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "NonExistentKey" + } + }, + "NewImage": { + "attr1": { + "S": "value1" + }, + "id": { + "S": "NonExistentKey" + } + }, + "SequenceNumber": "", + "SizeBytes": 43, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "NewKey" + } + }, + "OldImage": { + "attr1": { + "S": "value1" + }, + "attr2": { + "S": "value2" + }, + "id": { + "S": "NewKey" + } + }, + "SequenceNumber": "", + "SizeBytes": 38, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "REMOVE", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "Fred" + } + }, + "NewImage": { + "id": { + "S": "Fred" + }, + "name": { + "S": "Fred" + } + }, + "OldImage": { + "id": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 26, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "MODIFY", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + } + ] + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_write_items_streaming_for_different_tables": { + "recorded-date": "02-04-2024, 21:45:36", + "recorded-content": { + "create-table-stream": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-table-no-stream": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-stream": { + "StreamDescription": { + "CreationRequestDateTime": "datetime", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "Shards": [ + { + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "" + } + ], + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "StreamStatus": "ENABLED", + "StreamViewType": "NEW_AND_OLD_IMAGES", + "TableName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "transact-write-two-tables": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-records": { + "Records": [ + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "Fred" + } + }, + "NewImage": { + "id": { + "S": "Fred" + } + }, + "SequenceNumber": "", + "SizeBytes": 12, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + } + ] + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_stream_records_with_update_item": { + "recorded-date": "26-06-2024, 14:28:27", + "recorded-content": { + "create-table": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-stream": { + "StreamDescription": { + "CreationRequestDateTime": "datetime", + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "Shards": [ + { + "SequenceNumberRange": { + "StartingSequenceNumber": "starting-sequence-number" + }, + "ShardId": "" + } + ], + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "StreamStatus": "ENABLED", + "StreamViewType": "NEW_AND_OLD_IMAGES", + "TableName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-shard-iterator": { + "ShardIterator": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-records": { + "Records": [ + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "my-item-id" + } + }, + "NewImage": { + "attr1": { + "S": "value1" + }, + "attr2": { + "S": "value2" + }, + "id": { + "S": "my-item-id" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "datetime", + "Keys": { + "id": { + "S": "my-item-id" + } + }, + "NewImage": { + "attr1": { + "S": "value2" + }, + "attr2": { + "S": "value3" + }, + "id": { + "S": "my-item-id" + } + }, + "OldImage": { + "attr1": { + "S": "value1" + }, + "attr2": { + "S": "value2" + }, + "id": { + "S": "my-item-id" + } + }, + "SequenceNumber": "", + "SizeBytes": 80, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventID": "", + "eventName": "MODIFY", + "eventSource": "aws:dynamodb", + "eventVersion": "1.1" + } + ] + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_update_table_without_sse_specification_change": { + "recorded-date": "17-12-2024, 10:40:03", + "recorded-content": { + "create_table_sse_description": { + "KMSMasterKeyArn": "arn::kms::111111111111:key/", + "SSEType": "KMS", + "Status": "ENABLED" + }, + "describe_kms_key": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "Default key that protects my DynamoDB data when no other key is defined", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "AWS", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_table_sse_description": { + "KMSMasterKeyArn": "arn::kms::111111111111:key/", + "SSEType": "KMS", + "Status": "ENABLED" + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_execute_statement_empy_parameter": { + "recorded-date": "03-01-2025, 09:24:27", + "recorded-content": { + "invalid-param-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[]' at 'parameters' failed to satisfy constraint: Member must have length greater than or equal to 1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_gsi_with_billing_mode[PAY_PER_REQUEST]": { + "recorded-date": "08-01-2025, 18:17:06", + "recorded-content": { + "create-table-with-gsi": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "TID", + "AttributeType": "S" + }, + { + "AttributeName": "TRID", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "GlobalSecondaryIndexes": [ + { + "IndexArn": "arn::dynamodb::111111111111:table//index/TransactionRecordID", + "IndexName": "TransactionRecordID", + "IndexSizeBytes": 0, + "IndexStatus": "", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TRID", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + } + } + ], + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TID", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-table": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "TID", + "AttributeType": "S" + }, + { + "AttributeName": "TRID", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "GlobalSecondaryIndexes": [ + { + "IndexArn": "arn::dynamodb::111111111111:table//index/TransactionRecordID", + "IndexName": "TransactionRecordID", + "IndexSizeBytes": 0, + "IndexStatus": "", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TRID", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + } + } + ], + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TID", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_gsi_with_billing_mode[PROVISIONED]": { + "recorded-date": "08-01-2025, 18:17:21", + "recorded-content": { + "create-table-with-gsi": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "TID", + "AttributeType": "S" + }, + { + "AttributeName": "TRID", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "GlobalSecondaryIndexes": [ + { + "IndexArn": "arn::dynamodb::111111111111:table//index/TransactionRecordID", + "IndexName": "TransactionRecordID", + "IndexSizeBytes": 0, + "IndexStatus": "", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TRID", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + } + } + ], + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TID", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-table": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "TID", + "AttributeType": "S" + }, + { + "AttributeName": "TRID", + "AttributeType": "S" + } + ], + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "GlobalSecondaryIndexes": [ + { + "IndexArn": "arn::dynamodb::111111111111:table//index/TransactionRecordID", + "IndexName": "TransactionRecordID", + "IndexSizeBytes": 0, + "IndexStatus": "", + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TRID", + "KeyType": "HASH" + } + ], + "Projection": { + "ProjectionType": "ALL" + }, + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + } + } + ], + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "TID", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_streams_on_global_tables": { + "recorded-date": "22-05-2025, 12:44:58", + "recorded-content": { + "region-streams": { + "Streams": [ + { + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "TableName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "secondary-region-streams": { + "Streams": [ + { + "StreamArn": "arn::dynamodb::111111111111:table//stream/", + "StreamLabel": "", + "TableName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/dynamodb/test_dynamodb.validation.json b/tests/aws/services/dynamodb/test_dynamodb.validation.json new file mode 100644 index 0000000000000..6a2220f1f2937 --- /dev/null +++ b/tests/aws/services/dynamodb/test_dynamodb.validation.json @@ -0,0 +1,101 @@ +{ + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_binary": { + "last_validated_date": "2023-08-23T14:32:35+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_items": { + "last_validated_date": "2023-08-23T14:33:43+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_items_streaming": { + "last_validated_date": "2023-10-22T21:23:09+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_not_existing_table": { + "last_validated_date": "2023-10-22T18:45:30+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_batch_write_not_matching_schema": { + "last_validated_date": "2023-08-23T14:34:26+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_continuous_backup_update": { + "last_validated_date": "2023-08-23T14:34:39+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_create_duplicate_table": { + "last_validated_date": "2023-08-23T14:33:08+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_data_encoding_consistency": { + "last_validated_date": "2023-08-24T08:56:37+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_batch_execute_statement": { + "last_validated_date": "2023-08-23T14:32:51+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_create_table_with_partial_sse_specification": { + "last_validated_date": "2024-01-10T12:59:50+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_execute_statement_empy_parameter": { + "last_validated_date": "2025-01-03T09:24:27+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_execute_transaction": { + "last_validated_date": "2023-08-23T14:32:44+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_get_batch_items": { + "last_validated_date": "2023-08-23T14:33:58+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_idempotent_writing": { + "last_validated_date": "2023-08-23T14:39:54+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_partiql_missing": { + "last_validated_date": "2023-08-23T14:33:00+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_pay_per_request": { + "last_validated_date": "2023-08-23T14:33:43+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_stream_records_with_update_item": { + "last_validated_date": "2024-06-26T14:28:26+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_stream_stream_view_type": { + "last_validated_date": "2024-05-31T14:49:56+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_streams_describe_with_exclusive_start_shard_id": { + "last_validated_date": "2023-10-22T20:27:28+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_dynamodb_update_table_without_sse_specification_change": { + "last_validated_date": "2024-12-17T10:39:19+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_empty_and_binary_values": { + "last_validated_date": "2023-08-23T14:32:29+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_gsi_with_billing_mode[PAY_PER_REQUEST]": { + "last_validated_date": "2025-01-08T18:17:06+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_gsi_with_billing_mode[PROVISIONED]": { + "last_validated_date": "2025-01-08T18:17:21+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_return_values_in_put_item": { + "last_validated_date": "2023-08-23T14:32:21+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_return_values_on_conditions_check_failure": { + "last_validated_date": "2024-01-03T17:52:19+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_streams_on_global_tables": { + "last_validated_date": "2025-05-22T12:44:55+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_get_items": { + "last_validated_date": "2023-08-23T14:33:37+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_write_items_streaming": { + "last_validated_date": "2024-05-31T14:47:04+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transact_write_items_streaming_for_different_tables": { + "last_validated_date": "2024-04-02T21:45:36+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_binary_data": { + "last_validated_date": "2023-08-23T14:33:31+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_canceled": { + "last_validated_date": "2023-08-23T14:33:24+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_transaction_write_items": { + "last_validated_date": "2023-08-23T14:33:16+00:00" + }, + "tests/aws/services/dynamodb/test_dynamodb.py::TestDynamoDB::test_valid_local_secondary_index": { + "last_validated_date": "2023-08-23T14:32:11+00:00" + } +} diff --git a/tests/aws/services/dynamodbstreams/__init__.py b/tests/aws/services/dynamodbstreams/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/dynamodbstreams/test_dynamodb_streams.py b/tests/aws/services/dynamodbstreams/test_dynamodb_streams.py new file mode 100644 index 0000000000000..cb1eb03fe0154 --- /dev/null +++ b/tests/aws/services/dynamodbstreams/test_dynamodb_streams.py @@ -0,0 +1,207 @@ +import json +import re + +import aws_cdk as cdk +import pytest +from botocore.exceptions import ClientError + +from localstack import config +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws import arns, resources +from localstack.utils.aws.arns import kinesis_stream_arn +from localstack.utils.aws.queries import kinesis_get_latest_records +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +# default partition key used for test tables +PARTITION_KEY = "id" + + +class TestDynamoDBStreams: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Table.ProvisionedThroughput.LastDecreaseDateTime", + "$..Table.ProvisionedThroughput.LastIncreaseDateTime", + "$..Table.Replicas", + ] + ) + def test_table_v2_stream(self, aws_client, infrastructure_setup, snapshot): + snapshot.add_transformer(snapshot.transform.dynamodb_streams_api()) + snapshot.add_transformer(snapshot.transform.key_value("LatestStreamArn"), priority=-1) + snapshot.add_transformer(snapshot.transform.key_value("TableArn"), priority=-1) + + infra = infrastructure_setup(namespace="TestTableV2Stream") + stack = cdk.Stack(infra.cdk_app, "TableV2StreamStack") + + table = cdk.aws_dynamodb.TableV2( + stack, + "v2table", + partition_key=cdk.aws_dynamodb.Attribute( + name=PARTITION_KEY, type=cdk.aws_dynamodb.AttributeType.STRING + ), + removal_policy=cdk.RemovalPolicy.DESTROY, + dynamo_stream=cdk.aws_dynamodb.StreamViewType.NEW_AND_OLD_IMAGES, + ) + + cdk.CfnOutput(stack, "tableName", value=table.table_name) + + with infra.provisioner(skip_teardown=False) as prov: + table_name = prov.get_stack_outputs(stack_name="TableV2StreamStack")["tableName"] + response = aws_client.dynamodb.describe_table(TableName=table_name) + snapshot.match("global-table-v2", response) + + @markers.aws.only_localstack + def test_stream_spec_and_region_replacement(self, aws_client, region_name): + # our V1 and V2 implementation are pretty different, and we need different ways to test it + ddbstreams = aws_client.dynamodbstreams + table_name = f"ddb-{short_uid()}" + resources.create_dynamodb_table( + table_name, + partition_key=PARTITION_KEY, + stream_view_type="NEW_AND_OLD_IMAGES", + client=aws_client.dynamodb, + ) + + table = aws_client.dynamodb.describe_table(TableName=table_name)["Table"] + + # assert ARN formats + expected_arn_prefix = f"arn:aws:dynamodb:{region_name}" + assert table["TableArn"].startswith(expected_arn_prefix) + assert table["LatestStreamArn"].startswith(expected_arn_prefix) + + # test list_streams filtering + stream_tables = ddbstreams.list_streams(TableName="foo")["Streams"] + assert len(stream_tables) == 0 + + if not config.DDB_STREAMS_PROVIDER_V2: + from localstack.services.dynamodbstreams.dynamodbstreams_api import ( + get_kinesis_stream_name, + ) + + stream_name = get_kinesis_stream_name(table_name) + assert stream_name in aws_client.kinesis.list_streams()["StreamNames"] + + # assert stream has been created + stream_tables = [ + s["TableName"] for s in ddbstreams.list_streams(TableName=table_name)["Streams"] + ] + assert table_name in stream_tables + assert len(stream_tables) == 1 + + # assert shard ID formats + result = ddbstreams.describe_stream(StreamArn=table["LatestStreamArn"])["StreamDescription"] + assert "Shards" in result + for shard in result["Shards"]: + assert re.match(r"^shardId-[0-9]{20}-[a-zA-Z0-9]{1,36}$", shard["ShardId"]) + + # clean up + aws_client.dynamodb.delete_table(TableName=table_name) + + def _assert_stream_disabled(): + if config.DDB_STREAMS_PROVIDER_V2: + _result = aws_client.dynamodbstreams.describe_stream( + StreamArn=table["LatestStreamArn"] + ) + assert _result["StreamDescription"]["StreamStatus"] == "DISABLED" + else: + _stream_tables = [s["TableName"] for s in ddbstreams.list_streams()["Streams"]] + assert table_name not in _stream_tables + assert stream_name not in aws_client.kinesis.list_streams()["StreamNames"] + + # assert stream has been deleted + retry(_assert_stream_disabled, sleep=1, retries=20) + + @pytest.mark.skipif( + condition=not is_aws_cloud() or config.DDB_STREAMS_PROVIDER_V2, + reason="Flaky, and not implemented yet on v2 implementation", + ) + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..EncryptionType", "$..SizeBytes"]) + def test_enable_kinesis_streaming_destination( + self, + aws_client, + dynamodb_create_table, + kinesis_create_stream, + wait_for_stream_ready, + account_id, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.key_value("SequenceNumber")) + snapshot.add_transformer(snapshot.transform.key_value("PartitionKey")) + snapshot.add_transformer( + snapshot.transform.key_value("ApproximateArrivalTimestamp", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("ApproximateCreationDateTime", reference_replacement=False) + ) + snapshot.add_transformer(snapshot.transform.key_value("eventID")) + snapshot.add_transformer(snapshot.transform.key_value("tableName")) + + dynamodb = aws_client.dynamodb + kinesis = aws_client.kinesis + + # create DDB table and Kinesis stream + table = dynamodb_create_table() + table_name = table["TableDescription"]["TableName"] + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + stream_arn = kinesis_stream_arn( + stream_name, account_id, region_name=kinesis.meta.region_name + ) + stream_details = kinesis.describe_stream(StreamName=stream_name)["StreamDescription"] + shards = stream_details["Shards"] + assert len(shards) == 1 + + # enable kinesis streaming destination + dynamodb.enable_kinesis_streaming_destination(TableName=table_name, StreamArn=stream_arn) + + def _stream_active(): + details = dynamodb.describe_kinesis_streaming_destination(TableName=table_name) + destinations = details["KinesisDataStreamDestinations"] + assert len(destinations) == 1 + assert destinations[0]["DestinationStatus"] == "ACTIVE" + return destinations[0] + + # wait until stream is active + retry(_stream_active, sleep=10 if is_aws_cloud() else 0.7, retries=10) + + # write item to table + updates = [{"Put": {"Item": {PARTITION_KEY: {"S": "test"}}, "TableName": table_name}}] + dynamodb.transact_write_items(TransactItems=updates) + + def _receive_records(): + _records = kinesis_get_latest_records(stream_name, shards[0]["ShardId"], client=kinesis) + assert _records + return _records + + # assert that record has been received in the stream + records = retry(_receive_records, sleep=0.7, retries=15) + + for record in records: + record["Data"] = json.loads(record["Data"]) + + # assert that the PartitionKey is a Hex string looking like an MD5 hash + assert len(records[0]["PartitionKey"]) == 32 + assert int(records[0]["PartitionKey"], 16) + snapshot.match("result-records", records) + + @markers.aws.validated + def test_non_existent_stream(self, aws_client, region_name, account_id, snapshot): + table_name = f"non-existent-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(table_name, "")) + with pytest.raises(ClientError) as e: + bad_stream_name = arns.dynamodb_stream_arn( + account_id=account_id, + region_name=region_name, + latest_stream_label="2024-11-18T14:36:44.149", + table_name=table_name, + ) + aws_client.dynamodbstreams.describe_stream(StreamArn=bad_stream_name) + + snapshot.match("non-existent-stream", e.value.response) + message = e.value.response["Error"]["Message"] + # assert that we do not have ddblocal region and default account id + assert f":{account_id}:" in message + assert f":{region_name}" in message diff --git a/tests/aws/services/dynamodbstreams/test_dynamodb_streams.snapshot.json b/tests/aws/services/dynamodbstreams/test_dynamodb_streams.snapshot.json new file mode 100644 index 0000000000000..129c09529015e --- /dev/null +++ b/tests/aws/services/dynamodbstreams/test_dynamodb_streams.snapshot.json @@ -0,0 +1,101 @@ +{ + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_enable_kinesis_streaming_destination": { + "recorded-date": "30-01-2024, 20:27:32", + "recorded-content": { + "result-records": [ + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": { + "awsRegion": "", + "eventID": "", + "eventName": "INSERT", + "userIdentity": null, + "recordFormat": "application/json", + "tableName": "", + "dynamodb": { + "ApproximateCreationDateTime": "approximate-creation-date-time", + "Keys": { + "id": { + "S": "test" + } + }, + "NewImage": { + "id": { + "S": "test" + } + }, + "SizeBytes": 12 + }, + "eventSource": "aws:dynamodb" + }, + "PartitionKey": "" + } + ] + } + }, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_table_v2_stream": { + "recorded-date": "12-06-2024, 21:57:48", + "recorded-content": { + "global-table-v2": { + "Table": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "TableArn": "", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_non_existent_stream": { + "recorded-date": "20-11-2024, 11:02:24", + "recorded-content": { + "non-existent-stream": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Requested resource not found: Stream: arn::dynamodb::111111111111:table//stream/2024-11-18T14:36:44.149 not found" + }, + "message": "Requested resource not found: Stream: arn::dynamodb::111111111111:table//stream/2024-11-18T14:36:44.149 not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/dynamodbstreams/test_dynamodb_streams.validation.json b/tests/aws/services/dynamodbstreams/test_dynamodb_streams.validation.json new file mode 100644 index 0000000000000..da11f399f14e0 --- /dev/null +++ b/tests/aws/services/dynamodbstreams/test_dynamodb_streams.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_enable_kinesis_streaming_destination": { + "last_validated_date": "2024-01-30T20:27:32+00:00" + }, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_non_existent_stream": { + "last_validated_date": "2024-11-20T11:02:24+00:00" + }, + "tests/aws/services/dynamodbstreams/test_dynamodb_streams.py::TestDynamoDBStreams::test_table_v2_stream": { + "last_validated_date": "2024-06-12T21:57:48+00:00" + } +} diff --git a/tests/aws/services/ec2/__init__.py b/tests/aws/services/ec2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/ec2/test_ec2.py b/tests/aws/services/ec2/test_ec2.py new file mode 100644 index 0000000000000..bc809e1edd022 --- /dev/null +++ b/tests/aws/services/ec2/test_ec2.py @@ -0,0 +1,1032 @@ +import contextlib +import logging + +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import SortingTransformer +from moto.ec2 import ec2_backends +from moto.ec2.utils import ( + random_security_group_id, + random_subnet_id, + random_vpc_id, +) + +from localstack.constants import AWS_REGION_US_EAST_1, TAG_KEY_CUSTOM_ID +from localstack.services.ec2.patches import SecurityGroupIdentifier, VpcIdentifier +from localstack.testing.pytest import markers +from localstack.utils.id_generator import localstack_id_manager +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +LOG = logging.getLogger(__name__) + +# public amazon image used for ec2 launch templates +PUBLIC_AMAZON_LINUX_IMAGE = "ami-06c39ed6b42908a36" +PUBLIC_AMAZON_UBUNTU_IMAGE = "ami-03e08697c325f02ab" + + +@pytest.fixture() +def create_launch_template(aws_client): + template_ids = [] + + def create(template_name): + response = aws_client.ec2.create_launch_template( + LaunchTemplateName=template_name, + LaunchTemplateData={ + "ImageId": PUBLIC_AMAZON_LINUX_IMAGE, + }, + ) + template_ids.append(response["LaunchTemplate"]["LaunchTemplateId"]) + return response + + yield create + for id in template_ids: + with contextlib.suppress(ClientError): + aws_client.ec2.delete_launch_template(LaunchTemplateId=id) + + +@pytest.fixture() +def create_vpc(aws_client): + vpcs = [] + + def _create_vpc( + cidr_block: str, + tag_specifications: list[dict] | None = None, + ): + tag_specifications = tag_specifications or [] + vpc = aws_client.ec2.create_vpc(CidrBlock=cidr_block, TagSpecifications=tag_specifications) + vpcs.append(vpc["Vpc"]["VpcId"]) + return vpc + + yield _create_vpc + + for vpc_id in vpcs: + # Best effort deletion of VPC resources + try: + aws_client.ec2.delete_vpc(VpcId=vpc_id) + except Exception: + pass + + +class TestEc2Integrations: + @markers.snapshot.skip_snapshot_verify(paths=["$..PropagatingVgws"]) + @markers.aws.validated + def test_create_route_table_association(self, cleanups, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("vpc_id"), + snapshot.transform.key_value("subnet_id"), + snapshot.transform.key_value("route_table_id"), + snapshot.transform.key_value("association_id"), + snapshot.transform.key_value("ClientToken"), + ] + ) + vpc_id = aws_client.ec2.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpc_id)) + snapshot.match("vpc_id", vpc_id) + + subnet_id = aws_client.ec2.create_subnet(VpcId=vpc_id, CidrBlock="10.0.0.0/24")["Subnet"][ + "SubnetId" + ] + cleanups.append(lambda: aws_client.ec2.delete_subnet(SubnetId=subnet_id)) + snapshot.match("subnet_id", subnet_id) + + route_table_id = aws_client.ec2.create_route_table(VpcId=vpc_id)["RouteTable"][ + "RouteTableId" + ] + cleanups.append(lambda: aws_client.ec2.delete_route_table(RouteTableId=route_table_id)) + snapshot.match("route_table_id", route_table_id) + + association_id = aws_client.ec2.associate_route_table( + RouteTableId=route_table_id, + SubnetId=subnet_id, + )["AssociationId"] + cleanups.append( + lambda: aws_client.ec2.disassociate_route_table(AssociationId=association_id) + ) + snapshot.match("association_id", association_id) + + route_tables = aws_client.ec2.describe_route_tables(RouteTableIds=[route_table_id])[ + "RouteTables" + ] + snapshot.match("route_tables", route_tables) + + aws_client.ec2.disassociate_route_table(AssociationId=association_id) + for route_tables in aws_client.ec2.describe_route_tables(RouteTableIds=[route_table_id])[ + "RouteTables" + ]: + assert route_tables["Associations"] == [] + + @markers.aws.needs_fixing + # TODO LocalStack fails to delete endpoints + # LocalStack does not properly initiate Endpoints with no VpcEndpointType fix probably needed in moto + # AWS does not allow for lowercase VpcEndpointType: gateway => Gateway + def test_create_vpc_endpoint(self, cleanups, aws_client): + vpc = aws_client.ec2.create_vpc(CidrBlock="10.0.0.0/16") + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpc["Vpc"]["VpcId"])) + subnet = aws_client.ec2.create_subnet(VpcId=vpc["Vpc"]["VpcId"], CidrBlock="10.0.0.0/24") + cleanups.append(lambda: aws_client.ec2.delete_subnet(SubnetId=subnet["Subnet"]["SubnetId"])) + route_table = aws_client.ec2.create_route_table(VpcId=vpc["Vpc"]["VpcId"]) + cleanups.append( + lambda: aws_client.ec2.delete_route_table( + RouteTableId=route_table["RouteTable"]["RouteTableId"] + ) + ) + + # test without any endpoint type specified + vpc_endpoint = aws_client.ec2.create_vpc_endpoint( + VpcId=vpc["Vpc"]["VpcId"], + ServiceName="com.amazonaws.us-east-1.s3", + RouteTableIds=[route_table["RouteTable"]["RouteTableId"]], + ) + cleanups.append( + lambda: aws_client.ec2.delete_vpc_endpoints( + VpcEndpointIds=[vpc_endpoint["VpcEndpoint"]["VpcEndpointId"]] + ) + ) + + assert "com.amazonaws.us-east-1.s3" == vpc_endpoint["VpcEndpoint"]["ServiceName"] + assert ( + route_table["RouteTable"]["RouteTableId"] + == vpc_endpoint["VpcEndpoint"]["RouteTableIds"][0] + ) + assert vpc["Vpc"]["VpcId"] == vpc_endpoint["VpcEndpoint"]["VpcId"] + assert 0 == len(vpc_endpoint["VpcEndpoint"]["DnsEntries"]) + + # test with any endpoint type as gateway + vpc_endpoint = aws_client.ec2.create_vpc_endpoint( + VpcId=vpc["Vpc"]["VpcId"], + ServiceName="com.amazonaws.us-east-1.s3", + RouteTableIds=[route_table["RouteTable"]["RouteTableId"]], + VpcEndpointType="gateway", + ) + cleanups.append( + lambda: aws_client.ec2.delete_vpc_endpoints( + VpcEndpointIds=[vpc_endpoint["VpcEndpoint"]["VpcEndpointId"]] + ) + ) + + assert "com.amazonaws.us-east-1.s3" == vpc_endpoint["VpcEndpoint"]["ServiceName"] + assert ( + route_table["RouteTable"]["RouteTableId"] + == vpc_endpoint["VpcEndpoint"]["RouteTableIds"][0] + ) + assert vpc["Vpc"]["VpcId"] == vpc_endpoint["VpcEndpoint"]["VpcId"] + assert 0 == len(vpc_endpoint["VpcEndpoint"]["DnsEntries"]) + + # test with endpoint type as interface + vpc_endpoint = aws_client.ec2.create_vpc_endpoint( + VpcId=vpc["Vpc"]["VpcId"], + ServiceName="com.amazonaws.us-east-1.s3", + SubnetIds=[subnet["Subnet"]["SubnetId"]], + VpcEndpointType="interface", + ) + cleanups.append( + lambda: aws_client.ec2.delete_vpc_endpoints( + VpcEndpointIds=[vpc_endpoint["VpcEndpoint"]["VpcEndpointId"]] + ) + ) + + assert "com.amazonaws.us-east-1.s3" == vpc_endpoint["VpcEndpoint"]["ServiceName"] + assert subnet["Subnet"]["SubnetId"] == vpc_endpoint["VpcEndpoint"]["SubnetIds"][0] + assert vpc["Vpc"]["VpcId"] == vpc_endpoint["VpcEndpoint"]["VpcId"] + assert len(vpc_endpoint["VpcEndpoint"]["DnsEntries"]) > 0 + + @markers.aws.only_localstack + # This test would attempt to purchase Reserved instance. + def test_reserved_instance_api(self, aws_client): + rs = aws_client.ec2.describe_reserved_instances_offerings( + AvailabilityZone="us-east-1a", + IncludeMarketplace=True, + InstanceType="t2.small", + OfferingClass="standard", + ProductDescription="Linux/UNIX", + ReservedInstancesOfferingIds=[ + "string", + ], + OfferingType="Heavy Utilization", + ) + assert 200 == rs["ResponseMetadata"]["HTTPStatusCode"] + + rs = aws_client.ec2.purchase_reserved_instances_offering( + InstanceCount=1, + ReservedInstancesOfferingId="string", + LimitPrice={"Amount": 100.0, "CurrencyCode": "USD"}, + ) + assert 200 == rs["ResponseMetadata"]["HTTPStatusCode"] + + rs = aws_client.ec2.describe_reserved_instances( + OfferingClass="standard", + ReservedInstancesIds=[ + "string", + ], + OfferingType="Heavy Utilization", + ) + assert 200 == rs["ResponseMetadata"]["HTTPStatusCode"] + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # AWS doesn't populate all info of the requester to the peer describe until connection available + "$..pending-acceptance..VpcPeeringConnections..AccepterVpcInfo.CidrBlock", + "$..pending-acceptance..VpcPeeringConnections..AccepterVpcInfo.PeeringOptions", + # LS leaves as `[]` + "$..VpcPeeringConnections..AccepterVpcInfo.CidrBlockSet", + "$..VpcPeeringConnections..RequesterVpcInfo.CidrBlockSet", + # LS adds, not on AWS + "$..VpcPeeringConnections..AccepterVpcInfo.Ipv6CidrBlockSet", + "$..VpcPeeringConnections..RequesterVpcInfo.Ipv6CidrBlockSet", + # LS doesn't add + "$..VpcPeeringConnections..ExpirationTime", + ] + ) + @markers.aws.validated + def test_vcp_peering_difference_regions( + self, aws_client_factory, region_name, cleanups, snapshot, secondary_region_name + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("vpc-id"), + snapshot.transform.key_value("peering-connection-id"), + snapshot.transform.key_value("region"), + ] + ) + region1 = region_name + region2 = secondary_region_name + ec2_client1 = aws_client_factory(region_name=region1).ec2 + ec2_client2 = aws_client_factory(region_name=region2).ec2 + + def _delete_vpc(client, vpc_id): + # The Peering connection in the peer vpc might have a delay to detach from the vpc + return lambda: retry(lambda: client.delete_vpc(VpcId=vpc_id), retries=10, sleep=5) + + # CIDR range can't overlap when creating peering connection + cidr_block1 = "192.168.1.0/24" + cidr_block2 = "192.168.2.0/24" + peer_vpc1_id = ec2_client1.create_vpc(CidrBlock=cidr_block1)["Vpc"]["VpcId"] + cleanups.append(_delete_vpc(ec2_client1, peer_vpc1_id)) + snapshot.match("vpc1", {"vpc-id": peer_vpc1_id, "region": region1}) + + peer_vpc2_id = ec2_client2.create_vpc(CidrBlock=cidr_block2)["Vpc"]["VpcId"] + cleanups.append(_delete_vpc(ec2_client2, peer_vpc2_id)) + snapshot.match("vpc2", {"vpc-id": peer_vpc2_id, "region": region2}) + + peering_connection_id = ec2_client1.create_vpc_peering_connection( + VpcId=peer_vpc1_id, + PeerVpcId=peer_vpc2_id, + PeerRegion=region2, + )["VpcPeeringConnection"]["VpcPeeringConnectionId"] + cleanups.append( + lambda: ec2_client1.delete_vpc_peering_connection( + VpcPeeringConnectionId=peering_connection_id + ) + ) + snapshot.match("peering-connection-id", peering_connection_id) + + def _describe_peering_connections(client, expected_status): + response = client.describe_vpc_peering_connections( + VpcPeeringConnectionIds=[peering_connection_id] + ) + assert response["VpcPeeringConnections"][0]["Status"]["Code"] == expected_status + return response + + # wait for the peering connection to be observable in the peer region + pending_peer = retry( + lambda: _describe_peering_connections(ec2_client2, "pending-acceptance"), + retries=10, + sleep=5, + ) + snapshot.match("pending-acceptance", pending_peer) + + # Not creating a snapshot of the response as aws isn't consistent + ec2_client2.accept_vpc_peering_connection(VpcPeeringConnectionId=peering_connection_id) + + # wait for peering connection to be active in the requester region + requester_peer = retry( + lambda: _describe_peering_connections(ec2_client1, "active"), retries=10, sleep=5 + ) + snapshot.match("requester-peer", requester_peer) + + # wait for peering connection to be active in the peer region + accepter_peer = retry( + lambda: _describe_peering_connections(ec2_client2, "active"), retries=10, sleep=5 + ) + snapshot.match("accepter-peer", accepter_peer) + + @markers.snapshot.skip_snapshot_verify( + paths=["$..AmazonSideAsn", "$..AvailabilityZone", "$..Tags"] + ) + @markers.aws.validated + def test_describe_vpn_gateways_filter_by_vpc(self, aws_client, cleanups, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("vpc-id"), + snapshot.transform.key_value("VpnGatewayId"), + ] + ) + + vpc_id = aws_client.ec2.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpc_id)) + snapshot.match("vpc-id", vpc_id) + + gateway = aws_client.ec2.create_vpn_gateway(AvailabilityZone="us-east-1a", Type="ipsec.1") + gateway_id = gateway["VpnGateway"]["VpnGatewayId"] + cleanups.append(lambda: aws_client.ec2.delete_vpn_gateway(VpnGatewayId=gateway_id)) + snapshot.match("gateway", gateway) + + def _detach_vpn_gateway(): + aws_client.ec2.detach_vpn_gateway(VpcId=vpc_id, VpnGatewayId=gateway_id) + # This is a bit convoluted, but trying to delete a vpc with an attached vpn gateway + # fails silently. So a simple retry on the delete_vpc will not work. + retry( + lambda: aws_client.ec2.describe_vpn_gateways( + Filters=[ + {"Name": "vpn-gateway-id", "Values": [gateway_id]}, + {"Name": "attachment.state", "Values": ["detached"]}, + ] + )["VpnGateways"][0], + retries=20, + sleep=5, + ) + + aws_client.ec2.attach_vpn_gateway(VpcId=vpc_id, VpnGatewayId=gateway_id) + cleanups.append(_detach_vpn_gateway) + + def _describe_vpn_gateway(): + gateways = aws_client.ec2.describe_vpn_gateways( + Filters=[ + {"Name": "attachment.vpc-id", "Values": [vpc_id]}, + ], + )["VpnGateways"] + assert gateways[0]["VpcAttachments"][0]["State"] == "attached" + return gateways[0] + + gateway = retry(_describe_vpn_gateway, retries=20, sleep=5) + snapshot.match("attached-gateway", gateway) + + @markers.aws.needs_fixing + # AWS returns 272 elements and a fair bit more information about them than LS + def test_describe_vpc_endpoints_with_filter(self, aws_client, region_name): + vpc = aws_client.ec2.create_vpc(CidrBlock="10.0.0.0/16") + vpc_id = vpc["Vpc"]["VpcId"] + + # test filter of Gateway endpoint services + vpc_endpoint_gateway_services = aws_client.ec2.describe_vpc_endpoint_services( + Filters=[ + {"Name": "service-type", "Values": ["Gateway"]}, + ], + ) + + assert 200 == vpc_endpoint_gateway_services["ResponseMetadata"]["HTTPStatusCode"] + services = vpc_endpoint_gateway_services["ServiceNames"] + assert 2 == len(services) + assert f"com.amazonaws.{region_name}.dynamodb" in services + assert f"com.amazonaws.{region_name}.s3" in services + + # test filter of Interface endpoint services + vpc_endpoint_interface_services = aws_client.ec2.describe_vpc_endpoint_services( + Filters=[ + {"Name": "service-type", "Values": ["Interface"]}, + ], + ) + + assert 200 == vpc_endpoint_interface_services["ResponseMetadata"]["HTTPStatusCode"] + services = vpc_endpoint_interface_services["ServiceNames"] + assert len(services) > 0 + assert ( + f"com.amazonaws.{region_name}.s3" in services + ) # S3 is both gateway and interface service + assert f"com.amazonaws.{region_name}.kinesis-firehose" in services + + # test filter that does not exist + vpc_endpoint_interface_services = aws_client.ec2.describe_vpc_endpoint_services( + Filters=[ + {"Name": "service-type", "Values": ["fake"]}, + ], + ) + + assert 200 == vpc_endpoint_interface_services["ResponseMetadata"]["HTTPStatusCode"] + services = vpc_endpoint_interface_services["ServiceNames"] + assert len(services) == 0 + + # clean up + aws_client.ec2.delete_vpc(VpcId=vpc_id) + + @markers.aws.validated + @pytest.mark.parametrize("id_type", ["id", "name"]) + def test_modify_launch_template(self, create_launch_template, id_type, aws_client): + launch_template_result = create_launch_template(f"template-with-versions-{short_uid()}") + template = launch_template_result["LaunchTemplate"] + + # call the API identifying the template either by `LaunchTemplateId` or `LaunchTemplateName` + kwargs = ( + {"LaunchTemplateId": template["LaunchTemplateId"]} + if (id_type == "id") + else {"LaunchTemplateName": template["LaunchTemplateName"]} + ) + + new_version_result = aws_client.ec2.create_launch_template_version( + LaunchTemplateData={"ImageId": PUBLIC_AMAZON_UBUNTU_IMAGE}, **kwargs + ) + + new_default_version = new_version_result["LaunchTemplateVersion"]["VersionNumber"] + aws_client.ec2.modify_launch_template( + LaunchTemplateId=template["LaunchTemplateId"], + DefaultVersion=str(new_default_version), + ) + + modified_template = aws_client.ec2.describe_launch_templates( + LaunchTemplateIds=[template["LaunchTemplateId"]] + ) + assert modified_template["LaunchTemplates"][0]["DefaultVersionNumber"] == int( + new_default_version + ) + + @markers.aws.only_localstack + def test_create_vpc_with_custom_id(self, aws_client, create_vpc): + custom_id = random_vpc_id() + + # Check if the custom ID is present + vpc: dict = create_vpc( + cidr_block="10.0.0.0/16", + tag_specifications=[ + { + "ResourceType": "vpc", + "Tags": [ + {"Key": TAG_KEY_CUSTOM_ID, "Value": custom_id}, + ], + } + ], + ) + assert vpc["Vpc"]["VpcId"] == custom_id + + # Check if the custom ID is present in the describe_vpcs response as well + vpc: dict = aws_client.ec2.describe_vpcs(VpcIds=[custom_id])["Vpcs"][0] + assert vpc["VpcId"] == custom_id + assert len(vpc["Tags"]) == 1 + assert vpc["Tags"][0]["Key"] == TAG_KEY_CUSTOM_ID + assert vpc["Tags"][0]["Value"] == custom_id + + # Check if an duplicate custom ID exception is thrown if we try to recreate the VPC with the same custom ID + with pytest.raises(ClientError) as e: + create_vpc( + cidr_block="10.0.0.0/16", + tag_specifications=[ + { + "ResourceType": "vpc", + "Tags": [ + {"Key": TAG_KEY_CUSTOM_ID, "Value": custom_id}, + ], + } + ], + ) + + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert e.value.response["Error"]["Code"] == "InvalidVpc.DuplicateCustomId" + + @markers.aws.only_localstack + def test_create_subnet_with_tags(self, cleanups, aws_client, create_vpc): + # Create a VPC. + vpc: dict = create_vpc( + cidr_block="10.0.0.0/16", + tag_specifications=[ + { + "ResourceType": "vpc", + "Tags": [ + {"Key": "Name", "Value": "main-vpc"}, + ], + } + ], + ) + vpc_id: str = vpc["Vpc"]["VpcId"] + + # Create a subnet with a tag. + subnet: dict = aws_client.ec2.create_subnet( + VpcId=vpc_id, + CidrBlock="10.0.0.0/24", + TagSpecifications=[ + { + "ResourceType": "subnet", + "Tags": [ + {"Key": "Name", "Value": "main-subnet"}, + ], + } + ], + ) + cleanups.append(lambda: aws_client.ec2.delete_subnet(SubnetId=subnet["Subnet"]["SubnetId"])) + assert subnet["Subnet"]["VpcId"] == vpc_id + subnet_id: str = subnet["Subnet"]["SubnetId"] + + # Now check that the tags make it back on the describe subnets call. + subnet: dict = aws_client.ec2.describe_subnets( + SubnetIds=[subnet_id], + )["Subnets"][0] + assert subnet["SubnetId"] == subnet_id + assert subnet["VpcId"] == vpc_id + assert len(subnet["Tags"]) == 1 + assert subnet["Tags"][0]["Key"] == "Name" + assert subnet["Tags"][0]["Value"] == "main-subnet" + + @markers.aws.only_localstack + def test_create_subnet_with_custom_id(self, cleanups, aws_client, create_vpc): + custom_id = random_subnet_id() + + # Create necessary VPC resource + vpc: dict = create_vpc(cidr_block="10.0.0.0/16", tag_specifications=[]) + vpc_id = vpc["Vpc"]["VpcId"] + + # Check if subnet ID matches the custom ID + subnet: dict = aws_client.ec2.create_subnet( + VpcId=vpc_id, + CidrBlock="10.0.0.0/24", + TagSpecifications=[ + { + "ResourceType": "subnet", + "Tags": [ + {"Key": TAG_KEY_CUSTOM_ID, "Value": custom_id}, + ], + } + ], + ) + cleanups.append(lambda: aws_client.ec2.delete_subnet(SubnetId=subnet["Subnet"]["SubnetId"])) + assert subnet["Subnet"]["SubnetId"] == custom_id + + # Check if the custom ID is present in the describe_subnets response as well + subnet: dict = aws_client.ec2.describe_subnets( + SubnetIds=[custom_id], + )["Subnets"][0] + assert subnet["SubnetId"] == custom_id + + # Check if a duplicate custom ID exception is thrown if we try to recreate the subnet with the same custom ID + with pytest.raises(ClientError) as e: + aws_client.ec2.create_subnet( + CidrBlock="10.0.1.0/24", + VpcId=vpc_id, + TagSpecifications=[ + { + "ResourceType": "subnet", + "Tags": [ + {"Key": TAG_KEY_CUSTOM_ID, "Value": custom_id}, + ], + } + ], + ) + + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert e.value.response["Error"]["Code"] == "InvalidSubnet.DuplicateCustomId" + + @markers.aws.only_localstack + def test_create_subnet_with_custom_id_and_vpc_id(self, cleanups, aws_client, create_vpc): + custom_subnet_id = random_subnet_id() + custom_vpc_id = random_vpc_id() + + # Create the VPC with the custom ID. + vpc: dict = create_vpc( + cidr_block="10.0.0.0/16", + tag_specifications=[ + { + "ResourceType": "vpc", + "Tags": [ + {"Key": TAG_KEY_CUSTOM_ID, "Value": custom_vpc_id}, + ], + } + ], + ) + assert vpc["Vpc"]["VpcId"] == custom_vpc_id + + # Check if subnet ID matches the custom ID + subnet: dict = aws_client.ec2.create_subnet( + VpcId=custom_vpc_id, + CidrBlock="10.0.0.0/24", + TagSpecifications=[ + { + "ResourceType": "subnet", + "Tags": [ + {"Key": TAG_KEY_CUSTOM_ID, "Value": custom_subnet_id}, + ], + } + ], + ) + cleanups.append(lambda: aws_client.ec2.delete_subnet(SubnetId=custom_subnet_id)) + assert subnet["Subnet"]["SubnetId"] == custom_subnet_id + + # Check if the custom ID is present in the describe_subnets response as well + subnet: dict = aws_client.ec2.describe_subnets( + SubnetIds=[custom_subnet_id], + )["Subnets"][0] + assert subnet["SubnetId"] == custom_subnet_id + assert subnet["VpcId"] == custom_vpc_id + assert len(subnet["Tags"]) == 1 + assert subnet["Tags"][0]["Key"] == TAG_KEY_CUSTOM_ID + assert subnet["Tags"][0]["Value"] == custom_subnet_id + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["tag", "id_manager"]) + @pytest.mark.parametrize("default_vpc", [True, False]) + def test_create_security_group_with_custom_id( + self, cleanups, aws_client, create_vpc, strategy, account_id, region_name, default_vpc + ): + custom_id = random_security_group_id() + group_name = f"test-security-group-{short_uid()}" + vpc_id = None + + # Create necessary VPC resource + if default_vpc: + vpc: dict = aws_client.ec2.describe_vpcs( + Filters=[{"Name": "is-default", "Values": ["true"]}] + )["Vpcs"][0] + vpc_id = vpc["VpcId"] + else: + vpc: dict = create_vpc( + cidr_block="10.0.0.0/24", + tag_specifications=[], + ) + vpc_id = vpc["Vpc"]["VpcId"] + + def _create_security_group() -> dict: + req_kwargs = {"Description": "Test security group", "GroupName": group_name} + if not default_vpc: + # vpc_id does not need to be provided for default vpc + req_kwargs["VpcId"] = vpc_id + if strategy == "tag": + req_kwargs["TagSpecifications"] = [ + { + "ResourceType": "security-group", + "Tags": [{"Key": TAG_KEY_CUSTOM_ID, "Value": custom_id}], + } + ] + return aws_client.ec2.create_security_group(**req_kwargs) + else: + with localstack_id_manager.custom_id( + SecurityGroupIdentifier( + account_id=account_id, + region=region_name, + vpc_id=vpc_id, + group_name=group_name, + ), + custom_id, + ): + return aws_client.ec2.create_security_group(**req_kwargs) + + security_group: dict = _create_security_group() + + cleanups.append(lambda: aws_client.ec2.delete_security_group(GroupId=custom_id)) + # Check if security group ID matches the custom ID + assert security_group["GroupId"] == custom_id, ( + f"Security group ID does not match custom ID: {security_group}" + ) + + # Check if the custom ID is present in the describe_security_groups response as well + security_groups: dict = aws_client.ec2.describe_security_groups( + GroupIds=[custom_id], + )["SecurityGroups"] + + # Get security group that match a given VPC id + security_group = next((sg for sg in security_groups if sg["VpcId"] == vpc_id), None) + assert security_group["GroupId"] == custom_id + if strategy == "tag": + assert len(security_group["Tags"]) == 1 + assert security_group["Tags"][0]["Key"] == TAG_KEY_CUSTOM_ID + assert security_group["Tags"][0]["Value"] == custom_id + + # Check if a duplicate custom ID exception is thrown if we try to recreate the security group with the same custom ID + with pytest.raises(ClientError) as e: + _create_security_group() + + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert e.value.response["Error"]["Code"] == "InvalidSecurityGroupId.DuplicateCustomId" + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Tags", # Tags can differ between environments + "$..Vpc.IsDefault", # TODO: CreateVPC should return an IsDefault param + "$..Vpc.DhcpOptionsId", # FIXME: DhcpOptionsId uses different reference formats in AWS vs LocalStack + ] + ) + @markers.aws.validated + def test_get_security_groups_for_vpc( + self, snapshot, aws_client, create_vpc, ec2_create_security_group + ): + group_name = f"test-security-group-{short_uid()}" + group_description = f"Description for {group_name}" + + # Returned security groups appear to be sorted by the randomly generated GroupId field, + # so we should sort snapshots by this value to mitigate flakiness for runs against AWS. + snapshot.add_transformer( + SortingTransformer("SecurityGroupForVpcs", lambda x: x["GroupName"]) + ) + snapshot.add_transformer(snapshot.transform.key_value("GroupId")) + snapshot.add_transformer(snapshot.transform.key_value("GroupName")) + snapshot.add_transformer(snapshot.transform.key_value("VpcId")) + snapshot.add_transformer(snapshot.transform.key_value("AssociationId")) + snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId")) + + # Create VPC for testing + vpc: dict = create_vpc( + cidr_block="10.0.0.0/16", + tag_specifications=[ + { + "ResourceType": "vpc", + "Tags": [ + {"Key": "test-key", "Value": "test-value"}, + ], + } + ], + ) + vpc_id: str = vpc["Vpc"]["VpcId"] + snapshot.match("create_vpc_response", vpc) + + # Wait to ensure VPC is available + waiter = aws_client.ec2.get_waiter("vpc_available") + waiter.wait(VpcIds=[vpc_id]) + + # Get all security groups in the VPC + get_security_groups_for_vpc = aws_client.ec2.get_security_groups_for_vpc(VpcId=vpc_id) + snapshot.match("get_security_groups_for_vpc", get_security_groups_for_vpc) + + # Create new security group in the VPC + create_security_group = ec2_create_security_group( + GroupName=group_name, + Description=group_description, + VpcId=vpc_id, + ports=[22], # TODO: Handle port issues in the fixture + ) + snapshot.match("create_security_group", create_security_group) + + # Ensure new security group is in the VPC + get_security_groups_for_vpc_after_addition = aws_client.ec2.get_security_groups_for_vpc( + VpcId=vpc_id + ) + snapshot.match( + "get_security_groups_for_vpc_after_addition", get_security_groups_for_vpc_after_addition + ) + + +@markers.snapshot.skip_snapshot_verify( + # Moto and LS do not return the ClientToken + paths=["$..ClientToken"], +) +class TestEc2FlowLogs: + @pytest.fixture + def create_flow_logs(self, aws_client): + flow_logs = [] + + def _create(**kwargs): + response = aws_client.ec2.create_flow_logs(**kwargs) + flow_logs.extend(response.get("FlowLogIds", [])) + return response + + yield _create + + try: + aws_client.ec2.delete_flow_logs(FlowLogIds=flow_logs) + except Exception: + LOG.debug("Error while cleaning up FlowLogs %s", flow_logs) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # not returned by Moto + "$..FlowLogs..DestinationOptions", + "$..FlowLogs..Tags", + ], + ) + @markers.aws.validated + def test_ec2_flow_logs_s3(self, aws_client, create_vpc, s3_bucket, create_flow_logs, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("ClientToken"), + snapshot.transform.key_value("FlowLogId"), + snapshot.transform.key_value("ResourceId"), + snapshot.transform.resource_name(), + snapshot.transform.jsonpath( + "$.create-flow-logs-s3-subfolder.FlowLogIds[0]", + value_replacement="flow-log-id-sub", + ), + ] + ) + vpc = create_vpc( + cidr_block="10.0.0.0/24", + tag_specifications=[], + ) + vpc_id = vpc["Vpc"]["VpcId"] + + response = create_flow_logs( + ResourceIds=[vpc_id], + ResourceType="VPC", + LogDestinationType="s3", + LogDestination=f"arn:aws:s3:::{s3_bucket}", + TrafficType="ALL", + ) + snapshot.match("create-flow-logs-s3", response) + + describe_flow_logs = aws_client.ec2.describe_flow_logs(FlowLogIds=response["FlowLogIds"]) + snapshot.match("describe-flow-logs", describe_flow_logs) + + response = create_flow_logs( + ResourceIds=[vpc_id], + ResourceType="VPC", + LogDestinationType="s3", + LogDestination=f"arn:aws:s3:::{s3_bucket}/subfolder/", + TrafficType="ALL", + ) + snapshot.match("create-flow-logs-s3-subfolder", response) + + @markers.aws.validated + def test_ec2_flow_logs_s3_validation( + self, aws_client, create_vpc, create_flow_logs, s3_bucket, snapshot + ): + bad_bucket_name = f"{s3_bucket}-{short_uid()}-{short_uid()}" + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("ClientToken"), + snapshot.transform.key_value("ResourceId"), + snapshot.transform.regex(bad_bucket_name, replacement=""), + ] + ) + vpc = create_vpc( + cidr_block="10.0.0.0/24", + tag_specifications=[], + ) + vpc_id = vpc["Vpc"]["VpcId"] + + # TODO: write an IAM test if the bucket exists but there are no permissions: + # the error would be the following if the bucket exists: + # Access Denied for LogDestination: bad-bucket. Please check LogDestination permission + non_existent_bucket = create_flow_logs( + ResourceIds=[vpc_id], + ResourceType="VPC", + LogDestinationType="s3", + LogDestination=f"arn:aws:s3:::{bad_bucket_name}", + TrafficType="ALL", + ) + snapshot.match("non-existent-bucket", non_existent_bucket) + + with pytest.raises(ClientError) as e: + create_flow_logs( + ResourceIds=[vpc_id], + ResourceType="VPC", + LogDestinationType="s3", + LogDestination=f"arn:aws:s3:::{s3_bucket}", + LogGroupName="test-group-name", + TrafficType="ALL", + ) + snapshot.match("with-log-group-name", e.value.response) + + with pytest.raises(ClientError) as e: + create_flow_logs( + ResourceIds=[vpc_id], + ResourceType="VPC", + LogDestinationType="s3", + TrafficType="ALL", + ) + snapshot.match("no-log-destination", e.value.response) + + with pytest.raises(ClientError) as e: + create_flow_logs( + ResourceIds=[vpc_id], + ResourceType="VPC", + LogDestinationType="s3", + LogGroupName="test", + TrafficType="ALL", + ) + snapshot.match("log-group-name-s3-destination", e.value.response) + + +@markers.aws.validated +def test_raise_modify_to_invalid_default_version(create_launch_template, aws_client): + launch_template_result = create_launch_template(f"my-first-launch-template-{short_uid()}") + template = launch_template_result["LaunchTemplate"] + + with pytest.raises(ClientError) as e: + aws_client.ec2.modify_launch_template( + LaunchTemplateId=template["LaunchTemplateId"], DefaultVersion="666" + ) + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert e.value.response["Error"]["Code"] == "InvalidLaunchTemplateId.VersionNotFound" + + +@markers.aws.validated +def test_raise_when_launch_template_data_missing(aws_client): + with pytest.raises(ClientError) as e: + aws_client.ec2.create_launch_template( + LaunchTemplateName=f"unique_name-{short_uid()}", LaunchTemplateData={} + ) + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert e.value.response["Error"]["Code"] == "MissingParameter" + + +@markers.aws.validated +def test_raise_invalid_launch_template_name(create_launch_template): + with pytest.raises(ClientError) as e: + create_launch_template(f"some illegal name {short_uid()}") + + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert e.value.response["Error"]["Code"] == "InvalidLaunchTemplateName.MalformedException" + + +@markers.aws.validated +def test_raise_duplicate_launch_template_name(create_launch_template): + create_launch_template("name") + + with pytest.raises(ClientError) as e: + create_launch_template("name") + + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert e.value.response["Error"]["Code"] == "InvalidLaunchTemplateName.AlreadyExistsException" + + +@pytest.fixture +def pickle_backends(): + def _can_pickle(*args) -> bool: + import dill + + try: + for i in args: + dill.dumps(i) + except TypeError: + return False + return True + + return _can_pickle + + +@markers.aws.only_localstack +def test_pickle_ec2_backend(pickle_backends, aws_client): + _ = aws_client.ec2.describe_account_attributes() + pickle_backends(ec2_backends) + assert pickle_backends(ec2_backends) + + +@markers.aws.only_localstack +def test_create_specific_vpc_id(account_id, region_name, create_vpc, set_resource_custom_id): + cidr_block = "10.0.0.0/16" + custom_id = "my-custom-id" + set_resource_custom_id( + VpcIdentifier(account_id=account_id, region=region_name, cidr_block=cidr_block), + f"vpc-{custom_id}", + ) + + vpc = create_vpc(cidr_block=cidr_block) + assert vpc["Vpc"]["VpcId"] == f"vpc-{custom_id}" + + +@markers.aws.validated +def test_raise_create_volume_without_size(snapshot, aws_client): + with pytest.raises(ClientError) as e: + aws_client.ec2.create_volume(AvailabilityZone="eu-central-1a") + snapshot.match("request-missing-size", e.value.response) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # not implemented in LS + "$..AvailabilityZones..GroupLongName", + "$..AvailabilityZones..GroupName", + "$..AvailabilityZones..NetworkBorderGroup", + "$..AvailabilityZones..OptInStatus", + ] +) +@markers.aws.validated +def test_describe_availability_zones_filter_with_zone_names(snapshot, aws_client_factory): + snapshot.add_transformer(snapshot.transform.regex(AWS_REGION_US_EAST_1, "")) + + ec2_client = aws_client_factory(region_name=AWS_REGION_US_EAST_1).ec2 + availability_zones = ec2_client.describe_availability_zones(ZoneNames=["us-east-1a"]) + snapshot.match("availability_zones", availability_zones) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # not implemented in LS + "$..AvailabilityZones..GroupLongName", + "$..AvailabilityZones..GroupName", + "$..AvailabilityZones..NetworkBorderGroup", + "$..AvailabilityZones..OptInStatus", + ] +) +@markers.aws.validated +def test_describe_availability_zones_filter_with_zone_ids(snapshot, aws_client_factory): + snapshot.add_transformer(snapshot.transform.regex(AWS_REGION_US_EAST_1, "")) + + ec2_client = aws_client_factory(region_name=AWS_REGION_US_EAST_1).ec2 + availability_zones = ec2_client.describe_availability_zones(ZoneIds=["use1-az1"]) + snapshot.match("availability_zones", availability_zones) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # not implemented in LS + "$..AvailabilityZones..GroupLongName", + "$..AvailabilityZones..GroupName", + "$..AvailabilityZones..NetworkBorderGroup", + "$..AvailabilityZones..OptInStatus", + ] +) +@markers.aws.validated +def test_describe_availability_zones_filters(snapshot, aws_client_factory): + snapshot.add_transformer(snapshot.transform.regex(AWS_REGION_US_EAST_1, "")) + + ec2_client = aws_client_factory(region_name=AWS_REGION_US_EAST_1).ec2 + availability_zones = ec2_client.describe_availability_zones( + Filters=[{"Name": "zone-name", "Values": ["us-east-1a"]}] + ) + snapshot.match("availability_zones", availability_zones) diff --git a/tests/aws/services/ec2/test_ec2.snapshot.json b/tests/aws/services/ec2/test_ec2.snapshot.json new file mode 100644 index 0000000000000..026c53fa57960 --- /dev/null +++ b/tests/aws/services/ec2/test_ec2.snapshot.json @@ -0,0 +1,498 @@ +{ + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_route_table_association": { + "recorded-date": "06-06-2024, 19:21:49", + "recorded-content": { + "vpc_id": "", + "subnet_id": "", + "route_table_id": "", + "association_id": "", + "route_tables": [ + { + "Associations": [ + { + "Main": false, + "RouteTableAssociationId": "", + "RouteTableId": "", + "SubnetId": "", + "AssociationState": { + "State": "associated" + } + } + ], + "PropagatingVgws": [], + "RouteTableId": "", + "Routes": [ + { + "DestinationCidrBlock": "10.0.0.0/16", + "GatewayId": "local", + "Origin": "CreateRouteTable", + "State": "active" + } + ], + "Tags": [], + "VpcId": "", + "OwnerId": "111111111111" + } + ] + } + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_vcp_peering_difference_regions": { + "recorded-date": "07-06-2024, 21:28:25", + "recorded-content": { + "vpc1": { + "region": "", + "vpc-id": "" + }, + "vpc2": { + "region": "", + "vpc-id": "" + }, + "peering-connection-id": "", + "pending-acceptance": { + "VpcPeeringConnections": [ + { + "AccepterVpcInfo": { + "OwnerId": "111111111111", + "Region": "", + "VpcId": "" + }, + "ExpirationTime": "", + "RequesterVpcInfo": { + "CidrBlock": "192.168.1.0/24", + "CidrBlockSet": [ + { + "CidrBlock": "192.168.1.0/24" + } + ], + "OwnerId": "111111111111", + "PeeringOptions": { + "AllowDnsResolutionFromRemoteVpc": false, + "AllowEgressFromLocalClassicLinkToRemoteVpc": false, + "AllowEgressFromLocalVpcToRemoteClassicLink": false + }, + "Region": "", + "VpcId": "" + }, + "Status": { + "Code": "pending-acceptance", + "Message": "Pending Acceptance by 111111111111" + }, + "Tags": [], + "VpcPeeringConnectionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "requester-peer": { + "VpcPeeringConnections": [ + { + "AccepterVpcInfo": { + "CidrBlock": "192.168.2.0/24", + "CidrBlockSet": [ + { + "CidrBlock": "192.168.2.0/24" + } + ], + "OwnerId": "111111111111", + "PeeringOptions": { + "AllowDnsResolutionFromRemoteVpc": false, + "AllowEgressFromLocalClassicLinkToRemoteVpc": false, + "AllowEgressFromLocalVpcToRemoteClassicLink": false + }, + "Region": "", + "VpcId": "" + }, + "RequesterVpcInfo": { + "CidrBlock": "192.168.1.0/24", + "CidrBlockSet": [ + { + "CidrBlock": "192.168.1.0/24" + } + ], + "OwnerId": "111111111111", + "PeeringOptions": { + "AllowDnsResolutionFromRemoteVpc": false, + "AllowEgressFromLocalClassicLinkToRemoteVpc": false, + "AllowEgressFromLocalVpcToRemoteClassicLink": false + }, + "Region": "", + "VpcId": "" + }, + "Status": { + "Code": "active", + "Message": "Active" + }, + "Tags": [], + "VpcPeeringConnectionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "accepter-peer": { + "VpcPeeringConnections": [ + { + "AccepterVpcInfo": { + "CidrBlock": "192.168.2.0/24", + "CidrBlockSet": [ + { + "CidrBlock": "192.168.2.0/24" + } + ], + "OwnerId": "111111111111", + "PeeringOptions": { + "AllowDnsResolutionFromRemoteVpc": false, + "AllowEgressFromLocalClassicLinkToRemoteVpc": false, + "AllowEgressFromLocalVpcToRemoteClassicLink": false + }, + "Region": "", + "VpcId": "" + }, + "RequesterVpcInfo": { + "CidrBlock": "192.168.1.0/24", + "CidrBlockSet": [ + { + "CidrBlock": "192.168.1.0/24" + } + ], + "OwnerId": "111111111111", + "PeeringOptions": { + "AllowDnsResolutionFromRemoteVpc": false, + "AllowEgressFromLocalClassicLinkToRemoteVpc": false, + "AllowEgressFromLocalVpcToRemoteClassicLink": false + }, + "Region": "", + "VpcId": "" + }, + "Status": { + "Code": "active", + "Message": "Active" + }, + "Tags": [], + "VpcPeeringConnectionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_describe_vpn_gateways_filter_by_vpc": { + "recorded-date": "07-06-2024, 01:11:12", + "recorded-content": { + "vpc-id": "", + "gateway": { + "VpnGateway": { + "AmazonSideAsn": 64512, + "State": "available", + "Type": "ipsec.1", + "VpcAttachments": [], + "VpnGatewayId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-gateway": { + "AmazonSideAsn": 64512, + "State": "available", + "Tags": [], + "Type": "ipsec.1", + "VpcAttachments": [ + { + "State": "attached", + "VpcId": "" + } + ], + "VpnGatewayId": "" + } + } + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2FlowLogs::test_ec2_flow_logs_s3": { + "recorded-date": "24-09-2024, 23:19:46", + "recorded-content": { + "create-flow-logs-s3": { + "ClientToken": "", + "FlowLogIds": [ + "" + ], + "Unsuccessful": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-flow-logs": { + "FlowLogs": [ + { + "CreationTime": "", + "DeliverLogsStatus": "SUCCESS", + "DestinationOptions": { + "FileFormat": "plain-text", + "HiveCompatiblePartitions": false, + "PerHourPartition": false + }, + "FlowLogId": "", + "FlowLogStatus": "ACTIVE", + "LogDestination": "arn::s3:::", + "LogDestinationType": "s3", + "LogFormat": "${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status}", + "MaxAggregationInterval": 600, + "ResourceId": "", + "Tags": [], + "TrafficType": "ALL" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-flow-logs-s3-subfolder": { + "ClientToken": "", + "FlowLogIds": [ + "" + ], + "Unsuccessful": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2FlowLogs::test_ec2_flow_logs_s3_validation": { + "recorded-date": "24-09-2024, 23:26:43", + "recorded-content": { + "non-existent-bucket": { + "ClientToken": "", + "FlowLogIds": [], + "Unsuccessful": [ + { + "Error": { + "Code": "400", + "Message": "LogDestination: does not exist" + }, + "ResourceId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "with-log-group-name": { + "Error": { + "Code": "InvalidParameter", + "Message": "Please only provide LogGroupName or only provide LogDestination." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "no-log-destination": { + "Error": { + "Code": "InvalidParameter", + "Message": "LogDestination can't be empty if LogGroupName is not provided." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "log-group-name-s3-destination": { + "Error": { + "Code": "InvalidParameter", + "Message": "LogDestination type must be cloud-watch-logs if LogGroupName is provided." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ec2/test_ec2.py::test_raise_create_volume_without_size": { + "recorded-date": "04-02-2025, 12:53:29", + "recorded-content": { + "request-missing-size": { + "Error": { + "Code": "MissingParameter", + "Message": "The request must contain the parameter size/snapshot" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_get_security_groups_for_vpc": { + "recorded-date": "19-05-2025, 13:53:56", + "recorded-content": { + "create_vpc_response": { + "Vpc": { + "CidrBlock": "10.0.0.0/16", + "CidrBlockAssociationSet": [ + { + "AssociationId": "", + "CidrBlock": "10.0.0.0/16", + "CidrBlockState": { + "State": "associated" + } + } + ], + "DhcpOptionsId": "", + "InstanceTenancy": "", + "Ipv6CidrBlockAssociationSet": [], + "IsDefault": false, + "OwnerId": "111111111111", + "State": "pending", + "Tags": [ + { + "Key": "test-key", + "Value": "test-value" + } + ], + "VpcId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_security_groups_for_vpc": { + "SecurityGroupForVpcs": [ + { + "Description": " VPC security group", + "GroupId": "", + "GroupName": "", + "OwnerId": "111111111111", + "PrimaryVpcId": "", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_security_group": { + "GroupId": "", + "SecurityGroupArn": "arn::ec2::111111111111:security-group/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_security_groups_for_vpc_after_addition": { + "SecurityGroupForVpcs": [ + { + "Description": " VPC security group", + "GroupId": "", + "GroupName": "", + "OwnerId": "111111111111", + "PrimaryVpcId": "", + "Tags": [] + }, + { + "Description": "Description for ", + "GroupId": "", + "GroupName": "", + "OwnerId": "111111111111", + "PrimaryVpcId": "", + "Tags": [] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filter_with_zone_names": { + "recorded-date": "28-05-2025, 09:16:53", + "recorded-content": { + "availability_zones": { + "AvailabilityZones": [ + { + "GroupLongName": "US East (N. Virginia) 1", + "GroupName": "-zg-1", + "Messages": [], + "NetworkBorderGroup": "", + "OptInStatus": "opt-in-not-required", + "RegionName": "", + "State": "available", + "ZoneId": "use1-az6", + "ZoneName": "a", + "ZoneType": "availability-zone" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filter_with_zone_ids": { + "recorded-date": "28-05-2025, 09:17:24", + "recorded-content": { + "availability_zones": { + "AvailabilityZones": [ + { + "GroupLongName": "US East (N. Virginia) 1", + "GroupName": "-zg-1", + "Messages": [], + "NetworkBorderGroup": "", + "OptInStatus": "opt-in-not-required", + "RegionName": "", + "State": "available", + "ZoneId": "use1-az1", + "ZoneName": "b", + "ZoneType": "availability-zone" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filters": { + "recorded-date": "28-05-2025, 09:17:47", + "recorded-content": { + "availability_zones": { + "AvailabilityZones": [ + { + "GroupLongName": "US East (N. Virginia) 1", + "GroupName": "-zg-1", + "Messages": [], + "NetworkBorderGroup": "", + "OptInStatus": "opt-in-not-required", + "RegionName": "", + "State": "available", + "ZoneId": "use1-az6", + "ZoneName": "a", + "ZoneType": "availability-zone" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/ec2/test_ec2.validation.json b/tests/aws/services/ec2/test_ec2.validation.json new file mode 100644 index 0000000000000..c26b3e4033cc4 --- /dev/null +++ b/tests/aws/services/ec2/test_ec2.validation.json @@ -0,0 +1,32 @@ +{ + "tests/aws/services/ec2/test_ec2.py::TestEc2FlowLogs::test_ec2_flow_logs_s3": { + "last_validated_date": "2024-09-24T23:19:46+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2FlowLogs::test_ec2_flow_logs_s3_validation": { + "last_validated_date": "2024-09-24T23:26:43+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_create_route_table_association": { + "last_validated_date": "2024-06-06T19:21:49+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_describe_vpn_gateways_filter_by_vpc": { + "last_validated_date": "2024-06-07T01:11:12+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_get_security_groups_for_vpc": { + "last_validated_date": "2025-05-19T13:54:09+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_vcp_peering_difference_regions": { + "last_validated_date": "2024-06-07T21:28:25+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filter_with_zone_ids": { + "last_validated_date": "2025-05-28T09:17:24+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filter_with_zone_names": { + "last_validated_date": "2025-05-28T09:16:53+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::test_describe_availability_zones_filters": { + "last_validated_date": "2025-05-28T09:17:56+00:00" + }, + "tests/aws/services/ec2/test_ec2.py::test_raise_create_volume_without_size": { + "last_validated_date": "2025-02-04T12:53:29+00:00" + } +} diff --git a/tests/aws/services/es/__init__.py b/tests/aws/services/es/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/es/test_es.py b/tests/aws/services/es/test_es.py new file mode 100644 index 0000000000000..a736aed91cc06 --- /dev/null +++ b/tests/aws/services/es/test_es.py @@ -0,0 +1,213 @@ +import logging +import threading + +import botocore.exceptions +import pytest + +from localstack import config +from localstack.constants import ELASTICSEARCH_DEFAULT_VERSION, OPENSEARCH_DEFAULT_VERSION +from localstack.services.opensearch.packages import elasticsearch_package, opensearch_package +from localstack.testing.pytest import markers +from localstack.utils.common import safe_requests as requests +from localstack.utils.common import short_uid, start_worker_thread + +LOG = logging.getLogger(__name__) + +# Common headers used when sending requests to OpenSearch +COMMON_HEADERS = {"content-type": "application/json", "Accept-encoding": "identity"} + +# Lock and event to ensure that the installation is executed before the tests +INIT_LOCK = threading.Lock() +installed = threading.Event() + + +def install_async(): + """ + Installs the default elasticsearch version in a worker thread. Used by conftest.py to make + sure elasticsearch is downloaded once the tests arrive here. + """ + if installed.is_set(): + return + + def run_install(*args): + with INIT_LOCK: + if installed.is_set(): + return + LOG.info("installing elasticsearch default version") + elasticsearch_package.install() + LOG.info("done installing elasticsearch default version") + LOG.info("installing opensearch default version") + opensearch_package.install() + LOG.info("done installing opensearch default version") + installed.set() + + start_worker_thread(run_install) + + +@pytest.fixture(autouse=True) +def elasticsearch(): + if not installed.is_set(): + install_async() + + assert installed.wait(timeout=5 * 60), "gave up waiting for elasticsearch to install" + yield + + +def try_cluster_health(cluster_url: str): + response = requests.get(cluster_url) + assert response.ok, f"cluster endpoint returned an error: {response.text}" + + response = requests.get(f"{cluster_url}/_cluster/health") + assert response.ok, f"cluster health endpoint returned an error: {response.text}" + assert response.json()["status"] in [ + "orange", + "yellow", + "green", + ], "expected cluster state to be in a valid state" + + +@pytest.mark.skip(reason="flaky") +class TestElasticsearchProvider: + @markers.aws.validated + def test_list_versions(self, aws_client): + response = aws_client.es.list_elasticsearch_versions() + + assert "ElasticsearchVersions" in response + versions = response["ElasticsearchVersions"] + + assert "OpenSearch_1.0" in versions + assert "OpenSearch_1.1" in versions + assert "7.10" in versions + + @markers.aws.needs_fixing + def test_get_compatible_versions(self, aws_client): + response = aws_client.es.get_compatible_elasticsearch_versions() + + assert "CompatibleElasticsearchVersions" in response + + versions = response["CompatibleElasticsearchVersions"] + + assert len(versions) == 25 + + assert { + "SourceVersion": "OpenSearch_1.0", + "TargetVersions": ["OpenSearch_1.1", "OpenSearch_1.2", "OpenSearch_1.3"], + } in versions + assert { + "SourceVersion": "7.10", + "TargetVersions": [ + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + } in versions + assert { + "SourceVersion": "7.7", + "TargetVersions": [ + "7.8", + "7.9", + "7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + ], + } in versions + + @markers.skip_offline + @markers.aws.needs_fixing + def test_get_compatible_version_for_domain(self, opensearch_domain, aws_client): + response = aws_client.es.get_compatible_elasticsearch_versions(DomainName=opensearch_domain) + assert "CompatibleElasticsearchVersions" in response + versions = response["CompatibleElasticsearchVersions"] + # Assert the possible versions to upgrade from the current default version. + # The default version is 2.11 version (current latest is 2.11) + assert len(versions) == 0 + + @markers.skip_offline + @markers.aws.needs_fixing + def test_create_domain(self, opensearch_create_domain, aws_client): + es_domain = opensearch_create_domain(EngineVersion=ELASTICSEARCH_DEFAULT_VERSION) + response = aws_client.es.list_domain_names(EngineType="Elasticsearch") + domain_names = [domain["DomainName"] for domain in response["DomainNames"]] + assert es_domain in domain_names + + @markers.skip_offline + @markers.aws.needs_fixing + def test_create_existing_domain_causes_exception(self, opensearch_create_domain, aws_client): + domain_name = opensearch_create_domain(EngineVersion=ELASTICSEARCH_DEFAULT_VERSION) + + with pytest.raises(botocore.exceptions.ClientError) as exc_info: + aws_client.es.create_elasticsearch_domain(DomainName=domain_name) + assert exc_info.type.__name__ == "ResourceAlreadyExistsException" + + @markers.skip_offline + @markers.aws.needs_fixing + def test_describe_domains(self, opensearch_create_domain, aws_client): + opensearch_domain = opensearch_create_domain(EngineVersion=ELASTICSEARCH_DEFAULT_VERSION) + response = aws_client.es.describe_elasticsearch_domains(DomainNames=[opensearch_domain]) + assert len(response["DomainStatusList"]) == 1 + assert response["DomainStatusList"][0]["DomainName"] == opensearch_domain + + @markers.skip_offline + @markers.aws.needs_fixing + def test_domain_version(self, opensearch_domain, opensearch_create_domain, aws_client): + response = aws_client.es.describe_elasticsearch_domain(DomainName=opensearch_domain) + assert "DomainStatus" in response + status = response["DomainStatus"] + assert "ElasticsearchVersion" in status + assert status["ElasticsearchVersion"] == OPENSEARCH_DEFAULT_VERSION + domain_name = opensearch_create_domain(EngineVersion=ELASTICSEARCH_DEFAULT_VERSION) + response = aws_client.es.describe_elasticsearch_domain(DomainName=domain_name) + assert "DomainStatus" in response + status = response["DomainStatus"] + assert "ElasticsearchVersion" in status + assert status["ElasticsearchVersion"] == "7.10" + + @markers.skip_offline + @markers.aws.only_localstack + def test_path_endpoint_strategy(self, monkeypatch, opensearch_create_domain, aws_client): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "path") + monkeypatch.setattr(config, "OPENSEARCH_MULTI_CLUSTER", True) + + domain_name = f"es-domain-{short_uid()}" + + opensearch_create_domain(DomainName=domain_name) + status = aws_client.es.describe_elasticsearch_domain(DomainName=domain_name)["DomainStatus"] + + assert "Endpoint" in status + endpoint = status["Endpoint"] + assert endpoint.endswith(f"/{domain_name}") + + @markers.aws.needs_fixing + def test_update_domain_config(self, opensearch_domain, aws_client): + initial_response = aws_client.es.describe_elasticsearch_domain_config( + DomainName=opensearch_domain + ) + update_response = aws_client.es.update_elasticsearch_domain_config( + DomainName=opensearch_domain, + ElasticsearchClusterConfig={"InstanceType": "r4.16xlarge.elasticsearch"}, + ) + final_response = aws_client.es.describe_elasticsearch_domain_config( + DomainName=opensearch_domain + ) + + assert ( + initial_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"][ + "InstanceType" + ] + != update_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"][ + "InstanceType" + ] + ) + assert ( + update_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"]["InstanceType"] + == "r4.16xlarge.elasticsearch" + ) + assert ( + update_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"]["InstanceType"] + == final_response["DomainConfig"]["ElasticsearchClusterConfig"]["Options"][ + "InstanceType" + ] + ) diff --git a/tests/aws/services/es/test_es.validation.json b/tests/aws/services/es/test_es.validation.json new file mode 100644 index 0000000000000..c6e7d1a29457a --- /dev/null +++ b/tests/aws/services/es/test_es.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/es/test_es.py::TestElasticsearchProvider::test_list_versions": { + "last_validated_date": "2024-07-15T10:16:46+00:00" + } +} diff --git a/tests/aws/services/events/__init__.py b/tests/aws/services/events/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/events/conftest.py b/tests/aws/services/events/conftest.py new file mode 100644 index 0000000000000..77b9d925e033c --- /dev/null +++ b/tests/aws/services/events/conftest.py @@ -0,0 +1,476 @@ +import json +import logging +from typing import Tuple + +import pytest + +from localstack.testing.snapshots.transformer_utility import TransformerUtility +from localstack.utils.aws.arns import get_partition +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.events.helper_functions import put_entries_assert_results_sqs + +LOG = logging.getLogger(__name__) + +# some fixtures are shared in localstack/testing/pytest/fixtures.py + + +@pytest.fixture +def events_create_default_or_custom_event_bus(events_create_event_bus, region_name, account_id): + def _create_default_or_custom_event_bus(event_bus_type: str = "default"): + if event_bus_type == "default": + event_bus_name = "default" + event_bus_arn = f"arn:{get_partition(region_name)}:events:{region_name}:{account_id}:event-bus/default" + else: + event_bus_name = f"test-bus-{short_uid()}" + response = events_create_event_bus(Name=event_bus_name) + event_bus_arn = response["EventBusArn"] + return event_bus_name, event_bus_arn + + return _create_default_or_custom_event_bus + + +@pytest.fixture +def create_role_event_bus_source_to_bus_target(create_iam_role_with_policy): + def _create_role_event_bus_to_bus(): + assume_role_policy_document_bus_source_to_bus_target = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + policy_document_bus_source_to_bus_target = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": "events:PutEvents", + "Resource": "arn:aws:events:*:*:event-bus/*", + } + ], + } + + role_arn_bus_source_to_bus_target = create_iam_role_with_policy( + RoleDefinition=assume_role_policy_document_bus_source_to_bus_target, + PolicyDefinition=policy_document_bus_source_to_bus_target, + ) + + return role_arn_bus_source_to_bus_target + + yield _create_role_event_bus_to_bus + + +@pytest.fixture +def events_create_archive(aws_client, region_name, account_id): + archives = [] + + def _create_archive(**kwargs): + if "ArchiveName" not in kwargs: + kwargs["ArchiveName"] = f"test-archive-{short_uid()}" + + if "EventSourceArn" not in kwargs: + kwargs["EventSourceArn"] = ( + f"arn:aws:events:{region_name}:{account_id}:event-bus/default" + ) + + response = aws_client.events.create_archive(**kwargs) + archives.append(kwargs["ArchiveName"]) + return response + + yield _create_archive + + for archive in archives: + try: + aws_client.events.delete_archive(ArchiveName=archive) + except Exception as e: + LOG.warning( + "Failed to delete archive %s: %s", + archive, + e, + ) + + +@pytest.fixture +def put_event_to_archive(aws_client, events_create_event_bus, events_create_archive): + def _put_event_to_archive( + archive_name: str | None = None, + event_pattern: dict | None = None, + event_bus_name: str | None = None, + event_source_arn: str | None = None, + entries: list[dict] | None = None, + num_events: int = 1, + ): + if not event_bus_name: + event_bus_name = f"test-bus-{short_uid()}" + if not event_source_arn: + response = events_create_event_bus(Name=event_bus_name) + event_source_arn = response["EventBusArn"] + if not archive_name: + archive_name = f"test-archive-{short_uid()}" + + response = events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_source_arn, + EventPattern=json.dumps(event_pattern), + RetentionDays=1, + ) + archive_arn = response["ArchiveArn"] + + if entries: + num_events = len(entries) + else: + entries = [] + for i in range(num_events): + entries.append( + { + "Source": "testSource", + "DetailType": "testDetailType", + "Detail": f"event number {i}", + "EventBusName": event_bus_name, + } + ) + + aws_client.events.put_events( + Entries=entries, + ) + + def wait_for_archive_event_count(): + response = aws_client.events.describe_archive(ArchiveName=archive_name) + event_count = response["EventCount"] + assert event_count == num_events + + retry( + wait_for_archive_event_count, retries=35, sleep=10 + ) # events are batched and sent to the archive, this mostly takes at least 5 minutes on AWS + + return { + "ArchiveName": archive_name, + "ArchiveArn": archive_arn, + "EventBusName": event_bus_name, + "EventBusArn": event_source_arn, + } + + yield _put_event_to_archive + + +@pytest.fixture +def events_allow_event_rule_to_sqs_queue(aws_client): + def _allow_event_rule(sqs_queue_url, sqs_queue_arn, event_rule_arn) -> None: + # allow event rule to write to sqs queue + aws_client.sqs.set_queue_attributes( + QueueUrl=sqs_queue_url, + Attributes={ + "Policy": json.dumps( + { + "Statement": [ + { + "Sid": "AllowEventsToQueue", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": sqs_queue_arn, + "Condition": {"ArnEquals": {"aws:SourceArn": event_rule_arn}}, + } + ] + } + ) + }, + ) + + return _allow_event_rule + + +@pytest.fixture +def put_events_with_filter_to_sqs( + aws_client, events_create_event_bus, events_put_rule, sqs_as_events_target +): + def _put_events_with_filter_to_sqs( + pattern: dict, + entries_asserts: list[Tuple[list[dict], bool]], + event_bus_name: str = None, + input_path: str = None, + input_transformer: dict[dict, str] = None, + ): + rule_name = f"test-rule-{short_uid()}" + target_id = f"test-target-{short_uid()}" + if not event_bus_name: + event_bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + + queue_url, queue_arn = sqs_as_events_target() + + events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(pattern), + ) + + kwargs = {"InputPath": input_path} if input_path else {} + if input_transformer: + kwargs["InputTransformer"] = input_transformer + + response = aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[{"Id": target_id, "Arn": queue_arn, **kwargs}], + ) + + assert response["FailedEntryCount"] == 0 + assert response["FailedEntries"] == [] + + messages = [] + for entry_asserts in entries_asserts: + entries = entry_asserts[0] + for entry in entries: + entry["EventBusName"] = event_bus_name + message = put_entries_assert_results_sqs( + aws_client.events, + aws_client.sqs, + queue_url, + entries=entries, + should_match=entry_asserts[1], + ) + if message is not None: + messages.extend(message) + + return messages + + yield _put_events_with_filter_to_sqs + + +@pytest.fixture +def events_log_group(aws_client, account_id, region_name): + log_groups = [] + policy_names = [] + + def _create_log_group(): + log_group_name = f"/aws/events/test-log-group-{short_uid()}" + aws_client.logs.create_log_group(logGroupName=log_group_name) + log_group_arn = f"arn:aws:logs:{region_name}:{account_id}:log-group:{log_group_name}" + log_groups.append(log_group_name) + + resource_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EventBridgePutLogEvents", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": f"{log_group_arn}:*", + } + ], + } + policy_name = f"EventBridgePolicy-{short_uid()}" + aws_client.logs.put_resource_policy( + policyName=policy_name, policyDocument=json.dumps(resource_policy) + ) + policy_names.append(policy_name) + + return { + "log_group_name": log_group_name, + "log_group_arn": log_group_arn, + "policy_name": policy_name, + } + + yield _create_log_group + + for log_group in log_groups: + try: + aws_client.logs.delete_log_group(logGroupName=log_group) + except Exception as e: + LOG.debug("error cleaning up log group %s: %s", log_group, e) + + for policy_name in policy_names: + try: + aws_client.logs.delete_resource_policy(policyName=policy_name) + except Exception as e: + LOG.debug("error cleaning up resource policy %s: %s", policy_name, e) + + +@pytest.fixture +def logs_create_log_group(aws_client): + log_group_names = [] + + def _create_log_group(name: str = None) -> str: + if not name: + name = f"test-log-group-{short_uid()}" + + aws_client.logs.create_log_group(logGroupName=name) + log_group_names.append(name) + + return name + + yield _create_log_group + + for name in log_group_names: + try: + aws_client.logs.delete_log_group(logGroupName=name) + except Exception as e: + LOG.debug("error cleaning up log group %s: %s", name, e) + + +@pytest.fixture +def add_resource_policy_logs_events_access(aws_client): + policies = [] + + def _add_resource_policy_logs_events_access(log_group_arn: str): + policy_name = f"test-policy-{short_uid()}" + + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowPutEvents", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": ["logs:PutLogEvents", "logs:CreateLogStream"], + "Resource": log_group_arn, + }, + ], + } + policy = aws_client.logs.put_resource_policy( + policyName=policy_name, + policyDocument=json.dumps(policy_document), + ) + + policies.append(policy_name) + + return policy + + yield _add_resource_policy_logs_events_access + + for policy_name in policies: + aws_client.logs.delete_resource_policy(policyName=policy_name) + + +@pytest.fixture +def get_primary_secondary_client( + aws_client_factory, + secondary_aws_client_factory, + region_name, + secondary_region_name, + account_id, + secondary_account_id, +): + def _get_primary_secondary_clients(cross_scenario: str): + secondary_region = secondary_region_name + secondary_account = secondary_account_id + if cross_scenario not in ["region", "account", "region_account"]: + raise ValueError(f"cross_scenario {cross_scenario} not supported") + + primary_client = aws_client_factory(region_name=region_name) + + if cross_scenario == "region": + secondary_account = account_id + secondary_client = aws_client_factory(region_name=secondary_region_name) + + elif cross_scenario == "account": + secondary_region = region_name + secondary_client = secondary_aws_client_factory(region_name=region_name) + + elif cross_scenario == "region_account": + secondary_client = secondary_aws_client_factory(region_name=secondary_region) + + else: + raise ValueError(f"cross_scenario {cross_scenario} not supported") + + return { + "primary_aws_client": primary_client, + "secondary_aws_client": secondary_client, + "secondary_region_name": secondary_region, + "secondary_account_id": secondary_account, + } + + return _get_primary_secondary_clients + + +@pytest.fixture +def connection_name(): + return f"test-connection-{short_uid()}" + + +@pytest.fixture +def destination_name(): + return f"test-destination-{short_uid()}" + + +@pytest.fixture +def create_connection(aws_client, connection_name): + """Fixture to create a connection with given auth type and parameters.""" + + def _create_connection(auth_type_or_auth, auth_parameters=None): + # Handle both formats: + # 1. (auth_type, auth_parameters) - used by TestEventBridgeConnections + # 2. (auth) - used by TestEventBridgeApiDestinations + if auth_parameters is None: + # Format 2: Single auth dict parameter + auth = auth_type_or_auth + return aws_client.events.create_connection( + Name=connection_name, + AuthorizationType=auth.get("type"), + AuthParameters={ + auth.get("key"): auth.get("parameters"), + }, + ) + else: + # Format 1: auth type and auth parameters + return aws_client.events.create_connection( + Name=connection_name, + AuthorizationType=auth_type_or_auth, + AuthParameters=auth_parameters, + ) + + yield _create_connection + + try: + aws_client.events.delete_connection(Name=connection_name) + except Exception as e: + LOG.debug("Error cleaning up connection: %s", e) + + +@pytest.fixture +def create_api_destination(aws_client, destination_name): + """Fixture to create an API destination with given parameters.""" + + def _create_api_destination(**kwargs): + return aws_client.events.create_api_destination( + Name=destination_name, + **kwargs, + ) + + return _create_api_destination + + +############################# +# Common Transformer Fixtures +############################# + + +@pytest.fixture +def api_destination_snapshot(snapshot, destination_name): + snapshot.add_transformers_list( + [ + snapshot.transform.regex(destination_name, ""), + snapshot.transform.key_value("ApiDestinationArn", reference_replacement=False), + snapshot.transform.key_value("ConnectionArn", reference_replacement=False), + ] + ) + return snapshot + + +@pytest.fixture +def connection_snapshot(snapshot, connection_name): + snapshot.add_transformers_list( + [ + snapshot.transform.regex(connection_name, ""), + TransformerUtility.resource_name(), + ] + ) + return snapshot diff --git a/tests/aws/services/events/event_pattern_templates/arrays.json5 b/tests/aws/services/events/event_pattern_templates/arrays.json5 new file mode 100644 index 0000000000000..7a103bd30e3b9 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/arrays.json5 @@ -0,0 +1,22 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-arrays.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "resources": [ + "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:eb56d16b-bbf0-401d-b893-d5978ed4a025:autoScalingGroupName/ASGTerminate", + "arn:aws:ec2:us-east-1:123456789012:instance/i-b188560f" + ] + }, + "EventPattern": { + "resources": [ + "arn:aws:ec2:us-east-1:123456789012:instance/i-b188560f", + "arn:aws:ec2:us-east-1:111122223333:instance/i-b188560f", + "arn:aws:ec2:us-east-1:444455556666:instance/i-b188560f", + ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/arrays_NEG.json5 b/tests/aws/services/events/event_pattern_templates/arrays_NEG.json5 new file mode 100644 index 0000000000000..140f8d319bc9c --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/arrays_NEG.json5 @@ -0,0 +1,21 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-arrays.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "resources": [ + "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:eb56d16b-bbf0-401d-b893-d5978ed4a025:autoScalingGroupName/ASGTerminate", + ] + }, + "EventPattern": { + "resources": [ + "arn:aws:ec2:us-east-1:123456789012:instance/i-b188560f", + "arn:aws:ec2:us-east-1:111122223333:instance/i-b188560f", + "arn:aws:ec2:us-east-1:444455556666:instance/i-b188560f", + ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/arrays_empty_EXC.json5 b/tests/aws/services/events/event_pattern_templates/arrays_empty_EXC.json5 new file mode 100644 index 0000000000000..30be2359d3b03 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/arrays_empty_EXC.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-arrays.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "resources": [] + }, + "EventPattern": { + "resources": [] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/arrays_empty_null_NEG.json5 b/tests/aws/services/events/event_pattern_templates/arrays_empty_null_NEG.json5 new file mode 100644 index 0000000000000..63263844de317 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/arrays_empty_null_NEG.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-arrays.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "resources": [] + }, + "EventPattern": { + "resources": [null] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/boolean.json5 b/tests/aws/services/events/event_pattern_templates/boolean.json5 new file mode 100644 index 0000000000000..00af9a445c8af --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/boolean.json5 @@ -0,0 +1,14 @@ +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "boolean": false + }, + "EventPattern": { + "boolean": [false] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/boolean_NEG.json5 b/tests/aws/services/events/event_pattern_templates/boolean_NEG.json5 new file mode 100644 index 0000000000000..66078368fbe6d --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/boolean_NEG.json5 @@ -0,0 +1,14 @@ +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "boolean": true + }, + "EventPattern": { + "boolean": [false] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/complex_many_rules.json5 b/tests/aws/services/events/event_pattern_templates/complex_many_rules.json5 new file mode 100644 index 0000000000000..b9a9b636b29ca --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/complex_many_rules.json5 @@ -0,0 +1,58 @@ +// Based on the test case (not AWS validated!): +// tests.aws.services.events.test_events_rules.test_put_event_with_content_base_rule_in_pattern +{ + "Event": { + "id": "1", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "EventBusName": "my-event-bus-name", + "source": "core.update-account-command", + "detail-type": "core.app.backend", + "detail": { + "description": "this-is-event-details", + "amount": 200, + "salary": 2000, + "env": "prod", + "user": "user3", + "admins": "admin", + "test1": 300, + "test2": "test22", + "test3": "test333", + "test4": "this test4", + "ip": "10.102.1.100", + "num-test1": 100, + "num-test2": 200, + "num-test3": 300, + "num-test4": 200, + "num-test5": 500, + "num-test6": 300, + "num-test7": 300, + } + }, + "EventPattern": { + "source": [{"exists": true}], + "detail-type": [{"prefix": "core.app"}], + "detail": { + "description": ["this-is-event-details"], + "amount": [200], + "salary": [2000, 4000], + "env": ["dev", "prod"], + "user": ["user1", "user2", "user3"], + "admins": ["skyli", {"prefix": "hey"}, {"prefix": "ad"}], + "test1": [{"anything-but": 200}], + "test2": [{"anything-but": "test2"}], + "test3": [{"anything-but": ["test3", "test33"]}], + "test4": [{"anything-but": {"prefix": "test4"}}], +// TODO: implement IP matching in LocalStack +// "ip": [{"cidr": "10.102.1.0/24"}], + "num-test1": [{"numeric": ["<", 200]}], + "num-test2": [{"numeric": ["<=", 200]}], + "num-test3": [{"numeric": [">", 200]}], + "num-test4": [{"numeric": [">=", 200]}], + "num-test5": [{"numeric": [">=", 200, "<=", 500]}], + "num-test6": [{"numeric": [">", 200, "<", 500]}], + "num-test7": [{"numeric": [">=", 200, "<", 500]}], + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/complex_multi_key_event.json b/tests/aws/services/events/event_pattern_templates/complex_multi_key_event.json new file mode 100644 index 0000000000000..dd385c4694e21 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/complex_multi_key_event.json @@ -0,0 +1,11 @@ +{ + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "location": "eu-central-1" + } +} diff --git a/tests/aws/services/events/event_pattern_templates/complex_multi_key_event_pattern.json b/tests/aws/services/events/event_pattern_templates/complex_multi_key_event_pattern.json new file mode 100644 index 0000000000000..5676987a9d287 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/complex_multi_key_event_pattern.json @@ -0,0 +1,6 @@ +{ + "detail": { + "location": [ { "prefix": "us-" } ], + "location": [ { "anything-but": "us-east" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/complex_multi_match.json5 b/tests/aws/services/events/event_pattern_templates/complex_multi_match.json5 new file mode 100644 index 0000000000000..3bd0dd29cf286 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/complex_multi_match.json5 @@ -0,0 +1,26 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-complex-example +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "processing", + "c-count": 3, + "d-count": 9, + "x-limit": 999 + } + }, + "EventPattern": { + "time": [ { "prefix": "2022-07-13" } ], + "detail": { + "state": [ { "anything-but": "initializing" } ], + "c-count": [ { "numeric": [ ">", 0, "<=", 5 ] } ], + "d-count": [ { "numeric": [ "<", 10 ] } ], + "x-limit": [ { "anything-but": [ 100, 200, 300 ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/complex_multi_match_NEG.json5 b/tests/aws/services/events/event_pattern_templates/complex_multi_match_NEG.json5 new file mode 100644 index 0000000000000..60a0a1a53713e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/complex_multi_match_NEG.json5 @@ -0,0 +1,27 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-complex-example +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "processing", + "c-count": 3, + "d-count": 9, + // Matches 300 + "x-limit": 300 + } + }, + "EventPattern": { + "time": [ { "prefix": "2022-07-13" } ], + "detail": { + "state": [ { "anything-but": "initializing" } ], + "c-count": [ { "numeric": [ ">", 0, "<=", 5 ] } ], + "d-count": [ { "numeric": [ "<", 10 ] } ], + "x-limit": [ { "anything-but": [ 100, 200, 300 ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/complex_or.json5 b/tests/aws/services/events/event_pattern_templates/complex_or.json5 new file mode 100644 index 0000000000000..fc5721986d059 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/complex_or.json5 @@ -0,0 +1,26 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-complex-example-or +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "c-count": 0, + // Matches <10 + "d-count": 0, + "x-limit": 9.018e2 + } + }, + "EventPattern": { + "detail": { + "$or": [ + { "c-count": [ { "numeric": [ ">", 0, "<=", 5 ] } ] }, + { "d-count": [ { "numeric": [ "<", 10 ] } ] }, + { "x-limit": [ { "numeric": [ "=", 3.018e2 ] } ] } + ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/complex_or_NEG.json5 b/tests/aws/services/events/event_pattern_templates/complex_or_NEG.json5 new file mode 100644 index 0000000000000..85798d4c20d7c --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/complex_or_NEG.json5 @@ -0,0 +1,25 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-complex-example-or +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "c-count": 0, + "d-count": 10, + "x-limit": 9.018e2 + } + }, + "EventPattern": { + "detail": { + "$or": [ + { "c-count": [ { "numeric": [ ">", 0, "<=", 5 ] } ] }, + { "d-count": [ { "numeric": [ "<", 10 ] } ] }, + { "x-limit": [ { "numeric": [ "=", 3.018e2 ] } ] } + ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase.json5 new file mode 100644 index 0000000000000..9f73a6360e99e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "detail": { + "state" : [{ "anything-but": { "equals-ignore-case": "initializing" }}] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_EXC.json5 new file mode 100644 index 0000000000000..68ca8d92e5f81 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "detail": { + "state" : [{ "anything-but": { "equals-ignore-case": 123 }}] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_NEG.json5 new file mode 100644 index 0000000000000..d80b8eef4bed1 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "INITIALIZING" + } + }, + "EventPattern": { + "detail": { + "state" : [{ "anything-but": { "equals-ignore-case": "initializing" }}] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list.json5 new file mode 100644 index 0000000000000..f7185bb85a7b2 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "detail": { + "state" : [{ "anything-but": { "equals-ignore-case": ["initializing", "stopped"] }}] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list_EXC.json5 new file mode 100644 index 0000000000000..0b7f4f8bdf067 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "detail": { + "state" : [{ "anything-but": { "equals-ignore-case": [123, 456] }}] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list_NEG.json5 new file mode 100644 index 0000000000000..6f112c3ab7a36 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_ignorecase_list_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "Stopped" + } + }, + "EventPattern": { + "detail": { + "state" : [{ "anything-but": { "equals-ignore-case": ["initializing", "stopped"] }}] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_number.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_number.json5 new file mode 100644 index 0000000000000..8746fa73d1ce8 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_number.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "x-limit": 789 + } + }, + "EventPattern": { + "detail": { + "x-limit": [ { "anything-but": 123 } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_number_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_NEG.json5 new file mode 100644 index 0000000000000..34fe749dd5ee4 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "x-limit": 123 + } + }, + "EventPattern": { + "detail": { + "x-limit": [ { "anything-but": 123 } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_number_list.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_list.json5 new file mode 100644 index 0000000000000..a455273290fab --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_list.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "x-limit": 999 + } + }, + "EventPattern": { + "detail": { + "x-limit": [ { "anything-but": [ 100, 200, 300 ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_number_list_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_list_NEG.json5 new file mode 100644 index 0000000000000..577d58ecbb666 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_list_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "x-limit": 100 + } + }, + "EventPattern": { + "detail": { + "x-limit": [ { "anything-but": [ 100, 200, 300 ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_number_zero.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_zero.json5 new file mode 100644 index 0000000000000..3bde294bf90dd --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_number_zero.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "x-limit": 789 + } + }, + "EventPattern": { + "detail": { + "x-limit": [ { "anything-but": 0 } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_string.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_string.json5 new file mode 100644 index 0000000000000..f3ba506fb8ae7 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_string.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": "initializing" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_string_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_NEG.json5 new file mode 100644 index 0000000000000..eead136029933 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "initializing" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": "initializing" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_string_list.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_list.json5 new file mode 100644 index 0000000000000..ac73eb8670a44 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_list.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": [ "stopped", "overloaded" ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_string_list_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_list_NEG.json5 new file mode 100644 index 0000000000000..35e3432aab7d6 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_list_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "stopped" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": [ "stopped", "overloaded" ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_but_string_null.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_null.json5 new file mode 100644 index 0000000000000..c0f437399730e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_but_string_null.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": null + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": "initializing" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix.json5 new file mode 100644 index 0000000000000..a0e90f42dded2 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "post-init" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "prefix": "init" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_NEG.json5 new file mode 100644 index 0000000000000..7e08802a9815d --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "init-prefix" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "prefix": "init" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix_empty_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_empty_EXC.json5 new file mode 100644 index 0000000000000..a441ced662de8 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_empty_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "prefix": "" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix_ignorecase_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_ignorecase_EXC.json5 new file mode 100644 index 0000000000000..3233ae6b05e79 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_ignorecase_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "prefix": { "equals-ignore-case": "file" }} } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix_int_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_int_EXC.json5 new file mode 100644 index 0000000000000..9e0fb60ec6de3 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_int_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "post-init" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "prefix": 123 } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list.json5 new file mode 100644 index 0000000000000..57465d83b1305 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "post-init" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "prefix": ["init", "test"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list_NEG.json5 new file mode 100644 index 0000000000000..4a7a91a66dc90 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "post-init" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "prefix": ["init", "post"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list_type_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list_type_EXC.json5 new file mode 100644 index 0000000000000..a1a43c6dd1ff0 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_prefix_list_type_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "post-init" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "prefix": [123, "test"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix.json5 new file mode 100644 index 0000000000000..1d8e1403cbd73 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": ".txt" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_NEG.json5 new file mode 100644 index 0000000000000..836005720a425 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": ".txt" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix_empty_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_empty_EXC.json5 new file mode 100644 index 0000000000000..04cbb758a9d37 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_empty_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": "" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix_ignorecase_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_ignorecase_EXC.json5 new file mode 100644 index 0000000000000..87a47bb65375f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_ignorecase_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": { "equals-ignore-case": ".png" }} } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix_int_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_int_EXC.json5 new file mode 100644 index 0000000000000..5fcb5ae223d1d --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_int_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": 123 } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list.json5 new file mode 100644 index 0000000000000..2e89c74c408ac --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": [".txt", ".jpg"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list_NEG.json5 new file mode 100644 index 0000000000000..9e7edb0b0a64a --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.jpg" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": [".txt", ".jpg"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list_type_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list_type_EXC.json5 new file mode 100644 index 0000000000000..61308b55cd2a1 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_suffix_list_type_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "file.txt.bak" + } + }, + "EventPattern": { + "detail": { + "FileName": [ { "anything-but": { "suffix": [123, ".txt"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_wildcard.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard.json5 new file mode 100644 index 0000000000000..32c3b12af8a71 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FilePath": "dir/init/file" + } + }, + "EventPattern": { + "detail": { + "FilePath": [ { "anything-but": { "wildcard": "*/dir/*" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_NEG.json5 new file mode 100644 index 0000000000000..7bf54079df002 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FilePath": "dir/init/file" + } + }, + "EventPattern": { + "detail": { + "FilePath": [ { "anything-but": { "wildcard": "*/init/*" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_empty.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_empty.json5 new file mode 100644 index 0000000000000..351b5277a3e18 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_empty.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FilePath": "dir/init/file" + } + }, + "EventPattern": { + "detail": { + "FilePath": [ { "anything-but": { "wildcard": "" } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list.json5 new file mode 100644 index 0000000000000..1fe576b0208b6 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "dir/post/dir" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "wildcard": ["*/init/*", "*/dir/*"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list_NEG.json5 new file mode 100644 index 0000000000000..86af67b3c7ad0 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "dir/init/dir" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "wildcard": ["*/init/*", "*/dir/*"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list_type_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list_type_EXC.json5 new file mode 100644 index 0000000000000..5af83d01e4370 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_list_type_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "dir/post/dir" + } + }, + "EventPattern": { + "detail": { + "state": [ { "anything-but": { "wildcard": [123, "*/dir/*"] } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_type_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_type_EXC.json5 new file mode 100644 index 0000000000000..ee855a4ecc0a5 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_anything_wildcard_type_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-anything-but +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FilePath": "dir/init/file" + } + }, + "EventPattern": { + "detail": { + "FilePath": [ { "anything-but": { "wildcard": 123 } } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_exists.json5 b/tests/aws/services/events/event_pattern_templates/content_exists.json5 new file mode 100644 index 0000000000000..fb11fb313307e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_exists.json5 @@ -0,0 +1,20 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "instance-id": "i-abcd1111", + "state": "pending" + } + }, + "EventPattern": { + "detail": { + "state": [ { "exists": true } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_exists_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_exists_NEG.json5 new file mode 100644 index 0000000000000..615636d453443 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_exists_NEG.json5 @@ -0,0 +1,22 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + // Does NOT match because the detail.state field is missing + "detail": { + "c-count" : { + "c1": 100 + } + } + }, + "EventPattern": { + "detail": { + "state": [ { "exists": true } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_exists_false.json5 b/tests/aws/services/events/event_pattern_templates/content_exists_false.json5 new file mode 100644 index 0000000000000..04adeda7a97fa --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_exists_false.json5 @@ -0,0 +1,27 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "level1": { + "level2": "l2 value" + } + } + }, + "EventPattern": { + "detail": { + "level1": { + "level2:": { + "level3": { + "level4": [ { "exists": false } ] + } + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_exists_false_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_exists_false_NEG.json5 new file mode 100644 index 0000000000000..6723c62a52aab --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_exists_false_NEG.json5 @@ -0,0 +1,31 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "level1": { + "level2": { + "level3": { + "level4": "l4 value" + } + } + } + } + }, + "EventPattern": { + "detail": { + "level1": { + "level2": { + "level3": { + "level4": [ { "exists": false } ] + } + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ignorecase.json5 b/tests/aws/services/events/event_pattern_templates/content_ignorecase.json5 new file mode 100644 index 0000000000000..edcf8fa574f07 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ignorecase.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-equals-ignore-case-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": [ "EC2 Instance State-change Notification" ], + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "detail-type": [ { "equals-ignore-case": "ec2 instance state-change notification" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ignorecase_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_ignorecase_EXC.json5 new file mode 100644 index 0000000000000..0d45f3eb541f1 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ignorecase_EXC.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-equals-ignore-case-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": [ "EC2 Instance State-change Notification" ], + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "detail-type": [ { "equals-ignore-case": ["ec2 instance state-change notification"] } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ignorecase_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_ignorecase_NEG.json5 new file mode 100644 index 0000000000000..72a7fd59e7294 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ignorecase_NEG.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-equals-ignore-case-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": [ "EC2 Instance State-change Notification" ], + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "detail-type": [ { "equals-ignore-case": "I do not match" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ignorecase_empty.json5 b/tests/aws/services/events/event_pattern_templates/content_ignorecase_empty.json5 new file mode 100644 index 0000000000000..ab7c2c12c0b07 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ignorecase_empty.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-equals-ignore-case-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "random-value", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "value": "" + } + }, + "EventPattern": { + "detail": { + "value": [ { "equals-ignore-case": "" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ignorecase_empty_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_ignorecase_empty_NEG.json5 new file mode 100644 index 0000000000000..75ca6865bdd52 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ignorecase_empty_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-equals-ignore-case-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "random-value", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "value": "test-value" + } + }, + "EventPattern": { + "detail": { + "value": [ { "equals-ignore-case": "" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ignorecase_list_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_ignorecase_list_EXC.json5 new file mode 100644 index 0000000000000..826fbab8a0c0f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ignorecase_list_EXC.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-equals-ignore-case-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": [ "EC2 Instance State-change Notification" ], + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "detail-type": [ { "equals-ignore-case": {"prefix": "ec2"} } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address.json5 new file mode 100644 index 0000000000000..cb66ade6e382c --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "10.0.0.255" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "10.0.0.0/24" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_EXC.json5 new file mode 100644 index 0000000000000..f199a531c267e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "10.0.0.255" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "bad-filter" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_NEG.json5 new file mode 100644 index 0000000000000..af00a054883ee --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "10.0.0.256" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "10.0.0.0/24" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_bad_ip_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_bad_ip_EXC.json5 new file mode 100644 index 0000000000000..2a4b73ec6f382 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_bad_ip_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "10.0.0.255" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "xx.11.xx/8" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_bad_mask_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_bad_mask_EXC.json5 new file mode 100644 index 0000000000000..4e2be00912d0d --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_bad_mask_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "10.0.0.255" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "bad-/64filter" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_type_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_type_EXC.json5 new file mode 100644 index 0000000000000..867ef10625319 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_type_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "10.0.0.255" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": ["bad-type"] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_v6.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_v6.json5 new file mode 100644 index 0000000000000..a8b8b63548500 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_v6.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "2001:0db8:1234:1a00:0000:0000:0000:0000" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "2001:db8:1234:1a00::/64" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_v6_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_v6_NEG.json5 new file mode 100644 index 0000000000000..72b2f87784323 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_v6_NEG.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "2001:0db8:123f:1a01:0000:0000:0000:0000" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "2001:db8:1234:1a00::/64" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_ip_address_v6_bad_ip_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_ip_address_v6_bad_ip_EXC.json5 new file mode 100644 index 0000000000000..00f7926a8a576 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_ip_address_v6_bad_ip_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-ip-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceIPAddress": "10.0.0.255" + } + }, + "EventPattern": { + "detail": { + "sourceIPAddress": [ { "cidr": "xxxx:db8:1234:1a00::/64" } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_numeric_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_numeric_EXC.json5 new file mode 100644 index 0000000000000..66bea44174ffc --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_numeric_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#filtering-numeric-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "c-count": 3, + } + }, + "EventPattern": { + "detail": { + "c-count": [ { "numeric": [ ">", 0, ">", 0 ] } ], + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_numeric_and.json5 b/tests/aws/services/events/event_pattern_templates/content_numeric_and.json5 new file mode 100644 index 0000000000000..159d52aad325e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_numeric_and.json5 @@ -0,0 +1,23 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#filtering-numeric-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "c-count": 3, + "d-count": 9, + "x-limit": 3.018e2 + } + }, + "EventPattern": { + "detail": { + "c-count": [ { "numeric": [ ">", 0, "<=", 5 ] } ], + "d-count": [ { "numeric": [ "<", 10 ] } ], + "x-limit": [ { "numeric": [ "=", 3.018e2 ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_numeric_and_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_numeric_and_NEG.json5 new file mode 100644 index 0000000000000..d147cab87a66f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_numeric_and_NEG.json5 @@ -0,0 +1,23 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#filtering-numeric-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "c-count": 42, + "d-count": 9, + "x-limit": 3.018e2 + } + }, + "EventPattern": { + "detail": { + "c-count": [ { "numeric": [ ">", 0, "<=", 5 ] } ], + "d-count": [ { "numeric": [ "<", 10 ] } ], + "x-limit": [ { "numeric": [ "=", 3.018e2 ] } ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_numeric_number_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_numeric_number_EXC.json5 new file mode 100644 index 0000000000000..4c6e9357be846 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_numeric_number_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#filtering-numeric-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "c-count": 3, + } + }, + "EventPattern": { + "detail": { + "c-count": [ { "numeric": 10 } ], + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_numeric_operatorcasing_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_numeric_operatorcasing_EXC.json5 new file mode 100644 index 0000000000000..18027381ff697 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_numeric_operatorcasing_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#filtering-numeric-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "equal": 5, + } + }, + "EventPattern": { + "detail": { + "equal": [ { "NUMERIC": [ "=", 5 ] } ], + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_numeric_syntax_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_numeric_syntax_EXC.json5 new file mode 100644 index 0000000000000..7dcc348c6ab08 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_numeric_syntax_EXC.json5 @@ -0,0 +1,19 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#filtering-numeric-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "c-count": 3, + } + }, + "EventPattern": { + "detail": { + "c-count": [ { "numeric": [ ">", 0, "<" ] } ], + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_prefix.json5 b/tests/aws/services/events/event_pattern_templates/content_prefix.json5 new file mode 100644 index 0000000000000..361e52ff7f997 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_prefix.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-prefix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "time": [ { "prefix": "2022-07-13" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_prefix_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_prefix_NEG.json5 new file mode 100644 index 0000000000000..094135ffdfd71 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_prefix_NEG.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-prefix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "time": [ { "prefix": "2022-07-99" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_prefix_empty.json5 b/tests/aws/services/events/event_pattern_templates/content_prefix_empty.json5 new file mode 100644 index 0000000000000..027df9fc438f1 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_prefix_empty.json5 @@ -0,0 +1,17 @@ +// Based on https://stackoverflow.com/questions/62406933/aws-eventbridge-pattern-to-capture-all-events +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "source": [{"prefix": ""}] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_prefix_ignorecase.json5 b/tests/aws/services/events/event_pattern_templates/content_prefix_ignorecase.json5 new file mode 100644 index 0000000000000..4cfd3523f4d02 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_prefix_ignorecase.json5 @@ -0,0 +1,17 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-prefix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "service": "EvEnTb" + } + }, + "EventPattern": { + "detail": {"service" : [{ "prefix": { "equals-ignore-case": "EventB" }}]} + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_prefix_int_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_prefix_int_EXC.json5 new file mode 100644 index 0000000000000..e2b030b5527ed --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_prefix_int_EXC.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-prefix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "time": [ { "prefix": 123 } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_prefix_list_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_prefix_list_EXC.json5 new file mode 100644 index 0000000000000..2182689cec58d --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_prefix_list_EXC.json5 @@ -0,0 +1,14 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-prefix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "time": [ { "prefix": ["2022-07-13"] } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_suffix.json5 b/tests/aws/services/events/event_pattern_templates/content_suffix.json5 new file mode 100644 index 0000000000000..f699b596c7683 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_suffix.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-suffix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "image.png" + }, + "EventPattern": { + "FileName": [ { "suffix": ".png" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_suffix_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_suffix_NEG.json5 new file mode 100644 index 0000000000000..70cc5f9b9883b --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_suffix_NEG.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-suffix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "image.png" + }, + "EventPattern": { + "FileName": [ { "suffix": ".PNG" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_suffix_empty.json5 b/tests/aws/services/events/event_pattern_templates/content_suffix_empty.json5 new file mode 100644 index 0000000000000..3cd0ef2eeba3d --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_suffix_empty.json5 @@ -0,0 +1,17 @@ +// Based on https://stackoverflow.com/questions/62406933/aws-eventbridge-pattern-to-capture-all-events +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": "pending" + } + }, + "EventPattern": { + "source": [{"suffix": ""}] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_suffix_ignorecase.json5 b/tests/aws/services/events/event_pattern_templates/content_suffix_ignorecase.json5 new file mode 100644 index 0000000000000..bc1704ea1831b --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_suffix_ignorecase.json5 @@ -0,0 +1,17 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-suffix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "image.PNG" + }, + }, + "EventPattern": { + "detail": {"FileName" : [{ "suffix": { "equals-ignore-case": ".png" }}]} + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_suffix_ignorecase_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_suffix_ignorecase_NEG.json5 new file mode 100644 index 0000000000000..6537fe76a5a17 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_suffix_ignorecase_NEG.json5 @@ -0,0 +1,17 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-suffix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "FileName": "image.jpg" + }, + }, + "EventPattern": { + "detail": {"FileName" : [{ "suffix": { "equals-ignore-case": ".png" }}]} + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_suffix_int_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_suffix_int_EXC.json5 new file mode 100644 index 0000000000000..0e5e862d00d7e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_suffix_int_EXC.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-suffix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "image.png" + }, + "EventPattern": { + "FileName": [ { "suffix": 123 } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_suffix_list_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_suffix_list_EXC.json5 new file mode 100644 index 0000000000000..4cc9933bb06f9 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_suffix_list_EXC.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-suffix-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "image.png" + }, + "EventPattern": { + "FileName": [ { "suffix": [".png"] } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_complex_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_complex_EXC.json5 new file mode 100644 index 0000000000000..754f3e3fbb4e4 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_complex_EXC.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "EventBusArn": "arn:aws:events:us-east-1:123456789012:event-bus/myEventBus" + }, + "EventPattern": { + "EventBusArn": [ { "wildcard": "*:*:*:*:*:event-bus/*" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_empty_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_empty_NEG.json5 new file mode 100644 index 0000000000000..c01132015d45c --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_empty_NEG.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "EventBusArn": "arn:aws:events:us-east-1:123456789012:event-bus/myEventBus" + }, + "EventPattern": { + "EventBusArn": [ { "wildcard": "" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_int_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_int_EXC.json5 new file mode 100644 index 0000000000000..f000e40c7157d --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_int_EXC.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "EventBusArn": "arn:aws:events:us-east-1:123456789012:event-bus/myEventBus" + }, + "EventPattern": { + "EventBusArn": [ { "wildcard": 123 } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_list_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_list_EXC.json5 new file mode 100644 index 0000000000000..c9531b4ea8b92 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_list_EXC.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "EventBusArn": "arn:aws:events:us-east-1:123456789012:event-bus/myEventBus" + }, + "EventPattern": { + "EventBusArn": [ { "wildcard": ["arn:aws:events:us-east-1:**:event-bus/*"] } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_nonrepeating.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_nonrepeating.json5 new file mode 100644 index 0000000000000..ab6b58ad794df --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_nonrepeating.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "/Users/ls_user/dir/dir/dir/dir/dir/doc.txt" + }, + "EventPattern": { + "FileName": [ { "wildcard": "/Users/*/doc.txt" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_nonrepeating_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_nonrepeating_NEG.json5 new file mode 100644 index 0000000000000..d2911b623f345 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_nonrepeating_NEG.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "/Users/ls_user/dir/dir/dir/dir/dir/notmatchingdoc.txt" + }, + "EventPattern": { + "FileName": [ { "wildcard": "/Users/*/doc.txt" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating.json5 new file mode 100644 index 0000000000000..c46dfa1262ec4 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "/Users/ls_user/dir/dir/dir/dir/dir/doc.txt" + }, + "EventPattern": { + "FileName": [ { "wildcard": "/Users/*/dir/dir/dir/dir/dir/doc.txt" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating_NEG.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating_NEG.json5 new file mode 100644 index 0000000000000..ce36c4d593f6e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating_NEG.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "FileName": "/Users/ls_user/dir/dir/dir/dir/otherdir/doc.txt" + }, + "EventPattern": { + "FileName": [ { "wildcard": "/Users/*/dir/dir/dir/dir/dir/doc.txt" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating_star_EXC.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating_star_EXC.json5 new file mode 100644 index 0000000000000..411658e590530 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_repeating_star_EXC.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "EventBusArn": "arn:aws:events:us-east-1:123456789012:event-bus/myEventBus" + }, + "EventPattern": { + "EventBusArn": [ { "wildcard": "arn:aws:events:us-east-1:**:event-bus/*" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/content_wildcard_simplified.json5 b/tests/aws/services/events/event_pattern_templates/content_wildcard_simplified.json5 new file mode 100644 index 0000000000000..e90d3964174ca --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/content_wildcard_simplified.json5 @@ -0,0 +1,15 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "EventBusArn": "arn:aws:events:us-east-1:123456789012:event-bus/myEventBus" + }, + "EventPattern": { + "EventBusArn": [ { "wildcard": "arn:aws:events:us-east-1:*:event-bus/*" } ] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/dot_joining_event.json5 b/tests/aws/services/events/event_pattern_templates/dot_joining_event.json5 new file mode 100644 index 0000000000000..a416f130b162e --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/dot_joining_event.json5 @@ -0,0 +1,19 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state.status": "running" + } + }, + "EventPattern": { + "detail": { + "state": { "status": [ "running" ] } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/dot_joining_event_NEG.json5 b/tests/aws/services/events/event_pattern_templates/dot_joining_event_NEG.json5 new file mode 100644 index 0000000000000..a007e949bf28f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/dot_joining_event_NEG.json5 @@ -0,0 +1,21 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state.status": { + "status": "running" + } + } + }, + "EventPattern": { + "detail": { + "state": { "status": [ "running" ] } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/dot_joining_pattern.json5 b/tests/aws/services/events/event_pattern_templates/dot_joining_pattern.json5 new file mode 100644 index 0000000000000..ba158dcba3d5f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/dot_joining_pattern.json5 @@ -0,0 +1,21 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": { + "status": "running" + } + } + }, + "EventPattern": { + "detail" : { + "state.status": [ "running" ] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/dot_joining_pattern_NEG.json5 b/tests/aws/services/events/event_pattern_templates/dot_joining_pattern_NEG.json5 new file mode 100644 index 0000000000000..90f70c344c38b --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/dot_joining_pattern_NEG.json5 @@ -0,0 +1,23 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "state": { + "status": "running" + } + } + }, + "EventPattern": { + "detail" : { + "state.status": { + "status": [ "running" ] + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/dynamodb.json5 b/tests/aws/services/events/event_pattern_templates/dynamodb.json5 new file mode 100644 index 0000000000000..bccf084d5103f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/dynamodb.json5 @@ -0,0 +1,27 @@ +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "NewImage": { + "homemade": { + "S": "ABCD", + "N": "1234" + } + } + } + }, + "EventPattern": { + "dynamodb": { + "NewImage": { + "homemade": { + "N": ["1234"] + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/exists_dynamodb.json5 b/tests/aws/services/events/event_pattern_templates/exists_dynamodb.json5 new file mode 100644 index 0000000000000..00594198d50b4 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/exists_dynamodb.json5 @@ -0,0 +1,24 @@ +// DynamoDB Stream Tutorial: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.Lambda.Tutorial2.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "id": {"S": "test1234"}, + "presentKey": {"S": "test123"} + } + }, + "EventPattern": { + "dynamodb": { + // "Exists matching only works on leaf nodes. It does not work on intermediate nodes." + // https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching + "presentKey": { + "S": [{"exists": true}] + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/exists_dynamodb_NEG.json5 b/tests/aws/services/events/event_pattern_templates/exists_dynamodb_NEG.json5 new file mode 100644 index 0000000000000..5c0976ac373eb --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/exists_dynamodb_NEG.json5 @@ -0,0 +1,22 @@ +// DynamoDB Stream Tutorial: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.Lambda.Tutorial2.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "id": {"S": "test1234"}, + "presentKey": {"S": "test123"} + } + }, + "EventPattern": { + "dynamodb": { + // "Exists matching only works on leaf nodes. It does not work on intermediate nodes." + // https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching + "presentKey": [{"exists": true}] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/exists_list_empty_NEG.json5 b/tests/aws/services/events/event_pattern_templates/exists_list_empty_NEG.json5 new file mode 100644 index 0000000000000..eb34b0b2dacbc --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/exists_list_empty_NEG.json5 @@ -0,0 +1,20 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-null-values.html +{ + "Event": { + "version": "0", + "id": "3e3c153a-8339-4e30-8c35-687ebef853fe", + "detail-type": "EC2 Instance Launch Successful", + "source": "aws.autoscaling", + "account": "123456789012", + "time": "2015-11-11T21:31:47Z", + "region": "us-east-1", + "resources": [], + "detail": { + "eventVersion": "", + "responseElements": null + } + }, + "EventPattern": { + "resources": [{ "exists": true }] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/int_nolist_EXC.json5 b/tests/aws/services/events/event_pattern_templates/int_nolist_EXC.json5 new file mode 100644 index 0000000000000..ccb92aa68573f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/int_nolist_EXC.json5 @@ -0,0 +1,15 @@ +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "int": 42 + }, + "EventPattern": { + // Without the list + "int": 42 + } +} diff --git a/tests/aws/services/events/event_pattern_templates/key_case_sensitive_NEG.json5 b/tests/aws/services/events/event_pattern_templates/key_case_sensitive_NEG.json5 new file mode 100644 index 0000000000000..7b811554810ac --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/key_case_sensitive_NEG.json5 @@ -0,0 +1,14 @@ +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "my_key": "my-value", + }, + "EventPattern": { + "MY_KEY": ["my-value"] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/list_within_dict.json5 b/tests/aws/services/events/event_pattern_templates/list_within_dict.json5 new file mode 100644 index 0000000000000..23f811ef54d91 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/list_within_dict.json5 @@ -0,0 +1,26 @@ +// Motivated by https://github.com/localstack/localstack/pull/10600 +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "automations": [ + {"key1": "value1"}, + // the "exists" operator matches because at least one element of the list matches + {"id": "match-does-exist"}, + {"key2": "value2"} + ] + } + }, + "EventPattern": { + "detail": { + "automations": { + "id": [{"exists": true}] + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/minimal.json5 b/tests/aws/services/events/event_pattern_templates/minimal.json5 new file mode 100644 index 0000000000000..5de636d785e50 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/minimal.json5 @@ -0,0 +1,15 @@ +// API: https://docs.aws.amazon.com/eventbridge/latest/APIReference/API_TestEventPattern.html +// Mandatory fields for events: id, account, source, time, region, resources, detail-type +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + }, + "EventPattern": { + "id": ["1"] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/nested_json_NEG.json5 b/tests/aws/services/events/event_pattern_templates/nested_json_NEG.json5 new file mode 100644 index 0000000000000..5ed5f2bc46671 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/nested_json_NEG.json5 @@ -0,0 +1,16 @@ +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": "{\"my-key\": \"my-value\"}", + }, + "EventPattern": { + "detail": { + "my-key": ["my-value"] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/null_value.json5 b/tests/aws/services/events/event_pattern_templates/null_value.json5 new file mode 100644 index 0000000000000..28d2d55af637b --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/null_value.json5 @@ -0,0 +1,23 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-null-values.html +{ + "Event": { + "version": "0", + "id": "3e3c153a-8339-4e30-8c35-687ebef853fe", + "detail-type": "EC2 Instance Launch Successful", + "source": "aws.autoscaling", + "account": "123456789012", + "time": "2015-11-11T21:31:47Z", + "region": "us-east-1", + "resources": [ + ], + "detail": { + "eventVersion": "", + "responseElements": null + } + }, + "EventPattern": { + "detail": { + "responseElements": [null] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/null_value_NEG.json5 b/tests/aws/services/events/event_pattern_templates/null_value_NEG.json5 new file mode 100644 index 0000000000000..9f4caa79d8b1b --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/null_value_NEG.json5 @@ -0,0 +1,23 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-null-values.html +{ + "Event": { + "version": "0", + "id": "3e3c153a-8339-4e30-8c35-687ebef853fe", + "detail-type": "EC2 Instance Launch Successful", + "source": "aws.autoscaling", + "account": "123456789012", + "time": "2015-11-11T21:31:47Z", + "region": "us-east-1", + "resources": [ + ], + "detail": { + "eventVersion": "", + "responseElements": "null" + } + }, + "EventPattern": { + "detail": { + "responseElements": [null] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/number_comparison_float.json5 b/tests/aws/services/events/event_pattern_templates/number_comparison_float.json5 new file mode 100644 index 0000000000000..67a1852d10c68 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/number_comparison_float.json5 @@ -0,0 +1,20 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + // The scientific notation 3.0e2 requires different testing because it gets serialized into 300.0 + // Gets serialized into the string '... "number": 300.0}' + "number": 300.0 + }, + "EventPattern": { + // This behavior contradicts the AWS documentation: + // For numbers, EventBridge uses string representation. For example, 300, 300.0, and 3.0e2 are not considered equal. + // Gets serialized into the string '{"number": [300]}' + "number": [300] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/numeric-int-float.json5 b/tests/aws/services/events/event_pattern_templates/numeric-int-float.json5 new file mode 100644 index 0000000000000..02490053e6ca8 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/numeric-int-float.json5 @@ -0,0 +1,15 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "number": 101.0 + }, + "EventPattern": { + "number": [{"numeric": [">", 100]}] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/numeric-null_NEG.json5 b/tests/aws/services/events/event_pattern_templates/numeric-null_NEG.json5 new file mode 100644 index 0000000000000..55b05b96ac961 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/numeric-null_NEG.json5 @@ -0,0 +1,15 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "number": null + }, + "EventPattern": { + "number": [{"numeric": [">", 100]}] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/numeric-string_NEG.json5 b/tests/aws/services/events/event_pattern_templates/numeric-string_NEG.json5 new file mode 100644 index 0000000000000..1a860efc1fe66 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/numeric-string_NEG.json5 @@ -0,0 +1,15 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "number": "300" + }, + "EventPattern": { + "number": [{"numeric": [">", 100]}] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/operator_case_sensitive_EXC.json5 b/tests/aws/services/events/event_pattern_templates/operator_case_sensitive_EXC.json5 new file mode 100644 index 0000000000000..46a1a4befaa97 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/operator_case_sensitive_EXC.json5 @@ -0,0 +1,14 @@ +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "my_key": "my-value", + }, + "EventPattern": { + "my_key": [{ "EXISTS": true }] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/operator_multiple_list.json5 b/tests/aws/services/events/event_pattern_templates/operator_multiple_list.json5 new file mode 100644 index 0000000000000..fe780a0ccdc4b --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/operator_multiple_list.json5 @@ -0,0 +1,15 @@ +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "my_key": "my-value", + }, + "EventPattern": { + // Only the first operator in the list gets evaluated, others are ignored without raising an exception + "my_key": [{ "exists": true }, {"prefix": "IGNORED" }, {"suffix": "IGNORED"}] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/or-anything-but.json5 b/tests/aws/services/events/event_pattern_templates/or-anything-but.json5 new file mode 100644 index 0000000000000..2208a36f9fb58 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/or-anything-but.json5 @@ -0,0 +1,38 @@ +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "NewImage": { + "homemade": { + "S": "ABCD", + "N": "1234" + } + } + } + }, + "EventPattern": { + "dynamodb": { + "NewImage": { + "homemade": { + "S": [ + // Matches this filter because ABCD is not "roses" + { + "anything-but": [ + "roses" + ] + }, + // Does NOT match this filter because S exists + { + "exists": false + } + ] + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/or-exists-parent.json5 b/tests/aws/services/events/event_pattern_templates/or-exists-parent.json5 new file mode 100644 index 0000000000000..90b31da0ba3e5 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/or-exists-parent.json5 @@ -0,0 +1,38 @@ +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "NewImage": { + // "homemade>S" does NOT exist + "purchased": { + "N": "789" + } + } + } + }, + "EventPattern": { + "dynamodb": { + "NewImage": { + "homemade": { + "S": [ + // Does NOT match this filter because "homemade" is not present + { + "anything-but": [ + "roses" + ] + }, + // Matches this filter because "homemade>S" does not exist + { + "exists": false + } + ] + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/or-exists.json5 b/tests/aws/services/events/event_pattern_templates/or-exists.json5 new file mode 100644 index 0000000000000..0128eecb034f9 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/or-exists.json5 @@ -0,0 +1,37 @@ +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "NewImage": { + "homemade": { + "N": "789" + } + } + } + }, + "EventPattern": { + "dynamodb": { + "NewImage": { + "homemade": { + "S": [ + // Does NOT match this filter because "homemade" is not present + { + "anything-but": [ + "roses" + ] + }, + // Matches this filter because S does not exist + { + "exists": false + } + ] + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/or-numeric-anything-but.json5 b/tests/aws/services/events/event_pattern_templates/or-numeric-anything-but.json5 new file mode 100644 index 0000000000000..c80b47c19670f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/or-numeric-anything-but.json5 @@ -0,0 +1,38 @@ +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "ApproximateCreationDateTime": 1733418659.0, + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + }, + "numericFilter": { + "N": "42" + } + }, + "SequenceNumber": "49658361752382621885697088319781165717078428243510427650", + "SizeBytes": 52, + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + }, + "EventPattern": { + "dynamodb": { + "NewImage": { + "numericFilter": { + "N": [{"numeric": [">", 100]}, {"anything-but": "101"}] + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/or-numeric-anything-but_NEG.json5 b/tests/aws/services/events/event_pattern_templates/or-numeric-anything-but_NEG.json5 new file mode 100644 index 0000000000000..722926954fa2f --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/or-numeric-anything-but_NEG.json5 @@ -0,0 +1,38 @@ +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "ApproximateCreationDateTime": 1733418659.0, + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + }, + "numericFilter": { + "N": "101" + } + }, + "SequenceNumber": "49658361752382621885697088319781165717078428243510427650", + "SizeBytes": 52, + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + }, + "EventPattern": { + "dynamodb": { + "NewImage": { + "numericFilter": { + "N": [{"numeric": [">", 100]}, {"anything-but": "101"}] + } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/prefix.json5 b/tests/aws/services/events/event_pattern_templates/prefix.json5 new file mode 100644 index 0000000000000..d901062ac1058 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/prefix.json5 @@ -0,0 +1,40 @@ +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "dynamodb": { + "ApproximateCreationDateTime": 1664559083.0, + "Keys": { + "SK": { "S": "PRODUCT#CHOCOLATE#DARK#1000" }, + "PK": { "S": "COMPANY#1000" } + }, + "NewImage": { + "quantity": { "N": "50" }, + "company_id": { "S": "1000" }, + "fabric": { "S": "Florida Chocolates" }, + "price": { "N": "15" }, + "stores": { "N": "5" }, + "product_id": { "S": "1000" }, + "SK": { "S": "PRODUCT#CHOCOLATE#DARK#1000" }, + "PK": { "S": "COMPANY#1000" }, + "state": { "S": "FL" }, + "type": { "S": "" } + }, + "SequenceNumber": "700000000000888747038", + "SizeBytes": 174, + "StreamViewType": "NEW_AND_OLD_IMAGES" + } + }, + "EventPattern": { + "dynamodb": { + "Keys": { + "PK": { "S": [{ "prefix": "COMPANY" }] }, + "SK": { "S": [{ "prefix": "PRODUCT" }] } + } + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/sample1.json5 b/tests/aws/services/events/event_pattern_templates/sample1.json5 new file mode 100644 index 0000000000000..dd44d17ecf669 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/sample1.json5 @@ -0,0 +1,20 @@ +// This sample is based on our API tests +// API: https://docs.aws.amazon.com/eventbridge/latest/APIReference/API_TestEventPattern.html +{ + "Event": { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z" + }, + "EventPattern": { + "source": [ + "order" + ], + "detail-type": [ + "Test" + ], + } +} diff --git a/tests/aws/services/events/event_pattern_templates/string.json5 b/tests/aws/services/events/event_pattern_templates/string.json5 new file mode 100644 index 0000000000000..710c5774c36f1 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/string.json5 @@ -0,0 +1,16 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "string": "A CamelCase string with an emoji πŸš€ and trailing whitespace " + }, + "EventPattern": { + // For strings, EventBridge uses exact character-by-character matching without case-folding or any other string normalization. + "string": ["A CamelCase string with an emoji πŸš€ and trailing whitespace "] + } +} diff --git a/tests/aws/services/events/event_pattern_templates/string_empty.json5 b/tests/aws/services/events/event_pattern_templates/string_empty.json5 new file mode 100644 index 0000000000000..3ce9ed20d5f62 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/string_empty.json5 @@ -0,0 +1,22 @@ +// Based on https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-null-values.html +{ + "Event": { + "version": "0", + "id": "3e3c153a-8339-4e30-8c35-687ebef853fe", + "detail-type": "EC2 Instance Launch Successful", + "source": "aws.autoscaling", + "account": "123456789012", + "time": "2015-11-11T21:31:47Z", + "region": "us-east-1", + "resources": [], + "detail": { + "eventVersion": "", + "responseElements": null + } + }, + "EventPattern": { + "detail": { + "eventVersion": [""] + } + } +} diff --git a/tests/aws/services/events/event_pattern_templates/string_nolist_EXC.json5 b/tests/aws/services/events/event_pattern_templates/string_nolist_EXC.json5 new file mode 100644 index 0000000000000..8dc89de8c3ba2 --- /dev/null +++ b/tests/aws/services/events/event_pattern_templates/string_nolist_EXC.json5 @@ -0,0 +1,16 @@ +// Based on "Considerations when creating event patterns" from https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html +{ + "Event": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "string": "my-value", + }, + "EventPattern": { + // Without the list + "string": "my-value", + } +} diff --git a/tests/aws/services/events/helper_functions.py b/tests/aws/services/events/helper_functions.py new file mode 100644 index 0000000000000..0c39f6b7b813a --- /dev/null +++ b/tests/aws/services/events/helper_functions.py @@ -0,0 +1,149 @@ +import json +import os +from datetime import datetime, timedelta, timezone + +from localstack.testing.aws.util import is_aws_cloud +from localstack.utils.sync import retry + + +def is_v2_provider(): + return ( + os.environ.get("PROVIDER_OVERRIDE_EVENTS", "") not in ("v1", "legacy") + and not is_aws_cloud() + ) + + +def is_old_provider(): + return os.environ.get("PROVIDER_OVERRIDE_EVENTS", "") in ("v1", "legacy") and not is_aws_cloud() + + +def events_time_string_to_timestamp(time_string: str) -> datetime: + time_string_format = "%Y-%m-%dT%H:%M:%SZ" + return datetime.strptime(time_string, time_string_format) + + +def get_cron_expression(delta_minutes: int) -> tuple[str, datetime]: + """Get a exact cron expression for a future time in UTC from now rounded to the next full minute + delta_minutes.""" + now = datetime.now(timezone.utc) + future_time = now + timedelta(minutes=delta_minutes) + + # Round to the next full minute + future_time += timedelta(minutes=1) + future_time = future_time.replace(second=0, microsecond=0) + + cron_string = ( + f"cron({future_time.minute} {future_time.hour} {future_time.day} {future_time.month} ? *)" + ) + + return cron_string, future_time + + +def put_entries_assert_results_sqs( + events_client, sqs_client, queue_url: str, entries: list[dict], should_match: bool +): + """ + Put events to the event bus, receives the messages resulting from the event in the sqs queue and deletes them out of the queue. + If should_match is True, the content of the messages is asserted to be the same as the events put to the event bus. + + :param events_client: boto3.client("events") + :param sqs_client: boto3.client("sqs") + :param queue_url: URL of the sqs queue + :param entries: List of entries to put to the event bus, each entry must + be a dict that contains the keys: "Source", "DetailType", "Detail" + :param should_match: + + :return: Messages from the queue if should_match is True, otherwise None + """ + response = events_client.put_events(Entries=entries) + assert not response.get("FailedEntryCount") + + def get_message(queue_url): + resp = sqs_client.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=5, MaxNumberOfMessages=1 + ) + messages = resp.get("Messages") + if messages: + for message in messages: + receipt_handle = message["ReceiptHandle"] + sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + if should_match: + assert len(messages) == 1 + return messages + + messages = retry(get_message, retries=5, queue_url=queue_url) + + if should_match: + try: + actual_event = json.loads(messages[0]["Body"]) + except json.JSONDecodeError: + actual_event = messages[0]["Body"] + if isinstance(actual_event, dict) and "detail" in actual_event: + assert_valid_event(actual_event) + return messages + else: + assert not messages + return None + + +def assert_valid_event(event): + expected_fields = ( + "version", + "id", + "detail-type", + "source", + "account", + "time", + "region", + "resources", + "detail", + ) + for field in expected_fields: + assert field in event + + +def sqs_collect_messages( + aws_client, + queue_url: str, + expected_events_count: int, + wait_time: int = 1, + retries: int = 3, +) -> list[dict]: + """ + Polls the given queue for the given amount of time and extracts and flattens from the received messages all + events (messages that have a "Records" field in their body, and where the records can be json-deserialized). + + :param queue_url: the queue URL to listen from + :param expected_events_count: the minimum number of events to receive to wait for + :param wait_time: the number of seconds to wait between retries + :param retries: the number of retries before raising an assert error + :return: a list with the deserialized records from the SQS messages + """ + + events = [] + + def collect_events() -> None: + _response = aws_client.sqs.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=10, WaitTimeSeconds=wait_time + ) + messages = _response.get("Messages", []) + + for m in messages: + events.append(m) + aws_client.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"]) + + assert len(events) >= expected_events_count + + retry(collect_events, retries=retries, sleep=0.01) + + return events + + +def wait_for_replay_in_state( + aws_client, replay_name: str, expected_state: str, retries: int = 10, sleep: int = 10 +) -> bool: + def _wait_for_state(): + response = aws_client.events.describe_replay(ReplayName=replay_name) + state = response["State"] + assert state == expected_state + + return retry(_wait_for_state, retries, sleep) diff --git a/tests/aws/services/events/test_api_destinations_and_connection.py b/tests/aws/services/events/test_api_destinations_and_connection.py new file mode 100644 index 0000000000000..00e0aec57536c --- /dev/null +++ b/tests/aws/services/events/test_api_destinations_and_connection.py @@ -0,0 +1,384 @@ +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.sync import poll_condition +from tests.aws.services.events.helper_functions import is_old_provider + +API_DESTINATION_AUTHS = [ + { + "type": "BASIC", + "key": "BasicAuthParameters", + "parameters": {"Username": "user", "Password": "pass"}, + }, + { + "type": "API_KEY", + "key": "ApiKeyAuthParameters", + "parameters": {"ApiKeyName": "Api", "ApiKeyValue": "apikey_secret"}, + }, + { + "type": "OAUTH_CLIENT_CREDENTIALS", + "key": "OAuthParameters", + "parameters": { + "AuthorizationEndpoint": "replace_this", + "ClientParameters": {"ClientID": "id", "ClientSecret": "password"}, + "HttpMethod": "put", + "OAuthHttpParameters": { + "BodyParameters": [{"Key": "oauthbody", "Value": "value1"}], + "HeaderParameters": [{"Key": "oauthheader", "Value": "value2"}], + "QueryStringParameters": [{"Key": "oauthquery", "Value": "value3"}], + }, + }, + }, +] + +API_DESTINATION_AUTH_PARAMS = [ + { + "AuthorizationType": "BASIC", + "AuthParameters": { + "BasicAuthParameters": {"Username": "user", "Password": "pass"}, + }, + }, + { + "AuthorizationType": "API_KEY", + "AuthParameters": { + "ApiKeyAuthParameters": {"ApiKeyName": "ApiKey", "ApiKeyValue": "secret"}, + }, + }, + { + "AuthorizationType": "OAUTH_CLIENT_CREDENTIALS", + "AuthParameters": { + "OAuthParameters": { + "AuthorizationEndpoint": "https://example.com/oauth", + "ClientParameters": {"ClientID": "client_id", "ClientSecret": "client_secret"}, + "HttpMethod": "POST", + } + }, + }, +] + + +class TestEventBridgeApiDestinations: + @markers.aws.validated + @pytest.mark.parametrize("auth", API_DESTINATION_AUTHS) + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_api_destinations( + self, + aws_client, + create_connection, + create_api_destination, + destination_name, + auth, + api_destination_snapshot, + ): + connection_response = create_connection(auth) + connection_arn = connection_response["ConnectionArn"] + + response = create_api_destination( + ConnectionArn=connection_arn, + HttpMethod="POST", + InvocationEndpoint="https://example.com/api", + Description="Test API destination", + ) + api_destination_snapshot.match("create-api-destination", response) + + describe_response = aws_client.events.describe_api_destination(Name=destination_name) + api_destination_snapshot.match("describe-api-destination", describe_response) + + list_response = aws_client.events.list_api_destinations(NamePrefix=destination_name) + api_destination_snapshot.match("list-api-destinations", list_response) + + update_response = aws_client.events.update_api_destination( + Name=destination_name, + ConnectionArn=connection_arn, + HttpMethod="PUT", + InvocationEndpoint="https://example.com/api/v2", + Description="Updated API destination", + ) + api_destination_snapshot.match("update-api-destination", update_response) + + describe_updated_response = aws_client.events.describe_api_destination( + Name=destination_name + ) + api_destination_snapshot.match( + "describe-updated-api-destination", describe_updated_response + ) + + delete_response = aws_client.events.delete_api_destination(Name=destination_name) + api_destination_snapshot.match("delete-api-destination", delete_response) + + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as exc_info: + aws_client.events.describe_api_destination(Name=destination_name) + api_destination_snapshot.match( + "describe-api-destination-not-found-error", exc_info.value.response + ) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="V1 provider does not support this feature") + def test_create_api_destination_invalid_parameters( + self, aws_client, api_destination_snapshot, destination_name + ): + with pytest.raises(ClientError) as e: + aws_client.events.create_api_destination( + Name=destination_name, + ConnectionArn="invalid-connection-arn", + HttpMethod="INVALID_METHOD", + InvocationEndpoint="invalid-endpoint", + ) + api_destination_snapshot.match( + "create-api-destination-invalid-parameters-error", e.value.response + ) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="V1 provider does not support this feature") + def test_create_api_destination_name_validation( + self, aws_client, api_destination_snapshot, create_connection + ): + invalid_name = "Invalid Name With Spaces!" + + connection_response = create_connection(API_DESTINATION_AUTHS[0]) + connection_arn = connection_response["ConnectionArn"] + + with pytest.raises(ClientError) as e: + aws_client.events.create_api_destination( + Name=invalid_name, + ConnectionArn=connection_arn, + HttpMethod="POST", + InvocationEndpoint="https://example.com/api", + ) + api_destination_snapshot.match( + "create-api-destination-invalid-name-error", e.value.response + ) + + +class TestEventBridgeConnections: + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_create_connection( + self, aws_client, connection_snapshot, create_connection, connection_name + ): + response = create_connection( + "API_KEY", + { + "ApiKeyAuthParameters": {"ApiKeyName": "ApiKey", "ApiKeyValue": "secret"}, + "InvocationHttpParameters": {}, + }, + ) + connection_snapshot.match("create-connection", response) + + describe_response = aws_client.events.describe_connection(Name=connection_name) + connection_snapshot.match("describe-connection", describe_response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize("auth_params", API_DESTINATION_AUTH_PARAMS) + def test_create_connection_with_auth( + self, aws_client, connection_snapshot, create_connection, auth_params, connection_name + ): + response = create_connection( + auth_params["AuthorizationType"], + auth_params["AuthParameters"], + ) + connection_snapshot.match("create-connection-auth", response) + + describe_response = aws_client.events.describe_connection(Name=connection_name) + connection_snapshot.match("describe-connection-auth", describe_response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_list_connections( + self, aws_client, connection_snapshot, create_connection, connection_name + ): + create_connection( + "BASIC", + { + "BasicAuthParameters": {"Username": "user", "Password": "pass"}, + "InvocationHttpParameters": {}, + }, + ) + + response = aws_client.events.list_connections(NamePrefix=connection_name) + connection_snapshot.match("list-connections", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_delete_connection( + self, aws_client, connection_snapshot, create_connection, connection_name + ): + response = create_connection( + "API_KEY", + { + "ApiKeyAuthParameters": {"ApiKeyName": "ApiKey", "ApiKeyValue": "secret"}, + "InvocationHttpParameters": {}, + }, + ) + connection_snapshot.match("create-connection-response", response) + + secret_arn = aws_client.events.describe_connection(Name=connection_name)["SecretArn"] + # check if secret exists + aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + + delete_response = aws_client.events.delete_connection(Name=connection_name) + connection_snapshot.match("delete-connection", delete_response) + + # wait until connection is deleted + def is_connection_deleted(): + try: + aws_client.events.describe_connection(Name=connection_name) + return False + except Exception: + return True + + poll_condition(is_connection_deleted) + + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as exc: + aws_client.events.describe_connection(Name=connection_name) + connection_snapshot.match("describe-deleted-connection", exc.value.response) + + def is_secret_deleted(): + try: + aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + return False + except Exception: + return True + + poll_condition(is_secret_deleted) + + with pytest.raises(aws_client.secretsmanager.exceptions.ResourceNotFoundException): + aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_create_connection_invalid_parameters( + self, aws_client, connection_snapshot, connection_name + ): + with pytest.raises(ClientError) as e: + aws_client.events.create_connection( + Name=connection_name, + AuthorizationType="INVALID_AUTH_TYPE", + AuthParameters={}, + ) + connection_snapshot.match("create-connection-invalid-auth-error", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_update_connection( + self, aws_client, snapshot, connection_snapshot, create_connection, connection_name + ): + create_response = create_connection( + "BASIC", + { + "BasicAuthParameters": {"Username": "user", "Password": "pass"}, + "InvocationHttpParameters": {}, + }, + ) + connection_snapshot.match("create-connection", create_response) + + describe_response = aws_client.events.describe_connection(Name=connection_name) + connection_snapshot.match("describe-created-connection", describe_response) + + # add secret id transformer + secret_id = describe_response["SecretArn"] + secret_uuid, _, secret_suffix = secret_id.rpartition("/")[2].rpartition("-") + connection_snapshot.add_transformer( + snapshot.transform.regex(secret_uuid, ""), priority=-1 + ) + connection_snapshot.add_transformer( + snapshot.transform.regex(secret_suffix, ""), priority=-1 + ) + + get_secret_response = aws_client.secretsmanager.get_secret_value(SecretId=secret_id) + connection_snapshot.match("connection-secret-before-update", get_secret_response) + + update_response = aws_client.events.update_connection( + Name=connection_name, + AuthorizationType="BASIC", + AuthParameters={ + "BasicAuthParameters": {"Username": "new_user", "Password": "new_pass"}, + "InvocationHttpParameters": {}, + }, + ) + connection_snapshot.match("update-connection", update_response) + + describe_response = aws_client.events.describe_connection(Name=connection_name) + connection_snapshot.match("describe-updated-connection", describe_response) + + get_secret_response = aws_client.secretsmanager.get_secret_value(SecretId=secret_id) + connection_snapshot.match("connection-secret-after-update", get_secret_response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_create_connection_name_validation(self, aws_client, connection_snapshot): + invalid_name = "Invalid Name With Spaces!" + + with pytest.raises(ClientError) as e: + aws_client.events.create_connection( + Name=invalid_name, + AuthorizationType="API_KEY", + AuthParameters={ + "ApiKeyAuthParameters": {"ApiKeyName": "ApiKey", "ApiKeyValue": "secret"}, + "InvocationHttpParameters": {}, + }, + ) + connection_snapshot.match("create-connection-invalid-name-error", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "auth_params", API_DESTINATION_AUTH_PARAMS, ids=["basic", "api-key", "oauth"] + ) + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_connection_secrets( + self, + aws_client, + snapshot, + connection_snapshot, + create_connection, + connection_name, + auth_params, + ): + response = create_connection( + auth_params["AuthorizationType"], + auth_params["AuthParameters"], + ) + connection_snapshot.match("create-connection-auth", response) + + describe_response = aws_client.events.describe_connection(Name=connection_name) + connection_snapshot.match("describe-connection-auth", describe_response) + + secret_id = describe_response["SecretArn"] + secret_uuid, _, secret_suffix = secret_id.rpartition("/")[2].rpartition("-") + connection_snapshot.add_transformer( + snapshot.transform.regex(secret_uuid, ""), priority=-1 + ) + connection_snapshot.add_transformer( + snapshot.transform.regex(secret_suffix, ""), priority=-1 + ) + get_secret_response = aws_client.secretsmanager.get_secret_value(SecretId=secret_id) + connection_snapshot.match("connection-secret", get_secret_response) diff --git a/tests/aws/services/events/test_api_destinations_and_connection.snapshot.json b/tests/aws/services/events/test_api_destinations_and_connection.snapshot.json new file mode 100644 index 0000000000000..3a1216c94be8f --- /dev/null +++ b/tests/aws/services/events/test_api_destinations_and_connection.snapshot.json @@ -0,0 +1,798 @@ +{ + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection": { + "recorded-date": "09-12-2024, 10:16:11", + "recorded-content": { + "create-connection": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-connection": { + "AuthParameters": { + "ApiKeyAuthParameters": { + "ApiKeyName": "ApiKey" + }, + "InvocationHttpParameters": {} + }, + "AuthorizationType": "API_KEY", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params0]": { + "recorded-date": "09-12-2024, 10:16:12", + "recorded-content": { + "create-connection-auth": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-connection-auth": { + "AuthParameters": { + "BasicAuthParameters": { + "Username": "user" + } + }, + "AuthorizationType": "BASIC", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params1]": { + "recorded-date": "09-12-2024, 10:16:13", + "recorded-content": { + "create-connection-auth": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-connection-auth": { + "AuthParameters": { + "ApiKeyAuthParameters": { + "ApiKeyName": "ApiKey" + } + }, + "AuthorizationType": "API_KEY", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params2]": { + "recorded-date": "09-12-2024, 10:16:13", + "recorded-content": { + "create-connection-auth": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZING", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-connection-auth": { + "AuthParameters": { + "OAuthParameters": { + "AuthorizationEndpoint": "https://example.com/oauth", + "ClientParameters": { + "ClientID": "client_id" + }, + "HttpMethod": "POST" + } + }, + "AuthorizationType": "OAUTH_CLIENT_CREDENTIALS", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZING", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_list_connections": { + "recorded-date": "09-12-2024, 10:16:14", + "recorded-content": { + "list-connections": { + "Connections": [ + { + "AuthorizationType": "BASIC", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_delete_connection": { + "recorded-date": "09-12-2024, 10:16:19", + "recorded-content": { + "create-connection-response": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-connection": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "DELETING", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-deleted-connection": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Failed to describe the connection(s). Connection '' does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_invalid_parameters": { + "recorded-date": "09-12-2024, 10:16:20", + "recorded-content": { + "create-connection-invalid-auth-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'INVALID_AUTH_TYPE' at 'authorizationType' failed to satisfy constraint: Member must satisfy enum value set: [BASIC, OAUTH_CLIENT_CREDENTIALS, API_KEY]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_update_connection": { + "recorded-date": "09-12-2024, 10:16:22", + "recorded-content": { + "create-connection": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-created-connection": { + "AuthParameters": { + "BasicAuthParameters": { + "Username": "user" + }, + "InvocationHttpParameters": {} + }, + "AuthorizationType": "BASIC", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//-", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "connection-secret-before-update": { + "ARN": "arn::secretsmanager::111111111111:secret:events!connection//-", + "CreatedDate": "datetime", + "Name": "events!connection//", + "SecretString": { + "username": "user", + "password": "pass", + "invocation_http_parameters": {} + }, + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-connection": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-updated-connection": { + "AuthParameters": { + "BasicAuthParameters": { + "Username": "new_user" + }, + "InvocationHttpParameters": {} + }, + "AuthorizationType": "BASIC", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//-", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "connection-secret-after-update": { + "ARN": "arn::secretsmanager::111111111111:secret:events!connection//-", + "CreatedDate": "datetime", + "Name": "events!connection//", + "SecretString": { + "username": "new_user", + "password": "new_pass", + "invocation_http_parameters": {} + }, + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_name_validation": { + "recorded-date": "09-12-2024, 10:16:22", + "recorded-content": { + "create-connection-invalid-name-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'Invalid Name With Spaces!' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[basic]": { + "recorded-date": "09-12-2024, 10:16:24", + "recorded-content": { + "create-connection-auth": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-connection-auth": { + "AuthParameters": { + "BasicAuthParameters": { + "Username": "user" + } + }, + "AuthorizationType": "BASIC", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//-", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "connection-secret": { + "ARN": "arn::secretsmanager::111111111111:secret:events!connection//-", + "CreatedDate": "datetime", + "Name": "events!connection//", + "SecretString": { + "username": "user", + "password": "pass" + }, + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[api-key]": { + "recorded-date": "09-12-2024, 10:16:25", + "recorded-content": { + "create-connection-auth": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-connection-auth": { + "AuthParameters": { + "ApiKeyAuthParameters": { + "ApiKeyName": "ApiKey" + } + }, + "AuthorizationType": "API_KEY", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZED", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//-", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "connection-secret": { + "ARN": "arn::secretsmanager::111111111111:secret:events!connection//-", + "CreatedDate": "datetime", + "Name": "events!connection//", + "SecretString": { + "api_key_name": "ApiKey", + "api_key_value": "secret" + }, + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[oauth]": { + "recorded-date": "09-12-2024, 10:16:25", + "recorded-content": { + "create-connection-auth": { + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZING", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-connection-auth": { + "AuthParameters": { + "OAuthParameters": { + "AuthorizationEndpoint": "https://example.com/oauth", + "ClientParameters": { + "ClientID": "client_id" + }, + "HttpMethod": "POST" + } + }, + "AuthorizationType": "OAUTH_CLIENT_CREDENTIALS", + "ConnectionArn": "arn::events::111111111111:connection//", + "ConnectionState": "AUTHORIZING", + "CreationTime": "datetime", + "LastAuthorizedTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "SecretArn": "arn::secretsmanager::111111111111:secret:events!connection//-", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "connection-secret": { + "ARN": "arn::secretsmanager::111111111111:secret:events!connection//-", + "CreatedDate": "datetime", + "Name": "events!connection//", + "SecretString": { + "client_id": "client_id", + "client_secret": "client_secret", + "authorization_endpoint": "https://example.com/oauth", + "http_method": "POST" + }, + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth0]": { + "recorded-date": "09-12-2024, 10:21:06", + "recorded-content": { + "create-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "Description": "Test API destination", + "HttpMethod": "POST", + "InvocationEndpoint": "https://example.com/api", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-api-destinations": { + "ApiDestinations": [ + { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "HttpMethod": "POST", + "InvocationEndpoint": "https://example.com/api", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-updated-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "Description": "Updated API destination", + "HttpMethod": "PUT", + "InvocationEndpoint": "https://example.com/api/v2", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-api-destination": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-api-destination-not-found-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Failed to describe the api-destination(s). An api-destination '' does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth1]": { + "recorded-date": "09-12-2024, 10:21:08", + "recorded-content": { + "create-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "Description": "Test API destination", + "HttpMethod": "POST", + "InvocationEndpoint": "https://example.com/api", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-api-destinations": { + "ApiDestinations": [ + { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "HttpMethod": "POST", + "InvocationEndpoint": "https://example.com/api", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-updated-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "ACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "Description": "Updated API destination", + "HttpMethod": "PUT", + "InvocationEndpoint": "https://example.com/api/v2", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-api-destination": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-api-destination-not-found-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Failed to describe the api-destination(s). An api-destination '' does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth2]": { + "recorded-date": "09-12-2024, 10:21:10", + "recorded-content": { + "create-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "INACTIVE", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "INACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "Description": "Test API destination", + "HttpMethod": "POST", + "InvocationEndpoint": "https://example.com/api", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-api-destinations": { + "ApiDestinations": [ + { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "INACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "HttpMethod": "POST", + "InvocationEndpoint": "https://example.com/api", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "INACTIVE", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-updated-api-destination": { + "ApiDestinationArn": "api-destination-arn", + "ApiDestinationState": "INACTIVE", + "ConnectionArn": "connection-arn", + "CreationTime": "datetime", + "Description": "Updated API destination", + "HttpMethod": "PUT", + "InvocationEndpoint": "https://example.com/api/v2", + "InvocationRateLimitPerSecond": 300, + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-api-destination": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-api-destination-not-found-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Failed to describe the api-destination(s). An api-destination '' does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_create_api_destination_invalid_parameters": { + "recorded-date": "09-12-2024, 10:21:11", + "recorded-content": { + "create-api-destination-invalid-parameters-error": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value 'invalid-connection-arn' at 'connectionArn' failed to satisfy constraint: Member must satisfy regular expression pattern: ^arn:aws([a-z]|\\-)*:events:([a-z]|\\d|\\-)*:([0-9]{12})?:connection\\/[\\.\\-_A-Za-z0-9]+\\/[\\-A-Za-z0-9]+$; Value 'INVALID_METHOD' at 'httpMethod' failed to satisfy constraint: Member must satisfy enum value set: [HEAD, POST, PATCH, DELETE, PUT, GET, OPTIONS]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_create_api_destination_name_validation": { + "recorded-date": "09-12-2024, 10:21:12", + "recorded-content": { + "create-api-destination-invalid-name-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'Invalid Name With Spaces!' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/events/test_api_destinations_and_connection.validation.json b/tests/aws/services/events/test_api_destinations_and_connection.validation.json new file mode 100644 index 0000000000000..580cdf7853b68 --- /dev/null +++ b/tests/aws/services/events/test_api_destinations_and_connection.validation.json @@ -0,0 +1,53 @@ +{ + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth0]": { + "last_validated_date": "2024-12-09T10:21:06+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth1]": { + "last_validated_date": "2024-12-09T10:21:08+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_api_destinations[auth2]": { + "last_validated_date": "2024-12-09T10:21:10+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_create_api_destination_invalid_parameters": { + "last_validated_date": "2024-12-09T10:21:11+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeApiDestinations::test_create_api_destination_name_validation": { + "last_validated_date": "2024-12-09T10:21:12+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[api-key]": { + "last_validated_date": "2024-12-09T10:16:25+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[basic]": { + "last_validated_date": "2024-12-09T10:16:24+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_connection_secrets[oauth]": { + "last_validated_date": "2024-12-09T10:16:25+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection": { + "last_validated_date": "2024-12-09T10:16:11+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_invalid_parameters": { + "last_validated_date": "2024-12-09T10:16:20+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_name_validation": { + "last_validated_date": "2024-12-09T10:16:22+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params0]": { + "last_validated_date": "2024-12-09T10:16:12+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params1]": { + "last_validated_date": "2024-12-09T10:16:12+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_create_connection_with_auth[auth_params2]": { + "last_validated_date": "2024-12-09T10:16:13+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_delete_connection": { + "last_validated_date": "2024-12-09T10:16:19+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_list_connections": { + "last_validated_date": "2024-12-09T10:16:14+00:00" + }, + "tests/aws/services/events/test_api_destinations_and_connection.py::TestEventBridgeConnections::test_update_connection": { + "last_validated_date": "2024-12-09T10:16:22+00:00" + } +} diff --git a/tests/aws/services/events/test_archive_and_replay.py b/tests/aws/services/events/test_archive_and_replay.py new file mode 100644 index 0000000000000..18c18804c24f4 --- /dev/null +++ b/tests/aws/services/events/test_archive_and_replay.py @@ -0,0 +1,916 @@ +import json +from datetime import datetime, timedelta, timezone + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.events.helper_functions import ( + is_old_provider, + sqs_collect_messages, + wait_for_replay_in_state, +) +from tests.aws.services.events.test_events import ( + TEST_EVENT_DETAIL, + TEST_EVENT_PATTERN, + TEST_EVENT_PATTERN_NO_DETAIL, +) + + +class TestArchive: + @markers.aws.validated + @pytest.mark.parametrize("event_bus_type", ["default", "custom"]) + def test_create_list_describe_update_delete_archive( + self, + event_bus_type, + events_create_default_or_custom_event_bus, + events_create_archive, + aws_client, + snapshot, + ): + event_bus_name, event_bus_arn = events_create_default_or_custom_event_bus(event_bus_type) + + archive_name = f"test-archive-{short_uid()}" + response_create_archive = events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, # ARN of the source event bus + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + # TODO list rule created for archive + + snapshot.add_transformer( + [ + snapshot.transform.regex(event_bus_name, ""), + snapshot.transform.regex(archive_name, ""), + ] + ) + + snapshot.match("create-archive", response_create_archive) + + response_list_archives = aws_client.events.list_archives(NamePrefix=archive_name) + snapshot.match("list-archives", response_list_archives) + + response_describe_archive = aws_client.events.describe_archive(ArchiveName=archive_name) + snapshot.match("describe-archive", response_describe_archive) + + response_update_archive = aws_client.events.update_archive( + ArchiveName=archive_name, + Description="updated description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN_NO_DETAIL), + RetentionDays=2, + ) + snapshot.match("update-archive", response_update_archive) + + response_delete_archive = aws_client.events.delete_archive( + ArchiveName=archive_name + ) # TODO test delete archive with active replay + snapshot.match("delete-archive", response_delete_archive) + + @markers.aws.validated + @pytest.mark.parametrize("event_bus_type", ["default", "custom"]) + def test_list_archive_with_name_prefix( + self, + event_bus_type, + events_create_default_or_custom_event_bus, + events_create_archive, + aws_client, + snapshot, + ): + event_bus_name, event_bus_arn = events_create_default_or_custom_event_bus(event_bus_type) + + archive_name_prefix = "test-archive" + archive_name = f"{archive_name_prefix}-{short_uid()}" + events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + + response_list_archives_prefix = aws_client.events.list_archives( + NamePrefix=archive_name_prefix + ) + + snapshot.add_transformer( + [ + snapshot.transform.regex(event_bus_name, ""), + snapshot.transform.regex(archive_name, ""), + ] + ) + snapshot.match("list-archives-with-name-prefix", response_list_archives_prefix) + + response_list_archives_full_name = aws_client.events.list_archives(NamePrefix=archive_name) + snapshot.match("list-archives-with-full-name", response_list_archives_full_name) + + response_list_not_existing_archive = aws_client.events.list_archives( + NamePrefix="doesnotexist" + ) + snapshot.match("list-archives-not-existing-archive", response_list_not_existing_archive) + + @markers.aws.validated + @pytest.mark.parametrize("event_bus_type", ["default", "custom"]) + def test_list_archive_with_source_arn( + self, + event_bus_type, + events_create_default_or_custom_event_bus, + events_create_archive, + aws_client, + snapshot, + ): + event_bus_name, event_bus_arn = events_create_default_or_custom_event_bus(event_bus_type) + + archive_name = f"test-archive-{short_uid()}" + events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + + response_list_archives_source_arn = aws_client.events.list_archives( + EventSourceArn=event_bus_arn + ) + + snapshot.add_transformer( + [ + snapshot.transform.regex(event_bus_name, ""), + snapshot.transform.regex(archive_name, ""), + ] + ) + snapshot.match("list-archives-with-source-arn", response_list_archives_source_arn) + + @markers.aws.validated + @pytest.mark.parametrize("event_bus_type", ["default", "custom"]) + def test_list_archive_state_enabled( + self, + event_bus_type, + events_create_default_or_custom_event_bus, + events_create_archive, + aws_client, + snapshot, + ): + event_bus_name, event_bus_arn = events_create_default_or_custom_event_bus(event_bus_type) + + archive_name = f"test-archive-{short_uid()}" + events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + + response_list_archives = aws_client.events.list_archives(State="ENABLED") + snapshot.add_transformer( + [ + snapshot.transform.regex(event_bus_name, ""), + snapshot.transform.regex(archive_name, ""), + ] + ) + snapshot.match("list-archives-state-enabled", response_list_archives) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + # TODO test with input path and input transformer + @pytest.mark.parametrize("event_bus_type", ["default", "custom"]) + @pytest.mark.parametrize("archive_pattern_match", [True, False]) + @markers.snapshot.skip_snapshot_verify( + paths=["$..SizeBytes"] + ) # TODO currently not possible to accurately predict the size of the archive + def test_list_archive_with_events( + self, + event_bus_type, + archive_pattern_match, + events_create_default_or_custom_event_bus, + events_create_archive, + aws_client, + put_events_with_filter_to_sqs, + snapshot, + ): + event_bus_name, event_bus_arn = events_create_default_or_custom_event_bus(event_bus_type) + + archive_name = f"test-archive-{short_uid()}" + if archive_pattern_match: + events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + else: + events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + Description="description of the archive", + RetentionDays=1, + ) + + num_events = 10 + + entries = [] + for _ in range(num_events): + entry = { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + entries.append(entry) + + put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=[(entries, True)], + event_bus_name=event_bus_name, + ) + + def wait_for_archive_event_count(): + response = aws_client.events.describe_archive(ArchiveName=archive_name) + event_count = response["EventCount"] + assert event_count == num_events + + retry( + wait_for_archive_event_count, retries=35, sleep=10 + ) # events are batched and sent to the archive, this mostly takes at least 5 minutes on AWS + + snapshot.add_transformer( + [ + snapshot.transform.regex(event_bus_name, ""), + snapshot.transform.regex(archive_name, ""), + ] + ) + + response_list_archives = aws_client.events.list_archives(NamePrefix=archive_name) + snapshot.match("list-archives", response_list_archives) + + response_describe_archive = aws_client.events.describe_archive(ArchiveName=archive_name) + snapshot.match("describe-archive", response_describe_archive) + + # Tests Errors + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + @pytest.mark.parametrize("event_bus_type", ["default", "custom"]) + def test_create_archive_error_duplicate( + self, + event_bus_type, + events_create_default_or_custom_event_bus, + events_create_archive, + aws_client, + snapshot, + ): + _, event_bus_arn = events_create_default_or_custom_event_bus(event_bus_type) + + archive_name = f"test-archive-{short_uid()}" + events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + with pytest.raises(ClientError) as error: + aws_client.events.create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + + snapshot.add_transformer([snapshot.transform.regex(archive_name, "")]) + snapshot.match("create-archive-duplicate-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_create_archive_error_unknown_event_bus(self, aws_client, snapshot): + not_existing_event_bus_name = f"doesnotexist-{short_uid()}" + non_existing_event_bus_arn = ( + f"arn:aws:events:us-east-1:123456789012:event-bus/{not_existing_event_bus_name}" + ) + with pytest.raises(ClientError) as error: + aws_client.events.create_archive( + ArchiveName="test-archive", + EventSourceArn=non_existing_event_bus_arn, + Description="description of the archive", + EventPattern=json.dumps(TEST_EVENT_PATTERN), + RetentionDays=1, + ) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_event_bus_name, "")] + ) + snapshot.match("create-archive-unknown-event-bus-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_describe_archive_error_unknown_archive(self, aws_client, snapshot): + not_existing_archive_name = f"doesnotexist-{short_uid()}" + with pytest.raises(ClientError) as error: + aws_client.events.describe_archive(ArchiveName=not_existing_archive_name) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_archive_name, "")] + ) + snapshot.match("describe-archive-unknown-archive-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_list_archive_error_unknown_source_arn( + self, region_name, account_id, aws_client, snapshot + ): + not_existing_event_bus_name = f"doesnotexist-{short_uid()}" + non_existing_event_bus_arn = ( + f"arn:aws:events:{region_name}:{account_id}:event-bus/{not_existing_event_bus_name}" + ) + with pytest.raises(ClientError) as error: + aws_client.events.list_archives(EventSourceArn=non_existing_event_bus_arn) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_event_bus_name, "")] + ) + snapshot.match("list-archives-unknown-event-bus-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skip(reason="not possible to test with localstack") + def test_update_archive_error_unknown_archive(self, aws_client, snapshot): + not_existing_archive_name = f"doesnotexist-{short_uid()}" + with pytest.raises(ClientError) as error: + aws_client.events.update_archive(ArchiveName=not_existing_archive_name) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_archive_name, "")] + ) + snapshot.match("update-archive-unknown-archive-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_delete_archive_error_unknown_archive(self, aws_client, snapshot): + not_existing_archive_name = f"doesnotexist-{short_uid()}" + with pytest.raises(ClientError) as error: + aws_client.events.delete_archive(ArchiveName=not_existing_archive_name) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_archive_name, "")] + ) + snapshot.match("delete-archive-unknown-archive-error", error.value.response) + + +class TestReplay: + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + # TODO: Investigate and fix type error + @pytest.mark.skip(reason="Fails with `TypeError: str.replace() takes no keyword arguments`") + @pytest.mark.parametrize("event_bus_type", ["default", "custom"]) + @pytest.mark.skip_snapshot_verify(paths=["$..State"]) + def test_start_list_describe_canceled_replay( + self, + event_bus_type, + events_create_default_or_custom_event_bus, + events_put_rule, + sqs_as_events_target, + put_event_to_archive, + aws_client, + snapshot, + ): + event_start_time = datetime.now(timezone.utc) + event_end_time = event_start_time + timedelta(minutes=1) + + # setup event bus + event_bus_name, event_bus_arn = events_create_default_or_custom_event_bus(event_bus_type) + + # setup rule + rule_name = f"test-rule-{short_uid()}" + response = events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + rule_arn = response["RuleArn"] + + # setup sqs target + queue_url, queue_arn = sqs_as_events_target() + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn}, + ], + ) + + # put events to archive + num_events = 3 + entries = [] + for _ in range(num_events): + entry = { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + "EventBusName": event_bus_name, + } + entries.append(entry) + + archive_name = f"test-archive-{short_uid()}" + archive_arn = put_event_to_archive( + archive_name, + TEST_EVENT_PATTERN, + event_bus_name, + event_bus_arn, + entries, + )["ArchiveArn"] + sqs_collect_messages( + aws_client, queue_url, expected_events_count=num_events, wait_time=5, retries=12 + ) # reset queue for replay + + # start replay + replay_name = f"test-replay-{short_uid()}" + response_start_replay = aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=event_start_time, + EventEndTime=event_end_time, + Destination={ + "Arn": event_bus_arn, + "FilterArns": [ + rule_arn, + ], + }, + ) + + snapshot.add_transformer( + [ + snapshot.transform.regex(event_bus_name, ""), + snapshot.transform.regex(rule_name, ""), + snapshot.transform.regex(archive_name, ""), + snapshot.transform.regex(replay_name, ""), + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + snapshot.transform.key_value("ReplayName", reference_replacement=False), + ] + ) + snapshot.match("start-replay", response_start_replay) + + # replaying an archive mostly takes at least 5 minutes on AWS + wait_for_replay_in_state(aws_client, replay_name, "COMPLETED", retries=35, sleep=10) + + # fetch messages from sqs + messages_replay = sqs_collect_messages( + aws_client, queue_url, num_events, wait_time=5, retries=12 + ) + + snapshot.match("replay-messages", messages_replay) + + response_list_replays = aws_client.events.list_replays(NamePrefix=replay_name) + snapshot.match("list-replays", response_list_replays) + + response_describe_replay = aws_client.events.describe_replay(ReplayName=replay_name) + snapshot.match("describe-replay", response_describe_replay) + + replay_canceled_name = f"test-replay-canceled-{short_uid()}" + aws_client.events.start_replay( + ReplayName=replay_canceled_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=event_start_time, + EventEndTime=event_end_time, + Destination={ + "Arn": event_bus_arn, + "FilterArns": [ + rule_arn, + ], + }, + ) + + response_cancel_replay = aws_client.events.cancel_replay(ReplayName=replay_canceled_name) + snapshot.add_transformer( + snapshot.transform.regex(replay_canceled_name, "") + ) + snapshot.match("cancel-replay", response_cancel_replay) + + response_describe_replay_canceled = aws_client.events.describe_replay( + ReplayName=replay_canceled_name + ) + snapshot.match("describe-replay-canceled", response_describe_replay_canceled) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_list_replays_with_prefix( + self, events_create_archive, events_create_event_bus, aws_client, snapshot + ): + event_start_time = datetime.now(timezone.utc) + event_end_time = event_start_time + timedelta(minutes=1) + + event_bus_name = f"test-bus-{short_uid()}" + event_bus_arn = events_create_event_bus(Name=event_bus_name)["EventBusArn"] + archive_arn = events_create_archive( + EventSourceArn=event_bus_arn, + RetentionDays=1, + )["ArchiveArn"] + + replay_name_prefix = ( + short_uid() # prefix must be unique since replays are stored 90 days + ) + replay_name = f"{replay_name_prefix}-test-replay" + aws_client.events.start_replay( + ReplayName=replay_name, + EventSourceArn=archive_arn, + EventStartTime=event_start_time, + EventEndTime=event_end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + wait_for_replay_in_state(aws_client, replay_name, "COMPLETED", retries=35, sleep=10) + + replay_name_second = f"{short_uid()}-this-replay-should-not-be-listed" + aws_client.events.start_replay( + ReplayName=replay_name_second, + EventSourceArn=archive_arn, + EventStartTime=event_start_time, + EventEndTime=event_end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + response_list_replays_full_name = aws_client.events.list_replays(NamePrefix=replay_name) + + snapshot.add_transformer( + [ + snapshot.transform.regex(replay_name_prefix, ""), + snapshot.transform.regex(archive_arn, ""), + ] + ) + snapshot.match("list-replays-with-full-name", response_list_replays_full_name) + + response_list_replays_prefix = aws_client.events.list_replays(NamePrefix=replay_name_prefix) + snapshot.match("list-replays-with-prefix", response_list_replays_prefix) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_list_replays_with_event_source_arn( + self, events_create_event_bus, events_create_archive, aws_client, snapshot + ): + event_start_time = datetime.now(timezone.utc) + event_end_time = event_start_time + timedelta(minutes=1) + + event_bus_name = f"test-bus-{short_uid()}" + event_bus_arn = events_create_event_bus(Name=event_bus_name)["EventBusArn"] + archive_arn = events_create_archive( + EventSourceArn=event_bus_arn, + RetentionDays=1, + )["ArchiveArn"] + + replay_name = f"test-replay-{short_uid()}" + aws_client.events.start_replay( + ReplayName=replay_name, + EventSourceArn=archive_arn, + EventStartTime=event_start_time, + EventEndTime=event_end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + wait_for_replay_in_state(aws_client, replay_name, "COMPLETED", retries=35, sleep=10) + + response_list_replays = aws_client.events.list_replays(EventSourceArn=archive_arn) + + snapshot.add_transformer( + [ + snapshot.transform.regex(replay_name, ""), + snapshot.transform.regex(archive_arn, ""), + ] + ) + snapshot.match("list-replays-with-event-source-arn", response_list_replays) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_list_replay_with_limit( + self, events_create_event_bus, events_create_archive, aws_client, snapshot + ): + event_start_time = datetime.now(timezone.utc) + event_end_time = event_start_time + timedelta(minutes=1) + + event_bus_name = f"test-bus-{short_uid()}" + event_bus_arn = events_create_event_bus(Name=event_bus_name)["EventBusArn"] + + archive_name = f"test-archive-{short_uid()}" + archive_arn = events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + RetentionDays=1, + )["ArchiveArn"] + + replay_name_prefix = short_uid() + + num_replays = 6 + for i in range(num_replays): + replay_name = f"{replay_name_prefix}-test-replay-{i}" + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=event_start_time, + EventEndTime=event_end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + wait_for_replay_in_state(aws_client, replay_name, "COMPLETED", retries=35, sleep=10) + + response = aws_client.events.list_replays( + Limit=int(num_replays / 2), NamePrefix=replay_name_prefix + ) + snapshot.add_transformer( + [ + snapshot.transform.regex(replay_name_prefix, ""), + snapshot.transform.regex(archive_name, ""), + snapshot.transform.jsonpath("$..NextToken", "next_token"), + ] + ) + snapshot.match("list-replays-with-limit", response) + + response = aws_client.events.list_replays( + Limit=int(num_replays / 2) + 2, + NextToken=response["NextToken"], + NamePrefix=replay_name_prefix, + ) + snapshot.match("list-replays-with-limit-next-token", response) + + # Tests Errors + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_start_replay_error_unknown_event_bus( + self, + events_create_archive, + region_name, + account_id, + events_create_event_bus, + aws_client, + snapshot, + ): + archive_arn = events_create_archive( + RetentionDays=1, + )["ArchiveArn"] + + not_existing_event_bus_name = f"doesnotexist-{short_uid()}" + not_existing_event_bus_arn = ( + f"arn:aws:events:{region_name}:{account_id}:event-bus/{not_existing_event_bus_name}" + ) + + start_time = datetime.now(timezone.utc) - timedelta(minutes=1) + end_time = datetime.now(timezone.utc) + + replay_name = f"test-replay-{short_uid()}" + with pytest.raises(ClientError) as error: + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": not_existing_event_bus_arn, + }, # the destination must be the exact same event bus the archive is created for + ) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_event_bus_name, "")] + ) + snapshot.match("start-replay-unknown-event-bus-error", error.value.response) + + event_bus_arn = events_create_event_bus(Name=not_existing_event_bus_name)["EventBusArn"] + + with pytest.raises(ClientError) as error: + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn, + }, # the destination must be the exact same event bus the archive is created for + ) + + snapshot.match("start-replay-wrong-event-bus-error", error.value.response) + + @markers.aws.validated + def test_start_replay_error_unknown_archive( + self, aws_client, region_name, account_id, snapshot + ): + not_existing_archive_name = f"doesnotexist-{short_uid()}" + start_time = datetime.now(timezone.utc) - timedelta(minutes=1) + end_time = datetime.now(timezone.utc) + with pytest.raises(ClientError) as error: + aws_client.events.start_replay( + ReplayName="test-replay", + Description="description of the replay", + EventSourceArn=f"arn:aws:events:{region_name}:{account_id}:archive/{not_existing_archive_name}", + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": f"arn:aws:events:{region_name}:{account_id}:event-bus/default", + }, + ) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_archive_name, "")] + ) + snapshot.match("start-replay-unknown-archive-error", error.value.response) + + @markers.aws.validated + def test_start_replay_error_duplicate_name_same_archive( + self, events_create_archive, aws_client, snapshot + ): + event_bus_name = f"test-bus-{short_uid()}" + event_bus_arn = aws_client.events.create_event_bus(Name=event_bus_name)["EventBusArn"] + + archive_arn = events_create_archive(EventSourceArn=event_bus_arn, RetentionDays=1)[ + "ArchiveArn" + ] + + replay_name = f"test-replay-{short_uid()}" + start_time = datetime.now(timezone.utc) - timedelta(minutes=1) + end_time = datetime.now(timezone.utc) + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + with pytest.raises(ClientError) as error: + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + snapshot.add_transformer([snapshot.transform.regex(replay_name, "")]) + snapshot.match("start-replay-duplicate-error", error.value.response) + + @markers.aws.validated + def test_start_replay_error_duplicate_different_archive( + self, events_create_archive, aws_client, snapshot + ): + event_bus_name_one = f"test-bus-{short_uid()}" + event_bus_arn_one = aws_client.events.create_event_bus(Name=event_bus_name_one)[ + "EventBusArn" + ] + archive_arn_one = events_create_archive(EventSourceArn=event_bus_arn_one, RetentionDays=1)[ + "ArchiveArn" + ] + event_bus_name_two = f"test-bus-{short_uid()}" + event_bus_arn_two = aws_client.events.create_event_bus(Name=event_bus_name_two)[ + "EventBusArn" + ] + archive_arn_two = events_create_archive(EventSourceArn=event_bus_arn_two, RetentionDays=1)[ + "ArchiveArn" + ] + + start_time = datetime.now(timezone.utc) - timedelta(minutes=1) + end_time = datetime.now(timezone.utc) + + replay_name = f"test-replay-{short_uid()}" + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn_one, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn_one, + }, + ) + + with pytest.raises(ClientError) as error: + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn_two, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn_two, + }, + ) + + snapshot.add_transformer([snapshot.transform.regex(replay_name, "")]) + snapshot.match("start-replay-duplicate-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + @pytest.mark.parametrize("negative_time_delta_seconds", [0, 10]) + def test_start_replay_error_invalid_end_time( + self, negative_time_delta_seconds, events_create_archive, aws_client, snapshot + ): + event_bus_name = f"test-bus-{short_uid()}" + event_bus_arn = aws_client.events.create_event_bus(Name=event_bus_name)["EventBusArn"] + + response = events_create_archive() + archive_arn = response["ArchiveArn"] + + start_time = datetime.now(timezone.utc) + end_time = start_time.replace(microsecond=0) - timedelta( + seconds=negative_time_delta_seconds + ) + + replay_name = f"test-replay-{short_uid()}" + with pytest.raises(ClientError) as error: + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + snapshot.match("start-replay-invalid-end-time-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skip(reason="currently no concurrency for replays in localstack") + def tests_concurrency_error_too_many_active_replays( + self, events_create_event_bus, events_create_archive, aws_client, snapshot + ): + event_bus_name = f"test-bus-{short_uid()}" + event_bus_arn = events_create_event_bus(Name=event_bus_name)["EventBusArn"] + + archive_name = f"test-archive-{short_uid()}" + archive_arn = events_create_archive( + ArchiveName=archive_name, + EventSourceArn=event_bus_arn, + RetentionDays=1, + )["ArchiveArn"] + + replay_name_prefix = short_uid() + start_time = datetime.now(timezone.utc) - timedelta(minutes=1) + end_time = datetime.now(timezone.utc) + + num_replays = 10 + for i in range(num_replays): + replay_name = f"{replay_name_prefix}-test-replay-{i}" + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + # only 10 replays are allowed to be in state STARTING or RUNNING at the same time + with pytest.raises(ClientError) as error: + replay_name = f"{replay_name_prefix}-test-replay-{num_replays}" + aws_client.events.start_replay( + ReplayName=replay_name, + Description="description of the replay", + EventSourceArn=archive_arn, + EventStartTime=start_time, + EventEndTime=end_time, + Destination={ + "Arn": event_bus_arn, + }, + ) + + snapshot.add_transformer( + [ + snapshot.transform.regex(replay_name_prefix, ""), + snapshot.transform.regex(archive_name, ""), + snapshot.transform.jsonpath("$..NextToken", "next_token"), + ] + ) + snapshot.match("list-replays-with-limit", error.value.response) + + @markers.aws.validated + def test_describe_replay_error_unknown_replay(self, aws_client, snapshot): + not_existing_replay_name = f"doesnotexist-{short_uid()}" + with pytest.raises(ClientError) as error: + aws_client.events.describe_replay(ReplayName=not_existing_replay_name) + + snapshot.add_transformer( + [snapshot.transform.regex(not_existing_replay_name, "")] + ) + snapshot.match("describe-replay-unknown-replay-error", error.value.response) diff --git a/tests/aws/services/events/test_archive_and_replay.snapshot.json b/tests/aws/services/events/test_archive_and_replay.snapshot.json new file mode 100644 index 0000000000000..23c024303a6a4 --- /dev/null +++ b/tests/aws/services/events/test_archive_and_replay.snapshot.json @@ -0,0 +1,1194 @@ +{ + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_list_describe_update_delete_archive[default]": { + "recorded-date": "17-05-2024, 15:15:31", + "recorded-content": { + "create-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "CreationTime": "datetime", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-archives": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "ArchiveName": "", + "CreationTime": "datetime", + "Description": "description of the archive", + "EventCount": 0, + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "CreationTime": "datetime", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-archive": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_list_describe_update_delete_archive[custom]": { + "recorded-date": "17-05-2024, 15:15:32", + "recorded-content": { + "create-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "CreationTime": "datetime", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-archives": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "ArchiveName": "", + "CreationTime": "datetime", + "Description": "description of the archive", + "EventCount": 0, + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "CreationTime": "datetime", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-archive": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_unknown_event_bus": { + "recorded-date": "12-03-2025, 10:17:26", + "recorded-content": { + "create-archive-unknown-event-bus-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_duplicate[default]": { + "recorded-date": "12-03-2025, 10:15:19", + "recorded-content": { + "create-archive-duplicate-error": { + "Error": { + "Code": "ResourceAlreadyExistsException", + "Message": "Archive already exists." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_duplicate[custom]": { + "recorded-date": "12-03-2025, 10:15:21", + "recorded-content": { + "create-archive-duplicate-error": { + "Error": { + "Code": "ResourceAlreadyExistsException", + "Message": "Archive already exists." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_describe_archive_error_unknown_archive": { + "recorded-date": "12-03-2025, 10:17:34", + "recorded-content": { + "describe-archive-unknown-archive-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Archive does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_name_prefix[default]": { + "recorded-date": "17-05-2024, 16:28:03", + "recorded-content": { + "list-archives-with-name-prefix": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-archives-with-full-name": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-archives-not-existing-archive": { + "Archives": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_name_prefix[custom]": { + "recorded-date": "17-05-2024, 16:28:04", + "recorded-content": { + "list-archives-with-name-prefix": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-archives-with-full-name": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-archives-not-existing-archive": { + "Archives": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_source_arn[default]": { + "recorded-date": "17-05-2024, 16:32:51", + "recorded-content": { + "list-archives-with-source-arn": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_source_arn[custom]": { + "recorded-date": "17-05-2024, 16:32:52", + "recorded-content": { + "list-archives-with-source-arn": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_error_unknown_source_arn": { + "recorded-date": "12-03-2025, 10:17:42", + "recorded-content": { + "list-archives-unknown-event-bus-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_update_archive_error_unknown_archive": { + "recorded-date": "11-03-2025, 19:49:00", + "recorded-content": { + "update-archive-unknown-archive-error": { + "Error": { + "Code": "ValidationException", + "Message": "At least one of EventPattern, RetentionDays or Description must be provided." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_delete_archive_error_unknown_archive": { + "recorded-date": "12-03-2025, 10:17:49", + "recorded-content": { + "delete-archive-unknown-archive-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Archive does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_state_enabled[default]": { + "recorded-date": "17-05-2024, 16:51:14", + "recorded-content": { + "list-archives-state-enabled": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_state_enabled[custom]": { + "recorded-date": "17-05-2024, 16:51:14", + "recorded-content": { + "list-archives-state-enabled": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 0, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 0, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[True-default]": { + "recorded-date": "21-05-2024, 13:15:50", + "recorded-content": { + "list-archives": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 10, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 1590, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "ArchiveName": "", + "CreationTime": "datetime", + "Description": "description of the archive", + "EventCount": 10, + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 1590, + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[True-custom]": { + "recorded-date": "21-05-2024, 13:18:32", + "recorded-content": { + "list-archives": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 10, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 1590, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "ArchiveName": "", + "CreationTime": "datetime", + "Description": "description of the archive", + "EventCount": 10, + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 1590, + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[False-default]": { + "recorded-date": "21-05-2024, 13:24:58", + "recorded-content": { + "list-archives": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 10, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 10959, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "ArchiveName": "", + "CreationTime": "datetime", + "Description": "description of the archive", + "EventCount": 10, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 10959, + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[False-custom]": { + "recorded-date": "21-05-2024, 13:29:14", + "recorded-content": { + "list-archives": { + "Archives": [ + { + "ArchiveName": "", + "CreationTime": "datetime", + "EventCount": 10, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 1590, + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-archive": { + "ArchiveArn": "arn::events::111111111111:archive/", + "ArchiveName": "", + "CreationTime": "datetime", + "Description": "description of the archive", + "EventCount": 10, + "EventSourceArn": "arn::events::111111111111:event-bus/", + "RetentionDays": 1, + "SizeBytes": 1590, + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_list_describe_canceled_replay[default]": { + "recorded-date": "27-05-2024, 13:17:56", + "recorded-content": { + "start-replay": { + "ReplayArn": "arn::events::111111111111:replay/", + "ReplayStartTime": "datetime", + "State": "STARTING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "replay-messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "replay-name": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "replay-name": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "replay-name": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "list-replays": { + "Replays": [ + { + "EventEndTime": "datetime", + "EventLastReplayedTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "replay-name", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-replay": { + "Description": "description of the replay", + "Destination": { + "Arn": "arn::events::111111111111:event-bus/", + "FilterArns": [ + "arn::events::111111111111:rule/" + ] + }, + "EventEndTime": "datetime", + "EventLastReplayedTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayArn": "arn::events::111111111111:replay/", + "ReplayEndTime": "datetime", + "ReplayName": "replay-name", + "ReplayStartTime": "datetime", + "State": "COMPLETED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel-replay": { + "ReplayArn": "arn::events::111111111111:replay/", + "State": "CANCELLING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-replay-canceled": { + "Description": "description of the replay", + "Destination": { + "Arn": "arn::events::111111111111:event-bus/", + "FilterArns": [ + "arn::events::111111111111:rule/" + ] + }, + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayArn": "arn::events::111111111111:replay/", + "ReplayName": "replay-name", + "ReplayStartTime": "datetime", + "State": "CANCELLING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_list_describe_canceled_replay[custom]": { + "recorded-date": "27-05-2024, 13:22:58", + "recorded-content": { + "start-replay": { + "ReplayArn": "arn::events::111111111111:replay/", + "ReplayStartTime": "datetime", + "State": "STARTING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "replay-messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "replay-name": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "replay-name": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "replay-name": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "list-replays": { + "Replays": [ + { + "EventEndTime": "datetime", + "EventLastReplayedTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "replay-name", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-replay": { + "Description": "description of the replay", + "Destination": { + "Arn": "arn::events::111111111111:event-bus/", + "FilterArns": [ + "arn::events::111111111111:rule//" + ] + }, + "EventEndTime": "datetime", + "EventLastReplayedTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayArn": "arn::events::111111111111:replay/", + "ReplayEndTime": "datetime", + "ReplayName": "replay-name", + "ReplayStartTime": "datetime", + "State": "COMPLETED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel-replay": { + "ReplayArn": "arn::events::111111111111:replay/", + "State": "CANCELLING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-replay-canceled": { + "Description": "description of the replay", + "Destination": { + "Arn": "arn::events::111111111111:event-bus/", + "FilterArns": [ + "arn::events::111111111111:rule//" + ] + }, + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayArn": "arn::events::111111111111:replay/", + "ReplayName": "replay-name", + "ReplayStartTime": "datetime", + "State": "CANCELLING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_event_bus[missing]": { + "recorded-date": "22-05-2024, 11:58:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_event_bus[cross_account]": { + "recorded-date": "22-05-2024, 11:49:30", + "recorded-content": {} + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_event_bus[cross_region]": { + "recorded-date": "22-05-2024, 11:49:31", + "recorded-content": {} + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_event_bus": { + "recorded-date": "12-03-2025, 10:18:09", + "recorded-content": { + "start-replay-unknown-event-bus-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "start-replay-wrong-event-bus-error": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter Destination.Arn is not valid. Reason: Cross event bus replay is not permitted." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replays_with_prefix": { + "recorded-date": "22-05-2024, 13:15:37", + "recorded-content": { + "list-replays-with-full-name": { + "Replays": [ + { + "EventEndTime": "datetime", + "EventSourceArn": "", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-replays-with-prefix": { + "Replays": [ + { + "EventEndTime": "datetime", + "EventSourceArn": "", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replay_with_limit": { + "recorded-date": "22-05-2024, 13:43:13", + "recorded-content": { + "list-replays-with-limit": { + "NextToken": "", + "Replays": [ + { + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay-0", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + }, + { + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay-1", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + }, + { + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay-2", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-replays-with-limit-next-token": { + "Replays": [ + { + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay-3", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + }, + { + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay-4", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + }, + { + "EventEndTime": "datetime", + "EventSourceArn": "arn::events::111111111111:archive/", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "-test-replay-5", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_archive": { + "recorded-date": "12-03-2025, 10:18:18", + "recorded-content": { + "start-replay-unknown-archive-error": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter EventSourceArn is not valid. Reason: Archive does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_duplicate_name_same_archive": { + "recorded-date": "12-03-2025, 10:18:30", + "recorded-content": { + "start-replay-duplicate-error": { + "Error": { + "Code": "ResourceAlreadyExistsException", + "Message": "Replay already exists." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_invalid_end_time[0]": { + "recorded-date": "12-03-2025, 10:17:58", + "recorded-content": { + "start-replay-invalid-end-time-error": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter EventEndTime is not valid. Reason: EventStartTime must be before EventEndTime." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_invalid_end_time[10]": { + "recorded-date": "12-03-2025, 10:17:59", + "recorded-content": { + "start-replay-invalid-end-time-error": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter EventEndTime is not valid. Reason: EventStartTime must be before EventEndTime." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_describe_replay_error_unknown_replay": { + "recorded-date": "12-03-2025, 10:18:50", + "recorded-content": { + "describe-replay-unknown-replay-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Replay does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::tests_concurrency_error_too_many_active_replays": { + "recorded-date": "11-03-2025, 19:55:54", + "recorded-content": { + "list-replays-with-limit": { + "Error": { + "Code": "LimitExceededException", + "Message": "The requested resource exceeds the maximum number allowed." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replays_with_event_source_arn": { + "recorded-date": "10-06-2024, 14:31:54", + "recorded-content": { + "list-replays-with-event-source-arn": { + "Replays": [ + { + "EventEndTime": "datetime", + "EventSourceArn": "", + "EventStartTime": "datetime", + "ReplayEndTime": "datetime", + "ReplayName": "", + "ReplayStartTime": "datetime", + "State": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_duplicate_different_archive": { + "recorded-date": "12-03-2025, 10:18:41", + "recorded-content": { + "start-replay-duplicate-error": { + "Error": { + "Code": "ResourceAlreadyExistsException", + "Message": "Replay already exists." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/events/test_archive_and_replay.validation.json b/tests/aws/services/events/test_archive_and_replay.validation.json new file mode 100644 index 0000000000000..bb5dc8f7a0da6 --- /dev/null +++ b/tests/aws/services/events/test_archive_and_replay.validation.json @@ -0,0 +1,104 @@ +{ + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_duplicate[custom]": { + "last_validated_date": "2025-03-12T10:15:21+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_duplicate[default]": { + "last_validated_date": "2025-03-12T10:15:19+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_archive_error_unknown_event_bus": { + "last_validated_date": "2025-03-12T10:17:26+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_list_describe_update_delete_archive[custom]": { + "last_validated_date": "2024-05-17T15:15:32+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_create_list_describe_update_delete_archive[default]": { + "last_validated_date": "2024-05-17T15:15:31+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_delete_archive_error_unknown_archive": { + "last_validated_date": "2025-03-12T10:17:49+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_describe_archive_error_unknown_archive": { + "last_validated_date": "2025-03-12T10:17:34+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_error_unknown_source_arn": { + "last_validated_date": "2025-03-12T10:17:42+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_state_enabled[custom]": { + "last_validated_date": "2024-05-17T16:51:14+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_state_enabled[default]": { + "last_validated_date": "2024-05-17T16:51:14+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[False-custom]": { + "last_validated_date": "2024-05-21T13:29:14+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[True-custom]": { + "last_validated_date": "2024-05-21T13:18:32+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[True-default]": { + "last_validated_date": "2024-05-21T13:15:50+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[custom]": { + "last_validated_date": "2024-05-21T12:50:29+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_events[default]": { + "last_validated_date": "2024-05-21T12:50:27+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_name_prefix": { + "last_validated_date": "2024-05-17T16:23:31+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_name_prefix[custom]": { + "last_validated_date": "2024-05-17T16:28:04+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_name_prefix[default]": { + "last_validated_date": "2024-05-17T16:28:03+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_source_arn[custom]": { + "last_validated_date": "2024-05-17T16:32:52+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_list_archive_with_source_arn[default]": { + "last_validated_date": "2024-05-17T16:32:51+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestArchive::test_update_archive_error_unknown_archive": { + "last_validated_date": "2025-03-11T19:49:00+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_describe_replay_error_unknown_replay": { + "last_validated_date": "2025-03-12T10:18:50+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replay_with_limit": { + "last_validated_date": "2024-05-22T13:43:13+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replays_with_event_source_arn": { + "last_validated_date": "2024-06-10T14:31:54+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_list_replays_with_prefix": { + "last_validated_date": "2024-05-22T13:15:37+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_list_describe_canceled_replay[custom]": { + "last_validated_date": "2024-05-27T13:22:58+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_list_describe_canceled_replay[default]": { + "last_validated_date": "2024-05-27T13:17:56+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_duplicate_different_archive": { + "last_validated_date": "2025-03-12T10:18:41+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_duplicate_name_same_archive": { + "last_validated_date": "2025-03-12T10:18:30+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_invalid_end_time[0]": { + "last_validated_date": "2025-03-12T10:17:58+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_invalid_end_time[10]": { + "last_validated_date": "2025-03-12T10:17:59+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_archive": { + "last_validated_date": "2025-03-12T10:18:18+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::test_start_replay_error_unknown_event_bus": { + "last_validated_date": "2025-03-12T10:18:09+00:00" + }, + "tests/aws/services/events/test_archive_and_replay.py::TestReplay::tests_concurrency_error_too_many_active_replays": { + "last_validated_date": "2025-03-11T19:55:54+00:00" + } +} diff --git a/tests/aws/services/events/test_events.py b/tests/aws/services/events/test_events.py new file mode 100644 index 0000000000000..cb748eb832c1c --- /dev/null +++ b/tests/aws/services/events/test_events.py @@ -0,0 +1,2351 @@ +"""General EventBridge and EventBridgeBus tests. +Test creating and modifying event buses, as well as putting events to custom and the default bus. +""" + +import datetime +import json +import os +import re +import time +import uuid + +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.services.events.v1.provider import _get_events_tmp_dir +from localstack.testing.aws.eventbus_utils import allow_event_rule_to_sqs_queue +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import load_file +from localstack.utils.strings import long_uid, short_uid +from localstack.utils.sync import retry +from localstack.utils.testutil import check_expected_lambda_log_events_length +from tests.aws.services.events.helper_functions import ( + assert_valid_event, + is_old_provider, + is_v2_provider, + sqs_collect_messages, +) +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_PYTHON_ECHO, +) + +EVENT_DETAIL = {"command": "update-account", "payload": {"acc_id": "0a787ecb-4015", "sf_id": "baz"}} + +SPECIAL_EVENT_DETAIL = { + "command": "update-account", + "payload": {"acc_id": "0a787ecb-4015", "sf_id": "baz"}, + "listsingle": ["HIGH"], + "listmulti": ["ACTIVE", "INACTIVE"], +} + +TEST_EVENT_DETAIL = { + "command": "update-account", + "payload": {"acc_id": "0a787ecb-4015", "sf_id": "baz"}, +} + +TEST_EVENT_PATTERN = { + "source": ["core.update-account-command"], + "detail-type": ["core.update-account-command"], + "detail": {"command": ["update-account"]}, +} + +TEST_EVENT_PATTERN_NO_DETAIL = { + "source": ["core.update-account-command"], + "detail-type": ["core.update-account-command"], +} + +TEST_EVENT_PATTERN_NO_SOURCE = { + "detail-type": ["core.update-account-command"], + "detail": {"command": ["update-account"]}, +} + + +EVENT_BUS_ROLE = { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } +} + + +class TestEvents: + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_events_without_source(self, snapshot, aws_client): + entries = [ + { + "DetailType": TEST_EVENT_PATTERN_NO_SOURCE["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + }, + ] + response = aws_client.events.put_events(Entries=entries) + snapshot.match("put-events", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_event_without_detail(self, snapshot, aws_client): + entries = [ + { + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + }, + ] + response = aws_client.events.put_events(Entries=entries) + snapshot.match("put-events", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_event_with_too_big_detail(self, snapshot, aws_client): + entries = [ + { + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps({"payload": ["p" * (256 * 1024 - 17)]}), + }, + ] + + with pytest.raises(ClientError) as e: + aws_client.events.put_events(Entries=entries) + snapshot.match("put-events-too-big-detail-error", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_event_without_detail_type(self, snapshot, aws_client): + entries = [ + { + "Source": "some.source", + "Detail": json.dumps(TEST_EVENT_DETAIL), + "DetailType": "", + }, + ] + response = aws_client.events.put_events(Entries=entries) + snapshot.match("put-events", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize( + "detail", + ["NotJSON", "[]", "{{}", json.dumps("NotJSON")], + ids=["STRING", "ARRAY", "MALFORMED_JSON", "SERIALIZED_STRING"], + ) + def test_put_event_malformed_detail(self, snapshot, aws_client, detail): + entries = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": detail, + }, + ] + response = aws_client.events.put_events(Entries=entries) + snapshot.match("put-events", response) + + @markers.aws.validated + def test_put_events_time(self, put_events_with_filter_to_sqs, snapshot): + entries1 = [ + { + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps({"message": "short time"}), + "Time": "2022-01-01", + }, + ] + entries2 = [ + { + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps({"message": "new time"}), + "Time": "01-01-2022T00:00:00Z", + }, + ] + entries3 = [ + { + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps({"message": "long time"}), + "Time": "2022-01-01 00:00:00Z", + }, + ] + entries_asserts = [(entries1, True), (entries2, True), (entries3, True)] + messages = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN_NO_DETAIL, + entries_asserts=entries_asserts, + ) + + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("messages", messages) + + # check for correct time strings in the messages + for message in messages: + message_body = json.loads(message["Body"]) + assert message_body["time"] == "2022-01-01T00:00:00Z" + + @markers.aws.validated + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_events_exceed_limit_ten_entries( + self, bus_name, events_create_event_bus, aws_client, snapshot + ): + if bus_name == "custom": + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + entries = [] + for i in range(11): + entries.append( + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + "EventBusName": bus_name, + } + ) + with pytest.raises(ClientError) as e: + aws_client.events.put_events(Entries=entries) + + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + snapshot.match("put-events-exceed-limit-error", e.value.response) + + @markers.aws.only_localstack + # tests for legacy v1 provider delete once v1 provider is removed + @pytest.mark.skipif( + is_v2_provider(), reason="Whitebox test for v1 provider only, completely irrelevant for v2" + ) + def test_events_written_to_disk_are_timestamp_prefixed_for_chronological_ordering( + self, aws_client + ): + event_type = str(uuid.uuid4()) + event_details_to_publish = [f"event {n}" for n in range(10)] + + for detail in event_details_to_publish: + aws_client.events.put_events( + Entries=[ + { + "Source": "unittest", + "Resources": [], + "DetailType": event_type, + "Detail": json.dumps(detail), + } + ] + ) + + events_tmp_dir = _get_events_tmp_dir() + sorted_events_written_to_disk = ( + json.loads(str(load_file(os.path.join(events_tmp_dir, filename)))) + for filename in sorted(os.listdir(events_tmp_dir)) + ) + sorted_events = list( + filter( + lambda event: event.get("DetailType") == event_type, + sorted_events_written_to_disk, + ) + ) + + assert [json.loads(event["Detail"]) for event in sorted_events] == event_details_to_publish + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="V1 provider does not support this feature") + def test_create_connection_validations(self, aws_client, snapshot): + connection_name = "This should fail with two errors 123467890123412341234123412341234" + + with pytest.raises(ClientError) as e: + ( + aws_client.events.create_connection( + Name=connection_name, + AuthorizationType="INVALID", + AuthParameters={ + "BasicAuthParameters": {"Username": "user", "Password": "pass"} + }, + ), + ) + snapshot.match("create_connection_exc", e.value.response) + + @markers.aws.validated + def test_put_events_response_entries_order( + self, events_put_rule, sqs_as_events_target, aws_client, snapshot, clean_up + ): + """Test that put_events response contains each EventId only once, even with multiple targets.""" + + queue_url_1, queue_arn_1 = sqs_as_events_target() + queue_url_2, queue_arn_2 = sqs_as_events_target() + + rule_name = f"test-rule-{short_uid()}" + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("EventId", reference_replacement=False), + snapshot.transform.key_value("detail", reference_replacement=False), + snapshot.transform.regex(queue_arn_1, ""), + snapshot.transform.regex(queue_arn_2, ""), + snapshot.transform.regex(rule_name, ""), + *snapshot.transform.sqs_api(), + *snapshot.transform.sns_api(), + ] + ) + + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN_NO_DETAIL), + ) + + def check_rule_active(): + rule = aws_client.events.describe_rule(Name=rule_name) + assert rule["State"] == "ENABLED" + + retry(check_rule_active, retries=10, sleep=1) + + target_id_1 = f"test-target-1-{short_uid()}" + target_id_2 = f"test-target-2-{short_uid()}" + target_response = aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": target_id_1, "Arn": queue_arn_1}, + {"Id": target_id_2, "Arn": queue_arn_2}, + ], + ) + + assert target_response["FailedEntryCount"] == 0, ( + f"Failed to add targets: {target_response.get('FailedEntries', [])}" + ) + + # Use the test constants for the event + test_event = { + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + + event_response = aws_client.events.put_events(Entries=[test_event]) + + snapshot.match("put-events-response", event_response) + + assert len(event_response["Entries"]) == 1 + event_id = event_response["Entries"][0]["EventId"] + assert event_id, "EventId not found in response" + + def verify_message_content(message, original_event_id): + """Verify the message content matches what we sent.""" + body = json.loads(message["Body"]) + + assert body["source"] == TEST_EVENT_PATTERN_NO_DETAIL["source"][0], ( + f"Unexpected source: {body['source']}" + ) + assert body["detail-type"] == TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], ( + f"Unexpected detail-type: {body['detail-type']}" + ) + + detail = body["detail"] # detail is already parsed as dict + assert isinstance(detail, dict), f"Detail should be a dict, got {type(detail)}" + assert detail == TEST_EVENT_DETAIL, f"Unexpected detail content: {detail}" + + assert body["id"] == original_event_id, ( + f"Event ID mismatch. Expected {original_event_id}, got {body['id']}" + ) + + return body + + try: + messages_1 = sqs_collect_messages( + aws_client, queue_url_1, expected_events_count=1, retries=30, wait_time=5 + ) + messages_2 = sqs_collect_messages( + aws_client, queue_url_2, expected_events_count=1, retries=30, wait_time=5 + ) + except Exception as e: + raise Exception(f"Failed to collect messages: {str(e)}") + + assert len(messages_1) == 1, f"Expected 1 message in queue 1, got {len(messages_1)}" + assert len(messages_2) == 1, f"Expected 1 message in queue 2, got {len(messages_2)}" + + verify_message_content(messages_1[0], event_id) + verify_message_content(messages_2[0], event_id) + + snapshot.match( + "sqs-messages", {"queue1_messages": messages_1, "queue2_messages": messages_2} + ) + + @markers.aws.validated + def test_put_events_with_target_delivery_failure( + self, events_put_rule, sqs_create_queue, sqs_get_queue_arn, aws_client, snapshot, clean_up + ): + """Test that put_events returns successful EventId even when target delivery fails due to non-existent queue.""" + # Create a queue and get its ARN + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + # Delete the queue to simulate a failure scenario + aws_client.sqs.delete_queue(QueueUrl=queue_url) + + rule_name = f"test-rule-{short_uid()}" + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("EventId"), + snapshot.transform.regex(queue_arn, ""), + snapshot.transform.regex(rule_name, ""), + *snapshot.transform.sqs_api(), + ] + ) + + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN_NO_DETAIL), + ) + + target_id = f"test-target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn}, + ], + ) + + test_event = { + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + + response = aws_client.events.put_events(Entries=[test_event]) + snapshot.match("put-events-response", response) + + assert len(response["Entries"]) == 1 + assert "EventId" in response["Entries"][0] + assert response["FailedEntryCount"] == 0 + + new_queue_url = sqs_create_queue() + messages = aws_client.sqs.receive_message( + QueueUrl=new_queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=1 + ).get("Messages", []) + + assert len(messages) == 0, "No messages should be delivered when queue doesn't exist" + + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="Test specific for v2 provider") + def test_put_events_with_time_field( + self, events_put_rule, sqs_as_events_target, aws_client, snapshot + ): + """Test that EventBridge correctly handles datetime serialization in events.""" + rule_name = f"test-rule-{short_uid()}" + queue_url, queue_arn = sqs_as_events_target() + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + *snapshot.transform.sqs_api(), + ] + ) + + events_put_rule( + Name=rule_name, + EventPattern=json.dumps( + {"source": ["test-source"], "detail-type": ["test-detail-type"]} + ), + ) + + aws_client.events.put_targets(Rule=rule_name, Targets=[{"Id": "id1", "Arn": queue_arn}]) + + timestamp = datetime.datetime.utcnow() + event = { + "Source": "test-source", + "DetailType": "test-detail-type", + "Time": timestamp, + "Detail": json.dumps({"message": "test message"}), + } + + response = aws_client.events.put_events(Entries=[event]) + snapshot.match("put-events", response) + + messages = sqs_collect_messages(aws_client, queue_url, expected_events_count=1) + assert len(messages) == 1 + snapshot.match("sqs-messages", messages) + + received_event = json.loads(messages[0]["Body"]) + # Explicit assertions for time field format GH issue: https://github.com/localstack/localstack/issues/11630#issuecomment-2506187279 + assert "time" in received_event, "Time field missing in the event" + time_str = received_event["time"] + + # Verify ISO8601 format: YYYY-MM-DDThh:mm:ssZ + # Example: "2024-11-28T13:44:36Z" + assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", time_str), ( + f"Time field '{time_str}' does not match ISO8601 format (YYYY-MM-DDThh:mm:ssZ)" + ) + + # Verify we can parse it back to datetime + datetime_obj = datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ") + assert isinstance(datetime_obj, datetime.datetime), ( + f"Failed to parse time string '{time_str}' back to datetime object" + ) + + time_difference = abs((datetime_obj - timestamp.replace(microsecond=0)).total_seconds()) + assert time_difference <= 60, ( + f"Time in event '{time_str}' differs too much from sent time '{timestamp.isoformat()}'" + ) + + +class TestEventBus: + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize("regions", [["us-east-1"], ["us-east-1", "us-west-1", "eu-central-1"]]) + @pytest.mark.parametrize("with_description", [True, False]) + def test_create_list_describe_delete_custom_event_buses( + self, with_description, aws_client_factory, regions, snapshot + ): + bus_name = f"test-bus-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + + for region in regions: + # overwriting randomized region https://docs.localstack.cloud/contributing/multi-account-region-testing/ + # requires manually adding region replacement for snapshot + snapshot.add_transformer(snapshot.transform.regex(region, "")) + events = aws_client_factory(region_name=region).events + + kwargs = {"Description": "test bus"} if with_description else {} + response = events.create_event_bus(Name=bus_name, **kwargs) + snapshot.match(f"create-custom-event-bus-{region}", response) + + response = events.list_event_buses(NamePrefix=bus_name) + snapshot.match(f"list-event-buses-after-create-{region}", response) + + response = events.describe_event_bus(Name=bus_name) + snapshot.match(f"describe-custom-event-bus-{region}", response) + + # multiple event buses with same name in multiple regions before deleting them + for region in regions: + events = aws_client_factory(region_name=region).events + + kwargs = {"Description": "test bus"} if with_description else {} + response = events.delete_event_bus(Name=bus_name) + snapshot.match(f"delete-custom-event-bus-{region}", response) + + response = events.list_event_buses(NamePrefix=bus_name) + snapshot.match(f"list-event-buses-after-delete-{region}", response) + + @markers.aws.validated + def test_create_multiple_event_buses_same_name( + self, events_create_event_bus, aws_client, snapshot + ): + bus_name = f"test-bus-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + events_create_event_bus(Name=bus_name) + + with pytest.raises(aws_client.events.exceptions.ResourceAlreadyExistsException) as e: + events_create_event_bus(Name=bus_name) + snapshot.match("create-multiple-event-buses-same-name", e.value.response) + + @markers.aws.validated + def test_describe_delete_not_existing_event_bus(self, aws_client, snapshot): + bus_name = f"this-bus-does-not-exist-1234567890-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as e: + aws_client.events.describe_event_bus(Name=bus_name) + snapshot.match("describe-not-existing-event-bus-error", e.value.response) + + aws_client.events.delete_event_bus(Name=bus_name) + snapshot.match("delete-not-existing-event-bus", e.value.response) + + @markers.aws.validated + def test_delete_default_event_bus(self, aws_client, snapshot): + with pytest.raises(aws_client.events.exceptions.ClientError) as e: + aws_client.events.delete_event_bus(Name="default") + snapshot.match("delete-default-event-bus-error", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_list_event_buses_with_prefix(self, events_create_event_bus, aws_client, snapshot): + events = aws_client.events + bus_name = f"unique-prefix-1234567890-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + + bus_name_not_match = "no-prefix-match" + snapshot.add_transformer(snapshot.transform.regex(bus_name_not_match, "")) + + events_create_event_bus(Name=bus_name) + events_create_event_bus(Name=bus_name_not_match) + + response = events.list_event_buses(NamePrefix=bus_name) + snapshot.match("list-event-buses-prefix-complete-name", response) + + response = events.list_event_buses(NamePrefix=bus_name.split("-")[0]) + snapshot.match("list-event-buses-prefix", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_list_event_buses_with_limit(self, events_create_event_bus, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.jsonpath("$..NextToken", "next_token")) + events = aws_client.events + bus_name_prefix = f"test-bus-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name_prefix, "")) + count = 6 + + for i in range(count): + bus_name = f"{bus_name_prefix}-{i}" + events_create_event_bus(Name=bus_name) + + response = events.list_event_buses(Limit=int(count / 2), NamePrefix=bus_name_prefix) + snapshot.match("list-event-buses-limit", response) + + response = events.list_event_buses( + Limit=int(count / 2) + 2, NextToken=response["NextToken"], NamePrefix=bus_name_prefix + ) + snapshot.match("list-event-buses-limit-next-token", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_put_permission( + self, + bus_name, + events_create_event_bus, + aws_client, + account_id, + secondary_account_id, + snapshot, + ): + if bus_name == "custom": + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + if bus_name == "default": + try: + aws_client.events.remove_permission( + EventBusName=bus_name, RemoveAllPermissions=True + ) # error if no permission is present + except Exception: + pass + + snapshot.add_transformer( + [ + snapshot.transform.regex(bus_name, ""), + snapshot.transform.regex(account_id, ""), + snapshot.transform.regex(secondary_account_id, ""), + SortingTransformer("Statement", lambda o: o["Sid"]), + snapshot.transform.key_value("Sid"), + ] + ) + + statement_id_primary = f"statement-1-{short_uid()}" + response = aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal=account_id, + StatementId=statement_id_primary, + ) + snapshot.match("put-permission", response) + + statement_id_primary = f"statement-2-{short_uid()}" + aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal=account_id, + StatementId=statement_id_primary, + ) + + statement_id_secondary = f"statement-3-{short_uid()}" + aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal=secondary_account_id, + StatementId=statement_id_secondary, + ) + + response = aws_client.events.describe_event_bus(Name=bus_name) + snapshot.match("describe-event-bus-put-permission-multiple-principals", response) + + # allow all principals to put events + statement_id = f"statement-4-{short_uid()}" + # only events:PutEvents is allowed for actions + # only a single access policy is allowed per event bus + aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal="*", # required if condition is present + StatementId=statement_id, + # Condition={"Type": "StringEquals", "Key": "aws:PrincipalOrgID", "Value": "org id"}, + ) + + # put permission just replaces the existing permission + aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal="*", + StatementId=statement_id, + ) + + response = aws_client.events.describe_event_bus(Name=bus_name) + snapshot.match("describe-event-bus-put-permission", response) + + # allow with policy document + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": f"statement-5-{short_uid()}", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "events:ListRules", + "Resource": "*", + } + ], + } + response = aws_client.events.put_permission( + EventBusName=bus_name, + Policy=json.dumps(policy), + ) + snapshot.match("put-permission-policy", response) + + response = aws_client.events.describe_event_bus(Name=bus_name) + snapshot.match("describe-event-bus-put-permission-policy", response) + + try: + aws_client.events.remove_permission(EventBusName=bus_name, RemoveAllPermissions=True) + except Exception: + pass + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_permission_non_existing_event_bus(self, aws_client, snapshot): + non_exist_bus_name = f"non-existing-bus-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(non_exist_bus_name, "")) + + with pytest.raises(ClientError) as e: + aws_client.events.put_permission( + EventBusName=non_exist_bus_name, + Action="events:PutEvents", + Principal="*", + StatementId="statement-id", + ) + snapshot.match("remove-permission-non-existing-sid-error", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_remove_permission( + self, + bus_name, + events_create_event_bus, + aws_client, + account_id, + secondary_account_id, + snapshot, + ): + if bus_name == "custom": + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + if bus_name == "default": + try: + aws_client.events.remove_permission( + EventBusName=bus_name, RemoveAllPermissions=True + ) # error if no permission is present + except Exception: + pass + + snapshot.add_transformer( + [ + snapshot.transform.regex(bus_name, ""), + snapshot.transform.regex(account_id, ""), + snapshot.transform.regex(secondary_account_id, ""), + SortingTransformer("Statement", lambda o: o["Sid"]), + snapshot.transform.key_value("Sid"), + ] + ) + + statement_id_primary = f"statement-1-{short_uid()}" + aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal=account_id, + StatementId=statement_id_primary, + ) + + statement_id_secondary = f"statement-2-{short_uid()}" + aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal=secondary_account_id, + StatementId=statement_id_secondary, + ) + + response_remove_permission = aws_client.events.remove_permission( + EventBusName=bus_name, StatementId=statement_id_primary, RemoveAllPermissions=False + ) + snapshot.match("remove-permission", response_remove_permission) + + response = aws_client.events.describe_event_bus(Name=bus_name) + snapshot.match("describe-event-bus-remove-permission", response) + + response_remove_all = aws_client.events.remove_permission( + EventBusName=bus_name, RemoveAllPermissions=True + ) + snapshot.match("remove-permission-all", response_remove_all) + + response = aws_client.events.describe_event_bus(Name=bus_name) + snapshot.match("describe-event-bus-remove-permission-all", response) + + try: + aws_client.events.remove_permission(EventBusName=bus_name, RemoveAllPermissions=True) + except Exception: + pass + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + @pytest.mark.parametrize("policy_exists", [True, False]) + def test_remove_permission_non_existing_sid( + self, aws_client, bus_name, policy_exists, events_create_event_bus, account_id, snapshot + ): + if bus_name == "custom": + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + if bus_name == "default": + try: + aws_client.events.remove_permission( + EventBusName=bus_name, RemoveAllPermissions=True + ) # error if no permission is present + except Exception: + pass + + if policy_exists: + aws_client.events.put_permission( + EventBusName=bus_name, + Action="events:PutEvents", + Principal=account_id, + StatementId=f"statement-{short_uid()}", + ) + + with pytest.raises(ClientError) as e: + aws_client.events.remove_permission( + EventBusName=bus_name, StatementId="non-existing-sid" + ) + snapshot.match("remove-permission-non-existing-sid-error", e.value.response) + + @markers.aws.validated + # TODO move to test targets + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_put_events_bus_to_bus( + self, + strategy, + monkeypatch, + sqs_as_events_target, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + bus_name_one = "bus1-{}".format(short_uid()) + bus_name_two = "bus2-{}".format(short_uid()) + + events_create_event_bus(Name=bus_name_one) + event_bus_2_arn = events_create_event_bus(Name=bus_name_two)["EventBusArn"] + + # Create permission for event bus in primary region to send events to event bus in secondary region + + role_name_bus_one_to_bus_two = f"event-bus-one-to-two-role-{short_uid()}" + assume_role_policy_document_bus_one_to_bus_two = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + role_arn_bus_one_to_bus_two = aws_client.iam.create_role( + RoleName=role_name_bus_one_to_bus_two, + AssumeRolePolicyDocument=json.dumps(assume_role_policy_document_bus_one_to_bus_two), + )["Role"]["Arn"] + + policy_name_bus_one_to_bus_two = f"event-bus-one-to-two-policy-{short_uid()}" + policy_document_bus_one_to_bus_two = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": "events:PutEvents", + "Resource": "arn:aws:events:*:*:event-bus/*", + } + ], + } + + aws_client.iam.put_role_policy( + RoleName=role_name_bus_one_to_bus_two, + PolicyName=policy_name_bus_one_to_bus_two, + PolicyDocument=json.dumps(policy_document_bus_one_to_bus_two), + ) + + if is_aws_cloud(): + time.sleep(10) + + # Rule and target bus 1 to bus 2 + rule_name_bus_one = f"rule-{short_uid()}" + events_put_rule( + Name=rule_name_bus_one, + EventBusName=bus_name_one, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + target_id_bus_one_to_bus_two = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name_bus_one, + EventBusName=bus_name_one, + Targets=[ + { + "Id": target_id_bus_one_to_bus_two, + "Arn": event_bus_2_arn, + "RoleArn": role_arn_bus_one_to_bus_two, + } + ], + ) + + # Create sqs target + queue_url, queue_arn = sqs_as_events_target() + + # Rule and target bus 2 to sqs + rule_name_bus_two = f"rule-{short_uid()}" + events_put_rule( + Name=rule_name_bus_two, + EventBusName=bus_name_two, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + target_id_bus_two_to_sqs = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name_bus_two, + EventBusName=bus_name_two, + Targets=[{"Id": target_id_bus_two_to_sqs, "Arn": queue_arn}], + ) + + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name_one, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + messages = sqs_collect_messages(aws_client, queue_url, expected_events_count=1, retries=3) + + snapshot.add_transformer( + [ + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + ] + ) + snapshot.match("messages", messages) + + @markers.aws.validated + # TODO simplify and use sqs as target + def test_put_events_to_default_eventbus_for_custom_eventbus( + self, + events_create_event_bus, + events_put_rule, + sqs_create_queue, + sqs_get_queue_arn, + create_role, + create_policy, + s3_bucket, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.resource_name()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.jsonpath("$..detail.bucket.name", "bucket-name"), + snapshot.transform.jsonpath("$..detail.object.key", "key-name"), + snapshot.transform.jsonpath( + "$..detail.object.sequencer", "object-sequencer", reference_replacement=False + ), + snapshot.transform.jsonpath( + "$..detail.request-id", "request-id", reference_replacement=False + ), + snapshot.transform.jsonpath( + "$..detail.requester", "", reference_replacement=False + ), + snapshot.transform.jsonpath("$..detail.source-ip-address", "ip-address"), + ] + ) + default_bus_rule_name = f"test-default-bus-rule-{short_uid()}" + custom_bus_rule_name = f"test-custom-bus-rule-{short_uid()}" + default_bus_target_id = f"test-target-default-b-{short_uid()}" + custom_bus_target_id = f"test-target-custom-b-{short_uid()}" + custom_bus_name = f"test-eventbus-{short_uid()}" + + role = f"test-eventbus-role-{short_uid()}" + policy_name = f"test-eventbus-role-policy-{short_uid()}" + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration={"EventBridgeConfiguration": {}} + ) + + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + custom_event_bus = events_create_event_bus(Name=custom_bus_name) + snapshot.match("create-custom-event-bus", custom_event_bus) + custom_event_bus_arn = custom_event_bus["EventBusArn"] + + event_bus_policy = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "events:PutEvents", "Resource": custom_event_bus_arn} + ], + } + + role_response = create_role( + RoleName=role, AssumeRolePolicyDocument=json.dumps(EVENT_BUS_ROLE) + ) + role_arn = role_response["Role"]["Arn"] + policy_arn = create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(event_bus_policy) + )["Policy"]["Arn"] + aws_client.iam.attach_role_policy(RoleName=role, PolicyArn=policy_arn) + if is_aws_cloud(): + # wait for the policy to be properly attached + time.sleep(20) + + rule_on_default_bus = events_put_rule( + Name=default_bus_rule_name, + EventPattern='{"detail-type":["Object Created"],"source":["aws.s3"]}', + State="ENABLED", + ) + snapshot.match("create-rule-1", rule_on_default_bus) + + custom_bus_rule_event_pattern = { + "detail": { + "bucket": {"name": [s3_bucket]}, + "object": {"key": [{"prefix": "delivery/"}]}, + }, + "detail-type": ["Object Created"], + "source": ["aws.s3"], + } + + rule_on_custom_bus = events_put_rule( + Name=custom_bus_rule_name, + EventBusName=custom_bus_name, + EventPattern=json.dumps(custom_bus_rule_event_pattern), + State="ENABLED", + ) + rule_on_custom_bus_arn = rule_on_custom_bus["RuleArn"] + snapshot.match("create-rule-2", rule_on_custom_bus) + + allow_event_rule_to_sqs_queue( + aws_client=aws_client, + sqs_queue_url=queue_url, + sqs_queue_arn=queue_arn, + event_rule_arn=rule_on_custom_bus_arn, + ) + + resp = aws_client.events.put_targets( + Rule=default_bus_rule_name, + Targets=[ + {"Id": default_bus_target_id, "Arn": custom_event_bus_arn, "RoleArn": role_arn} + ], + ) + snapshot.match("put-target-1", resp) + + resp = aws_client.events.put_targets( + Rule=custom_bus_rule_name, + EventBusName=custom_bus_name, + Targets=[{"Id": custom_bus_target_id, "Arn": queue_arn}], + ) + snapshot.match("put-target-2", resp) + + aws_client.s3.put_object(Bucket=s3_bucket, Key="delivery/test.txt", Body=b"data") + + retries = 20 if is_aws_cloud() else 3 + messages = sqs_collect_messages( + aws_client, queue_url, expected_events_count=1, retries=retries, wait_time=5 + ) + assert len(messages) == 1 + snapshot.match("get-events", {"Messages": messages}) + + received_event = json.loads(messages[0]["Body"]) + + assert_valid_event(received_event) + + @markers.aws.validated # TODO fix condition for this test, only succeeds if run on its own + def test_put_events_nonexistent_event_bus( + self, + aws_client, + sqs_create_queue, + sqs_get_queue_arn, + events_put_rule, + snapshot, + ): + default_bus_rule_name = f"rule-{short_uid()}" + default_bus_target_id = f"test-target-default-b-{short_uid()}" + nonexistent_event_bus = f"event-bus-{short_uid()}" + + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), # the event contains a timestamp + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.regex(nonexistent_event_bus, ""), + ] + ) + + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + rule_on_default_bus = events_put_rule( + Name=default_bus_rule_name, + EventPattern=json.dumps({"detail-type": ["CustomType"], "source": ["MySource"]}), + State="ENABLED", + ) + + allow_event_rule_to_sqs_queue( + aws_client=aws_client, + event_rule_arn=rule_on_default_bus["RuleArn"], + sqs_queue_arn=queue_arn, + sqs_queue_url=queue_url, + ) + + aws_client.events.put_targets( + Rule=default_bus_rule_name, + Targets=[{"Id": default_bus_target_id, "Arn": queue_arn}], + ) + + entries = [ + { + "Source": "MySource", + "DetailType": "CustomType", + "Detail": json.dumps({"message": "for the default event bus"}), + }, + { + "EventBusName": nonexistent_event_bus, # nonexistent EventBusName, message should be ignored + "Source": "MySource", + "DetailType": "CustomType", + "Detail": json.dumps({"message": "for the custom event bus"}), + }, + ] + response = aws_client.events.put_events(Entries=entries) + snapshot.match("put-events", response) + + def _get_sqs_messages(): # TODO cleanup use exiting fixture + resp = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=1 + ) + msgs = resp.get("Messages") + assert len(msgs) == 1 + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=msgs[0]["ReceiptHandle"] + ) + return msgs + + messages = retry(_get_sqs_messages, retries=10, sleep=0.1) + snapshot.match("get-events", messages) + + with pytest.raises(ClientError) as e: + aws_client.events.describe_event_bus(Name=nonexistent_event_bus) + snapshot.match("non-existent-bus-error", e.value.response) + + +class TestEventRule: + @markers.aws.validated + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_put_list_with_prefix_describe_delete_rule( + self, bus_name, events_create_event_bus, events_put_rule, aws_client, snapshot + ): + if bus_name == "custom": + bus_name = f"bus-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + events_create_event_bus(Name=bus_name) + + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + response = events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + EventBusName=bus_name, + ) + snapshot.match("put-rule", response) + + # NamePrefix required for default bus against AWS + response = aws_client.events.list_rules(NamePrefix=rule_name, EventBusName=bus_name) + snapshot.match("list-rules", response) + + response = aws_client.events.describe_rule(Name=rule_name, EventBusName=bus_name) + snapshot.match("describe-rule", response) + + response = aws_client.events.delete_rule(Name=rule_name, EventBusName=bus_name) + snapshot.match("delete-rule", response) + + response = aws_client.events.list_rules(NamePrefix=rule_name, EventBusName=bus_name) + snapshot.match("list-rules-after-delete", response) + + @markers.aws.validated + def test_put_multiple_rules_with_same_name( + self, events_create_event_bus, events_put_rule, aws_client, snapshot + ): + event_bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + snapshot.add_transformer(snapshot.transform.regex(event_bus_name, "")) + + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + + response = events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + snapshot.match("put-rule", response) + + # put_rule updates the rule if it already exists + response = events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + snapshot.match("re-put-rule", response) + + response = aws_client.events.list_rules(EventBusName=event_bus_name) + snapshot.match("list-rules", response) + + @markers.aws.validated + def test_list_rule_with_limit( + self, events_create_event_bus, events_put_rule, aws_client, snapshot + ): + snapshot.add_transformer(snapshot.transform.jsonpath("$..NextToken", "next_token")) + + event_bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + snapshot.add_transformer(snapshot.transform.regex(event_bus_name, "")) + + rule_name_prefix = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name_prefix, "")) + count = 6 + + for i in range(count): + rule_name = f"{rule_name_prefix}-{i}" + events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + response = aws_client.events.list_rules(Limit=int(count / 2), EventBusName=event_bus_name) + snapshot.match("list-rules-limit", response) + + response = aws_client.events.list_rules( + Limit=int(count / 2) + 2, NextToken=response["NextToken"], EventBusName=event_bus_name + ) + snapshot.match("list-rules-limit-next-token", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_describe_nonexistent_rule(self, aws_client, snapshot): + rule_name = f"this-rule-does-not-exist-1234567890-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as e: + aws_client.events.describe_rule(Name=rule_name) + snapshot.match("describe-not-existing-rule-error", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_disable_re_enable_rule( + self, events_create_event_bus, events_put_rule, aws_client, snapshot, bus_name + ): + if bus_name == "custom": + bus_name = f"bus-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + events_create_event_bus(Name=bus_name) + + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + EventBusName=bus_name, + ) + + response = aws_client.events.disable_rule(Name=rule_name, EventBusName=bus_name) + snapshot.match("disable-rule", response) + + response = aws_client.events.describe_rule(Name=rule_name, EventBusName=bus_name) + snapshot.match("describe-rule-disabled", response) + + response = aws_client.events.enable_rule(Name=rule_name, EventBusName=bus_name) + snapshot.match("enable-rule", response) + + response = aws_client.events.describe_rule(Name=rule_name, EventBusName=bus_name) + snapshot.match("describe-rule-enabled", response) + + @markers.aws.validated + def test_delete_rule_with_targets( + self, events_put_rule, sqs_create_queue, sqs_get_queue_arn, aws_client, snapshot + ): + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + target_id = f"test-target-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(target_id, "")) + + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + snapshot.add_transformer(snapshot.transform.regex(queue_arn, "")) + + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + { + "Id": target_id, + "Arn": queue_arn, + } + ], + ) + + with pytest.raises(aws_client.events.exceptions.ClientError) as e: + aws_client.events.delete_rule(Name=rule_name) + snapshot.match("delete-rule-with-targets-error", e.value.response) + + @markers.aws.validated + def test_update_rule_with_targets( + self, events_put_rule, sqs_create_queue, sqs_get_queue_arn, aws_client, snapshot + ): + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + target_id = f"test-target-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(target_id, "")) + + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + snapshot.add_transformer(snapshot.transform.regex(queue_arn, "")) + + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + { + "Id": target_id, + "Arn": queue_arn, + } + ], + ) + + response = aws_client.events.list_targets_by_rule(Rule=rule_name) + snapshot.match("list-targets", response) + + response = events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + snapshot.match("update-rule", response) + + response = aws_client.events.list_targets_by_rule(Rule=rule_name) + snapshot.match("list-targets-after-update", response) + + @markers.aws.validated + def test_process_to_multiple_matching_rules_different_targets( + self, + events_create_event_bus, + sqs_create_queue, + sqs_get_queue_arn, + events_put_rule, + aws_client, + ): + """two rules with each two sqs targets, all 4 queues should receive the event""" + + custom_bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=custom_bus_name) + + # create sqs queues targets + targets = {} + for i in range(4): + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + targets[f"sqs_target_{i}"] = {"queue_url": queue_url, "queue_arn": queue_arn} + + # create rules + rules = {} + for i in range(2): + rule_name = f"test-rule-{i}-{short_uid()}" + rule = events_put_rule( + Name=rule_name, + EventBusName=custom_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN_NO_DETAIL), + State="ENABLED", + ) + rule_arn = rule["RuleArn"] + rules[f"rule_{i}"] = {"rule_name": rule_name, "rule_arn": rule_arn} + + # attach targets to rule + combinations = [("0", ["0", "1"]), ("1", ["2", "3"])] + for rule_idx, targets_idxs in combinations: + rule_arn = rules[f"rule_{rule_idx}"]["rule_arn"] + for target_idx in targets_idxs: + queue_url = targets[f"sqs_target_{target_idx}"]["queue_url"] + queue_arn = targets[f"sqs_target_{target_idx}"]["queue_arn"] + allow_event_rule_to_sqs_queue( + aws_client=aws_client, + sqs_queue_url=queue_url, + sqs_queue_arn=queue_arn, + event_rule_arn=rule_arn, + ) + + aws_client.events.put_targets( + Rule=rules[f"rule_{rule_idx}"]["rule_name"], + EventBusName=custom_bus_name, + Targets=[ + {"Id": f"test-target-{target_idx}-{short_uid()}", "Arn": queue_arn}, + ], + ) + + # put event + aws_client.events.put_events( + Entries=[ + { + "EventBusName": custom_bus_name, + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ], + ) + + sqs_collect_messages( + aws_client, targets["sqs_target_0"]["queue_url"], expected_events_count=1 + ) + sqs_collect_messages( + aws_client, targets["sqs_target_1"]["queue_url"], expected_events_count=1 + ) + sqs_collect_messages( + aws_client, targets["sqs_target_2"]["queue_url"], expected_events_count=1 + ) + sqs_collect_messages( + aws_client, targets["sqs_target_3"]["queue_url"], expected_events_count=1 + ) + + @markers.aws.validated + def test_process_to_multiple_matching_rules_single_target( + self, + create_lambda_function, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + ): + """two rules with both the same lambda target, the lambda target should be invoked twice. + This will only work for certain targets, since e.g. sqs has message deduplication""" + + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + # create lambda target + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + # create rules + for i in range(2): + rule_name = f"test-rule-{i}-{short_uid()}" + rule = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN_NO_DETAIL), + State="ENABLED", + ) + rule_arn = rule["RuleArn"] + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + + target_id = f"test-target-{i}-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": lambda_function_arn}], + ) + + # put event + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ], + ) + + # check lambda invocation + events = retry( + check_expected_lambda_log_events_length, + retries=3, + sleep=1, + function_name=function_name, + expected_length=2, + logs_client=aws_client.logs, + ) + snapshot.match("events", events) + + @markers.aws.validated + def test_process_to_single_matching_rules_single_target( + self, + create_lambda_function, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + ): + """Three rules with all the same lambda target, but different patterns as condition. + The lambda should onl be invoked by the rule matching the event pattern.""" + + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + # create lambda target + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + # create rules + sources = ["source-one", "source-two", "source-three"] + for i, source in zip(range(3), sources, strict=False): + rule_name = f"test-rule-{i}-{short_uid()}" + rule = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps({"source": [source]}), + State="ENABLED", + ) + rule_arn = rule["RuleArn"] + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + + target_id = f"test-target-{i}-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": lambda_function_arn}], + ) + + for i, source in zip(range(3), sources, strict=False): + num_events = i + 1 + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": source, + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ], + ) + + # check lambda invocation + events = retry( + check_expected_lambda_log_events_length, + retries=3, + sleep=1, + function_name=function_name, + expected_length=num_events, + logs_client=aws_client.logs, + ) + snapshot.match(f"events-{source}", events) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_process_pattern_to_single_matching_rules_single_target( + self, + create_lambda_function, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + ): + """Three rules with all the same lambda target, but different patterns as condition. + The lambda should onl be invoked by the rule matching the event pattern.""" + + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + # create lambda target + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + # create rules + input_path_map = {"detail": "$.detail"} + patterns = [ + {"detail": {"payload": {"id": [{"exists": True}]}}}, + {"detail": {"id": [{"exists": True}]}}, + ] + input_transformers = [ + { + "InputPathsMap": input_path_map, + "InputTemplate": '{"detail-payload-with-id": }', + }, + { + "InputPathsMap": input_path_map, + "InputTemplate": '{"detail-with-id": }', + }, + ] + for i, pattern, input_transformer in zip( + range(2), patterns, input_transformers, strict=False + ): + rule_name = f"test-rule-{i}-{short_uid()}" + rule = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(pattern), + State="ENABLED", + ) + rule_arn = rule["RuleArn"] + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + + target_id = f"test-target-{i}-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + { + "Id": target_id, + "Arn": lambda_function_arn, + "InputTransformer": input_transformer, + } + ], + ) + + details = [ + {"payload": {"id": "123"}}, + {"id": "123"}, + ] + for i, detail in zip(range(2), details, strict=False): + num_events = i + 1 + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN_NO_DETAIL["source"][0], + "DetailType": TEST_EVENT_PATTERN_NO_DETAIL["detail-type"][0], + "Detail": json.dumps(detail), + } + ], + ) + + # check lambda invocation + events = retry( + check_expected_lambda_log_events_length, + retries=3, + sleep=1, + function_name=function_name, + expected_length=num_events, + logs_client=aws_client.logs, + ) + snapshot.match(f"events-{num_events}", events) + + @markers.aws.validated + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_list_rule_names_by_target( + self, + bus_name, + events_create_event_bus, + events_put_rule, + sqs_create_queue, + sqs_get_queue_arn, + aws_client, + snapshot, + clean_up, + ): + """Test the ListRuleNamesByTarget API to verify it correctly returns rules associated with a target.""" + # Create an SQS queue to use as a target + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + # Create an event bus if using custom bus + if bus_name == "custom": + bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + # Create multiple rules targeting the same SQS queue + rule_prefix = f"rule-{short_uid()}-" + snapshot.add_transformer(snapshot.transform.regex(rule_prefix, "")) + rule_names = [] + + # Create 3 rules all targeting the same SQS queue + for i in range(3): + rule_name = f"{rule_prefix}{i}" + rule_names.append(rule_name) + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps({"source": [f"source-{i}"]}), + ) + + # Add the SQS queue as a target for this rule + target_id = f"target-{i}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": queue_arn}], + ) + + # Create a rule targeting a different resource (to verify filtering) + other_rule = f"{rule_prefix}other" + events_put_rule( + Name=other_rule, + EventBusName=bus_name, + EventPattern=json.dumps({"source": ["other-source"]}), + ) + + # Test the ListRuleNamesByTarget API + response = aws_client.events.list_rule_names_by_target( + TargetArn=queue_arn, + EventBusName=bus_name, + ) + + # The response should contain all rules that target our queue + snapshot.match("list_rule_names_by_target", response) + + @markers.aws.validated + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_list_rule_names_by_target_with_limit( + self, + bus_name, + events_create_event_bus, + events_put_rule, + sqs_create_queue, + sqs_get_queue_arn, + aws_client, + snapshot, + clean_up, + ): + """Test the ListRuleNamesByTarget API with pagination to verify it correctly handles limits and next tokens.""" + # Create an SQS queue to use as a target + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + # Create an event bus if using custom bus + if bus_name == "custom": + bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + # Create multiple rules targeting the same SQS queue + rule_prefix = f"rule-{short_uid()}-" + snapshot.add_transformer(snapshot.transform.regex(rule_prefix, "")) + rule_names = [] + + # Create 5 rules all targeting the same SQS queue + for i in range(5): + rule_name = f"{rule_prefix}{i}" + rule_names.append(rule_name) + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps({"source": [f"source-{i}"]}), + ) + + # Add the SQS queue as a target for this rule + target_id = f"target-{i}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": queue_arn}], + ) + + # Test pagination with limit=2 + all_rule_names = [] + next_token = None + + # First page + response = aws_client.events.list_rule_names_by_target( + TargetArn=queue_arn, + EventBusName=bus_name, + Limit=2, + ) + # Store the original NextToken value before replacing it for snapshot comparison + next_token = response["NextToken"] + snapshot.add_transformer( + snapshot.transform.jsonpath( + jsonpath="$..NextToken", + value_replacement="", + reference_replacement=True, + ) + ) + + snapshot.match("first_page", response) + all_rule_names.extend(response["RuleNames"]) + + # Second page + response = aws_client.events.list_rule_names_by_target( + TargetArn=queue_arn, + EventBusName=bus_name, + Limit=2, + NextToken=next_token, + ) + # Store the original NextToken value before replacing it for snapshot comparison + next_token = response["NextToken"] + snapshot.match("second_page", response) + all_rule_names.extend(response["RuleNames"]) + + # Third page (should have 1 remaining) + response = aws_client.events.list_rule_names_by_target( + TargetArn=queue_arn, + EventBusName=bus_name, + Limit=2, + NextToken=next_token, + ) + snapshot.match("third_page", response) + all_rule_names.extend(response["RuleNames"]) + + @markers.aws.validated + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_list_rule_names_by_target_no_matches( + self, + bus_name, + events_create_event_bus, + events_put_rule, + sqs_create_queue, + sqs_get_queue_arn, + aws_client, + snapshot, + clean_up, + ): + """Test that ListRuleNamesByTarget returns empty result when no rules match the target.""" + # Create two SQS queues + search_queue_url = sqs_create_queue() + search_queue_arn = sqs_get_queue_arn(search_queue_url) + + target_queue_url = sqs_create_queue() + target_queue_arn = sqs_get_queue_arn(target_queue_url) + + # Create event bus if needed + if bus_name == "custom": + bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + # Create rules targeting the target queue, but none targeting the search queue + rule_name = f"rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps({"source": ["test-source"]}), + ) + + # Add the target + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": "target-1", "Arn": target_queue_arn}], + ) + + # Test the ListRuleNamesByTarget API with the search queue ARN + response = aws_client.events.list_rule_names_by_target( + TargetArn=search_queue_arn, + EventBusName=bus_name, + ) + + snapshot.match("list_rule_names_by_target_no_matches", response) + + +class TestEventPattern: + @markers.aws.validated + def test_put_events_pattern_with_values_in_array(self, put_events_with_filter_to_sqs, snapshot): + pattern = {"detail": {"event": {"data": {"type": ["1", "2"]}}}} + entries1 = [ + { + "Source": "test", + "DetailType": "test", + "Detail": json.dumps({"event": {"data": {"type": ["3", "1"]}}}), + } + ] + entries2 = [ + { + "Source": "test", + "DetailType": "test", + "Detail": json.dumps({"event": {"data": {"type": ["2"]}}}), + } + ] + entries3 = [ + { + "Source": "test", + "DetailType": "test", + "Detail": json.dumps({"event": {"data": {"type": ["3"]}}}), + } + ] + entries_asserts = [(entries1, True), (entries2, True), (entries3, False)] + messages = put_events_with_filter_to_sqs( + pattern=pattern, + entries_asserts=entries_asserts, + input_path="$.detail", + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("messages", messages) + + @markers.aws.validated + def test_put_events_pattern_nested(self, put_events_with_filter_to_sqs, snapshot): + pattern = {"detail": {"event": {"data": {"type": ["1"]}}}} + entries1 = [ + { + "Source": "test", + "DetailType": "test", + "Detail": json.dumps({"event": {"data": {"type": "1"}}}), + } + ] + entries2 = [ + { + "Source": "test", + "DetailType": "test", + "Detail": json.dumps({"event": {"data": {"type": "2"}}}), + } + ] + entries3 = [ + { + "Source": "test", + "DetailType": "test", + "Detail": json.dumps({"hello": "world"}), + } + ] + entries_asserts = [(entries1, True), (entries2, False), (entries3, False)] + messages = put_events_with_filter_to_sqs( + pattern=pattern, + entries_asserts=entries_asserts, + input_path="$.detail", + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("messages", messages) + + +class TestEventTarget: + @markers.aws.validated + @pytest.mark.parametrize("bus_name", ["custom", "default"]) + def test_put_list_remove_target( + self, + bus_name, + events_create_event_bus, + events_put_rule, + sqs_create_queue, + sqs_get_queue_arn, + aws_client, + snapshot, + ): + kwargs = {} + if bus_name == "custom": + bus_name = f"bus-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + events_create_event_bus(Name=bus_name) + kwargs["EventBusName"] = bus_name # required for custom event bus, optional for default + + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + EventBusName=bus_name, + ) + + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + snapshot.add_transformer(snapshot.transform.regex(queue_arn, "")) + target_id = f"test-target-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(target_id, "")) + response = aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + { + "Id": target_id, + "Arn": queue_arn, + } + ], + **kwargs, + ) + snapshot.match("put-target", response) + + response = aws_client.events.list_targets_by_rule(Rule=rule_name, **kwargs) + snapshot.match("list-targets", response) + + response = aws_client.events.remove_targets(Rule=rule_name, Ids=[target_id], **kwargs) + snapshot.match("remove-target", response) + + response = aws_client.events.list_targets_by_rule(Rule=rule_name, **kwargs) + snapshot.match("list-targets-after-delete", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_add_exceed_fife_targets_per_rule( + self, events_put_rule, sqs_create_queue, sqs_get_queue_arn, aws_client, snapshot + ): + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + snapshot.add_transformer(snapshot.transform.regex(queue_arn, "")) + + targets = [{"Id": f"test-target-{i}", "Arn": queue_arn} for i in range(7)] + snapshot.add_transformer(snapshot.transform.regex("test-target-", "")) + + with pytest.raises(aws_client.events.exceptions.LimitExceededException) as error: + aws_client.events.put_targets(Rule=rule_name, Targets=targets) + snapshot.match("put-targets-client-error", error.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_list_target_by_rule_limit( + self, events_put_rule, sqs_create_queue, sqs_get_queue_arn, aws_client, snapshot + ): + snapshot.add_transformer(snapshot.transform.jsonpath("$..NextToken", "next_token")) + rule_name = f"test-rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + snapshot.add_transformer(snapshot.transform.regex(queue_arn, "")) + + targets = [{"Id": f"test-target-{i}", "Arn": queue_arn} for i in range(5)] + snapshot.add_transformer(snapshot.transform.regex("test-target-", "")) + aws_client.events.put_targets(Rule=rule_name, Targets=targets) + + response = aws_client.events.list_targets_by_rule(Rule=rule_name, Limit=3) + snapshot.match("list-targets-limit", response) + + response = aws_client.events.list_targets_by_rule( + Rule=rule_name, NextToken=response["NextToken"] + ) + snapshot.match("list-targets-limit-next-token", response) + + @markers.aws.validated + def test_put_target_id_validation( + self, sqs_create_queue, sqs_get_queue_arn, events_put_rule, snapshot, aws_client + ): + rule_name = f"rule-{short_uid()}" + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + events_put_rule( + Name=rule_name, EventPattern=json.dumps(TEST_EVENT_PATTERN), State="ENABLED" + ) + + target_id = "!@#$@!#$" + with pytest.raises(ClientError) as e: + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}, + ], + ) + snapshot.add_transformer(snapshot.transform.regex(target_id, "invalid-target-id")) + snapshot.match("put-targets-invalid-id-error", e.value.response) + + target_id = f"{long_uid()}-{long_uid()}-extra" + with pytest.raises(ClientError) as e: + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}, + ], + ) + snapshot.add_transformer(snapshot.transform.regex(target_id, "second-invalid-target-id")) + snapshot.match("put-targets-length-error", e.value.response) + + target_id = f"test-With_valid.Characters-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}, + ], + ) + + @markers.aws.validated + def test_put_multiple_targets_with_same_id_single_rule( + self, sqs_create_queue, sqs_get_queue_arn, events_put_rule, snapshot, aws_client + ): + """Targets attached to a rule must have unique IDs, but there is no validation for this. + The last target with the same ID will overwrite the previous one.""" + rule_name = f"rule-{short_uid()}" + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + events_put_rule( + Name=rule_name, EventPattern=json.dumps(TEST_EVENT_PATTERN), State="ENABLED" + ) + + target_id = f"test-With_valid.Characters-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}, + ], + ) + + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + { + "Id": target_id, + "Arn": queue_arn, + "InputPath": "$.notexisting", + }, + ], + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.regex(target_id, "target-id"), + snapshot.transform.regex(queue_arn, "target-arn"), + ] + ) + response = aws_client.events.list_targets_by_rule(Rule=rule_name) + snapshot.match("list-targets", response) + + @markers.aws.validated + def test_put_multiple_targets_with_same_id_across_different_rules( + self, sqs_create_queue, sqs_get_queue_arn, events_put_rule, snapshot, aws_client + ): + """Targets attached to different rules can have the same ID""" + rule_one_name = f"test-rule-one-{short_uid()}" + rule_two_name = f"test-rule-two-{short_uid()}" + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + events_put_rule( + Name=rule_one_name, EventPattern=json.dumps(TEST_EVENT_PATTERN), State="ENABLED" + ) + events_put_rule( + Name=rule_two_name, EventPattern=json.dumps(TEST_EVENT_PATTERN), State="ENABLED" + ) + + target_id = f"test-With_valid.Characters-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_one_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}, + ], + ) + + aws_client.events.put_targets( + Rule=rule_two_name, + Targets=[ + { + "Id": target_id, + "Arn": queue_arn, + "InputPath": "$.notexisting", + }, + ], + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.regex(target_id, "target-id"), + snapshot.transform.regex(queue_arn, "target-arn"), + ] + ) + + response = aws_client.events.list_targets_by_rule(Rule=rule_one_name) + snapshot.match("list-targets-rule-one", response) + + response = aws_client.events.list_targets_by_rule(Rule=rule_two_name) + snapshot.match("list-targets-rule-two", response) + + @markers.aws.validated + def test_put_multiple_targets_with_same_arn_single_rule( + self, sqs_create_queue, sqs_get_queue_arn, events_put_rule, snapshot, aws_client + ): + """Targets attached to a rule can have the same ARN, but different IDs""" + rule_name = f"rule-{short_uid()}" + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + events_put_rule( + Name=rule_name, EventPattern=json.dumps(TEST_EVENT_PATTERN), State="ENABLED" + ) + + target_id_one = f"test-With_valid.Characters-{short_uid()}" + target_id_two = f"test-With_valid.Characters-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": target_id_one, "Arn": queue_arn, "InputPath": "$.detail"}, + {"Id": target_id_two, "Arn": queue_arn, "InputPath": "$.doesnotexist"}, + ], + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.regex(target_id_one, "target-id-one"), + snapshot.transform.regex(target_id_two, "target-id-two"), + snapshot.transform.regex(queue_arn, "target-arn"), + ] + ) + + response = aws_client.events.list_targets_by_rule(Rule=rule_name) + snapshot.match("list-targets", response) + + @markers.aws.validated + def test_put_multiple_targets_with_same_arn_across_different_rules( + self, sqs_create_queue, sqs_get_queue_arn, events_put_rule, snapshot, aws_client + ): + """Targets attached to different rules can have the same ARN""" + rule_one_name = f"test-rule-one-{short_uid()}" + rule_two_name = f"test-rule-two-{short_uid()}" + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + events_put_rule( + Name=rule_one_name, EventPattern=json.dumps(TEST_EVENT_PATTERN), State="ENABLED" + ) + events_put_rule( + Name=rule_two_name, EventPattern=json.dumps(TEST_EVENT_PATTERN), State="ENABLED" + ) + + target_id = f"test-With_valid.Characters-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_one_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}, + ], + ) + + aws_client.events.put_targets( + Rule=rule_two_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputPath": "$.doesnotexist"}, + ], + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.regex(target_id, "target-id"), + snapshot.transform.regex(queue_arn, "target-arn"), + ] + ) + + response = aws_client.events.list_targets_by_rule(Rule=rule_one_name) + snapshot.match("list-targets-rule-one", response) + + response = aws_client.events.list_targets_by_rule(Rule=rule_two_name) + snapshot.match("list-targets-rule-two", response) diff --git a/tests/aws/services/events/test_events.snapshot.json b/tests/aws/services/events/test_events.snapshot.json new file mode 100644 index 0000000000000..668c13edfb4fe --- /dev/null +++ b/tests/aws/services/events/test_events.snapshot.json @@ -0,0 +1,2705 @@ +{ + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_without_source": { + "recorded-date": "08-01-2025, 15:24:06", + "recorded-content": { + "put-events": { + "Entries": [ + { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter Source is not valid. Reason: Source is a required argument." + } + ], + "FailedEntryCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_without_detail": { + "recorded-date": "08-01-2025, 15:24:07", + "recorded-content": { + "put-events": { + "Entries": [ + { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter Detail is not valid. Reason: Detail is a required argument." + } + ], + "FailedEntryCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_with_too_big_detail": { + "recorded-date": "08-01-2025, 15:24:07", + "recorded-content": { + "put-events-too-big-detail-error": { + "Error": { + "Code": "ValidationException", + "Message": "Total size of the entries in the request is over the limit." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_without_detail_type": { + "recorded-date": "08-01-2025, 15:24:08", + "recorded-content": { + "put-events": { + "Entries": [ + { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter DetailType is not valid. Reason: DetailType is a required argument." + } + ], + "FailedEntryCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[STRING]": { + "recorded-date": "08-01-2025, 15:24:08", + "recorded-content": { + "put-events": { + "Entries": [ + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[ARRAY]": { + "recorded-date": "08-01-2025, 15:24:08", + "recorded-content": { + "put-events": { + "Entries": [ + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[MALFORMED_JSON]": { + "recorded-date": "08-01-2025, 15:24:08", + "recorded-content": { + "put-events": { + "Entries": [ + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[SERIALIZED_STRING]": { + "recorded-date": "08-01-2025, 15:24:08", + "recorded-content": { + "put-events": { + "Entries": [ + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_time": { + "recorded-date": "08-01-2025, 15:24:11", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "message": "short time" + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "message": "new time" + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "message": "long time" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_exceed_limit_ten_entries[custom]": { + "recorded-date": "08-01-2025, 15:24:12", + "recorded-content": { + "put-events-exceed-limit-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null)]' at 'entries' failed to satisfy constraint: Member must have length less than or equal to 10" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_exceed_limit_ten_entries[default]": { + "recorded-date": "08-01-2025, 15:24:13", + "recorded-content": { + "put-events-exceed-limit-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null), PutEventsRequestEntry(time=null, source=core.update-account-command, resources=null, detailType=core.update-account-command, detail={\"command\": \"update-account\", \"payload\": {\"acc_id\": \"0a787ecb-4015\", \"sf_id\": \"baz\"}}, eventBusName=, traceHeader=null, kmsKeyIdentifier=null, internalMetadata=null)]' at 'entries' failed to satisfy constraint: Member must have length less than or equal to 10" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_create_connection_validations": { + "recorded-date": "08-01-2025, 15:24:14", + "recorded-content": { + "create_connection_exc": { + "Error": { + "Code": "ValidationException", + "Message": "3 validation errors detected: Value 'This should fail with two errors 123467890123412341234123412341234' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+; Value 'This should fail with two errors 123467890123412341234123412341234' at 'name' failed to satisfy constraint: Member must have length less than or equal to 64; Value 'INVALID' at 'authorizationType' failed to satisfy constraint: Member must satisfy enum value set: [BASIC, OAUTH_CLIENT_CREDENTIALS, API_KEY]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_response_entries_order": { + "recorded-date": "08-01-2025, 15:34:46", + "recorded-content": { + "put-events-response": { + "Entries": [ + { + "EventId": "event-id" + } + ], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sqs-messages": { + "queue1_messages": [ + { + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": "detail" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "queue2_messages": [ + { + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": "detail" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_target_delivery_failure": { + "recorded-date": "08-01-2025, 15:27:00", + "recorded-content": { + "put-events-response": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_time_field": { + "recorded-date": "08-01-2025, 15:27:02", + "recorded-content": { + "put-events": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sqs-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "test-detail-type", + "source": "test-source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "message": "test message" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[True-regions0]": { + "recorded-date": "08-01-2025, 15:27:04", + "recorded-content": { + "create-custom-event-bus-us-east-1": { + "Description": "test bus", + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-us-east-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-us-east-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-us-east-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-us-east-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[True-regions1]": { + "recorded-date": "08-01-2025, 15:27:06", + "recorded-content": { + "create-custom-event-bus-us-east-1": { + "Description": "test bus", + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-us-east-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-us-east-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-custom-event-bus-us-west-1": { + "Description": "test bus", + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-us-west-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-us-west-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-custom-event-bus-eu-central-1": { + "Description": "test bus", + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-eu-central-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-eu-central-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "Description": "test bus", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-us-east-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-us-east-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-us-west-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-us-west-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-eu-central-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-eu-central-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[False-regions0]": { + "recorded-date": "08-01-2025, 15:27:07", + "recorded-content": { + "create-custom-event-bus-us-east-1": { + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-us-east-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-us-east-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-us-east-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-us-east-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[False-regions1]": { + "recorded-date": "08-01-2025, 15:27:09", + "recorded-content": { + "create-custom-event-bus-us-east-1": { + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-us-east-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-us-east-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-custom-event-bus-us-west-1": { + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-us-west-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-us-west-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-custom-event-bus-eu-central-1": { + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-create-eu-central-1": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-custom-event-bus-eu-central-1": { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-us-east-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-us-east-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-us-west-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-us-west-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-custom-event-bus-eu-central-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-after-delete-eu-central-1": { + "EventBuses": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_multiple_event_buses_same_name": { + "recorded-date": "12-03-2025, 10:20:08", + "recorded-content": { + "create-multiple-event-buses-same-name": { + "Error": { + "Code": "ResourceAlreadyExistsException", + "Message": "Event bus already exists." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_describe_delete_not_existing_event_bus": { + "recorded-date": "12-03-2025, 10:20:18", + "recorded-content": { + "describe-not-existing-event-bus-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-not-existing-event-bus": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_delete_default_event_bus": { + "recorded-date": "12-03-2025, 10:20:26", + "recorded-content": { + "delete-default-event-bus-error": { + "Error": { + "Code": "ValidationException", + "Message": "Cannot delete event bus default." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_list_event_buses_with_prefix": { + "recorded-date": "08-01-2025, 15:27:12", + "recorded-content": { + "list-event-buses-prefix-complete-name": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-prefix": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_list_event_buses_with_limit": { + "recorded-date": "08-01-2025, 15:27:14", + "recorded-content": { + "list-event-buses-limit": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/-0", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "-0" + }, + { + "Arn": "arn::events::111111111111:event-bus/-1", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "-1" + }, + { + "Arn": "arn::events::111111111111:event-bus/-2", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "-2" + } + ], + "NextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-event-buses-limit-next-token": { + "EventBuses": [ + { + "Arn": "arn::events::111111111111:event-bus/-3", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "-3" + }, + { + "Arn": "arn::events::111111111111:event-bus/-4", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "-4" + }, + { + "Arn": "arn::events::111111111111:event-bus/-5", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "-5" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission[custom]": { + "recorded-date": "08-01-2025, 15:27:19", + "recorded-content": { + "put-permission": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-put-permission-multiple-principals": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-put-permission": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": "*", + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-permission-policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-put-permission-policy": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com" + }, + "Action": "events:ListRules", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission[default]": { + "recorded-date": "08-01-2025, 15:27:22", + "recorded-content": { + "put-permission": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-put-permission-multiple-principals": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-put-permission": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + }, + { + "Sid": "", + "Effect": "Allow", + "Principal": "*", + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-permission-policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-put-permission-policy": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com" + }, + "Action": "events:ListRules", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission_non_existing_event_bus": { + "recorded-date": "12-03-2025, 10:20:41", + "recorded-content": { + "remove-permission-non-existing-sid-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission[custom]": { + "recorded-date": "08-01-2025, 15:27:23", + "recorded-content": { + "remove-permission": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-remove-permission": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-permission-all": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-remove-permission-all": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission[default]": { + "recorded-date": "08-01-2025, 15:27:25", + "recorded-content": { + "remove-permission": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-remove-permission": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam:::root" + }, + "Action": "events:PutEvents", + "Resource": "arn::events:::event-bus/" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-permission-all": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-event-bus-remove-permission-all": { + "Arn": "arn::events:::event-bus/", + "CreationTime": "datetime", + "LastModifiedTime": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[True-custom]": { + "recorded-date": "12-03-2025, 10:20:50", + "recorded-content": { + "remove-permission-non-existing-sid-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Statement with the provided id does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[True-default]": { + "recorded-date": "12-03-2025, 10:20:51", + "recorded-content": { + "remove-permission-non-existing-sid-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Statement with the provided id does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[False-custom]": { + "recorded-date": "12-03-2025, 10:20:52", + "recorded-content": { + "remove-permission-non-existing-sid-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Statement with the provided id does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[False-default]": { + "recorded-date": "12-03-2025, 10:20:54", + "recorded-content": { + "remove-permission-non-existing-sid-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Statement with the provided id does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[standard]": { + "recorded-date": "08-01-2025, 15:27:41", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[domain]": { + "recorded-date": "08-01-2025, 15:27:56", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[path]": { + "recorded-date": "08-01-2025, 15:28:12", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_to_default_eventbus_for_custom_eventbus": { + "recorded-date": "08-01-2025, 15:28:39", + "recorded-content": { + "create-custom-event-bus": { + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-rule-1": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-rule-2": { + "RuleArn": "arn::events::111111111111:rule//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-target-1": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-target-2": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-events": { + "Messages": [ + { + "Body": { + "version": "0", + "id": "", + "detail-type": "Object Created", + "source": "aws.s3", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::s3:::" + ], + "detail": { + "version": "0", + "bucket": { + "name": "" + }, + "object": { + "key": "", + "size": 4, + "etag": "8d777f385d3dfec8815d20f7496026dc", + "sequencer": "object-sequencer" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "reason": "PutObject" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_nonexistent_event_bus": { + "recorded-date": "08-01-2025, 15:28:43", + "recorded-content": { + "put-events": { + "Entries": [ + { + "EventId": "" + }, + { + "EventId": "" + } + ], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-events": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "CustomType", + "source": "MySource", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "message": "for the default event bus" + } + } + } + ], + "non-existent-bus-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_list_with_prefix_describe_delete_rule[custom]": { + "recorded-date": "08-01-2025, 15:28:45", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule//", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-rule": { + "Arn": "arn::events::111111111111:rule//", + "CreatedBy": "111111111111", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-rule": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules-after-delete": { + "Rules": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_list_with_prefix_describe_delete_rule[default]": { + "recorded-date": "08-01-2025, 15:28:47", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-rule": { + "Arn": "arn::events::111111111111:rule/", + "CreatedBy": "111111111111", + "EventBusName": "default", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-rule": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules-after-delete": { + "Rules": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_multiple_rules_with_same_name": { + "recorded-date": "08-01-2025, 15:28:48", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "re-put-rule": { + "RuleArn": "arn::events::111111111111:rule//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule//", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_with_limit": { + "recorded-date": "08-01-2025, 15:28:51", + "recorded-content": { + "list-rules-limit": { + "NextToken": "", + "Rules": [ + { + "Arn": "arn::events::111111111111:rule//-0", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "-0", + "State": "ENABLED" + }, + { + "Arn": "arn::events::111111111111:rule//-1", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "-1", + "State": "ENABLED" + }, + { + "Arn": "arn::events::111111111111:rule//-2", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "-2", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules-limit-next-token": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule//-3", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "-3", + "State": "ENABLED" + }, + { + "Arn": "arn::events::111111111111:rule//-4", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "-4", + "State": "ENABLED" + }, + { + "Arn": "arn::events::111111111111:rule//-5", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "-5", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_describe_nonexistent_rule": { + "recorded-date": "12-03-2025, 10:21:02", + "recorded-content": { + "describe-not-existing-rule-error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Rule does not exist on EventBus default." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_disable_re_enable_rule[custom]": { + "recorded-date": "08-01-2025, 15:28:55", + "recorded-content": { + "disable-rule": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-rule-disabled": { + "Arn": "arn::events::111111111111:rule//", + "CreatedBy": "111111111111", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "DISABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "enable-rule": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-rule-enabled": { + "Arn": "arn::events::111111111111:rule//", + "CreatedBy": "111111111111", + "EventBusName": "", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_disable_re_enable_rule[default]": { + "recorded-date": "08-01-2025, 15:28:56", + "recorded-content": { + "disable-rule": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-rule-disabled": { + "Arn": "arn::events::111111111111:rule/", + "CreatedBy": "111111111111", + "EventBusName": "default", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "DISABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "enable-rule": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-rule-enabled": { + "Arn": "arn::events::111111111111:rule/", + "CreatedBy": "111111111111", + "EventBusName": "default", + "EventPattern": { + "source": [ + "core.update-account-command" + ], + "detail-type": [ + "core.update-account-command" + ], + "detail": { + "command": [ + "update-account" + ] + } + }, + "Name": "", + "State": "ENABLED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_delete_rule_with_targets": { + "recorded-date": "12-03-2025, 10:21:10", + "recorded-content": { + "delete-rule-with-targets-error": { + "Error": { + "Code": "ValidationException", + "Message": "Rule can't be deleted since it has targets." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_update_rule_with_targets": { + "recorded-date": "08-01-2025, 15:28:59", + "recorded-content": { + "list-targets": { + "Targets": [ + { + "Arn": "", + "Id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets-after-update": { + "Targets": [ + { + "Arn": "", + "Id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_multiple_matching_rules_single_target": { + "recorded-date": "08-01-2025, 15:29:25", + "recorded-content": { + "events": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_single_matching_rules_single_target": { + "recorded-date": "08-01-2025, 15:30:04", + "recorded-content": { + "events-source-one": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-one", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ], + "events-source-two": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-one", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-two", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ], + "events-source-three": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-one", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-two", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-three", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_pattern_to_single_matching_rules_single_target": { + "recorded-date": "08-01-2025, 15:30:26", + "recorded-content": { + "events-1": [ + { + "detail-payload-with-id": { + "payload": { + "id": "123" + } + } + } + ], + "events-2": [ + { + "detail-payload-with-id": { + "payload": { + "id": "123" + } + } + }, + { + "detail-with-id": { + "id": "123" + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventPattern::test_put_events_pattern_with_values_in_array": { + "recorded-date": "08-01-2025, 15:30:36", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "event": { + "data": { + "type": [ + "3", + "1" + ] + } + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "event": { + "data": { + "type": [ + "2" + ] + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventPattern::test_put_events_pattern_nested": { + "recorded-date": "08-01-2025, 15:30:49", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "event": { + "data": { + "type": "1" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_list_remove_target[custom]": { + "recorded-date": "08-01-2025, 15:30:51", + "recorded-content": { + "put-target": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets": { + "Targets": [ + { + "Arn": "", + "Id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-target": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets-after-delete": { + "Targets": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_list_remove_target[default]": { + "recorded-date": "08-01-2025, 15:30:53", + "recorded-content": { + "put-target": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets": { + "Targets": [ + { + "Arn": "", + "Id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-target": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets-after-delete": { + "Targets": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_add_exceed_fife_targets_per_rule": { + "recorded-date": "12-03-2025, 10:21:21", + "recorded-content": { + "put-targets-client-error": { + "Error": { + "Code": "LimitExceededException", + "Message": "The requested resource exceeds the maximum number allowed." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_list_target_by_rule_limit": { + "recorded-date": "08-01-2025, 15:30:56", + "recorded-content": { + "list-targets-limit": { + "NextToken": "", + "Targets": [ + { + "Arn": "", + "Id": "0" + }, + { + "Arn": "", + "Id": "1" + }, + { + "Arn": "", + "Id": "2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets-limit-next-token": { + "Targets": [ + { + "Arn": "", + "Id": "3" + }, + { + "Arn": "", + "Id": "4" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_target_id_validation": { + "recorded-date": "08-01-2025, 15:30:58", + "recorded-content": { + "put-targets-invalid-id-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '!@#$@!#$' at 'targets.1.member.id' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\.\\-_A-Za-z0-9]+" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-targets-length-error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'second-invalid-target-id' at 'targets.1.member.id' failed to satisfy constraint: Member must have length less than or equal to 64" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_id_single_rule": { + "recorded-date": "08-01-2025, 15:31:00", + "recorded-content": { + "list-targets": { + "Targets": [ + { + "Arn": "target-arn", + "Id": "target-id", + "InputPath": "$.notexisting" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_id_across_different_rules": { + "recorded-date": "08-01-2025, 15:31:01", + "recorded-content": { + "list-targets-rule-one": { + "Targets": [ + { + "Arn": "target-arn", + "Id": "target-id", + "InputPath": "$.detail" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets-rule-two": { + "Targets": [ + { + "Arn": "target-arn", + "Id": "target-id", + "InputPath": "$.notexisting" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_arn_single_rule": { + "recorded-date": "08-01-2025, 15:31:03", + "recorded-content": { + "list-targets": { + "Targets": [ + { + "Arn": "target-arn", + "Id": "target-id-one", + "InputPath": "$.detail" + }, + { + "Arn": "target-arn", + "Id": "target-id-two", + "InputPath": "$.doesnotexist" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_arn_across_different_rules": { + "recorded-date": "08-01-2025, 15:31:05", + "recorded-content": { + "list-targets-rule-one": { + "Targets": [ + { + "Arn": "target-arn", + "Id": "target-id", + "InputPath": "$.detail" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-targets-rule-two": { + "Targets": [ + { + "Arn": "target-arn", + "Id": "target-id", + "InputPath": "$.doesnotexist" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target[custom]": { + "recorded-date": "19-05-2025, 07:53:33", + "recorded-content": { + "list_rule_names_by_target": { + "RuleNames": [ + "0", + "1", + "2" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target[default]": { + "recorded-date": "19-05-2025, 07:53:34", + "recorded-content": { + "list_rule_names_by_target": { + "RuleNames": [ + "0", + "1", + "2" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_with_limit[custom]": { + "recorded-date": "19-05-2025, 07:54:06", + "recorded-content": { + "first_page": { + "NextToken": "<:1>", + "RuleNames": [ + "0", + "1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_page": { + "NextToken": "<:2>", + "RuleNames": [ + "2", + "3" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "third_page": { + "RuleNames": [ + "4" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_with_limit[default]": { + "recorded-date": "19-05-2025, 07:54:07", + "recorded-content": { + "first_page": { + "NextToken": "<:1>", + "RuleNames": [ + "0", + "1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_page": { + "NextToken": "<:2>", + "RuleNames": [ + "2", + "3" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "third_page": { + "RuleNames": [ + "4" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_no_matches[custom]": { + "recorded-date": "19-05-2025, 07:54:49", + "recorded-content": { + "list_rule_names_by_target_no_matches": { + "RuleNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_no_matches[default]": { + "recorded-date": "19-05-2025, 07:54:50", + "recorded-content": { + "list_rule_names_by_target_no_matches": { + "RuleNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/events/test_events.validation.json b/tests/aws/services/events/test_events.validation.json new file mode 100644 index 0000000000000..a28109be3f564 --- /dev/null +++ b/tests/aws/services/events/test_events.validation.json @@ -0,0 +1,206 @@ +{ + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[False-regions0]": { + "last_validated_date": "2025-01-08T15:27:07+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[False-regions1]": { + "last_validated_date": "2025-01-08T15:27:09+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[True-regions0]": { + "last_validated_date": "2025-01-08T15:27:04+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_list_describe_delete_custom_event_buses[True-regions1]": { + "last_validated_date": "2025-01-08T15:27:06+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_create_multiple_event_buses_same_name": { + "last_validated_date": "2025-03-12T10:20:08+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_delete_default_event_bus": { + "last_validated_date": "2025-03-12T10:20:26+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_describe_delete_not_existing_event_bus": { + "last_validated_date": "2025-03-12T10:20:18+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_list_event_buses_with_limit": { + "last_validated_date": "2025-01-08T15:27:14+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_list_event_buses_with_prefix": { + "last_validated_date": "2025-01-08T15:27:12+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[domain]": { + "last_validated_date": "2025-01-08T15:27:56+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[path]": { + "last_validated_date": "2025-01-08T15:28:11+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_bus_to_bus[standard]": { + "last_validated_date": "2025-01-08T15:27:41+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_nonexistent_event_bus": { + "last_validated_date": "2025-01-08T15:28:43+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_events_to_default_eventbus_for_custom_eventbus": { + "last_validated_date": "2025-01-08T15:28:39+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission[custom]": { + "last_validated_date": "2025-01-08T15:27:19+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission[default]": { + "last_validated_date": "2025-01-08T15:27:22+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_put_permission_non_existing_event_bus": { + "last_validated_date": "2025-03-12T10:20:41+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission[custom]": { + "last_validated_date": "2025-01-08T15:27:23+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission[default]": { + "last_validated_date": "2025-01-08T15:27:25+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[False-custom]": { + "last_validated_date": "2025-03-12T10:20:52+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[False-default]": { + "last_validated_date": "2025-03-12T10:20:54+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[True-custom]": { + "last_validated_date": "2025-03-12T10:20:50+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventBus::test_remove_permission_non_existing_sid[True-default]": { + "last_validated_date": "2025-03-12T10:20:51+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventPattern::test_put_events_pattern_nested": { + "last_validated_date": "2025-01-08T15:30:49+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventPattern::test_put_events_pattern_with_values_in_array": { + "last_validated_date": "2025-01-08T15:30:36+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_delete_rule_with_targets": { + "last_validated_date": "2025-03-12T10:21:10+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_describe_nonexistent_rule": { + "last_validated_date": "2025-03-12T10:21:02+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_disable_re_enable_rule[custom]": { + "last_validated_date": "2025-01-08T15:28:55+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_disable_re_enable_rule[default]": { + "last_validated_date": "2025-01-08T15:28:56+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target[custom]": { + "last_validated_date": "2025-05-19T07:53:33+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target[default]": { + "last_validated_date": "2025-05-19T07:53:34+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_no_matches[custom]": { + "last_validated_date": "2025-05-19T07:54:49+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_no_matches[default]": { + "last_validated_date": "2025-05-19T07:54:50+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_with_limit[custom]": { + "last_validated_date": "2025-05-19T07:54:06+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_names_by_target_with_limit[default]": { + "last_validated_date": "2025-05-19T07:54:07+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_list_rule_with_limit": { + "last_validated_date": "2025-01-08T15:28:51+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_pattern_to_single_matching_rules_single_target": { + "last_validated_date": "2025-01-08T15:30:26+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_multiple_matching_rules_different_targets": { + "last_validated_date": "2025-01-08T15:29:04+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_multiple_matching_rules_single_target": { + "last_validated_date": "2025-01-08T15:29:25+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_process_to_single_matching_rules_single_target": { + "last_validated_date": "2025-01-08T15:30:04+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_list_with_prefix_describe_delete_rule[custom]": { + "last_validated_date": "2025-01-08T15:28:45+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_list_with_prefix_describe_delete_rule[default]": { + "last_validated_date": "2025-01-08T15:28:47+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_put_multiple_rules_with_same_name": { + "last_validated_date": "2025-01-08T15:28:48+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventRule::test_update_rule_with_targets": { + "last_validated_date": "2025-01-08T15:28:59+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_add_exceed_fife_targets_per_rule": { + "last_validated_date": "2025-03-12T10:21:21+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_list_target_by_rule_limit": { + "last_validated_date": "2025-01-08T15:30:56+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_list_remove_target[custom]": { + "last_validated_date": "2025-01-08T15:30:51+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_list_remove_target[default]": { + "last_validated_date": "2025-01-08T15:30:53+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_arn_across_different_rules": { + "last_validated_date": "2025-01-08T15:31:05+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_arn_single_rule": { + "last_validated_date": "2025-01-08T15:31:03+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_id_across_different_rules": { + "last_validated_date": "2025-01-08T15:31:01+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_multiple_targets_with_same_id_single_rule": { + "last_validated_date": "2025-01-08T15:31:00+00:00" + }, + "tests/aws/services/events/test_events.py::TestEventTarget::test_put_target_id_validation": { + "last_validated_date": "2025-01-08T15:30:58+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_create_connection_validations": { + "last_validated_date": "2025-01-08T15:24:14+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[ARRAY]": { + "last_validated_date": "2025-01-08T15:24:08+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[MALFORMED_JSON]": { + "last_validated_date": "2025-01-08T15:24:08+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[SERIALIZED_STRING]": { + "last_validated_date": "2025-01-08T15:24:08+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_malformed_detail[STRING]": { + "last_validated_date": "2025-01-08T15:24:08+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_with_too_big_detail": { + "last_validated_date": "2025-01-08T15:24:07+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_without_detail": { + "last_validated_date": "2025-01-08T15:24:07+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_event_without_detail_type": { + "last_validated_date": "2025-01-08T15:24:08+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_exceed_limit_ten_entries[custom]": { + "last_validated_date": "2025-01-08T15:24:12+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_exceed_limit_ten_entries[default]": { + "last_validated_date": "2025-01-08T15:24:13+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_response_entries_order": { + "last_validated_date": "2025-01-08T15:34:46+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_time": { + "last_validated_date": "2025-01-08T15:24:11+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_target_delivery_failure": { + "last_validated_date": "2025-01-08T15:27:00+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_with_time_field": { + "last_validated_date": "2025-01-08T15:27:02+00:00" + }, + "tests/aws/services/events/test_events.py::TestEvents::test_put_events_without_source": { + "last_validated_date": "2025-01-08T15:24:06+00:00" + } +} diff --git a/tests/aws/services/events/test_events_cross_account_region.py b/tests/aws/services/events/test_events_cross_account_region.py new file mode 100644 index 0000000000000..5a7e8adb1837a --- /dev/null +++ b/tests/aws/services/events/test_events_cross_account_region.py @@ -0,0 +1,455 @@ +import json +import time + +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from tests.aws.services.events.helper_functions import ( + is_old_provider, + sqs_collect_messages, +) +from tests.aws.services.events.test_events import ( + TEST_EVENT_DETAIL, + TEST_EVENT_PATTERN_NO_SOURCE, +) + +SOURCE_PRIMARY = "source-primary" +SOURCE_SECONDARY = "source-secondary" + + +@markers.aws.validated +@pytest.mark.skipif(is_old_provider(), reason="Not supported in v1 provider") +@pytest.mark.parametrize("cross_scenario", ["region", "account", "region_account"]) +@pytest.mark.parametrize("event_bus_name", ["default", "custom"]) +def test_event_bus_to_event_bus_cross_account_region( + cross_scenario, + event_bus_name, + region_name, + account_id, + get_primary_secondary_client, + cleanups, + snapshot, +): + # Create aws clients + response = get_primary_secondary_client(cross_scenario) + aws_client = response["primary_aws_client"] + secondary_aws_client = response["secondary_aws_client"] + secondary_region_name = response["secondary_region_name"] + secondary_account_id = response["secondary_account_id"] + + # overwriting randomized region https://docs.localstack.cloud/contributing/multi-account-region-testing/ + # requires manually adding region replacement for snapshot + snapshot.add_transformer(snapshot.transform.regex(region_name, "")) + snapshot.add_transformer(snapshot.transform.regex(secondary_region_name, "")) + snapshot.add_transformer(snapshot.transform.regex(account_id, "")) + snapshot.add_transformer(snapshot.transform.regex(secondary_account_id, "")) + + # Create event buses + if event_bus_name == "default": + event_bus_name_primary = "default" + event_bus_name_secondary = "default" + event_bus_arn_secondary = ( + f"arn:aws:events:{secondary_region_name}:{secondary_account_id}:event-bus/default" + ) + if event_bus_name == "custom": + event_bus_name_primary = f"test-event-bus-primary-{short_uid()}" + aws_client.events.create_event_bus(Name=event_bus_name_primary)["EventBusArn"] + + event_bus_name_secondary = f"test-event-bus-secondary-{short_uid()}" + event_bus_arn_secondary = secondary_aws_client.events.create_event_bus( + Name=event_bus_name_secondary + )["EventBusArn"] + + # Permission for event bus in secondary region to receive events to event bus in primary region + secondary_aws_client.events.put_permission( + StatementId=f"SecondaryEventBusAccessPermission{short_uid()}", + EventBusName=event_bus_name_secondary, + Action="events:PutEvents", + Principal="*", + ) + + # Create SQS queues + queue_name_primary = f"test-queue-primary-{short_uid()}" + queue_url_primary = aws_client.sqs.create_queue(QueueName=queue_name_primary)["QueueUrl"] + queue_arn_primary = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url_primary, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + policy_events_sqs_primary = { + "Version": "2012-10-17", + "Id": f"sqs-eventbridge-{short_uid()}", + "Statement": [ + { + "Sid": f"SendMessage-{short_uid()}", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": queue_arn_primary, + } + ], + } + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url_primary, + Attributes={"Policy": json.dumps(policy_events_sqs_primary)}, + ) + + queue_name_secondary = f"test-queue-secondary-{short_uid()}" + queue_url_secondary = secondary_aws_client.sqs.create_queue(QueueName=queue_name_secondary)[ + "QueueUrl" + ] + queue_arn_secondary = secondary_aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url_secondary, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + policy_events_sqs_secondary = { + "Version": "2012-10-17", + "Id": f"sqs-eventbridge-{short_uid()}", + "Statement": [ + { + "Sid": f"SendMessage-{short_uid()}", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": queue_arn_secondary, + } + ], + } + secondary_aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url_secondary, + Attributes={"Policy": json.dumps(policy_events_sqs_secondary)}, + ) + + # Create rule in primary region + rule_name = f"test-rule-primary-sqs-{short_uid()}" + aws_client.events.put_rule( + Name=rule_name, + EventPattern=json.dumps({"source": [SOURCE_PRIMARY]}), + # EventPattern=json.dumps({"source": [SOURCE_PRIMARY], **TEST_EVENT_PATTERN_NO_SOURCE}), + EventBusName=event_bus_name_primary, + ) + + # Create target in primary region sqs + target_id_sqs_primary = f"test-target-primary-sqs-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name_primary, + Targets=[ + { + "Id": target_id_sqs_primary, + "Arn": queue_arn_primary, + } + ], + ) + + # Create permission for event bus in primary region to send events to event bus in secondary region + role_name_bus_primary_to_bus_secondary = f"event-bus-primary-to-secondary-role-{short_uid()}" + assume_role_policy_document_bus_primary_to_bus_secondary = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + role_arn_bus_primary_to_bus_secondary = aws_client.iam.create_role( + RoleName=role_name_bus_primary_to_bus_secondary, + AssumeRolePolicyDocument=json.dumps( + assume_role_policy_document_bus_primary_to_bus_secondary + ), + )["Role"]["Arn"] + + policy_name_bus_primary_to_bus_secondary = ( + f"event-bus-primary-to-secondary-policy-{short_uid()}" + ) + policy_document_bus_primary_to_bus_secondary = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": "events:PutEvents", + "Resource": "arn:aws:events:*:*:event-bus/*", + } + ], + } + + aws_client.iam.put_role_policy( + RoleName=role_name_bus_primary_to_bus_secondary, + PolicyName=policy_name_bus_primary_to_bus_secondary, + PolicyDocument=json.dumps(policy_document_bus_primary_to_bus_secondary), + ) + + if is_aws_cloud(): + time.sleep(10) + + # Create target in primary region event bus secondary region + target_id_event_bus_secondary = f"test-target-primary-events-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name_primary, + Targets=[ + { + "Id": target_id_event_bus_secondary, + "Arn": event_bus_arn_secondary, + "RoleArn": role_arn_bus_primary_to_bus_secondary, + } + ], + ) + + # Create rule in secondary region + rule_name_secondary = f"test-rule-secondary-sqs-{short_uid()}" + secondary_aws_client.events.put_rule( + Name=rule_name_secondary, + EventPattern=json.dumps({"source": [SOURCE_PRIMARY, SOURCE_SECONDARY]}), + EventBusName=event_bus_name_secondary, + ) + cleanups.append(lambda: secondary_aws_client.events.delete_rule(Name=rule_name_secondary)) + + # Create target in secondary region sqs + target_id_sqs_secondary = f"test-target-secondary-{short_uid()}" + secondary_aws_client.events.put_targets( + Rule=rule_name_secondary, + EventBusName=event_bus_name_secondary, + Targets=[ + { + "Id": target_id_sqs_secondary, + "Arn": queue_arn_secondary, + } + ], + ) + + ###### + # Test + ###### + + # Put events into primary event bus + aws_client.events.put_events( + Entries=[ + { + "Source": SOURCE_PRIMARY, + "DetailType": TEST_EVENT_PATTERN_NO_SOURCE["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + "EventBusName": event_bus_name_primary, + } + ], + ) + + # Collect messages from primary queue + messages_primary = sqs_collect_messages( + aws_client, queue_url_primary, expected_events_count=1, wait_time=1, retries=5 + ) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + ], + ) + snapshot.match("messages_primary_queue_from_primary_event_bus", messages_primary) + + # # Collect messages from secondary queue + messages_secondary = sqs_collect_messages( + secondary_aws_client, + queue_url_secondary, + expected_events_count=1, + wait_time=1, + retries=5, + ) + snapshot.match("messages_secondary_queue_from_primary_event_bus", messages_secondary) + + # Put events into secondary event bus + secondary_aws_client.events.put_events( + Entries=[ + { + "Source": SOURCE_SECONDARY, + "DetailType": TEST_EVENT_PATTERN_NO_SOURCE["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + "EventBusName": event_bus_name_secondary, + } + ], + ) + + # Collect messages from secondary queue + messages_secondary = sqs_collect_messages( + secondary_aws_client, + queue_url_secondary, + expected_events_count=1, + wait_time=1, + retries=5, + ) + snapshot.match("messages_secondary_queue_from_secondary_event_bus", messages_secondary) + + # Custom cleanup + aws_client.events.remove_targets( + Rule=rule_name, + EventBusName=event_bus_name_primary, + Ids=[target_id_sqs_primary, target_id_event_bus_secondary], + ) + aws_client.events.delete_rule(EventBusName=event_bus_name_primary, Name=rule_name) + try: + aws_client.events.delete_event_bus( + Name=event_bus_name_primary + ) # default bus cannot be deleted + except Exception: + pass + + secondary_aws_client.events.remove_targets( + Rule=rule_name_secondary, + EventBusName=event_bus_name_secondary, + Ids=[target_id_sqs_secondary], + ) + secondary_aws_client.events.delete_rule( + EventBusName=event_bus_name_secondary, Name=rule_name_secondary + ) + try: + secondary_aws_client.events.delete_event_bus( + Name=event_bus_name_secondary + ) # default bus cannot be deleted + except Exception: + pass + + aws_client.sqs.delete_queue(QueueUrl=queue_url_primary) + secondary_aws_client.sqs.delete_queue(QueueUrl=queue_url_secondary) + + aws_client.iam.delete_role_policy( + RoleName=role_name_bus_primary_to_bus_secondary, + PolicyName=policy_name_bus_primary_to_bus_secondary, + ) + aws_client.iam.delete_role(RoleName=role_name_bus_primary_to_bus_secondary) + + +class TestEventsCrossAccountRegion: + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="Not supported in v1 provider") + @pytest.mark.parametrize("cross_scenario", ["account"]) + @pytest.mark.parametrize("event_bus_type", ["custom"]) + def test_put_events( + self, + cross_scenario, + event_bus_type, + region_name, + secondary_region_name, + account_id, + secondary_account_id, + get_primary_secondary_client, + snapshot, + ): + # Create aws clients + response = get_primary_secondary_client(cross_scenario) + aws_client = response["primary_aws_client"] + secondary_aws_client = response["secondary_aws_client"] + secondary_region_name = response["secondary_region_name"] + secondary_account_id = response["secondary_account_id"] + + # overwriting randomized region https://docs.localstack.cloud/contributing/multi-account-region-testing/ + # requires manually adding region replacement for snapshot + snapshot.add_transformer(snapshot.transform.regex(region_name, "")) + snapshot.add_transformer( + snapshot.transform.regex(secondary_region_name, "") + ) + snapshot.add_transformer(snapshot.transform.regex(account_id, "")) + snapshot.add_transformer( + snapshot.transform.regex(secondary_account_id, "") + ) + + # Create event bus in secondary region / account + if event_bus_type == "default": + event_bus_name = "default" + event_bus_arn = f"arn:aws:events:{region_name}:{account_id}:event-bus/default" + else: + event_bus_name = f"test-bus-{short_uid()}" + response = secondary_aws_client.events.create_event_bus(Name=event_bus_name) + event_bus_arn = response["EventBusArn"] + + # Allow principal to put event to event bus + secondary_aws_client.events.put_permission( + StatementId=f"TargetEventBusAccessPermission{short_uid()}", + EventBusName=event_bus_name, + Action="events:PutEvents", + Principal="*", + ) + + # Create SQS queue in secondary region / account + queue_name = f"test-queue-{short_uid()}" + queue_url = secondary_aws_client.sqs.create_queue(QueueName=queue_name)["QueueUrl"] + queue_arn = secondary_aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + policy = { + "Version": "2012-10-17", + "Id": f"sqs-eventbridge-{short_uid()}", + "Statement": [ + { + "Sid": f"SendMessage-{short_uid()}", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": queue_arn, + } + ], + } + secondary_aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url, Attributes={"Policy": json.dumps(policy)} + ) + + # Create rule in secondary region / account + rule_name = f"test-rule-sqs-{short_uid()}" + secondary_aws_client.events.put_rule( + Name=rule_name, + EventPattern=json.dumps({"source": [SOURCE_PRIMARY]}), + EventBusName=event_bus_name, + ) + + # Create target in secondary region / account + target_id_sqs_secondary = f"test-target-primary-sqs-{short_uid()}" + secondary_aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[ + { + "Id": target_id_sqs_secondary, + "Arn": queue_arn, + } + ], + ) + + ###### + # Test + ###### + + aws_client.events.put_events( + Entries=[ + { + "Source": SOURCE_PRIMARY, + "DetailType": TEST_EVENT_PATTERN_NO_SOURCE["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + "EventBusName": event_bus_arn, # using arn for cross region / cross account + } + ], + ) + + messages = sqs_collect_messages( + secondary_aws_client, + queue_url, + expected_events_count=1, + wait_time=1, + retries=5, + ) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + ], + ) + snapshot.match("messages", messages) + + # Cleanup + secondary_aws_client.events.remove_targets( + Rule=rule_name, EventBusName=event_bus_name, Ids=[target_id_sqs_secondary] + ) + secondary_aws_client.events.delete_rule(EventBusName=event_bus_name, Name=rule_name) + if event_bus_type == "custom": + secondary_aws_client.events.delete_event_bus(Name=event_bus_name) + secondary_aws_client.sqs.delete_queue(QueueUrl=queue_url) diff --git a/tests/aws/services/events/test_events_cross_account_region.snapshot.json b/tests/aws/services/events/test_events_cross_account_region.snapshot.json new file mode 100644 index 0000000000000..9cc89bc70bedc --- /dev/null +++ b/tests/aws/services/events/test_events_cross_account_region.snapshot.json @@ -0,0 +1,493 @@ +{ + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-region]": { + "recorded-date": "14-06-2024, 11:29:49", + "recorded-content": { + "messages_primary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_secondary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-secondary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-account]": { + "recorded-date": "14-06-2024, 11:30:09", + "recorded-content": { + "messages_primary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_secondary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-secondary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-region_account]": { + "recorded-date": "14-06-2024, 11:31:05", + "recorded-content": { + "messages_primary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_secondary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-secondary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-region]": { + "recorded-date": "14-06-2024, 11:13:16", + "recorded-content": { + "messages_primary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_secondary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-secondary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-account]": { + "recorded-date": "14-06-2024, 11:13:34", + "recorded-content": { + "messages_primary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_secondary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-secondary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-region_account]": { + "recorded-date": "14-06-2024, 11:20:57", + "recorded-content": { + "messages_primary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_primary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ], + "messages_secondary_queue_from_secondary_event_bus": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-secondary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_cross_account_region.py::TestEventsCrossAccountRegion::test_put_events[custom-account]": { + "recorded-date": "18-07-2024, 07:45:08", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "source-primary", + "account": "", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + } +} diff --git a/tests/aws/services/events/test_events_cross_account_region.validation.json b/tests/aws/services/events/test_events_cross_account_region.validation.json new file mode 100644 index 0000000000000..b2f9fe91ceefe --- /dev/null +++ b/tests/aws/services/events/test_events_cross_account_region.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/events/test_events_cross_account_region.py::TestEventsCrossAccountRegion::test_put_events[custom-account]": { + "last_validated_date": "2024-07-18T07:45:08+00:00" + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-account]": { + "last_validated_date": "2024-06-14T11:13:34+00:00" + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-region]": { + "last_validated_date": "2024-06-14T11:13:16+00:00" + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[custom-region_account]": { + "last_validated_date": "2024-06-14T11:20:57+00:00" + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-account]": { + "last_validated_date": "2024-06-14T11:30:09+00:00" + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-region]": { + "last_validated_date": "2024-06-14T11:29:49+00:00" + }, + "tests/aws/services/events/test_events_cross_account_region.py::test_event_bus_to_event_bus_cross_account_region[default-region_account]": { + "last_validated_date": "2024-06-14T11:31:05+00:00" + } +} diff --git a/tests/aws/services/events/test_events_inputs.py b/tests/aws/services/events/test_events_inputs.py new file mode 100644 index 0000000000000..65e225a460c87 --- /dev/null +++ b/tests/aws/services/events/test_events_inputs.py @@ -0,0 +1,594 @@ +"""Tests for input path and input transformer in AWS EventBridge.""" + +import json + +import pytest +from botocore.client import Config +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from tests.aws.services.events.helper_functions import ( + is_old_provider, + sqs_collect_messages, +) +from tests.aws.services.events.test_events import ( + SPECIAL_EVENT_DETAIL, + TEST_EVENT_DETAIL, + TEST_EVENT_PATTERN, +) + +EVENT_DETAIL_DUPLICATED_KEY = { + "command": "update-account", + "payload": {"acc_id": "0a787ecb-4015", "payload": {"message": "baz", "id": "123"}}, +} + + +INPUT_TEMPLATE_PREDEFINE_VARIABLES_STR = '"Message containing all pre defined variables "' +INPUT_TEMPLATE_PREDEFINED_VARIABLES_JSON = '{"originalEvent": , "originalEventJson": }' # important to not quote the predefined variables + + +@markers.aws.validated +@pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", +) +def test_put_event_input_path_and_input_transformer( + sqs_as_events_target, events_create_event_bus, events_put_rule, aws_client, snapshot +): + _, queue_arn = sqs_as_events_target() + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"test-rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + target_id = f"target-{short_uid()}" + input_path_map = { + "detail-type": "$.detail-type", + "timestamp": "$.time", + "command": "$.detail.command", + } + input_template = '{"detailType": , "time": , "command": }' + input_transformer = { + "InputPathsMap": input_path_map, + "InputTemplate": input_template, + } + with pytest.raises(ClientError) as exception: + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + { + "Id": target_id, + "Arn": queue_arn, + "InputTransformer": input_transformer, + "InputPath": "$.detail", + }, + ], + ) + + snapshot.add_transformer(snapshot.transform.regex(target_id, "")) + snapshot.match("duplicated-input-operations-error", exception.value.response) + + +class TestInputPath: + @markers.aws.validated + def test_put_events_with_input_path(self, put_events_with_filter_to_sqs, snapshot): + entries1 = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + entries_asserts = [(entries1, True)] + messages = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_path="$.detail", + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("message", messages) + + @markers.aws.validated + @pytest.mark.parametrize("event_detail", [TEST_EVENT_DETAIL, EVENT_DETAIL_DUPLICATED_KEY]) + def test_put_events_with_input_path_nested( + self, event_detail, put_events_with_filter_to_sqs, snapshot + ): + entries1 = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(event_detail), + } + ] + entries_asserts = [(entries1, True)] + messages = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_path="$.detail.payload", + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("message", messages) + + @markers.aws.validated + def test_put_events_with_input_path_max_level_depth( + self, put_events_with_filter_to_sqs, snapshot + ): + entries1 = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + entries_asserts = [(entries1, True)] + messages = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_path="$.detail.payload.sf_id", + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("message", messages) + + @markers.aws.validated + def test_put_events_with_input_path_multiple_targets( + self, + aws_client, + sqs_as_events_target, + events_create_event_bus, + events_put_rule, + snapshot, + ): + # prepare target queues + queue_url_1, queue_arn_1 = sqs_as_events_target() + queue_url_2, queue_arn_2 = sqs_as_events_target() + + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"test-rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + target_id_1 = f"target-{short_uid()}" + target_id_2 = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + {"Id": target_id_1, "Arn": queue_arn_1, "InputPath": "$.detail"}, + {"Id": target_id_2, "Arn": queue_arn_2}, + ], + ) + + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + messages_queue_1 = sqs_collect_messages( + aws_client, queue_url_1, expected_events_count=1, retries=3 + ) + messages_queue_2 = sqs_collect_messages( + aws_client, queue_url_2, expected_events_count=1, retries=3 + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("message-queue-1", messages_queue_1) + snapshot.match("message-queue-2", messages_queue_2) + + +class TestInputTransformer: + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize( + "input_template", + [ + '"Event of type, at time , info extracted from detail "', + '"{[/Check with special starting characters for event of type"', + ], + ) + def test_put_events_with_input_transformer_input_template_string( + self, input_template, put_events_with_filter_to_sqs, snapshot + ): + entries = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + entries_asserts = [(entries, True)] + + # input transformer with all keys in template present in message + input_path_map = { + "detail-type": "$.detail-type", + "timestamp": "$.time", + "command": "$.detail.command", + } + input_template = input_template + input_transformer_match_all = { + "InputPathsMap": input_path_map, + "InputTemplate": input_template, + } + messages_match_all = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_transformer=input_transformer_match_all, + ) + + # input transformer with keys in template missing from message + input_path_map_missing_key = { + "detail-type": "$.detail-type", + "timestamp": "$.time", + "command": "$.detail.notinmessage", + } + input_transformer_not_match_all = { + "InputPathsMap": input_path_map_missing_key, + "InputTemplate": input_template, + } + messages_not_match_all = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_transformer=input_transformer_not_match_all, + ) + + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("custom-variables-match-all", messages_match_all) + snapshot.match("custom-variables-not-match-all", messages_not_match_all) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_events_with_input_transformer_input_template_json( + self, put_events_with_filter_to_sqs, snapshot + ): + entries = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + entries_asserts = [(entries, True)] + + # input transformer with all keys in template present in message + input_path_map = { + "detail-type": "$.detail-type", + "timestamp": "$.time", + "command": "$.detail.command", + } + input_template = '{"detailType": , "time": , "command": }' + input_transformer_match_all = { + "InputPathsMap": input_path_map, + "InputTemplate": input_template, + } + messages_match_all = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_transformer=input_transformer_match_all, + ) + + # input transformer with keys in template missing from message + input_path_map_missing_key = { + "detail-type": "$.detail-type", + "timestamp": "$.time", + "command": "$.detail.notinmessage", + } + input_transformer_not_match_all = { + "InputPathsMap": input_path_map_missing_key, + "InputTemplate": input_template, + } + messages_not_match_all = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_transformer=input_transformer_not_match_all, + ) + + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("custom-variables-match-all", messages_match_all) + snapshot.match("custom-variables-not-match-all", messages_not_match_all) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_put_events_with_input_transformer_missing_keys( + self, + sqs_as_events_target, + events_create_event_bus, + events_put_rule, + aws_client_factory, + snapshot, + ): + _, queue_arn = sqs_as_events_target() + + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"test-rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + events_client = aws_client_factory(config=Config(parameter_validation=False)).events + target_id = f"target-{short_uid()}" + input_path_map = { + "detail-type": "$.detail-type", + "timestamp": "$.time", + "command": "$.detail.command", + } + # input template with not defined key + input_template = '"Event of type, with not defined key "' + input_transformer = { + "InputPathsMap": input_path_map, + "InputTemplate": input_template, + } + + with pytest.raises(ClientError) as exception: + events_client.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputTransformer": input_transformer}, + ], + ) + + snapshot.add_transformer(snapshot.transform.regex(target_id, "")) + snapshot.match("missing-key-exception-error", exception.value.response) + + # TODO test wrong input template + # '{"userId": "users//profile/"}', + # ("prefix__suffix",) + # ("multi_replacement/users//second/",) + # "abc: ", + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize( + "input_template", + [INPUT_TEMPLATE_PREDEFINE_VARIABLES_STR, INPUT_TEMPLATE_PREDEFINED_VARIABLES_JSON], + ) + # Todo deal with + # "instance": "$.detail.resources[0].id", + # "platform": "$.detail.resources[0].details.awsEc2Instance.platform", + # "region": "$.detail.resources[0].region", + def test_input_transformer_predefined_variables( + self, + input_template, + sqs_as_events_target, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + ): + # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-transform-target-input.html#eb-transform-input-predefined + + # prepare target queues + queue_url, queue_arn = sqs_as_events_target() + + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"test-rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + # no input path map required for predefined variables + input_transformer = { + "InputTemplate": input_template, + } + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn, "InputTransformer": input_transformer}, + ], + ) + + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + messages = sqs_collect_messages(aws_client, queue_url, expected_events_count=1, retries=3) + + snapshot.add_transformer( + [ + snapshot.transform.regex(bus_name, ""), + snapshot.transform.regex(rule_name, ""), + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.jsonpath( + "$.messages[*].Body.originalEvent.time", + value_replacement="", + reference_replacement=False, + ), + ] + ) + snapshot.match("messages", messages) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize( + "input_template", + [ + '{"method": "PUT", "path": "users-service/users/", "bod": }', + '"Payload of with path users-service/users/ and "', + '{"id" : }', + '{"id" : ""}', + '{"method": "PUT", "path": "users-service/users/", "id": , "body": }', + '{"method": "PUT", "path": "users-service/users/", "bod": [, "hardcoded"]}', + '{"method": "PUT", "nested": {"level1": {"level2": {"level3": "users-service/users/"} } }, "bod": ""}', + '" single list item"', + '" multiple list items"', + '{"singlelistitem": }', + '" single list item multiple list items system account id payload user id"', + '{"multi_replacement": "users//second/"}', + # TODO known limitation due to sqs message handling sting with new line + # '" single list item\n multiple list items"', + ], + ) + def test_input_transformer_nested_keys_replacement( + self, + input_template, + put_events_with_filter_to_sqs, + snapshot, + ): + """ + Mapping a nested key via input path map e.g. + "userId" : "$.detail.id" maped to "users-service/users/" + replacement values that are valid json strings cannot be placed in quotes in the input template + since this will result in a non valid json string + """ + entries = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(SPECIAL_EVENT_DETAIL), + } + ] + entries_asserts = [(entries, True)] + + input_path_map = { + "userId": "$.detail.payload.acc_id", + "payload": "$.detail.payload", + "systemstring": "$.detail.awsAccountId", # with resolve to empty value + "listsingle": "$.detail.listsingle", + "listmulti": "$.detail.listmulti", + } + input_transformer = { + "InputPathsMap": input_path_map, + "InputTemplate": input_template, + } + messages = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_transformer=input_transformer, + ) + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + ] + ) + snapshot.match("input-transformed-messages", messages) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + @pytest.mark.parametrize( + "input_template", + [ + '{"not_valid": "users-service/users/", "bod": }', + '{"payload": ""}', # json value must not be enclosed in quotes + '{"singlelistitem": ""}', # list value must not be enclosed in quotes + ], + ) + def test_input_transformer_nested_keys_replacement_not_valid( + self, + input_template, + put_events_with_filter_to_sqs, + ): + """ + Mapping a nested key via input path map must be a valid string or json + else it will be silently ignored + """ + entries = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(SPECIAL_EVENT_DETAIL), + } + ] + entries_asserts = [(entries, False)] + + input_path_map = { + "userId": "$.detail.payload.acc_id", + "payload": "$.detail.payload", + "listsingle": "$.detail.listsingle", + } + input_transformer = { + "InputPathsMap": input_path_map, + "InputTemplate": input_template, + } + put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=entries_asserts, + input_transformer=input_transformer, + ) diff --git a/tests/aws/services/events/test_events_inputs.snapshot.json b/tests/aws/services/events/test_events_inputs.snapshot.json new file mode 100644 index 0000000000000..cf6ee9653ff58 --- /dev/null +++ b/tests/aws/services/events/test_events_inputs.snapshot.json @@ -0,0 +1,515 @@ +{ + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path": { + "recorded-date": "13-05-2024, 12:27:07", + "recorded-content": { + "message": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_nested[event_detail0]": { + "recorded-date": "13-05-2024, 12:27:09", + "recorded-content": { + "message": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_nested[event_detail1]": { + "recorded-date": "13-05-2024, 12:27:11", + "recorded-content": { + "message": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "acc_id": "0a787ecb-4015", + "payload": { + "message": "baz", + "id": "123" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_max_level_depth": { + "recorded-date": "13-05-2024, 12:27:13", + "recorded-content": { + "message": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"baz\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_multiple_targets": { + "recorded-date": "13-05-2024, 12:27:16", + "recorded-content": { + "message-queue-1": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ], + "message-queue-2": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string": { + "recorded-date": "13-05-2024, 12:27:20", + "recorded-content": { + "custom-variables-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"Event of core.update-account-command type, at time date, info extracted from detail update-account\"" + } + ], + "custom-variables-not-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"Event of core.update-account-command type, at time date, info extracted from detail \"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_json": { + "recorded-date": "11-06-2024, 08:33:04", + "recorded-content": { + "custom-variables-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "detailType": "core.update-account-command", + "time": "date", + "command": "update-account" + } + } + ], + "custom-variables-not-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "detailType": "core.update-account-command", + "time": "date", + "command": "" + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_missing_keys": { + "recorded-date": "12-03-2025, 10:19:13", + "recorded-content": { + "missing-key-exception-error": { + "Error": { + "Code": "ValidationException", + "Message": "InputTemplate for target contains invalid placeholder notdefinedkey." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_predefined_variables[\"Message containing all pre defined variables \"]": { + "recorded-date": "11-06-2024, 08:33:10", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"Message containing all pre defined variables arn::events::111111111111:rule// date\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_predefined_variables[{\"originalEvent\": , \"originalEventJson\": }]": { + "recorded-date": "11-06-2024, 08:33:13", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "originalEvent": { + "id": "", + "account": "111111111111", + "detailType": "core.update-account-command", + "time": "", + "source": "core.update-account-command", + "region": "", + "resources": [], + "version": "0" + }, + "originalEventJson": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::test_put_event_input_path_and_input_transformer": { + "recorded-date": "12-03-2025, 10:19:01", + "recorded-content": { + "duplicated-input-operations-error": { + "Error": { + "Code": "ValidationException", + "Message": "Only one of Input, InputPath, or InputTransformer must be provided for target ." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string[\"Event of type, at time , info extracted from detail \"]": { + "recorded-date": "11-06-2024, 08:32:56", + "recorded-content": { + "custom-variables-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"Event of core.update-account-command type, at time date, info extracted from detail update-account\"" + } + ], + "custom-variables-not-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"Event of core.update-account-command type, at time date, info extracted from detail \"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string[\"{[/Check with special starting characters for event of type\"]": { + "recorded-date": "11-06-2024, 08:33:00", + "recorded-content": { + "custom-variables-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"{[/Check with special starting characters for event of core.update-account-command type\"" + } + ], + "custom-variables-not-match-all": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"{[/Check with special starting characters for event of core.update-account-command type\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"bod\": }]": { + "recorded-date": "13-12-2024, 18:03:12", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "method": "PUT", + "path": "users-service/users/0a787ecb-4015", + "bod": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\"Payload of with path users-service/users/ and \"]": { + "recorded-date": "13-12-2024, 18:03:14", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"Payload of {acc_id:0a787ecb-4015,sf_id:baz} with path users-service/users/0a787ecb-4015 and 0a787ecb-4015\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"id\": , \"body\": }]": { + "recorded-date": "13-12-2024, 18:03:18", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "method": "PUT", + "path": "users-service/users/0a787ecb-4015", + "id": "0a787ecb-4015", + "body": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"bod\": [, \"hardcoded\"]}]": { + "recorded-date": "13-12-2024, 18:03:21", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "method": "PUT", + "path": "users-service/users/0a787ecb-4015", + "bod": [ + "0a787ecb-4015", + "hardcoded" + ] + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"nested\": {\"level1\": {\"level2\": {\"level3\": \"users-service/users/\"} } }, \"bod\": \"\"}]": { + "recorded-date": "13-12-2024, 18:03:23", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "method": "PUT", + "nested": { + "level1": { + "level2": { + "level3": "users-service/users/0a787ecb-4015" + } + } + }, + "bod": "0a787ecb-4015" + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" single list item\"\\n\" multiple list items\"\\n\" system account id\"\\n\" payload\"\\n\" user id\"]": { + "recorded-date": "13-12-2024, 17:27:50", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"[HIGH] single list item\"\n\"[ACTIVE,INACTIVE] multiple list items\"\n\" system account id\"\n\"{acc_id:0a787ecb-4015,sf_id:baz} payload\"\n\"0a787ecb-4015 user id\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"id\" : }]": { + "recorded-date": "13-12-2024, 18:03:16", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "id": "0a787ecb-4015" + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[ single list item]": { + "recorded-date": "13-12-2024, 17:13:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[ multiple list items]": { + "recorded-date": "13-12-2024, 17:13:36", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" single list item\"]": { + "recorded-date": "13-12-2024, 18:03:25", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"[HIGH] single list item\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" multiple list items\"]": { + "recorded-date": "13-12-2024, 18:03:28", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"[ACTIVE,INACTIVE] multiple list items\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"singlelistitem\": \"\", \"multiplelistitems\": \"\"}]": { + "recorded-date": "13-12-2024, 17:15:23", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"singlelistitem\": \"\"}]": { + "recorded-date": "13-12-2024, 17:16:48", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"singlelistitem\": }]": { + "recorded-date": "13-12-2024, 18:03:30", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "singlelistitem": [ + "HIGH" + ] + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" single list item multiple list items system account id payload user id\"]": { + "recorded-date": "13-12-2024, 18:03:32", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "\"[HIGH] single list item [ACTIVE,INACTIVE] multiple list items system account id {acc_id:0a787ecb-4015,sf_id:baz} payload 0a787ecb-4015 user id\"" + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"multi_replacement\": \"users//second/\"}]": { + "recorded-date": "13-12-2024, 18:03:35", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "multi_replacement": "users/0a787ecb-4015/second/0a787ecb-4015" + } + } + ] + } + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"id\" : \"\"}]": { + "recorded-date": "16-12-2024, 12:26:02", + "recorded-content": { + "input-transformed-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "id": "0a787ecb-4015" + } + } + ] + } + } +} diff --git a/tests/aws/services/events/test_events_inputs.validation.json b/tests/aws/services/events/test_events_inputs.validation.json new file mode 100644 index 0000000000000..7a2e137c1e527 --- /dev/null +++ b/tests/aws/services/events/test_events_inputs.validation.json @@ -0,0 +1,104 @@ +{ + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path": { + "last_validated_date": "2024-05-13T12:27:07+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_max_level_depth": { + "last_validated_date": "2024-05-13T12:27:13+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_multiple_targets": { + "last_validated_date": "2024-05-13T12:27:16+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_nested[event_detail0]": { + "last_validated_date": "2024-05-13T12:27:09+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputPath::test_put_events_with_input_path_nested[event_detail1]": { + "last_validated_date": "2024-05-13T12:27:11+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement": { + "last_validated_date": "2024-12-06T11:07:17+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" multiple list items\"]": { + "last_validated_date": "2024-12-13T18:03:28+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" single list item multiple list items system account id payload user id\"]": { + "last_validated_date": "2024-12-13T18:03:32+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" single list item\"\\n\" multiple list items\"\\n\" system account id\"\\n\" payload\"\\n\" user id\"]": { + "last_validated_date": "2024-12-13T17:27:50+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\" single list item\"]": { + "last_validated_date": "2024-12-13T18:03:25+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\"Payload of with path users-service/users/ and \"]": { + "last_validated_date": "2024-12-13T18:03:14+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[\"Payload of with path users-service/users/\"]": { + "last_validated_date": "2024-12-13T13:20:30+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"id\" : \"\"}]": { + "last_validated_date": "2024-12-16T12:26:02+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"id\" : }]": { + "last_validated_date": "2024-12-13T18:03:16+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"id\": }]": { + "last_validated_date": "2024-12-13T14:56:24+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"nested\": {\"level1\": {\"level2\": {\"level3\": \"users-service/users/\"} } }, \"bod\": \"\"}]": { + "last_validated_date": "2024-12-13T18:03:23+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"bod\": \"\"}]": { + "last_validated_date": "2024-12-13T13:20:32+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"bod\": }]": { + "last_validated_date": "2024-12-13T18:03:12+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"bod\": [, \"hardcoded\"]}]": { + "last_validated_date": "2024-12-13T18:03:21+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"id\": \"\", \"body\": }]": { + "last_validated_date": "2024-12-13T14:54:39+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"method\": \"PUT\", \"path\": \"users-service/users/\", \"id\": , \"body\": }]": { + "last_validated_date": "2024-12-13T18:03:18+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"multi_replacement\": \"users//second/\"}]": { + "last_validated_date": "2024-12-13T18:03:35+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement[{\"singlelistitem\": }]": { + "last_validated_date": "2024-12-13T18:03:30+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement_not_valid[{\"not_valid\": \"users-service/users/\", \"bod\": }]": { + "last_validated_date": "2024-12-13T14:55:05+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement_not_valid[{\"payload\": \"\"}]": { + "last_validated_date": "2024-12-13T14:55:13+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_nested_keys_replacement_not_valid[{\"singlelistitem\": \"\"}]": { + "last_validated_date": "2024-12-13T17:19:20+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_predefined_variables[\"Message containing all pre defined variables \"]": { + "last_validated_date": "2024-06-11T08:33:10+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_input_transformer_predefined_variables[{\"originalEvent\": , \"originalEventJson\": }]": { + "last_validated_date": "2024-06-11T08:33:13+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_json": { + "last_validated_date": "2024-06-11T08:33:04+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string": { + "last_validated_date": "2024-05-13T12:27:20+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string[\"Event of type, at time , info extracted from detail \"]": { + "last_validated_date": "2024-06-11T08:32:56+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_input_template_string[\"{[/Check with special starting characters for event of type\"]": { + "last_validated_date": "2024-06-11T08:33:00+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::TestInputTransformer::test_put_events_with_input_transformer_missing_keys": { + "last_validated_date": "2025-03-12T10:19:13+00:00" + }, + "tests/aws/services/events/test_events_inputs.py::test_put_event_input_path_and_input_transformer": { + "last_validated_date": "2025-03-12T10:19:01+00:00" + } +} diff --git a/tests/aws/services/events/test_events_patterns.py b/tests/aws/services/events/test_events_patterns.py new file mode 100644 index 0000000000000..a8af3d5cc1a8b --- /dev/null +++ b/tests/aws/services/events/test_events_patterns.py @@ -0,0 +1,538 @@ +import copy +import json +import os +from datetime import datetime +from pathlib import Path +from typing import List, Tuple + +import json5 +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid +from localstack.utils.files import load_file +from tests.aws.services.events.helper_functions import ( + is_old_provider, + sqs_collect_messages, +) + +THIS_FOLDER: str = os.path.dirname(os.path.realpath(__file__)) +REQUEST_TEMPLATE_DIR = os.path.join(THIS_FOLDER, "event_pattern_templates") +COMPLEX_MULTI_KEY_EVENT_PATTERN = os.path.join( + REQUEST_TEMPLATE_DIR, "complex_multi_key_event_pattern.json" +) +COMPLEX_MULTI_KEY_EVENT = os.path.join(REQUEST_TEMPLATE_DIR, "complex_multi_key_event.json") +TEST_PAYLOAD_DIR = os.path.join(THIS_FOLDER, "test_payloads") + + +def load_request_templates(directory_path: str) -> List[Tuple[dict, str]]: + json5_files = list_files_with_suffix(directory_path, ".json5") + return [load_request_template(file_path) for file_path in json5_files] + + +def load_request_template(file_path: str) -> Tuple[dict, str]: + with open(file_path, "r") as df: + template = json5.load(df) + return template, Path(file_path).stem + + +def list_files_with_suffix(directory_path: str, suffix: str) -> List[str]: + files = [] + for root, _, filenames in os.walk(directory_path): + for filename in filenames: + if filename.endswith(suffix): + absolute_filepath = os.path.join(root, filename) + files.append(absolute_filepath) + + return files + + +request_template_tuples = load_request_templates(REQUEST_TEMPLATE_DIR) + + +class TestEventPattern: + # TODO: extend these test cases based on the open source docs + tests: https://github.com/aws/event-ruler + # For example, "JSON Array Matching", "And and Or Relationship among fields with Ruler", rule validation, + # and exception handling. + @pytest.mark.parametrize( + "request_template,label", + request_template_tuples, + ids=[t[1] for t in request_template_tuples], + ) + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..MessageRaw"], # AWS returns Java validation parts, we skip those + ) + def test_event_pattern(self, aws_client, snapshot, request_template, label): + """This parametrized test handles three outcomes: + a) MATCH (default): The EventPattern matches the Event yielding true as result. + b) NO MATCH (_NEG suffix): The EventPattern does NOT match the Event yielding false as result. + c) EXCEPTION (_EXC suffix): The EventPattern is invalid and raises an exception. + """ + + def _transform_raw_exc_message( + boto_error: dict[str, dict[str, str]], + ) -> dict[str, dict[str, str]]: + if message := boto_error.get("Error", {}).get("Message"): + boto_error = copy.deepcopy(boto_error) + boto_error["Error"]["MessageRaw"] = message + boto_error["Error"]["Message"] = message.split("\n")[0] + + return boto_error + + event = request_template["Event"] + event_pattern = request_template["EventPattern"] + + if label.endswith("_EXC"): + with pytest.raises(ClientError) as e: + aws_client.events.test_event_pattern( + Event=json.dumps(event), + EventPattern=json.dumps(event_pattern), + ) + exception_info = { + "exception_type": type(e.value), + "exception_message": _transform_raw_exc_message(e.value.response), + } + snapshot.match(label, exception_info) + else: + response = aws_client.events.test_event_pattern( + Event=json.dumps(event), + EventPattern=json.dumps(event_pattern), + ) + + # Validate the test intention: The _NEG suffix indicates negative tests + # (i.e., a pattern not matching the event) + if label.endswith("_NEG"): + assert not response["Result"] + else: + assert response["Result"] + + @markers.aws.validated + def test_event_pattern_with_multi_key(self, aws_client): + """Test the special case of a duplicate JSON key separately because it requires working around the + uniqueness constraints of the JSON5 library and Python dicts, which would already de-deduplicate the key "location". + This example is based on the following AWS documentation: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-complex-example + """ + + with ( + open(COMPLEX_MULTI_KEY_EVENT, "r") as event_file, + open(COMPLEX_MULTI_KEY_EVENT_PATTERN, "r") as event_pattern_file, + ): + event = event_file.read() + event_pattern = event_pattern_file.read() + + response = aws_client.events.test_event_pattern( + Event=event, + EventPattern=event_pattern, + ) + assert response["Result"] + + @markers.aws.validated + def test_event_pattern_with_escape_characters(self, aws_client): + r"""Test the special case of using escape characters separately because it requires working around JSON escaping. + Escape characters are explained in the AWS documentation: + https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-wildcard-matching + * "The string \* represents the literal * character" + * "The string \\ represents the literal \ character" + """ + + event = r'{"id": "1", "source": "test-source", "detail-type": "test-detail-type", "account": "123456789012", "region": "us-east-2", "time": "2022-07-13T13:48:01Z", "detail": {"escape_star": "*", "escape_backslash": "\\"}}' + # TODO: devise better testing strategy for * because the wildcard matches everything and "\\*" does not match. + event_pattern = r'{"detail": {"escape_star": ["*"], "escape_backslash": ["\\"]}}' + + response = aws_client.events.test_event_pattern( + Event=event, + EventPattern=event_pattern, + ) + assert response["Result"] + + @markers.aws.validated + def test_event_pattern_source(self, aws_client, snapshot, account_id, region_name): + response = aws_client.events.test_event_pattern( + Event=json.dumps( + { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": account_id, + "region": region_name, + "time": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + } + ), + EventPattern=json.dumps( + { + "source": ["order"], + "detail-type": ["Test"], + } + ), + ) + snapshot.match("eventbridge-test-event-pattern-response", response) + + # negative test, source is not matched + response = aws_client.events.test_event_pattern( + Event=json.dumps( + { + "id": "1", + "source": "order", + "detail-type": "Test", + "account": account_id, + "region": region_name, + "time": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + } + ), + EventPattern=json.dumps( + { + "source": ["shipment"], + "detail-type": ["Test"], + } + ), + ) + snapshot.match("eventbridge-test-event-pattern-response-no-match", response) + + @markers.aws.validated + @pytest.mark.parametrize( + "pattern", + [ + "this is valid json but not a dict", + "{'bad': 'quotation'}", + '{"not": closed mark"', + '["not", "a", "dict", "but valid json"]', + ], + ) + @markers.snapshot.skip_snapshot_verify( + # we cannot really validate the message, it is strongly coupled to AWS parsing engine + paths=["$..Error.Message"], + ) + def test_invalid_json_event_pattern(self, aws_client, pattern, snapshot): + event = '{"id": "1", "source": "test-source", "detail-type": "test-detail-type", "account": "123456789012", "region": "us-east-2", "time": "2022-07-13T13:48:01Z", "detail": {"test": "test"}}' + + with pytest.raises(ClientError) as e: + aws_client.events.test_event_pattern( + Event=event, + EventPattern=pattern, + ) + snapshot.match("invalid-pattern", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not properly validate", + ) + def test_plain_string_payload(self, aws_client, snapshot): + event = "plain string" + pattern = {"body": {"test2": [{"numeric": [">", 100]}]}} + + with pytest.raises(ClientError) as e: + aws_client.events.test_event_pattern( + Event=event, + EventPattern=json.dumps(pattern), + ) + snapshot.match("plain-string-payload-exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not properly validate", + ) + def test_array_event_payload(self, aws_client, snapshot): + event = ["plain string"] + pattern = {"body": {"test2": [{"numeric": [">", 100]}]}} + + with pytest.raises(ClientError) as e: + aws_client.events.test_event_pattern( + Event=json.dumps(event), + EventPattern=json.dumps(pattern), + ) + snapshot.match("array-event-payload-exc", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not properly validate", + ) + def test_invalid_event_payload(self, aws_client, snapshot): + # following fields are mandatory: `id`, `account`, `source`, `time`, `region`, `detail-type` + event = {"testEvent": "value"} + pattern = {"body": {"test2": [{"numeric": [">", 100]}]}} + + with pytest.raises(ClientError) as e: + aws_client.events.test_event_pattern( + Event=json.dumps(event), + EventPattern=json.dumps(pattern), + ) + snapshot.match("plain-string-payload-exc", e.value.response) + + @markers.aws.validated + def test_event_with_large_and_complex_payload(self, aws_client, snapshot): + event_file_path = os.path.join(TEST_PAYLOAD_DIR, "large_complex_payload.json") + event = load_file(event_file_path) + + simple_pattern = {"detail-type": ["cmd.documents.generate"]} + response = aws_client.events.test_event_pattern( + Event=event, + EventPattern=json.dumps(simple_pattern), + ) + snapshot.match("complex-event-simple-pattern", response) + + complex_pattern = { + "detail": {"payload.nested.another-level.deep": {"inside-list": [{"prefix": "q-test"}]}} + } + response = aws_client.events.test_event_pattern( + Event=event, + EventPattern=json.dumps(complex_pattern), + ) + snapshot.match("complex-event-complex-pattern", response) + + +class TestRuleWithPattern: + @markers.aws.validated + def test_put_events_with_rule_pattern_anything_but( + self, put_events_with_filter_to_sqs, snapshot + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.jsonpath("$..EventBusName", "event-bus-name"), + ] + ) + + event_detail_match = {"command": "display-message", "payload": "baz"} + event_detail_null = {"command": None, "payload": "baz"} + event_detail_no_match = {"command": "no-message", "payload": "baz"} + test_event_pattern_anything_but = { + "source": ["core.update-account-command"], + "detail-type": ["core.update-account-command"], + "detail": {"command": [{"anything-but": ["no-message"]}]}, + } + entries_match = [ + { + "Source": test_event_pattern_anything_but["source"][0], + "DetailType": test_event_pattern_anything_but["detail-type"][0], + "Detail": json.dumps(event_detail_match), + } + ] + entries_match_null = [ + { + "Source": test_event_pattern_anything_but["source"][0], + "DetailType": test_event_pattern_anything_but["detail-type"][0], + "Detail": json.dumps(event_detail_null), + } + ] + entries_no_match = [ + { + "Source": test_event_pattern_anything_but["source"][0], + "DetailType": test_event_pattern_anything_but["detail-type"][0], + "Detail": json.dumps(event_detail_no_match), + } + ] + + entries_asserts = [ + (entries_match, True), + (entries_match_null, True), + (entries_no_match, False), + ] + + messages = put_events_with_filter_to_sqs( + pattern=test_event_pattern_anything_but, + entries_asserts=entries_asserts, + ) + snapshot.match("rule-anything-but", messages) + + @markers.aws.validated + def test_put_events_with_rule_pattern_exists_true( + self, put_events_with_filter_to_sqs, snapshot + ): + """ + Exists matching True condition: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching + """ + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.jsonpath("$..EventBusName", "event-bus-name"), + ] + ) + + event_detail_exists = {"key": "value", "payload": "baz"} + event_detail_not_exists = {"no-key": "no-value", "payload": "baz"} + event_patter_details = ["core.update-account-command"] + test_event_pattern_exists = { + "source": event_patter_details, + "detail-type": event_patter_details, + "detail": {"key": [{"exists": True}]}, + } + entries_exists = [ + { + "Source": test_event_pattern_exists["source"][0], + "DetailType": test_event_pattern_exists["detail-type"][0], + "Detail": json.dumps(event_detail_exists), + } + ] + entries_not_exists = [ + { + "Source": test_event_pattern_exists["source"][0], + "DetailType": test_event_pattern_exists["detail-type"][0], + "Detail": json.dumps(event_detail_not_exists), + } + ] + entries_asserts = [ + (entries_exists, True), + (entries_not_exists, False), + ] + + messages = put_events_with_filter_to_sqs( + pattern=test_event_pattern_exists, + entries_asserts=entries_asserts, + ) + snapshot.match("rule-exists-true", messages) + + @markers.aws.validated + def test_put_events_with_rule_pattern_exists_false( + self, put_events_with_filter_to_sqs, snapshot + ): + """ + Exists matching False condition: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns-content-based-filtering.html#eb-filtering-exists-matching + """ + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.jsonpath("$..EventBusName", "event-bus-name"), + ] + ) + + event_detail_exists = {"key": "value", "payload": "baz"} + event_detail_not_exists = {"no-key": "no-value", "payload": "baz"} + event_patter_details = ["core.update-account-command"] + test_event_pattern_not_exists = { + "source": event_patter_details, + "detail-type": event_patter_details, + "detail": {"key": [{"exists": False}]}, + } + entries_exists = [ + { + "Source": test_event_pattern_not_exists["source"][0], + "DetailType": test_event_pattern_not_exists["detail-type"][0], + "Detail": json.dumps(event_detail_exists), + } + ] + entries_not_exists = [ + { + "Source": test_event_pattern_not_exists["source"][0], + "DetailType": test_event_pattern_not_exists["detail-type"][0], + "Detail": json.dumps(event_detail_not_exists), + } + ] + entries_asserts_exists_false = [ + (entries_exists, False), + (entries_not_exists, True), + ] + + messages_not_exists = put_events_with_filter_to_sqs( + pattern=test_event_pattern_not_exists, + entries_asserts=entries_asserts_exists_false, + ) + snapshot.match("rule-exists-false", messages_not_exists) + + @markers.aws.validated + def test_put_event_with_content_base_rule_in_pattern( + self, + sqs_as_events_target, + events_create_event_bus, + events_put_rule, + snapshot, + aws_client, + ): + queue_url, queue_arn = sqs_as_events_target() + + # Create event bus + event_bus_name = f"event-bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + + # Put rule + rule_name = f"rule-{short_uid()}" + # EventBridge apparently converts some fields, for example: Source=>source, DetailType=>detail-type + # but the actual pattern matching is case-sensitive by key! + pattern = { + "source": [{"exists": True}], + "detail-type": [{"prefix": "core.app"}], + "detail": { + "description": ["this-is-event-details"], + "amount": [200], + "salary": [2000, 4000], + "env": ["dev", "prod"], + "user": ["user1", "user2", "user3"], + "admins": ["skyli", {"prefix": "hey"}, {"prefix": "ad"}], + "test1": [{"anything-but": 200}], + "test2": [{"anything-but": "test2"}], + "test3": [{"anything-but": ["test3", "test33"]}], + "test4": [{"anything-but": {"prefix": "test4"}}], + # TODO: unsupported in LocalStack + # "ip": [{"cidr": "10.102.1.0/24"}], + "num-test1": [{"numeric": ["<", 200]}], + "num-test2": [{"numeric": ["<=", 200]}], + "num-test3": [{"numeric": [">", 200]}], + "num-test4": [{"numeric": [">=", 200]}], + "num-test5": [{"numeric": [">=", 200, "<=", 500]}], + "num-test6": [{"numeric": [">", 200, "<", 500]}], + "num-test7": [{"numeric": [">=", 200, "<", 500]}], + }, + } + + events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(pattern), + ) + + # Put target + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[{"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}], + ) + + event = { + "EventBusName": event_bus_name, + "Source": "core.update-account-command", + "DetailType": "core.app.backend", + "Detail": json.dumps( + { + "description": "this-is-event-details", + "amount": 200, + "salary": 2000, + "env": "prod", + "user": "user3", + "admins": "admin", + "test1": 300, + "test2": "test22", + "test3": "test333", + "test4": "this test4", + "ip": "10.102.1.100", + "num-test1": 100, + "num-test2": 200, + "num-test3": 300, + "num-test4": 200, + "num-test5": 500, + "num-test6": 300, + "num-test7": 300, + } + ), + } + + aws_client.events.put_events(Entries=[event]) + + messages = sqs_collect_messages(aws_client, queue_url, expected_events_count=1, retries=3) + + snapshot.add_transformer( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.key_value("MessageId"), + ] + ) + snapshot.match("messages", messages) diff --git a/tests/aws/services/events/test_events_patterns.snapshot.json b/tests/aws/services/events/test_events_patterns.snapshot.json new file mode 100644 index 0000000000000..3dbc5cd4f1301 --- /dev/null +++ b/tests/aws/services/events/test_events_patterns.snapshot.json @@ -0,0 +1,1319 @@ +{ + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating]": { + "recorded-date": "22-01-2025, 10:56:14", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[list_within_dict]": { + "recorded-date": "22-01-2025, 10:56:14", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_NEG]": { + "recorded-date": "22-01-2025, 10:56:14", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_multi_match]": { + "recorded-date": "22-01-2025, 10:56:15", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[int_nolist_EXC]": { + "recorded-date": "22-01-2025, 10:56:15", + "recorded-content": { + "int_nolist_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: \"int\" must be an object or an array", + "MessageRaw": "Event pattern is not valid. Reason: \"int\" must be an object or an array\n at [Source: (String)\"{\"int\": 42}\"; line: 1, column: 11]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating_NEG]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list_NEG]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_simplified]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-exists]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_nonrepeating]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_multi_match_NEG]": { + "recorded-date": "22-01-2025, 10:56:18", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string]": { + "recorded-date": "22-01-2025, 10:56:18", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_operatorcasing_EXC]": { + "recorded-date": "22-01-2025, 10:56:18", + "recorded-content": { + "content_numeric_operatorcasing_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Unrecognized match type NUMERIC", + "MessageRaw": "Event pattern is not valid. Reason: Unrecognized match type NUMERIC\n at [Source: (String)\"{\"detail\": {\"equal\": [{\"NUMERIC\": [\"=\", 5]}]}}\"; line: 1, column: 36]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_list_NEG]": { + "recorded-date": "22-01-2025, 10:56:19", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address]": { + "recorded-date": "22-01-2025, 10:56:19", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_NEG]": { + "recorded-date": "22-01-2025, 10:56:19", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string_nolist_EXC]": { + "recorded-date": "22-01-2025, 10:56:20", + "recorded-content": { + "string_nolist_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: \"string\" must be an object or an array", + "MessageRaw": "Event pattern is not valid. Reason: \"string\" must be an object or an array\n at [Source: (String)\"{\"string\": \"my-value\"}\"; line: 1, column: 13]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_dynamodb_NEG]": { + "recorded-date": "22-01-2025, 10:56:20", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_NEG]": { + "recorded-date": "22-01-2025, 10:56:20", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[boolean_NEG]": { + "recorded-date": "22-01-2025, 10:56:20", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_dynamodb]": { + "recorded-date": "22-01-2025, 10:56:20", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-exists-parent]": { + "recorded-date": "22-01-2025, 10:56:21", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_empty_EXC]": { + "recorded-date": "22-01-2025, 10:56:21", + "recorded-content": { + "arrays_empty_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Empty arrays are not allowed", + "MessageRaw": "Event pattern is not valid. Reason: Empty arrays are not allowed\n at [Source: (String)\"{\"resources\": []}\"; line: 1, column: 17]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[nested_json_NEG]": { + "recorded-date": "22-01-2025, 10:56:21", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_pattern]": { + "recorded-date": "22-01-2025, 10:56:21", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[operator_multiple_list]": { + "recorded-date": "22-01-2025, 10:56:21", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_list]": { + "recorded-date": "22-01-2025, 10:56:21", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dynamodb]": { + "recorded-date": "22-01-2025, 10:56:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase]": { + "recorded-date": "22-01-2025, 10:56:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string_empty]": { + "recorded-date": "22-01-2025, 10:56:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_many_rules]": { + "recorded-date": "22-01-2025, 10:56:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[null_value]": { + "recorded-date": "22-01-2025, 10:56:23", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_list]": { + "recorded-date": "22-01-2025, 10:56:23", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_nonrepeating_NEG]": { + "recorded-date": "22-01-2025, 10:56:23", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_NEG]": { + "recorded-date": "22-01-2025, 10:56:23", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[operator_case_sensitive_EXC]": { + "recorded-date": "22-01-2025, 10:56:24", + "recorded-content": { + "operator_case_sensitive_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Unrecognized match type EXISTS", + "MessageRaw": "Event pattern is not valid. Reason: Unrecognized match type EXISTS\n at [Source: (String)\"{\"my_key\": [{\"EXISTS\": true}]}\"; line: 1, column: 28]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists]": { + "recorded-date": "22-01-2025, 10:56:24", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_NEG]": { + "recorded-date": "22-01-2025, 10:56:24", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_EXC]": { + "recorded-date": "22-01-2025, 10:56:25", + "recorded-content": { + "content_numeric_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Bad numeric range operator: >", + "MessageRaw": "Event pattern is not valid. Reason: Bad numeric range operator: >\n at [Source: (String)\"{\"detail\": {\"c-count\": [{\"numeric\": [\">\", 0, \">\", 0]}]}}\"; line: 1, column: 49]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_or_NEG]": { + "recorded-date": "22-01-2025, 10:56:25", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_or]": { + "recorded-date": "22-01-2025, 10:56:25", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_and_NEG]": { + "recorded-date": "22-01-2025, 10:56:26", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_event]": { + "recorded-date": "22-01-2025, 10:56:26", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_NEG]": { + "recorded-date": "22-01-2025, 10:56:26", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_ignorecase]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_ignorecase]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[null_value_NEG]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_pattern_NEG]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[sample1]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[key_case_sensitive_NEG]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_event_NEG]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[prefix]": { + "recorded-date": "22-01-2025, 10:56:28", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix]": { + "recorded-date": "22-01-2025, 10:56:28", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list]": { + "recorded-date": "22-01-2025, 10:56:29", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_NEG]": { + "recorded-date": "22-01-2025, 10:56:29", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string]": { + "recorded-date": "22-01-2025, 10:56:29", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_empty_null_NEG]": { + "recorded-date": "22-01-2025, 10:56:30", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_ignorecase_NEG]": { + "recorded-date": "22-01-2025, 10:56:30", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_complex_EXC]": { + "recorded-date": "22-01-2025, 10:56:31", + "recorded-content": { + "content_wildcard_complex_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Rule is too complex - try using fewer wildcard characters or fewer repeating character sequences after a wildcard character", + "MessageRaw": "Event pattern is not valid. Reason: Rule is too complex - try using fewer wildcard characters or fewer repeating character sequences after a wildcard character" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_list_NEG]": { + "recorded-date": "22-01-2025, 10:56:32", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix]": { + "recorded-date": "22-01-2025, 10:56:33", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_NEG]": { + "recorded-date": "22-01-2025, 10:56:33", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[minimal]": { + "recorded-date": "22-01-2025, 10:56:33", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_false]": { + "recorded-date": "22-01-2025, 10:56:34", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[number_comparison_float]": { + "recorded-date": "22-01-2025, 10:56:35", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_NEG]": { + "recorded-date": "22-01-2025, 10:56:35", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix]": { + "recorded-date": "22-01-2025, 10:56:35", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_NEG]": { + "recorded-date": "22-01-2025, 10:56:36", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase]": { + "recorded-date": "22-01-2025, 10:56:37", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_false_NEG]": { + "recorded-date": "22-01-2025, 10:56:37", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_NEG]": { + "recorded-date": "22-01-2025, 10:56:37", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_syntax_EXC]": { + "recorded-date": "22-01-2025, 10:56:38", + "recorded-content": { + "content_numeric_syntax_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Value of < must be numeric", + "MessageRaw": "Event pattern is not valid. Reason: Value of < must be numeric\n at [Source: (String)\"{\"detail\": {\"c-count\": [{\"numeric\": [\">\", 0, \"<\"]}]}}\"; line: 1, column: 50]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_and]": { + "recorded-date": "22-01-2025, 10:56:38", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[boolean]": { + "recorded-date": "22-01-2025, 10:56:38", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number]": { + "recorded-date": "22-01-2025, 10:56:38", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-anything-but]": { + "recorded-date": "22-01-2025, 10:56:38", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern_source": { + "recorded-date": "11-07-2024, 13:55:39", + "recorded-content": { + "eventbridge-test-event-pattern-response": { + "Result": true, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "eventbridge-test-event-pattern-response-no-match": { + "Result": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_anything_but": { + "recorded-date": "11-07-2024, 13:55:46", + "recorded-content": { + "rule-anything-but": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "display-message", + "payload": "baz" + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": null, + "payload": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_exists_true": { + "recorded-date": "11-07-2024, 13:55:54", + "recorded-content": { + "rule-exists-true": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "key": "value", + "payload": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_exists_false": { + "recorded-date": "11-07-2024, 13:56:06", + "recorded-content": { + "rule-exists-false": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "no-key": "no-value", + "payload": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_event_with_content_base_rule_in_pattern": { + "recorded-date": "11-07-2024, 14:14:42", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "description": "this-is-event-details", + "amount": 200, + "salary": 2000, + "env": "prod", + "user": "user3", + "admins": "admin", + "test1": 300, + "test2": "test22", + "test3": "test333", + "test4": "this test4", + "ip": "10.102.1.100", + "num-test1": 100, + "num-test2": 200, + "num-test3": 300, + "num-test4": 200, + "num-test5": 500, + "num-test6": 300, + "num-test7": 300 + } + } + ] + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating_star_EXC]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": { + "content_wildcard_repeating_star_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Consecutive wildcard characters at pos 26", + "MessageRaw": "Event pattern is not valid. Reason: Consecutive wildcard characters at pos 26\n at [Source: (String)\"{\"EventBusArn\": [{\"wildcard\": \"arn::events::**:event-bus/*\"}]}\"; line: 1, column: 72]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_EXC]": { + "recorded-date": "22-01-2025, 10:56:23", + "recorded-content": { + "content_ignorecase_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: equals-ignore-case match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: equals-ignore-case match pattern must be a string\n at [Source: (String)\"{\"detail-type\": [{\"equals-ignore-case\": [\"ec2 instance state-change notification\"]}]}\"; line: 1, column: 42]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_EXC]": { + "recorded-date": "22-01-2025, 10:56:34", + "recorded-content": { + "content_ip_address_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Malformed CIDR, one '/' required", + "MessageRaw": "Event pattern is not valid. Reason: Malformed CIDR, one '/' required" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_EXC]": { + "recorded-date": "22-01-2025, 10:56:24", + "recorded-content": { + "content_anything_but_ignorecase_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Inside anything-but/equals-ignore-case list, number|start|null|boolean is not supported.", + "MessageRaw": "Event pattern is not valid. Reason: Inside anything-but/equals-ignore-case list, number|start|null|boolean is not supported.\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"equals-ignore-case\": 123}}]}}\"; line: 1, column: 66]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list_EXC]": { + "recorded-date": "22-01-2025, 10:56:29", + "recorded-content": { + "content_anything_but_ignorecase_list_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Inside anything-but/equals-ignore-case list, number|start|null|boolean is not supported.", + "MessageRaw": "Event pattern is not valid. Reason: Inside anything-but/equals-ignore-case list, number|start|null|boolean is not supported.\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"equals-ignore-case\": [123, 456]}}]}}\"; line: 1, column: 67]\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"equals-ignore-case\": [123, 456]}}]}}\"; line: 1, column: 67]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_list_EXC]": { + "recorded-date": "22-01-2025, 10:56:31", + "recorded-content": { + "content_ignorecase_list_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: equals-ignore-case match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: equals-ignore-case match pattern must be a string\n at [Source: (String)\"{\"detail-type\": [{\"equals-ignore-case\": {\"prefix\": \"ec2\"}}]}\"; line: 1, column: 42]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[this is valid json but not a dict]": { + "recorded-date": "29-11-2024, 00:19:32", + "recorded-content": { + "invalid-pattern": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Unrecognized token 'this': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (String)\"this is valid json but not a dict\"; line: 1, column: 5]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[{'bad': 'quotation'}]": { + "recorded-date": "29-11-2024, 00:19:32", + "recorded-content": { + "invalid-pattern": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Unexpected character (''' (code 39)): was expecting double-quote to start field name\n at [Source: (String)\"{'bad': 'quotation'}\"; line: 1, column: 2]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[{\"not\": closed mark\"]": { + "recorded-date": "29-11-2024, 00:19:33", + "recorded-content": { + "invalid-pattern": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Unrecognized token 'closed': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (String)\"{\"not\": closed mark\"\"; line: 1, column: 15]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[[\"not\", \"a\", \"dict\", \"but valid json\"]]": { + "recorded-date": "29-11-2024, 00:19:33", + "recorded-content": { + "invalid-pattern": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Filter is not an object\n at [Source: (String)\"[\"not\", \"a\", \"dict\", \"but valid json\"]\"; line: 1, column: 2]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_null]": { + "recorded-date": "22-01-2025, 10:56:25", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_NEG]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list]": { + "recorded-date": "22-01-2025, 10:56:18", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list_NEG]": { + "recorded-date": "22-01-2025, 10:56:23", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard]": { + "recorded-date": "22-01-2025, 10:56:34", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_int_EXC]": { + "recorded-date": "22-01-2025, 10:56:33", + "recorded-content": { + "content_wildcard_int_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: wildcard match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: wildcard match pattern must be a string\n at [Source: (String)\"{\"EventBusArn\": [{\"wildcard\": 123}]}\"; line: 1, column: 34]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_list_EXC]": { + "recorded-date": "22-01-2025, 10:56:36", + "recorded-content": { + "content_wildcard_list_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: wildcard match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: wildcard match pattern must be a string\n at [Source: (String)\"{\"EventBusArn\": [{\"wildcard\": [\"arn::events::**:event-bus/*\"]}]}\"; line: 1, column: 32]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list_type_EXC]": { + "recorded-date": "22-01-2025, 10:56:16", + "recorded-content": { + "content_anything_wildcard_list_type_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: wildcard match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: wildcard match pattern must be a string\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"wildcard\": [123, \"*/dir/*\"]}}]}}\"; line: 1, column: 57]\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"wildcard\": [123, \"*/dir/*\"]}}]}}\"; line: 1, column: 57]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_type_EXC]": { + "recorded-date": "22-01-2025, 10:56:32", + "recorded-content": { + "content_anything_wildcard_type_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: wildcard match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: wildcard match pattern must be a string\n at [Source: (String)\"{\"detail\": {\"FilePath\": [{\"anything-but\": {\"wildcard\": 123}}]}}\"; line: 1, column: 59]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list_type_EXC]": { + "recorded-date": "22-01-2025, 10:56:14", + "recorded-content": { + "content_anything_suffix_list_type_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string\n at [Source: (String)\"{\"detail\": {\"FileName\": [{\"anything-but\": {\"suffix\": [123, \".txt\"]}}]}}\"; line: 1, column: 58]\n at [Source: (String)\"{\"detail\": {\"FileName\": [{\"anything-but\": {\"suffix\": [123, \".txt\"]}}]}}\"; line: 1, column: 58]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list_NEG]": { + "recorded-date": "22-01-2025, 10:56:17", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_int_EXC]": { + "recorded-date": "22-01-2025, 10:56:22", + "recorded-content": { + "content_anything_suffix_int_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string\n at [Source: (String)\"{\"detail\": {\"FileName\": [{\"anything-but\": {\"suffix\": 123}}]}}\"; line: 1, column: 57]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list_NEG]": { + "recorded-date": "22-01-2025, 10:56:31", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list_type_EXC]": { + "recorded-date": "22-01-2025, 10:56:33", + "recorded-content": { + "content_anything_prefix_list_type_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"prefix\": [123, \"test\"]}}]}}\"; line: 1, column: 55]\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"prefix\": [123, \"test\"]}}]}}\"; line: 1, column: 55]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_int_EXC]": { + "recorded-date": "22-01-2025, 10:56:36", + "recorded-content": { + "content_anything_prefix_int_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: prefix/suffix match pattern must be a string\n at [Source: (String)\"{\"detail\": {\"state\": [{\"anything-but\": {\"prefix\": 123}}]}}\"; line: 1, column: 54]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_list_EXC]": { + "recorded-date": "22-01-2025, 10:56:19", + "recorded-content": { + "content_prefix_list_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: prefix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: prefix match pattern must be a string\n at [Source: (String)\"{\"time\": [{\"prefix\": [\"2022-07-13\"]}]}\"; line: 1, column: 23]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_int_EXC]": { + "recorded-date": "22-01-2025, 10:56:26", + "recorded-content": { + "content_prefix_int_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: prefix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: prefix match pattern must be a string\n at [Source: (String)\"{\"time\": [{\"prefix\": 123}]}\"; line: 1, column: 25]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_int_EXC]": { + "recorded-date": "22-01-2025, 10:56:30", + "recorded-content": { + "content_suffix_int_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: suffix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: suffix match pattern must be a string\n at [Source: (String)\"{\"FileName\": [{\"suffix\": 123}]}\"; line: 1, column: 29]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_list_EXC]": { + "recorded-date": "22-01-2025, 10:56:34", + "recorded-content": { + "content_suffix_list_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: suffix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: suffix match pattern must be a string\n at [Source: (String)\"{\"FileName\": [{\"suffix\": [\".png\"]}]}\"; line: 1, column: 27]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_ignorecase_EXC]": { + "recorded-date": "22-01-2025, 10:56:30", + "recorded-content": { + "content_anything_prefix_ignorecase_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Value of anything-but must be an array or single string/number value.", + "MessageRaw": "Event pattern is not valid. Reason: Value of anything-but must be an array or single string/number value.\n at [Source: (String)\"{\"detail\": {\"FileName\": [{\"anything-but\": {\"prefix\": {\"equals-ignore-case\": \"file\"}}}]}}\"; line: 1, column: 55]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_ignorecase_EXC]": { + "recorded-date": "22-01-2025, 10:56:32", + "recorded-content": { + "content_anything_suffix_ignorecase_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Value of anything-but must be an array or single string/number value.", + "MessageRaw": "Event pattern is not valid. Reason: Value of anything-but must be an array or single string/number value.\n at [Source: (String)\"{\"detail\": {\"FileName\": [{\"anything-but\": {\"suffix\": {\"equals-ignore-case\": \".png\"}}}]}}\"; line: 1, column: 55]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_bad_mask_EXC]": { + "recorded-date": "22-01-2025, 10:56:15", + "recorded-content": { + "content_ip_address_bad_mask_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Malformed CIDR, mask bits must be an integer", + "MessageRaw": "Event pattern is not valid. Reason: Malformed CIDR, mask bits must be an integer" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6_NEG]": { + "recorded-date": "22-01-2025, 10:56:20", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6]": { + "recorded-date": "22-01-2025, 10:56:21", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_bad_ip_EXC]": { + "recorded-date": "22-01-2025, 10:56:28", + "recorded-content": { + "content_ip_address_bad_ip_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Nonstandard IP address: xx.11.xx", + "MessageRaw": "Event pattern is not valid. Reason: Nonstandard IP address: xx.11.xx" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_type_EXC]": { + "recorded-date": "22-01-2025, 10:56:35", + "recorded-content": { + "content_ip_address_type_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: prefix match pattern must be a string", + "MessageRaw": "Event pattern is not valid. Reason: prefix match pattern must be a string\n at [Source: (String)\"{\"detail\": {\"sourceIPAddress\": [{\"cidr\": [\"bad-type\"]}]}}\"; line: 1, column: 43]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6_bad_ip_EXC]": { + "recorded-date": "22-01-2025, 10:56:37", + "recorded-content": { + "content_ip_address_v6_bad_ip_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Nonstandard IP address: xxxx:db8:1234:1a00::", + "MessageRaw": "Event pattern is not valid. Reason: Nonstandard IP address: xxxx:db8:1234:1a00::" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_list_empty]": { + "recorded-date": "29-11-2024, 21:45:57", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_list_empty_NEG]": { + "recorded-date": "22-01-2025, 10:56:16", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_plain_string_payload": { + "recorded-date": "05-12-2024, 16:59:10", + "recorded-content": { + "plain-string-payload-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter Event is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-numeric-anything-but_NEG]": { + "recorded-date": "22-01-2025, 10:56:19", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-numeric-anything-but]": { + "recorded-date": "22-01-2025, 10:56:33", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-string_NEG]": { + "recorded-date": "22-01-2025, 10:56:25", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_event_payload": { + "recorded-date": "05-12-2024, 17:44:17", + "recorded-content": { + "plain-string-payload-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter Event is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-null_NEG]": { + "recorded-date": "22-01-2025, 10:56:32", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-int-float]": { + "recorded-date": "22-01-2025, 10:56:25", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_array_event_payload": { + "recorded-date": "06-12-2024, 09:49:56", + "recorded-content": { + "array-event-payload-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter Event is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_empty_EXC]": { + "recorded-date": "22-01-2025, 10:56:14", + "recorded-content": { + "content_anything_prefix_empty_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Null prefix/suffix not allowed", + "MessageRaw": "Event pattern is not valid. Reason: Null prefix/suffix not allowed\n at [Source: (String)\"{\"detail\": {\"FileName\": [{\"anything-but\": {\"prefix\": \"\"}}]}}\"; line: 1, column: 56]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_empty_NEG]": { + "recorded-date": "22-01-2025, 10:56:14", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_empty]": { + "recorded-date": "22-01-2025, 10:56:15", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_empty_NEG]": { + "recorded-date": "22-01-2025, 10:56:16", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_empty]": { + "recorded-date": "22-01-2025, 10:56:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_empty]": { + "recorded-date": "22-01-2025, 10:56:22", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_zero]": { + "recorded-date": "22-01-2025, 10:56:27", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_empty]": { + "recorded-date": "22-01-2025, 10:56:29", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_empty_EXC]": { + "recorded-date": "22-01-2025, 10:56:37", + "recorded-content": { + "content_anything_suffix_empty_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Null prefix/suffix not allowed", + "MessageRaw": "Event pattern is not valid. Reason: Null prefix/suffix not allowed\n at [Source: (String)\"{\"detail\": {\"FileName\": [{\"anything-but\": {\"suffix\": \"\"}}]}}\"; line: 1, column: 56]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_number_EXC]": { + "recorded-date": "22-01-2025, 10:56:28", + "recorded-content": { + "content_numeric_number_EXC": { + "exception_message": { + "Error": { + "Code": "InvalidEventPatternException", + "Message": "Event pattern is not valid. Reason: Value of numeric must be an array.", + "MessageRaw": "Event pattern is not valid. Reason: Value of numeric must be an array.\n at [Source: (String)\"{\"detail\": {\"c-count\": [{\"numeric\": 10}]}}\"; line: 1, column: 39]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "exception_type": "" + } + } + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_with_large_and_complex_payload": { + "recorded-date": "17-03-2025, 10:58:02", + "recorded-content": { + "complex-event-simple-pattern": { + "Result": true, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complex-event-complex-pattern": { + "Result": true, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/events/test_events_patterns.validation.json b/tests/aws/services/events/test_events_patterns.validation.json new file mode 100644 index 0000000000000..e4d69240f1b7a --- /dev/null +++ b/tests/aws/services/events/test_events_patterns.validation.json @@ -0,0 +1,437 @@ +{ + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_array_event_payload": { + "last_validated_date": "2024-12-06T09:49:56+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_NEG]": { + "last_validated_date": "2025-01-22T10:56:36+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_empty_EXC]": { + "last_validated_date": "2025-01-22T10:56:21+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[arrays_empty_null_NEG]": { + "last_validated_date": "2025-01-22T10:56:30+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[boolean]": { + "last_validated_date": "2025-01-22T10:56:38+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[boolean_NEG]": { + "last_validated_date": "2025-01-22T10:56:20+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_many_rules]": { + "last_validated_date": "2025-01-22T10:56:22+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_multi_match]": { + "last_validated_date": "2025-01-22T10:56:15+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_multi_match_NEG]": { + "last_validated_date": "2025-01-22T10:56:18+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_or]": { + "last_validated_date": "2025-01-22T10:56:25+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[complex_or_NEG]": { + "last_validated_date": "2025-01-22T10:56:25+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase]": { + "last_validated_date": "2025-01-22T10:56:22+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_EXC]": { + "last_validated_date": "2025-01-22T10:56:24+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_NEG]": { + "last_validated_date": "2025-01-22T10:56:35+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list]": { + "last_validated_date": "2025-01-22T10:56:29+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list_EXC]": { + "last_validated_date": "2025-01-22T10:56:29+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_ignorecase_list_NEG]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number]": { + "last_validated_date": "2025-01-22T10:56:38+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_NEG]": { + "last_validated_date": "2025-01-22T10:56:23+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_list]": { + "last_validated_date": "2025-01-22T10:56:23+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_list_NEG]": { + "last_validated_date": "2025-01-22T10:56:19+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_number_zero]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string]": { + "last_validated_date": "2025-01-22T10:56:18+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_NEG]": { + "last_validated_date": "2025-01-22T10:56:20+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_list]": { + "last_validated_date": "2025-01-22T10:56:21+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_list_NEG]": { + "last_validated_date": "2025-01-22T10:56:32+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_but_string_null]": { + "last_validated_date": "2025-01-22T10:56:25+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_NEG]": { + "last_validated_date": "2025-01-22T10:56:19+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_empty_EXC]": { + "last_validated_date": "2025-01-22T10:56:14+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_ignorecase_EXC]": { + "last_validated_date": "2025-01-22T10:56:30+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_int_EXC]": { + "last_validated_date": "2025-01-22T10:56:36+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list_NEG]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_prefix_list_type_EXC]": { + "last_validated_date": "2025-01-22T10:56:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix]": { + "last_validated_date": "2025-01-22T10:56:28+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_NEG]": { + "last_validated_date": "2025-01-22T10:56:26+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_empty_EXC]": { + "last_validated_date": "2025-01-22T10:56:37+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_ignorecase_EXC]": { + "last_validated_date": "2025-01-22T10:56:32+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_int_EXC]": { + "last_validated_date": "2025-01-22T10:56:22+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list_NEG]": { + "last_validated_date": "2025-01-22T10:56:31+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_suffix_list_type_EXC]": { + "last_validated_date": "2025-01-22T10:56:14+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard]": { + "last_validated_date": "2025-01-22T10:56:34+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_NEG]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_empty]": { + "last_validated_date": "2025-01-22T10:56:22+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list]": { + "last_validated_date": "2025-01-22T10:56:18+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list_NEG]": { + "last_validated_date": "2025-01-22T10:56:23+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_list_type_EXC]": { + "last_validated_date": "2025-01-22T10:56:16+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_anything_wildcard_type_EXC]": { + "last_validated_date": "2025-01-22T10:56:32+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists]": { + "last_validated_date": "2025-01-22T10:56:24+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_NEG]": { + "last_validated_date": "2025-01-22T10:56:37+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_false]": { + "last_validated_date": "2025-01-22T10:56:34+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_exists_false_NEG]": { + "last_validated_date": "2025-01-22T10:56:37+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase]": { + "last_validated_date": "2025-01-22T10:56:37+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_EXC]": { + "last_validated_date": "2025-01-22T10:56:23+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_NEG]": { + "last_validated_date": "2025-01-22T10:56:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_empty]": { + "last_validated_date": "2025-01-22T10:56:22+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_empty_NEG]": { + "last_validated_date": "2025-01-22T10:56:14+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ignorecase_list_EXC]": { + "last_validated_date": "2025-01-22T10:56:31+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address]": { + "last_validated_date": "2025-01-22T10:56:19+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_EXC]": { + "last_validated_date": "2025-01-22T10:56:34+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_NEG]": { + "last_validated_date": "2025-01-22T10:56:24+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_bad_ip_EXC]": { + "last_validated_date": "2025-01-22T10:56:28+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_bad_mask_EXC]": { + "last_validated_date": "2025-01-22T10:56:15+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_type_EXC]": { + "last_validated_date": "2025-01-22T10:56:35+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6]": { + "last_validated_date": "2025-01-22T10:56:21+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6_NEG]": { + "last_validated_date": "2025-01-22T10:56:20+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_ip_address_v6_bad_ip_EXC]": { + "last_validated_date": "2025-01-22T10:56:37+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_EXC]": { + "last_validated_date": "2025-01-22T10:56:25+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_and]": { + "last_validated_date": "2025-01-22T10:56:38+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_and_NEG]": { + "last_validated_date": "2025-01-22T10:56:26+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_number_EXC]": { + "last_validated_date": "2025-01-22T10:56:28+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_operatorcasing_EXC]": { + "last_validated_date": "2025-01-22T10:56:18+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_numeric_syntax_EXC]": { + "last_validated_date": "2025-01-22T10:56:38+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix]": { + "last_validated_date": "2025-01-22T10:56:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_NEG]": { + "last_validated_date": "2025-01-22T10:56:29+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_empty]": { + "last_validated_date": "2025-01-22T10:56:15+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_ignorecase]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_int_EXC]": { + "last_validated_date": "2025-01-22T10:56:26+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_prefix_list_EXC]": { + "last_validated_date": "2025-01-22T10:56:19+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix]": { + "last_validated_date": "2025-01-22T10:56:35+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_NEG]": { + "last_validated_date": "2025-01-22T10:56:14+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_empty]": { + "last_validated_date": "2025-01-22T10:56:29+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_ignorecase]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_ignorecase_NEG]": { + "last_validated_date": "2025-01-22T10:56:30+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_int_EXC]": { + "last_validated_date": "2025-01-22T10:56:30+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_suffix_list_EXC]": { + "last_validated_date": "2025-01-22T10:56:34+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_complex_EXC]": { + "last_validated_date": "2025-01-22T10:56:31+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_empty_NEG]": { + "last_validated_date": "2025-01-22T10:56:16+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_int_EXC]": { + "last_validated_date": "2025-01-22T10:56:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_list_EXC]": { + "last_validated_date": "2025-01-22T10:56:36+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_nonrepeating]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_nonrepeating_NEG]": { + "last_validated_date": "2025-01-22T10:56:23+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating]": { + "last_validated_date": "2025-01-22T10:56:14+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating_NEG]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_repeating_star_EXC]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[content_wildcard_simplified]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_event]": { + "last_validated_date": "2025-01-22T10:56:26+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_event_NEG]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_pattern]": { + "last_validated_date": "2025-01-22T10:56:21+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dot_joining_pattern_NEG]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[dynamodb]": { + "last_validated_date": "2025-01-22T10:56:22+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[empty_prefix]": { + "last_validated_date": "2025-01-21T13:16:50+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_dynamodb]": { + "last_validated_date": "2025-01-22T10:56:20+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_dynamodb_NEG]": { + "last_validated_date": "2025-01-22T10:56:20+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[exists_list_empty_NEG]": { + "last_validated_date": "2025-01-22T10:56:16+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[int_nolist_EXC]": { + "last_validated_date": "2025-01-22T10:56:15+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[key_case_sensitive_NEG]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[list_within_dict]": { + "last_validated_date": "2025-01-22T10:56:14+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[minimal]": { + "last_validated_date": "2025-01-22T10:56:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[nested_json_NEG]": { + "last_validated_date": "2025-01-22T10:56:21+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[null_value]": { + "last_validated_date": "2025-01-22T10:56:23+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[null_value_NEG]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[number_comparison_float]": { + "last_validated_date": "2025-01-22T10:56:35+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-int-float]": { + "last_validated_date": "2025-01-22T10:56:25+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-null_NEG]": { + "last_validated_date": "2025-01-22T10:56:32+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[numeric-string_NEG]": { + "last_validated_date": "2025-01-22T10:56:25+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[operator_case_sensitive_EXC]": { + "last_validated_date": "2025-01-22T10:56:24+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[operator_multiple_list]": { + "last_validated_date": "2025-01-22T10:56:21+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-anything-but]": { + "last_validated_date": "2025-01-22T10:56:38+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-exists-parent]": { + "last_validated_date": "2025-01-22T10:56:21+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-exists]": { + "last_validated_date": "2025-01-22T10:56:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-numeric-anything-but]": { + "last_validated_date": "2025-01-22T10:56:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[or-numeric-anything-but_NEG]": { + "last_validated_date": "2025-01-22T10:56:19+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[prefix]": { + "last_validated_date": "2025-01-22T10:56:28+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[sample1]": { + "last_validated_date": "2025-01-22T10:56:27+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string]": { + "last_validated_date": "2025-01-22T10:56:29+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string_empty]": { + "last_validated_date": "2025-01-22T10:56:22+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern[string_nolist_EXC]": { + "last_validated_date": "2025-01-22T10:56:20+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern_source": { + "last_validated_date": "2024-07-11T13:55:39+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern_with_escape_characters": { + "last_validated_date": "2024-07-11T13:55:38+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_pattern_with_multi_key": { + "last_validated_date": "2024-07-11T13:55:38+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_event_with_large_and_complex_payload": { + "last_validated_date": "2025-03-17T10:58:02+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_event_payload": { + "last_validated_date": "2024-12-05T17:44:17+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[[\"not\", \"a\", \"dict\", \"but valid json\"]]": { + "last_validated_date": "2024-11-29T00:19:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[this is valid json but not a dict]": { + "last_validated_date": "2024-11-29T00:19:32+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[{\"not\": closed mark\"]": { + "last_validated_date": "2024-11-29T00:19:33+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_invalid_json_event_pattern[{'bad': 'quotation'}]": { + "last_validated_date": "2024-11-29T00:19:32+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestEventPattern::test_plain_string_payload": { + "last_validated_date": "2024-12-05T16:59:10+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_event_with_content_base_rule_in_pattern": { + "last_validated_date": "2024-07-11T14:14:42+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_anything_but": { + "last_validated_date": "2024-07-11T13:55:46+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_exists_false": { + "last_validated_date": "2024-07-11T13:56:06+00:00" + }, + "tests/aws/services/events/test_events_patterns.py::TestRuleWithPattern::test_put_events_with_rule_pattern_exists_true": { + "last_validated_date": "2024-07-11T13:55:54+00:00" + } +} diff --git a/tests/aws/services/events/test_events_schedule.py b/tests/aws/services/events/test_events_schedule.py new file mode 100644 index 0000000000000..aef36fadb04f2 --- /dev/null +++ b/tests/aws/services/events/test_events_schedule.py @@ -0,0 +1,424 @@ +import json +import time +from datetime import timedelta, timezone + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.aws.eventbus_utils import trigger_scheduled_rule +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import TransformerUtility +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.events.helper_functions import ( + events_time_string_to_timestamp, + get_cron_expression, + is_old_provider, + sqs_collect_messages, +) + + +class TestScheduleRate: + @markers.aws.validated + def test_put_rule_with_schedule_rate(self, events_put_rule, aws_client, snapshot): + rule_name = f"rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + + response = events_put_rule(Name=rule_name, ScheduleExpression="rate(1 minute)") + snapshot.match("put-rule", response) + + response = aws_client.events.list_rules(NamePrefix=rule_name) + snapshot.match("list-rules", response) + + @markers.aws.validated + def tests_put_rule_with_schedule_custom_event_bus( + self, + events_create_event_bus, + aws_client, + snapshot, + ): + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"test-rule-{short_uid()}" + with pytest.raises(ClientError) as e: + aws_client.events.put_rule( + Name=rule_name, EventBusName=bus_name, ScheduleExpression="rate(1 minute)" + ) + snapshot.match("put-rule-with-custom-event-bus-error", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "schedule_expression", + [ + "rate(10 seconds)", + "rate(10 years)", + "rate(1 minutes)", + "rate(1 hours)", + "rate(1 days)", + "rate(10 minute)", + "rate(10 hour)", + "rate(10 day)", + "rate()", + "rate(10)", + "rate(10 minutess)", + "rate(foo minutes)", + "rate(0 minutes)", + "rate(-10 minutes)", + "rate(10 MINUTES)", + "rate( 10 minutes )", + " rate(10 minutes)", + ], + ) + def test_put_rule_with_invalid_schedule_rate(self, schedule_expression, aws_client): + with pytest.raises(ClientError) as e: + aws_client.events.put_rule( + Name=f"rule-{short_uid()}", ScheduleExpression=schedule_expression + ) + + assert e.value.response["Error"] == { + "Code": "ValidationException", + "Message": "Parameter ScheduleExpression is not valid.", + } + + @markers.aws.validated + @pytest.mark.skip(reason="flakey when comparing 'messages-second' against snapshot") + def tests_schedule_rate_target_sqs( + self, + sqs_as_events_target, + events_put_rule, + aws_client, + snapshot, + ): + queue_name = f"test-queue-{short_uid()}" + queue_url, queue_arn = sqs_as_events_target(queue_name) + + bus_name = "default" + rule_name = f"test-rule-{short_uid()}" + events_put_rule(Name=rule_name, EventBusName=bus_name, ScheduleExpression="rate(1 minute)") + + target_id = f"test-target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn}, + ], + ) # cleanup is handled by rule fixture + + response = aws_client.events.list_targets_by_rule(Rule=rule_name) + snapshot.match("list-targets", response) + + time.sleep(60) + messages_first = sqs_collect_messages( + aws_client, queue_url, expected_events_count=1, retries=3 + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.regex(target_id, ""), + snapshot.transform.regex(rule_name, ""), + snapshot.transform.regex(queue_name, ""), + snapshot.transform.regex(queue_arn, ""), + ] + ) + snapshot.match("messages", messages_first) + + @markers.aws.needs_fixing + @markers.snapshot.skip_snapshot_verify( + paths=[ + # tokens and IDs cannot be properly transformed + "$..eventId", + "$..uploadSequenceToken", + # FIXME: storedBytes should be implemented + "$..storedBytes", + ] + ) + @pytest.mark.skip( + reason="This test is flaky is CI, might be race conditions" # FIXME: investigate and fix + ) + def test_scheduled_rule_logs( + self, + logs_create_log_group, + events_put_rule, + add_resource_policy_logs_events_access, + aws_client, + snapshot, + ): + schedule_expression = "rate(1 minute)" + rule_name = f"rule-{short_uid()}" + snapshot.add_transformers_list( + [ + snapshot.transform.regex(rule_name, ""), + snapshot.transform.regex(logs_create_log_group, ""), + ] + ) + snapshot.add_transformer(TransformerUtility.logs_api()) + + response = aws_client.logs.describe_log_groups(logGroupNamePrefix=logs_create_log_group) + log_group_arn = response["logGroups"][0]["arn"] + + rule_arn = events_put_rule(Name=rule_name, ScheduleExpression=schedule_expression)[ + "RuleArn" + ] + add_resource_policy_logs_events_access(rule_arn, log_group_arn) + + # TODO: add target to test InputTransformer + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + {"Id": "1", "Arn": log_group_arn}, + {"Id": "2", "Arn": log_group_arn}, + ], + ) + + trigger_scheduled_rule(rule_arn) + + # wait for log stream to be created + def _get_log_stream(): + result = ( + aws_client.logs.get_paginator("describe_log_streams") + .paginate(logGroupName=logs_create_log_group) + .build_full_result() + ) + assert len(result["logStreams"]) >= 2 + # FIXME: this is a check against a flake in LocalStack + # sometimes the logStreams are created but not yet populated with events, so the snapshot fails + # assert that the stream has the events before returning + assert result["logStreams"][0]["firstEventTimestamp"] + return result["logStreams"] + + log_streams = retry(_get_log_stream, 60) + log_streams.sort(key=lambda stream: stream["creationTime"]) + snapshot.match("log-streams", log_streams) + + # collect events from log streams in group + def _get_events(): + _events = [] + + _response = ( + aws_client.logs.get_paginator("filter_log_events") + .paginate(logGroupName=logs_create_log_group) + .build_full_result() + ) + _events.extend(_response["events"]) + + if len(_events) < 2: + raise AssertionError( + f"Expected at least two events in log group streams, was {_events}" + ) + return _events + + events = retry(_get_events, retries=5) + + events.sort(key=lambda event: event["timestamp"]) + + snapshot.match("log-events", events) + + +class TestScheduleCron: + @markers.aws.validated + @pytest.mark.parametrize( + "schedule_cron", + [ + "cron(0 2 ? * SAT *)", # Run at 2:00 am every Saturday + "cron(0 12 * * ? *)", # Run at 12:00 pm every day + "cron(5,35 14 * * ? *)", # Run at 2:05 pm and 2:35 pm every day + "cron(15 10 ? * 6L 2002-2005)", # Run at 10:15 am on the last Friday of every month during the years 2002-2005 + "cron(0 2 ? * SAT#3 *)", # Run at 2:00 am on the third Saturday of every month + "cron(* * ? * SAT#3 *)", # Run every minute on the third Saturday of every month + "cron(0/5 5 ? JAN 1-5 2022)", # RUN every 5 minutes on the first 5 days of January 2022 + "cron(0 10 * * ? *)", # Run at 10:00 am every day + "cron(15 12 * * ? *)", # Run at 12:15 pm every day + "cron(0 18 ? * MON-FRI *)", # Run at 6:00 pm every Monday through Friday + "cron(0 8 1 * ? *)", # Run at 8:00 am on the 1st day of every month + "cron(0/15 * * * ? *)", # Run every 15 minutes + "cron(0/10 * ? * MON-FRI *)", # Run every 10 minutes Monday through Friday + "cron(0/5 8-17 ? * MON-FRI *)", # Run every 5 minutes Monday through Friday between 8:00 am and 5:55 pm + "cron(0/30 20-23 ? * MON-FRI *)", # Run every 30 minutes between 8:00 pm and 11:59 pm Monday through Friday + "cron(0/30 0-2 ? * MON-FRI *)", # Run every 30 minutes between 12:00 am and 2:00 am Monday through Friday + ], + ) + def tests_put_rule_with_schedule_cron( + self, schedule_cron, events_put_rule, aws_client, snapshot + ): + rule_name = f"rule-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + + response = events_put_rule(Name=rule_name, ScheduleExpression=schedule_cron) + snapshot.match("put-rule", response) + + response = aws_client.events.list_rules(NamePrefix=rule_name) + snapshot.match("list-rules", response) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not properly validate", + ) + @pytest.mark.parametrize( + "schedule_cron", + [ + "cron(0 1 * * * *)", # you can't specify the Day-of-month and Day-of-week fields in the same cron expression + "cron(7 20 * * NOT *)", + "cron(INVALID)", + "cron(0 dummy ? * MON-FRI *)", + "cron(71 8 1 * ? *)", + ], + ) + def tests_put_rule_with_invalid_schedule_cron(self, schedule_cron, events_put_rule, snapshot): + rule_name = f"rule-{short_uid()}" + + with pytest.raises(ClientError) as e: + events_put_rule(Name=rule_name, ScheduleExpression=schedule_cron) + snapshot.match("invalid-put-rule", e.value.response) + + @markers.aws.validated + @pytest.mark.skip("Flaky, target time can be 1min off message time") + def test_schedule_cron_target_sqs( + self, + sqs_as_events_target, + events_put_rule, + aws_client, + snapshot, + ): + queue_url, queue_arn = sqs_as_events_target() + + schedule_cron, target_datetime = get_cron_expression( + 1 + ) # only next full minut might be to fast for setup must be UTC time zone + + bus_name = "default" + rule_name = f"test-rule-{short_uid()}" + events_put_rule(Name=rule_name, EventBusName=bus_name, ScheduleExpression=schedule_cron) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + {"Id": target_id, "Arn": queue_arn}, + ], + ) + + time.sleep(120) # required to wait for time delta 1 minute starting from next full minute + messages = sqs_collect_messages(aws_client, queue_url, expected_events_count=1, retries=5) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("MD5OfBody"), + snapshot.transform.key_value("ReceiptHandle"), + snapshot.transform.regex(rule_name, ""), + ] + ) + snapshot.match("message", messages) + + # check if message was delivered at the correct time + time_message = events_time_string_to_timestamp( + json.loads(messages[0]["Body"])["time"] + ).replace(tzinfo=timezone.utc) + + # TODO fix JobScheduler to execute on exact time + # round datetime to nearest minute + if time_message.second > 0: + time_message += timedelta(minutes=1) + time_message = time_message.replace(second=0, microsecond=0) + + assert time_message == target_datetime + + @markers.aws.validated + def tests_scheduled_rule_does_not_trigger_on_put_events( + self, sqs_as_events_target, events_put_rule, aws_client + ): + queue_url, queue_arn = sqs_as_events_target() + + bus_name = "default" + rule_name = f"test-rule-{short_uid()}" + events_put_rule( + Name=rule_name, EventBusName=bus_name, ScheduleExpression="rate(10 minutes)" + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[ + { + "Id": target_id, + "Arn": queue_arn, + "Input": json.dumps({"custom-value": "somecustominput"}), + }, + ], + ) + test_event = { + "Source": "core.update-account-command", + "DetailType": "core.update-account-command", + "Detail": json.dumps({"command": ["update-account"]}), + } + aws_client.events.put_events(Entries=[test_event]) + + messages = aws_client.sqs.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=10 if is_aws_cloud() else 3 + ) + assert not messages.get("Messages") diff --git a/tests/aws/services/events/test_events_schedule.snapshot.json b/tests/aws/services/events/test_events_schedule.snapshot.json new file mode 100644 index 0000000000000..7f109f2af2451 --- /dev/null +++ b/tests/aws/services/events/test_events_schedule.snapshot.json @@ -0,0 +1,694 @@ +{ + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_schedule_rate": { + "recorded-date": "14-05-2024, 11:23:22", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "rate(1 minute)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_schedule_sqs_target": { + "recorded-date": "14-05-2024, 11:34:46", + "recorded-content": {} + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_put_rule_with_schedule_custom_event_bus": { + "recorded-date": "12-03-2025, 10:19:22", + "recorded-content": { + "put-rule-with-custom-event-bus-error": { + "Error": { + "Code": "ValidationException", + "Message": "ScheduleExpression is supported only on the default event bus." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_schedule_rate_target_sqs": { + "recorded-date": "15-05-2024, 08:57:51", + "recorded-content": { + "list-targets": { + "Targets": [ + { + "Arn": "arn::sqs::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-first": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "Scheduled Event", + "source": "aws.events", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::events::111111111111:rule/" + ], + "detail": {} + } + } + ], + "messages-second": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "Scheduled Event", + "source": "aws.events", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::events::111111111111:rule/" + ], + "detail": {} + } + } + ] + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron": { + "recorded-date": "14-05-2024, 14:50:51", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0 20 * * ? *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::test_schedule_cron_target_sqs": { + "recorded-date": "15-05-2024, 10:58:53", + "recorded-content": { + "message": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "version": "0", + "id": "", + "detail-type": "Scheduled Event", + "source": "aws.events", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::events::111111111111:rule/" + ], + "detail": {} + } + } + ] + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 10 * * ? *)]": { + "recorded-date": "22-01-2025, 13:22:45", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0 10 * * ? *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(15 12 * * ? *)]": { + "recorded-date": "22-01-2025, 13:22:46", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(15 12 * * ? *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 18 ? * MON-FRI *)]": { + "recorded-date": "22-01-2025, 13:22:47", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0 18 ? * MON-FRI *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 8 1 * ? *)]": { + "recorded-date": "22-01-2025, 13:22:47", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0 8 1 * ? *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/15 * * * ? *)]": { + "recorded-date": "22-01-2025, 13:22:48", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0/15 * * * ? *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/10 * ? * MON-FRI *)]": { + "recorded-date": "22-01-2025, 13:22:48", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0/10 * ? * MON-FRI *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/5 8-17 ? * MON-FRI *)]": { + "recorded-date": "22-01-2025, 13:22:49", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0/5 8-17 ? * MON-FRI *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/30 20-23 ? * MON-FRI *)]": { + "recorded-date": "22-01-2025, 13:22:49", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0/30 20-23 ? * MON-FRI *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/30 0-2 ? * MON-FRI *)]": { + "recorded-date": "22-01-2025, 13:22:50", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0/30 0-2 ? * MON-FRI *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_schedule_rate_custom_input_target_sqs": { + "recorded-date": "15-05-2024, 09:31:53", + "recorded-content": { + "list-targets": { + "Targets": [ + { + "Arn": "", + "Id": "", + "Input": { + "custom-value": "somecustominput" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "custom-value": "somecustominput" + } + } + ] + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(INVALID)]": { + "recorded-date": "22-01-2025, 13:55:49", + "recorded-content": { + "invalid-put-rule": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter ScheduleExpression is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(0 dummy ? * MON-FRI *)]": { + "recorded-date": "22-01-2025, 13:55:50", + "recorded-content": { + "invalid-put-rule": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter ScheduleExpression is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 2 ? * SAT *)]": { + "recorded-date": "22-01-2025, 13:22:42", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0 2 ? * SAT *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 12 * * ? *)]": { + "recorded-date": "22-01-2025, 13:22:42", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0 12 * * ? *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(5,35 14 * * ? *)]": { + "recorded-date": "22-01-2025, 13:22:43", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(5,35 14 * * ? *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(15 10 ? * 6L 2002-2005)]": { + "recorded-date": "22-01-2025, 13:22:43", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(15 10 ? * 6L 2002-2005)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 2 ? * SAT#3 *)]": { + "recorded-date": "22-01-2025, 13:22:44", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0 2 ? * SAT#3 *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(* * ? * SAT#3 *)]": { + "recorded-date": "22-01-2025, 13:22:44", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(* * ? * SAT#3 *)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/5 5 ? JAN 1-5 2022)]": { + "recorded-date": "22-01-2025, 13:22:45", + "recorded-content": { + "put-rule": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-rules": { + "Rules": [ + { + "Arn": "arn::events::111111111111:rule/", + "EventBusName": "default", + "Name": "", + "ScheduleExpression": "cron(0/5 5 ? JAN 1-5 2022)", + "State": "ENABLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(7 20 * * NOT *)]": { + "recorded-date": "22-01-2025, 13:55:49", + "recorded-content": { + "invalid-put-rule": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter ScheduleExpression is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(71 8 1 * ? *)]": { + "recorded-date": "22-01-2025, 13:55:50", + "recorded-content": { + "invalid-put-rule": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter ScheduleExpression is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(0 1 * * * *)]": { + "recorded-date": "22-01-2025, 13:55:48", + "recorded-content": { + "invalid-put-rule": { + "Error": { + "Code": "ValidationException", + "Message": "Parameter ScheduleExpression is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/events/test_events_schedule.validation.json b/tests/aws/services/events/test_events_schedule.validation.json new file mode 100644 index 0000000000000..2dce0326ca018 --- /dev/null +++ b/tests/aws/services/events/test_events_schedule.validation.json @@ -0,0 +1,143 @@ +{ + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::test_schedule_cron_target_sqs": { + "last_validated_date": "2024-05-15T10:58:53+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(0 1 * * * *)]": { + "last_validated_date": "2025-01-22T13:55:48+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(0 dummy ? * MON-FRI *)]": { + "last_validated_date": "2025-01-22T13:55:50+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(7 20 * * NOT *)]": { + "last_validated_date": "2025-01-22T13:55:49+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(71 8 1 * ? *)]": { + "last_validated_date": "2025-01-22T13:55:50+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_invalid_schedule_cron[cron(INVALID)]": { + "last_validated_date": "2025-01-22T13:55:49+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron": { + "last_validated_date": "2024-05-14T14:50:51+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(* * ? * SAT#3 *)]": { + "last_validated_date": "2025-01-22T13:22:44+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 10 * * ? *)]": { + "last_validated_date": "2025-01-22T13:22:45+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 12 * * ? *)]": { + "last_validated_date": "2025-01-22T13:22:42+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 18 ? * MON-FRI *)]": { + "last_validated_date": "2025-01-22T13:22:47+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 2 ? * SAT *)]": { + "last_validated_date": "2025-01-22T13:22:42+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 2 ? * SAT#3 *)]": { + "last_validated_date": "2025-01-22T13:22:44+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0 8 1 * ? *)]": { + "last_validated_date": "2025-01-22T13:22:47+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/10 * ? * MON-FRI *)]": { + "last_validated_date": "2025-01-22T13:22:48+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/15 * * * ? *)]": { + "last_validated_date": "2025-01-22T13:22:48+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/30 0-2 ? * MON-FRI *)]": { + "last_validated_date": "2025-01-22T13:22:50+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/30 20-23 ? * MON-FRI *)]": { + "last_validated_date": "2025-01-22T13:22:49+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/5 5 ? JAN 1-5 2022)]": { + "last_validated_date": "2025-01-22T13:22:45+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(0/5 8-17 ? * MON-FRI *)]": { + "last_validated_date": "2025-01-22T13:22:49+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(15 10 ? * 6L 2002-2005)]": { + "last_validated_date": "2025-01-22T13:22:43+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(15 12 * * ? *)]": { + "last_validated_date": "2025-01-22T13:22:46+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_put_rule_with_schedule_cron[cron(5,35 14 * * ? *)]": { + "last_validated_date": "2025-01-22T13:22:43+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleCron::tests_scheduled_rule_does_not_trigger_on_put_events": { + "last_validated_date": "2025-06-04T19:23:59+00:00", + "durations_in_seconds": { + "setup": 0.56, + "call": 11.78, + "teardown": 1.18, + "total": 13.52 + } + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[ rate(10 minutes)]": { + "last_validated_date": "2024-05-14T11:27:18+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate( 10 minutes )]": { + "last_validated_date": "2024-05-14T11:27:18+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate()]": { + "last_validated_date": "2024-05-14T11:27:13+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(-10 minutes)]": { + "last_validated_date": "2024-05-14T11:27:16+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(0 minutes)]": { + "last_validated_date": "2024-05-14T11:27:16+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(1 days)]": { + "last_validated_date": "2024-05-14T11:27:11+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(1 hours)]": { + "last_validated_date": "2024-05-14T11:27:10+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(1 minutes)]": { + "last_validated_date": "2024-05-14T11:27:09+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 MINUTES)]": { + "last_validated_date": "2024-05-14T11:27:17+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 day)]": { + "last_validated_date": "2024-05-14T11:27:13+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 hour)]": { + "last_validated_date": "2024-05-14T11:27:12+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 minute)]": { + "last_validated_date": "2024-05-14T11:27:11+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 minutess)]": { + "last_validated_date": "2024-05-14T11:27:14+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 seconds)]": { + "last_validated_date": "2024-05-14T11:27:08+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10 years)]": { + "last_validated_date": "2024-05-14T11:27:09+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(10)]": { + "last_validated_date": "2024-05-14T11:27:14+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_invalid_schedule_rate[rate(foo minutes)]": { + "last_validated_date": "2024-05-14T11:27:15+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::test_put_rule_with_schedule_rate": { + "last_validated_date": "2024-05-14T11:23:22+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_put_rule_with_schedule_custom_event_bus": { + "last_validated_date": "2025-03-12T10:19:22+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_schedule_rate_custom_input_target_sqs": { + "last_validated_date": "2024-05-15T09:31:53+00:00" + }, + "tests/aws/services/events/test_events_schedule.py::TestScheduleRate::tests_schedule_rate_target_sqs": { + "last_validated_date": "2024-05-15T08:57:51+00:00" + } +} diff --git a/tests/aws/services/events/test_events_tags.py b/tests/aws/services/events/test_events_tags.py new file mode 100644 index 0000000000000..239e5fb1a4720 --- /dev/null +++ b/tests/aws/services/events/test_events_tags.py @@ -0,0 +1,289 @@ +import json + +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from tests.aws.services.events.helper_functions import is_old_provider +from tests.aws.services.events.test_events import TEST_EVENT_PATTERN + + +@markers.aws.validated +@pytest.mark.parametrize("event_bus_name", ["event_bus_default", "event_bus_custom"]) +@pytest.mark.parametrize("resource_to_tag", ["event_bus", "rule"]) +def tests_tag_untag_resource( + event_bus_name, + resource_to_tag, + region_name, + account_id, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, +): + if event_bus_name == "event_bus_default": + bus_name = "default" + event_bus_arn = f"arn:aws:events:{region_name}:{account_id}:event-bus/default" + if event_bus_name == "event_bus_custom": + bus_name = f"test_bus-{short_uid()}" + response = events_create_event_bus(Name=bus_name) + event_bus_arn = response["EventBusArn"] + + if resource_to_tag == "event_bus": + resource_arn = event_bus_arn + if resource_to_tag == "rule": + rule_name = f"test_rule-{short_uid()}" + response = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + rule_arn = response["RuleArn"] + resource_arn = rule_arn + + tag_key_2 = "tag2" + response_tag_resource = aws_client.events.tag_resource( + ResourceARN=resource_arn, + Tags=[ + { + "Key": "tag1", + "Value": "value1", + }, + { + "Key": tag_key_2, + "Value": "value2", + }, + ], + ) + snapshot.match("tag_resource", response_tag_resource) + + response = aws_client.events.list_tags_for_resource(ResourceARN=resource_arn) + snapshot.match("list_tags_for_resource", response) + + response_untag_resource = aws_client.events.untag_resource( + ResourceARN=resource_arn, + TagKeys=[tag_key_2], + ) + snapshot.match("untag_resource", response_untag_resource) + + response = aws_client.events.list_tags_for_resource(ResourceARN=resource_arn) + snapshot.match("list_tags_for_untagged_resource", response) + + response_untag_resource_not_existing_tag = aws_client.events.untag_resource( + ResourceARN=resource_arn, + TagKeys=[f"not_existing_tag-{short_uid()}"], + ) + snapshot.match("untag_resource_not_existing_tag", response_untag_resource_not_existing_tag) + + +@markers.aws.validated +@pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", +) +@pytest.mark.parametrize("resource_to_tag", ["not_existing_rule", "not_existing_event_bus"]) +def tests_tag_list_untag_not_existing_resource( + resource_to_tag, + region_name, + account_id, + aws_client, + snapshot, +): + resource_name = short_uid() + if resource_to_tag == "not_existing_rule": + resource_arn = f"arn:aws:events:{region_name}:{account_id}:rule/{resource_name}" + if resource_to_tag == "not_existing_event_bus": + resource_arn = f"arn:aws:events:{region_name}:{account_id}:event-bus/{resource_name}" + + tag_key_1 = "tag1" + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as error: + aws_client.events.tag_resource( + ResourceARN=resource_arn, + Tags=[ + { + "Key": tag_key_1, + "Value": "value1", + }, + ], + ) + + snapshot.match("tag_not_existing_resource_error", error.value.response) + + snapshot.add_transformer( + snapshot.transform.regex(resource_name, "") + ) + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as error: + aws_client.events.list_tags_for_resource(ResourceARN=resource_arn) + snapshot.match("list_tags_for_not_existing_resource_error", error.value.response) + + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as error: + aws_client.events.untag_resource( + ResourceARN=resource_arn, + TagKeys=[tag_key_1], + ) + snapshot.match("untag_not_existing_resource_error", error.value.response) + + +@markers.aws.validated +@pytest.mark.parametrize("event_bus_name", ["event_bus_default", "event_bus_custom"]) +@pytest.mark.parametrize("resource_to_tag", ["event_bus", "rule"]) +def test_recreate_tagged_resource_without_tags( + event_bus_name, + resource_to_tag, + region_name, + account_id, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, +): + if event_bus_name == "event_bus_default": + bus_name = "default" + event_bus_arn = f"arn:aws:events:{region_name}:{account_id}:event-bus/default" + if event_bus_name == "event_bus_custom": + bus_name = f"test_bus-{short_uid()}" + response = events_create_event_bus(Name=bus_name) + event_bus_arn = response["EventBusArn"] + + if resource_to_tag == "event_bus": + resource_arn = event_bus_arn + if resource_to_tag == "rule": + rule_name = f"test_rule-{short_uid()}" + response = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + rule_arn = response["RuleArn"] + resource_arn = rule_arn + + aws_client.events.tag_resource( + ResourceARN=resource_arn, + Tags=[ + { + "Key": "tag1", + "Value": "value1", + } + ], + ) + + if resource_to_tag == "event_bus" and event_bus_name == "event_bus_custom": + aws_client.events.delete_event_bus(Name=bus_name) + events_create_event_bus(Name=bus_name) + + if resource_to_tag == "rule": + aws_client.events.delete_rule(Name=rule_name, EventBusName=bus_name) + events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + response = aws_client.events.list_tags_for_resource(ResourceARN=resource_arn) + snapshot.match("list_tags_for_resource", response) + + +class TestRuleTags: + @markers.aws.validated + def test_put_rule_with_tags( + self, events_create_event_bus, events_put_rule, aws_client, snapshot + ): + bus_name = f"test_bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"test_rule-{short_uid()}" + response_put_rule = events_put_rule( + Name=rule_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + Tags=[ + { + "Key": "tag1", + "Value": "value1", + }, + { + "Key": "tag2", + "Value": "value2", + }, + ], + ) + rule_arn = response_put_rule["RuleArn"] + snapshot.match("put_rule_with_tags", response_put_rule) + + response_put_rule = aws_client.events.list_tags_for_resource(ResourceARN=rule_arn) + snapshot.add_transformer(snapshot.transform.regex(rule_name, "")) + snapshot.match("list_tags_for_rule", response_put_rule) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_list_tags_for_deleted_rule( + self, events_create_event_bus, events_put_rule, aws_client, snapshot + ): + bus_name = f"test_bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"test_rule-{short_uid()}" + response_put_rule = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + rule_arn = response_put_rule["RuleArn"] + + aws_client.events.delete_rule(Name=rule_name, EventBusName=bus_name) + + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as error: + aws_client.events.list_tags_for_resource(ResourceARN=rule_arn) + + snapshot.add_transformer( + [ + snapshot.transform.regex(rule_name, ""), + snapshot.transform.regex(bus_name, ""), + ] + ) + snapshot.match("list_tags_for_deleted_rule_error", error.value.response) + + +class TestEventBusTags: + @markers.aws.validated + def test_create_event_bus_with_tags(self, events_create_event_bus, aws_client, snapshot): + bus_name = f"test_bus-{short_uid()}" + response_create_event_bus = events_create_event_bus( + Name=bus_name, + Tags=[ + { + "Key": "tag1", + "Value": "value1", + }, + { + "Key": "tag2", + "Value": "value2", + }, + ], + ) + bus_arn = response_create_event_bus["EventBusArn"] + snapshot.match("create_event_bus_with_tags", response_create_event_bus) + + response_create_event_bus = aws_client.events.list_tags_for_resource(ResourceARN=bus_arn) + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + snapshot.match("list_tags_for_event_bus", response_create_event_bus) + + @markers.aws.validated + @pytest.mark.skipif( + is_old_provider(), + reason="V1 provider does not support this feature", + ) + def test_list_tags_for_deleted_event_bus(self, events_create_event_bus, aws_client, snapshot): + bus_name = f"test_bus-{short_uid()}" + response = events_create_event_bus(Name=bus_name) + bus_arn = response["EventBusArn"] + + aws_client.events.delete_event_bus(Name=bus_name) + + with pytest.raises(aws_client.events.exceptions.ResourceNotFoundException) as error: + aws_client.events.list_tags_for_resource(ResourceARN=bus_arn) + + snapshot.add_transformer(snapshot.transform.regex(bus_name, "")) + snapshot.match("list_tags_for_deleted_rule_error", error.value.response) diff --git a/tests/aws/services/events/test_events_tags.snapshot.json b/tests/aws/services/events/test_events_tags.snapshot.json new file mode 100644 index 0000000000000..a97ccf1d31798 --- /dev/null +++ b/tests/aws/services/events/test_events_tags.snapshot.json @@ -0,0 +1,505 @@ +{ + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus]": { + "recorded-date": "15-05-2024, 14:57:52", + "recorded-content": { + "tag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_untagged_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule]": { + "recorded-date": "15-05-2024, 14:57:54", + "recorded-content": { + "tag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_untagged_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_list_untag_not_existing_resource[not_existing_rule]": { + "recorded-date": "12-03-2025, 10:19:54", + "recorded-content": { + "tag_not_existing_resource_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Rule does not exist on EventBus default." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_tags_for_not_existing_resource_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Rule does not exist on EventBus default." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "untag_not_existing_resource_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Rule does not exist on EventBus default." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_list_untag_not_existing_resource[not_existing_event_bus]": { + "recorded-date": "12-03-2025, 10:19:56", + "recorded-content": { + "tag_not_existing_resource_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_tags_for_not_existing_resource_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "untag_not_existing_resource_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::TestRuleTags::test_put_rule_with_tags": { + "recorded-date": "15-05-2024, 14:58:01", + "recorded-content": { + "put_rule_with_tags": { + "RuleArn": "arn::events::111111111111:rule/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_rule": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::TestRuleTags::test_list_tags_for_deleted_rule": { + "recorded-date": "12-03-2025, 10:19:42", + "recorded-content": { + "list_tags_for_deleted_rule_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Rule does not exist on EventBus ." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::TestEventBusTags::test_create_event_bus_with_tags": { + "recorded-date": "15-05-2024, 14:58:04", + "recorded-content": { + "create_event_bus_with_tags": { + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_event_bus": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::TestEventBusTags::test_list_tags_for_deleted_event_bus": { + "recorded-date": "12-03-2025, 10:19:32", + "recorded-content": { + "list_tags_for_deleted_rule_error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event bus does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus-event_bus_default]": { + "recorded-date": "16-05-2024, 11:45:30", + "recorded-content": { + "tag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_untagged_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_not_existing_tag": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus-event_bus_custom]": { + "recorded-date": "16-05-2024, 11:45:31", + "recorded-content": { + "tag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_untagged_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_not_existing_tag": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule-event_bus_default]": { + "recorded-date": "16-05-2024, 11:45:32", + "recorded-content": { + "tag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_untagged_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_not_existing_tag": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule-event_bus_custom]": { + "recorded-date": "16-05-2024, 11:45:34", + "recorded-content": { + "tag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_untagged_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_not_existing_tag": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[event_bus-event_bus_default]": { + "recorded-date": "16-05-2024, 12:13:16", + "recorded-content": { + "list_tags_for_resource": { + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[event_bus-event_bus_custom]": { + "recorded-date": "16-05-2024, 12:13:17", + "recorded-content": { + "list_tags_for_resource": { + "Tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[rule-event_bus_default]": { + "recorded-date": "16-05-2024, 12:13:18", + "recorded-content": { + "list_tags_for_resource": { + "Tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[rule-event_bus_custom]": { + "recorded-date": "16-05-2024, 12:13:20", + "recorded-content": { + "list_tags_for_resource": { + "Tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/events/test_events_tags.validation.json b/tests/aws/services/events/test_events_tags.validation.json new file mode 100644 index 0000000000000..5b320806c3413 --- /dev/null +++ b/tests/aws/services/events/test_events_tags.validation.json @@ -0,0 +1,50 @@ +{ + "tests/aws/services/events/test_events_tags.py::TestEventBusTags::test_create_event_bus_with_tags": { + "last_validated_date": "2024-05-15T14:58:04+00:00" + }, + "tests/aws/services/events/test_events_tags.py::TestEventBusTags::test_list_tags_for_deleted_event_bus": { + "last_validated_date": "2025-03-12T10:19:32+00:00" + }, + "tests/aws/services/events/test_events_tags.py::TestRuleTags::test_list_tags_for_deleted_rule": { + "last_validated_date": "2025-03-12T10:19:42+00:00" + }, + "tests/aws/services/events/test_events_tags.py::TestRuleTags::test_put_rule_with_tags": { + "last_validated_date": "2024-05-15T14:58:01+00:00" + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[event_bus-event_bus_custom]": { + "last_validated_date": "2024-05-16T12:13:17+00:00" + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[event_bus-event_bus_default]": { + "last_validated_date": "2024-05-16T12:13:16+00:00" + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[rule-event_bus_custom]": { + "last_validated_date": "2024-05-16T12:13:20+00:00" + }, + "tests/aws/services/events/test_events_tags.py::test_recreate_tagged_resource_without_tags[rule-event_bus_default]": { + "last_validated_date": "2024-05-16T12:13:18+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_list_untag_not_existing_resource[not_existing_event_bus]": { + "last_validated_date": "2025-03-12T10:19:56+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_list_untag_not_existing_resource[not_existing_rule]": { + "last_validated_date": "2025-03-12T10:19:54+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus-event_bus_custom]": { + "last_validated_date": "2024-05-16T11:45:31+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus-event_bus_default]": { + "last_validated_date": "2024-05-16T11:45:30+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[event_bus]": { + "last_validated_date": "2024-05-15T14:57:52+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule-event_bus_custom]": { + "last_validated_date": "2024-05-16T11:45:34+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule-event_bus_default]": { + "last_validated_date": "2024-05-16T11:45:32+00:00" + }, + "tests/aws/services/events/test_events_tags.py::tests_tag_untag_resource[rule]": { + "last_validated_date": "2024-05-15T14:57:54+00:00" + } +} diff --git a/tests/aws/services/events/test_events_targets.py b/tests/aws/services/events/test_events_targets.py new file mode 100644 index 0000000000000..a4c641466f4d2 --- /dev/null +++ b/tests/aws/services/events/test_events_targets.py @@ -0,0 +1,1421 @@ +"""Tests for integrations between AWS EventBridge and other AWS services. +Tests are separated in different classes for each target service. +Classes are ordered alphabetically.""" + +import base64 +import json +import time + +import aws_cdk as cdk +import pytest +from pytest_httpserver import HTTPServer +from werkzeug import Request, Response + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import poll_condition, retry +from localstack.utils.testutil import check_expected_lambda_log_events_length +from tests.aws.scenario.kinesis_firehose.conftest import get_all_expected_messages_from_s3 +from tests.aws.services.events.helper_functions import is_old_provider, sqs_collect_messages +from tests.aws.services.events.test_api_destinations_and_connection import API_DESTINATION_AUTHS +from tests.aws.services.events.test_events import TEST_EVENT_DETAIL, TEST_EVENT_PATTERN +from tests.aws.services.firehose.helper_functions import get_firehose_iam_documents +from tests.aws.services.kinesis.helper_functions import get_shard_iterator +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_AWS_PROXY_FORMAT, + TEST_LAMBDA_PYTHON_ECHO, +) + +# TODO: +# These tests should go into LocalStack Pro: +# - AppSync (pro) +# - Batch (pro) +# - Container (pro) +# - Redshift (pro) +# - Sagemaker (pro) + + +class TestEventsTargetApiDestination: + # TODO validate against AWS & use common fixtures + @markers.aws.only_localstack + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + @pytest.mark.parametrize("auth", API_DESTINATION_AUTHS) + def test_put_events_to_target_api_destinations( + self, httpserver: HTTPServer, auth, aws_client, clean_up + ): + token = short_uid() + bearer = f"Bearer {token}" + + def _handler(_request: Request): + return Response( + json.dumps( + { + "access_token": token, + "token_type": "Bearer", + "expires_in": 86400, + } + ), + mimetype="application/json", + ) + + httpserver.expect_request("").respond_with_handler(_handler) + http_endpoint = httpserver.url_for("/") + + if auth.get("type") == "OAUTH_CLIENT_CREDENTIALS": + auth["parameters"]["AuthorizationEndpoint"] = http_endpoint + + connection_name = f"c-{short_uid()}" + connection_arn = aws_client.events.create_connection( + Name=connection_name, + AuthorizationType=auth.get("type"), + AuthParameters={ + auth.get("key"): auth.get("parameters"), + "InvocationHttpParameters": { + "BodyParameters": [ + { + "Key": "connection_body_param", + "Value": "value", + "IsValueSecret": False, + }, + ], + "HeaderParameters": [ + { + "Key": "connection-header-param", + "Value": "value", + "IsValueSecret": False, + }, + { + "Key": "overwritten-header", + "Value": "original", + "IsValueSecret": False, + }, + ], + "QueryStringParameters": [ + { + "Key": "connection_query_param", + "Value": "value", + "IsValueSecret": False, + }, + { + "Key": "overwritten_query", + "Value": "original", + "IsValueSecret": False, + }, + ], + }, + }, + )["ConnectionArn"] + + # create api destination + dest_name = f"d-{short_uid()}" + result = aws_client.events.create_api_destination( + Name=dest_name, + ConnectionArn=connection_arn, + InvocationEndpoint=http_endpoint, + HttpMethod="POST", + ) + + # create rule and target + rule_name = f"r-{short_uid()}" + target_id = f"target-{short_uid()}" + pattern = json.dumps( + {"source": ["source-123"], "detail-type": ["type-123"]} + ) # TODO use standard defined event and pattern + aws_client.events.put_rule(Name=rule_name, EventPattern=pattern) + aws_client.events.put_targets( + Rule=rule_name, + Targets=[ + { + "Id": target_id, + "Arn": result["ApiDestinationArn"], + "Input": '{"target_value":"value"}', + "HttpParameters": { + "PathParameterValues": ["target_path"], + "HeaderParameters": { + "target-header": "target_header_value", + "overwritten_header": "changed", + }, + "QueryStringParameters": { + "target_query": "t_query", + "overwritten_query": "changed", + }, + }, + } + ], + ) + + entries = [ + { + "Source": "source-123", + "DetailType": "type-123", + "Detail": '{"i": 0}', + } + ] + aws_client.events.put_events(Entries=entries) + + # clean up + aws_client.events.delete_connection(Name=connection_name) + aws_client.events.delete_api_destination(Name=dest_name) + clean_up(rule_name=rule_name, target_ids=target_id) + + to_recv = 2 if auth["type"] == "OAUTH_CLIENT_CREDENTIALS" else 1 + assert poll_condition(lambda: len(httpserver.log) >= to_recv, timeout=5) + + event_request, _ = httpserver.log[-1] + event = event_request.get_json(force=True) + headers = event_request.headers + query_args = event_request.args + + # Connection data validation + assert event["connection_body_param"] == "value" + assert headers["Connection-Header-Param"] == "value" + assert query_args["connection_query_param"] == "value" + + # Target parameters validation + assert "/target_path" in event_request.path + assert event["target_value"] == "value" + assert headers["Target-Header"] == "target_header_value" + assert query_args["target_query"] == "t_query" + + # connection/target overwrite test + assert headers["Overwritten-Header"] == "original" + assert query_args["overwritten_query"] == "original" + + # Auth validation + match auth["type"]: + case "BASIC": + user_pass = to_str(base64.b64encode(b"user:pass")) + assert headers["Authorization"] == f"Basic {user_pass}" + case "API_KEY": + assert headers["Api"] == "apikey_secret" + + case "OAUTH_CLIENT_CREDENTIALS": + assert headers["Authorization"] == bearer + + oauth_request, _ = httpserver.log[0] + oauth_login = oauth_request.get_json(force=True) + # Oauth login validation + assert oauth_login["client_id"] == "id" + assert oauth_login["client_secret"] == "password" + assert oauth_login["oauthbody"] == "value1" + assert oauth_request.headers["oauthheader"] == "value2" + assert oauth_request.args["oauthquery"] == "value3" + + +class TestEventsTargetApiGateway: + @markers.aws.validated + @pytest.mark.skipif( + condition=is_old_provider() and not is_aws_cloud(), + reason="not supported by the old provider", + ) + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: those headers are sent by Events via the SDK, we should at least populate X-Amz-Source-Account + # and X-Amz-Source-Arn + "$..headers.amz-sdk-invocation-id", + "$..headers.amz-sdk-request", + "$..headers.amz-sdk-retry", + "$..headers.X-Amz-Security-Token", + "$..headers.X-Amz-Source-Account", + "$..headers.X-Amz-Source-Arn", + # seems like this one can vary in casing between runs? + "$..headers.x-amz-date", + "$..headers.X-Amz-Date", + # those headers are missing in API Gateway + "$..headers.CloudFront-Forwarded-Proto", + "$..headers.CloudFront-Is-Desktop-Viewer", + "$..headers.CloudFront-Is-Mobile-Viewer", + "$..headers.CloudFront-Is-SmartTV-Viewer", + "$..headers.CloudFront-Is-Tablet-Viewer", + "$..headers.CloudFront-Viewer-ASN", + "$..headers.CloudFront-Viewer-Country", + "$..headers.X-Amz-Cf-Id", + "$..headers.Via", + # sent by `requests` library by default + "$..headers.Accept-Encoding", + "$..headers.Accept", + ] + ) + @markers.snapshot.skip_snapshot_verify( + condition=lambda: not config.APIGW_NEXT_GEN_PROVIDER, + paths=[ + # parity issue from previous APIGW implementation + "$..headers.x-localstack-edge", + "$..headers.Connection", + "$..headers.Content-Length", + "$..headers.accept-encoding", + "$..headers.accept", + "$..headers.X-Amzn-Trace-Id", + "$..headers.X-Forwarded-Port", + "$..headers.X-Forwarded-Proto", + "$..pathParameters", + "$..requestContext.authorizer", + "$..requestContext.deploymentId", + "$..requestContext.extendedRequestId", + "$..requestContext.identity", + "$..requestContext.requestId", + "$..stageVariables", + ], + ) + def test_put_events_with_target_api_gateway( + self, + create_lambda_function, + create_rest_apigw, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + create_role_with_policy, + region_name, + account_id, + ): + snapshot.add_transformers_list( + [ + *snapshot.transform.lambda_api(), + *snapshot.transform.apigateway_api(), + *snapshot.transform.apigateway_proxy_event(), + snapshot.transform.key_value("CodeSha256"), + snapshot.transform.key_value("EventId", reference_replacement=False), + snapshot.transform.key_value( + "multiValueHeaders", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value("apiId"), + snapshot.transform.key_value("amz-sdk-request"), + snapshot.transform.key_value("amz-sdk-retry"), + snapshot.transform.key_value("X-Amz-Date"), + snapshot.transform.key_value("x-amz-date"), + # Events use the Java SDK to forward the event, and the User-Agent reflects that + snapshot.transform.key_value("User-Agent"), + snapshot.transform.key_value("X-Forwarded-For", reference_replacement=False), + snapshot.transform.key_value("X-Forwarded-Port", reference_replacement=False), + snapshot.transform.key_value("X-Forwarded-Proto", reference_replacement=False), + ] + ) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("X-Amz-Security-Token", reference_replacement=False), + snapshot.transform.key_value("domainName"), + snapshot.transform.key_value("amz-sdk-invocation-id", reference_replacement=False), + snapshot.transform.key_value("CloudFront-Viewer-ASN", reference_replacement=False), + snapshot.transform.key_value( + "CloudFront-Viewer-Country", reference_replacement=False + ), + ], + priority=-2, + ) + + # Step a: Create a Lambda function with a unique name using the existing fixture + function_name = f"test-lambda-{short_uid()}" + + # Create the Lambda function with the correct handler + create_lambda_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_AWS_PROXY_FORMAT, + handler="lambda_aws_proxy_format.handler", + runtime=Runtime.python3_12, + ) + lambda_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + snapshot.match("create_lambda_response", create_lambda_response) + + # Step b: Set up an API Gateway + api_id, _, root_id = create_rest_apigw( + name=f"test-api-${short_uid()}", + description="Test Integration with EventBridge", + ) + + # Create a resource under the root + resource_response = aws_client.apigateway.create_resource( + restApiId=api_id, + parentId=root_id, + pathPart="test", + ) + resource_id = resource_response["id"] + + # Set up POST method + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + + # Define source_arn + source_arn = f"arn:aws:execute-api:{region_name}:{account_id}:{api_id}/*/POST/test" + + # Integrate the method with the Lambda function + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations", + ) + + # Give permission to API Gateway to invoke Lambda + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"sid-{short_uid()}", + Action="lambda:InvokeFunction", + Principal="apigateway.amazonaws.com", + SourceArn=source_arn, + ) + + # Deploy the API to a 'test' stage + stage_name = "test" + deployment = aws_client.apigateway.create_deployment( + restApiId=api_id, + stageName=stage_name, + ) + snapshot.match("deployment_response", deployment) + + # Step c: Create a new event bus + event_bus_name = f"test-bus-{short_uid()}" + event_bus_response = events_create_event_bus(Name=event_bus_name) + snapshot.match("event_bus_response", event_bus_response) + + # Step d: Create a rule on this bus + rule_name = f"test-rule-{short_uid()}" + event_pattern = {"source": ["test.source"], "detail-type": ["test.detail.type"]} + rule_response = events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(event_pattern), + ) + snapshot.match("rule_response", rule_response) + + # Step e: Create an IAM Role for EventBridge to invoke API Gateway + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + role_name, role_arn = create_role_with_policy( + effect="Allow", + actions="execute-api:Invoke", + assume_policy_doc=json.dumps(assume_role_policy_document), + resource=source_arn, + attach=False, # Since we're using put_role_policy, not attach_role_policy + ) + + # Allow some time for IAM role propagation (only needed in AWS) + if is_aws_cloud(): + time.sleep(10) + + # Step f: Add the API Gateway as a target with the RoleArn + target_id = f"target-{short_uid()}" + api_target_arn = ( + f"arn:aws:execute-api:{region_name}:{account_id}:{api_id}/{stage_name}/POST/test" + ) + + # TODO: test path parameters, headers and query strings + put_targets_response = aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[ + { + "Id": target_id, + "Arn": api_target_arn, + "RoleArn": role_arn, + "Input": json.dumps({"message": "Hello from EventBridge"}), + "RetryPolicy": {"MaximumRetryAttempts": 0}, + } + ], + ) + snapshot.match("put_targets_response", put_targets_response) + assert put_targets_response["FailedEntryCount"] == 0 + + # Step g: Send an event to EventBridge + event_entry = { + "EventBusName": event_bus_name, + "Source": "test.source", + "DetailType": "test.detail.type", + "Detail": json.dumps({"message": "Hello from EventBridge"}), + } + put_events_response = aws_client.events.put_events(Entries=[event_entry]) + snapshot.match("put_events_response", put_events_response) + assert put_events_response["FailedEntryCount"] == 0 + + # Step h: Verify the Lambda invocation + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=10, + sleep_before=10 if is_aws_cloud() else 1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + snapshot.match("lambda_logs", events) + + +class TestEventsTargetCloudWatchLogs: + @markers.aws.validated + def test_put_events_with_target_cloudwatch_logs( + self, + events_create_event_bus, + events_put_rule, + events_log_group, + aws_client, + snapshot, + cleanups, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("EventId"), + snapshot.transform.key_value("RuleArn"), + snapshot.transform.key_value("EventBusArn"), + ] + ) + + event_bus_name = f"test-bus-{short_uid()}" + event_bus_response = events_create_event_bus(Name=event_bus_name) + snapshot.match("event_bus_response", event_bus_response) + + log_group = events_log_group() + log_group_name = log_group["log_group_name"] + log_group_arn = log_group["log_group_arn"] + + resource_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EventBridgePutLogEvents", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": f"{log_group_arn}:*", + } + ], + } + policy_name = f"EventBridgePolicy-{short_uid()}" + aws_client.logs.put_resource_policy( + policyName=policy_name, policyDocument=json.dumps(resource_policy) + ) + + if is_aws_cloud(): + # Wait for IAM role propagation in AWS cloud environment before proceeding + # This delay is necessary as IAM changes can take several seconds to propagate globally + time.sleep(10) + + rule_name = f"test-rule-{short_uid()}" + rule_response = events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + snapshot.match("rule_response", rule_response) + + target_id = f"target-{short_uid()}" + put_targets_response = aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[ + { + "Id": target_id, + "Arn": log_group_arn, + } + ], + ) + snapshot.match("put_targets_response", put_targets_response) + assert put_targets_response["FailedEntryCount"] == 0 + + event_entry = { + "EventBusName": event_bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + put_events_response = aws_client.events.put_events(Entries=[event_entry]) + snapshot.match("put_events_response", put_events_response) + assert put_events_response["FailedEntryCount"] == 0 + + def get_log_events(): + response = aws_client.logs.describe_log_streams(logGroupName=log_group_name) + log_streams = response.get("logStreams", []) + assert log_streams, "No log streams found" + + log_stream_name = log_streams[0]["logStreamName"] + events_response = aws_client.logs.get_log_events( + logGroupName=log_group_name, + logStreamName=log_stream_name, + ) + events = events_response.get("events", []) + assert events, "No log events found" + return events + + events = retry(get_log_events, retries=5, sleep=5) + snapshot.match("log_events", events) + + +class TestEventsTargetEvents: + # cross region and cross account event bus to event buss tests are in test_events_cross_account_region.py + + @markers.aws.validated + @pytest.mark.parametrize( + "bus_combination", [("default", "custom"), ("custom", "custom"), ("custom", "default")] + ) + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_put_events_with_target_events( + self, + bus_combination, + events_create_event_bus, + region_name, + account_id, + events_put_rule, + create_role_event_bus_source_to_bus_target, + sqs_as_events_target, + aws_client, + snapshot, + ): + # Create event buses + bus_source, bus_target = bus_combination + if bus_source == "default": + event_bus_name_source = "default" + if bus_source == "custom": + event_bus_name_source = f"test-event-bus-source-{short_uid()}" + events_create_event_bus(Name=event_bus_name_source) + if bus_target == "default": + event_bus_name_target = "default" + event_bus_arn_target = f"arn:aws:events:{region_name}:{account_id}:event-bus/default" + if bus_target == "custom": + event_bus_name_target = f"test-event-bus-target-{short_uid()}" + event_bus_arn_target = events_create_event_bus(Name=event_bus_name_target)[ + "EventBusArn" + ] + + # Create permission for event bus source to send events to event bus target + role_arn_bus_source_to_bus_target = create_role_event_bus_source_to_bus_target() + + if is_aws_cloud(): + time.sleep(10) # required for role propagation + + # Permission for event bus target to receive events from event bus source + aws_client.events.put_permission( + StatementId=f"TargetEventBusAccessPermission{short_uid()}", + EventBusName=event_bus_name_target, + Action="events:PutEvents", + Principal="*", + ) + + # Create rule source event bus to target + rule_name_source_to_target = f"test-rule-source-to-target-{short_uid()}" + events_put_rule( + Name=rule_name_source_to_target, + EventBusName=event_bus_name_source, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + # Add target event bus as target + target_id_event_bus_target = f"test-target-source-events-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name_source_to_target, + EventBusName=event_bus_name_source, + Targets=[ + { + "Id": target_id_event_bus_target, + "Arn": event_bus_arn_target, + "RoleArn": role_arn_bus_source_to_bus_target, + } + ], + ) + + # Setup sqs target for target event bus + rule_name_target_to_sqs = f"test-rule-target-{short_uid()}" + events_put_rule( + Name=rule_name_target_to_sqs, + EventBusName=event_bus_name_target, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + queue_url, queue_arn = sqs_as_events_target() + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name_target_to_sqs, + EventBusName=event_bus_arn_target, + Targets=[ + {"Id": target_id, "Arn": queue_arn}, + ], + ) + + ###### + # Test + ###### + + # Put events into primary event bus + aws_client.events.put_events( + Entries=[ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + "EventBusName": event_bus_name_source, + } + ], + ) + + # Collect messages from primary queue + messages = sqs_collect_messages( + aws_client, queue_url, expected_events_count=1, wait_time=1, retries=5 + ) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + ], + ) + snapshot.match("messages", messages) + + +class TestEventsTargetFirehose: + @markers.aws.validated + def test_put_events_with_target_firehose( + self, + aws_client, + create_iam_role_with_policy, + s3_bucket, + firehose_create_delivery_stream, + events_create_event_bus, + events_put_rule, + s3_empty_bucket, + snapshot, + ): + # create firehose target bucket + bucket_arn = arns.s3_bucket_arn(s3_bucket) + + # Create access policy for firehose + role_policy, policy_document = get_firehose_iam_documents(bucket_arn, "*") + + firehose_delivery_stream_to_s3_role_arn = create_iam_role_with_policy( + RoleDefinition=role_policy, PolicyDefinition=policy_document + ) + + if is_aws_cloud(): + time.sleep(10) # AWS IAM propagation delay + + # create firehose delivery stream to s3 + delivery_stream_name = f"test-delivery-stream-{short_uid()}" + s3_prefix = "testeventdata" + + delivery_stream_arn = firehose_create_delivery_stream( + DeliveryStreamName=delivery_stream_name, + DeliveryStreamType="DirectPut", + ExtendedS3DestinationConfiguration={ + "BucketARN": bucket_arn, + "RoleARN": firehose_delivery_stream_to_s3_role_arn, + "Prefix": s3_prefix, + "BufferingHints": {"SizeInMBs": 1, "IntervalInSeconds": 1}, + }, + )["DeliveryStreamARN"] + + # Create event bus, rule and target + event_bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + + rule_name = f"rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + # Create IAM role event bridge bus to firehose delivery stream + assume_role_policy_document_bus_to_firehose = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + policy_document_bus_to_firehose = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": ["firehose:PutRecord", "firehose:PutRecordBatch"], + "Resource": delivery_stream_arn, + } + ], + } + + event_bridge_bus_to_firehose_role_arn = create_iam_role_with_policy( + RoleDefinition=assume_role_policy_document_bus_to_firehose, + PolicyDefinition=policy_document_bus_to_firehose, + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[ + { + "Id": target_id, + "Arn": delivery_stream_arn, + "RoleArn": event_bridge_bus_to_firehose_role_arn, + } + ], + ) + + if is_aws_cloud(): + time.sleep( + 30 + ) # not clear yet why but firehose needs time to receive events event though status is ACTIVE + + for _ in range(10): + aws_client.events.put_events( + Entries=[ + { + "EventBusName": event_bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + ###### + # Test + ###### + + if is_aws_cloud(): + sleep = 10 + retries = 30 + else: + sleep = 1 + retries = 5 + + bucket_data = get_all_expected_messages_from_s3( + aws_client, + s3_bucket, + expected_message_count=10, + sleep=sleep, + retries=retries, + ) + snapshot.match("s3", bucket_data) + + # empty and delete bucket + s3_empty_bucket(s3_bucket) + aws_client.s3.delete_bucket(Bucket=s3_bucket) + + +class TestEventsTargetKinesis: + @markers.aws.validated + def test_put_events_with_target_kinesis( + self, + kinesis_create_stream, + wait_for_stream_ready, + create_iam_role_with_policy, + aws_client, + events_create_event_bus, + events_put_rule, + snapshot, + ): + # Create a Kinesis stream + stream_name = kinesis_create_stream(ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name) + + # Create IAM role event bridge bus to kinesis stream + assume_role_policy_document_bus_to_kinesis = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + policy_document_bus_to_kinesis = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": ["kinesis:PutRecord", "kinesis:PutRecords"], + "Resource": stream_arn, + } + ], + } + event_bridge_bus_to_kinesis_role_arn = create_iam_role_with_policy( + RoleDefinition=assume_role_policy_document_bus_to_kinesis, + PolicyDefinition=policy_document_bus_to_kinesis, + ) + + # Create an event bus + event_bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + + rule_name = f"rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[ + { + "Id": target_id, + "Arn": stream_arn, + "RoleArn": event_bridge_bus_to_kinesis_role_arn, + "KinesisParameters": {"PartitionKeyPath": "$.detail-type"}, + } + ], + ) + + if is_aws_cloud(): + time.sleep( + 30 + ) # cold start of connection event bus to kinesis takes some time until messages can be sent + + aws_client.events.put_events( + Entries=[ + { + "EventBusName": event_bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + shard_iterator = get_shard_iterator(stream_name, aws_client.kinesis) + response = aws_client.kinesis.get_records(ShardIterator=shard_iterator) + + assert len(response["Records"]) == 1 + + data = response["Records"][0]["Data"].decode("utf-8") + + snapshot.match("response", data) + + +class TestEventsTargetLambda: + @markers.aws.validated + def test_put_events_with_target_lambda( + self, + create_lambda_function, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + ): + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"rule-{short_uid()}" + rule_arn = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + )["RuleArn"] + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": lambda_function_arn}], + ) + + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + # Get lambda's log events + events = retry( + check_expected_lambda_log_events_length, + retries=3, + sleep=1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + + snapshot.match("events", events) + + @markers.aws.validated + def test_put_events_with_target_lambda_list_entry( + self, create_lambda_function, events_create_event_bus, events_put_rule, aws_client, snapshot + ): + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + event_pattern = {"detail": {"payload": {"automations": {"id": [{"exists": True}]}}}} + + bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"rule-{short_uid()}" + rule_arn = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(event_pattern), + )["RuleArn"] + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": lambda_function_arn}], + ) + + event_detail = { + "payload": { + "userId": 10, + "businessId": 3, + "channelId": 6, + "card": {"foo": "bar"}, + "targetEntity": True, + "entityAuditTrailEvent": {"foo": "bar"}, + "automations": [ + { + "id": "123", + "actions": [ + { + "id": "321", + "type": "SEND_NOTIFICATION", + "settings": { + "message": "", + "recipientEmails": [], + "subject": "", + "type": "SEND_NOTIFICATION", + }, + } + ], + } + ], + } + } + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(event_detail), + } + ] + ) + + # Get lambda's log events + events = retry( + check_expected_lambda_log_events_length, + retries=15, + sleep=1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + snapshot.match("events", events) + + @markers.aws.validated + def test_put_events_with_target_lambda_list_entries_partial_match( + self, + create_lambda_function, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + ): + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + event_pattern = {"detail": {"payload": {"automations": {"id": [{"exists": True}]}}}} + + bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"rule-{short_uid()}" + rule_arn = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(event_pattern), + )["RuleArn"] + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": lambda_function_arn}], + ) + + event_detail_partial_match = { + "payload": { + "userId": 10, + "businessId": 3, + "channelId": 6, + "card": {"foo": "bar"}, + "targetEntity": True, + "entityAuditTrailEvent": {"foo": "bar"}, + "automations": [ + {"foo": "bar"}, + { + "id": "123", + "actions": [ + { + "id": "321", + "type": "SEND_NOTIFICATION", + "settings": { + "message": "", + "recipientEmails": [], + "subject": "", + "type": "SEND_NOTIFICATION", + }, + } + ], + }, + {"bar": "foo"}, + ], + } + } + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(event_detail_partial_match), + }, + ] + ) + + # Get lambda's log events + events = retry( + check_expected_lambda_log_events_length, + retries=15, + sleep=1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + snapshot.match("events", events) + + +class TestEventsTargetSns: + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_put_events_with_target_sns( + self, + monkeypatch, + sqs_create_queue, + sqs_get_queue_arn, + sns_create_topic, + sns_subscription, + events_create_event_bus, + events_put_rule, + aws_client, + snapshot, + strategy, + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + # Create sqs queue and give sns permission to send messages + queue_name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + policy = { + "Version": "2012-10-17", + "Id": f"sqs-sns-{short_uid()}", + "Statement": [ + { + "Sid": f"SendMessage-{short_uid()}", + "Effect": "Allow", + "Principal": {"Service": "sns.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": queue_arn, + } + ], + } + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url, Attributes={"Policy": json.dumps(policy)} + ) + + # Create sns topic and subscribe it to sqs queue + topic_name = f"test-topic-{short_uid()}" + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + + sns_subscription(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn) + + # Enable event bridge to push to sns + policy = { + "Version": "2012-10-17", + "Id": f"sns-eventbridge-{short_uid()}", + "Statement": [ + { + "Sid": f"SendMessage-{short_uid()}", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sns:Publish", + "Resource": topic_arn, + } + ], + } + aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, AttributeName="Policy", AttributeValue=json.dumps(policy) + ) + + # Create event bus, rule and target + event_bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + + rule_name = f"test-rule-{short_uid()}" + events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[{"Id": target_id, "Arn": topic_arn}], + ) + + # Test + aws_client.events.put_events( + Entries=[ + { + "EventBusName": event_bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + messages = sqs_collect_messages(aws_client, queue_url, expected_events_count=1) + + body = json.loads(messages[0]["Body"]) + message_id = json.loads(body["Message"])["id"] + snapshot.add_transformer( + [ + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + snapshot.transform.key_value("Signature", reference_replacement=False), + snapshot.transform.key_value("SigningCertURL", reference_replacement=False), + snapshot.transform.key_value("UnsubscribeURL", reference_replacement=False), + snapshot.transform.regex(topic_arn, "topic-arn"), + snapshot.transform.regex(message_id, "message-id"), + ] + ) + snapshot.match("messages", messages) + + +class TestEventsTargetSqs: + @markers.aws.validated + def test_put_events_with_target_sqs(self, put_events_with_filter_to_sqs, snapshot): + entries = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + message = put_events_with_filter_to_sqs( + pattern=TEST_EVENT_PATTERN, + entries_asserts=[(entries, True)], + ) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + ], + ) + snapshot.match("message", message) + + @markers.aws.validated + def test_put_events_with_target_sqs_event_detail_match( + self, put_events_with_filter_to_sqs, snapshot + ): + entries1 = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps({"EventType": "1"}), + } + ] + entries2 = [ + { + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps({"EventType": "2"}), + } + ] + entries_asserts = [(entries1, True), (entries2, False)] + messages = put_events_with_filter_to_sqs( + pattern={"detail": {"EventType": ["0", "1"]}}, + entries_asserts=entries_asserts, + input_path="$.detail", + ) + + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("ReceiptHandle", reference_replacement=False), + snapshot.transform.key_value("MD5OfBody", reference_replacement=False), + ], + ) + snapshot.match("messages", messages) + + +class TestEventsTargetStepFunctions: + @markers.aws.validated + @pytest.mark.skipif(is_old_provider(), reason="not supported by the old provider") + def test_put_events_with_target_statefunction_machine(self, infrastructure_setup, aws_client): + infra = infrastructure_setup(namespace="EventsTests") + stack_name = "stack-events-target-stepfunctions" + stack = cdk.Stack(infra.cdk_app, stack_name=stack_name) + + bus_name = "MyEventBus" + bus = cdk.aws_events.EventBus(stack, "MyEventBus", event_bus_name=bus_name) + + queue = cdk.aws_sqs.Queue(stack, "MyQueue", queue_name="MyQueue") + + send_to_sqs_task = cdk.aws_stepfunctions_tasks.SqsSendMessage( + stack, + "SendToQueue", + queue=queue, + message_body=cdk.aws_stepfunctions.TaskInput.from_object( + {"message": cdk.aws_stepfunctions.JsonPath.entire_payload} + ), + ) + + state_machine = cdk.aws_stepfunctions.StateMachine( + stack, + "MyStateMachine", + definition=send_to_sqs_task, + state_machine_name="MyStateMachine", + ) + + detail_type = "myDetailType" + rule = cdk.aws_events.Rule( + stack, + "MyRule", + event_bus=bus, + event_pattern=cdk.aws_events.EventPattern(detail_type=[detail_type]), + ) + + rule.add_target(cdk.aws_events_targets.SfnStateMachine(state_machine)) + + cdk.CfnOutput(stack, "MachineArn", value=state_machine.state_machine_arn) + cdk.CfnOutput(stack, "QueueUrl", value=queue.queue_url) + + with infra.provisioner() as prov: + outputs = prov.get_stack_outputs(stack_name=stack_name) + + entries = [ + { + "Source": "com.sample.resource", + "DetailType": detail_type, + "Detail": json.dumps({"Key1": "Value"}), + "EventBusName": bus_name, + } + for i in range(5) + ] + put_events = aws_client.events.put_events(Entries=entries) + + state_machine_arn = outputs["MachineArn"] + + def _assert_executions(): + executions = ( + aws_client.stepfunctions.get_paginator("list_executions") + .paginate(stateMachineArn=state_machine_arn) + .build_full_result() + ) + assert len(executions["executions"]) > 0 + + matched_executions = [ + e + for e in executions["executions"] + if e["name"].startswith(put_events["Entries"][0]["EventId"]) + ] + assert len(matched_executions) > 0 + + retry_config = { + "retries": (20 if is_aws_cloud() else 5), + "sleep": (2 if is_aws_cloud() else 1), + "sleep_before": (2 if is_aws_cloud() else 0), + } + retry(_assert_executions, **retry_config) + + messages = [] + queue_url = outputs["QueueUrl"] + + def _assert_messages(): + queue_msgs = aws_client.sqs.receive_message(QueueUrl=queue_url) + for msg in queue_msgs.get("Messages", []): + messages.append(msg) + + assert len(messages) > 0 + + retry(_assert_messages, **retry_config) diff --git a/tests/aws/services/events/test_events_targets.snapshot.json b/tests/aws/services/events/test_events_targets.snapshot.json new file mode 100644 index 0000000000000..0be3d1b9d3577 --- /dev/null +++ b/tests/aws/services/events/test_events_targets.snapshot.json @@ -0,0 +1,903 @@ +{ + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_lambda_list_entry": { + "recorded-date": "08-04-2024, 17:32:58", + "recorded-content": { + "events": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "payload": { + "userId": 10, + "businessId": 3, + "channelId": 6, + "card": { + "foo": "bar" + }, + "targetEntity": true, + "entityAuditTrailEvent": { + "foo": "bar" + }, + "automations": [ + { + "id": "123", + "actions": [ + { + "id": "321", + "type": "SEND_NOTIFICATION", + "settings": { + "message": "", + "recipientEmails": [], + "subject": "", + "type": "SEND_NOTIFICATION" + } + } + ] + } + ] + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_lambda_list_entries_partial_match": { + "recorded-date": "03-04-2024, 20:00:13", + "recorded-content": { + "events": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "payload": { + "userId": 10, + "businessId": 3, + "channelId": 6, + "card": { + "foo": "bar" + }, + "targetEntity": true, + "entityAuditTrailEvent": { + "foo": "bar" + }, + "automations": [ + { + "foo": "bar" + }, + { + "id": "123", + "actions": [ + { + "id": "321", + "type": "SEND_NOTIFICATION", + "settings": { + "message": "", + "recipientEmails": [], + "subject": "", + "type": "SEND_NOTIFICATION" + } + } + ] + }, + { + "bar": "foo" + } + ] + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_sqs": { + "recorded-date": "26-04-2024, 08:43:27", + "recorded-content": { + "message": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_sqs_event_detail_match": { + "recorded-date": "07-05-2024, 10:40:38", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "EventType": "1" + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination0]": { + "recorded-date": "11-07-2024, 08:59:28", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination1]": { + "recorded-date": "11-07-2024, 08:59:42", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination2]": { + "recorded-date": "11-07-2024, 08:59:57", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetFirehose::test_put_events_with_target_firehose": { + "recorded-date": "11-07-2024, 09:03:40", + "recorded-content": { + "s3": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetKinesis::test_put_events_with_target_kinesis": { + "recorded-date": "11-07-2024, 09:04:25", + "recorded-content": { + "response": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda": { + "recorded-date": "11-07-2024, 09:04:48", + "recorded-content": { + "events": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda_list_entry": { + "recorded-date": "11-07-2024, 09:05:04", + "recorded-content": { + "events": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "payload": { + "userId": 10, + "businessId": 3, + "channelId": 6, + "card": { + "foo": "bar" + }, + "targetEntity": true, + "entityAuditTrailEvent": { + "foo": "bar" + }, + "automations": [ + { + "id": "123", + "actions": [ + { + "id": "321", + "type": "SEND_NOTIFICATION", + "settings": { + "message": "", + "recipientEmails": [], + "subject": "", + "type": "SEND_NOTIFICATION" + } + } + ] + } + ] + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda_list_entries_partial_match": { + "recorded-date": "11-07-2024, 09:19:35", + "recorded-content": { + "events": [ + { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "payload": { + "userId": 10, + "businessId": 3, + "channelId": 6, + "card": { + "foo": "bar" + }, + "targetEntity": true, + "entityAuditTrailEvent": { + "foo": "bar" + }, + "automations": [ + { + "foo": "bar" + }, + { + "id": "123", + "actions": [ + { + "id": "321", + "type": "SEND_NOTIFICATION", + "settings": { + "message": "", + "recipientEmails": [], + "subject": "", + "type": "SEND_NOTIFICATION" + } + } + ] + }, + { + "bar": "foo" + } + ] + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[standard]": { + "recorded-date": "11-07-2024, 09:55:26", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "topic-arn", + "Message": "{\"version\":\"0\",\"id\":\"message-id\",\"detail-type\":\"core.update-account-command\",\"source\":\"core.update-account-command\",\"account\":\"111111111111\",\"time\":\"date\",\"region\":\"\",\"resources\":[],\"detail\":{\"command\":\"update-account\",\"payload\":{\"acc_id\":\"0a787ecb-4015\",\"sf_id\":\"baz\"}}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "signature", + "SigningCertURL": "signing-cert-u-r-l", + "UnsubscribeURL": "unsubscribe-u-r-l" + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[domain]": { + "recorded-date": "11-07-2024, 09:55:31", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "topic-arn", + "Message": "{\"version\":\"0\",\"id\":\"message-id\",\"detail-type\":\"core.update-account-command\",\"source\":\"core.update-account-command\",\"account\":\"111111111111\",\"time\":\"date\",\"region\":\"\",\"resources\":[],\"detail\":{\"command\":\"update-account\",\"payload\":{\"acc_id\":\"0a787ecb-4015\",\"sf_id\":\"baz\"}}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "signature", + "SigningCertURL": "signing-cert-u-r-l", + "UnsubscribeURL": "unsubscribe-u-r-l" + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[path]": { + "recorded-date": "11-07-2024, 09:55:34", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "topic-arn", + "Message": "{\"version\":\"0\",\"id\":\"message-id\",\"detail-type\":\"core.update-account-command\",\"source\":\"core.update-account-command\",\"account\":\"111111111111\",\"time\":\"date\",\"region\":\"\",\"resources\":[],\"detail\":{\"command\":\"update-account\",\"payload\":{\"acc_id\":\"0a787ecb-4015\",\"sf_id\":\"baz\"}}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "signature", + "SigningCertURL": "signing-cert-u-r-l", + "UnsubscribeURL": "unsubscribe-u-r-l" + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSqs::test_put_events_with_target_sqs": { + "recorded-date": "11-07-2024, 09:05:35", + "recorded-content": { + "message": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSqs::test_put_events_with_target_sqs_event_detail_match": { + "recorded-date": "11-07-2024, 09:05:43", + "recorded-content": { + "messages": [ + { + "MessageId": "", + "ReceiptHandle": "receipt-handle", + "MD5OfBody": "m-d5-of-body", + "Body": { + "EventType": "1" + } + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetApiGateway::test_put_events_with_target_api_gateway": { + "recorded-date": "03-10-2024, 20:10:40", + "recorded-content": { + "create_lambda_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_aws_proxy_format.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "deployment_response": { + "createdDate": "datetime", + "id": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "event_bus_response": { + "EventBusArn": "arn::events::111111111111:event-bus/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "rule_response": { + "RuleArn": "arn::events::111111111111:rule//", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_targets_response": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_events_response": { + "Entries": [ + { + "EventId": "event-id" + } + ], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "lambda_logs": [ + { + "resource": "/test", + "path": "/test/", + "httpMethod": "POST", + "headers": { + "amz-sdk-invocation-id": "amz-sdk-invocation-id", + "amz-sdk-request": "", + "amz-sdk-retry": "", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "", + "CloudFront-Viewer-Country": "", + "Content-Type": "application/json", + "Host": "", + "User-Agent": "", + "Via": "", + "X-Amz-Cf-Id": "", + "x-amz-date": "", + "X-Amz-Security-Token": "x--amz--security--token", + "X-Amz-Source-Account": "111111111111", + "X-Amz-Source-Arn": "arn::events::111111111111:rule//", + "X-Amzn-Trace-Id": "", + "X-Forwarded-For": "x--forwarded--for", + "X-Forwarded-Port": "x--forwarded--port", + "X-Forwarded-Proto": "x--forwarded--proto" + }, + "multiValueHeaders": "", + "queryStringParameters": null, + "multiValueQueryStringParameters": null, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "resourceId": "", + "resourcePath": "/test", + "httpMethod": "POST", + "extendedRequestId": "", + "requestTime": "", + "path": "/test/test/", + "accountId": "111111111111", + "protocol": "HTTP/1.1", + "stage": "test", + "domainPrefix": "", + "requestTimeEpoch": "", + "requestId": "", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "sourceIp": "", + "principalOrgId": null, + "accessKey": null, + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "", + "user": null + }, + "domainName": "", + "deploymentId": "", + "apiId": "" + }, + "body": { + "message": "Hello from EventBridge" + }, + "isBase64Encoded": false + } + ] + } + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetCloudWatchLogs::test_put_events_with_target_cloudwatch_logs": { + "recorded-date": "07-11-2024, 14:26:16", + "recorded-content": { + "event_bus_response": { + "EventBusArn": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "rule_response": { + "RuleArn": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_targets_response": { + "FailedEntries": [], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_events_response": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "log_events": [ + { + "timestamp": "timestamp", + "message": { + "version": "0", + "id": "", + "detail-type": "core.update-account-command", + "source": "core.update-account-command", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [], + "detail": { + "command": "update-account", + "payload": { + "acc_id": "0a787ecb-4015", + "sf_id": "baz" + } + } + }, + "ingestionTime": "timestamp" + } + ] + } + } +} diff --git a/tests/aws/services/events/test_events_targets.validation.json b/tests/aws/services/events/test_events_targets.validation.json new file mode 100644 index 0000000000000..61e32c4085480 --- /dev/null +++ b/tests/aws/services/events/test_events_targets.validation.json @@ -0,0 +1,65 @@ +{ + "tests/aws/services/events/test_events_targets.py::TestEventsTargetApiGateway::test_put_events_with_target_api_gateway": { + "last_validated_date": "2024-10-03T20:10:39+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetCloudWatchLogs::test_put_events_with_target_cloudwatch_logs": { + "last_validated_date": "2024-11-07T14:26:16+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination0]": { + "last_validated_date": "2024-07-11T08:59:28+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination1]": { + "last_validated_date": "2024-07-11T08:59:42+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetEvents::test_put_events_with_target_events[bus_combination2]": { + "last_validated_date": "2024-07-11T08:59:57+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetFirehose::test_put_events_with_target_firehose": { + "last_validated_date": "2024-07-11T09:03:40+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetKinesis::test_put_events_with_target_kinesis": { + "last_validated_date": "2024-07-11T09:04:25+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda": { + "last_validated_date": "2024-07-11T09:04:48+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda_list_entries_partial_match": { + "last_validated_date": "2024-07-11T09:19:35+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetLambda::test_put_events_with_target_lambda_list_entry": { + "last_validated_date": "2024-07-11T09:05:04+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[domain]": { + "last_validated_date": "2024-07-11T09:55:31+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[path]": { + "last_validated_date": "2024-07-11T09:55:34+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSns::test_put_events_with_target_sns[standard]": { + "last_validated_date": "2024-07-11T09:55:26+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSqs::test_put_events_with_target_sqs": { + "last_validated_date": "2024-07-11T09:05:35+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetSqs::test_put_events_with_target_sqs_event_detail_match": { + "last_validated_date": "2024-07-11T09:05:43+00:00" + }, + "tests/aws/services/events/test_events_targets.py::TestEventsTargetStepFunctions::test_put_events_with_target_statefunction_machine": { + "last_validated_date": "2024-08-29T18:06:56+00:00" + }, + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_lambda_list_entries_partial_match": { + "last_validated_date": "2024-04-08T17:36:24+00:00" + }, + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_lambda_list_entry": { + "last_validated_date": "2024-04-08T17:33:44+00:00" + }, + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_sqs": { + "last_validated_date": "2024-04-26T08:43:27+00:00" + }, + "tests/aws/services/events/test_events_targets.py::test_put_events_with_target_sqs_event_detail_match": { + "last_validated_date": "2024-05-07T10:40:38+00:00" + }, + "tests/aws/services/events/test_events_targets.py::test_should_ignore_schedules_for_put_event": { + "last_validated_date": "2024-03-26T15:51:47+00:00" + } +} diff --git a/tests/aws/services/events/test_payloads/large_complex_payload.json b/tests/aws/services/events/test_payloads/large_complex_payload.json new file mode 100644 index 0000000000000..d01727f12c2c9 --- /dev/null +++ b/tests/aws/services/events/test_payloads/large_complex_payload.json @@ -0,0 +1,874 @@ +{ + "id": "1", + "source": "soft1", + "detail-type": "cmd.documents.generate", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceHostname": "lambda.amazonaws.com", + "sourceMessagingLib": "w", + "correlationUuid": "a26f70c2-e071-459f-8b87-81fdfb43e28a", + "createdAt": "2025-03-14T17:05:46.019Z", + "retryCount": 0, + "metadata": {}, + "payload": { + "WElTQLlSVUBpx": "zCUYxaqSGUXLtCVyHiAcRJxKnBcLeac", + "URXrhXqFQULAGIVx": 6, + "AWFqITBBeoWQ": "TopVogywzrBtZWTMWadzyUmTAOfcPxwwuJbc", + "NwVWQPgFRzeENvXIblm": "NQmVRJbvVDtHoVuFaOFvZEhM", + "RVjgwOLpVhEPhsUdGCmc": "DfsbMyKZUKBvQvidnzXPGGom", + "nested": { + "MhuRrcNygnJ": { + "MsMzulBhOI": "TKBLUCBZ", + "uYOQyOyWlCREF": "JeCNesuWhslgGYnZDjmNOBzqGEJicBqgvFzJcGnfNhajYSPQMMJsMzqNbWhNxCHJfzKuqKuvVCprLFVQgCsYIihQVvbpLiLLmoFeXiy", + "yOIbDKLerwwRVJKnjpi": 14, + "zyHteNLbKlOttm": [ + { + "Yf": "W", + "AOpS": "iJvXlgsrunicxdTuukghMcw", + "tnFMYzoWwB": "mBGLRKGfSaOKfiCIa" + } + ], + "YCxEXubXBBOROVD": { + "bjQQvYoyFzYiDGvon": [ + { + "yaXLKTjG": 191, + "UzhK": "VrkanWpo", + "XgOvBdEVfr": 3289, + "xkZq": "PpQ", + "Go": 944, + "Tb": 854, + "qNTcP": 132, + "Dbb": 3.97, + "WEA": 38, + "OTF": 2117, + "WMcSFly": 50.6, + "ROzkCJoGm": 92.67, + "eSePorxJI": 8.164598354536087, + "knoJyMOoK": 1.894621779845863, + "nKsgnqAZs": 4.423379701209955, + "hquYzxZ": 1.173164884858382, + "JTIfuzg": 65.79, + "TlxyyuZ": 60.35, + "cZhilKYDflAzFzOc": "yeCuYBmA", + "ZvuzDFjGtdclKcJEVMZP": "tp", + "BfUnZuOfCcuRMj": "NLehVjkCzjmZGRHsOjKZEb" + } + ] + }, + "gwooISnKeGTLdFimWkju": null + }, + "hnWBTAzubJWQmLDY": { + "ufulSIPsMu": "fjbMKRRA", + "fAsGuGrUajGhL": "keuqGQQqJunZDqXlxaLYXbMeZclCPQMqecurApAcDJso", + "BOUfucwDQWNHgb": "iLHbpUokCDovAhNvOOmswXLRMYSOtOIKtGNTCMlmeK", + "aHmXVKiuoQNWeWavLgO": 29, + "LCjOZFlCoHbMkAGeKl": 0, + "OkcbFmLlXzkooW": [ + { + "Zk": "dYPSbrHlicABJxQEUmPGHA", + "iuZV": "ixXPRFPqvkdtPGTaWrRYnNkJARfKnTGmUHNGHoGIfSM", + "WyVGwfcAXa": "hBXltKbzeAWLunuJAg" + } + ], + "vfxjgRVADEkWhxP": { + "vmNxIhDGBdCJvIFPRi": [ + { + "mCvvonTb": 7942264, + "zKzk": 9, + "xEAthobZTu": 8, + "eKgMzIe": 8, + "XdUQy": "pdJzICPuMYOvBUNlootMHYXYhcKJt", + "zBKcgiQUd": "sAHYUVTjbAWdgFQEmrRuZsGichOjV" + }, + { + "MrElexnI": 7652102, + "Ehbv": 1, + "HIbcTxEaUX": 5, + "WmRycFY": 4, + "JFLMQ": "vJDgJauwsABMZFbUTffrioCpBGmse", + "jyMEFezhT": "FfJJeYrLQnDGCrMCnXxJwGEJMlJfo" + }, + { + "cHncKhRL": 1693855, + "muAr": 2, + "SNbOLmbeZu": 7, + "mULkTxE": 3, + "gKycu": "BIGJnSim", + "SkxMGyvYx": "havqqzSM" + }, + { + "GyZFLuoQ": 2976017, + "SqCm": 51, + "hSZoojwlFH": 2, + "CSyLRCI": 1, + "KGpgu": "jHmDzLQEVkKrcUt", + "XFMSAIUoj": "iwhbcYcBflLBVsF" + }, + { + "BIHvwrHi": 1043893, + "cWnu": 2, + "zONwuXyZPu": 3, + "YgnMYTl": 7, + "GqjQa": "vwiRRNiJ", + "pgPlJFhcm": "cDHRlLYL" + }, + { + "SuIixzIv": 9249481, + "jTgZ": 6, + "vjgbsBqnoQ": 5, + "MgSMFQm": 6, + "gIARZ": "eMXqInvJBgWbTHgxgOPDioCcoDCj", + "dArwvMcuW": "cJQpdjdlmFlArjSBRaTxMpjyRCWH" + }, + { + "UxaDeEaF": 2538086, + "Qzqe": 3, + "EcJKQAPywI": 4, + "FswYsld": 4, + "XhnBj": "WkKYsqRwxIQNtVOByOMaUCBGUmykb", + "Onfbkbwca": "SpZlMUtNfnBXwEnzytLqlvYcdESPp" + }, + { + "wqZfLRXV": 6866658, + "ppIy": 7, + "omfEsDSBpm": 9, + "neVloPA": 6, + "kVSAt": "WztMWoqBsQFfwo", + "WubGMvWiT": "QIsSzDmeNlprfk" + }, + { + "LzwRmmgZ": 3224814, + "YJlg": 2, + "ocNTsgJsFA": 3, + "SPqPpJu": 4, + "nWTWz": "QaUpdhcrklslGrNjKxcJVmNsndepR", + "hPPcNtPlm": "MnvHHzNYPENYhSUFDoLcrwgbpjyaF" + }, + { + "jjhPZjTv": 3373968, + "mmrI": 8, + "YkJCEwyLdG": 2, + "NRhwMJn": 5, + "lpkaO": "QeiODlPPFqFUqfoulSLrCucINWhPf", + "ZzoWtjchC": "cfdtheSrvRJxqvZSKFqcFNKjjWiuF" + }, + { + "XwGDqdRU": 8445297, + "uJfU": 7, + "yzKOqetYwV": 2, + "FwkZEQG": 3, + "XesxG": "yiXveXvEgdnwVkoISGNejILgpAzjZZMoSygG", + "cuoUeiPgY": "xyppRzMXiHmuUVsmkWjYibiHFTESETGAApWO" + }, + { + "hptqabaG": 5953056, + "hJPc": 8, + "zoltzLwnRq": 8, + "mqKONTM": 8, + "FIOoG": "XpkeOWtM", + "EZjrNXtDs": "FRUoivil" + }, + { + "MMPxfvpd": 2700237, + "WhsD": 6, + "VnNsPWOgBE": 2, + "RQuokGn": 6, + "fdeLB": "FulxrCKUMkvqHxNLSoOvDbLeDpXQn", + "tvSdPHpYe": "kOJcPRRSMXGRMytCegLFGqFjGTTqh" + }, + { + "wLPifMoa": 8803711, + "xnpd": 3, + "HNqCRsMwdz": 8, + "wTHcwQi": 5, + "pWXDZ": "xoUqqOgidcrCyWSKOUqQKTGoBCYaQBdcilIS", + "juUIJCKTw": "frJOdSOmOHzlPsHffGlrwnlaQYNhSheVVvuc" + }, + { + "QnRuUKZS": 7742212, + "fjUv": 9, + "NOIULzKDFU": 5, + "xrRHTyw": 2, + "IjUms": "evEFIbPqZojTcy", + "QxzjvmCku": "VgXgHRNAayjRFE" + }, + { + "xdkiUPxT": 6876317, + "JvvB": 9, + "IwvPwGCzAT": 6, + "zvjOzqE": 4, + "KRlJS": "BJMStNIALLJjRkxnjFoSXxDpBpBBl", + "GnDsNdjsv": "fDJndULJzYANHpFTLdMQtBdHcGbje" + }, + { + "aHQIfnrJ": 6313562, + "vVur": 4, + "soPcZyqqSm": 6, + "tlpFZPT": 6, + "HYlGB": "CvKTRdKSaqkLzbTCzCMSxBASBTCF", + "TZkTcZXxp": "SuEHDdBaECVBoPUwzgPCKurmCBDl" + }, + { + "wUHQFWHE": 8259408, + "VzUD": 7, + "clhmPXUief": 2, + "rysfyAl": 4, + "hTwJh": "swkgJseZNdKwXocQxeuuDiAhAkAyStXkRSgD", + "mdhBfqDrX": "MejSWHcnIAMSWXqffbmCWTSXpRbmyBlTeTKs" + }, + { + "BcLOBQRV": 2384915, + "xLJP": 5, + "yaIXYcuwCD": 2, + "ukVDPQv": 8, + "eMQSO": "uZyMjsmcJRwIrxlWhWlQuJznaunk", + "LhXRomjux": "zANDenVmhGNFJKeARKkXEEBRkoMM" + }, + { + "UACXwlbS": 4593772, + "YjLr": 5, + "beFaYBKHiA": 7, + "gkdxrVL": 8, + "ojfFY": "dnYQdCeMnqqxZN", + "DRNVjRfjH": "fhRNwOUrFXLDAV" + } + ] + }, + "JKrdOYIQUutPaKIMHiFf": null + }, + "iYPjyGjAKeIwmIKPwzAzgvsVSASYns": { + "HyRwEDlNhK": "INYowiqg", + "ixDNOnyHIPhQt": "pCYJORoZMMqjDPzHTIGzslDrBFWwSUuaRRdywVkuSuTFHwjHKAOzYDMVKvXhVEGqVRIUJllDWChdjfGRRDxwQZikiRQpzOwkF", + "aIPuumcFdhwPKj": "HoZauRPJpQVXbpiLZVEJSMDUgqZGBbVGJmKDvAhPmxUdduFVP", + "kSxTHrhafUOFtTRNcTs": 88, + "HbThjVSaOEYsXp": [ + { + "dw": "b", + "jqsZ": "uXPBCtovSqE", + "LyDwPspWbu": "GqLLuzhAfomv" + } + ], + "hXSlwNKxGkXxNum": { + "qrtLaDGRWYwU": [ + { + "CjmJvMKa": 389, + "buPsLrTkBPlzUi": "ohNaqUUXgNWegFJMLqRlSmpjdB", + "BqZv": "ITpSqRRdtpwXSNPxpTKSlyxqpIrmVZHIJDBTSSRpLbrCAMtgmrZtcrZFfdAkcUQVAXgApkdERPvJktFHQVZkloIKCxasee" + } + ] + }, + "BzwVGqXOgsFSnvoiKBgi": null + }, + "IkMUdnfWtCIQXiVHWLMmna": { + "sfsJyHHikG": "OJQzjOGR", + "GExIhvNwzAFoC": "CRMdyBjRVthyOWbvAqyuOLRwmsejtvxObSOkOYyOnqQJrSMSJLOjEeapCSMwuDThjJOKBjFvMKKCftpJwcwdBoPQQ", + "KQnbulNgdRjgvR": "DSItDQNQDOxCGOkzBugnxdrCpMIFysGB", + "urvISwUKEkrbNIzOYTp": 73, + "rVzmkRfjsdNpIh": [ + { + "zG": "s", + "qoXf": "cqPNuTrkNBdaipsQZsboFTjwBpGs", + "hDqUKomOht": "IXeFjZKgCWAxnczhGh" + }, + { + "kj": "y", + "snSW": "qkHnlUjymmqawJbOwbcbZwLddnkFPpzvgbKxSZR", + "LlOTNAEWuu": "wuPqpBbynoqphyetZruBVYrnOFpMFkj" + }, + { + "jz": "V", + "cnGq": "hOeRTXmPJpOJQmVemZLqFNFpvCDuxGDswcCbrHpM", + "traOHspbRX": "nPQVFnTpGKxylFAkymUzHGovzj" + } + ], + "PsAaNeXOJKnDGuv": { + "oxUJcwvEyzyfZiTLed": [], + "uQOOQzWFvsmWbEFiLaIhOrocCZijqPU": [ + { + "dHUMtcWb": 85, + "fqoTKfOeQ": "SNbsylguksFxHzdlb", + "rKEvC": "XFGOcrsIdfhanyWMkLVtntJWCHLZyhAJjdpHuhQdLeQIGkgrJAkHGXgwezQqYnHnresPYzcSoFeHzUQzHsUuccFmhxRsIiZqFT", + "DbPuKJj": "GfbWEWaKAuPkCPpGRLtmxuOAZRDBNjfoiqARoCEG", + "Clne": "olDYCUaLUlEvYNLkjVWlxIkIjaBDUnvPIaikgKESfmFMbanppuAxXzvGxuYNndsxBexWMOSgNxNdcn", + "kZWiLI": 5066.92, + "kUCsVditfgC": "jmXERYF", + "iApmqxbKGX": 902625679990, + "WZimIsfqD": 7276919958509 + }, + { + "YUaixnQc": 985, + "tGlWxeVJk": "cnKiOcwwZIOVeGAeD", + "hGInR": "iUUidOJxPjEmdryOiqmCLbWEbrtBAQrPAkturwfdQrxiazNPuheZlfArEAkndZXcwHqHSjkLVxRrweTdRkvzYdhUyevAgjlAuLkkjJsDSzCPvxxTHFdIbWstDlG", + "JfGqgXr": "wlMhzANfrZJOiQDiCXNoucTTNXdXDcEPKZN", + "pLDp": "vyhDFGSzfZYHXSJBYsfxOfqxoMXLRyQiNPlcBIpGvBdweDQjgDmdDEfxzrHRJNQGPYWNQsdAEmCOuK", + "fEyCzb": 3243.94, + "oTFPnVAOkGb": "gLikIHu", + "ftqVbkPdih": 939431310405, + "KVYkHxRnZ": 5443974824723 + } + ], + "CmzfuVsgGtodifQOUsCbuVlNSe": [] + }, + "FIhZUrphEmlMpwRvXroY": null + }, + "another-level": { + "mlukciiYIy": "zCXvUxXu", + "CnfMDPFZOGQEI": "miXAsAGcDSncucISmpPvVqkRBOMELoCdXeJHNAGfpJkobprShTPAJBngvpkuYneHLsrqsVZkrVCVrmcbuReieATTg", + "GcEdfwLAsprYbk": "MotmAOvgVXsKIhTtCQUyYVviArnBWgYr", + "shnfmTJxNAjCYIRZbqE": 30, + "deep": [ + { + "inside-list": "q-test-value", + "gurT": "hyPIFAsGGjJrqIkvIunZMRFuiAzWUEIf", + "sQunoNuips": "hBPaXgkzgirAtWFRiSzvH" + }, + { + "Ji": "A", + "mzNy": "pRozZecAPsYFfpFKWSMHJkBTNQeQBwdXKSklLSYdCqMm", + "RZFoexDHBd": "ejNlGwvktjcGhoLdebJhryTf" + }, + { + "zI": "f", + "YCLX": "BZsltRfcouxQvlrKimkBEmXSqPeWUbfUUMPPvTCTyzC", + "nATlIsuwIk": "akRjMDGYlmJQdsvbEDxrmOxpgJ" + }, + { + "ZG": "S", + "fsfm": "jpnbkpwONIzyEfCRmfJirTePTPyPLKIUGhIlLHGsva", + "oBqaPDAwdQ": "SOlxsEKyzwvuSRnVudndPDLbKnXVfU" + }, + { + "aX": "J", + "ATcQ": "HIKOIlQLZKFKsGWizKkbltJipVdebSWYieREtglNbVhnmKaDuQIqGG", + "VBixFproZM": "JyyyDXQGGhBGUuvoKRUgCxDisRwktSDAp" + }, + { + "NP": "F", + "gJkc": "YjzqvcTtFpYbMoqWRebzoSCnOOuLjknMuldlVcMIGlcD", + "iWHzDRCesT": "NkyxSwhEHoNQWkrVlATxRFkKZrgmG" + } + ], + "qxjFsOxYrHqndWw": { + "kPFsJUBksPtZGZNYTImXG": [], + "boXLFpUBeyymYVQlONCZqPgs": [ + { + "kXhsgeuH": 645, + "KRJSrwyUw": "rGKdEYaIZlvmGsidj", + "eAbHh": "opnBCinG", + "ePFMEEI": "pviqdZqNDYndpoADMaMypWXoNadIaAMEpONhijsimGNlVKzsvtJxFewDpaFuzrPhYFMMD", + "CFSZ": "GyqWiSKbfFrrFtZIFzYadCwqXKvbQkBHiJMquyxthZXwLEbUmaYUNXiXaLwvaKOImiSiXeXEGXtYYl", + "zZSuDi": 8468.66241833, + "NqRRvrpGAK": "KpabDY", + "vQAYWUCLri": 1337206744292, + "btnUszoxojT": "sCjqoSzCcl", + "lPoQmj": "quJMDF", + "UoWmnAzd": "qdoHexEHdFBxtgzwdwzzEZwRizboXqfAXnhOHnTFjpdZVKHjeVTBRrrVDOukMYYNDeeqN", + "jDJMpKcPP": 8379092631755 + } + ], + "qnrTGJHiIXgpZcXYPkuamvAiXL": [ + { + "emodjnAn": 543, + "BAEnSSMih": "ulaBwyrmoQNwWxZwn", + "dNGno": "NLmGXWOf", + "lPxbHIc": "lwkNVzxUBOQZhsjYpjqcHIdnYVFQaNQpUfWwikaxhs", + "hnph": "BVfWThPsXleSDbysvmGZkIGBKUukiSxStEgczqqmVATdDyNcUZqYDLSReenzPxCUufnGEaesUpFWGb", + "vfIEjn": 3264.24432241, + "fLNcrrBOiS": "MXYFoX", + "HGYipRZebc": 4367194090471, + "NOSgotBWxOP": "zqUwwsQlwV", + "htGzhH": "prNQup", + "hONVdjIl": "CgUJVLGNnqinwWKhNOCPKSkkuZPTkmYgllzBvkgNEf", + "qVphjWUQx": 4327528108500 + } + ], + "IYFDxlctDvPKbXuUPNrFROrtBdKEfx": [], + "WWRwlLMfbVlSzXLvvZhYOuQlpLzUNXhEb": [], + "kUFUnnvpsdiFdxRjqCPubBARkiPuK": [] + }, + "PGHrEwkgDLjXnthXURFV": null + }, + "KNfWgzXAbpOIQzhBCbGqiHh": { + "iDFKxGAvgL": "UXGQvYSY", + "KykgTclosDeBe": "sswTusviDgHWYKdpFOarusGeUNQhhmcCdLAuKNdpFDzkGCjjRjgzgpZfJggzYurFMIvywsBMQrHixUtEJyOfqYoyWr", + "VsiaCVENQIOZkY": "WuNqwWVZQueOVPnXNhjclBKczfvUONmCyZeZUtezFNGvwarXkSo", + "YZxYNVCiWxDzdvefIbg": 56, + "tRKloJtUqMKsXT": [ + { + "eE": "s", + "pvoW": "PseeDBqWKWwRqNWzpKgJoBqAyizyvTexLEaUpCMGMKCs", + "MjOtyZRDjp": "CvSddqRgxWPEiBSETqycHhVFPdkWudowZUX" + } + ], + "OEOidnwMIXvaOTp": { + "GgALdhujAGMZmDPkJagDhQriYcqAWmNAfIN": [] + }, + "kdLfKIhGnAFChahXtqMd": null + }, + "NPnmHgQRSnSiCAfQpNYbbOmMOILcsePYj": { + "SDaijJGmnE": "UHtMOOzt", + "ecoBChzAdTzAE": "KtPIdsawotEXCekSGPaySUBzjPfIISnQGQQqjWQHIoNRHvzWmIPIrRPIOZCOAPFSRAOBjbRcHhSlfiJqaRXXTcEzQHGSQrQrB", + "MjnqBzitIOKFuDeTpgQ": 48, + "xdvcHbkHKtQEbq": [ + { + "Cg": "u", + "Iwti": "ddVWdADQypEFcJsdIysGrCoINuwUkofxhDgvZHaWnON", + "exBxcPyYoF": "FZnAbeTpGzswPkpmDREmDiXMZrOcR" + }, + { + "ZT": "Q", + "RqCu": "SDeaqemsIhDICtcCIbejcEFLEzUuYlv", + "EEQCKLbqSF": "JXEABzhSpyTZUNbLNOOjRPovPeVRzD" + } + ], + "ElqrjJJnQtbfYHk": { + "zNBjkzOFcfurjwOflWqoSoJHGcgWt": [], + "RzxxAzSoKflaNflshVFjmgDBVihViQ": [] + }, + "NXpcmgwnbpJgvXSEmZBy": null + }, + "YRWRIllnFNzOFxeOfWsRRW": { + "dRPBQjRsQH": "tsanJRTf", + "dxWTtEeCciZyv": "ZgLZPsRUCfZrDCzjFZugEOrDNQxuWcZlosVsWfVKsRFbwneUmZOVJLRvqSkkqbNWFFdaXovZOzcFQQfpKazMpqXLn", + "lSnqsyYMVcferd": "JIMncVFZVoKTCRRaofAvKELeXCDSvUvAqottR", + "AkTMuJnjHdFOMhdIPvL": 40, + "hGKIvkUJeFpqAiVLTE": 0.1, + "LCbZIgxDMwsfnd": [ + { + "DE": "j", + "DiLd": "yTnKbAhURddC", + "qDomOIBGrK": "jeGXxatqaiMNUS" + } + ], + "yCOvIPbwEFamgrV": { + "IROWYcoirJGdBW": [ + { + "PkfavXEg": 51312, + "SdLdWmbDs": "hxkuAiyaKABojvWUj", + "zqnQMWOJCvi": "CfAkqLnivujzkmSJzLyACUFuqckDZffoFHyLQXPIyJLPUu", + "rfcrKmYGcSez": "qqONyt", + "kXWOBjrahEwH": 688204086555, + "tIoAdvGHvQihUK": null, + "lhnwfonrEgAbIa": null, + "PvpZ": "ErjqcXdIEjxNJKTAtJpiocEGqfrKreFXzybjDaEnMolQIgWFGifJyougqkEnTbtkfnEIHUwwR", + "ovpZOvxuJuiUh": "sJxbgmMCgyRqy", + "iCsefePPEtXwmAFyBAElEF": 1631732229544, + "GVPsrQLeX": 1703394242891 + }, + { + "urKJkPNj": 39788, + "PunDUGyZr": "yeyAAQAgjcwajeHQq", + "kKDyxGuXMvA": "WJtCzEVpqVTt", + "ChBfyLHpYKSZ": "RaALMS", + "LQqsVuXlQyqy": 927018569345, + "ZbhiZXSCKndYVa": null, + "WEaulqQpkCralT": null, + "tRnS": "jywWCrEHUKEOTaMtDNAuoCnKEIvrsDjOnsexbgFFZNMMrncnWrgvBsoblnxDMTriOdtoCfoWR", + "ciiWoRJygyQlz": "sNAYRXLJAJFPy", + "DamhVKfvyPqfQdBxBNrssp": 3740238446611, + "bNRhZXtph": 2276109534140 + } + ] + }, + "marGUjRDbzFHcFEtSLQS": null + }, + "jqBEzDosuKbGPtWADcBrld": { + "zhswOzzqNy": "jfVDhVxc", + "GHibzoBXWVoAF": "vMbAYoftvcAVgQIFAfKZhWiTRdpSZbdbIiMEjZKcahqBxZbiisUYdbSkOJtpFyJSVIBEuJsmUApiDozXMQRoyxOzc", + "NaXQABuwoCQFmK": "wdwZffquHWUHEAuVVTnMBgQghUvcvtVjqcWjVwiNOupUQGTCbaFKfBkXxhe", + "keedCEXRemGflrVZoMj": 54, + "gyIMHdqLNYmsON": [ + { + "it": "PN", + "JzEV": "CWqhQbRLdWHSBNEWslzufvTSjgccnHyg", + "XFUfCUVabj": "CmJKehESMvSGKMOJBggxz" + }, + { + "eR": "Np", + "vJmV": "aRblzAuYCHzKLyos", + "tSMrRZPPXy": "bYeIPQKVuaRagdnIj" + }, + { + "AA": "LH", + "SJIB": "bruXLgKJJpdVnfsIzJwLguJRpeMNgOPQsxMpWoj", + "nfCOviLuVs": "LkRxwSgTsBPdvzkNoynfsOkKPSzHn" + }, + { + "tb": "GI", + "ErmO": "hQXjgkDNCLinoyLdQHL", + "MvqGbTZDhT": "JgbfDZRJeCTcmRWK" + }, + { + "VT": "oh", + "gDVN": "MKpIWDZYowgqqjSZewNfYVtOw", + "jKrlMGMUlt": "zARquavZLTZODgCYAA" + } + ], + "sUyDEIFIDTugdUa": { + "bBhGHevLDSmYvDMRrwyqN": [], + "DyyDdnssgfxkTWVtM": [], + "DiCDIanIMgrIwzSJLJIVxkMQjcTrW": [], + "jyDYYryGeHrOAMiD": [ + { + "vxstfHhp": 46341, + "wliJbJSbktz": "ZUx", + "GVqxRNDAgYD": "gUTfbVwyMd", + "bcreNyUEbk": "tABZayrk", + "klIpCWXleG": "FzMmnuUWZukgLxrLxiqosSnoEhFHBNoQlZwxWMNVQALHlRkUBOHloGthsDnqdedAtINmahFjCd", + "dbEKOKxFkSAjzp": null, + "XibVvl": 51.7, + "uSnRHDd": "xJiLvMvPyCrLNddMxdkUZmhlkdjaborlXLQiyWEw", + "MBxtfwFrI": 9606836466097 + }, + { + "kpguejAy": 5844, + "bmjXJeUlLRJ": "zvo", + "GpkxboHNBTF": "mRhrUJRQaQBwqABISdJDnCeXe", + "pCMZoUMbMI": "COVQnpLf", + "jriMJKkoff": "vEFeSnqGaEFFfDQdAJGutKghdnHuCYuBktxrQjdKjHNtklGSCUkJXtknnwKoncmAknYuWeNJQS", + "goYjxAyLAEGUXk": null, + "AzoQwY": 269.76, + "JkqRgaf": "HSsfSokLjZEkd", + "njmvZzZNz": 5742622133449 + } + ], + "qqfRkAfbZrdgBblbQw": [ + { + "zLemOZoD": 76, + "dmgxURtCw": "mxVBOpcxKNukmzVqO", + "MPzbCW": "StQKfHOtD", + "GRAyU": "DFnbnQSGXiqcwoMJrbvmmBdtNebdXeczi", + "scKgKzI": "hdwAfZDXINklHzpFMnEQdRDtRpcAptQfcfYEexDcnlIRwraJEBmWqvNgZuAFeXhgNedjDvEAvsKftVCcKwiqhVmIlSaE", + "ZOqw": "MzJsFzFqpootUOuThjkvQmhOaWCLOdBnxOEGLrNhqBluEfqFxyuPtNkFtXSMoJtyjYjgKPysZGmptMbDiRJeYAPmG", + "NFwAAG": 5176.72, + "SUEdNZosa": 5197286070086 + } + ] + }, + "TejxCezasCThYeurrEuJ": null + }, + "ZZEnDphijQGNBypYDmQenl": { + "HriFSJFUBh": "VpghnJef", + "MCpSylkvPLeeo": "svLBzYdemMizSdZmlXaKikvvdYyUvgprPOhFvVFAtvjarGfbFiUpLUANrohLYLDSaNycXxCsRxUcbdBBUSoXMsIqt", + "YomEUTirqgfPwN": "JXtPkZQiIvsCVHRfpkfqJKHLYCOiquHwhVyiJYIdhfb", + "WlfvSKaMEwLHHGuNNYD": 67, + "FqeqKtcJBdDmvK": [ + { + "uI": "s", + "aooF": "JzbuBHDlLmqppGuWpPCfJEoZBGbpJZPFfATiD", + "krKqAeKWNf": "LGYqdUWpwcOBVnnKbHJoRjdFv" + } + ], + "TurIqeqrrHSjzYU": { + "IecsBNTrPUdPRUYzOQZUFOdTz": [ + { + "oddxFZnx": 972, + "GvFZyLzIgVvnPsWjHpY": "cqfTUpgjLFUoVYFwO", + "kZCOb": "TtDyFvfP", + "BXvRvLAvzoFNWveNE": "vXGPdtkErdSHKPjEUvXFYIoNbcahzHNQoBWmABTwOBMohAQAdUQwzCiaSffXZDJWaqmSXwwICcLATAGCinP", + "yvSICcAPsMnXbR": "kuRkOMLRoDfaShpfSmQmPUiewgTSONhJqLrlKnenTOwEOGLhsUgTMjAIsCPjekPs", + "nTvsFY": 1983.25775195, + "ObKvAzEFewB": "hCrgXDlFqO", + "AeuSYLmSU": 3997831424599, + "wDkPbqWzT": "mPmsnzXqwQchooZnqrHkkIbkUChJcBevlNg", + "VFSdwye": "SxCrJzbfARzwOTQczcXZcCk", + "yxDJ": "DTMGGglkCvYvWDJdFkRRznBJvTIxlyuCZafUkCDbxdRdKKPfkOVpGqPZrwqQdHGLdJPkbqNRVHrczJyNfqhbnsodCVeFkT" + } + ] + }, + "dfmuLUhhXdftfnMaooTI": null + }, + "QtQYmzdOGKeW": { + "lEJXCBlUOe": "ScVeyfKi", + "EscHtUjObLyZk": "eTTtrjaVmFUadljRGOhUbUPgibwpYQDdMWBmNABzQvrxSUCZjxvTfWsQkawqcXkJkoAbKZZCcO", + "gBTtAaJyaUkeAL": "tQRQIvvXCOSIpPBSocMHVGNQoNqpUBcpOeX", + "JoBLSfQLRCwpPLBsYYT": 28, + "lvJIaKfeVhdqrG": [ + { + "Fl": "x", + "HExS": "oTOOYUYhGVRHOEXYPwFEqCGeTTmmMcob", + "hiObWgvPXw": "NkOzxqozkvNlezkrH" + } + ], + "ifBxEkXcLTKbFrF": { + "dGCslZCVAxJmIqCCQ": [ + { + "OVYQSUTR": 99, + "ZbSpZnhEOUGRSGLwKJdrWCEERi": "aUdQgZIpPQo", + "vXzoxwmVKVNbwdqHkpFAgJurxPhu": "TMbaH", + "khtJjmTVDdgQOnVEipdYzyLRGmrVup": "NueamThyWdirY", + "NnLfXsmxdfSjTDsaaIcVkhbG": "joOoS", + "oZxPqrebudrHYJeEskjVXgWrFHdYiMCXA": "lHWpRBTHelNgEINA", + "DVayuivgKJoccQRdakuuqJUhRMQ": "NfSNWsQ", + "qnGLDWFrKozkQpVIXCneczU": "hDCunOHGHesvjdIZiUT", + "mXWpmlVqBYTguvDmM": "DTBHXrr", + "rfMhBuWPUZKtzKHlRetzPCOiBK": "loqavRTIYkZWUYDmVRfXaa", + "jNieTWhAammmjLebOpop": "LtLQxouUfXwEihgcTCUOLqhtfEKpKUNxVFKr", + "MlETPKr": 966.76942147 + } + ] + }, + "RCKbeFLEVcVVgCToUjQr": null + }, + "FsJyBjQiC": { + "wrmASxpQrD": "NSsgTqut", + "mKqsjCGnnWZnX": "xFwvsHFOlqJqUDFUMXAzOqCAgfIKFfcIgfiiQmMhcmgtTMRRjbEOdcelghOiBOWnTwaHWioPYDmYjpydHZzslSb", + "DIihcGMzeSncgU": "MofiBjYGXcOBoDDDCeltebYnFgujQhKpBWNathbIyfcSeW", + "kDnJgAckltZpkiRgvgU": 15, + "JCwUZVopvfpPSj": [ + { + "iW": "P", + "cOBY": "UQNqgCIerPNslAatZubiGylLRIITOmvgxtsj", + "cXsoviCTgK": "GDYppAXPxwXdasqmXTlZWw" + }, + { + "Eb": "C", + "UaBw": "tkUOIBcesFiHSySbJPsWvFUzeYbMcBQCtjXt", + "tCwwFJRAlg": "IdjYnMVILDzuWarnlpovSl" + } + ], + "RxpKDunHqEGslVE": { + "FnoKxBiaAmkmVcnVgthJCm": [], + "WcPolxGTZctqgjVSOHXDWF": [ + { + "TxPPNcqY": 79, + "ssbyaGwnbSKqdwXSmBp": "YrPjpBNV", + "EydDkRvqINikusKKuIm": "fCmfcwnliJlqqjJWBBPiopaTkwShykZoLmXwLPujuyethKpVwrUx", + "KRhbOemIgEIBEMfTTmAW": "egsabOQzBwlGrcezEFGiK", + "cQPk": "BAiHhexdiNcxazZAMtZQiPdECNPoqzsOsbSqZ", + "IoVeYA": 578754, + "eGxJLbikzmYG": 6308740477591, + "IQgOYkY": null + } + ] + }, + "VgcGHHmAGmiunYchgZSy": null + }, + "ZrEhPOLhT": { + "ThOhSuEhkf": "cMLJWfEd", + "JVOajxZCbdyuA": "cxaFUMiUyCIaLwYVwzoizJGeiDSSWyNlPobKLGdIMfDNIkXjtWyADbaidasQUPFWbi", + "eCEkbtwEfreMVu": "UmDFqKGDSbfSREnltivyQIAkRjRCrOSDMZsVnWEBumJAKGfEndHYhhepvtCuEWvuKEVGWaytoIKXPfyAulWhva", + "ZcWVqhvhpGjqRaUepPm": 13, + "AAVUVpQBzqOdnf": [ + { + "zs": "f", + "myMw": "VfkIFLvROCCkQoUApAnoahH", + "RZEQcEjWVL": "wvNUPkVOZNEFeBNjT" + } + ], + "XgZpFrSFdfOaiyG": { + "EOpkfIwYsqMJVEuCd": [ + { + "WNWqbIGE": "KV", + "WBelZShKJQOfrdzaHmeB": 5472896691686, + "JBIXmFBrqTZCrFiRIsX": 2537640159589, + "ZqeyuVaHEzqGguT": "mU", + "IGGwKTUNVuIqWhHxguJ": 920159032553, + "FrXZApVt": "iw", + "PwKyEEv": "WVgel", + "pzerZSUF": "OeexCJqW", + "iEZNSXUzExuGRBCJPpfSYAjKGVMySEg": "Xxod", + "BYeMnDMghzdu": "VmhfTPb", + "RAfvTTlXFndzMdzQ": "DijII", + "lFyvSptmIXLEaCITguUlOWUiKinU": "G", + "ocjcEOUYvrjuXHGcPvMZZLanWfVO": "s", + "VAvZVQCr": 56168, + "cNuPEdjOOBIflxU": "zrlEWdItNm", + "CdivJDXYCdDGCKUwvYzGhc": "RLQNkpniJf", + "TxUWBLLBIvWnLWU": "gNdlqeZMWb" + } + ] + }, + "mNeCoNPnyAGmHbaKPcew": null + }, + "iSdPgmIL": { + "PQWhZZAAmc": "KaxKwOfV", + "lBixuJnwKkTXb": "mdYfMahBCGKxYxYUzmuLWatjbvwoEJpvINrBfIRufZilKWEBBhTNVfQNGacQLFrbBPYKrZ", + "thwyvLhRZtMEXp": "CfmYqPuDqxdozCexWCVbAMhFjMADKtoJAVooKRavwe", + "XBwebaSjudhpIdegNuX": 41, + "PVEhBcIvAEUExR": [ + { + "hP": "b", + "ZREX": "yGjjCFbIHmChGFjCwUWPCL", + "fSuYODYuTI": "XSmBDnTzFsWKfPrSZtNlw" + }, + { + "xk": "u", + "MaTZ": "nHYbQnreirVRtzxtNBZZxAiPdOkMfKbTAumbOhIHTtZe", + "aeELNUjGXc": "ckfAhwDcStirCeYjEiDohaqYZCubwHlDosxRy" + }, + { + "ip": "D", + "IeZa": "dIoIXtndoYuPqtFWpGPNhrICQxGGtZHwNaNMPCfwsgqs", + "MLHSeWQvun": "asWubIslOzHcXEvKvmgFmLXOoPVBXWSZZLjgKK" + }, + { + "KL": "a", + "Deci": "LqYMdCjQvypYEYeqfkZxQZSyNOuchvzxbPYiqrPOyjxA", + "RGdhviYBSA": "tYysPwlcraDHTaJOAhDOqHgHrnDtgQUtXdQvs" + }, + { + "ui": "t", + "uRPi": "BlzWoFrIFrYNeYFJhWDgiEz", + "pOfcXLFiRG": "ylYemBXNczSbqgHiMgBPPmLu" + }, + { + "Am": "F", + "OjNZ": "fQfHSNQqHBCtZcwhcAKyqxzdFfIIOxMftGKplAoAkwjgOhl", + "SvFMBmUzak": "UYsxRElPxuenwTrLascZSuadKdLlT" + }, + { + "bO": "K", + "sidr": "PjqwXqNtAGnSQyv", + "tqFzZUMriu": "dDLBwYlhxSaW" + } + ], + "mzIDMiArtQSpMXe": { + "PvZUFGOrSQiKVPtioChPn": [], + "bTwocvbiAytXxsMDuWWkobDytxmCDrpvfvrmw": [], + "dsckhTZaxeCoMoutliYFhNKMeggqpifDiaDUiV": [], + "iMJDWUfULWvFEKAzyksTGIwcgEnuBnBCqsAtV": [], + "aFbYQFVXmVCTAVgCFIxjxlWa": [ + { + "rxccKLhC": 607209, + "jxRkiFOszchOWCXsPa": 853415, + "ZFUqqHGtgGfShZrfyLlEpeM": "PU", + "ETALsHZNmhTvtPLQABi": "EPnDNvAMzWyBXZIHiCtdFfZElKCpVelqfjpHAeSjocCczAEUGTQSOxzOELKkmIKsfflKbLczKi", + "hJAcpYcQCTTcMLWHxpBv": "VIClylsnmKYna", + "qgtueiKiivReWVPilYZQDXunxqvlJkXeBF": "fbeTJKqcdPE", + "UFCQRAHGHlptSNswzzhubxWtHadsgZ": 4, + "YwvFAcZpEfsiiCJIxORuIfpQxiQgz": "r", + "dXpmjLAyxkRKZiHeqWMrcnnWgmvgSl": "q", + "WOyHyfMdnEplxmZaTLooHZWChQ": "gudjRcFWOLRERIeKXyOgsPgIXPLM", + "odvUOeunJkgQwlTbGQfMpqQXpWDbxB": 49757, + "sjsEqexwjwmVjTofGNUAkrONNkXxRUeSKJwo": 1, + "XZQdtbutsHYeEkAlSDh": 14902, + "OghMlcTCKykmdSOoMFqKqILVb": 9, + "hLfbsUWGKzNHwJMbstdGnoY": "tEsBbEZL", + "wERJdBMqpOvISKCQbOVUhaqijbPfF": "m", + "HgVrSBmZQAod": "aLPSrNvQ", + "HTMJaQlPdeialUpCrn": "o", + "tRcUUiuNxlFuGwgmqJQnORXNWfWla": "ujjVgiOKgDzaEEgS", + "uQqUWGIGJRXUmuNehmF": "bDkLQRq", + "ZuwOFzVKzhZXgKniCDa": "KlBNidYpPOduorGECuF", + "TeRfmZady": "kJxokCn" + } + ], + "dqvnKDiCkvhCqKrSDGkpRkXCDIftR": [], + "PWHymzgDFOaA": [] + }, + "fEyJNjSyMZPIFepbZogE": { + "ltwjGeZxWeeGELSWMdxFT": [], + "WgRMTIQEsVYDwrkYujshoKPATzuKICzHHyrnW": [], + "CdfTMvbcWnDzROPJLmRTHXBkYqlrNfNLfqrzcZ": [], + "FqFcxVmbnaBdBNbzmnpaAxxRnBsDktOpbsvqi": [], + "eGyzHvHntiqnRHfwFkOuOTRm": [ + { + "ZaLEuyOQ": 551336, + "JbjQRdfWNlihlrTTNe": 665065, + "VTdVCMIYsielRhFvXgKeLsK": "YV", + "uEYAOFxlVuBZlSlReeF": "zYvYQKZfkCNnxyrirHQaILExJKmNbzjGSOPBQicLZRjpgMjpCkVshOxoCReMvuwLKGVYfseZSh", + "iNLaNOeehzRdsLrCqkJK": "BHxezgseqMKZf", + "dukMUTiUlQZQUufztKLovwiQxschLRsdOW": "PnhNVRmJNGJ", + "rcSjXuIaGcfuDrQtTmuXXEgcfyniLe": 1, + "WJxbdGMIEbrjbHnzKYHCfjMyBrTYV": "y", + "sEyFRkVqMpCXQlBUVbLRYZwtaDyoPN": "t", + "kfjgJjpqynKLMhDyDOWmtHeHjP": "WnHxhgOETDJUITCjlBylUrscDSYK", + "LejvWPtJwajVLTLdDcdaEMTXfXXMLr": 56303, + "ZGovOsYyOKxOQsOLobkdhqyZQjsSnJrADkuI": 5, + "oJbBWMqlPQjuRRcxeab": 38262, + "EKljILsmyegQoCpgxiDZUHqRn": 9, + "tIzcjBaZfVEGVHDdzlSERWY": "EZzWUFaa", + "kSwZsdMwneegDrbDtrqDrsHCkDhXm": "V", + "LdyxtvxSlvoO": "hhOEdElb", + "XXhMDijIpqbbFGeWSL": "s", + "HkKEgyKDWCiuhraOmUMtTJwCbRurs": "IDQCaeYIKCokMaKZ", + "vxTksqnWOuOurGvqmCz": "YCHSCGH", + "JNgtIFmpcCKZNYcdBcL": "uVsineYMkkJqRxVcNXc", + "TpelcIFbX": "bXyOyCr" + }, + { + "SALUwgGO": 422160, + "vUPoqCeyXZYVyKdDez": 110181, + "wuudGJomXEbQerkPAeuiHdQ": "Rq", + "HJaHAJDwUxlDJVZEJtL": "IADfuWftNRNMWsbTqAlTcebAdSriMXAGtODjtVefGcipUuhpcpXvNUAACECiCHGqldwqyQSjzk", + "lOoqUabKofbFaFNhWAce": "vHOeURaAPZKZo", + "fPAsoVQTMaYtCLUIhtJSoEXwmQiEueBOoT": "pbMtzhGNFuw", + "MkopzNpKrfqKMvfQudArubAIJCKaPn": 1, + "mGdnyuYRkoepaFhWcvurdzFptTfpY": "q", + "XXFafRRdtZrjfGEqUJjsKFHFJNkDgb": "m", + "OfYzQuLFBLIztNRebcrgApfXFr": "CKWuWfYuemwUuZHASdutpkyZDEiCz", + "tfCWhBWHBAPCjiVflgHumZshNyDlnX": 61351, + "xFxUmTfoQRkBoxjUObDwGRLuNRoPqTqSgOZC": 4, + "NiDyWWTaTgRPgwOkhKm": 24342, + "UShOPbdowGsHPaVZIoJGAulgU": 6, + "maREjMjuZKBYfhjpKfApJNJ": "DCftkfKw", + "YWURXoXhvPTvsLYSSWJrQvuaESwjZ": "i", + "kQbsQaAUkKui": "UtGEJuCQ", + "JFXelEkDJbobOlqITR": "Z", + "WSZyvSdVSmEtDPuvaxhjvZMYPTHFU": "gzuAUDiKhSSKzUvD", + "CXNQtGtVVgZuQDmpIVl": "gClSrZn", + "hsOsbDuVUxYZshFaHYw": "gxnNDDnMkpNTygSQFFH", + "sYprmhCGN": "ilfhBoD" + }, + { + "HYYoAEXq": 888873, + "sdxgslAUiBmttwDBlI": 579463, + "nAyDIUtNKsxpDdVXDIbuQFj": "ay", + "CrgHZFdSEkIiBzEFsSn": "pAXHeVZezbDAFBVonEDmbwksjOkCkCrRsyWBvAKMhogmhaWUUJgvaHylPFeGqooHqiYPEJBbrK", + "AllKJtgeiHqNEMNYyAGg": "rApYKQbcQvcHf", + "EehtqHSYQeVhGSrxPdTwDpOpZtBOWryJui": "kCZEvWOCHBY", + "CSDFuGPObwocJLQjkMSmeNaHGwkdiT": 3, + "gIFGmniKsjcrTXBpdjXLgqAuTgNLH": "C", + "OAwnMHqtoUoyYeIffrNhMYpEcOhLgG": "e", + "LmEHwVpaVRpISbUSvyRQvGEsYB": "ajUjUMkYYtrLOzLyCRnrGIyHSPcMP", + "kfdrIuzfuMJNLmTaGuLsJgJIrzcLte": 86692, + "WzQgVsABgeBuYJagjNwvMqxbDsKsJQlqhNPq": 5, + "KDMMcdELUBZVMRQrFaq": 49970, + "UNZFnVUvWmdYvdMVYmMEMtCiR": 5, + "PTfmooQOsfhdpOoJZaIoRbP": "EIcImRxG", + "wCPtSYedohQXjBSTsVRzBninLgUWN": "R", + "tKweVWmpUfxI": "rXHiyDYd", + "xFXsHVjXcFVmvZmQLp": "A", + "ScZSUFQCzCspzjOSRdyPTukuCsfYM": "rZuKxCcnsFVYMRmO", + "LDcQksbwplenjbJvDBW": "UoiDLmz", + "INZskzBorkuzkhKNOps": "eUbTWBkljMMqbusgukl", + "mrfTmDiHW": "LFCjGfg" + }, + { + "EjNCWrFb": 742413, + "NMVvbenrZuBgTDUZCp": 186545, + "BUtfbTGYlHreDCxyWILogKd": "Mz", + "NKrESnACBnpJJHjmkTZ": "qASBSQVhyRLbdZdVxwzflldWiPJHVTVKiPYYPfEfCCbhdoOqFxDcioreqnsJfTpnRqQgygySaZ", + "aiNcHSGPLDbbWgCNwYur": "AYNcpKIqqjMyq", + "ILZhJVUfFssjrztzcpVVDvKkQYZzwjCoxN": "WnCnNqoerIc", + "lsLxVCBnLkmJzHHGiPTuRjGiOMsdcl": 1, + "OpAEZwatpCTUKdNeGhdrNYKiDCjKF": "H", + "oieUmtelFLuhNltJTbpnlDUrumqEUh": "w", + "HbCaXRqyuyMOwTBlGnNZXJFGxr": "tNegGiMyeuEJjvmLBHaftebPfTUgt", + "nqwdZutJpaaJnWIpexLGvelyBzAQAR": 23649, + "rcDqtgcbrVcihshHVKGJKswBScQkpIOdsOFv": 7, + "HIEJnealWLrYSoncIBg": 86311, + "MVXXrgZRsbVJXHdUHaCgKnwiV": 1, + "WKPsIWKDqDtcRBsQwualDYd": "EHpHqhZh", + "IxwTNNpzhgXrFgDdfJUrPSJJlXwIv": "n", + "iofIoBqJwtem": "msvMqPBN", + "QLCHAVqTVdLUOFjHXA": "z", + "NsHhiTPivEbEQYEREvxCXUUfgVNDb": "IAUoWhCriVcellGN", + "SrpJdwlzurxhyTYVZNd": "DLGNoPy", + "zzjpPxKOqxtIgPmKnhI": "sSOJzIbjrPSjjFECexD", + "ICTfrcAwa": "ilXpLHy" + } + ], + "RGTbNGqucucZmQwmPnzuMMGgpBYjh": [], + "QzSZKtDHHvnV": [] + } + } + }, + "uiINEgYLQtWZTKPk": {} + }, + "payloadClaimCheck": null + } +} diff --git a/tests/aws/services/events/test_x_ray_trace_propagation.py b/tests/aws/services/events/test_x_ray_trace_propagation.py new file mode 100644 index 0000000000000..a894dc6345b7d --- /dev/null +++ b/tests/aws/services/events/test_x_ray_trace_propagation.py @@ -0,0 +1,434 @@ +import json +import time + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from localstack.utils.testutil import check_expected_lambda_log_events_length +from localstack.utils.xray.trace_header import TraceHeader +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_AWS_PROXY_FORMAT + +APIGATEWAY_ASSUME_ROLE_POLICY = { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": {"Service": "apigateway.amazonaws.com"}, + "Action": "sts:AssumeRole", + } +} +import re + +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from tests.aws.services.events.helper_functions import is_old_provider +from tests.aws.services.events.test_events import TEST_EVENT_DETAIL, TEST_EVENT_PATTERN +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_XRAY_TRACEID + +# currently only API Gateway v2 and Lambda support X-Ray tracing + + +@markers.aws.validated +@pytest.mark.skipif( + condition=is_old_provider(), + reason="not supported by the old provider", +) +def test_xray_trace_propagation_events_api_gateway( + aws_client, + create_role_with_policy, + create_lambda_function, + create_rest_apigw, + events_create_event_bus, + events_put_rule, + region_name, + cleanups, + account_id, +): + # create lambda + function_name = f"test-function-{short_uid()}" + function_arn = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_AWS_PROXY_FORMAT, + handler="lambda_aws_proxy_format.handler", + runtime=Runtime.python3_12, + )["CreateFunctionResponse"]["FunctionArn"] + + # create api gateway with lambda integration + # create rest api + api_id, api_name, root_id = create_rest_apigw( + name=f"test-api-{short_uid()}", + description="Test Integration with EventBridge X-Ray", + ) + + resource_id = aws_client.apigateway.create_resource( + restApiId=api_id, parentId=root_id, pathPart="test" + )["id"] + + aws_client.apigateway.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + authorizationType="NONE", + ) + + # Lambda AWS_PROXY integration + aws_client.apigateway.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod="POST", + type="AWS_PROXY", + integrationHttpMethod="POST", + uri=f"arn:aws:apigateway:{region_name}:lambda:path/2015-03-31/functions/{function_arn}/invocations", + ) + + # Give permission to API Gateway to invoke Lambda + source_arn = f"arn:aws:execute-api:{region_name}:{account_id}:{api_id}/*/POST/test" + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"sid-{short_uid()}", + Action="lambda:InvokeFunction", + Principal="apigateway.amazonaws.com", + SourceArn=source_arn, + ) + + stage_name = "test" + aws_client.apigateway.create_deployment(restApiId=api_id, stageName=stage_name) + + # Create event bus + event_bus_name = f"test-bus-{short_uid()}" + events_create_event_bus(Name=event_bus_name) + + # Create rule + rule_name = f"test-rule-{short_uid()}" + event_pattern = {"source": ["test.source"], "detail-type": ["test.detail.type"]} + events_put_rule( + Name=rule_name, + EventBusName=event_bus_name, + EventPattern=json.dumps(event_pattern), + ) + + # Create an IAM Role for EventBridge to invoke API Gateway + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + role_name, role_arn = create_role_with_policy( + effect="Allow", + actions="execute-api:Invoke", + assume_policy_doc=json.dumps(assume_role_policy_document), + resource=source_arn, + attach=False, # Since we're using put_role_policy, not attach_role_policy + ) + + # Allow some time for IAM role propagation (only needed in AWS) + if is_aws_cloud(): + time.sleep(10) + + # Add the API Gateway as a target with the RoleArn + target_id = f"target-{short_uid()}" + api_target_arn = ( + f"arn:aws:execute-api:{region_name}:{account_id}:{api_id}/{stage_name}/POST/test" + ) + put_targets_response = aws_client.events.put_targets( + Rule=rule_name, + EventBusName=event_bus_name, + Targets=[ + { + "Id": target_id, + "Arn": api_target_arn, + "RoleArn": role_arn, + "Input": json.dumps({"message": "Hello from EventBridge"}), + "RetryPolicy": {"MaximumRetryAttempts": 0}, + } + ], + ) + assert put_targets_response["FailedEntryCount"] == 0 + + ###### + # Test + ###### + # Enable X-Ray tracing for the aws_client + trace_id = "1-67f4141f-e1cd7672871da115129f8b19" + parent_id = "d0ee9531727135a0" + xray_trace_header = TraceHeader(root=trace_id, parent=parent_id, sampled=1) + + def add_xray_header(request, **kwargs): + request.headers["X-Amzn-Trace-Id"] = xray_trace_header.to_header_str() + + event_name = "before-send.events.*" + aws_client.events.meta.events.register(event_name, add_xray_header) + + # make sure the hook gets cleaned up after the test + cleanups.append(lambda: aws_client.events.meta.events.unregister(event_name, add_xray_header)) + + event_entry = { + "EventBusName": event_bus_name, + "Source": "test.source", + "DetailType": "test.detail.type", + "Detail": json.dumps({"message": "Hello from EventBridge"}), + } + put_events_response = aws_client.events.put_events(Entries=[event_entry]) + assert put_events_response["FailedEntryCount"] == 0 + + # Verify the Lambda invocation + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=10, + sleep_before=10 if is_aws_cloud() else 1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + + # TODO how to assert X-Ray trace ID correct propagation from eventbridge to api gateway if no X-Ray trace id is present in the event + + lambda_trace_header = events[0]["headers"].get("X-Amzn-Trace-Id") + assert lambda_trace_header is not None + lambda_trace_id = re.search(r"Root=([^;]+)", lambda_trace_header).group(1) + assert lambda_trace_id == trace_id + + +@markers.aws.validated +@pytest.mark.skipif( + condition=is_old_provider(), + reason="not supported by the old provider", +) +def test_xray_trace_propagation_events_lambda( + create_lambda_function, + events_create_event_bus, + events_put_rule, + cleanups, + aws_client, +): + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_XRAY_TRACEID, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + bus_name = f"bus-{short_uid()}" + events_create_event_bus(Name=bus_name) + + rule_name = f"rule-{short_uid()}" + rule_arn = events_put_rule( + Name=rule_name, + EventBusName=bus_name, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + )["RuleArn"] + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn, + ) + + target_id = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name, + EventBusName=bus_name, + Targets=[{"Id": target_id, "Arn": lambda_function_arn}], + ) + + # Enable X-Ray tracing for the aws_client + trace_id = "1-67f4141f-e1cd7672871da115129f8b19" + parent_id = "d0ee9531727135a0" + xray_trace_header = TraceHeader(root=trace_id, parent=parent_id, sampled=1) + + def add_xray_header(request, **kwargs): + request.headers["X-Amzn-Trace-Id"] = xray_trace_header.to_header_str() + + event_name = "before-send.events.*" + aws_client.events.meta.events.register(event_name, add_xray_header) + # make sure the hook gets cleaned up after the test + cleanups.append(lambda: aws_client.events.meta.events.unregister(event_name, add_xray_header)) + + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + # Verify the Lambda invocation + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=10, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + + # TODO how to assert X-Ray trace ID correct propagation from eventbridge to api lambda if no X-Ray trace id is present in the event + + lambda_trace_header = events[0]["trace_id_inside_handler"] + assert lambda_trace_header is not None + lambda_trace_id = re.search(r"Root=([^;]+)", lambda_trace_header).group(1) + assert lambda_trace_id == trace_id + + +@markers.aws.validated +@pytest.mark.parametrize( + "bus_combination", [("default", "custom"), ("custom", "custom"), ("custom", "default")] +) +@pytest.mark.skipif( + condition=is_old_provider(), + reason="not supported by the old provider", +) +def test_xray_trace_propagation_events_events( + bus_combination, + create_lambda_function, + events_create_event_bus, + create_role_event_bus_source_to_bus_target, + region_name, + account_id, + events_put_rule, + cleanups, + aws_client, +): + """ + Event Bridge Bus Source to Event Bridge Bus Target to Lambda for asserting X-Ray trace propagation + """ + # Create event buses + bus_source, bus_target = bus_combination + if bus_source == "default": + bus_name_source = "default" + if bus_source == "custom": + bus_name_source = f"test-event-bus-source-{short_uid()}" + events_create_event_bus(Name=bus_name_source) + if bus_target == "default": + bus_name_target = "default" + bus_arn_target = f"arn:aws:events:{region_name}:{account_id}:event-bus/default" + if bus_target == "custom": + bus_name_target = f"test-event-bus-target-{short_uid()}" + bus_arn_target = events_create_event_bus(Name=bus_name_target)["EventBusArn"] + + # Create permission for event bus source to send events to event bus target + role_arn_bus_source_to_bus_target = create_role_event_bus_source_to_bus_target() + + if is_aws_cloud(): + time.sleep(10) # required for role propagation + + # Permission for event bus target to receive events from event bus source + aws_client.events.put_permission( + StatementId=f"TargetEventBusAccessPermission{short_uid()}", + EventBusName=bus_name_target, + Action="events:PutEvents", + Principal="*", + ) + + # Create rule source event bus to target + rule_name_source_to_target = f"test-rule-source-to-target-{short_uid()}" + events_put_rule( + Name=rule_name_source_to_target, + EventBusName=bus_name_source, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + ) + + # Add target event bus as target + target_id_event_bus_target = f"test-target-source-events-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name_source_to_target, + EventBusName=bus_name_source, + Targets=[ + { + "Id": target_id_event_bus_target, + "Arn": bus_arn_target, + "RoleArn": role_arn_bus_source_to_bus_target, + } + ], + ) + + # Create Lambda function + function_name = f"lambda-func-{short_uid()}" + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_XRAY_TRACEID, + func_name=function_name, + runtime=Runtime.python3_12, + ) + lambda_function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + # Connect Event Bus Target to Lambda + rule_name_lambda = f"rule-{short_uid()}" + rule_arn_lambda = events_put_rule( + Name=rule_name_lambda, + EventBusName=bus_name_target, + EventPattern=json.dumps(TEST_EVENT_PATTERN), + )["RuleArn"] + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"{rule_name_lambda}-Event", + Action="lambda:InvokeFunction", + Principal="events.amazonaws.com", + SourceArn=rule_arn_lambda, + ) + + target_id_lambda = f"target-{short_uid()}" + aws_client.events.put_targets( + Rule=rule_name_lambda, + EventBusName=bus_name_target, + Targets=[{"Id": target_id_lambda, "Arn": lambda_function_arn}], + ) + + ###### + # Test + ###### + + # Enable X-Ray tracing for the aws_client + trace_id = "1-67f4141f-e1cd7672871da115129f8b19" + parent_id = "d0ee9531727135a0" + xray_trace_header = TraceHeader(root=trace_id, parent=parent_id, sampled=1) + + def add_xray_header(request, **kwargs): + request.headers["X-Amzn-Trace-Id"] = xray_trace_header.to_header_str() + + event_name = "before-send.events.*" + aws_client.events.meta.events.register(event_name, add_xray_header) + # make sure the hook gets cleaned up after the test + cleanups.append(lambda: aws_client.events.meta.events.unregister(event_name, add_xray_header)) + + aws_client.events.put_events( + Entries=[ + { + "EventBusName": bus_name_source, + "Source": TEST_EVENT_PATTERN["source"][0], + "DetailType": TEST_EVENT_PATTERN["detail-type"][0], + "Detail": json.dumps(TEST_EVENT_DETAIL), + } + ] + ) + + # Verify the Lambda invocation + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=10, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + + # TODO how to assert X-Ray trace ID correct propagation from eventbridge to eventbridge lambda if no X-Ray trace id is present in the event + + lambda_trace_header = events[0]["trace_id_inside_handler"] + assert lambda_trace_header is not None + lambda_trace_id = re.search(r"Root=([^;]+)", lambda_trace_header).group(1) + assert lambda_trace_id == trace_id diff --git a/tests/aws/services/events/test_x_ray_trace_propagation.validation.json b/tests/aws/services/events/test_x_ray_trace_propagation.validation.json new file mode 100644 index 0000000000000..5ce2e5c48fff7 --- /dev/null +++ b/tests/aws/services/events/test_x_ray_trace_propagation.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_api_gateway": { + "last_validated_date": "2025-04-08T10:51:26+00:00" + }, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_events[bus_combination0]": { + "last_validated_date": "2025-04-10T10:13:06+00:00" + }, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_events[bus_combination1]": { + "last_validated_date": "2025-04-10T10:13:27+00:00" + }, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_events[bus_combination2]": { + "last_validated_date": "2025-04-10T10:14:01+00:00" + }, + "tests/aws/services/events/test_x_ray_trace_propagation.py::test_xray_trace_propagation_events_lambda": { + "last_validated_date": "2025-04-08T10:46:50+00:00" + } +} diff --git a/tests/aws/services/firehose/__init__.py b/tests/aws/services/firehose/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/firehose/conftest.py b/tests/aws/services/firehose/conftest.py new file mode 100644 index 0000000000000..a0b903ccdbf4b --- /dev/null +++ b/tests/aws/services/firehose/conftest.py @@ -0,0 +1,36 @@ +import logging +from typing import Literal + +import pytest + +from localstack.utils.sync import retry + +StreamType = Literal["DirectPut", "KinesisStreamAsSource", "MSKAsSource"] + +LOG = logging.getLogger(__name__) + + +@pytest.fixture +def read_s3_data(aws_client): + s3 = aws_client.s3 + + def _read_s3_data(bucket_name: str, timeout: int = 10) -> dict[str, str]: + def _get_data(): + response = s3.list_objects(Bucket=bucket_name) + if response.get("Contents") is None: + raise Exception("No data in bucket yet") + + keys = [obj.get("Key") for obj in response.get("Contents")] + + bucket_data = dict() + for key in keys: + response = s3.get_object(Bucket=bucket_name, Key=key) + data = response["Body"].read().decode("utf-8") + bucket_data[key] = data + return bucket_data + + bucket_data = retry(_get_data, sleep=1, retries=timeout) + + return bucket_data + + return _read_s3_data diff --git a/tests/aws/services/firehose/helper_functions.py b/tests/aws/services/firehose/helper_functions.py new file mode 100644 index 0000000000000..6993e264734d4 --- /dev/null +++ b/tests/aws/services/firehose/helper_functions.py @@ -0,0 +1,60 @@ +from typing import Union + + +def get_firehose_iam_documents( + bucket_arns: Union[list[str], str], stream_arns: Union[list[str], str] +) -> tuple[dict, dict]: + """ + Generate the required IAM role and policy documents for Firehose. + """ + if isinstance(bucket_arns, str): + bucket_arns = [bucket_arns] + bucket_arns.extend([f"{bucket_arn}/*" for bucket_arn in bucket_arns]) + if isinstance(stream_arns, str): + stream_arns = [stream_arns] + + role_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "FirehoseAssumeRole", + "Effect": "Allow", + "Principal": {"Service": "firehose.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:PutObject", + ], + "Resource": bucket_arns, + }, + { + "Effect": "Allow", + "Action": [ + "kinesis:DescribeStream", + "kinesis:GetShardIterator", + "kinesis:GetRecords", + "kinesis:ListShards", + ], + "Resource": stream_arns, + }, + { + "Effect": "Allow", + "Action": ["logs:PutLogEvents", "logs:CreateLogStream"], + "Resource": "arn:aws:logs:*:*:*", + }, + ], + } + return role_document, policy_document diff --git a/tests/aws/services/firehose/test_firehose.py b/tests/aws/services/firehose/test_firehose.py new file mode 100644 index 0000000000000..33497aa875fb5 --- /dev/null +++ b/tests/aws/services/firehose/test_firehose.py @@ -0,0 +1,590 @@ +import base64 +import json +import time + +import pytest as pytest +import requests +from pytest_httpserver import HTTPServer + +from localstack import config +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.strings import short_uid, to_bytes, to_str +from localstack.utils.sync import poll_condition, retry +from tests.aws.services.firehose.helper_functions import get_firehose_iam_documents + +PROCESSOR_LAMBDA = """ +def handler(event, context): + import base64 + records = event.get("records", []) + for i in range(len(records)): + # assert that metadata are contained in the records + assert "approximateArrivalTimestamp" in records[i] + assert "kinesisRecordMetadata" in records[i] + assert records[i]["kinesisRecordMetadata"]["shardId"] + assert records[i]["kinesisRecordMetadata"]["partitionKey"] + assert records[i]["kinesisRecordMetadata"]["approximateArrivalTimestamp"] + assert records[i]["kinesisRecordMetadata"]["sequenceNumber"] + # convert record data + data = records[i].get("data") + data = base64.b64decode(data) + b"-processed" + records[i]["data"] = base64.b64encode(data).decode("utf-8") + return {"records": records} +""" + +TEST_MESSAGE = "Test-message-2948294kdlsie" + + +@pytest.mark.parametrize("lambda_processor_enabled", [True, False]) +@markers.aws.unknown +def test_kinesis_firehose_http( + aws_client, + lambda_processor_enabled: bool, + create_lambda_function, + httpserver: HTTPServer, + cleanups, +): + httpserver.expect_request("").respond_with_data(b"", 200) + http_endpoint = httpserver.url_for("/") + if lambda_processor_enabled: + # create processor func + func_name = f"proc-{short_uid()}" + func_arn = create_lambda_function(handler_file=PROCESSOR_LAMBDA, func_name=func_name)[ + "CreateFunctionResponse" + ]["FunctionArn"] + + # define firehose configs + http_destination_update = { + "EndpointConfiguration": {"Url": http_endpoint, "Name": "test_update"} + } + http_destination = { + "EndpointConfiguration": {"Url": http_endpoint}, + "S3BackupMode": "FailedDataOnly", + "S3Configuration": { + "RoleARN": "arn:.*", + "BucketARN": "arn:.*", + "Prefix": "", + "ErrorOutputPrefix": "", + "BufferingHints": {"SizeInMBs": 1, "IntervalInSeconds": 60}, + }, + } + + if lambda_processor_enabled: + http_destination["ProcessingConfiguration"] = { + "Enabled": True, + "Processors": [ + { + "Type": "Lambda", + "Parameters": [ + { + "ParameterName": "LambdaArn", + "ParameterValue": func_arn, + } + ], + } + ], + } + + # create firehose stream with http destination + firehose = aws_client.firehose + stream_name = "firehose_" + short_uid() + stream = firehose.create_delivery_stream( + DeliveryStreamName=stream_name, + HttpEndpointDestinationConfiguration=http_destination, + ) + assert stream + cleanups.append(lambda: firehose.delete_delivery_stream(DeliveryStreamName=stream_name)) + + stream_description = firehose.describe_delivery_stream(DeliveryStreamName=stream_name) + stream_description = stream_description["DeliveryStreamDescription"] + destination_description = stream_description["Destinations"][0][ + "HttpEndpointDestinationDescription" + ] + assert len(stream_description["Destinations"]) == 1 + assert destination_description["EndpointConfiguration"]["Url"] == http_endpoint + + # put record + msg_text = "Hello World!" + firehose.put_record(DeliveryStreamName=stream_name, Record={"Data": msg_text}) + + # wait for the result to arrive with proper content + assert poll_condition(lambda: len(httpserver.log) >= 1, timeout=5) + request, _ = httpserver.log[0] + record = request.get_json(force=True) + received_record = record["records"][0] + received_record_data = to_str(base64.b64decode(to_bytes(received_record["data"]))) + assert received_record_data == f"{msg_text}{'-processed' if lambda_processor_enabled else ''}" + + # update stream destination + destination_id = stream_description["Destinations"][0]["DestinationId"] + version_id = stream_description["VersionId"] + firehose.update_destination( + DeliveryStreamName=stream_name, + DestinationId=destination_id, + CurrentDeliveryStreamVersionId=version_id, + HttpEndpointDestinationUpdate=http_destination_update, + ) + stream_description = firehose.describe_delivery_stream(DeliveryStreamName=stream_name) + stream_description = stream_description["DeliveryStreamDescription"] + destination_description = stream_description["Destinations"][0][ + "HttpEndpointDestinationDescription" + ] + assert destination_description["EndpointConfiguration"]["Name"] == "test_update" + + +class TestFirehoseIntegration: + @markers.skip_offline + @markers.aws.unknown + @pytest.mark.skip(reason="flaky") + def test_kinesis_firehose_elasticsearch_s3_backup( + self, + s3_bucket, + kinesis_create_stream, + cleanups, + aws_client, + account_id, + ): + domain_name = f"test-domain-{short_uid()}" + stream_name = f"test-stream-{short_uid()}" + role_arn = f"arn:aws:iam::{account_id}:role/Firehose-Role" + delivery_stream_name = f"test-delivery-stream-{short_uid()}" + es_create_response = aws_client.es.create_elasticsearch_domain(DomainName=domain_name) + cleanups.append(lambda: aws_client.es.delete_elasticsearch_domain(DomainName=domain_name)) + es_url = f"http://{es_create_response['DomainStatus']['Endpoint']}" + es_arn = es_create_response["DomainStatus"]["ARN"] + + # create s3 backup bucket arn + bucket_arn = arns.s3_bucket_arn(s3_bucket) + + # create kinesis stream + kinesis_create_stream(StreamName=stream_name, ShardCount=2) + stream_info = aws_client.kinesis.describe_stream(StreamName=stream_name) + stream_arn = stream_info["StreamDescription"]["StreamARN"] + + kinesis_stream_source_def = { + "KinesisStreamARN": stream_arn, + "RoleARN": role_arn, + } + elasticsearch_destination_configuration = { + "RoleARN": role_arn, + "DomainARN": es_arn, + "IndexName": "activity", + "TypeName": "activity", + "S3BackupMode": "AllDocuments", + "S3Configuration": { + "RoleARN": role_arn, + "BucketARN": bucket_arn, + }, + } + aws_client.firehose.create_delivery_stream( + DeliveryStreamName=delivery_stream_name, + DeliveryStreamType="KinesisStreamAsSource", + KinesisStreamSourceConfiguration=kinesis_stream_source_def, + ElasticsearchDestinationConfiguration=elasticsearch_destination_configuration, + ) + cleanups.append( + lambda: aws_client.firehose.delete_delivery_stream(DeliveryStreamName=stream_name) + ) + + # wait for delivery stream to be ready + def check_stream_state(): + stream = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=delivery_stream_name + ) + return stream["DeliveryStreamDescription"]["DeliveryStreamStatus"] == "ACTIVE" + + assert poll_condition(check_stream_state, 45, 1) + + # wait for ES cluster to be ready + def check_domain_state(): + result = aws_client.es.describe_elasticsearch_domain(DomainName=domain_name) + return not result["DomainStatus"]["Processing"] + + # if ElasticSearch is not yet installed, it might take some time to download the package before starting the domain + assert poll_condition(check_domain_state, 120, 1) + + # put kinesis stream record + kinesis_record = {"target": "hello"} + aws_client.kinesis.put_record( + StreamName=stream_name, Data=to_bytes(json.dumps(kinesis_record)), PartitionKey="1" + ) + + firehose_record = {"target": "world"} + aws_client.firehose.put_record( + DeliveryStreamName=delivery_stream_name, + Record={"Data": to_bytes(json.dumps(firehose_record))}, + ) + + def assert_elasticsearch_contents(): + response = requests.get(f"{es_url}/activity/_search") + response_bod = response.json() + assert "hits" in response_bod + response_bod_hits = response_bod["hits"] + assert "hits" in response_bod_hits + result = response_bod_hits["hits"] + assert len(result) == 2 + sources = [item["_source"] for item in result] + assert firehose_record in sources + assert kinesis_record in sources + + retry(assert_elasticsearch_contents) + + def assert_s3_contents(): + result = aws_client.s3.list_objects(Bucket=s3_bucket) + contents = [] + for o in result.get("Contents"): + data = aws_client.s3.get_object(Bucket=s3_bucket, Key=o.get("Key")) + content = data["Body"].read() + contents.append(content) + assert len(contents) == 2 + assert to_bytes(json.dumps(firehose_record)) in contents + assert to_bytes(json.dumps(kinesis_record)) in contents + + retry(assert_s3_contents) + + @markers.skip_offline + @pytest.mark.parametrize("opensearch_endpoint_strategy", ["domain", "path", "port"]) + @markers.aws.unknown + @pytest.mark.skip(reason="flaky") + def test_kinesis_firehose_opensearch_s3_backup( + self, + s3_bucket, + kinesis_create_stream, + monkeypatch, + opensearch_endpoint_strategy, + aws_client, + account_id, + ): + domain_name = f"test-domain-{short_uid()}" + stream_name = f"test-stream-{short_uid()}" + role_arn = f"arn:aws:iam::{account_id}:role/Firehose-Role" + delivery_stream_name = f"test-delivery-stream-{short_uid()}" + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", opensearch_endpoint_strategy) + try: + opensearch_create_response = aws_client.opensearch.create_domain(DomainName=domain_name) + opensearch_url = f"http://{opensearch_create_response['DomainStatus']['Endpoint']}" + opensearch_arn = opensearch_create_response["DomainStatus"]["ARN"] + + # create s3 backup bucket arn + bucket_arn = arns.s3_bucket_arn(s3_bucket) + + # create kinesis stream + kinesis_create_stream(StreamName=stream_name, ShardCount=2) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + kinesis_stream_source_def = { + "KinesisStreamARN": stream_arn, + "RoleARN": role_arn, + } + opensearch_destination_configuration = { + "RoleARN": role_arn, + "DomainARN": opensearch_arn, + "IndexName": "activity", + "TypeName": "activity", + "S3BackupMode": "AllDocuments", + "S3Configuration": { + "RoleARN": role_arn, + "BucketARN": bucket_arn, + }, + } + aws_client.firehose.create_delivery_stream( + DeliveryStreamName=delivery_stream_name, + DeliveryStreamType="KinesisStreamAsSource", + KinesisStreamSourceConfiguration=kinesis_stream_source_def, + AmazonopensearchserviceDestinationConfiguration=opensearch_destination_configuration, + ) + + # wait for delivery stream to be ready + def check_stream_state(): + stream = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=delivery_stream_name + ) + return stream["DeliveryStreamDescription"]["DeliveryStreamStatus"] == "ACTIVE" + + assert poll_condition(check_stream_state, 60, 1) + + # wait for opensearch cluster to be ready + def check_domain_state(): + result = aws_client.opensearch.describe_domain(DomainName=domain_name)[ + "DomainStatus" + ]["Processing"] + return not result + + # if OpenSearch is not yet installed, it might take some time to download the package before starting the domain + assert poll_condition(check_domain_state, 120, 1) + + # put kinesis stream record + kinesis_record = {"target": "hello"} + aws_client.kinesis.put_record( + StreamName=stream_name, Data=to_bytes(json.dumps(kinesis_record)), PartitionKey="1" + ) + + firehose_record = {"target": "world"} + aws_client.firehose.put_record( + DeliveryStreamName=delivery_stream_name, + Record={"Data": to_bytes(json.dumps(firehose_record))}, + ) + + def assert_opensearch_contents(): + response = requests.get(f"{opensearch_url}/activity/_search") + response_bod = response.json() + assert "hits" in response_bod + response_bod_hits = response_bod["hits"] + assert "hits" in response_bod_hits + result = response_bod_hits["hits"] + assert len(result) == 2 + sources = [item["_source"] for item in result] + assert firehose_record in sources + assert kinesis_record in sources + + retry(assert_opensearch_contents) + + def assert_s3_contents(): + result = aws_client.s3.list_objects(Bucket=s3_bucket) + contents = [] + for o in result.get("Contents"): + data = aws_client.s3.get_object(Bucket=s3_bucket, Key=o.get("Key")) + content = data["Body"].read() + contents.append(content) + assert len(contents) == 2 + assert to_bytes(json.dumps(firehose_record)) in contents + assert to_bytes(json.dumps(kinesis_record)) in contents + + retry(assert_s3_contents) + + finally: + aws_client.firehose.delete_delivery_stream(DeliveryStreamName=delivery_stream_name) + aws_client.opensearch.delete_domain(DomainName=domain_name) + + @markers.aws.unknown + def test_kinesis_firehose_kinesis_as_source( + self, s3_bucket, kinesis_create_stream, cleanups, aws_client, account_id + ): + bucket_arn = arns.s3_bucket_arn(s3_bucket) + stream_name = f"test-stream-{short_uid()}" + log_group_name = f"group{short_uid()}" + role_arn = f"arn:aws:iam::{account_id}:role/Firehose-Role" + delivery_stream_name = f"test-delivery-stream-{short_uid()}" + + kinesis_create_stream(StreamName=stream_name, ShardCount=2) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + response = aws_client.firehose.create_delivery_stream( + DeliveryStreamName=delivery_stream_name, + DeliveryStreamType="KinesisStreamAsSource", + KinesisStreamSourceConfiguration={ + "KinesisStreamARN": stream_arn, + "RoleARN": role_arn, + }, + ExtendedS3DestinationConfiguration={ + "BucketARN": bucket_arn, + "RoleARN": role_arn, + "BufferingHints": {"IntervalInSeconds": 60, "SizeInMBs": 64}, + "DynamicPartitioningConfiguration": {"Enabled": True}, + "ProcessingConfiguration": { + "Enabled": True, + "Processors": [ + { + "Type": "MetadataExtraction", + "Parameters": [ + { + "ParameterName": "MetadataExtractionQuery", + "ParameterValue": "{s3Prefix: .tableName}", + }, + {"ParameterName": "JsonParsingEngine", "ParameterValue": "JQ-1.6"}, + ], + }, + ], + }, + "DataFormatConversionConfiguration": {"Enabled": True}, + "CompressionFormat": "GZIP", + "Prefix": "firehoseTest/!{partitionKeyFromQuery:s3Prefix}/!{partitionKeyFromLambda:companyId}/!{partitionKeyFromLambda:year}/!{partitionKeyFromLambda:month}/", + "ErrorOutputPrefix": "firehoseTest-errors/!{firehose:error-output-type}/", + "CloudWatchLoggingOptions": { + "Enabled": True, + "LogGroupName": log_group_name, + }, + }, + ) + cleanups.append( + lambda: aws_client.firehose.delete_delivery_stream( + DeliveryStreamName=delivery_stream_name + ) + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # make sure the stream will come up at some point, for cleaner cleanup + def check_stream_state(): + stream = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=delivery_stream_name + ) + return stream["DeliveryStreamDescription"]["DeliveryStreamStatus"] == "ACTIVE" + + assert poll_condition(check_stream_state, 45, 1) + + @markers.aws.validated + def test_kinesis_firehose_kinesis_as_source_multiple_delivery_streams( + self, + s3_create_bucket, + kinesis_create_stream, + create_iam_role_with_policy, + wait_for_stream_ready, + firehose_create_delivery_stream, + read_s3_data, + snapshot, + aws_client, + ): + # create s3 bucket a and b + bucket_a_name = f"test-bucket-a-{short_uid()}" + s3_create_bucket(Bucket=bucket_a_name) + bucket_a_arn = arns.s3_bucket_arn(bucket_a_name) + bucket_b_name = f"test-bucket-b-{short_uid()}" + s3_create_bucket(Bucket=bucket_b_name) + bucket_b_arn = arns.s3_bucket_arn(bucket_b_name) + + # create kinesis stream + stream_name = f"test-stream-{short_uid()}" + kinesis_create_stream( + StreamName=stream_name, + ShardCount=1, + StreamModeDetails={"StreamMode": "PROVISIONED"}, + ) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + # create IAM role and policy + role_document, policy_document = get_firehose_iam_documents( + [bucket_a_arn, bucket_b_arn], stream_arn + ) + role_arn = create_iam_role_with_policy( + RoleDefinition=role_document, PolicyDefinition=policy_document + ) + # required for role propagation delay on aws + if is_aws_cloud(): + time.sleep(10) + wait_for_stream_ready(stream_name) + + # create log groupe for firehose delivery stream error logging + log_group_name = f"group-{short_uid()}" + aws_client.logs.create_log_group( + logGroupName=log_group_name, + ) + + # create firehose streams & subscribe to kinesis + delivery_stream_a_name = f"test-delivery-stream-a-{short_uid()}" + delivery_stream_b_name = f"test-delivery-stream-b-{short_uid()}" + + for bucket_arn, delivery_stream_name in [ + (bucket_a_arn, delivery_stream_a_name), + (bucket_b_arn, delivery_stream_b_name), + ]: + extended_s3_destination_configuration = { + "RoleARN": role_arn, + "BucketARN": bucket_arn, + "Prefix": "firehoseTest", + "ErrorOutputPrefix": "firehoseTest-errors/!{firehose:error-output-type}/", + "BufferingHints": {"SizeInMBs": 1, "IntervalInSeconds": 1}, + "CompressionFormat": "UNCOMPRESSED", + "EncryptionConfiguration": {"NoEncryptionConfig": "NoEncryption"}, + "CloudWatchLoggingOptions": { + "Enabled": True, + "LogGroupName": log_group_name, + "LogStreamName": f"stream-{short_uid()}", + }, + } + + firehose_create_delivery_stream( + DeliveryStreamName=delivery_stream_name, + DeliveryStreamType="KinesisStreamAsSource", + KinesisStreamSourceConfiguration={ + "KinesisStreamARN": stream_arn, + "RoleARN": role_arn, + }, + ExtendedS3DestinationConfiguration=extended_s3_destination_configuration, + ) + + # put message to kinesis event stream + record_data = TEST_MESSAGE + aws_client.kinesis.put_record( + StreamName=stream_name, + Data=record_data, + PartitionKey="1", + ) + + # poll file from s3 buckets + s3_data = dict() + for bucket_name in [bucket_a_name, bucket_b_name]: + s3_data_bucket = read_s3_data(bucket_name, timeout=300) + assert len(s3_data_bucket.keys()) == 1 + assert record_data == next(iter(s3_data_bucket.values())) + s3_data_bucket = {"folder-name": s3_data_bucket.popitem()[1]} + s3_data[bucket_name] = s3_data_bucket + + snapshot.add_transformer( + [ + snapshot.transform.regex(bucket_a_name, ""), + snapshot.transform.regex(bucket_b_name, ""), + ] + ) + snapshot.match("kinesis-event-stream-multiple-delivery-streams", s3_data) + + @markers.aws.validated + def test_kinesis_firehose_s3_as_destination_with_file_extension( + self, + s3_bucket, + aws_client, + account_id, + firehose_create_delivery_stream, + create_iam_role_with_policy, + ): + bucket_arn = arns.s3_bucket_arn(s3_bucket) + delivery_stream_name = f"test-delivery-stream-{short_uid()}" + file_extension = ".txt" + + role_policy, policy_document = get_firehose_iam_documents(bucket_arn, "*") + + role_arn = create_iam_role_with_policy( + RoleDefinition=role_policy, PolicyDefinition=policy_document + ) + + if is_aws_cloud(): + time.sleep(10) # AWS IAM propagation delay + + firehose_create_delivery_stream( + DeliveryStreamName=delivery_stream_name, + DeliveryStreamType="DirectPut", + ExtendedS3DestinationConfiguration={ + "BucketARN": bucket_arn, + "RoleARN": role_arn, + "FileExtension": file_extension, + "ErrorOutputPrefix": "errors", + }, + ) + + # prepare sample message + data = base64.b64encode(TEST_MESSAGE.encode()) + record = {"Data": data} + + def assert_s3_contents(): + aws_client.firehose.put_record( + DeliveryStreamName=delivery_stream_name, + Record=record, + ) + s3_objects = aws_client.s3.list_objects(Bucket=s3_bucket)["Contents"] + s3_object = s3_objects[0] + assert s3_object["Key"].endswith(file_extension) + + retry_options = {"sleep": 1, "retries": 10, "sleep_before": 1} + + if is_aws_cloud(): + retry_options["retries"] = 600 + retry_options["sleep"] = 5 + retry_options["sleep_before"] = 10 + + retry(assert_s3_contents, **retry_options) diff --git a/tests/aws/services/firehose/test_firehose.snapshot.json b/tests/aws/services/firehose/test_firehose.snapshot.json new file mode 100644 index 0000000000000..3ae5658d02344 --- /dev/null +++ b/tests/aws/services/firehose/test_firehose.snapshot.json @@ -0,0 +1,15 @@ +{ + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_kinesis_as_source_multiple_delivery_streams": { + "recorded-date": "25-01-2024, 10:54:39", + "recorded-content": { + "kinesis-event-stream-multiple-delivery-streams": { + "": { + "folder-name": "Test-message-2948294kdlsie" + }, + "": { + "folder-name": "Test-message-2948294kdlsie" + } + } + } + } +} diff --git a/tests/aws/services/firehose/test_firehose.validation.json b/tests/aws/services/firehose/test_firehose.validation.json new file mode 100644 index 0000000000000..d864164ae54ad --- /dev/null +++ b/tests/aws/services/firehose/test_firehose.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_kinesis_firehose_s3_as_destination_with_file_extension": { + "last_validated_date": "2024-05-22T18:18:59+00:00" + }, + "tests/aws/services/firehose/test_firehose.py::TestFirehoseIntegration::test_multiple_delivery_streams_with_kinesis_as_source": { + "last_validated_date": "2024-01-25T10:54:39+00:00" + } +} diff --git a/tests/aws/services/iam/__init__.py b/tests/aws/services/iam/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/iam/test_iam.py b/tests/aws/services/iam/test_iam.py new file mode 100755 index 0000000000000..9fe79e743661c --- /dev/null +++ b/tests/aws/services/iam/test_iam.py @@ -0,0 +1,1515 @@ +import functools +import json +import logging +from urllib.parse import quote_plus + +import pytest +from botocore.exceptions import ClientError + +from localstack.aws.api.iam import Tag +from localstack.services.iam.iam_patches import ADDITIONAL_MANAGED_POLICIES +from localstack.testing.aws.util import create_client_with_keys, wait_for_user +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import PATTERN_UUID +from localstack.utils.aws.arns import get_partition +from localstack.utils.common import short_uid +from localstack.utils.strings import long_uid +from localstack.utils.sync import retry + +LOG = logging.getLogger(__name__) + +GET_USER_POLICY_DOC = """{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "sgetuser", + "Effect": "Allow", + "Action": ["iam:GetUser"], + "Resource": "*" + } + ] +}""" + + +class TestIAMExtensions: + @markers.aws.validated + def test_get_user_without_username_as_user(self, create_user, aws_client, region_name): + user_name = f"user-{short_uid()}" + policy_name = f"policy={short_uid()}" + create_user(UserName=user_name) + aws_client.iam.put_user_policy( + UserName=user_name, PolicyName=policy_name, PolicyDocument=GET_USER_POLICY_DOC + ) + account_id = aws_client.sts.get_caller_identity()["Account"] + keys = aws_client.iam.create_access_key(UserName=user_name)["AccessKey"] + wait_for_user(keys, region_name) + iam_client_as_user = create_client_with_keys("iam", keys=keys, region_name=region_name) + user_response = iam_client_as_user.get_user() + user = user_response["User"] + assert user["UserName"] == user_name + assert user["Arn"] == f"arn:{get_partition(region_name)}:iam::{account_id}:user/{user_name}" + + @markers.aws.only_localstack + def test_get_user_without_username_as_root(self, aws_client): + """Test get_user on root account. Marked only localstack, since we usually cannot access as root directly""" + account_id = aws_client.sts.get_caller_identity()["Account"] + user_response = aws_client.iam.get_user() + user = user_response["User"] + assert user["UserId"] == account_id + assert user["Arn"] == f"arn:aws:iam::{account_id}:root" + + @markers.aws.validated + def test_get_user_without_username_as_role( + self, create_role, wait_and_assume_role, aws_client, region_name + ): + role_name = f"role-{short_uid()}" + policy_name = f"policy={short_uid()}" + session_name = f"session-{short_uid()}" + account_arn = aws_client.sts.get_caller_identity()["Arn"] + assume_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"AWS": account_arn}, + "Effect": "Allow", + } + ], + } + created_role_arn = create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(assume_policy_doc) + )["Role"]["Arn"] + aws_client.iam.put_role_policy( + RoleName=role_name, PolicyName=policy_name, PolicyDocument=GET_USER_POLICY_DOC + ) + keys = wait_and_assume_role(role_arn=created_role_arn, session_name=session_name) + iam_client_as_role = create_client_with_keys("iam", keys=keys, region_name=region_name) + with pytest.raises(ClientError) as e: + iam_client_as_role.get_user() + e.match("Must specify userName when calling with non-User credentials") + + @markers.aws.validated + def test_create_user_with_permission_boundary(self, create_user, create_policy, aws_client): + user_name = f"user-{short_uid()}" + policy_name = f"policy-{short_uid()}" + policy_arn = create_policy(PolicyName=policy_name, PolicyDocument=GET_USER_POLICY_DOC)[ + "Policy" + ]["Arn"] + create_user_reply = create_user(UserName=user_name, PermissionsBoundary=policy_arn) + assert "PermissionsBoundary" in create_user_reply["User"] + assert { + "PermissionsBoundaryArn": policy_arn, + "PermissionsBoundaryType": "Policy", + } == create_user_reply["User"]["PermissionsBoundary"] + get_user_reply = aws_client.iam.get_user(UserName=user_name) + assert "PermissionsBoundary" in get_user_reply["User"] + assert { + "PermissionsBoundaryArn": policy_arn, + "PermissionsBoundaryType": "Policy", + } == get_user_reply["User"]["PermissionsBoundary"] + aws_client.iam.delete_user_permissions_boundary(UserName=user_name) + get_user_reply = aws_client.iam.get_user(UserName=user_name) + assert "PermissionsBoundary" not in get_user_reply["User"] + + @markers.aws.validated + def test_create_user_add_permission_boundary_afterwards( + self, create_user, create_policy, aws_client + ): + user_name = f"user-{short_uid()}" + policy_name = f"policy-{short_uid()}" + policy_arn = create_policy(PolicyName=policy_name, PolicyDocument=GET_USER_POLICY_DOC)[ + "Policy" + ]["Arn"] + create_user_reply = create_user(UserName=user_name) + assert "PermissionsBoundary" not in create_user_reply["User"] + get_user_reply = aws_client.iam.get_user(UserName=user_name) + assert "PermissionsBoundary" not in get_user_reply["User"] + aws_client.iam.put_user_permissions_boundary( + UserName=user_name, PermissionsBoundary=policy_arn + ) + get_user_reply = aws_client.iam.get_user(UserName=user_name) + assert "PermissionsBoundary" in get_user_reply["User"] + assert { + "PermissionsBoundaryArn": policy_arn, + "PermissionsBoundaryType": "Policy", + } == get_user_reply["User"]["PermissionsBoundary"] + aws_client.iam.delete_user_permissions_boundary(UserName=user_name) + get_user_reply = aws_client.iam.get_user(UserName=user_name) + assert "PermissionsBoundary" not in get_user_reply["User"] + + @markers.aws.validated + def test_create_role_with_malformed_assume_role_policy_document(self, aws_client, snapshot): + role_name = f"role-{short_uid()}" + # The error in this document is the trailing comma after `"Effect": "Allow"` + assume_role_policy_document = """ + { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": "*", + "Effect": "Allow", + } + ] + } + """ + with pytest.raises(ClientError) as e: + aws_client.iam.create_role( + RoleName=role_name, AssumeRolePolicyDocument=assume_role_policy_document + ) + snapshot.match("invalid-json", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + def test_role_with_path_lifecycle(self, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.iam_api()) + role_name = f"role-{short_uid()}" + path = f"/path{short_uid()}/" + snapshot.add_transformer(snapshot.transform.regex(path, "")) + assume_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Effect": "Allow", + } + ], + } + + create_role_response = aws_client.iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(assume_policy_document), + Path=path, + ) + snapshot.match("create-role-response", create_role_response) + + get_role_response = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("get-role-response", get_role_response) + + delete_role_response = aws_client.iam.delete_role(RoleName=role_name) + snapshot.match("delete-role-response", delete_role_response) + + +class TestIAMIntegrations: + @markers.aws.validated + def test_attach_iam_role_to_new_iam_user( + self, aws_client, account_id, create_user, create_policy + ): + test_policy_document = { + "Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "s3:ListBucket", + "Resource": "arn:aws:s3:::example_bucket", + }, + } + test_user_name = f"test-user-{short_uid()}" + + create_user(UserName=test_user_name) + response = create_policy( + PolicyName=f"test-policy-{short_uid()}", PolicyDocument=json.dumps(test_policy_document) + ) + test_policy_arn = response["Policy"]["Arn"] + assert account_id in test_policy_arn + + aws_client.iam.attach_user_policy(UserName=test_user_name, PolicyArn=test_policy_arn) + attached_user_policies = aws_client.iam.list_attached_user_policies(UserName=test_user_name) + + assert len(attached_user_policies["AttachedPolicies"]) == 1 + assert attached_user_policies["AttachedPolicies"][0]["PolicyArn"] == test_policy_arn + + # clean up + aws_client.iam.detach_user_policy(UserName=test_user_name, PolicyArn=test_policy_arn) + aws_client.iam.delete_policy(PolicyArn=test_policy_arn) + aws_client.iam.delete_user(UserName=test_user_name) + + with pytest.raises(ClientError) as ctx: + aws_client.iam.get_user(UserName=test_user_name) + assert ctx.typename == "NoSuchEntityException" + assert ctx.value.response["Error"]["Code"] == "NoSuchEntity" + + @markers.aws.validated + def test_delete_non_existent_policy_returns_no_such_entity( + self, aws_client, snapshot, account_id + ): + non_existent_policy_arn = f"arn:aws:iam::{account_id}:policy/non-existent-policy" + + with pytest.raises(ClientError) as e: + aws_client.iam.delete_policy(PolicyArn=non_existent_policy_arn) + snapshot.match("delete-non-existent-policy-exc", e.value.response) + + @markers.aws.validated + def test_recreate_iam_role(self, aws_client, create_role): + role_name = f"role-{short_uid()}" + + assume_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Effect": "Allow", + } + ], + } + + rs = create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(assume_policy_document), + ) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + + try: + # Create role with same name + aws_client.iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(assume_policy_document), + ) + pytest.fail("This call should not be successful as the role already exists") + + except ClientError as e: + assert e.response["Error"]["Code"] == "EntityAlreadyExists" + + @markers.aws.validated + def test_instance_profile_tags(self, aws_client, cleanups): + def gen_tag(): + return Tag(Key=f"key-{long_uid()}", Value=f"value-{short_uid()}") + + def _sort_key(entry): + return entry["Key"] + + user_name = f"user-role-{short_uid()}" + aws_client.iam.create_instance_profile(InstanceProfileName=user_name) + cleanups.append( + lambda: aws_client.iam.delete_instance_profile(InstanceProfileName=user_name) + ) + + tags_v0 = [] + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == tags_v0.sort(key=_sort_key) + + tags_v1 = [gen_tag()] + # + rs = aws_client.iam.tag_instance_profile(InstanceProfileName=user_name, Tags=tags_v1) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == tags_v1.sort(key=_sort_key) + + tags_v2_new = [gen_tag() for _ in range(5)] + tags_v2 = tags_v1 + tags_v2_new + rs = aws_client.iam.tag_instance_profile(InstanceProfileName=user_name, Tags=tags_v2) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == tags_v2.sort(key=_sort_key) + + rs = aws_client.iam.tag_instance_profile(InstanceProfileName=user_name, Tags=tags_v2) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == tags_v2.sort(key=_sort_key) + + tags_v3_new = [gen_tag()] + tags_v3 = tags_v1 + tags_v3_new + target_tags_v3 = tags_v2 + tags_v3_new + rs = aws_client.iam.tag_instance_profile(InstanceProfileName=user_name, Tags=tags_v3) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == target_tags_v3.sort(key=_sort_key) + + tags_v4 = tags_v1 + target_tags_v4 = target_tags_v3 + rs = aws_client.iam.tag_instance_profile(InstanceProfileName=user_name, Tags=tags_v4) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == target_tags_v4.sort(key=_sort_key) + + tags_u_v1 = [tag["Key"] for tag in tags_v1] + target_tags_u_v1 = tags_v2_new + tags_v3_new + aws_client.iam.untag_instance_profile(InstanceProfileName=user_name, TagKeys=tags_u_v1) + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == target_tags_u_v1.sort(key=_sort_key) + + tags_u_v2 = [f"key-{long_uid()}"] + target_tags_u_v2 = target_tags_u_v1 + aws_client.iam.untag_instance_profile(InstanceProfileName=user_name, TagKeys=tags_u_v2) + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == target_tags_u_v2.sort(key=_sort_key) + + tags_u_v3 = [tag["Key"] for tag in target_tags_u_v1] + target_tags_u_v3 = [] + aws_client.iam.untag_instance_profile(InstanceProfileName=user_name, TagKeys=tags_u_v3) + # + rs = aws_client.iam.list_instance_profile_tags(InstanceProfileName=user_name) + assert rs["Tags"].sort(key=_sort_key) == target_tags_u_v3.sort(key=_sort_key) + + @markers.aws.validated + def test_create_user_with_tags(self, aws_client): + user_name = f"user-role-{short_uid()}" + + rs = aws_client.iam.create_user( + UserName=user_name, Tags=[{"Key": "env", "Value": "production"}] + ) + + assert "Tags" in rs["User"] + assert rs["User"]["Tags"][0]["Key"] == "env" + + rs = aws_client.iam.get_user(UserName=user_name) + + assert "Tags" in rs["User"] + assert rs["User"]["Tags"][0]["Value"] == "production" + + # clean up + aws_client.iam.delete_user(UserName=user_name) + + @markers.aws.validated + def test_attach_detach_role_policy(self, aws_client, region_name): + role_name = f"s3-role-{short_uid()}" + policy_name = f"s3-role-policy-{short_uid()}" + + policy_arns = [p["Arn"] for p in ADDITIONAL_MANAGED_POLICIES.values()] + policy_arns = [ + arn.replace("arn:aws:", f"arn:{get_partition(region_name)}:") for arn in policy_arns + ] + + assume_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"Service": "s3.amazonaws.com"}, + "Effect": "Allow", + } + ], + } + + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetReplicationConfiguration", + "s3:GetObjectVersion", + "s3:ListBucket", + ], + "Effect": "Allow", + "Resource": [f"arn:{get_partition(region_name)}:s3:::bucket_name"], + } + ], + } + + aws_client.iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(assume_policy_document), + ) + + policy_arn = aws_client.iam.create_policy( + PolicyName=policy_name, Path="/", PolicyDocument=json.dumps(policy_document) + )["Policy"]["Arn"] + policy_arns.append(policy_arn) + + # Attach some polices + for policy_arn in policy_arns: + rs = aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + + try: + # Try to delete role + aws_client.iam.delete_role(RoleName=role_name) + pytest.fail("This call should not be successful as the role has policies attached") + + except ClientError as e: + assert e.response["Error"]["Code"] == "DeleteConflict" + + for policy_arn in policy_arns: + rs = aws_client.iam.detach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # clean up + rs = aws_client.iam.delete_role(RoleName=role_name) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + + aws_client.iam.delete_policy(PolicyArn=policy_arn) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..EvaluationResults"]) + @pytest.mark.parametrize("arn_type", ["role", "group", "user"]) + def test_simulate_principle_policy( + self, + arn_type, + aws_client, + create_role, + create_policy, + create_user, + s3_bucket, + snapshot, + cleanups, + ): + bucket = s3_bucket + snapshot.add_transformer(snapshot.transform.regex(bucket, "bucket")) + snapshot.add_transformer(snapshot.transform.key_value("SourcePolicyId")) + + policy_arn = create_policy( + PolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": { + "Sid": "", + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "*", + }, + } + ) + )["Policy"]["Arn"] + + if arn_type == "role": + role_name = f"role-{short_uid()}" + role_arn = create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": {"Service": "apigateway.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + } + ), + )["Role"]["Arn"] + aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + arn = role_arn + + elif arn_type == "group": + group_name = f"group-{short_uid()}" + group = aws_client.iam.create_group(GroupName=group_name)["Group"] + cleanups.append(lambda: aws_client.iam.delete_group(GroupName=group_name)) + aws_client.iam.attach_group_policy(GroupName=group_name, PolicyArn=policy_arn) + arn = group["Arn"] + + else: + user_name = f"user-{short_uid()}" + user = create_user(UserName=user_name)["User"] + aws_client.iam.attach_user_policy(UserName=user_name, PolicyArn=policy_arn) + arn = user["Arn"] + + rs = aws_client.iam.simulate_principal_policy( + PolicySourceArn=arn, + ActionNames=["s3:PutObject", "s3:GetObjectVersion"], + ResourceArns=[f"arn:aws:s3:::{bucket}"], + ) + + snapshot.match("response", rs) + + @markers.aws.validated + def test_create_role_with_assume_role_policy(self, aws_client, account_id, create_role): + role_name_1 = f"role-{short_uid()}" + role_name_2 = f"role-{short_uid()}" + + assume_role_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + } + ], + } + str_assume_role_policy_doc = json.dumps(assume_role_policy_doc) + + create_role( + Path="/", + RoleName=role_name_1, + AssumeRolePolicyDocument=str_assume_role_policy_doc, + ) + + roles = aws_client.iam.list_roles()["Roles"] + for role in roles: + if role["RoleName"] == role_name_1: + assert role["AssumeRolePolicyDocument"] == assume_role_policy_doc + + create_role( + Path="/", + RoleName=role_name_2, + AssumeRolePolicyDocument=str_assume_role_policy_doc, + Description="string", + ) + + roles = aws_client.iam.list_roles()["Roles"] + for role in roles: + if role["RoleName"] in [role_name_1, role_name_2]: + assert role["AssumeRolePolicyDocument"] == assume_role_policy_doc + aws_client.iam.delete_role(RoleName=role["RoleName"]) + + create_role( + Path="/myPath/", + RoleName=role_name_2, + AssumeRolePolicyDocument=str_assume_role_policy_doc, + Description="string", + ) + + roles = aws_client.iam.list_roles(PathPrefix="/my") + assert len(roles["Roles"]) == 1 + assert roles["Roles"][0]["Path"] == "/myPath/" + assert roles["Roles"][0]["RoleName"] == role_name_2 + + @markers.aws.validated + @pytest.mark.skip + @pytest.mark.parametrize( + "service_name, expected_role", + [ + ("ecs.amazonaws.com", "AWSServiceRoleForECS"), + ("eks.amazonaws.com", "AWSServiceRoleForAmazonEKS"), + ], + ) + def test_service_linked_role_name_should_match_aws( + self, service_name, expected_role, aws_client + ): + role_name = None + try: + service_linked_role = aws_client.iam.create_service_linked_role( + AWSServiceName=service_name + ) + role_name = service_linked_role["Role"]["RoleName"] + assert role_name == expected_role + finally: + if role_name: + aws_client.iam.delete_service_linked_role(RoleName=role_name) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + def test_update_assume_role_policy(self, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.iam_api()) + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": ["ec2.amazonaws.com"]}, + "Action": ["sts:AssumeRole"], + } + ], + } + + role_name = f"role-{short_uid()}" + result = aws_client.iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(policy), + ) + snapshot.match("created_role", result) + try: + result = aws_client.iam.update_assume_role_policy( + RoleName=role_name, + PolicyDocument=json.dumps(policy), + ) + snapshot.match("updated_policy", result) + finally: + aws_client.iam.delete_role(RoleName=role_name) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + def test_create_describe_role(self, snapshot, aws_client, create_role, cleanups): + snapshot.add_transformer(snapshot.transform.iam_api()) + path_prefix = f"/{short_uid()}/" + snapshot.add_transformer(snapshot.transform.regex(path_prefix, "//")) + + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + role_name = f"role-{short_uid()}" + create_role_result = create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Path=path_prefix + ) + snapshot.match("create_role_result", create_role_result) + get_role_result = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("get_role_result", get_role_result) + + list_roles_result = aws_client.iam.list_roles(PathPrefix=path_prefix) + snapshot.match("list_roles_result", list_roles_result) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + def test_list_roles_with_permission_boundary( + self, snapshot, aws_client, create_role, create_policy, cleanups + ): + snapshot.add_transformer(snapshot.transform.iam_api()) + path_prefix = f"/{short_uid()}/" + snapshot.add_transformer(snapshot.transform.regex(path_prefix, "//")) + + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + permission_boundary = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["lambda:ListFunctions"], "Resource": ["*"]} + ], + } + + role_name = f"role-{short_uid()}" + policy_name = f"policy-{short_uid()}" + result = create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Path=path_prefix + ) + snapshot.match("created_role", result) + policy_arn = create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(permission_boundary) + )["Policy"]["Arn"] + + aws_client.iam.put_role_permissions_boundary( + RoleName=role_name, PermissionsBoundary=policy_arn + ) + cleanups.append(lambda: aws_client.iam.delete_role_permissions_boundary(RoleName=role_name)) + + list_roles_result = aws_client.iam.list_roles(PathPrefix=path_prefix) + snapshot.match("list_roles_result", list_roles_result) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Policy.IsAttachable", + "$..Policy.PermissionsBoundaryUsageCount", + "$..Policy.Tags", + "$..Policy.Description", + ] + ) + def test_role_attach_policy(self, snapshot, aws_client, create_role, create_policy): + snapshot.add_transformer(snapshot.transform.iam_api()) + + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + policy_document = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["lambda:ListFunctions"], "Resource": ["*"]} + ], + } + + role_name = f"test-role-{short_uid()}" + policy_name = f"test-policy-{short_uid()}" + create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy)) + create_policy_response = create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(policy_document) + ) + snapshot.match("create_policy_response", create_policy_response) + policy_arn = create_policy_response["Policy"]["Arn"] + + with pytest.raises(ClientError) as e: + aws_client.iam.attach_role_policy( + RoleName=role_name, PolicyArn="longpolicynamebutnoarn" + ) + snapshot.match("non_existent_malformed_policy_arn", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_name) + snapshot.match("existing_policy_name_provided", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=f"{policy_arn}123") + snapshot.match("valid_arn_not_existent", e.value.response) + + attach_policy_response = aws_client.iam.attach_role_policy( + RoleName=role_name, PolicyArn=policy_arn + ) + snapshot.match("valid_policy_arn", attach_policy_response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Policy.IsAttachable", + "$..Policy.PermissionsBoundaryUsageCount", + "$..Policy.Tags", + "$..Policy.Description", + ] + ) + def test_user_attach_policy(self, snapshot, aws_client, create_user, create_policy): + snapshot.add_transformer(snapshot.transform.iam_api()) + + policy_document = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["lambda:ListFunctions"], "Resource": ["*"]} + ], + } + + user_name = f"test-role-{short_uid()}" + policy_name = f"test-policy-{short_uid()}" + create_user(UserName=user_name) + create_policy_response = create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(policy_document) + ) + snapshot.match("create_policy_response", create_policy_response) + policy_arn = create_policy_response["Policy"]["Arn"] + + with pytest.raises(ClientError) as e: + aws_client.iam.attach_user_policy( + UserName=user_name, PolicyArn="longpolicynamebutnoarn" + ) + snapshot.match("non_existent_malformed_policy_arn", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.iam.attach_user_policy(UserName=user_name, PolicyArn=policy_name) + snapshot.match("existing_policy_name_provided", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.iam.attach_user_policy(UserName=user_name, PolicyArn=f"{policy_arn}123") + snapshot.match("valid_arn_not_existent", e.value.response) + + attach_policy_response = aws_client.iam.attach_user_policy( + UserName=user_name, PolicyArn=policy_arn + ) + snapshot.match("valid_policy_arn", attach_policy_response) + + +class TestIAMPolicyEncoding: + @markers.aws.validated + def test_put_user_policy_encoding(self, snapshot, aws_client, create_user, region_name): + snapshot.add_transformer(snapshot.transform.iam_api()) + + target_arn = quote_plus(f"arn:aws:apigateway:{region_name}::/restapis/aaeeieije") + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["apigatway:PUT"], + "Resource": [f"arn:aws:apigateway:{region_name}::/tags/{target_arn}"], + } + ], + } + + user_name = f"test-user-{short_uid()}" + policy_name = f"test-policy-{short_uid()}" + create_user(UserName=user_name) + + aws_client.iam.put_user_policy( + UserName=user_name, PolicyName=policy_name, PolicyDocument=json.dumps(policy_document) + ) + get_policy_response = aws_client.iam.get_user_policy( + UserName=user_name, PolicyName=policy_name + ) + snapshot.match("get-policy-response", get_policy_response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + def test_put_role_policy_encoding(self, snapshot, aws_client, create_role, region_name): + snapshot.add_transformer(snapshot.transform.iam_api()) + + target_arn = quote_plus(f"arn:aws:apigateway:{region_name}::/restapis/aaeeieije") + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["apigatway:PUT"], + "Resource": [f"arn:aws:apigateway:{region_name}::/tags/{target_arn}"], + } + ], + } + assume_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Effect": "Allow", + "Condition": {"StringEquals": {"aws:SourceArn": target_arn}}, + } + ], + } + + role_name = f"test-role-{short_uid()}" + policy_name = f"test-policy-{short_uid()}" + path = f"/{short_uid()}/" + snapshot.add_transformer(snapshot.transform.key_value("Path")) + create_role_response = create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(assume_policy_document), + Path=path, + ) + snapshot.match("create-role-response", create_role_response) + + aws_client.iam.put_role_policy( + RoleName=role_name, PolicyName=policy_name, PolicyDocument=json.dumps(policy_document) + ) + get_policy_response = aws_client.iam.get_role_policy( + RoleName=role_name, PolicyName=policy_name + ) + snapshot.match("get-policy-response", get_policy_response) + + get_role_response = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("get-role-response", get_role_response) + + list_roles_response = aws_client.iam.list_roles(PathPrefix=path) + snapshot.match("list-roles-response", list_roles_response) + + @markers.aws.validated + def test_put_group_policy_encoding(self, snapshot, aws_client, region_name, cleanups): + snapshot.add_transformer(snapshot.transform.iam_api()) + + # create quoted target arn + target_arn = quote_plus(f"arn:aws:apigateway:{region_name}::/restapis/aaeeieije") + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["apigatway:PUT"], + "Resource": [f"arn:aws:apigateway:{region_name}::/tags/{target_arn}"], + } + ], + } + + group_name = f"test-group-{short_uid()}" + policy_name = f"test-policy-{short_uid()}" + aws_client.iam.create_group(GroupName=group_name) + cleanups.append(lambda: aws_client.iam.delete_group(GroupName=group_name)) + + aws_client.iam.put_group_policy( + GroupName=group_name, PolicyName=policy_name, PolicyDocument=json.dumps(policy_document) + ) + cleanups.append( + lambda: aws_client.iam.delete_group_policy(GroupName=group_name, PolicyName=policy_name) + ) + + get_policy_response = aws_client.iam.get_group_policy( + GroupName=group_name, PolicyName=policy_name + ) + snapshot.match("get-policy-response", get_policy_response) + + +class TestIAMServiceSpecificCredentials: + @pytest.fixture(autouse=True) + def register_snapshot_transformers(self, snapshot): + snapshot.add_transformer(snapshot.transform.iam_api()) + snapshot.add_transformer(snapshot.transform.key_value("ServicePassword")) + snapshot.add_transformer(snapshot.transform.key_value("ServiceSpecificCredentialId")) + + @pytest.fixture + def create_service_specific_credential(self, aws_client): + username_id_pairs = [] + + def _create_service_specific_credential(*args, **kwargs): + response = aws_client.iam.create_service_specific_credential(*args, **kwargs) + username_id_pairs.append( + ( + response["ServiceSpecificCredential"]["ServiceSpecificCredentialId"], + response["ServiceSpecificCredential"]["UserName"], + ) + ) + return response + + yield _create_service_specific_credential + + for credential_id, user_name in username_id_pairs: + try: + aws_client.iam.delete_service_specific_credential( + ServiceSpecificCredentialId=credential_id, UserName=user_name + ) + except Exception: + LOG.debug( + "Unable to delete service specific credential '%s' for user name '%s'", + credential_id, + user_name, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "service_name", ["codecommit.amazonaws.com", "cassandra.amazonaws.com"] + ) + def test_service_specific_credential_lifecycle( + self, aws_client, create_user, snapshot, service_name + ): + """Test the lifecycle of service specific credentials.""" + user_name = f"user-{short_uid()}" + create_user_response = create_user(UserName=user_name) + snapshot.match("create-user-response", create_user_response) + + # create + create_service_specific_credential_response = ( + aws_client.iam.create_service_specific_credential( + UserName=user_name, ServiceName=service_name + ) + ) + snapshot.match( + "create-service-specific-credential-response", + create_service_specific_credential_response, + ) + credential_id = create_service_specific_credential_response["ServiceSpecificCredential"][ + "ServiceSpecificCredentialId" + ] + + # list + list_service_specific_credentials_response = ( + aws_client.iam.list_service_specific_credentials( + UserName=user_name, ServiceName=service_name + ) + ) + snapshot.match( + "list-service-specific-credentials-response-before-update", + list_service_specific_credentials_response, + ) + + # update + update_service_specific_credential_response = ( + aws_client.iam.update_service_specific_credential( + UserName=user_name, ServiceSpecificCredentialId=credential_id, Status="Inactive" + ) + ) + snapshot.match( + "update-service-specific-credential-response", + update_service_specific_credential_response, + ) + + # list after update + list_service_specific_credentials_response = ( + aws_client.iam.list_service_specific_credentials( + UserName=user_name, ServiceName=service_name + ) + ) + snapshot.match( + "list-service-specific-credentials-response-after-update", + list_service_specific_credentials_response, + ) + + # reset + reset_service_specific_credential_response = ( + aws_client.iam.reset_service_specific_credential( + UserName=user_name, ServiceSpecificCredentialId=credential_id + ) + ) + snapshot.match( + "reset-service-specific-credential-response", reset_service_specific_credential_response + ) + + # delete + delete_service_specific_credential_response = ( + aws_client.iam.delete_service_specific_credential( + ServiceSpecificCredentialId=credential_id, UserName=user_name + ) + ) + snapshot.match( + "delete-service-specific-credentials-response", + delete_service_specific_credential_response, + ) + + @markers.aws.validated + def test_create_service_specific_credential_invalid_user(self, aws_client, snapshot): + """Use invalid users for the create operation""" + user_name = "non-existent-user" + with pytest.raises(ClientError) as e: + aws_client.iam.create_service_specific_credential( + UserName=user_name, ServiceName="codecommit.amazonaws.com" + ) + snapshot.match("invalid-user-name-exception", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.iam.create_service_specific_credential( + UserName=user_name, ServiceName="nonexistentservice.amazonaws.com" + ) + snapshot.match("invalid-user-and-service-exception", e.value.response) + + @markers.aws.validated + def test_create_service_specific_credential_invalid_service( + self, aws_client, create_user, snapshot + ): + """Test different scenarios of invalid service names passed to the create operation""" + user_name = f"user-{short_uid()}" + create_user_response = create_user(UserName=user_name) + snapshot.match("create-user-response", create_user_response) + + # a bogus service which does not exist on AWS + with pytest.raises(ClientError) as e: + aws_client.iam.create_service_specific_credential( + UserName=user_name, ServiceName="nonexistentservice.amazonaws.com" + ) + snapshot.match("invalid-service-exception", e.value.response) + + # a random string not even ending in amazonaws.com + with pytest.raises(ClientError) as e: + aws_client.iam.create_service_specific_credential( + UserName=user_name, ServiceName="o3on3n3onosneo" + ) + snapshot.match("invalid-service-completely-malformed-exception", e.value.response) + + # existing service, which is not supported by service specific credentials + with pytest.raises(ClientError) as e: + aws_client.iam.create_service_specific_credential( + UserName=user_name, ServiceName="lambda.amazonaws.com" + ) + snapshot.match("invalid-service-existing-but-unsupported-exception", e.value.response) + + @markers.aws.validated + def test_list_service_specific_credential_different_service( + self, aws_client, create_user, snapshot, create_service_specific_credential + ): + """Test different scenarios of invalid or wrong service names passed to the list operation""" + user_name = f"user-{short_uid()}" + create_user_response = create_user(UserName=user_name) + snapshot.match("create-user-response", create_user_response) + + with pytest.raises(ClientError) as e: + aws_client.iam.list_service_specific_credentials( + UserName=user_name, ServiceName="nonexistentservice.amazonaws.com" + ) + snapshot.match("list-service-specific-credentials-invalid-service", e.value.response) + + # Create a proper credential for codecommit + create_service_specific_credential_response = ( + aws_client.iam.create_service_specific_credential( + UserName=user_name, ServiceName="codecommit.amazonaws.com" + ) + ) + snapshot.match( + "create-service-specific-credential-response", + create_service_specific_credential_response, + ) + + # List credentials for cassandra + list_service_specific_credentials_response = ( + aws_client.iam.list_service_specific_credentials( + UserName=user_name, ServiceName="cassandra.amazonaws.com" + ) + ) + snapshot.match( + "list-service-specific-credentials-response-wrong-service", + list_service_specific_credentials_response, + ) + + @markers.aws.validated + def test_delete_user_after_service_credential_created( + self, aws_client, create_user, snapshot, create_service_specific_credential + ): + """Try deleting a user with active service credentials""" + user_name = f"user-{short_uid()}" + create_user_response = create_user(UserName=user_name) + snapshot.match("create-user-response", create_user_response) + + # Create a credential + create_service_specific_credential_response = create_service_specific_credential( + UserName=user_name, ServiceName="codecommit.amazonaws.com" + ) + snapshot.match( + "create-service-specific-credential-response", + create_service_specific_credential_response, + ) + + # delete user + with pytest.raises(ClientError) as e: + aws_client.iam.delete_user(UserName=user_name) + snapshot.match("delete-user-existing-credential", e.value.response) + + @markers.aws.validated + def test_id_match_user_mismatch( + self, aws_client, create_user, snapshot, create_service_specific_credential + ): + """Test operations with valid ids, but invalid users""" + user_name = f"user-{short_uid()}" + wrong_user_name = "wrong-user-name" + create_user_response = create_user(UserName=user_name) + snapshot.match("create-user-response", create_user_response) + + create_service_specific_credential_response = create_service_specific_credential( + UserName=user_name, ServiceName="codecommit.amazonaws.com" + ) + snapshot.match( + "create-service-specific-credential-response", + create_service_specific_credential_response, + ) + credential_id = create_service_specific_credential_response["ServiceSpecificCredential"][ + "ServiceSpecificCredentialId" + ] + + # update + with pytest.raises(ClientError) as e: + aws_client.iam.update_service_specific_credential( + UserName=wrong_user_name, + ServiceSpecificCredentialId=credential_id, + Status="Inactive", + ) + snapshot.match("update-wrong-user-name", e.value.response) + + # reset + with pytest.raises(ClientError) as e: + aws_client.iam.reset_service_specific_credential( + UserName=wrong_user_name, ServiceSpecificCredentialId=credential_id + ) + snapshot.match("reset-wrong-user-name", e.value.response) + + # delete + with pytest.raises(ClientError) as e: + aws_client.iam.delete_service_specific_credential( + UserName=wrong_user_name, ServiceSpecificCredentialId=credential_id + ) + snapshot.match("delete-wrong-user-name", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "wrong_credential_id", + ["totally-wrong-credential-id-with-hyphens", "satisfiesregexbutstillinvalid"], + ) + def test_user_match_id_mismatch( + self, + aws_client, + create_user, + snapshot, + create_service_specific_credential, + wrong_credential_id, + ): + """Test operations with valid usernames, but invalid ids""" + user_name = f"user-{short_uid()}" + create_user_response = create_user(UserName=user_name) + snapshot.match("create-user-response", create_user_response) + + create_service_specific_credential_response = create_service_specific_credential( + UserName=user_name, ServiceName="codecommit.amazonaws.com" + ) + snapshot.match( + "create-service-specific-credential-response", + create_service_specific_credential_response, + ) + + # update + with pytest.raises(ClientError) as e: + aws_client.iam.update_service_specific_credential( + UserName=user_name, + ServiceSpecificCredentialId=wrong_credential_id, + Status="Inactive", + ) + snapshot.match("update-wrong-id", e.value.response) + + # reset + with pytest.raises(ClientError) as e: + aws_client.iam.reset_service_specific_credential( + UserName=user_name, ServiceSpecificCredentialId=wrong_credential_id + ) + snapshot.match("reset-wrong-id", e.value.response) + + # delete + with pytest.raises(ClientError) as e: + aws_client.iam.delete_service_specific_credential( + UserName=user_name, ServiceSpecificCredentialId=wrong_credential_id + ) + snapshot.match("delete-wrong-id", e.value.response) + + @markers.aws.validated + def test_invalid_update_parameters( + self, aws_client, create_user, snapshot, create_service_specific_credential + ): + """Try updating a service specific credential with invalid values""" + user_name = f"user-{short_uid()}" + create_user_response = create_user(UserName=user_name) + snapshot.match("create-user-response", create_user_response) + + create_service_specific_credential_response = create_service_specific_credential( + UserName=user_name, ServiceName="codecommit.amazonaws.com" + ) + snapshot.match( + "create-service-specific-credential-response", + create_service_specific_credential_response, + ) + credential_id = create_service_specific_credential_response["ServiceSpecificCredential"][ + "ServiceSpecificCredentialId" + ] + + with pytest.raises(ClientError) as e: + aws_client.iam.update_service_specific_credential( + ServiceSpecificCredentialId=credential_id, Status="Invalid" + ) + snapshot.match("update-invalid-status", e.value.response) + + +class TestIAMServiceRoles: + SERVICES = { + "accountdiscovery.ssm.amazonaws.com": (), + "acm.amazonaws.com": (), + "appmesh.amazonaws.com": (), + "autoscaling-plans.amazonaws.com": (), + "autoscaling.amazonaws.com": (), + "backup.amazonaws.com": (), + "batch.amazonaws.com": (), + "cassandra.application-autoscaling.amazonaws.com": (), + "cks.kms.amazonaws.com": (), + "cloudtrail.amazonaws.com": (), + "codestar-notifications.amazonaws.com": (), + "config.amazonaws.com": (), + "connect.amazonaws.com": (), + "dms-fleet-advisor.amazonaws.com": (), + "dms.amazonaws.com": (), + "docdb-elastic.amazonaws.com": (), + "ec2-instance-connect.amazonaws.com": (), + "ec2.application-autoscaling.amazonaws.com": (), + "ecr.amazonaws.com": (), + "ecs.amazonaws.com": (), + "eks-connector.amazonaws.com": (), + "eks-fargate.amazonaws.com": (), + "eks-nodegroup.amazonaws.com": (), + "eks.amazonaws.com": (), + "elasticache.amazonaws.com": (), + "elasticbeanstalk.amazonaws.com": (), + "elasticfilesystem.amazonaws.com": (), + "elasticloadbalancing.amazonaws.com": (), + "email.cognito-idp.amazonaws.com": (), + "emr-containers.amazonaws.com": (), + "emrwal.amazonaws.com": (), + "fis.amazonaws.com": (), + "grafana.amazonaws.com": (), + "imagebuilder.amazonaws.com": (), + "iotmanagedintegrations.amazonaws.com": ( + markers.snapshot.skip_snapshot_verify(paths=["$..AttachedPolicies"]) + ), # TODO include aws managed policy in the future + "kafka.amazonaws.com": (), + "kafkaconnect.amazonaws.com": (), + "lakeformation.amazonaws.com": (), + "lex.amazonaws.com": ( + markers.snapshot.skip_snapshot_verify(paths=["$..AttachedPolicies"]) + ), # TODO include aws managed policy in the future + "lexv2.amazonaws.com": (), + "lightsail.amazonaws.com": (), + # "logs.amazonaws.com": (), # not possible to create on AWS + "m2.amazonaws.com": (), + "memorydb.amazonaws.com": (), + "mq.amazonaws.com": (), + "mrk.kms.amazonaws.com": (), + "notifications.amazonaws.com": (), + "observability.aoss.amazonaws.com": (), + "opensearchservice.amazonaws.com": (), + "ops.apigateway.amazonaws.com": (), + "ops.emr-serverless.amazonaws.com": (), + "opsdatasync.ssm.amazonaws.com": (), + "opsinsights.ssm.amazonaws.com": (), + "pullthroughcache.ecr.amazonaws.com": (), + "ram.amazonaws.com": (), + "rds.amazonaws.com": (), + "redshift.amazonaws.com": (), + "replication.cassandra.amazonaws.com": (), + "replication.ecr.amazonaws.com": (), + "repository.sync.codeconnections.amazonaws.com": (), + "resource-explorer-2.amazonaws.com": (), + # "resourcegroups.amazonaws.com": (), # not possible to create on AWS + "rolesanywhere.amazonaws.com": (), + "s3-outposts.amazonaws.com": (), + "ses.amazonaws.com": (), + "shield.amazonaws.com": (), + "ssm-incidents.amazonaws.com": (), + "ssm-quicksetup.amazonaws.com": (), + "ssm.amazonaws.com": (), + "sso.amazonaws.com": (), + "vpcorigin.cloudfront.amazonaws.com": (), + "waf.amazonaws.com": (), + "wafv2.amazonaws.com": (), + } + + SERVICES_CUSTOM_SUFFIX = [ + "autoscaling.amazonaws.com", + "connect.amazonaws.com", + "lexv2.amazonaws.com", + ] + + @pytest.fixture + def create_service_linked_role(self, aws_client): + role_names = [] + + @functools.wraps(aws_client.iam.create_service_linked_role) + def _create_service_linked_role(*args, **kwargs): + response = aws_client.iam.create_service_linked_role(*args, **kwargs) + role_names.append(response["Role"]["RoleName"]) + return response + + yield _create_service_linked_role + for role_name in role_names: + try: + aws_client.iam.delete_service_linked_role(RoleName=role_name) + except Exception as e: + LOG.debug("Error while deleting service linked role '%s': %s", role_name, e) + + @pytest.fixture + def create_service_linked_role_if_not_exists(self, aws_client, create_service_linked_role): + """This fixture is necessary since some service linked roles cannot be deleted - so we have to snapshot the existing ones""" + + def _create_service_linked_role_if_not_exists(*args, **kwargs): + try: + return create_service_linked_role(*args, **kwargs)["Role"]["RoleName"] + except aws_client.iam.exceptions.InvalidInputException as e: + # return the role name from the error message for now, quite hacky. + return e.response["Error"]["Message"].split()[3] + + return _create_service_linked_role_if_not_exists + + @pytest.fixture(autouse=True) + def snapshot_transformers(self, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("RoleId")) + + @markers.aws.validated + # last used and the description depend on whether the role was created in the snapshot account by a service or manually + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.RoleLastUsed", "$..Role.Description", "$..Role.Tags"] + ) + @pytest.mark.parametrize( + "service_name", + [pytest.param(service, marks=marker) for service, marker in SERVICES.items()], + ) + def test_service_role_lifecycle( + self, aws_client, snapshot, create_service_linked_role_if_not_exists, service_name + ): + # some roles are already present and not deletable - so we just create them if they exist, and snapshot later + role_name = create_service_linked_role_if_not_exists(AWSServiceName=service_name) + + response = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("describe-response", response) + + response = aws_client.iam.list_role_policies(RoleName=role_name) + snapshot.match("inline-role-policies", response) + + response = aws_client.iam.list_attached_role_policies(RoleName=role_name) + snapshot.match("attached-role-policies", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + @pytest.mark.parametrize("service_name", SERVICES_CUSTOM_SUFFIX) + def test_service_role_lifecycle_custom_suffix( + self, aws_client, snapshot, create_service_linked_role, service_name + ): + """Tests services allowing custom suffixes""" + custom_suffix = short_uid() + snapshot.add_transformer(snapshot.transform.regex(custom_suffix, "")) + response = create_service_linked_role( + AWSServiceName=service_name, CustomSuffix=custom_suffix + ) + role_name = response["Role"]["RoleName"] + + response = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("describe-response", response) + + response = aws_client.iam.list_role_policies(RoleName=role_name) + snapshot.match("inline-role-policies", response) + + response = aws_client.iam.list_attached_role_policies(RoleName=role_name) + snapshot.match("attached-role-policies", response) + + @markers.aws.validated + @pytest.mark.parametrize( + "service_name", list(set(SERVICES.keys()) - set(SERVICES_CUSTOM_SUFFIX)) + ) + def test_service_role_lifecycle_custom_suffix_not_allowed( + self, aws_client, snapshot, create_service_linked_role, service_name + ): + """Test services which do not allow custom suffixes""" + suffix = "testsuffix" + with pytest.raises(ClientError) as e: + aws_client.iam.create_service_linked_role( + AWSServiceName=service_name, CustomSuffix=suffix + ) + snapshot.match("custom-suffix-not-allowed", e.value.response) + + @markers.aws.validated + def test_service_role_deletion(self, aws_client, snapshot, create_service_linked_role): + """Testing deletion only with one service name to avoid undeletable service linked roles in developer accounts""" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + service_name = "batch.amazonaws.com" + role_name = create_service_linked_role(AWSServiceName=service_name)["Role"]["RoleName"] + + response = aws_client.iam.delete_service_linked_role(RoleName=role_name) + snapshot.match("service-linked-role-deletion-response", response) + deletion_task_id = response["DeletionTaskId"] + + def wait_role_deleted(): + response = aws_client.iam.get_service_linked_role_deletion_status( + DeletionTaskId=deletion_task_id + ) + assert response["Status"] == "SUCCEEDED" + return response + + response = retry(wait_role_deleted, retries=10, sleep=1) + snapshot.match("service-linked-role-deletion-status-response", response) + + @markers.aws.validated + def test_service_role_already_exists(self, aws_client, snapshot, create_service_linked_role): + service_name = "batch.amazonaws.com" + create_service_linked_role(AWSServiceName=service_name) + + with pytest.raises(ClientError) as e: + aws_client.iam.create_service_linked_role(AWSServiceName=service_name) + snapshot.match("role-already-exists-error", e.value.response) + + +class TestRoles: + @pytest.fixture(autouse=True) + def snapshot_transformers(self, snapshot): + snapshot.add_transformer(snapshot.transform.iam_api()) + + @markers.aws.validated + def test_role_with_tags(self, aws_client, account_id, create_role, snapshot): + role_name = f"role-{short_uid()}" + path = "/role-with-tags/" + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + tags = [{"Key": "test", "Value": "value"}] + + create_role_response = create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(trust_policy), + Tags=tags, + Path=path, + ) + snapshot.match("create-role-response", create_role_response) + + get_role_response = aws_client.iam.get_role(RoleName=role_name) + snapshot.match("get-role-response", get_role_response) + + list_role_response = aws_client.iam.list_roles(PathPrefix=path) + snapshot.match("list-role-response", list_role_response) diff --git a/tests/aws/services/iam/test_iam.snapshot.json b/tests/aws/services/iam/test_iam.snapshot.json new file mode 100644 index 0000000000000..80eba4371a21a --- /dev/null +++ b/tests/aws/services/iam/test_iam.snapshot.json @@ -0,0 +1,6577 @@ +{ + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_update_assume_role_policy": { + "recorded-date": "06-03-2025, 12:24:58", + "recorded-content": { + "created_role": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "ec2.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "/", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_create_role_with_malformed_assume_role_policy_document": { + "recorded-date": "06-03-2025, 12:24:44", + "recorded-content": { + "invalid-json": { + "Error": { + "Code": "MalformedPolicyDocument", + "Message": "This policy contains invalid Json", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_list_roles_with_permission_boundary": { + "recorded-date": "06-03-2025, 12:25:01", + "recorded-content": { + "created_role": { + "Role": { + "Arn": "arn::iam::111111111111:role//", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "//", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_roles_result": { + "IsTruncated": false, + "Roles": [ + { + "Arn": "arn::iam::111111111111:role//", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "//", + "RoleId": "", + "RoleName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_create_describe_role": { + "recorded-date": "06-03-2025, 12:24:59", + "recorded-content": { + "create_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role//", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "//", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_role_result": { + "Role": { + "Arn": "arn::iam::111111111111:role//", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "//", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_roles_result": { + "IsTruncated": false, + "Roles": [ + { + "Arn": "arn::iam::111111111111:role//", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "//", + "RoleId": "", + "RoleName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_role_attach_policy": { + "recorded-date": "19-06-2025, 11:47:40", + "recorded-content": { + "create_policy_response": { + "Policy": { + "Arn": "arn::iam::111111111111:policy/", + "AttachmentCount": 0, + "CreateDate": "", + "DefaultVersionId": "v1", + "IsAttachable": true, + "Path": "/", + "PermissionsBoundaryUsageCount": 0, + "PolicyId": "", + "PolicyName": "", + "UpdateDate": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "non_existent_malformed_policy_arn": { + "Error": { + "Code": "ValidationError", + "Message": "Invalid ARN: Could not be parsed!", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "existing_policy_name_provided": { + "Error": { + "Code": "ValidationError", + "Message": "Invalid ARN: Could not be parsed!", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "valid_arn_not_existent": { + "Error": { + "Code": "NoSuchEntity", + "Message": "Policy arn::iam::111111111111:policy/123 does not exist or is not attachable.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "valid_policy_arn": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_user_attach_policy": { + "recorded-date": "06-03-2025, 12:25:06", + "recorded-content": { + "create_policy_response": { + "Policy": { + "Arn": "arn::iam::111111111111:policy/", + "AttachmentCount": 0, + "CreateDate": "", + "DefaultVersionId": "v1", + "IsAttachable": true, + "Path": "/", + "PermissionsBoundaryUsageCount": 0, + "PolicyId": "", + "PolicyName": "", + "UpdateDate": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "non_existent_malformed_policy_arn": { + "Error": { + "Code": "ValidationError", + "Message": "Invalid ARN: Could not be parsed!", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "existing_policy_name_provided": { + "Error": { + "Code": "ValidationError", + "Message": "Invalid ARN: Could not be parsed!", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "valid_arn_not_existent": { + "Error": { + "Code": "NoSuchEntity", + "Message": "Policy arn::iam::111111111111:policy/123 does not exist or is not attachable.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "valid_policy_arn": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_role_with_path_lifecycle": { + "recorded-date": "19-06-2025, 11:39:59", + "recorded-content": { + "create-role-response": { + "Role": { + "Arn": "arn::iam::111111111111:role", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-role-response": { + "Role": { + "Arn": "arn::iam::111111111111:role", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-role-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_user_policy_encoding": { + "recorded-date": "06-03-2025, 12:25:08", + "recorded-content": { + "get-policy-response": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "apigatway:PUT" + ], + "Effect": "Allow", + "Resource": [ + "arn::apigateway:::/tags/arn%3Aaws%3Aapigateway%3A%3A%3A%2Frestapis%2Faaeeieije" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "", + "UserName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_role_policy_encoding": { + "recorded-date": "06-03-2025, 12:25:09", + "recorded-content": { + "create-role-response": { + "Role": { + "Arn": "arn::iam::111111111111:role", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceArn": "arn%3Aaws%3Aapigateway%3A%3A%3A%2Frestapis%2Faaeeieije" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-policy-response": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "apigatway:PUT" + ], + "Effect": "Allow", + "Resource": [ + "arn::apigateway:::/tags/arn%3Aaws%3Aapigateway%3A%3A%3A%2Frestapis%2Faaeeieije" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "", + "RoleName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-role-response": { + "Role": { + "Arn": "arn::iam::111111111111:role", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceArn": "arn%3Aaws%3Aapigateway%3A%3A%3A%2Frestapis%2Faaeeieije" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-roles-response": { + "IsTruncated": false, + "Roles": [ + { + "Arn": "arn::iam::111111111111:role", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "aws:SourceArn": "arn%3Aaws%3Aapigateway%3A%3A%3A%2Frestapis%2Faaeeieije" + } + }, + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "", + "RoleId": "", + "RoleName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_group_policy_encoding": { + "recorded-date": "06-03-2025, 12:25:10", + "recorded-content": { + "get-policy-response": { + "GroupName": "", + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "apigatway:PUT" + ], + "Effect": "Allow", + "Resource": [ + "arn::apigateway:::/tags/arn%3Aaws%3Aapigateway%3A%3A%3A%2Frestapis%2Faaeeieije" + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_service_specific_credential_lifecycle[codecommit.amazonaws.com]": { + "recorded-date": "06-03-2025, 16:58:34", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-service-specific-credentials-response-before-update": { + "ServiceSpecificCredentials": [ + { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-service-specific-credential-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-service-specific-credentials-response-after-update": { + "ServiceSpecificCredentials": [ + { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Inactive", + "UserName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "reset-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Inactive", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-service-specific-credentials-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_service_specific_credential_lifecycle[cassandra.amazonaws.com]": { + "recorded-date": "06-03-2025, 16:58:36", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "cassandra.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-service-specific-credentials-response-before-update": { + "ServiceSpecificCredentials": [ + { + "CreateDate": "", + "ServiceName": "cassandra.amazonaws.com", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-service-specific-credential-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-service-specific-credentials-response-after-update": { + "ServiceSpecificCredentials": [ + { + "CreateDate": "", + "ServiceName": "cassandra.amazonaws.com", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Inactive", + "UserName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "reset-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "cassandra.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Inactive", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-service-specific-credentials-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_delete_non_existent_policy_returns_no_such_entity": { + "recorded-date": "06-03-2025, 12:29:55", + "recorded-content": { + "delete-non-existent-policy-exc": { + "Error": { + "Code": "NoSuchEntity", + "Message": "Policy arn::iam::111111111111:policy/non-existent-policy was not found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_create_service_specific_credential_invalid_user": { + "recorded-date": "06-03-2025, 16:58:36", + "recorded-content": { + "invalid-user-name-exception": { + "Error": { + "Code": "NoSuchEntity", + "Message": "The user with name non-existent-user cannot be found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "invalid-user-and-service-exception": { + "Error": { + "Code": "NoSuchEntity", + "Message": "The user with name non-existent-user cannot be found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_create_service_specific_credential_invalid_service": { + "recorded-date": "06-03-2025, 16:58:38", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalid-service-exception": { + "Error": { + "Code": "NoSuchEntity", + "Message": "No such service nonexistentservice.amazonaws.com is supported for Service Specific Credentials", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "invalid-service-completely-malformed-exception": { + "Error": { + "Code": "NoSuchEntity", + "Message": "No such service o3on3n3onosneo is supported for Service Specific Credentials", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "invalid-service-existing-but-unsupported-exception": { + "Error": { + "Code": "NoSuchEntity", + "Message": "No such service lambda.amazonaws.com is supported for Service Specific Credentials", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_list_service_specific_credential_different_service": { + "recorded-date": "06-03-2025, 16:58:39", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-service-specific-credentials-invalid-service": { + "Error": { + "Code": "NoSuchEntity", + "Message": "No such service nonexistentservice.amazonaws.com is supported for Service Specific Credentials", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-service-specific-credentials-response-wrong-service": { + "ServiceSpecificCredentials": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_delete_user_after_service_credential_created": { + "recorded-date": "06-03-2025, 16:58:41", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-user-existing-credential": { + "Error": { + "Code": "DeleteConflict", + "Message": "Cannot delete entity, must remove referenced objects first.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_match_id_mismatch[totally-wrong-credential-id-with-hyphens]": { + "recorded-date": "06-03-2025, 16:58:45", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-wrong-id": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value at 'serviceSpecificCredentialId' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w]+", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "reset-wrong-id": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value at 'serviceSpecificCredentialId' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w]+", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-wrong-id": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value at 'serviceSpecificCredentialId' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w]+", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_match_id_mismatch[satisfiesregexbutstillinvalid]": { + "recorded-date": "06-03-2025, 16:58:47", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-wrong-id": { + "Error": { + "Code": "NoSuchEntity", + "Message": "No such credential satisfiesregexbutstillinvalid exists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "reset-wrong-id": { + "Error": { + "Code": "NoSuchEntity", + "Message": "No such credential satisfiesregexbutstillinvalid exists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-wrong-id": { + "Error": { + "Code": "NoSuchEntity", + "Message": "No such credential satisfiesregexbutstillinvalid exists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_id_match_user_mismatch": { + "recorded-date": "06-03-2025, 16:58:43", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-wrong-user-name": { + "Error": { + "Code": "NoSuchEntity", + "Message": "The user with name wrong-user-name cannot be found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "reset-wrong-user-name": { + "Error": { + "Code": "NoSuchEntity", + "Message": "The user with name wrong-user-name cannot be found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-wrong-user-name": { + "Error": { + "Code": "NoSuchEntity", + "Message": "The user with name wrong-user-name cannot be found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_invalid_update_parameters": { + "recorded-date": "06-03-2025, 16:58:49", + "recorded-content": { + "create-user-response": { + "User": { + "Arn": "arn::iam::111111111111:user/", + "CreateDate": "", + "Path": "/", + "UserId": "", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-service-specific-credential-response": { + "ServiceSpecificCredential": { + "CreateDate": "", + "ServiceName": "codecommit.amazonaws.com", + "ServicePassword": "", + "ServiceSpecificCredentialId": "", + "ServiceUserName": "-at-111111111111", + "Status": "Active", + "UserName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-invalid-status": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value at 'status' failed to satisfy constraint: Member must satisfy enum value set", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[accountdiscovery.ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:49", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/accountdiscovery.ssm.amazonaws.com/AWSServiceRoleForAmazonSSM_AccountDiscovery", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "accountdiscovery.ssm.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/accountdiscovery.ssm.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonSSM_AccountDiscovery" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSSystemsManagerAccountDiscoveryServicePolicy", + "PolicyName": "AWSSystemsManagerAccountDiscoveryServicePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[acm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:50", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/acm.amazonaws.com/AWSServiceRoleForCertificateManager", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "acm.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/acm.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForCertificateManager" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/CertificateManagerServiceRolePolicy", + "PolicyName": "CertificateManagerServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[appmesh.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:51", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/appmesh.amazonaws.com/AWSServiceRoleForAppMesh", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "appmesh.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/appmesh.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAppMesh" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSAppMeshServiceRolePolicy", + "PolicyName": "AWSAppMeshServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[autoscaling-plans.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:51", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/autoscaling-plans.amazonaws.com/AWSServiceRoleForAutoScalingPlans_EC2AutoScaling", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling-plans.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/autoscaling-plans.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAutoScalingPlans_EC2AutoScaling" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSAutoScalingPlansEC2AutoScalingPolicy", + "PolicyName": "AWSAutoScalingPlansEC2AutoScalingPolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[autoscaling.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:52", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/autoscaling.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAutoScaling" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AutoScalingServiceRolePolicy", + "PolicyName": "AutoScalingServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[backup.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:53", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/backup.amazonaws.com/AWSServiceRoleForBackup", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "backup.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/backup.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForBackup" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSBackupServiceLinkedRolePolicyForBackup", + "PolicyName": "AWSBackupServiceLinkedRolePolicyForBackup" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[batch.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:54", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/batch.amazonaws.com/AWSServiceRoleForBatch", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "batch.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/batch.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForBatch" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/BatchServiceRolePolicy", + "PolicyName": "BatchServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cassandra.application-autoscaling.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:55", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/cassandra.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_CassandraTable", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "cassandra.application-autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/cassandra.application-autoscaling.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForApplicationAutoScaling_CassandraTable" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSApplicationAutoscalingCassandraTablePolicy", + "PolicyName": "AWSApplicationAutoscalingCassandraTablePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cks.kms.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:56", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/cks.kms.amazonaws.com/AWSServiceRoleForKeyManagementServiceCustomKeyStores", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "cks.kms.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/cks.kms.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForKeyManagementServiceCustomKeyStores" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSKeyManagementServiceCustomKeyStoresServiceRolePolicy", + "PolicyName": "AWSKeyManagementServiceCustomKeyStoresServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cloudtrail.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:57", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/cloudtrail.amazonaws.com/AWSServiceRoleForCloudTrail", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "cloudtrail.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/cloudtrail.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForCloudTrail" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/CloudTrailServiceRolePolicy", + "PolicyName": "CloudTrailServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[codestar-notifications.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:58", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/codestar-notifications.amazonaws.com/AWSServiceRoleForCodeStarNotifications", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "codestar-notifications.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/codestar-notifications.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForCodeStarNotifications" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSCodeStarNotificationsServiceRolePolicy", + "PolicyName": "AWSCodeStarNotificationsServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[config.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:30:59", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "config.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/config.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForConfig" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSConfigServiceRolePolicy", + "PolicyName": "AWSConfigServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[connect.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:00", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/connect.amazonaws.com/AWSServiceRoleForAmazonConnect", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "connect.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/connect.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonConnect" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonConnectServiceLinkedRolePolicy", + "PolicyName": "AmazonConnectServiceLinkedRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[dms-fleet-advisor.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:01", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/dms-fleet-advisor.amazonaws.com/AWSServiceRoleForDMSFleetAdvisor", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "dms-fleet-advisor.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/dms-fleet-advisor.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForDMSFleetAdvisor" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSDMSFleetAdvisorServiceRolePolicy", + "PolicyName": "AWSDMSFleetAdvisorServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[dms.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:02", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/dms.amazonaws.com/AWSServiceRoleForDMSServerless", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "dms.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/dms.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForDMSServerless" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSDMSServerlessServiceRolePolicy", + "PolicyName": "AWSDMSServerlessServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[docdb-elastic.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:03", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/docdb-elastic.amazonaws.com/AWSServiceRoleForDocDB-Elastic", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "docdb-elastic.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/docdb-elastic.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForDocDB-Elastic" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonDocDB-ElasticServiceRolePolicy", + "PolicyName": "AmazonDocDB-ElasticServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ec2-instance-connect.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:04", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ec2-instance-connect.amazonaws.com/AWSServiceRoleForEc2InstanceConnect", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2-instance-connect.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ec2-instance-connect.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForEc2InstanceConnect" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/Ec2InstanceConnectEndpoint", + "PolicyName": "Ec2InstanceConnectEndpoint" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ec2.application-autoscaling.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:05", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ec2.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_EC2SpotFleetRequest", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.application-autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ec2.application-autoscaling.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForApplicationAutoScaling_EC2SpotFleetRequest" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSApplicationAutoscalingEC2SpotFleetRequestPolicy", + "PolicyName": "AWSApplicationAutoscalingEC2SpotFleetRequestPolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ecr.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:06", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ecr.amazonaws.com/AWSServiceRoleForECRTemplate", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecr.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ecr.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForECRTemplate" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/ECRTemplateServiceRolePolicy", + "PolicyName": "ECRTemplateServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ecs.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:07", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ecs.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForECS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonECSServiceRolePolicy", + "PolicyName": "AmazonECSServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-connector.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:08", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/eks-connector.amazonaws.com/AWSServiceRoleForAmazonEKSConnector", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "eks-connector.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/eks-connector.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonEKSConnector" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonEKSConnectorServiceRolePolicy", + "PolicyName": "AmazonEKSConnectorServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-fargate.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:08", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/eks-fargate.amazonaws.com/AWSServiceRoleForAmazonEKSForFargate", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "eks-fargate.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/eks-fargate.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonEKSForFargate" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonEKSForFargateServiceRolePolicy", + "PolicyName": "AmazonEKSForFargateServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-nodegroup.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:09", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/eks-nodegroup.amazonaws.com/AWSServiceRoleForAmazonEKSNodegroup", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "eks-nodegroup.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/eks-nodegroup.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonEKSNodegroup" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSServiceRoleForAmazonEKSNodegroup", + "PolicyName": "AWSServiceRoleForAmazonEKSNodegroup" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:10", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/eks.amazonaws.com/AWSServiceRoleForAmazonEKS", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "eks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/eks.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonEKS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonEKSServiceRolePolicy", + "PolicyName": "AmazonEKSServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticache.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:11", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/elasticache.amazonaws.com/AWSServiceRoleForElastiCache", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "elasticache.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/elasticache.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForElastiCache" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/ElastiCacheServiceRolePolicy", + "PolicyName": "ElastiCacheServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticbeanstalk.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:12", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/elasticbeanstalk.amazonaws.com/AWSServiceRoleForElasticBeanstalk", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "elasticbeanstalk.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/elasticbeanstalk.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForElasticBeanstalk" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSElasticBeanstalkServiceRolePolicy", + "PolicyName": "AWSElasticBeanstalkServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticfilesystem.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:13", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/elasticfilesystem.amazonaws.com/AWSServiceRoleForAmazonElasticFileSystem", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "elasticfilesystem.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/elasticfilesystem.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonElasticFileSystem" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonElasticFileSystemServiceRolePolicy", + "PolicyName": "AmazonElasticFileSystemServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticloadbalancing.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:14", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForElasticLoadBalancing", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "elasticloadbalancing.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/elasticloadbalancing.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForElasticLoadBalancing" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSElasticLoadBalancingServiceRolePolicy", + "PolicyName": "AWSElasticLoadBalancingServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[email.cognito-idp.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:14", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/email.cognito-idp.amazonaws.com/AWSServiceRoleForAmazonCognitoIdpEmailService", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "email.cognito-idp.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/email.cognito-idp.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonCognitoIdpEmailService" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonCognitoIdpEmailServiceRolePolicy", + "PolicyName": "AmazonCognitoIdpEmailServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[emr-containers.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:15", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/emr-containers.amazonaws.com/AWSServiceRoleForAmazonEMRContainers", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "emr-containers.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/emr-containers.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonEMRContainers" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonEMRContainersServiceRolePolicy", + "PolicyName": "AmazonEMRContainersServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[emrwal.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:16", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/emrwal.amazonaws.com/AWSServiceRoleForEMRWAL", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "emrwal.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/emrwal.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForEMRWAL" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/EMRDescribeClusterPolicyForEMRWAL", + "PolicyName": "EMRDescribeClusterPolicyForEMRWAL" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[fis.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:17", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/fis.amazonaws.com/AWSServiceRoleForFIS", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "fis.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/fis.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForFIS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonFISServiceRolePolicy", + "PolicyName": "AmazonFISServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[grafana.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:18", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/grafana.amazonaws.com/AWSServiceRoleForAmazonGrafana", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "grafana.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/grafana.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonGrafana" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonGrafanaServiceLinkedRolePolicy", + "PolicyName": "AmazonGrafanaServiceLinkedRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[imagebuilder.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:19", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/imagebuilder.amazonaws.com/AWSServiceRoleForImageBuilder", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "imagebuilder.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/imagebuilder.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForImageBuilder" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSServiceRoleForImageBuilder", + "PolicyName": "AWSServiceRoleForImageBuilder" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[iotmanagedintegrations.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:20", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/iotmanagedintegrations.amazonaws.com/AWSServiceRoleForIoTManagedIntegrations", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "iotmanagedintegrations.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/iotmanagedintegrations.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForIoTManagedIntegrations" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSIoTManagedIntegrationsRolePolicy", + "PolicyName": "AWSIoTManagedIntegrationsRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[kafka.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:21", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/kafka.amazonaws.com/AWSServiceRoleForKafka", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "kafka.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/kafka.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": { + "LastUsedDate": "", + "Region": "" + }, + "RoleName": "AWSServiceRoleForKafka" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/KafkaServiceRolePolicy", + "PolicyName": "KafkaServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[kafkaconnect.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:22", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/kafkaconnect.amazonaws.com/AWSServiceRoleForKafkaConnect", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "kafkaconnect.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/kafkaconnect.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForKafkaConnect" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/KafkaConnectServiceRolePolicy", + "PolicyName": "KafkaConnectServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lakeformation.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:23", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/lakeformation.amazonaws.com/AWSServiceRoleForLakeFormationDataAccess", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lakeformation.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/lakeformation.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForLakeFormationDataAccess" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/LakeFormationDataAccessServiceRolePolicy", + "PolicyName": "LakeFormationDataAccessServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lex.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:24", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/lex.amazonaws.com/AWSServiceRoleForLexBots", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lex.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/lex.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForLexBots" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonLexBotPolicy", + "PolicyName": "AmazonLexBotPolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lexv2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:25", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/lexv2.amazonaws.com/AWSServiceRoleForLexV2Bots", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lexv2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/lexv2.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForLexV2Bots" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonLexV2BotPolicy", + "PolicyName": "AmazonLexV2BotPolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lightsail.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:25", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/lightsail.amazonaws.com/AWSServiceRoleForLightsail", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lightsail.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/lightsail.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForLightsail" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/LightsailExportAccess", + "PolicyName": "LightsailExportAccess" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[m2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:26", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/m2.amazonaws.com/AWSServiceRoleForAWSM2", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "m2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/m2.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAWSM2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSM2ServicePolicy", + "PolicyName": "AWSM2ServicePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[memorydb.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:27", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/memorydb.amazonaws.com/AWSServiceRoleForMemoryDB", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "memorydb.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/memorydb.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForMemoryDB" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/MemoryDBServiceRolePolicy", + "PolicyName": "MemoryDBServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[mq.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:28", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/mq.amazonaws.com/AWSServiceRoleForAmazonMQ", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "mq.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/mq.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonMQ" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonMQServiceRolePolicy", + "PolicyName": "AmazonMQServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[mrk.kms.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:29", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/mrk.kms.amazonaws.com/AWSServiceRoleForKeyManagementServiceMultiRegionKeys", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "mrk.kms.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/mrk.kms.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForKeyManagementServiceMultiRegionKeys" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSKeyManagementServiceMultiRegionKeysServiceRolePolicy", + "PolicyName": "AWSKeyManagementServiceMultiRegionKeysServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[notifications.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:30", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/notifications.amazonaws.com/AWSServiceRoleForAwsUserNotifications", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "notifications.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/notifications.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAwsUserNotifications" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSUserNotificationsServiceLinkedRolePolicy", + "PolicyName": "AWSUserNotificationsServiceLinkedRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[observability.aoss.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:31", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/observability.aoss.amazonaws.com/AWSServiceRoleForAmazonOpenSearchServerless", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "observability.aoss.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/observability.aoss.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonOpenSearchServerless" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonOpenSearchServerlessServiceRolePolicy", + "PolicyName": "AmazonOpenSearchServerlessServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opensearchservice.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:32", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/opensearchservice.amazonaws.com/AWSServiceRoleForAmazonOpenSearchService", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "opensearchservice.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/opensearchservice.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonOpenSearchService" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonOpenSearchServiceRolePolicy", + "PolicyName": "AmazonOpenSearchServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ops.apigateway.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:33", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ops.apigateway.amazonaws.com/AWSServiceRoleForAPIGateway", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ops.apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ops.apigateway.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAPIGateway" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/APIGatewayServiceRolePolicy", + "PolicyName": "APIGatewayServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ops.emr-serverless.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:34", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ops.emr-serverless.amazonaws.com/AWSServiceRoleForAmazonEMRServerless", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ops.emr-serverless.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ops.emr-serverless.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonEMRServerless" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonEMRServerlessServiceRolePolicy", + "PolicyName": "AmazonEMRServerlessServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opsdatasync.ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:35", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/opsdatasync.ssm.amazonaws.com/AWSServiceRoleForSystemsManagerOpsDataSync", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "opsdatasync.ssm.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/opsdatasync.ssm.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForSystemsManagerOpsDataSync" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSSystemsManagerOpsDataSyncServiceRolePolicy", + "PolicyName": "AWSSystemsManagerOpsDataSyncServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opsinsights.ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:35", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/opsinsights.ssm.amazonaws.com/AWSServiceRoleForAmazonSSM_OpsInsights", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "opsinsights.ssm.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/opsinsights.ssm.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonSSM_OpsInsights" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSSSMOpsInsightsServiceRolePolicy", + "PolicyName": "AWSSSMOpsInsightsServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[pullthroughcache.ecr.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:36", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/pullthroughcache.ecr.amazonaws.com/AWSServiceRoleForECRPullThroughCache", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "pullthroughcache.ecr.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/pullthroughcache.ecr.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForECRPullThroughCache" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSECRPullThroughCache_ServiceRolePolicy", + "PolicyName": "AWSECRPullThroughCache_ServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ram.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:37", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ram.amazonaws.com/AWSServiceRoleForResourceAccessManager", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ram.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ram.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForResourceAccessManager" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSResourceAccessManagerServiceRolePolicy", + "PolicyName": "AWSResourceAccessManagerServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[rds.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:38", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/rds.amazonaws.com/AWSServiceRoleForRDS", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "rds.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Description": "Allows Amazon RDS to manage AWS resources on your behalf", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/rds.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": { + "LastUsedDate": "", + "Region": "ap-southeast-1" + }, + "RoleName": "AWSServiceRoleForRDS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonRDSServiceRolePolicy", + "PolicyName": "AmazonRDSServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[redshift.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:39", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/redshift.amazonaws.com/AWSServiceRoleForRedshift", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "redshift.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/redshift.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForRedshift" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonRedshiftServiceLinkedRolePolicy", + "PolicyName": "AmazonRedshiftServiceLinkedRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[replication.cassandra.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:40", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/replication.cassandra.amazonaws.com/AWSServiceRoleForKeyspacesReplication", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "replication.cassandra.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/replication.cassandra.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForKeyspacesReplication" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/KeyspacesReplicationServiceRolePolicy", + "PolicyName": "KeyspacesReplicationServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[replication.ecr.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:41", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/replication.ecr.amazonaws.com/AWSServiceRoleForECRReplication", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "replication.ecr.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/replication.ecr.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForECRReplication" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/ECRReplicationServiceRolePolicy", + "PolicyName": "ECRReplicationServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[repository.sync.codeconnections.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:42", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/repository.sync.codeconnections.amazonaws.com/AWSServiceRoleForGitSync", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "repository.sync.codeconnections.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/repository.sync.codeconnections.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForGitSync" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSGitSyncServiceRolePolicy", + "PolicyName": "AWSGitSyncServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[resource-explorer-2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:43", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/resource-explorer-2.amazonaws.com/AWSServiceRoleForResourceExplorer", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "resource-explorer-2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/resource-explorer-2.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": { + "LastUsedDate": "", + "Region": "us-west-2" + }, + "RoleName": "AWSServiceRoleForResourceExplorer" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSResourceExplorerServiceRolePolicy", + "PolicyName": "AWSResourceExplorerServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[rolesanywhere.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:44", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/rolesanywhere.amazonaws.com/AWSServiceRoleForRolesAnywhere", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "rolesanywhere.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/rolesanywhere.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForRolesAnywhere" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSRolesAnywhereServicePolicy", + "PolicyName": "AWSRolesAnywhereServicePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[s3-outposts.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:45", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/s3-outposts.amazonaws.com/AWSServiceRoleForS3OnOutposts", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "s3-outposts.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/s3-outposts.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForS3OnOutposts" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSS3OnOutpostsServiceRolePolicy", + "PolicyName": "AWSS3OnOutpostsServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ses.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:46", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ses.amazonaws.com/AWSServiceRoleForAmazonSES", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ses.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ses.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonSES" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonSESServiceRolePolicy", + "PolicyName": "AmazonSESServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[shield.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:47", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/shield.amazonaws.com/AWSServiceRoleForAWSShield", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "shield.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/shield.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAWSShield" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSShieldServiceRolePolicy", + "PolicyName": "AWSShieldServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm-incidents.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:48", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ssm-incidents.amazonaws.com/AWSServiceRoleForIncidentManager", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ssm-incidents.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ssm-incidents.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForIncidentManager" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSIncidentManagerServiceRolePolicy", + "PolicyName": "AWSIncidentManagerServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm-quicksetup.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:49", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ssm-quicksetup.amazonaws.com/AWSServiceRoleForSSMQuickSetup", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ssm-quicksetup.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ssm-quicksetup.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForSSMQuickSetup" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/SSMQuickSetupRolePolicy", + "PolicyName": "SSMQuickSetupRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:50", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/ssm.amazonaws.com/AWSServiceRoleForAmazonSSM", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ssm.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Description": "Provides access to AWS Resources managed or used by Amazon SSM.", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/ssm.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": { + "LastUsedDate": "", + "Region": "" + }, + "RoleName": "AWSServiceRoleForAmazonSSM" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonSSMServiceRolePolicy", + "PolicyName": "AmazonSSMServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[sso.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:51", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/sso.amazonaws.com/AWSServiceRoleForSSO", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "sso.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Description": "Service-linked role used by AWS SSO to manage AWS resources, including IAM roles, policies and SAML IdP on your behalf.", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/sso.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": { + "LastUsedDate": "", + "Region": "us-east-1" + }, + "RoleName": "AWSServiceRoleForSSO" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSSSOServiceRolePolicy", + "PolicyName": "AWSSSOServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[vpcorigin.cloudfront.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:52", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/vpcorigin.cloudfront.amazonaws.com/AWSServiceRoleForCloudFrontVPCOrigin", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "vpcorigin.cloudfront.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/vpcorigin.cloudfront.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForCloudFrontVPCOrigin" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AWSCloudFrontVPCOriginServiceRolePolicy", + "PolicyName": "AWSCloudFrontVPCOriginServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[waf.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:53", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/waf.amazonaws.com/AWSServiceRoleForWAFLogging", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "waf.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/waf.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForWAFLogging" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/WAFLoggingServiceRolePolicy", + "PolicyName": "WAFLoggingServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[wafv2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:54", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/wafv2.amazonaws.com/AWSServiceRoleForWAFV2Logging", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "wafv2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/wafv2.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForWAFV2Logging" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/WAFV2LoggingServiceRolePolicy", + "PolicyName": "WAFV2LoggingServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[autoscaling.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:55", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling_", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/autoscaling.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAutoScaling_" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AutoScalingServiceRolePolicy", + "PolicyName": "AutoScalingServiceRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[connect.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:56", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/connect.amazonaws.com/AWSServiceRoleForAmazonConnect_", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "connect.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/connect.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForAmazonConnect_" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonConnectServiceLinkedRolePolicy", + "PolicyName": "AmazonConnectServiceLinkedRolePolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[lexv2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:57", + "recorded-content": { + "describe-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/aws-service-role/lexv2.amazonaws.com/AWSServiceRoleForLexV2Bots_", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lexv2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/aws-service-role/lexv2.amazonaws.com/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "AWSServiceRoleForLexV2Bots_" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "inline-role-policies": { + "IsTruncated": false, + "PolicyNames": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "attached-role-policies": { + "AttachedPolicies": [ + { + "PolicyArn": "arn::iam::aws:policy/aws-service-role/AmazonLexV2BotPolicy", + "PolicyName": "AmazonLexV2BotPolicy" + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ops.apigateway.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:57", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ops.apigateway.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-fargate.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:57", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for eks-fargate.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[emrwal.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:58", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for emrwal.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ops.emr-serverless.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:59", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ops.emr-serverless.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:31:59", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for eks.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[iotmanagedintegrations.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:00", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for iotmanagedintegrations.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[kafkaconnect.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:00", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for kafkaconnect.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[wafv2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:01", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for wafv2.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[rolesanywhere.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:01", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for rolesanywhere.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[m2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:02", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for m2.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[dms-fleet-advisor.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:03", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for dms-fleet-advisor.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opsdatasync.ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:03", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for opsdatasync.ssm.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[resource-explorer-2.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:04", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for resource-explorer-2.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opsinsights.ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:04", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for opsinsights.ssm.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[codestar-notifications.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:05", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for codestar-notifications.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[appmesh.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:05", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for appmesh.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[waf.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:06", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for waf.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[notifications.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:07", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for notifications.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[docdb-elastic.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:07", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for docdb-elastic.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticbeanstalk.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:08", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for elasticbeanstalk.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[replication.ecr.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:08", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for replication.ecr.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ecs.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:09", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ecs.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[batch.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:09", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for batch.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[shield.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:10", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for shield.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[redshift.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:10", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for redshift.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[rds.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:11", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for rds.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[mrk.kms.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:12", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for mrk.kms.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-connector.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:12", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for eks-connector.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[kafka.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:13", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for kafka.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[observability.aoss.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:13", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for observability.aoss.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lakeformation.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:14", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for lakeformation.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[memorydb.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:14", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for memorydb.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticache.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:15", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for elasticache.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[emr-containers.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:16", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for emr-containers.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[pullthroughcache.ecr.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:16", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for pullthroughcache.ecr.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[imagebuilder.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:17", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for imagebuilder.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cassandra.application-autoscaling.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:17", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for cassandra.application-autoscaling.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[s3-outposts.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:18", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for s3-outposts.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-nodegroup.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:18", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for eks-nodegroup.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[accountdiscovery.ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:19", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for accountdiscovery.ssm.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm-incidents.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:20", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ssm-incidents.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[dms.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:20", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for dms.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ecr.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:21", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ecr.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[sso.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:21", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for sso.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lex.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:22", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for lex.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[repository.sync.codeconnections.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:22", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for repository.sync.codeconnections.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ram.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:23", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ram.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cks.kms.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:23", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for cks.kms.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cloudtrail.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:24", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for cloudtrail.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ses.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:25", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ses.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opensearchservice.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:25", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for opensearchservice.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm-quicksetup.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:26", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ssm-quicksetup.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[vpcorigin.cloudfront.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:26", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for vpcorigin.cloudfront.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ec2-instance-connect.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:27", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ec2-instance-connect.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[acm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:27", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for acm.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticloadbalancing.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:28", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for elasticloadbalancing.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[fis.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:29", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for fis.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticfilesystem.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:29", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for elasticfilesystem.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[replication.cassandra.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:30", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for replication.cassandra.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[config.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:30", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for config.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[autoscaling-plans.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:31", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for autoscaling-plans.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[mq.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:31", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for mq.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[email.cognito-idp.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:32", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for email.cognito-idp.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ec2.application-autoscaling.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:32", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ec2.application-autoscaling.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[backup.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:33", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for backup.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[grafana.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:34", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for grafana.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lightsail.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:34", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for lightsail.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm.amazonaws.com]": { + "recorded-date": "13-03-2025, 08:32:35", + "recorded-content": { + "custom-suffix-not-allowed": { + "Error": { + "Code": "InvalidInput", + "Message": "Custom suffix is not allowed for ssm.amazonaws.com", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_already_exists": { + "recorded-date": "13-03-2025, 15:18:42", + "recorded-content": { + "role-already-exists-error": { + "Error": { + "Code": "InvalidInput", + "Message": "Service role name AWSServiceRoleForBatch has been taken in this account, please try a different suffix.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_deletion": { + "recorded-date": "13-03-2025, 16:11:23", + "recorded-content": { + "service-linked-role-deletion-response": { + "DeletionTaskId": "task/aws-service-role/batch.amazonaws.com/AWSServiceRoleForBatch/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "service-linked-role-deletion-status-response": { + "Status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[role]": { + "recorded-date": "21-04-2025, 20:07:35", + "recorded-content": { + "response": { + "EvaluationResults": [ + { + "EvalActionName": "s3:PutObject", + "EvalDecision": "allowed", + "EvalDecisionDetails": {}, + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [ + { + "EndPosition": { + "Column": 113, + "Line": 1 + }, + "SourcePolicyId": "", + "SourcePolicyType": "IAM Policy", + "StartPosition": { + "Column": 41, + "Line": 1 + } + } + ], + "MissingContextValues": [], + "OrganizationsDecisionDetail": { + "AllowedByOrganizations": true + }, + "ResourceSpecificResults": [ + { + "EvalResourceDecision": "allowed", + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [ + { + "EndPosition": { + "Column": 113, + "Line": 1 + }, + "SourcePolicyId": "", + "SourcePolicyType": "IAM Policy", + "StartPosition": { + "Column": 41, + "Line": 1 + } + } + ], + "MissingContextValues": [] + } + ] + }, + { + "EvalActionName": "s3:GetObjectVersion", + "EvalDecision": "implicitDeny", + "EvalDecisionDetails": {}, + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [], + "MissingContextValues": [], + "OrganizationsDecisionDetail": { + "AllowedByOrganizations": true + }, + "ResourceSpecificResults": [ + { + "EvalResourceDecision": "implicitDeny", + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [], + "MissingContextValues": [] + } + ] + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[group]": { + "recorded-date": "21-04-2025, 20:07:37", + "recorded-content": { + "response": { + "EvaluationResults": [ + { + "EvalActionName": "s3:PutObject", + "EvalDecision": "allowed", + "EvalDecisionDetails": {}, + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [ + { + "EndPosition": { + "Column": 113, + "Line": 1 + }, + "SourcePolicyId": "", + "SourcePolicyType": "IAM Policy", + "StartPosition": { + "Column": 41, + "Line": 1 + } + } + ], + "MissingContextValues": [], + "OrganizationsDecisionDetail": { + "AllowedByOrganizations": true + }, + "ResourceSpecificResults": [ + { + "EvalResourceDecision": "allowed", + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [ + { + "EndPosition": { + "Column": 113, + "Line": 1 + }, + "SourcePolicyId": "", + "SourcePolicyType": "IAM Policy", + "StartPosition": { + "Column": 41, + "Line": 1 + } + } + ], + "MissingContextValues": [] + } + ] + }, + { + "EvalActionName": "s3:GetObjectVersion", + "EvalDecision": "implicitDeny", + "EvalDecisionDetails": {}, + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [], + "MissingContextValues": [], + "OrganizationsDecisionDetail": { + "AllowedByOrganizations": true + }, + "ResourceSpecificResults": [ + { + "EvalResourceDecision": "implicitDeny", + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [], + "MissingContextValues": [] + } + ] + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[user]": { + "recorded-date": "21-04-2025, 20:07:38", + "recorded-content": { + "response": { + "EvaluationResults": [ + { + "EvalActionName": "s3:PutObject", + "EvalDecision": "allowed", + "EvalDecisionDetails": {}, + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [ + { + "EndPosition": { + "Column": 113, + "Line": 1 + }, + "SourcePolicyId": "", + "SourcePolicyType": "IAM Policy", + "StartPosition": { + "Column": 41, + "Line": 1 + } + } + ], + "MissingContextValues": [], + "OrganizationsDecisionDetail": { + "AllowedByOrganizations": true + }, + "ResourceSpecificResults": [ + { + "EvalResourceDecision": "allowed", + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [ + { + "EndPosition": { + "Column": 113, + "Line": 1 + }, + "SourcePolicyId": "", + "SourcePolicyType": "IAM Policy", + "StartPosition": { + "Column": 41, + "Line": 1 + } + } + ], + "MissingContextValues": [] + } + ] + }, + { + "EvalActionName": "s3:GetObjectVersion", + "EvalDecision": "implicitDeny", + "EvalDecisionDetails": {}, + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [], + "MissingContextValues": [], + "OrganizationsDecisionDetail": { + "AllowedByOrganizations": true + }, + "ResourceSpecificResults": [ + { + "EvalResourceDecision": "implicitDeny", + "EvalResourceName": "arn::s3:::bucket", + "MatchedStatements": [], + "MissingContextValues": [] + } + ] + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/iam/test_iam.py::TestRoles::test_role_with_tags": { + "recorded-date": "02-07-2025, 09:40:33", + "recorded-content": { + "create-role-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/role-with-tags/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "/role-with-tags/", + "RoleId": "", + "RoleName": "", + "Tags": [ + { + "Key": "test", + "Value": "value" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-role-response": { + "Role": { + "Arn": "arn::iam::111111111111:role/role-with-tags/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/role-with-tags/", + "RoleId": "", + "RoleLastUsed": {}, + "RoleName": "", + "Tags": [ + { + "Key": "test", + "Value": "value" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-role-response": { + "IsTruncated": false, + "Roles": [ + { + "Arn": "arn::iam::111111111111:role/role-with-tags/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "MaxSessionDuration": 3600, + "Path": "/role-with-tags/", + "RoleId": "", + "RoleName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/iam/test_iam.validation.json b/tests/aws/services/iam/test_iam.validation.json new file mode 100644 index 0000000000000..e4ba6fcc28cc4 --- /dev/null +++ b/tests/aws/services/iam/test_iam.validation.json @@ -0,0 +1,569 @@ +{ + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_create_role_with_malformed_assume_role_policy_document": { + "last_validated_date": "2025-03-06T12:24:44+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_create_user_add_permission_boundary_afterwards": { + "last_validated_date": "2025-03-06T12:24:43+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_create_user_with_permission_boundary": { + "last_validated_date": "2025-03-06T12:24:41+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_get_user_without_username_as_role": { + "last_validated_date": "2025-03-06T12:24:39+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_get_user_without_username_as_user": { + "last_validated_date": "2025-03-06T12:24:26+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMExtensions::test_role_with_path_lifecycle": { + "last_validated_date": "2025-06-19T11:39:59+00:00", + "durations_in_seconds": { + "setup": 1.34, + "call": 2.25, + "teardown": 0.01, + "total": 3.6 + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_attach_detach_role_policy": { + "last_validated_date": "2025-03-06T12:24:54+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_attach_iam_role_to_new_iam_user": { + "last_validated_date": "2025-03-06T12:24:47+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_create_describe_role": { + "last_validated_date": "2025-03-06T12:24:59+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_create_role_with_assume_role_policy": { + "last_validated_date": "2025-03-06T12:24:57+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_create_user_with_tags": { + "last_validated_date": "2025-03-06T12:24:52+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_delete_non_existent_policy_returns_no_such_entity": { + "last_validated_date": "2025-03-06T12:29:55+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_instance_profile_tags": { + "last_validated_date": "2025-03-06T12:24:52+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_list_roles_with_permission_boundary": { + "last_validated_date": "2025-03-06T12:25:00+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_recreate_iam_role": { + "last_validated_date": "2025-03-06T12:24:48+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_role_attach_policy": { + "last_validated_date": "2025-06-19T11:47:40+00:00", + "durations_in_seconds": { + "setup": 1.46, + "call": 5.22, + "teardown": 2.05, + "total": 8.73 + } + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[group]": { + "last_validated_date": "2025-04-21T20:07:37+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[role]": { + "last_validated_date": "2025-04-21T20:07:35+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_simulate_principle_policy[user]": { + "last_validated_date": "2025-04-21T20:07:38+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_update_assume_role_policy": { + "last_validated_date": "2025-03-06T12:24:58+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMIntegrations::test_user_attach_policy": { + "last_validated_date": "2025-03-06T12:25:05+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_group_policy_encoding": { + "last_validated_date": "2025-03-06T12:25:10+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_role_policy_encoding": { + "last_validated_date": "2025-03-06T12:25:09+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMPolicyEncoding::test_put_user_policy_encoding": { + "last_validated_date": "2025-03-06T12:25:07+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_already_exists": { + "last_validated_date": "2025-03-13T15:18:42+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_deletion": { + "last_validated_date": "2025-03-13T16:11:22+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle": { + "last_validated_date": "2025-03-11T13:49:49+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[accountdiscovery.ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:48+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[acm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:49+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[appmesh.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:50+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[autoscaling-plans.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:51+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[autoscaling.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:52+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[backup.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:53+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[batch.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:54+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cassandra.application-autoscaling.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:55+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cks.kms.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:56+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[cloudtrail.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:57+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[codestar-notifications.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:58+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[config.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:30:59+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[connect.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:00+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[dms-fleet-advisor.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:01+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[dms.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:02+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[docdb-elastic.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:03+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ec2-instance-connect.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:04+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ec2.application-autoscaling.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:05+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ecr.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:06+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ecs.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:07+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-connector.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:07+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-fargate.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:08+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks-nodegroup.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:09+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[eks.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:10+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticache.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:11+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticbeanstalk.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:12+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticfilesystem.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:13+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[elasticloadbalancing.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:13+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[email.cognito-idp.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:14+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[emr-containers.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:15+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[emrwal.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:16+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[fis.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:17+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[grafana.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:18+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[imagebuilder.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:19+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[iotmanagedintegrations.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:20+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[kafka.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:21+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[kafkaconnect.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:22+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lakeformation.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:23+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lex.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:24+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lexv2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:24+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[lightsail.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:25+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[m2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:26+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[memorydb.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:27+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[mq.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:28+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[mrk.kms.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:29+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[notifications.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:30+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[observability.aoss.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:31+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opensearchservice.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:32+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ops.apigateway.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:33+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ops.emr-serverless.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:33+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opsdatasync.ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:34+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[opsinsights.ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:35+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[pullthroughcache.ecr.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:36+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ram.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:37+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[rds.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:38+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[redshift.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:39+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[replication.cassandra.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:40+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[replication.ecr.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:41+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[repository.sync.codeconnections.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:42+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[resource-explorer-2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:43+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[rolesanywhere.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:44+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[s3-outposts.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:45+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ses.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:46+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[shield.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:47+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm-incidents.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:48+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm-quicksetup.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:48+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:50+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[sso.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:51+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[vpcorigin.cloudfront.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:52+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[waf.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:53+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle[wafv2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:54+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[autoscaling.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:55+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[connect.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:56+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix[lexv2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:56+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[accountdiscovery.ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:19+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[acm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:27+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[appmesh.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:05+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[autoscaling-plans.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:31+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[backup.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:33+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[batch.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:09+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cassandra.application-autoscaling.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:17+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cks.kms.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:23+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[cloudtrail.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:24+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[codestar-notifications.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:05+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[config.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:30+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[dms-fleet-advisor.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:03+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[dms.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:20+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[docdb-elastic.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:07+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ec2-instance-connect.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:27+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ec2.application-autoscaling.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:32+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ecr.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:21+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ecs.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:09+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-connector.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:12+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-fargate.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:57+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks-nodegroup.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:18+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[eks.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:59+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticache.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:15+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticbeanstalk.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:08+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticfilesystem.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:29+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[elasticloadbalancing.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:28+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[email.cognito-idp.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:32+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[emr-containers.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:16+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[emrwal.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:58+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[fis.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:29+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[grafana.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:34+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[imagebuilder.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:17+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[iotmanagedintegrations.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:00+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[kafka.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:13+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[kafkaconnect.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:00+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lakeformation.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:14+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lex.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:22+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[lightsail.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:34+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[m2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:02+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[memorydb.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:14+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[mq.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:31+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[mrk.kms.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:12+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[notifications.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:07+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[observability.aoss.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:13+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opensearchservice.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:25+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ops.apigateway.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:57+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ops.emr-serverless.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:31:59+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opsdatasync.ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:03+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[opsinsights.ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:04+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[pullthroughcache.ecr.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:16+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ram.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:23+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[rds.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:11+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[redshift.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:10+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[replication.cassandra.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:30+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[replication.ecr.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:08+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[repository.sync.codeconnections.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:22+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[resource-explorer-2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:04+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[rolesanywhere.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:01+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[s3-outposts.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:18+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ses.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:24+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[shield.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:10+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm-incidents.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:19+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm-quicksetup.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:26+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[ssm.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:35+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[sso.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:21+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[vpcorigin.cloudfront.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:26+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[waf.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:06+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceRoles::test_service_role_lifecycle_custom_suffix_not_allowed[wafv2.amazonaws.com]": { + "last_validated_date": "2025-03-13T08:32:01+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_create_service_specific_credential_invalid_service": { + "last_validated_date": "2025-03-06T16:58:37+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_create_service_specific_credential_invalid_user": { + "last_validated_date": "2025-03-06T16:58:36+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_delete_user_after_service_credential_created": { + "last_validated_date": "2025-03-06T16:58:40+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_id_match_user_mismatch": { + "last_validated_date": "2025-03-06T16:58:42+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_invalid_update_parameters": { + "last_validated_date": "2025-03-06T16:58:48+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_list_service_specific_credential_different_service": { + "last_validated_date": "2025-03-06T16:58:39+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_service_specific_credential_lifecycle": { + "last_validated_date": "2025-03-05T17:56:55+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_service_specific_credential_lifecycle[cassandra.amazonaws.com]": { + "last_validated_date": "2025-03-06T16:58:35+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_service_specific_credential_lifecycle[codecommit.amazonaws.com]": { + "last_validated_date": "2025-03-06T16:58:33+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_id_mismatch": { + "last_validated_date": "2025-03-06T13:34:01+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_match_id_mismatch": { + "last_validated_date": "2025-03-06T13:36:53+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_match_id_mismatch[satisfiesregexbutstillinvalid]": { + "last_validated_date": "2025-03-06T16:58:47+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestIAMServiceSpecificCredentials::test_user_match_id_mismatch[totally-wrong-credential-id-with-hyphens]": { + "last_validated_date": "2025-03-06T16:58:44+00:00" + }, + "tests/aws/services/iam/test_iam.py::TestRoles::test_role_with_tags": { + "last_validated_date": "2025-07-02T09:40:33+00:00", + "durations_in_seconds": { + "setup": 1.44, + "call": 1.03, + "teardown": 0.59, + "total": 3.06 + } + } +} diff --git a/tests/aws/services/kinesis/__init__.py b/tests/aws/services/kinesis/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/kinesis/conftest.py b/tests/aws/services/kinesis/conftest.py new file mode 100644 index 0000000000000..84905fbf1fd79 --- /dev/null +++ b/tests/aws/services/kinesis/conftest.py @@ -0,0 +1,49 @@ +import logging + +import pytest + +from localstack.utils.common import poll_condition + +LOG = logging.getLogger(__name__) + + +@pytest.fixture +def kinesis_register_consumer(aws_client): + kinesis = aws_client.kinesis + consumer_arns = [] + + def _kinesis_register_consumer(stream_arn: str, consumer_name: str): + response = kinesis.register_stream_consumer( + StreamARN=stream_arn, ConsumerName=consumer_name + ) + consumer_arn = response["Consumer"]["ConsumerARN"] + consumer_arns.append(consumer_arn) + + return response + + yield _kinesis_register_consumer + + for consumer_arn in consumer_arns: + try: + kinesis.deregister_stream_consumer(ConsumerARN=consumer_arn) + except Exception: + LOG.info("Failed to deregister stream consumer %s", consumer_arn) + + +@pytest.fixture(autouse=True) +def kinesis_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.kinesis_api()) + + +@pytest.fixture +def wait_for_kinesis_consumer_ready(aws_client): + def _wait_for_kinesis_consumer_ready(consumer_arn: str): + def is_consumer_ready(): + describe_response = aws_client.kinesis.describe_stream_consumer( + ConsumerARN=consumer_arn + ) + return describe_response["ConsumerDescription"]["ConsumerStatus"] == "ACTIVE" + + poll_condition(is_consumer_ready) + + return _wait_for_kinesis_consumer_ready diff --git a/tests/aws/services/kinesis/helper_functions.py b/tests/aws/services/kinesis/helper_functions.py new file mode 100644 index 0000000000000..c9c185a5852eb --- /dev/null +++ b/tests/aws/services/kinesis/helper_functions.py @@ -0,0 +1,16 @@ +def get_shard_iterator(stream_name, kinesis_client): + response = kinesis_client.describe_stream(StreamName=stream_name) + sequence_number = ( + response.get("StreamDescription") + .get("Shards")[0] + .get("SequenceNumberRange") + .get("StartingSequenceNumber") + ) + shard_id = response.get("StreamDescription").get("Shards")[0].get("ShardId") + response = kinesis_client.get_shard_iterator( + StreamName=stream_name, + ShardId=shard_id, + ShardIteratorType="AT_SEQUENCE_NUMBER", + StartingSequenceNumber=sequence_number, + ) + return response.get("ShardIterator") diff --git a/tests/aws/services/kinesis/test_kinesis.py b/tests/aws/services/kinesis/test_kinesis.py new file mode 100644 index 0000000000000..041b25bc28bcf --- /dev/null +++ b/tests/aws/services/kinesis/test_kinesis.py @@ -0,0 +1,819 @@ +import json +import logging +import os +import subprocess +import time +from datetime import datetime, timedelta +from typing import Any +from unittest.mock import patch + +import pytest +from botocore.auth import SigV4Auth +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError + +# cbor2: explicitly load from private _encoder/_decoder module to avoid using the (non-patched) C-version +from cbor2._decoder import loads as cbor2_loads +from cbor2._encoder import dumps as cbor2_dumps +from requests import Response + +from localstack import config, constants +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.client import _patch_cbor2 +from localstack.services.kinesis import provider as kinesis_provider +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws import resources +from localstack.utils.common import retry, select_attributes, short_uid +from localstack.utils.files import load_file +from localstack.utils.kinesis import kinesis_connector +from tests.aws.services.kinesis.helper_functions import get_shard_iterator +from tests.aws.services.lambda_.test_lambda import THIS_FOLDER as LAMBDA_TEST_FOLDER + +LOGGER = logging.getLogger(__name__) + +# make sure cbor2 patches are applied +# (for the test-data decoding, usually done as init hook in LocalStack) +_patch_cbor2() + + +class KinesisHTTPClient: + """ + Simple HTTP client for making Kinesis requests manually using the CBOR serialization type. + + This serialization type is not available via botocore. + """ + + def __init__( + self, + account_id: str, + region_name: str, + client_factory, + ): + self.account_id = account_id + self.region_name = region_name + self._client = client_factory("kinesis", region=self.region_name, signer_factory=SigV4Auth) + + def post_raw( + self, operation: str, payload: dict, datetime_as_timestamp: bool = True, **kwargs + ) -> Response: + """ + Perform a kinesis operation, encoding the request payload with CBOR and returning the raw + response without any processing or checks. + """ + response = self._client.post( + self.endpoint, + data=cbor2_dumps(payload, datetime_as_timestamp=datetime_as_timestamp), + headers=self._build_headers(operation), + **kwargs, + ) + return response + + def post(self, operation: str, payload: dict, datetime_as_timestamp: bool = True) -> Any: + """ + Perform a kinesis operation, encoding the request payload with CBOR, checking the response status code + and decoding the response with CBOR. + """ + response = self.post_raw(operation, payload, datetime_as_timestamp) + response_content = response.content + response_body = cbor2_loads(response_content) + if response.status_code != 200: + raise ValueError(f"Bad status: {response.status_code}, response body: {response_body}") + return response_body + + def _build_headers(self, operation: str) -> dict: + return { + "content-type": constants.APPLICATION_AMZ_CBOR_1_1, + "x-amz-target": f"Kinesis_20131202.{operation}", + "host": self.endpoint, + } + + @property + def endpoint(self) -> str: + return ( + f"https://{self.account_id}.control-kinesis.{self.region_name}.amazonaws.com" + if is_aws_cloud() + else config.internal_service_url() + ) + + +@pytest.fixture +def kinesis_http_client(account_id, region_name, aws_http_client_factory): + return KinesisHTTPClient(account_id, region_name, client_factory=aws_http_client_factory) + + +class TestKinesis: + @staticmethod + def _get_endpoint(account_id: str, region_name: str): + return ( + f"https://{account_id}.control-kinesis.{region_name}.amazonaws.com" + if is_aws_cloud() + else config.internal_service_url() + ) + + @markers.aws.validated + def test_create_stream_without_stream_name_raises(self, aws_client_factory): + boto_config = BotoConfig(parameter_validation=False) + kinesis_client = aws_client_factory(config=boto_config).kinesis + with pytest.raises(ClientError) as e: + kinesis_client.create_stream() + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + # TODO snapshotting reveals that the Error.Message is different + + @markers.aws.validated + def test_create_stream_without_shard_count( + self, kinesis_create_stream, wait_for_stream_ready, snapshot, aws_client, cleanups + ): + stream_name = kinesis_create_stream() + wait_for_stream_ready(stream_name) + describe_stream = aws_client.kinesis.describe_stream(StreamName=stream_name) + + shards = describe_stream["StreamDescription"]["Shards"] + shards.sort(key=lambda k: k.get("ShardId")) + + snapshot.match("Shards", shards) + + @markers.aws.validated + def test_stream_consumers( + self, + kinesis_create_stream, + wait_for_stream_ready, + kinesis_register_consumer, + wait_for_kinesis_consumer_ready, + snapshot, + aws_client, + ): + # create stream and assert 0 consumers + stream_name = kinesis_create_stream(ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name) + + # no consumer snapshot + consumer_list = aws_client.kinesis.list_stream_consumers(StreamARN=stream_arn).get( + "Consumers" + ) + assert len(consumer_list) == 0 + + # create consumer and snapshot 1 consumer by list_stream_consumers + consumer_name = "consumer" + response = kinesis_register_consumer(stream_arn, consumer_name) + consumer_arn = response["Consumer"]["ConsumerARN"] + wait_for_kinesis_consumer_ready(consumer_arn=consumer_arn) + + consumer_list = aws_client.kinesis.list_stream_consumers(StreamARN=stream_arn).get( + "Consumers" + ) + snapshot.match("One_consumer_by_list_stream", consumer_list) + + # lookup stream consumer by describe_stream_consumer + consumer_description_by_arn = aws_client.kinesis.describe_stream_consumer( + StreamARN=stream_arn, ConsumerARN=consumer_arn + )["ConsumerDescription"] + + snapshot.match("One_consumer_by_describe_stream", consumer_description_by_arn) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Records..EncryptionType"]) + def test_subscribe_to_shard( + self, + kinesis_create_stream, + wait_for_stream_ready, + kinesis_register_consumer, + wait_for_kinesis_consumer_ready, + snapshot, + aws_client, + ): + # create stream and consumer + stream_name = kinesis_create_stream(ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name) + consumer_name = "c1" + response = kinesis_register_consumer(stream_arn, consumer_name) + consumer_arn = response["Consumer"]["ConsumerARN"] + wait_for_kinesis_consumer_ready(consumer_arn=consumer_arn) + + # subscribe to shard + response = aws_client.kinesis.describe_stream(StreamName=stream_name) + + shard_id = response.get("StreamDescription").get("Shards")[0].get("ShardId") + result = aws_client.kinesis.subscribe_to_shard( + ConsumerARN=consumer_arn, + ShardId=shard_id, + StartingPosition={"Type": "TRIM_HORIZON"}, + ) + stream = result["EventStream"] + + # put records + num_records = 5 + msg = "Hello world" + for i in range(num_records): + aws_client.kinesis.put_records( + StreamName=stream_name, Records=[{"Data": f"{msg}_{i}", "PartitionKey": "1"}] + ) + + # read out results + results = [] + for entry in stream: + records = entry["SubscribeToShardEvent"]["Records"] + results.extend(records) + if len(results) >= num_records: + break + + results.sort(key=lambda k: k.get("Data")) + snapshot.match("Records", results) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Records..EncryptionType"]) + def test_subscribe_to_shard_with_at_timestamp( + self, + kinesis_create_stream, + wait_for_stream_ready, + kinesis_register_consumer, + wait_for_kinesis_consumer_ready, + snapshot, + aws_client, + ): + # create stream and consumer + stream_name = kinesis_create_stream(ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name) + + consumer_name = "c1" + response = kinesis_register_consumer(stream_arn, consumer_name) + consumer_arn = response["Consumer"]["ConsumerARN"] + wait_for_kinesis_consumer_ready(consumer_arn=consumer_arn) + + # subscribe to shard with iterator type as AT_TIMESTAMP + response = aws_client.kinesis.describe_stream(StreamName=stream_name) + shard_id = response.get("StreamDescription").get("Shards")[0].get("ShardId") + result = aws_client.kinesis.subscribe_to_shard( + ConsumerARN=consumer_arn, + ShardId=shard_id, + StartingPosition={"Type": "AT_TIMESTAMP", "Timestamp": datetime(2015, 1, 1)}, + ) + stream = result["EventStream"] + + # put records + num_records = 5 + msg = "Hello world" + for i in range(num_records): + aws_client.kinesis.put_records( + StreamName=stream_name, Records=[{"Data": f"{msg}_{i}", "PartitionKey": "1"}] + ) + + # read out results + results = [] + for entry in stream: + records = entry["SubscribeToShardEvent"]["Records"] + results.extend(records) + if len(results) >= num_records: + break + + results.sort(key=lambda k: k.get("Data")) + snapshot.match("Records", results) + + @markers.aws.needs_fixing + # TODO SubscribeToShard raises a 500 (Internal Server Error) against AWS + def test_subscribe_to_shard_cbor_at_timestamp( + self, + kinesis_create_stream, + wait_for_stream_ready, + aws_client, + kinesis_register_consumer, + kinesis_http_client, + account_id, + region_name, + wait_for_kinesis_consumer_ready, + ): + # create stream + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + # subscribe to shard with CBOR encoding + response = aws_client.kinesis.describe_stream(StreamName=stream_name) + shard_id = response.get("StreamDescription").get("Shards")[0].get("ShardId") + + # create consumer + consumer_name = "c1" + response = kinesis_register_consumer( + response["StreamDescription"]["StreamARN"], consumer_name + ) + consumer_arn = response["Consumer"]["ConsumerARN"] + wait_for_kinesis_consumer_ready(consumer_arn=consumer_arn) + + found_record = False + with kinesis_http_client.post_raw( + operation="SubscribeToShard", + payload={ + "ConsumerARN": consumer_arn, + "ShardId": shard_id, + "StartingPosition": { + "Type": "AT_TIMESTAMP", + "Timestamp": datetime.now().astimezone(), + }, + }, + stream=True, + ) as result: + assert 200 == result.status_code + + # put records + aws_client.kinesis.put_records( + StreamName=stream_name, Records=[{"Data": "--RECORD--", "PartitionKey": "1"}] + ) + + # botocore does not support parsing CBOR responses + # just check for the presence of the record marker + for chunk in result.iter_lines(delimiter=b"\00"): + if b"--RECORD--" in chunk: + found_record = True + break + + assert found_record + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Records..EncryptionType"]) + def test_subscribe_to_shard_with_sequence_number_as_iterator( + self, + kinesis_create_stream, + wait_for_stream_ready, + kinesis_register_consumer, + wait_for_kinesis_consumer_ready, + snapshot, + aws_client, + ): + # create stream and consumer + stream_name = kinesis_create_stream(ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name) + + consumer_name = "c1" + response = kinesis_register_consumer(stream_arn, consumer_name) + consumer_arn = response["Consumer"]["ConsumerARN"] + wait_for_kinesis_consumer_ready(consumer_arn=consumer_arn) + + # get starting sequence number + response = aws_client.kinesis.describe_stream(StreamName=stream_name) + sequence_number = ( + response.get("StreamDescription") + .get("Shards")[0] + .get("SequenceNumberRange") + .get("StartingSequenceNumber") + ) + + # subscribe to shard with iterator type as AT_SEQUENCE_NUMBER + response = aws_client.kinesis.describe_stream(StreamName=stream_name) + shard_id = response.get("StreamDescription").get("Shards")[0].get("ShardId") + result = aws_client.kinesis.subscribe_to_shard( + ConsumerARN=consumer_arn, + ShardId=shard_id, + StartingPosition={ + "Type": "AT_SEQUENCE_NUMBER", + "SequenceNumber": sequence_number, + }, + ) + stream = result["EventStream"] + + # put records + num_records = 5 + msg = "Hello world" + for i in range(num_records): + aws_client.kinesis.put_records( + StreamName=stream_name, Records=[{"Data": f"{msg}_{i}", "PartitionKey": "1"}] + ) + + # read out results + results = [] + for entry in stream: + records = entry["SubscribeToShardEvent"]["Records"] + results.extend(records) + if len(results) >= num_records: + break + + results.sort(key=lambda k: k.get("Data")) + snapshot.match("Records", results) + + @markers.aws.validated + def test_get_records( + self, + kinesis_create_stream, + wait_for_stream_ready, + aws_client, + kinesis_http_client, + account_id, + region_name, + kinesis_register_consumer, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.key_value("StreamName")) + + # create stream + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + aws_client.kinesis.put_records( + StreamName=stream_name, + Records=[{"Data": "SGVsbG8gd29ybGQ=", "PartitionKey": "1"}], + ) + + # get records with JSON encoding + iterator = get_shard_iterator(stream_name, aws_client.kinesis) + response = aws_client.kinesis.get_records(ShardIterator=iterator) + json_records = response.get("Records") + assert 1 == len(json_records) + assert "Data" in json_records[0] + + # get records with CBOR encoding + iterator = get_shard_iterator(stream_name, aws_client.kinesis) + result = kinesis_http_client.post( + operation="GetRecords", payload={"ShardIterator": iterator} + ) + attrs = ("Data", "EncryptionType", "PartitionKey", "SequenceNumber") + assert select_attributes(json_records[0], attrs) == select_attributes( + result["Records"][0], attrs + ) + # ensure that the CBOR datetime format is parsed the same way + assert ( + json_records[0]["ApproximateArrivalTimestamp"] + == result["Records"][0]["ApproximateArrivalTimestamp"] + ) + + @markers.aws.validated + def test_get_records_empty_stream( + self, + kinesis_create_stream, + wait_for_stream_ready, + aws_client, + kinesis_http_client, + account_id, + region_name, + ): + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + # empty get records with JSON encoding + iterator = get_shard_iterator(stream_name, aws_client.kinesis) + json_response = aws_client.kinesis.get_records(ShardIterator=iterator) + json_records = json_response.get("Records") + assert 0 == len(json_records) + + # empty get records with CBOR encoding + cbor_records_content = kinesis_http_client.post( + operation="GetRecords", payload={"ShardIterator": iterator} + ) + cbor_records = cbor_records_content.get("Records") + assert 0 == len(cbor_records) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Records..EncryptionType"]) + def test_record_lifecycle_data_integrity( + self, kinesis_create_stream, wait_for_stream_ready, snapshot, aws_client + ): + """ + kinesis records should contain the same data from when they are sent to when they are received + """ + records_data = {"test", "ΓΌnicΓΆdΓ© η»ŸδΈ€η  πŸ’£πŸ’»πŸ”₯", "a" * 1000, ""} + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + iterator = get_shard_iterator(stream_name, aws_client.kinesis) + + for record_data in records_data: + aws_client.kinesis.put_record( + StreamName=stream_name, + Data=record_data, + PartitionKey="1", + ) + + response = aws_client.kinesis.get_records(ShardIterator=iterator) + response_records = response.get("Records") + response_records.sort(key=lambda k: k.get("Data")) + snapshot.match("Records", response_records) + + @markers.aws.validated + @patch.object(kinesis_provider, "MAX_SUBSCRIPTION_SECONDS", 3) + def test_subscribe_to_shard_timeout( + self, + kinesis_create_stream, + wait_for_stream_ready, + kinesis_register_consumer, + wait_for_kinesis_consumer_ready, + aws_client, + ): + # create stream and consumer + stream_name = kinesis_create_stream(ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name) + + # create consumer + consumer_name = "c1" + response = kinesis_register_consumer(stream_arn, consumer_name) + consumer_arn = response["Consumer"]["ConsumerARN"] + wait_for_kinesis_consumer_ready(consumer_arn=consumer_arn) + + # subscribe to shard + response = aws_client.kinesis.describe_stream(StreamName=stream_name) + shard_id = response.get("StreamDescription").get("Shards")[0].get("ShardId") + result = aws_client.kinesis.subscribe_to_shard( + ConsumerARN=consumer_arn, + ShardId=shard_id, + StartingPosition={"Type": "TRIM_HORIZON"}, + ) + stream = result["EventStream"] + + # letting the subscription run out + time.sleep(5) + + # put records + msg = b"Hello world" + aws_client.kinesis.put_records( + StreamName=stream_name, Records=[{"Data": msg, "PartitionKey": "1"}] + ) + + # due to the subscription being timed out, we should not be able to read out results + results = [] + for entry in stream: + records = entry["SubscribeToShardEvent"]["Records"] + results.extend(records) + + assert len(results) == 0 + + @markers.aws.validated + def test_add_tags_to_stream( + self, kinesis_create_stream, wait_for_stream_ready, snapshot, aws_client + ): + test_tags = {"foo": "bar"} + + # create stream + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + # adding tags + aws_client.kinesis.add_tags_to_stream(StreamName=stream_name, Tags=test_tags) + + # reading stream tags + stream_tags_response = aws_client.kinesis.list_tags_for_stream(StreamName=stream_name) + + snapshot.match("Tags", stream_tags_response["Tags"][0]) + assert not stream_tags_response["HasMoreTags"] + + @markers.aws.validated + def test_get_records_next_shard_iterator( + self, kinesis_create_stream, wait_for_stream_ready, aws_client + ): + stream_name = kinesis_create_stream() + wait_for_stream_ready(stream_name) + + first_stream_shard_data = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["Shards"][0] + shard_id = first_stream_shard_data["ShardId"] + + shard_iterator = aws_client.kinesis.get_shard_iterator( + StreamName=stream_name, ShardIteratorType="LATEST", ShardId=shard_id + )["ShardIterator"] + + get_records_response = aws_client.kinesis.get_records(ShardIterator=shard_iterator) + new_shard_iterator = get_records_response["NextShardIterator"] + assert shard_iterator != new_shard_iterator + get_records_response = aws_client.kinesis.get_records(ShardIterator=new_shard_iterator) + assert shard_iterator != get_records_response["NextShardIterator"] + assert new_shard_iterator != get_records_response["NextShardIterator"] + + @markers.aws.validated + def test_get_records_shard_iterator_with_surrounding_quotes( + self, kinesis_create_stream, wait_for_stream_ready, aws_client + ): + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + aws_client.kinesis.put_records( + StreamName=stream_name, Records=[{"Data": b"Hello world", "PartitionKey": "1"}] + ) + + first_stream_shard_data = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["Shards"][0] + shard_id = first_stream_shard_data["ShardId"] + + shard_iterator = aws_client.kinesis.get_shard_iterator( + StreamName=stream_name, ShardIteratorType="TRIM_HORIZON", ShardId=shard_id + )["ShardIterator"] + + assert aws_client.kinesis.get_records(ShardIterator=f'"{shard_iterator}"')["Records"] + + @markers.aws.validated + def test_subscribe_to_shard_with_at_timestamp_cbor( + self, + kinesis_create_stream, + wait_for_stream_ready, + aws_client, + kinesis_http_client, + ): + # create stream + pre_create_timestamp = (datetime.now() - timedelta(hours=0, minutes=1)).astimezone() + stream_name = kinesis_create_stream(ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name) + post_create_timestamp = (datetime.now() + timedelta(hours=0, minutes=1)).astimezone() + + # perform a raw DescribeStream request to test the datetime serialization by LocalStack + describe_response_raw = kinesis_http_client.post_raw( + operation="DescribeStream", + payload={"StreamARN": stream_arn}, + ) + assert 200 == describe_response_raw.status_code + cbor_content = describe_response_raw.content + + # Ensure that the timestamp is in an integer and not a floating point (AWS SDKs can't handle that) + # Assert via testing the binary stream for the CBOR tag types according to RFC 7049: + # - Byte 1: 0xc1 -> 11000001 + # - first 3 bit (110) are major type -> 6 -> "Semantic Tag" + # - the next 5 bit (00001) are the tag type -> 1 -> "numerical representation of seconds relative to epoch" + # - Byte 2: 0xfb -> 11111011 + # - declares for deterministic encoding that the floating point is encoded as binary 64 (section 4.2.2) + + assert b"StreamCreationTimestamp\xc1\xfb" not in cbor_content + describe_response_data = cbor2_loads(cbor_content) + + # verify that the request can be properly parsed, and that the timestamp is within the + # boundaries + assert ( + pre_create_timestamp + <= describe_response_data["StreamDescription"]["StreamCreationTimestamp"] + <= post_create_timestamp + ) + + shard_id = describe_response_data["StreamDescription"]["Shards"][0]["ShardId"] + shard_iterator_response_data = kinesis_http_client.post( + "GetShardIterator", + payload={ + "StreamARN": stream_arn, + "ShardId": shard_id, + "ShardIteratorType": "AT_TIMESTAMP", + "Timestamp": datetime.now().astimezone(), + }, + ) + assert "ShardIterator" in shard_iterator_response_data + + @markers.aws.validated + def test_cbor_blob_handling( + self, + kinesis_create_stream, + wait_for_stream_ready, + aws_client, + kinesis_http_client, + ): + # create stream + stream_name = kinesis_create_stream(ShardCount=1) + wait_for_stream_ready(stream_name) + + test_data = f"hello world {short_uid()}" + + # put a record on to the stream + kinesis_http_client.post( + operation="PutRecord", + payload={ + "Data": test_data.encode("utf-8"), + "PartitionKey": f"key-{short_uid()}", + "StreamName": stream_name, + }, + ) + + # don't need to get shard iterator manually, so use the SDK + shard_iterator: str | None = get_shard_iterator(stream_name, aws_client.kinesis) + assert shard_iterator is not None + + def _get_record(): + # send get records request via the http client + get_records_response = kinesis_http_client.post( + operation="GetRecords", + payload={ + "ShardIterator": shard_iterator, + }, + ) + assert len(get_records_response["Records"]) == 1 + return get_records_response["Records"][0] + + record = retry(_get_record, sleep=1, retries=5) + assert record["Data"].decode("utf-8") == test_data + + +class TestKinesisJavaSDK: + # the lambda function is stored in the lambda common functions folder to re-use existing caching in CI + TEST_LAMBDA_KINESIS_SDK_V2 = os.path.join( + LAMBDA_TEST_FOLDER, + "functions/common/kinesis_sdkv2/java17/handler.zip", + ) + + @markers.aws.validated + def test_subscribe_to_shard_with_java_sdk_v2_lambda( + self, + kinesis_create_stream, + wait_for_stream_ready, + create_lambda_function, + lambda_su_role, + aws_client, + ): + # lazily build the lambda if it's not there yet + if not os.path.exists(self.TEST_LAMBDA_KINESIS_SDK_V2) or not os.path.isfile( + self.TEST_LAMBDA_KINESIS_SDK_V2 + ): + build_cmd = ["make", "build"] + LOGGER.info("Building Java Lambda for Kinesis AWS SDK v2 test.") + result = subprocess.run(build_cmd, cwd=os.path.dirname(self.TEST_LAMBDA_KINESIS_SDK_V2)) + if result.returncode != 0: + raise Exception("Failed to build lambda for Kinesis Java AWS SDK v2 test.") + + stream_name = kinesis_create_stream() + wait_for_stream_ready(stream_name) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + lambda_name = f"test-{short_uid()}" + zip_file = load_file(self.TEST_LAMBDA_KINESIS_SDK_V2, mode="rb") + create_lambda_function( + zip_file=zip_file, + runtime=Runtime.java17, + handler="kinesis.Handler", + func_name=lambda_name, + role=lambda_su_role, + ) + result = aws_client.lambda_.invoke( + FunctionName=lambda_name, Payload=json.dumps({"StreamARN": stream_arn}) + ) + response_content = json.load(result["Payload"]) + assert response_content == "ok" + + +@pytest.mark.skipif( + condition=is_aws_cloud(), + reason="Duplicate of all tests in TestKinesis. Since we cannot unmark test cases, only run against LocalStack.", +) +class TestKinesisMockScala(TestKinesis): + @pytest.fixture(autouse=True) + def set_kinesis_mock_scala_engine(self, monkeypatch): + monkeypatch.setattr(config, "KINESIS_MOCK_PROVIDER_ENGINE", "scala") + + @pytest.fixture(autouse=True, scope="function") + def override_snapshot_session(self, _snapshot_session): + # Replace the scope_key of the snapshot session to reference parent class' recorded snapshots + _snapshot_session.scope_key = _snapshot_session.scope_key.replace( + "TestKinesisMockScala", "TestKinesis" + ) + # Ensure we load in the previously recorded state now that the scope key has been updated + _snapshot_session.recorded_state = _snapshot_session._load_state() + + +class TestKinesisPythonClient: + @markers.skip_offline + @markers.aws.only_localstack + def test_run_kcl(self, aws_client, account_id, region_name): + result = [] + + def process_records(records): + result.extend(records) + + # start Kinesis client + kinesis = aws_client.kinesis + stream_name = f"test-foobar-{short_uid()}" + resources.create_kinesis_stream(kinesis, stream_name, delete=True) + process = kinesis_connector.listen_to_kinesis( + stream_name=stream_name, + account_id=account_id, + region_name=region_name, + listener_func=process_records, + wait_until_started=True, + ) + + try: + stream_summary = kinesis.describe_stream_summary(StreamName=stream_name) + assert 1 == stream_summary["StreamDescriptionSummary"]["OpenShardCount"] + + num_events_kinesis = 10 + kinesis.put_records( + Records=[ + {"Data": "{}", "PartitionKey": "test_%s" % i} + for i in range(0, num_events_kinesis) + ], + StreamName=stream_name, + ) + + def check_events(): + assert num_events_kinesis == len(result) + + retry(check_events, retries=4, sleep=2) + finally: + process.stop() diff --git a/tests/aws/services/kinesis/test_kinesis.snapshot.json b/tests/aws/services/kinesis/test_kinesis.snapshot.json new file mode 100644 index 0000000000000..e71ca426b61e6 --- /dev/null +++ b/tests/aws/services/kinesis/test_kinesis.snapshot.json @@ -0,0 +1,224 @@ +{ + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_create_stream_without_shard_count": { + "recorded-date": "26-08-2022, 09:30:59", + "recorded-content": { + "Shards": [ + { + "ShardId": "", + "HashKeyRange": { + "StartingHashKey": "starting_hash", + "EndingHashKey": "ending_hash" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + } + }, + { + "ShardId": "", + "HashKeyRange": { + "StartingHashKey": "starting_hash", + "EndingHashKey": "ending_hash" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + } + }, + { + "ShardId": "", + "HashKeyRange": { + "StartingHashKey": "starting_hash", + "EndingHashKey": "ending_hash" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + } + }, + { + "ShardId": "", + "HashKeyRange": { + "StartingHashKey": "starting_hash", + "EndingHashKey": "ending_hash" + }, + "SequenceNumberRange": { + "StartingSequenceNumber": "" + } + } + ] + } + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_add_tags_to_stream": { + "recorded-date": "25-08-2022, 08:56:43", + "recorded-content": { + "Tags": { + "Key": "foo", + "Value": "bar" + } + } + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_record_lifecycle_data_integrity": { + "recorded-date": "25-08-2022, 12:39:44", + "recorded-content": { + "Records": [ + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b''", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'test'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'\\xc3\\xbcnic\\xc3\\xb6d\\xc3\\xa9 \\xe7\\xbb\\x9f\\xe4\\xb8\\x80\\xe7\\xa0\\x81 \\xf0\\x9f\\x92\\xa3\\xf0\\x9f\\x92\\xbb\\xf0\\x9f\\x94\\xa5'", + "PartitionKey": "1" + } + ] + } + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_sequence_number_as_iterator": { + "recorded-date": "26-08-2022, 09:29:21", + "recorded-content": { + "Records": [ + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_0'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_1'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_2'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_3'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_4'", + "PartitionKey": "1" + } + ] + } + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_stream_consumers": { + "recorded-date": "26-08-2022, 10:23:46", + "recorded-content": { + "One_consumer_by_list_stream": [ + { + "ConsumerName": "consumer", + "ConsumerARN": "arn::kinesis::111111111111:/", + "ConsumerStatus": "ACTIVE", + "ConsumerCreationTimestamp": "timestamp" + } + ], + "One_consumer_by_describe_stream": { + "ConsumerARN": "arn::kinesis::111111111111:/", + "ConsumerCreationTimestamp": "timestamp", + "ConsumerName": "consumer", + "ConsumerStatus": "ACTIVE", + "StreamARN": "arn::kinesis::111111111111:" + } + } + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard": { + "recorded-date": "26-08-2022, 09:33:29", + "recorded-content": { + "Records": [ + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_0'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_1'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_2'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_3'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_4'", + "PartitionKey": "1" + } + ] + } + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_cbor_at_timestamp": { + "recorded-date": "21-06-2024, 15:18:03", + "recorded-content": {} + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_at_timestamp": { + "recorded-date": "21-06-2024, 15:19:50", + "recorded-content": { + "Records": [ + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_0'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_1'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_2'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_3'", + "PartitionKey": "1" + }, + { + "SequenceNumber": "", + "ApproximateArrivalTimestamp": "timestamp", + "Data": "b'Hello world_4'", + "PartitionKey": "1" + } + ] + } + } +} diff --git a/tests/aws/services/kinesis/test_kinesis.validation.json b/tests/aws/services/kinesis/test_kinesis.validation.json new file mode 100644 index 0000000000000..93c7322a4e4cf --- /dev/null +++ b/tests/aws/services/kinesis/test_kinesis.validation.json @@ -0,0 +1,44 @@ +{ + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_add_tags_to_stream": { + "last_validated_date": "2022-08-25T06:56:43+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_cbor_blob_handling": { + "last_validated_date": "2024-07-31T11:17:28+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_create_stream_without_shard_count": { + "last_validated_date": "2022-08-26T07:30:59+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_create_stream_without_stream_name_raises": { + "last_validated_date": "2024-06-10T09:38:19+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_get_records": { + "last_validated_date": "2024-07-04T14:57:38+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_get_records_empty_stream": { + "last_validated_date": "2024-07-04T14:59:11+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_record_lifecycle_data_integrity": { + "last_validated_date": "2022-08-25T10:39:44+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_stream_consumers": { + "last_validated_date": "2022-08-26T08:23:46+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard": { + "last_validated_date": "2022-08-26T07:33:29+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_at_timestamp": { + "last_validated_date": "2024-06-21T15:20:46+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_at_timestamp_cbor": { + "last_validated_date": "2024-07-30T14:49:34+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesis::test_subscribe_to_shard_with_sequence_number_as_iterator": { + "last_validated_date": "2022-08-26T07:29:21+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::TestKinesisJavaSDK::test_subscribe_to_shard_with_java_sdk_v2_lambda": { + "last_validated_date": "2024-07-31T11:18:43+00:00" + }, + "tests/aws/services/kinesis/test_kinesis.py::test_subscribe_to_shard_with_at_timestamp_cbor": { + "last_validated_date": "2024-07-04T07:13:22+00:00" + } +} diff --git a/tests/aws/services/kms/__init__.py b/tests/aws/services/kms/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/kms/test_kms.py b/tests/aws/services/kms/test_kms.py new file mode 100644 index 0000000000000..92fcf1f085139 --- /dev/null +++ b/tests/aws/services/kms/test_kms.py @@ -0,0 +1,2180 @@ +import base64 +import hashlib +import json +import os +import uuid +from datetime import datetime +from random import getrandbits + +import pytest +from botocore.config import Config +from botocore.exceptions import ClientError +from cryptography.hazmat.primitives import hashes, hmac, serialization +from cryptography.hazmat.primitives.asymmetric import ec, padding, utils +from cryptography.hazmat.primitives.serialization import load_der_public_key + +from localstack.services.kms.models import ( + IV_LEN, + ON_DEMAND_ROTATION_LIMIT, + Ciphertext, + _serialize_ciphertext_blob, +) +from localstack.services.kms.utils import get_hash_algorithm +from localstack.testing.aws.util import in_default_partition +from localstack.testing.pytest import markers +from localstack.utils.crypto import encrypt +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import poll_condition + + +def create_tags(**kwargs): + return [{"TagKey": key, "TagValue": value} for key, value in kwargs.items()] + + +def get_signature_kwargs(signing_algorithm, message_type): + algo_map = { + "SHA_256": (hashes.SHA256(), 32), + "SHA_384": (hashes.SHA384(), 48), + "SHA_512": (hashes.SHA512(), 64), + } + hasher, salt = next((h, s) for k, (h, s) in algo_map.items() if k in signing_algorithm) + algorithm = utils.Prehashed(hasher) if message_type == "DIGEST" else hasher + kwargs = {} + + if signing_algorithm.startswith("ECDSA"): + kwargs["signature_algorithm"] = ec.ECDSA(algorithm) + elif signing_algorithm.startswith("RSA"): + if "PKCS" in signing_algorithm: + kwargs["padding"] = padding.PKCS1v15() + elif "PSS" in signing_algorithm: + kwargs["padding"] = padding.PSS(mgf=padding.MGF1(hasher), salt_length=salt) + kwargs["algorithm"] = algorithm + return kwargs + + +@pytest.fixture(scope="class") +def kms_client_for_region(aws_client_factory): + def _kms_client( + region_name: str = None, + ): + return aws_client_factory(region_name=region_name).kms + + return _kms_client + + +@pytest.fixture(scope="class") +def user_arn(aws_client): + return aws_client.sts.get_caller_identity()["Arn"] + + +def _get_all_key_ids(kms_client): + ids = set() + next_token = None + while True: + kwargs = {"nextToken": next_token} if next_token else {} + response = kms_client.list_keys(**kwargs) + for key in response["Keys"]: + ids.add(key["KeyId"]) + if "nextToken" not in response: + break + next_token = response["nextToken"] + return ids + + +def _get_alias(kms_client, alias_name, key_id=None): + next_token = None + # TODO potential bug on pagination on "nextToken" attribute key + while True: + kwargs = {"nextToken": next_token} if next_token else {} + if key_id: + kwargs["KeyId"] = key_id + response = kms_client.list_aliases(**kwargs) + for alias in response["Aliases"]: + if alias["AliasName"] == alias_name: + return alias + if "nextToken" not in response: + break + next_token = response["nextToken"] + return None + + +class TestKMS: + @pytest.fixture(autouse=True) + def kms_api_snapshot_transformer(self, snapshot): + snapshot.add_transformer(snapshot.transform.kms_api()) + + @markers.aws.validated + def test_create_alias(self, kms_create_alias, kms_create_key, snapshot): + alias_name = f"{short_uid()}" + alias_key_id = kms_create_key()["KeyId"] + with pytest.raises(Exception) as e: + kms_create_alias(AliasName=alias_name, TargetKeyId=alias_key_id) + + snapshot.match("create_alias", e.value.response) + + @markers.aws.validated + def test_create_key( + self, kms_client_for_region, kms_create_key, snapshot, aws_client, account_id, region_name + ): + kms_client = kms_client_for_region(region_name) + + key_ids_before = _get_all_key_ids(kms_client) + + key_id = kms_create_key( + region_name=region_name, Description="test key 123", KeyUsage="ENCRYPT_DECRYPT" + )["KeyId"] + assert key_id not in key_ids_before + + key_ids_after = _get_all_key_ids(kms_client) + assert key_id in key_ids_after + + response = kms_client.describe_key(KeyId=key_id)["KeyMetadata"] + snapshot.match("describe-key", response) + + assert response["KeyId"] == key_id + assert f":{region_name}:" in response["Arn"] + assert f":{account_id}:" in response["Arn"] + + @markers.aws.validated + def test_tag_existing_key_and_untag( + self, kms_client_for_region, kms_create_key, snapshot, region_name + ): + kms_client = kms_client_for_region(region_name) + key_id = kms_create_key( + region_name=region_name, Description="test key 123", KeyUsage="ENCRYPT_DECRYPT" + )["KeyId"] + + tags = create_tags(tag1="value1", tag2="value2") + kms_client.tag_resource(KeyId=key_id, Tags=tags) + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags", response) + + tag_keys = [tag["TagKey"] for tag in tags] + kms_client.untag_resource(KeyId=key_id, TagKeys=tag_keys) + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags-after-all-untagged", response) + + @markers.aws.validated + def test_create_key_with_tag_and_untag( + self, kms_client_for_region, kms_create_key, snapshot, region_name + ): + kms_client = kms_client_for_region(region_name) + + tags = create_tags(tag1="value1", tag2="value2") + key_id = kms_create_key( + region_name=region_name, + Description="test key 123", + KeyUsage="ENCRYPT_DECRYPT", + Tags=tags, + )["KeyId"] + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags", response) + + tag_keys = [tag["TagKey"] for tag in tags] + kms_client.untag_resource(KeyId=key_id, TagKeys=tag_keys) + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags-after-all-untagged", response) + + @markers.aws.validated + def test_untag_key_partially( + self, kms_client_for_region, kms_create_key, snapshot, region_name + ): + kms_client = kms_client_for_region(region_name) + + tag_key_to_untag = "tag2" + tags = create_tags(**{"tag1": "value1", tag_key_to_untag: "value2", "tag3": "value3"}) + key_id = kms_create_key( + region_name=region_name, + Description="test key 123", + KeyUsage="ENCRYPT_DECRYPT", + Tags=tags, + )["KeyId"] + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags", response) + + kms_client.untag_resource(KeyId=key_id, TagKeys=[tag_key_to_untag]) + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags-after-partially-untagged", response) + + @markers.aws.validated + def test_update_and_add_tags_on_tagged_key( + self, kms_client_for_region, kms_create_key, snapshot, region_name + ): + kms_client = kms_client_for_region(region_name) + + tag_key_to_modify = "tag2" + tags = create_tags(**{"tag1": "value1", tag_key_to_modify: "value2", "tag3": "value3"}) + key_id = kms_create_key( + region_name=region_name, + Description="test key 123", + KeyUsage="ENCRYPT_DECRYPT", + Tags=tags, + )["KeyId"] + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags", response) + + new_tags = create_tags( + **{"tag4": "value4", tag_key_to_modify: "updated_value2", "tag5": "value5"} + ) + kms_client.tag_resource(KeyId=key_id, Tags=new_tags) + + response = kms_client.list_resource_tags(KeyId=key_id)["Tags"] + snapshot.match("list-resource-tags-after-tags-updated", response) + + @markers.aws.validated + def test_tag_key_with_duplicate_tag_keys_raises_error( + self, kms_client_for_region, kms_create_key, snapshot, region_name + ): + kms_client = kms_client_for_region(region_name) + key_id = kms_create_key( + region_name=region_name, Description="test key 123", KeyUsage="ENCRYPT_DECRYPT" + )["KeyId"] + + tags = [ + {"TagKey": "tag1", "TagValue": "value1"}, + {"TagKey": "tag1", "TagValue": "another-value1"}, + ] + with pytest.raises(ClientError) as e: + kms_client.tag_resource(KeyId=key_id, Tags=tags) + snapshot.match("duplicate-tag-keys", e.value.response) + + @markers.aws.validated + def test_create_key_with_too_many_tags_raises_error( + self, kms_create_key, snapshot, region_name + ): + max_tags = 50 + tags = create_tags(**{f"key{i}": f"value{i}" for i in range(0, max_tags + 1)}) + + with pytest.raises(ClientError) as e: + kms_create_key( + region_name=region_name, + Description="test key 123", + KeyUsage="ENCRYPT_DECRYPT", + Tags=tags, + )["KeyId"] + snapshot.match("invalid-tag-key", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "invalid_tag_key", + ["aws:key1", "AWS:key1", "a" * 129], + ids=["lowercase_prefix", "uppercase_prefix", "too_long_key"], + ) + def test_create_key_with_invalid_tag_key( + self, invalid_tag_key, kms_create_key, snapshot, region_name + ): + tags = create_tags(**{invalid_tag_key: "value1"}) + + with pytest.raises(ClientError) as e: + kms_create_key( + region_name=region_name, + Description="test key 123", + KeyUsage="ENCRYPT_DECRYPT", + Tags=tags, + )["KeyId"] + snapshot.match("invalid-tag-key", e.value.response) + + @markers.aws.validated + def test_tag_existing_key_with_invalid_tag_key( + self, kms_client_for_region, kms_create_key, snapshot, region_name + ): + kms_client = kms_client_for_region(region_name) + + key_id = kms_create_key( + region_name=region_name, Description="test key 123", KeyUsage="ENCRYPT_DECRYPT" + )["KeyId"] + tags = create_tags(**{"aws:key1": "value1"}) + + with pytest.raises(ClientError) as e: + kms_client.tag_resource(KeyId=key_id, Tags=tags) + snapshot.match("invalid-tag-key", e.value.response) + + @markers.aws.validated + def test_key_with_long_tag_value_raises_error(self, kms_create_key, snapshot, region_name): + tags = create_tags(**{"tag1": "v" * 257}) + + with pytest.raises(ClientError) as e: + kms_create_key( + region_name=region_name, + Description="test key 123", + KeyUsage="ENCRYPT_DECRYPT", + Tags=tags, + )["KeyId"] + snapshot.match("too-long-tag-value", e.value.response) + + @markers.aws.only_localstack + def test_create_key_custom_id(self, kms_create_key, aws_client): + custom_id = str(uuid.uuid4()) + key_id = kms_create_key(Tags=[{"TagKey": "_custom_id_", "TagValue": custom_id}])["KeyId"] + assert custom_id == key_id + result = aws_client.kms.describe_key(KeyId=key_id)["KeyMetadata"] + assert result["KeyId"] == key_id + assert result["Arn"].endswith(f":key/{key_id}") + + @markers.aws.only_localstack + def test_create_key_custom_key_material_hmac(self, kms_create_key, aws_client): + custom_key_material = b"custom test key material" + custom_key_tag_value = base64.b64encode(custom_key_material).decode("utf-8") + message = "some important message" + key_spec = "HMAC_256" + mac_algo = "HMAC_SHA_256" + + # Generate expected MAC + h = hmac.HMAC(custom_key_material, hashes.SHA256()) + h.update(message.encode("utf-8")) + expected_mac = h.finalize() + + key_id = kms_create_key( + KeySpec=key_spec, + KeyUsage="GENERATE_VERIFY_MAC", + Tags=[{"TagKey": "_custom_key_material_", "TagValue": custom_key_tag_value}], + )["KeyId"] + + mac = aws_client.kms.generate_mac( + KeyId=key_id, + Message=message, + MacAlgorithm=mac_algo, + )["Mac"] + assert mac == expected_mac + + verify_mac_response = aws_client.kms.verify_mac( + KeyId=key_id, + Message="some important message", + MacAlgorithm=mac_algo, + Mac=expected_mac, + ) + assert verify_mac_response["MacValid"] + + @markers.aws.only_localstack + def test_create_key_custom_key_material_symmetric_decrypt(self, kms_create_key, aws_client): + custom_key_material = b"custom test key material" + custom_key_tag_value = base64.b64encode(custom_key_material).decode("utf-8") + algo = "SYMMETRIC_DEFAULT" + message = b"test message 123 !%$@ 1234567890" + + key_id = kms_create_key( + Tags=[{"TagKey": "_custom_key_material_", "TagValue": custom_key_tag_value}] + )["KeyId"] + + # Generate expected cipher text + iv = os.urandom(IV_LEN) + ciphertext, tag = encrypt(custom_key_material, message, iv, b"") + expected_ciphertext_blob = _serialize_ciphertext_blob( + ciphertext=Ciphertext(key_id=key_id, iv=iv, ciphertext=ciphertext, tag=tag) + ) + + plaintext = aws_client.kms.decrypt( + KeyId=key_id, + CiphertextBlob=expected_ciphertext_blob, + EncryptionAlgorithm=algo, + )["Plaintext"] + assert plaintext == message + + @markers.aws.only_localstack + def test_create_custom_key_asymmetric(self, kms_create_key, aws_client): + crypto_key = ec.generate_private_key(ec.SECP256K1()) + raw_private_key = crypto_key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + raw_public_key = crypto_key.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + custom_key_material = raw_private_key + + custom_key_tag_value = base64.b64encode(custom_key_material).decode("utf-8") + + key_spec = "ECC_SECG_P256K1" + key_usage = "SIGN_VERIFY" + + key_id = kms_create_key( + Tags=[{"TagKey": "_custom_key_material_", "TagValue": custom_key_tag_value}], + KeySpec=key_spec, + KeyUsage=key_usage, + )["KeyId"] + + public_key = aws_client.kms.get_public_key(KeyId=key_id)["PublicKey"] + + assert public_key == raw_public_key + + # Do a sign/verify cycle + plaintext = b"test message 123 !%$@ 1234567890" + + signature = crypto_key.sign( + plaintext, + ec.ECDSA(hashes.SHA256()), + ) + + verify_data = aws_client.kms.verify( + Message=plaintext, + Signature=signature, + MessageType="RAW", + SigningAlgorithm="ECDSA_SHA_256", + KeyId=key_id, + ) + assert verify_data["SignatureValid"] + + @markers.aws.validated + def test_get_key_in_different_region( + self, kms_client_for_region, kms_create_key, snapshot, region_name, secondary_region_name + ): + snapshot.add_transformer( + snapshot.transform.regex(secondary_region_name, "") + ) + client_region = region_name + key_region = secondary_region_name + us_east_1_kms_client = kms_client_for_region(client_region) + us_west_2_kms_client = kms_client_for_region(key_region) + + response = kms_create_key(region_name=key_region, Description="test key 123") + key_id = response["KeyId"] + key_arn = response["Arn"] + + with pytest.raises(ClientError) as e: + us_east_1_kms_client.describe_key(KeyId=key_id) + + snapshot.match("describe-key-diff-region-with-id", e.value.response) + + with pytest.raises(ClientError) as e: + us_east_1_kms_client.describe_key(KeyId=key_arn) + + snapshot.match("describe-key-diff-region-with-arn", e.value.response) + + response = us_west_2_kms_client.describe_key(KeyId=key_id) + snapshot.match("describe-key-same-specific-region-with-id", response) + + response = us_west_2_kms_client.describe_key(KeyId=key_arn) + snapshot.match("describe-key-same-specific-region-with-arn", response) + + @markers.aws.validated + def test_get_key_does_not_exist(self, kms_create_key, snapshot, aws_client): + # we create a real key to base our fake key ARN on, so we have real account ID and same region + response = kms_create_key(Description="test key 123") + key_id = response["KeyId"] + key_arn = response["Arn"] + + # valid UUID + fake_key_uuid = "134f2428-cec1-4b25-a1ae-9048164dba47" + fake_key_arn = key_arn.replace(key_id, fake_key_uuid) + + with pytest.raises(ClientError) as e: + aws_client.kms.describe_key(KeyId=fake_key_uuid) + + snapshot.match("describe-nonexistent-key-with-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.kms.describe_key(KeyId=fake_key_arn) + + snapshot.match("describe-nonexistent-with-arn", e.value.response) + + # valid multi region keyId + fake_mr_key_uuid = "mrk-d3b95762d3b95762d3b95762d3b95762" + + with pytest.raises(ClientError) as e: + aws_client.kms.describe_key(KeyId=fake_mr_key_uuid) + + snapshot.match("describe-key-with-valid-id-mrk", e.value.response) + + @markers.aws.validated + def test_get_key_invalid_uuid(self, snapshot, aws_client): + # valid regular KeyId format + # "134f2428-cec1-4b25-a1ae-9048164dba47" + with pytest.raises(ClientError) as e: + aws_client.kms.describe_key(KeyId="fake-key-id") + snapshot.match("describe-key-with-invalid-uuid", e.value.response) + + # this UUID is valid for python + # "134f2428cec14b25a1ae9048164dba47" + with pytest.raises(ClientError) as e: + aws_client.kms.describe_key(KeyId="134f2428cec14b25a1ae9048164dba47") + snapshot.match("describe-key-with-invalid-uuid-2", e.value.response) + + # valid MultiRegionKey KeyId format + # "mrk-e4b2ea8ffcd4461e9821c9b9521a8896" + with pytest.raises(ClientError) as e: + aws_client.kms.describe_key(KeyId="mrk-fake-key-id") + snapshot.match("describe-key-with-invalid-uuid-mrk", e.value.response) + + @markers.aws.validated + def test_list_keys(self, kms_create_key, aws_client): + created_key = kms_create_key() + next_token = None + while True: + kwargs = {"nextToken": next_token} if next_token else {} + response = aws_client.kms.list_keys(**kwargs) + for key in response["Keys"]: + assert key["KeyId"] + assert key["KeyArn"] + if key["KeyId"] == created_key["KeyId"]: + assert key["KeyArn"] == created_key["Arn"] + if "nextToken" not in response: + break + next_token = response["nextToken"] + + @markers.aws.validated + def test_schedule_and_cancel_key_deletion(self, kms_create_key, aws_client): + key_id = kms_create_key()["KeyId"] + aws_client.kms.schedule_key_deletion(KeyId=key_id) + result = aws_client.kms.describe_key(KeyId=key_id) + assert result["KeyMetadata"]["Enabled"] is False + assert result["KeyMetadata"]["KeyState"] == "PendingDeletion" + assert result["KeyMetadata"]["DeletionDate"] + + aws_client.kms.cancel_key_deletion(KeyId=key_id) + result = aws_client.kms.describe_key(KeyId=key_id) + assert result["KeyMetadata"]["Enabled"] is False + assert result["KeyMetadata"]["KeyState"] == "Disabled" + assert not result["KeyMetadata"].get("DeletionDate") + + @markers.aws.validated + def test_disable_and_enable_key(self, kms_create_key, aws_client): + key_id = kms_create_key()["KeyId"] + result = aws_client.kms.describe_key(KeyId=key_id) + assert result["KeyMetadata"]["Enabled"] is True + assert result["KeyMetadata"]["KeyState"] == "Enabled" + + aws_client.kms.disable_key(KeyId=key_id) + result = aws_client.kms.describe_key(KeyId=key_id) + assert result["KeyMetadata"]["Enabled"] is False + assert result["KeyMetadata"]["KeyState"] == "Disabled" + + aws_client.kms.enable_key(KeyId=key_id) + result = aws_client.kms.describe_key(KeyId=key_id) + assert result["KeyMetadata"]["Enabled"] is True + assert result["KeyMetadata"]["KeyState"] == "Enabled" + + # Not sure how useful this test is, as it just fails during key validation, before grant-specific logic kicks in. + @markers.aws.validated + def test_create_grant_with_invalid_key(self, user_arn, aws_client): + with pytest.raises(ClientError) as e: + aws_client.kms.create_grant( + KeyId="invalid", + GranteePrincipal=user_arn, + Operations=["Decrypt", "Encrypt"], + ) + e.match("NotFoundException") + + # Not sure how useful this test is, as it just fails during key validation, before grant-specific logic kicks in. + @markers.aws.validated + def test_list_grants_with_invalid_key(self, aws_client): + with pytest.raises(ClientError) as e: + aws_client.kms.list_grants( + KeyId="invalid", + ) + e.match("NotFoundException") + + @markers.aws.validated + def test_create_grant_with_valid_key(self, kms_key, user_arn, aws_client): + key_id = kms_key["KeyId"] + + grants_before = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + + grant = aws_client.kms.create_grant( + KeyId=key_id, + GranteePrincipal=user_arn, + Operations=["Decrypt", "Encrypt"], + ) + assert "GrantId" in grant + assert "GrantToken" in grant + + grants_after = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + assert len(grants_after) == len(grants_before) + 1 + + @markers.aws.validated + def test_create_grant_with_same_name_two_keys(self, kms_create_key, user_arn, aws_client): + first_key_id = kms_create_key()["KeyId"] + second_key_id = kms_create_key()["KeyId"] + + grant_name = "TestGrantName" + + first_grant = aws_client.kms.create_grant( + KeyId=first_key_id, + GranteePrincipal=user_arn, + Name=grant_name, + Operations=["Decrypt", "DescribeKey"], + ) + assert "GrantId" in first_grant + assert "GrantToken" in first_grant + + second_grant = aws_client.kms.create_grant( + KeyId=second_key_id, + GranteePrincipal=user_arn, + Name=grant_name, + Operations=["Decrypt", "DescribeKey"], + ) + assert "GrantId" in second_grant + assert "GrantToken" in second_grant + + first_grants_after = aws_client.kms.list_grants(KeyId=first_key_id)["Grants"] + assert len(first_grants_after) == 1 + + second_grants_after = aws_client.kms.list_grants(KeyId=second_key_id)["Grants"] + assert len(second_grants_after) == 1 + + @markers.aws.validated + def test_revoke_grant(self, kms_grant_and_key, aws_client): + grant = kms_grant_and_key[0] + key_id = kms_grant_and_key[1]["KeyId"] + grants_before = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + + aws_client.kms.revoke_grant(KeyId=key_id, GrantId=grant["GrantId"]) + + grants_after = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + assert len(grants_after) == len(grants_before) - 1 + + @markers.aws.validated + def test_retire_grant_with_grant_token(self, kms_grant_and_key, aws_client): + grant = kms_grant_and_key[0] + key_id = kms_grant_and_key[1]["KeyId"] + grants_before = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + + aws_client.kms.retire_grant(GrantToken=grant["GrantToken"]) + + grants_after = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + assert len(grants_after) == len(grants_before) - 1 + + @markers.aws.validated + def test_retire_grant_with_grant_id_and_key_id(self, kms_grant_and_key, aws_client): + grant = kms_grant_and_key[0] + key_id = kms_grant_and_key[1]["KeyId"] + grants_before = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + + aws_client.kms.retire_grant(GrantId=grant["GrantId"], KeyId=key_id) + + grants_after = aws_client.kms.list_grants(KeyId=key_id)["Grants"] + assert len(grants_after) == len(grants_before) - 1 + + # Fails against AWS, as the retiring_principal_arn_prefix is invalid there. + @markers.aws.only_localstack + def test_list_retirable_grants(self, kms_create_key, kms_create_grant, aws_client): + retiring_principal_arn_prefix = ( + "arn:aws:kms:eu-central-1:123456789876:key/198a5a78-52c3-489f-ac70-" + ) + right_retiring_principal = retiring_principal_arn_prefix + "000000000001" + wrong_retiring_principal = retiring_principal_arn_prefix + "000000000002" + key_id = kms_create_key()["KeyId"] + right_grant_id = kms_create_grant(KeyId=key_id, RetiringPrincipal=right_retiring_principal)[ + 0 + ] + wrong_grant_id_one = kms_create_grant( + KeyId=key_id, RetiringPrincipal=wrong_retiring_principal + )[0] + wrong_grant_id_two = kms_create_grant(KeyId=key_id)[0] + wrong_grant_ids = [wrong_grant_id_one, wrong_grant_id_two] + + next_token = None + right_grant_found = False + wrong_grant_found = False + while True: + kwargs = {"nextToken": next_token} if next_token else {} + response = aws_client.kms.list_retirable_grants( + RetiringPrincipal=right_retiring_principal, **kwargs + ) + for grant in response["Grants"]: + if grant["GrantId"] == right_grant_id: + right_grant_found = True + if grant["GrantId"] in wrong_grant_ids: + wrong_grant_found = True + if "nextToken" not in response: + break + next_token = response["nextToken"] + + assert right_grant_found + assert not wrong_grant_found + + @pytest.mark.parametrize("number_of_bytes", [12, 44, 91, 1, 1024]) + @markers.aws.validated + def test_generate_random(self, snapshot, number_of_bytes, aws_client): + result = aws_client.kms.generate_random(NumberOfBytes=number_of_bytes) + + plain_text = result.get("Plaintext") + + assert plain_text + assert isinstance(plain_text, bytes) + assert len(plain_text) == number_of_bytes + snapshot.match("result_length", len(plain_text)) + + @pytest.mark.parametrize("number_of_bytes", [None, 0, 1025]) + @markers.aws.validated + def test_generate_random_invalid_number_of_bytes( + self, aws_client_factory, snapshot, number_of_bytes + ): + kms_client = aws_client_factory(config=Config(parameter_validation=False)).kms + + with pytest.raises(ClientError) as e: + kms_client.generate_random(NumberOfBytes=number_of_bytes) + + snapshot.match("generate-random-exc", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Error.Message", + "$..message", + ] + ) + @pytest.mark.parametrize( + "key_spec,sign_algo", + [ + ("RSA_2048", "RSASSA_PSS_SHA_256"), + ("RSA_2048", "RSASSA_PSS_SHA_384"), + ("RSA_2048", "RSASSA_PSS_SHA_512"), + ("RSA_4096", "RSASSA_PKCS1_V1_5_SHA_256"), + ("RSA_4096", "RSASSA_PKCS1_V1_5_SHA_512"), + ("ECC_NIST_P256", "ECDSA_SHA_256"), + ("ECC_NIST_P384", "ECDSA_SHA_384"), + ("ECC_SECG_P256K1", "ECDSA_SHA_256"), + ], + ) + def test_sign_verify(self, kms_create_key, snapshot, key_spec, sign_algo, aws_client): + hash_algo = get_hash_algorithm(sign_algo) + hasher = getattr(hashlib, hash_algo.replace("_", "").lower()) + + plaintext = b"test message !%$@ 1234567890" + digest = hasher(plaintext).digest() + + key_id = kms_create_key(KeyUsage="SIGN_VERIFY", KeySpec=key_spec)["KeyId"] + + kwargs = {"KeyId": key_id, "SigningAlgorithm": sign_algo} + + bad_signature = aws_client.kms.sign(MessageType="RAW", Message="bad", **kwargs)["Signature"] + bad_message = b"bad message 321" + + # Ensure raw messages can be signed and verified + signature = aws_client.kms.sign(MessageType="RAW", Message=plaintext, **kwargs) + snapshot.match("signature", signature) + verification = aws_client.kms.verify( + MessageType="RAW", Signature=signature["Signature"], Message=plaintext, **kwargs + ) + snapshot.match("verification", verification) + assert verification["SignatureValid"] + + # Ensure pre-hashed messages can be signed and verified + signature = aws_client.kms.sign(MessageType="DIGEST", Message=digest, **kwargs) + verification = aws_client.kms.verify( + MessageType="DIGEST", Signature=signature["Signature"], Message=digest, **kwargs + ) + assert verification["SignatureValid"] + + # Ensure bad digest raises during signing + with pytest.raises(ClientError) as exc: + aws_client.kms.sign(MessageType="DIGEST", Message=plaintext, **kwargs) + assert exc.match("ValidationException") + snapshot.match("bad-digest", exc.value.response) + + # Ensure bad signature raises during verify + with pytest.raises(ClientError) as exc: + aws_client.kms.verify( + MessageType="RAW", Signature=bad_signature, Message=plaintext, **kwargs + ) + assert exc.match("KMSInvalidSignatureException") + snapshot.match("bad-signature", exc.value.response) + + # Ensure bad message raises during verify + with pytest.raises(ClientError) as exc: + aws_client.kms.verify( + MessageType="RAW", Signature=signature["Signature"], Message=bad_message, **kwargs + ) + assert exc.match("KMSInvalidSignatureException") + + # Ensure bad digest raises during verify + with pytest.raises(ClientError) as exc: + aws_client.kms.verify( + MessageType="DIGEST", + Signature=signature["Signature"], + Message=bad_message, + **kwargs, + ) + assert exc.match("ValidationException") + + @markers.aws.validated + @pytest.mark.parametrize( + "key_spec,sign_algo", + [ + ("RSA_2048", "RSASSA_PSS_SHA_256"), + ("RSA_2048", "RSASSA_PSS_SHA_384"), + ("RSA_2048", "RSASSA_PSS_SHA_512"), + ("RSA_4096", "RSASSA_PKCS1_V1_5_SHA_256"), + ("RSA_4096", "RSASSA_PKCS1_V1_5_SHA_512"), + ("ECC_NIST_P256", "ECDSA_SHA_256"), + ("ECC_NIST_P384", "ECDSA_SHA_384"), + ("ECC_SECG_P256K1", "ECDSA_SHA_256"), + ], + ) + def test_verify_salt_length(self, aws_client, kms_create_key, key_spec, sign_algo): + plaintext = b"test message !%$@ 1234567890" + + hash_algo = get_hash_algorithm(sign_algo) + hasher = getattr(hashlib, hash_algo.replace("_", "").lower()) + digest = hasher(plaintext).digest() + + key_id = kms_create_key(KeyUsage="SIGN_VERIFY", KeySpec=key_spec)["KeyId"] + public_key = aws_client.kms.get_public_key(KeyId=key_id)["PublicKey"] + key = load_der_public_key(public_key) + + kwargs = {"KeyId": key_id, "SigningAlgorithm": sign_algo} + + for msg_type, message in [("RAW", plaintext), ("DIGEST", digest)]: + signature = aws_client.kms.sign(MessageType=msg_type, Message=message, **kwargs)[ + "Signature" + ] + vargs = get_signature_kwargs(sign_algo, msg_type) + key.verify(signature=signature, data=message, **vargs) + + @markers.aws.validated + def test_invalid_key_usage(self, kms_create_key, aws_client): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="RSA_4096")["KeyId"] + with pytest.raises(ClientError) as exc: + aws_client.kms.sign( + MessageType="RAW", + Message="hello", + KeyId=key_id, + SigningAlgorithm="RSASSA_PSS_SHA_256", + ) + assert exc.match("InvalidKeyUsageException") + + key_id = kms_create_key(KeyUsage="SIGN_VERIFY", KeySpec="RSA_4096")["KeyId"] + with pytest.raises(ClientError) as exc: + aws_client.kms.encrypt( + Plaintext="hello", + KeyId=key_id, + EncryptionAlgorithm="RSAES_OAEP_SHA_256", + ) + assert exc.match("InvalidKeyUsageException") + + @pytest.mark.parametrize( + "key_spec,algo", + [ + ("SYMMETRIC_DEFAULT", "SYMMETRIC_DEFAULT"), + ("RSA_2048", "RSAES_OAEP_SHA_256"), + ], + ) + @markers.aws.validated + def test_encrypt_decrypt(self, kms_create_key, key_spec, algo, aws_client): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec=key_spec)["KeyId"] + message = b"test message 123 !%$@ 1234567890" + ciphertext = aws_client.kms.encrypt( + KeyId=key_id, Plaintext=base64.b64encode(message), EncryptionAlgorithm=algo + )["CiphertextBlob"] + plaintext = aws_client.kms.decrypt( + KeyId=key_id, CiphertextBlob=ciphertext, EncryptionAlgorithm=algo + )["Plaintext"] + assert base64.b64decode(plaintext) == message + + @pytest.mark.parametrize( + "key_spec,algo", + [ + ("SYMMETRIC_DEFAULT", "SYMMETRIC_DEFAULT"), + ("RSA_2048", "RSAES_OAEP_SHA_256"), + ], + ) + @markers.aws.validated + def test_re_encrypt(self, kms_create_key, key_spec, algo, aws_client, snapshot): + message = b"test message 123 !%$@ 1234567890" + source_key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec=key_spec)["KeyId"] + destination_key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec=key_spec)["KeyId"] + # Encrypt the message using the source key + ciphertext = aws_client.kms.encrypt( + KeyId=source_key_id, Plaintext=base64.b64encode(message), EncryptionAlgorithm=algo + )["CiphertextBlob"] + # Re-encrypt the previously encryted message using the destination key + result = aws_client.kms.re_encrypt( + SourceKeyId=source_key_id, + DestinationKeyId=destination_key_id, + CiphertextBlob=ciphertext, + SourceEncryptionAlgorithm=algo, + DestinationEncryptionAlgorithm=algo, + ) + snapshot.match("test_re_encrypt", result) + # Decrypt using the source key + source_key_plaintext = aws_client.kms.decrypt( + KeyId=source_key_id, CiphertextBlob=ciphertext, EncryptionAlgorithm=algo + )["Plaintext"] + # Decrypt using the destination key + destination_key_plaintext = aws_client.kms.decrypt( + KeyId=destination_key_id, + CiphertextBlob=result["CiphertextBlob"], + EncryptionAlgorithm=algo, + )["Plaintext"] + # Both source and destination plain texts should match the original + assert base64.b64decode(source_key_plaintext) == message + assert base64.b64decode(destination_key_plaintext) == message + + @markers.aws.validated + def test_re_encrypt_incorrect_source_key(self, kms_create_key, aws_client, snapshot): + algo = "SYMMETRIC_DEFAULT" + message = b"test message 123 !%$@ 1234567890" + source_key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec=algo)["KeyId"] + ciphertext = aws_client.kms.encrypt( + KeyId=source_key_id, Plaintext=base64.b64encode(message), EncryptionAlgorithm=algo + )["CiphertextBlob"] + invalid_key_id = kms_create_key( + Description="test hmac key", + KeySpec="HMAC_224", + KeyUsage="GENERATE_VERIFY_MAC", + )["KeyId"] + + with pytest.raises(ClientError) as exc: + aws_client.kms.re_encrypt( + SourceKeyId=invalid_key_id, + DestinationKeyId=invalid_key_id, + CiphertextBlob=ciphertext, + SourceEncryptionAlgorithm=algo, + DestinationEncryptionAlgorithm=algo, + ) + snapshot.match("incorrect-source-key", exc.value.response) + + @markers.aws.validated + def test_re_encrypt_invalid_destination_key(self, kms_create_key, aws_client): + algo = "SYMMETRIC_DEFAULT" + message = b"test message 123 !%$@ 1234567890" + source_key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec=algo)["KeyId"] + ciphertext = aws_client.kms.encrypt( + KeyId=source_key_id, Plaintext=base64.b64encode(message), EncryptionAlgorithm=algo + )["CiphertextBlob"] + invalid_key_id = kms_create_key(KeyUsage="SIGN_VERIFY", KeySpec="ECC_NIST_P256")["KeyId"] + with pytest.raises(ClientError) as exc: + aws_client.kms.re_encrypt( + SourceKeyId=source_key_id, + DestinationKeyId=invalid_key_id, + CiphertextBlob=ciphertext, + SourceEncryptionAlgorithm=algo, + DestinationEncryptionAlgorithm=algo, + ) + # TODO: Determine where 'context.operation.name' is being set to 'ReEncryptTo' as the expected AWS operation name is 'ReEncrypt' + # Then enable the snapshot check + # snapshot.match("invalid-destination-key-usage", exc.value.response) + assert exc.match("InvalidKeyUsageException") + + @pytest.mark.parametrize( + "key_spec,algo", + [ + ("RSA_2048", "RSAES_OAEP_SHA_1"), + ("RSA_2048", "RSAES_OAEP_SHA_256"), + ("RSA_3072", "RSAES_OAEP_SHA_1"), + ("RSA_3072", "RSAES_OAEP_SHA_256"), + ("RSA_4096", "RSAES_OAEP_SHA_1"), + ("RSA_4096", "RSAES_OAEP_SHA_256"), + ], + ) + @markers.aws.validated + def test_symmetric_encrypt_offline_decrypt_online( + self, kms_create_key, key_spec, algo, aws_client + ): + key = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec=key_spec) + response = aws_client.kms.get_public_key(KeyId=key["KeyId"]) + + pub_key = response.get("PublicKey") + + public_key = load_der_public_key(pub_key) + message = b"test message 123 !%$@ 1234567890" + ciphertext = public_key.encrypt( + base64.b64encode(message), + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + plaintext = aws_client.kms.decrypt( + KeyId=key["KeyId"], CiphertextBlob=ciphertext, EncryptionAlgorithm=algo + )["Plaintext"] + assert base64.b64decode(plaintext) == message + + @markers.aws.validated + @pytest.mark.parametrize( + "key_spec,algo", + [ + ("RSA_2048", "RSAES_OAEP_SHA_1"), + ("RSA_2048", "RSAES_OAEP_SHA_256"), + ("RSA_3072", "RSAES_OAEP_SHA_1"), + ("RSA_3072", "RSAES_OAEP_SHA_256"), + ("RSA_4096", "RSAES_OAEP_SHA_1"), + ("RSA_4096", "RSAES_OAEP_SHA_256"), + ], + ) + def test_encrypt_validate_plaintext_size_per_key_type( + self, kms_create_key, key_spec, algo, snapshot, aws_client + ): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec=key_spec)["KeyId"] + message = b"more than 500 bytes and less than 4096 bytes" * 20 + with pytest.raises(ClientError) as e: + aws_client.kms.encrypt( + KeyId=key_id, Plaintext=base64.b64encode(message), EncryptionAlgorithm=algo + ) + snapshot.match("generate-random-exc", e.value.response) + + @markers.aws.validated + def test_get_public_key(self, kms_create_key, aws_client): + key = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="RSA_2048") + response = aws_client.kms.get_public_key(KeyId=key["KeyId"]) + assert response.get("KeyId") == key["Arn"] + assert response.get("KeySpec") == key["KeySpec"] + assert response.get("KeyUsage") == key["KeyUsage"] + assert response.get("PublicKey") + + @markers.aws.validated + def test_describe_and_list_sign_key(self, kms_create_key, aws_client): + response = kms_create_key(KeyUsage="SIGN_VERIFY", CustomerMasterKeySpec="ECC_NIST_P256") + + key_id = response["KeyId"] + describe_response = aws_client.kms.describe_key(KeyId=key_id)["KeyMetadata"] + assert describe_response["KeyId"] == key_id + assert key_id in _get_all_key_ids(aws_client.kms) + + @markers.aws.validated + def test_import_key_symmetric(self, kms_create_key, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Description")) + key = kms_create_key(Origin="EXTERNAL") + snapshot.match("created-key", key) + key_id = key["KeyId"] + + # try key before importing + plaintext = b"test content 123 !#" + with pytest.raises(ClientError) as e: + aws_client.kms.encrypt(Plaintext=plaintext, KeyId=key_id) + snapshot.match("encrypt-before-import-error", e.value.response) + + # get key import params + params = aws_client.kms.get_parameters_for_import( + KeyId=key_id, WrappingAlgorithm="RSAES_OAEP_SHA_256", WrappingKeySpec="RSA_2048" + ) + assert params["KeyId"] == key["Arn"] + assert params["ImportToken"] + assert params["PublicKey"] + assert isinstance(params["ParametersValidTo"], datetime) + + # create 256 bit symmetric key (import_key_material(..) works with symmetric keys, as per the docs) + symmetric_key = bytes(getrandbits(8) for _ in range(32)) + assert len(symmetric_key) == 32 + + # import symmetric key (key material) into KMS + public_key = load_der_public_key(params["PublicKey"]) + encrypted_key = public_key.encrypt( + symmetric_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None + ), + ) + describe_key_before_import = aws_client.kms.describe_key(KeyId=key_id) + snapshot.match("describe-key-before-import", describe_key_before_import) + + with pytest.raises(ClientError) as e: + aws_client.kms.import_key_material( + KeyId=key_id, + ImportToken=params["ImportToken"], + EncryptedKeyMaterial=encrypted_key, + ) + snapshot.match("import-expiring-key-without-valid-to", e.value.response) + aws_client.kms.import_key_material( + KeyId=key_id, + ImportToken=params["ImportToken"], + EncryptedKeyMaterial=encrypted_key, + ExpirationModel="KEY_MATERIAL_DOES_NOT_EXPIRE", + ) + describe_key_after_import = aws_client.kms.describe_key(KeyId=key_id) + snapshot.match("describe-key-after-import", describe_key_after_import) + + # use key to encrypt/decrypt data + encrypt_result = aws_client.kms.encrypt(Plaintext=plaintext, KeyId=key_id) + api_decrypted = aws_client.kms.decrypt( + CiphertextBlob=encrypt_result["CiphertextBlob"], KeyId=key_id + ) + assert api_decrypted["Plaintext"] == plaintext + + aws_client.kms.delete_imported_key_material(KeyId=key_id) + describe_key_after_deleted_import = aws_client.kms.describe_key(KeyId=key_id) + snapshot.match("describe-key-after-deleted-import", describe_key_after_deleted_import) + + with pytest.raises(ClientError) as e: + aws_client.kms.encrypt(Plaintext=plaintext, KeyId=key_id) + snapshot.match("encrypt-after-delete-error", e.value.response) + + @markers.aws.validated + def test_import_key_asymmetric(self, kms_create_key, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Description")) + key = kms_create_key(Origin="EXTERNAL", KeySpec="ECC_NIST_P256", KeyUsage="SIGN_VERIFY") + snapshot.match("created-key", key) + key_id = key["KeyId"] + + crypto_key = ec.generate_private_key(ec.SECP256R1()) + raw_private_key = crypto_key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + raw_public_key = crypto_key.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + plaintext = b"test content 123 !#" + + # get key import params + params = aws_client.kms.get_parameters_for_import( + KeyId=key_id, WrappingAlgorithm="RSAES_OAEP_SHA_256", WrappingKeySpec="RSA_2048" + ) + + # import asymmetric key (key material) into KMS + public_key = load_der_public_key(params["PublicKey"]) + encrypted_key = public_key.encrypt( + raw_private_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None + ), + ) + describe_key_before_import = aws_client.kms.describe_key(KeyId=key_id) + snapshot.match("describe-key-before-import", describe_key_before_import) + + aws_client.kms.import_key_material( + KeyId=key_id, + ImportToken=params["ImportToken"], + EncryptedKeyMaterial=encrypted_key, + ExpirationModel="KEY_MATERIAL_DOES_NOT_EXPIRE", + ) + describe_key_after_import = aws_client.kms.describe_key(KeyId=key_id) + snapshot.match("describe-key-after-import", describe_key_after_import) + + # Check whether public key is derived correctly + get_public_key_after_import = aws_client.kms.get_public_key(KeyId=key_id) + assert get_public_key_after_import["PublicKey"] == raw_public_key + + # Do a sign/verify cycle + signed_data = aws_client.kms.sign( + Message=plaintext, MessageType="RAW", SigningAlgorithm="ECDSA_SHA_256", KeyId=key_id + ) + verify_data = aws_client.kms.verify( + Message=plaintext, + Signature=signed_data["Signature"], + MessageType="RAW", + SigningAlgorithm="ECDSA_SHA_256", + KeyId=key_id, + ) + assert verify_data["SignatureValid"] + + aws_client.kms.delete_imported_key_material(KeyId=key_id) + describe_key_after_deleted_import = aws_client.kms.describe_key(KeyId=key_id) + snapshot.match("describe-key-after-deleted-import", describe_key_after_deleted_import) + + @markers.aws.validated + def test_list_aliases_of_key(self, kms_create_key, kms_create_alias, aws_client): + aliased_key_id = kms_create_key()["KeyId"] + comparison_key_id = kms_create_key()["KeyId"] + + alias_name = f"alias/{short_uid()}" + kms_create_alias(AliasName=alias_name, TargetKeyId=aliased_key_id) + + assert _get_alias(aws_client.kms, alias_name, aliased_key_id) is not None + assert _get_alias(aws_client.kms, alias_name, comparison_key_id) is None + + @markers.aws.validated + def test_all_types_of_key_id_can_be_used_for_encryption( + self, kms_create_key, kms_create_alias, aws_client + ): + def get_alias_arn_by_alias_name(kms_client, alias_name): + for response in kms_client.get_paginator("list_aliases").paginate(KeyId=key_id): + for alias_list_entry in response["Aliases"]: + if alias_list_entry["AliasName"] == alias_name: + return alias_list_entry["AliasArn"] + return None + + key_metadata = kms_create_key() + key_arn = key_metadata["Arn"] + key_id = key_metadata["KeyId"] + alias_name = kms_create_alias(TargetKeyId=key_id) + alias_arn = get_alias_arn_by_alias_name(aws_client.kms, alias_name) + assert alias_arn + aws_client.kms.encrypt(KeyId=key_arn, Plaintext="encrypt-me") + aws_client.kms.encrypt(KeyId=key_id, Plaintext="encrypt-me") + aws_client.kms.encrypt(KeyId=alias_arn, Plaintext="encrypt-me") + aws_client.kms.encrypt(KeyId=alias_name, Plaintext="encrypt-me") + + @markers.aws.validated + def test_create_multi_region_key(self, kms_create_key, snapshot): + snapshot.add_transformer(snapshot.transform.kms_api()) + key = kms_create_key(MultiRegion=True, Description="test multi region key") + assert key["KeyId"].startswith("mrk-") + snapshot.match("create_multi_region_key", key) + + @markers.aws.validated + def test_non_multi_region_keys_should_not_have_multi_region_properties( + self, kms_create_key, snapshot + ): + snapshot.add_transformer(snapshot.transform.kms_api()) + key = kms_create_key(MultiRegion=False, Description="test non multi region key") + assert not key["KeyId"].startswith("mrk-") + snapshot.match("non_multi_region_keys_should_not_have_multi_region_properties", key) + + @pytest.mark.skipif( + not in_default_partition(), reason="Test not applicable in non-default partitions" + ) + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..KeyMetadata.Enabled", + "$..KeyMetadata.KeyState", + "$..ReplicaKeyMetadata.Enabled", + "$..ReplicaKeyMetadata.KeyState", + "$..ReplicaPolicy", # not implemented + ], + ) + def test_replicate_key( + self, + kms_client_for_region, + kms_create_key, + kms_replicate_key, + snapshot, + region_name, + secondary_region_name, + ): + region_to_replicate_from = region_name + region_to_replicate_to = secondary_region_name + us_east_1_kms_client = kms_client_for_region(region_to_replicate_from) + us_west_1_kms_client = kms_client_for_region(region_to_replicate_to) + + key_id = kms_create_key( + region_name=region_to_replicate_from, + MultiRegion=True, + Description="test replicated key", + )["KeyId"] + + with pytest.raises(ClientError) as e: + us_west_1_kms_client.describe_key(KeyId=key_id) + snapshot.match("describe-key-from-different-region", e.value.response) + + response = kms_replicate_key( + region_from=region_to_replicate_from, KeyId=key_id, ReplicaRegion=region_to_replicate_to + ) + snapshot.match("replicate-key", response) + # assert response.get("ReplicaKeyMetadata") + # describe original key with the client from its region + response = us_east_1_kms_client.describe_key(KeyId=key_id) + snapshot.match("describe-key-from-region", response) + + # describe replicated key + response = us_west_1_kms_client.describe_key(KeyId=key_id) + snapshot.match("describe-replicated-key", response) + + @markers.aws.validated + def test_update_key_description(self, kms_create_key, aws_client): + old_description = "old_description" + new_description = "new_description" + key = kms_create_key(Description=old_description) + key_id = key["KeyId"] + assert ( + aws_client.kms.describe_key(KeyId=key_id)["KeyMetadata"]["Description"] + == old_description + ) + result = aws_client.kms.update_key_description(KeyId=key_id, Description=new_description) + assert "ResponseMetadata" in result + assert ( + aws_client.kms.describe_key(KeyId=key_id)["KeyMetadata"]["Description"] + == new_description + ) + + @markers.aws.validated + def test_key_rotation_status(self, kms_key, aws_client): + key_id = kms_key["KeyId"] + # According to AWS docs, supposed to be False by default. + assert aws_client.kms.get_key_rotation_status(KeyId=key_id)["KeyRotationEnabled"] is False + aws_client.kms.enable_key_rotation(KeyId=key_id) + assert aws_client.kms.get_key_rotation_status(KeyId=key_id)["KeyRotationEnabled"] is True + aws_client.kms.disable_key_rotation(KeyId=key_id) + assert aws_client.kms.get_key_rotation_status(KeyId=key_id)["KeyRotationEnabled"] is False + + @markers.aws.validated + def test_key_rotations_encryption_decryption(self, kms_create_key, aws_client, snapshot): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="SYMMETRIC_DEFAULT")["KeyId"] + message = b"test message 123 !%$@ 1234567890" + + ciphertext = aws_client.kms.encrypt( + KeyId=key_id, + Plaintext=base64.b64encode(message), + EncryptionAlgorithm="SYMMETRIC_DEFAULT", + )["CiphertextBlob"] + + deciphered_text_before = aws_client.kms.decrypt( + KeyId=key_id, + CiphertextBlob=ciphertext, + EncryptionAlgorithm="SYMMETRIC_DEFAULT", + )["Plaintext"] + + aws_client.kms.rotate_key_on_demand(KeyId=key_id) + + deciphered_text_after = aws_client.kms.decrypt( + KeyId=key_id, + CiphertextBlob=ciphertext, + EncryptionAlgorithm="SYMMETRIC_DEFAULT", + )["Plaintext"] + + assert deciphered_text_after == deciphered_text_before + + # checking for the exception + bad_ciphertext = ciphertext + b"bad_data" + + with pytest.raises(ClientError) as e: + aws_client.kms.decrypt( + KeyId=key_id, + CiphertextBlob=bad_ciphertext, + EncryptionAlgorithm="SYMMETRIC_DEFAULT", + ) + + snapshot.match("bad-ciphertext", e.value) + + @markers.aws.validated + def test_key_rotations_limits(self, kms_create_key, aws_client, snapshot): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="SYMMETRIC_DEFAULT")["KeyId"] + + def _assert_on_demand_rotation_completed(): + response = aws_client.kms.get_key_rotation_status(KeyId=key_id) + return "OnDemandRotationStartDate" not in response + + for _ in range(ON_DEMAND_ROTATION_LIMIT): + aws_client.kms.rotate_key_on_demand(KeyId=key_id) + assert poll_condition( + condition=_assert_on_demand_rotation_completed, timeout=10, interval=1 + ) + + with pytest.raises(ClientError) as e: + aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + def test_rotate_key_on_demand_modifies_key_material(self, kms_create_key, aws_client, snapshot): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="SYMMETRIC_DEFAULT")["KeyId"] + message = b"test message 123 !%$@ 1234567890" + + ciphertext_before = aws_client.kms.encrypt( + KeyId=key_id, + Plaintext=base64.b64encode(message), + EncryptionAlgorithm="SYMMETRIC_DEFAULT", + )["CiphertextBlob"] + + rotate_on_demand_response = aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("rotate-on-demand-response", rotate_on_demand_response) + + ciphertext_after = aws_client.kms.encrypt( + KeyId=key_id, + Plaintext=base64.b64encode(message), + EncryptionAlgorithm="SYMMETRIC_DEFAULT", + )["CiphertextBlob"] + + assert ciphertext_before != ciphertext_after + + @markers.aws.validated + def test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_disabled( + self, kms_key, aws_client, snapshot + ): + key_id = kms_key["KeyId"] + + rotate_on_demand_response = aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("rotate-on-demand-response", rotate_on_demand_response) + + def _assert_on_demand_rotation_start_date_not_present(): + response = aws_client.kms.get_key_rotation_status(KeyId=key_id) + return "OnDemandRotationStartDate" not in response + + assert poll_condition( + condition=_assert_on_demand_rotation_start_date_not_present, timeout=10, interval=1 + ) + + rotation_status_response = aws_client.kms.get_key_rotation_status(KeyId=key_id) + snapshot.match("rotation-status-response-after-rotation", rotation_status_response) + + @markers.aws.validated + def test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_enabled( + self, kms_key, aws_client, snapshot + ): + key_id = kms_key["KeyId"] + + aws_client.kms.enable_key_rotation(KeyId=key_id) + rotation_status_response_before = aws_client.kms.get_key_rotation_status(KeyId=key_id) + + rotate_on_demand_response = aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("rotate-on-demand-response", rotate_on_demand_response) + + rotation_status_response_after = aws_client.kms.get_key_rotation_status(KeyId=key_id) + assert ( + rotation_status_response_after["NextRotationDate"] + == rotation_status_response_before["NextRotationDate"] + ) + + def _assert_on_demand_rotation_start_date_not_present(): + response = aws_client.kms.get_key_rotation_status(KeyId=key_id) + return "OnDemandRotationStartDate" not in response + + assert poll_condition( + condition=_assert_on_demand_rotation_start_date_not_present, timeout=10, interval=1 + ) + + rotation_status_response = aws_client.kms.get_key_rotation_status(KeyId=key_id) + snapshot.match("rotation-status-response-after-rotation", rotation_status_response) + + @markers.aws.validated + def test_rotate_key_on_demand_raises_error_given_key_is_disabled( + self, kms_create_key, aws_client, snapshot + ): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="RSA_4096")["KeyId"] + aws_client.kms.disable_key(KeyId=key_id) + + with pytest.raises(ClientError) as e: + aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + def test_rotate_key_on_demand_raises_error_given_key_that_does_not_exist( + self, aws_client, snapshot + ): + key_id = "1234abcd-12ab-34cd-56ef-1234567890ab" + + with pytest.raises(ClientError) as e: + aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..message", + ], + ) + def test_rotate_key_on_demand_raises_error_given_non_symmetric_key( + self, kms_create_key, aws_client, snapshot + ): + key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="RSA_4096")["KeyId"] + + with pytest.raises(ClientError) as e: + aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + def test_rotate_key_on_demand_raises_error_given_key_with_imported_key_material( + self, kms_create_key, aws_client, snapshot + ): + key_id = kms_create_key(Origin="EXTERNAL")["KeyId"] + + with pytest.raises(ClientError) as e: + aws_client.kms.rotate_key_on_demand(KeyId=key_id) + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize("rotation_period_in_days", [90, 180]) + def test_key_enable_rotation_status( + self, + kms_key, + aws_client, + rotation_period_in_days, + snapshot, + ): + key_id = kms_key["KeyId"] + aws_client.kms.enable_key_rotation( + KeyId=key_id, RotationPeriodInDays=rotation_period_in_days + ) + result = aws_client.kms.get_key_rotation_status(KeyId=key_id) + snapshot.match("match_response", result) + + @markers.aws.validated + def test_create_list_delete_alias(self, kms_create_alias, aws_client): + alias_name = f"alias/{short_uid()}" + assert _get_alias(aws_client.kms, alias_name) is None + kms_create_alias(AliasName=alias_name) + assert _get_alias(aws_client.kms, alias_name) is not None + aws_client.kms.delete_alias(AliasName=alias_name) + assert _get_alias(aws_client.kms, alias_name) is None + + @markers.aws.validated + def test_update_alias(self, kms_create_key, kms_create_alias, aws_client): + alias_name = f"alias/{short_uid()}" + old_key_id = kms_create_key()["KeyId"] + kms_create_alias(AliasName=alias_name, TargetKeyId=old_key_id) + alias = _get_alias(aws_client.kms, alias_name, old_key_id) + assert alias is not None + assert alias["TargetKeyId"] == old_key_id + + new_key_id = kms_create_key()["KeyId"] + aws_client.kms.update_alias(AliasName=alias_name, TargetKeyId=new_key_id) + alias = _get_alias(aws_client.kms, alias_name, new_key_id) + assert alias is not None + assert alias["TargetKeyId"] == new_key_id + + @markers.aws.validated + def test_get_put_list_key_policies(self, kms_create_key, aws_client, account_id): + base_policy = { + "Version": "2012-10-17", + "Id": "key-default-1", + "Statement": [ + { + "Sid": "This is the default key policy", + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + "Action": "kms:*", + "Resource": "*", + }, + { + "Sid": "This is some additional stuff to look special", + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + "Action": "kms:*", + "Resource": "*", + }, + ], + } + policy_one = base_policy.copy() + policy_one["Statement"][1]["Action"] = "kms:ListAliases" + policy_one = json.dumps(policy_one) + policy_two = base_policy.copy() + policy_two["Statement"][1]["Action"] = "kms:ListGrants" + policy_two = json.dumps(policy_two) + + key_id = kms_create_key(Policy=policy_one)["KeyId"] + # AWS currently supports only the default policy, so just a fixed response. + response = aws_client.kms.list_key_policies(KeyId=key_id) + assert response.get("PolicyNames") == ["default"] + assert response.get("Truncated") is False + + key_policy = aws_client.kms.get_key_policy(KeyId=key_id, PolicyName="default")["Policy"] + # AWS policy string has newlines littered in the response. The JSON load/dump sanitises the policy string. + assert json.dumps(json.loads(key_policy)) == policy_one + + aws_client.kms.put_key_policy(KeyId=key_id, PolicyName="default", Policy=policy_two) + + key_policy = aws_client.kms.get_key_policy(KeyId=key_id, PolicyName="default")["Policy"] + assert json.dumps(json.loads(key_policy)) == policy_two + + @markers.aws.validated + def test_cant_use_disabled_or_deleted_keys(self, kms_create_key, aws_client): + key_id = kms_create_key(KeySpec="SYMMETRIC_DEFAULT", KeyUsage="ENCRYPT_DECRYPT")["KeyId"] + aws_client.kms.generate_data_key(KeyId=key_id, KeySpec="AES_256") + + aws_client.kms.disable_key(KeyId=key_id) + with pytest.raises(ClientError) as e: + aws_client.kms.generate_data_key(KeyId=key_id, KeySpec="AES_256") + e.match("DisabledException") + + aws_client.kms.schedule_key_deletion(KeyId=key_id) + with pytest.raises(ClientError) as e: + aws_client.kms.generate_data_key(KeyId=key_id, KeySpec="AES_256") + e.match("KMSInvalidStateException") + + @markers.aws.validated + def test_cant_delete_deleted_key(self, kms_create_key, aws_client): + key_id = kms_create_key()["KeyId"] + aws_client.kms.schedule_key_deletion(KeyId=key_id) + + with pytest.raises(ClientError) as e: + aws_client.kms.schedule_key_deletion(KeyId=key_id) + e.match("KMSInvalidStateException") + + @markers.aws.validated + def test_hmac_create_key(self, kms_client_for_region, kms_create_key, snapshot, region_name): + kms_client = kms_client_for_region(region_name) + key_ids_before = _get_all_key_ids(kms_client) + + response = kms_create_key( + region_name=region_name, + Description="test key", + KeySpec="HMAC_256", + KeyUsage="GENERATE_VERIFY_MAC", + ) + key_id = response["KeyId"] + snapshot.match("create-hmac-key", response) + + assert key_id not in key_ids_before + key_ids_after = _get_all_key_ids(kms_client) + assert key_id in key_ids_after + + response = kms_client.describe_key(KeyId=key_id)["KeyMetadata"] + snapshot.match("describe-key", response) + + @markers.aws.validated + def test_hmac_create_key_invalid_operations(self, kms_create_key, snapshot, region_name): + with pytest.raises(ClientError) as e: + kms_create_key(Description="test HMAC key without key usage", KeySpec="HMAC_256") + snapshot.match("create-hmac-key-without-key-usage", e.value.response) + + with pytest.raises(ClientError) as e: + kms_create_key(Description="test invalid HMAC spec", KeySpec="HMAC_random") + snapshot.match("create-hmac-key-invalid-spec", e.value.response) + + with pytest.raises(ClientError) as e: + kms_create_key( + region_name=region_name, + Description="test invalid HMAC spec", + KeySpec="HMAC_256", + KeyUsage="RANDOM", + ) + snapshot.match("create-hmac-key-invalid-key-usage", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "key_spec,mac_algo", + [ + ("HMAC_224", "HMAC_SHA_224"), + ("HMAC_256", "HMAC_SHA_256"), + ("HMAC_384", "HMAC_SHA_384"), + ("HMAC_512", "HMAC_SHA_512"), + ], + ) + def test_generate_and_verify_mac( + self, kms_create_key, key_spec, mac_algo, snapshot, aws_client + ): + key_id = kms_create_key( + Description="test hmac key", + KeySpec=key_spec, + KeyUsage="GENERATE_VERIFY_MAC", + )["KeyId"] + + generate_mac_response = aws_client.kms.generate_mac( + KeyId=key_id, + Message="some important message", + MacAlgorithm=mac_algo, + ) + snapshot.match("generate-mac", generate_mac_response) + + verify_mac_response = aws_client.kms.verify_mac( + KeyId=key_id, + Message="some important message", + MacAlgorithm=mac_algo, + Mac=generate_mac_response["Mac"], + ) + snapshot.match("verify-mac", verify_mac_response) + + # test generate mac with invalid key-id + with pytest.raises(ClientError) as e: + aws_client.kms.generate_mac( + KeyId="key_id", + Message="some important message", + MacAlgorithm=mac_algo, + ) + snapshot.match("generate-mac-invalid-key-id", e.value.response) + + # test verify mac with invalid key-id + with pytest.raises(ClientError) as e: + aws_client.kms.verify_mac( + KeyId="key_id", + Message="some important message", + MacAlgorithm=mac_algo, + Mac=generate_mac_response["Mac"], + ) + snapshot.match("verify-mac-invalid-key-id", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "key_spec,mac_algo", + [ + ("HMAC_224", "HMAC_SHA_256"), + ("HMAC_256", "INVALID"), + ], + ) + def test_invalid_generate_mac(self, kms_create_key, key_spec, mac_algo, snapshot, aws_client): + key_id = kms_create_key( + Description="test hmac key", + KeySpec=key_spec, + KeyUsage="GENERATE_VERIFY_MAC", + )["KeyId"] + + with pytest.raises(ClientError) as e: + aws_client.kms.generate_mac( + KeyId=key_id, + Message="some important message", + MacAlgorithm=mac_algo, + ) + snapshot.match("generate-mac", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..message"]) + @pytest.mark.parametrize( + "key_spec,mac_algo,verify_msg", + [ + ("HMAC_256", "HMAC_SHA_256", "some different important message"), + ("HMAC_256", "HMAC_SHA_512", "some important message"), + ("HMAC_256", "INVALID", "some important message"), + ], + ) + def test_invalid_verify_mac( + self, kms_create_key, key_spec, mac_algo, verify_msg, snapshot, aws_client + ): + key_id = kms_create_key( + Description="test hmac key", + KeySpec=key_spec, + KeyUsage="GENERATE_VERIFY_MAC", + )["KeyId"] + + generate_mac_response = aws_client.kms.generate_mac( + KeyId=key_id, + Message="some important message", + MacAlgorithm="HMAC_SHA_256", + ) + snapshot.match("generate-mac", generate_mac_response) + + with pytest.raises(ClientError) as e: + aws_client.kms.verify_mac( + KeyId=key_id, + Message=verify_msg, + MacAlgorithm=mac_algo, + Mac=generate_mac_response["Mac"], + ) + snapshot.match("verify-mac", e.value.response) + + @markers.aws.validated + def test_error_messaging_for_invalid_keys(self, aws_client, kms_create_key, snapshot): + hmac_key_id = kms_create_key( + Description="test key hmac", + KeySpec="HMAC_224", + KeyUsage="GENERATE_VERIFY_MAC", + )["KeyId"] + + encrypt_decrypt_key_id = kms_create_key(Description="test key encrypt decrypt")["KeyId"] + + sign_verify_key_id = kms_create_key( + Description="test key sign verify", KeyUsage="SIGN_VERIFY", KeySpec="RSA_2048" + )["KeyId"] + + # test generate mac with invalid key id + with pytest.raises(ClientError) as e: + aws_client.kms.generate_mac( + KeyId=encrypt_decrypt_key_id, + Message="some important message", + MacAlgorithm="HMAC_SHA_224", + ) + snapshot.match("generate-mac-invalid-key-id", e.value.response) + + # test create signature for a message with invalid key id + kwargs = {"KeyId": hmac_key_id, "SigningAlgorithm": "RSASSA_PSS_SHA_256"} + with pytest.raises(ClientError) as e: + aws_client.kms.sign(MessageType="RAW", Message="test message 123!@#", **kwargs) + snapshot.match("sign-invalid-key-id", e.value.response) + + # test verify signature for a message with invalid key id + with pytest.raises(ClientError) as e: + aws_client.kms.verify( + MessageType="RAW", + Signature=b"random text", + Message="test message", + KeyId=encrypt_decrypt_key_id, + SigningAlgorithm="ECDSA_SHA_256", + ) + snapshot.match("verify-invalid-key-id", e.value.response) + + # test encrypting a text with invalid key id + with pytest.raises(ClientError) as e: + aws_client.kms.encrypt(Plaintext="test message 123!@#", KeyId=sign_verify_key_id) + snapshot.match("encrypt-invalid-key-id", e.value.response) + + # test decrypting a text with invalid key id + ciphertext_blob = aws_client.kms.encrypt( + Plaintext="test message 123!@#", KeyId=encrypt_decrypt_key_id + )["CiphertextBlob"] + with pytest.raises(ClientError) as e: + aws_client.kms.decrypt(CiphertextBlob=ciphertext_blob, KeyId=hmac_key_id) + snapshot.match("decrypt-invalid-key-id", e.value.response) + + @markers.aws.validated + def test_plaintext_size_for_encrypt(self, kms_create_key, snapshot, aws_client): + key_id = kms_create_key()["KeyId"] + message = b"test message 123 !%$@ 1234567890" + + with pytest.raises(ClientError) as e: + aws_client.kms.encrypt(KeyId=key_id, Plaintext=base64.b64encode(message * 100)) + snapshot.match("invalid-plaintext-size-encrypt", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..message"]) + def test_encrypt_decrypt_encryption_context(self, kms_create_key, snapshot, aws_client): + key_id = kms_create_key()["KeyId"] + message = b"test message 123 !%$@ 1234567890" + encryption_context = {"context-key": "context-value"} + algo = "SYMMETRIC_DEFAULT" + + encrypt_response = aws_client.kms.encrypt( + KeyId=key_id, + Plaintext=base64.b64encode(message), + EncryptionAlgorithm=algo, + EncryptionContext=encryption_context, + ) + snapshot.match("encrypt_response", encrypt_response) + ciphertext = encrypt_response["CiphertextBlob"] + + decrypt_response = aws_client.kms.decrypt( + KeyId=key_id, + CiphertextBlob=ciphertext, + EncryptionAlgorithm=algo, + EncryptionContext=encryption_context, + ) + snapshot.match("decrypt_response_with_encryption_context", decrypt_response) + + with pytest.raises(ClientError) as e: + aws_client.kms.decrypt( + KeyId=key_id, + CiphertextBlob=ciphertext, + EncryptionAlgorithm=algo, + ) + snapshot.match("decrypt_response_without_encryption_context", e.value.response) + + @markers.aws.validated + def test_get_parameters_for_import(self, kms_create_key, snapshot, aws_client): + sign_verify_key = kms_create_key( + KeyUsage="SIGN_VERIFY", KeySpec="ECC_NIST_P256", Origin="EXTERNAL" + ) + params_sign_verify = aws_client.kms.get_parameters_for_import( + KeyId=sign_verify_key["KeyId"], + WrappingAlgorithm="RSAES_OAEP_SHA_256", + WrappingKeySpec="RSA_4096", + ) + assert params_sign_verify["KeyId"] == sign_verify_key["Arn"] + assert params_sign_verify["ImportToken"] + assert params_sign_verify["PublicKey"] + assert isinstance(params_sign_verify["ParametersValidTo"], datetime) + + encrypt_decrypt_key = kms_create_key() + with pytest.raises(ClientError) as e: + aws_client.kms.get_parameters_for_import( + KeyId=encrypt_decrypt_key["KeyId"], + WrappingAlgorithm="RSAES_OAEP_SHA_256", + WrappingKeySpec="RSA_4096", + ) + snapshot.match("response-error", e.value.response) + + @markers.aws.validated + def test_derive_shared_secret(self, kms_create_key, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value("SharedSecret", reference_replacement=False) + ) + + # Create two keys and derive the shared secret + key1 = kms_create_key(KeySpec="ECC_NIST_P256", KeyUsage="KEY_AGREEMENT") + pub_key1 = aws_client.kms.get_public_key(KeyId=key1["KeyId"])["PublicKey"] + + key2 = kms_create_key(KeySpec="ECC_NIST_P256", KeyUsage="KEY_AGREEMENT") + pub_key2 = aws_client.kms.get_public_key(KeyId=key2["KeyId"])["PublicKey"] + + secret1 = aws_client.kms.derive_shared_secret( + KeyId=key1["KeyId"], + KeyAgreementAlgorithm="ECDH", + PublicKey=pub_key2, + ) + + snapshot.match("response", secret1) + + # Check the two derived shared secrets are equal + secret2 = aws_client.kms.derive_shared_secret( + KeyId=key2["KeyId"], + KeyAgreementAlgorithm="ECDH", + PublicKey=pub_key1, + ) + + assert secret1["SharedSecret"] == secret2["SharedSecret"] + + # Create a key with invalid key usage + key3 = kms_create_key(KeySpec="ECC_NIST_P256", KeyUsage="SIGN_VERIFY") + with pytest.raises(ClientError) as e: + aws_client.kms.derive_shared_secret( + KeyId=key3["KeyId"], KeyAgreementAlgorithm="ECDH", PublicKey=pub_key2 + ) + snapshot.match("response-invalid-key-usage", e.value.response) + + # Create a key with invalid key spec + with pytest.raises(ClientError) as e: + kms_create_key(KeySpec="RSA_2048", KeyUsage="KEY_AGREEMENT") + snapshot.match("response-invalid-key-spec", e.value.response) + + # Create a key with invalid key agreement algorithm + with pytest.raises(ClientError) as e: + aws_client.kms.derive_shared_secret( + KeyId=key1["KeyId"], KeyAgreementAlgorithm="INVALID", PublicKey=pub_key2 + ) + snapshot.match("response-invalid-key-agreement-algo", e.value.response) + + # Create a symmetric and try to derive the shared secret + key4 = kms_create_key() + with pytest.raises(ClientError) as e: + aws_client.kms.derive_shared_secret( + KeyId=key4["KeyId"], KeyAgreementAlgorithm="ECDH", PublicKey=pub_key2 + ) + snapshot.match("response-invalid-key", e.value.response) + + # Call derive shared secret function with invalid public key + with pytest.raises(ClientError) as e: + aws_client.kms.derive_shared_secret( + KeyId=key1["KeyId"], KeyAgreementAlgorithm="ECDH", PublicKey=b"InvalidPublicKey" + ) + snapshot.match("response-invalid-public-key", e.value.response) + + +class TestKMSMultiAccounts: + @markers.aws.needs_fixing + # TODO: this test could not work against AWS, we need to assign proper permissions to the user/resources + def test_cross_accounts_access( + self, aws_client, secondary_aws_client, kms_create_key, user_arn + ): + # Create the keys in the primary AWS account. They will only be referred to by their ARNs hereon + key_arn_1 = kms_create_key()["Arn"] + key_arn_2 = kms_create_key(KeyUsage="SIGN_VERIFY", KeySpec="RSA_4096")["Arn"] + key_arn_3 = kms_create_key(KeyUsage="GENERATE_VERIFY_MAC", KeySpec="HMAC_512")["Arn"] + + # Create client in secondary account and attempt to run operations with the above keys + client = secondary_aws_client.kms + + # Cross-account access is supported for following operations in KMS: + # - CreateGrant + # - DescribeKey + # - GetKeyRotationStatus + # - GetPublicKey + # - ListGrants + # - RetireGrant + # - RevokeGrant + + response = client.create_grant( + KeyId=key_arn_1, + GranteePrincipal=user_arn, + Operations=["Decrypt", "Encrypt"], + ) + grant_token = response["GrantToken"] + + response = client.create_grant( + KeyId=key_arn_2, + GranteePrincipal=user_arn, + Operations=["Sign", "Verify"], + ) + grant_id = response["GrantId"] + + assert client.describe_key(KeyId=key_arn_1)["KeyMetadata"] + + assert client.get_key_rotation_status(KeyId=key_arn_1) + + assert client.get_public_key(KeyId=key_arn_1) + + assert client.list_grants(KeyId=key_arn_1)["Grants"] + + assert client.retire_grant(GrantToken=grant_token) + + assert client.revoke_grant(GrantId=grant_id, KeyId=key_arn_2) + + # And additionally, the following cryptographic operations: + # - Decrypt + # - Encrypt + # - GenerateDataKey + # - GenerateDataKeyPair + # - GenerateDataKeyPairWithoutPlaintext + # - GenerateDataKeyWithoutPlaintext + # - GenerateMac + # - ReEncrypt + # - Sign + # - Verify + # - VerifyMac + + assert client.generate_data_key(KeyId=key_arn_1) + + assert client.generate_data_key_without_plaintext(KeyId=key_arn_1) + + assert client.generate_data_key_pair(KeyId=key_arn_1, KeyPairSpec="RSA_2048") + + assert client.generate_data_key_pair_without_plaintext( + KeyId=key_arn_1, KeyPairSpec="RSA_2048" + ) + + plaintext = "hello" + ciphertext = client.encrypt(KeyId=key_arn_1, Plaintext="hello")["CiphertextBlob"] + + response = client.decrypt(CiphertextBlob=ciphertext, KeyId=key_arn_1) + assert plaintext == to_str(response["Plaintext"]) + + message = "world" + signature = client.sign( + KeyId=key_arn_2, + MessageType="RAW", + Message=message, + SigningAlgorithm="RSASSA_PKCS1_V1_5_SHA_256", + )["Signature"] + + assert client.verify( + KeyId=key_arn_2, + Signature=signature, + Message=message, + SigningAlgorithm="RSASSA_PKCS1_V1_5_SHA_256", + )["SignatureValid"] + + mac = client.generate_mac(KeyId=key_arn_3, Message=message, MacAlgorithm="HMAC_SHA_512")[ + "Mac" + ] + + assert client.verify_mac( + KeyId=key_arn_3, Message=message, MacAlgorithm="HMAC_SHA_512", Mac=mac + )["MacValid"] + + +class TestKMSGenerateKeys: + @pytest.fixture(autouse=True) + def generate_key_transformers(self, snapshot): + snapshot.add_transformer(snapshot.transform.resource_name()) + + @markers.aws.validated + def test_generate_data_key_pair_without_plaintext(self, kms_key, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value("PrivateKeyCiphertextBlob", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PublicKey", reference_replacement=False) + ) + + key_id = kms_key["KeyId"] + result = aws_client.kms.generate_data_key_pair_without_plaintext( + KeyId=key_id, KeyPairSpec="RSA_2048" + ) + snapshot.match("generate-data-key-pair-without-plaintext", result) + + @markers.aws.validated + def test_generate_data_key_pair(self, kms_key, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value("PrivateKeyCiphertextBlob", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PrivateKeyPlaintext", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PublicKey", reference_replacement=False) + ) + + key_id = kms_key["KeyId"] + result = aws_client.kms.generate_data_key_pair(KeyId=key_id, KeyPairSpec="RSA_2048") + snapshot.match("generate-data-key-pair", result) + + # assert correct value of encrypted key + decrypted = aws_client.kms.decrypt( + CiphertextBlob=result["PrivateKeyCiphertextBlob"], KeyId=key_id + ) + assert decrypted["Plaintext"] == result["PrivateKeyPlaintext"] + + @markers.aws.validated + def test_generate_data_key(self, kms_key, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value("CiphertextBlob", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("Plaintext", reference_replacement=False) + ) + + key_id = kms_key["KeyId"] + # LocalStack currently doesn't act on KeySpec or on NumberOfBytes params, but one of them has to be set. + result = aws_client.kms.generate_data_key(KeyId=key_id, KeySpec="AES_256") + snapshot.match("generate-data-key-result", result) + + # assert correct value of encrypted key + decrypted = aws_client.kms.decrypt(CiphertextBlob=result["CiphertextBlob"], KeyId=key_id) + assert decrypted["Plaintext"] == result["Plaintext"] + + @markers.aws.validated + def test_generate_data_key_without_plaintext(self, kms_key, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value("CiphertextBlob", reference_replacement=False) + ) + key_id = kms_key["KeyId"] + # LocalStack currently doesn't act on KeySpec or on NumberOfBytes params, but one of them has to be set. + result = aws_client.kms.generate_data_key_without_plaintext(KeyId=key_id, KeySpec="AES_256") + snapshot.match("generate-data-key-without-plaintext", result) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + def test_encryption_context_generate_data_key(self, kms_key, aws_client, snapshot): + encryption_context = {"context-key": "context-value"} + key_id = kms_key["KeyId"] + result = aws_client.kms.generate_data_key( + KeyId=key_id, KeySpec="AES_256", EncryptionContext=encryption_context + ) + + with pytest.raises(ClientError) as e: + aws_client.kms.decrypt(CiphertextBlob=result["CiphertextBlob"], KeyId=key_id) + snapshot.match("decrypt-without-encryption-context", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + def test_encryption_context_generate_data_key_without_plaintext( + self, kms_key, aws_client, snapshot + ): + encryption_context = {"context-key": "context-value"} + key_id = kms_key["KeyId"] + result = aws_client.kms.generate_data_key_without_plaintext( + KeyId=key_id, KeySpec="AES_256", EncryptionContext=encryption_context + ) + + with pytest.raises(ClientError) as e: + aws_client.kms.decrypt(CiphertextBlob=result["CiphertextBlob"], KeyId=key_id) + snapshot.match("decrypt-without-encryption-context", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..message"]) + def test_encryption_context_generate_data_key_pair(self, kms_key, aws_client, snapshot): + encryption_context = {"context-key": "context-value"} + key_id = kms_key["KeyId"] + result = aws_client.kms.generate_data_key_pair( + KeyId=key_id, KeyPairSpec="RSA_2048", EncryptionContext=encryption_context + ) + + with pytest.raises(ClientError) as e: + aws_client.kms.decrypt(CiphertextBlob=result["PrivateKeyCiphertextBlob"], KeyId=key_id) + snapshot.match("decrypt-without-encryption-context", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..message"]) + def test_encryption_context_generate_data_key_pair_without_plaintext( + self, kms_key, aws_client, snapshot + ): + encryption_context = {"context-key": "context-value"} + key_id = kms_key["KeyId"] + result = aws_client.kms.generate_data_key_pair_without_plaintext( + KeyId=key_id, KeyPairSpec="RSA_2048", EncryptionContext=encryption_context + ) + + with pytest.raises(ClientError) as e: + aws_client.kms.decrypt(CiphertextBlob=result["PrivateKeyCiphertextBlob"], KeyId=key_id) + snapshot.match("decrypt-without-encryption-context", e.value.response) + + @markers.aws.validated + def test_generate_data_key_pair_dry_run(self, kms_key, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value("PrivateKeyCiphertextBlob", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PrivateKeyPlaintext", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PublicKey", reference_replacement=False) + ) + + key_id = kms_key["KeyId"] + + with pytest.raises(ClientError) as exc: + aws_client.kms.generate_data_key_pair(KeyId=key_id, KeyPairSpec="RSA_2048", DryRun=True) + + err = exc.value.response + snapshot.match("dryrun_exception", err) + + @markers.aws.validated + def test_generate_data_key_pair_without_plaintext_dry_run(self, kms_key, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value("PrivateKeyCiphertextBlob", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value("PublicKey", reference_replacement=False) + ) + + key_id = kms_key["KeyId"] + aws_client.kms.generate_data_key_pair_without_plaintext( + KeyId=key_id, KeyPairSpec="RSA_2048" + ) + + with pytest.raises(ClientError) as exc: + aws_client.kms.generate_data_key_pair_without_plaintext( + KeyId=key_id, KeyPairSpec="RSA_2048", DryRun=True + ) + + err = exc.value.response + snapshot.match("dryrun_exception", err) diff --git a/tests/aws/services/kms/test_kms.snapshot.json b/tests/aws/services/kms/test_kms.snapshot.json new file mode 100644 index 0000000000000..0d4f5ff03be92 --- /dev/null +++ b/tests/aws/services/kms/test_kms.snapshot.json @@ -0,0 +1,2290 @@ +{ + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[None]": { + "recorded-date": "13-04-2023, 11:29:52", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "NumberOfBytes is required." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[0]": { + "recorded-date": "13-04-2023, 11:29:53", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '0' at 'numberOfBytes' failed to satisfy constraint: Member must have value greater than or equal to 1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[1025]": { + "recorded-date": "13-04-2023, 11:29:54", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1025' at 'numberOfBytes' failed to satisfy constraint: Member must have value less than or equal to 1024" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_2048-RSAES_OAEP_SHA_1]": { + "recorded-date": "13-04-2023, 11:30:27", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Algorithm RSAES_OAEP_SHA_1 and key spec RSA_2048 cannot encrypt data larger than 214 bytes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_2048-RSAES_OAEP_SHA_256]": { + "recorded-date": "13-04-2023, 11:30:28", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Algorithm RSAES_OAEP_SHA_256 and key spec RSA_2048 cannot encrypt data larger than 190 bytes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_3072-RSAES_OAEP_SHA_1]": { + "recorded-date": "13-04-2023, 11:30:29", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Algorithm RSAES_OAEP_SHA_1 and key spec RSA_3072 cannot encrypt data larger than 342 bytes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_3072-RSAES_OAEP_SHA_256]": { + "recorded-date": "13-04-2023, 11:30:30", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Algorithm RSAES_OAEP_SHA_256 and key spec RSA_3072 cannot encrypt data larger than 318 bytes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_4096-RSAES_OAEP_SHA_1]": { + "recorded-date": "13-04-2023, 11:30:31", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Algorithm RSAES_OAEP_SHA_1 and key spec RSA_4096 cannot encrypt data larger than 470 bytes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_4096-RSAES_OAEP_SHA_256]": { + "recorded-date": "13-04-2023, 11:30:32", + "recorded-content": { + "generate-random-exc": { + "Error": { + "Code": "ValidationException", + "Message": "Algorithm RSAES_OAEP_SHA_256 and key spec RSA_4096 cannot encrypt data larger than 446 bytes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[12]": { + "recorded-date": "13-04-2023, 11:29:50", + "recorded-content": { + "result_length": 12 + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[44]": { + "recorded-date": "13-04-2023, 11:29:50", + "recorded-content": { + "result_length": 44 + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[91]": { + "recorded-date": "13-04-2023, 11:29:51", + "recorded-content": { + "result_length": 91 + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[1]": { + "recorded-date": "13-04-2023, 11:29:51", + "recorded-content": { + "result_length": 1 + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[1024]": { + "recorded-date": "13-04-2023, 11:29:51", + "recorded-content": { + "result_length": 1024 + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key": { + "recorded-date": "13-04-2023, 11:29:30", + "recorded-content": { + "describe-key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "test key 123", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_in_different_region": { + "recorded-date": "14-06-2024, 13:34:43", + "recorded-content": { + "describe-key-diff-region-with-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Key 'arn::kms::111111111111:key/' does not exist" + }, + "message": "Key 'arn::kms::111111111111:key/' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-key-diff-region-with-arn": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid arn " + }, + "message": "Invalid arn ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-key-same-specific-region-with-id": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "test key 123", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-key-same-specific-region-with-arn": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "test key 123", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_does_not_exist": { + "recorded-date": "13-04-2023, 11:29:34", + "recorded-content": { + "describe-nonexistent-key-with-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Key '' does not exist" + }, + "message": "Key '' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-nonexistent-with-arn": { + "Error": { + "Code": "NotFoundException", + "Message": "Key '' does not exist" + }, + "message": "Key '' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-key-with-valid-id-mrk": { + "Error": { + "Code": "NotFoundException", + "Message": "Key 'arn::kms::111111111111:key/mrk-d3b95762d3b95762d3b95762d3b95762' does not exist" + }, + "message": "Key 'arn::kms::111111111111:key/mrk-d3b95762d3b95762d3b95762d3b95762' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_invalid_uuid": { + "recorded-date": "07-11-2023, 14:05:57", + "recorded-content": { + "describe-key-with-invalid-uuid": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'fake-key-id'" + }, + "message": "Invalid keyId 'fake-key-id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-key-with-invalid-uuid-2": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId '134f2428cec14b25a1ae9048164dba47'" + }, + "message": "Invalid keyId '134f2428cec14b25a1ae9048164dba47'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-key-with-invalid-uuid-mrk": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'mrk-fake-key-id'" + }, + "message": "Invalid keyId 'mrk-fake-key-id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_replicate_key": { + "recorded-date": "11-04-2024, 15:51:52", + "recorded-content": { + "describe-key-from-different-region": { + "Error": { + "Code": "NotFoundException", + "Message": "Key 'arn::kms:ap-southeast-1:111111111111:key/' does not exist" + }, + "message": "Key 'arn::kms:ap-southeast-1:111111111111:key/' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "replicate-key": { + "ReplicaKeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms:ap-southeast-1:111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": false, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Creating", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": true, + "MultiRegionConfiguration": { + "MultiRegionKeyType": "REPLICA", + "PrimaryKey": { + "Arn": "arn::kms::111111111111:key/", + "Region": "" + }, + "ReplicaKeys": [ + { + "Arn": "arn::kms:ap-southeast-1:111111111111:key/", + "Region": "ap-southeast-1" + } + ] + }, + "Origin": "AWS_KMS" + }, + "ReplicaPolicy": { + "Version": "2012-10-17", + "Id": "key-default-1", + "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:root" + }, + "Action": "kms:*", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-key-from-region": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "test replicated key", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": true, + "MultiRegionConfiguration": { + "MultiRegionKeyType": "PRIMARY", + "PrimaryKey": { + "Arn": "arn::kms::111111111111:key/", + "Region": "" + }, + "ReplicaKeys": [ + { + "Arn": "arn::kms:ap-southeast-1:111111111111:key/", + "Region": "ap-southeast-1" + } + ] + }, + "Origin": "AWS_KMS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-replicated-key": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms:ap-southeast-1:111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": false, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Creating", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": true, + "MultiRegionConfiguration": { + "MultiRegionKeyType": "REPLICA", + "PrimaryKey": { + "Arn": "arn::kms::111111111111:key/", + "Region": "" + }, + "ReplicaKeys": [ + { + "Arn": "arn::kms:ap-southeast-1:111111111111:key/", + "Region": "ap-southeast-1" + } + ] + }, + "Origin": "AWS_KMS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_alias": { + "recorded-date": "13-04-2023, 11:29:27", + "recorded-content": { + "create_alias": { + "Error": { + "Code": "ValidationException", + "Message": "Alias must start with the prefix \"alias/\". Please see https://docs.aws.amazon.com/kms/latest/developerguide/kms-alias.html" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_256]": { + "recorded-date": "13-04-2023, 11:29:59", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "RSASSA_PSS_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "RSASSA_PSS_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm RSASSA_PSS_SHA_256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_384]": { + "recorded-date": "13-04-2023, 11:30:02", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "RSASSA_PSS_SHA_384", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "RSASSA_PSS_SHA_384", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm RSASSA_PSS_SHA_384." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_512]": { + "recorded-date": "13-04-2023, 11:30:05", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "RSASSA_PSS_SHA_512", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "RSASSA_PSS_SHA_512", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm RSASSA_PSS_SHA_512." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_4096-RSASSA_PKCS1_V1_5_SHA_256]": { + "recorded-date": "13-04-2023, 11:30:09", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "RSASSA_PKCS1_V1_5_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "RSASSA_PKCS1_V1_5_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm RSASSA_PKCS1_V1_5_SHA_256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_4096-RSASSA_PKCS1_V1_5_SHA_512]": { + "recorded-date": "13-04-2023, 11:30:12", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "RSASSA_PKCS1_V1_5_SHA_512", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm RSASSA_PKCS1_V1_5_SHA_512." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_NIST_P256-ECDSA_SHA_256]": { + "recorded-date": "13-04-2023, 11:30:15", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "ECDSA_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "ECDSA_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm ECDSA_SHA_256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_NIST_P256-ECDSA_SHA_384]": { + "recorded-date": "16-03-2023, 18:57:59", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_SECG_P256K1-ECDSA_SHA_256]": { + "recorded-date": "13-04-2023, 11:30:22", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "ECDSA_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "ECDSA_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm ECDSA_SHA_256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_SECG_P256K1-ECDSA_SHA_512]": { + "recorded-date": "16-03-2023, 18:58:07", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_NIST_P384-ECDSA_SHA_384]": { + "recorded-date": "13-04-2023, 11:30:19", + "recorded-content": { + "signature": { + "KeyId": "", + "Signature": "", + "SigningAlgorithm": "ECDSA_SHA_384", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verification": { + "KeyId": "", + "SignatureValid": true, + "SigningAlgorithm": "ECDSA_SHA_384", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-digest": { + "Error": { + "Code": "ValidationException", + "Message": "Digest is invalid length for algorithm ECDSA_SHA_384." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "bad-signature": { + "Error": { + "Code": "KMSInvalidSignatureException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_hmac_create_key": { + "recorded-date": "13-04-2023, 11:34:18", + "recorded-content": { + "create-hmac-key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "HMAC_256", + "Description": "test key", + "Enabled": true, + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "HMAC_256", + "KeyState": "Enabled", + "KeyUsage": "GENERATE_VERIFY_MAC", + "MacAlgorithms": [ + "HMAC_SHA_256" + ], + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "describe-key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "HMAC_256", + "Description": "test key", + "Enabled": true, + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "HMAC_256", + "KeyState": "Enabled", + "KeyUsage": "GENERATE_VERIFY_MAC", + "MacAlgorithms": [ + "HMAC_SHA_256" + ], + "MultiRegion": false, + "Origin": "AWS_KMS" + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_hmac_create_key_invalid_operations": { + "recorded-date": "13-04-2023, 11:31:06", + "recorded-content": { + "create-hmac-key-without-key-usage": { + "Error": { + "Code": "ValidationException", + "Message": "You must specify a KeyUsage value for all KMS keys except for symmetric encryption keys." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-hmac-key-invalid-spec": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'HMAC_random' at 'keySpec' failed to satisfy constraint: Member must satisfy enum value set: [RSA_2048, ECC_NIST_P384, ECC_NIST_P256, ECC_NIST_P521, HMAC_384, RSA_3072, ECC_SECG_P256K1, RSA_4096, SYMMETRIC_DEFAULT, HMAC_256, HMAC_224, HMAC_512]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-hmac-key-invalid-key-usage": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'RANDOM' at 'keyUsage' failed to satisfy constraint: Member must satisfy enum value set: [ENCRYPT_DECRYPT, SIGN_VERIFY, GENERATE_VERIFY_MAC]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_224-HMAC_SHA_224]": { + "recorded-date": "07-11-2023, 14:06:46", + "recorded-content": { + "generate-mac": { + "KeyId": "", + "Mac": "", + "MacAlgorithm": "HMAC_SHA_224", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verify-mac": { + "KeyId": "", + "MacAlgorithm": "HMAC_SHA_224", + "MacValid": true, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "generate-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "verify-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_256-HMAC_SHA_256]": { + "recorded-date": "07-11-2023, 14:06:48", + "recorded-content": { + "generate-mac": { + "KeyId": "", + "Mac": "", + "MacAlgorithm": "HMAC_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verify-mac": { + "KeyId": "", + "MacAlgorithm": "HMAC_SHA_256", + "MacValid": true, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "generate-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "verify-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_384-HMAC_SHA_384]": { + "recorded-date": "07-11-2023, 14:06:51", + "recorded-content": { + "generate-mac": { + "KeyId": "", + "Mac": "", + "MacAlgorithm": "HMAC_SHA_384", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verify-mac": { + "KeyId": "", + "MacAlgorithm": "HMAC_SHA_384", + "MacValid": true, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "generate-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "verify-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_512-HMAC_SHA_512]": { + "recorded-date": "07-11-2023, 14:06:52", + "recorded-content": { + "generate-mac": { + "KeyId": "", + "Mac": "", + "MacAlgorithm": "HMAC_SHA_512", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verify-mac": { + "KeyId": "", + "MacAlgorithm": "HMAC_SHA_512", + "MacValid": true, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "generate-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "verify-mac-invalid-key-id": { + "Error": { + "Code": "NotFoundException", + "Message": "Invalid keyId 'key_id'" + }, + "message": "Invalid keyId 'key_id'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_generate_mac[HMAC_224-HMAC_SHA_256]": { + "recorded-date": "13-04-2023, 11:31:14", + "recorded-content": { + "generate-mac": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": "Algorithm HMAC_SHA_256 is incompatible with key spec HMAC_224." + }, + "message": "Algorithm HMAC_SHA_256 is incompatible with key spec HMAC_224.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_generate_mac[HMAC_256-INVALID]": { + "recorded-date": "13-04-2023, 11:31:15", + "recorded-content": { + "generate-mac": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'INVALID' at 'macAlgorithm' failed to satisfy constraint: Member must satisfy enum value set: [HMAC_SHA_384, HMAC_SHA_256, HMAC_SHA_224, HMAC_SHA_512]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-HMAC_SHA_256-some different important message]": { + "recorded-date": "13-04-2023, 11:31:17", + "recorded-content": { + "generate-mac": { + "KeyId": "", + "Mac": "", + "MacAlgorithm": "HMAC_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verify-mac": { + "Error": { + "Code": "KMSInvalidMacException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-HMAC_SHA_512-some important message]": { + "recorded-date": "13-04-2023, 11:31:18", + "recorded-content": { + "generate-mac": { + "KeyId": "", + "Mac": "", + "MacAlgorithm": "HMAC_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verify-mac": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": "Algorithm HMAC_SHA_512 is incompatible with key spec HMAC_256." + }, + "message": "Algorithm HMAC_SHA_512 is incompatible with key spec HMAC_256.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-INVALID-some important message]": { + "recorded-date": "13-04-2023, 11:31:19", + "recorded-content": { + "generate-mac": { + "KeyId": "", + "Mac": "", + "MacAlgorithm": "HMAC_SHA_256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "verify-mac": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'INVALID' at 'macAlgorithm' failed to satisfy constraint: Member must satisfy enum value set: [HMAC_SHA_384, HMAC_SHA_256, HMAC_SHA_224, HMAC_SHA_512]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_import_key_symmetric": { + "recorded-date": "24-01-2024, 10:44:12", + "recorded-content": { + "created-key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": false, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "PendingImport", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "EXTERNAL" + }, + "encrypt-before-import-error": { + "Error": { + "Code": "KMSInvalidStateException", + "Message": "arn::kms::111111111111:key/ is pending import." + }, + "message": "arn::kms::111111111111:key/ is pending import.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-key-before-import": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": false, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "PendingImport", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "EXTERNAL" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "import-expiring-key-without-valid-to": { + "Error": { + "Code": "ValidationException", + "Message": "A validTo date must be set if the ExpirationModel is KEY_MATERIAL_EXPIRES" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe-key-after-import": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "ExpirationModel": "KEY_MATERIAL_DOES_NOT_EXPIRE", + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "EXTERNAL" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-key-after-deleted-import": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": false, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "PendingImport", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "EXTERNAL" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "encrypt-after-delete-error": { + "Error": { + "Code": "KMSInvalidStateException", + "Message": "arn::kms::111111111111:key/ is pending import." + }, + "message": "arn::kms::111111111111:key/ is pending import.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_import_key_asymmetric": { + "recorded-date": "24-01-2024, 10:44:14", + "recorded-content": { + "created-key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "ECC_NIST_P256", + "Description": "", + "Enabled": false, + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "ECC_NIST_P256", + "KeyState": "PendingImport", + "KeyUsage": "SIGN_VERIFY", + "MultiRegion": false, + "Origin": "EXTERNAL", + "SigningAlgorithms": [ + "ECDSA_SHA_256" + ] + }, + "describe-key-before-import": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "ECC_NIST_P256", + "Description": "", + "Enabled": false, + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "ECC_NIST_P256", + "KeyState": "PendingImport", + "KeyUsage": "SIGN_VERIFY", + "MultiRegion": false, + "Origin": "EXTERNAL", + "SigningAlgorithms": [ + "ECDSA_SHA_256" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-key-after-import": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "ECC_NIST_P256", + "Description": "", + "Enabled": true, + "ExpirationModel": "KEY_MATERIAL_DOES_NOT_EXPIRE", + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "ECC_NIST_P256", + "KeyState": "Enabled", + "KeyUsage": "SIGN_VERIFY", + "MultiRegion": false, + "Origin": "EXTERNAL", + "SigningAlgorithms": [ + "ECDSA_SHA_256" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-key-after-deleted-import": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "ECC_NIST_P256", + "Description": "", + "Enabled": false, + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "ECC_NIST_P256", + "KeyState": "PendingImport", + "KeyUsage": "SIGN_VERIFY", + "MultiRegion": false, + "Origin": "EXTERNAL", + "SigningAlgorithms": [ + "ECDSA_SHA_256" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_error_messaging_for_invalid_keys": { + "recorded-date": "13-04-2023, 11:31:23", + "recorded-content": { + "generate-mac-invalid-key-id": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": " key usage is ENCRYPT_DECRYPT which is not valid for GenerateMac." + }, + "message": " key usage is ENCRYPT_DECRYPT which is not valid for GenerateMac.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "sign-invalid-key-id": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": " key usage is GENERATE_VERIFY_MAC which is not valid for Sign." + }, + "message": " key usage is GENERATE_VERIFY_MAC which is not valid for Sign.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "verify-invalid-key-id": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": " key usage is ENCRYPT_DECRYPT which is not valid for Verify." + }, + "message": " key usage is ENCRYPT_DECRYPT which is not valid for Verify.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "encrypt-invalid-key-id": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": " key usage is SIGN_VERIFY which is not valid for Encrypt." + }, + "message": " key usage is SIGN_VERIFY which is not valid for Encrypt.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "decrypt-invalid-key-id": { + "Error": { + "Code": "IncorrectKeyException", + "Message": "The key ID in the request does not identify a CMK that can perform this operation." + }, + "message": "The key ID in the request does not identify a CMK that can perform this operation.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_plaintext_size_for_encrypt": { + "recorded-date": "13-04-2023, 11:31:24", + "recorded-content": { + "invalid-plaintext-size-encrypt": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value at 'plaintext' failed to satisfy constraint: Member must have length less than or equal to 4096" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_without_plaintext": { + "recorded-date": "11-05-2023, 14:40:23", + "recorded-content": { + "generate-data-key-pair-without-plaintext": { + "KeyId": "arn::kms::111111111111:key/", + "KeyPairSpec": "RSA_2048", + "PrivateKeyCiphertextBlob": "private-key-ciphertext-blob", + "PublicKey": "public-key", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair": { + "recorded-date": "11-05-2023, 14:40:23", + "recorded-content": { + "generate-data-key-pair": { + "KeyId": "arn::kms::111111111111:key/", + "KeyPairSpec": "RSA_2048", + "PrivateKeyCiphertextBlob": "private-key-ciphertext-blob", + "PrivateKeyPlaintext": "private-key-plaintext", + "PublicKey": "public-key", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key": { + "recorded-date": "11-05-2023, 14:40:24", + "recorded-content": { + "generate-data-key-result": { + "CiphertextBlob": "ciphertext-blob", + "KeyId": "arn::kms::111111111111:key/", + "Plaintext": "plaintext", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_without_plaintext": { + "recorded-date": "11-05-2023, 14:40:24", + "recorded-content": { + "generate-data-key-without-plaintext": { + "CiphertextBlob": "ciphertext-blob", + "KeyId": "arn::kms::111111111111:key/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_decrypt_encryption_context": { + "recorded-date": "11-05-2023, 22:46:49", + "recorded-content": { + "encrypt_response": { + "CiphertextBlob": "ciphertext-blob", + "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", + "KeyId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "decrypt_response_with_encryption_context": { + "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", + "KeyId": "", + "Plaintext": "plaintext", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "decrypt_response_without_encryption_context": { + "Error": { + "Code": "InvalidCiphertextException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key": { + "recorded-date": "16-06-2023, 12:47:28", + "recorded-content": { + "decrypt-without-encryption-context": { + "Error": { + "Code": "InvalidCiphertextException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_without_plaintext": { + "recorded-date": "16-06-2023, 12:47:45", + "recorded-content": { + "decrypt-without-encryption-context": { + "Error": { + "Code": "InvalidCiphertextException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_pair": { + "recorded-date": "16-06-2023, 17:51:27", + "recorded-content": { + "decrypt-without-encryption-context": { + "Error": { + "Code": "InvalidCiphertextException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_pair_without_plaintext": { + "recorded-date": "16-06-2023, 17:51:43", + "recorded-content": { + "decrypt-without-encryption-context": { + "Error": { + "Code": "InvalidCiphertextException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_parameters_for_import": { + "recorded-date": "25-10-2023, 12:29:27", + "recorded-content": { + "response-error": { + "Error": { + "Code": "UnsupportedOperationException", + "Message": " origin is AWS_KMS which is not valid for this operation." + }, + "message": " origin is AWS_KMS which is not valid for this operation.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_multi_region_key": { + "recorded-date": "11-04-2024, 14:46:26", + "recorded-content": { + "create_multi_region_key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "test multi region key", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": true, + "MultiRegionConfiguration": { + "MultiRegionKeyType": "PRIMARY", + "PrimaryKey": { + "Arn": "arn::kms::111111111111:key/", + "Region": "" + }, + "ReplicaKeys": [] + }, + "Origin": "AWS_KMS" + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_non_multi_region_keys_should_not_have_multi_region_properties": { + "recorded-date": "11-04-2024, 15:47:59", + "recorded-content": { + "non_multi_region_keys_should_not_have_multi_region_properties": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "test non multi region key", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_derive_shared_secret": { + "recorded-date": "25-12-2024, 14:45:02", + "recorded-content": { + "response": { + "KeyAgreementAlgorithm": "ECDH", + "KeyId": "", + "KeyOrigin": "AWS_KMS", + "SharedSecret": "shared-secret", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-invalid-key-usage": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": " key usage is SIGN_VERIFY which is not valid for DeriveSharedSecret." + }, + "message": " key usage is SIGN_VERIFY which is not valid for DeriveSharedSecret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "response-invalid-key-spec": { + "Error": { + "Code": "ValidationException", + "Message": "KeyUsage KEY_AGREEMENT is not compatible with KeySpec RSA_2048" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "response-invalid-key-agreement-algo": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'INVALID' at 'keyAgreementAlgorithm' failed to satisfy constraint: Member must satisfy enum value set: [ECDH]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "response-invalid-key": { + "Error": { + "Code": "InvalidKeyUsageException", + "Message": " key usage is ENCRYPT_DECRYPT which is not valid for DeriveSharedSecret." + }, + "message": " key usage is ENCRYPT_DECRYPT which is not valid for DeriveSharedSecret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "response-invalid-public-key": { + "Error": { + "Code": "ValidationException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_existing_key_and_untag": { + "recorded-date": "10-01-2025, 09:39:48", + "recorded-content": { + "list-resource-tags": [ + { + "TagKey": "tag1", + "TagValue": "value1" + }, + { + "TagKey": "tag2", + "TagValue": "value2" + } + ], + "list-resource-tags-after-all-untagged": [] + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_tag_and_untag": { + "recorded-date": "10-01-2025, 09:40:40", + "recorded-content": { + "list-resource-tags": [ + { + "TagKey": "tag1", + "TagValue": "value1" + }, + { + "TagKey": "tag2", + "TagValue": "value2" + } + ], + "list-resource-tags-after-all-untagged": [] + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_untag_key_partially": { + "recorded-date": "10-01-2025, 09:41:02", + "recorded-content": { + "list-resource-tags": [ + { + "TagKey": "tag1", + "TagValue": "value1" + }, + { + "TagKey": "tag2", + "TagValue": "value2" + }, + { + "TagKey": "tag3", + "TagValue": "value3" + } + ], + "list-resource-tags-after-partially-untagged": [ + { + "TagKey": "tag1", + "TagValue": "value1" + }, + { + "TagKey": "tag3", + "TagValue": "value3" + } + ] + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_update_and_add_tags_on_tagged_key": { + "recorded-date": "17-01-2025, 12:25:39", + "recorded-content": { + "list-resource-tags": [ + { + "TagKey": "tag1", + "TagValue": "value1" + }, + { + "TagKey": "tag2", + "TagValue": "value2" + }, + { + "TagKey": "tag3", + "TagValue": "value3" + } + ], + "list-resource-tags-after-tags-updated": [ + { + "TagKey": "tag1", + "TagValue": "value1" + }, + { + "TagKey": "tag2", + "TagValue": "updated_value2" + }, + { + "TagKey": "tag3", + "TagValue": "value3" + }, + { + "TagKey": "tag4", + "TagValue": "value4" + }, + { + "TagKey": "tag5", + "TagValue": "value5" + } + ] + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_key_with_duplicate_tag_keys_raises_error": { + "recorded-date": "17-01-2025, 13:35:08", + "recorded-content": { + "duplicate-tag-keys": { + "Error": { + "Code": "TagException", + "Message": "Duplicate tag keys" + }, + "message": "Duplicate tag keys", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_too_many_tags_raises_error": { + "recorded-date": "21-01-2025, 17:15:38", + "recorded-content": { + "invalid-tag-key": { + "Error": { + "Code": "TagException", + "Message": "Too many tags" + }, + "message": "Too many tags", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_existing_key_with_invalid_tag_key": { + "recorded-date": "21-01-2025, 17:17:25", + "recorded-content": { + "invalid-tag-key": { + "Error": { + "Code": "TagException", + "Message": "Tags beginning with aws: are reserved" + }, + "message": "Tags beginning with aws: are reserved", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_with_long_tag_value_raises_error": { + "recorded-date": "21-01-2025, 17:18:18", + "recorded-content": { + "too-long-tag-value": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv' at 'tags.1.member.tagValue' failed to satisfy constraint: Member must have length less than or equal to 256" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[lowercase_prefix]": { + "recorded-date": "22-01-2025, 13:37:31", + "recorded-content": { + "invalid-tag-key": { + "Error": { + "Code": "TagException", + "Message": "Tags beginning with aws: are reserved" + }, + "message": "Tags beginning with aws: are reserved", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[uppercase_prefix]": { + "recorded-date": "22-01-2025, 13:37:32", + "recorded-content": { + "invalid-tag-key": { + "Error": { + "Code": "TagException", + "Message": "Tags beginning with aws: are reserved" + }, + "message": "Tags beginning with aws: are reserved", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[too_long_key]": { + "recorded-date": "22-01-2025, 13:37:32", + "recorded-content": { + "invalid-tag-key": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'tags.1.member.tagKey' failed to satisfy constraint: Member must have length less than or equal to 128" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_enable_rotation_status[90]": { + "recorded-date": "02-03-2025, 13:34:02", + "recorded-content": { + "match_response": { + "KeyId": "", + "KeyRotationEnabled": true, + "NextRotationDate": "datetime", + "RotationPeriodInDays": 90, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_enable_rotation_status[180]": { + "recorded-date": "02-03-2025, 13:34:03", + "recorded-content": { + "match_response": { + "KeyId": "", + "KeyRotationEnabled": true, + "NextRotationDate": "datetime", + "RotationPeriodInDays": 180, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_modifies_key_material": { + "recorded-date": "08-03-2025, 09:24:16", + "recorded-content": { + "rotate-on-demand-response": { + "KeyId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_disabled": { + "recorded-date": "12-03-2025, 19:05:50", + "recorded-content": { + "rotate-on-demand-response": { + "KeyId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "rotation-status-response-after-rotation": { + "KeyId": "", + "KeyRotationEnabled": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_enabled": { + "recorded-date": "12-03-2025, 19:07:01", + "recorded-content": { + "rotate-on-demand-response": { + "KeyId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "rotation-status-response-after-rotation": { + "KeyId": "", + "KeyRotationEnabled": true, + "NextRotationDate": "datetime", + "RotationPeriodInDays": 365, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_is_disabled": { + "recorded-date": "08-03-2025, 09:26:50", + "recorded-content": { + "error-response": { + "Error": { + "Code": "DisabledException", + "Message": " is disabled." + }, + "message": " is disabled.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_that_does_not_exist": { + "recorded-date": "08-03-2025, 09:27:10", + "recorded-content": { + "error-response": { + "Error": { + "Code": "NotFoundException", + "Message": "Key '' does not exist" + }, + "message": "Key '' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_non_symmetric_key": { + "recorded-date": "08-03-2025, 09:27:44", + "recorded-content": { + "error-response": { + "Error": { + "Code": "UnsupportedOperationException", + "Message": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_with_imported_key_material": { + "recorded-date": "08-03-2025, 09:28:13", + "recorded-content": { + "error-response": { + "Error": { + "Code": "UnsupportedOperationException", + "Message": " origin is EXTERNAL which is not valid for this operation." + }, + "message": " origin is EXTERNAL which is not valid for this operation.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_256]": { + "recorded-date": "02-04-2025, 06:06:52", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_384]": { + "recorded-date": "02-04-2025, 06:06:54", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_512]": { + "recorded-date": "02-04-2025, 06:06:57", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_4096-RSASSA_PKCS1_V1_5_SHA_256]": { + "recorded-date": "02-04-2025, 06:06:59", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_4096-RSASSA_PKCS1_V1_5_SHA_512]": { + "recorded-date": "02-04-2025, 06:07:01", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_NIST_P256-ECDSA_SHA_256]": { + "recorded-date": "02-04-2025, 06:07:03", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_NIST_P384-ECDSA_SHA_384]": { + "recorded-date": "02-04-2025, 06:07:06", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_SECG_P256K1-ECDSA_SHA_256]": { + "recorded-date": "02-04-2025, 06:07:08", + "recorded-content": {} + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotations_encryption_decryption": { + "recorded-date": "03-04-2025, 09:34:48", + "recorded-content": { + "bad-ciphertext": "An error occurred (InvalidCiphertextException) when calling the Decrypt operation: " + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotations_limits": { + "recorded-date": "03-04-2025, 11:10:33", + "recorded-content": { + "error-response": { + "Error": { + "Code": "LimitExceededException", + "Message": "The on-demand rotations limit has been reached for the given keyId. No more on-demand rotations can be performed for this key: " + }, + "message": "The on-demand rotations limit has been reached for the given keyId. No more on-demand rotations can be performed for this key: ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_dry_run": { + "recorded-date": "06-04-2025, 11:54:20", + "recorded-content": { + "dryrun_exception": { + "Error": { + "Code": "DryRunOperationException", + "Message": "The request would have succeeded, but the DryRun option is set." + }, + "message": "The request would have succeeded, but the DryRun option is set.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_without_plaintext_dry_run": { + "recorded-date": "07-04-2025, 17:12:37", + "recorded-content": { + "dryrun_exception": { + "Error": { + "Code": "DryRunOperationException", + "Message": "The request would have succeeded, but the DryRun option is set." + }, + "message": "The request would have succeeded, but the DryRun option is set.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt[SYMMETRIC_DEFAULT-SYMMETRIC_DEFAULT]": { + "recorded-date": "09-06-2025, 12:52:58", + "recorded-content": { + "test_re_encrypt": { + "CiphertextBlob": "ciphertext-blob", + "DestinationEncryptionAlgorithm": "SYMMETRIC_DEFAULT", + "KeyId": "", + "SourceEncryptionAlgorithm": "SYMMETRIC_DEFAULT", + "SourceKeyId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt[RSA_2048-RSAES_OAEP_SHA_256]": { + "recorded-date": "09-06-2025, 12:53:00", + "recorded-content": { + "test_re_encrypt": { + "CiphertextBlob": "ciphertext-blob", + "DestinationEncryptionAlgorithm": "RSAES_OAEP_SHA_256", + "KeyId": "", + "SourceEncryptionAlgorithm": "RSAES_OAEP_SHA_256", + "SourceKeyId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt_incorrect_source_key": { + "recorded-date": "09-06-2025, 12:53:24", + "recorded-content": { + "incorrect-source-key": { + "Error": { + "Code": "IncorrectKeyException", + "Message": "The key ID in the request does not identify a CMK that can perform this operation." + }, + "message": "The key ID in the request does not identify a CMK that can perform this operation.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/kms/test_kms.validation.json b/tests/aws/services/kms/test_kms.validation.json new file mode 100644 index 0000000000000..df19dfe77dbba --- /dev/null +++ b/tests/aws/services/kms/test_kms.validation.json @@ -0,0 +1,347 @@ +{ + "tests/aws/services/kms/test_kms.py::TestKMS::test_all_types_of_key_id_can_be_used_for_encryption": { + "last_validated_date": "2024-04-11T15:53:39+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_cant_delete_deleted_key": { + "last_validated_date": "2024-04-11T15:54:00+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_cant_use_disabled_or_deleted_keys": { + "last_validated_date": "2024-04-11T15:53:59+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_alias": { + "last_validated_date": "2024-04-11T15:52:25+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_grant_with_invalid_key": { + "last_validated_date": "2024-04-11T15:52:39+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_grant_with_same_name_two_keys": { + "last_validated_date": "2024-04-11T15:52:42+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_grant_with_valid_key": { + "last_validated_date": "2024-04-11T15:52:40+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key": { + "last_validated_date": "2024-04-11T15:26:14+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[lowercase_prefix]": { + "last_validated_date": "2025-01-22T13:37:31+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[too_long_key]": { + "last_validated_date": "2025-01-22T13:37:32+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_invalid_tag_key[uppercase_prefix]": { + "last_validated_date": "2025-01-22T13:37:32+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_tag_and_untag": { + "last_validated_date": "2025-01-10T09:40:40+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_key_with_too_many_tags_raises_error": { + "last_validated_date": "2025-01-21T17:15:38+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_list_delete_alias": { + "last_validated_date": "2024-04-11T15:53:50+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_create_multi_region_key": { + "last_validated_date": "2024-04-11T15:53:40+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_derive_shared_secret": { + "last_validated_date": "2024-12-25T14:45:00+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_describe_and_list_sign_key": { + "last_validated_date": "2024-04-11T15:53:27+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_disable_and_enable_key": { + "last_validated_date": "2024-04-11T15:52:38+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_decrypt[RSA_2048-RSAES_OAEP_SHA_256]": { + "last_validated_date": "2024-04-11T15:53:19+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_decrypt[SYMMETRIC_DEFAULT-SYMMETRIC_DEFAULT]": { + "last_validated_date": "2024-04-11T15:53:18+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_decrypt_encryption_context": { + "last_validated_date": "2024-04-11T15:54:22+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_2048-RSAES_OAEP_SHA_1]": { + "last_validated_date": "2024-04-11T15:53:20+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_2048-RSAES_OAEP_SHA_256]": { + "last_validated_date": "2024-04-11T15:53:21+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_3072-RSAES_OAEP_SHA_1]": { + "last_validated_date": "2024-04-11T15:53:22+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_3072-RSAES_OAEP_SHA_256]": { + "last_validated_date": "2024-04-11T15:53:22+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_4096-RSAES_OAEP_SHA_1]": { + "last_validated_date": "2024-04-11T15:53:23+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_encrypt_validate_plaintext_size_per_key_type[RSA_4096-RSAES_OAEP_SHA_256]": { + "last_validated_date": "2024-04-11T15:53:24+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_error_messaging_for_invalid_keys": { + "last_validated_date": "2024-04-11T15:54:19+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_224-HMAC_SHA_224]": { + "last_validated_date": "2024-04-11T15:54:05+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_256-HMAC_SHA_256]": { + "last_validated_date": "2024-04-11T15:54:07+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_384-HMAC_SHA_384]": { + "last_validated_date": "2024-04-11T15:54:09+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_and_verify_mac[HMAC_512-HMAC_SHA_512]": { + "last_validated_date": "2024-04-11T15:54:11+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[1024]": { + "last_validated_date": "2024-04-11T15:52:49+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[12]": { + "last_validated_date": "2024-04-11T15:52:48+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[1]": { + "last_validated_date": "2024-04-11T15:52:49+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[44]": { + "last_validated_date": "2024-04-11T15:52:48+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random[91]": { + "last_validated_date": "2024-04-11T15:52:49+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[0]": { + "last_validated_date": "2024-04-11T15:52:50+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[1025]": { + "last_validated_date": "2024-04-11T15:52:51+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_generate_random_invalid_number_of_bytes[None]": { + "last_validated_date": "2024-04-11T15:52:50+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_does_not_exist": { + "last_validated_date": "2024-04-11T15:52:32+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_in_different_region": { + "last_validated_date": "2024-06-14T13:35:20+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_key_invalid_uuid": { + "last_validated_date": "2024-04-11T15:52:33+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_parameters_for_import": { + "last_validated_date": "2024-04-11T15:54:23+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_public_key": { + "last_validated_date": "2024-04-11T15:53:25+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_get_put_list_key_policies": { + "last_validated_date": "2024-04-11T15:53:55+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_hmac_create_key": { + "last_validated_date": "2024-04-10T20:40:13+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_hmac_create_key_invalid_operations": { + "last_validated_date": "2023-04-13T09:31:06+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_import_key_asymmetric": { + "last_validated_date": "2024-04-11T15:53:35+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_import_key_symmetric": { + "last_validated_date": "2024-04-11T15:53:31+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_generate_mac[HMAC_224-HMAC_SHA_256]": { + "last_validated_date": "2024-04-11T15:54:11+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_generate_mac[HMAC_256-INVALID]": { + "last_validated_date": "2024-04-11T15:54:12+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_key_usage": { + "last_validated_date": "2024-04-11T15:53:16+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-HMAC_SHA_256-some different important message]": { + "last_validated_date": "2024-04-11T15:54:14+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-HMAC_SHA_512-some important message]": { + "last_validated_date": "2024-04-11T15:54:15+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_invalid_verify_mac[HMAC_256-INVALID-some important message]": { + "last_validated_date": "2024-04-11T15:54:16+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_enable_rotation_status[180]": { + "last_validated_date": "2025-03-02T13:34:03+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_enable_rotation_status[90]": { + "last_validated_date": "2025-03-02T13:34:02+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotation_status": { + "last_validated_date": "2024-04-11T15:53:48+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotations_encryption_decryption": { + "last_validated_date": "2025-04-03T09:34:47+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_rotations_limits": { + "last_validated_date": "2025-04-03T11:10:33+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_key_with_long_tag_value_raises_error": { + "last_validated_date": "2025-01-21T17:18:18+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_list_aliases_of_key": { + "last_validated_date": "2024-04-11T15:53:36+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_list_grants_with_invalid_key": { + "last_validated_date": "2024-04-11T15:52:39+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_list_keys": { + "last_validated_date": "2024-04-11T15:52:34+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_non_multi_region_keys_should_not_have_multi_region_properties": { + "last_validated_date": "2024-04-11T15:53:41+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_plaintext_size_for_encrypt": { + "last_validated_date": "2024-04-11T15:54:20+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt[RSA_2048-RSAES_OAEP_SHA_256]": { + "last_validated_date": "2025-06-09T12:53:00+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt[SYMMETRIC_DEFAULT-SYMMETRIC_DEFAULT]": { + "last_validated_date": "2025-06-09T12:52:58+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_re_encrypt_incorrect_source_key": { + "last_validated_date": "2025-06-09T12:53:24+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_replicate_key": { + "last_validated_date": "2024-04-11T15:53:44+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_retire_grant_with_grant_token": { + "last_validated_date": "2024-04-11T15:52:46+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_revoke_grant": { + "last_validated_date": "2024-04-11T15:52:44+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_modifies_key_material": { + "last_validated_date": "2025-03-08T09:24:15+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_is_disabled": { + "last_validated_date": "2025-03-08T09:26:50+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_that_does_not_exist": { + "last_validated_date": "2025-03-08T09:27:10+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_with_imported_key_material": { + "last_validated_date": "2025-03-08T09:28:13+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_non_symmetric_key": { + "last_validated_date": "2025-03-08T09:27:44+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_disabled": { + "last_validated_date": "2025-03-12T19:05:50+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_enabled": { + "last_validated_date": "2025-03-12T19:07:01+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_schedule_and_cancel_key_deletion": { + "last_validated_date": "2024-04-11T15:52:36+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_NIST_P256-ECDSA_SHA_256]": { + "last_validated_date": "2024-04-11T15:53:09+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_NIST_P384-ECDSA_SHA_384]": { + "last_validated_date": "2024-04-11T15:53:12+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[ECC_SECG_P256K1-ECDSA_SHA_256]": { + "last_validated_date": "2024-04-11T15:53:15+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_256]": { + "last_validated_date": "2024-04-11T15:52:53+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_384]": { + "last_validated_date": "2024-04-11T15:52:57+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_2048-RSASSA_PSS_SHA_512]": { + "last_validated_date": "2024-04-11T15:53:00+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_4096-RSASSA_PKCS1_V1_5_SHA_256]": { + "last_validated_date": "2024-04-11T15:53:03+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_sign_verify[RSA_4096-RSASSA_PKCS1_V1_5_SHA_512]": { + "last_validated_date": "2024-04-11T15:53:06+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_existing_key_and_untag": { + "last_validated_date": "2025-01-10T09:39:48+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_existing_key_with_invalid_tag_key": { + "last_validated_date": "2025-01-21T17:17:25+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_key_with_duplicate_tag_keys_raises_error": { + "last_validated_date": "2025-01-17T13:35:08+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_tag_untag_list_tags": { + "last_validated_date": "2024-04-11T15:53:57+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_untag_key_partially": { + "last_validated_date": "2025-01-10T09:41:02+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_update_alias": { + "last_validated_date": "2024-04-11T15:53:53+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_update_and_add_tags_on_tagged_key": { + "last_validated_date": "2025-01-17T12:25:39+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_update_key_description": { + "last_validated_date": "2024-04-11T15:53:46+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_NIST_P256-ECDSA_SHA_256]": { + "last_validated_date": "2025-04-02T06:07:03+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_NIST_P384-ECDSA_SHA_384]": { + "last_validated_date": "2025-04-02T06:07:05+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[ECC_SECG_P256K1-ECDSA_SHA_256]": { + "last_validated_date": "2025-04-02T06:07:08+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_256]": { + "last_validated_date": "2025-04-02T06:06:52+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_384]": { + "last_validated_date": "2025-04-02T06:06:54+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_2048-RSASSA_PSS_SHA_512]": { + "last_validated_date": "2025-04-02T06:06:56+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_4096-RSASSA_PKCS1_V1_5_SHA_256]": { + "last_validated_date": "2025-04-02T06:06:58+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMS::test_verify_salt_length[RSA_4096-RSASSA_PKCS1_V1_5_SHA_512]": { + "last_validated_date": "2025-04-02T06:07:01+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key": { + "last_validated_date": "2024-04-11T15:54:32+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_pair": { + "last_validated_date": "2024-04-11T15:54:35+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_pair_without_plaintext": { + "last_validated_date": "2024-04-11T15:54:36+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_encryption_context_generate_data_key_without_plaintext": { + "last_validated_date": "2024-04-11T15:54:34+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key": { + "last_validated_date": "2024-04-11T15:54:30+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair": { + "last_validated_date": "2024-04-11T15:54:29+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_dry_run": { + "last_validated_date": "2025-04-06T11:54:20+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_without_plaintext": { + "last_validated_date": "2024-04-11T15:54:28+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_pair_without_plaintext_dry_run": { + "last_validated_date": "2025-04-13T15:44:57+00:00" + }, + "tests/aws/services/kms/test_kms.py::TestKMSGenerateKeys::test_generate_data_key_without_plaintext": { + "last_validated_date": "2024-04-11T15:54:31+00:00" + } +} diff --git a/tests/aws/services/lambda_/__init__.py b/tests/aws/services/lambda_/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/conftest.py b/tests/aws/services/lambda_/conftest.py new file mode 100644 index 0000000000000..030eb27d94644 --- /dev/null +++ b/tests/aws/services/lambda_/conftest.py @@ -0,0 +1,45 @@ +import os +from pathlib import Path + +import pytest +from _pytest.python import Metafunc + +from localstack.testing.aws.lambda_utils import ParametrizedLambda, generate_tests, package_for_lang + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "multiruntime: Multi runtime", + ) + + +def pytest_generate_tests(metafunc: Metafunc): + generate_tests(metafunc) + + +@pytest.fixture +def multiruntime_lambda(aws_client, request, lambda_su_role) -> ParametrizedLambda: + scenario, runtime, handler = request.param + + zip_file_path = package_for_lang( + scenario=scenario, runtime=runtime, root_folder=Path(os.path.dirname(__file__)) + ) + param_lambda = ParametrizedLambda( + lambda_client=aws_client.lambda_, + scenario=scenario, + runtime=runtime, + handler=handler, + zip_file_path=zip_file_path, + role=lambda_su_role, + ) + + yield param_lambda + + param_lambda.destroy() + + +@pytest.fixture +def dummylayer(): + with open(os.path.join(os.path.dirname(__file__), "layers/testlayer.zip"), "rb") as fd: + yield fd.read() diff --git a/tests/aws/services/lambda_/event_source_mapping/__init__.py b/tests/aws/services/lambda_/event_source_mapping/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/event_source_mapping/conftest.py b/tests/aws/services/lambda_/event_source_mapping/conftest.py new file mode 100644 index 0000000000000..2a107c7d9f99a --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/conftest.py @@ -0,0 +1,26 @@ +import pytest +from localstack_snapshot.snapshots import SnapshotSession +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.snapshots.transformer_utility import ( + SNAPSHOT_BASIC_TRANSFORMER_NEW, + TransformerUtility, +) +from localstack.utils.aws.arns import get_partition + + +# Here, we overwrite the snapshot fixture to allow the event_source_mapping subdir +# to use the newer basic transformer. +@pytest.fixture(scope="function") +def snapshot(request, _snapshot_session: SnapshotSession, account_id, region_name): + _snapshot_session.transform = TransformerUtility + + _snapshot_session.add_transformer(RegexTransformer(account_id, "1" * 12), priority=2) + _snapshot_session.add_transformer(RegexTransformer(region_name, ""), priority=2) + _snapshot_session.add_transformer( + RegexTransformer(f"arn:{get_partition(region_name)}:", "arn::"), priority=2 + ) + + _snapshot_session.add_transformer(SNAPSHOT_BASIC_TRANSFORMER_NEW, priority=0) + + return _snapshot_session diff --git a/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.py b/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.py new file mode 100644 index 0000000000000..5488c8c1742bf --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.py @@ -0,0 +1,50 @@ +import json +import os + +from localstack.testing.pytest import markers +from localstack.testing.scenario.provisioning import cleanup_s3_bucket +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + + +@markers.aws.validated +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Tags.'aws:cloudformation:logical-id'", + "$..Tags.'aws:cloudformation:stack-id'", + "$..Tags.'aws:cloudformation:stack-name'", + ] +) +def test_adding_tags(deploy_cfn_template, aws_client, snapshot, cleanups): + template_path = os.path.join( + os.path.join(os.path.dirname(__file__), "../../../templates/event_source_mapping_tags.yml") + ) + assert os.path.isfile(template_path) + + output_key = f"key-{short_uid()}" + stack = deploy_cfn_template( + template_path=template_path, + parameters={"OutputKey": output_key}, + ) + # ensure the S3 bucket is empty so we can delete it + cleanups.append(lambda: cleanup_s3_bucket(aws_client.s3, stack.outputs["OutputBucketName"])) + + snapshot.add_transformer(snapshot.transform.regex(stack.stack_id, "")) + snapshot.add_transformer(snapshot.transform.regex(stack.stack_name, "")) + + event_source_mapping_arn = stack.outputs["EventSourceMappingArn"] + tags_response = aws_client.lambda_.list_tags(Resource=event_source_mapping_arn) + snapshot.match("event-source-mapping-tags", tags_response) + + # check the mapping works + queue_url = stack.outputs["QueueUrl"] + aws_client.sqs.send_message( + QueueUrl=queue_url, + MessageBody=json.dumps({"body": "something"}), + ) + + retry( + lambda: aws_client.s3.head_object(Bucket=stack.outputs["OutputBucketName"], Key=output_key), + retries=10, + sleep=5.0, + ) diff --git a/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.snapshot.json b/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.snapshot.json new file mode 100644 index 0000000000000..618589334f8f8 --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.snapshot.json @@ -0,0 +1,19 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.py::test_adding_tags": { + "recorded-date": "19-05-2025, 09:32:18", + "recorded-content": { + "event-source-mapping-tags": { + "Tags": { + "aws:cloudformation:logical-id": "EventSourceMapping", + "aws:cloudformation:stack-id": "", + "aws:cloudformation:stack-name": "", + "my": "tag" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.validation.json b/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.validation.json new file mode 100644 index 0000000000000..2a6ef1af1c4db --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_cfn_resource.py::test_adding_tags": { + "last_validated_date": "2025-05-19T09:33:12+00:00", + "durations_in_seconds": { + "setup": 0.54, + "call": 69.88, + "teardown": 54.76, + "total": 125.18 + } + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py new file mode 100644 index 0000000000000..fed8e9c4a8723 --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py @@ -0,0 +1,1238 @@ +import json +import math +import time +from datetime import datetime + +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer + +from localstack import config +from localstack.aws.api.lambda_ import InvalidParameterValueException, Runtime +from localstack.testing.aws.lambda_utils import ( + _await_dynamodb_table_active, + _await_event_source_mapping_enabled, + _get_lambda_invocation_events, + esm_lambda_permission, + lambda_role, +) +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import s3_bucket_arn +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from localstack.utils.testutil import check_expected_lambda_log_events_length, get_lambda_log_events +from tests.aws.services.lambda_.event_source_mapping.utils import ( + create_lambda_with_response, +) +from tests.aws.services.lambda_.functions import FUNCTIONS_PATH +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_PYTHON_ECHO, + TEST_LAMBDA_PYTHON_UNHANDLED_ERROR, +) + +TEST_LAMBDA_DYNAMODB_BATCH_ITEM_FAILURE = ( + FUNCTIONS_PATH / "lambda_report_batch_item_failures_dynamodb.py" +) + + +@pytest.fixture(autouse=True) +def _snapshot_transformers(snapshot): + # manual transformers since we are passing SQS attributes through lambdas and back again + snapshot.add_transformer(snapshot.transform.resource_name()) + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "ApproximateCreationDateTime" else None, + "", + replace_reference=False, + ) + ) + snapshot.add_transformer(snapshot.transform.key_value("SequenceNumber")) + snapshot.add_transformer(snapshot.transform.key_value("eventID")) + snapshot.add_transformer(snapshot.transform.key_value("shardId")) + + +@pytest.fixture +def get_lambda_logs_event(aws_client): + def _get_lambda_logs_event(function_name, expected_num_events, retries=30): + return _get_lambda_invocation_events( + logs_client=aws_client.logs, + function_name=function_name, + expected_num_events=expected_num_events, + retries=retries, + ) + + return _get_lambda_logs_event + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # Lifecycle updates not yet implemented in ESM v2 + "$..LastProcessingResult", + ], +) +@markers.snapshot.skip_snapshot_verify( + paths=[ + # dynamodb issues, not related to lambda + "$..TableDescription.BillingModeSummary.LastUpdateToPayPerRequestDateTime", + "$..TableDescription.DeletionProtectionEnabled", + "$..TableDescription.ProvisionedThroughput.LastDecreaseDateTime", + "$..TableDescription.ProvisionedThroughput.LastIncreaseDateTime", + "$..TableDescription.StreamSpecification", + "$..TableDescription.TableStatus", + "$..Records..dynamodb.SizeBytes", + "$..Records..eventVersion", + ], +) +@markers.snapshot.skip_snapshot_verify( + # DynamoDB-Local returns an UUID for the event ID even though AWS returns something + # like 'ab0ed3713569f4655f353e5ef61a88c4' + condition=lambda: config.DDB_STREAMS_PROVIDER_V2, + paths=[ + "$..eventID", + ], +) +class TestDynamoDBEventSourceMapping: + @markers.aws.validated + def test_esm_with_not_existing_dynamodb_stream( + self, aws_client, create_lambda_function, lambda_su_role, account_id, region_name, snapshot + ): + function_name = f"simple-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=5, + ) + not_existing_dynamodb_stream_arn = f"arn:aws:dynamodb:{region_name}:{account_id}:table/test-table-4a53f4e8/stream/2025-02-22T03:03:25.490" + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=not_existing_dynamodb_stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + ) + snapshot.match("error", e.value.response) + + @markers.aws.validated + def test_dynamodb_event_source_mapping( + self, + create_lambda_function, + create_iam_role_with_policy, + dynamodb_create_table, + get_lambda_logs_event, + cleanups, + wait_for_dynamodb_stream_ready, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + db_item = {partition_key: {"S": "hello world"}, "binary_key": {"B": b"foobar"}} + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + create_table_result = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + # snapshot create table to get the table name registered as resource + snapshot.match("create-table-result", create_table_result) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + stream_arn = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + )["TableDescription"]["LatestStreamArn"] + assert wait_for_dynamodb_stream_ready(stream_arn) + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", # TODO investigate how to get it back to LATEST + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_uuid = create_event_source_mapping_response["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_uuid) + ) + + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_uuid) + + def _send_and_receive_events(): + aws_client.dynamodb.put_item(TableName=table_name, Item=db_item) + return get_lambda_logs_event( + function_name=function_name, expected_num_events=1, retries=20 + ) + + event_logs = retry(_send_and_receive_events, retries=3) + snapshot.match("event_logs", event_logs) + # check if the timestamp has the correct format + timestamp = event_logs[0]["Records"][0]["dynamodb"]["ApproximateCreationDateTime"] + # check if the timestamp has same amount of numbers before the comma as the current timestamp + # this will fail in november 2286, if this code is still around by then, read this comment and update to 10 + assert int(math.log10(timestamp)) == 9 + + @markers.aws.validated + def test_duplicate_event_source_mappings( + self, + create_lambda_function, + lambda_su_role, + create_event_source_mapping, + dynamodb_create_table, + wait_for_dynamodb_stream_ready, + snapshot, + aws_client, + cleanups, + ): + function_name_1 = f"lambda_func-{short_uid()}" + function_name_2 = f"lambda_func-{short_uid()}" + + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + + create_table_result = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + # snapshot create table to get the table name registered as resource + snapshot.match("create-table-result", create_table_result) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + event_source_arn = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + )["TableDescription"]["LatestStreamArn"] + + # extra arguments for create_event_source_mapping calls + kwargs = dict( + StartingPosition="TRIM_HORIZON", + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_1, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + response = create_event_source_mapping( + FunctionName=function_name_1, + EventSourceArn=event_source_arn, + **kwargs, + ) + snapshot.match("create", response) + + with pytest.raises(ClientError) as e: + create_event_source_mapping( + FunctionName=function_name_1, + EventSourceArn=event_source_arn, + **kwargs, + ) + + response = e.value.response + snapshot.match("error", response) + + # this should work without problem since it's a new function + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_2, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + create_event_source_mapping( + FunctionName=function_name_2, + EventSourceArn=event_source_arn, + **kwargs, + ) + + @markers.aws.validated + def test_disabled_dynamodb_event_source_mapping( + self, + create_lambda_function, + dynamodb_create_table, + lambda_su_role, + cleanups, + wait_for_dynamodb_stream_ready, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + ddb_table = f"ddb_table-{short_uid()}" + items = [ + {"id": {"S": short_uid()}, "data": {"S": "data1"}}, + {"id": {"S": short_uid()}, "data": {"S": "data2"}}, + ] + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + dynamodb_create_table_result = dynamodb_create_table( + table_name=ddb_table, partition_key="id", stream_view_type="NEW_IMAGE" + ) + latest_stream_arn = dynamodb_create_table_result["TableDescription"]["LatestStreamArn"] + snapshot.match("dynamodb_create_table_result", dynamodb_create_table_result) + rs = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=latest_stream_arn, + StartingPosition="TRIM_HORIZON", + MaximumBatchingWindowInSeconds=1, + ) + snapshot.match("create_event_source_mapping_result", rs) + uuid = rs["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + assert wait_for_dynamodb_stream_ready(latest_stream_arn) + + aws_client.dynamodb.put_item(TableName=ddb_table, Item=items[0]) + + # Lambda should be invoked 1 time + retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=3, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + # disable event source mapping + update_event_source_mapping_result = aws_client.lambda_.update_event_source_mapping( + UUID=uuid, Enabled=False + ) + snapshot.match("update_event_source_mapping_result", update_event_source_mapping_result) + time.sleep(2) + aws_client.dynamodb.put_item(TableName=ddb_table, Item=items[1]) + # lambda no longer invoked, still have 1 event + check_expected_lambda_log_events_length( + expected_length=1, function_name=function_name, logs_client=aws_client.logs + ) + + @markers.aws.validated + def test_deletion_event_source_mapping_with_dynamodb( + self, + create_lambda_function, + lambda_su_role, + snapshot, + cleanups, + dynamodb_create_table, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + ddb_table = f"ddb_table-{short_uid()}" + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + create_dynamodb_table_response = dynamodb_create_table( + table_name=ddb_table, + partition_key="id", + client=aws_client.dynamodb, + stream_view_type="NEW_IMAGE", + ) + snapshot.match("create_dynamodb_table_response", create_dynamodb_table_response) + _await_dynamodb_table_active(aws_client.dynamodb, ddb_table) + latest_stream_arn = create_dynamodb_table_response["TableDescription"]["LatestStreamArn"] + result = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=latest_stream_arn, + StartingPosition="TRIM_HORIZON", + ) + snapshot.match("create_event_source_mapping_result", result) + _await_event_source_mapping_enabled(aws_client.lambda_, result["UUID"]) + cleanups.append(lambda: aws_client.dynamodb.delete_table(TableName=ddb_table)) + + event_source_mapping_uuid = result["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + aws_client.dynamodb.delete_table(TableName=ddb_table) + list_esm = aws_client.lambda_.list_event_source_mappings(EventSourceArn=latest_stream_arn) + snapshot.match("list_event_source_mapping_result", list_esm) + + # TODO re-record snapshot, now TableId is returned but new WarmThroughput property is not + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TableDescription.TableId", + ], + ) + @markers.aws.validated + def test_dynamodb_event_source_mapping_with_sns_on_failure_destination_config( + self, + create_lambda_function, + sqs_get_queue_arn, + sqs_create_queue, + sns_create_topic, + sns_allow_topic_sqs_queue, + create_iam_role_with_policy, + dynamodb_create_table, + snapshot, + cleanups, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sns_api()) + + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + snapshot.add_transformer(snapshot.transform.key_value("endSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + item = {partition_key: {"S": "hello world"}} + + # create topic and queue + queue_url = sqs_create_queue() + topic_info = sns_create_topic() + topic_arn = topic_info["TopicArn"] + + # subscribe SQS to SNS + queue_arn = sqs_get_queue_arn(queue_url) + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn, + Protocol="sqs", + Endpoint=queue_arn, + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) + ) + + sns_allow_topic_sqs_queue( + sqs_queue_url=queue_url, sqs_queue_arn=queue_arn, sns_topic_arn=topic_arn + ) + + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_UNHANDLED_ERROR, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + create_table_response = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + snapshot.match("create_table_response", create_table_response) + + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + ) + snapshot.match("update_table_response", update_table_response) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + + destination_config = {"OnFailure": {"Destination": topic_arn}} + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + DestinationConfig=destination_config, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + aws_client.dynamodb.put_item(TableName=table_name, Item=item) + + def verify_failure_received(): + res = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert len(res.get("Messages", [])) == 1 + return res + + # It can take ~3 min against AWS until the message is received + sleep = 15 if is_aws_cloud() else 5 + messages = retry(verify_failure_received, retries=15, sleep=sleep, sleep_before=5) + + # The failure context payload of the SQS response is in JSON-string format. + # Rather extract, parse, and snapshot it since the SQS information is irrelevant. + failure_sns_payload = messages.get("Messages", []).pop(0) + failure_sns_body_json = failure_sns_payload.get("Body", {}) + failure_sns_message = json.loads(failure_sns_body_json) + + snapshot.match("failure_sns_message", failure_sns_message) + + # TODO re-record snapshot, now TableId is returned but new WarmThroughput property is not + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TableDescription.TableId", + ], + ) + @markers.aws.validated + def test_dynamodb_event_source_mapping_with_on_failure_destination_config( + self, + create_lambda_function, + sqs_get_queue_arn, + sqs_create_queue, + create_iam_role_with_policy, + dynamodb_create_table, + snapshot, + cleanups, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + item = {partition_key: {"S": "hello world"}} + + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_UNHANDLED_ERROR, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + create_table_response = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + snapshot.match("create_table_response", create_table_response) + + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + ) + snapshot.match("update_table_response", update_table_response) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + destination_queue = sqs_create_queue() + queue_failure_event_source_mapping_arn = sqs_get_queue_arn(destination_queue) + destination_config = {"OnFailure": {"Destination": queue_failure_event_source_mapping_arn}} + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + DestinationConfig=destination_config, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + aws_client.dynamodb.put_item(TableName=table_name, Item=item) + + def verify_failure_received(): + res = aws_client.sqs.receive_message(QueueUrl=destination_queue) + assert res.get("Messages") + return res + + # It can take ~3 min against AWS until the message is received + sleep = 15 if is_aws_cloud() else 5 + messages = retry(verify_failure_received, retries=15, sleep=sleep, sleep_before=5) + snapshot.match("destination_queue_messages", messages) + + # FIXME UpdateTable is not returning a WarmThroughput property + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TableDescription.WarmThroughput", + "$..requestContext.requestId", # TODO there is an extra uuid in the snapshot when run in CI on itest-ddb-v2-provider step, need to look why + ], + ) + @markers.aws.validated + def test_dynamodb_event_source_mapping_with_s3_on_failure_destination( + self, + s3_bucket, + create_lambda_function, + aws_client, + cleanups, + dynamodb_create_table, + create_iam_role_with_policy, + region_name, + snapshot, + ): + # set up s3, lambda, dynamdodb + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + item = {partition_key: {"S": "hello world"}} + + bucket_name = s3_bucket + bucket_arn = s3_bucket_arn(bucket_name, region=region_name) + + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_UNHANDLED_ERROR, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + + create_table_response = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + snapshot.match("create_table_response", create_table_response) + + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + ) + snapshot.match("update_table_response", update_table_response) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + + # create event source mapping + + destination_config = {"OnFailure": {"Destination": bucket_arn}} + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + DestinationConfig=destination_config, + ) + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + # trigger ESM source + + aws_client.dynamodb.put_item(TableName=table_name, Item=item) + + # add snapshot transformers + + snapshot.add_transformer(snapshot.transform.regex(r"shardId-.*", "")) + + # verify failure record data + + def get_invocation_record(): + list_objects_response = aws_client.s3.list_objects_v2(Bucket=bucket_name) + bucket_objects = list_objects_response["Contents"] + assert len(bucket_objects) == 1 + object_key = bucket_objects[0]["Key"] + + invocation_record = aws_client.s3.get_object( + Bucket=bucket_name, + Key=object_key, + ) + return invocation_record, object_key + + sleep = 15 if is_aws_cloud() else 5 + s3_invocation_record, s3_object_key = retry( + get_invocation_record, retries=15, sleep=sleep, sleep_before=5 + ) + + record_body = json.loads(s3_invocation_record["Body"].read().decode("utf-8")) + snapshot.match("record_body", record_body) + + failure_datetime = datetime.fromisoformat(record_body["timestamp"]) + timestamp = failure_datetime.strftime("%Y-%m-%dT%H.%M.%S") + year_month_day = failure_datetime.strftime("%Y/%m/%d") + assert s3_object_key.startswith( + f"aws/lambda/{event_source_mapping_uuid}/{record_body['DDBStreamBatchInfo']['shardId']}/{year_month_day}/{timestamp}" + ) # there is a random UUID at the end of object key, checking that the key starts with deterministic values + + # TODO: consider re-designing this test case because it currently does negative testing for the second event, + # which can be unreliable due to undetermined waiting times (i.e., retries). For reliable testing, we need + # a) strict event ordering and b) a final event that passes all filters to reliably determine the end of the test. + # The current behavior leads to hard-to-detect false negatives such as in this CI run: + # https://app.circleci.com/pipelines/github/localstack/localstack/24012/workflows/461664c2-0203-45f9-aec2-394666f48f03/jobs/197705/tests + @pytest.mark.parametrize( + # Calls represents the expected number of Lambda invocations (either 1 or 2). + # Negative tests with calls=0 are unreliable due to undetermined waiting times. + "item_to_put1, item_to_put2, filter, calls", + [ + # Test with filter, and two times same entry + pytest.param( + {"id": {"S": "id_value"}, "id2": {"S": "id2_value"}}, + # Inserting the same event (identified by PK) twice triggers a MODIFY event. + {"id": {"S": "id_value"}, "id2": {"S": "id2_value"}}, + {"eventName": ["INSERT"]}, + 1, + id="insert_same_entry_twice", + ), + # Test with OR filter + pytest.param( + {"id": {"S": "id_value"}}, + {"id": {"S": "id_value"}, "id2": {"S": "id2_new_value"}}, + {"eventName": ["INSERT", "MODIFY"]}, + 2, + id="content_or_filter", + ), + # Test with 2 filters (AND), and two times same entry (second time modified aka MODIFY eventName) + pytest.param( + {"id": {"S": "id_value"}}, + {"id": {"S": "id_value"}, "id2": {"S": "id2_new_value"}}, + {"eventName": ["INSERT"], "eventSource": ["aws:dynamodb"]}, + 1, + id="content_multiple_filters", + marks=pytest.mark.skip(reason="Broken, needs investigation"), + ), + # Test content filter using the DynamoDB data type "S" + pytest.param( + {"id": {"S": "id_value_1"}, "presentKey": {"S": "presentValue"}}, + {"id": {"S": "id_value_2"}}, + # Omitting the "S" does NOT match: {"dynamodb": {"NewImage": {"presentKey": ["presentValue"]}}} + {"dynamodb": {"NewImage": {"presentKey": {"S": ["presentValue"]}}}}, + 1, + id="content_filter_type", + ), + # Test exists filter using the DynamoDB data type "S" + pytest.param( + {"id": {"S": "id_value_1"}, "presentKey": {"S": "presentValue"}}, + {"id": {"S": "id_value_2"}}, + # Omitting the "S" does NOT match: {"dynamodb": {"NewImage": {"presentKey": [{"exists": True}]}}} + {"dynamodb": {"NewImage": {"presentKey": {"S": [{"exists": True}]}}}}, + 1, + id="exists_filter_type", + ), + pytest.param( + {"id": {"S": "id_value_1"}}, + {"id": {"S": "id_value_2"}, "presentKey": {"S": "presentValue"}}, + {"dynamodb": {"NewImage": {"presentKey": [{"exists": False}]}}}, + 2, + id="exists_false_filter", + ), + # numeric filter + # NOTE: numeric filters do not work with DynamoDB because all values are represented as string + # and not converted to numbers for filtering. + # The following AWS tutorial has a note about numeric filtering, which does not apply to DynamoDB strings: + # https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.Lambda.Tutorial2.html + pytest.param( + {"id": {"S": "id_value_1"}, "numericFilter": {"N": "42"}}, + {"id": {"S": "id_value_2"}, "numericFilter": {"N": "101"}}, + { + "dynamodb": { + "NewImage": { + "numericFilter": { + # Filtering passes if at least one of the filter conditions matches + "N": [{"numeric": [">", 100]}, {"anything-but": "101"}] + } + } + } + }, + 1, + id="numeric_filter", + ), + # Prefix + pytest.param( + {"id": {"S": "id_value_1"}, "prefix": {"S": "us-1-other-suffix"}}, + {"id": {"S": "id_value_1"}, "prefix": {"S": "other-suffix"}}, + {"dynamodb": {"NewImage": {"prefix": {"S": [{"prefix": "us-1"}]}}}}, + 1, + id="prefix_filter", + ), + # DynamoDB ApproximateCreationDateTime (datetime) gets converted into a float BEFORE filtering + # https://docs.aws.amazon.com/lambda/latest/dg/invocation-eventfiltering.html#filtering-ddb + # Using a numeric operator implicitly checks whether ApproximateCreationDateTime is a numeric type + pytest.param( + {"id": {"S": "id_value_1"}}, + {"id": {"S": "id_value_2"}}, + {"dynamodb": {"ApproximateCreationDateTime": [{"numeric": [">", 0]}]}}, + 2, + id="date_time_conversion", + ), + ], + ) + @markers.aws.validated + def test_dynamodb_event_filter( + self, + create_lambda_function, + dynamodb_create_table, + lambda_su_role, + wait_for_dynamodb_stream_ready, + filter, + calls, + item_to_put1, + item_to_put2, + cleanups, + snapshot, + aws_client, + ): + """Test event filtering for DynamoDB streams: + https://docs.aws.amazon.com/lambda/latest/dg/invocation-eventfiltering.html#filtering-ddb + + Slow against AWS taking ~2min per test case. + + Test assumption: The first item MUST always match the filter and the second item CAN match the filter. + => This enables two-step testing (i.e., snapshots between inserts) but is unreliable and should be revised. + """ + function_name = f"lambda_func-{short_uid()}" + table_name = f"test-table-{short_uid()}" + max_retries = 50 + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + table_creation_response = dynamodb_create_table(table_name=table_name, partition_key="id") + snapshot.match("table_creation_response", table_creation_response) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + stream_arn = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + )["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn) + event_source_mapping_kwargs = { + "FunctionName": function_name, + "BatchSize": 1, + "StartingPosition": "TRIM_HORIZON", + "EventSourceArn": stream_arn, + "MaximumBatchingWindowInSeconds": 1, + "MaximumRetryAttempts": 1, + } + event_source_mapping_kwargs.update( + FilterCriteria={ + "Filters": [ + {"Pattern": json.dumps(filter)}, + ] + } + ) + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + **event_source_mapping_kwargs + ) + event_source_uuid = create_event_source_mapping_response["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_uuid) + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_uuid) + + # Insert item_to_put1 + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put1) + + def assert_lambda_called(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + assert len(events) == 1 + return events + + events = retry(assert_lambda_called, retries=max_retries) + snapshot.match("lambda-log-events", events) + + # Insert item_to_put2 + aws_client.dynamodb.put_item(TableName=table_name, Item=item_to_put2) + + # The Lambda might be called multiple times depending on the items to put and filter. + if calls > 1: + + def assert_events_called_multiple(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + assert len(events) == calls + return events + + # lambda was called a second time, so new records should be found + events = retry(assert_events_called_multiple, retries=max_retries) + else: + # lambda wasn't called a second time, so no new records should be found + events = retry(assert_lambda_called, retries=max_retries) + + # Validate events containing either one or two records + for event in events: + for record in event["Records"]: + if creation_time := record.get("dynamodb", {}).get("ApproximateCreationDateTime"): + # Ensure the timestamp is in the right format (e.g., no unserializable datetime) + assert isinstance(creation_time, float) + snapshot.match("lambda-multiple-log-events", events) + + @markers.aws.validated + @pytest.mark.parametrize( + "filter", + [ + "single-string", + '[{"eventName": ["INSERT"=123}]', + ], + ) + def test_dynamodb_invalid_event_filter( + self, + create_lambda_function, + dynamodb_create_table, + lambda_su_role, + wait_for_dynamodb_stream_ready, + filter, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + table_name = f"test-table-{short_uid()}" + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + dynamodb_create_table(table_name=table_name, partition_key="id") + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + stream_arn = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + )["TableDescription"]["LatestStreamArn"] + wait_for_dynamodb_stream_ready(stream_arn) + event_source_mapping_kwargs = { + "FunctionName": function_name, + "BatchSize": 1, + "StartingPosition": "TRIM_HORIZON", + "EventSourceArn": stream_arn, + "MaximumBatchingWindowInSeconds": 1, + "MaximumRetryAttempts": 1, + "FilterCriteria": { + "Filters": [ + {"Pattern": filter}, + ] + }, + } + + with pytest.raises(Exception) as expected: + aws_client.lambda_.create_event_source_mapping(**event_source_mapping_kwargs) + snapshot.match("exception_event_source_creation", expected.value.response) + expected.match(InvalidParameterValueException.code) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TableDescription.TableId", + "$..Records", # TODO Figure out why there is an extra log record + ], + ) + @markers.aws.validated + def test_dynamodb_report_batch_item_failures( + self, + create_lambda_function, + create_event_source_mapping, + sqs_get_queue_arn, + sqs_create_queue, + create_iam_role_with_policy, + dynamodb_create_table, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + + # Used in ESM config and assertions + expected_successes = 5 + expected_failures = 1 + + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_DYNAMODB_BATCH_ITEM_FAILURE, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + create_table_response = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + snapshot.match("create_table_response", create_table_response) + + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + ) + snapshot.match("update_table_response", update_table_response) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + + destination_queue = sqs_create_queue() + queue_failure_event_source_mapping_arn = sqs_get_queue_arn(destination_queue) + destination_config = {"OnFailure": {"Destination": queue_failure_event_source_mapping_arn}} + + create_event_source_mapping_response = create_event_source_mapping( + FunctionName=function_name, + BatchSize=3, + StartingPosition="TRIM_HORIZON", + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=3, + DestinationConfig=destination_config, + FunctionResponseTypes=["ReportBatchItemFailures"], + ) + + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_uuid) + + dynamodb_items = [ + {partition_key: {"S": f"testId{i}"}, "should_fail": {"BOOL": i == 5}} + for i in range(expected_successes + expected_failures) + ] + + # TODO Batching behaviour is flakey since DynamoDB streams are unordered. Look into some patterns for ordering. + for db_item in dynamodb_items: + aws_client.dynamodb.put_item(TableName=table_name, Item=db_item) + time.sleep(0.1) + + def verify_failure_received(): + res = aws_client.sqs.receive_message(QueueUrl=destination_queue) + assert res.get("Messages") + return res + + # It can take ~3 min against AWS until the message is received + sleep = 15 if is_aws_cloud() else 5 + messages = retry(verify_failure_received, retries=15, sleep=sleep, sleep_before=5) + snapshot.match("destination_queue_messages", messages) + + batched_records = get_lambda_log_events(function_name, logs_client=aws_client.logs) + flattened_records = [ + record for batch in batched_records for record in batch.get("Records", []) + ] + + # Although DynamoDB streams doesn't guarantee such ordering, this test is more concerned with whether + # the failed items were repeated. + sorted_records = sorted( + flattened_records, key=lambda item: item["dynamodb"]["Keys"][partition_key]["S"] + ) + + snapshot.match("dynamodb_records", {"Records": sorted_records}) + + @pytest.mark.parametrize( + "set_lambda_response", + [ + # Failures + {"batchItemFailures": [{"itemIdentifier": 123}]}, + {"batchItemFailures": [{"itemIdentifier": ""}]}, + {"batchItemFailures": [{"itemIdentifier": None}]}, + {"batchItemFailures": [{"foo": 123}]}, + {"batchItemFailures": [{"foo": None}]}, + # Unhandled Exceptions + "(lambda: 1 / 0)()", # This will (lazily) evaluate, raise an exception, and re-trigger the whole batch + ], + ids=[ + # Failures + "item_identifier_not_present_failure", + "empty_string_item_identifier_failure", + "null_item_identifier_failure", + "invalid_key_foo_failure", + "invalid_key_foo_null_value_failure", + # Unhandled Exceptions + "unhandled_exception_in_function", + ], + ) + @markers.aws.validated + def test_dynamodb_report_batch_item_failure_scenarios( + self, + create_lambda_function, + dynamodb_create_table, + create_event_source_mapping, + wait_for_dynamodb_stream_ready, + sqs_get_queue_arn, + sqs_create_queue, + snapshot, + aws_client, + set_lambda_response, + lambda_su_role, + ): + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + + function_name = f"lambda_func-{short_uid()}" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + db_item = {partition_key: {"S": "hello world"}, "binary_key": {"B": b"foobar"}} + + create_lambda_function( + handler_file=create_lambda_with_response(set_lambda_response), + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + create_table_result = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + # snapshot create table to get the table name registered as resource + snapshot.match("create-table-result", create_table_result) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + stream_arn = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + )["TableDescription"]["LatestStreamArn"] + assert wait_for_dynamodb_stream_ready(stream_arn) + + destination_queue = sqs_create_queue() + queue_failure_event_source_mapping_arn = sqs_get_queue_arn(destination_queue) + destination_config = {"OnFailure": {"Destination": queue_failure_event_source_mapping_arn}} + + create_event_source_mapping_response = create_event_source_mapping( + FunctionName=function_name, + BatchSize=3, + StartingPosition="TRIM_HORIZON", + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=3, + DestinationConfig=destination_config, + FunctionResponseTypes=["ReportBatchItemFailures"], + ) + + event_source_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_uuid) + aws_client.dynamodb.put_item(TableName=table_name, Item=db_item) + + def verify_failure_received(): + res = aws_client.sqs.receive_message(QueueUrl=destination_queue) + assert res.get("Messages") + return res + + # It can take ~3 min against AWS until the message is received + sleep = 15 if is_aws_cloud() else 5 + messages = retry(verify_failure_received, retries=15, sleep=sleep, sleep_before=5) + snapshot.match("destination_queue_messages", messages) + + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + + # This will filter out exception messages being added to the log stream + invocation_events = [event for event in events if "Records" in event] + snapshot.match("dynamodb_events", invocation_events) + + @markers.aws.validated + @pytest.mark.parametrize( + "set_lambda_response", + [ + # Successes + [], + None, + {}, + {"batchItemFailures": []}, + {"batchItemFailures": None}, + ], + ids=[ + # Successes + "empty_list_success", + "null_success", + "empty_dict_success", + "empty_batch_item_failure_success", + "null_batch_item_failure_success", + ], + ) + def test_dynamodb_report_batch_item_success_scenarios( + self, + create_lambda_function, + create_event_source_mapping, + dynamodb_create_table, + wait_for_dynamodb_stream_ready, + snapshot, + aws_client, + set_lambda_response, + lambda_su_role, + ): + function_name = f"lambda_func-{short_uid()}" + table_name = f"test-table-{short_uid()}" + partition_key = "my_partition_key" + db_item = {partition_key: {"S": "hello world"}, "binary_key": {"B": b"foobar"}} + + create_lambda_function( + handler_file=create_lambda_with_response(set_lambda_response), + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + create_table_result = dynamodb_create_table( + table_name=table_name, partition_key=partition_key + ) + # snapshot create table to get the table name registered as resource + snapshot.match("create-table-result", create_table_result) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + stream_arn = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + )["TableDescription"]["LatestStreamArn"] + assert wait_for_dynamodb_stream_ready(stream_arn) + + retry_attempts = 2 + create_event_source_mapping_response = create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="TRIM_HORIZON", + BatchSize=1, + MaximumBatchingWindowInSeconds=0, + FunctionResponseTypes=["ReportBatchItemFailures"], + MaximumRetryAttempts=retry_attempts, + ) + + event_source_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_uuid) + aws_client.dynamodb.put_item(TableName=table_name, Item=db_item) + + def _verify_messages_received(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + + # This will filter out exception messages being added to the log stream + record_events = [event for event in events if "Records" in event] + + assert len(record_events) >= 1 + return record_events + + invocation_events = retry(_verify_messages_received, retries=30, sleep=5) + snapshot.match("dynamodb_events", invocation_events) diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.snapshot.json b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.snapshot.json new file mode 100644 index 0000000000000..709bfc346d2f0 --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.snapshot.json @@ -0,0 +1,4480 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping": { + "recorded-date": "22-02-2025, 03:03:03", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "event_logs": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_disabled_dynamodb_event_source_mapping": { + "recorded-date": "12-10-2024, 10:57:16", + "recorded-content": { + "dynamodb_create_table_result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_result": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "update_event_source_mapping_result": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "OK", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Disabling", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_deletion_event_source_mapping_with_dynamodb": { + "recorded-date": "12-10-2024, 10:57:49", + "recorded-content": { + "create_dynamodb_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_result": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "list_event_source_mapping_result": { + "EventSourceMappings": [ + { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_on_failure_destination_config": { + "recorded-date": "12-10-2024, 11:01:18", + "recorded-content": { + "create_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "UPDATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 2 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_invalid_event_filter[single-string]": { + "recorded-date": "12-10-2024, 11:21:25", + "recorded-content": { + "exception_event_source_creation": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Invalid filter pattern definition." + }, + "Type": "User", + "message": "Invalid filter pattern definition.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_invalid_event_filter[[{\"eventName\": [\"INSERT\"=123}]]": { + "recorded-date": "12-10-2024, 11:21:41", + "recorded-content": { + "exception_event_source_creation": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Invalid filter pattern definition." + }, + "Type": "User", + "message": "Invalid filter pattern definition.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_duplicate_event_source_mappings": { + "recorded-date": "12-10-2024, 10:55:48", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "error": { + "Error": { + "Code": "ResourceConflictException", + "Message": "The event source arn (\" arn::dynamodb::111111111111:table//stream/ \") and function (\" \") provided mapping already exists. Please update or delete the existing mapping with UUID " + }, + "Type": "User", + "message": "The event source arn (\" arn::dynamodb::111111111111:table//stream/ \") and function (\" \") provided mapping already exists. Please update or delete the existing mapping with UUID ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[insert_same_entry_twice]": { + "recorded-date": "12-10-2024, 11:03:08", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "eventName": [ + "INSERT" + ] + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value" + } + }, + "NewImage": { + "id2": { + "S": "id2_value" + }, + "id": { + "S": "id_value" + } + }, + "SequenceNumber": "", + "SizeBytes": 32, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value" + } + }, + "NewImage": { + "id2": { + "S": "id2_value" + }, + "id": { + "S": "id_value" + } + }, + "SequenceNumber": "", + "SizeBytes": 32, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_or_filter]": { + "recorded-date": "12-10-2024, 11:05:33", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "eventName": [ + "INSERT", + "MODIFY" + ] + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value" + } + }, + "NewImage": { + "id": { + "S": "id_value" + } + }, + "SequenceNumber": "", + "SizeBytes": 20, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value" + } + }, + "NewImage": { + "id": { + "S": "id_value" + } + }, + "SequenceNumber": "", + "SizeBytes": 20, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "MODIFY", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value" + } + }, + "NewImage": { + "id2": { + "S": "id2_new_value" + }, + "id": { + "S": "id_value" + } + }, + "OldImage": { + "id": { + "S": "id_value" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_multiple_filters]": { + "recorded-date": "12-10-2024, 11:07:07", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "eventName": [ + "INSERT" + ], + "eventSource": [ + "aws:dynamodb" + ] + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value" + } + }, + "NewImage": { + "id": { + "S": "id_value" + } + }, + "SequenceNumber": "", + "SizeBytes": 20, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value" + } + }, + "NewImage": { + "id": { + "S": "id_value" + } + }, + "SequenceNumber": "", + "SizeBytes": 20, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_filter_type]": { + "recorded-date": "12-10-2024, 11:08:12", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "dynamodb": { + "NewImage": { + "presentKey": { + "S": [ + "presentValue" + ] + } + } + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + }, + "presentKey": { + "S": "presentValue" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + }, + "presentKey": { + "S": "presentValue" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[exists_filter_type]": { + "recorded-date": "12-10-2024, 11:10:06", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "dynamodb": { + "NewImage": { + "presentKey": { + "S": [ + { + "exists": true + } + ] + } + } + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + }, + "presentKey": { + "S": "presentValue" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + }, + "presentKey": { + "S": "presentValue" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[exists_false_filter]": { + "recorded-date": "05-12-2024, 15:58:42", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "dynamodb": { + "NewImage": { + "presentKey": [ + { + "exists": false + } + ] + } + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 24, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 24, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_2" + } + }, + "NewImage": { + "id": { + "S": "id_value_2" + }, + "presentKey": { + "S": "presentValue" + } + }, + "SequenceNumber": "", + "SizeBytes": 46, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[numeric_filter]": { + "recorded-date": "05-12-2024, 16:01:14", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "dynamodb": { + "NewImage": { + "numericFilter": { + "N": [ + { + "numeric": [ + ">", + 100 + ] + }, + { + "anything-but": "101" + } + ] + } + } + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "numericFilter": { + "N": "42" + }, + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 39, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "numericFilter": { + "N": "42" + }, + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 39, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[prefix_filter]": { + "recorded-date": "12-10-2024, 11:11:06", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "dynamodb": { + "NewImage": { + "prefix": { + "S": [ + { + "prefix": "us-1" + } + ] + } + } + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "prefix": { + "S": "us-1-other-suffix" + }, + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 47, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "prefix": { + "S": "us-1-other-suffix" + }, + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 47, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[date_time_conversion]": { + "recorded-date": "12-10-2024, 11:20:10", + "recorded-content": { + "table_creation_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "dynamodb": { + "ApproximateCreationDateTime": "" + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "lambda-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 24, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ], + "lambda-multiple-log-events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_1" + } + }, + "NewImage": { + "id": { + "S": "id_value_1" + } + }, + "SequenceNumber": "", + "SizeBytes": 24, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "id": { + "S": "id_value_2" + } + }, + "NewImage": { + "id": { + "S": "id_value_2" + } + }, + "SequenceNumber": "", + "SizeBytes": 24, + "StreamViewType": "NEW_AND_OLD_IMAGES" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_sns_on_failure_destination_config": { + "recorded-date": "12-10-2024, 10:59:12", + "recorded-content": { + "create_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "UPDATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sns::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "failure_sns_message": { + "Message": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 2 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Timestamp": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failures": { + "recorded-date": "12-10-2024, 11:25:25", + "recorded-content": { + "create_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "UPDATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 3, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 3, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_records": { + "Records": [ + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId0" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId0" + }, + "should_fail": { + "BOOL": false + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId1" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId1" + }, + "should_fail": { + "BOOL": false + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId2" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId2" + }, + "should_fail": { + "BOOL": false + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId3" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId3" + }, + "should_fail": { + "BOOL": false + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId4" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId4" + }, + "should_fail": { + "BOOL": false + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId5" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId5" + }, + "should_fail": { + "BOOL": true + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId5" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId5" + }, + "should_fail": { + "BOOL": true + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId5" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId5" + }, + "should_fail": { + "BOOL": true + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId5" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId5" + }, + "should_fail": { + "BOOL": true + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + }, + { + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "testId5" + } + }, + "NewImage": { + "my_partition_key": { + "S": "testId5" + }, + "should_fail": { + "BOOL": true + } + }, + "SequenceNumber": "", + "SizeBytes": 58, + "StreamViewType": "NEW_IMAGE" + }, + "eventID": "", + "eventName": "INSERT", + "eventSource": "aws:dynamodb", + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/", + "eventVersion": "1.1" + } + ] + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failures_with_on_failure_destination_config": { + "recorded-date": "11-09-2024, 11:52:02", + "recorded-content": { + "create_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "UPDATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 3, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 3, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[empty_string_item_identifier_failure]": { + "recorded-date": "12-10-2024, 11:30:34", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[null_item_identifier_failure]": { + "recorded-date": "12-10-2024, 11:33:20", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[invalid_key_foo_failure]": { + "recorded-date": "12-10-2024, 11:35:08", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[invalid_key_foo_null_value_failure]": { + "recorded-date": "14-10-2024, 21:33:18", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[unhandled_exception_in_function]": { + "recorded-date": "12-10-2024, 11:39:13", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_list_success]": { + "recorded-date": "12-10-2024, 11:40:57", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[null_success]": { + "recorded-date": "12-10-2024, 11:42:06", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_dict_success]": { + "recorded-date": "12-10-2024, 11:43:06", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_batch_item_failure_success]": { + "recorded-date": "12-10-2024, 11:44:06", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[null_batch_item_failure_success]": { + "recorded-date": "12-10-2024, 11:45:51", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[item_identifier_not_present_failure]": { + "recorded-date": "12-10-2024, 11:27:29", + "recorded-content": { + "create-table-result": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "destination_queue_messages": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "DDBStreamBatchInfo": { + "shardId": "", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::dynamodb::111111111111:table//stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dynamodb_events": [ + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "binary_key": { + "B": "Zm9vYmFy" + }, + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 70, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_s3_on_failure_destination": { + "recorded-date": "03-01-2025, 16:42:26", + "recorded-content": { + "create_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "CREATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "my_partition_key", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "" + }, + "CreationDateTime": "", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "my_partition_key", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "UPDATING", + "WarmThroughput": { + "ReadUnitsPerSecond": 12000, + "Status": "ACTIVE", + "WriteUnitsPerSecond": 4000 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::s3:::" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "record_body": { + "DDBStreamBatchInfo": { + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "endSequenceNumber": "", + "shardId": "", + "startSequenceNumber": "", + "streamArn": "arn::dynamodb::111111111111:table//stream/" + }, + "payload": { + "Records": [ + { + "eventID": "", + "eventName": "INSERT", + "eventVersion": "1.1", + "eventSource": "aws:dynamodb", + "awsRegion": "", + "dynamodb": { + "ApproximateCreationDateTime": "", + "Keys": { + "my_partition_key": { + "S": "hello world" + } + }, + "NewImage": { + "my_partition_key": { + "S": "hello world" + } + }, + "SequenceNumber": "", + "SizeBytes": 54, + "StreamViewType": "NEW_IMAGE" + }, + "eventSourceARN": "arn::dynamodb::111111111111:table//stream/" + } + ] + }, + "requestContext": { + "approximateInvokeCount": 2, + "condition": "RetryAttemptsExhausted", + "functionArn": "arn::lambda::111111111111:function:", + "requestId": "" + }, + "responseContext": { + "executedVersion": "$LATEST", + "functionError": "Unhandled", + "statusCode": 200 + }, + "timestamp": "", + "version": "1.0" + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_esm_with_not_existing_dynamodb_stream": { + "recorded-date": "26-02-2025, 03:08:09", + "recorded-content": { + "error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Stream not found: arn::dynamodb::111111111111:table/test-table-4a53f4e8/stream/2025-02-22T03:03:25.490" + }, + "Type": "User", + "message": "Stream not found: arn::dynamodb::111111111111:table/test-table-4a53f4e8/stream/2025-02-22T03:03:25.490", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.validation.json b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.validation.json new file mode 100644 index 0000000000000..0bfbdbc8c52c6 --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.validation.json @@ -0,0 +1,95 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_deletion_event_source_mapping_with_dynamodb": { + "last_validated_date": "2024-10-12T10:57:32+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_disabled_dynamodb_event_source_mapping": { + "last_validated_date": "2024-10-12T10:57:15+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_duplicate_event_source_mappings": { + "last_validated_date": "2024-10-12T10:55:43+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_filter_type]": { + "last_validated_date": "2024-10-12T11:08:10+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_multiple_filters]": { + "last_validated_date": "2024-10-12T11:07:06+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[content_or_filter]": { + "last_validated_date": "2024-10-12T11:05:31+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[date_time_conversion]": { + "last_validated_date": "2024-10-12T11:19:06+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[exists_false_filter]": { + "last_validated_date": "2024-12-05T15:58:41+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[exists_filter_type]": { + "last_validated_date": "2024-10-12T11:10:04+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[insert_same_entry_twice]": { + "last_validated_date": "2024-10-12T11:03:06+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[numeric_filter]": { + "last_validated_date": "2024-12-05T16:01:13+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_filter[prefix_filter]": { + "last_validated_date": "2024-10-12T11:11:04+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping": { + "last_validated_date": "2025-02-22T03:03:01+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_on_failure_destination_config": { + "last_validated_date": "2024-10-12T11:01:14+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_s3_on_failure_destination": { + "last_validated_date": "2025-01-03T16:42:22+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_event_source_mapping_with_sns_on_failure_destination_config": { + "last_validated_date": "2024-10-12T10:59:07+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_invalid_event_filter[[{\"eventName\": [\"INSERT\"=123}]]": { + "last_validated_date": "2024-10-12T11:21:39+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_invalid_event_filter[single-string]": { + "last_validated_date": "2024-10-12T11:21:23+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[empty_string_item_identifier_failure]": { + "last_validated_date": "2024-10-12T11:30:32+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[invalid_key_foo_failure]": { + "last_validated_date": "2024-10-12T11:35:06+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[invalid_key_foo_null_value_failure]": { + "last_validated_date": "2024-10-14T21:33:15+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[item_identifier_not_present_failure]": { + "last_validated_date": "2024-10-12T11:27:26+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[null_item_identifier_failure]": { + "last_validated_date": "2024-10-12T11:33:17+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failure_scenarios[unhandled_exception_in_function]": { + "last_validated_date": "2024-10-12T11:39:10+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_failures": { + "last_validated_date": "2024-10-12T11:25:21+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_batch_item_failure_success]": { + "last_validated_date": "2024-10-12T11:44:04+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_dict_success]": { + "last_validated_date": "2024-10-12T11:43:04+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[empty_list_success]": { + "last_validated_date": "2024-10-12T11:40:54+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[null_batch_item_failure_success]": { + "last_validated_date": "2024-10-12T11:45:49+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_dynamodb_report_batch_item_success_scenarios[null_success]": { + "last_validated_date": "2024-10-12T11:42:04+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_dynamodbstreams.py::TestDynamoDBEventSourceMapping::test_esm_with_not_existing_dynamodb_stream": { + "last_validated_date": "2025-02-26T03:08:08+00:00" + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py new file mode 100644 index 0000000000000..27906cb93f71d --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py @@ -0,0 +1,1514 @@ +import base64 +import json +import math +import time +from datetime import datetime + +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer, SortingTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.lambda_.event_source_mapping.pollers.kinesis_poller import ( + KinesisPoller, +) +from localstack.testing.aws.lambda_utils import ( + _await_event_source_mapping_enabled, + _await_event_source_mapping_state, + _get_lambda_invocation_events, + esm_lambda_permission, + get_lambda_log_events, + lambda_role, +) +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import s3_bucket_arn +from localstack.utils.strings import short_uid, to_bytes +from localstack.utils.sync import ShortCircuitWaitException, retry, wait_until +from tests.aws.services.lambda_.event_source_mapping.utils import ( + create_lambda_with_response, +) +from tests.aws.services.lambda_.functions import FUNCTIONS_PATH, lambda_integration +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_EVENT_SOURCE_MAPPING_SEND_MESSAGE, + TEST_LAMBDA_PYTHON, + TEST_LAMBDA_PYTHON_ECHO, +) + +TEST_LAMBDA_PARALLEL_FILE = FUNCTIONS_PATH / "lambda_parallel.py" +TEST_LAMBDA_KINESIS_LOG = FUNCTIONS_PATH / "kinesis_log.py" +TEST_LAMBDA_KINESIS_BATCH_ITEM_FAILURE = ( + FUNCTIONS_PATH / "lambda_report_batch_item_failures_kinesis.py" +) +TEST_LAMBDA_ECHO_FAILURE = FUNCTIONS_PATH / "lambda_echofail.py" +TEST_LAMBDA_PROVIDED_BOOTSTRAP_EMPTY = FUNCTIONS_PATH / "provided_bootstrap_empty" + + +@pytest.fixture(autouse=True) +def _snapshot_transformers(snapshot): + # manual transformers since we are passing SQS attributes through lambdas and back again + snapshot.add_transformer(snapshot.transform.key_value("sequenceNumber")) + snapshot.add_transformer(snapshot.transform.resource_name()) + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "approximateArrivalTimestamp" else None, + "", + replace_reference=False, + ) + ) + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "executionStart" else None, + "", + replace_reference=False, + ) + ) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: Fix transformer conflict between shardId and AWS account number (e.g., 000000000000): + # 'shardId-000000000000:' β†’ 'shardId-111111111111:' (expected β†’ actual) + "$..Records..eventID", + # TODO: Fix transformer issue: 'shardId-000000000000' β†’ 'shardId-111111111111' ... (expected β†’ actual) + "$..Messages..Body.KinesisBatchInfo.shardId", + "$..Message.KinesisBatchInfo.shardId", + ], +) +class TestKinesisSource: + @markers.aws.validated + def test_esm_with_not_existing_kinesis_stream( + self, aws_client, create_lambda_function, lambda_su_role, snapshot, account_id, region_name + ): + function_name = f"simple-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=5, + ) + not_existing_stream_arn = ( + f"arn:aws:kinesis:{region_name}:{account_id}:stream/test-foobar-81ded7e8" + ) + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + EventSourceArn=not_existing_stream_arn, + FunctionName=function_name, + StartingPosition="LATEST", + ) + snapshot.match("error", e.value.response) + + @markers.aws.validated + def test_create_kinesis_event_source_mapping( + self, + create_lambda_function, + kinesis_create_stream, + lambda_su_role, + wait_for_stream_ready, + cleanups, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-foobar-{short_uid()}" + record_data = "hello" + num_events_kinesis = 10 + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=stream_arn, FunctionName=function_name, StartingPosition="LATEST" + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + def _send_and_receive_messages(): + aws_client.kinesis.put_records( + Records=[ + {"Data": record_data, "PartitionKey": f"test_{i}"} + for i in range(0, num_events_kinesis) + ], + StreamName=stream_name, + ) + + return _get_lambda_invocation_events( + aws_client.logs, function_name, expected_num_events=1, retries=5 + ) + + # need to retry here in case the LATEST StartingPosition of the event source mapping does not catch records + events = retry(_send_and_receive_messages, retries=3) + records = events[0] + snapshot.match("kinesis_records", records) + # check if the timestamp has the correct format + timestamp = events[0]["Records"][0]["kinesis"]["approximateArrivalTimestamp"] + # check if the timestamp has same amount of numbers before the comma as the current timestamp + # this will fail in november 2286, if this code is still around by then, read this comment and update to 10 + assert int(math.log10(timestamp)) == 9 + + @markers.aws.validated + def test_create_kinesis_event_source_mapping_multiple_lambdas_single_kinesis_event_stream( + self, + create_lambda_function, + kinesis_create_stream, + lambda_su_role, + wait_for_stream_ready, + create_event_source_mapping, + snapshot, + aws_client, + ): + # create kinesis event stream + stream_name = f"test-stream-{short_uid()}" + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + # create event source mapping for two lambda functions + function_a_name = f"lambda_func-{short_uid()}" + function_b_name = f"lambda_func-{short_uid()}" + functions = [(function_a_name, "a"), (function_b_name, "b")] + for function_name, function_id in functions: + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + create_event_source_mapping_response = create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="TRIM_HORIZON", # TODO: test with different starting positions + ) + snapshot.match( + f"create_event_source_mapping_response-{function_id}", + create_event_source_mapping_response, + ) + + # send messages to kinesis + record_data = "hello" + aws_client.kinesis.put_records( + Records=[{"Data": record_data, "PartitionKey": "test_1"}], + StreamName=stream_name, + ) + + # verify that both lambdas are invoked + for function_name, function_id in functions: + events = _get_lambda_invocation_events( + aws_client.logs, function_name, expected_num_events=1, retries=5 + ) + records = events[0] + snapshot.match(f"kinesis_records-{function_id}", records) + # check if the timestamp has the correct format + timestamp = events[0]["Records"][0]["kinesis"]["approximateArrivalTimestamp"] + # check if the timestamp has same amount of numbers before the comma as the current timestamp + # this will fail in november 2286, if this code is still around by then, read this comment and update to 10 + assert int(math.log10(timestamp)) == 9 + + @markers.aws.validated + def test_duplicate_event_source_mappings( + self, + create_lambda_function, + lambda_su_role, + create_event_source_mapping, + kinesis_create_stream, + wait_for_stream_ready, + snapshot, + aws_client, + ): + function_name_1 = f"lambda_func-{short_uid()}" + function_name_2 = f"lambda_func-{short_uid()}" + + stream_name = f"test-foobar-{short_uid()}" + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + event_source_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_1, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + response = create_event_source_mapping( + FunctionName=function_name_1, + EventSourceArn=event_source_arn, + StartingPosition="LATEST", + ) + snapshot.match("create", response) + + with pytest.raises(ClientError) as e: + create_event_source_mapping( + FunctionName=function_name_1, + EventSourceArn=event_source_arn, + StartingPosition="LATEST", + ) + + response = e.value.response + snapshot.match("error", response) + + # this should work without problem since it's a new function + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_2, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + create_event_source_mapping( + FunctionName=function_name_2, + EventSourceArn=event_source_arn, + StartingPosition="LATEST", + ) + + @markers.aws.validated + def test_kinesis_event_source_mapping_with_async_invocation( + self, + create_lambda_function, + kinesis_create_stream, + wait_for_stream_ready, + lambda_su_role, + cleanups, + snapshot, + aws_client, + ): + """Tests that records are processed in sequence when submitting 2 batches with 10 records each + because Kinesis streams ensure strict ordering.""" + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-foobar-{short_uid()}" + num_records_per_batch = 10 + num_batches = 2 + + create_lambda_function( + handler_file=TEST_LAMBDA_PARALLEL_FILE, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="LATEST", + BatchSize=num_records_per_batch, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + def _send_and_receive_messages(): + for i in range(num_batches): + start = time.perf_counter() + aws_client.kinesis.put_records( + Records=[ + {"Data": json.dumps({"record_id": j}), "PartitionKey": f"test_{i}"} + for j in range(0, num_records_per_batch) + ], + StreamName=stream_name, + ) + assert (time.perf_counter() - start) < 1 # this should not take more than a second + + return _get_lambda_invocation_events( + aws_client.logs, function_name, expected_num_events=num_batches, retries=5 + ) + + # need to retry here in case the LATEST StartingPosition of the event source mapping does not catch records + invocation_events = retry(_send_and_receive_messages, retries=3) + snapshot.match("invocation_events", invocation_events) + + # Processing of the second batch should happen at least 5 seconds after first batch because the Lambda function + # of the first batch waits for 5 seconds. + assert (invocation_events[1]["executionStart"] - invocation_events[0]["executionStart"]) > 5 + + @markers.aws.validated + def test_kinesis_event_source_trim_horizon( + self, + create_lambda_function, + kinesis_create_stream, + wait_for_stream_ready, + lambda_su_role, + cleanups, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-foobar-{short_uid()}" + num_records_per_batch = 10 + num_batches = 3 + + create_lambda_function( + handler_file=TEST_LAMBDA_PARALLEL_FILE, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + + # insert some records before event source mapping created + for i in range(num_batches - 1): + aws_client.kinesis.put_records( + Records=[ + {"Data": json.dumps({"record_id": j}), "PartitionKey": f"test_{i}"} + for j in range(0, num_records_per_batch) + ], + StreamName=stream_name, + ) + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="TRIM_HORIZON", + BatchSize=num_records_per_batch, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + # insert some more records + aws_client.kinesis.put_records( + Records=[ + {"Data": json.dumps({"record_id": i}), "PartitionKey": f"test_{num_batches}"} + for i in range(0, num_records_per_batch) + ], + StreamName=stream_name, + ) + + invocation_events = _get_lambda_invocation_events( + aws_client.logs, function_name, expected_num_events=num_batches + ) + snapshot.match("invocation_events", invocation_events) + + @markers.aws.validated + def test_disable_kinesis_event_source_mapping( + self, + create_lambda_function, + kinesis_create_stream, + wait_for_stream_ready, + lambda_su_role, + cleanups, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-foobar-{short_uid()}" + num_records_per_batch = 10 + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + wait_for_stream_ready(stream_name=stream_name) + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="LATEST", + BatchSize=num_records_per_batch, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_uuid = create_event_source_mapping_response["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_uuid) + ) + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_uuid) + + def _send_and_receive_messages(): + aws_client.kinesis.put_records( + Records=[ + {"Data": json.dumps({"record_id": i}), "PartitionKey": "test"} + for i in range(0, num_records_per_batch) + ], + StreamName=stream_name, + ) + + return _get_lambda_invocation_events( + aws_client.logs, function_name, expected_num_events=1, retries=10 + ) + + invocation_events = retry(_send_and_receive_messages, retries=3) + snapshot.match("invocation_events", invocation_events) + + aws_client.lambda_.update_event_source_mapping(UUID=event_source_uuid, Enabled=False) + _await_event_source_mapping_state(aws_client.lambda_, event_source_uuid, state="Disabled") + # we need to wait here, so the event source mapping is for sure disabled, sadly the state is no real indication + if is_aws_cloud(): + time.sleep(60) + aws_client.kinesis.put_records( + Records=[ + {"Data": json.dumps({"record_id_disabled": i}), "PartitionKey": "test"} + for i in range(0, num_records_per_batch) + ], + StreamName=stream_name, + ) + time.sleep(7) # wait for records to pass through stream + # should still only get the first batch from before mapping was disabled + _get_lambda_invocation_events( + aws_client.logs, function_name, expected_num_events=1, retries=10 + ) + + @markers.aws.validated + def test_kinesis_event_source_mapping_with_on_failure_destination_config( + self, + create_lambda_function, + sqs_get_queue_arn, + sqs_create_queue, + create_iam_role_with_policy, + wait_for_stream_ready, + cleanups, + snapshot, + aws_client, + ): + # snapshot setup + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + kinesis_name = f"test-kinesis-{short_uid()}" + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + aws_client.kinesis.create_stream(StreamName=kinesis_name, ShardCount=1) + cleanups.append( + lambda: aws_client.kinesis.delete_stream( + StreamName=kinesis_name, EnforceConsumerDeletion=True + ) + ) + result = aws_client.kinesis.describe_stream(StreamName=kinesis_name)["StreamDescription"] + kinesis_arn = result["StreamARN"] + wait_for_stream_ready(stream_name=kinesis_name) + queue_event_source_mapping = sqs_create_queue() + destination_queue = sqs_get_queue_arn(queue_event_source_mapping) + destination_config = {"OnFailure": {"Destination": destination_queue}} + message = { + "input": "hello", + "value": "world", + lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1, + } + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=kinesis_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + DestinationConfig=destination_config, + ) + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + aws_client.kinesis.put_record( + StreamName=kinesis_name, Data=to_bytes(json.dumps(message)), PartitionKey="custom" + ) + + def verify_failure_received(): + result = aws_client.sqs.receive_message(QueueUrl=queue_event_source_mapping) + assert result.get("Messages") + return result + + sleep = 15 if is_aws_cloud() else 5 + sqs_payload = retry(verify_failure_received, retries=15, sleep=sleep, sleep_before=5) + snapshot.match("sqs_payload", sqs_payload) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: Figure out why there is an extra log record + "$..Records", + ], + ) + @markers.aws.validated + def test_kinesis_report_batch_item_failures( + self, + create_lambda_function, + create_event_source_mapping, + sqs_get_queue_arn, + sqs_create_queue, + create_iam_role_with_policy, + wait_for_stream_ready, + cleanups, + snapshot, + aws_client, + ): + # snapshot setup + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + kinesis_name = f"test-kinesis-{short_uid()}" + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_KINESIS_BATCH_ITEM_FAILURE, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + aws_client.kinesis.create_stream(StreamName=kinesis_name, ShardCount=1) + cleanups.append( + lambda: aws_client.kinesis.delete_stream( + StreamName=kinesis_name, EnforceConsumerDeletion=True + ) + ) + result = aws_client.kinesis.describe_stream(StreamName=kinesis_name)["StreamDescription"] + kinesis_arn = result["StreamARN"] + wait_for_stream_ready(stream_name=kinesis_name) + + # Use OnFailure config with a DLQ to minimise flakiness instead of relying on Cloudwatch logs + queue_event_source_mapping = sqs_create_queue() + destination_queue = sqs_get_queue_arn(queue_event_source_mapping) + destination_config = {"OnFailure": {"Destination": destination_queue}} + + create_event_source_mapping_response = create_event_source_mapping( + FunctionName=function_name, + BatchSize=3, + StartingPosition="TRIM_HORIZON", + EventSourceArn=kinesis_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=3, + DestinationConfig=destination_config, + FunctionResponseTypes=["ReportBatchItemFailures"], + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + kinesis_records = [ + {"Data": json.dumps({"should_fail": i == 5}), "PartitionKey": f"test_{i}"} + for i in range(6) + ] + + aws_client.kinesis.put_records( + Records=kinesis_records, + StreamName=kinesis_name, + ) + + def verify_failure_received(): + result = aws_client.sqs.receive_message(QueueUrl=queue_event_source_mapping) + assert result.get("Messages") + return result + + sleep = 15 if is_aws_cloud() else 5 + sqs_payload = retry(verify_failure_received, retries=15, sleep=sleep, sleep_before=5) + snapshot.match("sqs_payload", sqs_payload) + + batched_records = get_lambda_log_events(function_name, logs_client=aws_client.logs) + flattened_records = [ + record for batch in batched_records for record in batch.get("Records", []) + ] + sorted_records = sorted(flattened_records, key=lambda item: item["kinesis"]["partitionKey"]) + + snapshot.match("kinesis_records", {"Records": sorted_records}) + + @markers.aws.validated + @pytest.mark.parametrize( + "set_lambda_response", + [ + # Failures + {"batchItemFailures": [{"itemIdentifier": 123}]}, + {"batchItemFailures": [{"itemIdentifier": ""}]}, + {"batchItemFailures": [{"itemIdentifier": None}]}, + {"batchItemFailures": [{"foo": 123}]}, + {"batchItemFailures": [{"foo": None}]}, + # Unhandled Exceptions + "(lambda: 1 / 0)()", # This will (lazily) evaluate, raise an exception, and re-trigger the whole batch + ], + ids=[ + # Failures + "item_identifier_not_present_failure", + "empty_string_item_identifier_failure", + "null_item_identifier_failure", + "invalid_key_foo_failure", + "invalid_key_foo_null_value_failure", + # Unhandled Exceptions + "unhandled_exception_in_function", + ], + ) + def test_kinesis_report_batch_item_failure_scenarios( + self, + create_lambda_function, + create_event_source_mapping, + kinesis_create_stream, + lambda_su_role, + wait_for_stream_ready, + snapshot, + aws_client, + set_lambda_response, + sqs_get_queue_arn, + sqs_create_queue, + ): + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-foobar-{short_uid()}" + record_data = "hello" + + create_lambda_function( + handler_file=create_lambda_with_response(set_lambda_response), + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + queue_event_source_mapping = sqs_create_queue() + destination_queue = sqs_get_queue_arn(queue_event_source_mapping) + destination_config = {"OnFailure": {"Destination": destination_queue}} + + create_event_source_mapping_response = create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="TRIM_HORIZON", + BatchSize=1, + MaximumBatchingWindowInSeconds=1, + FunctionResponseTypes=["ReportBatchItemFailures"], + MaximumRetryAttempts=2, + DestinationConfig=destination_config, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + aws_client.kinesis.put_record( + Data=record_data, + PartitionKey="test", + StreamName=stream_name, + ) + + def verify_failure_received(): + result = aws_client.sqs.receive_message(QueueUrl=queue_event_source_mapping) + assert result.get("Messages") + return result + + sleep = 15 if is_aws_cloud() else 5 + sqs_payload = retry(verify_failure_received, retries=15, sleep=sleep, sleep_before=5) + snapshot.match("sqs_payload", sqs_payload) + + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + + # This will filter out exception messages being added to the log stream + invocation_events = [event for event in events if "Records" in event] + snapshot.match("kinesis_events", invocation_events) + + @markers.aws.validated + def test_kinesis_event_source_mapping_with_sns_on_failure_destination_config( + self, + create_lambda_function, + sqs_get_queue_arn, + sqs_create_queue, + sns_create_topic, + sns_allow_topic_sqs_queue, + create_iam_role_with_policy, + wait_for_stream_ready, + cleanups, + snapshot, + aws_client, + ): + # snapshot setup + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + kinesis_name = f"test-kinesis-{short_uid()}" + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + # create topic and queue + queue_url = sqs_create_queue() + topic_info = sns_create_topic() + topic_arn = topic_info["TopicArn"] + + # subscribe SQS to SNS + queue_arn = sqs_get_queue_arn(queue_url) + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn, + Protocol="sqs", + Endpoint=queue_arn, + ) + cleanups.append( + lambda: aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) + ) + + sns_allow_topic_sqs_queue( + sqs_queue_url=queue_url, sqs_queue_arn=queue_arn, sns_topic_arn=topic_arn + ) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + aws_client.kinesis.create_stream(StreamName=kinesis_name, ShardCount=1) + cleanups.append( + lambda: aws_client.kinesis.delete_stream( + StreamName=kinesis_name, EnforceConsumerDeletion=True + ) + ) + result = aws_client.kinesis.describe_stream(StreamName=kinesis_name)["StreamDescription"] + kinesis_arn = result["StreamARN"] + wait_for_stream_ready(stream_name=kinesis_name) + + destination_config = {"OnFailure": {"Destination": topic_arn}} + message = { + "input": "hello", + "value": "world", + lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1, + } + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=kinesis_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + DestinationConfig=destination_config, + ) + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + aws_client.kinesis.put_record( + StreamName=kinesis_name, Data=to_bytes(json.dumps(message)), PartitionKey="custom" + ) + + def verify_failure_received(): + result = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert result["Messages"] + return result + + messages = retry(verify_failure_received, retries=50, sleep=5, sleep_before=5) + + # The failure context payload of the SQS response is in JSON-string format. + # Rather extract, parse, and snapshot it since the SQS information is irrelevant. + failure_sns_payload = messages.get("Messages", []).pop(0) + failure_sns_body_json = failure_sns_payload.get("Body", {}) + failure_sns_message = json.loads(failure_sns_body_json) + + snapshot.match("failure_sns_message", failure_sns_message) + + @markers.aws.validated + def test_kinesis_event_source_mapping_with_s3_on_failure_destination( + self, + s3_bucket, + create_lambda_function, + aws_client, + cleanups, + wait_for_stream_ready, + create_iam_role_with_policy, + region_name, + snapshot, + ): + # set up s3, lambda, kinesis + + function_name = f"lambda_func-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + kinesis_name = f"test-kinesis-{short_uid()}" + + bucket_name = s3_bucket + bucket_arn = s3_bucket_arn(bucket_name, region=region_name) + + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON, + func_name=function_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + + aws_client.kinesis.create_stream(StreamName=kinesis_name, ShardCount=1) + cleanups.append( + lambda: aws_client.kinesis.delete_stream( + StreamName=kinesis_name, EnforceConsumerDeletion=True + ) + ) + result = aws_client.kinesis.describe_stream(StreamName=kinesis_name)["StreamDescription"] + kinesis_arn = result["StreamARN"] + wait_for_stream_ready(stream_name=kinesis_name) + + # create event source mapping + + destination_config = {"OnFailure": {"Destination": bucket_arn}} + message = { + "input": "hello", + "value": "world", + lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1, + } + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=kinesis_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + DestinationConfig=destination_config, + ) + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + # trigger ESM source + + aws_client.kinesis.put_record( + StreamName=kinesis_name, Data=to_bytes(json.dumps(message)), PartitionKey="custom" + ) + + # add snapshot transformers + + snapshot.add_transformer(snapshot.transform.key_value("ETag")) + snapshot.add_transformer(snapshot.transform.regex(r"shardId-\d+", "")) + + # verify failure record data + + def get_invocation_record(): + list_objects_response = aws_client.s3.list_objects_v2(Bucket=bucket_name) + bucket_objects = list_objects_response["Contents"] + assert len(bucket_objects) == 1 + object_key = bucket_objects[0]["Key"] + + invocation_record = aws_client.s3.get_object( + Bucket=bucket_name, + Key=object_key, + ) + return invocation_record, object_key + + sleep = 15 if is_aws_cloud() else 5 + s3_invocation_record, s3_object_key = retry( + get_invocation_record, retries=15, sleep=sleep, sleep_before=5 + ) + + record_body = json.loads(s3_invocation_record["Body"].read().decode("utf-8")) + snapshot.match("record_body", record_body) + + failure_datetime = datetime.fromisoformat(record_body["timestamp"]) + timestamp = failure_datetime.strftime("%Y-%m-%dT%H.%M.%S") + year_month_day = failure_datetime.strftime("%Y/%m/%d") + assert s3_object_key.startswith( + f"aws/lambda/{event_source_mapping_uuid}/{record_body['KinesisBatchInfo']['shardId']}/{year_month_day}/{timestamp}" + ) # there is a random UUID at the end of object key, checking that the key starts with deterministic values + + @markers.aws.validated + @pytest.mark.parametrize( + "set_lambda_response", + [ + # Successes + "", + [], + None, + {}, + {"batchItemFailures": []}, + {"batchItemFailures": None}, + ], + ids=[ + # Successes + "empty_string_success", + "empty_list_success", + "null_success", + "empty_dict_success", + "empty_batch_item_failure_success", + "null_batch_item_failure_success", + ], + ) + def test_kinesis_report_batch_item_success_scenarios( + self, + create_lambda_function, + kinesis_create_stream, + lambda_su_role, + wait_for_stream_ready, + cleanups, + snapshot, + aws_client, + set_lambda_response, + ): + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-foobar-{short_uid()}" + record_data = "hello" + + create_lambda_function( + handler_file=create_lambda_with_response(set_lambda_response), + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="TRIM_HORIZON", + BatchSize=1, + MaximumBatchingWindowInSeconds=1, + FunctionResponseTypes=["ReportBatchItemFailures"], + MaximumRetryAttempts=2, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + aws_client.kinesis.put_record( + Data=record_data, + PartitionKey="test", + StreamName=stream_name, + ) + + def _verify_messages_received(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + + # This will filter out exception messages being added to the log stream + record_events = [event for event in events if "Records" in event] + + assert len(record_events) >= 1 + return record_events + + invocation_events = retry(_verify_messages_received, retries=30, sleep=5) + snapshot.match("kinesis_events", invocation_events) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # FIXME: Generate and send a requestContext in StreamPoller for RecordAgeExceeded + # which contains no responseContext object. + "$..Messages..Body.requestContext", + "$..Messages..MessageId", # Skip while no requestContext generated in StreamPoller due to transformation issues + ] + ) + @pytest.mark.parametrize( + "processing_delay_seconds, max_retries", + [ + # The record expired while retrying + pytest.param(0, -1, id="expire-while-retrying"), + # The record expired prior to arriving (no retries expected) + pytest.param(60 if is_aws_cloud() else 5, 0, id="expire-before-ingestion"), + ], + ) + def test_kinesis_maximum_record_age_exceeded( + self, + create_lambda_function, + kinesis_create_stream, + sqs_get_queue_arn, + create_event_source_mapping, + lambda_su_role, + wait_for_stream_ready, + snapshot, + aws_client, + sqs_create_queue, + monkeypatch, + # Parametrized arguments + processing_delay_seconds, + max_retries, + ): + # snapshot setup + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-kinesis-{short_uid()}" + + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + if not is_aws_cloud(): + # LocalStack test optimization: Override MaximumRecordAgeInSeconds directly + # in the poller to bypass the AWS API validation (where MaximumRecordAgeInSeconds >= 60s). + # This saves 55s waiting time. + def _patched_stream_parameters(self): + params = self.source_parameters.get("KinesisStreamParameters", {}) + params["MaximumRecordAgeInSeconds"] = 5 + return params + + monkeypatch.setattr( + KinesisPoller, "stream_parameters", property(_patched_stream_parameters) + ) + + aws_client.kinesis.put_record( + Data="stream-data", + PartitionKey="test", + StreamName=stream_name, + ) + + # Optionally delay the ESM creation, allowing a record to expire prior to being ingested. + time.sleep(processing_delay_seconds) + + create_lambda_function( + handler_file=TEST_LAMBDA_ECHO_FAILURE, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + # Use OnFailure config with a DLQ to minimise flakiness instead of relying on Cloudwatch logs + queue_event_source_mapping = sqs_create_queue() + destination_queue = sqs_get_queue_arn(queue_event_source_mapping) + destination_config = {"OnFailure": {"Destination": destination_queue}} + + create_event_source_mapping_response = create_event_source_mapping( + FunctionName=function_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=max_retries, + MaximumRecordAgeInSeconds=60, + DestinationConfig=destination_config, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + def _verify_failure_received(): + result = aws_client.sqs.receive_message(QueueUrl=queue_event_source_mapping) + assert result.get("Messages") + return result + + sleep = 15 if is_aws_cloud() else 5 + record_age_exceeded_payload = retry( + _verify_failure_received, retries=30, sleep=sleep, sleep_before=5 + ) + snapshot.match("record_age_exceeded_payload", record_age_exceeded_payload) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # FIXME: Generate and send a requestContext in StreamPoller for RecordAgeExceeded + # which contains no responseContext object. + "$..Messages..Body.requestContext", + "$..Messages..MessageId", # Skip while no requestContext generated in StreamPoller due to transformation issues + ] + ) + def test_kinesis_maximum_record_age_exceeded_discard_records( + self, + create_lambda_function, + kinesis_create_stream, + sqs_get_queue_arn, + create_event_source_mapping, + lambda_su_role, + wait_for_stream_ready, + snapshot, + aws_client, + sqs_create_queue, + monkeypatch, + ): + # snapshot setup + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + # PutRecords does not have guaranteed ordering so we should sort the retrieved records to ensure consistency + # between runs. + snapshot.add_transformer( + SortingTransformer( + "Records", lambda x: base64.b64decode(x["kinesis"]["data"]).decode("utf-8") + ), + ) + + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-kinesis-{short_uid()}" + wait_before_processing = 80 + + if not is_aws_cloud(): + wait_before_processing = 5 + + # LS test optimization + def _patched_stream_parameters(self): + params = self.source_parameters.get("KinesisStreamParameters", {}) + params["MaximumRecordAgeInSeconds"] = wait_before_processing + return params + + monkeypatch.setattr( + KinesisPoller, "stream_parameters", property(_patched_stream_parameters) + ) + + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + aws_client.kinesis.put_record( + Data="stream-data", + PartitionKey="test", + StreamName=stream_name, + ) + + # Ensure that the first record has expired + time.sleep(wait_before_processing) + + # The first record in the batch will have expired with the remaining batch not exceeding any age-limits. + aws_client.kinesis.put_records( + Records=[{"Data": f"stream-data-{i + 1}", "PartitionKey": "test"} for i in range(5)], + StreamName=stream_name, + ) + + destination_queue_url = sqs_create_queue() + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_EVENT_SOURCE_MAPPING_SEND_MESSAGE, + runtime=Runtime.python3_12, + envvars={"SQS_QUEUE_URL": destination_queue_url}, + role=lambda_su_role, + ) + + # Use OnFailure config with a DLQ to minimise flakiness instead of relying on Cloudwatch logs + dead_letter_queue = sqs_create_queue() + dead_letter_queue_arn = sqs_get_queue_arn(dead_letter_queue) + destination_config = {"OnFailure": {"Destination": dead_letter_queue_arn}} + + create_event_source_mapping_response = create_event_source_mapping( + FunctionName=function_name, + BatchSize=10, + StartingPosition="TRIM_HORIZON", + EventSourceArn=stream_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=0, + MaximumRecordAgeInSeconds=60, + DestinationConfig=destination_config, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + def _verify_failure_received(): + result = aws_client.sqs.receive_message(QueueUrl=dead_letter_queue) + assert result.get("Messages") + return result + + batches = [] + + def _verify_events_received(expected: int): + messages_to_delete = [] + receive_message_response = aws_client.sqs.receive_message( + QueueUrl=destination_queue_url, + MaxNumberOfMessages=10, + VisibilityTimeout=120, + WaitTimeSeconds=5 if is_aws_cloud() else 1, + ) + messages = receive_message_response.get("Messages", []) + for message in messages: + received_batch = json.loads(message["Body"]) + batches.append(received_batch) + messages_to_delete.append( + {"Id": message["MessageId"], "ReceiptHandle": message["ReceiptHandle"]} + ) + if messages_to_delete: + aws_client.sqs.delete_message_batch( + QueueUrl=destination_queue_url, Entries=messages_to_delete + ) + assert sum([len(batch) for batch in batches]) == expected + return [message for batch in batches for message in batch] + + sleep = 15 if is_aws_cloud() else 5 + record_age_exceeded_payload = retry( + _verify_failure_received, retries=15, sleep=sleep, sleep_before=5 + ) + snapshot.match("record_age_exceeded_payload", record_age_exceeded_payload) + + # While 6 records were sent, we expect 5 records since the first + # record should have expired and been discarded. + kinesis_events = retry( + _verify_events_received, retries=30, sleep=sleep, sleep_before=5, expected=5 + ) + snapshot.match("Records", kinesis_events) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: Fix flaky status 'OK' β†’ 'No records processed' ... (expected β†’ actual) + "$..LastProcessingResult", + ], + ) + def test_kinesis_empty_provided( + self, + create_lambda_function, + kinesis_create_stream, + lambda_su_role, + wait_for_stream_ready, + cleanups, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + stream_name = f"test-foobar-{short_uid()}" + record_data = "hello" + + create_lambda_function( + handler_file=TEST_LAMBDA_PROVIDED_BOOTSTRAP_EMPTY, + func_name=function_name, + runtime=Runtime.provided_al2023, + role=lambda_su_role, + ) + + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name=stream_name) + stream_summary = aws_client.kinesis.describe_stream_summary(StreamName=stream_name) + assert stream_summary["StreamDescriptionSummary"]["OpenShardCount"] == 1 + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=stream_arn, + FunctionName=function_name, + StartingPosition="TRIM_HORIZON", + BatchSize=1, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=2, + ) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + aws_client.kinesis.put_record( + Data=record_data, + PartitionKey="test", + StreamName=stream_name, + ) + + def _verify_invoke(): + log_events = aws_client.logs.filter_log_events( + logGroupName=f"/aws/lambda/{function_name}", + )["events"] + assert len([e["message"] for e in log_events if e["message"].startswith("REPORT")]) == 1 + + retry(_verify_invoke, retries=30, sleep=5) + + get_esm_result = aws_client.lambda_.get_event_source_mapping(UUID=uuid) + snapshot.match("get_esm_result", get_esm_result) + + +# TODO: add tests for different edge cases in filtering (e.g. message isn't json => needs to be dropped) +# https://docs.aws.amazon.com/lambda/latest/dg/invocation-eventfiltering.html#filtering-kinesis +class TestKinesisEventFiltering: + @markers.aws.validated + def test_kinesis_event_filtering_json_pattern( + self, + create_lambda_function, + create_iam_role_with_policy, + wait_for_stream_ready, + cleanups, + snapshot, + aws_client, + ): + """ + 1 kinesis stream + 2 lambda functions + each function has a different event source mapping with a different filter on the same kinesis stream + """ + # snapshot setup + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("startSequenceNumber")) + + function1_name = f"lambda_func1-{short_uid()}" + function2_name = f"lambda_func2-{short_uid()}" + role = f"test-lambda-role-{short_uid()}" + policy_name = f"test-lambda-policy-{short_uid()}" + kinesis_name = f"test-kinesis-{short_uid()}" + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=lambda_role, + PolicyDefinition=esm_lambda_permission, + ) + + create_lambda_function( + handler_file=TEST_LAMBDA_KINESIS_LOG, + func_name=function1_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + create_lambda_function( + handler_file=TEST_LAMBDA_KINESIS_LOG, + func_name=function2_name, + runtime=Runtime.python3_12, + role=role_arn, + ) + aws_client.kinesis.create_stream(StreamName=kinesis_name, ShardCount=1) + cleanups.append( + lambda: aws_client.kinesis.delete_stream( + StreamName=kinesis_name, EnforceConsumerDeletion=True + ) + ) + result = aws_client.kinesis.describe_stream(StreamName=kinesis_name)["StreamDescription"] + kinesis_arn = result["StreamARN"] + wait_for_stream_ready(stream_name=kinesis_name) + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function1_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=kinesis_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + FilterCriteria={ + "Filters": [{"Pattern": json.dumps({"data": {"event_type": ["function_1"]}})}] + }, + ) + + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + event_source_mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_uuid) + ) + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_uuid) + + create_event_source_mapping_2_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function2_name, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + EventSourceArn=kinesis_arn, + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + FilterCriteria={ + "Filters": [{"Pattern": json.dumps({"data": {"event_type": ["function_2"]}})}] + }, + ) + snapshot.match( + "create_event_source_mapping_2_response", create_event_source_mapping_2_response + ) + event_source_mapping_2_uuid = create_event_source_mapping_2_response["UUID"] + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=event_source_mapping_2_uuid) + ) + _await_event_source_mapping_enabled(aws_client.lambda_, event_source_mapping_2_uuid) + + msg1 = {"event_type": "function_1", "message": "foo"} + msg2 = {"event_type": "function_2", "message": "bar"} + aws_client.kinesis.put_record( + StreamName=kinesis_name, Data=to_bytes(json.dumps(msg1)), PartitionKey="custom" + ) + aws_client.kinesis.put_record( + StreamName=kinesis_name, Data=to_bytes(json.dumps(msg2)), PartitionKey="custom" + ) + + # on AWS this can take a bit (~2 min) + + def _wait_lambda_fn_invoked_x_times(fn_name: str, x: int): + def _inner(): + log_events = aws_client.logs.filter_log_events( + logGroupName=f"/aws/lambda/{fn_name}" + ) + report_events = [e for e in log_events["events"] if "REPORT" in e["message"]] + report_count = len(report_events) + if report_count > x: + raise ShortCircuitWaitException( + f"Too many events. Expected {x}, received {len(report_events)}" + ) + elif report_count == x: + return True + else: + return False + + return _inner + + assert wait_until(_wait_lambda_fn_invoked_x_times(function1_name, 1)) + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{function1_name}") + records = [e for e in log_events["events"] if "{" in e["message"]] + message = records[0]["message"] + # TODO: missing trailing \n is a LocalStack Lambda logging issue + snapshot.match("kinesis-record-lambda-payload", message.strip()) + assert wait_until(_wait_lambda_fn_invoked_x_times(function2_name, 1)) diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.snapshot.json b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.snapshot.json new file mode 100644 index 0000000000000..809b9f0d539cd --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.snapshot.json @@ -0,0 +1,3454 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_create_kinesis_event_source_mapping": { + "recorded-date": "12-10-2024, 11:47:16", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "LATEST", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_records": { + "Records": [ + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_2", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_4", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_5", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_6", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_7", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_8", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_9", + "sequenceNumber": "" + } + } + ] + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_async_invocation": { + "recorded-date": "11-12-2024, 09:54:54", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "LATEST", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "executionStart": "", + "event": { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAwfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAxfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAyfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAzfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA0fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA1fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA2fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA3fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA4fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA5fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + }, + { + "executionStart": "", + "event": { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAwfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAxfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAyfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAzfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA0fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA1fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA2fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA3fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA4fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA5fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_trim_horizon": { + "recorded-date": "12-10-2024, 11:50:52", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "executionStart": "", + "event": { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAwfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAxfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAyfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAzfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA0fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA1fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA2fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA3fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA4fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA5fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + }, + { + "executionStart": "", + "event": { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAwfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAxfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAyfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAzfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA0fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA1fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA2fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA3fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA4fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA5fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + }, + { + "executionStart": "", + "event": { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAwfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAxfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAyfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAzfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA0fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA1fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA2fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA3fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA4fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA5fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_disable_kinesis_event_source_mapping": { + "recorded-date": "12-10-2024, 11:54:22", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "LATEST", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAwfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAxfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAyfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiAzfQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA0fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA1fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA2fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA3fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA4fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "eyJyZWNvcmRfaWQiOiA5fQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_on_failure_destination_config": { + "recorded-date": "12-10-2024, 12:29:14", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 2 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisEventFiltering::test_kinesis_event_filtering_json_pattern": { + "recorded-date": "12-10-2024, 13:31:54", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "data": { + "event_type": [ + "function_1" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "create_event_source_mapping_2_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "data": { + "event_type": [ + "function_2" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis-record-lambda-payload": "[{\"event_type\": \"function_1\", \"message\": \"foo\"}]" + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_duplicate_event_source_mappings": { + "recorded-date": "12-10-2024, 11:48:49", + "recorded-content": { + "create": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "LATEST", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "error": { + "Error": { + "Code": "ResourceConflictException", + "Message": "The event source arn (\" arn::kinesis::111111111111:stream/ \") and function (\" \") provided mapping already exists. Please update or delete the existing mapping with UUID " + }, + "Type": "User", + "message": "The event source arn (\" arn::kinesis::111111111111:stream/ \") and function (\" \") provided mapping already exists. Please update or delete the existing mapping with UUID ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_create_kinesis_event_source_mapping_multiple_lambdas_single_kinesis_event_stream": { + "recorded-date": "12-10-2024, 13:58:19", + "recorded-content": { + "create_event_source_mapping_response-a": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "create_event_source_mapping_response-b": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_records-a": { + "Records": [ + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "" + } + } + ] + }, + "kinesis_records-b": { + "Records": [ + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "aGVsbG8=", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "" + } + } + ] + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_sns_on_failure_destination_config": { + "recorded-date": "12-10-2024, 13:17:57", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sns::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "failure_sns_message": { + "Message": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 2 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Timestamp": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failures": { + "recorded-date": "12-10-2024, 14:17:06", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 3, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 3, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 4 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "kinesis_records": { + "Records": [ + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IGZhbHNlfQ==", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_0", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IGZhbHNlfQ==", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_1", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IGZhbHNlfQ==", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_2", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IGZhbHNlfQ==", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_3", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IGZhbHNlfQ==", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_4", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IHRydWV9", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_5", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IHRydWV9", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_5", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IHRydWV9", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_5", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IHRydWV9", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_5", + "sequenceNumber": "" + } + }, + { + "awsRegion": "", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "eventSource": "aws:kinesis", + "eventSourceARN": "arn::kinesis::111111111111:stream/", + "eventVersion": "1.0", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "kinesis": { + "approximateArrivalTimestamp": "", + "data": "eyJzaG91bGRfZmFpbCI6IHRydWV9", + "kinesisSchemaVersion": "1.0", + "partitionKey": "test_5", + "sequenceNumber": "" + } + } + ] + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_list_success]": { + "recorded-date": "12-10-2024, 13:21:25", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[null_success]": { + "recorded-date": "12-10-2024, 13:23:15", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_dict_success]": { + "recorded-date": "12-10-2024, 13:25:13", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_batch_item_failure_success]": { + "recorded-date": "12-10-2024, 13:27:12", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[null_batch_item_failure_success]": { + "recorded-date": "12-10-2024, 13:28:05", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[empty_string_item_identifier_failure]": { + "recorded-date": "12-10-2024, 13:08:09", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 3 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[null_item_identifier_failure]": { + "recorded-date": "12-10-2024, 13:10:20", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 3 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[invalid_key_foo_failure]": { + "recorded-date": "12-10-2024, 13:13:18", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 3 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[invalid_key_foo_null_value_failure]": { + "recorded-date": "12-10-2024, 13:14:13", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 3 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[unhandled_exception_in_function]": { + "recorded-date": "14-10-2024, 18:10:16", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 3 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[item_identifier_not_present_failure]": { + "recorded-date": "12-10-2024, 13:06:13", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "sqs_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RetryAttemptsExhausted", + "approximateInvokeCount": 3 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": null + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_string_success]": { + "recorded-date": "12-10-2024, 13:19:37", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "kinesis_events": [ + { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "aGVsbG8=", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_empty_provided": { + "recorded-date": "11-10-2024, 11:04:55", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get_esm_result": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "OK", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 2, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_s3_on_failure_destination": { + "recorded-date": "03-01-2025, 14:50:27", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::s3:::" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "record_body": { + "KinesisBatchInfo": { + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "endSequenceNumber": "", + "shardId": "", + "startSequenceNumber": "", + "streamArn": "arn::kinesis::111111111111:stream/" + }, + "payload": { + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "custom", + "sequenceNumber": "", + "data": "eyJpbnB1dCI6ICJoZWxsbyIsICJ2YWx1ZSI6ICJ3b3JsZCIsICJyYWlzZV9lcnJvciI6IDF9", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": ":", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + }, + "requestContext": { + "approximateInvokeCount": 2, + "condition": "RetryAttemptsExhausted", + "functionArn": "arn::lambda::111111111111:function:", + "requestId": "" + }, + "responseContext": { + "executedVersion": "$LATEST", + "functionError": "Unhandled", + "statusCode": 200 + }, + "timestamp": "", + "version": "1.0" + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_esm_with_not_existing_kinesis_stream": { + "recorded-date": "26-02-2025, 03:05:30", + "recorded-content": { + "error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Stream not found: arn::kinesis::111111111111:stream/test-foobar-81ded7e8" + }, + "Type": "User", + "message": "Stream not found: arn::kinesis::111111111111:stream/test-foobar-81ded7e8", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded[expire-while-retrying]": { + "recorded-date": "13-04-2025, 15:00:55", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": 60, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "record_age_exceeded_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RecordAgeExceeded", + "approximateInvokeCount": 1 + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded[expire-before-ingestion]": { + "recorded-date": "13-04-2025, 16:29:29", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": 60, + "MaximumRetryAttempts": 0, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "record_age_exceeded_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RecordAgeExceeded", + "approximateInvokeCount": 1 + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded_discard_records": { + "recorded-date": "13-04-2025, 17:05:16", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::kinesis::111111111111:stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": 60, + "MaximumRetryAttempts": 0, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "record_age_exceeded_payload": { + "Messages": [ + { + "Body": { + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function:", + "condition": "RecordAgeExceeded", + "approximateInvokeCount": 1 + }, + "version": "1.0", + "timestamp": "", + "KinesisBatchInfo": { + "shardId": "shardId-000000000000", + "startSequenceNumber": "", + "endSequenceNumber": "", + "approximateArrivalOfFirstRecord": "", + "approximateArrivalOfLastRecord": "", + "batchSize": 1, + "streamArn": "arn::kinesis::111111111111:stream/" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Records": [ + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "c3RyZWFtLWRhdGEtMQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "c3RyZWFtLWRhdGEtMg==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "c3RyZWFtLWRhdGEtMw==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "c3RyZWFtLWRhdGEtNA==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + }, + { + "kinesis": { + "kinesisSchemaVersion": "1.0", + "partitionKey": "test", + "sequenceNumber": "", + "data": "c3RyZWFtLWRhdGEtNQ==", + "approximateArrivalTimestamp": "" + }, + "eventSource": "aws:kinesis", + "eventVersion": "1.0", + "eventID": "shardId-000000000000:", + "eventName": "aws:kinesis:record", + "invokeIdentityArn": "arn::iam::111111111111:role/", + "awsRegion": "", + "eventSourceARN": "arn::kinesis::111111111111:stream/" + } + ] + } + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.validation.json b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.validation.json new file mode 100644 index 0000000000000..4f3d4284e0547 --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.validation.json @@ -0,0 +1,86 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisEventFiltering::test_kinesis_event_filtering_json_pattern": { + "last_validated_date": "2024-12-13T14:48:09+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_create_kinesis_event_source_mapping": { + "last_validated_date": "2024-12-13T14:01:07+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_create_kinesis_event_source_mapping_multiple_lambdas_single_kinesis_event_stream": { + "last_validated_date": "2024-12-13T14:02:48+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_disable_kinesis_event_source_mapping": { + "last_validated_date": "2024-12-13T14:10:20+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_duplicate_event_source_mappings": { + "last_validated_date": "2024-12-13T14:03:01+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_esm_with_not_existing_kinesis_stream": { + "last_validated_date": "2025-02-26T03:05:29+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_empty_provided": { + "last_validated_date": "2024-12-13T14:45:29+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_async_invocation": { + "last_validated_date": "2024-12-13T14:04:46+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_s3_on_failure_destination": { + "last_validated_date": "2025-01-03T14:50:23+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_mapping_with_sns_on_failure_destination_config": { + "last_validated_date": "2024-12-13T14:35:43+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_event_source_trim_horizon": { + "last_validated_date": "2024-12-13T14:06:49+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded": { + "last_validated_date": "2025-04-13T15:57:25+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded[expire-before-ingestion]": { + "last_validated_date": "2025-04-13T16:29:25+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded[expire-with-mixed-arrival-batch]": { + "last_validated_date": "2025-04-13T16:39:43+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_maximum_record_age_exceeded_discard_records": { + "last_validated_date": "2025-04-23T21:42:09+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[empty_string_item_identifier_failure]": { + "last_validated_date": "2024-12-13T14:23:18+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[invalid_key_foo_failure]": { + "last_validated_date": "2024-12-13T14:27:36+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[invalid_key_foo_null_value_failure]": { + "last_validated_date": "2024-12-13T14:31:32+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[item_identifier_not_present_failure]": { + "last_validated_date": "2024-12-13T14:20:08+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[null_item_identifier_failure]": { + "last_validated_date": "2024-12-13T14:25:26+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failure_scenarios[unhandled_exception_in_function]": { + "last_validated_date": "2024-12-13T14:34:41+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_failures": { + "last_validated_date": "2024-12-13T14:18:13+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_batch_item_failure_success]": { + "last_validated_date": "2024-12-13T14:42:49+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_dict_success]": { + "last_validated_date": "2024-12-13T14:41:30+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_list_success]": { + "last_validated_date": "2024-12-13T14:38:21+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[empty_string_success]": { + "last_validated_date": "2024-12-13T14:37:20+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[null_batch_item_failure_success]": { + "last_validated_date": "2024-12-13T14:44:14+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_kinesis.py::TestKinesisSource::test_kinesis_report_batch_item_success_scenarios[null_success]": { + "last_validated_date": "2024-12-13T14:39:47+00:00" + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py new file mode 100644 index 0000000000000..603752f0b650b --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py @@ -0,0 +1,1743 @@ +import json +import math +import time + +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer, SortingTransformer + +from localstack.aws.api.lambda_ import InvalidParameterValueException, Runtime +from localstack.config import is_env_true +from localstack.testing.aws.lambda_utils import _await_event_source_mapping_enabled +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from localstack.utils.testutil import check_expected_lambda_log_events_length, get_lambda_log_events +from tests.aws.services.lambda_.functions import FUNCTIONS_PATH, lambda_integration +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_EVENT_SOURCE_MAPPING_SEND_MESSAGE, + TEST_LAMBDA_PYTHON, + TEST_LAMBDA_PYTHON_ECHO, + TEST_LAMBDA_PYTHON_ECHO_VERSION_ENV, +) + +LAMBDA_SQS_INTEGRATION_FILE = FUNCTIONS_PATH / "lambda_sqs_integration.py" +LAMBDA_SQS_BATCH_ITEM_FAILURE_FILE = FUNCTIONS_PATH / "lambda_sqs_batch_item_failure.py" +LAMBDA_SLEEP_FILE = FUNCTIONS_PATH / "lambda_sleep.py" +# AWS API reference: +# https://docs.aws.amazon.com/lambda/latest/dg/API_CreateEventSourceMapping.html#SSS-CreateEventSourceMapping-request-BatchSize +DEFAULT_SQS_BATCH_SIZE = 10 +MAX_SQS_BATCH_SIZE_FIFO = 10 + + +@pytest.fixture(autouse=True) +def _snapshot_transformers(snapshot): + # manual transformers since we are passing SQS attributes through lambdas and back again + snapshot.add_transformer(snapshot.transform.key_value("QueueUrl")) + snapshot.add_transformer(snapshot.transform.key_value("ReceiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("SenderId", reference_replacement=False)) + snapshot.add_transformer(snapshot.transform.key_value("SequenceNumber")) + snapshot.add_transformer(snapshot.transform.resource_name()) + # body contains dynamic attributes so md5 hash changes + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + # lower-case for when messages are rendered in lambdas + snapshot.add_transformer(snapshot.transform.key_value("receiptHandle")) + snapshot.add_transformer(snapshot.transform.key_value("md5OfBody")) + # transform UNIX timestamps + snapshot.add_transformer( + snapshot.transform.key_value("SentTimestamp", reference_replacement=False) + ) + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "ApproximateFirstReceiveTimestamp" else None, + "", + replace_reference=False, + ) + ) + + +@markers.aws.validated +def test_esm_with_not_existing_sqs_queue( + aws_client, account_id, region_name, create_lambda_function, lambda_su_role, snapshot +): + function_name = f"simple-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_INTEGRATION_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=5, + ) + not_existing_queue_arn = ( + f"arn:aws:sqs:{region_name}:{account_id}:not-existing-queue-{short_uid()}" + ) + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + EventSourceArn=not_existing_queue_arn, + FunctionName=function_name, + BatchSize=1, + ) + snapshot.match("error", e.value.response) + + +@markers.aws.validated +def test_failing_lambda_retries_after_visibility_timeout( + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, +): + """This test verifies a basic SQS retry scenario. The lambda uses an SQS queue as event source, and we are + testing whether the lambda automatically retries after the visibility timeout expires, and, after the retry, + properly deletes the message from the queue.""" + + # create queue used in the lambda to send events to (to verify lambda was invoked) + destination_queue_name = f"destination-queue-{short_uid()}" + destination_url = sqs_create_queue(QueueName=destination_queue_name) + snapshot.match( + "get_destination_queue_url", aws_client.sqs.get_queue_url(QueueName=destination_queue_name) + ) + + # timeout in seconds, used for both the lambda and the queue visibility timeout + retry_timeout = 5 + + # set up lambda function + function_name = f"failing-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_INTEGRATION_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=retry_timeout, # timeout needs to be <= than visibility timeout + ) + + # create event source queue + event_source_url = sqs_create_queue( + QueueName=f"source-queue-{short_uid()}", + Attributes={ + # the visibility timeout is implicitly also the time between retries + "VisibilityTimeout": str(retry_timeout), + }, + ) + event_source_arn = sqs_get_queue_arn(event_source_url) + + # wire everything with the event source mapping + response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=event_source_arn, + FunctionName=function_name, + BatchSize=1, + ) + mapping_uuid = response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + response = aws_client.lambda_.get_event_source_mapping(UUID=mapping_uuid) + snapshot.match("event_source_mapping", response) + + # trigger lambda with a message and pass the result destination url. the event format is expected by the + # lambda_sqs_integration.py lambda. + event = {"destination": destination_url, "fail_attempts": 1} + aws_client.sqs.send_message( + QueueUrl=event_source_url, + MessageBody=json.dumps(event), + ) + + # now wait for the first invocation result which is expected to fail + then = time.time() + first_response = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=15, MaxNumberOfMessages=1 + ) + snapshot.match("first_attempt", first_response) + + # and then after a few seconds (at least the visibility timeout), we expect the + second_response = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=15, MaxNumberOfMessages=1 + ) + snapshot.match("second_attempt", second_response) + + # check that it took at least the retry timeout between the first and second attempt + assert time.time() >= then + retry_timeout + + # assert message is removed from the queue + third_response = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=retry_timeout + 1, MaxNumberOfMessages=1 + ) + assert "Messages" not in third_response or third_response["Messages"] == [] + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # AWS returns empty lists for these values, even though they are not implemented yet + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_MessageAttributeValue.html + "$..stringListValues", + "$..binaryListValues", + ] +) +@markers.aws.validated +def test_message_body_and_attributes_passed_correctly( + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, +): + # create queue used in the lambda to send events to (to verify lambda was invoked) + destination_queue_name = f"destination-queue-{short_uid()}" + destination_url = sqs_create_queue(QueueName=destination_queue_name) + snapshot.match( + "get_destination_queue_url", aws_client.sqs.get_queue_url(QueueName=destination_queue_name) + ) + + # timeout in seconds, used for both the lambda and the queue visibility timeout + retry_timeout = 5 + retries = 2 + + # set up lambda function + function_name = f"lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_INTEGRATION_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=retry_timeout, # timeout needs to be <= than visibility timeout + ) + + # create dlq for event source queue + event_dlq_url = sqs_create_queue(QueueName=f"event-dlq-{short_uid()}") + event_dlq_arn = sqs_get_queue_arn(event_dlq_url) + + # create event source queue + event_source_url = sqs_create_queue( + QueueName=f"source-queue-{short_uid()}", + Attributes={ + # the visibility timeout is implicitly also the time between retries + "VisibilityTimeout": str(retry_timeout), + "RedrivePolicy": json.dumps( + {"deadLetterTargetArn": event_dlq_arn, "maxReceiveCount": retries} + ), + }, + ) + event_source_arn = sqs_get_queue_arn(event_source_url) + + # wire everything with the event source mapping + mapping_uuid = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=event_source_arn, + FunctionName=function_name, + BatchSize=1, + )["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # trigger lambda with a message and pass the result destination url. the event format is expected by the + # lambda_sqs_integration.py lambda. + event = {"destination": destination_url, "fail_attempts": 0} + aws_client.sqs.send_message( + QueueUrl=event_source_url, + MessageBody=json.dumps(event), + MessageAttributes={ + "Title": {"DataType": "String", "StringValue": "The Whistler"}, + "Author": {"DataType": "String", "StringValue": "John Grisham"}, + "WeeksOn": {"DataType": "Number", "StringValue": "6"}, + }, + ) + + # now wait for the first invocation result which is expected to fail + response = aws_client.sqs.receive_message( + QueueUrl=destination_url, + WaitTimeSeconds=15, + MaxNumberOfMessages=1, + ) + assert "Messages" in response + snapshot.match("first_attempt", response) + + +@markers.aws.validated +def test_redrive_policy_with_failing_lambda( + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, +): + """This test verifies that SQS moves a message that is passed to a failing lambda to a DLQ according to the + redrive policy, and the lambda is invoked the correct number of times. The test retries twice and the event + source mapping should then automatically move the message to the DLQ, but not earlier (see + https://github.com/localstack/localstack/issues/5283)""" + + # create queue used in the lambda to send events to (to verify lambda was invoked) + destination_queue_name = f"destination-queue-{short_uid()}" + destination_url = sqs_create_queue(QueueName=destination_queue_name) + snapshot.match( + "get_destination_queue_url", aws_client.sqs.get_queue_url(QueueName=destination_queue_name) + ) + + # timeout in seconds, used for both the lambda and the queue visibility timeout + retry_timeout = 5 + retries = 2 + + # set up lambda function + function_name = f"failing-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_INTEGRATION_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=retry_timeout, # timeout needs to be <= than visibility timeout + ) + + # create dlq for event source queue + event_dlq_url = sqs_create_queue(QueueName=f"event-dlq-{short_uid()}") + event_dlq_arn = sqs_get_queue_arn(event_dlq_url) + + # create event source queue + event_source_url = sqs_create_queue( + QueueName=f"source-queue-{short_uid()}", + Attributes={ + # the visibility timeout is implicitly also the time between retries + "VisibilityTimeout": str(retry_timeout), + "RedrivePolicy": json.dumps( + {"deadLetterTargetArn": event_dlq_arn, "maxReceiveCount": retries} + ), + }, + ) + event_source_arn = sqs_get_queue_arn(event_source_url) + + # wire everything with the event source mapping + mapping_uuid = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=event_source_arn, + FunctionName=function_name, + BatchSize=1, + )["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # trigger lambda with a message and pass the result destination url. the event format is expected by the + # lambda_sqs_integration.py lambda. + event = {"destination": destination_url, "fail_attempts": retries} + aws_client.sqs.send_message( + QueueUrl=event_source_url, + MessageBody=json.dumps(event), + ) + + # now wait for the first invocation result which is expected to fail + first_response = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=15, MaxNumberOfMessages=1 + ) + snapshot.match("first_attempt", first_response) + + # check that the DLQ is empty + second_response = aws_client.sqs.receive_message(QueueUrl=event_dlq_url, WaitTimeSeconds=1) + assert "Messages" not in second_response or second_response["Messages"] == [] + + # the second is also expected to fail, and then the message moves into the DLQ + third_response = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=15, MaxNumberOfMessages=1 + ) + snapshot.match("second_attempt", third_response) + + # now check that the event messages was placed in the DLQ + dlq_response = aws_client.sqs.receive_message(QueueUrl=event_dlq_url, WaitTimeSeconds=15) + snapshot.match("dlq_response", dlq_response) + + +@markers.aws.validated +def test_sqs_queue_as_lambda_dead_letter_queue( + lambda_su_role, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + snapshot, + aws_client, +): + snapshot.add_transformer( + [ + # MessageAttributes contain the request id, messes the hash + snapshot.transform.key_value( + "MD5OfMessageAttributes", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.jsonpath( + "$..Messages..MessageAttributes.RequestID.StringValue", "request-id" + ), + ] + ) + + dlq_queue_url = sqs_create_queue() + dlq_queue_arn = sqs_get_queue_arn(dlq_queue_url) + + function_name = f"lambda-fn-{short_uid()}" + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON, + runtime=Runtime.python3_12, + role=lambda_su_role, + DeadLetterConfig={"TargetArn": dlq_queue_arn}, + ) + snapshot.match( + "lambda-response-dlq-config", + lambda_creation_response["CreateFunctionResponse"]["DeadLetterConfig"], + ) + + # Set retries to zero to speed up the test + aws_client.lambda_.put_function_event_invoke_config( + FunctionName=function_name, + MaximumRetryAttempts=0, + ) + + # invoke Lambda, triggering an error + payload = {lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1} + aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=json.dumps(payload), + InvocationType="Event", + ) + + def receive_dlq(): + result = aws_client.sqs.receive_message( + QueueUrl=dlq_queue_url, MessageAttributeNames=["All"], VisibilityTimeout=0 + ) + assert len(result["Messages"]) > 0 + return result + + # It can take ~3 min against AWS until the message is received + sleep = 15 if is_aws_cloud() else 5 + messages = retry(receive_dlq, retries=15, sleep=sleep, sleep_before=5) + + snapshot.match("messages", messages) + + +@markers.aws.validated +@pytest.mark.skip( + reason="Flaky as an SQS queue will not always return messages in a ReceiveMessages call." +) +def test_report_batch_item_failures( + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, +): + """This test verifies the SQS Lambda integration feature Reporting batch item failures + redrive policy, and the lambda is invoked the correct number of times. The test retries twice and the event + source mapping should then automatically move the message to the DLQ, but not earlier (see + https://github.com/localstack/localstack/issues/5283)""" + + # create queue used in the lambda to send invocation results to (to verify lambda was invoked) + destination_queue_name = f"destination-queue-{short_uid()}" + destination_url = sqs_create_queue(QueueName=destination_queue_name) + snapshot.match( + "get_destination_queue_url", aws_client.sqs.get_queue_url(QueueName=destination_queue_name) + ) + + # If an SQS queue is not receiving a lot of traffic, Lambda can take up to 20s between invocations. + # See AWS docs https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html. + retry_timeout = 6 + visibility_timeout = 8 + retries = 2 + + # AWS recommends a visibility timeout should be x6 a Lambda's retry timeout. To ensure a short test + # runtime, we just want to ensure messages are re-queued a couple of seconda after any potential timeouts. + # See https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html#events-sqs-queueconfig + assert visibility_timeout > retry_timeout, ( + "A lambda needs to finish processing prior to re-queuing invisible messages" + ) + + # set up lambda function + function_name = f"failing-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_BATCH_ITEM_FAILURE_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=retry_timeout, + envvars={"DESTINATION_QUEUE_URL": destination_url}, + ) + + # create dlq for event source queue + event_dlq_url = sqs_create_queue( + QueueName=f"event-dlq-{short_uid()}.fifo", Attributes={"FifoQueue": "true"} + ) + event_dlq_arn = sqs_get_queue_arn(event_dlq_url) + + # create event source queue + # we use a FIFO queue to be sure the lambda is invoked in a deterministic way + event_source_url = sqs_create_queue( + QueueName=f"source-queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + # the visibility timeout is implicitly also the time between retries + "VisibilityTimeout": str(visibility_timeout), + "RedrivePolicy": json.dumps( + {"deadLetterTargetArn": event_dlq_arn, "maxReceiveCount": retries} + ), + }, + ) + event_source_arn = sqs_get_queue_arn(event_source_url) + + # put a batch in the queue. the event format is expected by the lambda_sqs_batch_item_failure.py lambda. + # we add the batch before the event_source_mapping to be sure that the entire batch is sent to the first invocation. + # message 1 succeeds immediately + # message 2 and 3 succeeds after one retry + # message 4 fails after 2 retries and lands in the DLQ + response = aws_client.sqs.send_message_batch( + QueueUrl=event_source_url, + Entries=[ + { + "Id": "message-1", + "MessageBody": json.dumps({"message": 1, "fail_attempts": 0}), + "MessageGroupId": "1", + "MessageDeduplicationId": "dedup-1", + }, + { + "Id": "message-2", + "MessageBody": json.dumps({"message": 2, "fail_attempts": 1}), + "MessageGroupId": "1", + "MessageDeduplicationId": "dedup-2", + }, + { + "Id": "message-3", + "MessageBody": json.dumps({"message": 3, "fail_attempts": 1}), + "MessageGroupId": "1", + "MessageDeduplicationId": "dedup-3", + }, + { + "Id": "message-4", + "MessageBody": json.dumps({"message": 4, "fail_attempts": retries}), + "MessageGroupId": "1", + "MessageDeduplicationId": "dedup-4", + }, + ], + ) + # sort so snapshotting works + response["Successful"].sort(key=lambda r: r["Id"]) + snapshot.match("send_message_batch", response) + + # wait for all items to appear in the queue + _await_queue_size(aws_client.sqs, event_source_url, qsize=4, retries=30) + + # wire everything with the event source mapping + response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=event_source_arn, + FunctionName=function_name, + BatchSize=10, + MaximumBatchingWindowInSeconds=0, + FunctionResponseTypes=["ReportBatchItemFailures"], + ) + snapshot.match("create_event_source_mapping", response) + mapping_uuid = response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # now wait for the first invocation result which is expected to have processed message 1 we wait half the retry + # interval to wait long enough for the message to appear, but short enough to check that the DLQ is empty after + # the first attempt. + # FIXME: We cannot assume that the queue will always return a message in the given time-interval. + first_invocation = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=int(retry_timeout / 2), MaxNumberOfMessages=1 + ) + # hack to make snapshot work + first_invocation["Messages"][0]["Body"] = json.loads(first_invocation["Messages"][0]["Body"]) + first_invocation["Messages"][0]["Body"]["event"]["Records"].sort( + key=lambda record: json.loads(record["body"])["message"] + ) + snapshot.match("first_invocation", first_invocation) + + # check that the DLQ is empty + dlq_messages = aws_client.sqs.receive_message(QueueUrl=event_dlq_url) + assert "Messages" not in dlq_messages or dlq_messages["Messages"] == [] + + # now wait for the second invocation result which is expected to have processed message 2 and 3 + # Since we are re-queuing twice, with a visiblity timeout of 8s, this should instead be waiting for 20s => 8s x 2 retries (+ 4s margin). + # See AWS docs: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_ReceiveMessage.html#API_ReceiveMessage_RequestSyntax + second_timeout_with_margin = (visibility_timeout * 2) + 4 + assert second_timeout_with_margin <= 20, ( + "An SQS ReceiveMessage operation cannot wait for more than 20s" + ) + second_invocation = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=second_timeout_with_margin, MaxNumberOfMessages=1 + ) + assert "Messages" in second_invocation + # hack to make snapshot work + second_invocation["Messages"][0]["Body"] = json.loads(second_invocation["Messages"][0]["Body"]) + second_invocation["Messages"][0]["Body"]["event"]["Records"].sort( + key=lambda record: json.loads(record["body"])["message"] + ) + snapshot.match("second_invocation", second_invocation) + + # here we make sure there's actually not a third attempt, since our retries = 2 + third_attempt = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=1, MaxNumberOfMessages=1 + ) + assert "Messages" not in third_attempt or third_attempt["Messages"] == [] + + # now check that message 4 was placed in the DLQ + dlq_response = aws_client.sqs.receive_message(QueueUrl=event_dlq_url, WaitTimeSeconds=15) + snapshot.match("dlq_response", dlq_response) + + +@markers.aws.validated +def test_report_batch_item_failures_on_lambda_error( + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, +): + # timeout in seconds, used for both the lambda and the queue visibility timeout + retry_timeout = 2 + retries = 2 + + # set up lambda function + function_name = f"failing-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_INTEGRATION_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=retry_timeout, # timeout needs to be <= than visibility timeout + ) + + # create dlq for event source queue + event_dlq_url = sqs_create_queue(QueueName=f"event-dlq-{short_uid()}") + event_dlq_arn = sqs_get_queue_arn(event_dlq_url) + + # create event source queue + event_source_url = sqs_create_queue( + QueueName=f"source-queue-{short_uid()}", + Attributes={ + # the visibility timeout is implicitly also the time between retries + "VisibilityTimeout": str(retry_timeout), + "RedrivePolicy": json.dumps( + {"deadLetterTargetArn": event_dlq_arn, "maxReceiveCount": retries} + ), + }, + ) + event_source_arn = sqs_get_queue_arn(event_source_url) + + # send a batch with a message to the queue that provokes a lambda failure (the lambda tries to parse the body as + # JSON, but if it's not a json document, it fails). consequently, the entire batch should be discarded + aws_client.sqs.send_message_batch( + QueueUrl=event_source_url, + Entries=[ + { + "Id": "message-1", + "MessageBody": "{not a json body", + }, + { + # this one's ok, but will be sent to the DLQ nonetheless because it's part of this bad batch. + "Id": "message-2", + "MessageBody": json.dumps({"message": 2, "fail_attempts": 0}), + }, + ], + ) + _await_queue_size(aws_client.sqs, event_source_url, qsize=2) + + # wire everything with the event source mapping + mapping_uuid = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=event_source_arn, + FunctionName=function_name, + FunctionResponseTypes=["ReportBatchItemFailures"], + )["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # the message should arrive in the DLQ after 2 retries + some time for processing + + messages = [] + + def _collect_message(): + dlq_response = aws_client.sqs.receive_message(QueueUrl=event_dlq_url) + messages.extend(dlq_response.get("Messages", [])) + assert len(messages) >= 2 + + # the message should arrive in the DLQ after 2 retries + some time for processing + wait_time = retry_timeout * retries + retry(_collect_message, retries=10, sleep=1, sleep_before=wait_time) + + messages.sort( + key=lambda m: m["MD5OfBody"] + ) # otherwise the two messages are switched around sometimes (not deterministic) + + snapshot.match("dlq_messages", messages) + + +@markers.aws.validated +def test_report_batch_item_failures_invalid_result_json_batch_fails( + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, +): + # create queue used in the lambda to send invocation results to (to verify lambda was invoked) + destination_queue_name = f"destination-queue-{short_uid()}" + destination_url = sqs_create_queue(QueueName=destination_queue_name) + snapshot.match( + "get_destination_queue_url", aws_client.sqs.get_queue_url(QueueName=destination_queue_name) + ) + + # timeout in seconds, used for both the lambda and the queue visibility timeout. + # increase to 10 if testing against AWS fails. + retry_timeout = 4 + retries = 2 + + # set up lambda function + function_name = f"failing-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_BATCH_ITEM_FAILURE_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=retry_timeout, # timeout needs to be <= than visibility timeout + envvars={ + "DESTINATION_QUEUE_URL": destination_url, + "OVERWRITE_RESULT": '{"batchItemFailures": [{"foo":"notvalid"}]}', + }, + ) + + # create dlq for event source queue + event_dlq_url = sqs_create_queue(QueueName=f"event-dlq-{short_uid()}") + event_dlq_arn = sqs_get_queue_arn(event_dlq_url) + + # create event source queue + event_source_url = sqs_create_queue( + QueueName=f"source-queue-{short_uid()}", + Attributes={ + # the visibility timeout is implicitly also the time between retries + "VisibilityTimeout": str(retry_timeout), + "RedrivePolicy": json.dumps( + {"deadLetterTargetArn": event_dlq_arn, "maxReceiveCount": retries} + ), + }, + ) + event_source_arn = sqs_get_queue_arn(event_source_url) + + # wire everything with the event source mapping + mapping_uuid = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=event_source_arn, + FunctionName=function_name, + BatchSize=10, + MaximumBatchingWindowInSeconds=0, + FunctionResponseTypes=["ReportBatchItemFailures"], + )["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # trigger the lambda, the message content doesn't matter because the whole batch should be treated as failure + aws_client.sqs.send_message( + QueueUrl=event_source_url, + MessageBody=json.dumps({"message": 1, "fail_attempts": 0}), + ) + + # now wait for the first invocation result which is expected to have processed message 1 we wait half the retry + # interval to wait long enough for the message to appear, but short enough to check that the DLQ is empty after + # the first attempt. + first_invocation = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=15, MaxNumberOfMessages=1 + ) + assert "Messages" in first_invocation + snapshot.match("first_invocation", first_invocation) + + # now wait for the second invocation result, which should be a retry of the first + second_invocation = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=15, MaxNumberOfMessages=1 + ) + assert "Messages" in second_invocation + # hack to make snapshot work + snapshot.match("second_invocation", second_invocation) + + # now check that the messages was placed in the DLQ + dlq_response = aws_client.sqs.receive_message(QueueUrl=event_dlq_url, WaitTimeSeconds=15) + assert "Messages" in dlq_response + snapshot.match("dlq_response", dlq_response) + + +@markers.aws.validated +def test_report_batch_item_failures_empty_json_batch_succeeds( + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, +): + # create queue used in the lambda to send invocation results to (to verify lambda was invoked) + destination_queue_name = f"destination-queue-{short_uid()}" + destination_url = sqs_create_queue(QueueName=destination_queue_name) + snapshot.match( + "get_destination_queue_url", aws_client.sqs.get_queue_url(QueueName=destination_queue_name) + ) + + # timeout in seconds, used for both the lambda and the queue visibility timeout. + # increase to 10 if testing against AWS fails. + retry_timeout = 4 + retries = 1 + + # set up lambda function + function_name = f"failing-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SQS_BATCH_ITEM_FAILURE_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=retry_timeout, # timeout needs to be <= than visibility timeout + envvars={"DESTINATION_QUEUE_URL": destination_url, "OVERWRITE_RESULT": "{}"}, + ) + + # create dlq for event source queue + event_dlq_url = sqs_create_queue(QueueName=f"event-dlq-{short_uid()}") + event_dlq_arn = sqs_get_queue_arn(event_dlq_url) + + # create event source queue + # we use a FIFO queue to be sure the lambda is invoked in a deterministic way + event_source_url = sqs_create_queue( + QueueName=f"source-queue-{short_uid()}", + Attributes={ + # the visibility timeout is implicitly also the time between retries + "VisibilityTimeout": str(retry_timeout), + "RedrivePolicy": json.dumps( + {"deadLetterTargetArn": event_dlq_arn, "maxReceiveCount": retries} + ), + }, + ) + event_source_arn = sqs_get_queue_arn(event_source_url) + + # wire everything with the event source mapping + mapping_uuid = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=event_source_arn, + FunctionName=function_name, + BatchSize=10, + MaximumBatchingWindowInSeconds=0, + FunctionResponseTypes=["ReportBatchItemFailures"], + )["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # trigger the lambda, the message content doesn't matter because the whole batch should be treated as failure + aws_client.sqs.send_message( + QueueUrl=event_source_url, + MessageBody=json.dumps({"message": 1, "fail_attempts": 0}), + ) + + # now wait for the first invocation result which is expected to have processed message 1 we wait half the retry + # interval to wait long enough for the message to appear, but short enough to check that the DLQ is empty after + # the first attempt. + first_invocation = aws_client.sqs.receive_message( + QueueUrl=destination_url, WaitTimeSeconds=15, MaxNumberOfMessages=1 + ) + snapshot.match("first_invocation", first_invocation) + + # now check that the messages was placed in the DLQ + dlq_response = aws_client.sqs.receive_message( + QueueUrl=event_dlq_url, WaitTimeSeconds=retry_timeout + 1 + ) + assert "Messages" not in dlq_response or dlq_response["Messages"] == [] + + +@markers.aws.validated +def test_fifo_message_group_parallelism( + aws_client, + create_lambda_function, + lambda_su_role, + cleanups, + snapshot, +): + # https://github.com/localstack/localstack/issues/7036 + lambda_client = aws_client.lambda_ + logs_client = aws_client.logs + + # create FIFO queue + queue_name = f"test-queue-{short_uid()}.fifo" + create_queue_result = aws_client.sqs.create_queue( + QueueName=queue_name, + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + "VisibilityTimeout": "60", + }, + ) + queue_url = create_queue_result["QueueUrl"] + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + message_group_id = "fixed-message-group-id-test" + + # create a lambda to process messages + function_name = f"function-name-{short_uid()}" + + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_SLEEP_FILE, + runtime=Runtime.python3_12, + role=lambda_su_role, + timeout=10, + Environment={"Variables": {"TEST_SLEEP_S": "5"}}, + ) + + # create event source mapping + create_esm_result = lambda_client.create_event_source_mapping( + FunctionName=function_name, EventSourceArn=queue_arn, Enabled=False, BatchSize=1 + ) + snapshot.match("create_esm_disabled", create_esm_result) + esm_uuid = create_esm_result["UUID"] + cleanups.append(lambda: lambda_client.delete_event_source_mapping(UUID=esm_uuid)) + + # send messages + for i in range(5): + aws_client.sqs.send_message( + QueueUrl=queue_url, MessageBody=f"message-{i}", MessageGroupId=message_group_id + ) + + # enable event source mapping + update_esm_enabling = lambda_client.update_event_source_mapping(UUID=esm_uuid, Enabled=True) + snapshot.match("update_esm_enabling", update_esm_enabling) + _await_event_source_mapping_enabled(lambda_client, esm_uuid) + get_esm_enabled = lambda_client.get_event_source_mapping(UUID=esm_uuid) + snapshot.match("get_esm_enabled", get_esm_enabled) + + # since the lambda has to be called in-order anyway, there shouldn't be any parallel executions + log_group_name = f"/aws/lambda/{function_name}" + + time.sleep(60) + + log_streams = logs_client.describe_log_streams(logGroupName=log_group_name) + assert len(log_streams["logStreams"]) == 1 + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # events attribute + "$..Records..md5OfMessageAttributes", + ], +) +class TestSQSEventSourceMapping: + @markers.aws.validated + def test_event_source_mapping_default_batch_size( + self, + create_lambda_function, + create_event_source_mapping, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.lambda_api()) + function_name = f"lambda_func-{short_uid()}" + queue_name_1 = f"queue-{short_uid()}-1" + queue_name_2 = f"queue-{short_uid()}-2" + queue_url_1 = sqs_create_queue(QueueName=queue_name_1) + queue_arn_1 = sqs_get_queue_arn(queue_url_1) + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + rs = create_event_source_mapping(EventSourceArn=queue_arn_1, FunctionName=function_name) + snapshot.match("create-event-source-mapping", rs) + + uuid = rs["UUID"] + assert DEFAULT_SQS_BATCH_SIZE == rs["BatchSize"] + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + with pytest.raises(ClientError) as e: + # Update batch size with invalid value + rs = aws_client.lambda_.update_event_source_mapping( + UUID=uuid, + FunctionName=function_name, + BatchSize=MAX_SQS_BATCH_SIZE_FIFO + 1, + ) + snapshot.match("invalid-update-event-source-mapping", e.value.response) + e.match(InvalidParameterValueException.code) + + queue_url_2 = sqs_create_queue(QueueName=queue_name_2) + queue_arn_2 = sqs_get_queue_arn(queue_url_2) + + with pytest.raises(ClientError) as e: + # Create event source mapping with invalid batch size value + rs = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn_2, + FunctionName=function_name, + BatchSize=MAX_SQS_BATCH_SIZE_FIFO + 1, + ) + snapshot.match("invalid-create-event-source-mapping", e.value.response) + e.match(InvalidParameterValueException.code) + + @markers.aws.validated + def test_sqs_event_source_mapping( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + queue_name_1 = f"queue-{short_uid()}-1" + mapping_uuid = None + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + queue_url_1 = sqs_create_queue(QueueName=queue_name_1) + queue_arn_1 = sqs_get_queue_arn(queue_url_1) + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn_1, + FunctionName=function_name, + MaximumBatchingWindowInSeconds=1, + ) + mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + snapshot.match("create-event-source-mapping-response", create_event_source_mapping_response) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + aws_client.sqs.send_message(QueueUrl=queue_url_1, MessageBody=json.dumps({"foo": "bar"})) + + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + snapshot.match("events", events) + + rs = aws_client.sqs.receive_message(QueueUrl=queue_url_1) + assert rs.get("Messages", []) == [] + + @pytest.mark.parametrize("batch_size", [15, 100, 1_000, 10_000]) + @markers.aws.validated + def test_sqs_event_source_mapping_batch_size( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, + batch_size, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(SortingTransformer("Records", lambda s: s["body"]), priority=-1) + # Intentional parity difference to speed up testing in LocalStack + snapshot.add_transformer( + snapshot.transform.key_value( + "MaximumBatchingWindowInSeconds", reference_replacement=False + ) + ) + + destination_queue_name = f"destination-queue-{short_uid()}" + function_name = f"lambda_func-{short_uid()}" + source_queue_name = f"source-queue-{short_uid()}" + mapping_uuid = None + + destination_queue_url = sqs_create_queue(QueueName=destination_queue_name) + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_EVENT_SOURCE_MAPPING_SEND_MESSAGE, + runtime=Runtime.python3_12, + envvars={"SQS_QUEUE_URL": destination_queue_url}, + role=lambda_su_role, + ) + + queue_url = sqs_create_queue(QueueName=source_queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn, + FunctionName=function_name, + # Speed up testing in LocalStack by waiting only up to 2s instead of up to 10s; AWS is slower. + MaximumBatchingWindowInSeconds=10 if is_aws_cloud() else 2, + BatchSize=batch_size, + ) + mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + snapshot.match("create-event-source-mapping-response", create_event_source_mapping_response) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + response_batch_send_10 = aws_client.sqs.send_message_batch( + QueueUrl=queue_url, + Entries=[{"Id": f"{i}-0", "MessageBody": f"{i}-0-message-{i}"} for i in range(10)], + ) + snapshot.match("send-message-batch-result-10", response_batch_send_10) + + response_batch_send_5 = aws_client.sqs.send_message_batch( + QueueUrl=queue_url, + Entries=[{"Id": f"{i}-1", "MessageBody": f"{i}-1-message-{i}"} for i in range(5)], + ) + snapshot.match("send-message-batch-result-5", response_batch_send_5) + + batches = [] + + def get_msg_from_q(): + messages_to_delete = [] + receive_message_response = aws_client.sqs.receive_message( + QueueUrl=destination_queue_url, + MaxNumberOfMessages=10, + VisibilityTimeout=120, + WaitTimeSeconds=5 if is_aws_cloud() else 1, + ) + messages = receive_message_response.get("Messages", []) + for message in messages: + received_batch = json.loads(message["Body"]) + batches.append(received_batch) + messages_to_delete.append( + {"Id": message["MessageId"], "ReceiptHandle": message["ReceiptHandle"]} + ) + if messages_to_delete: + aws_client.sqs.delete_message_batch( + QueueUrl=destination_queue_url, Entries=messages_to_delete + ) + assert sum([len(batch) for batch in batches]) == 15 + return [message for batch in batches for message in batch] + + events = retry(get_msg_from_q, retries=15, sleep=5) + snapshot.match("Records", events) + + @markers.aws.validated + def test_sqs_event_source_mapping_batching_reserved_concurrency( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(SortingTransformer("Records", lambda s: s["body"]), priority=-1) + + destination_queue_name = f"destination-queue-{short_uid()}" + function_name = f"lambda_func-{short_uid()}" + source_queue_name = f"source-queue-{short_uid()}" + mapping_uuid = None + + destination_queue_url = sqs_create_queue(QueueName=destination_queue_name) + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_EVENT_SOURCE_MAPPING_SEND_MESSAGE, + runtime=Runtime.python3_12, + envvars={"SQS_QUEUE_URL": destination_queue_url}, + role=lambda_su_role, + ) + + # Prevent more than 2 Lambdas from being spun up at a time + put_concurrency_resp = aws_client.lambda_.put_function_concurrency( + FunctionName=function_name, ReservedConcurrentExecutions=2 + ) + snapshot.match("put_concurrency_resp", put_concurrency_resp) + + queue_url = sqs_create_queue(QueueName=source_queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + for b in range(3): + aws_client.sqs.send_message_batch( + QueueUrl=queue_url, + Entries=[{"Id": f"{i}-{b}", "MessageBody": f"{i}-{b}-message"} for i in range(10)], + ) + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn, + FunctionName=function_name, + MaximumBatchingWindowInSeconds=1, + BatchSize=20, + ScalingConfig={ + "MaximumConcurrency": 2 + }, # Prevent more than 2 concurrent SQS workers from being spun up at a time + ) + mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + snapshot.match("create-event-source-mapping-response", create_event_source_mapping_response) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + batches = [] + + def get_msg_from_q(): + messages_to_delete = [] + receive_message_response = aws_client.sqs.receive_message( + QueueUrl=destination_queue_url, + MaxNumberOfMessages=10, + VisibilityTimeout=120, + WaitTimeSeconds=5, + ) + messages = receive_message_response.get("Messages", []) + for message in messages: + received_batch = json.loads(message["Body"]) + batches.append(received_batch) + messages_to_delete.append( + {"Id": message["MessageId"], "ReceiptHandle": message["ReceiptHandle"]} + ) + + if messages_to_delete: + aws_client.sqs.delete_message_batch( + QueueUrl=destination_queue_url, Entries=messages_to_delete + ) + assert sum([len(batch) for batch in batches]) == 30 + return [message for batch in batches for message in batch] + + events = retry(get_msg_from_q, retries=15, sleep=5) + + # We expect to receive 2 batches where each batch contains some proportion of the + # 30 messages we sent through, divided by the 20 ESM batch size. How this is split is + # not determinable a priori so rather just snapshots the events and the no. of batches. + snapshot.match("batch_info", {"total_batches_received": len(batches)}) + snapshot.match("Records", events) + + @markers.aws.validated + @pytest.mark.parametrize( + # EventBridge event pattern filtering test suite: tests/aws/services/events/test_events_patterns.py + # Event filtering: https://docs.aws.amazon.com/lambda/latest/dg/invocation-eventfiltering.html + # Special cases behavior: https://docs.aws.amazon.com/lambda/latest/dg/with-sqs-filtering.html + "filter, item_matching, item_not_matching", + [ + # test single filter + pytest.param( + {"body": {"my-key": ["my-value"]}}, + {"my-key": "my-value"}, + {"my-key": "other-value"}, + id="single", + ), + # test OR filter + pytest.param( + {"body": {"my-key": ["my-value-one", "my-value-two"]}}, + {"my-key": "my-value-two"}, + {"my-key": "other-value"}, + id="or", + ), + # test AND filter + pytest.param( + { + "body": { + "my-key-one": ["other-filter", "my-value-one"], + "my-key-two": ["my-value-two"], + } + }, + {"my-key-one": "my-value-one", "my-key-two": "my-value-two"}, + {"my-key-one": "other-value-", "my-key-two": "my-value-two"}, + id="and", + ), + # exists + pytest.param( + {"body": {"my-key": [{"exists": True}]}}, + {"my-key": "any-value-one"}, + {"other-key": "any-value-two"}, + id="exists", + ), + # numeric (bigger) + pytest.param( + {"body": {"my-number": [{"numeric": [">", 100]}]}}, + {"my-number": 101}, + {"my-number": 100}, + id="numeric-bigger", + ), + # numeric (smaller) + pytest.param( + {"body": {"my-number": [{"numeric": ["<", 100]}]}}, + {"my-number": 99}, + {"my-number": 100}, + id="numeric-smaller", + ), + # numeric (range) + pytest.param( + {"body": {"my-number": [{"numeric": [">=", 100, "<", 200]}]}}, + {"my-number": 100}, + {"my-number": 200}, + id="numeric-range", + ), + # prefix + pytest.param( + {"body": {"my-key": [{"prefix": "yes"}]}}, + {"my-key": "yes-value"}, + {"my-key": "no-value"}, + id="prefix", + ), + # plain string matching + # TODO: How is plain string matching supposed to work? + # https://docs.aws.amazon.com/lambda/latest/dg/with-sqs-filtering.html + pytest.param( + {"body": "plain-string"}, + "plain-string", + "plain-string-not-matching", + id="plain-string-matching", + marks=pytest.mark.skip(reason="figure out how plain string matching works"), + ), + # plain string filter + # TODO: How is plain string matching supposed to work? + # https://docs.aws.amazon.com/lambda/latest/dg/with-sqs-filtering.html + pytest.param( + {"body": "plain-string"}, + "plain-string", + # valid json body vs. plain string filter for body -> drop the message + {"valid-json-key": "plain-string"}, + id="plain-string-filter", + marks=pytest.mark.skip(reason="figure out how plain string matching works"), + ), + # valid json filter + pytest.param( + {"body": {"my-key": ["my-value"]}}, + {"my-key": "my-value"}, + # plain string body vs. valid json filter for body -> drop the message + "plain-string", + id="valid-json-filter", + ), + ], + ) + def test_sqs_event_filter( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + filter, + item_matching, + item_not_matching, + snapshot, + cleanups, + aws_client, + monkeypatch, + ): + function_name = f"lambda_func-{short_uid()}" + queue_name_1 = f"queue-{short_uid()}-1" + mapping_uuid = None + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + queue_url_1 = sqs_create_queue(QueueName=queue_name_1) + queue_arn_1 = sqs_get_queue_arn(queue_url_1) + + aws_client.sqs.send_message(QueueUrl=queue_url_1, MessageBody=json.dumps(item_matching)) + aws_client.sqs.send_message( + QueueUrl=queue_url_1, + MessageBody=json.dumps(item_not_matching) + if not isinstance(item_not_matching, str) + else item_not_matching, + ) + + def _assert_qsize(): + response = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url_1, AttributeNames=["ApproximateNumberOfMessages"] + ) + assert int(response["Attributes"]["ApproximateNumberOfMessages"]) == 2 + + retry(_assert_qsize, retries=10) + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn_1, + FunctionName=function_name, + MaximumBatchingWindowInSeconds=1, + FilterCriteria={ + "Filters": [ + {"Pattern": json.dumps(filter)}, + ] + }, + ) + mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + snapshot.match("create_event_source_mapping_response", create_event_source_mapping_response) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + def _check_lambda_logs(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + # once invoked + assert len(events) == 1 + records = events[0]["Records"] + # one record processed + assert len(records) == 1 + # check for correct record presence + if "body" in json.dumps(filter): + item_matching_str = json.dumps(item_matching) + assert records[0]["body"] == item_matching_str + return events + + invocation_events = retry(_check_lambda_logs, retries=10) + snapshot.match("invocation_events", invocation_events) + + rs = aws_client.sqs.receive_message(QueueUrl=queue_url_1) + assert rs.get("Messages", []) == [] + + @markers.aws.validated + @pytest.mark.parametrize( + "invalid_filter", [None, "simple string", {"eventSource": "aws:sqs"}, {"eventSource": []}] + ) + def test_sqs_invalid_event_filter( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + invalid_filter, + snapshot, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + queue_name_1 = f"queue-{short_uid()}" + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + queue_url_1 = sqs_create_queue(QueueName=queue_name_1) + queue_arn_1 = sqs_get_queue_arn(queue_url_1) + + with pytest.raises(ClientError) as expected: + aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn_1, + FunctionName=function_name, + MaximumBatchingWindowInSeconds=1, + FilterCriteria={ + "Filters": [ + { + "Pattern": invalid_filter + if isinstance(invalid_filter, str) + else json.dumps(invalid_filter) + }, + ] + }, + ) + snapshot.match("create_event_source_mapping_exception", expected.value.response) + expected.match(InvalidParameterValueException.code) + + @markers.aws.validated + def test_sqs_event_source_mapping_update( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + cleanups, + aws_client, + ): + """ + Testing an update to an event source mapping that changes the targeted lambda function version + + Resources used: + - Lambda function + - 2 published versions of that lambda function + - 1 event source mapping + + First the event source mapping points towards the qualified ARN of the first version. + A message is sent to the SQS queue, triggering the function version with ID 1. + The lambda function is updated with a different value for the environment variable and a new version published. + Then we update the event source mapping and make the qualified ARN of the function version with ID 2 the new target. + A message is sent to the SQS queue, triggering the function with version ID 2. + + We should have one log entry for each of the invocations. + + """ + function_name = f"lambda_func-{short_uid()}" + queue_name_1 = f"queue-{short_uid()}-1" + mapping_uuid = None + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO_VERSION_ENV, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Environment={"Variables": {"CUSTOM_VAR": "a"}} + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + publish_v1 = aws_client.lambda_.publish_version(FunctionName=function_name) + aws_client.lambda_.get_waiter("function_active_v2").wait( + FunctionName=publish_v1["FunctionArn"] + ) + + queue_url_1 = sqs_create_queue(QueueName=queue_name_1) + queue_arn_1 = sqs_get_queue_arn(queue_url_1) + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn_1, + FunctionName=publish_v1["FunctionArn"], + MaximumBatchingWindowInSeconds=1, + ) + mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + snapshot.match("create-event-source-mapping-response", create_event_source_mapping_response) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + aws_client.sqs.send_message(QueueUrl=queue_url_1, MessageBody=json.dumps({"foo": "bar"})) + + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + snapshot.match("events", events) + + rs = aws_client.sqs.receive_message(QueueUrl=queue_url_1) + assert rs.get("Messages", []) == [] + + # # create new function version + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Environment={"Variables": {"CUSTOM_VAR": "b"}} + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + publish_v2 = aws_client.lambda_.publish_version(FunctionName=function_name) + aws_client.lambda_.get_waiter("function_active_v2").wait( + FunctionName=publish_v2["FunctionArn"] + ) + # we're now pointing the existing event source mapping towards the new version. + # only v2 should now be called + updated_esm = aws_client.lambda_.update_event_source_mapping( + UUID=mapping_uuid, FunctionName=publish_v2["FunctionArn"] + ) + assert mapping_uuid == updated_esm["UUID"] + assert publish_v2["FunctionArn"] == updated_esm["FunctionArn"] + snapshot.match("updated_esm", updated_esm) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # TODO: we actually would probably need to wait for an updating state here. + # we experience flaky cases on AWS where the next send actually goes to the old version. + # Not sure yet how we could prevent this + if is_aws_cloud(): + time.sleep(10) + + # Verify that the ESM we get actually points to the latest version of Lambda function. + get_updated_esm = aws_client.lambda_.get_event_source_mapping(UUID=mapping_uuid) + snapshot.match("updated_event_source_mapping", get_updated_esm) + + # verify function v2 was called, not latest and not v1 + aws_client.sqs.send_message(QueueUrl=queue_url_1, MessageBody=json.dumps({"foo": "bar2"})) + # get the event message + events_postupdate = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=1, + function_name=function_name, + expected_length=2, + logs_client=aws_client.logs, + ) + snapshot.match("events_postupdate", events_postupdate) + + rs = aws_client.sqs.receive_message(QueueUrl=queue_url_1) + assert rs.get("Messages", []) == [] + + @markers.aws.validated + def test_duplicate_event_source_mappings( + self, + create_lambda_function, + lambda_su_role, + create_event_source_mapping, + sqs_create_queue, + sqs_get_queue_arn, + snapshot, + aws_client, + ): + function_name_1 = f"lambda_func-{short_uid()}" + function_name_2 = f"lambda_func-{short_uid()}" + + event_source_arn = sqs_get_queue_arn(sqs_create_queue()) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_1, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + response = create_event_source_mapping( + FunctionName=function_name_1, + EventSourceArn=event_source_arn, + ) + snapshot.match("create", response) + + with pytest.raises(ClientError) as e: + create_event_source_mapping( + FunctionName=function_name_1, + EventSourceArn=event_source_arn, + ) + + response = e.value.response + snapshot.match("error", response) + + # this should work without problem since it's a new function + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_2, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + create_event_source_mapping( + FunctionName=function_name_2, + EventSourceArn=event_source_arn, + ) + + @pytest.mark.parametrize( + "batch_size", + [ + 20, + 100, + 1_000, + pytest.param( + 10_000, + id="10000", + marks=pytest.mark.skipif( + condition=not is_env_true("TEST_PERFORMANCE"), + reason="Too resource intensive to be randomly allocated to a runner.", + ), + ), + ], + ) + @markers.aws.only_localstack + def test_sqs_event_source_mapping_batch_size_override( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + cleanups, + aws_client, + batch_size, + ): + function_name = f"lambda_func-{short_uid()}" + queue_name = f"queue-{short_uid()}" + mapping_uuid = None + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + # Send messages in batches of 10 i.e batch_size = 10_000 means 1_000 requests of 10 messages each. + for _ in range(batch_size // 10): + entries = [{"Id": str(i), "MessageBody": json.dumps({"foo": "bar"})} for i in range(10)] + aws_client.sqs.send_message_batch(QueueUrl=queue_url, Entries=entries) + + # Wait a few seconds to ensure all messages are loaded in queue + _await_queue_size(aws_client.sqs, queue_url, batch_size) + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn, + FunctionName=function_name, + MaximumBatchingWindowInSeconds=10, + BatchSize=batch_size, + ) + mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + expected_invocations = math.ceil(batch_size / 2500) + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=1, + function_name=function_name, + expected_length=expected_invocations, + logs_client=aws_client.logs, + ) + + assert sum(len(event.get("Records", [])) for event in events) == batch_size + + rs = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert rs.get("Messages", []) == [] + + @markers.aws.only_localstack + def test_sqs_event_source_mapping_batching_window_size_override( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + cleanups, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + queue_name = f"queue-{short_uid()}" + mapping_uuid = None + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + create_event_source_mapping_response = aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn, + FunctionName=function_name, + MaximumBatchingWindowInSeconds=30, + BatchSize=10_000, + ) + mapping_uuid = create_event_source_mapping_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=mapping_uuid)) + _await_event_source_mapping_enabled(aws_client.lambda_, mapping_uuid) + + # Send 4 messages and delay their arrival by 5, 10, 15, and 25 seconds respectively + for s in [5, 10, 15, 25]: + aws_client.sqs.send_message( + QueueUrl=queue_url, + MessageBody=json.dumps({"delayed": f"{s}"}), + ) + + events = retry( + check_expected_lambda_log_events_length, + retries=60, + sleep=1, + function_name=function_name, + expected_length=1, + logs_client=aws_client.logs, + ) + + assert len(events) == 1 + assert len(events[0].get("Records", [])) == 4 + + rs = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert rs.get("Messages", []) == [] + + +def _await_queue_size(sqs_client, queue_url: str, qsize: int, retries=10, sleep=1): + # wait for all items to appear in the queue + def _verify_event_queue_size(): + attr = "ApproximateNumberOfMessages" + _approx = int( + sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=[attr])[ + "Attributes" + ][attr] + ) + assert _approx >= qsize + + retry(_verify_event_queue_size, retries=retries, sleep=sleep) diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.snapshot.json b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.snapshot.json new file mode 100644 index 0000000000000..cae128ced9d5c --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.snapshot.json @@ -0,0 +1,4580 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_failing_lambda_retries_after_visibility_timeout": { + "recorded-date": "12-10-2024, 13:32:32", + "recorded-content": { + "get_destination_queue_url": { + "QueueUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "event_source_mapping": { + "BatchSize": 1, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 0, + "State": "Enabled", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_attempt": { + "Messages": [ + { + "Body": { + "error": "failed attempt", + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"destination\": \"\", \"fail_attempts\": 1}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_attempt": { + "Messages": [ + { + "Body": { + "error": null, + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"destination\": \"\", \"fail_attempts\": 1}", + "attributes": { + "ApproximateReceiveCount": "2", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_redrive_policy_with_failing_lambda": { + "recorded-date": "12-10-2024, 13:33:30", + "recorded-content": { + "get_destination_queue_url": { + "QueueUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_attempt": { + "Messages": [ + { + "Body": { + "error": "failed attempt", + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"destination\": \"\", \"fail_attempts\": 2}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_attempt": { + "Messages": [ + { + "Body": { + "error": "failed attempt", + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"destination\": \"\", \"fail_attempts\": 2}", + "attributes": { + "ApproximateReceiveCount": "2", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dlq_response": { + "Messages": [ + { + "Body": { + "destination": "", + "fail_attempts": 2 + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_sqs_queue_as_lambda_dead_letter_queue": { + "recorded-date": "12-10-2024, 13:33:41", + "recorded-content": { + "lambda-response-dlq-config": { + "TargetArn": "arn::sqs::111111111111:" + }, + "messages": { + "Messages": [ + { + "Body": { + "raise_error": 1 + }, + "MD5OfBody": "", + "MD5OfMessageAttributes": "", + "MessageAttributes": { + "ErrorCode": { + "DataType": "Number", + "StringValue": "200" + }, + "ErrorMessage": { + "DataType": "String", + "StringValue": "Test exception (this is intentional)" + }, + "RequestID": { + "DataType": "String", + "StringValue": "" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures": { + "recorded-date": "03-03-2025, 11:31:17", + "recorded-content": { + "get_destination_queue_url": { + "QueueUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "send_message_batch": { + "Successful": [ + { + "Id": "message-1", + "MD5OfMessageBody": "", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "message-2", + "MD5OfMessageBody": "", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "message-3", + "MD5OfMessageBody": "", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "message-4", + "MD5OfMessageBody": "", + "MessageId": "", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_event_source_mapping": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [ + "ReportBatchItemFailures" + ], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "first_invocation": { + "Messages": [ + { + "Body": { + "event": { + "Records": [ + { + "attributes": { + "ApproximateFirstReceiveTimestamp": "", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "dedup-1", + "MessageGroupId": "1", + "SenderId": "sender-id", + "SentTimestamp": "sent-timestamp", + "SequenceNumber": "" + }, + "awsRegion": "", + "body": { + "message": 1, + "fail_attempts": 0 + }, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "md5OfBody": "", + "messageAttributes": {}, + "messageId": "", + "receiptHandle": "" + }, + { + "attributes": { + "ApproximateFirstReceiveTimestamp": "", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "dedup-2", + "MessageGroupId": "1", + "SenderId": "sender-id", + "SentTimestamp": "sent-timestamp", + "SequenceNumber": "" + }, + "awsRegion": "", + "body": { + "message": 2, + "fail_attempts": 1 + }, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "md5OfBody": "", + "messageAttributes": {}, + "messageId": "", + "receiptHandle": "" + }, + { + "attributes": { + "ApproximateFirstReceiveTimestamp": "", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "dedup-3", + "MessageGroupId": "1", + "SenderId": "sender-id", + "SentTimestamp": "sent-timestamp", + "SequenceNumber": "" + }, + "awsRegion": "", + "body": { + "message": 3, + "fail_attempts": 1 + }, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "md5OfBody": "", + "messageAttributes": {}, + "messageId": "", + "receiptHandle": "" + }, + { + "attributes": { + "ApproximateFirstReceiveTimestamp": "", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "dedup-4", + "MessageGroupId": "1", + "SenderId": "sender-id", + "SentTimestamp": "sent-timestamp", + "SequenceNumber": "" + }, + "awsRegion": "", + "body": { + "message": 4, + "fail_attempts": 2 + }, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "md5OfBody": "", + "messageAttributes": {}, + "messageId": "", + "receiptHandle": "" + } + ] + }, + "result": { + "batchItemFailures": [ + { + "itemIdentifier": "" + }, + { + "itemIdentifier": "" + }, + { + "itemIdentifier": "" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_invocation": { + "Messages": [ + { + "Body": { + "event": { + "Records": [ + { + "attributes": { + "ApproximateFirstReceiveTimestamp": "", + "ApproximateReceiveCount": "2", + "MessageDeduplicationId": "dedup-2", + "MessageGroupId": "1", + "SenderId": "sender-id", + "SentTimestamp": "sent-timestamp", + "SequenceNumber": "" + }, + "awsRegion": "", + "body": { + "message": 2, + "fail_attempts": 1 + }, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "md5OfBody": "", + "messageAttributes": {}, + "messageId": "", + "receiptHandle": "" + }, + { + "attributes": { + "ApproximateFirstReceiveTimestamp": "", + "ApproximateReceiveCount": "2", + "MessageDeduplicationId": "dedup-3", + "MessageGroupId": "1", + "SenderId": "sender-id", + "SentTimestamp": "sent-timestamp", + "SequenceNumber": "" + }, + "awsRegion": "", + "body": { + "message": 3, + "fail_attempts": 1 + }, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "md5OfBody": "", + "messageAttributes": {}, + "messageId": "", + "receiptHandle": "" + }, + { + "attributes": { + "ApproximateFirstReceiveTimestamp": "", + "ApproximateReceiveCount": "2", + "MessageDeduplicationId": "dedup-4", + "MessageGroupId": "1", + "SenderId": "sender-id", + "SentTimestamp": "sent-timestamp", + "SequenceNumber": "" + }, + "awsRegion": "", + "body": { + "message": 4, + "fail_attempts": 2 + }, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "md5OfBody": "", + "messageAttributes": {}, + "messageId": "", + "receiptHandle": "" + } + ] + }, + "result": { + "batchItemFailures": [ + { + "itemIdentifier": "" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dlq_response": { + "Messages": [ + { + "Body": { + "message": 4, + "fail_attempts": 2 + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_on_lambda_error": { + "recorded-date": "12-10-2024, 13:34:40", + "recorded-content": { + "dlq_messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": "{not a json body" + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "", + "Body": { + "message": 2, + "fail_attempts": 0 + } + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_invalid_result_json_batch_fails": { + "recorded-date": "12-10-2024, 13:35:12", + "recorded-content": { + "get_destination_queue_url": { + "QueueUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_invocation": { + "Messages": [ + { + "Body": { + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"message\": 1, \"fail_attempts\": 0}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + }, + "result": { + "batchItemFailures": [ + { + "foo": "notvalid" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_invocation": { + "Messages": [ + { + "Body": { + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"message\": 1, \"fail_attempts\": 0}", + "attributes": { + "ApproximateReceiveCount": "2", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + }, + "result": { + "batchItemFailures": [ + { + "foo": "notvalid" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dlq_response": { + "Messages": [ + { + "Body": { + "message": 1, + "fail_attempts": 0 + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_empty_json_batch_succeeds": { + "recorded-date": "12-10-2024, 13:35:43", + "recorded-content": { + "get_destination_queue_url": { + "QueueUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_invocation": { + "Messages": [ + { + "Body": { + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"message\": 1, \"fail_attempts\": 0}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + }, + "result": {} + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_event_source_mapping_default_batch_size": { + "recorded-date": "12-10-2024, 13:37:20", + "recorded-content": { + "create-event-source-mapping": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invalid-update-event-source-mapping": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Maximum batch window in seconds must be greater than 0 if maximum batch size is greater than 10" + }, + "Type": "User", + "message": "Maximum batch window in seconds must be greater than 0 if maximum batch size is greater than 10", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-create-event-source-mapping": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Maximum batch window in seconds must be greater than 0 if maximum batch size is greater than 10" + }, + "Type": "User", + "message": "Maximum batch window in seconds must be greater than 0 if maximum batch size is greater than 10", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping": { + "recorded-date": "12-10-2024, 13:38:02", + "recorded-content": { + "create-event-source-mapping-response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "foo": "bar" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter0-item_matching0-item_not_matching0]": { + "recorded-date": "12-10-2024, 13:38:39", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "testItem": [ + "test24" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "testItem": "test24" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter1-item_matching1-item_not_matching1]": { + "recorded-date": "12-10-2024, 13:39:28", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "testItem": [ + "test24", + "test45" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "testItem": "test45" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter2-item_matching2-item_not_matching2]": { + "recorded-date": "12-10-2024, 13:40:02", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "testItem": [ + "test24", + "test45" + ], + "test2": [ + "go" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "testItem": "test45", + "test2": "go" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter3-item_matching3-item_not_matching3]": { + "recorded-date": "12-10-2024, 13:40:47", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "test2": [ + { + "exists": true + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "test2": "7411" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter4-item_matching4-this is a test string]": { + "recorded-date": "12-10-2024, 13:41:34", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "test2": [ + { + "numeric": [ + ">", + 100 + ] + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "test2": 105 + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter5-item_matching5-item_not_matching5]": { + "recorded-date": "12-10-2024, 13:42:13", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "test2": [ + { + "numeric": [ + "<", + 100 + ] + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "test2": 93 + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter6-item_matching6-item_not_matching6]": { + "recorded-date": "12-10-2024, 13:42:54", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "test2": [ + { + "numeric": [ + ">=", + 100, + "<", + 200 + ] + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "test2": 105 + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter7-item_matching7-item_not_matching7]": { + "recorded-date": "12-10-2024, 13:43:33", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "test2": [ + { + "prefix": "us-1" + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "test2": "us-1-48454" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[None]": { + "recorded-date": "12-10-2024, 13:43:37", + "recorded-content": { + "create_event_source_mapping_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Invalid filter pattern definition." + }, + "Type": "User", + "message": "Invalid filter pattern definition.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[simple string]": { + "recorded-date": "12-10-2024, 13:43:42", + "recorded-content": { + "create_event_source_mapping_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Invalid filter pattern definition." + }, + "Type": "User", + "message": "Invalid filter pattern definition.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[invalid_filter2]": { + "recorded-date": "12-10-2024, 13:43:47", + "recorded-content": { + "create_event_source_mapping_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Invalid filter pattern definition." + }, + "Type": "User", + "message": "Invalid filter pattern definition.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[invalid_filter3]": { + "recorded-date": "12-10-2024, 13:43:52", + "recorded-content": { + "create_event_source_mapping_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Invalid filter pattern definition." + }, + "Type": "User", + "message": "Invalid filter pattern definition.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_message_body_and_attributes_passed_correctly": { + "recorded-date": "12-10-2024, 13:32:53", + "recorded-content": { + "get_destination_queue_url": { + "QueueUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_attempt": { + "Messages": [ + { + "Body": { + "error": null, + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "{\"destination\": \"\", \"fail_attempts\": 0}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": { + "WeeksOn": { + "stringValue": "6", + "stringListValues": [], + "binaryListValues": [], + "dataType": "Number" + }, + "Author": { + "stringValue": "John Grisham", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String" + }, + "Title": { + "stringValue": "The Whistler", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String" + } + }, + "md5OfBody": "", + "md5OfMessageAttributes": "d25a6aea97eb8f585bfa92d314504a92", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_update": { + "recorded-date": "12-10-2024, 13:45:45", + "recorded-content": { + "create-event-source-mapping-response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "events": [ + { + "function_version": "1", + "CUSTOM_VAR": "a", + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "foo": "bar" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + } + ], + "updated_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Updating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "updated_event_source_mapping": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Enabled", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "events_postupdate": [ + { + "function_version": "1", + "CUSTOM_VAR": "a", + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "foo": "bar" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + { + "function_version": "2", + "CUSTOM_VAR": "b", + "event": { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "foo": "bar2" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_duplicate_event_source_mappings": { + "recorded-date": "12-10-2024, 13:45:56", + "recorded-content": { + "create": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "error": { + "Error": { + "Code": "ResourceConflictException", + "Message": "An event source mapping with SQS arn (\" arn::sqs::111111111111: \") and function (\" \") already exists. Please update or delete the existing mapping with UUID " + }, + "Type": "User", + "message": "An event source mapping with SQS arn (\" arn::sqs::111111111111: \") and function (\" \") already exists. Please update or delete the existing mapping with UUID ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_fifo_message_group_parallelism": { + "recorded-date": "12-10-2024, 13:37:01", + "recorded-content": { + "create_esm_disabled": { + "BatchSize": 1, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 0, + "State": "Disabled", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "update_esm_enabling": { + "BatchSize": 1, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 0, + "State": "Enabling", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get_esm_enabled": { + "BatchSize": 1, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 0, + "State": "Enabled", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[15]": { + "recorded-date": "11-12-2024, 13:42:57", + "recorded-content": { + "create-event-source-mapping-response": { + "BatchSize": 15, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": "maximum-batching-window-in-seconds", + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "send-message-batch-result-10": { + "Successful": [ + { + "Id": "0-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "5-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "6-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "7-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "8-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "9-0", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "send-message-batch-result-5": { + "Successful": [ + { + "Id": "0-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-1", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "0-0-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "md5OfMessageAttributes": null, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "0-1-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-0-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "md5OfMessageAttributes": null, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-1-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "md5OfMessageAttributes": null, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-0-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "md5OfMessageAttributes": null, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-1-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-0-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-1-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "md5OfMessageAttributes": null, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-0-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-1-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "5-0-message-5", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "6-0-message-6", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "7-0-message-7", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "8-0-message-8", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "9-0-message-9", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "md5OfMessageAttributes": null, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batching_reserved_concurrency": { + "recorded-date": "25-02-2025, 16:35:01", + "recorded-content": { + "put_concurrency_resp": { + "ReservedConcurrentExecutions": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-event-source-mapping-response": { + "BatchSize": 20, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "ScalingConfig": { + "MaximumConcurrency": 2 + }, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "batch_info": { + "total_batches_received": 2 + }, + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "0-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "0-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "0-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "5-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "5-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "5-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "6-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "6-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "6-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "7-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "7-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "7-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "8-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "8-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "8-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "9-0-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "9-1-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "9-2-message", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[100]": { + "recorded-date": "11-12-2024, 13:43:49", + "recorded-content": { + "create-event-source-mapping-response": { + "BatchSize": 100, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": "maximum-batching-window-in-seconds", + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "send-message-batch-result-10": { + "Successful": [ + { + "Id": "0-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "5-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "6-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "7-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "8-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "9-0", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "send-message-batch-result-5": { + "Successful": [ + { + "Id": "0-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-1", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "0-0-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfBody": "", + "md5OfMessageAttributes": null, + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "0-1-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-0-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-1-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-0-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-1-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-0-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-1-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-0-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-1-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "5-0-message-5", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "6-0-message-6", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "7-0-message-7", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "8-0-message-8", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "9-0-message-9", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[1000]": { + "recorded-date": "11-12-2024, 13:44:40", + "recorded-content": { + "create-event-source-mapping-response": { + "BatchSize": 1000, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": "maximum-batching-window-in-seconds", + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "send-message-batch-result-10": { + "Successful": [ + { + "Id": "0-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "5-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "6-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "7-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "8-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "9-0", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "send-message-batch-result-5": { + "Successful": [ + { + "Id": "0-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-1", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "0-0-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "0-1-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-0-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-1-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-0-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-1-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-0-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-1-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-0-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-1-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "5-0-message-5", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "6-0-message-6", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "7-0-message-7", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "8-0-message-8", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "9-0-message-9", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[10000]": { + "recorded-date": "11-12-2024, 13:45:32", + "recorded-content": { + "create-event-source-mapping-response": { + "BatchSize": 10000, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": "maximum-batching-window-in-seconds", + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "send-message-batch-result-10": { + "Successful": [ + { + "Id": "0-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "5-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "6-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "7-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "8-0", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "9-0", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "send-message-batch-result-5": { + "Successful": [ + { + "Id": "0-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "1-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "2-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "3-1", + "MD5OfMessageBody": "", + "MessageId": "" + }, + { + "Id": "4-1", + "MD5OfMessageBody": "", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": "0-0-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "0-1-message-0", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-0-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "1-1-message-1", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-0-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "2-1-message-2", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-0-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "3-1-message-3", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-0-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "4-1-message-4", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "5-0-message-5", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "6-0-message-6", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "7-0-message-7", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "8-0-message-8", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + }, + { + "messageId": "", + "receiptHandle": "", + "body": "9-0-message-9", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batching_behaviour[100]": { + "recorded-date": "26-11-2024, 14:23:08", + "recorded-content": {} + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[single-filter]": { + "recorded-date": "10-12-2024, 17:35:40", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "testItem": [ + "test24" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "testItem": "test24" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[or-filter]": { + "recorded-date": "10-12-2024, 17:36:20", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "testItem": [ + "test24", + "test45" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "testItem": "test45" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[and-filter]": { + "recorded-date": "10-12-2024, 17:37:03", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "testItem": [ + "test24", + "test45" + ], + "test2": [ + "go" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "testItem": "test45", + "test2": "go" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[exists-filter]": { + "recorded-date": "10-12-2024, 17:37:41", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "test2": [ + { + "exists": true + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "test2": "7411" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-bigger]": { + "recorded-date": "10-12-2024, 19:37:10", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-number": [ + { + "numeric": [ + ">", + 100 + ] + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-number": 101 + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-smaller]": { + "recorded-date": "10-12-2024, 19:37:55", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-number": [ + { + "numeric": [ + "<", + 100 + ] + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-number": 99 + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-range]": { + "recorded-date": "10-12-2024, 19:38:28", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-number": [ + { + "numeric": [ + ">=", + 100, + "<", + 200 + ] + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-number": 100 + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-prefix]": { + "recorded-date": "10-12-2024, 17:40:33", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "test2": [ + { + "prefix": "us-1" + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "test2": "us-1-48454" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[single]": { + "recorded-date": "10-12-2024, 19:34:32", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-key": [ + "my-value" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-key": "my-value" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[or]": { + "recorded-date": "10-12-2024, 19:35:19", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-key": [ + "my-value-one", + "my-value-two" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-key": "my-value-two" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[and]": { + "recorded-date": "10-12-2024, 19:35:54", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-key-one": [ + "other-filter", + "my-value-one" + ], + "my-key-two": [ + "my-value-two" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-key-one": "my-value-one", + "my-key-two": "my-value-two" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[exists]": { + "recorded-date": "10-12-2024, 19:36:24", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-key": [ + { + "exists": true + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-key": "any-value-one" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[plain-string-matching]": { + "recorded-date": "10-12-2024, 19:47:26", + "recorded-content": {} + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[plain-string-filter]": { + "recorded-date": "10-12-2024, 19:47:31", + "recorded-content": {} + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[valid-json-filter]": { + "recorded-date": "10-12-2024, 19:40:28", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-key": [ + "my-value" + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-key": "my-value" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[prefix]": { + "recorded-date": "10-12-2024, 19:47:22", + "recorded-content": { + "create_event_source_mapping_response": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FilterCriteria": { + "Filters": [ + { + "Pattern": { + "body": { + "my-key": [ + { + "prefix": "yes" + } + ] + } + } + } + ] + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "", + "MaximumBatchingWindowInSeconds": 1, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invocation_events": [ + { + "Records": [ + { + "messageId": "", + "receiptHandle": "", + "body": { + "my-key": "yes-value" + }, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "sent-timestamp", + "SenderId": "sender-id", + "ApproximateFirstReceiveTimestamp": "" + }, + "messageAttributes": {}, + "md5OfMessageAttributes": null, + "md5OfBody": "", + "eventSource": "aws:sqs", + "eventSourceARN": "arn::sqs::111111111111:", + "awsRegion": "" + } + ] + } + ] + } + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_esm_with_not_existing_sqs_queue": { + "recorded-date": "26-02-2025, 03:01:33", + "recorded-content": { + "error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Error occurred while ReceiveMessage. SQS Error Code: AWS.SimpleQueueService.NonExistentQueue. SQS Error Message: The specified queue does not exist." + }, + "Type": "User", + "message": "Error occurred while ReceiveMessage. SQS Error Code: AWS.SimpleQueueService.NonExistentQueue. SQS Error Message: The specified queue does not exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.validation.json b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.validation.json new file mode 100644 index 0000000000000..6c608cee264c9 --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.validation.json @@ -0,0 +1,137 @@ +{ + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_duplicate_event_source_mappings": { + "last_validated_date": "2024-10-12T13:45:52+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_event_source_mapping_default_batch_size": { + "last_validated_date": "2024-10-12T13:37:18+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[and-filter]": { + "last_validated_date": "2024-12-10T17:37:02+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[and]": { + "last_validated_date": "2024-12-10T19:35:53+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[exists-filter]": { + "last_validated_date": "2024-12-10T17:37:40+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[exists]": { + "last_validated_date": "2024-12-10T19:36:23+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter0-item_matching0-item_not_matching0]": { + "last_validated_date": "2024-10-12T13:38:37+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter1-item_matching1-item_not_matching1]": { + "last_validated_date": "2024-10-12T13:39:27+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter2-item_matching2-item_not_matching2]": { + "last_validated_date": "2024-10-12T13:40:01+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter3-item_matching3-item_not_matching3]": { + "last_validated_date": "2024-10-12T13:40:46+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter4-item_matching4-this is a test string]": { + "last_validated_date": "2024-10-12T13:41:32+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter5-item_matching5-item_not_matching5]": { + "last_validated_date": "2024-10-12T13:42:11+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter6-item_matching6-item_not_matching6]": { + "last_validated_date": "2024-10-12T13:42:52+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[filter7-item_matching7-item_not_matching7]": { + "last_validated_date": "2024-10-12T13:43:31+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-bigger]": { + "last_validated_date": "2024-12-10T19:37:09+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-prefix]": { + "last_validated_date": "2024-12-10T17:40:32+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-range]": { + "last_validated_date": "2024-12-10T19:38:27+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[numeric-smaller]": { + "last_validated_date": "2024-12-10T19:37:54+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[or-filter]": { + "last_validated_date": "2024-12-10T17:36:19+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[or]": { + "last_validated_date": "2024-12-10T19:35:18+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[prefix]": { + "last_validated_date": "2024-12-10T19:47:21+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[single-filter]": { + "last_validated_date": "2024-12-10T17:35:39+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[single]": { + "last_validated_date": "2024-12-10T19:34:31+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_filter[valid-json-filter]": { + "last_validated_date": "2024-12-10T19:40:27+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping": { + "last_validated_date": "2024-11-25T15:46:54+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[10000]": { + "last_validated_date": "2024-12-11T13:45:31+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[1000]": { + "last_validated_date": "2024-12-11T13:44:38+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[100]": { + "last_validated_date": "2024-12-11T13:43:48+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batch_size[15]": { + "last_validated_date": "2024-12-11T13:42:55+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_batching_reserved_concurrency": { + "last_validated_date": "2025-02-25T16:34:59+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_event_source_mapping_update": { + "last_validated_date": "2024-10-12T13:45:43+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[None]": { + "last_validated_date": "2024-10-12T13:43:35+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[invalid_filter2]": { + "last_validated_date": "2024-10-12T13:43:45+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[invalid_filter3]": { + "last_validated_date": "2024-10-12T13:43:50+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::TestSQSEventSourceMapping::test_sqs_invalid_event_filter[simple string]": { + "last_validated_date": "2024-10-12T13:43:40+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_esm_with_not_existing_sqs_queue": { + "last_validated_date": "2025-02-26T03:01:32+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_failing_lambda_retries_after_visibility_timeout": { + "last_validated_date": "2024-11-25T12:12:47+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_fifo_message_group_parallelism": { + "last_validated_date": "2024-10-12T13:37:00+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_message_body_and_attributes_passed_correctly": { + "last_validated_date": "2024-10-12T13:32:50+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_redrive_policy_with_failing_lambda": { + "last_validated_date": "2024-10-12T13:33:27+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures": { + "last_validated_date": "2025-03-03T11:31:14+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_empty_json_batch_succeeds": { + "last_validated_date": "2024-10-12T13:35:40+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_invalid_result_json_batch_fails": { + "last_validated_date": "2024-10-12T13:35:09+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_report_batch_item_failures_on_lambda_error": { + "last_validated_date": "2024-10-12T13:34:37+00:00" + }, + "tests/aws/services/lambda_/event_source_mapping/test_lambda_integration_sqs.py::test_sqs_queue_as_lambda_dead_letter_queue": { + "last_validated_date": "2024-10-12T13:33:39+00:00" + } +} diff --git a/tests/aws/services/lambda_/event_source_mapping/utils.py b/tests/aws/services/lambda_/event_source_mapping/utils.py new file mode 100644 index 0000000000000..c8bb04e7dd31c --- /dev/null +++ b/tests/aws/services/lambda_/event_source_mapping/utils.py @@ -0,0 +1,12 @@ +_LAMBDA_WITH_RESPONSE = """ +import json + +def handler(event, context): + print(json.dumps(event)) + return {response} +""" + + +def create_lambda_with_response(response: str) -> str: + """Creates a lambda with pre-defined response""" + return _LAMBDA_WITH_RESPONSE.format(response=response) diff --git a/tests/aws/services/lambda_/functions/__init__.py b/tests/aws/services/lambda_/functions/__init__.py new file mode 100644 index 0000000000000..35876e8a6dcdc --- /dev/null +++ b/tests/aws/services/lambda_/functions/__init__.py @@ -0,0 +1,4 @@ +from pathlib import Path + +"""Importing this variable is more robust with respect to refactorings compared to relative paths everywhere.""" +FUNCTIONS_PATH = Path(__file__).resolve().parent diff --git a/tests/aws/services/lambda_/functions/apigw_integration.js b/tests/aws/services/lambda_/functions/apigw_integration.js new file mode 100644 index 0000000000000..78e91368680e6 --- /dev/null +++ b/tests/aws/services/lambda_/functions/apigw_integration.js @@ -0,0 +1,9 @@ +exports.handler = async function (event, context) { + console.log("FUNCTION HAS RUN") + return { + isBase64Encoded: false, + statusCode: 300, + headers: { "content-type": "application/xml" }, + body: "completed" + } +} diff --git a/tests/aws/services/lambda_/functions/common/.gitignore b/tests/aws/services/lambda_/functions/common/.gitignore new file mode 100644 index 0000000000000..aecdcde92b5b7 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/.gitignore @@ -0,0 +1,4 @@ +**/build/ +**/*.zip + +**/.gradle diff --git a/tests/aws/services/lambda_/functions/common/Makefile b/tests/aws/services/lambda_/functions/common/Makefile new file mode 100644 index 0000000000000..1fd2a9d5903cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/Makefile @@ -0,0 +1,10 @@ +# Top-level Makefile to invoke all make targets in sub-directories + +# Based on https://stackoverflow.com/a/72209214/6875981 +SUBDIRS := $(patsubst %/,%,$(wildcard */)) + +.PHONY: all $(MAKECMDGOALS) $(SUBDIRS) +$(MAKECMDGOALS) all: $(SUBDIRS) + +$(SUBDIRS): + $(MAKE) -C $@ $(MAKECMDGOALS) diff --git a/tests/aws/services/lambda_/functions/common/README.md b/tests/aws/services/lambda_/functions/common/README.md new file mode 100644 index 0000000000000..1ddce71287a31 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/README.md @@ -0,0 +1,51 @@ +# Lambda Multiruntime Builds + +This directory contains the source code and build instructions for Lambda multiruntime tests. +Example tests are available under `tests.aws.lambda_.functions.common` + +Each scenario (e.g., "echo") has the following folder structure: `.//runtime/` +A runtime can be an aggregated runtime defined in `runtimes.py` (e.g., "python") or +a specific runtime (e.g., "python3.12") if customizations are required. + +Each runtime directory defines a `Makefile` that +* MUST define a `build` target that: + * a) creates a `build` directory containing all Lambda sources ready for packaging + * b) creates a `handler.zip` file with a Lambda deployment package +* SHOULD define an `ARCHITECTURE` parameter to overwrite the target architecture (i.e., `x86_64` or `arm64`) + if architecture-specific builds are required (e.g., Dotnet, Golang, Rust). + * By default, the Makefile should build a deployment package with the same architecture as the host. + * However, for testing on multi-architecture platforms, we should be able to overwrite the `ARCHITECTURE` parameter. + * We need to standardize `uname -m` into `ARCHITECTURE` because the output differs depending on the platform. + Ubuntu yields `aarch64` and macOS yields `arm64`. + * If we want to support dev systems without the `uname` utility, we could add `|| echo x86_64` to the uname detection. +* SHOULD define a `clean` target that deletes any build artefacts, including the `handler.zip`. + This helps a lot during development to tidy up and invalidate caching. + +Checkout the [AWS guides](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-package.html) on +"Building with " (e.g., "Building with Java") for instructions how to +build Lambda deployment packages correctly. + +The top-level and intermediary directories provided a meta-Makefile that automatically invokes sub-Makefiles such that +we can run `make clean` at the top-level recursively. + +## Dotnet + +The `dotnet` directory contains the original source code and a parametrizable Makefile to build multiple Dotnet versions. +We create individual subdirectories for supported Dotnet versions (e.g., `dotnet6` and `dotnet8`) with a Makefile that +invokes the original Makefile in the `dotnet` directory. + +Using the shared `dotnet` directory has a couple of limitations: +* In CI, we currently waste one extra build cycle for the top-level dotnet directory +* Concurrent builds of Dotnet runtimes are unsafe +* We need to use a concrete sub-directory (i.e., `dotnet6` and `dotnet8`) for pre-building +* We need to clean before building to avoid picking up leftover from another Dotnet version build +* We need to parametrize the build directory to mitigate a Docker race condition when executing two builds in succession + +## Rust + +ARM builds had some issues but were finally fixed. Here are the relevant sources: + +* List of Rust build targets in the docs: https://doc.rust-lang.org/nightly/rustc/platform-support.html +* This issue mentioned that "Aarch64 stack probes are tested in CI" and everything should work: https://github.com/rust-lang/rust/issues/77071 +* The fix was done in this PR and released with Rust `1.76.0`: https://github.com/rust-lang/rust/pull/118491 +* The `-musl` suffix was required to fix a GLIBC not found error with the Lambda runtime `provided.al2` diff --git a/tests/aws/services/lambda_/functions/common/__init__.py b/tests/aws/services/lambda_/functions/common/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/echo/Makefile b/tests/aws/services/lambda_/functions/common/echo/Makefile new file mode 100644 index 0000000000000..1fd2a9d5903cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/Makefile @@ -0,0 +1,10 @@ +# Top-level Makefile to invoke all make targets in sub-directories + +# Based on https://stackoverflow.com/a/72209214/6875981 +SUBDIRS := $(patsubst %/,%,$(wildcard */)) + +.PHONY: all $(MAKECMDGOALS) $(SUBDIRS) +$(MAKECMDGOALS) all: $(SUBDIRS) + +$(SUBDIRS): + $(MAKE) -C $@ $(MAKECMDGOALS) diff --git a/tests/aws/services/lambda_/functions/common/echo/__init__.py b/tests/aws/services/lambda_/functions/common/echo/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/echo/dotnet/Makefile b/tests/aws/services/lambda_/functions/common/echo/dotnet/Makefile new file mode 100644 index 0000000000000..31704c534390c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/dotnet/Makefile @@ -0,0 +1,36 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +# The packaging function architecture is x86_64 by default and needs to be set explicitly for arm64 +# https://github.com/aws/aws-extensions-for-dotnet-cli/blob/cdd490450e0407139d49248d94a4a899367e84df/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs#L111 +FUNCTION_ARCHITECTURE ?= $(ARCHITECTURE) + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 +# Workaround for a Docker race condition causing an I/O error upon zipping to /out/handler.zip if +# two builds are executed in short succession. Example: `make -C dotnet build && make -C dotnet6 build` +BUILD_DIR ?= build-$(FRAMEWORK) + +# https://gallery.ecr.aws/sam/build-dotnet8 +IMAGE ?= public.ecr.aws/sam/build-dotnet8:1.112.0 + +# Emulated builds with Dotnet8 are currently (2024-03-19) broken as discussed in many issues: +# https://github.com/NuGet/Home/issues/12227 +# https://github.com/dotnet/runtime/issues/78340 +# https://github.com/dotnet/msbuild/issues/8508 +# Root cause QEMU issue: https://gitlab.com/qemu-project/qemu/-/issues/249 +# Workaround: Instead of emulating the build (works for Dotnet6), we use the native Docker image +# and cross-build the Dotnet package using the flag `--function-architecture` (x86_64 or arm64). + +build: + mkdir -p $(BUILD_DIR) && \ + docker run --rm -v $$(pwd)/src:/app -v $$(pwd)/$(BUILD_DIR):/out $(IMAGE) bash -c "mkdir -p /app2 && cp /app/* /app2 && cd /app2 && dotnet lambda package --framework $(FRAMEWORK) --function-architecture $(FUNCTION_ARCHITECTURE) -o ../out/handler.zip" && \ + cp $(BUILD_DIR)/handler.zip handler.zip + +clean: + $(RM) -r $(BUILD_DIR) handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/dotnet/src/Function.cs b/tests/aws/services/lambda_/functions/common/echo/dotnet/src/Function.cs new file mode 100644 index 0000000000000..4b1fb83ea3719 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/dotnet/src/Function.cs @@ -0,0 +1,18 @@ +using System.IO; + +using Amazon.Lambda.Core; + +// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace Dotnet +{ + public class Function + { + + public object FunctionHandler(object input, ILambdaContext context) + { + return input; + } + } +} diff --git a/tests/aws/services/lambda_/functions/common/echo/dotnet/src/dotnet.csproj b/tests/aws/services/lambda_/functions/common/echo/dotnet/src/dotnet.csproj new file mode 100644 index 0000000000000..1bd07d2e45a5a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/dotnet/src/dotnet.csproj @@ -0,0 +1,13 @@ +ο»Ώ + + net6.0;net8.0 + true + Lambda + + + + + + + + diff --git a/tests/aws/services/lambda_/functions/common/echo/dotnet6/Makefile b/tests/aws/services/lambda_/functions/common/echo/dotnet6/Makefile new file mode 100644 index 0000000000000..43573df40d616 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/dotnet6/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net6.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean FRAMEWORK=$(FRAMEWORK) + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/dotnet8/Makefile b/tests/aws/services/lambda_/functions/common/echo/dotnet8/Makefile new file mode 100644 index 0000000000000..f7c7f2ec8908c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/dotnet8/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/java/Makefile b/tests/aws/services/lambda_/functions/common/echo/java/Makefile new file mode 100644 index 0000000000000..6916941cbe138 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/java/Makefile @@ -0,0 +1,12 @@ +# https://hub.docker.com/_/gradle/tags +IMAGE ?= gradle:8.4.0-jdk17 + +build: + mkdir -p build && \ + docker run --rm -v "$$(pwd)/src:/app" -v "$$(pwd)/build:/out" $(IMAGE) bash -c "mkdir -p /build && cp -r /app/* /build && cd /build && gradle packageFat && cp build/distributions/build.zip /out/handler.zip" && \ + cp build/handler.zip handler.zip + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/java/src/build.gradle b/tests/aws/services/lambda_/functions/common/echo/java/src/build.gradle new file mode 100644 index 0000000000000..7427f9c4497dc --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/java/src/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' +} + +java { + // Target oldest tested Java runtime + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS Java libraries https://github.com/aws/aws-lambda-java-libs + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + + // SLF4J logging + implementation 'org.slf4j:slf4j-nop:2.0.9' +} + +task packageFat(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageLibs(type: Zip) { + into('java/lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageSkinny(type: Zip) { + from compileJava + from processResources +} + +tasks.withType(AbstractArchiveTask) { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +build.dependsOn packageSkinny diff --git a/tests/aws/services/lambda_/functions/common/echo/java/src/src/main/java/echo/Handler.java b/tests/aws/services/lambda_/functions/common/echo/java/src/src/main/java/echo/Handler.java new file mode 100644 index 0000000000000..c159a00be5614 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/java/src/src/main/java/echo/Handler.java @@ -0,0 +1,20 @@ +package echo; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class Handler implements RequestStreamHandler { + @Override + public void handleRequest(InputStream input, OutputStream output, Context context) throws IOException { + int n; + byte[] buffer = new byte[1024]; + while((n = input.read(buffer)) > -1) { + output.write(buffer,0, n); + } + output.close(); + } +} diff --git a/tests/aws/services/lambda_/functions/common/echo/nodejs/Makefile b/tests/aws/services/lambda_/functions/common/echo/nodejs/Makefile new file mode 100644 index 0000000000000..c3b2190a84a3a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/nodejs/Makefile @@ -0,0 +1,8 @@ +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/nodejs/src/index.js b/tests/aws/services/lambda_/functions/common/echo/nodejs/src/index.js new file mode 100644 index 0000000000000..3eb4131296527 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/nodejs/src/index.js @@ -0,0 +1,3 @@ +exports.handler = async function(event, context) { + return event +}; diff --git a/tests/aws/services/lambda_/functions/common/echo/provided/Makefile b/tests/aws/services/lambda_/functions/common/echo/provided/Makefile new file mode 100644 index 0000000000000..09d0587c47e0e --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/provided/Makefile @@ -0,0 +1,8 @@ +build: + mkdir -p build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/provided/src/bootstrap b/tests/aws/services/lambda_/functions/common/echo/provided/src/bootstrap new file mode 100755 index 0000000000000..b0be430f83a5b --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/provided/src/bootstrap @@ -0,0 +1,23 @@ +#!/bin/sh + +set -euo pipefail + +# Initialization - load function handler +source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh" + +# Processing +while true +do + HEADERS="$(mktemp)" + # Get an event. The HTTP request will block until one is received + EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next") + + # Extract request ID by scraping response headers received above + REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2) + + # Run the handler function from the script + RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA") + + # Send the response (using stdin to circumvent max input length) + echo ${RESPONSE} | curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" --data @'-' +done diff --git a/tests/aws/services/lambda_/functions/common/echo/provided/src/function.sh b/tests/aws/services/lambda_/functions/common/echo/provided/src/function.sh new file mode 100755 index 0000000000000..641726e064c0d --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/provided/src/function.sh @@ -0,0 +1,7 @@ +function handler () { + EVENT_DATA=$1 + echo "$EVENT_DATA" 1>&2; + RESPONSE=$EVENT_DATA + + echo $RESPONSE +} diff --git a/tests/aws/services/lambda_/functions/common/echo/python/Makefile b/tests/aws/services/lambda_/functions/common/echo/python/Makefile new file mode 100644 index 0000000000000..c3b2190a84a3a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/python/Makefile @@ -0,0 +1,8 @@ +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/python/__init__.py b/tests/aws/services/lambda_/functions/common/echo/python/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/echo/python/src/__init__.py b/tests/aws/services/lambda_/functions/common/echo/python/src/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/echo/python/src/handler.py b/tests/aws/services/lambda_/functions/common/echo/python/src/handler.py new file mode 100644 index 0000000000000..aa9c0481ffbe6 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/python/src/handler.py @@ -0,0 +1,6 @@ +import json + + +def handler(event, ctx): + print(json.dumps(event)) + return event diff --git a/tests/aws/services/lambda_/functions/common/echo/ruby/Makefile b/tests/aws/services/lambda_/functions/common/echo/ruby/Makefile new file mode 100644 index 0000000000000..7ccfb2d560a72 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/ruby/Makefile @@ -0,0 +1,8 @@ +build: + mkdir -p build && \ + cp -r ./src/* build/ + +clean: + rm -rf build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/echo/ruby/src/function.rb b/tests/aws/services/lambda_/functions/common/echo/ruby/src/function.rb new file mode 100644 index 0000000000000..6aad70a9b9c01 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/echo/ruby/src/function.rb @@ -0,0 +1,3 @@ +def handler(event:, context:) + event +end diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/Makefile new file mode 100644 index 0000000000000..1fd2a9d5903cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/Makefile @@ -0,0 +1,10 @@ +# Top-level Makefile to invoke all make targets in sub-directories + +# Based on https://stackoverflow.com/a/72209214/6875981 +SUBDIRS := $(patsubst %/,%,$(wildcard */)) + +.PHONY: all $(MAKECMDGOALS) $(SUBDIRS) +$(MAKECMDGOALS) all: $(SUBDIRS) + +$(SUBDIRS): + $(MAKE) -C $@ $(MAKECMDGOALS) diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/__init__.py b/tests/aws/services/lambda_/functions/common/endpointinjection/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/Makefile new file mode 100644 index 0000000000000..31704c534390c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/Makefile @@ -0,0 +1,36 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +# The packaging function architecture is x86_64 by default and needs to be set explicitly for arm64 +# https://github.com/aws/aws-extensions-for-dotnet-cli/blob/cdd490450e0407139d49248d94a4a899367e84df/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs#L111 +FUNCTION_ARCHITECTURE ?= $(ARCHITECTURE) + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 +# Workaround for a Docker race condition causing an I/O error upon zipping to /out/handler.zip if +# two builds are executed in short succession. Example: `make -C dotnet build && make -C dotnet6 build` +BUILD_DIR ?= build-$(FRAMEWORK) + +# https://gallery.ecr.aws/sam/build-dotnet8 +IMAGE ?= public.ecr.aws/sam/build-dotnet8:1.112.0 + +# Emulated builds with Dotnet8 are currently (2024-03-19) broken as discussed in many issues: +# https://github.com/NuGet/Home/issues/12227 +# https://github.com/dotnet/runtime/issues/78340 +# https://github.com/dotnet/msbuild/issues/8508 +# Root cause QEMU issue: https://gitlab.com/qemu-project/qemu/-/issues/249 +# Workaround: Instead of emulating the build (works for Dotnet6), we use the native Docker image +# and cross-build the Dotnet package using the flag `--function-architecture` (x86_64 or arm64). + +build: + mkdir -p $(BUILD_DIR) && \ + docker run --rm -v $$(pwd)/src:/app -v $$(pwd)/$(BUILD_DIR):/out $(IMAGE) bash -c "mkdir -p /app2 && cp /app/* /app2 && cd /app2 && dotnet lambda package --framework $(FRAMEWORK) --function-architecture $(FUNCTION_ARCHITECTURE) -o ../out/handler.zip" && \ + cp $(BUILD_DIR)/handler.zip handler.zip + +clean: + $(RM) -r $(BUILD_DIR) handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/src/Function.cs b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/src/Function.cs new file mode 100644 index 0000000000000..569a40de2215b --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/src/Function.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Threading.Tasks; +using System; + +using Amazon.Lambda.Core; +using Amazon.Runtime; +using Amazon.SQS; +using Amazon.SQS.Model; + +// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace Dotnet +{ + + public class Function + { + + public async Task FunctionHandler(object input, ILambdaContext context) + { + AmazonSQSConfig sqsClientConfig = new AmazonSQSConfig(); + string endpointUrl = Environment.GetEnvironmentVariable("AWS_ENDPOINT_URL"); + if (endpointUrl != null) { + sqsClientConfig = new AmazonSQSConfig + { + ServiceURL = endpointUrl, + }; + } + AmazonSQSClient sqsClient = new AmazonSQSClient(sqsClientConfig); + + ListQueuesRequest request = new ListQueuesRequest(); + ListQueuesResponse response = await sqsClient.ListQueuesAsync(request); + Console.WriteLine("QueueUrls: [" + string.Join(", ", response.QueueUrls) + "]"); + } + } +} diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/src/dotnet.csproj b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/src/dotnet.csproj new file mode 100644 index 0000000000000..3db7fab8de15c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet/src/dotnet.csproj @@ -0,0 +1,15 @@ +ο»Ώ + + net6.0;net8.0 + true + Lambda + + + + + + + + + + diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet6/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet6/Makefile new file mode 100644 index 0000000000000..43573df40d616 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet6/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net6.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean FRAMEWORK=$(FRAMEWORK) + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet8/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet8/Makefile new file mode 100644 index 0000000000000..7ec1ea467ff87 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/dotnet8/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean FRAMEWORK=$(FRAMEWORK) + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/java/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/java/Makefile new file mode 100644 index 0000000000000..6916941cbe138 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/java/Makefile @@ -0,0 +1,12 @@ +# https://hub.docker.com/_/gradle/tags +IMAGE ?= gradle:8.4.0-jdk17 + +build: + mkdir -p build && \ + docker run --rm -v "$$(pwd)/src:/app" -v "$$(pwd)/build:/out" $(IMAGE) bash -c "mkdir -p /build && cp -r /app/* /build && cd /build && gradle packageFat && cp build/distributions/build.zip /out/handler.zip" && \ + cp build/handler.zip handler.zip + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/java/src/build.gradle b/tests/aws/services/lambda_/functions/common/endpointinjection/java/src/build.gradle new file mode 100644 index 0000000000000..3d69c6574ea23 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/java/src/build.gradle @@ -0,0 +1,59 @@ +plugins { + id 'java' +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS SDK v2: https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html + implementation platform('software.amazon.awssdk:bom:2.21.33') + // AWS Java libraries https://github.com/aws/aws-lambda-java-libs + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + + // Custom SQS and HTTP clients + compileOnly 'software.amazon.awssdk:apache-client' + implementation 'software.amazon.awssdk:sqs' + implementation 'software.amazon.awssdk:url-connection-client' + implementation 'software.amazon.awssdk:aws-crt-client' + implementation 'software.amazon.awssdk:netty-nio-client' + + // SLF4J logging + implementation 'org.slf4j:slf4j-nop:2.0.9' +} + +task packageFat(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageLibs(type: Zip) { + into('java/lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageSkinny(type: Zip) { + from compileJava + from processResources +} + +tasks.withType(AbstractArchiveTask) { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +build.dependsOn packageSkinny diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/java/src/src/main/java/echo/Handler.java b/tests/aws/services/lambda_/functions/common/endpointinjection/java/src/src/main/java/echo/Handler.java new file mode 100644 index 0000000000000..09dcf92e96ad6 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/java/src/src/main/java/echo/Handler.java @@ -0,0 +1,95 @@ +package echo; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.http.crt.AwsCrtAsyncHttpClient; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.SqsClientBuilder; +import software.amazon.awssdk.services.sqs.SqsAsyncClientBuilder; +import software.amazon.awssdk.services.sqs.model.ListQueuesResponse; + +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + + +public class Handler implements RequestHandler, String> { + + public String handleRequest(Map event, Context context) { + // Test only the Java AWS SDK v2 clients because v1 is not shipped by default in the Java runtimes >=java17 + + // v2 synchronous: test both apache and urlconnection http clients to ensure both are instrumented + ListQueuesResponse response = this.getSqsClient().listQueues(); + System.out.println("QueueUrls (SDK v2 sync SQS)=" + response.queueUrls().toString()); + response = this.getUrlConnectionSqsClient().listQueues(); + System.out.println("QueueUrls (SDK v2 sync Url)=" + response.queueUrls().toString()); + + // v2 asynchronous: test both CRT and netty http client + Future listQueuesFutureCrt = this.getAsyncCRTSqsClient().listQueues(); + Future listQueuesFutureNetty = this.getAsyncNettySqsClient().listQueues(); + try { + System.out.println("QueueUrls (SDK v2 async Crt)=" + listQueuesFutureCrt.get()); + System.out.println("QueueUrls (SDK v2 async Netty)=" + listQueuesFutureNetty.get()); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + + return "ok"; + } + + private SqsClient getSqsClient() { + return this.getSqsClientBuilder() + .httpClient(ApacheHttpClient.builder().build()) + .build(); + } + + private SqsClient getUrlConnectionSqsClient() { + return this.getSqsClientBuilder() + .httpClient(UrlConnectionHttpClient.builder().socketTimeout(Duration.ofMinutes(5)).build()) + .build(); + } + + private SqsClientBuilder getSqsClientBuilder() { + String endpointUrl = System.getenv("AWS_ENDPOINT_URL"); + if (endpointUrl != null) { + // Choosing a specific endpoint + // https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/region-selection.html + return SqsClient.builder() + .endpointOverride(URI.create(endpointUrl)); + } + return SqsClient.builder(); + } + + private SqsAsyncClient getAsyncCRTSqsClient() { + return this.getSqsAsyncClientBuilder() + .httpClientBuilder(AwsCrtAsyncHttpClient + .builder() + .connectionTimeout(Duration.ofSeconds(3)) + .maxConcurrency(10)) + .build(); + } + + private SqsAsyncClient getAsyncNettySqsClient() { + return this.getSqsAsyncClientBuilder() + .httpClientBuilder(NettyNioAsyncHttpClient + .builder() + .connectionTimeout(Duration.ofSeconds(3)) + .maxConcurrency(10)) + .build(); + } + + private SqsAsyncClientBuilder getSqsAsyncClientBuilder() { + String endpointUrl = System.getenv("AWS_ENDPOINT_URL"); + if (endpointUrl != null) { + return SqsAsyncClient.builder() + .endpointOverride(URI.create(endpointUrl)); + } + return SqsAsyncClient.builder(); + } +} diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/Makefile new file mode 100644 index 0000000000000..6916941cbe138 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/Makefile @@ -0,0 +1,12 @@ +# https://hub.docker.com/_/gradle/tags +IMAGE ?= gradle:8.4.0-jdk17 + +build: + mkdir -p build && \ + docker run --rm -v "$$(pwd)/src:/app" -v "$$(pwd)/build:/out" $(IMAGE) bash -c "mkdir -p /build && cp -r /app/* /build && cd /build && gradle packageFat && cp build/distributions/build.zip /out/handler.zip" && \ + cp build/handler.zip handler.zip + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/src/build.gradle b/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/src/build.gradle new file mode 100644 index 0000000000000..dcf75827b31d1 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/src/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'java' +} + +java { + // Target oldest tested Java runtime + // VERSION_21 requires a Gradle update, which is incompatible with VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS SDK v1: https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/welcome.html + implementation platform('com.amazonaws:aws-java-sdk-bom:1.12.599') + // Lambda and SQS + implementation 'com.amazonaws:aws-java-sdk-lambda' + implementation 'com.amazonaws:aws-java-sdk-sqs' + + // AWS SDK v2: https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html + implementation platform('software.amazon.awssdk:bom:2.21.35') + // SQS and HTTP clients + implementation 'software.amazon.awssdk:sqs' + compileOnly 'software.amazon.awssdk:apache-client' + implementation 'software.amazon.awssdk:url-connection-client' + implementation 'software.amazon.awssdk:aws-crt-client' + implementation 'software.amazon.awssdk:netty-nio-client' + + // AWS Java libraries https://github.com/aws/aws-lambda-java-libs + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + + // SLF4J logging + implementation 'org.slf4j:slf4j-nop:2.0.9' +} + +task packageFat(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageLibs(type: Zip) { + into('java/lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageSkinny(type: Zip) { + from compileJava + from processResources +} + +tasks.withType(AbstractArchiveTask) { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +build.dependsOn packageSkinny diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/src/src/main/java/echo/Handler.java b/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/src/src/main/java/echo/Handler.java new file mode 100644 index 0000000000000..520c1311fd17e --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/java8.al2/src/src/main/java/echo/Handler.java @@ -0,0 +1,122 @@ +package echo; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.sqs.AmazonSQS; +import com.amazonaws.services.sqs.AmazonSQSClientBuilder; +import com.amazonaws.services.sqs.model.ListQueuesRequest; +import com.amazonaws.services.sqs.model.ListQueuesResult; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.EnvironmentVariableCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +// v2 +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.http.crt.AwsCrtAsyncHttpClient; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.services.sqs.SqsAsyncClient; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.SqsClientBuilder; +import software.amazon.awssdk.services.sqs.SqsAsyncClientBuilder; +import software.amazon.awssdk.services.sqs.model.ListQueuesResponse; + +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + + +public class Handler implements RequestHandler, String> { + + public String handleRequest(Map event, Context context) { + // Inspect ssl property for the Java AWS SDK v1 client. Removed in v2. + System.out.println("com.amazonaws.sdk.disableCertChecking=" + System.getProperty("com.amazonaws.sdk.disableCertChecking")); + + // v1 + ListQueuesResult responseV1 = this.getSqsClientV1().listQueues(new ListQueuesRequest()); + System.out.println("QueueUrls (SDK v1)=" + responseV1.getQueueUrls().toString()); + + // v2 synchronous: test both apache and urlconnection http clients to ensure both are instrumented + ListQueuesResponse response = this.getSqsClient().listQueues(); + System.out.println("QueueUrls (SDK v2 sync SQS)=" + response.queueUrls().toString()); + response = this.getUrlConnectionSqsClient().listQueues(); + System.out.println("QueueUrls (SDK v2 sync Url)=" + response.queueUrls().toString()); + + // v2 asynchronous: test both CRT and netty http client + Future listQueuesFutureCrt = this.getAsyncCRTSqsClient().listQueues(); + Future listQueuesFutureNetty = this.getAsyncNettySqsClient().listQueues(); + try { + System.out.println("QueueUrls (SDK v2 async Crt)=" + listQueuesFutureCrt.get()); + System.out.println("QueueUrls (SDK v2 async Netty)=" + listQueuesFutureNetty.get()); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + + return "ok"; + } + + private AmazonSQS getSqsClientV1() { + String endpointUrl = System.getenv("AWS_ENDPOINT_URL"); + String region = System.getenv("AWS_REGION"); + if (endpointUrl != null) { + return AmazonSQSClientBuilder.standard() + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpointUrl, region)) + .build(); + } + return AmazonSQSClientBuilder.standard().build(); + } + + private SqsClient getSqsClient() { + return this.getSqsClientBuilder() + .httpClient(ApacheHttpClient.builder().build()) + .build(); + } + + private SqsClient getUrlConnectionSqsClient() { + return this.getSqsClientBuilder() + .httpClient(UrlConnectionHttpClient.builder().socketTimeout(Duration.ofMinutes(5)).build()) + .build(); + } + + private SqsClientBuilder getSqsClientBuilder() { + String endpointUrl = System.getenv("AWS_ENDPOINT_URL"); + if (endpointUrl != null) { + // Choosing a specific endpoint + // https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/region-selection.html + return SqsClient.builder() + .endpointOverride(URI.create(endpointUrl)); + } + return SqsClient.builder(); + } + + private SqsAsyncClient getAsyncCRTSqsClient() { + return this.getSqsAsyncClientBuilder() + .httpClientBuilder(AwsCrtAsyncHttpClient + .builder() + .connectionTimeout(Duration.ofSeconds(3)) + .maxConcurrency(10)) + .build(); + } + + private SqsAsyncClient getAsyncNettySqsClient() { + return this.getSqsAsyncClientBuilder() + .httpClientBuilder(NettyNioAsyncHttpClient + .builder() + .connectionTimeout(Duration.ofSeconds(3)) + .maxConcurrency(10)) + .build(); + } + + private SqsAsyncClientBuilder getSqsAsyncClientBuilder() { + String endpointUrl = System.getenv("AWS_ENDPOINT_URL"); + if (endpointUrl != null) { + return SqsAsyncClient.builder() + .endpointOverride(URI.create(endpointUrl)); + } + return SqsAsyncClient.builder(); + } +} diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs/Makefile new file mode 100644 index 0000000000000..c3b2190a84a3a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs/Makefile @@ -0,0 +1,8 @@ +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs/src/index.mjs b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs/src/index.mjs new file mode 100644 index 0000000000000..5cfc5989be340 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs/src/index.mjs @@ -0,0 +1,15 @@ +// SDK v3: https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/welcome.html +import {SQSClient, ListQueuesCommand} from "@aws-sdk/client-sqs"; + + +const sqsClient = new SQSClient({ + endpoint: process.env.AWS_ENDPOINT_URL +}); + +export const handler = async(event) => { + const cmd = new ListQueuesCommand({}); + const response = await sqsClient.send(cmd); + console.log(response); + + return "ok" +}; diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs16.x/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs16.x/Makefile new file mode 100644 index 0000000000000..c3b2190a84a3a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs16.x/Makefile @@ -0,0 +1,8 @@ +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs16.x/src/index.js b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs16.x/src/index.js new file mode 100644 index 0000000000000..f59336e5ec095 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/nodejs16.x/src/index.js @@ -0,0 +1,15 @@ +// SDK v2: https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/welcome.html +const AWS = require("aws-sdk"); + + +const sqsClient = new AWS.SQS({ + endpoint: process.env.AWS_ENDPOINT_URL +}); + +exports.handler = async function(event, context) { + // NOTE: With older AWS SDKs, SQS responses are broken returning some ResponseMetadata instead of real data. + const response = await sqsClient.listQueues().promise(); + console.log(response); + + return "ok" +}; diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/python/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/python/Makefile new file mode 100644 index 0000000000000..4426ee52bf0ef --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/python/Makefile @@ -0,0 +1,9 @@ + +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/python/__init__.py b/tests/aws/services/lambda_/functions/common/endpointinjection/python/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/python/src/__init__.py b/tests/aws/services/lambda_/functions/common/endpointinjection/python/src/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/python/src/handler.py b/tests/aws/services/lambda_/functions/common/endpointinjection/python/src/handler.py new file mode 100644 index 0000000000000..3db3fbacd1672 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/python/src/handler.py @@ -0,0 +1,11 @@ +import os + +import boto3 + +sqs_client = boto3.client("sqs", endpoint_url=os.environ.get("AWS_ENDPOINT_URL")) + + +def handler(event, context): + queues = sqs_client.list_queues() + print("queues=" + str(queues)) + return "ok" diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/ruby/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection/ruby/Makefile new file mode 100644 index 0000000000000..3406319b9eab3 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/ruby/Makefile @@ -0,0 +1,8 @@ +build: + mkdir -p build && \ + cp -r ./src/* build/ + +clean: + $(RM) -rf build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection/ruby/src/function.rb b/tests/aws/services/lambda_/functions/common/endpointinjection/ruby/src/function.rb new file mode 100644 index 0000000000000..c8b021779653c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection/ruby/src/function.rb @@ -0,0 +1,14 @@ +# SDK v2: https://docs.aws.amazon.com/sdk-for-ruby/v2/api/ +require 'aws-sdk' + + +def handler(event:, context:) + config = {} + if ENV['AWS_ENDPOINT_URL'] + config['endpoint'] = ENV['AWS_ENDPOINT_URL'] + end + sqs_client = Aws::SQS::Client.new(config) + queues = sqs_client.list_queues() + puts("queues=#{queues}") + return "ok" +end diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection_extra/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/Makefile new file mode 100644 index 0000000000000..1fd2a9d5903cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/Makefile @@ -0,0 +1,10 @@ +# Top-level Makefile to invoke all make targets in sub-directories + +# Based on https://stackoverflow.com/a/72209214/6875981 +SUBDIRS := $(patsubst %/,%,$(wildcard */)) + +.PHONY: all $(MAKECMDGOALS) $(SUBDIRS) +$(MAKECMDGOALS) all: $(SUBDIRS) + +$(SUBDIRS): + $(MAKE) -C $@ $(MAKECMDGOALS) diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/Makefile b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/Makefile new file mode 100644 index 0000000000000..bccba26238a6c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/Makefile @@ -0,0 +1,26 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +DOCKER_PLATFORM ?= linux/$(ARCHITECTURE) +# GOARCH enables cross-compilation but by default, `go build` selects the right architecture based on the environment +ifeq ($(ARCHITECTURE),arm64) + GOARCH ?= arm64 +else + GOARCH ?= amd64 +endif + +# https://hub.docker.com/_/golang/tags +# Golang EOL overview: https://endoflife.date/go +DOCKER_GOLANG_IMAGE ?= golang:1.19.6 + +build: + mkdir -p build && \ + docker run --rm --platform $(DOCKER_PLATFORM) -v $$(pwd)/src:/app -v $$(pwd)/build:/out $(DOCKER_GOLANG_IMAGE) /bin/bash -c "cd /app && GOOS=linux GOARCH=$(GOARCH) CGO_ENABLED=0 go build -trimpath -ldflags=-buildid= -o /out/bootstrap main.go && chown $$(id -u):$$(id -g) /out/bootstrap" + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/go.mod b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/go.mod new file mode 100644 index 0000000000000..cdd0df3bd042d --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/go.mod @@ -0,0 +1,9 @@ +module localstack.cloud/endpointinjection + +go 1.19 + +require ( + github.com/aws/aws-lambda-go v1.37.0 // indirect + github.com/aws/aws-sdk-go v1.44.179 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect +) diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/go.sum b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/go.sum new file mode 100644 index 0000000000000..10f96499df33a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/go.sum @@ -0,0 +1,40 @@ +github.com/aws/aws-lambda-go v1.37.0 h1:WXkQ/xhIcXZZ2P5ZBEw+bbAKeCEcb5NtiYpSwVVzIXg= +github.com/aws/aws-lambda-go v1.37.0/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= +github.com/aws/aws-sdk-go v1.44.179 h1:2mLZYSRc6awtjfD3XV+8NbuQWUVOo03/5VJ0tPenMJ0= +github.com/aws/aws-sdk-go v1.44.179/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/main.go b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/main.go new file mode 100644 index 0000000000000..71e59967a249d --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/endpointinjection_extra/provided/src/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sqs" + "os" + "fmt" +) + +func getConfig() *aws.Config { + shouldConfigure := os.Getenv("CONFIGURE_CLIENT") + if shouldConfigure == "1" { + endpointUrl := os.Getenv("AWS_ENDPOINT_URL") + return &aws.Config{ + Region: aws.String("us-east-1"), + Credentials: credentials.NewStaticCredentials("test", "test", ""), + Endpoint: aws.String(endpointUrl), + } + } + return &aws.Config{ + Region: aws.String("us-east-1"), + Credentials: credentials.NewStaticCredentials("test", "test", ""), + } +} + +func HandleRequest(context context.Context, event map[string]string) (string, error) { + // SDK v1: https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/welcome.html + config := &aws.Config{} + endpointUrl := os.Getenv("AWS_ENDPOINT_URL") + if endpointUrl != "" { + config = &aws.Config{ + Endpoint: aws.String(endpointUrl), + } + } + + sess := session.Must(session.NewSession(config)) + svc := sqs.New(sess) + input := &sqs.ListQueuesInput{} + response, err := svc.ListQueues(input) + if err != nil { + return "fail", err + } + fmt.Printf("response: %+v\n", response) + + return "ok", nil +} + +func main() { + lambda.Start(HandleRequest) +} diff --git a/tests/aws/services/lambda_/functions/common/introspection/Makefile b/tests/aws/services/lambda_/functions/common/introspection/Makefile new file mode 100644 index 0000000000000..1fd2a9d5903cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/Makefile @@ -0,0 +1,10 @@ +# Top-level Makefile to invoke all make targets in sub-directories + +# Based on https://stackoverflow.com/a/72209214/6875981 +SUBDIRS := $(patsubst %/,%,$(wildcard */)) + +.PHONY: all $(MAKECMDGOALS) $(SUBDIRS) +$(MAKECMDGOALS) all: $(SUBDIRS) + +$(SUBDIRS): + $(MAKE) -C $@ $(MAKECMDGOALS) diff --git a/tests/aws/services/lambda_/functions/common/introspection/__init__.py b/tests/aws/services/lambda_/functions/common/introspection/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/introspection/dotnet/Makefile b/tests/aws/services/lambda_/functions/common/introspection/dotnet/Makefile new file mode 100644 index 0000000000000..31704c534390c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/dotnet/Makefile @@ -0,0 +1,36 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +# The packaging function architecture is x86_64 by default and needs to be set explicitly for arm64 +# https://github.com/aws/aws-extensions-for-dotnet-cli/blob/cdd490450e0407139d49248d94a4a899367e84df/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs#L111 +FUNCTION_ARCHITECTURE ?= $(ARCHITECTURE) + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 +# Workaround for a Docker race condition causing an I/O error upon zipping to /out/handler.zip if +# two builds are executed in short succession. Example: `make -C dotnet build && make -C dotnet6 build` +BUILD_DIR ?= build-$(FRAMEWORK) + +# https://gallery.ecr.aws/sam/build-dotnet8 +IMAGE ?= public.ecr.aws/sam/build-dotnet8:1.112.0 + +# Emulated builds with Dotnet8 are currently (2024-03-19) broken as discussed in many issues: +# https://github.com/NuGet/Home/issues/12227 +# https://github.com/dotnet/runtime/issues/78340 +# https://github.com/dotnet/msbuild/issues/8508 +# Root cause QEMU issue: https://gitlab.com/qemu-project/qemu/-/issues/249 +# Workaround: Instead of emulating the build (works for Dotnet6), we use the native Docker image +# and cross-build the Dotnet package using the flag `--function-architecture` (x86_64 or arm64). + +build: + mkdir -p $(BUILD_DIR) && \ + docker run --rm -v $$(pwd)/src:/app -v $$(pwd)/$(BUILD_DIR):/out $(IMAGE) bash -c "mkdir -p /app2 && cp /app/* /app2 && cd /app2 && dotnet lambda package --framework $(FRAMEWORK) --function-architecture $(FUNCTION_ARCHITECTURE) -o ../out/handler.zip" && \ + cp $(BUILD_DIR)/handler.zip handler.zip + +clean: + $(RM) -r $(BUILD_DIR) handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/dotnet/src/Function.cs b/tests/aws/services/lambda_/functions/common/introspection/dotnet/src/Function.cs new file mode 100644 index 0000000000000..f7f602e24e44b --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/dotnet/src/Function.cs @@ -0,0 +1,40 @@ +using System.Collections; +using System.Collections.Generic; +using System; +using System.Linq; + +using Amazon.Lambda.Core; + +// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace Dotnet +{ + public class LambdaResponse + { + public IDictionary environment { get; set; } + public IDictionary ctx { get; set; } + public List packages { get; set; } + } + public class Function + { + + public LambdaResponse FunctionHandler(object input, ILambdaContext context) + { + return new LambdaResponse{ + environment = Environment.GetEnvironmentVariables().Cast().ToDictionary(kvp => (string) kvp.Key, kvp => (string) kvp.Value), + ctx = new Dictionary(){ + {"function_name", context.FunctionName}, + {"function_version", context.FunctionVersion}, + {"invoked_function_arn", context.InvokedFunctionArn}, + {"memory_limit_in_mb", context.MemoryLimitInMB.ToString()}, + {"aws_request_id", context.AwsRequestId}, + {"log_group_name", context.LogGroupName}, + {"log_stream_name", context.LogStreamName}, + {"remaining_time_in_millis", context.RemainingTime.Milliseconds.ToString()} + }, + packages = new List() + }; + } + } +} diff --git a/tests/aws/services/lambda_/functions/common/introspection/dotnet/src/dotnet.csproj b/tests/aws/services/lambda_/functions/common/introspection/dotnet/src/dotnet.csproj new file mode 100644 index 0000000000000..1bd07d2e45a5a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/dotnet/src/dotnet.csproj @@ -0,0 +1,13 @@ +ο»Ώ + + net6.0;net8.0 + true + Lambda + + + + + + + + diff --git a/tests/aws/services/lambda_/functions/common/introspection/dotnet6/Makefile b/tests/aws/services/lambda_/functions/common/introspection/dotnet6/Makefile new file mode 100644 index 0000000000000..43573df40d616 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/dotnet6/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net6.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean FRAMEWORK=$(FRAMEWORK) + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/dotnet8/Makefile b/tests/aws/services/lambda_/functions/common/introspection/dotnet8/Makefile new file mode 100644 index 0000000000000..7ec1ea467ff87 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/dotnet8/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean FRAMEWORK=$(FRAMEWORK) + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/java/Makefile b/tests/aws/services/lambda_/functions/common/introspection/java/Makefile new file mode 100644 index 0000000000000..6916941cbe138 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/java/Makefile @@ -0,0 +1,12 @@ +# https://hub.docker.com/_/gradle/tags +IMAGE ?= gradle:8.4.0-jdk17 + +build: + mkdir -p build && \ + docker run --rm -v "$$(pwd)/src:/app" -v "$$(pwd)/build:/out" $(IMAGE) bash -c "mkdir -p /build && cp -r /app/* /build && cd /build && gradle packageFat && cp build/distributions/build.zip /out/handler.zip" && \ + cp build/handler.zip handler.zip + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/java/src/build.gradle b/tests/aws/services/lambda_/functions/common/introspection/java/src/build.gradle new file mode 100644 index 0000000000000..7427f9c4497dc --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/java/src/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' +} + +java { + // Target oldest tested Java runtime + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS Java libraries https://github.com/aws/aws-lambda-java-libs + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + + // SLF4J logging + implementation 'org.slf4j:slf4j-nop:2.0.9' +} + +task packageFat(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageLibs(type: Zip) { + into('java/lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageSkinny(type: Zip) { + from compileJava + from processResources +} + +tasks.withType(AbstractArchiveTask) { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +build.dependsOn packageSkinny diff --git a/tests/aws/services/lambda_/functions/common/introspection/java/src/src/main/java/echo/Handler.java b/tests/aws/services/lambda_/functions/common/introspection/java/src/src/main/java/echo/Handler.java new file mode 100644 index 0000000000000..6dd8e27e964e5 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/java/src/src/main/java/echo/Handler.java @@ -0,0 +1,37 @@ +package echo; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class ReturnValue { + public Map environment; + public Map ctx; + public List packages; + + public ReturnValue(Context context) { + this.environment = System.getenv(); + this.ctx = new HashMap<>(); + this.ctx.put("function_name", context.getFunctionName()); + this.ctx.put("function_version", context.getFunctionVersion()); + this.ctx.put("invoked_function_arn", context.getInvokedFunctionArn()); + this.ctx.put("memory_limit_in_mb", Integer.toString(context.getMemoryLimitInMB())); + this.ctx.put("aws_request_id", context.getAwsRequestId()); + this.ctx.put("log_group_name", context.getLogGroupName()); + this.ctx.put("log_stream_name", context.getLogStreamName()); + this.ctx.put("remaining_time_in_millis", Integer.toString(context.getRemainingTimeInMillis())); + this.packages = new ArrayList<>(); + } +} + +public class Handler implements RequestHandler, ReturnValue> { + + public ReturnValue handleRequest(Map event, Context context) { + return new ReturnValue(context); + } +} diff --git a/tests/aws/services/lambda_/functions/common/introspection/nodejs/Makefile b/tests/aws/services/lambda_/functions/common/introspection/nodejs/Makefile new file mode 100644 index 0000000000000..c3b2190a84a3a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/nodejs/Makefile @@ -0,0 +1,8 @@ +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/nodejs/src/index.js b/tests/aws/services/lambda_/functions/common/introspection/nodejs/src/index.js new file mode 100644 index 0000000000000..5e42bf9cf13bc --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/nodejs/src/index.js @@ -0,0 +1,12 @@ +exports.handler = async function(event, context) { + return {environment: process.env, ctx: { + function_name: context.functionName, + function_version: context.functionVersion, + invoked_function_arn: context.invokedFunctionArn, + memory_limit_in_mb: context.memoryLimitInMb, + aws_request_id: context.awsRequestId, + log_group_name: context.logGroupName, + log_stream_name: context.logStreamName, + remaining_time_in_millis: context.getRemainingTimeInMillis(), + }, packages: []} +}; diff --git a/tests/aws/services/lambda_/functions/common/introspection/provided/Makefile b/tests/aws/services/lambda_/functions/common/introspection/provided/Makefile new file mode 100644 index 0000000000000..61f2768c515b2 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/provided/Makefile @@ -0,0 +1,32 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +DOCKER_PLATFORM ?= linux/$(ARCHITECTURE) +# Manual implementation of the --arm64 logic from `cargo lambda lambda build`: +# https://docs.aws.amazon.com/lambda/latest/dg/rust-package.html +# https://github.com/cargo-lambda/cargo-lambda/blob/7b0977e6fd9a6b03d8f6ddf71eff5a5b9999e0c0/crates/cargo-lambda-build/src/target_arch.rs#L10 +ifeq ($(ARCHITECTURE),arm64) + # ARM builds are finally fixed since 1.76.0: https://github.com/rust-lang/rust/issues/77071 + # The suffix -musl instead of -gnu is required for the runtime `provided.al2` to fix a GLIBC version not found error: + # /var/task/bootstrap: /lib64/libc.so.6: version `GLIBC_2.28' not found (required by /var/task/bootstrap) + # https://github.com/awslabs/aws-lambda-rust-runtime/issues/17#issuecomment-645064821 + RUST_TARGET ?= aarch64-unknown-linux-musl +else + RUST_TARGET ?= x86_64-unknown-linux-musl +endif + +# https://hub.docker.com/_/rust/tags +DOCKER_RUST_IMAGE ?= rust:1.76.0 + +build: + mkdir -p build && \ + docker run --rm --platform=$(DOCKER_PLATFORM) -v $$(pwd)/src:/app -v $$(pwd)/build:/out:cached $(DOCKER_RUST_IMAGE) \ + bash -c "rustup target add $(RUST_TARGET) && mkdir -p /app2 && cp -r /app/* /app2 && cd /app2 && cargo build --release --target $(RUST_TARGET) && cp ./target/$(RUST_TARGET)/release/bootstrap /out && chown $$(id -u):$$(id -g) /out/bootstrap" + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/provided/src/.gitignore b/tests/aws/services/lambda_/functions/common/introspection/provided/src/.gitignore new file mode 100644 index 0000000000000..2f7896d1d1365 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/provided/src/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/aws/services/lambda_/functions/common/introspection/provided/src/Cargo.lock b/tests/aws/services/lambda_/functions/common/introspection/provided/src/Cargo.lock new file mode 100644 index 0000000000000..66d5cd706f319 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/provided/src/Cargo.lock @@ -0,0 +1,897 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "async-stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.92", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "lambda_runtime", + "serde_json", + "tokio", +] + +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + +[[package]] +name = "cc" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ba8f7aaa012f30d5b2861462f6708eccd49c3c39863fe083a308035f63d723" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f560b665ad9f1572cfcaf034f7fb84338a7ce945216d64a90fd81f046a3caee" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + +[[package]] +name = "lambda_runtime" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deca8f65d7ce9a8bfddebb49d7d91b22e788a59ca0c5190f26794ab80ed7a702" +dependencies = [ + "async-stream", + "base64", + "bytes", + "futures", + "http", + "http-body", + "http-serde", + "hyper", + "lambda_runtime_api_client", + "serde", + "serde_json", + "serde_path_to_error", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "lambda_runtime_api_client" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "690c5ae01f3acac8c9c3348b556fc443054e9b7f1deaf53e9ebab716282bf0ed" +dependencies = [ + "http", + "hyper", + "tokio", + "tower-service", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "lock_api" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "parking_lot" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.36.1", +] + +[[package]] +name = "pin-project" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.92", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "serde_json" +version = "1.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd154a240de39fdebcf5775d2675c204d7c13cf39a4c697be6493c8e734337c" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" + +[[package]] +name = "smallvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "socket2" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "syn" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff7c592601f11445996a06f8ad0c27f094a58857c2f89e97974ab9235b92c52" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "tokio-stream" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a89fd63ad6adf737582df5db40d286574513c69a11dac5214dc3b5603d6713e" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62" + +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-xid" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.4", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" diff --git a/tests/aws/services/lambda_/functions/common/introspection/provided/src/Cargo.toml b/tests/aws/services/lambda_/functions/common/introspection/provided/src/Cargo.toml new file mode 100644 index 0000000000000..40e75bde46b15 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/provided/src/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# https://crates.io/crates/lambda_runtime +lambda_runtime = "0.8.3" +# https://crates.io/crates/serde_json +serde_json = "1.0.108" +# https://crates.io/crates/tokio +tokio = { version = "1.34.0", features = ["full"] } diff --git a/tests/aws/services/lambda_/functions/common/introspection/provided/src/src/main.rs b/tests/aws/services/lambda_/functions/common/introspection/provided/src/src/main.rs new file mode 100644 index 0000000000000..b05b7a4004a24 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/provided/src/src/main.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; +use lambda_runtime::{service_fn, LambdaEvent, Error}; +use serde_json::{json, Value}; + +#[tokio::main] +async fn main() -> Result<(), Error> { + lambda_runtime::run(service_fn(func)).await?; + Ok(()) +} + +async fn func(event: LambdaEvent) -> Result { + let (_event, context) = event.into_parts(); + + let env: HashMap = std::env::vars().collect(); + Ok(json!({ "environment": env, "ctx": { + "function_name": context.env_config.function_name, + "function_version": context.env_config.version, + "invoked_function_arn": context.invoked_function_arn, + "memory_limit_in_mb": context.env_config.memory, + "aws_request_id": context.request_id, + "log_group_name": context.env_config.log_group, + "log_stream_name": context.env_config.log_stream, + "deadline": context.deadline.to_string() + }, "packages": [] })) +} diff --git a/tests/aws/services/lambda_/functions/common/introspection/python/Makefile b/tests/aws/services/lambda_/functions/common/introspection/python/Makefile new file mode 100644 index 0000000000000..4426ee52bf0ef --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/python/Makefile @@ -0,0 +1,9 @@ + +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/python/__init__.py b/tests/aws/services/lambda_/functions/common/introspection/python/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/introspection/python/src/__init__.py b/tests/aws/services/lambda_/functions/common/introspection/python/src/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/introspection/python/src/handler.py b/tests/aws/services/lambda_/functions/common/introspection/python/src/handler.py new file mode 100644 index 0000000000000..5fda90f7adb82 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/python/src/handler.py @@ -0,0 +1,20 @@ +import json +import os + + +def handler(event, context): + print(json.dumps(event)) + return { + "environment": dict(os.environ), + "ctx": { + "function_name": context.function_name, + "function_version": context.function_version, + "invoked_function_arn": context.invoked_function_arn, + "memory_limit_in_mb": context.memory_limit_in_mb, + "aws_request_id": context.aws_request_id, + "log_group_name": context.log_group_name, + "log_stream_name": context.log_stream_name, + "remaining_time_in_millis": context.get_remaining_time_in_millis(), + }, + "packages": [], + } diff --git a/tests/aws/services/lambda_/functions/common/introspection/ruby/Makefile b/tests/aws/services/lambda_/functions/common/introspection/ruby/Makefile new file mode 100644 index 0000000000000..7ccfb2d560a72 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/ruby/Makefile @@ -0,0 +1,8 @@ +build: + mkdir -p build && \ + cp -r ./src/* build/ + +clean: + rm -rf build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/introspection/ruby/src/function.rb b/tests/aws/services/lambda_/functions/common/introspection/ruby/src/function.rb new file mode 100644 index 0000000000000..f64992d30343a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/introspection/ruby/src/function.rb @@ -0,0 +1,12 @@ +def handler(event:, context:) + {"environment" => ENV.to_h, "ctx" => { + "function_name" => context.function_name, + "function_version" => context.function_version, + "invoked_function_arn" => context.invoked_function_arn, + "memory_limit_in_mb" => context.memory_limit_in_mb, + "aws_request_id" => context.aws_request_id, + "log_group_name" => context.log_group_name, + "log_stream_name" => context.log_stream_name, + "remaining_time_in_millis" => context.get_remaining_time_in_millis() + }, "packages" => []} +end diff --git a/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/README.md b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/README.md new file mode 100644 index 0000000000000..f13ce44782f2c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/README.md @@ -0,0 +1,10 @@ +## Java Lambda with AWS SDK v2 -> Kinesis + +This lambda function is used to ensure the compatibility of Kinesis with the Java AWS SDK v2, +especially the CBOR content encoding (which is enabled by default). + +This Lambda is not directly being used for the multi-runtime lambda tests, but it is here in this folder in order to +benefit from the caching mechanisms in CI. +The JAR file is too big to be directly commited to the Git repo (~10MB). + +Initially introduced with https://github.com/localstack/localstack/pull/11286. diff --git a/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/Makefile b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/Makefile new file mode 100644 index 0000000000000..6916941cbe138 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/Makefile @@ -0,0 +1,12 @@ +# https://hub.docker.com/_/gradle/tags +IMAGE ?= gradle:8.4.0-jdk17 + +build: + mkdir -p build && \ + docker run --rm -v "$$(pwd)/src:/app" -v "$$(pwd)/build:/out" $(IMAGE) bash -c "mkdir -p /build && cp -r /app/* /build && cd /build && gradle packageFat && cp build/distributions/build.zip /out/handler.zip" && \ + cp build/handler.zip handler.zip + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/src/build.gradle b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/src/build.gradle new file mode 100644 index 0000000000000..a37bf878dbd31 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/src/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'java' +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS SDK v2: https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html + implementation platform('software.amazon.awssdk:bom:2.26.27') + // AWS Java libraries https://github.com/aws/aws-lambda-java-libs + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + + // Kinesis clients + implementation 'software.amazon.awssdk:kinesis' + + // SLF4J logging + implementation 'org.slf4j:slf4j-nop:2.0.13' +} + +task packageFat(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageLibs(type: Zip) { + into('java/lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageSkinny(type: Zip) { + from compileJava + from processResources +} + +tasks.withType(AbstractArchiveTask) { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +build.dependsOn packageSkinny diff --git a/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/src/src/main/java/kinesis/Handler.java b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/src/src/main/java/kinesis/Handler.java new file mode 100644 index 0000000000000..2292aa9731456 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/kinesis_sdkv2/java17/src/src/main/java/kinesis/Handler.java @@ -0,0 +1,56 @@ +package kinesis; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.kinesis.KinesisClient; +import software.amazon.awssdk.services.kinesis.model.*; + +import java.net.URI; +import java.nio.charset.Charset; +import java.time.Instant; +import java.util.*; + + +public class Handler implements RequestHandler, String> { + + public String handleRequest(Map event, Context context) { + // extract the StreamARN from the event + String streamArn = event.get("StreamARN"); + System.out.print("Stream ARN = " + streamArn); + + // get the first shard + DescribeStreamResponse streamDescription = this.getKinesisClient().describeStream(DescribeStreamRequest.builder().streamARN(streamArn).build()); + Instant streamCreationTimestamp = streamDescription.streamDescription().streamCreationTimestamp(); + Shard shard = streamDescription.streamDescription().shards().get(0);; + System.out.println("Shard ID = " + shard.shardId()); + + // create the shardIterator starting now + GetShardIteratorResponse shardIterator = this.getKinesisClient().getShardIterator(GetShardIteratorRequest.builder().streamARN(streamArn).shardId(shard.shardId()).shardIteratorType(ShardIteratorType.AT_TIMESTAMP).timestamp(Instant.now()).build()); + System.out.println("Stream Creation Timestamp = " + streamCreationTimestamp); + + // put a record + SdkBytes testData = SdkBytes.fromString("test-string", Charset.defaultCharset()); + this.getKinesisClient().putRecord(PutRecordRequest.builder().streamARN(streamArn).partitionKey("test-partition-key").data(testData).build()); + + // get the record again + GetRecordsResponse records = this.getKinesisClient().getRecords(GetRecordsRequest.builder().streamARN(streamArn).shardIterator(shardIterator.shardIterator()).build()); + + // expect the record has been returned + assert records.hasRecords(); + assert records.records().get(0).data() == testData; + + return "ok"; + } + + private KinesisClient getKinesisClient() { + String endpointUrl = System.getenv("AWS_ENDPOINT_URL"); + if (endpointUrl != null) { + // Choosing a specific endpoint + // https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/region-selection.html + return KinesisClient.builder() + .endpointOverride(URI.create(endpointUrl)).build(); + } + return KinesisClient.builder().build(); + } +} diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/Makefile new file mode 100644 index 0000000000000..1fd2a9d5903cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/Makefile @@ -0,0 +1,10 @@ +# Top-level Makefile to invoke all make targets in sub-directories + +# Based on https://stackoverflow.com/a/72209214/6875981 +SUBDIRS := $(patsubst %/,%,$(wildcard */)) + +.PHONY: all $(MAKECMDGOALS) $(SUBDIRS) +$(MAKECMDGOALS) all: $(SUBDIRS) + +$(SUBDIRS): + $(MAKE) -C $@ $(MAKECMDGOALS) diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/__init__.py b/tests/aws/services/lambda_/functions/common/uncaughtexception/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/Makefile new file mode 100644 index 0000000000000..31704c534390c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/Makefile @@ -0,0 +1,36 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +# The packaging function architecture is x86_64 by default and needs to be set explicitly for arm64 +# https://github.com/aws/aws-extensions-for-dotnet-cli/blob/cdd490450e0407139d49248d94a4a899367e84df/src/Amazon.Lambda.Tools/LambdaDefinedCommandOptions.cs#L111 +FUNCTION_ARCHITECTURE ?= $(ARCHITECTURE) + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 +# Workaround for a Docker race condition causing an I/O error upon zipping to /out/handler.zip if +# two builds are executed in short succession. Example: `make -C dotnet build && make -C dotnet6 build` +BUILD_DIR ?= build-$(FRAMEWORK) + +# https://gallery.ecr.aws/sam/build-dotnet8 +IMAGE ?= public.ecr.aws/sam/build-dotnet8:1.112.0 + +# Emulated builds with Dotnet8 are currently (2024-03-19) broken as discussed in many issues: +# https://github.com/NuGet/Home/issues/12227 +# https://github.com/dotnet/runtime/issues/78340 +# https://github.com/dotnet/msbuild/issues/8508 +# Root cause QEMU issue: https://gitlab.com/qemu-project/qemu/-/issues/249 +# Workaround: Instead of emulating the build (works for Dotnet6), we use the native Docker image +# and cross-build the Dotnet package using the flag `--function-architecture` (x86_64 or arm64). + +build: + mkdir -p $(BUILD_DIR) && \ + docker run --rm -v $$(pwd)/src:/app -v $$(pwd)/$(BUILD_DIR):/out $(IMAGE) bash -c "mkdir -p /app2 && cp /app/* /app2 && cd /app2 && dotnet lambda package --framework $(FRAMEWORK) --function-architecture $(FUNCTION_ARCHITECTURE) -o ../out/handler.zip" && \ + cp $(BUILD_DIR)/handler.zip handler.zip + +clean: + $(RM) -r $(BUILD_DIR) handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/src/Function.cs b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/src/Function.cs new file mode 100644 index 0000000000000..2bfa409859623 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/src/Function.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +using Amazon.Lambda.Core; + +// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class. +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace Dotnet +{ + public class Function + { + + public object FunctionHandler(IDictionary input, ILambdaContext context) + { + throw new System.Exception("Error: " + input["error_msg"]); + } + } +} diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/src/dotnet.csproj b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/src/dotnet.csproj new file mode 100644 index 0000000000000..1bd07d2e45a5a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet/src/dotnet.csproj @@ -0,0 +1,13 @@ +ο»Ώ + + net6.0;net8.0 + true + Lambda + + + + + + + + diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet6/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet6/Makefile new file mode 100644 index 0000000000000..43573df40d616 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet6/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net6.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean FRAMEWORK=$(FRAMEWORK) + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet8/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet8/Makefile new file mode 100644 index 0000000000000..7ec1ea467ff87 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/dotnet8/Makefile @@ -0,0 +1,20 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif + +# Target Dotnet framework version +FRAMEWORK ?= net8.0 + +# Forward build for different Dotnet framework version to avoid code duplication +build: + cd ../dotnet && $(MAKE) clean build ARCHITECTURE=$(ARCHITECTURE) FRAMEWORK=$(FRAMEWORK) + mv ../dotnet/handler.zip . + +clean: + $(RM) -r build handler.zip + cd ../dotnet && $(MAKE) clean FRAMEWORK=$(FRAMEWORK) + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/java/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/java/Makefile new file mode 100644 index 0000000000000..6916941cbe138 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/java/Makefile @@ -0,0 +1,12 @@ +# https://hub.docker.com/_/gradle/tags +IMAGE ?= gradle:8.4.0-jdk17 + +build: + mkdir -p build && \ + docker run --rm -v "$$(pwd)/src:/app" -v "$$(pwd)/build:/out" $(IMAGE) bash -c "mkdir -p /build && cp -r /app/* /build && cd /build && gradle packageFat && cp build/distributions/build.zip /out/handler.zip" && \ + cp build/handler.zip handler.zip + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/java/src/build.gradle b/tests/aws/services/lambda_/functions/common/uncaughtexception/java/src/build.gradle new file mode 100644 index 0000000000000..7427f9c4497dc --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/java/src/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'java' +} + +java { + // Target oldest tested Java runtime + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +dependencies { + // AWS Java libraries https://github.com/aws/aws-lambda-java-libs + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' + + // SLF4J logging + implementation 'org.slf4j:slf4j-nop:2.0.9' +} + +task packageFat(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageLibs(type: Zip) { + into('java/lib') { + from configurations.runtimeClasspath + } + dirMode = 0755 + fileMode = 0755 +} + +task packageSkinny(type: Zip) { + from compileJava + from processResources +} + +tasks.withType(AbstractArchiveTask) { + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +build.dependsOn packageSkinny diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/java/src/src/main/java/echo/Handler.java b/tests/aws/services/lambda_/functions/common/uncaughtexception/java/src/src/main/java/echo/Handler.java new file mode 100644 index 0000000000000..1b7482c20526d --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/java/src/src/main/java/echo/Handler.java @@ -0,0 +1,13 @@ +package echo; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; + +import java.util.Map; + +public class Handler implements RequestHandler, Map> { + + public Map handleRequest(Map event, Context context) { + throw new RuntimeException("Error: " + event.get("error_msg")); + } +} diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/nodejs/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/nodejs/Makefile new file mode 100644 index 0000000000000..c3b2190a84a3a --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/nodejs/Makefile @@ -0,0 +1,8 @@ +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/nodejs/src/index.js b/tests/aws/services/lambda_/functions/common/uncaughtexception/nodejs/src/index.js new file mode 100644 index 0000000000000..38610ad5a62b5 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/nodejs/src/index.js @@ -0,0 +1,3 @@ +exports.handler = async function(event, context) { + throw Error(`Error: ${event.error_msg}`) +}; diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/Makefile new file mode 100644 index 0000000000000..61f2768c515b2 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/Makefile @@ -0,0 +1,32 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +DOCKER_PLATFORM ?= linux/$(ARCHITECTURE) +# Manual implementation of the --arm64 logic from `cargo lambda lambda build`: +# https://docs.aws.amazon.com/lambda/latest/dg/rust-package.html +# https://github.com/cargo-lambda/cargo-lambda/blob/7b0977e6fd9a6b03d8f6ddf71eff5a5b9999e0c0/crates/cargo-lambda-build/src/target_arch.rs#L10 +ifeq ($(ARCHITECTURE),arm64) + # ARM builds are finally fixed since 1.76.0: https://github.com/rust-lang/rust/issues/77071 + # The suffix -musl instead of -gnu is required for the runtime `provided.al2` to fix a GLIBC version not found error: + # /var/task/bootstrap: /lib64/libc.so.6: version `GLIBC_2.28' not found (required by /var/task/bootstrap) + # https://github.com/awslabs/aws-lambda-rust-runtime/issues/17#issuecomment-645064821 + RUST_TARGET ?= aarch64-unknown-linux-musl +else + RUST_TARGET ?= x86_64-unknown-linux-musl +endif + +# https://hub.docker.com/_/rust/tags +DOCKER_RUST_IMAGE ?= rust:1.76.0 + +build: + mkdir -p build && \ + docker run --rm --platform=$(DOCKER_PLATFORM) -v $$(pwd)/src:/app -v $$(pwd)/build:/out:cached $(DOCKER_RUST_IMAGE) \ + bash -c "rustup target add $(RUST_TARGET) && mkdir -p /app2 && cp -r /app/* /app2 && cd /app2 && cargo build --release --target $(RUST_TARGET) && cp ./target/$(RUST_TARGET)/release/bootstrap /out && chown $$(id -u):$$(id -g) /out/bootstrap" + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/.gitignore b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/.gitignore new file mode 100644 index 0000000000000..2f7896d1d1365 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/Cargo.lock b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/Cargo.lock new file mode 100644 index 0000000000000..9726e78122d90 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/Cargo.lock @@ -0,0 +1,886 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "async-stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "lambda_runtime", + "serde_json", + "tokio", +] + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aaa7bd5fb665c6864b5f963dd9097905c54125909c7aa94c9e18507cdbe6c53" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +dependencies = [ + "cfg-if", + "lazy_static", +] + +[[package]] +name = "flate2" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af" +dependencies = [ + "cfg-if", + "crc32fast", + "libc", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-channel" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" + +[[package]] +name = "futures-sink" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" + +[[package]] +name = "futures-task" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" + +[[package]] +name = "futures-util" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.10.2+wasi-snapshot-preview1", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "hdrhistogram" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31672b7011be2c4f7456c4ddbcb40e7e9a4a9fad8efe49a6ebaf5f307d0109c0" +dependencies = [ + "base64", + "byteorder", + "crossbeam-channel", + "flate2", + "nom", + "num-traits", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff8670570af52249509a86f5e3e18a08c60b177071826898fde8997cf5f6bfbb" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b26ae0a80afebe130861d90abf98e3814a4f28a4c6ffeb5ab8ebb2be311e0ef2" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "indexmap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + +[[package]] +name = "lambda_runtime" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c73464e59463e91bf179195a308738477df90240173649d5dd2a1f3a2e4a2d2" +dependencies = [ + "async-stream", + "bytes", + "http", + "hyper", + "lambda_runtime_api_client", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "lambda_runtime_api_client" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e921024b5eb4e2f0800a5d6e25c7ed554562aa62f02cf5f60a48c26c8a678974" +dependencies = [ + "http", + "hyper", + "tokio", + "tower-service", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "lock_api" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "parking_lot" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.36.1", +] + +[[package]] +name = "pin-project" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro2" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec757218438d5fda206afc041538b2f6d889286160d649a86a24d37e1235afd1" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "serde" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" + +[[package]] +name = "smallvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "socket2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "syn" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff7c592601f11445996a06f8ad0c27f094a58857c2f89e97974ab9235b92c52" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "tokio" +version = "1.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050c618355082ae5a89ec63bbf897225d5ffe84c7c4e036874e4d185a5044e" +dependencies = [ + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edfdeb067411dba2044da6d1cb2df793dd35add7888d73c16e3381ded401764" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a89fd63ad6adf737582df5db40d286574513c69a11dac5214dc3b5603d6713e" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62" + +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + +[[package]] +name = "tracing" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6b8ad3567499f98a1db7a752b07a7c8c7c7c34c332ec00effb2b0027974b7c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "unicode-xid" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/Cargo.toml b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/Cargo.toml new file mode 100644 index 0000000000000..f6df3fcf493cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lambda_runtime = "0.5" +serde_json = "1.0" +tokio = { version = "1.18", features = ["full"] } diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/src/main.rs b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/src/main.rs new file mode 100644 index 0000000000000..4c1318f2d06ae --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/provided/src/src/main.rs @@ -0,0 +1,13 @@ +use lambda_runtime::{service_fn, LambdaEvent, Error}; +use serde_json::{Value}; + +#[tokio::main] +async fn main() -> Result<(), Error> { + lambda_runtime::run(service_fn(func)).await?; + Ok(()) +} + +async fn func(event: LambdaEvent) -> Result { + let (event, _context) = event.into_parts(); + Err(Error::try_from(format!("Error: {}", event.get("error_msg").unwrap())).unwrap()) +} diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/python/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/python/Makefile new file mode 100644 index 0000000000000..4426ee52bf0ef --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/python/Makefile @@ -0,0 +1,9 @@ + +build: + mkdir build && \ + cp -r ./src/* build/ + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/python/__init__.py b/tests/aws/services/lambda_/functions/common/uncaughtexception/python/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/python/src/__init__.py b/tests/aws/services/lambda_/functions/common/uncaughtexception/python/src/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/python/src/handler.py b/tests/aws/services/lambda_/functions/common/uncaughtexception/python/src/handler.py new file mode 100644 index 0000000000000..741e273c51bc6 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/python/src/handler.py @@ -0,0 +1,16 @@ +""" +Raises an exception with a message containing part of the invoke payload. + +Example invoke payload: + +{ + "error_msg": "test123" +} +""" + +import json + + +def handler(event, ctx): + print(json.dumps(event)) + raise Exception(f"Failed: {event.get('error_msg')}") diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/ruby/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception/ruby/Makefile new file mode 100644 index 0000000000000..7ccfb2d560a72 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/ruby/Makefile @@ -0,0 +1,8 @@ +build: + mkdir -p build && \ + cp -r ./src/* build/ + +clean: + rm -rf build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception/ruby/src/function.rb b/tests/aws/services/lambda_/functions/common/uncaughtexception/ruby/src/function.rb new file mode 100644 index 0000000000000..f0d06f99c3eeb --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception/ruby/src/function.rb @@ -0,0 +1,3 @@ +def handler(event:, context:) + raise "Error: " + event["error_msg"] +end diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/Makefile new file mode 100644 index 0000000000000..1fd2a9d5903cd --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/Makefile @@ -0,0 +1,10 @@ +# Top-level Makefile to invoke all make targets in sub-directories + +# Based on https://stackoverflow.com/a/72209214/6875981 +SUBDIRS := $(patsubst %/,%,$(wildcard */)) + +.PHONY: all $(MAKECMDGOALS) $(SUBDIRS) +$(MAKECMDGOALS) all: $(SUBDIRS) + +$(SUBDIRS): + $(MAKE) -C $@ $(MAKECMDGOALS) diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/Makefile b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/Makefile new file mode 100644 index 0000000000000..bccba26238a6c --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/Makefile @@ -0,0 +1,26 @@ +UNAME := $(shell uname -m) +ifeq ($(UNAME),x86_64) + ARCHITECTURE ?= x86_64 +else + ARCHITECTURE ?= arm64 +endif +DOCKER_PLATFORM ?= linux/$(ARCHITECTURE) +# GOARCH enables cross-compilation but by default, `go build` selects the right architecture based on the environment +ifeq ($(ARCHITECTURE),arm64) + GOARCH ?= arm64 +else + GOARCH ?= amd64 +endif + +# https://hub.docker.com/_/golang/tags +# Golang EOL overview: https://endoflife.date/go +DOCKER_GOLANG_IMAGE ?= golang:1.19.6 + +build: + mkdir -p build && \ + docker run --rm --platform $(DOCKER_PLATFORM) -v $$(pwd)/src:/app -v $$(pwd)/build:/out $(DOCKER_GOLANG_IMAGE) /bin/bash -c "cd /app && GOOS=linux GOARCH=$(GOARCH) CGO_ENABLED=0 go build -trimpath -ldflags=-buildid= -o /out/bootstrap main.go && chown $$(id -u):$$(id -g) /out/bootstrap" + +clean: + $(RM) -r build handler.zip + +.PHONY: build clean diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/go.mod b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/go.mod new file mode 100644 index 0000000000000..e83228494e3c5 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/go.mod @@ -0,0 +1,5 @@ +module localstack.cloud/introspection + +go 1.19 + +require github.com/aws/aws-lambda-go v1.34.1 diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/go.sum b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/go.sum new file mode 100644 index 0000000000000..9cb9a0047a9d9 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/go.sum @@ -0,0 +1,6 @@ +github.com/aws/aws-lambda-go v1.34.1 h1:M3a/uFYBjii+tDcOJ0wL/WyFi2550FHoECdPf27zvOs= +github.com/aws/aws-lambda-go v1.34.1/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/main.go b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/main.go new file mode 100644 index 0000000000000..1c3205208f8e6 --- /dev/null +++ b/tests/aws/services/lambda_/functions/common/uncaughtexception_extra/provided/src/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + "errors" + "github.com/aws/aws-lambda-go/lambda" +) + +func HandleRequest(context context.Context, event map[string]string) (map[string]string, error) { + + return nil, errors.New("Error: " + event["error_msg"]) +} + +func main() { + lambda.Start(HandleRequest) +} diff --git a/tests/aws/services/lambda_/functions/echo.zip b/tests/aws/services/lambda_/functions/echo.zip new file mode 100644 index 0000000000000..abb5c108cc31c Binary files /dev/null and b/tests/aws/services/lambda_/functions/echo.zip differ diff --git a/tests/aws/services/lambda_/functions/host_prefix_operation.py b/tests/aws/services/lambda_/functions/host_prefix_operation.py new file mode 100644 index 0000000000000..ccc49da725a62 --- /dev/null +++ b/tests/aws/services/lambda_/functions/host_prefix_operation.py @@ -0,0 +1,71 @@ +import json +import os +from urllib.parse import urlparse + +import boto3 +from botocore.config import Config + +region = os.environ["AWS_REGION"] +account = boto3.client("sts").get_caller_identity()["Account"] +state_machine_arn_doesnotexist = ( + f"arn:aws:states:{region}:{account}:stateMachine:doesNotExistStateMachine" +) + + +def do_test(test_case): + sfn_client = test_case["client"] + try: + sfn_client.start_sync_execution( + stateMachineArn=state_machine_arn_doesnotexist, + input=json.dumps({}), + name="SyncExecution", + ) + return {"status": "failure"} + except sfn_client.exceptions.StateMachineDoesNotExist: + # We are testing the error case here, so we expect this exception to be raised. + # Testing the error case simplifies the test case because we don't need to set up a StepFunction. + return {"status": "success"} + except Exception as e: + return {"status": "exception", "exception": str(e)} + + +def handler(event, context): + # The environment variable AWS_ENDPOINT_URL is only available in LocalStack + aws_endpoint_url = os.environ.get("AWS_ENDPOINT_URL") + + host_prefix_client = boto3.client( + "stepfunctions", + endpoint_url=os.environ.get("AWS_ENDPOINT_URL"), + ) + localstack_adjusted_domain = None + # The localstack domain only works in LocalStack, None is ignored + if aws_endpoint_url: + port = urlparse(aws_endpoint_url).port + localstack_adjusted_domain = f"http://localhost.localstack.cloud:{port}" + host_prefix_client_localstack_domain = boto3.client( + "stepfunctions", + endpoint_url=localstack_adjusted_domain, + ) + no_host_prefix_client = boto3.client( + "stepfunctions", + endpoint_url=os.environ.get("AWS_ENDPOINT_URL"), + config=Config(inject_host_prefix=False), + ) + + test_cases = [ + {"name": "host_prefix", "client": host_prefix_client}, + {"name": "host_prefix_localstack_domain", "client": host_prefix_client_localstack_domain}, + # Omitting the host prefix can only work in LocalStack + { + "name": "no_host_prefix", + "client": no_host_prefix_client if aws_endpoint_url else host_prefix_client, + }, + ] + + test_results = {} + for test_case in test_cases: + test_name = test_case["name"] + test_result = do_test(test_case) + test_results[test_name] = test_result + + return test_results diff --git a/tests/aws/services/lambda_/functions/hot-reloading/__init__.py b/tests/aws/services/lambda_/functions/hot-reloading/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/hot-reloading/nodejs/handler.mjs b/tests/aws/services/lambda_/functions/hot-reloading/nodejs/handler.mjs new file mode 100644 index 0000000000000..7f5e9b8337a56 --- /dev/null +++ b/tests/aws/services/lambda_/functions/hot-reloading/nodejs/handler.mjs @@ -0,0 +1,11 @@ +const testConstant = "value1"; +let testCounter = 0; + +export const handler = async(event) => { + testCounter++; + + return { + counter: testCounter, + constant: testConstant, + }; +}; diff --git a/tests/aws/services/lambda_/functions/hot-reloading/python/__init__.py b/tests/aws/services/lambda_/functions/hot-reloading/python/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/hot-reloading/python/handler.py b/tests/aws/services/lambda_/functions/hot-reloading/python/handler.py new file mode 100644 index 0000000000000..7f7af28f5d226 --- /dev/null +++ b/tests/aws/services/lambda_/functions/hot-reloading/python/handler.py @@ -0,0 +1,8 @@ +CONSTANT_VARIABLE = "value1" +COUNTER = 0 + + +def handler(event, context): + global COUNTER + COUNTER += 1 + return {"counter": COUNTER, "constant": CONSTANT_VARIABLE} diff --git a/tests/aws/services/lambda_/functions/index.py b/tests/aws/services/lambda_/functions/index.py new file mode 100644 index 0000000000000..ffd91ce25b746 --- /dev/null +++ b/tests/aws/services/lambda_/functions/index.py @@ -0,0 +1,7 @@ +import json + + +def handler(event, context): + # Just print the event that was passed to the Lambda + print(json.dumps(event)) + return event diff --git a/tests/aws/services/lambda_/functions/java/lambda_echo/.gitignore b/tests/aws/services/lambda_/functions/java/lambda_echo/.gitignore new file mode 100644 index 0000000000000..eb5a316cbd195 --- /dev/null +++ b/tests/aws/services/lambda_/functions/java/lambda_echo/.gitignore @@ -0,0 +1 @@ +target diff --git a/tests/aws/services/lambda_/functions/java/lambda_echo/README.md b/tests/aws/services/lambda_/functions/java/lambda_echo/README.md new file mode 100644 index 0000000000000..c07b0838b62e1 --- /dev/null +++ b/tests/aws/services/lambda_/functions/java/lambda_echo/README.md @@ -0,0 +1,3 @@ +`lambda-function-with-lib-0.0.1.jar` was generated using `mvn package` and +committed to the repo. Generation of the .jar _could_ be added to the build +process, but this seems adequate. diff --git a/tests/aws/services/lambda_/functions/java/lambda_echo/build.gradle b/tests/aws/services/lambda_/functions/java/lambda_echo/build.gradle new file mode 100755 index 0000000000000..b15b851bea271 --- /dev/null +++ b/tests/aws/services/lambda_/functions/java/lambda_echo/build.gradle @@ -0,0 +1,42 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java project to get you started. + * For more details take a look at the Java Quickstart chapter in the Gradle + * user guide available at https://docs.gradle.org/5.0/userguide/tutorial_java_projects.html + */ + +plugins { + // Apply the java plugin to add support for Java + id 'java' + + // Apply the application plugin to add support for building an application + id 'application' +} + +repositories { + // Use jcenter for resolving your dependencies. + // You can declare any Maven/Ivy/file repository here. + jcenter() +} + +dependencies { + // This dependency is found on compile classpath of this component and consumers. + implementation 'com.amazonaws:aws-lambda-java-core:1.2.1' + implementation 'com.google.code.gson:gson:2.8.6' +} + +// Define the main class for the application +mainClassName = 'cloud.localstack.sample.LambdaHandlerWithLib' + +// create a single Jar with all dependencies +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + archiveName 'lambda-function-built-by-gradle.zip' +} + +build.dependsOn buildZip diff --git a/tests/aws/services/lambda_/functions/java/lambda_echo/build/distributions/lambda-function-built-by-gradle.zip b/tests/aws/services/lambda_/functions/java/lambda_echo/build/distributions/lambda-function-built-by-gradle.zip new file mode 100644 index 0000000000000..ac9d72db424f0 Binary files /dev/null and b/tests/aws/services/lambda_/functions/java/lambda_echo/build/distributions/lambda-function-built-by-gradle.zip differ diff --git a/tests/aws/services/lambda_/functions/java/lambda_echo/lambda-function-with-lib-0.0.1.jar b/tests/aws/services/lambda_/functions/java/lambda_echo/lambda-function-with-lib-0.0.1.jar new file mode 100644 index 0000000000000..3868ee5d2b114 Binary files /dev/null and b/tests/aws/services/lambda_/functions/java/lambda_echo/lambda-function-with-lib-0.0.1.jar differ diff --git a/tests/aws/services/lambda_/functions/java/lambda_echo/pom.xml b/tests/aws/services/lambda_/functions/java/lambda_echo/pom.xml new file mode 100644 index 0000000000000..192dffac42bbf --- /dev/null +++ b/tests/aws/services/lambda_/functions/java/lambda_echo/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + cloud.localstack.sample + lambda-function-with-lib + 0.0.1 + + + 11 + 11 + + + + + com.amazonaws + aws-lambda-java-core + 1.2.1 + + + + com.google.code.gson + gson + 2.8.9 + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.2 + + + copy-dependencies + prepare-package + + copy-dependencies + + + ${project.build.directory}/classes/lib + runtime + + + + + + + + diff --git a/tests/aws/services/lambda_/functions/java/lambda_echo/src/main/java/cloud/localstack/sample/LambdaHandlerWithLib.java b/tests/aws/services/lambda_/functions/java/lambda_echo/src/main/java/cloud/localstack/sample/LambdaHandlerWithLib.java new file mode 100644 index 0000000000000..6009f1361e314 --- /dev/null +++ b/tests/aws/services/lambda_/functions/java/lambda_echo/src/main/java/cloud/localstack/sample/LambdaHandlerWithLib.java @@ -0,0 +1,13 @@ +package cloud.localstack.sample; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import java.util.Map; +import com.google.gson.Gson; + +public class LambdaHandlerWithLib implements RequestHandler{ + public String handleRequest(Map echo, Context context) { + Gson gson = new Gson(); + return gson.toJson(echo); + } +} diff --git a/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/build.gradle b/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/build.gradle new file mode 100755 index 0000000000000..c31fb42bab1f5 --- /dev/null +++ b/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/build.gradle @@ -0,0 +1,42 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java project to get you started. + * For more details take a look at the Java Quickstart chapter in the Gradle + * user guide available at https://docs.gradle.org/5.0/userguide/tutorial_java_projects.html + */ + +plugins { + // Apply the java plugin to add support for Java + id 'java' + + // Apply the application plugin to add support for building an application + id 'application' +} + +repositories { + // Use jcenter for resolving your dependencies. + // You can declare any Maven/Ivy/file repository here. + mavenCentral() +} + +dependencies { + // This dependency is found on compile classpath of this component and consumers. + implementation 'com.amazonaws:aws-lambda-java-core:1.2.1' + implementation 'com.google.code.gson:gson:2.8.6' +} + +// Define the main class for the application +mainClassName = 'cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom' + +// create a single Jar with all dependencies +task buildZip(type: Zip) { + from compileJava + from processResources + into('lib') { + from configurations.runtimeClasspath + } + archiveName 'lambda-function-with-multiple-handlers.zip' +} + +build.dependsOn buildZip diff --git a/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/build/distributions/lambda-function-with-multiple-handlers.zip b/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/build/distributions/lambda-function-with-multiple-handlers.zip new file mode 100644 index 0000000000000..de9b23da10bb8 Binary files /dev/null and b/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/build/distributions/lambda-function-with-multiple-handlers.zip differ diff --git a/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/src/main/java/cloud/localstack/sample/LambdaHandlerWithInterfaceAndCustom.java b/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/src/main/java/cloud/localstack/sample/LambdaHandlerWithInterfaceAndCustom.java new file mode 100644 index 0000000000000..4c6957de8966d --- /dev/null +++ b/tests/aws/services/lambda_/functions/java/lambda_multiple_handlers/src/main/java/cloud/localstack/sample/LambdaHandlerWithInterfaceAndCustom.java @@ -0,0 +1,34 @@ +package cloud.localstack.sample; + +import java.util.Map; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.LambdaLogger; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +public class LambdaHandlerWithInterfaceAndCustom implements RequestHandler, String> { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + public String handleRequestCustom(Map event, Context context) + { + LambdaLogger logger = context.getLogger(); + logger.log("CUSTOM HANDLER"); + logger.log("ENV: " + gson.toJson(System.getenv())); + logger.log("EVENT: " + gson.toJson(event)); + logger.log("EVENT CLASS: " + event.getClass()); + logger.log("CONTEXT: " + gson.toJson(context)); + return "CUSTOM"; + } + public String handleRequest(Map event, Context context) { + LambdaLogger logger = context.getLogger(); + logger.log("INTERFACE HANDLER"); + logger.log("ENV: " + gson.toJson(System.getenv())); + logger.log("EVENT: " + gson.toJson(event)); + logger.log("EVENT CLASS: " + event.getClass()); + logger.log("CONTEXT: " + gson.toJson(context)); + return "INTERFACE"; + } +} diff --git a/tests/aws/services/lambda_/functions/kinesis_log.py b/tests/aws/services/lambda_/functions/kinesis_log.py new file mode 100644 index 0000000000000..f22a073505d2a --- /dev/null +++ b/tests/aws/services/lambda_/functions/kinesis_log.py @@ -0,0 +1,14 @@ +import json +from base64 import b64decode + + +def _process_kinesis_records(event): + for record in event["Records"]: + raw_data = record["kinesis"]["data"] + parsed_data = b64decode(raw_data.encode()) + yield json.loads(parsed_data.decode()) + + +def handler(event, context): + records_data = list(_process_kinesis_records(event)) + print(json.dumps(records_data)) diff --git a/tests/aws/services/lambda_/functions/lambda_aws_proxy.py b/tests/aws/services/lambda_/functions/lambda_aws_proxy.py new file mode 100644 index 0000000000000..83b92b144b670 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_aws_proxy.py @@ -0,0 +1,11 @@ +import json + + +def handler(event, context): + # Just print the event that was passed to the Lambda + print(json.dumps(event)) + return { + "statusCode": 200, + "body": json.dumps(event), + "isBase64Encoded": False, + } diff --git a/tests/aws/services/lambda_/functions/lambda_aws_proxy_format.py b/tests/aws/services/lambda_/functions/lambda_aws_proxy_format.py new file mode 100644 index 0000000000000..877059e1ca210 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_aws_proxy_format.py @@ -0,0 +1,22 @@ +import json + + +def handler(event, context): + # Just print the event that was passed to the Lambda + print(json.dumps(event)) + if event["path"] == "/no-body": + return {"statusCode": 200} + elif event["path"] == "/only-headers": + return {"statusCode": 200, "headers": {"test-header": "value"}} + elif event["path"] == "/wrong-format": + return {"statusCode": 200, "wrongValue": "value"} + + elif event["path"] == "/empty-response": + return {} + + else: + return { + "statusCode": 200, + "body": json.dumps(event), + "isBase64Encoded": False, + } diff --git a/tests/aws/services/lambda_/functions/lambda_cache.js b/tests/aws/services/lambda_/functions/lambda_cache.js new file mode 100644 index 0000000000000..7f53b4f7c3d38 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_cache.js @@ -0,0 +1,11 @@ +const localCache = { + counter: 0 +}; + +const handler = async (event, context) => { + return { + counter: localCache.counter++ + }; +}; + +module.exports = {handler}; diff --git a/tests/aws/services/lambda_/functions/lambda_cache.py b/tests/aws/services/lambda_/functions/lambda_cache.py new file mode 100644 index 0000000000000..a68dfb77b0b9d --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_cache.py @@ -0,0 +1,8 @@ +counter = 0 + + +def handler(event, context): + global counter + result = {"counter": counter} + counter += 1 + return result diff --git a/tests/aws/services/lambda_/functions/lambda_cloudwatch_logs.py b/tests/aws/services/lambda_/functions/lambda_cloudwatch_logs.py new file mode 100644 index 0000000000000..354749aa06122 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_cloudwatch_logs.py @@ -0,0 +1,10 @@ +""" +A simple handler which does a print on the "body" key of the event passed in. +Can be used to log different payloads, to check for the correct format in cloudwatch logs +""" + + +def handler(event, context): + # Just print the log line that was passed to lambda + print(event["body"]) + return event diff --git a/tests/aws/services/lambda_/functions/lambda_context.py b/tests/aws/services/lambda_/functions/lambda_context.py new file mode 100644 index 0000000000000..1a165513c5fb5 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_context.py @@ -0,0 +1,8 @@ +def handler(event, context): + print(event) + print(context.aws_request_id) + + if event.get("fail"): + raise Exception("Intentional failure") + + return context.aws_request_id diff --git a/tests/aws/services/lambda_/functions/lambda_echo.js b/tests/aws/services/lambda_/functions/lambda_echo.js new file mode 100644 index 0000000000000..601b930cebfa9 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_echo.js @@ -0,0 +1,4 @@ +exports.handler = async function(event, context) { + console.log(event); + return event; +}; diff --git a/tests/aws/services/lambda_/functions/lambda_echo.py b/tests/aws/services/lambda_/functions/lambda_echo.py new file mode 100644 index 0000000000000..ffd91ce25b746 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_echo.py @@ -0,0 +1,7 @@ +import json + + +def handler(event, context): + # Just print the event that was passed to the Lambda + print(json.dumps(event)) + return event diff --git a/tests/aws/services/lambda_/functions/lambda_echo_json_body.py b/tests/aws/services/lambda_/functions/lambda_echo_json_body.py new file mode 100644 index 0000000000000..1ac9cca0b028a --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_echo_json_body.py @@ -0,0 +1,25 @@ +""" +Interprets the body of the lambda event as a JSON document and returns it. This allows you to +dynamically simulate functions that look like:: + + def handler(event, context): + return { + "statusCode": 201, + "headers": { + "Content-Type": "application/json", + "My-Custom-Header": "Custom Value" + }, + "body": json.dumps({ + "message": "Hello, world!" + }), + "isBase64Encoded": False, + } +""" + +import json + + +def handler(event, context): + # Just print the event that was passed to the Lambda + print(json.dumps(event)) + return json.loads(event["body"]) diff --git a/tests/aws/services/lambda_/functions/lambda_echo_status_code.py b/tests/aws/services/lambda_/functions/lambda_echo_status_code.py new file mode 100644 index 0000000000000..fd41fae575e96 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_echo_status_code.py @@ -0,0 +1,19 @@ +import json +from http import HTTPStatus + + +def make_response(status_code: int, message: str): + return { + "statusCode": status_code, + "headers": {"Content-Type": "application/json"}, + "body": {"status_code": status_code, "message": message}, + } + + +def handler(event, context): + print(json.dumps(event)) + path: str = event["requestContext"]["http"].get("path", "") + status_code = path.split("/")[-1] + if not status_code.isdigit() or int(status_code) not in list(HTTPStatus): + return make_response(HTTPStatus.BAD_REQUEST, f"No valid status found at end of path {path}") + return make_response(int(status_code), "") diff --git a/tests/aws/services/lambda_/functions/lambda_echo_version_env.py b/tests/aws/services/lambda_/functions/lambda_echo_version_env.py new file mode 100644 index 0000000000000..b7329f8161d28 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_echo_version_env.py @@ -0,0 +1,16 @@ +import json +import os + + +def handler(event, context): + # Just print the event that was passed to the Lambda + print( + json.dumps( + { + "function_version": os.environ.get("AWS_LAMBDA_FUNCTION_VERSION"), + "CUSTOM_VAR": os.environ.get("CUSTOM_VAR"), + "event": event, + } + ) + ) + return event diff --git a/tests/aws/services/lambda_/functions/lambda_echofail.py b/tests/aws/services/lambda_/functions/lambda_echofail.py new file mode 100644 index 0000000000000..5580ede996c08 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_echofail.py @@ -0,0 +1,7 @@ +import json + + +def handler(event, context): + # Just print the event that was passed to the Lambda + print(json.dumps({"event": event, "aws_request_id": context.aws_request_id})) + raise Exception("intentional failure") diff --git a/tests/aws/services/lambda_/functions/lambda_environment.py b/tests/aws/services/lambda_/functions/lambda_environment.py new file mode 100644 index 0000000000000..b47c936c9bd7d --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_environment.py @@ -0,0 +1,15 @@ +import os + +MSG_BODY_RAISE_ERROR_FLAG = "raise_error" + + +def handler(event, context): + """Simple Lambda function that returns the value of the "Hello" environment variable""" + if MSG_BODY_RAISE_ERROR_FLAG in event: + raise Exception("Test exception (this is intentional)") + raw_string = os.environ.get("raw_string_result") + if raw_string: + return raw_string + if event.get("map"): + return {"Hello": event.get("map")} + return {"Hello": os.environ.get("Hello", "_value_missing_")} diff --git a/tests/aws/services/lambda_/functions/lambda_event_source_mapping_send_message.py b/tests/aws/services/lambda_/functions/lambda_event_source_mapping_send_message.py new file mode 100644 index 0000000000000..6ce293c92fe2f --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_event_source_mapping_send_message.py @@ -0,0 +1,21 @@ +import json +import os + +import boto3 + + +def handler(event, context): + endpoint_url = os.environ.get("AWS_ENDPOINT_URL") + + region_name = ( + os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") or "us-east-1" + ) + + sqs = boto3.client("sqs", endpoint_url=endpoint_url, verify=False, region_name=region_name) + + queue_url = os.environ.get("SQS_QUEUE_URL") + + records = event.get("Records", []) + sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(records)) + + return {"count": len(records)} diff --git a/tests/aws/services/lambda_/functions/lambda_handler.js b/tests/aws/services/lambda_/functions/lambda_handler.js new file mode 100644 index 0000000000000..416c72ee8e864 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_handler.js @@ -0,0 +1,9 @@ +exports.handler = async (event) => { + console.log(JSON.stringify(event)) + return { + statusCode: 200, + body: JSON.stringify(`response from localstack lambda: ${JSON.stringify(event)}`), + isBase64Encoded: false, + headers: {} + } +} diff --git a/tests/aws/services/lambda_/functions/lambda_handler_error.py b/tests/aws/services/lambda_/functions/lambda_handler_error.py new file mode 100644 index 0000000000000..d59bb08795fc3 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_handler_error.py @@ -0,0 +1,2 @@ +def handler(event, context): + raise Exception("Handler fails") diff --git a/tests/aws/services/lambda_/functions/lambda_handler_es6.mjs b/tests/aws/services/lambda_/functions/lambda_handler_es6.mjs new file mode 100644 index 0000000000000..6f1224c535969 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_handler_es6.mjs @@ -0,0 +1,8 @@ + +export const handler = async (event) => { + console.log(JSON.stringify(event)) + return { + statusCode: 200, + body: JSON.stringify(`response from localstack lambda: ${JSON.stringify(event)}`) + } +} diff --git a/tests/aws/services/lambda_/functions/lambda_handler_exit.py b/tests/aws/services/lambda_/functions/lambda_handler_exit.py new file mode 100644 index 0000000000000..a0e406d6fd8c5 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_handler_exit.py @@ -0,0 +1,5 @@ +import sys + + +def handler(event, context): + sys.exit(0) diff --git a/tests/aws/services/lambda_/functions/lambda_integration.js b/tests/aws/services/lambda_/functions/lambda_integration.js new file mode 100644 index 0000000000000..1724ccd42d094 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_integration.js @@ -0,0 +1,9 @@ +exports.handler = function(event, context, callback) { + console.log('Node.js Lambda handler executing.'); + var result = {context}; + if (callback) { + callback(null, result); + } else { + context.succeed(result); + } +}; diff --git a/tests/aws/services/lambda_/functions/lambda_integration.py b/tests/aws/services/lambda_/functions/lambda_integration.py new file mode 100644 index 0000000000000..b889985104b36 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_integration.py @@ -0,0 +1,201 @@ +""" +TODO: replace this file with smaller scoped lambda handlers +""" + +import base64 +import json +import logging +import os +from io import BytesIO +from typing import Union + +import boto3.dynamodb.types + +TEST_BUCKET_NAME = "test-bucket" +KINESIS_STREAM_NAME = os.getenv("KINESIS_STREAM_NAME") or "test_stream_1" +MSG_BODY_RAISE_ERROR_FLAG = "raise_error" +MSG_BODY_MESSAGE_TARGET = "message_target" +MSG_BODY_DELETE_BATCH = "delete_batch_test" + +logging.basicConfig(level=logging.INFO) +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.INFO) +LOGGER.flush = lambda: [handler_weakref().flush() for handler_weakref in logging._handlerList] + + +# Do not import this function from localstack.utils.common (this is a standalone application / lambda). +def to_str(obj: Union[str, bytes], encoding: str = "utf-8", errors="strict") -> str: + return obj.decode(encoding, errors) if isinstance(obj, bytes) else obj + + +# Do not import this function from localstack.utils.common (this is a standalone application / lambda). +def to_bytes(obj: Union[str, bytes], encoding: str = "utf-8", errors="strict") -> bytes: + return obj.encode(encoding, errors) if isinstance(obj, str) else obj + + +# Subclass of boto's TypeDeserializer for DynamoDB +# to adjust for DynamoDB Stream format. +class TypeDeserializer(boto3.dynamodb.types.TypeDeserializer): + def _deserialize_n(self, value): + return float(value) + + def _deserialize_b(self, value): + return value # already in Base64 + + +def handler(event, context): + """Generic event forwarder Lambda.""" + + # print test messages (to test CloudWatch Logs integration) + print("Lambda log message - print function", flush=True) + LOGGER.info("Lambda log message - logging module") + LOGGER.flush() + + if MSG_BODY_RAISE_ERROR_FLAG in event: + raise Exception("Test exception (this is intentional)") + + if "httpMethod" in event: + # looks like this is a call from an AWS_PROXY API Gateway + try: + body = json.loads(event["body"]) + except Exception: + body = {} + + body["path"] = event.get("path") + body["resource"] = event.get("resource") + body["pathParameters"] = event.get("pathParameters") + body["requestContext"] = event.get("requestContext") + body["queryStringParameters"] = event.get("queryStringParameters") + body["httpMethod"] = event.get("httpMethod") + body["body"] = event.get("body") + body["headers"] = event.get("headers") + body["isBase64Encoded"] = event.get("isBase64Encoded") + if body["httpMethod"] == "DELETE": + return {"statusCode": 204, "body": ""} + + # This parameter is often just completely excluded from the response. + base64_response = {} + is_base_64_encoded = body.get("return_is_base_64_encoded") + if is_base_64_encoded is not None: + base64_response["isBase64Encoded"] = is_base_64_encoded + + status_code = body.get("return_status_code", 200) + headers = body.get("return_headers", {}) + body = body.get("return_raw_body") or body + + return { + "body": body, + "statusCode": status_code, + "isBase64Encoded": is_base_64_encoded, + "headers": headers, + "multiValueHeaders": {"set-cookie": ["language=en-US", "theme=blue moon"]}, + **base64_response, + } + if MSG_BODY_DELETE_BATCH in event: + sqs_client = create_external_boto_client("sqs") + queue_url = event.get(MSG_BODY_DELETE_BATCH) + message = sqs_client.receive_message(QueueUrl=queue_url)["Messages"][0] + sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"]) + messages = sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=10)[ + "Messages" + ] + entries = [message["ReceiptHandle"] for message in messages] + sqs_client.delete_message_batch(QueueUrl=queue_url, Entries=entries) + + if "Records" not in event: + result_map = {"event": event, "context": {}} + result_map["context"]["invoked_function_arn"] = context.invoked_function_arn + result_map["context"]["function_version"] = context.function_version + result_map["context"]["function_name"] = context.function_name + result_map["context"]["memory_limit_in_mb"] = context.memory_limit_in_mb + result_map["context"]["aws_request_id"] = context.aws_request_id + result_map["context"]["log_group_name"] = context.log_group_name + result_map["context"]["log_stream_name"] = context.log_stream_name + + if hasattr(context, "client_context"): + result_map["context"]["client_context"] = context.client_context + + return result_map + + raw_event_messages = [] + for record in event["Records"]: + # Deserialize into Python dictionary and extract the + # "NewImage" (the new version of the full ddb document) + ddb_new_image = deserialize_event(record) + + if MSG_BODY_RAISE_ERROR_FLAG in ddb_new_image.get("data", {}): + raise Exception("Test exception (this is intentional)") + + # Place the raw event message document into the Kinesis message format + kinesis_record = {"PartitionKey": "key123", "Data": json.dumps(ddb_new_image)} + + if MSG_BODY_MESSAGE_TARGET in ddb_new_image.get("data", {}): + forwarding_target = ddb_new_image["data"][MSG_BODY_MESSAGE_TARGET] + target_name = forwarding_target.split(":")[-1] + if forwarding_target.startswith("kinesis:"): + ddb_new_image["data"][MSG_BODY_MESSAGE_TARGET] = "s3:test_chain_result" + kinesis_record["Data"] = json.dumps(ddb_new_image["data"]) + forward_event_to_target_stream(kinesis_record, target_name) + elif forwarding_target.startswith("s3:"): + s3_client = create_external_boto_client("s3") + test_data = to_bytes(json.dumps({"test_data": ddb_new_image["data"]["test_data"]})) + s3_client.upload_fileobj(BytesIO(test_data), TEST_BUCKET_NAME, target_name) + else: + raw_event_messages.append(kinesis_record) + + # Forward messages to Kinesis + forward_events(raw_event_messages) + + +def deserialize_event(event): + # Deserialize into Python dictionary and extract the "NewImage" (the new version of the full ddb document) + ddb = event.get("dynamodb") + if ddb: + result = { + "__action_type": event.get("eventName"), + } + + ddb_deserializer = TypeDeserializer() + if ddb.get("OldImage"): + result["old_image"] = ddb_deserializer.deserialize({"M": ddb.get("OldImage")}) + if ddb.get("NewImage"): + result["new_image"] = ddb_deserializer.deserialize({"M": ddb.get("NewImage")}) + + return result + kinesis = event.get("kinesis") + if kinesis: + assert kinesis["sequenceNumber"] + kinesis["data"] = json.loads(to_str(base64.b64decode(kinesis["data"]))) + return kinesis + sqs = event.get("sqs") + if sqs: + result = {"data": event["body"]} + return result + sns = event.get("Sns") + if sns: + result = {"data": sns["Message"]} + return result + + +def forward_events(records): + if not records: + return + kinesis = create_external_boto_client("kinesis") + kinesis.put_records(StreamName=KINESIS_STREAM_NAME, Records=records) + + +def forward_event_to_target_stream(record, stream_name): + kinesis = create_external_boto_client("kinesis") + kinesis.put_record( + StreamName=stream_name, Data=record["Data"], PartitionKey=record["PartitionKey"] + ) + + +def create_external_boto_client(service): + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + region_name = ( + os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") or "us-east-1" + ) + return boto3.client(service, endpoint_url=endpoint_url, region_name=region_name) diff --git a/tests/aws/services/lambda_/functions/lambda_introspect.py b/tests/aws/services/lambda_/functions/lambda_introspect.py new file mode 100644 index 0000000000000..dd77591474a9c --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_introspect.py @@ -0,0 +1,38 @@ +import getpass +import os +import platform +import re +import stat +import subprocess +import time +from pathlib import Path + + +def handler(event, context): + if event.get("wait"): + time.sleep(event["wait"]) + + paths = ["/var/task", "/opt", "/tmp", "/lambda-entrypoint.sh"] + path_details = {} + for p in paths: + path_label = re.sub("/", "_", p) + path = Path(p) + path_details[f"{path_label}_mode"] = stat.filemode(path.stat().st_mode) + path_details[f"{path_label}_uid"] = path.stat().st_uid + path_details[f"{path_label}_owner"] = path.owner() + path_details[f"{path_label}_gid"] = path.stat().st_gid + # Raises KeyError "'getgrgid(): gid not found: 995'" + # path_details[f"{path_label}_group"] = path.group() + + return { + # Tested in tests/aws/services/lambda_/test_lambda_common.py + # "environment": dict(os.environ), + "event": event, + # user behavior: https://stackoverflow.com/a/25574419 + "user_login_name": getpass.getuser(), + "user_whoami": subprocess.getoutput("whoami"), + "platform_system": platform.system(), + "platform_machine": platform.machine(), + "pwd": os.getcwd(), + "paths": path_details, + } diff --git a/tests/aws/services/lambda_/functions/lambda_invocation_type.py b/tests/aws/services/lambda_/functions/lambda_invocation_type.py new file mode 100644 index 0000000000000..e7bb2f816559f --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_invocation_type.py @@ -0,0 +1,10 @@ +import os +import time + + +def handler(event, context): + if event.get("wait"): + time.sleep(event["wait"]) + init_type = os.environ["AWS_LAMBDA_INITIALIZATION_TYPE"] + print(f"{init_type=}") + return init_type diff --git a/tests/aws/services/lambda_/functions/lambda_logging.py b/tests/aws/services/lambda_/functions/lambda_logging.py new file mode 100644 index 0000000000000..dc03ae224fea0 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_logging.py @@ -0,0 +1,10 @@ +import logging + +logger = logging.getLogger() +logger.setLevel(logging.DEBUG) + + +def handler(event, ctx): + verification_token = event["verification_token"] + logging.info("verification_token=%s", verification_token) + return {"verification_token": verification_token} diff --git a/tests/aws/services/lambda_/functions/lambda_mapping_responses.py b/tests/aws/services/lambda_/functions/lambda_mapping_responses.py new file mode 100644 index 0000000000000..0c3d088e1622c --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_mapping_responses.py @@ -0,0 +1,7 @@ +def handler(event, context): + body = event.get("body") + status_code = body.get("httpStatus") + if int(status_code) >= 400: + return {"statusCode": 200, "body": "customerror"} + else: + return {"statusCode": 200, "body": "noerror"} diff --git a/tests/aws/services/lambda_/functions/lambda_multiple_handlers.py b/tests/aws/services/lambda_/functions/lambda_multiple_handlers.py new file mode 100644 index 0000000000000..73da89d5658ab --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_multiple_handlers.py @@ -0,0 +1,14 @@ +def handler(event, context): + result = { + "handler": "handler", + } + print(result) + return result + + +def handler_two(event, context): + result = { + "handler": "handler_two", + } + print(result) + return result diff --git a/tests/aws/services/lambda_/functions/lambda_networks.py b/tests/aws/services/lambda_/functions/lambda_networks.py new file mode 100644 index 0000000000000..f7f3292c5068b --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_networks.py @@ -0,0 +1,10 @@ +from urllib.request import Request, urlopen + + +def handler(event, context): + url = event.get("url") + + httprequest = Request(url, headers={"Accept": "application/json"}) + + with urlopen(httprequest) as response: + return {"status": response.status, "response": response.read().decode()} diff --git a/tests/aws/services/lambda_/functions/lambda_none.js b/tests/aws/services/lambda_/functions/lambda_none.js new file mode 100644 index 0000000000000..43fdf14b8f6d4 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_none.js @@ -0,0 +1,3 @@ +exports.handler = async (event) => { + console.log(JSON.stringify(event)) +} diff --git a/tests/aws/services/lambda_/functions/lambda_none.py b/tests/aws/services/lambda_/functions/lambda_none.py new file mode 100644 index 0000000000000..91a4fc6d5d866 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_none.py @@ -0,0 +1,7 @@ +import json + + +def handler(event, context): + # Just print the event that was passed to the Lambda and return nothing + print(json.dumps(event)) + return diff --git a/tests/aws/services/lambda_/functions/lambda_notifier.py b/tests/aws/services/lambda_/functions/lambda_notifier.py new file mode 100644 index 0000000000000..01b75c6fd64b9 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_notifier.py @@ -0,0 +1,40 @@ +import datetime +import json +import os +import time + +import boto3 + +sqs_client = boto3.client("sqs", endpoint_url=os.environ.get("AWS_ENDPOINT_URL")) + + +def handler(event, context): + """Example: Send a message to the queue_url provided in notify and then wait for 7 seconds. + The message includes the value of the environment variable called "FUNCTION_VARIANT". + aws_client.lambda_.invoke( + FunctionName=fn_arn, + InvocationType="Event", + Payload=json.dumps({"notify": queue_url, "env_var": "FUNCTION_VARIANT", "label": "01-sleep", "wait": 7}) + ) + + Parameters: + * `notify`: SQS queue URL to notify a message + * `env_var`: Name of the environment variable that should be included in the message + * `label`: Label to be included in the message + * `wait`: Time in seconds to sleep + """ + if queue_url := event.get("notify"): + message = { + "request_id": context.aws_request_id, + "timestamp": datetime.datetime.now(datetime.UTC).isoformat(), + } + if env_var := event.get("env_var"): + message[env_var] = os.environ[env_var] + if label := event.get("label"): + message["label"] = label + print(f"Notify message: {message}") + sqs_client.send_message(QueueUrl=queue_url, MessageBody=json.dumps(message)) + + if wait_time := event.get("wait"): + print(f"Sleeping for {wait_time} seconds ...") + time.sleep(wait_time) diff --git a/tests/aws/services/lambda_/functions/lambda_parallel.py b/tests/aws/services/lambda_/functions/lambda_parallel.py new file mode 100644 index 0000000000000..d2d5464a2b1e1 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_parallel.py @@ -0,0 +1,10 @@ +import json +import time + + +def handler(event, context): + result = {"executionStart": time.time(), "event": event} + time.sleep(5) + # Just print the event was passed to lambda + print(json.dumps(result)) + return result diff --git a/tests/aws/services/lambda_/functions/lambda_print.py b/tests/aws/services/lambda_/functions/lambda_print.py new file mode 100644 index 0000000000000..99e7c9e886a1d --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_print.py @@ -0,0 +1,4 @@ +def handler(event, ctx): + verification_token = event["verification_token"] + print(f"{verification_token=}") + return {"verification_token": verification_token} diff --git a/tests/aws/services/lambda_/functions/lambda_process_inspection.py b/tests/aws/services/lambda_/functions/lambda_process_inspection.py new file mode 100644 index 0000000000000..8aa03169bcc80 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_process_inspection.py @@ -0,0 +1,8 @@ +def handler(event, context): + pid = event.get("pid") + with open(f"/proc/{pid}/environ", mode="rt") as f: + environment = f.read() + environment = environment.split("\x00") + env_partition = [env.partition("=") for env in environment if env] + env_dict = {env[0]: env[2] for env in env_partition} + return {"environment": env_dict} diff --git a/tests/aws/services/lambda_/functions/lambda_put_item.py b/tests/aws/services/lambda_/functions/lambda_put_item.py new file mode 100644 index 0000000000000..4d71f2e9a6d16 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_put_item.py @@ -0,0 +1,22 @@ +import os + +import boto3 + +# TODO - merge this file with lambda_send_message.py, to avoid duplication + + +def handler(event, context): + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + ddb = boto3.resource( + "dynamodb", + endpoint_url=endpoint_url, + region_name=event["region_name"], + verify=False, + ) + + table_name = event["table_name"] + table = ddb.Table(table_name) + for item in event["items"]: + table.put_item(Item=item) diff --git a/tests/aws/services/lambda_/functions/lambda_python_apigwhandler.py b/tests/aws/services/lambda_/functions/lambda_python_apigwhandler.py new file mode 100644 index 0000000000000..f5472f62cef68 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_python_apigwhandler.py @@ -0,0 +1,10 @@ +import json + + +def handler(event, context): + return { + "isBase64Encoded": False, + "headers": {}, + "body": json.dumps({"test": "hello world"}), + "statusCode": 200, + } diff --git a/tests/aws/services/lambda_/functions/lambda_python_version.py b/tests/aws/services/lambda_/functions/lambda_python_version.py new file mode 100644 index 0000000000000..654def0ae9475 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_python_version.py @@ -0,0 +1,9 @@ +import sys + + +def handler(event, context): + return { + "version": "python{major}.{minor}".format( + major=sys.version_info.major, minor=sys.version_info.minor + ) + } diff --git a/tests/aws/services/lambda_/functions/lambda_report_batch_item_failures_dynamodb.py b/tests/aws/services/lambda_/functions/lambda_report_batch_item_failures_dynamodb.py new file mode 100644 index 0000000000000..346f72c794b15 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_report_batch_item_failures_dynamodb.py @@ -0,0 +1,19 @@ +# For DynamoDB Streams and Kinesis: +# If the batchItemFailures array contains multiple items, Lambda uses the record with the lowest sequence number as the checkpoint. +# Lambda then retries all records starting from that checkpoint. + +import json + + +def handler(event, context): + batch_item_failures = [] + print(json.dumps(event)) + + for record in event.get("Records", []): + new_image = record["dynamodb"].get("NewImage", {}) + + # If multiple items, the lowest sequence number is selected + if new_image.get("should_fail", {}).get("BOOL", False): + batch_item_failures.append({"itemIdentifier": record["dynamodb"]["SequenceNumber"]}) + + return {"batchItemFailures": batch_item_failures} diff --git a/tests/aws/services/lambda_/functions/lambda_report_batch_item_failures_kinesis.py b/tests/aws/services/lambda_/functions/lambda_report_batch_item_failures_kinesis.py new file mode 100644 index 0000000000000..c8712cfde5831 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_report_batch_item_failures_kinesis.py @@ -0,0 +1,20 @@ +# For DynamoDB Streams and Kinesis: +# If the batchItemFailures array contains multiple items, Lambda uses the record with the lowest sequence number as the checkpoint. +# Lambda then retries all records starting from that checkpoint. + +import base64 +import json + + +def handler(event, context): + batch_item_failures = [] + print(json.dumps(event)) + + for record in event.get("Records", []): + payload = json.loads(base64.b64decode(record["kinesis"]["data"])) + + # If multiple items, the lowest sequence number is selected + if payload.get("should_fail", False): + batch_item_failures.append({"itemIdentifier": record["kinesis"]["sequenceNumber"]}) + + return {"batchItemFailures": batch_item_failures} diff --git a/tests/aws/services/lambda_/functions/lambda_request_id.py b/tests/aws/services/lambda_/functions/lambda_request_id.py new file mode 100644 index 0000000000000..6c91041d9e68f --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_request_id.py @@ -0,0 +1,11 @@ +import logging + +logging.basicConfig(level=logging.INFO) +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.INFO) + + +def handler(event, context): + # Logger format: []\tdate\t\t + LOGGER.info("RequestId log message") + return context.aws_request_id diff --git a/tests/aws/services/lambda_/functions/lambda_response_size.py b/tests/aws/services/lambda_/functions/lambda_response_size.py new file mode 100644 index 0000000000000..589b7bdbeb6f8 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_response_size.py @@ -0,0 +1,4 @@ +def handler(event, ctx): + print("generating bytes...") + bytenum = event["bytenum"] + return "a" * bytenum diff --git a/tests/aws/services/lambda_/functions/lambda_role.py b/tests/aws/services/lambda_/functions/lambda_role.py new file mode 100644 index 0000000000000..5603e4abed11f --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_role.py @@ -0,0 +1,13 @@ +import os + +import boto3 + + +def handler(event, context): + aws_endpoint_url = os.environ.get("AWS_ENDPOINT_URL") + if aws_endpoint_url: + sts_client = boto3.client("sts", endpoint_url=aws_endpoint_url) + else: + sts_client = boto3.client("sts") + + return sts_client.get_caller_identity() diff --git a/tests/aws/services/lambda_/functions/lambda_runtime_error.py b/tests/aws/services/lambda_/functions/lambda_runtime_error.py new file mode 100644 index 0000000000000..648b59fceedc2 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_runtime_error.py @@ -0,0 +1 @@ +raise Exception("Runtime startup fails") diff --git a/tests/aws/services/lambda_/functions/lambda_runtime_exit.py b/tests/aws/services/lambda_/functions/lambda_runtime_exit.py new file mode 100644 index 0000000000000..a0d15772aca7a --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_runtime_exit.py @@ -0,0 +1,3 @@ +import sys + +sys.exit(0) diff --git a/tests/aws/services/lambda_/functions/lambda_runtime_exit_segfault.py b/tests/aws/services/lambda_/functions/lambda_runtime_exit_segfault.py new file mode 100644 index 0000000000000..017dbcb469d12 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_runtime_exit_segfault.py @@ -0,0 +1,14 @@ +import sys + +# Triggers segfault through a stack overflow when using unbound recursion: +# https://stackoverflow.com/questions/61031604/why-am-i-getting-a-segmentation-fault-using-python3#comment107974230_61031712 +sys.setrecursionlimit(10**6) + + +# Unbound recursion: https://code-maven.com/slides/python/unbound-recursion +def recursion(n): + print(f"In recursion {n}") + recursion(n + 1) + + +recursion(1) diff --git a/tests/aws/services/lambda_/functions/lambda_s3_integration.mjs b/tests/aws/services/lambda_/functions/lambda_s3_integration.mjs new file mode 100644 index 0000000000000..a867af6aa4446 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_s3_integration.mjs @@ -0,0 +1,37 @@ +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; + +export const handler = async (event, context) => { + const BUCKET_NAME = process.env.AWS_LAMBDA_FUNCTION_NAME; + let s3; + if (process.env.AWS_ENDPOINT_URL) { + const CREDENTIALS = { + secretAccessKey: 'test', + accessKeyId: 'test', + }; + + s3 = new S3Client({ + endpoint: "http://s3.localhost.localstack.cloud:4566", + region: 'us-east-1', + credentials: CREDENTIALS, + }); + } else { + s3 = new S3Client() + } + + const url = await getSignedUrl( + s3, + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: 'key.png', + ContentType: 'image/png' + }), + { + expiresIn: 86400 + } + ); + return { + statusCode: 200, + body: JSON.stringify(url) + }; +} diff --git a/tests/aws/services/lambda_/functions/lambda_s3_integration.py b/tests/aws/services/lambda_/functions/lambda_s3_integration.py new file mode 100644 index 0000000000000..47ab0acd06502 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_s3_integration.py @@ -0,0 +1,24 @@ +import json +import os +import tempfile + +import boto3 + +s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL")) +BUCKET_NAME = os.environ["S3_BUCKET_NAME"] + + +def handler(event, context): + s3_key = context.aws_request_id + file_size_bytes = event.get("file_size_bytes") + if file_size_bytes is not None: + # Upload random file if file_size_bytes is present in the event + with tempfile.SpooledTemporaryFile() as tmpfile: + tmpfile.write(os.urandom(file_size_bytes)) + s3.upload_fileobj(tmpfile, BUCKET_NAME, s3_key) + else: + # Upload the event otherwise + s3.put_object(Bucket=BUCKET_NAME, Key=s3_key, Body=json.dumps(event)) + + function_version = os.environ["AWS_LAMBDA_FUNCTION_VERSION"] + return {"s3_key": s3_key, "function_version": function_version} diff --git a/tests/aws/services/lambda_/functions/lambda_s3_integration_function_version.py b/tests/aws/services/lambda_/functions/lambda_s3_integration_function_version.py new file mode 100644 index 0000000000000..3dfc62dda49d1 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_s3_integration_function_version.py @@ -0,0 +1,34 @@ +import json +import os +import time + +import boto3 + +s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL")) +S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME") +# Configurable identifier to test function updates +FUNCTION_VARIANT = os.environ.get("FUNCTION_VARIANT") + + +def handler(event, context): + sleep_duration = int(event.get("sleep_seconds", 0)) + if sleep_duration > 0: + print(f"Sleeping for {sleep_duration} seconds ...") + time.sleep(sleep_duration) + print("... done sleeping") + + request_prefix = event.get("request_prefix") + response = { + "function_version": context.function_version, + "request_id": context.aws_request_id, + "request_prefix": request_prefix, + "function_variant": FUNCTION_VARIANT, + } + + # The side effect is required to test async invokes + if S3_BUCKET_NAME: + s3_key = f"{request_prefix}--{FUNCTION_VARIANT}" + response["s3_key"] = s3_key + s3.put_object(Bucket=S3_BUCKET_NAME, Key=s3_key, Body=json.dumps(response)) + + return response diff --git a/tests/aws/services/lambda_/functions/lambda_s3_integration_presign.mjs b/tests/aws/services/lambda_/functions/lambda_s3_integration_presign.mjs new file mode 100644 index 0000000000000..cf0699b45dbe4 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_s3_integration_presign.mjs @@ -0,0 +1,41 @@ +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; + +export const handler = async (event, context) => { + const BUCKET_NAME = process.env.AWS_LAMBDA_FUNCTION_NAME; + const bodyMd5AsBase64 = '4QrcOUm6Wau+VuBX8g+IPg=='; // body should be '123456' + + let s3; + if (process.env.AWS_ENDPOINT_URL) { + const CREDENTIALS = { + secretAccessKey: process.env.SECRET_KEY, + accessKeyId: process.env.ACCESS_KEY, + }; + + s3 = new S3Client({ + endpoint: "http://s3.localhost.localstack.cloud:4566", + region: process.env.AWS_REGION, + credentials: CREDENTIALS, + }); + } else { + s3 = new S3Client() + } + + const url = await getSignedUrl( + s3, + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: 'temp.txt', + StorageClass: 'STANDARD', + Metadata: {"foo": "bar-complicated-no-random"}, + ContentMD5: bodyMd5AsBase64 + }), + { + expiresIn: 86400 + } + ); + return { + statusCode: 200, + body: JSON.stringify(url) + }; +} diff --git a/tests/aws/services/lambda_/functions/lambda_s3_integration_sdk_v2.js b/tests/aws/services/lambda_/functions/lambda_s3_integration_sdk_v2.js new file mode 100644 index 0000000000000..f617a4b8de957 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_s3_integration_sdk_v2.js @@ -0,0 +1,47 @@ +exports.handler = async (event, context, callback) => { + const AWS = require('aws-sdk'); + + const BUCKET_NAME = process.env.AWS_LAMBDA_FUNCTION_NAME; + const bodyMd5AsBase64 = '4QrcOUm6Wau+VuBX8g+IPg=='; // body should be '123456' + + let s3; + if (process.env.AWS_ENDPOINT_URL) { + const CREDENTIALS = { + secretAccessKey: process.env.SECRET_KEY, + accessKeyId: process.env.ACCESS_KEY, + }; + s3 = new AWS.S3({ + endpoint: "http://s3.localhost.localstack.cloud:4566", + region: process.env.AWS_REGION, + signatureVersion: 'v4', // Required for the presigned URL functionality with extra headers + credentials: CREDENTIALS + }); + + } else { + s3 = new AWS.S3({ signatureVersion: 'v4' }) + } + + const url = s3.getSignedUrl('putObject', { + Bucket: BUCKET_NAME, + Key: 'key-for-signed-headers-in-qs', + Expires: 3600, + ServerSideEncryption: 'AES256', // Adds 'X-Amz-Server-Side-Encryption' in query string + ContentMD5: bodyMd5AsBase64 // Adds 'Content-MD5' parameter in query string + }); + + // url: http://localhost:4566/test-bucket-ls-presigned/key-for-signed-headers-in-qs + // ?Content-MD5=4QrcOUm6Wau%2BVuBX8g%2BIPg%3D%3D + // &X-Amz-Algorithm=AWS4-HMAC-SHA256 + // &X-Amz-Credential=test%2F20220113%2Fus-east-1%2Fs3%2Faws4_request + // &X-Amz-Date=20220113T142952Z + // &X-Amz-Expires=3600 + // &X-Amz-Signature=d219a729f06e37d40a136bb5fec777265b1b34e879f9e338d385b39a3760a14f + // &X-Amz-SignedHeaders=content-md5%3Bhost%3Bx-amz-server-side-encryption + // &x-amz-server-side-encryption=AES256 + // + // NOTE X-Amz-SignedHeaders contains `content-md5` and `x-amz-server-side-encryption` keys as well + return { + statusCode: 200, + body: JSON.stringify(url) + }; +} diff --git a/tests/aws/services/lambda_/functions/lambda_select_pattern.py b/tests/aws/services/lambda_/functions/lambda_select_pattern.py new file mode 100644 index 0000000000000..12429f1990555 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_select_pattern.py @@ -0,0 +1,11 @@ +def handler(event, context): + status_code = event["statusCode"] + match status_code: + case "200": + return "Pass" + case "400": + raise Exception("Error: Raising four hundred from within the Lambda function") + case "500": + raise Exception("Error: Raising five hundred from within the Lambda function") + case _: + return "Error Value in the json request should either be 400 or 500 to demonstrate" diff --git a/tests/aws/services/lambda_/functions/lambda_send_message.py b/tests/aws/services/lambda_/functions/lambda_send_message.py new file mode 100644 index 0000000000000..8edf152af84f2 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_send_message.py @@ -0,0 +1,17 @@ +import os + +import boto3 + + +def handler(event, context): + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + sqs = boto3.client( + "sqs", endpoint_url=endpoint_url, region_name=event["region_name"], verify=False + ) + + queue_url = sqs.get_queue_url(QueueName=event["queue_name"])["QueueUrl"] + rs = sqs.send_message(QueueUrl=queue_url, MessageBody=event["message"]) + + return rs["MessageId"] diff --git a/tests/aws/services/lambda_/functions/lambda_sleep.py b/tests/aws/services/lambda_/functions/lambda_sleep.py new file mode 100644 index 0000000000000..41f77d9c3170a --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_sleep.py @@ -0,0 +1,11 @@ +import os +import time + +sleep_duration = int(os.getenv("TEST_SLEEP_S", "0")) + + +def handler(event, context): + print(f"sleeping for {sleep_duration}") + time.sleep(sleep_duration) + print("done sleeping") + return {"status": "ok"} diff --git a/tests/aws/services/lambda_/functions/lambda_sleep_environment.py b/tests/aws/services/lambda_/functions/lambda_sleep_environment.py new file mode 100644 index 0000000000000..220f6a1273f9b --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_sleep_environment.py @@ -0,0 +1,8 @@ +import os +import time + + +def handler(event, context): + if event.get("sleep"): + time.sleep(event.get("sleep")) + return {"environment": dict(os.environ)} diff --git a/tests/aws/services/lambda_/functions/lambda_sqs_batch_item_failure.py b/tests/aws/services/lambda_/functions/lambda_sqs_batch_item_failure.py new file mode 100644 index 0000000000000..ce87a859bb0e8 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_sqs_batch_item_failure.py @@ -0,0 +1,77 @@ +"""This lambda is used for lambda/sqs integration tests. Since SQS event source mappings don't allow +DestinationConfigurations that send lambda results to other source (like SQS queues), that can be used to verify +invocations, this lambda does this manually. You can pass in an event that looks like this:: + + { + "fail_attempts": 2 + } + +Which will cause the lambda to mark that record as failure twice (comparing the "ApproximateReceiveCount" of the SQS +event triggering the lambda). The lambda returns a batchItemFailures list that contains every failed record. All +other records are sent to the DESTINATION_QUEUE_URL as successfully processed. + +The lambda understands two env variables: +* OVERWRITE_RESULT: a string (potentially a json document) that can be used to return custom responses to provoke errors +* DESTINATION_QUEUE_URL: the queue url to send the event and result to +""" + +import json +import os + +import boto3 + + +def handler(event, context): + sqs = create_external_boto_client("sqs") + + print("incoming event:") + print(json.dumps(event)) + + # this lambda expects inputs from an SQS event source mapping + if not event.get("Records"): + raise ValueError("no records passed to event") + + batch_item_failures_ids = [] + + for record in event["Records"]: + message = json.loads(record["body"]) + + if message.get("fail_attempts") is None: + raise ValueError("no fail_attempts for the event given") + + if message["fail_attempts"] >= int(record["attributes"]["ApproximateReceiveCount"]): + batch_item_failures_ids.append(record["messageId"]) + + result = { + "batchItemFailures": [ + {"itemIdentifier": message_id} for message_id in batch_item_failures_ids + ] + } + + if os.environ.get("OVERWRITE_RESULT") is not None: + # try to parse the overwrite result as json + result = os.environ.get("OVERWRITE_RESULT") + try: + result = json.loads(result) + except Exception: + pass + + destination_queue_url = os.environ.get("DESTINATION_QUEUE_URL") + if destination_queue_url: + sqs.send_message( + QueueUrl=destination_queue_url, + MessageBody=json.dumps({"event": event, "result": result}), + ) + + return result + + +def create_external_boto_client(service): + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + # fix for local lambda executor + region_name = ( + os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") or "us-east-1" + ) + return boto3.client(service, endpoint_url=endpoint_url, region_name=region_name) diff --git a/tests/aws/services/lambda_/functions/lambda_sqs_integration.py b/tests/aws/services/lambda_/functions/lambda_sqs_integration.py new file mode 100644 index 0000000000000..a2522a5249ce0 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_sqs_integration.py @@ -0,0 +1,54 @@ +"""This lambda is used for lambda/sqs integration tests. Since SQS event source mappings don't allow +DestinationConfigurations that send lambda results to other source (like SQS queues), that can be used to verify +invocations, this lambda does this manually. You can pass in an event that looks like this:: + + { + "destination": "", + "fail_attempts": 2 + } + +Which will cause the lambda to fail twice (comparing the "ApproximateReceiveCount" of the SQS event triggering +the lambda), and send either an error or success result to the SQS queue passed in the destination key. +""" + +import json +import os + +import boto3 + + +def handler(event, context): + # this lambda expects inputs from an SQS event source mapping + if len(event.get("Records", [])) != 1: + raise ValueError("the payload must consist of exactly one record") + + # it expects exactly one record where the message body is '{"destination": ""}' that mimics a + # DestinationConfig (which is not possible with SQS event source mappings). + record = event["Records"][0] + message = json.loads(record["body"]) + + if not message.get("destination"): + raise ValueError("no destination for the event given") + + error = None + try: + if message["fail_attempts"] >= int(record["attributes"]["ApproximateReceiveCount"]): + raise ValueError("failed attempt") + except Exception as e: + error = e + raise + finally: + # we then send a message to the destination queue + result = {"error": None if not error else str(error), "event": event} + sqs = create_external_boto_client("sqs") + sqs.send_message(QueueUrl=message.get("destination"), MessageBody=json.dumps(result)) + + +def create_external_boto_client(service): + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + region_name = ( + os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") or "us-east-1" + ) + return boto3.client(service, endpoint_url=endpoint_url, region_name=region_name) diff --git a/tests/aws/services/lambda_/functions/lambda_start_execution.py b/tests/aws/services/lambda_/functions/lambda_start_execution.py new file mode 100644 index 0000000000000..11ce686fb0bc5 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_start_execution.py @@ -0,0 +1,22 @@ +import json +import os + +import boto3 + +# TODO - merge this file with lambda_send_message.py, to avoid duplication + + +def handler(event, context): + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + sf = boto3.client( + "stepfunctions", + endpoint_url=endpoint_url, + region_name=event["region_name"], + verify=False, + ) + + sf.start_execution(stateMachineArn=event["state_machine_arn"], input=json.dumps(event["input"])) + + return 0 diff --git a/tests/aws/services/lambda_/functions/lambda_timeout.py b/tests/aws/services/lambda_/functions/lambda_timeout.py new file mode 100644 index 0000000000000..a89aa32ee5987 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_timeout.py @@ -0,0 +1,12 @@ +import logging +import time + + +def handler(event, context): + try: + print("starting wait") + time.sleep(event["wait"]) + print("done waiting") + except Exception as e: + print("exception while waiting") + logging.error(e) diff --git a/tests/aws/services/lambda_/functions/lambda_timeout_env.py b/tests/aws/services/lambda_/functions/lambda_timeout_env.py new file mode 100644 index 0000000000000..5542d5121f61d --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_timeout_env.py @@ -0,0 +1,30 @@ +import logging +import time + +INTERNAL_NUMBER = 0 + + +def handler(event, context): + if write_content := event.get("write-file"): + with open("/tmp/temp-store", mode="wt") as f: + f.write(write_content) + elif event.get("read-file"): + with open("/tmp/temp-store", mode="rt") as f: + payload = {"content": f.read(write_content)} + print(payload) + return payload + elif new_num := event.get("set-number"): + global INTERNAL_NUMBER + INTERNAL_NUMBER = new_num + elif event.get("read-number"): + payload = {"number": INTERNAL_NUMBER} + print(payload) + return payload + elif sleep_time := event.get("sleep"): + try: + print("starting wait") + time.sleep(sleep_time) + print("done waiting") + except Exception as e: + print("exception while waiting") + logging.error(e) diff --git a/tests/aws/services/lambda_/functions/lambda_triggered_by_s3.py b/tests/aws/services/lambda_/functions/lambda_triggered_by_s3.py new file mode 100644 index 0000000000000..f73106794e40e --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_triggered_by_s3.py @@ -0,0 +1,22 @@ +import os +import uuid + +import boto3 + +# TODO - merge this file with lambda_send_message.py, to avoid duplication + + +def handler(event, context): + # Parse s3 event + r = event["Records"][0] + + region = r["awsRegion"] + s3_metadata = r["s3"]["object"] + table_name = s3_metadata["key"] + + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + + ddb = boto3.resource("dynamodb", endpoint_url=endpoint_url, region_name=region, verify=False) + ddb.Table(table_name).put_item(Item={"uuid": str(uuid.uuid4())[0:8], "data": r}) diff --git a/tests/aws/services/lambda_/functions/lambda_triggered_by_sqs_download_s3_file.py b/tests/aws/services/lambda_/functions/lambda_triggered_by_sqs_download_s3_file.py new file mode 100644 index 0000000000000..bd641c2b30e1b --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_triggered_by_sqs_download_s3_file.py @@ -0,0 +1,17 @@ +import os + +import boto3 + + +def handler(event, context): + endpoint_url = None + if os.environ.get("AWS_ENDPOINT_URL"): + endpoint_url = os.environ["AWS_ENDPOINT_URL"] + s3 = boto3.client("s3", endpoint_url=endpoint_url, verify=False) + s3.download_file( + os.environ["BUCKET_NAME"], + os.environ["OBJECT_NAME"], + os.environ["LOCAL_FILE_NAME"], + ) + print("success") + return diff --git a/tests/aws/services/lambda_/functions/lambda_ulimits.py b/tests/aws/services/lambda_/functions/lambda_ulimits.py new file mode 100644 index 0000000000000..25d062449c545 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_ulimits.py @@ -0,0 +1,18 @@ +import resource + + +def handler(event, context): + # https://docs.python.org/3/library/resource.html + ulimit_names = { + "RLIMIT_AS": resource.RLIMIT_AS, + "RLIMIT_CORE": resource.RLIMIT_CORE, + "RLIMIT_CPU": resource.RLIMIT_CPU, + "RLIMIT_DATA": resource.RLIMIT_DATA, + "RLIMIT_FSIZE": resource.RLIMIT_FSIZE, + "RLIMIT_MEMLOCK": resource.RLIMIT_MEMLOCK, + "RLIMIT_NOFILE": resource.RLIMIT_NOFILE, + "RLIMIT_NPROC": resource.RLIMIT_NPROC, + "RLIMIT_RSS": resource.RLIMIT_RSS, + "RLIMIT_STACK": resource.RLIMIT_STACK, + } + return {label: resource.getrlimit(res) for label, res in ulimit_names.items()} diff --git a/tests/aws/services/lambda_/functions/lambda_unhandled_error.py b/tests/aws/services/lambda_/functions/lambda_unhandled_error.py new file mode 100644 index 0000000000000..2a944bc4053f3 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_unhandled_error.py @@ -0,0 +1,6 @@ +class CustomException(Exception): + pass + + +def handler(event, context): + raise CustomException("some error occurred") diff --git a/tests/aws/services/lambda_/functions/lambda_url.js b/tests/aws/services/lambda_/functions/lambda_url.js new file mode 100644 index 0000000000000..88a89afdd9101 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_url.js @@ -0,0 +1,6 @@ +exports.handler = async (event, context) => { + return { + statusCode: 200, + body: JSON.stringify({event,context}) + } +} diff --git a/tests/aws/services/lambda_/functions/lambda_version.py b/tests/aws/services/lambda_/functions/lambda_version.py new file mode 100644 index 0000000000000..428b88faa0009 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_version.py @@ -0,0 +1,8 @@ +def handler(event, context): + result = { + "version_id": "%s", + "invoked_arn": context.invoked_function_arn, + "version_from_ctx": context.function_version, + } + print(result) + return result diff --git a/tests/aws/services/lambda_/functions/provided_bootstrap_empty b/tests/aws/services/lambda_/functions/provided_bootstrap_empty new file mode 100755 index 0000000000000..af6f603fa8bfc --- /dev/null +++ b/tests/aws/services/lambda_/functions/provided_bootstrap_empty @@ -0,0 +1,16 @@ +#!/bin/sh + +set -euo pipefail + +# Processing +while true +do + HEADERS="$(mktemp)" + # Get an event. The HTTP request will block until one is received + EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next") + + REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2) + + # Send an empty response (using stdin to circumvent max input length) + curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" +done diff --git a/tests/aws/services/lambda_/functions/python3/Makefile b/tests/aws/services/lambda_/functions/python3/Makefile new file mode 100644 index 0000000000000..7abb66216a565 --- /dev/null +++ b/tests/aws/services/lambda_/functions/python3/Makefile @@ -0,0 +1,3 @@ +build: + cd lambda1; zip -r lambda1.zip __init__.py handler1.py settings.py + cd lambda2; zip -r lambda2.zip __init__.py handler2.py settings.py diff --git a/tests/aws/services/lambda_/functions/python3/README.md b/tests/aws/services/lambda_/functions/python3/README.md new file mode 100644 index 0000000000000..c0a323bd2ac23 --- /dev/null +++ b/tests/aws/services/lambda_/functions/python3/README.md @@ -0,0 +1,2 @@ +`lambda(1|2).zip` was generated using `make` and committed to the repo. +Generation of the .jar _could_ be added to the buildp rocess, but this seems adequate. diff --git a/tests/aws/services/lambda_/functions/python3/__init__.py b/tests/aws/services/lambda_/functions/python3/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/python3/lambda1/__init__.py b/tests/aws/services/lambda_/functions/python3/lambda1/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/python3/lambda1/handler1.py b/tests/aws/services/lambda_/functions/python3/lambda1/handler1.py new file mode 100644 index 0000000000000..340e527984ffb --- /dev/null +++ b/tests/aws/services/lambda_/functions/python3/lambda1/handler1.py @@ -0,0 +1,7 @@ +import settings + +constant = settings.SETTING1 + + +def handler(event, context): + return constant diff --git a/tests/aws/services/lambda_/functions/python3/lambda1/lambda1.zip b/tests/aws/services/lambda_/functions/python3/lambda1/lambda1.zip new file mode 100644 index 0000000000000..8ecacb36b40ee Binary files /dev/null and b/tests/aws/services/lambda_/functions/python3/lambda1/lambda1.zip differ diff --git a/tests/aws/services/lambda_/functions/python3/lambda1/settings.py b/tests/aws/services/lambda_/functions/python3/lambda1/settings.py new file mode 100644 index 0000000000000..a18c018c17929 --- /dev/null +++ b/tests/aws/services/lambda_/functions/python3/lambda1/settings.py @@ -0,0 +1 @@ +SETTING1 = "setting1" diff --git a/tests/aws/services/lambda_/functions/python3/lambda2/__init__.py b/tests/aws/services/lambda_/functions/python3/lambda2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/lambda_/functions/python3/lambda2/handler2.py b/tests/aws/services/lambda_/functions/python3/lambda2/handler2.py new file mode 100644 index 0000000000000..c6d6f06dfcb0e --- /dev/null +++ b/tests/aws/services/lambda_/functions/python3/lambda2/handler2.py @@ -0,0 +1,7 @@ +import settings + +constant = settings.SETTING2 + + +def handler(event, context): + return constant diff --git a/tests/aws/services/lambda_/functions/python3/lambda2/lambda2.zip b/tests/aws/services/lambda_/functions/python3/lambda2/lambda2.zip new file mode 100644 index 0000000000000..4445378ddc1ea Binary files /dev/null and b/tests/aws/services/lambda_/functions/python3/lambda2/lambda2.zip differ diff --git a/tests/aws/services/lambda_/functions/python3/lambda2/settings.py b/tests/aws/services/lambda_/functions/python3/lambda2/settings.py new file mode 100644 index 0000000000000..ea8392ac4ff7f --- /dev/null +++ b/tests/aws/services/lambda_/functions/python3/lambda2/settings.py @@ -0,0 +1 @@ +SETTING2 = "setting2" diff --git a/tests/aws/services/lambda_/functions/rust-lambda/.gitignore b/tests/aws/services/lambda_/functions/rust-lambda/.gitignore new file mode 100644 index 0000000000000..2f7896d1d1365 --- /dev/null +++ b/tests/aws/services/lambda_/functions/rust-lambda/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/aws/services/lambda_/functions/rust-lambda/Cargo.lock b/tests/aws/services/lambda_/functions/rust-lambda/Cargo.lock new file mode 100644 index 0000000000000..bc901a881bea1 --- /dev/null +++ b/tests/aws/services/lambda_/functions/rust-lambda/Cargo.lock @@ -0,0 +1,1014 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "async-stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "aws_lambda_events" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee63f075c09f34e9b79834669e60488584b256b958ad647b802c36462e84b426" +dependencies = [ + "base64", + "bytes", + "chrono", + "http", + "http-body", + "http-serde", + "query_map", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "lambda_http", + "serde_json", + "tokio", +] + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "serde", + "time", + "winapi", +] + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aaa7bd5fb665c6864b5f963dd9097905c54125909c7aa94c9e18507cdbe6c53" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +dependencies = [ + "cfg-if", + "lazy_static", +] + +[[package]] +name = "flate2" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af" +dependencies = [ + "cfg-if", + "crc32fast", + "libc", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" + +[[package]] +name = "futures-sink" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" + +[[package]] +name = "futures-task" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" + +[[package]] +name = "futures-util" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.10.2+wasi-snapshot-preview1", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "hdrhistogram" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31672b7011be2c4f7456c4ddbcb40e7e9a4a9fad8efe49a6ebaf5f307d0109c0" +dependencies = [ + "base64", + "byteorder", + "crossbeam-channel", + "flate2", + "nom", + "num-traits", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff8670570af52249509a86f5e3e18a08c60b177071826898fde8997cf5f6bfbb" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d98b3d9662de70952b14c4840ee0f37e23973542a363e2275f4b9d024ff6cca" +dependencies = [ + "http", + "serde", +] + +[[package]] +name = "httparse" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b26ae0a80afebe130861d90abf98e3814a4f28a4c6ffeb5ab8ebb2be311e0ef2" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "indexmap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + +[[package]] +name = "lambda_http" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ef7e546b1219e6b48c3c825a78894a20412e9d598260ea30044a9d3bc5846b" +dependencies = [ + "aws_lambda_events", + "base64", + "bytes", + "http", + "http-body", + "lambda_runtime", + "query_map", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "lambda_runtime" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c73464e59463e91bf179195a308738477df90240173649d5dd2a1f3a2e4a2d2" +dependencies = [ + "async-stream", + "bytes", + "http", + "hyper", + "lambda_runtime_api_client", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", +] + +[[package]] +name = "lambda_runtime_api_client" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e921024b5eb4e2f0800a5d6e25c7ed554562aa62f02cf5f60a48c26c8a678974" +dependencies = [ + "http", + "hyper", + "tokio", + "tower-service", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "lock_api" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "parking_lot" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.36.1", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro2" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec757218438d5fda206afc041538b2f6d889286160d649a86a24d37e1235afd1" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "query_map" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9b3184a42d995e58fefd23c04d277a2b4d51efb5df7ad4efbe0a43d77b5923" +dependencies = [ + "form_urlencoded", + "serde", + "serde_derive", +] + +[[package]] +name = "quote" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "serde" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.137" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" + +[[package]] +name = "smallvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "socket2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "syn" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff7c592601f11445996a06f8ad0c27f094a58857c2f89e97974ab9235b92c52" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "tokio" +version = "1.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050c618355082ae5a89ec63bbf897225d5ffe84c7c4e036874e4d185a5044e" +dependencies = [ + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50145484efff8818b5ccd256697f36863f587da82cf8b409c53adf1e840798e3" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edfdeb067411dba2044da6d1cb2df793dd35add7888d73c16e3381ded401764" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a89fd63ad6adf737582df5db40d286574513c69a11dac5214dc3b5603d6713e" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62" + +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + +[[package]] +name = "tracing" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6b8ad3567499f98a1db7a752b07a7c8c7c7c34c332ec00effb2b0027974b7c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "unicode-xid" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/tests/aws/services/lambda_/functions/rust-lambda/Cargo.toml b/tests/aws/services/lambda_/functions/rust-lambda/Cargo.toml new file mode 100644 index 0000000000000..b5c98f083695b --- /dev/null +++ b/tests/aws/services/lambda_/functions/rust-lambda/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +lambda_http = "0.5" +serde_json = "1.0" +tokio = { version = "1.18", features = ["full"] } diff --git a/tests/aws/services/lambda_/functions/rust-lambda/Makefile b/tests/aws/services/lambda_/functions/rust-lambda/Makefile new file mode 100644 index 0000000000000..fd2ff219a2aed --- /dev/null +++ b/tests/aws/services/lambda_/functions/rust-lambda/Makefile @@ -0,0 +1,5 @@ +build: + cargo build --release --target x86_64-unknown-linux-musl + cp ./target/x86_64-unknown-linux-musl/release/bootstrap . + zip function.zip bootstrap + rm bootstrap diff --git a/tests/aws/services/lambda_/functions/rust-lambda/function.zip b/tests/aws/services/lambda_/functions/rust-lambda/function.zip new file mode 100644 index 0000000000000..507fda8d6b7a1 Binary files /dev/null and b/tests/aws/services/lambda_/functions/rust-lambda/function.zip differ diff --git a/tests/aws/services/lambda_/functions/rust-lambda/src/main.rs b/tests/aws/services/lambda_/functions/rust-lambda/src/main.rs new file mode 100644 index 0000000000000..5b679196ba2c7 --- /dev/null +++ b/tests/aws/services/lambda_/functions/rust-lambda/src/main.rs @@ -0,0 +1,17 @@ +use lambda_http::{service_fn, Error, IntoResponse, Request, RequestExt, Response}; + +#[tokio::main] +async fn main() -> Result<(), Error> { + lambda_http::run(service_fn(func)).await?; + Ok(()) +} + +async fn func(event: Request) -> Result { + Ok(match event.query_string_parameters().first("first_name") { + Some(first_name) => format!("Hello, {}!", first_name).into_response(), + _ => Response::builder() + .status(400) + .body("Empty first name".into()) + .expect("failed to render response"), + }) +} diff --git a/tests/aws/services/lambda_/functions/xray_tracing_traceid.py b/tests/aws/services/lambda_/functions/xray_tracing_traceid.py new file mode 100644 index 0000000000000..224a0d4ca32b3 --- /dev/null +++ b/tests/aws/services/lambda_/functions/xray_tracing_traceid.py @@ -0,0 +1,13 @@ +import json +import os + +trace_id_outside_handler = str(os.environ.get("_X_AMZN_TRACE_ID")) + + +def handler(event, context): + response = { + "trace_id_outside_handler": trace_id_outside_handler, + "trace_id_inside_handler": str(os.environ.get("_X_AMZN_TRACE_ID")), + } + print(json.dumps(response)) + return response diff --git a/tests/aws/services/lambda_/layers/testlayer.zip b/tests/aws/services/lambda_/layers/testlayer.zip new file mode 100644 index 0000000000000..c05c21e49952b Binary files /dev/null and b/tests/aws/services/lambda_/layers/testlayer.zip differ diff --git a/tests/aws/services/lambda_/test_lambda.py b/tests/aws/services/lambda_/test_lambda.py new file mode 100644 index 0000000000000..08d8d77f0e4f2 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda.py @@ -0,0 +1,3543 @@ +"""Tests for Lambda behavior and implicit functionality. +Everything related to API operations goes into test_lambda_api.py instead.""" + +import base64 +import json +import logging +import os +import random +import re +import string +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO +from typing import Dict, TypeVar + +import pytest +import requests +from botocore.config import Config +from botocore.response import StreamingBody +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer + +from localstack import config +from localstack.aws.api.lambda_ import Architecture, InvocationType, InvokeMode, Runtime +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.services.lambda_.provider import TAG_KEY_CUSTOM_URL +from localstack.services.lambda_.runtimes import RUNTIMES_AGGREGATED +from localstack.testing.aws.lambda_utils import ( + concurrency_update_done, + get_invoke_init_type, + update_done, +) +from localstack.testing.aws.util import ( + create_client_with_keys, + is_aws_cloud, +) +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import PATTERN_UUID +from localstack.utils import files, platform, testutil +from localstack.utils.aws import arns +from localstack.utils.aws.arns import get_partition, lambda_function_name +from localstack.utils.files import load_file +from localstack.utils.http import safe_requests +from localstack.utils.platform import Arch, standardized_arch +from localstack.utils.strings import short_uid, to_bytes, to_str +from localstack.utils.sync import retry, wait_until +from localstack.utils.testutil import create_lambda_archive +from tests.aws.services.lambda_.utils import get_s3_keys + +LOG = logging.getLogger(__name__) + +# TODO: find a better way to manage these handler files +THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +TEST_LAMBDA_PYTHON = os.path.join(THIS_FOLDER, "functions/lambda_integration.py") +TEST_LAMBDA_PYTHON_ECHO = os.path.join(THIS_FOLDER, "functions/lambda_echo.py") +TEST_LAMBDA_PYTHON_ECHO_JSON_BODY = os.path.join(THIS_FOLDER, "functions/lambda_echo_json_body.py") +TEST_LAMBDA_PYTHON_ECHO_STATUS_CODE = os.path.join( + THIS_FOLDER, "functions/lambda_echo_status_code.py" +) +TEST_LAMBDA_PYTHON_REQUEST_ID = os.path.join(THIS_FOLDER, "functions/lambda_request_id.py") +TEST_LAMBDA_PYTHON_ECHO_VERSION_ENV = os.path.join( + THIS_FOLDER, "functions/lambda_echo_version_env.py" +) +TEST_LAMBDA_PYTHON_ROLE = os.path.join(THIS_FOLDER, "functions/lambda_role.py") +TEST_LAMBDA_MAPPING_RESPONSES = os.path.join(THIS_FOLDER, "functions/lambda_mapping_responses.py") +TEST_LAMBDA_PYTHON_SELECT_PATTERN = os.path.join(THIS_FOLDER, "functions/lambda_select_pattern.py") +TEST_LAMBDA_PYTHON_ECHO_ZIP = os.path.join(THIS_FOLDER, "functions/echo.zip") +TEST_LAMBDA_PYTHON_VERSION = os.path.join(THIS_FOLDER, "functions/lambda_python_version.py") +TEST_LAMBDA_PYTHON_UNHANDLED_ERROR = os.path.join( + THIS_FOLDER, "functions/lambda_unhandled_error.py" +) +TEST_LAMBDA_PYTHON_RUNTIME_ERROR = os.path.join(THIS_FOLDER, "functions/lambda_runtime_error.py") +TEST_LAMBDA_PYTHON_RUNTIME_EXIT = os.path.join(THIS_FOLDER, "functions/lambda_runtime_exit.py") +TEST_LAMBDA_PYTHON_RUNTIME_EXIT_SEGFAULT = os.path.join( + THIS_FOLDER, "functions/lambda_runtime_exit_segfault.py" +) +TEST_LAMBDA_PYTHON_HANDLER_ERROR = os.path.join(THIS_FOLDER, "functions/lambda_handler_error.py") +TEST_LAMBDA_PYTHON_HANDLER_EXIT = os.path.join(THIS_FOLDER, "functions/lambda_handler_exit.py") +TEST_LAMBDA_PYTHON_NONE = os.path.join(THIS_FOLDER, "functions/lambda_none.py") +TEST_LAMBDA_AWS_PROXY = os.path.join(THIS_FOLDER, "functions/lambda_aws_proxy.py") +TEST_LAMBDA_AWS_PROXY_FORMAT = os.path.join(THIS_FOLDER, "functions/lambda_aws_proxy_format.py") +TEST_LAMBDA_PYTHON_S3_INTEGRATION = os.path.join(THIS_FOLDER, "functions/lambda_s3_integration.py") +TEST_LAMBDA_PYTHON_S3_INTEGRATION_FUNCTION_VERSION = os.path.join( + THIS_FOLDER, "functions/lambda_s3_integration_function_version.py" +) +TEST_LAMBDA_INTEGRATION_NODEJS = os.path.join(THIS_FOLDER, "functions/lambda_integration.js") +TEST_LAMBDA_NODEJS = os.path.join(THIS_FOLDER, "functions/lambda_handler.js") +TEST_LAMBDA_NODEJS_NONE = os.path.join(THIS_FOLDER, "functions/lambda_none.js") +TEST_LAMBDA_NODEJS_ES6 = os.path.join(THIS_FOLDER, "functions/lambda_handler_es6.mjs") +TEST_LAMBDA_NODEJS_ECHO = os.path.join(THIS_FOLDER, "functions/lambda_echo.js") +TEST_LAMBDA_NODEJS_APIGW_INTEGRATION = os.path.join(THIS_FOLDER, "functions/apigw_integration.js") +TEST_LAMBDA_HTTP_RUST = os.path.join(THIS_FOLDER, "functions/rust-lambda/function.zip") +TEST_LAMBDA_JAVA_WITH_LIB = os.path.join( + THIS_FOLDER, "functions/java/lambda_echo/lambda-function-with-lib-0.0.1.jar" +) +TEST_LAMBDA_JAVA_MULTIPLE_HANDLERS = os.path.join( + THIS_FOLDER, + "functions", + "java", + "lambda_multiple_handlers", + "build", + "distributions", + "lambda-function-with-multiple-handlers.zip", +) +TEST_LAMBDA_ENV = os.path.join(THIS_FOLDER, "functions/lambda_environment.py") + +TEST_LAMBDA_EVENT_SOURCE_MAPPING_SEND_MESSAGE = os.path.join( + THIS_FOLDER, "functions/lambda_event_source_mapping_send_message.py" +) +TEST_LAMBDA_SEND_MESSAGE_FILE = os.path.join(THIS_FOLDER, "functions/lambda_send_message.py") +TEST_LAMBDA_PUT_ITEM_FILE = os.path.join(THIS_FOLDER, "functions/lambda_put_item.py") +TEST_LAMBDA_START_EXECUTION_FILE = os.path.join(THIS_FOLDER, "functions/lambda_start_execution.py") +TEST_LAMBDA_URL = os.path.join(THIS_FOLDER, "functions/lambda_url.js") +TEST_LAMBDA_CACHE_NODEJS = os.path.join(THIS_FOLDER, "functions/lambda_cache.js") +TEST_LAMBDA_CACHE_PYTHON = os.path.join(THIS_FOLDER, "functions/lambda_cache.py") +TEST_LAMBDA_TIMEOUT_PYTHON = os.path.join(THIS_FOLDER, "functions/lambda_timeout.py") +TEST_LAMBDA_TIMEOUT_ENV_PYTHON = os.path.join(THIS_FOLDER, "functions/lambda_timeout_env.py") +TEST_LAMBDA_SLEEP_ENVIRONMENT = os.path.join(THIS_FOLDER, "functions/lambda_sleep_environment.py") +TEST_LAMBDA_INTROSPECT_PYTHON = os.path.join(THIS_FOLDER, "functions/lambda_introspect.py") +TEST_LAMBDA_ULIMITS = os.path.join(THIS_FOLDER, "functions/lambda_ulimits.py") +TEST_LAMBDA_INVOCATION_TYPE = os.path.join(THIS_FOLDER, "functions/lambda_invocation_type.py") +TEST_LAMBDA_VERSION = os.path.join(THIS_FOLDER, "functions/lambda_version.py") +TEST_LAMBDA_CONTEXT_REQID = os.path.join(THIS_FOLDER, "functions/lambda_context.py") +TEST_LAMBDA_PROCESS_INSPECTION = os.path.join(THIS_FOLDER, "functions/lambda_process_inspection.py") +TEST_LAMBDA_CUSTOM_RESPONSE_SIZE = os.path.join(THIS_FOLDER, "functions/lambda_response_size.py") +TEST_LAMBDA_PYTHON_MULTIPLE_HANDLERS = os.path.join( + THIS_FOLDER, "functions/lambda_multiple_handlers.py" +) +TEST_LAMBDA_NOTIFIER = os.path.join(THIS_FOLDER, "functions/lambda_notifier.py") +TEST_LAMBDA_CLOUDWATCH_LOGS = os.path.join(THIS_FOLDER, "functions/lambda_cloudwatch_logs.py") +TEST_LAMBDA_XRAY_TRACEID = os.path.join(THIS_FOLDER, "functions/xray_tracing_traceid.py") +TEST_LAMBDA_HOST_PREFIX_OPERATION = os.path.join(THIS_FOLDER, "functions/host_prefix_operation.py") + +PYTHON_TEST_RUNTIMES = RUNTIMES_AGGREGATED["python"] +NODE_TEST_RUNTIMES = RUNTIMES_AGGREGATED["nodejs"] +JAVA_TEST_RUNTIMES = RUNTIMES_AGGREGATED["java"] + +TEST_LAMBDA_LIBS = [ + "requests", + "psutil", + "urllib3", + "charset_normalizer", + "certifi", + "idna", + "pip", + "dns", +] + +T = TypeVar("T") + + +def read_streams(payload: T) -> T: + new_payload = {} + for k, v in payload.items(): + if isinstance(v, Dict): + new_payload[k] = read_streams(v) + elif isinstance(v, StreamingBody): + new_payload[k] = to_str(v.read()) + else: + new_payload[k] = v + return new_payload + + +def check_concurrency_quota(aws_client: ServiceLevelClientFactory, min_concurrent_executions: int): + account_settings = aws_client.lambda_.get_account_settings() + concurrent_executions = account_settings["AccountLimit"]["ConcurrentExecutions"] + if concurrent_executions < min_concurrent_executions: + pytest.skip( + "Account limit for Lambda ConcurrentExecutions is too low:" + f" ({concurrent_executions}/{min_concurrent_executions})." + " Request a quota increase on AWS: https://console.aws.amazon.com/servicequotas/home" + ) + else: + unreserved_concurrent_executions = account_settings["AccountLimit"][ + "UnreservedConcurrentExecutions" + ] + if unreserved_concurrent_executions < min_concurrent_executions: + LOG.warning( + "Insufficient UnreservedConcurrentExecutions available for this test. " + "Ensure that no other tests use any reserved or provisioned concurrency." + ) + + +@pytest.fixture(autouse=True) +def fixture_snapshot(snapshot): + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer( + snapshot.transform.key_value("CodeSha256", reference_replacement=False) + ) + + +class TestLambdaBaseFeatures: + @markers.snapshot.skip_snapshot_verify(paths=["$..LogResult"]) + @markers.aws.validated + def test_large_payloads(self, caplog, create_lambda_function, aws_client): + """Testing large payloads sent to lambda functions (~5MB)""" + # Set the loglevel to INFO for this test to avoid breaking a CI environment (due to excessive log outputs) + caplog.set_level(logging.INFO) + + function_name = f"large_payload-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + large_value = "test123456" * 100 * 1000 * 5 + payload = {"test": large_value} # 5MB payload + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=to_bytes(json.dumps(payload)) + ) + # do not use snapshots here - loading 5MB json takes ~14 sec + assert "FunctionError" not in result + assert payload == json.load(result["Payload"]) + + @markers.aws.validated + def test_lambda_large_response(self, caplog, create_lambda_function, aws_client): + # Set the loglevel to INFO for this test to avoid breaking a CI environment (due to excessive log outputs) + caplog.set_level(logging.INFO) + + function_name = f"large_response-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_CUSTOM_RESPONSE_SIZE, + func_name=function_name, + runtime=Runtime.python3_12, + ) + response_size = 6 * 1024 * 1024 # actually + 100 is the upper limit + payload = {"bytenum": response_size} # 6MB response size + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=to_bytes(json.dumps(payload)) + ) + assert "FunctionError" not in result + assert "a" * response_size == json.load(result["Payload"]) + + @markers.aws.validated + def test_lambda_too_large_response(self, create_lambda_function, aws_client, snapshot): + function_name = f"large_payload-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_CUSTOM_RESPONSE_SIZE, + func_name=function_name, + runtime=Runtime.python3_12, + ) + response_size = 7 * 1024 * 1024 # 7MB response size (i.e. over 6MB limit) + payload = {"bytenum": response_size} + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=to_bytes(json.dumps(payload)) + ) + snapshot.match("invoke_result", result) + + # second invoke to make sure we didn't break further invocations + result2 = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=to_bytes(json.dumps(payload)) + ) + snapshot.match("invoke_result_2", result2) + + # check that our manual log/print statement ends up in CW + def _check_print_in_logs(): + log_events = ( + aws_client.logs.get_paginator("filter_log_events") + .paginate(logGroupName=f"/aws/lambda/{function_name}") + .build_full_result() + ) + assert any("generating bytes" in e["message"] for e in log_events["events"]) + + retry(_check_print_in_logs, retries=10) + + @markers.aws.only_localstack + def test_lambda_too_large_response_but_with_custom_limit( + self, caplog, create_lambda_function, aws_client, monkeypatch + ): + # Set the loglevel to INFO for this test to avoid breaking a CI environment (due to excessive log outputs) + caplog.set_level(logging.INFO) + monkeypatch.setattr( + config, "LAMBDA_LIMITS_MAX_FUNCTION_PAYLOAD_SIZE_BYTES", str(7 * 1024 * 1024 + 100) + ) + + function_name = f"large_payload-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_CUSTOM_RESPONSE_SIZE, + func_name=function_name, + runtime=Runtime.python3_12, + ) + response_size = 7 * 1024 * 1024 # 7MB response size (i.e. over 6MB limit) + payload = {"bytenum": response_size} + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=to_bytes(json.dumps(payload)) + ) + assert "a" * response_size == json.load(result["Payload"]) + + @markers.aws.validated + def test_function_state(self, lambda_su_role, snapshot, create_lambda_function_aws, aws_client): + """Tests if a lambda starts in state "Pending" but moves to "Active" at some point""" + + function_name = f"test-function-{short_uid()}" + zip_file = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + + # create_response is the original create call response, even though the fixture waits until it's not pending + create_response = create_lambda_function_aws( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Handler="handler.handler", + Role=lambda_su_role, + Code={"ZipFile": zip_file}, + ) + snapshot.match("create-fn-response", create_response) + + response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-fn-response", response) + + @pytest.mark.parametrize("function_name_length", [1, 2]) + @markers.aws.validated + def test_assume_role( + self, + create_lambda_function, + aws_client, + snapshot, + function_name_length, + account_id, + region_name, + ): + """Motivated by a GitHub issue where a single-character function name fails to start upon invocation + due to an invalid role ARN: https://github.com/localstack/localstack/issues/9016 + Notice that the assumed role depends on the length of the function name because single-character functions + are suffixed with "@lambda_function". Examples: + # 1: arn:aws:sts::111111111111:assumed-role/lambda-autogenerated-c33a16ee/u@lambda_function + # 2: arn:aws:sts::111111111111:assumed-role/lambda-autogenerated-8ca8c35a/zz + # 60: arn:aws:sts::111111111111:assumed-role/lambda-autogenerated-edc0e63c/nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn + # 64: arn:aws:sts::111111111111:assumed-role/lambda-autogenerated-ebed06d4/GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG + # 65: fails due to validation constraint + Unknown whether the 8-character hexadecimal number suffix after "lambda-autogenerated-" follows any pattern. + """ + lambda_autogen_role = "(?<=lambda-autogenerated-)([0-9a-f]{8})" + # avoid any other transformers possible registering reference replacements (especially resource transformer) + snapshot.transformers.clear() + snapshot.add_transformer( + [ + snapshot.transform.regex(account_id, "1" * 12), + snapshot.transform.regex(f"arn:{get_partition(region_name)}:", "arn::"), + snapshot.transform.regex(lambda_autogen_role, ""), + snapshot.transform.regex(r'(?<=/)[a-zA-Z]{1,2}(?="|@)', ""), + ] + ) + + # Generate single-character name (matching [a-z]/i) + random_letter = random.choice(string.ascii_letters) + function_name = str(random_letter * function_name_length) + + create_result = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ROLE, + func_name=function_name, + runtime=Runtime.python3_12, + ) + # Example: arn:aws:iam::111111111111:role/lambda-autogenerated-d5da4d52 + create_role_resource = arns.extract_resource_from_arn( + create_result["CreateFunctionResponse"]["Role"] + ) + + invoke_result = aws_client.lambda_.invoke(FunctionName=function_name) + payload = json.load(invoke_result["Payload"]) + snapshot.match("invoke-result-assumed-role-arn", payload["Arn"]) + + # Example: arn:aws:sts::111111111111:assumed-role/lambda-autogenerated-c33a16ee/f@lambda_function + # Example: arn:aws:sts::111111111111:assumed-role/lambda-autogenerated-c33a16ee/fn + assume_role_resource = arns.extract_resource_from_arn(payload["Arn"]) + assert create_role_resource.split("/")[1] == assume_role_resource.split("/")[1], ( + "role name upon create_function does not match the assumed role name upon Lambda invocation" + ) + + # The resource transformer masks the naming policy and does not support role prefixes. + # Therefore, we need test the special case of a one-character function name separately. + if function_name_length == 1: + assert assume_role_resource.split("/")[-1] == f"{function_name}@lambda_function" + else: + assert assume_role_resource.split("/")[-1] == function_name + + @markers.aws.validated + def test_lambda_different_iam_keys_environment( + self, lambda_su_role, create_lambda_function, snapshot, aws_client, region_name + ): + """ + In this test we want to check if multiple lambda environments (= instances of hot functions) have + different AWS access keys + """ + function_name = f"fn-{short_uid()}" + create_result = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_SLEEP_ENVIRONMENT, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + snapshot.match("create-result", create_result) + + # invoke two versions in two threads at the same time so environments won't be reused really quick + def _invoke_lambda(*args): + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=to_bytes(json.dumps({"sleep": 2})) + ) + return json.load(result["Payload"])["environment"] + + def _transform_to_key_dict(env: Dict[str, str]): + return { + "AccessKeyId": env["AWS_ACCESS_KEY_ID"], + "SecretAccessKey": env["AWS_SECRET_ACCESS_KEY"], + "SessionToken": env["AWS_SESSION_TOKEN"], + } + + def _assert_invocations(): + with ThreadPoolExecutor(2) as executor: + results = list(executor.map(_invoke_lambda, range(2))) + assert len(results) == 2 + assert ( + results[0]["AWS_LAMBDA_LOG_STREAM_NAME"] != results[1]["AWS_LAMBDA_LOG_STREAM_NAME"] + ), "Environments identical for both invocations" + # if we got different environments, those should differ as well + assert results[0]["AWS_ACCESS_KEY_ID"] != results[1]["AWS_ACCESS_KEY_ID"], ( + "Access Key IDs have to differ" + ) + assert results[0]["AWS_SECRET_ACCESS_KEY"] != results[1]["AWS_SECRET_ACCESS_KEY"], ( + "Secret Access keys have to differ" + ) + assert results[0]["AWS_SESSION_TOKEN"] != results[1]["AWS_SESSION_TOKEN"], ( + "Session tokens have to differ" + ) + # check if the access keys match the same role, and the role matches the one provided + # since a lot of asserts are based on the structure of the arns, snapshots are not too nice here, so manual + keys_1 = _transform_to_key_dict(results[0]) + keys_2 = _transform_to_key_dict(results[1]) + sts_client_1 = create_client_with_keys("sts", keys=keys_1, region_name=region_name) + sts_client_2 = create_client_with_keys("sts", keys=keys_2, region_name=region_name) + identity_1 = sts_client_1.get_caller_identity() + identity_2 = sts_client_2.get_caller_identity() + assert identity_1["Arn"] == identity_2["Arn"] + role_part = ( + identity_1["Arn"] + .replace("sts", "iam") + .replace("assumed-role", "role") + .rpartition("/") + ) + assert lambda_su_role == role_part[0] + assert function_name == role_part[2] + assert identity_1["Account"] == identity_2["Account"] + assert identity_1["UserId"] == identity_2["UserId"] + assert function_name == identity_1["UserId"].partition(":")[2] + + retry(_assert_invocations) + + +class TestLambdaBehavior: + @markers.snapshot.skip_snapshot_verify( + paths=[ + # requires creating a new user `slicer` and chown /var/task + "$..Payload.paths._var_task_gid", + "$..Payload.paths._var_task_owner", + "$..Payload.paths._var_task_uid", + ], + ) + @markers.aws.validated + @markers.only_on_amd64 + def test_runtime_introspection_x86(self, create_lambda_function, snapshot, aws_client): + func_name = f"test_lambda_x86_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INTROSPECT_PYTHON, + runtime=Runtime.python3_12, + timeout=9, + Architectures=[Architecture.x86_64], + ) + + invoke_result = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("invoke_runtime_x86_introspection", invoke_result) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # requires creating a new user `slicer` and chown /var/task + "$..Payload.paths._var_task_gid", + "$..Payload.paths._var_task_owner", + "$..Payload.paths._var_task_uid", + ], + ) + @markers.aws.validated + @markers.only_on_arm64 + def test_runtime_introspection_arm(self, create_lambda_function, snapshot, aws_client): + func_name = f"test_lambda_arm_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INTROSPECT_PYTHON, + runtime=Runtime.python3_12, + timeout=9, + Architectures=[Architecture.arm64], + ) + + invoke_result = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("invoke_runtime_arm_introspection", invoke_result) + + @markers.aws.validated + def test_runtime_ulimits(self, create_lambda_function, snapshot, monkeypatch, aws_client): + """We consider ulimits parity as opt-in because development environments could hit these limits unlike in + optimized production deployments.""" + monkeypatch.setattr( + config, + "LAMBDA_DOCKER_FLAGS", + "--ulimit nofile=1024:1024 --ulimit nproc=742:742 --ulimit core=-1:-1 --ulimit stack=8388608:-1 --ulimit memlock=65536:65536", + ) + + func_name = f"test_lambda_ulimits_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_ULIMITS, + runtime=Runtime.python3_12, + ) + + invoke_result = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("invoke_runtime_ulimits", invoke_result) + + @markers.aws.only_localstack + def test_ignore_architecture(self, create_lambda_function, monkeypatch, aws_client): + """Test configuration to ignore lambda architecture by creating a lambda with non-native architecture.""" + monkeypatch.setattr(config, "LAMBDA_IGNORE_ARCHITECTURE", True) + + # Assumes that LocalStack runs on native Docker host architecture + # This assumption could be violated when using remote Lambda executors + native_arch = platform.get_arch() + non_native_architecture = ( + Architecture.x86_64 if native_arch == Arch.arm64 else Architecture.arm64 + ) + func_name = f"test_lambda_arch_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INTROSPECT_PYTHON, + runtime=Runtime.python3_12, + Architectures=[non_native_architecture], + ) + + invoke_result = aws_client.lambda_.invoke(FunctionName=func_name) + payload = json.load(invoke_result["Payload"]) + lambda_arch = standardized_arch(payload.get("platform_machine")) + assert lambda_arch == native_arch + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..RuntimeVersionConfig.RuntimeVersionArn", + ] + ) + @markers.aws.validated + # Special case requiring both architectures + @markers.only_on_amd64 + @markers.only_on_arm64 + def test_mixed_architecture(self, create_lambda_function, aws_client, snapshot): + """Test emulation of a lambda function changing architectures. + Limitation: only works on hosts that support both ARM and AMD64 architectures. + """ + func_name = f"test_lambda_mixed_arch_{short_uid()}" + zip_file = create_lambda_archive(load_file(TEST_LAMBDA_INTROSPECT_PYTHON), get_content=True) + create_function_response = create_lambda_function( + func_name=func_name, + zip_file=zip_file, + runtime=Runtime.python3_12, + Architectures=[Architecture.x86_64], + ) + snapshot.match("create_function_response", create_function_response) + + invoke_result_x86 = aws_client.lambda_.invoke(FunctionName=func_name) + assert "FunctionError" not in invoke_result_x86 + payload = json.load(invoke_result_x86["Payload"]) + assert payload.get("platform_machine") == "x86_64" + + update_function_code_response = aws_client.lambda_.update_function_code( + FunctionName=func_name, ZipFile=zip_file, Architectures=[Architecture.arm64] + ) + snapshot.match("update_function_code_response", update_function_code_response) + aws_client.lambda_.get_waiter(waiter_name="function_updated_v2").wait( + FunctionName=func_name + ) + + invoke_result_arm = aws_client.lambda_.invoke(FunctionName=func_name) + assert "FunctionError" not in invoke_result_arm + payload_arm = json.load(invoke_result_arm["Payload"]) + assert payload_arm.get("platform_machine") == "aarch64" + + @pytest.mark.parametrize( + ["lambda_fn", "lambda_runtime"], + [ + (TEST_LAMBDA_CACHE_NODEJS, Runtime.nodejs20_x), + (TEST_LAMBDA_CACHE_PYTHON, Runtime.python3_12), + ], + ids=["nodejs", "python"], + ) + @markers.aws.validated + def test_lambda_cache_local( + self, create_lambda_function, lambda_fn, lambda_runtime, snapshot, aws_client + ): + """tests the local context reuse of packages in AWS lambda""" + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=lambda_fn, + runtime=lambda_runtime, + client=aws_client.lambda_, + ) + + first_invoke_result = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("first_invoke_result", first_invoke_result) + + second_invoke_result = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("second_invoke_result", second_invoke_result) + + @markers.aws.validated + def test_lambda_invoke_with_timeout(self, create_lambda_function, snapshot, aws_client): + # Snapshot generation could be flaky against AWS with a small timeout margin (e.g., 1.02 instead of 1.00) + regex = re.compile(r".*\s(?P[-a-z0-9]+) Task timed out after \d.\d+ seconds") + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: regex.search(v).group("uuid") if k == "errorMessage" else None, + "", + replace_reference=False, + ) + ) + + func_name = f"test_lambda_{short_uid()}" + create_result = create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_TIMEOUT_PYTHON, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + snapshot.match("create-result", create_result) + + result = aws_client.lambda_.invoke(FunctionName=func_name, Payload=json.dumps({"wait": 2})) + snapshot.match("invoke-result", result) + + log_group_name = f"/aws/lambda/{func_name}" + + def _log_stream_available(): + result = aws_client.logs.describe_log_streams(logGroupName=log_group_name)["logStreams"] + return len(result) > 0 + + wait_until(_log_stream_available, strategy="linear") + + ls_result = aws_client.logs.describe_log_streams(logGroupName=log_group_name) + log_stream_name = ls_result["logStreams"][0]["logStreamName"] + + def assert_events(): + log_events = aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name + )["events"] + + assert any("starting wait" in e["message"] for e in log_events) + # TODO: this part is a bit flaky, at least locally with old provider + assert not any("done waiting" in e["message"] for e in log_events) + + retry(assert_events, retries=15) + + @markers.aws.validated + def test_lambda_invoke_no_timeout(self, create_lambda_function, snapshot, aws_client): + func_name = f"test_lambda_{short_uid()}" + create_result = create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_TIMEOUT_PYTHON, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=2, + ) + snapshot.match("create-result", create_result) + + result = aws_client.lambda_.invoke(FunctionName=func_name, Payload=json.dumps({"wait": 1})) + snapshot.match("invoke-result", result) + log_group_name = f"/aws/lambda/{func_name}" + + def _log_stream_available(): + result = aws_client.logs.describe_log_streams(logGroupName=log_group_name)["logStreams"] + return len(result) > 0 + + wait_until(_log_stream_available, strategy="linear") + + ls_result = aws_client.logs.describe_log_streams(logGroupName=log_group_name) + log_stream_name = ls_result["logStreams"][0]["logStreamName"] + + def _assert_log_output(): + log_events = aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name + )["events"] + return any("starting wait" in e["message"] for e in log_events) and any( + "done waiting" in e["message"] for e in log_events + ) + + wait_until(_assert_log_output, strategy="linear") + + @pytest.mark.skip(reason="Currently flaky in CI") + @markers.aws.validated + def test_lambda_invoke_timed_out_environment_reuse( + self, create_lambda_function, snapshot, aws_client + ): + """Test checking if a timeout leads to a new environment with a new filesystem (and lost /tmp) or not""" + regex = re.compile(r".*\s(?P[-a-z0-9]+) Task timed out after \d.\d+ seconds") + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: regex.search(v).group("uuid") if k == "errorMessage" else None, + "", + replace_reference=False, + ) + ) + + func_name = f"test_lambda_{short_uid()}" + create_result = create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_TIMEOUT_ENV_PYTHON, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=1, + ) + snapshot.match("create-result", create_result) + file_content = "some-content" + set_number = 42 + + result = aws_client.lambda_.invoke( + FunctionName=func_name, Payload=json.dumps({"write-file": file_content}) + ) + snapshot.match("invoke-result-file-write", result) + result = aws_client.lambda_.invoke( + FunctionName=func_name, Payload=json.dumps({"read-file": True}) + ) + snapshot.match("invoke-result-file-read", result) + result = aws_client.lambda_.invoke( + FunctionName=func_name, Payload=json.dumps({"set-number": set_number}) + ) + snapshot.match("invoke-result-set-number", result) + result = aws_client.lambda_.invoke( + FunctionName=func_name, Payload=json.dumps({"read-number": True}) + ) + snapshot.match("invoke-result-read-number", result) + # file is written, let's let the function timeout and check if it is still there + + result = aws_client.lambda_.invoke(FunctionName=func_name, Payload=json.dumps({"sleep": 2})) + snapshot.match("invoke-result-timed-out", result) + log_group_name = f"/aws/lambda/{func_name}" + + def _log_stream_available(): + result = aws_client.logs.describe_log_streams(logGroupName=log_group_name)["logStreams"] + return len(result) > 0 + + wait_until(_log_stream_available, strategy="linear") + + ls_result = aws_client.logs.describe_log_streams(logGroupName=log_group_name) + log_stream_name = ls_result["logStreams"][0]["logStreamName"] + + def assert_events(): + log_events = aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name + )["events"] + + assert any("starting wait" in e["message"] for e in log_events) + # TODO: this part is a bit flaky, at least locally with old provider + assert not any("done waiting" in e["message"] for e in log_events) + + retry(assert_events, retries=15) + + # check if, for the next normal invocation, the file is still there: + result = aws_client.lambda_.invoke( + FunctionName=func_name, Payload=json.dumps({"read-file": True}) + ) + snapshot.match("invoke-result-file-read-after-timeout", result) + result = aws_client.lambda_.invoke( + FunctionName=func_name, Payload=json.dumps({"read-number": True}) + ) + snapshot.match("invoke-result-read-number-after-timeout", result) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # not set directly on init in lambda, but only on runtime processes + "$..Payload.environment.AWS_ACCESS_KEY_ID", + "$..Payload.environment.AWS_SECRET_ACCESS_KEY", + "$..Payload.environment.AWS_SESSION_TOKEN", + "$..Payload.environment.AWS_XRAY_DAEMON_ADDRESS", + # variables set by default in the image/docker + "$..Payload.environment.HOME", + "$..Payload.environment.HOSTNAME", + # LocalStack specific variables + "$..Payload.environment.AWS_ENDPOINT_URL", + "$..Payload.environment.AWS_LAMBDA_FUNCTION_TIMEOUT", + "$..Payload.environment.EDGE_PORT", + "$..Payload.environment.LOCALSTACK_FUNCTION_ACCOUNT_ID", + "$..Payload.environment.LOCALSTACK_HOSTNAME", + "$..Payload.environment.LOCALSTACK_INIT_LOG_LEVEL", + "$..Payload.environment.LOCALSTACK_RUNTIME_ENDPOINT", + "$..Payload.environment.LOCALSTACK_RUNTIME_ID", + "$..Payload.environment.LOCALSTACK_USER", + "$..Payload.environment.LOCALSTACK_POST_INVOKE_WAIT_MS", + "$..Payload.environment.LOCALSTACK_MAX_PAYLOAD_SIZE", + "$..Payload.environment.LOCALSTACK_CHMOD_PATHS", + # internal AWS lambda functionality + "$..Payload.environment._AWS_XRAY_DAEMON_ADDRESS", + "$..Payload.environment._LAMBDA_CONSOLE_SOCKET", + "$..Payload.environment._LAMBDA_CONTROL_SOCKET", + "$..Payload.environment._LAMBDA_DIRECT_INVOKE_SOCKET", + "$..Payload.environment._LAMBDA_LOG_FD", + "$..Payload.environment._LAMBDA_RUNTIME_LOAD_TIME", + "$..Payload.environment._LAMBDA_SB_ID", + "$..Payload.environment._LAMBDA_SHARED_MEM_FD", + "$..Payload.environment._LAMBDA_TELEMETRY_API_PASSPHRASE", + "$..Payload.environment._X_AMZN_TRACE_ID", + ] + ) + def test_lambda_init_environment( + self, aws_client, create_lambda_function, snapshot, monkeypatch + ): + if not is_aws_cloud(): + # needed to be able to read /proc/1/environ + monkeypatch.setattr(config, "LAMBDA_INIT_USER", "root") + func_name = f"test_lambda_{short_uid()}" + # The file descriptors might change, and might have to be added to the transformers at some point + snapshot.add_transformer( + [ + snapshot.transform.key_value( + "_LAMBDA_TELEMETRY_API_PASSPHRASE", "telemetry-passphrase" + ), + snapshot.transform.key_value("AWS_LAMBDA_LOG_STREAM_NAME", "log-stream-name"), + snapshot.transform.key_value("_X_AMZN_TRACE_ID", "xray-trace-id"), + snapshot.transform.key_value("_LAMBDA_RUNTIME_LOAD_TIME", "runtime-load-time"), + ] + ) + create_result = create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_PROCESS_INSPECTION, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + snapshot.match("create-result", create_result) + + result = aws_client.lambda_.invoke(FunctionName=func_name, Payload=json.dumps({"pid": 1})) + snapshot.match("lambda-init-inspection", result) + + @markers.aws.validated + @pytest.mark.skipif( + not config.use_custom_dns(), + reason="Host prefix cannot be resolved if DNS server is disabled", + ) + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: Fix hostPrefix operations failing by default within Lambda + # Idea: Support prefixed and non-prefixed operations by default and botocore should drop the prefix for + # non-supported hostnames such as IPv4 (e.g., `sync-192.168.65.254`) + "$..Payload.host_prefix.*", + ], + ) + def test_lambda_host_prefix_api_operation(self, create_lambda_function, aws_client, snapshot): + """Ensure that API operations with a hostPrefix are forwarded to the LocalStack instance. Examples: + * StartSyncExecution: https://docs.aws.amazon.com/step-functions/latest/apireference/API_StartSyncExecution.html + * DiscoverInstances: https://docs.aws.amazon.com/cloud-map/latest/api/API_DiscoverInstances.html + hostPrefix background test_host_prefix_no_subdomain + StepFunction example for the hostPrefix `sync-` based on test_start_sync_execution + """ + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_HOST_PREFIX_OPERATION, + runtime=Runtime.python3_12, + ) + invoke_result = aws_client.lambda_.invoke(FunctionName=func_name) + assert "FunctionError" not in invoke_result + snapshot.match("invoke-result", invoke_result) + + +URL_HANDLER_CODE = """ +def handler(event, ctx): + return <> +""" + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..event.headers.x-forwarded-proto", + "$..event.headers.x-forwarded-port", + "$..event.headers.x-amzn-trace-id", + ], +) +class TestLambdaURL: + # TODO: add more tests + + @pytest.mark.parametrize( + "returnvalue", + [ + '{"hello": "world"}', + '{"statusCode": 200, "body": "body123"}', + '{"statusCode": 200, "body": "{\\"hello\\": \\"world\\"}"}', + '["hello", 3, True]', + '"hello"', + "3", + "3.1", + "True", + ], + ids=[ + "dict", + "http-response", + "http-response-json", + "list-mixed", + "string", + "integer", + "float", + "boolean", + ], + ) + @markers.aws.validated + def test_lambda_url_invocation(self, create_lambda_function, snapshot, returnvalue, aws_client): + snapshot.add_transformer( + snapshot.transform.key_value( + "FunctionUrl", "", reference_replacement=False + ) + ) + + function_name = f"test-function-{short_uid()}" + + handler_file = files.new_tmp_file() + handler_code = URL_HANDLER_CODE.replace("<>", returnvalue) + files.save_file(handler_file, handler_code) + + create_lambda_function( + func_name=function_name, + handler_file=handler_file, + runtime=Runtime.python3_12, + ) + + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + + snapshot.match("create_lambda_url_config", url_config) + + permissions_response = aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + snapshot.match("add_permission", permissions_response) + + url = f"{url_config['FunctionUrl']}custom_path/extend?test_param=test_value" + result = safe_requests.post(url, data=b"{'key':'value'}") + snapshot.match( + "lambda_url_invocation", + { + "statuscode": result.status_code, + "headers": { + "Content-Type": result.headers["Content-Type"], + "Content-Length": result.headers["Content-Length"], + }, + "content": to_str(result.content), + }, + ) + + @markers.aws.validated + def test_lambda_url_invocation_custom_id(self, create_lambda_function, aws_client): + function_name = f"test-function-{short_uid()}" + custom_id = "my-custom-id" + function_return_value = '{"hello": "world"}' + + handler_file = files.new_tmp_file() + handler_code = URL_HANDLER_CODE.replace("<>", function_return_value) + files.save_file(handler_file, handler_code) + + create_lambda_function( + func_name=function_name, + handler_file=handler_file, + runtime=Runtime.python3_12, + Tags={TAG_KEY_CUSTOM_URL: custom_id}, + ) + + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + + url = f"{url_config['FunctionUrl']}custom_path/extend?test_param=test_value" + result = safe_requests.post(url, data=b"{'key':'value'}") + assert result.status_code == 200 + + @markers.aws.validated + def test_lambda_url_invocation_custom_id_aliased(self, create_lambda_function, aws_client): + function_name = f"test-function-{short_uid()}" + custom_id = "my-custom-id" + function_return_value = '{"hello": "world"}' + alias_name = "myalias" + + handler_file = files.new_tmp_file() + handler_code = URL_HANDLER_CODE.replace("<>", function_return_value) + files.save_file(handler_file, handler_code) + + create_lambda_function( + func_name=function_name, + handler_file=handler_file, + runtime=Runtime.python3_12, + Tags={TAG_KEY_CUSTOM_URL: custom_id}, + ) + + # create version & alias pointing to the version + aws_client.lambda_.create_alias( + FunctionName=function_name, FunctionVersion="$LATEST", Name=alias_name + ) + + aws_client.lambda_.get_waiter(waiter_name="function_active_v2").wait( + FunctionName=function_name, Qualifier=alias_name + ) + + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + Qualifier=alias_name, + ) + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + Qualifier=alias_name, + ) + + url = f"{url_config['FunctionUrl']}custom_path/extend?test_param=test_value" + if not is_aws_cloud(): + assert f"://{custom_id}-{alias_name}.lambda-url." in url + + result = safe_requests.post(url, data=b"{'key':'value'}") + assert result.status_code == 200 + + @pytest.mark.parametrize( + "invoke_mode", + [None, InvokeMode.BUFFERED, InvokeMode.RESPONSE_STREAM], + ) + @markers.aws.validated + def test_lambda_url_echo_invoke( + self, create_lambda_function, snapshot, aws_client, invoke_mode + ): + if invoke_mode == "RESPONSE_STREAM" and not is_aws_cloud(): + pytest.skip( + "'RESPONSE_STREAM should invoke the lambda using InvokeWithResponseStream, " + "but this is not implemented on LS yet. '" + ) + + snapshot.add_transformer( + snapshot.transform.key_value( + "FunctionUrl", "", reference_replacement=False + ) + ) + function_name = f"test-fnurl-echo-{short_uid()}" + + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_URL, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_url.handler", + ) + + if invoke_mode: + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, AuthType="NONE", InvokeMode=invoke_mode + ) + else: + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + snapshot.match("create_lambda_url_config", url_config) + + permissions_response = aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + snapshot.match("add_permission", permissions_response) + + url = f"{url_config['FunctionUrl']}custom_path/extend?test_param=test_value" + + # TODO: add more cases + result = safe_requests.post(url, data="text", headers={"Content-Type": "text/plain"}) + assert result.status_code == 200 + + if invoke_mode != "RESPONSE_STREAM": + event = json.loads(result.content)["event"] + assert event["body"] == "text" + assert event["isBase64Encoded"] is False + + result = safe_requests.post(url) + event = json.loads(result.content)["event"] + + else: + response_chunks = [] + for chunk in result.iter_content(chunk_size=1024): + if chunk: # Filter out keep-alive new chunks + response_chunks.append(chunk.decode("utf-8")) + + # Join the chunks to form the complete response string + complete_response = "".join(response_chunks) + + response_json = json.loads(complete_response) + event = json.loads(response_json["body"])["event"] + # TODO the chunk-event actually contains a key "body": "text" - not sure if we need more/other validation here + # but it's not implemented in LS anyhow yet + + assert "Body" not in event + assert event["isBase64Encoded"] is False + + @markers.aws.validated + def test_lambda_url_headers_and_status(self, create_lambda_function, aws_client): + function_name = f"test-fnurl-echo-{short_uid()}" + + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_PYTHON_ECHO_JSON_BODY, get_content=True), + runtime=Runtime.python3_12, + handler="lambda_echo_json_body.handler", + ) + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + + url = f"{url_config['FunctionUrl']}custom_path/extend?test_param=test_value" + + event = { + "statusCode": 201, + "headers": { + "Content-Type": "application/json", + "My-Custom-Header": "Custom Value", + }, + "body": json.dumps({"message": "hello-world"}), + "isBase64Encoded": False, + } + result = requests.post(url, json=event) + assert result.json() == {"message": "hello-world"} + assert result.status_code == 201 + assert "my-custom-header" in result.headers + assert result.headers["my-custom-header"] == "Custom Value" + + # try with string status code + event = { + "statusCode": "418", + "body": "i'm a teapot", + "isBase64Encoded": False, + } + result = requests.post(url, json=event) + assert result.text == "i'm a teapot" + assert result.status_code == 418 + + @markers.aws.validated + def test_lambda_update_function_url_config(self, create_lambda_function, snapshot, aws_client): + snapshot.add_transformer( + snapshot.transform.key_value( + "FunctionUrl", "", reference_replacement=False + ) + ) + function_name = f"test-fnurl-echo-{short_uid()}" + + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_URL, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_url.handler", + ) + + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, AuthType="NONE", InvokeMode=InvokeMode.BUFFERED + ) + snapshot.match("create_lambda_url_config", url_config) + + get_url_config = aws_client.lambda_.get_function_url_config(FunctionName=function_name) + snapshot.match("get_url_config", get_url_config) + + modify_config = aws_client.lambda_.update_function_url_config( + FunctionName=function_name, InvokeMode="RESPONSE_STREAM" + ) + snapshot.match("modify_lambda_url_config", modify_config) + + get_url_config = aws_client.lambda_.get_function_url_config(FunctionName=function_name) + snapshot.match("get_url_config_2", get_url_config) + + # test if this removes the invoke-mode from the function + modify_config = aws_client.lambda_.update_function_url_config( + FunctionName=function_name, + ) + snapshot.match("modify_lambda_url_config_none", modify_config) + + get_url_config = aws_client.lambda_.get_function_url_config(FunctionName=function_name) + snapshot.match("get_url_config_3", get_url_config) + + @markers.aws.validated + def test_lambda_url_invocation_exception(self, create_lambda_function, snapshot, aws_client): + # TODO: extend tests + snapshot.add_transformer( + snapshot.transform.key_value("FunctionUrl", reference_replacement=False) + ) + function_name = f"test-function-{short_uid()}" + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_UNHANDLED_ERROR, + runtime=Runtime.python3_12, + ) + get_fn_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_fn_result", get_fn_result) + + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + snapshot.match("create_lambda_url_config", url_config) + + permissions_response = aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + snapshot.match("add_permission", permissions_response) + + url = url_config["FunctionUrl"] + + result = safe_requests.post( + url, data=b"{}", headers={"User-Agent": "python-requests/testing"} + ) + assert to_str(result.content) == "Internal Server Error" + assert result.status_code == 502 + + @markers.aws.validated + def test_lambda_url_persists_after_alias_delete( + self, create_lambda_function, snapshot, aws_client + ): + snapshot.add_transformer( + snapshot.transform.key_value( + "FunctionUrl", "", reference_replacement=False + ) + ) + + function_name = f"test-fnurl-echo-{short_uid()}" + alias_name = f"alias-{short_uid()}" + + exists_waiter = aws_client.lambda_.get_waiter(waiter_name="function_exists") + active_waiter = aws_client.lambda_.get_waiter("function_active_v2") + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_URL, + runtime=Runtime.nodejs20_x, + ) + + # Wait for function to exist and then create publish version and create alias + exists_waiter.wait(FunctionName=function_name) + fn_version_v1 = aws_client.lambda_.publish_version(FunctionName=function_name).get( + "Version" + ) + aws_client.lambda_.create_alias( + FunctionName=function_name, FunctionVersion=fn_version_v1, Name=alias_name + ) + + # Create an aliased URL + exists_waiter.wait(FunctionName=function_name, Qualifier=alias_name) + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + InvokeMode=InvokeMode.BUFFERED, + Qualifier=alias_name, + ) + snapshot.match("create_lambda_url_config", url_config) + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + Qualifier=alias_name, + ) + + def _invoke_function() -> dict: + url = url_config["FunctionUrl"] + response = safe_requests.post(url, data="text", headers={"Content-Type": "text/plain"}) + result = {"status_code": response.status_code} + if error_type := response.headers.get("x-amzn-ErrorType"): + return { + **result, + "headers": { + "x-amzn-ErrorType": error_type, + }, + "content": to_str(response.text), + } + + return result + + def _is_url_config_deleted(): + try: + aws_client.lambda_.get_function_url_config( + FunctionName=function_name, Qualifier=alias_name + ) + return False + except aws_client.lambda_.exceptions.ResourceNotFoundException: + return True + + # Wait for active lambda and then invoke + active_waiter.wait(FunctionName=function_name, Qualifier=alias_name) + snapshot.match("invoke_aliased_url_response", _invoke_function()) + + # Delete alias and then invoke aliased-URL + delete_alias_resp = aws_client.lambda_.delete_alias( + FunctionName=function_name, Name=alias_name + ) + snapshot.match("delete_alias_resp", delete_alias_resp) + + # TODO: The below will fail on LocalStack since URL deletions are async. + # When async URL deletion has been implemented, the below can be uncommented + # snapshot.match("invoke_deleted_alias_url_response_immediate", _invoke_function()) + + # Wait until URL config has deleted and then re-invoke aliased-URL + wait_until(_is_url_config_deleted, strategy="linear") + time.sleep(2) # Wait some extra time to ensure cleanup on AWS + snapshot.match("invoke_deleted_alias_url_response_delayed", _invoke_function()) + + @markers.aws.validated + def test_lambda_url_invalid_invoke_mode(self, create_lambda_function, snapshot, aws_client): + function_name = f"test-fn-echo-{short_uid()}" + + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO_JSON_BODY, + runtime=Runtime.python3_12, + handler="lambda_echo_json_body.handler", + ) + + with pytest.raises(Exception) as e: + aws_client.lambda_.create_function_url_config( + FunctionName=function_name, AuthType="NONE", InvokeMode="UNKNOWN" + ) + snapshot.match("invoke_function_invalid_invoke_type", e.value.response) + + @markers.aws.validated + def test_lambda_url_non_existing_url(self): + lambda_url_subdomain = "0123456789abcdefghijklmnopqrstuv.lambda-url.us-east-1" + + if is_aws_cloud(): + url = f"https://{lambda_url_subdomain}.on.aws" + else: + url = config.external_service_url(subdomains=lambda_url_subdomain) + + response = requests.get(url) + assert response.text == '{"Message":null}' + assert response.status_code == 403 + assert response.headers["Content-Type"] == "application/json" + assert response.headers["x-amzn-ErrorType"] == "AccessDeniedException" + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..headers.domain", # TODO: LS Lambda should populate this value for AWS parity + "$..headers.x-forwarded-for", + "$..headers.x-amzn-trace-id", + "$..origin", # TODO: LS Lambda should populate this value for AWS parity + ] + ) + @markers.aws.validated + def test_lambda_url_echo_http_fixture_default( + self, create_echo_http_server, snapshot, aws_client + ): + key_value_transform = [ + "domain", + "origin", + "x-amzn-tls-cipher-suite", + "x-amzn-tls-version", + "x-amzn-trace-id", + "x-forwarded-for", + "x-forwarded-port", + "x-forwarded-proto", + ] + for key in key_value_transform: + snapshot.add_transformer(snapshot.transform.key_value(key)) + echo_url = create_echo_http_server() + response = requests.post( + url=echo_url + "/pa2%Fth/1?q=query&multiQuery=foo&multiQuery=%2Fbar", + headers={ + "content-type": "application/json", + "ExTrA-HeadErs": "With WeiRd CapS", + "user-agent": "test/echo", + }, + json={"foo": "bar"}, + ) + snapshot.match("url_response", response.json()) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..content.headers.domain", # TODO: LS Lambda should populate this value for AWS parity + "$..origin", # TODO: LS Lambda should populate this value for AWS parity + ] + ) + @markers.aws.validated + def test_lambda_url_echo_http_fixture_trim_x_headers( + self, create_echo_http_server, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.key_value("domain")) + snapshot.add_transformer(snapshot.transform.key_value("origin")) + echo_url = create_echo_http_server(trim_x_headers=True) + response = requests.post( + url=echo_url + "/path/1?q=query", + headers={ + "content-type": "application/json", + "ExTrA-HeadErs": "With WeiRd CapS", + "user-agent": "test/echo", + }, + json={"foo": "bar"}, + ) + snapshot.match("url_response", response.json()) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..origin", # FIXME: LS does not populate the value + ] + ) + def test_lambda_url_form_payload(self, create_echo_http_server, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("domain"), + snapshot.transform.key_value("origin"), + ] + ) + echo_server_url = create_echo_http_server(trim_x_headers=True) + # multipart body + body = ( + b"\r\n--4efd159eae0c4f4e125a5a509e073d85\r\n" + b'Content-Disposition: form-data; name="formfield"\r\n\r\n' + b"not a file, just a field" + b"\r\n--4efd159eae0c4f4e125a5a509e073d85\r\n" + b'Content-Disposition: form-data; name="foo"; filename="foo"\r\n' + b"Content-Type: text/plain;\r\n\r\n" + b"bar" + b"\r\n\r\n--4efd159eae0c4f4e125a5a509e073d85--\r\n" + ) + response = requests.post( + url=f"{echo_server_url}/test/value", + headers={ + "Content-Type": "multipart/form-data; boundary=4efd159eae0c4f4e125a5a509e073d85", + "User-Agent": "python/test-request", + }, + data=body, + verify=False, + ) + assert response.status_code == 200 + response_json = response.json() + snapshot.match("url_response", response_json) + + form_data = base64.b64decode(response_json["data"]) + assert form_data == body + + +@pytest.mark.skipif(not is_aws_cloud(), reason="Not yet implemented") +class TestLambdaPermissions: + @markers.aws.validated + def test_lambda_permission_url_invocation(self, create_lambda_function, snapshot, aws_client): + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_URL, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_url.handler", + ) + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + + # Intentionally missing add_permission for invoking lambda function + + url = url_config["FunctionUrl"] + result = safe_requests.post(url, data="text", headers={"Content-Type": "text/plain"}) + assert result.status_code == 403 + snapshot.match("lambda_url_invocation_missing_permission", result.text) + + +class TestLambdaFeatures: + @pytest.fixture( + params=[ + ("nodejs16.x", TEST_LAMBDA_NODEJS_ECHO), + ("python3.10", TEST_LAMBDA_PYTHON_ECHO), + ], + ids=["nodejs16.x", "python3.10"], + ) + def invocation_echo_lambda(self, create_lambda_function, request): + function_name = f"echo-func-{short_uid()}" + runtime, handler = request.param + creation_result = create_lambda_function( + handler_file=handler, + func_name=function_name, + runtime=runtime, + ) + return creation_result["CreateFunctionResponse"]["FunctionArn"] + + # TODO remove, currently missing init duration in REPORT + @markers.snapshot.skip_snapshot_verify(paths=["$..logs.logs"]) + @markers.aws.validated + def test_invocation_with_logs(self, snapshot, invocation_echo_lambda, aws_client): + """Test invocation of a lambda with no invocation type set, but LogType="Tail""" "" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + snapshot.add_transformer( + snapshot.transform.key_value("LogResult", reference_replacement=False) + ) + + result = aws_client.lambda_.invoke( + FunctionName=invocation_echo_lambda, Payload=b"{}", LogType="Tail" + ) + snapshot.match("invoke", result) + + # assert that logs are contained in response + logs = result.get("LogResult", "") + logs = to_str(base64.b64decode(to_str(logs))) + snapshot.add_transformer(snapshot.transform.lambda_report_logs()) + snapshot.match("logs", {"logs": logs.splitlines(keepends=True)}) + assert "START" in logs + assert "{}" in logs + assert "END" in logs + assert "REPORT" in logs + + @markers.aws.validated + def test_invoke_exceptions(self, aws_client, snapshot): + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.invoke(FunctionName="doesnotexist") + snapshot.match("invoke_function_doesnotexist", e.value.response) + + @markers.aws.validated + def test_invocation_type_request_response(self, snapshot, invocation_echo_lambda, aws_client): + """Test invocation with InvocationType RequestResponse explicitly set""" + result = aws_client.lambda_.invoke( + FunctionName=invocation_echo_lambda, + Payload=b"{}", + InvocationType="RequestResponse", + ) + snapshot.match("invoke-result", result) + + @markers.aws.validated + def test_invocation_type_event( + self, snapshot, invocation_echo_lambda, aws_client, check_lambda_logs + ): + """Check invocation response for type event""" + function_arn = invocation_echo_lambda + function_name = lambda_function_name(invocation_echo_lambda) + result = aws_client.lambda_.invoke( + FunctionName=function_arn, Payload=b"{}", InvocationType="Event" + ) + result = read_streams(result) + snapshot.match("invoke-result", result) + + assert 202 == result["StatusCode"] + + # Assert that the function gets invoked by checking the logs. + # This also ensures that we wait until the invocation is done before deleting the function. + expected = [".*{}"] + + def check_logs(): + check_lambda_logs(function_name, expected_lines=expected) + + retry(check_logs, retries=15) + + @pytest.mark.parametrize( + "invocation_type", [InvocationType.RequestResponse, InvocationType.Event] + ) + @pytest.mark.parametrize( + ["lambda_fn", "lambda_runtime"], + [ + (TEST_LAMBDA_PYTHON_NONE, Runtime.python3_12), + (TEST_LAMBDA_NODEJS_NONE, Runtime.nodejs18_x), + ], + ids=[ + "python", + "nodejs", + ], + ) + @markers.aws.validated + def test_invocation_type_no_return_payload( + self, + snapshot, + create_lambda_function, + invocation_type, + aws_client, + check_lambda_logs, + lambda_fn, + lambda_runtime, + ): + """Check invocation response when Lambda does not return a payload""" + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=lambda_fn, + runtime=lambda_runtime, + ) + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=b"{}", InvocationType=invocation_type + ) + result = read_streams(result) + snapshot.match("invoke-result", result) + + # Assert that the function gets invoked by checking the logs. + # This also ensures that we wait until the invocation is done before deleting the function. + expected = [".*{}"] + + def check_logs(): + check_lambda_logs(function_name, expected_lines=expected) + + retry(check_logs, retries=15) + + # TODO: implement for new provider (was tested in old provider) + @pytest.mark.skip(reason="Not yet implemented") + @markers.aws.validated + def test_invocation_type_dry_run(self, snapshot, invocation_echo_lambda, aws_client): + """Check invocation response for type dryrun""" + result = aws_client.lambda_.invoke( + FunctionName=invocation_echo_lambda, Payload=b"{}", InvocationType="DryRun" + ) + result = read_streams(result) + snapshot.match("invoke-result", result) + + assert 204 == result["StatusCode"] + + @pytest.mark.skip(reason="Not yet implemented") + @markers.aws.validated + def test_invocation_type_event_error(self, create_lambda_function, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + + function_name = f"test-function-{short_uid()}" + creation_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_UNHANDLED_ERROR, + runtime=Runtime.python3_12, + ) + snapshot.match("creation_response", creation_response) + invocation_response = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=b"{}", InvocationType="Event" + ) + snapshot.match("invocation_response", invocation_response) + + # check logs if lambda was executed twice + log_group_name = f"/aws/lambda/{function_name}" + + def assert_events(): + ls_result = aws_client.logs.describe_log_streams(logGroupName=log_group_name) + log_stream_name = ls_result["logStreams"][0]["logStreamName"] + log_events = aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name + )["events"] + + assert len([e["message"] for e in log_events if e["message"].startswith("START")]) == 2 + assert len([e["message"] for e in log_events if e["message"].startswith("REPORT")]) == 2 + return log_events + + events = retry(assert_events, retries=120, sleep=2) + # TODO: fix transformers for numbers etc or selectively match log events + snapshot.match("log_events", events) + # check if both request ids are identical, since snapshots currently do not support reference replacement for regexes + start_messages = [e["message"] for e in events if e["message"].startswith("START")] + uuids = [PATTERN_UUID.search(message).group(0) for message in start_messages] + assert len(uuids) == 2 + assert uuids[0] == uuids[1] + + @markers.aws.validated + def test_invocation_with_qualifier( + self, + s3_bucket, + lambda_su_role, + create_lambda_function_aws, + snapshot, + aws_client, + ): + """Tests invocation of python lambda with a given qualifier""" + snapshot.add_transformer(snapshot.transform.key_value("LogResult")) + + function_name = f"test_lambda_{short_uid()}" + bucket_key = "test_lambda.zip" + + # upload zip file to S3 + zip_file = create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON), + get_content=True, + runtime=Runtime.python3_12, + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + # create lambda function + response = create_lambda_function_aws( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Role=lambda_su_role, + Publish=True, + Handler="handler.handler", + Code={"S3Bucket": s3_bucket, "S3Key": bucket_key}, + Timeout=10, + ) + snapshot.match("creation-response", response) + qualifier = response["Version"] + + # invoke lambda function + invoke_result = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=b'{"foo": "bar with \'quotes\\""}', + Qualifier=qualifier, + LogType="Tail", + ) + log_result = invoke_result["LogResult"] + raw_logs = to_str(base64.b64decode(to_str(log_result))) + log_lines = raw_logs.splitlines() + snapshot.match( + "log_result", + {"log_result": [line for line in log_lines if not line.startswith("REPORT")]}, + ) + snapshot.match("invocation-response", invoke_result) + + @markers.aws.validated + def test_upload_lambda_from_s3( + self, + s3_bucket, + lambda_su_role, + wait_until_lambda_ready, + snapshot, + create_lambda_function_aws, + aws_client, + ): + """Test invocation of a python lambda with its deployment package uploaded to s3""" + snapshot.add_transformer(snapshot.transform.s3_api()) + + function_name = f"test_lambda_{short_uid()}" + bucket_key = "test_lambda.zip" + + # upload zip file to S3 + zip_file = testutil.create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON), + get_content=True, + runtime=Runtime.python3_12, + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + # create lambda function + create_response = create_lambda_function_aws( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Handler="handler.handler", + Role=lambda_su_role, + Code={"S3Bucket": s3_bucket, "S3Key": bucket_key}, + Timeout=10, + ) + snapshot.match("creation-response", create_response) + + # invoke lambda function + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=b'{"foo": "bar with \'quotes\\""}' + ) + snapshot.match("invocation-response", result) + + # TODO: implement in new provider (was tested in old provider) + @pytest.mark.skip(reason="Not yet implemented") + @markers.aws.validated + def test_lambda_with_context( + self, create_lambda_function, check_lambda_logs, snapshot, aws_client + ): + """Test context of nodejs lambda invocation""" + function_name = f"test-function-{short_uid()}" + creation_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_INTEGRATION_NODEJS, + handler="lambda_integration.handler", + runtime=Runtime.nodejs20_x, + ) + snapshot.match("creation", creation_response) + ctx = { + "custom": {"foo": "bar"}, + "client": {"snap": ["crackle", "pop"]}, + "env": {"fizz": "buzz"}, + } + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=b"{}", + ClientContext=to_str(base64.b64encode(to_bytes(json.dumps(ctx)))), + ) + result = read_streams(result) + snapshot.match("invocation", result) + + result_data = result["Payload"] + assert 200 == result["StatusCode"] + client_context = json.loads(result_data)["context"]["clientContext"] + assert "bar" == client_context.get("custom").get("foo") + + # assert that logs are present + expected = [".*Node.js Lambda handler executing."] + + def check_logs(): + check_lambda_logs(function_name, expected_lines=expected) + + retry(check_logs, retries=15) + + +class TestLambdaErrors: + @markers.aws.validated + # TODO it seems like the used lambda images have a newer version of the RIC than AWS in production + # remove this skip once they have caught up + @markers.snapshot.skip_snapshot_verify(paths=["$..Payload.stackTrace"]) + def test_lambda_runtime_error(self, aws_client, create_lambda_function, snapshot): + """Test Lambda that raises an exception during runtime startup.""" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_RUNTIME_ERROR, + handler="lambda_runtime_error.handler", + runtime=Runtime.python3_13, + ) + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + ) + snapshot.match("invocation_error", result) + + @pytest.mark.skipif( + not is_aws_cloud(), reason="Not yet supported. Need to report exit in Lambda init binary." + ) + @markers.aws.validated + def test_lambda_runtime_exit(self, aws_client, create_lambda_function, snapshot): + """Test Lambda that exits during runtime startup.""" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_RUNTIME_EXIT, + handler="lambda_runtime_exit.handler", + runtime=Runtime.python3_12, + ) + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + ) + snapshot.match("invocation_error", result) + + @pytest.mark.skipif( + not is_aws_cloud(), reason="Not yet supported. Need to report exit in Lambda init binary." + ) + @markers.aws.validated + def test_lambda_runtime_exit_segfault(self, aws_client, create_lambda_function, snapshot): + """Test Lambda that exits during runtime startup with a segmentation fault.""" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_RUNTIME_EXIT_SEGFAULT, + handler="lambda_runtime_exit_segfault.handler", + runtime=Runtime.python3_12, + ) + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + ) + snapshot.match("invocation_error", result) + + @markers.aws.validated + def test_lambda_handler_error(self, aws_client, create_lambda_function, snapshot): + """Test Lambda that raises an exception in the handler.""" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_HANDLER_ERROR, + handler="lambda_handler_error.handler", + runtime=Runtime.python3_12, + ) + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + ) + snapshot.match("invocation_error", result) + + @pytest.mark.skipif( + not is_aws_cloud(), reason="Not yet supported. Need to report exit in Lambda init binary." + ) + @markers.aws.validated + def test_lambda_handler_exit(self, aws_client, create_lambda_function, snapshot): + """Test Lambda that exits in the handler.""" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_HANDLER_EXIT, + handler="lambda_handler_exit.handler", + runtime=Runtime.python3_12, + ) + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + ) + snapshot.match("invocation_error", result) + + @pytest.mark.skipif( + not is_aws_cloud(), reason="Not yet supported. Need to raise error in Lambda init binary." + ) + @markers.aws.validated + def test_lambda_runtime_wrapper_not_found(self, aws_client, create_lambda_function, snapshot): + """Test Lambda that points to a non-existing Lambda wrapper""" + snapshot.add_transformer(snapshot.transform.regex(PATTERN_UUID, "")) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + envvars={"AWS_LAMBDA_EXEC_WRAPPER": "/idontexist.sh"}, + ) + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + ) + snapshot.match("invocation_error", result) + + @markers.aws.only_localstack( + reason="Can only induce Lambda-internal Docker error in LocalStack" + ) + def test_lambda_runtime_startup_timeout( + self, aws_client_no_retry, create_lambda_function, monkeypatch + ): + """Test Lambda that times out during runtime startup""" + monkeypatch.setattr( + config, "LAMBDA_DOCKER_FLAGS", "-e LOCALSTACK_RUNTIME_ENDPOINT=http://somehost.invalid" + ) + monkeypatch.setattr(config, "LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT", 2) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + ) + + with pytest.raises(aws_client_no_retry.lambda_.exceptions.ServiceException) as e: + aws_client_no_retry.lambda_.invoke( + FunctionName=function_name, + ) + assert e.match( + r"An error occurred \(ServiceException\) when calling the Invoke operation \(reached max " + r"retries: \d\): \[[^]]*\] Timeout while starting up lambda environment .*" + ) + + @markers.aws.only_localstack( + reason="Can only induce Lambda-internal Docker error in LocalStack" + ) + def test_lambda_runtime_startup_error( + self, aws_client_no_retry, create_lambda_function, monkeypatch + ): + """Test Lambda that errors during runtime startup""" + monkeypatch.setattr(config, "LAMBDA_DOCKER_FLAGS", "invalid_flags") + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + ) + + with pytest.raises(aws_client_no_retry.lambda_.exceptions.ServiceException) as e: + aws_client_no_retry.lambda_.invoke( + FunctionName=function_name, + ) + assert e.match( + r"An error occurred \(ServiceException\) when calling the Invoke operation \(reached max " + r"retries: \d\): \[[^]]*\] Internal error while executing lambda" + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + @pytest.mark.parametrize( + ["label", "payload"], + [ + # Body taken from AWS CLI v2 call: + # aws lambda invoke --debug --function-name localstack-lambda-url-example \ + # --payload '{"body": "{\"num1\": \"10\", \"num2\": \"10\"}" }' output.txt + ("body", b"n\x87r\x9e\xe9\xb5\xd7I\xee\x9bmt"), + # Body taken from AWS CLI v2 call: + # aws lambda invoke --debug --function-name localstack-lambda-url-example \ + # --payload '{"message": "hello" }' output.txt + ("message", b"\x99\xeb,j\x07\xa1zYh"), + ], + ) + def test_lambda_invoke_payload_encoding_error( + self, aws_client_factory, create_lambda_function, snapshot, label, payload + ): + """Test Lambda invoke with invalid encoding error. + This happens when using the AWS CLI v2 with an inline --payload '{"input": "my-input"}' without specifying + the flag --cli-binary-format raw-in-base64-out because base64 is the new default in v2. See AWS docs: + https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-options.html + """ + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + ) + + client_config = Config( + retries={"max_attempts": 0}, + ) + no_retry_lambda_client = aws_client_factory.get_client("lambda", config=client_config) + with pytest.raises(no_retry_lambda_client.exceptions.InvalidRequestContentException) as e: + no_retry_lambda_client.invoke(FunctionName=function_name, Payload=payload) + snapshot.match(f"invoke_function_invalid_payload_{label}", e.value.response) + + +class TestLambdaCleanup: + @pytest.mark.skip( + reason="Not yet handled properly. Currently raises an InvalidStatusException." + ) + @markers.aws.validated + def test_delete_lambda_during_sync_invoke(self, aws_client, create_lambda_function, snapshot): + """Test deleting a Lambda during a synchronous invocation. + + Unlike AWS, we will throw an error and clean up all containers to avoid dangling containers. + """ + func_name = f"func-{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_SLEEP_ENVIRONMENT, + runtime=Runtime.python3_12, + Timeout=30, + ) + + # Warm up the Lambda + invoke_result_1 = aws_client.lambda_.invoke( + FunctionName=func_name, + Payload=json.dumps({"sleep": 0}), + InvocationType="RequestResponse", + ) + assert invoke_result_1["StatusCode"] == 200 + assert "FunctionError" not in invoke_result_1 + + # Simultaneously invoke and delete the Lambda function + errored = False + + def _invoke_function(): + nonlocal errored + try: + invoke_result_2 = aws_client.lambda_.invoke( + FunctionName=func_name, + Payload=json.dumps({"sleep": 20}), + InvocationType="RequestResponse", + ) + assert invoke_result_2["StatusCode"] == 200 + assert "FunctionError" not in invoke_result_2 + except Exception: + LOG.exception("Invoke failed") + errored = True + + thread = threading.Thread(target=_invoke_function) + thread.start() + + # Ensure that the invoke has been sent before deleting the function + time.sleep(5) + delete_result = aws_client.lambda_.delete_function(FunctionName=func_name) + snapshot.match("delete-result", delete_result) + + thread.join() + + assert not errored + + @markers.aws.validated + def test_recreate_function( + self, aws_client, create_lambda_function, check_lambda_logs, snapshot + ): + """Recreating a function with the same name should not cause any resource cleanup issues or namespace collisions + Reproduces a GitHub issue: https://github.com/localstack/localstack/issues/10280""" + function_name = f"test-function-{short_uid()}" + create_function_response_one = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + ) + snapshot.match("create_function_response_one", create_function_response_one) + + aws_client.lambda_.delete_function(FunctionName=function_name) + + # Immediately re-create the same function + create_function_response_two = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + handler="lambda_echo.handler", + runtime=Runtime.python3_12, + ) + snapshot.match("create_function_response_two", create_function_response_two) + + # Validate that async invokes still work + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType="Event", + ) + invoke_response = read_streams(invoke_response) + assert 202 == invoke_response["StatusCode"] + + # Assert that the function gets invoked by checking the logs. + # This also ensures that we wait until the invocation is done before deleting the function. + expected = [".*{}"] + + def check_logs(): + check_lambda_logs(function_name, expected_lines=expected) + + retry(check_logs, retries=15) + + +class TestLambdaMultiAccounts: + @pytest.fixture + def primary_client(self, aws_client): + return aws_client.lambda_ + + @pytest.fixture + def secondary_client(self, secondary_aws_client): + return secondary_aws_client.lambda_ + + @pytest.fixture + def created_lambda_arn(self, create_lambda_function, primary_client, secondary_account_id): + # Operations related to functions. + # See: https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html#permissions-resource-xaccountinvoke + # + # - Invoke + # - GetFunction + # - GetFunctionConfiguration + # - UpdateFunctionCode + # - DeleteFunction + # - PublishVersion + # - ListVersionsByFunction + # - CreateAlias + # - GetAlias + # - ListAliases + # - UpdateAlias + # - DeleteAlias + # - GetPolicy + # - PutFunctionConcurrency + # - DeleteFunctionConcurrency + # - ListTags + # - TagResource + # - UntagResource + + func_name = f"func-{short_uid()}" + func_arn = create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INTROSPECT_PYTHON, + runtime=Runtime.python3_12, + )["CreateFunctionResponse"]["FunctionArn"] + + statement_id = f"stm-{short_uid()}" + primary_client.add_permission( + FunctionName=func_name, + StatementId=statement_id, + Principal=secondary_account_id, + Action="lambda:*", + ) + return func_arn + + @pytest.fixture + def created_layer_arn( + self, primary_client, create_lambda_function, dummylayer, secondary_account_id + ): + # As of 2024-02, AWS restricts the input for adding resource-based policy to layer versions via AddLayerVersionPermission. + # Only 'lambda:GetLayerVersion' is accepted for Action. + # https://github.com/boto/botocore/blob/cf7b8449643187670620ab699596ca785e3ec889/botocore/data/lambda/2015-03-31/service-2.json#L3906-L3909 + # This appears to have been the case at least since 2021-06. + # Furthermore this contradicts with what its docs on valid IAM actions for layer versions: + # https://docs.aws.amazon.com/lambda/latest/dg/lambda-api-permissions-ref.html#permissions-resources-layers + + # Operations related to Lambda layers supported by cross account + # - GetLayerVersion + # - GetLayerVersionByArn + + # Operations not supported by lambda layer cross account + # - ListLayerVersions + # - DeleteLayerVersion + # - AddLayerVersionPermission + # - GetLayerVersionPolicy + # - RemoveLayerVersionPermission + + layer_name = f"layer-{short_uid()}" + layer_arn = primary_client.publish_layer_version( + LayerName=layer_name, Content={"ZipFile": dummylayer} + )["LayerArn"] + + layer_statement_id = f"stm-{short_uid()}" + primary_client.add_layer_version_permission( + LayerName=layer_arn, + StatementId=layer_statement_id, + Principal=secondary_account_id, + Action="lambda:GetLayerVersion", + VersionNumber=1, + ) + + return layer_arn + + @markers.aws.validated + def test_get_lambda_layer(self, secondary_client, created_layer_arn): + secondary_client.get_layer_version(LayerName=created_layer_arn, VersionNumber=1) + secondary_client.get_layer_version_by_arn(Arn=created_layer_arn + ":1") + + @markers.aws.validated + def test_get_function(self, secondary_client, created_lambda_arn): + secondary_client.get_function(FunctionName=created_lambda_arn) + + @markers.aws.validated + def test_get_function_configuration(self, secondary_client, created_lambda_arn): + secondary_client.get_function_configuration(FunctionName=created_lambda_arn) + + @markers.aws.validated + def test_list_versions_by_function(self, secondary_client, created_lambda_arn): + secondary_client.get_function_configuration(FunctionName=created_lambda_arn) + + @markers.aws.validated + def test_function_concurrency(self, secondary_client, created_lambda_arn): + secondary_client.put_function_concurrency( + FunctionName=created_lambda_arn, ReservedConcurrentExecutions=1 + ) + secondary_client.delete_function_concurrency(FunctionName=created_lambda_arn) + + @markers.aws.validated + def test_function_alias(self, secondary_client, created_lambda_arn): + alias_name = f"alias-{short_uid()}" + secondary_client.create_alias( + FunctionName=created_lambda_arn, FunctionVersion="$LATEST", Name=alias_name + ) + + secondary_client.get_alias(FunctionName=created_lambda_arn, Name=alias_name) + + alias_description = f"alias-description-{short_uid()}" + secondary_client.update_alias( + FunctionName=created_lambda_arn, Name=alias_name, Description=alias_description + ) + + resp = secondary_client.list_aliases(FunctionName=created_lambda_arn) + assert len(resp["Aliases"]) == 1 + assert resp["Aliases"][0]["Description"] == alias_description + + secondary_client.delete_alias(FunctionName=created_lambda_arn, Name=alias_name) + + @markers.aws.validated + def test_function_tags(self, secondary_client, created_lambda_arn): + tags = {"foo": "bar"} + secondary_client.tag_resource(Resource=created_lambda_arn, Tags=tags) + assert secondary_client.list_tags(Resource=created_lambda_arn)["Tags"] == tags + secondary_client.untag_resource(Resource=created_lambda_arn, TagKeys=["lorem"]) + + @markers.aws.validated + def test_function_invocation(self, secondary_client, created_lambda_arn): + secondary_client.invoke(FunctionName=created_lambda_arn) + + @markers.aws.validated + def test_publish_version(self, secondary_client, created_lambda_arn): + secondary_client.publish_version(FunctionName=created_lambda_arn) + + @markers.aws.validated + def test_delete_function(self, secondary_client, created_lambda_arn): + secondary_client.delete_function(FunctionName=created_lambda_arn) + + +class TestLambdaConcurrency: + @markers.aws.validated + def test_lambda_concurrency_crud(self, snapshot, create_lambda_function, aws_client): + func_name = f"fn-concurrency-{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + ) + + default_concurrency_result = aws_client.lambda_.get_function_concurrency( + FunctionName=func_name + ) + snapshot.match("get_function_concurrency_default", default_concurrency_result) + + # 0 should always succeed independent of the UnreservedConcurrentExecution limits + reserved_concurrency_result = aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=0 + ) + snapshot.match("put_function_concurrency", reserved_concurrency_result) + + updated_concurrency_result = aws_client.lambda_.get_function_concurrency( + FunctionName=func_name + ) + snapshot.match("get_function_concurrency_updated", updated_concurrency_result) + assert updated_concurrency_result["ReservedConcurrentExecutions"] == 0 + + aws_client.lambda_.delete_function_concurrency(FunctionName=func_name) + + deleted_concurrency_result = aws_client.lambda_.get_function_concurrency( + FunctionName=func_name + ) + snapshot.match("get_function_concurrency_deleted", deleted_concurrency_result) + + @pytest.mark.skip_snapshot_verify(paths=["$..Configuration", "$..Code"]) + @markers.aws.validated + def test_lambda_concurrency_update(self, snapshot, create_lambda_function, aws_client): + func_name = f"fn-concurrency-{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + ) + new_reserved_concurrency = 3 + reserved_concurrency_result = aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=new_reserved_concurrency + ) + snapshot.match("put_function_concurrency", reserved_concurrency_result) + + updated_concurrency_result = aws_client.lambda_.get_function_concurrency( + FunctionName=func_name + ) + snapshot.match("get_function_concurrency_updated", updated_concurrency_result) + assert ( + updated_concurrency_result["ReservedConcurrentExecutions"] == new_reserved_concurrency + ) + + function_concurrency_info = aws_client.lambda_.get_function(FunctionName=func_name) + snapshot.match("get_function_concurrency_info", function_concurrency_info) + + aws_client.lambda_.delete_function_concurrency(FunctionName=func_name) + + deleted_concurrency_result = aws_client.lambda_.get_function_concurrency( + FunctionName=func_name + ) + snapshot.match("get_function_concurrency_deleted", deleted_concurrency_result) + + @markers.aws.validated + def test_lambda_concurrency_block(self, snapshot, create_lambda_function, aws_client): + """ + Tests an edge case where reserved concurrency is equal to the sum of all provisioned concurrencies for a function. + In this case we can't call $LATEST anymore since there's no "free"/unclaimed concurrency left to execute the function with + """ + min_concurrent_executions = 10 + 2 # reserved concurrency + provisioned concurrency + check_concurrency_quota(aws_client, min_concurrent_executions) + + # function + func_name = f"fn-concurrency-{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + ) + + # reserved concurrency + v1_result = aws_client.lambda_.publish_version(FunctionName=func_name) + snapshot.match("v1_result", v1_result) + v1 = v1_result["Version"] + + # assert version is available(!) + aws_client.lambda_.get_waiter(waiter_name="function_active_v2").wait( + FunctionName=func_name, Qualifier=v1 + ) + + # Reserved concurrency works on the whole function + reserved_concurrency_result = aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=1 + ) + snapshot.match("reserved_concurrency_result", reserved_concurrency_result) + + # verify we can call $LATEST + invoke_latest_before_block = aws_client.lambda_.invoke( + FunctionName=func_name, Qualifier="$LATEST", Payload=json.dumps({"hello": "world"}) + ) + snapshot.match("invoke_latest_before_block", invoke_latest_before_block) + + # Provisioned concurrency works on individual version/aliases, but *not* on $LATEST + provisioned_concurrency_result = aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1, ProvisionedConcurrentExecutions=1 + ) + snapshot.match("provisioned_concurrency_result", provisioned_concurrency_result) + + assert wait_until(concurrency_update_done(aws_client.lambda_, func_name, v1)) + + # verify we can't call $LATEST anymore + with pytest.raises(aws_client.lambda_.exceptions.TooManyRequestsException) as e: + aws_client.lambda_.invoke( + FunctionName=func_name, Qualifier="$LATEST", Payload=json.dumps({"hello": "world"}) + ) + snapshot.match("invoke_latest_first_exc", e.value.response) + + # but we can call the version with provisioned concurrency + invoke_v1_after_block = aws_client.lambda_.invoke( + FunctionName=func_name, Qualifier=v1, Payload=json.dumps({"hello": "world"}) + ) + snapshot.match("invoke_v1_after_block", invoke_v1_after_block) + + # verify we can't call $LATEST again + with pytest.raises(aws_client.lambda_.exceptions.TooManyRequestsException) as e: + aws_client.lambda_.invoke( + FunctionName=func_name, Qualifier="$LATEST", Payload=json.dumps({"hello": "world"}) + ) + snapshot.match("invoke_latest_second_exc", e.value.response) + + @pytest.mark.skip(reason="Not yet implemented") + @pytest.mark.skipif(condition=is_aws_cloud(), reason="very slow (only execute when needed)") + @markers.aws.validated + def test_lambda_provisioned_concurrency_moves_with_alias( + self, create_lambda_function, snapshot, aws_client + ): + """ + create fn β‡’ publish version β‡’ create alias for version β‡’ put concurrency on alias + β‡’ new version with change β‡’ change alias to new version β‡’ concurrency moves with alias? same behavior for calls to alias/version? + """ + + # TODO: validate once implemented + min_concurrent_executions = 10 + 2 # for alias and version + check_concurrency_quota(aws_client, min_concurrent_executions) + + func_name = f"test_lambda_{short_uid()}" + alias_name = f"test_alias_{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(alias_name, "")) + + create_result = create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INVOCATION_TYPE, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=2, + ) + snapshot.match("create-result", create_result) + + fn = aws_client.lambda_.get_function_configuration( + FunctionName=func_name, Qualifier="$LATEST" + ) + snapshot.match("get-function-configuration", fn) + + first_ver = aws_client.lambda_.publish_version( + FunctionName=func_name, RevisionId=fn["RevisionId"], Description="my-first-version" + ) + snapshot.match("publish_version_1", first_ver) + + get_function_configuration = aws_client.lambda_.get_function_configuration( + FunctionName=func_name, Qualifier=first_ver["Version"] + ) + snapshot.match("get_function_configuration_version_1", get_function_configuration) + + aws_client.lambda_.get_waiter("function_updated_v2").wait( + FunctionName=func_name, Qualifier=first_ver["Version"] + ) + + # There's no ProvisionedConcurrencyConfiguration yet + assert ( + get_invoke_init_type(aws_client.lambda_, func_name, first_ver["Version"]) == "on-demand" + ) + + # Create Alias and add ProvisionedConcurrencyConfiguration to it + alias = aws_client.lambda_.create_alias( + FunctionName=func_name, FunctionVersion=first_ver["Version"], Name=alias_name + ) + snapshot.match("create_alias", alias) + get_function_result = aws_client.lambda_.get_function( + FunctionName=func_name, Qualifier=first_ver["Version"] + ) + snapshot.match("get_function_before_provisioned", get_function_result) + aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=alias_name, ProvisionedConcurrentExecutions=1 + ) + assert wait_until(concurrency_update_done(aws_client.lambda_, func_name, alias_name)) + get_function_result = aws_client.lambda_.get_function( + FunctionName=func_name, Qualifier=alias_name + ) + snapshot.match("get_function_after_provisioned", get_function_result) + + # Alias AND Version now both use provisioned-concurrency (!) + assert ( + get_invoke_init_type(aws_client.lambda_, func_name, first_ver["Version"]) + == "provisioned-concurrency" + ) + assert ( + get_invoke_init_type(aws_client.lambda_, func_name, alias_name) + == "provisioned-concurrency" + ) + + # Update lambda configuration and publish new version + aws_client.lambda_.update_function_configuration(FunctionName=func_name, Timeout=10) + assert wait_until(update_done(aws_client.lambda_, func_name)) + lambda_conf = aws_client.lambda_.get_function_configuration(FunctionName=func_name) + snapshot.match("get_function_after_update", lambda_conf) + + # Move existing alias to the new version + new_version = aws_client.lambda_.publish_version( + FunctionName=func_name, RevisionId=lambda_conf["RevisionId"] + ) + snapshot.match("publish_version_2", new_version) + new_alias = aws_client.lambda_.update_alias( + FunctionName=func_name, FunctionVersion=new_version["Version"], Name=alias_name + ) + snapshot.match("update_alias", new_alias) + + # lambda should now be provisioning new "hot" execution environments for this new alias->version pointer + # the old one should be de-provisioned + get_provisioned_config_result = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=alias_name + ) + snapshot.match("get_provisioned_config_after_alias_move", get_provisioned_config_result) + assert wait_until( + concurrency_update_done(aws_client.lambda_, func_name, alias_name), + strategy="linear", + wait=30, + max_retries=20, + _max_wait=600, + ) # this is SLOW (~6-8 min) + + # concurrency should still only work for the alias now + # NOTE: the old version has been de-provisioned and will run 'on-demand' now! + assert ( + get_invoke_init_type(aws_client.lambda_, func_name, first_ver["Version"]) == "on-demand" + ) + assert ( + get_invoke_init_type(aws_client.lambda_, func_name, new_version["Version"]) + == "provisioned-concurrency" + ) + assert ( + get_invoke_init_type(aws_client.lambda_, func_name, alias_name) + == "provisioned-concurrency" + ) + + # ProvisionedConcurrencyConfig should only be "registered" to the alias, not the referenced version + with pytest.raises( + aws_client.lambda_.exceptions.ProvisionedConcurrencyConfigNotFoundException + ) as e: + aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=new_version["Version"] + ) + snapshot.match("provisioned_concurrency_notfound", e.value.response) + + @markers.aws.validated + def test_provisioned_concurrency(self, create_lambda_function, snapshot, aws_client): + """ + TODO: what happens with running invocations in provisioned environments when the provisioned concurrency is deleted? + TODO: are the previous provisioned environments not available for new invocations anymore? + TODO: lambda_client.delete_provisioned_concurrency_config() + + Findings (mostly through manual testing, observing, changing the test here and doing semi-manual runs) + - execution environments are provisioned nearly in parallel (we had *ONE* case where it first spawned 19/20) + - it generates 2x provisioned concurrency cloudwatch logstreams with only INIT_START + - updates while IN_PROGRESS are allowed and overwrite the previous config + """ + min_concurrent_executions = 10 + 5 + check_concurrency_quota(aws_client, min_concurrent_executions) + + func_name = f"test_lambda_{short_uid()}" + + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INVOCATION_TYPE, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + v1 = aws_client.lambda_.publish_version(FunctionName=func_name) + + put_provisioned = aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"], ProvisionedConcurrentExecutions=5 + ) + snapshot.match("put_provisioned_5", put_provisioned) + + get_provisioned_prewait = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"] + ) + + # TODO: test invoke before provisioned concurrency actually updated + # maybe repeated executions to see when we get the provisioned invocation type + + snapshot.match("get_provisioned_prewait", get_provisioned_prewait) + assert wait_until(concurrency_update_done(aws_client.lambda_, func_name, v1["Version"])) + get_provisioned_postwait = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"] + ) + snapshot.match("get_provisioned_postwait", get_provisioned_postwait) + + invoke_result1 = aws_client.lambda_.invoke(FunctionName=func_name, Qualifier=v1["Version"]) + result1 = json.load(invoke_result1["Payload"]) + assert result1 == "provisioned-concurrency" + + invoke_result2 = aws_client.lambda_.invoke(FunctionName=func_name, Qualifier="$LATEST") + result2 = json.load(invoke_result2["Payload"]) + assert result2 == "on-demand" + + @markers.aws.validated + def test_provisioned_concurrency_on_alias(self, create_lambda_function, snapshot, aws_client): + """ + Tests provisioned concurrency created and invoked using an alias + """ + # TODO add test that you cannot set provisioned concurrency on both alias and version it points to + # TODO can you set provisioned concurrency on multiple aliases pointing to the same function version? + min_concurrent_executions = 10 + 5 + check_concurrency_quota(aws_client, min_concurrent_executions) + + func_name = f"test_lambda_{short_uid()}" + alias_name = "live" + + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INVOCATION_TYPE, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + v1 = aws_client.lambda_.publish_version(FunctionName=func_name) + aws_client.lambda_.create_alias( + FunctionName=func_name, Name=alias_name, FunctionVersion=v1["Version"] + ) + + put_provisioned = aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=alias_name, ProvisionedConcurrentExecutions=5 + ) + snapshot.match("put_provisioned_5", put_provisioned) + + get_provisioned_prewait = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=alias_name + ) + + snapshot.match("get_provisioned_prewait", get_provisioned_prewait) + assert wait_until(concurrency_update_done(aws_client.lambda_, func_name, alias_name)) + get_provisioned_postwait = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=alias_name + ) + snapshot.match("get_provisioned_postwait", get_provisioned_postwait) + + invoke_result1 = aws_client.lambda_.invoke(FunctionName=func_name, Qualifier=alias_name) + result1 = json.load(invoke_result1["Payload"]) + assert result1 == "provisioned-concurrency" + + invoke_result1 = aws_client.lambda_.invoke(FunctionName=func_name, Qualifier=v1["Version"]) + result1 = json.load(invoke_result1["Payload"]) + assert result1 == "provisioned-concurrency" + + invoke_result2 = aws_client.lambda_.invoke(FunctionName=func_name, Qualifier="$LATEST") + result2 = json.load(invoke_result2["Payload"]) + assert result2 == "on-demand" + + @markers.aws.validated + def test_lambda_provisioned_concurrency_scheduling( + self, snapshot, create_lambda_function, aws_client + ): + min_concurrent_executions = 10 + 1 + check_concurrency_quota(aws_client, min_concurrent_executions) + + """Tests that invokes should be scheduled to provisioned-concurrency instances rather than on-demand + if-and-only-if free provisioned concurrency is available.""" + func_name = f"fn-concurrency-{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INVOCATION_TYPE, + runtime=Runtime.python3_12, + timeout=10, + ) + + v1 = aws_client.lambda_.publish_version(FunctionName=func_name) + + aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"], ProvisionedConcurrentExecutions=1 + ) + assert wait_until(concurrency_update_done(aws_client.lambda_, func_name, v1["Version"])) + + get_provisioned_postwait = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"] + ) + snapshot.match("get_provisioned_postwait", get_provisioned_postwait) + + # Invoke should favor provisioned concurrency function over launching a new on-demand instance + invoke_result = aws_client.lambda_.invoke( + FunctionName=func_name, + Qualifier=v1["Version"], + ) + result = json.load(invoke_result["Payload"]) + assert result == "provisioned-concurrency" + + # Send two simultaneous invokes + errored = False + + def _invoke_lambda(): + nonlocal errored + try: + invoke_result1 = aws_client.lambda_.invoke( + FunctionName=func_name, + Qualifier=v1["Version"], + Payload=json.dumps({"wait": 6}), + ) + result1 = json.load(invoke_result1["Payload"]) + assert result1 == "provisioned-concurrency" + except Exception: + LOG.exception("Invoking Lambda failed") + errored = True + + thread = threading.Thread(target=_invoke_lambda) + thread.start() + + # Ensure the first provisioned-concurrency invoke is running before sending the second on-demand invoke + time.sleep(2) + + # Invoke while the first invoke is still running + invoke_result2 = aws_client.lambda_.invoke( + FunctionName=func_name, + Qualifier=v1["Version"], + ) + result2 = json.load(invoke_result2["Payload"]) + assert result2 == "on-demand" + + # Wait for the first invoker thread + thread.join() + assert not errored + + @markers.aws.validated + def test_reserved_concurrency_async_queue( + self, + create_lambda_function, + sqs_create_queue, + sqs_collect_messages, + snapshot, + aws_client, + aws_client_no_retry, + ): + """Test async/event invoke retry behavior due to limited reserved concurrency. + Timeline: + 1) Set ReservedConcurrentExecutions=1 + 2) sync_invoke_warm_up => ok + 3) async_invoke_one => ok + 4) async_invoke_two => gets retried + 5) sync invoke => fails with TooManyRequestsException + 6) Set ReservedConcurrentExecutions=3 + 7) sync_invoke_final => ok + """ + min_concurrent_executions = 10 + 3 + check_concurrency_quota(aws_client, min_concurrent_executions) + + queue_url = sqs_create_queue() + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_NOTIFIER, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=30, + ) + + fn = aws_client.lambda_.get_function_configuration( + FunctionName=func_name, Qualifier="$LATEST" + ) + snapshot.match("fn", fn) + fn_arn = fn["FunctionArn"] + + # configure reserved concurrency for sequential execution + put_fn_concurrency = aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=1 + ) + snapshot.match("put_fn_concurrency", put_fn_concurrency) + + # warm up the Lambda function to mitigate flakiness due to cold start + sync_invoke_warm_up = aws_client.lambda_.invoke( + FunctionName=fn_arn, InvocationType="RequestResponse" + ) + assert "FunctionError" not in sync_invoke_warm_up + + # Immediately queue two event invocations: + # 1) The first event invoke gets executed immediately + async_invoke_one = aws_client.lambda_.invoke( + FunctionName=fn_arn, + InvocationType="Event", + Payload=json.dumps({"notify": queue_url, "wait": 15}), + ) + assert "FunctionError" not in async_invoke_one + # 2) The second event invoke gets throttled and re-scheduled with an internal retry + async_invoke_two = aws_client.lambda_.invoke( + FunctionName=fn_arn, + InvocationType="Event", + Payload=json.dumps({"notify": queue_url}), + ) + assert "FunctionError" not in async_invoke_two + + # Wait until the first async invoke is being executed while the second async invoke is in the queue. + messages = sqs_collect_messages( + queue_url, + expected=1, + timeout=15, + ) + async_invoke_one_notification = json.loads(messages[0]["Body"]) + assert ( + async_invoke_one_notification["request_id"] + == async_invoke_one["ResponseMetadata"]["RequestId"] + ) + + # Synchronous invocations raise an exception because insufficient reserved concurrency is available + # It is important to disable botocore retries because the concurrency limit is time-bound because it only + # triggers as long as the first async invoke is processing! + with pytest.raises(aws_client.lambda_.exceptions.TooManyRequestsException) as e: + aws_client_no_retry.lambda_.invoke( + FunctionName=fn_arn, InvocationType="RequestResponse" + ) + snapshot.match("too_many_requests_exc", e.value.response) + + # ReservedConcurrentExecutions=2 is insufficient because the throttled async event invoke might be + # re-scheduled before the synchronous invoke while the first async invoke is still running. + aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=3 + ) + # Invocations succeed after raising reserved concurrency + sync_invoke_final = aws_client.lambda_.invoke( + FunctionName=fn_arn, + InvocationType="RequestResponse", + Payload=json.dumps({"notify": queue_url}), + ) + assert "FunctionError" not in sync_invoke_final + + # Contains the re-queued `async_invoke_two` and the `sync_invoke_final`, but the order might differ + # depending on whether invoke_two gets re-schedule before or after the final invoke. + # AWS docs: https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-error-handling.html + # "The retry interval increases exponentially from 1 second after the first attempt to a maximum of 5 minutes." + final_messages = sqs_collect_messages( + queue_url, + expected=2, + timeout=20, + ) + invoked_request_ids = {json.loads(msg["Body"])["request_id"] for msg in final_messages} + assert { + async_invoke_two["ResponseMetadata"]["RequestId"], + sync_invoke_final["ResponseMetadata"]["RequestId"], + } == invoked_request_ids + + @markers.snapshot.skip_snapshot_verify(paths=["$..Attributes.AWSTraceHeader"]) + @markers.aws.validated + def test_reserved_concurrency( + self, create_lambda_function, snapshot, sqs_create_queue, aws_client + ): + snapshot.add_transformer( + snapshot.transform.key_value("MD5OfBody", "", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.key_value( + "ReceiptHandle", "receipt-handle", reference_replacement=True + ) + ) + snapshot.add_transformer( + snapshot.transform.key_value("SenderId", "", reference_replacement=False) + ) + func_name = f"test_lambda_{short_uid()}" + + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INTROSPECT_PYTHON, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + timeout=20, + ) + + fn = aws_client.lambda_.get_function_configuration( + FunctionName=func_name, Qualifier="$LATEST" + ) + snapshot.match("fn", fn) + fn_arn = fn["FunctionArn"] + + # block execution by setting reserved concurrency to 0 + put_reserved = aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=0 + ) + snapshot.match("put_reserved", put_reserved) + + with pytest.raises(aws_client.lambda_.exceptions.TooManyRequestsException) as e: + aws_client.lambda_.invoke(FunctionName=fn_arn, InvocationType="RequestResponse") + snapshot.match("exc_no_cap_requestresponse", e.value.response) + + queue_name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + put_event_invoke_conf = aws_client.lambda_.put_function_event_invoke_config( + FunctionName=func_name, + MaximumRetryAttempts=0, + DestinationConfig={"OnFailure": {"Destination": queue_arn}}, + ) + snapshot.match("put_event_invoke_conf", put_event_invoke_conf) + + time.sleep(3) # just to be sure the event invoke config is active + + invoke_result = aws_client.lambda_.invoke(FunctionName=fn_arn, InvocationType="Event") + snapshot.match("invoke_result", invoke_result) + + def get_msg_from_queue(): + msgs = aws_client.sqs.receive_message( + QueueUrl=queue_url, AttributeNames=["All"], WaitTimeSeconds=5 + ) + return msgs["Messages"][0] + + msg = retry(get_msg_from_queue, retries=10, sleep=2) + snapshot.match("msg", msg) + + @markers.aws.validated + def test_reserved_provisioned_overlap(self, create_lambda_function, snapshot, aws_client): + min_concurrent_executions = 10 + 4 # provisioned concurrency (2) + reserved concurrency (2) + check_concurrency_quota(aws_client, min_concurrent_executions) + + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_INVOCATION_TYPE, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + v1 = aws_client.lambda_.publish_version(FunctionName=func_name) + + put_provisioned = aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"], ProvisionedConcurrentExecutions=2 + ) + snapshot.match("put_provisioned_5", put_provisioned) + + get_provisioned_prewait = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"] + ) + snapshot.match("get_provisioned_prewait", get_provisioned_prewait) + assert wait_until(concurrency_update_done(aws_client.lambda_, func_name, v1["Version"])) + get_provisioned_postwait = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"] + ) + snapshot.match("get_provisioned_postwait", get_provisioned_postwait) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=1 + ) + snapshot.match("reserved_lower_than_provisioned_exc", e.value.response) + aws_client.lambda_.put_function_concurrency( + FunctionName=func_name, ReservedConcurrentExecutions=2 + ) + get_concurrency = aws_client.lambda_.get_function_concurrency(FunctionName=func_name) + snapshot.match("get_concurrency", get_concurrency) + + # absolute limit, this means there is no free function execution for any invoke that doesn't have provisioned concurrency (!) + with pytest.raises(aws_client.lambda_.exceptions.TooManyRequestsException) as e: + aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("reserved_equals_provisioned_latest_invoke_exc", e.value.response) + + # passes since the version has a provisioned concurrency config set + invoke_result1 = aws_client.lambda_.invoke(FunctionName=func_name, Qualifier=v1["Version"]) + result1 = json.load(invoke_result1["Payload"]) + assert result1 == "provisioned-concurrency" + + # try to add a new provisioned concurrency config to another qualifier on the same function + update_func_config = aws_client.lambda_.update_function_configuration( + FunctionName=func_name, Timeout=15 + ) + snapshot.match("update_func_config", update_func_config) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=func_name) + + v2 = aws_client.lambda_.publish_version(FunctionName=func_name) + assert v1["Version"] != v2["Version"] + # doesn't work because the reserved function concurrency is 2 and we already have a total provisioned sum of 2 + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v2["Version"], ProvisionedConcurrentExecutions=1 + ) + snapshot.match("reserved_equals_provisioned_another_provisioned_exc", e.value.response) + + # updating the provisioned concurrency config of v1 to 3 (from 2) should also not work + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=func_name, Qualifier=v1["Version"], ProvisionedConcurrentExecutions=3 + ) + snapshot.match("reserved_equals_provisioned_increase_provisioned_exc", e.value.response) + + +class TestLambdaVersions: + @markers.aws.validated + def test_lambda_versions_with_code_changes( + self, lambda_su_role, create_lambda_function_aws, snapshot, aws_client + ): + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + function_name = f"fn-{short_uid()}" + zip_file_v1 = create_lambda_archive( + load_file(TEST_LAMBDA_VERSION) % "version1", get_content=True + ) + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="handler.handler", + Code={"ZipFile": zip_file_v1}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Description="No version :(", + ) + snapshot.match("create_response", create_response) + first_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="First version description :)" + ) + snapshot.match("first_publish_response", first_publish_response) + zip_file_v2 = create_lambda_archive( + load_file(TEST_LAMBDA_VERSION) % "version2", get_content=True + ) + update_lambda_response = aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=zip_file_v2 + ) + snapshot.match("update_lambda_response", update_lambda_response) + waiter.wait(FunctionName=function_name) + invocation_result_latest = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=b"{}" + ) + snapshot.match("invocation_result_latest", invocation_result_latest) + invocation_result_v1 = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=first_publish_response["Version"], Payload=b"{}" + ) + snapshot.match("invocation_result_v1", invocation_result_v1) + second_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="Second version description :)" + ) + snapshot.match("second_publish_response", second_publish_response) + waiter.wait(FunctionName=function_name, Qualifier=second_publish_response["Version"]) + invocation_result_v2 = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=second_publish_response["Version"], Payload=b"{}" + ) + snapshot.match("invocation_result_v2", invocation_result_v2) + zip_file_v3 = create_lambda_archive( + load_file(TEST_LAMBDA_VERSION) % "version3", get_content=True + ) + update_lambda_response_with_publish = aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=zip_file_v3, Publish=True + ) + snapshot.match("update_lambda_response_with_publish", update_lambda_response_with_publish) + waiter.wait( + FunctionName=function_name, Qualifier=update_lambda_response_with_publish["Version"] + ) + invocation_result_v3 = aws_client.lambda_.invoke( + FunctionName=function_name, + Qualifier=update_lambda_response_with_publish["Version"], + Payload=b"{}", + ) + snapshot.match("invocation_result_v3", invocation_result_v3) + invocation_result_latest_end = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=b"{}" + ) + snapshot.match("invocation_result_latest_end", invocation_result_latest_end) + invocation_result_v2 = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=second_publish_response["Version"], Payload=b"{}" + ) + snapshot.match("invocation_result_v2_end", invocation_result_v2) + invocation_result_v1 = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=first_publish_response["Version"], Payload=b"{}" + ) + snapshot.match("invocation_result_v1_end", invocation_result_v1) + + @markers.aws.validated + def test_lambda_handler_update(self, aws_client, create_lambda_function, snapshot): + func_name = f"test_lambda_{short_uid()}" + create_lambda_function( + func_name=func_name, + # handler.handler by convention + handler_file=TEST_LAMBDA_PYTHON_MULTIPLE_HANDLERS, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + invoke_result_handler_one = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("invoke_result_handler_one", invoke_result_handler_one) + + update_function_configuration_result = aws_client.lambda_.update_function_configuration( + FunctionName=func_name, Handler="handler.handler_two" + ) + snapshot.match("update_function_configuration_result", update_function_configuration_result) + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + waiter.wait(FunctionName=func_name) + + get_function_result = aws_client.lambda_.get_function(FunctionName=func_name) + snapshot.match("get_function_result", get_function_result) + + invoke_result_handler_two = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("invoke_result_handler_two", invoke_result_handler_two) + + publish_version_result = aws_client.lambda_.publish_version(FunctionName=func_name) + waiter.wait(FunctionName=func_name, Qualifier=publish_version_result["Version"]) + + invoke_result_handler_two_postpublish = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match( + "invoke_result_handler_two_postpublish", invoke_result_handler_two_postpublish + ) + + # TODO: Fix this test by not stopping running invokes for function updates of $LATEST + @pytest.mark.skip( + reason="""Fails with 'Internal error while executing lambda' because + the current implementation stops all running invokes upon update.""" + ) + @markers.aws.validated + def test_function_update_during_invoke(self, aws_client, create_lambda_function, snapshot): + function_name = f"test-function-{short_uid()}" + environment_v1 = {"Variables": {"FUNCTION_VARIANT": "variant-1"}} + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION_FUNCTION_VERSION, + runtime=Runtime.python3_12, + Environment=environment_v1, + ) + + errored = False + + def _update_function(): + nonlocal errored + try: + # Make it very likely that the invocation is being processed because the incoming invocation acquires + # an invocation lease quickly. + time.sleep(5) + + environment_v2 = environment_v1.copy() + environment_v2["Variables"]["FUNCTION_VARIANT"] = "variant-2" + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Environment=environment_v2 + ) + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + waiter.wait(FunctionName=function_name) + + payload = {"request_prefix": "2-post-update"} + invoke_response_after = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=json.dumps(payload), + ) + assert invoke_response_after["StatusCode"] == 200 + payload = json.load(invoke_response_after["Payload"]) + assert payload["function_variant"] == "variant-2" + assert payload["function_version"] == "$LATEST" + except Exception: + LOG.exception("Updating lambda function %s failed.", function_name) + errored = True + + # Start thread with upcoming function update (slightly delayed) + thread = threading.Thread(target=_update_function) + thread.start() + + # Start an invocation with a sleep + payload = {"request_prefix": "1-sleep", "sleep_seconds": 20} + invoke_response_before = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=json.dumps(payload), + ) + snapshot.match("invoke_response_before", invoke_response_before) + + thread.join() + assert not errored + + # TODO: Fix first invoke getting retried and ending up being executed against the new variant because the + # update stops the running function version. We should let running executions finish for $LATEST in this case. + # MAYBE: consider validating whether a code update behaves differently than a configuration update + @markers.aws.validated + def test_async_invoke_queue_upon_function_update( + self, aws_client, create_lambda_function, s3_create_bucket, snapshot + ): + """Test what happens with queued async invokes (i.e., event invokes) when updating a function. + We are using a combination of reserved concurrency and sleeps to design this test case predictable. + Observation: If we don't wait after sending the first invoke, some queued invokes can still be handled by an + old variant in some non-deterministic way. + """ + # HACK: workaround to ignore `$..async_invoke_history_sorted[0]` because indices don't work in the ignore list + snapshot.add_transformer( + snapshot.transform.regex("01-sleep--variant-2", "01-sleep--variant-1") + ) + bucket_name = f"lambda-target-bucket-{short_uid()}" + s3_create_bucket(Bucket=bucket_name) + + function_name = f"test-function-{short_uid()}" + environment_v1 = { + "Variables": {"S3_BUCKET_NAME": bucket_name, "FUNCTION_VARIANT": "variant-1"} + } + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION_FUNCTION_VERSION, + runtime=Runtime.python3_12, + Environment=environment_v1, + ) + # Add reserved concurrency limits the throughput and makes it easier to cause event invokes to queue up. + reserved_concurrency_response = aws_client.lambda_.put_function_concurrency( + FunctionName=function_name, + ReservedConcurrentExecutions=1, + ) + assert reserved_concurrency_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + payload = {"request_prefix": f"{1:02}-sleep", "sleep_seconds": 22} + aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=json.dumps(payload), + ) + # Make it very likely that the invocation is being processed because the Lambda poller should pick up queued + # async invokes quickly using long polling. + time.sleep(2) + + # Send async invocation, which should queue up before we update the function + num_invocations_before = 9 + for index in range(num_invocations_before): + payload = {"request_prefix": f"{index + 2:02}-before"} + aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=json.dumps(payload), + ) + + # Update the function variant while still having invokes in the async invoke queue + environment_v2 = environment_v1.copy() + environment_v2["Variables"]["FUNCTION_VARIANT"] = "variant-2" + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Environment=environment_v2 + ) + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + waiter.wait(FunctionName=function_name) + + # Send further async invocations after the update succeeded + num_invocations_after = 5 + for index in range(num_invocations_after): + payload = {"request_prefix": f"{index + num_invocations_before + 2:02}-after"} + aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType="Event", + Payload=json.dumps(payload), + ) + + # +1 for the first sleep invocation + total_invocations = 1 + num_invocations_before + num_invocations_after + + def assert_s3_objects(): + s3_keys_output = get_s3_keys(aws_client, bucket_name) + assert len(s3_keys_output) >= total_invocations + return s3_keys_output + + s3_keys = retry(assert_s3_objects, retries=20, sleep=5) + s3_keys_sorted = sorted(s3_keys) + snapshot.match("async_invoke_history_sorted", s3_keys_sorted) + + +# TODO: test if routing is static for a single invocation: +# Do retries for an event invoke, take the same "path" for every retry? +class TestLambdaAliases: + @markers.aws.validated + def test_lambda_alias_moving( + self, lambda_su_role, create_lambda_function_aws, snapshot, aws_client + ): + """Check if alias only moves after it is updated""" + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + function_name = f"fn-{short_uid()}" + zip_file_v1 = create_lambda_archive( + load_file(TEST_LAMBDA_VERSION) % "version1", get_content=True + ) + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="handler.handler", + Code={"ZipFile": zip_file_v1}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Description="No version :(", + ) + snapshot.match("create_response", create_response) + first_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="First version description :)" + ) + waiter.wait(FunctionName=function_name, Qualifier=first_publish_response["Version"]) + # create alias + create_alias_response = aws_client.lambda_.create_alias( + FunctionName=function_name, + FunctionVersion=first_publish_response["Version"], + Name="alias1", + ) + snapshot.match("create_alias_response", create_alias_response) + invocation_result_qualifier_v1 = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=create_alias_response["Name"], Payload=b"{}" + ) + snapshot.match("invocation_result_qualifier_v1", invocation_result_qualifier_v1) + invocation_result_qualifier_v1_arn = aws_client.lambda_.invoke( + FunctionName=create_alias_response["AliasArn"], Payload=b"{}" + ) + snapshot.match("invocation_result_qualifier_v1_arn", invocation_result_qualifier_v1_arn) + zip_file_v2 = create_lambda_archive( + load_file(TEST_LAMBDA_VERSION) % "version2", get_content=True + ) + # update lambda code + update_lambda_response = aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=zip_file_v2 + ) + snapshot.match("update_lambda_response", update_lambda_response) + waiter.wait(FunctionName=function_name) + # check if alias is still first version + invocation_result_qualifier_v1_after_update = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=create_alias_response["Name"], Payload=b"{}" + ) + snapshot.match( + "invocation_result_qualifier_v1_after_update", + invocation_result_qualifier_v1_after_update, + ) + # publish to 2 + second_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="Second version description :)" + ) + snapshot.match("second_publish_response", second_publish_response) + waiter.wait(FunctionName=function_name, Qualifier=second_publish_response["Version"]) + # check if invoke still targets 1 + invocation_result_qualifier_v1_after_publish = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=create_alias_response["Name"], Payload=b"{}" + ) + snapshot.match( + "invocation_result_qualifier_v1_after_publish", + invocation_result_qualifier_v1_after_publish, + ) + # move alias to 2 + update_alias_response = aws_client.lambda_.update_alias( + FunctionName=function_name, + Name=create_alias_response["Name"], + FunctionVersion=second_publish_response["Version"], + ) + snapshot.match("update_alias_response", update_alias_response) + # check if alias moved to 2 + invocation_result_qualifier_v2 = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=create_alias_response["Name"], Payload=b"{}" + ) + snapshot.match("invocation_result_qualifier_v2", invocation_result_qualifier_v2) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier="non-existent-alias", Payload=b"{}" + ) + snapshot.match("invocation_exc_not_existent", e.value.response) + + @markers.aws.validated + def test_alias_routingconfig( + self, lambda_su_role, create_lambda_function_aws, snapshot, aws_client + ): + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + function_name = f"fn-{short_uid()}" + zip_file_v1 = create_lambda_archive( + load_file(TEST_LAMBDA_VERSION) % "version1", get_content=True + ) + create_function_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="handler.handler", + Code={"ZipFile": zip_file_v1}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Description="First version :)", + Publish=True, + ) + waiter.wait(FunctionName=function_name) + zip_file_v2 = create_lambda_archive( + load_file(TEST_LAMBDA_VERSION) % "version2", get_content=True + ) + # update lambda code + aws_client.lambda_.update_function_code(FunctionName=function_name, ZipFile=zip_file_v2) + waiter.wait(FunctionName=function_name) + + second_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="Second version description :)" + ) + waiter.wait(FunctionName=function_name, Qualifier=second_publish_response["Version"]) + # create alias + create_alias_response = aws_client.lambda_.create_alias( + FunctionName=function_name, + FunctionVersion=create_function_response["Version"], + Name="alias1", + RoutingConfig={"AdditionalVersionWeights": {second_publish_response["Version"]: 0.5}}, + ) + snapshot.match("create_alias_response", create_alias_response) + retries = 0 + max_retries = 20 + versions_hit = set() + while len(versions_hit) < 2 and retries < max_retries: + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, Qualifier=create_alias_response["Name"], Payload=b"{}" + ) + payload = json.load(invoke_response["Payload"]) + versions_hit.add(payload["version_from_ctx"]) + retries += 1 + assert len(versions_hit) == 2, f"Did not hit both versions after {max_retries} retries" + + +class TestRequestIdHandling: + @markers.aws.validated + def test_request_id_format(self, aws_client): + r = aws_client.lambda_.list_functions() + request_id = r["ResponseMetadata"]["RequestId"] + assert re.match( + r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", request_id + ) + + # TODO remove, currently missing init duration in REPORT + @markers.snapshot.skip_snapshot_verify(paths=["$..logs"]) + @markers.aws.validated + def test_request_id_invoke(self, aws_client, create_lambda_function, snapshot): + """Test that the request_id within the Lambda context matches with CloudWatch logs.""" + func_name = f"test_lambda_{short_uid()}" + log_group_name = f"/aws/lambda/{func_name}" + + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_PYTHON_REQUEST_ID, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + result = aws_client.lambda_.invoke(FunctionName=func_name) + snapshot.match("invoke_result", result) + snapshot.add_transformer( + snapshot.transform.regex(result["ResponseMetadata"]["RequestId"], "") + ) + + def fetch_logs(): + log_events_result = aws_client.logs.filter_log_events(logGroupName=log_group_name) + assert any("REPORT" in e["message"] for e in log_events_result["events"]) + return log_events_result + + log_events = retry(fetch_logs, retries=10, sleep=2) + log_entries = [ + line["message"].rstrip() + for line in log_events["events"] + if "RequestId" in line["message"] + ] + snapshot.match("log_entries", {"logs": log_entries}) + snapshot.add_transformer(snapshot.transform.lambda_report_logs()) + + @markers.aws.validated + def test_request_id_invoke_url(self, aws_client, create_lambda_function, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value( + "FunctionUrl", "", reference_replacement=False + ) + ) + + fn_name = f"test-url-fn{short_uid()}" + log_group_name = f"/aws/lambda/{fn_name}" + + handler_file = files.new_tmp_file() + handler_code = URL_HANDLER_CODE.replace("<>", "'hi'") + files.save_file(handler_file, handler_code) + + create_lambda_function( + func_name=fn_name, + handler_file=handler_file, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + url_config = aws_client.lambda_.create_function_url_config( + FunctionName=fn_name, + AuthType="NONE", + ) + snapshot.match("create_lambda_url_config", url_config) + + permissions_response = aws_client.lambda_.add_permission( + FunctionName=fn_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + snapshot.match("add_permission", permissions_response) + + url = f"{url_config['FunctionUrl']}custom_path/extend?test_param=test_value" + result = safe_requests.post(url, data=b"{'key':'value'}") + snapshot.match( + "lambda_url_invocation", + { + "statuscode": result.status_code, + "headers": {"x-amzn-RequestId": result.headers.get("x-amzn-RequestId")}, + "content": to_str(result.content), + }, + ) + + def fetch_logs(): + log_events_result = aws_client.logs.filter_log_events(logGroupName=log_group_name) + assert any("REPORT" in e["message"] for e in log_events_result["events"]) + return log_events_result + + log_events = retry(fetch_logs, retries=10, sleep=2) + # TODO: AWS appends a "\n" so we need to trim here. Should explore this more + end_log_entries = [ + e["message"].rstrip() for e in log_events["events"] if e["message"].startswith("END") + ] + snapshot.match("end_log_entries", end_log_entries) + + @markers.aws.validated + def test_request_id_async_invoke_with_retry( + self, aws_client, create_lambda_function, monkeypatch, snapshot + ): + snapshot.add_transformer( + snapshot.transform.key_value("eventId", "", reference_replacement=False) + ) + test_delay_base = 60 + if not is_aws_cloud(): + test_delay_base = 5 + monkeypatch.setattr(config, "LAMBDA_RETRY_BASE_DELAY_SECONDS", test_delay_base) + + func_name = f"test_lambda_{short_uid()}" + log_group_name = f"/aws/lambda/{func_name}" + create_lambda_function( + func_name=func_name, + handler_file=TEST_LAMBDA_CONTEXT_REQID, + runtime=Runtime.python3_12, + client=aws_client.lambda_, + ) + + aws_client.lambda_.put_function_event_invoke_config( + FunctionName=func_name, MaximumRetryAttempts=1 + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=func_name) + + result = aws_client.lambda_.invoke( + FunctionName=func_name, InvocationType="Event", Payload=json.dumps({"fail": 1}) + ) + snapshot.match("invoke_result", result) + + request_id = result["ResponseMetadata"]["RequestId"] + snapshot.add_transformer(snapshot.transform.regex(request_id, "")) + + time.sleep(test_delay_base * 2) + + log_events = aws_client.logs.filter_log_events(logGroupName=log_group_name) + report_messages = [e for e in log_events["events"] if "REPORT" in e["message"]] + assert len(report_messages) == 2 + assert all(request_id in rm["message"] for rm in report_messages) + end_messages = [ + e["message"].rstrip() for e in log_events["events"] if "END" in e["message"] + ] + snapshot.match("end_messages", end_messages) diff --git a/tests/aws/services/lambda_/test_lambda.snapshot.json b/tests/aws/services/lambda_/test_lambda.snapshot.json new file mode 100644 index 0000000000000..121d9b01ef397 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda.snapshot.json @@ -0,0 +1,4608 @@ +{ + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_basic_invoke": { + "recorded-date": "09-09-2022, 19:13:34", + "recorded-content": { + "lambda_create_fn": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_create_fn_2": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_get_fn": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "lambda_get_fn_2": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "lambda_invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_cache_local[nodejs]": { + "recorded-date": "08-04-2024, 16:55:57", + "recorded-content": { + "first_invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "counter": 0 + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "counter": 1 + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_cache_local[python]": { + "recorded-date": "08-04-2024, 16:56:00", + "recorded-content": { + "first_invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "counter": 0 + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "counter": 1 + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_with_timeout": { + "recorded-date": "08-04-2024, 16:56:05", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 1, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "date Task timed out after 1.00 seconds" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_no_timeout": { + "recorded-date": "08-04-2024, 16:56:14", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 2, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": "null", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_function_state": { + "recorded-date": "08-04-2024, 16:55:21", + "recorded-content": { + "create-fn-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-fn-response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_logs[python3.10]": { + "recorded-date": "08-04-2024, 16:57:44", + "recorded-content": { + "invoke": { + "ExecutedVersion": "$LATEST", + "LogResult": "log-result", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logs": { + "logs": [ + "START RequestId: Version: $LATEST\n", + "{}\n", + "END RequestId: \n", + "REPORT RequestId: \tDuration: ms\tBilled Duration: ms\tMemory Size: 128 MB\tMax Memory Used: MB\tInit Duration: ms\t\n" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_logs[nodejs16.x]": { + "recorded-date": "08-04-2024, 16:57:41", + "recorded-content": { + "invoke": { + "ExecutedVersion": "$LATEST", + "LogResult": "log-result", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logs": { + "logs": [ + "START RequestId: Version: $LATEST\n", + "date\t\tINFO\t{}\n", + "END RequestId: \n", + "REPORT RequestId: \tDuration: ms\tBilled Duration: ms\tMemory Size: 128 MB\tMax Memory Used: MB\tInit Duration: ms\t\n" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_request_response[python3.9]": { + "recorded-date": "17-02-2023, 14:01:34", + "recorded-content": { + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_request_response[python3.10]": { + "recorded-date": "08-04-2024, 16:57:51", + "recorded-content": { + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_request_response[nodejs16.x]": { + "recorded-date": "08-04-2024, 16:57:48", + "recorded-content": { + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event[python3.9]": { + "recorded-date": "17-02-2023, 14:01:40", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event[python3.10]": { + "recorded-date": "08-04-2024, 16:58:09", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event[nodejs16.x]": { + "recorded-date": "08-04-2024, 16:57:59", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_dry_run[python3.9]": { + "recorded-date": "09-09-2022, 19:15:23", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 204, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_dry_run[python3.10]": { + "recorded-date": "26-04-2023, 19:37:23", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 204, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_dry_run[nodejs16.x]": { + "recorded-date": "09-09-2022, 19:15:38", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 204, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_lambda_environment": { + "recorded-date": "09-09-2022, 20:46:21", + "recorded-content": { + "creation-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "Hello": "World" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invocation-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "Hello": "World" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-configuration-result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "Hello": "World" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_qualifier": { + "recorded-date": "08-04-2024, 16:58:22", + "recorded-content": { + "creation-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "log_result": { + "log_result": [ + "START RequestId: Version: 1", + "Lambda log message - print function", + "[INFO]\tdate\t\tLambda log message - logging module", + "END RequestId: " + ] + }, + "invocation-response": { + "ExecutedVersion": "1", + "LogResult": "", + "Payload": { + "event": { + "foo": "bar with 'quotes\"" + }, + "context": { + "invoked_function_arn": "arn::lambda::111111111111:function::1", + "function_version": "1", + "function_name": "", + "memory_limit_in_mb": "128", + "aws_request_id": "", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "client_context": null + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_upload_lambda_from_s3": { + "recorded-date": "08-04-2024, 16:58:27", + "recorded-content": { + "creation-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation-response": { + "ExecutedVersion": "$LATEST", + "Payload": { + "event": { + "foo": "bar with 'quotes\"" + }, + "context": { + "invoked_function_arn": "arn::lambda::111111111111:function:", + "function_version": "$LATEST", + "function_name": "", + "memory_limit_in_mb": "128", + "aws_request_id": "", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "client_context": null + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_lambda_with_context": { + "recorded-date": "09-09-2022, 20:19:33", + "recorded-content": { + "creation": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_integration.handler", + "LastModified": "date", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs16.x", + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invocation": { + "ExecutedVersion": "$LATEST", + "Payload": { + "context": { + "callbackWaitsForEmptyEventLoop": true, + "functionVersion": "$LATEST", + "functionName": "", + "memoryLimitInMB": "128", + "logGroupName": "/aws/lambda/", + "logStreamName": "", + "clientContext": { + "custom": { + "foo": "bar" + }, + "client": { + "snap": [ + "crackle", + "pop" + ] + }, + "env": { + "fizz": "buzz" + } + }, + "invokedFunctionArn": "arn::lambda::111111111111:function:", + "awsRequestId": "" + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_block": { + "recorded-date": "08-04-2024, 17:02:07", + "recorded-content": { + "v1_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "reserved_concurrency_result": { + "ReservedConcurrentExecutions": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke_latest_before_block": { + "ExecutedVersion": "$LATEST", + "Payload": { + "hello": "world" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "provisioned_concurrency_result": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "invoke_latest_first_exc": { + "Error": { + "Code": "TooManyRequestsException", + "Message": "Rate Exceeded." + }, + "Reason": "ReservedFunctionConcurrentInvocationLimitExceeded", + "Type": "User", + "message": "Rate Exceeded.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 429 + } + }, + "invoke_v1_after_block": { + "ExecutedVersion": "1", + "Payload": { + "hello": "world" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke_latest_second_exc": { + "Error": { + "Code": "TooManyRequestsException", + "Message": "Rate Exceeded." + }, + "Reason": "ReservedFunctionConcurrentInvocationLimitExceeded", + "Type": "User", + "message": "Rate Exceeded.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 429 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_lambda_versions_with_code_changes": { + "recorded-date": "08-04-2024, 17:10:52", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "first_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "First version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_lambda_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_latest": { + "ExecutedVersion": "$LATEST", + "Payload": { + "version_id": "version2", + "invoked_arn": "arn::lambda::111111111111:function:", + "version_from_ctx": "$LATEST" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_v1": { + "ExecutedVersion": "1", + "Payload": { + "version_id": "version1", + "invoked_arn": "arn::lambda::111111111111:function::1", + "version_from_ctx": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "Second version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_v2": { + "ExecutedVersion": "2", + "Payload": { + "version_id": "version2", + "invoked_arn": "arn::lambda::111111111111:function::2", + "version_from_ctx": "2" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_lambda_response_with_publish": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::3", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_v3": { + "ExecutedVersion": "3", + "Payload": { + "version_id": "version3", + "invoked_arn": "arn::lambda::111111111111:function::3", + "version_from_ctx": "3" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_latest_end": { + "ExecutedVersion": "$LATEST", + "Payload": { + "version_id": "version3", + "invoked_arn": "arn::lambda::111111111111:function:", + "version_from_ctx": "$LATEST" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_v2_end": { + "ExecutedVersion": "2", + "Payload": { + "version_id": "version2", + "invoked_arn": "arn::lambda::111111111111:function::2", + "version_from_ctx": "2" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_v1_end": { + "ExecutedVersion": "1", + "Payload": { + "version_id": "version1", + "invoked_arn": "arn::lambda::111111111111:function::1", + "version_from_ctx": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaAliases::test_lambda_alias_moving": { + "recorded-date": "08-04-2024, 17:11:05", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_alias_response": { + "AliasArn": "arn::lambda::111111111111:function::alias1", + "Description": "", + "FunctionVersion": "1", + "Name": "alias1", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_qualifier_v1": { + "ExecutedVersion": "1", + "Payload": { + "version_id": "version1", + "invoked_arn": "arn::lambda::111111111111:function::alias1", + "version_from_ctx": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_qualifier_v1_arn": { + "ExecutedVersion": "1", + "Payload": { + "version_id": "version1", + "invoked_arn": "arn::lambda::111111111111:function::alias1", + "version_from_ctx": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_lambda_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_qualifier_v1_after_update": { + "ExecutedVersion": "1", + "Payload": { + "version_id": "version1", + "invoked_arn": "arn::lambda::111111111111:function::alias1", + "version_from_ctx": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "Second version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_qualifier_v1_after_publish": { + "ExecutedVersion": "1", + "Payload": { + "version_id": "version1", + "invoked_arn": "arn::lambda::111111111111:function::alias1", + "version_from_ctx": "1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_alias_response": { + "AliasArn": "arn::lambda::111111111111:function::alias1", + "Description": "", + "FunctionVersion": "2", + "Name": "alias1", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_result_qualifier_v2": { + "ExecutedVersion": "2", + "Payload": { + "version_id": "version2", + "invoked_arn": "arn::lambda::111111111111:function::alias1", + "version_from_ctx": "2" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invocation_exc_not_existent": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::non-existent-alias" + }, + "Message": "Function not found: arn::lambda::111111111111:function::non-existent-alias", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaAliases::test_alias_routingconfig": { + "recorded-date": "08-04-2024, 17:11:17", + "recorded-content": { + "create_alias_response": { + "AliasArn": "arn::lambda::111111111111:function:", + "Description": "", + "FunctionVersion": "1", + "Name": "alias1", + "RevisionId": "", + "RoutingConfig": { + "AdditionalVersionWeights": { + "2": 0.5 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_different_iam_keys_environment": { + "recorded-date": "08-04-2024, 16:55:38", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_provisioned_concurrency_moves_with_alias": { + "recorded-date": "21-03-2023, 08:47:38", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 2, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get-function-configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 2, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_version_1": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "my-first-version", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 2, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_configuration_version_1": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "my-first-version", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 2, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_alias": { + "AliasArn": "arn::lambda::111111111111:function::", + "Description": "", + "FunctionVersion": "1", + "Name": "", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_before_provisioned": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "my-first-version", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 2, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_after_provisioned": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "my-first-version", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 2, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_after_update": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_version_2": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_alias": { + "AliasArn": "arn::lambda::111111111111:function::", + "Description": "", + "FunctionVersion": "2", + "Name": "", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_provisioned_config_after_alias_move": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "provisioned_concurrency_notfound": { + "Error": { + "Code": "ProvisionedConcurrencyConfigNotFoundException", + "Message": "No Provisioned Concurrency Config found for this function" + }, + "Type": "User", + "message": "No Provisioned Concurrency Config found for this function", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event_error": { + "recorded-date": "04-09-2023, 22:49:02", + "recorded-content": { + "creation_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invocation_response": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "log_events": [ + { + "timestamp": "timestamp", + "message": "INIT_START Runtime Version: python:3.10.v11\tRuntime Version ARN: arn::lambda:::runtime:\n", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "START RequestId: Version: $LATEST\n", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "[ERROR] CustomException: some error occurred\nTraceback (most recent call last):\n\u00a0\u00a0File \"/var/task/handler.py\", line 6, in handler\n\u00a0\u00a0\u00a0\u00a0raise CustomException(\"some error occurred\")", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "END RequestId: \n", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "REPORT RequestId: \tDuration: 3.01 ms\tBilled Duration: 4 ms\tMemory Size: 128 MB\tMax Memory Used: 36 MB\tInit Duration: 110.10 ms\t\n", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "START RequestId: Version: $LATEST\n", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "[ERROR] CustomException: some error occurred\nTraceback (most recent call last):\n\u00a0\u00a0File \"/var/task/handler.py\", line 6, in handler\n\u00a0\u00a0\u00a0\u00a0raise CustomException(\"some error occurred\")", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "END RequestId: \n", + "ingestionTime": "timestamp" + }, + { + "timestamp": "timestamp", + "message": "REPORT RequestId: \tDuration: 2.90 ms\tBilled Duration: 3 ms\tMemory Size: 128 MB\tMax Memory Used: 36 MB\t\n", + "ingestionTime": "timestamp" + } + ] + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_timed_out_environment_reuse": { + "recorded-date": "08-04-2024, 16:56:22", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 1, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result-file-write": { + "ExecutedVersion": "$LATEST", + "Payload": "null", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke-result-file-read": { + "ExecutedVersion": "$LATEST", + "Payload": { + "content": "some-content" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke-result-set-number": { + "ExecutedVersion": "$LATEST", + "Payload": "null", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke-result-read-number": { + "ExecutedVersion": "$LATEST", + "Payload": { + "number": 42 + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke-result-timed-out": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "date Task timed out after 1.00 seconds" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke-result-file-read-after-timeout": { + "ExecutedVersion": "$LATEST", + "Payload": { + "content": "some-content" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke-result-read-number-after-timeout": { + "ExecutedVersion": "$LATEST", + "Payload": { + "number": 0 + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_exception": { + "recorded-date": "08-04-2024, 16:57:23", + "recorded-content": { + "get_fn_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "function-url", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_crud": { + "recorded-date": "22-05-2025, 08:04:13", + "recorded-content": { + "get_function_concurrency_default": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_function_concurrency": { + "ReservedConcurrentExecutions": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_concurrency_updated": { + "ReservedConcurrentExecutions": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_concurrency_deleted": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaPermissions::test_lambda_permission_url_invocation": { + "recorded-date": "08-04-2024, 16:57:38", + "recorded-content": { + "lambda_url_invocation_missing_permission": { + "Message": "Forbidden" + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[dict]": { + "recorded-date": "08-04-2024, 16:56:29", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": { + "hello": "world" + }, + "headers": { + "Content-Length": "17", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[http-response]": { + "recorded-date": "08-04-2024, 16:56:33", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": "body123", + "headers": { + "Content-Length": "7", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[http-response-json]": { + "recorded-date": "08-04-2024, 16:56:37", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": { + "hello": "world" + }, + "headers": { + "Content-Length": "18", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[list-mixed]": { + "recorded-date": "08-04-2024, 16:56:41", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": "[\"hello\",3,true]", + "headers": { + "Content-Length": "16", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[string]": { + "recorded-date": "08-04-2024, 16:56:45", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": "hello", + "headers": { + "Content-Length": "5", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[integer]": { + "recorded-date": "08-04-2024, 16:56:49", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": "3", + "headers": { + "Content-Length": "1", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[float]": { + "recorded-date": "08-04-2024, 16:56:53", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": "3.1", + "headers": { + "Content-Length": "3", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[boolean]": { + "recorded-date": "08-04-2024, 16:56:57", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": "true", + "headers": { + "Content-Length": "4", + "Content-Type": "application/json" + }, + "statuscode": 200 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_introspection_arm": { + "recorded-date": "08-04-2024, 16:55:44", + "recorded-content": { + "invoke_runtime_arm_introspection": { + "ExecutedVersion": "$LATEST", + "Payload": { + "event": {}, + "user_login_name": "sbx_user1051", + "user_whoami": "sbx_user1051", + "platform_system": "Linux", + "platform_machine": "aarch64", + "pwd": "/var/task", + "paths": { + "_var_task_mode": "drwxr-xr-x", + "_var_task_uid": 998, + "_var_task_owner": "slicer", + "_var_task_gid": 995, + "_opt_mode": "drwxr-xr-x", + "_opt_uid": 0, + "_opt_owner": "root", + "_opt_gid": 0, + "_tmp_mode": "drwx------", + "_tmp_uid": 993, + "_tmp_owner": "sbx_user1051", + "_tmp_gid": 990, + "_lambda-entrypoint.sh_mode": "-rwxr-xr-x", + "_lambda-entrypoint.sh_uid": 0, + "_lambda-entrypoint.sh_owner": "root", + "_lambda-entrypoint.sh_gid": 0 + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_introspection_x86": { + "recorded-date": "08-04-2024, 16:55:41", + "recorded-content": { + "invoke_runtime_x86_introspection": { + "ExecutedVersion": "$LATEST", + "Payload": { + "event": {}, + "user_login_name": "sbx_user1051", + "user_whoami": "sbx_user1051", + "platform_system": "Linux", + "platform_machine": "x86_64", + "pwd": "/var/task", + "paths": { + "_var_task_mode": "drwxr-xr-x", + "_var_task_uid": 998, + "_var_task_owner": "slicer", + "_var_task_gid": 995, + "_opt_mode": "drwxr-xr-x", + "_opt_uid": 0, + "_opt_owner": "root", + "_opt_gid": 0, + "_tmp_mode": "drwx------", + "_tmp_uid": 993, + "_tmp_owner": "sbx_user1051", + "_tmp_gid": 990, + "_lambda-entrypoint.sh_mode": "-rwxr-xr-x", + "_lambda-entrypoint.sh_uid": 0, + "_lambda-entrypoint.sh_owner": "root", + "_lambda-entrypoint.sh_gid": 0 + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_ulimits": { + "recorded-date": "16-04-2024, 08:12:12", + "recorded-content": { + "invoke_runtime_ulimits": { + "ExecutedVersion": "$LATEST", + "Payload": { + "RLIMIT_AS": [ + -1, + -1 + ], + "RLIMIT_CORE": [ + -1, + -1 + ], + "RLIMIT_CPU": [ + -1, + -1 + ], + "RLIMIT_DATA": [ + -1, + -1 + ], + "RLIMIT_FSIZE": [ + -1, + -1 + ], + "RLIMIT_MEMLOCK": [ + 65536, + 65536 + ], + "RLIMIT_NOFILE": [ + 1024, + 1024 + ], + "RLIMIT_NPROC": [ + 742, + 742 + ], + "RLIMIT_RSS": [ + -1, + -1 + ], + "RLIMIT_STACK": [ + 8388608, + -1 + ] + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_concurrency": { + "recorded-date": "08-04-2024, 17:08:11", + "recorded-content": { + "fn": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 20, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_reserved": { + "ReservedConcurrentExecutions": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exc_no_cap_requestresponse": { + "Error": { + "Code": "TooManyRequestsException", + "Message": "Rate Exceeded." + }, + "Reason": "ReservedFunctionConcurrentInvocationLimitExceeded", + "Type": "User", + "message": "Rate Exceeded.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 429 + } + }, + "put_event_invoke_conf": { + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + }, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke_result": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "msg": { + "Attributes": { + "AWSTraceHeader": "Root=1-6614247a-4513533344453f9a2d077845;Parent=282ed520d6ca75c8;Sampled=0", + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "ZeroReservedConcurrency", + "approximateInvokeCount": 0 + }, + "requestPayload": {} + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_concurrency_async_queue": { + "recorded-date": "26-03-2025, 10:53:54", + "recorded-content": { + "fn": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_fn_concurrency": { + "ReservedConcurrentExecutions": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "too_many_requests_exc": { + "Error": { + "Code": "TooManyRequestsException", + "Message": "Rate Exceeded." + }, + "Reason": "ReservedFunctionConcurrentInvocationLimitExceeded", + "Type": "User", + "message": "Rate Exceeded.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 429 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_provisioned_concurrency": { + "recorded-date": "08-04-2024, 17:04:21", + "recorded-content": { + "put_provisioned_5": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 5, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get_provisioned_prewait": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 5, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_provisioned_postwait": { + "AllocatedProvisionedConcurrentExecutions": 5, + "AvailableProvisionedConcurrentExecutions": 5, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 5, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_provisioned_overlap": { + "recorded-date": "08-04-2024, 17:10:37", + "recorded-content": { + "put_provisioned_5": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 2, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get_provisioned_prewait": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 2, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_provisioned_postwait": { + "AllocatedProvisionedConcurrentExecutions": 2, + "AvailableProvisionedConcurrentExecutions": 2, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 2, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "reserved_lower_than_provisioned_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": " ReservedConcurrentExecutions 1 should not be lower than function's total provisioned concurrency [2]." + }, + "message": " ReservedConcurrentExecutions 1 should not be lower than function's total provisioned concurrency [2].", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_concurrency": { + "ReservedConcurrentExecutions": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "reserved_equals_provisioned_latest_invoke_exc": { + "Error": { + "Code": "TooManyRequestsException", + "Message": "Rate Exceeded." + }, + "Reason": "ReservedFunctionConcurrentInvocationLimitExceeded", + "Type": "User", + "message": "Rate Exceeded.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 429 + } + }, + "update_func_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 15, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "reserved_equals_provisioned_another_provisioned_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Requested Provisioned Concurrency should not be greater than the reservedConcurrentExecution for function" + }, + "Type": "User", + "message": "Requested Provisioned Concurrency should not be greater than the reservedConcurrentExecution for function", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "reserved_equals_provisioned_increase_provisioned_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Requested Provisioned Concurrency should not be greater than the reservedConcurrentExecution for function" + }, + "Type": "User", + "message": "Requested Provisioned Concurrency should not be greater than the reservedConcurrentExecution for function", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_format": { + "recorded-date": "08-04-2024, 17:11:17", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_invoke": { + "recorded-date": "08-04-2024, 17:11:26", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": "\"\"", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "log_entries": { + "logs": [ + "START RequestId: Version: $LATEST", + "[INFO]\tdate\t\tRequestId log message", + "END RequestId: ", + "REPORT RequestId: \tDuration: ms\tBilled Duration: ms\tMemory Size: 128 MB\tMax Memory Used: MB\tInit Duration: ms" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_async_invoke_with_retry": { + "recorded-date": "08-04-2024, 17:13:39", + "recorded-content": { + "invoke_result": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "end_messages": [ + "END RequestId: ", + "END RequestId: " + ] + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_invoke_url": { + "recorded-date": "08-04-2024, 17:11:35", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "lambda_url_invocation": { + "content": "hi", + "headers": { + "x-amzn-RequestId": "" + }, + "statuscode": 200 + }, + "end_log_entries": [ + "END RequestId: " + ] + } + }, + "tests/aws/lambda_/test_lambda.py::TestLambdaFeatures::test_invoke_exceptions": { + "recorded-date": "11-08-2023, 15:57:21", + "recorded-content": { + "invoke_function_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_error": { + "recorded-date": "24-02-2025, 16:26:37", + "recorded-content": { + "invocation_error": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Runtime startup fails", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/lang/lib/python3.13/importlib/__init__.py\", line 88, in import_module\n return _bootstrap._gcd_import(name[level:], package, level)\n", + " File \"\", line 1387, in _gcd_import\n", + " File \"\", line 1360, in _find_and_load\n", + " File \"\", line 1331, in _find_and_load_unlocked\n", + " File \"\", line 935, in _load_unlocked\n", + " File \"\", line 1022, in exec_module\n", + " File \"\", line 488, in _call_with_frames_removed\n", + " File \"/var/task/lambda_runtime_error.py\", line 1, in \n raise Exception(\"Runtime startup fails\")\n" + ] + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invoke_exceptions": { + "recorded-date": "08-04-2024, 16:57:45", + "recorded-content": { + "invoke_function_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_wrapper_not_found": { + "recorded-date": "08-04-2024, 16:59:29", + "recorded-content": { + "invocation_error": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Runtime.ExitError", + "errorMessage": "RequestId: Error: Runtime exited with error: exit status 127" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_exit": { + "recorded-date": "08-04-2024, 16:58:35", + "recorded-content": { + "invocation_error": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Runtime.ExitError", + "errorMessage": "RequestId: Error: Runtime exited without providing a reason" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_handler_exit": { + "recorded-date": "08-04-2024, 16:59:26", + "recorded-content": { + "invocation_error": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Runtime.ExitError", + "errorMessage": "RequestId: Error: Runtime exited without providing a reason" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_handler_error": { + "recorded-date": "08-04-2024, 16:59:23", + "recorded-content": { + "invocation_error": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Handler fails", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/lambda_handler_error.py\", line 2, in handler\n raise Exception(\"Handler fails\")\n" + ] + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_exit_segfault": { + "recorded-date": "08-04-2024, 16:59:20", + "recorded-content": { + "invocation_error": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "date Task timed out after 30.13 seconds" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_invoke_payload_encoding_error[body-n\\x87r\\x9e\\xe9\\xb5\\xd7I\\xee\\x9bmt]": { + "recorded-date": "08-04-2024, 16:59:32", + "recorded-content": { + "invoke_function_invalid_payload_body": { + "Error": { + "Code": "InvalidRequestContentException", + "Message": "Could not parse request body into json: Could not parse payload into json: Invalid UTF-8 start byte 0x87\n at [Source: (byte[])\"n\ufffdr\ufffd\ufffd\ufffdI\ufffdmt\"; line: 1, column: 3]" + }, + "Type": "User", + "message": "Could not parse request body into json: Could not parse payload into json: Invalid UTF-8 start byte 0x87\n at [Source: (byte[])\"n\ufffdr\ufffd\ufffd\ufffdI\ufffdmt\"; line: 1, column: 3]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_invoke_payload_encoding_error[message-\\x99\\xeb,j\\x07\\xa1zYh]": { + "recorded-date": "08-04-2024, 16:59:35", + "recorded-content": { + "invoke_function_invalid_payload_message": { + "Error": { + "Code": "InvalidRequestContentException", + "Message": "Could not parse request body into json: Could not parse payload into json: Unexpected character ((CTRL-CHAR, code 153)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (byte[])\"\ufffd\ufffd,j\u0007\ufffdzYh\"; line: 1, column: 2]" + }, + "Type": "User", + "message": "Could not parse request body into json: Could not parse payload into json: Unexpected character ((CTRL-CHAR, code 153)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (byte[])\"\ufffd\ufffd,j\u0007\ufffdzYh\"; line: 1, column: 2]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_assume_role[1]": { + "recorded-date": "08-04-2024, 16:55:26", + "recorded-content": { + "invoke-result-assumed-role-arn": "arn::sts::111111111111:assumed-role/lambda-autogenerated-/@lambda_function" + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_assume_role[2]": { + "recorded-date": "08-04-2024, 16:55:32", + "recorded-content": { + "invoke-result-assumed-role-arn": "arn::sts::111111111111:assumed-role/lambda-autogenerated-/" + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_large_payloads": { + "recorded-date": "08-04-2024, 16:55:07", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_mixed_architecture": { + "recorded-date": "15-05-2024, 12:55:53", + "recorded-content": { + "create_function_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "update_function_code_response": { + "Architectures": [ + "arm64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_provisioned_concurrency_scheduling": { + "recorded-date": "16-04-2024, 08:03:42", + "recorded-content": { + "get_provisioned_postwait": { + "AllocatedProvisionedConcurrentExecutions": 1, + "AvailableProvisionedConcurrentExecutions": 1, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_init_environment": { + "recorded-date": "08-04-2024, 16:56:25", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "lambda-init-inspection": { + "ExecutedVersion": "$LATEST", + "Payload": { + "environment": { + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "_LAMBDA_TELEMETRY_API_PASSPHRASE": "", + "LANG": "en_US.UTF-8", + "TZ": ":UTC", + "_HANDLER": "handler.handler", + "_LAMBDA_DIRECT_INVOKE_SOCKET": "9", + "_LAMBDA_CONTROL_SOCKET": "11", + "_LAMBDA_CONSOLE_SOCKET": "12", + "LAMBDA_TASK_ROOT": "/var/task", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "_LAMBDA_LOG_FD": "18", + "_LAMBDA_SB_ID": "0", + "_LAMBDA_SHARED_MEM_FD": "8", + "AWS_REGION": "", + "AWS_DEFAULT_REGION": "", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "128", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "_X_AMZN_TRACE_ID": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_rapid", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "_LAMBDA_RUNTIME_LOAD_TIME": "" + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_too_large_response": { + "recorded-date": "08-04-2024, 16:55:18", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Response payload size exceeded maximum allowed payload size (6291556 bytes).", + "errorType": "Function.ResponseSizeTooLarge" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke_result_2": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Response payload size exceeded maximum allowed payload size (6291556 bytes).", + "errorType": "Function.ResponseSizeTooLarge" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_lambda_handler_update": { + "recorded-date": "08-04-2024, 17:10:59", + "recorded-content": { + "invoke_result_handler_one": { + "ExecutedVersion": "$LATEST", + "Payload": { + "handler": "handler" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_function_configuration_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler_two", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler_two", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke_result_handler_two": { + "ExecutedVersion": "$LATEST", + "Payload": { + "handler": "handler_two" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke_result_handler_two_postpublish": { + "ExecutedVersion": "$LATEST", + "Payload": { + "handler": "handler_two" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invalid_invoke_mode": { + "recorded-date": "08-04-2024, 16:57:26", + "recorded-content": { + "invoke_function_invalid_invoke_type": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'UNKNOWN' at 'invokeMode' failed to satisfy constraint: Member must satisfy enum value set: [RESPONSE_STREAM, BUFFERED]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_update_function_url_config": { + "recorded-date": "08-04-2024, 16:57:18", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "modify_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "RESPONSE_STREAM", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_url_config_2": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "RESPONSE_STREAM", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "modify_lambda_url_config_none": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "RESPONSE_STREAM", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_url_config_3": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "RESPONSE_STREAM", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[RESPONSE_STREAM]": { + "recorded-date": "08-04-2024, 16:57:10", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "RESPONSE_STREAM", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[BUFFERED]": { + "recorded-date": "08-04-2024, 16:57:05", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[None]": { + "recorded-date": "08-04-2024, 16:57:02", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_http_fixture_default": { + "recorded-date": "18-07-2024, 14:18:08", + "recorded-content": { + "url_response": { + "args": { + "multiQuery": "foo,/bar", + "q": "query" + }, + "data": { + "foo": "bar" + }, + "domain": "", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "content-length": "14", + "content-type": "application/json", + "extra-headers": "With WeiRd CapS", + "host": "", + "user-agent": "test/echo", + "x-amzn-tls-cipher-suite": "", + "x-amzn-tls-version": "", + "x-amzn-trace-id": "", + "x-forwarded-for": "", + "x-forwarded-port": "", + "x-forwarded-proto": "" + }, + "method": "POST", + "origin": "", + "path": "/pa2%Fth/1" + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_http_fixture_trim_x_headers": { + "recorded-date": "08-04-2024, 16:57:34", + "recorded-content": { + "url_response": { + "args": { + "q": "query" + }, + "data": { + "foo": "bar" + }, + "domain": "", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "content-length": "14", + "content-type": "application/json", + "extra-headers": "With WeiRd CapS", + "host": "", + "user-agent": "test/echo" + }, + "method": "POST", + "origin": "", + "path": "/path/1" + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_large_response": { + "recorded-date": "08-04-2024, 16:55:12", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_headers_and_status": { + "recorded-date": "08-04-2024, 16:57:14", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaCleanup::test_recreate_function": { + "recorded-date": "15-05-2024, 10:16:45", + "recorded-content": { + "create_function_response_one": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_echo.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "create_function_response_two": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_echo.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_async_invoke_queue_upon_function_update": { + "recorded-date": "15-05-2024, 17:38:05", + "recorded-content": { + "async_invoke_history_sorted": [ + "01-sleep--variant-1", + "02-before--variant-2", + "03-before--variant-2", + "04-before--variant-2", + "05-before--variant-2", + "06-before--variant-2", + "07-before--variant-2", + "08-before--variant-2", + "09-before--variant-2", + "10-before--variant-2", + "11-after--variant-2", + "12-after--variant-2", + "13-after--variant-2", + "14-after--variant-2", + "15-after--variant-2" + ] + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_function_update_during_invoke": { + "recorded-date": "15-05-2024, 19:05:05", + "recorded-content": { + "invoke_response_before": { + "ExecutedVersion": "$LATEST", + "Payload": { + "function_version": "$LATEST", + "request_id": "", + "request_prefix": "1-sleep", + "function_variant": "variant-1" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_form_payload": { + "recorded-date": "13-06-2024, 21:01:16", + "recorded-content": { + "url_response": { + "args": {}, + "data": "DQotLTRlZmQxNTllYWUwYzRmNGUxMjVhNWE1MDllMDczZDg1DQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZvcm1maWVsZCINCg0Kbm90IGEgZmlsZSwganVzdCBhIGZpZWxkDQotLTRlZmQxNTllYWUwYzRmNGUxMjVhNWE1MDllMDczZDg1DQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZvbyI7IGZpbGVuYW1lPSJmb28iDQpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47DQoNCmJhcg0KDQotLTRlZmQxNTllYWUwYzRmNGUxMjVhNWE1MDllMDczZDg1LS0NCg==", + "domain": "", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "content-length": "286", + "content-type": "multipart/form-data; boundary=4efd159eae0c4f4e125a5a509e073d85", + "host": "", + "user-agent": "python/test-request" + }, + "method": "POST", + "origin": "", + "path": "/test/value" + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_cross_account_access": { + "recorded-date": "14-06-2024, 12:09:32", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_lambda_layer": { + "recorded-date": "14-06-2024, 15:16:46", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_function": { + "recorded-date": "14-06-2024, 15:16:50", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_function_configuration": { + "recorded-date": "14-06-2024, 15:16:53", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_list_versions_by_function": { + "recorded-date": "14-06-2024, 15:16:56", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_concurrency": { + "recorded-date": "14-06-2024, 15:16:59", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_alias": { + "recorded-date": "14-06-2024, 15:17:03", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_tags": { + "recorded-date": "14-06-2024, 15:17:07", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_invocation": { + "recorded-date": "14-06-2024, 15:17:10", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_publish_version": { + "recorded-date": "14-06-2024, 15:17:14", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_delete_function": { + "recorded-date": "14-06-2024, 15:17:17", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_custom_id": { + "recorded-date": "05-08-2024, 12:24:48", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_custom_id_aliased": { + "recorded-date": "05-08-2024, 12:48:07", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_persists_after_alias_delete": { + "recorded-date": "05-08-2024, 13:57:04", + "recorded-content": { + "create_lambda_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "", + "InvokeMode": "BUFFERED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invoke_aliased_url_response": { + "status_code": 200 + }, + "delete_alias_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "invoke_deleted_alias_url_response_delayed": { + "content": { + "Message": null + }, + "headers": { + "x-amzn-ErrorType": "AccessDeniedException" + }, + "status_code": 403 + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[python-RequestResponse]": { + "recorded-date": "09-10-2024, 16:15:57", + "recorded-content": { + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": "null", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[python-Event]": { + "recorded-date": "09-10-2024, 16:16:05", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[nodejs-RequestResponse]": { + "recorded-date": "09-10-2024, 16:16:14", + "recorded-content": { + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": "null", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[nodejs-Event]": { + "recorded-date": "09-10-2024, 16:16:28", + "recorded-content": { + "invoke-result": { + "Payload": "", + "StatusCode": 202, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_provisioned_concurrency_on_alias": { + "recorded-date": "07-05-2025, 09:26:54", + "recorded-content": { + "put_provisioned_5": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 5, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get_provisioned_prewait": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 5, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_provisioned_postwait": { + "AllocatedProvisionedConcurrentExecutions": 5, + "AvailableProvisionedConcurrentExecutions": 5, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 5, + "Status": "READY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_update": { + "recorded-date": "22-05-2025, 14:11:12", + "recorded-content": { + "put_function_concurrency": { + "ReservedConcurrentExecutions": 3, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_concurrency_updated": { + "ReservedConcurrentExecutions": 3, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_concurrency_info": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Concurrency": { + "ReservedConcurrentExecutions": 3 + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "code-sha256", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_concurrency_deleted": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_host_prefix_api_operation": { + "recorded-date": "26-05-2025, 16:38:54", + "recorded-content": { + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "host_prefix": { + "status": "success" + }, + "host_prefix_localstack_domain": { + "status": "success" + }, + "no_host_prefix": { + "status": "success" + } + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/lambda_/test_lambda.validation.json b/tests/aws/services/lambda_/test_lambda.validation.json new file mode 100644 index 0000000000000..9b5d816f5ac1e --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda.validation.json @@ -0,0 +1,299 @@ +{ + "tests/aws/services/lambda_/test_lambda.py::TestLambdaAliases::test_alias_routingconfig": { + "last_validated_date": "2024-04-08T17:11:17+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaAliases::test_lambda_alias_moving": { + "last_validated_date": "2024-04-08T17:11:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_assume_role[1]": { + "last_validated_date": "2024-04-08T16:55:25+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_assume_role[2]": { + "last_validated_date": "2024-04-08T16:55:31+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_function_state": { + "last_validated_date": "2024-04-08T16:55:20+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_different_iam_keys_environment": { + "last_validated_date": "2024-04-08T16:55:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_large_response": { + "last_validated_date": "2024-04-08T16:55:11+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_lambda_too_large_response": { + "last_validated_date": "2024-04-08T16:55:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBaseFeatures::test_large_payloads": { + "last_validated_date": "2024-04-08T16:55:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_cache_local[nodejs]": { + "last_validated_date": "2024-04-08T16:55:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_cache_local[python]": { + "last_validated_date": "2024-04-08T16:55:59+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_host_prefix_api_operation": { + "last_validated_date": "2025-05-26T16:38:53+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_init_environment": { + "last_validated_date": "2024-04-08T16:56:25+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_no_timeout": { + "last_validated_date": "2024-04-08T16:56:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_timed_out_environment_reuse": { + "last_validated_date": "2024-04-08T16:56:22+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_lambda_invoke_with_timeout": { + "last_validated_date": "2024-04-08T16:56:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_mixed_architecture": { + "last_validated_date": "2024-05-15T12:55:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_introspection_arm": { + "last_validated_date": "2024-04-08T16:55:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_introspection_x86": { + "last_validated_date": "2024-04-08T16:55:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaBehavior::test_runtime_ulimits": { + "last_validated_date": "2024-04-16T08:12:11+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaCleanup::test_recreate_function": { + "last_validated_date": "2024-05-15T10:16:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_block": { + "last_validated_date": "2024-04-08T17:02:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_crud": { + "last_validated_date": "2025-05-22T08:04:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_concurrency_update": { + "last_validated_date": "2025-05-22T14:11:12+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_provisioned_concurrency_moves_with_alias": { + "last_validated_date": "2023-03-21T07:47:38+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_lambda_provisioned_concurrency_scheduling": { + "last_validated_date": "2024-04-16T08:03:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_provisioned_concurrency": { + "last_validated_date": "2024-04-08T17:04:20+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_provisioned_concurrency_on_alias": { + "last_validated_date": "2025-05-07T09:26:54+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_concurrency": { + "last_validated_date": "2024-04-08T17:08:10+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_concurrency_async_queue": { + "last_validated_date": "2025-03-26T10:54:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaConcurrency::test_reserved_provisioned_overlap": { + "last_validated_date": "2024-04-08T17:10:36+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_handler_error": { + "last_validated_date": "2024-04-08T16:59:22+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_handler_exit": { + "last_validated_date": "2024-04-08T16:59:25+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_invoke_payload_encoding_error[body-n\\x87r\\x9e\\xe9\\xb5\\xd7I\\xee\\x9bmt]": { + "last_validated_date": "2024-04-08T16:59:31+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_invoke_payload_encoding_error[message-\\x99\\xeb,j\\x07\\xa1zYh]": { + "last_validated_date": "2024-04-08T16:59:34+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_error": { + "last_validated_date": "2025-02-24T16:26:36+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_exit": { + "last_validated_date": "2024-04-08T16:58:35+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_exit_segfault": { + "last_validated_date": "2024-04-08T16:59:19+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaErrors::test_lambda_runtime_wrapper_not_found": { + "last_validated_date": "2024-04-08T16:59:28+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_dry_run[nodejs16.x]": { + "last_validated_date": "2022-09-09T17:15:38+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_dry_run[python3.10]": { + "last_validated_date": "2023-04-26T17:37:23+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event[nodejs16.x]": { + "last_validated_date": "2024-04-08T16:57:59+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event[python3.10]": { + "last_validated_date": "2024-04-08T16:58:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_event_error": { + "last_validated_date": "2023-09-04T20:49:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[nodejs-Event]": { + "last_validated_date": "2024-10-09T16:16:27+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[nodejs-RequestResponse]": { + "last_validated_date": "2024-10-09T16:16:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[python-Event]": { + "last_validated_date": "2024-10-09T16:16:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_no_return_payload[python-RequestResponse]": { + "last_validated_date": "2024-10-09T16:15:57+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_request_response[nodejs16.x]": { + "last_validated_date": "2024-04-08T16:57:47+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_type_request_response[python3.10]": { + "last_validated_date": "2024-04-08T16:57:50+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_logs[nodejs16.x]": { + "last_validated_date": "2024-04-08T16:57:40+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_logs[python3.10]": { + "last_validated_date": "2024-04-08T16:57:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invocation_with_qualifier": { + "last_validated_date": "2024-04-08T16:58:20+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_invoke_exceptions": { + "last_validated_date": "2024-04-08T16:57:45+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_lambda_with_context": { + "last_validated_date": "2022-09-09T18:19:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaFeatures::test_upload_lambda_from_s3": { + "last_validated_date": "2024-04-08T16:58:25+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_cross_account_access": { + "last_validated_date": "2024-06-14T12:09:31+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_delete_function": { + "last_validated_date": "2024-06-14T15:17:16+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_alias": { + "last_validated_date": "2024-06-14T15:17:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_concurrency": { + "last_validated_date": "2024-06-14T15:16:58+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_invocation": { + "last_validated_date": "2024-06-14T15:17:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_function_tags": { + "last_validated_date": "2024-06-14T15:17:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_function": { + "last_validated_date": "2024-06-14T15:16:49+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_function_configuration": { + "last_validated_date": "2024-06-14T15:16:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_get_lambda_layer": { + "last_validated_date": "2024-06-14T15:16:46+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_list_versions_by_function": { + "last_validated_date": "2024-06-14T15:16:55+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaMultiAccounts::test_publish_version": { + "last_validated_date": "2024-06-14T15:17:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaPermissions::test_lambda_permission_url_invocation": { + "last_validated_date": "2024-04-08T16:57:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_update_function_url_config": { + "last_validated_date": "2024-04-08T16:57:17+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_http_fixture": { + "last_validated_date": "2024-03-28T22:20:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_http_fixture_default": { + "last_validated_date": "2024-07-18T14:18:07+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_http_fixture_trim_x_headers": { + "last_validated_date": "2024-04-08T16:57:34+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[BUFFERED]": { + "last_validated_date": "2024-04-08T16:57:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[None]": { + "last_validated_date": "2024-04-08T16:57:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_echo_invoke[RESPONSE_STREAM]": { + "last_validated_date": "2024-04-08T16:57:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_form_payload": { + "last_validated_date": "2024-06-13T21:01:15+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_headers_and_status": { + "last_validated_date": "2024-04-08T16:57:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invalid_invoke_mode": { + "last_validated_date": "2024-04-08T16:57:25+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[boolean]": { + "last_validated_date": "2024-04-08T16:56:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[dict]": { + "last_validated_date": "2024-04-08T16:56:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[float]": { + "last_validated_date": "2024-04-08T16:56:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[http-response-json]": { + "last_validated_date": "2024-04-08T16:56:36+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[http-response]": { + "last_validated_date": "2024-04-08T16:56:32+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[integer]": { + "last_validated_date": "2024-04-08T16:56:48+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[list-mixed]": { + "last_validated_date": "2024-04-08T16:56:40+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation[string]": { + "last_validated_date": "2024-04-08T16:56:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_custom_id": { + "last_validated_date": "2024-08-05T12:24:46+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_custom_id_aliased": { + "last_validated_date": "2024-08-05T12:48:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_invocation_exception": { + "last_validated_date": "2024-04-08T16:57:22+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_non_existing_url": { + "last_validated_date": "2024-04-11T17:16:39+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaURL::test_lambda_url_persists_after_alias_delete": { + "last_validated_date": "2024-08-05T13:57:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_async_invoke_queue_upon_function_update": { + "last_validated_date": "2024-05-15T18:29:38+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_function_update_during_invoke": { + "last_validated_date": "2024-05-15T19:05:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_lambda_handler_update": { + "last_validated_date": "2024-04-08T17:10:58+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestLambdaVersions::test_lambda_versions_with_code_changes": { + "last_validated_date": "2024-04-08T17:10:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_async_invoke_with_retry": { + "last_validated_date": "2024-04-08T17:13:38+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_format": { + "last_validated_date": "2024-04-08T17:11:17+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_invoke": { + "last_validated_date": "2024-04-08T17:11:26+00:00" + }, + "tests/aws/services/lambda_/test_lambda.py::TestRequestIdHandling::test_request_id_invoke_url": { + "last_validated_date": "2024-04-08T17:11:34+00:00" + } +} diff --git a/tests/aws/services/lambda_/test_lambda_api.py b/tests/aws/services/lambda_/test_lambda_api.py new file mode 100644 index 0000000000000..9b897a1326192 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_api.py @@ -0,0 +1,6889 @@ +"""API-focused tests only. +Everything related to behavior and implicit functionality goes into test_lambda.py instead +Don't add tests for asynchronous, blocking or implicit behavior here. + +# TODO: create a re-usable pattern for fairly reproducible scenarios with slower updates/creates to test intermediary states +# TODO: code signing https://docs.aws.amazon.com/lambda/latest/dg/configuration-codesigning.html +# TODO: file systems https://docs.aws.amazon.com/lambda/latest/dg/configuration-filesystem.html +# TODO: VPC config https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html + +""" + +import base64 +import io +import json +import logging +import re +import threading +from hashlib import sha256 +from io import BytesIO +from random import randint +from typing import Callable + +import pytest +import requests +from botocore.config import Config +from botocore.exceptions import ClientError, ParamValidationError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack import config +from localstack.aws.api.lambda_ import ( + Architecture, + LogFormat, + Runtime, +) +from localstack.services.lambda_.api_utils import ARCHITECTURES +from localstack.services.lambda_.provider import TAG_KEY_CUSTOM_URL +from localstack.services.lambda_.provider_utils import LambdaLayerVersionIdentifier +from localstack.services.lambda_.runtimes import ( + ALL_RUNTIMES, + DEPRECATED_RUNTIMES, + SNAP_START_SUPPORTED_RUNTIMES, +) +from localstack.testing.aws.lambda_utils import ( + _await_dynamodb_table_active, + _await_event_source_mapping_enabled, + is_docker_runtime_executor, +) +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils import testutil +from localstack.utils.aws import arns +from localstack.utils.aws.arns import ( + get_partition, + lambda_event_source_mapping_arn, + lambda_function_arn, +) +from localstack.utils.docker_utils import DOCKER_CLIENT +from localstack.utils.files import load_file +from localstack.utils.functions import call_safe +from localstack.utils.strings import long_uid, short_uid, to_str +from localstack.utils.sync import ShortCircuitWaitException, wait_until +from localstack.utils.testutil import create_lambda_archive +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_NODEJS, + TEST_LAMBDA_PYTHON_ECHO, + TEST_LAMBDA_PYTHON_ECHO_ZIP, + TEST_LAMBDA_PYTHON_VERSION, + TEST_LAMBDA_VERSION, + check_concurrency_quota, +) + +LOG = logging.getLogger(__name__) + +KB = 1024 + + +@pytest.fixture(autouse=True) +def fixture_snapshot(snapshot): + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + + +def string_length_bytes(s: str) -> int: + return len(s.encode("utf-8")) + + +def environment_length_bytes(e: dict) -> int: + serialized_environment = json.dumps(e, separators=(":", ",")) + return string_length_bytes(serialized_environment) + + +class TestRuntimeValidation: + @markers.aws.only_localstack + def test_create_deprecated_function_runtime_with_validation_disabled( + self, create_lambda_function, lambda_su_role, aws_client, monkeypatch + ): + monkeypatch.setattr(config, "LAMBDA_RUNTIME_VALIDATION", 0) + function_name = f"fn-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_7, + role=lambda_su_role, + MemorySize=256, + Timeout=5, + LoggingConfig={ + "LogFormat": LogFormat.JSON, + }, + ) + + @markers.aws.validated + @markers.lambda_runtime_update + @pytest.mark.parametrize("runtime", DEPRECATED_RUNTIMES) + def test_create_deprecated_function_runtime_with_validation_enabled( + self, runtime, lambda_su_role, aws_client, monkeypatch, snapshot + ): + monkeypatch.setattr(config, "LAMBDA_RUNTIME_VALIDATION", 1) + function_name = f"fn-{short_uid()}" + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + testutil.create_lambda_function( + client=aws_client.lambda_, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=runtime, + role=lambda_su_role, + MemorySize=256, + Timeout=5, + LoggingConfig={ + "LogFormat": LogFormat.JSON, + }, + ) + snapshot.match("deprecation_error", e.value.response) + + +class TestPartialARNMatching: + @markers.aws.validated + def test_update_function_configuration_full_arn(self, create_lambda_function, aws_client): + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + MemorySize=256, + Timeout=5, + ) + + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + full_arn = create_response["CreateFunctionResponse"]["FunctionArn"] + partial_arn = ":".join(full_arn.split(":")[-3:]) + valid_names = [full_arn, function_name, partial_arn] + + # update configuration with various clarifiers + for name in valid_names: + aws_client.lambda_.update_function_configuration( + FunctionName=name, + Description="Changed-Description", + MemorySize=512, + Timeout=10, + Environment={"Variables": {"ENV_A": "a"}}, + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + @markers.aws.validated + def test_cross_region_arn_function_access( + self, create_lambda_function, aws_client, secondary_aws_client + ): + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + MemorySize=256, + Timeout=5, + ) + + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + full_arn = create_response["CreateFunctionResponse"]["FunctionArn"] + # if nothing breaks, all is good :) + secondary_aws_client.lambda_.get_function(FunctionName=full_arn) + + +class TestLoggingConfig: + @markers.aws.validated + def test_function_advanced_logging_configuration( + self, snapshot, create_lambda_function, lambda_su_role, aws_client + ): + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + MemorySize=256, + Timeout=5, + LoggingConfig={ + "LogFormat": LogFormat.JSON, + }, + ) + + snapshot.match("create_response", create_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response", get_function_response) + + function_config = aws_client.lambda_.get_function_configuration(FunctionName=function_name) + snapshot.match("function_config", function_config) + + advanced_config = { + "LogFormat": LogFormat.JSON, + "ApplicationLogLevel": "INFO", + "SystemLogLevel": "INFO", + "LogGroup": "cool_lambda", + } + updated_config = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, LoggingConfig=advanced_config + ) + snapshot.match("updated_config", updated_config) + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + received_conf = aws_client.lambda_.get_function_configuration( + FunctionName=function_name, + ) + snapshot.match("received_config", received_conf) + + @markers.aws.validated + def test_advanced_logging_configuration_format_switch( + self, snapshot, create_lambda_function, lambda_su_role, aws_client + ): + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + MemorySize=256, + Timeout=5, + ) + + snapshot.match("create_response", create_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response", get_function_response) + + function_config = aws_client.lambda_.get_function_configuration(FunctionName=function_name) + snapshot.match("function_config", function_config) + + updated_config = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, LoggingConfig={"LogFormat": LogFormat.JSON} + ) + snapshot.match("updated_config", updated_config) + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + received_conf = aws_client.lambda_.get_function_configuration( + FunctionName=function_name, + ) + snapshot.match("received_config", received_conf) + + updated_config = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, LoggingConfig={"LogFormat": LogFormat.Text} + ) + snapshot.match("updated_config_v2", updated_config) + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + received_conf = aws_client.lambda_.get_function_configuration( + FunctionName=function_name, + ) + snapshot.match("received_config_v2", received_conf) + + @markers.aws.validated + @pytest.mark.parametrize( + "partial_config", + [ + {"LogFormat": LogFormat.JSON}, + {"LogFormat": LogFormat.JSON, "ApplicationLogLevel": "DEBUG"}, + {"LogFormat": LogFormat.JSON, "SystemLogLevel": "DEBUG"}, + {"LogGroup": "cool_lambda"}, + ], + ) + def test_function_partial_advanced_logging_configuration_update( + self, snapshot, create_lambda_function, lambda_su_role, aws_client, partial_config + ): + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + MemorySize=256, + Timeout=5, + ) + + snapshot.match("create_response", create_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response", get_function_response) + + function_config = aws_client.lambda_.get_function_configuration(FunctionName=function_name) + snapshot.match("function_config", function_config) + + updated_config = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, LoggingConfig=partial_config + ) + snapshot.match("updated_config", updated_config) + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + received_conf = aws_client.lambda_.get_function_configuration( + FunctionName=function_name, + ) + snapshot.match("received_config", received_conf) + + +class TestLambdaFunction: + @markers.snapshot.skip_snapshot_verify( + # The RuntimeVersionArn is currently a hardcoded id and therefore does not reflect the ARN resource update + # for different runtime versions" + paths=["$..RuntimeVersionConfig.RuntimeVersionArn"] + ) + @markers.aws.validated + def test_function_lifecycle(self, snapshot, create_lambda_function, lambda_su_role, aws_client): + """Tests CRUD for the lifecycle of a Lambda function and its config""" + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + MemorySize=256, + Timeout=5, + ) + + snapshot.match("create_response", create_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response", get_function_response) + + update_func_conf_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Runtime=Runtime.python3_11, + Description="Changed-Description", + MemorySize=512, + Timeout=10, + Environment={"Variables": {"ENV_A": "a"}}, + ) + snapshot.match("update_func_conf_response", update_func_conf_response) + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + get_function_response_postupdate = aws_client.lambda_.get_function( + FunctionName=function_name + ) + snapshot.match("get_function_response_postupdate", get_function_response_postupdate) + + zip_f = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_VERSION), get_content=True) + update_code_response = aws_client.lambda_.update_function_code( + FunctionName=function_name, + ZipFile=zip_f, + ) + snapshot.match("update_code_response", update_code_response) + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + get_function_response_postcodeupdate = aws_client.lambda_.get_function( + FunctionName=function_name + ) + snapshot.match("get_function_response_postcodeupdate", get_function_response_postcodeupdate) + + delete_response = aws_client.lambda_.delete_function(FunctionName=function_name) + snapshot.match("delete_response", delete_response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.delete_function(FunctionName=function_name) + snapshot.match("delete_postdelete", e.value.response) + + @markers.aws.validated + def test_redundant_updates(self, create_lambda_function, snapshot, aws_client): + """validates that redundant updates work (basically testing idempotency)""" + function_name = f"fn-{short_uid()}" + + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Description="Initial description", + ) + snapshot.match("create_response", create_response) + + first_update_result = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description="1st update description" + ) + snapshot.match("first_update_result", first_update_result) + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + get_fn_config_result = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get_fn_config_result", get_fn_config_result) + + get_fn_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_fn_result", get_fn_result) + + redundant_update_result = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description="1st update description" + ) + snapshot.match("redundant_update_result", redundant_update_result) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + get_fn_result_after_redundant_update = aws_client.lambda_.get_function( + FunctionName=function_name + ) + snapshot.match("get_fn_result_after_redundant_update", get_fn_result_after_redundant_update) + + @pytest.mark.parametrize( + "clientfn", + [ + "delete_function", + "get_function", + "get_function_configuration", + ], + ) + @markers.aws.validated + def test_ops_with_arn_qualifier_mismatch( + self, create_lambda_function, snapshot, account_id, clientfn, aws_client + ): + function_name = "some-function" + method = getattr(aws_client.lambda_, clientfn) + region_name = aws_client.lambda_.meta.region_name + with pytest.raises(ClientError) as e: + method( + FunctionName=f"arn:{get_partition(region_name)}:lambda:{region_name}:{account_id}:function:{function_name}:1", + Qualifier="$LATEST", + ) + snapshot.match("not_match_exception", e.value.response) + # check if it works if it matches - still no function there + with pytest.raises(ClientError) as e: + method( + FunctionName=f"arn:{get_partition(region_name)}:lambda:{region_name}:{account_id}:function:{function_name}:$LATEST", + Qualifier="$LATEST", + ) + snapshot.match("match_exception", e.value.response) + + @pytest.mark.parametrize( + "clientfn", + [ + "get_function", + "get_function_configuration", + "get_function_event_invoke_config", + ], + ) + @markers.aws.validated + def test_ops_on_nonexisting_version( + self, create_lambda_function, snapshot, clientfn, aws_client + ): + """Test API responses on existing function names, but not existing versions""" + function_name = f"i-exist-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Description="Initial description", + ) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + method = getattr(aws_client.lambda_, clientfn) + method(FunctionName=function_name, Qualifier="1221") + snapshot.match("version_not_found_exception", e.value.response) + + @markers.aws.validated + def test_delete_on_nonexisting_version(self, create_lambda_function, snapshot, aws_client): + """Test API responses on existing function names, but not existing versions""" + function_name = f"i-exist-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Description="Initial description", + ) + # it seems delete function on a random qualifier is idempotent + aws_client.lambda_.delete_function(FunctionName=function_name, Qualifier="1233") + aws_client.lambda_.delete_function(FunctionName=function_name, Qualifier="1233") + aws_client.lambda_.delete_function(FunctionName=function_name) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.delete_function(FunctionName=function_name) + snapshot.match("delete_function_response_non_existent", e.value.response) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.delete_function(FunctionName=function_name, Qualifier="1233") + snapshot.match("delete_function_response_non_existent_with_qualifier", e.value.response) + + @pytest.mark.parametrize( + "clientfn", + [ + "delete_function", + "get_function", + "get_function_configuration", + "get_function_url_config", + "get_function_code_signing_config", + "get_function_event_invoke_config", + "get_function_concurrency", + ], + ) + @markers.aws.validated + def test_ops_on_nonexisting_fn(self, snapshot, clientfn, aws_client): + """Test API responses on non-existing function names""" + # technically the short_uid isn't really required but better safe than sorry + function_name = f"i-dont-exist-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + method = getattr(aws_client.lambda_, clientfn) + method(FunctionName=function_name) + snapshot.match("not_found_exception", e.value.response) + + @pytest.mark.parametrize( + "clientfn", + [ + "get_function", + "get_function_configuration", + "get_function_url_config", + "get_function_code_signing_config", + "get_function_event_invoke_config", + "get_function_concurrency", + "delete_function", + "invoke", + ], + ) + @markers.aws.validated + def test_get_function_wrong_region( + self, create_lambda_function, account_id, snapshot, clientfn, aws_client + ): + function_name = f"i-exist-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Description="Initial description", + ) + wrong_region = ( + "us-east-1" if aws_client.lambda_.meta.region_name != "us-east-1" else "eu-central-1" + ) + snapshot.add_transformer(snapshot.transform.regex(wrong_region, "")) + wrong_region_arn = f"arn:{get_partition(wrong_region)}:lambda:{wrong_region}:{account_id}:function:{function_name}" + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + method = getattr(aws_client.lambda_, clientfn) + method(FunctionName=wrong_region_arn) + snapshot.match("wrong_region_exception", e.value.response) + + @markers.aws.validated + def test_lambda_code_location_zipfile( + self, snapshot, create_lambda_function_aws, lambda_su_role, aws_client + ): + function_name = f"code-function-{short_uid()}" + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("create-response-zip-file", create_response) + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-response", get_function_response) + code_location = get_function_response["Code"]["Location"] + response = requests.get(code_location) + assert zip_file_bytes == response.content + h = sha256(zip_file_bytes) + b64digest = to_str(base64.b64encode(h.digest())) + assert b64digest == get_function_response["Configuration"]["CodeSha256"] + assert len(zip_file_bytes) == get_function_response["Configuration"]["CodeSize"] + zip_file_bytes_updated = create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_VERSION), get_content=True + ) + update_function_response = aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=zip_file_bytes_updated + ) + snapshot.match("update-function-response", update_function_response) + get_function_response_updated = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-response-updated", get_function_response_updated) + code_location_updated = get_function_response_updated["Code"]["Location"] + response = requests.get(code_location_updated) + assert zip_file_bytes_updated == response.content + h = sha256(zip_file_bytes_updated) + b64digest_updated = to_str(base64.b64encode(h.digest())) + assert b64digest != b64digest_updated + assert b64digest_updated == get_function_response_updated["Configuration"]["CodeSha256"] + assert ( + len(zip_file_bytes_updated) + == get_function_response_updated["Configuration"]["CodeSize"] + ) + + @markers.aws.validated + def test_lambda_code_location_s3( + self, s3_bucket, snapshot, create_lambda_function_aws, lambda_su_role, aws_client + ): + function_name = f"code-function-{short_uid()}" + bucket_key = "code/code-function.zip" + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + aws_client.s3.upload_fileobj( + Fileobj=io.BytesIO(zip_file_bytes), Bucket=s3_bucket, Key=bucket_key + ) + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={"S3Bucket": s3_bucket, "S3Key": bucket_key}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("create_response_s3", create_response) + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-response", get_function_response) + code_location = get_function_response["Code"]["Location"] + response = requests.get(code_location) + assert zip_file_bytes == response.content + h = sha256(zip_file_bytes) + b64digest = to_str(base64.b64encode(h.digest())) + assert b64digest == get_function_response["Configuration"]["CodeSha256"] + assert len(zip_file_bytes) == get_function_response["Configuration"]["CodeSize"] + zip_file_bytes_updated = create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_VERSION), get_content=True + ) + # TODO check bucket addressing with version id as well? + aws_client.s3.upload_fileobj( + Fileobj=io.BytesIO(zip_file_bytes_updated), Bucket=s3_bucket, Key=bucket_key + ) + update_function_response = aws_client.lambda_.update_function_code( + FunctionName=function_name, S3Bucket=s3_bucket, S3Key=bucket_key + ) + snapshot.match("update-function-response", update_function_response) + get_function_response_updated = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-response-updated", get_function_response_updated) + code_location_updated = get_function_response_updated["Code"]["Location"] + response = requests.get(code_location_updated) + assert zip_file_bytes_updated == response.content + h = sha256(zip_file_bytes_updated) + b64digest_updated = to_str(base64.b64encode(h.digest())) + assert b64digest != b64digest_updated + assert b64digest_updated == get_function_response_updated["Configuration"]["CodeSha256"] + assert ( + len(zip_file_bytes_updated) + == get_function_response_updated["Configuration"]["CodeSize"] + ) + + # TODO: fix type of AccessDeniedException yielding null + @markers.snapshot.skip_snapshot_verify( + paths=[ + "function_arn_other_account_exc..Error.Message", + "$..CodeSha256", + ] + ) + @markers.aws.validated + def test_function_arns( + self, create_lambda_function, region_name, account_id, aws_client, lambda_su_role, snapshot + ): + # create_function + function_name_1 = f"test-function-arn-{short_uid()}" + function_arn = f"arn:{get_partition(region_name)}:lambda:{region_name}:{account_id}:function:{function_name_1}" + function_arn_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_arn, + runtime=Runtime.python3_12, + ) + snapshot.match("create-function-arn-response", function_arn_response) + + function_name_2 = f"test-partial-arn-{short_uid()}" + partial_arn = f"{account_id}:function:{function_name_2}" + function_partial_arn_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=partial_arn, + runtime=Runtime.python3_12, + ) + snapshot.match("create-function-partial-arn-response", function_partial_arn_response) + + # create_function exceptions + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + # test invalid function name + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName="invalid:function:name", + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("invalid_function_name_exc", e.value.response) + + # test too long function name + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName="a" * 65, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("long_function_name_exc", e.value.response) + + # test too long function arn + max_function_arn_length = 140 + function_arn_prefix = ( + f"arn:{get_partition(region_name)}:lambda:{region_name}:{account_id}:function:" + ) + suffix_length = max_function_arn_length - len(function_arn_prefix) + 1 + long_function_name = "a" * suffix_length + snapshot.add_transformer(snapshot.transform.regex(long_function_name, "")) + long_function_arn = function_arn_prefix + long_function_name + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=long_function_arn, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("long_function_arn_exc", e.value.response) + + # test other region in function arn than client + function_name_1 = f"test-function-arn-{short_uid()}" + other_region = "ap-southeast-1" + assert region_name != other_region, ( + "This test assumes that the region in the function arn differs from the client region" + ) + function_arn_other_region = f"arn:{get_partition(other_region)}:lambda:{other_region}:{account_id}:function:{function_name_1}" + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_arn_other_region, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("function_arn_other_region_exc", e.value.response) + + # test other account in function arn than client + function_name_1 = f"test-function-arn-{short_uid()}" + other_account = "123456789012" + assert account_id != other_account, ( + "This test assumes that the account in the function arn differs from the client region" + ) + function_arn_other_account = f"arn:{get_partition(region_name)}:lambda:{region_name}:{other_account}:function:{function_name_1}" + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_arn_other_account, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("function_arn_other_account_exc", e.value.response) + + @pytest.mark.parametrize( + "clientfn", + [ + "get_function", + "delete_function", + "invoke", + "create_function", + ], + ) + @pytest.mark.parametrize( + "test_case", + [ + pytest.param( + {"FunctionName": "my-function!"}, + id="invalid_characters_in_function_name", + ), + pytest.param( + {"FunctionName": "*"}, + id="function_name_is_single_invalid", + ), + pytest.param( + { + "FunctionName": "my-function", + "Qualifier": "invalid!", + }, + id="invalid_characters_in_qualifier", + ), + pytest.param( + { + "FunctionName": "my-function", + "Qualifier": "a" * 129, + }, + id="qualifier_too_long", + ), + pytest.param( + { + "FunctionName": "invalid-account:function:my-function", + }, + id="invalid_account_id_in_partial_arn", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda:invalid-region:{account_id}:function:my-function", + }, + id="invalid_region_in_arn", + ), + pytest.param( + { + "FunctionName": "arn:aws:ec2:{region_name}:{account_id}:instance:i-1234567890abcdef0", + }, + id="non_lambda_arn", + ), + pytest.param( + {"FunctionName": "a" * 65}, + id="function_name_too_long", + ), + pytest.param( + { + "FunctionName": f"arn:aws:lambda:invalid-region:{{account_id}}:function:my-function{'a' * 170}", + }, + id="function_name_too_long_and_invalid_region", + ), + pytest.param( + { + "FunctionName": f"arn:aws:lambda:invalid-region:{{account_id}}:function:my-function-{'a' * 170}", + "Qualifier": "a" * 129, + }, + id="full_arn_and_qualifier_too_long_and_invalid_region", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda:{region_name}:{account_id}:function:my-function:1:2", + }, + id="full_arn_with_multiple_qualifiers", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda:{region_name}:{account_id}:function", + }, + id="incomplete_arn", + ), + pytest.param( + { + "FunctionName": "function:my-function:$LATEST:extra", + }, + id="partial_arn_with_extra_qualifier", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda:{region_name}:{account_id}:function:my-function:$LATEST", + "Qualifier": "1", + }, + id="latest_version_with_additional_qualifier", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda:{region_name}:{account_id}:function:my-function", + "Qualifier": "$latest", + }, + id="lowercase_latest_qualifier", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda::{account_id}:function:my-function", + }, + id="missing_region_in_arn", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda:{region_name}::function:my-function", + }, + id="missing_account_id_in_arn", + ), + pytest.param( + { + "FunctionName": "arn:aws:lambda:{region_name}:{account_id}:function:my-function:$LATES", + }, + id="misspelled_latest_in_arn", + ), + ], + ) + @markers.aws.validated + def test_function_name_and_qualifier_validation( + self, + request, + region_name, + account_id, + aws_client, + clientfn, + lambda_su_role, + test_case, + snapshot, + ): + if ( + request.node.callspec.id + in ( + "incomplete_arn-create_function", # "arn:aws:lambda:{region_name}:{account_id}:function" is valid + "lowercase_latest_qualifier-delete_function", # --qualifier "$latest" is valid + # TODO: both are 'valid' but LocalStack does not include the version qualifier '$LATEST' in raised NotFound exception + "function_name_too_long-invoke", + "incomplete_arn-invoke", + ) + ): + pytest.skip("skipping test case") + + function_name = test_case["FunctionName"].format( + region_name=region_name, account_id=account_id + ) + test_case["FunctionName"] = function_name + + # (Create|Delete)Function has a max length of 140, but GetFunction and Invoke 170. + max_arn_length = 170 if clientfn in ("invoke", "get_function") else 140 + max_qualifier_length = 129 + max_function_name_length = 65 + + snapshot.add_transformer( + snapshot.transform.regex("a" * max_arn_length, f"") + ) + snapshot.add_transformer( + snapshot.transform.regex("a" * max_qualifier_length, f"") + ) + + snapshot.add_transformer( + snapshot.transform.regex( + "a" * max_function_name_length, f"" + ) + ) + + def _extract_from_error_message(exception_response): + error_pattern = r"(Value '.*?' at '.*?' failed to satisfy constraint: .+?(?=;|$))" + error_message = exception_response["Error"]["Message"] + error_code = exception_response["Error"]["Code"] + + if error_messages_matches := re.findall(error_pattern, error_message): + return { + "Code": error_code, + "Errors": sorted(error_messages_matches), + "Count": len(error_messages_matches), + } + + return {"Code": error_code, "Message": error_message} + + def _wrap_create_function(FunctionName, Qualifier=""): + full_function_name = f"{FunctionName}:{Qualifier}" if Qualifier else FunctionName + zip_file_bytes = create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + return aws_client.lambda_.create_function( + FunctionName=full_function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + + method = getattr(aws_client.lambda_, clientfn) + if clientfn == "create_function": + method = _wrap_create_function + + with pytest.raises(Exception) as ex: + method(**test_case) + + snapshot.match( + f"{clientfn}_exception", + _extract_from_error_message(ex.value.response), + ) + + @markers.lambda_runtime_update + @markers.aws.validated + def test_create_lambda_exceptions(self, lambda_su_role, snapshot, aws_client): + function_name = f"invalid-function-{short_uid()}" + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + # test invalid role arn + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role="r1", + Runtime=Runtime.python3_12, + ) + snapshot.match("invalid_role_arn_exc", e.value.response) + # test invalid runtimes + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime="non-existent-runtime", + ) + snapshot.match("invalid_runtime_exc", e.value.response) + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime="PYTHON3.9", + ) + snapshot.match("uppercase_runtime_exc", e.value.response) + + # test empty architectures + with pytest.raises(ParamValidationError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Architectures=[], + ) + snapshot.match("empty_architectures", e.value) + + # test multiple architectures + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Architectures=[Architecture.x86_64, Architecture.arm64], + ) + snapshot.match("multiple_architectures", e.value.response) + + # test invalid architecture: capital "X" instead of "x" + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Architectures=["X86_64"], + ) + snapshot.match("uppercase_architecture", e.value.response) + + # test what happens with an invalid zip file + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": b"this is not a zipfile, just a random string"}, + PackageType="Zip", + Role=lambda_su_role, + Runtime="python3.9", + ) + snapshot.match("invalid_zip_exc", e.value.response) + + @markers.lambda_runtime_update + @markers.aws.validated + def test_update_lambda_exceptions( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + function_name = f"invalid-function-{short_uid()}" + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + with pytest.raises(ClientError) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Role="r1", + ) + snapshot.match("invalid_role_arn_exc", e.value.response) + with pytest.raises(ClientError) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Runtime="non-existent-runtime", + ) + snapshot.match("invalid_runtime_exc", e.value.response) + with pytest.raises(ClientError) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Runtime="PYTHON3.9", + ) + snapshot.match("uppercase_runtime_exc", e.value.response) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..CodeSha256", # TODO + ] + ) + @markers.aws.validated + def test_list_functions(self, create_lambda_function, lambda_su_role, snapshot, aws_client): + snapshot.add_transformer(SortingTransformer("Functions", lambda x: x["FunctionArn"])) + + function_name_1 = f"list-fn-1-{short_uid()}" + function_name_2 = f"list-fn-2-{short_uid()}" + # create lambda + version + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_1, + runtime=Runtime.python3_12, + role=lambda_su_role, + Publish=True, + ) + snapshot.match("create_response_1", create_response) + + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_2, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + snapshot.match("create_response_2", create_response) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.list_functions(FunctionVersion="invalid") + snapshot.match("list_functions_invalid_functionversion", e.value.response) + + list_paginator = aws_client.lambda_.get_paginator("list_functions") + # ALL means it should also return all published versions for the functions + test_fn = [function_name_1, function_name_2] + list_all = list_paginator.paginate( + FunctionVersion="ALL", + PaginationConfig={ + "PageSize": 1, + }, + ).build_full_result() + list_default = list_paginator.paginate(PaginationConfig={"PageSize": 1}).build_full_result() + + # we can't filter on the API level, so we'll just need to remove all entries that don't belong here manually before snapshotting + list_all["Functions"] = [f for f in list_all["Functions"] if f["FunctionName"] in test_fn] + list_default["Functions"] = [ + f for f in list_default["Functions"] if f["FunctionName"] in test_fn + ] + + assert len(list_all["Functions"]) == 3 # $LATEST + Version "1" for fn1 & $LATEST for fn2 + assert len(list_default["Functions"]) == 2 # $LATEST for fn1 and fn2 + + snapshot.match("list_all", list_all) + snapshot.match("list_default", list_default) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Ipv6AllowedForDualStack"]) + @markers.aws.validated + def test_vpc_config( + self, create_lambda_function, lambda_su_role, snapshot, aws_client, cleanups + ): + """ + Test "VpcConfig" Property on the Lambda Function + + Note: on AWS this takes quite a while since creating a function with VPC usually takes at least 4 minutes + FIXME: Unfortunately the cleanup in this test doesn't work properly on AWS and the last subnet/security group + vpc are leaking. + TODO: test a few more edge cases (e.g. multiple subnets / security groups, invalid vpc ids, etc.) + """ + + # VPC setup + security_group_name_1 = f"test-security-group-{short_uid()}" + security_group_name_2 = f"test-security-group-{short_uid()}" + vpc_id = aws_client.ec2.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpc_id)) + aws_client.ec2.get_waiter("vpc_available").wait(VpcIds=[vpc_id]) + security_group_id_1 = aws_client.ec2.create_security_group( + VpcId=vpc_id, GroupName=security_group_name_1, Description="Test security group 1" + )["GroupId"] + cleanups.append(lambda: aws_client.ec2.delete_security_group(GroupId=security_group_id_1)) + security_group_id_2 = aws_client.ec2.create_security_group( + VpcId=vpc_id, GroupName=security_group_name_2, Description="Test security group 2" + )["GroupId"] + cleanups.append(lambda: aws_client.ec2.delete_security_group(GroupId=security_group_id_2)) + subnet_id_1 = aws_client.ec2.create_subnet(VpcId=vpc_id, CidrBlock="10.0.0.0/24")["Subnet"][ + "SubnetId" + ] + cleanups.append(lambda: aws_client.ec2.delete_subnet(SubnetId=subnet_id_1)) + subnet_id_2 = aws_client.ec2.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24")["Subnet"][ + "SubnetId" + ] + cleanups.append(lambda: aws_client.ec2.delete_subnet(SubnetId=subnet_id_2)) + snapshot.add_transformer(snapshot.transform.regex(vpc_id, "")) + snapshot.add_transformer(snapshot.transform.regex(subnet_id_1, "")) + snapshot.add_transformer(snapshot.transform.regex(subnet_id_2, "")) + snapshot.add_transformer( + snapshot.transform.regex(security_group_id_1, "") + ) + snapshot.add_transformer( + snapshot.transform.regex(security_group_id_2, "") + ) + + cleanups.append( + lambda: aws_client.lambda_.delete_function(FunctionName=function_name) + ) # needed because otherwise VPC is still linked to function and deletion is blocked + + # Lambda creation + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + MemorySize=256, + Timeout=5, + VpcConfig={ + "SubnetIds": [subnet_id_1], + "SecurityGroupIds": [security_group_id_1], + }, + ) + + snapshot.match("create_response", create_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response", get_function_response) + + # update VPC config + update_vpcconfig_update_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + VpcConfig={ + "SubnetIds": [subnet_id_2], + "SecurityGroupIds": [security_group_id_2], + }, + ) + snapshot.match("update_vpcconfig_update_response", update_vpcconfig_update_response) + # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/lambda/waiter/FunctionUpdatedV2.html#Lambda.Waiter.FunctionUpdatedV2.wait + waiter_config = {"Delay": 1, "MaxAttempts": 60} + # Increase timeouts because it can take longer than 5 minutes against AWS due to VPC. + if is_aws_cloud(): + waiter_config = {"Delay": 5, "MaxAttempts": 90} + aws_client.lambda_.get_waiter("function_updated_v2").wait( + FunctionName=function_name, WaiterConfig=waiter_config + ) + + update_vpcconfig_get_function_response = aws_client.lambda_.get_function( + FunctionName=function_name + ) + snapshot.match( + "update_vpcconfig_get_function_response", update_vpcconfig_get_function_response + ) + + # update VPC config (delete VPC => should detach VPC) + delete_vpcconfig_update_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + VpcConfig={ + "SubnetIds": [], + "SecurityGroupIds": [], + }, + ) + snapshot.match("delete_vpcconfig_update_response", delete_vpcconfig_update_response) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + delete_vpcconfig_get_function_response = aws_client.lambda_.get_function( + FunctionName=function_name + ) + snapshot.match( + "delete_vpcconfig_get_function_response", delete_vpcconfig_get_function_response + ) + + @markers.aws.validated + def test_invalid_vpc_config_subnet( + self, create_lambda_function, lambda_su_role, snapshot, aws_client, cleanups + ): + """ + Test invalid "VpcConfig.SubnetIds" Property on the Lambda Function + """ + non_existent_subnet_id = f"subnet-{short_uid()}" + wrong_format_subnet_id = f"bad-format-{short_uid()}" + + # AWS validates the Security Group first, so we need a valid one to test SubnetsIds + security_groups = aws_client.ec2.describe_security_groups(MaxResults=5)["SecurityGroups"] + security_group_id = security_groups[0]["GroupId"] + + snapshot.add_transformer(snapshot.transform.regex(non_existent_subnet_id, "")) + snapshot.add_transformer(snapshot.transform.regex(wrong_format_subnet_id, "")) + + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=f"fn-{short_uid()}", + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + VpcConfig={ + "SubnetIds": [non_existent_subnet_id], + "SecurityGroupIds": [security_group_id], + }, + ) + + snapshot.match("create-response-non-existent-subnet-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=f"fn-{short_uid()}", + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + VpcConfig={ + "SubnetIds": [wrong_format_subnet_id], + "SecurityGroupIds": [security_group_id], + }, + ) + + snapshot.match("create-response-invalid-format-subnet-id", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif(reason="Not yet implemented", condition=not is_aws_cloud()) + def test_invalid_vpc_config_security_group( + self, create_lambda_function, lambda_su_role, snapshot, aws_client, cleanups + ): + """ + Test invalid "VpcConfig.SecurityGroupIds" Property on the Lambda Function + """ + # TODO: maybe add validation of security group id, not currently validated in LocalStack + non_existent_sg_id = f"sg-{short_uid()}" + wrong_format_sg_id = f"bad-format-{short_uid()}" + # this way, we assert that SecurityGroups existence is validated before SubnetIds + subnet_id = f"subnet-{short_uid()}" + + snapshot.add_transformer( + snapshot.transform.regex(non_existent_sg_id, "") + ) + snapshot.add_transformer( + snapshot.transform.regex(wrong_format_sg_id, "") + ) + + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=f"fn-{short_uid()}", + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + VpcConfig={ + "SubnetIds": [subnet_id], + "SecurityGroupIds": [non_existent_sg_id], + }, + ) + + snapshot.match("create-response-non-existent-security-group", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=f"fn-{short_uid()}", + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + VpcConfig={ + "SubnetIds": [subnet_id], + "SecurityGroupIds": [wrong_format_sg_id], + }, + ) + + snapshot.match("create-response-invalid-format-security-group", e.value.response) + + @markers.aws.validated + def test_invalid_invoke(self, aws_client, snapshot): + region_name = aws_client.lambda_.meta.region_name + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.invoke( + FunctionName=f"arn:{get_partition(region_name)}:lambda:{region_name}:123400000000@function:myfn", + Payload=b"{}", + ) + snapshot.match("invoke_function_name_pattern_exc", e.value.response) + + @pytest.mark.skipif( + not is_docker_runtime_executor(), + reason="Test will fail against other executors as they are not patched to take longer for the update", + ) + @markers.aws.validated + def test_lambda_concurrent_code_updates( + self, aws_client, create_lambda_function_aws, lambda_su_role, snapshot, monkeypatch + ): + # patch a function necessary for the lambda update to wait until we release it + # to be able to reliably capture the in-progress update state in LocalStack + from localstack.services.lambda_.invocation import docker_runtime_executor + from localstack.services.lambda_.invocation.docker_runtime_executor import ( + get_runtime_client_path, + ) + + update_finish_event = threading.Event() + update_finish_event.set() + + def _runtime_client_path(*args, **kwargs): + update_finish_event.wait() + return get_runtime_client_path(*args, **kwargs) + + monkeypatch.setattr( + docker_runtime_executor, "get_runtime_client_path", _runtime_client_path + ) + + function_name = f"test-lambda-{short_uid()}" + version_handler = load_file(TEST_LAMBDA_VERSION) + zip_file = create_lambda_archive(version_handler % "version0", get_content=True) + create_response = create_lambda_function_aws( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Role=lambda_su_role, + Handler="handler.handler", + Code={"ZipFile": zip_file}, + ) + snapshot.match("create-function-response", create_response) + + # clear flag so the update operation takes as long as we want + update_finish_event.clear() + + zip_file_1 = create_lambda_archive(version_handler % "version1", get_content=True) + zip_file_2 = create_lambda_archive(version_handler % "version2", get_content=True) + aws_client.lambda_.update_function_code(FunctionName=function_name, ZipFile=zip_file_1) + with pytest.raises(ClientError) as e: + aws_client.lambda_.update_function_code(FunctionName=function_name, ZipFile=zip_file_2) + snapshot.match("update-during-in-progress-update-exc", e.value.response) + + # release hold on updates + update_finish_event.set() + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + @pytest.mark.skipif( + not is_docker_runtime_executor(), + reason="Test will fail against other executors as they are not patched to take longer for the update", + ) + @markers.aws.validated + def test_lambda_concurrent_config_updates( + self, aws_client, create_lambda_function, lambda_su_role, snapshot, monkeypatch + ): + # patch a function necessary for the lambda update to wait until we release it + # to be able to reliably capture the in-progress update state in LocalStack + from localstack.services.lambda_.invocation import docker_runtime_executor + from localstack.services.lambda_.invocation.docker_runtime_executor import ( + get_runtime_client_path, + ) + + update_finish_event = threading.Event() + update_finish_event.set() + + def _runtime_client_path(*args, **kwargs): + update_finish_event.wait() + return get_runtime_client_path(*args, **kwargs) + + monkeypatch.setattr( + docker_runtime_executor, "get_runtime_client_path", _runtime_client_path + ) + + function_name = f"test-lambda-{short_uid()}" + create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + snapshot.match("create-function-response", create_response) + + # clear flag so the update operation takes as long as we want + update_finish_event.clear() + + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Environment={"Variables": {"TEST": "TEST1"}} + ) + with pytest.raises(ClientError) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Environment={"Variables": {"TEST": "TEST2"}} + ) + snapshot.match("update-during-in-progress-update-exc", e.value.response) + + # release hold on updates + update_finish_event.set() + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + +class TestLambdaRecursion: + @markers.aws.validated + def test_put_function_recursion_config_allow( + self, create_lambda_function, account_id, snapshot, aws_client + ): + """Tests Lambda recursion configuration with allowance.""" + # Arrange: Create a Lambda function + function_name = f"recursion-test-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Description="Lambda with recursion test", + ) + + # Act: Put recursion configuration to Allow + put_response = aws_client.lambda_.put_function_recursion_config( + FunctionName=function_name, RecursiveLoop="Allow" + ) + + # Assert: Validate the recursion config is set to Allow + snapshot.match("put_recursion_config_response", put_response) + + get_response = aws_client.lambda_.get_function_recursion_config( + FunctionName=function_name, + ) + snapshot.match("get_recursion_config_response", get_response) + assert get_response["RecursiveLoop"] == "Allow" + + @markers.aws.validated + def test_put_function_recursion_config_default_terminate( + self, create_lambda_function, account_id, snapshot, aws_client + ): + """Tests Lambda recursion config with default termination behavior.""" + # Arrange: Create a Lambda function + function_name = f"recursion-test-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Description="Lambda with recursion test", + ) + + # Act: Get recursion configuration without setting it (default behavior) + get_response = aws_client.lambda_.get_function_recursion_config( + FunctionName=function_name, + ) + + # Assert: Default should be "Terminate" + snapshot.match("get_recursion_default_terminate_response", get_response) + assert get_response["RecursiveLoop"] == "Terminate" + + @markers.aws.validated + def test_put_function_recursion_config_invalid_value( + self, create_lambda_function, account_id, snapshot, aws_client + ): + """Tests Lambda recursion configuration with invalid value.""" + # Arrange: Create a Lambda function + function_name = f"recursion-test-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Description="Lambda with recursion test", + ) + + # Act and Assert: Set an invalid RecursiveLoop value and expect ClientError + invalid_value = "InvalidValue" + with pytest.raises(ClientError) as e: + aws_client.lambda_.put_function_recursion_config( + FunctionName=function_name, RecursiveLoop=invalid_value + ) + + # Match the error response for the invalid value + snapshot.match("put_recursion_invalid_value_error", e.value.response) + + +class TestLambdaImages: + @pytest.fixture(scope="class") + def login_docker_client(self, aws_client): + if not is_aws_cloud(): + return + auth_data = aws_client.ecr.get_authorization_token() + # if check is necessary since registry login data is not available at LS before min. 1 repository is created + if auth_data["authorizationData"]: + auth_data = auth_data["authorizationData"][0] + decoded_auth_token = str( + base64.decodebytes(bytes(auth_data["authorizationToken"], "utf-8")), "utf-8" + ) + username, password = decoded_auth_token.split(":") + DOCKER_CLIENT.login( + username=username, password=password, registry=auth_data["proxyEndpoint"] + ) + + @pytest.fixture(scope="class") + def ecr_image(self, aws_client, login_docker_client): + repository_names = [] + image_names = [] + + def _create_test_image(base_image: str): + if is_aws_cloud(): + repository_name = f"test-repo-{short_uid()}" + repository_uri = aws_client.ecr.create_repository(repositoryName=repository_name)[ + "repository" + ]["repositoryUri"] + image_name = f"{repository_uri}:latest" + repository_names.append(repository_name) + else: + image_name = f"test-image-{short_uid()}:latest" + image_names.append(image_name) + + DOCKER_CLIENT.pull_image(base_image) + DOCKER_CLIENT.tag_image(base_image, image_name) + if is_aws_cloud(): + DOCKER_CLIENT.push_image(image_name) + return image_name + + yield _create_test_image + + for image_name in image_names: + try: + DOCKER_CLIENT.remove_image(image=image_name, force=True) + except Exception as e: + LOG.debug("Error cleaning up image %s: %s", image_name, e) + + for repository_name in repository_names: + try: + image_ids = aws_client.ecr.list_images(repositoryName=repository_name).get( + "imageIds", [] + ) + if image_ids: + call_safe( + aws_client.ecr.batch_delete_image, + kwargs={"repositoryName": repository_name, "imageIds": image_ids}, + ) + aws_client.ecr.delete_repository(repositoryName=repository_name) + except Exception as e: + LOG.debug("Error cleaning up repository %s: %s", repository_name, e) + + @markers.aws.validated + def test_lambda_image_crud( + self, create_lambda_function_aws, lambda_su_role, ecr_image, snapshot, aws_client + ): + """Test lambda crud with package type image""" + image = ecr_image("alpine") + repo_uri = image.rpartition(":")[0] + snapshot.add_transformer(snapshot.transform.regex(repo_uri, "")) + function_name = f"test-function-{short_uid()}" + create_image_response = create_lambda_function_aws( + FunctionName=function_name, + Role=lambda_su_role, + Code={"ImageUri": image}, + PackageType="Image", + Environment={"Variables": {"CUSTOM_ENV": "test"}}, + ) + snapshot.match("create-image-response", create_image_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-code-response", get_function_response) + get_function_config_response = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get-function-config-response", get_function_config_response) + + # try update to a zip file - should fail + with pytest.raises(ClientError) as e: + aws_client.lambda_.update_function_code( + FunctionName=function_name, + ZipFile=create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True), + ) + snapshot.match("image-to-zipfile-error", e.value.response) + + image_2 = ecr_image("debian") + repo_uri_2 = image_2.rpartition(":")[0] + snapshot.add_transformer(snapshot.transform.regex(repo_uri_2, "")) + update_function_code_response = aws_client.lambda_.update_function_code( + FunctionName=function_name, ImageUri=image_2 + ) + snapshot.match("update-function-code-response", update_function_code_response) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-code-response-after-update", get_function_response) + get_function_config_response = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get-function-config-response-after-update", get_function_config_response) + + @markers.aws.validated + def test_lambda_zip_file_to_image( + self, create_lambda_function_aws, lambda_su_role, ecr_image, snapshot, aws_client + ): + """Test that verifies conversion from zip file lambda to image lambda is not possible""" + image = ecr_image("alpine") + repo_uri = image.rpartition(":")[0] + snapshot.add_transformer(snapshot.transform.regex(repo_uri, "")) + function_name = f"test-function-{short_uid()}" + create_image_response = create_lambda_function_aws( + FunctionName=function_name, + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Handler="handler.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + ) + snapshot.match("create-image-response", create_image_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-code-response", get_function_response) + get_function_config_response = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get-function-config-response", get_function_config_response) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.update_function_code(FunctionName=function_name, ImageUri=image) + snapshot.match("zipfile-to-image-error", e.value.response) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-code-response-after-update", get_function_response) + get_function_config_response = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get-function-config-response-after-update", get_function_config_response) + + @markers.aws.validated + def test_lambda_image_and_image_config_crud( + self, create_lambda_function_aws, lambda_su_role, ecr_image, snapshot, aws_client + ): + """Test lambda crud with packagetype image and image configs""" + image = ecr_image("alpine") + repo_uri = image.rpartition(":")[0] + snapshot.add_transformer(snapshot.transform.regex(repo_uri, "")) + # Create another lambda with image config + function_name = f"test-function-{short_uid()}" + image_config = { + "EntryPoint": ["sh"], + "Command": ["-c", "echo test"], + "WorkingDirectory": "/app1", + } + create_image_response = create_lambda_function_aws( + FunctionName=function_name, + Role=lambda_su_role, + Code={"ImageUri": image}, + PackageType="Image", + ImageConfig=image_config, + Environment={"Variables": {"CUSTOM_ENV": "test"}}, + ) + snapshot.match("create-image-with-config-response", create_image_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-code-with-config-response", get_function_response) + get_function_config_response = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get-function-config-with-config-response", get_function_config_response) + + # update image config + new_image_config = { + "Command": ["-c", "echo test1"], + "WorkingDirectory": "/app1", + } + update_function_config_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, ImageConfig=new_image_config + ) + snapshot.match("update-function-code-response", update_function_config_response) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-code-response-after-update", get_function_response) + get_function_config_response = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get-function-config-response-after-update", get_function_config_response) + + # update to empty image config + update_function_config_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, ImageConfig={} + ) + snapshot.match( + "update-function-code-delete-imageconfig-response", update_function_config_response + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function-code-response-after-delete-imageconfig", get_function_response) + get_function_config_response = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match( + "get-function-config-response-after-delete-imageconfig", get_function_config_response + ) + + @markers.aws.validated + def test_lambda_image_versions( + self, create_lambda_function_aws, lambda_su_role, ecr_image, snapshot, aws_client + ): + """Test lambda versions with package type image""" + image = ecr_image("alpine") + repo_uri = image.rpartition(":")[0] + snapshot.add_transformer(snapshot.transform.regex(repo_uri, "")) + function_name = f"test-function-{short_uid()}" + create_image_response = create_lambda_function_aws( + FunctionName=function_name, + Role=lambda_su_role, + Code={"ImageUri": image}, + PackageType="Image", + Environment={"Variables": {"CUSTOM_ENV": "test"}}, + Publish=True, + ) + snapshot.match("create_image_response", create_image_response) + + get_function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_result", get_function_result) + + list_versions_result = aws_client.lambda_.list_versions_by_function( + FunctionName=function_name + ) + snapshot.match("list_versions_result", list_versions_result) + + first_update_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description="Second version :)" + ) + snapshot.match("first_update_response", first_update_response) + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + waiter.wait(FunctionName=function_name) + first_update_get_function = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("first_update_get_function", first_update_get_function) + + # Try publishing with wrong codesha256 + with pytest.raises(ClientError) as e: + aws_client.lambda_.publish_version( + FunctionName=function_name, + Description="Second version description :)", + CodeSha256="a" * 64, + ) + snapshot.match("invalid_sha_publish", e.value.response) + + # publish with correct codesha256 + first_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, + Description="Second version description :)", + CodeSha256=get_function_result["Configuration"]["CodeSha256"], + ) + snapshot.match("first_publish_response", first_publish_response) + + second_update_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description="Third version :)" + ) + snapshot.match("second_update_response", second_update_response) + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + waiter.wait(FunctionName=function_name) + second_update_get_function = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("second_update_get_function", second_update_get_function) + + # publish without codesha256 + second_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="Third version description :)" + ) + snapshot.match("second_publish_response", second_publish_response) + + +class TestLambdaVersions: + @markers.aws.validated + def test_publish_version_on_create( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + function_name = f"fn-{short_uid()}" + + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Publish=True, + ) + snapshot.match("create_response", create_response) + + get_function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_result", get_function_result) + + get_function_version_result = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier="1" + ) + snapshot.match("get_function_version_result", get_function_version_result) + + get_function_latest_result = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier="$LATEST" + ) + snapshot.match("get_function_latest_result", get_function_latest_result) + + list_versions_result = aws_client.lambda_.list_versions_by_function( + FunctionName=function_name + ) + snapshot.match("list_versions_result", list_versions_result) + + # rerelease just published function, should not release new version + repeated_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="Repeated version description :)" + ) + snapshot.match("repeated_publish_response", repeated_publish_response) + list_versions_result_after_publish = aws_client.lambda_.list_versions_by_function( + FunctionName=function_name + ) + snapshot.match("list_versions_result_after_publish", list_versions_result_after_publish) + + @markers.aws.validated + def test_version_lifecycle( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + """ + Test the function version "lifecycle" (there are no deletes) + """ + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Description="No version :(", + ) + snapshot.match("create_response", create_response) + + get_function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_result", get_function_result) + + list_versions_result = aws_client.lambda_.list_versions_by_function( + FunctionName=function_name + ) + snapshot.match("list_versions_result", list_versions_result) + + first_update_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description="First version :)" + ) + snapshot.match("first_update_response", first_update_response) + waiter.wait(FunctionName=function_name) + first_update_get_function = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("first_update_get_function", first_update_get_function) + + first_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="First version description :)" + ) + snapshot.match("first_publish_response", first_publish_response) + + first_publish_get_function = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=first_publish_response["Version"] + ) + snapshot.match("first_publish_get_function", first_publish_get_function) + first_publish_get_function_config = aws_client.lambda_.get_function_configuration( + FunctionName=function_name, Qualifier=first_publish_response["Version"] + ) + snapshot.match("first_publish_get_function_config", first_publish_get_function_config) + + second_update_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description="Second version :))" + ) + snapshot.match("second_update_response", second_update_response) + waiter.wait(FunctionName=function_name) + # check if first publish get function changed: + first_publish_get_function_after_update = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=first_publish_response["Version"] + ) + snapshot.match( + "first_publish_get_function_after_update", first_publish_get_function_after_update + ) + + # Same state published as two different versions. + # The publish_version api is idempotent, so the second publish_version will *NOT* create a new version because $LATEST hasn't been updated! + second_publish_response = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("second_publish_response", second_publish_response) + third_publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="Third version description :)))" + ) + snapshot.match("third_publish_response", third_publish_response) + + list_versions_result_end = aws_client.lambda_.list_versions_by_function( + FunctionName=function_name + ) + snapshot.match("list_versions_result_after_third_publish", list_versions_result_end) + + aws_client.lambda_.delete_function( + FunctionName=f"{function_name}:{first_publish_response['Version']}" + ) + list_versions_result_end = aws_client.lambda_.list_versions_by_function( + FunctionName=function_name + ) + snapshot.match( + "list_versions_result_after_deletion_of_first_version", list_versions_result_end + ) + + @markers.aws.validated + def test_publish_with_wrong_sha256( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + function_name = f"fn-{short_uid()}" + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("create_response", create_response) + + get_fn_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_fn_response", get_fn_response) + + # publish_versions fails for the wrong revision id + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.publish_version( + FunctionName=function_name, CodeSha256="somenonexistentsha256" + ) + snapshot.match("publish_wrong_sha256_exc", e.value.response) + + # but with the proper rev id, it should work + publish_result = aws_client.lambda_.publish_version( + FunctionName=function_name, CodeSha256=get_fn_response["Configuration"]["CodeSha256"] + ) + snapshot.match("publish_result", publish_result) + + @markers.aws.validated + def test_publish_with_update( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + function_name = f"fn-{short_uid()}" + + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + snapshot.match("create_response", create_response) + + get_function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_result", get_function_result) + update_zip_file = create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_VERSION), get_content=True + ) + update_function_code_result = aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=update_zip_file, Publish=True + ) + snapshot.match("update_function_code_result", update_function_code_result) + + get_function_version_result = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier="1" + ) + snapshot.match("get_function_version_result", get_function_version_result) + + get_function_latest_result = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier="$LATEST" + ) + snapshot.match("get_function_latest_result", get_function_latest_result) + + +class TestLambdaAlias: + @markers.aws.validated + def test_alias_lifecycle( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + """ + The function has 2 (excl. $LATEST) versions: + Version 1: env with testenv==staging + Version 2: env with testenv==prod + + Alias A (Version == 1) has a routing config targeting both versions + Alias B (Version == 1) has no routing config and simply is an alias for Version 1 + Alias C (Version == 2) has no routing config + + """ + function_name = f"alias-fn-{short_uid()}" + snapshot.add_transformer(SortingTransformer("Aliases", lambda x: x["Name"])) + + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Environment={"Variables": {"testenv": "staging"}}, + ) + snapshot.match("create_response", create_response) + + publish_v1 = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("publish_v1", publish_v1) + + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Environment={"Variables": {"testenv": "prod"}} + ) + waiter = aws_client.lambda_.get_waiter("function_updated_v2") + waiter.wait(FunctionName=function_name) + + publish_v2 = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("publish_v2", publish_v2) + + create_alias_1_1 = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name="aliasname1_1", + FunctionVersion="1", + Description="custom-alias", + RoutingConfig={"AdditionalVersionWeights": {"2": 0.2}}, + ) + snapshot.match("create_alias_1_1", create_alias_1_1) + get_alias_1_1 = aws_client.lambda_.get_alias( + FunctionName=function_name, Name="aliasname1_1" + ) + snapshot.match("get_alias_1_1", get_alias_1_1) + get_function_alias_1_1 = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier="aliasname1_1" + ) + snapshot.match("get_function_alias_1_1", get_function_alias_1_1) + get_function_byarn_alias_1_1 = aws_client.lambda_.get_function( + FunctionName=create_alias_1_1["AliasArn"] + ) + snapshot.match("get_function_byarn_alias_1_1", get_function_byarn_alias_1_1) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier="aliasdoesnotexist" + ) + snapshot.match("get_function_alias_notfound_exc", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_function( + FunctionName=create_alias_1_1["AliasArn"].replace( + "aliasname1_1", "aliasdoesnotexist" + ) + ) + snapshot.match("get_function_alias_byarn_notfound_exc", e.value.response) + + create_alias_1_2 = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name="aliasname1_2", + FunctionVersion="1", + Description="custom-alias", + ) + snapshot.match("create_alias_1_2", create_alias_1_2) + get_alias_1_2 = aws_client.lambda_.get_alias( + FunctionName=function_name, Name="aliasname1_2" + ) + snapshot.match("get_alias_1_2", get_alias_1_2) + + create_alias_1_3 = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name="aliasname1_3", + FunctionVersion="1", + ) + snapshot.match("create_alias_1_3", create_alias_1_3) + get_alias_1_3 = aws_client.lambda_.get_alias( + FunctionName=function_name, Name="aliasname1_3" + ) + snapshot.match("get_alias_1_3", get_alias_1_3) + + create_alias_2 = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name="aliasname2", + FunctionVersion="2", + Description="custom-alias", + ) + snapshot.match("create_alias_2", create_alias_2) + get_alias_2 = aws_client.lambda_.get_alias(FunctionName=function_name, Name="aliasname2") + snapshot.match("get_alias_2", get_alias_2) + + # list_aliases can be optionally called with a FunctionVersion to filter only aliases for this version + list_alias_paginator = aws_client.lambda_.get_paginator("list_aliases") + list_aliases_for_fnname = list_alias_paginator.paginate( + FunctionName=function_name, PaginationConfig={"PageSize": 1} + ).build_full_result() # 4 aliases + snapshot.match("list_aliases_for_fnname", list_aliases_for_fnname) + assert len(list_aliases_for_fnname["Aliases"]) == 4 + # update alias 1_1 to remove routing config + update_alias_1_1 = aws_client.lambda_.update_alias( + FunctionName=function_name, + Name="aliasname1_1", + RoutingConfig={"AdditionalVersionWeights": {}}, + ) + snapshot.match("update_alias_1_1", update_alias_1_1) + get_alias_1_1_after_update = aws_client.lambda_.get_alias( + FunctionName=function_name, Name="aliasname1_1" + ) + snapshot.match("get_alias_1_1_after_update", get_alias_1_1_after_update) + list_aliases_for_fnname_after_update = aws_client.lambda_.list_aliases( + FunctionName=function_name + ) # 4 aliases + snapshot.match("list_aliases_for_fnname_after_update", list_aliases_for_fnname_after_update) + assert len(list_aliases_for_fnname_after_update["Aliases"]) == 4 + # check update without changes + update_alias_1_2 = aws_client.lambda_.update_alias( + FunctionName=function_name, + Name="aliasname1_2", + ) + snapshot.match("update_alias_1_2", update_alias_1_2) + get_alias_1_2_after_update = aws_client.lambda_.get_alias( + FunctionName=function_name, Name="aliasname1_2" + ) + snapshot.match("get_alias_1_2_after_update", get_alias_1_2_after_update) + list_aliases_for_fnname_after_update_2 = aws_client.lambda_.list_aliases( + FunctionName=function_name + ) # 4 aliases + snapshot.match( + "list_aliases_for_fnname_after_update_2", list_aliases_for_fnname_after_update_2 + ) + assert len(list_aliases_for_fnname_after_update["Aliases"]) == 4 + + list_aliases_for_version = aws_client.lambda_.list_aliases( + FunctionName=function_name, FunctionVersion="1" + ) # 3 aliases + snapshot.match("list_aliases_for_version", list_aliases_for_version) + assert len(list_aliases_for_version["Aliases"]) == 3 + + delete_alias_response = aws_client.lambda_.delete_alias( + FunctionName=function_name, Name="aliasname1_1" + ) + snapshot.match("delete_alias_response", delete_alias_response) + + list_aliases_for_fnname_afterdelete = aws_client.lambda_.list_aliases( + FunctionName=function_name + ) # 3 aliases + snapshot.match("list_aliases_for_fnname_afterdelete", list_aliases_for_fnname_afterdelete) + + @markers.aws.validated + def test_non_existent_alias_deletion( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + """ + This test checks the behaviour when deleting a non-existent alias. + No error is raised. + """ + function_name = f"alias-fn-{short_uid()}" + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Environment={"Variables": {"testenv": "staging"}}, + ) + snapshot.match("create_response", create_response) + + delete_alias_response = aws_client.lambda_.delete_alias( + FunctionName=function_name, Name="non-existent" + ) + snapshot.match("delete_alias_response", delete_alias_response) + + @markers.aws.validated + def test_non_existent_alias_update( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + """ + This test checks the behaviour when updating a non-existent alias. + An error (ResourceNotFoundException) is raised. + """ + function_name = f"alias-fn-{short_uid()}" + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Environment={"Variables": {"testenv": "staging"}}, + ) + snapshot.match("create_response", create_response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.update_alias( + FunctionName=function_name, + Name="non-existent", + ) + snapshot.match("update_alias_response", e.value.response) + + @markers.aws.validated + def test_notfound_and_invalid_routingconfigs( + self, aws_client_factory, create_lambda_function_aws, snapshot, lambda_su_role, aws_client + ): + lambda_client = aws_client_factory(config=Config(parameter_validation=False)).lambda_ + function_name = f"alias-fn-{short_uid()}" + + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Publish=True, + Environment={"Variables": {"testenv": "staging"}}, + ) + snapshot.match("create_response", create_response) + + # create 2 versions + publish_v1 = lambda_client.publish_version(FunctionName=function_name) + snapshot.match("publish_v1", publish_v1) + + lambda_client.update_function_configuration( + FunctionName=function_name, Environment={"Variables": {"testenv": "prod"}} + ) + waiter = lambda_client.get_waiter("function_updated_v2") + waiter.wait(FunctionName=function_name) + + publish_v2 = lambda_client.publish_version(FunctionName=function_name) + snapshot.match("publish_v2", publish_v2) + + # routing config with more than one entry (which isn't supported atm by AWS) + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"1": 0.8, "2": 0.2}}, + ) + snapshot.match("routing_config_exc_toomany", e.value.response) + + # value > 1 + with pytest.raises(ClientError) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"2": 2}}, + ) + snapshot.match("routing_config_exc_toohigh", e.value.response) + + # value < 0 + with pytest.raises(ClientError) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"2": -1}}, + ) + snapshot.match("routing_config_exc_subzero", e.value.response) + + # same version as alias pointer + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"1": 0.5}}, + ) + snapshot.match("routing_config_exc_sameversion", e.value.response) + + # function version 10 doesn't exist + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="10", + RoutingConfig={"AdditionalVersionWeights": {"2": 0.5}}, + ) + snapshot.match("target_version_doesnotexist", e.value.response) + # function version 10 doesn't exist (routingconfig) + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"10": 0.5}}, + ) + snapshot.match("routing_config_exc_version_doesnotexist", e.value.response) + # function version $LATEST not supported in function version if it points to more than one version + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="$LATEST", + RoutingConfig={"AdditionalVersionWeights": {"1": 0.5}}, + ) + snapshot.match("target_version_exc_version_latest", e.value.response) + # function version $LATEST not supported in routing config + with pytest.raises(ClientError) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"$LATEST": 0.5}}, + ) + snapshot.match("routing_config_exc_version_latest", e.value.response) + create_alias_latest = lambda_client.create_alias( + FunctionName=function_name, + Name="custom-latest", + FunctionVersion="$LATEST", + ) + snapshot.match("create-alias-latest", create_alias_latest) + + # function doesn't exist + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.create_alias( + FunctionName=f"{function_name}-unknown", + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"2": 0.5}}, + ) + snapshot.match("routing_config_exc_fn_doesnotexist", e.value.response) + + # empty routing config works fine + create_alias_empty_routingconfig = lambda_client.create_alias( + FunctionName=function_name, + Name="custom-empty-routingconfig", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {}}, + ) + snapshot.match("create_alias_empty_routingconfig", create_alias_empty_routingconfig) + + # "normal scenario" works: + create_alias_response = lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"2": 0.5}}, + ) + snapshot.match("create_alias_response", create_alias_response) + # can't create a second alias with the same name + with pytest.raises(lambda_client.exceptions.ResourceConflictException) as e: + lambda_client.create_alias( + FunctionName=function_name, + Name="custom", + FunctionVersion="1", + RoutingConfig={"AdditionalVersionWeights": {"2": 0.5}}, + ) + snapshot.match("routing_config_exc_already_exist", e.value.response) + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.get_alias( + FunctionName=function_name, + Name="non-existent", + ) + snapshot.match("alias_does_not_exist_esc", e.value.response) + + @markers.aws.validated + def test_alias_naming(self, aws_client, snapshot, create_lambda_function_aws, lambda_su_role): + """ + numbers can be included and can even start the alias name, but it can't be purely a number + """ + function_name = f"alias-fn-{short_uid()}" + create_response = create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Environment={"Variables": {"testenv": "staging"}}, + ) + snapshot.match("create_response", create_response) + + publish_v1 = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("publish_v1", publish_v1) + + # alias in date format + alias_name = "2024-01-02" + create_alias_date = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion="1", + Description="custom-alias", + ) + snapshot.match("create_alias_date", create_alias_date) + get_alias_date = aws_client.lambda_.get_alias(FunctionName=function_name, Name=alias_name) + snapshot.match("get_alias_date", get_alias_date) + aws_client.lambda_.invoke(FunctionName=f"{function_name}:{alias_name}") + + # alias as a number should fail + alias_name_number = "2024" + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.create_alias( + FunctionName=function_name, + Name=alias_name_number, + FunctionVersion="1", + Description="custom-alias", + ) + snapshot.match("create_alias_number_exception", e.value.response) + + +class TestLambdaRevisions: + @markers.snapshot.skip_snapshot_verify( + # The RuntimeVersionArn is currently a hardcoded id and therefore does not reflect the ARN resource update + # from python3.9 to python3.8 in update_function_configuration_response_rev5. + paths=[ + "update_function_configuration_response_rev5..RuntimeVersionConfig.RuntimeVersionArn", + "get_function_response_rev6..RuntimeVersionConfig.RuntimeVersionArn", + ] + ) + @markers.aws.validated + def test_function_revisions_basic(self, create_lambda_function, snapshot, aws_client): + """Tests basic revision id lifecycle for creating and updating functions""" + function_name = f"fn-{short_uid()}" + zip_file_content = load_file(TEST_LAMBDA_PYTHON_ECHO_ZIP, mode="rb") + + # rev1: create function + # The fixture waits until the function is not in Pending state anymore + create_function_response = create_lambda_function( + func_name=function_name, + zip_file=zip_file_content, + handler="index.handler", + runtime=Runtime.python3_12, + ) + snapshot.match("create_function_response_rev1", create_function_response) + rev1_create_function = create_function_response["CreateFunctionResponse"]["RevisionId"] + + # rev2: created function becomes active (the fixture does the waiting) + get_function_response_rev2 = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response_rev2", get_function_response_rev2) + rev2_active_state = get_function_response_rev2["Configuration"]["RevisionId"] + # State change from Pending to Active causes revision id change! + # Lambda function states: https://docs.aws.amazon.com/lambda/latest/dg/functions-states.html + assert rev1_create_function != rev2_active_state + + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.update_function_code( + FunctionName=function_name, + ZipFile=zip_file_content, + RevisionId="wrong", + ) + snapshot.match("update_function_revision_exception", e.value.response) + + # rev3: update function code + update_fn_code_response = aws_client.lambda_.update_function_code( + FunctionName=function_name, + ZipFile=zip_file_content, + RevisionId=rev2_active_state, + ) + snapshot.match("update_function_code_response_rev3", update_fn_code_response) + rev3_update_fn_code = update_fn_code_response["RevisionId"] + assert rev2_active_state != rev3_update_fn_code + + # rev4: function code update completed + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + get_function_response_rev4 = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response_rev4", get_function_response_rev4) + rev4_fn_code_updated = get_function_response_rev4["Configuration"]["RevisionId"] + assert rev3_update_fn_code != rev4_fn_code_updated + + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Runtime=Runtime.python3_8, RevisionId="wrong" + ) + snapshot.match("update_function_configuration_revision_exception", e.value.response) + + # rev5: update function configuration + update_fn_config_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Runtime=Runtime.python3_8, RevisionId=rev4_fn_code_updated + ) + snapshot.match("update_function_configuration_response_rev5", update_fn_config_response) + rev5_fn_config_update = update_fn_config_response["RevisionId"] + assert rev4_fn_code_updated != rev5_fn_config_update + + # rev6: function configuration updated completed + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + get_function_response_rev6 = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response_rev6", get_function_response_rev6) + rev6_fn_config_update_done = get_function_response_rev6["Configuration"]["RevisionId"] + assert rev5_fn_config_update != rev6_fn_config_update_done + + @markers.aws.validated + def test_function_revisions_version_and_alias( + self, create_lambda_function, snapshot, aws_client + ): + """Tests revision id lifecycle for 1) publishing function versions and 2) creating and updating aliases + Shortcut notation to clarify branching: + revN: revision counter for $LATEST + rev_vN: revision counter for versions + rev_aN: revision counter for aliases + """ + # rev1: create function + function_name = f"fn-{short_uid()}" + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + ) + snapshot.match("create_function_response_rev1", create_function_response) + rev1_create_function = create_function_response["CreateFunctionResponse"]["RevisionId"] + + # rev2: created function becomes active + get_function_response_rev2 = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_active_rev2", get_function_response_rev2) + rev2_active_state = get_function_response_rev2["Configuration"]["RevisionId"] + assert rev1_create_function != rev2_active_state + + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.publish_version(FunctionName=function_name, RevisionId="wrong") + snapshot.match("publish_version_revision_exception", e.value.response) + + # rev_v1: publish version + fn_version_response = aws_client.lambda_.publish_version( + FunctionName=function_name, RevisionId=rev2_active_state + ) + snapshot.match("publish_version_response_rev_v1", fn_version_response) + function_version = fn_version_response["Version"] + rev_v1_publish_version = fn_version_response["RevisionId"] + assert rev2_active_state != rev_v1_publish_version + + # rev_v2: published version becomes active does NOT change revision + aws_client.lambda_.get_waiter("published_version_active").wait(FunctionName=function_name) + get_function_response_rev_v2 = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=function_version + ) + snapshot.match("get_function_published_version_rev_v2", get_function_response_rev_v2) + rev_v2_publish_version_done = get_function_response_rev_v2["Configuration"]["RevisionId"] + assert rev_v1_publish_version == rev_v2_publish_version_done + + # publish_version changes the revision id of $LATEST + get_function_response_rev3 = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_latest_rev3", get_function_response_rev3) + rev3_publish_version = get_function_response_rev3["Configuration"]["RevisionId"] + assert rev2_active_state != rev3_publish_version + + # rev_a1: create alias + alias_name = "revision_alias" + create_alias_response = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion=function_version, + ) + snapshot.match("create_alias_response_rev_a1", create_alias_response) + rev_a1_create_alias = create_alias_response["RevisionId"] + assert rev_v2_publish_version_done != rev_a1_create_alias + + # create_alias does NOT change the revision id of $LATEST + get_function_response_rev4 = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_latest_rev4", get_function_response_rev4) + rev4_create_alias = get_function_response_rev4["Configuration"]["RevisionId"] + assert rev3_publish_version == rev4_create_alias + + # create_alias does NOT change the revision id of versions + get_function_response_rev_v3 = aws_client.lambda_.get_function( + FunctionName=function_name, Qualifier=function_version + ) + snapshot.match("get_function_published_version_rev_v3", get_function_response_rev_v3) + rev_v3_create_alias = get_function_response_rev_v3["Configuration"]["RevisionId"] + assert rev_v2_publish_version_done == rev_v3_create_alias + + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.update_alias( + FunctionName=function_name, + Name=alias_name, + RevisionId="wrong", + ) + snapshot.match("update_alias_revision_exception", e.value.response) + + # rev_a2: update alias + update_alias_response = aws_client.lambda_.update_alias( + FunctionName=function_name, + Name=alias_name, + Description="something changed", + RevisionId=rev_a1_create_alias, + ) + snapshot.match("update_alias_response_rev_a2", update_alias_response) + rev_a2_update_alias = update_alias_response["RevisionId"] + assert rev_a1_create_alias != rev_a2_update_alias + + @markers.aws.validated + def test_function_revisions_permissions(self, create_lambda_function, snapshot, aws_client): + """Tests revision id lifecycle for adding and removing permissions""" + # rev1: create function + function_name = f"fn-{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + ) + + # rev2: created function becomes active + get_function_response_rev2 = aws_client.lambda_.get_function(FunctionName=function_name) + rev2_active_state = get_function_response_rev2["Configuration"]["RevisionId"] + + sid = "s3" + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=sid, + Action="lambda:InvokeFunction", + Principal="s3.amazonaws.com", + RevisionId="wrong", + ) + snapshot.match("add_permission_revision_exception", e.value.response) + + # rev3: add permission + add_permission_response = aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=sid, + Action="lambda:InvokeFunction", + Principal="s3.amazonaws.com", + RevisionId=rev2_active_state, + ) + snapshot.match("add_permission_response", add_permission_response) + + get_policy_response_rev3 = aws_client.lambda_.get_policy(FunctionName=function_name) + snapshot.match("get_policy_response_rev3", get_policy_response_rev3) + rev3policy_added_permission = get_policy_response_rev3["RevisionId"] + assert rev2_active_state != rev3policy_added_permission + # function revision is the same as policy revision + get_function_response_rev3 = aws_client.lambda_.get_function(FunctionName=function_name) + rev3_added_permission = get_function_response_rev3["Configuration"]["RevisionId"] + assert rev3_added_permission == rev3policy_added_permission + + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.remove_permission( + FunctionName=function_name, StatementId=sid, RevisionId="wrong" + ) + snapshot.match("remove_permission_revision_exception", e.value.response) + + # rev4: remove permission + remove_permission_response = aws_client.lambda_.remove_permission( + FunctionName=function_name, StatementId=sid, RevisionId=rev3_added_permission + ) + snapshot.match("remove_permission_response", remove_permission_response) + + get_function_response_rev4 = aws_client.lambda_.get_function(FunctionName=function_name) + rev4_removed_permission = get_function_response_rev4["Configuration"]["RevisionId"] + assert rev3_added_permission != rev4_removed_permission + + +class TestLambdaTag: + @pytest.fixture(scope="function") + def fn_arn(self, create_lambda_function, aws_client): + """simple reusable setup to test tagging operations against Lambda function resources""" + function_name = f"fn-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + yield aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"][ + "FunctionArn" + ] + + @pytest.fixture(scope="function") + def esm_arn(self, fn_arn, create_event_source_mapping, sqs_create_queue, sqs_get_queue_arn): + """simple reusable setup to test tagging operations against ESM resources""" + + # Create an SQS queue and pass it as an event source for the mapping + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + create_response = create_event_source_mapping( + EventSourceArn=queue_arn, + FunctionName=fn_arn, + BatchSize=1, + ) + + yield create_response["EventSourceMappingArn"] + + @markers.aws.validated + def test_create_tag_on_fn_create(self, create_lambda_function, snapshot, aws_client): + function_name = f"fn-{short_uid()}" + custom_tag = f"tag-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(custom_tag, "")) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Tags={"testtag": custom_tag}, + ) + get_function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_result", get_function_result) + fn_arn = get_function_result["Configuration"]["FunctionArn"] + + list_tags_result = aws_client.lambda_.list_tags(Resource=fn_arn) + snapshot.match("list_tags_result", list_tags_result) + + @markers.aws.validated + def test_create_tag_on_esm_create( + self, + create_lambda_function, + create_event_source_mapping, + sqs_create_queue, + sqs_get_queue_arn, + snapshot, + aws_client, + ): + function_name = f"fn-{short_uid()}" + custom_tag = f"tag-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(custom_tag, "")) + + queue_url = sqs_create_queue() + queue_arn = sqs_get_queue_arn(queue_url) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + create_response = create_event_source_mapping( + EventSourceArn=queue_arn, + FunctionName=function_name, + BatchSize=1, + Tags={"testtag": custom_tag}, + ) + + uuid = create_response["UUID"] + + # the stream might not be active immediately(!) + def check_esm_active(): + return aws_client.lambda_.get_event_source_mapping(UUID=uuid)["State"] != "Creating" + + get_response = wait_until(check_esm_active) + snapshot.match("get_event_source_mapping_with_tag", get_response) + + esm_arn = create_response["EventSourceMappingArn"] + list_tags_result = aws_client.lambda_.list_tags(Resource=esm_arn) + snapshot.match("list_tags_result", list_tags_result) + + @pytest.mark.parametrize( + "resource_arn_fixture", + ["fn_arn", "esm_arn"], + ids=["lambda_function", "event_source_mapping"], + ) + @markers.aws.validated + def test_tag_lifecycle(self, snapshot, aws_client, resource_arn_fixture, request): + # Lazily get + resource_arn = request.getfixturevalue(resource_arn_fixture) + # 1. add tag + tag_single_response = aws_client.lambda_.tag_resource( + Resource=resource_arn, Tags={"A": "tag-a"} + ) + snapshot.match("tag_single_response", tag_single_response) + snapshot.match( + "tag_single_response_listtags", aws_client.lambda_.list_tags(Resource=resource_arn) + ) + + # 2. add multiple tags + tag_multiple_response = aws_client.lambda_.tag_resource( + Resource=resource_arn, Tags={"B": "tag-b", "C": "tag-c"} + ) + snapshot.match("tag_multiple_response", tag_multiple_response) + snapshot.match( + "tag_multiple_response_listtags", aws_client.lambda_.list_tags(Resource=resource_arn) + ) + + # 3. add overlapping tags + tag_overlap_response = aws_client.lambda_.tag_resource( + Resource=resource_arn, Tags={"C": "tag-c-newsuffix", "D": "tag-d"} + ) + snapshot.match("tag_overlap_response", tag_overlap_response) + snapshot.match( + "tag_overlap_response_listtags", aws_client.lambda_.list_tags(Resource=resource_arn) + ) + + # 3. remove tag + untag_single_response = aws_client.lambda_.untag_resource( + Resource=resource_arn, TagKeys=["A"] + ) + snapshot.match("untag_single_response", untag_single_response) + snapshot.match( + "untag_single_response_listtags", aws_client.lambda_.list_tags(Resource=resource_arn) + ) + + # 4. remove multiple tags + untag_multiple_response = aws_client.lambda_.untag_resource( + Resource=resource_arn, TagKeys=["B", "C"] + ) + snapshot.match("untag_multiple_response", untag_multiple_response) + snapshot.match( + "untag_multiple_response_listtags", aws_client.lambda_.list_tags(Resource=resource_arn) + ) + + # 5. try to remove only tags that don't exist + untag_nonexisting_response = aws_client.lambda_.untag_resource( + Resource=resource_arn, TagKeys=["F"] + ) + snapshot.match("untag_nonexisting_response", untag_nonexisting_response) + snapshot.match( + "untag_nonexisting_response_listtags", + aws_client.lambda_.list_tags(Resource=resource_arn), + ) + + # 6. remove a mix of tags that exist & don't exist + untag_existing_and_nonexisting_response = aws_client.lambda_.untag_resource( + Resource=resource_arn, TagKeys=["D", "F"] + ) + snapshot.match( + "untag_existing_and_nonexisting_response", untag_existing_and_nonexisting_response + ) + snapshot.match( + "untag_existing_and_nonexisting_response_listtags", + aws_client.lambda_.list_tags(Resource=resource_arn), + ) + + @pytest.mark.parametrize( + "create_resource_arn", + [lambda_function_arn, lambda_event_source_mapping_arn], + ids=["lambda_function", "event_source_mapping"], + ) + @markers.aws.validated + def test_tag_exceptions( + self, snapshot, aws_client, create_resource_arn, region_name, account_id + ): + resource_name = long_uid() + snapshot.add_transformer(snapshot.transform.regex(resource_name, "")) + + resource_arn = create_resource_arn(resource_name, account_id, region_name) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.tag_resource(Resource=resource_arn, Tags={"A": "B"}) + snapshot.match("not_found_exception_tag", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.untag_resource(Resource=resource_arn, TagKeys=["A"]) + snapshot.match("not_found_exception_untag", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.list_tags(Resource=resource_arn) + snapshot.match("not_found_exception_list", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.list_tags(Resource=f"{resource_arn}:alias") + snapshot.match("aliased_arn_exception", e.value.response) + + # change the resource name to an invalid one + parts = resource_arn.rsplit(":", 2) + parts[1] = "foobar" + invalid_resource_arn = ":".join(parts) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.list_tags(Resource=f"{invalid_resource_arn}") + snapshot.match("invalid_arn_exception", e.value.response) + + @markers.aws.validated + def test_tag_nonexisting_resource(self, snapshot, fn_arn, aws_client): + get_result = aws_client.lambda_.get_function(FunctionName=fn_arn) + snapshot.match("pre_delete_get_function", get_result) + aws_client.lambda_.delete_function(FunctionName=fn_arn) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.tag_resource(Resource=fn_arn, Tags={"A": "B"}) + snapshot.match("not_found_exception_tag", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.untag_resource(Resource=fn_arn, TagKeys=["A"]) + snapshot.match("not_found_exception_untag", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.list_tags(Resource=fn_arn) + snapshot.match("not_found_exception_list", e.value.response) + + +class TestLambdaEventInvokeConfig: + """TODO: add sqs & stream specific lifecycle snapshot tests""" + + @markers.aws.validated + def test_lambda_eventinvokeconfig_lifecycle( + self, create_lambda_function, lambda_su_role, snapshot, aws_client + ): + function_name = f"fn-eventinvoke-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + put_invokeconfig_retries_0 = aws_client.lambda_.put_function_event_invoke_config( + FunctionName=function_name, + MaximumRetryAttempts=0, + ) + snapshot.match("put_invokeconfig_retries_0", put_invokeconfig_retries_0) + + put_invokeconfig_eventage_60 = aws_client.lambda_.put_function_event_invoke_config( + FunctionName=function_name, MaximumEventAgeInSeconds=60 + ) + snapshot.match("put_invokeconfig_eventage_60", put_invokeconfig_eventage_60) + + update_invokeconfig_eventage_nochange = ( + aws_client.lambda_.update_function_event_invoke_config( + FunctionName=function_name, MaximumEventAgeInSeconds=60 + ) + ) + snapshot.match( + "update_invokeconfig_eventage_nochange", update_invokeconfig_eventage_nochange + ) + + update_invokeconfig_retries = aws_client.lambda_.update_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=1 + ) + snapshot.match("update_invokeconfig_retries", update_invokeconfig_retries) + + get_invokeconfig = aws_client.lambda_.get_function_event_invoke_config( + FunctionName=function_name + ) + snapshot.match("get_invokeconfig", get_invokeconfig) + + get_invokeconfig_latest = aws_client.lambda_.get_function_event_invoke_config( + FunctionName=function_name, Qualifier="$LATEST" + ) + snapshot.match("get_invokeconfig_latest", get_invokeconfig_latest) + + list_single_invokeconfig = aws_client.lambda_.list_function_event_invoke_configs( + FunctionName=function_name + ) + snapshot.match("list_single_invokeconfig", list_single_invokeconfig) + + # publish a version so we can have more than one entries for list ops + publish_version_result = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("publish_version_result", publish_version_result) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_function_event_invoke_config( + FunctionName=function_name, Qualifier=publish_version_result["Version"] + ) + snapshot.match("get_invokeconfig_postpublish", e.value.response) + + put_published_invokeconfig = aws_client.lambda_.put_function_event_invoke_config( + FunctionName=function_name, + Qualifier=publish_version_result["Version"], + MaximumEventAgeInSeconds=120, + ) + snapshot.match("put_published_invokeconfig", put_published_invokeconfig) + + get_published_invokeconfig = aws_client.lambda_.get_function_event_invoke_config( + FunctionName=function_name, Qualifier=publish_version_result["Version"] + ) + snapshot.match("get_published_invokeconfig", get_published_invokeconfig) + + # list paging + list_paging_single = aws_client.lambda_.list_function_event_invoke_configs( + FunctionName=function_name, MaxItems=1 + ) + list_paging_nolimit = aws_client.lambda_.list_function_event_invoke_configs( + FunctionName=function_name + ) + assert len(list_paging_single["FunctionEventInvokeConfigs"]) == 1 + assert len(list_paging_nolimit["FunctionEventInvokeConfigs"]) == 2 + + all_arns = {a["FunctionArn"] for a in list_paging_nolimit["FunctionEventInvokeConfigs"]} + + list_paging_remaining = aws_client.lambda_.list_function_event_invoke_configs( + FunctionName=function_name, Marker=list_paging_single["NextMarker"], MaxItems=1 + ) + assert len(list_paging_remaining["FunctionEventInvokeConfigs"]) == 1 + assert all_arns == { + list_paging_single["FunctionEventInvokeConfigs"][0]["FunctionArn"], + list_paging_remaining["FunctionEventInvokeConfigs"][0]["FunctionArn"], + } + + aws_client.lambda_.delete_function_event_invoke_config(FunctionName=function_name) + list_paging_nolimit_postdelete = aws_client.lambda_.list_function_event_invoke_configs( + FunctionName=function_name + ) + snapshot.match("list_paging_nolimit_postdelete", list_paging_nolimit_postdelete) + + @markers.aws.validated + def test_lambda_eventinvokeconfig_exceptions( + self, + create_lambda_function, + snapshot, + lambda_su_role, + account_id, + aws_client_factory, + aws_client, + ): + """some parts could probably be split apart (e.g. overwriting with update)""" + lambda_client = aws_client_factory(config=Config(parameter_validation=False)).lambda_ + snapshot.add_transformer( + SortingTransformer( + key="FunctionEventInvokeConfigs", sorting_fn=lambda conf: conf["FunctionArn"] + ) + ) + function_name = f"fn-eventinvoke-{short_uid()}" + function_name_2 = f"fn-eventinvoke-2-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + get_fn_result = lambda_client.get_function(FunctionName=function_name) + fn_arn = get_fn_result["Configuration"]["FunctionArn"] + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name_2, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + get_fn_result_2 = lambda_client.get_function(FunctionName=function_name_2) + fn_arn_2 = get_fn_result_2["Configuration"]["FunctionArn"] + + # one version and one alias + + fn_version_result = lambda_client.publish_version(FunctionName=function_name) + snapshot.match("fn_version_result", fn_version_result) + fn_version = fn_version_result["Version"] + + fn_alias_result = lambda_client.create_alias( + FunctionName=function_name, Name="eventinvokealias", FunctionVersion=fn_version + ) + snapshot.match("fn_alias_result", fn_alias_result) + fn_alias = fn_alias_result["Name"] + + # FunctionName tests + + region_name = lambda_client.meta.region_name + fake_arn = f"arn:{get_partition(region_name)}:lambda:{region_name}:{account_id}:function:doesnotexist" + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName="doesnotexist", MaximumRetryAttempts=1 + ) + snapshot.match("put_functionname_name_notfound", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=fake_arn, MaximumRetryAttempts=1 + ) + snapshot.match("put_functionname_arn_notfound", e.value.response) + + # Arguments missing + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config(FunctionName="doesnotexist") + snapshot.match("put_functionname_nootherargs", e.value.response) + + # Destination value tests + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=function_name, + DestinationConfig={"OnSuccess": {"Destination": fake_arn}}, + ) + snapshot.match("put_destination_lambda_doesntexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=function_name, DestinationConfig={"OnSuccess": {"Destination": fn_arn}} + ) + snapshot.match("put_destination_recursive", e.value.response) + + response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, DestinationConfig={"OnSuccess": {"Destination": fn_arn_2}} + ) + snapshot.match("put_destination_other_lambda", response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=function_name, + DestinationConfig={ + "OnSuccess": {"Destination": fn_arn.replace(":lambda:", ":iam:")} + }, + ) + snapshot.match("put_destination_invalid_service_arn", e.value.response) + + response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, DestinationConfig={"OnSuccess": {}} + ) + snapshot.match("put_destination_success_no_destination_arn", response) + + response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, DestinationConfig={"OnFailure": {}} + ) + snapshot.match("put_destination_failure_no_destination_arn", response) + + with pytest.raises(lambda_client.exceptions.ClientError) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=function_name, + DestinationConfig={ + "OnFailure": {"Destination": fn_arn.replace(":lambda:", ":_-/!lambda:")} + }, + ) + snapshot.match("put_destination_invalid_arn_pattern", e.value.response) + + # Function Name & Qualifier tests + response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=1 + ) + snapshot.match("put_destination_latest", response) + response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, Qualifier="$LATEST", MaximumRetryAttempts=1 + ) + snapshot.match("put_destination_latest_explicit_qualifier", response) + + response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, Qualifier=fn_version, MaximumRetryAttempts=1 + ) + snapshot.match("put_destination_version", response) + response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, Qualifier=fn_alias, MaximumRetryAttempts=1 + ) + snapshot.match("put_alias_functionname_qualifier", response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=function_name, + Qualifier=f"{fn_alias}doesnotexist", + MaximumRetryAttempts=1, + ) + snapshot.match("put_alias_doesnotexist", e.value.response) + response = lambda_client.put_function_event_invoke_config( + FunctionName=fn_alias_result["AliasArn"], MaximumRetryAttempts=1 + ) + snapshot.match("put_alias_qualifiedarn", response) + response = lambda_client.put_function_event_invoke_config( + FunctionName=fn_alias_result["AliasArn"], Qualifier=fn_alias, MaximumRetryAttempts=1 + ) + snapshot.match("put_alias_qualifiedarn_qualifier", response) + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=fn_alias_result["AliasArn"], + Qualifier=f"{fn_alias}doesnotexist", + MaximumRetryAttempts=1, + ) + snapshot.match("put_alias_qualifiedarn_qualifierconflict", e.value.response) + + response = lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:{fn_alias}", MaximumRetryAttempts=1 + ) + snapshot.match("put_alias_shorthand", response) + response = lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:{fn_alias}", Qualifier=fn_alias, MaximumRetryAttempts=1 + ) + snapshot.match("put_alias_shorthand_qualifier", response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:{fn_alias}", + Qualifier=f"{fn_alias}doesnotexist", + MaximumRetryAttempts=1, + ) + snapshot.match("put_alias_shorthand_qualifierconflict", e.value.response) + + # apparently this also works with function numbers (not in the docs!) + response = lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:{fn_version}", MaximumRetryAttempts=1 + ) + snapshot.match("put_version_shorthand", response) + + response = lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:$LATEST", Qualifier="$LATEST", MaximumRetryAttempts=1 + ) + snapshot.match("put_shorthand_qualifier_match", response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:{fn_version}", + Qualifier="$LATEST", + MaximumRetryAttempts=1, + ) + snapshot.match("put_shorthand_qualifier_mismatch_1", e.value.response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:$LATEST", + Qualifier=fn_version, + MaximumRetryAttempts=1, + ) + snapshot.match("put_shorthand_qualifier_mismatch_2", e.value.response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_event_invoke_config( + FunctionName=f"{function_name}:{fn_version}", + Qualifier=fn_alias, + MaximumRetryAttempts=1, + ) + snapshot.match("put_shorthand_qualifier_mismatch_3", e.value.response) + + put_maxevent_maxvalue_result = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=2, MaximumEventAgeInSeconds=21600 + ) + snapshot.match("put_maxevent_maxvalue_result", put_maxevent_maxvalue_result) + + # Test overwrite existing values + differences between put & update + # first create a config with both values set, then overwrite it with only one value set + + first_overwrite_response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=2, MaximumEventAgeInSeconds=60 + ) + snapshot.match("put_pre_overwrite", first_overwrite_response) + second_overwrite_response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=0 + ) + snapshot.match("put_post_overwrite", second_overwrite_response) + second_overwrite_existing_response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=0 + ) + snapshot.match("second_overwrite_existing_response", second_overwrite_existing_response) + get_postoverwrite_response = lambda_client.get_function_event_invoke_config( + FunctionName=function_name + ) + snapshot.match("get_post_overwrite", get_postoverwrite_response) + assert get_postoverwrite_response["MaximumRetryAttempts"] == 0 + assert "MaximumEventAgeInSeconds" not in get_postoverwrite_response + + pre_update_response = lambda_client.put_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=2, MaximumEventAgeInSeconds=60 + ) + snapshot.match("pre_update_response", pre_update_response) + update_response = lambda_client.update_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=0 + ) + snapshot.match("update_response", update_response) + + update_response_existing = lambda_client.update_function_event_invoke_config( + FunctionName=function_name, MaximumRetryAttempts=0 + ) + snapshot.match("update_response_existing", update_response_existing) + + get_postupdate_response = lambda_client.get_function_event_invoke_config( + FunctionName=function_name + ) + assert get_postupdate_response["MaximumRetryAttempts"] == 0 + assert get_postupdate_response["MaximumEventAgeInSeconds"] == 60 + + # Test delete & listing + list_response = lambda_client.list_function_event_invoke_configs(FunctionName=function_name) + snapshot.match("list_configs", list_response) + + paged_response = lambda_client.list_function_event_invoke_configs( + FunctionName=function_name, MaxItems=2 + ) # 2 out of 3 + assert len(paged_response["FunctionEventInvokeConfigs"]) == 2 + assert paged_response["NextMarker"] + + delete_latest = lambda_client.delete_function_event_invoke_config( + FunctionName=function_name, Qualifier="$LATEST" + ) + snapshot.match("delete_latest", delete_latest) + delete_version = lambda_client.delete_function_event_invoke_config( + FunctionName=function_name, Qualifier=fn_version + ) + snapshot.match("delete_version", delete_version) + delete_alias = lambda_client.delete_function_event_invoke_config( + FunctionName=function_name, Qualifier=fn_alias + ) + snapshot.match("delete_alias", delete_alias) + + list_response_postdelete = lambda_client.list_function_event_invoke_configs( + FunctionName=function_name + ) + snapshot.match("list_configs_postdelete", list_response_postdelete) + assert len(list_response_postdelete["FunctionEventInvokeConfigs"]) == 0 + + # already deleted, try to delete again + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.delete_function_event_invoke_config(FunctionName=function_name) + snapshot.match("delete_function_not_found", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.delete_function_event_invoke_config(FunctionName="doesnotexist") + snapshot.match("delete_function_doesnotexist", e.value.response) + + # more excs + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.list_function_event_invoke_configs(FunctionName="doesnotexist") + snapshot.match("list_function_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.get_function_event_invoke_config(FunctionName="doesnotexist") + snapshot.match("get_function_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.get_function_event_invoke_config( + FunctionName=function_name, Qualifier="doesnotexist" + ) + snapshot.match("get_qualifier_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.update_function_event_invoke_config( + FunctionName="doesnotexist", MaximumRetryAttempts=0 + ) + snapshot.match("update_eventinvokeconfig_function_doesnotexist", e.value.response) + + # ARN is valid but the alias doesn't have an event invoke config anymore (see previous delete) + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.get_function_event_invoke_config(FunctionName=fn_alias_result["AliasArn"]) + snapshot.match("get_eventinvokeconfig_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.update_function_event_invoke_config( + FunctionName=fn_alias_result["AliasArn"], MaximumRetryAttempts=0 + ) + snapshot.match( + "update_eventinvokeconfig_config_doesnotexist_with_qualifier", e.value.response + ) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.update_function_event_invoke_config( + FunctionName=fn_arn, MaximumRetryAttempts=0 + ) + snapshot.match( + "update_eventinvokeconfig_config_doesnotexist_without_qualifier", e.value.response + ) + + +# NOTE: These tests are inherently a bit flaky on AWS since they depend on account/region global usage limits/quotas +# Against AWS, these tests might require increasing the service quota for concurrent executions (e.g., 10 => 101): +# https://us-east-1.console.aws.amazon.com/servicequotas/home/services/lambda/quotas/L-B99A9384 +# New accounts in an organization have by default a quota of 10 or 50. +class TestLambdaReservedConcurrency: + @markers.aws.validated + def test_function_concurrency_exceptions(self, create_lambda_function, snapshot, aws_client): + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.put_function_concurrency( + FunctionName="doesnotexist", ReservedConcurrentExecutions=1 + ) + snapshot.match("put_function_concurrency_with_function_name_doesnotexist", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.put_function_concurrency( + FunctionName="doesnotexist", ReservedConcurrentExecutions=0 + ) + snapshot.match( + "put_function_concurrency_with_function_name_doesnotexist_and_invalid_concurrency", + e.value.response, + ) + + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + fn = aws_client.lambda_.get_function_configuration( + FunctionName=function_name, Qualifier="$LATEST" + ) + + qualified_arn_latest = fn["FunctionArn"] + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.put_function_concurrency( + FunctionName=qualified_arn_latest, ReservedConcurrentExecutions=0 + ) + snapshot.match("put_function_concurrency_with_qualified_arn", e.value.response) + + @markers.aws.validated + def test_function_concurrency_limits( + self, aws_client, aws_client_factory, create_lambda_function, snapshot, monkeypatch + ): + """Test limits exceptions separately because they require custom transformers.""" + monkeypatch.setattr(config, "LAMBDA_LIMITS_CONCURRENT_EXECUTIONS", 5) + monkeypatch.setattr(config, "LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY", 3) + + # We need to replace limits that are specific to AWS accounts (see test_provisioned_concurrency_limits) + # Unlike for provisioned concurrency, reserved concurrency does not have a different error message for + # values higher than the account limit of concurrent executions. + prefix = re.escape("minimum value of [") + number_pattern = "\d+" # noqa W605 + suffix = re.escape("]") + min_unreserved_regex = re.compile(f"(?<={prefix}){number_pattern}(?={suffix})") + snapshot.add_transformer( + snapshot.transform.regex(min_unreserved_regex, "") + ) + + lambda_client = aws_client.lambda_ + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + account_settings = aws_client.lambda_.get_account_settings() + concurrent_executions = account_settings["AccountLimit"]["ConcurrentExecutions"] + + # Higher reserved concurrency than ConcurrentExecutions account limit + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_concurrency( + FunctionName=function_name, + ReservedConcurrentExecutions=concurrent_executions + 1, + ) + snapshot.match("put_function_concurrency_account_limit_exceeded", e.value.response) + + # Not enough UnreservedConcurrentExecutions available in account + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_function_concurrency( + FunctionName=function_name, + ReservedConcurrentExecutions=concurrent_executions, + ) + snapshot.match("put_function_concurrency_below_unreserved_min_value", e.value.response) + + @markers.aws.validated + def test_function_concurrency(self, create_lambda_function, snapshot, aws_client, monkeypatch): + """Testing the api of the put function concurrency action""" + # A lower limits (e.g., 11) could work if the minium unreservered concurrency is lower as well + min_concurrent_executions = 101 + monkeypatch.setattr( + config, "LAMBDA_LIMITS_CONCURRENT_EXECUTIONS", min_concurrent_executions + ) + check_concurrency_quota(aws_client, min_concurrent_executions) + + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + # Disable the function by throttling all incoming events. + put_0_response = aws_client.lambda_.put_function_concurrency( + FunctionName=function_name, ReservedConcurrentExecutions=0 + ) + snapshot.match("put_function_concurrency_with_reserved_0", put_0_response) + + put_1_response = aws_client.lambda_.put_function_concurrency( + FunctionName=function_name, ReservedConcurrentExecutions=1 + ) + snapshot.match("put_function_concurrency_with_reserved_1", put_1_response) + + get_response = aws_client.lambda_.get_function_concurrency(FunctionName=function_name) + snapshot.match("get_function_concurrency", get_response) + + delete_response = aws_client.lambda_.delete_function_concurrency(FunctionName=function_name) + snapshot.match("delete_response", delete_response) + + get_response_after_delete = aws_client.lambda_.get_function_concurrency( + FunctionName=function_name + ) + snapshot.match("get_function_concurrency_after_delete", get_response_after_delete) + + # Maximum limit + account_settings = aws_client.lambda_.get_account_settings() + unreserved_concurrent_executions = account_settings["AccountLimit"][ + "UnreservedConcurrentExecutions" + ] + max_reserved_concurrent_executions = ( + unreserved_concurrent_executions - min_concurrent_executions + ) + put_max_response = aws_client.lambda_.put_function_concurrency( + FunctionName=function_name, + ReservedConcurrentExecutions=max_reserved_concurrent_executions, + ) + # Cannot snapshot this edge case because the maximum value depends on the AWS account + assert ( + put_max_response["ReservedConcurrentExecutions"] == max_reserved_concurrent_executions + ) + + +class TestLambdaProvisionedConcurrency: + # TODO: test ARN + # TODO: test shorthand ARN + @markers.aws.validated + def test_provisioned_concurrency_exceptions( + self, aws_client, aws_client_factory, create_lambda_function, snapshot + ): + lambda_client = aws_client_factory(config=Config(parameter_validation=False)).lambda_ + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + publish_version_result = lambda_client.publish_version(FunctionName=function_name) + function_version = publish_version_result["Version"] + snapshot.match("publish_version_result", publish_version_result) + + ### GET + + # normal (valid) structure, but function version doesn't have a provisioned config yet + with pytest.raises( + lambda_client.exceptions.ProvisionedConcurrencyConfigNotFoundException + ) as e: + lambda_client.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=function_version + ) + snapshot.match("get_provisioned_config_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.get_provisioned_concurrency_config( + FunctionName="doesnotexist", Qualifier="noalias" + ) + snapshot.match("get_provisioned_functionname_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="noalias" + ) + snapshot.match("get_provisioned_qualifier_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="10" + ) + snapshot.match("get_provisioned_version_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="$LATEST" + ) + snapshot.match("get_provisioned_latest", e.value.response) + + ### LIST + + list_empty = lambda_client.list_provisioned_concurrency_configs(FunctionName=function_name) + snapshot.match("list_provisioned_noconfigs", list_empty) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.list_provisioned_concurrency_configs(FunctionName="doesnotexist") + snapshot.match("list_provisioned_functionname_doesnotexist", e.value.response) + + ### DELETE + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.delete_provisioned_concurrency_config( + FunctionName="doesnotexist", Qualifier=function_version + ) + snapshot.match("delete_provisioned_functionname_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.delete_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="noalias" + ) + snapshot.match("delete_provisioned_qualifier_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.delete_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="10" + ) + snapshot.match("delete_provisioned_version_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.delete_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="$LATEST" + ) + snapshot.match("delete_provisioned_latest", e.value.response) + + delete_nonexistent = lambda_client.delete_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=function_version + ) + snapshot.match("delete_provisioned_config_doesnotexist", delete_nonexistent) + + ### PUT + + # is provisioned = 0 equal to deleted? => no, invalid + with pytest.raises(Exception) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ProvisionedConcurrentExecutions=0, + ) + snapshot.match("put_provisioned_invalid_param_0", e.value.response) + + # function does not exist + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName="doesnotexist", Qualifier="noalias", ProvisionedConcurrentExecutions=1 + ) + snapshot.match("put_provisioned_functionname_doesnotexist_alias", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName="doesnotexist", Qualifier="1", ProvisionedConcurrentExecutions=1 + ) + snapshot.match("put_provisioned_functionname_doesnotexist_version", e.value.response) + + # invalid alias + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier="doesnotexist", + ProvisionedConcurrentExecutions=1, + ) + snapshot.match("put_provisioned_qualifier_doesnotexist", e.value.response) + + with pytest.raises(lambda_client.exceptions.ResourceNotFoundException) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="10", ProvisionedConcurrentExecutions=1 + ) + snapshot.match("put_provisioned_version_doesnotexist", e.value.response) + + # set for $LATEST + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, Qualifier="$LATEST", ProvisionedConcurrentExecutions=1 + ) + snapshot.match("put_provisioned_latest", e.value.response) + + @markers.aws.validated + def test_provisioned_concurrency_limits( + self, aws_client, aws_client_factory, create_lambda_function, snapshot, monkeypatch + ): + """Test limits exceptions separately because this could be a dangerous test to run when misconfigured on AWS!""" + # Adjust limits in LocalStack to avoid creating a Lambda fork-bomb + monkeypatch.setattr(config, "LAMBDA_LIMITS_CONCURRENT_EXECUTIONS", 5) + monkeypatch.setattr(config, "LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY", 3) + + # We need to replace limits that are specific to AWS accounts + # Using positive lookarounds to ensure we replace the correct number (e.g., if both limits have the same value) + # Example: unreserved concurrency [10] => unreserved concurrency [] + prefix = re.escape("unreserved concurrency [") + number_pattern = "\d+" # noqa W605 + suffix = re.escape("]") + unreserved_regex = re.compile(f"(?<={prefix}){number_pattern}(?={suffix})") + snapshot.add_transformer( + snapshot.transform.regex(unreserved_regex, "") + ) + prefix = re.escape("minimum value of [") + min_unreserved_regex = re.compile(f"(?<={prefix}){number_pattern}(?={suffix})") + snapshot.add_transformer( + snapshot.transform.regex(min_unreserved_regex, "") + ) + + lambda_client = aws_client.lambda_ + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + publish_version_result = lambda_client.publish_version(FunctionName=function_name) + function_version = publish_version_result["Version"] + + account_settings = aws_client.lambda_.get_account_settings() + concurrent_executions = account_settings["AccountLimit"]["ConcurrentExecutions"] + + # Higher provisioned concurrency than ConcurrentExecutions account limit + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ProvisionedConcurrentExecutions=concurrent_executions + 1, + ) + snapshot.match("put_provisioned_concurrency_account_limit_exceeded", e.value.response) + assert ( + int(re.search(unreserved_regex, e.value.response["message"]).group(0)) + == concurrent_executions + ) + + # Not enough UnreservedConcurrentExecutions available in account + with pytest.raises(lambda_client.exceptions.InvalidParameterValueException) as e: + lambda_client.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ProvisionedConcurrentExecutions=concurrent_executions, + ) + snapshot.match("put_provisioned_concurrency_below_unreserved_min_value", e.value.response) + + @markers.aws.validated + def test_lambda_provisioned_lifecycle( + self, create_lambda_function, snapshot, aws_client, monkeypatch + ): + min_unreservered_executions = 10 + # Required +2 for the extra alias + min_concurrent_executions = min_unreservered_executions + 2 + monkeypatch.setattr( + config, "LAMBDA_LIMITS_CONCURRENT_EXECUTIONS", min_concurrent_executions + ) + monkeypatch.setattr( + config, "LAMBDA_LIMITS_MINIMUM_UNRESERVED_CONCURRENCY", min_unreservered_executions + ) + check_concurrency_quota(aws_client, min_concurrent_executions) + + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + publish_version_result = aws_client.lambda_.publish_version(FunctionName=function_name) + function_version = publish_version_result["Version"] + snapshot.match("publish_version_result", publish_version_result) + + aws_client.lambda_.get_waiter("function_active_v2").wait( + FunctionName=function_name, Qualifier=function_version + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait( + FunctionName=function_name, Qualifier=function_version + ) + + alias_name = f"alias-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(alias_name, "")) + create_alias_result = aws_client.lambda_.create_alias( + FunctionName=function_name, Name=alias_name, FunctionVersion=function_version + ) + snapshot.match("create_alias_result", create_alias_result) + + # some edge cases + + # attempt to set up provisioned concurrency for an alias that is pointing to a version that already has a provisioned concurrency setup + + put_provisioned_on_version = aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ProvisionedConcurrentExecutions=1, + ) + snapshot.match("put_provisioned_on_version", put_provisioned_on_version) + with pytest.raises(aws_client.lambda_.exceptions.ResourceConflictException) as e: + aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=alias_name, ProvisionedConcurrentExecutions=1 + ) + snapshot.match("put_provisioned_on_alias_versionconflict", e.value.response) + + # TODO: implement updates while IN_PROGRESS in LocalStack (currently not supported) + def _wait_provisioned(): + status = aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=function_version + )["Status"] + if status == "FAILED": + raise ShortCircuitWaitException("terminal fail state") + return status == "READY" + + assert wait_until(_wait_provisioned) + + delete_provisioned_version = aws_client.lambda_.delete_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=function_version + ) + snapshot.match("delete_provisioned_version", delete_provisioned_version) + + with pytest.raises( + aws_client.lambda_.exceptions.ProvisionedConcurrencyConfigNotFoundException + ) as e: + aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=function_version + ) + snapshot.match("get_provisioned_version_postdelete", e.value.response) + + # now the other way around + + put_provisioned_on_alias = aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=alias_name, + ProvisionedConcurrentExecutions=1, + ) + snapshot.match("put_provisioned_on_alias", put_provisioned_on_alias) + with pytest.raises(aws_client.lambda_.exceptions.ResourceConflictException) as e: + aws_client.lambda_.put_provisioned_concurrency_config( + FunctionName=function_name, + Qualifier=function_version, + ProvisionedConcurrentExecutions=1, + ) + snapshot.match("put_provisioned_on_version_conflict", e.value.response) + + # deleting the alias will also delete the provisioned concurrency config that points to it + delete_alias_result = aws_client.lambda_.delete_alias( + FunctionName=function_name, Name=alias_name + ) + snapshot.match("delete_alias_result", delete_alias_result) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_provisioned_concurrency_config( + FunctionName=function_name, Qualifier=alias_name + ) + snapshot.match("get_provisioned_alias_postaliasdelete", e.value.response) + + list_response_postdeletes = aws_client.lambda_.list_provisioned_concurrency_configs( + FunctionName=function_name + ) + assert len(list_response_postdeletes["ProvisionedConcurrencyConfigs"]) == 0 + snapshot.match("list_response_postdeletes", list_response_postdeletes) + + +class TestLambdaPermissions: + @markers.aws.validated + def test_permission_exceptions( + self, create_lambda_function, account_id, snapshot, aws_client, region_name + ): + function_name = f"lambda_func-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(function_name, "")) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + # invalid statement id + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="example.com", + Principal="s3.amazonaws.com", + ) + snapshot.match("add_permission_invalid_statement_id", e.value.response) + + # qualifier mismatch between specified Qualifier and derived ARN from FunctionName + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.add_permission( + FunctionName=f"{function_name}:alias-not-42", + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + Qualifier="42", + ) + snapshot.match("add_permission_fn_qualifier_mismatch", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.add_permission( + FunctionName=f"{function_name}:$LATEST", + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + Qualifier="$LATEST", + ) + snapshot.match("add_permission_fn_qualifier_latest", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="lambda", + Principal="invalid.nonaws.com", + # TODO: implement AWS principle matching based on explicit list + # Principal="invalid.amazonaws.com", + SourceAccount=account_id, + ) + snapshot.match("add_permission_principal_invalid", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_policy(FunctionName="doesnotexist") + snapshot.match("get_policy_fn_doesnotexist", e.value.response) + + non_existing_version = "77" + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_policy( + FunctionName=function_name, Qualifier=non_existing_version + ) + snapshot.match("get_policy_fn_version_doesnotexist", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.add_permission( + FunctionName="doesnotexist", + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + ) + snapshot.match("add_permission_fn_doesnotexist", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.remove_permission( + FunctionName=function_name, + StatementId="s3", + ) + snapshot.match("remove_permission_policy_doesnotexist", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.add_permission( + FunctionName=f"{function_name}:alias-doesnotexist", + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + ) + snapshot.match("add_permission_fn_alias_doesnotexist", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, # same behavior with version postfix :42 + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + Qualifier="42", + ) + snapshot.match("add_permission_fn_version_doesnotexist", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + Qualifier="invalid-qualifier-with-?-char", + ) + snapshot.match("add_permission_fn_qualifier_invalid", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + # NOTE: $ is allowed here because "$LATEST" is a valid version + Qualifier="valid-with-$-but-doesnotexist", + ) + snapshot.match("add_permission_fn_qualifier_valid_doesnotexist", e.value.response) + + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="s3", + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + ) + + sid = "s3" + with pytest.raises(aws_client.lambda_.exceptions.ResourceConflictException) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId=sid, + Principal="s3.amazonaws.com", + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + ) + snapshot.match("add_permission_conflicting_statement_id", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.remove_permission( + FunctionName="doesnotexist", + StatementId=sid, + ) + snapshot.match("remove_permission_fn_doesnotexist", e.value.response) + + non_existing_alias = "alias-doesnotexist" + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.remove_permission( + FunctionName=function_name, StatementId=sid, Qualifier=non_existing_alias + ) + snapshot.match("remove_permission_fn_alias_doesnotexist", e.value.response) + + @markers.aws.validated + def test_add_lambda_permission_aws( + self, create_lambda_function, account_id, snapshot, aws_client, region_name + ): + """Testing the add_permission call on lambda, by adding a new resource-based policy to a lambda function""" + + function_name = f"lambda_func-{short_uid()}" + lambda_create_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + snapshot.match("create_lambda", lambda_create_response) + # create lambda permission + action = "lambda:InvokeFunction" + sid = "s3" + principal = "s3.amazonaws.com" + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=sid, + Principal=principal, + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + ) + snapshot.match("add_permission", resp) + + # fetch lambda policy + get_policy_result = aws_client.lambda_.get_policy(FunctionName=function_name) + snapshot.match("get_policy", get_policy_result) + + @markers.aws.validated + def test_lambda_permission_fn_versioning( + self, create_lambda_function, account_id, snapshot, aws_client, region_name + ): + """Testing how lambda permissions behave when publishing different function versions and using qualifiers""" + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + # create lambda permission + action = "lambda:InvokeFunction" + sid = "s3" + principal = "s3.amazonaws.com" + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=sid, + Principal=principal, + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + ) + snapshot.match("add_permission", resp) + + # fetch lambda policy + get_policy_result_base = aws_client.lambda_.get_policy(FunctionName=function_name) + snapshot.match("get_policy", get_policy_result_base) + + # publish version + fn_version_result = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("publish_version_result", fn_version_result) + fn_version = fn_version_result["Version"] + aws_client.lambda_.get_waiter("published_version_active").wait(FunctionName=function_name) + get_function_result_after_publish = aws_client.lambda_.get_function( + FunctionName=function_name + ) + snapshot.match("get_function_result_after_publishing", get_function_result_after_publish) + get_policy_result_after_publishing = aws_client.lambda_.get_policy( + FunctionName=function_name + ) + snapshot.match("get_policy_after_publishing_latest", get_policy_result_after_publishing) + + # permissions apply per function unless providing a specific version or alias + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_policy(FunctionName=function_name, Qualifier=fn_version) + snapshot.match("get_policy_after_publishing_new_version", e.value.response) + + # create lambda permission with the same sid for specific function version + aws_client.lambda_.add_permission( + FunctionName=f"{function_name}:{fn_version}", # version suffix matching Qualifier + Action=action, + StatementId=sid, + Principal=principal, + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + Qualifier=fn_version, + ) + get_policy_result_version = aws_client.lambda_.get_policy( + FunctionName=function_name, Qualifier=fn_version + ) + snapshot.match("get_policy_version", get_policy_result_version) + + alias_name = "permission-alias" + create_alias_response = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion=fn_version, + ) + snapshot.match("create_alias_response", create_alias_response) + + get_alias_response = aws_client.lambda_.get_alias( + FunctionName=function_name, Name=alias_name + ) + snapshot.match("get_alias", get_alias_response) + assert get_alias_response["RevisionId"] == create_alias_response["RevisionId"] + + sid = "s3" + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=sid, + Principal=principal, + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + Qualifier=alias_name, + RevisionId="wrong", + ) + snapshot.match("add_permission_alias_revision_exception", e.value.response) + + # create lambda permission with the same sid for specific alias + aws_client.lambda_.add_permission( + FunctionName=f"{function_name}:{alias_name}", # alias suffix matching Qualifier + Action=action, + StatementId=sid, + Principal=principal, + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + Qualifier=alias_name, + RevisionId=create_alias_response["RevisionId"], + ) + get_policy_result_alias = aws_client.lambda_.get_policy( + FunctionName=function_name, Qualifier=alias_name + ) + snapshot.match("get_policy_alias", get_policy_result_alias) + + get_policy_result = aws_client.lambda_.get_policy(FunctionName=function_name) + snapshot.match("get_policy_after_adding_to_new_version", get_policy_result) + + # create lambda permission with other sid and correct revision id + aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=f"{sid}_2", + Principal=principal, + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + RevisionId=get_policy_result["RevisionId"], + ) + + get_policy_result_adding_2 = aws_client.lambda_.get_policy(FunctionName=function_name) + snapshot.match("get_policy_after_adding_2", get_policy_result_adding_2) + + @markers.aws.validated + def test_add_lambda_permission_fields( + self, create_lambda_function, account_id, snapshot, aws_client, region_name + ): + # prevent resource transformer from matching the LS default username "root", which collides with other resources + snapshot.add_transformer( + snapshot.transform.jsonpath( + "add_permission_principal_arn..Statement.Principal.AWS", + "", + reference_replacement=False, + ), + priority=-1, + ) + + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="wilcard", + Principal="*", + SourceAccount=account_id, + ) + snapshot.match("add_permission_principal_wildcard", resp) + + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="lambda", + Principal="lambda.amazonaws.com", + SourceAccount=account_id, + ) + snapshot.match("add_permission_principal_service", resp) + + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="account-id", + Principal=account_id, + ) + snapshot.match("add_permission_principal_account", resp) + + user_arn = aws_client.sts.get_caller_identity()["Arn"] + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action="lambda:InvokeFunction", + StatementId="user-arn", + Principal=user_arn, + SourceAccount=account_id, + ) + snapshot.match("add_permission_principal_arn", resp) + assert json.loads(resp["Statement"])["Principal"]["AWS"] == user_arn + + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + # optional fields: + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + SourceAccount=account_id, + PrincipalOrgID="o-1234567890", + # "FunctionUrlAuthType is only supported for lambda:InvokeFunctionUrl action" + FunctionUrlAuthType="NONE", + ) + snapshot.match("add_permission_optional_fields", resp) + + # create alexa skill lambda permission: + # https://developer.amazon.com/en-US/docs/alexa/custom-skills/host-a-custom-skill-as-an-aws-lambda-function.html#use-aws-cli + response = aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="alexaSkill", + Action="lambda:InvokeFunction", + Principal="*", + # alexa skill token cannot be used together with source account and source arn + EventSourceToken="amzn1.ask.skill.xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ) + snapshot.match("add_permission_alexa_skill", response) + + @markers.aws.validated + def test_remove_multi_permissions( + self, create_lambda_function, snapshot, aws_client, region_name + ): + """Tests creation and subsequent removal of multiple permissions, including the changes in the policy""" + + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + action = "lambda:InvokeFunction" + sid = "s3" + principal = "s3.amazonaws.com" + permission_1_add = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=sid, + Principal=principal, + ) + snapshot.match("add_permission_1", permission_1_add) + + sid_2 = "sqs" + principal_2 = "sqs.amazonaws.com" + permission_2_add = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=sid_2, + Principal=principal_2, + SourceArn=arns.s3_bucket_arn("test-bucket", region=region_name), + ) + snapshot.match("add_permission_2", permission_2_add) + policy_response = aws_client.lambda_.get_policy( + FunctionName=function_name, + ) + snapshot.match("policy_after_2_add", policy_response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.remove_permission( + FunctionName=function_name, + StatementId="non-existent", + ) + snapshot.match("remove_permission_exception_nonexisting_sid", e.value.response) + + aws_client.lambda_.remove_permission( + FunctionName=function_name, + StatementId=sid_2, + ) + + policy_response_removal = aws_client.lambda_.get_policy( + FunctionName=function_name, + ) + snapshot.match("policy_after_removal", policy_response_removal) + + policy_response_removal_attempt = aws_client.lambda_.get_policy( + FunctionName=function_name, + ) + snapshot.match("policy_after_removal_attempt", policy_response_removal_attempt) + + aws_client.lambda_.remove_permission( + FunctionName=function_name, + StatementId=sid, + RevisionId=policy_response_removal_attempt["RevisionId"], + ) + # get_policy raises an exception after removing all permissions + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as ctx: + aws_client.lambda_.get_policy(FunctionName=function_name) + snapshot.match("get_policy_exception_removed_all", ctx.value.response) + + @markers.aws.validated + def test_create_multiple_lambda_permissions(self, create_lambda_function, snapshot, aws_client): + """Test creating multiple lambda permissions and checking the policy""" + + function_name = f"test-function-{short_uid()}" + + create_lambda_function( + func_name=function_name, + runtime=Runtime.python3_12, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + ) + + action = "lambda:InvokeFunction" + sid = "logs" + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=sid, + Principal="logs.amazonaws.com", + ) + snapshot.match("add_permission_response_1", resp) + + sid = "kinesis" + resp = aws_client.lambda_.add_permission( + FunctionName=function_name, + Action=action, + StatementId=sid, + Principal="kinesis.amazonaws.com", + ) + snapshot.match("add_permission_response_2", resp) + + policy_response = aws_client.lambda_.get_policy( + FunctionName=function_name, + ) + snapshot.match("policy_after_2_add", policy_response) + + +class TestLambdaUrl: + @markers.aws.validated + def test_url_config_exceptions(self, create_lambda_function, snapshot, aws_client): + """ + note: list order is not defined + """ + snapshot.add_transformer( + snapshot.transform.key_value("FunctionUrl", "lambda-url", reference_replacement=False) + ) + snapshot.add_transformer( + SortingTransformer("FunctionUrlConfigs", sorting_fn=lambda x: x["FunctionArn"]) + ) + # broken at AWS yielding InternalFailure but should return InvalidParameterValueException as in + # get_function_url_config_qualifier_alias_doesnotmatch_arn + snapshot.add_transformer( + snapshot.transform.jsonpath( + "delete_function_url_config_qualifier_alias_doesnotmatch_arn", + "", + reference_replacement=False, + ), + priority=-1, + ) + function_name = f"test-function-{short_uid()}" + alias_name = "urlalias" + + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_NODEJS, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_handler.handler", + ) + fn_arn = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"][ + "FunctionArn" + ] + fn_version_result = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("fn_version_result", fn_version_result) + create_alias_result = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion=fn_version_result["Version"], + ) + snapshot.match("create_alias_result", create_alias_result) + + # function name + qualifier tests + fn_arn_doesnotexist = fn_arn.replace(function_name, "doesnotexist") + + def assert_name_and_qualifier(method: Callable, snapshot_prefix: str, tests, **kwargs): + for t in tests: + with pytest.raises(t["exc"]) as e: + method(**t["args"], **kwargs) + snapshot.match(f"{snapshot_prefix}_{t['SnapshotName']}", e.value.response) + + tests = [ + { + "args": {"FunctionName": "doesnotexist"}, + "SnapshotName": "name_doesnotexist", + "exc": aws_client.lambda_.exceptions.ResourceNotFoundException, + }, + { + "args": {"FunctionName": fn_arn_doesnotexist}, + "SnapshotName": "arn_doesnotexist", + "exc": aws_client.lambda_.exceptions.ResourceNotFoundException, + }, + { + "args": {"FunctionName": "doesnotexist", "Qualifier": "1"}, + "SnapshotName": "name_doesnotexist_qualifier", + "exc": aws_client.lambda_.exceptions.ClientError, + }, + { + "args": {"FunctionName": function_name, "Qualifier": "1"}, + "SnapshotName": "qualifier_version", + "exc": aws_client.lambda_.exceptions.ClientError, + }, + { + "args": {"FunctionName": function_name, "Qualifier": "2"}, + "SnapshotName": "qualifier_version_doesnotexist", + "exc": aws_client.lambda_.exceptions.ClientError, + }, + { + "args": {"FunctionName": function_name, "Qualifier": "v1"}, + "SnapshotName": "qualifier_alias_doesnotexist", + "exc": aws_client.lambda_.exceptions.ResourceNotFoundException, + }, + { + "args": { + "FunctionName": f"{function_name}:{alias_name}-doesnotmatch", + "Qualifier": alias_name, + }, + "SnapshotName": "qualifier_alias_doesnotmatch_arn", + "exc": aws_client.lambda_.exceptions.ClientError, + }, + { + "args": { + "FunctionName": function_name, + # Note: Shouldn't raise an exception (according to docs) but it does. + "Qualifier": "$LATEST", + }, + "SnapshotName": "qualifier_latest", + "exc": aws_client.lambda_.exceptions.ClientError, + }, + ] + config_doesnotexist_tests = [ + { + "args": {"FunctionName": function_name}, + "SnapshotName": "config_doesnotexist", + "exc": aws_client.lambda_.exceptions.ResourceNotFoundException, + }, + ] + + assert_name_and_qualifier( + aws_client.lambda_.create_function_url_config, + "create_function_url_config", + tests, + AuthType="NONE", + ) + assert_name_and_qualifier( + aws_client.lambda_.get_function_url_config, + "get_function_url_config", + tests + config_doesnotexist_tests, + ) + assert_name_and_qualifier( + aws_client.lambda_.delete_function_url_config, + "delete_function_url_config", + tests + config_doesnotexist_tests, + ) + assert_name_and_qualifier( + aws_client.lambda_.update_function_url_config, + "update_function_url_config", + tests + config_doesnotexist_tests, + AuthType="AWS_IAM", + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..FunctionUrlConfigs..InvokeMode"]) + @markers.aws.validated + def test_url_config_list_paging(self, create_lambda_function, snapshot, aws_client): + snapshot.add_transformer( + snapshot.transform.key_value("FunctionUrl", "lambda-url", reference_replacement=False) + ) + snapshot.add_transformer( + SortingTransformer("FunctionUrlConfigs", sorting_fn=lambda x: x["FunctionArn"]) + ) + function_name = f"test-function-{short_uid()}" + alias_name = "urlalias" + + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_NODEJS, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_handler.handler", + ) + + fn_version_result = aws_client.lambda_.publish_version(FunctionName=function_name) + snapshot.match("fn_version_result", fn_version_result) + create_alias_result = aws_client.lambda_.create_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion=fn_version_result["Version"], + ) + snapshot.match("create_alias_result", create_alias_result) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.list_function_url_configs(FunctionName="doesnotexist") + snapshot.match("list_function_notfound", e.value.response) + + list_all_empty = aws_client.lambda_.list_function_url_configs(FunctionName=function_name) + snapshot.match("list_all_empty", list_all_empty) + + url_config_fn = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, AuthType="NONE" + ) + snapshot.match("url_config_fn", url_config_fn) + url_config_alias = aws_client.lambda_.create_function_url_config( + FunctionName=f"{function_name}:{alias_name}", Qualifier=alias_name, AuthType="NONE" + ) + snapshot.match("url_config_alias", url_config_alias) + + list_all = aws_client.lambda_.list_function_url_configs(FunctionName=function_name) + snapshot.match("list_all", list_all) + + total_configs = [url_config_fn["FunctionUrl"], url_config_alias["FunctionUrl"]] + + list_max_1_item = aws_client.lambda_.list_function_url_configs( + FunctionName=function_name, MaxItems=1 + ) + assert len(list_max_1_item["FunctionUrlConfigs"]) == 1 + assert list_max_1_item["FunctionUrlConfigs"][0]["FunctionUrl"] in total_configs + + list_max_2_item = aws_client.lambda_.list_function_url_configs( + FunctionName=function_name, MaxItems=2 + ) + assert len(list_max_2_item["FunctionUrlConfigs"]) == 2 + assert list_max_2_item["FunctionUrlConfigs"][0]["FunctionUrl"] in total_configs + assert list_max_2_item["FunctionUrlConfigs"][1]["FunctionUrl"] in total_configs + + list_max_1_item_marker = aws_client.lambda_.list_function_url_configs( + FunctionName=function_name, MaxItems=1, Marker=list_max_1_item["NextMarker"] + ) + assert len(list_max_1_item_marker["FunctionUrlConfigs"]) == 1 + assert list_max_1_item_marker["FunctionUrlConfigs"][0]["FunctionUrl"] in total_configs + assert ( + list_max_1_item_marker["FunctionUrlConfigs"][0]["FunctionUrl"] + != list_max_1_item["FunctionUrlConfigs"][0]["FunctionUrl"] + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..InvokeMode"]) + @markers.aws.validated + def test_url_config_lifecycle(self, create_lambda_function, snapshot, aws_client): + snapshot.add_transformer( + snapshot.transform.key_value("FunctionUrl", "lambda-url", reference_replacement=False) + ) + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_NODEJS, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_handler.handler", + ) + + url_config_created = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + snapshot.match("url_creation", url_config_created) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceConflictException) as ex: + aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + snapshot.match("failed_duplication", ex.value.response) + + url_config_obtained = aws_client.lambda_.get_function_url_config(FunctionName=function_name) + snapshot.match("get_url_config", url_config_obtained) + + url_config_updated = aws_client.lambda_.update_function_url_config( + FunctionName=function_name, + AuthType="AWS_IAM", + ) + snapshot.match("updated_url_config", url_config_updated) + + aws_client.lambda_.delete_function_url_config(FunctionName=function_name) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as ex: + aws_client.lambda_.get_function_url_config(FunctionName=function_name) + snapshot.match("failed_getter", ex.value.response) + + @markers.snapshot.skip_snapshot_verify(paths=["$..InvokeMode"]) + @markers.aws.validated + def test_url_config_deletion_without_qualifier( + self, create_lambda_function_aws, lambda_su_role, snapshot, aws_client + ): + """ + This test checks that delete_function_url_config doesn't delete the function url configs of all aliases, + when not specifying the Qualifier. + """ + snapshot.add_transformer( + snapshot.transform.key_value("FunctionUrl", "lambda-url", reference_replacement=False) + ) + + function_name = f"alias-fn-{short_uid()}" + create_lambda_function_aws( + FunctionName=function_name, + Handler="index.handler", + Code={ + "ZipFile": create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True + ) + }, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Environment={"Variables": {"testenv": "staging"}}, + ) + aws_client.lambda_.publish_version(FunctionName=function_name) + + alias_name = "test-alias" + aws_client.lambda_.create_alias( + FunctionName=function_name, + Name=alias_name, + FunctionVersion="1", + Description="custom-alias", + ) + + url_config_created = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + snapshot.match("url_creation", url_config_created) + + url_config_with_alias_created = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + Qualifier=alias_name, + ) + snapshot.match("url_with_alias_creation", url_config_with_alias_created) + + url_config_obtained = aws_client.lambda_.get_function_url_config(FunctionName=function_name) + snapshot.match("get_url_config", url_config_obtained) + + url_config_obtained_with_alias = aws_client.lambda_.get_function_url_config( + FunctionName=function_name, Qualifier=alias_name + ) + snapshot.match("get_url_config_with_alias", url_config_obtained_with_alias) + + # delete function url config by only specifying function name (no qualifier) + delete_function_url_config_response = aws_client.lambda_.delete_function_url_config( + FunctionName=function_name + ) + snapshot.match("delete_function_url_config", delete_function_url_config_response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_function_url_config(FunctionName=function_name) + snapshot.match("get_url_config_after_deletion", e.value.response) + + # only specifying the function name, doesn't delete the url config from all related aliases + get_url_config_with_alias_after_deletion = aws_client.lambda_.get_function_url_config( + FunctionName=function_name, Qualifier=alias_name + ) + snapshot.match( + "get_url_config_with_alias_after_deletion", get_url_config_with_alias_after_deletion + ) + + @markers.aws.only_localstack + def test_create_url_config_custom_id_tag(self, create_lambda_function, aws_client): + custom_id_value = "my-custom-subdomain" + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_NODEJS, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_handler.handler", + Tags={TAG_KEY_CUSTOM_URL: custom_id_value}, + ) + url_config_created = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + # Since we're not comparing the entire string, this should be robust to + # region changes, https vs http, etc + assert f"://{custom_id_value}.lambda-url." in url_config_created["FunctionUrl"] + + @markers.aws.only_localstack + def test_create_url_config_custom_id_tag_invalid_id( + self, create_lambda_function, aws_client, caplog + ): + custom_id_value = "_not_valid_subdomain" + + function_name = f"test-function-{short_uid()}" + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_NODEJS, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_handler.handler", + Tags={TAG_KEY_CUSTOM_URL: custom_id_value}, + ) + + caplog.clear() + with caplog.at_level(logging.INFO): + url_config_created = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + ) + assert any("Invalid custom ID tag value" in record.message for record in caplog.records) + assert f"://{custom_id_value}.lambda-url." not in url_config_created["FunctionUrl"] + + @markers.aws.only_localstack + def test_create_url_config_custom_id_tag_alias(self, create_lambda_function, aws_client): + custom_id_value = "my-custom-subdomain" + function_name = f"test-function-{short_uid()}" + zip_contents = testutil.create_zip_file(TEST_LAMBDA_PYTHON_ECHO, get_content=True) + + create_lambda_function( + func_name=function_name, + zip_file=zip_contents, + runtime=Runtime.nodejs20_x, + handler="lambda_handler.handler", + Tags={TAG_KEY_CUSTOM_URL: custom_id_value}, + ) + + def _assert_create_function_url(qualifier: str | None, expected_url_id: str): + params = {"FunctionName": function_name, "AuthType": "NONE"} + if qualifier: + # Note: boto3 will throw an exception if the Qualifier parameter is None or "" + params["Qualifier"] = qualifier + + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + url_config_created = aws_client.lambda_.create_function_url_config(**params) + assert f"://{expected_url_id}.lambda-url." in url_config_created["FunctionUrl"] + + def _assert_create_aliased_function_url(fn_version: str, fn_alias: str): + aws_client.lambda_.create_alias( + FunctionName=function_name, FunctionVersion=fn_version, Name=fn_alias + ) + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + Qualifier=fn_alias, + ) + + _assert_create_function_url(fn_alias, f"{custom_id_value}-{fn_alias}") + + # Publishes a new version and creates an aliased URL + update_function_code_v1_resp = aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=zip_contents, Publish=True + ) + version = update_function_code_v1_resp.get("Version") + _assert_create_aliased_function_url(fn_version=version, fn_alias="v1") + + # Alias the $LATEST version + _assert_create_aliased_function_url(fn_version="$LATEST", fn_alias="latest") + + # Update the code, creating an unpublished version + update_function_code_latest_resp = aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=zip_contents + ) + + # Assert that both functions are equal + function_v1_sha256 = update_function_code_v1_resp.get("CodeSha256") + function_latest_sha256 = update_function_code_latest_resp.get("CodeSha256") + assert function_v1_sha256 and function_latest_sha256 + assert function_v1_sha256 == function_latest_sha256 + + # Assert that update actually did occur + last_modified_v1 = update_function_code_v1_resp.get("LastModified") + last_modified_latest = update_function_code_latest_resp.get("LastModified") + assert last_modified_latest > last_modified_v1 + + # Create a URL for an unpublished function + _assert_create_function_url(qualifier=None, expected_url_id=custom_id_value) + + # Ensure that these compound url-id's are stored correctly + with pytest.raises(aws_client.lambda_.exceptions.ResourceConflictException) as ex: + aws_client.lambda_.create_function_url_config( + FunctionName=function_name, AuthType="NONE", Qualifier="v1" + ) + assert ex.match("ResourceConflictException") + + # Ensure that all aliased URLs can be correctly retrieved + for alias in ["v1", "latest"]: + function_url = aws_client.lambda_.get_function_url_config( + FunctionName=function_name, Qualifier=alias + ).get("FunctionUrl") + assert f"://{custom_id_value}-{alias}.lambda-url." in function_url + + # Finally, check if the non-aliased URL can be retrieved + function_url = aws_client.lambda_.get_function_url_config(FunctionName=function_name).get( + "FunctionUrl" + ) + assert f"://{custom_id_value}.lambda-url." in function_url + + +class TestLambdaSizeLimits: + def _generate_sized_python_str(self, filepath: str, size: int) -> str: + """Generate a text of the specified size by appending #s at the end of the file""" + with open(filepath, "r") as f: + py_str = f.read() + py_str += "#" * (size - len(py_str)) + return py_str + + @markers.aws.validated + def test_oversized_request_create_lambda(self, lambda_su_role, snapshot, aws_client): + function_name = f"test_lambda_{short_uid()}" + # ensure that we are slightly below the zipped size limit because it is checked before the request limit + code_str = self._generate_sized_python_str( + TEST_LAMBDA_PYTHON_ECHO, config.LAMBDA_LIMITS_CODE_SIZE_ZIPPED - 1024 + ) + + # upload zip file to S3 + zip_file = testutil.create_lambda_archive( + code_str, get_content=True, runtime=Runtime.python3_12 + ) + + # enlarge the request beyond its limit while accounting for the zip file size + delta = ( + config.LAMBDA_LIMITS_CREATE_FUNCTION_REQUEST_SIZE + - config.LAMBDA_LIMITS_CODE_SIZE_ZIPPED + ) + large_env = self._generate_sized_python_str(TEST_LAMBDA_PYTHON_ECHO, delta + 1024 * 1024) + + # create lambda function + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Handler="handler.handler", + Role=lambda_su_role, + Code={"ZipFile": zip_file}, + Timeout=10, + Environment={"Variables": {"largeKey": large_env}}, + ) + snapshot.match("invalid_param_exc", e.value.response) + + @markers.aws.validated + def test_oversized_zipped_create_lambda(self, lambda_su_role, snapshot, aws_client): + function_name = f"test_lambda_{short_uid()}" + # use the highest boundary to test that the zipped size is checked before the request size + code_str = self._generate_sized_python_str( + TEST_LAMBDA_PYTHON_ECHO, config.LAMBDA_LIMITS_CODE_SIZE_ZIPPED + ) + + # upload zip file to S3 + zip_file = testutil.create_lambda_archive( + code_str, get_content=True, runtime=Runtime.python3_12 + ) + + # create lambda function + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Handler="handler.handler", + Role=lambda_su_role, + Code={"ZipFile": zip_file}, + Timeout=10, + ) + snapshot.match("invalid_param_exc", e.value.response) + + @markers.aws.validated + def test_oversized_unzipped_lambda(self, s3_bucket, lambda_su_role, snapshot, aws_client): + function_name = f"test_lambda_{short_uid()}" + bucket_key = "test_lambda.zip" + code_str = self._generate_sized_python_str( + TEST_LAMBDA_PYTHON_ECHO, config.LAMBDA_LIMITS_CODE_SIZE_UNZIPPED + ) + + # upload zip file to S3 + zip_file = testutil.create_lambda_archive( + code_str, get_content=True, runtime=Runtime.python3_12 + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + # create lambda function + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Handler="handler.handler", + Role=lambda_su_role, + Code={"S3Bucket": s3_bucket, "S3Key": bucket_key}, + Timeout=10, + ) + snapshot.match("invalid_param_exc", e.value.response) + + @markers.aws.validated + def test_large_lambda(self, s3_bucket, lambda_su_role, snapshot, cleanups, aws_client): + function_name = f"test_lambda_{short_uid()}" + cleanups.append(lambda: aws_client.lambda_.delete_function(FunctionName=function_name)) + bucket_key = "test_lambda.zip" + code_str = self._generate_sized_python_str( + TEST_LAMBDA_PYTHON_ECHO, config.LAMBDA_LIMITS_CODE_SIZE_UNZIPPED - 1000 + ) + + # upload zip file to S3 + zip_file = testutil.create_lambda_archive( + code_str, get_content=True, runtime=Runtime.python3_12 + ) + aws_client.s3.upload_fileobj(BytesIO(zip_file), s3_bucket, bucket_key) + + # create lambda function + result = aws_client.lambda_.create_function( + FunctionName=function_name, + Runtime=Runtime.python3_12, + Handler="handler.handler", + Role=lambda_su_role, + Code={"S3Bucket": s3_bucket, "S3Key": bucket_key}, + Timeout=10, + ) + snapshot.match("create_function_large_zip", result) + + # TODO: Test and fix deleting a non-active Lambda + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + @markers.aws.validated + def test_large_environment_variables_fails(self, create_lambda_function, snapshot, aws_client): + """Lambda functions with environment variables larger than 4 KB should fail to create.""" + snapshot.add_transformer(snapshot.transform.lambda_api()) + + # set up environment mapping with a total size of 4 KB + key = "LARGE_VAR" + key_bytes = string_length_bytes(key) + # need to reserve bytes for json encoding ({, }, 2x" and :). This is 7 + # bytes, so reserving 6 makes the environment variables one byte to + # big. + target_size = 4 * KB - 6 + large_envvar_bytes = target_size - key_bytes + large_envvar = "x" * large_envvar_bytes + + function_name = f"large-envvar-lambda-{short_uid()}" + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as ex: + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + envvars={ + "LARGE_VAR": large_envvar, + }, + ) + + snapshot.match("failed_create_fn_result", ex.value.response) + with pytest.raises(ClientError) as ex: + aws_client.lambda_.get_function(FunctionName=function_name) + + assert ex.match("ResourceNotFoundException") + + @markers.aws.validated + def test_large_environment_fails_multiple_keys( + self, create_lambda_function, snapshot, aws_client + ): + """Lambda functions with environment mappings larger than 4 KB should fail to create""" + snapshot.add_transformer(snapshot.transform.lambda_api()) + + # set up environment mapping with a total size of 4 KB + env = {"SMALL_VAR": "ok"} + + key = "LARGE_VAR" + # this size makes the environment > 4K + target_size = 4064 + large_envvar = "x" * target_size + env[key] = large_envvar + assert environment_length_bytes(env) == 4097 + + function_name = f"large-envvar-lambda-{short_uid()}" + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as ex: + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + envvars=env, + ) + + snapshot.match("failured_create_fn_result_multi_key", ex.value.response) + + with pytest.raises(ClientError) as exc: + aws_client.lambda_.get_function(FunctionName=function_name) + + assert exc.match("ResourceNotFoundException") + + @markers.aws.validated + def test_lambda_envvars_near_limit_succeeds(self, create_lambda_function, snapshot, aws_client): + """Lambda functions with environments less than or equal to 4 KB can be created.""" + snapshot.add_transformer(snapshot.transform.lambda_api()) + + # set up environment mapping with a total size of 4 KB + key = "LARGE_VAR" + key_bytes = string_length_bytes(key) + # the environment variable size is exactly 4KB, so should succeed + target_size = 4 * KB - 7 + large_envvar_bytes = target_size - key_bytes + large_envvar = "x" * large_envvar_bytes + + function_name = f"large-envvar-lambda-{short_uid()}" + res = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + envvars={ + "LARGE_VAR": large_envvar, + }, + ) + + snapshot.match("successful_create_fn_result", res) + aws_client.lambda_.get_function(FunctionName=function_name) + + +# TODO: test paging +# TODO: test function name / ARN resolving +class TestCodeSigningConfig: + @markers.aws.validated + def test_function_code_signing_config( + self, create_lambda_function, snapshot, account_id, aws_client, region_name + ): + """Testing the API of code signing config""" + + function_name = f"lambda_func-{short_uid()}" + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + response = aws_client.lambda_.create_code_signing_config( + Description="Testing CodeSigning Config", + AllowedPublishers={ + "SigningProfileVersionArns": [ + f"arn:{get_partition(region_name)}:signer:{region_name}:{account_id}:/signing-profiles/test", + ] + }, + CodeSigningPolicies={"UntrustedArtifactOnDeployment": "Enforce"}, + ) + snapshot.match("create_code_signing_config", response) + + code_signing_arn = response["CodeSigningConfig"]["CodeSigningConfigArn"] + response = aws_client.lambda_.update_code_signing_config( + CodeSigningConfigArn=code_signing_arn, + CodeSigningPolicies={"UntrustedArtifactOnDeployment": "Warn"}, + ) + snapshot.match("update_code_signing_config", response) + + response = aws_client.lambda_.get_code_signing_config(CodeSigningConfigArn=code_signing_arn) + snapshot.match("get_code_signing_config", response) + + response = aws_client.lambda_.put_function_code_signing_config( + CodeSigningConfigArn=code_signing_arn, FunctionName=function_name + ) + snapshot.match("put_function_code_signing_config", response) + + response = aws_client.lambda_.get_function_code_signing_config(FunctionName=function_name) + snapshot.match("get_function_code_signing_config", response) + + response = aws_client.lambda_.list_code_signing_configs() + + # TODO we should snapshot match entire response not just last element in list + # issue is that AWS creates 3 list entries where we only have one + # I believe on their end that they are keeping each configuration version as separate entry + snapshot.match("list_code_signing_configs", response["CodeSigningConfigs"][-1]) + + response = aws_client.lambda_.list_functions_by_code_signing_config( + CodeSigningConfigArn=code_signing_arn + ) + snapshot.match("list_functions_by_code_signing_config", response) + + response = aws_client.lambda_.delete_function_code_signing_config( + FunctionName=function_name + ) + snapshot.match("delete_function_code_signing_config", response) + + response = aws_client.lambda_.delete_code_signing_config( + CodeSigningConfigArn=code_signing_arn + ) + snapshot.match("delete_code_signing_config", response) + + @markers.aws.validated + def test_code_signing_not_found_excs( + self, snapshot, create_lambda_function, account_id, aws_client, region_name + ): + """tests for exceptions on missing resources and related corner cases""" + + function_name = f"lambda_func-{short_uid()}" + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + response = aws_client.lambda_.create_code_signing_config( + Description="Testing CodeSigning Config", + AllowedPublishers={ + "SigningProfileVersionArns": [ + f"arn:{get_partition(region_name)}:signer:{region_name}:{account_id}:/signing-profiles/test", + ] + }, + CodeSigningPolicies={"UntrustedArtifactOnDeployment": "Enforce"}, + ) + snapshot.match("create_code_signing_config", response) + + csc_arn = response["CodeSigningConfig"]["CodeSigningConfigArn"] + csc_arn_invalid = f"{csc_arn[:-1]}x" + snapshot.add_transformer(snapshot.transform.regex(csc_arn_invalid, "")) + + nonexisting_fn_name = "csc-test-doesnotexist" + + # deletes + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.delete_code_signing_config(CodeSigningConfigArn=csc_arn_invalid) + snapshot.match("delete_csc_notfound", e.value.response) + + nothing_to_delete_response = aws_client.lambda_.delete_function_code_signing_config( + FunctionName=function_name + ) + snapshot.match("nothing_to_delete_response", nothing_to_delete_response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.delete_function_code_signing_config( + FunctionName="csc-test-doesnotexist" + ) + snapshot.match("delete_function_csc_fnnotfound", e.value.response) + + # put + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.put_function_code_signing_config( + FunctionName=nonexisting_fn_name, CodeSigningConfigArn=csc_arn + ) + snapshot.match("put_function_csc_invalid_fnname", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.CodeSigningConfigNotFoundException) as e: + aws_client.lambda_.put_function_code_signing_config( + FunctionName=function_name, CodeSigningConfigArn=csc_arn_invalid + ) + snapshot.match("put_function_csc_invalid_csc_arn", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.CodeSigningConfigNotFoundException) as e: + aws_client.lambda_.put_function_code_signing_config( + FunctionName=nonexisting_fn_name, CodeSigningConfigArn=csc_arn_invalid + ) + snapshot.match("put_function_csc_invalid_both", e.value.response) + + # update csc + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.update_code_signing_config( + CodeSigningConfigArn=csc_arn_invalid, Description="new-description" + ) + snapshot.match("update_csc_invalid_csc_arn", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.update_code_signing_config(CodeSigningConfigArn=csc_arn_invalid) + snapshot.match("update_csc_noupdates", e.value.response) + + update_csc_noupdate_response = aws_client.lambda_.update_code_signing_config( + CodeSigningConfigArn=csc_arn + ) + snapshot.match("update_csc_noupdate_response", update_csc_noupdate_response) + + # get + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_code_signing_config(CodeSigningConfigArn=csc_arn_invalid) + snapshot.match("get_csc_invalid", e.value.response) + + get_function_csc_fnwithoutcsc = aws_client.lambda_.get_function_code_signing_config( + FunctionName=function_name + ) + snapshot.match("get_function_csc_fnwithoutcsc", get_function_csc_fnwithoutcsc) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_function_code_signing_config(FunctionName=nonexisting_fn_name) + snapshot.match("get_function_csc_nonexistingfn", e.value.response) + + # list + list_functions_by_csc_fnwithoutcsc = ( + aws_client.lambda_.list_functions_by_code_signing_config(CodeSigningConfigArn=csc_arn) + ) + snapshot.match("list_functions_by_csc_fnwithoutcsc", list_functions_by_csc_fnwithoutcsc) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.list_functions_by_code_signing_config( + CodeSigningConfigArn=csc_arn_invalid + ) + snapshot.match("list_functions_by_csc_invalid_cscarn", e.value.response) + + +class TestLambdaAccountSettings: + @markers.aws.validated + def test_account_settings(self, snapshot, aws_client): + """Limitation: only checks keys because AccountLimits are specific to AWS accounts. Example limits (2022-12-05): + + "AccountLimit": { + "TotalCodeSize": 80530636800, + "CodeSizeUnzipped": 262144000, + "CodeSizeZipped": 52428800, + "ConcurrentExecutions": 10, + "UnreservedConcurrentExecutions": 10 + }""" + acc_settings = aws_client.lambda_.get_account_settings() + acc_settings_modded = acc_settings + acc_settings_modded["AccountLimit"] = sorted(acc_settings["AccountLimit"].keys()) + acc_settings_modded["AccountUsage"] = sorted(acc_settings["AccountUsage"].keys()) + snapshot.match("acc_settings_modded", acc_settings_modded) + + @markers.aws.validated + def test_account_settings_total_code_size( + self, create_lambda_function, dummylayer, cleanups, snapshot, aws_client + ): + """Caveat: Could be flaky if another test simultaneously deletes a lambda function or layer in the same region. + Hence, testing for monotonically increasing `TotalCodeSize` rather than matching exact differences. + However, the parity tests use exact matching based on zip files with deterministic size. + """ + acc_settings0 = aws_client.lambda_.get_account_settings() + + # 1) create a new function + function_name = f"lambda_func-{short_uid()}" + zip_file_content = load_file(TEST_LAMBDA_PYTHON_ECHO_ZIP, mode="rb") + create_lambda_function( + zip_file=zip_file_content, + handler="index.handler", + func_name=function_name, + runtime=Runtime.python3_12, + ) + acc_settings1 = aws_client.lambda_.get_account_settings() + assert ( + acc_settings1["AccountUsage"]["TotalCodeSize"] + > acc_settings0["AccountUsage"]["TotalCodeSize"] + ) + assert ( + acc_settings1["AccountUsage"]["FunctionCount"] + > acc_settings0["AccountUsage"]["FunctionCount"] + ) + snapshot.match( + "total_code_size_diff_create_function", + acc_settings1["AccountUsage"]["TotalCodeSize"] + - acc_settings0["AccountUsage"]["TotalCodeSize"], + ) + + # 2) update the function + aws_client.lambda_.update_function_code( + FunctionName=function_name, ZipFile=zip_file_content, Publish=True + ) + # there is no need to wait until function_updated_v2 here because TotalCodeSize changes upon publishing + acc_settings2 = aws_client.lambda_.get_account_settings() + assert ( + acc_settings2["AccountUsage"]["TotalCodeSize"] + > acc_settings1["AccountUsage"]["TotalCodeSize"] + ) + snapshot.match( + "total_code_size_diff_update_function", + acc_settings2["AccountUsage"]["TotalCodeSize"] + - acc_settings1["AccountUsage"]["TotalCodeSize"], + ) + + # 3) publish a new layer + layer_name = f"testlayer-{short_uid()}" + publish_result1 = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, Content={"ZipFile": dummylayer} + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result1["Version"] + ) + ) + acc_settings3 = aws_client.lambda_.get_account_settings() + assert ( + acc_settings3["AccountUsage"]["TotalCodeSize"] + > acc_settings2["AccountUsage"]["TotalCodeSize"] + ) + snapshot.match( + "total_code_size_diff_publish_layer", + acc_settings3["AccountUsage"]["TotalCodeSize"] + - acc_settings2["AccountUsage"]["TotalCodeSize"], + ) + + # 4) publish a new layer version + publish_result2 = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, Content={"ZipFile": dummylayer} + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result2["Version"] + ) + ) + acc_settings4 = aws_client.lambda_.get_account_settings() + assert ( + acc_settings4["AccountUsage"]["TotalCodeSize"] + > acc_settings3["AccountUsage"]["TotalCodeSize"] + ) + snapshot.match( + "total_code_size_diff_publish_layer_version", + acc_settings4["AccountUsage"]["TotalCodeSize"] + - acc_settings3["AccountUsage"]["TotalCodeSize"], + ) + + @markers.aws.validated + def test_account_settings_total_code_size_config_update( + self, create_lambda_function, snapshot, aws_client + ): + """TotalCodeSize always changes when publishing a new lambda function, + even after config updates without code changes.""" + acc_settings0 = aws_client.lambda_.get_account_settings() + + # 1) create a new function + function_name = f"lambda_func-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_NODEJS, + func_name=function_name, + runtime=Runtime.nodejs18_x, + ) + acc_settings1 = aws_client.lambda_.get_account_settings() + assert ( + acc_settings1["AccountUsage"]["TotalCodeSize"] + > acc_settings0["AccountUsage"]["TotalCodeSize"] + ) + snapshot.match( + # fuzzy matching because exact the zip size differs by OS (e.g., 368 bytes) + "is_total_code_size_diff_create_function_more_than_200", + ( + acc_settings1["AccountUsage"]["TotalCodeSize"] + - acc_settings0["AccountUsage"]["TotalCodeSize"] + ) + > 200, + ) + + # 2) update function configuration (i.e., code remains identical) + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Runtime=Runtime.nodejs20_x + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + acc_settings2 = aws_client.lambda_.get_account_settings() + assert ( + acc_settings2["AccountUsage"]["TotalCodeSize"] + == acc_settings1["AccountUsage"]["TotalCodeSize"] + ) + snapshot.match( + "total_code_size_diff_update_function_configuration", + acc_settings2["AccountUsage"]["TotalCodeSize"] + - acc_settings1["AccountUsage"]["TotalCodeSize"], + ) + + # 3) publish updated function config + aws_client.lambda_.publish_version( + FunctionName=function_name, Description="actually publish the config update" + ) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + acc_settings3 = aws_client.lambda_.get_account_settings() + assert ( + acc_settings3["AccountUsage"]["TotalCodeSize"] + > acc_settings2["AccountUsage"]["TotalCodeSize"] + ) + snapshot.match( + "is_total_code_size_diff_publish_version_after_config_update_more_than_200", + ( + acc_settings3["AccountUsage"]["TotalCodeSize"] + - acc_settings2["AccountUsage"]["TotalCodeSize"] + ) + > 200, + ) + + +class TestLambdaEventSourceMappings: + @markers.aws.validated + def test_event_source_mapping_exceptions(self, snapshot, aws_client): + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_event_source_mapping(UUID=long_uid()) + snapshot.match("get_unknown_uuid", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.delete_event_source_mapping(UUID=long_uid()) + snapshot.match("delete_unknown_uuid", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.update_event_source_mapping(UUID=long_uid(), Enabled=False) + snapshot.match("update_unknown_uuid", e.value.response) + + # note: list doesn't care about the resource filters existing + aws_client.lambda_.list_event_source_mappings() + aws_client.lambda_.list_event_source_mappings(FunctionName="doesnotexist") + aws_client.lambda_.list_event_source_mappings( + EventSourceArn="arn:aws:sqs:us-east-1:111111111111:somequeue" + ) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.create_event_source_mapping(FunctionName="doesnotexist") + snapshot.match("create_no_event_source_arn", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName="doesnotexist", + EventSourceArn="arn:aws:sqs:us-east-1:111111111111:somequeue", + ) + snapshot.match("create_unknown_params", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName="doesnotexist", + EventSourceArn="arn:aws:sqs:us-east-1:111111111111:somequeue", + DestinationConfig={ + "OnSuccess": { + "Destination": "arn:aws:sqs:us-east-1:111111111111:someotherqueue" + } + }, + ) + snapshot.match("destination_config_failure", e.value.response) + + # TODO: add test for event source arn == failure destination + # TODO: add test for adding success destination + # TODO: add test_multiple_esm_conflict: create an event source mapping for a combination of function + target ARN that already exists + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # all dynamodb service issues not related to lambda + "$..TableDescription.DeletionProtectionEnabled", + "$..TableDescription.ProvisionedThroughput.LastDecreaseDateTime", + "$..TableDescription.ProvisionedThroughput.LastIncreaseDateTime", + "$..TableDescription.TableStatus", + "$..TableDescription.TableId", + "$..UUID", + ] + ) + @markers.aws.validated + def test_event_source_mapping_lifecycle( + self, + create_lambda_function, + snapshot, + sqs_create_queue, + cleanups, + lambda_su_role, + dynamodb_create_table, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + table_name = f"teststreamtable-{short_uid()}" + + destination_queue_url = sqs_create_queue() + destination_queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=destination_queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + dynamodb_create_table(table_name=table_name, partition_key="id") + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + ) + snapshot.match("update_table_response", update_table_response) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + # "minimal" + create_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=stream_arn, + DestinationConfig={"OnFailure": {"Destination": destination_queue_arn}}, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + ) + uuid = create_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + snapshot.match("create_response", create_response) + + # the stream might not be active immediately(!) + def check_esm_active(): + return aws_client.lambda_.get_event_source_mapping(UUID=uuid)["State"] != "Creating" + + assert wait_until(check_esm_active) + + get_response = aws_client.lambda_.get_event_source_mapping(UUID=uuid) + snapshot.match("get_response", get_response) + # + delete_response = aws_client.lambda_.delete_event_source_mapping(UUID=uuid) + snapshot.match("delete_response", delete_response) + + # TODO: continue here after initial CRUD PR + # check what happens when we delete the function + # check behavior in relation to version/alias + # wait until the stream is actually active + # + # lambda_client.update_event_source_mapping() + # + # lambda_client.list_event_source_mappings(FunctionName=function_name) + # lambda_client.list_event_source_mappings(FunctionName=function_name, EventSourceArn=queue_arn) + # lambda_client.list_event_source_mappings(EventSourceArn=queue_arn) + # + # lambda_client.delete_event_source_mapping(UUID=uuid) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # all dynamodb service issues not related to lambda + "$..TableDescription.DeletionProtectionEnabled", + "$..TableDescription.ProvisionedThroughput.LastDecreaseDateTime", + "$..TableDescription.ProvisionedThroughput.LastIncreaseDateTime", + "$..TableDescription.TableStatus", + "$..TableDescription.TableId", + "$..UUID", + ] + ) + @markers.aws.validated + def test_event_source_mapping_lifecycle_delete_function( + self, + create_lambda_function, + snapshot, + sqs_create_queue, + cleanups, + lambda_su_role, + dynamodb_create_table, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + table_name = f"teststreamtable-{short_uid()}" + + destination_queue_url = sqs_create_queue() + destination_queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=destination_queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + dynamodb_create_table(table_name=table_name, partition_key="id") + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_IMAGE"}, + ) + snapshot.match("update_table_response", update_table_response) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + # "minimal" + create_response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=stream_arn, + DestinationConfig={"OnFailure": {"Destination": destination_queue_arn}}, + BatchSize=1, + StartingPosition="TRIM_HORIZON", + MaximumBatchingWindowInSeconds=1, + MaximumRetryAttempts=1, + ) + + uuid = create_response["UUID"] + cleanups.append(lambda: aws_client.lambda_.delete_event_source_mapping(UUID=uuid)) + snapshot.match("create_response", create_response) + + # the stream might not be active immediately(!) + _await_event_source_mapping_enabled(aws_client.lambda_, uuid) + + get_response = aws_client.lambda_.get_event_source_mapping(UUID=uuid) + snapshot.match("get_response", get_response) + + delete_function_response = aws_client.lambda_.delete_function(FunctionName=function_name) + snapshot.match("delete_function_response", delete_function_response) + + def _assert_function_deleted(): + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_function(FunctionName=function_name) + return True + + wait_until(_assert_function_deleted) + + get_response_post_delete = aws_client.lambda_.get_event_source_mapping(UUID=uuid) + snapshot.match("get_response_post_delete", get_response_post_delete) + # + delete_response = aws_client.lambda_.delete_event_source_mapping(UUID=uuid) + snapshot.match("delete_response", delete_response) + + @markers.aws.validated + def test_function_name_variations( + self, + create_lambda_function, + snapshot, + sqs_create_queue, + cleanups, + lambda_su_role, + aws_client, + ): + function_name = f"lambda_func-{short_uid()}" + + queue_url = sqs_create_queue() + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + # create version & alias pointing to the version + v1 = aws_client.lambda_.publish_version(FunctionName=function_name) + alias = aws_client.lambda_.create_alias( + FunctionName=function_name, FunctionVersion=v1["Version"], Name="myalias" + ) + fn = aws_client.lambda_.get_function(FunctionName=function_name) + + def _create_esm(snapshot_scope: str, tested_name: str): + result = aws_client.lambda_.create_event_source_mapping( + FunctionName=tested_name, + EventSourceArn=queue_arn, + ) + cleanups.append( + lambda: aws_client.lambda_.delete_event_source_mapping(UUID=result["UUID"]) + ) + snapshot.match(f"{snapshot_scope}_create_esm", result) + _await_event_source_mapping_enabled(aws_client.lambda_, result["UUID"]) + aws_client.lambda_.delete_event_source_mapping(UUID=result["UUID"]) + + def _assert_esm_deleted(): + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException): + aws_client.lambda_.get_event_source_mapping(UUID=result["UUID"]) + + return True + + wait_until(_assert_esm_deleted) + + _create_esm("name_only", function_name) + _create_esm("partial_arn_latest", f"{function_name}:$LATEST") + _create_esm("partial_arn_version", f"{function_name}:{v1['Version']}") + _create_esm("partial_arn_alias", f"{function_name}:{alias['Name']}") + _create_esm("full_arn_latest", fn["Configuration"]["FunctionArn"]) + _create_esm("full_arn_version", v1["FunctionArn"]) + _create_esm("full_arn_alias", alias["AliasArn"]) + + @markers.aws.validated + def test_create_event_source_validation( + self, create_lambda_function, lambda_su_role, dynamodb_create_table, snapshot, aws_client + ): + """missing & invalid required field for DynamoDb stream event source mapping""" + function_name = f"function-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + table_name = f"table-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(table_name, "")) + + dynamodb_create_table(table_name=table_name, partition_key="id") + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + snapshot.add_transformer( + snapshot.transform.regex( + update_table_response["TableDescription"]["LatestStreamLabel"], "" + ) + ) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, EventSourceArn=stream_arn + ) + snapshot.match("no_starting_position", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, EventSourceArn=stream_arn, StartingPosition="invalid" + ) + snapshot.match("invalid_starting_position", e.value.response) + + # AT_TIMESTAMP is not supported for DynamoDBStreams + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=stream_arn, + StartingPosition="AT_TIMESTAMP", + StartingPositionTimestamp="1741010802", + ) + snapshot.match("incompatible_starting_position", e.value.response) + + @markers.aws.validated + def test_create_event_source_validation_kinesis( + self, + create_lambda_function, + lambda_su_role, + kinesis_create_stream, + wait_for_stream_ready, + snapshot, + aws_client, + ): + """missing & invalid required field for Kinesis stream event source mapping""" + + snapshot.add_transformer(snapshot.transform.kinesis_api()) + + function_name = f"function-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + stream_name = f"stream-{short_uid()}" + kinesis_create_stream(StreamName=stream_name, ShardCount=1) + wait_for_stream_ready(stream_name) + + stream_arn = aws_client.kinesis.describe_stream(StreamName=stream_name)[ + "StreamDescription" + ]["StreamARN"] + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, EventSourceArn=stream_arn + ) + snapshot.match("no_starting_position", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, EventSourceArn=stream_arn, StartingPosition="invalid" + ) + snapshot.match("invalid_starting_position", e.value.response) + + @markers.aws.validated + def test_create_event_filter_criteria_validation( + self, + create_lambda_function, + lambda_su_role, + dynamodb_create_table, + snapshot, + aws_client, + ): + function_name = f"function-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + + table_name = f"table-{short_uid()}" + # FIXME: Why is this not being automatically transformed? + snapshot.add_transformer(snapshot.transform.regex(table_name, "")) + + dynamodb_create_table(table_name=table_name, partition_key="id") + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + update_table_response = aws_client.dynamodb.update_table( + TableName=table_name, + StreamSpecification={"StreamEnabled": True, "StreamViewType": "NEW_AND_OLD_IMAGES"}, + ) + stream_arn = update_table_response["TableDescription"]["LatestStreamArn"] + + response = aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=stream_arn, + StartingPosition="LATEST", + FilterCriteria={"Filters": []}, + ) + snapshot.match("response-with-empty-filters", response) + + with pytest.raises(ParamValidationError): + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=stream_arn, + StartingPosition="LATEST", + FilterCriteria={"Filters": [{"Pattern": []}]}, + ) + + with pytest.raises(ParamValidationError): + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=stream_arn, + StartingPosition="LATEST", + FilterCriteria={"wrong": []}, + ) + + with pytest.raises(ParamValidationError): + aws_client.lambda_.create_event_source_mapping( + FunctionName=function_name, + EventSourceArn=stream_arn, + StartingPosition="LATEST", + FilterCriteria=None, + ) + + @markers.aws.validated + @pytest.mark.skip(reason="ESM v2 validation for Kafka poller only works with ext") + def test_create_event_source_self_managed( + self, + create_lambda_function, + lambda_su_role, + snapshot, + aws_client, + create_secret, + create_event_source_mapping, + ): + function_name = f"function-{short_uid()}" + secret_name = f"secret-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + secret = create_secret( + Name=secret_name, + SecretString=json.dumps({"username": "someUsername", "password": "somePassword"}), + ) + + # Missing SourceAccessConfigurations + with pytest.raises(ClientError) as e: + create_event_source_mapping( + Topics=["topic"], + FunctionName=function_name, + SelfManagedEventSource={"Endpoints": {"KAFKA_BOOTSTRAP_SERVERS": ["kafka:1000"]}}, + ) + snapshot.match("missing-source-access-configuration", e.value.response) + + # default values + event_source_mapping = create_event_source_mapping( + Topics=["topic"], + FunctionName=function_name, + SourceAccessConfigurations=[{"Type": "BASIC_AUTH", "URI": secret["ARN"]}], + SelfManagedEventSource={"Endpoints": {"KAFKA_BOOTSTRAP_SERVERS": ["kafka:1000"]}}, + ) + snapshot.match("event-source-mapping-default", event_source_mapping) + + # Duplicate source + with pytest.raises(ClientError) as e: + create_event_source_mapping( + Topics=["topic"], + FunctionName=function_name, + SourceAccessConfigurations=[{"Type": "BASIC_AUTH", "URI": secret["ARN"]}], + SelfManagedEventSource={"Endpoints": {"KAFKA_BOOTSTRAP_SERVERS": ["kafka:1000"]}}, + ) + snapshot.match("duplicate-source", e.value.response) + + # override default + event_source_mapping = create_event_source_mapping( + Topics=["topic_2"], + FunctionName=function_name, + SourceAccessConfigurations=[{"Type": "BASIC_AUTH", "URI": secret["ARN"]}], + SelfManagedEventSource={"Endpoints": {"KAFKA_BOOTSTRAP_SERVERS": ["kafka:1000"]}}, + BatchSize=1, + SelfManagedKafkaEventSourceConfig={"ConsumerGroupId": "random_id"}, + StartingPosition="LATEST", + ) + snapshot.match("event-source-mapping-values", event_source_mapping) + + # Multiple Duplicate source + with pytest.raises(ClientError) as e: + create_event_source_mapping( + Topics=["topic"], + FunctionName=function_name, + SourceAccessConfigurations=[{"Type": "BASIC_AUTH", "URI": secret["ARN"]}], + SelfManagedEventSource={ + "Endpoints": {"KAFKA_BOOTSTRAP_SERVERS": ["kafka:1000", "kafka:2000"]} + }, + BatchSize=1, + SelfManagedKafkaEventSourceConfig={"ConsumerGroupId": "random_id"}, + ) + snapshot.match("multiple-duplicate-source", e.value.response) + + +class TestLambdaTags: + @markers.aws.validated + def test_tag_exceptions( + self, create_lambda_function, snapshot, account_id, aws_client, region_name + ): + function_name = f"fn-tag-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + function_arn = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"][ + "FunctionArn" + ] + arn_prefix = f"arn:{get_partition(region_name)}:lambda:{region_name}:{account_id}:function:" + + # invalid ARN + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.tag_resource( + Resource=f"arn:{get_partition(region_name)}:something", Tags={"key_a": "value_a"} + ) + snapshot.match("tag_lambda_invalidarn", e.value.response) + + # ARN valid but lambda function doesn't exist + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.tag_resource( + Resource=f"{arn_prefix}doesnotexist", Tags={"key_a": "value_a"} + ) + snapshot.match("tag_lambda_doesnotexist", e.value.response) + + # function exists but the qualifier in the ARN doesn't + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.tag_resource( + Resource=f"{function_arn}:v1", Tags={"key_a": "value_a"} + ) + snapshot.match("tag_lambda_qualifier_doesnotexist", e.value.response) + + # get tags for resource that never had tags + list_tags_response = aws_client.lambda_.list_tags(Resource=function_arn) + snapshot.match("list_tag_lambda_empty", list_tags_response) + + # delete non-existing tag key + untag_nomatch = aws_client.lambda_.untag_resource( + Resource=function_arn, TagKeys=["somekey"] + ) + snapshot.match("untag_nomatch", untag_nomatch) + + # delete empty tags + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.untag_resource(Resource=function_arn, TagKeys=[]) + snapshot.match("untag_empty_keys", e.value.response) + + # add empty tags + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.tag_resource(Resource=function_arn, Tags={}) + snapshot.match("tag_empty_tags", e.value.response) + + # partial delete (one exists, one doesn't) + aws_client.lambda_.tag_resource( + Resource=function_arn, Tags={"a_key": "a_value", "b_key": "b_value"} + ) + aws_client.lambda_.untag_resource(Resource=function_arn, TagKeys=["a_key", "c_key"]) + assert "a_key" not in aws_client.lambda_.list_tags(Resource=function_arn)["Tags"] + assert "b_key" in aws_client.lambda_.list_tags(Resource=function_arn)["Tags"] + + @markers.aws.validated + def test_tag_limits(self, create_lambda_function, snapshot, aws_client, lambda_su_role): + """test the limit of 50 tags per resource""" + function_name = f"fn-tag-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + function_arn = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"][ + "FunctionArn" + ] + + # invalid + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.tag_resource( + Resource=function_arn, Tags={f"{k}_key": f"{k}_value" for k in range(51)} + ) + snapshot.match("tag_lambda_too_many_tags", e.value.response) + + # valid + tag_response = aws_client.lambda_.tag_resource( + Resource=function_arn, Tags={f"{k}_key": f"{k}_value" for k in range(50)} + ) + snapshot.match("tag_response", tag_response) + + list_tags_response = aws_client.lambda_.list_tags(Resource=function_arn) + snapshot.match("list_tags_response", list_tags_response) + + get_fn_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_fn_response", get_fn_response) + + # try to add one more :) + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.tag_resource(Resource=function_arn, Tags={"a_key": "a_value"}) + snapshot.match("tag_lambda_too_many_tags_additional", e.value.response) + + # add too many tags on a CreateFunction + function_name = f"fn-tag-{short_uid()}" + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="index.handler", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.python3_12, + Tags={f"{k}_key": f"{k}_value" for k in range(51)}, + ) + snapshot.match("create_function_invalid_tags", e.value.response) + + @markers.aws.validated + def test_tag_versions(self, create_lambda_function, snapshot, aws_client): + function_name = f"fn-tag-{short_uid()}" + create_function_result = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Tags={"key_a": "value_a"}, + ) + function_arn = create_function_result["CreateFunctionResponse"]["FunctionArn"] + publish_version_response = aws_client.lambda_.publish_version(FunctionName=function_name) + version_arn = publish_version_response["FunctionArn"] + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.tag_resource( + Resource=version_arn, + Tags={ + "key_b": "value_b", + "key_c": "value_c", + "key_d": "value_d", + "key_e": "value_e", + }, + ) + snapshot.match("tag_resource_exception", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.tag_resource( + Resource=f"{function_arn}:$LATEST", + Tags={ + "key_b": "value_b", + "key_c": "value_c", + "key_d": "value_d", + "key_e": "value_e", + }, + ) + snapshot.match("tag_resource_latest_exception", e.value.response) + + @markers.aws.validated + def test_tag_lifecycle(self, create_lambda_function, snapshot, aws_client): + function_name = f"fn-tag-{short_uid()}" + + def snapshot_tags_for_resource(resource_arn: str, snapshot_suffix: str): + list_tags_response = aws_client.lambda_.list_tags(Resource=resource_arn) + snapshot.match(f"list_tags_response_{snapshot_suffix}", list_tags_response) + get_fn_response = aws_client.lambda_.get_function(FunctionName=resource_arn) + snapshot.match(f"get_fn_response_{snapshot_suffix}", get_fn_response) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + Tags={"key_a": "value_a"}, + ) + fn_arn = aws_client.lambda_.get_function(FunctionName=function_name)["Configuration"][ + "FunctionArn" + ] + snapshot_tags_for_resource(fn_arn, "postfncreate") + + tag_resource_response = aws_client.lambda_.tag_resource( + Resource=fn_arn, + Tags={ + "key_b": "value_b", + "key_c": "value_c", + "key_d": "value_d", + "key_e": "value_e", + }, + ) + snapshot.match("tag_resource_response", tag_resource_response) + snapshot_tags_for_resource(fn_arn, "postaddtags") + + tag_resource_response = aws_client.lambda_.tag_resource( + Resource=fn_arn, + Tags={ + "key_b": "value_b", + "key_c": "value_x", + }, + ) + snapshot.match("tag_resource_overwrite", tag_resource_response) + snapshot_tags_for_resource(fn_arn, "overwrite") + + # remove two tags + aws_client.lambda_.untag_resource(Resource=fn_arn, TagKeys=["key_c", "key_d"]) + snapshot_tags_for_resource(fn_arn, "postuntag") + + # remove all tags + aws_client.lambda_.untag_resource(Resource=fn_arn, TagKeys=["key_a", "key_b", "key_e"]) + snapshot_tags_for_resource(fn_arn, "postuntagall") + + aws_client.lambda_.delete_function(FunctionName=function_name) + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.list_tags(Resource=fn_arn) + snapshot.match("list_tags_postdelete", e.value.response) + + +# TODO: add more tests where layername can be an ARN +# TODO: test if function has to be in same region as layer +class TestLambdaLayer: + @markers.lambda_runtime_update + @markers.aws.validated + # AWS only allows a max of 15 compatible runtimes, split runtimes and run two tests + @pytest.mark.parametrize("runtimes", [ALL_RUNTIMES[:14], ALL_RUNTIMES[14:]]) + def test_layer_compatibilities(self, snapshot, dummylayer, cleanups, aws_client, runtimes): + """Creates a single layer which is compatible with all""" + layer_name = f"testlayer-{short_uid()}" + + publish_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=runtimes, + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=ARCHITECTURES, + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + ) + snapshot.match("publish_result", publish_result) + + @markers.lambda_runtime_update + @markers.aws.validated + def test_layer_exceptions(self, snapshot, dummylayer, cleanups, aws_client): + """ + API-level exceptions and edge cases for lambda layers + """ + layer_name = f"testlayer-{short_uid()}" + + publish_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[Runtime.python3_12], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + ) + snapshot.match("publish_result", publish_result) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.list_layers(CompatibleRuntime="runtimedoesnotexist") + snapshot.match("list_layers_exc_compatibleruntime_invalid", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.list_layers(CompatibleArchitecture="archdoesnotexist") + snapshot.match("list_layers_exc_compatiblearchitecture_invalid", e.value.response) + + list_nonexistent_layer = aws_client.lambda_.list_layer_versions( + LayerName="layerdoesnotexist" + ) + snapshot.match("list_nonexistent_layer", list_nonexistent_layer) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_layer_version(LayerName="layerdoesnotexist", VersionNumber=1) + snapshot.match("get_layer_version_exc_layer_doesnotexist", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.get_layer_version(LayerName=layer_name, VersionNumber=-1) + snapshot.match( + "get_layer_version_exc_layer_version_doesnotexist_negative", e.value.response + ) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.get_layer_version(LayerName=layer_name, VersionNumber=0) + snapshot.match("get_layer_version_exc_layer_version_doesnotexist_zero", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_layer_version(LayerName=layer_name, VersionNumber=2) + snapshot.match("get_layer_version_exc_layer_version_doesnotexist_2", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.get_layer_version_by_arn( + Arn=publish_result["LayerArn"] + ) # doesn't include version in the arn + snapshot.match("get_layer_version_by_arn_exc_invalidarn", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_layer_version_by_arn(Arn=f"{publish_result['LayerArn']}:2") + snapshot.match("get_layer_version_by_arn_exc_nonexistentversion", e.value.response) + + # delete seem to be "idempotent" + delete_nonexistent_response = aws_client.lambda_.delete_layer_version( + LayerName="layerdoesnotexist", VersionNumber=1 + ) + snapshot.match("delete_nonexistent_response", delete_nonexistent_response) + + delete_nonexistent_version_response = aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=2 + ) + snapshot.match("delete_nonexistent_version_response", delete_nonexistent_version_response) + + # this delete has an actual side effect (deleting the published layer) + delete_layer_response = aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=1 + ) + snapshot.match("delete_layer_response", delete_layer_response) + delete_layer_again_response = aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=1 + ) + snapshot.match("delete_layer_again_response", delete_layer_again_response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.delete_layer_version(LayerName=layer_name, VersionNumber=-1) + snapshot.match("delete_layer_version_exc_layerversion_invalid_version", e.value.response) + + # note: empty CompatibleRuntimes and CompatibleArchitectures are actually valid (!) + layer_empty_name = f"testlayer-empty-{short_uid()}" + publish_empty_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_empty_name, + Content={"ZipFile": dummylayer}, + CompatibleRuntimes=[], + CompatibleArchitectures=[], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_empty_name, VersionNumber=publish_empty_result["Version"] + ) + ) + snapshot.match("publish_empty_result", publish_empty_result) + + # TODO: test list_layers with invalid filter values + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.publish_layer_version( + LayerName=f"testlayer-2-{short_uid()}", + Content={"ZipFile": dummylayer}, + CompatibleRuntimes=["invalidruntime"], + CompatibleArchitectures=["invalidarch"], + ) + snapshot.match("publish_layer_version_exc_invalid_runtime_arch", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.publish_layer_version( + LayerName=f"testlayer-2-{short_uid()}", + Content={"ZipFile": dummylayer}, + CompatibleRuntimes=["invalidruntime", "invalidruntime2", Runtime.nodejs20_x], + CompatibleArchitectures=["invalidarch", Architecture.x86_64], + ) + snapshot.match("publish_layer_version_exc_partially_invalid_values", e.value.response) + + @markers.aws.validated + def test_layer_function_exceptions( + self, + create_lambda_function, + snapshot, + dummylayer, + cleanups, + aws_client_factory, + aws_client, + secondary_region_name, + ): + """Test interaction of layers when adding them to the function""" + function_name = f"fn-layer-{short_uid()}" + layer_name = f"testlayer-{short_uid()}" + + publish_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + ) + snapshot.match("publish_result", publish_result) + + publish_result_2 = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result_2["Version"] + ) + ) + snapshot.match("publish_result_2", publish_result_2) + + publish_result_3 = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result_3["Version"] + ) + ) + snapshot.match("publish_result_3", publish_result_3) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + get_fn_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_fn_result", get_fn_result) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Layers=[ + publish_result["LayerVersionArn"], + publish_result_2["LayerVersionArn"], + ], + ) + snapshot.match("two_layer_versions_single_function_exc", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Layers=[ + publish_result["LayerVersionArn"], + publish_result_2["LayerVersionArn"], + publish_result_3["LayerVersionArn"], + ], + ) + snapshot.match("three_layer_versions_single_function_exc", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Layers=[ + publish_result["LayerVersionArn"], + publish_result["LayerVersionArn"], + ], + ) + snapshot.match("two_identical_layer_versions_single_function_exc", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Layers=[ + f"{publish_result['LayerArn'].replace(layer_name, 'doesnotexist')}:1", + ], + ) + snapshot.match("add_nonexistent_layer_exc", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.InvalidParameterValueException) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + Layers=[ + f"{publish_result['LayerArn']}:9", + ], + ) + snapshot.match("add_nonexistent_layer_version_exc", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Layers=[publish_result["LayerArn"]] + ) + snapshot.match("add_layer_arn_without_version_exc", e.value.response) + + other_region_lambda_client = aws_client_factory(region_name=secondary_region_name).lambda_ + other_region_layer_result = other_region_lambda_client.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: other_region_lambda_client.delete_layer_version( + LayerName=layer_name, VersionNumber=other_region_layer_result["Version"] + ) + ) + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + layers=[other_region_layer_result["LayerVersionArn"]], + ) + snapshot.match("create_function_with_layer_in_different_region", e.value.response) + + @markers.aws.validated + def test_layer_function_quota_exception( + self, create_lambda_function, snapshot, dummylayer, cleanups, aws_client + ): + """Test lambda quota of "up to five layers" + Layer docs: https://docs.aws.amazon.com/lambda/latest/dg/invocation-layers.html#invocation-layers-using + Lambda quota: https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html#function-configuration-deployment-and-execution + """ + layer_arns = [] + for n in range(6): + layer_name_N = f"testlayer-{n + 1}-{short_uid()}" + publish_result_N = aws_client.lambda_.publish_layer_version( + LayerName=layer_name_N, + CompatibleRuntimes=[], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name_N, VersionNumber=publish_result_N["Version"] + ) + ) + layer_arns.append(publish_result_N["LayerVersionArn"]) + + function_name = f"fn-layer-{short_uid()}" + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + layers=layer_arns, + ) + snapshot.match("create_function_with_six_layers", e.value.response) + + @markers.aws.validated + def test_layer_lifecycle( + self, create_lambda_function, snapshot, dummylayer, cleanups, aws_client + ): + """ + Tests the general lifecycle of a Lambda layer + + There are a few interesting behaviors we can observe + 1. deleting all layer versions for a layer name and then publishing a new layer version with the same layer name, still increases the previous version counter + 2. deleting a layer version that is associated with a lambda won't affect the lambda configuration + + TODO: test paging of list operations + TODO: test list_layers + + """ + function_name = f"fn-layer-{short_uid()}" + layer_name = f"testlayer-{short_uid()}" + license_info = f"licenseinfo-{short_uid()}" + description = f"description-{short_uid()}" + + snapshot.add_transformer(snapshot.transform.regex(license_info, "")) + snapshot.add_transformer(snapshot.transform.regex(description, "")) + + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + get_fn_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_fn_result", get_fn_result) + + get_fn_config_result = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get_fn_config_result", get_fn_config_result) + + publish_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[Runtime.python3_12], + LicenseInfo=license_info, + Description=description, + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + ) + snapshot.match("publish_result", publish_result) + + # note: we don't even need to change anything for a second version to be published + publish_result_2 = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[Runtime.python3_12], + LicenseInfo=license_info, + Description=description, + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result_2["Version"] + ) + ) + snapshot.match("publish_result_2", publish_result_2) + + assert publish_result["Version"] == 1 + assert publish_result_2["Version"] == 2 + assert publish_result["Content"]["CodeSha256"] == publish_result_2["Content"]["CodeSha256"] + + update_fn_config = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Layers=[publish_result["LayerVersionArn"]] + ) + snapshot.match("update_fn_config", update_fn_config) + + # wait for update to be finished + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + get_fn_config = aws_client.lambda_.get_function_configuration(FunctionName=function_name) + snapshot.match("get_fn_config", get_fn_config) + + get_layer_ver_result = aws_client.lambda_.get_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + snapshot.match("get_layer_ver_result", get_layer_ver_result) + + get_layer_by_arn_version = aws_client.lambda_.get_layer_version_by_arn( + Arn=publish_result["LayerVersionArn"] + ) + snapshot.match("get_layer_by_arn_version", get_layer_by_arn_version) + + list_layer_versions_predelete = aws_client.lambda_.list_layer_versions(LayerName=layer_name) + snapshot.match("list_layer_versions_predelete", list_layer_versions_predelete) + + # scenario: what happens if we remove the layer when it's still associated with a function? + delete_layer_1 = aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=1 + ) + snapshot.match("delete_layer_1", delete_layer_1) + + # still there + get_fn_config_postdelete = aws_client.lambda_.get_function_configuration( + FunctionName=function_name + ) + snapshot.match("get_fn_config_postdelete", get_fn_config_postdelete) + delete_layer_2 = aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=2 + ) + snapshot.match("delete_layer_2", delete_layer_2) + + # now there's no layer version left for + list_layer_versions_postdelete = aws_client.lambda_.list_layer_versions( + LayerName=layer_name + ) + snapshot.match("list_layer_versions_postdelete", list_layer_versions_postdelete) + assert len(list_layer_versions_postdelete["LayerVersions"]) == 0 + + # creating a new layer version should still increment the previous version + publish_result_3 = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[Runtime.python3_12], + LicenseInfo=license_info, + Description=description, + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result_3["Version"] + ) + ) + snapshot.match("publish_result_3", publish_result_3) + assert publish_result_3["Version"] == 3 + + @markers.aws.validated + def test_layer_s3_content( + self, s3_create_bucket, create_lambda_function, snapshot, dummylayer, cleanups, aws_client + ): + """Publish a layer by referencing an s3 bucket instead of uploading the content directly""" + bucket = s3_create_bucket() + + layer_name = f"bucket-layer-{short_uid()}" + + bucket_key = "/layercontent.zip" + aws_client.s3.upload_fileobj(Fileobj=io.BytesIO(dummylayer), Bucket=bucket, Key=bucket_key) + + publish_layer_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, Content={"S3Bucket": bucket, "S3Key": bucket_key} + ) + snapshot.match("publish_layer_result", publish_layer_result) + + @markers.aws.validated + def test_layer_policy_exceptions(self, snapshot, dummylayer, cleanups, aws_client): + """ + API-level exceptions and edge cases for lambda layer permissions + + TODO: OrganizationId + """ + layer_name = f"layer4policy-{short_uid()}" + + publish_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[Runtime.python3_12], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + ) + snapshot.match("publish_result", publish_result) + + # we didn't add any permissions yet, so the policy does not exist + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_layer_version_policy(LayerName=layer_name, VersionNumber=1) + snapshot.match("layer_permission_nopolicy_get", e.value.response) + + # add a policy with statement id "s1" + add_layer_permission_result = aws_client.lambda_.add_layer_version_permission( + LayerName=layer_name, + VersionNumber=1, + Action="lambda:GetLayerVersion", + Principal="*", + StatementId="s1", + ) + snapshot.match("add_layer_permission_result", add_layer_permission_result) + + # action can only be lambda:GetLayerVersion + with pytest.raises(aws_client.lambda_.exceptions.ClientError) as e: + aws_client.lambda_.add_layer_version_permission( + LayerName=layer_name, + VersionNumber=1, + Action="*", + Principal="*", + StatementId=f"s-{short_uid()}", + ) + snapshot.match("layer_permission_action_invalid", e.value.response) + + # duplicate statement Id (s1) + with pytest.raises(aws_client.lambda_.exceptions.ResourceConflictException) as e: + aws_client.lambda_.add_layer_version_permission( + LayerName=layer_name, + VersionNumber=1, + Action="lambda:GetLayerVersion", + Principal="*", + StatementId="s1", + ) + snapshot.match("layer_permission_duplicate_statement", e.value.response) + + # wrong revision id + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.add_layer_version_permission( + LayerName=layer_name, + VersionNumber=1, + Action="lambda:GetLayerVersion", + Principal="*", + StatementId="s2", + RevisionId="wrong", + ) + snapshot.match("layer_permission_wrong_revision", e.value.response) + + # layer does not exist + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.add_layer_version_permission( + LayerName=f"{layer_name}-doesnotexist", + VersionNumber=1, + Action="lambda:GetLayerVersion", + Principal="*", + StatementId=f"s-{short_uid()}", + ) + snapshot.match("layer_permission_layername_doesnotexist_add", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_layer_version_policy( + LayerName=f"{layer_name}-doesnotexist", VersionNumber=1 + ) + snapshot.match("layer_permission_layername_doesnotexist_get", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.remove_layer_version_permission( + LayerName=f"{layer_name}-doesnotexist", VersionNumber=1, StatementId="s1" + ) + snapshot.match("layer_permission_layername_doesnotexist_remove", e.value.response) + + # layer with given version does not exist + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.add_layer_version_permission( + LayerName=layer_name, + VersionNumber=2, + Action="lambda:GetLayerVersion", + Principal="*", + StatementId=f"s-{short_uid()}", + ) + snapshot.match("layer_permission_layerversion_doesnotexist_add", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.get_layer_version_policy(LayerName=layer_name, VersionNumber=2) + snapshot.match("layer_permission_layerversion_doesnotexist_get", e.value.response) + + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.remove_layer_version_permission( + LayerName=layer_name, VersionNumber=2, StatementId="s1" + ) + snapshot.match("layer_permission_layerversion_doesnotexist_remove", e.value.response) + + # statement id does not exist for given layer version + with pytest.raises(aws_client.lambda_.exceptions.ResourceNotFoundException) as e: + aws_client.lambda_.remove_layer_version_permission( + LayerName=layer_name, VersionNumber=1, StatementId="doesnotexist" + ) + snapshot.match("layer_permission_statementid_doesnotexist_remove", e.value.response) + + # wrong revision id + with pytest.raises(aws_client.lambda_.exceptions.PreconditionFailedException) as e: + aws_client.lambda_.remove_layer_version_permission( + LayerName=layer_name, VersionNumber=1, StatementId="s1", RevisionId="wrong" + ) + snapshot.match("layer_permission_wrong_revision_remove", e.value.response) + + @markers.aws.validated + def test_layer_policy_lifecycle( + self, create_lambda_function, snapshot, dummylayer, cleanups, aws_client + ): + """ + Simple lifecycle tests for lambda layer policies + + TODO: OrganizationId + """ + layer_name = f"testlayer-{short_uid()}" + + publish_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[Runtime.python3_12], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + ) + + snapshot.match("publish_result", publish_result) + + add_policy_s1 = aws_client.lambda_.add_layer_version_permission( + LayerName=layer_name, + VersionNumber=1, + StatementId="s1", + Action="lambda:GetLayerVersion", + Principal="*", + ) + snapshot.match("add_policy_s1", add_policy_s1) + + get_layer_version_policy = aws_client.lambda_.get_layer_version_policy( + LayerName=layer_name, VersionNumber=1 + ) + snapshot.match("get_layer_version_policy", get_layer_version_policy) + + add_policy_s2 = aws_client.lambda_.add_layer_version_permission( + LayerName=layer_name, + VersionNumber=1, + StatementId="s2", + Action="lambda:GetLayerVersion", + Principal="*", + RevisionId=get_layer_version_policy["RevisionId"], + ) + snapshot.match("add_policy_s2", add_policy_s2) + + get_layer_version_policy_postadd2 = aws_client.lambda_.get_layer_version_policy( + LayerName=layer_name, VersionNumber=1 + ) + snapshot.match("get_layer_version_policy_postadd2", get_layer_version_policy_postadd2) + + remove_s2 = aws_client.lambda_.remove_layer_version_permission( + LayerName=layer_name, + VersionNumber=1, + StatementId="s2", + RevisionId=get_layer_version_policy_postadd2["RevisionId"], + ) + snapshot.match("remove_s2", remove_s2) + + get_layer_version_policy_postdeletes2 = aws_client.lambda_.get_layer_version_policy( + LayerName=layer_name, VersionNumber=1 + ) + snapshot.match( + "get_layer_version_policy_postdeletes2", get_layer_version_policy_postdeletes2 + ) + + @markers.aws.only_localstack(reason="Deterministic id generation is LS only") + def test_layer_deterministic_version( + self, dummylayer, cleanups, aws_client, account_id, region_name, set_resource_custom_id + ): + """ + Test deterministic layer version generation. + Ensuring we can control the version of the layer created through the LocalstackIdManager + """ + layer_name = f"testlayer-{short_uid()}" + layer_version = randint(1, 10) + + layer_version_identifier = LambdaLayerVersionIdentifier( + account_id=account_id, region=region_name, layer_name=layer_name + ) + set_resource_custom_id(layer_version_identifier, layer_version) + publish_result = aws_client.lambda_.publish_layer_version( + LayerName=layer_name, + CompatibleRuntimes=[Runtime.python3_12], + Content={"ZipFile": dummylayer}, + CompatibleArchitectures=[Architecture.x86_64], + ) + cleanups.append( + lambda: aws_client.lambda_.delete_layer_version( + LayerName=layer_name, VersionNumber=publish_result["Version"] + ) + ) + assert publish_result["Version"] == layer_version + + # Try to get the layer version. it will raise an error if it can't be found + aws_client.lambda_.get_layer_version(LayerName=layer_name, VersionNumber=layer_version) + + +class TestLambdaSnapStart: + @markers.aws.validated + @markers.lambda_runtime_update + @markers.multiruntime(scenario="echo", runtimes=SNAP_START_SUPPORTED_RUNTIMES) + def test_snapstart_lifecycle(self, multiruntime_lambda, snapshot, aws_client): + """Test the API of the SnapStart feature. The optimization behavior is not supported in LocalStack. + Slow (~1-2min) against AWS. + """ + create_function_response = multiruntime_lambda.create_function(MemorySize=1024, Timeout=5) + function_name = create_function_response["FunctionName"] + snapshot.match("create_function_response", create_function_response) + + publish_response = aws_client.lambda_.publish_version( + FunctionName=function_name, Description="version1" + ) + version_1 = publish_response["Version"] + aws_client.lambda_.get_waiter("published_version_active").wait( + FunctionName=function_name, Qualifier=version_1 + ) + + get_function_response = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get_function_response_latest", get_function_response) + + get_function_response = aws_client.lambda_.get_function( + FunctionName=f"{function_name}:{version_1}" + ) + snapshot.match("get_function_response_version_1", get_function_response) + + @markers.aws.validated + @markers.lambda_runtime_update + @markers.multiruntime(scenario="echo", runtimes=SNAP_START_SUPPORTED_RUNTIMES) + def test_snapstart_update_function_configuration( + self, multiruntime_lambda, snapshot, aws_client + ): + """Test enabling SnapStart when updating a function.""" + create_function_response = multiruntime_lambda.create_function(MemorySize=1024, Timeout=5) + function_name = create_function_response["FunctionName"] + snapshot.match("create_function_response", create_function_response) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + + update_function_response = aws_client.lambda_.update_function_configuration( + FunctionName=function_name, + SnapStart={"ApplyOn": "PublishedVersions"}, + ) + snapshot.match("update_function_response", update_function_response) + + @markers.aws.validated + def test_snapstart_exceptions(self, lambda_su_role, snapshot, aws_client): + function_name = f"invalid-function-{short_uid()}" + zip_file_bytes = create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + + with pytest.raises(ClientError) as e: + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="cloud.localstack.sample.LambdaHandlerWithLib", + Code={"ZipFile": zip_file_bytes}, + PackageType="Zip", + Role=lambda_su_role, + Runtime=Runtime.java21, + SnapStart={"ApplyOn": "invalidOption"}, + ) + snapshot.match("create_function_invalid_snapstart_apply", e.value.response) diff --git a/tests/aws/services/lambda_/test_lambda_api.snapshot.json b/tests/aws/services/lambda_/test_lambda_api.snapshot.json new file mode 100644 index 0000000000000..1e63ff2f5b8b0 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_api.snapshot.json @@ -0,0 +1,19004 @@ +{ + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_lifecycle": { + "recorded-date": "12-09-2024, 11:29:18", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_func_conf_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Changed-Description", + "Environment": { + "Variables": { + "ENV_A": "a" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 512, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_postupdate": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Changed-Description", + "Environment": { + "Variables": { + "ENV_A": "a" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 512, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_code_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Changed-Description", + "Environment": { + "Variables": { + "ENV_A": "a" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 512, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_postcodeupdate": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Changed-Description", + "Environment": { + "Variables": { + "ENV_A": "a" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 512, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_postdelete": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_redundant_updates": { + "recorded-date": "12-09-2024, 11:29:23", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Initial description", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "first_update_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "1st update description", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_config_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "1st update description", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "1st update description", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "redundant_update_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "1st update description", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_result_after_redundant_update": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "1st update description", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[delete_function]": { + "recorded-date": "12-09-2024, 11:29:23", + "recorded-content": { + "not_match_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "match_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "$LATEST version cannot be deleted without deleting the function." + }, + "Type": "User", + "message": "$LATEST version cannot be deleted without deleting the function.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[get_function]": { + "recorded-date": "12-09-2024, 11:29:23", + "recorded-content": { + "not_match_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "match_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:some-function:$LATEST" + }, + "Message": "Function not found: arn::lambda::111111111111:function:some-function:$LATEST", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[get_function_configuration]": { + "recorded-date": "12-09-2024, 11:29:24", + "recorded-content": { + "not_match_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "match_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:some-function:$LATEST" + }, + "Message": "Function not found: arn::lambda::111111111111:function:some-function:$LATEST", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function]": { + "recorded-date": "12-09-2024, 11:29:26", + "recorded-content": { + "version_not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::1221" + }, + "Message": "Function not found: arn::lambda::111111111111:function::1221", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function_configuration]": { + "recorded-date": "12-09-2024, 11:29:28", + "recorded-content": { + "version_not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::1221" + }, + "Message": "Function not found: arn::lambda::111111111111:function::1221", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function_event_invoke_config]": { + "recorded-date": "12-09-2024, 11:29:30", + "recorded-content": { + "version_not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::1221 doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::1221 doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_delete_on_nonexisting_version": { + "recorded-date": "12-09-2024, 11:29:32", + "recorded-content": { + "delete_function_response_non_existent": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_function_response_non_existent_with_qualifier": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[delete_function]": { + "recorded-date": "12-09-2024, 11:29:32", + "recorded-content": { + "not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function]": { + "recorded-date": "12-09-2024, 11:29:32", + "recorded-content": { + "not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_configuration]": { + "recorded-date": "12-09-2024, 11:29:33", + "recorded-content": { + "not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_url_config]": { + "recorded-date": "12-09-2024, 11:29:33", + "recorded-content": { + "not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_code_signing_config]": { + "recorded-date": "12-09-2024, 11:29:33", + "recorded-content": { + "not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_event_invoke_config]": { + "recorded-date": "12-09-2024, 11:29:33", + "recorded-content": { + "not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::$LATEST doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::$LATEST doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_concurrency]": { + "recorded-date": "12-09-2024, 11:29:33", + "recorded-content": { + "not_found_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function]": { + "recorded-date": "12-09-2024, 11:29:35", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_configuration]": { + "recorded-date": "12-09-2024, 11:29:37", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_url_config]": { + "recorded-date": "12-09-2024, 11:29:39", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_code_signing_config]": { + "recorded-date": "12-09-2024, 11:29:41", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_event_invoke_config]": { + "recorded-date": "12-09-2024, 11:29:43", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_concurrency]": { + "recorded-date": "12-09-2024, 11:29:46", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[delete_function]": { + "recorded-date": "12-09-2024, 11:29:48", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[invoke]": { + "recorded-date": "12-09-2024, 11:29:50", + "recorded-content": { + "wrong_region_exception": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from '' are not reachable in this region ('')" + }, + "Message": "Functions from '' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_code_location_zipfile": { + "recorded-date": "12-09-2024, 11:29:52", + "recorded-content": { + "create-response-zip-file": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-function-response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-function-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-response-updated": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_code_location_s3": { + "recorded-date": "12-09-2024, 11:29:57", + "recorded-content": { + "create_response_s3": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-function-response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-function-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-response-updated": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_version_on_create": { + "recorded-date": "10-04-2024, 09:12:04", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_version_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_latest_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_result": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "repeated_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "list_versions_result_after_publish": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_version_lifecycle": { + "recorded-date": "12-07-2024, 11:43:40", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_result": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "No version :(", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_update_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "First version :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_update_get_function": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "First version :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "First version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "first_publish_get_function": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "First version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_publish_get_function_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "First version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_update_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :))", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_publish_get_function_after_update": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "First version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :))", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "third_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :))", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "list_versions_result_after_third_publish": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :))", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "First version description :)", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :))", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_result_after_deletion_of_first_version": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :))", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :))", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_with_wrong_revisionid": { + "recorded-date": "30-09-2022, 15:31:42", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_fn_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_wrong_revisionid_exc": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "publish_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_with_wrong_sha256": { + "recorded-date": "10-04-2024, 09:12:15", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_fn_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_wrong_sha256_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "CodeSHA256 (somenonexistentsha256) is different from current CodeSHA256 in $LATEST (). Please try again with the CodeSHA256 in $LATEST." + }, + "Type": "User", + "message": "CodeSHA256 (somenonexistentsha256) is different from current CodeSHA256 in $LATEST (). Please try again with the CodeSHA256 in $LATEST.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "publish_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_with_update": { + "recorded-date": "10-04-2024, 09:12:18", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_function_code_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_version_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_latest_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_alias_lifecycle": { + "recorded-date": "21-11-2024, 13:44:49", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_v1": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_v2": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "prod" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_alias_1_1": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "", + "RoutingConfig": { + "AdditionalVersionWeights": { + "2": 0.2 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_alias_1_1": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "", + "RoutingConfig": { + "AdditionalVersionWeights": { + "2": 0.2 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_alias_1_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::aliasname1_1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_byarn_alias_1_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::aliasname1_1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_alias_notfound_exc": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::aliasdoesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function::aliasdoesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_function_alias_byarn_notfound_exc": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::aliasdoesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function::aliasdoesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create_alias_1_2": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_alias_1_2": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_alias_1_3": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_3", + "Description": "", + "FunctionVersion": "1", + "Name": "aliasname1_3", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_alias_1_3": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_3", + "Description": "", + "FunctionVersion": "1", + "Name": "aliasname1_3", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_alias_2": { + "AliasArn": "arn::lambda::111111111111:function::aliasname2", + "Description": "custom-alias", + "FunctionVersion": "2", + "Name": "aliasname2", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_alias_2": { + "AliasArn": "arn::lambda::111111111111:function::aliasname2", + "Description": "custom-alias", + "FunctionVersion": "2", + "Name": "aliasname2", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_aliases_for_fnname": { + "Aliases": [ + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "", + "RoutingConfig": { + "AdditionalVersionWeights": { + "2": 0.2 + } + } + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_3", + "Description": "", + "FunctionVersion": "1", + "Name": "aliasname1_3", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname2", + "Description": "custom-alias", + "FunctionVersion": "2", + "Name": "aliasname2", + "RevisionId": "" + } + ] + }, + "update_alias_1_1": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_alias_1_1_after_update": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_aliases_for_fnname_after_update": { + "Aliases": [ + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_3", + "Description": "", + "FunctionVersion": "1", + "Name": "aliasname1_3", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname2", + "Description": "custom-alias", + "FunctionVersion": "2", + "Name": "aliasname2", + "RevisionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_alias_1_2": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_alias_1_2_after_update": { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_aliases_for_fnname_after_update_2": { + "Aliases": [ + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_3", + "Description": "", + "FunctionVersion": "1", + "Name": "aliasname1_3", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname2", + "Description": "custom-alias", + "FunctionVersion": "2", + "Name": "aliasname2", + "RevisionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_aliases_for_version": { + "Aliases": [ + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_1", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_1", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_3", + "Description": "", + "FunctionVersion": "1", + "Name": "aliasname1_3", + "RevisionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_alias_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list_aliases_for_fnname_afterdelete": { + "Aliases": [ + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_2", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "aliasname1_2", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname1_3", + "Description": "", + "FunctionVersion": "1", + "Name": "aliasname1_3", + "RevisionId": "" + }, + { + "AliasArn": "arn::lambda::111111111111:function::aliasname2", + "Description": "custom-alias", + "FunctionVersion": "2", + "Name": "aliasname2", + "RevisionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_notfound_and_invalid_routingconfigs": { + "recorded-date": "21-11-2024, 13:45:06", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_v1": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_v2": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "prod" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "routing_config_exc_toomany": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Number of items in AdditionalVersionWeights cannot be greater than 1" + }, + "Type": "User", + "message": "Number of items in AdditionalVersionWeights cannot be greater than 1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "routing_config_exc_toohigh": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '{2=2.0}' at 'routingConfig.additionalVersionWeights' failed to satisfy constraint: Map value must satisfy constraint: [Member must have value less than or equal to 1.0, Member must have value greater than or equal to 0.0, Member must not be null]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "routing_config_exc_subzero": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '{2=-1.0}' at 'routingConfig.additionalVersionWeights' failed to satisfy constraint: Map value must satisfy constraint: [Member must have value less than or equal to 1.0, Member must have value greater than or equal to 0.0, Member must not be null]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "routing_config_exc_sameversion": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Invalid function version 1. Function version 1 is already included in routing configuration." + }, + "Type": "User", + "message": "Invalid function version 1. Function version 1 is already included in routing configuration.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "target_version_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::10" + }, + "Message": "Function not found: arn::lambda::111111111111:function::10", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "routing_config_exc_version_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::10" + }, + "Message": "Function not found: arn::lambda::111111111111:function::10", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "target_version_exc_version_latest": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "$LATEST is not supported for an alias pointing to more than 1 version" + }, + "message": "$LATEST is not supported for an alias pointing to more than 1 version", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "routing_config_exc_version_latest": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '{$LATEST=0.5}' at 'routingConfig.additionalVersionWeights' failed to satisfy constraint: Map keys must satisfy constraint: [Member must have length less than or equal to 1024, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: [0-9]+, Member must not be null]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-alias-latest": { + "AliasArn": "arn::lambda::111111111111:function::custom-latest", + "Description": "", + "FunctionVersion": "$LATEST", + "Name": "custom-latest", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "routing_config_exc_fn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:-unknown:1" + }, + "Message": "Function not found: arn::lambda::111111111111:function:-unknown:1", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create_alias_empty_routingconfig": { + "AliasArn": "arn::lambda::111111111111:function::custom-empty-routingconfig", + "Description": "", + "FunctionVersion": "1", + "Name": "custom-empty-routingconfig", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_alias_response": { + "AliasArn": "arn::lambda::111111111111:function::custom", + "Description": "", + "FunctionVersion": "1", + "Name": "custom", + "RevisionId": "", + "RoutingConfig": { + "AdditionalVersionWeights": { + "2": 0.5 + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "routing_config_exc_already_exist": { + "Error": { + "Code": "ResourceConflictException", + "Message": "Alias already exists: arn::lambda::111111111111:function::custom" + }, + "Type": "User", + "message": "Alias already exists: arn::lambda::111111111111:function::custom", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "alias_does_not_exist_esc": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::non-existent" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::non-existent", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_create_tag_on_fn_create": { + "recorded-date": "10-04-2024, 09:13:05", + "recorded-content": { + "get_function_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "testtag": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_result": { + "Tags": { + "testtag": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle": { + "recorded-date": "10-04-2024, 09:13:10", + "recorded-content": { + "tag_single_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_single_response_listtags": { + "Tags": { + "A": "tag-a" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_multiple_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_multiple_response_listtags": { + "Tags": { + "A": "tag-a", + "B": "tag-b", + "C": "tag-c" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_overlap_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_overlap_response_listtags": { + "Tags": { + "A": "tag-a", + "B": "tag-b", + "C": "tag-c-newsuffix", + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_single_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_single_response_listtags": { + "Tags": { + "B": "tag-b", + "C": "tag-c-newsuffix", + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_multiple_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_multiple_response_listtags": { + "Tags": { + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_nonexisting_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_nonexisting_response_listtags": { + "Tags": { + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_existing_and_nonexisting_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_existing_and_nonexisting_response_listtags": { + "Tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_nonexisting_resource": { + "recorded-date": "10-04-2024, 09:13:14", + "recorded-content": { + "pre_delete_get_function": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "not_found_exception_tag": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "not_found_exception_untag": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "not_found_exception_list": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventInvokeConfig::test_lambda_eventinvokeconfig_exceptions": { + "recorded-date": "10-04-2024, 09:13:39", + "recorded-content": { + "fn_version_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "fn_alias_result": { + "AliasArn": "arn::lambda::111111111111:function::eventinvokealias", + "Description": "", + "FunctionVersion": "1", + "Name": "eventinvokealias", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put_functionname_name_notfound": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function doesn't exist." + }, + "Message": "The function doesn't exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_functionname_arn_notfound": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function doesn't exist." + }, + "Message": "The function doesn't exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_functionname_nootherargs": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "You must specify at least one of error handling or destination setting." + }, + "Type": "User", + "message": "You must specify at least one of error handling or destination setting.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_destination_lambda_doesntexist": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The destination ARN arn::lambda::111111111111:function:doesnotexist is invalid." + }, + "Type": "User", + "message": "The destination ARN arn::lambda::111111111111:function:doesnotexist is invalid.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_destination_recursive": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "You can't specify the function as a destination for itself." + }, + "Type": "User", + "message": "You can't specify the function as a destination for itself.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_destination_other_lambda": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": { + "Destination": "arn::lambda::111111111111:function:" + } + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_destination_invalid_service_arn": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The provided destination config DestinationConfig(onSuccess=OnSuccess(destination=arn::iam::111111111111:function:), onFailure=null) is invalid." + }, + "Type": "User", + "message": "The provided destination config DestinationConfig(onSuccess=OnSuccess(destination=arn::iam::111111111111:function:), onFailure=null) is invalid.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_destination_success_no_destination_arn": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_destination_failure_no_destination_arn": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_destination_invalid_arn_pattern": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::_-/!lambda::111111111111:function:' at 'destinationConfig.onFailure.destination' failed to satisfy constraint: Member must satisfy regular expression pattern: ^$|arn:(aws[a-zA-Z0-9-]*):([a-zA-Z0-9\\-])+:([a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\\d{1})?:(\\d{12})?:(.*)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_destination_latest": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_destination_latest_explicit_qualifier": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_destination_version": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_alias_functionname_qualifier": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::eventinvokealias", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_alias_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function doesn't exist." + }, + "Message": "The function doesn't exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_alias_qualifiedarn": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::eventinvokealias", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_alias_qualifiedarn_qualifier": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::eventinvokealias", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_alias_qualifiedarn_qualifierconflict": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_alias_shorthand": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::eventinvokealias", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_alias_shorthand_qualifier": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::eventinvokealias", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_alias_shorthand_qualifierconflict": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_version_shorthand": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_shorthand_qualifier_match": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_shorthand_qualifier_mismatch_1": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_shorthand_qualifier_mismatch_2": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_shorthand_qualifier_mismatch_3": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_maxevent_maxvalue_result": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 21600, + "MaximumRetryAttempts": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_pre_overwrite": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_post_overwrite": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_overwrite_existing_response": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_post_overwrite": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "pre_update_response": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_response": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_response_existing": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_configs": { + "FunctionEventInvokeConfigs": [ + { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 0 + }, + { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "LastModified": "datetime", + "MaximumRetryAttempts": 1 + }, + { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::eventinvokealias", + "LastModified": "datetime", + "MaximumRetryAttempts": 1 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_latest": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_version": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_alias": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list_configs_postdelete": { + "FunctionEventInvokeConfigs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_function_not_found": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::$LATEST doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::$LATEST doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_function_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function:doesnotexist:$LATEST doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function:doesnotexist:$LATEST doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list_function_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function doesn't exist." + }, + "Message": "The function doesn't exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_function_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function:doesnotexist:$LATEST doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function:doesnotexist:$LATEST doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_qualifier_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::doesnotexist doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::doesnotexist doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_eventinvokeconfig_function_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function doesn't exist." + }, + "Message": "The function doesn't exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_eventinvokeconfig_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::eventinvokealias doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::eventinvokealias doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_eventinvokeconfig_config_doesnotexist_with_qualifier": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::eventinvokealias doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::eventinvokealias doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_eventinvokeconfig_config_doesnotexist_without_qualifier": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::$LATEST doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::$LATEST doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency_exceptions": { + "recorded-date": "10-04-2024, 09:13:44", + "recorded-content": { + "put_function_concurrency_with_function_name_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:$LATEST" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:$LATEST", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_function_concurrency_with_function_name_doesnotexist_and_invalid_concurrency": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:$LATEST" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:$LATEST", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_function_concurrency_with_qualified_arn": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "This operation is permitted on Lambda functions only. Aliases and versions do not support this operation. Please specify either a function name or an unqualified function ARN." + }, + "Type": "User", + "message": "This operation is permitted on Lambda functions only. Aliases and versions do not support this operation. Please specify either a function name or an unqualified function ARN.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency": { + "recorded-date": "10-04-2024, 09:13:51", + "recorded-content": { + "put_function_concurrency_with_reserved_0": { + "ReservedConcurrentExecutions": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_function_concurrency_with_reserved_1": { + "ReservedConcurrentExecutions": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_concurrency": { + "ReservedConcurrentExecutions": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_function_concurrency_after_delete": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_add_lambda_permission_aws": { + "recorded-date": "10-04-2024, 09:16:23", + "recorded-content": { + "create_lambda": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "add_permission": { + "Statement": { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_policy": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_remove_multi_permissions": { + "recorded-date": "10-04-2024, 09:16:38", + "recorded-content": { + "add_permission_1": { + "Statement": { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission_2": { + "Statement": { + "Sid": "sqs", + "Effect": "Allow", + "Principal": { + "Service": "sqs.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "policy_after_2_add": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + }, + { + "Sid": "sqs", + "Effect": "Allow", + "Principal": { + "Service": "sqs.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove_permission_exception_nonexisting_sid": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Statement non-existent is not found in resource policy." + }, + "Message": "Statement non-existent is not found in resource policy.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "policy_after_removal": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "policy_after_removal_attempt": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_exception_removed_all": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_create_multiple_lambda_permissions": { + "recorded-date": "10-04-2024, 09:16:41", + "recorded-content": { + "add_permission_response_1": { + "Statement": { + "Sid": "logs", + "Effect": "Allow", + "Principal": { + "Service": "logs.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission_response_2": { + "Statement": { + "Sid": "kinesis", + "Effect": "Allow", + "Principal": { + "Service": "kinesis.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "policy_after_2_add": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "logs", + "Effect": "Allow", + "Principal": { + "Service": "logs.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + }, + { + "Sid": "kinesis", + "Effect": "Allow", + "Principal": { + "Service": "kinesis.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_lifecycle": { + "recorded-date": "21-11-2024, 13:44:13", + "recorded-content": { + "url_creation": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "lambda-url", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "failed_duplication": { + "Error": { + "Code": "ResourceConflictException", + "Message": "Failed to create function url config for [functionArn = arn::lambda::111111111111:function:]. Error message: FunctionUrlConfig exists for this Lambda function" + }, + "Type": "User", + "message": "Failed to create function url config for [functionArn = arn::lambda::111111111111:function:]. Error message: FunctionUrlConfig exists for this Lambda function", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "get_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "lambda-url", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_url_config": { + "AuthType": "AWS_IAM", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "lambda-url", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "failed_getter": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_lambda": { + "recorded-date": "30-09-2022, 15:33:49", + "recorded-content": { + "invalid_param_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Unzipped size must be smaller than 262144000 bytes" + }, + "Type": "User", + "message": "Unzipped size must be smaller than 262144000 bytes", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_lambda": { + "recorded-date": "10-04-2024, 09:18:29", + "recorded-content": { + "create_function_large_zip": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 10, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_environment_variables_fails": { + "recorded-date": "10-04-2024, 09:18:46", + "recorded-content": { + "failed_create_fn_result": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Lambda was unable to configure your environment variables because the environment variables you have provided exceeded the 4KB limit. String measured: {\"LARGE_VAR\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}" + }, + "Type": "User", + "message": "Lambda was unable to configure your environment variables because the environment variables you have provided exceeded the 4KB limit. String measured: {\"LARGE_VAR\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_environment_fails_multiple_keys": { + "recorded-date": "10-04-2024, 09:19:04", + "recorded-content": { + "failured_create_fn_result_multi_key": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Lambda was unable to configure your environment variables because the environment variables you have provided exceeded the 4KB limit. String measured: {\"SMALL_VAR\":\"ok\",\"LARGE_VAR\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}" + }, + "Type": "User", + "message": "Lambda was unable to configure your environment variables because the environment variables you have provided exceeded the 4KB limit. String measured: {\"SMALL_VAR\":\"ok\",\"LARGE_VAR\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_lambda_envvars_near_limit_succeeds": { + "recorded-date": "10-04-2024, 09:19:07", + "recorded-content": { + "successful_create_fn_result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "LARGE_VAR": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestCodeSigningConfig::test_function_code_signing_config": { + "recorded-date": "10-04-2024, 09:19:11", + "recorded-content": { + "create_code_signing_config": { + "CodeSigningConfig": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Enforce" + }, + "Description": "Testing CodeSigning Config", + "LastModified": "date" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_code_signing_config": { + "CodeSigningConfig": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Warn" + }, + "Description": "Testing CodeSigning Config", + "LastModified": "date" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_code_signing_config": { + "CodeSigningConfig": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Warn" + }, + "Description": "Testing CodeSigning Config", + "LastModified": "date" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_function_code_signing_config": { + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "FunctionName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_code_signing_config": { + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "FunctionName": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_code_signing_configs": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Warn" + }, + "Description": "Testing CodeSigning Config", + "LastModified": "date" + }, + "list_functions_by_code_signing_config": { + "FunctionArns": [ + "arn::lambda::111111111111:function:" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_function_code_signing_config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_code_signing_config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestCodeSigningConfig::test_code_signing_not_found_excs": { + "recorded-date": "10-04-2024, 09:19:16", + "recorded-content": { + "create_code_signing_config": { + "CodeSigningConfig": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Enforce" + }, + "Description": "Testing CodeSigning Config", + "LastModified": "date" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "delete_csc_notfound": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The Lambda code signing configuration can not be found." + }, + "Message": "The Lambda code signing configuration can not be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "nothing_to_delete_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_function_csc_fnnotfound": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:csc-test-doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:csc-test-doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_function_csc_invalid_fnname": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:csc-test-doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:csc-test-doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_function_csc_invalid_csc_arn": { + "Error": { + "Code": "CodeSigningConfigNotFoundException", + "Message": "The code signing configuration cannot be found. Check that the provided configuration is not deleted: ." + }, + "Message": "The code signing configuration cannot be found. Check that the provided configuration is not deleted: .", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_function_csc_invalid_both": { + "Error": { + "Code": "CodeSigningConfigNotFoundException", + "Message": "The code signing configuration cannot be found. Check that the provided configuration is not deleted: ." + }, + "Message": "The code signing configuration cannot be found. Check that the provided configuration is not deleted: .", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_csc_invalid_csc_arn": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The Lambda code signing configuration can not be found." + }, + "Message": "The Lambda code signing configuration can not be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_csc_noupdates": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The Lambda code signing configuration can not be found." + }, + "Message": "The Lambda code signing configuration can not be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_csc_noupdate_response": { + "CodeSigningConfig": { + "AllowedPublishers": { + "SigningProfileVersionArns": [ + "arn::signer::111111111111:/signing-profiles/test" + ] + }, + "CodeSigningConfigArn": "arn::lambda::111111111111:code-signing-config:", + "CodeSigningConfigId": "", + "CodeSigningPolicies": { + "UntrustedArtifactOnDeployment": "Enforce" + }, + "Description": "Testing CodeSigning Config", + "LastModified": "date" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_csc_invalid": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The Lambda code signing configuration can not be found." + }, + "Message": "The Lambda code signing configuration can not be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_function_csc_fnwithoutcsc": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_csc_nonexistingfn": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:csc-test-doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:csc-test-doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list_functions_by_csc_fnwithoutcsc": { + "FunctionArns": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_functions_by_csc_invalid_cscarn": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The Lambda code signing configuration can not be found." + }, + "Message": "The Lambda code signing configuration can not be found.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings": { + "recorded-date": "10-04-2024, 09:19:17", + "recorded-content": { + "acc_settings_modded": { + "AccountLimit": [ + "CodeSizeUnzipped", + "CodeSizeZipped", + "ConcurrentExecutions", + "TotalCodeSize", + "UnreservedConcurrentExecutions" + ], + "AccountUsage": [ + "FunctionCount", + "TotalCodeSize" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_exceptions": { + "recorded-date": "05-12-2024, 10:52:30", + "recorded-content": { + "get_unknown_uuid": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_unknown_uuid": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_unknown_uuid": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create_no_event_source_arn": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Unrecognized event source." + }, + "Type": "User", + "message": "Unrecognized event source.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_unknown_params": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Function does not exist" + }, + "Type": "User", + "message": "Function does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "destination_config_failure": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Unsupported DestinationConfig parameter for given event source mapping type." + }, + "Type": "User", + "message": "Unsupported DestinationConfig parameter for given event source mapping type.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_lifecycle": { + "recorded-date": "14-10-2024, 12:36:57", + "recorded-content": { + "update_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "UPDATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Deleting", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_exceptions": { + "recorded-date": "24-10-2024, 15:22:29", + "recorded-content": { + "tag_lambda_invalidarn": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::something' at 'resource' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*):lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:(function:[a-zA-Z0-9-_]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?|layer:([a-zA-Z0-9-_]+)|code-signing-config:csc-[a-z0-9]{17}|event-source-mapping:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "tag_lambda_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "tag_lambda_qualifier_doesnotexist": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Tags on function aliases and versions are not supported. Please specify a function ARN." + }, + "Type": "User", + "message": "Tags on function aliases and versions are not supported. Please specify a function ARN.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_tag_lambda_empty": { + "Tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_nomatch": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_empty_keys": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value null at 'tagKeys' failed to satisfy constraint: Member must not be null" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "tag_empty_tags": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "An error occurred and the request cannot be processed." + }, + "Type": "User", + "message": "An error occurred and the request cannot be processed.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_limits": { + "recorded-date": "28-10-2024, 14:16:38", + "recorded-content": { + "tag_lambda_too_many_tags": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Number of tags exceeds resource tag limit." + }, + "Type": "User", + "message": "Number of tags exceeds resource tag limit.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "tag_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list_tags_response": { + "Tags": { + "0_key": "0_value", + "10_key": "10_value", + "11_key": "11_value", + "12_key": "12_value", + "13_key": "13_value", + "14_key": "14_value", + "15_key": "15_value", + "16_key": "16_value", + "17_key": "17_value", + "18_key": "18_value", + "19_key": "19_value", + "1_key": "1_value", + "20_key": "20_value", + "21_key": "21_value", + "22_key": "22_value", + "23_key": "23_value", + "24_key": "24_value", + "25_key": "25_value", + "26_key": "26_value", + "27_key": "27_value", + "28_key": "28_value", + "29_key": "29_value", + "2_key": "2_value", + "30_key": "30_value", + "31_key": "31_value", + "32_key": "32_value", + "33_key": "33_value", + "34_key": "34_value", + "35_key": "35_value", + "36_key": "36_value", + "37_key": "37_value", + "38_key": "38_value", + "39_key": "39_value", + "3_key": "3_value", + "40_key": "40_value", + "41_key": "41_value", + "42_key": "42_value", + "43_key": "43_value", + "44_key": "44_value", + "45_key": "45_value", + "46_key": "46_value", + "47_key": "47_value", + "48_key": "48_value", + "49_key": "49_value", + "4_key": "4_value", + "5_key": "5_value", + "6_key": "6_value", + "7_key": "7_value", + "8_key": "8_value", + "9_key": "9_value" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "0_key": "0_value", + "10_key": "10_value", + "11_key": "11_value", + "12_key": "12_value", + "13_key": "13_value", + "14_key": "14_value", + "15_key": "15_value", + "16_key": "16_value", + "17_key": "17_value", + "18_key": "18_value", + "19_key": "19_value", + "1_key": "1_value", + "20_key": "20_value", + "21_key": "21_value", + "22_key": "22_value", + "23_key": "23_value", + "24_key": "24_value", + "25_key": "25_value", + "26_key": "26_value", + "27_key": "27_value", + "28_key": "28_value", + "29_key": "29_value", + "2_key": "2_value", + "30_key": "30_value", + "31_key": "31_value", + "32_key": "32_value", + "33_key": "33_value", + "34_key": "34_value", + "35_key": "35_value", + "36_key": "36_value", + "37_key": "37_value", + "38_key": "38_value", + "39_key": "39_value", + "3_key": "3_value", + "40_key": "40_value", + "41_key": "41_value", + "42_key": "42_value", + "43_key": "43_value", + "44_key": "44_value", + "45_key": "45_value", + "46_key": "46_value", + "47_key": "47_value", + "48_key": "48_value", + "49_key": "49_value", + "4_key": "4_value", + "5_key": "5_value", + "6_key": "6_value", + "7_key": "7_value", + "8_key": "8_value", + "9_key": "9_value" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_lambda_too_many_tags_additional": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Number of tags exceeds resource tag limit." + }, + "Type": "User", + "message": "Number of tags exceeds resource tag limit.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_function_invalid_tags": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Number of tags exceeds resource tag limit." + }, + "Type": "User", + "message": "Number of tags exceeds resource tag limit.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_lifecycle": { + "recorded-date": "24-10-2024, 15:22:49", + "recorded-content": { + "list_tags_response_postfncreate": { + "Tags": { + "key_a": "value_a" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_response_postfncreate": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "key_a": "value_a" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list_tags_response_postaddtags": { + "Tags": { + "key_a": "value_a", + "key_b": "value_b", + "key_c": "value_c", + "key_d": "value_d", + "key_e": "value_e" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_response_postaddtags": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "key_a": "value_a", + "key_b": "value_b", + "key_c": "value_c", + "key_d": "value_d", + "key_e": "value_e" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_overwrite": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list_tags_response_overwrite": { + "Tags": { + "key_a": "value_a", + "key_b": "value_b", + "key_c": "value_x", + "key_d": "value_d", + "key_e": "value_e" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_response_overwrite": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "key_a": "value_a", + "key_b": "value_b", + "key_c": "value_x", + "key_d": "value_d", + "key_e": "value_e" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_response_postuntag": { + "Tags": { + "key_a": "value_a", + "key_b": "value_b", + "key_e": "value_e" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_response_postuntag": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "Tags": { + "key_a": "value_a", + "key_b": "value_b", + "key_e": "value_e" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_response_postuntagall": { + "Tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_response_postuntagall": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_postdelete": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_provisioned_concurrency_exceptions": { + "recorded-date": "10-04-2024, 09:13:57", + "recorded-content": { + "publish_version_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_provisioned_config_doesnotexist": { + "Error": { + "Code": "ProvisionedConcurrencyConfigNotFoundException", + "Message": "No Provisioned Concurrency Config found for this function" + }, + "Type": "User", + "message": "No Provisioned Concurrency Config found for this function", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_provisioned_functionname_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function:doesnotexist:noalias" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function:doesnotexist:noalias", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_provisioned_qualifier_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::noalias" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::noalias", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_provisioned_version_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::10" + }, + "Message": "Function not found: arn::lambda::111111111111:function::10", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_provisioned_latest": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The function resource provided must be an alias or a published version." + }, + "Type": "User", + "message": "The function resource provided must be an alias or a published version.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_provisioned_noconfigs": { + "ProvisionedConcurrencyConfigs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_provisioned_functionname_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_provisioned_functionname_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:1" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:1", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_provisioned_qualifier_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::noalias" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::noalias", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_provisioned_version_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::10" + }, + "Message": "Function not found: arn::lambda::111111111111:function::10", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_provisioned_latest": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The function resource provided must be an alias or a published version." + }, + "Type": "User", + "message": "The function resource provided must be an alias or a published version.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_provisioned_config_doesnotexist": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put_provisioned_invalid_param_0": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '0' at 'provisionedConcurrentExecutions' failed to satisfy constraint: Member must have value greater than or equal to 1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_provisioned_functionname_doesnotexist_alias": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function:doesnotexist:noalias" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function:doesnotexist:noalias", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_provisioned_functionname_doesnotexist_version": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:1" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist:1", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_provisioned_qualifier_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::doesnotexist" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_provisioned_version_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::10" + }, + "Message": "Function not found: arn::lambda::111111111111:function::10", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_provisioned_latest": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Provisioned Concurrency Configs cannot be applied to unpublished function versions." + }, + "Type": "User", + "message": "Provisioned Concurrency Configs cannot be applied to unpublished function versions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_lambda_provisioned_lifecycle": { + "recorded-date": "10-04-2024, 09:16:15", + "recorded-content": { + "publish_version_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_alias_result": { + "AliasArn": "arn::lambda::111111111111:function::", + "Description": "", + "FunctionVersion": "1", + "Name": "", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put_provisioned_on_version": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "put_provisioned_on_alias_versionconflict": { + "Error": { + "Code": "ResourceConflictException", + "Message": "Alias can't be used for Provisioned Concurrency configuration on an already Provisioned version" + }, + "Type": "User", + "message": "Alias can't be used for Provisioned Concurrency configuration on an already Provisioned version", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "delete_provisioned_version": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_provisioned_version_postdelete": { + "Error": { + "Code": "ProvisionedConcurrencyConfigNotFoundException", + "Message": "No Provisioned Concurrency Config found for this function" + }, + "Type": "User", + "message": "No Provisioned Concurrency Config found for this function", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_provisioned_on_alias": { + "AllocatedProvisionedConcurrentExecutions": 0, + "AvailableProvisionedConcurrentExecutions": 0, + "LastModified": "date", + "RequestedProvisionedConcurrentExecutions": 1, + "Status": "IN_PROGRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "put_provisioned_on_version_conflict": { + "Error": { + "Code": "ResourceConflictException", + "Message": "Version is pointed by a Provisioned Concurrency alias" + }, + "Type": "User", + "message": "Version is pointed by a Provisioned Concurrency alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "delete_alias_result": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_provisioned_alias_postaliasdelete": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list_response_postdeletes": { + "ProvisionedConcurrencyConfigs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_request_create_lambda": { + "recorded-date": "10-04-2024, 09:17:14", + "recorded-content": { + "invalid_param_exc": { + "Error": { + "Code": "RequestEntityTooLargeException", + "Message": "Request must be smaller than 70167211 bytes for the CreateFunction operation" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 413 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_versions": { + "recorded-date": "24-10-2024, 15:22:40", + "recorded-content": { + "tag_resource_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Tags on function aliases and versions are not supported. Please specify a function ARN." + }, + "Type": "User", + "message": "Tags on function aliases and versions are not supported. Please specify a function ARN.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "tag_resource_latest_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Tags on function aliases and versions are not supported. Please specify a function ARN." + }, + "Type": "User", + "message": "Tags on function aliases and versions are not supported. Please specify a function ARN.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_list_paging": { + "recorded-date": "21-11-2024, 13:44:10", + "recorded-content": { + "fn_version_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "lambda_handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs20.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_alias_result": { + "AliasArn": "arn::lambda::111111111111:function::urlalias", + "Description": "", + "FunctionVersion": "1", + "Name": "urlalias", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "list_function_notfound": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function does not exist" + }, + "Message": "Function does not exist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list_all_empty": { + "FunctionUrlConfigs": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "url_config_fn": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "lambda-url", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "url_config_alias": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function::urlalias", + "FunctionUrl": "lambda-url", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "list_all": { + "FunctionUrlConfigs": [ + { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "lambda-url", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date" + }, + { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function::urlalias", + "FunctionUrl": "lambda-url", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_exceptions": { + "recorded-date": "21-11-2024, 13:44:05", + "recorded-content": { + "fn_version_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "lambda_handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs20.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_alias_result": { + "AliasArn": "arn::lambda::111111111111:function::urlalias", + "Description": "", + "FunctionVersion": "1", + "Name": "urlalias", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_function_url_config_name_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function does not exist" + }, + "Message": "Function does not exist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create_function_url_config_arn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function does not exist" + }, + "Message": "Function does not exist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create_function_url_config_name_doesnotexist_qualifier": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_function_url_config_qualifier_version": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_function_url_config_qualifier_version_doesnotexist": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '2' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_function_url_config_qualifier_alias_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function does not exist" + }, + "Message": "Function does not exist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "create_function_url_config_qualifier_alias_doesnotmatch_arn": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_function_url_config_qualifier_latest": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '$LATEST' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_function_url_config_name_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_function_url_config_arn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_function_url_config_name_doesnotexist_qualifier": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_function_url_config_qualifier_version": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_function_url_config_qualifier_version_doesnotexist": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '2' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_function_url_config_qualifier_alias_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_function_url_config_qualifier_alias_doesnotmatch_arn": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_function_url_config_qualifier_latest": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '$LATEST' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_function_url_config_config_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_function_url_config_name_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_function_url_config_arn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_function_url_config_name_doesnotexist_qualifier": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_function_url_config_qualifier_version": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_function_url_config_qualifier_version_doesnotexist": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '2' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_function_url_config_qualifier_alias_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_function_url_config_qualifier_alias_doesnotmatch_arn": "", + "delete_function_url_config_qualifier_latest": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '$LATEST' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_function_url_config_config_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_function_url_config_name_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function does not exist" + }, + "Message": "Function does not exist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_function_url_config_arn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function does not exist" + }, + "Message": "Function does not exist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_function_url_config_name_doesnotexist_qualifier": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update_function_url_config_qualifier_version": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update_function_url_config_qualifier_version_doesnotexist": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '2' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update_function_url_config_qualifier_alias_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function does not exist" + }, + "Message": "Function does not exist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "update_function_url_config_qualifier_alias_doesnotmatch_arn": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update_function_url_config_qualifier_latest": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '$LATEST' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: ((?!^\\d+$)^[0-9a-zA-Z-_]+$)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update_function_url_config_config_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_permission_exceptions": { + "recorded-date": "10-04-2024, 09:16:20", + "recorded-content": { + "add_permission_invalid_statement_id": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'example.com' at 'statementId' failed to satisfy constraint: Member must satisfy regular expression pattern: ([a-zA-Z0-9-_]+)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add_permission_fn_qualifier_mismatch": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + }, + "Type": "User", + "message": "The derived qualifier from the function name does not match the specified qualifier.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add_permission_fn_qualifier_latest": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "We currently do not support adding policies for $LATEST." + }, + "Type": "User", + "message": "We currently do not support adding policies for $LATEST.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add_permission_principal_invalid": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The provided principal was invalid. Please check the principal and try again." + }, + "Type": "User", + "message": "The provided principal was invalid. Please check the principal and try again.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_policy_fn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_policy_fn_version_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "add_permission_fn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function:doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "remove_permission_policy_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "No policy is associated with the given resource." + }, + "Message": "No policy is associated with the given resource.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "add_permission_fn_alias_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::alias-doesnotexist" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::alias-doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "add_permission_fn_version_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::42" + }, + "Message": "Function not found: arn::lambda::111111111111:function::42", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "add_permission_fn_qualifier_invalid": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'invalid-qualifier-with-?-char' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add_permission_fn_qualifier_valid_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::valid-with-$-but-doesnotexist" + }, + "Message": "Function not found: arn::lambda::111111111111:function::valid-with-$-but-doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "add_permission_conflicting_statement_id": { + "Error": { + "Code": "ResourceConflictException", + "Message": "The statement id (s3) provided already exists. Please provide a new statement id, or remove the existing statement." + }, + "Type": "User", + "message": "The statement id (s3) provided already exists. Please provide a new statement id, or remove the existing statement.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "remove_permission_fn_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "No policy found for: arn::lambda::111111111111:function:doesnotexist" + }, + "Message": "No policy found for: arn::lambda::111111111111:function:doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "remove_permission_fn_alias_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::alias-doesnotexist" + }, + "Message": "Cannot find alias arn: arn::lambda::111111111111:function::alias-doesnotexist", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_unzipped_lambda": { + "recorded-date": "10-04-2024, 09:17:47", + "recorded-content": { + "invalid_param_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Unzipped size must be smaller than 262144000 bytes" + }, + "Type": "User", + "message": "Unzipped size must be smaller than 262144000 bytes", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_create_lambda_exceptions": { + "recorded-date": "01-04-2025, 13:08:21", + "recorded-content": { + "invalid_role_arn_exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'r1' at 'role' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*)?:iam::\\d{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_runtime_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Value non-existent-runtime at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN" + }, + "Type": "User", + "message": "Value non-existent-runtime at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "uppercase_runtime_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Value PYTHON3.9 at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN" + }, + "Type": "User", + "message": "Value PYTHON3.9 at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "empty_architectures": "Parameter validation failed:\nInvalid length for parameter Architectures, value: 0, valid min length: 1", + "multiple_architectures": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[x86_64, arm64]' at 'architectures' failed to satisfy constraint: Member must have length less than or equal to 1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "uppercase_architecture": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[X86_64]' at 'architectures' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [x86_64, arm64], Member must not be null]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_zip_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Could not unzip uploaded file. Please check your file, then try to upload again." + }, + "Type": "User", + "message": "Could not unzip uploaded file. Please check your file, then try to upload again.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_update_lambda_exceptions": { + "recorded-date": "01-04-2025, 13:09:51", + "recorded-content": { + "invalid_role_arn_exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'r1' at 'role' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*)?:iam::\\d{12}:role/?[a-zA-Z_0-9+=,.@\\-_/]+" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_runtime_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Value non-existent-runtime at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN" + }, + "Type": "User", + "message": "Value non-existent-runtime at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "uppercase_runtime_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Value PYTHON3.9 at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN" + }, + "Type": "User", + "message": "Value PYTHON3.9 at 'runtime' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9] or be a valid ARN", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_list_functions": { + "recorded-date": "12-09-2024, 11:30:20", + "recorded-content": { + "create_response_1": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "create_response_2": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "list_functions_invalid_functionversion": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'invalid' at 'functionVersion' failed to satisfy constraint: Member must satisfy enum value set: [ALL]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_all": { + "Functions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + } + ] + }, + "list_default": { + "Functions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + } + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_exceptions": { + "recorded-date": "01-04-2025, 13:19:20", + "recorded-content": { + "publish_result": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "list_layers_exc_compatibleruntime_invalid": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'runtimedoesnotexist' at 'compatibleRuntime' failed to satisfy constraint: Member must satisfy enum value set: [ruby2.6, dotnetcore1.0, python3.7, nodejs8.10, nasa, ruby2.7, python2.7-greengrass, dotnetcore2.0, python3.8, java21, dotnet6, dotnetcore2.1, python3.9, java11, nodejs6.10, provided, dotnetcore3.1, dotnet8, java25, java17, nodejs, nodejs4.3, java8.al2, go1.x, dotnet10, nodejs20.x, go1.9, byol, nodejs10.x, provided.al2023, nodejs22.x, python3.10, java8, nodejs12.x, python3.11, nodejs24.x, nodejs8.x, python3.12, nodejs14.x, nodejs8.9, python3.13, python3.14, nodejs16.x, provided.al2, nodejs4.3-edge, nodejs18.x, ruby3.2, python3.4, ruby3.3, ruby3.4, ruby2.5, python3.6, python2.7]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_layers_exc_compatiblearchitecture_invalid": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'archdoesnotexist' at 'compatibleArchitecture' failed to satisfy constraint: Member must satisfy enum value set: [x86_64, arm64]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_nonexistent_layer": { + "LayerVersions": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_layer_version_exc_layer_doesnotexist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_layer_version_exc_layer_version_doesnotexist_negative": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Layer Version Cannot be less than 1" + }, + "Type": "User", + "message": "Layer Version Cannot be less than 1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_layer_version_exc_layer_version_doesnotexist_zero": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Layer Version Cannot be less than 1" + }, + "Type": "User", + "message": "Layer Version Cannot be less than 1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_layer_version_exc_layer_version_doesnotexist_2": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_layer_version_by_arn_exc_invalidarn": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::lambda::111111111111:layer:' at 'arn' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:layer:[a-zA-Z0-9-_]+:[0-9]+)|(arn:[a-zA-Z0-9-]+:lambda:::awslayer:[a-zA-Z0-9-_]+)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_layer_version_by_arn_exc_nonexistentversion": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_nonexistent_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_nonexistent_version_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_layer_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_layer_again_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_layer_version_exc_layerversion_invalid_version": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Layer Version Cannot be less than 1" + }, + "Type": "User", + "message": "Layer Version Cannot be less than 1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "publish_empty_result": { + "CompatibleArchitectures": [], + "CompatibleRuntimes": [], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_layer_version_exc_invalid_runtime_arch": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value '[invalidruntime]' at 'compatibleRuntimes' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9]; Value '[invalidarch]' at 'compatibleArchitectures' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [x86_64, arm64]]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "publish_layer_version_exc_partially_invalid_values": { + "Error": { + "Code": "ValidationException", + "Message": "2 validation errors detected: Value '[invalidruntime, invalidruntime2, nodejs20.x]' at 'compatibleRuntimes' failed to satisfy constraint: Member must satisfy enum value set: [nodejs20.x, provided.al2023, python3.12, python3.13, nodejs22.x, java17, nodejs16.x, dotnet8, python3.10, java11, python3.11, dotnet6, java21, nodejs18.x, provided.al2, ruby3.3, ruby3.4, java8.al2, ruby3.2, python3.8, python3.9]; Value '[invalidarch, x86_64]' at 'compatibleArchitectures' failed to satisfy constraint: Member must satisfy constraint: [Member must satisfy enum value set: [x86_64, arm64]]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_lifecycle": { + "recorded-date": "10-04-2024, 09:24:17", + "recorded-content": { + "get_fn_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_config_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_result": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "LicenseInfo": "", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_result_2": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::2", + "LicenseInfo": "", + "Version": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_fn_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "Layers": [ + { + "Arn": "arn::lambda::111111111111:layer::1", + "CodeSize": "" + } + ], + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_fn_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "Layers": [ + { + "Arn": "arn::lambda::111111111111:layer::1", + "CodeSize": "" + } + ], + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_layer_ver_result": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "LicenseInfo": "", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_layer_by_arn_version": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "LicenseInfo": "", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_layer_versions_predelete": { + "LayerVersions": [ + { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "CreatedDate": "date", + "Description": "", + "LayerVersionArn": "arn::lambda::111111111111:layer::2", + "LicenseInfo": "", + "Version": 2 + }, + { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "CreatedDate": "date", + "Description": "", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "LicenseInfo": "", + "Version": 1 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_layer_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_fn_config_postdelete": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "Layers": [ + { + "Arn": "arn::lambda::111111111111:layer::1", + "CodeSize": "" + } + ], + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_layer_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list_layer_versions_postdelete": { + "LayerVersions": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_result_3": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::3", + "LicenseInfo": "", + "Version": 3, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_policy_exceptions": { + "recorded-date": "10-04-2024, 09:24:29", + "recorded-content": { + "publish_result": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "layer_permission_nopolicy_get": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "No policy is associated with the given resource." + }, + "Message": "No policy is associated with the given resource.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "add_layer_permission_result": { + "RevisionId": "", + "Statement": { + "Sid": "s1", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:GetLayerVersion", + "Resource": "arn::lambda::111111111111:layer::1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "layer_permission_action_invalid": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '*' at 'action' failed to satisfy constraint: Member must satisfy regular expression pattern: lambda:GetLayerVersion" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "layer_permission_duplicate_statement": { + "Error": { + "Code": "ResourceConflictException", + "Message": "The statement id (s1) provided already exists. Please provide a new statement id, or remove the existing statement." + }, + "Type": "User", + "message": "The statement id (s1) provided already exists. Please provide a new statement id, or remove the existing statement.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "layer_permission_wrong_revision": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetLayerPolicy API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetLayerPolicy API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "layer_permission_layername_doesnotexist_add": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Layer version arn::lambda::111111111111:layer:-doesnotexist:1 does not exist." + }, + "Message": "Layer version arn::lambda::111111111111:layer:-doesnotexist:1 does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "layer_permission_layername_doesnotexist_get": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Layer version arn::lambda::111111111111:layer:-doesnotexist:1 does not exist." + }, + "Message": "Layer version arn::lambda::111111111111:layer:-doesnotexist:1 does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "layer_permission_layername_doesnotexist_remove": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Layer version arn::lambda::111111111111:layer:-doesnotexist:1 does not exist." + }, + "Message": "Layer version arn::lambda::111111111111:layer:-doesnotexist:1 does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "layer_permission_layerversion_doesnotexist_add": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Layer version arn::lambda::111111111111:layer::2 does not exist." + }, + "Message": "Layer version arn::lambda::111111111111:layer::2 does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "layer_permission_layerversion_doesnotexist_get": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Layer version arn::lambda::111111111111:layer::2 does not exist." + }, + "Message": "Layer version arn::lambda::111111111111:layer::2 does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "layer_permission_layerversion_doesnotexist_remove": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Layer version arn::lambda::111111111111:layer::2 does not exist." + }, + "Message": "Layer version arn::lambda::111111111111:layer::2 does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "layer_permission_statementid_doesnotexist_remove": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Statement doesnotexist is not found in resource policy." + }, + "Message": "Statement doesnotexist is not found in resource policy.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "layer_permission_wrong_revision_remove": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetLayerPolicy API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetLayerPolicy API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_policy_lifecycle": { + "recorded-date": "10-04-2024, 09:24:35", + "recorded-content": { + "publish_result": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [ + "python3.12" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_policy_s1": { + "RevisionId": "", + "Statement": { + "Sid": "s1", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:GetLayerVersion", + "Resource": "arn::lambda::111111111111:layer::1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_layer_version_policy": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s1", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:GetLayerVersion", + "Resource": "arn::lambda::111111111111:layer::1" + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "add_policy_s2": { + "RevisionId": "", + "Statement": { + "Sid": "s2", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:GetLayerVersion", + "Resource": "arn::lambda::111111111111:layer::1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_layer_version_policy_postadd2": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s1", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:GetLayerVersion", + "Resource": "arn::lambda::111111111111:layer::1" + }, + { + "Sid": "s2", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:GetLayerVersion", + "Resource": "arn::lambda::111111111111:layer::1" + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove_s2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_layer_version_policy_postdeletes2": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s1", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:GetLayerVersion", + "Resource": "arn::lambda::111111111111:layer::1" + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_s3_content": { + "recorded-date": "10-04-2024, 09:24:23", + "recorded-content": { + "publish_layer_result": { + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_function_exceptions": { + "recorded-date": "10-04-2024, 09:23:19", + "recorded-content": { + "publish_result": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_result_2": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::2", + "Version": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_result_3": { + "CompatibleArchitectures": [ + "x86_64" + ], + "CompatibleRuntimes": [], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::3", + "Version": 3, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_fn_result": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "two_layer_versions_single_function_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Two different versions of the same layer are not allowed to be referenced in the same function. arn::lambda::111111111111:layer::1 and arn::lambda::111111111111:layer::2 are versions of the same layer." + }, + "Type": "User", + "message": "Two different versions of the same layer are not allowed to be referenced in the same function. arn::lambda::111111111111:layer::1 and arn::lambda::111111111111:layer::2 are versions of the same layer.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "three_layer_versions_single_function_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Two different versions of the same layer are not allowed to be referenced in the same function. arn::lambda::111111111111:layer::1 and arn::lambda::111111111111:layer::2 are versions of the same layer." + }, + "Type": "User", + "message": "Two different versions of the same layer are not allowed to be referenced in the same function. arn::lambda::111111111111:layer::1 and arn::lambda::111111111111:layer::2 are versions of the same layer.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "two_identical_layer_versions_single_function_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Two different versions of the same layer are not allowed to be referenced in the same function. arn::lambda::111111111111:layer::1 and arn::lambda::111111111111:layer::1 are versions of the same layer." + }, + "Type": "User", + "message": "Two different versions of the same layer are not allowed to be referenced in the same function. arn::lambda::111111111111:layer::1 and arn::lambda::111111111111:layer::1 are versions of the same layer.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add_nonexistent_layer_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Layer version arn::lambda::111111111111:layer:doesnotexist:1 does not exist." + }, + "Type": "User", + "message": "Layer version arn::lambda::111111111111:layer:doesnotexist:1 does not exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add_nonexistent_layer_version_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Layer version arn::lambda::111111111111:layer::9 does not exist." + }, + "Type": "User", + "message": "Layer version arn::lambda::111111111111:layer::9 does not exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "add_layer_arn_without_version_exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[arn::lambda::111111111111:layer:]' at 'layers' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 140, Member must have length greater than or equal to 1, Member must satisfy regular expression pattern: (arn:[a-zA-Z0-9-]+:lambda:[a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\\d{1}:\\d{12}:layer:[a-zA-Z0-9-_]+:[0-9]+)|(arn:[a-zA-Z0-9-]+:lambda:::awslayer:[a-zA-Z0-9-_]+), Member must not be null]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_function_with_layer_in_different_region": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Layers are not in the same region as the function. Layers are expected to be in region ." + }, + "Type": "User", + "message": "Layers are not in the same region as the function. Layers are expected to be in region .", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_crud": { + "recorded-date": "10-04-2024, 09:10:21", + "recorded-content": { + "create-image-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-function-code-response": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-config-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "image-to-zipfile-error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Please provide ImageUri when updating a function with packageType Image." + }, + "Type": "User", + "message": "Please provide ImageUri when updating a function with packageType Image.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-function-code-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-code-response-after-update": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-config-response-after-update": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_and_image_config_crud": { + "recorded-date": "10-04-2024, 09:11:14", + "recorded-content": { + "create-image-with-config-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "ImageConfigResponse": { + "ImageConfig": { + "Command": [ + "-c", + "echo test" + ], + "EntryPoint": [ + "sh" + ], + "WorkingDirectory": "/app1" + } + }, + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-function-code-with-config-response": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "ImageConfigResponse": { + "ImageConfig": { + "Command": [ + "-c", + "echo test" + ], + "EntryPoint": [ + "sh" + ], + "WorkingDirectory": "/app1" + } + }, + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-config-with-config-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "ImageConfigResponse": { + "ImageConfig": { + "Command": [ + "-c", + "echo test" + ], + "EntryPoint": [ + "sh" + ], + "WorkingDirectory": "/app1" + } + }, + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-function-code-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "ImageConfigResponse": { + "ImageConfig": { + "Command": [ + "-c", + "echo test1" + ], + "WorkingDirectory": "/app1" + } + }, + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-code-response-after-update": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "ImageConfigResponse": { + "ImageConfig": { + "Command": [ + "-c", + "echo test1" + ], + "WorkingDirectory": "/app1" + } + }, + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-config-response-after-update": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "ImageConfigResponse": { + "ImageConfig": { + "Command": [ + "-c", + "echo test1" + ], + "WorkingDirectory": "/app1" + } + }, + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-function-code-delete-imageconfig-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-code-response-after-delete-imageconfig": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-config-response-after-delete-imageconfig": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_zip_file_to_image": { + "recorded-date": "10-04-2024, 09:10:38", + "recorded-content": { + "create-image-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get-function-code-response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-config-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "zipfile-to-image-error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Please don't provide ImageUri when updating a function with packageType Zip." + }, + "Type": "User", + "message": "Please don't provide ImageUri when updating a function with packageType Zip.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-function-code-response-after-update": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-function-config-response-after-update": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings_total_code_size": { + "recorded-date": "10-04-2024, 09:19:29", + "recorded-content": { + "total_code_size_diff_create_function": 276, + "total_code_size_diff_update_function": 276, + "total_code_size_diff_publish_layer": 169, + "total_code_size_diff_publish_layer_version": 169 + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings_total_code_size_config_update": { + "recorded-date": "10-04-2024, 09:19:35", + "recorded-content": { + "is_total_code_size_diff_create_function_more_than_200": true, + "total_code_size_diff_update_function_configuration": 0, + "is_total_code_size_diff_publish_version_after_config_update_more_than_200": true + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventInvokeConfig::test_lambda_eventinvokeconfig_lifecycle": { + "recorded-date": "10-04-2024, 09:13:21", + "recorded-content": { + "put_invokeconfig_retries_0": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_invokeconfig_eventage_60": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_invokeconfig_eventage_nochange": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_invokeconfig_retries": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_invokeconfig": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_invokeconfig_latest": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_single_invokeconfig": { + "FunctionEventInvokeConfigs": [ + { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 60, + "MaximumRetryAttempts": 1 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_version_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_invokeconfig_postpublish": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The function arn::lambda::111111111111:function::1 doesn't have an EventInvokeConfig" + }, + "Message": "The function arn::lambda::111111111111:function::1 doesn't have an EventInvokeConfig", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_published_invokeconfig": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 120, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_published_invokeconfig": { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 120, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_paging_nolimit_postdelete": { + "FunctionEventInvokeConfigs": [ + { + "DestinationConfig": { + "OnFailure": {}, + "OnSuccess": {} + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "LastModified": "datetime", + "MaximumEventAgeInSeconds": 120 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_add_lambda_permission_optional_fields": { + "recorded-date": "21-12-2022, 21:09:27", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_lambda_permission_versioning": { + "recorded-date": "21-12-2022, 20:35:15", + "recorded-content": { + "add_permission": { + "Statement": { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_policy": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_after_publishing_latest": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_after_publishing_new_version": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_policy_after_adding_to_new_version": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_after_adding_2": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + { + "Sid": "s3_2", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_add_lambda_permission_fields": { + "recorded-date": "10-04-2024, 09:16:33", + "recorded-content": { + "add_permission_principal_wildcard": { + "Statement": { + "Sid": "wilcard", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "AWS:SourceAccount": "111111111111" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission_principal_service": { + "Statement": { + "Sid": "lambda", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "AWS:SourceAccount": "111111111111" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission_principal_account": { + "Statement": { + "Sid": "account-id", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission_principal_arn": { + "Statement": { + "Sid": "user-arn", + "Effect": "Allow", + "Principal": { + "AWS": "" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "AWS:SourceAccount": "111111111111" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission_optional_fields": { + "Statement": { + "Sid": "urlPermission", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "AWS:SourceAccount": "111111111111", + "lambda:FunctionUrlAuthType": "NONE", + "aws:PrincipalOrgID": "o-1234567890" + }, + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "add_permission_alexa_skill": { + "Statement": { + "Sid": "alexaSkill", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "lambda:EventSourceToken": "amzn1.ask.skill.xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_lambda_permission_fn_versioning": { + "recorded-date": "10-04-2024, 09:16:29", + "recorded-content": { + "add_permission": { + "Statement": { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_policy": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_version_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_result_after_publishing": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_after_publishing_latest": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_after_publishing_new_version": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_policy_version": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function::1", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_alias_response": { + "AliasArn": "arn::lambda::111111111111:function::permission-alias", + "Description": "", + "FunctionVersion": "1", + "Name": "permission-alias", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_alias": { + "AliasArn": "arn::lambda::111111111111:function::permission-alias", + "Description": "", + "FunctionVersion": "1", + "Name": "permission-alias", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "add_permission_alias_revision_exception": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "get_policy_alias": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function::permission-alias", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_after_adding_to_new_version": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_after_adding_2": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + { + "Sid": "s3_2", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_lambda_permission_revisions": { + "recorded-date": "23-12-2022, 09:38:35", + "recorded-content": { + "add_permission_revision_id_doesnotmatch": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "remove_permission_fn_revision_id_doesnotmatch": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "get_policy_after_publish_version": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + { + "Sid": "s3_2", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_v1_after_publish_version": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3_2", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function::1", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_fn8": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_fn8_v1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_fn9": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_fn9_v1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_policy_v1_after_add_permission9": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3_2", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function::1", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + }, + { + "Sid": "s3_3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function::1", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::s3:::" + } + } + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_basic": { + "recorded-date": "10-04-2024, 09:12:53", + "recorded-content": { + "create_function_response_rev1": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response_rev2": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_function_revision_exception": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "update_function_code_response_rev3": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_rev4": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_function_configuration_revision_exception": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "update_function_configuration_response_rev5": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_rev6": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_version_and_alias": { + "recorded-date": "10-04-2024, 09:12:58", + "recorded-content": { + "create_function_response_rev1": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_active_rev2": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_version_revision_exception": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "publish_version_response_rev_v1": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_published_version_rev_v2": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_latest_rev3": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_alias_response_rev_a1": { + "AliasArn": "arn::lambda::111111111111:function::revision_alias", + "Description": "", + "FunctionVersion": "1", + "Name": "revision_alias", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_latest_rev4": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_published_version_rev_v3": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_alias_revision_exception": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "update_alias_response_rev_a2": { + "AliasArn": "arn::lambda::111111111111:function::revision_alias", + "Description": "something changed", + "FunctionVersion": "1", + "Name": "revision_alias", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_permissions": { + "recorded-date": "10-04-2024, 09:13:02", + "recorded-content": { + "add_permission_revision_exception": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "add_permission_response": { + "Statement": { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_policy_response_rev3": { + "Policy": { + "Version": "2012-10-17", + "Id": "default", + "Statement": [ + { + "Sid": "s3", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:" + } + ] + }, + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove_permission_revision_exception": { + "Error": { + "Code": "PreconditionFailedException", + "Message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id" + }, + "Type": "User", + "message": "The Revision Id provided does not match the latest Revision Id. Call the GetFunction/GetAlias API to retrieve the latest Revision Id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "remove_permission_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_function_quota_exception": { + "recorded-date": "10-04-2024, 09:23:59", + "recorded-content": { + "create_function_with_six_layers": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Cannot reference more than 5 layers." + }, + "Type": "User", + "message": "Cannot reference more than 5 layers.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_validation": { + "recorded-date": "03-03-2025, 17:07:45", + "recorded-content": { + "no_starting_position": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "1 validation error detected: Value null at 'startingPosition' failed to satisfy constraint: Member must not be null." + }, + "Type": "User", + "message": "1 validation error detected: Value null at 'startingPosition' failed to satisfy constraint: Member must not be null.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_starting_position": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'invalid' at 'startingPosition' failed to satisfy constraint: Member must satisfy enum value set: [LATEST, AT_TIMESTAMP, TRIM_HORIZON]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "incompatible_starting_position": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Unsupported starting position for arn type: arn::dynamodb::111111111111:table//stream/" + }, + "Type": "User", + "message": "Unsupported starting position for arn type: arn::dynamodb::111111111111:table//stream/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_exceptions": { + "recorded-date": "31-03-2025, 16:15:53", + "recorded-content": { + "create_function_invalid_snapstart_apply": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'invalidOption' at 'snapStart.applyOn' failed to satisfy constraint: Member must satisfy enum value set: [PublishedVersions, None]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_versions": { + "recorded-date": "10-04-2024, 09:11:51", + "recorded-content": { + "create_image_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_result": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_result": { + "Versions": [ + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionName": "", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_update_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :)", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_update_get_function": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version :)", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalid_sha_publish": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "CodeSHA256 (aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa) is different from current CodeSHA256 in $LATEST (). Please try again with the CodeSHA256 in $LATEST." + }, + "Type": "User", + "message": "CodeSHA256 (aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa) is different from current CodeSHA256 in $LATEST (). Please try again with the CodeSHA256 in $LATEST.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "first_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Second version description :)", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::2", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "second_update_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Third version :)", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_update_get_function": { + "Code": { + "ImageUri": ":latest", + "RepositoryType": "ECR", + "ResolvedImageUri": "@sha256:" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Third version :)", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_publish_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "Third version description :)", + "Environment": { + "Variables": { + "CUSTOM_ENV": "test" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::3", + "FunctionName": "", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Image", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java11]": { + "recorded-date": "01-04-2025, 13:30:54", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_response_latest": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_version_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "version1", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java17]": { + "recorded-date": "01-04-2025, 13:30:58", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_response_latest": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_version_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "version1", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_vpc_config": { + "recorded-date": "12-09-2024, 11:34:43", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "VpcConfig": { + "Ipv6AllowedForDualStack": false, + "SecurityGroupIds": [ + "" + ], + "SubnetIds": [ + "" + ], + "VpcId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "VpcConfig": { + "Ipv6AllowedForDualStack": false, + "SecurityGroupIds": [ + "" + ], + "SubnetIds": [ + "" + ], + "VpcId": "" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_vpcconfig_update_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "VpcConfig": { + "Ipv6AllowedForDualStack": false, + "SecurityGroupIds": [ + "" + ], + "SubnetIds": [ + "" + ], + "VpcId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_vpcconfig_get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "VpcConfig": { + "Ipv6AllowedForDualStack": false, + "SecurityGroupIds": [ + "" + ], + "SubnetIds": [ + "" + ], + "VpcId": "" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_vpcconfig_update_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "VpcConfig": { + "Ipv6AllowedForDualStack": false, + "SecurityGroupIds": [], + "SubnetIds": [], + "VpcId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_vpcconfig_get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "VpcConfig": { + "Ipv6AllowedForDualStack": false, + "SecurityGroupIds": [], + "SubnetIds": [], + "VpcId": "" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_compatibilities[runtimes0]": { + "recorded-date": "01-04-2025, 13:12:59", + "recorded-content": { + "publish_result": { + "CompatibleArchitectures": [ + "arm64", + "x86_64" + ], + "CompatibleRuntimes": [ + "nodejs22.x", + "nodejs20.x", + "nodejs18.x", + "nodejs16.x", + "nodejs14.x", + "nodejs12.x", + "python3.13", + "python3.12", + "python3.11", + "python3.10", + "python3.9", + "python3.8", + "python3.7", + "java21" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_compatibilities[runtimes1]": { + "recorded-date": "01-04-2025, 13:13:03", + "recorded-content": { + "publish_result": { + "CompatibleArchitectures": [ + "arm64", + "x86_64" + ], + "CompatibleRuntimes": [ + "java17", + "java11", + "java8.al2", + "java8", + "dotnet8", + "dotnet6", + "dotnetcore3.1", + "go1.x", + "ruby3.4", + "ruby3.3", + "ruby3.2", + "ruby2.7", + "provided.al2023", + "provided.al2", + "provided" + ], + "Content": { + "CodeSha256": "", + "CodeSize": "", + "Location": "" + }, + "CreatedDate": "date", + "Description": "", + "LayerArn": "arn::lambda::111111111111:layer:", + "LayerVersionArn": "arn::lambda::111111111111:layer::1", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_provisioned_concurrency_limits": { + "recorded-date": "10-04-2024, 09:14:00", + "recorded-content": { + "put_provisioned_concurrency_account_limit_exceeded": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Specified ConcurrentExecutions for function is greater than account's unreserved concurrency []." + }, + "message": "Specified ConcurrentExecutions for function is greater than account's unreserved concurrency [].", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_provisioned_concurrency_below_unreserved_min_value": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Specified ConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of []." + }, + "message": "Specified ConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of [].", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency_limits": { + "recorded-date": "10-04-2024, 09:13:47", + "recorded-content": { + "put_function_concurrency_account_limit_exceeded": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Specified ReservedConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of []." + }, + "message": "Specified ReservedConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of [].", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_function_concurrency_below_unreserved_min_value": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Specified ReservedConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of []." + }, + "message": "Specified ReservedConcurrentExecutions for function decreases account's UnreservedConcurrentExecution below its minimum value of [].", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java21]": { + "recorded-date": "01-04-2025, 13:31:02", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_response_latest": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_version_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "version1", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_zipped_create_lambda": { + "recorded-date": "10-04-2024, 09:17:26", + "recorded-content": { + "invalid_param_exc": { + "Error": { + "Code": "RequestEntityTooLargeException", + "Message": "Zipped size must be smaller than 52428800 bytes" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 413 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_arns": { + "recorded-date": "12-09-2024, 11:30:01", + "recorded-content": { + "create-function-arn-response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "create-function-partial-arn-response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invalid_function_name_exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'invalid:function:name' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "long_function_name_exc": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "1 validation error detected: Value 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 64" + }, + "Type": "User", + "message": "1 validation error detected: Value 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 64", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "long_function_arn_exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::lambda::111111111111:function:' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "function_arn_other_region_exc": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Functions from 'ap-southeast-1' are not reachable in this region ('')" + }, + "Message": "Functions from 'ap-southeast-1' are not reachable in this region ('')", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "function_arn_other_account_exc": { + "Error": { + "Code": "AccessDeniedException", + "Message": null + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_function_name_variations": { + "recorded-date": "14-10-2024, 12:46:37", + "recorded-content": { + "name_only_create_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "partial_arn_latest_create_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "partial_arn_version_create_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "partial_arn_alias_create_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function::myalias", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "full_arn_latest_create_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "full_arn_version_create_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "full_arn_alias_create_esm": { + "BatchSize": 10, + "EventSourceArn": "arn::sqs::111111111111:", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function::myalias", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "MaximumBatchingWindowInSeconds": 0, + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_alias_naming": { + "recorded-date": "21-11-2024, 13:45:11", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "publish_v1": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "create_alias_date": { + "AliasArn": "arn::lambda::111111111111:function::2024-01-02", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "2024-01-02", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_alias_date": { + "AliasArn": "arn::lambda::111111111111:function::2024-01-02", + "Description": "custom-alias", + "FunctionVersion": "1", + "Name": "2024-01-02", + "RevisionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_alias_number_exception": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '2024' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: (?!^[0-9]+$)([a-zA-Z0-9-_]+)" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_invoke": { + "recorded-date": "12-09-2024, 11:34:43", + "recorded-content": { + "invoke_function_name_pattern_exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::lambda::123400000000@function:myfn' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_concurrent_code_updates": { + "recorded-date": "12-09-2024, 11:34:47", + "recorded-content": { + "create-function-response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update-during-in-progress-update-exc": { + "Error": { + "Code": "ResourceConflictException", + "Message": "The operation cannot be performed at this time. An update is in progress for resource: arn::lambda::111111111111:function:" + }, + "Type": "User", + "message": "The operation cannot be performed at this time. An update is in progress for resource: arn::lambda::111111111111:function:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_concurrent_config_updates": { + "recorded-date": "12-09-2024, 11:34:50", + "recorded-content": { + "create-function-response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "update-during-in-progress-update-exc": { + "Error": { + "Code": "ResourceConflictException", + "Message": "The operation cannot be performed at this time. An update is in progress for resource: arn::lambda::111111111111:function:" + }, + "Type": "User", + "message": "The operation cannot be performed at this time. An update is in progress for resource: arn::lambda::111111111111:function:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_advanced_logging_configuration": { + "recorded-date": "19-04-2024, 08:20:18", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "function_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "cool_lambda", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "received_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "cool_lambda", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_advanced_logging_configuration_format_switch": { + "recorded-date": "17-04-2024, 14:16:55", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "function_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "received_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_config_v2": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "received_config_v2": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config0]": { + "recorded-date": "17-04-2024, 14:17:01", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "function_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "received_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config1]": { + "recorded-date": "17-04-2024, 14:17:06", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "function_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "ApplicationLogLevel": "DEBUG", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "received_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "ApplicationLogLevel": "DEBUG", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "INFO" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config2]": { + "recorded-date": "17-04-2024, 14:17:11", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "function_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "DEBUG" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "received_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "ApplicationLogLevel": "INFO", + "LogFormat": "JSON", + "LogGroup": "/aws/lambda/", + "SystemLogLevel": "DEBUG" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config3]": { + "recorded-date": "17-04-2024, 14:17:17", + "recorded-content": { + "create_response": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "get_function_response": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "function_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "cool_lambda" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "received_config": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "cool_lambda" + }, + "MemorySize": 256, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestPartialARNMatching::test_update_function_configuration_full_arn": { + "recorded-date": "05-06-2024, 11:49:05", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[java8]": { + "recorded-date": "01-04-2025, 13:02:29", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of java8 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of java8 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[go1.x]": { + "recorded-date": "01-04-2025, 13:02:29", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of go1.x is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of go1.x is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[provided]": { + "recorded-date": "01-04-2025, 13:02:30", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of provided is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of provided is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[ruby2.7]": { + "recorded-date": "01-04-2025, 13:02:30", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of ruby2.7 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of ruby2.7 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[nodejs14.x]": { + "recorded-date": "01-04-2025, 13:02:30", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of nodejs14.x is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of nodejs14.x is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[python3.7]": { + "recorded-date": "01-04-2025, 13:02:30", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of python3.7 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of python3.7 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[dotnetcore3.1]": { + "recorded-date": "01-04-2025, 13:02:31", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of dotnetcore3.1 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of dotnetcore3.1 is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[nodejs12.x]": { + "recorded-date": "01-04-2025, 13:02:31", + "recorded-content": { + "deprecation_error": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "The runtime parameter of nodejs12.x is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions." + }, + "Type": "User", + "message": "The runtime parameter of nodejs12.x is no longer supported for creating or updating AWS Lambda functions. We recommend you use a supported runtime while creating or updating functions.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestPartialARNMatching::test_cross_region_arn_function_access": { + "recorded-date": "11-06-2024, 13:06:45", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-get_function]": { + "recorded-date": "12-09-2024, 11:30:01", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '*' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-delete_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '*' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-invoke]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '*' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-create_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '*' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-get_function]": { + "recorded-date": "12-09-2024, 11:30:01", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'my-function!' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-delete_function]": { + "recorded-date": "12-09-2024, 11:30:01", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'my-function!' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-invoke]": { + "recorded-date": "12-09-2024, 11:30:01", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'my-function!' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-create_function]": { + "recorded-date": "12-09-2024, 11:30:01", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'my-function!' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-get_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'invalid!' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-delete_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'invalid!' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-invoke]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'invalid!' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-create_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'my-function:invalid!' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-get_function]": { + "recorded-date": "22-08-2024, 15:07:09", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-delete_function]": { + "recorded-date": "22-08-2024, 15:07:10", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-invoke]": { + "recorded-date": "22-08-2024, 15:07:10", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-create_function]": { + "recorded-date": "22-08-2024, 15:07:10", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'my-function:' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-get_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'invalid-account:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-delete_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'invalid-account:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-invoke]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'invalid-account:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-create_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'invalid-account:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-get_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-delete_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-invoke]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-create_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-get_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::ec2::111111111111:instance:i-1234567890abcdef0' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-delete_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::ec2::111111111111:instance:i-1234567890abcdef0' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-invoke]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::ec2::111111111111:instance:i-1234567890abcdef0' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-create_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::ec2::111111111111:instance:i-1234567890abcdef0' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-get_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "get_function_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-delete_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "delete_function_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-invoke]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "invoke_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function::$LATEST" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-create_function]": { + "recorded-date": "12-09-2024, 11:30:03", + "recorded-content": { + "create_function_exception": { + "Code": "InvalidParameterValueException", + "Count": 1, + "Errors": [ + "Value '' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 64" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-get_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 2, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 170", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-delete_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 2, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-functionaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140", + "Value 'arn::lambda:invalid-region:111111111111:function:my-functionaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-invoke]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 2, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 170", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-create_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 2, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-functionaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140", + "Value 'arn::lambda:invalid-region:111111111111:function:my-functionaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-get_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 3, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 170", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-delete_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 3, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-invoke]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 3, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 170", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-create_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 2, + "Errors": [ + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140", + "Value 'arn::lambda:invalid-region:111111111111:function:my-function-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long1-get_function]": { + "recorded-date": "22-08-2024, 15:07:18", + "recorded-content": { + "get_function_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:my-function:" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long1-delete_function]": { + "recorded-date": "22-08-2024, 15:07:18", + "recorded-content": { + "delete_function_exception": { + "Code": "InvalidParameterValueException", + "Message": "Deletion of aliases is not currently supported." + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long1-invoke]": { + "recorded-date": "22-08-2024, 15:07:18", + "recorded-content": { + "invoke_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:my-function:" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long1-create_function]": { + "recorded-date": "22-08-2024, 15:07:19", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-get_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:1:2' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-delete_function]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:1:2' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-invoke]": { + "recorded-date": "12-09-2024, 11:30:04", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:1:2' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-create_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:1:2' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-get_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "get_function_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:function" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-delete_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "delete_function_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:function" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-invoke]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "invoke_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:function:$LATEST" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-create_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-get_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'function:my-function:$LATEST:extra' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-delete_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'function:my-function:$LATEST:extra' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-invoke]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'function:my-function:$LATEST:extra' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-create_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'function:my-function:$LATEST:extra' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-get_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "get_function_exception": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-delete_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "delete_function_exception": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-invoke]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "invoke_exception": { + "Code": "InvalidParameterValueException", + "Message": "The derived qualifier from the function name does not match the specified qualifier." + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-create_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:$LATEST:1' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-get_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": { + "get_function_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:my-function:$latest" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-delete_function]": { + "recorded-date": "12-09-2024, 11:30:05", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-invoke]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "invoke_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:my-function:$latest" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-create_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:$latest' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-get_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-delete_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-invoke]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-create_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-get_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:::function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-delete_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:::function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-invoke]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:::function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-create_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda:::function:my-function' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-get_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:$LATES' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-delete_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:$LATES' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-invoke]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:$LATES' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-create_function]": { + "recorded-date": "12-09-2024, 11:30:06", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'arn::lambda::111111111111:function:my-function:$LATES' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:)?(\\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-get_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "get_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-delete_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "delete_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-invoke]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "invoke_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value '' at 'qualifier' failed to satisfy constraint: Member must have length less than or equal to 128" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-create_function]": { + "recorded-date": "12-09-2024, 11:30:02", + "recorded-content": { + "create_function_exception": { + "Code": "ValidationException", + "Count": 1, + "Errors": [ + "Value 'my-function:' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140" + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_and_qualifier_too_long-get_function]": { + "recorded-date": "22-08-2024, 15:10:43", + "recorded-content": { + "get_function_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:my-function:" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_and_qualifier_too_long-delete_function]": { + "recorded-date": "22-08-2024, 15:10:43", + "recorded-content": { + "delete_function_exception": { + "Code": "InvalidParameterValueException", + "Message": "Deletion of aliases is not currently supported." + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_and_qualifier_too_long-invoke]": { + "recorded-date": "22-08-2024, 15:10:44", + "recorded-content": { + "invoke_exception": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:my-function:" + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_and_qualifier_too_long-create_function]": { + "recorded-date": "22-08-2024, 15:10:45", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_self_managed": { + "recorded-date": "03-09-2024, 20:58:29", + "recorded-content": { + "missing-source-access-configuration": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Required 'sourceAccessConfigurations' parameter is missing." + }, + "Type": "User", + "message": "Required 'sourceAccessConfigurations' parameter is missing.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "event-source-mapping-default": { + "BatchSize": 100, + "FunctionArn": "arn::lambda::111111111111:function:", + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "SelfManagedEventSource": { + "Endpoints": { + "KAFKA_BOOTSTRAP_SERVERS": [ + "kafka:1000" + ] + } + }, + "SelfManagedKafkaEventSourceConfig": { + "ConsumerGroupId": "" + }, + "SourceAccessConfigurations": [ + { + "Type": "BASIC_AUTH", + "URI": "arn::secretsmanager::111111111111:secret:" + } + ], + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "Topics": [ + "topic" + ], + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "duplicate-source": { + "Error": { + "Code": "ResourceConflictException", + "Message": "An event source mapping with event source (\"kafka:1000\"), function (\"arn::lambda::111111111111:function:\"), topics (\"topic\") already exists. Please update or delete the existing mapping with UUID " + }, + "Type": "User", + "message": "An event source mapping with event source (\"kafka:1000\"), function (\"arn::lambda::111111111111:function:\"), topics (\"topic\") already exists. Please update or delete the existing mapping with UUID ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "event-source-mapping-values": { + "BatchSize": 1, + "FunctionArn": "arn::lambda::111111111111:function:", + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "SelfManagedEventSource": { + "Endpoints": { + "KAFKA_BOOTSTRAP_SERVERS": [ + "kafka:1000" + ] + } + }, + "SelfManagedKafkaEventSourceConfig": { + "ConsumerGroupId": "random_id" + }, + "SourceAccessConfigurations": [ + { + "Type": "BASIC_AUTH", + "URI": "arn::secretsmanager::111111111111:secret:" + } + ], + "StartingPosition": "LATEST", + "State": "Creating", + "StateTransitionReason": "USER_INITIATED", + "Topics": [ + "topic_2" + ], + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "multiple-duplicate-source": { + "Error": { + "Code": "ResourceConflictException", + "Message": "An event source mapping with event source (\"kafka:1000,kafka:2000\"), function (\"arn::lambda::111111111111:function:\"), topics (\"topic\") already exists. Please update or delete the existing mapping with UUID " + }, + "Type": "User", + "message": "An event source mapping with event source (\"kafka:1000,kafka:2000\"), function (\"arn::lambda::111111111111:function:\"), topics (\"topic\") already exists. Please update or delete the existing mapping with UUID ", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_allow": { + "recorded-date": "12-09-2024, 11:35:20", + "recorded-content": { + "put_recursion_config_response": { + "RecursiveLoop": "Allow", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_recursion_config_response": { + "RecursiveLoop": "Allow", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_default_terminate": { + "recorded-date": "12-09-2024, 11:35:22", + "recorded-content": { + "get_recursion_default_terminate_response": { + "RecursiveLoop": "Terminate", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_invalid_value": { + "recorded-date": "12-09-2024, 11:35:24", + "recorded-content": { + "put_recursion_invalid_value_error": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'InvalidValue' at 'recursiveLoop' failed to satisfy constraint: Member must satisfy enum value set: [Terminate, Allow]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle[lambda_function]": { + "recorded-date": "23-10-2024, 10:51:15", + "recorded-content": { + "tag_single_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_single_response_listtags": { + "Tags": { + "A": "tag-a" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_multiple_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_multiple_response_listtags": { + "Tags": { + "A": "tag-a", + "B": "tag-b", + "C": "tag-c" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_overlap_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_overlap_response_listtags": { + "Tags": { + "A": "tag-a", + "B": "tag-b", + "C": "tag-c-newsuffix", + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_single_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_single_response_listtags": { + "Tags": { + "B": "tag-b", + "C": "tag-c-newsuffix", + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_multiple_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_multiple_response_listtags": { + "Tags": { + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_nonexisting_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_nonexisting_response_listtags": { + "Tags": { + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_existing_and_nonexisting_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_existing_and_nonexisting_response_listtags": { + "Tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle[event_source_mapping]": { + "recorded-date": "23-10-2024, 10:51:27", + "recorded-content": { + "tag_single_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_single_response_listtags": { + "Tags": { + "A": "tag-a" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_multiple_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_multiple_response_listtags": { + "Tags": { + "A": "tag-a", + "B": "tag-b", + "C": "tag-c" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_overlap_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "tag_overlap_response_listtags": { + "Tags": { + "A": "tag-a", + "B": "tag-b", + "C": "tag-c-newsuffix", + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_single_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_single_response_listtags": { + "Tags": { + "B": "tag-b", + "C": "tag-c-newsuffix", + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_multiple_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_multiple_response_listtags": { + "Tags": { + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_nonexisting_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_nonexisting_response_listtags": { + "Tags": { + "D": "tag-d" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_existing_and_nonexisting_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "untag_existing_and_nonexisting_response_listtags": { + "Tags": {}, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_create_tag_on_esm_create": { + "recorded-date": "24-10-2024, 14:16:07", + "recorded-content": { + "get_event_source_mapping_with_tag": true, + "list_tags_result": { + "Tags": { + "testtag": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_exceptions[lambda_function]": { + "recorded-date": "24-10-2024, 12:42:56", + "recorded-content": { + "not_found_exception_tag": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "not_found_exception_untag": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "not_found_exception_list": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Function not found: arn::lambda::111111111111:function:" + }, + "Message": "Function not found: arn::lambda::111111111111:function:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "aliased_arn_exception": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Tags on function aliases and versions are not supported. Please specify a function ARN." + }, + "Type": "User", + "message": "Tags on function aliases and versions are not supported. Please specify a function ARN.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_arn_exception": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::lambda::111111111111:foobar:' at 'resource' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*):lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:(function:[a-zA-Z0-9-_]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?|layer:([a-zA-Z0-9-_]+)|code-signing-config:csc-[a-z0-9]{17}|event-source-mapping:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_exceptions[event_source_mapping]": { + "recorded-date": "24-10-2024, 12:42:57", + "recorded-content": { + "not_found_exception_tag": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event source mapping not found: arn::lambda::111111111111:event-source-mapping:" + }, + "Message": "Event source mapping not found: arn::lambda::111111111111:event-source-mapping:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "not_found_exception_untag": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event source mapping not found: arn::lambda::111111111111:event-source-mapping:" + }, + "Message": "Event source mapping not found: arn::lambda::111111111111:event-source-mapping:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "not_found_exception_list": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Event source mapping not found: arn::lambda::111111111111:event-source-mapping:" + }, + "Message": "Event source mapping not found: arn::lambda::111111111111:event-source-mapping:", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "aliased_arn_exception": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::lambda::111111111111:event-source-mapping::alias' at 'resource' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*):lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:(function:[a-zA-Z0-9-_]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?|layer:([a-zA-Z0-9-_]+)|code-signing-config:csc-[a-z0-9]{17}|event-source-mapping:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_arn_exception": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'arn::lambda::111111111111:foobar:' at 'resource' failed to satisfy constraint: Member must satisfy regular expression pattern: arn:(aws[a-zA-Z-]*):lambda:[a-z]{2}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{1}:\\d{12}:(function:[a-zA-Z0-9-_]+(:(\\$LATEST|[a-zA-Z0-9-_]+))?|layer:([a-zA-Z0-9-_]+)|code-signing-config:csc-[a-z0-9]{17}|event-source-mapping:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_lifecycle_delete_function": { + "recorded-date": "12-10-2024, 10:00:01", + "recorded-content": { + "update_table_response": { + "TableDescription": { + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "BillingModeSummary": { + "BillingMode": "PAY_PER_REQUEST", + "LastUpdateToPayPerRequestDateTime": "datetime" + }, + "CreationDateTime": "datetime", + "DeletionProtectionEnabled": false, + "ItemCount": 0, + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "LatestStreamArn": "arn::dynamodb::111111111111:table//stream/", + "LatestStreamLabel": "", + "ProvisionedThroughput": { + "NumberOfDecreasesToday": 0, + "ReadCapacityUnits": 0, + "WriteCapacityUnits": 0 + }, + "StreamSpecification": { + "StreamEnabled": true, + "StreamViewType": "NEW_IMAGE" + }, + "TableArn": "arn::dynamodb::111111111111:table/", + "TableId": "", + "TableName": "", + "TableSizeBytes": 0, + "TableStatus": "UPDATING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + }, + "get_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_function_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_response_post_delete": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Enabled", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_response": { + "BatchSize": 1, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + } + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 1, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": 1, + "ParallelizationFactor": 1, + "StartingPosition": "TRIM_HORIZON", + "State": "Deleting", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_deletion_without_qualifier": { + "recorded-date": "21-11-2024, 13:44:17", + "recorded-content": { + "url_creation": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "lambda-url", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "url_with_alias_creation": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function::test-alias", + "FunctionUrl": "lambda-url", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_url_config": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionUrl": "lambda-url", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_url_config_with_alias": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function::test-alias", + "FunctionUrl": "lambda-url", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_function_url_config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_url_config_after_deletion": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource you requested does not exist." + }, + "Message": "The resource you requested does not exist.", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get_url_config_with_alias_after_deletion": { + "AuthType": "NONE", + "CreationTime": "date", + "FunctionArn": "arn::lambda::111111111111:function::test-alias", + "FunctionUrl": "lambda-url", + "InvokeMode": "BUFFERED", + "LastModifiedTime": "date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_non_existent_alias_deletion": { + "recorded-date": "21-11-2024, 13:44:51", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "delete_alias_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_non_existent_alias_update": { + "recorded-date": "21-11-2024, 13:44:53", + "recorded-content": { + "create_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "testenv": "staging" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_alias_response": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Alias not found: arn::lambda::111111111111:function::non-existent" + }, + "Message": "Alias not found: arn::lambda::111111111111:function::non-existent", + "Type": "User", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_filter_criteria_validation": { + "recorded-date": "11-12-2024, 11:29:54", + "recorded-content": { + "response-with-empty-filters": { + "BatchSize": 100, + "BisectBatchOnFunctionError": false, + "DestinationConfig": { + "OnFailure": {} + }, + "EventSourceArn": "arn::dynamodb::111111111111:table//stream/", + "EventSourceMappingArn": "arn::lambda::111111111111:event-source-mapping:", + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionResponseTypes": [], + "LastModified": "datetime", + "LastProcessingResult": "No records processed", + "MaximumBatchingWindowInSeconds": 0, + "MaximumRecordAgeInSeconds": -1, + "MaximumRetryAttempts": -1, + "ParallelizationFactor": 1, + "StartingPosition": "LATEST", + "State": "Creating", + "StateTransitionReason": "User action", + "TumblingWindowInSeconds": 0, + "UUID": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[python3.13]": { + "recorded-date": "01-04-2025, 13:31:11", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_response_latest": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_version_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "version1", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[python3.12]": { + "recorded-date": "01-04-2025, 13:31:07", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_response_latest": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_version_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "version1", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_vpc_config_subnet": { + "recorded-date": "20-02-2025, 17:53:33", + "recorded-content": { + "create-response-non-existent-subnet-id": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Error occurred while DescribeSubnets. EC2 Error Code: InvalidSubnetID.NotFound. EC2 Error Message: The subnet ID '' does not exist" + }, + "Type": "User", + "message": "Error occurred while DescribeSubnets. EC2 Error Code: InvalidSubnetID.NotFound. EC2 Error Message: The subnet ID '' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-response-invalid-format-subnet-id": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[]' at 'vpcConfig.subnetIds' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 1024, Member must have length greater than or equal to 0, Member must satisfy regular expression pattern: ^subnet-[0-9a-z]*$]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_vpc_config_security_group": { + "recorded-date": "20-02-2025, 17:57:29", + "recorded-content": { + "create-response-non-existent-security-group": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "Error occurred while DescribeSecurityGroups. EC2 Error Code: InvalidGroup.NotFound. EC2 Error Message: The security group '' does not exist" + }, + "Type": "User", + "message": "Error occurred while DescribeSecurityGroups. EC2 Error Code: InvalidGroup.NotFound. EC2 Error Message: The security group '' does not exist", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-response-invalid-format-security-group": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '[]' at 'vpcConfig.securityGroupIds' failed to satisfy constraint: Member must satisfy constraint: [Member must have length less than or equal to 1024, Member must have length greater than or equal to 0, Member must satisfy regular expression pattern: ^sg-[0-9a-zA-Z]*$]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_validation_kinesis": { + "recorded-date": "03-03-2025, 16:49:40", + "recorded-content": { + "no_starting_position": { + "Error": { + "Code": "InvalidParameterValueException", + "Message": "1 validation error detected: Value null at 'startingPosition' failed to satisfy constraint: Member must not be null." + }, + "Type": "User", + "message": "1 validation error detected: Value null at 'startingPosition' failed to satisfy constraint: Member must not be null.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_starting_position": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'invalid' at 'startingPosition' failed to satisfy constraint: Member must satisfy enum value set: [LATEST, AT_TIMESTAMP, TRIM_HORIZON]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[dotnet8]": { + "recorded-date": "01-04-2025, 13:31:15", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_function_response_latest": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_function_response_version_1": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "version1", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function::1", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java11]": { + "recorded-date": "01-04-2025, 13:40:26", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "PublishedVersions", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java17]": { + "recorded-date": "01-04-2025, 13:40:32", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "PublishedVersions", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java21]": { + "recorded-date": "01-04-2025, 13:40:35", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "PublishedVersions", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[python3.12]": { + "recorded-date": "01-04-2025, 13:40:40", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "PublishedVersions", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[python3.13]": { + "recorded-date": "01-04-2025, 13:40:44", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "PublishedVersions", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[dotnet8]": { + "recorded-date": "01-04-2025, 13:40:47", + "recorded-content": { + "create_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "update_function_response": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "PublishedVersions", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 5, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/lambda_/test_lambda_api.validation.json b/tests/aws/services/lambda_/test_lambda_api.validation.json new file mode 100644 index 0000000000000..757169d7ade65 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_api.validation.json @@ -0,0 +1,695 @@ +{ + "tests/aws/services/lambda_/test_lambda_api.py::TestCodeSigningConfig::test_code_signing_not_found_excs": { + "last_validated_date": "2024-04-10T09:19:15+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestCodeSigningConfig::test_function_code_signing_config": { + "last_validated_date": "2024-04-10T09:19:10+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings": { + "last_validated_date": "2024-04-10T09:19:17+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings_total_code_size": { + "last_validated_date": "2024-04-10T09:19:28+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAccountSettings::test_account_settings_total_code_size_config_update": { + "last_validated_date": "2024-04-10T09:19:34+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_alias_lifecycle": { + "last_validated_date": "2024-11-21T13:44:48+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_alias_naming": { + "last_validated_date": "2024-11-21T13:45:11+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_non_existent_alias_deletion": { + "last_validated_date": "2024-11-21T13:44:51+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_non_existent_alias_update": { + "last_validated_date": "2024-11-21T13:44:53+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaAlias::test_notfound_and_invalid_routingconfigs": { + "last_validated_date": "2024-11-21T13:45:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventInvokeConfig::test_lambda_eventinvokeconfig_exceptions": { + "last_validated_date": "2024-04-10T09:13:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventInvokeConfig::test_lambda_eventinvokeconfig_lifecycle": { + "last_validated_date": "2024-04-10T09:13:20+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_filter_criteria_validation": { + "last_validated_date": "2024-12-11T11:29:51+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_self_managed": { + "last_validated_date": "2024-09-03T20:58:27+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_validation": { + "last_validated_date": "2025-03-03T17:07:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_create_event_source_validation_kinesis": { + "last_validated_date": "2025-03-03T16:49:39+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_exceptions": { + "last_validated_date": "2024-12-05T10:52:30+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_lifecycle": { + "last_validated_date": "2024-10-14T12:36:54+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_event_source_mapping_lifecycle_delete_function": { + "last_validated_date": "2024-10-12T09:59:58+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaEventSourceMappings::test_function_name_variations": { + "last_validated_date": "2024-10-14T12:46:32+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_advance_logging_configuration_format_switch": { + "last_validated_date": "2024-04-10T08:58:47+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_create_lambda_exceptions": { + "last_validated_date": "2025-04-01T13:08:49+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_delete_on_nonexisting_version": { + "last_validated_date": "2024-09-12T11:29:32+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_advanced_configuration": { + "last_validated_date": "2024-03-28T09:54:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_advanced_logging_configuration": { + "last_validated_date": "2024-04-10T08:59:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_arns": { + "last_validated_date": "2024-09-12T11:30:00+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_lifecycle": { + "last_validated_date": "2024-09-12T11:29:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-create_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-delete_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-get_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_and_qualifier_too_long_and_invalid_region-invoke]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-create_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-delete_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-get_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[full_arn_with_multiple_qualifiers-invoke]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_and_qualifier_too_long-delete_function]": { + "last_validated_date": "2024-08-22T15:10:43+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_and_qualifier_too_long-get_function]": { + "last_validated_date": "2024-08-22T15:10:43+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_and_qualifier_too_long-invoke]": { + "last_validated_date": "2024-08-22T15:10:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-create_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-delete_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-get_function]": { + "last_validated_date": "2024-09-12T11:30:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_is_single_invalid-invoke]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-create_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-delete_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-get_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long-invoke]": { + "last_validated_date": "2024-08-22T15:20:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-create_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-delete_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-get_function]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[function_name_too_long_and_invalid_region-invoke]": { + "last_validated_date": "2024-09-12T11:30:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-delete_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-get_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[incomplete_arn-invoke]": { + "last_validated_date": "2024-08-22T15:20:38+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-create_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-delete_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-get_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_account_id_in_partial_arn-invoke]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-create_function]": { + "last_validated_date": "2024-09-12T11:30:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-delete_function]": { + "last_validated_date": "2024-09-12T11:30:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-get_function]": { + "last_validated_date": "2024-09-12T11:30:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_function_name-invoke]": { + "last_validated_date": "2024-09-12T11:30:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-create_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-delete_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-get_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_characters_in_qualifier-invoke]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-create_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-delete_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-get_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[invalid_region_in_arn-invoke]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-create_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-delete_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-get_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[latest_version_with_additional_qualifier-invoke]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-create_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-get_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[lowercase_latest_qualifier-invoke]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-create_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-delete_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-get_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_account_id_in_arn-invoke]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-create_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-delete_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-get_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[missing_region_in_arn-invoke]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-create_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-delete_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-get_function]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[misspelled_latest_in_arn-invoke]": { + "last_validated_date": "2024-09-12T11:30:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-create_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-delete_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-get_function]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[non_lambda_arn-invoke]": { + "last_validated_date": "2024-09-12T11:30:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-create_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-delete_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-get_function]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[partial_arn_with_extra_qualifier-invoke]": { + "last_validated_date": "2024-09-12T11:30:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-create_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-delete_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-get_function]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long-invoke]": { + "last_validated_date": "2024-09-12T11:30:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-create_function]": { + "last_validated_date": "2024-08-22T15:07:10+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-delete_function]": { + "last_validated_date": "2024-08-22T15:07:10+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-get_function]": { + "last_validated_date": "2024-08-22T15:07:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long0-invoke]": { + "last_validated_date": "2024-08-22T15:07:10+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long1-delete_function]": { + "last_validated_date": "2024-08-22T15:07:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long1-get_function]": { + "last_validated_date": "2024-08-22T15:07:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_name_and_qualifier_validation[qualifier_too_long1-invoke]": { + "last_validated_date": "2024-08-22T15:07:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_partial_advanced_logging_configuration_update[partial_config0]": { + "last_validated_date": "2024-04-10T08:58:53+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_partial_advanced_logging_configuration_update[partial_config1]": { + "last_validated_date": "2024-04-10T08:58:58+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_partial_advanced_logging_configuration_update[partial_config2]": { + "last_validated_date": "2024-04-10T08:59:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_function_partial_advanced_logging_configuration_update[partial_config3]": { + "last_validated_date": "2024-04-10T08:59:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[delete_function]": { + "last_validated_date": "2024-09-12T11:29:47+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function]": { + "last_validated_date": "2024-09-12T11:29:35+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_code_signing_config]": { + "last_validated_date": "2024-09-12T11:29:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_concurrency]": { + "last_validated_date": "2024-09-12T11:29:45+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_configuration]": { + "last_validated_date": "2024-09-12T11:29:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_event_invoke_config]": { + "last_validated_date": "2024-09-12T11:29:43+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[get_function_url_config]": { + "last_validated_date": "2024-09-12T11:29:39+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_get_function_wrong_region[invoke]": { + "last_validated_date": "2024-09-12T11:29:49+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_invoke": { + "last_validated_date": "2024-09-12T11:34:43+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_vpc_config": { + "last_validated_date": "2025-02-20T17:44:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_vpc_config_security_group": { + "last_validated_date": "2025-02-20T17:57:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_invalid_vpc_config_subnet": { + "last_validated_date": "2025-02-20T17:53:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_code_location_s3": { + "last_validated_date": "2024-09-12T11:29:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_code_location_zipfile": { + "last_validated_date": "2024-09-12T11:29:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_concurrent_code_updates": { + "last_validated_date": "2024-09-12T11:34:46+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_lambda_concurrent_config_updates": { + "last_validated_date": "2024-09-12T11:34:50+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_list_functions": { + "last_validated_date": "2024-09-12T11:30:19+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[delete_function]": { + "last_validated_date": "2024-09-12T11:29:32+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function]": { + "last_validated_date": "2024-09-12T11:29:32+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_code_signing_config]": { + "last_validated_date": "2024-09-12T11:29:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_concurrency]": { + "last_validated_date": "2024-09-12T11:29:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_configuration]": { + "last_validated_date": "2024-09-12T11:29:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_event_invoke_config]": { + "last_validated_date": "2024-09-12T11:29:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_fn[get_function_url_config]": { + "last_validated_date": "2024-09-12T11:29:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function]": { + "last_validated_date": "2024-09-12T11:29:25+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function_configuration]": { + "last_validated_date": "2024-09-12T11:29:27+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_on_nonexisting_version[get_function_event_invoke_config]": { + "last_validated_date": "2024-09-12T11:29:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[delete_function]": { + "last_validated_date": "2024-09-12T11:29:23+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[get_function]": { + "last_validated_date": "2024-09-12T11:29:23+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_ops_with_arn_qualifier_mismatch[get_function_configuration]": { + "last_validated_date": "2024-09-12T11:29:24+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_redundant_updates": { + "last_validated_date": "2024-09-12T11:29:23+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_update_lambda_exceptions": { + "last_validated_date": "2025-04-01T13:10:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaFunction::test_vpc_config": { + "last_validated_date": "2024-09-12T11:34:40+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_and_image_config_crud": { + "last_validated_date": "2024-04-10T09:11:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_crud": { + "last_validated_date": "2024-04-10T09:10:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_image_versions": { + "last_validated_date": "2024-04-10T09:11:50+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaImages::test_lambda_zip_file_to_image": { + "last_validated_date": "2024-04-10T09:10:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_compatibilities[runtimes0]": { + "last_validated_date": "2025-04-01T13:14:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_compatibilities[runtimes1]": { + "last_validated_date": "2025-04-01T13:15:00+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_exceptions": { + "last_validated_date": "2025-04-01T13:19:40+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_function_exceptions": { + "last_validated_date": "2024-04-10T09:23:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_function_quota_exception": { + "last_validated_date": "2024-04-10T09:23:58+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_lifecycle": { + "last_validated_date": "2024-04-10T09:24:16+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_policy_exceptions": { + "last_validated_date": "2024-04-10T09:24:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_policy_lifecycle": { + "last_validated_date": "2024-04-10T09:24:34+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaLayer::test_layer_s3_content": { + "last_validated_date": "2024-04-10T09:24:22+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_add_lambda_permission_aws": { + "last_validated_date": "2024-04-10T09:16:22+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_add_lambda_permission_fields": { + "last_validated_date": "2024-04-10T09:16:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_create_multiple_lambda_permissions": { + "last_validated_date": "2024-04-10T09:16:40+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_lambda_permission_fn_versioning": { + "last_validated_date": "2024-04-10T09:16:28+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_permission_exceptions": { + "last_validated_date": "2024-04-10T09:16:19+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaPermissions::test_remove_multi_permissions": { + "last_validated_date": "2024-04-10T09:16:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_lambda_provisioned_lifecycle": { + "last_validated_date": "2024-04-10T09:16:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_provisioned_concurrency_exceptions": { + "last_validated_date": "2024-04-10T09:13:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaProvisionedConcurrency::test_provisioned_concurrency_limits": { + "last_validated_date": "2024-04-10T09:13:59+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_allow": { + "last_validated_date": "2024-09-12T11:35:19+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_default_terminate": { + "last_validated_date": "2024-09-12T11:35:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRecursion::test_put_function_recursion_config_invalid_value": { + "last_validated_date": "2024-09-12T11:35:23+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency": { + "last_validated_date": "2024-04-10T09:13:50+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency_exceptions": { + "last_validated_date": "2024-04-10T09:13:43+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaReservedConcurrency::test_function_concurrency_limits": { + "last_validated_date": "2024-04-10T09:13:46+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_basic": { + "last_validated_date": "2024-04-10T09:12:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_permissions": { + "last_validated_date": "2024-04-10T09:13:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaRevisions::test_function_revisions_version_and_alias": { + "last_validated_date": "2024-04-10T09:12:57+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_lambda_envvars_near_limit_succeeds": { + "last_validated_date": "2024-04-10T09:19:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_environment_fails_multiple_keys": { + "last_validated_date": "2024-04-10T09:19:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_environment_variables_fails": { + "last_validated_date": "2024-04-10T09:18:46+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_large_lambda": { + "last_validated_date": "2024-04-10T09:18:27+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_request_create_lambda": { + "last_validated_date": "2024-04-10T09:17:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_unzipped_lambda": { + "last_validated_date": "2024-04-10T09:17:46+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSizeLimits::test_oversized_zipped_create_lambda": { + "last_validated_date": "2024-04-10T09:17:26+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_exceptions": { + "last_validated_date": "2025-03-31T16:15:53+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[dotnet8]": { + "last_validated_date": "2025-04-01T13:31:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java11]": { + "last_validated_date": "2025-04-01T13:30:54+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java17]": { + "last_validated_date": "2025-04-01T13:30:57+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[java21]": { + "last_validated_date": "2025-04-01T13:31:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[python3.12]": { + "last_validated_date": "2025-04-01T13:31:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_lifecycle[python3.13]": { + "last_validated_date": "2025-04-01T13:31:10+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[dotnet8]": { + "last_validated_date": "2025-04-01T13:42:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java11]": { + "last_validated_date": "2025-04-01T13:41:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java17]": { + "last_validated_date": "2025-04-01T13:41:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[java21]": { + "last_validated_date": "2025-04-01T13:42:01+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[python3.12]": { + "last_validated_date": "2025-04-01T13:42:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaSnapStart::test_snapstart_update_function_configuration[python3.13]": { + "last_validated_date": "2025-04-01T13:42:08+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_create_tag_on_esm_create": { + "last_validated_date": "2024-10-24T14:16:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_create_tag_on_fn_create": { + "last_validated_date": "2024-04-10T09:13:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_exceptions[event_source_mapping]": { + "last_validated_date": "2024-10-24T12:42:57+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_exceptions[lambda_function]": { + "last_validated_date": "2024-10-24T12:42:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle": { + "last_validated_date": "2024-04-10T09:13:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle[event_source_mapping]": { + "last_validated_date": "2024-10-23T10:51:25+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_lifecycle[lambda_function]": { + "last_validated_date": "2024-10-23T10:51:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTag::test_tag_nonexisting_resource": { + "last_validated_date": "2024-04-10T09:13:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_exceptions": { + "last_validated_date": "2024-10-24T15:22:27+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_lifecycle": { + "last_validated_date": "2024-10-24T15:22:47+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_limits": { + "last_validated_date": "2024-10-28T14:16:36+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaTags::test_tag_versions": { + "last_validated_date": "2024-10-24T15:22:38+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_deletion_without_qualifier": { + "last_validated_date": "2024-11-21T13:44:17+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_exceptions": { + "last_validated_date": "2024-11-21T13:44:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_lifecycle": { + "last_validated_date": "2024-11-21T13:44:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaUrl::test_url_config_list_paging": { + "last_validated_date": "2024-11-21T13:44:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_version_on_create": { + "last_validated_date": "2024-04-10T09:12:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_with_update": { + "last_validated_date": "2024-04-10T09:12:17+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_publish_with_wrong_sha256": { + "last_validated_date": "2024-04-10T09:12:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLambdaVersions::test_version_lifecycle": { + "last_validated_date": "2024-07-12T11:43:40+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_advanced_logging_configuration_format_switch": { + "last_validated_date": "2024-04-17T14:16:55+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_advanced_logging_configuration": { + "last_validated_date": "2024-04-19T08:20:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config0]": { + "last_validated_date": "2024-04-17T14:17:00+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config1]": { + "last_validated_date": "2024-04-17T14:17:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config2]": { + "last_validated_date": "2024-04-17T14:17:11+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestLoggingConfig::test_function_partial_advanced_logging_configuration_update[partial_config3]": { + "last_validated_date": "2024-04-17T14:17:16+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestPartialARNMatching::test_cross_region_arn_function_access": { + "last_validated_date": "2024-06-11T13:06:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestPartialARNMatching::test_update_function_configuration_full_arn": { + "last_validated_date": "2024-06-05T11:49:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[dotnetcore3.1]": { + "last_validated_date": "2025-04-01T13:06:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[go1.x]": { + "last_validated_date": "2025-04-01T13:06:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[java8]": { + "last_validated_date": "2025-04-01T13:06:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[nodejs12.x]": { + "last_validated_date": "2025-04-01T13:06:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[nodejs14.x]": { + "last_validated_date": "2025-04-01T13:06:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[provided]": { + "last_validated_date": "2025-04-01T13:06:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[python3.7]": { + "last_validated_date": "2025-04-01T13:06:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_api.py::TestRuntimeValidation::test_create_deprecated_function_runtime_with_validation_enabled[ruby2.7]": { + "last_validated_date": "2025-04-01T13:06:04+00:00" + } +} diff --git a/tests/aws/services/lambda_/test_lambda_common.py b/tests/aws/services/lambda_/test_lambda_common.py new file mode 100644 index 0000000000000..632f899b2040b --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_common.py @@ -0,0 +1,282 @@ +"""Testing different runtimes focusing on common functionality that should work across all runtimes (e.g., echo invoke). +Internally, these tests are also known as multiruntime tests. + +Directly correlates to the structure found in tests.aws.lambda_.functions.common +Each scenario has the following folder structure: ./common//runtime/ +Runtime can either be directly one of the supported runtimes (e.g. in case of version specific compilation instructions) +or one of the keys in RUNTIMES_AGGREGATED. To selectively execute runtimes, use the runtimes parameter of multiruntime. +Example: runtimes=[Runtime.go1_x] +""" + +import json +import logging +import time +import zipfile + +import pytest +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer + +from localstack.services.lambda_.runtimes import RUNTIMES_AGGREGATED, TESTED_RUNTIMES +from localstack.testing.pytest import markers +from localstack.utils.files import cp_r +from localstack.utils.strings import short_uid, to_bytes + +LOG = logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def snapshot_transformers(snapshot): + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("AWS_ACCESS_KEY_ID", "aws-access-key-id"), + snapshot.transform.key_value("AWS_SECRET_ACCESS_KEY", "aws-secret-access-key"), + snapshot.transform.key_value("AWS_SESSION_TOKEN", "aws-session-token"), + snapshot.transform.key_value("_X_AMZN_TRACE_ID", "x-amzn-trace-id"), + # Works in LocalStack locally but the hash changes in CI and every time at AWS (except for Java runtimes) + snapshot.transform.key_value( + "CodeSha256", value_replacement="", reference_replacement=False + ), + # workaround for integer values + KeyValueBasedTransformer( + lambda k, v: str(v) if k == "remaining_time_in_millis" else None, + "", + replace_reference=False, + ), + snapshot.transform.key_value("deadline", "deadline"), + ] + ) + + +@markers.lambda_runtime_update +class TestLambdaRuntimesCommon: + # TODO: refactor builds by creating a generic parametrizable Makefile per runtime (possibly with an option to + # provide a specific one). This might be doable by including another Makefile: + # https://www.gnu.org/software/make/manual/make.html#Include + + @markers.aws.validated + @markers.multiruntime(scenario="echo") + def test_echo_invoke(self, multiruntime_lambda, aws_client): + # provided lambdas take a little longer for large payloads, hence timeout to 5s + create_function_result = multiruntime_lambda.create_function(MemorySize=1024, Timeout=5) + + def _invoke_with_payload(payload): + invoke_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"], + Payload=to_bytes(json.dumps(payload)), + ) + + assert invoke_result["StatusCode"] == 200 + assert json.load(invoke_result["Payload"]) == payload + assert not invoke_result.get("FunctionError") + + # simple payload + payload = {"hello": "world"} + _invoke_with_payload(payload) + # payload with quotes and other special characters + payload = {"hello": "'\" some other ''\"\" quotes, a emoji πŸ₯³ and some brackets {[}}[([]))"} + _invoke_with_payload(payload) + + # large payload (5MB+) + payload = {"hello": "obi wan!" * 128 * 1024 * 5} + _invoke_with_payload(payload) + + # test non json invocations + # boolean value + payload = True + _invoke_with_payload(payload) + payload = False + _invoke_with_payload(payload) + # None value + payload = None + _invoke_with_payload(payload) + # array value + payload = [1, 2] + _invoke_with_payload(payload) + # number value + payload = 1 + _invoke_with_payload(payload) + # no payload at all + invoke_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"] + ) + assert invoke_result["StatusCode"] == 200 + assert json.load(invoke_result["Payload"]) == {} + assert not invoke_result.get("FunctionError") + + # skip snapshots of LS specific env variables + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: implement logging config + "$..LoggingConfig", + # LocalStack API + "$..environment.LOCALSTACK_HOSTNAME", + "$..environment.EDGE_PORT", + "$..environment.AWS_ENDPOINT_URL", + # TODO: unset RIE API vars in RIE + "$..environment.AWS_LAMBDA_FUNCTION_TIMEOUT", + # AWS SDK container credentials: + # https://docs.aws.amazon.com/sdkref/latest/guide/feature-container-credentials.html + "$..environment.AWS_CONTAINER_AUTHORIZATION_TOKEN", + "$..environment.AWS_CONTAINER_CREDENTIALS_FULL_URI", + # TODO: xray + "$..environment.AWS_XRAY_CONTEXT_MISSING", + "$..environment.AWS_XRAY_DAEMON_ADDRESS", + "$..environment._AWS_XRAY_DAEMON_ADDRESS", + "$..environment._AWS_XRAY_DAEMON_PORT", + "$..environment._X_AMZN_TRACE_ID", + # Specific runtimes + # TODO: Only nodejs18.x: AWS=/etc/pki/tls/certs/ca-bundle.crt LS=var/runtime/ca-cert.pem + "$..environment.NODE_EXTRA_CA_CERTS", + "$..environment._LAMBDA_TELEMETRY_LOG_FD", # Only java8, dotnetcore3.1, dotnet6, go1.x + "$..environment.AWS_EXECUTION_ENV", # Only rust runtime + "$..environment.LD_LIBRARY_PATH", # Only rust runtime (additional /var/lang/bin) + "$..environment.PATH", # Only rust runtime (additional /var/lang/bin) + "$..environment.LC_CTYPE", # Only python3.11 (part of a broken image rollout, likely rolled back) + "$..environment.RUBYLIB", # Changed around 2025-06-17 + # Newer Nodejs images explicitly disable a temporary performance workaround for Nodejs 20 on certain hosts: + # https://nodejs.org/api/cli.html#uv_use_io_uringvalue + # https://techfindings.net/archives/6469 + "$..environment.UV_USE_IO_URING", # Only Nodejs runtimes + # Only Dotnet8 + "$..environment.DOTNET_CLI_TELEMETRY_OPTOUT", + "$..environment.DOTNET_NOLOGO", + "$..environment.DOTNET_RUNNING_IN_CONTAINER", + "$..environment.DOTNET_VERSION", + # Changed from 127.0.0.1:9001 to 169.254.100.1:9001 around 2024-11, which would require network changes + "$..environment.AWS_LAMBDA_RUNTIME_API", + ] + ) + @markers.aws.validated + @markers.multiruntime(scenario="introspection") + def test_introspection_invoke(self, multiruntime_lambda, snapshot, aws_client): + create_function_result = multiruntime_lambda.create_function( + MemorySize=1024, Environment={"Variables": {"TEST_KEY": "TEST_VAL"}} + ) + snapshot.match("create_function_result", create_function_result) + + # simple payload + invoke_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"], + Payload=b'{"simple": "payload"}', + ) + + assert invoke_result["StatusCode"] == 200 + invocation_result_payload = json.load(invoke_result["Payload"]) + assert "environment" in invocation_result_payload + assert "ctx" in invocation_result_payload + assert "packages" in invocation_result_payload + snapshot.match("invocation_result_payload", invocation_result_payload) + + # Check again with a qualified arn as function name + invoke_result_qualified = aws_client.lambda_.invoke( + FunctionName=f"{create_function_result['FunctionArn']}:$LATEST", + Payload=b'{"simple": "payload"}', + ) + + assert invoke_result["StatusCode"] == 200 + invocation_result_payload_qualified = json.load(invoke_result_qualified["Payload"]) + snapshot.match("invocation_result_payload_qualified", invocation_result_payload_qualified) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: implement logging config + "$..LoggingConfig", + ] + ) + @markers.aws.validated + @markers.multiruntime(scenario="uncaughtexception") + def test_uncaught_exception_invoke(self, multiruntime_lambda, snapshot, aws_client): + # unfortunately the stack trace is quite unreliable and changes when AWS updates the runtime transparently + # since the stack trace contains references to internal runtime code. + snapshot.add_transformer( + snapshot.transform.key_value("stackTrace", "", reference_replacement=False) + ) + # for nodejs + snapshot.add_transformer( + snapshot.transform.key_value("trace", "", reference_replacement=False) + ) + create_function_result = multiruntime_lambda.create_function(MemorySize=1024) + snapshot.match("create_function_result", create_function_result) + + # simple payload + invocation_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"], + Payload=b'{"error_msg": "some_error_msg"}', + ) + assert "FunctionError" in invocation_result + snapshot.match("error_result", invocation_result) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: implement logging config + "$..LoggingConfig", + ] + ) + @markers.aws.validated + # Only works for >=al2 runtimes, except for any provided runtimes + # Does NOT work for provided runtimes + # Source: https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper + @markers.multiruntime( + scenario="introspection", + runtimes=list(set(TESTED_RUNTIMES) - set(RUNTIMES_AGGREGATED.get("provided"))), + ) + def test_runtime_wrapper_invoke(self, multiruntime_lambda, snapshot, tmp_path, aws_client): + # copy and modify zip file, pretty dirty hack to reuse scenario and reduce CI test runtime + modified_zip = str(tmp_path / f"temp-zip-{short_uid()}.zip") + cp_r(multiruntime_lambda.zip_file_path, modified_zip) + test_value = f"test-value-{short_uid()}" + env_wrapper = f"""#!/bin/bash + export WRAPPER_VAR={test_value} + exec "$@" + """ + with zipfile.ZipFile(modified_zip, mode="a") as zip_file: + info = zipfile.ZipInfo("environment_wrapper") + info.date_time = time.localtime() + info.external_attr = 0o100755 << 16 + zip_file.writestr(info, env_wrapper) + + # use new zipfile for file upload + multiruntime_lambda.zip_file_path = modified_zip + create_function_result = multiruntime_lambda.create_function( + MemorySize=1024, + Environment={"Variables": {"AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper"}}, + ) + snapshot.match("create_function_result", create_function_result) + + # simple payload + invoke_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"], + Payload=b'{"simple": "payload"}', + ) + + assert invoke_result["StatusCode"] == 200 + invocation_result_payload = json.load(invoke_result["Payload"]) + assert "environment" in invocation_result_payload + assert "ctx" in invocation_result_payload + assert "packages" in invocation_result_payload + assert invocation_result_payload["environment"]["WRAPPER_VAR"] == test_value + + +@markers.lambda_runtime_update +class TestLambdaCallingLocalstack: + """=> Keep these tests synchronized with `test_lambda_endpoint_injection.py` in ext!""" + + @markers.multiruntime( + scenario="endpointinjection", + runtimes=list(set(TESTED_RUNTIMES) - set(RUNTIMES_AGGREGATED.get("provided"))), + ) + @markers.aws.validated + def test_manual_endpoint_injection(self, multiruntime_lambda, tmp_path, aws_client): + """Test calling SQS from Lambda using manual AWS SDK client configuration via AWS_ENDPOINT_URL. + This must work for all runtimes. + The code might differ depending on the SDK version shipped with the Lambda runtime. + This test is designed to be AWS-compatible using minimal code changes to configure the endpoint url for LS. + """ + + create_function_result = multiruntime_lambda.create_function(MemorySize=1024, Timeout=15) + + invocation_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"], + ) + assert "FunctionError" not in invocation_result diff --git a/tests/aws/services/lambda_/test_lambda_common.snapshot.json b/tests/aws/services/lambda_/test_lambda_common.snapshot.json new file mode 100644 index 0000000000000..fa2db765c511b --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_common.snapshot.json @@ -0,0 +1,5425 @@ +{ + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.8]": { + "recorded-date": "31-03-2025, 12:14:56", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java11]": { + "recorded-date": "31-03-2025, 12:15:23", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[provided.al2]": { + "recorded-date": "31-03-2025, 12:17:30", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java8.al2]": { + "recorded-date": "31-03-2025, 12:15:42", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.2]": { + "recorded-date": "31-03-2025, 12:15:54", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.11]": { + "recorded-date": "31-03-2025, 12:14:05", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java17]": { + "recorded-date": "31-03-2025, 12:15:14", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs18.x]": { + "recorded-date": "31-03-2025, 12:13:11", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java21]": { + "recorded-date": "31-03-2025, 12:15:05", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[provided.al2023]": { + "recorded-date": "31-03-2025, 12:17:17", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.9]": { + "recorded-date": "31-03-2025, 12:14:39", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.10]": { + "recorded-date": "31-03-2025, 12:14:20", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.12]": { + "recorded-date": "31-03-2025, 12:13:57", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs20.x]": { + "recorded-date": "31-03-2025, 12:12:59", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[dotnet6]": { + "recorded-date": "31-03-2025, 12:16:46", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs16.x]": { + "recorded-date": "31-03-2025, 12:13:31", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.8]": { + "recorded-date": "17-06-2025, 09:51:26", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.8", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.8", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java11]": { + "recorded-date": "17-06-2025, 09:51:40", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY": "", + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java11", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SECRET_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY": "", + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java11", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SECRET_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[provided.al2]": { + "recorded-date": "17-06-2025, 09:52:11", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "provided.al2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "deadline": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": 1024 + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "deadline": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": 1024 + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java8.al2]": { + "recorded-date": "17-06-2025, 09:51:44", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java8.al2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY": "", + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java8.al2", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SECRET_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY": "", + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java8.al2", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SECRET_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.2]": { + "recorded-date": "17-06-2025, 09:51:47", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_ruby3.2", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "GEM_HOME": "/var/runtime", + "GEM_PATH": "/var/task/vendor/bundle/ruby/3.2.0:/opt/ruby/gems/3.2.0:/var/runtime:/var/runtime/ruby/3.2.0", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "RUBYLIB": "/var/runtime/gems/aws_lambda_ric-3.0.0/lib:/var/task:/var/runtime/lib:/opt/ruby/lib", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_ruby3.2", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "GEM_HOME": "/var/runtime", + "GEM_PATH": "/var/task/vendor/bundle/ruby/3.2.0:/opt/ruby/gems/3.2.0:/var/runtime:/var/runtime/ruby/3.2.0", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "RUBYLIB": "/var/runtime/gems/aws_lambda_ric-3.0.0/lib:/var/task:/var/runtime/lib:/opt/ruby/lib", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.11]": { + "recorded-date": "17-06-2025, 09:51:17", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.11", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.11", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java17]": { + "recorded-date": "17-06-2025, 09:51:36", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java17", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_LAMBDA_TELEMETRY_LOG_FD": "62" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java17", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_LAMBDA_TELEMETRY_LOG_FD": "62" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs18.x]": { + "recorded-date": "17-06-2025, 09:51:05", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs18.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_EXTRA_CA_CERTS": "/var/runtime/ca-cert.pem", + "NODE_PATH": "/opt/nodejs/node18/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs18.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_EXTRA_CA_CERTS": "/var/runtime/ca-cert.pem", + "NODE_PATH": "/opt/nodejs/node18/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java21]": { + "recorded-date": "17-06-2025, 09:51:33", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java21", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_LAMBDA_TELEMETRY_LOG_FD": "62" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_java21", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "echo.Handler", + "_LAMBDA_TELEMETRY_LOG_FD": "62" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[provided.al2023]": { + "recorded-date": "17-06-2025, 09:52:07", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "provided.al2023", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "deadline": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": 1024 + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "deadline": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": 1024 + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.9]": { + "recorded-date": "17-06-2025, 09:51:23", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.9", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.9", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.10]": { + "recorded-date": "17-06-2025, 09:51:20", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.10", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.10", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.12]": { + "recorded-date": "17-06-2025, 09:51:14", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.12", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LC_CTYPE": "C.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.12", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LC_CTYPE": "C.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs20.x]": { + "recorded-date": "17-06-2025, 09:51:01", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs20.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs20.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_PATH": "/opt/nodejs/node20/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs20.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_PATH": "/opt/nodejs/node20/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[dotnet6]": { + "recorded-date": "17-06-2025, 09:51:57", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet6", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_dotnet6", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "DOTNET_ROOT": "/var/lang/bin", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_RUNTIME_NAME": "dotnet6", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "dotnet::Dotnet.Function::FunctionHandler", + "_LAMBDA_TELEMETRY_LOG_FD": "3", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_dotnet6", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "DOTNET_ROOT": "/var/lang/bin", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_RUNTIME_NAME": "dotnet6", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "dotnet::Dotnet.Function::FunctionHandler", + "_LAMBDA_TELEMETRY_LOG_FD": "3", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs16.x]": { + "recorded-date": "17-06-2025, 09:51:08", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs16.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs16.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_EXTRA_CA_CERTS": "/var/runtime/ca-cert.pem", + "NODE_PATH": "/opt/nodejs/node16/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs16.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "127.0.0.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_EXTRA_CA_CERTS": "/var/runtime/ca-cert.pem", + "NODE_PATH": "/opt/nodejs/node16/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.79.129", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.8]": { + "recorded-date": "31-03-2025, 12:22:12", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Failed: some_error_msg", + "errorType": "Exception", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java11]": { + "recorded-date": "31-03-2025, 12:22:27", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "java.lang.RuntimeException", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[provided.al2]": { + "recorded-date": "31-03-2025, 12:26:16", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "provided.al2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "&alloc::boxed::Box", + "errorMessage": "Error: \"some_error_msg\"" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java8.al2]": { + "recorded-date": "31-03-2025, 12:22:31", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java8.al2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "java.lang.RuntimeException", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.2]": { + "recorded-date": "31-03-2025, 12:22:34", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "Function", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.11]": { + "recorded-date": "31-03-2025, 12:22:04", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Failed: some_error_msg", + "errorType": "Exception", + "requestId": "", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java17]": { + "recorded-date": "31-03-2025, 12:22:24", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "java.lang.RuntimeException", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs18.x]": { + "recorded-date": "31-03-2025, 12:21:54", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Error", + "errorMessage": "Error: some_error_msg", + "trace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java21]": { + "recorded-date": "31-03-2025, 12:22:21", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "java.lang.RuntimeException", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[provided.al2023]": { + "recorded-date": "31-03-2025, 12:26:03", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "provided.al2023", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "&alloc::boxed::Box", + "errorMessage": "Error: \"some_error_msg\"" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.9]": { + "recorded-date": "31-03-2025, 12:22:09", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Failed: some_error_msg", + "errorType": "Exception", + "requestId": "", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.10]": { + "recorded-date": "31-03-2025, 12:22:07", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Failed: some_error_msg", + "errorType": "Exception", + "requestId": "", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.12]": { + "recorded-date": "31-03-2025, 12:22:02", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Failed: some_error_msg", + "errorType": "Exception", + "requestId": "", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs20.x]": { + "recorded-date": "31-03-2025, 12:21:51", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs20.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Error", + "errorMessage": "Error: some_error_msg", + "trace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[dotnet6]": { + "recorded-date": "31-03-2025, 12:22:49", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet6", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Exception", + "errorMessage": "Error: some_error_msg", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs16.x]": { + "recorded-date": "31-03-2025, 12:21:57", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs16.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Error", + "errorMessage": "Error: some_error_msg", + "trace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.8]": { + "recorded-date": "31-03-2025, 12:26:39", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java11]": { + "recorded-date": "31-03-2025, 12:26:57", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java8.al2]": { + "recorded-date": "31-03-2025, 12:27:11", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java8.al2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.2]": { + "recorded-date": "31-03-2025, 12:26:45", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.11]": { + "recorded-date": "31-03-2025, 12:26:19", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java17]": { + "recorded-date": "31-03-2025, 12:26:29", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs18.x]": { + "recorded-date": "31-03-2025, 12:26:41", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java21]": { + "recorded-date": "31-03-2025, 12:27:07", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "echo.Handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.9]": { + "recorded-date": "31-03-2025, 12:27:16", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.9", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.10]": { + "recorded-date": "31-03-2025, 12:27:14", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.10", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.12]": { + "recorded-date": "31-03-2025, 12:27:00", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs20.x]": { + "recorded-date": "31-03-2025, 12:26:50", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs20.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[dotnet6]": { + "recorded-date": "31-03-2025, 12:26:33", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet6", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs16.x]": { + "recorded-date": "31-03-2025, 12:26:25", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs16.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs20.x]": { + "recorded-date": "31-03-2025, 17:43:04", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs18.x]": { + "recorded-date": "31-03-2025, 17:42:22", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs16.x]": { + "recorded-date": "31-03-2025, 17:44:41", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.8]": { + "recorded-date": "31-03-2025, 17:43:58", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.9]": { + "recorded-date": "31-03-2025, 17:44:04", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.10]": { + "recorded-date": "31-03-2025, 17:42:25", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.11]": { + "recorded-date": "31-03-2025, 17:43:22", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.12]": { + "recorded-date": "31-03-2025, 17:44:01", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.2]": { + "recorded-date": "31-03-2025, 17:43:19", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[dotnet8]": { + "recorded-date": "31-03-2025, 12:17:03", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[dotnet8]": { + "recorded-date": "17-06-2025, 09:52:01", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_dotnet8", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "DOTNET_ROOT": "/var/lang/bin", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_RUNTIME_NAME": "dotnet8", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "SSL_CERT_FILE": "/var/runtime/empty-certificates.crt", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "dotnet::Dotnet.Function::FunctionHandler", + "_LAMBDA_TELEMETRY_LOG_FD": "62", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_dotnet8", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "DOTNET_ROOT": "/var/lang/bin", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_RUNTIME_NAME": "dotnet8", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "SSL_CERT_FILE": "/var/runtime/empty-certificates.crt", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "dotnet::Dotnet.Function::FunctionHandler", + "_LAMBDA_TELEMETRY_LOG_FD": "62", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[dotnet8]": { + "recorded-date": "31-03-2025, 12:22:56", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Exception", + "errorMessage": "Error: some_error_msg", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[dotnet8]": { + "recorded-date": "31-03-2025, 12:27:03", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "dotnet::Dotnet.Function::FunctionHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "dotnet8", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java17]": { + "recorded-date": "31-03-2025, 17:42:41", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java21]": { + "recorded-date": "31-03-2025, 17:43:01", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java11]": { + "recorded-date": "31-03-2025, 17:44:31", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java8.al2]": { + "recorded-date": "31-03-2025, 17:43:50", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[dotnet6]": { + "recorded-date": "31-03-2025, 17:43:15", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[dotnet8]": { + "recorded-date": "31-03-2025, 17:43:54", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.3]": { + "recorded-date": "31-03-2025, 12:16:02", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.3]": { + "recorded-date": "17-06-2025, 09:51:50", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.3", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_ruby3.3", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "GEM_HOME": "/var/runtime", + "GEM_PATH": "/var/task/vendor/bundle/ruby/3.3.0:/opt/ruby/gems/3.3.0:/var/runtime:/var/runtime/ruby/3.3.0", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "RUBYLIB": "/var/runtime/gems/aws_lambda_ric-3.0.0/lib:/var/task:/var/runtime/lib:/opt/ruby/lib", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_ruby3.3", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "GEM_HOME": "/var/runtime", + "GEM_PATH": "/var/task/vendor/bundle/ruby/3.3.0:/opt/ruby/gems/3.3.0:/var/runtime:/var/runtime/ruby/3.3.0", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "RUBYLIB": "/var/runtime/gems/aws_lambda_ric-3.0.0/lib:/var/task:/var/runtime/lib:/opt/ruby/lib", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.3]": { + "recorded-date": "31-03-2025, 12:22:37", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.3", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "Function", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.3]": { + "recorded-date": "31-03-2025, 12:26:22", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.3", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.3]": { + "recorded-date": "31-03-2025, 17:44:35", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.13]": { + "recorded-date": "31-03-2025, 12:13:46", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.13]": { + "recorded-date": "17-06-2025, 09:51:11", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.13", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LC_CTYPE": "C.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_python3.13", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LC_CTYPE": "C.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "PYTHONPATH": "/var/runtime", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "handler.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.13]": { + "recorded-date": "31-03-2025, 12:21:59", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Failed: some_error_msg", + "errorType": "Exception", + "requestId": "", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.13]": { + "recorded-date": "31-03-2025, 12:26:53", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.13", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.13]": { + "recorded-date": "31-03-2025, 17:44:38", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs22.x]": { + "recorded-date": "31-03-2025, 12:12:50", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs22.x]": { + "recorded-date": "17-06-2025, 09:50:58", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs22.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs22.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_PATH": "/opt/nodejs/node22/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_nodejs22.x", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "NODE_PATH": "/opt/nodejs/node22/node_modules:/opt/nodejs/node_modules:/var/runtime/node_modules:/var/runtime:/var/task", + "PATH": "/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "index.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs22.x]": { + "recorded-date": "31-03-2025, 12:21:49", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs22.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorType": "Error", + "errorMessage": "Error: some_error_msg", + "trace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs22.x]": { + "recorded-date": "31-03-2025, 12:26:36", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "index.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs22.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs22.x]": { + "recorded-date": "31-03-2025, 17:42:44", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.4]": { + "recorded-date": "31-03-2025, 12:16:24", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.4]": { + "recorded-date": "17-06-2025, 09:51:54", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "TEST_KEY": "TEST_VAL" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.4", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "invocation_result_payload": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function:", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_ruby3.4", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "GEM_HOME": "/var/runtime", + "GEM_PATH": "/var/task/vendor/bundle/ruby/3.4.0:/opt/ruby/gems/3.4.0:/var/runtime:/var/runtime/ruby/3.4.0", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "RUBYLIB": "/var/runtime/gems/aws_lambda_ric-3.0.0/lib:/var/task:/var/runtime/lib:/opt/ruby/lib", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + }, + "invocation_result_payload_qualified": { + "ctx": { + "aws_request_id": "", + "function_name": "", + "function_version": "$LATEST", + "invoked_function_arn": "arn::lambda::111111111111:function::$LATEST", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "memory_limit_in_mb": "1024", + "remaining_time_in_millis": "" + }, + "environment": { + "AWS_ACCESS_KEY_ID": "", + "AWS_DEFAULT_REGION": "", + "AWS_EXECUTION_ENV": "AWS_Lambda_ruby3.4", + "AWS_LAMBDA_FUNCTION_MEMORY_SIZE": "1024", + "AWS_LAMBDA_FUNCTION_NAME": "", + "AWS_LAMBDA_FUNCTION_VERSION": "$LATEST", + "AWS_LAMBDA_INITIALIZATION_TYPE": "on-demand", + "AWS_LAMBDA_LOG_GROUP_NAME": "/aws/lambda/", + "AWS_LAMBDA_LOG_STREAM_NAME": "", + "AWS_LAMBDA_RUNTIME_API": "169.254.100.1:9001", + "AWS_REGION": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_XRAY_CONTEXT_MISSING": "LOG_ERROR", + "AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1:2000", + "GEM_HOME": "/var/runtime", + "GEM_PATH": "/var/task/vendor/bundle/ruby/3.4.0:/opt/ruby/gems/3.4.0:/var/runtime:/var/runtime/ruby/3.4.0", + "LAMBDA_RUNTIME_DIR": "/var/runtime", + "LAMBDA_TASK_ROOT": "/var/task", + "LANG": "en_US.UTF-8", + "LD_LIBRARY_PATH": "/var/lang/lib:/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib", + "PATH": "/var/lang/bin:/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin", + "PWD": "/var/task", + "RUBYLIB": "/var/runtime/gems/aws_lambda_ric-3.0.0/lib:/var/task:/var/runtime/lib:/opt/ruby/lib", + "SHLVL": "0", + "TEST_KEY": "TEST_VAL", + "TZ": ":UTC", + "_AWS_XRAY_DAEMON_ADDRESS": "169.254.100.1", + "_AWS_XRAY_DAEMON_PORT": "2000", + "_HANDLER": "function.handler", + "_X_AMZN_TRACE_ID": "" + }, + "packages": [] + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.4]": { + "recorded-date": "31-03-2025, 12:22:40", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.4", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "Function", + "stackTrace": "" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.4]": { + "recorded-date": "31-03-2025, 12:26:47", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": { + "AWS_LAMBDA_EXEC_WRAPPER": "/var/task/environment_wrapper" + } + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "ruby3.4", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.4]": { + "recorded-date": "31-03-2025, 17:43:08", + "recorded-content": {} + } +} diff --git a/tests/aws/services/lambda_/test_lambda_common.validation.json b/tests/aws/services/lambda_/test_lambda_common.validation.json new file mode 100644 index 0000000000000..f17afd9193b9c --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_common.validation.json @@ -0,0 +1,431 @@ +{ + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[dotnet6]": { + "last_validated_date": "2025-03-31T17:43:14+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[dotnet8]": { + "last_validated_date": "2025-03-31T17:43:54+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java11]": { + "last_validated_date": "2025-03-31T17:44:30+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java17]": { + "last_validated_date": "2025-03-31T17:42:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java21]": { + "last_validated_date": "2025-03-31T17:43:00+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[java8.al2]": { + "last_validated_date": "2025-03-31T17:43:49+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs16.x]": { + "last_validated_date": "2025-03-31T17:44:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs18.x]": { + "last_validated_date": "2025-03-31T17:42:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs20.x]": { + "last_validated_date": "2025-03-31T17:43:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[nodejs22.x]": { + "last_validated_date": "2025-03-31T17:42:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.10]": { + "last_validated_date": "2025-03-31T17:42:24+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.11]": { + "last_validated_date": "2025-03-31T17:43:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.12]": { + "last_validated_date": "2025-03-31T17:44:00+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.13]": { + "last_validated_date": "2025-03-31T17:44:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.8]": { + "last_validated_date": "2025-03-31T17:43:57+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[python3.9]": { + "last_validated_date": "2025-03-31T17:44:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.2]": { + "last_validated_date": "2025-03-31T17:43:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.3]": { + "last_validated_date": "2025-03-31T17:44:34+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaCallingLocalstack::test_manual_endpoint_injection[ruby3.4]": { + "last_validated_date": "2025-03-31T17:43:07+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[dotnet6]": { + "last_validated_date": "2025-03-31T12:16:46+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[dotnet8]": { + "last_validated_date": "2025-03-31T12:17:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java11]": { + "last_validated_date": "2025-03-31T12:15:22+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java17]": { + "last_validated_date": "2025-03-31T12:15:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java21]": { + "last_validated_date": "2025-03-31T12:15:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[java8.al2]": { + "last_validated_date": "2025-03-31T12:15:42+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs16.x]": { + "last_validated_date": "2025-03-31T12:13:31+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs18.x]": { + "last_validated_date": "2025-03-31T12:13:11+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs20.x]": { + "last_validated_date": "2025-03-31T12:12:59+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[nodejs22.x]": { + "last_validated_date": "2025-03-31T12:12:50+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[provided.al2023]": { + "last_validated_date": "2025-03-31T12:17:17+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[provided.al2]": { + "last_validated_date": "2025-03-31T12:17:30+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.10]": { + "last_validated_date": "2025-03-31T12:14:20+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.11]": { + "last_validated_date": "2025-03-31T12:14:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.12]": { + "last_validated_date": "2025-03-31T12:13:57+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.13]": { + "last_validated_date": "2025-03-31T12:13:45+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.8]": { + "last_validated_date": "2025-03-31T12:14:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[python3.9]": { + "last_validated_date": "2025-03-31T12:14:39+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.2]": { + "last_validated_date": "2025-03-31T12:15:53+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.3]": { + "last_validated_date": "2025-03-31T12:16:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_echo_invoke[ruby3.4]": { + "last_validated_date": "2025-03-31T12:16:24+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[dotnet6]": { + "last_validated_date": "2025-06-17T09:54:42+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.97, + "teardown": 0.37, + "total": 3.34 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[dotnet8]": { + "last_validated_date": "2025-06-17T09:54:45+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 3.14, + "teardown": 0.35, + "total": 3.49 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java11]": { + "last_validated_date": "2025-06-17T09:54:24+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 3.48, + "teardown": 0.38, + "total": 3.86 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java17]": { + "last_validated_date": "2025-06-17T09:54:20+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 3.21, + "teardown": 0.4, + "total": 3.61 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java21]": { + "last_validated_date": "2025-06-17T09:54:17+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 3.45, + "teardown": 0.36, + "total": 3.81 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[java8.al2]": { + "last_validated_date": "2025-06-17T09:54:28+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 3.87, + "teardown": 0.37, + "total": 4.24 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs16.x]": { + "last_validated_date": "2025-06-17T09:53:53+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.92, + "teardown": 0.34, + "total": 3.26 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs18.x]": { + "last_validated_date": "2025-06-17T09:53:50+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.9, + "teardown": 0.34, + "total": 3.24 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs20.x]": { + "last_validated_date": "2025-06-17T09:53:47+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.91, + "teardown": 0.37, + "total": 3.28 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[nodejs22.x]": { + "last_validated_date": "2025-06-17T09:53:44+00:00", + "durations_in_seconds": { + "setup": 11.98, + "call": 3.16, + "teardown": 0.35, + "total": 15.49 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[provided.al2023]": { + "last_validated_date": "2025-06-17T09:54:51+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 5.57, + "teardown": 0.4, + "total": 5.97 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[provided.al2]": { + "last_validated_date": "2025-06-17T09:54:58+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 5.13, + "teardown": 1.61, + "total": 6.74 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.10]": { + "last_validated_date": "2025-06-17T09:54:06+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.9, + "teardown": 0.35, + "total": 3.25 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.11]": { + "last_validated_date": "2025-06-17T09:54:03+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.67, + "teardown": 0.42, + "total": 3.09 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.12]": { + "last_validated_date": "2025-06-17T09:54:00+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.83, + "teardown": 0.38, + "total": 3.21 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.13]": { + "last_validated_date": "2025-06-17T09:53:57+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.83, + "teardown": 0.35, + "total": 3.18 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.8]": { + "last_validated_date": "2025-06-17T09:54:13+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.97, + "teardown": 0.34, + "total": 3.31 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[python3.9]": { + "last_validated_date": "2025-06-17T09:54:09+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.82, + "teardown": 0.36, + "total": 3.18 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.2]": { + "last_validated_date": "2025-06-17T09:54:31+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.85, + "teardown": 0.36, + "total": 3.21 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.3]": { + "last_validated_date": "2025-06-17T09:54:35+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 3.02, + "teardown": 0.36, + "total": 3.38 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_introspection_invoke[ruby3.4]": { + "last_validated_date": "2025-06-17T09:54:38+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 2.97, + "teardown": 0.35, + "total": 3.32 + } + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[dotnet6]": { + "last_validated_date": "2025-03-31T12:26:32+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[dotnet8]": { + "last_validated_date": "2025-03-31T12:27:03+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java11]": { + "last_validated_date": "2025-03-31T12:26:57+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java17]": { + "last_validated_date": "2025-03-31T12:26:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java21]": { + "last_validated_date": "2025-03-31T12:27:06+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[java8.al2]": { + "last_validated_date": "2025-03-31T12:27:10+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs16.x]": { + "last_validated_date": "2025-03-31T12:26:24+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs18.x]": { + "last_validated_date": "2025-03-31T12:26:41+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs20.x]": { + "last_validated_date": "2025-03-31T12:26:50+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[nodejs22.x]": { + "last_validated_date": "2025-03-31T12:26:35+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.10]": { + "last_validated_date": "2025-03-31T12:27:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.11]": { + "last_validated_date": "2025-03-31T12:26:18+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.12]": { + "last_validated_date": "2025-03-31T12:27:00+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.13]": { + "last_validated_date": "2025-03-31T12:26:53+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.8]": { + "last_validated_date": "2025-03-31T12:26:38+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[python3.9]": { + "last_validated_date": "2025-03-31T12:27:16+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.2]": { + "last_validated_date": "2025-03-31T12:26:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.3]": { + "last_validated_date": "2025-03-31T12:26:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_runtime_wrapper_invoke[ruby3.4]": { + "last_validated_date": "2025-03-31T12:26:47+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[dotnet6]": { + "last_validated_date": "2025-03-31T12:22:48+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[dotnet8]": { + "last_validated_date": "2025-03-31T12:22:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java11]": { + "last_validated_date": "2025-03-31T12:22:27+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java17]": { + "last_validated_date": "2025-03-31T12:22:23+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java21]": { + "last_validated_date": "2025-03-31T12:22:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[java8.al2]": { + "last_validated_date": "2025-03-31T12:22:31+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs16.x]": { + "last_validated_date": "2025-03-31T12:21:56+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs18.x]": { + "last_validated_date": "2025-03-31T12:21:54+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs20.x]": { + "last_validated_date": "2025-03-31T12:21:51+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[nodejs22.x]": { + "last_validated_date": "2025-03-31T12:21:48+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[provided.al2023]": { + "last_validated_date": "2025-03-31T12:26:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[provided.al2]": { + "last_validated_date": "2025-03-31T12:26:16+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.10]": { + "last_validated_date": "2025-03-31T12:22:07+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.11]": { + "last_validated_date": "2025-03-31T12:22:04+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.12]": { + "last_validated_date": "2025-03-31T12:22:02+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.13]": { + "last_validated_date": "2025-03-31T12:21:59+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.8]": { + "last_validated_date": "2025-03-31T12:22:12+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[python3.9]": { + "last_validated_date": "2025-03-31T12:22:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.2]": { + "last_validated_date": "2025-03-31T12:22:34+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.3]": { + "last_validated_date": "2025-03-31T12:22:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_common.py::TestLambdaRuntimesCommon::test_uncaught_exception_invoke[ruby3.4]": { + "last_validated_date": "2025-03-31T12:22:40+00:00" + } +} diff --git a/tests/aws/services/lambda_/test_lambda_destinations.py b/tests/aws/services/lambda_/test_lambda_destinations.py new file mode 100644 index 0000000000000..0e6b809b6e866 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_destinations.py @@ -0,0 +1,608 @@ +import base64 +import json +import os +import time +from typing import TYPE_CHECKING + +import aws_cdk as cdk +import aws_cdk.aws_events as events +import aws_cdk.aws_lambda as awslambda +import aws_cdk.aws_lambda_destinations as destinations +import pytest +from aws_cdk.aws_events import EventPattern, Rule + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid, to_bytes, to_str +from localstack.utils.sync import retry, wait_until +from tests.aws.services.lambda_.functions import lambda_integration +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_PYTHON + +if TYPE_CHECKING: + from mypy_boto3_s3 import CloudWatchLogsClient + + +class TestLambdaDLQ: + @markers.snapshot.skip_snapshot_verify( + paths=["$..DeadLetterConfig", "$..result", "$..LoggingConfig"] + ) + @markers.aws.validated + def test_dead_letter_queue( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + aws_client, + monkeypatch, + ): + if not is_aws_cloud(): + monkeypatch.setattr(config, "LAMBDA_RETRY_BASE_DELAY_SECONDS", 5) + + """Creates a lambda with a defined dead letter queue, and check failed lambda invocation leads to a message""" + # create DLQ and Lambda function + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256")) + snapshot.add_transformer(snapshot.transform.key_value("MD5OfMessageAttributes")) + snapshot.add_transformer( + snapshot.transform.key_value("LogResult") + ) # will be handled separately + + queue_name = f"test-{short_uid()}" + lambda_name = f"test-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + create_lambda_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON, + func_name=lambda_name, + runtime=Runtime.python3_12, + DeadLetterConfig={"TargetArn": queue_arn}, + role=lambda_su_role, + ) + snapshot.match("create_lambda_with_dlq", create_lambda_response) + + # invoke Lambda, triggering an error + payload = {lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1} + aws_client.lambda_.invoke( + FunctionName=lambda_name, + Payload=json.dumps(payload), + InvocationType="Event", + ) + + # assert that message has been received on the DLQ + def receive_dlq(): + result = aws_client.sqs.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ) + assert len(result["Messages"]) > 0 + return result + + # on AWS, event retries can be quite delayed, so we have to wait up to 6 minutes here, potential flakes + receive_result = retry(receive_dlq, retries=120, sleep=3) + snapshot.match("receive_result", receive_result) + + # update DLQ config + update_function_config_response = aws_client.lambda_.update_function_configuration( + FunctionName=lambda_name, DeadLetterConfig={} + ) + snapshot.match("delete_dlq", update_function_config_response) + # TODO: test function update with running invocation => don't kill them all in that case + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=lambda_name) + invoke_result = aws_client.lambda_.invoke( + FunctionName=lambda_name, Payload=json.dumps(payload), LogType="Tail" + ) + snapshot.match("invoke_result", invoke_result) + + log_result = invoke_result["LogResult"] + raw_logs = to_str(base64.b64decode(log_result)) + log_lines = raw_logs.splitlines() + snapshot.match( + "log_result", + {"result": [line for line in log_lines if not line.startswith("REPORT")]}, + ) + + +def wait_until_log_group_exists(fn_name: str, logs_client: "CloudWatchLogsClient"): + def log_group_exists(): + return ( + len( + logs_client.describe_log_groups(logGroupNamePrefix=f"/aws/lambda/{fn_name}")[ + "logGroups" + ] + ) + == 1 + ) + + wait_until(log_group_exists, max_retries=30 if is_aws_cloud() else 10) + + +class TestLambdaDestinationSqs: + @pytest.mark.parametrize( + "payload", + [ + {}, + {lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1}, + ], + ) + @markers.aws.validated + def test_assess_lambda_destination_invocation( + self, + payload, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + aws_client, + ): + """Testing the destination config API and operation (for the OnSuccess case)""" + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + + # create DLQ and Lambda function + queue_name = f"test-{short_uid()}" + lambda_name = f"test-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON, + runtime=Runtime.python3_12, + func_name=lambda_name, + role=lambda_su_role, + ) + + put_event_invoke_config_response = aws_client.lambda_.put_function_event_invoke_config( + FunctionName=lambda_name, + MaximumRetryAttempts=0, + DestinationConfig={ + "OnSuccess": {"Destination": queue_arn}, + "OnFailure": {"Destination": queue_arn}, + }, + ) + snapshot.match("put_function_event_invoke_config", put_event_invoke_config_response) + + aws_client.lambda_.invoke( + FunctionName=lambda_name, + Payload=json.dumps(payload), + InvocationType="Event", + ) + + def receive_message(): + rs = aws_client.sqs.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=2, MessageAttributeNames=["All"] + ) + assert len(rs["Messages"]) > 0 + return rs + + receive_message_result = retry(receive_message, retries=120, sleep=1) + snapshot.match("receive_message_result", receive_message_result) + + @markers.aws.validated + def test_lambda_destination_default_retries( + self, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + snapshot, + monkeypatch, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.key_value("MD5OfBody")) + + if not is_aws_cloud(): + monkeypatch.setattr(config, "LAMBDA_RETRY_BASE_DELAY_SECONDS", 5) + + # create DLQ and Lambda function + queue_name = f"test-{short_uid()}" + lambda_name = f"test-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON, + runtime=Runtime.python3_12, + func_name=lambda_name, + role=lambda_su_role, + ) + + put_event_invoke_config_response = aws_client.lambda_.put_function_event_invoke_config( + FunctionName=lambda_name, + DestinationConfig={ + "OnSuccess": {"Destination": queue_arn}, + "OnFailure": {"Destination": queue_arn}, + }, + ) + snapshot.match("put_function_event_invoke_config", put_event_invoke_config_response) + + aws_client.lambda_.invoke( + FunctionName=lambda_name, + Payload=json.dumps({lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1}), + InvocationType="Event", + ) + + def receive_message(): + rs = aws_client.sqs.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=2, MessageAttributeNames=["All"] + ) + assert len(rs["Messages"]) > 0 + return rs + + # this will take at least 3 minutes on AWS + receive_message_result = retry(receive_message, retries=120, sleep=3) + snapshot.match("receive_message_result", receive_message_result) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Body.requestContext.functionArn"]) + @markers.aws.validated + def test_retries( + self, + snapshot, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + monkeypatch, + aws_client, + ): + """ + behavior test, we don't really care about any API surface here right now + + this is quite long since lambda waits 1 minute between the invoke and first retry and 2 minutes between the first retry and the second retry! + TODO: test if invocation/request ID changes between retries + """ + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer( + snapshot.transform.key_value( + "MD5OfBody", value_replacement="", reference_replacement=False + ) + ) + + test_delay_base = 60 + if not is_aws_cloud(): + test_delay_base = 5 + monkeypatch.setattr(config, "LAMBDA_RETRY_BASE_DELAY_SECONDS", test_delay_base) + + # setup + queue_name = f"destination-queue-{short_uid()}" + fn_name = f"retry-fn-{short_uid()}" + message_id = f"retry-msg-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(message_id, "")) + + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + create_lambda_function( + handler_file=os.path.join(os.path.dirname(__file__), "functions/lambda_echofail.py"), + func_name=fn_name, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + aws_client.lambda_.put_function_event_invoke_config( + FunctionName=fn_name, + MaximumRetryAttempts=2, + DestinationConfig={"OnFailure": {"Destination": queue_arn}}, + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=fn_name) + + invoke_result = aws_client.lambda_.invoke( + FunctionName=fn_name, + Payload=to_bytes(json.dumps({"message": message_id})), + InvocationType="Event", # important, otherwise destinations won't be triggered + ) + assert 200 <= invoke_result["StatusCode"] < 300 + + def get_filtered_event_count() -> int: + filter_result = retry( + aws_client.logs.filter_log_events, sleep=2.0, logGroupName=f"/aws/lambda/{fn_name}" + ) + filtered_log_events = [e for e in filter_result["events"] if message_id in e["message"]] + return len(filtered_log_events) + + # between 0 and 1 min the lambda should NOT have been retried yet + # between 1 min and 3 min the lambda should have been retried once + # TODO: parse log and calculate time diffs for better/more reliable matching + # SQS queue has a thread checking every second, hence we need a 1 second offset + test_delay_base_with_offset = test_delay_base + 1 + time.sleep(test_delay_base_with_offset / 2) + assert get_filtered_event_count() == 1 + time.sleep(test_delay_base_with_offset) + assert get_filtered_event_count() == 2 + time.sleep(test_delay_base_with_offset * 2) + assert get_filtered_event_count() == 3 + + # 1. event should be in queue + def msg_in_queue(): + msgs = aws_client.sqs.receive_message( + QueueUrl=queue_url, AttributeNames=["All"], VisibilityTimeout=0 + ) + return len(msgs["Messages"]) == 1 + + assert wait_until(msg_in_queue) + + # We didn't delete the message so it should be available again after waiting shortly (2x visibility timeout to be sure) + msgs = aws_client.sqs.receive_message( + QueueUrl=queue_url, AttributeNames=["All"], VisibilityTimeout=1 + ) + snapshot.match("queue_destination_payload", msgs) + + # 2. there should be only one event stream (re-use of environment) + # technically not guaranteed but should be nearly 100% + log_streams = aws_client.logs.describe_log_streams(logGroupName=f"/aws/lambda/{fn_name}") + assert len(log_streams["logStreams"]) == 1 + + # 3. the lambda should have been called 3 times (correlation via custom message id) + assert get_filtered_event_count() == 3 + + # verify the event ID is the same in all calls + log_events = aws_client.logs.filter_log_events(logGroupName=f"/aws/lambda/{fn_name}")[ + "events" + ] + + # only get messages with the printed event + request_ids = [ + json.loads(e["message"])["aws_request_id"] + for e in log_events + if message_id in e["message"] + ] + + assert len(request_ids) == 3 # gather invocation ID from all 3 invocations + assert len(set(request_ids)) == 1 # all 3 are equal + + @markers.snapshot.skip_snapshot_verify( + paths=["$..SenderId", "$..Body.requestContext.functionArn"] + ) + @markers.aws.validated + def test_maxeventage( + self, + snapshot, + create_lambda_function, + sqs_create_queue, + sqs_get_queue_arn, + lambda_su_role, + monkeypatch, + aws_client, + ): + """ + Behavior test for MaximumRetryAttempts in EventInvokeConfig + + Noteworthy observation: + * lambda doesn't even wait for the full 60s before the OnFailure destination / DLQ is triggered + + """ + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer( + snapshot.transform.key_value( + "MD5OfBody", value_replacement="", reference_replacement=False + ) + ) + + queue_name = f"destination-queue-{short_uid()}" + fn_name = f"retry-fn-{short_uid()}" + message_id = f"retry-msg-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(message_id, "")) + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + + create_lambda_function( + handler_file=os.path.join(os.path.dirname(__file__), "functions/lambda_echofail.py"), + func_name=fn_name, + role=lambda_su_role, + ) + aws_client.lambda_.put_function_event_invoke_config( + FunctionName=fn_name, + MaximumRetryAttempts=2, + MaximumEventAgeInSeconds=60, + DestinationConfig={"OnFailure": {"Destination": queue_arn}}, + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=fn_name) + + aws_client.lambda_.invoke( + FunctionName=fn_name, + Payload=to_bytes(json.dumps({"message": message_id})), + InvocationType="Event", # important, otherwise destinations won't be triggered + ) + + # wait for log group to exist + + wait_until_log_group_exists(fn_name, aws_client.logs) + + def get_filtered_event_count() -> int: + filter_result = retry( + aws_client.logs.filter_log_events, sleep=2.0, logGroupName=f"/aws/lambda/{fn_name}" + ) + filtered_log_events = [e for e in filter_result["events"] if message_id in e["message"]] + return len(filtered_log_events) + + # lambda doesn't retry because the first delay already is 60s + # invocation + 60s (1st delay) > 60s (configured max) + + def get_msg_from_q(): + msgs = aws_client.sqs.receive_message( + QueueUrl=queue_url, + AttributeNames=["All"], + VisibilityTimeout=3, + MaxNumberOfMessages=1, + WaitTimeSeconds=5, + ) + assert len(msgs["Messages"]) == 1 + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=msgs["Messages"][0]["ReceiptHandle"] + ) + return msgs["Messages"][0] + + msg = retry(get_msg_from_q, retries=15, sleep=3) + snapshot.match("no_retry_failure_message", msg) + + def _assert_event_count(count: int): + assert get_filtered_event_count() == count + + retry(_assert_event_count, retries=5, sleep=1, count=1) # 1 attempt in total (no retries) + + # now we increase the max event age to give it a bit of a buffer for the actual lambda execution (60s + 30s buffer = 90s) + # one retry should now be attempted since there's enough time left + aws_client.lambda_.update_function_event_invoke_config( + FunctionName=fn_name, MaximumEventAgeInSeconds=90, MaximumRetryAttempts=2 + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=fn_name) + + # deleting the log group, so we have a 'fresh' counter + # without it, the assertion later would need to accommodate for previous invocations + aws_client.logs.delete_log_group(logGroupName=f"/aws/lambda/{fn_name}") + + aws_client.lambda_.invoke( + FunctionName=fn_name, + Payload=to_bytes(json.dumps({"message": message_id})), + InvocationType="Event", # important, otherwise destinations won't be triggered + ) + time.sleep( + 60 + ) # absolute minimum wait time (time lambda waits between invoke and first retry) + + msg_retried = retry(get_msg_from_q, retries=15, sleep=3) + snapshot.match("single_retry_failure_message", msg_retried) + + retry(_assert_event_count, retries=5, sleep=1, count=2) # 2 attempts in total (1 retry) + + +# class TestLambdaDestinationSns: +# ... # TODO +# +# +# class TestLambdaDestinationLambda: +# ... # TODO +# +# + + +class TestLambdaDestinationEventbridge: + EVENT_BRIDGE_STACK = "EventbridgeStack" + INPUT_FUNCTION_NAME_OUTPUT = "InputFunc" + TRIGGERED_FUNCTION_NAME_OUTPUT = "TriggeredFunc" + EVENT_BUS_NAME_OUTPUT = "EventBusName" + + INPUT_LAMBDA_CODE = """ +def handler(event, context): + if event.get("mode") == "failure": + raise Exception("intentional failure!") + else: + return { + "hello": "world", + "test": "abc", + "val": 5, + "success": True + } +""" + TRIGGERED_LAMBDA_CODE = """ +import json + +def handler(event, context): + print(json.dumps(event)) + return {"invocation": True} +""" + + @pytest.fixture(scope="class", autouse=True) + def infrastructure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="LambdaDestinationEventbridge") + + # setup a stack with two lambdas: + # - input-lambda will be invoked manually + # - triggered lambda invoked by EventBridge + stack = cdk.Stack(infra.cdk_app, self.EVENT_BRIDGE_STACK) + event_bus = events.EventBus(stack, "CustomEventBus") + + input_func = awslambda.Function( + stack, + "InputLambda", + runtime=awslambda.Runtime.PYTHON_3_12, + handler="index.handler", + code=awslambda.InlineCode(code=self.INPUT_LAMBDA_CODE), + on_success=destinations.EventBridgeDestination(event_bus=event_bus), + on_failure=destinations.EventBridgeDestination(event_bus=event_bus), + retry_attempts=0, + ) + + triggered_func = awslambda.Function( + stack, + "TriggeredLambda", + runtime=awslambda.Runtime.PYTHON_3_12, + code=awslambda.InlineCode(code=self.TRIGGERED_LAMBDA_CODE), + handler="index.handler", + ) + + Rule( + stack, + "EmptyFilterRule", + event_bus=event_bus, + rule_name="CustomRule", + event_pattern=EventPattern(version=["0"]), + targets=[cdk.aws_events_targets.LambdaFunction(triggered_func)], + ) + + cdk.CfnOutput(stack, self.INPUT_FUNCTION_NAME_OUTPUT, value=input_func.function_name) + cdk.CfnOutput( + stack, self.TRIGGERED_FUNCTION_NAME_OUTPUT, value=triggered_func.function_name + ) + cdk.CfnOutput(stack, self.EVENT_BUS_NAME_OUTPUT, value=event_bus.event_bus_name) + + with infra.provisioner(skip_teardown=False) as prov: + yield prov + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..resources"]) + def test_invoke_lambda_eventbridge(self, infrastructure, aws_client, snapshot): + outputs = infrastructure.get_stack_outputs(self.EVENT_BRIDGE_STACK) + input_fn_name = outputs.get(self.INPUT_FUNCTION_NAME_OUTPUT) + triggered_fn_name = outputs.get(self.TRIGGERED_FUNCTION_NAME_OUTPUT) + event_bus_name = outputs.get(self.EVENT_BUS_NAME_OUTPUT) + snapshot.add_transformer(snapshot.transform.regex(event_bus_name, "")) + snapshot.add_transformer(snapshot.transform.regex(triggered_fn_name, "")) + snapshot.add_transformer(snapshot.transform.regex(input_fn_name, "")) + + def _get_event_payload(payload_to_match: str): + log_events = aws_client.logs.filter_log_events( + logGroupName=f"/aws/lambda/{triggered_fn_name}" + ) + forwarded_events = [ + e["message"] + for e in log_events["events"] + if "detail-type" in e["message"] and payload_to_match in e["message"] + ] + assert len(forwarded_events) >= 1 + # message payload is a JSON string but for snapshots it's easier to compare individual fields + return json.loads(forwarded_events[0]) + + # Lambda Destination (SUCCESS) + aws_client.lambda_.invoke( + FunctionName=input_fn_name, + Payload=b'{"mode": "success"}', + InvocationType="Event", # important, otherwise destinations won't be triggered + ) + success_payload = retry( + _get_event_payload, + retries=10, + sleep=10 if is_aws_cloud() else 1, + payload_to_match="success", + ) + snapshot.match("lambda_destination_event_bus_success", success_payload) + + # Lambda Destination (FAILURE) + aws_client.lambda_.invoke( + FunctionName=input_fn_name, + Payload=b'{"mode": "failure"}', + InvocationType="Event", # important, otherwise destinations won't be triggered + ) + failure_payload = retry( + _get_event_payload, + retries=10, + sleep=10 if is_aws_cloud() else 1, + payload_to_match="failure", + ) + snapshot.match("lambda_destination_event_bus_failure", failure_payload) diff --git a/tests/aws/services/lambda_/test_lambda_destinations.snapshot.json b/tests/aws/services/lambda_/test_lambda_destinations.snapshot.json new file mode 100644 index 0000000000000..0775ff1fc5f4b --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_destinations.snapshot.json @@ -0,0 +1,625 @@ +{ + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_assess_lambda_destination_invocation[Success-payload0]": { + "recorded-date": "01-09-2022, 09:00:22", + "recorded-content": { + "put_function_event_invoke_config": { + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + }, + "OnSuccess": { + "Destination": "arn::sqs::111111111111:" + } + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_result": { + "Messages": [ + { + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "Success", + "approximateInvokeCount": 1 + }, + "requestPayload": {}, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST" + }, + "responsePayload": { + "event": {}, + "context": { + "invoked_function_arn": "arn::lambda::111111111111:function:", + "function_version": "$LATEST", + "function_name": "", + "memory_limit_in_mb": "128", + "aws_request_id": "", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "client_context": null + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_assess_lambda_destination_invocation[payload0]": { + "recorded-date": "21-03-2024, 12:26:43", + "recorded-content": { + "put_function_event_invoke_config": { + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + }, + "OnSuccess": { + "Destination": "arn::sqs::111111111111:" + } + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_result": { + "Messages": [ + { + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "Success", + "approximateInvokeCount": 1 + }, + "requestPayload": {}, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST" + }, + "responsePayload": { + "event": {}, + "context": { + "invoked_function_arn": "arn::lambda::111111111111:function:", + "function_version": "$LATEST", + "function_name": "", + "memory_limit_in_mb": "128", + "aws_request_id": "", + "log_group_name": "/aws/lambda/", + "log_stream_name": "", + "client_context": null + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_assess_lambda_destination_invocation[payload1]": { + "recorded-date": "21-03-2024, 12:26:48", + "recorded-content": { + "put_function_event_invoke_config": { + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + }, + "OnSuccess": { + "Destination": "arn::sqs::111111111111:" + } + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "MaximumRetryAttempts": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_result": { + "Messages": [ + { + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "RetriesExhausted", + "approximateInvokeCount": 1 + }, + "requestPayload": { + "raise_error": 1 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "responsePayload": { + "errorMessage": "Test exception (this is intentional)", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 55, in handler\n raise Exception(\"Test exception (this is intentional)\")\n" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDLQ::test_dead_letter_queue": { + "recorded-date": "17-06-2024, 11:49:58", + "recorded-content": { + "create_lambda_with_dlq": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "DeadLetterConfig": { + "TargetArn": "arn::sqs::111111111111:" + }, + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "receive_result": { + "Messages": [ + { + "Body": { + "raise_error": 1 + }, + "MD5OfBody": "ba08269cbd0ce515889bfba6593731e1", + "MD5OfMessageAttributes": "", + "MessageAttributes": { + "ErrorCode": { + "DataType": "Number", + "StringValue": "200" + }, + "ErrorMessage": { + "DataType": "String", + "StringValue": "Test exception (this is intentional)" + }, + "RequestID": { + "DataType": "String", + "StringValue": "" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_dlq": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "", + "CodeSize": "", + "DeadLetterConfig": { + "TargetArn": "arn::sqs::111111111111:" + }, + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "handler.handler", + "LastModified": "date", + "LastUpdateStatus": "InProgress", + "LastUpdateStatusReason": "The function is being created.", + "LastUpdateStatusReasonCode": "Creating", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "python3.12", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invoke_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "LogResult": "", + "Payload": { + "errorMessage": "Test exception (this is intentional)", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 55, in handler\n raise Exception(\"Test exception (this is intentional)\")\n" + ] + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "log_result": { + "result": [ + "START RequestId: Version: $LATEST", + "Lambda log message - print function", + "[INFO]\tdate\t\tLambda log message - logging module", + "LAMBDA_WARNING: Unhandled exception. The most likely cause is an issue in the function code. However, in rare cases, a Lambda runtime update can cause unexpected function behavior. For functions using managed runtimes, runtime updates can be triggered by a function change, or can be applied automatically. To determine if the runtime has been updated, check the runtime version in the INIT_START log entry. If this error correlates with a change in the runtime version, you may be able to mitigate this error by temporarily rolling back to the previous runtime version. For more information, see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-update.html", + "[ERROR] Exception: Test exception (this is intentional)", + "Traceback (most recent call last):", + "\u00a0\u00a0File \"/var/task/handler.py\", line 55, in handler", + "\u00a0\u00a0\u00a0\u00a0raise Exception(\"Test exception (this is intentional)\")END RequestId: " + ] + } + } + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_retries": { + "recorded-date": "22-03-2023, 18:50:18", + "recorded-content": { + "queue_destination_payload": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "RetriesExhausted", + "approximateInvokeCount": 3 + }, + "requestPayload": { + "message": "" + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "responsePayload": { + "errorMessage": "intentional failure", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 7, in handler\n raise Exception(\"intentional failure\")\n" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_maxeventage": { + "recorded-date": "22-03-2023, 18:52:05", + "recorded-content": { + "no_retry_failure_message": { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "EventAgeExceeded", + "approximateInvokeCount": 1 + }, + "requestPayload": { + "message": "" + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "responsePayload": { + "errorMessage": "intentional failure", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 7, in handler\n raise Exception(\"intentional failure\")\n" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + "single_retry_failure_message": { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "EventAgeExceeded", + "approximateInvokeCount": 2 + }, + "requestPayload": { + "message": "" + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "responsePayload": { + "errorMessage": "intentional failure", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 7, in handler\n raise Exception(\"intentional failure\")\n" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + } + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_lambda_destination_default_retries": { + "recorded-date": "21-03-2024, 12:24:09", + "recorded-content": { + "put_function_event_invoke_config": { + "DestinationConfig": { + "OnFailure": { + "Destination": "arn::sqs::111111111111:" + }, + "OnSuccess": { + "Destination": "arn::sqs::111111111111:" + } + }, + "FunctionArn": "arn::lambda::111111111111:function::$LATEST", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_result": { + "Messages": [ + { + "Body": { + "version": "1.0", + "timestamp": "date", + "requestContext": { + "requestId": "", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "condition": "RetriesExhausted", + "approximateInvokeCount": 3 + }, + "requestPayload": { + "raise_error": 1 + }, + "responseContext": { + "statusCode": 200, + "executedVersion": "$LATEST", + "functionError": "Unhandled" + }, + "responsePayload": { + "errorMessage": "Test exception (this is intentional)", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 55, in handler\n raise Exception(\"Test exception (this is intentional)\")\n" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationEventbridge::test_invoke_lambda_eventbridge": { + "recorded-date": "19-11-2024, 08:49:47", + "recorded-content": { + "lambda_destination_event_bus_success": { + "account": "111111111111", + "detail": { + "requestContext": { + "approximateInvokeCount": 1, + "condition": "Success", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "requestId": "" + }, + "requestPayload": { + "mode": "success" + }, + "responseContext": { + "executedVersion": "$LATEST", + "statusCode": 200 + }, + "responsePayload": { + "hello": "world", + "success": true, + "test": "abc", + "val": 5 + }, + "timestamp": "date", + "version": "1.0" + }, + "detail-type": "Lambda Function Invocation Result - Success", + "id": "", + "region": "", + "resources": [ + "arn::events::111111111111:event-bus/", + "arn::lambda::111111111111:function::$LATEST" + ], + "source": "lambda", + "time": "date", + "version": "0" + }, + "lambda_destination_event_bus_failure": { + "account": "111111111111", + "detail": { + "requestContext": { + "approximateInvokeCount": 1, + "condition": "RetriesExhausted", + "functionArn": "arn::lambda::111111111111:function::$LATEST", + "requestId": "" + }, + "requestPayload": { + "mode": "failure" + }, + "responseContext": { + "executedVersion": "$LATEST", + "functionError": "Unhandled", + "statusCode": 200 + }, + "responsePayload": { + "errorMessage": "intentional failure!", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/index.py\", line 4, in handler\n raise Exception(\"intentional failure!\")\n" + ] + }, + "timestamp": "date", + "version": "1.0" + }, + "detail-type": "Lambda Function Invocation Result - Failure", + "id": "", + "region": "", + "resources": [ + "arn::events::111111111111:event-bus/", + "arn::lambda::111111111111:function::$LATEST" + ], + "source": "lambda", + "time": "date", + "version": "0" + } + } + } +} diff --git a/tests/aws/services/lambda_/test_lambda_destinations.validation.json b/tests/aws/services/lambda_/test_lambda_destinations.validation.json new file mode 100644 index 0000000000000..01ad2cef6650d --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_destinations.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDLQ::test_dead_letter_queue": { + "last_validated_date": "2024-06-17T11:49:58+00:00" + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationEventbridge::test_invoke_lambda_eventbridge": { + "last_validated_date": "2024-11-19T08:54:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_assess_lambda_destination_invocation[payload0]": { + "last_validated_date": "2024-03-21T12:26:43+00:00" + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_assess_lambda_destination_invocation[payload1]": { + "last_validated_date": "2024-03-21T12:26:48+00:00" + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_lambda_destination_default_retries": { + "last_validated_date": "2024-03-21T12:24:09+00:00" + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_maxeventage": { + "last_validated_date": "2023-03-22T17:52:05+00:00" + }, + "tests/aws/services/lambda_/test_lambda_destinations.py::TestLambdaDestinationSqs::test_retries": { + "last_validated_date": "2023-03-22T17:50:18+00:00" + } +} diff --git a/tests/aws/services/lambda_/test_lambda_developer_tools.py b/tests/aws/services/lambda_/test_lambda_developer_tools.py new file mode 100644 index 0000000000000..cd637ef2c26e6 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_developer_tools.py @@ -0,0 +1,283 @@ +import json +import os +import time + +import pytest + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.utils.container_networking import get_main_container_network +from localstack.utils.docker_utils import DOCKER_CLIENT, get_host_path_for_path_in_docker +from localstack.utils.files import load_file, mkdir, rm_rf +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from localstack.utils.testutil import create_lambda_archive +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_ENV, THIS_FOLDER + +HOT_RELOADING_NODEJS_HANDLER = os.path.join( + THIS_FOLDER, "functions/hot-reloading/nodejs/handler.mjs" +) +HOT_RELOADING_PYTHON_HANDLER = os.path.join( + THIS_FOLDER, "functions/hot-reloading/python/handler.py" +) +LAMBDA_NETWORKS_PYTHON_HANDLER = os.path.join(THIS_FOLDER, "functions/lambda_networks.py") + + +class TestHotReloading: + @pytest.mark.parametrize( + "runtime,handler_file,handler_filename", + [ + (Runtime.nodejs20_x, HOT_RELOADING_NODEJS_HANDLER, "handler.mjs"), + (Runtime.python3_12, HOT_RELOADING_PYTHON_HANDLER, "handler.py"), + ], + ids=["nodejs20.x", "python3.12"], + ) + @markers.aws.only_localstack + def test_hot_reloading( + self, + create_lambda_function_aws, + runtime, + handler_file, + handler_filename, + lambda_su_role, + cleanups, + aws_client, + ): + """Test hot reloading of lambda code""" + # Hot reloading is debounced with 500ms + # 0.6 works on Linux, but it takes slightly longer on macOS + sleep_time = 0.8 + function_name = f"test-hot-reloading-{short_uid()}" + hot_reloading_bucket = config.BUCKET_MARKER_LOCAL + tmp_path = config.dirs.mounted_tmp + hot_reloading_dir_path = os.path.join(tmp_path, f"hot-reload-{short_uid()}") + mkdir(hot_reloading_dir_path) + cleanups.append(lambda: rm_rf(hot_reloading_dir_path)) + function_content = load_file(handler_file) + with open(os.path.join(hot_reloading_dir_path, handler_filename), mode="wt") as f: + f.write(function_content) + + mount_path = get_host_path_for_path_in_docker(hot_reloading_dir_path) + create_lambda_function_aws( + FunctionName=function_name, + Handler="handler.handler", + Code={"S3Bucket": hot_reloading_bucket, "S3Key": mount_path}, + Role=lambda_su_role, + Runtime=runtime, + ) + response = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + response_dict = json.load(response["Payload"]) + assert response_dict["counter"] == 1 + assert response_dict["constant"] == "value1" + response = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + response_dict = json.load(response["Payload"]) + assert response_dict["counter"] == 2 + assert response_dict["constant"] == "value1" + with open(os.path.join(hot_reloading_dir_path, handler_filename), mode="wt") as f: + f.write(function_content.replace("value1", "value2")) + # we have to sleep here, since the hot reloading is debounced with 500ms + time.sleep(sleep_time) + response = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + response_dict = json.load(response["Payload"]) + assert response_dict["counter"] == 1 + assert response_dict["constant"] == "value2" + response = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + response_dict = json.load(response["Payload"]) + assert response_dict["counter"] == 2 + assert response_dict["constant"] == "value2" + + # test subdirs + test_folder = os.path.join(hot_reloading_dir_path, "test-folder") + mkdir(test_folder) + # make sure the creation of the folder triggered reload + time.sleep(sleep_time) + response = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + response_dict = json.load(response["Payload"]) + assert response_dict["counter"] == 1 + assert response_dict["constant"] == "value2" + # now writing something in the new folder to check if it will reload + with open(os.path.join(test_folder, "test-file"), mode="wt") as f: + f.write("test-content") + time.sleep(sleep_time) + response = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + response_dict = json.load(response["Payload"]) + assert response_dict["counter"] == 1 + assert response_dict["constant"] == "value2" + + @markers.aws.only_localstack + def test_hot_reloading_publish_version( + self, create_lambda_function_aws, lambda_su_role, cleanups, aws_client + ): + """ + Test if publish version code sha256s are ignored when using hot-reload (cannot be matched anyways) + Serverless, for example, will hash the code before publishing on the client side, which can brick the publish + version operation + """ + + function_name = f"test-hot-reloading-{short_uid()}" + hot_reloading_bucket = config.BUCKET_MARKER_LOCAL + tmp_path = config.dirs.mounted_tmp + hot_reloading_dir_path = os.path.join(tmp_path, f"hot-reload-{short_uid()}") + mkdir(hot_reloading_dir_path) + cleanups.append(lambda: rm_rf(hot_reloading_dir_path)) + function_content = load_file(HOT_RELOADING_NODEJS_HANDLER) + with open(os.path.join(hot_reloading_dir_path, "handler.mjs"), mode="wt") as f: + f.write(function_content) + + mount_path = get_host_path_for_path_in_docker(hot_reloading_dir_path) + create_lambda_function_aws( + FunctionName=function_name, + Handler="handler.handler", + Code={"S3Bucket": hot_reloading_bucket, "S3Key": mount_path}, + Role=lambda_su_role, + Runtime=Runtime.nodejs20_x, + ) + aws_client.lambda_.publish_version(FunctionName=function_name, CodeSha256="zipfilehash") + + @markers.aws.only_localstack + def test_hot_reloading_error_path_not_absolute( + self, + create_lambda_function_aws, + lambda_su_role, + cleanups, + aws_client, + ): + """Tests validation of hot reloading paths""" + function_name = f"test-hot-reloading-{short_uid()}" + hot_reloading_bucket = config.BUCKET_MARKER_LOCAL + with pytest.raises(Exception): + aws_client.lambda_.create_function( + FunctionName=function_name, + Handler="handler.handler", + Code={"S3Bucket": hot_reloading_bucket, "S3Key": "not/an/absolute/path"}, + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + + @markers.aws.only_localstack + def test_hot_reloading_environment_placeholder( + self, create_lambda_function_aws, lambda_su_role, cleanups, aws_client, monkeypatch + ): + """Test hot reloading of lambda code when the S3Key containers an environment variable placeholder like $DIR""" + function_name = f"test-hot-reloading-{short_uid()}" + hot_reloading_bucket = config.BUCKET_MARKER_LOCAL + tmp_path = config.dirs.mounted_tmp + hot_reloading_dir_path = os.path.join(tmp_path, f"hot-reload-{short_uid()}") + mkdir(hot_reloading_dir_path) + cleanups.append(lambda: rm_rf(hot_reloading_dir_path)) + function_content = load_file(HOT_RELOADING_PYTHON_HANDLER) + with open(os.path.join(hot_reloading_dir_path, "handler.py"), mode="wt") as f: + f.write(function_content) + + mount_path = get_host_path_for_path_in_docker(hot_reloading_dir_path) + head, tail = os.path.split(mount_path) + monkeypatch.setenv("HEAD_DIR", head) + + create_lambda_function_aws( + FunctionName=function_name, + Handler="handler.handler", + Code={"S3Bucket": hot_reloading_bucket, "S3Key": f"$HEAD_DIR/{tail}"}, + Role=lambda_su_role, + Runtime=Runtime.python3_12, + ) + response = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + response_dict = json.load(response["Payload"]) + assert response_dict["counter"] == 1 + assert response_dict["constant"] == "value1" + + +class TestDockerFlags: + @markers.aws.only_localstack + def test_additional_docker_flags(self, create_lambda_function, monkeypatch, aws_client): + env_value = short_uid() + monkeypatch.setattr(config, "LAMBDA_DOCKER_FLAGS", f"-e Hello={env_value}") + function_name = f"test-flags-{short_uid()}" + + create_lambda_function( + handler_file=TEST_LAMBDA_ENV, + func_name=function_name, + runtime=Runtime.python3_12, + ) + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + result = aws_client.lambda_.invoke(FunctionName=function_name, Payload="{}") + result_data = json.load(result["Payload"]) + assert {"Hello": env_value} == result_data + + @markers.aws.only_localstack + def test_lambda_docker_networks(self, lambda_su_role, monkeypatch, aws_client, cleanups): + function_name = f"test-network-{short_uid()}" + container_name = f"server-{short_uid()}" + additional_network = f"test-network-{short_uid()}" + + # networking setup + main_network = get_main_container_network() + DOCKER_CLIENT.create_network(additional_network) + + def _delete_network(): + retry(lambda: DOCKER_CLIENT.delete_network(additional_network)) + + cleanups.append(_delete_network) + DOCKER_CLIENT.run_container( + image_name="nginx", + remove=True, + detach=True, + name=container_name, + network=additional_network, + ) + cleanups.append(lambda: DOCKER_CLIENT.stop_container(container_name=container_name)) + monkeypatch.setattr(config, "LAMBDA_DOCKER_NETWORK", f"{main_network},{additional_network}") + + # we need to create a lambda manually here for the right cleanup order + # (we need to destroy the function before the network, not the other way around. This is only guaranteed + # with the cleanups fixture) + zip_file = create_lambda_archive( + load_file(LAMBDA_NETWORKS_PYTHON_HANDLER), + get_content=True, + runtime=Runtime.python3_12, + ) + aws_client.lambda_.create_function( + FunctionName=function_name, + Code={"ZipFile": zip_file}, + Handler="handler.handler", + Runtime=Runtime.python3_12, + Role=lambda_su_role, + ) + cleanups.append(lambda: aws_client.lambda_.delete_function(FunctionName=function_name)) + + aws_client.lambda_.get_waiter("function_active_v2").wait(FunctionName=function_name) + result = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=json.dumps({"url": f"http://{container_name}"}) + ) + result_data = json.load(result["Payload"]) + assert result_data["status"] == 200 + assert "nginx" in result_data["response"] + + +class TestLambdaDNS: + @markers.aws.only_localstack + @pytest.mark.skipif( + not config.use_custom_dns(), reason="Test invalid if DNS server is disabled" + ) + def test_lambda_localhost_localstack_cloud_connectivity( + self, create_lambda_function, aws_client + ): + function_name = f"test-network-{short_uid()}" + create_lambda_function( + handler_file=LAMBDA_NETWORKS_PYTHON_HANDLER, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + result = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=json.dumps( + { + "url": f"http://localhost.localstack.cloud:{config.GATEWAY_LISTEN[0].port}/_localstack/health" + } + ), + ) + assert "FunctionError" not in result + result_data = json.load(result["Payload"]) + assert result_data["status"] == 200 + assert "services" in result_data["response"] diff --git a/tests/aws/services/lambda_/test_lambda_integration_xray.py b/tests/aws/services/lambda_/test_lambda_integration_xray.py new file mode 100644 index 0000000000000..726b7c74d9728 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_integration_xray.py @@ -0,0 +1,75 @@ +import json +import os +import time + +import pytest + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.xray.trace_header import TraceHeader + +TEST_LAMBDA_XRAY_TRACEID = os.path.join( + os.path.dirname(__file__), "functions/xray_tracing_traceid.py" +) + + +@pytest.mark.parametrize("tracing_mode", ["Active", "PassThrough"]) +@markers.aws.validated +def test_traceid_outside_handler(create_lambda_function, lambda_su_role, tracing_mode, aws_client): + fn_name = f"test-xray-traceid-fn-{short_uid()}" + + create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_XRAY_TRACEID, + runtime=Runtime.python3_12, + role=lambda_su_role, + TracingConfig={"Mode": tracing_mode}, + ) + + invoke_result_1 = aws_client.lambda_.invoke(FunctionName=fn_name) + parsed_result_1 = json.load(invoke_result_1["Payload"]) + time.sleep(1) # to guarantee sampling on AWS + invoke_result_2 = aws_client.lambda_.invoke(FunctionName=fn_name) + parsed_result_2 = json.load(invoke_result_2["Payload"]) + + assert parsed_result_1["trace_id_outside_handler"] == "None" + assert parsed_result_2["trace_id_outside_handler"] == "None" + assert parsed_result_1["trace_id_inside_handler"] != parsed_result_2["trace_id_inside_handler"] + + +@markers.aws.validated +def test_xray_trace_propagation( + create_lambda_function, lambda_su_role, snapshot, aws_client, cleanups +): + """Test trace header parsing and propagation from an incoming Lambda invoke request into a Lambda invocation. + This test should work independently of the TracingConfig: PassThrough (default) vs. Active + https://stackoverflow.com/questions/50077890/aws-sam-x-ray-tracing-active-vs-passthrough + """ + fn_name = f"test-xray-trace-propagation-fn-{short_uid()}" + + create_lambda_function( + func_name=fn_name, + handler_file=TEST_LAMBDA_XRAY_TRACEID, + runtime=Runtime.python3_12, + ) + + # add boto hook + root_trace_id = "1-3152b799-8954dae64eda91bc9a23a7e8" + xray_trace_header = TraceHeader(root=root_trace_id, parent="7fa8c0f79203be72", sampled=1) + + def add_xray_header(request, **kwargs): + request.headers["X-Amzn-Trace-Id"] = xray_trace_header.to_header_str() + + event_name = "before-send.lambda.*" + aws_client.lambda_.meta.events.register(event_name, add_xray_header) + # make sure the hook gets cleaned up after the test + cleanups.append(lambda: aws_client.lambda_.meta.events.unregister(event_name, add_xray_header)) + + result = aws_client.lambda_.invoke(FunctionName=fn_name) + payload = json.load(result["Payload"]) + actual_root_trace_id = TraceHeader.from_header_str(payload["trace_id_inside_handler"]).root + assert actual_root_trace_id == root_trace_id + + # TODO: lineage field missing in LocalStack and xray trace header transformers needed for snapshotting + # snapshot.match("trace-header", payload["envs"]["_X_AMZN_TRACE_ID"]) diff --git a/tests/aws/services/lambda_/test_lambda_performance.py b/tests/aws/services/lambda_/test_lambda_performance.py new file mode 100644 index 0000000000000..6cbdeba4ce95a --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_performance.py @@ -0,0 +1,718 @@ +""" +Basic opt-in performance tests for Lambda. Usage: +1) Set TEST_PERFORMANCE=1 +2) Set TEST_PERFORMANCE_RESULTS_DIR=$HOME/Downloads if you want to export performance results as CSV +3) Adjust repeat=100 to configure the number of repetitions +""" + +import csv +import json +import logging +import math +import os +import pathlib +import statistics +import threading +import time +import timeit +import uuid +from datetime import datetime, timedelta + +import pytest +from botocore.config import Config + +from localstack import config +from localstack.aws.api.lambda_ import InvocationType, Runtime +from localstack.config import is_env_true +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid, to_bytes +from localstack.utils.sync import poll_condition, retry +from tests.aws.services.lambda_.test_lambda import ( + TEST_LAMBDA_PYTHON_ECHO, + TEST_LAMBDA_PYTHON_S3_INTEGRATION, +) +from tests.aws.services.lambda_.utils import get_s3_keys + +# These performance tests are opt-in because we currently do not track performance systematically. +if not is_env_true("TEST_PERFORMANCE"): + pytest.skip("Skip slow and resource-intensive tests", allow_module_level=True) + + +LOG = logging.getLogger(__name__) + + +# Custom botocore configuration suitable for performance testing. +# Using the aws_client_factory can +CLIENT_CONFIG = Config( + # using shorter timeouts can help to detect issues earlier but longer timeouts give some room for high load + connect_timeout=60, + read_timeout=60, + # retries might be necessary under high load, but could increase load through excessive retries + retries={"max_attempts": 2}, + # 10 is the default but sometimes reducing it to 1 can help detecting issues. However, this should never be an issue + # because it only relates to the number of cached connections. + max_pool_connections=3000, +) + + +@markers.aws.validated +def test_invoke_warm_start(create_lambda_function, aws_client): + function_name = f"test-lambda-perf-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + def invoke(): + aws_client.lambda_.invoke(FunctionName=function_name) + + # Ignore initial cold start + invoke() + + # Test warm starts + repeat = 100 + timings = timeit.repeat(invoke, number=1, repeat=repeat) + LOG.info(f" EXECUTION TIME (s) for {repeat} repetitions ".center(80, "=")) + LOG.info(format_summary(timings)) + export_csv(timings, "test_invoke_warm_start") + + +@markers.aws.only_localstack +def test_invoke_cold_start(create_lambda_function, aws_client, monkeypatch): + monkeypatch.setattr(config, "LAMBDA_KEEPALIVE_MS", 0) + function_name = f"test-lambda-perf-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=function_name, + runtime=Runtime.python3_12, + ) + + def invoke(): + aws_client.lambda_.invoke(FunctionName=function_name) + + # Ignore the initial cold start, which could be even slower due to init downloading + invoke() + + # Test cold starts caused by the option LAMBDA_KEEPALIVE_MS=0 + repeat = 100 + # Optionally sleep in between repetitions to avoid delays caused by the previous container shutting down + sleep_s = 4 + timings = timeit.repeat( + invoke, number=1, repeat=repeat, setup=f"import time; time.sleep({sleep_s})" + ) + LOG.info(f" EXECUTION TIME (s) for {repeat} repetitions ".center(80, "=")) + LOG.info(format_summary(timings)) + export_csv(timings, "test_invoke_cold_start") + + +class ThreadSafeCounter: + def __init__(self): + self.lock = threading.Lock() + self.counter = 0 + + def increment(self): + with self.lock: + self.counter += 1 + + +@markers.aws.validated +def test_number_of_function_versions_sync(create_lambda_function, s3_bucket, aws_client): + """Test how many function versions LocalStack can support; validating **synchronous** invokes.""" + num_function_versions = 2 if is_aws_cloud() else 100 + + function_name = f"test-lambda-perf-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION, + func_name=function_name, + runtime=Runtime.python3_12, + Environment={"Variables": {"S3_BUCKET_NAME": s3_bucket}}, + ) + + # Publish function versions + versions = ["$LATEST"] + for i in range(num_function_versions): + # Publishing a new function version requires updating the function configuration or code + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description=str(i + 1) + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + publish_version_response = aws_client.lambda_.publish_version(FunctionName=function_name) + versions.append(publish_version_response["Version"]) + + # Invoke each function version once + for version in versions: + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType=InvocationType.RequestResponse, + Qualifier=version, + ) + assert "FunctionError" not in invoke_response + assert invoke_response["ExecutedVersion"] == version + payload = json.load(invoke_response["Payload"]) + assert payload["function_version"] == version + request_id = invoke_response["ResponseMetadata"]["RequestId"] + assert payload["s3_key"] == request_id + + +@markers.aws.validated +def test_number_of_function_versions_async(create_lambda_function, s3_bucket, aws_client): + """Test how many function versions LocalStack can support; validating **asynchronous** invokes.""" + num_function_versions = 2 if is_aws_cloud() else 100 + # Timeout for waiting until all async invokes are completed depends on num_function_version and machine + timeout_seconds = 5 * 60 + + function_name = f"test-lambda-perf-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION, + func_name=function_name, + runtime=Runtime.python3_12, + Environment={"Variables": {"S3_BUCKET_NAME": s3_bucket}}, + ) + + # Publish function versions + versions = ["$LATEST"] + for i in range(num_function_versions): + # Publishing a new function version requires updating the function configuration or code + aws_client.lambda_.update_function_configuration( + FunctionName=function_name, Description=str(i + 1) + ) + aws_client.lambda_.get_waiter("function_updated_v2").wait(FunctionName=function_name) + publish_version_response = aws_client.lambda_.publish_version(FunctionName=function_name) + versions.append(publish_version_response["Version"]) + + # Invoke each function version once + request_ids = [] + for version in versions: + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType=InvocationType.Event, + Qualifier=version, + ) + assert "FunctionError" not in invoke_response + request_id = invoke_response["ResponseMetadata"]["RequestId"] + request_ids.append(request_id) + + # Wait until all event invokes are completed + def assert_s3_objects(): + s3_keys_output = get_s3_keys(aws_client, s3_bucket) + return len(s3_keys_output) == len(versions) + + assert poll_condition(assert_s3_objects, timeout=timeout_seconds, interval=5) + + s3_request_ids = get_s3_keys(aws_client, s3_bucket) + assert set(s3_request_ids) == set(request_ids) + + +@markers.aws.validated +def test_number_of_functions_sync( + create_lambda_function, s3_bucket, aws_client, aws_client_factory +): + """Test how many active functions LocalStack can support; validating **synchronous** invokes.""" + # TODO: investigate why ~56/150 Lambda containers don't shut down in host mode (N=150 => 5min) + num_functions = 2 if is_aws_cloud() else 150 + + function_names = [] + uuid = short_uid() + for num in range(num_functions): + function_name = f"test-lambda-perf-{uuid}-{num}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION, + func_name=function_name, + runtime=Runtime.python3_12, + Environment={"Variables": {"S3_BUCKET_NAME": s3_bucket}}, + ) + function_names.append(function_name) + + # Invoke each function once + for function_name in function_names: + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType=InvocationType.RequestResponse, + ) + assert "FunctionError" not in invoke_response + + +@markers.aws.validated +def test_number_of_functions_async( + create_lambda_function, s3_bucket, aws_client, aws_client_factory +): + """Test how many active functions LocalStack can support; validating **asynchronous** invokes.""" + # TODO: investigate why ~7/150 Lambda containers don't shut down in host mode (N=150 => 5min) + num_functions = 2 if is_aws_cloud() else 150 + # Timeout for waiting until all async invokes are completed depends on num_functions and machine + timeout_seconds = 5 * 60 + + function_names = [] + uuid = short_uid() + for num in range(num_functions): + function_name = f"test-lambda-perf-{uuid}-{num}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION, + func_name=function_name, + runtime=Runtime.python3_12, + Environment={"Variables": {"S3_BUCKET_NAME": s3_bucket}}, + ) + function_names.append(function_name) + + # Invoke each function once + request_ids = [] + for function_name in function_names: + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, + InvocationType=InvocationType.Event, + ) + assert "FunctionError" not in invoke_response + request_id = invoke_response["ResponseMetadata"]["RequestId"] + request_ids.append(request_id) + + # Wait until all event invokes are completed + def assert_s3_objects(): + s3_keys_output = get_s3_keys(aws_client, s3_bucket) + return len(s3_keys_output) == len(request_ids) + + assert poll_condition(assert_s3_objects, timeout=timeout_seconds, interval=5) + + s3_request_ids = get_s3_keys(aws_client, s3_bucket) + assert set(s3_request_ids) == set(request_ids) + + +@markers.aws.validated +def test_lambda_event_invoke(create_lambda_function, s3_bucket, aws_client, aws_client_factory): + """Test concurrent Lambda event invokes and validate the number of Lambda invocations using CloudWatch and S3.""" + num_invocations = 800 + + function_name = f"test-lambda-perf-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION, + func_name=function_name, + runtime=Runtime.python3_12, + Environment={"Variables": {"S3_BUCKET_NAME": s3_bucket}}, + ) + + # Limit concurrency to avoid resource bottlenecks. This is typically not required because the ThreadPoolExecutor + # in the pollers are limited to 32 threads. The actual concurrency depends on the number of available CPU cores. + # aws_client.lambda_.put_function_concurrency( + # FunctionName=function_name, ReservedConcurrentExecutions=1 + # ) + + lock = threading.Lock() + request_ids = [] + error_counter = ThreadSafeCounter() + invoke_barrier = threading.Barrier(num_invocations) + + def invoke(runner: int): + nonlocal request_ids + nonlocal error_counter + nonlocal invoke_barrier + try: + payload = {"file_size_bytes": 1} + lambda_client = aws_client_factory(config=CLIENT_CONFIG).lambda_ + # Wait until all threads are ready to invoke simultaneously + invoke_barrier.wait() + result = lambda_client.invoke( + FunctionName=function_name, + InvocationType=InvocationType.Event, + Payload=to_bytes(json.dumps(payload)), + ) + request_id = result["ResponseMetadata"]["RequestId"] + with lock: + request_ids.append(request_id) + except Exception as e: + LOG.error("runner-%s failed: %s", runner, e) + error_counter.increment() + + start_time = datetime.utcnow() + # Use threads to invoke Lambda function in parallel + thread_list = [] + for i in range(1, num_invocations + 1): + thread = threading.Thread(target=invoke, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + end_time = datetime.utcnow() + diff = end_time - start_time + LOG.info("N=%s took %s seconds", num_invocations, diff.total_seconds()) + assert error_counter.counter == 0 + + # Sleeping here is a bit hacky, but we want to avoid polling for now because polling affects the results. + sleep_seconds = 2000 + LOG.info("Sleeping for %s ...", sleep_seconds) + time.sleep(sleep_seconds) + + # Validate CloudWatch invocation metric + def assert_cloudwatch_metric(): + metric_query_params = { + "Namespace": "AWS/Lambda", + "MetricName": "Invocations", + "Dimensions": [{"Name": "FunctionName", "Value": function_name}], + "StartTime": start_time, + # CloudWatch Lambda metrics can be delayed is a known issue: + # https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Lambda-Insights-Troubleshooting.html + "EndTime": end_time + timedelta(minutes=20), + "Period": 3600, # in seconds + "Statistics": ["Sum"], + } + response = aws_client.cloudwatch.get_metric_statistics(**metric_query_params) + num_invocations_metric = 0 + for datapoint in response["Datapoints"]: + num_invocations_metric += int(datapoint["Sum"]) + # assert num_invocations_metric == num_invocations + return num_invocations_metric + + metric_count = assert_cloudwatch_metric() + # metric_count = retry(assert_cloudwatch_metric, retries=300, sleep=10) + + # Validate CloudWatch invocation logs + def assert_log_events(): + # Using a paginator because the default and maximum limit is 10k events and + # tests against AWS were missing invocations because they contained a `nextToken` + paginator = aws_client.logs.get_paginator("filter_log_events") + page_iterator = paginator.paginate( + logGroupName=f"/aws/lambda/{function_name}", + ) + invocation_count = 0 + for page in page_iterator: + log_events = page["events"] + invocation_count += len( + [event["message"] for event in log_events if event["message"].startswith("REPORT")] + ) + # assert invocation_count == num_invocations + return invocation_count + + log_count = assert_log_events() + # NOTE: slow against AWS (can take minutes and would likely require more retries) + # log_count = retry(assert_log_events, retries=300, sleep=2) + + # Validate S3 object creation + def assert_s3_objects(): + s3_keys_output = get_s3_keys(aws_client, s3_bucket) + # assert len(s3_keys_output) == num_invocations + return len(s3_keys_output) + + s3_count = assert_s3_objects() + # s3_count = retry(assert_s3_objects, retries=300, sleep=2) + + # TODO: the CloudWatch metrics can be unreliable due to concurrency issues (new CW provider is WIP) + # The s3_count does not consider re-tries, which have the same request_ids! + assert [metric_count, log_count, s3_count] == [ + num_invocations, + num_invocations, + num_invocations, + ] + + +@markers.aws.unknown +def test_lambda_event_source_mapping_sqs( + create_lambda_function, + s3_bucket, + sqs_create_queue, + sqs_get_queue_arn, + aws_client, + aws_client_factory, +): + """Test SQS => Lambda event source mapping with concurrent event invokes and validate the number of invocations.""" + # TODO: define IAM permissions + num_invocations = 2000 + batch_size = 1 + # This calculation might not be 100% accurate if the batch window is short, but it works for now + target_invocations = math.ceil(num_invocations / batch_size) + + function_name = f"test-lambda-perf-{short_uid()}" + create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION, + func_name=function_name, + runtime=Runtime.python3_12, + Environment={"Variables": {"S3_BUCKET_NAME": s3_bucket}}, + ) + + queue_name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + aws_client.lambda_.create_event_source_mapping( + EventSourceArn=queue_arn, + FunctionName=function_name, + BatchSize=batch_size, + ) + + lock = threading.Lock() + request_ids = [] + error_counter = ThreadSafeCounter() + invoke_barrier = threading.Barrier(num_invocations) + + def invoke(runner: int): + nonlocal request_ids + nonlocal error_counter + nonlocal invoke_barrier + try: + sqs_client = aws_client_factory(config=CLIENT_CONFIG).sqs + invoke_barrier.wait() + result = sqs_client.send_message( + QueueUrl=queue_url, MessageBody=json.dumps({"message": str(uuid.uuid4())}) + ) + # SQS request_id does not match the Lambda request id because batching can apply + request_id = result["ResponseMetadata"]["RequestId"] + with lock: + request_ids.append(request_id) + except Exception as e: + LOG.error("runner-%s failed: %s", runner, e) + error_counter.increment() + + start_time = datetime.utcnow() + # Use threads to invoke Lambda function in parallel + thread_list = [] + for i in range(1, num_invocations + 1): + thread = threading.Thread(target=invoke, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + end_time = datetime.utcnow() + diff = end_time - start_time + LOG.info("N=%s took %s seconds", num_invocations, diff.total_seconds()) + assert error_counter.counter == 0 + + # Sleeping here is a bit hacky, but we want to avoid polling for now because polling affects the results. + sleep_seconds = 400 + LOG.info("Sleeping for %s ...", sleep_seconds) + time.sleep(sleep_seconds) + + # Validate CloudWatch invocation metric + def assert_cloudwatch_metric(): + metric_query_params = { + "Namespace": "AWS/Lambda", + "MetricName": "Invocations", + "Dimensions": [{"Name": "FunctionName", "Value": function_name}], + "StartTime": start_time, + # CloudWatch Lambda metrics can be delayed is a known issue: + # https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Lambda-Insights-Troubleshooting.html + "EndTime": end_time + timedelta(minutes=20), + "Period": 3600, # in seconds + "Statistics": ["Sum"], + } + response = aws_client.cloudwatch.get_metric_statistics(**metric_query_params) + num_invocations_metric = 0 + for datapoint in response["Datapoints"]: + num_invocations_metric += int(datapoint["Sum"]) + # assert num_invocations_metric == num_invocations + return num_invocations_metric + + metric_count = assert_cloudwatch_metric() + # metric_count = retry(assert_cloudwatch_metric, retries=300, sleep=10) + + # Validate CloudWatch invocation logs + def assert_log_events(): + # Using a paginator because the default and maximum limit is 10k events and + # tests against AWS were missing invocations because they contained a `nextToken` + paginator = aws_client.logs.get_paginator("filter_log_events") + page_iterator = paginator.paginate( + logGroupName=f"/aws/lambda/{function_name}", + ) + invocation_count = 0 + for page in page_iterator: + log_events = page["events"] + invocation_count += len( + [event["message"] for event in log_events if event["message"].startswith("REPORT")] + ) + # assert invocation_count == num_invocations + return invocation_count + + log_count = assert_log_events() + # NOTE: slow against AWS (can take minutes and would likely require more retries) + # log_count = retry(assert_log_events, retries=300, sleep=2) + + # Validate S3 object creation + def assert_s3_objects(): + s3_keys_output = get_s3_keys(aws_client, s3_bucket) + # assert len(s3_keys_output) == num_invocations + return len(s3_keys_output) + + s3_count = assert_s3_objects() + # s3_count = retry(assert_s3_objects, retries=300, sleep=2) + + # TODO: fix unreliable event source mapping (e.g., [168, 168, 169] with N=200) + assert [metric_count, log_count, s3_count] == [ + target_invocations, + target_invocations, + target_invocations, + ] + + +@markers.aws.unknown +def test_sns_subscription_lambda( + create_lambda_function, + s3_bucket, + sns_create_topic, + sns_subscription, + aws_client, + aws_client_factory, +): + """Test SNS => Lambda subscription with concurrent event invokes and validate the number of invocations.""" + # TODO: define IAM permissions + num_invocations = 800 + + function_name = f"test-lambda-perf-{short_uid()}" + lambda_creation_response = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_S3_INTEGRATION, + func_name=function_name, + runtime=Runtime.python3_12, + Environment={"Variables": {"S3_BUCKET_NAME": s3_bucket}}, + ) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + + topic_name = f"test-sns-{short_uid()}" + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=f"test-statement-{short_uid()}", + Action="lambda:InvokeFunction", + Principal="sns.amazonaws.com", + SourceArn=topic_arn, + ) + + subscription = sns_subscription( + TopicArn=topic_arn, + Protocol="lambda", + Endpoint=lambda_arn, + ) + + def check_subscription(): + subscription_arn = subscription["SubscriptionArn"] + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert subscription_attrs["Attributes"]["PendingConfirmation"] == "false" + + retry(check_subscription, retries=4, sleep=0.5) + + lock = threading.Lock() + request_ids = [] + error_counter = ThreadSafeCounter() + invoke_barrier = threading.Barrier(num_invocations) + + def invoke(runner: int): + nonlocal request_ids + nonlocal error_counter + nonlocal invoke_barrier + try: + sns_client = aws_client_factory(config=CLIENT_CONFIG).sns + invoke_barrier.wait() + result = sns_client.publish( + TopicArn=topic_arn, Subject="test-subject", Message=str(uuid.uuid4()) + ) + # TODO: validate against AWS whether the SNS request_id gets propagated into Lambda?! + request_id = result["ResponseMetadata"]["RequestId"] + with lock: + request_ids.append(request_id) + except Exception as e: + LOG.error("runner-%s failed: %s", runner, e) + error_counter.increment() + + start_time = datetime.utcnow() + # Use threads to invoke Lambda function in parallel + thread_list = [] + for i in range(1, num_invocations + 1): + thread = threading.Thread(target=invoke, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + end_time = datetime.utcnow() + diff = end_time - start_time + LOG.info("N=%s took %s seconds", num_invocations, diff.total_seconds()) + assert error_counter.counter == 0 + + sleep_seconds = 600 + LOG.info("Sleeping for %s ...", sleep_seconds) + time.sleep(sleep_seconds) + + # Validate CloudWatch invocation metric + def assert_cloudwatch_metric(): + metric_query_params = { + "Namespace": "AWS/Lambda", + "MetricName": "Invocations", + "Dimensions": [{"Name": "FunctionName", "Value": function_name}], + "StartTime": start_time, + # CloudWatch Lambda metrics can be delayed is a known issue: + # https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Lambda-Insights-Troubleshooting.html + "EndTime": end_time + timedelta(minutes=20), + "Period": 3600, # in seconds + "Statistics": ["Sum"], + } + response = aws_client.cloudwatch.get_metric_statistics(**metric_query_params) + num_invocations_metric = 0 + for datapoint in response["Datapoints"]: + num_invocations_metric += int(datapoint["Sum"]) + # assert num_invocations_metric == num_invocations + return num_invocations_metric + + metric_count = assert_cloudwatch_metric() + # metric_count = retry(assert_cloudwatch_metric, retries=300, sleep=10) + + # Validate CloudWatch invocation logs + def assert_log_events(): + # Using a paginator because the default and maximum limit is 10k events and + # tests against AWS were missing invocations because they contained a `nextToken` + paginator = aws_client.logs.get_paginator("filter_log_events") + page_iterator = paginator.paginate( + logGroupName=f"/aws/lambda/{function_name}", + ) + invocation_count = 0 + for page in page_iterator: + log_events = page["events"] + invocation_count += len( + [event["message"] for event in log_events if event["message"].startswith("REPORT")] + ) + # assert invocation_count == num_invocations + return invocation_count + + log_count = assert_log_events() + # NOTE: slow against AWS (can take minutes and would likely require more retries) + # log_count = retry(assert_log_events, retries=300, sleep=2) + + # Validate S3 object creation first because it works synchronously and is most reliable + def assert_s3_objects(): + s3_keys_output = get_s3_keys(aws_client, s3_bucket) + # assert len(s3_keys_output) == num_invocations + return len(s3_keys_output) + + s3_count = assert_s3_objects() + # s3_count = retry(assert_s3_objects, retries=300, sleep=2) + + assert [metric_count, log_count, s3_count] == [ + num_invocations, + num_invocations, + num_invocations, + ] + + +def format_summary(timings: [float]) -> str: + """Format summary statistics in seconds.""" + p99 = ( + statistics.quantiles(timings, n=100, method="inclusive")[98] if len(timings) > 1 else "N/A" + ) + stats = [ + f"{min(timings)} (min)", + f"{statistics.median(timings)} (median)", + f"""{p99} (p99)""", + f"{max(timings)} (max)", + ] + return ", ".join(stats) + + +def export_csv(timings: [float], label: str = "") -> None: + """Export the given timings into a csv file if the environment variable TEST_PERFORMANCE_RESULTS_DIR is set.""" + if results_dir := os.environ.get("TEST_PERFORMANCE_RESULTS_DIR"): + timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + file_name = f"{timestamp}_{label}.csv" + file_path = pathlib.Path(results_dir) / file_name + file = open(file_path, "w") + data = [[value] for value in timings] + with file: + write = csv.writer(file) + write.writerows(data) diff --git a/tests/aws/services/lambda_/test_lambda_performance.validation.json b/tests/aws/services/lambda_/test_lambda_performance.validation.json new file mode 100644 index 0000000000000..98ef9fb3d805a --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_performance.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/lambda_/test_lambda_performance.py::test_number_of_function_versions_async": { + "last_validated_date": "2024-02-08T17:18:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda_performance.py::test_number_of_function_versions_sync": { + "last_validated_date": "2024-02-08T17:01:53+00:00" + }, + "tests/aws/services/lambda_/test_lambda_performance.py::test_number_of_functions_async": { + "last_validated_date": "2024-02-08T17:55:20+00:00" + }, + "tests/aws/services/lambda_/test_lambda_performance.py::test_number_of_functions_sync": { + "last_validated_date": "2024-02-08T17:50:10+00:00" + } +} diff --git a/tests/aws/services/lambda_/test_lambda_runtimes.py b/tests/aws/services/lambda_/test_lambda_runtimes.py new file mode 100644 index 0000000000000..6c0e82bbec038 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_runtimes.py @@ -0,0 +1,546 @@ +"""Testing different Lambda runtimes focusing on specifics of particular runtimes (e.g., Nodejs ES6 modules). + +See `test_lambda_common.py` for tests focusing on common functionality across all runtimes. +""" + +import json +import os +import shutil +import textwrap +from typing import List + +import pytest + +from localstack.aws.api.lambda_ import Runtime +from localstack.constants import LOCALSTACK_MAVEN_VERSION, MAVEN_REPO_URL +from localstack.packages import DownloadInstaller, Package, PackageInstaller +from localstack.services.lambda_.packages import lambda_java_libs_package +from localstack.testing.pytest import markers +from localstack.utils import testutil +from localstack.utils.archives import unzip +from localstack.utils.files import cp_r, load_file, mkdir, new_tmp_dir, save_file +from localstack.utils.functions import run_safe +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import retry +from localstack.utils.testutil import check_expected_lambda_log_events_length, get_lambda_log_events +from tests.aws.services.lambda_.test_lambda import ( + JAVA_TEST_RUNTIMES, + NODE_TEST_RUNTIMES, + PYTHON_TEST_RUNTIMES, + TEST_LAMBDA_CLOUDWATCH_LOGS, + TEST_LAMBDA_JAVA_MULTIPLE_HANDLERS, + TEST_LAMBDA_JAVA_WITH_LIB, + TEST_LAMBDA_NODEJS_ES6, + TEST_LAMBDA_PYTHON, + TEST_LAMBDA_PYTHON_VERSION, + THIS_FOLDER, + read_streams, +) + +# Java Test Jar Download (used for tests) +TEST_LAMBDA_JAR_URL_TEMPLATE = "{url}/cloud/localstack/{name}/{version}/{name}-{version}-tests.jar" + + +class LambdaJavaTestlibsPackage(Package): + def __init__(self): + super().__init__("JavaLambdaTestlibs", LOCALSTACK_MAVEN_VERSION) + + def get_versions(self) -> List[str]: + return [LOCALSTACK_MAVEN_VERSION] + + def _get_installer(self, version: str) -> PackageInstaller: + return LambdaJavaTestlibsPackageInstaller(version) + + +class LambdaJavaTestlibsPackageInstaller(DownloadInstaller): + def __init__(self, version): + super().__init__("lambda-java-testlibs", version) + + def _get_download_url(self) -> str: + return TEST_LAMBDA_JAR_URL_TEMPLATE.format( + version=self.version, url=MAVEN_REPO_URL, name="localstack-utils" + ) + + +# TODO: inline this outdated dependency into test build +lambda_java_testlibs_package = LambdaJavaTestlibsPackage() + +# TODO: consider using the multiruntime annotation directly?! +parametrize_python_runtimes = pytest.mark.parametrize("runtime", PYTHON_TEST_RUNTIMES) +parametrize_node_runtimes = pytest.mark.parametrize("runtime", NODE_TEST_RUNTIMES) +parametrize_java_runtimes = pytest.mark.parametrize("runtime", JAVA_TEST_RUNTIMES) + + +@pytest.fixture(autouse=True) +def add_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.lambda_api()) + snapshot.add_transformer(snapshot.transform.key_value("CodeSha256", "")) + + +class TestNodeJSRuntimes: + @markers.snapshot.skip_snapshot_verify(paths=["$..LoggingConfig"]) + @parametrize_node_runtimes + @markers.aws.validated + def test_invoke_nodejs_es6_lambda(self, create_lambda_function, snapshot, runtime, aws_client): + """Test simple nodejs lambda invocation""" + + function_name = f"test-function-{short_uid()}" + result = create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(TEST_LAMBDA_NODEJS_ES6, get_content=True), + runtime=runtime, + handler="lambda_handler_es6.handler", + ) + snapshot.match("creation-result", result) + + rs = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=json.dumps({"event_type": "test_lambda"}), + ) + assert 200 == rs["ResponseMetadata"]["HTTPStatusCode"] + rs = read_streams(rs) + snapshot.match("invocation-result", rs) + + payload = rs["Payload"] + response = json.loads(payload) + assert "response from localstack lambda" in response["body"] + + def assert_events(): + events = get_lambda_log_events(function_name, logs_client=aws_client.logs) + assert len(events) > 0 + + retry(assert_events, retries=10) + + +class TestJavaRuntimes: + @pytest.fixture(scope="class") + def java_jar(self) -> bytes: + lambda_java_testlibs_package.install() + java_file = load_file( + lambda_java_testlibs_package.get_installer().get_executable_path(), mode="rb" + ) + return java_file + + @pytest.fixture(scope="class") + def java_zip(self, tmpdir_factory, java_jar) -> bytes: + tmpdir = tmpdir_factory.mktemp("tmp-java-zip") + zip_lib_dir = os.path.join(tmpdir, "lib") + zip_jar_path = os.path.join(zip_lib_dir, "test.lambda.jar") + mkdir(zip_lib_dir) + installer = lambda_java_libs_package.get_installer() + installer.install() + java_lib_dir = installer.get_executable_path() + cp_r( + java_lib_dir, + os.path.join(zip_lib_dir, "executor.lambda.jar"), + ) + save_file(zip_jar_path, java_jar) + return testutil.create_zip_file(tmpdir, get_content=True) + + @markers.snapshot.skip_snapshot_verify(paths=["$..LoggingConfig"]) + @markers.aws.validated + def test_java_runtime_with_lib(self, create_lambda_function, snapshot, aws_client): + """Test lambda creation/invocation with different deployment package types (jar, zip, zip-with-gradle)""" + + java_jar_with_lib = load_file(TEST_LAMBDA_JAVA_WITH_LIB, mode="rb") + + # create ZIP file from JAR file + jar_dir = new_tmp_dir() + zip_dir = new_tmp_dir() + unzip(TEST_LAMBDA_JAVA_WITH_LIB, jar_dir) + zip_lib_dir = os.path.join(zip_dir, "lib") + shutil.move(os.path.join(jar_dir, "lib"), zip_lib_dir) + jar_without_libs_file = testutil.create_zip_file(jar_dir) + shutil.copy(jar_without_libs_file, os.path.join(zip_lib_dir, "lambda.jar")) + java_zip_with_lib = testutil.create_zip_file(zip_dir, get_content=True) + + java_zip_with_lib_gradle = load_file( + os.path.join( + THIS_FOLDER, + "functions/java/lambda_echo/build/distributions/lambda-function-built-by-gradle.zip", + ), + mode="rb", + ) + + for archive_desc, archive in [ + ("jar-with-lib", java_jar_with_lib), + ("zip-with-lib", java_zip_with_lib), + ("zip-with-lib-gradle", java_zip_with_lib_gradle), + ]: + lambda_name = f"test-function-{short_uid()}" + create_result = create_lambda_function( + func_name=lambda_name, + zip_file=archive, + runtime=Runtime.java11, + handler="cloud.localstack.sample.LambdaHandlerWithLib", + ) + snapshot.match(f"create-result-{archive_desc}", create_result) + + result = aws_client.lambda_.invoke(FunctionName=lambda_name, Payload=b'{"echo":"echo"}') + result = read_streams(result) + snapshot.match(f"invoke-result-{archive_desc}", result) + result_data = result["Payload"] + + assert 200 == result["StatusCode"] + assert "echo" in to_str(result_data) + + @parametrize_java_runtimes + @markers.aws.validated + def test_stream_handler(self, create_lambda_function, java_jar, runtime, snapshot, aws_client): + function_name = f"test-lambda-{short_uid()}" + create_lambda_function( + func_name=function_name, + zip_file=java_jar, + runtime=runtime, + handler="cloud.localstack.awssdkv1.sample.LambdaStreamHandler", + ) + result = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=b'{"echo":"echo"}', + ) + snapshot.match("invoke_result", result) + + @markers.snapshot.skip_snapshot_verify(paths=["$..LoggingConfig"]) + @parametrize_java_runtimes + @markers.aws.validated + def test_serializable_input_object( + self, create_lambda_function, java_zip, runtime, snapshot, aws_client + ): + # deploy lambda - Java with serializable input object + function_name = f"test-lambda-{short_uid()}" + create_result = create_lambda_function( + func_name=function_name, + zip_file=java_zip, + runtime=runtime, + handler="cloud.localstack.awssdkv1.sample.SerializedInputLambdaHandler", + ) + snapshot.match("create-result", create_result) + result = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=b'{"bucket": "test_bucket", "key": "test_key"}', + ) + result = read_streams(result) + snapshot.match("invoke-result", result) + result_data = result["Payload"] + + assert 200 == result["StatusCode"] + assert json.loads(result_data) == { + "validated": True, + "bucket": "test_bucket", + "key": "test_key", + } + + @markers.snapshot.skip_snapshot_verify(paths=["$..LoggingConfig"]) + @pytest.mark.parametrize( + "handler,expected_result", + [ + ( + "cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequestCustom", + "CUSTOM", + ), + ("cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom", "INTERFACE"), + ( + "cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequest", + "INTERFACE", + ), + ], + ) + @markers.aws.validated + # this test is only compiled against java 11 + def test_java_custom_handler_method_specification( + self, + create_lambda_function, + handler, + expected_result, + check_lambda_logs, + snapshot, + aws_client, + ): + # TODO check if we can update this test and others in this file to utilise java 21 + java_handler_multiple_handlers = load_file(TEST_LAMBDA_JAVA_MULTIPLE_HANDLERS, mode="rb") + expected = ['.*"echo": "echo".*'] + + function_name = f"lambda_handler_test_{short_uid()}" + create_result = create_lambda_function( + func_name=function_name, + zip_file=java_handler_multiple_handlers, + runtime=Runtime.java11, + handler=handler, + ) + snapshot.match("create-result", create_result) + + result = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b'{"echo":"echo"}') + result = read_streams(result) + snapshot.match("invoke-result", result) + result_data = result["Payload"] + + assert 200 == result["StatusCode"] + assert expected_result == result_data.strip('"\n ') + + def check_logs(): + check_lambda_logs(function_name, expected_lines=expected) + + retry(check_logs, retries=20) + + @markers.snapshot.skip_snapshot_verify(paths=["$..LoggingConfig"]) + @markers.aws.validated + # TODO maybe snapshot payload as well + def test_java_lambda_subscribe_sns_topic( + self, + sns_subscription, + s3_bucket, + sns_create_topic, + snapshot, + create_lambda_function, + java_zip, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("Sid")) + function_name = f"java-test-function-{short_uid()}" + topic_name = f"topic-{short_uid()}" + key = f"key-{short_uid()}" + create_lambda_function( + func_name=function_name, + zip_file=java_zip, + runtime=Runtime.java11, + handler="cloud.localstack.sample.LambdaHandler", + ) + function_result = aws_client.lambda_.get_function(FunctionName=function_name) + snapshot.match("get-function", function_result) + function_arn = function_result["Configuration"]["FunctionArn"] + permission_id = f"test-statement-{short_uid()}" + + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + + s3_sns_policy = f"""{{ + "Version": "2012-10-17", + "Id": "example-ID", + "Statement": [ + {{ + "Sid": "Example SNS topic policy", + "Effect": "Allow", + "Principal": {{ + "Service": "s3.amazonaws.com" + }}, + "Action": [ + "SNS:Publish" + ], + "Resource": "{topic_arn}", + "Condition": {{ + "ArnLike": {{ + "aws:SourceArn": "arn:aws:s3:*:*:{s3_bucket}" + }} + }} + }} + ] + }} + """ + aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, AttributeName="Policy", AttributeValue=s3_sns_policy + ) + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration={ + "TopicConfigurations": [{"TopicArn": topic_arn, "Events": ["s3:ObjectCreated:*"]}] + }, + ) + + add_permission_response = aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=permission_id, + Action="lambda:InvokeFunction", + Principal="sns.amazonaws.com", + SourceArn=topic_arn, + ) + + snapshot.match("add-permission", add_permission_response) + + sns_subscription( + TopicArn=topic_arn, + Protocol="lambda", + Endpoint=function_arn, + ) + + events_before = ( + run_safe( + get_lambda_log_events, + function_name, + regex_filter="Records", + logs_client=aws_client.logs, + ) + or [] + ) + + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="something") + + # We got an event that confirm lambda invoked + retry( + function=check_expected_lambda_log_events_length, + retries=30, + sleep=1, + expected_length=len(events_before) + 1, + function_name=function_name, + regex_filter="Records", + logs_client=aws_client.logs, + ) + + # clean up + aws_client.s3.delete_objects(Bucket=s3_bucket, Delete={"Objects": [{"Key": key}]}) + + +class TestPythonRuntimes: + @parametrize_python_runtimes + @markers.aws.validated + def test_handler_in_submodule(self, create_lambda_function, runtime, aws_client): + """Test invocation of a lambda handler which resides in a submodule (= not root module)""" + function_name = f"test-function-{short_uid()}" + zip_file = testutil.create_lambda_archive( + load_file(TEST_LAMBDA_PYTHON), + get_content=True, + runtime=runtime, + file_name="localstack_package/def/main.py", + ) + create_lambda_function( + func_name=function_name, + zip_file=zip_file, + handler="localstack_package.def.main.handler", + runtime=runtime, + ) + + # invoke function and assert result + result = aws_client.lambda_.invoke(FunctionName=function_name, Payload=b"{}") + result_data = json.load(result["Payload"]) + assert 200 == result["StatusCode"] + assert json.loads("{}") == result_data["event"] + + @parametrize_python_runtimes + @markers.aws.validated + def test_python_runtime_correct_versions(self, create_lambda_function, runtime, aws_client): + """Test different versions of python runtimes to report back the correct python version""" + function_name = f"test_python_executor_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_VERSION, + runtime=runtime, + ) + result = aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=b"{}", + ) + result = json.load(result["Payload"]) + assert result["version"] == runtime + + +class TestGoProvidedRuntimes: + """These tests are a subset of the common tests focusing on exercising Golang, which had a dedicated runtime. + + The Lambda sources are under ./common//runtime/ + The tests `test_uncaught_exception_invoke` and `test_manual_endpoint_injection` are copied from the common tests + because the common tests only test each runtime once. Multiple tests per runtime are not supported and would make + them even more complex. Usually, only a subset of the test scenarios is relevant to have extra test coverage. + For example, Go used to have a dedicated runtime and therefore, we want to test the migration path. + Calling LocalStack and uncaught exception behavior can be language-specific and deserve dedicated tests while + echo invoke is redundant (runtime is already tested and every other scenario covers this basic functionality). + """ + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: implement logging config + "$..LoggingConfig", + "$..CodeSha256", # works locally but unfortunately still produces a different hash in CI + ] + ) + @markers.aws.validated + @markers.multiruntime(scenario="uncaughtexception_extra", runtimes=["provided"]) + def test_uncaught_exception_invoke(self, multiruntime_lambda, snapshot, aws_client): + # unfortunately the stack trace is quite unreliable and changes when AWS updates the runtime transparently + # since the stack trace contains references to internal runtime code. + snapshot.add_transformer( + snapshot.transform.key_value("stackTrace", "", reference_replacement=False) + ) + create_function_result = multiruntime_lambda.create_function(MemorySize=1024) + snapshot.match("create_function_result", create_function_result) + + # simple payload + invocation_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"], + Payload=b'{"error_msg": "some_error_msg"}', + ) + assert "FunctionError" in invocation_result + snapshot.match("error_result", invocation_result) + + @markers.aws.validated + @markers.multiruntime(scenario="endpointinjection_extra", runtimes=["provided"]) + def test_manual_endpoint_injection(self, multiruntime_lambda, tmp_path, aws_client): + """Test calling SQS from Lambda using manual AWS SDK client configuration via AWS_ENDPOINT_URL. + This must work for all runtimes. + The code might differ depending on the SDK version shipped with the Lambda runtime. + This test is designed to be AWS-compatible using minimal code changes to configure the endpoint url for LS. + """ + + create_function_result = multiruntime_lambda.create_function(MemorySize=1024, Timeout=15) + + invocation_result = aws_client.lambda_.invoke( + FunctionName=create_function_result["FunctionName"], + ) + assert "FunctionError" not in invocation_result + + +class TestCloudwatchLogs: + @pytest.fixture(autouse=True) + def snapshot_transformers(self, snapshot): + snapshot.add_transformer(snapshot.transform.lambda_report_logs()) + snapshot.add_transformer( + snapshot.transform.key_value("eventId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.regex(r"::runtime:\w+", "::runtime:") + ) + snapshot.add_transformer(snapshot.transform.regex("\\.v\\d{2}", ".v")) + + @markers.aws.validated + # skip all snapshots - the logs are too different + # TODO add INIT_START to make snapshotting of logs possible + @markers.snapshot.skip_snapshot_verify() + def test_multi_line_prints(self, aws_client, create_lambda_function, snapshot): + function_name = f"test_lambda_{short_uid()}" + log_group_name = f"/aws/lambda/{function_name}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_CLOUDWATCH_LOGS, + runtime=Runtime.python3_13, + ) + + payload = { + "body": textwrap.dedent(""" + multi + line + string + another\rline + """) + } + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=json.dumps(payload) + ) + snapshot.add_transformer( + snapshot.transform.regex( + invoke_response["ResponseMetadata"]["RequestId"], "" + ) + ) + + def fetch_logs(): + log_events_result = aws_client.logs.filter_log_events(logGroupName=log_group_name) + assert any("REPORT" in e["message"] for e in log_events_result["events"]) + return log_events_result["events"] + + log_events = retry(fetch_logs, retries=10, sleep=2) + snapshot.match("log-events", log_events) + + log_messages = [log["message"] for log in log_events] + # some manual assertions until we can actually use the snapshot + assert "multi\n" in log_messages + assert "line\n" in log_messages + assert "string\n" in log_messages + assert "another\rline\n" in log_messages diff --git a/tests/aws/services/lambda_/test_lambda_runtimes.snapshot.json b/tests/aws/services/lambda_/test_lambda_runtimes.snapshot.json new file mode 100644 index 0000000000000..314aec2afb7e4 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_runtimes.snapshot.json @@ -0,0 +1,1275 @@ +{ + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs20.x]": { + "recorded-date": "26-11-2024, 09:42:35", + "recorded-content": { + "creation-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_handler_es6.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs20.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invocation-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "statusCode": 200, + "body": "\"response from localstack lambda: {\\\"event_type\\\":\\\"test_lambda\\\"}\"" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs18.x]": { + "recorded-date": "26-11-2024, 09:42:45", + "recorded-content": { + "creation-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_handler_es6.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs18.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invocation-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "statusCode": 200, + "body": "\"response from localstack lambda: {\\\"event_type\\\":\\\"test_lambda\\\"}\"" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs16.x]": { + "recorded-date": "26-11-2024, 09:42:54", + "recorded-content": { + "creation-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_handler_es6.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs16.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invocation-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "statusCode": 200, + "body": "\"response from localstack lambda: {\\\"event_type\\\":\\\"test_lambda\\\"}\"" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_runtime_with_lib": { + "recorded-date": "26-11-2024, 09:43:08", + "recorded-content": { + "create-result-jar-with-lib": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.sample.LambdaHandlerWithLib", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result-jar-with-lib": { + "ExecutedVersion": "$LATEST", + "Payload": "\"{\\\"echo\\\":\\\"echo\\\"}\"", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-result-zip-with-lib": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:2>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.sample.LambdaHandlerWithLib", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result-zip-with-lib": { + "ExecutedVersion": "$LATEST", + "Payload": "\"{\\\"echo\\\":\\\"echo\\\"}\"", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-result-zip-with-lib-gradle": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:3>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.sample.LambdaHandlerWithLib", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result-zip-with-lib-gradle": { + "ExecutedVersion": "$LATEST", + "Payload": "\"{\\\"echo\\\":\\\"echo\\\"}\"", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java21]": { + "recorded-date": "26-11-2024, 09:43:11", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java17]": { + "recorded-date": "26-11-2024, 09:43:14", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java11]": { + "recorded-date": "26-11-2024, 09:43:17", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java8.al2]": { + "recorded-date": "26-11-2024, 09:43:20", + "recorded-content": { + "invoke_result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java21]": { + "recorded-date": "26-11-2024, 09:43:37", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.awssdkv1.sample.SerializedInputLambdaHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java21", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "bucket": "test_bucket", + "key": "test_key", + "validated": true + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java17]": { + "recorded-date": "26-11-2024, 09:43:52", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.awssdkv1.sample.SerializedInputLambdaHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java17", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "bucket": "test_bucket", + "key": "test_key", + "validated": true + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java11]": { + "recorded-date": "26-11-2024, 09:44:07", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.awssdkv1.sample.SerializedInputLambdaHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "bucket": "test_bucket", + "key": "test_key", + "validated": true + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java8.al2]": { + "recorded-date": "26-11-2024, 09:44:22", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.awssdkv1.sample.SerializedInputLambdaHandler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java8.al2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "bucket": "test_bucket", + "key": "test_key", + "validated": true + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequestCustom-CUSTOM]": { + "recorded-date": "26-11-2024, 09:44:29", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequestCustom", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": "\"CUSTOM\"", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom-INTERFACE]": { + "recorded-date": "26-11-2024, 09:44:39", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": "\"INTERFACE\"", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequest-INTERFACE]": { + "recorded-date": "26-11-2024, 09:44:50", + "recorded-content": { + "create-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequest", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invoke-result": { + "ExecutedVersion": "$LATEST", + "Payload": "\"INTERFACE\"", + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_lambda_subscribe_sns_topic": { + "recorded-date": "26-11-2024, 09:45:22", + "recorded-content": { + "get-function": { + "Code": { + "Location": "", + "RepositoryType": "S3" + }, + "Configuration": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "cloud.localstack.sample.LambdaHandler", + "LastModified": "date", + "LastUpdateStatus": "Successful", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "java11", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Active", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "add-permission": { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "ArnLike": { + "AWS:SourceArn": "arn::sns::111111111111:" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.12]": { + "recorded-date": "26-11-2024, 09:45:27", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.11]": { + "recorded-date": "26-11-2024, 09:45:30", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.10]": { + "recorded-date": "26-11-2024, 09:45:32", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.9]": { + "recorded-date": "26-11-2024, 09:45:35", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.8]": { + "recorded-date": "26-11-2024, 09:45:38", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.12]": { + "recorded-date": "26-11-2024, 09:45:42", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.11]": { + "recorded-date": "26-11-2024, 09:45:45", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.10]": { + "recorded-date": "26-11-2024, 09:45:47", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.9]": { + "recorded-date": "26-11-2024, 09:45:49", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.8]": { + "recorded-date": "26-11-2024, 09:45:51", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_uncaught_exception_invoke[provided.al2023]": { + "recorded-date": "26-11-2024, 09:46:26", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "provided.al2023", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "errorString" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_uncaught_exception_invoke[provided.al2]": { + "recorded-date": "26-11-2024, 09:46:32", + "recorded-content": { + "create_function_result": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "function.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 1024, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "provided.al2", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 3, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "error_result": { + "ExecutedVersion": "$LATEST", + "FunctionError": "Unhandled", + "Payload": { + "errorMessage": "Error: some_error_msg", + "errorType": "errorString" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_manual_endpoint_injection[provided.al2023]": { + "recorded-date": "26-11-2024, 09:46:59", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_manual_endpoint_injection[provided.al2]": { + "recorded-date": "26-11-2024, 09:47:11", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.13]": { + "recorded-date": "26-11-2024, 09:45:25", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.13]": { + "recorded-date": "26-11-2024, 09:45:40", + "recorded-content": {} + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs22.x]": { + "recorded-date": "26-11-2024, 09:42:29", + "recorded-content": { + "creation-result": { + "CreateEventSourceMappingResponse": null, + "CreateFunctionResponse": { + "Architectures": [ + "x86_64" + ], + "CodeSha256": "<:1>", + "CodeSize": "", + "Description": "", + "Environment": { + "Variables": {} + }, + "EphemeralStorage": { + "Size": 512 + }, + "FunctionArn": "arn::lambda::111111111111:function:", + "FunctionName": "", + "Handler": "lambda_handler_es6.handler", + "LastModified": "date", + "LoggingConfig": { + "LogFormat": "Text", + "LogGroup": "/aws/lambda/" + }, + "MemorySize": 128, + "PackageType": "Zip", + "RevisionId": "", + "Role": "arn::iam::111111111111:role/", + "Runtime": "nodejs22.x", + "RuntimeVersionConfig": { + "RuntimeVersionArn": "arn::lambda:::runtime:" + }, + "SnapStart": { + "ApplyOn": "None", + "OptimizationStatus": "Off" + }, + "State": "Pending", + "StateReason": "The function is being created.", + "StateReasonCode": "Creating", + "Timeout": 30, + "TracingConfig": { + "Mode": "PassThrough" + }, + "Version": "$LATEST", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + } + }, + "invocation-result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "statusCode": 200, + "body": "\"response from localstack lambda: {\\\"event_type\\\":\\\"test_lambda\\\"}\"" + }, + "StatusCode": 200, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestCloudwatchLogs::test_multi_line_prints": { + "recorded-date": "02-04-2025, 12:35:33", + "recorded-content": { + "log-events": [ + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "INIT_START Runtime Version: python:3.13.v\tRuntime Version ARN: arn::lambda:::runtime:\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "START RequestId: Version: $LATEST\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "multi\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "line\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "string\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "another\rline\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "END RequestId: \n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "REPORT RequestId: \tDuration: ms\tBilled Duration: ms\tMemory Size: 128 MB\tMax Memory Used: MB\tInit Duration: ms\t\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + } + ] + } + } +} diff --git a/tests/aws/services/lambda_/test_lambda_runtimes.validation.json b/tests/aws/services/lambda_/test_lambda_runtimes.validation.json new file mode 100644 index 0000000000000..4d29b8b622534 --- /dev/null +++ b/tests/aws/services/lambda_/test_lambda_runtimes.validation.json @@ -0,0 +1,104 @@ +{ + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestCloudwatchLogs::test_multi_line_prints": { + "last_validated_date": "2025-04-02T12:35:33+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_manual_endpoint_injection[provided.al2023]": { + "last_validated_date": "2024-11-26T09:46:59+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_manual_endpoint_injection[provided.al2]": { + "last_validated_date": "2024-11-26T09:47:11+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_uncaught_exception_invoke[provided.al2023]": { + "last_validated_date": "2024-11-26T09:46:26+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_uncaught_exception_invoke[provided.al2]": { + "last_validated_date": "2024-11-26T09:46:31+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom-INTERFACE]": { + "last_validated_date": "2024-11-26T09:44:39+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequest-INTERFACE]": { + "last_validated_date": "2024-11-26T09:44:50+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_custom_handler_method_specification[cloud.localstack.sample.LambdaHandlerWithInterfaceAndCustom::handleRequestCustom-CUSTOM]": { + "last_validated_date": "2024-11-26T09:44:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_lambda_subscribe_sns_topic": { + "last_validated_date": "2024-11-26T09:45:21+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_java_runtime_with_lib": { + "last_validated_date": "2024-11-26T09:43:07+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java11]": { + "last_validated_date": "2024-11-26T09:44:07+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java17]": { + "last_validated_date": "2024-11-26T09:43:52+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java21]": { + "last_validated_date": "2024-11-26T09:43:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_serializable_input_object[java8.al2]": { + "last_validated_date": "2024-11-26T09:44:22+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java11]": { + "last_validated_date": "2024-11-26T09:43:16+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java17]": { + "last_validated_date": "2024-11-26T09:43:13+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java21]": { + "last_validated_date": "2024-11-26T09:43:11+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestJavaRuntimes::test_stream_handler[java8.al2]": { + "last_validated_date": "2024-11-26T09:43:19+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs16.x]": { + "last_validated_date": "2024-11-26T09:42:54+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs18.x]": { + "last_validated_date": "2024-11-26T09:42:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs20.x]": { + "last_validated_date": "2024-11-26T09:42:35+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestNodeJSRuntimes::test_invoke_nodejs_es6_lambda[nodejs22.x]": { + "last_validated_date": "2024-11-26T09:42:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.10]": { + "last_validated_date": "2024-11-26T09:45:32+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.11]": { + "last_validated_date": "2024-11-26T09:45:29+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.12]": { + "last_validated_date": "2024-11-26T09:45:27+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.13]": { + "last_validated_date": "2024-11-26T09:45:24+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.8]": { + "last_validated_date": "2024-11-26T09:45:37+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_handler_in_submodule[python3.9]": { + "last_validated_date": "2024-11-26T09:45:35+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.10]": { + "last_validated_date": "2024-11-26T09:45:47+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.11]": { + "last_validated_date": "2024-11-26T09:45:44+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.12]": { + "last_validated_date": "2024-11-26T09:45:42+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.13]": { + "last_validated_date": "2024-11-26T09:45:40+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.8]": { + "last_validated_date": "2024-11-26T09:45:51+00:00" + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestPythonRuntimes::test_python_runtime_correct_versions[python3.9]": { + "last_validated_date": "2024-11-26T09:45:49+00:00" + } +} diff --git a/tests/aws/services/lambda_/utils.py b/tests/aws/services/lambda_/utils.py new file mode 100644 index 0000000000000..1f7aac51ab497 --- /dev/null +++ b/tests/aws/services/lambda_/utils.py @@ -0,0 +1,13 @@ +"""Test utils for Lambda.""" + +from localstack.aws.connect import ServiceLevelClientFactory + + +def get_s3_keys(aws_client: ServiceLevelClientFactory, s3_bucket: str) -> list[str]: + s3_keys_output = [] + paginator = aws_client.s3.get_paginator("list_objects_v2") + page_iterator = paginator.paginate(Bucket=s3_bucket) + for page in page_iterator: + for obj in page.get("Contents", []): + s3_keys_output.append(obj["Key"]) + return s3_keys_output diff --git a/tests/aws/services/logs/__init__.py b/tests/aws/services/logs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/logs/test_logs.py b/tests/aws/services/logs/test_logs.py new file mode 100644 index 0000000000000..bf3abb3a5294f --- /dev/null +++ b/tests/aws/services/logs/test_logs.py @@ -0,0 +1,658 @@ +import base64 +import gzip +import json +import re + +import pytest +from localstack_snapshot.pytest.snapshot import is_aws +from localstack_snapshot.snapshots.transformer import KeyValueBasedTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.constants import APPLICATION_AMZ_JSON_1_1 +from localstack.testing.config import TEST_AWS_REGION_NAME +from localstack.testing.pytest import markers +from localstack.utils import testutil +from localstack.utils.aws import arns +from localstack.utils.aws.arns import get_partition +from localstack.utils.common import now_utc, poll_condition, retry, short_uid +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_PYTHON_ECHO + +logs_role = { + "Statement": { + "Effect": "Allow", + "Principal": {"Service": f"logs.{TEST_AWS_REGION_NAME}.amazonaws.com"}, + "Action": "sts:AssumeRole", + } +} +kinesis_permission = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "kinesis:PutRecord", "Resource": "*"}], +} +s3_firehose_role = { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": {"Service": "firehose.amazonaws.com"}, + "Action": "sts:AssumeRole", + } +} +s3_firehose_permission = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": ["s3:*", "s3-object-lambda:*"], "Resource": "*"}], +} + +firehose_permission = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": ["firehose:*"], "Resource": "*"}], +} + + +@pytest.fixture +def logs_log_group(aws_client): + name = f"test-log-group-{short_uid()}" + aws_client.logs.create_log_group(logGroupName=name) + yield name + aws_client.logs.delete_log_group(logGroupName=name) + + +@pytest.fixture +def logs_log_stream(logs_log_group, aws_client): + name = f"test-log-stream-{short_uid()}" + aws_client.logs.create_log_stream(logGroupName=logs_log_group, logStreamName=name) + yield name + aws_client.logs.delete_log_stream(logStreamName=name, logGroupName=logs_log_group) + + +class TestCloudWatchLogs: + # TODO make creation and description atomic to avoid possible flake? + @markers.aws.validated + def test_create_and_delete_log_group(self, aws_client): + test_name = f"test-log-group-{short_uid()}" + log_groups_before = aws_client.logs.describe_log_groups( + logGroupNamePrefix="test-log-group-" + ).get("logGroups", []) + + aws_client.logs.create_log_group(logGroupName=test_name) + + log_groups_between = aws_client.logs.describe_log_groups( + logGroupNamePrefix="test-log-group-" + ).get("logGroups", []) + assert poll_condition( + lambda: len(log_groups_between) == len(log_groups_before) + 1, timeout=5.0, interval=0.5 + ) + + aws_client.logs.delete_log_group(logGroupName=test_name) + + log_groups_after = aws_client.logs.describe_log_groups( + logGroupNamePrefix="test-log-group-" + ).get("logGroups", []) + assert poll_condition( + lambda: len(log_groups_after) == len(log_groups_between) - 1, timeout=5.0, interval=0.5 + ) + assert len(log_groups_after) == len(log_groups_before) + + @markers.aws.validated + def test_resource_does_not_exist(self, aws_client, snapshot, cleanups): + log_group_name = f"log-group-{short_uid()}" + log_stream_name = f"log-stream-{short_uid()}" + with pytest.raises(Exception) as ctx: + aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name + ) + snapshot.match("error-log-group-does-not-exist", ctx.value.response) + + aws_client.logs.create_log_group(logGroupName=log_group_name) + cleanups.append(lambda: aws_client.logs.delete_log_group(logGroupName=log_group_name)) + + with pytest.raises(Exception) as ctx: + aws_client.logs.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name + ) + snapshot.match("error-log-stream-does-not-exist", ctx.value.response) + + @markers.aws.validated + def test_list_tags_log_group(self, snapshot, aws_client): + test_name = f"test-log-group-{short_uid()}" + try: + aws_client.logs.create_log_group(logGroupName=test_name, tags={"env": "testing1"}) + response = aws_client.logs.list_tags_log_group(logGroupName=test_name) + snapshot.match("list_tags_after_create_log_group", response) + + # get group arn, to use the tag-resource api + log_group_arn = aws_client.logs.describe_log_groups(logGroupNamePrefix=test_name)[ + "logGroups" + ][0]["arn"].rstrip(":*") + + # add a tag - new api + aws_client.logs.tag_resource( + resourceArn=log_group_arn, tags={"test1": "val1", "test2": "val2"} + ) + + response = aws_client.logs.list_tags_log_group(logGroupName=test_name) + response_2 = aws_client.logs.list_tags_for_resource(resourceArn=log_group_arn) + + snapshot.match("list_tags_log_group_after_tag_resource", response) + snapshot.match("list_tags_for_resource_after_tag_resource", response_2) + # values should be the same + assert response["tags"] == response_2["tags"] + + # add a tag - old api + aws_client.logs.tag_log_group(logGroupName=test_name, tags={"test3": "val3"}) + + response = aws_client.logs.list_tags_log_group(logGroupName=test_name) + response_2 = aws_client.logs.list_tags_for_resource(resourceArn=log_group_arn) + + snapshot.match("list_tags_log_group_after_tag_log_group", response) + snapshot.match("list_tags_for_resource_after_tag_log_group", response_2) + assert response["tags"] == response_2["tags"] + + # untag - use both apis + aws_client.logs.untag_log_group(logGroupName=test_name, tags=["test3"]) + aws_client.logs.untag_resource(resourceArn=log_group_arn, tagKeys=["env", "test1"]) + + response = aws_client.logs.list_tags_log_group(logGroupName=test_name) + response_2 = aws_client.logs.list_tags_for_resource(resourceArn=log_group_arn) + snapshot.match("list_tags_log_group_after_untag", response) + snapshot.match("list_tags_for_resource_after_untag", response_2) + + assert response["tags"] == response_2["tags"] + + finally: + # clean up + aws_client.logs.delete_log_group(logGroupName=test_name) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO 'describe-log-groups' returns different attributes on AWS when using + # 'logGroupNamePattern' compared to 'logGroupNamePrefix' (for the same log group) + # seems like a weird issue on AWS side, we just exclude the paths here for this particular call + "$..describe-log-groups-pattern.logGroups..metricFilterCount", + "$..describe-log-groups-pattern.logGroups..storedBytes", + "$..describe-log-groups-pattern.nextToken", + ] + ) + @markers.aws.validated + def test_create_and_delete_log_stream(self, logs_log_group, aws_client, region_name, snapshot): + snapshot.add_transformer(snapshot.transform.logs_api()) + test_name = f"test-log-stream-{short_uid()}" + + # filter for prefix/entire name here + response = aws_client.logs.describe_log_groups(logGroupNamePrefix=logs_log_group) + snapshot.match("describe-log-groups-prefix", response) + + # pattern for the short-uid + # for some reason, this does not work immediately on AWS + assert poll_condition( + lambda: len( + aws_client.logs.describe_log_groups( + logGroupNamePattern=logs_log_group.split("-")[-1] + ).get("logGroups") + ) + == 1, + timeout=5.0, + interval=0.5, + ) + response = aws_client.logs.describe_log_groups( + logGroupNamePattern=logs_log_group.split("-")[-1] + ) + snapshot.match("describe-log-groups-pattern", response) + + # using prefix + pattern should raise error + with pytest.raises(Exception) as ctx: + aws_client.logs.describe_log_groups( + logGroupNamePattern=logs_log_group, logGroupNamePrefix=logs_log_group + ) + snapshot.match("error-describe-logs-group", ctx.value.response) + + aws_client.logs.create_log_stream(logGroupName=logs_log_group, logStreamName=test_name) + log_streams_between = aws_client.logs.describe_log_streams(logGroupName=logs_log_group).get( + "logStreams", [] + ) + + snapshot.match("logs_log_group", log_streams_between) + + # using log-group-name and log-group-identifier should raise exception + with pytest.raises(Exception) as ctx: + aws_client.logs.describe_log_streams( + logGroupName=logs_log_group, logGroupIdentifier=logs_log_group + ) + snapshot.match("error-describe-logs-streams", ctx.value.response) + + # log group identifier using the name of the log-group + response = aws_client.logs.describe_log_streams(logGroupIdentifier=logs_log_group).get( + "logStreams" + ) + snapshot.match("log_group_identifier", response) + # log group identifier using arn + response = aws_client.logs.describe_log_streams( + logGroupIdentifier=arns.log_group_arn( + logs_log_group, + account_id=aws_client.sts.get_caller_identity()["Account"], + region_name=region_name, + ) + ).get("logStreams") + snapshot.match("log_group_identifier-arn", response) + + aws_client.logs.delete_log_stream(logGroupName=logs_log_group, logStreamName=test_name) + + log_streams_after = aws_client.logs.describe_log_streams(logGroupName=logs_log_group).get( + "logStreams", [] + ) + assert len(log_streams_after) == 0 + + @markers.aws.validated + def test_put_events_multi_bytes_msg(self, logs_log_group, logs_log_stream, aws_client): + body_msg = "πŸ™€ - ε‚γ‚ˆ - ζ—₯本θͺž" + events = [{"timestamp": now_utc(millis=True), "message": body_msg}] + response = aws_client.logs.put_log_events( + logGroupName=logs_log_group, logStreamName=logs_log_stream, logEvents=events + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + def get_log_events(): + events = aws_client.logs.get_log_events( + logGroupName=logs_log_group, logStreamName=logs_log_stream + )["events"] + assert events[0]["message"] == body_msg + + retry( + get_log_events, + retries=20 if is_aws() else 3, + sleep=5 if is_aws() else 1, + sleep_before=3 if is_aws() else 0, + ) + + @markers.aws.validated + def test_filter_log_events_response_header(self, logs_log_group, logs_log_stream, aws_client): + events = [ + {"timestamp": now_utc(millis=True), "message": "log message 1"}, + {"timestamp": now_utc(millis=True), "message": "log message 2"}, + ] + response = aws_client.logs.put_log_events( + logGroupName=logs_log_group, logStreamName=logs_log_stream, logEvents=events + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + response = aws_client.logs.filter_log_events(logGroupName=logs_log_group) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert ( + response["ResponseMetadata"]["HTTPHeaders"]["content-type"] == APPLICATION_AMZ_JSON_1_1 + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Statement.Condition.StringEquals", + "$..add_permission.ResponseMetadata.HTTPStatusCode", + ] + ) + def test_put_subscription_filter_lambda( + self, + logs_log_group, + logs_log_stream, + create_lambda_function, + snapshot, + aws_client, + region_name, + ): + snapshot.add_transformer(snapshot.transform.lambda_api()) + # special replacements for this test case: + snapshot.add_transformer(snapshot.transform.key_value("logGroupName")) + snapshot.add_transformer(snapshot.transform.key_value("logStreamName")) + snapshot.add_transformer( + KeyValueBasedTransformer( + lambda k, v: ( + v + if k == "id" and (isinstance(v, str) and re.match(re.compile(r"^[0-9]+$"), v)) + else None + ), + replacement="id", + replace_reference=False, + ), + ) + + test_lambda_name = f"test-lambda-function-{short_uid()}" + func_arn = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=test_lambda_name, + runtime=Runtime.python3_12, + )["CreateFunctionResponse"]["FunctionArn"] + aws_client.lambda_.invoke(FunctionName=test_lambda_name, Payload=b"{}") + # get account-id to set the correct policy + account_id = aws_client.sts.get_caller_identity()["Account"] + result = aws_client.lambda_.add_permission( + FunctionName=test_lambda_name, + StatementId=test_lambda_name, + Principal=f"logs.{region_name}.amazonaws.com", + Action="lambda:InvokeFunction", + SourceArn=f"arn:{get_partition(region_name)}:logs:{region_name}:{account_id}:log-group:{logs_log_group}:*", + SourceAccount=account_id, + ) + + snapshot.match("add_permission", result) + + result = aws_client.logs.put_subscription_filter( + logGroupName=logs_log_group, + filterName="test", + filterPattern="", + destinationArn=func_arn, + ) + snapshot.match("put_subscription_filter", result) + + aws_client.logs.put_log_events( + logGroupName=logs_log_group, + logStreamName=logs_log_stream, + logEvents=[ + {"timestamp": now_utc(millis=True), "message": "test"}, + {"timestamp": now_utc(millis=True), "message": "test 2"}, + ], + ) + + response = aws_client.logs.describe_subscription_filters(logGroupName=logs_log_group) + assert len(response["subscriptionFilters"]) == 1 + snapshot.match("describe_subscription_filter", response) + + def check_invocation(): + events = testutil.list_all_log_events( + log_group_name=f"/aws/lambda/{test_lambda_name}", logs_client=aws_client.logs + ) + # we only are interested in events that contain "awslogs" + filtered_events = [] + for e in events: + if "awslogs" in e["message"]: + # the message will look like this: + # {"messageType":"DATA_MESSAGE","owner":"000000000000","logGroup":"log-group", + # "logStream":"log-stream","subscriptionFilters":["test"], + # "logEvents":[{"id":"7","timestamp":1679056073581,"message":"test"}, + # {"id":"8","timestamp":1679056073581,"message":"test 2"}]} + data = json.loads(e["message"])["awslogs"]["data"].encode("utf-8") + decoded_data = gzip.decompress(base64.b64decode(data)).decode("utf-8") + for log_event in json.loads(decoded_data)["logEvents"]: + filtered_events.append(log_event) + assert len(filtered_events) == 2 + + filtered_events.sort(key=lambda k: k.get("message")) + snapshot.match("list_all_log_events", filtered_events) + + retry(check_invocation, retries=6, sleep=3.0) + + @markers.aws.validated + def test_put_subscription_filter_firehose( + self, logs_log_group, logs_log_stream, s3_bucket, create_iam_role_with_policy, aws_client + ): + try: + firehose_name = f"test-firehose-{short_uid()}" + s3_bucket_arn = f"arn:aws:s3:::{s3_bucket}" + + role = f"test-firehose-s3-role-{short_uid()}" + policy_name = f"test-firehose-s3-role-policy-{short_uid()}" + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=s3_firehose_role, + PolicyDefinition=s3_firehose_permission, + ) + + # TODO AWS has troubles creating the delivery stream the first time + # policy is not accepted at first, so we try again + def create_delivery_stream(): + aws_client.firehose.create_delivery_stream( + DeliveryStreamName=firehose_name, + S3DestinationConfiguration={ + "BucketARN": s3_bucket_arn, + "RoleARN": role_arn, + "BufferingHints": {"SizeInMBs": 1, "IntervalInSeconds": 60}, + }, + ) + + retry(create_delivery_stream, retries=5, sleep=10.0) + + response = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=firehose_name + ) + firehose_arn = response["DeliveryStreamDescription"]["DeliveryStreamARN"] + + role = f"test-firehose-role-{short_uid()}" + policy_name = f"test-firehose-role-policy-{short_uid()}" + role_arn_logs = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=logs_role, + PolicyDefinition=firehose_permission, + ) + + def check_stream_active(): + state = aws_client.firehose.describe_delivery_stream( + DeliveryStreamName=firehose_name + )["DeliveryStreamDescription"]["DeliveryStreamStatus"] + if state != "ACTIVE": + raise Exception(f"DeliveryStreamStatus is {state}") + + retry(check_stream_active, retries=60, sleep=30.0) + + aws_client.logs.put_subscription_filter( + logGroupName=logs_log_group, + filterName="Destination", + filterPattern="", + destinationArn=firehose_arn, + roleArn=role_arn_logs, + ) + + aws_client.logs.put_log_events( + logGroupName=logs_log_group, + logStreamName=logs_log_stream, + logEvents=[ + {"timestamp": now_utc(millis=True), "message": "test"}, + {"timestamp": now_utc(millis=True), "message": "test 2"}, + ], + ) + + def list_objects(): + response = aws_client.s3.list_objects(Bucket=s3_bucket) + assert len(response["Contents"]) >= 1 + + retry(list_objects, retries=60, sleep=30.0) + response = aws_client.s3.list_objects(Bucket=s3_bucket) + key = response["Contents"][-1]["Key"] + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + content = gzip.decompress(response["Body"].read()).decode("utf-8") + assert "DATA_MESSAGE" in content + assert "test" in content + assert "test 2" in content + + finally: + # clean up + aws_client.firehose.delete_delivery_stream( + DeliveryStreamName=firehose_name, AllowForceDelete=True + ) + + @markers.aws.validated + def test_put_subscription_filter_kinesis( + self, logs_log_group, logs_log_stream, create_iam_role_with_policy, aws_client + ): + kinesis_name = f"test-kinesis-{short_uid()}" + filter_name = "Destination" + aws_client.kinesis.create_stream(StreamName=kinesis_name, ShardCount=1) + + try: + result = aws_client.kinesis.describe_stream(StreamName=kinesis_name)[ + "StreamDescription" + ] + kinesis_arn = result["StreamARN"] + role = f"test-kinesis-role-{short_uid()}" + policy_name = f"test-kinesis-role-policy-{short_uid()}" + role_arn = create_iam_role_with_policy( + RoleName=role, + PolicyName=policy_name, + RoleDefinition=logs_role, + PolicyDefinition=kinesis_permission, + ) + + # wait for stream-status "ACTIVE" + status = result["StreamStatus"] + if status != "ACTIVE": + + def check_stream_active(): + state = aws_client.kinesis.describe_stream(StreamName=kinesis_name)[ + "StreamDescription" + ]["StreamStatus"] + if state != "ACTIVE": + raise Exception(f"StreamStatus is {state}") + + retry(check_stream_active, retries=6, sleep=1.0, sleep_before=2.0) + + def put_subscription_filter(): + aws_client.logs.put_subscription_filter( + logGroupName=logs_log_group, + filterName=filter_name, + filterPattern="", + destinationArn=kinesis_arn, + roleArn=role_arn, + ) + + # for a weird reason the put_subscription_filter fails on AWS the first time, + # even-though we check for ACTIVE state... + retry(put_subscription_filter, retries=6, sleep=3.0) + + def put_event(): + aws_client.logs.put_log_events( + logGroupName=logs_log_group, + logStreamName=logs_log_stream, + logEvents=[ + {"timestamp": now_utc(millis=True), "message": "test"}, + {"timestamp": now_utc(millis=True), "message": "test 2"}, + ], + ) + + retry(put_event, retries=6, sleep=3.0) + + shard_iterator = aws_client.kinesis.get_shard_iterator( + StreamName=kinesis_name, + ShardId="shardId-000000000000", + ShardIteratorType="TRIM_HORIZON", + )["ShardIterator"] + + response = aws_client.kinesis.get_records(ShardIterator=shard_iterator) + # AWS sends messages as health checks + assert len(response["Records"]) >= 1 + found = False + for record in response["Records"]: + data = record["Data"] + unzipped_data = gzip.decompress(data) + json_data = json.loads(unzipped_data) + if "test" in json.dumps(json_data["logEvents"]): + assert len(json_data["logEvents"]) == 2 + assert json_data["logEvents"][0]["message"] == "test" + assert json_data["logEvents"][1]["message"] == "test 2" + found = True + + assert found + # clean up + finally: + aws_client.kinesis.delete_stream(StreamName=kinesis_name, EnforceConsumerDeletion=True) + aws_client.logs.delete_subscription_filter( + logGroupName=logs_log_group, filterName=filter_name + ) + + @pytest.mark.skip("TODO: failing against community - filters are only in pro -> move test?") + @markers.aws.validated + def test_metric_filters(self, logs_log_group, logs_log_stream, aws_client): + basic_filter_name = f"test-filter-basic-{short_uid()}" + json_filter_name = f"test-filter-json-{short_uid()}" + namespace_name = f"test-metric-namespace-{short_uid()}" + basic_metric_name = f"test-basic-metric-{short_uid()}" + json_metric_name = f"test-json-metric-{short_uid()}" + basic_transforms = { + "metricNamespace": namespace_name, + "metricName": basic_metric_name, + "metricValue": "1", + "defaultValue": 0, + } + json_transforms = { + "metricNamespace": namespace_name, + "metricName": json_metric_name, + "metricValue": "1", + "defaultValue": 0, + } + aws_client.logs.put_metric_filter( + logGroupName=logs_log_group, + filterName=basic_filter_name, + filterPattern=" ", + metricTransformations=[basic_transforms], + ) + aws_client.logs.put_metric_filter( + logGroupName=logs_log_group, + filterName=json_filter_name, + filterPattern='{$.message = "test"}', + metricTransformations=[json_transforms], + ) + + response = aws_client.logs.describe_metric_filters( + logGroupName=logs_log_group, filterNamePrefix="test-filter-" + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + filter_names = [_filter["filterName"] for _filter in response["metricFilters"]] + assert basic_filter_name in filter_names + assert json_filter_name in filter_names + + # put log events and assert metrics being published + events = [ + {"timestamp": now_utc(millis=True), "message": "log message 1"}, + {"timestamp": now_utc(millis=True), "message": "log message 2"}, + ] + aws_client.logs.put_log_events( + logGroupName=logs_log_group, logStreamName=logs_log_stream, logEvents=events + ) + + # list metrics + def list_metrics(): + res = aws_client.cloudwatch.list_metrics(Namespace=namespace_name) + assert len(res["Metrics"]) == 2 + + retry( + list_metrics, + retries=20 if is_aws() else 3, + sleep=5 if is_aws() else 1, + sleep_before=3 if is_aws() else 0, + ) + + # delete filters + aws_client.logs.delete_metric_filter( + logGroupName=logs_log_group, filterName=basic_filter_name + ) + aws_client.logs.delete_metric_filter( + logGroupName=logs_log_group, filterName=json_filter_name + ) + + response = aws_client.logs.describe_metric_filters( + logGroupName=logs_log_group, filterNamePrefix="test-filter-" + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + filter_names = [_filter["filterName"] for _filter in response["metricFilters"]] + assert basic_filter_name not in filter_names + assert json_filter_name not in filter_names + + @markers.aws.needs_fixing + def test_delivery_logs_for_sns(self, sns_create_topic, sns_subscription, aws_client): + topic_name = f"test-logs-{short_uid()}" + contact = "+10123456789" + + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + sns_subscription(TopicArn=topic_arn, Protocol="sms", Endpoint=contact) + + message = "Good news everyone!" + aws_client.sns.publish(Message=message, TopicArn=topic_arn) + logs_group_name = topic_arn.replace("arn:aws:", "").replace(":", "/") + + def log_group_exists(): + # TODO on AWS the log group is not created, probably need iam role + # see also https://repost.aws/knowledge-center/monitor-sns-texts-cloudwatch + response = aws_client.logs.describe_log_streams(logGroupName=logs_group_name) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + retry( + log_group_exists, + retries=20 if is_aws() else 3, + sleep=5 if is_aws() else 1, + sleep_before=3 if is_aws() else 0, + ) diff --git a/tests/aws/services/logs/test_logs.snapshot.json b/tests/aws/services/logs/test_logs.snapshot.json new file mode 100644 index 0000000000000..9f6e7b429a931 --- /dev/null +++ b/tests/aws/services/logs/test_logs.snapshot.json @@ -0,0 +1,245 @@ +{ + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_put_subscription_filter_lambda": { + "recorded-date": "17-03-2023, 13:55:00", + "recorded-content": { + "add_permission": { + "Statement": { + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "logs..amazonaws.com" + }, + "Action": "lambda:InvokeFunction", + "Resource": "arn::lambda::111111111111:function:", + "Condition": { + "StringEquals": { + "AWS:SourceAccount": "111111111111" + }, + "ArnLike": { + "AWS:SourceArn": "arn::logs::111111111111:log-group::" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "put_subscription_filter": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_subscription_filter": { + "subscriptionFilters": [ + { + "creationTime": "timestamp", + "destinationArn": "arn::lambda::111111111111:function:", + "distribution": "ByLogStream", + "filterName": "test", + "filterPattern": "", + "logGroupName": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_all_log_events": [ + { + "id": "id", + "timestamp": "timestamp", + "message": "test" + }, + { + "id": "id", + "timestamp": "timestamp", + "message": "test 2" + } + ] + } + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_list_tags_log_group": { + "recorded-date": "22-12-2022, 17:46:54", + "recorded-content": { + "list_tags_after_create_log_group": { + "tags": { + "env": "testing1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_log_group_after_tag_resource": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_after_tag_resource": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_log_group_after_tag_log_group": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2", + "test3": "val3" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_after_tag_log_group": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2", + "test3": "val3" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_log_group_after_untag": { + "tags": { + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_after_untag": { + "tags": { + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_create_and_delete_log_stream": { + "recorded-date": "06-04-2023, 11:42:42", + "recorded-content": { + "describe-log-groups-prefix": { + "logGroups": [ + { + "arn": "arn::logs::111111111111:log-group::*", + "creationTime": "timestamp", + "logGroupName": "", + "metricFilterCount": 0, + "storedBytes": 0 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-log-groups-pattern": { + "logGroups": [ + { + "arn": "arn::logs::111111111111:log-group::*", + "creationTime": "timestamp", + "logGroupName": "" + } + ], + "nextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error-describe-logs-group": { + "Error": { + "Code": "InvalidParameterException", + "Message": "LogGroup name prefix and LogGroup name pattern are mutually exclusive parameters." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "logs_log_group": [ + { + "logStreamName": "", + "creationTime": "timestamp", + "arn": "arn::logs::111111111111:log-group::log-stream:", + "storedBytes": 0 + } + ], + "error-describe-logs-streams": { + "Error": { + "Code": "ValidationException", + "Message": "LogGroup name and LogGroup ARN are mutually exclusive parameters." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "log_group_identifier": [ + { + "logStreamName": "", + "creationTime": "timestamp", + "arn": "arn::logs::111111111111:log-group::log-stream:", + "storedBytes": 0 + } + ], + "log_group_identifier-arn": [ + { + "logStreamName": "", + "creationTime": "timestamp", + "arn": "arn::logs::111111111111:log-group::log-stream:", + "storedBytes": 0 + } + ] + } + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_resource_does_not_exist": { + "recorded-date": "05-09-2024, 16:43:20", + "recorded-content": { + "error-log-group-does-not-exist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The specified log group does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-log-stream-does-not-exist": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The specified log stream does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/logs/test_logs.validation.json b/tests/aws/services/logs/test_logs.validation.json new file mode 100644 index 0000000000000..4be0d1979501c --- /dev/null +++ b/tests/aws/services/logs/test_logs.validation.json @@ -0,0 +1,26 @@ +{ + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_create_and_delete_log_group": { + "last_validated_date": "2024-05-24T13:57:11+00:00" + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_create_and_delete_log_stream": { + "last_validated_date": "2023-04-06T09:42:42+00:00" + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_filter_log_events_response_header": { + "last_validated_date": "2024-05-24T13:58:30+00:00" + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_list_tags_log_group": { + "last_validated_date": "2022-12-22T16:46:54+00:00" + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_metric_filters": { + "last_validated_date": "2024-05-24T14:17:33+00:00" + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_put_events_multi_bytes_msg": { + "last_validated_date": "2024-05-24T14:23:19+00:00" + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_put_subscription_filter_lambda": { + "last_validated_date": "2023-03-17T12:55:00+00:00" + }, + "tests/aws/services/logs/test_logs.py::TestCloudWatchLogs::test_resource_does_not_exist": { + "last_validated_date": "2024-09-05T16:43:20+00:00" + } +} diff --git a/tests/aws/services/opensearch/__init__.py b/tests/aws/services/opensearch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/opensearch/test_opensearch.py b/tests/aws/services/opensearch/test_opensearch.py new file mode 100644 index 0000000000000..b62dfce58f93b --- /dev/null +++ b/tests/aws/services/opensearch/test_opensearch.py @@ -0,0 +1,1041 @@ +import gzip +import json +import logging +import os +import threading +from http.client import HTTPConnection +from urllib.parse import urlparse + +import botocore.exceptions +import pytest +from botocore.auth import SigV4Auth +from opensearchpy import OpenSearch +from opensearchpy.exceptions import AuthorizationException + +from localstack import config +from localstack.aws.api.opensearch import ( + AdvancedSecurityOptionsInput, + ClusterConfig, + DomainEndpointOptions, + EBSOptions, + EncryptionAtRestOptions, + MasterUserOptions, + NodeToNodeEncryptionOptions, + OpenSearchPartitionInstanceType, + VolumeType, +) +from localstack.constants import ( + ELASTICSEARCH_DEFAULT_VERSION, + OPENSEARCH_DEFAULT_VERSION, + OPENSEARCH_PLUGIN_LIST, +) +from localstack.services.opensearch import provider +from localstack.services.opensearch.cluster import CustomEndpoint, EdgeProxiedOpensearchCluster +from localstack.services.opensearch.cluster_manager import ( + CustomBackendManager, + DomainKey, + MultiClusterManager, + MultiplexingClusterManager, + SingletonClusterManager, + create_cluster_manager, +) +from localstack.services.opensearch.packages import opensearch_package +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import call_safe, poll_condition, retry, short_uid, start_worker_thread +from localstack.utils.common import safe_requests as requests +from localstack.utils.strings import to_str +from localstack.utils.urls import localstack_host + +LOG = logging.getLogger(__name__) + +# Common headers used when sending requests to OpenSearch +COMMON_HEADERS = {"content-type": "application/json", "Accept-encoding": "identity"} + +# Lock and event to ensure that the installation is executed before the tests +INIT_LOCK = threading.Lock() +installed = threading.Event() + + +def install_async(): + """ + Installs the default opensearch version in a worker thread. Used by conftest.py to make + sure opensearch is downloaded once the tests arrive here. + """ + if installed.is_set(): + return + + def run_install(*args): + with INIT_LOCK: + if installed.is_set(): + return + LOG.info("installing opensearch default version") + opensearch_package.install() + LOG.info("done installing opensearch default version") + installed.set() + + start_worker_thread(run_install) + + +@pytest.fixture(autouse=True) +def opensearch(): + if not installed.is_set(): + install_async() + + assert installed.wait(timeout=5 * 60), "gave up waiting for opensearch to install" + yield + + +def try_cluster_health(cluster_url: str): + response = requests.get(cluster_url) + assert response.ok, f"cluster endpoint returned an error: {response.text}" + + response = requests.get(f"{cluster_url}/_cluster/health") + assert response.ok, f"cluster health endpoint returned an error: {response.text}" + assert response.json()["status"] in [ + "orange", + "yellow", + "green", + ], "expected cluster state to be in a valid state" + + +@markers.skip_offline +class TestOpensearchProvider: + """ + Because this test reuses the localstack instance for each test, all tests are performed with + OPENSEARCH_MULTI_CLUSTER=True, regardless of changes in the config value. + """ + + @markers.aws.validated + def test_list_versions(self, aws_client, snapshot): + response = aws_client.opensearch.list_versions() + versions = sorted( + version + for version in response["Versions"] + # LocalStack does not support these versions + if version not in ["Elasticsearch_1.5", "Elasticsearch_2.3"] + ) + snapshot.match("versions", versions) + + @markers.aws.validated + def test_get_compatible_versions(self, aws_client, snapshot): + response = aws_client.opensearch.get_compatible_versions() + source_versions = sorted( + version["SourceVersion"] for version in response["CompatibleVersions"] + ) + snapshot.match("source_versions", source_versions) + for version in sorted(response["CompatibleVersions"], key=lambda x: x["SourceVersion"]): + snapshot.match(f"source_{version['SourceVersion'].lower()}", version["TargetVersions"]) + + @markers.aws.needs_fixing + def test_get_compatible_version_for_domain(self, opensearch_create_domain, aws_client): + opensearch_domain = opensearch_create_domain(EngineVersion=ELASTICSEARCH_DEFAULT_VERSION) + response = aws_client.opensearch.get_compatible_versions(DomainName=opensearch_domain) + assert "CompatibleVersions" in response + compatible_versions = response["CompatibleVersions"] + + assert len(compatible_versions) == 1 + compatibility = compatible_versions[0] + assert compatibility["SourceVersion"] == ELASTICSEARCH_DEFAULT_VERSION + # Just check if 1.1 is contained (not equality) to avoid breaking the test if new versions are supported + assert "OpenSearch_1.3" in compatibility["TargetVersions"] + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..AIMLOptions", + "$..AccessPolicies.Status.State", + "$..AccessPolicies.Status.UpdateVersion", + "$..AdvancedOptions.Status.UpdateVersion", + "$..AdvancedSecurityOptions..AnonymousAuthEnabled", + "$..AdvancedSecurityOptions.Status.UpdateVersion", + "$..AutoTuneOptions..State", + "$..AutoTuneOptions..UseOffPeakWindow", + "$..AutoTuneOptions.Options.DesiredState", + "$..AutoTuneOptions.Status.UpdateVersion", + "$..ChangeProgressDetails", + "$..ClusterConfig..DedicatedMasterCount", + "$..ClusterConfig..DedicatedMasterEnabled", + "$..ClusterConfig..DedicatedMasterType", + "$..ClusterConfig..MultiAZWithStandbyEnabled", + "$..ClusterConfig.Options.ColdStorageOptions", + "$..ClusterConfig.Options.WarmEnabled", + "$..ClusterConfig.Status.UpdateVersion", + "$..CognitoOptions.Status.UpdateVersion", + "$..DomainEndpointOptions..TLSSecurityPolicy", + "$..DomainEndpointOptions.Status.UpdateVersion", + "$..EBSOptions.Options.VolumeSize", + "$..EBSOptions.Status.UpdateVersion", + "$..EncryptionAtRestOptions.Status.UpdateVersion", + "$..Endpoint", + "$..EngineVersion.Status.UpdateVersion", + "$..IPAddressType", + "$..IdentityCenterOptions", + "$..LogPublishingOptions.Status.UpdateVersion", + "$..ModifyingProperties", + "$..NodeToNodeEncryptionOptions.Status.UpdateVersion", + "$..OffPeakWindowOptions", + "$..ServiceSoftwareOptions.CurrentVersion", + "$..SnapshotOptions.Options.AutomatedSnapshotStartHour", + "$..SnapshotOptions.Status.UpdateVersion", + "$..SoftwareUpdateOptions", + "$..VPCOptions.Status.UpdateVersion", + ] + ) + def test_domain_lifecycle( + self, opensearch_wait_for_cluster, aws_client, snapshot, aws_http_client_factory, cleanups + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("DomainName"), + snapshot.transform.key_value("account-arn"), + ] + ) + snapshot.add_transformer(snapshot.transform.key_value("Endpoint"), priority=-1) + + domain_name = f"opensearch-domain-{short_uid()}" + account_arn = aws_client.sts.get_caller_identity()["Arn"] + snapshot.match("account-arn", account_arn) + + http_client = aws_http_client_factory(service="es", signer_factory=SigV4Auth) + create_response = aws_client.opensearch.create_domain( + DomainName=domain_name, + EngineVersion="Elasticsearch_7.10", + ClusterConfig={ + "InstanceType": "t2.small.search", + "InstanceCount": 1, + }, + EBSOptions={ + "EBSEnabled": True, + "VolumeSize": 10, + "VolumeType": "gp2", + }, + ) + snapshot.match("create-response", create_response["DomainStatus"]) + cleanups.append(lambda: aws_client.opensearch.delete_domain(DomainName=domain_name)) + + list_response = aws_client.opensearch.list_domain_names(EngineType="Elasticsearch") + domain_names = [domain["DomainName"] for domain in list_response["DomainNames"]] + + assert domain_name in domain_names + # wait for the cluster + opensearch_wait_for_cluster(domain_name=domain_name) + + domain_status = aws_client.opensearch.describe_domain(DomainName=domain_name)[ + "DomainStatus" + ] + snapshot.match("created-domain", domain_status) + + # updating domain to add iam permission + update_response = aws_client.opensearch.update_domain_config( + DomainName=domain_name, + AccessPolicies=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": account_arn}, + "Action": "es:*", + "Resource": f"{domain_status['ARN']}/*", + } + ], + } + ), + ) + snapshot.match("update-response", update_response["DomainConfig"]) + + # wait for the cluster + opensearch_wait_for_cluster(domain_name=domain_name) + domain_status = aws_client.opensearch.describe_domain(DomainName=domain_name)[ + "DomainStatus" + ] + snapshot.match("updated-domain", domain_status) + + # make sure the plugins are installed (Sort and display component) + plugins_url = f"https://{domain_status['Endpoint']}/_cat/plugins?s=component&h=component" + + if is_aws_cloud(): + plugins_response = http_client.get(plugins_url, headers={"Accept": "application/json"}) + else: + # TODO fix ssl validation error when using the signed request for the elastic search domain + plugins_response = requests.get(plugins_url, headers={"Accept": "application/json"}) + + installed_plugins = {plugin["component"] for plugin in plugins_response.json()} + requested_plugins = set(OPENSEARCH_PLUGIN_LIST) + assert requested_plugins.issubset(installed_plugins) + + delete_response = aws_client.opensearch.delete_domain(DomainName=domain_name) + snapshot.match("delete-response", delete_response["DomainStatus"]) + + @markers.aws.only_localstack + def test_security_plugin(self, opensearch_create_domain, aws_client): + master_user_auth = ("master-user", "1[D3&2S)u9[G") + + # enable the security plugin for this test + advanced_security_options = AdvancedSecurityOptionsInput( + Enabled=True, + InternalUserDatabaseEnabled=True, + MasterUserOptions=MasterUserOptions( + MasterUserName=master_user_auth[0], + MasterUserPassword=master_user_auth[1], + ), + ) + domain_name = opensearch_create_domain(AdvancedSecurityOptions=advanced_security_options) + endpoint = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"][ + "Endpoint" + ] + + # make sure the plugins are installed (Sort and display component) + plugins_url = f"https://{endpoint}/_cat/plugins?s=component&h=component" + + # request without credentials fails + unauthorized_response = requests.get(plugins_url, headers={"Accept": "application/json"}) + assert unauthorized_response.status_code == 401 + + # request with default admin credentials is successful + plugins_response = requests.get( + plugins_url, headers={"Accept": "application/json"}, auth=master_user_auth + ) + assert plugins_response.status_code == 200 + installed_plugins = {plugin["component"] for plugin in plugins_response.json()} + assert "opensearch-security" in installed_plugins + + # create a new index with the admin user + test_index_name = "new-index" + test_index_id = "new-index-id" + test_document = {"test-key": "test-value"} + admin_client = OpenSearch(hosts=endpoint, http_auth=master_user_auth) + admin_client.create(index=test_index_name, id=test_index_id, body={}) + admin_client.index(index=test_index_name, body=test_document) + + # create a new "readall" rolemapping + test_rolemapping = {"backend_roles": ["readall"], "users": []} + response = requests.put( + f"https://{endpoint}/_plugins/_security/api/rolesmapping/readall", + json=test_rolemapping, + auth=master_user_auth, + ) + assert response.status_code == 201 + + # create a new user which is only mapped to the readall role + test_user_auth = ("test_user", "J2j7Gun!30Abvy") + test_user = {"password": test_user_auth[1], "backend_roles": ["readall"]} + response = requests.put( + f"https://{endpoint}/_plugins/_security/api/internalusers/{test_user_auth[0]}", + json=test_user, + auth=master_user_auth, + ) + assert response.status_code == 201 + + # ensure the user can only read but cannot write + test_user_client = OpenSearch(hosts=endpoint, http_auth=test_user_auth) + + def _search(): + search_result = test_user_client.search( + index=test_index_name, body={"query": {"match": {"test-key": "value"}}} + ) + assert "hits" in search_result + assert search_result["hits"]["hits"][0]["_source"] == test_document + + # it might take a bit for the document to be indexed + retry(_search, sleep=0.5, retries=3) + + with pytest.raises(AuthorizationException): + test_user_client.create(index="new-index2", id="new-index-id2", body={}) + + with pytest.raises(AuthorizationException): + test_user_client.index(index=test_index_name, body={"test-key1": "test-value1"}) + + # add the user to the all_access role + rolemappins_patch = [{"op": "add", "path": "/users/-", "value": "test_user"}] + response = requests.patch( + f"https://{endpoint}/_plugins/_security/api/rolesmapping/all_access", + json=rolemappins_patch, + auth=master_user_auth, + ) + assert response.status_code == 200 + + # ensure the user can now write and create a new index + test_user_client.create(index="new-index2", id="new-index-id2", body={}) + test_user_client.index(index=test_index_name, body={"test-key1": "test-value1"}) + + @markers.aws.validated + def test_sql_plugin(self, opensearch_create_domain, aws_client, snapshot, account_id): + master_user_auth = ("admin", "QWERTYuiop123!") + domain_name = f"sql-test-domain-{short_uid()}" + access_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "es:*", + "Resource": f"arn:aws:es:*:{account_id}:domain/{domain_name}/*", + } + ], + } + + # create a domain that works on aws + opensearch_create_domain( + DomainName=domain_name, + EngineVersion=OPENSEARCH_DEFAULT_VERSION, + ClusterConfig=ClusterConfig( + InstanceType=OpenSearchPartitionInstanceType("t3.small.search"), InstanceCount=1 + ), + EBSOptions=EBSOptions(EBSEnabled=True, VolumeType=VolumeType("gp2"), VolumeSize=10), + AdvancedSecurityOptions=AdvancedSecurityOptionsInput( + Enabled=True, + InternalUserDatabaseEnabled=True, + MasterUserOptions=MasterUserOptions( + MasterUserName=master_user_auth[0], + MasterUserPassword=master_user_auth[1], + ), + ), + NodeToNodeEncryptionOptions=NodeToNodeEncryptionOptions(Enabled=True), + EncryptionAtRestOptions=EncryptionAtRestOptions(Enabled=True), + DomainEndpointOptions=DomainEndpointOptions(EnforceHTTPS=True), + AccessPolicies=json.dumps(access_policy), + ) + endpoint = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"][ + "Endpoint" + ] + + # make sure the sql plugin is installed (Sort and display component) + plugins_url = f"https://{endpoint}/_cat/plugins?s=component&h=component" + response = requests.get( + plugins_url, + auth=master_user_auth, + headers={**COMMON_HEADERS, "Accept": "application/json"}, + ) + installed_plugins = {plugin["component"] for plugin in response.json()} + assert "opensearch-sql" in installed_plugins + assert "opensearch-sql" in installed_plugins, "Opensearch sql plugin is not present" + + # data insert preparation for sql query + document = { + "first_name": "Boba", + "last_name": "Fett", + "age": 41, + "about": "I'm just a simple man, trying to make my way in the universe.", + "interests": ["mandalorian armor", "tusken culture"], + } + index = "bountyhunters" + document_path = f"https://{endpoint}/{index}/_doc/1" + response = requests.put( + document_path, + auth=master_user_auth, + data=json.dumps(document), + headers=COMMON_HEADERS, + ) + assert response.ok + + # force the refresh of the index after the document was added, so it can appear in search + response = requests.post( + f"https://{endpoint}/_refresh", auth=master_user_auth, headers=COMMON_HEADERS + ) + assert response.ok + + # ensure sql query returns correct + query = {"query": f"SELECT * FROM {index} WHERE last_name = 'Fett'"} + response = requests.post( + f"https://{endpoint}/_plugins/_sql", + auth=master_user_auth, + data=json.dumps(query), + headers=COMMON_HEADERS, + ) + snapshot.match("sql_query_response", response.json()) + + assert "I'm just a simple man" in response.text, ( + f"query unsuccessful({response.status_code}): {response.text}" + ) + + @markers.aws.validated + def test_create_domain_with_invalid_name(self, aws_client): + with pytest.raises(botocore.exceptions.ClientError) as e: + aws_client.opensearch.create_domain( + DomainName="123abc" + ) # domain needs to start with alphabetic characters + assert e.value.response["Error"]["Code"] == "ValidationException" + + with pytest.raises(botocore.exceptions.ClientError) as e: + aws_client.opensearch.create_domain(DomainName="abc#") # no special characters allowed + assert e.value.response["Error"]["Code"] == "ValidationException" + + @markers.aws.needs_fixing + def test_create_domain_with_invalid_custom_endpoint(self, aws_client): + with pytest.raises(botocore.exceptions.ClientError) as e: + aws_client.opensearch.create_domain( + DomainName="abc", + DomainEndpointOptions={ + "CustomEndpoint": "custom-endpoint", + }, + ) # CustomEndpoint cannot be set without CustomEndpointEnabled + assert e.value.response["Error"]["Code"] == "ValidationException" + + with pytest.raises(botocore.exceptions.ClientError) as e: + aws_client.opensearch.create_domain( + DomainName="abc", + DomainEndpointOptions={ + "CustomEndpointEnabled": True, + }, + ) # CustomEndpointEnabled cannot be set without CustomEndpoint + assert e.value.response["Error"]["Code"] == "ValidationException" + + @markers.aws.validated + def test_exception_header_field(self, aws_client): + """Test if the error response correctly sets the error code in the headers (see #6304).""" + with pytest.raises(botocore.exceptions.ClientError) as e: + # use an invalid domain name to provoke an exception + aws_client.opensearch.create_domain(DomainName="123") + assert ( + e.value.response["ResponseMetadata"]["HTTPHeaders"]["x-amzn-errortype"] + == "ValidationException" + ) + + @markers.aws.needs_fixing + def test_create_existing_domain_causes_exception(self, opensearch_wait_for_cluster, aws_client): + domain_name = f"opensearch-domain-{short_uid()}" + try: + aws_client.opensearch.create_domain(DomainName=domain_name) + with pytest.raises(botocore.exceptions.ClientError) as e: + aws_client.opensearch.create_domain(DomainName=domain_name) + assert e.value.response["Error"]["Code"] == "ResourceAlreadyExistsException" + opensearch_wait_for_cluster(domain_name=domain_name) + finally: + aws_client.opensearch.delete_domain(DomainName=domain_name) + + @markers.aws.needs_fixing + def test_describe_domains(self, opensearch_domain, aws_client): + response = aws_client.opensearch.describe_domains(DomainNames=[opensearch_domain]) + assert len(response["DomainStatusList"]) == 1 + assert response["DomainStatusList"][0]["DomainName"] == opensearch_domain + + @markers.aws.needs_fixing + def test_gzip_responses(self, opensearch_endpoint, monkeypatch): + def send_plain_request(method, url): + """ + It's basically impossible to send a request without an Accept-Encoding header with Python's standard + lib when using http.client. + This function sends a plain request by directly interacting with the HTTPConnection. + """ + parsed = urlparse(url) + connection = HTTPConnection(host=parsed.hostname, port=parsed.port) + connection.putrequest(method, parsed.path, skip_accept_encoding=True) + connection.endheaders() + response = connection.getresponse() + try: + return response, response.read() + finally: + # make sure to close the connectio *after* we've called ``response.read()``. + response.close() + + plain_response, data = send_plain_request("GET", opensearch_endpoint) + assert plain_response.status == 200 + assert "cluster_name" in to_str(data) + + # ensure that requests with the "Accept-Encoding": "gzip" header receive gzip compressed responses + gzip_accept_headers = {"Accept-Encoding": "gzip"} + gzip_response = requests.get(opensearch_endpoint, headers=gzip_accept_headers, stream=True) + # get the raw data, don't let requests decode the response + raw_gzip_data = b"".join( + chunk for chunk in gzip_response.raw.stream(1024, decode_content=False) + ) + # force the gzip decoding here (which would raise an exception if it's not actually gzip) + assert gzip.decompress(raw_gzip_data) + + @markers.aws.needs_fixing + def test_domain_version(self, opensearch_domain, opensearch_create_domain, aws_client): + response = aws_client.opensearch.describe_domain(DomainName=opensearch_domain) + assert "DomainStatus" in response + status = response["DomainStatus"] + assert "EngineVersion" in status + assert status["EngineVersion"] == OPENSEARCH_DEFAULT_VERSION + + @markers.aws.needs_fixing + def test_update_domain_config(self, opensearch_domain, aws_client): + initial_response = aws_client.opensearch.describe_domain_config( + DomainName=opensearch_domain + ) + update_response = aws_client.opensearch.update_domain_config( + DomainName=opensearch_domain, ClusterConfig={"InstanceType": "r4.16xlarge.search"} + ) + final_response = aws_client.opensearch.describe_domain_config(DomainName=opensearch_domain) + + assert ( + initial_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"] + != update_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"] + ) + assert ( + update_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"] + == "r4.16xlarge.search" + ) + assert ( + update_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"] + == final_response["DomainConfig"]["ClusterConfig"]["Options"]["InstanceType"] + ) + + @markers.aws.needs_fixing + def test_create_indices(self, opensearch_endpoint): + indices = ["index1", "index2"] + for index_name in indices: + index_path = f"{opensearch_endpoint}/{index_name}" + requests.put(index_path, headers=COMMON_HEADERS) + endpoint = f"{opensearch_endpoint}/_cat/indices/{index_name}?format=json&pretty" + req = requests.get(endpoint) + assert req.status_code == 200 + req_result = json.loads(req.text) + assert req_result[0]["health"] in ["green", "yellow"] + assert req_result[0]["index"] in indices + + # create a knn index to make sure the knn plugin works + index_path = f"{opensearch_endpoint}/knn" + body = { + "settings": {"index": {"knn": True}}, + "mappings": { + "properties": { + "embedding": { + "type": "knn_vector", + "dimension": 2, + } + } + }, + } + put = requests.put(index_path, headers=COMMON_HEADERS, json=body) + assert put.status_code == 200 + get = requests.get(f"{opensearch_endpoint}/_cat/indices/knn?format=json&pretty") + assert get.status_code == 200 + + # add a document + document_path = f"{opensearch_endpoint}/knn/_doc/test_document" + body = {"embedding": [1, 2]} + put = requests.put(document_path, headers=COMMON_HEADERS, json=body) + assert put.status_code == 201 + + get = requests.get(document_path) + assert get.status_code == 200 + + @markers.aws.needs_fixing + def test_get_document(self, opensearch_document_path): + response = requests.get(opensearch_document_path) + assert "I'm just a simple man" in response.text, ( + f"document not found({response.status_code}): {response.text}" + ) + + @markers.aws.needs_fixing + def test_search(self, opensearch_endpoint, opensearch_document_path): + index = "/".join(opensearch_document_path.split("/")[:-2]) + # force the refresh of the index after the document was added, so it can appear in search + response = requests.post(f"{opensearch_endpoint}/_refresh", headers=COMMON_HEADERS) + assert response.ok + + search = {"query": {"match": {"last_name": "Fett"}}} + response = requests.get(f"{index}/_search", data=json.dumps(search), headers=COMMON_HEADERS) + + assert "I'm just a simple man" in response.text, ( + f"search unsuccessful({response.status_code}): {response.text}" + ) + + @markers.aws.only_localstack + def test_endpoint_strategy_path(self, monkeypatch, opensearch_create_domain, aws_client): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "path") + + domain_name = f"opensearch-domain-{short_uid()}" + + opensearch_create_domain(DomainName=domain_name) + status = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"] + + assert "Endpoint" in status + endpoint = status["Endpoint"] + assert endpoint.endswith(f"/{domain_name}") + + @markers.aws.only_localstack + def test_endpoint_strategy_port(self, monkeypatch, opensearch_create_domain, aws_client): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "port") + + domain_name = f"opensearch-domain-{short_uid()}" + + opensearch_create_domain(DomainName=domain_name) + status = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"] + + assert "Endpoint" in status + endpoint = status["Endpoint"] + parts = endpoint.split(":") + assert parts[0] in (localstack_host().host, "127.0.0.1") + assert int(parts[1]) in range( + config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END + ) + + # testing CloudFormation deployment here to make sure OpenSearch is installed + @markers.aws.needs_fixing + def test_cloudformation_deployment(self, deploy_cfn_template, aws_client): + domain_name = f"domain-{short_uid()}" + deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/opensearch_domain.yaml" + ), + parameters={"OpenSearchDomainName": domain_name}, + ) + + response = aws_client.opensearch.list_domain_names(EngineType="OpenSearch") + domain_names = [domain["DomainName"] for domain in response["DomainNames"]] + assert domain_name in domain_names + + +@markers.skip_offline +class TestEdgeProxiedOpensearchCluster: + @markers.aws.only_localstack + def test_route_through_edge(self): + cluster_id = f"domain-{short_uid()}" + cluster_url = f"{config.internal_service_url()}/{cluster_id}" + arn = f"arn:aws:es:us-east-1:000000000000:domain/{cluster_id}" + cluster = EdgeProxiedOpensearchCluster(cluster_url, arn, CustomEndpoint(True, cluster_url)) + + try: + cluster.start() + assert cluster.wait_is_up(240), "gave up waiting for server" + + response = requests.get(cluster_url) + assert response.ok, f"cluster endpoint returned an error: {response.text}" + assert response.json()["version"]["number"] == "2.11.1" + + response = requests.get(f"{cluster_url}/_cluster/health") + assert response.ok, f"cluster health endpoint returned an error: {response.text}" + assert response.json()["status"] in [ + "red", + "orange", + "yellow", + "green", + ], "expected cluster state to be in a valid state" + + finally: + cluster.shutdown() + + assert poll_condition(lambda: not cluster.is_up(), timeout=240), ( + "gave up waiting for cluster to shut down" + ) + + @markers.aws.only_localstack + def test_custom_endpoint( + self, opensearch_wait_for_cluster, opensearch_create_domain, aws_client + ): + domain_name = f"opensearch-domain-{short_uid()}" + custom_endpoint = "http://localhost:4566/my-custom-endpoint" + domain_endpoint_options = { + "CustomEndpoint": custom_endpoint, + "CustomEndpointEnabled": True, + } + + opensearch_create_domain( + DomainName=domain_name, DomainEndpointOptions=domain_endpoint_options + ) + + response = aws_client.opensearch.describe_domain(DomainName=domain_name) + response_domain_endpoint_options = response["DomainStatus"]["DomainEndpointOptions"] + assert response_domain_endpoint_options["EnforceHTTPS"] is False + assert response_domain_endpoint_options["TLSSecurityPolicy"] + assert response_domain_endpoint_options["CustomEndpointEnabled"] is True + assert response_domain_endpoint_options["CustomEndpoint"] == custom_endpoint + + response = aws_client.opensearch.list_domain_names(EngineType="OpenSearch") + domain_names = [domain["DomainName"] for domain in response["DomainNames"]] + + assert domain_name in domain_names + # wait for the cluster + opensearch_wait_for_cluster(domain_name=domain_name) + response = requests.get(f"{custom_endpoint}/_cluster/health") + assert response.ok + assert response.status_code == 200 + + @markers.aws.only_localstack + def test_custom_endpoint_disabled( + self, opensearch_wait_for_cluster, opensearch_create_domain, aws_client + ): + domain_name = f"opensearch-domain-{short_uid()}" + domain_endpoint_options = { + "CustomEndpointEnabled": False, + } + + opensearch_create_domain( + DomainName=domain_name, DomainEndpointOptions=domain_endpoint_options + ) + + response = aws_client.opensearch.describe_domain(DomainName=domain_name) + response_domain_name = response["DomainStatus"]["DomainName"] + assert domain_name == response_domain_name + + response_domain_endpoint_options = response["DomainStatus"]["DomainEndpointOptions"] + assert response_domain_endpoint_options["EnforceHTTPS"] is False + assert response_domain_endpoint_options["TLSSecurityPolicy"] + assert response_domain_endpoint_options["CustomEndpointEnabled"] is False + assert "CustomEndpoint" not in response_domain_endpoint_options + + endpoint = f"http://{response['DomainStatus']['Endpoint']}" + + # wait for the cluster + opensearch_wait_for_cluster(domain_name=domain_name) + response = requests.get(f"{endpoint}/_cluster/health") + assert response.ok + assert response.status_code == 200 + + +@markers.skip_offline +class TestMultiClusterManager: + @markers.aws.only_localstack + def test_multi_cluster(self, account_id, monkeypatch): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "domain") + monkeypatch.setattr(config, "OPENSEARCH_MULTI_CLUSTER", True) + + manager = MultiClusterManager() + + # create two opensearch domains + domain_key_0 = DomainKey( + domain_name=f"domain-{short_uid()}", + region="us-east-1", + account=account_id, + ) + domain_key_1 = DomainKey( + domain_name=f"domain-{short_uid()}", + region="us-east-1", + account=account_id, + ) + cluster_0 = manager.create(domain_key_0.arn, OPENSEARCH_DEFAULT_VERSION) + cluster_1 = manager.create(domain_key_1.arn, OPENSEARCH_DEFAULT_VERSION) + + try: + # spawn the two clusters + assert cluster_0.wait_is_up(240) + assert cluster_1.wait_is_up(240) + + retry(lambda: try_cluster_health(cluster_0.url), retries=12, sleep=10) + retry(lambda: try_cluster_health(cluster_1.url), retries=12, sleep=10) + + # create an index in cluster_0, wait for it to appear, make sure it's not in cluster_1 + index_url_0 = cluster_0.url + "/my-index?pretty" + index_url_1 = cluster_1.url + "/my-index?pretty" + + response = requests.put(index_url_0) + assert response.ok, f"failed to put index into cluster {cluster_0.url}: {response.text}" + assert poll_condition(lambda: requests.head(index_url_0).ok, timeout=10), ( + "gave up waiting for index" + ) + + assert not requests.head(index_url_1).ok, "index should not appear in second cluster" + + finally: + call_safe(cluster_0.shutdown) + call_safe(cluster_1.shutdown) + + +@markers.skip_offline +class TestMultiplexingClusterManager: + @markers.aws.only_localstack + def test_multiplexing_cluster(self, account_id, monkeypatch): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "domain") + monkeypatch.setattr(config, "OPENSEARCH_MULTI_CLUSTER", False) + + manager = MultiplexingClusterManager() + + # create two opensearch domains + domain_key_0 = DomainKey( + domain_name=f"domain-{short_uid()}", + region="us-east-1", + account=account_id, + ) + domain_key_1 = DomainKey( + domain_name=f"domain-{short_uid()}", + region="us-east-1", + account=account_id, + ) + cluster_0 = manager.create(domain_key_0.arn, OPENSEARCH_DEFAULT_VERSION) + cluster_1 = manager.create(domain_key_1.arn, OPENSEARCH_DEFAULT_VERSION) + + try: + # spawn the two clusters + assert cluster_0.wait_is_up(240) + assert cluster_1.wait_is_up(240) + + retry(lambda: try_cluster_health(cluster_0.url), retries=12, sleep=10) + retry(lambda: try_cluster_health(cluster_1.url), retries=12, sleep=10) + + # create an index in cluster_0, wait for it to appear, make sure it's in cluster_1, too + index_url_0 = cluster_0.url + "/my-index?pretty" + index_url_1 = cluster_1.url + "/my-index?pretty" + + response = requests.put(index_url_0) + assert response.ok, f"failed to put index into cluster {cluster_0.url}: {response.text}" + assert poll_condition(lambda: requests.head(index_url_0).ok, timeout=10), ( + "gave up waiting for index" + ) + + assert requests.head(index_url_1).ok, "index should appear in second cluster" + + finally: + call_safe(cluster_0.shutdown) + call_safe(cluster_1.shutdown) + + +@markers.skip_offline +class TestSingletonClusterManager: + @markers.aws.only_localstack + def test_endpoint_strategy_port_singleton_cluster(self, account_id, monkeypatch): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "port") + monkeypatch.setattr(config, "OPENSEARCH_MULTI_CLUSTER", False) + + manager = SingletonClusterManager() + + # create two opensearch domains + domain_key_0 = DomainKey( + domain_name=f"domain-{short_uid()}", + region="us-east-1", + account=account_id, + ) + domain_key_1 = DomainKey( + domain_name=f"domain-{short_uid()}", + region="us-east-1", + account=account_id, + ) + cluster_0 = manager.create(domain_key_0.arn, OPENSEARCH_DEFAULT_VERSION) + cluster_1 = manager.create(domain_key_1.arn, OPENSEARCH_DEFAULT_VERSION) + + # check if the first port url matches the port range + + parts = cluster_0.url.split(":") + assert parts[0] == "http" + # either f"//{the bind host}" is used, or in the case of "//0.0.0.0" the localstack hostname instead + assert parts[1][2:] in [config.GATEWAY_LISTEN[0].host, localstack_host().host] + assert int(parts[2]) in range( + config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END + ) + + # check if the second url matches the first one + assert cluster_0.url == cluster_1.url + + try: + # wait for the two clusters + assert cluster_0.wait_is_up(240) + # make sure cluster_0 (which is equal to cluster_1) is reachable + retry(lambda: try_cluster_health(cluster_0.url), retries=3, sleep=5) + finally: + call_safe(cluster_0.shutdown) + call_safe(cluster_1.shutdown) + + +@markers.skip_offline +class TestCustomBackendManager: + @markers.aws.only_localstack + def test_custom_backend(self, account_id, httpserver, monkeypatch): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "domain") + monkeypatch.setattr(config, "OPENSEARCH_CUSTOM_BACKEND", httpserver.url_for("/")) + + # create fake elasticsearch cluster + httpserver.expect_request("/").respond_with_json( + { + "name": "om", + "cluster_name": "opensearch", + "cluster_uuid": "gREewvVZR0mIswR-8-6VRQ", + "version": { + "number": "7.10.0", + "build_flavor": "default", + "build_type": "tar", + "build_hash": "51e9d6f22758d0374a0f3f5c6e8f3a7997850f96", + "build_date": "2020-11-09T21:30:33.964949Z", + "build_snapshot": False, + "lucene_version": "8.7.0", + "minimum_wire_compatibility_version": "6.8.0", + "minimum_index_compatibility_version": "6.0.0-beta1", + }, + "tagline": "You Know, for Search", + } + ) + httpserver.expect_request("/_cluster/health").respond_with_json( + { + "cluster_name": "opensearch", + "status": "green", + "timed_out": False, + "number_of_nodes": 1, + "number_of_data_nodes": 1, + "active_primary_shards": 0, + "active_shards": 0, + "relocating_shards": 0, + "initializing_shards": 0, + "unassigned_shards": 0, + "delayed_unassigned_shards": 0, + "number_of_pending_tasks": 0, + "number_of_in_flight_fetch": 0, + "task_max_waiting_in_queue_millis": 0, + "active_shards_percent_as_number": 100, + } + ) + + manager = create_cluster_manager() + assert isinstance(manager, CustomBackendManager) + + domain_key = DomainKey( + domain_name=f"domain-{short_uid()}", + region="us-east-1", + account=account_id, + ) + cluster = manager.create(domain_key.arn, OPENSEARCH_DEFAULT_VERSION) + # check that we're using the domain endpoint strategy + assert f"{domain_key.domain_name}." in cluster.url + + try: + assert cluster.wait_is_up(10) + retry(lambda: try_cluster_health(cluster.url), retries=3, sleep=5) + + finally: + call_safe(cluster.shutdown) + + httpserver.check() + + @markers.aws.only_localstack + def test_custom_backend_with_custom_endpoint( + self, + httpserver, + monkeypatch, + opensearch_wait_for_cluster, + opensearch_create_domain, + aws_client, + ): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "domain") + monkeypatch.setattr(config, "OPENSEARCH_CUSTOM_BACKEND", httpserver.url_for("/")) + # reset the singleton for the test + monkeypatch.setattr(provider, "__CLUSTER_MANAGER", None) + + # create fake elasticsearch cluster + custom_name = "my_very_special_custom_backend" + httpserver.expect_request("/").respond_with_json( + { + "name": custom_name, + "status": "green", + } + ) + httpserver.expect_request("/_cluster/health").respond_with_json( + { + "name_health": custom_name, + "status": "green", + } + ) + domain_name = f"opensearch-domain-{short_uid()}" + custom_endpoint = "http://localhost:4566/my-custom-endpoint" + domain_endpoint_options = { + "CustomEndpoint": custom_endpoint, + "CustomEndpointEnabled": True, + } + opensearch_create_domain( + DomainName=domain_name, DomainEndpointOptions=domain_endpoint_options + ) + response = aws_client.opensearch.list_domain_names(EngineType="OpenSearch") + domain_names = [domain["DomainName"] for domain in response["DomainNames"]] + + assert domain_name in domain_names + + opensearch_wait_for_cluster(domain_name=domain_name) + + response = requests.get(f"{custom_endpoint}/") + assert response.ok + assert custom_name in response.text + response = requests.get(f"{custom_endpoint}/_cluster/health") + assert response.ok + assert custom_name in response.text diff --git a/tests/aws/services/opensearch/test_opensearch.snapshot.json b/tests/aws/services/opensearch/test_opensearch.snapshot.json new file mode 100644 index 0000000000000..61a80b9be103f --- /dev/null +++ b/tests/aws/services/opensearch/test_opensearch.snapshot.json @@ -0,0 +1,1180 @@ +{ + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_get_compatible_versions": { + "recorded-date": "16-07-2024, 13:05:15", + "recorded-content": { + "source_versions": [ + "Elasticsearch_5.1", + "Elasticsearch_5.3", + "Elasticsearch_5.5", + "Elasticsearch_5.6", + "Elasticsearch_6.0", + "Elasticsearch_6.2", + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8", + "Elasticsearch_7.1", + "Elasticsearch_7.10", + "Elasticsearch_7.4", + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + "OpenSearch_2.11", + "OpenSearch_2.3", + "OpenSearch_2.5", + "OpenSearch_2.7", + "OpenSearch_2.9" + ], + "source_elasticsearch_5.1": [ + "Elasticsearch_5.6" + ], + "source_elasticsearch_5.3": [ + "Elasticsearch_5.6" + ], + "source_elasticsearch_5.5": [ + "Elasticsearch_5.6" + ], + "source_elasticsearch_5.6": [ + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8" + ], + "source_elasticsearch_6.0": [ + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8" + ], + "source_elasticsearch_6.2": [ + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8" + ], + "source_elasticsearch_6.3": [ + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8" + ], + "source_elasticsearch_6.4": [ + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8" + ], + "source_elasticsearch_6.5": [ + "Elasticsearch_6.7", + "Elasticsearch_6.8" + ], + "source_elasticsearch_6.7": [ + "Elasticsearch_6.8" + ], + "source_elasticsearch_6.8": [ + "Elasticsearch_7.1", + "Elasticsearch_7.4", + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_elasticsearch_7.1": [ + "Elasticsearch_7.4", + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_elasticsearch_7.10": [ + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_elasticsearch_7.4": [ + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_elasticsearch_7.7": [ + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_elasticsearch_7.8": [ + "Elasticsearch_7.9", + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_elasticsearch_7.9": [ + "Elasticsearch_7.10", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_opensearch_1.0": [ + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_opensearch_1.1": [ + "OpenSearch_1.2", + "OpenSearch_1.3" + ], + "source_opensearch_1.2": [ + "OpenSearch_1.3" + ], + "source_opensearch_1.3": [ + "OpenSearch_2.3", + "OpenSearch_2.5", + "OpenSearch_2.7", + "OpenSearch_2.9", + "OpenSearch_2.11", + "OpenSearch_2.13" + ], + "source_opensearch_2.11": [ + "OpenSearch_2.13" + ], + "source_opensearch_2.3": [ + "OpenSearch_2.5", + "OpenSearch_2.7", + "OpenSearch_2.9", + "OpenSearch_2.11", + "OpenSearch_2.13" + ], + "source_opensearch_2.5": [ + "OpenSearch_2.7", + "OpenSearch_2.9", + "OpenSearch_2.11", + "OpenSearch_2.13" + ], + "source_opensearch_2.7": [ + "OpenSearch_2.9", + "OpenSearch_2.11", + "OpenSearch_2.13" + ], + "source_opensearch_2.9": [ + "OpenSearch_2.11", + "OpenSearch_2.13" + ] + } + }, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_list_versions": { + "recorded-date": "16-07-2024, 13:18:18", + "recorded-content": { + "versions": [ + "Elasticsearch_5.1", + "Elasticsearch_5.3", + "Elasticsearch_5.5", + "Elasticsearch_5.6", + "Elasticsearch_6.0", + "Elasticsearch_6.2", + "Elasticsearch_6.3", + "Elasticsearch_6.4", + "Elasticsearch_6.5", + "Elasticsearch_6.7", + "Elasticsearch_6.8", + "Elasticsearch_7.1", + "Elasticsearch_7.10", + "Elasticsearch_7.4", + "Elasticsearch_7.7", + "Elasticsearch_7.8", + "Elasticsearch_7.9", + "OpenSearch_1.0", + "OpenSearch_1.1", + "OpenSearch_1.2", + "OpenSearch_1.3", + "OpenSearch_2.11", + "OpenSearch_2.13", + "OpenSearch_2.3", + "OpenSearch_2.5", + "OpenSearch_2.7", + "OpenSearch_2.9" + ] + } + }, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_sql_plugin": { + "recorded-date": "03-12-2024, 21:07:16", + "recorded-content": { + "sql_plugin_installed": true, + "sql_query_response": { + "datarows": [ + [ + "I'm just a simple man, trying to make my way in the universe.", + "Fett", + "mandalorian armor", + "Boba", + 41 + ] + ], + "schema": [ + { + "name": "about", + "type": "text" + }, + { + "name": "last_name", + "type": "text" + }, + { + "name": "interests", + "type": "text" + }, + { + "name": "first_name", + "type": "text" + }, + { + "name": "age", + "type": "long" + } + ], + "size": 1, + "status": 200, + "total": 1 + } + } + }, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_domain_lifecycle": { + "recorded-date": "07-07-2025, 23:26:58", + "recorded-content": { + "account-arn": "", + "create-response": { + "AIMLOptions": { + "NaturalLanguageQueryGenerationOptions": { + "CurrentState": "NOT_ENABLED", + "DesiredState": "DISABLED" + } + }, + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": "", + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "DISABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "Pending", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "", + "StartTime": "" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "t2.small.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-2-2019-07" + }, + "DomainId": "111111111111/", + "DomainName": "", + "DomainProcessingStatus": "Creating", + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "EngineVersion": "Elasticsearch_7.10", + "IPAddressType": "ipv4", + "IdentityCenterOptions": {}, + "ModifyingProperties": [ + { + "ActiveValue": "", + "Name": "AIMLOptions.NaturalLanguageQueryGenerationOptions", + "PendingValue": { + "CurrentState": "NOT_ENABLED", + "DesiredState": "DISABLED" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "AdvancedOptions", + "PendingValue": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.AnonymousAuthDisableDate", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.AnonymousAuthEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.InternalUserDatabaseEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.JWTOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.MasterUserOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "AdvancedSecurityOptions.SAMLOptions", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.ColdStorageOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.DedicatedMasterCount", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.DedicatedMasterEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.DedicatedMasterType", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.InstanceCount", + "PendingValue": "1", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.InstanceType", + "PendingValue": "t2.small.search", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.MultiAZWithStandbyEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.WarmCount", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.WarmEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.WarmStorage", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.WarmType", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "ClusterConfig.ZoneAwarenessEnabled", + "PendingValue": "false", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "EngineVersion", + "PendingValue": "Elasticsearch_7.10", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "IPAddressType", + "PendingValue": "ipv4", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "TAGS", + "PendingValue": "", + "ValueType": "PLAIN_TEXT" + }, + { + "ActiveValue": "", + "Name": "DomainEndpointOptions", + "PendingValue": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-2-2019-07" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "EBSOptions", + "PendingValue": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "EncryptionAtRestOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "NodeToNodeEncryptionOptions", + "PendingValue": { + "Enabled": false + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "OffPeakWindowOptions", + "PendingValue": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "SnapshotOptions", + "PendingValue": { + "AutomatedSnapshotStartHour": 0 + }, + "ValueType": "STRINGIFIED_JSON" + }, + { + "ActiveValue": "", + "Name": "SoftwareUpdateOptions", + "PendingValue": { + "AutoSoftwareUpdateEnabled": false + }, + "ValueType": "STRINGIFIED_JSON" + } + ], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": true, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "", + "Cancellable": false, + "CurrentVersion": "", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + }, + "created-domain": { + "AIMLOptions": { + "NaturalLanguageQueryGenerationOptions": { + "CurrentState": "NOT_ENABLED", + "DesiredState": "DISABLED" + } + }, + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": "", + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "DISABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "Completed", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "", + "StartTime": "" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "t2.small.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-2-2019-07" + }, + "DomainId": "111111111111/", + "DomainName": "", + "DomainProcessingStatus": "Active", + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "", + "EngineVersion": "Elasticsearch_7.10", + "IPAddressType": "ipv4", + "IdentityCenterOptions": {}, + "ModifyingProperties": [], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "", + "Cancellable": false, + "CurrentVersion": "Elasticsearch_7_10_R20250625", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + }, + "update-response": { + "AIMLOptions": { + "Options": { + "NaturalLanguageQueryGenerationOptions": { + "CurrentState": "NOT_ENABLED", + "DesiredState": "DISABLED" + } + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 10 + } + }, + "AccessPolicies": { + "Options": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "" + }, + "Action": "es:*", + "Resource": "arn::es::111111111111:domain//*" + } + ] + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Processing", + "UpdateDate": "", + "UpdateVersion": 10 + } + }, + "AdvancedOptions": { + "Options": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "AdvancedSecurityOptions": { + "Options": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "AutoTuneOptions": { + "Options": { + "DesiredState": "DISABLED", + "MaintenanceSchedules": [], + "RollbackOnDisable": "NO_ROLLBACK", + "UseOffPeakWindow": false + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "DISABLED", + "UpdateDate": "", + "UpdateVersion": 10 + } + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "Pending", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "", + "StartTime": "" + }, + "ClusterConfig": { + "Options": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "t2.small.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "CognitoOptions": { + "Options": { + "Enabled": false + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 10 + } + }, + "DomainEndpointOptions": { + "Options": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-2-2019-07" + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "EBSOptions": { + "Options": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "EncryptionAtRestOptions": { + "Options": { + "Enabled": false + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "EngineVersion": { + "Options": "Elasticsearch_7.10", + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "IPAddressType": { + "Options": "ipv4", + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "IdentityCenterOptions": { + "Options": {}, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 10 + } + }, + "LogPublishingOptions": { + "Options": {}, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 10 + } + }, + "ModifyingProperties": [ + { + "ActiveValue": "", + "Name": "AccessPolicies", + "PendingValue": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "111111111111" + }, + "Action": "es:*", + "Resource": "arn::es::111111111111:domain//*" + } + ] + }, + "ValueType": "STRINGIFIED_JSON" + } + ], + "NodeToNodeEncryptionOptions": { + "Options": { + "Enabled": false + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "OffPeakWindowOptions": { + "Options": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "SnapshotOptions": { + "Options": { + "AutomatedSnapshotStartHour": 0 + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "SoftwareUpdateOptions": { + "Options": { + "AutoSoftwareUpdateEnabled": false + }, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 9 + } + }, + "VPCOptions": { + "Options": {}, + "Status": { + "CreationDate": "", + "PendingDeletion": false, + "State": "Active", + "UpdateDate": "", + "UpdateVersion": 10 + } + } + }, + "updated-domain": { + "AIMLOptions": { + "NaturalLanguageQueryGenerationOptions": { + "CurrentState": "NOT_ENABLED", + "DesiredState": "DISABLED" + } + }, + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "" + }, + "Action": "es:*", + "Resource": "arn::es::111111111111:domain//*" + } + ] + }, + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "DISABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "Completed", + "InitiatedBy": "CUSTOMER", + "LastUpdatedTime": "", + "StartTime": "" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "t2.small.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": false, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-2-2019-07" + }, + "DomainId": "111111111111/", + "DomainName": "", + "DomainProcessingStatus": "Active", + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "", + "EngineVersion": "Elasticsearch_7.10", + "IPAddressType": "ipv4", + "IdentityCenterOptions": {}, + "ModifyingProperties": [], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": false, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "", + "Cancellable": false, + "CurrentVersion": "Elasticsearch_7_10_R20250625", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + }, + "delete-response": { + "AIMLOptions": { + "NaturalLanguageQueryGenerationOptions": { + "CurrentState": "NOT_ENABLED", + "DesiredState": "DISABLED" + } + }, + "ARN": "arn::es::111111111111:domain/", + "AccessPolicies": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "" + }, + "Action": "es:*", + "Resource": "arn::es::111111111111:domain//*" + } + ] + }, + "AdvancedOptions": { + "override_main_response_version": "false", + "rest.action.multi.allow_explicit_index": "true" + }, + "AdvancedSecurityOptions": { + "AnonymousAuthEnabled": false, + "Enabled": false, + "InternalUserDatabaseEnabled": false + }, + "AutoTuneOptions": { + "State": "DISABLED", + "UseOffPeakWindow": false + }, + "ChangeProgressDetails": { + "ChangeId": "", + "ConfigChangeStatus": "ApplyingChanges", + "LastUpdatedTime": "", + "StartTime": "" + }, + "ClusterConfig": { + "ColdStorageOptions": { + "Enabled": false + }, + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "t2.small.search", + "MultiAZWithStandbyEnabled": false, + "WarmEnabled": false, + "ZoneAwarenessEnabled": false + }, + "CognitoOptions": { + "Enabled": false + }, + "Created": true, + "Deleted": true, + "DomainEndpointOptions": { + "CustomEndpointEnabled": false, + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-2-2019-07" + }, + "DomainId": "111111111111/", + "DomainName": "", + "DomainProcessingStatus": "Deleting", + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "Endpoint": "", + "EngineVersion": "Elasticsearch_7.10", + "IPAddressType": "ipv4", + "IdentityCenterOptions": {}, + "ModifyingProperties": [], + "NodeToNodeEncryptionOptions": { + "Enabled": false + }, + "OffPeakWindowOptions": { + "Enabled": true, + "OffPeakWindow": { + "WindowStartTime": { + "Hours": 2, + "Minutes": 0 + } + } + }, + "Processing": true, + "ServiceSoftwareOptions": { + "AutomatedUpdateDate": "", + "Cancellable": false, + "CurrentVersion": "Elasticsearch_7_10_R20250625", + "Description": "There is no software update available for this domain.", + "NewVersion": "", + "OptionalDeployment": true, + "UpdateAvailable": false, + "UpdateStatus": "COMPLETED" + }, + "SnapshotOptions": { + "AutomatedSnapshotStartHour": 0 + }, + "SoftwareUpdateOptions": { + "AutoSoftwareUpdateEnabled": false + }, + "UpgradeProcessing": false + } + } + } +} diff --git a/tests/aws/services/opensearch/test_opensearch.validation.json b/tests/aws/services/opensearch/test_opensearch.validation.json new file mode 100644 index 0000000000000..e7cbb17ad097b --- /dev/null +++ b/tests/aws/services/opensearch/test_opensearch.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_domain_lifecycle": { + "last_validated_date": "2025-07-07T23:26:58+00:00", + "durations_in_seconds": { + "setup": 0.53, + "call": 839.91, + "teardown": 0.25, + "total": 840.69 + } + }, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_get_compatible_versions": { + "last_validated_date": "2024-07-16T13:05:15+00:00" + }, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_list_versions": { + "last_validated_date": "2024-07-16T13:18:18+00:00" + }, + "tests/aws/services/opensearch/test_opensearch.py::TestOpensearchProvider::test_sql_plugin": { + "last_validated_date": "2024-12-03T21:07:16+00:00" + } +} diff --git a/tests/aws/services/redshift/__init__.py b/tests/aws/services/redshift/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/redshift/test_redshift.py b/tests/aws/services/redshift/test_redshift.py new file mode 100644 index 0000000000000..1b242080bb1e0 --- /dev/null +++ b/tests/aws/services/redshift/test_redshift.py @@ -0,0 +1,78 @@ +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid +from localstack.utils.sync import retry + + +class TestRedshift: + # only runs in Docker when run against Pro (since it needs postgres on the system) + @markers.only_in_docker + @markers.aws.validated + def test_create_clusters(self, aws_client): + # create + cluster_id = f"c-{short_uid()}" + response = aws_client.redshift.create_cluster( + ClusterIdentifier=cluster_id, + NodeType="ra3.xlplus", + MasterUsername="test", + MasterUserPassword="testABc123", + NumberOfNodes=2, + PubliclyAccessible=False, + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # describe + clusters = aws_client.redshift.describe_clusters()["Clusters"] + matching = [c for c in clusters if c["ClusterIdentifier"] == cluster_id] + assert matching + + # wait until available + def check_running(): + result = aws_client.redshift.describe_clusters()["Clusters"] + status = result[0].get("ClusterStatus") + assert status == "available" + return result[0] + + retries = 500 if is_aws_cloud() else 60 + sleep = 30 if is_aws_cloud() else 1 + retry(check_running, sleep=sleep, retries=retries) + + # delete + response = aws_client.redshift.delete_cluster( + ClusterIdentifier=cluster_id, SkipFinalClusterSnapshot=True + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # assert that cluster deleted + def check_deleted(): + with pytest.raises(Exception) as e: + aws_client.redshift.describe_clusters(ClusterIdentifier=cluster_id) + assert "ClusterNotFound" in str(e) + + retry(check_deleted, sleep=sleep, retries=retries) + + @markers.aws.manual_setup_required + def test_cluster_security_groups(self, snapshot, aws_client): + # Note: AWS parity testing not easily possible with our account, due to error message + # "VPC-by-Default customers cannot use cluster security groups" + + group_name = f"g-{short_uid()}" + aws_client.redshift.create_cluster_security_group( + ClusterSecurityGroupName=group_name, Description="test 123" + ) + + cidr_ip = "192.168.100.101/32" + aws_client.redshift.authorize_cluster_security_group_ingress( + ClusterSecurityGroupName=group_name, CIDRIP=cidr_ip + ) + + result = aws_client.redshift.describe_cluster_security_groups( + ClusterSecurityGroupName=group_name + ) + groups = result.get("ClusterSecurityGroups", []) + assert len(groups) == 1 + assert groups[0].get("IPRanges") + assert groups[0]["IPRanges"][0]["Status"] == "authorized" + assert groups[0]["IPRanges"][0]["CIDRIP"] == cidr_ip diff --git a/tests/aws/services/redshift/test_redshift.validation.json b/tests/aws/services/redshift/test_redshift.validation.json new file mode 100644 index 0000000000000..0b83be4ae9793 --- /dev/null +++ b/tests/aws/services/redshift/test_redshift.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/redshift/test_redshift.py::TestRedshift::test_create_clusters": { + "last_validated_date": "2024-05-29T17:46:32+00:00" + } +} diff --git a/tests/aws/services/resource_groups/__init__.py b/tests/aws/services/resource_groups/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/resource_groups/test_resource_groups.py b/tests/aws/services/resource_groups/test_resource_groups.py new file mode 100644 index 0000000000000..c9fa7a10163be --- /dev/null +++ b/tests/aws/services/resource_groups/test_resource_groups.py @@ -0,0 +1,388 @@ +import contextlib +import json +import os + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid +from localstack.utils.sync import retry + + +@pytest.fixture +def resourcegroups_create_group(aws_client): + groups = [] + + def _create_group(**kwargs): + response = aws_client.resource_groups.create_group(**kwargs) + groups.append(response["Group"]["Name"]) + return response + + yield _create_group + + for group_name in groups: + with contextlib.suppress( + ClientError, KeyError + ): # adding KeyError to the list because Moto has a bug + aws_client.resource_groups.delete_group(GroupName=group_name) + + +@pytest.fixture +def sqs_create_queue_in_region(aws_client_factory): + region_queue_urls = {} + + def factory(region, **kwargs): + if "QueueName" not in kwargs: + kwargs["QueueName"] = "test-queue-%s" % short_uid() + response = aws_client_factory(region_name=region).sqs.create_queue(**kwargs) + url = response["QueueUrl"] + region_queue_urls.setdefault(region, []).append(url) + + return url + + yield factory + + # cleanup + for queues_region, queue_urls in region_queue_urls.items(): + sqs_client = aws_client_factory(region_name=queues_region).sqs + for queue_url in queue_urls: + with contextlib.suppress(ClientError): + sqs_client.delete_queue(QueueUrl=queue_url) + + +@pytest.fixture(autouse=True) +def resource_groups_snapshot_transformers(snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Name"), + snapshot.transform.key_value("NextToken"), + ] + ) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..GroupArn", # Moto is always returning the region as `us-west-1`, seems to be hard-coded + "$..GroupConfiguration", + "$..NextToken", + ] +) +class TestResourceGroups: + """ + List of Services integrations with resourcegroups + see: https://docs.aws.amazon.com/ARG/latest/userguide/integrated-services-list.html + List of supported resources: + see: https://docs.aws.amazon.com/ARG/latest/userguide/supported-resources.html + """ + + @markers.aws.validated + def test_create_group(self, aws_client, resourcegroups_create_group, snapshot): + name = f"resource_group-{short_uid()}" + response = resourcegroups_create_group( + Name=name, + Description="description", + ResourceQuery={ + "Type": "TAG_FILTERS_1_0", + "Query": json.dumps( + { + "ResourceTypeFilters": ["AWS::AllSupported"], + "TagFilters": [ + { + "Key": "resources_tag_key", + "Values": ["resources_tag_value"], + } + ], + } + ), + }, + Tags={"resource_group_tag_key": "resource_group_tag_value"}, + ) + snapshot.match("create-group", response) + assert name == response["Group"]["Name"] + assert "TAG_FILTERS_1_0" == response["ResourceQuery"]["Type"] + assert "resource_group_tag_value" == response["Tags"]["resource_group_tag_key"] + + response = aws_client.resource_groups.get_group(GroupName=name) + snapshot.match("get-group", response) + assert "description" == response["Group"]["Description"] + + response = aws_client.resource_groups.list_groups() + snapshot.match("list-groups", response) + assert 1 == len(response["GroupIdentifiers"]) + assert 1 == len(response["Groups"]) + + response = aws_client.resource_groups.delete_group(GroupName=name) + snapshot.match("delete-group", response) + assert name == response["Group"]["Name"] + + response = aws_client.resource_groups.list_groups() + snapshot.match("list-groups-after-delete", response) + assert 0 == len(response["GroupIdentifiers"]) + assert 0 == len(response["Groups"]) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Not implemented in moto (ListGroupResources)" + ) + def test_resource_groups_tag_query( + self, aws_client, snapshot, resourcegroups_create_group, s3_bucket, sqs_create_queue + ): + snapshot.add_transformer(snapshot.transform.resource_name()) + group_name = f"resource_group-{short_uid()}" + response = resourcegroups_create_group( + Name=group_name, + Description="test-tag-query", + ResourceQuery={ + "Type": "TAG_FILTERS_1_0", + "Query": json.dumps( + { + "ResourceTypeFilters": ["AWS::AllSupported"], + "TagFilters": [ + { + "Key": "Stage", + "Values": ["test-resource-group"], + } + ], + } + ), + }, + Tags={"GroupTag": "GroupTag1"}, + ) + snapshot.match("create-group", response) + + response = aws_client.resource_groups.list_group_resources(Group=group_name) + snapshot.match("list-group-resources-empty", response) + + # create SQS queue + tagged_queue_url = sqs_create_queue() + # tag queue + tags = {"Stage": "test-resource-group"} + aws_client.sqs.tag_queue(QueueUrl=tagged_queue_url, Tags=tags) + + aws_client.s3.put_bucket_tagging( + Bucket=s3_bucket, Tagging={"TagSet": [{"Key": "Stage", "Value": "test-resource-group"}]} + ) + + not_tagged_queue_url = sqs_create_queue() + tags = {"Stage": "not-part-resource-group"} + aws_client.sqs.tag_queue(QueueUrl=not_tagged_queue_url, Tags=tags) + + response = aws_client.resource_groups.list_group_resources(Group=group_name) + snapshot.match("list-group-resources", response) + + queue_tags = aws_client.sqs.list_queue_tags(QueueUrl=tagged_queue_url) + snapshot.match("get-queue-tags", queue_tags) + + aws_client.sqs.untag_queue(QueueUrl=tagged_queue_url, TagKeys=["Stage"]) + + def _get_group_resources(): + _response = aws_client.resource_groups.list_group_resources(Group=group_name) + assert len(response["Resources"]) == 1 + return _response + + response = retry(_get_group_resources, retries=3, sleep=1) + snapshot.match("list-group-resources-after-queue-removal", response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Not implemented in moto (ListGroupResources)" + ) + def test_resource_groups_different_region( + self, + aws_client_factory, + snapshot, + resourcegroups_create_group, + sqs_create_queue_in_region, + region_name, + ): + """Resource groups can only have resources from the same Region, the one of the group""" + region_1 = region_name + region_2 = "us-east-2" + resourcegroups_client = aws_client_factory(region_name=region_1).resource_groups + snapshot.add_transformer(snapshot.transform.resource_name()) + group_name = f"resource_group-{short_uid()}" + response = resourcegroups_create_group( + Name=group_name, + Description="test-tag-query", + ResourceQuery={ + "Type": "TAG_FILTERS_1_0", + "Query": json.dumps( + { + "ResourceTypeFilters": ["AWS::AllSupported"], + "TagFilters": [ + { + "Key": "Stage", + "Values": ["test-resource-group"], + } + ], + } + ), + }, + Tags={"GroupTag": "GroupTag1"}, + ) + snapshot.match("create-group", response) + + # create 2 SQS queues in different regions with tags + tags = {"Stage": "test-resource-group"} + sqs_create_queue_in_region(region=region_1, tags=tags) + sqs_create_queue_in_region(region=region_2, tags=tags) + + response = resourcegroups_client.list_group_resources(Group=group_name) + snapshot.match("list-group-resources", response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Not implemented in moto (ListGroupResources)" + ) + def test_resource_type_filters( + self, aws_client, snapshot, resourcegroups_create_group, s3_bucket, sqs_create_queue + ): + """Resource group can filter with a ResourceType, like `AWS::S3::Bucket`""" + snapshot.add_transformer(snapshot.transform.resource_name()) + group_name = f"resource_group-{short_uid()}" + response = resourcegroups_create_group( + Name=group_name, + Description="test-tag-query", + ResourceQuery={ + "Type": "TAG_FILTERS_1_0", + "Query": json.dumps( + { + "ResourceTypeFilters": ["AWS::S3::Bucket"], + "TagFilters": [ + { + "Key": "Stage", + "Values": ["test-resource-group"], + } + ], + } + ), + }, + Tags={"GroupTag": "GroupTag1"}, + ) + snapshot.match("create-group", response) + + # create SQS queue with tags + sqs_create_queue(tags={"Stage": "test-resource-group"}) + + aws_client.s3.put_bucket_tagging( + Bucket=s3_bucket, Tagging={"TagSet": [{"Key": "Stage", "Value": "test-resource-group"}]} + ) + + response = aws_client.resource_groups.list_group_resources(Group=group_name) + snapshot.match("list-group-resources", response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Not implemented in moto (ListGroupResources)" + ) + def test_cloudformation_query( + self, aws_client, deploy_cfn_template, snapshot, resourcegroups_create_group + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("StackIdentifier"), + snapshot.transform.resource_name(), + ] + ) + stack = deploy_cfn_template( + template_path=os.path.join( + os.path.dirname(__file__), "../../templates/deploy_template_2.yaml" + ), + parameters={"CompanyName": "MyCompany", "MyEmail1": "my@email.com"}, + ) + assert len(stack.outputs) == 3 + topic_arn = stack.outputs["MyTopic"] + + group_name = f"resource_group-{short_uid()}" + response = resourcegroups_create_group( + Name=group_name, + Description="test-cfn-query", + ResourceQuery={ + "Type": "CLOUDFORMATION_STACK_1_0", + "Query": json.dumps( + { + "ResourceTypeFilters": ["AWS::AllSupported"], + "StackIdentifier": stack.stack_id, + } + ), + }, + ) + snapshot.match("create-group", response) + + response = aws_client.resource_groups.list_group_resources(Group=group_name) + snapshot.match("list-group-resources", response) + + assert topic_arn in [ + resource["ResourceArn"] for resource in response["ResourceIdentifiers"] + ] + + stack.destroy() + + response = aws_client.resource_groups.list_group_resources(Group=group_name) + snapshot.match("list-group-resources-after-destroy", response) + + with pytest.raises(ClientError) as e: + resourcegroups_create_group( + Name="going-to-fail", + Description="test-cfn-query", + ResourceQuery={ + "Type": "CLOUDFORMATION_STACK_1_0", + "Query": json.dumps( + { + "ResourceTypeFilters": ["AWS::AllSupported"], + "StackIdentifier": stack.stack_id, + } + ), + }, + ) + snapshot.match("create-group-with-delete-stack", e.value.response) + + @markers.aws.validated + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Not implemented in moto (SearchResources)" + ) + def test_search_resources(self, aws_client, sqs_create_queue, snapshot): + snapshot.add_transformer(snapshot.transform.resource_name()) + # create SQS queue with tags + queue_url = sqs_create_queue(tags={"Stage": "test-resource-group"}) + queue_tags = aws_client.sqs.list_queue_tags(QueueUrl=queue_url) + snapshot.match("queue-tags", queue_tags) + + def _get_resources(resource_types: list[str], expected: int): + _response = aws_client.resource_groups.search_resources( + ResourceQuery={ + "Type": "TAG_FILTERS_1_0", + "Query": json.dumps( + { + "ResourceTypeFilters": resource_types, + "TagFilters": [ + { + "Key": "Stage", + "Values": ["test-resource-group"], + } + ], + } + ), + } + ) + assert len(_response["ResourceIdentifiers"]) == expected + return _response + + retries = 10 if is_aws_cloud() else 3 + sleep = 1 if is_aws_cloud() else 0.1 + + response = retry( + _get_resources, + resource_types=["AWS::AllSupported"], + expected=1, + retries=retries, + sleep=sleep, + ) + snapshot.match("list-group-resources-sqs", response) + + response = retry( + _get_resources, resource_types=["AWS::S3::Bucket"], expected=0, retries=1, sleep=1 + ) + snapshot.match("list-group-resources-s3", response) diff --git a/tests/aws/services/resource_groups/test_resource_groups.snapshot.json b/tests/aws/services/resource_groups/test_resource_groups.snapshot.json new file mode 100644 index 0000000000000..7dcbee6912f24 --- /dev/null +++ b/tests/aws/services/resource_groups/test_resource_groups.snapshot.json @@ -0,0 +1,411 @@ +{ + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_create_group": { + "recorded-date": "13-09-2023, 17:21:22", + "recorded-content": { + "create-group": { + "Group": { + "Description": "description", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + }, + "ResourceQuery": { + "Query": { + "ResourceTypeFilters": [ + "AWS::AllSupported" + ], + "TagFilters": [ + { + "Key": "resources_tag_key", + "Values": [ + "resources_tag_value" + ] + } + ] + }, + "Type": "TAG_FILTERS_1_0" + }, + "Tags": { + "resource_group_tag_key": "resource_group_tag_value" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-group": { + "Group": { + "Description": "description", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-groups": { + "GroupIdentifiers": [ + { + "GroupArn": "arn::resource-groups::111111111111:group/", + "GroupName": "" + } + ], + "Groups": [ + { + "Description": "description", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + } + ], + "NextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-group": { + "Group": { + "Description": "description", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-groups-after-delete": { + "GroupIdentifiers": [], + "Groups": [], + "NextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_groups_tag_query": { + "recorded-date": "13-09-2023, 17:31:56", + "recorded-content": { + "create-group": { + "Group": { + "Description": "test-tag-query", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + }, + "ResourceQuery": { + "Query": { + "ResourceTypeFilters": [ + "AWS::AllSupported" + ], + "TagFilters": [ + { + "Key": "Stage", + "Values": [ + "test-resource-group" + ] + } + ] + }, + "Type": "TAG_FILTERS_1_0" + }, + "Tags": { + "GroupTag": "GroupTag1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources-empty": { + "QueryErrors": [], + "ResourceIdentifiers": [], + "Resources": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources": { + "QueryErrors": [], + "ResourceIdentifiers": [ + { + "ResourceArn": "arn::s3:::", + "ResourceType": "AWS::S3::Bucket" + } + ], + "Resources": [ + { + "Identifier": { + "ResourceArn": "arn::s3:::", + "ResourceType": "AWS::S3::Bucket" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-tags": { + "Tags": { + "Stage": "test-resource-group" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources-after-queue-removal": { + "QueryErrors": [], + "ResourceIdentifiers": [ + { + "ResourceArn": "arn::s3:::", + "ResourceType": "AWS::S3::Bucket" + }, + { + "ResourceArn": "arn::sqs::111111111111:", + "ResourceType": "AWS::SQS::Queue" + } + ], + "Resources": [ + { + "Identifier": { + "ResourceArn": "arn::s3:::", + "ResourceType": "AWS::S3::Bucket" + } + }, + { + "Identifier": { + "ResourceArn": "arn::sqs::111111111111:", + "ResourceType": "AWS::SQS::Queue" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_groups_different_region": { + "recorded-date": "13-09-2023, 17:51:28", + "recorded-content": { + "create-group": { + "Group": { + "Description": "test-tag-query", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + }, + "ResourceQuery": { + "Query": { + "ResourceTypeFilters": [ + "AWS::AllSupported" + ], + "TagFilters": [ + { + "Key": "Stage", + "Values": [ + "test-resource-group" + ] + } + ] + }, + "Type": "TAG_FILTERS_1_0" + }, + "Tags": { + "GroupTag": "GroupTag1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources": { + "QueryErrors": [], + "ResourceIdentifiers": [ + { + "ResourceArn": "arn::sqs::111111111111:", + "ResourceType": "AWS::SQS::Queue" + } + ], + "Resources": [ + { + "Identifier": { + "ResourceArn": "arn::sqs::111111111111:", + "ResourceType": "AWS::SQS::Queue" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_type_filters": { + "recorded-date": "13-09-2023, 17:56:20", + "recorded-content": { + "create-group": { + "Group": { + "Description": "test-tag-query", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + }, + "ResourceQuery": { + "Query": { + "ResourceTypeFilters": [ + "AWS::S3::Bucket" + ], + "TagFilters": [ + { + "Key": "Stage", + "Values": [ + "test-resource-group" + ] + } + ] + }, + "Type": "TAG_FILTERS_1_0" + }, + "Tags": { + "GroupTag": "GroupTag1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources": { + "QueryErrors": [], + "ResourceIdentifiers": [ + { + "ResourceArn": "arn::s3:::", + "ResourceType": "AWS::S3::Bucket" + } + ], + "Resources": [ + { + "Identifier": { + "ResourceArn": "arn::s3:::", + "ResourceType": "AWS::S3::Bucket" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_cloudformation_query": { + "recorded-date": "13-09-2023, 18:41:46", + "recorded-content": { + "create-group": { + "Group": { + "Description": "test-cfn-query", + "GroupArn": "arn::resource-groups::111111111111:group/", + "Name": "" + }, + "ResourceQuery": { + "Query": { + "ResourceTypeFilters": [ + "AWS::AllSupported" + ], + "StackIdentifier": "" + }, + "Type": "CLOUDFORMATION_STACK_1_0" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources": { + "QueryErrors": [], + "ResourceIdentifiers": [ + { + "ResourceArn": "arn::sns::111111111111:", + "ResourceType": "AWS::SNS::Topic" + } + ], + "Resources": [ + { + "Identifier": { + "ResourceArn": "arn::sns::111111111111:", + "ResourceType": "AWS::SNS::Topic" + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources-after-destroy": { + "QueryErrors": [ + { + "ErrorCode": "CLOUDFORMATION_STACK_INACTIVE", + "Message": "The specified CloudFormation stack cannot have the following statuses: DELETE_COMPLETE, ROLLBACK_COMPLETE, CREATE_FAILED." + } + ], + "ResourceIdentifiers": [], + "Resources": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-group-with-delete-stack": { + "Error": { + "Code": "BadRequestException", + "Message": "Query not valid: The specified CloudFormation stack cannot have the following statuses: DELETE_COMPLETE, ROLLBACK_COMPLETE, CREATE_FAILED." + }, + "Message": "Query not valid: The specified CloudFormation stack cannot have the following statuses: DELETE_COMPLETE, ROLLBACK_COMPLETE, CREATE_FAILED.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_search_resources": { + "recorded-date": "13-09-2023, 19:06:38", + "recorded-content": { + "queue-tags": { + "Tags": { + "Stage": "test-resource-group" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources-sqs": { + "QueryErrors": [], + "ResourceIdentifiers": [ + { + "ResourceArn": "arn::sqs::111111111111:", + "ResourceType": "AWS::SQS::Queue" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-group-resources-s3": { + "QueryErrors": [], + "ResourceIdentifiers": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/resource_groups/test_resource_groups.validation.json b/tests/aws/services/resource_groups/test_resource_groups.validation.json new file mode 100644 index 0000000000000..c08d13b775c2e --- /dev/null +++ b/tests/aws/services/resource_groups/test_resource_groups.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_cloudformation_query": { + "last_validated_date": "2023-09-13T16:41:46+00:00" + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_create_group": { + "last_validated_date": "2023-09-13T15:21:22+00:00" + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_groups_different_region": { + "last_validated_date": "2023-09-13T15:51:28+00:00" + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_groups_tag_query": { + "last_validated_date": "2023-09-13T15:31:56+00:00" + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_resource_type_filters": { + "last_validated_date": "2023-09-13T15:56:20+00:00" + }, + "tests/aws/services/resource_groups/test_resource_groups.py::TestResourceGroups::test_search_resources": { + "last_validated_date": "2023-09-13T17:06:38+00:00" + } +} diff --git a/tests/aws/services/resourcegroupstaggingapi/__init__.py b/tests/aws/services/resourcegroupstaggingapi/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/resourcegroupstaggingapi/test_rgsa.py b/tests/aws/services/resourcegroupstaggingapi/test_rgsa.py new file mode 100644 index 0000000000000..60e85f094c495 --- /dev/null +++ b/tests/aws/services/resourcegroupstaggingapi/test_rgsa.py @@ -0,0 +1,23 @@ +from localstack.testing.pytest import markers + + +class TestRGSAIntegrations: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..PaginationToken"]) + def test_get_resources(self, aws_client, cleanups, snapshot): + vpc = aws_client.ec2.create_vpc(CidrBlock="10.0.0.0/16") + vpd_id = vpc.get("Vpc").get("VpcId") + + snapshot.add_transformers_list([snapshot.transform.key_value("ResourceARN", "ARN")]) + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpd_id)) + + tags = [{"Key": "test", "Value": "test"}] + + aws_client.ec2.create_tags( + Resources=[vpc.get("Vpc").get("VpcId")], + Tags=tags, + ) + resp = aws_client.resourcegroupstaggingapi.get_resources( + TagFilters=[{"Key": "test", "Values": ["test"]}] + ) + snapshot.match("get_resources", resp) diff --git a/tests/aws/services/resourcegroupstaggingapi/test_rgsa.snapshot.json b/tests/aws/services/resourcegroupstaggingapi/test_rgsa.snapshot.json new file mode 100644 index 0000000000000..405bb2cfe6395 --- /dev/null +++ b/tests/aws/services/resourcegroupstaggingapi/test_rgsa.snapshot.json @@ -0,0 +1,25 @@ +{ + "tests/aws/services/resourcegroupstaggingapi/test_rgsa.py::TestRGSAIntegrations::test_get_resources": { + "recorded-date": "20-03-2024, 06:15:41", + "recorded-content": { + "get_resources": { + "PaginationToken": "", + "ResourceTagMappingList": [ + { + "ResourceARN": "", + "Tags": [ + { + "Key": "test", + "Value": "test" + } + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/resourcegroupstaggingapi/test_rgsa.validation.json b/tests/aws/services/resourcegroupstaggingapi/test_rgsa.validation.json new file mode 100644 index 0000000000000..80b413a88f6d3 --- /dev/null +++ b/tests/aws/services/resourcegroupstaggingapi/test_rgsa.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/resourcegroupstaggingapi/test_rgsa.py::TestRGSAIntegrations::test_get_resources": { + "last_validated_date": "2024-03-20T06:15:41+00:00" + } +} diff --git a/tests/aws/services/route53/__init__.py b/tests/aws/services/route53/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/route53/test_route53.py b/tests/aws/services/route53/test_route53.py new file mode 100644 index 0000000000000..c706f784e0d9c --- /dev/null +++ b/tests/aws/services/route53/test_route53.py @@ -0,0 +1,225 @@ +from urllib.parse import urlparse + +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + + +@pytest.fixture(autouse=True) +def route53_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.route53_api()) + + +class TestRoute53: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..DelegationSet.Id", "$..HostedZone.CallerReference"] + ) + def test_create_hosted_zone(self, aws_client, hosted_zone, snapshot): + response = hosted_zone(Name=f"zone-{short_uid()}.com") + zone_id = response["HostedZone"]["Id"] + snapshot.match("create_hosted_zone_response", response) + + response = aws_client.route53.get_hosted_zone(Id=zone_id) + snapshot.match("get_hosted_zone", response) + + @markers.aws.validated + def test_crud_health_check(self, echo_http_server_post, echo_http_server, aws_client): + parsed_url = urlparse(echo_http_server_post) + protocol = parsed_url.scheme.upper() + host, _, port = parsed_url.netloc.partition(":") + port = port or (443 if protocol == "HTTPS" else 80) + path = ( + f"{parsed_url.path}health" + if parsed_url.path.endswith("/") + else f"{parsed_url.path}/health" + ) + + response = aws_client.route53.create_health_check( + CallerReference=short_uid(), + HealthCheckConfig={ + "FullyQualifiedDomainName": host, + "Port": int(port), + "ResourcePath": path, + "Type": protocol, + "RequestInterval": 10, + "FailureThreshold": 2, + }, + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 201 + health_check_id = response["HealthCheck"]["Id"] + response = aws_client.route53.get_health_check(HealthCheckId=health_check_id) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert response["HealthCheck"]["Id"] == health_check_id + response = aws_client.route53.delete_health_check(HealthCheckId=health_check_id) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + with pytest.raises(Exception) as ctx: + aws_client.route53.delete_health_check(HealthCheckId=health_check_id) + assert "NoSuchHealthCheck" in str(ctx.value) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..HostedZone.CallerReference", + # moto does not return MaxItems for list_hosted_zones_by_vpc + "$..MaxItems", + ] + ) + def test_create_private_hosted_zone( + self, region_name, aws_client, cleanups, snapshot, hosted_zone + ): + vpc = aws_client.ec2.create_vpc(CidrBlock="10.113.0.0/24") + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpc["Vpc"]["VpcId"])) + vpc_id = vpc["Vpc"]["VpcId"] + snapshot.add_transformer(snapshot.transform.key_value("VPCId")) + + name = f"zone-{short_uid()}.com" + response = hosted_zone( + Name=name, + HostedZoneConfig={ + "PrivateZone": True, + "Comment": "test", + }, + VPC={ + "VPCId": vpc_id, + "VPCRegion": region_name, + }, + ) + snapshot.match("create-hosted-zone-response", response) + zone_id = response["HostedZone"]["Id"] + + response = aws_client.route53.get_hosted_zone(Id=zone_id) + snapshot.match("get_hosted_zone", response) + + response = aws_client.route53.list_hosted_zones_by_vpc(VPCId=vpc_id, VPCRegion=region_name) + snapshot.match("list_hosted_zones_by_vpc", response) + + response = aws_client.route53.list_hosted_zones() + zones = [zone for zone in response["HostedZones"] if name in zone["Name"]] + snapshot.match("list_hosted_zones", zones) + + @markers.aws.validated + def test_associate_vpc_with_hosted_zone( + self, cleanups, hosted_zone, aws_client, account_id, region_name + ): + # create VPCs + vpc1 = aws_client.ec2.create_vpc(CidrBlock="10.113.0.0/24") + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpc1["Vpc"]["VpcId"])) + vpc1_id = vpc1["Vpc"]["VpcId"] + vpc2 = aws_client.ec2.create_vpc(CidrBlock="10.114.0.0/24") + cleanups.append(lambda: aws_client.ec2.delete_vpc(VpcId=vpc2["Vpc"]["VpcId"])) + vpc2_id = vpc2["Vpc"]["VpcId"] + + name = "zone123" + response = hosted_zone( + Name=name, + HostedZoneConfig={ + "PrivateZone": True, + "Comment": "test", + }, + VPC={"VPCId": vpc1_id, "VPCRegion": region_name}, + ) + zone_id = response["HostedZone"]["Id"] + zone_id = zone_id.replace("/hostedzone/", "") + + # associate zone with VPC + vpc_region = region_name + for vpc_id in [vpc2_id]: + result = aws_client.route53.associate_vpc_with_hosted_zone( + HostedZoneId=zone_id, + VPC={"VPCRegion": vpc_region, "VPCId": vpc_id}, + Comment="test 123", + ) + assert result["ChangeInfo"].get("Id") + + cleanups.append( + lambda: aws_client.route53.disassociate_vpc_from_hosted_zone( + HostedZoneId=zone_id, VPC={"VPCRegion": vpc_region, "VPCId": vpc1_id} + ) + ) + + # list zones by VPC + result = aws_client.route53.list_hosted_zones_by_vpc(VPCId=vpc1_id, VPCRegion=vpc_region)[ + "HostedZoneSummaries" + ] + expected = { + "HostedZoneId": zone_id, + "Name": "%s." % name, + "Owner": {"OwningAccount": account_id}, + } + assert expected in result + + # list zones by name + result = aws_client.route53.list_hosted_zones_by_name(DNSName=name).get("HostedZones") + assert result[0]["Name"] == "zone123." + result = aws_client.route53.list_hosted_zones_by_name(DNSName="%s." % name).get( + "HostedZones" + ) + assert result[0]["Name"] == "zone123." + + # assert that VPC is attached in Zone response + result = aws_client.route53.get_hosted_zone(Id=zone_id) + for vpc_id in [vpc1_id, vpc2_id]: + assert {"VPCRegion": vpc_region, "VPCId": vpc_id} in result["VPCs"] + + # disassociate + aws_client.route53.disassociate_vpc_from_hosted_zone( + HostedZoneId=zone_id, + VPC={"VPCRegion": vpc_region, "VPCId": vpc2_id}, + Comment="test2", + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] in [200, 201] + # subsequent call (after disassociation) should fail with 404 error + with pytest.raises(Exception): + aws_client.route53.disassociate_vpc_from_hosted_zone( + HostedZoneId=zone_id, + VPC={"VPCRegion": vpc_region, "VPCId": vpc2_id}, + ) + + @markers.aws.validated + def test_create_hosted_zone_in_non_existent_vpc( + self, aws_client, hosted_zone, snapshot, region_name + ): + vpc = {"VPCId": "non-existent", "VPCRegion": region_name} + with pytest.raises(aws_client.route53.exceptions.InvalidVPCId) as exc_info: + hosted_zone(Name=f"zone-{short_uid()}.com", VPC=vpc) + + snapshot.match("failure-response", exc_info.value.response) + + @markers.aws.validated + def test_reusable_delegation_sets(self, aws_client): + client = aws_client.route53 + + sets_before = client.list_reusable_delegation_sets().get("DelegationSets", []) + + call_ref_1 = "c-%s" % short_uid() + result_1 = client.create_reusable_delegation_set(CallerReference=call_ref_1)[ + "DelegationSet" + ] + set_id_1 = result_1["Id"] + + call_ref_2 = "c-%s" % short_uid() + result_2 = client.create_reusable_delegation_set(CallerReference=call_ref_2)[ + "DelegationSet" + ] + set_id_2 = result_2["Id"] + + result_1 = client.get_reusable_delegation_set(Id=set_id_1) + assert result_1["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert result_1["DelegationSet"]["Id"] == set_id_1 + + result_1 = client.list_reusable_delegation_sets() + assert result_1["ResponseMetadata"]["HTTPStatusCode"] == 200 + # TODO: assertion should be updated, to allow for parallel tests + assert len(result_1["DelegationSets"]) == len(sets_before) + 2 + + result_1 = client.delete_reusable_delegation_set(Id=set_id_1) + assert result_1["ResponseMetadata"]["HTTPStatusCode"] == 200 + + result_2 = client.delete_reusable_delegation_set(Id=set_id_2) + assert result_2["ResponseMetadata"]["HTTPStatusCode"] == 200 + + with pytest.raises(Exception) as ctx: + client.get_reusable_delegation_set(Id=set_id_1) + assert "NoSuchDelegationSet" in str(ctx.value) diff --git a/tests/aws/services/route53/test_route53.snapshot.json b/tests/aws/services/route53/test_route53.snapshot.json new file mode 100644 index 0000000000000..e82bcf05108b1 --- /dev/null +++ b/tests/aws/services/route53/test_route53.snapshot.json @@ -0,0 +1,146 @@ +{ + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_hosted_zone": { + "recorded-date": "02-11-2023, 12:59:59", + "recorded-content": { + "create_hosted_zone_response": { + "ChangeInfo": { + "Id": "/change/", + "Status": "", + "SubmittedAt": "datetime" + }, + "DelegationSet": { + "NameServers": "" + }, + "HostedZone": { + "CallerReference": "", + "Config": { + "PrivateZone": false + }, + "Id": "/hostedzone/", + "Name": "", + "ResourceRecordSetCount": 2 + }, + "Location": "https://route53.amazonaws.com/2013-04-01/hostedzone/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_hosted_zone": { + "DelegationSet": { + "NameServers": "" + }, + "HostedZone": { + "CallerReference": "", + "Config": { + "PrivateZone": false + }, + "Id": "/hostedzone/", + "Name": "", + "ResourceRecordSetCount": 2 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_private_hosted_zone": { + "recorded-date": "11-04-2024, 14:03:14", + "recorded-content": { + "create-hosted-zone-response": { + "ChangeInfo": { + "Id": "/change/", + "Status": "", + "SubmittedAt": "datetime" + }, + "HostedZone": { + "CallerReference": "", + "Config": { + "Comment": "test", + "PrivateZone": true + }, + "Id": "/hostedzone/", + "Name": "", + "ResourceRecordSetCount": 2 + }, + "Location": "https://route53.amazonaws.com/2013-04-01/hostedzone/", + "VPC": { + "VPCId": "", + "VPCRegion": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 201 + } + }, + "get_hosted_zone": { + "HostedZone": { + "CallerReference": "", + "Config": { + "Comment": "test", + "PrivateZone": true + }, + "Id": "/hostedzone/", + "Name": "", + "ResourceRecordSetCount": 2 + }, + "VPCs": [ + { + "VPCId": "", + "VPCRegion": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_hosted_zones_by_vpc": { + "HostedZoneSummaries": [ + { + "HostedZoneId": "", + "Name": "", + "Owner": { + "OwningAccount": "111111111111" + } + } + ], + "MaxItems": "100", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_hosted_zones": [ + { + "Id": "/hostedzone/", + "Name": "", + "CallerReference": "", + "Config": { + "Comment": "test", + "PrivateZone": true + }, + "ResourceRecordSetCount": 2 + } + ] + } + }, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_hosted_zone_in_non_existent_vpc": { + "recorded-date": "10-04-2024, 15:47:22", + "recorded-content": { + "failure-response": { + "Error": { + "Code": "InvalidVPCId", + "Message": "The VPC ID is invalid.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/route53/test_route53.validation.json b/tests/aws/services/route53/test_route53.validation.json new file mode 100644 index 0000000000000..5a7b0037f1b09 --- /dev/null +++ b/tests/aws/services/route53/test_route53.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/route53/test_route53.py::TestRoute53::test_associate_vpc_with_hosted_zone": { + "last_validated_date": "2024-06-13T07:40:39+00:00" + }, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_hosted_zone": { + "last_validated_date": "2023-11-02T11:59:59+00:00" + }, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_hosted_zone_in_non_existent_vpc": { + "last_validated_date": "2024-04-10T15:47:22+00:00" + }, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_create_private_hosted_zone": { + "last_validated_date": "2024-04-11T14:03:14+00:00" + }, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_crud_health_check": { + "last_validated_date": "2024-06-13T08:05:47+00:00" + }, + "tests/aws/services/route53/test_route53.py::TestRoute53::test_reusable_delegation_sets": { + "last_validated_date": "2024-06-13T07:43:08+00:00" + } +} diff --git a/tests/aws/services/route53resolver/__init__.py b/tests/aws/services/route53resolver/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/route53resolver/test_route53resolver.py b/tests/aws/services/route53resolver/test_route53resolver.py new file mode 100644 index 0000000000000..778e8ec500001 --- /dev/null +++ b/tests/aws/services/route53resolver/test_route53resolver.py @@ -0,0 +1,867 @@ +import logging +import re + +import pytest + +from localstack.aws.api.route53resolver import ( + Action, + ListResolverEndpointsResponse, + ListResolverQueryLogConfigsResponse, + ListResolverRuleAssociationsResponse, +) +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid +from localstack.utils.functions import call_safe +from localstack.utils.sync import poll_condition + +LOG = logging.getLogger(__name__) + + +@pytest.fixture(autouse=True) +def route53resolver_api_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.route53resolver_api()) + + +@pytest.fixture +def create_firewall_rule(aws_client: ServiceLevelClientFactory): + rules = [] + + def inner(**kwargs): + kwargs.setdefault("Name", f"rule-name-{short_uid()}") + rule_group_id = kwargs["FirewallRuleGroupId"] + domain_list_id = kwargs["FirewallDomainListId"] + response = aws_client.route53resolver.create_firewall_rule(**kwargs) + rules.append((rule_group_id, domain_list_id)) + return response + + yield inner + + for rule_group_id, domain_list_id in rules[::-1]: + aws_client.route53resolver.delete_firewall_rule( + FirewallRuleGroupId=rule_group_id, + FirewallDomainListId=domain_list_id, + ) + + +# TODO: extract this somewhere so that we can reuse it in other places +def _cleanup_vpc(aws_client: ServiceLevelClientFactory, vpc_id: str): + """ + perform a safe cleanup of a VPC + this method assumes that any existing network interfaces have already been detached + """ + # delete security groups + sgs = aws_client.ec2.describe_security_groups(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]) + for sg in sgs["SecurityGroups"]: + if sg["GroupName"] != "default": # default security group can't be deleted + call_safe(lambda: aws_client.ec2.delete_security_group(GroupId=sg["GroupId"])) + + # delete subnets + subnets = aws_client.ec2.describe_subnets(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]) + for subnet in subnets["Subnets"]: + call_safe(lambda: aws_client.ec2.delete_subnet(SubnetId=subnet["SubnetId"])) + + # finally, delete the vpc + aws_client.ec2.delete_vpc(VpcId=vpc_id) + + +def delete_route53_resolver_endpoint(route53resolver_client, resolver_endpoint_id: str): + """delete a route53resolver resolver endpoint and wait until it is actually deleted""" + + def _delete_resolver_endpoint(): + # deleting a resolver endpoint is an async operation, but we can't properly observe it since there's no "deleted" terminal state + route53resolver_client.delete_resolver_endpoint(ResolverEndpointId=resolver_endpoint_id) + + # retry until we can't get it anymore + def _is_endpoint_deleted(): + try: + route53resolver_client.get_resolver_endpoint( + ResolverEndpointId=resolver_endpoint_id + ) + except Exception: + return True + else: + return False + + poll_condition(_is_endpoint_deleted, timeout=180, interval=3 if is_aws_cloud() else 1) + + return _delete_resolver_endpoint + + +@markers.snapshot.skip_snapshot_verify(paths=["$..ResolverEndpointType"]) +class TestRoute53Resolver: + # TODO: make this class level? + @pytest.fixture(scope="function") + def setup_resources(self, aws_client, cleanups): + vpc = aws_client.ec2.create_vpc(CidrBlock="10.78.0.0/16") + vpc_id = vpc["Vpc"]["VpcId"] + cleanups.append(lambda: _cleanup_vpc(aws_client, vpc_id)) + + subnet1 = aws_client.ec2.create_subnet(VpcId=vpc_id, CidrBlock="10.78.1.0/24") + subnet2 = aws_client.ec2.create_subnet(VpcId=vpc_id, CidrBlock="10.78.2.0/24") + + security_group = aws_client.ec2.create_security_group( + GroupName=f"test-sg-{short_uid()}", VpcId=vpc_id, Description="test sg" + ) + + yield vpc, subnet1, subnet2, security_group + + def _construct_ip_for_cidr_and_host(self, cidr_block: str, host_id: str) -> str: + return re.sub(r"(.*)\.[0-9]+/.+", r"\1." + host_id, cidr_block) + + @staticmethod + def _wait_associate_or_disassociate_resolver_rule(client, resolver_rule_id, vpc_id, action): + def _is_resolver_rule_disassociated(lst): + for rra in lst.get("ResolverRuleAssociations", []): + if rra["ResolverRuleId"] == resolver_rule_id and rra["VPCId"] == vpc_id: + return False + return True + + def _is_resolver_rule_associated(lst): + for rra in lst.get("ResolverRuleAssociations", []): + if ( + rra["ResolverRuleId"] == resolver_rule_id + and rra["VPCId"] == vpc_id + and rra["Status"] == "COMPLETE" + ): + return True + return False + + def _list_resolver_rule_associations(): + lst: ListResolverRuleAssociationsResponse = client.list_resolver_rule_associations() + if action == "disassociate": + return _is_resolver_rule_disassociated(lst) + elif action == "associate": + return _is_resolver_rule_associated(lst) + + if not poll_condition(condition=_list_resolver_rule_associations, timeout=180, interval=2): + LOG.warning( + "Timed out while awaiting for resolver rule to '%s' with with VPCId:'%s' and ResolverRuleId: '%s'.", + action, + vpc_id, + resolver_rule_id, + ) + else: + return True + + @staticmethod + def _wait_created_log_config_is_listed_with_status(client, id, status): + def _is_req_id_in_list(): + lst: ListResolverQueryLogConfigsResponse = client.list_resolver_query_log_configs() + rqlc_ids_status = {} + for rqlc in lst.get("ResolverQueryLogConfigs", []): + rqlc_ids_status[rqlc["Id"]] = rqlc["Status"] + for key, value in rqlc_ids_status.items(): + if key == id: + return value == status + return False + + if not poll_condition(condition=_is_req_id_in_list, timeout=120, interval=2): + LOG.warning( + "Timed out while awaiting for resolver query log config with with id:'%s' to become listable.", + id, + ) + else: + return True + + @staticmethod + def _wait_created_endpoint_is_listed_with_status(client, req_id, status): + def _is_req_id_in_list(): + lst: ListResolverEndpointsResponse = client.list_resolver_endpoints() + resolver_endpoint_request_ids_status = {} + + for resolver_endpoint in lst.get("ResolverEndpoints", []): + resolver_endpoint_request_ids_status[resolver_endpoint["CreatorRequestId"]] = ( + resolver_endpoint["Status"] + ) + for key, value in resolver_endpoint_request_ids_status.items(): + if key == req_id: + return value == status + return False + + if not poll_condition(condition=_is_req_id_in_list, timeout=180, interval=2): + LOG.warning( + "Timed out while awaiting for resolver endpoint with with request id:'%s' to become listable.", + req_id, + ) + else: + return True + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..SecurityGroupIds"]) + @pytest.mark.parametrize( + "direction, hostId", + [ + ("INBOUND", "5"), + ("OUTBOUND", "10"), + ], + ) + def test_create_resolver_endpoint( + self, direction, hostId, cleanups, snapshot, aws_client, setup_resources + ): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], hostId) + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], hostId) + + request_id = short_uid() + resolver_endpoint_name = f"rs-{request_id}" + create_resolver_endpoint_res = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction=direction, + Name=resolver_endpoint_name, + IpAddresses=[ + {"SubnetId": subnet1_id, "Ip": ip1}, + {"SubnetId": subnet2_id, "Ip": ip2}, + ], + ) + resolver_endpoint = create_resolver_endpoint_res["ResolverEndpoint"] + cleanups.append( + delete_route53_resolver_endpoint(aws_client.route53resolver, resolver_endpoint["Id"]) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + @markers.aws.validated + def test_route53resolver_bad_create_endpoint_security_groups( + self, snapshot, aws_client, setup_resources + ): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "43") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "43") + + request_id = short_uid() + resolver_endpoint_name = f"rs-{request_id}" + with pytest.raises( + aws_client.route53resolver.exceptions.InvalidParameterException + ) as inavlid_param_request_res: + aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=["test-invalid-sg-123"], + Direction="INBOUND", + Name=resolver_endpoint_name, + IpAddresses=[ + {"SubnetId": subnet1_id, "Ip": ip1}, + {"SubnetId": subnet2_id, "Ip": ip2}, + ], + ) + snapshot.match("inavlid_param_request_res", inavlid_param_request_res.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..SecurityGroupIds"]) + def test_multiple_create_resolver_endpoint_with_same_req_id( + self, cleanups, snapshot, aws_client, setup_resources + ): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "41") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "41") + + request_id = short_uid() + resolver_endpoint_name = f"rs-{request_id}" + result = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="INBOUND", + Name=resolver_endpoint_name, + IpAddresses=[{"SubnetId": subnet1_id, "Ip": ip1}, {"SubnetId": subnet2_id, "Ip": ip2}], + ) + create_resolver_endpoint_res = result.get("ResolverEndpoint") + cleanups.append( + delete_route53_resolver_endpoint( + aws_client.route53resolver, create_resolver_endpoint_res["Id"] + ) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + with pytest.raises( + aws_client.route53resolver.exceptions.ResourceExistsException + ) as res_exists_ex: + aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="INBOUND", + Name=resolver_endpoint_name, + IpAddresses=[ + {"SubnetId": subnet1_id, "Ip": ip1}, + {"SubnetId": subnet2_id, "Ip": ip2}, + ], + ) + + snapshot.match( + "res_exists_ex_error_code", res_exists_ex.value.response.get("Error", {}).get("Code") + ) + snapshot.match( + "res_exists_ex_http_status_code", + res_exists_ex.value.response.get("ResponseMetadata", {}).get("HTTPStatusCode"), + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..SecurityGroupIds"]) + def test_update_resolver_endpoint(self, cleanups, snapshot, aws_client, setup_resources): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "58") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "58") + + request_id = short_uid() + result = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="INBOUND", + IpAddresses=[{"SubnetId": subnet1_id, "Ip": ip1}, {"SubnetId": subnet2_id, "Ip": ip2}], + ) + create_resolver_endpoint_res = result.get("ResolverEndpoint") + cleanups.append( + delete_route53_resolver_endpoint( + aws_client.route53resolver, create_resolver_endpoint_res["Id"] + ) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + # update resolver endpoint + update_resolver_endpoint_res = aws_client.route53resolver.update_resolver_endpoint( + ResolverEndpointId=create_resolver_endpoint_res["Id"], Name="resolver_endpoint_name" + ) + + if self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ): + update_resolver_endpoint_res["Status"] = "OPERATIONAL" + snapshot.match("update_resolver_endpoint_res", update_resolver_endpoint_res) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..SecurityGroupIds"]) + def test_delete_resolver_endpoint(self, cleanups, snapshot, aws_client, setup_resources): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "48") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "48") + + request_id = short_uid() + result = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="INBOUND", + IpAddresses=[{"SubnetId": subnet1_id, "Ip": ip1}, {"SubnetId": subnet2_id, "Ip": ip2}], + ) + create_resolver_endpoint_res = result.get("ResolverEndpoint") + cleanups.append( + delete_route53_resolver_endpoint( + aws_client.route53resolver, create_resolver_endpoint_res["Id"] + ) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + delete_resolver_endpoint = aws_client.route53resolver.delete_resolver_endpoint( + ResolverEndpointId=create_resolver_endpoint_res["Id"] + ) + snapshot.match("delete_resolver_endpoint_res", delete_resolver_endpoint) + + @markers.aws.validated + def test_delete_non_existent_resolver_endpoint(self, snapshot, aws_client): + resolver_endpoint_id = "rslvr-123" + with pytest.raises( + aws_client.route53resolver.exceptions.ResourceNotFoundException + ) as resource_not_found: + aws_client.route53resolver.delete_resolver_endpoint( + ResolverEndpointId=resolver_endpoint_id + ) + snapshot.match( + "resource_not_found_ex_error_code", + resource_not_found.value.response.get("Error", {}).get("Code"), + ) + snapshot.match( + "resource_not_found_ex_http_status_code", + resource_not_found.value.response.get("ResponseMetadata", {}).get("HTTPStatusCode"), + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..SecurityGroupIds", "$..ShareStatus"]) + def test_create_resolver_rule(self, cleanups, snapshot, aws_client, setup_resources): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "38") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "38") + + request_id = short_uid() + resolver_endpoint_name = f"rs-{request_id}" + result = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="OUTBOUND", + Name=resolver_endpoint_name, + IpAddresses=[{"SubnetId": subnet1_id, "Ip": ip1}, {"SubnetId": subnet2_id, "Ip": ip2}], + ) + create_resolver_endpoint_res = result.get("ResolverEndpoint") + cleanups.append( + delete_route53_resolver_endpoint( + aws_client.route53resolver, create_resolver_endpoint_res["Id"] + ) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + create_resolver_rule_res = aws_client.route53resolver.create_resolver_rule( + CreatorRequestId=short_uid(), + RuleType="FORWARD", + DomainName="www.example1.com", + ResolverEndpointId=create_resolver_endpoint_res["Id"], + TargetIps=[ + {"Ip": "10.0.1.200", "Port": 123}, + ], + ) + create_resolver_rule_res = create_resolver_rule_res.get("ResolverRule") + snapshot.match("create_resolver_rule_res", create_resolver_rule_res) + delete_resolver_rule_res = aws_client.route53resolver.delete_resolver_rule( + ResolverRuleId=create_resolver_rule_res["Id"] + ) + snapshot.match("delete_resolver_rule_res", delete_resolver_rule_res) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..SecurityGroupIds"]) + def test_create_resolver_rule_with_invalid_direction( + self, cleanups, snapshot, aws_client, setup_resources + ): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "28") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "28") + + request_id = short_uid() + resolver_endpoint_name = f"rs-{request_id}" + result = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="INBOUND", + Name=resolver_endpoint_name, + IpAddresses=[{"SubnetId": subnet1_id, "Ip": ip1}, {"SubnetId": subnet2_id, "Ip": ip2}], + ) + + create_resolver_endpoint_res = result.get("ResolverEndpoint") + cleanups.append( + delete_route53_resolver_endpoint( + aws_client.route53resolver, create_resolver_endpoint_res["Id"] + ) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + with pytest.raises( + aws_client.route53resolver.exceptions.InvalidRequestException + ) as inavlid_request: + aws_client.route53resolver.create_resolver_rule( + CreatorRequestId=short_uid(), + RuleType="FORWARD", + DomainName="www.example2.com", + ResolverEndpointId=create_resolver_endpoint_res["Id"], + TargetIps=[ + {"Ip": "10.0.1.200", "Port": 123}, + ], + ) + + snapshot.match("invalid_request_ex", inavlid_request.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..SecurityGroupIds", "$..ShareStatus"]) + def test_multipe_create_resolver_rule(self, cleanups, snapshot, aws_client, setup_resources): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "18") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "18") + + request_id = short_uid() + resolver_endpoint_name = f"rs-{request_id}" + result = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="OUTBOUND", + Name=resolver_endpoint_name, + IpAddresses=[{"SubnetId": subnet1_id, "Ip": ip1}, {"SubnetId": subnet2_id, "Ip": ip2}], + ) + create_resolver_endpoint_res = result.get("ResolverEndpoint") + resolver_endpoint_id = create_resolver_endpoint_res["Id"] + cleanups.append( + delete_route53_resolver_endpoint( + aws_client.route53resolver, create_resolver_endpoint_res["Id"] + ) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + rslvr_rule_req_ids = [short_uid(), short_uid(), short_uid()] + for ind, req_id in enumerate(rslvr_rule_req_ids): + create_resolver_rule_res = aws_client.route53resolver.create_resolver_rule( + CreatorRequestId=req_id, + RuleType="FORWARD", + DomainName=f"www.example{ind}.com", + ResolverEndpointId=resolver_endpoint_id, + TargetIps=[ + {"Ip": "10.0.1.100", "Port": 123}, + ], + ) + + create_resolver_rule_res = create_resolver_rule_res.get("ResolverRule") + resolver_rule_id = create_resolver_rule_res["Id"] + snapshot.match(f"create_resolver_rule_res_{ind}", create_resolver_rule_res) + + delete_resolver_rule = aws_client.route53resolver.delete_resolver_rule( + ResolverRuleId=resolver_rule_id + ) + snapshot.match(f"delete_resolver_rule_res{ind}", delete_resolver_rule) + + @markers.aws.validated + def test_delete_non_existent_resolver_rule(self, snapshot, aws_client): + resolver_rule_id = "id-123" + with pytest.raises( + aws_client.route53resolver.exceptions.ResourceNotFoundException + ) as resource_not_found: + aws_client.route53resolver.delete_resolver_rule(ResolverRuleId=resolver_rule_id) + snapshot.match("resource_not_found_res", resource_not_found.value.response) + + @markers.aws.validated + def test_disassociate_non_existent_association(self, snapshot, aws_client): + with pytest.raises( + aws_client.route53resolver.exceptions.ResourceNotFoundException + ) as resource_not_found: + aws_client.route53resolver.disassociate_resolver_rule( + ResolverRuleId="rslvr-123", VPCId="vpc-123" + ) + snapshot.match("resource_not_found_res", resource_not_found.value.response) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..DestinationArn" # arn of log group has a ":*" suffix which create_resolver_query_log_config seems to strip on AWS + ] + ) + @markers.aws.validated + def test_create_resolver_query_log_config(self, cleanups, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("Name")) + request_id = short_uid() + + log_group_name = f"test-r53resolver-lg-{short_uid()}" + aws_client.logs.create_log_group(logGroupName=log_group_name) + cleanups.append(lambda: aws_client.logs.delete_log_group(logGroupName=log_group_name)) + log_group_arn = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + + result = aws_client.route53resolver.create_resolver_query_log_config( + Name=f"test-{short_uid()}", + DestinationArn=log_group_arn, + CreatorRequestId=request_id, + ) + create_rqlc = result.get("ResolverQueryLogConfig") + resolver_config_id = create_rqlc["Id"] + cleanups.append( + lambda: aws_client.route53resolver.delete_resolver_query_log_config( + ResolverQueryLogConfigId=resolver_config_id + ) + ) + if self._wait_created_log_config_is_listed_with_status( + aws_client.route53resolver, resolver_config_id, "CREATED" + ): + create_rqlc["Status"] = "CREATED" + + snapshot.match("create_resolver_query_log_config_res", create_rqlc) + + delete_resolver_config = aws_client.route53resolver.delete_resolver_query_log_config( + ResolverQueryLogConfigId=resolver_config_id + ) + snapshot.match("delete_resolver_query_log_config_res", delete_resolver_config) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Message"]) + @markers.aws.validated + def test_delete_non_existent_resolver_query_log_config(self, snapshot, aws_client): + resolver_rqlc_id = "test_123_doesntexist" + with pytest.raises( + aws_client.route53resolver.exceptions.ResourceNotFoundException + ) as resource_not_found: + aws_client.route53resolver.delete_resolver_query_log_config( + ResolverQueryLogConfigId=resolver_rqlc_id, + ) + error_msg = resource_not_found.value.response["Error"]["Message"] + match = re.search('Trace Id: "(.+)"', error_msg) + if match: + trace_id = match.groups()[0] + snapshot.add_transformer(snapshot.transform.regex(trace_id, "")) + + snapshot.match( + "resource_not_found_ex", + resource_not_found.value.response, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..SecurityGroupIds", "$..ShareStatus", "$..StatusMessage"] + ) + def test_associate_and_disassociate_resolver_rule( + self, cleanups, snapshot, aws_client, setup_resources + ): + vpc, subnet1, subnet2, security_group = setup_resources + subnet1_id = subnet1["Subnet"]["SubnetId"] + subnet2_id = subnet2["Subnet"]["SubnetId"] + security_group_id = security_group["GroupId"] + ip1 = self._construct_ip_for_cidr_and_host(subnet1["Subnet"]["CidrBlock"], "68") + ip2 = self._construct_ip_for_cidr_and_host(subnet2["Subnet"]["CidrBlock"], "68") + + snapshot.add_transformer(snapshot.transform.key_value("ResolverRuleId", "rslvr-rr-id")) + snapshot.add_transformer(snapshot.transform.key_value("VPCId", "vpc-id")) + request_id = short_uid() + resolver_endpoint_name = f"rs-{request_id}" + result = aws_client.route53resolver.create_resolver_endpoint( + CreatorRequestId=request_id, + SecurityGroupIds=[security_group_id], + Direction="OUTBOUND", + Name=resolver_endpoint_name, + IpAddresses=[{"SubnetId": subnet1_id, "Ip": ip1}, {"SubnetId": subnet2_id, "Ip": ip2}], + ) + + create_resolver_endpoint_res = result.get("ResolverEndpoint") + resolver_endpoint_id = create_resolver_endpoint_res["Id"] + cleanups.append( + delete_route53_resolver_endpoint( + aws_client.route53resolver, create_resolver_endpoint_res["Id"] + ) + ) + + self._wait_created_endpoint_is_listed_with_status( + aws_client.route53resolver, request_id, "OPERATIONAL" + ) + snapshot.match("create_resolver_endpoint_res", create_resolver_endpoint_res) + + create_resolver_rule_res = aws_client.route53resolver.create_resolver_rule( + CreatorRequestId=short_uid(), + RuleType="FORWARD", + DomainName="www.example4.com", + ResolverEndpointId=resolver_endpoint_id, + TargetIps=[ + {"Ip": "10.0.1.100", "Port": 123}, + ], + ) + + create_resolver_rule_res = create_resolver_rule_res.get("ResolverRule") + resolver_rule_id = create_resolver_rule_res["Id"] + + cleanups.append( + lambda: aws_client.route53resolver.delete_resolver_rule(ResolverRuleId=resolver_rule_id) + ) + + snapshot.match("create_resolver_rule_res", create_resolver_rule_res) + + vpc_id = vpc["Vpc"]["VpcId"] + + associated_resolver_rule_res = aws_client.route53resolver.associate_resolver_rule( + ResolverRuleId=resolver_rule_id, + Name="test-associate-resolver-rule", + VPCId=vpc_id, + )["ResolverRuleAssociation"] + + assert self._wait_associate_or_disassociate_resolver_rule( + aws_client.route53resolver, resolver_rule_id, vpc_id, "associate" + ) + rule_association = aws_client.route53resolver.get_resolver_rule_association( + ResolverRuleAssociationId=associated_resolver_rule_res["Id"] + ) + snapshot.match("rule_association", rule_association) + + disassociate_resolver_rule_res = aws_client.route53resolver.disassociate_resolver_rule( + ResolverRuleId=resolver_rule_id, VPCId=vpc_id + ) + # wait till resolver rule is disassociated + self._wait_associate_or_disassociate_resolver_rule( + aws_client.route53resolver, resolver_rule_id, vpc_id, "disassociate" + ) + snapshot.match("disassociate_resolver_rule_res", disassociate_resolver_rule_res) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..ManagedOwnerName"]) + def test_list_firewall_domain_lists(self, cleanups, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("Id")) + + tags = [{"Key": "hello", "Value": "world"}] + firewall_name = "my_firewall_domain" + + result = aws_client.route53resolver.create_firewall_domain_list( + CreatorRequestId="test", Name=firewall_name, Tags=tags + ) + snapshot.match("create-firewall-domain-list", result) + arn = result["FirewallDomainList"]["Arn"] + firewall_id = result["FirewallDomainList"]["Id"] + cleanups.append( + lambda: aws_client.route53resolver.delete_firewall_domain_list( + FirewallDomainListId=firewall_id + ) + ) + + result_list = aws_client.route53resolver.list_firewall_domain_lists() + extracted = [r for r in result_list["FirewallDomainLists"] if r["Name"] == firewall_name] + snapshot.match("list-firewall-domain-list-filtered", extracted) + + tag_result = aws_client.route53resolver.list_tags_for_resource(ResourceArn=arn) + snapshot.match("list-tags-for-resource", tag_result) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Message"]) + def test_list_firewall_rules_for_missing_rule_group(self, snapshot, aws_client): + """Test listing firewall rules for a non-existing rule-group.""" + with pytest.raises( + aws_client.route53resolver.exceptions.ResourceNotFoundException + ) as resource_not_found: + aws_client.route53resolver.list_firewall_rules(FirewallRuleGroupId="missing-id") + + snapshot.add_transformer( + snapshot.transform.regex(r"\d{1}-[a-f0-9]{8}-[a-f0-9]{24}", "trace-id") + ) + snapshot.match("missing-firewall-rule-group-id", resource_not_found.value.response) + + @markers.aws.validated + def test_list_firewall_rules_for_empty_rule_group(self, cleanups, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("Name")) + + rule_group_response = aws_client.route53resolver.create_firewall_rule_group( + Name=f"empty-{short_uid()}" + ) + cleanups.append( + lambda: aws_client.route53resolver.delete_firewall_rule_group( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"] + ) + ) + snapshot.match("create-firewall-rule-group", rule_group_response) + + response = aws_client.route53resolver.list_firewall_rules( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"] + ) + snapshot.match("empty-firewall-rule-group", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..FirewallDomainRedirectionAction"]) + def test_list_firewall_rules( + self, + cleanups, + snapshot, + aws_client, + create_firewall_rule, + ): + """Test listing firewall rules. + + We test listing: + - all rules in the rule-group + - rules filtered by priority + - rules filtered by action + - rules filtered by priority and action + """ + + snapshot.add_transformer( + [ + snapshot.transform.key_value("Name"), + snapshot.transform.key_value("FirewallRuleGroupId"), + snapshot.transform.key_value("FirewallDomainListId"), + ] + ) + + firewall_rule_group_name = f"fw-rule-group-{short_uid()}" + rule_group_response = aws_client.route53resolver.create_firewall_rule_group( + Name=firewall_rule_group_name + ) + cleanups.append( + lambda rule_group_id=rule_group_response["FirewallRuleGroup"][ + "Id" + ]: aws_client.route53resolver.delete_firewall_rule_group( + FirewallRuleGroupId=rule_group_id + ) + ) + # Parameters for creating resources + priorities = [1, 2, 3, 4] + actions = [Action.ALLOW, Action.ALERT, Action.ALERT, Action.ALLOW] + + for action, priority in zip(actions, priorities, strict=False): + domain_list_response = aws_client.route53resolver.create_firewall_domain_list( + Name=f"fw-domain-list-{short_uid()}" + ) + cleanups.append( + lambda domain_list_id=domain_list_response["FirewallDomainList"][ + "Id" + ]: aws_client.route53resolver.delete_firewall_domain_list( + FirewallDomainListId=domain_list_id + ) + ) + create_firewall_rule( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"], + FirewallDomainListId=domain_list_response["FirewallDomainList"]["Id"], + Priority=priority, + Action=action, + ) + + # Check list filtering + list_all_response = aws_client.route53resolver.list_firewall_rules( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"] + ) + snapshot.match("firewall-rules-list-all", list_all_response) + + filter_by_priority_response = aws_client.route53resolver.list_firewall_rules( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"], Priority=1 + ) + snapshot.match("firewall-rules-list-by-priority", filter_by_priority_response) + + filter_by_action_response = aws_client.route53resolver.list_firewall_rules( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"], Action=Action.ALLOW + ) + snapshot.match("firewall-rules-list-by-action", filter_by_action_response) + + action_and_priority_response = aws_client.route53resolver.list_firewall_rules( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"], + Action=Action.ALLOW, + Priority=4, + ) + snapshot.match("firewall-rules-list-by-action-and-priority", action_and_priority_response) + + filter_empty_response = aws_client.route53resolver.list_firewall_rules( + FirewallRuleGroupId=rule_group_response["FirewallRuleGroup"]["Id"], + Action=Action.ALLOW, + Priority=0, # 0 catches cases when integers pose as booleans + ) + snapshot.match("firewall-rules-list-no-match", filter_empty_response) diff --git a/tests/aws/services/route53resolver/test_route53resolver.snapshot.json b/tests/aws/services/route53resolver/test_route53resolver.snapshot.json new file mode 100644 index 0000000000000..bdd7423f0a59f --- /dev/null +++ b/tests/aws/services/route53resolver/test_route53resolver.snapshot.json @@ -0,0 +1,806 @@ +{ + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_endpoint[INBOUND-5]": { + "recorded-date": "08-09-2023, 08:22:31", + "recorded-content": { + "create_resolver_endpoint_res": { + "ResolverEndpoint": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "INBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "rs-", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_endpoint[OUTBOUND-10]": { + "recorded-date": "08-09-2023, 08:25:18", + "recorded-content": { + "create_resolver_endpoint_res": { + "ResolverEndpoint": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "OUTBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "rs-", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_route53resolver_bad_create_endpoint_security_groups": { + "recorded-date": "08-09-2023, 08:35:45", + "recorded-content": { + "inavlid_param_request_res": { + "Error": { + "Code": "InvalidParameterException", + "Message": "[RSLVR-00408] Malformed security group ID: \"Invalid id: \"test-invalid-sg-123\" (expecting \"sg-...\")\"." + }, + "Message": "[RSLVR-00408] Malformed security group ID: \"Invalid id: \"test-invalid-sg-123\" (expecting \"sg-...\")\".", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_multiple_create_resolver_endpoint_with_same_req_id": { + "recorded-date": "08-09-2023, 08:37:11", + "recorded-content": { + "create_resolver_endpoint_res": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "INBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "rs-", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "res_exists_ex_error_code": "ResourceExistsException", + "res_exists_ex_http_status_code": 400 + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_update_resolver_endpoint": { + "recorded-date": "08-09-2023, 08:39:43", + "recorded-content": { + "create_resolver_endpoint_res": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "INBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "update_resolver_endpoint_res": { + "ResolverEndpoint": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "INBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "resolver_endpoint_name", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "OPERATIONAL", + "StatusMessage": "status-message" + }, + "Status": "OPERATIONAL", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_resolver_endpoint": { + "recorded-date": "08-09-2023, 08:42:35", + "recorded-content": { + "create_resolver_endpoint_res": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "INBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "delete_resolver_endpoint_res": { + "ResolverEndpoint": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "INBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "DELETING", + "StatusMessage": "status-message" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_endpoint": { + "recorded-date": "08-09-2023, 08:43:29", + "recorded-content": { + "resource_not_found_ex_error_code": "ResourceNotFoundException", + "resource_not_found_ex_http_status_code": 400 + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_rule": { + "recorded-date": "08-09-2023, 08:47:16", + "recorded-content": { + "create_resolver_endpoint_res": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "OUTBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "rs-", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "create_resolver_rule_res": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example1.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "COMPLETE", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.200", + "Port": 123 + } + ] + }, + "delete_resolver_rule_res": { + "ResolverRule": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example1.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "DELETING", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.200", + "Port": 123 + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_rule_with_invalid_direction": { + "recorded-date": "08-09-2023, 08:52:13", + "recorded-content": { + "create_resolver_endpoint_res": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "INBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "rs-", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "invalid_request_ex": { + "Error": { + "Code": "InvalidRequestException", + "Message": "[RSLVR-00700] Resolver rules can only be associated to OUTBOUND resolver endpoints." + }, + "Message": "[RSLVR-00700] Resolver rules can only be associated to OUTBOUND resolver endpoints.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_multipe_create_resolver_rule": { + "recorded-date": "08-09-2023, 10:10:19", + "recorded-content": { + "create_resolver_endpoint_res": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "OUTBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "rs-", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "create_resolver_rule_res_0": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example0.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "COMPLETE", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.100", + "Port": 123 + } + ] + }, + "delete_resolver_rule_res0": { + "ResolverRule": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example0.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "DELETING", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.100", + "Port": 123 + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_resolver_rule_res_1": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example1.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "COMPLETE", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.100", + "Port": 123 + } + ] + }, + "delete_resolver_rule_res1": { + "ResolverRule": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example1.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "DELETING", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.100", + "Port": 123 + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_resolver_rule_res_2": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example2.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "COMPLETE", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.100", + "Port": 123 + } + ] + }, + "delete_resolver_rule_res2": { + "ResolverRule": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example2.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "DELETING", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.100", + "Port": 123 + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_rule": { + "recorded-date": "08-09-2023, 10:10:29", + "recorded-content": { + "resource_not_found_res": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "[RSLVR-00703] Resolver rule with ID \"id-123\" does not exist." + }, + "Message": "[RSLVR-00703] Resolver rule with ID \"id-123\" does not exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_associate_and_disassociate_resolver_rule": { + "recorded-date": "08-09-2023, 10:43:50", + "recorded-content": { + "create_resolver_endpoint_res": { + "Arn": "arn::route53resolver::111111111111:resolver-endpoint/", + "CreationTime": "date", + "CreatorRequestId": "", + "Direction": "OUTBOUND", + "HostVPCId": "", + "Id": "", + "IpAddressCount": 2, + "ModificationTime": "date", + "Name": "rs-", + "ResolverEndpointType": "IPV4", + "SecurityGroupIds": "sg-ids", + "Status": "CREATING", + "StatusMessage": "status-message" + }, + "create_resolver_rule_res": { + "Arn": "arn::route53resolver::111111111111:resolver-rule/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainName": "www.example4.com.", + "Id": "", + "ModificationTime": "date", + "OwnerId": "111111111111", + "ResolverEndpointId": "", + "RuleType": "FORWARD", + "ShareStatus": "NOT_SHARED", + "Status": "COMPLETE", + "StatusMessage": "status-message", + "TargetIps": [ + { + "Ip": "10.0.1.100", + "Port": 123 + } + ] + }, + "rule_association": { + "ResolverRuleAssociation": { + "Id": "", + "Name": "test-associate-resolver-rule", + "ResolverRuleId": "", + "Status": "COMPLETE", + "StatusMessage": "status-message", + "VPCId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "disassociate_resolver_rule_res": { + "ResolverRuleAssociation": { + "Id": "", + "Name": "test-associate-resolver-rule", + "ResolverRuleId": "", + "Status": "DELETING", + "StatusMessage": "status-message", + "VPCId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_disassociate_non_existent_association": { + "recorded-date": "12-03-2025, 10:21:30", + "recorded-content": { + "resource_not_found_res": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "[RSLVR-00703] Resolver rule with ID \"rslvr-123\" does not exist." + }, + "Message": "[RSLVR-00703] Resolver rule with ID \"rslvr-123\" does not exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_query_log_config": { + "recorded-date": "08-09-2023, 10:27:08", + "recorded-content": { + "create_resolver_query_log_config_res": { + "Arn": "arn::route53resolver::111111111111:resolver-query-log-config/", + "AssociationCount": 0, + "CreationTime": "date", + "CreatorRequestId": "", + "DestinationArn": "arn::logs::111111111111:log-group:", + "Id": "", + "Name": "", + "OwnerId": "111111111111", + "ShareStatus": "NOT_SHARED", + "Status": "CREATED" + }, + "delete_resolver_query_log_config_res": { + "ResolverQueryLogConfig": { + "Arn": "arn::route53resolver::111111111111:resolver-query-log-config/", + "AssociationCount": 0, + "CreationTime": "date", + "CreatorRequestId": "", + "DestinationArn": "arn::logs::111111111111:log-group:", + "Id": "", + "Name": "", + "OwnerId": "111111111111", + "ShareStatus": "NOT_SHARED", + "Status": "DELETING" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_query_log_config": { + "recorded-date": "08-09-2023, 10:37:52", + "recorded-content": { + "resource_not_found_ex": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "[RSLVR-01601] The specified query logging configuration doesn't exist. Trace Id: \"\"" + }, + "Message": "[RSLVR-01601] The specified query logging configuration doesn't exist. Trace Id: \"\"", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_domain_lists": { + "recorded-date": "01-09-2023, 10:05:46", + "recorded-content": { + "create-firewall-domain-list": { + "FirewallDomainList": { + "Arn": "arn::route53resolver::111111111111:firewall-domain-list/", + "CreationTime": "date", + "CreatorRequestId": "", + "DomainCount": 0, + "Id": "", + "ModificationTime": "date", + "Name": "my_firewall_domain", + "Status": "COMPLETE", + "StatusMessage": "status-message" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-firewall-domain-list-filtered": [ + { + "Id": "", + "Arn": "arn::route53resolver::111111111111:firewall-domain-list/", + "Name": "my_firewall_domain", + "CreatorRequestId": "" + } + ], + "list-tags-for-resource": { + "Tags": [ + { + "Key": "hello", + "Value": "world" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules_for_missing_rule_group": { + "recorded-date": "21-01-2025, 16:40:17", + "recorded-content": { + "missing-firewall-rule-group-id": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "[RSLVR-02025] Can\u2019t find the resource with ID \"missing-id\". Trace Id: \"trace-id\"" + }, + "Message": "[RSLVR-02025] Can\u2019t find the resource with ID \"missing-id\". Trace Id: \"trace-id\"", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules_for_empty_rule_group": { + "recorded-date": "21-01-2025, 16:40:17", + "recorded-content": { + "create-firewall-rule-group": { + "FirewallRuleGroup": { + "Arn": "arn::route53resolver::111111111111:firewall-rule-group/", + "CreationTime": "date", + "CreatorRequestId": "", + "Id": "", + "ModificationTime": "date", + "Name": "", + "OwnerId": "111111111111", + "RuleCount": 0, + "ShareStatus": "NOT_SHARED", + "Status": "COMPLETE", + "StatusMessage": "status-message" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty-firewall-rule-group": { + "FirewallRules": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules": { + "recorded-date": "21-01-2025, 16:40:19", + "recorded-content": { + "firewall-rules-list-all": { + "FirewallRules": [ + { + "Action": "ALLOW", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 1 + }, + { + "Action": "ALERT", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 2 + }, + { + "Action": "ALERT", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 3 + }, + { + "Action": "ALLOW", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 4 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "firewall-rules-list-by-priority": { + "FirewallRules": [ + { + "Action": "ALLOW", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 1 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "firewall-rules-list-by-action": { + "FirewallRules": [ + { + "Action": "ALLOW", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 1 + }, + { + "Action": "ALLOW", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 4 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "firewall-rules-list-by-action-and-priority": { + "FirewallRules": [ + { + "Action": "ALLOW", + "CreationTime": "date", + "CreatorRequestId": "", + "FirewallDomainListId": "", + "FirewallDomainRedirectionAction": "INSPECT_REDIRECTION_DOMAIN", + "FirewallRuleGroupId": "", + "ModificationTime": "date", + "Name": "", + "Priority": 4 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "firewall-rules-list-no-match": { + "FirewallRules": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/route53resolver/test_route53resolver.validation.json b/tests/aws/services/route53resolver/test_route53resolver.validation.json new file mode 100644 index 0000000000000..78993845aeaa1 --- /dev/null +++ b/tests/aws/services/route53resolver/test_route53resolver.validation.json @@ -0,0 +1,59 @@ +{ + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_associate_and_disassociate_resolver_rule": { + "last_validated_date": "2023-09-08T08:43:50+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_endpoint[INBOUND-5]": { + "last_validated_date": "2023-09-08T06:22:31+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_endpoint[OUTBOUND-10]": { + "last_validated_date": "2023-09-08T06:25:18+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_query_log_config": { + "last_validated_date": "2023-09-08T08:27:08+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_rule": { + "last_validated_date": "2023-09-08T06:47:16+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_create_resolver_rule_with_invalid_direction": { + "last_validated_date": "2023-09-08T06:52:13+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_endpoint": { + "last_validated_date": "2023-09-08T06:43:29+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_query_log_config": { + "last_validated_date": "2023-09-08T08:37:52+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_non_existent_resolver_rule": { + "last_validated_date": "2023-09-08T08:10:29+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_delete_resolver_endpoint": { + "last_validated_date": "2023-09-08T06:42:35+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_disassociate_non_existent_association": { + "last_validated_date": "2025-03-12T10:21:30+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_domain_lists": { + "last_validated_date": "2023-09-01T08:05:46+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules": { + "last_validated_date": "2025-01-21T16:40:18+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules_for_empty_rule_group": { + "last_validated_date": "2025-01-21T16:40:17+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_list_firewall_rules_for_missing_rule_group": { + "last_validated_date": "2025-01-21T16:40:17+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_multipe_create_resolver_rule": { + "last_validated_date": "2023-09-08T08:10:19+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_multiple_create_resolver_endpoint_with_same_req_id": { + "last_validated_date": "2023-09-08T06:37:11+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_route53resolver_bad_create_endpoint_security_groups": { + "last_validated_date": "2023-09-08T06:35:45+00:00" + }, + "tests/aws/services/route53resolver/test_route53resolver.py::TestRoute53Resolver::test_update_resolver_endpoint": { + "last_validated_date": "2023-09-08T06:39:43+00:00" + } +} diff --git a/tests/aws/services/s3/__init__.py b/tests/aws/services/s3/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/s3/conftest.py b/tests/aws/services/s3/conftest.py new file mode 100644 index 0000000000000..26410c2b8e012 --- /dev/null +++ b/tests/aws/services/s3/conftest.py @@ -0,0 +1,3 @@ +import os + +TEST_S3_IMAGE = os.path.exists("/usr/lib/localstack/.s3-version") diff --git a/tests/aws/services/s3/test_s3.py b/tests/aws/services/s3/test_s3.py new file mode 100644 index 0000000000000..d94d381fffa88 --- /dev/null +++ b/tests/aws/services/s3/test_s3.py @@ -0,0 +1,13470 @@ +import base64 +import contextlib +import datetime +import gzip +import hashlib +import io +import json +import logging +import os +import shutil +import tempfile +import time +from importlib.util import find_spec +from io import BytesIO +from operator import itemgetter +from typing import TYPE_CHECKING +from urllib.parse import SplitResult, parse_qs, quote, urlencode, urlparse, urlunsplit +from zoneinfo import ZoneInfo + +import boto3 as boto3 +import botocore +import pytest +import requests +import xmltodict +from boto3.s3.transfer import KB, TransferConfig +from botocore import UNSIGNED +from botocore.auth import SigV4Auth +from botocore.client import Config +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import RegexTransformer + +import localstack.config +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.api.s3 import StorageClass, TransitionDefaultMinimumObjectSize +from localstack.config import S3_VIRTUAL_HOSTNAME +from localstack.constants import ( + AWS_REGION_US_EAST_1, + LOCALHOST_HOSTNAME, +) +from localstack.services.s3 import constants as s3_constants +from localstack.services.s3.utils import ( + RFC1123, + etag_to_base_64_content_md5, + parse_expiration_header, + rfc_1123_datetime, +) +from localstack.testing.aws.util import in_default_partition, is_aws_cloud +from localstack.testing.config import ( + SECONDARY_TEST_AWS_ACCESS_KEY_ID, + SECONDARY_TEST_AWS_SECRET_ACCESS_KEY, + TEST_AWS_ACCESS_KEY_ID, +) +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import TransformerUtility +from localstack.utils import testutil +from localstack.utils.aws.arns import get_partition +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.aws.resources import create_s3_bucket +from localstack.utils.files import load_file +from localstack.utils.run import run +from localstack.utils.strings import ( + checksum_crc32, + checksum_crc32c, + checksum_crc64nvme, + hash_sha1, + hash_sha256, + long_uid, + short_uid, + to_bytes, + to_str, +) +from localstack.utils.sync import retry +from localstack.utils.testutil import check_expected_lambda_log_events_length +from localstack.utils.urls import localstack_host as get_localstack_host +from tests.aws.services.s3.conftest import TEST_S3_IMAGE + +if TYPE_CHECKING: + from mypy_boto3_s3 import S3Client + +LOG = logging.getLogger(__name__) + + +# transformer list to transform headers, that will be validated for some specific s3-tests +HEADER_TRANSFORMER = [ + TransformerUtility.jsonpath("$..HTTPHeaders.date", "date", reference_replacement=False), + TransformerUtility.jsonpath( + "$..HTTPHeaders.last-modified", "last-modified", reference_replacement=False + ), + TransformerUtility.jsonpath("$..HTTPHeaders.server", "server", reference_replacement=False), + TransformerUtility.jsonpath("$..HTTPHeaders.x-amz-id-2", "id-2", reference_replacement=False), + TransformerUtility.jsonpath( + "$..HTTPHeaders.x-amz-request-id", "request-id", reference_replacement=False + ), + TransformerUtility.key_value("HostId", reference_replacement=False), + TransformerUtility.key_value("RequestId", reference_replacement=False), +] + +S3_ASSUME_ROLE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "s3.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} + +S3_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:*", + ], + "Resource": "*", + } + ], +} + + +@pytest.fixture +def anonymous_client(aws_client_factory, region_name): + """ + This fixture returns a factory that creates a client for a given service. This client is configured with credentials + that can be effectively be treated as anonymous. + """ + + def _anonymous_client(service_name: str): + return aws_client_factory.get_client( + service_name=service_name, + region_name=region_name, + aws_access_key_id=SECONDARY_TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=SECONDARY_TEST_AWS_SECRET_ACCESS_KEY, + config=Config(signature_version=UNSIGNED), + ) + + yield _anonymous_client + + +@pytest.fixture(scope="function") +def patch_s3_skip_signature_validation_false(monkeypatch): + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", False) + + +@pytest.fixture +def s3_multipart_upload(aws_client): + def perform_multipart_upload( + bucket, key, data=None, zipped=False, acl=None, parts: int = 1, **kwargs + ): + # beware, the last part can be under 5 MiB, but previous parts needs to be between 5MiB and 5GiB + if acl: + kwargs["ACL"] = acl + multipart_upload_dict = aws_client.s3.create_multipart_upload( + Bucket=bucket, Key=key, **kwargs + ) + upload_id = multipart_upload_dict["UploadId"] + data = data or (5 * short_uid()) + multipart_upload_parts = [] + for part in range(parts): + # Write contents to memory rather than a file. + part_number = part + 1 + + part_data = data or (5 * short_uid()) + if part_number < parts and ((len_data := len(part_data)) < 5_242_880): + # data must be at least 5MiB + multiple = 5_242_880 // len_data + part_data = part_data * (multiple + 1) + + part_data = to_bytes(part_data) + upload_file_object = BytesIO(part_data) + if zipped: + upload_file_object = BytesIO() + with gzip.GzipFile(fileobj=upload_file_object, mode="w") as filestream: + filestream.write(part_data) + + response = aws_client.s3.upload_part( + Bucket=bucket, + Key=key, + Body=upload_file_object, + PartNumber=part_number, + UploadId=upload_id, + ) + + multipart_upload_parts.append({"ETag": response["ETag"], "PartNumber": part_number}) + # multiple parts won't work with zip, stop at one + if zipped: + break + + return aws_client.s3.complete_multipart_upload( + Bucket=bucket, + Key=key, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + + return perform_multipart_upload + + +@pytest.fixture +def s3_multipart_upload_with_snapshot(aws_client, snapshot): + def perform_multipart_upload( + bucket: str, key: str, data: bytes, snapshot_prefix: str, **kwargs + ): + create_multipart_resp = aws_client.s3.create_multipart_upload( + Bucket=bucket, Key=key, **kwargs + ) + snapshot.match(f"{snapshot_prefix}-create-multipart", create_multipart_resp) + upload_id = create_multipart_resp["UploadId"] + + # Write contents to memory rather than a file. + upload_file_object = BytesIO(data) + + response = aws_client.s3.upload_part( + Bucket=bucket, + Key=key, + Body=upload_file_object, + PartNumber=1, + UploadId=upload_id, + ) + snapshot.match(f"{snapshot_prefix}-upload-part", response) + + response = aws_client.s3.complete_multipart_upload( + Bucket=bucket, + Key=key, + MultipartUpload={"Parts": [{"ETag": response["ETag"], "PartNumber": 1}]}, + UploadId=upload_id, + ) + snapshot.match(f"{snapshot_prefix}-compete-multipart", response) + return response + + return perform_multipart_upload + + +@pytest.fixture +def create_tmp_folder_lambda(): + cleanup_folders = [] + + def prepare_folder(path_to_lambda, run_command=None): + tmp_dir = tempfile.mkdtemp() + shutil.copy(path_to_lambda, tmp_dir) + if run_command: + run(f"cd {tmp_dir}; {run_command}") + cleanup_folders.append(tmp_dir) + return tmp_dir + + yield prepare_folder + + for folder in cleanup_folders: + try: + shutil.rmtree(folder) + except Exception: + LOG.warning("could not delete folder %s", folder) + + +@pytest.fixture +def allow_bucket_acl(s3_bucket, aws_client): + """ + # Since April 2023, AWS will by default block setting ACL to your bucket and object. You need to manually disable + # the BucketOwnershipControls and PublicAccessBlock to make your objects public. + # See https://aws.amazon.com/about-aws/whats-new/2022/12/amazon-s3-automatically-enable-block-public-access-disable-access-control-lists-buckets-april-2023/ + """ + aws_client.s3.delete_bucket_ownership_controls(Bucket=s3_bucket) + aws_client.s3.delete_public_access_block(Bucket=s3_bucket) + + +def _filter_header(param: dict) -> dict: + return {k: v for k, v in param.items() if k.startswith("x-amz") or k in ["content-type"]} + + +def _simple_bucket_policy(s3_bucket: str) -> dict: + return { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": f"arn:aws:s3:::{s3_bucket}/*", + "Principal": {"AWS": "*"}, + } + ], + } + + +class TestS3: + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + def test_copy_object_kms(self, s3_bucket, kms_create_key, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + # because of the kms-key, the etag will be different on AWS + # FIXME there is currently no server side encryption is place and thus the etag is the same for the copied objects in LS + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..CopyObjectResult.ETag", "copy-etag", reference_replacement=False + ) + ) + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..get-copied-object.ETag", "etag", reference_replacement=False + ) + ) + snapshot.add_transformer(snapshot.transform.key_value("SSEKMSKeyId", "key-id")) + key_id = kms_create_key()["KeyId"] + body = "hello world" + aws_client.s3.put_object(Bucket=s3_bucket, Key="mykey", Body=body) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key="mykey") + snapshot.match("get-object", response) + response = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/mykey", + Key="copiedkey", + BucketKeyEnabled=True, + SSEKMSKeyId=key_id, + ServerSideEncryption="aws:kms", + ) + snapshot.match("copy-object", response) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key="copiedkey") + snapshot.match("get-copied-object", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..AccessPointAlias"]) + def test_region_header_exists_outside_us_east_1( + self, s3_create_bucket_with_client, snapshot, aws_client_factory + ): + """ + We need the region to be hardcoded to something else than `us-east-1`, as you cannot create a bucket with + a LocationConstraint in that region + """ + snapshot.add_transformer(snapshot.transform.s3_api()) + region_us_west_2 = "us-west-2" + snapshot.add_transformer(RegexTransformer(region_us_west_2, "")) + client_us_east_1 = aws_client_factory(region_name=AWS_REGION_US_EAST_1).s3 + bucket_us_west_2 = f"test-bucket-{short_uid()}" + s3_create_bucket_with_client( + client_us_east_1, + Bucket=bucket_us_west_2, + CreateBucketConfiguration={"LocationConstraint": region_us_west_2}, + ) + + response = client_us_east_1.head_bucket(Bucket=bucket_us_west_2) + assert ( + response["ResponseMetadata"]["HTTPHeaders"]["x-amz-bucket-region"] == region_us_west_2 + ) + snapshot.match("head_bucket", response) + response = client_us_east_1.list_objects_v2(Bucket=bucket_us_west_2) + assert ( + response["ResponseMetadata"]["HTTPHeaders"]["x-amz-bucket-region"] == region_us_west_2 + ) + snapshot.match("list_objects_v2", response) + + bucket_us_east_1 = f"test-bucket-{short_uid()}" + s3_create_bucket_with_client(client_us_east_1, Bucket=bucket_us_east_1) + response = client_us_east_1.head_bucket(Bucket=bucket_us_east_1) + assert ( + response["ResponseMetadata"]["HTTPHeaders"]["x-amz-bucket-region"] + == AWS_REGION_US_EAST_1 + ) + + @markers.aws.validated + # TODO list-buckets contains other buckets when running in CI + @markers.snapshot.skip_snapshot_verify(paths=["$..Prefix", "$..list-buckets.Buckets"]) + def test_delete_bucket_with_content(self, s3_bucket, s3_empty_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + bucket_name = s3_bucket + + for i in range(0, 10, 1): + body = "test-" + str(i) + key = "test-key-" + str(i) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=body) + + resp = aws_client.s3.list_objects(Bucket=bucket_name, MaxKeys=100) + snapshot.match("list-objects", resp) + assert 10 == len(resp["Contents"]) + + s3_empty_bucket(bucket_name) + aws_client.s3.delete_bucket(Bucket=bucket_name) + + resp = aws_client.s3.list_buckets() + # TODO - this fails in the CI pipeline and is currently skipped from verification + snapshot.match("list-buckets", resp) + assert bucket_name not in [b["Name"] for b in resp["Buckets"]] + + @markers.aws.validated + def test_put_and_get_object_with_utf8_key(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + response = aws_client.s3.put_object(Bucket=s3_bucket, Key="Δ€0Γ„", Body=b"abc123") + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + snapshot.match("put-object", response) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key="Δ€0Γ„") + snapshot.match("get-object", response) + assert response["Body"].read() == b"abc123" + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..MaxAttemptsReached"]) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..HTTPHeaders.connection", + # TODO content-length and type is wrong, skipping for now + "$..HTTPHeaders.content-length", # 58, but should be 0 # TODO!!! + "$..HTTPHeaders.content-type", # application/xml but should not be set + ], + ) + def test_put_and_get_object_with_content_language_disposition( + self, s3_bucket, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(HEADER_TRANSFORMER) + + response = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test", + Body=b"abc123", + ContentLanguage="de", + ContentDisposition='attachment; filename="foo.jpg"', + CacheControl="no-cache", + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + snapshot.match("put-object", response) + snapshot.match("put-object-headers", response["ResponseMetadata"]) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key="test") + snapshot.match("get-object", response) + snapshot.match("get-object-headers", response["ResponseMetadata"]) + assert response["Body"].read() == b"abc123" + + @markers.aws.validated + @pytest.mark.parametrize( + "use_virtual_address", + [True, False], + ) + def test_object_with_slashes_in_key( + self, s3_bucket, aws_client_factory, use_virtual_address, snapshot + ): + snapshot.add_transformer(snapshot.transform.key_value("Name")) + s3_config = {"addressing_style": "virtual"} if use_virtual_address else {} + s3_client = aws_client_factory( + config=Config(s3=s3_config), + endpoint_url=_endpoint_url(), + ).s3 + + s3_client.put_object(Bucket=s3_bucket, Key="/foo", Body=b"foobar") + s3_client.put_object(Bucket=s3_bucket, Key="bar", Body=b"barfoo") + s3_client.put_object(Bucket=s3_bucket, Key="/bar/foo/", Body=b"test") + + list_objects = s3_client.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects-slashes", list_objects) + + with pytest.raises(ClientError, match="NoSuchKey"): + s3_client.get_object(Bucket=s3_bucket, Key="foo") + + with pytest.raises(ClientError, match="NoSuchKey"): + s3_client.get_object(Bucket=s3_bucket, Key="//foo") + + with pytest.raises(ClientError, match="NoSuchKey"): + s3_client.get_object(Bucket=s3_bucket, Key="/bar") + + response = s3_client.get_object(Bucket=s3_bucket, Key="/foo") + assert response["Body"].read() == b"foobar" + response = s3_client.get_object(Bucket=s3_bucket, Key="bar") + assert response["Body"].read() == b"barfoo" + response = s3_client.get_object(Bucket=s3_bucket, Key="/bar/foo/") + assert response["Body"].read() == b"test" + + @markers.aws.validated + def test_metadata_header_character_decoding(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + # Object metadata keys should accept keys with underscores + # https://github.com/localstack/localstack/issues/1790 + # put object + object_key = "key-with-metadata" + metadata = {"TEST_META_1": "foo", "__meta_2": "bar"} + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Metadata=metadata, Body="foo") + metadata_saved = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", metadata_saved) + + # note that casing is removed (since headers are case-insensitive) + assert metadata_saved["Metadata"] == {"test_meta_1": "foo", "__meta_2": "bar"} + + @markers.aws.validated + def test_upload_file_multipart(self, s3_bucket, tmpdir, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + key = "my-key" + # https://boto3.amazonaws.com/v1/documentation/api/latest/guide/s3.html#multipart-transfers + tranfer_config = TransferConfig(multipart_threshold=5 * KB, multipart_chunksize=1 * KB) + + file = tmpdir / "test-file.bin" + data = b"1" * (6 * KB) # create 6 kilobytes of ones + file.write(data=data, mode="w") + aws_client.s3.upload_file( + Bucket=s3_bucket, Key=key, Filename=str(file.realpath()), Config=tranfer_config + ) + + obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + assert obj["Body"].read() == data, f"body did not contain expected data {obj}" + snapshot.match("get_object", obj) + + @markers.aws.validated + @pytest.mark.parametrize( + "key", + [ + "file%2Fname", + "test@key/", + "test%123", + "test%percent", + "test key/", + "test key//", + "test%123/", + "a/%F0%9F%98%80/", + ], + ) + def test_put_get_object_special_character(self, s3_bucket, aws_client, snapshot, key): + snapshot.add_transformer(snapshot.transform.s3_api()) + resp = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"test") + snapshot.match("put-object-special-char", resp) + resp = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-object-special-char", resp) + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-object-special-char", resp) + resp = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("del-object-special-char", resp) + + @markers.aws.validated + def test_put_get_object_single_character_trailing_slash(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Name")) + single_chars = [ + "a/", + "t/", + "u/", + ] + for char in single_chars: + resp = aws_client.s3.put_object(Bucket=s3_bucket, Key=char, Body=b"test") + snapshot.match(f"put-object-single-char-{char}", resp) + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=char) + snapshot.match(f"get-object-single-char-{char}", resp) + + resp = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects-single-char", resp) + + @markers.aws.validated + def test_copy_object_special_character(self, s3_bucket, s3_create_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + dest_bucket = s3_create_bucket() + special_keys = [ + "file%2Fname", + "test@key/", + "test key/", + "test key//", + "a/%F0%9F%98%80/", + "test+key", + ] + + for key in special_keys: + resp = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"test") + snapshot.match(f"put-object-src-special-char-{key}", resp) + + copy_obj = aws_client.s3.copy_object( + Bucket=dest_bucket, + Key=key, + CopySource=f"{s3_bucket}/{key}", + ) + snapshot.match(f"copy-object-special-char-{key}", copy_obj) + + resp = aws_client.s3.list_objects_v2(Bucket=dest_bucket) + snapshot.match("list-object-copy-dest-special-char", resp) + + @markers.aws.validated + def test_copy_object_special_character_plus_for_space( + self, s3_bucket, aws_client, aws_http_client_factory + ): + """ + Different languages don't always handle the space character the same way when encoding URL. Python uses %20 + when Go for example encodes it with `+`, which is the form way. This leads to a specific edge case for + the CopySource header. + """ + object_key = "test key.txt" + dest_key = "dest-key" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="test-body") + + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + bucket_url = _bucket_url(s3_bucket) + + copy_object_url = f"{bucket_url}/{dest_key}" + copy_source = f"{s3_bucket}%2F{object_key.replace(' ', '+')}" + copy_resp = s3_http_client.put( + copy_object_url, + headers={ + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + "x-amz-copy-source": copy_source, + }, + ) + assert copy_resp.ok + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=dest_key) + assert head_object["ResponseMetadata"]["HTTPStatusCode"] == 200 + + @markers.aws.validated + @pytest.mark.parametrize( + "use_virtual_address", + [True, False], + ) + def test_url_encoded_key(self, s3_bucket, aws_client_factory, snapshot, use_virtual_address): + """Boto adds a trailing slash always?""" + snapshot.add_transformer(snapshot.transform.key_value("Name")) + s3_config = {"addressing_style": "virtual"} if use_virtual_address else {} + s3_client = aws_client_factory( + config=Config(s3=s3_config), + endpoint_url=_endpoint_url(), + ).s3 + + key = "test@key/" + s3_client.put_object(Bucket=s3_bucket, Key=key, Body=b"test-non-encoded") + encoded_key = "test%40key/" + s3_client.put_object(Bucket=s3_bucket, Key=encoded_key, Body=b"test-encoded") + encoded_key_no_trailing = "test%40key" + s3_client.put_object( + Bucket=s3_bucket, Key=encoded_key_no_trailing, Body=b"test-encoded-no-trailing" + ) + # assert that one did not override the over, and that both key are different + assert s3_client.get_object(Bucket=s3_bucket, Key=key)["Body"].read() == b"test-non-encoded" + assert ( + s3_client.get_object(Bucket=s3_bucket, Key=encoded_key)["Body"].read() + == b"test-encoded" + ) + assert ( + s3_client.get_object(Bucket=s3_bucket, Key=encoded_key_no_trailing)["Body"].read() + == b"test-encoded-no-trailing" + ) + + resp = s3_client.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-object-encoded-char", resp) + + @markers.aws.validated + def test_get_object_no_such_bucket(self, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=f"does-not-exist-{short_uid()}", Key="foobar") + + snapshot.match("expected_error", e.value.response) + + @markers.aws.validated + def test_delete_bucket_no_such_bucket(self, snapshot, aws_client): + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket(Bucket="does-not-exist-localstack-test") + + snapshot.match("expected_error", e.value.response) + + @markers.aws.validated + def test_get_bucket_notification_configuration_no_such_bucket(self, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_notification_configuration( + Bucket=f"doesnotexist-{short_uid()}" + ) + + snapshot.match("expected_error", e.value.response) + + @markers.aws.validated + def test_get_object_attributes(self, s3_bucket, snapshot, s3_multipart_upload, aws_client): + aws_client.s3.put_object(Bucket=s3_bucket, Key="data.txt", Body=b"69\n420\n") + response = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key="data.txt", + ObjectAttributes=["StorageClass", "ETag", "ObjectSize", "ObjectParts", "Checksum"], + ) + snapshot.match("object-attrs", response) + + multipart_key = "test-get-obj-attrs-multipart" + s3_multipart_upload(bucket=s3_bucket, key=multipart_key, data="upload-part-1" * 5) + response = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=multipart_key, + ObjectAttributes=["StorageClass", "ETag", "ObjectSize", "ObjectParts"], + ) + snapshot.match("object-attrs-multiparts-1-part", response) + + multipart_key = "test-get-obj-attrs-multipart-2" + s3_multipart_upload(bucket=s3_bucket, key=multipart_key, data="upload-part-1" * 5, parts=2) + response = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=multipart_key, + ObjectAttributes=["StorageClass", "ETag", "ObjectSize", "ObjectParts"], + MaxParts=3, + ) + snapshot.match("object-attrs-multiparts-2-parts", response) + + multipart_key = "test-get-obj-attrs-multipart-2" + s3_multipart_upload(bucket=s3_bucket, key=multipart_key, data="upload-part-1" * 5, parts=2) + response = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=multipart_key, + ObjectAttributes=["StorageClass", "ETag", "ObjectSize", "Checksum"], + MaxParts=3, + ) + snapshot.match("object-attrs-multiparts-2-parts-checksum", response) + + @markers.aws.validated + def test_get_object_attributes_with_space( + self, s3_bucket, aws_client, aws_http_client_factory, snapshot + ): + """ + It seems AWS SDKs are aligning themselves and are now putting whitespace between comas in headers list + See https://github.com/aws/aws-sdk-ruby/issues/3032 + https://www.rfc-editor.org/rfc/rfc9110.html#name-lists-rule-abnf-extension + """ + object_key = "test-attrs" + aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body="test-body", ChecksumAlgorithm="SHA256" + ) + + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + bucket_url = _bucket_url(s3_bucket) + + get_object_attrs_url = f"{bucket_url}/{object_key}?attributes" + get_obj_attrs_resp = s3_http_client.get( + get_object_attrs_url, + headers={ + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + "x-amz-object-attributes": "ETag, Checksum, ObjectParts, StorageClass, ObjectSize", + }, + ) + assert get_obj_attrs_resp.ok + body = xmltodict.parse(get_obj_attrs_resp.content) + snapshot.match("get-attrs-with-whitespace", body) + + get_obj_attrs_resp = s3_http_client.get( + get_object_attrs_url, + headers={ + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + "x-amz-object-attributes": "ETag,Checksum,ObjectParts,StorageClass,ObjectSize", + }, + ) + assert get_obj_attrs_resp.ok + body = xmltodict.parse(get_obj_attrs_resp.content) + snapshot.match("get-attrs-without-whitespace", body) + + @markers.aws.validated + def test_get_object_attributes_versioned(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + key = "key-attrs-versioned" + put_obj_1 = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"69\n420\n") + snapshot.match("put-obj-v1", put_obj_1) + + put_obj_2 = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"version 2") + snapshot.match("put-obj-v2", put_obj_2) + + response = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key, + ObjectAttributes=["StorageClass", "ETag", "ObjectSize", "ObjectParts", "Checksum"], + ) + snapshot.match("object-attrs", response) + + response = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("delete-key", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key, + ObjectAttributes=["StorageClass", "ETag", "ObjectSize"], + ) + snapshot.match("deleted-object-attrs", e.value.response) + + response = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key, + VersionId=put_obj_1["VersionId"], + ObjectAttributes=["StorageClass", "ETag", "ObjectSize"], + ) + snapshot.match("get-object-attrs-v1", response) + + @markers.aws.validated + def test_multipart_and_list_parts(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value( + "ID", value_replacement="owner-id", reference_replacement=False + ), + ] + ) + + key_name = "test-list-parts" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name) + snapshot.match("create-multipart", response) + upload_id = response["UploadId"] + + list_part = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + snapshot.match("list-part-after-created", list_part) + + list_multipart_uploads = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-all-uploads", list_multipart_uploads) + + # Write contents to memory rather than a file. + data = "upload-part-1" * 5 + data = to_bytes(data) + upload_file_object = BytesIO(data) + + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=upload_file_object, + PartNumber=1, + UploadId=upload_id, + ) + snapshot.match("upload-part", response) + list_part = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + snapshot.match("list-part-after-upload", list_part) + + list_multipart_uploads = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-all-uploads-after", list_multipart_uploads) + + multipart_upload_parts = [{"ETag": response["ETag"], "PartNumber": 1}] + + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart", response) + with pytest.raises(ClientError) as e: + aws_client.s3.list_parts(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + snapshot.match("list-part-after-complete-exc", e.value.response) + + list_multipart_uploads = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-all-uploads-completed", list_multipart_uploads) + + head_object = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("head-multipart-checksum", head_object) + + get_object = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("get-multipart-checksum", get_object) + + @markers.aws.validated + def test_multipart_no_such_upload(self, s3_bucket, snapshot, aws_client): + fake_upload_id = "fakeid" + fake_key = "fake-key" + + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=fake_key, + Body=BytesIO(b"data"), + PartNumber=1, + UploadId=fake_upload_id, + ) + snapshot.match("upload-exc", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, Key=fake_key, UploadId=fake_upload_id + ) + snapshot.match("complete-exc", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.abort_multipart_upload( + Bucket=s3_bucket, Key=fake_key, UploadId=fake_upload_id + ) + snapshot.match("abort-exc", e.value.response) + + @markers.aws.validated + def test_multipart_complete_multipart_too_small(self, s3_bucket, snapshot, aws_client): + key_name = "test-upload-part-exc" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name) + upload_id = response["UploadId"] + + parts = [] + + for i in range(1, 3): + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=BytesIO(b"data"), + PartNumber=i, + UploadId=upload_id, + ) + parts.append({"ETag": upload_part["ETag"], "PartNumber": i}) + snapshot.match(f"upload-part{i}", upload_part) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, Key=key_name, UploadId=upload_id + ) + snapshot.match("complete-exc-no-parts", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, Key=key_name, UploadId=upload_id, MultipartUpload={"Parts": parts} + ) + snapshot.match("complete-exc-too-small", e.value.response) + + @markers.aws.validated + def test_multipart_complete_multipart_wrong_part(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("UploadId")) + key_name = "test-upload-part-exc" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name) + upload_id = response["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=BytesIO(b"data"), + PartNumber=1, + UploadId=upload_id, + ) + part_etag = upload_part["ETag"] + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + UploadId=upload_id, + MultipartUpload={"Parts": [{"ETag": part_etag, "PartNumber": 2}]}, + ) + snapshot.match("complete-exc-wrong-part-number", e.value.response) + + with pytest.raises(ClientError) as e: + wrong_etag = "d41d8cd98f00b204e9800998ecf8427e" + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + UploadId=upload_id, + MultipartUpload={"Parts": [{"ETag": wrong_etag, "PartNumber": 1}]}, + ) + snapshot.match("complete-exc-wrong-etag", e.value.response) + + @markers.aws.validated + def test_put_and_get_object_with_hash_prefix(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + key_name = "#key-with-hash-prefix" + content = b"test 123" + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body=content) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + snapshot.match("put-object", response) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-object", response) + assert response["Body"].read() == content + + @markers.aws.validated + def test_invalid_range_error(self, s3_bucket, snapshot, aws_client): + key = "my-key" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"abcdefgh") + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=1024-4096") + snapshot.match("exc", e.value.response) + + @markers.aws.validated + def test_range_key_not_exists(self, s3_bucket, snapshot, aws_client): + key = "my-key" + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=1024-4096") + + snapshot.match("exc", e.value.response) + + @markers.aws.validated + def test_create_bucket_via_host_name(self, s3_vhost_client, aws_client, region_name): + # TODO check redirection (happens in AWS because of region name), should it happen in LS? + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#VirtualHostingBackwardsCompatibility + bucket_name = f"test-{short_uid()}" + try: + response = s3_vhost_client.create_bucket( + Bucket=bucket_name, + CreateBucketConfiguration={ + "LocationConstraint": region_name + if region_name != "us-east-1" + else "eu-central-1" + }, + ) + assert "Location" in response + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + response = s3_vhost_client.get_bucket_location(Bucket=bucket_name) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert ( + response["LocationConstraint"] == region_name + if region_name != "us-east-1" + else "eu-central-1" + ) + finally: + s3_vhost_client.delete_bucket(Bucket=bucket_name) + + @markers.aws.validated + def test_get_bucket_policy(self, s3_bucket, snapshot, aws_client, allow_bucket_acl, account_id): + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_policy(Bucket=s3_bucket) + snapshot.match("get-bucket-policy-no-such-bucket-policy", e.value.response) + + policy = _simple_bucket_policy(s3_bucket) + aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy)) + + # retrieve and check policy config + response = aws_client.s3.get_bucket_policy(Bucket=s3_bucket) + snapshot.match("get-bucket-policy", response) + assert policy == json.loads(response["Policy"]) + + response = aws_client.s3.get_bucket_policy(Bucket=s3_bucket, ExpectedBucketOwner=account_id) + snapshot.match("get-bucket-policy-with-expected-bucket-owner", response) + assert policy == json.loads(response["Policy"]) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_policy(Bucket=s3_bucket, ExpectedBucketOwner="000000000002") + snapshot.match("get-bucket-policy-with-expected-bucket-owner-error", e.value.response) + + @pytest.mark.parametrize( + "invalid_account_id", ["0000", "0000000000020", "abcd", "aa000000000$"] + ) + @markers.aws.validated + def test_get_bucket_policy_invalid_account_id( + self, s3_bucket, snapshot, aws_client, invalid_account_id + ): + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_policy( + Bucket=s3_bucket, ExpectedBucketOwner=invalid_account_id + ) + + snapshot.match("get-bucket-policy-invalid-bucket-owner", e.value.response) + + @markers.aws.validated + def test_put_bucket_policy(self, s3_bucket, snapshot, aws_client, allow_bucket_acl): + # just for the joke: Response syntax HTTP/1.1 200 + # sample response: HTTP/1.1 204 No Content + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketPolicy.html + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + # put bucket policy + policy = _simple_bucket_policy(s3_bucket) + response = aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy)) + snapshot.match("put-bucket-policy", response) + + response = aws_client.s3.get_bucket_policy(Bucket=s3_bucket) + snapshot.match("get-bucket-policy", response) + assert policy == json.loads(response["Policy"]) + + @markers.aws.validated + def test_put_bucket_policy_expected_bucket_owner( + self, s3_bucket, snapshot, aws_client, allow_bucket_acl, account_id, secondary_account_id + ): + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + policy = _simple_bucket_policy(s3_bucket) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_policy( + Bucket=s3_bucket, + Policy=json.dumps(policy), + ExpectedBucketOwner=secondary_account_id, + ) + snapshot.match("put-bucket-policy-with-expected-bucket-owner-error", e.value.response) + + response = aws_client.s3.put_bucket_policy( + Bucket=s3_bucket, Policy=json.dumps(policy), ExpectedBucketOwner=account_id + ) + snapshot.match("put-bucket-policy-with-expected-bucket-owner", response) + + @pytest.mark.parametrize( + "invalid_account_id", ["0000", "0000000000020", "abcd", "aa000000000$"] + ) + @markers.aws.validated + def test_put_bucket_policy_invalid_account_id( + self, s3_bucket, snapshot, aws_client, invalid_account_id + ): + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + policy = _simple_bucket_policy(s3_bucket) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_policy( + Bucket=s3_bucket, Policy=json.dumps(policy), ExpectedBucketOwner=invalid_account_id + ) + + snapshot.match("put-bucket-policy-invalid-bucket-owner", e.value.response) + + @markers.aws.validated + def test_delete_bucket_policy(self, s3_bucket, snapshot, aws_client, allow_bucket_acl): + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + + policy = _simple_bucket_policy(s3_bucket) + aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy)) + + response = aws_client.s3.delete_bucket_policy(Bucket=s3_bucket) + snapshot.match("delete-bucket-policy", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_policy(Bucket=s3_bucket) + snapshot.match("get-bucket-policy-no-such-bucket-policy", e.value.response) + + @markers.aws.validated + def test_delete_bucket_policy_expected_bucket_owner( + self, s3_bucket, snapshot, aws_client, allow_bucket_acl, account_id, secondary_account_id + ): + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + + policy = _simple_bucket_policy(s3_bucket) + aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy)) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket_policy( + Bucket=s3_bucket, ExpectedBucketOwner=secondary_account_id + ) + snapshot.match("delete-bucket-policy-with-expected-bucket-owner-error", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket_policy(Bucket=s3_bucket, ExpectedBucketOwner="invalid") + snapshot.match("delete-bucket-policy-invalid-bucket-owner", e.value.response) + + response = aws_client.s3.delete_bucket_policy( + Bucket=s3_bucket, ExpectedBucketOwner=account_id + ) + snapshot.match("delete-bucket-policy-with-expected-bucket-owner", response) + + @markers.aws.validated + def test_put_object_tagging_empty_list(self, s3_bucket, snapshot, aws_client): + key = "my-key" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"abcdefgh") + + object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=key) + snapshot.match("created-object-tags", object_tags) + + tag_set = {"TagSet": [{"Key": "tag1", "Value": "tag1"}, {"Key": "tag2", "Value": ""}]} + aws_client.s3.put_object_tagging(Bucket=s3_bucket, Key=key, Tagging=tag_set) + + object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=key) + snapshot.match("updated-object-tags", object_tags) + + aws_client.s3.put_object_tagging(Bucket=s3_bucket, Key=key, Tagging={"TagSet": []}) + + object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=key) + snapshot.match("deleted-object-tags", object_tags) + + @markers.aws.validated + def test_head_object_fields(self, s3_bucket, snapshot, aws_client): + key = "my-key" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"abcdefgh") + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key) + snapshot.match("head-object", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.head_object(Bucket=s3_bucket, Key="doesnotexist") + snapshot.match("head-object-404", e.value.response) + + @markers.aws.validated + def test_get_object_after_deleted_in_versioned_bucket(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + + key = "my-key" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"abcdefgh") + + s3_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-object", s3_obj) + + aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + + snapshot.match("get-object-after-delete", e.value.response) + + @markers.aws.validated + def test_s3_copy_metadata_replace(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + object_key = "source-object" + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + Metadata={"key": "value"}, + ContentLanguage="en-US", + ) + snapshot.match("put_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head_object", head_object) + + object_key_copy = f"{object_key}-copy" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key_copy, + Metadata={"another-key": "value"}, + ContentType="image/jpg", + MetadataDirective="REPLACE", + ) + snapshot.match("copy_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key_copy) + snapshot.match("head_object_copy", head_object) + + @markers.aws.validated + def test_s3_copy_metadata_directive_copy(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + object_key = "source-object" + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body="test", + Metadata={"key": "value"}, + ContentLanguage="en-US", + ) + snapshot.match("put-object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + object_key_copy = f"{object_key}-copy" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key_copy, + Metadata={"another-key": "value"}, # this will be ignored + ContentLanguage="en-GB", + ContentType="image/jpg", + MetadataDirective="COPY", + ) + snapshot.match("copy-object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key_copy) + snapshot.match("head-object-copy", head_object) + + @markers.aws.validated + @pytest.mark.parametrize("tagging_directive", ["COPY", "REPLACE", None]) + def test_s3_copy_tagging_directive(self, s3_bucket, snapshot, aws_client, tagging_directive): + snapshot.add_transformer(snapshot.transform.s3_api()) + + object_key = "source-object" + resp = aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body="test", Tagging="key1=value1" + ) + snapshot.match("put-object", resp) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tag", get_object_tags) + + kwargs = {"TaggingDirective": tagging_directive} if tagging_directive else {} + + object_key_copy = f"{object_key}-copy" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key_copy, + Tagging="key2=value2", + **kwargs, + ) + snapshot.match("copy-object", resp) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key_copy) + snapshot.match("get-copy-object-tag", get_object_tags) + + object_key_copy_tag_empty = f"{object_key}-copy-tag-empty" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key_copy_tag_empty, + **kwargs, + ) + snapshot.match("copy-object-tag-empty", resp) + + get_object_tags = aws_client.s3.get_object_tagging( + Bucket=s3_bucket, Key=object_key_copy_tag_empty + ) + snapshot.match("get-copy-object-tag-empty", get_object_tags) + + @markers.aws.validated + @pytest.mark.parametrize("tagging_directive", ["COPY", "REPLACE", None]) + def test_s3_copy_tagging_directive_versioned( + self, s3_bucket, snapshot, aws_client, tagging_directive + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + object_key = "source-object" + resp = aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body="test", Tagging="key1=value1" + ) + snapshot.match("put-object", resp) + version_1 = resp["VersionId"] + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body="test-v2", Tagging="key1=value1-v2" + ) + snapshot.match("put-object-v2", resp) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tag", get_object_tags) + + get_object_tags_v1 = aws_client.s3.get_object_tagging( + Bucket=s3_bucket, Key=object_key, VersionId=version_1 + ) + snapshot.match("get-object-tag-v1", get_object_tags_v1) + + kwargs = {"TaggingDirective": tagging_directive} if tagging_directive else {} + + object_key_copy = f"{object_key}-copy" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key_copy, + Tagging="key2=value2", + **kwargs, + ) + snapshot.match("copy-object", resp) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key_copy) + snapshot.match("get-copy-object-tag", get_object_tags) + + object_key_copy_tag_empty = f"{object_key}-copy-tag-empty" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key_copy_tag_empty, + **kwargs, + ) + snapshot.match("copy-object-tag-empty", resp) + + get_object_tags = aws_client.s3.get_object_tagging( + Bucket=s3_bucket, Key=object_key_copy_tag_empty + ) + snapshot.match("get-copy-object-tag-empty", get_object_tags) + + object_key_copy_v1 = f"{object_key}-copy-v1" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}?versionId={version_1}", + Key=object_key_copy_v1, + Tagging="key2=value2", + **kwargs, + ) + snapshot.match("copy-object-v1", resp) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key_copy_v1) + snapshot.match("get-copy-object-tag-v1", get_object_tags) + + object_key_copy_tag_empty_v1 = f"{object_key}-copy-tag-empty-v1" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}?versionId={version_1}", + Key=object_key_copy_tag_empty_v1, + **kwargs, + ) + snapshot.match("copy-object-tag-empty-v1", resp) + + get_object_tags = aws_client.s3.get_object_tagging( + Bucket=s3_bucket, Key=object_key_copy_tag_empty_v1 + ) + snapshot.match("get-copy-object-tag-empty-v1", get_object_tags) + + @markers.aws.validated + def test_s3_copy_content_type_and_metadata(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + Metadata={"key": "value"}, + ) + snapshot.match("put_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head_object", head_object) + + object_key_copy = f"{object_key}-copy" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, CopySource=f"{s3_bucket}/{object_key}", Key=object_key_copy + ) + snapshot.match("copy_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key_copy) + snapshot.match("head_object_copy", head_object) + + aws_client.s3.delete_objects( + Bucket=s3_bucket, Delete={"Objects": [{"Key": object_key_copy}]} + ) + + # does not set MetadataDirective=REPLACE, so the original metadata should be kept + object_key_copy = f"{object_key}-second-copy" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key_copy, + Metadata={"another-key": "value"}, + ContentType="application/javascript", + ) + snapshot.match("copy_object_second", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key_copy) + snapshot.match("head_object_second_copy", head_object) + + @markers.aws.validated + def test_s3_copy_object_in_place(self, s3_bucket, allow_bucket_acl, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer( + [ + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID", value_replacement="owner-id"), + ] + ) + object_key = "source-object" + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + Metadata={"key": "value"}, + ) + snapshot.match("put_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head_object", head_object) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["StorageClass"], + ) + snapshot.match("object-attrs", object_attrs) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, CopySource=f"{s3_bucket}/{object_key}", Key=object_key + ) + snapshot.match("copy-object-in-place-no-change", e.value.response) + + # it seems as long as you specify the field necessary, it does not check if the previous value was the same + # and allows the copy + + # copy the object with the same StorageClass as the source object + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + ChecksumAlgorithm="SHA256", + StorageClass=StorageClass.STANDARD, + ) + snapshot.match("copy-object-in-place-with-storage-class", resp) + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["StorageClass"], + ) + snapshot.match("object-attrs-after-copy", object_attrs) + + # get source object ACl, private + object_acl = aws_client.s3.get_object_acl(Bucket=s3_bucket, Key=object_key) + snapshot.match("object-acl", object_acl) + # copy the object with any ACL does not work, even if different from source object + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + ACL="public-read", + ) + snapshot.match("copy-object-in-place-with-acl", e.value.response) + + @markers.aws.validated + def test_s3_copy_object_in_place_versioned( + self, s3_bucket, allow_bucket_acl, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer( + [ + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID", value_replacement="owner-id"), + ] + ) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + object_key = "source-object" + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + Metadata={"key": "value"}, + ) + snapshot.match("put_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head_object", head_object) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["StorageClass"], + ) + snapshot.match("object-attrs", object_attrs) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, CopySource=f"{s3_bucket}/{object_key}", Key=object_key + ) + snapshot.match("copy-object-in-place-no-change", e.value.response) + + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-in-place-versioned", copy_obj) + object_version_id = copy_obj["VersionId"] + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object-copied", head_object) + + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-copied", get_obj) + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-in-place-versioned-suspended", copy_obj) + assert copy_obj["VersionId"] == "null" + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object-copied-suspended", head_object) + + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-copied-suspended", get_obj) + + head_object = aws_client.s3.head_object( + Bucket=s3_bucket, Key=object_key, VersionId=object_version_id + ) + snapshot.match("head-object-previous-version-suspended", head_object) + + # re-enable the bucket versioning, to copy from `null` to new version + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-in-place-versioned-re-enabled", copy_obj) + + @markers.aws.validated + def test_s3_copy_object_in_place_suspended_only( + self, s3_bucket, allow_bucket_acl, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer( + [ + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID", value_replacement="owner-id"), + ] + ) + object_key = "source-object" + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + Metadata={"key": "value"}, + ) + snapshot.match("put_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head_object", head_object) + + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-in-place-non-versioned", copy_obj) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object-copied", head_object) + + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-copied", get_obj) + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-in-place-versioned-suspended", copy_obj) + assert copy_obj["VersionId"] == "null" + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object-copied-suspended", head_object) + + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-copied-suspended", get_obj) + + # this is to verify the CopySourceVersionId field, if returned if both objects got `null` + copy_obj_again = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-in-place-versioned-suspended-twice", copy_obj_again) + assert copy_obj_again["VersionId"] == "null" + + @markers.aws.validated + def test_s3_copy_object_in_place_storage_class(self, s3_bucket, snapshot, aws_client): + # this test will validate that setting StorageClass (even the same as source) allows a copy in place + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body="test", + StorageClass=StorageClass.STANDARD, + ) + snapshot.match("put-object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["StorageClass"], + ) + snapshot.match("object-attrs", object_attrs) + + # copy the object with the same StorageClass as the source object + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + StorageClass=StorageClass.STANDARD, + ) + snapshot.match("copy-object-in-place-with-storage-class", resp) + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["StorageClass"], + ) + snapshot.match("object-attrs-after-copy", object_attrs) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ServerSideEncryption", + "$..SSEKMSKeyId", + # TODO: fix this in moto, when not providing a KMS key, it should use AWS managed one + "$..ETag", # Etag are different because of encryption + ] + ) + def test_s3_copy_object_in_place_with_encryption( + self, s3_bucket, kms_create_key, snapshot, aws_client + ): + # this test will validate encryption parameters that allows a copy in place + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("Description")) + snapshot.add_transformer(snapshot.transform.key_value("SSEKMSKeyId")) + object_key = "source-object" + kms_key_id = kms_create_key()["KeyId"] + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body="test", + ServerSideEncryption="aws:kms", + BucketKeyEnabled=True, + SSEKMSKeyId=kms_key_id, + ) + snapshot.match("put-object-with-kms-encryption", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + ServerSideEncryption="aws:kms", + # this will use AWS managed key, and not copy the original object key + ) + snapshot.match("copy-object-in-place-with-sse", resp) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-copy-with-sse", head_object) + + # this is an edge case, if the source object SSE was not AES256, AWS allows you to not specify any fields + # as it will use AES256 by default and is different from the source key + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + ) + snapshot.match("copy-object-in-place-without-kms-sse", resp) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-copy-without-kms-sse", head_object) + + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + ServerSideEncryption="AES256", + ) + snapshot.match("copy-object-in-place-with-aes", resp) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-copy-with-aes", head_object) + + @markers.aws.validated + def test_copy_in_place_with_bucket_encryption(self, aws_client, s3_bucket, snapshot): + response = aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}, + "BucketKeyEnabled": False, + }, + ] + }, + ) + snapshot.match("put-bucket-encryption", response) + + key_name = "test-enc" + response = aws_client.s3.put_object( + Body=b"", + Bucket=s3_bucket, + Key=key_name, + ) + snapshot.match("put-obj", response) + + response = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource={"Bucket": s3_bucket, "Key": key_name}, + Key=key_name, + ) + snapshot.match("copy-obj", response) + + @markers.aws.validated + def test_s3_copy_object_in_place_metadata_directive(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + Metadata={"key": "value"}, + ) + snapshot.match("put_object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head_object", head_object) + + with pytest.raises(ClientError) as e: + # copy the object with the same Metadata as the source object, it will fail + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + Metadata={"key": "value"}, + ) + snapshot.match("no-metadata-directive-fail", e.value.response) + + # copy the object in place, it needs MetadataDirective="REPLACE" + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + Metadata={"key2": "value2"}, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-replace-directive", resp) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-replace-directive", head_object) + + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="COPY", # this is the default value + StorageClass=StorageClass.STANDARD, + # we need to add storage class to make the copy request legal + ) + snapshot.match("copy-copy-directive", resp) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-copy-directive", head_object) + + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="COPY", + Metadata={"key3": "value3"}, # assert that this is ignored + StorageClass=StorageClass.STANDARD, + # we need to add storage class to make the copy request legal + ) + snapshot.match("copy-copy-directive-ignore", resp) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-copy-directive-ignore", head_object) + + # copy the object with no Metadata as the source object but with REPLACE + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-replace-directive-empty", resp) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-replace-directive-empty", head_object) + + @markers.aws.validated + def test_s3_copy_object_in_place_website_redirect_location( + self, s3_bucket, snapshot, aws_client + ): + # this test will validate that setting WebsiteRedirectLocation (even the same as source) allows a copy in place + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body="test", + WebsiteRedirectLocation="/test/direct", + ) + snapshot.match("put-object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + # copy the object with the same WebsiteRedirectLocation as the source object + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + WebsiteRedirectLocation="/test/direct", + ) + snapshot.match("copy-object-in-place-with-website-redirection", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object-after-copy", head_object) + + @markers.aws.validated + def test_s3_copy_object_storage_class(self, s3_bucket, snapshot, aws_client): + # this test will validate that setting StorageClass (even the same as source) allows a copy in place + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + dest_key = "dest-object" + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body="test", + StorageClass=StorageClass.STANDARD_IA, + ) + snapshot.match("put-object", resp) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["StorageClass"], + ) + snapshot.match("object-attrs", object_attrs) + + # copy the object to see if it keeps the StorageClass from the source object + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + ) + snapshot.match("copy-object-in-place-with-storage-class", resp) + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=dest_key, + ObjectAttributes=["StorageClass"], + ) + # the destination key does not keep the source key storage class + snapshot.match("object-attrs-after-copy", object_attrs) + + # try copying in place, as the StorageClass by default would be STANDARD and different from source + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + ) + snapshot.match("exc-invalid-request-storage-class", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME"]) + def test_s3_copy_object_with_checksum(self, s3_bucket, snapshot, aws_client, algorithm): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + # create key with no checksum + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + Metadata={"key": "value"}, + ) + snapshot.match("put-object-no-checksum", resp) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["Checksum"], + ) + snapshot.match("object-attrs", object_attrs) + + # copy the object in place with some metadata and replacing it, but with a checksum + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + ChecksumAlgorithm=algorithm, + Metadata={"key1": "value1"}, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-object-in-place-with-checksum", resp) + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["Checksum"], + ) + snapshot.match("object-attrs-after-copy", object_attrs) + + dest_key = "dest-object" + # copy the object to check if the new object has the checksum too + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + ) + snapshot.match("copy-object-to-dest-keep-checksum", resp) + + @markers.aws.validated + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME"]) + def test_s3_copy_object_with_default_checksum(self, s3_bucket, snapshot, aws_client, algorithm): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body='{"key": "value"}', + ContentType="application/json", + ChecksumAlgorithm=algorithm, + Metadata={"key": "value"}, + ) + snapshot.match("put-object-no-checksum", resp) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["Checksum"], + ) + snapshot.match("object-attrs", object_attrs) + + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=object_key, + Metadata={"key1": "value1"}, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-object-in-place-with-no-checksum", resp) + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["Checksum"], + ) + snapshot.match("object-attrs-after-copy", object_attrs) + + dest_key = "dest-object" + # copy the object to check if the new object has the checksum too + resp = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + ) + snapshot.match("copy-object-to-dest-keep-checksum", resp) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=dest_key, + ObjectAttributes=["Checksum"], + ) + snapshot.match("dest-object-attrs-after-copy", object_attrs) + + @markers.aws.validated + def test_s3_copy_object_preconditions(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + dest_key = "dest-object" + # create key with no checksum + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body=b"data", + ) + head_obj = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_obj) + + # wait a bit for the `unmodified_since` value so that it's unvalid. + # S3 compares it the last-modified field, but you can't set the value in the future otherwise it ignores it + # It needs to be now or less, but the object needs to be a bit more recent than that. + time.sleep(3) + + # we're testing the order of validation at the same time by validating all of them at once, by elimination + now = datetime.datetime.now().astimezone(tz=ZoneInfo("GMT")) + wrong_unmodified_since = now - datetime.timedelta(days=1) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + CopySourceIfModifiedSince=now, + CopySourceIfUnmodifiedSince=wrong_unmodified_since, + CopySourceIfMatch="etag123", + CopySourceIfNoneMatch=put_object["ETag"], + ) + snapshot.match("copy-precondition-if-match", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + CopySourceIfModifiedSince=now, + CopySourceIfUnmodifiedSince=wrong_unmodified_since, + CopySourceIfNoneMatch=put_object["ETag"], + ) + snapshot.match("copy-precondition-if-unmodified-since", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + CopySourceIfModifiedSince=now, + CopySourceIfNoneMatch=put_object["ETag"], + ) + snapshot.match("copy-precondition-if-none-match", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + CopySourceIfModifiedSince=now, + ) + snapshot.match("copy-precondition-if-modified-since", e.value.response) + + # AWS will ignore the value if it's in the future + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + CopySourceIfModifiedSince=now + datetime.timedelta(days=1), + ) + snapshot.match("copy-ignore-future-modified-since", copy_obj) + + # AWS will ignore the missing quotes around the ETag and still reject the request + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + CopySourceIfNoneMatch=put_object["ETag"].strip('"'), + ) + snapshot.match("copy-etag-missing-quotes", e.value.response) + + # Positive tests with all conditions checked + copy_obj_all_positive = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=f"{s3_bucket}/{object_key}", + Key=dest_key, + CopySourceIfMatch=put_object["ETag"].strip('"'), + CopySourceIfNoneMatch="etag123", + CopySourceIfModifiedSince=now - datetime.timedelta(days=1), + CopySourceIfUnmodifiedSince=now, + ) + snapshot.match("copy-success", copy_obj_all_positive) + + @markers.aws.validated + def test_s3_copy_object_wrong_format(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource="wrongformat", + Key="destination-key", + ) + snapshot.match("copy-object-wrong-copy-source", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize("method", ("get_object", "head_object")) + def test_s3_get_object_preconditions(self, s3_bucket, snapshot, aws_client, method): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "test-object" + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body=b"data", + ) + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + + client_method = getattr(aws_client.s3, method) + + # wait a bit for the `unmodified_since` value so that it's invalid. + # S3 compares it the last-modified field, but you can't set the value in the future otherwise it ignores it. + # It needs to be now or less, but the object needs to be a bit more recent than that. + time.sleep(3) + + # we're testing the order of validation at the same time by validating all of them at once, by elimination + now = datetime.datetime.now().astimezone(tz=ZoneInfo("GMT")) + wrong_unmodified_since = now - datetime.timedelta(days=1) + + with pytest.raises(ClientError) as e: + client_method( + Bucket=s3_bucket, + Key=object_key, + IfModifiedSince=now, + IfUnmodifiedSince=wrong_unmodified_since, + IfMatch="etag123", + IfNoneMatch=put_object["ETag"], + ) + snapshot.match("precondition-if-match", e.value.response) + + with pytest.raises(ClientError) as e: + client_method( + Bucket=s3_bucket, + Key=object_key, + IfModifiedSince=now, + IfUnmodifiedSince=wrong_unmodified_since, + IfNoneMatch=put_object["ETag"], + ) + snapshot.match("precondition-if-unmodified-since", e.value.response) + + with pytest.raises(ClientError) as e: + client_method( + Bucket=s3_bucket, + Key=object_key, + IfModifiedSince=now, + IfNoneMatch=put_object["ETag"], + ) + snapshot.match("precondition-if-none-match", e.value.response) + + with pytest.raises(ClientError) as e: + client_method( + Bucket=s3_bucket, + Key=object_key, + IfModifiedSince=now, + ) + snapshot.match("copy-precondition-if-modified-since", e.value.response) + + # AWS will ignore the value if it's in the future + get_obj = client_method( + Bucket=s3_bucket, + Key=object_key, + IfModifiedSince=now + datetime.timedelta(days=1), + ) + snapshot.match("obj-ignore-future-modified-since", get_obj) + # # AWS will ignore the missing quotes around the ETag and still reject the request + with pytest.raises(ClientError) as e: + client_method( + Bucket=s3_bucket, + Key=object_key, + IfModifiedSince=now, + IfNoneMatch=put_object["ETag"].strip('"'), + ) + snapshot.match("etag-missing-quotes", e.value.response) + + # test If*ModifiedSince precision + response = client_method( + Bucket=s3_bucket, + Key=object_key, + IfUnmodifiedSince=head_object["LastModified"], + ) + snapshot.match("precondition-if-unmodified-since-is-object", response) + + with pytest.raises(ClientError) as e: + client_method( + Bucket=s3_bucket, + Key=object_key, + IfModifiedSince=head_object["LastModified"], + ) + snapshot.match("precondition-if-modified-since-is-object", e.value.response) + + # Positive tests with all conditions checked + get_obj_all_positive = client_method( + Bucket=s3_bucket, + Key=object_key, + IfMatch=put_object["ETag"].strip('"'), + IfNoneMatch="etag123", + IfModifiedSince=now - datetime.timedelta(days=1), + IfUnmodifiedSince=now, + ) + snapshot.match("obj-success", get_obj_all_positive) + + @markers.aws.validated + def test_s3_multipart_upload_acls( + self, s3_bucket, allow_bucket_acl, s3_multipart_upload, snapshot, aws_client + ): + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/managing-acls.html + # > Bucket and object permissions are independent of each other. An object does not inherit the permissions + # > from its bucket. For example, if you create a bucket and grant write access to a user, you can't access + # > that user’s objects unless the user explicitly grants you access. + snapshot.add_transformer( + [ + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID", value_replacement="owner-id"), + ] + ) + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + response = aws_client.s3.get_bucket_acl(Bucket=s3_bucket) + snapshot.match("bucket-acl", response) + + def check_permissions(key): + acl_response = aws_client.s3.get_object_acl(Bucket=s3_bucket, Key=key) + snapshot.match(f"permission-{key}", acl_response) + + # perform uploads (multipart and regular) and check ACLs + aws_client.s3.put_object(Bucket=s3_bucket, Key="acl-key0", Body="something") + check_permissions("acl-key0") + s3_multipart_upload(bucket=s3_bucket, key="acl-key1") + check_permissions("acl-key1") + s3_multipart_upload(bucket=s3_bucket, key="acl-key2", acl="public-read-write") + check_permissions("acl-key2") + + @markers.aws.validated + def test_s3_bucket_acl(self, s3_bucket, allow_bucket_acl, snapshot, aws_client): + # loosely based on + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketAcl.html + snapshot.add_transformer( + [ + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID", value_replacement="owner-id"), + ] + ) + list_bucket_output = aws_client.s3.list_buckets() + owner = list_bucket_output["Owner"] + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + + response = aws_client.s3.get_bucket_acl(Bucket=s3_bucket) + snapshot.match("get-bucket-acl", response) + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="private") + + response = aws_client.s3.get_bucket_acl(Bucket=s3_bucket) + snapshot.match("get-bucket-canned-acl", response) + + aws_client.s3.put_bucket_acl( + Bucket=s3_bucket, GrantRead='uri="http://acs.amazonaws.com/groups/s3/LogDelivery"' + ) + + response = aws_client.s3.get_bucket_acl(Bucket=s3_bucket) + snapshot.match("get-bucket-grant-acl", response) + + # Owner is mandatory, otherwise raise MalformedXML + acp = { + "Owner": owner, + "Grants": [ + { + "Grantee": {"ID": owner["ID"], "Type": "CanonicalUser"}, + "Permission": "FULL_CONTROL", + }, + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "WRITE", + }, + ], + } + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy=acp) + + response = aws_client.s3.get_bucket_acl(Bucket=s3_bucket) + snapshot.match("get-bucket-acp-acl", response) + + @markers.aws.validated + def test_s3_bucket_acl_exceptions(self, s3_bucket, snapshot, aws_client): + list_bucket_output = aws_client.s3.list_buckets() + owner = list_bucket_output["Owner"] + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="fake-acl") + + snapshot.match("put-bucket-canned-acl", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl( + Bucket=s3_bucket, GrantWrite='uri="http://acs.amazonaws.com/groups/s3/FakeGroup"' + ) + + snapshot.match("put-bucket-grant-acl-fake-uri", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, GrantWrite='fakekey="1234"') + + snapshot.match("put-bucket-grant-acl-fake-key", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, GrantWrite='id="wrong-id"') + + snapshot.match("put-bucket-grant-acl-wrong-id", e.value.response) + + acp = { + "Grants": [ + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "WRITE", + } + ] + } + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy=acp) + snapshot.match("put-bucket-acp-acl-1", e.value.response) + + # add Owner, but modify the permission + acp["Owner"] = owner + acp["Grants"][0]["Permission"] = "WRONG-PERMISSION" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy=acp) + snapshot.match("put-bucket-acp-acl-2", e.value.response) + + # restore good permission, but put bad format Owner ID + acp["Owner"] = {"ID": "wrong-id"} + acp["Grants"][0]["Permission"] = "FULL_CONTROL" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy=acp) + snapshot.match("put-bucket-acp-acl-3", e.value.response) + + # restore owner, but wrong URI + acp["Owner"] = owner + acp["Grants"][0]["Grantee"]["URI"] = "http://acs.amazonaws.com/groups/s3/FakeGroup" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy=acp) + snapshot.match("put-bucket-acp-acl-4", e.value.response) + + # different type of failing grantee (CanonicalUser/ID) + acp["Grants"][0]["Grantee"]["Type"] = "CanonicalUser" + acp["Grants"][0]["Grantee"]["ID"] = "wrong-id" + acp["Grants"][0]["Grantee"].pop("URI") + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy=acp) + snapshot.match("put-bucket-acp-acl-5", e.value.response) + + # different type of failing grantee (Wrong type) + acp["Grants"][0]["Grantee"]["Type"] = "BadType" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy=acp) + snapshot.match("put-bucket-acp-acl-6", e.value.response) + + # test setting empty ACP + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, AccessControlPolicy={}) + + snapshot.match("put-bucket-empty-acp", e.value.response) + + # test setting nothing + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl(Bucket=s3_bucket) + + snapshot.match("put-bucket-empty", e.value.response) + + # test setting two different kind of valid ACL + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl( + Bucket=s3_bucket, + ACL="private", + GrantRead='uri="http://acs.amazonaws.com/groups/s3/LogDelivery"', + ) + + snapshot.match("put-bucket-two-type-acl", e.value.response) + + # test setting again two different kind of valid ACL + acp = { + "Owner": owner, + "Grants": [ + { + "Grantee": {"ID": owner["ID"], "Type": "CanonicalUser"}, + "Permission": "FULL_CONTROL", + }, + ], + } + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_acl( + Bucket=s3_bucket, + ACL="private", + AccessControlPolicy=acp, + ) + + snapshot.match("put-bucket-two-type-acl-acp", e.value.response) + + @markers.aws.validated + def test_s3_object_acl(self, s3_bucket, allow_bucket_acl, snapshot, aws_client): + # loosely based on + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketAcl.html + snapshot.add_transformer( + [ + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID", value_replacement="owner-id"), + ] + ) + list_bucket_output = aws_client.s3.list_buckets() + owner = list_bucket_output["Owner"] + object_key = "object-key-acl" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("put-object-default-acl", put_object) + + response = aws_client.s3.get_object_acl(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-acl-default", response) + + put_object_acl = aws_client.s3.put_object_acl( + Bucket=s3_bucket, Key=object_key, ACL="public-read" + ) + snapshot.match("put-object-acl", put_object_acl) + + response = aws_client.s3.get_object_acl(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-acl", response) + + # this a bucket URI? + aws_client.s3.put_object_acl( + Bucket=s3_bucket, + Key=object_key, + GrantRead='uri="http://acs.amazonaws.com/groups/s3/LogDelivery"', + ) + + response = aws_client.s3.get_object_acl(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-grant-acl", response) + + # Owner is mandatory, otherwise raise MalformedXML + acp = { + "Owner": owner, + "Grants": [ + { + "Grantee": {"ID": owner["ID"], "Type": "CanonicalUser"}, + "Permission": "FULL_CONTROL", + }, + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "WRITE", + }, + ], + } + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy=acp) + + response = aws_client.s3.get_object_acl(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-acp-acl", response) + + @markers.aws.validated + def test_s3_object_acl_exceptions(self, s3_bucket, snapshot, aws_client): + list_bucket_output = aws_client.s3.list_buckets() + owner = list_bucket_output["Owner"] + object_key = "object-key-acl" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, ACL="fake-acl") + snapshot.match("put-object-canned-acl", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, ACL="fake-acl") + snapshot.match("put-object-acl-canned-acl", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl( + Bucket=s3_bucket, + Key=object_key, + GrantWrite='uri="http://acs.amazonaws.com/groups/s3/FakeGroup"', + ) + snapshot.match("put-object-grant-acl-fake-uri", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl( + Bucket=s3_bucket, Key=object_key, GrantWrite='fakekey="1234"' + ) + snapshot.match("put-object-grant-acl-fake-key", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl( + Bucket=s3_bucket, Key=object_key, GrantWrite='id="wrong-id"' + ) + + snapshot.match("put-object-grant-acl-wrong-id", e.value.response) + + acp = { + "Grants": [ + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "WRITE", + } + ] + } + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy=acp) + snapshot.match("put-object-acp-acl-1", e.value.response) + + # add Owner, but modify the permission + acp["Owner"] = owner + acp["Grants"][0]["Permission"] = "WRONG-PERMISSION" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy=acp) + snapshot.match("put-object-acp-acl-2", e.value.response) + + # restore good permission, but put bad format Owner ID + acp["Owner"] = {"ID": "wrong-id"} + acp["Grants"][0]["Permission"] = "FULL_CONTROL" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy=acp) + snapshot.match("put-object-acp-acl-3", e.value.response) + + # restore owner, but wrong URI + acp["Owner"] = owner + acp["Grants"][0]["Grantee"]["URI"] = "http://acs.amazonaws.com/groups/s3/FakeGroup" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy=acp) + snapshot.match("put-object-acp-acl-4", e.value.response) + + # different type of failing grantee (CanonicalUser/ID) + acp["Grants"][0]["Grantee"]["Type"] = "CanonicalUser" + acp["Grants"][0]["Grantee"]["ID"] = "wrong-id" + acp["Grants"][0]["Grantee"].pop("URI") + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy=acp) + snapshot.match("put-object-acp-acl-5", e.value.response) + + # different type of failing grantee (Wrong type) + acp["Grants"][0]["Grantee"]["Type"] = "BadType" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy=acp) + snapshot.match("put-object-acp-acl-6", e.value.response) + + # test setting empty ACP + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, AccessControlPolicy={}) + + snapshot.match("put-object-empty-acp", e.value.response) + + # test setting nothing + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key) + + snapshot.match("put-object-acl-empty", e.value.response) + + # test setting two different kind of valid ACL + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl( + Bucket=s3_bucket, + Key=object_key, + ACL="private", + GrantRead='uri="http://acs.amazonaws.com/groups/s3/LogDelivery"', + ) + + snapshot.match("put-object-two-type-acl", e.value.response) + + # test setting again two different kind of valid ACL + acp = { + "Owner": owner, + "Grants": [ + { + "Grantee": {"ID": owner["ID"], "Type": "CanonicalUser"}, + "Permission": "FULL_CONTROL", + }, + ], + } + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl( + Bucket=s3_bucket, + Key=object_key, + ACL="private", + AccessControlPolicy=acp, + ) + + snapshot.match("put-object-two-type-acl-acp", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Restore"]) + def test_s3_object_expiry(self, s3_bucket, snapshot, aws_client): + # AWS only cleans up S3 expired object once a day usually + # the object stays accessible for quite a while after being expired + # https://stackoverflow.com/questions/38851456/aws-s3-object-expiration-less-than-24-hours + # handle s3 object expiry + # https://github.com/localstack/localstack/issues/1685 + # TODO: should we have a config var to not deleted immediately in the new provider? and schedule it? + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer( + snapshot.transform.key_value( + "ExpiresString", reference_replacement=False, value_replacement="" + ) + ) + # put object + short_expire = datetime.datetime.now(ZoneInfo("GMT")) + datetime.timedelta(seconds=1) + object_key_expired = "key-object-expired" + object_key_not_expired = "key-object-not-expired" + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key_expired, + Body="foo", + Expires=short_expire, + ) + # sleep so it expires + time.sleep(3) + # head_object does not raise an error for now in LS + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key_expired) + assert response["Expires"] < datetime.datetime.now(ZoneInfo("GMT")) + snapshot.match("head-object-expired", response) + + # try to fetch an object which is already expired + if not is_aws_cloud(): # fixme for now behaviour differs, have a look at it and discuss + with pytest.raises(Exception) as e: # this does not raise in AWS + aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key_expired) + + e.match("NoSuchKey") + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key_not_expired, + Body="foo", + Expires=datetime.datetime.now(ZoneInfo("GMT")) + datetime.timedelta(hours=1), + ) + + # try to fetch has not been expired yet. + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key_not_expired) + assert "Expires" in resp + assert resp["Expires"] > datetime.datetime.now(ZoneInfo("GMT")) + snapshot.match("get-object-not-yet-expired", resp) + + @markers.aws.validated + def test_upload_file_with_xml_preamble(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = f"key-{short_uid()}" + body = '' + + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body=body) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get_object", response) + + @markers.aws.validated + def test_bucket_availability(self, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + # make sure to have a non created bucket, got some AccessDenied against AWS + bucket_name = f"test-bucket-lifecycle-{long_uid()}" + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_lifecycle(Bucket=bucket_name) + snapshot.match("bucket-lifecycle", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_replication(Bucket=bucket_name) + snapshot.match("bucket-replication", e.value.response) + + @markers.aws.validated + def test_different_location_constraint( + self, + s3_create_bucket, + aws_client_factory, + s3_create_bucket_with_client, + snapshot, + aws_client, + ): + region_us_east_2 = "us-east-2" + region_us_west_1 = "us-west-1" + + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Location", "", reference_replacement=False), + snapshot.transform.key_value( + "LocationConstraint", "", reference_replacement=False + ), + snapshot.transform.regex(AWS_REGION_US_EAST_1, ""), + snapshot.transform.regex(region_us_east_2, ""), + snapshot.transform.regex(region_us_west_1, ""), + ] + ) + bucket_us_east_1 = f"bucket-{short_uid()}" + client_us_east_1 = aws_client_factory( + region_name=AWS_REGION_US_EAST_1, config=Config(parameter_validation=False) + ).s3 + s3_create_bucket_with_client( + client_us_east_1, + Bucket=bucket_us_east_1, + ) + response = client_us_east_1.get_bucket_location(Bucket=bucket_us_east_1) + snapshot.match("get-bucket-location-bucket-us-east-1", response) + + # assert creation fails with location constraint for us-east-1 region + with pytest.raises(ClientError) as exc: + client_us_east_1.create_bucket( + Bucket=f"bucket-{short_uid()}", + CreateBucketConfiguration={"LocationConstraint": AWS_REGION_US_EAST_1}, + ) + snapshot.match("create-bucket-constraint-us-east-1", exc.value.response) + + # assert creation fails with location constraint with the region unset + with pytest.raises(ClientError) as exc: + client_us_east_1.create_bucket( + Bucket=f"bucket-{short_uid()}", + CreateBucketConfiguration={"LocationConstraint": None}, + ) + snapshot.match("create-bucket-constraint-us-east-1-with-None", exc.value.response) + + client_us_east_2 = aws_client_factory(region_name=region_us_east_2).s3 + bucket_us_east_2 = f"bucket-{short_uid()}" + s3_create_bucket_with_client( + client_us_east_2, + Bucket=bucket_us_east_2, + CreateBucketConfiguration={"LocationConstraint": region_us_east_2}, + ) + response = client_us_east_2.get_bucket_location(Bucket=bucket_us_east_2) + snapshot.match("get-bucket-location-bucket-us-east-2", response) + + # assert creation fails without location constraint for us-east-2 region + with pytest.raises(ClientError) as exc: + client_us_east_2.create_bucket(Bucket=f"bucket-{short_uid()}") + snapshot.match("create-bucket-us-east-2-no-constraint-exc", exc.value.response) + + # assert creation fails with wrong location constraint from us-east-2 region to us-west-1 region + with pytest.raises(ClientError) as exc: + client_us_east_2.create_bucket( + Bucket=f"bucket-{short_uid()}", + CreateBucketConfiguration={"LocationConstraint": region_us_west_1}, + ) + snapshot.match("create-bucket-us-east-2-constraint-to-us-west-1", exc.value.response) + + client_us_west_1 = aws_client_factory(region_name=region_us_west_1).s3 + + with pytest.raises(ClientError) as exc: + client_us_west_1.create_bucket( + Bucket=f"bucket-{short_uid()}", + CreateBucketConfiguration={"LocationConstraint": region_us_east_2}, + ) + snapshot.match("create-bucket-us-west-1-constraint-to-us-east-2", exc.value.response) + + with pytest.raises(ClientError) as exc: + client_us_west_1.create_bucket( + Bucket=f"bucket-{short_uid()}", + CreateBucketConfiguration={"LocationConstraint": AWS_REGION_US_EAST_1}, + ) + snapshot.match("create-bucket-us-west-1-constraint-to-us-east-1", exc.value.response) + + with pytest.raises(ClientError) as exc: + aws_client.s3.get_bucket_location(Bucket=f"random-bucket-test-{short_uid()}") + + snapshot.match("get-bucket-location-non-existent-bucket", exc.value.response) + + @markers.aws.validated + def test_bucket_operation_between_regions( + self, + aws_client_factory, + s3_create_bucket_with_client, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + + region_us_west_2 = "us-west-2" + client_us_west_2 = aws_client_factory(region_name=region_us_west_2).s3 + bucket_name = f"bucket-{short_uid()}" + s3_create_bucket_with_client( + client_us_west_2, + Bucket=bucket_name, + CreateBucketConfiguration={"LocationConstraint": region_us_west_2}, + ) + + put_website_config = client_us_west_2.put_bucket_website( + Bucket=bucket_name, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + }, + ) + snapshot.match("put-website-config-region-1", put_website_config) + + bucket_cors_config = { + "CORSRules": [ + { + "AllowedOrigins": ["*"], + "AllowedMethods": ["GET"], + } + ] + } + put_cors_config = client_us_west_2.put_bucket_cors( + Bucket=bucket_name, CORSConfiguration=bucket_cors_config + ) + snapshot.match("put-cors-config-region-1", put_cors_config) + + client_us_east_1 = aws_client_factory(region_name=AWS_REGION_US_EAST_1).s3 + + get_website_config = client_us_east_1.get_bucket_website(Bucket=bucket_name) + snapshot.match("get-website-config-region-2", get_website_config) + + get_cors_config = client_us_east_1.get_bucket_cors(Bucket=bucket_name) + snapshot.match("get-cors-config-region-2", get_cors_config) + + @markers.aws.validated + def test_get_object_with_anon_credentials( + self, s3_bucket, allow_bucket_acl, snapshot, aws_client, anonymous_client + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = f"key-{short_uid()}" + body = "body data" + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body=body, + ) + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, ACL="public-read") + s3_anon_client = anonymous_client("s3") + + response = s3_anon_client.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get_object", response) + + @markers.aws.validated + def test_putobject_with_multiple_keys(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + key_by_path = "aws/key1/key2/key3" + + aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key_by_path) + result = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_by_path) + snapshot.match("get_object", result) + + @markers.aws.validated + def test_range_header_body_length(self, s3_bucket, snapshot, aws_client): + # Test for https://github.com/localstack/localstack/issues/1952 + # object created is random, ETag will be as well + snapshot.add_transformer(snapshot.transform.key_value("ETag")) + object_key = "sample.bin" + chunk_size = 1024 + + with io.BytesIO() as data: + data.write(os.urandom(chunk_size * 2)) + data.seek(0) + aws_client.s3.upload_fileobj(data, s3_bucket, object_key) + + range_header = f"bytes=0-{(chunk_size - 1)}" + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key, Range=range_header) + content = resp["Body"].read() + assert chunk_size == len(content) + snapshot.match("get-object", resp) + + range_header = f"bytes={chunk_size}-2048" + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key, Range=range_header) + content = resp["Body"].read() + assert chunk_size == len(content) + snapshot.match("get-object-2", resp) + + @markers.aws.validated + def test_download_fileobj_multiple_range_requests(self, s3_bucket, aws_client): + object_key = "test-download_fileobj" + + body = os.urandom(70_000 * 100 * 5) + aws_client.s3.upload_fileobj(BytesIO(body), s3_bucket, object_key) + + # get object and compare results + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + assert downloaded_object["Body"].read() == body + + # use download_fileobj to verify multithreaded range requests work + test_fileobj = BytesIO() + aws_client.s3.download_fileobj(Bucket=s3_bucket, Key=object_key, Fileobj=test_fileobj) + assert body == test_fileobj.getvalue() + + @markers.aws.validated + def test_get_range_object_headers(self, s3_bucket, aws_client): + object_key = "sample.bin" + chunk_size = 1024 + + with io.BytesIO() as data: + data.write(os.urandom(chunk_size * 2)) + data.seek(0) + aws_client.s3.upload_fileobj(data, s3_bucket, object_key) + + range_header = f"bytes=0-{(chunk_size - 1)}" + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key, Range=range_header) + assert resp.get("AcceptRanges") == "bytes" + resp_headers = resp["ResponseMetadata"]["HTTPHeaders"] + assert "x-amz-request-id" in resp_headers + assert "x-amz-id-2" in resp_headers + # `content-language` should not be in the response + if is_aws_cloud(): # fixme parity issue + assert "content-language" not in resp_headers + # We used to return `cache-control: no-cache` if the header wasn't set + # by the client, but this was a bug because s3 doesn't do that. It simply + # omits it. + assert "cache-control" not in resp_headers + # Do not send a content-encoding header as discussed in Issue #3608 + assert "content-encoding" not in resp_headers + + @markers.aws.only_localstack + def test_put_object_chunked_newlines(self, s3_bucket, aws_client, region_name): + # Boto still does not support chunk encoding, which means we can't test with the client nor + # aws_http_client_factory. See open issue: https://github.com/boto/boto3/issues/751 + # Test for https://github.com/localstack/localstack/issues/1571 + object_key = "data" + body = "Hello\r\n\r\n\r\n\r\n" + headers = { + "Authorization": mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": str(len(body)), + "Content-Encoding": "aws-chunked", + } + data = ( + "d;chunk-signature=af5e6c0a698b0192e9aa5d9083553d4d241d81f69ec62b184d05c509ad5166af\r\n" + f"{body}\r\n0;chunk-signature=f2a50a8c0ad4d212b579c2489c6d122db88d8a0d0b987ea1f3e9d081074a5937\r\n" + ) + # put object + url = f"{config.internal_service_url()}/{s3_bucket}/{object_key}" + requests.put(url, data, headers=headers, verify=False) + # get object and assert content length + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + download_file_object = to_str(downloaded_object["Body"].read()) + assert len(body) == len(str(download_file_object)) + assert body == str(download_file_object) + + @markers.aws.only_localstack + def test_put_object_chunked_newlines_with_trailing_checksum( + self, s3_bucket, aws_client, region_name + ): + # Boto still does not support chunk encoding, which means we can't test with the client nor + # aws_http_client_factory. See open issue: https://github.com/boto/boto3/issues/751 + # Test for https://github.com/localstack/localstack/issues/6659 + object_key = "data" + body = "Hello Blob" + valid_checksum = hash_sha256(body) + headers = { + "Authorization": mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": str(len(body)), + "x-amz-trailer": "x-amz-checksum-sha256", + "Content-Encoding": "aws-chunked", + } + + def get_data(content: str, checksum_value: str) -> str: + return ( + "a;chunk-signature=b5311ac60a88890e740a41e74f3d3b03179fd058b1e24bb3ab224042377c4ec9\r\n" + f"{content}\r\n" + "0;chunk-signature=78fae1c533e34dbaf2b83ad64ff02e4b64b7bc681ea76b6acf84acf1c48a83cb\r\n" + f"x-amz-checksum-sha256:{checksum_value}\r\n" + "x-amz-trailer-signature:712fb67227583c88ac32f468fc30a249cf9ceeb0d0e947ea5e5209a10b99181c\r\n\r\n" + ) + + url = f"{config.internal_service_url()}/{s3_bucket}/{object_key}" + + # test with wrong checksum + wrong_data = get_data(body, "wrongchecksum") + request = requests.put(url, wrong_data, headers=headers, verify=False) + assert request.status_code == 400 + assert "Value for x-amz-checksum-sha256 header is invalid." in request.text + + # assert the object has not been created + with pytest.raises(ClientError): + aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + + # put object with good checksum + valid_data = get_data(body, valid_checksum) + req = requests.put(url, valid_data, headers=headers, verify=False) + assert req.ok + + # get object and assert content length + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + download_file_object = to_str(downloaded_object["Body"].read()) + assert len(body) == len(str(download_file_object)) + assert body == str(download_file_object) + + @markers.aws.only_localstack + def test_put_object_chunked_checksum(self, s3_bucket, aws_client, region_name): + # Boto still does not support chunk encoding, which means we can't test with the client nor + # aws_http_client_factory. See open issue: https://github.com/boto/boto3/issues/751 + # Test for https://github.com/localstack/localstack/issues/6659 + object_key = "data" + body = "Hello Blob" + valid_checksum = hash_sha256(body) + headers = { + "Authorization": mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": str(len(body)), + "Content-Encoding": "aws-chunked", + } + + data = ( + "a;chunk-signature=b5311ac60a88890e740a41e74f3d3b03179fd058b1e24bb3ab224042377c4ec9\r\n" + f"{body}\r\n" + "0;chunk-signature=78fae1c533e34dbaf2b83ad64ff02e4b64b7bc681ea76b6acf84acf1c48a83cb\r\n" + ) + + url = f"{config.internal_service_url()}/{s3_bucket}/{object_key}" + + # test with wrong checksum + headers["x-amz-checksum-sha256"] = "wrongchecksum" + request = requests.put(url, data, headers=headers, verify=False) + assert request.status_code == 400 + assert "Value for x-amz-checksum-sha256 header is invalid." in request.text + + # assert the object has not been created + with pytest.raises(ClientError): + aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + + # put object with good checksum + headers["x-amz-checksum-sha256"] = valid_checksum + req = requests.put(url, data, headers=headers, verify=False) + assert req.ok + + # get object and assert content length + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + download_file_object = to_str(downloaded_object["Body"].read()) + assert len(body) == len(str(download_file_object)) + assert body == str(download_file_object) + + @markers.aws.only_localstack + def test_upload_part_chunked_newlines_valid_etag(self, s3_bucket, aws_client, region_name): + # Boto still does not support chunk encoding, which means we can't test with the client nor + # aws_http_client_factory. See open issue: https://github.com/boto/boto3/issues/751 + # Test for https://github.com/localstack/localstack/issues/8703 + body = "Hello Blob" + precalculated_etag = hashlib.md5(body.encode()).hexdigest() + headers = { + "Authorization": mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": str(len(body)), + "Content-Encoding": "aws-chunked", + } + + data = ( + "a;chunk-signature=b5311ac60a88890e740a41e74f3d3b03179fd058b1e24bb3ab224042377c4ec9\r\n" + f"{body}\r\n" + "0;chunk-signature=78fae1c533e34dbaf2b83ad64ff02e4b64b7bc681ea76b6acf84acf1c48a83cb\r\n" + ) + + key_name = "test-multipart-chunked" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + ) + upload_id = response["UploadId"] + + # # upload the part 1 + url = f"{config.internal_service_url()}/{s3_bucket}/{key_name}?partNumber={1}&uploadId={upload_id}" + response = requests.put(url, data, headers=headers, verify=False) + assert response.ok + part_etag = response.headers.get("ETag") + assert not response.content + + # validate that the object etag is the same as the pre-calculated one + assert part_etag.strip('"') == precalculated_etag + + multipart_upload_parts = [ + { + "ETag": part_etag, + "PartNumber": 1, + } + ] + + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + + completed_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + assert completed_object["Body"].read() == to_bytes(body) + + @markers.aws.only_localstack + def test_upload_part_chunked_cancelled_valid_etag(self, s3_bucket, aws_client, region_name): + """ + When using async-type requests, it's possible to cancel them inflight. This will make the request body + incomplete, and will fail during the stream decoding. We can simulate this with body by passing an incomplete + body, which triggers the same kind of exception. + This test is to avoid regression for https://github.com/localstack/localstack/issues/9851 + """ + body = "Hello Blob" + precalculated_etag = hashlib.md5(body.encode()).hexdigest() + headers = { + "Authorization": mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": str(len(body)), + "Content-Encoding": "aws-chunked", + } + + key_name = "test-multipart-chunked" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + ) + upload_id = response["UploadId"] + + # # upload the invalid part 1 + invalid_data = ( + "\r\n" + f"{body}\r\n" + "0;chunk-signature=78fae1c533e34dbaf2b83ad64ff02e4b64b7bc681ea76b6acf84acf1c48a83cb\r\n" + ) + url = f"{config.internal_service_url()}/{s3_bucket}/{key_name}?partNumber={1}&uploadId={upload_id}" + + response = requests.put(url, invalid_data, headers=headers, verify=False) + assert response.status_code == 500 + + # now re-upload the valid part and assert that the part was correctly uploaded + data = ( + "a;chunk-signature=b5311ac60a88890e740a41e74f3d3b03179fd058b1e24bb3ab224042377c4ec9\r\n" + f"{body}\r\n" + "0;chunk-signature=78fae1c533e34dbaf2b83ad64ff02e4b64b7bc681ea76b6acf84acf1c48a83cb\r\n" + ) + response = requests.put(url, data, headers=headers, verify=False) + assert response.ok + + part_etag = response.headers.get("ETag") + assert not response.content + + # validate that the object etag is the same as the pre-calculated one + assert part_etag.strip('"') == precalculated_etag + + multipart_upload_parts = [ + { + "ETag": part_etag, + "PartNumber": 1, + } + ] + + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + + completed_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + assert completed_object["Body"].read() == to_bytes(body) + + @markers.aws.only_localstack + def test_put_object_chunked_newlines_no_sig(self, s3_bucket, aws_client, region_name): + object_key = "data" + body = "test;test;test\r\ntest1;test1;test1\r\n" + headers = { + "Authorization": mock_aws_request_headers( + "s3", aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, region_name=region_name + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": str(len(body)), + "Content-Encoding": "aws-chunked", + "X-Amz-Trailer": "x-amz-checksum-crc32", + } + data = f"23\r\n{body}\r\n0\r\nx-amz-checksum-crc32:AKHICA==\r\n\r\n" + # put object + url = f"{config.internal_service_url()}/{s3_bucket}/{object_key}" + requests.put(url, data, headers=headers, verify=False) + # get object and assert content length + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + download_file_object = to_str(downloaded_object["Body"].read()) + assert len(body) == len(str(download_file_object)) + assert body == str(download_file_object) + + @markers.aws.only_localstack + def test_put_object_chunked_newlines_no_sig_empty_body( + self, s3_bucket, aws_client, region_name + ): + object_key = "data" + headers = { + "Authorization": mock_aws_request_headers( + "s3", aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, region_name=region_name + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": "0", + "Content-Encoding": "aws-chunked", + "X-Amz-Trailer": "x-amz-checksum-crc32", + } + data = "0\r\nx-amz-checksum-crc32:AAAAAA==\r\n\r\n" + # put object + url = f"{config.internal_service_url()}/{s3_bucket}/{object_key}" + requests.put(url, data, headers=headers, verify=False) + # get object and assert content length + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + download_file_object = to_str(downloaded_object["Body"].read()) + assert len(str(download_file_object)) == 0 + + @markers.aws.only_localstack + def test_put_object_chunked_content_encoding(self, s3_bucket, aws_client, region_name): + # when a request is sent with a content-encoding set to `aws-chunked`, AWS will remove it from the object + # Content-Encoding field. + # Comment from Amazon employee, saying the server should remove it + # https://github.com/aws/aws-sdk-java-v2/issues/5769#issuecomment-2594242699 + object_key = "data" + body = "Hello" + headers = { + "Authorization": mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + )["Authorization"], + "Content-Type": "audio/mpeg", + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD", + "X-Amz-Date": "20190918T051509Z", + "X-Amz-Decoded-Content-Length": str(len(body)), + "Content-Encoding": "aws-chunked", + } + data = ( + f"5;chunk-signature=af5e6c0a698b0192e9aa5d9083553d4d241d81f69ec62b184d05c509ad5166af\r\n" + f"{body}\r\n" + "0;chunk-signature=f2a50a8c0ad4d212b579c2489c6d122db88d8a0d0b987ea1f3e9d081074a5937\r\n" + ) + # put object + url = f"{config.internal_service_url()}/{s3_bucket}/{object_key}" + requests.put(url, data, headers=headers, verify=False) + # get object and assert content length + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + assert "ContentEncoding" not in downloaded_object + + upload_file_object = BytesIO() + mtime = 1676569620 # hardcode the GZIP timestamp + with gzip.GzipFile(fileobj=upload_file_object, mode="w", mtime=mtime) as filestream: + filestream.write(body.encode("utf-8")) + raw_gzip = upload_file_object.getvalue() + gzip_data = ( + b"19;chunk-signature=af5e6c0a698b0192e9aa5d9083553d4d241d81f69ec62b184d05c509ad5166af\r\n" + + raw_gzip + + b"\r\n" + + b"0;chunk-signature=f2a50a8c0ad4d212b579c2489c6d122db88d8a0d0b987ea1f3e9d081074a5937\r\n" + ) + headers["Content-Encoding"] = "aws-chunked,gzip" + headers["X-Amz-Decoded-Content-Length"] = str(len(raw_gzip)) + requests.put(url, gzip_data, headers=headers, verify=False, stream=True) + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + # assert that we correctly removed `aws-chunked` from the object ContentEncoding + assert downloaded_object["ContentEncoding"] == "gzip" + assert downloaded_object["Body"].read() == raw_gzip + + @markers.aws.only_localstack + def test_virtual_host_proxy_does_not_decode_gzip(self, aws_client, s3_bucket): + # Write contents to memory rather than a file. + data = "123gzipfile" + upload_file_object = BytesIO() + mtime = 1676569620 # hardcode the GZIP timestamp + with gzip.GzipFile(fileobj=upload_file_object, mode="w", mtime=mtime) as filestream: + filestream.write(data.encode("utf-8")) + raw_gzip = upload_file_object.getvalue() + # Upload gzip + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test.gz", + ContentEncoding="gzip", + Body=raw_gzip, + ) + + key_url = f"{_bucket_url_vhost(s3_bucket)}/test.gz" + gzip_response = requests.get(key_url, stream=True) + # get the raw data, don't let requests decode the response + raw_data = b"".join(chunk for chunk in gzip_response.raw.stream(1024, decode_content=False)) + assert raw_data == raw_gzip + + @markers.aws.only_localstack + def test_put_object_with_md5_and_chunk_signature(self, s3_bucket, aws_client): + # Boto still does not support chunk encoding, which means we can't test with the client nor + # aws_http_client_factory. See open issue: https://github.com/boto/boto3/issues/751 + # Test for https://github.com/localstack/localstack/issues/4987 + object_key = "test-runtime.properties" + object_data = ( + "#20211122+0100\n" + "#Mon Nov 22 20:10:44 CET 2021\n" + "last.sync.url.test-space-key=2822a50f-4992-425a-b8fb-923735a9ddff317e3479-5907-46cf-b33a-60da9709274f\n" + ) + object_data_chunked = ( + "93;chunk-signature=5be6b2d473e96bb9f297444da60bdf0ff8f5d2e211e1d551b3cf3646c0946641\r\n" + f"{object_data}" + "\r\n0;chunk-signature=bd5c830b94346b57ddc8805ba26c44a122256c207014433bf6579b0985f21df7\r\n\r\n" + ) + content_md5 = base64.b64encode(hashlib.md5(object_data.encode()).digest()).decode() + headers = { + "Content-Md5": content_md5, + "Content-Type": "application/octet-stream", + "User-Agent": ( + "aws-sdk-java/1.11.951 Mac_OS_X/10.15.7 OpenJDK_64-Bit_Server_VM/11.0.11+9-LTS " + "java/11.0.11 scala/2.13.6 kotlin/1.5.31 vendor/Amazon.com_Inc." + ), + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD", + "X-Amz-Date": "20211122T191045Z", + "X-Amz-Decoded-Content-Length": str(len(object_data)), + "Content-Length": str(len(object_data_chunked)), + "Connection": "Keep-Alive", + "Expect": "100-continue", + } + + url = aws_client.s3.generate_presigned_url( + "put_object", + Params={ + "Bucket": s3_bucket, + "Key": object_key, + "ContentType": "application/octet-stream", + "ContentMD5": content_md5, + }, + ) + result = requests.put(url, data=object_data_chunked, headers=headers) + assert result.status_code == 200, (result, result.content) + + @markers.aws.validated + def test_delete_object_tagging(self, s3_bucket, snapshot, aws_client): + object_key = "test-key-tagging" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + # get object and assert response + s3_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-obj", s3_obj) + # delete object tagging + aws_client.s3.delete_object_tagging(Bucket=s3_bucket, Key=object_key) + # assert that the object still exists + s3_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-obj-after-tag-deletion", s3_obj) + + @markers.aws.validated + def test_delete_non_existing_keys_quiet(self, s3_bucket, snapshot, aws_client): + object_key = "test-key-nonexistent" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + response = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [{"Key": object_key}, {"Key": "dummy1"}, {"Key": "dummy2"}], + "Quiet": True, + }, + ) + snapshot.match("deleted-resp", response) + assert "Deleted" not in response + assert "Errors" not in response + + @markers.aws.validated + def test_delete_non_existing_keys(self, s3_bucket, snapshot, aws_client): + object_key = "test-key-nonexistent" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + response = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [{"Key": object_key}, {"Key": "dummy1"}, {"Key": "dummy2"}], + }, + ) + response["Deleted"].sort(key=itemgetter("Key")) + snapshot.match("deleted-resp", response) + assert len(response["Deleted"]) == 3 + assert "Errors" not in response + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + # we cannot guarantee order nor we can sort it + path=["$..Deleted..VersionId"], + ) + def test_delete_keys_in_versioned_bucket(self, s3_bucket, snapshot, aws_client): + # see https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeletingObjectVersions.html + snapshot.add_transformer(snapshot.transform.s3_api()) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + object_key = "test-key-versioned" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something-v2") + + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects-v2", response) + + # delete objects + response = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [{"Key": object_key}], + }, + ) + snapshot.match("delete-object", response) + + response = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-version", response) + + # delete objects with version + versions_to_delete = [ + {"Key": version["Key"], "VersionId": version["VersionId"]} + for version in response["Versions"] + ] + response = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={"Objects": versions_to_delete}, + ) + snapshot.match("delete-object-version", response) + + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects-v2-after-delete", response) + + response = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-version-after-delete", response) + + delete_marker = response["DeleteMarkers"][0] + response = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [{"Key": delete_marker["Key"], "VersionId": delete_marker["VersionId"]}] + }, + ) + snapshot.match("delete-object-delete-marker", response) + + @markers.aws.validated + def test_delete_non_existing_keys_in_non_existing_bucket(self, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.delete_objects( + Bucket=f"non-existent-bucket-{long_uid()}", + Delete={"Objects": [{"Key": "dummy1"}, {"Key": "dummy2"}]}, + ) + assert "NoSuchBucket" == e.value.response["Error"]["Code"] + snapshot.match("error-non-existent-bucket", e.value.response) + + @markers.aws.validated + def test_delete_objects_encoding(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("Name")) + object_key_1 = "a%2Fb" + object_key_2 = "a/%F0%9F%98%80" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key_1, Body="percent encoding") + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key_2, Body="percent encoded emoji") + + list_objects = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects-before-delete", list_objects) + + response = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [ + {"Key": object_key_1}, + {"Key": object_key_2}, + ], + }, + ) + response["Deleted"].sort(key=itemgetter("Key")) + snapshot.match("deleted-resp", response) + + list_objects = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects", list_objects) + + @markers.aws.validated + def test_put_object_acl_on_delete_marker( + self, s3_bucket, allow_bucket_acl, snapshot, aws_client + ): + # see https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeletingObjectVersions.html + snapshot.add_transformer(snapshot.transform.s3_api()) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + object_key = "test-key-versioned" + put_obj_1 = aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + snapshot.match("put-obj-1", put_obj_1) + put_obj_2 = aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something-v2") + snapshot.match("put-obj-2", put_obj_2) + + response = aws_client.s3.delete_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("delete-object", response) + delete_marker_version_id = response["VersionId"] + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=object_key, ACL="public-read") + snapshot.match("put-acl-delete-marker", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_acl(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-acl-delete-marker", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_acl( + Bucket=s3_bucket, + Key=object_key, + VersionId=delete_marker_version_id, + ACL="public-read", + ) + snapshot.match("put-acl-delete-marker-version-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_acl( + Bucket=s3_bucket, Key=object_key, VersionId=delete_marker_version_id + ) + snapshot.match("get-acl-delete-marker-version-id", e.value.response) + + @markers.aws.validated + def test_s3_request_payer(self, s3_bucket, snapshot, aws_client): + response = aws_client.s3.put_bucket_request_payment( + Bucket=s3_bucket, RequestPaymentConfiguration={"Payer": "Requester"} + ) + snapshot.match("put-bucket-request-payment", response) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + response = aws_client.s3.get_bucket_request_payment(Bucket=s3_bucket) + snapshot.match("get-bucket-request-payment", response) + assert "Requester" == response["Payer"] + + @markers.aws.validated + def test_s3_request_payer_exceptions(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_request_payment( + Bucket=s3_bucket, RequestPaymentConfiguration={"Payer": "Random"} + ) + snapshot.match("wrong-payer-type", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_request_payment( + Bucket=f"fake-bucket-{long_uid()}", + RequestPaymentConfiguration={"Payer": "Requester"}, + ) + snapshot.match("wrong-bucket-name", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Error.RequestID", "$..Grants..Grantee.DisplayName"] + ) + def test_bucket_exists(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID", value_replacement="owner-id"), + ] + ) + aws_client.s3.put_bucket_cors( + Bucket=s3_bucket, + CORSConfiguration={ + "CORSRules": [ + { + "AllowedMethods": ["GET", "POST", "PUT", "DELETE"], + "AllowedOrigins": ["localhost"], + } + ] + }, + ) + + response = aws_client.s3.get_bucket_cors(Bucket=s3_bucket) + snapshot.match("get-bucket-cors", response) + + result = aws_client.s3.get_bucket_acl(Bucket=s3_bucket) + snapshot.match("get-bucket-acl", result) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_acl(Bucket="bucket-not-exists") + snapshot.match("get-bucket-not-exists", e.value.response) + + @markers.aws.validated + def test_s3_uppercase_key_names(self, s3_create_bucket, snapshot, aws_client): + # bucket name should be case-sensitive + bucket_name = f"testuppercase-{short_uid()}" + s3_create_bucket(Bucket=bucket_name) + + # key name should be case-sensitive + object_key = "camelCaseKey" + aws_client.s3.put_object(Bucket=bucket_name, Key=object_key, Body="something") + res = aws_client.s3.get_object(Bucket=bucket_name, Key=object_key) + snapshot.match("response", res) + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=bucket_name, Key="camelcasekey") + snapshot.match("wrong-case-key", e.value.response) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="Lambda not enabled in S3 image") + @markers.aws.validated + def test_s3_download_object_with_lambda( + self, s3_bucket, create_lambda_function, lambda_su_role, aws_client + ): + function_name = f"func-{short_uid()}" + key = f"key-{short_uid()}" + + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="something..") + + create_lambda_function( + handler_file=os.path.join( + os.path.dirname(__file__), + "../lambda_", + "functions", + "lambda_triggered_by_sqs_download_s3_file.py", + ), + func_name=function_name, + role=lambda_su_role, + runtime=Runtime.python3_12, + envvars={ + "BUCKET_NAME": s3_bucket, + "OBJECT_NAME": key, + "LOCAL_FILE_NAME": "/tmp/" + key, + }, + ) + aws_client.lambda_.invoke(FunctionName=function_name, InvocationType="Event") + + # TODO maybe this check can be improved (do not rely on logs) + retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=1, + function_name=function_name, + regex_filter="success", + expected_length=1, + logs_client=aws_client.logs, + ) + + @markers.aws.validated + def test_precondition_failed_error(self, s3_bucket, snapshot, aws_client): + aws_client.s3.put_object(Bucket=s3_bucket, Key="foo", Body=b'{"foo": "bar"}') + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key="foo", IfMatch='"not good etag"') + + snapshot.match("get-object-if-match", e.value.response) + + @markers.aws.validated + def test_s3_invalid_content_md5(self, s3_bucket, snapshot, aws_client): + # put object with invalid content MD5 + # TODO: implement ContentMD5 in ASF + content = "something" + response = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test-key", + Body=content, + ) + md = hashlib.md5(content.encode("utf-8")).digest() + content_md5 = base64.b64encode(md).decode("utf-8") + base_64_content_md5 = etag_to_base_64_content_md5(response["ETag"]) + assert content_md5 == base_64_content_md5 + + bad_digest_md5 = base64.b64encode( + hashlib.md5(f"{content}1".encode("utf-8")).digest() + ).decode("utf-8") + + hashes = [ + "__invalid__", + "000", + "not base64 encoded checksum", + "MTIz", + base64.b64encode(b"test-string").decode("utf-8"), + ] + + for index, md5hash in enumerate(hashes): + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test-key", + Body=content, + ContentMD5=md5hash, + ) + snapshot.match(f"md5-error-{index}", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test-key", + Body=content, + ContentMD5=bad_digest_md5, + ) + snapshot.match("md5-error-bad-digest", e.value.response) + + response = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test-key", + Body=content, + ContentMD5=base_64_content_md5, + ) + snapshot.match("success-put-object-md5", response) + + # also try with UploadPart, same logic + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key="multi-key") + upload_id = create_multipart["UploadId"] + + for index, md5hash in enumerate(hashes): + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key="multi-key", + Body=content, + UploadId=upload_id, + PartNumber=1, + ContentMD5=md5hash, + ) + snapshot.match(f"upload-part-md5-error-{index}", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key="multi-key", + Body=content, + UploadId=upload_id, + PartNumber=1, + ContentMD5=bad_digest_md5, + ) + snapshot.match("upload-part-md5-bad-digest", e.value.response) + + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key="multi-key", + Body=content, + UploadId=upload_id, + PartNumber=1, + ContentMD5=base_64_content_md5, + ) + snapshot.match("success-upload-part-md5", response) + + @markers.aws.validated + def test_s3_upload_download_gzip(self, s3_bucket, snapshot, aws_client): + data = "1234567890 " * 100 + + # Write contents to memory rather than a file. + upload_file_object = BytesIO() + mtime = 1676569620 # hardcode the GZIP timestamp + with gzip.GzipFile(fileobj=upload_file_object, mode="w", mtime=mtime) as filestream: + filestream.write(data.encode("utf-8")) + + # Upload gzip + response = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test.gz", + ContentEncoding="gzip", + Body=upload_file_object.getvalue(), + ) + snapshot.match("put-object", response) + + # Download gzip + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key="test.gz") + snapshot.match("get-object", downloaded_object) + download_file_object = BytesIO(downloaded_object["Body"].read()) + with gzip.GzipFile(fileobj=download_file_object, mode="rb") as filestream: + downloaded_data = filestream.read().decode("utf-8") + + assert downloaded_data == data + + @markers.aws.validated + def test_multipart_overwrite_key(self, s3_bucket, s3_multipart_upload, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("Bucket"), + ] + ) + key = "test.file" + content = b"test content 123" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=content) + snapshot.match("put-object", put_object) + + # create a multipart upload on an existing key, overwrite it + response = s3_multipart_upload(bucket=s3_bucket, key=key, data=content) + snapshot.match("multipart-upload", response) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + assert get_object["Body"].read() == content + + @markers.aws.validated + def test_multipart_copy_object_etag(self, s3_bucket, s3_multipart_upload, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("Bucket"), + ] + ) + key = "test.file" + copy_key = "copy.file" + src_object_path = f"{s3_bucket}/{key}" + content = "test content 123" + + response = s3_multipart_upload(bucket=s3_bucket, key=key, data=content) + snapshot.match("multipart-upload", response) + multipart_etag = response["ETag"] + + response = aws_client.s3.copy_object( + Bucket=s3_bucket, CopySource=src_object_path, Key=copy_key + ) + snapshot.match("copy-object", response) + copy_etag = response["CopyObjectResult"]["ETag"] + # etags should be different + assert copy_etag != multipart_etag + + # copy-in place to check + response = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource=src_object_path, + Key=key, + MetadataDirective="REPLACE", + ) + snapshot.match("copy-object-in-place", response) + copy_etag = response["CopyObjectResult"]["ETag"] + # etags should be different + assert copy_etag != multipart_etag + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=key, ChecksumMode="ENABLED") + snapshot.match("head-obj", head_object) + + @markers.aws.validated + def test_get_object_part(self, s3_bucket, s3_multipart_upload, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("Bucket"), + ] + ) + key = "test.file" + content = "test content 123" + + response = s3_multipart_upload(bucket=s3_bucket, key=key, data=content, parts=2) + snapshot.match("multipart-upload", response) + + head_object_part = aws_client.s3.head_object(Bucket=s3_bucket, Key=key, PartNumber=2) + snapshot.match("head-object-part", head_object_part) + + get_object_part = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, PartNumber=2) + snapshot.match("get-object-part", get_object_part) + + get_object_part = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key, PartNumber=2, ChecksumMode="ENABLED" + ) + snapshot.match("get-object-part-with-checksum", get_object_part) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, PartNumber=10) + snapshot.match("part-doesnt-exist", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key, + PartNumber=2, + Range="bytes=0-8", + ) + snapshot.match("part-with-range", e.value.response) + + key_no_part = "key-no-part" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key_no_part, Body="test-123") + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key_no_part, PartNumber=2) + snapshot.match("part-no-multipart", e.value.response) + + get_obj_no_part = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_no_part, PartNumber=1, ChecksumMode="ENABLED" + ) + snapshot.match("get-obj-no-multipart", get_obj_no_part) + + @markers.aws.validated + @pytest.mark.parametrize("checksum_type", ("COMPOSITE", "FULL_OBJECT")) + def test_get_object_part_checksum(self, s3_bucket, snapshot, aws_client, checksum_type): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("Bucket"), + snapshot.transform.key_value("UploadId"), + ] + ) + content = "test content 123" + key_name = "test-multipart-checksum" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm="CRC32C", ChecksumType=checksum_type + ) + snapshot.match("create-mpu-checksum", response) + upload_id = response["UploadId"] + + part_number = 1 + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=content, + PartNumber=part_number, + UploadId=upload_id, + ChecksumAlgorithm="CRC32C", + ) + snapshot.match("upload-part", response) + multipart_upload_parts = [ + { + "ETag": response["ETag"], + "PartNumber": part_number, + "ChecksumCRC32C": response["ChecksumCRC32C"], + } + ] + + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-checksum", response) + + head_object_part = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key_name, PartNumber=1, ChecksumMode="ENABLED" + ) + snapshot.match("head-object-part", head_object_part) + + get_object_part = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, PartNumber=1, ChecksumMode="ENABLED" + ) + snapshot.match("get-object-part", get_object_part) + + @markers.aws.validated + def test_set_external_hostname( + self, s3_bucket, allow_bucket_acl, s3_multipart_upload, monkeypatch, snapshot, aws_client + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("Bucket"), + ] + ) + custom_hostname = "foobar" + monkeypatch.setattr( + config, + "LOCALSTACK_HOST", + config.HostAndPort(host=custom_hostname, port=config.GATEWAY_LISTEN[0].port), + ) + key = "test.file" + content = "test content 123" + acl = "public-read" + # upload file + response = s3_multipart_upload(bucket=s3_bucket, key=key, data=content, acl=acl) + snapshot.match("multipart-upload", response) + + assert s3_bucket in response["Location"] + assert key in response["Location"] + if not is_aws_cloud(): + expected_url = ( + f"{_bucket_url(bucket_name=s3_bucket, localstack_host=custom_hostname)}/{key}" + ) + assert response["Location"] == expected_url + + # download object via API + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-object", response) + assert content == to_str(downloaded_object["Body"].read()) + + # download object directly from download link + download_url = response["Location"].replace( + f"{get_localstack_host().host}:", "localhost.localstack.cloud:" + ) + response = requests.get(download_url) + assert response.status_code == 200 + assert to_str(response.content) == content + + @markers.aws.only_localstack + def test_s3_hostname_with_subdomain(self, aws_http_client_factory, aws_client): + """ + This particular test validates the fix for localstack#7424 + Moto would still validate with the `host` header if buckets where subdomain based even though in the new ASF + provider, every request was forwarded by the VirtualHost proxy. + """ + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + endpoint_url = _endpoint_url() + # this will represent a ListBuckets call, calling the base endpoint + resp = s3_http_client.get(endpoint_url) + assert resp.ok + assert b""), + snapshot.transform.regex(rf"{bucket_2}", ""), + snapshot.transform.key_value("x-amz-id-2", reference_replacement=False), + snapshot.transform.key_value("x-amz-request-id", reference_replacement=False), + snapshot.transform.regex(r"s3\.amazonaws\.com", ""), + snapshot.transform.regex(r"s3\.localhost\.localstack\.cloud:4566", ""), + snapshot.transform.regex(r"s3\.localhost\.localstack\.cloud:443", ""), + snapshot.transform.key_value("x-amz-bucket-region"), + ] + ) + + client_us_east_1 = aws_client_factory(region_name=AWS_REGION_US_EAST_1).s3 + try: + response = client_us_east_1.create_bucket(Bucket=bucket_1) + snapshot.match("create_bucket", response) + + response = client_us_east_1.create_bucket( + Bucket=bucket_2, + CreateBucketConfiguration={ + "LocationConstraint": secondary_region_name, + }, + ) + snapshot.match("create_bucket_location_constraint", response) + + response = client_us_east_1.head_bucket(Bucket=bucket_1) + snapshot.match("head_bucket", response) + snapshot.match( + "head_bucket_filtered_header", + _filter_header(response["ResponseMetadata"]["HTTPHeaders"]), + ) + + response = aws_client.s3.head_bucket(Bucket=bucket_2) + snapshot.match("head_bucket_2", response) + snapshot.match( + "head_bucket_2_filtered_header", + _filter_header(response["ResponseMetadata"]["HTTPHeaders"]), + ) + + with pytest.raises(ClientError) as e: + aws_client.s3.head_bucket(Bucket=f"does-not-exist-{long_uid()}") + snapshot.match("head_bucket_not_exist", e.value.response) + finally: + client_us_east_1.delete_bucket(Bucket=bucket_1) + client_us_east_1.delete_bucket(Bucket=bucket_2) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + # TODO: it seems that we should not return the Owner when the request is public, but we dont have that concept + paths=["$..ListBucketResult.Contents.Owner"], + ) + def test_bucket_name_with_dots(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + bucket_name = f"my.bucket.name.{short_uid()}" + + s3_create_bucket(Bucket=bucket_name) + aws_client.s3.delete_bucket_ownership_controls(Bucket=bucket_name) + aws_client.s3.delete_public_access_block(Bucket=bucket_name) + aws_client.s3.put_bucket_acl(Bucket=bucket_name, ACL="public-read") + aws_client.s3.put_object(Bucket=bucket_name, Key="my-content", Body="something") + response = aws_client.s3.list_objects(Bucket=bucket_name) + snapshot.match("list-objects", response) + + # will result in a host-name-match if we use https, as the bucket contains dots + response_vhost = requests.get(_bucket_url_vhost(bucket_name).replace("https://", "http://")) + vhost_xml_response = xmltodict.parse(response_vhost.content) + snapshot.match("request-vhost-url-content", vhost_xml_response) + + response_path_style = requests.get(_bucket_url(bucket_name)) + path_xml_response = xmltodict.parse(response_path_style.content) + + snapshot.match("request-path-url-content", path_xml_response) + + @markers.aws.validated + def test_s3_put_more_than_1000_items(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + for i in range(0, 1010, 1): + body = "test-" + str(i) + key = "test-key-" + str(i) + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=body) + + # trying to get the last item of 1010 items added. + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key="test-key-1009") + snapshot.match("get_object-1009", resp) + + # trying to get the first item of 1010 items added. + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key="test-key-0") + snapshot.match("get_object-0", resp) + + # according docs for MaxKeys: the response might contain fewer keys but will never contain more. + # AWS returns less during testing + resp = aws_client.s3.list_objects(Bucket=s3_bucket, MaxKeys=1010) + assert 1010 >= len(resp["Contents"]) + + resp = aws_client.s3.list_objects(Bucket=s3_bucket, Delimiter="/") + assert 1000 == len(resp["Contents"]) + # way too much content, remove it from this match + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..list-objects.Contents", "", reference_replacement=False + ) + ) + snapshot.match("list-objects", resp) + next_marker = resp["NextMarker"] + + # Second list + resp = aws_client.s3.list_objects(Bucket=s3_bucket, Marker=next_marker) + snapshot.match("list-objects-next_marker", resp) + assert 10 == len(resp["Contents"]) + + @markers.aws.validated + def test_upload_big_file(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + bucket_name = f"bucket-{short_uid()}" + key1 = "test_key1" + key2 = "test_key1" + + s3_create_bucket(Bucket=bucket_name) + + body1 = "\x01" * 10000000 + rs = aws_client.s3.put_object(Bucket=bucket_name, Key=key1, Body=body1) + snapshot.match("put_object_key1", rs) + + body2 = "a" * 10000000 + rs = aws_client.s3.put_object(Bucket=bucket_name, Key=key2, Body=body2) + snapshot.match("put_object_key2", rs) + + rs = aws_client.s3.head_object(Bucket=bucket_name, Key=key1) + snapshot.match("head_object_key1", rs) + + rs = aws_client.s3.head_object(Bucket=bucket_name, Key=key2) + snapshot.match("head_object_key2", rs) + + @markers.aws.validated + def test_get_bucket_versioning_order(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + rs = aws_client.s3.list_object_versions(Bucket=s3_bucket, EncodingType="url") + snapshot.match("list_object_versions_before", rs) + + rs = aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + snapshot.match("put_bucket_versioning", rs) + + rs = aws_client.s3.get_bucket_versioning(Bucket=s3_bucket) + snapshot.match("get_bucket_versioning", rs) + + aws_client.s3.put_object(Bucket=s3_bucket, Key="test", Body="body") + aws_client.s3.put_object(Bucket=s3_bucket, Key="test", Body="body") + aws_client.s3.put_object(Bucket=s3_bucket, Key="test2", Body="body") + rs = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + ) + + snapshot.match("list_object_versions", rs) + + @markers.aws.validated + def test_etag_on_get_object_call(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "my-key" + + body = "Lorem ipsum dolor sit amet, ... " * 30 + rs = aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body=body) + + rs = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get_object", rs) + + rs = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=object_key, + Range="bytes=0-16", + ) + snapshot.match("get_object_range", rs) + + @markers.aws.validated + def test_s3_delete_object_with_version_id(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + test_1st_key = "aws/s3/testkey1.txt" + test_2nd_key = "aws/s3/testkey2.txt" + + body = "Lorem ipsum dolor sit amet, ... " * 30 + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Enabled"}, + ) + rs = aws_client.s3.get_bucket_versioning(Bucket=s3_bucket) + snapshot.match("get_bucket_versioning", rs) + + # put 2 objects + rs = aws_client.s3.put_object(Bucket=s3_bucket, Key=test_1st_key, Body=body) + aws_client.s3.put_object(Bucket=s3_bucket, Key=test_2nd_key, Body=body) + version_id = rs["VersionId"] + + # delete 1st object with version + rs = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={"Objects": [{"Key": test_1st_key, "VersionId": version_id}]}, + ) + + deleted = rs["Deleted"][0] + assert test_1st_key == deleted["Key"] + assert version_id == deleted["VersionId"] + snapshot.match("delete_objects", rs) + + rs = aws_client.s3.list_object_versions(Bucket=s3_bucket) + object_versions = [object["VersionId"] for object in rs["Versions"]] + snapshot.match("list_object_versions_after_delete", rs) + + assert version_id not in object_versions + + # disable versioning + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Suspended"}, + ) + rs = aws_client.s3.get_bucket_versioning(Bucket=s3_bucket) + snapshot.match("get_bucket_versioning_suspended", rs) + + @markers.aws.validated + def test_s3_put_object_versioned(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + # this object is put before the bucket is versioned, its internal versionId is `null` + key = "non-version-bucket-key" + put_obj_pre_versioned = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key, Body="non-versioned-key" + ) + snapshot.match("put-pre-versioned", put_obj_pre_versioned) + get_obj_pre_versioned = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-pre-versioned", get_obj_pre_versioned) + + list_obj_pre_versioned = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-pre-versioned", list_obj_pre_versioned) + + # we activate the bucket versioning then check if the object has a versionId + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Enabled"}, + ) + + get_obj_non_versioned = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-post-versioned", get_obj_non_versioned) + + # create versioned key, then update it, and check we got the last versionId + key_2 = "versioned-bucket-key" + put_obj_versioned_1 = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_2, Body="versioned-key" + ) + snapshot.match("put-obj-versioned-1", put_obj_versioned_1) + put_obj_versioned_2 = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_2, Body="versioned-key-updated" + ) + snapshot.match("put-obj-versioned-2", put_obj_versioned_2) + + get_obj_versioned = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_2) + snapshot.match("get-obj-versioned", get_obj_versioned) + + list_obj_post_versioned = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-versioned", list_obj_post_versioned) + + # disable versioning to check behaviour after getting keys + # all keys will now have versionId when getting them, even non-versioned ones + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Suspended"}, + ) + list_obj_post_versioned_disabled = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-bucket-suspended", list_obj_post_versioned_disabled) + + get_obj_versioned_disabled = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_2) + snapshot.match("get-obj-versioned-disabled", get_obj_versioned_disabled) + + get_obj_non_versioned_disabled = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-obj-non-versioned-disabled", get_obj_non_versioned_disabled) + + # won't return the versionId from put + key_3 = "non-version-bucket-key-after-disable" + put_obj_non_version_post_disable = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_3, Body="non-versioned-key-post" + ) + snapshot.match("put-non-versioned-post-disable", put_obj_non_version_post_disable) + # will return the versionId now, when it didn't before setting the BucketVersioning to `Enabled` + get_obj_non_version_post_disable = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_3) + snapshot.match("get-non-versioned-post-disable", get_obj_non_version_post_disable) + + # manually assert all VersionId, as it's hard to do in snapshots: + assert "VersionId" not in get_obj_pre_versioned + assert get_obj_non_versioned["VersionId"] == "null" + assert list_obj_pre_versioned["Versions"][0]["VersionId"] == "null" + assert get_obj_versioned["VersionId"] is not None + assert list_obj_post_versioned["Versions"][0]["VersionId"] == "null" + assert list_obj_post_versioned["Versions"][1]["VersionId"] is not None + assert list_obj_post_versioned["Versions"][2]["VersionId"] is not None + + @markers.aws.validated + @pytest.mark.skipif(reason="ACL behaviour is not implemented, see comments") + def test_s3_batch_delete_objects_using_requests_with_acl( + self, s3_bucket, allow_bucket_acl, snapshot, aws_client, anonymous_client + ): + # If an object is created in a public bucket by the owner, it can't be deleted by anonymous clients + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#specifying-grantee-predefined-groups + # only "public" created objects can be deleted by anonymous clients + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key_1 = "key-created-by-owner" + object_key_2 = "key-created-by-anonymous" + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read-write") + aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key_1, Body="This body document", ACL="public-read-write" + ) + anon = anonymous_client("s3") + anon.put_object( + Bucket=s3_bucket, + Key=object_key_2, + Body="This body document #2", + ACL="public-read-write", + ) + + url = f"{_bucket_url(s3_bucket, localstack_host=get_localstack_host().host)}?delete" + + data = f""" + + + {object_key_1} + + + {object_key_2} + + + """ + + md = hashlib.md5(data.encode("utf-8")).digest() + contents_md5 = base64.b64encode(md).decode("utf-8") + header = {"content-md5": contents_md5, "x-amz-request-payer": "requester"} + r = requests.post(url=url, data=data, headers=header) + + assert 200 == r.status_code + response = xmltodict.parse(r.content) + response["DeleteResult"].pop("@xmlns", None) + assert response["DeleteResult"]["Error"]["Key"] == object_key_1 + assert response["DeleteResult"]["Error"]["Code"] == "AccessDenied" + assert response["DeleteResult"]["Deleted"]["Key"] == object_key_2 + snapshot.match("multi-delete-with-requests", response) + + response = aws_client.s3.list_objects(Bucket=s3_bucket) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert len(response["Contents"]) == 1 + snapshot.match("list-remaining-objects", response) + + @markers.aws.validated + def test_s3_batch_delete_public_objects_using_requests( + self, s3_bucket, allow_bucket_acl, snapshot, aws_client, anonymous_client + ): + # only "public" created objects can be deleted by anonymous clients + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#specifying-grantee-predefined-groups + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key_1 = "key-created-by-anonymous-1" + object_key_2 = "key-created-by-anonymous-2" + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read-write") + anon = anonymous_client("s3") + anon.put_object( + Bucket=s3_bucket, Key=object_key_1, Body="This body document", ACL="public-read-write" + ) + anon.put_object( + Bucket=s3_bucket, + Key=object_key_2, + Body="This body document #2", + ACL="public-read-write", + ) + + # TODO delete does currently not work with S3_VIRTUAL_HOSTNAME + url = f"{_bucket_url(s3_bucket, localstack_host=get_localstack_host().host)}?delete" + + data = f""" + + + {object_key_1} + + + {object_key_2} + + + """ + + md = hashlib.md5(data.encode("utf-8")).digest() + contents_md5 = base64.b64encode(md).decode("utf-8") + header = {"content-md5": contents_md5, "x-amz-request-payer": "requester"} + r = requests.post(url=url, data=data, headers=header) + + assert 200 == r.status_code + response = xmltodict.parse(r.content) + response["DeleteResult"]["Deleted"].sort(key=itemgetter("Key")) + snapshot.match("multi-delete-with-requests", response) + + response = aws_client.s3.list_objects(Bucket=s3_bucket) + snapshot.match("list-remaining-objects", response) + + @markers.aws.validated + def test_s3_batch_delete_objects(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("Key")) + delete_object = [] + for _ in range(5): + key_name = f"key-batch-delete-{short_uid()}" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="This body document") + delete_object.append({"Key": key_name}) + + response = aws_client.s3.delete_objects(Bucket=s3_bucket, Delete={"Objects": delete_object}) + snapshot.match("batch-delete", response) + + response = aws_client.s3.list_objects(Bucket=s3_bucket) + snapshot.match("list-remaining-objects", response) + + @markers.aws.validated + def test_s3_get_object_header_overrides(self, s3_bucket, snapshot, aws_client): + # Signed requests may include certain header overrides in the querystring + # https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html + object_key = "key-header-overrides" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + expiry_date = "Wed, 21 Oct 2015 07:28:00 GMT" + response = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=object_key, + ResponseCacheControl="max-age=74", + ResponseContentDisposition='attachment; filename="foo.jpg"', + ResponseContentEncoding="identity", + ResponseContentLanguage="de-DE", + ResponseContentType="image/jpeg", + ResponseExpires=expiry_date, + ) + snapshot.match("get-object", response) + + @markers.aws.only_localstack + def test_virtual_host_proxying_headers(self, s3_bucket, aws_client): + # forwarding requests from virtual host to path addressed will double add server specific headers + # (date and server). Verify that those are not double added after a fix to the proxy + key = "test-double-headers" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test-headers", ACL="public-read") + + key_url = f"{_bucket_url(bucket_name=s3_bucket)}/{key}" + response = requests.get(key_url) + assert response.headers["server"] + + key_url = f"{_bucket_url_vhost(bucket_name=s3_bucket)}/{key}" + proxied_response = requests.get(key_url) + assert proxied_response.ok + assert proxied_response.headers["server"] == response.headers["server"] + assert len(proxied_response.headers["server"].split(",")) == 1 + assert len(proxied_response.headers["date"].split(",")) == 2 # coma in the date + + @pytest.mark.skipif( + not in_default_partition(), reason="Test not applicable in non-default partitions" + ) + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + def test_s3_sse_validate_kms_key( + self, + aws_client_factory, + s3_create_bucket_with_client, + kms_create_key, + monkeypatch, + snapshot, + ): + region_us_east_2 = "us-east-2" + region_us_west_2 = "us-west-2" + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Description"), + snapshot.transform.regex(region_us_east_2, ""), + snapshot.transform.regex(region_us_west_2, ""), + ] + ) + + data = b"test-sse" + bucket_name = f"bucket-test-kms-{short_uid()}" + + us_east_2_client = aws_client_factory(region_name=region_us_east_2).s3 + s3_create_bucket_with_client( + us_east_2_client, + Bucket=bucket_name, + CreateBucketConfiguration={"LocationConstraint": region_us_east_2}, + ) + # create key in a different region than the bucket + create_kms_key = kms_create_key(region_name=region_us_west_2) + # snapshot the KMS key to save the UUID for replacement in Error message. + snapshot.match("create-kms-key", create_kms_key) + + # test whether the validation is skipped when not disabling the validation + if not is_aws_cloud(): + key_name = "test-sse-validate-kms-key-no-check" + response = us_east_2_client.put_object( + Bucket=bucket_name, + Key=key_name, + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId="fake-key-id", + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + response = us_east_2_client.create_multipart_upload( + Bucket=bucket_name, + Key="multipart-test-sse-validate-kms-key-no-check", + ServerSideEncryption="aws:kms", + SSEKMSKeyId="fake-key-id", + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + response = us_east_2_client.copy_object( + Bucket=bucket_name, + Key="copy-test-sse-validate-kms-key-no-check", + CopySource={"Bucket": bucket_name, "Key": key_name}, + ServerSideEncryption="aws:kms", + SSEKMSKeyId="fake-key-id", + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + key_name = "test-sse-validate-kms-key" + fake_key_uuid = "134f2428-cec1-4b25-a1ae-9048164dba47" + + # activating the validation, for AWS parity + monkeypatch.setattr(config, "S3_SKIP_KMS_KEY_VALIDATION", False) + with pytest.raises(ClientError) as e: + us_east_2_client.put_object( + Bucket=bucket_name, + Key=key_name, + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId="fake-key-id", + ) + snapshot.match("put-obj-wrong-kms-key", e.value.response) + + with pytest.raises(ClientError) as e: + us_east_2_client.put_object( + Bucket=bucket_name, + Key=key_name, + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId=fake_key_uuid, + ) + snapshot.match("put-obj-wrong-kms-key-real-uuid", e.value.response) + + # we create a wrong arn but with the right region to test error message + wrong_id_arn = ( + create_kms_key["Arn"] + .replace(region_us_west_2, region_us_east_2) + .replace(create_kms_key["KeyId"], fake_key_uuid) + ) + with pytest.raises(ClientError) as e: + us_east_2_client.put_object( + Bucket=bucket_name, + Key=key_name, + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId=wrong_id_arn, + ) + snapshot.match("put-obj-wrong-kms-key-real-uuid-arn", e.value.response) + + with pytest.raises(ClientError) as e: + us_east_2_client.put_object( + Bucket=bucket_name, + Key="test-sse-validate-kms-key-no-check-region", + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId=create_kms_key["Arn"], + ) + snapshot.match("put-obj-different-region-kms-key", e.value.response) + + with pytest.raises(ClientError) as e: + us_east_2_client.put_object( + Bucket=bucket_name, + Key="test-sse-validate-kms-key-different-region-no-arn", + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId=create_kms_key["KeyId"], + ) + snapshot.match("put-obj-different-region-kms-key-no-arn", e.value.response) + + with pytest.raises(ClientError) as e: + us_east_2_client.create_multipart_upload( + Bucket=bucket_name, + Key="multipart-test-sse-validate-kms-key-no-check", + ServerSideEncryption="aws:kms", + SSEKMSKeyId="fake-key-id", + ) + snapshot.match("create-multipart-wrong-kms-key", e.value.response) + + # create a object to be copied + src_key = "key-to-be-copied" + us_east_2_client.put_object(Bucket=bucket_name, Key=src_key, Body=b"test-data") + with pytest.raises(ClientError) as e: + us_east_2_client.copy_object( + Bucket=bucket_name, + Key="copy-test-sse-validate-kms-key-no-check", + CopySource={"Bucket": bucket_name, "Key": src_key}, + ServerSideEncryption="aws:kms", + SSEKMSKeyId="fake-key-id", + ) + snapshot.match("copy-obj-wrong-kms-key", e.value.response) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ETag", # the ETag is different as we don't encrypt the object with the KMS key + ] + ) + def test_s3_sse_validate_kms_key_state( + self, s3_bucket, kms_create_key, monkeypatch, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.key_value("Description")) + data = b"test-sse" + + # create key in the same region as the bucket + kms_key = kms_create_key() + # snapshot the KMS key to save the UUID for replacement in Error message. + snapshot.match("create-kms-key", kms_key) + key_name = "put-object-with-sse" + put_object_with_sse = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId=kms_key["KeyId"], + ) + snapshot.match("success-put-object-sse", put_object_with_sse) + + get_object_with_sse = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + ) + snapshot.match("success-get-object-sse", get_object_with_sse) + + # disable the key + aws_client.kms.disable_key(KeyId=kms_key["KeyId"]) + + # test whether the validation is skipped when not disabling the validation + if not is_aws_cloud(): + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + assert get_object["ResponseMetadata"]["HTTPStatusCode"] == 200 + + response = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test-sse-kms-disabled-key-no-check", + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId=kms_key["KeyId"], + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # activating the validation, for AWS parity + monkeypatch.setattr(config, "S3_SKIP_KMS_KEY_VALIDATION", False) + + # disable the key, try to put an object + aws_client.kms.disable_key(KeyId=kms_key["KeyId"]) + + def _is_key_disabled(): + key = aws_client.kms.describe_key(KeyId=kms_key["KeyId"]) + assert not key["KeyMetadata"]["Enabled"] + + retry(_is_key_disabled, retries=3, sleep=0.5) + if is_aws_cloud(): + # time for the key state to be propagated + time.sleep(5) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + ) + snapshot.match("get-obj-disabled-key", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="key-is-deactivated", + Body=data, + ServerSideEncryption="aws:kms", + SSEKMSKeyId=kms_key["KeyId"], + ) + snapshot.match("put-obj-disabled-key", e.value.response) + + # schedule the deletion of the key + aws_client.kms.schedule_key_deletion(KeyId=kms_key["KeyId"], PendingWindowInDays=7) + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + ) + snapshot.match("get-obj-pending-deletion-key", e.value.response) + + @markers.aws.validated + def test_complete_multipart_parts_order(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + ] + ) + + key_name = "test-order-parts" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name) + upload_id = response["UploadId"] + + # data must be at least 5MiB + part_data = "a" * (5_242_880 + 1) + part_data = to_bytes(part_data) + + parts = 3 + multipart_upload_parts = [] + for part in range(parts): + # Write contents to memory rather than a file. + part_number = part + 1 + upload_file_object = BytesIO(part_data) + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=upload_file_object, + PartNumber=part_number, + UploadId=upload_id, + ) + multipart_upload_parts.append({"ETag": response["ETag"], "PartNumber": part_number}) + + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=BytesIO(b""), + PartNumber=-1, + UploadId=upload_id, + ) + snapshot.match("upload-part-negative-part-number", e.value.response) + + # testing completing the multipart with an unordered sequence of parts + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": list(reversed(multipart_upload_parts))}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-unordered", e.value.response) + + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-ordered", response) + + # testing completing the multipart with a sequence of parts number going from 2, 4, and 6 (missing numbers) + key_name_2 = "key-sequence-with-step-2" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name_2) + upload_id = response["UploadId"] + + multipart_upload_parts = [] + for part in range(parts): + # Write contents to memory rather than a file. + part_number = part + 2 + upload_file_object = BytesIO(part_data) + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name_2, + Body=upload_file_object, + PartNumber=part_number, + UploadId=upload_id, + ) + multipart_upload_parts.append({"ETag": response["ETag"], "PartNumber": part_number}) + + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name_2, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-with-step-2", response) + + @markers.aws.validated + @pytest.mark.parametrize( + "storage_class, is_retrievable", + [ + (StorageClass.STANDARD, True), + (StorageClass.STANDARD_IA, True), + (StorageClass.GLACIER, False), + (StorageClass.GLACIER_IR, True), + (StorageClass.REDUCED_REDUNDANCY, True), + (StorageClass.ONEZONE_IA, True), + (StorageClass.INTELLIGENT_TIERING, True), + (StorageClass.DEEP_ARCHIVE, False), + ], + ) + def test_put_object_storage_class( + self, s3_bucket, snapshot, storage_class, is_retrievable, aws_client + ): + key_name = "test-put-object-storage-class" + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=b"body-test", + StorageClass=storage_class, + ) + + response = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["StorageClass"], + ) + snapshot.match("get-object-storage-class", response) + + if is_retrievable: + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-object", response) + else: + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-object", e.value.response) + + @markers.aws.validated + def test_put_object_storage_class_outposts( + self, s3_bucket, s3_multipart_upload, snapshot, aws_client + ): + key_name = "test-put-object-storage-class" + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=b"body-test", + StorageClass=StorageClass.OUTPOSTS, + ) + snapshot.match("put-object-outposts", e.value.response) + + key_name = "test-multipart-storage-class" + with pytest.raises(ClientError) as e: + aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + StorageClass=StorageClass.OUTPOSTS, + ) + snapshot.match("create-multipart-outposts-exc", e.value.response) + + @markers.aws.validated + def test_response_structure(self, aws_http_client_factory, s3_bucket, aws_client): + """ + Test that the response structure is correct for the S3 API + """ + aws_client.s3.put_object(Bucket=s3_bucket, Key="test", Body="test") + headers = {"x-amz-content-sha256": "UNSIGNED-PAYLOAD"} + + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + + def get_xml_content(http_response_content: bytes) -> bytes: + # just format a bit the XML, nothing bad parity wise, but allow the test to run against AWS + return http_response_content.replace(b"'", b'"').replace(b"utf", b"UTF") + + # Lists all buckets + endpoint_url = _endpoint_url() + resp = s3_http_client.get(endpoint_url, headers=headers) + assert b'\n' in get_xml_content(resp.content) + + resp_dict = xmltodict.parse(resp.content) + assert "ListAllMyBucketsResult" in resp_dict + # validate that the Owner tag is first, before Buckets. This is because the Java SDK is counting on the order + # to properly set the Owner value to the buckets. + assert ( + resp_dict["ListAllMyBucketsResult"].pop("@xmlns") + == "http://s3.amazonaws.com/doc/2006-03-01/" + ) + list_buckets_tags = list(resp_dict["ListAllMyBucketsResult"].keys()) + assert list_buckets_tags[0] == "Owner" + assert list_buckets_tags[1] == "Buckets" + + # Lists all objects in a bucket + bucket_url = _bucket_url(s3_bucket) + resp = s3_http_client.get(bucket_url, headers=headers) + assert b'\n' in get_xml_content(resp.content) + resp_dict = xmltodict.parse(resp.content) + assert "ListBucketResult" in resp_dict + assert resp_dict["ListBucketResult"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/" + # validate that the Contents tag is last, after BucketName. Again for the Java SDK to properly set the + # BucketName value to the objects. + list_objects_tags = list(resp_dict["ListBucketResult"].keys()) + assert list_objects_tags.index("Name") < list_objects_tags.index("Contents") + assert list_objects_tags[-1] == "Contents" + + # Lists all objects V2 in a bucket + list_objects_v2_url = f"{bucket_url}?list-type=2" + resp = s3_http_client.get(list_objects_v2_url, headers=headers) + assert b'\n' in get_xml_content(resp.content) + resp_dict = xmltodict.parse(resp.content) + assert "ListBucketResult" in resp_dict + assert resp_dict["ListBucketResult"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/" + # same as ListObjects + list_objects_v2_tags = list(resp_dict["ListBucketResult"].keys()) + assert list_objects_v2_tags.index("Name") < list_objects_v2_tags.index("Contents") + assert list_objects_v2_tags[-1] == "Contents" + + # Lists all multipart uploads in a bucket + list_multipart_uploads_url = f"{bucket_url}?uploads" + resp = s3_http_client.get(list_multipart_uploads_url, headers=headers) + assert b'\n' in get_xml_content(resp.content) + resp_dict = xmltodict.parse(resp.content) + assert "ListMultipartUploadsResult" in resp_dict + assert ( + resp_dict["ListMultipartUploadsResult"]["@xmlns"] + == "http://s3.amazonaws.com/doc/2006-03-01/" + ) + + # GetBucketLocation + location_constraint_url = f"{bucket_url}?location" + resp = s3_http_client.get(location_constraint_url, headers=headers) + xml_content = get_xml_content(resp.content) + assert b'\n' in xml_content + assert b'\n' in get_xml_content(resp.content) + resp_dict = xmltodict.parse(resp.content) + assert "ListVersionsResult" in resp_dict + assert ( + resp_dict["ListVersionsResult"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/" + ) + # same as ListObjects + list_objects_versions_tags = list(resp_dict["ListVersionsResult"].keys()) + assert list_objects_versions_tags.index("Name") < list_objects_versions_tags.index( + "Version" + ) + assert list_objects_versions_tags[-1] == "Version" + + # GetObjectTagging + get_object_tagging_url = f"{bucket_url}/{key_name}?tagging" + resp = s3_http_client.get(get_object_tagging_url, headers=headers) + resp_dict = xmltodict.parse(resp.content) + assert resp_dict["Tagging"]["TagSet"] == {"Tag": {"Key": "tag1", "Value": "tag1"}} + assert resp_dict["Tagging"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/" + + # CopyObject + get_object_tagging_url = f"{bucket_url}/{key_name}?tagging" + resp = s3_http_client.get(get_object_tagging_url, headers=headers) + resp_dict = xmltodict.parse(resp.content) + assert resp_dict["Tagging"]["TagSet"] == {"Tag": {"Key": "tag1", "Value": "tag1"}} + assert resp_dict["Tagging"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/" + + copy_object_url = f"{bucket_url}/copied-key" + copy_object_headers = {**headers, "x-amz-copy-source": f"{bucket_url}/{key_name}"} + resp = s3_http_client.put(copy_object_url, headers=copy_object_headers) + resp_dict = xmltodict.parse(resp.content) + assert "CopyObjectResult" in resp_dict + assert resp_dict["CopyObjectResult"]["@xmlns"] == "http://s3.amazonaws.com/doc/2006-03-01/" + assert resp.status_code == 200 + + multipart_key = "multipart-key" + create_multipart = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=multipart_key + ) + upload_id = create_multipart["UploadId"] + + upload_part_url = f"{bucket_url}/{multipart_key}?UploadId={upload_id}&PartNumber=1" + resp = s3_http_client.put(upload_part_url, headers=headers) + assert not resp.content, resp.content + assert resp.status_code == 200 + assert resp.headers.get("Content-Type") is None + assert resp.headers["Content-Length"] == "0" + + # DeleteObjectTagging + resp = s3_http_client.delete(get_object_tagging_url, headers=headers) + assert not resp.content, resp.content + assert resp.status_code == 204 + assert resp.headers.get("Content-Type") is None + assert resp.headers.get("Content-Length") is None + + @markers.aws.validated + def test_s3_timestamp_precision(self, s3_bucket, aws_client, aws_http_client_factory): + object_key = "test-key" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="test-body") + + def assert_timestamp_is_iso8061_s3_format(_timestamp: str): + # the timestamp should be looking like the following + # 2023-11-15T12:02:40.000Z + assert _timestamp.endswith(".000Z") + assert len(_timestamp) == 24 + # assert that it follows the right format and it does not raise an exception during parsing + parsed_ts = datetime.datetime.strptime(_timestamp, "%Y-%m-%dT%H:%M:%S.%fZ") + assert parsed_ts.microsecond == 0 + + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + list_buckets_endpoint = _endpoint_url() + list_buckets_resp = s3_http_client.get( + list_buckets_endpoint, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"} + ) + list_buckets_dict = xmltodict.parse(list_buckets_resp.content) + + buckets = list_buckets_dict["ListAllMyBucketsResult"]["Buckets"]["Bucket"] + # because of XML parsing, it can either be a list or a dict + + if isinstance(buckets, list): + bucket = buckets[0] + else: + bucket = buckets + bucket_timestamp: str = bucket["CreationDate"] + assert_timestamp_is_iso8061_s3_format(bucket_timestamp) + + bucket_url = _bucket_url(s3_bucket) + object_url = f"{bucket_url}/{object_key}" + head_obj_resp = s3_http_client.head( + object_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"} + ) + last_modified: str = head_obj_resp.headers["Last-Modified"] + assert datetime.datetime.strptime(last_modified, RFC1123) + assert last_modified.endswith(" GMT") + + get_obj_resp = s3_http_client.get( + object_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"} + ) + last_modified: str = get_obj_resp.headers["Last-Modified"] + assert datetime.datetime.strptime(last_modified, RFC1123) + assert last_modified.endswith(" GMT") + + object_attrs_url = f"{object_url}?attributes" + get_obj_attrs_resp = s3_http_client.get( + object_attrs_url, + headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD", "x-amz-object-attributes": "ETag"}, + ) + last_modified: str = get_obj_attrs_resp.headers["Last-Modified"] + assert datetime.datetime.strptime(last_modified, RFC1123) + assert last_modified.endswith(" GMT") + + copy_object_url = f"{bucket_url}/copied-key" + copy_resp = s3_http_client.put( + copy_object_url, + headers={ + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + "x-amz-copy-source": f"{bucket_url}/{object_key}", + }, + ) + copy_resp_dict = xmltodict.parse(copy_resp.content) + copy_timestamp: str = copy_resp_dict["CopyObjectResult"]["LastModified"] + assert_timestamp_is_iso8061_s3_format(copy_timestamp) + + # This test doesn't work against AWS anymore because of some authorization error. + @markers.aws.only_localstack + def test_s3_delete_objects_trailing_slash(self, aws_http_client_factory, s3_bucket, aws_client): + object_key = "key-to-delete-trailing-slash" + # create an object to delete + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body=b"123") + + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + + # Endpoint as created by Rust and AWS JS SDK v3 + bucket_url = f"{_bucket_url(s3_bucket)}/?delete&x-id=DeleteObjects" + + delete_body = f""" + + {object_key} + + + """ + # Post the request to delete the objects, with a trailing slash in the URL + resp = s3_http_client.post(bucket_url, data=delete_body) + assert resp.status_code == 200, (resp.content, resp.headers) + + resp_dict = xmltodict.parse(resp.content) + assert "DeleteResult" in resp_dict + assert resp_dict["DeleteResult"]["Deleted"]["Key"] == object_key + + @markers.aws.validated + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + # there is currently no server side encryption is place in LS, ETag will be different + @markers.snapshot.skip_snapshot_verify(paths=["$..ETag"]) + def test_s3_multipart_upload_sse( + self, + aws_client, + s3_bucket, + s3_multipart_upload_with_snapshot, + kms_create_key, + snapshot, + ): + snapshot.add_transformer( + [ + snapshot.transform.resource_name("SSEKMSKeyId"), + snapshot.transform.key_value( + "Bucket", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("Location"), + ] + ) + + key_name = "test-sse-field-multipart" + data = b"test-sse" + key_id = kms_create_key()["KeyId"] + # if you only pass the key id, the key must be in the same region and account as the bucket + # otherwise, pass the ARN (always same region) + # but the response always return the ARN + + s3_multipart_upload_with_snapshot( + bucket=s3_bucket, + key=key_name, + data=data, + snapshot_prefix="multi-sse", + BucketKeyEnabled=True, + SSEKMSKeyId=key_id, + ServerSideEncryption="aws:kms", + ) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-obj", response) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + # there is currently no server side encryption is place in LS, ETag will be different + @markers.snapshot.skip_snapshot_verify(paths=["$..ETag"]) + def test_s3_sse_bucket_key_default( + self, + aws_client, + s3_bucket, + kms_create_key, + snapshot, + ): + snapshot.add_transformer( + [ + snapshot.transform.resource_name("SSEKMSKeyId"), + snapshot.transform.key_value( + "Bucket", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("Location"), + ] + ) + key_before_set = "test-sse-bucket-before" + key_after_set = "test-sse-bucket-after" + data = b"test-sse" + key_id = kms_create_key()["KeyId"] + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_before_set, Body=data) + snapshot.match("put-obj-default-before-setting", response) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_before_set) + snapshot.match("get-obj-default-before-setting", response) + + response = aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms", + "KMSMasterKeyID": key_id, + }, + "BucketKeyEnabled": True, + } + ] + }, + ) + snapshot.match("put-bucket-encryption", response) + + response = aws_client.s3.get_bucket_encryption(Bucket=s3_bucket) + snapshot.match("get-bucket-encryption", response) + + # verify that setting BucketKeyEnabled didn't affect existing keys + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_before_set) + snapshot.match("get-obj-default-after-setting", response) + + # set a new key and see the configuration is in effect + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_after_set, Body=data) + snapshot.match("put-obj-after-setting", response) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_after_set) + snapshot.match("get-obj-after-setting", response) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + @pytest.mark.skip( + reason="Behaviour not implemented yet: https://github.com/localstack/localstack/issues/6882" + ) + # there is currently no server side encryption is place in LS, ETag will be different + @markers.snapshot.skip_snapshot_verify(paths=["$..ETag"]) + def test_s3_sse_default_kms_key( + self, + aws_client, + s3_create_bucket, + snapshot, + ): + snapshot.add_transformer( + [ + snapshot.transform.resource_name("SSEKMSKeyId"), + snapshot.transform.key_value( + "Bucket", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("Location"), + ] + ) + bucket_1 = s3_create_bucket() + bucket_2 = s3_create_bucket() + key_name = "test-sse-default-key" + data = b"test-sse" + response = aws_client.s3.put_object( + Bucket=bucket_1, Key=key_name, Body=data, ServerSideEncryption="aws:kms" + ) + snapshot.match("put-obj-default-kms-s3-key", response) + + response = aws_client.s3.get_object(Bucket=bucket_1, Key=key_name) + snapshot.match("get-obj-default-kms-s3-key", response) + + # validate that the AWS managed key is the same between buckets + response = aws_client.s3.put_object( + Bucket=bucket_2, Key=key_name, Body=data, ServerSideEncryption="aws:kms" + ) + snapshot.match("put-obj-default-kms-s3-key-bucket-2", response) + + response = aws_client.s3.get_object(Bucket=bucket_2, Key=key_name) + snapshot.match("get-obj-default-kms-s3-key-bucket-2", response) + + response = aws_client.s3.put_bucket_encryption( + Bucket=bucket_1, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms", + }, + "BucketKeyEnabled": True, + } + ] + }, + ) + snapshot.match("put-bucket-encryption-default-kms-s3-key", response) + + response = aws_client.s3.get_bucket_encryption(Bucket=bucket_1) + snapshot.match("get-bucket-encryption-default-kms-s3-key", response) + + key_name = "test-sse-default-key-from-bucket" + response = aws_client.s3.put_object( + Bucket=bucket_1, Key=key_name, Body=data, ServerSideEncryption="aws:kms" + ) + snapshot.match("put-obj-default-kms-s3-key-from-bucket", response) + + response = aws_client.s3.get_object(Bucket=bucket_1, Key=key_name) + snapshot.match("get-obj-default-kms-s3-key-from-bucket", response) + + @markers.aws.validated + def test_s3_analytics_configurations(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer( + [ + snapshot.transform.key_value( + "Bucket", reference_replacement=False, value_replacement="" + ), + ] + ) + + bucket = s3_create_bucket() + analytics_bucket = s3_create_bucket() + analytics_bucket_arn = f"arn:aws:s3:::{analytics_bucket}" + + storage_analysis = { + "Id": "config_with_storage_analysis_1", + "Filter": { + "Prefix": "test_ls", + }, + "StorageClassAnalysis": { + "DataExport": { + "OutputSchemaVersion": "V_1", + "Destination": { + "S3BucketDestination": { + "Format": "CSV", + "Bucket": analytics_bucket_arn, + "Prefix": "test", + } + }, + } + }, + } + # id in storage analysis is different from the one in the request + with pytest.raises(ClientError) as err_put: + aws_client.s3.put_bucket_analytics_configuration( + Bucket=bucket, + Id="different-id", + AnalyticsConfiguration=storage_analysis, + ) + snapshot.match("put_config_with_storage_analysis_err", err_put.value.response) + + # non-existing storage analysis get + with pytest.raises(ClientError) as err_get: + aws_client.s3.get_bucket_analytics_configuration( + Bucket=bucket, + Id="non-existing", + ) + snapshot.match("get_config_with_storage_analysis_err", err_get.value.response) + + # non-existing storage analysis delete + with pytest.raises(ClientError) as err_delete: + aws_client.s3.delete_bucket_analytics_configuration( + Bucket=bucket, + Id=storage_analysis["Id"], + ) + snapshot.match("delete_config_with_storage_analysis_err", err_delete.value.response) + + # put storage analysis + response = aws_client.s3.put_bucket_analytics_configuration( + Bucket=bucket, + Id=storage_analysis["Id"], + AnalyticsConfiguration=storage_analysis, + ) + snapshot.match("put_config_with_storage_analysis_1", response) + + response = aws_client.s3.get_bucket_analytics_configuration( + Bucket=bucket, + Id=storage_analysis["Id"], + ) + snapshot.match("get_config_with_storage_analysis_1", response) + + # update storage analysis + storage_analysis["Filter"]["Prefix"] = "test_ls_2" + aws_client.s3.put_bucket_analytics_configuration( + Bucket=bucket, + Id=storage_analysis["Id"], + AnalyticsConfiguration=storage_analysis, + ) + response = aws_client.s3.get_bucket_analytics_configuration( + Bucket=bucket, + Id=storage_analysis["Id"], + ) + snapshot.match("get_config_with_storage_analysis_2", response) + + # add a new storage analysis + storage_analysis["Id"] = "config_with_storage_analysis_2" + storage_analysis["Filter"]["Prefix"] = "test_ls_3" + aws_client.s3.put_bucket_analytics_configuration( + Bucket=bucket, Id=storage_analysis["Id"], AnalyticsConfiguration=storage_analysis + ) + response = aws_client.s3.get_bucket_analytics_configuration( + Bucket=bucket, + Id=storage_analysis["Id"], + ) + snapshot.match("get_config_with_storage_analysis_3", response) + + response = aws_client.s3.list_bucket_analytics_configurations(Bucket=bucket) + snapshot.match("list_config_with_storage_analysis_1", response) + + # delete storage analysis + aws_client.s3.delete_bucket_analytics_configuration( + Bucket=bucket, + Id=storage_analysis["Id"], + ) + response = aws_client.s3.list_bucket_analytics_configurations(Bucket=bucket) + snapshot.match("list_config_with_storage_analysis_2", response) + + @markers.aws.validated + def test_s3_intelligent_tier_config(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + intelligent_tier_configuration = { + "Id": "test1", + "Filter": { + "Prefix": "test1", + }, + "Status": "Enabled", + "Tierings": [ + {"Days": 90, "AccessTier": "ARCHIVE_ACCESS"}, + ], + } + + # different id in tiering config and in put request + with pytest.raises(ClientError) as put_err_1: + aws_client.s3.put_bucket_intelligent_tiering_configuration( + Bucket=s3_bucket, + Id="incorrect_id", + IntelligentTieringConfiguration=intelligent_tier_configuration, + ) + snapshot.match( + "put_bucket_intelligent_tiering_configuration_err_1`", put_err_1.value.response + ) + + # put tiering config + response = aws_client.s3.put_bucket_intelligent_tiering_configuration( + Bucket=s3_bucket, + Id=intelligent_tier_configuration["Id"], + IntelligentTieringConfiguration=intelligent_tier_configuration, + ) + snapshot.match("put_bucket_intelligent_tiering_configuration_1", response) + + # get tiering config and snapshot match + response = aws_client.s3.get_bucket_intelligent_tiering_configuration( + Bucket=s3_bucket, + Id=intelligent_tier_configuration["Id"], + ) + snapshot.match("get_bucket_intelligent_tiering_configuration_1", response) + + # put tiering config with different id + intelligent_tier_configuration["Id"] = "test2" + intelligent_tier_configuration["Filter"]["Prefix"] = "test2" + + aws_client.s3.put_bucket_intelligent_tiering_configuration( + Bucket=s3_bucket, + Id=intelligent_tier_configuration["Id"], + IntelligentTieringConfiguration=intelligent_tier_configuration, + ) + + response = aws_client.s3.list_bucket_intelligent_tiering_configurations(Bucket=s3_bucket) + snapshot.match("list_bucket_intelligent_tiering_configurations_1", response) + + # update the config by adding config with same id + intelligent_tier_configuration["Id"] = "test1" + intelligent_tier_configuration["Filter"]["Prefix"] = "testupdate" + + aws_client.s3.put_bucket_intelligent_tiering_configuration( + Bucket=s3_bucket, + Id=intelligent_tier_configuration["Id"], + IntelligentTieringConfiguration=intelligent_tier_configuration, + ) + + response = aws_client.s3.list_bucket_intelligent_tiering_configurations(Bucket=s3_bucket) + snapshot.match("list_bucket_intelligent_tiering_configurations_2", response) + + # delete the config with non-existing bucket + with pytest.raises(ClientError) as delete_err_1: + aws_client.s3.delete_bucket_intelligent_tiering_configuration( + Bucket=f"non-existing-bucket-{short_uid()}-{short_uid()}", + Id=intelligent_tier_configuration["Id"], + ) + snapshot.match( + "delete_bucket_intelligent_tiering_configuration_err_1", delete_err_1.value.response + ) + + # delete the config with non-existing id + with pytest.raises(ClientError) as delete_err_2: + aws_client.s3.delete_bucket_intelligent_tiering_configuration( + Bucket=s3_bucket, + Id="non-existing-id", + ) + snapshot.match( + "delete_bucket_intelligent_tiering_configuration_err_2", delete_err_2.value.response + ) + + # delete the config + aws_client.s3.delete_bucket_intelligent_tiering_configuration( + Bucket=s3_bucket, + Id=intelligent_tier_configuration["Id"], + ) + + response = aws_client.s3.list_bucket_intelligent_tiering_configurations(Bucket=s3_bucket) + snapshot.match("list_bucket_intelligent_tiering_configurations_3", response) + + @markers.aws.validated + def test_s3_get_object_headers(self, aws_client, s3_bucket, snapshot): + key = "en-gb.wav" + file_path = os.path.join(os.path.dirname(__file__), f"../../files/{key}") + + aws_client.s3.upload_file(file_path, s3_bucket, key) + objects = aws_client.s3.list_objects(Bucket=s3_bucket) + etag = objects["Contents"][0]["ETag"] + + # TODO: some of the headers missing in the get object response + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, IfNoneMatch=etag) + snapshot.match("if_none_match_err_1", e.value.response["Error"]) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, IfNoneMatch=etag.strip('"')) + snapshot.match("if_none_match_err_2", e.value.response["Error"]) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, IfNoneMatch="etag") + snapshot.match("if_none_match_1", response["ResponseMetadata"]["HTTPStatusCode"]) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, IfMatch=etag) + snapshot.match("if_match_1", response["ResponseMetadata"]["HTTPStatusCode"]) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, IfMatch=etag.strip('"')) + snapshot.match("if_match_2", response["ResponseMetadata"]["HTTPStatusCode"]) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, IfMatch="etag") + snapshot.match("if_match_err_1", e.value.response["Error"]) + + @markers.aws.validated + def test_s3_inventory_report_crud(self, aws_client, s3_create_bucket, snapshot, region_name): + snapshot.add_transformer(snapshot.transform.resource_name()) + src_bucket = s3_create_bucket() + dest_bucket = s3_create_bucket() + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "InventoryPolicy", + "Effect": "Allow", + "Principal": {"Service": "s3.amazonaws.com"}, + "Action": "s3:PutObject", + "Resource": [f"arn:{get_partition(region_name)}:s3:::{dest_bucket}/*"], + "Condition": { + "ArnLike": {"aws:SourceArn": f"arn:aws:s3:::{src_bucket}"}, + }, + }, + ], + } + + aws_client.s3.put_bucket_policy(Bucket=dest_bucket, Policy=json.dumps(policy)) + inventory_config = { + "Id": "test-inventory", + "Destination": { + "S3BucketDestination": { + "Bucket": f"arn:{get_partition(region_name)}:s3:::{dest_bucket}", + "Format": "CSV", + } + }, + "IsEnabled": True, + "IncludedObjectVersions": "All", + "OptionalFields": ["Size", "ETag"], + "Schedule": {"Frequency": "Daily"}, + } + + put_inv_config = aws_client.s3.put_bucket_inventory_configuration( + Bucket=src_bucket, + Id=inventory_config["Id"], + InventoryConfiguration=inventory_config, + ) + snapshot.match("put-inventory-config", put_inv_config) + + list_inv_configs = aws_client.s3.list_bucket_inventory_configurations(Bucket=src_bucket) + snapshot.match("list-inventory-config", list_inv_configs) + + get_inv_config = aws_client.s3.get_bucket_inventory_configuration( + Bucket=src_bucket, + Id=inventory_config["Id"], + ) + snapshot.match("get-inventory-config", get_inv_config) + + del_inv_config = aws_client.s3.delete_bucket_inventory_configuration( + Bucket=src_bucket, + Id=inventory_config["Id"], + ) + snapshot.match("del-inventory-config", del_inv_config) + + list_inv_configs_after_del = aws_client.s3.list_bucket_inventory_configurations( + Bucket=src_bucket + ) + snapshot.match("list-inventory-config-after-del", list_inv_configs_after_del) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_inventory_configuration( + Bucket=src_bucket, + Id=inventory_config["Id"], + ) + snapshot.match("get-nonexistent-inv-config", e.value.response) + + @markers.aws.validated + def test_s3_put_inventory_report_exceptions(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.resource_name()) + src_bucket = s3_create_bucket() + dest_bucket = s3_create_bucket() + config_id = "test-inventory" + + def _get_config(): + return { + "Id": config_id, + "Destination": { + "S3BucketDestination": { + "Bucket": f"arn:aws:s3:::{dest_bucket}", + "Format": "CSV", + } + }, + "IsEnabled": True, + "IncludedObjectVersions": "All", + "Schedule": {"Frequency": "Daily"}, + } + + def _put_bucket_inventory_configuration(inventory_configuration): + aws_client.s3.put_bucket_inventory_configuration( + Bucket=src_bucket, + Id=config_id, + InventoryConfiguration=inventory_configuration, + ) + + # put an inventory config with a wrong ID + with pytest.raises(ClientError) as e: + inv_config = _get_config() + inv_config["Id"] = config_id + "wrong" + _put_bucket_inventory_configuration(inv_config) + snapshot.match("wrong-id", e.value.response) + + # set the Destination Bucket only as the name and not the ARN + with pytest.raises(ClientError) as e: + inv_config = _get_config() + inv_config["Destination"]["S3BucketDestination"]["Bucket"] = dest_bucket + _put_bucket_inventory_configuration(inv_config) + snapshot.match("wrong-destination-arn", e.value.response) + + # set the wrong Destination Format (should be CSV/ORC/Parquet) + with pytest.raises(ClientError) as e: + inv_config = _get_config() + inv_config["Destination"]["S3BucketDestination"]["Format"] = "WRONG-FORMAT" + _put_bucket_inventory_configuration(inv_config) + snapshot.match("wrong-destination-format", e.value.response) + + # set the wrong Schedule Frequency (should be Daily/Weekly) + with pytest.raises(ClientError) as e: + inv_config = _get_config() + inv_config["Schedule"]["Frequency"] = "Hourly" + _put_bucket_inventory_configuration(inv_config) + snapshot.match("wrong-schedule-frequency", e.value.response) + + # set the wrong IncludedObjectVersions (should be All/Current) + with pytest.raises(ClientError) as e: + inv_config = _get_config() + inv_config["IncludedObjectVersions"] = "Wrong" + _put_bucket_inventory_configuration(inv_config) + snapshot.match("wrong-object-versions", e.value.response) + + # set wrong OptionalFields + with pytest.raises(ClientError) as e: + inv_config = _get_config() + inv_config["OptionalFields"] = ["TestField"] + _put_bucket_inventory_configuration(inv_config) + snapshot.match("wrong-optional-field", e.value.response) + + @markers.aws.validated + def test_put_bucket_inventory_config_order( + self, aws_client, s3_create_bucket, snapshot, region_name + ): + snapshot.add_transformer(snapshot.transform.resource_name()) + src_bucket = s3_create_bucket() + dest_bucket = s3_create_bucket() + + def _put_bucket_inventory_configuration(config_id: str): + inventory_configuration = { + "Id": config_id, + "Destination": { + "S3BucketDestination": { + "Bucket": f"arn:{get_partition(region_name)}:s3:::{dest_bucket}", + "Format": "CSV", + } + }, + "IsEnabled": True, + "IncludedObjectVersions": "All", + "Schedule": {"Frequency": "Daily"}, + } + aws_client.s3.put_bucket_inventory_configuration( + Bucket=src_bucket, + Id=config_id, + InventoryConfiguration=inventory_configuration, + ) + + for inv_config_id in ("test-1", "z-test", "a-test"): + _put_bucket_inventory_configuration(inv_config_id) + + list_inv_configs = aws_client.s3.list_bucket_inventory_configurations(Bucket=src_bucket) + snapshot.match("list-inventory-config", list_inv_configs) + + del_inv_config = aws_client.s3.delete_bucket_inventory_configuration( + Bucket=src_bucket, + Id="z-test", + ) + snapshot.match("del-inventory-config", del_inv_config) + + list_inv_configs = aws_client.s3.list_bucket_inventory_configurations(Bucket=src_bucket) + snapshot.match("list-inventory-config-after-del", list_inv_configs) + + @pytest.mark.parametrize( + "use_virtual_address", + [True, False], + ) + @markers.aws.validated + def test_get_object_content_length_with_virtual_host( + self, + s3_bucket, + use_virtual_address, + snapshot, + aws_client, + aws_client_factory, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("x-amz-request-id"), + snapshot.transform.key_value("x-amz-id-2"), + snapshot.transform.key_value("last-modified", reference_replacement=False), + snapshot.transform.key_value("date", reference_replacement=False), + snapshot.transform.key_value("server"), + ] + ) + object_key = "temp.txt" + aws_client.s3.put_object(Key=object_key, Bucket=s3_bucket, Body="123") + + s3_config = {"addressing_style": "virtual"} if use_virtual_address else {} + client = aws_client_factory( + config=Config(s3=s3_config), + endpoint_url=_endpoint_url(), + ).s3 + + url = _generate_presigned_url(client, {"Bucket": s3_bucket, "Key": object_key}, expires=10) + response = requests.get(url) + assert response.ok + lowercase_headers = {k.lower(): v for k, v in response.headers.items()} + snapshot.match("get-obj-content-len-headers", lowercase_headers) + + @markers.aws.validated + def test_empty_bucket_fixture(self, s3_bucket, s3_empty_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("Name")) + for i in range(3): + aws_client.s3.put_object(Bucket=s3_bucket, Key=f"key{i}", Body="123") + + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-obj", response) + + s3_empty_bucket(s3_bucket) + + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-obj-after-empty", response) + + @markers.aws.only_localstack + def test_s3_raw_request_routing(self, s3_bucket, aws_client): + """ + When sending a PutObject request to S3 with a very raw request not having any indication that the request is + directed to S3 (no signing, no specific S3 endpoint) and encoded as a form, the request will go through the + ServiceNameParser handler. + This parser will try to parse the form data (which in our case is binary data), and will fail with a decoding + error. It also consumes the stream, and leaves S3 with no data to save. + This test verifies that this scenario works by skipping the service name thanks to the early S3 CORS handler. + """ + default_endpoint = f"http://{get_localstack_host().host_and_port()}" + object_key = "test-routing-key" + key_url = f"{default_endpoint}/{s3_bucket}/{object_key}" + data = os.urandom(445529) + resp = requests.put( + key_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + assert resp.ok + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + assert get_object["Body"].read() == data + + fake_key_url = f"{default_endpoint}/fake-bucket-{short_uid()}/{object_key}" + resp = requests.put( + fake_key_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + assert b"NoSuchBucket" in resp.content + + +class TestS3MultiAccounts: + @pytest.fixture + def primary_client(self, aws_client): + return aws_client.s3 + + @pytest.fixture + def secondary_client(self, secondary_aws_client): + """ + Create a boto client with secondary test credentials and region. + """ + return secondary_aws_client.s3 + + @markers.aws.only_localstack + def test_shared_bucket_namespace(self, primary_client, secondary_client, cleanups): + bucket_name = short_uid() + + # Ensure that the bucket name space is shared by all accounts and regions + create_s3_bucket(bucket_name=bucket_name, s3_client=primary_client) + cleanups.append(lambda: primary_client.delete_bucket(Bucket=bucket_name)) + + with pytest.raises(ClientError) as exc: + create_s3_bucket(bucket_name=bucket_name, s3_client=secondary_client) + exc.match("BucketAlreadyExists") + + @markers.aws.only_localstack + def test_cross_account_access( + self, primary_client, secondary_client, cleanups, s3_empty_bucket + ): + # Ensure that following operations can be performed across accounts + # - ListObjects + # - PutObject + # - GetObject + + bucket_name = short_uid() + key_name = "lorem/ipsum" + body1 = b"zaphod beeblebrox" + body2 = b"42" + + # First user creates a bucket and puts an object + create_s3_bucket(bucket_name=bucket_name, s3_client=primary_client) + cleanups.append(lambda: primary_client.delete_bucket(Bucket=bucket_name)) + cleanups.append(lambda: s3_empty_bucket(bucket_name)) + + response = primary_client.list_buckets() + assert bucket_name in [bucket["Name"] for bucket in response["Buckets"]] + primary_client.put_object(Bucket=bucket_name, Key=key_name, Body=body1) + + # Second user must not see this bucket in their `ListBuckets` response + response = secondary_client.list_buckets() + assert bucket_name not in [bucket["Name"] for bucket in response["Buckets"]] + + # Yet they should be able to `ListObjects` in that bucket + response = secondary_client.list_objects(Bucket=bucket_name) + assert key_name in [key["Key"] for key in response["Contents"]] + + # Along with `GetObject` and `PutObject` + # ACL and permission enforcement is currently not implemented + response = secondary_client.get_object(Bucket=bucket_name, Key=key_name) + assert response["Body"].read() == body1 + assert secondary_client.put_object(Bucket=bucket_name, Key=key_name, Body=body2) + + # The modified object must be reflected for the first user + response = primary_client.get_object(Bucket=bucket_name, Key=key_name) + assert response["Body"].read() == body2 + + @markers.aws.only_localstack + def test_cross_account_copy_object( + self, primary_client, secondary_client, cleanups, s3_empty_bucket + ): + bucket_name = short_uid() + key_name = "lorem/ipsum" + key_name_copy = "lorem/ipsum2" + body1 = b"zaphod beeblebrox" + + # First user creates a bucket and puts an object + create_s3_bucket(bucket_name=bucket_name, s3_client=primary_client) + cleanups.append(lambda: primary_client.delete_bucket(Bucket=bucket_name)) + cleanups.append(lambda: s3_empty_bucket(bucket_name)) + + primary_client.put_object(Bucket=bucket_name, Key=key_name, Body=body1) + + # Assert that the secondary client can copy an object in the other account bucket + response = secondary_client.copy_object( + Bucket=bucket_name, Key=key_name_copy, CopySource=f"{bucket_name}/{key_name}" + ) + + # Yet they should be able to `ListObjects` in that bucket + response = secondary_client.list_objects(Bucket=bucket_name) + bucket_keys = {key["Key"] for key in response["Contents"]} + assert key_name in bucket_keys + assert key_name_copy in bucket_keys + + +class TestS3TerraformRawRequests: + @markers.aws.only_localstack + def test_terraform_request_sequence(self, aws_client): + reqs = load_file( + os.path.join( + os.path.dirname(__file__), + "../../files/s3.requests.txt", + ) + ) + reqs = reqs.split("---") + + for req in reqs: + header, _, body = req.strip().partition("\n\n") + req, _, headers = header.strip().partition("\n") + headers = {h.split(":")[0]: h.partition(":")[2].strip() for h in headers.split("\n")} + method, path, _ = req.split(" ") + url = f"{config.internal_service_url()}{path}" + result = requests.request(method=method, url=url, data=body, headers=headers) + assert result.status_code < 400 + + +class TestS3PresignedUrl: + """ + These tests pertain to S3's presigned URL feature. + """ + + # # Note: This test may have side effects (via `s3_client.meta.events.register(..)`) and + # # may not be suitable for parallel execution + @markers.aws.validated + def test_presign_with_additional_query_params( + self, s3_bucket, patch_s3_skip_signature_validation_false, aws_client + ): + """related to issue: https://github.com/localstack/localstack/issues/4133""" + + def add_query_param(request, **kwargs): + request.url += "?requestedBy=abcDEF123" + + aws_client.s3.put_object(Body="test-value", Bucket=s3_bucket, Key="test") + s3_presigned_client = _s3_client_pre_signed_client( + Config(signature_version="s3v4"), + endpoint_url=_endpoint_url(), + ) + s3_presigned_client.meta.events.register("before-sign.s3.GetObject", add_query_param) + try: + presign_url = s3_presigned_client.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": s3_bucket, "Key": "test"}, + ExpiresIn=86400, + ) + assert "requestedBy=abcDEF123" in presign_url + response = requests.get(presign_url) + assert b"test-value" == response._content + finally: + s3_presigned_client.meta.events.unregister("before-sign.s3.GetObject", add_query_param) + + @markers.aws.only_localstack + def test_presign_check_signature_validation_for_port_permutation( + self, s3_bucket, patch_s3_skip_signature_validation_false, aws_client + ): + host = f"{S3_VIRTUAL_HOSTNAME}:{config.GATEWAY_LISTEN[0].port}" + s3_presign = _s3_client_pre_signed_client( + Config(signature_version="s3v4"), + endpoint_url=f"http://{host}", + ) + + aws_client.s3.put_object(Body="test-value", Bucket=s3_bucket, Key="test") + + presign_url = s3_presign.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": s3_bucket, "Key": "test"}, + ExpiresIn=86400, + ) + assert f":{config.GATEWAY_LISTEN[0].port}" in presign_url + + host_443 = host.replace(f":{config.GATEWAY_LISTEN[0].port}", ":443") + response = requests.get(presign_url, headers={"host": host_443}) + assert b"test-value" == response._content + + host_no_port = host_443.replace(":443", "") + response = requests.get(presign_url, headers={"host": host_no_port}) + assert b"test-value" == response._content + + @markers.aws.validated + def test_put_object(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + key = "my-key" + + url = aws_client.s3.generate_presigned_url( + "put_object", Params={"Bucket": s3_bucket, "Key": key} + ) + requests.put(url, data="something", verify=False) + + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + assert response["Body"].read() == b"something" + snapshot.match("get_object", response) + + @markers.aws.only_localstack + def test_get_request_expires_ignored_if_validation_disabled( + self, s3_bucket, monkeypatch, patch_s3_skip_signature_validation_false, aws_client + ): + aws_client.s3.put_object(Body="test-value", Bucket=s3_bucket, Key="test") + + presigned_request = aws_client.s3.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": s3_bucket, "Key": "test"}, + ExpiresIn=2, + ) + # sleep so it expires + time.sleep(3) + + # attempt to use the presigned request + response = requests.get(presigned_request) + # response should not be successful as it is expired -> signature will not match + # "SignatureDoesNotMatch" in str(response.content) + assert response.status_code in [400, 403] + + # set skip signature validation to True -> the request should now work + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", True) + response = requests.get(presigned_request) + assert response.status_code == 200 + assert b"test-value" == response.content + + @markers.aws.validated + def test_delete_has_empty_content_length_header(self, s3_bucket, aws_client): + for encoding in None, "gzip": + # put object + object_key = "key-by-hostname" + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body="something", + ContentType="text/html; charset=utf-8", + ) + url = aws_client.s3.generate_presigned_url( + "delete_object", Params={"Bucket": s3_bucket, "Key": object_key} + ) + + # get object and assert headers + headers = {} + if encoding: + headers["Accept-Encoding"] = encoding + response = requests.delete(url, headers=headers, verify=False) + assert not response.content + assert response.status_code == 204 + assert response.headers.get("x-amz-id-2") is not None + # AWS does not return a Content-Type when the body is empty and it returns 204 + assert response.headers.get("content-type") is None + # AWS does not send a content-length header at all + assert response.headers.get("content-length") is None + + @markers.aws.validated + def test_head_has_correct_content_length_header(self, s3_bucket, aws_client): + body = "something body \n \n\r" + # put object + object_key = "key-by-hostname" + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body=body, + ContentType="text/html; charset=utf-8", + ) + url = aws_client.s3.generate_presigned_url( + "head_object", Params={"Bucket": s3_bucket, "Key": object_key} + ) + # get object and assert headers + response = requests.head(url, verify=False) + assert response.headers.get("content-length") == str(len(body)) + + @markers.aws.validated + @pytest.mark.parametrize("verify_signature", (True, False)) + def test_put_url_metadata_with_sig_s3v4( + self, + s3_bucket, + snapshot, + aws_client, + verify_signature, + monkeypatch, + presigned_snapshot_transformers, + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("HostId")) + snapshot.add_transformer(snapshot.transform.key_value("RequestId")) + if verify_signature: + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", False) + else: + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", True) + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version="s3v4"), + endpoint_url=_endpoint_url(), + ) + + # Object metadata should be passed as signed headers when sending the pre-signed URL, the boto signer does not + # append it to the URL + # https://github.com/localstack/localstack/issues/544 + metadata = {"foo": "bar"} + object_key = "key-by-hostname" + + # put object via presigned URL with metadata + url = presigned_client.generate_presigned_url( + "put_object", + Params={"Bucket": s3_bucket, "Key": object_key, "Metadata": metadata}, + ) + assert "x-amz-meta-foo=bar" not in url + + # put the request without the headers + response = requests.put(url, data="content 123") + # if we skip validation, it should work for LocalStack + if not verify_signature and not is_aws_cloud(): + assert response.ok, f"response returned {response.status_code}: {response.text}" + # response body should be empty, see https://github.com/localstack/localstack/issues/1317 + assert not response.text + else: + assert response.status_code == 403 + exception = xmltodict.parse(response.content) + snapshot.match("no-meta-headers", exception) + + # put it now with the signed headers + response = requests.put(url, data="content 123", headers={"x-amz-meta-foo": "bar"}) + # assert metadata is present + assert response.ok + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + assert response["Metadata"]["foo"] == "bar" + snapshot.match("head_object", response) + + # assert with another metadata, should fail if verify_signature is not True + response = requests.put(url, data="content 123", headers={"x-amz-meta-wrong": "wrong"}) + + # if we skip validation, it should work for LocalStack + if not verify_signature and not is_aws_cloud(): + assert response.ok, f"response returned {response.status_code}: {response.text}" + else: + assert response.status_code == 403 + exception = xmltodict.parse(response.content) + snapshot.match("wrong-meta-headers", exception) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + if not verify_signature and not is_aws_cloud(): + assert head_object["Metadata"]["wrong"] == "wrong" + else: + assert "wrong" not in head_object["Metadata"] + + @markers.aws.validated + @pytest.mark.parametrize("verify_signature", (True, False)) + def test_put_url_metadata_with_sig_s3( + self, + s3_bucket, + snapshot, + aws_client, + verify_signature, + monkeypatch, + presigned_snapshot_transformers, + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + if verify_signature: + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", False) + else: + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", True) + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version="s3"), + endpoint_url=_endpoint_url(), + ) + + # Object metadata should be passed as query params via presigned URL + # https://github.com/localstack/localstack/issues/544 + metadata = {"foo": "bar"} + object_key = "key-by-hostname" + + # put object via presigned URL with metadata + url = presigned_client.generate_presigned_url( + "put_object", + Params={"Bucket": s3_bucket, "Key": object_key, "Metadata": metadata}, + ) + assert "x-amz-meta-foo=bar" in url + + response = requests.put(url, data="content 123", verify=False) + assert response.ok, f"response returned {response.status_code}: {response.text}" + # response body should be empty, see https://github.com/localstack/localstack/issues/1317 + assert not response.text + + # assert metadata is present + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + assert response.get("Metadata", {}).get("foo") == "bar" + snapshot.match("head_object", response) + + # assert with another metadata directly in the headers + response = requests.put(url, data="content 123", headers={"x-amz-meta-wrong": "wrong"}) + # if we skip validation, it should work for LocalStack + if not verify_signature and not is_aws_cloud(): + assert response.ok, f"response returned {response.status_code}: {response.text}" + else: + assert response.status_code == 403 + exception = xmltodict.parse(response.content) + snapshot.match("wrong-meta-headers", exception) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + if not verify_signature and not is_aws_cloud(): + assert head_object["Metadata"]["wrong"] == "wrong" + else: + assert "wrong" not in head_object["Metadata"] + + @markers.aws.validated + def test_get_object_ignores_request_body(self, s3_bucket, aws_client): + key = "foo-key" + body = "foobar" + + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=body) + + url = aws_client.s3.generate_presigned_url( + "get_object", Params={"Bucket": s3_bucket, "Key": key} + ) + + response = requests.get(url, data=b"get body is ignored by AWS") + assert response.status_code == 200 + assert response.text == body + + @markers.aws.validated + def test_presigned_double_encoded_credentials( + self, s3_bucket, aws_client, snapshot, presigned_snapshot_transformers + ): + key = "foo-key" + body = "foobar" + + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=body) + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version="s3v4"), + endpoint_url=_endpoint_url(), + ) + url = presigned_client.generate_presigned_url( + "get_object", Params={"Bucket": s3_bucket, "Key": key} + ) + url = url.replace("%2F", "%252F") + + response = requests.get(url) + assert response.status_code == 400 + exception = xmltodict.parse(response.content) + snapshot.match("error-malformed", exception) + + @markers.aws.validated + @pytest.mark.parametrize( + "signature_version, verify_signature", + [ + ("s3", True), + ("s3", False), + ("s3v4", True), + ("s3v4", False), + ], + ) + def test_put_object_with_md5_and_chunk_signature_bad_headers( + self, + s3_bucket, + signature_version, + verify_signature, + monkeypatch, + snapshot, + aws_client, + presigned_snapshot_transformers, + ): + snapshotted = False + if verify_signature: + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", False) + snapshotted = True + else: + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", True) + + object_key = "test-runtime.properties" + content_md5 = "pX8KKuGXS1f2VTcuJpqjkw==" + headers = { + "Content-Md5": content_md5, + "Content-Type": "application/octet-stream", + "X-Amz-Content-Sha256": "STREAMING-AWS4-HMAC-SHA256-PAYLOAD", + "X-Amz-Date": "20211122T191045Z", + "X-Amz-Decoded-Content-Length": "test", # string instead of int + "Content-Length": "10", + "Connection": "Keep-Alive", + "Expect": "100-continue", + } + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + url = presigned_client.generate_presigned_url( + "put_object", + Params={ + "Bucket": s3_bucket, + "Key": object_key, + "ContentType": "application/octet-stream", + "ContentMD5": content_md5, + }, + ) + result = requests.put(url, data="test", headers=headers) + assert result.status_code == 403 + if snapshotted: + exception = xmltodict.parse(result.content) + snapshot.match("with-decoded-content-length", exception) + + if signature_version == "s3" or (not verify_signature and not is_aws_cloud()): + assert b"SignatureDoesNotMatch" in result.content + # we are either using s3v4 with new provider or whichever signature against AWS + else: + assert b"AccessDenied" in result.content + + # check also no X-Amz-Decoded-Content-Length + headers.pop("X-Amz-Decoded-Content-Length") + result = requests.put(url, data="test", headers=headers) + assert result.status_code == 403, (result, result.content) + if snapshotted: + exception = xmltodict.parse(result.content) + snapshot.match("without-decoded-content-length", exception) + if signature_version == "s3" or (not verify_signature and not is_aws_cloud()): + assert b"SignatureDoesNotMatch" in result.content + else: + assert b"AccessDenied" in result.content + + @markers.aws.validated + def test_s3_get_response_default_content_type(self, s3_bucket, aws_client): + # When no content type is provided by a PUT request + # 'binary/octet-stream' should be used + # src: https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUT.html + + # put object + object_key = "key-by-hostname" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + # get object and assert headers + url = aws_client.s3.generate_presigned_url( + "get_object", Params={"Bucket": s3_bucket, "Key": object_key} + ) + response = requests.get(url, verify=False) + assert response.headers["content-type"] == "binary/octet-stream" + + @markers.aws.validated + @pytest.mark.parametrize("signature_version", ["s3", "s3v4"]) + def test_s3_presigned_url_expired( + self, + s3_bucket, + signature_version, + snapshot, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + object_key = "key-expires-in-2" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + # get object and assert headers + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + url = presigned_client.generate_presigned_url( + "get_object", Params={"Bucket": s3_bucket, "Key": object_key}, ExpiresIn=2 + ) + # retrieving it before expiry + resp = requests.get(url, verify=False) + assert resp.status_code == 200 + assert to_str(resp.content) == "something" + + time.sleep(3) # wait for the URL to expire + resp = requests.get(url, verify=False) + resp_content = to_str(resp.content) + assert resp.status_code == 403 + exception = xmltodict.parse(resp.content) + snapshot.match("expired-exception", exception) + + assert "AccessDenied" in resp_content + assert "Request has expired" in resp_content + + url = presigned_client.generate_presigned_url( + "get_object", + Params={"Bucket": s3_bucket, "Key": object_key}, + ExpiresIn=120, + ) + + resp = requests.get(url, verify=False) + assert resp.status_code == 200 + assert to_str(resp.content) == "something" + + @markers.aws.validated + @pytest.mark.parametrize("signature_version", ["s3", "s3v4"]) + def test_s3_put_presigned_url_with_different_headers( + self, + s3_bucket, + signature_version, + snapshot, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + object_key = "key-double-header-param" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + # Content-Type, Content-MD5 and Date are specific headers for SigV2 and are checked + # others are not verified in the signature + # Manually set the content-type for it to be added to the signature + presigned_url = presigned_client.generate_presigned_url( + "put_object", + Params={ + "Bucket": s3_bucket, + "Key": object_key, + "ContentType": "text/plain", + }, + ExpiresIn=10, + ) + # Use the pre-signed URL with the right ContentType + response = requests.put( + presigned_url, + data="test_data", + headers={"Content-Type": "text/plain"}, + ) + assert not response.content + assert response.status_code == 200 + + # Use the pre-signed URL with the wrong ContentType + response = requests.put( + presigned_url, + data="test_data", + headers={"Content-Type": "text/xml"}, + ) + assert response.status_code == 403 + + exception = xmltodict.parse(response.content) + exception["StatusCode"] = response.status_code + snapshot.match("content-type-exception", exception) + + if signature_version == "s3": + # we sleep 1 second to allow the StringToSign value in the exception change between both call + # (timestamped value, to avoid the test being flaky) + time.sleep(1.1) + + # regenerate a new pre-signed URL with no content-type specified + presigned_url = presigned_client.generate_presigned_url( + "put_object", + Params={ + "Bucket": s3_bucket, + "Key": object_key, + "ContentEncoding": "identity", + }, + ExpiresIn=10, + ) + + # send the pre-signed URL with the right ContentEncoding + response = requests.put( + presigned_url, + data="test_data", + headers={"Content-Encoding": "identity"}, + ) + assert not response.content + assert response.status_code == 200 + + # send the pre-signed URL with the right ContentEncoding but a new ContentType + # should fail with SigV2 and succeed with SigV4 + response = requests.put( + presigned_url, + data="test_data", + headers={"Content-Encoding": "identity", "Content-Type": "text/xml"}, + ) + if signature_version == "s3": + assert response.status_code == 403 + else: + assert response.status_code == 200 + + exception = xmltodict.parse(response.content) if response.content else {} + exception["StatusCode"] = response.status_code + snapshot.match("content-type-response", exception) + + # now send the pre-signed URL with the wrong ContentEncoding + # should succeed with SigV2 as only hard coded headers are checked + # but fail with SigV4 as Content-Encoding was part of the signed headers + response = requests.put( + presigned_url, + data="test_data", + headers={"Content-Encoding": "gzip"}, + ) + if signature_version == "s3": + assert response.status_code == 200 + else: + assert response.status_code == 403 + exception = xmltodict.parse(response.content) if response.content else {} + exception["StatusCode"] = response.status_code + snapshot.match("wrong-content-encoding-response", exception) + + @markers.aws.validated + def test_s3_put_presigned_url_same_header_and_qs_parameter( + self, + s3_bucket, + snapshot, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + # this test tries to check if double query/header values trigger InvalidRequest like said in the documentation + # spoiler: they do not + # https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html#query-string-auth-v4-signing + + object_key = "key-double-header-param" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version="s3v4"), + endpoint_url=_endpoint_url(), + ) + presigned_url = presigned_client.generate_presigned_url( + "put_object", + Params={ + "Bucket": s3_bucket, + "Key": object_key, + "RequestPayer": "requester", + }, + ExpiresIn=10, + ) + # add the same parameter as a query string parameter as well as header, with different values + parsed = urlparse(presigned_url) + query_params = parse_qs(parsed.query) + # auth params needs to be at the end + new_query_params = {"x-amz-request-payer": ["non-valid"]} + for k, v in query_params.items(): + new_query_params[k] = v[0] + + new_query_params = urlencode(new_query_params, quote_via=quote, safe=" ") + new_url = urlunsplit( + SplitResult( # noqa + parsed.scheme, parsed.netloc, parsed.path, new_query_params, parsed.fragment + ) + ) + response = requests.put( + new_url, + data="test_data", + headers={"x-amz-request-payer": "requester"}, + ) + exception = xmltodict.parse(response.content) if response.content else {} + exception["StatusCode"] = response.status_code + snapshot.match("double-header-query-string", exception) + + # test overriding a signed query parameter + response = requests.put( + presigned_url, + data="test_data", + headers={"x-amz-expires": "5"}, + ) + exception = xmltodict.parse(response.content) if response.content else {} + exception["StatusCode"] = response.status_code + snapshot.match("override-signed-qs", exception) + + @markers.aws.validated + @pytest.mark.parametrize("signature_version", ["s3", "s3v4"]) + def test_s3_put_presigned_url_missing_sig_param( + self, + s3_bucket, + signature_version, + snapshot, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + object_key = "key-missing-param" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + url = presigned_client.generate_presigned_url( + "get_object", Params={"Bucket": s3_bucket, "Key": object_key}, ExpiresIn=5 + ) + parsed = urlparse(url) + query_params = parse_qs(parsed.query) + # sig v2 + if "Signature" in query_params: + query_params.pop("Expires", None) + else: # sig v4 + query_params.pop("X-Amz-Date", None) + new_query_params = urlencode( + {k: v[0] for k, v in query_params.items()}, quote_via=quote, safe=" " + ) + + invalid_url = urlunsplit( + SplitResult( # noqa + parsed.scheme, parsed.netloc, parsed.path, new_query_params, parsed.fragment + ) + ) + + resp = requests.get(invalid_url, verify=False) + assert resp.status_code in [ + 400, + 403, + ] # the snapshot will differentiate between sig v2 and sig v4 + exception = xmltodict.parse(resp.content) + exception["StatusCode"] = resp.status_code + snapshot.match("missing-param-exception", exception) + + @markers.aws.validated + def test_s3_get_response_content_type_same_as_upload_and_range(self, s3_bucket, aws_client): + # put object + object_key = "foo/bar/key-by-hostname" + content_type = "foo/bar; charset=utf-8" + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=object_key, + Body="something " * 20, + ContentType=content_type, + ) + + url = aws_client.s3.generate_presigned_url( + "get_object", Params={"Bucket": s3_bucket, "Key": object_key} + ) + + # get object and assert headers + response = requests.get(url, verify=False) + assert content_type == response.headers["content-type"] + + # get object using range query and assert headers + response = requests.get(url, headers={"Range": "bytes=0-18"}, verify=False) + assert content_type == response.headers["content-type"] + # test we only get the first 18 bytes from the object + assert "something something" == to_str(response.content) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="STS not enabled in S3 image") + @markers.aws.validated + def test_presigned_url_with_session_token( + self, + s3_create_bucket_with_client, + patch_s3_skip_signature_validation_false, + aws_client, + region_name, + ): + bucket_name = f"bucket-{short_uid()}" + key_name = "key" + response = aws_client.sts.get_session_token() + if not is_aws_cloud(): + # Moto does not register the default returned value from STS as a valid IAM user, which is way we can't + # retrieve the secret access key + # we need to hardcode the secret access key to the default one + response["Credentials"]["SecretAccessKey"] = ( + s3_constants.DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY + ) + + client = boto3.client( + "s3", + config=Config(signature_version="s3v4"), + region_name=AWS_REGION_US_EAST_1, + endpoint_url=_endpoint_url(), + aws_access_key_id=response["Credentials"]["AccessKeyId"], + aws_secret_access_key=response["Credentials"]["SecretAccessKey"], + aws_session_token=response["Credentials"]["SessionToken"], + ) + s3_create_bucket_with_client(s3_client=client, Bucket=bucket_name) + client.put_object(Body="test-value", Bucket=bucket_name, Key=key_name) + presigned_url = client.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": bucket_name, "Key": key_name}, + ExpiresIn=600, + ) + response = requests.get(presigned_url) + assert response._content == b"test-value" + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="STS not enabled in S3 image") + @markers.aws.validated + def test_presigned_url_with_different_user_credentials( + self, + aws_client, + s3_create_bucket_with_client, + create_role_with_policy, + account_id, + wait_and_assume_role, + patch_s3_skip_signature_validation_false, + region_name, + aws_client_factory, + ): + bucket_name = f"bucket-{short_uid()}" + key_name = "key" + actions = [ + "s3:CreateBucket", + "s3:PutObject", + "s3:GetObject", + "s3:DeleteBucket", + "s3:DeleteObject", + ] + + assume_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"AWS": account_id}, + "Effect": "Allow", + } + ], + } + assume_policy_doc = json.dumps(assume_policy_doc) + role_name, role_arn = create_role_with_policy( + effect="Allow", + actions=actions, + assume_policy_doc=assume_policy_doc, + resource="*", + ) + + credentials = wait_and_assume_role(role_arn=role_arn) + + client = boto3.client( + "s3", + region_name=region_name, + config=Config(signature_version="s3v4"), + endpoint_url=_endpoint_url(), + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + ) + + kwargs = ( + {"CreateBucketConfiguration": {"LocationConstraint": region_name}} + if region_name != AWS_REGION_US_EAST_1 + else {} + ) + retry( + lambda: s3_create_bucket_with_client(s3_client=client, Bucket=bucket_name, **kwargs), + sleep=3 if is_aws_cloud() else 0.5, + ) + + client.put_object(Body="test-value", Bucket=bucket_name, Key=key_name) + presigned_url = client.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": bucket_name, "Key": key_name}, + ExpiresIn=600, + ) + response = requests.get(presigned_url) + assert response._content == b"test-value" + + @markers.aws.validated + @pytest.mark.parametrize("signature_version", ["s3", "s3v4"]) + def test_s3_get_response_header_overrides( + self, s3_bucket, signature_version, patch_s3_skip_signature_validation_false, aws_client + ): + # Signed requests may include certain header overrides in the querystring + # https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html + object_key = "key-header-overrides" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + # get object and assert headers + expiry_date = "Wed, 21 Oct 2015 07:28:00 GMT" + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), endpoint_url=_endpoint_url() + ) + + url = presigned_client.generate_presigned_url( + "get_object", + Params={ + "Bucket": s3_bucket, + "Key": object_key, + "ResponseCacheControl": "max-age=74", + "ResponseContentDisposition": 'attachment; filename="foo.jpg"', + "ResponseContentEncoding": "identity", + "ResponseContentLanguage": "de-DE", + "ResponseContentType": "image/jpeg", + "ResponseExpires": expiry_date, + }, + ) + response = requests.get(url, verify=False) + assert response.status_code == 200 + headers = response.headers + assert headers["cache-control"] == "max-age=74" + assert headers["content-disposition"] == 'attachment; filename="foo.jpg"' + assert headers["content-encoding"] == "identity" + assert headers["content-language"] == "de-DE" + assert headers["content-type"] == "image/jpeg" + + # Note: looks like depending on the environment/libraries, we can get different date formats... + possible_date_formats = ["2015-10-21T07:28:00Z", expiry_date] + assert headers["expires"] in possible_date_formats + + @markers.aws.validated + def test_s3_copy_md5(self, s3_bucket, snapshot, monkeypatch, aws_client): + if not is_aws_cloud(): + monkeypatch.setattr(config, "S3_SKIP_SIGNATURE_VALIDATION", False) + src_key = "src" + aws_client.s3.put_object(Bucket=s3_bucket, Key=src_key, Body="something") + + # copy object + dest_key = "dest" + response = aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource={"Bucket": s3_bucket, "Key": src_key}, + Key=dest_key, + ) + snapshot.match("copy-obj", response) + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version="s3v4", s3={"payload_signing_enabled": True}), + endpoint_url=_endpoint_url(), + ) + + # Create copy object to try to match s3a setting Content-MD5 + dest_key2 = "dest" + url = presigned_client.generate_presigned_url( + "copy_object", + Params={ + "Bucket": s3_bucket, + "CopySource": {"Bucket": s3_bucket, "Key": src_key}, + "Key": dest_key2, + }, + ) + + request_response = requests.put( + url, headers={"x-amz-copy-source": f"{s3_bucket}/{src_key}"}, verify=False + ) + assert request_response.status_code == 200 + + @markers.aws.only_localstack + def test_s3_get_response_case_sensitive_headers(self, s3_bucket, aws_client): + # Test that ETag headers is case sensitive + object_key = "key-by-hostname" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="something") + + # get object and assert headers + url = aws_client.s3.generate_presigned_url( + "get_object", Params={"Bucket": s3_bucket, "Key": object_key} + ) + response = requests.get(url, verify=False) + # expect that Etag is contained + header_names = list(response.headers.keys()) + assert "ETag" in header_names + + @pytest.mark.parametrize( + "signature_version, use_virtual_address", + [ + ("s3", False), + ("s3", True), + ("s3v4", False), + ("s3v4", True), + ], + ) + @markers.aws.validated + def test_presigned_url_signature_authentication_expired( + self, + s3_create_bucket, + signature_version, + use_virtual_address, + snapshot, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + bucket_name = f"presign-{short_uid()}" + + s3_endpoint_path_style = _endpoint_url() + + s3_create_bucket(Bucket=bucket_name) + object_key = "temp.txt" + aws_client.s3.put_object(Key=object_key, Bucket=bucket_name, Body="123") + + s3_config = {"addressing_style": "virtual"} if use_virtual_address else {} + client = _s3_client_pre_signed_client( + Config(signature_version=signature_version, s3=s3_config), + endpoint_url=s3_endpoint_path_style, + ) + + url = _generate_presigned_url(client, {"Bucket": bucket_name, "Key": object_key}, expires=1) + time.sleep(2) + response = requests.get(url) + assert response.status_code == 403 + exception = xmltodict.parse(response.content) + snapshot.match("expired", exception) + + @pytest.mark.parametrize( + "signature_version, use_virtual_address", + [ + ("s3", False), + ("s3", True), + ("s3v4", False), + ("s3v4", True), + ], + ) + @markers.aws.validated + def test_presigned_url_signature_authentication( + self, + s3_create_bucket, + signature_version, + use_virtual_address, + snapshot, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + bucket_name = f"presign-{short_uid()}" + + s3_endpoint_path_style = _endpoint_url() + s3_url = _bucket_url_vhost(bucket_name) if use_virtual_address else _bucket_url(bucket_name) + + s3_create_bucket(Bucket=bucket_name) + object_key = "temp.txt" + aws_client.s3.put_object(Key=object_key, Bucket=bucket_name, Body="123") + + s3_config = {"addressing_style": "virtual"} if use_virtual_address else {} + client = _s3_client_pre_signed_client( + Config(signature_version=signature_version, s3=s3_config), + endpoint_url=s3_endpoint_path_style, + ) + + expires = 20 + + # GET requests + simple_params = {"Bucket": bucket_name, "Key": object_key} + url = _generate_presigned_url(client, simple_params, expires) + response = requests.get(url) + assert response.status_code == 200 + assert response.content == b"123" + + params = { + "Bucket": bucket_name, + "Key": object_key, + "ResponseContentType": "text/plain", + "ResponseContentDisposition": "attachment; filename=test.txt", + } + + presigned = _generate_presigned_url(client, params, expires) + response = requests.get(presigned) + assert response.status_code == 200 + assert response.content == b"123" + + object_data = f"this should be found in when you download {object_key}." + + # invalid requests + response = requests.get( + _make_url_invalid(s3_url, object_key, presigned), + data=object_data, + headers={"Content-Type": "my-fake-content/type"}, + ) + assert response.status_code == 403 + exception = xmltodict.parse(response.content) + snapshot.match("invalid-get-1", exception) + + # put object valid + response = requests.put( + _generate_presigned_url(client, simple_params, expires, client_method="put_object"), + data=object_data, + ) + # body should be empty, and it will show us the exception if it's not + assert not response.content + assert response.status_code == 200 + + params = { + "Bucket": bucket_name, + "Key": object_key, + "ContentType": "text/plain", + } + presigned_put_url = _generate_presigned_url( + client, params, expires, client_method="put_object" + ) + response = requests.put( + presigned_put_url, + data=object_data, + headers={"Content-Type": "text/plain"}, + ) + assert not response.content + assert response.status_code == 200 + + # Invalid request + response = requests.put( + _make_url_invalid(s3_url, object_key, presigned_put_url), + data=object_data, + headers={"Content-Type": "my-fake-content/type"}, + ) + assert response.status_code == 403 + exception = xmltodict.parse(response.content) + snapshot.match("invalid-put-1", exception) + + # DELETE requests + presigned_delete_url = _generate_presigned_url( + client, simple_params, expires, client_method="delete_object" + ) + response = requests.delete(presigned_delete_url) + assert response.status_code == 204 + + @pytest.mark.parametrize( + "signature_version, use_virtual_address", + [ + ("s3", False), + ("s3", True), + ("s3v4", False), + ("s3v4", True), + ], + ) + @markers.aws.validated + def test_presigned_url_signature_authentication_multi_part( + self, + s3_create_bucket, + signature_version, + use_virtual_address, + patch_s3_skip_signature_validation_false, + aws_client, + ): + # it should test if the user is sending wrong signature + bucket_name = f"presign-{short_uid()}" + + s3_endpoint_path_style = _endpoint_url() + + s3_create_bucket(Bucket=bucket_name) + object_key = "temp.txt" + + s3_config = {"addressing_style": "virtual"} if use_virtual_address else {} + client = _s3_client_pre_signed_client( + Config(signature_version=signature_version, s3=s3_config), + endpoint_url=s3_endpoint_path_style, + ) + upload_id = client.create_multipart_upload( + Bucket=bucket_name, + Key=object_key, + )["UploadId"] + + data = to_bytes("hello this is a upload test") + upload_file_object = BytesIO(data) + + signed_url = _generate_presigned_url( + client, + { + "Bucket": bucket_name, + "Key": object_key, + "UploadId": upload_id, + "PartNumber": 1, + }, + expires=4, + client_method="upload_part", + ) + + response = requests.put(signed_url, data=upload_file_object) + assert response.status_code == 200 + multipart_upload_parts = [{"ETag": response.headers["ETag"], "PartNumber": 1}] + + response = client.complete_multipart_upload( + Bucket=bucket_name, + Key=object_key, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + simple_params = {"Bucket": bucket_name, "Key": object_key} + response = requests.get(_generate_presigned_url(client, simple_params, 4)) + assert response.status_code == 200 + assert response.content == data + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="Lambda not enabled in S3 image") + @markers.aws.validated + def test_presigned_url_v4_x_amz_in_qs( + self, + s3_bucket, + s3_create_bucket, + patch_s3_skip_signature_validation_false, + create_lambda_function, + lambda_su_role, + create_tmp_folder_lambda, + aws_client, + snapshot, + ): + # test that Boto does not hoist x-amz-storage-class in the query string while pre-signing + object_key = "temp.txt" + client = _s3_client_pre_signed_client( + Config(signature_version="s3v4"), + ) + url = client.generate_presigned_url( + "put_object", + Params={ + "Bucket": s3_bucket, + "Key": object_key, + "StorageClass": StorageClass.STANDARD, + "Metadata": {"foo": "bar-complicated-no-random"}, + }, + ) + assert StorageClass.STANDARD not in url + assert "bar-complicated-no-random" not in url + + handler_file = os.path.join( + os.path.dirname(__file__), "../lambda_/functions/lambda_s3_integration_presign.mjs" + ) + temp_folder = create_tmp_folder_lambda( + handler_file, + run_command="npm i @aws-sdk/util-endpoints @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/middleware-endpoint", + ) + + function_name = f"func-integration-{short_uid()}" + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(temp_folder, get_content=True), + runtime=Runtime.nodejs20_x, + handler="lambda_s3_integration_presign.handler", + role=lambda_su_role, + envvars={ + "ACCESS_KEY": s3_constants.DEFAULT_PRE_SIGNED_ACCESS_KEY_ID, + "SECRET_KEY": s3_constants.DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY, + }, + ) + s3_create_bucket(Bucket=function_name) + + response = aws_client.lambda_.invoke(FunctionName=function_name) + payload = json.load(response["Payload"]) + presigned_url = payload["body"].strip('"') + # assert that the Javascript SDK hoists it in the URL, unlike Boto + assert StorageClass.STANDARD in presigned_url + assert "bar-complicated-no-random" in presigned_url + # the JS SDK also adds a default checksum now even for pre-signed URLs + assert "x-amz-checksum-crc32=AAAAAA%3D%3D" in presigned_url + + # missing Content-MD5 + response = requests.put(presigned_url, verify=False, data=b"123456") + assert response.status_code == 403 + + # AWS needs the Content-MD5 header to validate the integrity of the file as set in the pre-signed URL + # but do not provide StorageClass in the headers, because it's not in SignedHeaders + response = requests.put( + presigned_url, + data=b"123456", + verify=False, + headers={"Content-MD5": "4QrcOUm6Wau+VuBX8g+IPg=="}, + ) + assert response.status_code == 200 + + # assert that the checksum-crc-32 value is still validated and important for the signature + bad_presigned_url = presigned_url.replace("crc32=AAAAAA%3D%3D", "crc32=BBBBBB%3D%3D") + response = requests.put( + bad_presigned_url, + data=b"123456", + verify=False, + headers={"Content-MD5": "4QrcOUm6Wau+VuBX8g+IPg=="}, + ) + assert response.status_code == 403 + + # verify that we properly saved the data + head_object = aws_client.s3.head_object( + Bucket=function_name, Key=object_key, ChecksumMode="ENABLED" + ) + snapshot.match("head-object", head_object) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="Lambda not enabled in S3 image") + @markers.aws.validated + def test_presigned_url_v4_signed_headers_in_qs( + self, + s3_bucket, + s3_create_bucket, + patch_s3_skip_signature_validation_false, + create_lambda_function, + lambda_su_role, + create_tmp_folder_lambda, + aws_client, + ): + # test that Boto does not hoist x-amz-server-side-encryption in the query string while pre-signing + # it means we would need to provide it in the request headers + object_key = "temp.txt" + client = _s3_client_pre_signed_client( + Config(signature_version="s3v4"), + ) + url = client.generate_presigned_url( + "put_object", + Params={"Bucket": s3_bucket, "Key": object_key, "ServerSideEncryption": "AES256"}, + ) + assert "=AES256" not in url + + handler_file = os.path.join( + os.path.dirname(__file__), "../lambda_/functions/lambda_s3_integration_sdk_v2.js" + ) + temp_folder = create_tmp_folder_lambda(handler_file) + + function_name = f"func-integration-{short_uid()}" + # we need the AWS SDK v2, and Node 16 still has it by default + # TODO since Node 16 is getting depricated we should consider ugrading to Node 20 + create_lambda_function( + func_name=function_name, + zip_file=testutil.create_zip_file(temp_folder, get_content=True), + runtime=Runtime.nodejs16_x, + handler="lambda_s3_integration_sdk_v2.handler", + role=lambda_su_role, + envvars={ + "ACCESS_KEY": s3_constants.DEFAULT_PRE_SIGNED_ACCESS_KEY_ID, + "SECRET_KEY": s3_constants.DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY, + }, + ) + s3_create_bucket(Bucket=function_name) + + response = aws_client.lambda_.invoke(FunctionName=function_name) + payload = json.load(response["Payload"]) + presigned_url = payload["body"].strip('"') + assert "=AES256" in presigned_url + + # AWS needs the Content-MD5 header to validate the integrity of the file as set in the pre-signed URL + response = requests.put(presigned_url, verify=False, data=b"123456") + assert response.status_code == 403 + + # assert that we don't need to give x-amz-server-side-encryption even though it's in SignedHeaders, + # because it's in the query string + response = requests.put( + presigned_url, + data=b"123456", + verify=False, + headers={"Content-MD5": "4QrcOUm6Wau+VuBX8g+IPg=="}, + ) + assert response.status_code == 200 + + # assert that even if we give x-amz-server-side-encryption, as long as it's the same value as the query string, + # it will work + response = requests.put( + presigned_url, + data=b"123456", + verify=False, + headers={ + "Content-MD5": "4QrcOUm6Wau+VuBX8g+IPg==", + "x-amz-server-side-encryption": "AES256", + }, + ) + assert response.status_code == 200 + + @markers.aws.validated + def test_pre_signed_url_forward_slash_bucket( + self, s3_bucket, patch_s3_skip_signature_validation_false, aws_client + ): + # PHP SDK accepts a bucket name with a forward slash when generating a pre-signed URL + # however the signature will not match afterward (in AWS or with LocalStack) + # the error message was misleading, because by default we remove the double slash from the path, and we did not + # calculate the same signature as AWS + object_key = "temp.txt" + aws_client.s3.put_object(Key=object_key, Bucket=s3_bucket, Body="123") + + s3_endpoint_path_style = _endpoint_url() + client = _s3_client_pre_signed_client( + Config(signature_version="s3v4", s3={}), + endpoint_url=s3_endpoint_path_style, + ) + + url = client.generate_presigned_url( + "put_object", + Params={"Bucket": s3_bucket, "Key": object_key}, + ) + parts = url.partition(s3_bucket) + # add URL encoded forward slash to the bucket name in the path + url_f_slash = parts[0] + "%2F" + parts[1] + parts[2] + + req = requests.get(url_f_slash) + request_content = xmltodict.parse(req.content) + assert "GET\n//test-bucket" in request_content["Error"]["CanonicalRequest"] + + @pytest.mark.parametrize( + "signature_version", + ["s3", "s3v4"], + ) + @markers.aws.validated + def test_s3_presign_url_encoding( + self, aws_client, s3_bucket, signature_version, patch_s3_skip_signature_validation_false + ): + object_key = "table1-partitioned/date=2023-06-28/test.csv" + aws_client.s3.put_object(Key=object_key, Bucket=s3_bucket, Body="123") + + s3_endpoint_path_style = _endpoint_url() + client = _s3_client_pre_signed_client( + Config(signature_version=signature_version, s3={}), + endpoint_url=s3_endpoint_path_style, + ) + + url = client.generate_presigned_url( + "get_object", + Params={"Bucket": s3_bucket, "Key": object_key}, + ) + + req = requests.get(url) + assert req.ok + assert req.content == b"123" + + @markers.aws.validated + def test_s3_ignored_special_headers( + self, + s3_bucket, + patch_s3_skip_signature_validation_false, + monkeypatch, + ): + # if the crt.auth is not available, not need to patch as it will use it by default + if find_spec("botocore.crt.auth"): + # the CRT client does not allow us to pass a protected header, it will trigger an exception, so we need + # to patch the Signer selection to the Python implementation which does not have this check + from botocore.auth import AUTH_TYPE_MAPS, S3SigV4QueryAuth + + monkeypatch.setitem(AUTH_TYPE_MAPS, "s3v4-query", S3SigV4QueryAuth) + + key = "my-key" + presigned_client = _s3_client_pre_signed_client( + Config(signature_version="s3v4", s3={"payload_signing_enabled": True}), + endpoint_url=_endpoint_url(), + ) + + def add_content_sha_header(request, **kwargs): + request.headers["x-amz-content-sha256"] = "UNSIGNED-PAYLOAD" + + presigned_client.meta.events.register( + "before-sign.s3.PutObject", + handler=add_content_sha_header, + ) + try: + url = presigned_client.generate_presigned_url( + "put_object", Params={"Bucket": s3_bucket, "Key": key} + ) + assert "x-amz-content-sha256" in url + # somehow, it's possible to add "x-amz-content-sha256" to signed headers, the AWS Go SDK does it + resp = requests.put( + url, + data="something", + verify=False, + headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}, + ) + assert resp.ok + + # if signed but not provided, AWS will raise an exception + resp = requests.put(url, data="something", verify=False) + assert resp.status_code == 403 + + finally: + presigned_client.meta.events.unregister( + "before-sign.s3.PutObject", + add_content_sha_header, + ) + + # recreate the request, without the signed header + url = presigned_client.generate_presigned_url( + "put_object", Params={"Bucket": s3_bucket, "Key": key} + ) + assert "x-amz-content-sha256" not in url + + # assert that if provided and not signed, AWS will ignore it even if it starts with `x-amz` + resp = requests.put( + url, + data="something", + verify=False, + headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}, + ) + assert resp.ok + + # assert that x-amz-user-agent is not ignored, it must be set in SignedHeaders + resp = requests.put( + url, data="something", verify=False, headers={"x-amz-user-agent": "test"} + ) + assert resp.status_code == 403 + + # X-Amz-Signature needs to be the last query string parameter: insert x-id before like the Go SDK + index = url.find("&X-Amz-Signature") + rewritten_url = url[:index] + "&x-id=PutObject" + url[index:] + # however, the x-id query string parameter is not ignored + resp = requests.put(rewritten_url, data="something", verify=False) + assert resp.status_code == 403 + + @markers.aws.validated + def test_pre_signed_url_if_none_match(self, s3_bucket, aws_client, aws_session): + # there currently is a bug in Boto3: https://github.com/boto/boto3/issues/4367 + # so we need to use botocore directly to allow testing of this, as other SDK like the Java SDK have the correct + # behavior + object_key = "temp.txt" + + s3_endpoint_path_style = _endpoint_url() + + # assert that the regular Boto3 client does not work, and does not sign the parameter as requested + client = _s3_client_pre_signed_client( + Config(signature_version="s3v4", s3={}), + endpoint_url=s3_endpoint_path_style, + ) + bad_url = client.generate_presigned_url( + "put_object", + Params={"Bucket": s3_bucket, "Key": object_key, "IfNoneMatch": "*"}, + ) + assert "if-none-match=%2a" not in bad_url.lower() + + req = botocore.awsrequest.AWSRequest( + method="PUT", + url=f"{s3_endpoint_path_style}/{s3_bucket}/{object_key}", + data={}, + params={ + "If-None-Match": "*", + }, + headers={}, + ) + + botocore.auth.S3SigV4QueryAuth(aws_session.get_credentials(), "s3", "us-east-1").add_auth( + req + ) + + assert "if-none-match=%2a" in req.url.lower() + + response = requests.put(req.url) + assert response.status_code == 200 + + response = requests.put(req.url) + # we are now failing because the object already exists + assert response.status_code == 412 + + @markers.aws.validated + def test_pre_signed_url_if_match(self, s3_bucket, aws_client, aws_session): + key = "test-precondition" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + + s3_endpoint_path_style = _endpoint_url() + # empty object ETag is provided + empty_object_etag = "d41d8cd98f00b204e9800998ecf8427e" + + # assert that the regular Boto3 client does not work, and does not sign the parameter as requested + client = _s3_client_pre_signed_client( + Config(signature_version="s3v4", s3={}), + endpoint_url=s3_endpoint_path_style, + ) + bad_url = client.generate_presigned_url( + "put_object", + Params={"Bucket": s3_bucket, "Key": key, "IfMatch": empty_object_etag}, + ) + assert "if-match=d41d8cd98f00b204e9800998ecf8427e" not in bad_url.lower() + + req = botocore.awsrequest.AWSRequest( + method="PUT", + url=f"{s3_endpoint_path_style}/{s3_bucket}/{key}", + data={}, + params={ + "If-Match": empty_object_etag, + }, + headers={}, + ) + + botocore.auth.S3SigV4QueryAuth(aws_session.get_credentials(), "s3", "us-east-1").add_auth( + req + ) + assert "if-match=d41d8cd98f00b204e9800998ecf8427e" in req.url.lower() + + response = requests.put(req.url) + assert response.status_code == 412 + + +class TestS3DeepArchive: + """ + Test to cover DEEP_ARCHIVE Storage Class functionality. + """ + + @markers.aws.validated + def test_storage_class_deep_archive(self, s3_bucket, tmpdir, aws_client): + key = "my-key" + + transfer_config = TransferConfig(multipart_threshold=5 * KB, multipart_chunksize=1 * KB) + + def upload_file(size_in_kb: int): + file = tmpdir / f"test-file-{short_uid()}.bin" + data = b"1" * (size_in_kb * KB) + file.write(data=data, mode="w") + aws_client.s3.upload_file( + Bucket=s3_bucket, + Key=key, + Filename=str(file.realpath()), + ExtraArgs={"StorageClass": "DEEP_ARCHIVE"}, + Config=transfer_config, + ) + + upload_file(1) + upload_file(9) + upload_file(15) + + for obj in aws_client.s3.list_objects_v2(Bucket=s3_bucket)["Contents"]: + assert obj["StorageClass"] == "DEEP_ARCHIVE" + + @markers.aws.validated + def test_s3_get_deep_archive_object_restore(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + bucket_name = f"bucket-{short_uid()}" + object_key = f"key-{short_uid()}" + + s3_create_bucket(Bucket=bucket_name) + + # put DEEP_ARCHIVE object + aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body="body data", + StorageClass="DEEP_ARCHIVE", + ) + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=bucket_name, Key=object_key) + snapshot.match("get-object-invalid-state", e.value.response) + + snapshot.match("get_object_invalid_state", e.value.response) + response = aws_client.s3.restore_object( + Bucket=bucket_name, + Key=object_key, + RestoreRequest={ + "Days": 30, + "GlacierJobParameters": { + "Tier": "Bulk", + }, + }, + ) + snapshot.match("restore_object", response) + + # AWS tier is currently configured to retrieve within 48 hours, so we cannot test the get-object here + response = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + if 'ongoing-request="false"' in response.get("Restore", ""): + # if the restoring happens in LocalStack (or was fast in AWS) we can retrieve the object + restore_bucket_name = f"bucket-{short_uid()}" + s3_create_bucket(Bucket=restore_bucket_name) + + aws_client.s3.copy_object( + CopySource={"Bucket": bucket_name, "Key": object_key}, + Bucket=restore_bucket_name, + Key=object_key, + ) + response = aws_client.s3.get_object(Bucket=restore_bucket_name, Key=object_key) + assert "etag" in response.get("ResponseMetadata").get("HTTPHeaders") + + +class TestS3StaticWebsiteHosting: + """ + Test to cover StaticWebsiteHosting functionality. + """ + + @markers.aws.validated + def test_s3_static_website_index(self, s3_bucket, aws_client, allow_bucket_acl): + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="index.html", + Body="index", + ContentType="text/html", + ACL="public-read", + ) + + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + }, + ) + + url = _website_bucket_url(s3_bucket) + + response = requests.get(url, verify=False) + assert response.status_code == 200 + assert response.text == "index" + + @markers.aws.validated + def test_s3_static_website_hosting(self, s3_bucket, aws_client, allow_bucket_acl): + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + index_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test/index.html", + Body="index", + ContentType="text/html", + ACL="public-read", + ) + error_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test/error.html", + Body="error", + ContentType="text/html", + ACL="public-read", + ) + actual_key_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="actual/key.html", + Body="key", + ContentType="text/html", + ACL="public-read", + ) + with_content_type_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key="with-content-type/key.js", + Body="some js", + ContentType="application/javascript; charset=utf-8", + ACL="public-read", + ) + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="to-be-redirected.html", + WebsiteRedirectLocation="/actual/key.html", + ACL="public-read", + ) + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "test/error.html"}, + }, + ) + website_url = _website_bucket_url(s3_bucket) + # actual key + url = f"{website_url}/actual/key.html" + response = requests.get(url, verify=False) + assert response.status_code == 200 + assert response.text == "key" + assert "content-type" in response.headers + assert response.headers["content-type"] == "text/html" + assert "etag" in response.headers + assert actual_key_obj["ETag"] in response.headers["etag"] + + # If-None-Match and Etag + response = requests.get( + url, headers={"If-None-Match": actual_key_obj["ETag"]}, verify=False + ) + assert response.status_code == 304 + + # key with specified content-type + url = f"{website_url}/with-content-type/key.js" + response = requests.get(url, verify=False) + assert response.status_code == 200 + assert response.text == "some js" + assert "content-type" in response.headers + assert response.headers["content-type"] == "application/javascript; charset=utf-8" + assert "etag" in response.headers + assert response.headers["etag"] == with_content_type_obj["ETag"] + + # index document + url = f"{website_url}/test" + response = requests.get(url, verify=False) + assert response.status_code == 200 + assert response.text == "index" + assert "content-type" in response.headers + assert "text/html" in response.headers["content-type"] + assert "etag" in response.headers + assert response.headers["etag"] == index_obj["ETag"] + + # root path test + url = f"{website_url}/" + response = requests.get(url, verify=False) + assert response.status_code == 404 + assert response.text == "error" + assert "content-type" in response.headers + assert "text/html" in response.headers["content-type"] + assert "etag" in response.headers + assert response.headers["etag"] == error_obj["ETag"] + + # error document + url = f"{website_url}/something" + response = requests.get(url, verify=False) + assert response.status_code == 404 + assert response.text == "error" + assert "content-type" in response.headers + assert "text/html" in response.headers["content-type"] + assert "etag" in response.headers + assert response.headers["etag"] == error_obj["ETag"] + + # redirect object + url = f"{website_url}/to-be-redirected.html" + response = requests.get(url, verify=False, allow_redirects=False) + assert response.status_code == 301 + assert "location" in response.headers + assert "actual/key.html" in response.headers["location"] + + response = requests.get(url, verify=False) + assert response.status_code == 200 + assert response.headers["etag"] == actual_key_obj["ETag"] + + @markers.aws.validated + def test_website_hosting_no_such_website( + self, s3_bucket, snapshot, aws_client, allow_bucket_acl + ): + snapshot.add_transformers_list(self._get_static_hosting_transformers(snapshot)) + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + + random_url = _website_bucket_url(f"non-existent-bucket-{short_uid()}") + response = requests.get(random_url, verify=False) + assert response.status_code == 404 + snapshot.match("no-such-bucket", response.text) + + website_url = _website_bucket_url(s3_bucket) + # actual key + response = requests.get(website_url, verify=False) + assert response.status_code == 404 + snapshot.match("no-such-website-config", response.text) + + url = f"{website_url}/actual/key.html" + response = requests.get(url) + assert response.status_code == 404 + snapshot.match("no-such-website-config-key", response.text) + + @markers.aws.validated + def test_website_hosting_http_methods(self, s3_bucket, snapshot, aws_client, allow_bucket_acl): + snapshot.add_transformers_list(self._get_static_hosting_transformers(snapshot)) + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + }, + ) + website_url = _website_bucket_url(s3_bucket) + req = requests.post(website_url, data="test") + assert req.status_code == 405 + error_response = req.text + snapshot.match("not-allowed-post", {"content": error_response}) + + req = requests.delete(website_url) + assert req.status_code == 405 + error_response = req.text + snapshot.match("not-allowed-delete", {"content": error_response}) + + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + }, + ) + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="error.html", + Body="error", + ContentType="text/html", + ACL="public-read", + ) + + # documentation states that error code in the range 4XX are redirected to the ErrorDocument + # 405 in not concerned by this + req = requests.post(website_url, data="test") + assert req.status_code == 405 + + @markers.aws.validated + def test_website_hosting_index_lookup(self, s3_bucket, snapshot, aws_client, allow_bucket_acl): + snapshot.add_transformers_list(self._get_static_hosting_transformers(snapshot)) + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + }, + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="index.html", + Body="index", + ContentType="text/html", + ACL="public-read", + ) + + website_url = _website_bucket_url(s3_bucket) + # actual key + response = requests.get(website_url) + assert response.status_code == 200 + assert response.text == "index" + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="directory/index.html", + Body="index", + ContentType="text/html", + ACL="public-read", + ) + + response = requests.get(f"{website_url}/directory", allow_redirects=False) + assert response.status_code == 302 + assert response.headers["Location"] == "/directory/" + + response = requests.get(f"{website_url}/directory/", verify=False) + assert response.status_code == 200 + assert response.text == "index" + + response = requests.get(f"{website_url}/directory-wrong", verify=False) + assert response.status_code == 404 + snapshot.match("404-no-trailing-slash", response.text) + + response = requests.get(f"{website_url}/directory-wrong/", verify=False) + assert response.status_code == 404 + snapshot.match("404-with-trailing-slash", response.text) + + @markers.aws.validated + def test_website_hosting_404(self, s3_bucket, snapshot, aws_client, allow_bucket_acl): + snapshot.add_transformers_list(self._get_static_hosting_transformers(snapshot)) + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + }, + ) + + website_url = _website_bucket_url(s3_bucket) + + response = requests.get(website_url) + assert response.status_code == 404 + snapshot.match("404-no-such-key", response.text) + + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + }, + ) + response = requests.get(website_url) + assert response.status_code == 404 + snapshot.match("404-no-such-key-nor-custom", response.text) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="error.html", + Body="error", + ContentType="text/html", + ACL="public-read", + ) + + response = requests.get(website_url) + assert response.status_code == 404 + assert response.text == "error" + + @markers.aws.validated + def test_object_website_redirect_location(self, s3_bucket, aws_client, allow_bucket_acl): + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + }, + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="index.html", + WebsiteRedirectLocation="/another/index.html", + ACL="public-read", + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="error.html", + Body="error_redirected", + WebsiteRedirectLocation="/another/error.html", + ACL="public-read", + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="another/error.html", + Body="error", + ACL="public-read", + ) + + website_url = _website_bucket_url(s3_bucket) + + response = requests.get(website_url) + # losing the status code because of the redirection in the error document + assert response.status_code == 200 + assert response.text == "error" + + @markers.aws.validated + def test_routing_rules_conditions(self, s3_bucket, aws_client, allow_bucket_acl): + # https://github.com/localstack/localstack/issues/6308 + + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + "RoutingRules": [ + { + "Condition": { + "KeyPrefixEquals": "both-prefixed/", + "HttpErrorCodeReturnedEquals": "404", + }, + "Redirect": {"ReplaceKeyWith": "redirected-both.html"}, + }, + { + "Condition": {"KeyPrefixEquals": "prefixed"}, + "Redirect": {"ReplaceKeyWith": "redirected.html"}, + }, + { + "Condition": {"HttpErrorCodeReturnedEquals": "404"}, + "Redirect": {"ReplaceKeyWith": "redirected.html"}, + }, + ], + }, + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="redirected.html", + Body="redirected", + ACL="public-read", + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="prefixed-key-test", + Body="prefixed", + ACL="public-read", + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="redirected-both.html", + Body="redirected-both", + ACL="public-read", + ) + + website_url = _website_bucket_url(s3_bucket) + + response = requests.get(f"{website_url}/non-existent-key", allow_redirects=False) + assert response.status_code == 301 + assert response.headers["Location"] == f"{website_url}/redirected.html" + + # redirects when the custom ErrorDocument is not found + response = requests.get(f"{website_url}/non-existent-key") + assert response.status_code == 200 + assert response.text == "redirected" + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="error.html", + Body="error", + ACL="public-read", + ) + + response = requests.get(f"{website_url}/non-existent-key") + assert response.status_code == 200 + assert response.text == "redirected" + + response = requests.get(f"{website_url}/prefixed-key-test") + assert response.status_code == 200 + assert response.text == "redirected" + + response = requests.get(f"{website_url}/both-prefixed/") + assert response.status_code == 200 + assert response.text == "redirected-both" + + @markers.aws.validated + def test_routing_rules_redirects(self, s3_bucket, aws_client, allow_bucket_acl): + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + "RoutingRules": [ + { + "Condition": { + "KeyPrefixEquals": "host/", + }, + "Redirect": {"HostName": "random-hostname"}, + }, + { + "Condition": { + "KeyPrefixEquals": "replace-prefix/", + }, + "Redirect": {"ReplaceKeyPrefixWith": "replaced-prefix/"}, + }, + { + "Condition": { + "KeyPrefixEquals": "protocol/", + }, + "Redirect": {"Protocol": "https"}, + }, + { + "Condition": { + "KeyPrefixEquals": "code/", + }, + "Redirect": {"HttpRedirectCode": "307"}, + }, + ], + }, + ) + + website_url = _website_bucket_url(s3_bucket) + + response = requests.get(f"{website_url}/host/key", allow_redirects=False) + assert response.status_code == 301 + assert response.headers["Location"] == "http://random-hostname/host/key" + + response = requests.get(f"{website_url}/replace-prefix/key", allow_redirects=False) + assert response.status_code == 301 + assert response.headers["Location"] == f"{website_url}/replaced-prefix/key" + + response = requests.get(f"{website_url}/protocol/key", allow_redirects=False) + assert response.status_code == 301 + assert not website_url.startswith("https") + assert response.headers["Location"].startswith("https") + + response = requests.get(f"{website_url}/code/key", allow_redirects=False) + assert response.status_code == 307 + + @markers.aws.validated + def test_routing_rules_empty_replace_prefix(self, s3_bucket, aws_client, allow_bucket_acl): + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="index.html", + Body="index", + ACL="public-read", + ) + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="test.html", + Body="test", + ACL="public-read", + ) + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="error.html", + Body="error", + ACL="public-read", + ) + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="mydocs/test.html", + Body="mydocs", + ACL="public-read", + ) + + # change configuration + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + "RoutingRules": [ + { + "Condition": {"KeyPrefixEquals": "docs/"}, + "Redirect": {"ReplaceKeyPrefixWith": ""}, + }, + { + "Condition": {"KeyPrefixEquals": "another/path/"}, + "Redirect": {"ReplaceKeyPrefixWith": ""}, + }, + ], + }, + ) + + website_url = _website_bucket_url(s3_bucket) + + # testing that routing rule redirect correctly (by removing the defined prefix) + response = requests.get(f"{website_url}/docs/test.html") + assert response.status_code == 200 + assert response.text == "test" + + response = requests.get(f"{website_url}/another/path/test.html") + assert response.status_code == 200 + assert response.text == "test" + + response = requests.get(f"{website_url}/docs/mydocs/test.html") + assert response.status_code == 200 + assert response.text == "mydocs" + + # no routing rule defined -> should result in error + response = requests.get(f"{website_url}/docs2/test.html") + assert response.status_code == 404 + assert response.text == "error" + + @markers.aws.validated + def test_routing_rules_order(self, s3_bucket, aws_client, allow_bucket_acl): + aws_client.s3.put_bucket_acl(Bucket=s3_bucket, ACL="public-read") + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + "ErrorDocument": {"Key": "error.html"}, + "RoutingRules": [ + { + "Condition": { + "KeyPrefixEquals": "prefix", + }, + "Redirect": {"ReplaceKeyWith": "redirected.html"}, + }, + { + "Condition": { + "KeyPrefixEquals": "index", + }, + "Redirect": {"ReplaceKeyWith": "redirected.html"}, + }, + ], + }, + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="index.html", + Body="index", + ACL="public-read", + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="redirected.html", + Body="redirected", + ACL="public-read", + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="website-redirected.html", + Body="website-redirected", + ACL="public-read", + ) + + aws_client.s3.put_object( + Bucket=s3_bucket, + Key="prefixed-key-test", + Body="prefixed", + ACL="public-read", + WebsiteRedirectLocation="/website-redirected.html", + ) + + website_url = _website_bucket_url(s3_bucket) + # testing that routing rules have precedence over individual object redirection + response = requests.get(f"{website_url}/prefixed-key-test") + assert response.status_code == 200 + assert response.text == "redirected" + + # assert that prefix rules don't apply for root path (internally redirected to index.html) + response = requests.get(website_url) + assert response.status_code == 200 + assert response.text == "index" + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + # todo: serializer issue with empty node, very tricky one... + paths=["$.invalid-website-conf-1.Error.ArgumentValue"] + ) + def test_validate_website_configuration(self, s3_bucket, snapshot, aws_client): + website_configurations = [ + # can't have slash in the suffix + { + "IndexDocument": {"Suffix": "/index.html"}, + }, + # empty suffix value + { + "IndexDocument": {"Suffix": ""}, + }, + # if RedirectAllRequestsTo is set, cannot have other fields + { + "RedirectAllRequestsTo": {"HostName": "test"}, + "IndexDocument": {"Suffix": "/index.html"}, + }, + # does not have an IndexDocument field + { + "ErrorDocument": {"Key": "/index.html"}, + }, + # wrong protocol, must be http|https + { + "IndexDocument": {"Suffix": "index.html"}, + "RoutingRules": [{"Redirect": {"Protocol": "protocol"}}], + }, + # has both ReplaceKeyPrefixWith and ReplaceKeyWith + { + "IndexDocument": {"Suffix": "index.html"}, + "RoutingRules": [ + { + "Redirect": { + "ReplaceKeyPrefixWith": "prefix", + "ReplaceKeyWith": "key-name", + } + } + ], + }, + # empty Condition field in Routing Rule + { + "IndexDocument": {"Suffix": "index.html"}, + "RoutingRules": [ + { + "Redirect": { + "ReplaceKeyPrefixWith": "prefix", + }, + "Condition": {}, + } + ], + }, + # empty routing rules + { + "IndexDocument": {"Suffix": "index.html"}, + "RoutingRules": [], + }, + ] + + for index, invalid_configuration in enumerate(website_configurations): + # not using pytest.raises, to have better debugging value in case of not raising exception + # because of the loop, we don't know which configuration has not raised the exception + try: + aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration=invalid_configuration, + ) + raise AssertionError(f"{invalid_configuration} should have raised an exception") + except ClientError as e: + snapshot.match(f"invalid-website-conf-{index}", e.response) + + @markers.aws.validated + def test_crud_website_configuration(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_website(Bucket=s3_bucket) + snapshot.match("get-no-such-website-config", e.value.response) + + resp = aws_client.s3.delete_bucket_website(Bucket=s3_bucket) + snapshot.match("del-no-such-website-config", resp) + + response = aws_client.s3.put_bucket_website( + Bucket=s3_bucket, + WebsiteConfiguration={"IndexDocument": {"Suffix": "index.html"}}, + ) + snapshot.match("put-website-config", response) + + response = aws_client.s3.get_bucket_website(Bucket=s3_bucket) + snapshot.match("get-website-config", response) + + aws_client.s3.delete_bucket_website(Bucket=s3_bucket) + with pytest.raises(ClientError): + aws_client.s3.get_bucket_website(Bucket=s3_bucket) + + @markers.aws.validated + def test_website_hosting_redirect_all(self, s3_create_bucket, aws_client): + bucket_name_redirected = f"bucket-{short_uid()}" + bucket_name = f"bucket-{short_uid()}" + + s3_create_bucket(Bucket=bucket_name_redirected) + aws_client.s3.delete_bucket_ownership_controls(Bucket=bucket_name_redirected) + aws_client.s3.delete_public_access_block(Bucket=bucket_name_redirected) + aws_client.s3.put_bucket_acl(Bucket=bucket_name_redirected, ACL="public-read") + + bucket_website_url = _website_bucket_url(bucket_name) + bucket_website_host = urlparse(bucket_website_url).netloc + + aws_client.s3.put_bucket_website( + Bucket=bucket_name_redirected, + WebsiteConfiguration={ + "RedirectAllRequestsTo": {"HostName": bucket_website_host}, + }, + ) + + s3_create_bucket(Bucket=bucket_name) + aws_client.s3.delete_bucket_ownership_controls(Bucket=bucket_name) + aws_client.s3.delete_public_access_block(Bucket=bucket_name) + aws_client.s3.put_bucket_acl(Bucket=bucket_name, ACL="public-read") + + aws_client.s3.put_bucket_website( + Bucket=bucket_name, + WebsiteConfiguration={ + "IndexDocument": {"Suffix": "index.html"}, + }, + ) + + aws_client.s3.put_object( + Bucket=bucket_name, + Key="index.html", + Body="index", + ContentType="text/html", + ACL="public-read", + ) + + redirected_bucket_website = _website_bucket_url(bucket_name_redirected) + + response_no_redirect = requests.get(redirected_bucket_website, allow_redirects=False) + assert response_no_redirect.status_code == 301 + assert response_no_redirect.content == b"" + + response_redirected = requests.get(redirected_bucket_website) + assert response_redirected.status_code == 200 + assert response_redirected.content == b"index" + + response = requests.get(bucket_website_url) + assert response.status_code == 200 + assert response.content == b"index" + + assert response.content == response_redirected.content + + response_redirected = requests.get(f"{redirected_bucket_website}/random-key") + assert response_redirected.status_code == 404 + + @staticmethod + def _get_static_hosting_transformers(snapshot): + return [ + snapshot.transform.regex( + "RequestId: (.*?)", replacement="RequestId: " + ), + snapshot.transform.regex("HostId: (.*?)", replacement="HostId: "), + snapshot.transform.regex( + "BucketName: (.*?)", replacement="BucketName: " + ), + ] + + +class TestS3Routing: + @markers.aws.only_localstack + @pytest.mark.parametrize( + "domain, use_virtual_address", + [ + ("s3.amazonaws.com", False), + ("s3.amazonaws.com", True), + ("s3.us-west-2.amazonaws.com", False), + ("s3.us-west-2.amazonaws.com", True), + ], + ) + def test_access_favicon_via_aws_endpoints( + self, s3_bucket, domain, use_virtual_address, aws_client, region_name + ): + """Assert that /favicon.ico objects can be created/accessed/deleted using amazonaws host headers""" + + s3_key = "favicon.ico" + content = b"test 123" + aws_client.s3.put_object(Bucket=s3_bucket, Key=s3_key, Body=content) + aws_client.s3.head_object(Bucket=s3_bucket, Key=s3_key) + + path = s3_key if use_virtual_address else f"{s3_bucket}/{s3_key}" + url = f"{config.internal_service_url()}/{path}" + headers = mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + ) + headers["host"] = f"{s3_bucket}.{domain}" if use_virtual_address else domain + + # get object via *.amazonaws.com host header + result = requests.get(url, headers=headers) + assert result.ok + assert result.content == content + + # delete object via *.amazonaws.com host header + result = requests.delete(url, headers=headers) + assert result.ok + + # assert that object has been deleted + with pytest.raises(ClientError) as exc: + aws_client.s3.head_object(Bucket=s3_bucket, Key=s3_key) + assert exc.value.response["Error"]["Message"] == "Not Found" + + +class TestS3BucketLifecycle: + @markers.aws.validated + def test_delete_bucket_lifecycle_configuration(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-exc-1", e.value.response) + + resp = aws_client.s3.delete_bucket_lifecycle(Bucket=s3_bucket) + snapshot.match("delete-bucket-lifecycle-no-bucket", resp) + + lfc = { + "Rules": [ + { + "Expiration": {"Days": 7}, + "ID": "wholebucket", + "Filter": {"Prefix": ""}, + "Status": "Enabled", + } + ] + } + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = retry( + aws_client.s3.get_bucket_lifecycle_configuration, retries=3, sleep=1, Bucket=s3_bucket + ) + snapshot.match("get-bucket-lifecycle-conf", result) + aws_client.s3.delete_bucket_lifecycle(Bucket=s3_bucket) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-exc-2", e.value.response) + + @markers.aws.validated + def test_delete_lifecycle_configuration_on_bucket_deletion( + self, s3_create_bucket, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + bucket_name = f"test-bucket-{short_uid()}" # keep the same name for both bucket + s3_create_bucket(Bucket=bucket_name) + lfc = { + "Rules": [ + { + "Expiration": {"Days": 7}, + "ID": "wholebucket", + "Filter": {"Prefix": ""}, + "Status": "Enabled", + } + ] + } + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=bucket_name, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=bucket_name) + snapshot.match("get-bucket-lifecycle-conf", result) + aws_client.s3.delete_bucket(Bucket=bucket_name) + s3_create_bucket(Bucket=bucket_name) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_lifecycle_configuration(Bucket=bucket_name) + snapshot.match("get-bucket-lifecycle-exc", e.value.response) + + @markers.aws.validated + def test_put_bucket_lifecycle_conf_exc(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + snapshot.transform.key_value("ArgumentValue", value_replacement="datetime") + ) + lfc = {"Rules": []} + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "Expiration": {"Days": 7}, + "Status": "Enabled", + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + snapshot.match("missing-id", e.value.response) + + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "Expiration": {"Days": 7}, + "ID": "wholebucket", + "Status": "Enabled", + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + snapshot.match("missing-filter", e.value.response) + + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "Expiration": {"Days": 7}, + "Filter": {}, + "ID": "wholebucket", + "Status": "Enabled", + "NoncurrentVersionExpiration": {}, + # No NewerNoncurrentVersions or NoncurrentDays + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + snapshot.match("missing-noncurrent-version-expiration-data", e.value.response) + + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "Expiration": {"Days": 7}, + "Filter": { + "And": { + "Prefix": "test", + }, + "Prefix": "", + }, + "ID": "wholebucket", + "Status": "Enabled", + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + snapshot.match("wrong-filter-and-plus-prefix", e.value.response) + + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "Expiration": {"Days": 7}, + "Filter": { + "ObjectSizeGreaterThan": 500, + "Prefix": "", + }, + "ID": "wholebucket", + "Status": "Enabled", + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + snapshot.match("wrong-filter-and-and-object-size", e.value.response) + + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "Expiration": { + "Date": datetime.datetime(year=2023, month=1, day=1, hour=2, minute=2) + }, + "ID": "wrong-data", + "Filter": {}, + "Status": "Enabled", + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + snapshot.match("wrong-data-no-midnight", e.value.response) + + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "ID": "duplicate-tag-keys", + "Filter": { + "And": { + "Tags": [ + { + "Key": "testlifecycle", + "Value": "positive", + }, + { + "Key": "testlifecycle", + "Value": "positive-two", + }, + ], + }, + }, + "Status": "Enabled", + "Expiration": {"Days": 1}, + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + + snapshot.match("duplicate-tag-keys", e.value.response) + + with pytest.raises(ClientError) as e: + lfc["Rules"] = [ + { + "ID": "expired-delete-marker-and-days", + "Filter": {}, + "Status": "Enabled", + "Expiration": { + "Days": 1, + "ExpiredObjectDeleteMarker": True, + }, + } + ] + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + + snapshot.match("expired-delete-marker-and-days", e.value.response) + + @markers.aws.validated + def test_bucket_lifecycle_configuration_date(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + ] + ) + rule_id = "rule_number_one" + + lfc = { + "Rules": [ + { + "Expiration": { + "Date": datetime.datetime(year=2023, month=1, day=1, tzinfo=ZoneInfo("GMT")) + }, + "ID": rule_id, + "Filter": {}, + "Status": "Enabled", + } + ] + } + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + @markers.aws.validated + def test_bucket_lifecycle_configuration_object_expiry(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + snapshot.transform.key_value( + "Expiration", reference_replacement=False, value_replacement="" + ), + ] + ) + rule_id = "rule_number_one" + + lfc = { + "Rules": [ + { + "Expiration": {"Days": 7}, + "ID": rule_id, + "Filter": {"Prefix": ""}, + "Status": "Enabled", + } + ] + } + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + key = "test-object-expiry" + aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key) + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key) + snapshot.match("head-object-expiry", response) + response = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-object-expiry", response) + + expiration = response["Expiration"] + + parsed_exp_date, parsed_exp_rule = parse_expiration_header(expiration) + assert parsed_exp_rule == rule_id + last_modified = response["LastModified"] + + # use a bit of margin for the 7 days expiration, as it can depend on the time of day, but at least we validate + assert 6 <= (parsed_exp_date - last_modified).days <= 8 + + @markers.aws.validated + def test_bucket_lifecycle_configuration_object_expiry_versioned( + self, s3_bucket, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.key_value("VersionId"), priority=-1) + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + snapshot.transform.key_value( + "Expiration", reference_replacement=False, value_replacement="" + ), + ] + ) + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + rule_id = "rule2" + current_exp_days = 3 + non_current_exp_days = 1 + lfc = { + "Rules": [ + { + "ID": rule_id, + "Status": "Enabled", + "Filter": {}, + "Expiration": {"Days": current_exp_days}, + "NoncurrentVersionExpiration": {"NoncurrentDays": non_current_exp_days}, + } + ] + } + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + key = "test-object-expiry" + put_object_1 = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key) + version_id_1 = put_object_1["VersionId"] + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key) + snapshot.match("head-object-expiry", response) + + parsed_exp_date, parsed_exp_rule = parse_expiration_header(response["Expiration"]) + assert parsed_exp_rule == rule_id + # use a bit of margin for the days expiration, as it can depend on the time of day, but at least we validate + assert ( + current_exp_days - 1 + <= (parsed_exp_date - response["LastModified"]).days + <= current_exp_days + 1 + ) + + key = "test-object-expiry" + put_object_2 = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key) + version_id_2 = put_object_2["VersionId"] + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key, VersionId=version_id_1) + snapshot.match("head-object-expiry-noncurrent", response) + + # This is not in the documentation anymore, but it still seems to be the case + # See https://stackoverflow.com/questions/33096697/object-expiration-of-non-current-version + # Note that for versioning-enabled buckets, this header applies only to current versions; Amazon S3 does not + # provide a header to infer when a noncurrent version will be eligible for permanent deletion. + assert "Expiration" not in response + + # if you specify the VersionId, AWS won't return the Expiration header, even if that's the current version + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key, VersionId=version_id_2) + snapshot.match("head-object-expiry-current-with-version-id", response) + assert "Expiration" not in response + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key) + snapshot.match("head-object-expiry-current-without-version-id", response) + # assert that the previous version id which didn't return the Expiration header is the same object + assert response["VersionId"] == version_id_2 + + parsed_exp_date, parsed_exp_rule = parse_expiration_header(response["Expiration"]) + assert parsed_exp_rule == rule_id + # use a bit of margin for the days expiration, as it can depend on the time of day, but at least we validate + assert ( + current_exp_days - 1 + <= (parsed_exp_date - response["LastModified"]).days + <= current_exp_days + 1 + ) + + @markers.aws.validated + def test_object_expiry_after_bucket_lifecycle_configuration( + self, s3_bucket, snapshot, aws_client + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + snapshot.transform.key_value( + "Expiration", reference_replacement=False, value_replacement="" + ), + ] + ) + key = "test-object-expiry" + put_object = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key) + snapshot.match("put-object-before", put_object) + + rule_id = "rule3" + current_exp_days = 7 + lfc = { + "Rules": [ + { + "Expiration": {"Days": current_exp_days}, + "ID": rule_id, + "Filter": {}, + "Status": "Enabled", + } + ] + } + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key) + snapshot.match("head-object-expiry-before", response) + + put_object = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key) + snapshot.match("put-object-after", put_object) + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key) + snapshot.match("head-object-expiry-after", response) + + @markers.aws.validated + def test_bucket_lifecycle_multiple_rules(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + snapshot.transform.key_value( + "Expiration", reference_replacement=False, value_replacement="" + ), + ] + ) + + rule_id_1 = "rule_one" + rule_id_2 = "rule_two" + rule_id_3 = "rule_three" + current_exp_days = 7 + lfc = { + "Rules": [ + { + "ID": rule_id_1, + "Filter": {"Prefix": "testobject"}, + "Status": "Enabled", + "Expiration": {"Days": current_exp_days}, + }, + { + "ID": rule_id_2, + "Filter": {"Prefix": "test"}, + "Status": "Enabled", + "Expiration": {"Days": current_exp_days}, + }, + { + "ID": rule_id_3, + "Filter": {"Prefix": "t"}, + "Status": "Enabled", + "Expiration": {"Days": current_exp_days}, + }, + ] + } + + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + key_match_1 = "testobject-expiry" + put_object = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key_match_1) + snapshot.match("put-object-match-both-rules", put_object) + + _, parsed_exp_rule = parse_expiration_header(put_object["Expiration"]) + assert parsed_exp_rule == rule_id_1 + + key_match_2 = "test-one-rule" + put_object_2 = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key_match_2) + snapshot.match("put-object-match-rule-2", put_object_2) + + _, parsed_exp_rule = parse_expiration_header(put_object_2["Expiration"]) + assert parsed_exp_rule == rule_id_2 + + key_no_match = "no-rules" + put_object_3 = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key_no_match) + snapshot.match("put-object-no-match", put_object_3) + assert "Expiration" not in put_object_3 + + @markers.aws.validated + def test_bucket_lifecycle_object_size_rules(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + snapshot.transform.key_value( + "Expiration", reference_replacement=False, value_replacement="" + ), + ] + ) + + rule_id_1 = "rule_one" + rule_id_2 = "rule_two" + current_exp_days = 7 + lfc = { + "Rules": [ + { + "ID": rule_id_1, + "Filter": { + "ObjectSizeGreaterThan": 20, + }, + "Status": "Enabled", + "Expiration": {"Days": current_exp_days}, + }, + { + "ID": rule_id_2, + "Filter": { + "ObjectSizeLessThan": 10, + }, + "Status": "Enabled", + "Expiration": {"Days": current_exp_days}, + }, + ] + } + + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + key_match_1 = "testobject-expiry" + put_object = aws_client.s3.put_object(Body=b"a" * 22, Bucket=s3_bucket, Key=key_match_1) + snapshot.match("put-object-match-rule-1", put_object) + + _, parsed_exp_rule = parse_expiration_header(put_object["Expiration"]) + assert parsed_exp_rule == rule_id_1 + + key_match_2 = "test-one-rule" + put_object_2 = aws_client.s3.put_object(Body=b"a" * 5, Bucket=s3_bucket, Key=key_match_2) + snapshot.match("put-object-match-rule-2", put_object_2) + + _, parsed_exp_rule = parse_expiration_header(put_object_2["Expiration"]) + assert parsed_exp_rule == rule_id_2 + + key_no_match = "no-rules" + put_object_3 = aws_client.s3.put_object(Body=b"a" * 15, Bucket=s3_bucket, Key=key_no_match) + snapshot.match("put-object-no-match", put_object_3) + assert "Expiration" not in put_object_3 + + @markers.aws.validated + def test_bucket_lifecycle_tag_rules(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + snapshot.transform.key_value( + "Expiration", reference_replacement=False, value_replacement="" + ), + ] + ) + + rule_id_1 = "rule_one" + rule_id_2 = "rule_two" + current_exp_days = 7 + lfc = { + "Rules": [ + { + "ID": rule_id_1, + "Filter": { + "Tag": { + "Key": "testlifecycle", + "Value": "positive", + }, + }, + "Status": "Enabled", + "Expiration": {"Days": current_exp_days}, + }, + { + "ID": rule_id_2, + "Filter": { + "And": { + "Tags": [ + { + "Key": "testlifecycle", + "Value": "positive", + }, + { + "Key": "testlifecycletwo", + "Value": "positive-two", + }, + ], + }, + }, + "Status": "Enabled", + "Expiration": {"Days": current_exp_days}, + }, + ] + } + + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + key_match_1 = "testobject-expiry" + tag_set_match = "testlifecycle=positive&testlifecycletwo=positivetwo" + put_object = aws_client.s3.put_object( + Body=b"test", Bucket=s3_bucket, Key=key_match_1, Tagging=tag_set_match + ) + snapshot.match("put-object-match-both-rules", put_object) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_match_1) + snapshot.match("get-object-match-both-rules", get_object) + + _, parsed_exp_rule = parse_expiration_header(put_object["Expiration"]) + assert parsed_exp_rule == rule_id_1 + + key_match_2 = "test-one-rule" + tag_set_match_one = "testlifecycle=positive" + put_object_2 = aws_client.s3.put_object( + Body=b"test", Bucket=s3_bucket, Key=key_match_2, Tagging=tag_set_match_one + ) + snapshot.match("put-object-match-rule-1", put_object_2) + + get_object_2 = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_match_2) + snapshot.match("get-object-match-rule-1", get_object_2) + + _, parsed_exp_rule = parse_expiration_header(put_object_2["Expiration"]) + assert parsed_exp_rule == rule_id_1 + + key_no_match = "no-rules" + tag_set_no_match = "testlifecycle2=positivetwo" + put_object_3 = aws_client.s3.put_object( + Body=b"test", Bucket=s3_bucket, Key=key_no_match, Tagging=tag_set_no_match + ) + snapshot.match("put-object-no-match", put_object_3) + assert "Expiration" not in put_object_3 + + get_object_3 = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_no_match) + snapshot.match("get-object-no-match", get_object_3) + + key_no_tags = "no-tags" + put_object_4 = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key_no_tags) + snapshot.match("put-object-no-tags", put_object_4) + assert "Expiration" not in put_object_4 + + get_object_4 = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_no_tags) + snapshot.match("get-object-no-tags", get_object_4) + + @markers.aws.validated + def test_lifecycle_expired_object_delete_marker(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("BucketName"), + snapshot.transform.key_value( + "Expiration", reference_replacement=False, value_replacement="" + ), + ] + ) + rule_id = "rule-marker" + lfc = { + "Rules": [ + { + "Expiration": {"ExpiredObjectDeleteMarker": True}, + "ID": rule_id, + "Filter": {}, + "Status": "Enabled", + } + ] + } + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, LifecycleConfiguration=lfc + ) + result = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-lifecycle-conf", result) + + key = "test-expired-object-delete-marker" + put_object = aws_client.s3.put_object(Body=b"test", Bucket=s3_bucket, Key=key) + snapshot.match("put-object", put_object) + + response = aws_client.s3.head_object(Bucket=s3_bucket, Key=key) + snapshot.match("head-object", response) + + @markers.aws.validated + def test_s3_transition_default_minimum_object_size(self, aws_client, s3_bucket, snapshot): + lfc = { + "Rules": [ + { + "Expiration": {"Days": 7}, + "ID": "wholebucket", + "Filter": {"Prefix": ""}, + "Status": "Enabled", + } + ] + } + put_lifecycle_varies = aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, + LifecycleConfiguration=lfc, + TransitionDefaultMinimumObjectSize=TransitionDefaultMinimumObjectSize.varies_by_storage_class, + ) + snapshot.match("varies-by-storage", put_lifecycle_varies) + + get_lifecycle_varies = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-varies-by-storage", get_lifecycle_varies) + + put_lifecycle_default = aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, + LifecycleConfiguration=lfc, + ) + snapshot.match("default", put_lifecycle_default) + + get_default = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-default", get_default) + + put_lifecycle_all_storage = aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, + LifecycleConfiguration=lfc, + TransitionDefaultMinimumObjectSize=TransitionDefaultMinimumObjectSize.all_storage_classes_128K, + ) + snapshot.match("all-storage", put_lifecycle_all_storage) + + get_all_storage = aws_client.s3.get_bucket_lifecycle_configuration(Bucket=s3_bucket) + snapshot.match("get-all-storage", get_all_storage) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_lifecycle_configuration( + Bucket=s3_bucket, + LifecycleConfiguration=lfc, + TransitionDefaultMinimumObjectSize="value", + ) + snapshot.match("bad-value", e.value.response) + + +class TestS3ObjectLockRetention: + @markers.aws.validated + def test_s3_object_retention_exc(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + s3_bucket_locked = s3_create_bucket(ObjectLockEnabledForBucket=True) + + current_year = datetime.datetime.now().year + future_datetime = datetime.datetime(current_year + 5, 1, 1) + + # non-existing bucket + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=f"non-existing-bucket-{long_uid()}", + Key="fake-key", + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": future_datetime}, + ) + snapshot.match("put-object-retention-no-bucket", e.value.response) + + # non-existing key + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_locked, + Key="non-existing-key", + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": future_datetime}, + ) + snapshot.match("put-object-retention-no-key", e.value.response) + + object_key = "test-key" + put_obj_locked = aws_client.s3.put_object( + Bucket=s3_bucket_locked, Key=object_key, Body="test" + ) + version_id = put_obj_locked["VersionId"] + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_retention( + Bucket=s3_bucket_locked, Key=object_key, VersionId=version_id + ) + snapshot.match("get-object-retention-never-set", e.value.response) + + # missing fields + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_locked, + Key=object_key, + Retention={"Mode": "GOVERNANCE"}, + BypassGovernanceRetention=True, + ) + snapshot.match("put-object-missing-retention-fields", e.value.response) + + # set a retention + aws_client.s3.put_object_retention( + Bucket=s3_bucket_locked, + Key=object_key, + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": future_datetime}, + ) + + # update a retention to be lower than the existing one without bypass + earlier_datetime = future_datetime - datetime.timedelta(days=365) + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_locked, + Key=object_key, + VersionId=version_id, + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": earlier_datetime}, + ) + snapshot.match("update-retention-no-bypass", e.value.response) + + # update a retention with date in the past + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_locked, + Key=object_key, + VersionId=version_id, + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": datetime.datetime(2020, 1, 1)}, + ) + snapshot.match("update-retention-past-date", e.value.response) + + # update a retention with a bad ObjectLock Mode + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_locked, + Key=object_key, + VersionId=version_id, + Retention={"Mode": "BAD-VALUE", "RetainUntilDate": future_datetime}, + ) + snapshot.match("update-retention-bad-value", e.value.response) + + s3_bucket_basic = s3_create_bucket(ObjectLockEnabledForBucket=False) # same as default + aws_client.s3.put_object(Bucket=s3_bucket_basic, Key=object_key, Body="test") + # put object retention in a object in bucket without lock configured + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_basic, + Key=object_key, + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": future_datetime}, + ) + snapshot.match("put-object-retention-regular-bucket", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_retention( + Bucket=s3_bucket_basic, + Key=object_key, + ) + snapshot.match("get-object-retention-regular-bucket", e.value.response) + + @markers.aws.validated + def test_s3_object_retention(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + object_key = "test-retention-locked-object" + + s3_bucket_lock = s3_create_bucket(ObjectLockEnabledForBucket=True) + put_obj_1 = aws_client.s3.put_object(Bucket=s3_bucket_lock, Key=object_key, Body="test") + snapshot.match("put-obj-locked-1", put_obj_1) + + version_id = put_obj_1["VersionId"] + + response = aws_client.s3.put_object_retention( + Bucket=s3_bucket_lock, + Key=object_key, + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": datetime.datetime(2030, 1, 1)}, + ) + snapshot.match("put-object-retention-on-key-1", response) + + response = aws_client.s3.get_object_retention(Bucket=s3_bucket_lock, Key=object_key) + snapshot.match("get-object-retention-on-key-1", response) + + head_object_locked = aws_client.s3.head_object(Bucket=s3_bucket_lock, Key=object_key) + snapshot.match("head-object-locked", head_object_locked) + + # delete object with retention lock without bypass + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=s3_bucket_lock, Key=object_key, VersionId=version_id) + snapshot.match("delete-obj-locked", e.value.response) + + # delete object with retention lock with bypass + response = aws_client.s3.delete_object( + Bucket=s3_bucket_lock, + Key=object_key, + VersionId=version_id, + BypassGovernanceRetention=True, + ) + snapshot.match("delete-obj-locked-bypass", response) + + put_obj_2 = aws_client.s3.put_object( + Bucket=s3_bucket_lock, + Key=object_key, + Body="test", + ObjectLockMode="GOVERNANCE", + ObjectLockRetainUntilDate=datetime.datetime(2030, 1, 1), + ) + snapshot.match("put-obj-locked-2", put_obj_2) + version_id = put_obj_2["VersionId"] + + # update object retention with 5 seconds retention, no bypass + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_lock, + Key=object_key, + Retention={ + "Mode": "GOVERNANCE", + "RetainUntilDate": datetime.datetime.now(tz=datetime.UTC) + + datetime.timedelta(seconds=5), + }, + ) + snapshot.match("update-retention-locked-object", e.value.response) + + # update with empty retention + empty_retention = aws_client.s3.put_object_retention( + Bucket=s3_bucket_lock, + Key=object_key, + Retention={}, + BypassGovernanceRetention=True, + ) + snapshot.match("put-object-empty-retention", empty_retention) + + update_retention = aws_client.s3.put_object_retention( + Bucket=s3_bucket_lock, + Key=object_key, + Retention={ + "Mode": "GOVERNANCE", + "RetainUntilDate": datetime.datetime.now(tz=datetime.UTC) + + datetime.timedelta(seconds=5), + }, + ) + snapshot.match("update-retention-object", update_retention) + + # delete object with retention lock without bypass before 5 seconds + with pytest.raises(ClientError): + aws_client.s3.delete_object(Bucket=s3_bucket_lock, Key=object_key, VersionId=version_id) + + # delete object with lock without bypass after 5 seconds + sleep = 10 if is_aws_cloud() else 6 + time.sleep(sleep) + + aws_client.s3.delete_object( + Bucket=s3_bucket_lock, + Key=object_key, + VersionId=version_id, + ) + + @markers.aws.validated + def test_s3_copy_object_retention_lock(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + dest_key = "dest-key" + # creating a bucket with ObjectLockEnabledForBucket enables versioning by default, as it's not allowed otherwise + # see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + + put_locked_objected = aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body='{"key": "value"}', + ObjectLockMode="GOVERNANCE", # allows the root user to delete it + ObjectLockRetainUntilDate=datetime.datetime.now() + datetime.timedelta(minutes=10), + ) + snapshot.match("put-source-object", put_locked_objected) + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-source-object", head_object) + + resp = aws_client.s3.copy_object( + Bucket=bucket_name, + CopySource=f"{bucket_name}/{object_key}", + Key=dest_key, + ) + snapshot.match("copy-lock", resp) + # the destination key did not keep the lock nor lock until from the source key + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=dest_key) + snapshot.match("head-dest-key", head_object) + + @markers.aws.validated + def test_bucket_config_default_retention(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + object_key = "default-object" + put_lock_config = aws_client.s3.put_object_lock_configuration( + Bucket=bucket_name, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Mode": "GOVERNANCE", + "Days": 1, + } + }, + }, + ) + snapshot.match("put-lock-config", put_lock_config) + + put_locked_object_default = aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body="test-default-lock", + ) + snapshot.match("put-object-default", put_locked_object_default) + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object-default", head_object) + + # add one day to LastModified to validate the Retain date is precise or rounding (it is precise, exactly 1 day + # after the LastModified (created date) + last_modified_and_one_day = head_object["LastModified"] + datetime.timedelta(days=1) + delta_2_min = datetime.timedelta(minutes=2) # to add a bit of margin + assert ( + last_modified_and_one_day - delta_2_min + <= head_object["ObjectLockRetainUntilDate"] + <= last_modified_and_one_day + delta_2_min + ) + + put_locked_object = aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body="test-put-object-lock", + ObjectLockMode="GOVERNANCE", + ObjectLockRetainUntilDate=datetime.datetime.now() + datetime.timedelta(minutes=10), + ) + snapshot.match("put-object-with-lock", put_locked_object) + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object-with-lock", head_object) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key + "2", + Body="test-put-object-lock", + ObjectLockMode="GOVERNANCE", + ) + snapshot.match("put-object-with-lock-no-date", e.value.response) + + @markers.aws.validated + def test_object_lock_delete_markers(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + object_key = "default-object" + + put_locked_object = aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body="test-put-object-lock", + ObjectLockMode="GOVERNANCE", + ObjectLockRetainUntilDate=datetime.datetime.now() + datetime.timedelta(minutes=10), + ) + snapshot.match("put-object-with-lock", put_locked_object) + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object-with-lock", head_object) + + put_delete_marker = aws_client.s3.delete_object(Bucket=bucket_name, Key=object_key) + snapshot.match("put-delete-marker", put_delete_marker) + delete_marker_version = put_delete_marker["VersionId"] + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=bucket_name, + Key=object_key, + VersionId=delete_marker_version, + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": datetime.datetime(2030, 1, 1)}, + ) + snapshot.match("put-object-retention-delete-marker", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_retention( + Bucket=bucket_name, Key=object_key, VersionId=delete_marker_version + ) + snapshot.match("get-object-retention-delete-marker", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.head_object( + Bucket=bucket_name, Key=object_key, VersionId=delete_marker_version + ) + snapshot.match("head-object-locked-delete-marker", e.value.response) + + @markers.aws.validated + def test_object_lock_extend_duration(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + object_key = "default-object" + + put_locked_object = aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body="test-put-object-lock", + ObjectLockMode="GOVERNANCE", + ObjectLockRetainUntilDate=datetime.datetime.now() + datetime.timedelta(minutes=10), + ) + snapshot.match("put-object-with-lock", put_locked_object) + version_id = put_locked_object["VersionId"] + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object-with-lock", head_object) + + # not putting BypassGovernanceRetention=True on purpose, to see if you can extend the duration by default + put_locked_object_extend = aws_client.s3.put_object_retention( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + Retention={ + "Mode": "GOVERNANCE", + "RetainUntilDate": datetime.datetime.now() + datetime.timedelta(minutes=20), + }, + ) + snapshot.match("put-object-retention-extend", put_locked_object_extend) + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object-with-lock-extended", head_object) + + # assert that reducing the duration again won't work + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + Retention={ + "Mode": "GOVERNANCE", + "RetainUntilDate": datetime.datetime.now() + datetime.timedelta(minutes=10), + }, + ) + snapshot.match("put-object-retention-reduce", e.value.response) + + @markers.aws.validated + def test_s3_object_retention_compliance_mode(self, aws_client, s3_create_bucket, snapshot): + # BEWARE of this test! + # using `COMPLIANCE` will make the object virtually *impossible* to delete, so don't set a long duration + # for the `RetainUntilDate` + # only way to delete the object and indirectly the bucket will be to delete the AWS Account + # see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html#object-lock-overview + # > The only way to delete an object under the compliance mode before its retention date expires is to delete + # > the associated AWS account. + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + object_key = "test-retention-locked-object" + + s3_bucket_lock = s3_create_bucket(ObjectLockEnabledForBucket=True) + put_obj_1 = aws_client.s3.put_object(Bucket=s3_bucket_lock, Key=object_key, Body="test") + snapshot.match("put-obj-locked-1", put_obj_1) + + version_id = put_obj_1["VersionId"] + + short_future = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta(seconds=5) + + update_retention = aws_client.s3.put_object_retention( + Bucket=s3_bucket_lock, + Key=object_key, + Retention={ + "Mode": "COMPLIANCE", + "RetainUntilDate": short_future, + }, + ) + snapshot.match("add-compliance-retention", update_retention) + + # delete object with retention lock without bypass before 5 seconds + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=s3_bucket_lock, Key=object_key, VersionId=version_id) + snapshot.match("delete-locked-1", e.value.response) + + put_delete_marker = aws_client.s3.delete_object(Bucket=s3_bucket_lock, Key=object_key) + snapshot.match("put-delete-marker", put_delete_marker) + + # delete object with retention lock with bypass before 5 seconds + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object( + Bucket=s3_bucket_lock, + Key=object_key, + VersionId=version_id, + BypassGovernanceRetention=True, + ) + snapshot.match("delete-locked-2", e.value.response) + + # update a retention to be lower than the existing one without bypass + earlier_datetime = short_future - datetime.timedelta(seconds=1) + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_lock, + Key=object_key, + VersionId=version_id, + Retention={"Mode": "COMPLIANCE", "RetainUntilDate": earlier_datetime}, + ) + snapshot.match("update-retention-shortened", e.value.response) + + # update a retention to be less restrictive than COMPLIANCE + earlier_datetime = short_future + datetime.timedelta(seconds=1) + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_retention( + Bucket=s3_bucket_lock, + Key=object_key, + VersionId=version_id, + Retention={"Mode": "GOVERNANCE", "RetainUntilDate": earlier_datetime}, + ) + snapshot.match("update-retention-less-restrictive", e.value.response) + + # delete object with lock without bypass after 5 seconds + sleep = 10 if is_aws_cloud() else 6 + time.sleep(sleep) + + response = aws_client.s3.delete_object( + Bucket=s3_bucket_lock, + Key=object_key, + VersionId=version_id, + ) + snapshot.match("delete-obj-after-lock-expiration", response) + + @markers.aws.validated + def test_s3_object_lock_mode_validation(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + object_key = "test-retention-validation" + + s3_bucket_lock = s3_create_bucket(ObjectLockEnabledForBucket=True) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket_lock, + Key=object_key, + Body="test", + ObjectLockMode="BAD-VALUE", + ) + snapshot.match("put-obj-locked-error-no-retain-date", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket_lock, + Key=object_key, + Body="test", + ObjectLockRetainUntilDate=datetime.datetime.now() + datetime.timedelta(minutes=10), + ) + snapshot.match("put-obj-locked-error-no-mode", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket_lock, + Key=object_key, + Body="test", + ObjectLockMode="BAD-VALUE", + ObjectLockRetainUntilDate=datetime.datetime.now() + datetime.timedelta(minutes=10), + ) + snapshot.match("put-obj-locked-bad-value", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.create_multipart_upload( + Bucket=s3_bucket_lock, + Key=object_key, + ObjectLockMode="BAD-VALUE", + ObjectLockRetainUntilDate=datetime.datetime.now() + datetime.timedelta(minutes=10), + ) + snapshot.match("create-mpu-locked-bad-value", e.value.response) + + +class TestS3ObjectLockLegalHold: + @markers.aws.validated + def test_put_get_object_legal_hold(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + object_key = "locked-object" + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + + put_obj = aws_client.s3.put_object(Bucket=bucket_name, Key=object_key, Body="test") + snapshot.match("put-obj", put_obj) + version_id = put_obj["VersionId"] + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_legal_hold( + Bucket=bucket_name, Key=object_key, VersionId=version_id + ) + snapshot.match("get-legal-hold-unset", e.value.response) + + put_legal_hold = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + LegalHold={"Status": "ON"}, + ) + snapshot.match("put-object-legal-hold", put_legal_hold) + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object-with-legal-hold", head_object) + + get_legal_hold = aws_client.s3.get_object_legal_hold( + Bucket=bucket_name, Key=object_key, VersionId=version_id + ) + snapshot.match("get-legal-hold-set", get_legal_hold) + + # disable the LegalHold so that the fixture can clean up + put_legal_hold = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + LegalHold={"Status": "OFF"}, + ) + snapshot.match("put-object-legal-hold-off", put_legal_hold) + + @markers.aws.validated + def test_put_object_with_legal_hold(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + object_key = "locked-object" + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + + put_obj = aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body="test", + ObjectLockLegalHoldStatus="ON", + ) + snapshot.match("put-obj", put_obj) + version_id = put_obj["VersionId"] + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object-with-legal-hold", head_object) + + # disable the LegalHold so that the fixture can clean up + put_legal_hold = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + LegalHold={"Status": "OFF"}, + ) + snapshot.match("put-object-legal-hold-off", put_legal_hold) + + @markers.aws.validated + def test_put_object_legal_hold_exc(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + s3_bucket_locked = s3_create_bucket(ObjectLockEnabledForBucket=True) + # non-existing bucket + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_legal_hold( + Bucket=f"non-existing-bucket-{long_uid()}", + Key="fake-key", + LegalHold={"Status": "ON"}, + ) + snapshot.match("put-object-legal-hold-no-bucket", e.value.response) + + # non-existing key + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_legal_hold( + Bucket=s3_bucket_locked, + Key="non-existing-key", + LegalHold={"Status": "ON"}, + ) + snapshot.match("put-object-legal-hold-no-key", e.value.response) + + object_key = "test-legal-hold" + s3_bucket_basic = s3_create_bucket(ObjectLockEnabledForBucket=False) # same as default + aws_client.s3.put_object(Bucket=s3_bucket_basic, Key=object_key, Body="test") + # put object retention in a object in bucket without lock configured + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_legal_hold( + Bucket=s3_bucket_basic, + Key=object_key, + LegalHold={"Status": "ON"}, + ) + snapshot.match("put-object-retention-regular-bucket", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_legal_hold( + Bucket=s3_bucket_basic, + Key=object_key, + ) + snapshot.match("put-object-retention-empty", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_legal_hold( + Bucket=s3_bucket_basic, + Key=object_key, + ) + snapshot.match("get-object-retention-regular-bucket", e.value.response) + + @markers.aws.validated + def test_delete_locked_object(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + object_key = "test-delete-locked" + put_obj = aws_client.s3.put_object(Bucket=bucket_name, Key=object_key, Body="test") + snapshot.match("put-obj", put_obj) + version_id = put_obj["VersionId"] + + put_legal_hold = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + LegalHold={"Status": "ON"}, + ) + snapshot.match("put-object-legal-hold", put_legal_hold) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=bucket_name, Key=object_key, VersionId=version_id) + snapshot.match("delete-object-locked", e.value.response) + + delete_objects = aws_client.s3.delete_objects( + Bucket=bucket_name, Delete={"Objects": [{"Key": object_key, "VersionId": version_id}]} + ) + snapshot.match("delete-objects-locked", delete_objects) + + # disable the LegalHold so that the fixture can clean up + put_legal_hold = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + LegalHold={"Status": "OFF"}, + ) + snapshot.match("put-object-legal-hold-off", put_legal_hold) + + @markers.aws.validated + def test_s3_legal_hold_lock_versioned(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "locked-object" + # creating a bucket with ObjectLockEnabledForBucket enables versioning by default, as it's not allowed otherwise + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + + # create an object, get version1 + resp = aws_client.s3.put_object(Bucket=bucket_name, Key=object_key, Body="test") + snapshot.match("put-object", resp) + version_id = resp["VersionId"] + + # put a legal hold on the object with version1 + resp = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + LegalHold={"Status": "ON"}, + ) + snapshot.match("put-object-legal-hold-ver1", resp) + + head_object = aws_client.s3.head_object( + Bucket=bucket_name, Key=object_key, VersionId=version_id + ) + snapshot.match("head-object-ver1", head_object) + + resp = aws_client.s3.put_object(Bucket=bucket_name, Key=object_key, Body="test") + snapshot.match("put-object-2", resp) + version_id_2 = resp["VersionId"] + + # put a legal hold on the object with version2 + resp = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id_2, + LegalHold={"Status": "ON"}, + ) + snapshot.match("put-object-legal-hold-ver2", resp) + + head_object = aws_client.s3.head_object( + Bucket=bucket_name, Key=object_key, VersionId=version_id_2 + ) + snapshot.match("head-object-ver2", head_object) + + # remove the legal hold from the version1 + resp = aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + LegalHold={"Status": "OFF"}, + ) + snapshot.match("remove-object-legal-hold-ver1", resp) + + head_object = aws_client.s3.head_object( + Bucket=bucket_name, Key=object_key, VersionId=version_id + ) + snapshot.match("head-object-ver1-no-lock", head_object) + + # now delete the object with version1, the legal hold should be off + resp = aws_client.s3.delete_object( + Bucket=bucket_name, + Key=object_key, + VersionId=version_id, + ) + snapshot.match("delete-object-ver1", resp) + + # disabled the Legal Hold so that the fixture can clean up + aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, + Key=object_key, + LegalHold={"Status": "OFF"}, + VersionId=version_id_2, + ) + + @markers.aws.validated + def test_s3_copy_object_legal_hold(self, s3_create_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + object_key = "source-object" + dest_key = "dest-key" + # creating a bucket with ObjectLockEnabledForBucket enables versioning by default + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + + resp = aws_client.s3.put_object( + Bucket=bucket_name, + Key=object_key, + Body='{"key": "value"}', + ObjectLockLegalHoldStatus="ON", + ) + snapshot.match("put-object", resp) + + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=object_key) + snapshot.match("head-object", head_object) + + resp = aws_client.s3.copy_object( + Bucket=bucket_name, + CopySource=f"{bucket_name}/{object_key}", + Key=dest_key, + ) + snapshot.match("copy-legal-hold", resp) + # the destination key did not keep the legal hold from the source key + head_object = aws_client.s3.head_object(Bucket=bucket_name, Key=dest_key) + snapshot.match("head-dest-key", head_object) + + # disable the Legal Hold so that the fixture can clean up + for key in (object_key, dest_key): + with contextlib.suppress(ClientError): + aws_client.s3.put_object_legal_hold( + Bucket=bucket_name, Key=key, LegalHold={"Status": "OFF"} + ) + + +class TestS3BucketLogging: + @markers.aws.validated + def test_put_bucket_logging(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer( + [ + snapshot.transform.key_value("TargetBucket"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value( + "ID", value_replacement="owner-id", reference_replacement=False + ), + ] + ) + + bucket_name = s3_create_bucket() + target_bucket = s3_create_bucket() + + resp = aws_client.s3.get_bucket_logging(Bucket=bucket_name) + snapshot.match("get-bucket-logging-default", resp) + + bucket_logging_status = { + "LoggingEnabled": { + "TargetBucket": target_bucket, + "TargetPrefix": "log", + }, + } + resp = aws_client.s3.get_bucket_acl(Bucket=target_bucket) + snapshot.match("get-bucket-default-acl", resp) + + # this might have been failing in the past, as the target bucket does not give access to LogDelivery to + # write/read_acp. however, AWS accepts it, because you can also set it with Permissions + resp = aws_client.s3.put_bucket_logging( + Bucket=bucket_name, BucketLoggingStatus=bucket_logging_status + ) + snapshot.match("put-bucket-logging", resp) + + resp = aws_client.s3.get_bucket_logging(Bucket=bucket_name) + snapshot.match("get-bucket-logging", resp) + + # delete BucketLogging + resp = aws_client.s3.put_bucket_logging(Bucket=bucket_name, BucketLoggingStatus={}) + snapshot.match("put-bucket-logging-delete", resp) + + @markers.aws.validated + def test_put_bucket_logging_accept_wrong_grants(self, aws_client, s3_create_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("TargetBucket")) + + bucket_name = s3_create_bucket() + + target_bucket = s3_create_bucket() + # We need to delete the ObjectOwnership from the bucket, because you otherwise can't set TargetGrants on it + # TODO: have the same default as AWS and have ObjectOwnership set + aws_client.s3.delete_bucket_ownership_controls(Bucket=target_bucket) + + bucket_logging_status = { + "LoggingEnabled": { + "TargetBucket": target_bucket, + "TargetPrefix": "log", + "TargetGrants": [ + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "WRITE", + }, + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "READ_ACP", + }, + ], + }, + } + + # from the documentation, only WRITE | READ | FULL_CONTROL are allowed, but AWS let READ_ACP pass + resp = aws_client.s3.put_bucket_logging( + Bucket=bucket_name, BucketLoggingStatus=bucket_logging_status + ) + snapshot.match("put-bucket-logging", resp) + + resp = aws_client.s3.get_bucket_logging(Bucket=bucket_name) + snapshot.match("get-bucket-logging", resp) + + @markers.aws.validated + def test_put_bucket_logging_wrong_target( + self, + aws_client_factory, + s3_create_bucket_with_client, + snapshot, + ): + region_us_west_2 = "us-west-2" + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("TargetBucket"), + snapshot.transform.regex(AWS_REGION_US_EAST_1, ""), + snapshot.transform.regex(region_us_west_2, ""), + ] + ) + + bucket_name_us_east_1 = f"bucket-{short_uid()}" + target_bucket_us_west_2 = f"bucket-{short_uid()}" + + client_us_east_1 = aws_client_factory(region_name=AWS_REGION_US_EAST_1).s3 + s3_create_bucket_with_client( + client_us_east_1, + Bucket=bucket_name_us_east_1, + ) + s3_create_bucket_with_client( + client_us_east_1, + Bucket=target_bucket_us_west_2, + CreateBucketConfiguration={"LocationConstraint": region_us_west_2}, + ) + + with pytest.raises(ClientError) as e: + bucket_logging_status = { + "LoggingEnabled": { + "TargetBucket": target_bucket_us_west_2, + "TargetPrefix": "log", + }, + } + client_us_east_1.put_bucket_logging( + Bucket=bucket_name_us_east_1, BucketLoggingStatus=bucket_logging_status + ) + snapshot.match("put-bucket-logging-different-regions", e.value.response) + + nonexistent_target_bucket = f"target-bucket-{long_uid()}" + with pytest.raises(ClientError) as e: + bucket_logging_status = { + "LoggingEnabled": { + "TargetBucket": nonexistent_target_bucket, + "TargetPrefix": "log", + }, + } + client_us_east_1.put_bucket_logging( + Bucket=bucket_name_us_east_1, BucketLoggingStatus=bucket_logging_status + ) + snapshot.match("put-bucket-logging-non-existent-bucket", e.value.response) + assert e.value.response["Error"]["TargetBucket"] == nonexistent_target_bucket + + @markers.aws.validated + def test_put_bucket_logging_cross_locations( + self, + aws_client, + aws_client_factory, + s3_create_bucket, + s3_create_bucket_with_client, + snapshot, + ): + # The aim of the test is to check the behavior of the CrossLocationLoggingProhibitions + # exception for us-east-1 and regions other than us-east-1. + region_us_east_2 = "us-east-2" + region_us_west_2 = "us-west-2" + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("TargetBucket"), + snapshot.transform.regex(AWS_REGION_US_EAST_1, ""), + snapshot.transform.regex(region_us_east_2, ""), + snapshot.transform.regex(region_us_west_2, ""), + ] + ) + + bucket_name_us_east_1 = f"bucket-{short_uid()}" + client_us_east_1 = aws_client_factory(region_name=AWS_REGION_US_EAST_1).s3 + s3_create_bucket_with_client(s3_client=client_us_east_1, Bucket=bucket_name_us_east_1) + + bucket_name_us_east_2 = f"bucket-{short_uid()}" + s3_create_bucket_with_client( + s3_client=client_us_east_1, + Bucket=bucket_name_us_east_2, + CreateBucketConfiguration={"LocationConstraint": region_us_east_2}, + ) + + target_bucket_us_west_2 = f"bucket-{short_uid()}" + s3_create_bucket_with_client( + s3_client=client_us_east_1, + Bucket=target_bucket_us_west_2, + CreateBucketConfiguration={"LocationConstraint": region_us_west_2}, + ) + + with pytest.raises(ClientError) as e: + bucket_logging_status = { + "LoggingEnabled": { + "TargetBucket": target_bucket_us_west_2, + "TargetPrefix": "log", + }, + } + client_us_east_1.put_bucket_logging( + Bucket=bucket_name_us_east_1, BucketLoggingStatus=bucket_logging_status + ) + snapshot.match("put-bucket-logging-cross-us-east-1", e.value.response) + + with pytest.raises(ClientError) as e: + bucket_logging_status = { + "LoggingEnabled": { + "TargetBucket": target_bucket_us_west_2, + "TargetPrefix": "log", + }, + } + client_us_east_1.put_bucket_logging( + Bucket=bucket_name_us_east_2, BucketLoggingStatus=bucket_logging_status + ) + snapshot.match("put-bucket-logging-different-regions", e.value.response) + + +# TODO: maybe we can fake the IAM role as it's not needed in LocalStack +@pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="IAM not enabled in S3 image") +class TestS3BucketReplication: + @markers.aws.validated + def test_replication_config_without_filter( + self, s3_create_bucket, create_iam_role_with_policy, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..ReplicationConfiguration.Role", "role", reference_replacement=False + ) + ) + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..Destination.Bucket", "dest-bucket", reference_replacement=False + ) + ) + bucket_src = f"src-{short_uid()}" + bucket_dst = f"dst-{short_uid()}" + role_name = f"replication_role_{short_uid()}" + policy_name = f"replication_policy_{short_uid()}" + + role_arn = create_iam_role_with_policy( + RoleName=role_name, + PolicyName=policy_name, + RoleDefinition=S3_ASSUME_ROLE_POLICY, + PolicyDefinition=S3_POLICY, + ) + s3_create_bucket(Bucket=bucket_src) + # enable versioning on src + aws_client.s3.put_bucket_versioning( + Bucket=bucket_src, VersioningConfiguration={"Status": "Enabled"} + ) + + s3_create_bucket(Bucket=bucket_dst) + + replication_config = { + "Role": role_arn, + "Rules": [ + { + "ID": "rtc", + "Priority": 0, + "Filter": {}, + "Status": "Disabled", + "Destination": { + "Bucket": "arn:aws:s3:::does-not-exist", + "StorageClass": "STANDARD", + "ReplicationTime": {"Status": "Enabled", "Time": {"Minutes": 15}}, + "Metrics": {"Status": "Enabled", "EventThreshold": {"Minutes": 15}}, + }, + "DeleteMarkerReplication": {"Status": "Disabled"}, + } + ], + } + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_replication( + ReplicationConfiguration=replication_config, Bucket=bucket_src + ) + snapshot.match("expected_error_dest_does_not_exist", e.value.response) + + # set correct destination + replication_config["Rules"][0]["Destination"]["Bucket"] = f"arn:aws:s3:::{bucket_dst}" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_replication( + ReplicationConfiguration=replication_config, Bucket=bucket_src + ) + snapshot.match("expected_error_dest_versioning_disabled", e.value.response) + + # enable versioning on destination bucket + aws_client.s3.put_bucket_versioning( + Bucket=bucket_dst, VersioningConfiguration={"Status": "Enabled"} + ) + + response = aws_client.s3.put_bucket_replication( + ReplicationConfiguration=replication_config, Bucket=bucket_src + ) + snapshot.match("put-bucket-replication", response) + + response = aws_client.s3.get_bucket_replication(Bucket=bucket_src) + snapshot.match("get-bucket-replication", response) + + @markers.aws.validated + def test_replication_config( + self, + s3_create_bucket, + s3_create_bucket_with_client, + create_iam_role_with_policy, + snapshot, + aws_client, + aws_client_factory, + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..ReplicationConfiguration.Role", "role", reference_replacement=False + ) + ) + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..Destination.Bucket", "dest-bucket", reference_replacement=False + ) + ) + snapshot.add_transformer( + snapshot.transform.key_value("ID", "id", reference_replacement=False) + ) + bucket_src = f"src-{short_uid()}" + bucket_dst = f"dst-{short_uid()}" + role_name = f"replication_role_{short_uid()}" + policy_name = f"replication_policy_{short_uid()}" + + role_arn = create_iam_role_with_policy( + RoleName=role_name, + PolicyName=policy_name, + RoleDefinition=S3_ASSUME_ROLE_POLICY, + PolicyDefinition=S3_POLICY, + ) + s3_create_bucket(Bucket=bucket_src) + + s3_client_secondary = aws_client_factory(region_name="us-west-2").s3 + s3_create_bucket_with_client( + s3_client=s3_client_secondary, + Bucket=bucket_dst, + CreateBucketConfiguration={"LocationConstraint": "us-west-2"}, + ) + aws_client.s3.put_bucket_versioning( + Bucket=bucket_dst, VersioningConfiguration={"Status": "Enabled"} + ) + + # expect error if versioning is disabled on src-bucket + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_replication(Bucket=bucket_src) + snapshot.match("expected_error_no_replication_set", e.value.response) + + replication_config = { + "Role": role_arn, + "Rules": [ + { + "Status": "Enabled", + "Priority": 1, + "DeleteMarkerReplication": {"Status": "Disabled"}, + "Filter": {"Prefix": "Tax"}, + "Destination": {"Bucket": f"arn:aws:s3:::{bucket_dst}"}, + } + ], + } + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_replication( + ReplicationConfiguration=replication_config, Bucket=bucket_src + ) + snapshot.match("expected_error_versioning_not_enabled", e.value.response) + + # enable versioning + aws_client.s3.put_bucket_versioning( + Bucket=bucket_src, VersioningConfiguration={"Status": "Enabled"} + ) + + response = aws_client.s3.put_bucket_replication( + ReplicationConfiguration=replication_config, Bucket=bucket_src + ) + snapshot.match("put-bucket-replication", response) + + response = aws_client.s3.get_bucket_replication(Bucket=bucket_src) + snapshot.match("get-bucket-replication", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_replication( + Bucket=bucket_src, + ReplicationConfiguration={ + "Role": role_arn, + "Rules": [], + }, + ) + snapshot.match("put-empty-bucket-replication-rules", e.value.response) + + delete_replication = aws_client.s3.delete_bucket_replication(Bucket=bucket_src) + snapshot.match("delete-bucket-replication", delete_replication) + + delete_replication = aws_client.s3.delete_bucket_replication(Bucket=bucket_src) + snapshot.match("delete-bucket-replication-idempotent", delete_replication) + + +class TestS3PresignedPost: + DEFAULT_FILE_VALUE = "abcdef" + + def post_generated_presigned_post_with_default_file( + self, generated_request: dict + ) -> requests.Response: + return requests.post( + generated_request["url"], + data=generated_request["fields"], + files={"file": self.DEFAULT_FILE_VALUE}, + verify=False, + allow_redirects=False, + ) + + @markers.aws.validated + def test_post_object_with_files(self, s3_bucket, aws_client): + object_key = "test-presigned-post-key" + + body = ( + b"0" * 70_000 + ) # make sure the payload size is large to force chunking in our internal implementation + + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[{"bucket": s3_bucket}], + ) + # put object + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": body}, + verify=False, + ) + assert response.status_code == 204 + + # get object and compare results + downloaded_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + assert downloaded_object["Body"].read() == body + + @markers.aws.validated + def test_post_request_expires( + self, s3_bucket, snapshot, aws_client, presigned_snapshot_transformers + ): + # presign a post with a short expiry time + object_key = "test-presigned-post-key" + + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, Key=object_key, ExpiresIn=2 + ) + + # sleep so it expires + time.sleep(3) + + # attempt to use the presigned request + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "file content"}, + verify=False, + ) + + exception = xmltodict.parse(response.content) + exception["StatusCode"] = response.status_code + snapshot.match("exception", exception) + assert response.status_code in [400, 403] + + @markers.aws.validated + @pytest.mark.parametrize( + "signature_version", + ["s3", "s3v4"], + ) + def test_post_request_malformed_policy( + self, + s3_bucket, + snapshot, + signature_version, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + object_key = "test-presigned-malformed-policy" + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + + presigned_request = presigned_client.generate_presigned_post( + Bucket=s3_bucket, Key=object_key, ExpiresIn=60 + ) + + # modify the base64 string to be wrong + original_policy = presigned_request["fields"]["policy"] + presigned_request["fields"]["policy"] = original_policy[:-2] + + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "file content"}, + verify=False, + ) + # the policy has been modified, so the signature does not correspond + exception = xmltodict.parse(response.content) + exception["StatusCode"] = response.status_code + snapshot.match("exception-policy", exception) + # assert fields that snapshot cannot match + signature_field = "signature" if signature_version == "s3" else "x-amz-signature" + assert ( + exception["Error"]["SignatureProvided"] == presigned_request["fields"][signature_field] + ) + assert exception["Error"]["StringToSign"] == presigned_request["fields"]["policy"] + + @markers.aws.validated + @pytest.mark.parametrize( + "signature_version", + ["s3", "s3v4"], + ) + def test_post_request_missing_signature( + self, + s3_bucket, + snapshot, + signature_version, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + object_key = "test-presigned-missing-signature" + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + + presigned_request = presigned_client.generate_presigned_post( + Bucket=s3_bucket, Key=object_key, ExpiresIn=60 + ) + + # remove the signature field + signature_field = "signature" if signature_version == "s3" else "x-amz-signature" + presigned_request["fields"].pop(signature_field) + + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "file content"}, + verify=False, + ) + + # AWS seems to detected what kind of signature is missing from the policy fields + exception = xmltodict.parse(response.content) + exception["StatusCode"] = response.status_code + snapshot.match("exception-missing-signature", exception) + + @markers.aws.validated + @pytest.mark.parametrize( + "signature_version", + ["s3", "s3v4"], + ) + def test_post_request_missing_fields( + self, + s3_bucket, + snapshot, + signature_version, + patch_s3_skip_signature_validation_false, + aws_client, + presigned_snapshot_transformers, + ): + object_key = "test-presigned-missing-fields" + + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + + presigned_request = presigned_client.generate_presigned_post( + Bucket=s3_bucket, Key=object_key, ExpiresIn=60 + ) + + # remove some signature related fields + if signature_version == "s3": + presigned_request["fields"].pop("AWSAccessKeyId") + else: + presigned_request["fields"].pop("x-amz-algorithm") + presigned_request["fields"].pop("x-amz-credential") + + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "file content"}, + verify=False, + ) + + exception = xmltodict.parse(response.content) + exception["StatusCode"] = response.status_code + snapshot.match("exception-missing-fields", exception) + + # pop everything else to see what exception comes back + presigned_request["fields"] = { + k: v for k, v in presigned_request["fields"].items() if k in ("key", "policy") + } + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "file content"}, + verify=False, + ) + + exception = xmltodict.parse(response.content) + exception["StatusCode"] = response.status_code + snapshot.match("exception-no-sig-related-fields", exception) + + @markers.aws.validated + def test_s3_presigned_post_success_action_status_201_response( + self, s3_bucket, aws_client, region_name + ): + # a security policy is required if the bucket is not publicly writable + # see https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html#RESTObjectPOST-requests-form-fields + body = "something body" + # get presigned URL + object_key = "key-${filename}" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_status": "201"}, + Conditions=[{"bucket": s3_bucket}, ["eq", "$success_action_status", "201"]], + ExpiresIn=60, + ) + files = {"file": ("my-file", body)} + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files=files, + verify=False, + ) + + assert response.status_code == 201 + json_response = xmltodict.parse(response.content) + assert "PostResponse" in json_response + json_response = json_response["PostResponse"] + + etag = '"43281e21fce675ac3bcb3524b38ca4ed"' + assert response.headers["ETag"] == etag + + location = f"{_bucket_url_vhost(s3_bucket, region_name)}/key-my-file" + if region_name != "us-east-1": + # the format is a bit different for non-default regions, we don't return the region as part of the + # `Location` to avoid SSL issue, but we still want to test it works with `_bucket_url_vhost` + location = location.replace(f".{region_name}.", ".") + + assert response.headers["Location"] == location + assert json_response["Location"] == location + + assert json_response["Bucket"] == s3_bucket + assert json_response["Key"] == "key-my-file" + assert json_response["ETag"] == etag + + @markers.aws.validated + def test_s3_presigned_post_success_action_redirect(self, s3_bucket, aws_client): + # a security policy is required if the bucket is not publicly writable + # see https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html#RESTObjectPOST-requests-form-fields + body = "something body" + # get presigned URL + object_key = "key-test" + redirect_location = "http://localhost.test/random" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + {"bucket": s3_bucket}, + ["eq", "$success_action_redirect", redirect_location], + ], + ExpiresIn=60, + ) + files = {"file": ("my-file", body)} + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files=files, + verify=False, + allow_redirects=False, + ) + + assert response.status_code == 303 + assert not response.text + location = urlparse(response.headers["Location"]) + location_qs = parse_qs(location.query) + assert location_qs["key"][0] == object_key + assert location_qs["bucket"][0] == s3_bucket + # TODO requests.post has known issues when running in CI -> sometimes the body is empty, etag is therefore different + # assert location_qs["etag"][0] == '"43281e21fce675ac3bcb3524b38ca4ed"' + + # If S3 cannot interpret the URL, it acts as if the field is not present. + wrong_redirect = "/wrong/redirect/relative" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": wrong_redirect}, + Conditions=[ + {"bucket": s3_bucket}, + ["eq", "$success_action_redirect", wrong_redirect], + ], + ExpiresIn=60, + ) + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files=files, + verify=False, + allow_redirects=False, + ) + assert response.status_code == 204 + + @markers.aws.validated + @pytest.mark.parametrize( + "tagging", + [ + "TagNameTagValue", + "TagNameTagValueTagName2TagValue2", + "", + "not-xml", + ], + ids=["single", "list", "invalid", "notxml"], + ) + @markers.snapshot.skip_snapshot_verify( + paths=["$..HostId"], # missing from the exception XML + ) + def test_post_object_with_tags(self, s3_bucket, aws_client, snapshot, tagging): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("HostId"), + snapshot.transform.key_value("RequestId"), + ] + ) + object_key = "test-presigned-post-key-tagging" + # need to set the tagging directly as XML, per the documentation + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Fields={"tagging": tagging}, + Conditions=[ + {"bucket": s3_bucket}, + ["eq", "$tagging", tagging], + ], + ) + # put object + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "test-body-tagging"}, + verify=False, + ) + if tagging == "not-xml": + assert response.status_code == 400 + snapshot.match("tagging-error", xmltodict.parse(response.content)) + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + e.match("NoSuchKey") + else: + assert response.status_code == 204 + tagging = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-tagging", tagging) + + @markers.aws.validated + def test_post_object_with_metadata(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer( + snapshot.transform.key_value( + "ExpiresString", reference_replacement=False, value_replacement="" + ) + ) + object_key = "test-presigned-post-key-metadata" + object_expires = rfc_1123_datetime( + datetime.datetime.now(ZoneInfo("GMT")) + datetime.timedelta(minutes=10) + ) + + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Fields={ + "x-amz-meta-test-1": "test-meta-1", + "x-amz-meta-TEST-2": "test-meta-2", + "Content-Type": "text/plain", + "Expires": object_expires, + }, + Conditions=[ + {"bucket": s3_bucket}, + ["eq", "$x-amz-meta-test-1", "test-meta-1"], + ["eq", "$x-amz-meta-TEST-2", "test-meta-2"], + ["eq", "$Content-Type", "text/plain"], + ["eq", "$Expires", object_expires], + ], + ) + # PostObject + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "test-body-tagging"}, + verify=False, + ) + assert response.status_code == 204 + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..HostId", + "$..ContentLength", + "$..ETag", + ], # missing from the exception XML, and failing in CI + ) + def test_post_object_with_storage_class(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("HostId"), + snapshot.transform.key_value("RequestId"), + ] + ) + object_key = "test-presigned-post-key-storage-class" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Fields={ + "x-amz-storage-class": StorageClass.STANDARD_IA, + }, + Conditions=[ + {"bucket": s3_bucket}, + ["eq", "$x-amz-storage-class", StorageClass.STANDARD_IA], + ], + ) + # PostObject + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "test-body-storage-class"}, + verify=False, + ) + assert response.status_code == 204 + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Fields={ + "x-amz-storage-class": "FakeClass", + }, + Conditions=[ + {"bucket": s3_bucket}, + ["eq", "$x-amz-storage-class", "FakeClass"], + ], + ) + # PostObject + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "test-body-storage-class"}, + verify=False, + ) + assert response.status_code == 400 + snapshot.match("invalid-storage-error", xmltodict.parse(response.content)) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..HostId"], + ) + def test_post_object_with_wrong_content_type(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("HostId"), + snapshot.transform.key_value("RequestId"), + ] + ) + object_key = "test-presigned-post-key-wrong-content-type" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": s3_bucket}, + ], + ) + # PostObject + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "test-body-wrong-content-type"}, + headers={"Content-Type": "text/html"}, + verify=False, + ) + + assert response.status_code == 412 + snapshot.match("invalid-content-type-error", xmltodict.parse(response.content)) + + @markers.aws.validated + def test_post_object_default_checksum(self, s3_bucket, aws_client, snapshot): + object_key = "test-presigned-post-checksum" + + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[{"bucket": s3_bucket}], + ) + + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "test-body-tagging"}, + verify=False, + ) + assert response.status_code == 204 + assert "x-amz-checksum-crc64nvme" in response.headers + assert response.headers["x-amz-checksum-type"] == "FULL_OBJECT" + + head_object = aws_client.s3.head_object( + Bucket=s3_bucket, Key=object_key, ChecksumMode="ENABLED" + ) + snapshot.match("head-object", head_object) + assert head_object["ChecksumCRC64NVME"] == response.headers["x-amz-checksum-crc64nvme"] + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ContentLength", + "$..ETag", + "$..HostId", + ], # FIXME: in CI, it fails sporadically and the form is empty + ) + def test_post_object_with_file_as_string(self, s3_bucket, aws_client, snapshot): + # this is a test for https://github.com/localstack/localstack/issues/10309 + # You can send requests with node.js with a different format than what we can with Python + # (the actual file would just be a regular `file` key of the form with content) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("HostId"), + snapshot.transform.key_value("RequestId"), + snapshot.transform.key_value("Name"), + ] + ) + object_key = "test-presigned-post-file-as-field" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": s3_bucket}, + ], + ) + + # we need to define a proper format for `files` so that we don't add the filename= field to the form + # see https://github.com/psf/requests/issues/1081 + + # PostObject + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={ + "file": (None, "test-body-file-as-field"), + }, + verify=False, + ) + assert response.status_code == 204 + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object", head_object) + + object_key = "file-as-field-${filename}" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": s3_bucket}, + ], + ) + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={ + "file": (None, "test-body-file-as-field-filename-replacement"), + }, + verify=False, + ) + assert response.status_code == 204 + + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects", response) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: wrong exception implement, still missing the extra input fields validation + "$.invalid-condition-missing-prefix.Error.Message", + # TODO: we should add HostId to every serialized exception for S3, and not have them as part as the spec + "$.invalid-condition-wrong-condition.Error.HostId", + ], + ) + @markers.aws.validated + def test_post_object_policy_conditions_validation_eq(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value( + "HostId", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("RequestId"), + snapshot.transform.key_value( + "ExpiresString", reference_replacement=False, value_replacement="" + ), + ] + ) + object_key = "validate-policy-1" + + redirect_location = "http://localhost.test/random" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + ["eq", "$success_action_redirect", redirect_location], + ], + ExpiresIn=60, + ) + + # PostObject with a wrong redirect location + presigned_request["fields"]["success_action_redirect"] = "http://wrong.location/test" + response = self.post_generated_presigned_post_with_default_file(presigned_request) + + # assert that it's rejected + assert response.status_code == 403 + snapshot.match("invalid-condition-eq", xmltodict.parse(response.content)) + + # PostObject with a wrong condition (missing $ prefix) + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + ["eq", "success_action_redirect", redirect_location], + ], + ExpiresIn=60, + ) + + response = self.post_generated_presigned_post_with_default_file(presigned_request) + + # assert that it's rejected + assert response.status_code == 403 + snapshot.match("invalid-condition-missing-prefix", xmltodict.parse(response.content)) + + # PostObject with a wrong condition (multiple condition in one dict) + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + {"bucket": s3_bucket, "success_action_redirect": redirect_location}, + ], + ExpiresIn=60, + ) + + response = self.post_generated_presigned_post_with_default_file(presigned_request) + + # assert that it's rejected + assert response.status_code == 400 + snapshot.match("invalid-condition-wrong-condition", xmltodict.parse(response.content)) + + # PostObject with a wrong condition value casing + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + ["eq", "$success_action_redirect", redirect_location.replace("http://", "HTTP://")], + ], + ExpiresIn=60, + ) + response = self.post_generated_presigned_post_with_default_file(presigned_request) + # assert that it's rejected + assert response.status_code == 403 + snapshot.match("invalid-condition-wrong-value-casing", xmltodict.parse(response.content)) + + object_expires = rfc_1123_datetime( + datetime.datetime.now(ZoneInfo("GMT")) + datetime.timedelta(minutes=10) + ) + + # test casing for x-amz-meta and specific Content-Type/Expires S3 headers + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Fields={ + "x-amz-meta-test-1": "test-meta-1", + "x-amz-meta-TEST-2": "test-meta-2", + "Content-Type": "text/plain", + "Expires": object_expires, + }, + Conditions=[ + {"bucket": s3_bucket}, + ["eq", "$x-amz-meta-test-1", "test-meta-1"], + ["eq", "$x-amz-meta-test-2", "test-meta-2"], + ["eq", "$content-type", "text/plain"], + ["eq", "$Expires", object_expires], + ], + ) + # assert that it kept the casing + assert "x-amz-meta-TEST-2" in presigned_request["fields"] + response = self.post_generated_presigned_post_with_default_file(presigned_request) + # assert that it's accepted + assert response.status_code == 204 + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-object-metadata", head_object) + + # PostObject with a wrong condition key casing, should still work + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + ["eq", "$success_Action_REDIRECT", redirect_location], + ], + ExpiresIn=60, + ) + + # load the generated policy to assert that it kept the casing, and it is sent to AWS + generated_policy = json.loads( + base64.b64decode(presigned_request["fields"]["policy"]).decode("utf-8") + ) + eq_condition = [ + cond + for cond in generated_policy["conditions"] + if isinstance(cond, list) and cond[0] == "eq" + ][0] + assert eq_condition[1] == "$success_Action_REDIRECT" + + response = self.post_generated_presigned_post_with_default_file(presigned_request) + # assert that it's accepted + assert response.status_code == 303 + + final_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("final-object", final_object) + + # test casing for x-amz-meta and specific Content-Type/Expires S3 headers, but without eq + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Fields={ + "x-amz-meta-test-1": "test-meta-1", + "x-amz-meta-TEST-2": "test-meta-2", + "Content-Type": "text/plain", + "Expires": object_expires, + }, + Conditions=[ + {"bucket": s3_bucket}, + {"x-amz-meta-test-1": "test-meta-1"}, + {"x-amz-meta-test-2": "test-meta-2"}, + {"Content-Type": "text/plain"}, + {"Expires": object_expires}, + ], + ) + # assert that it kept the casing + assert "x-amz-meta-TEST-2" in presigned_request["fields"] + assert "Content-Type" in presigned_request["fields"] + response = self.post_generated_presigned_post_with_default_file(presigned_request) + # assert that it's accepted + assert response.status_code == 204 + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: wrong exception implement, still missing the extra input fields validation + "$.invalid-condition-missing-prefix.Error.Message", + # TODO: we should add HostId to every serialized exception for S3, and not have them as part as the spec + "$.invalid-condition-wrong-condition.Error.HostId", + ], + ) + @markers.aws.validated + def test_post_object_policy_conditions_validation_starts_with( + self, s3_bucket, aws_client, snapshot + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value( + "HostId", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("RequestId"), + ] + ) + object_key = "validate-policy-2" + + redirect_location = "http://localhost.test/random" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + ["starts-with", "$success_action_redirect", "http://localhost"], + ], + ExpiresIn=60, + ) + + # PostObject with a wrong redirect location start + presigned_request["fields"]["success_action_redirect"] = "http://wrong.location/test" + response = self.post_generated_presigned_post_with_default_file(presigned_request) + + # assert that it's rejected + assert response.status_code == 403 + snapshot.match("invalid-condition-starts-with", xmltodict.parse(response.content)) + + # PostObject with a right redirect location start but wrong casing + presigned_request["fields"]["success_action_redirect"] = "HTTP://localhost.test/random" + response = self.post_generated_presigned_post_with_default_file(presigned_request) + + # assert that it's rejected + assert response.status_code == 403 + snapshot.match("invalid-condition-starts-with-casing", xmltodict.parse(response.content)) + + # PostObject with a right redirect location start + presigned_request["fields"]["success_action_redirect"] = redirect_location + response = self.post_generated_presigned_post_with_default_file(presigned_request) + + # assert that it's accepted + assert response.status_code == 303 + assert response.headers["Location"].startswith(redirect_location) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-1", get_object) + + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + Fields={"success_action_redirect": redirect_location}, + Conditions=[ + [ + "starts-with", + "$success_action_redirect", + "", + ], # this allows to accept anything for it + ], + ExpiresIn=60, + ) + + # PostObject with a different redirect location, but should be accepted + # manually generate the pre-signed with a different file value to change ETag, to later validate that the file + # has properly been written in S3 + presigned_request["fields"]["success_action_redirect"] = "http://wrong.location/test" + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "manual value to change ETag"}, + verify=False, + allow_redirects=False, + ) + + # assert that it's accepted + assert response.status_code == 303 + assert response.headers["Location"].startswith("http://wrong.location/test") + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-2", get_object) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: we should add HostId to every serialized exception for S3, and not have them as part as the spec + "$.invalid-content-length-too-small.Error.HostId", + ], + ) + @markers.aws.validated + def test_post_object_policy_validation_size(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value( + "HostId", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("RequestId"), + ] + ) + object_key = "validate-policy-content-length" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": s3_bucket}, + ["content-length-range", 5, 10], + ], + ) + # PostObject with a body length of 12 + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "a" * 12}, + verify=False, + ) + + # assert that it's rejected + assert response.status_code == 400 + snapshot.match("invalid-content-length-too-big", xmltodict.parse(response.content)) + + # PostObject with a body length of 1 + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "a" * 1}, + verify=False, + ) + + # assert that it's rejected + assert response.status_code == 400 + snapshot.match("invalid-content-length-too-small", xmltodict.parse(response.content)) + + # PostObject with a body length of 5 + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "a" * 5}, + verify=False, + ) + assert response.status_code == 204 + + # PostObject with a body length of 10 + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "a" * 10}, + verify=False, + ) + assert response.status_code == 204 + + final_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("final-object", final_object) + + # try with string values for the content length range + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": s3_bucket}, + ["content-length-range", "5", "10"], + ], + ) + # PostObject with a body length of 10 + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "a" * 10}, + verify=False, + ) + assert response.status_code == 204 + + # try with string values that are not cast-able for the content length range + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": s3_bucket}, + ["content-length-range", "test", "10"], + ], + ) + # PostObject with a body length of 10 + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": "a" * 10}, + verify=False, + ) + assert response.status_code == 403 + snapshot.match("invalid-content-length-wrong-type", xmltodict.parse(response.content)) + + @pytest.mark.skipif( + condition=TEST_S3_IMAGE, + reason="STS not enabled in S3 image", + ) + @markers.aws.validated + def test_presigned_post_with_different_user_credentials( + self, + aws_client, + s3_create_bucket_with_client, + create_role_with_policy, + account_id, + wait_and_assume_role, + snapshot, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value( + "HostId", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("RequestId"), + ] + ) + bucket_name = f"bucket-{short_uid()}" + actions = [ + "s3:CreateBucket", + "s3:PutObject", + "s3:GetObject", + "s3:DeleteBucket", + "s3:DeleteObject", + ] + + assume_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"AWS": account_id}, + "Effect": "Allow", + } + ], + } + assume_policy_doc = json.dumps(assume_policy_doc) + role_name, role_arn = create_role_with_policy( + effect="Allow", + actions=actions, + assume_policy_doc=assume_policy_doc, + resource="*", + ) + + credentials = wait_and_assume_role(role_arn=role_arn) + + client = boto3.client( + "s3", + config=Config(signature_version="s3v4"), + endpoint_url=_endpoint_url(), + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + ) + + retry( + lambda: s3_create_bucket_with_client(s3_client=client, Bucket=bucket_name), + sleep=3 if is_aws_cloud() else 0.5, + ) + + object_key = "validate-policy-full-credentials" + presigned_request = client.generate_presigned_post( + Bucket=bucket_name, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": bucket_name}, + ], + ) + # load the generated policy to assert that it kept the casing, and it is sent to AWS + generated_policy = json.loads( + base64.b64decode(presigned_request["fields"]["policy"]).decode("utf-8") + ) + policy_conditions_fields = set() + token_condition = None + for condition in generated_policy["conditions"]: + if isinstance(condition, dict): + for k, v in condition.items(): + policy_conditions_fields.add(k) + if k == "x-amz-security-token": + token_condition = v + else: + # format is [operator, key, value] + policy_conditions_fields.add(condition[1]) + + assert policy_conditions_fields == { + "bucket", + "key", + "x-amz-security-token", + "x-amz-credential", + "x-amz-date", + "x-amz-algorithm", + } + assert token_condition == credentials["SessionToken"] + + response = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files={"file": self.DEFAULT_FILE_VALUE}, + verify=False, + ) + assert response.status_code == 204 + assert response.headers.get("Content-Type") is None + + get_obj = aws_client.s3.get_object(Bucket=bucket_name, Key=object_key) + snapshot.match("get-obj", get_obj) + + @markers.aws.validated + @pytest.mark.parametrize( + "signature_version", + ["s3", "s3v4"], + ) + def test_post_object_policy_casing(self, s3_bucket, signature_version): + object_key = "validate-policy-casing" + presigned_client = _s3_client_pre_signed_client( + Config(signature_version=signature_version), + endpoint_url=_endpoint_url(), + ) + presigned_request = presigned_client.generate_presigned_post( + Bucket=s3_bucket, + Key=object_key, + ExpiresIn=60, + Conditions=[ + {"bucket": s3_bucket}, + ["content-length-range", 5, 10], + ], + ) + + # test that we can change the casing of the Policy field + fields = presigned_request["fields"] + fields["Policy"] = fields.pop("policy") + response = requests.post( + presigned_request["url"], + data=fields, + files={"file": "a" * 5}, + verify=False, + ) + assert response.status_code == 204 + + # test that we can change the casing of the credentials field + if signature_version == "s3": + field_name = "AWSAccessKeyId" + new_field_name = "awsaccesskeyid" + else: + field_name = "x-amz-credential" + new_field_name = "X-Amz-Credential" + + fields[new_field_name] = fields.pop(field_name) + response = requests.post( + presigned_request["url"], + data=fields, + files={"file": "a" * 5}, + verify=False, + ) + assert response.status_code == 204 + + +# LocalStack does not apply encryption, so the ETag is different +@markers.snapshot.skip_snapshot_verify(paths=["$..ETag"]) +class TestS3SSECEncryption: + # https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html + ENCRYPTION_KEY = b"1234567890abcdef1234567890abcdef" + ENCRYPTION_KEY_2 = b"abcdef1234567890abcdef1234567890" + + @staticmethod + def get_encryption_key_b64_and_md5(encryption_key: bytes) -> tuple[str, str]: + sse_customer_key_base64 = base64.b64encode(encryption_key).decode("utf-8") + sse_customer_key_md5 = base64.b64encode(hashlib.md5(encryption_key).digest()).decode( + "utf-8" + ) + return sse_customer_key_base64, sse_customer_key_md5 + + @markers.aws.validated + def test_put_object_lifecycle_with_sse_c(self, aws_client, s3_bucket, snapshot): + body = "test_data" + key_name = "test-sse-c" + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c", put_obj) + + head_obj = aws_client.s3.head_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("head-obj-sse-c", head_obj) + + get_obj = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("get-obj-sse-c", get_obj) + + get_obj_attr = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["ETag", "ObjectSize"], + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("get-obj-attrs-sse-c", get_obj_attr) + + del_obj = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("del-obj-sse-c", del_obj) + + @markers.aws.validated + def test_put_object_validation_sse_c(self, aws_client, s3_bucket, snapshot): + body = "test_data" + key_name = "test-sse-c" + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + ServerSideEncryption="AES256", + SSECustomerAlgorithm="KMS", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c-both-encryption", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerAlgorithm="KMS", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c-wrong-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c-no-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c-no-key", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + ) + snapshot.match("put-obj-sse-c-no-md5", e.value.response) + + with pytest.raises(ClientError) as e: + bad_key_size = base64.b64encode(self.ENCRYPTION_KEY[:10]).decode("utf-8") + bad_key_size_md5 = base64.b64encode( + hashlib.md5(self.ENCRYPTION_KEY[:10]).digest() + ).decode("utf-8") + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=bad_key_size, + SSECustomerKeyMD5=bad_key_size_md5, + ) + snapshot.match("put-obj-sse-c-wrong-key-size", e.value.response) + + with pytest.raises(ClientError) as e: + bad_char = "a" if cus_key_md5[0] != "a" else "b" + bad_md5 = bad_char + cus_key_md5[1:] + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=bad_md5, + ) + snapshot.match("put-obj-sse-c-bad-md5", e.value.response) + + @markers.aws.validated + def test_object_retrieval_sse_c(self, aws_client, s3_bucket, snapshot): + body = "test_data" + key_name = "test-sse-c" + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c", put_obj) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-obj-no-sse-c", e.value.response) + + with pytest.raises(ClientError) as e: + key_2, key_2_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY_2) + aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=key_2, + SSECustomerKeyMD5=key_2_md5, + ) + snapshot.match("get-obj-sse-c-wrong-key", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="KMS", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("get-obj-sse-c-wrong-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.head_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="KMS", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("head-obj-sse-c-wrong-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="KMS", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ObjectAttributes=["ETag"], + ) + snapshot.match("get-obj-attrs-sse-c-wrong-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + ) + snapshot.match("get-obj-sse-c-no-md5", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.head_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + ) + snapshot.match("head-obj-sse-c-no-md5", e.value.response) + + with pytest.raises(ClientError) as e: + bad_key_size = base64.b64encode(self.ENCRYPTION_KEY[:10]).decode("utf-8") + bad_key_size_md5 = base64.b64encode( + hashlib.md5(self.ENCRYPTION_KEY[:10]).digest() + ).decode("utf-8") + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=bad_key_size, + SSECustomerKeyMD5=bad_key_size_md5, + ) + snapshot.match("get-obj-sse-c-wrong-key-size", e.value.response) + + with pytest.raises(ClientError) as e: + bad_char = "a" if cus_key_md5[0] != "a" else "b" + bad_md5 = bad_char + cus_key_md5[1:] + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=bad_md5, + ) + snapshot.match("get-obj-sse-c-bad-md5", e.value.response) + + @markers.aws.validated + def test_copy_object_with_sse_c(self, aws_client, s3_bucket, snapshot): + body = "test_data" + key_name_src = "test-sse-c-src" + key_name_target = "test-sse-c-target" + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name_src, + Body=body, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c", put_obj) + + # successful copy without encrypting the target + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + Key=key_name_target, + CopySource=f"{s3_bucket}/{key_name_src}", + CopySourceSSECustomerAlgorithm="AES256", + CopySourceSSECustomerKey=cus_key, + CopySourceSSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("copy-obj-sse-c-target-no-sse-c", copy_obj) + + # successful copy while encrypting the target + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + Key=key_name_target, + CopySource=f"{s3_bucket}/{key_name_src}", + CopySourceSSECustomerAlgorithm="AES256", + CopySourceSSECustomerKey=cus_key, + CopySourceSSECustomerKeyMD5=cus_key_md5, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("copy-obj-sse-c", copy_obj) + + # assert the encryption is successful by trying to get object it without SSE-C + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name_target) + snapshot.match("get-obj-no-sse-c-param", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + Key=key_name_target, + CopySource=f"{s3_bucket}/{key_name_src}", + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("copy-obj-no-src-sse-c", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + Key=key_name_target, + CopySource=f"{s3_bucket}/{key_name_src}", + CopySourceSSECustomerAlgorithm="KMS", + CopySourceSSECustomerKey=cus_key, + CopySourceSSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("copy-obj-wrong-src-sse-c-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + Key=key_name_target, + CopySource=f"{s3_bucket}/{key_name_src}", + CopySourceSSECustomerAlgorithm="AES256", + CopySourceSSECustomerKey=cus_key, + CopySourceSSECustomerKeyMD5=cus_key_md5, + SSECustomerAlgorithm="KMS", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("copy-obj-wrong-target-sse-c-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.copy_object( + Bucket=s3_bucket, + Key=key_name_target, + CopySource=f"{s3_bucket}/{key_name_src}", + CopySourceSSECustomerAlgorithm="AES256", + CopySourceSSECustomerKey=cus_key, + CopySourceSSECustomerKeyMD5=cus_key_md5, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ServerSideEncryption="AES256", + ) + snapshot.match("copy-obj-target-double-encryption", e.value.response) + + @markers.aws.validated + def test_multipart_upload_sse_c(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + key_name = "test-sse-c-multipart" + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("create-mpu-sse-c", response) + upload_id = response["UploadId"] + + # data must be at least 5MiB + part_data = "a" * (5_242_880 + 1) + part_data = to_bytes(part_data) + + parts = 3 + multipart_upload_parts = [] + for part in range(parts): + # Write contents to memory rather than a file. + part_number = part + 1 + upload_file_object = BytesIO(part_data) + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=upload_file_object, + PartNumber=part_number, + UploadId=upload_id, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match(f"upload-part-{part}", response) + multipart_upload_parts.append( + { + "ETag": response["ETag"], + "PartNumber": part_number, + } + ) + + response = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + snapshot.match("list-parts", response) + + # no need to add the SSE-C on complete (from the documentation, but you still can?? weird?) TODO check + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-checksum", response) + + # assert the encryption is successful by trying to get object it without SSE-C + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-obj-no-sse-c-param", e.value.response) + + get_obj = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + # object is big, so we remove the body + get_obj["Body"].read() + snapshot.match("get-obj-sse-c", get_obj) + + @markers.aws.validated + def test_multipart_upload_sse_c_validation(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + body = "testbody" + key_name = "test-sse-c-multipart" + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + + # create a multipart without SSE-C + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + ) + snapshot.match("create-mpu-no-sse-c", response) + upload_id = response["UploadId"] + + # assert that if the multipart isnt created with SSE-C, you cannot upload with SSE-C + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=body, + PartNumber=1, + UploadId=upload_id, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("mpu-no-sse-c-upload-part-with-sse-c", e.value.response) + # remove the multipart + aws_client.s3.abort_multipart_upload(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + + # create a multipart with SSE-C + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("create-mpu-sse-c", response) + upload_id = response["UploadId"] + + # assert that if the multipart is created with SSE-C, you cannot upload without SSE-C + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=body, + PartNumber=1, + UploadId=upload_id, + ) + snapshot.match("mpu-sse-c-upload-part-no-sse-c", e.value.response) + + with pytest.raises(ClientError) as e: + key_2, key_2_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY_2) + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=body, + PartNumber=1, + UploadId=upload_id, + SSECustomerAlgorithm="AES256", + SSECustomerKey=key_2, + SSECustomerKeyMD5=key_2_md5, + ) + snapshot.match("mpu-sse-c-upload-part-wrong-sse-c", e.value.response) + # TODO: check complete with wrong parameters, even though it is not required to give them? + + @markers.aws.validated + def test_sse_c_with_versioning(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + # enable versioning on the bucket + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + # assert that you can use different encryption keys for different versions + key_name = "test-versioning-sse-c" + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body="version1", + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("put-obj-sse-c-version-1", put_obj) + version_1 = put_obj["VersionId"] + + cus_key_2, cus_key_2_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY_2) + + put_obj_version_2 = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key_name, + Body="version2", + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key_2, + SSECustomerKeyMD5=cus_key_2_md5, + ) + snapshot.match("put-obj-sse-c-version-2", put_obj_version_2) + version_2 = put_obj_version_2["VersionId"] + + # last version should be what we call version-2, try getting it with Key 2 + get_current_obj = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key_2, + SSECustomerKeyMD5=cus_key_2_md5, + ) + snapshot.match("get-obj-sse-c-last-version", get_current_obj) + + # access directly version 2 + get_obj_2 = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key_2, + SSECustomerKeyMD5=cus_key_2_md5, + VersionId=version_2, + ) + snapshot.match("get-obj-sse-c-version-2", get_obj_2) + + # try getting the version 1 with Key 2 + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key_2, + SSECustomerKeyMD5=cus_key_2_md5, + VersionId=version_1, + ) + snapshot.match("get-obj-sse-c-last-version-wrong-key", e.value.response) + + get_version_1_obj = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + VersionId=version_1, + ) + snapshot.match("get-obj-sse-c-version-1", get_version_1_obj) + + @markers.aws.validated + def test_put_object_default_checksum_with_sse_c( + self, aws_client, s3_bucket, snapshot, aws_http_client_factory + ): + cus_key, cus_key_md5 = self.get_encryption_key_b64_and_md5(self.ENCRYPTION_KEY) + headers = { + "x-amz-content-sha256": "UNSIGNED-PAYLOAD", + "x-amz-server-side-encryption-customer-algorithm": "AES256", + "x-amz-server-side-encryption-customer-key": cus_key, + "x-amz-server-side-encryption-customer-key-MD5": cus_key_md5, + } + data = b"test data.." + + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + bucket_url = _bucket_url(s3_bucket) + + no_checksum_key_sse_c = "test-sse-c" + + # https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html + no_checksum_put_object_url = f"{bucket_url}/{no_checksum_key_sse_c}" + resp = s3_http_client.put(no_checksum_put_object_url, headers=headers, data=data) + assert resp.ok + + head_obj = aws_client.s3.head_object( + Bucket=s3_bucket, + Key=no_checksum_key_sse_c, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ChecksumMode="ENABLED", + ) + snapshot.match("head-obj-sse-c", head_obj) + + get_obj = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=no_checksum_key_sse_c, + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("get-obj-sse-c", get_obj) + + get_obj_attr = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=no_checksum_key_sse_c, + ObjectAttributes=["ETag", "Checksum"], + SSECustomerAlgorithm="AES256", + SSECustomerKey=cus_key, + SSECustomerKeyMD5=cus_key_md5, + ) + snapshot.match("get-obj-attrs-sse-c", get_obj_attr) + + +class TestS3PutObjectChecksum: + @markers.aws.validated + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME"]) + def test_put_object_checksum(self, s3_bucket, algorithm, snapshot, aws_client): + key = f"file-{short_uid()}" + data = b"test data.." + + params = { + "Bucket": s3_bucket, + "Key": key, + "Body": data, + "ChecksumAlgorithm": algorithm, + f"Checksum{algorithm}": short_uid(), + } + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(**params) + snapshot.match("put-wrong-checksum-no-b64", e.value.response) + + with pytest.raises(ClientError) as e: + params[f"Checksum{algorithm}"] = get_checksum_for_algorithm(algorithm, b"bad data") + aws_client.s3.put_object(**params) + snapshot.match("put-wrong-checksum-value", e.value.response) + + # Test our generated checksums + params[f"Checksum{algorithm}"] = get_checksum_for_algorithm(algorithm, data) + response = aws_client.s3.put_object(**params) + snapshot.match("put-object-generated", response) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key, + ObjectAttributes=["ETag", "Checksum"], + ) + snapshot.match("get-object-attrs-generated", object_attrs) + + # Test the autogenerated checksums + params.pop(f"Checksum{algorithm}") + response = aws_client.s3.put_object(**params) + snapshot.match("put-object-autogenerated", response) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key, + ObjectAttributes=["ETag", "Checksum"], + ) + snapshot.match("get-object-attrs-auto-generated", object_attrs) + + get_object_with_checksum = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key, ChecksumMode="ENABLED" + ) + snapshot.match("head-object-with-checksum", get_object_with_checksum) + + @markers.aws.validated + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME", None]) + def test_s3_get_object_checksum(self, s3_bucket, snapshot, algorithm, aws_client): + key = "test-checksum-retrieval" + body = b"test-checksum" + kwargs = {} + if algorithm: + kwargs["ChecksumAlgorithm"] = algorithm + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=body, **kwargs) + snapshot.match("put-object", put_object) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + snapshot.match("get-object", get_object) + + get_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key, ChecksumMode="ENABLED" + ) + snapshot.match("get-object-with-checksum", get_object_with_checksum) + + # test that the casing of ChecksumMode is not important, the spec indicate only ENABLED + head_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key, ChecksumMode="enabled" + ) + snapshot.match("head-object-with-checksum", head_object_with_checksum) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key, + ObjectAttributes=["Checksum"], + ) + snapshot.match("get-object-attrs", object_attrs) + + @markers.aws.validated + def test_s3_checksum_with_content_encoding(self, s3_bucket, snapshot, aws_client): + data = "1234567890 " * 100 + key = "test.gz" + + # Write contents to memory rather than a file. + upload_file_object = BytesIO() + # GZIP has the timestamp and filename in its headers, so set them to have same ETag and hash for AWS and LS + # hardcode the timestamp, the filename will be an empty string because we're passing a BytesIO stream + mtime = 1676569620 + with gzip.GzipFile(fileobj=upload_file_object, mode="w", mtime=mtime) as filestream: + filestream.write(data.encode("utf-8")) + + response = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key, + ContentEncoding="gzip", + Body=upload_file_object.getvalue(), + ChecksumAlgorithm="SHA256", + ) + snapshot.match("put-object", response) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key) + # FIXME: empty the encoded GZIP stream so it does not break snapshot (can't decode it to UTF-8) + get_object["Body"].read() + snapshot.match("get-object", get_object) + + get_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key, ChecksumMode="ENABLED" + ) + get_object_with_checksum["Body"].read() + snapshot.match("get-object-with-checksum", get_object_with_checksum) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key, + ObjectAttributes=["Checksum"], + ) + snapshot.match("get-object-attrs", object_attrs) + + @markers.aws.validated + def test_s3_checksum_no_algorithm(self, s3_bucket, snapshot, aws_client): + key = f"file-{short_uid()}" + data = b"test data.." + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key, + Body=data, + ChecksumSHA256=short_uid(), + ) + snapshot.match("put-wrong-checksum", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key, + Body=data, + ChecksumSHA256=short_uid(), + ChecksumCRC32=short_uid(), + ) + snapshot.match("put-2-checksums", e.value.response) + + resp = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=key, + Body=data, + ChecksumSHA256=hash_sha256(data), + ) + snapshot.match("put-right-checksum", resp) + + head_obj = aws_client.s3.head_object(Bucket=s3_bucket, Key=key, ChecksumMode="ENABLED") + snapshot.match("head-obj", head_obj) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.wrong-checksum.Error.HostId", # FIXME: not returned in the exception + ] + ) + def test_s3_checksum_no_automatic_sdk_calculation( + self, s3_bucket, snapshot, aws_client, aws_http_client_factory + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("HostId"), + snapshot.transform.key_value("RequestId"), + ] + ) + headers = {"x-amz-content-sha256": "UNSIGNED-PAYLOAD"} + data = b"test data.." + hash_256_data = hash_sha256(data) + + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + bucket_url = _bucket_url(s3_bucket) + + wrong_object_key = "wrong-checksum" + wrong_put_object_url = f"{bucket_url}/{wrong_object_key}" + wrong_put_object_headers = {**headers, "x-amz-checksum-sha256": short_uid()} + resp = s3_http_client.put(wrong_put_object_url, headers=wrong_put_object_headers, data=data) + resp_dict = xmltodict.parse(resp.content) + snapshot.match("wrong-checksum", resp_dict) + + object_key = "right-checksum" + put_object_url = f"{bucket_url}/{object_key}" + put_object_headers = {**headers, "x-amz-checksum-sha256": hash_256_data} + resp = s3_http_client.put(put_object_url, headers=put_object_headers, data=data) + assert resp.ok + + head_obj = aws_client.s3.head_object( + Bucket=s3_bucket, Key=object_key, ChecksumMode="ENABLED" + ) + snapshot.match("head-obj-right-checksum", head_obj) + + algo_object_key = "algo-only-checksum" + algo_put_object_url = f"{bucket_url}/{algo_object_key}" + algo_put_object_headers = {**headers, "x-amz-checksum-algorithm": "SHA256"} + resp = s3_http_client.put(algo_put_object_url, headers=algo_put_object_headers, data=data) + assert resp.ok + + head_obj = aws_client.s3.head_object( + Bucket=s3_bucket, Key=algo_object_key, ChecksumMode="ENABLED" + ) + snapshot.match("head-obj-only-checksum-algo", head_obj) + + wrong_algo_object_key = "algo-wrong-checksum" + wrong_algo_put_object_url = f"{bucket_url}/{wrong_algo_object_key}" + wrong_algo_put_object_headers = {**headers, "x-amz-checksum-algorithm": "TEST"} + resp = s3_http_client.put( + wrong_algo_put_object_url, headers=wrong_algo_put_object_headers, data=data + ) + assert resp.ok + + algo_diff_object_key = "algo-diff-checksum" + algo_diff_put_object_url = f"{bucket_url}/{algo_diff_object_key}" + algo_diff_put_object_headers = { + **headers, + "x-amz-checksum-algorithm": "SHA1", + "x-amz-checksum-sha256": hash_256_data, + } + resp = s3_http_client.put( + algo_diff_put_object_url, headers=algo_diff_put_object_headers, data=data + ) + assert resp.ok + + head_obj = aws_client.s3.head_object( + Bucket=s3_bucket, Key=algo_diff_object_key, ChecksumMode="ENABLED" + ) + snapshot.match("head-obj-diff-checksum-algo", head_obj) + + # https://docs.aws.amazon.com/sdkref/latest/guide/feature-dataintegrity.html + no_checksum_object_key = "no-checksum" + no_checksum_put_object_url = f"{bucket_url}/{no_checksum_object_key}" + resp = s3_http_client.put(no_checksum_put_object_url, headers=headers, data=data) + assert resp.ok + + head_obj = aws_client.s3.head_object( + Bucket=s3_bucket, Key=no_checksum_object_key, ChecksumMode="ENABLED" + ) + snapshot.match("head-obj-no-checksum", head_obj) + + obj_attributes = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, Key=no_checksum_object_key, ObjectAttributes=["Checksum"] + ) + snapshot.match("get-obj-attrs-no-checksum", obj_attributes) + + dest_checksum_object_key = "dest-key-checksum" + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, + Key=dest_checksum_object_key, + CopySource=f"{s3_bucket}/{no_checksum_object_key}", + ) + snapshot.match("copy-obj-default-checksum", copy_obj) + + obj_attributes = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, Key=dest_checksum_object_key, ObjectAttributes=["Checksum"] + ) + snapshot.match("get-copy-obj-attrs-no-checksum", obj_attributes) + + +class TestS3MultipartUploadChecksum: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + # it seems the PartNumber might not be deterministic, possibly parallelized on S3 side? + paths=["$.complete-multipart-wrong-parts-checksum.Error.PartNumber"] + ) + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256"]) + def test_complete_multipart_parts_checksum_composite( + self, s3_bucket, snapshot, aws_client, algorithm + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + key_name = "test-multipart-checksum" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm=algorithm, ChecksumType="COMPOSITE" + ) + snapshot.match("create-mpu-checksum", response) + upload_id = response["UploadId"] + + # data must be at least 5MiB + part_data = "a" * (5_242_880 + 1) + part_data = to_bytes(part_data) + + parts = 3 + multipart_upload_parts = [] + for part in range(parts): + # Write contents to memory rather than a file. + part_number = part + 1 + if part_number == parts: + # the last part does not need to be 5mb, so make it smaller + part_data = part_data[:10] + upload_file_object = BytesIO(part_data) + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=upload_file_object, + PartNumber=part_number, + UploadId=upload_id, + ChecksumAlgorithm=algorithm, + ) + snapshot.match(f"upload-part-{part}", response) + multipart_upload_parts.append( + { + "ETag": response["ETag"], + "PartNumber": part_number, + f"Checksum{algorithm}": response[f"Checksum{algorithm}"], + } + ) + + response = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + snapshot.match("list-parts", response) + + with pytest.raises(ClientError) as e: + # testing completing the multipart with bad checksums of parts + multipart_upload_parts_wrong_checksum = [ + { + "ETag": upload_part["ETag"], + "PartNumber": upload_part["PartNumber"], + f"Checksum{algorithm}": get_checksum_for_algorithm(algorithm, b"bbb"), + } + for upload_part in multipart_upload_parts + ] + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts_wrong_checksum}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-wrong-parts-checksum", e.value.response) + + with pytest.raises(ClientError) as e: + # testing completing the multipart without the checksum of parts + multipart_upload_parts_no_checksum = [ + {"ETag": upload_part["ETag"], "PartNumber": upload_part["PartNumber"]} + for upload_part in multipart_upload_parts + ] + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts_no_checksum}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-no-checksum", e.value.response) + + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-checksum", response) + + get_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + # empty the stream, it's a 15MB string, we don't need to snapshot that + get_object_with_checksum["Body"].read() + snapshot.match("get-object-with-checksum", get_object_with_checksum) + + head_object_with_checksum = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("head-object-with-checksum", head_object_with_checksum) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-object-attrs", object_attrs) + + dest_key = "mpu-copy-checksum" + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, Key=dest_key, CopySource=f"{s3_bucket}/{key_name}" + ) + snapshot.match("copy-obj-checksum", copy_obj) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=dest_key, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-copy-object-attrs", object_attrs) + + get_object_part_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + PartNumber=3, + ChecksumMode="ENABLED", + ) + snapshot.match("get-object-part-checksum", get_object_part_checksum) + + @markers.aws.validated + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME"]) + @pytest.mark.parametrize("checksum_type", ["COMPOSITE", "FULL_OBJECT"]) + def test_multipart_checksum_type_compatibility( + self, aws_client, s3_bucket, snapshot, algorithm, checksum_type + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("UploadId"), + ] + ) + try: + key_name = "test-multipart-checksum-compat" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + ChecksumAlgorithm=algorithm, + ChecksumType=checksum_type, + ) + snapshot.match("create-mpu-checksum", response) + except ClientError as e: + snapshot.match("create-mpu-checksum-exc", e.response) + + @markers.aws.validated + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME"]) + def test_multipart_checksum_type_default_for_checksum( + self, aws_client, s3_bucket, snapshot, algorithm + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("UploadId"), + ] + ) + # test the default ChecksumType for each ChecksumAlgorithm + key_name = "test-multipart-checksum-default" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm=algorithm + ) + snapshot.match("create-mpu-default-checksum-type", response) + + @markers.aws.validated + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "SHA1", "SHA256", "CRC64NVME"]) + def test_multipart_upload_part_checksum_exception( + self, aws_client, s3_bucket, snapshot, algorithm + ): + key_name = "test-multipart-checksum-default" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name) + upload_id = response["UploadId"] + body = b"right body" + + with pytest.raises(ClientError) as e: + kwargs = { + f"Checksum{algorithm}": short_uid(), + } + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + UploadId=upload_id, + PartNumber=1, + Body=body, + ChecksumAlgorithm=algorithm, + **kwargs, + ) + snapshot.match("put-wrong-checksum-no-b64", e.value.response) + + with pytest.raises(ClientError) as e: + kwargs = {f"Checksum{algorithm}": get_checksum_for_algorithm(algorithm, b"bad data")} + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + UploadId=upload_id, + PartNumber=1, + Body=body, + ChecksumAlgorithm=algorithm, + **kwargs, + ) + snapshot.match("put-wrong-checksum-value", e.value.response) + + @markers.aws.validated + def test_multipart_parts_checksum_exceptions_composite(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + key_name = "test-multipart-checksum-exc" + with pytest.raises(ClientError) as e: + aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm="TEST" + ) + snapshot.match("create-mpu-wrong-checksum-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumType="COMPOSITE" + ) + snapshot.match("create-mpu-no-checksum-algo-with-type", e.value.response) + + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumType="COMPOSITE", ChecksumAlgorithm="CRC32" + ) + snapshot.match("create-mpu-composite-checksum", response) + upload_id = response["UploadId"] + + list_multiparts = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-multiparts", list_multiparts) + + part_data = "abc" + checksum_part = hash_sha256(to_bytes(part_data)) + + upload_resp = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=part_data, + PartNumber=1, + UploadId=upload_id, + ) + snapshot.match("upload-part-no-checksum-ok", upload_resp) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={ + "Parts": [ + { + "ETag": upload_resp["ETag"], + "PartNumber": 1, + "ChecksumSHA256": checksum_part, + } + ], + }, + UploadId=upload_id, + ) + snapshot.match("complete-part-with-checksum", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={ + "Parts": [ + { + "ETag": upload_resp["ETag"], + "PartNumber": 1, + } + ], + }, + UploadId=upload_id, + ChecksumType="FULL_OBJECT", + ) + snapshot.match("complete-part-with-bad-checksum-type", e.value.response) + + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm="SHA256" + ) + snapshot.match("create-mpu-with-checksum", response) + upload_id = response["UploadId"] + + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=part_data, + PartNumber=1, + UploadId=upload_id, + ) + snapshot.match("upload-part-different-checksum-exc", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + # it seems the PartNumber might not be deterministic, possibly parallelized on S3 side? + paths=[ + "$.complete-multipart-wrong-parts-checksum.Error.PartNumber", + "$.complete-multipart-wrong-parts-checksum.Error.ETag", + ] + ) + @pytest.mark.parametrize("algorithm", ["CRC32", "CRC32C", "CRC64NVME"]) + def test_complete_multipart_parts_checksum_full_object( + self, s3_bucket, snapshot, aws_client, algorithm + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + key_name = "test-multipart-checksum" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm=algorithm, ChecksumType="FULL_OBJECT" + ) + snapshot.match("create-mpu-checksum", response) + upload_id = response["UploadId"] + + # data must be at least 5MiB + part_data = "a" * (5_242_880 + 1) + part_data = to_bytes(part_data) + full_object_hash = get_checksum_for_algorithm( + algorithm, to_bytes(part_data * 2 + part_data[:10]) + ) + + parts = 3 + multipart_upload_parts = [] + for part in range(parts): + # Write contents to memory rather than a file. + part_number = part + 1 + if part_number == parts: + # the last part does not need to be 5mb, so make it smaller + part_data = part_data[:10] + upload_file_object = BytesIO(part_data) + response = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=upload_file_object, + PartNumber=part_number, + UploadId=upload_id, + ChecksumAlgorithm=algorithm, + ) + snapshot.match(f"upload-part-{part}", response) + # with `FULL_OBJECT`, there is no need to store intermediate part checksums + multipart_upload_parts.append({"ETag": response["ETag"], "PartNumber": part_number}) + + response = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + snapshot.match("list-parts", response) + + with pytest.raises(ClientError) as e: + # testing completing the multipart with bad checksums of parts + multipart_upload_parts_wrong_checksum = [ + { + "ETag": upload_part["ETag"], + "PartNumber": upload_part["PartNumber"], + f"Checksum{algorithm}": get_checksum_for_algorithm(algorithm, b"bbb"), + } + for upload_part in multipart_upload_parts + ] + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts_wrong_checksum}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-wrong-parts-checksum", e.value.response) + + kwargs = {f"Checksum{algorithm.upper()}": full_object_hash} + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ChecksumType="FULL_OBJECT", + **kwargs, + ) + snapshot.match("complete-multipart-checksum", response) + + get_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + # empty the stream, it's a 15MB string, we don't need to snapshot that + get_object_with_checksum["Body"].read() + snapshot.match("get-object-with-checksum", get_object_with_checksum) + + head_object_with_checksum = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("head-object-with-checksum", head_object_with_checksum) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-object-attrs", object_attrs) + + dest_key = "mpu-copy-checksum" + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, Key=dest_key, CopySource=f"{s3_bucket}/{key_name}" + ) + snapshot.match("copy-obj-checksum", copy_obj) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=dest_key, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-copy-object-attrs", object_attrs) + + get_object_part_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, + Key=key_name, + PartNumber=3, + ChecksumMode="ENABLED", + ) + snapshot.match("get-object-part-checksum", get_object_part_checksum) + + @markers.aws.validated + def test_multipart_parts_checksum_exceptions_full_object(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + key_name = "test-multipart-checksum-exc" + + with pytest.raises(ClientError) as e: + aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumType="FULL_OBJECT" + ) + snapshot.match("create-mpu-no-checksum-algo-with-type", e.value.response) + + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm="CRC32C", ChecksumType="FULL_OBJECT" + ) + snapshot.match("create-mpu-checksum-crc32c", response) + upload_id = response["UploadId"] + + list_multiparts = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-multiparts", list_multiparts) + + part_data = "abc" + checksum_part = checksum_crc32c(part_data) + + upload_resp = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=part_data, + PartNumber=1, + UploadId=upload_id, + ChecksumAlgorithm="CRC32C", + ) + snapshot.match("upload-part-no-checksum-ok", upload_resp) + + mpu_data = { + "Parts": [ + { + "ETag": upload_resp["ETag"], + "PartNumber": 1, + "ChecksumCRC32C": checksum_part, + } + ], + } + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload=mpu_data, + UploadId=upload_id, + ChecksumType="COMPOSITE", + ) + snapshot.match("complete-part-bad-checksum-type", e.value.response) + + with pytest.raises(ClientError) as e: + composite_hash = checksum_crc32c(base64.b64decode(checksum_part)) + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload=mpu_data, + UploadId=upload_id, + ChecksumCRC32C=f"{composite_hash}-1", + ) + snapshot.match("complete-part-good-checksum-no-type", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload=mpu_data, + UploadId=upload_id, + ChecksumCRC32C=checksum_part, + ) + snapshot.match("complete-part-only-checksum-algo", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload=mpu_data, + UploadId=upload_id, + ChecksumCRC64NVME=checksum_crc64nvme(part_data), + ) + snapshot.match("complete-part-only-checksum-algo-diff", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload=mpu_data, + UploadId=upload_id, + ChecksumCRC32C=checksum_crc32c("bad string"), + ChecksumType="FULL_OBJECT", + ) + snapshot.match("complete-part-bad-checksum", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload=mpu_data, + UploadId=upload_id, + ChecksumCRC32=checksum_crc32("bad string"), + ChecksumType="FULL_OBJECT", + ) + snapshot.match("complete-part-bad-checksum-algo", e.value.response) + + complete_mpu = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload=mpu_data, + UploadId=upload_id, + ChecksumCRC32C=checksum_part, + ChecksumType="FULL_OBJECT", + ) + snapshot.match("complete-success", complete_mpu) + + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm="CRC32C", ChecksumType="FULL_OBJECT" + ) + snapshot.match("create-mpu-with-checksum", response) + upload_id = response["UploadId"] + + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=part_data, + PartNumber=1, + UploadId=upload_id, + ChecksumAlgorithm="CRC32", + ) + snapshot.match("upload-part-different-checksum-exc", e.value.response) + + @markers.aws.validated + def test_complete_multipart_parts_checksum_default(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + key_name = "test-multipart-checksum" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name) + snapshot.match("create-mpu-no-checksum", response) + upload_id = response["UploadId"] + + list_multiparts = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-multiparts", list_multiparts) + + data = b"aaa" + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=data, + PartNumber=1, + UploadId=upload_id, + ChecksumAlgorithm="CRC32C", + ) + snapshot.match("upload-part-different-checksum-than-default", upload_part) + + list_parts = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key_name, UploadId=upload_id) + snapshot.match("list-parts", list_parts) + + multipart_upload_parts = [ + { + "ETag": upload_part["ETag"], + "PartNumber": 1, + "ChecksumCRC32C": upload_part["ChecksumCRC32C"], + } + ] + multipart_upload_parts_no_checksum = [ + {"ETag": upload_part["ETag"], "PartNumber": upload_part["PartNumber"]} + for upload_part in multipart_upload_parts + ] + + with pytest.raises(ClientError) as e: + # testing completing the multipart with the parts checksums will fail if the multipart does not have a + # configured checksum + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-parts-checksum", e.value.response) + + with pytest.raises(ClientError) as e: + # testing completing the multipart with different checksum type than uploaded + multipart_upload_parts_wrong_checksum = [ + { + "ETag": upload_part["ETag"], + "PartNumber": upload_part["PartNumber"], + "ChecksumSHA256": hash_sha256(data), + } + for upload_part in multipart_upload_parts + ] + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts_wrong_checksum}, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-wrong-parts-checksum", e.value.response) + + with pytest.raises(ClientError) as e: + # testing completing the multipart with bad checksum type? + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts_no_checksum}, + UploadId=upload_id, + ChecksumType="FULL_OBJECT", + ) + snapshot.match("complete-multipart-full-object-type", e.value.response) + + with pytest.raises(ClientError) as e: + # testing completing the multipart with bad checksum type? + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts_no_checksum}, + UploadId=upload_id, + ChecksumType="COMPOSITE", + ) + snapshot.match("complete-multipart-composite-type", e.value.response) + + # complete with the checksums even if unspecified + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": multipart_upload_parts_no_checksum}, + UploadId=upload_id, + # bad composite checksum, seems like it is ignored + ChecksumCRC32C=f"{checksum_crc32c(base64.b64decode(checksum_crc32c(data)))}-2", + ) + snapshot.match("complete-multipart-checksum", response) + + get_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + # empty the stream, it's a 15MB string, we don't need to snapshot that + get_object_with_checksum["Body"].read() + snapshot.match("get-object-with-checksum", get_object_with_checksum) + + head_object_with_checksum = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("head-object-with-checksum", head_object_with_checksum) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-object-attrs", object_attrs) + + dest_key = "mpu-copy-checksum" + copy_obj = aws_client.s3.copy_object( + Bucket=s3_bucket, Key=dest_key, CopySource=f"{s3_bucket}/{key_name}" + ) + snapshot.match("copy-obj-checksum", copy_obj) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=dest_key, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-copy-object-attrs", object_attrs) + + @markers.aws.validated + def test_complete_multipart_parts_checksum_full_object_default( + self, s3_bucket, snapshot, aws_client + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + key_name = "test-multipart-checksum" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm="CRC64NVME" + ) + snapshot.match("create-mpu-checksum-crc64", response) + upload_id = response["UploadId"] + + data = b"aaa" + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=data, + PartNumber=1, + UploadId=upload_id, + ChecksumAlgorithm="CRC64NVME", + ) + snapshot.match("upload-part", upload_part) + + # complete with no checksum type specified, just all default values + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={ + "Parts": [ + { + "ETag": upload_part["ETag"], + "PartNumber": 1, + "ChecksumCRC64NVME": upload_part["ChecksumCRC64NVME"], + } + ] + }, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-checksum", response) + + get_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + # empty the stream + get_object_with_checksum["Body"].read() + snapshot.match("get-object-with-checksum", get_object_with_checksum) + + head_object_with_checksum = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("head-object-with-checksum", head_object_with_checksum) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-object-attrs", object_attrs) + + @markers.aws.validated + def test_multipart_size_validation(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("Location"), + ] + ) + # test the default ChecksumType for each ChecksumAlgorithm + key_name = "test-multipart-size" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name) + snapshot.match("create-mpu", response) + upload_id = response["UploadId"] + + data = b"aaaa" + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=key_name, + Body=data, + PartNumber=1, + UploadId=upload_id, + ) + snapshot.match("upload-part", upload_part) + + parts = [ + { + "ETag": upload_part["ETag"], + "PartNumber": 1, + } + ] + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + MpuObjectSize=len(data) + 1, + ) + snapshot.match("complete-multipart-wrong-size", e.value.response) + + success = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + MpuObjectSize=len(data), + ) + snapshot.match("complete-multipart-good-size", success) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["Checksum", "ETag"], + ) + snapshot.match("get-object-attrs", object_attrs) + + @markers.aws.validated + @pytest.mark.parametrize("checksum_type", ("COMPOSITE", "FULL_OBJECT")) + def test_multipart_upload_part_copy_checksum( + self, s3_bucket, snapshot, aws_client, checksum_type + ): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + part_key = "test-part-checksum" + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, + Key=part_key, + Body="this is a part", + ) + snapshot.match("put-object", put_object) + + key_name = "test-multipart-checksum" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=key_name, ChecksumAlgorithm="CRC32C", ChecksumType=checksum_type + ) + snapshot.match("create-mpu-checksum-sha256", response) + upload_id = response["UploadId"] + + copy_source_key = f"{s3_bucket}/{part_key}" + upload_part_copy = aws_client.s3.upload_part_copy( + Bucket=s3_bucket, + UploadId=upload_id, + Key=key_name, + PartNumber=1, + CopySource=copy_source_key, + ) + snapshot.match("upload-part-copy", upload_part_copy) + + list_parts = aws_client.s3.list_parts( + Bucket=s3_bucket, + UploadId=upload_id, + Key=key_name, + ) + snapshot.match("list-parts", list_parts) + + # complete with no checksum type specified, just all default values + response = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={ + "Parts": [ + { + "ETag": upload_part_copy["CopyPartResult"]["ETag"], + "PartNumber": 1, + "ChecksumCRC32C": upload_part_copy["CopyPartResult"]["ChecksumCRC32C"], + } + ] + }, + UploadId=upload_id, + ) + snapshot.match("complete-multipart-checksum", response) + + get_object_with_checksum = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("get-object-with-checksum", get_object_with_checksum) + + head_object_with_checksum = aws_client.s3.head_object( + Bucket=s3_bucket, Key=key_name, ChecksumMode="ENABLED" + ) + snapshot.match("head-object-with-checksum", head_object_with_checksum) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=key_name, + ObjectAttributes=["Checksum", "ETag", "ObjectParts"], + ) + snapshot.match("get-object-attrs", object_attrs) + + +def _s3_client_pre_signed_client(conf: Config, endpoint_url: str = None): + if is_aws_cloud(): + return boto3.client("s3", config=conf, endpoint_url=endpoint_url) + + # TODO: create a similar ClientFactory for these parameters + return boto3.client( + "s3", + endpoint_url=endpoint_url, + config=conf, + aws_access_key_id=s3_constants.DEFAULT_PRE_SIGNED_ACCESS_KEY_ID, + aws_secret_access_key=s3_constants.DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY, + ) + + +def _endpoint_url(region: str = "", localstack_host: str = None) -> str: + if not region: + region = AWS_REGION_US_EAST_1 + if is_aws_cloud(): + if region == "us-east-1": + return "https://s3.amazonaws.com" + else: + return f"http://s3.{region}.amazonaws.com" + if region == "us-east-1": + return f"{config.internal_service_url(host=localstack_host or S3_VIRTUAL_HOSTNAME)}" + return config.internal_service_url(host=f"s3.{region}.{LOCALHOST_HOSTNAME}") + + +def _bucket_url(bucket_name: str, region: str = "", localstack_host: str = None) -> str: + return f"{_endpoint_url(region, localstack_host)}/{bucket_name}" + + +def _website_bucket_url(bucket_name: str): + # TODO depending on region the syntax of the website vary (dot vs dash before region) + if is_aws_cloud(): + region = AWS_REGION_US_EAST_1 + return f"http://{bucket_name}.s3-website-{region}.amazonaws.com" + return _bucket_url_vhost( + bucket_name, localstack_host=localstack.config.S3_STATIC_WEBSITE_HOSTNAME + ) + + +def _bucket_url_vhost(bucket_name: str, region: str = "", localstack_host: str = None) -> str: + if not region: + region = AWS_REGION_US_EAST_1 + if is_aws_cloud(): + if region == "us-east-1": + return f"https://{bucket_name}.s3.amazonaws.com" + else: + return f"https://{bucket_name}.s3.{region}.amazonaws.com" + + host_definition = get_localstack_host() + if localstack_host: + host_and_port = f"{localstack_host}:{config.GATEWAY_LISTEN[0].port}" + else: + host_and_port = ( + f"s3.{region}.{host_definition.host_and_port()}" + if region != "us-east-1" + else f"s3.{host_definition.host_and_port()}" + ) + + # TODO might add the region here + return f"{config.get_protocol()}://{bucket_name}.{host_and_port}" + + +def _generate_presigned_url( + client: "S3Client", params: dict, expires: int, client_method: str = "get_object" +) -> str: + return client.generate_presigned_url( + client_method, + Params=params, + ExpiresIn=expires, + ) + + +def _make_url_invalid(url_prefix: str, object_key: str, url: str) -> str: + parsed = urlparse(url) + query_params = parse_qs(parsed.query) + if "Signature" in query_params: + # v2 style + return "{}/{}?AWSAccessKeyId={}&Signature={}&Expires={}".format( + url_prefix, + object_key, + query_params["AWSAccessKeyId"][0], + query_params["Signature"][0], + query_params["Expires"][0], + ) + else: + # v4 style + return ( + "{}/{}?X-Amz-Algorithm=AWS4-HMAC-SHA256&" + "X-Amz-Credential={}&X-Amz-Date={}&" + "X-Amz-Expires={}&X-Amz-SignedHeaders=host&" + "X-Amz-Signature={}" + ).format( + url_prefix, + object_key, + quote(query_params["X-Amz-Credential"][0]).replace("/", "%2F"), + query_params["X-Amz-Date"][0], + query_params["X-Amz-Expires"][0], + query_params["X-Amz-Signature"][0], + ) + + +@pytest.fixture +def presigned_snapshot_transformers(snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("AWSAccessKeyId"), + snapshot.transform.key_value("HostId", reference_replacement=False), + snapshot.transform.key_value("RequestId"), + snapshot.transform.key_value("SignatureProvided"), + snapshot.transform.jsonpath( + "$..Error.StringToSign", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value("StringToSignBytes"), + snapshot.transform.jsonpath( + "$..Error.CanonicalRequest", + value_replacement="", + reference_replacement=False, + ), + snapshot.transform.key_value("CanonicalRequestBytes"), + ] + ) + + +def get_checksum_for_algorithm(algorithm: str, data: bytes) -> str: + # Test our generated checksums + match algorithm: + case "CRC32": + return checksum_crc32(data) + case "CRC32C": + return checksum_crc32c(data) + case "SHA1": + return hash_sha1(data) + case "SHA256": + return hash_sha256(data) + case "CRC64NVME": + return checksum_crc64nvme(data) + case _: + return "" diff --git a/tests/aws/services/s3/test_s3.snapshot.json b/tests/aws/services/s3/test_s3.snapshot.json new file mode 100644 index 0000000000000..349879b0c4896 --- /dev/null +++ b/tests/aws/services/s3/test_s3.snapshot.json @@ -0,0 +1,18232 @@ +{ + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_with_content": { + "recorded-date": "21-01-2025, 18:26:26", + "recorded-content": { + "list-objects": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"86639701cdcc5b39438a5f009bd74cb1\"", + "Key": "test-key-0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"70a37754eb5a2e7db8cd887aaf11cda7\"", + "Key": "test-key-1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"282ff2cb3d9dadeb831bb3ba0128f2f4\"", + "Key": "test-key-2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2b61ddda48445374b35a927b6ae2cd6d\"", + "Key": "test-key-3", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f533f549a84b9d7a381a7ed55c4f46b9\"", + "Key": "test-key-4", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0efcf24eb64fa875c294d05703096b0d\"", + "Key": "test-key-5", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"7b1b88bb19a8c5a6a1d53eaa75108b80\"", + "Key": "test-key-6", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"698fbf838fdda3065e058190398514f8\"", + "Key": "test-key-7", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"96c2178517e273d4001ab7f68fdde969\"", + "Key": "test-key-8", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"da51d6e22a1ae095154e69b07eef731b\"", + "Key": "test-key-9", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 6, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 100, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-buckets": { + "Buckets": [ + { + "CreationDate": "datetime", + "Name": "" + }, + { + "CreationDate": "datetime", + "Name": "" + }, + { + "CreationDate": "datetime", + "Name": "" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_utf8_key": { + "recorded-date": "21-01-2025, 18:26:27", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "zwK7XA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e99a18c428cb38d5f260853678922e03\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32": "zwK7XA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 6, + "ContentType": "binary/octet-stream", + "ETag": "\"e99a18c428cb38d5f260853678922e03\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_metadata_header_character_decoding": { + "recorded-date": "21-01-2025, 18:26:37", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 3, + "ContentType": "binary/octet-stream", + "ETag": "\"acbd18db4cc2f85cedef654fccc4a4d8\"", + "LastModified": "datetime", + "Metadata": { + "__meta_2": "bar", + "test_meta_1": "foo" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_file_multipart": { + "recorded-date": "21-01-2025, 18:26:40", + "recorded-content": { + "get_object": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32": "B63YTw==-1", + "ChecksumType": "COMPOSITE", + "ContentLength": 6144, + "ContentType": "binary/octet-stream", + "ETag": "\"8eabe9d6b43316e840b079170916c079-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_no_such_bucket": { + "recorded-date": "21-01-2025, 18:27:10", + "recorded-content": { + "expected_error": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_no_such_bucket": { + "recorded-date": "21-01-2025, 18:27:11", + "recorded-content": { + "expected_error": { + "Error": { + "BucketName": "does-not-exist-localstack-test", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_notification_configuration_no_such_bucket": { + "recorded-date": "21-01-2025, 18:27:11", + "recorded-content": { + "expected_error": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes": { + "recorded-date": "17-03-2025, 20:02:49", + "recorded-content": { + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "WC+ANw==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e92499db864217242396e8ef766079a9", + "LastModified": "datetime", + "ObjectSize": 7, + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-multiparts-1-part": { + "ETag": "e747540af6911dbc890f8d3e0b48549b-1", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 1 + }, + "ObjectSize": 65, + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-multiparts-2-parts": { + "ETag": "5389a7fb9c7e4b97c90255e2ee5e57f7-2", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 2 + }, + "ObjectSize": 5242965, + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-multiparts-2-parts-checksum": { + "Checksum": { + "ChecksumCRC64NVME": "VV86k746S6o=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "5389a7fb9c7e4b97c90255e2ee5e57f7-2", + "LastModified": "datetime", + "ObjectSize": 5242965, + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_hash_prefix": { + "recorded-date": "21-01-2025, 18:27:44", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "wmkP3w==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"39d0d586a701e199389d954f2d592720\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32": "wmkP3w==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"39d0d586a701e199389d954f2d592720\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_invalid_range_error": { + "recorded-date": "21-01-2025, 18:27:45", + "recorded-content": { + "exc": { + "Error": { + "ActualObjectSize": "8", + "Code": "InvalidRange", + "Message": "The requested range is not satisfiable", + "RangeRequested": "bytes=1024-4096" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 416 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_range_key_not_exists": { + "recorded-date": "21-01-2025, 18:27:47", + "recorded-content": { + "exc": { + "Error": { + "Code": "NoSuchKey", + "Key": "my-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_tagging_empty_list": { + "recorded-date": "21-01-2025, 18:28:08", + "recorded-content": { + "created-object-tags": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "updated-object-tags": { + "TagSet": [ + { + "Key": "tag1", + "Value": "tag1" + }, + { + "Key": "tag2", + "Value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deleted-object-tags": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_after_deleted_in_versioned_bucket": { + "recorded-date": "21-01-2025, 18:28:12", + "recorded-content": { + "get-object": { + "AcceptRanges": "bytes", + "Body": "abcdefgh", + "ChecksumCRC32": "ru8qUA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"e8dc4081b13434b45189a720b77b6818\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-after-delete": { + "Error": { + "Code": "NoSuchKey", + "Key": "my-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC32]": { + "recorded-date": "17-03-2025, 18:27:45", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-crc32 header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The CRC32 you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-generated": { + "ChecksumCRC32": "cZWHwQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-generated": { + "Checksum": { + "ChecksumCRC32": "cZWHwQ==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-autogenerated": { + "ChecksumCRC32": "cZWHwQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-auto-generated": { + "Checksum": { + "ChecksumCRC32": "cZWHwQ==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32": "cZWHwQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC32C]": { + "recorded-date": "17-03-2025, 18:27:58", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-crc32c header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The CRC32C you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-generated": { + "ChecksumCRC32C": "Pf4upw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-generated": { + "Checksum": { + "ChecksumCRC32C": "Pf4upw==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-autogenerated": { + "ChecksumCRC32C": "Pf4upw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-auto-generated": { + "Checksum": { + "ChecksumCRC32C": "Pf4upw==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32C": "Pf4upw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[SHA1]": { + "recorded-date": "17-03-2025, 18:28:09", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-sha1 header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The SHA1 you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-generated": { + "ChecksumSHA1": "B++3uSfJMSHWToQMQ1g6lIJY5Eo=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-generated": { + "Checksum": { + "ChecksumSHA1": "B++3uSfJMSHWToQMQ1g6lIJY5Eo=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-autogenerated": { + "ChecksumSHA1": "B++3uSfJMSHWToQMQ1g6lIJY5Eo=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-auto-generated": { + "Checksum": { + "ChecksumSHA1": "B++3uSfJMSHWToQMQ1g6lIJY5Eo=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumSHA1": "B++3uSfJMSHWToQMQ1g6lIJY5Eo=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[SHA256]": { + "recorded-date": "17-03-2025, 18:28:24", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-sha256 header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The SHA256 you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-generated": { + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-generated": { + "Checksum": { + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-autogenerated": { + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-auto-generated": { + "Checksum": { + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_metadata_replace": { + "recorded-date": "21-01-2025, 18:28:50", + "recorded-content": { + "put_object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLanguage": "en-US", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy_object": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object_copy": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "image/jpg", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "another-key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_content_type_and_metadata": { + "recorded-date": "21-01-2025, 18:29:13", + "recorded-content": { + "put_object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy_object": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object_copy": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy_object_second": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object_second_copy": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_multipart_upload_acls": { + "recorded-date": "21-01-2025, 18:30:13", + "recorded-content": { + "bucket-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/global/AllUsers" + }, + "Permission": "READ" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "permission-acl-key0": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "permission-acl-key1": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "permission-acl-key2": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/global/AllUsers" + }, + "Permission": "READ" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/global/AllUsers" + }, + "Permission": "WRITE" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_expiry": { + "recorded-date": "21-01-2025, 18:30:37", + "recorded-content": { + "head-object-expired": { + "AcceptRanges": "bytes", + "ContentLength": 3, + "ContentType": "binary/octet-stream", + "ETag": "\"acbd18db4cc2f85cedef654fccc4a4d8\"", + "Expires": "datetime", + "ExpiresString": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-not-yet-expired": { + "AcceptRanges": "bytes", + "Body": "foo", + "ChecksumCRC32": "jHNlIQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 3, + "ContentType": "binary/octet-stream", + "ETag": "\"acbd18db4cc2f85cedef654fccc4a4d8\"", + "Expires": "datetime", + "ExpiresString": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_file_with_xml_preamble": { + "recorded-date": "21-01-2025, 18:30:40", + "recorded-content": { + "get_object": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32": "hkzSQw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 45, + "ContentType": "binary/octet-stream", + "ETag": "\"8a793423f1e69103a7056b99e4ad6c0b\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_availability": { + "recorded-date": "21-01-2025, 18:30:41", + "recorded-content": { + "bucket-lifecycle": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "bucket-replication": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_different_location_constraint": { + "recorded-date": "21-01-2025, 18:30:47", + "recorded-content": { + "get-bucket-location-bucket-us-east-1": { + "LocationConstraint": null, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-bucket-constraint-us-east-1": { + "Error": { + "Code": "InvalidLocationConstraint", + "LocationConstraint": "", + "Message": "The specified location-constraint is not valid" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-bucket-constraint-us-east-1-with-None": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-bucket-location-bucket-us-east-2": { + "LocationConstraint": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-bucket-us-east-2-no-constraint-exc": { + "Error": { + "Code": "IllegalLocationConstraintException", + "Message": "The unspecified location constraint is incompatible for the region specific endpoint this request was sent to." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-bucket-us-east-2-constraint-to-us-west-1": { + "Error": { + "Code": "IllegalLocationConstraintException", + "Message": "The location constraint is incompatible for the region specific endpoint this request was sent to." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-bucket-us-west-1-constraint-to-us-east-2": { + "Error": { + "Code": "IllegalLocationConstraintException", + "Message": "The location constraint is incompatible for the region specific endpoint this request was sent to." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-bucket-us-west-1-constraint-to-us-east-1": { + "Error": { + "Code": "IllegalLocationConstraintException", + "Message": "The location constraint is incompatible for the region specific endpoint this request was sent to." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-bucket-location-non-existent-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "LocationConstraint": null, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_with_anon_credentials": { + "recorded-date": "21-01-2025, 18:30:54", + "recorded-content": { + "get_object": { + "AcceptRanges": "bytes", + "Body": "body data", + "ChecksumCRC32": "g/U4Hw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"53ebc26c3ff5decfe9ffc7bdbaa02459\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_putobject_with_multiple_keys": { + "recorded-date": "21-01-2025, 18:30:56", + "recorded-content": { + "get_object": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_delete_bucket_lifecycle_configuration": { + "recorded-date": "21-01-2025, 18:18:18", + "recorded-content": { + "get-bucket-lifecycle-exc-1": { + "Error": { + "BucketName": "", + "Code": "NoSuchLifecycleConfiguration", + "Message": "The lifecycle configuration does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-bucket-lifecycle-no-bucket": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": { + "Days": 7 + }, + "Filter": { + "Prefix": "" + }, + "ID": "wholebucket", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-lifecycle-exc-2": { + "Error": { + "BucketName": "", + "Code": "NoSuchLifecycleConfiguration", + "Message": "The lifecycle configuration does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_delete_lifecycle_configuration_on_bucket_deletion": { + "recorded-date": "21-01-2025, 18:18:20", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": { + "Days": 7 + }, + "Filter": { + "Prefix": "" + }, + "ID": "wholebucket", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-lifecycle-exc": { + "Error": { + "BucketName": "", + "Code": "NoSuchLifecycleConfiguration", + "Message": "The lifecycle configuration does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_object_expiry": { + "recorded-date": "21-01-2025, 18:18:28", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": { + "Prefix": "" + }, + "ID": "rule_number_one", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-expiry": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_range_header_body_length": { + "recorded-date": "21-01-2025, 18:30:58", + "recorded-content": { + "get-object": { + "AcceptRanges": "bytes", + "Body": "", + "ContentLength": 1024, + "ContentRange": "bytes 0-1023/2048", + "ContentType": "binary/octet-stream", + "ETag": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-object-2": { + "AcceptRanges": "bytes", + "Body": "", + "ContentLength": 1024, + "ContentRange": "bytes 1024-2047/2048", + "ContentType": "binary/octet-stream", + "ETag": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_object_tagging": { + "recorded-date": "21-01-2025, 18:31:28", + "recorded-content": { + "get-obj": { + "AcceptRanges": "bytes", + "Body": "something", + "ChecksumCRC32": "Cdox+w==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-after-tag-deletion": { + "AcceptRanges": "bytes", + "Body": "something", + "ChecksumCRC32": "Cdox+w==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys": { + "recorded-date": "21-01-2025, 18:31:31", + "recorded-content": { + "deleted-resp": { + "Deleted": [ + { + "Key": "dummy1" + }, + { + "Key": "dummy2" + }, + { + "Key": "test-key-nonexistent" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys_in_non_existing_bucket": { + "recorded-date": "21-01-2025, 18:31:36", + "recorded-content": { + "error-non-existent-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_request_payer": { + "recorded-date": "21-01-2025, 18:31:42", + "recorded-content": { + "put-bucket-request-payment": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-request-payment": { + "Payer": "Requester", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_request_payer_exceptions": { + "recorded-date": "21-01-2025, 18:31:43", + "recorded-content": { + "wrong-payer-type": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-bucket-name": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_exists": { + "recorded-date": "21-01-2025, 18:31:45", + "recorded-content": { + "get-bucket-cors": { + "CORSRules": [ + { + "AllowedMethods": [ + "GET", + "POST", + "PUT", + "DELETE" + ], + "AllowedOrigins": [ + "localhost" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-not-exists": { + "Error": { + "BucketName": "bucket-not-exists", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_uppercase_key_names": { + "recorded-date": "21-01-2025, 18:31:47", + "recorded-content": { + "response": { + "AcceptRanges": "bytes", + "Body": "something", + "ChecksumCRC32": "Cdox+w==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "wrong-case-key": { + "Error": { + "Code": "NoSuchKey", + "Key": "camelcasekey", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_precondition_failed_error": { + "recorded-date": "21-01-2025, 18:32:22", + "recorded-content": { + "get-object-if-match": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_invalid_content_md5": { + "recorded-date": "21-01-2025, 18:32:49", + "recorded-content": { + "md5-error-0": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "__invalid__", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "md5-error-1": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "000", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "md5-error-2": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "not base64 encoded checksum", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "md5-error-3": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "MTIz", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "md5-error-4": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "dGVzdC1zdHJpbmc=", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "md5-error-bad-digest": { + "Error": { + "CalculatedDigest": "Q3uTDbhLgHnC3YBKcZNrXw==", + "Code": "BadDigest", + "ExpectedDigest": "09891eb590524e35fc73372cddc5d596", + "Message": "The Content-MD5 you specified did not match what we received." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "success-put-object-md5": { + "ChecksumCRC32": "Cdox+w==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-md5-error-0": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "__invalid__", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-md5-error-1": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "000", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-md5-error-2": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "not base64 encoded checksum", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-md5-error-3": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "MTIz", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-md5-error-4": { + "Error": { + "Code": "InvalidDigest", + "Content-MD5": "dGVzdC1zdHJpbmc=", + "Message": "The Content-MD5 you specified was invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-md5-bad-digest": { + "Error": { + "CalculatedDigest": "Q3uTDbhLgHnC3YBKcZNrXw==", + "Code": "BadDigest", + "ExpectedDigest": "CYketZBSTjX8czcs3cXVlg==", + "Message": "The Content-MD5 you specified did not match what we received." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "success-upload-part-md5": { + "ChecksumCRC32": "Cdox+w==", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_upload_download_gzip": { + "recorded-date": "21-01-2025, 18:32:51", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "KBARJw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"5287ceabf01e3e9c080606b5a5b9bf70\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32": "KBARJw==", + "ChecksumType": "FULL_OBJECT", + "ContentEncoding": "gzip", + "ContentLength": 41, + "ContentType": "binary/octet-stream", + "ETag": "\"5287ceabf01e3e9c080606b5a5b9bf70\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_copy_object_etag": { + "recorded-date": "17-03-2025, 23:02:42", + "recorded-content": { + "multipart-upload": { + "Bucket": "", + "ChecksumCRC64NVME": "BsLNlKumA5I=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "Key": "test.file", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "BsLNlKumA5I=", + "ETag": "\"eee506dd7ada7ded524c77e359a0e7c6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "BsLNlKumA5I=", + "ETag": "\"eee506dd7ada7ded524c77e359a0e7c6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-obj": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "BsLNlKumA5I=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"eee506dd7ada7ded524c77e359a0e7c6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_set_external_hostname": { + "recorded-date": "17-03-2025, 21:30:10", + "recorded-content": { + "multipart-upload": { + "Bucket": "", + "ChecksumCRC64NVME": "BsLNlKumA5I=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "Key": "test.file", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "Bucket": "", + "ChecksumCRC64NVME": "BsLNlKumA5I=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "Key": "test.file", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_lambda_integration": { + "recorded-date": "21-01-2025, 18:34:07", + "recorded-content": { + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 0, + "ContentType": "binary/octet-stream", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_uppercase_bucket_name": { + "recorded-date": "21-01-2025, 18:34:09", + "recorded-content": { + "uppercase-bucket": { + "Error": { + "BucketName": "", + "Code": "InvalidBucketName", + "Message": "The specified bucket is not valid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_with_existing_name": { + "recorded-date": "21-01-2025, 18:34:10", + "recorded-content": { + "create-bucket-us-west-1": { + "Error": { + "BucketName": "", + "Code": "BucketAlreadyOwnedByYou", + "Message": "Your previous request to create the named bucket succeeded and you already own it." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "create-bucket-us-east-2": { + "Error": { + "BucketName": "", + "Code": "BucketAlreadyOwnedByYou", + "Message": "Your previous request to create the named bucket succeeded and you already own it." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_does_not_exist": { + "recorded-date": "21-01-2025, 18:34:13", + "recorded-content": { + "list_object": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list_object_vhost": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_head_bucket": { + "recorded-date": "21-01-2025, 18:34:17", + "recorded-content": { + "create_bucket": { + "Location": "/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_bucket_location_constraint": { + "Location": "http://./", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_bucket": { + "AccessPointAlias": false, + "BucketRegion": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_bucket_filtered_header": { + "content-type": "application/xml", + "x-amz-access-point-alias": "false", + "x-amz-bucket-region": "", + "x-amz-id-2": "x-amz-id-2", + "x-amz-request-id": "x-amz-request-id" + }, + "head_bucket_2": { + "AccessPointAlias": false, + "BucketRegion": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_bucket_2_filtered_header": { + "content-type": "application/xml", + "x-amz-access-point-alias": "false", + "x-amz-bucket-region": "", + "x-amz-id-2": "x-amz-id-2", + "x-amz-request-id": "x-amz-request-id" + }, + "head_bucket_not_exist": { + "Error": { + "Code": "404", + "Message": "Not Found" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_name_with_dots": { + "recorded-date": "21-01-2025, 18:34:19", + "recorded-content": { + "list-objects": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "Key": "my-content", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "request-vhost-url-content": { + "ListBucketResult": { + "@xmlns": "http://s3.amazonaws.com/doc/2006-03-01/", + "Contents": { + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "Key": "my-content", + "LastModified": "date", + "Size": "9", + "StorageClass": "STANDARD" + }, + "IsTruncated": "false", + "Marker": null, + "MaxKeys": "1000", + "Name": "", + "Prefix": null + } + }, + "request-path-url-content": { + "ListBucketResult": { + "@xmlns": "http://s3.amazonaws.com/doc/2006-03-01/", + "Contents": { + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "Key": "my-content", + "LastModified": "date", + "Size": "9", + "StorageClass": "STANDARD" + }, + "IsTruncated": "false", + "Marker": null, + "MaxKeys": "1000", + "Name": "", + "Prefix": null + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_more_than_1000_items": { + "recorded-date": "21-01-2025, 18:38:06", + "recorded-content": { + "get_object-1009": { + "AcceptRanges": "bytes", + "Body": "test-1009", + "ChecksumCRC32": "S5CC0w==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"7d2a1f93cc456846faba49b73eefc5b2\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_object-0": { + "AcceptRanges": "bytes", + "Body": "test-0", + "ChecksumCRC32": "XCKz9A==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 6, + "ContentType": "binary/octet-stream", + "ETag": "\"86639701cdcc5b39438a5f009bd74cb1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects": { + "Contents": "", + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "Marker": "", + "MaxKeys": 1000, + "Name": "", + "NextMarker": "test-key-99", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-next_marker": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"469aa468e8b397232fe0754ba11ba9f3\"", + "Key": "test-key-990", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"aa50de431ca7e15fa7f769df3615bac1\"", + "Key": "test-key-991", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"07844c1200a3eeb13dd3885d336c300e\"", + "Key": "test-key-992", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"43a56bbd65ff5cfa706996026b11f627\"", + "Key": "test-key-993", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"05e2fb7108663f7398dfeb41a048bf32\"", + "Key": "test-key-994", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"ca06c6ef5b6317771502c23ae4e941d7\"", + "Key": "test-key-995", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"bd6e51d9b1c43aa30906314e5ed9d857\"", + "Key": "test-key-996", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"3fda8ced7c145b9820e3d95d6458cbb9\"", + "Key": "test-key-997", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b3ed4e42f8e008bfeb879a9b0aeeff23\"", + "Key": "test-key-998", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"880a4a8e1643dc0014d8f0fc297327f4\"", + "Key": "test-key-999", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 8, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "Marker": "test-key-99", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_big_file": { + "recorded-date": "21-01-2025, 18:39:01", + "recorded-content": { + "put_object_key1": { + "ChecksumCRC32": "eH3dJA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a649c4228b2b9e8bfca3510ed9d9a764\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_object_key2": { + "ChecksumCRC32": "TRqKkg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"7095bae098259e0dda4b7acc624de4e2\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object_key1": { + "AcceptRanges": "bytes", + "ContentLength": 10000000, + "ContentType": "binary/octet-stream", + "ETag": "\"7095bae098259e0dda4b7acc624de4e2\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object_key2": { + "AcceptRanges": "bytes", + "ContentLength": 10000000, + "ContentType": "binary/octet-stream", + "ETag": "\"7095bae098259e0dda4b7acc624de4e2\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_versioning_order": { + "recorded-date": "21-01-2025, 18:39:04", + "recorded-content": { + "list_object_versions_before": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_bucket_versioning": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_bucket_versioning": { + "Status": "Enabled", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_object_versions": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"841a2d689ad86bd1611447453c22c6fc\"", + "IsLatest": true, + "Key": "test", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 4, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"841a2d689ad86bd1611447453c22c6fc\"", + "IsLatest": false, + "Key": "test", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 4, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"841a2d689ad86bd1611447453c22c6fc\"", + "IsLatest": true, + "Key": "test2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 4, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_etag_on_get_object_call": { + "recorded-date": "21-01-2025, 18:39:07", + "recorded-content": { + "get_object": { + "AcceptRanges": "bytes", + "Body": "Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... Lorem ipsum dolor sit amet, ... ", + "ChecksumCRC32": "K7RDlw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 960, + "ContentType": "binary/octet-stream", + "ETag": "\"c289c6e309be295fe68af649d1e6c6ec\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_object_range": { + "AcceptRanges": "bytes", + "Body": "Lorem ipsum dolor", + "ContentLength": 17, + "ContentRange": "bytes 0-16/960", + "ContentType": "binary/octet-stream", + "ETag": "\"c289c6e309be295fe68af649d1e6c6ec\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_delete_object_with_version_id": { + "recorded-date": "21-01-2025, 18:39:10", + "recorded-content": { + "get_bucket_versioning": { + "Status": "Enabled", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_objects": { + "Deleted": [ + { + "Key": "aws/s3/testkey1.txt", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_object_versions_after_delete": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c289c6e309be295fe68af649d1e6c6ec\"", + "IsLatest": true, + "Key": "aws/s3/testkey2.txt", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 960, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_bucket_versioning_suspended": { + "Status": "Suspended", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_objects_using_requests_with_acl": { + "recorded-date": "03-08-2023, 04:23:41", + "recorded-content": {} + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_public_objects_using_requests": { + "recorded-date": "21-01-2025, 19:48:17", + "recorded-content": { + "multi-delete-with-requests": { + "DeleteResult": { + "@xmlns": "http://s3.amazonaws.com/doc/2006-03-01/", + "Deleted": [ + { + "Key": "key-created-by-anonymous-1" + }, + { + "Key": "key-created-by-anonymous-2" + } + ] + } + }, + "list-remaining-objects": { + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_objects": { + "recorded-date": "21-01-2025, 18:39:22", + "recorded-content": { + "batch-delete": { + "Deleted": [ + { + "Key": "" + }, + { + "Key": "" + }, + { + "Key": "" + }, + { + "Key": "" + }, + { + "Key": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-remaining-objects": { + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object": { + "recorded-date": "17-03-2025, 21:31:27", + "recorded-content": { + "get_object": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC64NVME": "1BGd19PD4OU=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_copy_md5": { + "recorded-date": "21-01-2025, 18:24:04", + "recorded-content": { + "copy-obj": { + "CopyObjectResult": { + "ChecksumCRC32": "Cdox+w==", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3DeepArchive::test_s3_get_deep_archive_object_restore": { + "recorded-date": "14-08-2023, 22:35:53", + "recorded-content": { + "get-object-invalid-state": { + "Error": { + "Code": "InvalidObjectState", + "Message": "The operation is not valid for the object's storage class", + "StorageClass": "DEEP_ARCHIVE" + }, + "StorageClass": "DEEP_ARCHIVE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "get_object_invalid_state": { + "Error": { + "Code": "InvalidObjectState", + "Message": "The operation is not valid for the object's storage class", + "StorageClass": "DEEP_ARCHIVE" + }, + "StorageClass": "DEEP_ARCHIVE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "restore_object": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 202 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_head_object_fields": { + "recorded-date": "21-01-2025, 18:28:10", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"e8dc4081b13434b45189a720b77b6818\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-404": { + "Error": { + "Code": "404", + "Message": "Not Found" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_bucket_acl": { + "recorded-date": "21-01-2025, 18:30:17", + "recorded-content": { + "get-bucket-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/global/AllUsers" + }, + "Permission": "READ" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-canned-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-grant-acl": { + "Grants": [ + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "READ" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-acp-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "WRITE" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_bucket_acl_exceptions": { + "recorded-date": "21-01-2025, 18:30:22", + "recorded-content": { + "put-bucket-canned-acl": { + "Error": { + "ArgumentName": "x-amz-acl", + "ArgumentValue": "fake-acl", + "Code": "InvalidArgument", + "Message": null + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-grant-acl-fake-uri": { + "Error": { + "ArgumentName": "uri", + "ArgumentValue": "http://acs.amazonaws.com/groups/s3/FakeGroup", + "Code": "InvalidArgument", + "Message": "Invalid group uri" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-grant-acl-fake-key": { + "Error": { + "ArgumentName": "x-amz-grant-write", + "ArgumentValue": "fakekey=\"1234\"", + "Code": "InvalidArgument", + "Message": "Argument format not recognized" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-grant-acl-wrong-id": { + "Error": { + "ArgumentName": "id", + "ArgumentValue": "wrong-id", + "Code": "InvalidArgument", + "Message": "Invalid id" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-acp-acl-1": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-acp-acl-2": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-acp-acl-3": { + "Error": { + "ArgumentName": "CanonicalUser/ID", + "ArgumentValue": "wrong-id", + "Code": "InvalidArgument", + "Message": "Invalid id" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-acp-acl-4": { + "Error": { + "ArgumentName": "Group/URI", + "ArgumentValue": "http://acs.amazonaws.com/groups/s3/FakeGroup", + "Code": "InvalidArgument", + "Message": "Invalid group uri" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-acp-acl-5": { + "Error": { + "ArgumentName": "CanonicalUser/ID", + "ArgumentValue": "wrong-id", + "Code": "InvalidArgument", + "Message": "Invalid id" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-acp-acl-6": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-empty-acp": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-empty": { + "Error": { + "Code": "MissingSecurityHeader", + "Message": "Your request was missing a required header", + "MissingHeaderName": "x-amz-acl" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-two-type-acl": { + "Error": { + "Code": "InvalidRequest", + "Message": "Specifying both Canned ACLs and Header Grants is not allowed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-two-type-acl-acp": { + "Error": { + "Code": "UnexpectedContent", + "Message": "This request does not support content" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_header_overrides": { + "recorded-date": "21-01-2025, 18:39:23", + "recorded-content": { + "get-object": { + "AcceptRanges": "bytes", + "Body": "something", + "CacheControl": "max-age=74", + "ChecksumCRC32": "Cdox+w==", + "ChecksumType": "FULL_OBJECT", + "ContentDisposition": "attachment; filename=\"foo.jpg\"", + "ContentEncoding": "identity", + "ContentLanguage": "de-DE", + "ContentLength": 9, + "ContentType": "image/jpeg", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "Expires": "datetime", + "ExpiresString": "Wed, 21 Oct 2015 07:28:00 GMT", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_object_versioned": { + "recorded-date": "21-01-2025, 18:39:15", + "recorded-content": { + "put-pre-versioned": { + "ChecksumCRC32": "uQZ0CQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e1474add07e050008472599be0883b17\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-pre-versioned": { + "AcceptRanges": "bytes", + "Body": "non-versioned-key", + "ChecksumCRC32": "uQZ0CQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 17, + "ContentType": "binary/octet-stream", + "ETag": "\"e1474add07e050008472599be0883b17\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-pre-versioned": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e1474add07e050008472599be0883b17\"", + "IsLatest": true, + "Key": "non-version-bucket-key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 17, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-post-versioned": { + "AcceptRanges": "bytes", + "Body": "non-versioned-key", + "ChecksumCRC32": "uQZ0CQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 17, + "ContentType": "binary/octet-stream", + "ETag": "\"e1474add07e050008472599be0883b17\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-versioned-1": { + "ChecksumCRC32": "LyTTBg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c43b615a50200509ceccc5f4122da4bf\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-versioned-2": { + "ChecksumCRC32": "304OzQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a26fe9d9854f719b8865291904326b58\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-versioned": { + "AcceptRanges": "bytes", + "Body": "versioned-key-updated", + "ChecksumCRC32": "304OzQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 21, + "ContentType": "binary/octet-stream", + "ETag": "\"a26fe9d9854f719b8865291904326b58\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versioned": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e1474add07e050008472599be0883b17\"", + "IsLatest": true, + "Key": "non-version-bucket-key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 17, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a26fe9d9854f719b8865291904326b58\"", + "IsLatest": true, + "Key": "versioned-bucket-key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 21, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c43b615a50200509ceccc5f4122da4bf\"", + "IsLatest": false, + "Key": "versioned-bucket-key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 13, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-bucket-suspended": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e1474add07e050008472599be0883b17\"", + "IsLatest": true, + "Key": "non-version-bucket-key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 17, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a26fe9d9854f719b8865291904326b58\"", + "IsLatest": true, + "Key": "versioned-bucket-key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 21, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c43b615a50200509ceccc5f4122da4bf\"", + "IsLatest": false, + "Key": "versioned-bucket-key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 13, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-versioned-disabled": { + "AcceptRanges": "bytes", + "Body": "versioned-key-updated", + "ChecksumCRC32": "304OzQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 21, + "ContentType": "binary/octet-stream", + "ETag": "\"a26fe9d9854f719b8865291904326b58\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-non-versioned-disabled": { + "AcceptRanges": "bytes", + "Body": "non-versioned-key", + "ChecksumCRC32": "uQZ0CQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 17, + "ContentType": "binary/octet-stream", + "ETag": "\"e1474add07e050008472599be0883b17\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-non-versioned-post-disable": { + "ChecksumCRC32": "XHqTwA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"6c0a0d0895ef9829b63848d506a68536\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-non-versioned-post-disable": { + "AcceptRanges": "bytes", + "Body": "non-versioned-key-post", + "ChecksumCRC32": "XHqTwA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 22, + "ContentType": "binary/octet-stream", + "ETag": "\"6c0a0d0895ef9829b63848d506a68536\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3-True]": { + "recorded-date": "21-01-2025, 18:23:05", + "recorded-content": { + "with-decoded-content-length": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + }, + "without-decoded-content-length": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3-False]": { + "recorded-date": "21-01-2025, 18:23:07", + "recorded-content": {} + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3v4-True]": { + "recorded-date": "21-01-2025, 18:23:09", + "recorded-content": { + "with-decoded-content-length": { + "Error": { + "Code": "AccessDenied", + "HeadersNotSigned": "x-amz-date, x-amz-decoded-content-length", + "HostId": "host-id", + "Message": "There were headers present in the request which were not signed", + "RequestId": "" + } + }, + "without-decoded-content-length": { + "Error": { + "Code": "AccessDenied", + "HeadersNotSigned": "x-amz-date", + "HostId": "host-id", + "Message": "There were headers present in the request which were not signed", + "RequestId": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3v4-False]": { + "recorded-date": "21-01-2025, 18:23:10", + "recorded-content": {} + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presigned_url_expired[s3]": { + "recorded-date": "21-01-2025, 18:23:17", + "recorded-content": { + "expired-exception": { + "Error": { + "Code": "AccessDenied", + "Expires": "date", + "HostId": "host-id", + "Message": "Request has expired", + "RequestId": "", + "ServerTime": "date" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presigned_url_expired[s3v4]": { + "recorded-date": "21-01-2025, 18:23:23", + "recorded-content": { + "expired-exception": { + "Error": { + "Code": "AccessDenied", + "Expires": "date", + "HostId": "host-id", + "Message": "Request has expired", + "RequestId": "", + "ServerTime": "date", + "X-Amz-Expires": "2" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3-False]": { + "recorded-date": "21-01-2025, 18:24:08", + "recorded-content": { + "expired": { + "Error": { + "Code": "AccessDenied", + "Expires": "date", + "HostId": "host-id", + "Message": "Request has expired", + "RequestId": "", + "ServerTime": "date" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3-True]": { + "recorded-date": "21-01-2025, 18:24:12", + "recorded-content": { + "expired": { + "Error": { + "Code": "AccessDenied", + "Expires": "date", + "HostId": "host-id", + "Message": "Request has expired", + "RequestId": "", + "ServerTime": "date" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3v4-False]": { + "recorded-date": "21-01-2025, 18:24:16", + "recorded-content": { + "expired": { + "Error": { + "Code": "AccessDenied", + "Expires": "date", + "HostId": "host-id", + "Message": "Request has expired", + "RequestId": "", + "ServerTime": "date", + "X-Amz-Expires": "1" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3v4-True]": { + "recorded-date": "21-01-2025, 18:24:20", + "recorded-content": { + "expired": { + "Error": { + "Code": "AccessDenied", + "Expires": "date", + "HostId": "host-id", + "Message": "Request has expired", + "RequestId": "", + "ServerTime": "date", + "X-Amz-Expires": "1" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3-False]": { + "recorded-date": "21-01-2025, 18:24:24", + "recorded-content": { + "invalid-get-1": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + }, + "invalid-put-1": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3-True]": { + "recorded-date": "21-01-2025, 18:24:28", + "recorded-content": { + "invalid-get-1": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + }, + "invalid-put-1": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3v4-False]": { + "recorded-date": "21-01-2025, 18:24:31", + "recorded-content": { + "invalid-get-1": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + }, + "invalid-put-1": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3v4-True]": { + "recorded-date": "21-01-2025, 18:24:35", + "recorded-content": { + "invalid-get-1": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + }, + "invalid-put-1": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_missing_sig_param[s3]": { + "recorded-date": "21-01-2025, 18:23:35", + "recorded-content": { + "missing-param-exception": { + "Error": { + "Code": "AccessDenied", + "HostId": "host-id", + "Message": "Query-string authentication requires the Signature, Expires and AWSAccessKeyId parameters", + "RequestId": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_missing_sig_param[s3v4]": { + "recorded-date": "21-01-2025, 18:23:37", + "recorded-content": { + "missing-param-exception": { + "Error": { + "Code": "AuthorizationQueryParametersError", + "HostId": "host-id", + "Message": "Query-string authentication version 4 requires the X-Amz-Algorithm, X-Amz-Credential, X-Amz-Signature, X-Amz-Date, X-Amz-SignedHeaders, and X-Amz-Expires parameters.", + "RequestId": "" + }, + "StatusCode": 400 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_with_different_headers[s3]": { + "recorded-date": "21-01-2025, 18:23:27", + "recorded-content": { + "content-type-exception": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + }, + "StatusCode": 403 + }, + "content-type-response": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + }, + "StatusCode": 403 + }, + "wrong-content-encoding-response": { + "StatusCode": 200 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_with_different_headers[s3v4]": { + "recorded-date": "21-01-2025, 18:23:31", + "recorded-content": { + "content-type-exception": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + }, + "StatusCode": 403 + }, + "content-type-response": { + "StatusCode": 200 + }, + "wrong-content-encoding-response": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_same_header_and_qs_parameter": { + "recorded-date": "21-01-2025, 18:23:33", + "recorded-content": { + "double-header-query-string": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + }, + "StatusCode": 403 + }, + "override-signed-qs": { + "Error": { + "Code": "AccessDenied", + "HeadersNotSigned": "x-amz-expires", + "HostId": "host-id", + "Message": "There were headers present in the request which were not signed", + "RequestId": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_expires": { + "recorded-date": "17-03-2025, 20:16:24", + "recorded-content": { + "exception": { + "Error": { + "Code": "AccessDenied", + "HostId": "host-id", + "Message": "Invalid according to Policy: Policy expired.", + "RequestId": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_malformed_policy[s3]": { + "recorded-date": "17-03-2025, 20:16:26", + "recorded-content": { + "exception-policy": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_malformed_policy[s3v4]": { + "recorded-date": "17-03-2025, 20:16:27", + "recorded-content": { + "exception-policy": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_signature[s3]": { + "recorded-date": "17-03-2025, 20:16:29", + "recorded-content": { + "exception-missing-signature": { + "Error": { + "ArgumentName": "Signature", + "ArgumentValue": null, + "Code": "InvalidArgument", + "HostId": "host-id", + "Message": "Bucket POST must contain a field named 'Signature'. If it is specified, please check the order of the fields.", + "RequestId": "" + }, + "StatusCode": 400 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_signature[s3v4]": { + "recorded-date": "17-03-2025, 20:16:30", + "recorded-content": { + "exception-missing-signature": { + "Error": { + "ArgumentName": "X-Amz-Signature", + "ArgumentValue": null, + "Code": "InvalidArgument", + "HostId": "host-id", + "Message": "Bucket POST must contain a field named 'X-Amz-Signature'. If it is specified, please check the order of the fields.", + "RequestId": "" + }, + "StatusCode": 400 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_fields[s3]": { + "recorded-date": "17-03-2025, 20:16:32", + "recorded-content": { + "exception-missing-fields": { + "Error": { + "ArgumentName": "AWSAccessKeyId", + "ArgumentValue": null, + "Code": "InvalidArgument", + "HostId": "host-id", + "Message": "Bucket POST must contain a field named 'AWSAccessKeyId'. If it is specified, please check the order of the fields.", + "RequestId": "" + }, + "StatusCode": 400 + }, + "exception-no-sig-related-fields": { + "Error": { + "Code": "AccessDenied", + "HostId": "host-id", + "Message": "Access Denied", + "RequestId": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_fields[s3v4]": { + "recorded-date": "17-03-2025, 20:16:34", + "recorded-content": { + "exception-missing-fields": { + "Error": { + "ArgumentName": "X-Amz-Algorithm", + "ArgumentValue": null, + "Code": "InvalidArgument", + "HostId": "host-id", + "Message": "Bucket POST must contain a field named 'X-Amz-Algorithm'. If it is specified, please check the order of the fields.", + "RequestId": "" + }, + "StatusCode": 400 + }, + "exception-no-sig-related-fields": { + "Error": { + "Code": "AccessDenied", + "HostId": "host-id", + "Message": "Access Denied", + "RequestId": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_validate_website_configuration": { + "recorded-date": "26-08-2023, 00:30:03", + "recorded-content": { + "invalid-website-conf-0": { + "Error": { + "ArgumentName": "IndexDocument", + "ArgumentValue": "/index.html", + "Code": "InvalidArgument", + "Message": "The IndexDocument Suffix is not well formed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-website-conf-1": { + "Error": { + "ArgumentName": "IndexDocument", + "ArgumentValue": null, + "Code": "InvalidArgument", + "Message": "The IndexDocument Suffix is not well formed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-website-conf-2": { + "Error": { + "ArgumentName": "RedirectAllRequestsTo", + "ArgumentValue": "not null", + "Code": "InvalidArgument", + "Message": "RedirectAllRequestsTo cannot be provided in conjunction with other Routing Rules." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-website-conf-3": { + "Error": { + "ArgumentName": "IndexDocument", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "A value for IndexDocument Suffix must be provided if RedirectAllRequestsTo is empty" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-website-conf-4": { + "Error": { + "Code": "InvalidRequest", + "Message": "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-website-conf-5": { + "Error": { + "Code": "InvalidRequest", + "Message": "You can only define ReplaceKeyPrefix or ReplaceKey but not both." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-website-conf-6": { + "Error": { + "Code": "InvalidRequest", + "Message": "Condition cannot be empty. To redirect all requests without a condition, the condition element shouldn't be present." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-website-conf-7": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_crud_website_configuration": { + "recorded-date": "26-08-2023, 00:29:24", + "recorded-content": { + "get-no-such-website-config": { + "Error": { + "BucketName": "", + "Code": "NoSuchWebsiteConfiguration", + "Message": "The specified bucket does not have a website configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "del-no-such-website-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-website-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-website-config": { + "IndexDocument": { + "Suffix": "index.html" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_http_methods": { + "recorded-date": "26-08-2023, 00:30:55", + "recorded-content": { + "not-allowed-post": { + "content": "\n405 Method Not Allowed\n\n

405 Method Not Allowed

\n
    \n
  • Code: MethodNotAllowed
  • \n
  • Message: The specified method is not allowed against this resource.
  • \n
  • Method: POST
  • \n
  • ResourceType: OBJECT
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n" + }, + "not-allowed-delete": { + "content": "\n405 Method Not Allowed\n\n

405 Method Not Allowed

\n
    \n
  • Code: MethodNotAllowed
  • \n
  • Message: The specified method is not allowed against this resource.
  • \n
  • Method: DELETE
  • \n
  • ResourceType: OBJECT
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n" + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_index_lookup": { + "recorded-date": "26-08-2023, 00:31:15", + "recorded-content": { + "404-no-trailing-slash": "\n404 Not Found\n\n

404 Not Found

\n
    \n
  • Code: NoSuchKey
  • \n
  • Message: The specified key does not exist.
  • \n
  • Key: directory-wrong
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n", + "404-with-trailing-slash": "\n404 Not Found\n\n

404 Not Found

\n
    \n
  • Code: NoSuchKey
  • \n
  • Message: The specified key does not exist.
  • \n
  • Key: directory-wrong/index.html
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n" + } + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_404": { + "recorded-date": "26-08-2023, 00:30:33", + "recorded-content": { + "404-no-such-key": "\n404 Not Found\n\n

404 Not Found

\n
    \n
  • Code: NoSuchKey
  • \n
  • Message: The specified key does not exist.
  • \n
  • Key: index.html
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n", + "404-no-such-key-nor-custom": "\n404 Not Found\n\n

404 Not Found

\n
    \n
  • Code: NoSuchKey
  • \n
  • Message: The specified key does not exist.
  • \n
  • Key: index.html
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n

An Error Occurred While Attempting to Retrieve a Custom Error Document

\n
    \n
  • Code: NoSuchKey
  • \n
  • Message: The specified key does not exist.
  • \n
  • Key: error.html
  • \n
\n
\n\n\n" + } + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_no_such_website": { + "recorded-date": "26-08-2023, 00:31:30", + "recorded-content": { + "no-such-bucket": "\n404 Not Found\n\n

404 Not Found

\n
    \n
  • Code: NoSuchBucket
  • \n
  • Message: The specified bucket does not exist
  • \n
  • BucketName:
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n", + "no-such-website-config": "\n404 Not Found\n\n

404 Not Found

\n
    \n
  • Code: NoSuchWebsiteConfiguration
  • \n
  • Message: The specified bucket does not have a website configuration
  • \n
  • BucketName:
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n", + "no-such-website-config-key": "\n404 Not Found\n\n

404 Not Found

\n
    \n
  • Code: NoSuchWebsiteConfiguration
  • \n
  • Message: The specified bucket does not have a website configuration
  • \n
  • BucketName:
  • \n
  • RequestId:
  • \n
  • HostId:
  • \n
\n
\n\n\n" + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_and_list_parts": { + "recorded-date": "17-03-2025, 21:28:50", + "recorded-content": { + "create-multipart": { + "Bucket": "bucket", + "Key": "test-list-parts", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-part-after-created": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "IsTruncated": false, + "Key": "test-list-parts", + "MaxParts": 1000, + "NextPartNumberMarker": 0, + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "PartNumberMarker": 0, + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-all-uploads": { + "Bucket": "bucket", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "test-list-parts", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "test-list-parts", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part": { + "ChecksumCRC32": "axEe0A==", + "ETag": "\"3237c18681adb6a9d843c733ce249480\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-part-after-upload": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "IsTruncated": false, + "Key": "test-list-parts", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"3237c18681adb6a9d843c733ce249480\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 65 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-all-uploads-after": { + "Bucket": "bucket", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "test-list-parts", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "test-list-parts", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart": { + "Bucket": "bucket", + "ChecksumCRC64NVME": "CTXktO4pIQs=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e747540af6911dbc890f8d3e0b48549b-1\"", + "Key": "test-list-parts", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-part-after-complete-exc": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list-all-uploads-completed": { + "Bucket": "bucket", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-multipart-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "CTXktO4pIQs=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 65, + "ContentType": "binary/octet-stream", + "ETag": "\"e747540af6911dbc890f8d3e0b48549b-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-multipart-checksum": { + "AcceptRanges": "bytes", + "Body": "upload-part-1upload-part-1upload-part-1upload-part-1upload-part-1", + "ChecksumCRC64NVME": "CTXktO4pIQs=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 65, + "ContentType": "binary/octet-stream", + "ETag": "\"e747540af6911dbc890f8d3e0b48549b-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_content_language_disposition": { + "recorded-date": "21-01-2025, 18:26:29", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "zwK7XA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e99a18c428cb38d5f260853678922e03\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-headers": { + "HTTPHeaders": { + "content-length": "0", + "date": "date", + "etag": "\"e99a18c428cb38d5f260853678922e03\"", + "server": "server", + "x-amz-checksum-crc32": "zwK7XA==", + "x-amz-checksum-type": "FULL_OBJECT", + "x-amz-id-2": "id-2", + "x-amz-request-id": "request-id", + "x-amz-server-side-encryption": "AES256" + }, + "HTTPStatusCode": 200, + "HostId": "host-id", + "RequestId": "request-id", + "RetryAttempts": 0 + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "", + "CacheControl": "no-cache", + "ChecksumCRC32": "zwK7XA==", + "ChecksumType": "FULL_OBJECT", + "ContentDisposition": "attachment; filename=\"foo.jpg\"", + "ContentLanguage": "de", + "ContentLength": 6, + "ContentType": "binary/octet-stream", + "ETag": "\"e99a18c428cb38d5f260853678922e03\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-headers": { + "ChecksumAlgorithm": "crc32", + "HTTPHeaders": { + "accept-ranges": "bytes", + "cache-control": "no-cache", + "content-disposition": "attachment; filename=\"foo.jpg\"", + "content-language": "de", + "content-length": "6", + "content-type": "binary/octet-stream", + "date": "date", + "etag": "\"e99a18c428cb38d5f260853678922e03\"", + "last-modified": "last-modified", + "server": "server", + "x-amz-checksum-crc32": "zwK7XA==", + "x-amz-checksum-type": "FULL_OBJECT", + "x-amz-id-2": "id-2", + "x-amz-request-id": "request-id", + "x-amz-server-side-encryption": "AES256" + }, + "HTTPStatusCode": 200, + "HostId": "host-id", + "RequestId": "request-id", + "RetryAttempts": 0 + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_kms": { + "recorded-date": "21-01-2025, 18:26:17", + "recorded-content": { + "get-object": { + "AcceptRanges": "bytes", + "Body": "hello world", + "ChecksumCRC32": "DUoRhQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"5eb63bbbe01eeed093cb22bb8f5acdc3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "BucketKeyEnabled": true, + "CopyObjectResult": { + "ChecksumCRC32": "DUoRhQ==", + "ETag": "copy-etag", + "LastModified": "datetime" + }, + "SSEKMSKeyId": "", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copied-object": { + "AcceptRanges": "bytes", + "Body": "hello world", + "BucketKeyEnabled": true, + "ChecksumCRC32": "DUoRhQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "etag", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketReplication::test_replication_config": { + "recorded-date": "29-08-2024, 14:09:55", + "recorded-content": { + "expected_error_no_replication_set": { + "Error": { + "BucketName": "", + "Code": "ReplicationConfigurationNotFoundError", + "Message": "The replication configuration was not found" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "expected_error_versioning_not_enabled": { + "Error": { + "Code": "InvalidRequest", + "Message": "Versioning must be 'Enabled' on the bucket to apply a replication configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-replication": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-replication": { + "ReplicationConfiguration": { + "Role": "role", + "Rules": [ + { + "DeleteMarkerReplication": { + "Status": "Disabled" + }, + "Destination": { + "Bucket": "dest-bucket" + }, + "Filter": { + "Prefix": "Tax" + }, + "ID": "id", + "Priority": 1, + "Status": "Enabled" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-empty-bucket-replication-rules": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-bucket-replication": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-bucket-replication-idempotent": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys_quiet": { + "recorded-date": "21-01-2025, 18:31:30", + "recorded-content": { + "deleted-resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketReplication::test_replication_config_without_filter": { + "recorded-date": "03-08-2023, 04:13:02", + "recorded-content": { + "expected_error_dest_does_not_exist": { + "Error": { + "Code": "InvalidRequest", + "Message": "Destination bucket must have versioning enabled." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "expected_error_dest_versioning_disabled": { + "Error": { + "Code": "InvalidRequest", + "Message": "Destination bucket must have versioning enabled." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-replication": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-replication": { + "ReplicationConfiguration": { + "Role": "role", + "Rules": [ + { + "DeleteMarkerReplication": { + "Status": "Disabled" + }, + "Destination": { + "Bucket": "dest-bucket", + "Metrics": { + "EventThreshold": { + "Minutes": 15 + }, + "Status": "Enabled" + }, + "ReplicationTime": { + "Status": "Enabled", + "Time": { + "Minutes": 15 + } + }, + "StorageClass": "STANDARD" + }, + "Filter": {}, + "ID": "rtc", + "Priority": 0, + "Status": "Disabled" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_validate_kms_key": { + "recorded-date": "21-01-2025, 18:39:28", + "recorded-content": { + "create-kms-key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "put-obj-wrong-kms-key": { + "Error": { + "Code": "KMS.NotFoundException", + "Message": "Invalid keyId 'fake-key-id'" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-wrong-kms-key-real-uuid": { + "Error": { + "Code": "KMS.NotFoundException", + "Message": "Key 'arn::kms::111111111111:key/134f2428-cec1-4b25-a1ae-9048164dba47' does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-wrong-kms-key-real-uuid-arn": { + "Error": { + "Code": "KMS.NotFoundException", + "Message": "Key 'arn::kms::111111111111:key/134f2428-cec1-4b25-a1ae-9048164dba47' does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-different-region-kms-key": { + "Error": { + "Code": "KMS.NotFoundException", + "Message": "Invalid arn " + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-different-region-kms-key-no-arn": { + "Error": { + "Code": "KMS.NotFoundException", + "Message": "Key 'arn::kms::111111111111:key/' does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-multipart-wrong-kms-key": { + "Error": { + "Code": "KMS.NotFoundException", + "Message": "Invalid keyId 'fake-key-id'" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-obj-wrong-kms-key": { + "Error": { + "Code": "KMS.NotFoundException", + "Message": "Invalid keyId 'fake-key-id'" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_complete_multipart_parts_order": { + "recorded-date": "17-03-2025, 21:30:44", + "recorded-content": { + "upload-part-negative-part-number": { + "Error": { + "ArgumentName": "partNumber", + "ArgumentValue": "-1", + "Code": "InvalidArgument", + "Message": "Part number must be an integer between 1 and 10000, inclusive" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-unordered": { + "Error": { + "Code": "InvalidPartOrder", + "Message": "The list of parts was not in ascending order. Parts must be ordered by part number.", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-ordered": { + "Bucket": "bucket", + "ChecksumCRC64NVME": "Y8LhTZLfnr0=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c7cb0938a47e31f70cf07028d22e6913-3\"", + "Key": "test-order-parts", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-with-step-2": { + "Bucket": "bucket", + "ChecksumCRC64NVME": "Y8LhTZLfnr0=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c7cb0938a47e31f70cf07028d22e6913-3\"", + "Key": "key-sequence-with-step-2", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[STANDARD-True]": { + "recorded-date": "21-01-2025, 18:41:18", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "body-test", + "ChecksumCRC32": "DulxwQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"43ad557eaff4fdc42b9886b5a68147ab\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[STANDARD_IA-True]": { + "recorded-date": "21-01-2025, 18:41:20", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "STANDARD_IA", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "body-test", + "ChecksumCRC32": "DulxwQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"43ad557eaff4fdc42b9886b5a68147ab\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "StorageClass": "STANDARD_IA", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[GLACIER-False]": { + "recorded-date": "21-01-2025, 18:41:22", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "GLACIER", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "Error": { + "Code": "InvalidObjectState", + "Message": "The operation is not valid for the object's storage class", + "StorageClass": "GLACIER" + }, + "StorageClass": "GLACIER", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[GLACIER_IR-True]": { + "recorded-date": "21-01-2025, 18:41:24", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "GLACIER_IR", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "body-test", + "ChecksumCRC32": "DulxwQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"43ad557eaff4fdc42b9886b5a68147ab\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "StorageClass": "GLACIER_IR", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[ONEZONE_IA-True]": { + "recorded-date": "21-01-2025, 18:41:28", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "ONEZONE_IA", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "body-test", + "ChecksumCRC32": "DulxwQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"43ad557eaff4fdc42b9886b5a68147ab\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "StorageClass": "ONEZONE_IA", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[INTELLIGENT_TIERING-True]": { + "recorded-date": "21-01-2025, 18:41:30", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "INTELLIGENT_TIERING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "body-test", + "ChecksumCRC32": "DulxwQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"43ad557eaff4fdc42b9886b5a68147ab\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "StorageClass": "INTELLIGENT_TIERING", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[DEEP_ARCHIVE-False]": { + "recorded-date": "21-01-2025, 18:41:32", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "DEEP_ARCHIVE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "Error": { + "Code": "InvalidObjectState", + "Message": "The operation is not valid for the object's storage class", + "StorageClass": "DEEP_ARCHIVE" + }, + "StorageClass": "DEEP_ARCHIVE", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[REDUCED_REDUNDANCY-True]": { + "recorded-date": "21-01-2025, 18:41:26", + "recorded-content": { + "get-object-storage-class": { + "LastModified": "datetime", + "StorageClass": "REDUCED_REDUNDANCY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "body-test", + "ChecksumCRC32": "DulxwQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"43ad557eaff4fdc42b9886b5a68147ab\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "StorageClass": "REDUCED_REDUNDANCY", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class_outposts": { + "recorded-date": "21-01-2025, 18:41:34", + "recorded-content": { + "put-object-outposts": { + "Error": { + "Code": "InvalidStorageClass", + "Message": "The storage class you specified is not valid", + "StorageClassRequested": "OUTPOSTS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-multipart-outposts-exc": { + "Error": { + "Code": "InvalidStorageClass", + "Message": "The storage class you specified is not valid", + "StorageClassRequested": "OUTPOSTS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[SHA256]": { + "recorded-date": "17-03-2025, 18:28:54", + "recorded-content": { + "put-object": { + "ChecksumSHA256": "1YQo81vx2VFUl0q5ccWISq8AkSBQQ0WO80S82TmfdIQ=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumSHA256": "1YQo81vx2VFUl0q5ccWISq8AkSBQQ0WO80S82TmfdIQ=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumSHA256": "1YQo81vx2VFUl0q5ccWISq8AkSBQQ0WO80S82TmfdIQ=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumSHA256": "1YQo81vx2VFUl0q5ccWISq8AkSBQQ0WO80S82TmfdIQ=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumSHA256": "1YQo81vx2VFUl0q5ccWISq8AkSBQQ0WO80S82TmfdIQ=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[None]": { + "recorded-date": "17-03-2025, 18:29:00", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_with_content_encoding": { + "recorded-date": "17-03-2025, 18:29:02", + "recorded-content": { + "put-object": { + "ChecksumSHA256": "WO7lLNG8Mn/d4GkX4DqZXqeaVHJCN+BxvMNJXLOhukg=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"5287ceabf01e3e9c080606b5a5b9bf70\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumSHA256": "WO7lLNG8Mn/d4GkX4DqZXqeaVHJCN+BxvMNJXLOhukg=", + "ChecksumType": "FULL_OBJECT", + "ContentEncoding": "gzip", + "ContentLength": 41, + "ContentType": "binary/octet-stream", + "ETag": "\"5287ceabf01e3e9c080606b5a5b9bf70\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumSHA256": "WO7lLNG8Mn/d4GkX4DqZXqeaVHJCN+BxvMNJXLOhukg=", + "ChecksumType": "FULL_OBJECT", + "ContentEncoding": "gzip", + "ContentLength": 41, + "ContentType": "binary/octet-stream", + "ETag": "\"5287ceabf01e3e9c080606b5a5b9bf70\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumSHA256": "WO7lLNG8Mn/d4GkX4DqZXqeaVHJCN+BxvMNJXLOhukg=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_parts_checksum_exceptions_composite": { + "recorded-date": "15-06-2025, 17:11:24", + "recorded-content": { + "create-mpu-wrong-checksum-algo": { + "Error": { + "Code": "InvalidRequest", + "Message": "Checksum algorithm provided is unsupported. Please try again with any of the valid types: [CRC32, CRC32C, SHA1, SHA256]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-mpu-no-checksum-algo-with-type": { + "Error": { + "Code": "InvalidRequest", + "Message": "The x-amz-checksum-type header can only be used with the x-amz-checksum-algorithm header." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-mpu-composite-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-exc", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts": { + "Bucket": "bucket", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "test-multipart-checksum-exc", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "COMPOSITE", + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "Key": "test-multipart-checksum-exc", + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-no-checksum-ok": { + "ChecksumCRC32": "NSRBwg==", + "ETag": "\"900150983cd24fb0d6963f7d28e17f72\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-part-with-checksum": { + "Error": { + "Code": "BadDigest", + "Message": "The sha256 you specified for part 1 did not match what we received." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-part-with-bad-checksum-type": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using the COMPOSITE checksum mode. The complete request must use the same checksum mode." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-mpu-with-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA256", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-exc", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-different-checksum-exc": { + "Error": { + "Code": "InvalidRequest", + "Message": "Checksum Type mismatch occurred, expected checksum Type: sha256, actual checksum Type: crc32" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_validate_kms_key_state": { + "recorded-date": "21-01-2025, 18:39:38", + "recorded-content": { + "create-kms-key": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "CUSTOMER", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "success-put-object-sse": { + "ChecksumCRC32": "KHcEKQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e1ae6a8d27c2b77e7dcd9b4c8a3b579d\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "success-get-object-sse": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "ChecksumCRC32": "KHcEKQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"e1ae6a8d27c2b77e7dcd9b4c8a3b579d\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-disabled-key": { + "Error": { + "Code": "KMS.DisabledException", + "Message": "arn::kms::111111111111:key/ is disabled." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-disabled-key": { + "Error": { + "Code": "KMS.DisabledException", + "Message": "arn::kms::111111111111:key/ is disabled." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-obj-pending-deletion-key": { + "Error": { + "Code": "KMS.KMSInvalidStateException", + "Message": "arn::kms::111111111111:key/ is pending deletion." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_keys_in_versioned_bucket": { + "recorded-date": "21-01-2025, 18:31:34", + "recorded-content": { + "list-objects-v2": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d28473b5c0d7abeb397551aa2fe42be7\"", + "Key": "test-key-versioned", + "LastModified": "datetime", + "Size": 12, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object": { + "Deleted": [ + { + "DeleteMarker": true, + "DeleteMarkerVersionId": "", + "Key": "test-key-versioned" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version": { + "DeleteMarkers": [ + { + "IsLatest": true, + "Key": "test-key-versioned", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d28473b5c0d7abeb397551aa2fe42be7\"", + "IsLatest": false, + "Key": "test-key-versioned", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 12, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "IsLatest": false, + "Key": "test-key-versioned", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-version": { + "Deleted": [ + { + "Key": "test-key-versioned", + "VersionId": "" + }, + { + "Key": "test-key-versioned", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-after-delete": { + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 0, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-after-delete": { + "DeleteMarkers": [ + { + "IsLatest": true, + "Key": "test-key-versioned", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-delete-marker": { + "Deleted": [ + { + "DeleteMarker": true, + "DeleteMarkerVersionId": "", + "Key": "test-key-versioned", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_acl_on_delete_marker": { + "recorded-date": "21-01-2025, 18:31:40", + "recorded-content": { + "put-obj-1": { + "ChecksumCRC32": "Cdox+w==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"437b930db84b8079c2dd804a71936b5f\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-2": { + "ChecksumCRC32": "Z6wjEQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d28473b5c0d7abeb397551aa2fe42be7\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-acl-delete-marker": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "PUT", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "get-acl-delete-marker": { + "Error": { + "Code": "NoSuchKey", + "Key": "test-key-versioned", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-acl-delete-marker-version-id": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "PUT", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "get-acl-delete-marker-version-id": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "GET", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes_versioned": { + "recorded-date": "21-01-2025, 18:27:33", + "recorded-content": { + "put-obj-v1": { + "ChecksumCRC32": "WC+ANw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e92499db864217242396e8ef766079a9\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-v2": { + "ChecksumCRC32": "44ffIg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d4ca1ed7571e2e7b1f1c375bd50fa220\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "44ffIg==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "d4ca1ed7571e2e7b1f1c375bd50fa220", + "LastModified": "datetime", + "ObjectSize": 9, + "StorageClass": "STANDARD", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-key": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "deleted-object-attrs": { + "Error": { + "Code": "NoSuchKey", + "Key": "key-attrs-versioned", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-object-attrs-v1": { + "ETag": "e92499db864217242396e8ef766079a9", + "LastModified": "datetime", + "ObjectSize": 7, + "StorageClass": "STANDARD", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_list_objects_v2_with_prefix": { + "recorded-date": "26-10-2023, 18:10:52", + "recorded-content": { + "list-objects-v2-1": { + "Contents": [ + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/bar/foo/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-2": { + "Contents": [ + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/foo", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-3": { + "Contents": [ + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/foo/bar", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-no-encoding": { + "ListBucketResult": { + "Contents": [ + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "date", + "Size": "11", + "StorageClass": "STANDARD" + }, + { + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "date", + "Size": "11", + "StorageClass": "STANDARD" + } + ], + "IsTruncated": "false", + "KeyCount": "2", + "MaxKeys": "1000", + "Name": "", + "Prefix": "test/foo" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_multipart_upload_sse": { + "recorded-date": "17-03-2025, 21:31:09", + "recorded-content": { + "multi-sse-create-multipart": { + "Bucket": "", + "BucketKeyEnabled": true, + "Key": "test-sse-field-multipart", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multi-sse-upload-part": { + "BucketKeyEnabled": true, + "ChecksumCRC32": "KHcEKQ==", + "ETag": "\"f14c3faa9237b95312866412ecf80f93\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multi-sse-compete-multipart": { + "Bucket": "", + "BucketKeyEnabled": true, + "ETag": "\"a35f284c9ce41d60640bd70f8069a276-1\"", + "Key": "test-sse-field-multipart", + "Location": "", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "BucketKeyEnabled": true, + "ChecksumCRC64NVME": "Kr7StX/uFlQ=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"a35f284c9ce41d60640bd70f8069a276-1\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_bucket_key_default": { + "recorded-date": "21-01-2025, 18:42:37", + "recorded-content": { + "put-obj-default-before-setting": { + "ChecksumCRC32": "KHcEKQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"42832cdec7083e70a9cd6f2d5852e004\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-default-before-setting": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "ChecksumCRC32": "KHcEKQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"42832cdec7083e70a9cd6f2d5852e004\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-encryption": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-encryption": { + "ServerSideEncryptionConfiguration": { + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "KMSMasterKeyID": "", + "SSEAlgorithm": "aws:kms" + }, + "BucketKeyEnabled": true + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-default-after-setting": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "ChecksumCRC32": "KHcEKQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"42832cdec7083e70a9cd6f2d5852e004\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-after-setting": { + "BucketKeyEnabled": true, + "ChecksumCRC32": "KHcEKQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"7ebdc638d81c4fcc1479f682db3c21d3\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-after-setting": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "BucketKeyEnabled": true, + "ChecksumCRC32": "KHcEKQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"7ebdc638d81c4fcc1479f682db3c21d3\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_default_kms_key": { + "recorded-date": "03-04-2023, 22:16:19", + "recorded-content": { + "put-obj-default-kms-s3-key": { + "ETag": "\"dbcc38f7b88c4c92ee5f9484d181ff51\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-default-kms-s3-key": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"dbcc38f7b88c4c92ee5f9484d181ff51\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-default-kms-s3-key-bucket-2": { + "ETag": "\"9c8f3cc18cd06e60966725b1c5996554\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-default-kms-s3-key-bucket-2": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"9c8f3cc18cd06e60966725b1c5996554\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-encryption-default-kms-s3-key": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-encryption-default-kms-s3-key": { + "ServerSideEncryptionConfiguration": { + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms" + }, + "BucketKeyEnabled": true + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-default-kms-s3-key-from-bucket": { + "BucketKeyEnabled": true, + "ETag": "\"671ef6aeba0f69c2391cf0a1b094d0aa\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-default-kms-s3-key-from-bucket": { + "AcceptRanges": "bytes", + "Body": "test-sse", + "BucketKeyEnabled": true, + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"671ef6aeba0f69c2391cf0a1b094d0aa\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place": { + "recorded-date": "21-01-2025, 18:29:17", + "recorded-content": { + "put_object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "LastModified": "datetime", + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-no-change": { + "Error": { + "Code": "InvalidRequest", + "Message": "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-object-in-place-with-storage-class": { + "CopyObjectResult": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "LastModified": "datetime", + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-acl": { + "Error": { + "Code": "InvalidRequest", + "Message": "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_metadata_directive": { + "recorded-date": "21-01-2025, 18:29:37", + "recorded-content": { + "put_object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "no-metadata-directive-fail": { + "Error": { + "Code": "InvalidRequest", + "Message": "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-replace-directive": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-replace-directive": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key2": "value2" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-copy-directive": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-copy-directive": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key2": "value2" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-copy-directive-ignore": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-copy-directive-ignore": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key2": "value2" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-replace-directive-empty": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-replace-directive-empty": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_storage_class": { + "recorded-date": "21-01-2025, 18:29:28", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "LastModified": "datetime", + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-storage-class": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "LastModified": "datetime", + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_with_encryption": { + "recorded-date": "21-01-2025, 18:29:32", + "recorded-content": { + "put-object-with-kms-encryption": { + "BucketKeyEnabled": true, + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"526373ef70063d48e68f588bbdfec7ef\"", + "SSEKMSKeyId": "", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object": { + "AcceptRanges": "bytes", + "BucketKeyEnabled": true, + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"526373ef70063d48e68f588bbdfec7ef\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-sse": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"d83cbdabd3c2ff281f17810fd677232c\"", + "LastModified": "datetime" + }, + "SSEKMSKeyId": "", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-copy-with-sse": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"d83cbdabd3c2ff281f17810fd677232c\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-without-kms-sse": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-copy-without-kms-sse": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-aes": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-copy-with-aes": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_storage_class": { + "recorded-date": "21-01-2025, 18:29:41", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "StorageClass": "STANDARD_IA", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "LastModified": "datetime", + "StorageClass": "STANDARD_IA", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-storage-class": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "LastModified": "datetime", + "StorageClass": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exc-invalid-request-storage-class": { + "Error": { + "Code": "InvalidRequest", + "Message": "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_metadata_directive_copy": { + "recorded-date": "21-01-2025, 18:28:52", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object": { + "AcceptRanges": "bytes", + "ContentLanguage": "en-US", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-copy": { + "AcceptRanges": "bytes", + "ContentLanguage": "en-US", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_website_redirect_location": { + "recorded-date": "21-01-2025, 18:29:39", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "WebsiteRedirectLocation": "/test/direct", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-website-redirection": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-after-copy": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "WebsiteRedirectLocation": "/test/direct", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_in_place_with_bucket_encryption": { + "recorded-date": "21-01-2025, 18:29:34", + "recorded-content": { + "put-bucket-encryption": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj": { + "CopyObjectResult": { + "ChecksumCRC32": "AAAAAA==", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC32]": { + "recorded-date": "17-03-2025, 18:28:46", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32": "lVk/nw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC32C]": { + "recorded-date": "17-03-2025, 18:28:48", + "recorded-content": { + "put-object": { + "ChecksumCRC32C": "Fz3epA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32C": "Fz3epA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32C": "Fz3epA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC32C": "Fz3epA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32C": "Fz3epA==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[SHA1]": { + "recorded-date": "17-03-2025, 18:28:51", + "recorded-content": { + "put-object": { + "ChecksumSHA1": "jbXkHAsXUrubtL3dqDQ4w+7WXc0=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumSHA1": "jbXkHAsXUrubtL3dqDQ4w+7WXc0=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumSHA1": "jbXkHAsXUrubtL3dqDQ4w+7WXc0=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumSHA1": "jbXkHAsXUrubtL3dqDQ4w+7WXc0=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumSHA1": "jbXkHAsXUrubtL3dqDQ4w+7WXc0=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_analytics_configurations": { + "recorded-date": "21-01-2025, 18:42:41", + "recorded-content": { + "put_config_with_storage_analysis_err": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_config_with_storage_analysis_err": { + "Error": { + "Code": "NoSuchConfiguration", + "Message": "The specified configuration does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_config_with_storage_analysis_err": { + "Error": { + "Code": "NoSuchConfiguration", + "Message": "The specified configuration does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put_config_with_storage_analysis_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_config_with_storage_analysis_1": { + "AnalyticsConfiguration": { + "Filter": { + "Prefix": "test_ls" + }, + "Id": "config_with_storage_analysis_1", + "StorageClassAnalysis": { + "DataExport": { + "Destination": { + "S3BucketDestination": { + "Bucket": "", + "Format": "CSV", + "Prefix": "test" + } + }, + "OutputSchemaVersion": "V_1" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_config_with_storage_analysis_2": { + "AnalyticsConfiguration": { + "Filter": { + "Prefix": "test_ls_2" + }, + "Id": "config_with_storage_analysis_1", + "StorageClassAnalysis": { + "DataExport": { + "Destination": { + "S3BucketDestination": { + "Bucket": "", + "Format": "CSV", + "Prefix": "test" + } + }, + "OutputSchemaVersion": "V_1" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_config_with_storage_analysis_3": { + "AnalyticsConfiguration": { + "Filter": { + "Prefix": "test_ls_3" + }, + "Id": "config_with_storage_analysis_2", + "StorageClassAnalysis": { + "DataExport": { + "Destination": { + "S3BucketDestination": { + "Bucket": "", + "Format": "CSV", + "Prefix": "test" + } + }, + "OutputSchemaVersion": "V_1" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_config_with_storage_analysis_1": { + "AnalyticsConfigurationList": [ + { + "Filter": { + "Prefix": "test_ls_2" + }, + "Id": "config_with_storage_analysis_1", + "StorageClassAnalysis": { + "DataExport": { + "Destination": { + "S3BucketDestination": { + "Bucket": "", + "Format": "CSV", + "Prefix": "test" + } + }, + "OutputSchemaVersion": "V_1" + } + } + }, + { + "Filter": { + "Prefix": "test_ls_3" + }, + "Id": "config_with_storage_analysis_2", + "StorageClassAnalysis": { + "DataExport": { + "Destination": { + "S3BucketDestination": { + "Bucket": "", + "Format": "CSV", + "Prefix": "test" + } + }, + "OutputSchemaVersion": "V_1" + } + } + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_config_with_storage_analysis_2": { + "AnalyticsConfigurationList": [ + { + "Filter": { + "Prefix": "test_ls_2" + }, + "Id": "config_with_storage_analysis_1", + "StorageClassAnalysis": { + "DataExport": { + "Destination": { + "S3BucketDestination": { + "Bucket": "", + "Format": "CSV", + "Prefix": "test" + } + }, + "OutputSchemaVersion": "V_1" + } + } + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_intelligent_tier_config": { + "recorded-date": "21-01-2025, 19:48:46", + "recorded-content": { + "put_bucket_intelligent_tiering_configuration_err_1`": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_bucket_intelligent_tiering_configuration_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_bucket_intelligent_tiering_configuration_1": { + "IntelligentTieringConfiguration": { + "Filter": { + "Prefix": "test1" + }, + "Id": "test1", + "Status": "Enabled", + "Tierings": [ + { + "AccessTier": "ARCHIVE_ACCESS", + "Days": 90 + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_bucket_intelligent_tiering_configurations_1": { + "IntelligentTieringConfigurationList": [ + { + "Filter": { + "Prefix": "test1" + }, + "Id": "test1", + "Status": "Enabled", + "Tierings": [ + { + "AccessTier": "ARCHIVE_ACCESS", + "Days": 90 + } + ] + }, + { + "Filter": { + "Prefix": "test2" + }, + "Id": "test2", + "Status": "Enabled", + "Tierings": [ + { + "AccessTier": "ARCHIVE_ACCESS", + "Days": 90 + } + ] + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_bucket_intelligent_tiering_configurations_2": { + "IntelligentTieringConfigurationList": [ + { + "Filter": { + "Prefix": "testupdate" + }, + "Id": "test1", + "Status": "Enabled", + "Tierings": [ + { + "AccessTier": "ARCHIVE_ACCESS", + "Days": 90 + } + ] + }, + { + "Filter": { + "Prefix": "test2" + }, + "Id": "test2", + "Status": "Enabled", + "Tierings": [ + { + "AccessTier": "ARCHIVE_ACCESS", + "Days": 90 + } + ] + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_bucket_intelligent_tiering_configuration_err_1": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete_bucket_intelligent_tiering_configuration_err_2": { + "Error": { + "Code": "NoSuchConfiguration", + "Message": "The specified configuration does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list_bucket_intelligent_tiering_configurations_3": { + "IntelligentTieringConfigurationList": [ + { + "Filter": { + "Prefix": "test2" + }, + "Id": "test2", + "Status": "Enabled", + "Tierings": [ + { + "AccessTier": "ARCHIVE_ACCESS", + "Days": 90 + } + ] + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_no_such_upload": { + "recorded-date": "21-01-2025, 18:27:38", + "recorded-content": { + "upload-exc": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "fakeid" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "complete-exc": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "fakeid" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "abort-exc": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "fakeid" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC32]": { + "recorded-date": "24-01-2025, 19:06:29", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-checksum": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC32C]": { + "recorded-date": "24-01-2025, 19:06:31", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-checksum": { + "CopyObjectResult": { + "ChecksumCRC32C": "078Ilw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC32C": "078Ilw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumCRC32C": "078Ilw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[SHA1]": { + "recorded-date": "24-01-2025, 19:06:34", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-checksum": { + "CopyObjectResult": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[SHA256]": { + "recorded-date": "24-01-2025, 19:06:36", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-checksum": { + "CopyObjectResult": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[file%2Fname]": { + "recorded-date": "21-01-2025, 18:26:42", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "file%2Fname", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test@key/]": { + "recorded-date": "21-01-2025, 18:26:44", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test@key/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%123]": { + "recorded-date": "21-01-2025, 18:26:46", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test%123", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%percent]": { + "recorded-date": "21-01-2025, 18:26:48", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test%percent", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test key/]": { + "recorded-date": "21-01-2025, 18:26:50", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test key/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test key//]": { + "recorded-date": "21-01-2025, 18:26:52", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test key//", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%123/]": { + "recorded-date": "21-01-2025, 18:26:54", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test%123/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[a/%F0%9F%98%80/]": { + "recorded-date": "21-01-2025, 18:26:56", + "recorded-content": { + "put-object-special-char": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "a/%F0%9F%98%80/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-special-char": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-object-special-char": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_headers": { + "recorded-date": "21-01-2025, 18:42:49", + "recorded-content": { + "if_none_match_err_1": { + "Code": "304", + "Message": "Not Modified" + }, + "if_none_match_err_2": { + "Code": "304", + "Message": "Not Modified" + }, + "if_none_match_1": 200, + "if_match_1": 200, + "if_match_2": 200, + "if_match_err_1": { + "Code": "PreconditionFailed", + "Condition": "If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_preconditions": { + "recorded-date": "21-01-2025, 18:29:56", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-precondition-if-match": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "x-amz-copy-source-If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "copy-precondition-if-unmodified-since": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "x-amz-copy-source-If-Unmodified-Since", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "copy-precondition-if-none-match": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "x-amz-copy-source-If-None-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "copy-precondition-if-modified-since": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "x-amz-copy-source-If-Modified-Since", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "copy-ignore-future-modified-since": { + "CopyObjectResult": { + "ChecksumCRC32": "rfPzYw==", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-etag-missing-quotes": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "x-amz-copy-source-If-None-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "copy-success": { + "CopyObjectResult": { + "ChecksumCRC32": "rfPzYw==", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging": { + "recorded-date": "12-08-2023, 19:54:07", + "recorded-content": { + "get-bucket-logging-default": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-default-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "display-name", + "ID": "owner-id", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-logging": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-logging": { + "LoggingEnabled": { + "TargetBucket": "", + "TargetPrefix": "log" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-logging-delete": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_accept_wrong_grants": { + "recorded-date": "03-08-2023, 04:26:11", + "recorded-content": { + "put-bucket-logging": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-logging": { + "LoggingEnabled": { + "TargetBucket": "", + "TargetGrants": [ + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "WRITE" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "READ_ACP" + } + ], + "TargetPrefix": "log" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_wrong_target": { + "recorded-date": "30-08-2024, 11:31:48", + "recorded-content": { + "put-bucket-logging-different-regions": { + "Error": { + "Code": "CrossLocationLoggingProhibitted", + "Message": "Cross S3 location logging not allowed. ", + "TargetBucketLocation": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "put-bucket-logging-non-existent-bucket": { + "Error": { + "Code": "InvalidTargetBucketForLogging", + "Message": "The target bucket for logging does not exist", + "TargetBucket": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_object_expiry_versioned": { + "recorded-date": "21-01-2025, 18:18:31", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": {}, + "ID": "rule2", + "NoncurrentVersionExpiration": { + "NoncurrentDays": 1 + }, + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry-noncurrent": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry-current-with-version-id": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry-current-without-version-id": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_object_expiry_after_bucket_lifecycle_configuration": { + "recorded-date": "07-07-2023, 15:52:37", + "recorded-content": { + "put-object-before": { + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": {}, + "ID": "rule3", + "Status": "Enabled" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry-before": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-after": { + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry-after": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_multiple_rules": { + "recorded-date": "21-01-2025, 18:18:36", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": { + "Prefix": "testobject" + }, + "ID": "rule_one", + "Status": "Enabled" + }, + { + "Expiration": "", + "Filter": { + "Prefix": "test" + }, + "ID": "rule_two", + "Status": "Enabled" + }, + { + "Expiration": "", + "Filter": { + "Prefix": "t" + }, + "ID": "rule_three", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-match-both-rules": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-match-rule-2": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-no-match": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_put_bucket_lifecycle_conf_exc": { + "recorded-date": "21-01-2025, 18:18:24", + "recorded-content": { + "missing-id": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing-filter": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing-noncurrent-version-expiration-data": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-filter-and-plus-prefix": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-filter-and-and-object-size": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-data-no-midnight": { + "Error": { + "ArgumentName": "Date", + "ArgumentValue": "", + "Code": "InvalidArgument", + "Message": "'Date' must be at midnight GMT" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "duplicate-tag-keys": { + "Error": { + "Code": "InvalidRequest", + "Message": "Duplicate Tag Keys are not allowed." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "expired-delete-marker-and-days": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_date": { + "recorded-date": "21-01-2025, 18:18:26", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": { + "Date": "datetime" + }, + "Filter": {}, + "ID": "rule_number_one", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_object_size_rules": { + "recorded-date": "21-01-2025, 18:18:38", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": { + "ObjectSizeGreaterThan": 20 + }, + "ID": "rule_one", + "Status": "Enabled" + }, + { + "Expiration": "", + "Filter": { + "ObjectSizeLessThan": 10 + }, + "ID": "rule_two", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-match-rule-1": { + "ChecksumCRC32": "KJmf0A==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"ff49cfac3968dbce26ebe7d4823e58bd\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-match-rule-2": { + "ChecksumCRC32": "7qyTuQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"594f803b380a41396ed63dca39503542\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-no-match": { + "ChecksumCRC32": "Y5c8cQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"12f9cf6998d52dbe773b06f848bb3608\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_tag_rules": { + "recorded-date": "21-01-2025, 18:18:42", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": { + "Tag": { + "Key": "testlifecycle", + "Value": "positive" + } + }, + "ID": "rule_one", + "Status": "Enabled" + }, + { + "Expiration": "", + "Filter": { + "And": { + "Tags": [ + { + "Key": "testlifecycle", + "Value": "positive" + }, + { + "Key": "testlifecycletwo", + "Value": "positive-two" + } + ] + } + }, + "ID": "rule_two", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-match-both-rules": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-match-both-rules": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "TagCount": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-match-rule-1": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-match-rule-1": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "TagCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-no-match": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-no-match": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "TagCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-no-tags": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-no-tags": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_object_expiry_after_bucket_lifecycle_configuration": { + "recorded-date": "21-01-2025, 18:18:33", + "recorded-content": { + "put-object-before": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": {}, + "ID": "rule3", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry-before": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-after": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-expiry-after": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Expiration": "", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_inventory_report_crud": { + "recorded-date": "21-01-2025, 18:42:52", + "recorded-content": { + "put-inventory-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list-inventory-config": { + "InventoryConfigurationList": [ + { + "Destination": { + "S3BucketDestination": { + "Bucket": "arn::s3:::", + "Format": "CSV" + } + }, + "Id": "test-inventory", + "IncludedObjectVersions": "All", + "IsEnabled": true, + "OptionalFields": [ + "Size", + "ETag" + ], + "Schedule": { + "Frequency": "Daily" + } + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-inventory-config": { + "InventoryConfiguration": { + "Destination": { + "S3BucketDestination": { + "Bucket": "arn::s3:::", + "Format": "CSV" + } + }, + "Id": "test-inventory", + "IncludedObjectVersions": "All", + "IsEnabled": true, + "OptionalFields": [ + "Size", + "ETag" + ], + "Schedule": { + "Frequency": "Daily" + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-inventory-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list-inventory-config-after-del": { + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-nonexistent-inv-config": { + "Error": { + "Code": "NoSuchConfiguration", + "Message": "The specified configuration does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_inventory_report_exceptions": { + "recorded-date": "21-01-2025, 18:42:57", + "recorded-content": { + "wrong-id": { + "Error": { + "Code": "IdMismatch", + "Message": "Document ID does not match the specified configuration ID." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-destination-arn": { + "Error": { + "Code": "InvalidS3DestinationBucket", + "Message": "Invalid bucket ARN." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-destination-format": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-schedule-frequency": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-object-versions": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-optional-field": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_inventory_config_order": { + "recorded-date": "21-01-2025, 18:43:00", + "recorded-content": { + "list-inventory-config": { + "InventoryConfigurationList": [ + { + "Destination": { + "S3BucketDestination": { + "Bucket": "arn::s3:::", + "Format": "CSV" + } + }, + "Id": "a-test", + "IncludedObjectVersions": "All", + "IsEnabled": true, + "Schedule": { + "Frequency": "Daily" + } + }, + { + "Destination": { + "S3BucketDestination": { + "Bucket": "arn::s3:::", + "Format": "CSV" + } + }, + "Id": "test-1", + "IncludedObjectVersions": "All", + "IsEnabled": true, + "Schedule": { + "Frequency": "Daily" + } + }, + { + "Destination": { + "S3BucketDestination": { + "Bucket": "arn::s3:::", + "Format": "CSV" + } + }, + "Id": "z-test", + "IncludedObjectVersions": "All", + "IsEnabled": true, + "Schedule": { + "Frequency": "Daily" + } + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-inventory-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list-inventory-config-after-del": { + "InventoryConfigurationList": [ + { + "Destination": { + "S3BucketDestination": { + "Bucket": "arn::s3:::", + "Format": "CSV" + } + }, + "Id": "a-test", + "IncludedObjectVersions": "All", + "IsEnabled": true, + "Schedule": { + "Frequency": "Daily" + } + }, + { + "Destination": { + "S3BucketDestination": { + "Bucket": "arn::s3:::", + "Format": "CSV" + } + }, + "Id": "test-1", + "IncludedObjectVersions": "All", + "IsEnabled": true, + "Schedule": { + "Frequency": "Daily" + } + } + ], + "IsTruncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_lifecycle_expired_object_delete_marker": { + "recorded-date": "21-01-2025, 18:18:44", + "recorded-content": { + "get-bucket-lifecycle-conf": { + "Rules": [ + { + "Expiration": "", + "Filter": {}, + "ID": "rule-marker", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_complete_multipart_too_small": { + "recorded-date": "21-01-2025, 18:27:40", + "recorded-content": { + "upload-part1": { + "ChecksumCRC32": "rfPzYw==", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part2": { + "ChecksumCRC32": "rfPzYw==", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-exc-no-parts": { + "Error": { + "Code": "InvalidRequest", + "Message": "You must specify at least one part" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-exc-too-small": { + "Error": { + "Code": "EntityTooSmall", + "ETag": "8d777f385d3dfec8815d20f7496026dc", + "Message": "Your proposed upload is smaller than the minimum allowed size", + "MinSizeAllowed": "5242880", + "PartNumber": "1", + "ProposedSize": "4" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_complete_multipart_wrong_part": { + "recorded-date": "21-01-2025, 18:27:42", + "recorded-content": { + "complete-exc-wrong-part-number": { + "Error": { + "Code": "InvalidPart", + "ETag": "8d777f385d3dfec8815d20f7496026dc", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "2", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-exc-wrong-etag": { + "Error": { + "Code": "InvalidPart", + "ETag": "d41d8cd98f00b204e9800998ecf8427e", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "1", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[COPY]": { + "recorded-date": "21-01-2025, 18:28:54", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[REPLACE]": { + "recorded-date": "21-01-2025, 18:28:57", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag": { + "TagSet": [ + { + "Key": "key2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[None]": { + "recorded-date": "21-01-2025, 18:28:59", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_content_length_with_virtual_host[True]": { + "recorded-date": "21-01-2025, 18:43:02", + "recorded-content": { + "get-obj-content-len-headers": { + "accept-ranges": "bytes", + "content-length": "3", + "content-type": "binary/octet-stream", + "date": "date", + "etag": "\"202cb962ac59075b964b07152d234b70\"", + "last-modified": "last-modified", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_content_length_with_virtual_host[False]": { + "recorded-date": "21-01-2025, 18:43:04", + "recorded-content": { + "get-obj-content-len-headers": { + "accept-ranges": "bytes", + "content-length": "3", + "content-type": "binary/octet-stream", + "date": "date", + "etag": "\"202cb962ac59075b964b07152d234b70\"", + "last-modified": "last-modified", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention_exc": { + "recorded-date": "20-06-2025, 16:29:06", + "recorded-content": { + "put-object-retention-no-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-object-retention-no-key": { + "Error": { + "Code": "NoSuchKey", + "Key": "non-existing-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-object-retention-never-set": { + "Error": { + "Code": "NoSuchObjectLockConfiguration", + "Message": "The specified object does not have a ObjectLock configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-object-missing-retention-fields": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-retention-no-bypass": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "update-retention-past-date": { + "Error": { + "ArgumentName": "RetainUntilDate", + "ArgumentValue": "Tue Dec 31 16:00:00 PST 2019", + "Code": "InvalidArgument", + "Message": "The retain until date must be in the future!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "update-retention-bad-value": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-retention-regular-bucket": { + "Error": { + "Code": "InvalidRequest", + "Message": "Bucket is missing Object Lock Configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-object-retention-regular-bucket": { + "Error": { + "Code": "InvalidRequest", + "Message": "Bucket is missing Object Lock Configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_copy_object_retention_lock": { + "recorded-date": "21-01-2025, 18:18:00", + "recorded-content": { + "put-source-object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-source-object": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": "datetime", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-lock": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-dest-key": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention": { + "recorded-date": "20-06-2025, 17:02:01", + "recorded-content": { + "put-obj-locked-1": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-retention-on-key-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-retention-on-key-1": { + "Retention": { + "Mode": "GOVERNANCE", + "RetainUntilDate": "datetime" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-locked": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": "datetime", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-obj-locked": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "delete-obj-locked-bypass": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-obj-locked-2": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-retention-locked-object": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "put-object-empty-retention": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update-retention-object": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_bucket_config_default_retention": { + "recorded-date": "20-06-2025, 17:35:52", + "recorded-content": { + "put-lock-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-default": { + "ChecksumCRC32": "t0I6WA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1df86997d49364e87360e3831d87cc46\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-default": { + "AcceptRanges": "bytes", + "ContentLength": 17, + "ContentType": "binary/octet-stream", + "ETag": "\"1df86997d49364e87360e3831d87cc46\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": "datetime", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-with-lock": { + "ChecksumCRC32": "l5fLBg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f4b85168936b954f2d82998d6d3775c5\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-lock": { + "AcceptRanges": "bytes", + "ContentLength": 20, + "ContentType": "binary/octet-stream", + "ETag": "\"f4b85168936b954f2d82998d6d3775c5\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": "datetime", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-with-lock-no-date": { + "Error": { + "ArgumentName": "x-amz-object-lock-retain-until-date", + "Code": "InvalidArgument", + "Message": "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_object_lock_delete_markers": { + "recorded-date": "21-01-2025, 18:18:05", + "recorded-content": { + "put-object-with-lock": { + "ChecksumCRC32": "l5fLBg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f4b85168936b954f2d82998d6d3775c5\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-lock": { + "AcceptRanges": "bytes", + "ContentLength": 20, + "ContentType": "binary/octet-stream", + "ETag": "\"f4b85168936b954f2d82998d6d3775c5\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": "datetime", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-object-retention-delete-marker": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "PUT", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "get-object-retention-delete-marker": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "GET", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "head-object-locked-delete-marker": { + "Error": { + "Code": "405", + "Message": "Method Not Allowed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_object_lock_extend_duration": { + "recorded-date": "21-01-2025, 18:18:07", + "recorded-content": { + "put-object-with-lock": { + "ChecksumCRC32": "l5fLBg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f4b85168936b954f2d82998d6d3775c5\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-lock": { + "AcceptRanges": "bytes", + "ContentLength": 20, + "ContentType": "binary/octet-stream", + "ETag": "\"f4b85168936b954f2d82998d6d3775c5\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": "datetime", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-retention-extend": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-lock-extended": { + "AcceptRanges": "bytes", + "ContentLength": 20, + "ContentType": "binary/octet-stream", + "ETag": "\"f4b85168936b954f2d82998d6d3775c5\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": "datetime", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-retention-reduce": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_get_object_legal_hold": { + "recorded-date": "21-01-2025, 18:17:06", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-legal-hold-unset": { + "Error": { + "Code": "NoSuchObjectLockConfiguration", + "Message": "The specified object does not have a ObjectLock configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-object-legal-hold": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-legal-hold": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockLegalHoldStatus": "ON", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-legal-hold-set": { + "LegalHold": { + "Status": "ON" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-legal-hold-off": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_object_legal_hold_exc": { + "recorded-date": "21-01-2025, 18:17:12", + "recorded-content": { + "put-object-legal-hold-no-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-object-legal-hold-no-key": { + "Error": { + "Code": "NoSuchKey", + "Key": "non-existing-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-object-retention-regular-bucket": { + "Error": { + "Code": "InvalidRequest", + "Message": "Bucket is missing Object Lock Configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-retention-empty": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-object-retention-regular-bucket": { + "Error": { + "Code": "InvalidRequest", + "Message": "Bucket is missing Object Lock Configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_delete_locked_object": { + "recorded-date": "21-01-2025, 18:17:15", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-legal-hold": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-locked": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "delete-objects-locked": { + "Errors": [ + { + "Code": "AccessDenied", + "Key": "test-delete-locked", + "Message": "Access Denied because object protected by object lock.", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-legal-hold-off": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_s3_legal_hold_lock_versioned": { + "recorded-date": "21-01-2025, 18:17:18", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-legal-hold-ver1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-ver1": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockLegalHoldStatus": "ON", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-2": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-legal-hold-ver2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-ver2": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockLegalHoldStatus": "ON", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-object-legal-hold-ver1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-ver1-no-lock": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockLegalHoldStatus": "OFF", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-ver1": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_s3_copy_object_legal_hold": { + "recorded-date": "21-01-2025, 18:17:21", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockLegalHoldStatus": "ON", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-legal-hold": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-dest-key": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_object_with_legal_hold": { + "recorded-date": "21-01-2025, 18:17:08", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-legal-hold": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ObjectLockLegalHoldStatus": "ON", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-legal-hold-off": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_preconditions[get_object]": { + "recorded-date": "21-01-2025, 18:30:04", + "recorded-content": { + "precondition-if-match": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "precondition-if-unmodified-since": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-Unmodified-Since", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "precondition-if-none-match": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "copy-precondition-if-modified-since": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "obj-ignore-future-modified-since": { + "AcceptRanges": "bytes", + "Body": "data", + "ChecksumCRC32": "rfPzYw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "etag-missing-quotes": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "precondition-if-unmodified-since-is-object": { + "AcceptRanges": "bytes", + "Body": "data", + "ChecksumCRC32": "rfPzYw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "precondition-if-modified-since-is-object": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "obj-success": { + "AcceptRanges": "bytes", + "Body": "data", + "ChecksumCRC32": "rfPzYw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_preconditions[head_object]": { + "recorded-date": "21-01-2025, 18:30:10", + "recorded-content": { + "precondition-if-match": { + "Error": { + "Code": "412", + "Message": "Precondition Failed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "precondition-if-unmodified-since": { + "Error": { + "Code": "412", + "Message": "Precondition Failed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "precondition-if-none-match": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "copy-precondition-if-modified-since": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "obj-ignore-future-modified-since": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "etag-missing-quotes": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "precondition-if-unmodified-since-is-object": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "precondition-if-modified-since-is-object": { + "Error": { + "Code": "304", + "Message": "Not Modified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 304 + } + }, + "obj-success": { + "AcceptRanges": "bytes", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part": { + "recorded-date": "07-07-2025, 17:56:14", + "recorded-content": { + "multipart-upload": { + "Bucket": "", + "ChecksumCRC64NVME": "44QnbGeotHE=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2848839dc84e13fa00a0944e760e233b-2\"", + "Key": "test.file", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-part": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentRange": "bytes 5242896-5242911/5242912", + "ContentType": "binary/octet-stream", + "ETag": "\"2848839dc84e13fa00a0944e760e233b-2\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 2, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-object-part": { + "AcceptRanges": "bytes", + "Body": "test content 123", + "ContentLength": 16, + "ContentRange": "bytes 5242896-5242911/5242912", + "ContentType": "binary/octet-stream", + "ETag": "\"2848839dc84e13fa00a0944e760e233b-2\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 2, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-object-part-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test content 123", + "ContentLength": 16, + "ContentRange": "bytes 5242896-5242911/5242912", + "ContentType": "binary/octet-stream", + "ETag": "\"2848839dc84e13fa00a0944e760e233b-2\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 2, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "part-doesnt-exist": { + "Error": { + "ActualPartCount": "2", + "Code": "InvalidPartNumber", + "Message": "The requested partnumber is not satisfiable", + "PartNumberRequested": "10" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 416 + } + }, + "part-with-range": { + "Error": { + "Code": "InvalidRequest", + "Message": "Cannot specify both Range header and partNumber query parameter" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "part-no-multipart": { + "Error": { + "ActualPartCount": "1", + "Code": "InvalidPartNumber", + "Message": "The requested partnumber is not satisfiable", + "PartNumberRequested": "2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 416 + } + }, + "get-obj-no-multipart": { + "AcceptRanges": "bytes", + "Body": "test-123", + "ChecksumCRC32": "MAPXAg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentRange": "bytes 0-7/8", + "ContentType": "binary/octet-stream", + "ETag": "\"ca6d00e33edff0e9cb3782d31182de33\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[single]": { + "recorded-date": "17-03-2025, 20:16:38", + "recorded-content": { + "get-tagging": { + "TagSet": [ + { + "Key": "TagName", + "Value": "TagValue" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[list]": { + "recorded-date": "17-03-2025, 20:16:39", + "recorded-content": { + "get-tagging": { + "TagSet": [ + { + "Key": "TagName", + "Value": "TagValue" + }, + { + "Key": "TagName2", + "Value": "TagValue2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[invalid]": { + "recorded-date": "17-03-2025, 20:16:41", + "recorded-content": { + "get-tagging": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[notxml]": { + "recorded-date": "17-03-2025, 20:16:43", + "recorded-content": { + "tagging-error": { + "Error": { + "Code": "MalformedXML", + "HostId": "", + "Message": "The XML you provided was not well-formed or did not validate against our published schema", + "RequestId": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_metadata": { + "recorded-date": "17-03-2025, 21:56:03", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 17, + "ContentType": "text/plain", + "ETag": "\"a7d8531d918474360de3e2eaeb110cda\"", + "Expires": "datetime", + "ExpiresString": "", + "LastModified": "datetime", + "Metadata": { + "test-1": "test-meta-1", + "test-2": "test-meta-2" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_storage_class": { + "recorded-date": "17-03-2025, 20:16:47", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 23, + "ContentType": "binary/octet-stream", + "ETag": "\"f73f1a2dbae1bbd6c42f86e771298073\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "StorageClass": "STANDARD_IA", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalid-storage-error": { + "Error": { + "Code": "InvalidStorageClass", + "HostId": "", + "Message": "The storage class you specified is not valid", + "RequestId": "", + "StorageClassRequested": "FakeClass" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_acl": { + "recorded-date": "21-01-2025, 18:30:26", + "recorded-content": { + "put-object-default-acl": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-acl-default": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-acl": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/global/AllUsers" + }, + "Permission": "READ" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-grant-acl": { + "Grants": [ + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "READ" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-acp-acl": { + "Grants": [ + { + "Grantee": { + "DisplayName": "", + "ID": "", + "Type": "CanonicalUser" + }, + "Permission": "FULL_CONTROL" + }, + { + "Grantee": { + "Type": "Group", + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery" + }, + "Permission": "WRITE" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_acl_exceptions": { + "recorded-date": "21-01-2025, 18:30:32", + "recorded-content": { + "put-object-canned-acl": { + "Error": { + "ArgumentName": "x-amz-acl", + "ArgumentValue": "fake-acl", + "Code": "InvalidArgument", + "Message": null + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acl-canned-acl": { + "Error": { + "ArgumentName": "x-amz-acl", + "ArgumentValue": "fake-acl", + "Code": "InvalidArgument", + "Message": null + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-grant-acl-fake-uri": { + "Error": { + "ArgumentName": "uri", + "ArgumentValue": "http://acs.amazonaws.com/groups/s3/FakeGroup", + "Code": "InvalidArgument", + "Message": "Invalid group uri" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-grant-acl-fake-key": { + "Error": { + "ArgumentName": "x-amz-grant-write", + "ArgumentValue": "fakekey=\"1234\"", + "Code": "InvalidArgument", + "Message": "Argument format not recognized" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-grant-acl-wrong-id": { + "Error": { + "ArgumentName": "id", + "ArgumentValue": "wrong-id", + "Code": "InvalidArgument", + "Message": "Invalid id" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acp-acl-1": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acp-acl-2": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acp-acl-3": { + "Error": { + "ArgumentName": "CanonicalUser/ID", + "ArgumentValue": "wrong-id", + "Code": "InvalidArgument", + "Message": "Invalid id" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acp-acl-4": { + "Error": { + "ArgumentName": "Group/URI", + "ArgumentValue": "http://acs.amazonaws.com/groups/s3/FakeGroup", + "Code": "InvalidArgument", + "Message": "Invalid group uri" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acp-acl-5": { + "Error": { + "ArgumentName": "CanonicalUser/ID", + "ArgumentValue": "wrong-id", + "Code": "InvalidArgument", + "Message": "Invalid id" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acp-acl-6": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-empty-acp": { + "Error": { + "Code": "MalformedACLError", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-acl-empty": { + "Error": { + "Code": "MissingSecurityHeader", + "Message": "Your request was missing a required header", + "MissingHeaderName": "x-amz-acl" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-two-type-acl": { + "Error": { + "Code": "InvalidRequest", + "Message": "Specifying both Canned ACLs and Header Grants is not allowed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-two-type-acl-acp": { + "Error": { + "Code": "UnexpectedContent", + "Message": "This request does not support content" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_empty_bucket_fixture": { + "recorded-date": "21-01-2025, 18:43:07", + "recorded-content": { + "list-obj": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"202cb962ac59075b964b07152d234b70\"", + "Key": "key0", + "LastModified": "datetime", + "Size": 3, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"202cb962ac59075b964b07152d234b70\"", + "Key": "key1", + "LastModified": "datetime", + "Size": 3, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"202cb962ac59075b964b07152d234b70\"", + "Key": "key2", + "LastModified": "datetime", + "Size": 3, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-obj-after-empty": { + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 0, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_operation_between_regions": { + "recorded-date": "21-01-2025, 18:30:52", + "recorded-content": { + "put-website-config-region-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-cors-config-region-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-website-config-region-2": { + "IndexDocument": { + "Suffix": "index.html" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-cors-config-region-2": { + "CORSRules": [ + { + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_overwrite_key": { + "recorded-date": "17-03-2025, 21:29:05", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "B18g1g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"eee506dd7ada7ded524c77e359a0e7c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multipart-upload": { + "Bucket": "", + "ChecksumCRC64NVME": "BsLNlKumA5I=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "Key": "test.file", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_objects_encoding": { + "recorded-date": "21-01-2025, 18:31:37", + "recorded-content": { + "list-objects-before-delete": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1ac438708eff428b768f07249b3e2bb2\"", + "Key": "a%2Fb", + "LastModified": "datetime", + "Size": 16, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"ec53baa61c0c0b736a567bdef59250f3\"", + "Key": "a/%F0%9F%98%80", + "LastModified": "datetime", + "Size": 21, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deleted-resp": { + "Deleted": [ + { + "Key": "a%2Fb" + }, + { + "Key": "a/%F0%9F%98%80" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects": { + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 0, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_src_not_exists": { + "recorded-date": "26-10-2023, 15:03:13", + "recorded-content": { + "copy-object-src-bucket-not-exists": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "copy-object-src-object-not-exists": { + "Error": { + "Code": "NoSuchKey", + "Key": "random-src-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-obj-versioned": { + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-obj-versioned": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "copy-object-src-delete-marker-current-version": { + "Error": { + "Code": "NoSuchKey", + "Key": "random-src-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "copy-object-src-target-delete-marker": { + "Error": { + "Code": "InvalidRequest", + "Message": "The source of a copy request may not specifically refer to a delete marker by version id." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-object-src-target-object-version": { + "CopyObjectResult": { + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_key_validation": { + "recorded-date": "26-10-2023, 16:04:23", + "recorded-content": { + "upload-part-wrong-key-name": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "complete-multipart-wrong-key-name": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "list-parts-wrong-key-name": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "abort-multipart-wrong-key-name": { + "Error": { + "Code": "NoSuchUpload", + "Message": "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed.", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_url_encoded_key[True]": { + "recorded-date": "21-01-2025, 18:27:06", + "recorded-content": { + "list-object-encoded-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"03dc4443b5f395b54d011fdb7d9e0ae1\"", + "Key": "test%40key", + "LastModified": "datetime", + "Size": 24, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"51a6065890415b4b299dec1aa33d712c\"", + "Key": "test%40key/", + "LastModified": "datetime", + "Size": 12, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b792145c4a8e8d9ac95d3c2f9f0ac42d\"", + "Key": "test@key/", + "LastModified": "datetime", + "Size": 16, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_url_encoded_key[False]": { + "recorded-date": "21-01-2025, 18:27:09", + "recorded-content": { + "list-object-encoded-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"03dc4443b5f395b54d011fdb7d9e0ae1\"", + "Key": "test%40key", + "LastModified": "datetime", + "Size": 24, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"51a6065890415b4b299dec1aa33d712c\"", + "Key": "test%40key/", + "LastModified": "datetime", + "Size": 12, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b792145c4a8e8d9ac95d3c2f9f0ac42d\"", + "Key": "test@key/", + "LastModified": "datetime", + "Size": 16, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_list_parts_empty_part_number_marker": { + "recorded-date": "11-11-2023, 00:20:09", + "recorded-content": { + "list-parts-empty-marker": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-empty-marker", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 4 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts-empty-max-parts": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-empty-marker", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 4 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3v4[True]": { + "recorded-date": "21-01-2025, 18:22:52", + "recorded-content": { + "no-meta-headers": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "LastModified": "datetime", + "Metadata": { + "foo": "bar" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "wrong-meta-headers": { + "Error": { + "Code": "AccessDenied", + "HeadersNotSigned": "x-amz-meta-wrong", + "HostId": "", + "Message": "There were headers present in the request which were not signed", + "RequestId": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3v4[False]": { + "recorded-date": "21-01-2025, 18:22:55", + "recorded-content": { + "no-meta-headers": { + "Error": { + "AWSAccessKeyId": "", + "CanonicalRequest": "", + "CanonicalRequestBytes": "", + "Code": "SignatureDoesNotMatch", + "HostId": "", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "LastModified": "datetime", + "Metadata": { + "foo": "bar" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "wrong-meta-headers": { + "Error": { + "Code": "AccessDenied", + "HeadersNotSigned": "x-amz-meta-wrong", + "HostId": "", + "Message": "There were headers present in the request which were not signed", + "RequestId": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3[True]": { + "recorded-date": "21-01-2025, 18:22:57", + "recorded-content": { + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "LastModified": "datetime", + "Metadata": { + "foo": "bar" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "wrong-meta-headers": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3[False]": { + "recorded-date": "21-01-2025, 18:22:59", + "recorded-content": { + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "LastModified": "datetime", + "Metadata": { + "foo": "bar" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "wrong-meta-headers": { + "Error": { + "AWSAccessKeyId": "", + "Code": "SignatureDoesNotMatch", + "HostId": "host-id", + "Message": "The request signature we calculated does not match the signature you provided. Check your key and signing method.", + "RequestId": "", + "SignatureProvided": "", + "StringToSign": "", + "StringToSignBytes": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_v4_x_amz_in_qs": { + "recorded-date": "17-03-2025, 21:32:11", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "CVmbZh4IWzA=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 6, + "ContentType": "binary/octet-stream", + "ETag": "\"e10adc3949ba59abbe56e057f20f883e\"", + "LastModified": "datetime", + "Metadata": { + "foo": "bar-complicated-no-random" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_object_with_slashes_in_key[True]": { + "recorded-date": "21-01-2025, 18:26:32", + "recorded-content": { + "list-objects-slashes": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "/bar/foo/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"3858f62230ac3c915f300c664312c63f\"", + "Key": "/foo", + "LastModified": "datetime", + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"96948aad3fcae80c08a35c9b5958cd89\"", + "Key": "bar", + "LastModified": "datetime", + "Size": 6, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_object_with_slashes_in_key[False]": { + "recorded-date": "21-01-2025, 18:26:36", + "recorded-content": { + "list-objects-slashes": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "/bar/foo/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"3858f62230ac3c915f300c664312c63f\"", + "Key": "/foo", + "LastModified": "datetime", + "Size": 6, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"96948aad3fcae80c08a35c9b5958cd89\"", + "Key": "bar", + "LastModified": "datetime", + "Size": 6, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_cross_locations": { + "recorded-date": "29-08-2024, 15:58:14", + "recorded-content": { + "put-bucket-logging-cross-us-east-1": { + "Error": { + "Code": "CrossLocationLoggingProhibitted", + "Message": "Cross S3 location logging not allowed. ", + "TargetBucketLocation": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "put-bucket-logging-different-regions": { + "Error": { + "Code": "CrossLocationLoggingProhibitted", + "Message": "Cross S3 location logging not allowed. ", + "SourceBucketLocation": "", + "TargetBucketLocation": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_special_character": { + "recorded-date": "21-01-2025, 18:27:00", + "recorded-content": { + "put-object-src-special-char-file%2Fname": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-special-char-file%2Fname": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-src-special-char-test@key/": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-special-char-test@key/": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-src-special-char-test key/": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-special-char-test key/": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-src-special-char-test key//": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-special-char-test key//": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-src-special-char-a/%F0%9F%98%80/": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-special-char-a/%F0%9F%98%80/": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-src-special-char-test+key": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-special-char-test+key": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-copy-dest-special-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "a/%F0%9F%98%80/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "file%2Fname", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test key/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test key//", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test+key", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "test@key/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 6, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_wrong_content_type": { + "recorded-date": "17-03-2025, 20:16:49", + "recorded-content": { + "invalid-content-type-error": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "Bucket POST must be of the enclosure-type multipart/form-data", + "HostId": "", + "Message": "At least one of the pre-conditions you specified did not hold", + "RequestId": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_file_as_string": { + "recorded-date": "17-03-2025, 20:16:51", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ContentLength": 23, + "ContentType": "binary/octet-stream", + "ETag": "\"518feee9a33977e5047cda470999729a\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC64NVME" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"31ac3a102af06a3d79f0172d01158b49\"", + "Key": "file-as-field-${filename}", + "LastModified": "datetime", + "Size": 44, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC64NVME" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"518feee9a33977e5047cda470999729a\"", + "Key": "test-presigned-post-file-as-field", + "LastModified": "datetime", + "Size": 23, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_wrong_format": { + "recorded-date": "21-01-2025, 18:29:58", + "recorded-content": { + "copy-object-wrong-copy-source": { + "Error": { + "ArgumentName": "x-amz-copy-source", + "ArgumentValue": "x-amz-copy-source", + "Code": "InvalidArgument", + "Message": "Invalid copy source object key" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_versioned": { + "recorded-date": "21-01-2025, 18:29:22", + "recorded-content": { + "put_object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "LastModified": "datetime", + "StorageClass": "STANDARD", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-no-change": { + "Error": { + "Code": "InvalidRequest", + "Message": "This copy request is illegal because it is trying to copy an object to itself without changing the object's metadata, storage class, website redirect location or encryption attributes." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-in-place-versioned": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-copied": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-copied": { + "AcceptRanges": "bytes", + "Body": { + "key": "value" + }, + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-in-place-versioned-suspended": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-copied-suspended": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-copied-suspended": { + "AcceptRanges": "bytes", + "Body": { + "key": "value" + }, + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-previous-version-suspended": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-in-place-versioned-re-enabled": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_validation_size": { + "recorded-date": "17-03-2025, 20:17:02", + "recorded-content": { + "invalid-content-length-too-big": { + "Error": { + "Code": "EntityTooLarge", + "HostId": "", + "MaxSizeAllowed": "10", + "Message": "Your proposed upload exceeds the maximum allowed size", + "ProposedSize": "12", + "RequestId": "" + } + }, + "invalid-content-length-too-small": { + "Error": { + "Code": "EntityTooSmall", + "HostId": "", + "Message": "Your proposed upload is smaller than the minimum allowed size", + "MinSizeAllowed": "5", + "ProposedSize": "1", + "RequestId": "" + } + }, + "final-object": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ChecksumCRC64NVME": "DBqAA21lxVU=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentType": "binary/octet-stream", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalid-content-length-wrong-type": { + "Error": { + "Code": "AccessDenied", + "HostId": "", + "Message": "Invalid according to Policy: Policy Condition failed: [\"content-length-range\", \"test\", \"10\"]", + "RequestId": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_conditions_validation_eq": { + "recorded-date": "17-03-2025, 20:16:55", + "recorded-content": { + "invalid-condition-eq": { + "Error": { + "Code": "AccessDenied", + "HostId": "", + "Message": "Invalid according to Policy: Policy Condition failed: [\"eq\", \"$success_action_redirect\", \"http://localhost.test/random\"]", + "RequestId": "" + } + }, + "invalid-condition-missing-prefix": { + "Error": { + "Code": "AccessDenied", + "HostId": "", + "Message": "Invalid according to Policy: Policy Condition failed: [\"eq\", \"success_action_redirect\", \"http://localhost.test/random\"]", + "RequestId": "" + } + }, + "invalid-condition-wrong-condition": { + "Error": { + "Code": "InvalidPolicyDocument", + "HostId": "", + "Message": "Invalid Policy: Invalid Simple-Condition: Simple-Conditions must have exactly one property specified.", + "RequestId": "" + } + }, + "invalid-condition-wrong-value-casing": { + "Error": { + "Code": "AccessDenied", + "HostId": "", + "Message": "Invalid according to Policy: Policy Condition failed: [\"eq\", \"$success_action_redirect\", \"HTTP://localhost.test/random\"]", + "RequestId": "" + } + }, + "head-object-metadata": { + "AcceptRanges": "bytes", + "ContentLength": 6, + "ContentType": "text/plain", + "ETag": "\"e80b5017098950fc58aad83c8c14978e\"", + "Expires": "datetime", + "ExpiresString": "", + "LastModified": "datetime", + "Metadata": { + "test-1": "test-meta-1", + "test-2": "test-meta-2" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "final-object": { + "AcceptRanges": "bytes", + "Body": "abcdef", + "ChecksumCRC64NVME": "Pact6XRTYyA=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 6, + "ContentType": "binary/octet-stream", + "ETag": "\"e80b5017098950fc58aad83c8c14978e\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_conditions_validation_starts_with": { + "recorded-date": "17-03-2025, 20:16:58", + "recorded-content": { + "invalid-condition-starts-with": { + "Error": { + "Code": "AccessDenied", + "HostId": "", + "Message": "Invalid according to Policy: Policy Condition failed: [\"starts-with\", \"$success_action_redirect\", \"http://localhost\"]", + "RequestId": "" + } + }, + "invalid-condition-starts-with-casing": { + "Error": { + "Code": "AccessDenied", + "HostId": "", + "Message": "Invalid according to Policy: Policy Condition failed: [\"starts-with\", \"$success_action_redirect\", \"http://localhost\"]", + "RequestId": "" + } + }, + "get-object-1": { + "AcceptRanges": "bytes", + "Body": "abcdef", + "ChecksumCRC64NVME": "Pact6XRTYyA=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 6, + "ContentType": "binary/octet-stream", + "ETag": "\"e80b5017098950fc58aad83c8c14978e\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-2": { + "AcceptRanges": "bytes", + "Body": "manual value to change ETag", + "ChecksumCRC64NVME": "wSCEAfbAYnk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 27, + "ContentType": "binary/octet-stream", + "ETag": "\"365cb4550a52593ad95c6b31242d7418\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_presigned_post_with_different_user_credentials": { + "recorded-date": "17-03-2025, 20:17:16", + "recorded-content": { + "get-obj": { + "AcceptRanges": "bytes", + "Body": "abcdef", + "ChecksumCRC64NVME": "Pact6XRTYyA=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 6, + "ContentType": "binary/octet-stream", + "ETag": "\"e80b5017098950fc58aad83c8c14978e\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_double_encoded_credentials": { + "recorded-date": "21-01-2025, 18:23:03", + "recorded-content": { + "error-malformed": { + "Error": { + "Code": "AuthorizationQueryParametersError", + "HostId": "host-id", + "Message": "Error parsing the X-Amz-Credential parameter; the Credential is mal-formed; expecting \"/YYYYMMDD/REGION/SERVICE/aws4_request\".", + "RequestId": "" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_suspended_only": { + "recorded-date": "21-01-2025, 18:29:26", + "recorded-content": { + "put_object": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head_object": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "application/json", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": { + "key": "value" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-in-place-non-versioned": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-copied": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-copied": { + "AcceptRanges": "bytes", + "Body": { + "key": "value" + }, + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-in-place-versioned-suspended": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-copied-suspended": { + "AcceptRanges": "bytes", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-copied-suspended": { + "AcceptRanges": "bytes", + "Body": { + "key": "value" + }, + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentType": "binary/octet-stream", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-in-place-versioned-suspended-twice": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes_with_space": { + "recorded-date": "21-01-2025, 18:27:30", + "recorded-content": { + "get-attrs-with-whitespace": { + "GetObjectAttributesResponse": { + "@xmlns": "http://s3.amazonaws.com/doc/2006-03-01/", + "Checksum": { + "ChecksumSHA256": "2dhlzFTsYGePGxGQhK15rn+TV9HEUZxkV94zFLf7uoo=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "70b68ae721a61941a1a62724dde5d5e4", + "ObjectSize": "9", + "StorageClass": "STANDARD" + } + }, + "get-attrs-without-whitespace": { + "GetObjectAttributesResponse": { + "@xmlns": "http://s3.amazonaws.com/doc/2006-03-01/", + "Checksum": { + "ChecksumSHA256": "2dhlzFTsYGePGxGQhK15rn+TV9HEUZxkV94zFLf7uoo=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "70b68ae721a61941a1a62724dde5d5e4", + "ObjectSize": "9", + "StorageClass": "STANDARD" + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_no_algorithm": { + "recorded-date": "17-03-2025, 18:29:05", + "recorded-content": { + "put-wrong-checksum": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-sha256 header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-2-checksums": { + "Error": { + "Code": "InvalidRequest", + "Message": "Expecting a single x-amz-checksum- header. Multiple checksum Types are not allowed." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-right-checksum": { + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-obj": { + "AcceptRanges": "bytes", + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_validation_sse_c": { + "recorded-date": "21-01-2025, 18:16:18", + "recorded-content": { + "put-obj-sse-c-both-encryption": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "AES256", + "Code": "InvalidArgument", + "Message": "Server Side Encryption with Customer provided key is incompatible with the encryption method specified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-sse-c-wrong-algo": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "KMS", + "Code": "InvalidEncryptionAlgorithmError", + "Message": "The Encryption request you specified is not valid. Supported value: AES256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-sse-c-no-algo": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "Requests specifying Server Side Encryption with Customer provided keys must provide a valid encryption algorithm." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-sse-c-no-key": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "Requests specifying Server Side Encryption with Customer provided keys must provide an appropriate secret key." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-sse-c-no-md5": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "The secret key was invalid for the specified algorithm." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-sse-c-wrong-key-size": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "The secret key was invalid for the specified algorithm." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-sse-c-bad-md5": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "The calculated MD5 hash of the key did not match the hash that was provided." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_object_retrieval_sse_c": { + "recorded-date": "22-01-2025, 14:21:49", + "recorded-content": { + "put-obj-sse-c": { + "ChecksumCRC32": "qIrZrA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"7f021303b8ca8e5af2c5ee7bf1e96a18\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-sse-c": { + "Error": { + "Code": "InvalidRequest", + "Message": "The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-obj-sse-c-wrong-key": { + "Error": { + "Code": "AccessDenied", + "Message": "Requests specifying Server Side Encryption with Customer provided keys must provide the correct secret key." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "get-obj-sse-c-wrong-algo": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "KMS", + "Code": "InvalidEncryptionAlgorithmError", + "Message": "The Encryption request you specified is not valid. Supported value: AES256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "head-obj-sse-c-wrong-algo": { + "Error": { + "Code": "400", + "Message": "Bad Request" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-obj-attrs-sse-c-wrong-algo": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "KMS", + "Code": "InvalidEncryptionAlgorithmError", + "Message": "The Encryption request you specified is not valid. Supported value: AES256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-obj-sse-c-no-md5": { + "Error": { + "Code": "AccessDenied", + "Message": "Requests specifying Server Side Encryption with Customer provided keys must provide the correct secret key." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "head-obj-sse-c-no-md5": { + "Error": { + "Code": "403", + "Message": "Forbidden" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "get-obj-sse-c-wrong-key-size": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "The secret key was invalid for the specified algorithm." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-obj-sse-c-bad-md5": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "null", + "Code": "InvalidArgument", + "Message": "The calculated MD5 hash of the key did not match the hash that was provided." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_copy_object_with_sse_c": { + "recorded-date": "21-01-2025, 18:16:26", + "recorded-content": { + "put-obj-sse-c": { + "ChecksumCRC32": "qIrZrA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"9e12596cb25a080bf57d9655b61cce93\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-sse-c-target-no-sse-c": { + "CopyObjectResult": { + "ChecksumCRC32": "qIrZrA==", + "ETag": "\"6af8307c2460f2d208ad254f04be4b0d\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-sse-c": { + "CopyObjectResult": { + "ChecksumCRC32": "qIrZrA==", + "ETag": "\"f8c18b77e4724f2b67755eb07ca0d417\"", + "LastModified": "datetime" + }, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-sse-c-param": { + "Error": { + "Code": "InvalidRequest", + "Message": "The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-obj-no-src-sse-c": { + "Error": { + "Code": "InvalidRequest", + "Message": "The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-obj-wrong-src-sse-c-algo": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "KMS", + "Code": "InvalidEncryptionAlgorithmError", + "Message": "The Encryption request you specified is not valid. Supported value: AES256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-obj-wrong-target-sse-c-algo": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "KMS", + "Code": "InvalidEncryptionAlgorithmError", + "Message": "The Encryption request you specified is not valid. Supported value: AES256." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "copy-obj-target-double-encryption": { + "Error": { + "ArgumentName": "x-amz-server-side-encryption", + "ArgumentValue": "AES256", + "Code": "InvalidArgument", + "Message": "Server Side Encryption with Customer provided key is incompatible with the encryption method specified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_multipart_upload_sse_c": { + "recorded-date": "17-03-2025, 22:55:35", + "recorded-content": { + "create-mpu-sse-c": { + "Bucket": "bucket", + "Key": "test-sse-c-multipart", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"55725a011e3346d563c0704e1619e91c\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"5a89fb15ffa5db577508d72fe9d5b61d\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"000a293bf05bdb2787a36ffe787ba40e\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-sse-c-multipart", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"55725a011e3346d563c0704e1619e91c\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ETag": "\"5a89fb15ffa5db577508d72fe9d5b61d\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ETag": "\"000a293bf05bdb2787a36ffe787ba40e\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 5242881 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ETag": "\"8d8dff3df79d195957f14d81d054538e-3\"", + "Key": "test-sse-c-multipart", + "Location": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-no-sse-c-param": { + "Error": { + "Code": "InvalidRequest", + "Message": "The object was stored using a form of Server Side Encryption. The correct parameters must be provided to retrieve the object." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-obj-sse-c": { + "AcceptRanges": "bytes", + "Body": "", + "ContentLength": 15728643, + "ContentType": "binary/octet-stream", + "ETag": "\"8d8dff3df79d195957f14d81d054538e-3\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_multipart_upload_sse_c_validation": { + "recorded-date": "21-01-2025, 18:16:43", + "recorded-content": { + "create-mpu-no-sse-c": { + "Bucket": "bucket", + "Key": "test-sse-c-multipart", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "mpu-no-sse-c-upload-part-with-sse-c": { + "Error": { + "Code": "InvalidRequest", + "Message": "The multipart upload initiate requested encryption. Subsequent part requests must include the appropriate encryption parameters." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-mpu-sse-c": { + "Bucket": "bucket", + "Key": "test-sse-c-multipart", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "mpu-sse-c-upload-part-no-sse-c": { + "Error": { + "Code": "InvalidRequest", + "Message": "The multipart upload initiate requested encryption. Subsequent part requests must include the appropriate encryption parameters." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "mpu-sse-c-upload-part-wrong-sse-c": { + "Error": { + "Code": "InvalidRequest", + "Message": "The provided encryption parameters did not match the ones used originally." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_lifecycle_with_sse_c": { + "recorded-date": "21-01-2025, 18:16:15", + "recorded-content": { + "put-obj-sse-c": { + "ChecksumCRC32": "qIrZrA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1339c8b8d4cf4416490531cabb5b5963\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-obj-sse-c": { + "AcceptRanges": "bytes", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"1339c8b8d4cf4416490531cabb5b5963\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-sse-c": { + "AcceptRanges": "bytes", + "Body": "test_data", + "ChecksumCRC32": "qIrZrA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"1339c8b8d4cf4416490531cabb5b5963\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-attrs-sse-c": { + "ETag": "1339c8b8d4cf4416490531cabb5b5963", + "LastModified": "datetime", + "ObjectSize": 9, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-obj-sse-c": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_sse_c_with_versioning": { + "recorded-date": "21-01-2025, 18:16:46", + "recorded-content": { + "put-obj-sse-c-version-1": { + "ChecksumCRC32": "gQ5gbg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2e00061193ff6efbafd20ee93b0898f2\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-sse-c-version-2": { + "ChecksumCRC32": "GAcx1A==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"5e5d63b0148e2c6dc33e7d3316be8581\"", + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "KoitZ78ZSAQHz4+gxDpJqQ==", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-sse-c-last-version": { + "AcceptRanges": "bytes", + "Body": "version2", + "ChecksumCRC32": "GAcx1A==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"5e5d63b0148e2c6dc33e7d3316be8581\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "KoitZ78ZSAQHz4+gxDpJqQ==", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-sse-c-version-2": { + "AcceptRanges": "bytes", + "Body": "version2", + "ChecksumCRC32": "GAcx1A==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"5e5d63b0148e2c6dc33e7d3316be8581\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "KoitZ78ZSAQHz4+gxDpJqQ==", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-sse-c-last-version-wrong-key": { + "Error": { + "Code": "AccessDenied", + "Message": "Requests specifying Server Side Encryption with Customer provided keys must provide the correct secret key." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "get-obj-sse-c-version-1": { + "AcceptRanges": "bytes", + "Body": "version1", + "ChecksumCRC32": "gQ5gbg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 8, + "ContentType": "binary/octet-stream", + "ETag": "\"2e00061193ff6efbafd20ee93b0898f2\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[COPY]": { + "recorded-date": "21-01-2025, 18:29:02", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-v2": { + "ChecksumCRC32": "BnpCOA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1-v2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag-v1": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC32": "BnpCOA==", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1-v2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty": { + "CopyObjectResult": { + "ChecksumCRC32": "BnpCOA==", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1-v2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-v1": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-v1": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty-v1": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty-v1": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[REPLACE]": { + "recorded-date": "21-01-2025, 18:29:06", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-v2": { + "ChecksumCRC32": "BnpCOA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1-v2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag-v1": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC32": "BnpCOA==", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag": { + "TagSet": [ + { + "Key": "key2", + "Value": "value2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty": { + "CopyObjectResult": { + "ChecksumCRC32": "BnpCOA==", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty": { + "TagSet": [], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-v1": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-v1": { + "TagSet": [ + { + "Key": "key2", + "Value": "value2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty-v1": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty-v1": { + "TagSet": [], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[None]": { + "recorded-date": "21-01-2025, 18:29:10", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-v2": { + "ChecksumCRC32": "BnpCOA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1-v2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tag-v1": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object": { + "CopyObjectResult": { + "ChecksumCRC32": "BnpCOA==", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1-v2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty": { + "CopyObjectResult": { + "ChecksumCRC32": "BnpCOA==", + "ETag": "\"c814444dd0b31747f0a59e12a5351daa\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1-v2" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-v1": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-v1": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-tag-empty-v1": { + "CopyObjectResult": { + "ChecksumCRC32": "2H9+DA==", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime" + }, + "CopySourceVersionId": "", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-tag-empty-v1": { + "TagSet": [ + { + "Key": "key1", + "Value": "value1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_region_header_exists_outside_us_east_1": { + "recorded-date": "21-01-2025, 18:26:20", + "recorded-content": { + "head_bucket": { + "AccessPointAlias": false, + "BucketRegion": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_objects_v2": { + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 0, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy": { + "recorded-date": "21-01-2025, 18:27:51", + "recorded-content": { + "get-bucket-policy-no-such-bucket-policy": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucketPolicy", + "Message": "The bucket policy does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-bucket-policy": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": "s3:GetObject", + "Resource": "" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-policy-with-expected-bucket-owner": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": "s3:GetObject", + "Resource": "" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-policy-with-expected-bucket-owner-error": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy": { + "recorded-date": "21-01-2025, 18:28:06", + "recorded-content": { + "delete-bucket-policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-policy-no-such-bucket-policy": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucketPolicy", + "Message": "The bucket policy does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy": { + "recorded-date": "21-01-2025, 18:27:58", + "recorded-content": { + "put-bucket-policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-policy": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": "s3:GetObject", + "Resource": "" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy_expected_bucket_owner": { + "recorded-date": "21-01-2025, 18:50:24", + "recorded-content": { + "delete-bucket-policy-with-expected-bucket-owner-error": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "delete-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [invalid]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-bucket-policy-with-expected-bucket-owner": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_expected_bucket_owner": { + "recorded-date": "21-01-2025, 18:50:21", + "recorded-content": { + "put-bucket-policy-with-expected-bucket-owner-error": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "put-bucket-policy-with-expected-bucket-owner": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000]": { + "recorded-date": "21-01-2025, 18:27:52", + "recorded-content": { + "get-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000000000020]": { + "recorded-date": "21-01-2025, 18:27:53", + "recorded-content": { + "get-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000000000020]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[abcd]": { + "recorded-date": "21-01-2025, 18:27:55", + "recorded-content": { + "get-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [abcd]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[aa000000000$]": { + "recorded-date": "21-01-2025, 18:27:56", + "recorded-content": { + "get-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [aa000000000$]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000]": { + "recorded-date": "21-01-2025, 18:28:00", + "recorded-content": { + "put-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000000000020]": { + "recorded-date": "21-01-2025, 18:28:01", + "recorded-content": { + "put-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [0000000000020]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[abcd]": { + "recorded-date": "21-01-2025, 18:28:03", + "recorded-content": { + "put-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [abcd]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[aa000000000$]": { + "recorded-date": "21-01-2025, 18:28:04", + "recorded-content": { + "put-bucket-policy-invalid-bucket-owner": { + "Error": { + "Code": "InvalidBucketOwnerAWSAccountID", + "Message": "The value of the expected bucket owner parameter must be an AWS Account ID... [aa000000000$]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC64NVME]": { + "recorded-date": "17-03-2025, 18:28:57", + "recorded-content": { + "put-object": { + "ChecksumCRC64NVME": "1f2xscCCZCU=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC64NVME": "1f2xscCCZCU=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC64NVME": "1f2xscCCZCU=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "test-checksum", + "ChecksumCRC64NVME": "1f2xscCCZCU=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 13, + "ContentType": "binary/octet-stream", + "ETag": "\"f2081dd61dfa700a0fd5e29b9c3cc23d\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "1f2xscCCZCU=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_single_character_trailing_slash": { + "recorded-date": "22-01-2025, 19:05:31", + "recorded-content": { + "put-object-single-char-a/": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-single-char-a/": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-single-char-t/": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-single-char-t/": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-single-char-u/": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-single-char-u/": { + "AcceptRanges": "bytes", + "Body": "test", + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 4, + "ContentType": "binary/octet-stream", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-single-char": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "a/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "t/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "Key": "u/", + "LastModified": "datetime", + "Size": 4, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC64NVME]": { + "recorded-date": "24-01-2025, 19:06:38", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-checksum": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC64NVME]": { + "recorded-date": "17-03-2025, 18:28:43", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-crc64nvme header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The CRC64NVME you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-generated": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-generated": { + "Checksum": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-autogenerated": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-auto-generated": { + "Checksum": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e6d9226c2a86b7232933663c13467527", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_no_automatic_sdk_calculation": { + "recorded-date": "17-03-2025, 22:25:43", + "recorded-content": { + "wrong-checksum": { + "Error": { + "Code": "InvalidRequest", + "HostId": "", + "Message": "Value for x-amz-checksum-sha256 header is invalid.", + "RequestId": "" + } + }, + "head-obj-right-checksum": { + "AcceptRanges": "bytes", + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-obj-only-checksum-algo": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-obj-diff-checksum-algo": { + "AcceptRanges": "bytes", + "ChecksumSHA256": "2l26x0trnT0r2AvakoFk2MB7eKVKzYESLMxSAKAzoik=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-obj-no-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-attrs-no-checksum": { + "Checksum": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-default-checksum": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ETag": "\"e6d9226c2a86b7232933663c13467527\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-obj-attrs-no-checksum": { + "Checksum": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[CRC32]": { + "recorded-date": "07-07-2025, 17:39:49", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumCRC32": "TBHN8A==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "COMPOSITE", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumCRC32": "TBHN8A==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "c4c753e69bb853187f5854c46cf801c6", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "2", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-no-checksum": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using a crc32 checksum. The complete request must include the checksum for each part. It was missing for part 1 in the request." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC32": "5FRUiw==-3", + "ChecksumType": "COMPOSITE", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32": "5FRUiw==-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32": "5FRUiw==-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32": "5FRUiw==", + "ChecksumType": "COMPOSITE" + }, + "ETag": "4d45984fc3feb2ac9b22683c49674b56-3", + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32": "NRU+Sw==", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumCRC32": "NRU+Sw==", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumCRC32": "TBHN8A==", + "PartNumber": 3, + "Size": 10 + } + ], + "TotalPartsCount": 3 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumCRC32": "qSEQSA==", + "ETag": "\"a0397ee2df08832642af5d7e57c5760c\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumCRC32": "qSEQSA==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "a0397ee2df08832642af5d7e57c5760c", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-part-checksum": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ChecksumCRC32": "TBHN8A==", + "ChecksumType": "COMPOSITE", + "ContentLength": 10, + "ContentRange": "bytes 10485762-10485771/10485772", + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 3, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[CRC32C]": { + "recorded-date": "07-07-2025, 17:40:10", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumCRC32C": "5yZkMA==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "COMPOSITE", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumCRC32C": "5yZkMA==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "c4c753e69bb853187f5854c46cf801c6", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "1", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-no-checksum": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using a crc32c checksum. The complete request must include the checksum for each part. It was missing for part 1 in the request." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC32C": "XF5+4A==-3", + "ChecksumType": "COMPOSITE", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32C": "XF5+4A==-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32C": "XF5+4A==-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32C": "XF5+4A==", + "ChecksumType": "COMPOSITE" + }, + "ETag": "4d45984fc3feb2ac9b22683c49674b56-3", + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32C": "2/Ckiw==", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumCRC32C": "2/Ckiw==", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumCRC32C": "5yZkMA==", + "PartNumber": 3, + "Size": 10 + } + ], + "TotalPartsCount": 3 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumCRC32C": "eTdAQA==", + "ETag": "\"a0397ee2df08832642af5d7e57c5760c\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumCRC32C": "eTdAQA==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "a0397ee2df08832642af5d7e57c5760c", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-part-checksum": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ChecksumCRC32C": "5yZkMA==", + "ChecksumType": "COMPOSITE", + "ContentLength": 10, + "ContentRange": "bytes 10485762-10485771/10485772", + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 3, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[SHA1]": { + "recorded-date": "07-07-2025, 17:40:50", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA1", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumSHA1": "bH71WIZUKQtUwR2wKSSkFjCRBPM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumSHA1": "bH71WIZUKQtUwR2wKSSkFjCRBPM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumSHA1": "NJX/adNGcdHhWzOmPBN5/e3Toyo=", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA1", + "ChecksumType": "COMPOSITE", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumSHA1": "bH71WIZUKQtUwR2wKSSkFjCRBPM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumSHA1": "bH71WIZUKQtUwR2wKSSkFjCRBPM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumSHA1": "NJX/adNGcdHhWzOmPBN5/e3Toyo=", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "c4c753e69bb853187f5854c46cf801c6", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "2", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-no-checksum": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using a sha1 checksum. The complete request must include the checksum for each part. It was missing for part 1 in the request." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumSHA1": "AyE60nyQoBgJcwsyPHWu7aJuxBs=-3", + "ChecksumType": "COMPOSITE", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumSHA1": "AyE60nyQoBgJcwsyPHWu7aJuxBs=-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumSHA1": "AyE60nyQoBgJcwsyPHWu7aJuxBs=-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumSHA1": "AyE60nyQoBgJcwsyPHWu7aJuxBs=", + "ChecksumType": "COMPOSITE" + }, + "ETag": "4d45984fc3feb2ac9b22683c49674b56-3", + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumSHA1": "bH71WIZUKQtUwR2wKSSkFjCRBPM=", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumSHA1": "bH71WIZUKQtUwR2wKSSkFjCRBPM=", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumSHA1": "NJX/adNGcdHhWzOmPBN5/e3Toyo=", + "PartNumber": 3, + "Size": 10 + } + ], + "TotalPartsCount": 3 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumSHA1": "eIC+AqBqApUmeqCBQ+9n8OVTP+8=", + "ETag": "\"a0397ee2df08832642af5d7e57c5760c\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumSHA1": "eIC+AqBqApUmeqCBQ+9n8OVTP+8=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "a0397ee2df08832642af5d7e57c5760c", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-part-checksum": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ChecksumSHA1": "NJX/adNGcdHhWzOmPBN5/e3Toyo=", + "ChecksumType": "COMPOSITE", + "ContentLength": 10, + "ContentRange": "bytes 10485762-10485771/10485772", + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 3, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[SHA256]": { + "recorded-date": "07-07-2025, 17:41:10", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA256", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumSHA256": "vyy1imj2hNlaO3jvj2Ycmk5bCegsyPnMiMzpBSjK6yc=", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA256", + "ChecksumType": "COMPOSITE", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumSHA256": "vyy1imj2hNlaO3jvj2Ycmk5bCegsyPnMiMzpBSjK6yc=", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "c4c753e69bb853187f5854c46cf801c6", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "1", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-no-checksum": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using a sha256 checksum. The complete request must include the checksum for each part. It was missing for part 1 in the request." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumSHA256": "9o++y6AejqboiJ7MZCx0fahK2Vu5YC/qnNhhYsCLciI=-3", + "ChecksumType": "COMPOSITE", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumSHA256": "9o++y6AejqboiJ7MZCx0fahK2Vu5YC/qnNhhYsCLciI=-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumSHA256": "9o++y6AejqboiJ7MZCx0fahK2Vu5YC/qnNhhYsCLciI=-3", + "ChecksumType": "COMPOSITE", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumSHA256": "9o++y6AejqboiJ7MZCx0fahK2Vu5YC/qnNhhYsCLciI=", + "ChecksumType": "COMPOSITE" + }, + "ETag": "4d45984fc3feb2ac9b22683c49674b56-3", + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumSHA256": "vyy1imj2hNlaO3jvj2Ycmk5bCegsyPnMiMzpBSjK6yc=", + "PartNumber": 3, + "Size": 10 + } + ], + "TotalPartsCount": 3 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumSHA256": "0+991zqhqOQ5J2EdwChmHIeC1dXXuJzaCritTzqVGDw=", + "ETag": "\"a0397ee2df08832642af5d7e57c5760c\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumSHA256": "0+991zqhqOQ5J2EdwChmHIeC1dXXuJzaCritTzqVGDw=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "a0397ee2df08832642af5d7e57c5760c", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-part-checksum": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ChecksumSHA256": "vyy1imj2hNlaO3jvj2Ycmk5bCegsyPnMiMzpBSjK6yc=", + "ChecksumType": "COMPOSITE", + "ContentLength": 10, + "ContentRange": "bytes 10485762-10485771/10485772", + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 3, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC32]": { + "recorded-date": "15-06-2025, 17:09:49", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-compat", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC32C]": { + "recorded-date": "15-06-2025, 17:09:50", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-compat", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-SHA1]": { + "recorded-date": "15-06-2025, 17:09:52", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA1", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-compat", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-SHA256]": { + "recorded-date": "15-06-2025, 17:09:53", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA256", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-compat", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC64NVME]": { + "recorded-date": "15-06-2025, 17:09:54", + "recorded-content": { + "create-mpu-checksum-exc": { + "Error": { + "Code": "InvalidRequest", + "Message": "The COMPOSITE checksum type cannot be used with the crc64nvme checksum algorithm." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC32]": { + "recorded-date": "15-06-2025, 17:09:56", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum-compat", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC32C]": { + "recorded-date": "15-06-2025, 17:09:57", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum-compat", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-SHA1]": { + "recorded-date": "15-06-2025, 17:09:58", + "recorded-content": { + "create-mpu-checksum-exc": { + "Error": { + "Code": "InvalidRequest", + "Message": "The FULL_OBJECT checksum type cannot be used with the sha1 checksum algorithm." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-SHA256]": { + "recorded-date": "15-06-2025, 17:10:00", + "recorded-content": { + "create-mpu-checksum-exc": { + "Error": { + "Code": "InvalidRequest", + "Message": "The FULL_OBJECT checksum type cannot be used with the sha256 checksum algorithm." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC64NVME]": { + "recorded-date": "15-06-2025, 17:10:01", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC64NVME", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum-compat", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC32]": { + "recorded-date": "15-06-2025, 17:10:03", + "recorded-content": { + "create-mpu-default-checksum-type": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-default", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC32C]": { + "recorded-date": "15-06-2025, 17:10:04", + "recorded-content": { + "create-mpu-default-checksum-type": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-default", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[SHA1]": { + "recorded-date": "15-06-2025, 17:10:05", + "recorded-content": { + "create-mpu-default-checksum-type": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA1", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-default", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[SHA256]": { + "recorded-date": "15-06-2025, 17:10:07", + "recorded-content": { + "create-mpu-default-checksum-type": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA256", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum-default", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC64NVME]": { + "recorded-date": "15-06-2025, 17:10:08", + "recorded-content": { + "create-mpu-default-checksum-type": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC64NVME", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum-default", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC32]": { + "recorded-date": "07-07-2025, 17:41:34", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumCRC32": "TBHN8A==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "FULL_OBJECT", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumCRC32": "NRU+Sw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumCRC32": "TBHN8A==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "e09c80c42fda55f9d992e59ca6b3307d", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "3", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC32": "qSEQSA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32": "qSEQSA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32": "qSEQSA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32": "qSEQSA==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "4d45984fc3feb2ac9b22683c49674b56-3", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 3 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumCRC32": "qSEQSA==", + "ETag": "\"a0397ee2df08832642af5d7e57c5760c\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumCRC32": "qSEQSA==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "a0397ee2df08832642af5d7e57c5760c", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-part-checksum": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ContentLength": 10, + "ContentRange": "bytes 10485762-10485771/10485772", + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 3, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC32C]": { + "recorded-date": "07-07-2025, 17:41:53", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumCRC32C": "5yZkMA==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumCRC32C": "2/Ckiw==", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumCRC32C": "5yZkMA==", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "c4c753e69bb853187f5854c46cf801c6", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "2", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC32C": "eTdAQA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC32C": "eTdAQA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32C": "eTdAQA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32C": "eTdAQA==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "4d45984fc3feb2ac9b22683c49674b56-3", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 3 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumCRC32C": "eTdAQA==", + "ETag": "\"a0397ee2df08832642af5d7e57c5760c\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumCRC32C": "eTdAQA==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "a0397ee2df08832642af5d7e57c5760c", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-part-checksum": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ContentLength": 10, + "ContentRange": "bytes 10485762-10485771/10485772", + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 3, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC64NVME]": { + "recorded-date": "07-07-2025, 17:42:04", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC64NVME", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-0": { + "ChecksumCRC64NVME": "Kg7TOs6algM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-1": { + "ChecksumCRC64NVME": "Kg7TOs6algM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-2": { + "ChecksumCRC64NVME": "DBqAA21lxVU=", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC64NVME", + "ChecksumType": "FULL_OBJECT", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC64NVME": "Kg7TOs6algM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumCRC64NVME": "Kg7TOs6algM=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + }, + { + "ChecksumCRC64NVME": "DBqAA21lxVU=", + "ETag": "\"e09c80c42fda55f9d992e59ca6b3307d\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "c4c753e69bb853187f5854c46cf801c6", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "1", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC64NVME": "ZMNX55lZurA=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC64NVME": "ZMNX55lZurA=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "ZMNX55lZurA=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10485772, + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "ZMNX55lZurA=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "4d45984fc3feb2ac9b22683c49674b56-3", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 3 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "ZMNX55lZurA=", + "ETag": "\"a0397ee2df08832642af5d7e57c5760c\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "ZMNX55lZurA=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "a0397ee2df08832642af5d7e57c5760c", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-part-checksum": { + "AcceptRanges": "bytes", + "Body": "aaaaaaaaaa", + "ContentLength": 10, + "ContentRange": "bytes 10485762-10485771/10485772", + "ContentType": "binary/octet-stream", + "ETag": "\"4d45984fc3feb2ac9b22683c49674b56-3\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 3, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_parts_checksum_exceptions_full_object": { + "recorded-date": "15-06-2025, 17:12:51", + "recorded-content": { + "create-mpu-no-checksum-algo-with-type": { + "Error": { + "Code": "InvalidRequest", + "Message": "The x-amz-checksum-type header can only be used with the x-amz-checksum-algorithm header." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-mpu-checksum-crc32c": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum-exc", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts": { + "Bucket": "bucket", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "test-multipart-checksum-exc", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "Key": "test-multipart-checksum-exc", + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-no-checksum-ok": { + "ChecksumCRC32C": "Nks/tw==", + "ETag": "\"900150983cd24fb0d6963f7d28e17f72\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-part-bad-checksum-type": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using the FULL_OBJECT checksum mode. The complete request must use the same checksum mode." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-part-good-checksum-no-type": { + "Error": { + "Code": "BadDigest", + "Message": "The crc32c you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-part-only-checksum-algo": { + "Error": { + "Code": "BadDigest", + "Message": "The crc32c you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-part-only-checksum-algo-diff": { + "Error": { + "Code": "BadDigest", + "Message": "The crc32c you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-part-bad-checksum": { + "Error": { + "Code": "BadDigest", + "Message": "The crc32c you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-part-bad-checksum-algo": { + "Error": { + "Code": "BadDigest", + "Message": "The crc32c you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-success": { + "Bucket": "bucket", + "ChecksumCRC32C": "Nks/tw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"af5da9f45af7a300e3aded972f8ff687-1\"", + "Key": "test-multipart-checksum-exc", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-mpu-with-checksum": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum-exc", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-different-checksum-exc": { + "Error": { + "Code": "InvalidRequest", + "Message": "Checksum Type mismatch occurred, expected checksum Type: crc32c, actual checksum Type: crc32" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_default": { + "recorded-date": "15-06-2025, 17:12:56", + "recorded-content": { + "create-mpu-no-checksum": { + "Bucket": "bucket", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts": { + "Bucket": "bucket", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "test-multipart-checksum", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "Key": "test-multipart-checksum", + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-different-checksum-than-default": { + "ChecksumCRC32C": "45fn2Q==", + "ETag": "\"47bce5c74f589f4867dbd57e9ca9f808\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"47bce5c74f589f4867dbd57e9ca9f808\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 3 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "47bce5c74f589f4867dbd57e9ca9f808", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "1", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-wrong-parts-checksum": { + "Error": { + "Code": "InvalidPart", + "ETag": "47bce5c74f589f4867dbd57e9ca9f808", + "Message": "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", + "PartNumber": "1", + "UploadId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-full-object-type": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using the null checksum mode. The complete request must use the same checksum mode." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-composite-type": { + "Error": { + "Code": "InvalidRequest", + "Message": "The upload was created using the null checksum mode. The complete request must use the same checksum mode." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e2c3da976e66ec9e7dc128fbc782fc91-1\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 3, + "ContentType": "binary/octet-stream", + "ETag": "\"e2c3da976e66ec9e7dc128fbc782fc91-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 3, + "ContentType": "binary/octet-stream", + "ETag": "\"e2c3da976e66ec9e7dc128fbc782fc91-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e2c3da976e66ec9e7dc128fbc782fc91-1", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-obj-checksum": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ETag": "\"47bce5c74f589f4867dbd57e9ca9f808\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-copy-object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "47bce5c74f589f4867dbd57e9ca9f808", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_size_validation": { + "recorded-date": "15-06-2025, 17:13:01", + "recorded-content": { + "create-mpu": { + "Bucket": "bucket", + "Key": "test-multipart-size", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part": { + "ChecksumCRC32": "rZjlRQ==", + "ETag": "\"74b87337454200d4d33f80c4663dc5e5\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-wrong-size": { + "Error": { + "Code": "InvalidRequest", + "Message": "The provided 'x-amz-mp-object-size' header value 5 does not match what was computed: 4" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "complete-multipart-good-size": { + "Bucket": "bucket", + "ChecksumCRC64NVME": "GC2rW8VclPA=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c890740ac2875a29117863d66dacc4f0-1\"", + "Key": "test-multipart-size", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "GC2rW8VclPA=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "c890740ac2875a29117863d66dacc4f0-1", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC32]": { + "recorded-date": "15-06-2025, 17:10:17", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-crc32 header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The CRC32 you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC32C]": { + "recorded-date": "15-06-2025, 17:10:27", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-crc32c header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The CRC32C you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[SHA1]": { + "recorded-date": "15-06-2025, 17:10:44", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-sha1 header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The SHA1 you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[SHA256]": { + "recorded-date": "15-06-2025, 17:10:59", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-sha256 header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The SHA256 you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC64NVME]": { + "recorded-date": "15-06-2025, 17:11:10", + "recorded-content": { + "put-wrong-checksum-no-b64": { + "Error": { + "Code": "InvalidRequest", + "Message": "Value for x-amz-checksum-crc64nvme header is invalid." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-wrong-checksum-value": { + "Error": { + "Code": "BadDigest", + "Message": "The CRC64NVME you specified did not match the calculated checksum." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_s3_transition_default_minimum_object_size": { + "recorded-date": "03-02-2025, 10:15:23", + "recorded-content": { + "varies-by-storage": { + "TransitionDefaultMinimumObjectSize": "varies_by_storage_class", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-varies-by-storage": { + "Rules": [ + { + "Expiration": { + "Days": 7 + }, + "Filter": { + "Prefix": "" + }, + "ID": "wholebucket", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "varies_by_storage_class", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "default": { + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-default": { + "Rules": [ + { + "Expiration": { + "Days": 7 + }, + "Filter": { + "Prefix": "" + }, + "ID": "wholebucket", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all-storage": { + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-all-storage": { + "Rules": [ + { + "Expiration": { + "Days": 7 + }, + "Filter": { + "Prefix": "" + }, + "ID": "wholebucket", + "Status": "Enabled" + } + ], + "TransitionDefaultMinimumObjectSize": "all_storage_classes_128K", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "bad-value": { + "Error": { + "Code": "InvalidRequest", + "Message": "Invalid TransitionDefaultMinimumObjectSize found: value" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object_default": { + "recorded-date": "15-06-2025, 17:12:58", + "recorded-content": { + "create-mpu-checksum-crc64": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC64NVME", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part": { + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ETag": "\"47bce5c74f589f4867dbd57e9ca9f808\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"e2c3da976e66ec9e7dc128fbc782fc91-1\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "", + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 3, + "ContentType": "binary/octet-stream", + "ETag": "\"e2c3da976e66ec9e7dc128fbc782fc91-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 3, + "ContentType": "binary/octet-stream", + "ETag": "\"e2c3da976e66ec9e7dc128fbc782fc91-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "Rnr2/5P7Gsk=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "e2c3da976e66ec9e7dc128fbc782fc91-1", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_default_checksum": { + "recorded-date": "17-03-2025, 21:46:24", + "recorded-content": { + "head-object": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "L1qdhyEV1JY=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 17, + "ContentType": "binary/octet-stream", + "ETag": "\"a7d8531d918474360de3e2eaeb110cda\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC32]": { + "recorded-date": "17-03-2025, 22:17:03", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-no-checksum": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumCRC32": "MzVIGw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dest-object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC32": "MzVIGw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC32C]": { + "recorded-date": "17-03-2025, 22:17:06", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC32C": "078Ilw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC32C": "078Ilw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-no-checksum": { + "CopyObjectResult": { + "ChecksumCRC32C": "078Ilw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC32C": "078Ilw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumCRC32C": "078Ilw==", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dest-object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC32C": "078Ilw==", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[SHA1]": { + "recorded-date": "17-03-2025, 22:17:08", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-no-checksum": { + "CopyObjectResult": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dest-object-attrs-after-copy": { + "Checksum": { + "ChecksumSHA1": "5zXdjmjYk4EJ8Cw4PMnQVslCpRQ=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[SHA256]": { + "recorded-date": "17-03-2025, 22:17:11", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-no-checksum": { + "CopyObjectResult": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dest-object-attrs-after-copy": { + "Checksum": { + "ChecksumSHA256": "lyTB4g5uPk1/V+0l+dTvsAblCFkNUoyQ2ll/andcE+U=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC64NVME]": { + "recorded-date": "17-03-2025, 22:17:13", + "recorded-content": { + "put-object-no-checksum": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs": { + "Checksum": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-in-place-with-no-checksum": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "copy-object-to-dest-keep-checksum": { + "CopyObjectResult": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ETag": "\"88bac95f31528d13a072c05f2a1cf371\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dest-object-attrs-after-copy": { + "Checksum": { + "ChecksumCRC64NVME": "pX30eiUx5C0=", + "ChecksumType": "FULL_OBJECT" + }, + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_default_checksum_with_sse_c": { + "recorded-date": "17-03-2025, 23:22:53", + "recorded-content": { + "head-obj-sse-c": { + "AcceptRanges": "bytes", + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"14838aba23aac65c8befbb53acf51014\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-sse-c": { + "AcceptRanges": "bytes", + "Body": "test data..", + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"14838aba23aac65c8befbb53acf51014\"", + "LastModified": "datetime", + "Metadata": {}, + "SSECustomerAlgorithm": "AES256", + "SSECustomerKeyMD5": "JMwgiexXqwuPqIPjYFmIZQ==", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-attrs-sse-c": { + "Checksum": { + "ChecksumCRC64NVME": "qUVrWYOrIAM=", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "14838aba23aac65c8befbb53acf51014", + "LastModified": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_copy_checksum[COMPOSITE]": { + "recorded-date": "16-06-2025, 10:53:39", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "nG7pIA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"11df95d595559285eb2b042124e74f09\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-mpu-checksum-sha256": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-copy": { + "CopyPartResult": { + "ChecksumCRC32C": "iqJrOQ==", + "ETag": "\"11df95d595559285eb2b042124e74f09\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "COMPOSITE", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32C": "iqJrOQ==", + "ETag": "\"11df95d595559285eb2b042124e74f09\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 14 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC32C": "F9bAnw==-1", + "ChecksumType": "COMPOSITE", + "ETag": "\"395d97c07920de036bfa21e7568a2e9f-1\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "this is a part", + "ChecksumCRC32C": "F9bAnw==-1", + "ChecksumType": "COMPOSITE", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"395d97c07920de036bfa21e7568a2e9f-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32C": "F9bAnw==-1", + "ChecksumType": "COMPOSITE", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"395d97c07920de036bfa21e7568a2e9f-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32C": "F9bAnw==", + "ChecksumType": "COMPOSITE" + }, + "ETag": "395d97c07920de036bfa21e7568a2e9f-1", + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32C": "iqJrOQ==", + "PartNumber": 1, + "Size": 14 + } + ], + "TotalPartsCount": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_copy_checksum[FULL_OBJECT]": { + "recorded-date": "16-06-2025, 10:53:42", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "nG7pIA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"11df95d595559285eb2b042124e74f09\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-mpu-checksum-sha256": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-copy": { + "CopyPartResult": { + "ChecksumCRC32C": "iqJrOQ==", + "ETag": "\"11df95d595559285eb2b042124e74f09\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-multipart-checksum", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumCRC32C": "iqJrOQ==", + "ETag": "\"11df95d595559285eb2b042124e74f09\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 14 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-checksum": { + "Bucket": "bucket", + "ChecksumCRC32C": "iqJrOQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"395d97c07920de036bfa21e7568a2e9f-1\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-with-checksum": { + "AcceptRanges": "bytes", + "Body": "this is a part", + "ChecksumCRC32C": "iqJrOQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"395d97c07920de036bfa21e7568a2e9f-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-with-checksum": { + "AcceptRanges": "bytes", + "ChecksumCRC32C": "iqJrOQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"395d97c07920de036bfa21e7568a2e9f-1\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs": { + "Checksum": { + "ChecksumCRC32C": "iqJrOQ==", + "ChecksumType": "FULL_OBJECT" + }, + "ETag": "395d97c07920de036bfa21e7568a2e9f-1", + "LastModified": "datetime", + "ObjectParts": { + "TotalPartsCount": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention_compliance_mode": { + "recorded-date": "20-06-2025, 17:19:56", + "recorded-content": { + "put-obj-locked-1": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "add-compliance-retention": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-locked-1": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "put-delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-locked-2": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "update-retention-shortened": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "update-retention-less-restrictive": { + "Error": { + "Code": "AccessDenied", + "Message": "Access Denied because object protected by object lock." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + }, + "delete-obj-after-lock-expiration": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_lock_mode_validation": { + "recorded-date": "20-06-2025, 17:33:40", + "recorded-content": { + "put-obj-locked-error-no-retain-date": { + "Error": { + "ArgumentName": "x-amz-object-lock-retain-until-date", + "Code": "InvalidArgument", + "Message": "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-locked-error-no-mode": { + "Error": { + "ArgumentName": "x-amz-object-lock-mode", + "Code": "InvalidArgument", + "Message": "x-amz-object-lock-retain-until-date and x-amz-object-lock-mode must both be supplied" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-locked-bad-value": { + "Error": { + "ArgumentName": "x-amz-object-lock-mode", + "ArgumentValue": "BAD-VALUE", + "Code": "InvalidArgument", + "Message": "Unknown wormMode directive." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create-mpu-locked-bad-value": { + "Error": { + "ArgumentName": "x-amz-object-lock-mode", + "ArgumentValue": "BAD-VALUE", + "Code": "InvalidArgument", + "Message": "Unknown wormMode directive." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part_checksum[COMPOSITE]": { + "recorded-date": "07-07-2025, 18:23:54", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "COMPOSITE", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part": { + "ChecksumCRC32C": "fwuu0Q==", + "ETag": "\"eee506dd7ada7ded524c77e359a0e7c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-checksum": { + "Bucket": "", + "ChecksumCRC32C": "sZB91Q==-1", + "ChecksumType": "COMPOSITE", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-part": { + "AcceptRanges": "bytes", + "ChecksumCRC32C": "fwuu0Q==", + "ChecksumType": "COMPOSITE", + "ContentLength": 16, + "ContentRange": "bytes 0-15/16", + "ContentType": "binary/octet-stream", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 1, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-object-part": { + "AcceptRanges": "bytes", + "Body": "test content 123", + "ChecksumCRC32C": "fwuu0Q==", + "ChecksumType": "COMPOSITE", + "ContentLength": 16, + "ContentRange": "bytes 0-15/16", + "ContentType": "binary/octet-stream", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 1, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part_checksum[FULL_OBJECT]": { + "recorded-date": "07-07-2025, 18:23:57", + "recorded-content": { + "create-mpu-checksum": { + "Bucket": "", + "ChecksumAlgorithm": "CRC32C", + "ChecksumType": "FULL_OBJECT", + "Key": "test-multipart-checksum", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part": { + "ChecksumCRC32C": "fwuu0Q==", + "ETag": "\"eee506dd7ada7ded524c77e359a0e7c6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-checksum": { + "Bucket": "", + "ChecksumCRC32C": "fwuu0Q==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "Key": "test-multipart-checksum", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-part": { + "AcceptRanges": "bytes", + "ChecksumCRC32C": "fwuu0Q==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentRange": "bytes 0-15/16", + "ContentType": "binary/octet-stream", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 1, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-object-part": { + "AcceptRanges": "bytes", + "Body": "test content 123", + "ChecksumCRC32C": "fwuu0Q==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentRange": "bytes 0-15/16", + "ContentType": "binary/octet-stream", + "ETag": "\"b972025bc34adf8d76d6d51e93c035cc-1\"", + "LastModified": "datetime", + "Metadata": {}, + "PartsCount": 1, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + } + } + } +} diff --git a/tests/aws/services/s3/test_s3.validation.json b/tests/aws/services/s3/test_s3.validation.json new file mode 100644 index 0000000000000..f2eadc435b21a --- /dev/null +++ b/tests/aws/services/s3/test_s3.validation.json @@ -0,0 +1,1286 @@ +{ + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_availability": { + "last_validated_date": "2025-01-21T18:30:41+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_does_not_exist": { + "last_validated_date": "2025-01-21T18:34:13+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_exists": { + "last_validated_date": "2025-01-21T18:31:45+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_name_with_dots": { + "last_validated_date": "2025-01-21T18:34:19+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_bucket_operation_between_regions": { + "last_validated_date": "2025-01-21T18:30:52+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_complete_multipart_parts_order": { + "last_validated_date": "2025-03-17T21:30:44+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_in_place_with_bucket_encryption": { + "last_validated_date": "2025-01-21T18:29:34+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_kms": { + "last_validated_date": "2025-01-21T18:26:17+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_special_character": { + "last_validated_date": "2025-01-21T18:27:00+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_copy_object_special_character_plus_for_space": { + "last_validated_date": "2025-01-21T18:27:03+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_head_bucket": { + "last_validated_date": "2025-01-21T18:34:17+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_via_host_name": { + "last_validated_date": "2025-01-21T18:27:49+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_create_bucket_with_existing_name": { + "last_validated_date": "2025-01-21T18:34:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_no_such_bucket": { + "last_validated_date": "2025-01-21T18:27:11+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy": { + "last_validated_date": "2025-01-21T18:28:06+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_policy_expected_bucket_owner": { + "last_validated_date": "2025-01-21T18:50:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_bucket_with_content": { + "last_validated_date": "2025-01-21T18:26:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_keys_in_versioned_bucket": { + "last_validated_date": "2025-01-21T18:31:34+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys": { + "last_validated_date": "2025-01-21T18:31:31+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys_in_non_existing_bucket": { + "last_validated_date": "2025-01-21T18:31:36+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_non_existing_keys_quiet": { + "last_validated_date": "2025-01-21T18:31:30+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_object_tagging": { + "last_validated_date": "2025-01-21T18:31:28+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_delete_objects_encoding": { + "last_validated_date": "2025-01-21T18:31:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_different_location_constraint": { + "last_validated_date": "2025-01-21T18:30:47+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_download_fileobj_multiple_range_requests": { + "last_validated_date": "2025-01-21T18:31:23+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_empty_bucket_fixture": { + "last_validated_date": "2025-01-21T18:43:07+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_etag_on_get_object_call": { + "last_validated_date": "2025-01-21T18:39:07+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_notification_configuration_no_such_bucket": { + "last_validated_date": "2025-01-21T18:27:11+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy": { + "last_validated_date": "2025-01-21T18:27:51+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000000000020]": { + "last_validated_date": "2025-01-21T18:27:53+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[0000]": { + "last_validated_date": "2025-01-21T18:27:52+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[aa000000000$]": { + "last_validated_date": "2025-01-21T18:27:56+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_policy_invalid_account_id[abcd]": { + "last_validated_date": "2025-01-21T18:27:55+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_bucket_versioning_order": { + "last_validated_date": "2025-01-21T18:39:04+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_after_deleted_in_versioned_bucket": { + "last_validated_date": "2025-01-21T18:28:12+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes": { + "last_validated_date": "2025-03-17T20:02:49+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes_versioned": { + "last_validated_date": "2025-01-21T18:27:33+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_attributes_with_space": { + "last_validated_date": "2025-01-21T18:27:30+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_content_length_with_virtual_host[False]": { + "last_validated_date": "2025-01-21T18:43:04+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_content_length_with_virtual_host[True]": { + "last_validated_date": "2025-01-21T18:43:02+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_no_such_bucket": { + "last_validated_date": "2025-01-21T18:27:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part": { + "last_validated_date": "2025-07-07T17:56:15+00:00", + "durations_in_seconds": { + "setup": 1.4, + "call": 10.69, + "teardown": 1.07, + "total": 13.16 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part_checksum[COMPOSITE]": { + "last_validated_date": "2025-07-07T18:23:55+00:00", + "durations_in_seconds": { + "setup": 1.05, + "call": 0.85, + "teardown": 1.02, + "total": 2.92 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part_checksum[FULL_OBJECT]": { + "last_validated_date": "2025-07-07T18:23:58+00:00", + "durations_in_seconds": { + "setup": 0.6, + "call": 0.85, + "teardown": 1.17, + "total": 2.62 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_part_checksum_composite": { + "last_validated_date": "2025-07-07T18:06:04+00:00", + "durations_in_seconds": { + "setup": 1.29, + "call": 0.85, + "teardown": 1.19, + "total": 3.33 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_object_with_anon_credentials": { + "last_validated_date": "2025-01-21T18:30:54+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_get_range_object_headers": { + "last_validated_date": "2025-01-21T18:31:25+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_head_object_fields": { + "last_validated_date": "2025-01-21T18:28:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_invalid_range_error": { + "last_validated_date": "2025-01-21T18:27:45+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_metadata_header_character_decoding": { + "last_validated_date": "2025-01-21T18:26:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_and_list_parts": { + "last_validated_date": "2025-03-17T21:28:50+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_complete_multipart_too_small": { + "last_validated_date": "2025-01-21T18:27:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_complete_multipart_wrong_part": { + "last_validated_date": "2025-01-21T18:27:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_copy_object_etag": { + "last_validated_date": "2025-03-17T23:02:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_no_such_upload": { + "last_validated_date": "2025-01-21T18:27:38+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_multipart_overwrite_key": { + "last_validated_date": "2025-03-17T21:29:05+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_object_with_slashes_in_key[False]": { + "last_validated_date": "2025-01-21T18:26:36+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_object_with_slashes_in_key[True]": { + "last_validated_date": "2025-01-21T18:26:32+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_precondition_failed_error": { + "last_validated_date": "2025-01-21T18:32:22+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_content_language_disposition": { + "last_validated_date": "2025-01-21T18:26:29+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_hash_prefix": { + "last_validated_date": "2025-01-21T18:27:44+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_and_get_object_with_utf8_key": { + "last_validated_date": "2025-01-21T18:26:27+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_inventory_config_order": { + "last_validated_date": "2025-01-21T18:43:00+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy": { + "last_validated_date": "2025-01-21T18:27:58+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_expected_bucket_owner": { + "last_validated_date": "2025-01-21T18:50:21+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000000000020]": { + "last_validated_date": "2025-01-21T18:28:01+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[0000]": { + "last_validated_date": "2025-01-21T18:28:00+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[aa000000000$]": { + "last_validated_date": "2025-01-21T18:28:04+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_bucket_policy_invalid_account_id[abcd]": { + "last_validated_date": "2025-01-21T18:28:03+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_single_character_trailing_slash": { + "last_validated_date": "2025-01-22T19:05:31+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[a/%F0%9F%98%80/]": { + "last_validated_date": "2025-01-21T18:26:56+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[file%2Fname]": { + "last_validated_date": "2025-01-21T18:26:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test key//]": { + "last_validated_date": "2025-01-21T18:26:52+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test key/]": { + "last_validated_date": "2025-01-21T18:26:50+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%123/]": { + "last_validated_date": "2025-01-21T18:26:54+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%123]": { + "last_validated_date": "2025-01-21T18:26:46+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test%percent]": { + "last_validated_date": "2025-01-21T18:26:48+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_get_object_special_character[test@key/]": { + "last_validated_date": "2025-01-21T18:26:44+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_acl_on_delete_marker": { + "last_validated_date": "2025-01-21T18:31:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_checksum[CRC32C]": { + "last_validated_date": "2025-01-21T18:28:18+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_checksum[CRC32]": { + "last_validated_date": "2025-01-21T18:28:15+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_checksum[SHA1]": { + "last_validated_date": "2025-01-21T18:28:20+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_checksum[SHA256]": { + "last_validated_date": "2025-01-21T18:28:23+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[DEEP_ARCHIVE-False]": { + "last_validated_date": "2025-01-21T18:41:32+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[GLACIER-False]": { + "last_validated_date": "2025-01-21T18:41:22+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[GLACIER_IR-True]": { + "last_validated_date": "2025-01-21T18:41:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[INTELLIGENT_TIERING-True]": { + "last_validated_date": "2025-01-21T18:41:30+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[ONEZONE_IA-True]": { + "last_validated_date": "2025-01-21T18:41:28+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[REDUCED_REDUNDANCY-True]": { + "last_validated_date": "2025-01-21T18:41:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[STANDARD-True]": { + "last_validated_date": "2025-01-21T18:41:18+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class[STANDARD_IA-True]": { + "last_validated_date": "2025-01-21T18:41:20+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_storage_class_outposts": { + "last_validated_date": "2025-01-21T18:41:34+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_put_object_tagging_empty_list": { + "last_validated_date": "2025-01-21T18:28:08+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_putobject_with_multiple_keys": { + "last_validated_date": "2025-01-21T18:30:56+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_range_header_body_length": { + "last_validated_date": "2025-01-21T18:30:58+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_range_key_not_exists": { + "last_validated_date": "2025-01-21T18:27:47+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_region_header_exists_outside_us_east_1": { + "last_validated_date": "2025-01-21T18:26:20+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_response_structure": { + "last_validated_date": "2025-01-21T18:41:38+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_analytics_configurations": { + "last_validated_date": "2025-01-21T18:42:41+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_objects": { + "last_validated_date": "2025-01-21T18:39:22+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_objects_using_requests_with_acl": { + "last_validated_date": "2023-08-03T02:23:41+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_batch_delete_public_objects_using_requests": { + "last_validated_date": "2025-01-21T19:48:17+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_bucket_acl": { + "last_validated_date": "2025-01-21T18:30:17+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_bucket_acl_exceptions": { + "last_validated_date": "2025-01-21T18:30:22+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_checksum_no_algorithm": { + "last_validated_date": "2025-01-21T18:28:45+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_checksum_no_automatic_sdk_calculation": { + "last_validated_date": "2025-01-21T18:28:48+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_checksum_with_content_encoding": { + "last_validated_date": "2025-01-21T18:28:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_content_type_and_metadata": { + "last_validated_date": "2025-01-21T18:29:13+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_metadata_directive_copy": { + "last_validated_date": "2025-01-21T18:28:52+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_metadata_replace": { + "last_validated_date": "2025-01-21T18:28:50+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place": { + "last_validated_date": "2025-01-21T18:29:17+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_metadata_directive": { + "last_validated_date": "2025-01-21T18:29:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_storage_class": { + "last_validated_date": "2025-01-21T18:29:28+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_suspended_only": { + "last_validated_date": "2025-01-21T18:29:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_versioned": { + "last_validated_date": "2025-01-21T18:29:22+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_website_redirect_location": { + "last_validated_date": "2025-01-21T18:29:39+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_in_place_with_encryption": { + "last_validated_date": "2025-01-21T18:29:31+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_preconditions": { + "last_validated_date": "2025-01-21T18:29:56+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_storage_class": { + "last_validated_date": "2025-01-21T18:29:41+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC32C]": { + "last_validated_date": "2025-01-24T19:06:31+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC32]": { + "last_validated_date": "2025-01-24T19:06:29+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[CRC64NVME]": { + "last_validated_date": "2025-01-24T19:06:38+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[SHA1]": { + "last_validated_date": "2025-01-24T19:06:34+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_checksum[SHA256]": { + "last_validated_date": "2025-01-24T19:06:36+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC32C]": { + "last_validated_date": "2025-03-17T22:17:06+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC32]": { + "last_validated_date": "2025-03-17T22:17:03+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[CRC64NVME]": { + "last_validated_date": "2025-03-17T22:17:13+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[SHA1]": { + "last_validated_date": "2025-03-17T22:17:08+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_with_default_checksum[SHA256]": { + "last_validated_date": "2025-03-17T22:17:11+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_object_wrong_format": { + "last_validated_date": "2025-01-21T18:29:58+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[COPY]": { + "last_validated_date": "2025-01-21T18:28:54+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[None]": { + "last_validated_date": "2025-01-21T18:28:59+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive[REPLACE]": { + "last_validated_date": "2025-01-21T18:28:57+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[COPY]": { + "last_validated_date": "2025-01-21T18:29:02+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[None]": { + "last_validated_date": "2025-01-21T18:29:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_copy_tagging_directive_versioned[REPLACE]": { + "last_validated_date": "2025-01-21T18:29:06+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_delete_object_with_version_id": { + "last_validated_date": "2025-01-21T18:39:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_download_object_with_lambda": { + "last_validated_date": "2025-01-21T18:32:19+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_checksum[CRC32C]": { + "last_validated_date": "2025-01-21T18:28:29+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_checksum[CRC32]": { + "last_validated_date": "2025-01-21T18:28:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_checksum[CRC64NVME]": { + "last_validated_date": "2025-01-21T18:28:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_checksum[None]": { + "last_validated_date": "2025-01-21T18:28:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_checksum[SHA1]": { + "last_validated_date": "2025-01-21T18:28:32+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_checksum[SHA256]": { + "last_validated_date": "2025-01-21T18:28:35+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_header_overrides": { + "last_validated_date": "2025-01-21T18:39:23+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_headers": { + "last_validated_date": "2025-01-21T18:42:49+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_preconditions[get_object]": { + "last_validated_date": "2025-01-21T18:30:04+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_get_object_preconditions[head_object]": { + "last_validated_date": "2025-01-21T18:30:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_intelligent_tier_config": { + "last_validated_date": "2025-01-21T19:48:46+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_invalid_content_md5": { + "last_validated_date": "2025-01-21T18:32:49+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_inventory_report_crud": { + "last_validated_date": "2025-01-21T18:42:52+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_lambda_integration": { + "last_validated_date": "2025-01-21T18:34:07+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_multipart_upload_acls": { + "last_validated_date": "2025-01-21T18:30:13+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_multipart_upload_sse": { + "last_validated_date": "2025-03-17T21:31:09+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_acl": { + "last_validated_date": "2025-01-21T18:30:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_acl_exceptions": { + "last_validated_date": "2025-01-21T18:30:32+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_object_expiry": { + "last_validated_date": "2025-01-21T18:30:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_inventory_report_exceptions": { + "last_validated_date": "2025-01-21T18:42:57+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_more_than_1000_items": { + "last_validated_date": "2025-01-21T18:38:06+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_put_object_versioned": { + "last_validated_date": "2025-01-21T18:39:15+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_request_payer": { + "last_validated_date": "2025-01-21T18:31:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_request_payer_exceptions": { + "last_validated_date": "2025-01-21T18:31:43+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_bucket_key_default": { + "last_validated_date": "2025-01-21T18:42:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_default_kms_key": { + "last_validated_date": "2023-04-03T20:16:19+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_validate_kms_key": { + "last_validated_date": "2025-01-21T18:39:28+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_sse_validate_kms_key_state": { + "last_validated_date": "2025-01-21T18:39:38+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_timestamp_precision": { + "last_validated_date": "2025-01-21T18:41:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_upload_download_gzip": { + "last_validated_date": "2025-01-21T18:32:51+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_uppercase_bucket_name": { + "last_validated_date": "2025-01-21T18:34:09+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_s3_uppercase_key_names": { + "last_validated_date": "2025-01-21T18:31:47+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_set_external_hostname": { + "last_validated_date": "2025-03-17T21:30:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_big_file": { + "last_validated_date": "2025-01-21T18:39:01+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_file_multipart": { + "last_validated_date": "2025-01-21T18:26:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_upload_file_with_xml_preamble": { + "last_validated_date": "2025-01-21T18:30:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_url_encoded_key[False]": { + "last_validated_date": "2025-01-21T18:27:09+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3::test_url_encoded_key[True]": { + "last_validated_date": "2025-01-21T18:27:06+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_date": { + "last_validated_date": "2025-01-21T18:18:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_object_expiry": { + "last_validated_date": "2025-01-21T18:18:28+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_configuration_object_expiry_versioned": { + "last_validated_date": "2025-01-21T18:18:31+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_multiple_rules": { + "last_validated_date": "2025-01-21T18:18:36+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_object_size_rules": { + "last_validated_date": "2025-01-21T18:18:38+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_bucket_lifecycle_tag_rules": { + "last_validated_date": "2025-01-21T18:18:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_delete_bucket_lifecycle_configuration": { + "last_validated_date": "2025-01-21T18:18:18+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_delete_lifecycle_configuration_on_bucket_deletion": { + "last_validated_date": "2025-01-21T18:18:20+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_lifecycle_expired_object_delete_marker": { + "last_validated_date": "2025-01-21T18:18:44+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_object_expiry_after_bucket_lifecycle_configuration": { + "last_validated_date": "2025-01-21T18:18:33+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_put_bucket_lifecycle_conf_exc": { + "last_validated_date": "2025-01-21T18:18:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLifecycle::test_s3_transition_default_minimum_object_size": { + "last_validated_date": "2025-02-03T10:15:22+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging": { + "last_validated_date": "2023-08-12T17:54:07+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_accept_wrong_grants": { + "last_validated_date": "2023-08-03T02:26:11+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_cross_locations": { + "last_validated_date": "2024-08-29T15:58:14+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketLogging::test_put_bucket_logging_wrong_target": { + "last_validated_date": "2024-08-30T11:31:48+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketReplication::test_replication_config": { + "last_validated_date": "2024-08-29T14:09:55+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3BucketReplication::test_replication_config_without_filter": { + "last_validated_date": "2023-08-03T02:13:02+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3DeepArchive::test_s3_get_deep_archive_object_restore": { + "last_validated_date": "2023-08-14T20:35:53+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[CRC32C]": { + "last_validated_date": "2025-07-07T17:40:11+00:00", + "durations_in_seconds": { + "setup": 0.63, + "call": 18.57, + "teardown": 1.06, + "total": 20.26 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[CRC32]": { + "last_validated_date": "2025-07-07T17:39:51+00:00", + "durations_in_seconds": { + "setup": 0.99, + "call": 17.13, + "teardown": 1.18, + "total": 19.3 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[SHA1]": { + "last_validated_date": "2025-07-07T17:40:51+00:00", + "durations_in_seconds": { + "setup": 0.56, + "call": 38.2, + "teardown": 1.57, + "total": 40.33 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_composite[SHA256]": { + "last_validated_date": "2025-07-07T17:41:11+00:00", + "durations_in_seconds": { + "setup": 0.67, + "call": 17.67, + "teardown": 1.12, + "total": 19.46 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_default": { + "last_validated_date": "2025-06-15T17:12:57+00:00", + "durations_in_seconds": { + "setup": 0.59, + "call": 2.92, + "teardown": 0.99, + "total": 4.5 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC32C]": { + "last_validated_date": "2025-07-07T17:41:54+00:00", + "durations_in_seconds": { + "setup": 0.74, + "call": 17.12, + "teardown": 1.01, + "total": 18.87 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC32]": { + "last_validated_date": "2025-07-07T17:41:35+00:00", + "durations_in_seconds": { + "setup": 1.26, + "call": 16.39, + "teardown": 1.06, + "total": 18.71 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object[CRC64NVME]": { + "last_validated_date": "2025-07-07T17:42:05+00:00", + "durations_in_seconds": { + "setup": 0.66, + "call": 9.59, + "teardown": 1.14, + "total": 11.39 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_complete_multipart_parts_checksum_full_object_default": { + "last_validated_date": "2025-06-15T17:12:59+00:00", + "durations_in_seconds": { + "setup": 0.5, + "call": 0.92, + "teardown": 1.0, + "total": 2.42 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC32C]": { + "last_validated_date": "2025-06-15T17:09:51+00:00", + "durations_in_seconds": { + "setup": 0.5, + "call": 0.12, + "teardown": 0.61, + "total": 1.23 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC32]": { + "last_validated_date": "2025-06-15T17:09:50+00:00", + "durations_in_seconds": { + "setup": 0.48, + "call": 0.12, + "teardown": 0.56, + "total": 1.16 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-CRC64NVME]": { + "last_validated_date": "2025-06-15T17:09:55+00:00", + "durations_in_seconds": { + "setup": 0.56, + "call": 0.11, + "teardown": 0.77, + "total": 1.44 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-SHA1]": { + "last_validated_date": "2025-06-15T17:09:52+00:00", + "durations_in_seconds": { + "setup": 0.57, + "call": 0.14, + "teardown": 0.58, + "total": 1.29 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[COMPOSITE-SHA256]": { + "last_validated_date": "2025-06-15T17:09:54+00:00", + "durations_in_seconds": { + "setup": 0.48, + "call": 0.14, + "teardown": 0.57, + "total": 1.19 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC32C]": { + "last_validated_date": "2025-06-15T17:09:58+00:00", + "durations_in_seconds": { + "setup": 0.64, + "call": 0.13, + "teardown": 0.64, + "total": 1.41 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC32]": { + "last_validated_date": "2025-06-15T17:09:56+00:00", + "durations_in_seconds": { + "setup": 0.51, + "call": 0.13, + "teardown": 0.64, + "total": 1.28 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-CRC64NVME]": { + "last_validated_date": "2025-06-15T17:10:02+00:00", + "durations_in_seconds": { + "setup": 0.51, + "call": 0.15, + "teardown": 0.64, + "total": 1.3 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-SHA1]": { + "last_validated_date": "2025-06-15T17:09:59+00:00", + "durations_in_seconds": { + "setup": 0.51, + "call": 0.11, + "teardown": 0.91, + "total": 1.53 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_compatibility[FULL_OBJECT-SHA256]": { + "last_validated_date": "2025-06-15T17:10:01+00:00", + "durations_in_seconds": { + "setup": 0.51, + "call": 0.12, + "teardown": 0.83, + "total": 1.46 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC32C]": { + "last_validated_date": "2025-06-15T17:10:05+00:00", + "durations_in_seconds": { + "setup": 0.57, + "call": 0.13, + "teardown": 0.63, + "total": 1.33 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC32]": { + "last_validated_date": "2025-06-15T17:10:03+00:00", + "durations_in_seconds": { + "setup": 0.49, + "call": 0.12, + "teardown": 0.6, + "total": 1.21 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[CRC64NVME]": { + "last_validated_date": "2025-06-15T17:10:08+00:00", + "durations_in_seconds": { + "setup": 0.47, + "call": 0.11, + "teardown": 0.57, + "total": 1.15 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[SHA1]": { + "last_validated_date": "2025-06-15T17:10:06+00:00", + "durations_in_seconds": { + "setup": 0.59, + "call": 0.14, + "teardown": 0.6, + "total": 1.33 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_checksum_type_default_for_checksum[SHA256]": { + "last_validated_date": "2025-06-15T17:10:07+00:00", + "durations_in_seconds": { + "setup": 0.5, + "call": 0.11, + "teardown": 0.57, + "total": 1.18 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_parts_checksum_exceptions_composite": { + "last_validated_date": "2025-06-15T17:11:25+00:00", + "durations_in_seconds": { + "setup": 0.52, + "call": 12.45, + "teardown": 0.89, + "total": 13.86 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_parts_checksum_exceptions_full_object": { + "last_validated_date": "2025-06-15T17:12:52+00:00", + "durations_in_seconds": { + "setup": 0.49, + "call": 42.48, + "teardown": 1.16, + "total": 44.13 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_size_validation": { + "last_validated_date": "2025-06-15T17:13:02+00:00", + "durations_in_seconds": { + "setup": 0.53, + "call": 1.14, + "teardown": 1.03, + "total": 2.7 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC32C]": { + "last_validated_date": "2025-06-15T17:10:28+00:00", + "durations_in_seconds": { + "setup": 0.47, + "call": 9.47, + "teardown": 0.9, + "total": 10.84 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC32]": { + "last_validated_date": "2025-06-15T17:10:18+00:00", + "durations_in_seconds": { + "setup": 0.46, + "call": 8.02, + "teardown": 0.84, + "total": 9.32 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[CRC64NVME]": { + "last_validated_date": "2025-06-15T17:11:11+00:00", + "durations_in_seconds": { + "setup": 0.52, + "call": 9.39, + "teardown": 0.81, + "total": 10.72 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[SHA1]": { + "last_validated_date": "2025-06-15T17:10:44+00:00", + "durations_in_seconds": { + "setup": 0.52, + "call": 14.71, + "teardown": 0.86, + "total": 16.09 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_checksum_exception[SHA256]": { + "last_validated_date": "2025-06-15T17:11:00+00:00", + "durations_in_seconds": { + "setup": 0.62, + "call": 14.22, + "teardown": 0.81, + "total": 15.65 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_copy_checksum[COMPOSITE]": { + "last_validated_date": "2025-06-16T10:53:40+00:00", + "durations_in_seconds": { + "setup": 0.97, + "call": 1.5, + "teardown": 1.06, + "total": 3.53 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3MultipartUploadChecksum::test_multipart_upload_part_copy_checksum[FULL_OBJECT]": { + "last_validated_date": "2025-06-16T10:53:43+00:00", + "durations_in_seconds": { + "setup": 0.52, + "call": 1.4, + "teardown": 0.94, + "total": 2.86 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_delete_locked_object": { + "last_validated_date": "2025-01-21T18:17:15+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_get_object_legal_hold": { + "last_validated_date": "2025-01-21T18:17:06+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_object_legal_hold_exc": { + "last_validated_date": "2025-01-21T18:17:12+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_put_object_with_legal_hold": { + "last_validated_date": "2025-01-21T18:17:08+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_s3_copy_object_legal_hold": { + "last_validated_date": "2025-01-21T18:17:21+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockLegalHold::test_s3_legal_hold_lock_versioned": { + "last_validated_date": "2025-01-21T18:17:18+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_bucket_config_default_retention": { + "last_validated_date": "2025-06-20T17:35:53+00:00", + "durations_in_seconds": { + "setup": 0.48, + "call": 1.55, + "teardown": 1.78, + "total": 3.81 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_object_lock_delete_markers": { + "last_validated_date": "2025-01-21T18:18:05+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_object_lock_extend_duration": { + "last_validated_date": "2025-01-21T18:18:07+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_copy_object_retention_lock": { + "last_validated_date": "2025-01-21T18:18:00+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_lock_mode_validation": { + "last_validated_date": "2025-06-20T17:33:41+00:00", + "durations_in_seconds": { + "setup": 0.43, + "call": 1.68, + "teardown": 0.86, + "total": 2.97 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention": { + "last_validated_date": "2025-06-20T17:02:02+00:00", + "durations_in_seconds": { + "setup": 0.47, + "call": 12.42, + "teardown": 0.62, + "total": 13.51 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention_compliance_mode": { + "last_validated_date": "2025-06-20T17:19:57+00:00", + "durations_in_seconds": { + "setup": 0.44, + "call": 11.7, + "teardown": 1.29, + "total": 13.43 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3ObjectLockRetention::test_s3_object_retention_exc": { + "last_validated_date": "2025-06-20T16:29:08+00:00", + "durations_in_seconds": { + "setup": 0.5, + "call": 3.38, + "teardown": 2.69, + "total": 6.57 + } + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_default_checksum": { + "last_validated_date": "2025-03-17T21:46:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_casing[s3]": { + "last_validated_date": "2025-03-28T19:11:34+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_casing[s3v4]": { + "last_validated_date": "2025-03-28T19:11:36+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_conditions_validation_eq": { + "last_validated_date": "2025-03-17T20:16:55+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_conditions_validation_starts_with": { + "last_validated_date": "2025-03-17T20:16:58+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_policy_validation_size": { + "last_validated_date": "2025-03-17T20:17:02+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_file_as_string": { + "last_validated_date": "2025-03-17T20:16:51+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_metadata": { + "last_validated_date": "2025-03-17T21:56:03+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_storage_class": { + "last_validated_date": "2025-03-17T20:16:47+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[invalid]": { + "last_validated_date": "2025-03-17T20:16:41+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[list]": { + "last_validated_date": "2025-03-17T20:16:39+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[notxml]": { + "last_validated_date": "2025-03-17T20:16:43+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_tags[single]": { + "last_validated_date": "2025-03-17T20:16:38+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_object_with_wrong_content_type": { + "last_validated_date": "2025-03-17T20:16:49+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_expires": { + "last_validated_date": "2025-03-17T20:16:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_malformed_policy[s3]": { + "last_validated_date": "2025-03-17T20:16:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_malformed_policy[s3v4]": { + "last_validated_date": "2025-03-17T20:16:27+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_fields[s3]": { + "last_validated_date": "2025-03-17T20:16:32+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_fields[s3v4]": { + "last_validated_date": "2025-03-17T20:16:34+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_signature[s3]": { + "last_validated_date": "2025-03-17T20:16:29+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_post_request_missing_signature[s3v4]": { + "last_validated_date": "2025-03-17T20:16:30+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_presigned_post_with_different_user_credentials": { + "last_validated_date": "2025-03-17T20:17:16+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedPost::test_s3_presigned_post_success_action_redirect": { + "last_validated_date": "2025-03-17T20:16:36+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_delete_has_empty_content_length_header": { + "last_validated_date": "2025-01-21T18:22:48+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_get_object_ignores_request_body": { + "last_validated_date": "2025-01-21T18:23:01+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_head_has_correct_content_length_header": { + "last_validated_date": "2025-01-21T18:22:49+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_pre_signed_url_forward_slash_bucket": { + "last_validated_date": "2025-01-21T18:25:38+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_pre_signed_url_if_match": { + "last_validated_date": "2025-05-15T13:08:44+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_pre_signed_url_if_none_match": { + "last_validated_date": "2025-05-15T12:51:09+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presign_with_additional_query_params": { + "last_validated_date": "2025-01-21T18:22:43+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_double_encoded_credentials": { + "last_validated_date": "2025-01-21T18:23:03+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3-False]": { + "last_validated_date": "2025-01-21T18:24:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3-True]": { + "last_validated_date": "2025-01-21T18:24:28+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3v4-False]": { + "last_validated_date": "2025-01-21T18:24:31+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication[s3v4-True]": { + "last_validated_date": "2025-01-21T18:24:35+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3-False]": { + "last_validated_date": "2025-01-21T18:24:08+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3-True]": { + "last_validated_date": "2025-01-21T18:24:12+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3v4-False]": { + "last_validated_date": "2025-01-21T18:24:16+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_expired[s3v4-True]": { + "last_validated_date": "2025-01-21T18:24:20+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3-False]": { + "last_validated_date": "2025-01-21T18:24:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3-True]": { + "last_validated_date": "2025-01-21T18:24:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3v4-False]": { + "last_validated_date": "2025-01-21T18:24:43+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_signature_authentication_multi_part[s3v4-True]": { + "last_validated_date": "2025-01-21T18:24:45+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_v4_signed_headers_in_qs": { + "last_validated_date": "2025-01-21T18:25:34+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_v4_x_amz_in_qs": { + "last_validated_date": "2025-03-17T21:32:11+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_with_different_user_credentials": { + "last_validated_date": "2025-01-21T18:23:55+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_presigned_url_with_session_token": { + "last_validated_date": "2025-01-21T18:23:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object": { + "last_validated_date": "2025-03-17T21:31:27+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3-False]": { + "last_validated_date": "2025-01-21T18:23:07+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3-True]": { + "last_validated_date": "2025-01-21T18:23:05+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3v4-False]": { + "last_validated_date": "2025-01-21T18:23:10+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_object_with_md5_and_chunk_signature_bad_headers[s3v4-True]": { + "last_validated_date": "2025-01-21T18:23:09+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3[False]": { + "last_validated_date": "2025-01-21T18:22:59+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3[True]": { + "last_validated_date": "2025-01-21T18:22:57+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3v4[False]": { + "last_validated_date": "2025-01-21T18:22:55+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_put_url_metadata_with_sig_s3v4[True]": { + "last_validated_date": "2025-01-21T18:22:52+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_copy_md5": { + "last_validated_date": "2025-01-21T18:24:04+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_content_type_same_as_upload_and_range": { + "last_validated_date": "2025-01-21T18:23:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_default_content_type": { + "last_validated_date": "2025-01-21T18:23:12+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_header_overrides[s3]": { + "last_validated_date": "2025-01-21T18:23:58+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_get_response_header_overrides[s3v4]": { + "last_validated_date": "2025-01-21T18:24:00+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_ignored_special_headers": { + "last_validated_date": "2025-01-21T18:25:45+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presign_url_encoding[s3]": { + "last_validated_date": "2025-01-21T18:25:40+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presign_url_encoding[s3v4]": { + "last_validated_date": "2025-01-21T18:25:42+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presigned_url_expired[s3]": { + "last_validated_date": "2025-01-21T18:23:17+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_presigned_url_expired[s3v4]": { + "last_validated_date": "2025-01-21T18:23:23+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_missing_sig_param[s3]": { + "last_validated_date": "2025-01-21T18:23:35+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_missing_sig_param[s3v4]": { + "last_validated_date": "2025-01-21T18:23:37+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_same_header_and_qs_parameter": { + "last_validated_date": "2025-01-21T18:23:33+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_with_different_headers[s3]": { + "last_validated_date": "2025-01-21T18:23:27+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PresignedUrl::test_s3_put_presigned_url_with_different_headers[s3v4]": { + "last_validated_date": "2025-01-21T18:23:31+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC32C]": { + "last_validated_date": "2025-03-17T18:27:58+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC32]": { + "last_validated_date": "2025-03-17T18:27:45+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[CRC64NVME]": { + "last_validated_date": "2025-03-17T18:28:43+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[SHA1]": { + "last_validated_date": "2025-03-17T18:28:09+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_put_object_checksum[SHA256]": { + "last_validated_date": "2025-03-17T18:28:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_no_algorithm": { + "last_validated_date": "2025-03-17T18:29:05+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_no_automatic_sdk_calculation": { + "last_validated_date": "2025-03-17T22:25:43+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_checksum_with_content_encoding": { + "last_validated_date": "2025-03-17T18:29:02+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC32C]": { + "last_validated_date": "2025-03-17T18:28:48+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC32]": { + "last_validated_date": "2025-03-17T18:28:46+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[CRC64NVME]": { + "last_validated_date": "2025-03-17T18:28:57+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[None]": { + "last_validated_date": "2025-03-17T18:29:00+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[SHA1]": { + "last_validated_date": "2025-03-17T18:28:51+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3PutObjectChecksum::test_s3_get_object_checksum[SHA256]": { + "last_validated_date": "2025-03-17T18:28:54+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_copy_object_with_sse_c": { + "last_validated_date": "2025-01-21T18:16:26+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_multipart_upload_sse_c": { + "last_validated_date": "2025-03-17T22:55:35+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_multipart_upload_sse_c_validation": { + "last_validated_date": "2025-01-21T18:16:43+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_object_retrieval_sse_c": { + "last_validated_date": "2025-01-22T14:21:48+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_default_checksum_with_sse_c": { + "last_validated_date": "2025-03-17T23:22:52+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_lifecycle_with_sse_c": { + "last_validated_date": "2025-01-21T18:16:15+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_put_object_validation_sse_c": { + "last_validated_date": "2025-01-21T18:16:18+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3SSECEncryption::test_sse_c_with_versioning": { + "last_validated_date": "2025-01-21T18:16:46+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_crud_website_configuration": { + "last_validated_date": "2023-08-25T22:29:24+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_validate_website_configuration": { + "last_validated_date": "2023-08-25T22:30:03+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_404": { + "last_validated_date": "2023-08-25T22:30:33+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_http_methods": { + "last_validated_date": "2023-08-25T22:30:55+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_index_lookup": { + "last_validated_date": "2023-08-25T22:31:15+00:00" + }, + "tests/aws/services/s3/test_s3.py::TestS3StaticWebsiteHosting::test_website_hosting_no_such_website": { + "last_validated_date": "2023-08-25T22:31:30+00:00" + } +} diff --git a/tests/aws/services/s3/test_s3_api.py b/tests/aws/services/s3/test_s3_api.py new file mode 100644 index 0000000000000..c7d39a672c077 --- /dev/null +++ b/tests/aws/services/s3/test_s3_api.py @@ -0,0 +1,2411 @@ +import datetime +import json +import string +from operator import itemgetter +from urllib.parse import urlencode + +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import SortingTransformer + +from localstack.testing.pytest import markers +from localstack.utils.strings import long_uid, short_uid +from tests.aws.services.s3.conftest import TEST_S3_IMAGE + + +class TestS3BucketCRUD: + @markers.aws.validated + def test_delete_bucket_with_objects(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + key_name = "test-delete" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-delete") + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket(Bucket=s3_bucket) + snapshot.match("delete-with-obj", e.value.response) + + delete_object = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("delete-obj", delete_object) + + delete_bucket = aws_client.s3.delete_bucket(Bucket=s3_bucket) + snapshot.match("delete-bucket", delete_bucket) + # TODO: write a test with a multipart upload that is not completed? + + @markers.aws.validated + def test_delete_versioned_bucket_with_objects(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + # enable versioning on the bucket + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + key_name = "test-delete-versioned" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-delete") + # try deleting without specifying the object version, it sets a DeleteMarker on top + put_delete_marker = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket(Bucket=s3_bucket) + snapshot.match("delete-with-obj-and-delete-marker", e.value.response) + + # delete the object directly by its version, only the delete marker is left + delete_object_by_version = aws_client.s3.delete_object( + Bucket=s3_bucket, Key=key_name, VersionId=put_object["VersionId"] + ) + snapshot.match("delete-obj-by-version", delete_object_by_version) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket(Bucket=s3_bucket) + snapshot.match("delete-with-only-delete-marker", e.value.response) + + # delete the delete marker, the bucket should now be empty + delete_marker_by_version = aws_client.s3.delete_object( + Bucket=s3_bucket, Key=key_name, VersionId=put_delete_marker["VersionId"] + ) + snapshot.match("delete-marker-by-version", delete_marker_by_version) + + delete_bucket = aws_client.s3.delete_bucket(Bucket=s3_bucket) + snapshot.match("success-delete-bucket", delete_bucket) + + +class TestS3ObjectCRUD: + @markers.aws.validated + def test_delete_object(self, s3_bucket, aws_client, snapshot): + key_name = "test-delete" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-delete") + snapshot.match("put-object", put_object) + + delete_object = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("delete-object", delete_object) + + delete_object_2 = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("delete-nonexistent-object", delete_object_2) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object( + Bucket=s3_bucket, Key=key_name, VersionId="HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD" + ) + snapshot.match("delete-nonexistent-object-versionid", e.value.response) + + @markers.aws.validated + def test_delete_objects(self, s3_bucket, aws_client, snapshot): + key_name = "test-delete" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-delete") + snapshot.match("put-object", put_object) + + delete_objects = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [{"Key": key_name, "VersionId": "HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD"}] + }, + ) + + snapshot.match("delete-object-wrong-version-id", delete_objects) + + delete_objects = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [ + {"Key": key_name}, + {"Key": "c-wrong-key"}, + {"Key": "a-wrong-key"}, + ] + }, + ) + delete_objects["Deleted"].sort(key=itemgetter("Key")) + + snapshot.match("delete-objects", delete_objects) + + @markers.aws.validated + def test_delete_object_versioned(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("ArgumentValue")) + # enable versioning on the bucket + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + + key_name = "test-delete" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-delete") + snapshot.match("put-object", put_object) + object_version_id = put_object["VersionId"] + + # try deleting the last version of the object, it sets a DeleteMarker on top + delete_object = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("delete-object", delete_object) + delete_marker_version_id = delete_object["VersionId"] + + # try GetObject without VersionId + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-deleted-object", e.value.response) + + # Boto does not parse those headers in the exception, but they are present + response_headers = e.value.response["ResponseMetadata"]["HTTPHeaders"] + assert response_headers["x-amz-delete-marker"] == "true" + assert response_headers["x-amz-version-id"] == delete_marker_version_id + + # try GetObject with VersionId + get_object_with_version = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, VersionId=object_version_id + ) + snapshot.match("get-object-with-version", get_object_with_version) + + # try GetObject on a DeleteMarker + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, VersionId=delete_marker_version_id + ) + snapshot.match("get-delete-marker", e.value.response) + + # Boto does not parse those headers in the exception, but they are present + response_headers = e.value.response["ResponseMetadata"]["HTTPHeaders"] + assert response_headers["x-amz-delete-marker"] == "true" + assert response_headers["x-amz-version-id"] == delete_marker_version_id + assert response_headers["allow"] == "DELETE" + + # delete again without specifying a VersionId, this will just pile another DeleteMarker onto the stack + delete_object_2 = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("delete-object-2", delete_object_2) + + list_object_version = aws_client.s3.list_object_versions(Bucket=s3_bucket, Prefix=key_name) + snapshot.match("list-object-versions", list_object_version) + + # delete a DeleteMarker directly + delete_marker = aws_client.s3.delete_object( + Bucket=s3_bucket, Key=key_name, VersionId=delete_marker_version_id + ) + snapshot.match("delete-delete-marker", delete_marker) + # assert that the returned VersionId is the same as the DeleteMarker, indicating that the DeleteMarker + # was deleted + assert delete_object["VersionId"] == delete_marker_version_id + + # delete the object directly, without setting a DeleteMarker + delete_object_version = aws_client.s3.delete_object( + Bucket=s3_bucket, Key=key_name, VersionId=object_version_id + ) + snapshot.match("delete-object-version", delete_object_version) + # assert that we properly deleted an object and did not set a DeleteMarker or deleted One + assert "DeleteMarker" not in delete_object_version + + # try GetObject with VersionId on the now delete ObjectVersion + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name, VersionId=object_version_id) + snapshot.match("get-deleted-object-with-version", e.value.response) + + response_headers = e.value.response["ResponseMetadata"]["HTTPHeaders"] + assert "x-amz-delete-marker" not in response_headers + assert "x-amz-version-id" not in response_headers + + # try to delete with a wrong VersionId + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object( + Bucket=s3_bucket, + Key=key_name, + VersionId=object_version_id[:-4] + "ABCD", + ) + snapshot.match("delete-with-bad-version", e.value.response) + + response_headers = e.value.response["ResponseMetadata"]["HTTPHeaders"] + assert "x-amz-delete-marker" not in response_headers + assert "x-amz-version-id" not in response_headers + + # try deleting a never existing object + delete_wrong_key = aws_client.s3.delete_object(Bucket=s3_bucket, Key="wrong-key") + snapshot.match("delete-wrong-key", delete_wrong_key) + + @markers.aws.validated + def test_delete_objects_versioned(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("DeleteMarkerVersionId")) + snapshot.add_transformer(SortingTransformer("Deleted", itemgetter("Key"))) + snapshot.add_transformer(SortingTransformer("Errors", itemgetter("Key"))) + # enable versioning on the bucket + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + + key_name = "test-delete" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-delete") + snapshot.match("put-object", put_object) + object_version_id = put_object["VersionId"] + + delete_objects = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [ + {"Key": key_name}, + {"Key": "wrongkey"}, + {"Key": "wrongkey-x"}, + ] + }, + ) + snapshot.match("delete-objects-no-version-id", delete_objects) + delete_marker_version_id = delete_objects["Deleted"][0]["DeleteMarkerVersionId"] + + # delete a DeleteMarker directly + delete_objects_marker = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [ + { + "Key": key_name, + "VersionId": delete_marker_version_id, + } + ] + }, + ) + snapshot.match("delete-objects-marker", delete_objects_marker) + + # delete with a fake VersionId + delete_objects = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [ + { + "Key": key_name, + "VersionId": "HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD", + }, + { + "Key": "wrong-key-2", + "VersionId": "HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD", + }, + ] + }, + ) + + snapshot.match("delete-objects-wrong-version-id", delete_objects) + + # delete the object directly, without setting a DeleteMarker + delete_objects_marker = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [ + { + "Key": key_name, + "VersionId": object_version_id, + } + ] + }, + ) + snapshot.match("delete-objects-version-id", delete_objects_marker) + + @markers.aws.validated + def test_get_object_with_version_unversioned_bucket(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + key_name = "test-version" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-version") + snapshot.match("put-object", put_object) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object( + Bucket=s3_bucket, Key=key_name, VersionId="HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD" + ) + snapshot.match("get-obj-with-version", e.value.response) + + get_obj = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name, VersionId="null") + snapshot.match("get-obj-with-null-version", get_obj) + + @markers.aws.validated + def test_put_object_on_suspended_bucket(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + # enable versioning on the bucket + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + key_name = "test-version" + for i in range(3): + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body=f"test-version-{i}" + ) + snapshot.match(f"put-object-{i}", put_object) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-enabled", list_object_versions) + assert len(list_object_versions["Versions"]) == 3 + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-suspended", list_object_versions) + assert len(list_object_versions["Versions"]) == 3 + + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="test-version-suspended" + ) + snapshot.match("put-object-suspended", put_object) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-suspended-after-put", list_object_versions) + assert len(list_object_versions["Versions"]) == 4 + + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="test-version-suspended" + ) + snapshot.match("put-object-suspended-overwrite", put_object) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-suspended-after-overwrite", list_object_versions) + assert len(list_object_versions["Versions"]) == 4 + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-object-current", get_object) + + @markers.aws.validated + def test_delete_object_on_suspended_bucket(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + # enable versioning on the bucket + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + key_name = "test-delete-suspended" + for i in range(2): + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body=f"test-version-{i}" + ) + snapshot.match(f"put-object-{i}", put_object) + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-suspended", list_object_versions) + assert len(list_object_versions["Versions"]) == 2 + + # delete object with no version specified + delete_object_no_version = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("delete-object-no-version", delete_object_no_version) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-suspended-delete", list_object_versions) + # assert len(list_object_versions["Versions"]) == 2 + + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="test-version-suspended-after-delete" + ) + snapshot.match("put-object-suspended", put_object) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-suspended-put", list_object_versions) + + # delete object with no version specified again, should overwrite the last object + delete_object_no_version = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("delete-object-no-version-after-put", delete_object_no_version) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-suspended-after-put", list_object_versions) + + @markers.aws.validated + def test_list_object_versions_order_unversioned(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-empty", list_object_versions) + + key_name = "a-test-object-1" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-object-1") + snapshot.match("put-object", put_object) + + key_name = "c-test-object-3" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-object-3") + snapshot.match("put-object-3", put_object) + + key_name = "b-test-object-2" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="test-object-2") + snapshot.match("put-object-2", put_object) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-versions", list_object_versions) + + @markers.aws.validated + def test_get_object_range(self, aws_client, s3_bucket, snapshot): + content = "0123456789" + key = "test-key-range" + + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=content) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=0-8") + snapshot.match("get-0-8", resp) + + resp = aws_client.s3.get_object( + Bucket=s3_bucket, Key=key, Range="bytes=0-8", ChecksumMode="ENABLED" + ) + snapshot.match("get-0-8-checksum", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=1-1") + snapshot.match("get-1-1", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=1-0") + snapshot.match("get-1-0", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=1-") + snapshot.match("get-1-", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=-1-") + snapshot.match("get--1-", resp) + + # test suffix byte range, returning the 2 last bytes + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=-2") + snapshot.match("get--2", resp) + + # test suffix byte range, returning the 9 last bytes + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=-9") + snapshot.match("get--9", resp) + + # test suffix byte range, returning the 15 last bytes, which will return max 0 + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=-15") + snapshot.match("get--15", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=0-100") + snapshot.match("get-0-100", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=0-0") + snapshot.match("get-0-0", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=0--1") + snapshot.match("get-0--1", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=0-1,3-4,7-9") + snapshot.match("get-multiple-ranges", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="0-1") + snapshot.match("get-wrong-format", resp) + + resp = aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=-") + snapshot.match("get--", resp) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=-0") + snapshot.match("get--0", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object(Bucket=s3_bucket, Key=key, Range="bytes=100-200") + snapshot.match("get-100-200", e.value.response) + + # test that we can still put an object on the same key that failed GetObject with range request + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=content * 2) + snapshot.match("put-after-failed", put_obj) + + +class TestS3Multipart: + # TODO: write a validated test for UploadPartCopy preconditions + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..PartNumberMarker"]) # TODO: investigate this + def test_upload_part_copy_range(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + src_key = "src-key" + content = "0123456789" + put_src_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=src_key, Body=content) + snapshot.match("put-src-object", put_src_object) + key = "test-upload-part-copy" + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + copy_source_key = f"{s3_bucket}/{src_key}" + parts = [] + # not using parametrization here as it needs a lot of setup for only one operation tested + src_ranges_values = [ + "0-8", + "1-1", + "0-0", + ] + for i, src_range in enumerate(src_ranges_values): + upload_part_copy = aws_client.s3.upload_part_copy( + Bucket=s3_bucket, + UploadId=upload_id, + Key=key, + PartNumber=i + 1, + CopySource=copy_source_key, + CopySourceRange=f"bytes={src_range}", + ) + snapshot.match(f"upload-part-copy-{i + 1}", upload_part_copy) + parts.append({"ETag": upload_part_copy["CopyPartResult"]["ETag"], "PartNumber": i + 1}) + + list_parts = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key, UploadId=upload_id) + snapshot.match("list-parts", list_parts) + + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part_copy( + Bucket=s3_bucket, + UploadId=upload_id, + Key=key, + PartNumber=1, + CopySource=copy_source_key, + CopySourceRange="0-8", + ) + snapshot.match("upload-part-copy-wrong-format", e.value.response) + + wrong_src_ranges_values = [ + "1-0", + "-1-", + "0--1", + "0-1,3-4,7-9", + "-", + "-0", + "0-100", + "100-200", + "1-", + "-2", + "-15", + ] + for src_range in wrong_src_ranges_values: + with pytest.raises(ClientError) as e: + aws_client.s3.upload_part_copy( + Bucket=s3_bucket, + UploadId=upload_id, + Key=key, + PartNumber=1, + CopySource=copy_source_key, + CopySourceRange=f"bytes={src_range}", + ) + snapshot.match(f"upload-part-copy-range-exc-{src_range}", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + # Not always present depending on the region + paths=["$..Owner.DisplayName"], + ) + def test_upload_part_copy_no_copy_source_range(self, aws_client, s3_bucket, snapshot): + """ + upload_part_copy should not require CopySourceRange to be populated + """ + + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + + src_key = "src-key" + content = "0123456789" + put_src_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=src_key, Body=content) + snapshot.match("put-src-object", put_src_object) + key = "test-upload-part-copy" + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + copy_source_key = f"{s3_bucket}/{src_key}" + parts = [] + upload_part_copy = aws_client.s3.upload_part_copy( + Bucket=s3_bucket, UploadId=upload_id, Key=key, PartNumber=1, CopySource=copy_source_key + ) + snapshot.match("upload-part-copy", upload_part_copy) + parts.append({"ETag": upload_part_copy["CopyPartResult"]["ETag"], "PartNumber": 1}) + + list_parts = aws_client.s3.list_parts(Bucket=s3_bucket, Key=key, UploadId=upload_id) + snapshot.match("list-parts", list_parts) + + +class TestS3BucketVersioning: + @markers.aws.validated + def test_bucket_versioning_crud(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + get_versioning_before = aws_client.s3.get_bucket_versioning(Bucket=s3_bucket) + snapshot.match("get-versioning-before", get_versioning_before) + + put_versioning_suspended_before = aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + snapshot.match("put-versioning-suspended-before", put_versioning_suspended_before) + + get_versioning_before = aws_client.s3.get_bucket_versioning(Bucket=s3_bucket) + snapshot.match("get-versioning-after-suspended", get_versioning_before) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "enabled"} + ) + snapshot.match("put-versioning-enabled-lowercase", e.value.response) + + put_versioning_enabled = aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + snapshot.match("put-versioning-enabled-capitalized", put_versioning_enabled) + + get_versioning_after = aws_client.s3.get_bucket_versioning(Bucket=s3_bucket) + snapshot.match("get-versioning-after-enabled", get_versioning_after) + + put_versioning_suspended_after = aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + snapshot.match("put-versioning-suspended-after", put_versioning_suspended_after) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Disabled"} + ) + snapshot.match("put-versioning-disabled", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_versioning(Bucket=s3_bucket, VersioningConfiguration={}) + snapshot.match("put-versioning-empty", e.value.response) + + fake_bucket = f"myrandombucket{short_uid()}-{short_uid()}" + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_versioning( + Bucket=fake_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + snapshot.match("put-versioning-no-bucket", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_versioning(Bucket=fake_bucket) + snapshot.match("get-versioning-no-bucket", e.value.response) + + @markers.aws.validated + def test_object_version_id_format(self, aws_client, s3_bucket, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key="test-version-id") + snapshot.match("put-object", put_object) + version_id = put_object["VersionId"] + + # example version id + # gS53zabD7XTvkrwbjMnXlBylVWetO8ym + # the conditions under have been tested against more than 100 AWS VersionIds + assert len(version_id) == 32 + letters_and_digits_and_dot = string.ascii_letters + string.digits + "._" + assert all(char in letters_and_digits_and_dot for char in version_id) + + +class TestS3BucketEncryption: + @markers.aws.validated + def test_s3_default_bucket_encryption(self, s3_bucket, aws_client, snapshot): + get_default_encryption = aws_client.s3.get_bucket_encryption(Bucket=s3_bucket) + snapshot.match("default-bucket-encryption", get_default_encryption) + + delete_bucket_encryption = aws_client.s3.delete_bucket_encryption(Bucket=s3_bucket) + snapshot.match("delete-bucket-encryption", delete_bucket_encryption) + + delete_bucket_encryption_2 = aws_client.s3.delete_bucket_encryption(Bucket=s3_bucket) + snapshot.match("delete-bucket-encryption-idempotent", delete_bucket_encryption_2) + + bucket_versioning = aws_client.s3.get_bucket_versioning(Bucket=s3_bucket) + snapshot.match("get-bucket-no-encryption", bucket_versioning) + + @markers.aws.validated + def test_s3_default_bucket_encryption_exc(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + fake_bucket = f"fakebucket-{short_uid()}-{short_uid()}" + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_encryption(Bucket=fake_bucket) + snapshot.match("get-bucket-enc-no-bucket", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket_encryption(Bucket=fake_bucket) + snapshot.match("delete-bucket-enc-no-bucket", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_encryption( + Bucket=fake_bucket, ServerSideEncryptionConfiguration={"Rules": []} + ) + snapshot.match("put-bucket-enc-no-bucket", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, ServerSideEncryptionConfiguration={"Rules": []} + ) + snapshot.match("put-bucket-encryption-no-rules", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms", + }, + "BucketKeyEnabled": True, + }, + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256", + }, + }, + ] + }, + ) + snapshot.match("put-bucket-encryption-two-rules", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256", + "KMSMasterKeyID": "randomkeyid", + }, + } + ] + }, + ) + snapshot.match("put-bucket-encryption-kms-with-aes", e.value.response) + + @markers.aws.validated + def test_s3_bucket_encryption_sse_s3(self, s3_bucket, aws_client, snapshot): + # AES256 is already the default + # so set something with the BucketKey, which should only be set for KMS, to see if it returns + put_bucket_enc = aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256", + }, + "BucketKeyEnabled": True, + } + ] + }, + ) + snapshot.match("put-bucket-enc", put_bucket_enc) + + key_name = "key-encrypted" + put_object_encrypted = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="test-encrypted" + ) + snapshot.match("put-object-encrypted", put_object_encrypted) + + head_object_encrypted = aws_client.s3.head_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("head-object-encrypted", head_object_encrypted) + + get_object_encrypted = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-object-encrypted", get_object_encrypted) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + # there is currently no server side encryption is place in LS, ETag will be different + @markers.snapshot.skip_snapshot_verify(paths=["$..ETag"]) + def test_s3_bucket_encryption_sse_kms(self, s3_bucket, kms_key, aws_client, snapshot): + put_bucket_enc = aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms", + "KMSMasterKeyID": kms_key["KeyId"], + }, + "BucketKeyEnabled": True, + } + ] + }, + ) + snapshot.match("put-bucket-enc", put_bucket_enc) + + get_bucket_enc = aws_client.s3.get_bucket_encryption(Bucket=s3_bucket) + snapshot.match("get-bucket-enc", get_bucket_enc) + + key_name = "key-encrypted" + put_object_encrypted = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="test-encrypted" + ) + snapshot.match("put-object-encrypted", put_object_encrypted) + + head_object_encrypted = aws_client.s3.head_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("head-object-encrypted", head_object_encrypted) + + get_object_encrypted = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-object-encrypted", get_object_encrypted) + + # disable the BucketKeyEnabled + put_bucket_enc = aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms", + "KMSMasterKeyID": kms_key["KeyId"], + }, + "BucketKeyEnabled": False, + } + ] + }, + ) + snapshot.match("put-bucket-enc-bucket-key-disabled", put_bucket_enc) + + # if the BucketKeyEnabled is False, S3 does not return the field from PutObject + key_name = "key-encrypted-bucket-key-disabled" + put_object_encrypted = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="test-encrypted" + ) + snapshot.match("put-object-encrypted-bucket-key-disabled", put_object_encrypted) + + @pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="KMS not enabled in S3 image") + @markers.aws.validated + # there is currently no server side encryption is place in LS, ETag will be different + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ETag", + "$.managed-kms-key.KeyMetadata.KeyManager", # TODO: we have no internal way to create KMS key + ] + ) + def test_s3_bucket_encryption_sse_kms_aws_managed_key(self, s3_bucket, aws_client, snapshot): + # if you don't provide a KMS key, AWS will use an AWS managed one. + put_bucket_enc = aws_client.s3.put_bucket_encryption( + Bucket=s3_bucket, + ServerSideEncryptionConfiguration={ + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms", + }, + "BucketKeyEnabled": True, + } + ] + }, + ) + snapshot.match("put-bucket-enc", put_bucket_enc) + + get_bucket_enc = aws_client.s3.get_bucket_encryption(Bucket=s3_bucket) + snapshot.match("get-bucket-enc", get_bucket_enc) + + key_name = "key-encrypted" + put_object_encrypted = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="test-encrypted" + ) + snapshot.match("put-object-encrypted", put_object_encrypted) + + kms_key_id = put_object_encrypted["SSEKMSKeyId"] + kms_key_data = aws_client.kms.describe_key(KeyId=kms_key_id) + snapshot.match("managed-kms-key", kms_key_data) + + head_object_encrypted = aws_client.s3.head_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("head-object-encrypted", head_object_encrypted) + + get_object_encrypted = aws_client.s3.get_object(Bucket=s3_bucket, Key=key_name) + snapshot.match("get-object-encrypted", get_object_encrypted) + + +class TestS3BucketObjectTagging: + @markers.aws.validated + def test_bucket_tagging_crud(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_tagging(Bucket=s3_bucket) + snapshot.match("get-bucket-tags-empty", e.value.response) + + tag_set = {"TagSet": [{"Key": "tag1", "Value": "tag1"}, {"Key": "tag2", "Value": ""}]} + + put_bucket_tags = aws_client.s3.put_bucket_tagging(Bucket=s3_bucket, Tagging=tag_set) + snapshot.match("put-bucket-tags", put_bucket_tags) + + get_bucket_tags = aws_client.s3.get_bucket_tagging(Bucket=s3_bucket) + snapshot.match("get-bucket-tags", get_bucket_tags) + + tag_set_2 = {"TagSet": [{"Key": "tag3", "Value": "tag3"}]} + + put_bucket_tags = aws_client.s3.put_bucket_tagging(Bucket=s3_bucket, Tagging=tag_set_2) + snapshot.match("put-bucket-tags-overwrite", put_bucket_tags) + + get_bucket_tags = aws_client.s3.get_bucket_tagging(Bucket=s3_bucket) + snapshot.match("get-bucket-tags-overwritten", get_bucket_tags) + + delete_bucket_tags = aws_client.s3.delete_bucket_tagging(Bucket=s3_bucket) + snapshot.match("delete-bucket-tags", delete_bucket_tags) + + # test idempotency of delete + aws_client.s3.delete_bucket_tagging(Bucket=s3_bucket) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_tagging(Bucket=s3_bucket) + e.match("NoSuchTagSet") + + # setting an empty tag set is the same as effectively deleting the TagSet + tag_set_empty = {"TagSet": []} + + put_bucket_tags = aws_client.s3.put_bucket_tagging(Bucket=s3_bucket, Tagging=tag_set_empty) + snapshot.match("put-bucket-tags-empty", put_bucket_tags) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_tagging(Bucket=s3_bucket) + e.match("NoSuchTagSet") + + @markers.aws.validated + def test_bucket_tagging_exc(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + fake_bucket = f"fake-bucket-{short_uid()}-{short_uid()}" + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_tagging(Bucket=fake_bucket) + snapshot.match("get-no-bucket-tags", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_bucket_tagging(Bucket=fake_bucket) + snapshot.match("delete-no-bucket-tags", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_tagging(Bucket=fake_bucket, Tagging={"TagSet": []}) + snapshot.match("put-no-bucket-tags", e.value.response) + + @markers.aws.validated + def test_object_tagging_crud(self, s3_bucket, aws_client, snapshot): + object_key = "test-object-tagging" + put_object = aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="test-tagging") + snapshot.match("put-object", put_object) + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tags-empty", get_bucket_tags) + + tag_set = {"TagSet": [{"Key": "tag1", "Value": "tag1"}, {"Key": "tag2", "Value": ""}]} + + put_bucket_tags = aws_client.s3.put_object_tagging( + Bucket=s3_bucket, Key=object_key, Tagging=tag_set + ) + snapshot.match("put-object-tags", put_bucket_tags) + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tags", get_bucket_tags) + + tag_set_2 = {"TagSet": [{"Key": "tag3", "Value": "tag3"}]} + + put_bucket_tags = aws_client.s3.put_object_tagging( + Bucket=s3_bucket, Key=object_key, Tagging=tag_set_2 + ) + snapshot.match("put-object-tags-overwrite", put_bucket_tags) + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tags-overwritten", get_bucket_tags) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-obj-after-tags", get_object) + + delete_bucket_tags = aws_client.s3.delete_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("delete-object-tags", delete_bucket_tags) + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tags-deleted", get_bucket_tags) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-obj-after-tags-deleted", get_object) + + @markers.aws.validated + def test_object_tagging_exc(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + snapshot.add_transformer(snapshot.transform.regex(s3_bucket, replacement="")) + fake_bucket = f"fake-bucket-{short_uid()}-{short_uid()}" + fake_key = "fake-key" + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_tagging(Bucket=fake_bucket, Key=fake_key) + snapshot.match("get-no-bucket-tags", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object_tagging(Bucket=fake_bucket, Key=fake_key) + snapshot.match("delete-no-bucket-tags", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_tagging( + Bucket=fake_bucket, Tagging={"TagSet": []}, Key=fake_key + ) + snapshot.match("put-no-bucket-tags", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=fake_key) + snapshot.match("get-no-key-tags", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object_tagging(Bucket=s3_bucket, Key=fake_key) + snapshot.match("delete-no-key-tags", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_tagging(Bucket=s3_bucket, Tagging={"TagSet": []}, Key=fake_key) + snapshot.match("put-no-key-tags", e.value.response) + + with pytest.raises(ClientError) as e: + tagging = "key1=val1&key1=val2" + aws_client.s3.put_object(Bucket=s3_bucket, Key=fake_key, Body="", Tagging=tagging) + snapshot.match("put-obj-duplicate-tagging", e.value.response) + + with pytest.raises(ClientError) as e: + tagging = "key1=val1,key2=val2" + aws_client.s3.put_object(Bucket=s3_bucket, Key=fake_key, Body="", Tagging=tagging) + snapshot.match("put-obj-wrong-format", e.value.response) + + @markers.aws.validated + def test_object_tagging_versioned(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("VersionId")) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + object_key = "test-version-tagging" + version_ids = [] + v1_tags = {"test_tag": "tagv1"} + for i in range(2): + if i == 0: + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body=f"test-{i}", Tagging=urlencode(v1_tags) + ) + else: + put_obj = aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body=f"test-{i}" + ) + snapshot.match(f"put-obj-{i}", put_obj) + version_ids.append(put_obj["VersionId"]) + + version_id_1, version_id_2 = version_ids + + tag_set_2 = {"TagSet": [{"Key": "tag3", "Value": "tag3"}]} + + # test without specifying a VersionId + put_bucket_tags = aws_client.s3.put_object_tagging( + Bucket=s3_bucket, Key=object_key, Tagging=tag_set_2 + ) + snapshot.match("put-object-tags-current-version", put_bucket_tags) + assert put_bucket_tags["VersionId"] == version_id_2 + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tags-current-version", get_bucket_tags) + + get_bucket_tags = aws_client.s3.get_object_tagging( + Bucket=s3_bucket, Key=object_key, VersionId=version_id_1 + ) + snapshot.match("get-object-tags-previous-version", get_bucket_tags) + + tag_set_2 = {"TagSet": [{"Key": "tag1", "Value": "tag1"}]} + # test by specifying a VersionId to Version1 + put_bucket_tags = aws_client.s3.put_object_tagging( + Bucket=s3_bucket, Key=object_key, VersionId=version_id_1, Tagging=tag_set_2 + ) + snapshot.match("put-object-tags-previous-version", put_bucket_tags) + assert put_bucket_tags["VersionId"] == version_id_1 + + get_bucket_tags = aws_client.s3.get_object_tagging( + Bucket=s3_bucket, Key=object_key, VersionId=version_id_1 + ) + snapshot.match("get-object-tags-previous-version-again", get_bucket_tags) + + # Put a DeleteMarker on top of the stack + delete_current = aws_client.s3.delete_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("put-delete-marker", delete_current) + version_id_delete_marker = delete_current["VersionId"] + + # test to put/get tagging on the DeleteMarker + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_tagging( + Bucket=s3_bucket, + Key=object_key, + VersionId=version_id_delete_marker, + Tagging=tag_set_2, + ) + snapshot.match("put-object-tags-delete-marker-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_tagging( + Bucket=s3_bucket, Key=object_key, VersionId=version_id_delete_marker + ) + snapshot.match("get-object-tags-delete-marker-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object_tagging( + Bucket=s3_bucket, Key=object_key, VersionId=version_id_delete_marker + ) + snapshot.match("delete-object-tags-delete-marker-id", e.value.response) + + # test to put/get tagging on latest version (DeleteMarker) + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_tagging(Bucket=s3_bucket, Key=object_key, Tagging=tag_set_2) + snapshot.match("put-object-tags-delete-marker-latest", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_tagging( + Bucket=s3_bucket, + Key=object_key, + ) + snapshot.match("get-object-tags-delete-marker-latest", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object_tagging( + Bucket=s3_bucket, + Key=object_key, + ) + snapshot.match("delete-object-tags-delete-marker-latest", e.value.response) + + @markers.aws.validated + def test_put_object_with_tags(self, s3_bucket, aws_client, snapshot): + object_key = "test-put-object-tagging" + # tagging must be a URL encoded string directly + tag_set = "tag1=tag1&tag2=tag2&tag=" + put_object = aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body="test-tagging", Tagging=tag_set + ) + snapshot.match("put-object", put_object) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + # only TagSet set with the query string format are unordered, so not using the SortingTransformer + get_object_tags["TagSet"].sort(key=itemgetter("Key")) + snapshot.match("get-object-tags", get_object_tags) + + tag_set_2 = {"TagSet": [{"Key": "tag3", "Value": "tag3"}]} + put_bucket_tags = aws_client.s3.put_object_tagging( + Bucket=s3_bucket, Key=object_key, Tagging=tag_set_2 + ) + snapshot.match("put-object-tags", put_bucket_tags) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tags-override", get_object_tags) + + head_object = aws_client.s3.head_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("head-obj", head_object) + + get_object = aws_client.s3.get_object(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-obj", get_object) + + tagging = "wrongquery&wrongagain" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="", Tagging=tagging) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + # only TagSet set with the query string format are unordered, so not using the SortingTransformer + get_object_tags["TagSet"].sort(key=itemgetter("Key")) + snapshot.match("get-object-tags-wrong-format-qs", get_object_tags) + + tagging = "key1&&&key2" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="", Tagging=tagging) + + get_object_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-tags-wrong-format-qs-2", get_object_tags) + + @markers.aws.validated + def test_object_tags_delete_or_overwrite_object(self, s3_bucket, aws_client, snapshot): + # verify that tags aren't kept after object deletion + object_key = "test-put-object-tagging-kept" + aws_client.s3.put_object( + Bucket=s3_bucket, Key=object_key, Body="create", Tagging="tag1=val1" + ) + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-after-creation", get_bucket_tags) + + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="overwrite") + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-after-overwrite", get_bucket_tags) + + # put some tags to verify they won't be kept + tag_set = {"TagSet": [{"Key": "tag3", "Value": "tag3"}]} + aws_client.s3.put_object_tagging(Bucket=s3_bucket, Key=object_key, Tagging=tag_set) + + aws_client.s3.delete_object(Bucket=s3_bucket, Key=object_key) + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body="recreate") + + get_bucket_tags = aws_client.s3.get_object_tagging(Bucket=s3_bucket, Key=object_key) + snapshot.match("get-object-after-recreation", get_bucket_tags) + + @markers.aws.validated + def test_tagging_validation(self, s3_bucket, aws_client, snapshot): + object_key = "tagging-validation" + aws_client.s3.put_object(Bucket=s3_bucket, Key=object_key, Body=b"") + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_tagging( + Bucket=s3_bucket, + Tagging={ + "TagSet": [ + {"Key": "Key1", "Value": "Val1"}, + {"Key": "Key1", "Value": "Val1"}, + ] + }, + ) + snapshot.match("put-bucket-tags-duplicate-keys", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_tagging( + Bucket=s3_bucket, + Tagging={ + "TagSet": [ + {"Key": "Key1,Key2", "Value": "Val1"}, + ] + }, + ) + snapshot.match("put-bucket-tags-invalid-key", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_tagging( + Bucket=s3_bucket, + Tagging={ + "TagSet": [ + {"Key": "Key1", "Value": "Val1,Val2"}, + ] + }, + ) + snapshot.match("put-bucket-tags-invalid-value", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_tagging( + Bucket=s3_bucket, + Tagging={ + "TagSet": [ + {"Key": "aws:prefixed", "Value": "Val1"}, + ] + }, + ) + snapshot.match("put-bucket-tags-aws-prefixed", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_tagging( + Bucket=s3_bucket, + Key=object_key, + Tagging={ + "TagSet": [ + {"Key": "Key1", "Value": "Val1"}, + {"Key": "Key1", "Value": "Val1"}, + ] + }, + ) + + snapshot.match("put-object-tags-duplicate-keys", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_tagging( + Bucket=s3_bucket, + Key=object_key, + Tagging={"TagSet": [{"Key": "Key1,Key2", "Value": "Val1"}]}, + ) + + snapshot.match("put-object-tags-invalid-field", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_tagging( + Bucket=s3_bucket, + Key=object_key, + Tagging={"TagSet": [{"Key": "aws:prefixed", "Value": "Val1"}]}, + ) + snapshot.match("put-object-tags-aws-prefixed", e.value.response) + + +class TestS3ObjectLock: + @markers.aws.validated + def test_put_object_lock_configuration_on_existing_bucket( + self, s3_bucket, aws_client, snapshot + ): + # this has been updated by AWS: + # https://aws.amazon.com/about-aws/whats-new/2023/11/amazon-s3-enabling-object-lock-buckets/ + # before, S3 buckets had to be created with a specific config to be able to be use S3 Object Lock + # however, the bucket needs to be at least versioned + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_lock_configuration(Bucket=s3_bucket) + + snapshot.match("get-object-lock-existing-bucket-no-config", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + }, + ) + snapshot.match("put-object-lock-existing-bucket-no-versioning", e.value.response) + + suspend_versioning = aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + snapshot.match("suspended-versioning", suspend_versioning) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + }, + ) + snapshot.match("put-object-lock-existing-bucket-versioning-disabled", e.value.response) + + enable_versioning = aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + snapshot.match("enabled-versioning", enable_versioning) + + put_lock_on_existing_bucket = aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + }, + ) + snapshot.match("put-object-lock-existing-bucket-enabled", put_lock_on_existing_bucket) + + get_lock_on_existing_bucket = aws_client.s3.get_object_lock_configuration(Bucket=s3_bucket) + snapshot.match("get-object-lock-existing-bucket-enabled", get_lock_on_existing_bucket) + + @markers.aws.validated + def test_get_put_object_lock_configuration(self, s3_create_bucket, aws_client, snapshot): + s3_bucket = s3_create_bucket(ObjectLockEnabledForBucket=True) + + get_lock_config = aws_client.s3.get_object_lock_configuration(Bucket=s3_bucket) + snapshot.match("get-lock-config-start", get_lock_config) + + put_lock_config = aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Mode": "GOVERNANCE", + "Days": 1, + } + }, + }, + ) + snapshot.match("put-lock-config", put_lock_config) + + get_lock_config = aws_client.s3.get_object_lock_configuration(Bucket=s3_bucket) + snapshot.match("get-lock-config", get_lock_config) + + put_lock_config_enabled = aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + }, + ) + snapshot.match("put-lock-config-enabled", put_lock_config_enabled) + + get_lock_config = aws_client.s3.get_object_lock_configuration(Bucket=s3_bucket) + snapshot.match("get-lock-config-only-enabled", get_lock_config) + + @markers.aws.validated + def test_put_object_lock_configuration_exc(self, s3_create_bucket, aws_client, snapshot): + s3_bucket = s3_create_bucket(ObjectLockEnabledForBucket=True) + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "Rule": { + "DefaultRetention": { + "Mode": "GOVERNANCE", + "Days": 1, + } + } + }, + ) + snapshot.match("put-lock-config-no-enabled", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, ObjectLockConfiguration={} + ) + snapshot.match("put-lock-config-empty", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={"ObjectLockEnabled": "Enabled", "Rule": {}}, + ) + snapshot.match("put-lock-config-empty-rule", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + "Rule": {"DefaultRetention": {}}, + }, + ) + snapshot.match("put-lock-config-empty-retention", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Mode": "GOVERNANCE", + } + }, + }, + ) + snapshot.match("put-lock-config-no-days", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Mode": "BAD-VALUE", + "Days": 1, + } + }, + }, + ) + snapshot.match("put-lock-config-bad-mode", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object_lock_configuration( + Bucket=s3_bucket, + ObjectLockConfiguration={ + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Mode": "GOVERNANCE", + "Days": 1, + "Years": 1, + } + }, + }, + ) + snapshot.match("put-lock-config-both-days-years", e.value.response) + + @markers.aws.validated + def test_get_object_lock_configuration_exc(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_lock_configuration(Bucket=s3_bucket) + snapshot.match("get-lock-config-no-enabled", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_object_lock_configuration(Bucket=f"fake-bucket-ls-{long_uid()}") + snapshot.match("get-lock-config-bucket-not-exists", e.value.response) + + @markers.aws.validated + def test_disable_versioning_on_locked_bucket(self, s3_create_bucket, aws_client, snapshot): + bucket_name = s3_create_bucket(ObjectLockEnabledForBucket=True) + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_versioning( + Bucket=bucket_name, + VersioningConfiguration={ + "Status": "Suspended", + }, + ) + snapshot.match("disable-versioning-on-locked-bucket", e.value.response) + + put_bucket_versioning_again = aws_client.s3.put_bucket_versioning( + Bucket=bucket_name, + VersioningConfiguration={ + "Status": "Enabled", + }, + ) + snapshot.match("enable-versioning-again-on-locked-bucket", put_bucket_versioning_again) + + @markers.aws.validated + def test_delete_object_with_no_locking(self, s3_bucket, aws_client, snapshot): + key = "test-delete-no-lock" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"test") + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=s3_bucket, Key=key, BypassGovernanceRetention=True) + snapshot.match("delete-object-bypass-no-lock", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=s3_bucket, Key=key, BypassGovernanceRetention=False) + snapshot.match("delete-object-bypass-no-lock-false", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_objects( + Bucket=s3_bucket, Delete={"Objects": [{"Key": key}]}, BypassGovernanceRetention=True + ) + snapshot.match("delete-objects-bypass-no-lock", e.value.response) + + +class TestS3BucketOwnershipControls: + @markers.aws.validated + def test_crud_bucket_ownership_controls(self, s3_create_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + default_s3_bucket = s3_create_bucket() + get_default_ownership = aws_client.s3.get_bucket_ownership_controls( + Bucket=default_s3_bucket + ) + snapshot.match("default-ownership", get_default_ownership) + + put_ownership = aws_client.s3.put_bucket_ownership_controls( + Bucket=default_s3_bucket, + OwnershipControls={"Rules": [{"ObjectOwnership": "ObjectWriter"}]}, + ) + snapshot.match("put-ownership", put_ownership) + + get_ownership = aws_client.s3.get_bucket_ownership_controls(Bucket=default_s3_bucket) + snapshot.match("get-ownership", get_ownership) + + delete_ownership = aws_client.s3.delete_bucket_ownership_controls(Bucket=default_s3_bucket) + snapshot.match("delete-ownership", delete_ownership) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_ownership_controls(Bucket=default_s3_bucket) + snapshot.match("get-ownership-after-delete", e.value.response) + + delete_idempotent = aws_client.s3.delete_bucket_ownership_controls(Bucket=default_s3_bucket) + snapshot.match("delete-ownership-after-delete", delete_idempotent) + + s3_bucket = s3_create_bucket(ObjectOwnership="BucketOwnerPreferred") + get_ownership_at_creation = aws_client.s3.get_bucket_ownership_controls(Bucket=s3_bucket) + snapshot.match("get-ownership-at-creation", get_ownership_at_creation) + + @markers.aws.validated + def test_bucket_ownership_controls_exc(self, s3_create_bucket, aws_client, snapshot): + default_s3_bucket = s3_create_bucket() + get_default_ownership = aws_client.s3.get_bucket_ownership_controls( + Bucket=default_s3_bucket + ) + snapshot.match("default-ownership", get_default_ownership) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_ownership_controls( + Bucket=default_s3_bucket, + OwnershipControls={ + "Rules": [ + {"ObjectOwnership": "BucketOwnerPreferred"}, + {"ObjectOwnership": "ObjectWriter"}, + ] + }, + ) + snapshot.match("put-ownership-multiple-rules", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_ownership_controls( + Bucket=default_s3_bucket, + OwnershipControls={"Rules": [{"ObjectOwnership": "RandomValue"}]}, + ) + snapshot.match("put-ownership-wrong-value", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_ownership_controls( + Bucket=default_s3_bucket, OwnershipControls={"Rules": []} + ) + snapshot.match("put-ownership-empty-rule", e.value.response) + + with pytest.raises(ClientError) as e: + s3_create_bucket(ObjectOwnership="RandomValue") + snapshot.match("ownership-wrong-value-at-creation", e.value.response) + + with pytest.raises(ClientError) as e: + s3_create_bucket(ObjectOwnership="") + snapshot.match("ownership-non-value-at-creation", e.value.response) + + +class TestS3PublicAccessBlock: + @markers.aws.validated + def test_crud_public_access_block(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + get_public_access_block = aws_client.s3.get_public_access_block(Bucket=s3_bucket) + snapshot.match("get-default-public-access-block", get_public_access_block) + + put_public_access_block = aws_client.s3.put_public_access_block( + Bucket=s3_bucket, + PublicAccessBlockConfiguration={ + "BlockPublicAcls": False, + "IgnorePublicAcls": False, + "BlockPublicPolicy": False, + }, + ) + snapshot.match("put-public-access-block", put_public_access_block) + + get_public_access_block = aws_client.s3.get_public_access_block(Bucket=s3_bucket) + snapshot.match("get-public-access-block", get_public_access_block) + + delete_public_access_block = aws_client.s3.delete_public_access_block(Bucket=s3_bucket) + snapshot.match("delete-public-access-block", delete_public_access_block) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_public_access_block(Bucket=s3_bucket) + snapshot.match("get-public-access-block-after-delete", e.value.response) + + delete_public_access_block = aws_client.s3.delete_public_access_block(Bucket=s3_bucket) + snapshot.match("idempotent-delete-public-access-block", delete_public_access_block) + + +class TestS3BucketPolicy: + @markers.aws.validated + def test_bucket_policy_crud(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.key_value("Resource")) + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + # delete the OwnershipControls so that we can set a Policy + aws_client.s3.delete_bucket_ownership_controls(Bucket=s3_bucket) + aws_client.s3.delete_public_access_block(Bucket=s3_bucket) + + # get the default Policy, should raise + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_policy(Bucket=s3_bucket) + snapshot.match("get-bucket-default-policy", e.value.response) + + # put bucket policy + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": f"arn:aws:s3:::{s3_bucket}/*", + "Principal": {"AWS": "*"}, + } + ], + } + response = aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy=json.dumps(policy)) + snapshot.match("put-bucket-policy", response) + + # retrieve and check policy config + response = aws_client.s3.get_bucket_policy(Bucket=s3_bucket) + snapshot.match("get-bucket-policy", response) + assert policy == json.loads(response["Policy"]) + + response = aws_client.s3.delete_bucket_policy(Bucket=s3_bucket) + snapshot.match("delete-bucket-policy", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.get_bucket_policy(Bucket=s3_bucket) + snapshot.match("get-bucket-policy-after-delete", e.value.response) + + response = aws_client.s3.delete_bucket_policy(Bucket=s3_bucket) + snapshot.match("delete-bucket-policy-after-delete", response) + + @markers.aws.validated + def test_bucket_policy_exc(self, s3_bucket, snapshot, aws_client): + # delete the OwnershipControls so that we can set a Policy + aws_client.s3.delete_bucket_ownership_controls(Bucket=s3_bucket) + aws_client.s3.delete_public_access_block(Bucket=s3_bucket) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy="") + snapshot.match("put-empty-bucket-policy", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy="invalid json") + snapshot.match("put-bucket-policy-randomstring", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_policy(Bucket=s3_bucket, Policy="{}") + snapshot.match("put-bucket-policy-empty-json", e.value.response) + + +class TestS3BucketAccelerateConfiguration: + @markers.aws.validated + def test_bucket_acceleration_configuration_crud(self, s3_bucket, snapshot, aws_client): + get_default_config = aws_client.s3.get_bucket_accelerate_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-default-accelerate-config", get_default_config) + + response = aws_client.s3.put_bucket_accelerate_configuration( + Bucket=s3_bucket, + AccelerateConfiguration={"Status": "Enabled"}, + ) + snapshot.match("put-bucket-accelerate-config-enabled", response) + + response = aws_client.s3.get_bucket_accelerate_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-accelerate-config-enabled", response) + + response = aws_client.s3.put_bucket_accelerate_configuration( + Bucket=s3_bucket, + AccelerateConfiguration={"Status": "Suspended"}, + ) + snapshot.match("put-bucket-accelerate-config-disabled", response) + + response = aws_client.s3.get_bucket_accelerate_configuration(Bucket=s3_bucket) + snapshot.match("get-bucket-accelerate-config-disabled", response) + + @markers.aws.validated + def test_bucket_acceleration_configuration_exc( + self, s3_bucket, s3_create_bucket, snapshot, aws_client + ): + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_accelerate_configuration( + Bucket=s3_bucket, + AccelerateConfiguration={"Status": "enabled"}, + ) + snapshot.match("put-bucket-accelerate-config-lowercase", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_accelerate_configuration( + Bucket=s3_bucket, + AccelerateConfiguration={"Status": "random"}, + ) + snapshot.match("put-bucket-accelerate-config-random", e.value.response) + + bucket_with_name = s3_create_bucket(Bucket=f"test.bucket.{long_uid()}") + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_accelerate_configuration( + Bucket=bucket_with_name, + AccelerateConfiguration={"Status": "random"}, + ) + snapshot.match("put-bucket-accelerate-config-dot-bucket", e.value.response) + + +class TestS3ObjectWritePrecondition: + """ + https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html + """ + + @pytest.fixture(autouse=True) + def add_snapshot_transformers(self, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Bucket"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("VersionId"), + snapshot.transform.key_value("DisplayName"), + snapshot.transform.key_value("ID"), + snapshot.transform.key_value("Name"), + ] + ) + snapshot.add_transformer(snapshot.transform.key_value("Location"), priority=-1) + + @markers.aws.validated + def test_put_object_if_none_match(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj", put_obj) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj-if-none-match", e.value.response) + + del_obj = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("del-obj", del_obj) + + put_obj_after_del = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj-after-del", put_obj_after_del) + + @markers.aws.validated + def test_put_object_if_none_match_validation(self, s3_bucket, aws_client, snapshot): + key = "test-precondition-validation" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key) + snapshot.match("put-obj", put_obj) + obj_etag = put_obj["ETag"] + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch=obj_etag) + snapshot.match("put-obj-if-none-match-bad-value", e.value.response) + + @markers.aws.validated + def test_multipart_if_none_match_with_delete(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj", put_obj) + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfNoneMatch="*", + ) + snapshot.match("complete-multipart-if-none-match", e.value.response) + + del_obj = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("del-obj", del_obj) + + # the previous DeleteObject request was done between the CreateMultipartUpload and completion, so it takes + # precedence + # you need to restart the whole multipart for it to work + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfNoneMatch="*", + ) + snapshot.match("complete-multipart-after-del", e.value.response) + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + complete_multipart = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfNoneMatch="*", + ) + snapshot.match("complete-multipart-after-del-restart", complete_multipart) + + @markers.aws.validated + def test_multipart_if_none_match_with_put(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj", put_obj) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfNoneMatch="*", + ) + snapshot.match("complete-multipart-if-none-match-put-during", e.value.response) + + @markers.aws.validated + def test_put_object_if_none_match_versioned_bucket(self, s3_bucket, aws_client, snapshot): + # For buckets with versioning enabled, S3 checks for the presence of a current object version with the same + # name as part of the conditional evaluation. If there is no current object version with the same name, or + # if the current object version is a delete marker, then the write operation succeeds. + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj", put_obj) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj-if-none-match", e.value.response) + + del_obj = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("del-obj", del_obj) + + # if the last object is a delete marker, then we can use IfNoneMatch + put_obj_after_del = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*") + snapshot.match("put-obj-after-del", put_obj_after_del) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-versions", list_object_versions) + + @markers.aws.validated + def test_put_object_if_match(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj", put_obj) + etag = put_obj["ETag"] + + with pytest.raises(ClientError) as e: + # empty object is provided + aws_client.s3.put_object( + Bucket=s3_bucket, Key=key, IfMatch="d41d8cd98f00b204e9800998ecf8427e" + ) + snapshot.match("put-obj-if-match-wrong-etag", e.value.response) + + put_obj_overwrite = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfMatch=etag) + snapshot.match("put-obj-overwrite", put_obj_overwrite) + + del_obj = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("del-obj", del_obj) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfMatch=etag) + snapshot.match("put-obj-if-match-key-not-exists", e.value.response) + + put_obj_after_del = aws_client.s3.put_object(Bucket=s3_bucket, Key=key) + snapshot.match("put-obj-after-del", put_obj_after_del) + + @markers.aws.validated + def test_put_object_if_match_validation(self, s3_bucket, aws_client, snapshot): + key = "test-precondition-validation" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfMatch="*") + snapshot.match("put-obj-if-match-star-value", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfMatch="abcdef") + snapshot.match("put-obj-if-match-bad-value", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfMatch="bad-char_/") + snapshot.match("put-obj-if-match-bad-value-2", e.value.response) + + @markers.aws.validated + def test_multipart_if_match_with_put(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj", put_obj) + put_obj_etag_1 = put_obj["ETag"] + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + put_obj_2 = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test2") + snapshot.match("put-obj-during", put_obj_2) + put_obj_etag_2 = put_obj_2["ETag"] + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=put_obj_etag_1, + ) + snapshot.match("complete-multipart-if-match-put-before", e.value.response) + + # the previous PutObject request was done between the CreateMultipartUpload and completion, so it takes + # precedence + # you need to restart the whole multipart for it to work + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=put_obj_etag_2, + ) + snapshot.match("complete-multipart-if-match-put-during", e.value.response) + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart-again", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + complete_multipart = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=put_obj_etag_2, + ) + snapshot.match("complete-multipart-if-match-put-before-restart", complete_multipart) + + @markers.aws.validated + def test_multipart_if_match_with_put_identical(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj", put_obj) + put_obj_etag_1 = put_obj["ETag"] + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + put_obj_2 = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj-during", put_obj_2) + # same ETag as first put + put_obj_etag_2 = put_obj_2["ETag"] + assert put_obj_etag_1 == put_obj_etag_2 + + # it seems that even if we overwrite the object with the same content, S3 will still reject the request if a + # write operation was done between creation and completion of the multipart upload, like the `Delete` + # counterpart of `IfNoneMatch` + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=put_obj_etag_2, + ) + snapshot.match("complete-multipart-if-match-put-during", e.value.response) + # the previous PutObject request was done between the CreateMultipartUpload and completion, so it takes + # precedence + # you need to restart the whole multipart for it to work + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart-again", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + complete_multipart = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=put_obj_etag_2, + ) + snapshot.match("complete-multipart-if-match-put-before-restart", complete_multipart) + + @markers.aws.validated + def test_multipart_if_match_with_delete(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj", put_obj) + obj_etag = put_obj["ETag"] + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + del_obj = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("del-obj", del_obj) + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=obj_etag, + ) + snapshot.match("complete-multipart-after-del", e.value.response) + + put_obj_2 = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj-2", put_obj_2) + obj_etag_2 = put_obj_2["ETag"] + + with pytest.raises(ClientError) as e: + # even if we recreated the object, it still fails as it was done after the start of the upload + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=obj_etag_2, + ) + snapshot.match("complete-multipart-if-match-after-put", e.value.response) + + @markers.aws.validated + def test_put_object_if_match_versioned_bucket(self, s3_bucket, aws_client, snapshot): + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj", put_obj) + put_obj_etag_1 = put_obj["ETag"] + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfMatch="abcdef") + snapshot.match("put-obj-if-none-match-bad-value", e.value.response) + + del_obj = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + snapshot.match("del-obj", del_obj) + + # if the last object is a delete marker, then we can't use IfMatch + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfMatch=put_obj_etag_1) + snapshot.match("put-obj-after-del-exc", e.value.response) + + put_obj_2 = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test-after-del") + snapshot.match("put-obj-after-del", put_obj_2) + put_obj_etag_2 = put_obj_2["ETag"] + + put_obj_3 = aws_client.s3.put_object( + Bucket=s3_bucket, Key=key, Body="test-if-match", IfMatch=put_obj_etag_2 + ) + snapshot.match("put-obj-if-match", put_obj_3) + + list_object_versions = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-versions", list_object_versions) + + @markers.aws.validated + def test_put_object_if_match_and_if_none_match_validation( + self, s3_bucket, aws_client, snapshot + ): + key = "test-precondition-validation" + + with pytest.raises(ClientError) as e: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, IfNoneMatch="*", IfMatch="abcdef") + snapshot.match("put-obj-both-precondition", e.value.response) + + @markers.aws.validated + def test_multipart_if_match_etag(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + put_obj = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="test") + snapshot.match("put-obj", put_obj) + put_obj_etag_1 = put_obj["ETag"] + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + complete_multipart_1 = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=put_obj_etag_1, + ) + snapshot.match("complete-multipart-if-match", complete_multipart_1) + + multipart_etag = complete_multipart_1["ETag"] + # those are different, because multipart etag contains the amount of parts and is the hash of the hashes of the + # part + assert put_obj_etag_1 != multipart_etag + + create_multipart = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + snapshot.match("create-multipart-overwrite", create_multipart) + upload_id = create_multipart["UploadId"] + + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key, UploadId=upload_id, Body="test", PartNumber=1 + ) + parts = [{"ETag": upload_part["ETag"], "PartNumber": 1}] + + with pytest.raises(ClientError) as e: + aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=put_obj_etag_1, + ) + snapshot.match("complete-multipart-if-match-true-etag", e.value.response) + + complete_multipart_1 = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key, + MultipartUpload={"Parts": parts}, + UploadId=upload_id, + IfMatch=multipart_etag, + ) + snapshot.match("complete-multipart-if-match-overwrite-multipart", complete_multipart_1) + + +class TestS3MetricsConfiguration: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # PutBucketMetricsConfiguration should return 204, but we return 200 + "$.put_bucket_metrics_configuration.ResponseMetadata.HTTPStatusCode", + ] + ) + def test_put_bucket_metrics_configuration(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer([snapshot.transform.key_value("Id")]) + + metric_id = short_uid() + metrics_config = {"Id": metric_id, "Filter": {"Prefix": "logs/"}} + + put_result = aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id, MetricsConfiguration=metrics_config + ) + snapshot.match("put_bucket_metrics_configuration", put_result) + + get_result = aws_client.s3.get_bucket_metrics_configuration(Bucket=s3_bucket, Id=metric_id) + snapshot.match("get_bucket_metrics_configuration", get_result) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # PutBucketMetricsConfiguration should return 204, but we return 200 + "$.overwrite_bucket_metrics_configuration.ResponseMetadata.HTTPStatusCode", + ] + ) + def test_overwrite_bucket_metrics_configuration(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer([snapshot.transform.key_value("Id")]) + + metric_id = short_uid() + metrics_config = {"Id": metric_id, "Filter": {"Prefix": "logs/"}} + + aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id, MetricsConfiguration=metrics_config + ) + + metrics_config["Filter"]["Prefix"] = "logs/new-prefix" + + overwrite_result = aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id, MetricsConfiguration=metrics_config + ) + snapshot.match("overwrite_bucket_metrics_configuration", overwrite_result) + + get_result = aws_client.s3.get_bucket_metrics_configuration(Bucket=s3_bucket, Id=metric_id) + snapshot.match("get_bucket_metrics_configuration", get_result) + + @markers.aws.validated + def test_list_bucket_metrics_configurations(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Id")) + + metric_id_1 = f"1-{short_uid()}" + metric_id_2 = f"2-{short_uid()}" + + metrics_configs = { + metric_id_1: {"Id": metric_id_1, "Filter": {"Prefix": "logs/prefix"}}, + metric_id_2: {"Id": metric_id_2, "Filter": {"Prefix": "logs/prefix"}}, + } + + for metrics_config in metrics_configs.values(): + aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metrics_config["Id"], MetricsConfiguration=metrics_config + ) + + result_configs = aws_client.s3.list_bucket_metrics_configurations(Bucket=s3_bucket) + snapshot.match("list_bucket_metrics_configurations", result_configs) + + @markers.aws.validated + def test_list_bucket_metrics_configurations_paginated(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Id"), + snapshot.transform.key_value("NextContinuationToken"), + snapshot.transform.key_value("ContinuationToken"), + ] + ) + + metrics_configs = {} + for i in range(102): + metric_id = f"{100 + i}-{short_uid()}" + metrics_configs[metric_id] = {"Id": metric_id, "Filter": {"Prefix": "logs/prefix"}} + + for metrics_config in metrics_configs.values(): + aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metrics_config["Id"], MetricsConfiguration=metrics_config + ) + + result_configs_page_1 = aws_client.s3.list_bucket_metrics_configurations(Bucket=s3_bucket) + assert len(result_configs_page_1["MetricsConfigurationList"]) == 100 + assert result_configs_page_1["NextContinuationToken"] + + result_configs_page_2 = aws_client.s3.list_bucket_metrics_configurations( + Bucket=s3_bucket, ContinuationToken=result_configs_page_1["NextContinuationToken"] + ) + snapshot.match("list_bucket_metrics_configurations_page_2", result_configs_page_2) + + @markers.aws.validated + def test_get_bucket_metrics_configuration(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Id")) + + metric_id = short_uid() + metrics_config = {"Id": metric_id, "Filter": {"Prefix": "logs/"}} + + aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id, MetricsConfiguration=metrics_config + ) + + result = aws_client.s3.get_bucket_metrics_configuration(Bucket=s3_bucket, Id=metric_id) + snapshot.match("get_bucket_metrics_configuration", result) + + @markers.aws.validated + def test_get_bucket_metrics_configuration_not_exist(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Id")) + + with pytest.raises(ClientError) as get_err: + aws_client.s3.get_bucket_metrics_configuration(Bucket=s3_bucket, Id="does-not-exist") + snapshot.match("get_bucket_metrics_configuration", get_err.value.response) + + @markers.aws.validated + def test_delete_metrics_configuration(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Id")) + + metric_id = short_uid() + metrics_config = {"Id": metric_id, "Filter": {"Prefix": "logs/"}} + + aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id, MetricsConfiguration=metrics_config + ) + + delete_result = aws_client.s3.delete_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id + ) + snapshot.match("delete_bucket_metrics_configuration", delete_result) + + with pytest.raises(ClientError) as get_err: + aws_client.s3.get_bucket_metrics_configuration(Bucket=s3_bucket, Id=metric_id) + snapshot.match("get_bucket_metrics_configuration", get_err.value.response) + + @markers.aws.validated + def test_delete_metrics_configuration_twice(self, s3_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Id")) + + metric_id = short_uid() + metrics_config = {"Id": metric_id, "Filter": {"Prefix": "logs/"}} + + aws_client.s3.put_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id, MetricsConfiguration=metrics_config + ) + + delete_result_1 = aws_client.s3.delete_bucket_metrics_configuration( + Bucket=s3_bucket, Id=metric_id + ) + snapshot.match("delete_bucket_metrics_configuration_1", delete_result_1) + + with pytest.raises(ClientError) as delete_err: + aws_client.s3.delete_bucket_metrics_configuration(Bucket=s3_bucket, Id=metric_id) + snapshot.match("delete_bucket_metrics_configuration_2", delete_err.value.response) + + +class TestS3DeletePrecondition: + @markers.aws.validated + def test_delete_object_if_match_non_express(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=s3_bucket, Key=key, IfMatch="badvalue") + snapshot.match("delete-obj-if-match", e.value.response) + + @markers.aws.validated + def test_delete_object_if_match_modified_non_express(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key) + + earlier = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(days=1) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=s3_bucket, Key=key, IfMatchLastModifiedTime=earlier) + snapshot.match("delete-obj-if-match-last-modified", e.value.response) + + @markers.aws.validated + def test_delete_object_if_match_size_non_express(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object(Bucket=s3_bucket, Key=key, IfMatchSize=10) + snapshot.match("delete-obj-if-match-size", e.value.response) + + @markers.aws.validated + def test_delete_object_if_match_all_non_express(self, s3_bucket, aws_client, snapshot): + key = "test-precondition" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key) + earlier = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(days=1) + + with pytest.raises(ClientError) as e: + aws_client.s3.delete_object( + Bucket=s3_bucket, + Key=key, + IfMatchSize=10, + IfMatch="badvalue", + IfMatchLastModifiedTime=earlier, + ) + snapshot.match("delete-obj-if-match-all", e.value.response) diff --git a/tests/aws/services/s3/test_s3_api.snapshot.json b/tests/aws/services/s3/test_s3_api.snapshot.json new file mode 100644 index 0000000000000..34d66622f8dae --- /dev/null +++ b/tests/aws/services/s3/test_s3_api.snapshot.json @@ -0,0 +1,4694 @@ +{ + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object_versioned": { + "recorded-date": "21-01-2025, 18:09:37", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "1jy6qw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a9a43d6b467d3dc6514412c3a4987415\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-deleted-object": { + "Error": { + "Code": "NoSuchKey", + "Key": "test-delete", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-object-with-version": { + "AcceptRanges": "bytes", + "Body": "test-delete", + "ChecksumCRC32": "1jy6qw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"a9a43d6b467d3dc6514412c3a4987415\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-delete-marker": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "GET", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "delete-object-2": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list-object-versions": { + "DeleteMarkers": [ + { + "IsLatest": true, + "Key": "test-delete", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + }, + { + "IsLatest": false, + "Key": "test-delete", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "test-delete", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a9a43d6b467d3dc6514412c3a4987415\"", + "IsLatest": false, + "Key": "test-delete", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-object-version": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-deleted-object-with-version": { + "Error": { + "Code": "NoSuchVersion", + "Key": "test-delete", + "Message": "The specified version does not exist.", + "VersionId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-with-bad-version": { + "Error": { + "ArgumentName": "versionId", + "ArgumentValue": "", + "Code": "InvalidArgument", + "Message": "Invalid version id specified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-wrong-key": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object": { + "recorded-date": "21-01-2025, 18:09:31", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "1jy6qw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a9a43d6b467d3dc6514412c3a4987415\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-nonexistent-object": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-nonexistent-object-versionid": { + "Error": { + "ArgumentName": "versionId", + "ArgumentValue": "HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD", + "Code": "InvalidArgument", + "Message": "Invalid version id specified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketCRUD::test_delete_versioned_bucket_with_objects": { + "recorded-date": "01-08-2023, 16:54:38", + "recorded-content": { + "delete-with-obj-and-delete-marker": { + "Error": { + "BucketName": "", + "Code": "BucketNotEmpty", + "Message": "The bucket you tried to delete is not empty. You must delete all versions in the bucket." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "delete-obj-by-version": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-with-only-delete-marker": { + "Error": { + "BucketName": "", + "Code": "BucketNotEmpty", + "Message": "The bucket you tried to delete is not empty. You must delete all versions in the bucket." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "delete-marker-by-version": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "success-delete-bucket": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_get_object_with_version_unversioned_bucket": { + "recorded-date": "21-01-2025, 18:09:42", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "jSiR5g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a8b14b49cca6ee9a2dc6e28f87cc542c\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-with-version": { + "Error": { + "ArgumentName": "versionId", + "ArgumentValue": "HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD", + "Code": "InvalidArgument", + "Message": "Invalid version id specified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-obj-with-null-version": { + "AcceptRanges": "bytes", + "Body": "test-version", + "ChecksumCRC32": "jSiR5g==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 12, + "ContentType": "binary/octet-stream", + "ETag": "\"a8b14b49cca6ee9a2dc6e28f87cc542c\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_list_object_versions_order_unversioned": { + "recorded-date": "21-01-2025, 18:09:52", + "recorded-content": { + "list-empty": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object": { + "ChecksumCRC32": "yNTGAg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1b5c4d94104ea274dc3a49a55179de86\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-3": { + "ChecksumCRC32": "JtqnLg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2532913c38a0c3046be3dc4e434df6e6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-2": { + "ChecksumCRC32": "Ud2XuA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2f3c2d190be43f3f6cd1c26ce4c59ae6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versions": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"1b5c4d94104ea274dc3a49a55179de86\"", + "IsLatest": true, + "Key": "a-test-object-1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 13, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2f3c2d190be43f3f6cd1c26ce4c59ae6\"", + "IsLatest": true, + "Key": "b-test-object-2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 13, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2532913c38a0c3046be3dc4e434df6e6\"", + "IsLatest": true, + "Key": "c-test-object-3", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 13, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketCRUD::test_delete_bucket_with_objects": { + "recorded-date": "27-07-2023, 00:25:16", + "recorded-content": { + "delete-with-obj": { + "Error": { + "BucketName": "", + "Code": "BucketNotEmpty", + "Message": "The bucket you tried to delete is not empty" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "delete-obj": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-bucket": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_objects": { + "recorded-date": "21-01-2025, 18:09:33", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "1jy6qw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a9a43d6b467d3dc6514412c3a4987415\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-wrong-version-id": { + "Errors": [ + { + "Code": "NoSuchVersion", + "Key": "test-delete", + "Message": "The specified version does not exist.", + "VersionId": "HPniJFCxqTsMuIH9KX8K8wEjNUgmABCD" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-objects": { + "Deleted": [ + { + "Key": "a-wrong-key" + }, + { + "Key": "c-wrong-key" + }, + { + "Key": "test-delete" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_objects_versioned": { + "recorded-date": "21-01-2025, 18:09:40", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "1jy6qw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"a9a43d6b467d3dc6514412c3a4987415\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-objects-no-version-id": { + "Deleted": [ + { + "DeleteMarker": true, + "DeleteMarkerVersionId": "", + "Key": "test-delete" + }, + { + "DeleteMarker": true, + "DeleteMarkerVersionId": "", + "Key": "wrongkey" + }, + { + "DeleteMarker": true, + "DeleteMarkerVersionId": "", + "Key": "wrongkey-x" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-objects-marker": { + "Deleted": [ + { + "DeleteMarker": true, + "DeleteMarkerVersionId": "", + "Key": "test-delete", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-objects-wrong-version-id": { + "Errors": [ + { + "Code": "NoSuchVersion", + "Key": "test-delete", + "Message": "The specified version does not exist.", + "VersionId": "" + }, + { + "Code": "NoSuchVersion", + "Key": "wrong-key-2", + "Message": "The specified version does not exist.", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-objects-version-id": { + "Deleted": [ + { + "Key": "test-delete", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketVersioning::test_bucket_versioning_crud": { + "recorded-date": "21-01-2025, 18:10:29", + "recorded-content": { + "get-versioning-before": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-versioning-suspended-before": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-versioning-after-suspended": { + "Status": "Suspended", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-versioning-enabled-lowercase": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-versioning-enabled-capitalized": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-versioning-after-enabled": { + "Status": "Enabled", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-versioning-suspended-after": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-versioning-disabled": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-versioning-empty": { + "Error": { + "Code": "IllegalVersioningConfigurationException", + "Message": "The Versioning element must be specified" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-versioning-no-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-versioning-no-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_put_object_on_suspended_bucket": { + "recorded-date": "21-01-2025, 18:09:46", + "recorded-content": { + "put-object-0": { + "ChecksumCRC32": "yAYCLA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-1": { + "ChecksumCRC32": "vwEyug==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-2": { + "ChecksumCRC32": "JghjAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0aafaa2dd225df253328c024ceb9efc1\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-enabled": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0aafaa2dd225df253328c024ceb9efc1\"", + "IsLatest": true, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-suspended": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0aafaa2dd225df253328c024ceb9efc1\"", + "IsLatest": true, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-suspended": { + "ChecksumCRC32": "EfW/TQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"bb7af07292c35f415ac7da933eb5c927\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-suspended-after-put": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"bb7af07292c35f415ac7da933eb5c927\"", + "IsLatest": true, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 22, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0aafaa2dd225df253328c024ceb9efc1\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-suspended-overwrite": { + "ChecksumCRC32": "EfW/TQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"bb7af07292c35f415ac7da933eb5c927\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-suspended-after-overwrite": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"bb7af07292c35f415ac7da933eb5c927\"", + "IsLatest": true, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 22, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0aafaa2dd225df253328c024ceb9efc1\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-version", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-current": { + "AcceptRanges": "bytes", + "Body": "test-version-suspended", + "ChecksumCRC32": "EfW/TQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 22, + "ContentType": "binary/octet-stream", + "ETag": "\"bb7af07292c35f415ac7da933eb5c927\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object_on_suspended_bucket": { + "recorded-date": "21-01-2025, 18:09:50", + "recorded-content": { + "put-object-0": { + "ChecksumCRC32": "yAYCLA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-1": { + "ChecksumCRC32": "vwEyug==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-suspended": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": true, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-no-version": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list-suspended-delete": { + "DeleteMarkers": [ + { + "IsLatest": true, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": false, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-suspended": { + "ChecksumCRC32": "Hgr1MQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"195a8078a76b2922899312bf556585e1\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-suspended-put": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"195a8078a76b2922899312bf556585e1\"", + "IsLatest": true, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 35, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": false, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-no-version-after-put": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list-suspended-after-put": { + "DeleteMarkers": [ + { + "IsLatest": true, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"066f1ebd4608b82ed545041ff2254d36\"", + "IsLatest": false, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b8cb478d2b9408033ceb93aa90386661\"", + "IsLatest": false, + "Key": "test-delete-suspended", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_default_bucket_encryption": { + "recorded-date": "21-01-2025, 18:10:45", + "recorded-content": { + "default-bucket-encryption": { + "ServerSideEncryptionConfiguration": { + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + }, + "BucketKeyEnabled": false + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-bucket-encryption": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-bucket-encryption-idempotent": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-no-encryption": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_default_bucket_encryption_exc": { + "recorded-date": "21-01-2025, 18:10:47", + "recorded-content": { + "get-bucket-enc-no-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-bucket-enc-no-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-bucket-enc-no-bucket": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-bucket-encryption-no-rules": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-encryption-two-rules": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-encryption-kms-with-aes": { + "Error": { + "ArgumentName": "ApplyServerSideEncryptionByDefault", + "Code": "InvalidArgument", + "Message": "a KMSMasterKeyID is not applicable if the default sse algorithm is not aws:kms or aws:kms:dsse" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_s3": { + "recorded-date": "21-01-2025, 18:10:49", + "recorded-content": { + "put-bucket-enc": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-encrypted": { + "ChecksumCRC32": "J1mCHA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"16b66fb6b9c0e864b0291fa0dbb5a946\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-encrypted": { + "AcceptRanges": "bytes", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"16b66fb6b9c0e864b0291fa0dbb5a946\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-encrypted": { + "AcceptRanges": "bytes", + "Body": "test-encrypted", + "ChecksumCRC32": "J1mCHA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"16b66fb6b9c0e864b0291fa0dbb5a946\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_kms_aws_managed_key": { + "recorded-date": "21-01-2025, 18:10:55", + "recorded-content": { + "put-bucket-enc": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-enc": { + "ServerSideEncryptionConfiguration": { + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms" + }, + "BucketKeyEnabled": true + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-encrypted": { + "BucketKeyEnabled": true, + "ChecksumCRC32": "J1mCHA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"08d16e16e9b2006587e811c5d81ea74f\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "managed-kms-key": { + "KeyMetadata": { + "AWSAccountId": "111111111111", + "Arn": "arn::kms::111111111111:key/", + "CreationDate": "datetime", + "CustomerMasterKeySpec": "SYMMETRIC_DEFAULT", + "Description": "Default key that protects my S3 objects when no other key is defined", + "Enabled": true, + "EncryptionAlgorithms": [ + "SYMMETRIC_DEFAULT" + ], + "KeyId": "", + "KeyManager": "AWS", + "KeySpec": "SYMMETRIC_DEFAULT", + "KeyState": "Enabled", + "KeyUsage": "ENCRYPT_DECRYPT", + "MultiRegion": false, + "Origin": "AWS_KMS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-encrypted": { + "AcceptRanges": "bytes", + "BucketKeyEnabled": true, + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"08d16e16e9b2006587e811c5d81ea74f\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-encrypted": { + "AcceptRanges": "bytes", + "Body": "test-encrypted", + "BucketKeyEnabled": true, + "ChecksumCRC32": "J1mCHA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"08d16e16e9b2006587e811c5d81ea74f\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_kms": { + "recorded-date": "21-01-2025, 18:10:53", + "recorded-content": { + "put-bucket-enc": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-enc": { + "ServerSideEncryptionConfiguration": { + "Rules": [ + { + "ApplyServerSideEncryptionByDefault": { + "KMSMasterKeyID": "", + "SSEAlgorithm": "aws:kms" + }, + "BucketKeyEnabled": true + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-encrypted": { + "BucketKeyEnabled": true, + "ChecksumCRC32": "J1mCHA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"ed93a03fee21ae796b5619dfb8afbe13\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-object-encrypted": { + "AcceptRanges": "bytes", + "BucketKeyEnabled": true, + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"ed93a03fee21ae796b5619dfb8afbe13\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-encrypted": { + "AcceptRanges": "bytes", + "Body": "test-encrypted", + "BucketKeyEnabled": true, + "ChecksumCRC32": "J1mCHA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 14, + "ContentType": "binary/octet-stream", + "ETag": "\"ed93a03fee21ae796b5619dfb8afbe13\"", + "LastModified": "datetime", + "Metadata": {}, + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-enc-bucket-key-disabled": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-encrypted-bucket-key-disabled": { + "ChecksumCRC32": "J1mCHA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0b507d4ef8c3b14da00a61984206ca0d\"", + "SSEKMSKeyId": "arn::kms::111111111111:key/", + "ServerSideEncryption": "aws:kms", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_bucket_tagging_crud": { + "recorded-date": "21-01-2025, 18:11:06", + "recorded-content": { + "get-bucket-tags-empty": { + "Error": { + "BucketName": "", + "Code": "NoSuchTagSet", + "Message": "The TagSet does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-bucket-tags": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-tags": { + "TagSet": [ + { + "Key": "tag1", + "Value": "tag1" + }, + { + "Key": "tag2", + "Value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-tags-overwrite": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-tags-overwritten": { + "TagSet": [ + { + "Key": "tag3", + "Value": "tag3" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-bucket-tags": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-bucket-tags-empty": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_crud": { + "recorded-date": "21-01-2025, 18:11:10", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "lpqTBg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b635a7fc30aa9091e0d236bee77e6844\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-empty": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-tags": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags": { + "TagSet": [ + { + "Key": "tag1", + "Value": "tag1" + }, + { + "Key": "tag2", + "Value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-tags-overwrite": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-overwritten": { + "TagSet": [ + { + "Key": "tag3", + "Value": "tag3" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-after-tags": { + "AcceptRanges": "bytes", + "Body": "test-tagging", + "ChecksumCRC32": "lpqTBg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 12, + "ContentType": "binary/octet-stream", + "ETag": "\"b635a7fc30aa9091e0d236bee77e6844\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "TagCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-object-tags": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-object-tags-deleted": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj-after-tags-deleted": { + "AcceptRanges": "bytes", + "Body": "test-tagging", + "ChecksumCRC32": "lpqTBg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 12, + "ContentType": "binary/octet-stream", + "ETag": "\"b635a7fc30aa9091e0d236bee77e6844\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_put_object_with_tags": { + "recorded-date": "21-01-2025, 18:11:19", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "lpqTBg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b635a7fc30aa9091e0d236bee77e6844\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags": { + "TagSet": [ + { + "Key": "tag", + "Value": "" + }, + { + "Key": "tag1", + "Value": "tag1" + }, + { + "Key": "tag2", + "Value": "tag2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-tags": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-override": { + "TagSet": [ + { + "Key": "tag3", + "Value": "tag3" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "head-obj": { + "AcceptRanges": "bytes", + "ContentLength": 12, + "ContentType": "binary/octet-stream", + "ETag": "\"b635a7fc30aa9091e0d236bee77e6844\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-obj": { + "AcceptRanges": "bytes", + "Body": "test-tagging", + "ChecksumCRC32": "lpqTBg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 12, + "ContentType": "binary/octet-stream", + "ETag": "\"b635a7fc30aa9091e0d236bee77e6844\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "TagCount": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-wrong-format-qs": { + "TagSet": [ + { + "Key": "wrongagain", + "Value": "" + }, + { + "Key": "wrongquery", + "Value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-wrong-format-qs-2": { + "TagSet": [ + { + "Key": "key1", + "Value": "" + }, + { + "Key": "key2", + "Value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_bucket_tagging_exc": { + "recorded-date": "21-01-2025, 18:11:07", + "recorded-content": { + "get-no-bucket-tags": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-no-bucket-tags": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-no-bucket-tags": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_versioned": { + "recorded-date": "21-01-2025, 18:11:16", + "recorded-content": { + "put-obj-0": { + "ChecksumCRC32": "XCKz9A==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"86639701cdcc5b39438a5f009bd74cb1\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-1": { + "ChecksumCRC32": "KyWDYg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"70a37754eb5a2e7db8cd887aaf11cda7\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-tags-current-version": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-current-version": { + "TagSet": [ + { + "Key": "tag3", + "Value": "tag3" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-previous-version": { + "TagSet": [ + { + "Key": "test_tag", + "Value": "tagv1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-tags-previous-version": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-tags-previous-version-again": { + "TagSet": [ + { + "Key": "tag1", + "Value": "tag1" + } + ], + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-object-tags-delete-marker-id": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "PUT", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "get-object-tags-delete-marker-id": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "GET", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "delete-object-tags-delete-marker-id": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "DELETE", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "put-object-tags-delete-marker-latest": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "PUT", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "get-object-tags-delete-marker-latest": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "GET", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + }, + "delete-object-tags-delete-marker-latest": { + "Error": { + "Code": "MethodNotAllowed", + "Message": "The specified method is not allowed against this resource.", + "Method": "DELETE", + "ResourceType": "DeleteMarker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 405 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_exc": { + "recorded-date": "21-01-2025, 18:11:13", + "recorded-content": { + "get-no-bucket-tags": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-no-bucket-tags": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-no-bucket-tags": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-no-key-tags": { + "Error": { + "Code": "NoSuchKey", + "Key": "/fake-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-no-key-tags": { + "Error": { + "Code": "NoSuchKey", + "Key": "fake-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-no-key-tags": { + "Error": { + "Code": "NoSuchKey", + "Key": "fake-key", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-obj-duplicate-tagging": { + "Error": { + "ArgumentName": "x-amz-tagging", + "ArgumentValue": "key1=val1&key1=val2", + "Code": "InvalidArgument", + "Message": "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-obj-wrong-format": { + "Error": { + "ArgumentName": "x-amz-tagging", + "ArgumentValue": "key1=val1,key2=val2", + "Code": "InvalidArgument", + "Message": "The header 'x-amz-tagging' shall be encoded as UTF-8 then URLEncoded URL query parameters without tag name duplicates." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tags_delete_or_overwrite_object": { + "recorded-date": "21-01-2025, 18:11:22", + "recorded-content": { + "get-object-after-creation": { + "TagSet": [ + { + "Key": "tag1", + "Value": "val1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-after-overwrite": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-after-recreation": { + "TagSet": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_tagging_validation": { + "recorded-date": "21-01-2025, 18:11:25", + "recorded-content": { + "put-bucket-tags-duplicate-keys": { + "Error": { + "Code": "InvalidTag", + "Message": "Cannot provide multiple Tags with the same key", + "TagKey": "Key1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-tags-invalid-key": { + "Error": { + "Code": "InvalidTag", + "Message": "The TagKey you have provided is invalid", + "TagKey": "Key1,Key2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-tags-invalid-value": { + "Error": { + "Code": "InvalidTag", + "Message": "The TagValue you have provided is invalid", + "TagKey": "Key1", + "TagValue": "Val1,Val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-tags-aws-prefixed": { + "Error": { + "Code": "InvalidTag", + "Message": "System tags cannot be added/updated by requester", + "TagKey": "aws:prefixed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-tags-duplicate-keys": { + "Error": { + "Code": "InvalidTag", + "Message": "Cannot provide multiple Tags with the same key", + "TagKey": "Key1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-tags-invalid-field": { + "Error": { + "Code": "InvalidTag", + "Message": "The TagKey you have provided is invalid", + "TagKey": "Key1,Key2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-object-tags-aws-prefixed": { + "Error": { + "Code": "InvalidTag", + "Message": "Your TagKey cannot be prefixed with aws:", + "TagKey": "aws:prefixed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_put_object_lock_configuration_on_existing_bucket": { + "recorded-date": "21-01-2025, 18:11:36", + "recorded-content": { + "get-object-lock-existing-bucket-no-config": { + "Error": { + "BucketName": "", + "Code": "ObjectLockConfigurationNotFoundError", + "Message": "Object Lock configuration does not exist for this bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-object-lock-existing-bucket-no-versioning": { + "Error": { + "Code": "InvalidBucketState", + "Message": "Versioning must be 'Enabled' on the bucket to apply a Object Lock configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "suspended-versioning": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-lock-existing-bucket-versioning-disabled": { + "Error": { + "Code": "InvalidBucketState", + "Message": "Versioning must be 'Enabled' on the bucket to apply a Object Lock configuration" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "enabled-versioning": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-object-lock-existing-bucket-enabled": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-lock-existing-bucket-enabled": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_get_put_object_lock_configuration": { + "recorded-date": "21-01-2025, 18:11:37", + "recorded-content": { + "get-lock-config-start": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-lock-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-lock-config": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled", + "Rule": { + "DefaultRetention": { + "Days": 1, + "Mode": "GOVERNANCE" + } + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-lock-config-enabled": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-lock-config-only-enabled": { + "ObjectLockConfiguration": { + "ObjectLockEnabled": "Enabled" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_put_object_lock_configuration_exc": { + "recorded-date": "20-06-2025, 17:03:24", + "recorded-content": { + "put-lock-config-no-enabled": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-lock-config-empty": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-lock-config-empty-rule": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-lock-config-empty-retention": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-lock-config-no-days": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-lock-config-bad-mode": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-lock-config-both-days-years": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_get_object_lock_configuration_exc": { + "recorded-date": "21-01-2025, 18:11:42", + "recorded-content": { + "get-lock-config-no-enabled": { + "Error": { + "BucketName": "", + "Code": "ObjectLockConfigurationNotFoundError", + "Message": "Object Lock configuration does not exist for this bucket" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-lock-config-bucket-not-exists": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_disable_versioning_on_locked_bucket": { + "recorded-date": "21-01-2025, 18:11:43", + "recorded-content": { + "disable-versioning-on-locked-bucket": { + "Error": { + "Code": "InvalidBucketState", + "Message": "An Object Lock configuration is present on this bucket, so the versioning state cannot be changed." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "enable-versioning-again-on-locked-bucket": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketOwnershipControls::test_crud_bucket_ownership_controls": { + "recorded-date": "10-08-2023, 02:57:08", + "recorded-content": { + "default-ownership": { + "OwnershipControls": { + "Rules": [ + { + "ObjectOwnership": "BucketOwnerEnforced" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-ownership": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-ownership": { + "OwnershipControls": { + "Rules": [ + { + "ObjectOwnership": "ObjectWriter" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-ownership": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-ownership-after-delete": { + "Error": { + "BucketName": "", + "Code": "OwnershipControlsNotFoundError", + "Message": "The bucket ownership controls were not found" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-ownership-after-delete": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-ownership-at-creation": { + "OwnershipControls": { + "Rules": [ + { + "ObjectOwnership": "BucketOwnerPreferred" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketOwnershipControls::test_bucket_ownership_controls_exc": { + "recorded-date": "10-08-2023, 03:08:54", + "recorded-content": { + "default-ownership": { + "OwnershipControls": { + "Rules": [ + { + "ObjectOwnership": "BucketOwnerEnforced" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-ownership-multiple-rules": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-ownership-wrong-value": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-ownership-empty-rule": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ownership-wrong-value-at-creation": { + "Error": { + "ArgumentName": "x-amz-object-ownership", + "Code": "InvalidArgument", + "Message": "Invalid x-amz-object-ownership header: RandomValue" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ownership-non-value-at-creation": { + "Error": { + "ArgumentName": "x-amz-object-ownership", + "Code": "InvalidArgument", + "Message": "Invalid x-amz-object-ownership header: " + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3PublicAccessBlock::test_crud_public_access_block": { + "recorded-date": "10-08-2023, 03:29:18", + "recorded-content": { + "get-default-public-access-block": { + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-public-access-block": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-public-access-block": { + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": false, + "BlockPublicPolicy": false, + "IgnorePublicAcls": false, + "RestrictPublicBuckets": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-public-access-block": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-public-access-block-after-delete": { + "Error": { + "BucketName": "", + "Code": "NoSuchPublicAccessBlockConfiguration", + "Message": "The public access block configuration was not found" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "idempotent-delete-public-access-block": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3PublicAccessBlock::test_public_access_block_exc": { + "recorded-date": "10-08-2023, 03:30:54", + "recorded-content": {} + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketPolicy::test_bucket_policy_crud": { + "recorded-date": "20-10-2023, 17:31:38", + "recorded-content": { + "get-bucket-default-policy": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucketPolicy", + "Message": "The bucket policy does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-bucket-policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-policy": { + "Policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": "s3:GetObject", + "Resource": "" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-bucket-policy": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-bucket-policy-after-delete": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucketPolicy", + "Message": "The bucket policy does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-bucket-policy-after-delete": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketPolicy::test_bucket_policy_exc": { + "recorded-date": "10-08-2023, 17:35:26", + "recorded-content": { + "put-empty-bucket-policy": { + "Error": { + "Code": "MalformedPolicy", + "Message": "Policies must be valid JSON and the first byte must be '{'" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-policy-randomstring": { + "Error": { + "Code": "MalformedPolicy", + "Message": "Policies must be valid JSON and the first byte must be '{'" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-policy-empty-json": { + "Error": { + "Code": "MalformedPolicy", + "Message": "Missing required field Statement" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketAccelerateConfiguration::test_bucket_acceleration_configuration_crud": { + "recorded-date": "10-08-2023, 18:26:06", + "recorded-content": { + "get-bucket-default-accelerate-config": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-accelerate-config-enabled": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-accelerate-config-enabled": { + "Status": "Enabled", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-bucket-accelerate-config-disabled": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-bucket-accelerate-config-disabled": { + "Status": "Suspended", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketAccelerateConfiguration::test_bucket_acceleration_configuration_exc": { + "recorded-date": "10-08-2023, 18:01:50", + "recorded-content": { + "put-bucket-accelerate-config-lowercase": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-accelerate-config-random": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-bucket-accelerate-config-dot-bucket": { + "Error": { + "Code": "InvalidRequest", + "Message": "S3 Transfer Acceleration is not supported for buckets with periods (.) in their names" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_get_object_range": { + "recorded-date": "07-07-2025, 17:50:03", + "recorded-content": { + "get-0-8": { + "AcceptRanges": "bytes", + "Body": "012345678", + "ContentLength": 9, + "ContentRange": "bytes 0-8/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-0-8-checksum": { + "AcceptRanges": "bytes", + "Body": "012345678", + "ContentLength": 9, + "ContentRange": "bytes 0-8/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-1-1": { + "AcceptRanges": "bytes", + "Body": "1", + "ContentLength": 1, + "ContentRange": "bytes 1-1/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-1-0": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-1-": { + "AcceptRanges": "bytes", + "Body": "123456789", + "ContentLength": 9, + "ContentRange": "bytes 1-9/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get--1-": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get--2": { + "AcceptRanges": "bytes", + "Body": "89", + "ContentLength": 2, + "ContentRange": "bytes 8-9/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get--9": { + "AcceptRanges": "bytes", + "Body": "123456789", + "ContentLength": 9, + "ContentRange": "bytes 1-9/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get--15": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentRange": "bytes 0-9/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-0-100": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentRange": "bytes 0-9/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-0-0": { + "AcceptRanges": "bytes", + "Body": "0", + "ContentLength": 1, + "ContentRange": "bytes 0-0/10", + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 206 + } + }, + "get-0--1": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-multiple-ranges": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-wrong-format": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get--": { + "AcceptRanges": "bytes", + "Body": "0123456789", + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 10, + "ContentType": "binary/octet-stream", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get--0": { + "Error": { + "ActualObjectSize": "10", + "Code": "InvalidRange", + "Message": "The requested range is not satisfiable", + "RangeRequested": "bytes=-0" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 416 + } + }, + "get-100-200": { + "Error": { + "ActualObjectSize": "10", + "Code": "InvalidRange", + "Message": "The requested range is not satisfiable", + "RangeRequested": "bytes=100-200" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 416 + } + }, + "put-after-failed": { + "ChecksumCRC32": "/Im61Q==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"be497c2168e374f414a351c49379c01a\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3Multipart::test_upload_part_copy_range": { + "recorded-date": "13-06-2025, 12:42:54", + "recorded-content": { + "put-src-object": { + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart": { + "Bucket": "bucket", + "Key": "test-upload-part-copy", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-copy-1": { + "CopyPartResult": { + "ETag": "\"22975d8a5ed1b91445f6c55ac121505b\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-copy-2": { + "CopyPartResult": { + "ETag": "\"c4ca4238a0b923820dcc509a6f75849b\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-copy-3": { + "CopyPartResult": { + "ETag": "\"cfcd208495d565ef66e7dff9f98764da\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-upload-part-copy", + "MaxParts": 1000, + "NextPartNumberMarker": 3, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"22975d8a5ed1b91445f6c55ac121505b\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 9 + }, + { + "ETag": "\"c4ca4238a0b923820dcc509a6f75849b\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 1 + }, + { + "ETag": "\"cfcd208495d565ef66e7dff9f98764da\"", + "LastModified": "datetime", + "PartNumber": 3, + "Size": 1 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-copy-wrong-format": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "0-8", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc-1-0": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=1-0", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc--1-": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=-1-", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc-0--1": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=0--1", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc-0-1,3-4,7-9": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=0-1,3-4,7-9", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc--": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=-", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc--0": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=-0", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc-0-100": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=0-100", + "Code": "InvalidArgument", + "Message": "Range specified is not valid for source object of size: 10" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc-100-200": { + "Error": { + "Code": "InvalidRequest", + "Message": "The specified copy range is invalid for the source object size" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc-1-": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=1-", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc--2": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=-2", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "upload-part-copy-range-exc--15": { + "Error": { + "ArgumentName": "x-amz-copy-source-range", + "ArgumentValue": "bytes=-15", + "Code": "InvalidArgument", + "Message": "The x-amz-copy-source-range value must be of the form bytes=first-last where first and last are the zero-based offsets of the first and last bytes to copy" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_delete_object_with_no_locking": { + "recorded-date": "21-01-2025, 18:11:45", + "recorded-content": { + "delete-object-bypass-no-lock": { + "Error": { + "ArgumentName": "x-amz-bypass-governance-retention", + "Code": "InvalidArgument", + "Message": "x-amz-bypass-governance-retention is only applicable to Object Lock enabled buckets." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-object-bypass-no-lock-false": { + "Error": { + "ArgumentName": "x-amz-bypass-governance-retention", + "Code": "InvalidArgument", + "Message": "x-amz-bypass-governance-retention is only applicable to Object Lock enabled buckets." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-objects-bypass-no-lock": { + "Error": { + "ArgumentName": "x-amz-bypass-governance-retention", + "Code": "InvalidArgument", + "Message": "x-amz-bypass-governance-retention is only applicable to Object Lock enabled buckets." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3Multipart::test_upload_part_copy_no_copy_source_range": { + "recorded-date": "13-06-2025, 12:42:57", + "recorded-content": { + "put-src-object": { + "ChecksumCRC32": "poTHxg==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart": { + "Bucket": "bucket", + "Key": "test-upload-part-copy", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-part-copy": { + "CopyPartResult": { + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime" + }, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-upload-part-copy", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"781e5e245d69b566979b86e28d23f2c7\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 10 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match": { + "recorded-date": "17-03-2025, 20:15:37", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-if-none-match": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-None-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "del-obj": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-obj-after-del": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match_validation": { + "recorded-date": "17-03-2025, 20:15:39", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-if-none-match-bad-value": { + "Error": { + "Code": "NotImplemented", + "Header": "If-None-Match", + "Message": "A header you provided implies functionality that is not implemented", + "additionalMessage": "We don't accept the provided value of If-None-Match header for this API" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 501 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match_versioned_bucket": { + "recorded-date": "17-03-2025, 20:15:48", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-if-none-match": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-None-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "del-obj": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-obj-after-del": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versions": { + "DeleteMarkers": [ + { + "IsLatest": false, + "Key": "test-precondition", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": true, + "Key": "test-precondition", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "test-precondition", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_none_match_with_delete": { + "recorded-date": "17-03-2025, 20:15:42", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-none-match": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-None-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "del-obj": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "complete-multipart-after-del": { + "Error": { + "Code": "ConditionalRequestConflict", + "Condition": "If-None-Match", + "Key": "test-precondition", + "Message": "The conditional request cannot succeed due to a conflicting operation against this resource." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "complete-multipart-after-del-restart": { + "Bucket": "", + "ChecksumCRC64NVME": "+bu+zv66sUM=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"60cd54a928cbbcbb6e7b5595bab46a9e-1\"", + "Key": "test-precondition", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_none_match_with_put": { + "recorded-date": "17-03-2025, 20:15:45", + "recorded-content": { + "create-multipart": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-none-match-put-during": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-None-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketVersioning::test_object_version_id_format": { + "recorded-date": "21-01-2025, 18:10:31", + "recorded-content": { + "put-object": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match": { + "recorded-date": "17-03-2025, 20:15:51", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-if-match-wrong-etag": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "put-obj-overwrite": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-obj": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-obj-if-match-key-not-exists": { + "Error": { + "Code": "NoSuchKey", + "Key": "test-precondition", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-obj-after-del": { + "ChecksumCRC32": "AAAAAA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_validation": { + "recorded-date": "17-03-2025, 20:15:53", + "recorded-content": { + "put-obj-if-match-star-value": { + "Error": { + "Code": "NotImplemented", + "Header": "If-Match", + "Message": "A header you provided implies functionality that is not implemented", + "additionalMessage": "We don't accept the provided value of If-Match header for this API" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 501 + } + }, + "put-obj-if-match-bad-value": { + "Error": { + "Code": "NoSuchKey", + "Key": "test-precondition-validation", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-obj-if-match-bad-value-2": { + "Error": { + "Code": "NoSuchKey", + "Key": "test-precondition-validation", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_put": { + "recorded-date": "17-03-2025, 20:15:56", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-during": { + "ChecksumCRC32": "E7uNWA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"ad0234829205b9033196ba818f7a872b\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-match-put-before": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "complete-multipart-if-match-put-during": { + "Error": { + "Code": "ConditionalRequestConflict", + "Condition": "If-Match", + "Key": "test-precondition", + "Message": "The conditional request cannot succeed due to a conflicting operation against this resource." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "create-multipart-again": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-match-put-before-restart": { + "Bucket": "", + "ChecksumCRC64NVME": "+bu+zv66sUM=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"60cd54a928cbbcbb6e7b5595bab46a9e-1\"", + "Key": "test-precondition", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_put_identical": { + "recorded-date": "17-03-2025, 20:15:59", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-during": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-match-put-during": { + "Error": { + "Code": "ConditionalRequestConflict", + "Condition": "If-Match", + "Key": "test-precondition", + "Message": "The conditional request cannot succeed due to a conflicting operation against this resource." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + }, + "create-multipart-again": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-match-put-before-restart": { + "Bucket": "", + "ChecksumCRC64NVME": "+bu+zv66sUM=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"60cd54a928cbbcbb6e7b5595bab46a9e-1\"", + "Key": "test-precondition", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_delete": { + "recorded-date": "17-03-2025, 20:16:02", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-obj": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "complete-multipart-after-del": { + "Error": { + "Code": "NoSuchKey", + "Key": "test-precondition", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-obj-2": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-match-after-put": { + "Error": { + "Code": "ConditionalRequestConflict", + "Condition": "If-Match", + "Key": "test-precondition", + "Message": "The conditional request cannot succeed due to a conflicting operation against this resource." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_versioned_bucket": { + "recorded-date": "17-03-2025, 20:16:06", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-if-none-match-bad-value": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "del-obj": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-obj-after-del-exc": { + "Error": { + "Code": "NoSuchKey", + "Key": "test-precondition", + "Message": "The specified key does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-obj-after-del": { + "ChecksumCRC32": "SbCV6g==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b022e6afbcd118faed117e3c2b6e7b19\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-obj-if-match": { + "ChecksumCRC32": "Dp3Z0w==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"98e41c14fd4ec56bafc444346ecb74b7\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versions": { + "DeleteMarkers": [ + { + "IsLatest": false, + "Key": "test-precondition", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"98e41c14fd4ec56bafc444346ecb74b7\"", + "IsLatest": true, + "Key": "test-precondition", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 13, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"b022e6afbcd118faed117e3c2b6e7b19\"", + "IsLatest": false, + "Key": "test-precondition", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 14, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "IsLatest": false, + "Key": "test-precondition", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 4, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_and_if_none_match_validation": { + "recorded-date": "17-03-2025, 20:16:07", + "recorded-content": { + "put-obj-both-precondition": { + "Error": { + "Code": "NotImplemented", + "Header": "If-Match,If-None-Match", + "Message": "A header you provided implies functionality that is not implemented", + "additionalMessage": "Multiple conditional request headers present in the request" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 501 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_etag": { + "recorded-date": "17-03-2025, 20:16:10", + "recorded-content": { + "put-obj": { + "ChecksumCRC32": "2H9+DA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"098f6bcd4621d373cade4e832627b4f6\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-match": { + "Bucket": "", + "ChecksumCRC64NVME": "+bu+zv66sUM=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"60cd54a928cbbcbb6e7b5595bab46a9e-1\"", + "Key": "test-precondition", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-multipart-overwrite": { + "Bucket": "", + "Key": "test-precondition", + "ServerSideEncryption": "AES256", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-multipart-if-match-true-etag": { + "Error": { + "Code": "PreconditionFailed", + "Condition": "If-Match", + "Message": "At least one of the pre-conditions you specified did not hold" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 412 + } + }, + "complete-multipart-if-match-overwrite-multipart": { + "Bucket": "", + "ChecksumCRC64NVME": "+bu+zv66sUM=", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"60cd54a928cbbcbb6e7b5595bab46a9e-1\"", + "Key": "test-precondition", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_put_bucket_metrics_configuration": { + "recorded-date": "13-06-2025, 08:33:02", + "recorded-content": { + "put_bucket_metrics_configuration": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_bucket_metrics_configuration": { + "MetricsConfiguration": { + "Filter": { + "Prefix": "logs/" + }, + "Id": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_overwrite_bucket_metrics_configuration": { + "recorded-date": "13-06-2025, 08:33:03", + "recorded-content": { + "overwrite_bucket_metrics_configuration": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_bucket_metrics_configuration": { + "MetricsConfiguration": { + "Filter": { + "Prefix": "logs/new-prefix" + }, + "Id": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_list_bucket_metrics_configurations": { + "recorded-date": "13-06-2025, 08:33:04", + "recorded-content": { + "list_bucket_metrics_configurations": { + "IsTruncated": false, + "MetricsConfigurationList": [ + { + "Filter": { + "Prefix": "logs/prefix" + }, + "Id": "" + }, + { + "Filter": { + "Prefix": "logs/prefix" + }, + "Id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_get_bucket_metrics_configuration": { + "recorded-date": "13-06-2025, 08:33:17", + "recorded-content": { + "get_bucket_metrics_configuration": { + "MetricsConfiguration": { + "Filter": { + "Prefix": "logs/" + }, + "Id": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_get_bucket_metrics_configuration_not_exist": { + "recorded-date": "13-06-2025, 08:33:18", + "recorded-content": { + "get_bucket_metrics_configuration": { + "Error": { + "Code": "NoSuchConfiguration", + "Message": "The specified configuration does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_delete_metrics_configuration": { + "recorded-date": "13-06-2025, 08:33:19", + "recorded-content": { + "delete_bucket_metrics_configuration": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get_bucket_metrics_configuration": { + "Error": { + "Code": "NoSuchConfiguration", + "Message": "The specified configuration does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_list_bucket_metrics_configurations_paginated": { + "recorded-date": "13-06-2025, 08:33:16", + "recorded-content": { + "list_bucket_metrics_configurations_page_2": { + "ContinuationToken": "", + "IsTruncated": false, + "MetricsConfigurationList": [ + { + "Filter": { + "Prefix": "logs/prefix" + }, + "Id": "" + }, + { + "Filter": { + "Prefix": "logs/prefix" + }, + "Id": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_delete_metrics_configuration_twice": { + "recorded-date": "13-06-2025, 08:33:20", + "recorded-content": { + "delete_bucket_metrics_configuration_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete_bucket_metrics_configuration_2": { + "Error": { + "Code": "NoSuchConfiguration", + "Message": "The specified configuration does not exist." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_non_express": { + "recorded-date": "23-06-2025, 08:53:20", + "recorded-content": { + "delete-obj-if-match": { + "Error": { + "Code": "NotImplemented", + "Header": "If-Match", + "Message": "A header you provided implies functionality that is not implemented" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 501 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_modified_non_express": { + "recorded-date": "23-06-2025, 09:05:52", + "recorded-content": { + "delete-obj-if-match-last-modified": { + "Error": { + "Code": "NotImplemented", + "Header": "x-amz-if-match-last-modified-time", + "Message": "A header you provided implies functionality that is not implemented" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 501 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_size_non_express": { + "recorded-date": "23-06-2025, 09:05:59", + "recorded-content": { + "delete-obj-if-match-size": { + "Error": { + "Code": "NotImplemented", + "Header": "x-amz-if-match-size", + "Message": "A header you provided implies functionality that is not implemented" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 501 + } + } + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_all_non_express": { + "recorded-date": "23-06-2025, 09:06:41", + "recorded-content": { + "delete-obj-if-match-all": { + "Error": { + "Code": "NotImplemented", + "Header": "x-amz-if-match-last-modified-time", + "Message": "A header you provided implies functionality that is not implemented" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 501 + } + } + } + } +} diff --git a/tests/aws/services/s3/test_s3_api.validation.json b/tests/aws/services/s3/test_s3_api.validation.json new file mode 100644 index 0000000000000..afbcc78afc0df --- /dev/null +++ b/tests/aws/services/s3/test_s3_api.validation.json @@ -0,0 +1,248 @@ +{ + "tests/aws/services/s3/test_s3_api.py::TestS3BucketAccelerateConfiguration::test_bucket_acceleration_configuration_crud": { + "last_validated_date": "2023-08-10T16:26:06+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketAccelerateConfiguration::test_bucket_acceleration_configuration_exc": { + "last_validated_date": "2023-08-10T16:01:50+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketCRUD::test_delete_bucket_with_objects": { + "last_validated_date": "2023-07-26T22:25:16+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketCRUD::test_delete_versioned_bucket_with_objects": { + "last_validated_date": "2024-08-29T13:20:49+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_kms": { + "last_validated_date": "2025-01-21T18:10:53+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_kms_aws_managed_key": { + "last_validated_date": "2025-01-21T18:10:55+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_bucket_encryption_sse_s3": { + "last_validated_date": "2025-01-21T18:10:49+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_default_bucket_encryption": { + "last_validated_date": "2025-01-21T18:10:45+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketEncryption::test_s3_default_bucket_encryption_exc": { + "last_validated_date": "2025-01-21T18:10:47+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_bucket_tagging_crud": { + "last_validated_date": "2025-01-21T18:11:06+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_bucket_tagging_exc": { + "last_validated_date": "2025-06-12T23:32:16+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_crud": { + "last_validated_date": "2025-01-21T18:11:10+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_exc": { + "last_validated_date": "2025-01-21T18:11:13+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tagging_versioned": { + "last_validated_date": "2025-01-21T18:11:16+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_object_tags_delete_or_overwrite_object": { + "last_validated_date": "2025-01-21T18:11:22+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_put_object_with_tags": { + "last_validated_date": "2025-01-21T18:11:19+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketObjectTagging::test_tagging_validation": { + "last_validated_date": "2025-01-21T18:11:25+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketOwnershipControls::test_bucket_ownership_controls_exc": { + "last_validated_date": "2023-08-10T01:08:54+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketOwnershipControls::test_crud_bucket_ownership_controls": { + "last_validated_date": "2023-08-10T00:57:08+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketPolicy::test_bucket_policy_crud": { + "last_validated_date": "2023-10-20T15:31:38+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketPolicy::test_bucket_policy_exc": { + "last_validated_date": "2023-08-10T15:35:26+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketVersioning::test_bucket_versioning_crud": { + "last_validated_date": "2025-01-21T18:10:29+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3BucketVersioning::test_object_version_id_format": { + "last_validated_date": "2025-01-21T18:10:31+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_all_non_express": { + "last_validated_date": "2025-06-23T09:06:43+00:00", + "durations_in_seconds": { + "setup": 0.96, + "call": 0.24, + "teardown": 1.13, + "total": 2.33 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_modified_non_express": { + "last_validated_date": "2025-06-23T09:05:53+00:00", + "durations_in_seconds": { + "setup": 1.05, + "call": 0.23, + "teardown": 1.16, + "total": 2.44 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_non_express": { + "last_validated_date": "2025-06-23T08:53:21+00:00", + "durations_in_seconds": { + "setup": 1.05, + "call": 0.24, + "teardown": 1.1, + "total": 2.39 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3DeletePrecondition::test_delete_object_if_match_size_non_express": { + "last_validated_date": "2025-06-23T09:06:00+00:00", + "durations_in_seconds": { + "setup": 0.98, + "call": 0.22, + "teardown": 1.17, + "total": 2.37 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_delete_metrics_configuration": { + "last_validated_date": "2025-06-13T08:33:19+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_delete_metrics_configuration_twice": { + "last_validated_date": "2025-06-13T08:33:20+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_get_bucket_metrics_configuration": { + "last_validated_date": "2025-06-13T08:33:17+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_get_bucket_metrics_configuration_not_exist": { + "last_validated_date": "2025-06-13T08:33:18+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_list_bucket_metrics_configurations": { + "last_validated_date": "2025-06-13T08:33:04+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_list_bucket_metrics_configurations_paginated": { + "last_validated_date": "2025-06-13T08:33:16+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_overwrite_bucket_metrics_configuration": { + "last_validated_date": "2025-06-13T08:33:03+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3MetricsConfiguration::test_put_bucket_metrics_configuration": { + "last_validated_date": "2025-06-13T08:33:02+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3Multipart::test_upload_part_copy_no_copy_source_range": { + "last_validated_date": "2025-06-13T12:42:58+00:00", + "durations_in_seconds": { + "setup": 0.55, + "call": 0.66, + "teardown": 1.07, + "total": 2.28 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3Multipart::test_upload_part_copy_range": { + "last_validated_date": "2025-06-13T12:42:55+00:00", + "durations_in_seconds": { + "setup": 1.02, + "call": 5.28, + "teardown": 1.54, + "total": 7.84 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object": { + "last_validated_date": "2025-01-21T18:09:31+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object_on_suspended_bucket": { + "last_validated_date": "2025-01-21T18:09:50+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_object_versioned": { + "last_validated_date": "2025-01-21T18:09:37+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_objects": { + "last_validated_date": "2025-01-21T18:09:33+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_delete_objects_versioned": { + "last_validated_date": "2025-01-21T18:09:40+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_get_object_range": { + "last_validated_date": "2025-07-07T17:50:04+00:00", + "durations_in_seconds": { + "setup": 1.13, + "call": 6.21, + "teardown": 0.99, + "total": 8.33 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_get_object_with_version_unversioned_bucket": { + "last_validated_date": "2025-01-21T18:09:42+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_list_object_versions_order_unversioned": { + "last_validated_date": "2025-01-21T18:09:52+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectCRUD::test_put_object_on_suspended_bucket": { + "last_validated_date": "2025-01-21T18:09:46+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_delete_object_with_no_locking": { + "last_validated_date": "2025-01-21T18:11:45+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_disable_versioning_on_locked_bucket": { + "last_validated_date": "2025-01-21T18:11:43+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_get_object_lock_configuration_exc": { + "last_validated_date": "2025-01-21T18:11:42+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_get_put_object_lock_configuration": { + "last_validated_date": "2025-01-21T18:11:37+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_put_object_lock_configuration_exc": { + "last_validated_date": "2025-06-20T17:03:25+00:00", + "durations_in_seconds": { + "setup": 0.55, + "call": 2.64, + "teardown": 0.83, + "total": 4.02 + } + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectLock::test_put_object_lock_configuration_on_existing_bucket": { + "last_validated_date": "2025-01-21T18:11:36+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_etag": { + "last_validated_date": "2025-03-17T20:16:09+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_delete": { + "last_validated_date": "2025-03-17T20:16:01+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_put": { + "last_validated_date": "2025-03-17T20:15:55+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_match_with_put_identical": { + "last_validated_date": "2025-03-17T20:15:58+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_none_match_with_delete": { + "last_validated_date": "2025-03-17T20:15:41+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_multipart_if_none_match_with_put": { + "last_validated_date": "2025-03-17T20:15:44+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match": { + "last_validated_date": "2025-03-17T20:15:50+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_and_if_none_match_validation": { + "last_validated_date": "2025-06-12T23:32:49+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_validation": { + "last_validated_date": "2025-03-17T20:15:52+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_match_versioned_bucket": { + "last_validated_date": "2025-03-17T20:16:04+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match": { + "last_validated_date": "2025-03-17T20:15:36+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match_validation": { + "last_validated_date": "2025-03-17T20:15:38+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3ObjectWritePrecondition::test_put_object_if_none_match_versioned_bucket": { + "last_validated_date": "2025-03-17T20:15:46+00:00" + }, + "tests/aws/services/s3/test_s3_api.py::TestS3PublicAccessBlock::test_crud_public_access_block": { + "last_validated_date": "2023-08-10T01:29:18+00:00" + } +} diff --git a/tests/aws/services/s3/test_s3_concurrency.py b/tests/aws/services/s3/test_s3_concurrency.py new file mode 100644 index 0000000000000..21cb41b4131ec --- /dev/null +++ b/tests/aws/services/s3/test_s3_concurrency.py @@ -0,0 +1,138 @@ +import logging +import random +import threading + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +LOG = logging.getLogger(__name__) + + +class TestParallelBucketCreation: + @markers.aws.only_localstack + def test_parallel_bucket_creation(self, aws_client_factory, cleanups): + num_threads = 10 + create_barrier = threading.Barrier(num_threads) + errored = False + + def _create_bucket(runner: int): + nonlocal errored + bucket_name = f"bucket-{short_uid()}" + s3_client = aws_client_factory( + region_name="us-east-1", aws_access_key_id=f"{runner:012d}" + ).s3 + cleanups.append(lambda: s3_client.delete_bucket(Bucket=bucket_name)) + create_barrier.wait() + try: + s3_client.create_bucket(Bucket=bucket_name) + s3_client.create_bucket(Bucket=bucket_name) + except Exception: + LOG.exception("Create bucket failed") + errored = True + + thread_list = [] + for i in range(1, num_threads + 1): + thread = threading.Thread(target=_create_bucket, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + + assert not errored + + @markers.aws.only_localstack + def test_parallel_object_creation_and_listing(self, aws_client, s3_bucket): + num_threads = 20 + create_barrier = threading.Barrier(num_threads) + errored = False + + def _create_or_list(runner: int): + nonlocal errored + create_barrier.wait() + try: + if runner % 2: + aws_client.s3.list_objects_v2(Bucket=s3_bucket) + else: + aws_client.s3.put_object( + Bucket=s3_bucket, Key=f"random-key-{runner}", Body="random" + ) + except Exception: + LOG.exception("Listing objects failed") + errored = True + + thread_list = [] + for i in range(1, num_threads + 1): + thread = threading.Thread(target=_create_or_list, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + + assert not errored + + @markers.aws.only_localstack + def test_parallel_object_creation_and_read(self, aws_client, s3_bucket): + num_threads = 100 + create_barrier = threading.Barrier(num_threads) + errored = False + aws_client.s3.put_object(Bucket=s3_bucket, Key="random-key", Body="random") + + def _create_or_read(runner: int): + nonlocal errored + create_barrier.wait() + try: + if random.choice((1, 2)) == 1: + aws_client.s3.get_object(Bucket=s3_bucket, Key="random-key") + else: + aws_client.s3.put_object(Bucket=s3_bucket, Key="random-key", Body="random") + except Exception: + LOG.exception("Put/get objects failed") + errored = True + + thread_list = [] + for i in range(1, num_threads + 1): + thread = threading.Thread(target=_create_or_read, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + + assert not errored + + @markers.aws.only_localstack + def test_parallel_object_read_range(self, aws_client, s3_bucket): + num_threads = 100 + create_barrier = threading.Barrier(num_threads) + errored = False + body = "random" * 100 + len_body = len(body) + aws_client.s3.put_object(Bucket=s3_bucket, Key="random-key", Body=body) + + def _create_or_read(runner: int): + nonlocal errored + create_barrier.wait() + try: + start = random.randrange(0, len_body) + end = random.randrange(start, len_body) + + aws_client.s3.get_object( + Bucket=s3_bucket, Key="random-key", Range=f"bytes={start}-{end}" + ) + + except Exception: + LOG.exception("get ranged objects failed") + errored = True + + thread_list = [] + for i in range(1, num_threads + 1): + thread = threading.Thread(target=_create_or_read, args=[i]) + thread.start() + thread_list.append(thread) + + for thread in thread_list: + thread.join() + + assert not errored diff --git a/tests/aws/services/s3/test_s3_cors.py b/tests/aws/services/s3/test_s3_cors.py new file mode 100644 index 0000000000000..4e513d5be6804 --- /dev/null +++ b/tests/aws/services/s3/test_s3_cors.py @@ -0,0 +1,827 @@ +import gzip +from io import BytesIO + +import pytest +import requests +import xmltodict +from botocore.exceptions import ClientError + +from localstack import config +from localstack.aws.handlers.cors import ALLOWED_CORS_ORIGINS +from localstack.config import S3_VIRTUAL_HOSTNAME +from localstack.constants import ( + AWS_REGION_US_EAST_1, + LOCALHOST_HOSTNAME, +) +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID +from localstack.testing.pytest import markers +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.strings import checksum_crc32, short_uid + + +def _bucket_url_vhost(bucket_name: str, region: str = "", localstack_host: str = None) -> str: + if not region: + region = AWS_REGION_US_EAST_1 + if is_aws_cloud(): + if region == "us-east-1": + return f"https://{bucket_name}.s3.amazonaws.com" + else: + return f"https://{bucket_name}.s3.{region}.amazonaws.com" + host = localstack_host or ( + f"s3.{region}.{LOCALHOST_HOSTNAME}" if region != "us-east-1" else S3_VIRTUAL_HOSTNAME + ) + s3_edge_url = config.external_service_url(host=host) + # TODO might add the region here + return s3_edge_url.replace(f"://{host}", f"://{bucket_name}.{host}") + + +@pytest.fixture +def snapshot_headers(snapshot): + # should remove localstack specific headers as well + snapshot.add_transformer( + [ + snapshot.transform.key_value("x-amz-id-2"), + snapshot.transform.key_value("x-amz-request-id"), + snapshot.transform.key_value("date", reference_replacement=False), + snapshot.transform.key_value("Last-Modified", reference_replacement=False), + snapshot.transform.key_value("server"), + ] + ) + + +@pytest.fixture +def match_headers(snapshot, snapshot_headers): + def _match(key: str, response: requests.Response): + # lower case some server specific headers + lower_case_headers = {"Date", "Server", "Accept-Ranges"} + headers = { + k if k not in lower_case_headers else k.lower(): v + for k, v in dict(response.headers).items() + } + match_object = { + "StatusCode": response.status_code, + "Headers": headers, + } + if ( + response.headers.get("Content-Type") in ("application/xml", "text/xml") + and response.content + ): + match_object["Body"] = xmltodict.parse(response.content) + else: + match_object["Body"] = response.text + snapshot.match(key, match_object) + + return _match + + +@pytest.fixture(autouse=True) +def allow_bucket_acl(s3_bucket, aws_client): + """ + # Since April 2023, AWS will by default block setting ACL to your bucket and object. You need to manually disable + # the BucketOwnershipControls and PublicAccessBlock to make your objects public. + # See https://aws.amazon.com/about-aws/whats-new/2022/12/amazon-s3-automatically-enable-block-public-access-disable-access-control-lists-buckets-april-2023/ + """ + aws_client.s3.delete_bucket_ownership_controls(Bucket=s3_bucket) + aws_client.s3.delete_public_access_block(Bucket=s3_bucket) + + +@markers.snapshot.skip_snapshot_verify( + paths=["$..x-amz-id-2"] # we're currently using a static value in LocalStack +) +class TestS3Cors: + @markers.aws.validated + def test_cors_http_options_no_config(self, s3_bucket, snapshot, aws_client, allow_bucket_acl): + snapshot.add_transformer( + [ + snapshot.transform.key_value("HostId", reference_replacement=False), + snapshot.transform.key_value("RequestId"), + ] + ) + key = "test-cors-options-no-config" + body = "cors-test" + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=body, ACL="public-read") + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + key_url = f"{_bucket_url_vhost(bucket_name=s3_bucket)}/{key}" + + response = requests.options(key_url) + assert response.status_code == 400 + # yes, a body in an `options` request + parsed_response = xmltodict.parse(response.content) + snapshot.match("options-no-origin", parsed_response) + + response = requests.options( + key_url, headers={"Origin": "whatever", "Access-Control-Request-Method": "PUT"} + ) + assert response.status_code == 403 + parsed_response = xmltodict.parse(response.content) + snapshot.match("options-with-origin-and-method", parsed_response) + + response = requests.options(key_url, headers={"Origin": "whatever"}) + assert response.status_code == 403 + parsed_response = xmltodict.parse(response.content) + snapshot.match("options-with-origin-no-method", parsed_response) + + @markers.aws.validated + def test_cors_http_get_no_config(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("HostId", reference_replacement=False), + snapshot.transform.key_value("RequestId"), + ] + ) + key = "test-cors-get-no-config" + body = "cors-test" + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=body, ACL="public-read") + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + key_url = f"{_bucket_url_vhost(bucket_name=s3_bucket)}/{key}" + + response = requests.get(key_url) + assert response.status_code == 200 + assert response.text == body + assert not any("access-control" in header.lower() for header in response.headers) + + response = requests.get(key_url, headers={"Origin": "whatever"}) + assert response.status_code == 200 + assert response.text == body + assert not any("access-control" in header.lower() for header in response.headers) + + @markers.aws.only_localstack + def test_cors_no_config_localstack_allowed(self, s3_bucket, aws_client): + key = "test-cors-get-no-config" + body = "cors-test" + response = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=body, ACL="public-read") + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + key_url = f"{_bucket_url_vhost(bucket_name=s3_bucket)}/{key}" + origin = ALLOWED_CORS_ORIGINS[0] + + response = requests.options( + key_url, headers={"Origin": origin, "Access-Control-Request-Method": "PUT"} + ) + assert response.ok + assert response.headers["Access-Control-Allow-Origin"] == origin + + response = requests.get(key_url, headers={"Origin": origin}) + assert response.status_code == 200 + assert response.text == body + assert response.headers["Access-Control-Allow-Origin"] == origin + + @markers.aws.only_localstack + def test_cors_list_buckets(self, region_name): + # ListBuckets is an operation outside S3 CORS configuration management + # it should follow the default rules of LocalStack + + url = f"{config.internal_service_url()}/" + origin = ALLOWED_CORS_ORIGINS[0] + # we need to "sign" the request so that our service name parser recognize ListBuckets as an S3 operation + # if the request isn't signed, AWS will redirect to https://aws.amazon.com/s3/ + headers = mock_aws_request_headers( + "s3", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + ) + headers["Origin"] = origin + response = requests.options( + url, headers={**headers, "Access-Control-Request-Method": "GET"} + ) + assert response.ok + assert response.headers["Access-Control-Allow-Origin"] == origin + + response = requests.get(url, headers=headers) + assert response.status_code == 200 + assert response.headers["Access-Control-Allow-Origin"] == origin + # assert that we're getting ListBuckets result + assert b"" + } + }, + "options-with-origin-and-method": { + "Error": { + "Code": "AccessForbidden", + "HostId": "host-id", + "Message": "CORSResponse: CORS is not enabled for this bucket.", + "Method": "PUT", + "RequestId": "", + "ResourceType": "BUCKET" + } + }, + "options-with-origin-no-method": { + "Error": { + "Code": "AccessForbidden", + "HostId": "host-id", + "Message": "CORSResponse: CORS is not enabled for this bucket.", + "Method": "OPTIONS", + "RequestId": "", + "ResourceType": "BUCKET" + } + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_get_no_config": { + "recorded-date": "31-07-2023, 12:31:37", + "recorded-content": {} + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_options_non_existent_bucket": { + "recorded-date": "31-07-2023, 12:31:40", + "recorded-content": { + "options-no-origin": { + "Error": { + "Code": "BadRequest", + "HostId": "host-id", + "Message": "Insufficient information. Origin request header needed.", + "RequestId": "" + } + }, + "options-with-origin": { + "Error": { + "Code": "AccessForbidden", + "HostId": "host-id", + "Message": "CORSResponse: Bucket not found", + "Method": "OPTIONS", + "RequestId": "", + "ResourceType": "BUCKET" + } + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_origins": { + "recorded-date": "31-07-2023, 12:31:46", + "recorded-content": { + "opt-no-origin": { + "Body": { + "Error": { + "Code": "BadRequest", + "HostId": "", + "Message": "Insufficient information. Origin request header needed.", + "RequestId": "" + } + }, + "Headers": { + "Connection": "close", + "Content-Type": "application/xml", + "Transfer-Encoding": "chunked", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 400 + }, + "get-no-origin": { + "Body": "test-cors", + "Headers": { + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "opt-referer": { + "Body": { + "Error": { + "Code": "BadRequest", + "HostId": "", + "Message": "Insufficient information. Origin request header needed.", + "RequestId": "" + } + }, + "Headers": { + "Connection": "close", + "Content-Type": "application/xml", + "Transfer-Encoding": "chunked", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 400 + }, + "get-referer": { + "Body": "test-cors", + "Headers": { + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "opt-right-origin": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "get-right-origin": { + "Body": "test-cors", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "opt-wrong-origin": { + "Body": { + "Error": { + "Code": "AccessForbidden", + "HostId": "", + "Message": "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + "Method": "PUT", + "RequestId": "", + "ResourceType": "OBJECT" + } + }, + "Headers": { + "Content-Type": "application/xml", + "Transfer-Encoding": "chunked", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 403 + }, + "get-wrong-origin": { + "Body": "test-cors", + "Headers": { + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "opt-random-wildcard-origin": { + "Body": "", + "Headers": { + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Origin": "*", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "get-random-wildcard-origin": { + "Body": "test-cors", + "Headers": { + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Origin": "*", + "Access-Control-Max-Age": "3000", + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_methods": { + "recorded-date": "17-03-2025, 20:18:58", + "recorded-content": { + "opt-get": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "get-wrong-op": { + "Body": "test-cors", + "Headers": { + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "get-op": { + "Body": "test-cors", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "opt-put": { + "Body": { + "Error": { + "Code": "AccessForbidden", + "HostId": "", + "Message": "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + "Method": "PUT", + "RequestId": "", + "ResourceType": "OBJECT" + } + }, + "Headers": { + "Content-Type": "application/xml", + "Transfer-Encoding": "chunked", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 403 + }, + "put-op": { + "Body": "", + "Headers": { + "Content-Length": "0", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "date": "date", + "server": "", + "x-amz-checksum-crc64nvme": "AAAAAAAAAAA=", + "x-amz-checksum-type": "FULL_OBJECT", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_headers": { + "recorded-date": "07-07-2025, 17:12:03", + "recorded-content": { + "opt-get": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "x-amz-request-payer", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "opt-get-two": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "x-amz-request-payer, x-amz-expected-bucket-owner", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "get-op": { + "Body": "test-cors", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "get-bucket-cors-casing": { + "CORSRules": [ + { + "AllowedHeaders": [ + "x-amz-expected-bucket-owner", + "x-amz-server-side-encryption-customer-algorithm", + "x-AMZ-server-SIDE-encryption" + ], + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "https://localhost:4200" + ], + "MaxAgeSeconds": 3000 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "opt-get-non-allowed": { + "Body": { + "Error": { + "Code": "AccessForbidden", + "HostId": "", + "Message": "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + "Method": "GET", + "RequestId": "", + "ResourceType": "OBJECT" + } + }, + "Headers": { + "Content-Type": "application/xml", + "Transfer-Encoding": "chunked", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 403 + }, + "opt-get-allowed": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "x-amz-expected-bucket-owner", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "opt-get-allowed-diff-casing": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "x-amz-expected-bucket-owner, x-amz-server-side-encryption", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "opt-get-allowed-no-space": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "x-amz-expected-bucket-owner, x-amz-server-side-encryption", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "get-non-allowed-with-acl": { + "Body": "test-cors", + "Headers": { + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + }, + "get-non-allowed": { + "Body": "test-cors", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "9", + "Content-Type": "binary/octet-stream", + "ETag": "\"e94e402d42b2ca551212dbac49d5a38b\"", + "Last-Modified": "last--modified", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "accept-ranges": "bytes", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "", + "x-amz-server-side-encryption": "AES256" + }, + "StatusCode": 200 + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_get_cors": { + "recorded-date": "31-07-2023, 12:31:54", + "recorded-content": { + "get-cors-no-set": { + "Error": { + "BucketName": "", + "Code": "NoSuchCORSConfiguration", + "Message": "The CORS configuration does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-cors-after-set": { + "CORSRules": [ + { + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors": { + "recorded-date": "31-07-2023, 12:31:56", + "recorded-content": { + "put-cors": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-cors": { + "CORSRules": [ + { + "AllowedHeaders": [ + "x-amz-expected-bucket-owner", + "x-amz-server-side-encryption-customer-algorithm" + ], + "AllowedMethods": [ + "GET", + "PUT", + "HEAD" + ], + "AllowedOrigins": [ + "https://test.com", + "https://app.test.com", + "http://test.com:80" + ], + "MaxAgeSeconds": 3000 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_default_values": { + "recorded-date": "31-07-2023, 12:34:48", + "recorded-content": { + "opt-get": { + "Body": "", + "Headers": { + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "*", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, + "opt-get-headers": { + "Body": { + "Error": { + "Code": "AccessForbidden", + "HostId": "", + "Message": "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + "Method": "GET", + "RequestId": "", + "ResourceType": "OBJECT" + } + }, + "Headers": { + "Content-Type": "application/xml", + "Transfer-Encoding": "chunked", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 403 + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_invalid_rules": { + "recorded-date": "31-07-2023, 12:31:59", + "recorded-content": { + "put-cors-exc": { + "Error": { + "Code": "InvalidRequest", + "Message": "Found unsupported HTTP method in CORS config. Unsupported method is MYMETHOD" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put-cors-exc-empty": { + "Error": { + "Code": "MalformedXML", + "Message": "The XML you provided was not well-formed or did not validate against our published schema" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_delete_cors": { + "recorded-date": "31-07-2023, 12:32:03", + "recorded-content": { + "delete-cors-before-set": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "put-cors": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-cors": { + "CORSRules": [ + { + "AllowedMethods": [ + "GET" + ], + "AllowedOrigins": [ + "*" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-cors": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-cors-deleted": { + "Error": { + "BucketName": "", + "Code": "NoSuchCORSConfiguration", + "Message": "The CORS configuration does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_expose_headers": { + "recorded-date": "31-07-2023, 12:34:45", + "recorded-content": { + "opt-get": { + "Body": "", + "Headers": { + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "x-amz-id-2, x-amz-request-id, x-amz-request-payer", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_empty_origin": { + "recorded-date": "31-07-2023, 12:32:01", + "recorded-content": { + "get-cors-empty": { + "CORSRules": [ + { + "AllowedMethods": [ + "GET", + "PUT", + "HEAD" + ], + "AllowedOrigins": [ + "" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_options_match_partial_origin": { + "recorded-date": "29-02-2024, 23:37:44", + "recorded-content": { + "options_match_partial_origin": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "GET, PUT", + "Access-Control-Allow-Origin": "http://test.origin.com", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + } + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_options_fails_partial_origin": { + "recorded-date": "01-03-2024, 00:50:41", + "recorded-content": { + "options_fails_partial_origin": { + "Body": { + "Error": { + "Code": "AccessForbidden", + "HostId": "", + "Message": "CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.", + "Method": "GET", + "RequestId": "", + "ResourceType": "BUCKET" + } + }, + "Headers": { + "Content-Type": "application/xml", + "Transfer-Encoding": "chunked", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 403 + } + } + } +} diff --git a/tests/aws/services/s3/test_s3_cors.validation.json b/tests/aws/services/s3/test_s3_cors.validation.json new file mode 100644 index 0000000000000..63684e01782f0 --- /dev/null +++ b/tests/aws/services/s3/test_s3_cors.validation.json @@ -0,0 +1,53 @@ +{ + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_expose_headers": { + "last_validated_date": "2023-07-31T10:34:45+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_get_no_config": { + "last_validated_date": "2023-07-31T10:31:37+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_options_no_config": { + "last_validated_date": "2023-07-31T16:24:51+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_http_options_non_existent_bucket": { + "last_validated_date": "2023-07-31T10:31:40+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_headers": { + "last_validated_date": "2025-07-07T17:12:04+00:00", + "durations_in_seconds": { + "setup": 1.68, + "call": 4.08, + "teardown": 1.07, + "total": 6.83 + } + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_methods": { + "last_validated_date": "2025-03-17T20:18:58+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_origins": { + "last_validated_date": "2023-07-31T10:31:46+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_options_fails_partial_origin": { + "last_validated_date": "2024-03-01T00:50:41+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_options_match_partial_origin": { + "last_validated_date": "2024-02-29T23:37:44+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_delete_cors": { + "last_validated_date": "2023-07-31T10:32:03+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_get_cors": { + "last_validated_date": "2023-07-31T10:31:54+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors": { + "last_validated_date": "2023-07-31T10:31:56+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_default_values": { + "last_validated_date": "2023-07-31T10:34:48+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_empty_origin": { + "last_validated_date": "2023-07-31T10:32:01+00:00" + }, + "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_put_cors_invalid_rules": { + "last_validated_date": "2023-07-31T10:31:59+00:00" + } +} diff --git a/tests/aws/services/s3/test_s3_list_operations.py b/tests/aws/services/s3/test_s3_list_operations.py new file mode 100644 index 0000000000000..f081782c0ecb3 --- /dev/null +++ b/tests/aws/services/s3/test_s3_list_operations.py @@ -0,0 +1,1157 @@ +""" +This file is to test specific behaviour of List* operations of S3, especially pagination, which is pretty specific to +each implementation. They all have subtle differences which make it difficult to test. +""" + +import datetime +from io import BytesIO + +import pytest +import xmltodict +from botocore.auth import SigV4Auth +from botocore.client import Config +from botocore.exceptions import ClientError + +from localstack import config +from localstack.config import S3_VIRTUAL_HOSTNAME +from localstack.constants import AWS_REGION_US_EAST_1, LOCALHOST_HOSTNAME +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +def _bucket_url(bucket_name: str, region: str = "", localstack_host: str = None) -> str: + return f"{_endpoint_url(region, localstack_host)}/{bucket_name}" + + +def _endpoint_url(region: str = "", localstack_host: str = None) -> str: + if not region: + region = AWS_REGION_US_EAST_1 + if is_aws_cloud(): + if region == "us-east-1": + return "https://s3.amazonaws.com" + else: + return f"http://s3.{region}.amazonaws.com" + if region == "us-east-1": + return f"{config.internal_service_url(host=localstack_host or S3_VIRTUAL_HOSTNAME)}" + return config.internal_service_url(host=f"s3.{region}.{LOCALHOST_HOSTNAME}") + + +def assert_timestamp_is_iso8061_s3_format(timestamp: str): + # the timestamp should be looking like the following + # 2023-11-15T12:02:40.000Z + assert timestamp.endswith(".000Z") + assert len(timestamp) == 24 + # assert that it follows the right format and it does not raise an exception during parsing + parsed_ts = datetime.datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%fZ") + assert parsed_ts.microsecond == 0 + + +class TestS3ListBuckets: + @markers.aws.validated + def test_list_buckets_by_prefix_with_case_sensitivity( + self, s3_create_bucket, aws_client, snapshot + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("Prefix")) + + bucket_name = f"test-bucket-{short_uid()}" + s3_create_bucket(Bucket=bucket_name) + s3_create_bucket(Bucket=f"ignored-bucket-{short_uid()}") + + response = aws_client.s3.list_buckets(Prefix=bucket_name.upper()) + assert len(response["Buckets"]) == 0 + + snapshot.match("list-objects-by-prefix-empty", response) + + response = aws_client.s3.list_buckets(Prefix=bucket_name) + assert len(response["Buckets"]) == 1 + + returned_bucket = response["Buckets"][0] + assert returned_bucket["Name"] == bucket_name + + snapshot.match("list-objects-by-prefix-not-empty", response) + + @markers.aws.validated + def test_list_buckets_with_max_buckets(self, s3_create_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("ContinuationToken")) + + s3_create_bucket() + s3_create_bucket() + + response = aws_client.s3.list_buckets(MaxBuckets=1) + assert len(response["Buckets"]) == 1 + + snapshot.match("list-objects-with-max-buckets", response) + + @markers.aws.validated + def test_list_buckets_when_continuation_token_is_empty( + self, s3_create_bucket, aws_client, snapshot + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("ContinuationToken")) + + s3_create_bucket() + s3_create_bucket() + + response = aws_client.s3.list_buckets(ContinuationToken="", MaxBuckets=1) + assert len(response["Buckets"]) == 1 + + snapshot.match("list-objects-with-empty-continuation-token", response) + + @markers.aws.validated + # In some regions AWS returns the Owner display name (us-west-2) but in some it doesnt (eu-central-1) + @markers.snapshot.skip_snapshot_verify( + paths=["$.list-objects-by-bucket-region-empty..Owner.DisplayName"] + ) + def test_list_buckets_by_bucket_region( + self, s3_create_bucket, s3_create_bucket_with_client, aws_client_factory, snapshot + ): + region_us_west_2 = "us-west-2" + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.regex(region_us_west_2, "")) + + s3_create_bucket() + client_us_west_2 = aws_client_factory(region_name=region_us_west_2).s3 + + bucket_name = f"test-bucket-{short_uid()}" + s3_create_bucket_with_client( + client_us_west_2, + Bucket=bucket_name, + CreateBucketConfiguration={"LocationConstraint": region_us_west_2}, + ) + + region_eu_central_1 = "eu-central-1" + client_eu_central_1 = aws_client_factory(region_name=region_eu_central_1).s3 + response = client_eu_central_1.list_buckets( + BucketRegion=region_eu_central_1, Prefix=bucket_name + ) + assert len(response["Buckets"]) == 0 + + snapshot.match("list-objects-by-bucket-region-empty", response) + + response = client_us_west_2.list_buckets(BucketRegion=region_us_west_2, Prefix=bucket_name) + assert len(response["Buckets"]) == 1 + + returned_bucket = response["Buckets"][0] + assert returned_bucket["BucketRegion"] == region_us_west_2 + + snapshot.match("list-objects-by-bucket-region-not-empty", response) + + @markers.aws.validated + def test_list_buckets_with_continuation_token(self, s3_create_bucket, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("ContinuationToken")) + + s3_create_bucket() + s3_create_bucket() + s3_create_bucket() + response = aws_client.s3.list_buckets(MaxBuckets=1) + assert "ContinuationToken" in response + + first_returned_bucket = response["Buckets"][0] + continuation_token = response["ContinuationToken"] + + response = aws_client.s3.list_buckets(MaxBuckets=1, ContinuationToken=continuation_token) + + continuation_returned_bucket = response["Buckets"][0] + assert first_returned_bucket["Name"] != continuation_returned_bucket["Name"] + + snapshot.match("list-objects-with-continuation", response) + + +class TestS3ListObjects: + @markers.aws.validated + @pytest.mark.parametrize("delimiter", ["", "/", "%2F"]) + def test_list_objects_with_prefix( + self, s3_bucket, delimiter, snapshot, aws_client, aws_http_client_factory + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + key = "test/foo/bar/123" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_objects( + Bucket=s3_bucket, Prefix="test/", Delimiter=delimiter, MaxKeys=1, EncodingType="url" + ) + snapshot.match("list-objects", response) + + # Boto always add `EncodingType=url` in the request, so we need to bypass it to see the proper result, but only + # if %2F is already encoded + # change the prefix to `test` because it has a `/` in it which wouldn't work in the URL + # see https://github.com/boto/boto3/issues/816 + if delimiter == "%2F": + bucket_url = f"{_bucket_url(s3_bucket)}?prefix=test&delimiter={delimiter}" + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get( + bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"} + ) + resp_dict = xmltodict.parse(resp.content) + resp_dict["ListBucketResult"].pop("@xmlns", None) + snapshot.match("list-objects-no-encoding", resp_dict) + + @markers.aws.validated + def test_list_objects_next_marker(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("NextMarker")) + snapshot.add_transformer(snapshot.transform.key_value("Key"), priority=-1) + keys = [f"test_{i}" for i in range(3)] + for key in keys: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_objects(Bucket=s3_bucket) + snapshot.match("list-objects-all", response) + + response = aws_client.s3.list_objects(Bucket=s3_bucket, MaxKeys=1, Delimiter="/") + snapshot.match("list-objects-max-1", response) + # next marker is not there by default, you need a delimiter or you need to use the last key + next_marker = response["NextMarker"] + + response = aws_client.s3.list_objects(Bucket=s3_bucket, Marker=next_marker, MaxKeys=1) + snapshot.match("list-objects-rest", response) + + resp = aws_client.s3.list_objects(Bucket=s3_bucket, Marker="", MaxKeys=1) + snapshot.match("list-objects-marker-empty", resp) + + @markers.aws.validated + def test_s3_list_objects_empty_marker(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + resp = aws_client.s3.list_objects(Bucket=s3_bucket, Marker="") + snapshot.match("list-objects", resp) + + @markers.aws.validated + def test_list_objects_marker_common_prefixes(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + keys = [ + "folder/aSubfolder/subFile1", + "folder/aSubfolder/subFile2", + "folder/file1", + "folder/file2", + ] + for key in keys: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_objects(Bucket=s3_bucket) + snapshot.match("list-objects-all-keys", response) + + response = aws_client.s3.list_objects( + Bucket=s3_bucket, Prefix="folder/", Delimiter="/", MaxKeys=1 + ) + snapshot.match("list-objects-start", response) + marker_1 = response["NextMarker"] + + response = aws_client.s3.list_objects( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + Marker=marker_1, + ) + snapshot.match("list-objects-next-1", response) + marker_2 = response["NextMarker"] + + response = aws_client.s3.list_objects( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + Marker=marker_2, + ) + snapshot.match("list-objects-end", response) + assert not response["IsTruncated"] + + # try manually with the first key from the list, to assert the skipping of the second key as well + response = aws_client.s3.list_objects( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + Marker="folder/aSubfolder/subFile1", + ) + snapshot.match("list-objects-manual-first-file", response) + + @markers.aws.validated + @pytest.mark.parametrize( + "querystring", ["", "?list-type=2"], ids=["ListObjects", "ListObjectsV2"] + ) + def test_s3_list_objects_timestamp_precision( + self, s3_bucket, aws_client, aws_http_client_factory, querystring + ): + # behaviour is shared with ListObjectsV2 so we can do it in the same test + aws_client.s3.put_object(Bucket=s3_bucket, Key="test-key", Body="test-body") + bucket_url = f"{_bucket_url(s3_bucket)}{querystring}" + # Boto automatically parses the timestamp to ISO8601 with no precision, but AWS returns a different format + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get(bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}) + resp_dict = xmltodict.parse(resp.content) + timestamp: str = resp_dict["ListBucketResult"]["Contents"]["LastModified"] + + # the timestamp should be looking like the following: 2023-11-15T12:02:40.000Z + assert_timestamp_is_iso8061_s3_format(timestamp) + + +class TestS3ListObjectsV2: + @markers.aws.validated + def test_list_objects_v2_with_prefix( + self, s3_bucket, snapshot, aws_client, aws_http_client_factory + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + keys = ["test/foo/bar/123", "test/foo/bar/456", "test/bar/foo/123"] + for key in keys: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, Prefix="test/", EncodingType="url" + ) + snapshot.match("list-objects-v2-1", response) + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, Prefix="test/foo", EncodingType="url" + ) + snapshot.match("list-objects-v2-2", response) + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, Prefix="test/foo/bar", EncodingType="url" + ) + snapshot.match("list-objects-v2-3", response) + + # test without EncodingUrl, manually encode parameters + bucket_url = f"{_bucket_url(s3_bucket)}?list-type=2&prefix=test%2Ffoo" + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get(bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}) + resp_dict = xmltodict.parse(resp.content) + resp_dict["ListBucketResult"].pop("@xmlns", None) + snapshot.match("list-objects-v2-no-encoding", resp_dict) + + @markers.aws.validated + def test_list_objects_v2_with_prefix_and_delimiter(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("NextContinuationToken")) + keys = ["test/foo/bar/123", "test/foo/bar/456", "test/bar/foo/123"] + for key in keys: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, Prefix="test/", EncodingType="url", Delimiter="/" + ) + snapshot.match("list-objects-v2-1", response) + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, + Prefix="test/", + EncodingType="url", + Delimiter="/", + MaxKeys=1, + ) + snapshot.match("list-objects-v2-1-with-max-keys", response) + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, Prefix="test/foo", EncodingType="url", Delimiter="/" + ) + snapshot.match("list-objects-v2-2", response) + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, Prefix="test/foo/bar", EncodingType="url", Delimiter="/" + ) + snapshot.match("list-objects-v2-3", response) + + @markers.aws.validated + def test_list_objects_v2_continuation_start_after(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("NextContinuationToken")) + keys = [f"test_{i}" for i in range(12)] + for key in keys: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket, MaxKeys=5) + snapshot.match("list-objects-v2-max-5", response) + + continuation_token = response["NextContinuationToken"] + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, ContinuationToken=continuation_token + ) + snapshot.match("list-objects-v2-rest", response) + + # verify isTruncated behaviour + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket, StartAfter="test_7", MaxKeys=2) + snapshot.match("list-objects-start-after", response) + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, + StartAfter="test_7", + ContinuationToken=continuation_token, + ) + snapshot.match("list-objects-start-after-token", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.list_objects_v2(Bucket=s3_bucket, ContinuationToken="") + snapshot.match("exc-continuation-token", e.value.response) + + @markers.aws.validated + def test_list_objects_v2_continuation_common_prefixes(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("NextContinuationToken")) + keys = [ + "folder/aSubfolder/subFile1", + "folder/aSubfolder/subFile2", + "folder/file1", + "folder/file2", + ] + for key in keys: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_objects_v2(Bucket=s3_bucket) + snapshot.match("list-objects-v2-all-keys", response) + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, Prefix="folder/", Delimiter="/", MaxKeys=1 + ) + snapshot.match("list-objects-v2-start", response) + continuation_token_1 = response["NextContinuationToken"] + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + ContinuationToken=continuation_token_1, + ) + snapshot.match("list-objects-v2-next-1", response) + continuation_token_2 = response["NextContinuationToken"] + + response = aws_client.s3.list_objects_v2( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + ContinuationToken=continuation_token_2, + ) + snapshot.match("list-objects-v2-end", response) + assert "NextContinuationToken" not in response + + +class TestS3ListObjectVersions: + @markers.aws.validated + def test_list_objects_versions_markers(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + keys = [f"test_{i}" for i in range(3)] + # we need to snapshot the version ids in order of creation to understand better the ordering in snapshots + versions_ids = [] + for key in keys: + resp = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"version 1") + versions_ids.append(resp["VersionId"]) + + # add versions on top + resp = aws_client.s3.put_object(Bucket=s3_bucket, Key=keys[2], Body=b"version 2") + versions_ids.append(resp["VersionId"]) + + # put DeleteMarkers to change a bit the ordering + for key in keys: + resp = aws_client.s3.delete_object(Bucket=s3_bucket, Key=key) + versions_ids.append(resp["VersionId"]) + # re-add versions for some + for key in keys[:2]: + resp = aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"version 2") + versions_ids.append(resp["VersionId"]) + + snapshot.match( + "version-order", + {"Versions": [{"VersionId": version_id} for version_id in versions_ids]}, + ) + # get everything to check default order + response = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-objects-versions-all", response) + + response = aws_client.s3.list_object_versions(Bucket=s3_bucket, MaxKeys=5) + snapshot.match("list-objects-versions-5", response) + + next_key_marker = response["NextKeyMarker"] + next_version_id_marker = response["NextVersionIdMarker"] + + # try to see what's next when specifying only one + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, MaxKeys=1, KeyMarker=next_key_marker + ) + snapshot.match("list-objects-next-key-only", response) + + # try with last key + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, MaxKeys=1, KeyMarker=keys[-1] + ) + snapshot.match("list-objects-next-key-last", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.list_object_versions( + Bucket=s3_bucket, MaxKeys=1, VersionIdMarker=next_version_id_marker + ) + snapshot.match("list-objects-next-version-only", e.value.response) + + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + MaxKeys=1, + KeyMarker=next_key_marker, + VersionIdMarker=next_version_id_marker, + ) + snapshot.match("list-objects-both-markers", response) + + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + MaxKeys=1, + KeyMarker=keys[-1], + VersionIdMarker=versions_ids[3], + ) + snapshot.match("list-objects-last-key-last-version", response) + + response = aws_client.s3.list_object_versions(Bucket=s3_bucket, MaxKeys=1, KeyMarker="") + snapshot.match("list-objects-next-key-empty", response) + + @markers.aws.validated + def test_list_object_versions_pagination_common_prefixes(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + keys = [ + "folder/aSubfolder/subFile1", + "folder/aSubfolder/subFile2", + "folder/file1", + "folder/file2", + ] + for key in keys: + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body=b"content 123") + + response = aws_client.s3.list_object_versions(Bucket=s3_bucket) + snapshot.match("list-object-versions-all-keys", response) + + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, Prefix="folder/", Delimiter="/", MaxKeys=1 + ) + snapshot.match("list-object-versions-start", response) + next_key_marker_1 = response["NextKeyMarker"] + + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + KeyMarker=next_key_marker_1, + ) + snapshot.match("list-object-versions-next-1", response) + next_key_marker_2 = response["NextKeyMarker"] + + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + KeyMarker=next_key_marker_2, + ) + snapshot.match("list-object-versions-end", response) + assert not response["IsTruncated"] + + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxKeys=1, + KeyMarker="folder/aSubfolder/subFile1", + ) + snapshot.match("list-object-versions-manual-first-file", response) + + @markers.aws.validated + def test_list_objects_versions_with_prefix( + self, s3_bucket, snapshot, aws_client, aws_http_client_factory + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + objects = [ + {"Key": "dir/test", "Content": b"content key1-v1"}, + {"Key": "dir/test", "Content": b"content key-1v2"}, + {"Key": "dir/subdir/test2", "Content": b"content key2-v1"}, + {"Key": "dir/subdir/test2", "Content": b"content key2-v2"}, + ] + params = [ + {"Prefix": "dir/", "Delimiter": "/", "Id": 1}, + {"Prefix": "dir/s", "Delimiter": "/", "Id": 2}, + {"Prefix": "dir/test", "Delimiter": "/", "Id": 3}, + {"Prefix": "dir/subdir", "Delimiter": "/", "Id": 4}, + {"Prefix": "dir/subdir/", "Delimiter": "/", "Id": 5}, + {"Prefix": "dir/subdir/test2", "Delimiter": "/", "Id": 6}, + ] + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Enabled"}, + ) + + for obj in objects: + aws_client.s3.put_object(Bucket=s3_bucket, Key=obj["Key"], Body=obj["Content"]) + + for param in params: + response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, Delimiter=param["Delimiter"], Prefix=param["Prefix"] + ) + snapshot.match(f"list-object-version-{param['Id']}", response) + + # test without EncodingUrl, manually encode parameters + bucket_url = f"{_bucket_url(s3_bucket)}?versions&prefix=dir%2Fsubdir&delimiter=%2F" + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get(bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}) + resp_dict = xmltodict.parse(resp.content) + resp_dict["ListVersionsResult"].pop("@xmlns", None) + snapshot.match("list-objects-versions-no-encoding", resp_dict) + + @markers.aws.validated + def test_list_objects_versions_with_prefix_only_and_pagination( + self, + s3_bucket, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Enabled"}, + ) + + for _ in range(5): + aws_client.s3.put_object(Bucket=s3_bucket, Key="prefixed_key") + + aws_client.s3.put_object(Bucket=s3_bucket, Key="non_prefixed_key") + + prefixed_full = aws_client.s3.list_object_versions(Bucket=s3_bucket, Prefix="prefix") + snapshot.match("list-object-version-prefix-full", prefixed_full) + + full_response = aws_client.s3.list_object_versions(Bucket=s3_bucket) + assert len(full_response["Versions"]) == 6 + + page_1_response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, Prefix="prefix", MaxKeys=3 + ) + snapshot.match("list-object-version-prefix-page-1", page_1_response) + next_version_id_marker = page_1_response["NextVersionIdMarker"] + + page_2_key_marker_only = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + Prefix="prefix", + MaxKeys=4, + KeyMarker=page_1_response["NextKeyMarker"], + ) + snapshot.match("list-object-version-prefix-key-marker-only", page_2_key_marker_only) + + page_2_response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + Prefix="prefix", + MaxKeys=5, + KeyMarker=page_1_response["NextKeyMarker"], + VersionIdMarker=page_1_response["NextVersionIdMarker"], + ) + snapshot.match("list-object-version-prefix-page-2", page_2_response) + + delete_version_id_marker = aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [ + {"Key": version["Key"], "VersionId": version["VersionId"]} + for version in page_1_response["Versions"] + ], + }, + ) + # result is unordered in AWS, pretty hard to snapshot and tested in other places anyway + assert len(delete_version_id_marker["Deleted"]) == 3 + assert any( + version["VersionId"] == next_version_id_marker + for version in delete_version_id_marker["Deleted"] + ) + + page_2_response = aws_client.s3.list_object_versions( + Bucket=s3_bucket, + Prefix="prefix", + MaxKeys=5, + KeyMarker=page_1_response["NextKeyMarker"], + VersionIdMarker=next_version_id_marker, + ) + snapshot.match("list-object-version-prefix-page-2-after-delete", page_2_response) + + @markers.aws.validated + def test_list_objects_versions_with_prefix_only_and_pagination_many_versions( + self, + s3_bucket, + aws_client, + ): + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Enabled"}, + ) + # with our internal pagination system, we use characters from the alphabet (lower and upper) + digits + # by creating more than 100 objects, we can make sure we circle all the way around our sequencing, and properly + # paginate over all of them + for _ in range(101): + aws_client.s3.put_object(Bucket=s3_bucket, Key="prefixed_key") + + paginator = aws_client.s3.get_paginator("list_object_versions") + # even if the PageIterator looks like it should be an iterator, it's actually an iterable and needs to be + # wrapped in `iter` + page_iterator = iter( + paginator.paginate( + Bucket=s3_bucket, Prefix="prefix", PaginationConfig={"PageSize": 100} + ) + ) + page_1 = next(page_iterator) + assert len(page_1["Versions"]) == 100 + + page_2 = next(page_iterator) + assert len(page_2["Versions"]) == 1 + + @markers.aws.validated + def test_s3_list_object_versions_timestamp_precision( + self, s3_bucket, aws_client, aws_http_client_factory + ): + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, + VersioningConfiguration={"Status": "Enabled"}, + ) + # put Objects and DeleteMarker + aws_client.s3.put_object(Bucket=s3_bucket, Key="test-key", Body="test-body") + aws_client.s3.delete_object(Bucket=s3_bucket, Key="test-key") + + bucket_url = f"{_bucket_url(s3_bucket)}?versions" + # Boto automatically parses the timestamp to ISO8601 with no precision, but AWS returns a different format + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get(bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}) + resp_dict = xmltodict.parse(resp.content) + + timestamp_obj: str = resp_dict["ListVersionsResult"]["Version"]["LastModified"] + timestamp_marker: str = resp_dict["ListVersionsResult"]["DeleteMarker"]["LastModified"] + + for timestamp in (timestamp_obj, timestamp_marker): + # the timestamp should be looking like the following: 2023-11-15T12:02:40.000Z + assert_timestamp_is_iso8061_s3_format(timestamp) + + +class TestS3ListMultipartUploads: + @markers.aws.validated + def test_list_multiparts_next_marker(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("Bucket"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value( + "ID", value_replacement="owner-id", reference_replacement=False + ), + ] + ) + snapshot.add_transformer(snapshot.transform.key_value("Key"), priority=-1) + + response = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-multiparts-empty", response) + + keys = ["test_c", "test_b", "test_a"] + uploads_ids = [] + for key in keys: + # create 1 upload per key, except for the last one + resp = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + uploads_ids.append(resp["UploadId"]) + if key == "test_a": + for _ in range(2): + # add more upload for the last key to test UploadId ordering + resp = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + uploads_ids.append(resp["UploadId"]) + + # snapshot the upload ids ordering to compare with listing + snapshot.match( + "upload-ids-order", + {"UploadIds": [{"UploadId": upload_id} for upload_id in uploads_ids]}, + ) + + # AWS is saying on the doc that `UploadId` are sorted lexicographically, however tests shows that it's sorted + # by the Initiated time of the multipart + response = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket) + snapshot.match("list-multiparts-all", response) + + response = aws_client.s3.list_multipart_uploads(Bucket=s3_bucket, MaxUploads=1) + snapshot.match("list-multiparts-max-1", response) + + next_key_marker = response["NextKeyMarker"] + next_upload_id_marker = response["NextUploadIdMarker"] + + # try to see what's next when specifying only one + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, MaxUploads=1, KeyMarker=next_key_marker + ) + snapshot.match("list-multiparts-next-key-only", response) + + # try with last key lexicographically + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, MaxUploads=1, KeyMarker=keys[0] + ) + snapshot.match("list-multiparts-next-key-last", response) + + # UploadIdMarker is ignored if KeyMarker is not specified + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + MaxUploads=1, + UploadIdMarker=next_upload_id_marker, + ) + snapshot.match("list-multiparts-next-upload-only", response) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + MaxUploads=1, + KeyMarker=next_key_marker, + UploadIdMarker=next_upload_id_marker, + ) + snapshot.match("list-multiparts-both-markers", response) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + MaxUploads=1, + KeyMarker=next_key_marker, + UploadIdMarker=uploads_ids[-1], + ) + snapshot.match("list-multiparts-both-markers-2", response) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + MaxUploads=1, + KeyMarker=keys[1], + UploadIdMarker=uploads_ids[1], + ) + snapshot.match("list-multiparts-get-last-upload-no-truncate", response) + + with pytest.raises(ClientError) as e: + aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + MaxUploads=1, + KeyMarker=keys[0], + UploadIdMarker=uploads_ids[1], + ) + snapshot.match("list-multiparts-wrong-id-for-key", e.value.response) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, MaxUploads=1, KeyMarker="" + ) + snapshot.match("list-multiparts-next-key-empty", response) + + @markers.aws.validated + def test_list_multiparts_with_prefix_and_delimiter( + self, s3_bucket, snapshot, aws_client, aws_http_client_factory + ): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("Bucket"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value( + "ID", value_replacement="owner-id", reference_replacement=False + ), + ] + ) + snapshot.add_transformer(snapshot.transform.key_value("Key"), priority=-1) + keys = ["test/foo/bar/123", "test/foo/bar/456", "test/bar/foo/123"] + for key in keys: + aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + Prefix="test/", + EncodingType="url", + Delimiter="/", + ) + snapshot.match("list-multiparts-1", response) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, Prefix="test/foo/", EncodingType="url", Delimiter="/" + ) + snapshot.match("list-multiparts-2", response) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, Prefix="test/foo/bar", EncodingType="url", Delimiter="/" + ) + snapshot.match("list-multiparts-3", response) + + # test without EncodingUrl, manually encode parameters + bucket_url = f"{_bucket_url(s3_bucket)}?uploads&prefix=test%2Ffoo" + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get(bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}) + resp_dict = xmltodict.parse(resp.content) + resp_dict["ListMultipartUploadsResult"].pop("@xmlns", None) + snapshot.match("list-multiparts-no-encoding", resp_dict) + + @markers.aws.validated + def test_list_multipart_uploads_marker_common_prefixes(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("Bucket"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value( + "ID", value_replacement="owner-id", reference_replacement=False + ), + ] + ) + + keys = [ + "folder/aSubfolder/subFile1", + "folder/aSubfolder/subFile2", + "folder/file1", + "folder/file2", + ] + uploads_ids = [] + for key in keys: + resp = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key) + uploads_ids.append(resp["UploadId"]) + + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxUploads=1, + ) + snapshot.match("list-multiparts-start", response) + # AWS does not return a NextKeyMarker or a NextUploadIdMarker, so there is no way to paginate from here + + # try manually from previous experience with ListObjectVersions and ListObjects? + # this is equal of using the last prefix: CommonPrefix[-1]["Prefix"] + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxUploads=1, + KeyMarker="folder/aSubfolder/", + ) + snapshot.match("list-multiparts-manual-prefix", response) + + # try manually with the first key from the list, to assert the skipping of the second key as well + response = aws_client.s3.list_multipart_uploads( + Bucket=s3_bucket, + Prefix="folder/", + Delimiter="/", + MaxUploads=1, + KeyMarker="folder/aSubfolder/subFile1", + ) + snapshot.match("list-multiparts-manual-first-file", response) + + @markers.aws.validated + def test_s3_list_multiparts_timestamp_precision( + self, s3_bucket, aws_client, aws_http_client_factory + ): + object_key = "test-list-part-empty-marker" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=object_key) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + bucket_url = f"{_bucket_url(s3_bucket)}?uploads" + # Boto automatically parses the timestamp to ISO8601 with no precision, but AWS returns a different format + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get(bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}) + resp_dict = xmltodict.parse(resp.content) + + timestamp: str = resp_dict["ListMultipartUploadsResult"]["Upload"]["Initiated"] + # the timestamp should be looking like the following: 2023-11-15T12:02:40.000Z + assert_timestamp_is_iso8061_s3_format(timestamp) + + +class TestS3ListParts: + @markers.aws.validated + def test_list_parts_pagination(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + object_key = "test-list-part-pagination" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=object_key) + upload_id = response["UploadId"] + + response = aws_client.s3.list_parts(Bucket=s3_bucket, UploadId=upload_id, Key=object_key) + snapshot.match("list-parts-empty", response) + + for i in range(1, 3): + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=object_key, + Body=BytesIO(b"data"), + PartNumber=i, + UploadId=upload_id, + ) + + response = aws_client.s3.list_parts(Bucket=s3_bucket, UploadId=upload_id, Key=object_key) + snapshot.match("list-parts-all", response) + + response = aws_client.s3.list_parts( + Bucket=s3_bucket, UploadId=upload_id, Key=object_key, MaxParts=1 + ) + next_part_number_marker = response["NextPartNumberMarker"] + snapshot.match("list-parts-1", response) + + response = aws_client.s3.list_parts( + Bucket=s3_bucket, + UploadId=upload_id, + Key=object_key, + MaxParts=1, + PartNumberMarker=next_part_number_marker, + ) + + snapshot.match("list-parts-next", response) + + response = aws_client.s3.list_parts( + Bucket=s3_bucket, + UploadId=upload_id, + Key=object_key, + MaxParts=1, + PartNumberMarker=10, + ) + snapshot.match("list-parts-wrong-part", response) + + @markers.aws.validated + def test_list_parts_empty_part_number_marker(self, s3_bucket, snapshot, aws_client_factory): + # we need to disable validation for this test + s3_client = aws_client_factory(config=Config(parameter_validation=False)).s3 + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + object_key = "test-list-part-empty-marker" + response = s3_client.create_multipart_upload(Bucket=s3_bucket, Key=object_key) + upload_id = response["UploadId"] + + s3_client.upload_part( + Bucket=s3_bucket, + Key=object_key, + Body=BytesIO(b"data"), + PartNumber=1, + UploadId=upload_id, + ) + # it seems S3 does not care about empty string for integer query string parameters + response = s3_client.list_parts( + Bucket=s3_bucket, UploadId=upload_id, Key=object_key, PartNumberMarker="" + ) + snapshot.match("list-parts-empty-marker", response) + + response = s3_client.list_parts( + Bucket=s3_bucket, UploadId=upload_id, Key=object_key, MaxParts="" + ) + snapshot.match("list-parts-empty-max-parts", response) + + @markers.aws.validated + def test_s3_list_parts_timestamp_precision( + self, s3_bucket, aws_client, aws_http_client_factory + ): + object_key = "test-list-part-empty-marker" + response = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=object_key) + upload_id = response["UploadId"] + + aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=object_key, + Body=BytesIO(b"data"), + PartNumber=1, + UploadId=upload_id, + ) + + bucket_url = f"{_bucket_url(s3_bucket)}/{object_key}?uploadId={upload_id}" + # Boto automatically parses the timestamp to ISO8601 with no precision, but AWS returns a different format + s3_http_client = aws_http_client_factory("s3", signer_factory=SigV4Auth) + resp = s3_http_client.get(bucket_url, headers={"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}) + resp_dict = xmltodict.parse(resp.content) + + timestamp: str = resp_dict["ListPartsResult"]["Part"]["LastModified"] + # the timestamp should be looking like the following: 2023-11-15T12:02:40.000Z + assert_timestamp_is_iso8061_s3_format(timestamp) + + @markers.aws.validated + def test_list_parts_via_object_attrs_pagination(self, s3_bucket, snapshot, aws_client): + snapshot.add_transformer( + [ + snapshot.transform.key_value("Bucket", reference_replacement=False), + snapshot.transform.key_value("Location"), + snapshot.transform.key_value("UploadId"), + snapshot.transform.key_value("DisplayName", reference_replacement=False), + snapshot.transform.key_value("ID", reference_replacement=False), + ] + ) + object_key = "test-object-attrs-pagination" + response = aws_client.s3.create_multipart_upload( + Bucket=s3_bucket, Key=object_key, ChecksumAlgorithm="SHA256" + ) + upload_id = response["UploadId"] + + # data must be at least 5MiB + part_data = b"a" * (5_242_880 + 1) + multipart_upload_parts = [] + + for i in range(1, 3): + upload_part = aws_client.s3.upload_part( + Bucket=s3_bucket, + Key=object_key, + Body=part_data, + PartNumber=i, + UploadId=upload_id, + ChecksumAlgorithm="SHA256", + ) + multipart_upload_parts.append( + { + "ETag": upload_part["ETag"], + "PartNumber": i, + "ChecksumSHA256": upload_part["ChecksumSHA256"], + } + ) + + response = aws_client.s3.list_parts(Bucket=s3_bucket, UploadId=upload_id, Key=object_key) + snapshot.match("list-parts", response) + + complete_multipart = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + UploadId=upload_id, + Key=object_key, + MultipartUpload={"Parts": multipart_upload_parts}, + ) + snapshot.match("complete-mpu", complete_multipart) + + object_attrs = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["ObjectParts"], + ) + snapshot.match("get-object-attrs-all", object_attrs) + + object_attrs_1 = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, Key=object_key, ObjectAttributes=["ObjectParts"], MaxParts=1 + ) + snapshot.match("get-object-attrs-1", object_attrs) + next_part_number_marker = object_attrs_1["ObjectParts"]["NextPartNumberMarker"] + + object_attrs_next = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["ObjectParts"], + MaxParts=1, + PartNumberMarker=next_part_number_marker, + ) + snapshot.match("get-object-attrs-next", object_attrs_next) + + object_attrs_wrong = aws_client.s3.get_object_attributes( + Bucket=s3_bucket, + Key=object_key, + ObjectAttributes=["ObjectParts"], + MaxParts=1, + PartNumberMarker=10, + ) + snapshot.match("get-object-attrs-wrong-part", object_attrs_wrong) diff --git a/tests/aws/services/s3/test_s3_list_operations.snapshot.json b/tests/aws/services/s3/test_s3_list_operations.snapshot.json new file mode 100644 index 0000000000000..ab827dee03cff --- /dev/null +++ b/tests/aws/services/s3/test_s3_list_operations.snapshot.json @@ -0,0 +1,3372 @@ +{ + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[]": { + "recorded-date": "21-01-2025, 18:14:22", + "recorded-content": { + "list-objects": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1, + "Name": "", + "Prefix": "test/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[/]": { + "recorded-date": "21-01-2025, 18:14:24", + "recorded-content": { + "list-objects": { + "CommonPrefixes": [ + { + "Prefix": "test/foo/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1, + "Name": "", + "Prefix": "test/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[%2F]": { + "recorded-date": "21-01-2025, 18:14:26", + "recorded-content": { + "list-objects": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "Delimiter": "%252F", + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1, + "Name": "", + "Prefix": "test/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-no-encoding": { + "ListBucketResult": { + "CommonPrefixes": { + "Prefix": "test/" + }, + "Delimiter": "/", + "IsTruncated": "false", + "Marker": null, + "MaxKeys": "1000", + "Name": "", + "Prefix": "test" + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_next_marker": { + "recorded-date": "21-01-2025, 18:14:29", + "recorded-content": { + "list-objects-all": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-max-1": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "Marker": "", + "MaxKeys": 1, + "Name": "", + "NextMarker": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-rest": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": true, + "Marker": "", + "MaxKeys": 1, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-marker-empty": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": true, + "Marker": "", + "MaxKeys": 1, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_s3_list_objects_empty_marker": { + "recorded-date": "21-01-2025, 18:14:31", + "recorded-content": { + "list-objects": { + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_with_prefix": { + "recorded-date": "21-01-2025, 18:14:40", + "recorded-content": { + "list-objects-v2-1": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/bar/foo/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 3, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-2": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/foo", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-3": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/foo/bar", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-no-encoding": { + "ListBucketResult": { + "Contents": [ + { + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/123", + "LastModified": "date", + "Size": "11", + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": "CRC32", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test/foo/bar/456", + "LastModified": "date", + "Size": "11", + "StorageClass": "STANDARD" + } + ], + "IsTruncated": "false", + "KeyCount": "2", + "MaxKeys": "1000", + "Name": "", + "Prefix": "test/foo" + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_with_prefix_and_delimiter": { + "recorded-date": "21-01-2025, 18:14:43", + "recorded-content": { + "list-objects-v2-1": { + "CommonPrefixes": [ + { + "Prefix": "test/bar/" + }, + { + "Prefix": "test/foo/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-1-with-max-keys": { + "CommonPrefixes": [ + { + "Prefix": "test/bar/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "KeyCount": 1, + "MaxKeys": 1, + "Name": "", + "NextContinuationToken": "", + "Prefix": "test/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-2": { + "CommonPrefixes": [ + { + "Prefix": "test/foo/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/foo", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-3": { + "CommonPrefixes": [ + { + "Prefix": "test/foo/bar/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1000, + "Name": "", + "Prefix": "test/foo/bar", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_continuation_start_after": { + "recorded-date": "21-01-2025, 18:14:48", + "recorded-content": { + "list-objects-v2-max-5": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_0", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_1", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_10", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_11", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_2", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": true, + "KeyCount": 5, + "MaxKeys": 5, + "Name": "", + "NextContinuationToken": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-rest": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_3", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_4", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_5", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_6", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_7", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_8", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_9", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "ContinuationToken": "", + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 7, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-start-after": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_8", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_9", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 2, + "MaxKeys": 2, + "Name": "", + "Prefix": "", + "StartAfter": "test_7", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-start-after-token": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_3", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_4", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_5", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_6", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_7", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_8", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "test_9", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "ContinuationToken": "", + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 7, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exc-continuation-token": { + "Error": { + "ArgumentName": "continuation-token", + "Code": "InvalidArgument", + "Message": "The continuation token provided is incorrect" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_continuation_common_prefixes": { + "recorded-date": "21-01-2025, 18:14:51", + "recorded-content": { + "list-objects-v2-all-keys": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/aSubfolder/subFile1", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/aSubfolder/subFile2", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file1", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file2", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 4, + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-start": { + "CommonPrefixes": [ + { + "Prefix": "folder/aSubfolder/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "KeyCount": 1, + "MaxKeys": 1, + "Name": "", + "NextContinuationToken": "", + "Prefix": "folder/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-next-1": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file1", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "ContinuationToken": "", + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "KeyCount": 1, + "MaxKeys": 1, + "Name": "", + "NextContinuationToken": "", + "Prefix": "folder/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-v2-end": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file2", + "LastModified": "datetime", + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "ContinuationToken": "", + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyCount": 1, + "MaxKeys": 1, + "Name": "", + "Prefix": "folder/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_markers": { + "recorded-date": "21-01-2025, 18:14:56", + "recorded-content": { + "version-order": { + "Versions": [ + { + "VersionId": "" + }, + { + "VersionId": "" + }, + { + "VersionId": "" + }, + { + "VersionId": "" + }, + { + "VersionId": "" + }, + { + "VersionId": "" + }, + { + "VersionId": "" + }, + { + "VersionId": "" + }, + { + "VersionId": "" + } + ] + }, + "list-objects-versions-all": { + "DeleteMarkers": [ + { + "IsLatest": false, + "Key": "test_0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + }, + { + "IsLatest": false, + "Key": "test_1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + }, + { + "IsLatest": true, + "Key": "test_2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d4ca1ed7571e2e7b1f1c375bd50fa220\"", + "IsLatest": true, + "Key": "test_0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"db3ec040e20dfc657dab510aeab74759\"", + "IsLatest": false, + "Key": "test_0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d4ca1ed7571e2e7b1f1c375bd50fa220\"", + "IsLatest": true, + "Key": "test_1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"db3ec040e20dfc657dab510aeab74759\"", + "IsLatest": false, + "Key": "test_1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d4ca1ed7571e2e7b1f1c375bd50fa220\"", + "IsLatest": false, + "Key": "test_2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"db3ec040e20dfc657dab510aeab74759\"", + "IsLatest": false, + "Key": "test_2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-versions-5": { + "DeleteMarkers": [ + { + "IsLatest": false, + "Key": "test_0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + }, + { + "IsLatest": false, + "Key": "test_1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "", + "MaxKeys": 5, + "Name": "", + "NextKeyMarker": "test_1", + "NextVersionIdMarker": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d4ca1ed7571e2e7b1f1c375bd50fa220\"", + "IsLatest": true, + "Key": "test_0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"db3ec040e20dfc657dab510aeab74759\"", + "IsLatest": false, + "Key": "test_0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d4ca1ed7571e2e7b1f1c375bd50fa220\"", + "IsLatest": true, + "Key": "test_1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-next-key-only": { + "DeleteMarkers": [ + { + "IsLatest": true, + "Key": "test_2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "VersionId": "" + } + ], + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "test_1", + "MaxKeys": 1, + "Name": "", + "NextKeyMarker": "test_2", + "NextVersionIdMarker": "", + "Prefix": "", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-next-key-last": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "test_2", + "MaxKeys": 1, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-next-version-only": { + "Error": { + "ArgumentName": "version-id-marker", + "ArgumentValue": "", + "Code": "InvalidArgument", + "Message": "A version-id marker cannot be specified without a key marker." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-objects-both-markers": { + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "test_1", + "MaxKeys": 1, + "Name": "", + "NextKeyMarker": "test_1", + "NextVersionIdMarker": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"db3ec040e20dfc657dab510aeab74759\"", + "IsLatest": false, + "Key": "test_1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-last-key-last-version": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "test_2", + "MaxKeys": 1, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"db3ec040e20dfc657dab510aeab74759\"", + "IsLatest": false, + "Key": "test_2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-next-key-empty": { + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "", + "MaxKeys": 1, + "Name": "", + "NextKeyMarker": "test_0", + "NextVersionIdMarker": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d4ca1ed7571e2e7b1f1c375bd50fa220\"", + "IsLatest": true, + "Key": "test_0", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 9, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_object_versions_pagination_common_prefixes": { + "recorded-date": "21-01-2025, 18:14:59", + "recorded-content": { + "list-object-versions-all-keys": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "IsLatest": true, + "Key": "folder/aSubfolder/subFile1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "IsLatest": true, + "Key": "folder/aSubfolder/subFile2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "IsLatest": true, + "Key": "folder/file1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "IsLatest": true, + "Key": "folder/file2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versions-start": { + "CommonPrefixes": [ + { + "Prefix": "folder/aSubfolder/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "", + "MaxKeys": 1, + "Name": "", + "NextKeyMarker": "folder/aSubfolder/", + "Prefix": "folder/", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versions-next-1": { + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "folder/aSubfolder/", + "MaxKeys": 1, + "Name": "", + "NextKeyMarker": "folder/file1", + "NextVersionIdMarker": "", + "Prefix": "folder/", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "IsLatest": true, + "Key": "folder/file1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versions-end": { + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "folder/file1", + "MaxKeys": 1, + "Name": "", + "Prefix": "folder/", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "IsLatest": true, + "Key": "folder/file2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-versions-manual-first-file": { + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "folder/aSubfolder/subFile1", + "MaxKeys": 1, + "Name": "", + "NextKeyMarker": "folder/file1", + "NextVersionIdMarker": "", + "Prefix": "folder/", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "IsLatest": true, + "Key": "folder/file1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix": { + "recorded-date": "21-01-2025, 18:15:03", + "recorded-content": { + "list-object-version-1": { + "CommonPrefixes": [ + { + "Prefix": "dir/subdir/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "dir/", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c978d4a128605d97d7c5b1bd17250efd\"", + "IsLatest": true, + "Key": "dir/test", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"dbe906ced633d4580318b1cc37ce1ca4\"", + "IsLatest": false, + "Key": "dir/test", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-2": { + "CommonPrefixes": [ + { + "Prefix": "dir/subdir/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "dir/s", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-3": { + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "dir/test", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"c978d4a128605d97d7c5b1bd17250efd\"", + "IsLatest": true, + "Key": "dir/test", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"dbe906ced633d4580318b1cc37ce1ca4\"", + "IsLatest": false, + "Key": "dir/test", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-4": { + "CommonPrefixes": [ + { + "Prefix": "dir/subdir/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "dir/subdir", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-5": { + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "dir/subdir/", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"56a8b2485f9683f70ea3316e6fa46be1\"", + "IsLatest": true, + "Key": "dir/subdir/test2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"eafcff1b58415aa1e09ab4891ca2fa8a\"", + "IsLatest": false, + "Key": "dir/subdir/test2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-6": { + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "dir/subdir/test2", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"56a8b2485f9683f70ea3316e6fa46be1\"", + "IsLatest": true, + "Key": "dir/subdir/test2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"eafcff1b58415aa1e09ab4891ca2fa8a\"", + "IsLatest": false, + "Key": "dir/subdir/test2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 15, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-versions-no-encoding": { + "ListVersionsResult": { + "CommonPrefixes": { + "Prefix": "dir/subdir/" + }, + "Delimiter": "/", + "IsTruncated": "false", + "KeyMarker": null, + "MaxKeys": "1000", + "Name": "", + "Prefix": "dir/subdir", + "VersionIdMarker": null + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multiparts_next_marker": { + "recorded-date": "21-01-2025, 18:15:10", + "recorded-content": { + "list-multiparts-empty": { + "Bucket": "", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "upload-ids-order": { + "UploadIds": [ + { + "UploadId": "" + }, + { + "UploadId": "" + }, + { + "UploadId": "" + }, + { + "UploadId": "" + }, + { + "UploadId": "" + } + ] + }, + "list-multiparts-all": { + "Bucket": "", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + }, + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + }, + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + }, + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + }, + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-max-1": { + "Bucket": "", + "IsTruncated": true, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-next-key-only": { + "Bucket": "", + "IsTruncated": true, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-next-key-last": { + "Bucket": "", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-next-upload-only": { + "Bucket": "", + "IsTruncated": true, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-both-markers": { + "Bucket": "", + "IsTruncated": true, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-both-markers-2": { + "Bucket": "", + "IsTruncated": true, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-get-last-upload-no-truncate": { + "Bucket": "", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-wrong-id-for-key": { + "Error": { + "ArgumentName": "upload-id-marker", + "ArgumentValue": "", + "Code": "InvalidArgument", + "Message": "Invalid uploadId marker" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-multiparts-next-key-empty": { + "Bucket": "", + "IsTruncated": true, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multiparts_with_prefix_and_delimiter": { + "recorded-date": "21-01-2025, 18:15:12", + "recorded-content": { + "list-multiparts-1": { + "Bucket": "", + "CommonPrefixes": [ + { + "Prefix": "test/bar/" + }, + { + "Prefix": "test/foo/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "Prefix": "test/", + "UploadIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-2": { + "Bucket": "", + "CommonPrefixes": [ + { + "Prefix": "test/foo/bar/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "Prefix": "test/foo/", + "UploadIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-3": { + "Bucket": "", + "CommonPrefixes": [ + { + "Prefix": "test/foo/bar/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxUploads": 1000, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "Prefix": "test/foo/bar", + "UploadIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-no-encoding": { + "ListMultipartUploadsResult": { + "Bucket": "", + "IsTruncated": "false", + "KeyMarker": null, + "MaxUploads": "1000", + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "Prefix": "test/foo", + "Upload": [ + { + "Initiated": "date", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + }, + { + "Initiated": "date", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "UploadIdMarker": null + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_pagination": { + "recorded-date": "21-01-2025, 18:15:18", + "recorded-content": { + "list-parts-empty": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-pagination", + "MaxParts": 1000, + "NextPartNumberMarker": 0, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts-all": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-pagination", + "MaxParts": 1000, + "NextPartNumberMarker": 2, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 4 + }, + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 4 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts-1": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": true, + "Key": "test-list-part-pagination", + "MaxParts": 1, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 4 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts-next": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-pagination", + "MaxParts": 1, + "NextPartNumberMarker": 2, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 1, + "Parts": [ + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 4 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts-wrong-part": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-pagination", + "MaxParts": 1, + "NextPartNumberMarker": 0, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 10, + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_marker_common_prefixes": { + "recorded-date": "21-01-2025, 18:14:33", + "recorded-content": { + "list-objects-all-keys": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/aSubfolder/subFile1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/aSubfolder/subFile2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "EncodingType": "url", + "IsTruncated": false, + "Marker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-start": { + "CommonPrefixes": [ + { + "Prefix": "folder/aSubfolder/" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "Marker": "", + "MaxKeys": 1, + "Name": "", + "NextMarker": "folder/aSubfolder/", + "Prefix": "folder/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-next-1": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "Marker": "folder/aSubfolder/", + "MaxKeys": 1, + "Name": "", + "NextMarker": "folder/file1", + "Prefix": "folder/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-end": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file2", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": false, + "Marker": "folder/file1", + "MaxKeys": 1, + "Name": "", + "Prefix": "folder/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-manual-first-file": { + "Contents": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"0d9fa06a66933b40f615f530e59edd6b\"", + "Key": "folder/file1", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 11, + "StorageClass": "STANDARD" + } + ], + "Delimiter": "/", + "EncodingType": "url", + "IsTruncated": true, + "Marker": "folder/aSubfolder/subFile1", + "MaxKeys": 1, + "Name": "", + "NextMarker": "folder/file1", + "Prefix": "folder/", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multipart_uploads_marker_common_prefixes": { + "recorded-date": "21-01-2025, 18:15:14", + "recorded-content": { + "list-multiparts-start": { + "Bucket": "", + "CommonPrefixes": [ + { + "Prefix": "folder/aSubfolder/" + } + ], + "Delimiter": "/", + "IsTruncated": true, + "KeyMarker": "", + "MaxUploads": 1, + "NextKeyMarker": "", + "NextUploadIdMarker": "", + "Prefix": "folder/", + "UploadIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-manual-prefix": { + "Bucket": "", + "Delimiter": "/", + "IsTruncated": true, + "KeyMarker": "folder/aSubfolder/", + "MaxUploads": 1, + "NextKeyMarker": "folder/file1", + "NextUploadIdMarker": "", + "Prefix": "folder/", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "folder/file1", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-multiparts-manual-first-file": { + "Bucket": "", + "Delimiter": "/", + "IsTruncated": true, + "KeyMarker": "folder/aSubfolder/subFile1", + "MaxUploads": 1, + "NextKeyMarker": "folder/file1", + "NextUploadIdMarker": "", + "Prefix": "folder/", + "UploadIdMarker": "", + "Uploads": [ + { + "Initiated": "datetime", + "Initiator": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "Key": "folder/file1", + "Owner": { + "DisplayName": "display-name", + "ID": "owner-id" + }, + "StorageClass": "STANDARD", + "UploadId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_empty_part_number_marker": { + "recorded-date": "21-01-2025, 18:15:20", + "recorded-content": { + "list-parts-empty-marker": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-empty-marker", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 4 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-parts-empty-max-parts": { + "Bucket": "bucket", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-list-part-empty-marker", + "MaxParts": 1000, + "NextPartNumberMarker": 1, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 4 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix_only_and_pagination": { + "recorded-date": "13-02-2025, 03:52:21", + "recorded-content": { + "list-object-version-prefix-full": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "", + "MaxKeys": 1000, + "Name": "", + "Prefix": "prefix", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": true, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-prefix-page-1": { + "EncodingType": "url", + "IsTruncated": true, + "KeyMarker": "", + "MaxKeys": 3, + "Name": "", + "NextKeyMarker": "prefixed_key", + "NextVersionIdMarker": "", + "Prefix": "prefix", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": true, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-prefix-key-marker-only": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "prefixed_key", + "MaxKeys": 4, + "Name": "", + "Prefix": "prefix", + "VersionIdMarker": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-prefix-page-2": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "prefixed_key", + "MaxKeys": 5, + "Name": "", + "Prefix": "prefix", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-object-version-prefix-page-2-after-delete": { + "EncodingType": "url", + "IsTruncated": false, + "KeyMarker": "prefixed_key", + "MaxKeys": 5, + "Name": "", + "Prefix": "prefix", + "VersionIdMarker": "", + "Versions": [ + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": true, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + }, + { + "ChecksumAlgorithm": [ + "CRC32" + ], + "ChecksumType": "FULL_OBJECT", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "IsLatest": false, + "Key": "prefixed_key", + "LastModified": "datetime", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Size": 0, + "StorageClass": "STANDARD", + "VersionId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_with_max_buckets": { + "recorded-date": "14-05-2025, 09:10:49", + "recorded-content": { + "list-objects-with-max-buckets": { + "Buckets": [ + { + "BucketRegion": "", + "CreationDate": "datetime", + "Name": "" + } + ], + "ContinuationToken": "", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_with_continuation_token": { + "recorded-date": "14-05-2025, 09:10:59", + "recorded-content": { + "list-objects-with-continuation": { + "Buckets": [ + { + "BucketRegion": "", + "CreationDate": "datetime", + "Name": "" + } + ], + "ContinuationToken": "", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_when_continuation_token_is_empty": { + "recorded-date": "14-05-2025, 09:10:50", + "recorded-content": { + "list-objects-with-empty-continuation-token": { + "Buckets": [ + { + "BucketRegion": "", + "CreationDate": "datetime", + "Name": "" + } + ], + "ContinuationToken": "", + "Owner": { + "DisplayName": "", + "ID": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_by_prefix_with_case_sensitivity": { + "recorded-date": "14-05-2025, 09:10:46", + "recorded-content": { + "list-objects-by-prefix-empty": { + "Buckets": [], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-by-prefix-not-empty": { + "Buckets": [ + { + "BucketRegion": "", + "CreationDate": "datetime", + "Name": "" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_by_bucket_region": { + "recorded-date": "14-05-2025, 09:10:54", + "recorded-content": { + "list-objects-by-bucket-region-empty": { + "Buckets": [], + "Owner": { + "ID": "" + }, + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-objects-by-bucket-region-not-empty": { + "Buckets": [ + { + "BucketRegion": "", + "CreationDate": "datetime", + "Name": "" + } + ], + "Owner": { + "DisplayName": "", + "ID": "" + }, + "Prefix": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_via_object_attrs_pagination": { + "recorded-date": "16-06-2025, 13:47:27", + "recorded-content": { + "list-parts": { + "Bucket": "bucket", + "ChecksumAlgorithm": "SHA256", + "ChecksumType": "COMPOSITE", + "Initiator": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "IsTruncated": false, + "Key": "test-object-attrs-pagination", + "MaxParts": 1000, + "NextPartNumberMarker": 2, + "Owner": { + "DisplayName": "display-name", + "ID": "i-d" + }, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "ETag": "\"c4c753e69bb853187f5854c46cf801c6\"", + "LastModified": "datetime", + "PartNumber": 2, + "Size": 5242881 + } + ], + "StorageClass": "STANDARD", + "UploadId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "complete-mpu": { + "Bucket": "bucket", + "ChecksumSHA256": "2Wjxbc3N6c1y3Eqve6x8X+xPy4qXhB1vAMgge0qeZJM=-2", + "ChecksumType": "COMPOSITE", + "ETag": "\"14cb3f95b2dfe6bd47fb59d47949e00e-2\"", + "Key": "test-object-attrs-pagination", + "Location": "", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-all": { + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1000, + "NextPartNumberMarker": 2, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "PartNumber": 2, + "Size": 5242881 + } + ], + "TotalPartsCount": 2 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-1": { + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1000, + "NextPartNumberMarker": 2, + "PartNumberMarker": 0, + "Parts": [ + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "PartNumber": 1, + "Size": 5242881 + }, + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "PartNumber": 2, + "Size": 5242881 + } + ], + "TotalPartsCount": 2 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-next": { + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1, + "NextPartNumberMarker": 2, + "PartNumberMarker": 1, + "Parts": [ + { + "ChecksumSHA256": "DjU70AB1bON8k0n0fVHv2PJQVWcA/jWsITp6ti20Tbs=", + "PartNumber": 2, + "Size": 5242881 + } + ], + "TotalPartsCount": 2 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-object-attrs-wrong-part": { + "LastModified": "datetime", + "ObjectParts": { + "IsTruncated": false, + "MaxParts": 1, + "NextPartNumberMarker": 0, + "PartNumberMarker": 10, + "TotalPartsCount": 2 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/s3/test_s3_list_operations.validation.json b/tests/aws/services/s3/test_s3_list_operations.validation.json new file mode 100644 index 0000000000000..127660f3efd56 --- /dev/null +++ b/tests/aws/services/s3/test_s3_list_operations.validation.json @@ -0,0 +1,101 @@ +{ + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_by_bucket_region": { + "last_validated_date": "2025-05-14T09:11:19+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_by_prefix_with_case_sensitivity": { + "last_validated_date": "2025-05-14T09:11:11+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_when_continuation_token_is_empty": { + "last_validated_date": "2025-05-14T09:11:17+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_with_continuation_token": { + "last_validated_date": "2025-05-14T09:11:24+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListBuckets::test_list_buckets_with_max_buckets": { + "last_validated_date": "2025-05-14T09:11:14+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multipart_uploads_marker_common_prefixes": { + "last_validated_date": "2025-01-21T18:15:14+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multiparts_next_marker": { + "last_validated_date": "2025-01-21T18:15:10+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_list_multiparts_with_prefix_and_delimiter": { + "last_validated_date": "2025-01-21T18:15:12+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListMultipartUploads::test_s3_list_multiparts_timestamp_precision": { + "last_validated_date": "2025-01-21T18:15:16+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_object_versions_pagination_common_prefixes": { + "last_validated_date": "2025-01-21T18:14:59+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_markers": { + "last_validated_date": "2025-01-21T18:14:56+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix": { + "last_validated_date": "2025-01-21T18:15:03+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix_only_and_pagination": { + "last_validated_date": "2025-02-13T03:52:21+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_list_objects_versions_with_prefix_only_and_pagination_many_versions": { + "last_validated_date": "2025-02-13T20:24:26+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectVersions::test_s3_list_object_versions_timestamp_precision": { + "last_validated_date": "2025-01-21T18:15:06+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_marker_common_prefixes": { + "last_validated_date": "2025-01-21T18:14:33+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_next_marker": { + "last_validated_date": "2025-01-21T18:14:29+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[%2F]": { + "last_validated_date": "2025-01-21T18:14:26+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[/]": { + "last_validated_date": "2025-01-21T18:14:24+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_list_objects_with_prefix[]": { + "last_validated_date": "2025-01-21T18:14:22+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_s3_list_objects_empty_marker": { + "last_validated_date": "2025-01-21T18:14:31+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_s3_list_objects_timestamp_precision[ListObjectsV2]": { + "last_validated_date": "2025-01-21T18:14:37+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjects::test_s3_list_objects_timestamp_precision[ListObjects]": { + "last_validated_date": "2025-01-21T18:14:35+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_continuation_common_prefixes": { + "last_validated_date": "2025-01-21T18:14:51+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_continuation_start_after": { + "last_validated_date": "2025-01-21T18:14:48+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_with_prefix": { + "last_validated_date": "2025-01-21T18:14:40+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListObjectsV2::test_list_objects_v2_with_prefix_and_delimiter": { + "last_validated_date": "2025-01-21T18:14:43+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_empty_part_number_marker": { + "last_validated_date": "2025-01-21T18:15:20+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_pagination": { + "last_validated_date": "2025-01-21T18:15:18+00:00" + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_list_parts_via_object_attrs_pagination": { + "last_validated_date": "2025-06-16T13:47:28+00:00", + "durations_in_seconds": { + "setup": 0.97, + "call": 10.45, + "teardown": 0.97, + "total": 12.39 + } + }, + "tests/aws/services/s3/test_s3_list_operations.py::TestS3ListParts::test_s3_list_parts_timestamp_precision": { + "last_validated_date": "2025-01-21T18:15:22+00:00" + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_eventbridge.py b/tests/aws/services/s3/test_s3_notifications_eventbridge.py new file mode 100644 index 0000000000000..c34935fd3745b --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_eventbridge.py @@ -0,0 +1,345 @@ +import json + +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.s3.conftest import TEST_S3_IMAGE + + +@pytest.fixture +def basic_event_bridge_rule_to_sqs_queue( + s3_bucket, events_create_rule, sqs_create_queue, sqs_get_queue_arn, aws_client +): + bus_name = "default" + queue_name = f"test-queue-{short_uid()}" + rule_name = f"test-rule-{short_uid()}" + target_id = f"test-target-{short_uid()}" + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration={"EventBridgeConfiguration": {}} + ) + + pattern = { + "source": ["aws.s3"], + "detail-type": [ + "Object Created", + "Object Deleted", + "Object Restore Initiated", + "Object Restore Completed", + "Object Restore Expired", + "Object Tags Added", + "Object Tags Deleted", + "Object ACL Updated", + "Object Storage Class Changed", + "Object Access Tier Changed", + ], + "detail": {"bucket": {"name": [s3_bucket]}}, + } + rule_arn = events_create_rule(Name=rule_name, EventBusName=bus_name, EventPattern=pattern) + + queue_url = sqs_create_queue(QueueName=queue_name) + queue_arn = sqs_get_queue_arn(queue_url) + queue_policy = { + "Statement": [ + { + "Sid": "EventsToMyQueue", + "Effect": "Allow", + "Principal": {"Service": "events.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": queue_arn, + "Condition": {"ArnEquals": {"aws:SourceArn": rule_arn}}, + } + ] + } + aws_client.sqs.set_queue_attributes( + QueueUrl=queue_url, + Attributes={"Policy": json.dumps(queue_policy), "ReceiveMessageWaitTimeSeconds": "5"}, + ) + aws_client.events.put_targets(Rule=rule_name, Targets=[{"Id": target_id, "Arn": queue_arn}]) + + return s3_bucket, queue_url + + +@pytest.fixture(autouse=True) +def s3_event_bridge_notification(snapshot): + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformers_list( + [ + snapshot.transform.jsonpath("$..detail.bucket.name", "bucket-name"), + snapshot.transform.jsonpath("$..detail.object.key", "key-name"), + snapshot.transform.jsonpath( + "$..detail.object.sequencer", "object-sequencer", reference_replacement=False + ), + snapshot.transform.jsonpath( + "$..detail.request-id", "request-id", reference_replacement=False + ), + snapshot.transform.jsonpath( + "$..detail.requester", "", reference_replacement=False + ), + snapshot.transform.jsonpath("$..detail.source-ip-address", "ip-address"), + ] + ) + + +@pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="EventBridge not enabled in S3 image") +class TestS3NotificationsToEventBridge: + @markers.aws.validated + def test_object_created_put(self, basic_event_bridge_rule_to_sqs_queue, snapshot, aws_client): + bucket_name, queue_url = basic_event_bridge_rule_to_sqs_queue + + test_key = "test-key" + aws_client.s3.put_object(Bucket=bucket_name, Key=test_key, Body=b"data") + aws_client.s3.delete_object(Bucket=bucket_name, Key=test_key) + + messages = {} + + def _receive_messages(): + received = aws_client.sqs.receive_message(QueueUrl=queue_url).get("Messages", []) + for msg in received: + event_message = json.loads(msg["Body"]) + messages.update({event_message["detail-type"]: event_message}) + + assert len(messages) == 2 + + retries = 10 if is_aws_cloud() else 5 + retry(_receive_messages, retries=retries) + object_deleted_event = messages["Object Deleted"] + object_created_event = messages["Object Created"] + snapshot.match("object_deleted", object_deleted_event) + snapshot.match("object_created", object_created_event) + # assert that the request-id is randomly generated + # ideally, it should use the true request-id. However, the request-id is set in the serializer for now, + # and would need to be set before going through the skeleton + assert ( + object_deleted_event["detail"]["request-id"] + != object_created_event["detail"]["request-id"] + ) + + @markers.aws.validated + def test_object_put_acl(self, basic_event_bridge_rule_to_sqs_queue, snapshot, aws_client): + # setup fixture + bucket_name, queue_url = basic_event_bridge_rule_to_sqs_queue + aws_client.s3.delete_bucket_ownership_controls(Bucket=bucket_name) + aws_client.s3.delete_public_access_block(Bucket=bucket_name) + key_name = "my_key_acl" + + aws_client.s3.put_object(Bucket=bucket_name, Key=key_name, Body="something") + list_bucket_output = aws_client.s3.list_buckets() + owner = list_bucket_output["Owner"] + + # change the ACL to the default one, it should not send an Event. Use canned ACL first + aws_client.s3.put_object_acl(Bucket=bucket_name, Key=key_name, ACL="private") + # change the ACL, it should not send an Event. Use canned ACL first + aws_client.s3.put_object_acl(Bucket=bucket_name, Key=key_name, ACL="public-read") + # try changing ACL with Grant + aws_client.s3.put_object_acl( + Bucket=bucket_name, + Key=key_name, + GrantRead='uri="http://acs.amazonaws.com/groups/s3/LogDelivery"', + ) + # try changing ACL with ACP + acp = { + "Owner": owner, + "Grants": [ + { + "Grantee": {"ID": owner["ID"], "Type": "CanonicalUser"}, + "Permission": "FULL_CONTROL", + }, + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "WRITE", + }, + ], + } + aws_client.s3.put_object_acl(Bucket=bucket_name, Key=key_name, AccessControlPolicy=acp) + + messages = [] + + def _receive_messages(): + received = aws_client.sqs.receive_message(QueueUrl=queue_url).get("Messages", []) + for msg in received: + event_message = json.loads(msg["Body"]) + messages.append(event_message) + + assert len(messages) == 4 + + retries = 10 if is_aws_cloud() else 5 + sleep_time = 1 if is_aws_cloud() else 0.1 + retry(_receive_messages, retries=retries, sleep=sleep_time) + messages.sort(key=lambda x: (x["detail-type"], x["time"])) + snapshot.match("messages", {"messages": messages}) + + @markers.aws.validated + def test_restore_object(self, basic_event_bridge_rule_to_sqs_queue, snapshot, aws_client): + # setup fixture + bucket_name, queue_url = basic_event_bridge_rule_to_sqs_queue + key_name = "my_key_restore" + + # We set the StorageClass to Glacier Flexible Retrieval (formerly Glacier) as it's the only one allowing + # Expedited retrieval Tier (with the Intelligent Access Archive tier) + aws_client.s3.put_object( + Bucket=bucket_name, Key=key_name, Body="something", StorageClass="GLACIER" + ) + + aws_client.s3.restore_object( + Bucket=bucket_name, + Key=key_name, + RestoreRequest={ + "Days": 1, + "GlacierJobParameters": { + "Tier": "Expedited", # Set it as Expedited, it should be done within 1-5min + }, + }, + ) + + def _is_object_restored(): + resp = aws_client.s3.head_object(Bucket=bucket_name, Key=key_name) + assert 'ongoing-request="false"' in resp["Restore"] + + if is_aws_cloud(): + retries = 12 + sleep = 30 + else: + retries = 3 + sleep = 1 + + retry(_is_object_restored, retries=retries, sleep=sleep) + + messages = [] + + def _receive_messages(): + received = aws_client.sqs.receive_message(QueueUrl=queue_url).get("Messages", []) + for msg in received: + event_message = json.loads(msg["Body"]) + # skip PutObject + if event_message["detail-type"] != "Object Created": + messages.append(event_message) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"] + ) + + assert len(messages) == 2 + + retries = 20 if is_aws_cloud() else 5 + sleep_time = 1 if is_aws_cloud() else 0.1 + retry(_receive_messages, retries=retries, sleep=sleep_time) + messages.sort(key=lambda x: x["time"]) + snapshot.match("messages", {"messages": messages}) + + @markers.aws.validated + def test_object_created_put_in_different_region( + self, + basic_event_bridge_rule_to_sqs_queue, + snapshot, + aws_client_factory, + aws_client, + secondary_region_name, + region_name, + ): + snapshot.add_transformer(snapshot.transform.key_value("region"), priority=-1) + # create the bucket and the queue URL in the default region + bucket_name, queue_url = basic_event_bridge_rule_to_sqs_queue + + # create an S3 client in another region, to verify the region in the event + s3_client = aws_client_factory(region_name=secondary_region_name).s3 + test_key = "test-key" + s3_client.put_object(Bucket=bucket_name, Key=test_key, Body=b"data") + aws_client.s3.put_object(Bucket=bucket_name, Key=test_key, Body=b"data") + + messages = [] + + def _receive_messages(): + received = aws_client.sqs.receive_message(QueueUrl=queue_url).get("Messages", []) + for msg in received: + event_message = json.loads(msg["Body"]) + messages.append(event_message) + + assert len(messages) == 2 + + retries = 10 if is_aws_cloud() else 5 + retry(_receive_messages, retries=retries) + snapshot.match("object-created-different-regions", {"messages": messages}) + assert messages[0]["region"] == messages[1]["region"] == region_name + + @markers.aws.validated + def test_object_created_put_versioned( + self, basic_event_bridge_rule_to_sqs_queue, snapshot, aws_client + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("version-id"), + snapshot.transform.key_value("VersionId"), + ] + ) + bucket_name, queue_url = basic_event_bridge_rule_to_sqs_queue + aws_client.s3.put_bucket_versioning( + Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"} + ) + + test_key = "test-key" + # We snapshot all objects to snapshot their VersionId, to be sure the events correspond well to what we think + # create first version + obj_ver1 = aws_client.s3.put_object(Bucket=bucket_name, Key=test_key, Body=b"data") + snapshot.match("obj-ver1", obj_ver1) + # create second version + obj_ver2 = aws_client.s3.put_object(Bucket=bucket_name, Key=test_key, Body=b"data-version2") + snapshot.match("obj-ver2", obj_ver2) + # delete the top most version, creating a DeleteMarker + delete_marker = aws_client.s3.delete_object(Bucket=bucket_name, Key=test_key) + snapshot.match("delete-marker", delete_marker) + # delete a specific version (Version1) + delete_version = aws_client.s3.delete_object( + Bucket=bucket_name, Key=test_key, VersionId=obj_ver1["VersionId"] + ) + snapshot.match("delete-version", delete_version) + + messages = [] + + def _receive_messages(expected: int): + received = aws_client.sqs.receive_message(QueueUrl=queue_url).get("Messages", []) + for msg in received: + event_message = json.loads(msg["Body"]) + messages.append(event_message) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"] + ) + + assert len(messages) == expected + # make the list always be sorted the same way, as it can be inconsistent in AWS + messages.sort( + key=lambda x: ( + x["detail"].get("deletion-type", ""), + x["detail-type"], + x["detail"]["object"].get("etag", ""), + ) + ) + return messages + + retries = 15 if is_aws_cloud() else 5 + retry(_receive_messages, retries=retries, expected=4) + snapshot.match("message-versioning-active", messages) + messages.clear() + + # suspend the versioning + aws_client.s3.put_bucket_versioning( + Bucket=bucket_name, VersioningConfiguration={"Status": "Suspended"} + ) + # add a new object, which will have versionId = null + add_null_version = aws_client.s3.put_object( + Bucket=bucket_name, Key=test_key, Body=b"data-version3" + ) + snapshot.match("add-null-version", add_null_version) + # delete the DeleteMarker + del_delete_marker = aws_client.s3.delete_object( + Bucket=bucket_name, Key=test_key, VersionId=delete_marker["VersionId"] + ) + snapshot.match("del-delete-marker", del_delete_marker) + + retry(_receive_messages, retries=retries, expected=2) + snapshot.match("message-versioning-suspended", messages) diff --git a/tests/aws/services/s3/test_s3_notifications_eventbridge.snapshot.json b/tests/aws/services/s3/test_s3_notifications_eventbridge.snapshot.json new file mode 100644 index 0000000000000..fe563d07d1dec --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_eventbridge.snapshot.json @@ -0,0 +1,478 @@ +{ + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put": { + "recorded-date": "21-01-2025, 23:25:10", + "recorded-content": { + "object_deleted": { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "deletion-type": "Permanently Deleted", + "object": { + "key": "", + "sequencer": "object-sequencer" + }, + "reason": "DeleteObject", + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object Deleted", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + }, + "object_created": { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "8d777f385d3dfec8815d20f7496026dc", + "key": "", + "sequencer": "object-sequencer", + "size": 4 + }, + "reason": "PutObject", + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object Created", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + } + } + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_put_acl": { + "recorded-date": "21-01-2025, 23:36:07", + "recorded-content": { + "messages": { + "messages": [ + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "437b930db84b8079c2dd804a71936b5f", + "key": "" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object ACL Updated", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + }, + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "437b930db84b8079c2dd804a71936b5f", + "key": "" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object ACL Updated", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + }, + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "437b930db84b8079c2dd804a71936b5f", + "key": "" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object ACL Updated", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + }, + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "437b930db84b8079c2dd804a71936b5f", + "key": "", + "sequencer": "object-sequencer", + "size": 9 + }, + "reason": "PutObject", + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object Created", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_restore_object": { + "recorded-date": "21-01-2025, 23:38:04", + "recorded-content": { + "messages": { + "messages": [ + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "437b930db84b8079c2dd804a71936b5f", + "key": "", + "size": 9 + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "source-storage-class": "GLACIER", + "version": "0" + }, + "detail-type": "Object Restore Initiated", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + }, + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "437b930db84b8079c2dd804a71936b5f", + "key": "", + "size": 9 + }, + "request-id": "request-id", + "requester": "", + "restore-expiry-time": "date", + "source-storage-class": "GLACIER", + "version": "0" + }, + "detail-type": "Object Restore Completed", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put_in_different_region": { + "recorded-date": "21-01-2025, 23:29:29", + "recorded-content": { + "object-created-different-regions": { + "messages": [ + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "8d777f385d3dfec8815d20f7496026dc", + "key": "", + "sequencer": "object-sequencer", + "size": 4 + }, + "reason": "PutObject", + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object Created", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + }, + { + "account": "111111111111", + "detail": { + "bucket": { + "name": "" + }, + "object": { + "etag": "8d777f385d3dfec8815d20f7496026dc", + "key": "", + "sequencer": "object-sequencer", + "size": 4 + }, + "reason": "PutObject", + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "version": "0" + }, + "detail-type": "Object Created", + "id": "", + "region": "", + "resources": [ + "arn::s3:::" + ], + "source": "aws.s3", + "time": "date", + "version": "0" + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put_versioned": { + "recorded-date": "21-01-2025, 23:40:14", + "recorded-content": { + "obj-ver1": { + "ChecksumCRC32": "rfPzYw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "obj-ver2": { + "ChecksumCRC32": "DIwTWw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"6869c34ca384e0ed836d49214f881e87\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-version": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "message-versioning-active": [ + { + "version": "0", + "id": "", + "detail-type": "Object Created", + "source": "aws.s3", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::s3:::" + ], + "detail": { + "version": "0", + "bucket": { + "name": "" + }, + "object": { + "key": "", + "size": 13, + "etag": "2fb1d4988881168bbcd6432d7593be5a", + "sequencer": "object-sequencer" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "reason": "PutObject" + } + }, + { + "version": "0", + "id": "", + "detail-type": "Object Deleted", + "source": "aws.s3", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::s3:::" + ], + "detail": { + "version": "0", + "bucket": { + "name": "" + }, + "object": { + "key": "", + "version-id": "", + "sequencer": "object-sequencer" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "reason": "DeleteObject", + "deletion-type": "Permanently Deleted" + } + } + ], + "add-null-version": { + "ChecksumCRC32": "e4sjzQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2fb1d4988881168bbcd6432d7593be5a\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "message-versioning-suspended": [ + { + "version": "0", + "id": "", + "detail-type": "Object Created", + "source": "aws.s3", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::s3:::" + ], + "detail": { + "version": "0", + "bucket": { + "name": "" + }, + "object": { + "key": "", + "size": 13, + "etag": "2fb1d4988881168bbcd6432d7593be5a", + "sequencer": "object-sequencer" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "reason": "PutObject" + } + }, + { + "version": "0", + "id": "", + "detail-type": "Object Deleted", + "source": "aws.s3", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::s3:::" + ], + "detail": { + "version": "0", + "bucket": { + "name": "" + }, + "object": { + "key": "", + "version-id": "", + "sequencer": "object-sequencer" + }, + "request-id": "request-id", + "requester": "", + "source-ip-address": "", + "reason": "DeleteObject", + "deletion-type": "Permanently Deleted" + } + } + ] + } + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_eventbridge.validation.json b/tests/aws/services/s3/test_s3_notifications_eventbridge.validation.json new file mode 100644 index 0000000000000..fd612e07cdab6 --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_eventbridge.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put": { + "last_validated_date": "2025-01-21T23:25:09+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put_in_different_region": { + "last_validated_date": "2025-01-21T23:29:27+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_created_put_versioned": { + "last_validated_date": "2025-01-21T23:40:12+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_object_put_acl": { + "last_validated_date": "2025-01-21T23:36:06+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_eventbridge.py::TestS3NotificationsToEventBridge::test_restore_object": { + "last_validated_date": "2025-01-21T23:38:02+00:00" + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_lambda.py b/tests/aws/services/s3/test_s3_notifications_lambda.py new file mode 100644 index 0000000000000..1411bf5215388 --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_lambda.py @@ -0,0 +1,250 @@ +import json +import os + +import pytest +from botocore.config import Config +from botocore.exceptions import ClientError + +from localstack.testing.aws.lambda_utils import _await_dynamodb_table_active +from localstack.testing.aws.util import in_default_partition +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.aws.arns import get_partition +from localstack.utils.http import safe_requests as requests +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.s3.conftest import TEST_S3_IMAGE + +THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +TEST_LAMBDA_PYTHON_TRIGGERED_S3 = os.path.join( + THIS_FOLDER, "../lambda_", "functions", "lambda_triggered_by_s3.py" +) + + +@pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="Lambda not enabled in S3 image") +class TestS3NotificationsToLambda: + @markers.aws.validated + def test_create_object_put_via_dynamodb( + self, + s3_bucket, + create_lambda_function, + create_role, + dynamodb_create_table, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.s3_dynamodb_notifications()) + function_name = f"func-{short_uid()}" + table_name = f"table-{short_uid()}" + role_name = f"test-role-{short_uid()}" + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + ], + } + + role = create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy)) + aws_client.iam.attach_role_policy( + RoleName=role_name, + PolicyArn=f"arn:{get_partition(aws_client.iam.meta.region_name)}:iam::aws:policy/AWSLambdaExecute", + ) + aws_client.iam.attach_role_policy( + RoleName=role_name, + PolicyArn=f"arn:{get_partition(aws_client.iam.meta.region_name)}:iam::aws:policy/AmazonDynamoDBFullAccess", + ) + lambda_role = role["Role"]["Arn"] + + function = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_TRIGGERED_S3, func_name=function_name, role=lambda_role + )["CreateFunctionResponse"] + + aws_client.lambda_.add_permission( + StatementId="1", + FunctionName=function_name, + Action="lambda:InvokeFunction", + Principal="s3.amazonaws.com", + ) + + # this test uses dynamodb as an intermediary to get the notifications from the lambda back to the test + dynamodb_create_table( + table_name=table_name, partition_key="uuid", client=aws_client.dynamodb + ) + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration={ + "LambdaFunctionConfigurations": [ + { + "LambdaFunctionArn": function["FunctionArn"], + "Events": ["s3:ObjectCreated:*"], + } + ] + }, + ) + + # put an object + aws_client.s3.put_object(Bucket=s3_bucket, Key=table_name, Body="something..") + + def check_table(): + rs = aws_client.dynamodb.scan(TableName=table_name) + assert len(rs["Items"]) == 1 + event = rs["Items"][0]["data"] + snapshot.match("table_content", event) + + retry(check_table, retries=5, sleep=1) + + @pytest.mark.skipif( + not in_default_partition(), + reason="presigned_url_post currently not working with non-default partitions", + ) + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..data.M.s3.M.object.M.eTag.S", + "$..data.M.s3.M.object.M.size.N", + ], # TODO presigned-post sporadic failures in CI Pipeline + ) + def test_create_object_by_presigned_request_via_dynamodb( + self, + s3_bucket, + create_lambda_function, + dynamodb_create_table, + create_role, + snapshot, + aws_client, + aws_client_factory, + ): + snapshot.add_transformer(snapshot.transform.s3_dynamodb_notifications()) + function_name = f"func-{short_uid()}" + table_name = f"table-{short_uid()}" + role_name = f"test-role-{short_uid()}" + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + ], + } + role = create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy)) + aws_client.iam.attach_role_policy( + RoleName=role_name, + PolicyArn=f"arn:{get_partition(aws_client.iam.meta.region_name)}:iam::aws:policy/AWSLambdaExecute", + ) + aws_client.iam.attach_role_policy( + RoleName=role_name, + PolicyArn=f"arn:{get_partition(aws_client.iam.meta.region_name)}:iam::aws:policy/AmazonDynamoDBFullAccess", + ) + lambda_role = role["Role"]["Arn"] + + function = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_TRIGGERED_S3, func_name=function_name, role=lambda_role + )["CreateFunctionResponse"] + + aws_client.lambda_.add_permission( + StatementId="1", + FunctionName=function_name, + Action="lambda:InvokeFunction", + Principal="s3.amazonaws.com", + ) + + dynamodb_create_table( + table_name=table_name, partition_key="uuid", client=aws_client.dynamodb + ) + _await_dynamodb_table_active(aws_client.dynamodb, table_name) + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration={ + "LambdaFunctionConfigurations": [ + { + "LambdaFunctionArn": function["FunctionArn"], + "Events": ["s3:ObjectCreated:*"], + } + ] + }, + ) + + s3_sigv4_client = aws_client_factory( + config=Config(signature_version="s3v4"), + ).s3 + put_url = s3_sigv4_client.generate_presigned_url( + ClientMethod="put_object", Params={"Bucket": s3_bucket, "Key": table_name} + ) + requests.put(put_url, data="by_presigned_put") + + presigned_post = s3_sigv4_client.generate_presigned_post(Bucket=s3_bucket, Key=table_name) + # method 1 + requests.post( + presigned_post["url"], + data=presigned_post["fields"], + files={"file": b"by post method 1"}, + ) + + def check_table(): + rs = aws_client.dynamodb.scan(TableName=table_name) + items = sorted(rs["Items"], key=lambda x: x["data"]["M"]["eventName"]["S"]) + assert len(rs["Items"]) == 2 + snapshot.match("items", items) + + retry(check_table, retries=20, sleep=2) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Error.ArgumentName1", + "$..Error.ArgumentValue1", + "$..Error.ArgumentName", + "$..Error.ArgumentValue", + ], + ) + def test_invalid_lambda_arn(self, s3_bucket, account_id, snapshot, aws_client, region_name): + config = { + "LambdaFunctionConfigurations": [ + { + "Id": "id123", + "Events": ["s3:ObjectCreated:*"], + } + ] + } + + config["LambdaFunctionConfigurations"][0]["LambdaFunctionArn"] = "invalid-queue" + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + SkipDestinationValidation=False, + ) + snapshot.match("invalid_not_skip", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + SkipDestinationValidation=True, + ) + snapshot.match("invalid_skip", e.value.response) + + # set valid but not-existing lambda + config["LambdaFunctionConfigurations"][0]["LambdaFunctionArn"] = ( + f"{arns.lambda_function_arn('my-lambda', account_id=account_id, region_name=region_name)}" + ) + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + ) + snapshot.match("lambda-does-not-exist", e.value.response) + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration=config, SkipDestinationValidation=True + ) + config = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + snapshot.match("skip_destination_validation", config) diff --git a/tests/aws/services/s3/test_s3_notifications_lambda.snapshot.json b/tests/aws/services/s3/test_s3_notifications_lambda.snapshot.json new file mode 100644 index 0000000000000..94bcbc90393d3 --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_lambda.snapshot.json @@ -0,0 +1,338 @@ +{ + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_create_object_put_via_dynamodb": { + "recorded-date": "21-01-2025, 23:31:40", + "recorded-content": { + "table_content": { + "M": { + "awsRegion": { + "S": "" + }, + "eventName": { + "S": "ObjectCreated:Put" + }, + "eventSource": { + "S": "aws:s3" + }, + "eventTime": { + "S": "date" + }, + "eventVersion": { + "S": "2.1" + }, + "requestParameters": { + "M": { + "sourceIPAddress": { + "S": "" + } + } + }, + "responseElements": { + "M": { + "x-amz-id-2": { + "S": "amz-id" + }, + "x-amz-request-id": { + "S": "amz-request-id" + } + } + }, + "s3": { + "M": { + "bucket": { + "M": { + "arn": { + "S": "arn::s3:::" + }, + "name": { + "S": "" + }, + "ownerIdentity": { + "M": { + "principalId": { + "S": "" + } + } + } + } + }, + "configurationId": { + "S": "" + }, + "object": { + "M": { + "eTag": { + "S": "e9fb1ed1d5eaa36781088aae37acffbd" + }, + "key": { + "S": "" + }, + "sequencer": { + "S": "sequencer" + }, + "size": { + "N": "11" + } + } + }, + "s3SchemaVersion": { + "S": "1.0" + } + } + }, + "userIdentity": { + "M": { + "principalId": { + "S": "" + } + } + } + } + } + } + }, + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_create_object_by_presigned_request_via_dynamodb": { + "recorded-date": "17-03-2025, 20:19:48", + "recorded-content": { + "items": [ + { + "uuid": { + "S": "" + }, + "data": { + "M": { + "s3": { + "M": { + "bucket": { + "M": { + "name": { + "S": "" + }, + "arn": { + "S": "arn::s3:::" + }, + "ownerIdentity": { + "M": { + "principalId": { + "S": "" + } + } + } + } + }, + "configurationId": { + "S": "" + }, + "s3SchemaVersion": { + "S": "1.0" + }, + "object": { + "M": { + "eTag": { + "S": "232d7472010db416df31c55fdc0490a5" + }, + "size": { + "N": "16" + }, + "key": { + "S": "" + }, + "sequencer": { + "S": "sequencer" + } + } + } + } + }, + "awsRegion": { + "S": "" + }, + "eventVersion": { + "S": "2.1" + }, + "responseElements": { + "M": { + "x-amz-id-2": { + "S": "amz-id" + }, + "x-amz-request-id": { + "S": "amz-request-id" + } + } + }, + "eventSource": { + "S": "aws:s3" + }, + "eventTime": { + "S": "date" + }, + "requestParameters": { + "M": { + "sourceIPAddress": { + "S": "" + } + } + }, + "eventName": { + "S": "ObjectCreated:Post" + }, + "userIdentity": { + "M": { + "principalId": { + "S": "" + } + } + } + } + } + }, + { + "uuid": { + "S": "" + }, + "data": { + "M": { + "s3": { + "M": { + "bucket": { + "M": { + "name": { + "S": "" + }, + "arn": { + "S": "arn::s3:::" + }, + "ownerIdentity": { + "M": { + "principalId": { + "S": "" + } + } + } + } + }, + "configurationId": { + "S": "" + }, + "s3SchemaVersion": { + "S": "1.0" + }, + "object": { + "M": { + "eTag": { + "S": "b0d833b208acd18be70a25c9323f210d" + }, + "size": { + "N": "16" + }, + "key": { + "S": "" + }, + "sequencer": { + "S": "sequencer" + } + } + } + } + }, + "awsRegion": { + "S": "" + }, + "eventVersion": { + "S": "2.1" + }, + "responseElements": { + "M": { + "x-amz-id-2": { + "S": "amz-id" + }, + "x-amz-request-id": { + "S": "amz-request-id" + } + } + }, + "eventSource": { + "S": "aws:s3" + }, + "eventTime": { + "S": "date" + }, + "requestParameters": { + "M": { + "sourceIPAddress": { + "S": "" + } + } + }, + "eventName": { + "S": "ObjectCreated:Put" + }, + "userIdentity": { + "M": { + "principalId": { + "S": "" + } + } + } + } + } + } + ] + } + }, + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_invalid_lambda_arn": { + "recorded-date": "21-01-2025, 23:32:13", + "recorded-content": { + "invalid_not_skip": { + "Error": { + "ArgumentName": "CloudFunction", + "ArgumentValue": "invalid-queue", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_skip": { + "Error": { + "ArgumentName": "CloudFunction", + "ArgumentValue": "invalid-queue", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "lambda-does-not-exist": { + "Error": { + "ArgumentName1": "arn::lambda::111111111111:function:my-lambda, null", + "ArgumentValue1": "Not authorized to invoke function [arn::lambda::111111111111:function:my-lambda]", + "Code": "InvalidArgument", + "Message": "Unable to validate the following destination configurations" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "skip_destination_validation": { + "LambdaFunctionConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Id": "id123", + "LambdaFunctionArn": "arn::lambda::111111111111:function:my-lambda" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_lambda.validation.json b/tests/aws/services/s3/test_s3_notifications_lambda.validation.json new file mode 100644 index 0000000000000..8b820dd1c5827 --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_lambda.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_create_object_by_presigned_request_via_dynamodb": { + "last_validated_date": "2025-03-17T20:19:48+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_create_object_put_via_dynamodb": { + "last_validated_date": "2025-01-21T23:31:40+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_lambda.py::TestS3NotificationsToLambda::test_invalid_lambda_arn": { + "last_validated_date": "2025-01-21T23:32:13+00:00" + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_sns.py b/tests/aws/services/s3/test_s3_notifications_sns.py new file mode 100644 index 0000000000000..81b6b2bd675dd --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_sns.py @@ -0,0 +1,295 @@ +import json +import logging +from io import BytesIO +from typing import TYPE_CHECKING, Dict, List + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.strings import short_uid +from localstack.utils.sync import poll_condition +from tests.aws.services.s3.conftest import TEST_S3_IMAGE + +if TYPE_CHECKING: + from mypy_boto3_s3 import S3Client + from mypy_boto3_s3.literals import EventType + from mypy_boto3_sns import SNSClient + from mypy_boto3_sqs import SQSClient + +LOG = logging.getLogger(__name__) + + +def create_sns_bucket_notification( + s3_client: "S3Client", + sns_client: "SNSClient", + bucket_name: str, + topic_arn: str, + events: List["EventType"], +): + """A NotificationFactory.""" + bucket_arn = arns.s3_bucket_arn(bucket_name) + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "sns:Publish", + "Resource": topic_arn, + "Condition": {"ArnEquals": {"aws:SourceArn": bucket_arn}}, + } + ], + } + sns_client.set_topic_attributes( + TopicArn=topic_arn, AttributeName="Policy", AttributeValue=json.dumps(policy) + ) + + s3_client.put_bucket_notification_configuration( + Bucket=bucket_name, + NotificationConfiguration=dict( + TopicConfigurations=[ + dict( + TopicArn=topic_arn, + Events=events, + ) + ] + ), + ) + + +def sqs_collect_sns_messages( + sqs_client: "SQSClient", queue_url: str, min_messages: int, timeout: int = 10 +) -> List[Dict]: + """ + Polls the given queue for the given amount of time and extracts the received SQS messages all SNS messages (messages that have a "TopicArn" field). + + :param sqs_client: the boto3 client to use + :param queue_url: the queue URL connected to the topic + :param min_messages: the minimum number of messages to wait for + :param timeout: the number of seconds to wait before raising an assert error + :return: a list with the deserialized SNS messages + """ + + collected_messages = [] + + def collect_events() -> int: + _response = sqs_client.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=timeout, MaxNumberOfMessages=1 + ) + messages = _response.get("Messages", []) + if not messages: + LOG.info("no messages received from %s after %d seconds", queue_url, timeout) + + for m in messages: + body = m["Body"] + # see https://www.mikulskibartosz.name/what-is-s3-test-event/ + if "s3:TestEvent" in body: + continue + + doc = json.loads(body) + assert "TopicArn" in doc, f"unexpected event in message {m}" + collected_messages.append(doc) + + return len(collected_messages) + + assert poll_condition(lambda: collect_events() >= min_messages, timeout=timeout) + + return collected_messages + + +@pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="SNS not enabled in S3 image") +class TestS3NotificationsToSns: + @markers.aws.validated + def test_object_created_put( + self, + s3_bucket, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.sns_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + key_name = "bucket-key" + + # connect topic to queue + sns_create_sqs_subscription(topic_arn, queue_url) + create_sns_bucket_notification( + aws_client.s3, aws_client.sns, s3_bucket, topic_arn, ["s3:ObjectCreated:*"] + ) + + # trigger the events + aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="first event") + aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="second event") + + # collect messages + messages = sqs_collect_sns_messages(aws_client.sqs, queue_url, 2) + # order seems not be guaranteed - sort so we can rely on the order + messages.sort(key=lambda x: json.loads(x["Message"])["Records"][0]["s3"]["object"]["size"]) + snapshot.match("receive_messages", {"messages": messages}) + # asserts + # first event + message = messages[0] + assert message["Type"] == "Notification" + assert message["TopicArn"] == topic_arn + assert message["Subject"] == "Amazon S3 Notification" + + event = json.loads(message["Message"])["Records"][0] + assert event["eventSource"] == "aws:s3" + assert event["eventName"] == "ObjectCreated:Put" + assert event["s3"]["bucket"]["name"] == s3_bucket + assert event["s3"]["object"]["key"] == key_name + assert event["s3"]["object"]["size"] == len("first event") + + # second event + message = messages[1] + assert message["Type"] == "Notification" + assert message["TopicArn"] == topic_arn + assert message["Subject"] == "Amazon S3 Notification" + + event = json.loads(message["Message"])["Records"][0] + assert event["eventSource"] == "aws:s3" + assert event["eventName"] == "ObjectCreated:Put" + assert event["s3"]["bucket"]["name"] == s3_bucket + assert event["s3"]["object"]["key"] == key_name + assert event["s3"]["object"]["size"] == len("second event") + + @markers.aws.validated + def test_bucket_notifications_with_filter( + self, + sqs_create_queue, + sns_create_topic, + s3_bucket, + sns_create_sqs_subscription, + snapshot, + aws_client, + ): + # Tests s3->sns->sqs notifications + # + queue_name = f"queue-{short_uid()}" + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue(QueueName=queue_name) + + snapshot.add_transformer(snapshot.transform.regex(queue_name, "")) + snapshot.add_transformer(snapshot.transform.s3_notifications_transformer()) + snapshot.add_transformer(snapshot.transform.sns_api()) + + # connect topic to queue + sns_create_sqs_subscription(topic_arn, queue_url) + create_sns_bucket_notification( + aws_client.s3, aws_client.sns, s3_bucket, topic_arn, ["s3:ObjectCreated:*"] + ) + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration={ + "TopicConfigurations": [ + { + "Id": "id123", + "Events": ["s3:ObjectCreated:*"], + "TopicArn": topic_arn, + "Filter": { + "Key": {"FilterRules": [{"Name": "Prefix", "Value": "testupload/"}]} + }, + } + ] + }, + ) + test_key1 = "test/dir1/myfile.txt" + test_key2 = "testupload/dir1/testfile.txt" + test_data = b'{"test": "bucket_notification one"}' + + aws_client.s3.upload_fileobj(BytesIO(test_data), s3_bucket, test_key1) + aws_client.s3.upload_fileobj(BytesIO(test_data), s3_bucket, test_key2) + + messages = sqs_collect_sns_messages(aws_client.sqs, queue_url, 1) + assert len(messages) == 1 + snapshot.match("message", messages[0]) + message = messages[0] + assert message["Type"] == "Notification" + assert message["TopicArn"] == topic_arn + assert message["Subject"] == "Amazon S3 Notification" + + event = json.loads(message["Message"])["Records"][0] + assert event["eventSource"] == "aws:s3" + assert event["eventName"] == "ObjectCreated:Put" + assert event["s3"]["bucket"]["name"] == s3_bucket + assert event["s3"]["object"]["key"] == test_key2 + + @markers.aws.validated + def test_bucket_not_exist(self, account_id, region_name, snapshot, aws_client): + bucket_name = f"this-bucket-does-not-exist-{short_uid()}" + snapshot.add_transformer(snapshot.transform.s3_api()) + config = { + "TopicConfigurations": [ + { + "Id": "id123", + "Events": ["s3:ObjectCreated:*"], + "TopicArn": f"{arns.sns_topic_arn('my-topic', account_id=account_id, region_name=region_name)}", + } + ] + } + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=bucket_name, + NotificationConfiguration=config, + SkipDestinationValidation=True, + ) + snapshot.match("bucket_not_exists", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Error.ArgumentName", "$..Error.ArgumentValue"], + ) + def test_invalid_topic_arn(self, s3_bucket, account_id, region_name, snapshot, aws_client): + config = { + "TopicConfigurations": [ + { + "Id": "id123", + "Events": ["s3:ObjectCreated:*"], + } + ] + } + + config["TopicConfigurations"][0]["TopicArn"] = "invalid-topic" + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + SkipDestinationValidation=False, + ) + snapshot.match("invalid_not_skip", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + SkipDestinationValidation=True, + ) + snapshot.match("invalid_skip", e.value.response) + + # set valid but not-existing topic + config["TopicConfigurations"][0]["TopicArn"] = ( + f"{arns.sns_topic_arn('my-topic', account_id=account_id, region_name=region_name)}" + ) + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + ) + # TODO cannot snapshot as AWS seems to check permission first -> as it does not exist, we cannot set permissions here + assert e.match("InvalidArgument") + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration=config, SkipDestinationValidation=True + ) + config = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + snapshot.match("skip_destination_validation", config) diff --git a/tests/aws/services/s3/test_s3_notifications_sns.snapshot.json b/tests/aws/services/s3/test_s3_notifications_sns.snapshot.json new file mode 100644 index 0000000000000..47a75f963ecf8 --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_sns.snapshot.json @@ -0,0 +1,223 @@ +{ + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_object_created_put": { + "recorded-date": "21-01-2025, 23:20:27", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "Message": { + "Records": [ + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "bucket-key", + "size": 11, + "eTag": "c8e3a3027a133355210f85cfbb1acc35", + "sequencer": "sequencer" + } + } + } + ] + }, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Subject": "Amazon S3 Notification", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + { + "Message": { + "Records": [ + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "bucket-key", + "size": 12, + "eTag": "05c7c1b96e20928f6e55a881b5ca1c45", + "sequencer": "sequencer" + } + } + } + ] + }, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Subject": "Amazon S3 Notification", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_bucket_notifications_with_filter": { + "recorded-date": "21-01-2025, 23:20:33", + "recorded-content": { + "message": { + "Message": { + "Records": [ + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "testupload/dir1/testfile.txt", + "size": 35, + "eTag": "f5260f0f41af2722d9fe17a57dfa9da5", + "sequencer": "sequencer" + } + } + } + ] + }, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Subject": "Amazon S3 Notification", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_invalid_topic_arn": { + "recorded-date": "21-01-2025, 23:20:37", + "recorded-content": { + "invalid_not_skip": { + "Error": { + "ArgumentName": "Topic", + "ArgumentValue": "invalid-topic", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_skip": { + "Error": { + "ArgumentName": "Topic", + "ArgumentValue": "invalid-topic", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "skip_destination_validation": { + "TopicConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Id": "id123", + "TopicArn": "arn::sns::111111111111:my-topic" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_bucket_not_exist": { + "recorded-date": "21-01-2025, 23:20:35", + "recorded-content": { + "bucket_not_exists": { + "Error": { + "BucketName": "", + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_sns.validation.json b/tests/aws/services/s3/test_s3_notifications_sns.validation.json new file mode 100644 index 0000000000000..612862550bb2a --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_sns.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_bucket_not_exist": { + "last_validated_date": "2025-01-21T23:20:35+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_bucket_notifications_with_filter": { + "last_validated_date": "2025-01-21T23:20:33+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_invalid_topic_arn": { + "last_validated_date": "2025-01-21T23:20:37+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sns.py::TestS3NotificationsToSns::test_object_created_put": { + "last_validated_date": "2025-01-21T23:20:27+00:00" + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_sqs.py b/tests/aws/services/s3/test_s3_notifications_sqs.py new file mode 100644 index 0000000000000..498c589a7150c --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_sqs.py @@ -0,0 +1,1111 @@ +import json +import logging +from io import BytesIO +from typing import TYPE_CHECKING, Dict, List, Protocol + +import pytest +import requests +from boto3.s3.transfer import KB, TransferConfig +from botocore.config import Config +from botocore.exceptions import ClientError + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.s3.conftest import TEST_S3_IMAGE + +if TYPE_CHECKING: + from mypy_boto3_s3 import S3Client + from mypy_boto3_s3.literals import EventType + from mypy_boto3_sqs import SQSClient + +LOG = logging.getLogger(__name__) + + +class NotificationFactory(Protocol): + """ + A protocol for connecting a bucket to a queue with a notification configurations and the necessary policies. + """ + + def __call__(self, bucket_name: str, queue_url: str, events: List["EventType"]) -> None: + """ + Creates a new notification configuration and respective policies. + + :param bucket_name: the source bucket + :param queue_url: the target SQS queue + :param events: the type of S3 events to trigger the notification + :return: None + """ + raise NotImplementedError + + +def get_queue_arn(sqs_client, queue_url: str) -> str: + """ + Returns the given Queue's ARN. Expects the Queue to exist. + + :param sqs_client: the boto3 client + :param queue_url: the queue URL + :return: the QueueARN + """ + response = sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["QueueArn"]) + return response["Attributes"]["QueueArn"] + + +def set_policy_for_queue(sqs_client, queue_url, bucket_name): + queue_arn = get_queue_arn(sqs_client, queue_url) + assert queue_arn + bucket_arn = arns.s3_bucket_arn(bucket_name) + + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "sqs:SendMessage", + "Resource": queue_arn, + "Condition": {"ArnEquals": {"aws:SourceArn": bucket_arn}}, + } + ], + } + sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes={"Policy": json.dumps(policy)}) + return queue_arn + + +def create_sqs_bucket_notification( + s3_client: "S3Client", + sqs_client: "SQSClient", + bucket_name: str, + queue_url: str, + events: List["EventType"], +): + """A NotificationFactory.""" + queue_arn = set_policy_for_queue(sqs_client, queue_url, bucket_name) + s3_client.put_bucket_notification_configuration( + Bucket=bucket_name, + NotificationConfiguration=dict( + QueueConfigurations=[dict(QueueArn=queue_arn, Events=events)] + ), + ) + + +@pytest.fixture +def s3_create_sqs_bucket_notification(aws_client) -> NotificationFactory: + """ + A factory fixture for creating sqs bucket notifications. + """ + + def factory( + bucket_name: str, + queue_url: str, + events: List["EventType"], + s3_client=aws_client.s3, + sqs_client=aws_client.sqs, + ): + return create_sqs_bucket_notification(s3_client, sqs_client, bucket_name, queue_url, events) + + return factory + + +def sqs_collect_s3_events( + sqs_client: "SQSClient", queue_url: str, min_events: int, timeout: int = 10 +) -> List[Dict]: + """ + Polls the given queue for the given amount of time and extracts and flattens from the received messages all + events (messages that have a "Records" field in their body, and where the records can be json-deserialized). + + :param sqs_client: the boto3 client to use + :param queue_url: the queue URL to listen from + :param min_events: the minimum number of events to receive to wait for + :param timeout: the number of seconds to wait before raising an assert error + :return: a list with the deserialized records from the SQS messages + """ + + events = [] + + def collect_events() -> None: + _response = sqs_client.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=1, MaxNumberOfMessages=1 + ) + messages = _response.get("Messages", []) + if not messages: + LOG.info("no messages received from %s after 1 second", queue_url) + + for m in messages: + body = m["Body"] + # see https://www.mikulskibartosz.name/what-is-s3-test-event/ + if "s3:TestEvent" in body: + continue + + assert "Records" in body, "Unexpected event received" + + doc = json.loads(body) + events.extend(doc["Records"]) + sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"]) + + assert len(events) >= min_events + + retry(collect_events, retries=timeout, sleep=0.01) + + return events + + +@pytest.fixture +def sqs_create_queue_with_client(): + queue_urls = [] + + def factory(sqs_client, **kwargs): + if "QueueName" not in kwargs: + kwargs["QueueName"] = "test-queue-%s" % short_uid() + + response = sqs_client.create_queue(**kwargs) + url = response["QueueUrl"] + queue_urls.append((sqs_client, url)) + return url + + yield factory + + # cleanup + for client, queue_url in queue_urls: + try: + client.delete_queue(QueueUrl=queue_url) + except Exception as e: + LOG.debug("error cleaning up queue %s: %s", queue_url, e) + + +@pytest.mark.skipif(condition=TEST_S3_IMAGE, reason="SQS not enabled in S3 image") +class TestS3NotificationsToSQS: + @markers.aws.validated + def test_object_created_put( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + # setup fixture + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectCreated:Put"]) + + aws_client.s3.put_object(Bucket=s3_bucket, Key="my_key_0", Body="something") + aws_client.s3.put_object(Bucket=s3_bucket, Key="my_key_1", Body="something else") + + # collect s3 events from SQS queue + events = sqs_collect_s3_events(aws_client.sqs, queue_url, min_events=2) + + assert len(events) == 2, f"unexpected number of events in {events}" + # order seems not be guaranteed - sort so we can rely on the order + events.sort(key=lambda x: x["s3"]["object"]["size"]) + snapshot.match("receive_messages", {"messages": events}) + + @markers.aws.validated + def test_object_created_copy( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.jsonpath("$..s3.object.key", "object-key")) + + # setup fixture + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectCreated:Copy"]) + + src_key = "src-dest-%s" % short_uid() + dest_key = "key-dest-%s" % short_uid() + + aws_client.s3.put_object(Bucket=s3_bucket, Key=src_key, Body="something") + + assert not sqs_collect_s3_events(aws_client.sqs, queue_url, 0, timeout=1), ( + "unexpected event triggered for put_object" + ) + + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource={"Bucket": s3_bucket, "Key": src_key}, + Key=dest_key, + ) + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, 1) + assert len(events) == 1, f"unexpected number of events in {events}" + snapshot.match("receive_messages", {"messages": events}) + assert events[0]["eventSource"] == "aws:s3" + assert events[0]["eventName"] == "ObjectCreated:Copy" + assert events[0]["s3"]["bucket"]["name"] == s3_bucket + assert events[0]["s3"]["object"]["key"] == dest_key + + @markers.aws.validated + def test_object_created_and_object_removed( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.jsonpath("$..s3.object.key", "object-key")) + + # setup fixture + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification( + s3_bucket, queue_url, ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"] + ) + + src_key = f"src-dest-{short_uid()}" + dest_key = f"key-dest-{short_uid()}" + + # event0 = PutObject + aws_client.s3.put_object(Bucket=s3_bucket, Key=src_key, Body="something") + # event1 = CopyObject + aws_client.s3.copy_object( + Bucket=s3_bucket, + CopySource={"Bucket": s3_bucket, "Key": src_key}, + Key=dest_key, + ) + # event3 = DeleteObject + aws_client.s3.delete_object(Bucket=s3_bucket, Key=src_key) + + # collect events + events = sqs_collect_s3_events(aws_client.sqs, queue_url, 3) + assert len(events) == 3, f"unexpected number of events in {events}" + + # order seems not be guaranteed - sort so we can rely on the order + events.sort(key=lambda x: x["eventName"]) + + snapshot.match("receive_messages", {"messages": events}) + + assert events[1]["eventName"] == "ObjectCreated:Put" + assert events[1]["s3"]["bucket"]["name"] == s3_bucket + assert events[1]["s3"]["object"]["key"] == src_key + + assert events[0]["eventName"] == "ObjectCreated:Copy" + assert events[0]["s3"]["bucket"]["name"] == s3_bucket + assert events[0]["s3"]["object"]["key"] == dest_key + + assert events[2]["eventName"] == "ObjectRemoved:Delete" + assert events[2]["s3"]["bucket"]["name"] == s3_bucket + assert events[2]["s3"]["object"]["key"] == src_key + + @markers.aws.validated + def test_delete_objects( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.jsonpath("$..s3.object.key", "object-key")) + + # setup fixture + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectRemoved:*"]) + + key = "key-%s" % short_uid() + + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="something") + + # event3 = DeleteObject + aws_client.s3.delete_objects( + Bucket=s3_bucket, + Delete={ + "Objects": [{"Key": key}, {"Key": "dummy1"}, {"Key": "dummy2"}], + "Quiet": True, + }, + ) + + # delete_objects behaves like it deletes non-existing objects as well -> also events are triggered + events = sqs_collect_s3_events(aws_client.sqs, queue_url, 3) + assert len(events) == 3, f"unexpected number of events in {events}" + events.sort(key=lambda x: x["s3"]["object"]["key"]) + + snapshot.match("receive_messages", {"messages": events}) + assert events[2]["eventName"] == "ObjectRemoved:Delete" + assert events[2]["s3"]["bucket"]["name"] == s3_bucket + assert events[2]["s3"]["object"]["key"] == key + + @markers.aws.validated + def test_object_created_complete_multipart_upload( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + tmpdir, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + # setup fixture + queue_url = sqs_create_queue() + key = "test-key" + + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectCreated:*"]) + + # https://boto3.amazonaws.com/v1/documentation/api/latest/guide/s3.html#multipart-transfers + config = TransferConfig(multipart_threshold=5 * KB, multipart_chunksize=1 * KB) + + file = tmpdir / "test-file.bin" + data = b"1" * (6 * KB) # create 6 kilobytes of ones + file.write(data=data, mode="w") + aws_client.s3.upload_file( + Bucket=s3_bucket, Key=key, Filename=str(file.realpath()), Config=config + ) + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, 1) + snapshot.match("receive_messages", {"messages": events}) + + assert events[0]["eventName"] == "ObjectCreated:CompleteMultipartUpload" + assert events[0]["s3"]["bucket"]["name"] == s3_bucket + assert events[0]["s3"]["object"]["key"] == key + assert events[0]["s3"]["object"]["size"] == file.size() + + @markers.aws.validated + def test_key_encoding( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + # test for https://github.com/localstack/localstack/issues/2741 + + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectCreated:*"]) + + key = "a@b" + key_encoded = "a%40b" + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="something") + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, min_events=1) + snapshot.match("receive_messages", {"messages": events}) + + assert events[0]["eventName"] == "ObjectCreated:Put" + assert events[0]["s3"]["object"]["key"] == key_encoded + + @markers.aws.validated + def test_object_created_put_with_presigned_url_upload( + self, + s3_bucket, + s3_create_bucket_with_client, + sqs_create_queue, + sqs_create_queue_with_client, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + aws_client_factory, + secondary_region_name, + ): + """This test validates that pre-signed URL works with notification, and that the awsRegion field is the + bucket's region""" + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.key_value("awsRegion"), priority=-1) + + queue_url = sqs_create_queue() + key = "key-by-hostname" + s3_client = aws_client_factory( + config=Config(signature_version="s3v4"), + ).s3 + + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectCreated:*"]) + url = s3_client.generate_presigned_url( + "put_object", Params={"Bucket": s3_bucket, "Key": key} + ) + requests.put(url, data="something", verify=False) + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, 1) + snapshot.match("receive_messages", {"messages": events}) + + assert events[0]["eventName"] == "ObjectCreated:Put" + assert events[0]["s3"]["object"]["key"] == key + + # test with the bucket in a different region than the client + s3_client_region_2 = aws_client_factory(region_name=secondary_region_name).s3 + bucket_name_region_2 = f"test-bucket-{short_uid()}" + s3_create_bucket_with_client( + s3_client=s3_client_region_2, + Bucket=bucket_name_region_2, + CreateBucketConfiguration={"LocationConstraint": secondary_region_name}, + ) + + # the SQS queue needs to be in the same region as the S3 bucket + sqs_client_region_2 = aws_client_factory(region_name=secondary_region_name).sqs + queue_url_region_2 = sqs_create_queue_with_client(sqs_client_region_2) + s3_create_sqs_bucket_notification( + bucket_name=bucket_name_region_2, + queue_url=queue_url_region_2, + events=["s3:ObjectCreated:*"], + sqs_client=sqs_client_region_2, + s3_client=s3_client_region_2, + ) + # still generate the presign URL with the default client, with the default region + url_bucket_region_2 = s3_client_region_2.generate_presigned_url( + "put_object", Params={"Bucket": bucket_name_region_2, "Key": key} + ) + requests.put(url_bucket_region_2, data="something", verify=False) + + events = sqs_collect_s3_events(sqs_client_region_2, queue_url_region_2, 1) + snapshot.match("receive_messages_region_2", {"messages": events}) + + @markers.aws.validated + def test_object_tagging_put_event( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.jsonpath("$..s3.object.key", "object-key")) + + # setup fixture + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectTagging:Put"]) + + dest_key = f"key-dest-{short_uid()}" + + aws_client.s3.put_object(Bucket=s3_bucket, Key=dest_key, Body="FooBarBlitz") + + assert not sqs_collect_s3_events(aws_client.sqs, queue_url, 0, timeout=1), ( + "unexpected event triggered for put_object" + ) + + aws_client.s3.put_object_tagging( + Bucket=s3_bucket, + Key=dest_key, + Tagging={ + "TagSet": [ + {"Key": "swallow_type", "Value": "african"}, + ] + }, + ) + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, 1) + assert len(events) == 1, f"unexpected number of events in {events}" + snapshot.match("receive_messages", {"messages": events}) + + assert events[0]["eventSource"] == "aws:s3" + assert events[0]["eventName"] == "ObjectTagging:Put" + assert events[0]["s3"]["bucket"]["name"] == s3_bucket + assert events[0]["s3"]["object"]["key"] == dest_key + + @markers.aws.validated + def test_object_tagging_delete_event( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer(snapshot.transform.jsonpath("$..s3.object.key", "object-key")) + + # setup fixture + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectTagging:Delete"]) + + dest_key = "key-dest-%s" % short_uid() + + aws_client.s3.put_object(Bucket=s3_bucket, Key=dest_key, Body="FooBarBlitz") + + assert not sqs_collect_s3_events(aws_client.sqs, queue_url, 0, timeout=1), ( + "unexpected event triggered for put_object" + ) + + aws_client.s3.put_object_tagging( + Bucket=s3_bucket, + Key=dest_key, + Tagging={ + "TagSet": [ + {"Key": "swallow_type", "Value": "african"}, + ] + }, + ) + + aws_client.s3.delete_object_tagging( + Bucket=s3_bucket, + Key=dest_key, + ) + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, 1) + assert len(events) == 1, f"unexpected number of events in {events}" + snapshot.match("receive_messages", {"messages": events}) + + assert events[0]["eventSource"] == "aws:s3" + assert events[0]["eventName"] == "ObjectTagging:Delete" + assert events[0]["s3"]["bucket"]["name"] == s3_bucket + assert events[0]["s3"]["object"]["key"] == dest_key + + @markers.aws.validated + def test_xray_header( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + cleanups, + snapshot, + aws_client, + ): + # test for https://github.com/localstack/localstack/issues/3686 + + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + snapshot.add_transformer( + snapshot.transform.key_value("MD5OfBody", reference_replacement=False) + ) + + # add boto hook + def add_xray_header(request, **kwargs): + request.headers["X-Amzn-Trace-Id"] = ( + "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1" + ) + + aws_client.s3.meta.events.register("before-send.s3.*", add_xray_header) + # make sure the hook gets cleaned up after the test + cleanups.append( + lambda: aws_client.s3.meta.events.unregister("before-send.s3.*", add_xray_header) + ) + + key = "test-data" + queue_url = sqs_create_queue() + + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectCreated:*"]) + + # put an object where the bucket_name is in the path + aws_client.s3.put_object(Bucket=s3_bucket, Key=key, Body="something") + + def get_messages(): + recv_messages = [] + resp = aws_client.sqs.receive_message( + QueueUrl=queue_url, + AttributeNames=["AWSTraceHeader"], + MessageAttributeNames=["All"], + VisibilityTimeout=0, + ) + for m in resp["Messages"]: + if "s3:TestEvent" in m["Body"]: + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"] + ) + continue + recv_messages.append(m) + + assert len(recv_messages) >= 1 + return recv_messages + + messages = retry(get_messages, retries=10) + + assert "AWSTraceHeader" in messages[0]["Attributes"] + assert ( + messages[0]["Attributes"]["AWSTraceHeader"] + == "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1" + ) + snapshot.match("receive_messages", {"messages": messages}) + + @markers.aws.validated + def test_notifications_with_filter( + self, + s3_bucket, + s3_create_sqs_bucket_notification, + sqs_create_queue, + snapshot, + aws_client, + ): + # create test bucket and queue + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + snapshot.add_transformer(snapshot.transform.regex(queue_name, "")) + snapshot.add_transformer(snapshot.transform.regex(s3_bucket, "")) + snapshot.add_transformer(snapshot.transform.s3_notifications_transformer()) + queue_arn = set_policy_for_queue(aws_client.sqs, queue_url, s3_bucket) + + events = ["s3:ObjectCreated:*", "s3:ObjectRemoved:Delete"] + filter_rules = { + "FilterRules": [ + {"Name": "Prefix", "Value": "testupload/"}, + {"Name": "Suffix", "Value": "testfile.txt"}, + ] + } + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration={ + "QueueConfigurations": [ + { + "Id": "id0001", + "QueueArn": queue_arn, + "Events": events, + "Filter": {"Key": filter_rules}, + }, + { + # Add second config to test fix https://github.com/localstack/localstack/issues/450 + "Id": "id0002", + "QueueArn": queue_arn, + "Events": ["s3:ObjectTagging:*"], + "Filter": {"Key": filter_rules}, + }, + ] + }, + ) + + # retrieve and check notification config + config = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + snapshot.match("config", config) + assert 2 == len(config["QueueConfigurations"]) + config = [c for c in config["QueueConfigurations"] if c.get("Events")][0] + assert events == config["Events"] + assert filter_rules == config["Filter"]["Key"] + + # upload file to S3 (this should NOT trigger a notification) + test_key1 = "/testdata" + test_data1 = b'{"test": "bucket_notification1"}' + aws_client.s3.upload_fileobj(BytesIO(test_data1), s3_bucket, test_key1) + + # upload file to S3 (this should trigger a notification) + test_key2 = "testupload/dir1/testfile.txt" + test_data2 = b'{"test": "bucket_notification2"}' + aws_client.s3.upload_fileobj(BytesIO(test_data2), s3_bucket, test_key2) + + # receive, assert, and delete message from SQS + messages = sqs_collect_s3_events(aws_client.sqs, queue_url, 1) + assert len(messages) == 1 + snapshot.match("message", messages[0]) + assert messages[0]["s3"]["object"]["key"] == test_key2 + assert messages[0]["s3"]["bucket"]["name"] == s3_bucket + + # delete notification config + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration={} + ) + config = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + snapshot.match("config_empty", config) + assert not config.get("QueueConfigurations") + assert not config.get("TopicConfiguration") + # put notification config with single event type + event = "s3:ObjectCreated:*" + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration={ + "QueueConfigurations": [ + {"Id": "id123456", "QueueArn": queue_arn, "Events": [event]} + ] + }, + ) + config = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + snapshot.match("config_updated", config) + config = config["QueueConfigurations"][0] + assert [event] == config["Events"] + + # put notification config with single event type + event = "s3:ObjectCreated:*" + filter_rules = {"FilterRules": [{"Name": "Prefix", "Value": "testupload/"}]} + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration={ + "QueueConfigurations": [ + { + "Id": "id123456", + "QueueArn": queue_arn, + "Events": [event], + "Filter": {"Key": filter_rules}, + } + ] + }, + ) + config = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + snapshot.match("config_updated_filter", config) + config = config["QueueConfigurations"][0] + assert [event] == config["Events"] + assert filter_rules == config["Filter"]["Key"] + + @markers.aws.validated + def test_filter_rules_case_insensitive(self, s3_bucket, sqs_create_queue, snapshot, aws_client): + id = short_uid() + queue_url = sqs_create_queue(QueueName=f"my-queue-{id}") + queue_attributes = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + ) + snapshot.add_transformer(snapshot.transform.key_value("Id", "id")) + snapshot.add_transformer(snapshot.transform.regex(id, "")) + cfg = { + "QueueConfigurations": [ + { + "QueueArn": queue_attributes["Attributes"]["QueueArn"], + "Events": ["s3:ObjectCreated:*"], + "Filter": { + "Key": { + "FilterRules": [ + { + "Name": "suffix", + "Value": ".txt", + }, # different casing should be normalized to Suffix/Prefix + {"Name": "PREFIX", "Value": "notif-"}, + ] + } + }, + } + ] + } + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration=cfg, SkipDestinationValidation=True + ) + response = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + # verify casing of filter rule names + + rules = response["QueueConfigurations"][0]["Filter"]["Key"]["FilterRules"] + valid = ["Prefix", "Suffix"] + response["QueueConfigurations"][0]["Filter"]["Key"]["FilterRules"].sort( + key=lambda x: x["Name"] + ) + assert rules[0]["Name"] in valid + assert rules[1]["Name"] in valid + snapshot.match("bucket_notification_configuration", response) + + @markers.snapshot.skip_snapshot_verify( + paths=["$..Error.ArgumentName", "$..Error.ArgumentValue"], + ) # TODO: add to exception for ASF + @markers.aws.validated + def test_bucket_notification_with_invalid_filter_rules( + self, s3_bucket, sqs_create_queue, snapshot, aws_client + ): + queue_url = sqs_create_queue() + queue_attributes = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + ) + cfg = { + "QueueConfigurations": [ + { + "QueueArn": queue_attributes["Attributes"]["QueueArn"], + "Events": ["s3:ObjectCreated:*"], + "Filter": { + "Key": {"FilterRules": [{"Name": "INVALID", "Value": "does not matter"}]} + }, + } + ] + } + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration=cfg + ) + snapshot.match("invalid_filter_name", e.value.response) + + @markers.aws.validated + # AWS seems to return "ArgumentName" (without the number) if the request fails a basic verification + # - basically everything it can check isolated of the structure of the request + # and then the "ArgumentNameX" (with the number) for each verification against the target services + # e.g. queues not existing, no permissions etc. + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Error.ArgumentName1", + "$..Error.ArgumentValue1", + "$..Error.ArgumentName", + "$..Error.ArgumentValue", + ], + ) + def test_invalid_sqs_arn(self, s3_bucket, account_id, snapshot, aws_client, region_name): + config = { + "QueueConfigurations": [ + { + "Id": "id123", + "Events": ["s3:ObjectCreated:*"], + } + ] + } + + config["QueueConfigurations"][0]["QueueArn"] = "invalid-queue" + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + SkipDestinationValidation=False, + ) + snapshot.match("invalid_not_skip", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + SkipDestinationValidation=True, + ) + snapshot.match("invalid_skip", e.value.response) + + # set valid but not-existing queue + config["QueueConfigurations"][0]["QueueArn"] = arns.sqs_queue_arn( + "my-queue", account_id=account_id, region_name=region_name + ) + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + ) + snapshot.match("queue-does-not-exist", e.value.response) + + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, NotificationConfiguration=config, SkipDestinationValidation=True + ) + config = aws_client.s3.get_bucket_notification_configuration(Bucket=s3_bucket) + snapshot.match("skip_destination_validation", config) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Error.ArgumentName", + "$..Error.ArgumentValue", + "$..Error.ArgumentName1", + "$..Error.ArgumentValue1", + "$..Error.ArgumentName2", + "$..Error.ArgumentValue2", + # AWS seems to validate all "form" verifications beforehand, so one error message is wrong + "$..Error.Message", + ], + ) + def test_multiple_invalid_sqs_arns( + self, s3_bucket, account_id, snapshot, aws_client, region_name + ): + config = { + "QueueConfigurations": [ + {"Id": "id1", "Events": ["s3:ObjectCreated:*"], "QueueArn": "invalid_arn"}, + { + "Id": "id2", + "Events": ["s3:ObjectRemoved:*"], + "QueueArn": "invalid_arn_2", + }, + ] + } + # multiple invalid arns + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + ) + snapshot.match("two-queue-arns-invalid", e.value.response) + + # one invalid arn, one not existing + config["QueueConfigurations"][0]["QueueArn"] = arns.sqs_queue_arn( + "my-queue", account_id=account_id, region_name=region_name + ) + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + ) + snapshot.match("one-queue-invalid-one-not-existent", e.value.response) + + # multiple not existing queues + config["QueueConfigurations"][1]["QueueArn"] = arns.sqs_queue_arn( + "my-queue-2", account_id=account_id, region_name=aws_client.s3.meta.region_name + ) + with pytest.raises(ClientError) as e: + aws_client.s3.put_bucket_notification_configuration( + Bucket=s3_bucket, + NotificationConfiguration=config, + ) + snapshot.match("multiple-queues-do-not-exist", e.value.response) + + @markers.aws.validated + def test_object_put_acl( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + # setup fixture + aws_client.s3.delete_bucket_ownership_controls(Bucket=s3_bucket) + aws_client.s3.delete_public_access_block(Bucket=s3_bucket) + queue_url = sqs_create_queue() + key_name = "my_key_acl" + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectAcl:Put"]) + + aws_client.s3.put_object(Bucket=s3_bucket, Key=key_name, Body="something") + list_bucket_output = aws_client.s3.list_buckets() + owner = list_bucket_output["Owner"] + + # change the ACL to the default one, it should not send an Event. Use canned ACL first + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=key_name, ACL="private") + # change the ACL, it should not send an Event. Use canned ACL first + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=key_name, ACL="public-read") + # try changing ACL with Grant + aws_client.s3.put_object_acl( + Bucket=s3_bucket, + Key=key_name, + GrantRead='uri="http://acs.amazonaws.com/groups/s3/LogDelivery"', + ) + # try changing ACL with ACP + acp = { + "Owner": owner, + "Grants": [ + { + "Grantee": {"ID": owner["ID"], "Type": "CanonicalUser"}, + "Permission": "FULL_CONTROL", + }, + { + "Grantee": { + "URI": "http://acs.amazonaws.com/groups/s3/LogDelivery", + "Type": "Group", + }, + "Permission": "WRITE", + }, + ], + } + aws_client.s3.put_object_acl(Bucket=s3_bucket, Key=key_name, AccessControlPolicy=acp) + + # collect s3 events from SQS queue + events = sqs_collect_s3_events(aws_client.sqs, queue_url, min_events=3) + + assert len(events) == 3, f"unexpected number of events in {events}" + # order seems not be guaranteed - sort so we can rely on the order + events.sort(key=lambda x: x["eventTime"]) + snapshot.match("receive_messages", {"messages": events}) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..messages[1].requestParameters.sourceIPAddress", # AWS IP address is different as its internal + ], + ) + def test_restore_object( + self, + s3_bucket, + sqs_create_queue, + s3_create_sqs_bucket_notification, + snapshot, + aws_client, + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + + # setup fixture + queue_url = sqs_create_queue() + key_name = "my_key_restore" + s3_create_sqs_bucket_notification(s3_bucket, queue_url, ["s3:ObjectRestore:*"]) + + # We set the StorageClass to Glacier Flexible Retrieval (formerly Glacier) as it's the only one allowing + # Expedited retrieval Tier (with the Intelligent Access Archive tier) + aws_client.s3.put_object( + Bucket=s3_bucket, Key=key_name, Body="something", StorageClass="GLACIER" + ) + + aws_client.s3.restore_object( + Bucket=s3_bucket, + Key=key_name, + RestoreRequest={ + "Days": 1, + "GlacierJobParameters": { + "Tier": "Expedited", # Set it as Expedited, it should be done within 1-5min + }, + }, + ) + + def _is_object_restored(): + resp = aws_client.s3.head_object(Bucket=s3_bucket, Key=key_name) + assert 'ongoing-request="false"' in resp["Restore"] + + if is_aws_cloud(): + retries = 12 + sleep = 30 + else: + retries = 3 + sleep = 1 + + retry(_is_object_restored, retries=retries, sleep=sleep) + + # collect s3 events from SQS queue + events = sqs_collect_s3_events(aws_client.sqs, queue_url, min_events=2) + + assert len(events) == 2, f"unexpected number of events in {events}" + # order seems not be guaranteed - sort, so we can rely on the order + events.sort(key=lambda x: x["eventTime"]) + snapshot.match("receive_messages", {"messages": events}) + + @markers.aws.validated + def test_object_created_put_versioned( + self, s3_bucket, sqs_create_queue, s3_create_sqs_bucket_notification, snapshot, aws_client + ): + snapshot.add_transformer(snapshot.transform.sqs_api()) + snapshot.add_transformer(snapshot.transform.s3_api()) + # setup fixture + queue_url = sqs_create_queue() + s3_create_sqs_bucket_notification( + s3_bucket, queue_url, ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"] + ) + + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Enabled"} + ) + + test_key = "test-key" + # We snapshot all objects to snapshot their VersionId, to be sure the events correspond well to what we think + # create first version + obj_ver1 = aws_client.s3.put_object(Bucket=s3_bucket, Key=test_key, Body=b"data") + snapshot.match("obj-ver1", obj_ver1) + # create second version + obj_ver2 = aws_client.s3.put_object(Bucket=s3_bucket, Key=test_key, Body=b"data-version2") + snapshot.match("obj-ver2", obj_ver2) + # delete the top most version, creating a DeleteMarker + delete_marker = aws_client.s3.delete_object(Bucket=s3_bucket, Key=test_key) + snapshot.match("delete-marker", delete_marker) + # delete a specific version (Version1) + delete_version = aws_client.s3.delete_object( + Bucket=s3_bucket, Key=test_key, VersionId=obj_ver1["VersionId"] + ) + snapshot.match("delete-version", delete_version) + + def _sort_events(_events: list[dict]) -> list[dict]: + return sorted( + _events, + key=lambda x: ( + x["eventName"], + x["s3"]["object"].get("eTag", ""), + ), + ) + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, min_events=4) + snapshot.match("message-versioning-active", _sort_events(events)) + + # suspend the versioning + aws_client.s3.put_bucket_versioning( + Bucket=s3_bucket, VersioningConfiguration={"Status": "Suspended"} + ) + # add a new object, which will have versionId = null + add_null_version = aws_client.s3.put_object( + Bucket=s3_bucket, Key=test_key, Body=b"data-version3" + ) + snapshot.match("add-null-version", add_null_version) + # delete the DeleteMarker + del_delete_marker = aws_client.s3.delete_object( + Bucket=s3_bucket, Key=test_key, VersionId=delete_marker["VersionId"] + ) + snapshot.match("del-delete-marker", del_delete_marker) + + events = sqs_collect_s3_events(aws_client.sqs, queue_url, min_events=2) + snapshot.match("message-versioning-suspended", _sort_events(events)) diff --git a/tests/aws/services/s3/test_s3_notifications_sqs.snapshot.json b/tests/aws/services/s3/test_s3_notifications_sqs.snapshot.json new file mode 100644 index 0000000000000..d7416b0ff3a5e --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_sqs.snapshot.json @@ -0,0 +1,1382 @@ +{ + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put": { + "recorded-date": "21-01-2025, 23:21:10", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectCreated:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "my_key_0", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectCreated:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "6c7ba9c5a141421e1c03cb9807c97c74", + "key": "my_key_1", + "sequencer": "sequencer", + "size": 14 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_copy": { + "recorded-date": "21-01-2025, 23:21:14", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectCreated:Copy", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_and_object_removed": { + "recorded-date": "21-01-2025, 23:21:18", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectCreated:Copy", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectCreated:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectRemoved:Delete", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "key": "", + "sequencer": "sequencer" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_complete_multipart_upload": { + "recorded-date": "21-01-2025, 23:21:25", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectCreated:CompleteMultipartUpload", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "8eabe9d6b43316e840b079170916c079-1", + "key": "test-key", + "sequencer": "sequencer", + "size": 6144 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_key_encoding": { + "recorded-date": "21-01-2025, 23:21:29", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectCreated:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "a%40b", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put_with_presigned_url_upload": { + "recorded-date": "21-01-2025, 23:21:38", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectCreated:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "key-by-hostname", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + }, + "receive_messages_region_2": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectCreated:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "key-by-hostname", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_tagging_put_event": { + "recorded-date": "21-01-2025, 23:21:45", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectTagging:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.3", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "f3e6da48d914f3f04f6bd9cb092c044d", + "key": "" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_tagging_delete_event": { + "recorded-date": "21-01-2025, 23:21:49", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectTagging:Delete", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.3", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "f3e6da48d914f3f04f6bd9cb092c044d", + "key": "" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_xray_header": { + "recorded-date": "21-01-2025, 23:21:54", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "Attributes": { + "AWSTraceHeader": "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1" + }, + "Body": { + "Records": [ + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "test-data", + "size": 9, + "eTag": "437b930db84b8079c2dd804a71936b5f", + "sequencer": "sequencer" + } + } + } + ] + }, + "MD5OfBody": "m-d5-of-body", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_notifications_with_filter": { + "recorded-date": "21-01-2025, 23:21:59", + "recorded-content": { + "config": { + "QueueConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*", + "s3:ObjectRemoved:Delete" + ], + "Filter": { + "Key": { + "FilterRules": [ + { + "Name": "Prefix", + "Value": "testupload/" + }, + { + "Name": "Suffix", + "Value": "testfile.txt" + } + ] + } + }, + "Id": "", + "QueueArn": "arn::sqs::111111111111:" + }, + { + "Events": [ + "s3:ObjectTagging:*" + ], + "Filter": { + "Key": { + "FilterRules": [ + { + "Name": "Prefix", + "Value": "testupload/" + }, + { + "Name": "Suffix", + "Value": "testfile.txt" + } + ] + } + }, + "Id": "id0002", + "QueueArn": "arn::sqs::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "message": { + "awsRegion": "", + "eventName": "ObjectCreated:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "f527a5c9f427867aeaf091c134949aa0", + "key": "testupload/dir1/testfile.txt", + "sequencer": "sequencer", + "size": 32 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + "config_empty": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "config_updated": { + "QueueConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Id": "id123456", + "QueueArn": "arn::sqs::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "config_updated_filter": { + "QueueConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Filter": { + "Key": { + "FilterRules": [ + { + "Name": "Prefix", + "Value": "testupload/" + } + ] + } + }, + "Id": "id123456", + "QueueArn": "arn::sqs::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_filter_rules_case_insensitive": { + "recorded-date": "21-01-2025, 23:22:01", + "recorded-content": { + "bucket_notification_configuration": { + "QueueConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Filter": { + "Key": { + "FilterRules": [ + { + "Name": "Prefix", + "Value": "notif-" + }, + { + "Name": "Suffix", + "Value": ".txt" + } + ] + } + }, + "Id": "", + "QueueArn": "arn::sqs::111111111111:my-queue-" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_bucket_notification_with_invalid_filter_rules": { + "recorded-date": "21-01-2025, 23:22:02", + "recorded-content": { + "invalid_filter_name": { + "Error": { + "ArgumentName": "FilterRule.Name", + "ArgumentValue": "INVALID", + "Code": "InvalidArgument", + "Message": "filter rule name must be either prefix or suffix" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_invalid_sqs_arn": { + "recorded-date": "21-01-2025, 23:22:05", + "recorded-content": { + "invalid_not_skip": { + "Error": { + "ArgumentName": "Queue", + "ArgumentValue": "invalid-queue", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_skip": { + "Error": { + "ArgumentName": "Queue", + "ArgumentValue": "invalid-queue", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "queue-does-not-exist": { + "Error": { + "ArgumentName1": "arn::sqs::111111111111:my-queue", + "ArgumentValue1": "The destination queue does not exist", + "Code": "InvalidArgument", + "Message": "Unable to validate the following destination configurations" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "skip_destination_validation": { + "QueueConfigurations": [ + { + "Events": [ + "s3:ObjectCreated:*" + ], + "Id": "id123", + "QueueArn": "arn::sqs::111111111111:my-queue" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_delete_objects": { + "recorded-date": "21-01-2025, 23:21:22", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectRemoved:Delete", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "key": "", + "sequencer": "sequencer" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectRemoved:Delete", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "key": "", + "sequencer": "sequencer" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectRemoved:Delete", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "key": "", + "sequencer": "sequencer" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_put_acl": { + "recorded-date": "21-01-2025, 23:22:12", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectAcl:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.3", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "my_key_acl" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectAcl:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.3", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "my_key_acl" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectAcl:Put", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.3", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "my_key_acl" + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_multiple_invalid_sqs_arns": { + "recorded-date": "21-01-2025, 23:22:07", + "recorded-content": { + "two-queue-arns-invalid": { + "Error": { + "ArgumentName": "Queue", + "ArgumentValue": "invalid_arn", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "one-queue-invalid-one-not-existent": { + "Error": { + "ArgumentName": "Queue", + "ArgumentValue": "invalid_arn_2", + "Code": "InvalidArgument", + "Message": "The ARN could not be parsed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "multiple-queues-do-not-exist": { + "Error": { + "ArgumentName1": "arn::sqs::111111111111:my-queue", + "ArgumentName2": "arn::sqs::111111111111:my-queue-2", + "ArgumentValue1": "The destination queue does not exist", + "ArgumentValue2": "The destination queue does not exist", + "Code": "InvalidArgument", + "Message": "Unable to validate the following destination configurations" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_restore_object": { + "recorded-date": "21-01-2025, 23:23:47", + "recorded-content": { + "receive_messages": { + "messages": [ + { + "awsRegion": "", + "eventName": "ObjectRestore:Post", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "my_key_restore", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "" + } + }, + { + "awsRegion": "", + "eventName": "ObjectRestore:Completed", + "eventSource": "aws:s3", + "eventTime": "date", + "eventVersion": "2.1", + "glacierEventData": { + "restoreEventData": { + "lifecycleRestorationExpiryTime": "date", + "lifecycleRestoreStorageClass": "GLACIER" + } + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-id-2": "amz-id", + "x-amz-request-id": "amz-request-id" + }, + "s3": { + "bucket": { + "arn": "arn::s3:::", + "name": "", + "ownerIdentity": { + "principalId": "" + } + }, + "configurationId": "", + "object": { + "eTag": "437b930db84b8079c2dd804a71936b5f", + "key": "my_key_restore", + "sequencer": "sequencer", + "size": 9 + }, + "s3SchemaVersion": "1.0" + }, + "userIdentity": { + "principalId": "AmazonCustomer:" + } + } + ] + } + } + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put_versioned": { + "recorded-date": "21-01-2025, 23:23:54", + "recorded-content": { + "obj-ver1": { + "ChecksumCRC32": "rfPzYw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"8d777f385d3dfec8815d20f7496026dc\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "obj-ver2": { + "ChecksumCRC32": "DIwTWw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"6869c34ca384e0ed836d49214f881e87\"", + "ServerSideEncryption": "AES256", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "delete-version": { + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "message-versioning-active": [ + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "test-key", + "size": 13, + "eTag": "6869c34ca384e0ed836d49214f881e87", + "versionId": "version-id", + "sequencer": "sequencer" + } + } + }, + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "test-key", + "size": 4, + "eTag": "8d777f385d3dfec8815d20f7496026dc", + "versionId": "version-id", + "sequencer": "sequencer" + } + } + }, + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectRemoved:Delete", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "test-key", + "versionId": "version-id", + "sequencer": "sequencer" + } + } + }, + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectRemoved:DeleteMarkerCreated", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "test-key", + "eTag": "d41d8cd98f00b204e9800998ecf8427e", + "versionId": "version-id", + "sequencer": "sequencer" + } + } + } + ], + "add-null-version": { + "ChecksumCRC32": "e4sjzQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2fb1d4988881168bbcd6432d7593be5a\"", + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "del-delete-marker": { + "DeleteMarker": true, + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "message-versioning-suspended": [ + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "test-key", + "size": 13, + "eTag": "2fb1d4988881168bbcd6432d7593be5a", + "sequencer": "sequencer" + } + } + }, + { + "eventVersion": "2.1", + "eventSource": "aws:s3", + "awsRegion": "", + "eventTime": "date", + "eventName": "ObjectRemoved:Delete", + "userIdentity": { + "principalId": "" + }, + "requestParameters": { + "sourceIPAddress": "" + }, + "responseElements": { + "x-amz-request-id": "amz-request-id", + "x-amz-id-2": "amz-id" + }, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "", + "bucket": { + "name": "", + "ownerIdentity": { + "principalId": "" + }, + "arn": "arn::s3:::" + }, + "object": { + "key": "test-key", + "versionId": "version-id", + "sequencer": "sequencer" + } + } + } + ] + } + } +} diff --git a/tests/aws/services/s3/test_s3_notifications_sqs.validation.json b/tests/aws/services/s3/test_s3_notifications_sqs.validation.json new file mode 100644 index 0000000000000..a7eca3f14f917 --- /dev/null +++ b/tests/aws/services/s3/test_s3_notifications_sqs.validation.json @@ -0,0 +1,56 @@ +{ + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_bucket_notification_with_invalid_filter_rules": { + "last_validated_date": "2025-01-21T23:22:02+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_delete_objects": { + "last_validated_date": "2025-01-21T23:21:22+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_filter_rules_case_insensitive": { + "last_validated_date": "2025-01-21T23:22:01+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_invalid_sqs_arn": { + "last_validated_date": "2025-01-21T23:22:05+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_key_encoding": { + "last_validated_date": "2025-01-21T23:21:29+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_multiple_invalid_sqs_arns": { + "last_validated_date": "2025-01-21T23:22:07+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_notifications_with_filter": { + "last_validated_date": "2025-01-21T23:21:59+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_and_object_removed": { + "last_validated_date": "2025-01-21T23:21:18+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_complete_multipart_upload": { + "last_validated_date": "2025-01-21T23:21:25+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_copy": { + "last_validated_date": "2025-01-21T23:21:14+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put": { + "last_validated_date": "2025-01-21T23:21:10+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put_versioned": { + "last_validated_date": "2025-01-21T23:23:54+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_created_put_with_presigned_url_upload": { + "last_validated_date": "2025-01-21T23:21:38+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_put_acl": { + "last_validated_date": "2025-01-21T23:22:12+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_tagging_delete_event": { + "last_validated_date": "2025-01-21T23:21:49+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_object_tagging_put_event": { + "last_validated_date": "2025-01-21T23:21:45+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_restore_object": { + "last_validated_date": "2025-01-21T23:23:47+00:00" + }, + "tests/aws/services/s3/test_s3_notifications_sqs.py::TestS3NotificationsToSQS::test_xray_header": { + "last_validated_date": "2025-01-21T23:21:54+00:00" + } +} diff --git a/tests/aws/services/s3control/__init__.py b/tests/aws/services/s3control/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/s3control/test_s3control.py b/tests/aws/services/s3control/test_s3control.py new file mode 100644 index 0000000000000..c7daf08fad70d --- /dev/null +++ b/tests/aws/services/s3control/test_s3control.py @@ -0,0 +1,423 @@ +import contextlib + +import pytest +from botocore.client import Config +from botocore.exceptions import ClientError + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID, TEST_AWS_SECRET_ACCESS_KEY +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.urls import localstack_host + +# TODO: this fails in CI, not sure why yet +# s3_control_endpoint = f"http://s3-control.{localstack_host()}" +s3_control_endpoint = f"https://{localstack_host().host_and_port()}" + + +@pytest.fixture +def s3control_snapshot(snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("HostId", reference_replacement=False), + snapshot.transform.key_value("Name"), + snapshot.transform.key_value("Bucket"), + snapshot.transform.regex("amazonaws.com", ""), + snapshot.transform.regex(localstack_host().host_and_port(), ""), + snapshot.transform.regex( + '([a-z0-9]{34})(?=.*-s3alias")', replacement="" + ), + ] + ) + + +@pytest.fixture +def s3control_client(aws_client_factory, aws_client): + """ + The endpoint for S3 Control looks like `http(s)://.s3-control./v20180820/configuration/ + We need to manually set it to something else than `localhost` so that it is resolvable, as boto will prefix the host + with the account-id + """ + if not is_aws_cloud(): + return aws_client_factory( + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=TEST_AWS_SECRET_ACCESS_KEY, + endpoint_url=s3_control_endpoint, + ).s3control + else: + return aws_client.s3control + + +@pytest.fixture +def s3control_client_no_validation(aws_client_factory): + if not is_aws_cloud(): + client = aws_client_factory( + config=Config(parameter_validation=False), + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=TEST_AWS_SECRET_ACCESS_KEY, + endpoint_url=s3_control_endpoint, + ).s3control + else: + client = aws_client_factory(config=Config(parameter_validation=False)).s3control + + return client + + +@pytest.fixture +def s3control_create_access_point(s3control_client): + access_points = [] + + def _create_access_point(**kwargs): + resp = s3control_client.create_access_point(**kwargs) + access_points.append((kwargs["Name"], kwargs["AccountId"])) + return resp + + yield _create_access_point + + for access_point_name, req_account_id in access_points: + with contextlib.suppress(ClientError): + s3control_client.delete_access_point(AccountId=req_account_id, Name=access_point_name) + + +class TestLegacyS3Control: + """ + This class is related to the current Moto implementation, which is quite limited and not linked with S3 + anymore. Remove these tests in favor of the currently skipped ones, once we migrate to the new provider. + """ + + @markers.aws.validated + def test_lifecycle_public_access_block(self, s3control_client, account_id): + with pytest.raises(ClientError) as ce: + s3control_client.get_public_access_block(AccountId=account_id) + assert ce.value.response["Error"]["Code"] == "NoSuchPublicAccessBlockConfiguration" + + access_block_config = { + "BlockPublicAcls": True, + "IgnorePublicAcls": True, + "BlockPublicPolicy": True, + "RestrictPublicBuckets": True, + } + + try: + put_response = s3control_client.put_public_access_block( + AccountId=account_id, PublicAccessBlockConfiguration=access_block_config + ) + + assert put_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + get_response = s3control_client.get_public_access_block(AccountId=account_id) + assert access_block_config == get_response["PublicAccessBlockConfiguration"] + + finally: + s3control_client.delete_public_access_block(AccountId=account_id) + + @markers.aws.only_localstack + def test_public_access_block_validations(self, s3control_client, account_id): + # Moto forces IAM use with the account id even when not enabled + with pytest.raises(ClientError) as error: + s3control_client.get_public_access_block(AccountId="111111111111") + assert error.value.response["Error"]["Code"] == "AccessDenied" + + with pytest.raises(ClientError) as error: + s3control_client.put_public_access_block( + AccountId="111111111111", + PublicAccessBlockConfiguration={"BlockPublicAcls": True}, + ) + assert error.value.response["Error"]["Code"] == "AccessDenied" + + with pytest.raises(ClientError) as error: + s3control_client.put_public_access_block( + AccountId=account_id, PublicAccessBlockConfiguration={} + ) + assert error.value.response["Error"]["Code"] == "InvalidRequest" + + +@pytest.mark.skip("Not implemented yet in LocalStack") +class TestS3ControlPublicAccessBlock: + @markers.aws.validated + def test_crud_public_access_block(self, s3control_client, account_id, snapshot): + with pytest.raises(ClientError) as e: + s3control_client.get_public_access_block(AccountId=account_id) + snapshot.match("get-default-public-access-block", e.value.response) + + put_public_access_block = s3control_client.put_public_access_block( + AccountId=account_id, + PublicAccessBlockConfiguration={ + "BlockPublicAcls": False, + "IgnorePublicAcls": False, + "BlockPublicPolicy": False, + }, + ) + snapshot.match("put-public-access-block", put_public_access_block) + + get_public_access_block = s3control_client.get_public_access_block(AccountId=account_id) + snapshot.match("get-public-access-block", get_public_access_block) + + delete_public_access_block = s3control_client.delete_public_access_block( + AccountId=account_id + ) + snapshot.match("delete-public-access-block", delete_public_access_block) + + with pytest.raises(ClientError) as e: + s3control_client.get_public_access_block(AccountId=account_id) + snapshot.match("get-public-access-block-after-delete", e.value.response) + + delete_public_access_block = s3control_client.delete_public_access_block( + AccountId=account_id + ) + snapshot.match("idempotent-delete-public-access-block", delete_public_access_block) + + @markers.aws.validated + def test_empty_public_access_block(self, s3control_client_no_validation, account_id, snapshot): + # we need to disable validation for this test + + with pytest.raises(ClientError) as e: + s3control_client_no_validation.put_public_access_block( + AccountId=account_id, + PublicAccessBlockConfiguration={}, + ) + snapshot.match("put-public-access-block-empty", e.value.response) + # Wanted to try it with a wrong key in the PublicAccessBlockConfiguration but boto is unable to serialize + + +@pytest.mark.skip("Not implemented yet in LocalStack, missing link with S3") +class TestS3ControlAccessPoint: + @markers.aws.validated + def test_access_point_lifecycle( + self, + s3control_client, + s3control_create_access_point, + account_id, + s3_bucket, + snapshot, + s3control_snapshot, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Name"), + snapshot.transform.key_value("Bucket"), + snapshot.transform.regex("amazonaws.com", ""), + snapshot.transform.regex(localstack_host().host_and_port(), ""), + ] + ) + + list_access_points = s3control_client.list_access_points(AccountId=account_id) + snapshot.match("list-access-points-start", list_access_points) + + ap_name = short_uid() + create_access_point = s3control_create_access_point( + AccountId=account_id, Name=ap_name, Bucket=s3_bucket + ) + + alias_random_part = create_access_point["Alias"].split("-")[1] + assert len(alias_random_part) == 34 + + snapshot.match("create-access-point", create_access_point) + + get_access_point = s3control_client.get_access_point(AccountId=account_id, Name=ap_name) + snapshot.match("get-access-point", get_access_point) + + list_access_points = s3control_client.list_access_points(AccountId=account_id) + snapshot.match("list-access-points-after-create", list_access_points) + + delete_access_point = s3control_client.delete_access_point( + AccountId=account_id, Name=ap_name + ) + snapshot.match("delete-access-point", delete_access_point) + + list_access_points = s3control_client.list_access_points(AccountId=account_id) + snapshot.match("list-access-points-after-delete", list_access_points) + + with pytest.raises(ClientError) as e: + s3control_client.get_access_point(AccountId=account_id, Name=ap_name) + snapshot.match("get-delete-access-point", e.value.response) + + with pytest.raises(ClientError) as e: + s3control_client.delete_access_point(AccountId=account_id, Name=ap_name) + snapshot.match("delete-already-deleted-access-point", e.value.response) + + @markers.aws.validated + def test_access_point_bucket_not_exists( + self, s3control_create_access_point, account_id, snapshot, s3control_snapshot + ): + ap_name = short_uid() + with pytest.raises(ClientError) as e: + s3control_create_access_point( + AccountId=account_id, + Name=ap_name, + Bucket=f"fake-bucket-{short_uid()}-{short_uid()}", + ) + snapshot.match("access-point-bucket-not-exists", e.value.response) + + @markers.aws.validated + def test_access_point_name_validation( + self, s3control_client_no_validation, account_id, snapshot, s3_bucket, s3control_snapshot + ): + # not using parametrization because that would be a lot of snapshot. + # only validate the first one + wrong_name = "xn-_test-alias" + wrong_names = [ + "-hyphen-start", + "cannot-end-s3alias", + "cannot-have.dot", + ] + + with pytest.raises(ClientError) as e: + s3control_client_no_validation.create_access_point( + AccountId=account_id, + Name=wrong_name, + Bucket=s3_bucket, + ) + snapshot.match("access-point-wrong-naming", e.value.response) + + for name in wrong_names: + with pytest.raises(ClientError) as e: + s3control_client_no_validation.create_access_point( + AccountId=account_id, + Name=name, + Bucket=s3_bucket, + ) + assert e.match("Your Amazon S3 AccessPoint name is invalid"), (name, e.value.response) + + # error is different for too short of a name + with pytest.raises(ClientError) as e: + s3control_client_no_validation.create_access_point( + AccountId=account_id, + Name="sa", + Bucket=s3_bucket, + ) + snapshot.match("access-point-name-too-short", e.value.response) + + uri_error_names = [ + "a" * 51, + "WRONG-casing", + "cannot-have_underscore", + ] + for name in uri_error_names: + with pytest.raises(ClientError) as e: + s3control_client_no_validation.create_access_point( + AccountId=account_id, + Name="a" * 51, + Bucket=s3_bucket, + ) + assert e.match("InvalidURI"), (name, e.value.response) + + @markers.aws.validated + def test_access_point_already_exists( + self, s3control_create_access_point, s3_bucket, account_id, snapshot, s3control_snapshot + ): + ap_name = short_uid() + s3control_create_access_point(AccountId=account_id, Name=ap_name, Bucket=s3_bucket) + with pytest.raises(ClientError) as e: + s3control_create_access_point(AccountId=account_id, Name=ap_name, Bucket=s3_bucket) + snapshot.match("access-point-already-exists", e.value.response) + + @markers.aws.validated + def test_access_point_public_access_block_configuration( + self, + s3control_client, + s3control_create_access_point, + account_id, + snapshot, + s3_bucket, + s3control_snapshot, + ): + # set a letter in the name for ordering + ap_name_1 = f"a{short_uid()}" + response = s3control_create_access_point( + AccountId=account_id, + Name=ap_name_1, + Bucket=s3_bucket, + PublicAccessBlockConfiguration={}, + ) + snapshot.match("put-ap-empty-pabc", response) + get_ap = s3control_client.get_access_point(AccountId=account_id, Name=ap_name_1) + snapshot.match("get-ap-empty-pabc", get_ap) + + ap_name_2 = f"b{short_uid()}" + response = s3control_create_access_point( + AccountId=account_id, + Name=ap_name_2, + Bucket=s3_bucket, + PublicAccessBlockConfiguration={"BlockPublicAcls": False}, + ) + snapshot.match("put-ap-partial-pabc", response) + get_ap = s3control_client.get_access_point(AccountId=account_id, Name=ap_name_2) + snapshot.match("get-ap-partial-pabc", get_ap) + + ap_name_3 = f"c{short_uid()}" + response = s3control_create_access_point( + AccountId=account_id, + Name=ap_name_3, + Bucket=s3_bucket, + PublicAccessBlockConfiguration={"BlockPublicAcls": True}, + ) + snapshot.match("put-ap-partial-true-pabc", response) + get_ap = s3control_client.get_access_point(AccountId=account_id, Name=ap_name_3) + snapshot.match("get-ap-partial-true-pabc", get_ap) + + ap_name_4 = f"d{short_uid()}" + response = s3control_create_access_point( + AccountId=account_id, + Name=ap_name_4, + Bucket=s3_bucket, + ) + snapshot.match("put-ap-pabc-not-set", response) + get_ap = s3control_client.get_access_point(AccountId=account_id, Name=ap_name_4) + snapshot.match("get-ap-pabc-not-set", get_ap) + + list_access_points = s3control_client.list_access_points(AccountId=account_id) + snapshot.match("list-access-points", list_access_points) + + @markers.aws.validated + def test_access_point_pagination( + self, + s3control_client, + s3control_create_access_point, + account_id, + snapshot, + s3_create_bucket, + s3control_snapshot, + ): + # TODO: can you have 2 AP on one bucket? + snapshot.add_transformers_list( + [ + snapshot.transform.key_value("Name"), + snapshot.transform.key_value("Bucket"), + snapshot.transform.key_value("NextToken"), + snapshot.transform.regex("amazonaws.com", ""), + snapshot.transform.regex(localstack_host().host_and_port(), ""), + ] + ) + + ap_names = [] + bucket_1 = s3_create_bucket() + bucket_2 = s3_create_bucket() + for i in range(3): + ap_name = short_uid() + ap_names.append(ap_name) + create_access_point = s3control_create_access_point( + AccountId=account_id, Name=ap_name, Bucket=bucket_1 if i < 2 else bucket_2 + ) + snapshot.match(f"create-access-point-{i}", create_access_point) + + list_all = s3control_client.list_access_points(AccountId=account_id) + snapshot.match("list-all", list_all) + + list_one_max = s3control_client.list_access_points(AccountId=account_id, MaxResults=1) + snapshot.match("list-one-max", list_one_max) + next_token = list_one_max["NextToken"] + + list_one_next = s3control_client.list_access_points( + AccountId=account_id, NextToken=next_token, MaxResults=1 + ) + snapshot.match("list-one-next", list_one_next) + next_token = list_one_next["NextToken"] + + list_one_last = s3control_client.list_access_points( + AccountId=account_id, NextToken=next_token, MaxResults=1 + ) + snapshot.match("list-one-next-2", list_one_last) + + list_by_bucket = s3control_client.list_access_points(AccountId=account_id, Bucket=bucket_1) + snapshot.match("list-by-bucket", list_by_bucket) diff --git a/tests/aws/services/s3control/test_s3control.snapshot.json b/tests/aws/services/s3control/test_s3control.snapshot.json new file mode 100644 index 0000000000000..3165e12ab6836 --- /dev/null +++ b/tests/aws/services/s3control/test_s3control.snapshot.json @@ -0,0 +1,593 @@ +{ + "tests/aws/services/s3control/test_s3control.py::TestS3ControlPublicAccessBlock::test_crud_public_access_block": { + "recorded-date": "30-05-2024, 17:32:58", + "recorded-content": { + "get-default-public-access-block": { + "Error": { + "AccountId": "111111111111", + "Code": "NoSuchPublicAccessBlockConfiguration", + "Message": "The public access block configuration was not found" + }, + "HostId": "host-id", + "Message": "The public access block configuration was not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "put-public-access-block": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-public-access-block": { + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": false, + "BlockPublicPolicy": false, + "IgnorePublicAcls": false, + "RestrictPublicBuckets": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-public-access-block": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "get-public-access-block-after-delete": { + "Error": { + "AccountId": "111111111111", + "Code": "NoSuchPublicAccessBlockConfiguration", + "Message": "The public access block configuration was not found" + }, + "HostId": "host-id", + "Message": "The public access block configuration was not found", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "idempotent-delete-public-access-block": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + } + } + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlPublicAccessBlock::test_empty_public_access_block": { + "recorded-date": "30-05-2024, 17:32:59", + "recorded-content": { + "put-public-access-block-empty": { + "Error": { + "Code": "InvalidRequest", + "Message": "Must specify at least one configuration." + }, + "HostId": "host-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_lifecycle": { + "recorded-date": "30-05-2024, 17:37:58", + "recorded-content": { + "list-access-points-start": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-access-point": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-access-point": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "CreationDate": "datetime", + "Endpoints": { + "dualstack": "s3-accesspoint.dualstack..", + "fips": "s3-accesspoint-fips..", + "fips_dualstack": "s3-accesspoint-fips.dualstack..", + "ipv4": "s3-accesspoint.." + }, + "Name": "", + "NetworkOrigin": "Internet", + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-access-points-after-create": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-access-point": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 204 + } + }, + "list-access-points-after-delete": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-delete-access-point": { + "Error": { + "AccessPointName": "", + "Code": "NoSuchAccessPoint", + "Message": "The specified accesspoint does not exist" + }, + "HostId": "host-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "delete-already-deleted-access-point": { + "Error": { + "AccessPointName": "", + "Code": "NoSuchAccessPoint", + "Message": "The specified accesspoint does not exist" + }, + "HostId": "host-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_bucket_not_exists": { + "recorded-date": "30-05-2024, 17:37:59", + "recorded-content": { + "access-point-bucket-not-exists": { + "Error": { + "Code": "InvalidRequest", + "Message": "Amazon S3 AccessPoint can only be created for existing bucket" + }, + "HostId": "host-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_name_validation": { + "recorded-date": "30-05-2024, 17:38:04", + "recorded-content": { + "access-point-wrong-naming": { + "Error": { + "Code": "InvalidURI", + "Message": "Couldn't parse the specified URI.", + "URI": "accesspoint/xn-_test-alias" + }, + "HostId": "host-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "access-point-name-too-short": { + "Error": { + "Code": "InvalidURI", + "Message": "Couldn't parse the specified URI.", + "URI": "accesspoint/sa" + }, + "HostId": "host-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_already_exists": { + "recorded-date": "30-05-2024, 17:38:05", + "recorded-content": { + "access-point-already-exists": { + "Error": { + "Code": "AccessPointAlreadyOwnedByYou", + "Message": "Your previous request to create the named accesspoint succeeded and you already own it." + }, + "HostId": "host-id", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 409 + } + } + } + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_public_access_block_configuration": { + "recorded-date": "30-05-2024, 17:38:08", + "recorded-content": { + "put-ap-empty-pabc": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-ap-empty-pabc": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "CreationDate": "datetime", + "Endpoints": { + "dualstack": "s3-accesspoint.dualstack..", + "fips": "s3-accesspoint-fips..", + "fips_dualstack": "s3-accesspoint-fips.dualstack..", + "ipv4": "s3-accesspoint.." + }, + "Name": "", + "NetworkOrigin": "Internet", + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": false, + "BlockPublicPolicy": false, + "IgnorePublicAcls": false, + "RestrictPublicBuckets": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-ap-partial-pabc": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-ap-partial-pabc": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "CreationDate": "datetime", + "Endpoints": { + "dualstack": "s3-accesspoint.dualstack..", + "fips": "s3-accesspoint-fips..", + "fips_dualstack": "s3-accesspoint-fips.dualstack..", + "ipv4": "s3-accesspoint.." + }, + "Name": "", + "NetworkOrigin": "Internet", + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": false, + "BlockPublicPolicy": false, + "IgnorePublicAcls": false, + "RestrictPublicBuckets": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-ap-partial-true-pabc": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-ap-partial-true-pabc": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "CreationDate": "datetime", + "Endpoints": { + "dualstack": "s3-accesspoint.dualstack..", + "fips": "s3-accesspoint-fips..", + "fips_dualstack": "s3-accesspoint-fips.dualstack..", + "ipv4": "s3-accesspoint.." + }, + "Name": "", + "NetworkOrigin": "Internet", + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": false, + "IgnorePublicAcls": false, + "RestrictPublicBuckets": false + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-ap-pabc-not-set": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-ap-pabc-not-set": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "CreationDate": "datetime", + "Endpoints": { + "dualstack": "s3-accesspoint.dualstack..", + "fips": "s3-accesspoint-fips..", + "fips_dualstack": "s3-accesspoint-fips.dualstack..", + "ipv4": "s3-accesspoint.." + }, + "Name": "", + "NetworkOrigin": "Internet", + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-access-points": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_pagination": { + "recorded-date": "30-05-2024, 17:42:07", + "recorded-content": { + "create-access-point-0": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-access-point-1": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-access-point-2": { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-all": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-one-max": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "NextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-one-next": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "NextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-one-next-2": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "NextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-by-bucket": { + "AccessPointList": [ + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + }, + { + "AccessPointArn": "arn::s3::111111111111:accesspoint/", + "Alias": "--s3alias", + "Bucket": "", + "BucketAccountId": "111111111111", + "Name": "", + "NetworkOrigin": "Internet" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/s3control/test_s3control.validation.json b/tests/aws/services/s3control/test_s3control.validation.json new file mode 100644 index 0000000000000..4fcb430b372e8 --- /dev/null +++ b/tests/aws/services/s3control/test_s3control.validation.json @@ -0,0 +1,29 @@ +{ + "tests/aws/services/s3control/test_s3control.py::TestLegacyS3Control::test_lifecycle_public_access_block": { + "last_validated_date": "2024-05-30T17:31:18+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_already_exists": { + "last_validated_date": "2024-05-30T17:38:05+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_bucket_not_exists": { + "last_validated_date": "2024-05-30T17:37:59+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_lifecycle": { + "last_validated_date": "2024-05-30T17:37:58+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_name_validation": { + "last_validated_date": "2024-05-30T17:38:03+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_pagination": { + "last_validated_date": "2024-05-30T17:42:06+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlAccessPoint::test_access_point_public_access_block_configuration": { + "last_validated_date": "2024-05-30T17:38:08+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlPublicAccessBlock::test_crud_public_access_block": { + "last_validated_date": "2024-05-30T17:32:58+00:00" + }, + "tests/aws/services/s3control/test_s3control.py::TestS3ControlPublicAccessBlock::test_empty_public_access_block": { + "last_validated_date": "2024-05-30T17:32:59+00:00" + } +} diff --git a/tests/aws/services/scheduler/__init__.py b/tests/aws/services/scheduler/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/scheduler/conftest.py b/tests/aws/services/scheduler/conftest.py new file mode 100644 index 0000000000000..3591951039e6b --- /dev/null +++ b/tests/aws/services/scheduler/conftest.py @@ -0,0 +1,29 @@ +import logging + +import pytest + +from localstack.utils.strings import short_uid + +LOG = logging.getLogger(__name__) + + +@pytest.fixture +def events_scheduler_create_schedule_group(aws_client): + schedule_group_arns = [] + + def _events_scheduler_create_schedule_group(name, **kwargs): + if not name: + name = f"events-test-schedule-groupe-{short_uid()}" + response = aws_client.scheduler.create_schedule_group(Name=name, **kwargs) + schedule_group_arn = response["ScheduleGroupArn"] + schedule_group_arns.append(schedule_group_arn) + + return schedule_group_arn + + yield _events_scheduler_create_schedule_group + + for schedule_group_arn in schedule_group_arns: + try: + aws_client.scheduler.delete_schedule_group(ScheduleGroupArn=schedule_group_arn) + except Exception: + LOG.info("Failed to delete schedule group %s", schedule_group_arn) diff --git a/tests/aws/services/scheduler/test_scheduler.py b/tests/aws/services/scheduler/test_scheduler.py new file mode 100644 index 0000000000000..2a0b9ec8f1584 --- /dev/null +++ b/tests/aws/services/scheduler/test_scheduler.py @@ -0,0 +1,169 @@ +import json +import time + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.aws.util import in_default_partition, is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import get_partition +from localstack.utils.common import short_uid + + +@pytest.mark.skipif( + not in_default_partition(), reason="Test not applicable in non-default partitions" +) +@markers.aws.validated +def test_list_schedules(aws_client): + # simple smoke test to assert that the provider is available, without creating any schedules + result = aws_client.scheduler.list_schedules() + assert isinstance(result.get("Schedules"), list) + + +@markers.aws.validated +def test_tag_resource(aws_client, events_scheduler_create_schedule_group, snapshot): + name = short_uid() + schedule_group_arn = events_scheduler_create_schedule_group(name) + + response = aws_client.scheduler.tag_resource( + ResourceArn=schedule_group_arn, + Tags=[ + { + "Key": "TagKey", + "Value": "TagValue", + } + ], + ) + + response = aws_client.scheduler.list_tags_for_resource(ResourceArn=schedule_group_arn) + + assert response["Tags"][0]["Key"] == "TagKey" + assert response["Tags"][0]["Value"] == "TagValue" + + snapshot.match("list-tagged-schedule", response) + + +@markers.aws.validated +def test_untag_resource(aws_client, events_scheduler_create_schedule_group, snapshot): + name = short_uid() + tags = [ + { + "Key": "TagKey", + "Value": "TagValue", + } + ] + schedule_group_arn = events_scheduler_create_schedule_group(name, Tags=tags) + + response = aws_client.scheduler.untag_resource( + ResourceArn=schedule_group_arn, TagKeys=["TagKey"] + ) + + response = aws_client.scheduler.list_tags_for_resource(ResourceArn=schedule_group_arn) + + assert response["Tags"] == [] + + snapshot.match("list-untagged-schedule", response) + + +@markers.aws.validated +@pytest.mark.parametrize( + "schedule_expression", + [ + "cron(0 1 * * * *)", + "cron(7 20 * * NOT *)", + "cron(INVALID)", + "cron(0 dummy ? * MON-FRI *)", + "cron(71 8 1 * ? *)", + "cron()", + "rate(10 seconds)", + "rate(10 years)", + "rate()", + "rate(10)", + "rate(10 minutess)", + "rate(foo minutes)", + "rate(-10 minutes)", + "rate( 10 minutes )", + " rate(10 minutes)", + "at(2021-12-31T23:59:59Z)", + "at(2021-12-31)", + ], +) +def tests_create_schedule_with_invalid_schedule_expression( + schedule_expression, aws_client, region_name, account_id, snapshot +): + rule_name = f"rule-{short_uid()}" + + with pytest.raises(ClientError) as e: + aws_client.scheduler.create_schedule( + Name=rule_name, + ScheduleExpression=schedule_expression, + FlexibleTimeWindow={ + "MaximumWindowInMinutes": 4, + "Mode": "FLEXIBLE", + }, + Target={ + "Arn": f"arn:aws:lambda:{region_name}:{account_id}:function:dummy", + "RoleArn": f"arn:aws:iam::{account_id}:role/role-name", + }, + ) + snapshot.match("invalid-schedule-expression", e.value.response) + + +@markers.aws.validated +def tests_create_schedule_with_valid_schedule_expression( + create_role, aws_client, region_name, account_id, cleanups, snapshot +): + role_name = f"test-role-{short_uid()}" + scheduler_name = f"test-scheduler-{short_uid()}" + lambda_function_name = f"test-lambda-function-{short_uid()}" + schedule_expression = "at(2022-12-31T23:59:59)" + + snapshot.add_transformer(snapshot.transform.key_value("ScheduleArn")) + + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "scheduler.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + + role = aws_client.iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(trust_policy), + Description="IAM Role for EventBridge Scheduler to invoke Lambda.", + ) + role_arn = role["Role"]["Arn"] + + lambda_arn = f"arn:aws:lambda:{region_name}:{account_id}:function:{lambda_function_name}" + policy_arn = ( + f"arn:{get_partition(aws_client.iam.meta.region_name)}:iam::aws:policy/AWSLambdaExecute" + ) + + aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + + # Allow some time for IAM role propagation (only needed in AWS) + if is_aws_cloud(): + time.sleep(10) + + response = aws_client.scheduler.create_schedule( + Name=scheduler_name, + ScheduleExpression=schedule_expression, + FlexibleTimeWindow={ + "MaximumWindowInMinutes": 4, + "Mode": "FLEXIBLE", + }, + Target={"Arn": lambda_arn, "RoleArn": role_arn}, + ) + + # cleanup + cleanups.append( + lambda: aws_client.iam.delete_role_policy(RoleName=role_name, PolicyName=policy_arn) + ) + cleanups.append(lambda: aws_client.iam.delete_role(RoleName=role_name)) + cleanups.append(lambda: aws_client.scheduler.delete_schedule(Name=scheduler_name)) + + snapshot.match("valid-schedule-expression", response) diff --git a/tests/aws/services/scheduler/test_scheduler.snapshot.json b/tests/aws/services/scheduler/test_scheduler.snapshot.json new file mode 100644 index 0000000000000..9000ad747a3a0 --- /dev/null +++ b/tests/aws/services/scheduler/test_scheduler.snapshot.json @@ -0,0 +1,315 @@ +{ + "tests/aws/services/scheduler/test_scheduler.py::test_tag_resource": { + "recorded-date": "04-12-2024, 10:07:28", + "recorded-content": { + "list-tagged-schedule": { + "Tags": [ + { + "Key": "TagKey", + "Value": "TagValue" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::test_untag_resource": { + "recorded-date": "04-12-2024, 10:08:11", + "recorded-content": { + "list-untagged-schedule": { + "Tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(0 1 * * * *)]": { + "recorded-date": "26-01-2025, 15:45:53", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression cron(0 1 * * * *)." + }, + "Message": "Invalid Schedule Expression cron(0 1 * * * *).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(7 20 * * NOT *)]": { + "recorded-date": "26-01-2025, 15:45:53", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression cron(7 20 * * NOT *)." + }, + "Message": "Invalid Schedule Expression cron(7 20 * * NOT *).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(INVALID)]": { + "recorded-date": "26-01-2025, 15:45:54", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression cron(INVALID)." + }, + "Message": "Invalid Schedule Expression cron(INVALID).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(0 dummy ? * MON-FRI *)]": { + "recorded-date": "26-01-2025, 15:45:54", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression cron(0 dummy ? * MON-FRI *)." + }, + "Message": "Invalid Schedule Expression cron(0 dummy ? * MON-FRI *).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(71 8 1 * ? *)]": { + "recorded-date": "26-01-2025, 15:45:54", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression cron(71 8 1 * ? *)." + }, + "Message": "Invalid Schedule Expression cron(71 8 1 * ? *).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron()]": { + "recorded-date": "26-01-2025, 15:45:55", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression cron()." + }, + "Message": "Invalid Schedule Expression cron().", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 seconds)]": { + "recorded-date": "26-01-2025, 15:45:55", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate(10 seconds)." + }, + "Message": "Invalid Schedule Expression rate(10 seconds).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 years)]": { + "recorded-date": "26-01-2025, 15:45:56", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate(10 years)." + }, + "Message": "Invalid Schedule Expression rate(10 years).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate()]": { + "recorded-date": "26-01-2025, 15:45:56", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate()." + }, + "Message": "Invalid Schedule Expression rate().", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10)]": { + "recorded-date": "26-01-2025, 15:45:56", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate(10)." + }, + "Message": "Invalid Schedule Expression rate(10).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 minutess)]": { + "recorded-date": "26-01-2025, 15:45:57", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate(10 minutess)." + }, + "Message": "Invalid Schedule Expression rate(10 minutess).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(foo minutes)]": { + "recorded-date": "26-01-2025, 15:45:57", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate(foo minutes)." + }, + "Message": "Invalid Schedule Expression rate(foo minutes).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(-10 minutes)]": { + "recorded-date": "26-01-2025, 15:45:57", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate(-10 minutes)." + }, + "Message": "Invalid Schedule Expression rate(-10 minutes).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate( 10 minutes )]": { + "recorded-date": "26-01-2025, 15:45:58", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate( 10 minutes )." + }, + "Message": "Invalid Schedule Expression rate( 10 minutes ).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[ rate(10 minutes)]": { + "recorded-date": "26-01-2025, 15:45:58", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression rate(10 minutes)." + }, + "Message": "Invalid Schedule Expression rate(10 minutes).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[at(2021-12-31T23:59:59Z)]": { + "recorded-date": "26-01-2025, 15:45:59", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression at(2021-12-31T23:59:59Z)." + }, + "Message": "Invalid Schedule Expression at(2021-12-31T23:59:59Z).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[at(2021-12-31)]": { + "recorded-date": "26-01-2025, 15:45:59", + "recorded-content": { + "invalid-schedule-expression": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid Schedule Expression at(2021-12-31)." + }, + "Message": "Invalid Schedule Expression at(2021-12-31).", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_valid_schedule_expression": { + "recorded-date": "02-02-2025, 00:22:13", + "recorded-content": { + "valid-schedule-expression": { + "ScheduleArn": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/scheduler/test_scheduler.validation.json b/tests/aws/services/scheduler/test_scheduler.validation.json new file mode 100644 index 0000000000000..7f9a09fc8febe --- /dev/null +++ b/tests/aws/services/scheduler/test_scheduler.validation.json @@ -0,0 +1,65 @@ +{ + "tests/aws/services/scheduler/test_scheduler.py::test_list_schedules": { + "last_validated_date": "2024-06-11T22:50:50+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::test_tag_resource": { + "last_validated_date": "2024-12-04T10:07:28+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::test_untag_resource": { + "last_validated_date": "2024-12-04T10:08:11+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[ rate(10 minutes)]": { + "last_validated_date": "2025-01-26T15:45:58+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[at(2021-12-31)]": { + "last_validated_date": "2025-01-26T15:45:59+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[at(2021-12-31T23:59:59Z)]": { + "last_validated_date": "2025-01-26T15:45:59+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron()]": { + "last_validated_date": "2025-01-26T15:45:55+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(0 1 * * * *)]": { + "last_validated_date": "2025-01-26T15:45:53+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(0 dummy ? * MON-FRI *)]": { + "last_validated_date": "2025-01-26T15:45:54+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(7 20 * * NOT *)]": { + "last_validated_date": "2025-01-26T15:45:53+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(71 8 1 * ? *)]": { + "last_validated_date": "2025-01-26T15:45:54+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[cron(INVALID)]": { + "last_validated_date": "2025-01-26T15:45:54+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate( 10 minutes )]": { + "last_validated_date": "2025-01-26T15:45:58+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate()]": { + "last_validated_date": "2025-01-26T15:45:56+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(-10 minutes)]": { + "last_validated_date": "2025-01-26T15:45:57+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 minutess)]": { + "last_validated_date": "2025-01-26T15:45:57+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 seconds)]": { + "last_validated_date": "2025-01-26T15:45:55+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10 years)]": { + "last_validated_date": "2025-01-26T15:45:56+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(10)]": { + "last_validated_date": "2025-01-26T15:45:56+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_invalid_schedule_expression[rate(foo minutes)]": { + "last_validated_date": "2025-01-26T15:45:57+00:00" + }, + "tests/aws/services/scheduler/test_scheduler.py::tests_create_schedule_with_valid_schedule_expression": { + "last_validated_date": "2025-02-02T00:22:13+00:00" + } +} diff --git a/tests/aws/services/secretsmanager/__init__.py b/tests/aws/services/secretsmanager/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/secretsmanager/functions/__init__.py b/tests/aws/services/secretsmanager/functions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/secretsmanager/functions/lambda_rotate_secret.py b/tests/aws/services/secretsmanager/functions/lambda_rotate_secret.py new file mode 100644 index 0000000000000..ccfebb8621bf3 --- /dev/null +++ b/tests/aws/services/secretsmanager/functions/lambda_rotate_secret.py @@ -0,0 +1,237 @@ +# Adepted from: https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/blob/252449551d58075f353444743e81a8c56d9f96db/SecretsManagerRotationTemplate/lambda_function.py +# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +import logging +import os +from urllib.parse import urlparse + +import boto3 + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +# Returns the secret string used to update the version of the secret to version bounded to the +# provided secret id. +def secret_of_rotation_from_version_id(version_id: str) -> str: + return f"lambda_rotate_secret_rotation_{version_id}" + + +# Returns the SecretId used when signalling that a ResourceNotFoundException was received when +# requesting the secret value for a pending secret version during create_secret stage. +# The version_id given represents the version_id of the current secret value after rotation. +def secret_signal_resource_not_found_exception_on_create(version_id: str) -> str: + return f"ResourceNotFoundException_{version_id}" + + +def handler(event, context): + """Secrets Manager Rotation Template + + This is a template for creating an AWS Secrets Manager rotation lambda + + Args: + event (dict): Lambda dictionary of event parameters. These keys must include the following: + - SecretId: The secret ARN or identifier + - ClientRequestToken: The ClientRequestToken of the secret version + - Step: The rotation step (one of createSecret, setSecret, testSecret, or finishSecret) + + context (LambdaContext): The Lambda runtime information + + Raises: + ResourceNotFoundException: If the secret with the specified arn and stage does not exist + + ValueError: If the secret is not properly configured for rotation + + KeyError: If the event parameters do not contain the expected keys + + """ + # Client setup. + region = os.environ["AWS_REGION"] + endpoint_url = os.environ.get("AWS_ENDPOINT_URL") + + if endpoint_url: + verify = urlparse(endpoint_url).scheme == "https" + service_client = boto3.client( + "secretsmanager", endpoint_url=endpoint_url, verify=verify, region_name=region + ) + else: + service_client = boto3.client("secretsmanager", region_name=region) + + arn = event["SecretId"] + token = event["ClientRequestToken"] + step = event["Step"] + + # Make sure the version is staged correctly + metadata = service_client.describe_secret(SecretId=arn) + + if not metadata["RotationEnabled"]: + logger.error("Secret %s is not enabled for rotation", arn) + raise ValueError(f"Secret {arn} is not enabled for rotation") + # + versions = metadata["VersionIdsToStages"] + if token not in versions: + logger.error("Secret version %s has no stage for rotation of secret %s.", token, arn) + raise ValueError(f"Secret version {token} has no stage for rotation of secret {arn}.") + if "AWSCURRENT" in versions[token]: + logger.info("Secret version %s already set as AWSCURRENT for secret %s.", token, arn) + return + elif "AWSPENDING" not in versions[token]: + logger.error( + "Secret version %s not set as AWSPENDING for rotation of secret %s.", token, arn + ) + raise ValueError( + f"Secret version {token} not set as AWSPENDING for rotation of secret {arn}." + ) + + if step == "createSecret": + create_secret(service_client, arn, token) + + elif step == "setSecret": + set_secret(service_client, arn, token) + + elif step == "testSecret": + test_secret(service_client, arn, token) + + elif step == "finishSecret": + finish_secret(service_client, arn, token) + + else: + raise ValueError("Invalid step parameter") + + +def create_secret(service_client, arn, token): + """Create the secret + + This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a + new secret and put it with the passed in token. + + Args: + service_client (client): The secrets manager service client + + arn (string): The secret ARN or other identifier + + token (string): The ClientRequestToken associated with the secret version + + Raises: + ResourceNotFoundException: If the secret with the specified arn and stage does not exist + + """ + # Make sure the current secret exists + service_client.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT") + + # Now try to get the secret version, if that fails, put a new secret + try: + service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING") + logger.info("createSecret: Successfully retrieved secret for %s.", arn) + except service_client.exceptions.ResourceNotFoundException: + # Signal the correct exception was triggered during create_secret stage. + sig_exception = secret_signal_resource_not_found_exception_on_create(token) + service_client.create_secret(Name=sig_exception, SecretString=sig_exception) + + # Generate a random password + passwd = secret_of_rotation_from_version_id(token) + + # Put the secret + service_client.put_secret_value( + SecretId=arn, + ClientRequestToken=token, + SecretString=passwd, + VersionStages=["AWSPENDING"], + ) + logger.info( + "createSecret: Successfully put secret for ARN %s and version %s with passwd %s.", + arn, + token, + passwd, + ) + + +def set_secret(service_client, arn, token): + """Set the secret + + This method should set the AWSPENDING secret in the service that the secret belongs to. For example, if the secret is a database + credential, this method should take the value of the AWSPENDING secret and set the user's password to this value in the database. + + Args: + service_client (client): The secrets manager service client + + arn (string): The secret ARN or other identifier + + token (string): The ClientRequestToken associated with the secret version + + """ + logger.info("lambda_rotate_secret: set_secret not implemented.") + + +def test_secret(service_client, arn, token): + """Test the secret + + This method should validate that the AWSPENDING secret works in the service that the secret belongs to. For example, if the secret + is a database credential, this method should validate that the user can login with the password in AWSPENDING and that the user has + all of the expected permissions against the database. + + Args: + service_client (client): The secrets manager service client + + arn (string): The secret ARN or other identifier + + token (string): The ClientRequestToken associated with the secret version + + """ + logger.info("lambda_rotate_secret: test_secret not implemented.") + + +def finish_secret(service_client, arn, token): + """Finish the secret + + This method finalizes the rotation process by marking the secret version passed in as the AWSCURRENT secret. + + Args: + service_client (client): The secrets manager service client + + arn (string): The secret ARN or other identifier + + token (string): The ClientRequestToken associated with the secret version + + Raises: + ResourceNotFoundException: If the secret with the specified arn does not exist + + """ + # First describe the secret to get the current version + metadata = service_client.describe_secret(SecretId=arn) + current_version = None + for version in metadata["VersionIdsToStages"]: + if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: + if version == token: + # The correct version is already marked as current, return + logger.info( + "finishSecret: Version %s already marked as AWSCURRENT for %s", version, arn + ) + return + current_version = version + break + + # Finalize by staging the secret version current + service_client.update_secret_version_stage( + SecretId=arn, + VersionStage="AWSCURRENT", + MoveToVersionId=token, + RemoveFromVersionId=current_version, + ) + logger.info( + "finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s.", + token, + arn, + ) + if "AWSPENDING" in metadata["VersionIdsToStages"].get(token, []): + service_client.update_secret_version_stage( + SecretId=arn, + VersionStage="AWSPENDING", + RemoveFromVersionId=token, + ) + logger.info( + "finishSecret: Successfully removed AWSPENDING stage from version %s for secret %s.", + token, + arn, + ) diff --git a/tests/aws/services/secretsmanager/test_secretsmanager.py b/tests/aws/services/secretsmanager/test_secretsmanager.py new file mode 100644 index 0000000000000..7a91414c6879e --- /dev/null +++ b/tests/aws/services/secretsmanager/test_secretsmanager.py @@ -0,0 +1,2793 @@ +import json +import logging +import os +import random +import uuid +from datetime import datetime +from math import isclose +from typing import Optional + +import pytest +import requests +from botocore.auth import SigV4Auth +from botocore.exceptions import ClientError +from moto.secretsmanager.utils import SecretsManagerSecretIdentifier + +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.api.secretsmanager import ( + CreateSecretRequest, + CreateSecretResponse, + DeleteSecretRequest, + DeleteSecretResponse, + ListSecretsResponse, +) +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID, TEST_AWS_REGION_NAME +from localstack.testing.pytest import markers +from localstack.testing.snapshots.transformer_utility import TransformerUtility +from localstack.utils.aws import aws_stack +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.collections import select_from_typed_dict +from localstack.utils.strings import short_uid +from localstack.utils.sync import poll_condition +from localstack.utils.time import today_no_time + +LOG = logging.getLogger(__name__) + + +RESOURCE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "secretsmanager:GetSecretValue", + "Resource": "*", + } + ], +} + +THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +TEST_LAMBDA_ROTATE_SECRET = os.path.join(THIS_FOLDER, "functions", "lambda_rotate_secret.py") + + +class TestSecretsManager: + @pytest.fixture + def secret_name(self, aws_client, cleanups) -> str: + """ + Returns a new unique SecretId, and schedules its deletion though the cleanups mechanism. + :return: a new and automatically deleted unique SecretId. + """ + secret_name = f"s-{short_uid()}" + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + return secret_name + + @pytest.fixture + def sm_snapshot(self, snapshot): + snapshot.add_transformers_list(snapshot.transform.secretsmanager_api()) + return snapshot + + @pytest.fixture + def setup_invalid_rotation_secret(self, secret_name, aws_client, account_id, sm_snapshot): + def _setup(invalid_arn: str | None): + create_secret = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="init" + ) + sm_snapshot.add_transformer( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret, 0) + ) + sm_snapshot.match("create_secret", create_secret) + rotation_config = { + "SecretId": secret_name, + "RotationRules": { + "AutomaticallyAfterDays": 1, + }, + } + if invalid_arn: + rotation_config["RotationLambdaARN"] = invalid_arn + aws_client.secretsmanager.rotate_secret(**rotation_config) + + return _setup + + @pytest.fixture + def setup_rotation_secret( + self, + sm_snapshot, + secret_name, + create_secret, + create_lambda_function, + aws_client, + ): + cre_res = create_secret( + Name=secret_name, + SecretString="my_secret", + Description="testing rotation of secrets", + ) + + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(cre_res, 0) + ) + + function_name = f"s-{short_uid()}" + function_arn = create_lambda_function( + handler_file=TEST_LAMBDA_ROTATE_SECRET, + func_name=function_name, + runtime=Runtime.python3_12, + )["CreateFunctionResponse"]["FunctionArn"] + + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="secretsManagerPermission", + Action="lambda:InvokeFunction", + Principal="secretsmanager.amazonaws.com", + ) + return cre_res["VersionId"], function_arn + + @staticmethod + def _wait_created_is_listed(client, secret_id: str): + def _is_secret_in_list(): + lst: ListSecretsResponse = ( + client.get_paginator("list_secrets").paginate().build_full_result() + ) + secret_ids: set[str] = {secret["Name"] for secret in lst.get("SecretList", [])} + return secret_id in secret_ids + + assert poll_condition(condition=_is_secret_in_list, timeout=60, interval=2), ( + f"Retried check for listing of {secret_id=} timed out" + ) + + @staticmethod + def _wait_force_deletion_completed(client, secret_id: str): + def _is_secret_deleted(): + deleted = False + try: + client.describe_secret(SecretId=secret_id) + except Exception as ex: + if ex.response["Error"]["Code"] == "ResourceNotFoundException": + deleted = True + else: + raise ex + return deleted + + success = poll_condition(condition=_is_secret_deleted, timeout=120, interval=30) + if not success: + LOG.warning( + "Timed out whilst awaiting for force deletion of secret '%s' to complete.", + secret_id, + ) + + @staticmethod + def _wait_rotation(client, secret_id: str, secret_version: str): + def _is_secret_rotated(): + resp: dict = client.describe_secret(SecretId=secret_id) + secret_stage_tags = list() + for key, tags in resp.get("VersionIdsToStages", {}).items(): + if key == secret_version: + secret_stage_tags = tags + break + return "AWSCURRENT" in secret_stage_tags + + success = poll_condition(condition=_is_secret_rotated, timeout=120, interval=5) + if not success: + LOG.warning( + "Timed out whilst awaiting for secret '%s' to be rotated to new version.", + secret_id, + ) + + @pytest.mark.parametrize( + "secret_name", + [ + "s-c64bdc03", + "Valid/_+=.@-Name", + "Valid/_+=.@-Name-a1b2", + "Valid/_+=.@-Name-a1b2c3-", + ], + ) + @markers.aws.validated + def test_create_and_update_secret(self, secret_name: str, sm_snapshot, cleanups, aws_client): + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + description = "Testing secret creation." + create_secret_rs_1 = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret", + Description=description, + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_1, 0) + ) + sm_snapshot.match("create_secret_rs_1", create_secret_rs_1) + # + secret_arn = create_secret_rs_1["ARN"] + assert len(secret_arn.rpartition("-")[-1]) == 6 + + get_secret_value_rs_1 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_rs_1", get_secret_value_rs_1) + + describe_secret_rs_1 = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret_rs_1", describe_secret_rs_1) + + get_secret_value_rs_2 = aws_client.secretsmanager.get_secret_value(SecretId=secret_arn) + sm_snapshot.match("get_secret_value_rs_2", get_secret_value_rs_2) + + # Ensure retrieval with partial ARN works + get_secret_value_rs_3 = aws_client.secretsmanager.get_secret_value(SecretId=secret_arn[:-7]) + sm_snapshot.match("get_secret_value_rs_3", get_secret_value_rs_3) + + put_secret_value_rs_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="new_secret" + ) + sm_snapshot.match("put_secret_value_rs_1", put_secret_value_rs_1) + + get_secret_value_rs_4 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_rs_4", get_secret_value_rs_4) + + # update secret by ARN + update_secret_res_1 = aws_client.secretsmanager.update_secret( + SecretId=secret_arn, SecretString="test123", Description="d1" + ) + sm_snapshot.match("update_secret_res_1", update_secret_res_1) + + # clean up + delete_secret_res_1 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_1", delete_secret_res_1) + + @markers.aws.validated + def test_secret_restore(self, secret_name: str, sm_snapshot, cleanups, aws_client): + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + create_secret_rs = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret", + Description="test description", + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs, 0) + ) + + delete_secret_res = aws_client.secretsmanager.delete_secret(SecretId=secret_name) + sm_snapshot.match("delete_secret_res", delete_secret_res) + + restore_secret_res = aws_client.secretsmanager.restore_secret(SecretId=secret_name) + sm_snapshot.match("restore_secret_res", restore_secret_res) + + @markers.aws.validated + def test_secret_not_found(self, sm_snapshot, aws_client): + with pytest.raises(Exception) as not_found: + aws_client.secretsmanager.get_secret_value(SecretId=f"s-{short_uid()}") + sm_snapshot.match("get_secret_value_not_found_ex", not_found.value.response) + + with pytest.raises(Exception) as not_found: + aws_client.secretsmanager.list_secret_version_ids(SecretId=f"s-{short_uid()}") + sm_snapshot.match("list_secret_version_ids_not_found_ex", not_found.value.response) + + @markers.aws.validated + def test_secret_version_not_found(self, secret_name: str, sm_snapshot, cleanups, aws_client): + aws_client.secretsmanager.create_secret( + Name=secret_name, + ) + + version_id = str(uuid.uuid4()) + sm_snapshot.add_transformer(sm_snapshot.transform.regex(version_id, "")) + + with pytest.raises(ClientError) as not_found: + aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_no_version_ex", not_found.value) + + with pytest.raises(ClientError) as not_found: + aws_client.secretsmanager.get_secret_value(SecretId=secret_name, VersionId=version_id) + sm_snapshot.match("get_secret_value_version_not_found_ex", not_found.value) + + with pytest.raises(ClientError) as not_found: + aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, VersionStage="AWSPENDING" + ) + sm_snapshot.match("get_secret_value_stage_not_found_ex", not_found.value) + + @markers.aws.validated + def test_call_lists_secrets_multiple_times(self, secret_name, aws_client, cleanups): + aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret", + Description="testing creation of secrets", + ) + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name) + + # call list_secrets multiple times + for i in range(3): + rs = ( + aws_client.secretsmanager.get_paginator("list_secrets") + .paginate() + .build_full_result() + ) + secrets = [secret for secret in rs["SecretList"] if secret["Name"] == secret_name] + assert len(secrets) == 1 + + @pytest.mark.skip("needs to be reworked") + @markers.aws.needs_fixing # remove comparison with full list of secrets in account + def test_call_lists_secrets_multiple_times_snapshots( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_rs_1 = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret", + Description="testing creation of secrets", + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_1, 0) + ) + sm_snapshot.match("create_secret_rs_1", create_secret_rs_1) + + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name) + + # call list_secrets multiple times + for i in range(3): + list_secrets_res = ( + aws_client.secretsmanager.get_paginator("list_secrets") + .paginate() + .build_full_result() + ) + sm_snapshot.match(f"list_secrets_res_{i}", list_secrets_res) + + # clean up + delete_secret_res_1 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_1", delete_secret_res_1) + + @markers.aws.validated + def test_list_secrets_filtering(self, aws_client, create_secret): + suffix = random.randint(10000, 99999) + secret_name_1 = f"testing1/one-{suffix}" + secret_name_2 = f"/testing2/two-{suffix}" + secret_name_3 = f"testing3/three-{suffix}" + secret_name_4 = f"/testing4/four-{suffix}" + + create_secret(Name=secret_name_1, SecretString="secret", Description="a secret") + create_secret(Name=secret_name_2, SecretString="secret", Description="an secret") + create_secret(Name=secret_name_3, SecretString="secret", Description="asecret") + create_secret(Name=secret_name_4, SecretString="secret", Description="thesecret") + + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name_1) + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name_2) + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name_3) + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name_4) + + def assert_secret_names(res: dict, include_secrets: set[str], exclude_secrets: set[str]): + secret_names = {secret["Name"] for secret in res["SecretList"]} + assert (include_secrets - secret_names) == set(), ( + "At least one secret which should be included is not." + ) + assert (exclude_secrets - secret_names) == exclude_secrets, ( + "At least one secret which should not be included is." + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "name", "Values": ["/"]}] + ) + assert_secret_names( + response, {secret_name_2, secret_name_4}, {secret_name_1, secret_name_3} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "name", "Values": ["!/"]}] + ) + assert_secret_names( + response, {secret_name_1, secret_name_3}, {secret_name_2, secret_name_4} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "name", "Values": ["testing1 one"]}] + ) + assert_secret_names( + response, set(), {secret_name_1, secret_name_2, secret_name_3, secret_name_4} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "description", "Values": ["a"]}] + ) + assert_secret_names( + response, {secret_name_1, secret_name_2, secret_name_3}, {secret_name_4} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "description", "Values": ["!a"]}] + ) + assert_secret_names( + response, {secret_name_4}, {secret_name_1, secret_name_2, secret_name_3} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "description", "Values": ["a secret"]}] + ) + assert_secret_names( + response, {secret_name_1, secret_name_2}, {secret_name_3, secret_name_4} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[ + {"Key": "description", "Values": ["a"]}, + {"Key": "name", "Values": ["secret"]}, + ] + ) + assert_secret_names( + response, set(), {secret_name_1, secret_name_2, secret_name_3, secret_name_4} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[ + {"Key": "description", "Values": ["a"]}, + {"Key": "name", "Values": ["an"]}, + ] + ) + assert_secret_names( + response, set(), {secret_name_1, secret_name_2, secret_name_3, secret_name_4} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[ + {"Key": "description", "Values": ["a secret"]}, + ] + ) + assert_secret_names( + response, {secret_name_1, secret_name_2}, {secret_name_3, secret_name_4} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[ + {"Key": "description", "Values": ["!a"]}, + ] + ) + assert_secret_names( + response, {secret_name_4}, {secret_name_1, secret_name_2, secret_name_3} + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "description", "Values": ["!c"]}] + ) + assert_secret_names( + response, {secret_name_1, secret_name_2, secret_name_3, secret_name_4}, set() + ) + + response = aws_client.secretsmanager.list_secrets( + Filters=[{"Key": "name", "Values": ["testing1 one"]}] + ) + assert_secret_names( + response, set(), {secret_name_1, secret_name_2, secret_name_3, secret_name_4} + ) + + @markers.aws.validated + def test_create_multi_secrets(self, cleanups, aws_client): + secret_names = [short_uid(), short_uid(), short_uid()] + arns = [] + for secret_name in secret_names: + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + rs = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret_{}".format(secret_name), + Description="testing creation of secrets", + ) + arns.append(rs["ARN"]) + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name) + + rs = aws_client.secretsmanager.get_paginator("list_secrets").paginate().build_full_result() + secrets = { + secret["Name"]: secret["ARN"] + for secret in rs["SecretList"] + if secret["Name"] in secret_names + } + + assert len(secrets.keys()) == len(secret_names) + for arn in arns: + assert arn in secrets.values() + + # clean up + for secret_name in secret_names: + aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + + @pytest.mark.skip("needs to be reworked") + @markers.aws.needs_fixing # FIXME: leaks, snapshot mismatches since it tests the complete list of secrets in the account + def test_create_multi_secrets_snapshot(self, sm_snapshot, cleanups, aws_client): + secret_names = [short_uid() for _ in range(3)] + for i, secret_name in enumerate(secret_names): + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + create_secret_rs_1 = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=f"my_secret_{secret_name}", + Description="Testing secrets creation.", + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_1, i) + ) + + self._wait_created_is_listed(aws_client.secretsmanager, secret_name) + + list_secrets_res = ( + aws_client.secretsmanager.get_paginator("list_secrets").paginate().build_full_result() + ) + sm_snapshot.match("list_secrets_res", list_secrets_res) + + # clean up + for i, secret_name in enumerate(secret_names): + delete_secret_res = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match(f"delete_secret_res{i}", delete_secret_res) + + @markers.aws.validated + def test_get_random_exclude_characters_and_symbols(self, aws_client): + random_password = aws_client.secretsmanager.get_random_password( + PasswordLength=120, ExcludeCharacters="xyzDje@?!." + ) + + assert len(random_password["RandomPassword"]) == 120 + assert all(c not in "xyzDje@?!." for c in random_password["RandomPassword"]) + + @markers.aws.validated + def test_resource_policy(self, secret_name, aws_client, sm_snapshot, cleanups): + response = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret", + Description="testing creation of secrets", + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(response, 0) + ) + + response = aws_client.secretsmanager.put_resource_policy( + SecretId=secret_name, ResourcePolicy=json.dumps(RESOURCE_POLICY) + ) + sm_snapshot.match("put_resource_policy", response) + + rs = aws_client.secretsmanager.get_resource_policy(SecretId=secret_name) + sm_snapshot.match("get_resource_policy", rs) + + policy = json.loads(rs["ResourcePolicy"]) + + assert policy["Version"] == RESOURCE_POLICY["Version"] + assert policy["Statement"] == RESOURCE_POLICY["Statement"] + + rs = aws_client.secretsmanager.delete_resource_policy(SecretId=secret_name) + assert rs["ResponseMetadata"]["HTTPStatusCode"] == 200 + + @pytest.mark.parametrize("rotate_immediately", [True, None]) + @markers.snapshot.skip_snapshot_verify( + paths=["$..VersionIdsToStages", "$..Versions", "$..VersionId"] + ) + @markers.aws.validated + def test_rotate_secret_with_lambda_success( + self, + sm_snapshot, + secret_name, + create_secret, + create_lambda_function, + aws_client, + setup_rotation_secret, + rotate_immediately, + ): + """ + Tests secret rotation via a lambda function. + Parametrization ensures we test the default behavior which is an immediate rotation. + """ + rotation_config = { + "RotationRules": {"AutomaticallyAfterDays": 1}, + } + if rotate_immediately: + rotation_config["RotateImmediately"] = rotate_immediately + initial_secret_version, function_arn = setup_rotation_secret + + rotation_config = rotation_config or {} + if function_arn: + rotation_config["RotationLambdaARN"] = function_arn + + rot_res = aws_client.secretsmanager.rotate_secret( + SecretId=secret_name, + **rotation_config, + ) + + sm_snapshot.match("rotate_secret_immediately", rot_res) + + self._wait_rotation(aws_client.secretsmanager, secret_name, rot_res["VersionId"]) + + response = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret_rotated", response) + + list_secret_versions_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + + sm_snapshot.match("list_secret_versions_rotated_1", list_secret_versions_1) + + # As a result of the Lambda invocations. current version should be + # pointed to `AWSCURRENT` & previous version to `AWSPREVIOUS` + assert response["VersionIdsToStages"][initial_secret_version] == ["AWSPREVIOUS"] + assert response["VersionIdsToStages"][rot_res["VersionId"]] == ["AWSCURRENT"] + + @markers.snapshot.skip_snapshot_verify( + paths=["$..VersionIdsToStages", "$..Versions", "$..VersionId"] + ) + @markers.aws.validated + def test_rotate_secret_multiple_times_with_lambda_success( + self, + sm_snapshot, + secret_name, + create_secret, + create_lambda_function, + aws_client, + setup_rotation_secret, + ): + secret_initial_version, function_arn = setup_rotation_secret + runs_config = { + 1: { + "RotationRules": {"AutomaticallyAfterDays": 1}, + "RotateImmediately": True, + "RotationLambdaARN": function_arn, + }, + 2: {}, + } + + for index in range(1, 3): + rotation_config = runs_config[index] + + rot_res = aws_client.secretsmanager.rotate_secret( + SecretId=secret_name, + **rotation_config, + ) + + sm_snapshot.match(f"rotate_secret_immediately_{index}", rot_res) + + self._wait_rotation(aws_client.secretsmanager, secret_name, rot_res["VersionId"]) + + response = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match(f"describe_secret_rotated_{index}", response) + + list_secret_versions_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + + sm_snapshot.match(f"list_secret_versions_rotated_1_{index}", list_secret_versions_1) + + # As a result of the Lambda invocations. current version should be + # pointed to `AWSCURRENT` & previous version to `AWSPREVIOUS` + assert response["VersionIdsToStages"][secret_initial_version] == ["AWSPREVIOUS"] + assert response["VersionIdsToStages"][rot_res["VersionId"]] == ["AWSCURRENT"] + + secret_initial_version = aws_client.secretsmanager.get_secret_value( + SecretId=secret_name + )["VersionId"] + + @markers.snapshot.skip_snapshot_verify(paths=["$..Error", "$..Message"]) + @markers.aws.validated + def test_rotate_secret_invalid_lambda_arn( + self, setup_invalid_rotation_secret, aws_client, sm_snapshot, secret_name, account_id + ): + region_name = aws_client.secretsmanager.meta.region_name + invalid_arn = ( + f"arn:aws:lambda:{region_name}:{account_id}:function:rotate_secret_invalid_lambda_arn" + ) + with pytest.raises(Exception) as e: + setup_invalid_rotation_secret(invalid_arn) + sm_snapshot.match("rotate_secret_invalid_arn_exc", e.value.response) + + describe_secret = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret", describe_secret) + assert "RotationEnabled" not in describe_secret + assert "RotationRules" not in describe_secret + assert "RotationLambdaARN" not in describe_secret + + @markers.aws.validated + def test_first_rotate_secret_with_missing_lambda_arn( + self, setup_invalid_rotation_secret, sm_snapshot + ): + with pytest.raises(Exception) as e: + setup_invalid_rotation_secret(None) + sm_snapshot.match("rotate_secret_no_arn_exc", e.value.response) + + @markers.aws.validated + def test_put_secret_value_with_version_stages(self, sm_snapshot, secret_name, aws_client): + secret_string_v0: str = "secret_string_v0" + + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString=secret_string_v0 + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + get_secret_value_res_0 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_0", get_secret_value_res_0) + + secret_string_v1: str = "secret_string_v1" + version_stages_v1: list[str] = ["SAMPLESTAGE1", "SAMPLESTAGE0"] + pv_v1_vid: str = str(uuid.uuid4()) + # + put_secret_value_res_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, + SecretString=secret_string_v1, + VersionStages=version_stages_v1, + ClientRequestToken=pv_v1_vid, + ) + sm_snapshot.match("put_secret_value_res_1", put_secret_value_res_1) + + get_secret_value_res_1 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_1", get_secret_value_res_1) + + secret_string_v2: str = "secret_string_v2" + version_stages_v2: list[str] = version_stages_v1 + pv_v2_vid: str = str(uuid.uuid4()) + # + put_secret_value_res_2 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, + SecretString=secret_string_v2, + VersionStages=version_stages_v2, + ClientRequestToken=pv_v2_vid, + ) + sm_snapshot.match("put_secret_value_res_2", put_secret_value_res_2) + + get_secret_value_res_2 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_2", get_secret_value_res_2) + + secret_string_v3: str = "secret_string_v3" + version_stages_v3: ["str"] = ["AWSPENDING"] + pv_v3_vid: str = str(uuid.uuid4()) + # + put_secret_value_res_3 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, + SecretString=secret_string_v3, + VersionStages=version_stages_v3, + ClientRequestToken=pv_v3_vid, + ) + sm_snapshot.match("put_secret_value_res_3", put_secret_value_res_3) + + get_secret_value_res_3 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_3", get_secret_value_res_3) + + secret_string_v4: str = "secret_string_v4" + pv_v4_vid: str = str(uuid.uuid4()) + # + put_secret_value_res_4 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString=secret_string_v4, ClientRequestToken=pv_v4_vid + ) + sm_snapshot.match("put_secret_value_res_4", put_secret_value_res_4) + + get_secret_value_res_4 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_4", get_secret_value_res_4) + + delete_secret_res_1 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_1", delete_secret_res_1) + + @pytest.mark.parametrize( + "secret_name", ["Inv Name", " Inv Name", " Inv*Name? ", " Inv *?!]Name\\-"] + ) + @markers.aws.validated + def test_invalid_secret_name(self, sm_snapshot, cleanups, secret_name: str, aws_client): + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + # The secret name can contain ASCII letters, numbers, and the following characters: /_+=.@- + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.create_secret(Name=secret_name, SecretString="MySecretString") + sm_snapshot.match("ex_log_1", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("ex_log_2", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("ex_log_3", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("ex_log_4", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name, IncludeDeprecated=True + ) + sm_snapshot.match("ex_log_5", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="MySecretString" + ) + sm_snapshot.match("ex_log_6", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.tag_resource( + SecretId=secret_name, Tags=[{"Key": "FirstTag", "Value": "SomeValue"}] + ) + sm_snapshot.match("ex_log_7", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.untag_resource(SecretId=secret_name, TagKeys=["FirstTag"]) + sm_snapshot.match("ex_log_8", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.update_secret( + SecretId=secret_name, Description="MyNewDescription" + ) + sm_snapshot.match("ex_log_9", validation_exception.value.response) + + with pytest.raises(Exception) as validation_exception: + aws_client.secretsmanager.validate_resource_policy( + SecretId=secret_name, + ResourcePolicy='{\n"Version":"2012-10-17",\n"Statement":[{\n"Effect":"Allow",\n"Principal":{\n"AWS":"arn:aws:iam::123456789012:root"\n},\n"Action":"secretsmanager:GetSecretValue",\n"Resource":"*"\n}]\n}', + ) + sm_snapshot.match("ex_log_10", validation_exception.value.response) + + @markers.aws.validated + def test_last_accessed_date(self, cleanups, aws_client): + def last_accessed_scenario_1(fail_if_days_overlap: bool) -> bool: + secret_name = f"s-{short_uid()}" + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + + aws_client.secretsmanager.create_secret(Name=secret_name, SecretString="MySecretValue") + + des = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastAccessedDate" not in des + + t0 = today_no_time() + + aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + des = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastAccessedDate" in des + lad_v0 = des["LastAccessedDate"] + assert isinstance(lad_v0, datetime) + + aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + des = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastAccessedDate" in des + lad_v1 = des["LastAccessedDate"] + assert isinstance(lad_v1, datetime) + + if t0 == today_no_time() or fail_if_days_overlap: + assert lad_v0 == lad_v1 + return True + else: + return False + + if not last_accessed_scenario_1( + False + ): # Test started yesterday and ended today (where relevant). + last_accessed_scenario_1( + True + ) # Replay today or allow failure (this should never take longer than a day). + + @markers.aws.validated + def test_last_updated_date(self, secret_name, aws_client): + # TODO: moto is rounding time.time() but `secretsmanager`return a timestamp with 3 fraction digits + # adapt the tests for around equality + aws_client.secretsmanager.create_secret(Name=secret_name, SecretString="MySecretValue") + + res = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastChangedDate" in res + create_date = res["LastChangedDate"] + assert isinstance(create_date, datetime) + create_date_ts = create_date.timestamp() + + res = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + assert isclose(create_date_ts, res["CreatedDate"].timestamp(), rel_tol=1) + + res = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastChangedDate" in res + assert isclose(create_date_ts, res["LastChangedDate"].timestamp(), rel_tol=1) + + aws_client.secretsmanager.update_secret( + SecretId=secret_name, SecretString="MyNewSecretValue" + ) + + res = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastChangedDate" in res + assert create_date < res["LastChangedDate"] + last_changed = res["LastChangedDate"] + + aws_client.secretsmanager.update_secret( + SecretId=secret_name, SecretString="MyNewSecretValue" + ) + + res = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastChangedDate" in res + assert last_changed < res["LastChangedDate"] + + aws_client.secretsmanager.update_secret( + SecretId=secret_name, SecretString="MyVeryNewSecretValue" + ) + + res = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + assert "LastChangedDate" in res + assert create_date < res["LastChangedDate"] + + @markers.aws.validated + def test_update_secret_description(self, sm_snapshot, secret_name, aws_client): + secret_string_v0 = "MySecretString" + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString=secret_string_v0 + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + describe_secret_res_0 = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret_res_0", describe_secret_res_0) + + description_v1 = "MyDescription" + update_secret_res_0 = aws_client.secretsmanager.update_secret( + SecretId=secret_name, Description=description_v1 + ) + sm_snapshot.match("update_secret_res_0", update_secret_res_0) + + describe_secret_res_1 = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret_res_1", describe_secret_res_1) + + description_v2 = "MyNewDescription" + secret_string_v1 = "MyNewSecretString" + # + update_secret_res_1 = aws_client.secretsmanager.update_secret( + SecretId=secret_name, SecretString=secret_string_v1, Description=description_v2 + ) + sm_snapshot.match("update_secret_res_1", update_secret_res_1) + + describe_secret_res_2 = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret_res_2", describe_secret_res_2) + + update_secret_res_2 = aws_client.secretsmanager.update_secret( + SecretId=secret_name, SecretString=secret_string_v1 * 2 + ) + sm_snapshot.match("update_secret_res_2", update_secret_res_2) + + describe_secret_res_3 = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret_res_3", describe_secret_res_3) + + update_secret_res_3 = aws_client.secretsmanager.update_secret(SecretId=secret_name) + sm_snapshot.match("update_secret_res_3", update_secret_res_3) + + describe_secret_res_4 = aws_client.secretsmanager.describe_secret(SecretId=secret_name) + sm_snapshot.match("describe_secret_res_4", describe_secret_res_4) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.aws.validated + def test_update_secret_version_stages_return_type(self, sm_snapshot, secret_name, aws_client): + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="Something1" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + version_id_v0: str = create_secret_rs_0["VersionId"] + + put_secret_value_res_0 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="Something2", VersionStages=["AWSPENDING"] + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_res_0) + + version_id_v1 = put_secret_value_res_0["VersionId"] + assert version_id_v1 != version_id_v0 + + update_secret_version_stage_res_0 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=version_id_v0, + MoveToVersionId=version_id_v1, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_0", update_secret_version_stage_res_0) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_update_secret_version_stages_current_previous( + self, sm_snapshot, secret_name, aws_client + ): + secret_string_v0 = "secret_string_v0" + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString=secret_string_v0 + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + self._wait_created_is_listed(aws_client.secretsmanager, secret_name) + + list_secret_version_ids_res_0 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_0", list_secret_version_ids_res_0) + + secret_string_v1 = "secret_string_v1" + # + update_secret_res_0 = aws_client.secretsmanager.update_secret( + SecretId=secret_name, SecretString=secret_string_v1 + ) + sm_snapshot.match("update_secret_res_0", update_secret_res_0) + + list_secret_version_ids_res_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_1", list_secret_version_ids_res_1) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_update_secret_version_stages_current_pending( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="Something1" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + version_id_v0 = create_secret_rs_0["VersionId"] + + put_secret_value_res_0 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="Something2", VersionStages=["AWSPENDING"] + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_res_0) + + version_id_v1 = put_secret_value_res_0["VersionId"] + + list_secret_version_ids_res_0 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_0", list_secret_version_ids_res_0) + + update_secret_version_stage_res_0 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=version_id_v0, + MoveToVersionId=version_id_v1, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_0", update_secret_version_stage_res_0) + + list_secret_version_ids_res_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_1", list_secret_version_ids_res_1) + + put_secret_value_res_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="SS3" + ) + sm_snapshot.match("put_secret_value_res_1", put_secret_value_res_1) + + list_secret_version_ids_res_2 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_2", list_secret_version_ids_res_2) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_update_secret_version_stages_current_pending_cycle( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="S1" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + vid_0 = create_secret_rs_0["VersionId"] + + put_secret_value_res_0 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S2", VersionStages=["AWSPENDING"] + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_res_0) + + vid_1 = put_secret_value_res_0["VersionId"] + + list_secret_version_ids_res_0 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_0", list_secret_version_ids_res_0) + + get_secret_value_res_0 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_0", get_secret_value_res_0) + + update_secret_version_stage_res_0 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=vid_0, + MoveToVersionId=vid_1, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_0", update_secret_version_stage_res_0) + + list_secret_version_ids_res_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_1", list_secret_version_ids_res_1) + + get_secret_value_res_1 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_1", get_secret_value_res_1) + + put_secret_value_res_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S3", VersionStages=["AWSPENDING"] + ) + sm_snapshot.match("put_secret_value_res_1", put_secret_value_res_1) + + vid_2 = put_secret_value_res_1["VersionId"] + + list_secret_version_ids_res_2 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_2", list_secret_version_ids_res_2) + + get_secret_value_res_2 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_2", get_secret_value_res_2) + + put_secret_value_res_2 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=vid_1, + MoveToVersionId=vid_2, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("put_secret_value_res_2", put_secret_value_res_2) + + list_secret_version_ids_res_3 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_3", list_secret_version_ids_res_3) + + get_secret_value_res_3 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_3", get_secret_value_res_3) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_update_secret_version_stages_current_pending_cycle_custom_stages_1( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="S1" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + vid_0 = create_secret_rs_0["VersionId"] + + put_secret_value_res_0 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, + SecretString="S2", + VersionStages=["AWSSOMETHING", "AWSPENDING", "PUT1"], + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_res_0) + vid_1 = put_secret_value_res_0["VersionId"] + + list_secret_version_ids_res_0 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_0", list_secret_version_ids_res_0) + + get_secret_value_res_0 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_0", get_secret_value_res_0) + + update_secret_version_stage_res_0 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=vid_0, + MoveToVersionId=vid_1, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_0", update_secret_version_stage_res_0) + + list_secret_version_ids_res_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_1", list_secret_version_ids_res_1) + + get_secret_value_res_1 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_1", get_secret_value_res_1) + + put_secret_value_res_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S3", VersionStages=["AWSPENDING", "PUT2"] + ) + sm_snapshot.match("put_secret_value_res_1", put_secret_value_res_1) + vid_2 = put_secret_value_res_1["VersionId"] + + list_secret_version_ids_res_2 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_2", list_secret_version_ids_res_2) + + get_secret_value_res_2 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_2", get_secret_value_res_2) + + update_secret_version_stage_res_1 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=vid_1, + MoveToVersionId=vid_2, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_1", update_secret_version_stage_res_1) + + list_secret_version_ids_res_3 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_3", list_secret_version_ids_res_3) + + get_secret_value_res_3 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_3", get_secret_value_res_3) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Versions..KmsKeyIds"]) + @markers.aws.validated + def test_deprecated_secret_version_stage( + self, secret_name, create_secret, aws_client, sm_snapshot + ): + response = create_secret( + Name=secret_name, + SecretString="original", + Description="My secret", + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(response, 0) + ) + sm_snapshot.match("create_secret", response) + self._wait_created_is_listed(aws_client.secretsmanager, secret_name) + + response = aws_client.secretsmanager.list_secret_version_ids(SecretId=secret_name) + sm_snapshot.match("list_secret_version_ids", response) + + response = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="update1" + ) + sm_snapshot.match("put_secret_value_1", response) + + response = aws_client.secretsmanager.list_secret_version_ids(SecretId=secret_name) + sm_snapshot.match("list_secret_version_ids_1", response) + + response = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="update2" + ) + sm_snapshot.match("put_secret_value_2", response) + + response = aws_client.secretsmanager.list_secret_version_ids(SecretId=secret_name) + sm_snapshot.match("list_secret_version_ids_2", response) + + response = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name, IncludeDeprecated=True + ) + sm_snapshot.match("list_secret_version_ids_3", response) + + response = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="update3" + ) + sm_snapshot.match("put_secret_value_3", response) + + response = aws_client.secretsmanager.list_secret_version_ids(SecretId=secret_name) + sm_snapshot.match("list_secret_version_ids_4", response) + + response = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name, IncludeDeprecated=True + ) + sm_snapshot.match("list_secret_version_ids_5", response) + + @markers.aws.only_localstack + def test_deprecated_secret_version(self, secret_name, create_secret, aws_client): + """ + This test ensures the version cleanup behavior in a simulated AWS environment. + Secrets Manager typically retains a maximum of 100 versions and does not + immediately delete versions created within the last 24 hours. + However, this test operates under the assumption that version timestamps are not evaluated, + and the cleanup process solely depends on reaching a version count threshold. + """ + create_secret(Name=secret_name, SecretString="original", Description="My secret") + self._wait_created_is_listed(aws_client.secretsmanager, secret_name) + + for i in range(130): + aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString=f"update{i}" + ) + response = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name, IncludeDeprecated=True + ) + # In Secrets Manager, versions of secrets without labels are considered deprecated. + # There will be two labeled versions: + # - The current version, labeled AWSCURRENT + # - The previous version, labeled AWSPREVIOUS + # see: https://docs.aws.amazon.com/secretsmanager/latest/userguide/getting-started.html#term_version + assert len(response["Versions"]) == 102 + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_update_secret_version_stages_current_pending_cycle_custom_stages_2( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="SS" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + put_secret_value_res_0 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S1", VersionStages=["AWSCURRENT", "PUT0"] + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_res_0) + # + vid_0 = put_secret_value_res_0["VersionId"] + + list_secret_version_ids_res_0 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_0", list_secret_version_ids_res_0) + + put_secret_value_res_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S2", VersionStages=["AWSPENDING", "PUT1"] + ) + sm_snapshot.match("put_secret_value_res_1", put_secret_value_res_1) + # + vid_1 = put_secret_value_res_1["VersionId"] + + list_secret_version_ids_res_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_1", list_secret_version_ids_res_1) + + get_secret_value_res_0 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_0", get_secret_value_res_0) + + update_secret_version_stage_res_0 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=vid_0, + MoveToVersionId=vid_1, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_0", update_secret_version_stage_res_0) + + list_secret_version_ids_res_2 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_2", list_secret_version_ids_res_2) + + get_secret_value_res_1 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_1", get_secret_value_res_1) + + put_secret_value_res_2 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S3", VersionStages=["AWSPENDING", "PUT2"] + ) + sm_snapshot.match("put_secret_value_res_2", put_secret_value_res_2) + # + vid_2 = put_secret_value_res_2["VersionId"] + + list_secret_version_ids_res_3 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_3", list_secret_version_ids_res_3) + + get_secret_value_res_2 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_2", get_secret_value_res_2) + + update_secret_version_stage_res_1 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=vid_1, + MoveToVersionId=vid_2, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_1", update_secret_version_stage_res_1) + + list_secret_version_ids_res_4 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_4", list_secret_version_ids_res_4) + + get_secret_value_res_3 = aws_client.secretsmanager.get_secret_value(SecretId=secret_name) + sm_snapshot.match("get_secret_value_res_3", get_secret_value_res_3) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_update_secret_version_stages_current_pending_cycle_custom_stages_3( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_rs = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="SS" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs, 0) + ) + sm_snapshot.match("create_secret_rs", create_secret_rs) + + version_id_v1 = create_secret_rs["VersionId"] + + put_secret_value_rs = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S1", VersionStages=["PENDING"] + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_rs) + + version_id_v2 = put_secret_value_rs["VersionId"] + + list_secret_version_ids_rs = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_rs", list_secret_version_ids_rs) + versions = list_secret_version_ids_rs["Versions"] + assert len(versions) == 2 + + get_secret_value_v1_rs = aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, + VersionId=version_id_v1, + ) + sm_snapshot.match("get_secret_value_v1_rs", get_secret_value_v1_rs) + assert get_secret_value_v1_rs["VersionStages"] == ["AWSCURRENT"] + + get_secret_value_v2_rs = aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, + VersionId=version_id_v2, + ) + sm_snapshot.match("get_secret_value_v2_rs", get_secret_value_v2_rs) + assert get_secret_value_v2_rs["VersionStages"] == ["PENDING"] + + update_secret_version_stage_res_1 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=version_id_v1, + MoveToVersionId=version_id_v2, + VersionStage="AWSCURRENT", + ) + sm_snapshot.match("update_secret_version_stage_res_1", update_secret_version_stage_res_1) + + get_secret_value_v1_rs_1 = aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, + VersionId=version_id_v1, + ) + sm_snapshot.match("get_secret_value_v1_rs_1", get_secret_value_v1_rs_1) + assert get_secret_value_v1_rs_1["VersionStages"] == ["AWSPREVIOUS"] + + get_secret_value_v2_rs_1 = aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, + VersionId=version_id_v2, + ) + sm_snapshot.match("get_secret_value_v2_rs_1", get_secret_value_v2_rs_1) + assert sorted(get_secret_value_v2_rs_1["VersionStages"]) == sorted( + ["AWSCURRENT", "PENDING"] + ) + + update_secret_version_stage_res_2 = aws_client.secretsmanager.update_secret_version_stage( + SecretId=secret_name, + RemoveFromVersionId=version_id_v2, + VersionStage="PENDING", + ) + sm_snapshot.match("update_secret_version_stage_res_2", update_secret_version_stage_res_2) + + get_secret_value_v1_rs_2 = aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, + VersionId=version_id_v1, + ) + sm_snapshot.match("get_secret_value_v1_rs_2", get_secret_value_v1_rs_2) + assert get_secret_value_v1_rs_2["VersionStages"] == ["AWSPREVIOUS"] + + get_secret_value_v2_rs_2 = aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, + VersionId=version_id_v2, + ) + sm_snapshot.match("get_secret_value_v2_rs_2", get_secret_value_v2_rs_2) + assert get_secret_value_v2_rs_2["VersionStages"] == ["AWSCURRENT"] + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_non_versioning_version_stages_replacement(self, sm_snapshot, secret_name, aws_client): + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="S0" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + put_secret_value_res_0 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S1", VersionStages=["one", "two", "three"] + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_res_0) + + list_secret_version_ids_res_0 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_0", list_secret_version_ids_res_0) + + put_secret_value_res_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, + SecretString="S2", + VersionStages=["one", "two", "three", "four"], + ) + sm_snapshot.match("put_secret_value_res_1", put_secret_value_res_1) + + list_secret_version_ids_res_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_1", list_secret_version_ids_res_1) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @markers.snapshot.skip_snapshot_verify(paths=["$..KmsKeyId", "$..KmsKeyIds"]) + @markers.aws.validated + def test_non_versioning_version_stages_no_replacement( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_rs_0 = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="S0" + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_rs_0, 0) + ) + sm_snapshot.match("create_secret_rs_0", create_secret_rs_0) + + put_secret_value_res_0 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S1", VersionStages=["one", "two", "three"] + ) + sm_snapshot.match("put_secret_value_res_0", put_secret_value_res_0) + + list_secret_version_ids_res_0 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_0", list_secret_version_ids_res_0) + + put_secret_value_res_1 = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString="S2", VersionStages=["one", "two", "four"] + ) + sm_snapshot.match("put_secret_value_res_1", put_secret_value_res_1) + + list_secret_version_ids_res_1 = aws_client.secretsmanager.list_secret_version_ids( + SecretId=secret_name + ) + sm_snapshot.match("list_secret_version_ids_res_1", list_secret_version_ids_res_1) + + delete_secret_res_0 = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res_0", delete_secret_res_0) + + @staticmethod + def secretsmanager_http_json_headers(amz_target: str) -> dict: + headers = mock_aws_request_headers( + "secretsmanager", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=TEST_AWS_REGION_NAME, + ) + headers["X-Amz-Target"] = amz_target + return headers + + def secretsmanager_http_json_post(self, amz_target: str, http_body: json) -> requests.Response: + ep_url: str = aws_stack.get_local_service_url("secretsmanager") + http_headers: dict = self.secretsmanager_http_json_headers(amz_target) + return requests.post(ep_url, headers=http_headers, data=json.dumps(http_body)) + + def secretsmanager_http_create_secret_string( + self, secret_name: str, secret_string: str + ) -> requests.Response: + http_body: json = {"Name": secret_name, "SecretString": secret_string} + return self.secretsmanager_http_json_post("secretsmanager.CreateSecret", http_body) + + @staticmethod + def secretsmanager_http_create_secret_string_val_res( + res: requests.Response, secret_name: str + ) -> json: + assert res.status_code == 200 + res_json: json = res.json() + assert res_json["Name"] == secret_name + return res_json + + def secretsmanager_http_delete_secret(self, secret_id: str) -> requests.Response: + http_body: json = {"SecretId": secret_id} + return self.secretsmanager_http_json_post("secretsmanager.DeleteSecret", http_body) + + @staticmethod + def secretsmanager_http_delete_secret_val_res(res: requests.Response, secret_id: str) -> json: + assert res.status_code == 200 + res_json: json = res.json() + assert res_json["Name"] == secret_id + return res_json + + def secretsmanager_http_get_secret_value(self, secret_id: str) -> requests.Response: + http_body: json = {"SecretId": secret_id} + return self.secretsmanager_http_json_post("secretsmanager.GetSecretValue", http_body) + + @staticmethod + def secretsmanager_http_get_secret_value_val_res( + res: requests.Response, secret_name: str, secret_string: str, version_id: str + ) -> json: + assert res.status_code == 200 + res_json: json = res.json() + assert res_json["Name"] == secret_name + assert res_json["SecretString"] == secret_string + assert res_json["VersionId"] == version_id + return res_json + + def secretsmanager_http_get_secret_value_with( + self, secret_id: str, version_stage: str + ) -> requests.Response: + http_body: json = {"SecretId": secret_id, "VersionStage": version_stage} + return self.secretsmanager_http_json_post("secretsmanager.GetSecretValue", http_body) + + @staticmethod + def secretsmanager_http_get_secret_value_with_val_res( + res: requests.Response, + secret_name: str, + secret_string: str, + version_id: str, + version_stage: str, + ) -> json: + res_json = TestSecretsManager.secretsmanager_http_get_secret_value_val_res( + res, secret_name, secret_string, version_id + ) + assert res_json["VersionStages"] == [version_stage] + return res_json + + def secretsmanager_http_list_secret_version_ids(self, secret_id: str) -> requests.Response: + http_body: json = {"SecretId": secret_id} + return self.secretsmanager_http_json_post("secretsmanager.ListSecretVersionIds", http_body) + + @staticmethod + def secretsmanager_http_list_secret_version_ids_val_res( + res: requests.Response, secret_name: str, versions: json + ) -> json: + assert res.status_code == 200 + res_json: json = res.json() + assert res_json["Name"] == secret_name + res_versions: [json] = res_json["Versions"] + assert len(res_versions) == len(versions) + assert len({rv["VersionId"] for rv in res_versions}) == len(res_versions) + assert len({v["VersionId"] for v in versions}) == len(versions) + for version in versions: + vs_in_res: [json] = list( + filter(lambda rv: rv["VersionId"] == version["VersionId"], res_versions) + ) + assert len(vs_in_res) == 1 + v_in_res = vs_in_res[0] + assert v_in_res["VersionStages"] == version["VersionStages"] + return res_json + + def secretsmanager_http_put_secret_value( + self, secret_id: str, secret_string: str + ) -> requests.Response: + http_body: json = { + "SecretId": secret_id, + "SecretString": secret_string, + } + return self.secretsmanager_http_json_post("secretsmanager.PutSecretValue", http_body) + + @staticmethod + def secretsmanager_http_put_secret_value_val_res( + res: requests.Response, secret_name: str + ) -> json: + assert res.status_code == 200 + res_json: json = res.json() + assert res_json["Name"] == secret_name + return res_json + + def secretsmanager_http_put_pending_secret_value( + self, secret_id: str, secret_string: str + ) -> requests.Response: + http_body: json = { + "SecretId": secret_id, + "SecretString": secret_string, + "VersionStages": ["AWSPENDING"], + } + return self.secretsmanager_http_json_post("secretsmanager.PutSecretValue", http_body) + + @staticmethod + def secretsmanager_http_put_pending_secret_value_val_res( + res: requests.Response, secret_name: str + ) -> json: + return TestSecretsManager.secretsmanager_http_put_secret_value_val_res(res, secret_name) + + def secretsmanager_http_put_secret_value_with( + self, secret_id: str, secret_string: str, client_request_token: Optional[str] + ) -> requests.Response: + http_body: json = { + "SecretId": secret_id, + "SecretString": secret_string, + "ClientRequestToken": client_request_token, + } + return self.secretsmanager_http_json_post("secretsmanager.PutSecretValue", http_body) + + @staticmethod + def secretsmanager_http_put_secret_value_with_val_res( + res: requests.Response, secret_name: str, client_request_token: str + ) -> json: + assert res.status_code == 200 + res_json: json = res.json() + assert res_json["Name"] == secret_name + assert res_json["VersionId"] == client_request_token + return res_json + + def secretsmanager_http_update_secret( + self, secret_id: str, secret_string: str, client_request_token: Optional[str] + ): + http_body: json = {"SecretId": secret_id, "SecretString": secret_string} + if client_request_token: + http_body["ClientRequestToken"] = client_request_token + return self.secretsmanager_http_json_post("secretsmanager.UpdateSecret", http_body) + + @staticmethod + def secretsmanager_http_update_secret_val_res( + res: requests.Response, secret_name: str, client_request_token: Optional[str] + ): + assert res.status_code == 200 + res_json: json = res.json() + assert res_json["Name"] == secret_name + if client_request_token: + assert res_json["VersionId"] == client_request_token + return res_json + + def secretsmanager_http_put_secret_value_with_version( + self, + secret_id: str, + secret_string: str, + client_request_token: Optional[str], + version_stages: list[str], + ) -> requests.Response: + http_body: json = { + "SecretId": secret_id, + "SecretString": secret_string, + "ClientRequestToken": client_request_token, + "VersionStages": version_stages, + } + return self.secretsmanager_http_json_post("secretsmanager.PutSecretValue", http_body) + + @staticmethod + def secretsmanager_http_put_secret_value_with_version_val_res( + res: requests.Response, + secret_name: str, + client_request_token: Optional[str], + version_stages: list[str], + ) -> json: + req_version_id: str + if client_request_token is None: + assert res.status_code == 200 + req_version_id = res.json()["VersionId"] + else: + req_version_id = client_request_token + res_json = TestSecretsManager.secretsmanager_http_put_secret_value_with_val_res( + res, secret_name, req_version_id + ) + assert res_json["VersionStages"] == version_stages + return res_json + + @markers.aws.only_localstack # FIXME: all tests using the internal http utils of this class are only targeting localstack + def test_update_secret_with_non_provided_client_request_token(self, aws_client, secret_name): + # Create v0. + secret_string_v0: str = "secret_string_v0" + cr_v0_res_json: json = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=secret_string_v0, + ) + version_id_v0 = cr_v0_res_json["VersionId"] + + # Update with client request token. + secret_string_v1: str = "secret_string_v1" + version_id_v1: str = str(uuid.uuid4()) + self.secretsmanager_http_update_secret_val_res( + self.secretsmanager_http_update_secret(secret_name, secret_string_v1, version_id_v1), + secret_name, + version_id_v1, + ) + + # Get. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v1, + version_id_v1, + ) + + # Update without client request token, the SDK will generate it. + secret_string_v2: str = "secret_string_v2" + res_update_json = aws_client.secretsmanager.update_secret( + SecretId=secret_name, + SecretString=secret_string_v2, + ) + + version_id_v2 = res_update_json["VersionId"] + assert version_id_v2 != version_id_v1 + assert version_id_v2 != version_id_v0 + + # Get. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v2, + version_id_v2, + ) + + @markers.aws.only_localstack # FIXME: all tests using the internal http utils of this class are only targeting localstack + def test_put_secret_value_with_new_custom_client_request_token(self, secret_name, aws_client): + # Create v0. + secret_string_v0: str = "MySecretString" + cr_v0_res_json: json = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=secret_string_v0, + ) + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Update v0 with predefined ClientRequestToken. + secret_string_v1: str = "MyNewSecretString" + # + crt_v1: str = str(uuid.uuid4()) + while crt_v1 == cr_v0_res_json["VersionId"]: + crt_v1 = str(uuid.uuid4()) + # + self.secretsmanager_http_put_secret_value_val_res( + self.secretsmanager_http_put_secret_value_with(secret_name, secret_string_v1, crt_v1), + secret_name, + ) + # + # Check v1 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v1, + crt_v1, + ) + # + # Check versioning base consistency. + versions_v0_v1: json = [ + {"VersionId": cr_v0_res_json["VersionId"], "VersionStages": ["AWSPREVIOUS"]}, + {"VersionId": crt_v1, "VersionStages": ["AWSCURRENT"]}, + ] + self.secretsmanager_http_list_secret_version_ids_val_res( + self.secretsmanager_http_list_secret_version_ids(secret_name), + secret_name, + versions_v0_v1, + ) + + self.secretsmanager_http_delete_secret_val_res( + self.secretsmanager_http_delete_secret(secret_name), secret_name + ) + + @markers.aws.only_localstack # FIXME: all tests using the internal http utils of this class are only targeting localstack + def test_http_put_secret_value_with_duplicate_client_request_token( + self, secret_name, aws_client + ): + # Create v0. + secret_string_v0: str = "MySecretString" + cr_v0_res_json: json = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=secret_string_v0, + ) + + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Update v0 with duplicate ClientRequestToken. + secret_string_v1: str = "MyNewSecretString" + # + crt_v1: str = cr_v0_res_json["VersionId"] + # + self.secretsmanager_http_put_secret_value_val_res( + self.secretsmanager_http_put_secret_value_with(secret_name, secret_string_v1, crt_v1), + secret_name, + ) + # + # Check v1 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v1, + crt_v1, + ) + # + # Check versioning base consistency. + versions_v0_v1: json = [{"VersionId": crt_v1, "VersionStages": ["AWSCURRENT"]}] + self.secretsmanager_http_list_secret_version_ids_val_res( + self.secretsmanager_http_list_secret_version_ids(secret_name), + secret_name, + versions_v0_v1, + ) + + self.secretsmanager_http_delete_secret_val_res( + self.secretsmanager_http_delete_secret(secret_name), secret_name + ) + + @markers.aws.only_localstack # FIXME: all tests using the internal http utils of this class are only targeting localstack + def test_http_put_secret_value_with_non_provided_client_request_token( + self, secret_name, aws_client + ): + # Create v0. + secret_string_v0: str = "MySecretString" + cr_v0_res_json: json = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=secret_string_v0, + ) + + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Update v0 with non-provided ClientRequestToken (the SDK will generate one). + secret_string_v1: str = "MyNewSecretString" + pv_v1_res_json = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, SecretString=secret_string_v1 + ) + # Check v1 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v1, + pv_v1_res_json["VersionId"], + ) + + # Check versioning base consistency. + versions_v0_v1: json = [ + {"VersionId": cr_v0_res_json["VersionId"], "VersionStages": ["AWSPREVIOUS"]}, + {"VersionId": pv_v1_res_json["VersionId"], "VersionStages": ["AWSCURRENT"]}, + ] + self.secretsmanager_http_list_secret_version_ids_val_res( + self.secretsmanager_http_list_secret_version_ids(secret_name), + secret_name, + versions_v0_v1, + ) + + self.secretsmanager_http_delete_secret_val_res( + self.secretsmanager_http_delete_secret(secret_name), secret_name + ) + + @markers.aws.only_localstack # FIXME: all tests using the internal http utils of this class are only targeting localstack + def test_http_put_secret_value_duplicate_req(self, secret_name, aws_client): + # Create v0. + secret_string_v0: str = "MySecretString" + cr_v0_res_json: json = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=secret_string_v0, + ) + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Duplicate update. + self.secretsmanager_http_put_secret_value_val_res( + self.secretsmanager_http_put_secret_value_with( + secret_name, secret_string_v0, cr_v0_res_json["VersionId"] + ), + secret_name, + ) + # + # Check v1 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + # + # Check versioning base consistency. + versions_v0_v1: json = [ + {"VersionId": cr_v0_res_json["VersionId"], "VersionStages": ["AWSCURRENT"]}, + ] + self.secretsmanager_http_list_secret_version_ids_val_res( + self.secretsmanager_http_list_secret_version_ids(secret_name), + secret_name, + versions_v0_v1, + ) + + self.secretsmanager_http_delete_secret_val_res( + self.secretsmanager_http_delete_secret(secret_name), secret_name + ) + + @markers.aws.only_localstack # FIXME: all tests using the internal http utils of this class are only targeting localstack + def test_http_put_secret_value_null_client_request_token_new_version_stages( + self, secret_name, aws_client + ): + # Create v0. + secret_string_v0: str = "MySecretString" + cr_v0_res_json: json = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=secret_string_v0, + ) + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Update v0 with null ClientRequestToken (auto-generated by SDK). + secret_string_v1: str = "MyNewSecretString" + version_stages_v1: list[str] = ["AWSPENDING"] + + pv_v1_res_json = aws_client.secretsmanager.put_secret_value( + SecretId=secret_name, + SecretString=secret_string_v1, + VersionStages=version_stages_v1, + ) + + assert pv_v1_res_json["VersionId"] != cr_v0_res_json["VersionId"] + + # Check v1 base consistency. + self.secretsmanager_http_get_secret_value_with_val_res( + self.secretsmanager_http_get_secret_value_with(secret_name, "AWSPENDING"), + secret_name, + secret_string_v1, + pv_v1_res_json["VersionId"], + "AWSPENDING", + ) + + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Check versioning base consistency. + versions_v0_v1: json = [ + {"VersionId": cr_v0_res_json["VersionId"], "VersionStages": ["AWSCURRENT"]}, + {"VersionId": pv_v1_res_json["VersionId"], "VersionStages": ["AWSPENDING"]}, + ] + self.secretsmanager_http_list_secret_version_ids_val_res( + self.secretsmanager_http_list_secret_version_ids(secret_name), + secret_name, + versions_v0_v1, + ) + + self.secretsmanager_http_delete_secret_val_res( + self.secretsmanager_http_delete_secret(secret_name), secret_name + ) + + @markers.aws.only_localstack # FIXME: all tests using the internal http utils of this class are only targeting localstack + def test_http_put_secret_value_custom_client_request_token_new_version_stages( + self, + secret_name, + aws_client, + ): + # Create v0. + secret_string_v0: str = "MySecretString" + cr_v0_res_json: json = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString=secret_string_v0, + ) + + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Update v0 with new ClientRequestToken. + secret_string_v1: str = "MyNewSecretString" + version_stages_v1: list[str] = ["AWSPENDING"] + crt_v1: str = str(uuid.uuid4()) + while crt_v1 == cr_v0_res_json["VersionId"]: + crt_v1 = str(uuid.uuid4()) + + self.secretsmanager_http_put_secret_value_with_version_val_res( + self.secretsmanager_http_put_secret_value_with_version( + secret_name, secret_string_v1, crt_v1, version_stages_v1 + ), + secret_name, + crt_v1, + version_stages_v1, + ) + + # Check v1 base consistency. + self.secretsmanager_http_get_secret_value_with_val_res( + self.secretsmanager_http_get_secret_value_with(secret_name, "AWSPENDING"), + secret_name, + secret_string_v1, + crt_v1, + "AWSPENDING", + ) + + # Check v0 base consistency. + self.secretsmanager_http_get_secret_value_val_res( + self.secretsmanager_http_get_secret_value(secret_name), + secret_name, + secret_string_v0, + cr_v0_res_json["VersionId"], + ) + + # Check versioning base consistency. + versions_v0_v1: json = [ + {"VersionId": cr_v0_res_json["VersionId"], "VersionStages": ["AWSCURRENT"]}, + {"VersionId": crt_v1, "VersionStages": ["AWSPENDING"]}, + ] + self.secretsmanager_http_list_secret_version_ids_val_res( + self.secretsmanager_http_list_secret_version_ids(secret_name), + secret_name, + versions_v0_v1, + ) + + self.secretsmanager_http_delete_secret_val_res( + self.secretsmanager_http_delete_secret(secret_name), secret_name + ) + + @markers.aws.validated + def test_delete_non_existent_secret_returns_as_if_secret_exists(self, secret_name, aws_client): + """When ForceDeleteWithoutRecovery=True, AWS responds as if the non-existent secret was successfully deleted.""" + response = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + + assert response["Name"] == secret_name + assert response["ARN"] is not None + assert response["DeletionDate"] is not None + + @markers.aws.validated + def test_exp_raised_on_creation_of_secret_scheduled_for_deletion( + self, sm_snapshot, secret_name, aws_client + ): + create_secret_req: CreateSecretRequest = CreateSecretRequest( + Name=secret_name, SecretString=f"secretstr-{short_uid()}" + ) + stage_deletion_req: DeleteSecretRequest = DeleteSecretRequest( + SecretId=create_secret_req["Name"], RecoveryWindowInDays=7 + ) + + res = aws_client.secretsmanager.create_secret(**create_secret_req) + create_secret_res: CreateSecretResponse = select_from_typed_dict(CreateSecretResponse, res) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_res, 0) + ) + + res = aws_client.secretsmanager.delete_secret(**stage_deletion_req) + delete_res: DeleteSecretResponse = select_from_typed_dict(DeleteSecretResponse, res) + sm_snapshot.match("delete_res", delete_res) + + with pytest.raises(Exception) as invalid_req_ex: + aws_client.secretsmanager.create_secret(**create_secret_req) + sm_snapshot.match("invalid_req_ex", invalid_req_ex.value.response) + + @markers.aws.validated + def test_can_recreate_delete_secret(self, sm_snapshot, secret_name, aws_client): + # NOTE: AWS will behave as staged deletion for a small number of seconds (<10). + # We assume forced deletion is instantaneous, until the precise behaviour is understood. + + create_secret_req: CreateSecretRequest = CreateSecretRequest( + Name=secret_name, SecretString=f"secretstr-{short_uid()}" + ) + stage_deletion_req: DeleteSecretRequest = DeleteSecretRequest( + SecretId=create_secret_req["Name"], ForceDeleteWithoutRecovery=True + ) + + res = aws_client.secretsmanager.create_secret(**create_secret_req) + create_secret_res_0: CreateSecretResponse = select_from_typed_dict( + CreateSecretResponse, res + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_res_0, 0) + ) + sm_snapshot.match("create_secret_res_0", create_secret_res_0) + + res = aws_client.secretsmanager.delete_secret(**stage_deletion_req) + delete_res_1: DeleteSecretResponse = select_from_typed_dict(DeleteSecretResponse, res) + sm_snapshot.match("delete_res_1", delete_res_1) + + self._wait_force_deletion_completed( + aws_client.secretsmanager, stage_deletion_req["SecretId"] + ) + + res = aws_client.secretsmanager.create_secret(**create_secret_req) + create_secret_res_1: CreateSecretResponse = select_from_typed_dict( + CreateSecretResponse, res + ) + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_res_1, 1) + ) + sm_snapshot.match("create_secret_res_1", create_secret_res_1) + + aws_client.secretsmanager.delete_secret(**stage_deletion_req) + + @markers.aws.validated + def test_secret_exists(self, secret_name, aws_client): + description = "Testing secret already exists." + rs = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret_{}".format(secret_name), + Description=description, + ) + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name) + secret_arn = rs["ARN"] + secret_id = rs["Name"] + assert len(secret_arn.rpartition("-")[-1]) == 6 + + ls = aws_client.secretsmanager.get_paginator("list_secrets").paginate().build_full_result() + secrets = { + secret["Name"]: secret["ARN"] + for secret in ls["SecretList"] + if secret["Name"] == secret_name + } + assert len(secrets.keys()) == 1 + assert secret_arn in secrets.values() + + with pytest.raises( + aws_client.secretsmanager.exceptions.ResourceExistsException + ) as res_exists_ex: + aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret_{}".format(secret_name), + Description=description, + ) + assert res_exists_ex.typename == "ResourceExistsException" + assert res_exists_ex.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert ( + res_exists_ex.value.response["Error"]["Message"] + == f"The operation failed because the secret {secret_id} already exists." + ) + + @markers.aws.validated + def test_secret_exists_snapshots(self, secret_name, sm_snapshot, cleanups, aws_client): + description = "Snapshot testing secret already exists." + rs = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret_{}".format(secret_name), + Description=description, + ) + self._wait_created_is_listed(aws_client.secretsmanager, secret_id=secret_name) + sm_snapshot.add_transformers_list(sm_snapshot.transform.secretsmanager_secret_id_arn(rs, 0)) + + with pytest.raises( + aws_client.secretsmanager.exceptions.ResourceExistsException + ) as res_exists_ex: + aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="my_secret_{}".format(secret_name), + Description=description, + ) + sm_snapshot.match("ex_log", res_exists_ex.value.response) + + # clean up + delete_secret_res = aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("delete_secret_res", delete_secret_res) + + @markers.aws.validated + @pytest.mark.parametrize( + "operation", + [ + "CreateSecret", + "UpdateSecret", + "RotateSecret", + "PutSecretValue", + ], + ) + def test_no_client_request_token( + self, aws_client, sm_snapshot, cleanups, aws_http_client_factory, operation + ): + # https://docs.aws.amazon.com/cli/latest/reference/secretsmanager/create-secret.html#options + secret_name = short_uid() + # we should need to clean up but better safe than sorry + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + + client = aws_http_client_factory("secretsmanager", signer_factory=SigV4Auth) + # When using the SDK or CLI, it will automatically create and add ClientRequestToken to your request + # try to not append it to see what exception AWS returns + parameters = {"SecretString": "thisisthesecret", "Description": "My secret string"} + if operation == "CreateSecret": + parameters["Name"] = secret_name + else: + parameters["SecretId"] = secret_name + + headers = { + "X-Amz-Target": f"secretsmanager.{operation}", + "Content-Type": "application/x-amz-json-1.1", + } + + response = client.post("/", data=json.dumps(parameters), headers=headers) + exc_response = {"Error": response.json(), "Metadata": {"StatusCode": response.status_code}} + + sm_snapshot.match("no-client-request-exc", exc_response) + + @markers.aws.validated + def test_create_secret_version_from_empty_secret(self, aws_client, snapshot, cleanups): + snapshot.add_transformer(snapshot.transform.resource_name("secret-version"), priority=-1) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + + response = aws_client.secretsmanager.create_secret( + Name=f"test-version-{short_uid()}", Description="" + ) + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_id, ForceDeleteWithoutRecovery=True + ) + ) + snapshot.match("create-empty-secret", response) + secret_id = response["ARN"] + + response = aws_client.secretsmanager.describe_secret(SecretId=secret_id) + snapshot.match("describe-secret", response) + + response = aws_client.secretsmanager.put_secret_value( + SecretId=secret_id, SecretString="example-string-to-protect" + ) + snapshot.match("put-secret-value", response) + + @markers.aws.validated + def test_secret_tags(self, aws_client, create_secret, sm_snapshot, cleanups): + secret_name = short_uid() + response = create_secret( + Name=secret_name, + ) + + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(response, 0) + ) + sm_snapshot.match("create_secret", response) + + secret_arn = response["ARN"] + + describe_secret = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + sm_snapshot.match("describe_secret", describe_secret) + + tag_resource_1 = aws_client.secretsmanager.tag_resource( + SecretId=secret_arn, Tags=[{"Key": "tag1", "Value": "value1"}] + ) + sm_snapshot.match("tag_resource_1", tag_resource_1) + + describe_secret_1 = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + sm_snapshot.match("describe_secret_1", describe_secret_1) + + tag_resource_2 = aws_client.secretsmanager.tag_resource( + SecretId=secret_arn, Tags=[{"Key": "tag2", "Value": "value2"}] + ) + sm_snapshot.match("tag_resource_2", tag_resource_2) + + describe_secret_2 = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + sm_snapshot.match("describe_secret_2", describe_secret_2) + + untag_resource_1 = aws_client.secretsmanager.untag_resource( + SecretId=secret_arn, TagKeys=["tag1"] + ) + sm_snapshot.match("untag_resource_1", untag_resource_1) + + describe_secret_3 = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + sm_snapshot.match("describe_secret_3", describe_secret_3) + + untag_resource_2 = aws_client.secretsmanager.untag_resource( + SecretId=secret_arn, TagKeys=["tag2"] + ) + sm_snapshot.match("untag_resource_2", untag_resource_2) + + describe_secret_4 = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + sm_snapshot.match("describe_secret_4", describe_secret_4) + + aws_client.secretsmanager.tag_resource( + SecretId=secret_arn, + Tags=[{"Key": "tag3", "Value": "value3"}, {"Key": "tag4", "Value": "value4"}], + ) + + describe_secret_5 = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + sm_snapshot.match("describe_secret_5", describe_secret_5) + + aws_client.secretsmanager.untag_resource(SecretId=secret_arn, TagKeys=["tag3", "tag4"]) + + describe_secret_6 = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + sm_snapshot.match("describe_secret_6", describe_secret_6) + + @markers.aws.validated + def test_get_secret_value_errors(self, aws_client, create_secret, sm_snapshot): + secret_name = short_uid() + response = create_secret( + Name=secret_name, + SecretString="test", + ) + version_id = response["VersionId"] + + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(response, 0) + ) + + secret_arn = response["ARN"] + + with pytest.raises(ClientError) as ex: + aws_client.secretsmanager.get_secret_value( + SecretId=secret_arn, VersionStage="AWSPREVIOUS" + ) + sm_snapshot.match("error_get_secret_value_non_existing", ex.value.response) + + with pytest.raises(ClientError) as exc: + aws_client.secretsmanager.get_secret_value( + SecretId=secret_name, VersionId=version_id, VersionStage="AWSPREVIOUS" + ) + sm_snapshot.match("mismatch_version_id_and_stage", exc.value.response) + + @markers.aws.validated + def test_get_secret_value( + self, aws_client, aws_http_client_factory, region_name, create_secret, sm_snapshot + ): + """ + This is a regession test for #11319 + AWS returns decoded value when fetching secret from a SDK + but AWS returns base64 encoded value for a plain HTTP API request + This tests tries to verify both of these behaviours. + """ + secret_name = short_uid() + secret_string = "footest" + + response = create_secret( + Name=secret_name, + SecretBinary=secret_string, + ) + + sm_snapshot.add_transformers_list( + sm_snapshot.transform.secretsmanager_secret_id_arn(response, 0) + ) + + secret_arn = response["ARN"] + + # Testing from Boto client + + secret_value_response = aws_client.secretsmanager.get_secret_value(SecretId=secret_arn) + + sm_snapshot.match("secret_value_response", secret_value_response) + + # Testing as HTTP request + + client = aws_http_client_factory( + "secretsmanager", region=region_name, signer_factory=SigV4Auth + ) + parameters = {"SecretId": secret_name} + + headers = { + "X-Amz-Target": "secretsmanager.GetSecretValue", + "Content-Type": "application/x-amz-json-1.1", + } + + response = client.post("/", data=json.dumps(parameters), headers=headers) + json_response = response.json() + + sm_snapshot.add_transformer( + TransformerUtility.jsonpath("$..CreatedDate", "datetime", reference_replacement=False) + ) + sm_snapshot.match("secret_value_http_response", json_response) + + @markers.aws.only_localstack + def test_create_secret_with_custom_id( + self, account_id, region_name, create_secret, set_resource_custom_id + ): + secret_name = short_uid() + custom_id = "TestID" + set_resource_custom_id( + SecretsManagerSecretIdentifier( + account_id=account_id, region=region_name, secret_id=secret_name + ), + custom_id, + ) + + secret = create_secret(Name=secret_name, SecretBinary="test-secret") + + assert secret["ARN"].split(":")[-1] == "-".join((secret_name, custom_id)) + + @markers.aws.validated + def test_force_delete_deleted_secret(self, sm_snapshot, secret_name, aws_client): + """Test if a deleted secret can be force deleted afterwards.""" + create_secret_response = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString=f"secretstr-{short_uid()}" + ) + sm_snapshot.match("create_secret_response", create_secret_response) + secret_id = create_secret_response["ARN"] + + sm_snapshot.add_transformer( + sm_snapshot.transform.secretsmanager_secret_id_arn(create_secret_response, 0) + ) + + delete_secret_response = aws_client.secretsmanager.delete_secret(SecretId=secret_id) + sm_snapshot.match("delete_secret_response", delete_secret_response) + + describe_secret_response = aws_client.secretsmanager.describe_secret(SecretId=secret_id) + sm_snapshot.match("describe_secret_response", describe_secret_response) + + force_delete_secret_response = aws_client.secretsmanager.delete_secret( + SecretId=secret_id, ForceDeleteWithoutRecovery=True + ) + sm_snapshot.match("force_delete_secret_response", force_delete_secret_response) + + self._wait_force_deletion_completed(aws_client.secretsmanager, secret_id) + + +class TestSecretsManagerMultiAccounts: + @markers.aws.validated + def test_cross_account_access(self, aws_client, secondary_aws_client, cleanups): + # GetSecretValue and PutSecretValue can't be used if the default keys are used + principal_arn = secondary_aws_client.sts.get_caller_identity()["Arn"] + resource_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": principal_arn}, + "Action": ["secretsmanager:*"], + "Resource": "*", + } + ], + } + + secret_name = f"test-secret-{short_uid()}" + secret_arn = aws_client.secretsmanager.create_secret( + Name=secret_name, + SecretString="secret", + )["ARN"] + + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + + aws_client.secretsmanager.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(resource_policy) + ) + + # try to access the secret from the secondary account without the resource policy + response = secondary_aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + assert response["ARN"] == secret_arn + + kms_default_key_error = ( + "You can't access a secret from a different AWS account if you encrypt the secret " + "with the default KMS service key." + ) + + with pytest.raises(Exception) as ex: + secondary_aws_client.secretsmanager.get_secret_value(SecretId=secret_arn) + assert ex.value.response["Error"]["Code"] == "InvalidRequestException" + assert ex.value.response["Error"]["Message"] == kms_default_key_error + + with pytest.raises(Exception) as ex: + secondary_aws_client.secretsmanager.put_secret_value( + SecretId=secret_arn, SecretString="new-secret" + ) + assert ex.value.response["Error"]["Code"] == "InvalidRequestException" + assert ex.value.response["Error"]["Message"] == kms_default_key_error + + # try to add resource policy from the secondary account + policy = resource_policy + policy["Statement"][0]["Sid"] = "AllowCrossAccount" + secondary_aws_client.secretsmanager.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(policy) + ) + + # try to get the resource policy from the secondary account + response = secondary_aws_client.secretsmanager.get_resource_policy(SecretId=secret_arn) + assert json.loads(response["ResourcePolicy"])["Statement"][0]["Sid"] == "AllowCrossAccount" + + # try to access the secret version ids from the secondary account + response = secondary_aws_client.secretsmanager.list_secret_version_ids(SecretId=secret_arn) + assert len(response["Versions"]) == 1 + + # should not list the secret from the primary account + response = secondary_aws_client.secretsmanager.list_secrets() + assert len(response["SecretList"]) == 0 + + # set tags from the secondary account + secondary_aws_client.secretsmanager.tag_resource( + SecretId=secret_arn, Tags=[{"Key": "tag1", "Value": "value1"}] + ) + + # get tags from the primary account + response = aws_client.secretsmanager.describe_secret(SecretId=secret_arn) + assert response["Tags"] == [{"Key": "tag1", "Value": "value1"}] + + # set tags from the secondary account + secondary_aws_client.secretsmanager.untag_resource(SecretId=secret_arn, TagKeys=["tag1"]) + + # get tags from the primary account + # Note: when removing tags, the response will be empty list in case of AWS, + # but it will be None in Localstack. To avoid failing the test, we will use the default value as list + assert poll_condition( + lambda: aws_client.secretsmanager.describe_secret(SecretId=secret_arn).get("Tags", []) + == [], + timeout=5.0, + interval=0.5, + ) + + secondary_aws_client.secretsmanager.delete_secret( + SecretId=secret_arn, ForceDeleteWithoutRecovery=True + ) + + @markers.aws.validated + def test_cross_account_access_non_default_key(self, aws_client, secondary_aws_client, cleanups): + # GetSecretValue and PutSecretValue can't be used if the default keys are used + primary_identity = aws_client.sts.get_caller_identity() + primary_principal = primary_identity["Arn"] + primary_account_id = primary_identity["Account"] + secondary_principal = secondary_aws_client.sts.get_caller_identity()["Arn"] + + resource_policy_secretsmanager = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": secondary_principal}, + "Action": ["secretsmanager:*"], + "Resource": "*", + } + ], + } + + kms_policy_document = """{ + "Version": "2012-10-17", + "Id": "key-default-1", + "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::%s:root" + }, + "Action": "kms:*", + "Resource": "*" + }, + { + "Sid": "Allow administration of the key", + "Effect": "Allow", + "Principal": {"AWS": "%s"}, + "Action": "kms:*", + "Resource": "*" + }, + { + "Sid": "Allow use of the key", + "Effect": "Allow", + "Principal": {"AWS": "%s"}, + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ], + "Resource": "*" + } + ] + }""" + secret_name = f"test-secret-{short_uid()}" + + kms_policy = kms_policy_document % ( + primary_account_id, + primary_principal, + secondary_principal, + ) + key_arn = aws_client.kms.create_key( + Description="test-key", + Policy=kms_policy, + )["KeyMetadata"]["Arn"] + cleanups.append( + lambda: aws_client.kms.schedule_key_deletion(KeyId=key_arn, PendingWindowInDays=7) + ) + + secret_arn = aws_client.secretsmanager.create_secret( + Name=secret_name, SecretString="secret", KmsKeyId=key_arn + )["ARN"] + cleanups.append( + lambda: aws_client.secretsmanager.delete_secret( + SecretId=secret_name, ForceDeleteWithoutRecovery=True + ) + ) + aws_client.secretsmanager.put_resource_policy( + SecretId=secret_arn, ResourcePolicy=json.dumps(resource_policy_secretsmanager) + ) + + response = secondary_aws_client.secretsmanager.get_secret_value(SecretId=secret_arn) + assert response["SecretString"] == "secret" + + secondary_aws_client.secretsmanager.put_secret_value( + SecretId=secret_arn, SecretString="new-secret" + ) + + response = secondary_aws_client.secretsmanager.get_secret_value(SecretId=secret_arn) + assert response["SecretString"] == "new-secret" + + secondary_aws_client.secretsmanager.delete_secret( + SecretId=secret_arn, ForceDeleteWithoutRecovery=False, RecoveryWindowInDays=7 + ) + + assert poll_condition( + lambda: aws_client.secretsmanager.describe_secret(SecretId=secret_arn).get( + "DeletedDate" + ) + is not None, + timeout=5.0, + interval=0.5, + ) + + secondary_aws_client.secretsmanager.restore_secret(SecretId=secret_arn) + + assert poll_condition( + lambda: aws_client.secretsmanager.describe_secret(SecretId=secret_arn).get( + "DeletedDate" + ) + is None, + timeout=5.0, + interval=0.5, + ) diff --git a/tests/aws/services/secretsmanager/test_secretsmanager.snapshot.json b/tests/aws/services/secretsmanager/test_secretsmanager.snapshot.json new file mode 100644 index 0000000000000..8e52ed68a419c --- /dev/null +++ b/tests/aws/services/secretsmanager/test_secretsmanager.snapshot.json @@ -0,0 +1,4765 @@ +{ + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_can_recreate_delete_secret": { + "recorded-date": "28-09-2022, 10:17:08", + "recorded-content": { + "create_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "" + }, + "delete_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "" + }, + "create_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "" + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_exp_raised_on_creation_of_secret_scheduled_for_deletion": { + "recorded-date": "28-09-2022, 10:16:20", + "recorded-content": { + "delete_res": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "" + }, + "invalid_req_ex": { + "Error": { + "Code": "InvalidRequestException", + "Message": "You can't create this secret because a secret with this name is already scheduled for deletion." + }, + "Message": "You can't create this secret because a secret with this name is already scheduled for deletion.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[s-c64bdc03-True]": { + "recorded-date": "28-09-2022, 09:53:29", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-True]": { + "recorded-date": "28-09-2022, 09:53:30", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "Name": "Valid/_+=.@-Name", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "Name": "Valid/_+=.@-Name", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "Name": "Valid/_+=.@-Name", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "DeletionDate": "datetime", + "Name": "Valid/_+=.@-Name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2-True]": { + "recorded-date": "28-09-2022, 09:53:32", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "DeletionDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2c3--True]": { + "recorded-date": "28-09-2022, 09:53:33", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "DeletionDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Invalid/_+=.@-Name-a1b2c3-False]": { + "recorded-date": "28-09-2022, 09:53:35", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "CreatedDate": "datetime", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "CreatedDate": "datetime", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resource_not_found_dict_1": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Secrets Manager can't find the specified secret." + }, + "Message": "Secrets Manager can't find the specified secret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "CreatedDate": "datetime", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Invalid/_+=.@-Name-a1b2c3", + "DeletionDate": "datetime", + "Name": "Invalid/_+=.@-Name-a1b2c3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_call_lists_secrets_multiple_times": { + "recorded-date": "09-08-2022, 16:47:36", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "" + }, + "list_secrets_res_0": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing creation of secrets", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ] + }, + "list_secrets_res_1": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing creation of secrets", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ] + }, + "list_secrets_res_2": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing creation of secrets", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ] + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "" + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_multi_secrets": { + "recorded-date": "09-08-2022, 16:48:10", + "recorded-content": { + "list_secrets_res": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secrets creation.", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + }, + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secrets creation.", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + }, + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secrets creation.", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ] + }, + "delete_secret_res0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "" + }, + "delete_secret_res1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "" + }, + "delete_secret_res2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "" + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_put_secret_value_with_version_stages": { + "recorded-date": "28-09-2022, 10:10:50", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "secret_string_v0", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "SAMPLESTAGE0", + "SAMPLESTAGE1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "secret_string_v0", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "SAMPLESTAGE0", + "SAMPLESTAGE1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "secret_string_v0", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "secret_string_v0", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "secret_string_v4", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[Inv Name]": { + "recorded-date": "28-09-2022, 10:11:22", + "recorded-content": { + "ex_log_1": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_2": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_3": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_4": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_5": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_6": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_7": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_8": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_9": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_10": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv Name]": { + "recorded-date": "28-09-2022, 10:11:27", + "recorded-content": { + "ex_log_1": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_2": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_3": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_4": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_5": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_6": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_7": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_8": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_9": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_10": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv*Name? ]": { + "recorded-date": "28-09-2022, 10:11:32", + "recorded-content": { + "ex_log_1": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_2": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_3": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_4": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_5": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_6": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_7": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_8": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_9": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_10": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv *?!]Name\\\\-]": { + "recorded-date": "28-09-2022, 10:11:37", + "recorded-content": { + "ex_log_1": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_2": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_3": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_4": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_5": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_6": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_7": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_8": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_9": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "ex_log_10": { + "Error": { + "Code": "ValidationException", + "Message": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@!" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_description": { + "recorded-date": "28-09-2022, 10:12:12", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "MyDescription", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "MyNewDescription", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ], + "": [ + "AWSPREVIOUS" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "MyNewDescription", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSPREVIOUS" + ], + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_res_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "MyNewDescription", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSPREVIOUS" + ], + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_return_type": { + "recorded-date": "28-09-2022, 10:12:32", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_previous": { + "recorded-date": "28-09-2022, 10:13:01", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending": { + "recorded-date": "28-09-2022, 10:13:21", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle": { + "recorded-date": "28-09-2022, 10:13:41", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S1", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPENDING" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S3", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_1": { + "recorded-date": "28-09-2022, 10:14:08", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "AWSSOMETHING", + "PUT1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "AWSSOMETHING", + "PUT1" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S1", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "AWSSOMETHING", + "PUT1" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "AWSSOMETHING", + "PUT1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "PUT2" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSSOMETHING", + "PUT1" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "PUT2" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSSOMETHING", + "PUT1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS", + "AWSSOMETHING", + "PUT1" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "PUT2" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S3", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "PUT2" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_2": { + "recorded-date": "28-09-2022, 10:14:32", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "PUT0" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "PUT0" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "PUT1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "PUT0" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "PUT1" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S1", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "PUT0" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS", + "PUT0" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "PUT1" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "PUT1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "PUT2" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS", + "PUT0" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "PUT1" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPENDING", + "PUT2" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "PUT1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "PUT0" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS", + "PUT1" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "PUT2" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_res_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S3", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "AWSPENDING", + "PUT2" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_non_versioning_version_stages_replacement": { + "recorded-date": "28-09-2022, 10:14:57", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "one", + "three", + "two" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "one", + "three", + "two" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "four", + "one", + "three", + "two" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "four", + "one", + "three", + "two" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_non_versioning_version_stages_no_replacement": { + "recorded-date": "28-09-2022, 10:15:24", + "recorded-content": { + "create_secret_rs_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "one", + "three", + "two" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "one", + "three", + "two" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "four", + "one", + "two" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "three" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "four", + "one", + "two" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[s-4b175f02-True]": { + "recorded-date": "02-09-2022, 16:01:14", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "" + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "" + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "" + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_call_lists_secrets_multiple_times_snapshots": { + "recorded-date": "28-09-2022, 10:06:28", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secrets_res_0": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing creation of secrets", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secrets_res_1": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing creation of secrets", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secrets_res_2": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing creation of secrets", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_multi_secrets_snapshot": { + "recorded-date": "28-09-2022, 10:10:00", + "recorded-content": { + "list_secrets_res": { + "SecretList": [ + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secrets creation.", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + }, + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secrets creation.", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + }, + { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secrets creation.", + "LastChangedDate": "datetime", + "Name": "", + "SecretVersionsToStages": { + "": [ + "AWSCURRENT" + ] + } + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_not_found": { + "recorded-date": "28-09-2022, 10:05:07", + "recorded-content": { + "get_secret_value_not_found_ex": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Secrets Manager can't find the specified secret." + }, + "Message": "Secrets Manager can't find the specified secret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_secret_version_ids_not_found_ex": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Secrets Manager can't find the specified secret." + }, + "Message": "Secrets Manager can't find the specified secret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_exists_snapshots": { + "recorded-date": "27-10-2022, 17:29:07", + "recorded-content": { + "ex_log": { + "Error": { + "Code": "ResourceExistsException", + "Message": "The operation failed because the secret already exists." + }, + "Message": "The operation failed because the secret already exists.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete_secret_res": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[s-c64bdc03]": { + "recorded-date": "14-04-2023, 16:43:13", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name]": { + "recorded-date": "14-04-2023, 16:43:15", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "Name": "Valid/_+=.@-Name", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "Name": "Valid/_+=.@-Name", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "Name": "Valid/_+=.@-Name", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name", + "DeletionDate": "datetime", + "Name": "Valid/_+=.@-Name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2]": { + "recorded-date": "14-04-2023, 16:43:18", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "Name": "Valid/_+=.@-Name-a1b2", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2", + "DeletionDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2c3-]": { + "recorded-date": "14-04-2023, 16:43:20", + "recorded-content": { + "create_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Description": "Testing secret creation.", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_3": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "my_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_rs_4": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "CreatedDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "SecretString": "new_secret", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:Valid/_+=.@-Name-a1b2c3-", + "DeletionDate": "datetime", + "Name": "Valid/_+=.@-Name-a1b2c3-", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[CreateSecret]": { + "recorded-date": "12-05-2023, 12:09:22", + "recorded-content": { + "no-client-request-exc": { + "Error": { + "Message": "You must provide a ClientRequestToken value. We recommend a UUID-type value.", + "__type": "InvalidRequestException" + }, + "Metadata": { + "StatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[UpdateSecret]": { + "recorded-date": "12-05-2023, 12:09:23", + "recorded-content": { + "no-client-request-exc": { + "Error": { + "Message": "You must provide a ClientRequestToken value. We recommend a UUID-type value.", + "__type": "InvalidRequestException" + }, + "Metadata": { + "StatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[RotateSecret]": { + "recorded-date": "12-05-2023, 12:09:23", + "recorded-content": { + "no-client-request-exc": { + "Error": { + "Message": "You must provide a ClientRequestToken value. We recommend a UUID-type value.", + "__type": "InvalidParameterException" + }, + "Metadata": { + "StatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[PutSecretValue]": { + "recorded-date": "12-05-2023, 12:09:24", + "recorded-content": { + "no-client-request-exc": { + "Error": { + "Message": "You must provide a ClientRequestToken value. We recommend a UUID-type value.", + "__type": "InvalidRequestException" + }, + "Metadata": { + "StatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_secret_version_from_empty_secret": { + "recorded-date": "05-09-2023, 22:19:06", + "recorded-content": { + "create-empty-secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe-secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put-secret-value": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_with_lambda_success[True]": { + "recorded-date": "30-03-2025, 11:45:42", + "recorded-content": { + "rotate_secret_immediately": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rotated": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing rotation of secrets", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "LastRotatedDate": "datetime", + "Name": "", + "NextRotationDate": "datetime", + "RotationEnabled": true, + "RotationLambdaARN": "", + "RotationRules": { + "AutomaticallyAfterDays": 1 + }, + "VersionIdsToStages": { + "": [ + "AWSPREVIOUS" + ], + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_versions_rotated_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_with_lambda_success[None]": { + "recorded-date": "30-03-2025, 11:45:54", + "recorded-content": { + "rotate_secret_immediately": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rotated": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing rotation of secrets", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "LastRotatedDate": "datetime", + "Name": "", + "NextRotationDate": "datetime", + "RotationEnabled": true, + "RotationLambdaARN": "", + "RotationRules": { + "AutomaticallyAfterDays": 1 + }, + "VersionIdsToStages": { + "": [ + "AWSPREVIOUS" + ], + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_versions_rotated_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_3": { + "recorded-date": "18-01-2024, 04:20:44", + "recorded-content": { + "create_secret_rs": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "PENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_rs": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "PENDING" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_v1_rs": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "SS", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_v2_rs": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S1", + "VersionId": "", + "VersionStages": [ + "PENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_v1_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "SS", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_v2_rs_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S1", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT", + "PENDING" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_secret_version_stage_res_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_v1_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "SS", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_secret_value_v2_rs_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretString": "S1", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_res_0": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_invalid_lambda_arn": { + "recorded-date": "15-03-2024, 09:20:58", + "recorded-content": { + "create_secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "rotate_secret_invalid_arn_exc": { + "Error": { + "Code": "AccessDeniedException", + "Message": "Secrets Manager cannot invoke the specified Lambda function. Ensure that the function policy grants access to the principal secretsmanager.amazonaws.com." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe_secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_resource_policy": { + "recorded-date": "19-03-2024, 14:42:34", + "recorded-content": { + "put_resource_policy": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_resource_policy": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResourcePolicy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "secretsmanager:GetSecretValue", + "Resource": "*" + } + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_restore": { + "recorded-date": "20-03-2024, 13:43:42", + "recorded-content": { + "delete_secret_res": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "restore_secret_res": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_tags": { + "recorded-date": "05-04-2024, 10:40:13", + "recorded-content": { + "create_secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "tag1", + "Value": "value1" + }, + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "tag2", + "Value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_5": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [ + { + "Key": "tag3", + "Value": "value3" + }, + { + "Key": "tag4", + "Value": "value4" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_6": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "Tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_deprecated_secret_version_stage": { + "recorded-date": "29-03-2024, 11:26:17", + "recorded-content": { + "create_secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "" + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "put_secret_value_3": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_4": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_version_ids_5": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "" + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "" + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_secret_value_errors": { + "recorded-date": "11-04-2024, 05:37:47", + "recorded-content": { + "error_get_secret_value_non_existing": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Secrets Manager can't find the specified secret value for staging label: AWSPREVIOUS" + }, + "Message": "Secrets Manager can't find the specified secret value for staging label: AWSPREVIOUS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "mismatch_version_id_and_stage": { + "Error": { + "Code": "InvalidRequestException", + "Message": "You provided a VersionStage that is not associated to the provided VersionId." + }, + "Message": "You provided a VersionStage that is not associated to the provided VersionId.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_version_not_found": { + "recorded-date": "13-06-2024, 08:04:35", + "recorded-content": { + "get_secret_value_no_version_ex": "An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secrets Manager can't find the specified secret value for staging label: AWSCURRENT", + "get_secret_value_version_not_found_ex": "An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secrets Manager can't find the specified secret value for VersionId: ", + "get_secret_value_stage_not_found_ex": "An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secrets Manager can't find the specified secret value for staging label: AWSPENDING" + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_secret_value": { + "recorded-date": "24-09-2024, 11:18:11", + "recorded-content": { + "secret_value_response": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretBinary": "b'footest'", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "secret_value_http_response": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Name": "", + "SecretBinary": "Zm9vdGVzdA==", + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_force_delete_deleted_secret": { + "recorded-date": "11-10-2024, 14:33:45", + "recorded-content": { + "create_secret_response": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_secret_response": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_response": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "DeletedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "force_delete_secret_response": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "DeletionDate": "datetime", + "Name": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_first_rotate_secret_with_missing_lambda_arn": { + "recorded-date": "27-03-2025, 16:33:46", + "recorded-content": { + "create_secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "rotate_secret_no_arn_exc": { + "Error": { + "Code": "InvalidRequestException", + "Message": "No Lambda rotation function ARN is associated with this secret." + }, + "Message": "No Lambda rotation function ARN is associated with this secret.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe_secret": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "LastChangedDate": "datetime", + "Name": "", + "VersionIdsToStages": { + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_multiple_times_with_lambda_success": { + "recorded-date": "29-03-2025, 09:40:15", + "recorded-content": { + "rotate_secret_immediately_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rotated_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing rotation of secrets", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "LastRotatedDate": "datetime", + "Name": "", + "NextRotationDate": "datetime", + "RotationEnabled": true, + "RotationLambdaARN": "", + "RotationRules": { + "AutomaticallyAfterDays": 1 + }, + "VersionIdsToStages": { + "": [ + "AWSPREVIOUS" + ], + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_versions_rotated_1_1": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "rotate_secret_immediately_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "VersionId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_secret_rotated_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "CreatedDate": "datetime", + "Description": "testing rotation of secrets", + "LastAccessedDate": "datetime", + "LastChangedDate": "datetime", + "LastRotatedDate": "datetime", + "Name": "", + "NextRotationDate": "datetime", + "RotationEnabled": true, + "RotationLambdaARN": "", + "RotationRules": { + "AutomaticallyAfterDays": 1 + }, + "VersionIdsToStages": { + "": [ + "AWSPREVIOUS" + ], + "": [ + "AWSCURRENT" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_secret_versions_rotated_1_2": { + "ARN": "arn::secretsmanager::111111111111:secret:", + "Name": "", + "Versions": [ + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "LastAccessedDate": "datetime", + "VersionId": "", + "VersionStages": [ + "AWSPREVIOUS" + ] + }, + { + "CreatedDate": "datetime", + "KmsKeyIds": [ + "DefaultEncryptionKey" + ], + "VersionId": "", + "VersionStages": [ + "AWSCURRENT" + ] + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/secretsmanager/test_secretsmanager.validation.json b/tests/aws/services/secretsmanager/test_secretsmanager.validation.json new file mode 100644 index 0000000000000..d44fb5cb56bc5 --- /dev/null +++ b/tests/aws/services/secretsmanager/test_secretsmanager.validation.json @@ -0,0 +1,167 @@ +{ + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_call_lists_secrets_multiple_times": { + "last_validated_date": "2024-03-15T08:11:53+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_call_lists_secrets_multiple_times_snapshots": { + "last_validated_date": "2022-09-28T08:06:28+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_can_recreate_delete_secret": { + "last_validated_date": "2024-03-15T08:13:48+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2]": { + "last_validated_date": "2024-03-15T08:11:46+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name-a1b2c3-]": { + "last_validated_date": "2024-03-15T08:11:48+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[Valid/_+=.@-Name]": { + "last_validated_date": "2024-03-15T08:11:45+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_and_update_secret[s-c64bdc03]": { + "last_validated_date": "2024-03-15T08:11:43+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_multi_secrets": { + "last_validated_date": "2024-03-15T08:12:00+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_multi_secrets_snapshot": { + "last_validated_date": "2022-09-28T08:10:00+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_create_secret_version_from_empty_secret": { + "last_validated_date": "2024-03-15T08:14:58+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_delete_non_existent_secret_returns_as_if_secret_exists": { + "last_validated_date": "2024-03-15T08:13:15+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_deprecated_secret_version": { + "last_validated_date": "2024-03-29T09:36:11+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_deprecated_secret_version_stage": { + "last_validated_date": "2024-03-29T11:26:17+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_exp_raised_on_creation_of_secret_scheduled_for_deletion": { + "last_validated_date": "2024-03-15T08:13:16+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_first_rotate_secret_with_missing_lambda_arn": { + "last_validated_date": "2025-03-27T16:33:46+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_force_delete_deleted_secret": { + "last_validated_date": "2024-10-11T14:33:45+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_random_exclude_characters_and_symbols": { + "last_validated_date": "2024-03-15T08:12:01+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_secret_value": { + "last_validated_date": "2024-09-24T11:19:00+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_get_secret_value_errors": { + "last_validated_date": "2024-04-11T05:37:47+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv *?!]Name\\\\-]": { + "last_validated_date": "2024-03-15T08:12:44+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv Name]": { + "last_validated_date": "2024-03-15T08:12:35+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[ Inv*Name? ]": { + "last_validated_date": "2024-03-15T08:12:39+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_invalid_secret_name[Inv Name]": { + "last_validated_date": "2024-03-15T08:12:30+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_last_accessed_date": { + "last_validated_date": "2024-03-15T08:12:46+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_last_updated_date": { + "last_validated_date": "2024-03-15T08:12:47+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_list_secrets_filtering": { + "last_validated_date": "2024-04-10T08:25:18+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[CreateSecret]": { + "last_validated_date": "2024-03-15T08:14:56+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[PutSecretValue]": { + "last_validated_date": "2024-03-15T08:14:58+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[RotateSecret]": { + "last_validated_date": "2024-03-15T08:14:57+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_no_client_request_token[UpdateSecret]": { + "last_validated_date": "2024-03-15T08:14:57+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_non_versioning_version_stages_no_replacement": { + "last_validated_date": "2024-03-15T08:13:15+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_non_versioning_version_stages_replacement": { + "last_validated_date": "2024-03-15T08:13:14+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_put_secret_value_with_version_stages": { + "last_validated_date": "2024-03-15T08:12:26+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_resource_policy": { + "last_validated_date": "2024-03-19T14:42:34+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_invalid_lambda_arn": { + "last_validated_date": "2024-03-15T10:11:13+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_multiple_times_with_lambda_success": { + "last_validated_date": "2025-03-29T09:40:15+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_with_lambda_success": { + "last_validated_date": "2024-03-15T08:12:22+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_with_lambda_success[None]": { + "last_validated_date": "2025-03-30T11:45:54+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_rotate_secret_with_lambda_success[True]": { + "last_validated_date": "2025-03-30T11:45:41+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_exists": { + "last_validated_date": "2024-03-15T08:14:33+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_exists_snapshots": { + "last_validated_date": "2024-03-15T08:14:55+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_not_found": { + "last_validated_date": "2024-03-15T08:11:49+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_restore": { + "last_validated_date": "2024-03-20T13:43:42+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_tags": { + "last_validated_date": "2024-04-01T13:21:01+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_secret_version_not_found": { + "last_validated_date": "2024-06-13T08:04:35+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_description": { + "last_validated_date": "2024-03-15T08:12:49+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending": { + "last_validated_date": "2024-03-15T08:13:02+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle": { + "last_validated_date": "2024-03-15T08:13:05+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_1": { + "last_validated_date": "2024-03-15T08:13:07+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_2": { + "last_validated_date": "2024-03-15T08:13:10+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_pending_cycle_custom_stages_3": { + "last_validated_date": "2024-03-15T08:13:13+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_current_previous": { + "last_validated_date": "2024-03-15T08:13:01+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManager::test_update_secret_version_stages_return_type": { + "last_validated_date": "2024-03-15T08:12:50+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManagerMultiAccounts::test_cross_account_access": { + "last_validated_date": "2024-06-12T12:04:52+00:00" + }, + "tests/aws/services/secretsmanager/test_secretsmanager.py::TestSecretsManagerMultiAccounts::test_cross_account_access_non_default_key": { + "last_validated_date": "2024-03-21T10:28:06+00:00" + } +} diff --git a/tests/aws/services/ses/__init__.py b/tests/aws/services/ses/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/ses/test_ses.py b/tests/aws/services/ses/test_ses.py new file mode 100644 index 0000000000000..126edfc717ded --- /dev/null +++ b/tests/aws/services/ses/test_ses.py @@ -0,0 +1,975 @@ +import contextlib +import json +import os +from datetime import date, datetime +from typing import Optional, Tuple + +import pytest +import requests +from botocore.exceptions import ClientError + +import localstack.config as config +from localstack.services.ses.provider import EMAILS, EMAILS_ENDPOINT +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +SAMPLE_TEMPLATE = { + "TemplateName": "hello-world", + "SubjectPart": "Subject test", + "TextPart": "hello\nworld", + "HtmlPart": "hello
world", +} + +SAMPLE_SIMPLE_EMAIL = { + "Subject": { + "Data": "SOME_SUBJECT", + }, + "Body": { + "Text": { + "Data": "SOME_MESSAGE", + }, + "Html": { + "Data": "

SOME_HTML

", + }, + }, +} + + +@pytest.fixture +def create_template(aws_client): + created_template_names = [] + + def _create_template(template): + response = aws_client.ses.create_template(Template=template) + created_template_names.append(template["TemplateName"]) + return response + + yield _create_template + + for name in created_template_names: + aws_client.ses.delete_template(TemplateName=name) + + +@pytest.fixture +def ses_create_receipt_rule_set(aws_client): + receipt_rule_sets = [] + + def _create(**kwargs): + response = aws_client.ses.create_receipt_rule_set(**kwargs) + receipt_rule_sets.append(kwargs.get("RuleSetName")) + return response + + yield _create + + for name in receipt_rule_sets: + with contextlib.suppress(ClientError): + aws_client.ses.delete_receipt_rule_set(RuleSetName=name) + + +@pytest.fixture +def ses_clone_receipt_rule_set(aws_client): + receipt_rule_sets = [] + + def _clone(**kwargs): + response = aws_client.ses.clone_receipt_rule_set(**kwargs) + receipt_rule_sets.append(kwargs.get("RuleSetName")) + return response + + yield _clone + + for name in receipt_rule_sets: + with contextlib.suppress(ClientError): + aws_client.ses.delete_receipt_rule_set(RuleSetName=name) + + +@pytest.fixture +def setup_email_addresses(ses_verify_identity): + """ + If the test is running against AWS then assume the email addresses passed are already + verified, and passes the given email addresses through. Otherwise, it generates two random + email addresses and verifies them. + """ + + def inner( + sender_email_address: Optional[str] = None, recipient_email_address: Optional[str] = None + ) -> Tuple[str, str]: + if is_aws_cloud(): + if sender_email_address is None: + raise ValueError( + "sender_email_address must be specified to run this test against AWS" + ) + if recipient_email_address is None: + raise ValueError( + "recipient_email_address must be specified to run this test against AWS" + ) + else: + # overwrite the given parameters with localstack specific ones + sender_email_address = f"sender-{short_uid()}@example.com" + recipient_email_address = f"recipient-{short_uid()}@example.com" + ses_verify_identity(sender_email_address) + ses_verify_identity(recipient_email_address) + + return sender_email_address, recipient_email_address + + return inner + + +@pytest.fixture +def add_snapshot_transformer_for_sns_event(snapshot): + def _inner(sender_email, recipient_email, config_set_name): + snapshot.add_transformers_list( + [ + snapshot.transform.regex(sender_email, ""), + snapshot.transform.regex(recipient_email, ""), + snapshot.transform.regex(config_set_name, ""), + snapshot.transform.key_value("messageId"), + snapshot.transform.key_value( + "processingTimeMillis", + value_replacement="processing-time", + reference_replacement=False, + ), + snapshot.transform.jsonpath( + "$..Message.mail.tags.'ses:outgoing-ip'[0]", value_replacement="ses-outgoing-ip" + ), + snapshot.transform.jsonpath( + "$..Message.mail.tags.'ses:source-ip'[0]", value_replacement="ses-source-ip" + ), + snapshot.transform.jsonpath( + "$..Message.mail.tags.'ses:caller-identity'[0]", + value_replacement="ses-caller-identity", + ), + snapshot.transform.jsonpath( + "$..Message.mail.tags.'ses:from-domain'[0]", value_replacement="ses-from-domain" + ), + snapshot.transform.jsonpath( + "$..Message.mail.timestamp", + value_replacement="mail-timestamp", + reference_replacement=False, + ), + ] + + snapshot.transform.sns_api() + ) + + return _inner + + +def sort_mail_sqs_messages(message): + if "Successfully validated" in message["Message"]: + return 0 + elif json.loads(message["Message"])["eventType"] == "Send": + return 1 + elif json.loads(message["Message"])["eventType"] == "Delivery": + return 2 + else: + raise ValueError("bad") + + +class TestSES: + @markers.aws.validated + def test_list_templates(self, create_template, aws_client, snapshot): + create_templ = create_template(template=SAMPLE_TEMPLATE) + snapshot.match("create-template", create_templ) + + list_templates = aws_client.ses.list_templates() + snapshot.match("list-templates-1", list_templates) + + assert len(list_templates["TemplatesMetadata"]) == 1 + created_template = list_templates["TemplatesMetadata"][0] + assert created_template["Name"] == SAMPLE_TEMPLATE["TemplateName"] + assert type(created_template["CreatedTimestamp"]) in (date, datetime) + + # Should not fail after 2 consecutive tries + list_templates = aws_client.ses.list_templates() + snapshot.match("list-templates-2", list_templates) + + assert len(list_templates["TemplatesMetadata"]) == 1 + created_template = list_templates["TemplatesMetadata"][0] + assert created_template["Name"] == SAMPLE_TEMPLATE["TemplateName"] + assert type(created_template["CreatedTimestamp"]) in (date, datetime) + + @markers.aws.validated + def test_delete_template(self, create_template, aws_client, snapshot): + list_templates = aws_client.ses.list_templates() + snapshot.match("list-templates-empty", list_templates) + assert len(list_templates["TemplatesMetadata"]) == 0 + + create_template(template=SAMPLE_TEMPLATE) + + list_templates = aws_client.ses.list_templates() + snapshot.match("list-templates-after-create", list_templates) + assert len(list_templates["TemplatesMetadata"]) == 1 + + delete_template = aws_client.ses.delete_template( + TemplateName=SAMPLE_TEMPLATE["TemplateName"] + ) + snapshot.match("delete-template", delete_template) + + list_templates = aws_client.ses.list_templates() + snapshot.match("list-templates-after-delete", list_templates) + assert len(list_templates["TemplatesMetadata"]) == 0 + + @markers.aws.manual_setup_required + def test_get_identity_verification_attributes_for_email( + self, aws_client, snapshot, setup_sender_email_address + ): + email_address = setup_sender_email_address() + response = aws_client.ses.get_identity_verification_attributes(Identities=[email_address]) + verif_attrs = response["VerificationAttributes"][email_address] + assert verif_attrs["VerificationStatus"] == "Success" + assert "VerificationToken" not in verif_attrs + + @markers.aws.needs_fixing + def test_get_identity_verification_attributes_for_domain(self, aws_client): + # need to have access to a domain and its DNS configuration, and to validate it with DKIM on SES console. + domain = "example.com" + response = aws_client.ses.get_identity_verification_attributes(Identities=[domain]) + + verif_attrs = response["VerificationAttributes"] + assert len(verif_attrs) == 1 + assert "Success" == verif_attrs[domain]["VerificationStatus"] + assert "VerificationToken" in verif_attrs[domain] + + @markers.aws.needs_fixing # TODO: couldn't verify on AWS, the Quota would not go up, maybe due to Sandbox account + def test_sent_message_counter( + self, create_template, aws_client, snapshot, setup_email_addresses + ): + # Ensure all email send operations correctly update the `sent` email counter + snapshot.add_transformer( + snapshot.transform.key_value("SentLast24Hours", reference_replacement=False), + ) + + def _assert_sent_quota(expected_counter: int) -> dict: + _send_quota = aws_client.ses.get_send_quota() + assert _send_quota["SentLast24Hours"] == expected_counter + return _send_quota + + retries = 10 if is_aws_cloud() else 1 + + sender_email, recipient_email = setup_email_addresses() + send_quota = aws_client.ses.get_send_quota() + snapshot.match("get-quota-0", send_quota) + counter = send_quota["SentLast24Hours"] + + aws_client.ses.send_email( + Source=sender_email, + Message=SAMPLE_SIMPLE_EMAIL, + Destination={ + "ToAddresses": [recipient_email], + }, + ) + + send_quota = retry( + _assert_sent_quota, expected_counter=counter + 1, retries=retries, sleep=1 + ) + # snapshot.match('get-quota-1', send_quota) + counter = send_quota["SentLast24Hours"] + + create_template(template=SAMPLE_TEMPLATE) + aws_client.ses.send_templated_email( + Source=sender_email, + Template=SAMPLE_TEMPLATE["TemplateName"], + TemplateData='{"A key": "A value"}', + Destination={ + "ToAddresses": [recipient_email, sender_email], + }, + ) + + send_quota = retry( + _assert_sent_quota, expected_counter=counter + 2, retries=retries, sleep=1 + ) + # snapshot.match('get-quota-2', send_quota) + counter = send_quota["SentLast24Hours"] + + raw_message_data = f"From: {sender_email}\nTo: {recipient_email}\nSubject: test\n\nThis is the message body.\n\n" + aws_client.ses.send_raw_email(RawMessage={"Data": raw_message_data}) + + _ = retry(_assert_sent_quota, expected_counter=counter + 1, retries=retries, sleep=1) + # snapshot.match('get-quota-3', _) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Rules..Actions..AddHeaderAction", + "$..Rules..Recipients", + "$..Rules..Recipients", + "$..Rules..Actions..S3Action.KmsKeyArn", + "$..Rules..Actions..S3Action.TopicArn", + ] + ) + def test_clone_receipt_rule_set( + self, + aws_client, + ses_create_receipt_rule_set, + ses_clone_receipt_rule_set, + snapshot, + s3_bucket, + ): + snapshot.add_transformer(snapshot.transform.key_value("BucketName")) + # Test that rule set is cloned properly + # TODO: add some negative testing + original_rule_set_name = "RuleSetToClone" + rule_set_name = "RuleSetToCreate" + rule_names = ["MyRule1", "MyRule2"] + + # Create mock rule set called RuleSetToClone + create_receipt_rule_set = ses_create_receipt_rule_set(RuleSetName=original_rule_set_name) + snapshot.match("create-receipt-rule-set", create_receipt_rule_set) + + # moto does not support AddHeaderAction, added it because it does not need permissions + aws_client.ses.create_receipt_rule( + Rule={ + "Actions": [ + { + "AddHeaderAction": { + "HeaderName": "test-header", + "HeaderValue": "test", + }, + }, + ], + "Enabled": True, + "Name": rule_names[0], + "ScanEnabled": True, + "TlsPolicy": "Optional", + }, + RuleSetName=original_rule_set_name, + ) + + aws_client.s3.put_bucket_policy( + Bucket=s3_bucket, + Policy=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSESPuts", + "Effect": "Allow", + "Principal": {"Service": "ses.amazonaws.com"}, + "Action": "s3:PutObject", + "Resource": f"arn:aws:s3:::{s3_bucket}/*", + } + ], + } + ), + ) + + aws_client.ses.create_receipt_rule( + After=rule_names[0], + Rule={ + "Actions": [ + { + "S3Action": { + "BucketName": s3_bucket, + "ObjectKeyPrefix": "template", + }, + }, + ], + "Enabled": True, + "Name": rule_names[1], + "ScanEnabled": True, + "TlsPolicy": "Optional", + }, + RuleSetName=original_rule_set_name, + ) + + # Clone RuleSetToClone into RuleSetToCreate + clone_receipt_rule_set = ses_clone_receipt_rule_set( + RuleSetName=rule_set_name, OriginalRuleSetName=original_rule_set_name + ) + snapshot.match("clone-receipt-rule-set", clone_receipt_rule_set) + + original_rule_set = aws_client.ses.describe_receipt_rule_set( + RuleSetName=original_rule_set_name + ) + snapshot.match("original-rule-set", original_rule_set) + cloned_rule_set = aws_client.ses.describe_receipt_rule_set(RuleSetName=rule_set_name) + snapshot.match("cloned-rule-set", cloned_rule_set) + + assert original_rule_set["Metadata"]["Name"] == original_rule_set_name + assert cloned_rule_set["Metadata"]["Name"] == rule_set_name + assert cloned_rule_set["Rules"] == original_rule_set["Rules"] + assert [x["Name"] for x in cloned_rule_set["Rules"]] == rule_names + + @markers.aws.manual_setup_required + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Message.delivery.processingTimeMillis", + "$..Message.delivery.reportingMTA", + "$..Message.delivery.smtpResponse", + "$..Message.mail.commonHeaders", + "$..Message.mail.headers", + "$..Message.mail.headersTruncated", + "$..Message.mail.tags.'ses:caller-identity'", + "$..Message.mail.tags.'ses:configuration-set'", + "$..Message.mail.tags.'ses:from-domain'", + "$..Message.mail.tags.'ses:operation'", + "$..Message.mail.tags.'ses:outgoing-ip'", + "$..Message.mail.tags.'ses:source-ip'", + "$..Message.mail.timestamp", + ] + ) + def test_ses_sns_topic_integration_send_email( + self, + sqs_queue, + sns_topic, + sns_create_sqs_subscription, + ses_configuration_set, + ses_configuration_set_sns_event_destination, + sqs_receive_num_messages, + setup_email_addresses, + snapshot, + aws_client, + add_snapshot_transformer_for_sns_event, + ): + """ + Repro for #7184 - test that this test is not runnable in the sandbox account since it + requires a validated email address. We do not have support for this yet. + """ + + # add your email addresses in here to verify against AWS + sender_email_address, recipient_email_address = setup_email_addresses() + config_set_name = f"config-set-{short_uid()}" + + add_snapshot_transformer_for_sns_event( + sender_email_address, recipient_email_address, config_set_name + ) + + # create queue to listen for SES -> SNS events + topic_arn = sns_topic["Attributes"]["TopicArn"] + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=sqs_queue) + + # create the config set + ses_configuration_set(config_set_name) + event_destination_name = f"config-set-event-destination-{short_uid()}" + ses_configuration_set_sns_event_destination( + config_set_name, event_destination_name, topic_arn + ) + + # send an email to trigger the SNS message and SQS message + destination = { + "ToAddresses": [recipient_email_address], + } + aws_client.ses.send_email( + Destination=destination, + Message=SAMPLE_SIMPLE_EMAIL, + ConfigurationSetName=config_set_name, + Source=sender_email_address, + Tags=[ + { + "Name": "custom-tag", + "Value": "tag-value", + } + ], + ) + + messages = sqs_receive_num_messages(sqs_queue, 3) + messages.sort(key=sort_mail_sqs_messages) + snapshot.match("messages", messages) + + @markers.aws.manual_setup_required + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Message.delivery.processingTimeMillis", + "$..Message.delivery.reportingMTA", + "$..Message.delivery.smtpResponse", + "$..Message.mail.commonHeaders", + "$..Message.mail.headers", + "$..Message.mail.headersTruncated", + "$..Message.mail.tags.'ses:caller-identity'", + "$..Message.mail.tags.'ses:configuration-set'", + "$..Message.mail.tags.'ses:from-domain'", + "$..Message.mail.tags.'ses:operation'", + "$..Message.mail.tags.'ses:outgoing-ip'", + "$..Message.mail.tags.'ses:source-ip'", + "$..Message.mail.timestamp", + ] + ) + def test_ses_sns_topic_integration_send_templated_email( + self, + ses_configuration_set, + ses_configuration_set_sns_event_destination, + ses_email_template, + sns_create_sqs_subscription, + sns_create_topic, + sqs_queue, + sqs_receive_num_messages, + setup_email_addresses, + snapshot, + aws_client, + add_snapshot_transformer_for_sns_event, + ): + # add your email addresses in here to verify against AWS + sender_email_address, recipient_email_address = setup_email_addresses() + config_set_name = f"config-set-{short_uid()}" + + add_snapshot_transformer_for_sns_event( + sender_email_address, recipient_email_address, config_set_name + ) + + template_name = f"template-{short_uid()}" + ses_email_template(template_name, "Test template") + + # create queue to listen for SES -> SNS events + topic_arn = sns_create_topic()["TopicArn"] + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=sqs_queue) + + # create the config set + ses_configuration_set(config_set_name) + event_destination_name = f"config-set-event-destination-{short_uid()}" + ses_configuration_set_sns_event_destination( + config_set_name, event_destination_name, topic_arn + ) + + # send an email to trigger the SNS message and SQS message + destination = { + "ToAddresses": [recipient_email_address], + } + aws_client.ses.send_templated_email( + Destination=destination, + Template=template_name, + TemplateData=json.dumps({}), + ConfigurationSetName=config_set_name, + Source=sender_email_address, + Tags=[ + { + "Name": "custom-tag", + "Value": "tag-value", + } + ], + ) + + messages = sqs_receive_num_messages(sqs_queue, 3) + messages.sort(key=sort_mail_sqs_messages) + snapshot.match("messages", messages) + + @markers.aws.manual_setup_required + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Message.delivery.processingTimeMillis", + "$..Message.delivery.reportingMTA", + "$..Message.delivery.smtpResponse", + "$..Message.mail.commonHeaders", + "$..Message.mail.headers", + "$..Message.mail.headersTruncated", + "$..Message.mail.tags.'ses:caller-identity'", + "$..Message.mail.tags.'ses:configuration-set'", + "$..Message.mail.tags.'ses:from-domain'", + "$..Message.mail.tags.'ses:operation'", + "$..Message.mail.tags.'ses:outgoing-ip'", + "$..Message.mail.tags.'ses:source-ip'", + "$..Message.mail.timestamp", + ] + ) + def test_ses_sns_topic_integration_send_raw_email( + self, + ses_configuration_set, + ses_configuration_set_sns_event_destination, + sns_create_sqs_subscription, + sns_create_topic, + sqs_queue, + sqs_receive_num_messages, + setup_email_addresses, + snapshot, + aws_client, + add_snapshot_transformer_for_sns_event, + ): + # add your email addresses in here to verify against AWS + sender_email_address, recipient_email_address = setup_email_addresses() + config_set_name = f"config-set-{short_uid()}" + + add_snapshot_transformer_for_sns_event( + sender_email_address, recipient_email_address, config_set_name + ) + + # create queue to listen for SES -> SNS events + topic_arn = sns_create_topic()["TopicArn"] + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=sqs_queue) + + # create the config set + ses_configuration_set(config_set_name) + event_destination_name = f"config-set-event-destination-{short_uid()}" + ses_configuration_set_sns_event_destination( + config_set_name, event_destination_name, topic_arn + ) + + # send an email to trigger the SNS message and SQS message + aws_client.ses.send_raw_email( + Destinations=[recipient_email_address], + RawMessage={ + "Data": b"", + }, + ConfigurationSetName=config_set_name, + Source=sender_email_address, + Tags=[ + { + "Name": "custom-tag", + "Value": "tag-value", + } + ], + ) + + messages = sqs_receive_num_messages(sqs_queue, 3) + messages.sort(key=sort_mail_sqs_messages) + snapshot.match("messages", messages) + + @markers.aws.validated + def test_cannot_create_event_for_no_topic( + self, ses_configuration_set, snapshot, account_id, aws_client + ): + topic_name = f"missing-topic-{short_uid()}" + topic_arn = f"arn:aws:sns:{aws_client.ses.meta.region_name}:{account_id}:{topic_name}" + snapshot.add_transformer(snapshot.transform.regex(topic_arn, "")) + + config_set_name = f"config-set-{short_uid()}" + ses_configuration_set(config_set_name) + + event_destination_name = f"config-set-event-destination-{short_uid()}" + + # check if job is gone + with pytest.raises(ClientError) as e_info: + aws_client.ses.create_configuration_set_event_destination( + ConfigurationSetName=config_set_name, + EventDestination={ + "Name": event_destination_name, + "Enabled": True, + "MatchingEventTypes": ["send", "bounce", "delivery", "open", "click"], + "SNSDestination": { + "TopicARN": topic_arn, + }, + }, + ) + snapshot.match("create-error", e_info.value.response) + + @markers.aws.manual_setup_required + def test_sending_to_deleted_topic( + self, + sqs_queue, + sns_create_sqs_subscription, + sns_topic, + sns_wait_for_topic_delete, + ses_configuration_set, + sqs_receive_num_messages, + ses_configuration_set_sns_event_destination, + setup_email_addresses, + snapshot, + aws_client, + add_snapshot_transformer_for_sns_event, + ): + # add your email addresses in here to verify against AWS + sender_email_address, recipient_email_address = setup_email_addresses() + config_set_name = f"config-set-{short_uid()}" + + add_snapshot_transformer_for_sns_event( + sender_email_address, recipient_email_address, config_set_name + ) + + topic_arn = sns_topic["Attributes"]["TopicArn"] + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=sqs_queue) + + ses_configuration_set(config_set_name) + event_destination_name = f"config-set-event-destination-{short_uid()}" + ses_configuration_set_sns_event_destination( + config_set_name, event_destination_name, topic_arn + ) + + destination = { + "ToAddresses": [recipient_email_address], + } + message = { + "Subject": { + "Data": "foo subject", + }, + "Body": { + "Text": { + "Data": "saml body", + }, + }, + } + + # FIXME: there will be an issue with the fixture deleting the topic. Currently it logs + # only, but this may change in the future. + aws_client.sns.delete_topic(TopicArn=topic_arn) + sns_wait_for_topic_delete(topic_arn=topic_arn) + + aws_client.ses.send_email( + Destination=destination, + Message=message, + ConfigurationSetName=config_set_name, + Source=sender_email_address, + ) + + messages = sqs_receive_num_messages(sqs_queue, 1) + snapshot.match("messages", messages) + + @markers.aws.validated + def test_creating_event_destination_without_configuration_set( + self, sns_topic, snapshot, aws_client + ): + config_set_name = f"nonexistent-configuration-set-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(config_set_name, "")) + + topic_arn = sns_topic["Attributes"]["TopicArn"] + event_destination_name = f"event-destination-{short_uid()}" + with pytest.raises(ClientError) as e_info: + aws_client.ses.create_configuration_set_event_destination( + ConfigurationSetName=config_set_name, + EventDestination={ + "Name": event_destination_name, + "Enabled": True, + "MatchingEventTypes": ["send", "bounce", "delivery", "open", "click"], + "SNSDestination": { + "TopicARN": topic_arn, + }, + }, + ) + snapshot.match("create-error", e_info.value.response) + + @markers.aws.validated + def test_deleting_non_existent_configuration_set(self, snapshot, aws_client): + config_set_name = f"config-set-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(config_set_name, "")) + + with pytest.raises(ClientError) as e_info: + aws_client.ses.delete_configuration_set(ConfigurationSetName=config_set_name) + snapshot.match("delete-error", e_info.value.response) + + @markers.aws.validated + def test_deleting_non_existent_configuration_set_event_destination( + self, ses_configuration_set, snapshot, aws_client + ): + config_set_name = f"config-set-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(config_set_name, "")) + ses_configuration_set(config_set_name) + + event_destination_name = f"non-existent-configuration-set-{short_uid()}" + # check if job is gone + with pytest.raises(ClientError) as e_info: + aws_client.ses.delete_configuration_set_event_destination( + ConfigurationSetName=config_set_name, + EventDestinationName=event_destination_name, + ) + snapshot.match("delete-error", e_info.value.response) + + @markers.aws.validated + def test_trying_to_delete_event_destination_from_non_existent_configuration_set( + self, + ses_configuration_set, + ses_configuration_set_sns_event_destination, + sns_topic, + snapshot, + aws_client, + ): + config_set_name = f"config-set-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(config_set_name, "")) + + ses_configuration_set(config_set_name) + + event_destination_name = f"event-destination-{short_uid()}" + snapshot.add_transformer( + snapshot.transform.regex(event_destination_name, "") + ) + topic_arn = sns_topic["Attributes"]["TopicArn"] + ses_configuration_set_sns_event_destination( + config_set_name, event_destination_name, topic_arn + ) + + with pytest.raises(ClientError) as e_info: + aws_client.ses.delete_configuration_set_event_destination( + ConfigurationSetName="non-existent-configuration-set", + EventDestinationName=event_destination_name, + ) + snapshot.match("delete-error", e_info.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "tag_name,tag_value", + [ + ("test_invalid_name:123", "test"), + ("test", "test_invalid_value:123"), + ("test_invalid_name:123", "test_invalid_value:123"), + pytest.param("test_invalid_name_len" * 100, "test", id="test_invalid_name_len"), + pytest.param("test", "test_invalid_value_len" * 100, id="test_invalid_value_len"), + pytest.param( + "test_invalid_name@123", + "test_invalid_value_len" * 100, + id="test_priority_name_value", + ), + ("", ""), + ("", "test"), + ("test", ""), + ], + ) + def test_invalid_tags_send_email(self, tag_name, tag_value, snapshot, aws_client): + source = f"user-{short_uid()}@example.com" + destination = "success@example.com" + + with pytest.raises(ClientError) as e: + aws_client.ses.send_email( + Source=source, + Tags=[ + { + "Name": tag_name, + "Value": tag_value, + } + ], + Message=SAMPLE_SIMPLE_EMAIL, + Destination={ + "ToAddresses": [destination], + }, + ) + snapshot.match("response", e.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "tag_name,tag_value", + [ + ("ses:feedback-id-a", "this-marketing-campaign"), + ("ses:feedback-id-b", "that-campaign"), + ], + ) + def test_special_tags_send_email(self, tag_name, tag_value, aws_client): + source = f"user-{short_uid()}@example.com" + destination = "success@example.com" + + # Ensure that request passes validation and throws MessageRejected instead + with pytest.raises(ClientError) as exc: + aws_client.ses.send_email( + Source=source, + Tags=[ + { + "Name": tag_name, + "Value": tag_value, + } + ], + Message=SAMPLE_SIMPLE_EMAIL, + Destination={ + "ToAddresses": [destination], + }, + ) + + assert exc.match("MessageRejected") + + +@pytest.mark.usefixtures("openapi_validate") +class TestSESRetrospection: + @markers.aws.only_localstack + def test_send_email_can_retrospect(self, aws_client): + # Test that sent emails can be retrospected through saved file and API access + + # reset endpoint stored messages + EMAILS.clear() + + def _read_message_from_filesystem(message_id: str) -> dict: + """Given a message ID, read the message from filesystem and deserialise it.""" + data_dir = config.dirs.data or config.dirs.tmp + with open(os.path.join(data_dir, "ses", message_id + ".json"), "r") as f: + message = f.read() + return json.loads(message) + + email = f"user-{short_uid()}@example.com" + aws_client.ses.verify_email_address(EmailAddress=email) + + # Send a regular message + message1 = aws_client.ses.send_email( + Source=email, + Message=SAMPLE_SIMPLE_EMAIL, + Destination={ + "ToAddresses": ["success@example.com"], + }, + ) + message1_id = message1["MessageId"] + + # Ensure saved message + contents1 = _read_message_from_filesystem(message1_id) + assert contents1["Id"] == message1_id + assert contents1["Timestamp"] + assert contents1["Region"] + assert contents1["Source"] == email + assert contents1["Destination"] == {"ToAddresses": ["success@example.com"]} + assert contents1["Subject"] == SAMPLE_SIMPLE_EMAIL["Subject"]["Data"] + assert contents1["Body"] == { + "text_part": SAMPLE_SIMPLE_EMAIL["Body"]["Text"]["Data"], + "html_part": SAMPLE_SIMPLE_EMAIL["Body"]["Html"]["Data"], + } + assert "RawData" not in contents1 + + # Send a raw message + raw_message_data = f"From: {email}\nTo: recipient@example.com\nSubject: test\n\nThis is the message body.\n\n" + message2 = aws_client.ses.send_raw_email(RawMessage={"Data": raw_message_data}) + message2_id = message2["MessageId"] + + # Ensure saved raw message + contents2 = _read_message_from_filesystem(message2_id) + assert contents2["Id"] == message2_id + assert contents2["Timestamp"] + assert contents2["Region"] + assert contents2["Source"] == email + assert contents2["RawData"] == raw_message_data + assert "Destination" not in contents2 + assert "Subject" not in contents2 + assert "Body" not in contents2 + + # Ensure all sent messages can be retrieved using the API endpoint + emails_url = config.internal_service_url() + EMAILS_ENDPOINT + api_contents = [] + api_contents.extend(requests.get(emails_url + f"?id={message1_id}").json()["messages"]) + api_contents.extend(requests.get(emails_url + f"?id={message2_id}").json()["messages"]) + api_contents = {msg["Id"]: msg for msg in api_contents} + assert len(api_contents) >= 1 + assert message1_id in api_contents + assert message2_id in api_contents + assert api_contents[message1_id] == contents1 + assert api_contents[message2_id] == contents2 + + # Ensure messages can be filtered by email source via the REST endpoint + emails_url = config.internal_service_url() + EMAILS_ENDPOINT + "?email=none@example.com" + assert len(requests.get(emails_url).json()["messages"]) == 0 + emails_url = config.internal_service_url() + EMAILS_ENDPOINT + f"?email={email}" + assert len(requests.get(emails_url).json()["messages"]) == 2 + + emails_url = config.internal_service_url() + EMAILS_ENDPOINT + assert requests.delete(emails_url + f"?id={message1_id}").status_code == 204 + assert requests.delete(emails_url + f"?id={message2_id}").status_code == 204 + assert requests.get(emails_url).json() == {"messages": []} + + @markers.aws.only_localstack + def test_send_templated_email_can_retrospect(self, create_template, aws_client): + # Test that sent emails can be retrospected through saved file and API access + + # reset endpoint stored messages + EMAILS.clear() + + data_dir = config.dirs.data or config.dirs.tmp + email = f"user-{short_uid()}@example.com" + aws_client.ses.verify_email_address(EmailAddress=email) + aws_client.ses.delete_template(TemplateName=SAMPLE_TEMPLATE["TemplateName"]) + create_template(template=SAMPLE_TEMPLATE) + + message = aws_client.ses.send_templated_email( + Source=email, + Template=SAMPLE_TEMPLATE["TemplateName"], + TemplateData='{"A key": "A value"}', + Destination={ + "ToAddresses": ["success@example.com"], + }, + ) + message_id = message["MessageId"] + + with open(os.path.join(data_dir, "ses", message_id + ".json"), "r") as f: + message = f.read() + + contents = json.loads(message) + + assert email == contents["Source"] + assert SAMPLE_TEMPLATE["TemplateName"] == contents["Template"] + assert '{"A key": "A value"}' == contents["TemplateData"] + assert ["success@example.com"] == contents["Destination"]["ToAddresses"] + + api_contents = requests.get("http://localhost:4566/_aws/ses").json() + api_contents = {msg["Id"]: msg for msg in api_contents["messages"]} + assert message_id in api_contents + assert api_contents[message_id] == contents + + assert requests.delete("http://localhost:4566/_aws/ses").status_code == 204 + assert requests.get("http://localhost:4566/_aws/ses").json() == {"messages": []} diff --git a/tests/aws/services/ses/test_ses.snapshot.json b/tests/aws/services/ses/test_ses.snapshot.json new file mode 100644 index 0000000000000..73336d13e1921 --- /dev/null +++ b/tests/aws/services/ses/test_ses.snapshot.json @@ -0,0 +1,919 @@ +{ + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_email": { + "recorded-date": "25-08-2023, 23:53:37", + "recorded-content": { + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Successfully validated SNS topic for Amazon SES event publishing.", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Amazon SES Email Event Notification", + "Message": { + "eventType": "Send", + "mail": { + "timestamp": "timestamp", + "source": "", + "sourceArn": "arn::ses::111111111111:identity/", + "sendingAccountId": "111111111111", + "messageId": "", + "destination": [ + "" + ], + "headersTruncated": false, + "headers": [ + { + "name": "From", + "value": "" + }, + { + "name": "To", + "value": "" + }, + { + "name": "Subject", + "value": "SOME_SUBJECT" + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "multipart/alternative; boundary=\"----=_Part_444254_355072649.1693000415859\"" + } + ], + "commonHeaders": { + "from": [ + "" + ], + "to": [ + "" + ], + "messageId": "", + "subject": "SOME_SUBJECT" + }, + "tags": { + "ses:operation": [ + "SendEmail" + ], + "ses:configuration-set": [ + "" + ], + "ses:source-ip": [ + "" + ], + "ses:from-domain": [ + "" + ], + "custom-tag": [ + "tag-value" + ], + "ses:caller-identity": [ + "" + ] + } + }, + "send": {} + }, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Amazon SES Email Event Notification", + "Message": { + "eventType": "Delivery", + "mail": { + "timestamp": "timestamp", + "source": "", + "sourceArn": "arn::ses::111111111111:identity/", + "sendingAccountId": "111111111111", + "messageId": "", + "destination": [ + "" + ], + "headersTruncated": false, + "headers": [ + { + "name": "From", + "value": "" + }, + { + "name": "To", + "value": "" + }, + { + "name": "Subject", + "value": "SOME_SUBJECT" + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "multipart/alternative; boundary=\"----=_Part_444254_355072649.1693000415859\"" + } + ], + "commonHeaders": { + "from": [ + "" + ], + "to": [ + "" + ], + "messageId": "", + "subject": "SOME_SUBJECT" + }, + "tags": { + "ses:operation": [ + "SendEmail" + ], + "ses:configuration-set": [ + "" + ], + "ses:source-ip": [ + "" + ], + "ses:from-domain": [ + "" + ], + "custom-tag": [ + "tag-value" + ], + "ses:caller-identity": [ + "" + ], + "ses:outgoing-ip": [ + "" + ] + } + }, + "delivery": { + "timestamp": "date", + "processingTimeMillis": "processing-time", + "recipients": [ + "" + ], + "smtpResponse": "250 2.0.0 OK DMARC:Quarantine 1693000416 l9-20020a05620a28c900b0076d9e089c17si1864617qkp.664 - gsmtp", + "reportingMTA": "a8-77.smtp-out.amazonses.com" + } + }, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + ] + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_templated_email": { + "recorded-date": "26-08-2023, 00:00:04", + "recorded-content": { + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Successfully validated SNS topic for Amazon SES event publishing.", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Amazon SES Email Event Notification", + "Message": { + "eventType": "Send", + "mail": { + "timestamp": "timestamp", + "source": "", + "sendingAccountId": "111111111111", + "messageId": "", + "destination": [ + "" + ], + "headersTruncated": false, + "headers": [ + { + "name": "From", + "value": "" + }, + { + "name": "To", + "value": "" + }, + { + "name": "MIME-Version", + "value": "1.0" + } + ], + "commonHeaders": { + "from": [ + "" + ], + "to": [ + "" + ], + "messageId": "" + }, + "tags": { + "ses:operation": [ + "SendTemplatedEmail" + ], + "ses:configuration-set": [ + "" + ], + "ses:source-ip": [ + "" + ], + "ses:from-domain": [ + "" + ], + "custom-tag": [ + "tag-value" + ], + "ses:caller-identity": [ + "" + ] + } + }, + "send": {} + }, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Amazon SES Email Event Notification", + "Message": { + "eventType": "Delivery", + "mail": { + "timestamp": "timestamp", + "source": "", + "sourceArn": "arn::ses::111111111111:identity/", + "sendingAccountId": "111111111111", + "messageId": "", + "destination": [ + "" + ], + "headersTruncated": false, + "headers": [ + { + "name": "Date", + "value": "Fri, 25 Aug 2023 22:00:03 +0000" + }, + { + "name": "From", + "value": "" + }, + { + "name": "To", + "value": "" + }, + { + "name": "Subject", + "value": "Email template c51cf868" + }, + { + "name": "MIME-Version", + "value": "1.0" + }, + { + "name": "Content-Type", + "value": "text/plain; charset=UTF-8" + }, + { + "name": "Content-Transfer-Encoding", + "value": "7bit" + } + ], + "commonHeaders": { + "from": [ + "" + ], + "date": "Fri, 25 Aug 2023 22:00:03 +0000", + "to": [ + "" + ], + "messageId": "", + "subject": "Email template c51cf868" + }, + "tags": { + "ses:operation": [ + "SendTemplatedEmail" + ], + "ses:configuration-set": [ + "" + ], + "ses:source-ip": [ + "" + ], + "ses:from-domain": [ + "" + ], + "custom-tag": [ + "tag-value" + ], + "ses:caller-identity": [ + "" + ], + "ses:outgoing-ip": [ + "" + ] + } + }, + "delivery": { + "timestamp": "date", + "processingTimeMillis": "processing-time", + "recipients": [ + "" + ], + "smtpResponse": "250 2.0.0 OK DMARC:Quarantine 1693000804 o10-20020a05622a138a00b004120c782853si1668341qtk.699 - gsmtp", + "reportingMTA": "a48-118.smtp-out.amazonses.com" + } + }, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + ] + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_raw_email": { + "recorded-date": "26-08-2023, 00:01:24", + "recorded-content": { + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Successfully validated SNS topic for Amazon SES event publishing.", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Amazon SES Email Event Notification", + "Message": { + "eventType": "Send", + "mail": { + "timestamp": "timestamp", + "source": "", + "sourceArn": "arn::ses::111111111111:identity/", + "sendingAccountId": "111111111111", + "messageId": "", + "destination": [ + "" + ], + "headersTruncated": false, + "headers": [], + "commonHeaders": { + "messageId": "" + }, + "tags": { + "ses:operation": [ + "SendRawEmail" + ], + "ses:configuration-set": [ + "" + ], + "ses:source-ip": [ + "" + ], + "custom-tag": [ + "tag-value" + ], + "ses:caller-identity": [ + "" + ] + } + }, + "send": {} + }, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Amazon SES Email Event Notification", + "Message": { + "eventType": "Delivery", + "mail": { + "timestamp": "timestamp", + "source": "", + "sourceArn": "arn::ses::111111111111:identity/", + "sendingAccountId": "111111111111", + "messageId": "", + "destination": [ + "" + ], + "headersTruncated": false, + "headers": [], + "commonHeaders": { + "messageId": "" + }, + "tags": { + "ses:operation": [ + "SendRawEmail" + ], + "ses:configuration-set": [ + "" + ], + "ses:source-ip": [ + "" + ], + "custom-tag": [ + "tag-value" + ], + "ses:caller-identity": [ + "" + ], + "ses:outgoing-ip": [ + "" + ] + } + }, + "delivery": { + "timestamp": "date", + "processingTimeMillis": "processing-time", + "recipients": [ + "" + ], + "smtpResponse": "250 2.0.0 OK DMARC:Quarantine 1693000884 v14-20020a05622a130e00b004053819f665si191486qtk.608 - gsmtp", + "reportingMTA": "a48-119.smtp-out.amazonses.com" + } + }, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + ] + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_cannot_create_event_for_no_topic": { + "recorded-date": "26-08-2023, 00:04:12", + "recorded-content": { + "create-error": { + "Error": { + "Code": "InvalidSNSDestination", + "Message": "SNS topic <> not found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_deleting_non_existent_configuration_set": { + "recorded-date": "26-08-2023, 00:04:44", + "recorded-content": { + "delete-error": { + "Error": { + "Code": "ConfigurationSetDoesNotExist", + "Message": "Configuration set <> does not exist.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_deleting_non_existent_configuration_set_event_destination": { + "recorded-date": "26-08-2023, 00:04:53", + "recorded-content": { + "delete-error": { + "Error": { + "Code": "EventDestinationDoesNotExist", + "Message": "No EventDestination found for ", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_sending_to_deleted_topic": { + "recorded-date": "26-08-2023, 00:02:43", + "recorded-content": { + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Successfully validated SNS topic for Amazon SES event publishing.", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + ] + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_creating_event_destination_without_configuration_set": { + "recorded-date": "26-08-2023, 00:04:35", + "recorded-content": { + "create-error": { + "Error": { + "Code": "ConfigurationSetDoesNotExist", + "Message": "Configuration set <> does not exist.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_trying_to_delete_event_destination_from_non_existent_configuration_set": { + "recorded-date": "26-08-2023, 00:05:02", + "recorded-content": { + "delete-error": { + "Error": { + "Code": "ConfigurationSetDoesNotExist", + "Message": "Configuration set does not exist.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name:123-test]": { + "recorded-date": "30-07-2024, 10:18:07", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Invalid tag name : only alphanumeric ASCII characters, '_', '-' are allowed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test-test_invalid_value:123]": { + "recorded-date": "30-07-2024, 10:18:08", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Invalid tag value : only alphanumeric ASCII characters, '_', '-' , '.', '@' are allowed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name:123-test_invalid_value:123]": { + "recorded-date": "30-07-2024, 10:18:08", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Invalid tag name : only alphanumeric ASCII characters, '_', '-' are allowed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name_len]": { + "recorded-date": "30-07-2024, 10:18:08", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Tag name cannot exceed 255 characters.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_value_len]": { + "recorded-date": "30-07-2024, 10:18:08", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Tag value cannot exceed 255 characters.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_priority_name_value]": { + "recorded-date": "30-07-2024, 10:18:08", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Invalid tag name : only alphanumeric ASCII characters, '_', '-' are allowed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[-]": { + "recorded-date": "30-07-2024, 10:18:09", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The tag name must be specified.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[-test]": { + "recorded-date": "30-07-2024, 10:18:09", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The tag name must be specified.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test-]": { + "recorded-date": "30-07-2024, 10:18:09", + "recorded-content": { + "response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The tag value must be specified.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_list_templates": { + "recorded-date": "25-08-2023, 18:33:54", + "recorded-content": { + "create-template": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-templates-1": { + "TemplatesMetadata": [ + { + "CreatedTimestamp": "timestamp", + "Name": "hello-world" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-templates-2": { + "TemplatesMetadata": [ + { + "CreatedTimestamp": "timestamp", + "Name": "hello-world" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_delete_template": { + "recorded-date": "25-08-2023, 19:14:04", + "recorded-content": { + "list-templates-empty": { + "TemplatesMetadata": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-templates-after-create": { + "TemplatesMetadata": [ + { + "CreatedTimestamp": "timestamp", + "Name": "hello-world" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-template": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-templates-after-delete": { + "TemplatesMetadata": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_clone_receipt_rule_set": { + "recorded-date": "25-08-2023, 23:05:14", + "recorded-content": { + "create-receipt-rule-set": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "clone-receipt-rule-set": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "original-rule-set": { + "Metadata": { + "CreatedTimestamp": "timestamp", + "Name": "RuleSetToClone" + }, + "Rules": [ + { + "Actions": [ + { + "AddHeaderAction": { + "HeaderName": "test-header", + "HeaderValue": "test" + } + } + ], + "Enabled": true, + "Name": "MyRule1", + "ScanEnabled": true, + "TlsPolicy": "Optional" + }, + { + "Actions": [ + { + "S3Action": { + "BucketName": "", + "ObjectKeyPrefix": "template" + } + } + ], + "Enabled": true, + "Name": "MyRule2", + "ScanEnabled": true, + "TlsPolicy": "Optional" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cloned-rule-set": { + "Metadata": { + "CreatedTimestamp": "timestamp", + "Name": "RuleSetToCreate" + }, + "Rules": [ + { + "Actions": [ + { + "AddHeaderAction": { + "HeaderName": "test-header", + "HeaderValue": "test" + } + } + ], + "Enabled": true, + "Name": "MyRule1", + "ScanEnabled": true, + "TlsPolicy": "Optional" + }, + { + "Actions": [ + { + "S3Action": { + "BucketName": "", + "ObjectKeyPrefix": "template" + } + } + ], + "Enabled": true, + "Name": "MyRule2", + "ScanEnabled": true, + "TlsPolicy": "Optional" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_sent_message_counter": { + "recorded-date": "27-11-2024, 13:03:32", + "recorded-content": { + "get-quota-0": { + "Max24HourSend": 200.0, + "MaxSendRate": 1.0, + "SentLast24Hours": "sent-last24-hours", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/ses/test_ses.validation.json b/tests/aws/services/ses/test_ses.validation.json new file mode 100644 index 0000000000000..2a9af7ef3a441 --- /dev/null +++ b/tests/aws/services/ses/test_ses.validation.json @@ -0,0 +1,74 @@ +{ + "tests/aws/services/ses/test_ses.py::TestSES::test_cannot_create_event_for_no_topic": { + "last_validated_date": "2023-08-25T22:04:12+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_clone_receipt_rule_set": { + "last_validated_date": "2023-08-25T21:05:14+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_creating_event_destination_without_configuration_set": { + "last_validated_date": "2023-08-25T22:04:35+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_delete_template": { + "last_validated_date": "2023-08-25T17:14:04+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_deleting_non_existent_configuration_set": { + "last_validated_date": "2023-08-25T22:04:44+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_deleting_non_existent_configuration_set_event_destination": { + "last_validated_date": "2023-08-25T22:04:53+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[-]": { + "last_validated_date": "2024-07-30T10:18:09+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[-test]": { + "last_validated_date": "2024-07-30T10:18:09+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test-]": { + "last_validated_date": "2024-07-30T10:18:09+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test-test_invalid_value:123]": { + "last_validated_date": "2024-07-30T10:18:08+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name:123-test]": { + "last_validated_date": "2024-07-30T10:18:07+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name:123-test_invalid_value:123]": { + "last_validated_date": "2024-07-30T10:18:08+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_name_len]": { + "last_validated_date": "2024-07-30T10:18:08+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_invalid_value_len]": { + "last_validated_date": "2024-07-30T10:18:08+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_invalid_tags_send_email[test_priority_name_value]": { + "last_validated_date": "2024-07-30T10:18:08+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_list_templates": { + "last_validated_date": "2023-08-25T16:33:54+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_sending_to_deleted_topic": { + "last_validated_date": "2023-08-25T22:02:43+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_sent_message_counter": { + "last_validated_date": "2024-11-27T13:03:32+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_email": { + "last_validated_date": "2023-08-25T21:53:37+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_raw_email": { + "last_validated_date": "2023-08-25T22:01:24+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_ses_sns_topic_integration_send_templated_email": { + "last_validated_date": "2023-08-25T22:00:04+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_special_tags_send_email[ses:feedback-id-a-this-marketing-campaign]": { + "last_validated_date": "2024-07-30T13:01:32+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_special_tags_send_email[ses:feedback-id-b-that-campaign]": { + "last_validated_date": "2024-07-30T13:01:32+00:00" + }, + "tests/aws/services/ses/test_ses.py::TestSES::test_trying_to_delete_event_destination_from_non_existent_configuration_set": { + "last_validated_date": "2023-08-25T22:05:02+00:00" + } +} diff --git a/tests/aws/services/sns/__init__.py b/tests/aws/services/sns/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/sns/conftest.py b/tests/aws/services/sns/conftest.py new file mode 100644 index 0000000000000..290a292828b51 --- /dev/null +++ b/tests/aws/services/sns/conftest.py @@ -0,0 +1,57 @@ +import pytest + +from localstack.utils.strings import short_uid + +LAMBDA_FN_SNS_ENDPOINT = """ +import boto3, json, os +def handler(event, *args): + if "AWS_ENDPOINT_URL" in os.environ: + sqs_client = boto3.client("sqs", endpoint_url=os.environ["AWS_ENDPOINT_URL"]) + else: + sqs_client = boto3.client("sqs") + + queue_url = os.environ.get("SQS_QUEUE_URL") + message = {"event": event} + sqs_client.send_message(QueueUrl=queue_url, MessageBody=json.dumps(message), MessageGroupId="1") + return {"statusCode": 200} +""" + + +@pytest.fixture +def create_sns_http_endpoint_and_queue( + aws_client, account_id, create_lambda_function, sqs_create_queue +): + lambda_client = aws_client.lambda_ + + def _create_sns_http_endpoint(): + function_name = f"lambda_fn_sns_endpoint-{short_uid()}" + + # create SQS queue for results + queue_name = f"{function_name}.fifo" + queue_attrs = {"FifoQueue": "true", "ContentBasedDeduplication": "true"} + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=queue_attrs) + aws_client.sqs.add_permission( + QueueUrl=queue_url, + Label=f"lambda-sqs-{short_uid()}", + AWSAccountIds=[account_id], + Actions=["SendMessage"], + ) + + create_lambda_function( + func_name=function_name, + handler_file=LAMBDA_FN_SNS_ENDPOINT, + envvars={"SQS_QUEUE_URL": queue_url}, + ) + create_url_response = lambda_client.create_function_url_config( + FunctionName=function_name, AuthType="NONE", InvokeMode="BUFFERED" + ) + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId="urlPermission", + Action="lambda:InvokeFunctionUrl", + Principal="*", + FunctionUrlAuthType="NONE", + ) + return create_url_response["FunctionUrl"], queue_url + + return _create_sns_http_endpoint diff --git a/tests/aws/services/sns/test_payloads/complex_payload.json b/tests/aws/services/sns/test_payloads/complex_payload.json new file mode 100644 index 0000000000000..d01727f12c2c9 --- /dev/null +++ b/tests/aws/services/sns/test_payloads/complex_payload.json @@ -0,0 +1,874 @@ +{ + "id": "1", + "source": "soft1", + "detail-type": "cmd.documents.generate", + "account": "123456789012", + "region": "us-east-2", + "time": "2022-07-13T13:48:01Z", + "detail": { + "sourceHostname": "lambda.amazonaws.com", + "sourceMessagingLib": "w", + "correlationUuid": "a26f70c2-e071-459f-8b87-81fdfb43e28a", + "createdAt": "2025-03-14T17:05:46.019Z", + "retryCount": 0, + "metadata": {}, + "payload": { + "WElTQLlSVUBpx": "zCUYxaqSGUXLtCVyHiAcRJxKnBcLeac", + "URXrhXqFQULAGIVx": 6, + "AWFqITBBeoWQ": "TopVogywzrBtZWTMWadzyUmTAOfcPxwwuJbc", + "NwVWQPgFRzeENvXIblm": "NQmVRJbvVDtHoVuFaOFvZEhM", + "RVjgwOLpVhEPhsUdGCmc": "DfsbMyKZUKBvQvidnzXPGGom", + "nested": { + "MhuRrcNygnJ": { + "MsMzulBhOI": "TKBLUCBZ", + "uYOQyOyWlCREF": "JeCNesuWhslgGYnZDjmNOBzqGEJicBqgvFzJcGnfNhajYSPQMMJsMzqNbWhNxCHJfzKuqKuvVCprLFVQgCsYIihQVvbpLiLLmoFeXiy", + "yOIbDKLerwwRVJKnjpi": 14, + "zyHteNLbKlOttm": [ + { + "Yf": "W", + "AOpS": "iJvXlgsrunicxdTuukghMcw", + "tnFMYzoWwB": "mBGLRKGfSaOKfiCIa" + } + ], + "YCxEXubXBBOROVD": { + "bjQQvYoyFzYiDGvon": [ + { + "yaXLKTjG": 191, + "UzhK": "VrkanWpo", + "XgOvBdEVfr": 3289, + "xkZq": "PpQ", + "Go": 944, + "Tb": 854, + "qNTcP": 132, + "Dbb": 3.97, + "WEA": 38, + "OTF": 2117, + "WMcSFly": 50.6, + "ROzkCJoGm": 92.67, + "eSePorxJI": 8.164598354536087, + "knoJyMOoK": 1.894621779845863, + "nKsgnqAZs": 4.423379701209955, + "hquYzxZ": 1.173164884858382, + "JTIfuzg": 65.79, + "TlxyyuZ": 60.35, + "cZhilKYDflAzFzOc": "yeCuYBmA", + "ZvuzDFjGtdclKcJEVMZP": "tp", + "BfUnZuOfCcuRMj": "NLehVjkCzjmZGRHsOjKZEb" + } + ] + }, + "gwooISnKeGTLdFimWkju": null + }, + "hnWBTAzubJWQmLDY": { + "ufulSIPsMu": "fjbMKRRA", + "fAsGuGrUajGhL": "keuqGQQqJunZDqXlxaLYXbMeZclCPQMqecurApAcDJso", + "BOUfucwDQWNHgb": "iLHbpUokCDovAhNvOOmswXLRMYSOtOIKtGNTCMlmeK", + "aHmXVKiuoQNWeWavLgO": 29, + "LCjOZFlCoHbMkAGeKl": 0, + "OkcbFmLlXzkooW": [ + { + "Zk": "dYPSbrHlicABJxQEUmPGHA", + "iuZV": "ixXPRFPqvkdtPGTaWrRYnNkJARfKnTGmUHNGHoGIfSM", + "WyVGwfcAXa": "hBXltKbzeAWLunuJAg" + } + ], + "vfxjgRVADEkWhxP": { + "vmNxIhDGBdCJvIFPRi": [ + { + "mCvvonTb": 7942264, + "zKzk": 9, + "xEAthobZTu": 8, + "eKgMzIe": 8, + "XdUQy": "pdJzICPuMYOvBUNlootMHYXYhcKJt", + "zBKcgiQUd": "sAHYUVTjbAWdgFQEmrRuZsGichOjV" + }, + { + "MrElexnI": 7652102, + "Ehbv": 1, + "HIbcTxEaUX": 5, + "WmRycFY": 4, + "JFLMQ": "vJDgJauwsABMZFbUTffrioCpBGmse", + "jyMEFezhT": "FfJJeYrLQnDGCrMCnXxJwGEJMlJfo" + }, + { + "cHncKhRL": 1693855, + "muAr": 2, + "SNbOLmbeZu": 7, + "mULkTxE": 3, + "gKycu": "BIGJnSim", + "SkxMGyvYx": "havqqzSM" + }, + { + "GyZFLuoQ": 2976017, + "SqCm": 51, + "hSZoojwlFH": 2, + "CSyLRCI": 1, + "KGpgu": "jHmDzLQEVkKrcUt", + "XFMSAIUoj": "iwhbcYcBflLBVsF" + }, + { + "BIHvwrHi": 1043893, + "cWnu": 2, + "zONwuXyZPu": 3, + "YgnMYTl": 7, + "GqjQa": "vwiRRNiJ", + "pgPlJFhcm": "cDHRlLYL" + }, + { + "SuIixzIv": 9249481, + "jTgZ": 6, + "vjgbsBqnoQ": 5, + "MgSMFQm": 6, + "gIARZ": "eMXqInvJBgWbTHgxgOPDioCcoDCj", + "dArwvMcuW": "cJQpdjdlmFlArjSBRaTxMpjyRCWH" + }, + { + "UxaDeEaF": 2538086, + "Qzqe": 3, + "EcJKQAPywI": 4, + "FswYsld": 4, + "XhnBj": "WkKYsqRwxIQNtVOByOMaUCBGUmykb", + "Onfbkbwca": "SpZlMUtNfnBXwEnzytLqlvYcdESPp" + }, + { + "wqZfLRXV": 6866658, + "ppIy": 7, + "omfEsDSBpm": 9, + "neVloPA": 6, + "kVSAt": "WztMWoqBsQFfwo", + "WubGMvWiT": "QIsSzDmeNlprfk" + }, + { + "LzwRmmgZ": 3224814, + "YJlg": 2, + "ocNTsgJsFA": 3, + "SPqPpJu": 4, + "nWTWz": "QaUpdhcrklslGrNjKxcJVmNsndepR", + "hPPcNtPlm": "MnvHHzNYPENYhSUFDoLcrwgbpjyaF" + }, + { + "jjhPZjTv": 3373968, + "mmrI": 8, + "YkJCEwyLdG": 2, + "NRhwMJn": 5, + "lpkaO": "QeiODlPPFqFUqfoulSLrCucINWhPf", + "ZzoWtjchC": "cfdtheSrvRJxqvZSKFqcFNKjjWiuF" + }, + { + "XwGDqdRU": 8445297, + "uJfU": 7, + "yzKOqetYwV": 2, + "FwkZEQG": 3, + "XesxG": "yiXveXvEgdnwVkoISGNejILgpAzjZZMoSygG", + "cuoUeiPgY": "xyppRzMXiHmuUVsmkWjYibiHFTESETGAApWO" + }, + { + "hptqabaG": 5953056, + "hJPc": 8, + "zoltzLwnRq": 8, + "mqKONTM": 8, + "FIOoG": "XpkeOWtM", + "EZjrNXtDs": "FRUoivil" + }, + { + "MMPxfvpd": 2700237, + "WhsD": 6, + "VnNsPWOgBE": 2, + "RQuokGn": 6, + "fdeLB": "FulxrCKUMkvqHxNLSoOvDbLeDpXQn", + "tvSdPHpYe": "kOJcPRRSMXGRMytCegLFGqFjGTTqh" + }, + { + "wLPifMoa": 8803711, + "xnpd": 3, + "HNqCRsMwdz": 8, + "wTHcwQi": 5, + "pWXDZ": "xoUqqOgidcrCyWSKOUqQKTGoBCYaQBdcilIS", + "juUIJCKTw": "frJOdSOmOHzlPsHffGlrwnlaQYNhSheVVvuc" + }, + { + "QnRuUKZS": 7742212, + "fjUv": 9, + "NOIULzKDFU": 5, + "xrRHTyw": 2, + "IjUms": "evEFIbPqZojTcy", + "QxzjvmCku": "VgXgHRNAayjRFE" + }, + { + "xdkiUPxT": 6876317, + "JvvB": 9, + "IwvPwGCzAT": 6, + "zvjOzqE": 4, + "KRlJS": "BJMStNIALLJjRkxnjFoSXxDpBpBBl", + "GnDsNdjsv": "fDJndULJzYANHpFTLdMQtBdHcGbje" + }, + { + "aHQIfnrJ": 6313562, + "vVur": 4, + "soPcZyqqSm": 6, + "tlpFZPT": 6, + "HYlGB": "CvKTRdKSaqkLzbTCzCMSxBASBTCF", + "TZkTcZXxp": "SuEHDdBaECVBoPUwzgPCKurmCBDl" + }, + { + "wUHQFWHE": 8259408, + "VzUD": 7, + "clhmPXUief": 2, + "rysfyAl": 4, + "hTwJh": "swkgJseZNdKwXocQxeuuDiAhAkAyStXkRSgD", + "mdhBfqDrX": "MejSWHcnIAMSWXqffbmCWTSXpRbmyBlTeTKs" + }, + { + "BcLOBQRV": 2384915, + "xLJP": 5, + "yaIXYcuwCD": 2, + "ukVDPQv": 8, + "eMQSO": "uZyMjsmcJRwIrxlWhWlQuJznaunk", + "LhXRomjux": "zANDenVmhGNFJKeARKkXEEBRkoMM" + }, + { + "UACXwlbS": 4593772, + "YjLr": 5, + "beFaYBKHiA": 7, + "gkdxrVL": 8, + "ojfFY": "dnYQdCeMnqqxZN", + "DRNVjRfjH": "fhRNwOUrFXLDAV" + } + ] + }, + "JKrdOYIQUutPaKIMHiFf": null + }, + "iYPjyGjAKeIwmIKPwzAzgvsVSASYns": { + "HyRwEDlNhK": "INYowiqg", + "ixDNOnyHIPhQt": "pCYJORoZMMqjDPzHTIGzslDrBFWwSUuaRRdywVkuSuTFHwjHKAOzYDMVKvXhVEGqVRIUJllDWChdjfGRRDxwQZikiRQpzOwkF", + "aIPuumcFdhwPKj": "HoZauRPJpQVXbpiLZVEJSMDUgqZGBbVGJmKDvAhPmxUdduFVP", + "kSxTHrhafUOFtTRNcTs": 88, + "HbThjVSaOEYsXp": [ + { + "dw": "b", + "jqsZ": "uXPBCtovSqE", + "LyDwPspWbu": "GqLLuzhAfomv" + } + ], + "hXSlwNKxGkXxNum": { + "qrtLaDGRWYwU": [ + { + "CjmJvMKa": 389, + "buPsLrTkBPlzUi": "ohNaqUUXgNWegFJMLqRlSmpjdB", + "BqZv": "ITpSqRRdtpwXSNPxpTKSlyxqpIrmVZHIJDBTSSRpLbrCAMtgmrZtcrZFfdAkcUQVAXgApkdERPvJktFHQVZkloIKCxasee" + } + ] + }, + "BzwVGqXOgsFSnvoiKBgi": null + }, + "IkMUdnfWtCIQXiVHWLMmna": { + "sfsJyHHikG": "OJQzjOGR", + "GExIhvNwzAFoC": "CRMdyBjRVthyOWbvAqyuOLRwmsejtvxObSOkOYyOnqQJrSMSJLOjEeapCSMwuDThjJOKBjFvMKKCftpJwcwdBoPQQ", + "KQnbulNgdRjgvR": "DSItDQNQDOxCGOkzBugnxdrCpMIFysGB", + "urvISwUKEkrbNIzOYTp": 73, + "rVzmkRfjsdNpIh": [ + { + "zG": "s", + "qoXf": "cqPNuTrkNBdaipsQZsboFTjwBpGs", + "hDqUKomOht": "IXeFjZKgCWAxnczhGh" + }, + { + "kj": "y", + "snSW": "qkHnlUjymmqawJbOwbcbZwLddnkFPpzvgbKxSZR", + "LlOTNAEWuu": "wuPqpBbynoqphyetZruBVYrnOFpMFkj" + }, + { + "jz": "V", + "cnGq": "hOeRTXmPJpOJQmVemZLqFNFpvCDuxGDswcCbrHpM", + "traOHspbRX": "nPQVFnTpGKxylFAkymUzHGovzj" + } + ], + "PsAaNeXOJKnDGuv": { + "oxUJcwvEyzyfZiTLed": [], + "uQOOQzWFvsmWbEFiLaIhOrocCZijqPU": [ + { + "dHUMtcWb": 85, + "fqoTKfOeQ": "SNbsylguksFxHzdlb", + "rKEvC": "XFGOcrsIdfhanyWMkLVtntJWCHLZyhAJjdpHuhQdLeQIGkgrJAkHGXgwezQqYnHnresPYzcSoFeHzUQzHsUuccFmhxRsIiZqFT", + "DbPuKJj": "GfbWEWaKAuPkCPpGRLtmxuOAZRDBNjfoiqARoCEG", + "Clne": "olDYCUaLUlEvYNLkjVWlxIkIjaBDUnvPIaikgKESfmFMbanppuAxXzvGxuYNndsxBexWMOSgNxNdcn", + "kZWiLI": 5066.92, + "kUCsVditfgC": "jmXERYF", + "iApmqxbKGX": 902625679990, + "WZimIsfqD": 7276919958509 + }, + { + "YUaixnQc": 985, + "tGlWxeVJk": "cnKiOcwwZIOVeGAeD", + "hGInR": "iUUidOJxPjEmdryOiqmCLbWEbrtBAQrPAkturwfdQrxiazNPuheZlfArEAkndZXcwHqHSjkLVxRrweTdRkvzYdhUyevAgjlAuLkkjJsDSzCPvxxTHFdIbWstDlG", + "JfGqgXr": "wlMhzANfrZJOiQDiCXNoucTTNXdXDcEPKZN", + "pLDp": "vyhDFGSzfZYHXSJBYsfxOfqxoMXLRyQiNPlcBIpGvBdweDQjgDmdDEfxzrHRJNQGPYWNQsdAEmCOuK", + "fEyCzb": 3243.94, + "oTFPnVAOkGb": "gLikIHu", + "ftqVbkPdih": 939431310405, + "KVYkHxRnZ": 5443974824723 + } + ], + "CmzfuVsgGtodifQOUsCbuVlNSe": [] + }, + "FIhZUrphEmlMpwRvXroY": null + }, + "another-level": { + "mlukciiYIy": "zCXvUxXu", + "CnfMDPFZOGQEI": "miXAsAGcDSncucISmpPvVqkRBOMELoCdXeJHNAGfpJkobprShTPAJBngvpkuYneHLsrqsVZkrVCVrmcbuReieATTg", + "GcEdfwLAsprYbk": "MotmAOvgVXsKIhTtCQUyYVviArnBWgYr", + "shnfmTJxNAjCYIRZbqE": 30, + "deep": [ + { + "inside-list": "q-test-value", + "gurT": "hyPIFAsGGjJrqIkvIunZMRFuiAzWUEIf", + "sQunoNuips": "hBPaXgkzgirAtWFRiSzvH" + }, + { + "Ji": "A", + "mzNy": "pRozZecAPsYFfpFKWSMHJkBTNQeQBwdXKSklLSYdCqMm", + "RZFoexDHBd": "ejNlGwvktjcGhoLdebJhryTf" + }, + { + "zI": "f", + "YCLX": "BZsltRfcouxQvlrKimkBEmXSqPeWUbfUUMPPvTCTyzC", + "nATlIsuwIk": "akRjMDGYlmJQdsvbEDxrmOxpgJ" + }, + { + "ZG": "S", + "fsfm": "jpnbkpwONIzyEfCRmfJirTePTPyPLKIUGhIlLHGsva", + "oBqaPDAwdQ": "SOlxsEKyzwvuSRnVudndPDLbKnXVfU" + }, + { + "aX": "J", + "ATcQ": "HIKOIlQLZKFKsGWizKkbltJipVdebSWYieREtglNbVhnmKaDuQIqGG", + "VBixFproZM": "JyyyDXQGGhBGUuvoKRUgCxDisRwktSDAp" + }, + { + "NP": "F", + "gJkc": "YjzqvcTtFpYbMoqWRebzoSCnOOuLjknMuldlVcMIGlcD", + "iWHzDRCesT": "NkyxSwhEHoNQWkrVlATxRFkKZrgmG" + } + ], + "qxjFsOxYrHqndWw": { + "kPFsJUBksPtZGZNYTImXG": [], + "boXLFpUBeyymYVQlONCZqPgs": [ + { + "kXhsgeuH": 645, + "KRJSrwyUw": "rGKdEYaIZlvmGsidj", + "eAbHh": "opnBCinG", + "ePFMEEI": "pviqdZqNDYndpoADMaMypWXoNadIaAMEpONhijsimGNlVKzsvtJxFewDpaFuzrPhYFMMD", + "CFSZ": "GyqWiSKbfFrrFtZIFzYadCwqXKvbQkBHiJMquyxthZXwLEbUmaYUNXiXaLwvaKOImiSiXeXEGXtYYl", + "zZSuDi": 8468.66241833, + "NqRRvrpGAK": "KpabDY", + "vQAYWUCLri": 1337206744292, + "btnUszoxojT": "sCjqoSzCcl", + "lPoQmj": "quJMDF", + "UoWmnAzd": "qdoHexEHdFBxtgzwdwzzEZwRizboXqfAXnhOHnTFjpdZVKHjeVTBRrrVDOukMYYNDeeqN", + "jDJMpKcPP": 8379092631755 + } + ], + "qnrTGJHiIXgpZcXYPkuamvAiXL": [ + { + "emodjnAn": 543, + "BAEnSSMih": "ulaBwyrmoQNwWxZwn", + "dNGno": "NLmGXWOf", + "lPxbHIc": "lwkNVzxUBOQZhsjYpjqcHIdnYVFQaNQpUfWwikaxhs", + "hnph": "BVfWThPsXleSDbysvmGZkIGBKUukiSxStEgczqqmVATdDyNcUZqYDLSReenzPxCUufnGEaesUpFWGb", + "vfIEjn": 3264.24432241, + "fLNcrrBOiS": "MXYFoX", + "HGYipRZebc": 4367194090471, + "NOSgotBWxOP": "zqUwwsQlwV", + "htGzhH": "prNQup", + "hONVdjIl": "CgUJVLGNnqinwWKhNOCPKSkkuZPTkmYgllzBvkgNEf", + "qVphjWUQx": 4327528108500 + } + ], + "IYFDxlctDvPKbXuUPNrFROrtBdKEfx": [], + "WWRwlLMfbVlSzXLvvZhYOuQlpLzUNXhEb": [], + "kUFUnnvpsdiFdxRjqCPubBARkiPuK": [] + }, + "PGHrEwkgDLjXnthXURFV": null + }, + "KNfWgzXAbpOIQzhBCbGqiHh": { + "iDFKxGAvgL": "UXGQvYSY", + "KykgTclosDeBe": "sswTusviDgHWYKdpFOarusGeUNQhhmcCdLAuKNdpFDzkGCjjRjgzgpZfJggzYurFMIvywsBMQrHixUtEJyOfqYoyWr", + "VsiaCVENQIOZkY": "WuNqwWVZQueOVPnXNhjclBKczfvUONmCyZeZUtezFNGvwarXkSo", + "YZxYNVCiWxDzdvefIbg": 56, + "tRKloJtUqMKsXT": [ + { + "eE": "s", + "pvoW": "PseeDBqWKWwRqNWzpKgJoBqAyizyvTexLEaUpCMGMKCs", + "MjOtyZRDjp": "CvSddqRgxWPEiBSETqycHhVFPdkWudowZUX" + } + ], + "OEOidnwMIXvaOTp": { + "GgALdhujAGMZmDPkJagDhQriYcqAWmNAfIN": [] + }, + "kdLfKIhGnAFChahXtqMd": null + }, + "NPnmHgQRSnSiCAfQpNYbbOmMOILcsePYj": { + "SDaijJGmnE": "UHtMOOzt", + "ecoBChzAdTzAE": "KtPIdsawotEXCekSGPaySUBzjPfIISnQGQQqjWQHIoNRHvzWmIPIrRPIOZCOAPFSRAOBjbRcHhSlfiJqaRXXTcEzQHGSQrQrB", + "MjnqBzitIOKFuDeTpgQ": 48, + "xdvcHbkHKtQEbq": [ + { + "Cg": "u", + "Iwti": "ddVWdADQypEFcJsdIysGrCoINuwUkofxhDgvZHaWnON", + "exBxcPyYoF": "FZnAbeTpGzswPkpmDREmDiXMZrOcR" + }, + { + "ZT": "Q", + "RqCu": "SDeaqemsIhDICtcCIbejcEFLEzUuYlv", + "EEQCKLbqSF": "JXEABzhSpyTZUNbLNOOjRPovPeVRzD" + } + ], + "ElqrjJJnQtbfYHk": { + "zNBjkzOFcfurjwOflWqoSoJHGcgWt": [], + "RzxxAzSoKflaNflshVFjmgDBVihViQ": [] + }, + "NXpcmgwnbpJgvXSEmZBy": null + }, + "YRWRIllnFNzOFxeOfWsRRW": { + "dRPBQjRsQH": "tsanJRTf", + "dxWTtEeCciZyv": "ZgLZPsRUCfZrDCzjFZugEOrDNQxuWcZlosVsWfVKsRFbwneUmZOVJLRvqSkkqbNWFFdaXovZOzcFQQfpKazMpqXLn", + "lSnqsyYMVcferd": "JIMncVFZVoKTCRRaofAvKELeXCDSvUvAqottR", + "AkTMuJnjHdFOMhdIPvL": 40, + "hGKIvkUJeFpqAiVLTE": 0.1, + "LCbZIgxDMwsfnd": [ + { + "DE": "j", + "DiLd": "yTnKbAhURddC", + "qDomOIBGrK": "jeGXxatqaiMNUS" + } + ], + "yCOvIPbwEFamgrV": { + "IROWYcoirJGdBW": [ + { + "PkfavXEg": 51312, + "SdLdWmbDs": "hxkuAiyaKABojvWUj", + "zqnQMWOJCvi": "CfAkqLnivujzkmSJzLyACUFuqckDZffoFHyLQXPIyJLPUu", + "rfcrKmYGcSez": "qqONyt", + "kXWOBjrahEwH": 688204086555, + "tIoAdvGHvQihUK": null, + "lhnwfonrEgAbIa": null, + "PvpZ": "ErjqcXdIEjxNJKTAtJpiocEGqfrKreFXzybjDaEnMolQIgWFGifJyougqkEnTbtkfnEIHUwwR", + "ovpZOvxuJuiUh": "sJxbgmMCgyRqy", + "iCsefePPEtXwmAFyBAElEF": 1631732229544, + "GVPsrQLeX": 1703394242891 + }, + { + "urKJkPNj": 39788, + "PunDUGyZr": "yeyAAQAgjcwajeHQq", + "kKDyxGuXMvA": "WJtCzEVpqVTt", + "ChBfyLHpYKSZ": "RaALMS", + "LQqsVuXlQyqy": 927018569345, + "ZbhiZXSCKndYVa": null, + "WEaulqQpkCralT": null, + "tRnS": "jywWCrEHUKEOTaMtDNAuoCnKEIvrsDjOnsexbgFFZNMMrncnWrgvBsoblnxDMTriOdtoCfoWR", + "ciiWoRJygyQlz": "sNAYRXLJAJFPy", + "DamhVKfvyPqfQdBxBNrssp": 3740238446611, + "bNRhZXtph": 2276109534140 + } + ] + }, + "marGUjRDbzFHcFEtSLQS": null + }, + "jqBEzDosuKbGPtWADcBrld": { + "zhswOzzqNy": "jfVDhVxc", + "GHibzoBXWVoAF": "vMbAYoftvcAVgQIFAfKZhWiTRdpSZbdbIiMEjZKcahqBxZbiisUYdbSkOJtpFyJSVIBEuJsmUApiDozXMQRoyxOzc", + "NaXQABuwoCQFmK": "wdwZffquHWUHEAuVVTnMBgQghUvcvtVjqcWjVwiNOupUQGTCbaFKfBkXxhe", + "keedCEXRemGflrVZoMj": 54, + "gyIMHdqLNYmsON": [ + { + "it": "PN", + "JzEV": "CWqhQbRLdWHSBNEWslzufvTSjgccnHyg", + "XFUfCUVabj": "CmJKehESMvSGKMOJBggxz" + }, + { + "eR": "Np", + "vJmV": "aRblzAuYCHzKLyos", + "tSMrRZPPXy": "bYeIPQKVuaRagdnIj" + }, + { + "AA": "LH", + "SJIB": "bruXLgKJJpdVnfsIzJwLguJRpeMNgOPQsxMpWoj", + "nfCOviLuVs": "LkRxwSgTsBPdvzkNoynfsOkKPSzHn" + }, + { + "tb": "GI", + "ErmO": "hQXjgkDNCLinoyLdQHL", + "MvqGbTZDhT": "JgbfDZRJeCTcmRWK" + }, + { + "VT": "oh", + "gDVN": "MKpIWDZYowgqqjSZewNfYVtOw", + "jKrlMGMUlt": "zARquavZLTZODgCYAA" + } + ], + "sUyDEIFIDTugdUa": { + "bBhGHevLDSmYvDMRrwyqN": [], + "DyyDdnssgfxkTWVtM": [], + "DiCDIanIMgrIwzSJLJIVxkMQjcTrW": [], + "jyDYYryGeHrOAMiD": [ + { + "vxstfHhp": 46341, + "wliJbJSbktz": "ZUx", + "GVqxRNDAgYD": "gUTfbVwyMd", + "bcreNyUEbk": "tABZayrk", + "klIpCWXleG": "FzMmnuUWZukgLxrLxiqosSnoEhFHBNoQlZwxWMNVQALHlRkUBOHloGthsDnqdedAtINmahFjCd", + "dbEKOKxFkSAjzp": null, + "XibVvl": 51.7, + "uSnRHDd": "xJiLvMvPyCrLNddMxdkUZmhlkdjaborlXLQiyWEw", + "MBxtfwFrI": 9606836466097 + }, + { + "kpguejAy": 5844, + "bmjXJeUlLRJ": "zvo", + "GpkxboHNBTF": "mRhrUJRQaQBwqABISdJDnCeXe", + "pCMZoUMbMI": "COVQnpLf", + "jriMJKkoff": "vEFeSnqGaEFFfDQdAJGutKghdnHuCYuBktxrQjdKjHNtklGSCUkJXtknnwKoncmAknYuWeNJQS", + "goYjxAyLAEGUXk": null, + "AzoQwY": 269.76, + "JkqRgaf": "HSsfSokLjZEkd", + "njmvZzZNz": 5742622133449 + } + ], + "qqfRkAfbZrdgBblbQw": [ + { + "zLemOZoD": 76, + "dmgxURtCw": "mxVBOpcxKNukmzVqO", + "MPzbCW": "StQKfHOtD", + "GRAyU": "DFnbnQSGXiqcwoMJrbvmmBdtNebdXeczi", + "scKgKzI": "hdwAfZDXINklHzpFMnEQdRDtRpcAptQfcfYEexDcnlIRwraJEBmWqvNgZuAFeXhgNedjDvEAvsKftVCcKwiqhVmIlSaE", + "ZOqw": "MzJsFzFqpootUOuThjkvQmhOaWCLOdBnxOEGLrNhqBluEfqFxyuPtNkFtXSMoJtyjYjgKPysZGmptMbDiRJeYAPmG", + "NFwAAG": 5176.72, + "SUEdNZosa": 5197286070086 + } + ] + }, + "TejxCezasCThYeurrEuJ": null + }, + "ZZEnDphijQGNBypYDmQenl": { + "HriFSJFUBh": "VpghnJef", + "MCpSylkvPLeeo": "svLBzYdemMizSdZmlXaKikvvdYyUvgprPOhFvVFAtvjarGfbFiUpLUANrohLYLDSaNycXxCsRxUcbdBBUSoXMsIqt", + "YomEUTirqgfPwN": "JXtPkZQiIvsCVHRfpkfqJKHLYCOiquHwhVyiJYIdhfb", + "WlfvSKaMEwLHHGuNNYD": 67, + "FqeqKtcJBdDmvK": [ + { + "uI": "s", + "aooF": "JzbuBHDlLmqppGuWpPCfJEoZBGbpJZPFfATiD", + "krKqAeKWNf": "LGYqdUWpwcOBVnnKbHJoRjdFv" + } + ], + "TurIqeqrrHSjzYU": { + "IecsBNTrPUdPRUYzOQZUFOdTz": [ + { + "oddxFZnx": 972, + "GvFZyLzIgVvnPsWjHpY": "cqfTUpgjLFUoVYFwO", + "kZCOb": "TtDyFvfP", + "BXvRvLAvzoFNWveNE": "vXGPdtkErdSHKPjEUvXFYIoNbcahzHNQoBWmABTwOBMohAQAdUQwzCiaSffXZDJWaqmSXwwICcLATAGCinP", + "yvSICcAPsMnXbR": "kuRkOMLRoDfaShpfSmQmPUiewgTSONhJqLrlKnenTOwEOGLhsUgTMjAIsCPjekPs", + "nTvsFY": 1983.25775195, + "ObKvAzEFewB": "hCrgXDlFqO", + "AeuSYLmSU": 3997831424599, + "wDkPbqWzT": "mPmsnzXqwQchooZnqrHkkIbkUChJcBevlNg", + "VFSdwye": "SxCrJzbfARzwOTQczcXZcCk", + "yxDJ": "DTMGGglkCvYvWDJdFkRRznBJvTIxlyuCZafUkCDbxdRdKKPfkOVpGqPZrwqQdHGLdJPkbqNRVHrczJyNfqhbnsodCVeFkT" + } + ] + }, + "dfmuLUhhXdftfnMaooTI": null + }, + "QtQYmzdOGKeW": { + "lEJXCBlUOe": "ScVeyfKi", + "EscHtUjObLyZk": "eTTtrjaVmFUadljRGOhUbUPgibwpYQDdMWBmNABzQvrxSUCZjxvTfWsQkawqcXkJkoAbKZZCcO", + "gBTtAaJyaUkeAL": "tQRQIvvXCOSIpPBSocMHVGNQoNqpUBcpOeX", + "JoBLSfQLRCwpPLBsYYT": 28, + "lvJIaKfeVhdqrG": [ + { + "Fl": "x", + "HExS": "oTOOYUYhGVRHOEXYPwFEqCGeTTmmMcob", + "hiObWgvPXw": "NkOzxqozkvNlezkrH" + } + ], + "ifBxEkXcLTKbFrF": { + "dGCslZCVAxJmIqCCQ": [ + { + "OVYQSUTR": 99, + "ZbSpZnhEOUGRSGLwKJdrWCEERi": "aUdQgZIpPQo", + "vXzoxwmVKVNbwdqHkpFAgJurxPhu": "TMbaH", + "khtJjmTVDdgQOnVEipdYzyLRGmrVup": "NueamThyWdirY", + "NnLfXsmxdfSjTDsaaIcVkhbG": "joOoS", + "oZxPqrebudrHYJeEskjVXgWrFHdYiMCXA": "lHWpRBTHelNgEINA", + "DVayuivgKJoccQRdakuuqJUhRMQ": "NfSNWsQ", + "qnGLDWFrKozkQpVIXCneczU": "hDCunOHGHesvjdIZiUT", + "mXWpmlVqBYTguvDmM": "DTBHXrr", + "rfMhBuWPUZKtzKHlRetzPCOiBK": "loqavRTIYkZWUYDmVRfXaa", + "jNieTWhAammmjLebOpop": "LtLQxouUfXwEihgcTCUOLqhtfEKpKUNxVFKr", + "MlETPKr": 966.76942147 + } + ] + }, + "RCKbeFLEVcVVgCToUjQr": null + }, + "FsJyBjQiC": { + "wrmASxpQrD": "NSsgTqut", + "mKqsjCGnnWZnX": "xFwvsHFOlqJqUDFUMXAzOqCAgfIKFfcIgfiiQmMhcmgtTMRRjbEOdcelghOiBOWnTwaHWioPYDmYjpydHZzslSb", + "DIihcGMzeSncgU": "MofiBjYGXcOBoDDDCeltebYnFgujQhKpBWNathbIyfcSeW", + "kDnJgAckltZpkiRgvgU": 15, + "JCwUZVopvfpPSj": [ + { + "iW": "P", + "cOBY": "UQNqgCIerPNslAatZubiGylLRIITOmvgxtsj", + "cXsoviCTgK": "GDYppAXPxwXdasqmXTlZWw" + }, + { + "Eb": "C", + "UaBw": "tkUOIBcesFiHSySbJPsWvFUzeYbMcBQCtjXt", + "tCwwFJRAlg": "IdjYnMVILDzuWarnlpovSl" + } + ], + "RxpKDunHqEGslVE": { + "FnoKxBiaAmkmVcnVgthJCm": [], + "WcPolxGTZctqgjVSOHXDWF": [ + { + "TxPPNcqY": 79, + "ssbyaGwnbSKqdwXSmBp": "YrPjpBNV", + "EydDkRvqINikusKKuIm": "fCmfcwnliJlqqjJWBBPiopaTkwShykZoLmXwLPujuyethKpVwrUx", + "KRhbOemIgEIBEMfTTmAW": "egsabOQzBwlGrcezEFGiK", + "cQPk": "BAiHhexdiNcxazZAMtZQiPdECNPoqzsOsbSqZ", + "IoVeYA": 578754, + "eGxJLbikzmYG": 6308740477591, + "IQgOYkY": null + } + ] + }, + "VgcGHHmAGmiunYchgZSy": null + }, + "ZrEhPOLhT": { + "ThOhSuEhkf": "cMLJWfEd", + "JVOajxZCbdyuA": "cxaFUMiUyCIaLwYVwzoizJGeiDSSWyNlPobKLGdIMfDNIkXjtWyADbaidasQUPFWbi", + "eCEkbtwEfreMVu": "UmDFqKGDSbfSREnltivyQIAkRjRCrOSDMZsVnWEBumJAKGfEndHYhhepvtCuEWvuKEVGWaytoIKXPfyAulWhva", + "ZcWVqhvhpGjqRaUepPm": 13, + "AAVUVpQBzqOdnf": [ + { + "zs": "f", + "myMw": "VfkIFLvROCCkQoUApAnoahH", + "RZEQcEjWVL": "wvNUPkVOZNEFeBNjT" + } + ], + "XgZpFrSFdfOaiyG": { + "EOpkfIwYsqMJVEuCd": [ + { + "WNWqbIGE": "KV", + "WBelZShKJQOfrdzaHmeB": 5472896691686, + "JBIXmFBrqTZCrFiRIsX": 2537640159589, + "ZqeyuVaHEzqGguT": "mU", + "IGGwKTUNVuIqWhHxguJ": 920159032553, + "FrXZApVt": "iw", + "PwKyEEv": "WVgel", + "pzerZSUF": "OeexCJqW", + "iEZNSXUzExuGRBCJPpfSYAjKGVMySEg": "Xxod", + "BYeMnDMghzdu": "VmhfTPb", + "RAfvTTlXFndzMdzQ": "DijII", + "lFyvSptmIXLEaCITguUlOWUiKinU": "G", + "ocjcEOUYvrjuXHGcPvMZZLanWfVO": "s", + "VAvZVQCr": 56168, + "cNuPEdjOOBIflxU": "zrlEWdItNm", + "CdivJDXYCdDGCKUwvYzGhc": "RLQNkpniJf", + "TxUWBLLBIvWnLWU": "gNdlqeZMWb" + } + ] + }, + "mNeCoNPnyAGmHbaKPcew": null + }, + "iSdPgmIL": { + "PQWhZZAAmc": "KaxKwOfV", + "lBixuJnwKkTXb": "mdYfMahBCGKxYxYUzmuLWatjbvwoEJpvINrBfIRufZilKWEBBhTNVfQNGacQLFrbBPYKrZ", + "thwyvLhRZtMEXp": "CfmYqPuDqxdozCexWCVbAMhFjMADKtoJAVooKRavwe", + "XBwebaSjudhpIdegNuX": 41, + "PVEhBcIvAEUExR": [ + { + "hP": "b", + "ZREX": "yGjjCFbIHmChGFjCwUWPCL", + "fSuYODYuTI": "XSmBDnTzFsWKfPrSZtNlw" + }, + { + "xk": "u", + "MaTZ": "nHYbQnreirVRtzxtNBZZxAiPdOkMfKbTAumbOhIHTtZe", + "aeELNUjGXc": "ckfAhwDcStirCeYjEiDohaqYZCubwHlDosxRy" + }, + { + "ip": "D", + "IeZa": "dIoIXtndoYuPqtFWpGPNhrICQxGGtZHwNaNMPCfwsgqs", + "MLHSeWQvun": "asWubIslOzHcXEvKvmgFmLXOoPVBXWSZZLjgKK" + }, + { + "KL": "a", + "Deci": "LqYMdCjQvypYEYeqfkZxQZSyNOuchvzxbPYiqrPOyjxA", + "RGdhviYBSA": "tYysPwlcraDHTaJOAhDOqHgHrnDtgQUtXdQvs" + }, + { + "ui": "t", + "uRPi": "BlzWoFrIFrYNeYFJhWDgiEz", + "pOfcXLFiRG": "ylYemBXNczSbqgHiMgBPPmLu" + }, + { + "Am": "F", + "OjNZ": "fQfHSNQqHBCtZcwhcAKyqxzdFfIIOxMftGKplAoAkwjgOhl", + "SvFMBmUzak": "UYsxRElPxuenwTrLascZSuadKdLlT" + }, + { + "bO": "K", + "sidr": "PjqwXqNtAGnSQyv", + "tqFzZUMriu": "dDLBwYlhxSaW" + } + ], + "mzIDMiArtQSpMXe": { + "PvZUFGOrSQiKVPtioChPn": [], + "bTwocvbiAytXxsMDuWWkobDytxmCDrpvfvrmw": [], + "dsckhTZaxeCoMoutliYFhNKMeggqpifDiaDUiV": [], + "iMJDWUfULWvFEKAzyksTGIwcgEnuBnBCqsAtV": [], + "aFbYQFVXmVCTAVgCFIxjxlWa": [ + { + "rxccKLhC": 607209, + "jxRkiFOszchOWCXsPa": 853415, + "ZFUqqHGtgGfShZrfyLlEpeM": "PU", + "ETALsHZNmhTvtPLQABi": "EPnDNvAMzWyBXZIHiCtdFfZElKCpVelqfjpHAeSjocCczAEUGTQSOxzOELKkmIKsfflKbLczKi", + "hJAcpYcQCTTcMLWHxpBv": "VIClylsnmKYna", + "qgtueiKiivReWVPilYZQDXunxqvlJkXeBF": "fbeTJKqcdPE", + "UFCQRAHGHlptSNswzzhubxWtHadsgZ": 4, + "YwvFAcZpEfsiiCJIxORuIfpQxiQgz": "r", + "dXpmjLAyxkRKZiHeqWMrcnnWgmvgSl": "q", + "WOyHyfMdnEplxmZaTLooHZWChQ": "gudjRcFWOLRERIeKXyOgsPgIXPLM", + "odvUOeunJkgQwlTbGQfMpqQXpWDbxB": 49757, + "sjsEqexwjwmVjTofGNUAkrONNkXxRUeSKJwo": 1, + "XZQdtbutsHYeEkAlSDh": 14902, + "OghMlcTCKykmdSOoMFqKqILVb": 9, + "hLfbsUWGKzNHwJMbstdGnoY": "tEsBbEZL", + "wERJdBMqpOvISKCQbOVUhaqijbPfF": "m", + "HgVrSBmZQAod": "aLPSrNvQ", + "HTMJaQlPdeialUpCrn": "o", + "tRcUUiuNxlFuGwgmqJQnORXNWfWla": "ujjVgiOKgDzaEEgS", + "uQqUWGIGJRXUmuNehmF": "bDkLQRq", + "ZuwOFzVKzhZXgKniCDa": "KlBNidYpPOduorGECuF", + "TeRfmZady": "kJxokCn" + } + ], + "dqvnKDiCkvhCqKrSDGkpRkXCDIftR": [], + "PWHymzgDFOaA": [] + }, + "fEyJNjSyMZPIFepbZogE": { + "ltwjGeZxWeeGELSWMdxFT": [], + "WgRMTIQEsVYDwrkYujshoKPATzuKICzHHyrnW": [], + "CdfTMvbcWnDzROPJLmRTHXBkYqlrNfNLfqrzcZ": [], + "FqFcxVmbnaBdBNbzmnpaAxxRnBsDktOpbsvqi": [], + "eGyzHvHntiqnRHfwFkOuOTRm": [ + { + "ZaLEuyOQ": 551336, + "JbjQRdfWNlihlrTTNe": 665065, + "VTdVCMIYsielRhFvXgKeLsK": "YV", + "uEYAOFxlVuBZlSlReeF": "zYvYQKZfkCNnxyrirHQaILExJKmNbzjGSOPBQicLZRjpgMjpCkVshOxoCReMvuwLKGVYfseZSh", + "iNLaNOeehzRdsLrCqkJK": "BHxezgseqMKZf", + "dukMUTiUlQZQUufztKLovwiQxschLRsdOW": "PnhNVRmJNGJ", + "rcSjXuIaGcfuDrQtTmuXXEgcfyniLe": 1, + "WJxbdGMIEbrjbHnzKYHCfjMyBrTYV": "y", + "sEyFRkVqMpCXQlBUVbLRYZwtaDyoPN": "t", + "kfjgJjpqynKLMhDyDOWmtHeHjP": "WnHxhgOETDJUITCjlBylUrscDSYK", + "LejvWPtJwajVLTLdDcdaEMTXfXXMLr": 56303, + "ZGovOsYyOKxOQsOLobkdhqyZQjsSnJrADkuI": 5, + "oJbBWMqlPQjuRRcxeab": 38262, + "EKljILsmyegQoCpgxiDZUHqRn": 9, + "tIzcjBaZfVEGVHDdzlSERWY": "EZzWUFaa", + "kSwZsdMwneegDrbDtrqDrsHCkDhXm": "V", + "LdyxtvxSlvoO": "hhOEdElb", + "XXhMDijIpqbbFGeWSL": "s", + "HkKEgyKDWCiuhraOmUMtTJwCbRurs": "IDQCaeYIKCokMaKZ", + "vxTksqnWOuOurGvqmCz": "YCHSCGH", + "JNgtIFmpcCKZNYcdBcL": "uVsineYMkkJqRxVcNXc", + "TpelcIFbX": "bXyOyCr" + }, + { + "SALUwgGO": 422160, + "vUPoqCeyXZYVyKdDez": 110181, + "wuudGJomXEbQerkPAeuiHdQ": "Rq", + "HJaHAJDwUxlDJVZEJtL": "IADfuWftNRNMWsbTqAlTcebAdSriMXAGtODjtVefGcipUuhpcpXvNUAACECiCHGqldwqyQSjzk", + "lOoqUabKofbFaFNhWAce": "vHOeURaAPZKZo", + "fPAsoVQTMaYtCLUIhtJSoEXwmQiEueBOoT": "pbMtzhGNFuw", + "MkopzNpKrfqKMvfQudArubAIJCKaPn": 1, + "mGdnyuYRkoepaFhWcvurdzFptTfpY": "q", + "XXFafRRdtZrjfGEqUJjsKFHFJNkDgb": "m", + "OfYzQuLFBLIztNRebcrgApfXFr": "CKWuWfYuemwUuZHASdutpkyZDEiCz", + "tfCWhBWHBAPCjiVflgHumZshNyDlnX": 61351, + "xFxUmTfoQRkBoxjUObDwGRLuNRoPqTqSgOZC": 4, + "NiDyWWTaTgRPgwOkhKm": 24342, + "UShOPbdowGsHPaVZIoJGAulgU": 6, + "maREjMjuZKBYfhjpKfApJNJ": "DCftkfKw", + "YWURXoXhvPTvsLYSSWJrQvuaESwjZ": "i", + "kQbsQaAUkKui": "UtGEJuCQ", + "JFXelEkDJbobOlqITR": "Z", + "WSZyvSdVSmEtDPuvaxhjvZMYPTHFU": "gzuAUDiKhSSKzUvD", + "CXNQtGtVVgZuQDmpIVl": "gClSrZn", + "hsOsbDuVUxYZshFaHYw": "gxnNDDnMkpNTygSQFFH", + "sYprmhCGN": "ilfhBoD" + }, + { + "HYYoAEXq": 888873, + "sdxgslAUiBmttwDBlI": 579463, + "nAyDIUtNKsxpDdVXDIbuQFj": "ay", + "CrgHZFdSEkIiBzEFsSn": "pAXHeVZezbDAFBVonEDmbwksjOkCkCrRsyWBvAKMhogmhaWUUJgvaHylPFeGqooHqiYPEJBbrK", + "AllKJtgeiHqNEMNYyAGg": "rApYKQbcQvcHf", + "EehtqHSYQeVhGSrxPdTwDpOpZtBOWryJui": "kCZEvWOCHBY", + "CSDFuGPObwocJLQjkMSmeNaHGwkdiT": 3, + "gIFGmniKsjcrTXBpdjXLgqAuTgNLH": "C", + "OAwnMHqtoUoyYeIffrNhMYpEcOhLgG": "e", + "LmEHwVpaVRpISbUSvyRQvGEsYB": "ajUjUMkYYtrLOzLyCRnrGIyHSPcMP", + "kfdrIuzfuMJNLmTaGuLsJgJIrzcLte": 86692, + "WzQgVsABgeBuYJagjNwvMqxbDsKsJQlqhNPq": 5, + "KDMMcdELUBZVMRQrFaq": 49970, + "UNZFnVUvWmdYvdMVYmMEMtCiR": 5, + "PTfmooQOsfhdpOoJZaIoRbP": "EIcImRxG", + "wCPtSYedohQXjBSTsVRzBninLgUWN": "R", + "tKweVWmpUfxI": "rXHiyDYd", + "xFXsHVjXcFVmvZmQLp": "A", + "ScZSUFQCzCspzjOSRdyPTukuCsfYM": "rZuKxCcnsFVYMRmO", + "LDcQksbwplenjbJvDBW": "UoiDLmz", + "INZskzBorkuzkhKNOps": "eUbTWBkljMMqbusgukl", + "mrfTmDiHW": "LFCjGfg" + }, + { + "EjNCWrFb": 742413, + "NMVvbenrZuBgTDUZCp": 186545, + "BUtfbTGYlHreDCxyWILogKd": "Mz", + "NKrESnACBnpJJHjmkTZ": "qASBSQVhyRLbdZdVxwzflldWiPJHVTVKiPYYPfEfCCbhdoOqFxDcioreqnsJfTpnRqQgygySaZ", + "aiNcHSGPLDbbWgCNwYur": "AYNcpKIqqjMyq", + "ILZhJVUfFssjrztzcpVVDvKkQYZzwjCoxN": "WnCnNqoerIc", + "lsLxVCBnLkmJzHHGiPTuRjGiOMsdcl": 1, + "OpAEZwatpCTUKdNeGhdrNYKiDCjKF": "H", + "oieUmtelFLuhNltJTbpnlDUrumqEUh": "w", + "HbCaXRqyuyMOwTBlGnNZXJFGxr": "tNegGiMyeuEJjvmLBHaftebPfTUgt", + "nqwdZutJpaaJnWIpexLGvelyBzAQAR": 23649, + "rcDqtgcbrVcihshHVKGJKswBScQkpIOdsOFv": 7, + "HIEJnealWLrYSoncIBg": 86311, + "MVXXrgZRsbVJXHdUHaCgKnwiV": 1, + "WKPsIWKDqDtcRBsQwualDYd": "EHpHqhZh", + "IxwTNNpzhgXrFgDdfJUrPSJJlXwIv": "n", + "iofIoBqJwtem": "msvMqPBN", + "QLCHAVqTVdLUOFjHXA": "z", + "NsHhiTPivEbEQYEREvxCXUUfgVNDb": "IAUoWhCriVcellGN", + "SrpJdwlzurxhyTYVZNd": "DLGNoPy", + "zzjpPxKOqxtIgPmKnhI": "sSOJzIbjrPSjjFECexD", + "ICTfrcAwa": "ilXpLHy" + } + ], + "RGTbNGqucucZmQwmPnzuMMGgpBYjh": [], + "QzSZKtDHHvnV": [] + } + } + }, + "uiINEgYLQtWZTKPk": {} + }, + "payloadClaimCheck": null + } +} diff --git a/tests/aws/services/sns/test_sns.py b/tests/aws/services/sns/test_sns.py new file mode 100644 index 0000000000000..a95b936747fec --- /dev/null +++ b/tests/aws/services/sns/test_sns.py @@ -0,0 +1,5116 @@ +import base64 +import contextlib +import json +import logging +import queue +import random +import time +from io import BytesIO +from operator import itemgetter + +import pytest +import requests +import xmltodict +from botocore.auth import SigV4Auth +from botocore.config import Config +from botocore.exceptions import ClientError +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from pytest_httpserver import HTTPServer +from werkzeug import Response + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.config import external_service_url +from localstack.constants import ( + AWS_REGION_US_EAST_1, +) +from localstack.services.sns.constants import ( + PLATFORM_ENDPOINT_MSGS_ENDPOINT, + SMS_MSGS_ENDPOINT, + SUBSCRIPTION_TOKENS_ENDPOINT, +) +from localstack.services.sns.provider import SnsProvider +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID, TEST_AWS_SECRET_ACCESS_KEY +from localstack.testing.pytest import markers +from localstack.utils import testutil +from localstack.utils.aws.arns import get_partition, parse_arn, sqs_queue_arn +from localstack.utils.net import wait_for_port_closed, wait_for_port_open +from localstack.utils.strings import short_uid, to_bytes, to_str +from localstack.utils.sync import poll_condition, retry +from localstack.utils.testutil import check_expected_lambda_log_events_length +from tests.aws.services.lambda_.functions import lambda_integration +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_PYTHON, TEST_LAMBDA_PYTHON_ECHO + +LOG = logging.getLogger(__name__) + +PUBLICATION_TIMEOUT = 0.500 +PUBLICATION_RETRIES = 4 + + +@pytest.fixture(autouse=True) +def sns_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.sns_api()) + + +@pytest.fixture +def sns_create_platform_application(aws_client): + platform_applications = [] + + def factory(**kwargs): + if "Name" not in kwargs: + kwargs["Name"] = f"platform-app-{short_uid()}" + response = aws_client.sns.create_platform_application(**kwargs) + platform_applications.append(response["PlatformApplicationArn"]) + return response + + yield factory + + for platform_application in platform_applications: + endpoints = aws_client.sns.list_endpoints_by_platform_application( + PlatformApplicationArn=platform_application + ) + for endpoint in endpoints["Endpoints"]: + try: + aws_client.sns.delete_endpoint(EndpointArn=endpoint["EndpointArn"]) + except Exception as e: + LOG.debug( + "Error cleaning up platform endpoint '%s' for platform app '%s': %s", + endpoint["EndpointArn"], + platform_application, + e, + ) + try: + aws_client.sns.delete_platform_application(PlatformApplicationArn=platform_application) + except Exception as e: + LOG.debug("Error cleaning up platform application '%s': %s", platform_application, e) + + +class TestSNSTopicCrud: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-topic-attrs.Attributes.DeliveryPolicy", + "$.get-topic-attrs.Attributes.EffectiveDeliveryPolicy", + "$.get-topic-attrs.Attributes.Policy.Statement..Action", # SNS:Receive is added by moto but not returned in AWS + ] + ) + def test_create_topic_with_attributes(self, sns_create_topic, snapshot, aws_client): + create_topic = sns_create_topic( + Name="topictest.fifo", + Attributes={ + "DisplayName": "TestTopic", + "SignatureVersion": "2", + "FifoTopic": "true", + }, + ) + topic_arn = create_topic["TopicArn"] + + get_attrs_resp = aws_client.sns.get_topic_attributes( + TopicArn=topic_arn, + ) + snapshot.match("get-topic-attrs", get_attrs_resp) + + with pytest.raises(ClientError) as e: + wrong_topic_arn = f"{topic_arn[:-8]}{short_uid()}" + aws_client.sns.get_topic_attributes(TopicArn=wrong_topic_arn) + + snapshot.match("get-attrs-nonexistent-topic", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.get_topic_attributes(TopicArn="test-topic") + + snapshot.match("get-attrs-malformed-topic", e.value.response) + + @markers.aws.validated + def test_tags(self, sns_create_topic, snapshot, aws_client): + topic_arn = sns_create_topic()["TopicArn"] + with pytest.raises(ClientError) as exc: + aws_client.sns.tag_resource( + ResourceArn=topic_arn, + Tags=[ + {"Key": "k1", "Value": "v1"}, + {"Key": "k2", "Value": "v2"}, + {"Key": "k2", "Value": "v2"}, + ], + ) + snapshot.match("duplicate-key-error", exc.value.response) + + aws_client.sns.tag_resource( + ResourceArn=topic_arn, + Tags=[ + {"Key": "k1", "Value": "v1"}, + {"Key": "k2", "Value": "v2"}, + ], + ) + + tags = aws_client.sns.list_tags_for_resource(ResourceArn=topic_arn) + # could not figure out the logic for tag order in AWS, so resorting to sorting it manually in place + tags["Tags"].sort(key=itemgetter("Key")) + snapshot.match("list-created-tags", tags) + + aws_client.sns.untag_resource(ResourceArn=topic_arn, TagKeys=["k1"]) + tags = aws_client.sns.list_tags_for_resource(ResourceArn=topic_arn) + snapshot.match("list-after-delete-tags", tags) + + # test update tag + aws_client.sns.tag_resource(ResourceArn=topic_arn, Tags=[{"Key": "k2", "Value": "v2b"}]) + tags = aws_client.sns.list_tags_for_resource(ResourceArn=topic_arn) + snapshot.match("list-after-update-tags", tags) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.get-topic-attrs.Attributes.DeliveryPolicy", + "$.get-topic-attrs.Attributes.EffectiveDeliveryPolicy", + "$.get-topic-attrs.Attributes.Policy.Statement..Action", + # SNS:Receive is added by moto but not returned in AWS + ] + ) + def test_create_topic_test_arn(self, sns_create_topic, snapshot, aws_client, account_id): + topic_name = "topic-test-create" + response = sns_create_topic(Name=topic_name) + snapshot.match("create-topic", response) + topic_arn = response["TopicArn"] + topic_arn_params = topic_arn.split(":") + testutil.response_arn_matches_partition(aws_client.sns, topic_arn) + # we match the response but need to be sure the resource name is the same + assert topic_arn_params[5] == topic_name + + if not is_aws_cloud(): + assert topic_arn_params[4] == account_id + + topic_attrs = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("get-topic-attrs", topic_attrs) + + response = aws_client.sns.delete_topic(TopicArn=topic_arn) + snapshot.match("delete-topic", response) + + with pytest.raises(ClientError) as e: + aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("topic-not-exists", e.value.response) + + @markers.aws.validated + def test_delete_topic_idempotency(self, sns_create_topic, aws_client, snapshot): + topic_arn = sns_create_topic()["TopicArn"] + + response = aws_client.sns.delete_topic(TopicArn=topic_arn) + snapshot.match("delete-topic", response) + + with pytest.raises(ClientError): + aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + + delete_topic = aws_client.sns.delete_topic(TopicArn=topic_arn) + snapshot.match("delete-topic-again", delete_topic) + + @markers.aws.validated + def test_create_duplicate_topic_with_more_tags(self, sns_create_topic, snapshot, aws_client): + topic_name = "test-duplicated-topic-more-tags" + sns_create_topic(Name=topic_name) + + with pytest.raises(ClientError) as e: + aws_client.sns.create_topic(Name=topic_name, Tags=[{"Key": "key1", "Value": "value1"}]) + + snapshot.match("exception-duplicate", e.value.response) + + @markers.aws.validated + def test_create_duplicate_topic_check_idempotency(self, sns_create_topic, snapshot): + topic_name = f"test-{short_uid()}" + tags = [{"Key": "a", "Value": "1"}, {"Key": "b", "Value": "2"}] + kwargs = [ + {"Tags": tags}, # to create the same topic again with same tags + {"Tags": [tags[0]]}, # to create the same topic again with one of the tags from above + {"Tags": []}, # to create the same topic again with no tags + ] + + # create topic with two tags + response = sns_create_topic(Name=topic_name, Tags=tags) + snapshot.match("response-created", response) + + for index, arg in enumerate(kwargs): + response = sns_create_topic(Name=topic_name, **arg) + # we check in the snapshot that they all have the same tag (original topic) + snapshot.match(f"response-same-arn-{index}", response) + + @markers.aws.validated + def test_create_topic_after_delete_with_new_tags(self, sns_create_topic, snapshot, aws_client): + topic_name = f"test-{short_uid()}" + topic = sns_create_topic(Name=topic_name, Tags=[{"Key": "Name", "Value": "pqr"}]) + snapshot.match("topic-0", topic) + aws_client.sns.delete_topic(TopicArn=topic["TopicArn"]) + + topic1 = sns_create_topic(Name=topic_name, Tags=[{"Key": "Name", "Value": "abc"}]) + snapshot.match("topic-1", topic1) + + @markers.aws.validated + @pytest.mark.skip(reason="Not properly implemented in Moto, only mocked") + def test_topic_delivery_policy_crud(self, sns_create_topic, snapshot, aws_client): + # https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html + create_topic = sns_create_topic( + Name="topictest.fifo", + Attributes={ + "DisplayName": "TestTopic", + "SignatureVersion": "2", + "FifoTopic": "true", + "DeliveryPolicy": json.dumps( + { + "http": { + "defaultRequestPolicy": {"headerContentType": "application/json"}, + } + } + ), + }, + ) + topic_arn = create_topic["TopicArn"] + + get_attrs = aws_client.sns.get_topic_attributes( + TopicArn=topic_arn, + ) + snapshot.match("get-topic-attrs", get_attrs) + + set_attrs = aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="DeliveryPolicy", + AttributeValue=json.dumps( + { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 5, + "maxDelayTarget": 6, + "numRetries": 1, + } + } + } + ), + ) + snapshot.match("set-topic-attrs", set_attrs) + + get_attrs_updated = aws_client.sns.get_topic_attributes( + TopicArn=topic_arn, + ) + snapshot.match("get-topic-attrs-after-update", get_attrs_updated) + + set_attrs_none = aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="DeliveryPolicy", + AttributeValue=json.dumps( + { + "http": {"defaultHealthyRetryPolicy": None}, + } + ), + ) + snapshot.match("set-topic-attrs-none", set_attrs_none) + + get_attrs_updated = aws_client.sns.get_topic_attributes( + TopicArn=topic_arn, + ) + snapshot.match("get-topic-attrs-after-none", get_attrs_updated) + + set_attrs_none_full = aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="DeliveryPolicy", + AttributeValue="", + ) + snapshot.match("set-topic-attrs-full-none", set_attrs_none_full) + + get_attrs_updated = aws_client.sns.get_topic_attributes( + TopicArn=topic_arn, + ) + snapshot.match("get-topic-attrs-after-delete", get_attrs_updated) + + +class TestSNSPublishCrud: + """ + This class contains tests related to the global `Publish` validation, not tied to a particular kind of subscription + """ + + @markers.aws.validated + def test_publish_by_path_parameters( + self, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + aws_http_client_factory, + snapshot, + aws_client, + region_name, + ): + message = "test message direct post request" + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + client = aws_http_client_factory( + "sns", + signer_factory=SigV4Auth, + region=region_name, + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=TEST_AWS_SECRET_ACCESS_KEY, + ) + + if is_aws_cloud(): + endpoint_url = f"https://sns.{region_name}.amazonaws.com" + else: + endpoint_url = config.internal_service_url() + + response = client.post( + endpoint_url, + params={ + "Action": "Publish", + "Version": "2010-03-31", + "TopicArn": topic_arn, + "Message": message, + }, + ) + + json_response = xmltodict.parse(response.content) + json_response["PublishResponse"].pop("@xmlns") + json_response["PublishResponse"]["ResponseMetadata"]["HTTPStatusCode"] = ( + response.status_code + ) + json_response["PublishResponse"]["ResponseMetadata"]["HTTPHeaders"] = dict(response.headers) + snapshot.match("post-request", json_response) + + assert response.status_code == 200 + assert b" dict: + kwargs = {} + if attributes is not None: + kwargs["Attributes"] = attributes + response = aws_client.sns.subscribe( + TopicArn=topic_arn, + Protocol="sqs", + Endpoint=queue_arn, + ReturnSubscriptionArn=True, + **kwargs, + ) + return response + + subscribe_resp = subscribe_queue_to_topic( + { + "RawMessageDelivery": "True", + } + ) + snapshot.match("subscribe", subscribe_resp) + + get_attrs_resp = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscribe_resp["SubscriptionArn"] + ) + snapshot.match("get-sub-attrs", get_attrs_resp) + + subscribe_resp = subscribe_queue_to_topic( + { + "RawMessageDelivery": "true", + } + ) + snapshot.match("subscribe-exact-same-raw", subscribe_resp) + + subscribe_resp = subscribe_queue_to_topic( + { + "RawMessageDelivery": "true", + "FilterPolicyScope": "MessageAttributes", # test if it also matches default values + } + ) + + snapshot.match("subscribe-idempotent", subscribe_resp) + + # no attributes and empty attributes are working as well + subscribe_resp = subscribe_queue_to_topic() + snapshot.match("subscribe-idempotent-no-attributes", subscribe_resp) + + subscribe_resp = subscribe_queue_to_topic({}) + snapshot.match("subscribe-idempotent-empty-attributes", subscribe_resp) + + subscribe_resp = subscribe_queue_to_topic({"FilterPolicyScope": "MessageAttributes"}) + snapshot.match("subscribe-missing-attributes", subscribe_resp) + + with pytest.raises(ClientError) as e: + subscribe_queue_to_topic( + { + "RawMessageDelivery": "false", + "FilterPolicyScope": "MessageBody", + } + ) + snapshot.match("subscribe-diff-attributes", e.value.response) + + @markers.aws.validated + def test_unsubscribe_idempotency( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_name = f"topic-{short_uid()}" + queue_name = f"queue-{short_uid()}" + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + queue_url = sqs_create_queue(QueueName=queue_name) + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + sub_arn = subscription["SubscriptionArn"] + + unsubscribe_1 = aws_client.sns.unsubscribe(SubscriptionArn=sub_arn) + snapshot.match("unsubscribe-1", unsubscribe_1) + unsubscribe_2 = aws_client.sns.unsubscribe(SubscriptionArn=sub_arn) + snapshot.match("unsubscribe-2", unsubscribe_2) + + @markers.aws.validated + def test_unsubscribe_wrong_arn_format(self, snapshot, aws_client): + with pytest.raises(ClientError) as e: + aws_client.sns.unsubscribe(SubscriptionArn="randomstring") + + snapshot.match("invalid-unsubscribe-arn-1", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.unsubscribe(SubscriptionArn="arn:aws:sns:us-east-1:random") + + snapshot.match("invalid-unsubscribe-arn-2", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.unsubscribe(SubscriptionArn="arn:aws:sns:us-east-1:111111111111:random") + + snapshot.match("invalid-unsubscribe-arn-3", e.value.response) + + @markers.aws.validated + def test_subscribe_with_invalid_topic(self, sns_create_topic, sns_subscription, snapshot): + with pytest.raises(ClientError) as e: + sns_subscription( + TopicArn="randomstring", Protocol="email", Endpoint="localstack@yopmail.com" + ) + + snapshot.match("invalid-subscribe-arn-1", e.value.response) + + with pytest.raises(ClientError) as e: + sns_subscription( + TopicArn="arn:aws:sns:us-east-1:random", + Protocol="email", + Endpoint="localstack@yopmail.com", + ) + + snapshot.match("invalid-subscribe-arn-2", e.value.response) + + topic_arn = sns_create_topic()["TopicArn"] + bad_topic_arn = topic_arn + "aaa" + with pytest.raises(ClientError) as e: + sns_subscription( + TopicArn=bad_topic_arn, Protocol="email", Endpoint="localstack@yopmail.com" + ) + + snapshot.match("non-existent-topic", e.value.response) + + +class TestSNSSubscriptionLambda: + @markers.aws.validated + def test_python_lambda_subscribe_sns_topic( + self, + sns_create_topic, + sns_subscription, + lambda_su_role, + create_lambda_function, + snapshot, + aws_client, + ): + function_name = f"lambda-function-{short_uid()}" + permission_id = f"test-statement-{short_uid()}" + subject = "[Subject] Test subject" + message = "Hello world." + topic_arn = sns_create_topic()["TopicArn"] + + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=permission_id, + Action="lambda:InvokeFunction", + Principal="sns.amazonaws.com", + SourceArn=topic_arn, + ) + + subscription = sns_subscription( + TopicArn=topic_arn, + Protocol="lambda", + Endpoint=lambda_arn, + ) + + def check_subscription(): + subscription_arn = subscription["SubscriptionArn"] + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert subscription_attrs["Attributes"]["PendingConfirmation"] == "false" + + retry(check_subscription, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + aws_client.sns.publish(TopicArn=topic_arn, Subject=subject, Message=message) + + # access events sent by lambda + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=1, + function_name=function_name, + expected_length=1, + regex_filter="Records.*Sns", + logs_client=aws_client.logs, + ) + notification = events[0]["Records"][0]["Sns"] + snapshot.match("notification", notification) + + @markers.aws.validated + def test_sns_topic_as_lambda_dead_letter_queue( + self, + lambda_su_role, + create_lambda_function, + sns_create_topic, + sqs_create_queue, + sns_subscription, + sns_create_sqs_subscription, + snapshot, + aws_client, + ): + """Tests an async event chain: SNS => Lambda => SNS DLQ => SQS + 1) SNS => Lambda: An SNS subscription triggers the Lambda function asynchronously. + 2) Lambda => SNS DLQ: A failing Lambda function triggers the SNS DLQ after all retries are exhausted. + 3) SNS DLQ => SQS: An SNS subscription forwards the DLQ message to SQS. + """ + snapshot.add_transformer( + snapshot.transform.jsonpath( + "$..Messages..MessageAttributes.RequestID.Value", "request-id" + ) + ) + + # create an SNS topic that will be used as a DLQ by the lambda + dlq_topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + # sqs_subscription + sns_create_sqs_subscription(topic_arn=dlq_topic_arn, queue_url=queue_url) + + # create an SNS topic that will be used to invoke the lambda + lambda_topic_arn = sns_create_topic()["TopicArn"] + + function_name = f"lambda-function-{short_uid()}" + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON, + runtime=Runtime.python3_12, + role=lambda_su_role, + DeadLetterConfig={"TargetArn": dlq_topic_arn}, + ) + snapshot.match( + "lambda-response-dlq-config", + lambda_creation_response["CreateFunctionResponse"]["DeadLetterConfig"], + ) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + + # allow the SNS topic to invoke the lambda + permission_id = f"test-statement-{short_uid()}" + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=permission_id, + Action="lambda:InvokeFunction", + Principal="sns.amazonaws.com", + SourceArn=lambda_topic_arn, + ) + + # subscribe the lambda to the SNS topic: lambda_subscription + sns_subscription( + TopicArn=lambda_topic_arn, + Protocol="lambda", + Endpoint=lambda_arn, + ) + + # Set retries to zero to speed up the test + aws_client.lambda_.put_function_event_invoke_config( + FunctionName=function_name, + MaximumRetryAttempts=0, + ) + + payload = { + lambda_integration.MSG_BODY_RAISE_ERROR_FLAG: 1, + } + aws_client.sns.publish(TopicArn=lambda_topic_arn, Message=json.dumps(payload)) + + def receive_dlq(): + result = aws_client.sqs.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"], VisibilityTimeout=0 + ) + assert len(result["Messages"]) > 0 + return result + + sleep = 3 if is_aws_cloud() else 1 + messages = retry(receive_dlq, retries=30, sleep=sleep) + + messages["Messages"][0]["Body"] = json.loads(messages["Messages"][0]["Body"]) + messages["Messages"][0]["Body"]["Message"] = json.loads( + messages["Messages"][0]["Body"]["Message"] + ) + + snapshot.match("messages", messages) + + @markers.aws.validated + def test_redrive_policy_lambda_subscription( + self, + sns_create_topic, + sqs_create_queue, + sqs_get_queue_arn, + create_lambda_function, + lambda_su_role, + sns_subscription, + sns_allow_topic_sqs_queue, + snapshot, + aws_client, + ): + dlq_url = sqs_create_queue() + dlq_arn = sqs_get_queue_arn(dlq_url) + topic_arn = sns_create_topic()["TopicArn"] + sns_allow_topic_sqs_queue( + sqs_queue_url=dlq_url, sqs_queue_arn=dlq_arn, sns_topic_arn=topic_arn + ) + + lambda_name = f"test-{short_uid()}" + lambda_arn = create_lambda_function( + func_name=lambda_name, + handler_file=TEST_LAMBDA_PYTHON, + runtime=Runtime.python3_12, + role=lambda_su_role, + )["CreateFunctionResponse"]["FunctionArn"] + + subscription = sns_subscription(TopicArn=topic_arn, Protocol="lambda", Endpoint=lambda_arn) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": dlq_arn}), + ) + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"] + ) + + snapshot.match("subscription-attributes", response_attributes) + + aws_client.lambda_.delete_function(FunctionName=lambda_name) + + aws_client.sns.publish( + TopicArn=topic_arn, + Message="test_redrive_policy", + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": "1"}}, + ) + + response = aws_client.sqs.receive_message( + QueueUrl=dlq_url, WaitTimeSeconds=10, MessageAttributeNames=["All"] + ) + snapshot.match("messages", response) + + @markers.aws.validated + @pytest.mark.parametrize("signature_version", ["1", "2"]) + def test_publish_lambda_verify_signature( + self, + aws_client, + sns_create_topic, + create_lambda_function, + sns_subscription, + lambda_su_role, + snapshot, + signature_version, + ): + # Lambda always returns SignatureVersion=1 in messages, however, it can be v2 and the signature needs to be + # verified against v2 (SHA256). Weird bug on AWS side, we will do the same for now. + + function_name = f"lambda-function-{short_uid()}" + permission_id = f"test-statement-{short_uid()}" + subject = f"[Subject] Test subject Signature v{signature_version}" + message = "Hello world." + topic_arn = sns_create_topic( + Attributes={ + "DisplayName": "TestTopicSignatureLambda", + "SignatureVersion": signature_version, + }, + )["TopicArn"] + + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=permission_id, + Action="lambda:InvokeFunction", + Principal="sns.amazonaws.com", + SourceArn=topic_arn, + ) + + subscription = sns_subscription( + TopicArn=topic_arn, + Protocol="lambda", + Endpoint=lambda_arn, + ) + + def check_subscription(): + subscription_arn = subscription["SubscriptionArn"] + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert subscription_attrs["Attributes"]["PendingConfirmation"] == "false" + + retry(check_subscription, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + aws_client.sns.publish(TopicArn=topic_arn, Subject=subject, Message=message) + + # access events sent by lambda + events = retry( + check_expected_lambda_log_events_length, + retries=10, + sleep=1, + function_name=function_name, + expected_length=1, + regex_filter="Records.*Sns", + logs_client=aws_client.logs, + ) + + message = events[0]["Records"][0]["Sns"] + snapshot.match("notification", message) + + cert_url = message["SigningCertUrl"] + get_cert_req = requests.get(cert_url) + assert get_cert_req.ok + + cert = x509.load_pem_x509_certificate(get_cert_req.content) + message_signature = message["Signature"] + # create the canonical string + fields = ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"] + # Build the string to be signed. + string_to_sign = "".join( + [f"{field}\n{message[field]}\n" for field in fields if field in message] + ) + + # decode the signature from base64. + decoded_signature = base64.b64decode(message_signature) + + message_sig_version = message["SignatureVersion"] + # this is a bug on AWS side, assert our behaviour is the same for now, this might get fixed + assert message_sig_version == "1" + signature_hash = hashes.SHA1() if signature_version == "1" else hashes.SHA256() + + # calculate signature value with cert + is_valid = cert.public_key().verify( + decoded_signature, + to_bytes(string_to_sign), + padding=padding.PKCS1v15(), + algorithm=signature_hash, + ) + # if the verification failed, it would raise `InvalidSignature` + assert is_valid is None + + +class TestSNSSubscriptionSQS: + @markers.aws.validated + def test_subscribe_sqs_queue( + self, sqs_create_queue, sns_create_topic, sns_create_sqs_subscription, snapshot, aws_client + ): + # TODO: check with non default external port + + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + # create subscription with filter policy + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + filter_policy = {"attr1": [{"numeric": [">", 0, "<=", 100]}]} + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="FilterPolicy", + AttributeValue=json.dumps(filter_policy), + ) + + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + ) + snapshot.match("subscription-attributes", response_attributes) + + # publish message that satisfies the filter policy + message = "This is a test message" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": "99.12"}}, + ) + + # assert that message is received + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + MessageAttributeNames=["All"], + WaitTimeSeconds=4, + ) + snapshot.match("messages", response) + + @markers.aws.validated + def test_publish_unicode_chars( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + # publish message to SNS, receive it from SQS, assert that messages are equal + message = 'â§a1"_!?,. £$-' + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + + snapshot.match("received-message", response) + + @markers.aws.validated + def test_attribute_raw_subscribe( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + # the hash isn't the same because of the Binary attributes (maybe decoding order?) + snapshot.add_transformer( + snapshot.transform.key_value( + "MD5OfMessageAttributes", + value_replacement="", + reference_replacement=False, + ) + ) + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription( + topic_arn=topic_arn, queue_url=queue_url, Attributes={"RawMessageDelivery": "true"} + ) + subscription_arn = subscription["SubscriptionArn"] + + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("subscription-attributes", response_attributes) + + # publish message to SNS, receive it from SQS, assert that messages are equal and that they are Raw + message = "This is a test message" + binary_attribute = b"\x02\x03\x04" + # extending this test case to test support for binary message attribute data + # https://github.com/localstack/localstack/issues/2432 + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={"store": {"DataType": "Binary", "BinaryValue": binary_attribute}}, + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + MessageAttributeNames=["All"], + VisibilityTimeout=0, + WaitTimeSeconds=4, + ) + snapshot.match("messages-response", response) + + @markers.aws.validated + def test_sqs_topic_subscription_confirmation( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription_attrs = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + def check_subscription(): + nonlocal subscription_attrs + if not subscription_attrs["PendingConfirmation"] == "false": + subscription_arn = subscription_attrs["SubscriptionArn"] + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + )["Attributes"] + else: + snapshot.match("subscription-attrs", subscription_attrs) + + return subscription_attrs["PendingConfirmation"] == "false" + + # SQS subscriptions are auto confirmed if the endpoint and the topic are in the same AWS account + assert poll_condition(check_subscription, timeout=5) + + @markers.aws.validated + def test_publish_sqs_from_sns( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + response = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + snapshot.match("sub-attrs-raw-true", response) + + string_value = "99.12" + aws_client.sns.publish( + TopicArn=topic_arn, + Message="Test msg", + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": string_value}}, + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + MessageAttributeNames=["All"], + VisibilityTimeout=0, + WaitTimeSeconds=4, + ) + snapshot.match("message-raw-true", response) + # format is of SQS MessageAttributes when RawDelivery is set to "true" + assert response["Messages"][0]["MessageAttributes"] == { + "attr1": {"DataType": "Number", "StringValue": string_value} + } + + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="false", + ) + response = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + snapshot.match("sub-attrs-raw-false", response) + + string_value = "100.12" + aws_client.sns.publish( + TargetArn=topic_arn, + Message="Test msg", + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": string_value}}, + ) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + MessageAttributeNames=["All"], + VisibilityTimeout=0, + WaitTimeSeconds=4, + ) + snapshot.match("message-raw-false", response) + message_body = json.loads(response["Messages"][0]["Body"]) + # format is SNS MessageAttributes when RawDelivery is "false" + assert message_body["MessageAttributes"] == { + "attr1": {"Type": "Number", "Value": string_value} + } + + @markers.aws.validated + def test_publish_batch_messages_from_sns_to_sqs( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + response = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + snapshot.match("sub-attrs-raw-true", response) + + publish_batch_response = aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": "Test Message with two attributes", + "Subject": "Subject", + "MessageAttributes": { + "attr1": {"DataType": "Number", "StringValue": "99.12"}, + "attr2": {"DataType": "Number", "StringValue": "109.12"}, + }, + }, + { + "Id": "2", + "Message": "Test Message with one attribute", + "Subject": "Subject", + "MessageAttributes": {"attr1": {"DataType": "Number", "StringValue": "19.12"}}, + }, + { + "Id": "3", + "Message": "Test Message without attribute", + "Subject": "Subject", + }, + { + "Id": "4", + "Message": "Test Message without subject", + }, + { + "Id": "5", + "Message": json.dumps({"default": "test default", "sqs": "test sqs"}), + "MessageStructure": "json", + }, + ], + ) + snapshot.match("publish-batch", publish_batch_response) + + messages = [] + + def get_messages(): + # due to the random nature of receiving SQS messages, we need to consolidate a single object to match + sqs_response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=1, + VisibilityTimeout=0, + MessageAttributeNames=["All"], + AttributeNames=["All"], + ) + for message in sqs_response["Messages"]: + messages.append(message) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"] + ) + + assert len(messages) == 5 + + retry(get_messages, retries=10, sleep=0.1) + # we need to sort the list (the order does not matter as we're not using FIFO) + messages.sort(key=itemgetter("Body")) + snapshot.match("messages", {"Messages": messages}) + + @markers.aws.validated + def test_publish_batch_messages_without_topic(self, sns_create_topic, snapshot, aws_client): + topic_arn = sns_create_topic()["TopicArn"] + fake_topic_arn = topic_arn + "fake-topic" + + with pytest.raises(ClientError) as e: + aws_client.sns.publish_batch( + TopicArn=fake_topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": "Test Message with two attributes", + "Subject": "Subject", + } + ], + ) + snapshot.match("publish-batch-no-topic", e.value.response) + + @markers.aws.validated + def test_publish_batch_exceptions( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + fifo_topic_name = f"topic-{short_uid()}.fifo" + topic_arn = sns_create_topic(Name=fifo_topic_name, Attributes={"FifoTopic": "true"})[ + "TopicArn" + ] + + with pytest.raises(ClientError) as e: + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": "Test message without Group ID", + } + ], + ) + snapshot.match("no-group-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + {"Id": f"Id_{i}", "Message": "Too many messages"} for i in range(11) + ], + ) + snapshot.match("too-many-msg", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + {"Id": "1", "Message": "Messages with the same ID"} for _ in range(2) + ], + ) + snapshot.match("same-msg-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": "Test message without MessageDeduplicationId", + "MessageGroupId": "msg1", + } + ], + ) + snapshot.match("no-dedup-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": json.dumps({"sqs": "test sqs"}), + "MessageStructure": "json", + } + ], + ) + snapshot.match("no-default-key-json", e.value.response) + + @markers.aws.validated + def test_subscribe_to_sqs_with_queue_url( + self, sns_create_topic, sqs_create_queue, sns_subscription, snapshot + ): + topic = sns_create_topic() + topic_arn = topic["TopicArn"] + queue_url = sqs_create_queue() + with pytest.raises(ClientError) as e: + sns_subscription(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_url) + snapshot.match("sub-queue-url", e.value.response) + + @markers.aws.validated + def test_publish_sqs_from_sns_with_xray_propagation( + self, sns_create_topic, sqs_create_queue, sns_create_sqs_subscription, snapshot, aws_client + ): + def add_xray_header(request, **_kwargs): + request.headers["X-Amzn-Trace-Id"] = ( + "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1" + ) + + try: + aws_client.sns.meta.events.register("before-send.sns.Publish", add_xray_header) + + topic = sns_create_topic() + topic_arn = topic["TopicArn"] + queue_url = sqs_create_queue() + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + aws_client.sns.publish(TargetArn=topic_arn, Message="X-Ray propagation test msg") + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + AttributeNames=["SentTimestamp", "AWSTraceHeader"], + MaxNumberOfMessages=1, + MessageAttributeNames=["All"], + VisibilityTimeout=2, + WaitTimeSeconds=2, + ) + + assert len(response["Messages"]) == 1 + message = response["Messages"][0] + snapshot.match("xray-msg", message) + assert ( + message["Attributes"]["AWSTraceHeader"] + == "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1" + ) + finally: + aws_client.sns.meta.events.unregister("before-send.sns.Publish", add_xray_header) + + @pytest.mark.parametrize("raw_message_delivery", [True, False]) + @markers.aws.validated + def test_redrive_policy_sqs_queue_subscription( + self, + sns_create_topic, + sqs_create_queue, + sqs_get_queue_arn, + sqs_queue_exists, + sns_create_sqs_subscription, + sns_allow_topic_sqs_queue, + raw_message_delivery, + snapshot, + aws_client, + ): + # the hash isn't the same because of the Binary attributes (maybe decoding order?) + snapshot.add_transformer( + snapshot.transform.key_value( + "MD5OfMessageAttributes", + value_replacement="", + reference_replacement=False, + ) + ) + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + dlq_url = sqs_create_queue() + dlq_arn = sqs_get_queue_arn(dlq_url) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": dlq_arn}), + ) + + if raw_message_delivery: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + + sns_allow_topic_sqs_queue( + sqs_queue_url=dlq_url, + sqs_queue_arn=dlq_arn, + sns_topic_arn=topic_arn, + ) + + aws_client.sqs.delete_queue(QueueUrl=queue_url) + + # AWS takes some time to delete the queue, which make the test fails as it delivers the message correctly + assert poll_condition(lambda: not sqs_queue_exists(queue_url), timeout=5) + + message = "test_dlq_after_sqs_endpoint_deleted" + message_attr = { + "attr1": { + "DataType": "Number", + "StringValue": "111", + }, + "attr2": { + "DataType": "Binary", + "BinaryValue": b"\x02\x03\x04", + }, + } + aws_client.sns.publish(TopicArn=topic_arn, Message=message, MessageAttributes=message_attr) + + response = aws_client.sqs.receive_message( + QueueUrl=dlq_url, + WaitTimeSeconds=10, + AttributeNames=["All"], + MessageAttributeNames=["All"], + ) + snapshot.match("messages", response) + + @markers.aws.validated + def test_message_attributes_not_missing( + self, sns_create_sqs_subscription, sns_create_topic, sqs_create_queue, snapshot, aws_client + ): + # the hash isn't the same because of the Binary attributes (maybe decoding order?) + snapshot.add_transformer( + snapshot.transform.key_value( + "MD5OfMessageAttributes", + value_replacement="", + reference_replacement=False, + ) + ) + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + attributes = { + "an-attribute-key": {"DataType": "String", "StringValue": "an-attribute-value"}, + "binary-attribute": {"DataType": "Binary", "BinaryValue": b"\x02\x03\x04"}, + } + + publish_response = aws_client.sns.publish( + TopicArn=topic_arn, + Message="text", + MessageAttributes=attributes, + ) + snapshot.match("publish-msg-raw", publish_response) + + msg = aws_client.sqs.receive_message( + QueueUrl=queue_url, + AttributeNames=["All"], + MessageAttributeNames=["All"], + WaitTimeSeconds=3, + ) + # as SNS piggybacks on SQS MessageAttributes when RawDelivery is true + # BinaryValue depends on SQS implementation, and is decoded automatically + snapshot.match("raw-delivery-msg-attrs", msg) + + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=msg["Messages"][0]["ReceiptHandle"] + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RawMessageDelivery", + AttributeValue="false", + ) + + publish_response = aws_client.sns.publish( + TopicArn=topic_arn, + Message="text", + MessageAttributes=attributes, + ) + snapshot.match("publish-msg-json", publish_response) + + msg = aws_client.sqs.receive_message( + QueueUrl=queue_url, + AttributeNames=["All"], + MessageAttributeNames=["All"], + WaitTimeSeconds=3, + ) + snapshot.match("json-delivery-msg-attrs", msg) + # binary payload in base64 encoded by AWS, UTF-8 for JSON + # https://docs.aws.amazon.com/sns/latest/api/API_MessageAttributeValue.html + + @markers.aws.validated + def test_subscription_after_failure_to_deliver( + self, + sns_create_topic, + sqs_create_queue, + sqs_get_queue_arn, + sqs_queue_exists, + sns_create_sqs_subscription, + sns_allow_topic_sqs_queue, + sqs_receive_num_messages, + snapshot, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + dlq_url = sqs_create_queue() + dlq_arn = sqs_get_queue_arn(dlq_url) + + sns_allow_topic_sqs_queue( + sqs_queue_url=dlq_url, + sqs_queue_arn=dlq_arn, + sns_topic_arn=topic_arn, + ) + + sub_attrs = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + snapshot.match("subscriptions-attrs", sub_attrs) + + message = "test_dlq_before_sqs_endpoint_deleted" + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=10, MaxNumberOfMessages=4 + ) + snapshot.match("messages-before-delete", response) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + aws_client.sqs.delete_queue(QueueUrl=queue_url) + + # setting up a second queue to be able to poll and know approximately when the message on the deleted queue + # have been published + queue_test_url = sqs_create_queue() + test_subscription = sns_create_sqs_subscription( + topic_arn=topic_arn, queue_url=queue_test_url + ) + test_subscription_arn = test_subscription["SubscriptionArn"] + # try to send a message before setting a DLQ + message = "test_dlq_after_sqs_endpoint_deleted" + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + + # to avoid race condition, publish is async and the redrive policy can be in effect before the actual publish + # we wait until the 2nd subscription received the message + poll_condition( + lambda: sqs_receive_num_messages( + queue_url=queue_test_url, expected_messages=1, max_iterations=2 + ), + timeout=10, + ) + aws_client.sns.unsubscribe(SubscriptionArn=test_subscription_arn) + # we still wait a bit to be sure the message is well published + time.sleep(1) + + # check the subscription is still there after we deleted the queue + subscriptions = aws_client.sns.list_subscriptions_by_topic(TopicArn=topic_arn) + snapshot.match("subscriptions", subscriptions) + + # set the RedrivePolicy with a DLQ. Subsequent failing messages to the subscription should go there + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": dlq_arn}), + ) + + sub_attrs = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + snapshot.match("subscriptions-attrs-with-redrive", sub_attrs) + + # AWS takes some time to delete the queue, which make the test fails as it delivers the message correctly + assert poll_condition(lambda: not sqs_queue_exists(queue_url), timeout=5) + + # test sending and receiving multiple messages + for i in range(2): + message = f"test_dlq_after_sqs_endpoint_deleted_{i}" + + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + response = aws_client.sqs.receive_message( + QueueUrl=dlq_url, WaitTimeSeconds=10, MaxNumberOfMessages=4 + ) + aws_client.sqs.delete_message( + QueueUrl=dlq_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + snapshot.match(f"message-{i}-after-delete", response) + + @markers.aws.validated + def test_empty_or_wrong_message_attributes( + self, + sns_create_sqs_subscription, + sns_create_topic, + sqs_create_queue, + snapshot, + aws_client_factory, + region_name, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + client_no_validation = aws_client_factory( + region_name=region_name, config=Config(parameter_validation=False) + ).sns + + wrong_message_attributes = { + "missing_string_attr": {"attr1": {"DataType": "String", "StringValue": ""}}, + "fully_missing_string_attr": {"attr1": {"DataType": "String"}}, + "fully_missing_data_type": {"attr1": {"StringValue": "value"}}, + "missing_binary_attr": {"attr1": {"DataType": "Binary", "BinaryValue": b""}}, + "str_attr_binary_value": {"attr1": {"DataType": "String", "BinaryValue": b"123"}}, + "int_attr_binary_value": {"attr1": {"DataType": "Number", "BinaryValue": b"123"}}, + "binary_attr_string_value": {"attr1": {"DataType": "Binary", "StringValue": "123"}}, + "invalid_attr_string_value": { + "attr1": {"DataType": "InvalidType", "StringValue": "123"} + }, + "too_long_name": {"a" * 257: {"DataType": "String", "StringValue": "123"}}, + "invalid_name": {"a^*?": {"DataType": "String", "StringValue": "123"}}, + "invalid_name_2": {".abc": {"DataType": "String", "StringValue": "123"}}, + "invalid_name_3": {"abc.": {"DataType": "String", "StringValue": "123"}}, + "invalid_name_4": {"a..bc": {"DataType": "String", "StringValue": "123"}}, + } + + for error_type, msg_attrs in wrong_message_attributes.items(): + with pytest.raises(ClientError) as e: + client_no_validation.publish( + TopicArn=topic_arn, + Message="test message", + MessageAttributes=msg_attrs, + ) + + snapshot.match(error_type, e.value.response) + + with pytest.raises(ClientError) as e: + client_no_validation.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": "test-batch", + "MessageAttributes": msg_attrs, + }, + { + "Id": "3", + "Message": "valid-batch", + }, + ], + ) + snapshot.match(f"batch-{error_type}", e.value.response) + + @markers.aws.validated + def test_message_attributes_prefixes( + self, sns_create_sqs_subscription, sns_create_topic, sqs_create_queue, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish( + TopicArn=topic_arn, + Message="test message", + MessageAttributes={"attr1": {"DataType": "String.", "StringValue": "prefixed-1"}}, + ) + snapshot.match("publish-error", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish( + TopicArn=topic_arn, + Message="test message", + MessageAttributes={ + "attr1": {"DataType": "Stringprefixed", "StringValue": "prefixed-1"} + }, + ) + snapshot.match("publish-error-2", e.value.response) + + response = aws_client.sns.publish( + TopicArn=topic_arn, + Message="test message", + MessageAttributes={ + "attr1": {"DataType": "String.prefixed", "StringValue": "prefixed-1"} + }, + ) + snapshot.match("publish-ok-1", response) + + response = aws_client.sns.publish( + TopicArn=topic_arn, + Message="test message", + MessageAttributes={ + "attr1": {"DataType": "String. prefixed.", "StringValue": "prefixed-1"} + }, + ) + snapshot.match("publish-ok-2", response) + + @markers.aws.validated + def test_message_structure_json_to_sqs( + self, aws_client, sns_create_topic, sqs_create_queue, snapshot, sns_create_sqs_subscription + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + message = json.dumps({"default": "default field", "sqs": json.dumps({"field": "value"})}) + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageStructure="json", + ) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=10, MaxNumberOfMessages=1 + ) + snapshot.match("get-msg-json-sqs", response) + receipt_handle = response["Messages"][0]["ReceiptHandle"] + aws_client.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + # don't json dumps the SQS field, it will be ignored, and the message received will be the `default` + message = json.dumps({"default": "default field", "sqs": {"field": "value"}}) + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageStructure="json", + ) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=10, MaxNumberOfMessages=1 + ) + snapshot.match("get-msg-json-default", response) + + @markers.aws.validated + @pytest.mark.parametrize("signature_version", ["1", "2"]) + def test_publish_sqs_verify_signature( + self, + aws_client, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + snapshot, + signature_version, + ): + topic_arn = sns_create_topic( + Attributes={ + "DisplayName": "TestTopicSignature", + "SignatureVersion": signature_version, + }, + )["TopicArn"] + + queue_url = sqs_create_queue() + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + aws_client.sns.publish( + TopicArn=topic_arn, + Message="test signature value with attributes", + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": "1"}}, + ) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=10, + AttributeNames=["All"], + MessageAttributeNames=["All"], + ) + snapshot.match("messages", response) + message = json.loads(response["Messages"][0]["Body"]) + + cert_url = message["SigningCertURL"] + get_cert_req = requests.get(cert_url) + assert get_cert_req.ok + + cert = x509.load_pem_x509_certificate(get_cert_req.content) + message_signature = message["Signature"] + # create the canonical string + fields = ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"] + # Build the string to be signed. + string_to_sign = "".join( + [f"{field}\n{message[field]}\n" for field in fields if field in message] + ) + + # decode the signature from base64. + decoded_signature = base64.b64decode(message_signature) + + message_sig_version = message["SignatureVersion"] + assert message_sig_version == signature_version + signature_hash = hashes.SHA1() if message_sig_version == "1" else hashes.SHA256() + + # calculate signature value with cert + is_valid = cert.public_key().verify( + decoded_signature, + to_bytes(string_to_sign), + padding=padding.PKCS1v15(), + algorithm=signature_hash, + ) + # if the verification failed, it would raise `InvalidSignature` + assert is_valid is None + + +class TestSNSSubscriptionSQSFifo: + @markers.aws.validated + @pytest.mark.parametrize("content_based_deduplication", [True, False]) + def test_message_to_fifo_sqs( + self, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + snapshot, + content_based_deduplication, + aws_client, + ): + topic_name = f"topic-{short_uid()}.fifo" + queue_name = f"queue-{short_uid()}.fifo" + topic_attributes = {"FifoTopic": "true"} + queue_attributes = {"FifoQueue": "true"} + if content_based_deduplication: + topic_attributes["ContentBasedDeduplication"] = "true" + queue_attributes["ContentBasedDeduplication"] = "true" + + topic_arn = sns_create_topic( + Name=topic_name, + Attributes=topic_attributes, + )["TopicArn"] + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes=queue_attributes, + ) + + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + message = "Test" + kwargs = {"MessageGroupId": "message-group-id-1"} + if not content_based_deduplication: + kwargs["MessageDeduplicationId"] = "message-deduplication-id-1" + + aws_client.sns.publish(TopicArn=topic_arn, Message=message, **kwargs) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=10, + AttributeNames=["All"], + ) + snapshot.match("messages", response) + + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + # republish the message, to check deduplication + aws_client.sns.publish(TopicArn=topic_arn, Message=message, **kwargs) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=1, + AttributeNames=["All"], + ) + snapshot.match("dedup-messages", response) + + @markers.aws.validated + @pytest.mark.parametrize("content_based_deduplication", [True, False]) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.dedup-messages.Messages" + ], # FIXME: introduce deduplication at Topic level, not only SQS + ) + def test_fifo_topic_to_regular_sqs( + self, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + snapshot, + content_based_deduplication, + aws_client, + ): + # it seems change is coming on AWS, as FIFO topic do not require FIFO queues anymore. This might mean that + # the FIFO logic is being migrated to SNS and do not rely on SQS FIFO anymore? or that the FIFO is only + # guaranteed with FIFO queues, but you can also subscribe with regular subscribers for deduplication for ex. ? + # The change in error message suggest the latter: + # "RedrivePolicy: must use a FIFO queue as DLQ for a FIFO topic" became: + # -> "RedrivePolicy: must use a FIFO queue as DLQ for a FIFO Subscription to a FIFO Topic." + + topic_name = f"topic-{short_uid()}.fifo" + queue_name = f"queue-{short_uid()}" + topic_attributes = {"FifoTopic": "true"} + if content_based_deduplication: + topic_attributes["ContentBasedDeduplication"] = "true" + + topic_arn = sns_create_topic( + Name=topic_name, + Attributes=topic_attributes, + )["TopicArn"] + queue_url = sqs_create_queue(QueueName=queue_name) + + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + message = "Test" + kwargs = {"MessageGroupId": "message-group-id-1"} + if not content_based_deduplication: + kwargs["MessageDeduplicationId"] = "message-deduplication-id-1" + + aws_client.sns.publish(TopicArn=topic_arn, Message=message, **kwargs) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=10, + AttributeNames=["All"], + ) + snapshot.match("messages", response) + + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + # republish the message, to check deduplication + # TODO: not implemented in LocalStack yet, only deduplication with FIFO SQS queues + aws_client.sns.publish(TopicArn=topic_arn, Message=message, **kwargs) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=3, + AttributeNames=["All"], + ) + snapshot.match("dedup-messages", response) + + @markers.aws.validated + def test_validations_for_fifo( + self, + sns_create_topic, + sqs_create_queue, + sqs_get_queue_arn, + sns_create_sqs_subscription, + snapshot, + aws_client, + ): + topic_name = f"topic-{short_uid()}" + fifo_topic_name = f"topic-{short_uid()}.fifo" + queue_name = f"queue-{short_uid()}" + fifo_queue_name = f"queue-{short_uid()}.fifo" + not_fifo_dlq_name = f"queue-dlq-{short_uid()}" + + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + + fifo_topic_arn = sns_create_topic(Name=fifo_topic_name, Attributes={"FifoTopic": "true"})[ + "TopicArn" + ] + + fifo_queue_url = sqs_create_queue( + QueueName=fifo_queue_name, Attributes={"FifoQueue": "true"} + ) + + queue_url = sqs_create_queue(QueueName=queue_name) + not_fifo_dlq_url = sqs_create_queue(QueueName=not_fifo_dlq_name) + + with pytest.raises(ClientError) as e: + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=fifo_queue_url) + + assert e.match("standard SNS topic") + snapshot.match("not-fifo-topic", e.value.response) + + # SNS does not reject a regular SQS queue subscribed to a FIFO topic anymore + subscription_not_fifo = sns_create_sqs_subscription( + topic_arn=fifo_topic_arn, queue_url=queue_url + ) + snapshot.match("not-fifo-queue", subscription_not_fifo) + + not_fifo_queue_arn = sqs_get_queue_arn(not_fifo_dlq_url) + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_not_fifo["SubscriptionArn"], + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": not_fifo_queue_arn}), + ) + + subscription = sns_create_sqs_subscription( + topic_arn=fifo_topic_arn, queue_url=fifo_queue_url + ) + queue_arn = sqs_get_queue_arn(queue_url) + + with pytest.raises(ClientError) as e: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": queue_arn}), + ) + snapshot.match("regular-queue-for-dlq-of-fifo-topic", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish(TopicArn=fifo_topic_arn, Message="test") + + assert e.match("MessageGroupId") + snapshot.match("no-msg-group-id", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish( + TopicArn=fifo_topic_arn, Message="test", MessageGroupId=short_uid() + ) + # if ContentBasedDeduplication is not set at the topic level, it needs MessageDeduplicationId for each msg + assert e.match("MessageDeduplicationId") + assert e.match("ContentBasedDeduplication") + snapshot.match("no-dedup-policy", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish( + TopicArn=topic_arn, Message="test", MessageDeduplicationId=short_uid() + ) + assert e.match("MessageDeduplicationId") + snapshot.match("no-msg-dedup-regular-topic", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish(TopicArn=topic_arn, Message="test", MessageGroupId=short_uid()) + assert e.match("MessageGroupId") + snapshot.match("no-msg-group-id-regular-topic", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.topic-attrs.Attributes.DeliveryPolicy", + "$.topic-attrs.Attributes.EffectiveDeliveryPolicy", + "$.topic-attrs.Attributes.Policy.Statement..Action", # SNS:Receive is added by moto but not returned in AWS + ] + ) + @pytest.mark.parametrize("raw_message_delivery", [True, False]) + def test_publish_fifo_messages_to_dlq( + self, + sns_create_topic, + sqs_create_queue, + sqs_get_queue_arn, + sns_create_sqs_subscription, + sns_allow_topic_sqs_queue, + snapshot, + raw_message_delivery, + aws_client, + ): + # the hash isn't the same because of the Binary attributes (maybe decoding order?) + snapshot.add_transformer( + snapshot.transform.key_value( + "MD5OfMessageAttributes", + value_replacement="", + reference_replacement=False, + ) + ) + + topic_name = f"topic-{short_uid()}.fifo" + queue_name = f"queue-{short_uid()}.fifo" + dlq_name = f"dlq-{short_uid()}.fifo" + + topic_arn = sns_create_topic( + Name=topic_name, + Attributes={"FifoTopic": "true"}, + )["TopicArn"] + + response = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("topic-attrs", response) + + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes={"FifoQueue": "true"}, + ) + + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + if raw_message_delivery: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + + dlq_url = sqs_create_queue( + QueueName=dlq_name, + Attributes={"FifoQueue": "true"}, + ) + dlq_arn = sqs_get_queue_arn(dlq_url) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": dlq_arn}), + ) + + sns_allow_topic_sqs_queue( + sqs_queue_url=dlq_url, + sqs_queue_arn=dlq_arn, + sns_topic_arn=topic_arn, + ) + + aws_client.sqs.delete_queue(QueueUrl=queue_url) + + message_group_id = "complexMessageGroupId" + publish_batch_request_entries = [ + { + "Id": "1", + "MessageGroupId": message_group_id, + "Message": "Test Message with two attributes", + "Subject": "Subject", + "MessageAttributes": { + "attr1": {"DataType": "Number", "StringValue": "99.12"}, + "attr2": {"DataType": "Number", "StringValue": "109.12"}, + }, + "MessageDeduplicationId": "MessageDeduplicationId-1", + }, + { + "Id": "2", + "MessageGroupId": message_group_id, + "Message": "Test Message with one attribute", + "Subject": "Subject", + "MessageAttributes": {"attr1": {"DataType": "Number", "StringValue": "19.12"}}, + "MessageDeduplicationId": "MessageDeduplicationId-2", + }, + { + "Id": "3", + "MessageGroupId": message_group_id, + "Message": "Test Message without attribute", + "Subject": "Subject", + "MessageDeduplicationId": "MessageDeduplicationId-3", + }, + ] + + publish_batch_response = aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=publish_batch_request_entries, + ) + + snapshot.match("publish-batch-response-fifo", publish_batch_response) + + assert "Successful" in publish_batch_response + assert "Failed" in publish_batch_response + + for successful_resp in publish_batch_response["Successful"]: + assert "Id" in successful_resp + assert "MessageId" in successful_resp + + message_ids_received = set() + messages = [] + + def get_messages_from_dlq(amount_msg: int): + # due to the random nature of receiving SQS messages, we need to consolidate a single object to match + # MaxNumberOfMessages could return less than 3 messages + sqs_response = aws_client.sqs.receive_message( + QueueUrl=dlq_url, + MessageAttributeNames=["All"], + AttributeNames=["All"], + MaxNumberOfMessages=10, + WaitTimeSeconds=1, + VisibilityTimeout=1, + ) + + for message in sqs_response["Messages"]: + LOG.debug("Message received %s", message) + if message["MessageId"] in message_ids_received: + continue + + message_ids_received.add(message["MessageId"]) + messages.append(message) + aws_client.sqs.delete_message( + QueueUrl=dlq_url, ReceiptHandle=message["ReceiptHandle"] + ) + + assert len(messages) == amount_msg + + retry(get_messages_from_dlq, retries=5, sleep=1, amount_msg=3) + snapshot.match("batch-messages-in-dlq", {"Messages": messages}) + messages.clear() + + publish_response = aws_client.sns.publish( + TopicArn=topic_arn, + Message="test-message", + MessageGroupId="message-group-id-1", + MessageDeduplicationId="message-deduplication-id-1", + ) + snapshot.match("publish-response-fifo", publish_response) + retry(get_messages_from_dlq, retries=5, sleep=1, amount_msg=1) + snapshot.match("messages-in-dlq", {"Messages": messages}) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.topic-attrs.Attributes.DeliveryPolicy", + "$.topic-attrs.Attributes.EffectiveDeliveryPolicy", + "$.topic-attrs.Attributes.Policy.Statement..Action", # SNS:Receive is added by moto but not returned in AWS + "$.republish-batch-response-fifo.Successful..MessageId", # TODO: SNS doesnt keep track of duplicate + "$.republish-batch-response-fifo.Successful..SequenceNumber", # TODO: SNS doesnt keep track of duplicate + ] + ) + @pytest.mark.parametrize("content_based_deduplication", [True, False]) + def test_publish_batch_messages_from_fifo_topic_to_fifo_queue( + self, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + snapshot, + content_based_deduplication, + aws_client, + ): + topic_name = f"topic-{short_uid()}.fifo" + queue_name = f"queue-{short_uid()}.fifo" + topic_attributes = {"FifoTopic": "true"} + queue_attributes = {"FifoQueue": "true"} + if content_based_deduplication: + topic_attributes["ContentBasedDeduplication"] = "true" + queue_attributes["ContentBasedDeduplication"] = "true" + + topic_arn = sns_create_topic( + Name=topic_name, + Attributes=topic_attributes, + )["TopicArn"] + + response = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("topic-attrs", response) + + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes=queue_attributes, + ) + + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + + response = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + snapshot.match("sub-attrs-raw-true", response) + message_group_id = "complexMessageGroupId" + publish_batch_request_entries = [ + { + "Id": "1", + "MessageGroupId": message_group_id, + "Message": "Test Message with two attributes", + "Subject": "Subject", + "MessageAttributes": { + "attr1": {"DataType": "Number", "StringValue": "99.12"}, + "attr2": {"DataType": "Number", "StringValue": "109.12"}, + }, + }, + { + "Id": "2", + "MessageGroupId": message_group_id, + "Message": "Test Message with one attribute", + "Subject": "Subject", + "MessageAttributes": {"attr1": {"DataType": "Number", "StringValue": "19.12"}}, + }, + { + "Id": "3", + "MessageGroupId": message_group_id, + "Message": "Test Message without attribute", + "Subject": "Subject", + }, + ] + + if not content_based_deduplication: + for index, message in enumerate(publish_batch_request_entries): + message["MessageDeduplicationId"] = f"MessageDeduplicationId-{index}" + + publish_batch_response = aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=publish_batch_request_entries, + ) + + snapshot.match("publish-batch-response-fifo", publish_batch_response) + + assert "Successful" in publish_batch_response + assert "Failed" in publish_batch_response + + for successful_resp in publish_batch_response["Successful"]: + assert "Id" in successful_resp + assert "MessageId" in successful_resp + + message_ids_received = set() + messages = [] + + def get_messages(): + # due to the random nature of receiving SQS messages, we need to consolidate a single object to match + # MaxNumberOfMessages could return less than 3 messages + sqs_response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + MessageAttributeNames=["All"], + AttributeNames=["All"], + MaxNumberOfMessages=10, + WaitTimeSeconds=1, + VisibilityTimeout=10, + ) + + for _message in sqs_response["Messages"]: + if _message["MessageId"] in message_ids_received: + continue + + message_ids_received.add(_message["MessageId"]) + messages.append(_message) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=_message["ReceiptHandle"] + ) + + assert len(messages) == 3 + + retry(get_messages, retries=5, sleep=1) + snapshot.match("messages", {"Messages": messages}) + + publish_batch_response = aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=publish_batch_request_entries, + ) + + snapshot.match("republish-batch-response-fifo", publish_batch_response) + get_deduplicated_messages = aws_client.sqs.receive_message( + QueueUrl=queue_url, + MessageAttributeNames=["All"], + AttributeNames=["All"], + MaxNumberOfMessages=10, + WaitTimeSeconds=3, + VisibilityTimeout=0, + ) + # there should not be any messages here, as they are duplicate + # see https://docs.aws.amazon.com/sns/latest/dg/fifo-message-dedup.html + snapshot.match("duplicate-messages", get_deduplicated_messages) + + @markers.aws.validated + @pytest.mark.parametrize("raw_message_delivery", [True, False]) + def test_publish_to_fifo_topic_to_sqs_queue_no_content_dedup( + self, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + snapshot, + raw_message_delivery, + aws_client, + ): + topic_name = f"topic-{short_uid()}.fifo" + queue_name = f"queue-{short_uid()}.fifo" + topic_attributes = {"FifoTopic": "true", "ContentBasedDeduplication": "true"} + queue_attributes = {"FifoQueue": "true"} + + topic_arn = sns_create_topic( + Name=topic_name, + Attributes=topic_attributes, + )["TopicArn"] + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes=queue_attributes, + ) + + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + if raw_message_delivery: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + + # Topic has ContentBasedDeduplication set to true, the queue should receive only one message + # SNS will create a MessageDeduplicationId for the SQS queue, as it does not have ContentBasedDeduplication + for _ in range(2): + aws_client.sns.publish( + TopicArn=topic_arn, Message="Test single", MessageGroupId="message-group-id-1" + ) + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "MessageGroupId": "message-group-id-1", + "Message": "Test batched", + } + ], + ) + + messages = [] + message_ids_received = set() + + def get_messages(): + # due to the random nature of receiving SQS messages, we need to consolidate a single object to match + # MaxNumberOfMessages could return less than 2 messages + sqs_response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + MessageAttributeNames=["All"], + AttributeNames=["All"], + MaxNumberOfMessages=10, + WaitTimeSeconds=1, + VisibilityTimeout=10, + ) + + for message in sqs_response["Messages"]: + if message["MessageId"] in message_ids_received: + continue + + message_ids_received.add(message["MessageId"]) + messages.append(message) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"] + ) + + assert len(messages) == 2 + + retry(get_messages, retries=5, sleep=1) + messages.sort(key=lambda x: x["Attributes"]["MessageDeduplicationId"]) + snapshot.match("messages", {"Messages": messages}) + + @markers.aws.validated + def test_publish_to_fifo_topic_deduplication_on_topic_level( + self, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + snapshot, + aws_client, + ): + topic_name = f"topic-{short_uid()}.fifo" + queue_name = f"queue-{short_uid()}.fifo" + topic_attributes = {"FifoTopic": "true", "ContentBasedDeduplication": "true"} + queue_attributes = {"FifoQueue": "true"} + + topic_arn = sns_create_topic( + Name=topic_name, + Attributes=topic_attributes, + )["TopicArn"] + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes=queue_attributes, + ) + + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + # TODO: for message deduplication, we are using the underlying features of the SQS queue + # however, SQS queue only deduplicate at the Queue level, where the SNS topic deduplicate on the topic level + # we will need to implement this + # TODO: add a test with 2 subscriptions and a filter, to validate deduplication at topic level + message = "Test" + aws_client.sns.publish( + TopicArn=topic_arn, Message=message, MessageGroupId="message-group-id-1" + ) + time.sleep( + 0.5 + ) # this is to ensure order of arrival, because we do not deduplicate at SNS level yet + aws_client.sns.publish( + TopicArn=topic_arn, Message=message, MessageGroupId="message-group-id-2" + ) + + # get the deduplicated message and delete it + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=10, + WaitTimeSeconds=10, + AttributeNames=["All"], + ) + snapshot.match("messages", response) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + # assert there are no more messages in the queue + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=10, + WaitTimeSeconds=1, + AttributeNames=["All"], + ) + snapshot.match("dedup-messages", response) + + @markers.aws.validated + def test_publish_to_fifo_with_target_arn(self, sns_create_topic, aws_client): + topic_name = f"topic-{short_uid()}.fifo" + topic_attributes = { + "FifoTopic": "true", + "ContentBasedDeduplication": "true", + } + + topic_arn = sns_create_topic( + Name=topic_name, + Attributes=topic_attributes, + )["TopicArn"] + + message = {"foo": "bar"} + response = aws_client.sns.publish( + TargetArn=topic_arn, + Message=json.dumps({"default": json.dumps(message)}), + MessageStructure="json", + MessageGroupId="123", + ) + assert "MessageId" in response + + @markers.aws.validated + def test_message_to_fifo_sqs_ordering( + self, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + snapshot, + aws_client, + sqs_collect_messages, + ): + topic_name = f"topic-{short_uid()}.fifo" + topic_attributes = {"FifoTopic": "true", "ContentBasedDeduplication": "true"} + topic_arn = sns_create_topic( + Name=topic_name, + Attributes=topic_attributes, + )["TopicArn"] + + queue_attributes = {"FifoQueue": "true", "ContentBasedDeduplication": "true"} + queues = [] + queue_amount = 5 + message_amount = 10 + + for _ in range(queue_amount): + queue_name = f"queue-{short_uid()}.fifo" + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes=queue_attributes, + ) + sns_create_sqs_subscription( + topic_arn=topic_arn, queue_url=queue_url, Attributes={"RawMessageDelivery": "true"} + ) + queues.append(queue_url) + + for i in range(message_amount): + aws_client.sns.publish( + TopicArn=topic_arn, Message=str(i), MessageGroupId="message-group-id-1" + ) + + all_messages = [] + for queue_url in queues: + messages = sqs_collect_messages( + queue_url, + expected=message_amount, + timeout=10, + max_number_of_messages=message_amount, + ) + contents = [message["Body"] for message in messages] + all_messages.append(contents) + + # we're expecting the order to be the same across all queues + reference_order = all_messages[0] + for received_content in all_messages[1:]: + assert received_content == reference_order + + +class TestSNSSubscriptionSES: + @markers.aws.only_localstack + def test_topic_email_subscription_confirmation( + self, sns_create_topic, sns_subscription, aws_client + ): + # FIXME: we do not send the token to the email endpoint, so they cannot validate it + # create AWS validated test for format + # for now, access internals + topic_arn = sns_create_topic()["TopicArn"] + subscription = sns_subscription( + TopicArn=topic_arn, + Protocol="email", + Endpoint="localstack@yopmail.com", + ) + subscription_arn = subscription["SubscriptionArn"] + parsed_arn = parse_arn(subscription_arn) + store = SnsProvider.get_store(parsed_arn["account"], parsed_arn["region"]) + + sub_attr = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + assert sub_attr["Attributes"]["PendingConfirmation"] == "true" + + def check_subscription(): + for token, sub_arn in store.subscription_tokens.items(): + if sub_arn == subscription_arn: + aws_client.sns.confirm_subscription(TopicArn=topic_arn, Token=token) + + sub_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert sub_attributes["Attributes"]["PendingConfirmation"] == "false" + + retry(check_subscription, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + @markers.aws.only_localstack + def test_email_sender( + self, + sns_create_topic, + sns_subscription, + aws_client, + monkeypatch, + ): + # make sure to reset all received emails in SES + requests.delete("http://localhost:4566/_aws/ses") + + topic_arn = sns_create_topic()["TopicArn"] + sns_subscription( + TopicArn=topic_arn, + Protocol="email", + Endpoint="localstack@yopmail.com", + ) + + aws_client.sns.publish( + Message="Test message", + TopicArn=topic_arn, + ) + + def _get_messages(amount: int) -> list[dict]: + response = requests.get("http://localhost:4566/_aws/ses").json() + assert len(response["messages"]) == amount + return response["messages"] + + messages = retry(lambda: _get_messages(1), retries=PUBLICATION_RETRIES, sleep=1) + # legacy default value, should be replaced at some point + assert messages[0]["Source"] == "admin@localstack.com" + requests.delete("http://localhost:4566/_aws/ses") + + sender_address = "no-reply@sns.localstack.cloud" + monkeypatch.setattr(config, "SNS_SES_SENDER_ADDRESS", sender_address) + + aws_client.sns.publish( + Message="Test message", + TopicArn=topic_arn, + ) + messages = retry(lambda: _get_messages(1), retries=PUBLICATION_RETRIES, sleep=1) + assert messages[0]["Source"] == sender_address + + +class TestSNSPlatformEndpoint: + @markers.aws.only_localstack + def test_subscribe_platform_endpoint( + self, + sns_create_topic, + sns_subscription, + sns_create_platform_application, + aws_client, + account_id, + region_name, + ): + sns_backend = SnsProvider.get_store(account_id, region_name) + topic_arn = sns_create_topic()["TopicArn"] + + app_arn = sns_create_platform_application(Name="app1", Platform="p1", Attributes={})[ + "PlatformApplicationArn" + ] + platform_arn = aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=app_arn, Token="token_1" + )["EndpointArn"] + + # create subscription with filter policy + filter_policy = {"attr1": [{"numeric": [">", 0, "<=", 100]}]} + sns_subscription( + TopicArn=topic_arn, + Protocol="application", + Endpoint=platform_arn, + Attributes={"FilterPolicy": json.dumps(filter_policy)}, + ) + # publish message that satisfies the filter policy + message = "This is a test message" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": "99.12"}}, + ) + + # assert that message has been received + def check_message(): + assert len(sns_backend.platform_endpoint_messages[platform_arn]) > 0 + + retry(check_message, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + @markers.aws.needs_fixing + @pytest.mark.skip(reason="Test asserts wrong behaviour") + # AWS validating this is hard because we need real credentials for a GCM/Apple mobile app + # TODO: AWS validate this test + # See https://github.com/getmoto/moto/pull/6953 where Moto updated errors. + def test_create_platform_endpoint_check_idempotency( + self, sns_create_platform_application, aws_client + ): + response = sns_create_platform_application( + Name=f"test-{short_uid()}", + Platform="GCM", + Attributes={"PlatformCredential": "123"}, + ) + token = "test1" + # TODO: As per AWS docs: + # > The CreatePlatformEndpoint action is idempotent, so if the requester already owns an endpoint + # > with the same device token and attributes, that endpoint's ARN is returned without creating a new endpoint. + # The 'Token' and 'Attributes' are critical to idempotent behaviour. + kwargs_list = [ + {"Token": token, "CustomUserData": "test-data"}, + {"Token": token, "CustomUserData": "test-data"}, + {"Token": token}, + {"Token": token}, + ] + platform_arn = response["PlatformApplicationArn"] + responses = [] + for kwargs in kwargs_list: + responses.append( + aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=platform_arn, **kwargs + ) + ) + # Assert EndpointArn is returned in every call create platform call + assert all("EndpointArn" in response for response in responses) + endpoint_arn = responses[0]["EndpointArn"] + + with pytest.raises(ClientError) as e: + aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=platform_arn, + Token=token, + CustomUserData="different-user-data", + ) + assert e.value.response["Error"]["Code"] == "InvalidParameter" + assert ( + e.value.response["Error"]["Message"] + == f"Endpoint {endpoint_arn} already exists with the same Token, but different attributes." + ) + + @markers.aws.needs_fixing + # AWS validating this is hard because we need real credentials for a GCM/Apple mobile app + def test_publish_disabled_endpoint(self, sns_create_platform_application, aws_client): + response = sns_create_platform_application( + Name=f"test-{short_uid()}", + Platform="GCM", + Attributes={"PlatformCredential": "123"}, + ) + platform_arn = response["PlatformApplicationArn"] + response = aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=platform_arn, + Token="test1", + ) + endpoint_arn = response["EndpointArn"] + + get_attrs = aws_client.sns.get_endpoint_attributes(EndpointArn=endpoint_arn) + assert get_attrs["Attributes"]["Enabled"] == "true" + + aws_client.sns.set_endpoint_attributes( + EndpointArn=endpoint_arn, Attributes={"Enabled": "false"} + ) + + get_attrs = aws_client.sns.get_endpoint_attributes(EndpointArn=endpoint_arn) + assert get_attrs["Attributes"]["Enabled"] == "false" + + with pytest.raises(ClientError) as e: + message = { + "GCM": '{ "notification": {"title": "Title of notification", "body": "It works" } }' + } + aws_client.sns.publish( + TargetArn=endpoint_arn, MessageStructure="json", Message=json.dumps(message) + ) + + assert e.value.response["Error"]["Code"] == "EndpointDisabled" + assert e.value.response["Error"]["Message"] == "Endpoint is disabled" + + @markers.aws.only_localstack # needs real credentials for GCM/FCM + @pytest.mark.skip(reason="Need to implement credentials validation when creating platform") + def test_publish_to_gcm(self, sns_create_platform_application, aws_client): + key = "mock_server_key" + token = "mock_token" + + response = sns_create_platform_application( + Name="firebase", Platform="GCM", Attributes={"PlatformCredential": key} + ) + + platform_app_arn = response["PlatformApplicationArn"] + + response = aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=platform_app_arn, + Token=token, + ) + endpoint_arn = response["EndpointArn"] + + message = { + "GCM": '{ "notification": {"title": "Title of notification", "body": "It works" } }' + } + + with pytest.raises(ClientError) as ex: + aws_client.sns.publish( + TargetArn=endpoint_arn, MessageStructure="json", Message=json.dumps(message) + ) + assert ex.value.response["Error"]["Code"] == "InvalidParameter" + + @markers.aws.only_localstack + def test_publish_to_platform_endpoint_is_dispatched( + self, + sns_create_topic, + sns_subscription, + sns_create_platform_application, + aws_client, + account_id, + region_name, + ): + topic_arn = sns_create_topic()["TopicArn"] + endpoints_arn = {} + for platform_type in ["APNS", "GCM"]: + application_platform_name = f"app-platform-{platform_type}-{short_uid()}" + + # Create an Apple platform application + app_arn = sns_create_platform_application( + Name=application_platform_name, Platform=platform_type, Attributes={} + )["PlatformApplicationArn"] + + endpoint_arn = aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=app_arn, Token=short_uid() + )["EndpointArn"] + + # store the endpoint for checking results + endpoints_arn[platform_type] = endpoint_arn + + # subscribe this endpoint to a topic + sns_subscription( + TopicArn=topic_arn, + Protocol="application", + Endpoint=endpoint_arn, + ) + + # now we have two platform endpoints subscribed to the same topic + message = { + "default": "This is the default message which must be present when publishing a message to a topic.", + "APNS": '{"aps":{"alert": "Check out these awesome deals!","url":"www.amazon.com"} }', + "GCM": '{"data":{"message":"Check out these awesome deals!","url":"www.amazon.com"}}', + } + + # publish to the topic + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + MessageStructure="json", + ) + + sns_backend = SnsProvider.get_store(account_id, region_name) + platform_endpoint_msgs = sns_backend.platform_endpoint_messages + + # assert that message has been received + def check_message(): + assert len(platform_endpoint_msgs[endpoint_arn]) > 0 + + retry(check_message, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + # each endpoint should only receive the message that was directed to them + assert platform_endpoint_msgs[endpoints_arn["GCM"]][0]["Message"] == message["GCM"] + assert platform_endpoint_msgs[endpoints_arn["APNS"]][0]["Message"] == message["APNS"] + + +class TestSNSSMS: + @markers.aws.only_localstack + def test_publish_sms(self, aws_client, account_id, region_name): + phone_number = "+33000000000" + response = aws_client.sns.publish(PhoneNumber=phone_number, Message="This is a SMS") + assert "MessageId" in response + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + sns_backend = SnsProvider.get_store( + account_id=account_id, + region_name=region_name, + ) + + def check_messages(): + sms_was_found = False + for message in sns_backend.sms_messages: + if message["PhoneNumber"] == phone_number: + sms_was_found = True + break + + assert sms_was_found + + retry(check_messages, sleep=0.5) + + @markers.aws.validated + def test_subscribe_sms_endpoint(self, sns_create_topic, sns_subscription, snapshot, aws_client): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + response = sns_subscription(TopicArn=topic_arn, Protocol="sms", Endpoint=phone_number) + snapshot.match("subscribe-sms-endpoint", response) + + sub_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=response["SubscriptionArn"] + ) + snapshot.match("subscribe-sms-attrs", sub_attrs) + + @markers.aws.only_localstack + def test_publish_sms_endpoint( + self, sns_create_topic, sns_subscription, aws_client, account_id, region_name + ): + list_of_contacts = [ + f"+{random.randint(100000000, 9999999999)}", + f"+{random.randint(100000000, 9999999999)}", + f"+{random.randint(100000000, 9999999999)}", + ] + message = "Good news everyone!" + topic_arn = sns_create_topic()["TopicArn"] + for number in list_of_contacts: + sns_subscription(TopicArn=topic_arn, Protocol="sms", Endpoint=number) + + aws_client.sns.publish(Message=message, TopicArn=topic_arn) + + sns_backend = SnsProvider.get_store(account_id, region_name) + + def check_messages(): + sms_messages = sns_backend.sms_messages + for contact in list_of_contacts: + sms_was_found = False + for _message in sms_messages: + if _message["PhoneNumber"] == contact: + sms_was_found = True + break + + assert sms_was_found + + retry(check_messages, sleep=0.5) + + @markers.aws.validated + def test_publish_wrong_phone_format( + self, sns_create_topic, sns_subscription, snapshot, aws_client + ): + message = "Good news everyone!" + with pytest.raises(ClientError) as e: + aws_client.sns.publish(Message=message, PhoneNumber="+1a234") + + snapshot.match("invalid-number", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sns.publish(Message=message, PhoneNumber="NAA+15551234567") + + snapshot.match("wrong-format", e.value.response) + + topic_arn = sns_create_topic()["TopicArn"] + with pytest.raises(ClientError) as e: + sns_subscription(TopicArn=topic_arn, Protocol="sms", Endpoint="NAA+15551234567") + snapshot.match("wrong-endpoint", e.value.response) + + +class TestSNSSubscriptionHttp: + @markers.aws.validated + def test_http_subscription_response( + self, + sns_create_topic, + sns_subscription, + aws_client, + snapshot, + ): + topic_arn = sns_create_topic()["TopicArn"] + snapshot.match("topic-arn", {"TopicArn": topic_arn}) + + # we need to hit whatever URL, even external, the publishing is async, but we need an endpoint who won't + # confirm the subscription + subscription = sns_subscription( + TopicArn=topic_arn, + Protocol="http", + Endpoint="http://example.com", + ReturnSubscriptionArn=False, + ) + snapshot.match("subscription", subscription) + + subscription_with_arn = sns_subscription( + TopicArn=topic_arn, + Protocol="http", + Endpoint="http://example.com", + ReturnSubscriptionArn=True, + ) + snapshot.match("subscription-with-arn", subscription_with_arn) + + @markers.aws.manual_setup_required + def test_redrive_policy_http_subscription( + self, sns_create_topic, sqs_create_queue, sqs_get_queue_arn, sns_subscription, aws_client + ): + dlq_name = f"dlq-{short_uid()}" + dlq_url = sqs_create_queue(QueueName=dlq_name) + dlq_arn = sqs_get_queue_arn(dlq_url) + topic_arn = sns_create_topic()["TopicArn"] + + # create HTTP endpoint and connect it to SNS topic + with HTTPServer() as server: + server.expect_request("/subscription").respond_with_data(b"", 200) + http_endpoint = server.url_for("/subscription") + wait_for_port_open(server.port) + + subscription = sns_subscription( + TopicArn=topic_arn, Protocol="http", Endpoint=http_endpoint + ) + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription["SubscriptionArn"], + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": dlq_arn}), + ) + + # wait for subscription notification to arrive at http endpoint + poll_condition(lambda: len(server.log) >= 1, timeout=10) + request, _ = server.log[0] + event = request.get_json(force=True) + assert request.path.endswith("/subscription") + assert event["Type"] == "SubscriptionConfirmation" + assert event["TopicArn"] == topic_arn + aws_client.sns.confirm_subscription(TopicArn=topic_arn, Token=event["Token"]) + + wait_for_port_closed(server.port) + + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps({"message": "test_redrive_policy"}), + ) + + response = aws_client.sqs.receive_message(QueueUrl=dlq_url, WaitTimeSeconds=10) + assert len(response["Messages"]) == 1, ( + f"invalid number of messages in DLQ response {response}" + ) + message = json.loads(response["Messages"][0]["Body"]) + assert message["Type"] == "Notification" + assert json.loads(message["Message"])["message"] == "test_redrive_policy" + + @markers.aws.manual_setup_required + def test_multiple_subscriptions_http_endpoint( + self, sns_create_topic, sns_subscription, aws_client + ): + # create a topic + topic_arn = sns_create_topic()["TopicArn"] + + # build fake http server endpoints + _requests = queue.Queue() + + # create HTTP endpoint and connect it to SNS topic + def handler(_request): + _requests.put(_request) + return Response(status=429) + + number_of_endpoints = 4 + + servers = [] + try: + for _ in range(number_of_endpoints): + server = HTTPServer() + server.start() + servers.append(server) + server.expect_request("/").respond_with_handler(handler) + http_endpoint = server.url_for("/") + wait_for_port_open(http_endpoint) + + sns_subscription(TopicArn=topic_arn, Protocol="http", Endpoint=http_endpoint) + + # fetch subscription information + subscription_list = aws_client.sns.list_subscriptions_by_topic(TopicArn=topic_arn) + assert subscription_list["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert len(subscription_list["Subscriptions"]) == number_of_endpoints, ( + f"unexpected number of subscriptions {subscription_list}" + ) + + tokens = [] + for _ in range(number_of_endpoints): + request = _requests.get(timeout=2) + request_data = request.get_json(True) + tokens.append(request_data["Token"]) + assert request_data["TopicArn"] == topic_arn + + with pytest.raises(queue.Empty): + # make sure only four requests are received + _requests.get(timeout=1) + + # assert the first subscription is pending confirmation + sub_1 = subscription_list["Subscriptions"][0] + sub_1_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=sub_1["SubscriptionArn"] + ) + assert sub_1_attrs["Attributes"]["PendingConfirmation"] == "true" + + # assert the second subscription is pending confirmation + sub_2 = subscription_list["Subscriptions"][1] + sub_2_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=sub_2["SubscriptionArn"] + ) + assert sub_2_attrs["Attributes"]["PendingConfirmation"] == "true" + + # confirm the first subscription + response = aws_client.sns.confirm_subscription(TopicArn=topic_arn, Token=tokens[0]) + # assert the confirmed subscription is the first one + assert response["SubscriptionArn"] == sub_1["SubscriptionArn"] + + # assert the first subscription is confirmed + sub_1_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=sub_1["SubscriptionArn"] + ) + assert sub_1_attrs["Attributes"]["PendingConfirmation"] == "false" + + # assert the second subscription is NOT confirmed + sub_2_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=sub_2["SubscriptionArn"] + ) + assert sub_2_attrs["Attributes"]["PendingConfirmation"] == "true" + + finally: + subscription_list = aws_client.sns.list_subscriptions_by_topic(TopicArn=topic_arn) + for subscription in subscription_list["Subscriptions"]: + aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) + for server in servers: + server.stop() + + @markers.aws.manual_setup_required + @pytest.mark.parametrize("raw_message_delivery", [True, False]) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.http-message-headers.Accept", # requests adds the header but not SNS, not very important + "$.http-message-headers-raw.Accept", + "$.http-confirm-sub-headers.Accept", + ] + ) + def test_subscribe_external_http_endpoint( + self, sns_create_http_endpoint, raw_message_delivery, aws_client, snapshot + ): + def _get_snapshot_requests_response(response: requests.Response) -> dict: + parsed_xml_body = xmltodict.parse(response.content) + for root_tag, fields in parsed_xml_body.items(): + fields.pop("@xmlns", None) + if "ResponseMetadata" in fields: + fields["ResponseMetadata"]["HTTPHeaders"] = dict(response.headers) + fields["ResponseMetadata"]["HTTPStatusCode"] = response.status_code + return parsed_xml_body + + def _clean_headers(response_headers: dict): + return {key: val for key, val in response_headers.items() if "Forwarded" not in key} + + snapshot.add_transformer( + [ + snapshot.transform.key_value("RequestId"), + snapshot.transform.key_value("Token"), + snapshot.transform.key_value("Host"), + snapshot.transform.key_value( + "Content-Length", reference_replacement=False + ), # might change depending on compression + snapshot.transform.key_value( + "Connection", reference_replacement=False + ), # casing might change + snapshot.transform.regex( + r"(?i)(?<=SubscribeURL[\"|']:\s[\"|'])(https?.*?)(?=/\?Action=ConfirmSubscription)", + replacement="", + ), + ] + ) + + # Necessitate manual set up to allow external access to endpoint, only in local testing + topic_arn, subscription_arn, endpoint_url, server = sns_create_http_endpoint( + raw_message_delivery + ) + assert poll_condition( + lambda: len(server.log) >= 1, + timeout=5, + ) + sub_request, _ = server.log[0] + payload = sub_request.get_json(force=True) + snapshot.match("subscription-confirmation", payload) + assert payload["Type"] == "SubscriptionConfirmation" + assert sub_request.headers["x-amz-sns-message-type"] == "SubscriptionConfirmation" + assert "Signature" in payload + assert "SigningCertURL" in payload + + snapshot.match("http-confirm-sub-headers", _clean_headers(sub_request.headers)) + + token = payload["Token"] + subscribe_url = payload["SubscribeURL"] + service_url, subscribe_url_path = payload["SubscribeURL"].rsplit("/", maxsplit=1) + assert subscribe_url == ( + f"{service_url}/?Action=ConfirmSubscription&TopicArn={topic_arn}&Token={token}" + ) + + test_broken_confirm_url = ( + f"{service_url}/?Action=ConfirmSubscription&TopicArn=not-an-arn&Token={token}" + ) + broken_confirm_subscribe_request = requests.get(test_broken_confirm_url) + snapshot.match( + "broken-topic-arn-confirm", + _get_snapshot_requests_response(broken_confirm_subscribe_request), + ) + + test_broken_token_confirm_url = ( + f"{service_url}/?Action=ConfirmSubscription&TopicArn={topic_arn}&Token=abc123" + ) + broken_token_confirm_subscribe_request = requests.get(test_broken_token_confirm_url) + snapshot.match( + "broken-token-confirm", + _get_snapshot_requests_response(broken_token_confirm_subscribe_request), + ) + + # using the right topic name with a different region will fail when confirming the subscription + parsed_arn = parse_arn(topic_arn) + different_region = "eu-central-1" if parsed_arn["region"] != "eu-central-1" else "us-east-1" + different_region_topic = topic_arn.replace(parsed_arn["region"], different_region) + different_region_topic_confirm_url = f"{service_url}/?Action=ConfirmSubscription&TopicArn={different_region_topic}&Token={token}" + region_topic_confirm_subscribe_request = requests.get(different_region_topic_confirm_url) + snapshot.match( + "different-region-arn-confirm", + _get_snapshot_requests_response(region_topic_confirm_subscribe_request), + ) + + # but a nonexistent topic in the right region will succeed + last_fake_topic_char = "a" if topic_arn[-1] != "a" else "b" + nonexistent = topic_arn[:-1] + last_fake_topic_char + assert nonexistent != topic_arn + test_wrong_topic_confirm_url = ( + f"{service_url}/?Action=ConfirmSubscription&TopicArn={nonexistent}&Token={token}" + ) + wrong_topic_confirm_subscribe_request = requests.get(test_wrong_topic_confirm_url) + snapshot.match( + "nonexistent-token-confirm", + _get_snapshot_requests_response(wrong_topic_confirm_subscribe_request), + ) + + # weirdly, even with a wrong topic, SNS will confirm the topic + subscription_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert subscription_attributes["Attributes"]["PendingConfirmation"] == "false" + + confirm_subscribe_request = requests.get(subscribe_url) + confirm_subscribe = xmltodict.parse(confirm_subscribe_request.content) + assert ( + confirm_subscribe["ConfirmSubscriptionResponse"]["ConfirmSubscriptionResult"][ + "SubscriptionArn" + ] + == subscription_arn + ) + # also confirm that ConfirmSubscription is idempotent + snapshot.match( + "confirm-subscribe", _get_snapshot_requests_response(confirm_subscribe_request) + ) + + subscription_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert subscription_attributes["Attributes"]["PendingConfirmation"] == "false" + + message = "test_external_http_endpoint" + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + + assert poll_condition( + lambda: len(server.log) >= 2, + timeout=5, + ) + notification_request, _ = server.log[1] + assert notification_request.headers["x-amz-sns-message-type"] == "Notification" + + expected_unsubscribe_url = ( + f"{service_url}/?Action=Unsubscribe&SubscriptionArn={subscription_arn}" + ) + if raw_message_delivery: + payload = notification_request.data.decode() + assert payload == message + snapshot.match("http-message-headers-raw", _clean_headers(notification_request.headers)) + else: + payload = notification_request.get_json(force=True) + assert payload["Type"] == "Notification" + assert "Signature" in payload + assert "SigningCertURL" in payload + assert payload["Message"] == message + assert payload["UnsubscribeURL"] == expected_unsubscribe_url + snapshot.match("http-message", payload) + snapshot.match("http-message-headers", _clean_headers(notification_request.headers)) + + unsub_request = requests.get(expected_unsubscribe_url) + unsubscribe_confirmation = xmltodict.parse(unsub_request.content) + assert "UnsubscribeResponse" in unsubscribe_confirmation + snapshot.match("unsubscribe-response", _get_snapshot_requests_response(unsub_request)) + + assert poll_condition( + lambda: len(server.log) >= 3, + timeout=5, + ) + unsub_request, _ = server.log[2] + + payload = unsub_request.get_json(force=True) + assert payload["Type"] == "UnsubscribeConfirmation" + assert unsub_request.headers["x-amz-sns-message-type"] == "UnsubscribeConfirmation" + assert "Signature" in payload + assert "SigningCertURL" in payload + token = payload["Token"] + assert payload["SubscribeURL"] == ( + f"{service_url}/?Action=ConfirmSubscription&TopicArn={topic_arn}&Token={token}" + ) + snapshot.match("unsubscribe-request", payload) + + @markers.aws.manual_setup_required + @pytest.mark.parametrize("raw_message_delivery", [True, False]) + def test_dlq_external_http_endpoint( + self, + sqs_create_queue, + sqs_get_queue_arn, + sns_create_http_endpoint, + sns_allow_topic_sqs_queue, + raw_message_delivery, + aws_client, + ): + # Necessitate manual set up to allow external access to endpoint, only in local testing + topic_arn, http_subscription_arn, endpoint_url, server = sns_create_http_endpoint( + raw_message_delivery + ) + + dlq_url = sqs_create_queue() + dlq_arn = sqs_get_queue_arn(dlq_url) + + sns_allow_topic_sqs_queue( + sqs_queue_url=dlq_url, sqs_queue_arn=dlq_arn, sns_topic_arn=topic_arn + ) + aws_client.sns.set_subscription_attributes( + SubscriptionArn=http_subscription_arn, + AttributeName="RedrivePolicy", + AttributeValue=json.dumps({"deadLetterTargetArn": dlq_arn}), + ) + assert poll_condition( + lambda: len(server.log) >= 1, + timeout=5, + ) + sub_request, _ = server.log[0] + payload = sub_request.get_json(force=True) + assert payload["Type"] == "SubscriptionConfirmation" + assert sub_request.headers["x-amz-sns-message-type"] == "SubscriptionConfirmation" + + subscribe_url = payload["SubscribeURL"] + service_url, subscribe_url_path = payload["SubscribeURL"].rsplit("/", maxsplit=1) + + confirm_subscribe_request = requests.get(subscribe_url) + confirm_subscribe = xmltodict.parse(confirm_subscribe_request.content) + assert ( + confirm_subscribe["ConfirmSubscriptionResponse"]["ConfirmSubscriptionResult"][ + "SubscriptionArn" + ] + == http_subscription_arn + ) + + subscription_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=http_subscription_arn + ) + assert subscription_attributes["Attributes"]["PendingConfirmation"] == "false" + + server.stop() + wait_for_port_closed(server.port) + + message = "test_dlq_external_http_endpoint" + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + + response = aws_client.sqs.receive_message(QueueUrl=dlq_url, WaitTimeSeconds=3) + assert len(response["Messages"]) == 1, ( + f"invalid number of messages in DLQ response {response}" + ) + + if raw_message_delivery: + assert response["Messages"][0]["Body"] == message + else: + received_message = json.loads(response["Messages"][0]["Body"]) + assert received_message["Type"] == "Notification" + assert received_message["Message"] == message + + receipt_handle = response["Messages"][0]["ReceiptHandle"] + aws_client.sqs.delete_message(QueueUrl=dlq_url, ReceiptHandle=receipt_handle) + + expected_unsubscribe_url = ( + f"{service_url}/?Action=Unsubscribe&SubscriptionArn={http_subscription_arn}" + ) + + unsub_request = requests.get(expected_unsubscribe_url) + unsubscribe_confirmation = xmltodict.parse(unsub_request.content) + assert "UnsubscribeResponse" in unsubscribe_confirmation + + response = aws_client.sqs.receive_message(QueueUrl=dlq_url, WaitTimeSeconds=2) + # AWS doesn't send to the DLQ if the UnsubscribeConfirmation fails to be delivered + assert "Messages" not in response or response["Messages"] == [] + + @markers.aws.manual_setup_required + @pytest.mark.parametrize("raw_message_delivery", [True, False]) + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.http-message-headers.Accept", # requests adds the header but not SNS, not very important + "$.http-message-headers-raw.Accept", + "$.http-confirm-sub-headers.Accept", + # TODO: we need to fix this parity in Moto, in order to make the retrieval logic of those values easier + "$.sub-attrs.Attributes.ConfirmationWasAuthenticated", + "$.sub-attrs.Attributes.DeliveryPolicy", + "$.sub-attrs.Attributes.EffectiveDeliveryPolicy", + "$.topic-attrs.Attributes.DeliveryPolicy", + "$.topic-attrs.Attributes.EffectiveDeliveryPolicy", + "$.topic-attrs.Attributes.Policy.Statement..Action", + ] + ) + def test_subscribe_external_http_endpoint_content_type( + self, + sns_create_http_endpoint, + raw_message_delivery, + aws_client, + snapshot, + ): + def _clean_headers(response_headers: dict): + return {key: val for key, val in response_headers.items() if "Forwarded" not in key} + + snapshot.add_transformer( + [ + snapshot.transform.key_value("RequestId"), + snapshot.transform.key_value("Token"), + snapshot.transform.key_value("Host"), + snapshot.transform.key_value( + "Content-Length", reference_replacement=False + ), # might change depending on compression + snapshot.transform.key_value( + "Connection", reference_replacement=False + ), # casing might change + snapshot.transform.regex( + r"(?i)(?<=SubscribeURL[\"|']:\s[\"|'])(https?.*?)(?=/\?Action=ConfirmSubscription)", + replacement="", + ), + ] + ) + + # Necessitate manual set up to allow external access to endpoint, only in local testing + topic_arn, subscription_arn, endpoint_url, server = sns_create_http_endpoint( + raw_message_delivery + ) + + # try both setting the Topic attribute or Subscription attribute + # https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html#creating-delivery-policy + if raw_message_delivery: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="DeliveryPolicy", + AttributeValue=json.dumps( + { + "requestPolicy": {"headerContentType": "text/csv"}, + } + ), + ) + else: + aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="DeliveryPolicy", + AttributeValue=json.dumps( + { + "http": { + "defaultRequestPolicy": {"headerContentType": "application/json"}, + } + } + ), + ) + + topic_attrs = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("topic-attrs", topic_attrs) + + sub_attrs = aws_client.sns.get_subscription_attributes(SubscriptionArn=subscription_arn) + snapshot.match("sub-attrs", sub_attrs) + + assert poll_condition( + lambda: len(server.log) >= 1, + timeout=5, + ) + sub_request, _ = server.log[0] + payload = sub_request.get_json(force=True) + snapshot.match("subscription-confirmation", payload) + snapshot.match("http-confirm-sub-headers", _clean_headers(sub_request.headers)) + + token = payload["Token"] + aws_client.sns.confirm_subscription(TopicArn=topic_arn, Token=token) + + message = "test_external_http_endpoint" + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + + assert poll_condition( + lambda: len(server.log) >= 2, + timeout=5, + ) + notification_request, _ = server.log[1] + assert notification_request.headers["x-amz-sns-message-type"] == "Notification" + if raw_message_delivery: + payload = notification_request.data.decode() + assert payload == message + snapshot.match("http-message-headers-raw", _clean_headers(notification_request.headers)) + else: + payload = notification_request.get_json(force=True) + snapshot.match("http-message", payload) + snapshot.match("http-message-headers", _clean_headers(notification_request.headers)) + + @markers.aws.validated + def test_subscribe_external_http_endpoint_lambda_url_sig_validation( + self, + create_sns_http_endpoint_and_queue, + sns_create_topic, + sns_subscription, + aws_client, + snapshot, + sqs_collect_messages, + ): + def _get_snapshot_from_lambda_url_msg(events: list[dict]) -> dict: + formatted_events = [] + + def _filter_headers(headers: dict) -> dict: + filtered_headers = {} + for key, value in headers.items(): + l_key = key.lower() + if l_key.startswith("x-amz-sns") or key in ( + "content-type", + "accept-encoding", + "user-agent", + ): + filtered_headers[key] = value + + return filtered_headers + + for event in events: + msg = json.loads(event["Body"])["event"] + formatted_events.append( + {"headers": _filter_headers(msg["headers"]), "body": json.loads(msg["body"])} + ) + + return {"events": formatted_events} + + def validate_message_signature(msg_event: dict, msg_type: str): + cert_url = msg_event["SigningCertURL"] + get_cert_req = requests.get(cert_url) + assert get_cert_req.ok + + cert = x509.load_pem_x509_certificate(get_cert_req.content) + message_signature = msg_event["Signature"] + # create the canonical string + if msg_type == "Notification": + fields = ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"] + else: + fields = [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ] + + # Build the string to be signed. + string_to_sign = "".join( + [f"{field}\n{msg_event[field]}\n" for field in fields if field in msg_event] + ) + + # decode the signature from base64. + decoded_signature = base64.b64decode(message_signature) + + message_sig_version = msg_event["SignatureVersion"] + # this is a bug on AWS side, assert our behaviour is the same for now, this might get fixed + assert message_sig_version == "1" + signature_hash = hashes.SHA1() if message_sig_version == "1" else hashes.SHA256() + + # calculate signature value with cert + # if the signature is invalid, this will raise an exception + cert.public_key().verify( + decoded_signature, + to_bytes(string_to_sign), + padding=padding.PKCS1v15(), + algorithm=signature_hash, + ) + + snapshot.add_transformer( + [ + snapshot.transform.key_value("RequestId"), + snapshot.transform.key_value("Token"), + snapshot.transform.key_value("Host"), + snapshot.transform.regex( + r"(?i)(?<=SubscribeURL[\"|']:\s[\"|'])(https?.*?)(?=/\?Action=ConfirmSubscription)", + replacement="", + ), + ] + ) + http_endpoint_url, queue_url = create_sns_http_endpoint_and_queue() + topic_arn = sns_create_topic()["TopicArn"] + sns_protocol = http_endpoint_url.split("://")[0] + subscription = sns_subscription( + TopicArn=topic_arn, Protocol=sns_protocol, Endpoint=http_endpoint_url + ) + subscription_arn = subscription["SubscriptionArn"] + delivery_policy = { + "healthyRetryPolicy": { + "minDelayTarget": 1, + "maxDelayTarget": 1, + "numRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "numMaxDelayRetries": 0, + "backoffFunction": "linear", + }, + "sicklyRetryPolicy": None, + "throttlePolicy": {"maxReceivesPerSecond": 1000}, + "guaranteed": False, + } + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="DeliveryPolicy", + AttributeValue=json.dumps(delivery_policy), + ) + + messages = sqs_collect_messages(queue_url, expected=1, timeout=10) + subscribe_event = _get_snapshot_from_lambda_url_msg(messages) + snapshot.match("subscription-confirmation", subscribe_event) + + subscribe_payload = subscribe_event["events"][0]["body"] + + validate_message_signature( + subscribe_payload, + msg_type=subscribe_event["events"][0]["headers"]["x-amz-sns-message-type"], + ) + + token = subscribe_payload["Token"] + subscribe_url = subscribe_payload["SubscribeURL"] + service_url, subscribe_url_path = subscribe_url.rsplit("/", maxsplit=1) + # we manually assert here to be sure the format is right, as it hard to verify with snapshots + assert subscribe_url == ( + f"{service_url}/?Action=ConfirmSubscription&TopicArn={topic_arn}&Token={token}" + ) + + confirm_subscription = aws_client.sns.confirm_subscription(TopicArn=topic_arn, Token=token) + snapshot.match("confirm-subscription", confirm_subscription) + + subscription_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert subscription_attributes["Attributes"]["PendingConfirmation"] == "false" + + message = "test_external_http_endpoint" + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + + messages = sqs_collect_messages(queue_url, expected=1, timeout=10) + publish_event = _get_snapshot_from_lambda_url_msg(messages) + snapshot.match("publish-event", publish_event) + publish_payload = publish_event["events"][0]["body"] + validate_message_signature( + publish_payload, + msg_type=publish_event["events"][0]["headers"]["x-amz-sns-message-type"], + ) + + unsub_request = requests.get(publish_payload["UnsubscribeURL"]) + assert b"UnsubscribeResponse" in unsub_request.content + + messages = sqs_collect_messages(queue_url, expected=1, timeout=10) + unsubscribe_event = _get_snapshot_from_lambda_url_msg(messages) + snapshot.match("unsubscribe-event", unsubscribe_event) + + unsubscribe_payload = unsubscribe_event["events"][0]["body"] + validate_message_signature( + unsubscribe_payload, + msg_type=unsubscribe_event["events"][0]["headers"]["x-amz-sns-message-type"], + ) + + +class TestSNSSubscriptionFirehose: + @markers.aws.validated + def test_publish_to_firehose_with_s3( + self, + create_role, + s3_create_bucket, + firehose_create_delivery_stream, + sns_create_topic, + sns_subscription, + aws_client, + region_name, + ): + role_name = f"test-role-{short_uid()}" + stream_name = f"test-stream-{short_uid()}" + bucket_name = f"test-bucket-{short_uid()}" + topic_name = f"test_topic_{short_uid()}" + + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "s3.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + { + "Effect": "Allow", + "Principal": {"Service": "firehose.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + { + "Effect": "Allow", + "Principal": {"Service": "sns.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + ], + } + + role = create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy)) + + aws_client.iam.attach_role_policy( + RoleName=role_name, + PolicyArn=f"arn:{get_partition(region_name)}:iam::aws:policy/AmazonKinesisFirehoseFullAccess", + ) + + aws_client.iam.attach_role_policy( + RoleName=role_name, + PolicyArn=f"arn:{get_partition(region_name)}:iam::aws:policy/AmazonS3FullAccess", + ) + subscription_role_arn = role["Role"]["Arn"] + + if is_aws_cloud(): + time.sleep(10) + + s3_create_bucket(Bucket=bucket_name) + + stream = firehose_create_delivery_stream( + DeliveryStreamName=stream_name, + DeliveryStreamType="DirectPut", + S3DestinationConfiguration={ + "RoleARN": subscription_role_arn, + "BucketARN": f"arn:{get_partition(region_name)}:s3:::{bucket_name}", + "BufferingHints": {"SizeInMBs": 1, "IntervalInSeconds": 60}, + }, + ) + + topic = sns_create_topic(Name=topic_name) + sns_subscription( + TopicArn=topic["TopicArn"], + Protocol="firehose", + Endpoint=stream["DeliveryStreamARN"], + Attributes={"SubscriptionRoleArn": subscription_role_arn}, + ReturnSubscriptionArn=True, + ) + + message = json.dumps({"message": "hello world"}) + message_attributes = { + "testAttribute": {"DataType": "String", "StringValue": "valueOfAttribute"} + } + aws_client.sns.publish( + TopicArn=topic["TopicArn"], Message=message, MessageAttributes=message_attributes + ) + + def validate_content(): + files = aws_client.s3.list_objects(Bucket=bucket_name)["Contents"] + f = BytesIO() + aws_client.s3.download_fileobj(bucket_name, files[0]["Key"], f) + content = to_str(f.getvalue()) + + sns_message = json.loads(content.split("\n")[0]) + + assert "Type" in sns_message + assert "MessageId" in sns_message + assert "Message" in sns_message + assert "Timestamp" in sns_message + + assert message == sns_message["Message"] + + retries = 5 + sleep = 1 + sleep_before = 0 + if is_aws_cloud(): + retries = 30 + sleep = 10 + sleep_before = 10 + + retry(validate_content, retries=retries, sleep_before=sleep_before, sleep=sleep) + + +class TestSNSMultiAccounts: + @pytest.fixture + def sns_primary_client(self, aws_client): + return aws_client.sns + + @pytest.fixture + def sns_secondary_client(self, secondary_aws_client): + return secondary_aws_client.sns + + @pytest.fixture + def sqs_primary_client(self, aws_client): + return aws_client.sqs + + @pytest.fixture + def sqs_secondary_client(self, secondary_aws_client): + return secondary_aws_client.sqs + + @markers.aws.only_localstack + def test_cross_account_access(self, sns_primary_client, sns_secondary_client, sns_create_topic): + # Cross-account access is supported for below operations. + # This list is taken from ActionName param of the AddPermissions operation + # + # - GetTopicAttributes + # - SetTopicAttributes + # - AddPermission + # - RemovePermission + # - Publish + # - Subscribe + # - ListSubscriptionsByTopic + # - DeleteTopic + + topic_name = f"topic-{short_uid()}" + # sns_create_topic uses the primary client by default + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + + assert sns_secondary_client.set_topic_attributes( + TopicArn=topic_arn, AttributeName="DisplayName", AttributeValue="xenon" + ) + + response = sns_secondary_client.get_topic_attributes(TopicArn=topic_arn) + assert response["Attributes"]["DisplayName"] == "xenon" + + assert sns_secondary_client.add_permission( + TopicArn=topic_arn, + Label="foo", + AWSAccountId=["666666666666"], + ActionName=["AddPermission"], + ) + assert sns_secondary_client.remove_permission(TopicArn=topic_arn, Label="foo") + + assert sns_secondary_client.publish(TopicArn=topic_arn, Message="hello world") + + subscription_arn = sns_secondary_client.subscribe( + TopicArn=topic_arn, Protocol="email", Endpoint="devil@hell.com" + )["SubscriptionArn"] + + response = sns_secondary_client.list_subscriptions_by_topic(TopicArn=topic_arn) + subscriptions = [s["SubscriptionArn"] for s in response["Subscriptions"]] + assert subscription_arn in subscriptions + + response = sns_primary_client.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + response = sns_primary_client.get_subscription_attributes(SubscriptionArn=subscription_arn) + assert response["Attributes"]["RawMessageDelivery"] == "true" + + assert sns_secondary_client.delete_topic(TopicArn=topic_arn) + + @markers.aws.only_localstack + def test_cross_account_publish_to_sqs( + self, + sns_create_topic, + secondary_account_id, + region_name, + sns_primary_client, + sns_secondary_client, + sqs_primary_client, + sqs_secondary_client, + sqs_get_queue_arn, + cleanups, + ): + """ + This test validates that we can publish to SQS queues that are not in the default account, and that another + account can publish to the topic as well + + Note: we are not setting Queue policies here as it's only in localstack and IAM is not enforced, for the sake + of simplicity + """ + + topic_name = "sample_topic" + topic_1 = sns_create_topic(Name=topic_name) + topic_1_arn = topic_1["TopicArn"] + + # create a queue with the primary AccountId + queue_name = "sample_queue" + queue_1 = sqs_primary_client.create_queue(QueueName=queue_name) + queue_1_url = queue_1["QueueUrl"] + cleanups.append(lambda: sqs_primary_client.delete_queue(QueueUrl=queue_1_url)) + queue_1_arn = sqs_get_queue_arn(queue_1_url) + + # create a queue with the secondary AccountId + queue_2 = sqs_secondary_client.create_queue(QueueName=queue_name) + queue_2_url = queue_2["QueueUrl"] + cleanups.append(lambda: sqs_secondary_client.delete_queue(QueueUrl=queue_2_url)) + # test that we get the right queue URL at the same time, even if we use the primary client + queue_2_arn = sqs_queue_arn( + queue_2_url, + secondary_account_id, + region_name, + ) + + # create a second queue with the secondary AccountId + queue_name_2 = "sample_queue_two" + queue_3 = sqs_secondary_client.create_queue(QueueName=queue_name_2) + queue_3_url = queue_3["QueueUrl"] + cleanups.append(lambda: sqs_secondary_client.delete_queue(QueueUrl=queue_3_url)) + # test that we get the right queue URL at the same time, even if we use the primary client + queue_3_arn = sqs_queue_arn( + queue_3_url, + secondary_account_id, + region_name, + ) + + # test that we can subscribe with the primary client to a queue from the same account + sns_primary_client.subscribe( + TopicArn=topic_1_arn, + Protocol="sqs", + Endpoint=queue_1_arn, + ) + + # test that we can subscribe with the primary client to a queue from the secondary account + sns_primary_client.subscribe( + TopicArn=topic_1_arn, + Protocol="sqs", + Endpoint=queue_2_arn, + ) + + # test that we can subscribe with the secondary client (not owning the topic) to a queue of the secondary client + sns_secondary_client.subscribe( + TopicArn=topic_1_arn, + Protocol="sqs", + Endpoint=queue_3_arn, + ) + + # now, we have 3 subscriptions in topic_1, one to the queue_1 located in the same account, and 2 to queue_2 and + # queue_3 located in the secondary account + subscriptions = sns_primary_client.list_subscriptions_by_topic(TopicArn=topic_1_arn) + assert len(subscriptions["Subscriptions"]) == 3 + + sns_primary_client.publish(TopicArn=topic_1_arn, Message="TestMessageOwner") + + def get_messages_from_queues(message_content: str): + for client, queue_url in ( + (sqs_primary_client, queue_1_url), + (sqs_secondary_client, queue_2_url), + (sqs_secondary_client, queue_3_url), + ): + response = client.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + WaitTimeSeconds=5, + ) + messages = response["Messages"] + assert len(messages) == 1 + assert topic_1_arn in messages[0]["Body"] + assert message_content in messages[0]["Body"] + client.delete_message( + QueueUrl=queue_url, ReceiptHandle=messages[0]["ReceiptHandle"] + ) + + get_messages_from_queues("TestMessageOwner") + + # assert that we can also publish to the topic 1 from the secondary account + sns_secondary_client.publish(TopicArn=topic_1_arn, Message="TestMessageSecondary") + + get_messages_from_queues("TestMessageSecondary") + + +class TestSNSMultiRegions: + @pytest.fixture + def sns_region1_client(self, aws_client): + return aws_client.sns + + @pytest.fixture + def sns_region2_client(self, aws_client_factory, secondary_region_name): + return aws_client_factory(region_name=secondary_region_name).sns + + @pytest.fixture + def sqs_region2_client(self, aws_client_factory, secondary_region_name): + return aws_client_factory(region_name=secondary_region_name).sqs + + @markers.aws.validated + def test_cross_region_access(self, sns_region1_client, sns_region2_client, snapshot, cleanups): + # We do not have a list of supported Cross-region access for operations. + # This test is validating that Cross-account does not mean Cross-region most of the time + + topic_name = f"topic-{short_uid()}" + topic_arn = sns_region1_client.create_topic(Name=topic_name)["TopicArn"] + cleanups.append(lambda: sns_region1_client.delete_topic(TopicArn=topic_arn)) + + with pytest.raises(ClientError) as e: + sns_region2_client.set_topic_attributes( + TopicArn=topic_arn, AttributeName="DisplayName", AttributeValue="xenon" + ) + snapshot.match("set-topic-attrs", e.value.response) + + with pytest.raises(ClientError) as e: + sns_region2_client.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("get-topic-attrs", e.value.response) + + with pytest.raises(ClientError) as e: + sns_region2_client.publish(TopicArn=topic_arn, Message="hello world") + snapshot.match("cross-region-publish-forbidden", e.value.response) + + with pytest.raises(ClientError) as e: + sns_region2_client.subscribe( + TopicArn=topic_arn, Protocol="email", Endpoint="devil@hell.com" + ) + snapshot.match("cross-region-subscribe", e.value.response) + + with pytest.raises(ClientError) as e: + sns_region2_client.list_subscriptions_by_topic(TopicArn=topic_arn) + snapshot.match("list-subs", e.value.response) + + with pytest.raises(ClientError) as e: + sns_region2_client.delete_topic(TopicArn=topic_arn) + snapshot.match("delete-topic", e.value.response) + + @markers.aws.validated + def test_cross_region_delivery_sqs( + self, + sns_region1_client, + sns_region2_client, + sqs_region2_client, + sns_create_topic, + sqs_create_queue, + sns_allow_topic_sqs_queue, + cleanups, + snapshot, + ): + topic_arn = sns_create_topic()["TopicArn"] + + queue_url = sqs_create_queue() + response = sqs_region2_client.create_queue(QueueName=f"queue-{short_uid()}") + queue_url = response["QueueUrl"] + cleanups.append(lambda: sqs_region2_client.delete_queue(QueueUrl=queue_url)) + + queue_arn = sqs_region2_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + # allow topic to write to sqs queue + sqs_region2_client.set_queue_attributes( + QueueUrl=queue_url, + Attributes={ + "Policy": json.dumps( + { + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "sns.amazonaws.com"}, + "Action": "sqs:SendMessage", + "Resource": queue_arn, + "Condition": {"ArnEquals": {"aws:SourceArn": topic_arn}}, + } + ] + } + ) + }, + ) + + # connect sns topic to sqs + with pytest.raises(ClientError) as e: + sns_region2_client.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn) + snapshot.match("subscribe-cross-region", e.value.response) + + subscription = sns_region1_client.subscribe( + TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn + ) + snapshot.match("subscribe-same-region", subscription) + + message = "This is a test message" + # we already test that publishing from another region is forbidden with `test_topic_publish_another_region` + sns_region1_client.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": "99.12"}}, + ) + + # assert that message is received + response = sqs_region2_client.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + MessageAttributeNames=["All"], + WaitTimeSeconds=4, + ) + snapshot.match("messages", response) + + +class TestSNSPublishDelivery: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..Attributes.DeliveryPolicy", + "$..Attributes.EffectiveDeliveryPolicy", + "$..Attributes.Policy.Statement..Action", # SNS:Receive is added by moto but not returned in AWS + ] + ) + def test_delivery_lambda( + self, + sns_create_topic, + sns_subscription, + lambda_su_role, + create_lambda_function, + create_role, + create_policy, + snapshot, + aws_client, + ): + snapshot.add_transformers_list( + [ + snapshot.transform.key_value( + "dwellTimeMs", reference_replacement=False, value_replacement="" + ), + snapshot.transform.key_value("nextBackwardToken"), + snapshot.transform.key_value("nextForwardToken"), + ] + ) + function_name = f"lambda-function-{short_uid()}" + permission_id = f"test-statement-{short_uid()}" + subject = "[Subject] Test subject" + message_fail = "Should not be received" + message_success = "Should be received" + topic_name = f"test-topic-{short_uid()}" + topic_arn = sns_create_topic(Name=topic_name)["TopicArn"] + parsed_arn = parse_arn(topic_arn) + account_id = parsed_arn["account"] + region = parsed_arn["region"] + role_name = f"SNSSuccessFeedback-{short_uid()}" + policy_name = f"SNSSuccessFeedback-policy-{short_uid()}" + + # enable Success Feedback from SNS to be sent to CloudWatch + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "sns.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + cloudwatch_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:PutMetricFilter", + "logs:PutRetentionPolicy", + ], + "Resource": ["*"], + } + ], + } + + role_response = create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy) + ) + role_arn = role_response["Role"]["Arn"] + policy_arn = create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(cloudwatch_policy) + )["Policy"]["Arn"] + aws_client.iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + if is_aws_cloud(): + # wait for the policy to be properly attached + time.sleep(20) + + topic_attributes = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("get-topic-attrs", topic_attributes) + + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_PYTHON_ECHO, + runtime=Runtime.python3_12, + role=lambda_su_role, + ) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + aws_client.lambda_.add_permission( + FunctionName=function_name, + StatementId=permission_id, + Action="lambda:InvokeFunction", + Principal="sns.amazonaws.com", + SourceArn=topic_arn, + ) + + subscription = sns_subscription( + TopicArn=topic_arn, + Protocol="lambda", + Endpoint=lambda_arn, + ) + + def check_subscription(): + subscription_arn = subscription["SubscriptionArn"] + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + assert subscription_attrs["Attributes"]["PendingConfirmation"] == "false" + + retry(check_subscription, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + publish_no_logs = aws_client.sns.publish( + TopicArn=topic_arn, Subject=subject, Message=message_fail + ) + snapshot.match("publish-no-logs", publish_no_logs) + + # Then enable the SNS Delivery Logs for Lambda on the topic + aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="LambdaSuccessFeedbackRoleArn", + AttributeValue=role_arn, + ) + + aws_client.sns.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="LambdaSuccessFeedbackSampleRate", + AttributeValue="100", + ) + + topic_attributes = aws_client.sns.get_topic_attributes(TopicArn=topic_arn) + snapshot.match("get-topic-attrs-with-success-feedback", topic_attributes) + + publish_logs = aws_client.sns.publish( + TopicArn=topic_arn, Subject=subject, Message=message_success + ) + # we snapshot the publish call to match the messageId to the events + snapshot.match("publish-logs", publish_logs) + + # TODO: Wait until Lambda function actually executes and not only for SNS logs + log_group_name = f"sns/{region}/{account_id}/{topic_name}" + + def get_log_events(): + log_streams = aws_client.logs.describe_log_streams(logGroupName=log_group_name)[ + "logStreams" + ] + assert len(log_streams) == 1 + log_events = aws_client.logs.get_log_events( + logGroupName=log_group_name, + logStreamName=log_streams[0]["logStreamName"], + ) + assert len(log_events["events"]) == 1 + # the default retention is 30 days, so delete the logGroup to clean up AWS + with contextlib.suppress(ClientError): + aws_client.logs.delete_log_group(logGroupName=log_group_name) + return log_events + + sleep_time = 5 if is_aws_cloud() else 0.3 + events = retry(get_log_events, retries=10, sleep=sleep_time) + + # we need to decode the providerResponse to be able to properly match on the response + # test would raise an error anyway if it's not a JSON string + msg = json.loads(events["events"][0]["message"]) + events["events"][0]["message"] = msg + events["events"][0]["message"]["delivery"]["providerResponse"] = json.loads( + msg["delivery"]["providerResponse"] + ) + + snapshot.match("delivery-events", events) + + +class TestSNSCertEndpoint: + @markers.aws.only_localstack + @pytest.mark.parametrize("cert_host", ["", "sns.us-east-1.amazonaws.com"]) + def test_cert_endpoint_host( + self, + aws_client, + sns_create_topic, + sqs_create_queue, + sns_create_sqs_subscription, + monkeypatch, + cert_host, + ): + """ + Some SDK will validate the Cert URL matches a certain regex pattern. We validate the user can set the value + to arbitrary host, but those will obviously not resolve / return a valid certificate. + """ + monkeypatch.setattr(config, "SNS_CERT_URL_HOST", cert_host) + topic_arn = sns_create_topic( + Attributes={ + "DisplayName": "TestTopicSignature", + "SignatureVersion": "1", + }, + )["TopicArn"] + + queue_url = sqs_create_queue() + sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + + aws_client.sns.publish( + TopicArn=topic_arn, + Message="test cert host", + ) + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=10, + ) + message = json.loads(response["Messages"][0]["Body"]) + + cert_url = message["SigningCertURL"] + if not cert_host: + assert external_service_url() in cert_url + else: + assert cert_host in cert_url + assert external_service_url() not in cert_url + + +@pytest.mark.usefixtures("openapi_validate") +class TestSNSRetrospectionEndpoints: + @markers.aws.only_localstack + def test_publish_to_platform_endpoint_can_retrospect( + self, + sns_create_topic, + sns_subscription, + sns_create_platform_application, + aws_client, + account_id, + region_name, + secondary_region_name, + ): + sns_backend = SnsProvider.get_store(account_id, region_name) + # clean up the saved messages + sns_backend_endpoint_arns = list(sns_backend.platform_endpoint_messages.keys()) + for saved_endpoint_arn in sns_backend_endpoint_arns: + sns_backend.platform_endpoint_messages.pop(saved_endpoint_arn, None) + + topic_arn = sns_create_topic()["TopicArn"] + application_platform_name = f"app-platform-{short_uid()}" + + app_arn = sns_create_platform_application( + Name=application_platform_name, Platform="APNS", Attributes={} + )["PlatformApplicationArn"] + + endpoint_arn = aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=app_arn, Token=short_uid() + )["EndpointArn"] + + endpoint_arn_2 = aws_client.sns.create_platform_endpoint( + PlatformApplicationArn=app_arn, Token=short_uid() + )["EndpointArn"] + + sns_subscription( + TopicArn=topic_arn, + Protocol="application", + Endpoint=endpoint_arn, + ) + + # example message from + # https://docs.aws.amazon.com/sns/latest/dg/sns-send-custom-platform-specific-payloads-mobile-devices.html + message = json.dumps({"APNS": json.dumps({"aps": {"content-available": 1}})}) + message_for_topic = { + "default": "This is the default message which must be present when publishing a message to a topic.", + "APNS": json.dumps({"aps": {"content-available": 1}}), + } + message_for_topic_string = json.dumps(message_for_topic) + message_attributes = { + "AWS.SNS.MOBILE.APNS.TOPIC": { + "DataType": "String", + "StringValue": "com.amazon.mobile.messaging.myapp", + }, + "AWS.SNS.MOBILE.APNS.PUSH_TYPE": { + "DataType": "String", + "StringValue": "background", + }, + "AWS.SNS.MOBILE.APNS.PRIORITY": { + "DataType": "String", + "StringValue": "5", + }, + } + # publish to a topic which has a platform subscribed to it + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message_for_topic_string, + MessageAttributes=message_attributes, + MessageStructure="json", + ) + # publish directly to the platform endpoint + aws_client.sns.publish( + TargetArn=endpoint_arn_2, + Message=message, + MessageAttributes=message_attributes, + MessageStructure="json", + ) + + # assert that message has been received + def check_message(): + assert len(sns_backend.platform_endpoint_messages[endpoint_arn]) > 0 + + retry(check_message, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + msgs_url = config.internal_service_url() + PLATFORM_ENDPOINT_MSGS_ENDPOINT + api_contents = requests.get( + msgs_url, params={"region": region_name, "accountId": account_id} + ).json() + api_platform_endpoints_msgs = api_contents["platform_endpoint_messages"] + + assert len(api_platform_endpoints_msgs) == 2 + assert len(api_platform_endpoints_msgs[endpoint_arn]) == 1 + assert len(api_platform_endpoints_msgs[endpoint_arn_2]) == 1 + assert api_contents["region"] == region_name + + assert api_platform_endpoints_msgs[endpoint_arn][0]["Message"] == json.dumps( + message_for_topic["APNS"] + ) + assert ( + api_platform_endpoints_msgs[endpoint_arn][0]["MessageAttributes"] == message_attributes + ) + + # Ensure you can select the region + msg_with_region = requests.get( + msgs_url, + params={"region": secondary_region_name, "accountId": account_id}, + ).json() + assert len(msg_with_region["platform_endpoint_messages"]) == 0 + assert msg_with_region["region"] == secondary_region_name + + # Ensure default region is us-east-1 + msg_with_region = requests.get(msgs_url).json() + assert msg_with_region["region"] == AWS_REGION_US_EAST_1 + + # Ensure messages can be filtered by EndpointArn + api_contents_with_endpoint = requests.get( + msgs_url, + params={ + "endpointArn": endpoint_arn, + "region": region_name, + "accountId": account_id, + }, + ).json() + msgs_with_endpoint = api_contents_with_endpoint["platform_endpoint_messages"] + assert len(msgs_with_endpoint) == 1 + assert len(msgs_with_endpoint[endpoint_arn]) == 1 + assert api_contents_with_endpoint["region"] == region_name + + # Ensure you can reset the saved messages by EndpointArn + delete_res = requests.delete( + msgs_url, + params={ + "endpointArn": endpoint_arn, + "region": region_name, + "accountId": account_id, + }, + ) + assert delete_res.status_code == 204 + api_contents_with_endpoint = requests.get( + msgs_url, + params={ + "endpointArn": endpoint_arn, + "region": region_name, + "accountId": account_id, + }, + ).json() + msgs_with_endpoint = api_contents_with_endpoint["platform_endpoint_messages"] + assert len(msgs_with_endpoint[endpoint_arn]) == 0 + + # Ensure you can reset the saved messages by region + delete_res = requests.delete( + msgs_url, params={"region": region_name, "accountId": account_id} + ) + assert delete_res.status_code == 204 + msg_with_region = requests.get( + msgs_url, params={"region": region_name, "accountId": account_id} + ).json() + assert not msg_with_region["platform_endpoint_messages"] + + @markers.aws.only_localstack + def test_publish_sms_can_retrospect( + self, + sns_create_topic, + sns_subscription, + aws_client, + account_id, + region_name, + secondary_region_name, + ): + sns_store = SnsProvider.get_store(account_id, region_name) + + list_of_contacts = [ + f"+{random.randint(100000000, 9999999999)}", + f"+{random.randint(100000000, 9999999999)}", + f"+{random.randint(100000000, 9999999999)}", + ] + phone_number_1 = list_of_contacts[0] + message = "Good news everyone!" + topic_arn = sns_create_topic()["TopicArn"] + for number in list_of_contacts: + sns_subscription(TopicArn=topic_arn, Protocol="sms", Endpoint=number) + + # clean up the saved messages + sns_store.sms_messages.clear() + + # publish to a topic which has a PhoneNumbers subscribed to it + aws_client.sns.publish(Message=message, TopicArn=topic_arn) + + # publish directly to the PhoneNumber + aws_client.sns.publish( + PhoneNumber=phone_number_1, + Message=message, + ) + + # assert that message has been received + def check_message(): + assert len(sns_store.sms_messages) == 4 + + retry(check_message, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + msgs_url = config.internal_service_url() + SMS_MSGS_ENDPOINT + api_contents = requests.get( + msgs_url, params={"region": region_name, "accountId": account_id} + ).json() + api_sms_msgs = api_contents["sms_messages"] + + assert len(api_sms_msgs) == 3 + assert len(api_sms_msgs[phone_number_1]) == 2 + assert len(api_sms_msgs[list_of_contacts[1]]) == 1 + assert len(api_sms_msgs[list_of_contacts[2]]) == 1 + + assert api_contents["region"] == region_name + + assert api_sms_msgs[phone_number_1][0]["Message"] == "Good news everyone!" + + # Ensure you can select the region + msg_with_region = requests.get(msgs_url, params={"region": secondary_region_name}).json() + assert len(msg_with_region["sms_messages"]) == 0 + assert msg_with_region["region"] == secondary_region_name + + # Ensure default region is us-east-1 + msg_with_region = requests.get(msgs_url).json() + assert msg_with_region["region"] == AWS_REGION_US_EAST_1 + + # Ensure messages can be filtered by EndpointArn + api_contents_with_number = requests.get( + msgs_url, + params={ + "phoneNumber": phone_number_1, + "accountId": account_id, + "region": region_name, + }, + ).json() + msgs_with_number = api_contents_with_number["sms_messages"] + assert len(msgs_with_number) == 1 + assert len(msgs_with_number[phone_number_1]) == 2 + assert api_contents_with_number["region"] == region_name + + # Ensure you can reset the saved messages by EndpointArn + delete_res = requests.delete( + msgs_url, + params={ + "phoneNumber": phone_number_1, + "accountId": account_id, + "region": region_name, + }, + ) + assert delete_res.status_code == 204 + api_contents_with_number = requests.get( + msgs_url, params={"phoneNumber": phone_number_1} + ).json() + msgs_with_number = api_contents_with_number["sms_messages"] + assert len(msgs_with_number[phone_number_1]) == 0 + + # Ensure you can reset the saved messages by region + delete_res = requests.delete( + msgs_url, params={"region": region_name, "accountId": account_id} + ) + assert delete_res.status_code == 204 + msg_with_region = requests.get(msgs_url, params={"region": region_name}).json() + assert not msg_with_region["sms_messages"] + + @markers.aws.only_localstack + def test_subscription_tokens_can_retrospect( + self, + sns_create_topic, + sns_subscription, + sns_create_http_endpoint, + aws_client, + account_id, + region_name, + ): + sns_store = SnsProvider.get_store(account_id, region_name) + # clean up the saved tokens + sns_store.subscription_tokens.clear() + + message = "Good news everyone!" + # Necessitate manual set up to allow external access to endpoint, only in local testing + topic_arn, subscription_arn, endpoint_url, server = sns_create_http_endpoint() + assert poll_condition( + lambda: len(server.log) >= 1, + timeout=5, + ) + sub_request, _ = server.log[0] + payload = sub_request.get_json(force=True) + assert payload["Type"] == "SubscriptionConfirmation" + token = payload["Token"] + server.clear() + + # we won't confirm the subscription, to simulate an external provider that wouldn't be able to access LocalStack + # try to access the internal to confirm the Token is there + tokens_base_url = config.internal_service_url() + SUBSCRIPTION_TOKENS_ENDPOINT + api_contents = requests.get(f"{tokens_base_url}/{subscription_arn}").json() + assert api_contents["subscription_token"] == token + assert api_contents["subscription_arn"] == subscription_arn + + # try to send a message to an unconfirmed subscription, assert that the message isn't received + aws_client.sns.publish(Message=message, TopicArn=topic_arn) + + assert poll_condition( + lambda: len(server.log) == 0, + timeout=1, + ) + + aws_client.sns.confirm_subscription(TopicArn=topic_arn, Token=token) + aws_client.sns.publish(Message=message, TopicArn=topic_arn) + assert poll_condition( + lambda: len(server.log) == 1, + timeout=2, + ) + + wrong_sub_arn = subscription_arn.replace( + region_name, + "il-central-1" if region_name != "il-central-1" else "me-south-1", + ) + wrong_region_req = requests.get(f"{tokens_base_url}/{wrong_sub_arn}") + assert wrong_region_req.status_code == 404 + assert wrong_region_req.json() == { + "error": "The provided SubscriptionARN is not found", + "subscription_arn": wrong_sub_arn, + } + + # Ensure proper error is raised with wrong ARN + incorrect_arn_req = requests.get(f"{tokens_base_url}/randomarnhere") + assert incorrect_arn_req.status_code == 400 + assert incorrect_arn_req.json() == { + "error": "The provided SubscriptionARN is invalid", + "subscription_arn": "randomarnhere", + } diff --git a/tests/aws/services/sns/test_sns.snapshot.json b/tests/aws/services/sns/test_sns.snapshot.json new file mode 100644 index 0000000000000..5c2d7f8218b35 --- /dev/null +++ b/tests/aws/services/sns/test_sns.snapshot.json @@ -0,0 +1,5227 @@ +{ + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_with_attributes": { + "recorded-date": "06-10-2023, 20:11:02", + "recorded-content": { + "get-topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DisplayName": "TestTopic", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SignatureVersion": "2", + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-attrs-nonexistent-topic": { + "Error": { + "Code": "NotFound", + "Message": "Topic does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "get-attrs-malformed-topic": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not 1", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_tags": { + "recorded-date": "24-08-2023, 22:30:44", + "recorded-content": { + "duplicate-key-error": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Duplicated keys are not allowed.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-created-tags": { + "Tags": [ + { + "Key": "k1", + "Value": "v1" + }, + { + "Key": "k2", + "Value": "v2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-after-delete-tags": { + "Tags": [ + { + "Key": "k2", + "Value": "v2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-after-update-tags": { + "Tags": [ + { + "Key": "k2", + "Value": "v2b" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_test_arn": { + "recorded-date": "24-08-2023, 22:30:45", + "recorded-content": { + "create-topic": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-topic-attrs": { + "Attributes": { + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-topic": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "topic-not-exists": { + "Error": { + "Code": "NotFound", + "Message": "Topic does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_duplicate_topic_with_more_tags": { + "recorded-date": "24-08-2023, 22:30:46", + "recorded-content": { + "exception-duplicate": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Tags Reason: Topic already exists with different tags", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_duplicate_topic_check_idempotency": { + "recorded-date": "24-08-2023, 22:30:47", + "recorded-content": { + "response-created": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-same-arn-0": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-same-arn-1": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-same-arn-2": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_after_delete_with_new_tags": { + "recorded-date": "24-08-2023, 22:30:48", + "recorded-content": { + "topic-0": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "topic-1": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_by_path_parameters": { + "recorded-date": "24-08-2023, 22:31:40", + "recorded-content": { + "post-request": { + "PublishResponse": { + "PublishResult": { + "MessageId": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + }, + "messages": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test message direct post request", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_wrong_arn_format": { + "recorded-date": "11-03-2024, 10:36:34", + "recorded-content": { + "invalid-topic-arn": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not 1", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-topic-arn-1": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not 2", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "empty-topic": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not 1", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_message_by_target_arn": { + "recorded-date": "24-08-2023, 22:31:42", + "recorded-content": { + "receive-topic-arn": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test-msg-1", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive-target-arn": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test-msg-2", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_message_before_subscribe_topic": { + "recorded-date": "24-08-2023, 22:31:44", + "recorded-content": { + "publish-before-subscribing": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-after-subscribing": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive-messages": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "test-subject-after-sub", + "Message": "test_message_after", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_unknown_topic_publish": { + "recorded-date": "24-08-2023, 22:31:45", + "recorded-content": { + "success": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": { + "Error": { + "Code": "NotFound", + "Message": "Topic does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_non_existent_target": { + "recorded-date": "24-08-2023, 22:31:46", + "recorded-content": { + "non-existent-endpoint": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TargetArn Reason: No endpoint found for the target arn specified", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_with_empty_subject": { + "recorded-date": "24-08-2023, 22:31:46", + "recorded-content": { + "response-without-subject": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-with-empty-subject": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Subject", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_empty_sns_message": { + "recorded-date": "24-08-2023, 22:31:48", + "recorded-content": { + "empty-msg-error": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Empty message", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "queue-attrs": { + "Attributes": { + "ApproximateNumberOfMessages": "0" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_too_long_message": { + "recorded-date": "04-09-2024, 00:38:07", + "recorded-content": { + "error": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Message too long", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-with-attrs": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Message too long", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_message_structure_json_exc": { + "recorded-date": "24-08-2023, 22:31:50", + "recorded-content": { + "missing-default-key": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Message Structure - No default entry in JSON message body", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-json": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Message Structure - JSON message body failed to parse", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "duplicate-json-keys": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "key-is-not-string": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Message Structure - No default entry in JSON message body", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_with_invalid_protocol": { + "recorded-date": "24-08-2023, 23:27:50", + "recorded-content": { + "exception": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Amazon SNS does not support this protocol string: test-protocol", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_from_non_existing_subscription": { + "recorded-date": "24-08-2023, 23:27:52", + "recorded-content": { + "empty-unsubscribe": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_create_subscriptions_with_attributes": { + "recorded-date": "29-03-2024, 19:44:43", + "recorded-content": { + "subscribe-wrong-attr": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: RawMessageDelivery: Invalid value [wrongvalue]. Must be true or false.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "subscribe": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-attrs": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-attrs-nonexistent-sub": { + "Error": { + "Code": "NotFound", + "Message": "Subscription does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_not_found_error_on_set_subscription_attributes": { + "recorded-date": "24-08-2023, 23:27:55", + "recorded-content": { + "sub": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscriptions-for-topic-before-unsub": { + "Subscriptions": [ + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-not-found": { + "Error": { + "Code": "NotFound", + "Message": "Subscription does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + }, + "subscriptions-for-topic-after-unsub": { + "Subscriptions": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_validate_set_sub_attributes": { + "recorded-date": "29-03-2024, 19:30:24", + "recorded-content": { + "fake-attribute": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: AttributeName", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "raw-message-wrong-value": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: RawMessageDelivery: Invalid value [test-ValUe]. Must be true or false.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "raw-message-empty-value": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: RawMessageDelivery: Invalid value []. Must be true or false.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "fake-arn-redrive-policy": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: RedrivePolicy: deadLetterTargetArn is an invalid arn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-json-redrive-policy": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: RedrivePolicy: failed to parse JSON. Unexpected character ('i' (code 105)): was expecting double-quote to start field name\n at [Source: java.io.StringReader@469cc9aa; line: 1, column: 3]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-json-filter-policy": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: FilterPolicy: failed to parse JSON. Unexpected character ('i' (code 105)): was expecting double-quote to start field name\n at [Source: (String)\"{invalidjson}\"; line: 1, column: 3]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_sns_confirm_subscription_wrong_token": { + "recorded-date": "24-08-2023, 23:27:58", + "recorded-content": { + "topic-not-exists": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Token", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-token": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid token", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "token-not-exists": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Token", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_python_lambda_subscribe_sns_topic": { + "recorded-date": "24-08-2023, 23:28:59", + "recorded-content": { + "notification": { + "Message": "Hello world.", + "MessageAttributes": {}, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertUrl": "/SimpleNotificationService-", + "Subject": "[Subject] Test subject", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeUrl": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_sns_topic_as_lambda_dead_letter_queue": { + "recorded-date": "24-08-2023, 23:32:57", + "recorded-content": { + "lambda-response-dlq-config": { + "TargetArn": "arn::sns::111111111111:" + }, + "messages": { + "Messages": [ + { + "Body": { + "Message": { + "Records": [ + { + "EventSource": "aws:sns", + "EventSubscriptionArn": "arn::sns::111111111111::", + "EventVersion": "1.0", + "Sns": { + "Message": { + "raise_error": 1 + }, + "MessageAttributes": {}, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertUrl": "/SimpleNotificationService-", + "Subject": null, + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeUrl": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + } + ] + }, + "MessageAttributes": { + "ErrorCode": { + "Type": "Number", + "Value": "200" + }, + "ErrorMessage": { + "Type": "String", + "Value": "Test exception (this is intentional)" + }, + "RequestID": { + "Type": "String", + "Value": "" + } + }, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_redrive_policy_lambda_subscription": { + "recorded-date": "24-08-2023, 23:33:01", + "recorded-content": { + "subscription-attributes": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::lambda::111111111111:function:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "lambda", + "RawMessageDelivery": "false", + "RedrivePolicy": { + "deadLetterTargetArn": "arn::sqs::111111111111:" + }, + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test_redrive_policy", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "1" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscribe_sqs_queue": { + "recorded-date": "24-08-2023, 23:36:00", + "recorded-content": { + "subscription-attributes": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "attr1": [ + { + "numeric": [ + ">", + 0, + "<=", + 100 + ] + } + ] + }, + "FilterPolicyScope": "MessageAttributes", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This is a test message", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "99.12" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_unicode_chars": { + "recorded-date": "24-08-2023, 23:36:02", + "recorded-content": { + "received-message": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "\u00f6\u00a7a1\"_!?,. \u00a3$-", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_attribute_raw_subscribe": { + "recorded-date": "24-08-2023, 23:36:04", + "recorded-content": { + "subscription-attributes": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-response": { + "Messages": [ + { + "Body": "This is a test message", + "MD5OfBody": "", + "MD5OfMessageAttributes": "", + "MessageAttributes": { + "store": { + "BinaryValue": "b'\\x02\\x03\\x04'", + "DataType": "Binary" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_sqs_topic_subscription_confirmation": { + "recorded-date": "24-08-2023, 23:36:06", + "recorded-content": { + "subscription-attrs": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_from_sns": { + "recorded-date": "24-08-2023, 23:36:09", + "recorded-content": { + "sub-attrs-raw-true": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "message-raw-true": { + "Messages": [ + { + "Body": "Test msg", + "MD5OfBody": "", + "MD5OfMessageAttributes": "d302007e9aa062660afc85fd0a482472", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "99.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs-raw-false": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "message-raw-false": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test msg", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "100.12" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_messages_from_sns_to_sqs": { + "recorded-date": "24-08-2023, 23:36:12", + "recorded-content": { + "sub-attrs-raw-true": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-batch": { + "Failed": [], + "Successful": [ + { + "Id": "1", + "MessageId": "" + }, + { + "Id": "2", + "MessageId": "" + }, + { + "Id": "3", + "MessageId": "" + }, + { + "Id": "4", + "MessageId": "" + }, + { + "Id": "5", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "Test Message with one attribute", + "MD5OfBody": "", + "MD5OfMessageAttributes": "1f07082409022a373fa2a2601f82b3cb", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "19.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "Test Message with two attributes", + "MD5OfBody": "", + "MD5OfMessageAttributes": "9b720b3af309c1c4b0d395c53b08c502", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "99.12" + }, + "attr2": { + "DataType": "Number", + "StringValue": "109.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "Test Message without attribute", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "Test Message without subject", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "test sqs", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_messages_without_topic": { + "recorded-date": "24-08-2023, 23:36:13", + "recorded-content": { + "publish-batch-no-topic": { + "Error": { + "Code": "NotFound", + "Message": "Topic does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_exceptions": { + "recorded-date": "24-08-2023, 23:36:14", + "recorded-content": { + "no-group-id": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: The MessageGroupId parameter is required for FIFO topics", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "too-many-msg": { + "Error": { + "Code": "TooManyEntriesInBatchRequest", + "Message": "The batch request contains more entries than permissible.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "same-msg-id": { + "Error": { + "Code": "BatchEntryIdsNotDistinct", + "Message": "Two or more batch entries in the request have the same Id.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "no-dedup-id": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "no-default-key-json": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Message Structure - No default entry in JSON message body", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscribe_to_sqs_with_queue_url": { + "recorded-date": "24-08-2023, 23:36:15", + "recorded-content": { + "sub-queue-url": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: SQS endpoint ARN", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_from_sns_with_xray_propagation": { + "recorded-date": "24-08-2023, 23:36:16", + "recorded-content": { + "xray-msg": { + "Attributes": { + "AWSTraceHeader": "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1", + "SentTimestamp": "timestamp" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "X-Ray propagation test msg", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_redrive_policy_sqs_queue_subscription[True]": { + "recorded-date": "24-08-2023, 23:36:19", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "test_dlq_after_sqs_endpoint_deleted", + "MD5OfBody": "", + "MD5OfMessageAttributes": "", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "111" + }, + "attr2": { + "BinaryValue": "b'\\x02\\x03\\x04'", + "DataType": "Binary" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_redrive_policy_sqs_queue_subscription[False]": { + "recorded-date": "24-08-2023, 23:36:22", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test_dlq_after_sqs_endpoint_deleted", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr2": { + "Type": "Binary", + "Value": "AgME" + }, + "attr1": { + "Type": "Number", + "Value": "111" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_attributes_not_missing": { + "recorded-date": "24-08-2023, 23:36:25", + "recorded-content": { + "publish-msg-raw": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "raw-delivery-msg-attrs": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "text", + "MD5OfBody": "", + "MD5OfMessageAttributes": "", + "MessageAttributes": { + "an-attribute-key": { + "DataType": "String", + "StringValue": "an-attribute-value" + }, + "binary-attribute": { + "BinaryValue": "b'\\x02\\x03\\x04'", + "DataType": "Binary" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-msg-json": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "json-delivery-msg-attrs": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "text", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "an-attribute-key": { + "Type": "String", + "Value": "an-attribute-value" + }, + "binary-attribute": { + "Type": "Binary", + "Value": "AgME" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscription_after_failure_to_deliver": { + "recorded-date": "24-08-2023, 23:36:32", + "recorded-content": { + "subscriptions-attrs": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-before-delete": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test_dlq_before_sqs_endpoint_deleted", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscriptions": { + "Subscriptions": [ + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscriptions-attrs-with-redrive": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "RedrivePolicy": { + "deadLetterTargetArn": "arn::sqs::111111111111:" + }, + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "message-0-after-delete": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test_dlq_after_sqs_endpoint_deleted_0", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "message-1-after-delete": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test_dlq_after_sqs_endpoint_deleted_1", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_empty_or_wrong_message_attributes": { + "recorded-date": "15-11-2024, 18:55:20", + "recorded-content": { + "missing_string_attr": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' must contain non-empty message attribute value for message attribute type 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-missing_string_attr": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' must contain non-empty message attribute value for message attribute type 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "fully_missing_string_attr": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' must contain non-empty message attribute value for message attribute type 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-fully_missing_string_attr": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' must contain non-empty message attribute value for message attribute type 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "fully_missing_data_type": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value null at 'messageAttributes.attr1.member.dataType' failed to satisfy constraint: Member must not be null", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-fully_missing_data_type": { + "Error": { + "Code": "ValidationError", + "Message": "1 validation error detected: Value null at 'publishBatchRequestEntries.1.member.messageAttributes.attr1.member.dataType' failed to satisfy constraint: Member must not be null", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing_binary_attr": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' must contain non-empty message attribute value for message attribute type 'Binary'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-missing_binary_attr": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' must contain non-empty message attribute value for message attribute type 'Binary'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "str_attr_binary_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' with type 'String' must use field 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-str_attr_binary_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' with type 'String' must use field 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "int_attr_binary_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' with type 'Number' must use field 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-int_attr_binary_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' with type 'Number' must use field 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "binary_attr_string_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' with type 'Binary' must use field 'Binary'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-binary_attr_string_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' with type 'Binary' must use field 'Binary'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_attr_string_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-invalid_attr_string_value": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "too_long_name": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Length of message attribute name must be less than 256 bytes.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-too_long_name": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Length of message attribute name must be less than 256 bytes.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_name": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Invalid non-alphanumeric character '#x5E' was found in the message attribute name. Can only include alphanumeric characters, hyphens, underscores, or dots.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-invalid_name": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Invalid non-alphanumeric character '#x5E' was found in the message attribute name. Can only include alphanumeric characters, hyphens, underscores, or dots.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_name_2": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Invalid message attribute name starting with character '.' was found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-invalid_name_2": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Invalid message attribute name starting with character '.' was found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_name_3": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Invalid message attribute name ending with character '.' was found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-invalid_name_3": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Invalid message attribute name ending with character '.' was found.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_name_4": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Message attribute name can not have successive '.' character.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "batch-invalid_name_4": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "Message attribute name can not have successive '.' character.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_attributes_prefixes": { + "recorded-date": "24-08-2023, 23:36:37", + "recorded-content": { + "publish-error": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "publish-error-2": { + "Error": { + "Code": "ParameterValueInvalid", + "Message": "The message attribute 'attr1' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "publish-ok-1": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-ok-2": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_structure_json_to_sqs": { + "recorded-date": "24-08-2023, 23:36:39", + "recorded-content": { + "get-msg-json-sqs": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"field\": \"value\"}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-msg-json-default": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "default field", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs[True]": { + "recorded-date": "07-12-2023, 10:13:37", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "532eaabd9574880dbf76b9b8cc00832c20a6ec113d682299550d7a6e0f345e25", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs[False]": { + "recorded-date": "07-12-2023, 10:13:41", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "message-deduplication-id-1", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_validations_for_fifo": { + "recorded-date": "24-08-2023, 23:55:19", + "recorded-content": { + "not-fifo-topic": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Invalid parameter: Endpoint Reason: FIFO SQS Queues can not be subscribed to standard SNS topics", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "not-fifo-queue": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "regular-queue-for-dlq-of-fifo-topic": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: RedrivePolicy: must use a FIFO queue as DLQ for a FIFO Subscription to a FIFO Topic.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "no-msg-group-id": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: The MessageGroupId parameter is required for FIFO topics", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "no-dedup-policy": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "no-msg-dedup-regular-topic": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: MessageDeduplicationId Reason: The request includes MessageDeduplicationId parameter that is not valid for this topic type", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "no-msg-group-id-regular-topic": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: MessageGroupId Reason: The request includes MessageGroupId parameter that is not valid for this topic type", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_fifo_messages_to_dlq[True]": { + "recorded-date": "24-08-2023, 23:37:54", + "recorded-content": { + "topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-batch-response-fifo": { + "Failed": [], + "Successful": [ + { + "Id": "1", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "2", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "3", + "MessageId": "", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "batch-messages-in-dlq": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-1", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message with two attributes", + "MD5OfBody": "", + "MD5OfMessageAttributes": "", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "99.12" + }, + "attr2": { + "DataType": "Number", + "StringValue": "109.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-2", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message with one attribute", + "MD5OfBody": "", + "MD5OfMessageAttributes": "", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "19.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-3", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message without attribute", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + }, + "publish-response-fifo": { + "MessageId": "", + "SequenceNumber": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-in-dlq": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "message-deduplication-id-1", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "test-message", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_fifo_messages_to_dlq[False]": { + "recorded-date": "24-08-2023, 23:37:59", + "recorded-content": { + "topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-batch-response-fifo": { + "Failed": [], + "Successful": [ + { + "Id": "1", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "2", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "3", + "MessageId": "", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "batch-messages-in-dlq": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-1", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Subject", + "Message": "Test Message with two attributes", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr2": { + "Type": "Number", + "Value": "109.12" + }, + "attr1": { + "Type": "Number", + "Value": "99.12" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-2", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Subject", + "Message": "Test Message with one attribute", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "19.12" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-3", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Subject": "Subject", + "Message": "Test Message without attribute", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + }, + "publish-response-fifo": { + "MessageId": "", + "SequenceNumber": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-in-dlq": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "message-deduplication-id-1", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test-message", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_batch_messages_from_fifo_topic_to_fifo_queue[True]": { + "recorded-date": "07-12-2023, 10:10:22", + "recorded-content": { + "topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "true", + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs-raw-true": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-batch-response-fifo": { + "Failed": [], + "Successful": [ + { + "Id": "1", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "2", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "3", + "MessageId": "", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "47f6d7e2c477469ad670ae3bfee60ed3e79080bb0105fc01a2805a50f8b21358", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message with two attributes", + "MD5OfBody": "", + "MD5OfMessageAttributes": "9b720b3af309c1c4b0d395c53b08c502", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "99.12" + }, + "attr2": { + "DataType": "Number", + "StringValue": "109.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "2186166772ec2fb49b7ba9efbda53c6eabf8e785cb00420db531c8eb9287d8e1", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message with one attribute", + "MD5OfBody": "", + "MD5OfMessageAttributes": "1f07082409022a373fa2a2601f82b3cb", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "19.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "a579d4ebff398c5ad4d5037534a1c1bfab36410100c80d8c7c1029b57c7898fe", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message without attribute", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + }, + "republish-batch-response-fifo": { + "Failed": [], + "Successful": [ + { + "Id": "1", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "2", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "3", + "MessageId": "", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "duplicate-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_batch_messages_from_fifo_topic_to_fifo_queue[False]": { + "recorded-date": "07-12-2023, 10:10:29", + "recorded-content": { + "topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs-raw-true": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-batch-response-fifo": { + "Failed": [], + "Successful": [ + { + "Id": "1", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "2", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "3", + "MessageId": "", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-0", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message with two attributes", + "MD5OfBody": "", + "MD5OfMessageAttributes": "9b720b3af309c1c4b0d395c53b08c502", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "99.12" + }, + "attr2": { + "DataType": "Number", + "StringValue": "109.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-1", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message with one attribute", + "MD5OfBody": "", + "MD5OfMessageAttributes": "1f07082409022a373fa2a2601f82b3cb", + "MessageAttributes": { + "attr1": { + "DataType": "Number", + "StringValue": "19.12" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "MessageDeduplicationId-2", + "MessageGroupId": "complexMessageGroupId", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test Message without attribute", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + }, + "republish-batch-response-fifo": { + "Failed": [], + "Successful": [ + { + "Id": "1", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "2", + "MessageId": "", + "SequenceNumber": "" + }, + { + "Id": "3", + "MessageId": "", + "SequenceNumber": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "duplicate-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_to_sqs_queue_no_content_dedup[True]": { + "recorded-date": "24-08-2023, 23:38:14", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "2591186cff6c740cb9e0cfce1653a7e6998d57da12e1da13d85344b1c08be4cb", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test single", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "32a45f72fe3d20fb5d4f1647421d1c5f9d20ef207c8ab713443cc9f9b76c468c", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "Test batched", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_to_sqs_queue_no_content_dedup[False]": { + "recorded-date": "24-08-2023, 23:38:17", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "2591186cff6c740cb9e0cfce1653a7e6998d57da12e1da13d85344b1c08be4cb", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test single", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "32a45f72fe3d20fb5d4f1647421d1c5f9d20ef207c8ab713443cc9f9b76c468c", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test batched", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_deduplication_on_topic_level": { + "recorded-date": "07-12-2023, 10:12:08", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "532eaabd9574880dbf76b9b8cc00832c20a6ec113d682299550d7a6e0f345e25", + "MessageGroupId": "message-group-id-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_fifo_topic_to_regular_sqs[True]": { + "recorded-date": "24-08-2023, 23:53:59", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_fifo_topic_to_regular_sqs[False]": { + "recorded-date": "24-08-2023, 23:54:05", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "SequenceNumber": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "Test", + "Timestamp": "date", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_publish_wrong_phone_format": { + "recorded-date": "25-08-2023, 00:20:12", + "recorded-content": { + "invalid-number": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: PhoneNumber Reason: +1a234 is not valid to publish to", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-format": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: PhoneNumber Reason: NAA+15551234567 is not valid to publish to", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "wrong-endpoint": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid SMS endpoint: NAA+15551234567", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishDelivery::test_delivery_lambda": { + "recorded-date": "07-11-2023, 11:11:37", + "recorded-content": { + "get-topic-attrs": { + "Attributes": { + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-no-logs": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-topic-attrs-with-success-feedback": { + "Attributes": { + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "LambdaSuccessFeedbackRoleArn": "arn::iam::111111111111:role/", + "LambdaSuccessFeedbackSampleRate": "100", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-logs": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delivery-events": { + "events": [ + { + "ingestionTime": "timestamp", + "message": { + "delivery": { + "deliveryId": "", + "destination": "arn::lambda::111111111111:function:", + "dwellTimeMs": "", + "providerResponse": { + "lambdaRequestId": "" + }, + "statusCode": 202 + }, + "notification": { + "messageId": "", + "messageMD5Sum": "c1c7ff780647014d463e6801bf83c59d", + "timestamp": "timestamp", + "topicArn": "arn::sns::111111111111:" + }, + "status": "SUCCESS" + }, + "timestamp": "timestamp" + } + ], + "nextBackwardToken": "", + "nextForwardToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_list_subscriptions": { + "recorded-date": "25-08-2023, 16:23:53", + "recorded-content": { + "create-topic-1": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create-topic-2": { + "TopicArn": "arn::sns::111111111111:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-topic-1-0": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-topic-1-1": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-topic-1-2": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-topic-2-0": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-topic-2-1": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-topic-2-2": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-subscriptions-aggregated": { + "Subscriptions": [ + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + }, + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + }, + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + }, + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + }, + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + }, + { + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "Protocol": "sqs", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_idempotency": { + "recorded-date": "20-03-2025, 17:16:39", + "recorded-content": { + "subscribe": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-sub-attrs": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscribe-exact-same-raw": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscribe-idempotent": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscribe-idempotent-no-attributes": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscribe-idempotent-empty-attributes": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscribe-missing-attributes": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscribe-diff-attributes": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: Subscription already exists with different attributes", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[True]": { + "recorded-date": "12-10-2023, 00:47:24", + "recorded-content": { + "subscription-confirmation": { + "Message": "You have chosen to subscribe to the topic arn::sns::111111111111:.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "SubscriptionConfirmation" + }, + "http-confirm-sub-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "SubscriptionConfirmation", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + }, + "broken-topic-arn-confirm": { + "ErrorResponse": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Topic", + "Type": "Sender" + }, + "RequestId": "" + } + }, + "broken-token-confirm": { + "ErrorResponse": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Token", + "Type": "Sender" + }, + "RequestId": "" + } + }, + "different-region-arn-confirm": { + "ErrorResponse": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Topic", + "Type": "Sender" + }, + "RequestId": "" + } + }, + "nonexistent-token-confirm": { + "ConfirmSubscriptionResponse": { + "ConfirmSubscriptionResult": { + "SubscriptionArn": "arn::sns::111111111111::" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + }, + "confirm-subscribe": { + "ConfirmSubscriptionResponse": { + "ConfirmSubscriptionResult": { + "SubscriptionArn": "arn::sns::111111111111::" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + }, + "http-message-headers-raw": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "Notification", + "X-Amz-Sns-Rawdelivery": "true", + "X-Amz-Sns-Subscription-Arn": "arn::sns::111111111111::", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + }, + "unsubscribe-response": { + "UnsubscribeResponse": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + }, + "unsubscribe-request": { + "Message": "You have chosen to deactivate subscription arn::sns::111111111111::.\nTo cancel this operation and restore the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "UnsubscribeConfirmation" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[False]": { + "recorded-date": "12-10-2023, 00:47:29", + "recorded-content": { + "subscription-confirmation": { + "Message": "You have chosen to subscribe to the topic arn::sns::111111111111:.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "SubscriptionConfirmation" + }, + "http-confirm-sub-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "SubscriptionConfirmation", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + }, + "broken-topic-arn-confirm": { + "ErrorResponse": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Topic", + "Type": "Sender" + }, + "RequestId": "" + } + }, + "broken-token-confirm": { + "ErrorResponse": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Token", + "Type": "Sender" + }, + "RequestId": "" + } + }, + "different-region-arn-confirm": { + "ErrorResponse": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Topic", + "Type": "Sender" + }, + "RequestId": "" + } + }, + "nonexistent-token-confirm": { + "ConfirmSubscriptionResponse": { + "ConfirmSubscriptionResult": { + "SubscriptionArn": "arn::sns::111111111111::" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + }, + "confirm-subscribe": { + "ConfirmSubscriptionResponse": { + "ConfirmSubscriptionResult": { + "SubscriptionArn": "arn::sns::111111111111::" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + }, + "http-message": { + "Message": "test_external_http_endpoint", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "http-message-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "Notification", + "X-Amz-Sns-Subscription-Arn": "arn::sns::111111111111::", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + }, + "unsubscribe-response": { + "UnsubscribeResponse": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + }, + "unsubscribe-request": { + "Message": "You have chosen to deactivate subscription arn::sns::111111111111::.\nTo cancel this operation and restore the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "UnsubscribeConfirmation" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_wrong_arn_format": { + "recorded-date": "20-10-2023, 12:52:36", + "recorded-content": { + "invalid-unsubscribe-arn-1": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: SubscriptionArn Reason: An ARN must have at least 6 elements, not 1", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-unsubscribe-arn-2": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: SubscriptionArn Reason: An ARN must have at least 6 elements, not 5", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-unsubscribe-arn-3": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: SubscriptionId", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_idempotency": { + "recorded-date": "07-11-2023, 00:36:06", + "recorded-content": { + "unsubscribe-1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "unsubscribe-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_topic_publish_another_region": { + "recorded-date": "17-11-2023, 18:14:28", + "recorded-content": { + "success": { + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-batch": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_verify_signature[1]": { + "recorded-date": "04-01-2024, 17:39:18", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test signature value with attributes", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "1" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_verify_signature[2]": { + "recorded-date": "04-01-2024, 17:39:20", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "test signature value with attributes", + "Timestamp": "date", + "SignatureVersion": "2", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "1" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_publish_lambda_verify_signature[1]": { + "recorded-date": "04-01-2024, 18:31:42", + "recorded-content": { + "notification": { + "Message": "Hello world.", + "MessageAttributes": {}, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertUrl": "/SimpleNotificationService-", + "Subject": "[Subject] Test subject Signature v1", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeUrl": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_publish_lambda_verify_signature[2]": { + "recorded-date": "04-01-2024, 18:33:47", + "recorded-content": { + "notification": { + "Message": "Hello world.", + "MessageAttributes": {}, + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertUrl": "/SimpleNotificationService-", + "Subject": "[Subject] Test subject Signature v2", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeUrl": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_list_subscriptions_by_topic_pagination": { + "recorded-date": "16-05-2024, 10:32:16", + "recorded-content": { + "list-sub-per-topic-page-2": { + "Subscriptions": [ + { + "Endpoint": "", + "Owner": "111111111111", + "Protocol": "sms", + "SubscriptionArn": "arn::sns::111111111111::", + "TopicArn": "arn::sns::111111111111:" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_http_subscription_response": { + "recorded-date": "13-05-2024, 21:28:15", + "recorded-content": { + "topic-arn": { + "TopicArn": "arn::sns::111111111111:" + }, + "subscription": { + "SubscriptionArn": "pending confirmation", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-with-arn": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_subscribe_sms_endpoint": { + "recorded-date": "14-05-2024, 19:34:12", + "recorded-content": { + "subscribe-sms-endpoint": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscribe-sms-attrs": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "+123123123", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sms", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_batch_too_long_message": { + "recorded-date": "04-09-2024, 00:48:01", + "recorded-content": { + "error-no-attrs": { + "Error": { + "Code": "BatchRequestTooLong", + "Message": "The length of all the messages put together is more than the limit.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-with-attrs": { + "Error": { + "Code": "BatchRequestTooLong", + "Message": "The length of all the messages put together is more than the limit.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_topic_delivery_policy_crud": { + "recorded-date": "03-10-2024, 21:46:17", + "recorded-content": { + "get-topic-attrs": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DeliveryPolicy": { + "http": { + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "application/json" + } + } + }, + "DisplayName": "TestTopic", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "application/json" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SignatureVersion": "2", + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-topic-attrs": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-topic-attrs-after-update": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 5, + "maxDelayTarget": 6, + "numRetries": 1 + }, + "disableSubscriptionOverrides": false + } + }, + "DisplayName": "TestTopic", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 5, + "maxDelayTarget": 6, + "numRetries": 1, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SignatureVersion": "2", + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-topic-attrs-none": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-topic-attrs-after-none": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DeliveryPolicy": { + "http": { + "disableSubscriptionOverrides": false + } + }, + "DisplayName": "TestTopic", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SignatureVersion": "2", + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-topic-attrs-full-none": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-topic-attrs-after-delete": { + "Attributes": { + "ContentBasedDeduplication": "false", + "DisplayName": "TestTopic", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "FifoTopic": "true", + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SignatureVersion": "2", + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_content_type[True]": { + "recorded-date": "03-10-2024, 22:35:07", + "recorded-content": { + "topic-attrs": { + "Attributes": { + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "text/plain; charset=UTF-8" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs": { + "Attributes": { + "ConfirmationWasAuthenticated": "false", + "DeliveryPolicy": { + "healthyRetryPolicy": null, + "sicklyRetryPolicy": null, + "throttlePolicy": null, + "requestPolicy": { + "headerContentType": "text/csv" + }, + "guaranteed": false + }, + "EffectiveDeliveryPolicy": { + "healthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "sicklyRetryPolicy": null, + "throttlePolicy": null, + "requestPolicy": { + "headerContentType": "text/csv" + }, + "guaranteed": false + }, + "Endpoint": "http:///sns-endpoint", + "Owner": "111111111111", + "PendingConfirmation": "true", + "Protocol": "http", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-confirmation": { + "Message": "You have chosen to subscribe to the topic arn::sns::111111111111:.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "SubscriptionConfirmation" + }, + "http-confirm-sub-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "SubscriptionConfirmation", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + }, + "http-message-headers-raw": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/csv", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "Notification", + "X-Amz-Sns-Rawdelivery": "true", + "X-Amz-Sns-Subscription-Arn": "arn::sns::111111111111::", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_content_type[False]": { + "recorded-date": "03-10-2024, 22:35:10", + "recorded-content": { + "topic-attrs": { + "Attributes": { + "DeliveryPolicy": { + "http": { + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "application/json" + } + } + }, + "DisplayName": "", + "EffectiveDeliveryPolicy": { + "http": { + "defaultHealthyRetryPolicy": { + "minDelayTarget": 20, + "maxDelayTarget": 20, + "numRetries": 3, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "disableSubscriptionOverrides": false, + "defaultRequestPolicy": { + "headerContentType": "application/json" + } + } + }, + "Owner": "111111111111", + "Policy": { + "Version": "2008-10-17", + "Id": "__default_policy_ID", + "Statement": [ + { + "Sid": "__default_statement_ID", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish" + ], + "Resource": "arn::sns::111111111111:", + "Condition": { + "StringEquals": { + "AWS:SourceOwner": "111111111111" + } + } + } + ] + }, + "SubscriptionsConfirmed": "0", + "SubscriptionsDeleted": "0", + "SubscriptionsPending": "0", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs": { + "Attributes": { + "ConfirmationWasAuthenticated": "false", + "DeliveryPolicy": { + "healthyRetryPolicy": { + "minDelayTarget": 1, + "maxDelayTarget": 1, + "numRetries": 0, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "sicklyRetryPolicy": null, + "throttlePolicy": { + "maxReceivesPerSecond": 1000 + }, + "requestPolicy": null, + "guaranteed": false + }, + "EffectiveDeliveryPolicy": { + "healthyRetryPolicy": { + "minDelayTarget": 1, + "maxDelayTarget": 1, + "numRetries": 0, + "numMaxDelayRetries": 0, + "numNoDelayRetries": 0, + "numMinDelayRetries": 0, + "backoffFunction": "linear" + }, + "sicklyRetryPolicy": null, + "throttlePolicy": { + "maxReceivesPerSecond": 1000 + }, + "requestPolicy": { + "headerContentType": "application/json" + }, + "guaranteed": false + }, + "Endpoint": "http:///sns-endpoint", + "Owner": "111111111111", + "PendingConfirmation": "true", + "Protocol": "http", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-confirmation": { + "Message": "You have chosen to subscribe to the topic arn::sns::111111111111:.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "SubscriptionConfirmation" + }, + "http-confirm-sub-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "SubscriptionConfirmation", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + }, + "http-message": { + "Message": "test_external_http_endpoint", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "http-message-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "application/json", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "Notification", + "X-Amz-Sns-Subscription-Arn": "arn::sns::111111111111::", + "X-Amz-Sns-Topic-Arn": "arn::sns::111111111111:" + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_with_invalid_topic": { + "recorded-date": "23-01-2025, 22:38:17", + "recorded-content": { + "invalid-subscribe-arn-1": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not 1", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-subscribe-arn-2": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not 5", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "non-existent-topic": { + "Error": { + "Code": "NotFound", + "Message": "Topic does not exist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_lambda_url_sig_validation": { + "recorded-date": "24-01-2025, 18:51:33", + "recorded-content": { + "subscription-confirmation": { + "events": [ + { + "body": { + "Message": "You have chosen to subscribe to the topic arn::sns::111111111111:.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "SubscriptionConfirmation" + }, + "headers": { + "accept-encoding": "gzip,deflate", + "content-type": "text/plain; charset=UTF-8", + "user-agent": "Amazon Simple Notification Service Agent", + "x-amz-sns-message-id": "", + "x-amz-sns-message-type": "SubscriptionConfirmation", + "x-amz-sns-topic-arn": "arn::sns::111111111111:" + } + } + ] + }, + "confirm-subscription": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish-event": { + "events": [ + { + "body": { + "Message": "test_external_http_endpoint", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "Timestamp": "date", + "TopicArn": "arn::sns::111111111111:", + "Type": "Notification", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "headers": { + "accept-encoding": "gzip,deflate", + "content-type": "text/plain; charset=UTF-8", + "user-agent": "Amazon Simple Notification Service Agent", + "x-amz-sns-message-id": "", + "x-amz-sns-message-type": "Notification", + "x-amz-sns-subscription-arn": "arn::sns::111111111111::", + "x-amz-sns-topic-arn": "arn::sns::111111111111:" + } + } + ] + }, + "unsubscribe-event": { + "events": [ + { + "body": { + "Message": "You have chosen to deactivate subscription arn::sns::111111111111::.\nTo cancel this operation and restore the subscription, visit the SubscribeURL included in this message.", + "MessageId": "", + "Signature": "", + "SignatureVersion": "1", + "SigningCertURL": "/SimpleNotificationService-", + "SubscribeURL": "/?Action=ConfirmSubscription&TopicArn=arn::sns::111111111111:&Token=", + "Timestamp": "date", + "Token": "", + "TopicArn": "arn::sns::111111111111:", + "Type": "UnsubscribeConfirmation" + }, + "headers": { + "accept-encoding": "gzip,deflate", + "content-type": "text/plain; charset=UTF-8", + "user-agent": "Amazon Simple Notification Service Agent", + "x-amz-sns-message-id": "", + "x-amz-sns-message-type": "UnsubscribeConfirmation", + "x-amz-sns-subscription-arn": "arn::sns::111111111111::", + "x-amz-sns-topic-arn": "arn::sns::111111111111:" + } + } + ] + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs_ordering": { + "recorded-date": "19-02-2025, 01:29:15", + "recorded-content": {} + }, + "tests/aws/services/sns/test_sns.py::TestSNSMultiRegions::test_cross_region_access": { + "recorded-date": "28-05-2025, 09:53:33", + "recorded-content": { + "set-topic-attrs": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-topic-attrs": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "cross-region-publish-forbidden": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "cross-region-subscribe": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-subs": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-topic": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSMultiRegions::test_cross_region_delivery_sqs": { + "recorded-date": "28-05-2025, 09:55:17", + "recorded-content": { + "subscribe-cross-region": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: TopicArn", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "subscribe-same-region": { + "SubscriptionArn": "arn::sns::111111111111::", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This is a test message", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "99.12" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_delete_topic_idempotency": { + "recorded-date": "28-05-2025, 10:08:38", + "recorded-content": { + "delete-topic": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-topic-again": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/sns/test_sns.validation.json b/tests/aws/services/sns/test_sns.validation.json new file mode 100644 index 0000000000000..04ec06d7594ee --- /dev/null +++ b/tests/aws/services/sns/test_sns.validation.json @@ -0,0 +1,245 @@ +{ + "tests/aws/services/sns/test_sns.py::TestSNSMultiRegions::test_cross_region_access": { + "last_validated_date": "2025-05-28T09:53:32+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSMultiRegions::test_cross_region_delivery_sqs": { + "last_validated_date": "2025-05-28T09:55:16+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_empty_sns_message": { + "last_validated_date": "2023-08-24T20:31:48+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_message_structure_json_exc": { + "last_validated_date": "2023-08-24T20:31:50+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_batch_too_long_message": { + "last_validated_date": "2024-09-04T00:48:01+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_by_path_parameters": { + "last_validated_date": "2023-08-24T20:31:40+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_message_before_subscribe_topic": { + "last_validated_date": "2023-08-24T20:31:44+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_message_by_target_arn": { + "last_validated_date": "2023-08-24T20:31:42+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_non_existent_target": { + "last_validated_date": "2023-08-24T20:31:46+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_too_long_message": { + "last_validated_date": "2024-09-04T00:38:06+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_verify_signature": { + "last_validated_date": "2024-01-04T17:22:57+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_verify_signature[1]": { + "last_validated_date": "2024-01-04T17:39:17+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_verify_signature[2]": { + "last_validated_date": "2024-01-04T17:39:19+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_with_empty_subject": { + "last_validated_date": "2023-08-24T20:31:46+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_publish_wrong_arn_format": { + "last_validated_date": "2024-03-11T10:36:34+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_topic_publish_another_region": { + "last_validated_date": "2023-11-17T17:14:28+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishCrud::test_unknown_topic_publish": { + "last_validated_date": "2023-08-24T20:31:45+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSPublishDelivery::test_delivery_lambda": { + "last_validated_date": "2023-11-07T10:11:37+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_publish_wrong_phone_format": { + "last_validated_date": "2023-08-24T22:20:12+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSMS::test_subscribe_sms_endpoint": { + "last_validated_date": "2024-05-14T19:34:11+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_create_subscriptions_with_attributes": { + "last_validated_date": "2024-03-29T19:44:42+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_list_subscriptions": { + "last_validated_date": "2023-08-25T14:23:53+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_list_subscriptions_by_topic_pagination": { + "last_validated_date": "2024-05-16T10:31:56+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_not_found_error_on_set_subscription_attributes": { + "last_validated_date": "2023-08-24T21:27:55+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_sns_confirm_subscription_wrong_token": { + "last_validated_date": "2023-08-24T21:27:58+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_idempotency": { + "last_validated_date": "2025-03-20T17:16:39+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_with_invalid_protocol": { + "last_validated_date": "2023-08-24T21:27:50+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_subscribe_with_invalid_topic": { + "last_validated_date": "2025-01-23T22:38:16+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_from_non_existing_subscription": { + "last_validated_date": "2023-08-24T21:27:52+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_idempotency": { + "last_validated_date": "2023-11-06T23:36:06+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_unsubscribe_wrong_arn_format": { + "last_validated_date": "2023-10-20T10:52:36+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_validate_set_sub_attributes": { + "last_validated_date": "2024-03-29T19:30:23+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_http_subscription_response": { + "last_validated_date": "2024-05-13T21:28:15+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[False]": { + "last_validated_date": "2023-10-11T22:47:29+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[True]": { + "last_validated_date": "2023-10-11T22:47:24+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_content_type[False]": { + "last_validated_date": "2024-10-03T22:35:09+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_content_type[True]": { + "last_validated_date": "2024-10-03T22:35:07+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint_lambda_url_sig_validation": { + "last_validated_date": "2025-01-24T18:51:32+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_publish_lambda_verify_signature[1]": { + "last_validated_date": "2024-01-04T18:31:41+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_publish_lambda_verify_signature[2]": { + "last_validated_date": "2024-01-04T18:33:46+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_python_lambda_subscribe_sns_topic": { + "last_validated_date": "2023-08-24T21:28:59+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_redrive_policy_lambda_subscription": { + "last_validated_date": "2023-08-24T21:33:01+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionLambda::test_sns_topic_as_lambda_dead_letter_queue": { + "last_validated_date": "2023-08-24T21:32:57+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_attribute_raw_subscribe": { + "last_validated_date": "2023-08-24T21:36:04+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_empty_or_wrong_message_attributes": { + "last_validated_date": "2024-11-15T18:55:20+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_attributes_not_missing": { + "last_validated_date": "2023-08-24T21:36:25+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_attributes_prefixes": { + "last_validated_date": "2023-08-24T21:36:37+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_message_structure_json_to_sqs": { + "last_validated_date": "2023-08-24T21:36:39+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_exceptions": { + "last_validated_date": "2023-08-24T21:36:14+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_messages_from_sns_to_sqs": { + "last_validated_date": "2023-08-24T21:36:12+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_batch_messages_without_topic": { + "last_validated_date": "2023-08-24T21:36:13+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_from_sns": { + "last_validated_date": "2023-08-24T21:36:09+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_sqs_from_sns_with_xray_propagation": { + "last_validated_date": "2023-08-24T21:36:16+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_publish_unicode_chars": { + "last_validated_date": "2023-08-24T21:36:02+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_redrive_policy_sqs_queue_subscription[False]": { + "last_validated_date": "2023-08-24T21:36:22+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_redrive_policy_sqs_queue_subscription[True]": { + "last_validated_date": "2023-08-24T21:36:19+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_sqs_topic_subscription_confirmation": { + "last_validated_date": "2023-08-24T21:36:06+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscribe_sqs_queue": { + "last_validated_date": "2023-08-24T21:36:00+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscribe_to_sqs_with_queue_url": { + "last_validated_date": "2023-08-24T21:36:15+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQS::test_subscription_after_failure_to_deliver": { + "last_validated_date": "2023-08-24T21:36:32+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_fifo_topic_to_regular_sqs[False]": { + "last_validated_date": "2023-08-24T21:54:05+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_fifo_topic_to_regular_sqs[True]": { + "last_validated_date": "2023-08-24T21:53:59+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs[False]": { + "last_validated_date": "2023-11-09T20:12:07+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs[True]": { + "last_validated_date": "2023-11-09T20:12:03+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_message_to_fifo_sqs_ordering": { + "last_validated_date": "2025-02-19T01:29:14+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_batch_messages_from_fifo_topic_to_fifo_queue[False]": { + "last_validated_date": "2023-11-09T20:10:33+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_batch_messages_from_fifo_topic_to_fifo_queue[True]": { + "last_validated_date": "2023-11-09T20:10:27+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_fifo_messages_to_dlq[False]": { + "last_validated_date": "2023-08-24T21:37:59+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_fifo_messages_to_dlq[True]": { + "last_validated_date": "2023-08-24T21:37:54+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_deduplication_on_topic_level": { + "last_validated_date": "2023-11-09T20:07:36+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_to_sqs_queue_no_content_dedup[False]": { + "last_validated_date": "2023-08-24T21:38:17+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_publish_to_fifo_topic_to_sqs_queue_no_content_dedup[True]": { + "last_validated_date": "2023-08-24T21:38:14+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionSQSFifo::test_validations_for_fifo": { + "last_validated_date": "2023-08-24T21:55:19+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_duplicate_topic_check_idempotency": { + "last_validated_date": "2023-08-24T20:30:47+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_duplicate_topic_with_more_tags": { + "last_validated_date": "2023-08-24T20:30:46+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_after_delete_with_new_tags": { + "last_validated_date": "2023-08-24T20:30:48+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_test_arn": { + "last_validated_date": "2023-08-24T20:30:45+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_create_topic_with_attributes": { + "last_validated_date": "2023-10-06T18:11:02+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_delete_topic_idempotency": { + "last_validated_date": "2025-05-28T10:08:38+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_tags": { + "last_validated_date": "2023-08-24T20:30:44+00:00" + }, + "tests/aws/services/sns/test_sns.py::TestSNSTopicCrud::test_topic_delivery_policy_crud": { + "last_validated_date": "2024-10-03T21:46:17+00:00" + } +} diff --git a/tests/aws/services/sns/test_sns_filter_policy.py b/tests/aws/services/sns/test_sns_filter_policy.py new file mode 100644 index 0000000000000..18fc17eaec215 --- /dev/null +++ b/tests/aws/services/sns/test_sns_filter_policy.py @@ -0,0 +1,1830 @@ +import copy +import json +import os +from operator import itemgetter + +import pytest +from botocore.exceptions import ClientError + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.aws.arns import get_partition +from localstack.utils.files import load_file +from localstack.utils.sync import poll_condition, retry + +THIS_FOLDER: str = os.path.dirname(os.path.realpath(__file__)) +TEST_PAYLOAD_DIR = os.path.join(THIS_FOLDER, "test_payloads") + + +@pytest.fixture(autouse=True) +def sns_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.sns_api()) + + +@pytest.fixture +def sns_create_sqs_subscription_with_filter_policy(sns_create_sqs_subscription, aws_client): + def _inner(topic_arn: str, queue_url: str, filter_scope: str, filter_policy: dict): + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicyScope", + AttributeValue=filter_scope, + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(filter_policy), + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + return subscription_arn + + yield _inner + + +class TestSNSFilterPolicyCrud: + @markers.aws.validated + def test_set_subscription_filter_policy_scope( + self, sqs_create_queue, sns_create_topic, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + # we fetch the default subscription attributes + # note: the FilterPolicyScope is not present in the response + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("sub-attrs-default", subscription_attrs) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicyScope", + AttributeValue="MessageBody", + ) + + # we fetch the subscription attributes after setting the FilterPolicyScope + # note: the FilterPolicyScope is still not present in the response + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("sub-attrs-filter-scope-body", subscription_attrs) + + # we try to set random values to the FilterPolicyScope + with pytest.raises(ClientError) as e: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicyScope", + AttributeValue="RandomValue", + ) + + snapshot.match("sub-attrs-filter-scope-error", e.value.response) + + # we try to set a FilterPolicy to see if it will show the FilterPolicyScope in the attributes + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps({"attr": ["match-this"]}), + ) + # the FilterPolicyScope is now present in the attributes + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("sub-attrs-after-setting-policy", subscription_attrs) + + @markers.aws.validated + def test_sub_filter_policy_nested_property( + self, sqs_create_queue, sns_create_topic, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + # see https://aws.amazon.com/blogs/compute/introducing-payload-based-message-filtering-for-amazon-sns/ + nested_filter_policy = {"object": {"key": [{"prefix": "auto-"}]}} + with pytest.raises(ClientError) as e: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(nested_filter_policy), + ) + snapshot.match("sub-filter-policy-nested-error", e.value.response) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicyScope", + AttributeValue="MessageBody", + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(nested_filter_policy), + ) + + # the FilterPolicyScope is now present in the attributes + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("sub-attrs-after-setting-nested-policy", subscription_attrs) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$.sub-filter-policy-rule-no-list.Error.Message", # message contains java trace in AWS, assert instead + ] + ) + def test_sub_filter_policy_nested_property_constraints( + self, sqs_create_queue, sns_create_topic, sns_create_sqs_subscription, snapshot, aws_client + ): + # https://docs.aws.amazon.com/sns/latest/dg/subscription-filter-policy-constraints.html + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicyScope", + AttributeValue="MessageBody", + ) + + nested_filter_policy = { + "key_a": { + "key_b": {"key_c": ["value_one", "value_two", "value_three", "value_four"]}, + }, + "key_d": {"key_e": ["value_one", "value_two", "value_three"]}, + "key_f": ["value_one", "value_two", "value_three"], + } + # The first array has four values in a three-level nested key, and the second has three values in a two-level + # nested key. The total combination is calculated as follows: + # 3 x 4 x 2 x 3 x 1 x 3 = 216 + with pytest.raises(ClientError) as e: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(nested_filter_policy), + ) + snapshot.match("sub-filter-policy-nested-error-too-many-combinations", e.value.response) + + flat_filter_policy = { + "key_a": ["value_one"], + "key_b": ["value_two"], + "key_c": ["value_three"], + "key_d": ["value_four"], + "key_e": ["value_five"], + "key_f": ["value_six"], + } + # A filter policy can have a maximum of five attribute names. For a nested policy, only parent keys are counted. + with pytest.raises(ClientError) as e: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(flat_filter_policy), + ) + snapshot.match("sub-filter-policy-max-attr-keys", e.value.response) + + flat_filter_policy = {"key_a": "value_one"} + # Rules should be contained in a list + with pytest.raises(ClientError) as e: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(flat_filter_policy), + ) + snapshot.match("sub-filter-policy-rule-no-list", e.value.response) + assert e.value.response["Error"]["Message"].startswith( + 'Invalid parameter: FilterPolicy: "key_a" must be an object or an array' + ) + + +class TestSNSFilterPolicyAttributes: + @markers.aws.validated + def test_filter_policy( + self, sqs_create_queue, sns_create_topic, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + filter_policy = {"attr1": [{"numeric": [">", 0, "<=", 100]}]} + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(filter_policy), + ) + + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("subscription-attributes", response_attributes) + + response_0 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=1 + ) + snapshot.match("messages-0", response_0) + # get number of messages + num_msgs_0 = len(response_0.get("Messages", [])) + + # publish message that satisfies the filter policy, assert that message is received + message = "This is a test message" + message_attributes = {"attr1": {"DataType": "Number", "StringValue": "99"}} + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes=message_attributes, + ) + + response_1 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-1", response_1) + + num_msgs_1 = len(response_1["Messages"]) + assert num_msgs_1 == (num_msgs_0 + 1) + + # publish message that does not satisfy the filter policy, assert that message is not received + message = "This is another test message" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": "111"}}, + ) + + response_2 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-2", response_2) + num_msgs_2 = len(response_2["Messages"]) + assert num_msgs_2 == num_msgs_1 + + # remove all messages from the queue + receipt_handle = response_1["Messages"][0]["ReceiptHandle"] + aws_client.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + # test with a property value set to null with an OR operator with anything-but + filter_policy = json.dumps({"attr1": [None, {"anything-but": "whatever"}]}) + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=filter_policy, + ) + + def get_filter_policy(): + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + return subscription_attrs["Attributes"]["FilterPolicy"] + + # wait for the new filter policy to be in effect + poll_condition(lambda: get_filter_policy() == filter_policy, timeout=4) + response_attributes_2 = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("subscription-attributes-2", response_attributes_2) + + # publish message that does not satisfy the filter policy, assert that message is not received + message = "This the test message for null" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + ) + + response_3 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-3", response_3) + assert "Messages" not in response_3 or response_3["Messages"] == [] + + # unset the filter policy + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue="", + ) + + def check_no_filter_policy(): + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + return "FilterPolicy" not in subscription_attrs["Attributes"] + + poll_condition(check_no_filter_policy, timeout=4) + + # publish message that does not satisfy the previous filter policy, but assert that the message is received now + message = "This the test message for null" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + ) + + response_4 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-4", response_4) + + @markers.aws.validated + def test_exists_filter_policy( + self, sqs_create_queue, sns_create_topic, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + filter_policy = {"store": [{"exists": True}]} + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(filter_policy), + ) + + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("subscription-attributes-policy-1", response_attributes) + + response_0 = aws_client.sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + snapshot.match("messages-0", response_0) + # get number of messages + num_msgs_0 = len(response_0.get("Messages", [])) + + # publish message that satisfies the filter policy, assert that message is received + message_1 = "message-1" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message_1, + MessageAttributes={ + "store": {"DataType": "Number", "StringValue": "99"}, + "def": {"DataType": "Number", "StringValue": "99"}, + }, + ) + response_1 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-1", response_1) + num_msgs_1 = len(response_1["Messages"]) + assert num_msgs_1 == (num_msgs_0 + 1) + + # publish message that does not satisfy the filter policy, assert that message is not received + message_2 = "message-2" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message_2, + MessageAttributes={"attr1": {"DataType": "Number", "StringValue": "111"}}, + ) + + response_2 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-2", response_2) + num_msgs_2 = len(response_2["Messages"]) + assert num_msgs_2 == num_msgs_1 + + # delete first message + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response_1["Messages"][0]["ReceiptHandle"] + ) + + # test with exist operator set to false. + filter_policy = json.dumps({"store": [{"exists": False}]}) + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=filter_policy, + ) + + def get_filter_policy(): + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + return subscription_attrs["Attributes"]["FilterPolicy"] + + # wait for the new filter policy to be in effect + poll_condition(lambda: get_filter_policy() == filter_policy, timeout=4) + response_attributes_2 = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("subscription-attributes-policy-2", response_attributes_2) + + # publish message that satisfies the filter policy, assert that message is received + message_3 = "message-3" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message_3, + MessageAttributes={"def": {"DataType": "Number", "StringValue": "99"}}, + ) + + response_3 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-3", response_3) + num_msgs_3 = len(response_3["Messages"]) + assert num_msgs_3 == num_msgs_1 + + # publish message that does not satisfy the filter policy, assert that message is not received + message_4 = "message-4" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message_4, + MessageAttributes={ + "store": {"DataType": "Number", "StringValue": "99"}, + "def": {"DataType": "Number", "StringValue": "99"}, + }, + ) + + response_4 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-4", response_4) + num_msgs_4 = len(response_4["Messages"]) + assert num_msgs_4 == num_msgs_3 + + @markers.aws.validated + def test_exists_filter_policy_attributes_array( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription_with_filter_policy, + snapshot, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + filter_policy = {"store": ["value1"]} + subscription_arn = sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url, + filter_scope="MessageAttributes", + filter_policy=filter_policy, + ) + + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + snapshot.match("subscription-attributes-policy", response_attributes) + + response_0 = aws_client.sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + snapshot.match("messages-init", response_0) + + # publish message that satisfies the filter policy, assert that message is received + message = "message-1" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={ + "store": {"DataType": "String", "StringValue": "value1"}, + }, + ) + response_1 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response_1["Messages"][0]["ReceiptHandle"] + ) + snapshot.match("messages-1", response_1) + + # publish message that satisfies the filter policy but with String.Array + message = "message-2" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={ + "store": { + "DataType": "String.Array", + "StringValue": json.dumps(["value1", "value2"]), + }, + }, + ) + response_2 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + aws_client.sqs.delete_message( + QueueUrl=queue_url, ReceiptHandle=response_2["Messages"][0]["ReceiptHandle"] + ) + snapshot.match("messages-2", response_2) + + # publish message that does not satisfy the filter policy with String.Array + message = "message-3" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + MessageAttributes={ + "store": { + "DataType": "String.Array", + "StringValue": json.dumps(["value2", "value3"]), + }, + }, + ) + response_3 = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=4 + ) + snapshot.match("messages-3", response_3) + + +class TestSNSFilterPolicyBody: + @staticmethod + def get_messages(aws_client, _queue_url: str, _msg_list: list, expected: int): + # due to the random nature of receiving SQS messages, we need to consolidate a single object to match + sqs_response = aws_client.sqs.receive_message( + QueueUrl=_queue_url, + WaitTimeSeconds=1, + VisibilityTimeout=0, + MessageAttributeNames=["All"], + AttributeNames=["All"], + ) + for _message in sqs_response["Messages"]: + _msg_list.append(_message) + aws_client.sqs.delete_message( + QueueUrl=_queue_url, ReceiptHandle=_message["ReceiptHandle"] + ) + + assert len(_msg_list) == expected + + @markers.aws.validated + @pytest.mark.parametrize("raw_message_delivery", [True, False]) + def test_filter_policy_on_message_body( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription, + snapshot, + raw_message_delivery, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + # see https://aws.amazon.com/blogs/compute/introducing-payload-based-message-filtering-for-amazon-sns/ + nested_filter_policy = { + "object": { + "key": [{"prefix": "auto-"}, "hardcodedvalue"], + "nested_key": [{"exists": False}], + }, + "test": [{"exists": False}], + } + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicyScope", + AttributeValue="MessageBody", + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(nested_filter_policy), + ) + + if raw_message_delivery: + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="RawMessageDelivery", + AttributeValue="true", + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=1 + ) + snapshot.match("recv-init", response) + # assert there are no messages in the queue + assert "Messages" not in response or response["Messages"] == [] + + # publish messages that satisfies the filter policy, assert that messages are received + messages = [ + {"object": {"key": "auto-test"}}, + {"object": {"key": "hardcodedvalue"}}, + ] + for i, message in enumerate(messages): + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + WaitTimeSeconds=5 if is_aws_cloud() else 2, + ) + snapshot.match(f"recv-passed-msg-{i}", response) + receipt_handle = response["Messages"][0]["ReceiptHandle"] + aws_client.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + # publish messages that do not satisfy the filter policy, assert those messages are not received + messages = [ + {"object": {"key": "test-auto"}}, + {"object": {"key": "auto-test"}, "test": "just-exists"}, + {"object": {"key": "auto-test", "nested_key": "just-exists"}}, + {"object": {"test": "auto-test"}}, + {"test": "auto-test"}, + ] + for message in messages: + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=5 if is_aws_cloud() else 2 + ) + # assert there are no messages in the queue + assert "Messages" not in response or response["Messages"] == [] + + # publish message that does not satisfy the filter policy as it's not even JSON, or not a JSON object + message = "Regular string message" + aws_client.sns.publish( + TopicArn=topic_arn, + Message=message, + ) + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), # send it JSON encoded, but not an object + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=2 + ) + # assert there are no messages in the queue + assert "Messages" not in response or response["Messages"] == [] + + @markers.aws.validated + def test_filter_policy_for_batch( + self, sqs_create_queue, sns_create_topic, sns_create_sqs_subscription, snapshot, aws_client + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url_with_filter = sqs_create_queue() + subscription_with_filter = sns_create_sqs_subscription( + topic_arn=topic_arn, queue_url=queue_url_with_filter + ) + subscription_with_filter_arn = subscription_with_filter["SubscriptionArn"] + + queue_url_no_filter = sqs_create_queue() + subscription_no_filter = sns_create_sqs_subscription( + topic_arn=topic_arn, queue_url=queue_url_no_filter + ) + subscription_no_filter_arn = subscription_no_filter["SubscriptionArn"] + + filter_policy = {"attr1": [{"numeric": [">", 0, "<=", 100]}]} + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_with_filter_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(filter_policy), + ) + + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_with_filter_arn + ) + snapshot.match("subscription-attributes-with-filter", response_attributes) + + response_attributes = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_no_filter_arn + ) + snapshot.match("subscription-attributes-no-filter", response_attributes) + + sqs_wait_time = 4 if is_aws_cloud() else 1 + + response_before_publish_no_filter = aws_client.sqs.receive_message( + QueueUrl=queue_url_with_filter, VisibilityTimeout=0, WaitTimeSeconds=sqs_wait_time + ) + snapshot.match("messages-no-filter-before-publish", response_before_publish_no_filter) + + response_before_publish_filter = aws_client.sqs.receive_message( + QueueUrl=queue_url_with_filter, VisibilityTimeout=0, WaitTimeSeconds=sqs_wait_time + ) + snapshot.match("messages-with-filter-before-publish", response_before_publish_filter) + + # publish message that satisfies the filter policy, assert that message is received + message = "This is a test message" + message_attributes = {"attr1": {"DataType": "Number", "StringValue": "99"}} + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": message, + "MessageAttributes": message_attributes, + } + ], + ) + + response_after_publish_no_filter = aws_client.sqs.receive_message( + QueueUrl=queue_url_no_filter, VisibilityTimeout=0, WaitTimeSeconds=sqs_wait_time + ) + snapshot.match("messages-no-filter-after-publish-ok", response_after_publish_no_filter) + aws_client.sqs.delete_message( + QueueUrl=queue_url_no_filter, + ReceiptHandle=response_after_publish_no_filter["Messages"][0]["ReceiptHandle"], + ) + + response_after_publish_filter = aws_client.sqs.receive_message( + QueueUrl=queue_url_with_filter, VisibilityTimeout=0, WaitTimeSeconds=sqs_wait_time + ) + snapshot.match("messages-with-filter-after-publish-ok", response_after_publish_filter) + aws_client.sqs.delete_message( + QueueUrl=queue_url_with_filter, + ReceiptHandle=response_after_publish_filter["Messages"][0]["ReceiptHandle"], + ) + + # publish message that does not satisfy the filter policy, assert that message is not received by the + # subscription with the filter and received by the other + aws_client.sns.publish_batch( + TopicArn=topic_arn, + PublishBatchRequestEntries=[ + { + "Id": "1", + "Message": "This is another test message", + "MessageAttributes": {"attr1": {"DataType": "Number", "StringValue": "111"}}, + } + ], + ) + + response_after_publish_no_filter = aws_client.sqs.receive_message( + QueueUrl=queue_url_no_filter, VisibilityTimeout=0, WaitTimeSeconds=sqs_wait_time + ) + # there should be 1 message in the queue, latest sent + snapshot.match("messages-no-filter-after-publish-ok-1", response_after_publish_no_filter) + + response_after_publish_filter = aws_client.sqs.receive_message( + QueueUrl=queue_url_with_filter, VisibilityTimeout=0, WaitTimeSeconds=sqs_wait_time + ) + # there should be no messages in this queue + snapshot.match("messages-with-filter-after-publish-filtered", response_after_publish_filter) + + @markers.aws.validated + def test_filter_policy_on_message_body_dot_attribute( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription, + snapshot, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + subscription = sns_create_sqs_subscription(topic_arn=topic_arn, queue_url=queue_url) + subscription_arn = subscription["SubscriptionArn"] + + nested_filter_policy = json.dumps( + { + "object.nested": ["string.value"], + } + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicyScope", + AttributeValue="MessageBody", + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=nested_filter_policy, + ) + + def get_filter_policy(): + subscription_attrs = aws_client.sns.get_subscription_attributes( + SubscriptionArn=subscription_arn + ) + return subscription_attrs["Attributes"]["FilterPolicy"] + + # wait for the new filter policy to be in effect + poll_condition(lambda: get_filter_policy() == nested_filter_policy, timeout=4) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=1 + ) + snapshot.match("recv-init", response) + # assert there are no messages in the queue + assert "Messages" not in response or response["Messages"] == [] + + def _verify_and_snapshot_sqs_messages(msg_to_send: list[dict], snapshot_prefix: str): + for i, _message in enumerate(msg_to_send): + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(_message), + ) + + _response = aws_client.sqs.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + WaitTimeSeconds=5 if is_aws_cloud() else 2, + ) + snapshot.match(f"{snapshot_prefix}-{i}", _response) + receipt_handle = _response["Messages"][0]["ReceiptHandle"] + aws_client.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + # publish messages that satisfies the filter policy, assert that messages are received + messages = [ + {"object": {"nested": "string.value"}}, + {"object.nested": "string.value"}, + ] + _verify_and_snapshot_sqs_messages(messages, snapshot_prefix="recv-nested-msg") + + # publish messages that do not satisfy the filter policy, assert those messages are not received + messages = [ + {"object": {"nested": "test-auto"}}, + ] + for message in messages: + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=5 if is_aws_cloud() else 2 + ) + # assert there are no messages in the queue + assert "Messages" not in response or response["Messages"] == [] + + # assert with more nesting + deep_nested_filter_policy = json.dumps( + { + "object.nested.test": ["string.value"], + } + ) + + aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=deep_nested_filter_policy, + ) + # wait for the new filter policy to be in effect + poll_condition(lambda: get_filter_policy() == deep_nested_filter_policy, timeout=4) + + messages = [ + {"object": {"nested": {"test": "string.value"}}}, + {"object.nested.test": "string.value"}, + {"object.nested": {"test": "string.value"}}, + {"object": {"nested.test": "string.value"}}, + ] + _verify_and_snapshot_sqs_messages(messages, snapshot_prefix="recv-deep-nested-msg") + # publish messages that do not satisfy the filter policy, assert those messages are not received + messages = [ + {"object": {"nested": {"test": "string.notvalue"}}}, + ] + for message in messages: + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, WaitTimeSeconds=5 if is_aws_cloud() else 2 + ) + # assert there are no messages in the queue + assert "Messages" not in response or response["Messages"] == [] + + @markers.aws.validated + def test_filter_policy_on_message_body_array_attributes( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription_with_filter_policy, + snapshot, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url_1 = sqs_create_queue() + queue_url_2 = sqs_create_queue() + + filter_policy_1 = {"headers": {"route-to": ["queue1"]}} + sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url_1, + filter_scope="MessageBody", + filter_policy=filter_policy_1, + ) + + filter_policy_2 = {"headers": {"route-to": ["queue2"]}} + sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url_2, + filter_scope="MessageBody", + filter_policy=filter_policy_2, + ) + + queues = [queue_url_1, queue_url_2] + + # publish messages that satisfies the filter policy, assert that messages are received + messages = [ + {"headers": {"route-to": ["queue3"]}}, + {"headers": {"route-to": ["queue1"]}}, + {"headers": {"route-to": ["queue2"]}}, + {"headers": {"route-to": ["queue1", "queue2"]}}, + ] + for i, message in enumerate(messages): + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + for i, queue_url in enumerate(queues): + recv_messages = [] + retry( + self.get_messages, + retries=10, + sleep=0.1, + aws_client=aws_client, + _queue_url=queue_url, + _msg_list=recv_messages, + expected=2, + ) + # we need to sort the list (the order does not matter as we're not using FIFO) + recv_messages.sort(key=itemgetter("Body")) + snapshot.match(f"messages-queue-{i}", {"Messages": recv_messages}) + + @markers.aws.validated + def test_filter_policy_on_message_body_array_of_object_attributes( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription_with_filter_policy, + snapshot, + aws_client, + region_name, + ): + # example from https://aws.amazon.com/blogs/compute/introducing-payload-based-message-filtering-for-amazon-sns/ + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + # complex filter policy with different level of nesting + filter_policy = { + "Records": { + "s3": {"object": {"key": [{"prefix": "auto-"}]}}, + "eventName": [{"prefix": "ObjectCreated:"}], + } + } + + sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url, + filter_scope="MessageBody", + filter_policy=filter_policy, + ) + + # stripped down events + s3_event_auto_insurance_created = { + "Records": [ + { + "eventSource": "aws:s3", + "eventTime": "2022-11-21T03:41:29.743Z", + "eventName": "ObjectCreated:Put", + "s3": { + "bucket": { + "name": "insurance-bucket-demo", + "arn": f"arn:{get_partition(region_name)}:s3:::insurance-bucket-demo", + }, + "object": { + "key": "auto-insurance-2314.xml", + "size": 17, + }, + }, + } + ] + } + # copy the object to modify it + s3_event_auto_insurance_removed = copy.deepcopy(s3_event_auto_insurance_created) + s3_event_auto_insurance_removed["Records"][0]["eventName"] = "ObjectRemoved:Delete" + + # copy the object to modify it + s3_event_home_insurance_created = copy.deepcopy(s3_event_auto_insurance_created) + s3_event_home_insurance_created["Records"][0]["s3"]["object"]["key"] = ( + "home-insurance-2314.xml" + ) + + # stripped down events + s3_event_multiple_records = { + "Records": [ + { + "eventSource": "aws:s3", + "eventName": "ObjectCreated:Put", + "s3": { + # this object is a list of list of dict, and it works in AWS + "object": [ + [ + { + "key": "auto-insurance-2314.xml", + "size": 17, + } + ] + ], + }, + }, + { + "eventSource": "aws:s3", + "eventName": "ObjectRemoved:Delete", + "s3": { + "object": { + "key": "home-insurance-2314.xml", + "size": 17, + } + }, + }, + ] + } + + messages = [ + s3_event_multiple_records, + s3_event_auto_insurance_removed, + s3_event_home_insurance_created, + s3_event_auto_insurance_created, + ] + for i, message in enumerate(messages): + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + received_messages = [] + retry( + self.get_messages, + retries=10, + sleep=0.1, + aws_client=aws_client, + _queue_url=queue_url, + _msg_list=received_messages, + expected=2, + ) + # we need to sort the list (the order does not matter as we're not using FIFO) + received_messages.sort(key=itemgetter("Body")) + snapshot.match("messages", {"Messages": received_messages}) + + @markers.aws.validated + def test_filter_policy_on_message_body_or_attribute( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription_with_filter_policy, + snapshot, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + filter_policy = { + "$or": [ + {"metricName": ["CPUUtilization", "ReadLatency"]}, + {"namespace": ["AWS/EC2", "AWS/ES"]}, + ], + "detail": { + "scope": ["Service"], + "$or": [ + {"source": ["aws.cloudwatch"]}, + {"type": ["CloudWatch Alarm State Change"]}, + ], + }, + } + sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url, + filter_scope="MessageBody", + filter_policy=filter_policy, + ) + + # publish messages that satisfies the filter policy, assert that messages are received + messages = [ + # not passing + # wrong value for `detail.scope` + { + "metricName": "CPUUtilization", + "detail": {"scope": "aws.cloudwatch", "type": "CloudWatch Alarm State Change"}, + }, + # wrong value for `detail.type` + { + "metricName": "CPUUtilization", + "detail": {"scope": "Service", "type": "CPUUtilization"}, + }, + # missing value for `detail.scope` + {"metricName": "CPUUtilization", "detail": {"type": "CloudWatch Alarm State Change"}}, + # missing value for `detail.type` or `detail.source` + {"metricName": "CPUUtilization", "detail": {"scope": "Service"}}, + # missing value for `detail.scope` AND `detail.source` or `detail.type` + {"metricName": "CPUUtilization", "scope": "Service"}, + # wrong value for `metricName` + { + "metricName": "AWS/EC2", + "detail": {"scope": "Service", "type": "CloudWatch Alarm State Change"}, + }, + # wrong value for `namespace` + { + "namespace": "CPUUtilization", + "detail": {"scope": "Service", "type": "CloudWatch Alarm State Change"}, + }, + # passing + { + "metricName": "CPUUtilization", + "detail": {"scope": "Service", "source": "aws.cloudwatch"}, + }, + { + "metricName": "ReadLatency", + "detail": {"scope": "Service", "source": "aws.cloudwatch"}, + }, + {"namespace": "AWS/EC2", "detail": {"scope": "Service", "source": "aws.cloudwatch"}}, + {"namespace": "AWS/ES", "detail": {"scope": "Service", "source": "aws.cloudwatch"}}, + { + "metricName": "CPUUtilization", + "detail": {"scope": "Service", "type": "CloudWatch Alarm State Change"}, + }, + ] + for message in messages: + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + recv_messages = [] + retry( + self.get_messages, + retries=10, + sleep=0.1, + aws_client=aws_client, + _queue_url=queue_url, + _msg_list=recv_messages, + expected=5, + ) + # we need to sort the list (the order does not matter as we're not using FIFO) + recv_messages.sort(key=itemgetter("Body")) + snapshot.match("messages-queue", {"Messages": recv_messages}) + + @markers.aws.validated + def test_filter_policy_empty_array_payload( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription_with_filter_policy, + snapshot, + aws_client, + ): + # this test is a regression test for having an empty array in the payload, which could fail the logic and is + # a special condition (`resources` would fail `exists`) + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + filter_policy = {"detail": {"eventVersion": [""]}} + sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url, + filter_scope="MessageBody", + filter_policy=filter_policy, + ) + message = { + "version": "0", + "id": "3e3c153a-8339-4e30-8c35-687ebef853fe", + "detail-type": "EC2 Instance Launch Successful", + "source": "aws.autoscaling", + "account": "123456789012", + "time": "2015-11-11T21:31:47Z", + "region": "my-region", + "resources": [], + "detail": { + "eventVersion": "", + "responseElements": None, + }, + } + + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + recv_messages = [] + retry( + self.get_messages, + retries=10, + sleep=0.1, + aws_client=aws_client, + _queue_url=queue_url, + _msg_list=recv_messages, + expected=1, + ) + snapshot.match("messages-queue", {"Messages": recv_messages}) + + @markers.aws.validated + def test_filter_policy_large_complex_payload( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription_with_filter_policy, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + filter_policy = { + "detail": {"payload.nested.another-level.deep": {"inside-list": [{"prefix": "q-test"}]}} + } + sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url, + filter_scope="MessageBody", + filter_policy=filter_policy, + ) + large_payload_path = os.path.join(TEST_PAYLOAD_DIR, "complex_payload.json") + message = load_file(large_payload_path) + + aws_client.sns.publish(TopicArn=topic_arn, Message=message) + + recv_messages = [] + retry( + self.get_messages, + retries=10, + sleep=0.1, + aws_client=aws_client, + _queue_url=queue_url, + _msg_list=recv_messages, + expected=1, + ) + # we do not want to snapshot a massive 40kb message + assert len(recv_messages) == 1 + + @markers.aws.validated + def test_filter_policy_ip_address_condition( + self, + sqs_create_queue, + sns_create_topic, + sns_create_sqs_subscription_with_filter_policy, + snapshot, + aws_client, + ): + topic_arn = sns_create_topic()["TopicArn"] + queue_url = sqs_create_queue() + + filter_policy = { + "detail": { + "$or": [ + {"sourceIPAddress": [{"cidr": "10.0.0.0/24"}]}, + {"sourceIPAddressV6": [{"cidr": "2001:db8:1234:1a00::/64"}]}, + ], + }, + } + sns_create_sqs_subscription_with_filter_policy( + topic_arn=topic_arn, + queue_url=queue_url, + filter_scope="MessageBody", + filter_policy=filter_policy, + ) + messages = [ + { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "my-region", + "time": "2022-07-13T13:48:01Z", + "detail": {"sourceIPAddress": "10.0.0.255"}, + }, + { + "id": "2", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "my-region", + "time": "2022-07-13T13:48:01Z", + "detail": {"sourceIPAddress": "10.0.0.256"}, + }, + { + "id": "3", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "my-region", + "time": "2022-07-13T13:48:01Z", + "detail": {"sourceIPAddressV6": "2001:0db8:1234:1a00:0000:0000:0000:0000"}, + }, + { + "id": "4", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "my-region", + "time": "2022-07-13T13:48:01Z", + "detail": {"sourceIPAddressV6": "2001:0db8:123f:1a01:0000:0000:0000:0000"}, + }, + ] + for message in messages: + aws_client.sns.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + + recv_messages = [] + retry( + self.get_messages, + retries=10, + sleep=0.1, + aws_client=aws_client, + _queue_url=queue_url, + _msg_list=recv_messages, + expected=2, + ) + recv_messages.sort(key=itemgetter("Body")) + snapshot.match("messages-queue", {"Messages": recv_messages}) + + +class TestSNSFilterPolicyConditions: + @staticmethod + def _add_normalized_field_to_snapshot(error_dict): + error_dict["Error"]["_normalized"] = error_dict["Error"]["Message"].split("\n")[0] + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + # AWS adds JSON position error: `\n at [Source: (String)"{"key":[["value"]]}"; line: 1, column: 10]` + paths=["$..Error.Message"] + ) + def test_validate_policy( + self, + sns_create_topic, + sns_subscription, + snapshot, + aws_client, + ): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + + def _subscribe(policy: dict): + sns_subscription( + TopicArn=topic_arn, + Protocol="sms", + Endpoint=phone_number, + Attributes={"FilterPolicy": json.dumps(policy)}, + ) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [["value"]]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-list", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"wrong-operator": True}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-operator", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"suffix": "value", "prefix": "value2"}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-two-operators", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message"]) + def test_validate_policy_string_operators( + self, + sns_create_topic, + sns_subscription, + snapshot, + aws_client, + ): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + + def _subscribe(policy: dict): + return sns_subscription( + TopicArn=topic_arn, + Protocol="sms", + Endpoint=phone_number, + Attributes={"FilterPolicy": json.dumps(policy)}, + ) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": []} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-empty-array", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"suffix": 100}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-is-numeric", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"suffix": None}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-is-none", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": {"suffix": "value"}} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-is-not-list-and-operator", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"suffix": []}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-empty-list", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"suffix": ["test", "test2"]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-list-wrong-type", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": {"suffix": "value", "prefix": "value"}} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-is-not-list-two-ops", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": {"not-an-operator": "value"}} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-is-not-list-and-no-operator", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"cidr": ["bad-filter"]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-bad-type", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"cidr": "bad-filter"}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-bad-cidr-str", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"cidr": "bad-filter/64"}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-bad-cidr-str-slash", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"cidr": "bad-/64filter"}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-bad-cidr-str-slash-2", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"cidr": "xx.11.xx/8"}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-bad-cidr-v4", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"cidr": "xxxx:db8:1234:1a00::/64"}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-bad-cidr-v6", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message"]) + def test_validate_policy_numeric_operator( + self, + sns_create_topic, + sns_subscription, + snapshot, + aws_client, + ): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + + def _subscribe(policy: dict): + sns_subscription( + TopicArn=topic_arn, + Protocol="sms", + Endpoint=phone_number, + Attributes={"FilterPolicy": json.dumps(policy)}, + ) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": []}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-empty", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": ["operator"]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-wrong-operator", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [1, "<="]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-operator-order", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": ["=", "000"]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-type", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">="]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-missing-value", e.value.response) + + # dealing with range numeric + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": ["<", 100, ">", 10]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-wrong-range-order", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">=", 1, ">", 2]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-wrong-range-operators", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">", 3, "<", 1]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-wrong-value-order", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">", 20, "<="]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-missing-range-value", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">", 20, 30]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-missing-range-operator", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">", 20, "test"]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-wrong-second-range-operator", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">", "20", "<", "30"]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-wrong-range-value-1-type", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">", 20, "<", "30"]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-wrong-range-value-2-type", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"numeric": [">", 20, "<", 30, "<", 50]}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-numeric-too-many-range", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message"]) + def test_validate_policy_exists_operator( + self, + sns_create_topic, + sns_subscription, + snapshot, + aws_client, + ): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + + def _subscribe(policy: dict): + sns_subscription( + TopicArn=topic_arn, + Protocol="sms", + Endpoint=phone_number, + Attributes={"FilterPolicy": json.dumps(policy)}, + ) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"exists": None}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-none", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"exists": "no"}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-string", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message"]) + def test_validate_policy_nested_anything_but_operator( + self, + sns_create_topic, + sns_subscription, + snapshot, + aws_client, + ): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + + def _subscribe(policy: dict): + return sns_subscription( + TopicArn=topic_arn, + Protocol="sms", + Endpoint=phone_number, + Attributes={"FilterPolicy": json.dumps(policy)}, + ) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"anything-but": {"wrong-operator": None}}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-wrong-operator", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"anything-but": {"suffix": "test"}}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-anything-but-suffix", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"anything-but": {"exists": False}}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-anything-but-exists", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [{"anything-but": {"prefix": False}}]} + _subscribe(filter_policy) + self._add_normalized_field_to_snapshot(e.value.response) + snapshot.match("error-condition-anything-but-prefix-wrong-type", e.value.response) + + # positive testing + filter_policy = {"key": [{"anything-but": {"prefix": "test-"}}]} + response = _subscribe(filter_policy) + assert "SubscriptionArn" in response + subscription_arn = response["SubscriptionArn"] + + filter_policy = {"key": [{"anything-but": ["test", "test2"]}]} + response = aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(filter_policy), + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + filter_policy = {"key": [{"anything-but": "test"}]} + response = aws_client.sns.set_subscription_attributes( + SubscriptionArn=subscription_arn, + AttributeName="FilterPolicy", + AttributeValue=json.dumps(filter_policy), + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + @markers.aws.validated + def test_policy_complexity( + self, + sns_create_topic, + sns_subscription, + snapshot, + aws_client, + ): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + + def _subscribe(policy: dict): + sns_subscription( + TopicArn=topic_arn, + Protocol="sms", + Endpoint=phone_number, + Attributes={"FilterPolicy": json.dumps(policy)}, + ) + + with pytest.raises(ClientError) as e: + filter_policy = {"key": [f"value{i}" for i in range(151)]} + _subscribe(filter_policy) + snapshot.match("error-complexity-in-one-condition", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = { + "key1": [f"value{i}" for i in range(100)], + "key2": [f"value{i}" for i in range(51)], + } + _subscribe(filter_policy) + snapshot.match("error-complexity-in-two-conditions", e.value.response) + + with pytest.raises(ClientError) as e: + filter_policy = { + "key1": ["value1"], + "key2": ["value2"], + "key3": ["value3"], + "key4": ["value4"], + "key5": ["value5"], + "key6": ["value6"], + } + _subscribe(filter_policy) + snapshot.match("error-complexity-too-many-fields", e.value.response) + + @markers.aws.validated + def test_policy_complexity_with_or( + self, + sns_create_topic, + sns_subscription, + snapshot, + aws_client, + ): + phone_number = "+123123123" + topic_arn = sns_create_topic()["TopicArn"] + + def _subscribe(policy: dict, scope: str): + attributes = {"FilterPolicy": json.dumps(policy)} + if scope: + attributes["FilterPolicyScope"] = scope + + return sns_subscription( + TopicArn=topic_arn, + Protocol="sms", + Endpoint=phone_number, + Attributes=attributes, + ) + + with pytest.raises(ClientError) as e: + # (source * metricName) + (source * metricType * metricId) + (source * metricType * spaceId) + # = (4 * 6) + (4 * 4 * 4) + (4 * 4 * 4) + # = 24 + 64 + 64 + # = 152 + filter_policy = { + "source": ["aws.cloudwatch", "aws.events", "aws.test", "aws.test2"], + "$or": [ + {"metricName": ["CPUUtilization", "ReadLatency", "t1", "t2", "t3", "t4"]}, + { + "metricType": ["MetricType", "TestType", "TestType2", "TestType3"], + "$or": [{"metricId": [1234, 4321, 5678, 9012]}, {"spaceId": [1, 2, 3, 4]}], + }, + ], + } + + _subscribe(filter_policy, scope="MessageAttributes") + snapshot.match("error-complexity-or-flat", e.value.response) + + with pytest.raises(ClientError) as e: + # ("metricName" AND ("detail"."scope" AND "detail"."source") + # OR + # ("metricName" AND ("detail"."scope" AND "detail"."type") + # OR + # ("namespace" AND ("detail"."scope" AND "detail"."source") + # OR + # ("namespace" AND ("detail"."scope" AND "detail"."type") + # (3 * 4 * 2) + (3 * 4 * 6) + (2 * 4 * 2) + (2 * 4 * 6) + # = 24 + 72 + 16 + 48 + # = 160 + filter_policy = { + "$or": [ + {"metricName": ["CPUUtilization", "ReadLatency", "TestValue"]}, + {"namespace": ["AWS/EC2", "AWS/ES"]}, + ], + "detail": { + "scope": ["Service", "Test"], + "$or": [ + {"source": ["aws.cloudwatch"]}, + {"type": ["CloudWatch Alarm State Change", "TestValue", "TestValue2"]}, + ], + }, + } + + _subscribe(filter_policy, scope="MessageBody") + snapshot.match("error-complexity-or-nested", e.value.response) + + # (source * metricName) + (source * metricType * metricId) + (source * metricType * spaceId) + # = (3 * 6) + (3 * 4 * 4) + (3 * 4 * 7) + # = 18 + 48 + 84 + # = 150 + filter_policy = { + "source": ["aws.cloudwatch", "aws.events", "aws.test"], + "$or": [ + { + "metricName": [ + "CPUUtilization", + "ReadLatency", + "TestVal", + "TestVal2", + "TestVal3", + "TestVal4", + ] + }, + { + "metricType": ["MetricType", "TestType", "TestType2", "TestType3"], + "$or": [ + {"metricId": [1234, 4321, 5678, 9012]}, + {"spaceId": [1, 2, 3, 4, 5, 6, 7]}, + ], + }, + ], + } + response = _subscribe(filter_policy, scope="MessageAttributes") + assert "SubscriptionArn" in response diff --git a/tests/aws/services/sns/test_sns_filter_policy.snapshot.json b/tests/aws/services/sns/test_sns_filter_policy.snapshot.json new file mode 100644 index 0000000000000..8f40f14baad60 --- /dev/null +++ b/tests/aws/services/sns/test_sns_filter_policy.snapshot.json @@ -0,0 +1,1880 @@ +{ + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy": { + "recorded-date": "14-05-2024, 16:51:02", + "recorded-content": { + "error-condition-list": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Match value must be String, number, true, false, or null\n at [Source: (String)\"{\"key\":[[\"value\"]]}\"; line: 1, column: 10]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Match value must be String, number, true, false, or null" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-operator": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Unrecognized match type wrong-operator\n at [Source: (String)\"{\"key\":[{\"wrong-operator\":true}]}\"; line: 1, column: 31]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Unrecognized match type wrong-operator" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-two-operators": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Only one key allowed in match expression\n at [Source: (String)\"{\"key\":[{\"suffix\":\"value\",\"prefix\":\"value2\"}]}\"; line: 1, column: 37]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Only one key allowed in match expression" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_string_operators": { + "recorded-date": "03-12-2024, 22:11:13", + "recorded-content": { + "error-condition-empty-array": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Empty arrays are not allowed\n at [Source: (String)\"{\"key\":[]}\"; line: 1, column: 10]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Empty arrays are not allowed" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-is-numeric": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string\n at [Source: (String)\"{\"key\":[{\"suffix\":100}]}\"; line: 1, column: 22]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-is-none": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string\n at [Source: (String)\"{\"key\":[{\"suffix\":null}]}\"; line: 1, column: 23]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-is-not-list-and-operator": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: \"suffix\" must be an object or an array\n at [Source: (String)\"{\"key\":{\"suffix\":\"value\"}}\"; line: 1, column: 19]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: \"suffix\" must be an object or an array" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-empty-list": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string\n at [Source: (String)\"{\"key\":[{\"suffix\":[]}]}\"; line: 1, column: 20]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-list-wrong-type": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string\n at [Source: (String)\"{\"key\":[{\"suffix\":[\"test\",\"test2\"]}]}\"; line: 1, column: 20]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: suffix match pattern must be a string" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-is-not-list-two-ops": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: \"suffix\" must be an object or an array\n at [Source: (String)\"{\"key\":{\"suffix\":\"value\",\"prefix\":\"value\"}}\"; line: 1, column: 19]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: \"suffix\" must be an object or an array" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-is-not-list-and-no-operator": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: \"not-an-operator\" must be an object or an array\n at [Source: (String)\"{\"key\":{\"not-an-operator\":\"value\"}}\"; line: 1, column: 28]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: \"not-an-operator\" must be an object or an array" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-bad-type": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: prefix match pattern must be a string\n at [Source: (String)\"{\"key\":[{\"cidr\":[\"bad-filter\"]}]}\"; line: 1, column: 18]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: prefix match pattern must be a string" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-bad-cidr-str": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Malformed CIDR, one '/' required", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Malformed CIDR, one '/' required" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-bad-cidr-str-slash": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Nonstandard IP address: bad-filter", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Nonstandard IP address: bad-filter" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-bad-cidr-str-slash-2": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Malformed CIDR, mask bits must be an integer", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Malformed CIDR, mask bits must be an integer" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-bad-cidr-v4": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Nonstandard IP address: xx.11.xx", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Nonstandard IP address: xx.11.xx" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-bad-cidr-v6": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Nonstandard IP address: xxxx:db8:1234:1a00::", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Nonstandard IP address: xxxx:db8:1234:1a00::" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_numeric_operator": { + "recorded-date": "14-05-2024, 16:51:06", + "recorded-content": { + "error-numeric-empty": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Invalid member in numeric match: ]\n at [Source: (String)\"{\"key\":[{\"numeric\":[]}]}\"; line: 1, column: 22]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Invalid member in numeric match: ]" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-wrong-operator": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Unrecognized numeric range operator: operator\n at [Source: (String)\"{\"key\":[{\"numeric\":[\"operator\"]}]}\"; line: 1, column: 32]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Unrecognized numeric range operator: operator" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-operator-order": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Invalid member in numeric match: 1\n at [Source: (String)\"{\"key\":[{\"numeric\":[1,\"<=\"]}]}\"; line: 1, column: 22]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Invalid member in numeric match: 1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-type": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Value of equals must be numeric\n at [Source: (String)\"{\"key\":[{\"numeric\":[\"=\",\"000\"]}]}\"; line: 1, column: 26]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Value of equals must be numeric" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-missing-value": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Value of >= must be numeric\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">=\"]}]}\"; line: 1, column: 26]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Value of >= must be numeric" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-wrong-range-order": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Too many elements in numeric expression\n at [Source: (String)\"{\"key\":[{\"numeric\":[\"<\",100,\">\",10]}]}\"; line: 1, column: 30]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Too many elements in numeric expression" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-wrong-range-operators": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Bad numeric range operator: >\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">=\",1,\">\",2]}]}\"; line: 1, column: 31]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Bad numeric range operator: >" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-wrong-value-order": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Bottom must be less than top\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">\",3,\"<\",1]}]}\"; line: 1, column: 33]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Bottom must be less than top" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-missing-range-value": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Value of <= must be numeric\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">\",20,\"<=\"]}]}\"; line: 1, column: 33]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Value of <= must be numeric" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-missing-range-operator": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Bad value in numeric range: 30\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">\",20,30]}]}\"; line: 1, column: 30]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Bad value in numeric range: 30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-wrong-second-range-operator": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Bad numeric range operator: test\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">\",20,\"test\"]}]}\"; line: 1, column: 34]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Bad numeric range operator: test" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-wrong-range-value-1-type": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Value of > must be numeric\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">\",\"20\",\"<\",\"30\"]}]}\"; line: 1, column: 26]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Value of > must be numeric" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-wrong-range-value-2-type": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Value of < must be numeric\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">\",20,\"<\",\"30\"]}]}\"; line: 1, column: 33]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Value of < must be numeric" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-numeric-too-many-range": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Too many terms in numeric range expression\n at [Source: (String)\"{\"key\":[{\"numeric\":[\">\",20,\"<\",30,\"<\",50]}]}\"; line: 1, column: 36]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Too many terms in numeric range expression" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_exists_operator": { + "recorded-date": "14-05-2024, 16:51:07", + "recorded-content": { + "error-condition-none": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: exists match pattern must be either true or false.\n at [Source: (String)\"{\"key\":[{\"exists\":null}]}\"; line: 1, column: 23]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: exists match pattern must be either true or false." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-string": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: exists match pattern must be either true or false.\n at [Source: (String)\"{\"key\":[{\"exists\":\"no\"}]}\"; line: 1, column: 20]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: exists match pattern must be either true or false." + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_policy_complexity": { + "recorded-date": "14-05-2024, 16:51:07", + "recorded-content": { + "error-complexity-in-one-condition": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Filter policy is too complex", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-complexity-in-two-conditions": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Filter policy is too complex", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-complexity-too-many-fields": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Filter policy can not have more than 5 keys", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_sub_filter_policy_nested_property": { + "recorded-date": "14-05-2024, 16:49:14", + "recorded-content": { + "sub-filter-policy-nested-error": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Filter policy scope MessageAttributes does not support nested filter policy", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "sub-attrs-after-setting-nested-policy": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "object": { + "key": [ + { + "prefix": "auto-" + } + ] + } + }, + "FilterPolicyScope": "MessageBody", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_policy_complexity_with_or": { + "recorded-date": "14-05-2024, 16:51:09", + "recorded-content": { + "error-complexity-or-flat": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Filter policy is too complex", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-complexity-or-nested": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Filter policy is too complex", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_set_subscription_filter_policy_scope": { + "recorded-date": "14-05-2024, 16:49:12", + "recorded-content": { + "sub-attrs-default": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs-filter-scope-body": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sub-attrs-filter-scope-error": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: FilterPolicyScope: Invalid value [RandomValue]. Please use either MessageBody or MessageAttributes", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "sub-attrs-after-setting-policy": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "attr": [ + "match-this" + ] + }, + "FilterPolicyScope": "MessageBody", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_sub_filter_policy_nested_property_constraints": { + "recorded-date": "14-05-2024, 16:49:16", + "recorded-content": { + "sub-filter-policy-nested-error-too-many-combinations": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: FilterPolicy: Filter policy is too complex", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "sub-filter-policy-max-attr-keys": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: FilterPolicy: Filter policy can not have more than 5 keys", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "sub-filter-policy-rule-no-list": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: FilterPolicy: \"key_a\" must be an object or an array\n at [Source: (String)\"{\"key_a\":\"value_one\"}\"; line: 1, column: 11]", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_filter_policy": { + "recorded-date": "14-05-2024, 16:49:29", + "recorded-content": { + "subscription-attributes": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "attr1": [ + { + "numeric": [ + ">", + 0, + "<=", + 100 + ] + } + ] + }, + "FilterPolicyScope": "MessageAttributes", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-0": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-1": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This is a test message", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-2": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This is a test message", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-attributes-2": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "attr1": [ + null, + { + "anything-but": "whatever" + } + ] + }, + "FilterPolicyScope": "MessageAttributes", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-3": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-4": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This the test message for null", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_exists_filter_policy": { + "recorded-date": "14-05-2024, 16:49:37", + "recorded-content": { + "subscription-attributes-policy-1": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "store": [ + { + "exists": true + } + ] + }, + "FilterPolicyScope": "MessageAttributes", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-0": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-1": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "message-1", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "def": { + "Type": "Number", + "Value": "99" + }, + "store": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-2": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "message-1", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "def": { + "Type": "Number", + "Value": "99" + }, + "store": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-attributes-policy-2": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "store": [ + { + "exists": false + } + ] + }, + "FilterPolicyScope": "MessageAttributes", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-3": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "message-3", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "def": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-4": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "message-3", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "def": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_exists_filter_policy_attributes_array": { + "recorded-date": "14-05-2024, 16:49:44", + "recorded-content": { + "subscription-attributes-policy": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "store": [ + "value1" + ] + }, + "FilterPolicyScope": "MessageAttributes", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-init": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-1": { + "Messages": [ + { + "Body": "message-1", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-2": { + "Messages": [ + { + "Body": "message-2", + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-3": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body[True]": { + "recorded-date": "03-12-2024, 15:01:58", + "recorded-content": { + "recv-init": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-passed-msg-0": { + "Messages": [ + { + "Body": { + "object": { + "key": "auto-test" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-passed-msg-1": { + "Messages": [ + { + "Body": { + "object": { + "key": "hardcodedvalue" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body[False]": { + "recorded-date": "03-12-2024, 15:02:10", + "recorded-content": { + "recv-init": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-passed-msg-0": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object\": {\"key\": \"auto-test\"}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-passed-msg-1": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object\": {\"key\": \"hardcodedvalue\"}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_for_batch": { + "recorded-date": "03-12-2024, 15:02:27", + "recorded-content": { + "subscription-attributes-with-filter": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "FilterPolicy": { + "attr1": [ + { + "numeric": [ + ">", + 0, + "<=", + 100 + ] + } + ] + }, + "FilterPolicyScope": "MessageAttributes", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "subscription-attributes-no-filter": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-no-filter-before-publish": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-with-filter-before-publish": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-no-filter-after-publish-ok": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This is a test message", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-with-filter-after-publish-ok": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This is a test message", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "99" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-no-filter-after-publish-ok-1": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "This is another test message", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "attr1": { + "Type": "Number", + "Value": "111" + } + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages-with-filter-after-publish-filtered": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_dot_attribute": { + "recorded-date": "03-12-2024, 15:02:51", + "recorded-content": { + "recv-init": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-nested-msg-0": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object\": {\"nested\": \"string.value\"}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-nested-msg-1": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object.nested\": \"string.value\"}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-deep-nested-msg-0": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object\": {\"nested\": {\"test\": \"string.value\"}}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-deep-nested-msg-1": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object.nested.test\": \"string.value\"}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-deep-nested-msg-2": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object.nested\": {\"test\": \"string.value\"}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "recv-deep-nested-msg-3": { + "Messages": [ + { + "Body": { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "{\"object\": {\"nested.test\": \"string.value\"}}", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_array_attributes": { + "recorded-date": "03-12-2024, 15:02:56", + "recorded-content": { + "messages-queue-0": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "headers": { + "route-to": [ + "queue1", + "queue2" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "headers": { + "route-to": [ + "queue1" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + }, + "messages-queue-1": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "headers": { + "route-to": [ + "queue1", + "queue2" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "headers": { + "route-to": [ + "queue2" + ] + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_array_of_object_attributes": { + "recorded-date": "03-12-2024, 15:02:59", + "recorded-content": { + "messages": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Records": [ + { + "eventSource": "aws:s3", + "eventName": "ObjectCreated:Put", + "s3": { + "object": [ + [ + { + "key": "auto-insurance-2314.xml", + "size": 17 + } + ] + ] + } + }, + { + "eventSource": "aws:s3", + "eventName": "ObjectRemoved:Delete", + "s3": { + "object": { + "key": "home-insurance-2314.xml", + "size": 17 + } + } + } + ] + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "Records": [ + { + "eventSource": "aws:s3", + "eventTime": "date", + "eventName": "ObjectCreated:Put", + "s3": { + "bucket": { + "name": "", + "arn": "arn::s3:::" + }, + "object": { + "key": "auto-insurance-2314.xml", + "size": 17 + } + } + } + ] + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_or_attribute": { + "recorded-date": "03-12-2024, 15:05:46", + "recorded-content": { + "messages-queue": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "metricName": "CPUUtilization", + "detail": { + "scope": "Service", + "source": "aws.cloudwatch" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "metricName": "CPUUtilization", + "detail": { + "scope": "Service", + "type": "CloudWatch Alarm State Change" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "metricName": "ReadLatency", + "detail": { + "scope": "Service", + "source": "aws.cloudwatch" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "namespace": "AWS/EC2", + "detail": { + "scope": "Service", + "source": "aws.cloudwatch" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "namespace": "AWS/ES", + "detail": { + "scope": "Service", + "source": "aws.cloudwatch" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_nested_anything_but_operator": { + "recorded-date": "15-05-2024, 14:39:32", + "recorded-content": { + "error-condition-wrong-operator": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Unsupported anything-but pattern: wrong-operator\n at [Source: (String)\"{\"key\":[{\"anything-but\":{\"wrong-operator\":null}}]}\"; line: 1, column: 47]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Unsupported anything-but pattern: wrong-operator" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-anything-but-suffix": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Unsupported anything-but pattern: suffix\n at [Source: (String)\"{\"key\":[{\"anything-but\":{\"suffix\":\"test\"}}]}\"; line: 1, column: 36]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Unsupported anything-but pattern: suffix" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-anything-but-exists": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: Unsupported anything-but pattern: exists\n at [Source: (String)\"{\"key\":[{\"anything-but\":{\"exists\":false}}]}\"; line: 1, column: 40]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: Unsupported anything-but pattern: exists" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "error-condition-anything-but-prefix-wrong-type": { + "Error": { + "Code": "InvalidParameter", + "Message": "Invalid parameter: Attributes Reason: FilterPolicy: prefix match pattern must be a string\n at [Source: (String)\"{\"key\":[{\"anything-but\":{\"prefix\":false}}]}\"; line: 1, column: 40]", + "Type": "Sender", + "_normalized": "Invalid parameter: Attributes Reason: FilterPolicy: prefix match pattern must be a string" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_empty_array_payload": { + "recorded-date": "04-12-2024, 10:22:15", + "recorded-content": { + "messages-queue": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "version": "0", + "id": "", + "detail-type": "EC2 Instance Launch Successful", + "source": "aws.autoscaling", + "account": "123456789012", + "time": "date", + "region": "my-region", + "resources": [], + "detail": { + "eventVersion": "", + "responseElements": null + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_ip_address_condition": { + "recorded-date": "04-12-2024, 10:36:46", + "recorded-content": { + "messages-queue": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "id": "1", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "my-region", + "time": "date", + "detail": { + "sourceIPAddress": "10.0.0.255" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + }, + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": { + "id": "3", + "source": "test-source", + "detail-type": "test-detail-type", + "account": "123456789012", + "region": "my-region", + "time": "date", + "detail": { + "sourceIPAddressV6": "2001:0db8:1234:1a00:0000:0000:0000:0000" + } + }, + "MD5OfBody": "", + "MessageId": "", + "ReceiptHandle": "" + } + ] + } + } + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_large_complex_payload": { + "recorded-date": "17-03-2025, 12:37:52", + "recorded-content": {} + } +} diff --git a/tests/aws/services/sns/test_sns_filter_policy.validation.json b/tests/aws/services/sns/test_sns_filter_policy.validation.json new file mode 100644 index 0000000000000..9c5a2809f8f65 --- /dev/null +++ b/tests/aws/services/sns/test_sns_filter_policy.validation.json @@ -0,0 +1,71 @@ +{ + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_exists_filter_policy": { + "last_validated_date": "2024-05-14T16:49:36+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_exists_filter_policy_attributes_array": { + "last_validated_date": "2024-05-14T16:49:44+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyAttributes::test_filter_policy": { + "last_validated_date": "2024-05-14T16:49:28+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_empty_array_payload": { + "last_validated_date": "2024-12-04T10:22:14+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_for_batch": { + "last_validated_date": "2024-12-03T15:02:26+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_ip_address_condition": { + "last_validated_date": "2024-12-04T10:36:45+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_large_complex_payload": { + "last_validated_date": "2025-03-17T12:37:51+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body[False]": { + "last_validated_date": "2024-12-03T15:02:09+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body[True]": { + "last_validated_date": "2024-12-03T15:01:57+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_array_attributes": { + "last_validated_date": "2024-12-03T15:02:55+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_array_of_object_attributes": { + "last_validated_date": "2024-12-03T15:02:58+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_dot_attribute": { + "last_validated_date": "2024-12-03T15:02:50+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyBody::test_filter_policy_on_message_body_or_attribute": { + "last_validated_date": "2024-12-03T15:05:45+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_policy_complexity": { + "last_validated_date": "2024-05-14T16:51:07+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_policy_complexity_with_or": { + "last_validated_date": "2024-05-14T16:51:08+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy": { + "last_validated_date": "2024-05-14T16:51:02+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_exists_operator": { + "last_validated_date": "2024-05-14T16:51:06+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_nested_anything_but_operator": { + "last_validated_date": "2024-05-15T14:39:32+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_numeric_operator": { + "last_validated_date": "2024-05-14T16:51:06+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyConditions::test_validate_policy_string_operators": { + "last_validated_date": "2024-12-03T22:11:13+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_set_subscription_filter_policy_scope": { + "last_validated_date": "2024-05-14T16:49:11+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_sub_filter_policy_nested_property": { + "last_validated_date": "2024-05-14T16:49:13+00:00" + }, + "tests/aws/services/sns/test_sns_filter_policy.py::TestSNSFilterPolicyCrud::test_sub_filter_policy_nested_property_constraints": { + "last_validated_date": "2024-05-14T16:49:15+00:00" + } +} diff --git a/tests/aws/services/sqs/__init__.py b/tests/aws/services/sqs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/sqs/test_sqs.py b/tests/aws/services/sqs/test_sqs.py new file mode 100644 index 0000000000000..db4cf4180f6f3 --- /dev/null +++ b/tests/aws/services/sqs/test_sqs.py @@ -0,0 +1,5342 @@ +import json +import re +import threading +import time +from queue import Empty, Queue +from threading import Timer +from typing import TYPE_CHECKING, Dict + +import pytest +import requests +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import GenericTransformer + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.services.sqs.constants import DEFAULT_MAXIMUM_MESSAGE_SIZE, SQS_UUID_STRING_SEED +from localstack.services.sqs.models import sqs_stores +from localstack.services.sqs.provider import MAX_NUMBER_OF_MESSAGES +from localstack.services.sqs.utils import parse_queue_url +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.config import ( + SECONDARY_TEST_AWS_ACCESS_KEY_ID, + SECONDARY_TEST_AWS_SECRET_ACCESS_KEY, + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_SECRET_ACCESS_KEY, +) +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.aws.arns import get_partition +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.common import poll_condition, retry, short_uid, short_uid_from_seed, to_str +from localstack.utils.strings import token_generator +from localstack.utils.urls import localstack_host +from tests.aws.services.lambda_.functions import lambda_integration +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_PYTHON + +if TYPE_CHECKING: + from mypy_boto3_sqs import SQSClient + +TEST_POLICY = """ +{ + "Version":"2012-10-17", + "Statement":[ + { + "Effect": "Allow", + "Principal": { "AWS": "*" }, + "Action": "sqs:SendMessage", + "Resource": "'$sqs_queue_arn'", + "Condition":{ + "ArnEquals":{ + "aws:SourceArn":"'$sns_topic_arn'" + } + } + } + ] +} +""" + + +def get_qsize(sqs_client, queue_url: str) -> int: + """ + Returns the integer value of the ApproximateNumberOfMessages queue attribute. + + :param sqs_client: the boto3 client + :param queue_url: the queue URL + :return: the ApproximateNumberOfMessages converted to int + """ + response = sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages"] + ) + return int(response["Attributes"]["ApproximateNumberOfMessages"]) + + +@pytest.fixture(autouse=True) +def sqs_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.sqs_api()) + + +@pytest.fixture(params=["sqs", "sqs_query"]) +def aws_sqs_client(aws_client, request: str) -> "SQSClient": + yield getattr(aws_client, request.param) + + +class TestSqsProvider: + @markers.aws.only_localstack + def test_get_queue_url_contains_localstack_host( + self, + sqs_create_queue, + monkeypatch, + aws_sqs_client, + account_id, + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "off") + + queue_name = "test-queue-" + short_uid() + + sqs_create_queue(QueueName=queue_name) + + queue_url = aws_sqs_client.get_queue_url(QueueName=queue_name)["QueueUrl"] + + host_definition = localstack_host() + # our current queue pattern looks like this, but may change going forward, or may be configurable + assert queue_url == f"http://{host_definition.host_and_port()}/{account_id}/{queue_name}" + + @markers.aws.validated + def test_list_queues(self, sqs_create_queue, aws_client): + queue_names = [ + "a-test-queue-" + short_uid(), + "a-test-queue-" + short_uid(), + "b-test-queue-" + short_uid(), + ] + + # create three queues with prefixes and collect their urls + queue_urls = [] + for name in queue_names: + sqs_create_queue(QueueName=name) + queue_url = aws_client.sqs.get_queue_url(QueueName=name)["QueueUrl"] + assert queue_url.endswith(name) + queue_urls.append(queue_url) + + # list queues with first prefix + result = aws_client.sqs.list_queues(QueueNamePrefix="a-test-queue-") + assert "QueueUrls" in result + assert len(result["QueueUrls"]) == 2 + assert queue_urls[0] in result["QueueUrls"] + assert queue_urls[1] in result["QueueUrls"] + assert queue_urls[2] not in result["QueueUrls"] + + # list queues with second prefix + result = aws_client.sqs.list_queues(QueueNamePrefix="b-test-queue-") + assert "QueueUrls" in result + assert len(result["QueueUrls"]) == 1 + assert queue_urls[0] not in result["QueueUrls"] + assert queue_urls[1] not in result["QueueUrls"] + assert queue_urls[2] in result["QueueUrls"] + + # list queues regardless of prefix prefix + result = aws_client.sqs.list_queues() + assert "QueueUrls" in result + for url in queue_urls: + assert url in result["QueueUrls"] + + # list queues with empty result + result = aws_client.sqs.list_queues(QueueNamePrefix="nonexisting-queue-") + assert "QueueUrls" not in result + + @markers.aws.validated + def test_list_queues_pagination(self, sqs_create_queue, aws_client, snapshot): + queue_list_length = 10 + # ensures test is unique and prevents conflict in case of parrallel test runs + test_output_identifier = short_uid_from_seed(SQS_UUID_STRING_SEED) + max_result_1 = 2 + max_result_2 = 10 + + queue_names = [f"{test_output_identifier}-test-queue-{i}" for i in range(queue_list_length)] + + queue_urls = [] + for name in queue_names: + sqs_create_queue(QueueName=name) + queue_url = aws_client.sqs.get_queue_url(QueueName=name)["QueueUrl"] + assert queue_url.endswith(name) + queue_urls.append(queue_url) + + list_all = aws_client.sqs.list_queues(QueueNamePrefix=test_output_identifier) + assert "QueueUrls" in list_all + assert len(list_all["QueueUrls"]) == queue_list_length + snapshot.match("list_all", list_all) + + list_two_max = aws_client.sqs.list_queues( + MaxResults=max_result_1, QueueNamePrefix=test_output_identifier + ) + assert "QueueUrls" in list_two_max + assert "NextToken" in list_two_max + assert len(list_two_max["QueueUrls"]) == max_result_1 + snapshot.match("list_two_max", list_two_max) + next_token = list_two_max["NextToken"] + + list_remaining = aws_client.sqs.list_queues( + MaxResults=max_result_2, NextToken=next_token, QueueNamePrefix=test_output_identifier + ) + assert "QueueUrls" in list_remaining + assert "NextToken" not in list_remaining + assert len(list_remaining["QueueUrls"]) == max_result_2 - max_result_1 + snapshot.match("list_remaining", list_remaining) + + snapshot.add_transformer( + snapshot.transform.regex( + r"https://sqs\.(.+?)\.amazonaws\.com", + r"http://sqs.\1.localhost.localstack.cloud:4566", + ) + ) + + url = f"http://sqs..localhost.localstack.cloud:4566/111111111111/{test_output_identifier}-test-queue-{max_result_1 - 1}" + snapshot.add_transformer( + snapshot.transform.regex( + r'("NextToken":\s*")[^"]*(")', + r"\1" + token_generator(url) + r"\2", + ) + ) + + @markers.aws.validated + def test_create_queue_and_get_attributes(self, sqs_queue, aws_sqs_client): + result = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["QueueArn", "CreatedTimestamp", "VisibilityTimeout"] + ) + assert "Attributes" in result + + attrs = result["Attributes"] + assert len(attrs) == 3 + assert "test-queue-" in attrs["QueueArn"] + assert int(float(attrs["CreatedTimestamp"])) == pytest.approx(int(time.time()), 30) + assert int(attrs["VisibilityTimeout"]) == 30, "visibility timeout is not the default value" + + @markers.aws.validated + def test_create_queue_recently_deleted(self, sqs_create_queue, monkeypatch, aws_sqs_client): + monkeypatch.setattr(config, "SQS_DELAY_RECENTLY_DELETED", True) + + name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=name) + aws_sqs_client.delete_queue(QueueUrl=queue_url) + + with pytest.raises(ClientError) as e: + sqs_create_queue(QueueName=name) + + e.match("QueueDeletedRecently") + e.match( + "You must wait 60 seconds after deleting a queue before you can create another with the same name." + ) + + @markers.aws.only_localstack + def test_create_queue_recently_deleted_cache( + self, + sqs_create_queue, + monkeypatch, + aws_sqs_client, + account_id, + region_name, + ): + # this is a white-box test for the QueueDeletedRecently timeout behavior + from localstack.services.sqs import constants + + monkeypatch.setattr(config, "SQS_DELAY_RECENTLY_DELETED", True) + monkeypatch.setattr(constants, "RECENTLY_DELETED_TIMEOUT", 1) + + name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=name) + aws_sqs_client.delete_queue(QueueUrl=queue_url) + + with pytest.raises(ClientError) as e: + sqs_create_queue(QueueName=name) + + e.match("QueueDeletedRecently") + e.match( + "You must wait 60 seconds after deleting a queue before you can create another with the same name." + ) + + time.sleep(1.5) + store = sqs_stores[account_id][region_name] + assert name in store.deleted + assert queue_url == sqs_create_queue(QueueName=name) + assert name not in store.deleted + + @markers.aws.only_localstack + def test_create_queue_recently_deleted_can_be_disabled( + self, sqs_create_queue, monkeypatch, aws_sqs_client + ): + monkeypatch.setattr(config, "SQS_DELAY_RECENTLY_DELETED", False) + + name = f"test-queue-{short_uid()}" + + queue_url = sqs_create_queue(QueueName=name) + aws_sqs_client.delete_queue(QueueUrl=queue_url) + assert queue_url == sqs_create_queue(QueueName=name) + + @markers.aws.validated + def test_send_receive_message(self, sqs_queue, aws_sqs_client): + send_result = aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="message") + + assert send_result["MessageId"] + assert send_result["MD5OfMessageBody"] == "78e731027d8fd50ed642340b7c9a63b3" + # TODO: other attributes + + receive_result = aws_sqs_client.receive_message(QueueUrl=sqs_queue) + + assert len(receive_result["Messages"]) == 1 + message = receive_result["Messages"][0] + + assert message["ReceiptHandle"] + assert message["Body"] == "message" + assert message["MessageId"] == send_result["MessageId"] + assert message["MD5OfBody"] == send_result["MD5OfMessageBody"] + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_empty_message(self, sqs_queue, snapshot, aws_sqs_client): + with pytest.raises(ClientError) as e: + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="") + + snapshot.match("send_empty_message", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_receive_max_number_of_messages(self, sqs_queue, snapshot, aws_sqs_client): + queue_url = sqs_queue + send_result = aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="message") + assert send_result["MessageId"] + + with pytest.raises(ClientError) as e: + aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=MAX_NUMBER_OF_MESSAGES + 1 + ) + + snapshot.match("send_max_number_of_messages", e.value.response) + + @markers.aws.validated + def test_receive_empty_queue(self, sqs_queue, snapshot, aws_sqs_client): + queue_url = sqs_queue + + empty_short_poll_resp = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1 + ) + snapshot.match("empty_short_poll_resp", empty_short_poll_resp) + + empty_long_poll_resp = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=1 + ) + snapshot.match("empty_long_poll_resp", empty_long_poll_resp) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_receive_wait_time_seconds(self, sqs_queue, snapshot, aws_sqs_client): + queue_url = sqs_queue + send_result_1 = aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="message") + assert send_result_1["MessageId"] + + send_result_2 = aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="message") + assert send_result_2["MessageId"] + + MAX_WAIT_TIME_SECONDS = 20 + with pytest.raises(ClientError) as e: + aws_sqs_client.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=MAX_WAIT_TIME_SECONDS + 1 + ) + snapshot.match("recieve_message_error_too_large", e.value.response) + + with pytest.raises(ClientError) as e: + aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=-1) + snapshot.match("recieve_message_error_too_small", e.value.response) + + empty_short_poll_by_default_resp = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1 + ) + snapshot.match("empty_short_poll_by_default_resp", empty_short_poll_by_default_resp) + + empty_short_poll_explicit_resp = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=0 + ) + snapshot.match("empty_short_poll_explicit_resp", empty_short_poll_explicit_resp) + + @markers.aws.validated + def test_receive_message_attributes_timestamp_types(self, sqs_queue, aws_sqs_client): + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="message") + + r0 = aws_sqs_client.receive_message( + QueueUrl=sqs_queue, VisibilityTimeout=0, AttributeNames=["All"] + ) + attrs = r0["Messages"][0]["Attributes"] + assert float(attrs["ApproximateFirstReceiveTimestamp"]).is_integer() + assert float(attrs["SentTimestamp"]).is_integer() + + assert float(attrs["SentTimestamp"]) == pytest.approx( + float(attrs["ApproximateFirstReceiveTimestamp"]), 2 + ) + + @markers.aws.validated + def test_send_receive_message_multiple_queues(self, sqs_create_queue, aws_client): + queue0 = sqs_create_queue() + queue1 = sqs_create_queue() + + aws_client.sqs.send_message(QueueUrl=queue0, MessageBody="message") + + result = aws_client.sqs.receive_message(QueueUrl=queue1) + assert "Messages" not in result or result["Messages"] == [] + + result = aws_client.sqs.receive_message(QueueUrl=queue0) + assert len(result["Messages"]) == 1 + assert result["Messages"][0]["Body"] == "message" + + @markers.aws.validated + def test_send_receive_message_encoded_content(self, sqs_create_queue, aws_sqs_client): + queue = sqs_create_queue() + aws_sqs_client.send_message(QueueUrl=queue, MessageBody='"""\r') + result = aws_sqs_client.receive_message(QueueUrl=queue) + assert len(result["Messages"]) == 1 + assert result["Messages"][0]["Body"] == '"""\r' + + @markers.aws.validated + def test_send_message_batch(self, sqs_queue, aws_sqs_client): + aws_sqs_client.send_message_batch( + QueueUrl=sqs_queue, + Entries=[ + {"Id": "1", "MessageBody": "message-0"}, + {"Id": "2", "MessageBody": "message-1"}, + ], + ) + + response0 = aws_sqs_client.receive_message(QueueUrl=sqs_queue) + response1 = aws_sqs_client.receive_message(QueueUrl=sqs_queue) + response2 = aws_sqs_client.receive_message(QueueUrl=sqs_queue) + + assert len(response0.get("Messages", [])) == 1 + assert len(response1.get("Messages", [])) == 1 + assert len(response2.get("Messages", [])) == 0 + + message0 = response0["Messages"][0] + message1 = response1["Messages"][0] + + assert message0["Body"] == "message-0" + assert message1["Body"] == "message-1" + + @markers.aws.validated + def test_send_batch_receive_multiple(self, sqs_queue, aws_sqs_client): + # send a batch, then a single message, then receive them + # Important: AWS does not guarantee the order of messages, be it within the batch or between sends + message_count = 3 + aws_sqs_client.send_message_batch( + QueueUrl=sqs_queue, + Entries=[ + {"Id": "1", "MessageBody": "message-0"}, + {"Id": "2", "MessageBody": "message-1"}, + ], + ) + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="message-2") + i = 0 + result_recv = {"Messages": []} + while len(result_recv["Messages"]) < message_count and i < message_count: + result_recv["Messages"] = result_recv["Messages"] + ( + aws_sqs_client.receive_message( + QueueUrl=sqs_queue, MaxNumberOfMessages=message_count + ).get("Messages") + ) + i += 1 + assert len(result_recv["Messages"]) == message_count + assert {result_recv["Messages"][b]["Body"] for b in range(message_count)} == { + f"message-{b}" for b in range(message_count) + } + + @markers.aws.validated + def test_send_message_batch_with_empty_list(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue() + + try: + aws_sqs_client.send_message_batch(QueueUrl=queue_url, Entries=[]) + except ClientError as e: + assert "EmptyBatchRequest" in e.response["Error"]["Code"] + assert e.response["ResponseMetadata"]["HTTPStatusCode"] in [400, 404] + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_oversized_message(self, sqs_queue, snapshot, aws_sqs_client): + with pytest.raises(ClientError) as e: + message_attributes = {"k": {"DataType": "String", "StringValue": "x"}} + message_attributes_size = len("k") + len("String") + len("x") + message_body = "a" * (DEFAULT_MAXIMUM_MESSAGE_SIZE - message_attributes_size + 1) + aws_sqs_client.send_message( + QueueUrl=sqs_queue, + MessageBody=message_body, + MessageAttributes=message_attributes, + ) + + snapshot.match("send_oversized_message", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_message_with_updated_maximum_message_size( + self, sqs_queue, snapshot, aws_sqs_client + ): + new_max_message_size = 1024 + aws_sqs_client.set_queue_attributes( + QueueUrl=sqs_queue, + Attributes={"MaximumMessageSize": str(new_max_message_size)}, + ) + + # check base case still works + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="a" * new_max_message_size) + + # check error case + with pytest.raises(ClientError) as e: + message_attributes = {"k": {"DataType": "String", "StringValue": "x"}} + message_attributes_size = len("k") + len("String") + len("x") + message_body = "a" * (new_max_message_size - message_attributes_size + 1) + aws_sqs_client.send_message( + QueueUrl=sqs_queue, + MessageBody=message_body, + MessageAttributes=message_attributes, + ) + + snapshot.match("send_oversized_message", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_message_batch_with_oversized_contents(self, sqs_queue, snapshot, aws_sqs_client): + # Send two messages, one of max message size and a second with + # message body of size 1 + with pytest.raises(ClientError) as e: + message_attributes = {"k": {"DataType": "String", "StringValue": "x"}} + message_attributes_size = len("k") + len("String") + len("x") + message_body = "a" * (DEFAULT_MAXIMUM_MESSAGE_SIZE - message_attributes_size) + aws_sqs_client.send_message_batch( + QueueUrl=sqs_queue, + Entries=[ + { + "Id": "1", + "MessageBody": message_body, + "MessageAttributes": message_attributes, + }, + {"Id": "2", "MessageBody": "a"}, + ], + ) + + snapshot.match("send_oversized_message_batch", e.value.response) + + @markers.aws.validated + def test_send_message_batch_with_oversized_contents_with_updated_maximum_message_size( + self, sqs_queue, snapshot, aws_sqs_client + ): + new_max_message_size = 2048 + aws_sqs_client.set_queue_attributes( + QueueUrl=sqs_queue, + Attributes={"MaximumMessageSize": str(new_max_message_size)}, + ) + + # batch send seems to ignore the MaximumMessageSize of the queue + message_attributes = {"k": {"DataType": "String", "StringValue": "x"}} + message_attributes_size = len("k") + len("String") + len("x") + message_body = "a" * (new_max_message_size - message_attributes_size) + response = aws_sqs_client.send_message_batch( + QueueUrl=sqs_queue, + Entries=[ + {"Id": "1", "MessageBody": message_body, "MessageAttributes": message_attributes}, + {"Id": "2", "MessageBody": "a"}, + ], + ) + + snapshot.match("send_oversized_message_batch", response) + + @markers.aws.validated + def test_send_message_to_standard_queue_with_empty_message_group_id( + self, sqs_create_queue, aws_client, snapshot + ): + queue = sqs_create_queue() + + with pytest.raises(ClientError) as e: + aws_client.sqs.send_message(QueueUrl=queue, MessageBody="message", MessageGroupId="") + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + def test_tag_untag_queue(self, sqs_create_queue, aws_sqs_client, snapshot): + queue_url = sqs_create_queue() + + # tag queue + tags = {"tag1": "value1", "tag2": "value2", "tag3": ""} + aws_sqs_client.tag_queue(QueueUrl=queue_url, Tags=tags) + + # check queue tags + response = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + snapshot.match("get-tag-1", response) + assert response["Tags"] == tags + + # remove tag1 and tag3 + aws_sqs_client.untag_queue(QueueUrl=queue_url, TagKeys=["tag1", "tag3"]) + response = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + snapshot.match("get-tag-2", response) + assert response["Tags"] == {"tag2": "value2"} + + # remove tag2 + aws_sqs_client.untag_queue(QueueUrl=queue_url, TagKeys=["tag2"]) + + response = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + snapshot.match("get-tag-after-untag", response) + assert "Tags" not in response or response["Tags"] == {} + + @markers.aws.validated + def test_tags_case_sensitive(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue() + + # tag queue + tags = {"MyTag": "value1", "mytag": "value2"} + aws_sqs_client.tag_queue(QueueUrl=queue_url, Tags=tags) + + response = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + assert response["Tags"] == tags + + @markers.aws.validated + def test_untag_queue_ignores_non_existing_tag(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue() + + # tag queue + tags = {"tag1": "value1", "tag2": "value2"} + aws_sqs_client.tag_queue(QueueUrl=queue_url, Tags=tags) + + # remove tags + aws_sqs_client.untag_queue(QueueUrl=queue_url, TagKeys=["tag1", "tag3"]) + + response = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + assert response["Tags"] == {"tag2": "value2"} + + @markers.aws.validated + def test_tag_queue_overwrites_existing_tag(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue() + + # tag queue + tags = {"tag1": "value1", "tag2": "value2"} + aws_sqs_client.tag_queue(QueueUrl=queue_url, Tags=tags) + + # overwrite tags + tags = {"tag1": "VALUE1", "tag3": "value3"} + aws_sqs_client.tag_queue(QueueUrl=queue_url, Tags=tags) + + response = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + assert response["Tags"] == {"tag1": "VALUE1", "tag2": "value2", "tag3": "value3"} + + @markers.aws.validated + def test_create_queue_with_tags(self, sqs_create_queue, aws_sqs_client): + tags = {"tag1": "value1", "tag2": "value2"} + queue_url = sqs_create_queue(tags=tags) + + response = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + assert response["Tags"] == tags + + @markers.aws.validated + def test_create_queue_without_attributes_is_idempotent(self, sqs_create_queue): + queue_name = f"queue-{short_uid()}" + + queue_url = sqs_create_queue(QueueName=queue_name) + + assert sqs_create_queue(QueueName=queue_name) == queue_url + + @markers.aws.validated + def test_create_queue_with_same_attributes_is_idempotent(self, sqs_create_queue): + queue_name = f"queue-{short_uid()}" + attributes = { + "VisibilityTimeout": "69", + } + + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + assert sqs_create_queue(QueueName=queue_name, Attributes=attributes) == queue_url + + @markers.aws.validated + def test_receive_message_wait_time_seconds_and_max_number_of_messages_does_not_block( + self, sqs_create_queue, aws_sqs_client + ): + """ + this test makes sure that `WaitTimeSeconds` does not block when messages are in the queue, even when + `MaxNumberOfMessages` is provided. + """ + queue_url = sqs_create_queue() + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar1") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar2") + + # wait for the two messages to be in the queue + assert poll_condition(lambda: get_qsize(aws_sqs_client, queue_url) == 2, timeout=10) + + then = time.time() + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=3, WaitTimeSeconds=5 + ) + took = time.time() - then + assert took < 2 # should take much less than 5 seconds + + assert len(response.get("Messages", [])) >= 1, ( + f"unexpected number of messages in {response}" + ) + + @markers.aws.validated + def test_wait_time_seconds_waits_correctly(self, sqs_queue, aws_sqs_client): + def _send_message(): + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="foobared") + + Timer(1, _send_message).start() # send message asynchronously after 1 second + response = aws_sqs_client.receive_message(QueueUrl=sqs_queue, WaitTimeSeconds=10) + + assert len(response.get("Messages", [])) == 1, ( + f"unexpected number of messages in response {response}" + ) + + @markers.aws.validated + def test_wait_time_seconds_queue_attribute_waits_correctly( + self, sqs_create_queue, aws_sqs_client + ): + queue_url = sqs_create_queue( + Attributes={ + "ReceiveMessageWaitTimeSeconds": "10", + } + ) + + def _send_message(): + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobared") + + Timer(1, _send_message).start() # send message asynchronously after 1 second + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + + assert len(response.get("Messages", [])) == 1, ( + f"unexpected number of messages in response {response}" + ) + + @markers.aws.validated + def test_create_queue_with_default_attributes_is_idempotent(self, sqs_create_queue): + queue_name = f"queue-{short_uid()}" + attributes = { + "VisibilityTimeout": "69", + "ReceiveMessageWaitTimeSeconds": "1", + } + + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + assert sqs_create_queue(QueueName=queue_name) == queue_url + + @markers.aws.validated + def test_create_fifo_queue_with_same_attributes_is_idempotent(self, sqs_create_queue): + queue_name = f"queue-{short_uid()}.fifo" + attributes = {"FifoQueue": "true"} + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + assert sqs_create_queue(QueueName=queue_name, Attributes=attributes) == queue_url + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_create_fifo_queue_with_different_attributes_raises_error( + self, + sqs_create_queue, + aws_sqs_client, + snapshot, + ): + queue_name = f"queue-{short_uid()}.fifo" + sqs_create_queue( + QueueName=queue_name, + Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "true"}, + ) + with pytest.raises(ClientError) as e: + sqs_create_queue( + QueueName=queue_name, + Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "false"}, + ) + snapshot.match("queue-already-exists", e.value.response) + + @markers.aws.validated + def test_create_standard_queue_with_fifo_attribute_raises_error( + self, sqs_create_queue, aws_sqs_client, snapshot + ): + queue_name = f"queue-{short_uid()}" + with pytest.raises(ClientError) as e: + sqs_create_queue(QueueName=queue_name, Attributes={"FifoQueue": "false"}) + snapshot.match("invalid-attribute-fifo-queue", e.value.response) + + with pytest.raises(ClientError) as e: + sqs_create_queue( + QueueName=queue_name, Attributes={"ContentBasedDeduplication": "false"} + ) + snapshot.match("invalid-attribute-content-based-deduplication", e.value.response) + + with pytest.raises(ClientError) as e: + sqs_create_queue(QueueName=queue_name, Attributes={"DeduplicationScope": "queue"}) + snapshot.match("invalid-attribute-deduplication-scope", e.value.response) + + with pytest.raises(ClientError) as e: + sqs_create_queue(QueueName=queue_name, Attributes={"FifoThroughputLimit": "perQueue"}) + snapshot.match("invalid-attribute-throughput-limit", e.value.response) + + @markers.aws.validated + def test_send_message_with_delay_0_works_for_fifo(self, sqs_create_queue, aws_sqs_client): + # see issue https://github.com/localstack/localstack/issues/6612 + queue_name = f"queue-{short_uid()}.fifo" + attributes = {"FifoQueue": "true"} + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + message_sent_hash = aws_sqs_client.send_message( + QueueUrl=queue_url, + DelaySeconds=0, + MessageBody="Hello World!", + MessageGroupId="test", + MessageDeduplicationId="42", + )["MD5OfMessageBody"] + message_received_hash = aws_sqs_client.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0 + )["Messages"][0]["MD5OfBody"] + assert message_sent_hash == message_received_hash + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_message_deduplication_id_too_long(self, sqs_create_queue, aws_client, snapshot): + # see issue https://github.com/localstack/localstack/issues/6612 + queue_name = f"queue-{short_uid()}.fifo" + attributes = {"FifoQueue": "true"} + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + aws_client.sqs.send_message( + QueueUrl=queue_url, + MessageBody="Hello World!", + MessageGroupId="test", + MessageDeduplicationId="a" * 128, + ) + + with pytest.raises(ClientError) as e: + aws_client.sqs.send_message( + QueueUrl=queue_url, + MessageBody="Hello World!", + MessageGroupId="test", + MessageDeduplicationId="a" * 129, + ) + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_message_group_id_too_long(self, sqs_create_queue, aws_client, snapshot): + # see issue https://github.com/localstack/localstack/issues/6612 + queue_name = f"queue-{short_uid()}.fifo" + attributes = {"FifoQueue": "true"} + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + aws_client.sqs.send_message( + QueueUrl=queue_url, + MessageBody="Hello World!", + MessageGroupId="a" * 128, + MessageDeduplicationId="1", + ) + + with pytest.raises(ClientError) as e: + aws_client.sqs.send_message( + QueueUrl=queue_url, + MessageBody="Hello World!", + MessageGroupId="a" * 129, + MessageDeduplicationId="2", + ) + snapshot.match("error-response", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_create_queue_with_different_attributes_raises_exception( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + queue_name = f"queue-{short_uid()}" + + # create queue with ReceiveMessageWaitTimeSeconds=2 + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes={ + "ReceiveMessageWaitTimeSeconds": "1", + "DelaySeconds": "1", + }, + ) + + # try to create a queue without attributes works + assert queue_url == sqs_create_queue(QueueName=queue_name) + + # try to create a queue with one attribute specified + assert queue_url == sqs_create_queue(QueueName=queue_name, Attributes={"DelaySeconds": "1"}) + + # try to create a queue with the same name but different ReceiveMessageWaitTimeSeconds value + with pytest.raises(ClientError) as e: + sqs_create_queue( + QueueName=queue_name, + Attributes={ + "ReceiveMessageWaitTimeSeconds": "1", + "DelaySeconds": "2", + }, + ) + snapshot.match("create_queue_01", e.value.response) + + # update the attribute of the queue + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes={"DelaySeconds": "2"}) + + # try again + assert queue_url == sqs_create_queue( + QueueName=queue_name, + Attributes={ + "ReceiveMessageWaitTimeSeconds": "1", + "DelaySeconds": "2", + }, + ) + + # try with the original request + with pytest.raises(ClientError) as e: + sqs_create_queue( + QueueName=queue_name, + Attributes={ + "ReceiveMessageWaitTimeSeconds": "1", + "DelaySeconds": "1", + }, + ) + snapshot.match("create_queue_02", e.value.response) + + @markers.aws.validated + def test_create_queue_after_internal_attributes_changes_works( + self, sqs_create_queue, aws_sqs_client + ): + queue_name = f"queue-{short_uid()}" + + queue_url = sqs_create_queue(QueueName=queue_name) + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar-1", DelaySeconds=1) + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar-2") + + assert queue_url == sqs_create_queue(QueueName=queue_name) + + @markers.aws.validated + def test_create_and_update_queue_attributes(self, sqs_create_queue, snapshot, aws_sqs_client): + queue_url = sqs_create_queue( + Attributes={ + "MessageRetentionPeriod": "604800", # Unsupported by ElasticMq, should be saved in memory + "ReceiveMessageWaitTimeSeconds": "10", + "VisibilityTimeout": "20", + } + ) + + response = aws_sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["All"]) + snapshot.match("get_queue_attributes", response) + + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, + Attributes={ + "MaximumMessageSize": "2048", + "VisibilityTimeout": "69", + "DelaySeconds": "420", + }, + ) + + response = aws_sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["All"]) + snapshot.match("get_updated_queue_attributes", response) + + @markers.aws.validated + @pytest.mark.skip(reason="see https://github.com/localstack/localstack/issues/5938") + def test_create_queue_with_default_arguments_works_with_modified_attributes( + self, sqs_create_queue, aws_sqs_client + ): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, + Attributes={ + "VisibilityTimeout": "2", + "ReceiveMessageWaitTimeSeconds": "2", + }, + ) + + # original attributes + with pytest.raises(ClientError) as e: + sqs_create_queue( + QueueName=queue_name, + Attributes={ + "VisibilityTimeout": "1", + "ReceiveMessageWaitTimeSeconds": "1", + }, + ) + e.match("QueueAlreadyExists") + + # modified attributes + assert queue_url == sqs_create_queue( + QueueName=queue_name, + Attributes={ + "VisibilityTimeout": "2", + "ReceiveMessageWaitTimeSeconds": "2", + }, + ) + + # no attributes always works + assert queue_url == sqs_create_queue(QueueName=queue_name) + + @markers.aws.validated + @pytest.mark.skip(reason="see https://github.com/localstack/localstack/issues/5938") + def test_create_queue_after_modified_attributes(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes={ + "VisibilityTimeout": "1", + "ReceiveMessageWaitTimeSeconds": "1", + }, + ) + + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, + Attributes={ + "VisibilityTimeout": "2", + "ReceiveMessageWaitTimeSeconds": "2", + }, + ) + + # original attributes + with pytest.raises(ClientError) as e: + sqs_create_queue( + QueueName=queue_name, + Attributes={ + "VisibilityTimeout": "1", + "ReceiveMessageWaitTimeSeconds": "1", + }, + ) + e.match("QueueAlreadyExists") + + # modified attributes + assert queue_url == sqs_create_queue( + QueueName=queue_name, + Attributes={ + "VisibilityTimeout": "2", + "ReceiveMessageWaitTimeSeconds": "2", + }, + ) + + # no attributes always works + assert queue_url == sqs_create_queue(QueueName=queue_name) + + @markers.aws.validated + def test_create_queue_after_send(self, sqs_create_queue, aws_sqs_client): + # checks that intrinsic queue attributes like "ApproxMessages" does not hinder queue creation + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="bared") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="baz") + + def _qsize(_url): + response = aws_sqs_client.get_queue_attributes( + QueueUrl=_url, AttributeNames=["ApproximateNumberOfMessages"] + ) + return int(response["Attributes"]["ApproximateNumberOfMessages"]) + + assert poll_condition(lambda: _qsize(queue_url) > 0, timeout=10) + + # we know that the system attribute has changed, now check whether create_queue works + assert queue_url == sqs_create_queue(QueueName=queue_name) + + @markers.aws.validated + def test_send_delay_and_wait_time(self, sqs_queue, aws_sqs_client): + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="foobar", DelaySeconds=1) + + result = aws_sqs_client.receive_message(QueueUrl=sqs_queue) + assert "Messages" not in result or result["Messages"] == [] + + result = aws_sqs_client.receive_message(QueueUrl=sqs_queue, WaitTimeSeconds=2) + assert "Messages" in result + assert len(result["Messages"]) == 1 + + @markers.aws.only_localstack + def test_approximate_number_of_messages_delayed(self, sqs_queue, aws_sqs_client): + # this test does not work against AWS in the same way, because AWS only has eventual consistency guarantees + # for the tested attributes that can take up to a minute to update. + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="ed") + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="foo", DelaySeconds=2) + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="bar", DelaySeconds=2) + + result = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, + AttributeNames=[ + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesNotVisible", + "ApproximateNumberOfMessagesDelayed", + ], + ) + assert result["Attributes"] == { + "ApproximateNumberOfMessages": "1", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "2", + } + + def _assert(): + _result = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, + AttributeNames=[ + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesNotVisible", + "ApproximateNumberOfMessagesDelayed", + ], + ) + assert _result["Attributes"] == { + "ApproximateNumberOfMessages": "3", + "ApproximateNumberOfMessagesNotVisible": "0", + "ApproximateNumberOfMessagesDelayed": "0", + } + + retry(_assert) + + @markers.aws.validated + def test_receive_after_visibility_timeout(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "1"}) + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar") + + # receive the message + result = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" in result + message_receipt_0 = result["Messages"][0] + + # message should be within the visibility timeout + result = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" not in result or result["Messages"] == [] + + # visibility timeout should have expired + result = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) + assert "Messages" in result + message_receipt_1 = result["Messages"][0] + + assert message_receipt_0["ReceiptHandle"] != message_receipt_1["ReceiptHandle"], ( + "receipt handles should be different" + ) + + @markers.aws.validated + def test_delete_after_visibility_timeout(self, sqs_create_queue, aws_sqs_client, snapshot): + timeout = 1 + queue_url = sqs_create_queue( + QueueName=f"test-{short_uid()}", Attributes={"VisibilityTimeout": f"{timeout}"} + ) + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar") + # receive the message + initial_receive = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) + assert "Messages" in initial_receive + receipt_handle = initial_receive["Messages"][0]["ReceiptHandle"] + + # exceed the visibility timeout window + time.sleep(timeout) + + aws_sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + snapshot.match( + "delete_after_timeout_queue_empty", aws_sqs_client.receive_message(QueueUrl=queue_url) + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + @markers.aws.validated + def test_fifo_delete_after_visibility_timeout(self, sqs_create_queue, aws_sqs_client, snapshot): + timeout = 1 + queue_url = sqs_create_queue( + QueueName=f"test-{short_uid()}.fifo", + Attributes={ + "VisibilityTimeout": f"{timeout}", + "FifoQueue": "True", + "ContentBasedDeduplication": "True", + }, + ) + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar", MessageGroupId="1") + # receive the message + initial_receive = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) + snapshot.match("received_sqs_message", initial_receive) + receipt_handle = initial_receive["Messages"][0]["ReceiptHandle"] + + # exceed the visibility timeout window + time.sleep(timeout) + with pytest.raises(ClientError) as e: + aws_sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + snapshot.match("delete_after_timeout_fifo", e.value.response) + + @markers.aws.validated + def test_receive_terminate_visibility_timeout(self, sqs_queue, aws_sqs_client): + queue_url = sqs_queue + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="foobar") + + result = aws_sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + assert "Messages" in result + message_receipt_0 = result["Messages"][0] + + result = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" in result + message_receipt_1 = result["Messages"][0] + + assert message_receipt_0["ReceiptHandle"] != message_receipt_1["ReceiptHandle"], ( + "receipt handles should be different" + ) + + # TODO: check if this is correct (whether receive with VisibilityTimeout = 0 is permanent) + result = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" not in result or result["Messages"] == [] + + @markers.aws.validated + def test_extend_message_visibility_timeout_set_in_queue(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "2"}) + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="test") + response = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5) + receipt = response["Messages"][0]["ReceiptHandle"] + + # update even if time expires + for _ in range(4): + time.sleep(1) + # we've waited a total of four seconds, although the visibility timeout is 2, so we are extending it + aws_sqs_client.change_message_visibility( + QueueUrl=queue_url, ReceiptHandle=receipt, VisibilityTimeout=2 + ) + assert aws_sqs_client.receive_message(QueueUrl=queue_url).get("Messages", []) == [] + + messages = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=5).get( + "Messages", [] + ) + assert messages[0]["Body"] == "test" + assert len(messages) == 1 + + @markers.aws.validated + def test_change_message_visibility_after_visibility_timeout_expiration( + self, snapshot, sqs_create_queue, aws_sqs_client + ): + queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "1"}) + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="test") + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + receipt = response["Messages"][0]["ReceiptHandle"] + time.sleep(2) + # VisibiltyTimeout was 1 and has now expired + response = aws_sqs_client.change_message_visibility( + QueueUrl=queue_url, ReceiptHandle=receipt, VisibilityTimeout=2 + ) + snapshot.match("visibility_timeout_expired", response) + + @markers.aws.validated + def test_receive_message_with_visibility_timeout_updates_timeout( + self, sqs_create_queue, aws_sqs_client + ): + queue_url = sqs_create_queue() + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="test") + + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=2, VisibilityTimeout=0 + ) + assert len(response["Messages"]) == 1 + + response = aws_sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=3) + assert len(response["Messages"]) == 1 + + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert response.get("Messages", []) == [] + + @markers.aws.validated + def test_terminate_visibility_timeout_after_receive(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue() + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="test") + + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=2, VisibilityTimeout=0 + ) + receipt_1 = response["Messages"][0]["ReceiptHandle"] + assert len(response["Messages"]) == 1 + + response = aws_sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=3) + assert len(response["Messages"]) == 1 + + aws_sqs_client.change_message_visibility( + QueueUrl=queue_url, ReceiptHandle=receipt_1, VisibilityTimeout=0 + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1) + assert len(response["Messages"]) == 1 + + @markers.aws.validated + def test_message_retention(self, sqs_create_queue, aws_client, monkeypatch): + monkeypatch.setattr(config, "SQS_ENABLE_MESSAGE_RETENTION_PERIOD", True) + # in AWS, message retention is at least 60 seconds + if is_aws_cloud(): + retention = 60 + wait_time = 5 + else: + retention = 2 + wait_time = 1 + + queue_url = sqs_create_queue(Attributes={"MessageRetentionPeriod": str(retention)}) + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="foobar1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="foobar2") + + # let messages expire + time.sleep(retention + wait_time) + + # messages should have expired + result = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert not result.get("Messages") + + @markers.aws.validated + def test_message_retention_fifo(self, sqs_create_queue, aws_client, monkeypatch): + monkeypatch.setattr(config, "SQS_ENABLE_MESSAGE_RETENTION_PERIOD", True) + # in AWS, message retention is at least 60 seconds + if is_aws_cloud(): + retention = 60 + wait_time = 5 + else: + retention = 2 + wait_time = 1 + + queue_name = f"queue-{short_uid()}.fifo" + attributes = { + "FifoQueue": "true", + "MessageRetentionPeriod": str(retention), + "ContentBasedDeduplication": "true", + } + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="foobar1", MessageGroupId="1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="foobar2", MessageGroupId="2") + + # let messages expire + time.sleep(retention + wait_time) + + # messages should have expired + result = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert not result.get("Messages") + + @markers.aws.validated + def test_message_retention_with_inflight(self, sqs_create_queue, aws_client, monkeypatch): + # tests whether an inflight message is correctly removed after it expires + monkeypatch.setattr(config, "SQS_ENABLE_MESSAGE_RETENTION_PERIOD", True) + + if is_aws_cloud(): + message_retention_period = 60 + wait_time = 5 + else: + message_retention_period = 2 + wait_time = 1 + + # by setting message_retention_period = visibility_timeout, we can keep the waiting logic simple + queue_url = sqs_create_queue( + Attributes={ + "MessageRetentionPeriod": str(message_retention_period), + "VisibilityTimeout": str(message_retention_period), + } + ) + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="foobar1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="foobar2") + + # We need to wait for a bit so that once we receive the message, the visibility timeout + # expiration happens after the message expiration via message retention period + time.sleep(wait_time) + response = aws_client.sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1) + assert response["Messages"][0]["Body"] == "foobar1" + receipt_handle = response["Messages"][0]["ReceiptHandle"] + + # all messages should be removed after the retention policy timeframe has passed + time.sleep(message_retention_period + 1) + + response = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert not response.get("Messages") + + # wait until the visibility timeout of the first receive message has expired, should be nonexistent + time.sleep(wait_time * 1.5) + response = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert not response.get("Messages") + + # try to delete the expired message + aws_client.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + @markers.aws.validated + def test_fifo_empty_message_groups_added_back_to_queue( + self, sqs_create_queue, aws_sqs_client, snapshot + ): + # https://github.com/localstack/localstack/issues/10107 + queue_name = f"queue-{short_uid()}.fifo" + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes={ + "FifoQueue": "True", + }, + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageDeduplicationId="1", + MessageGroupId="g1", + MessageBody="Message 1", + ) + resp = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("inital-fifo-receive", resp) + + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=resp["Messages"][0]["ReceiptHandle"] + ) + + # call receive on the now empty message group + resp = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("empty-fifo-receive", resp) + + # ensure FIFO queue stays functional + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageDeduplicationId="2", + MessageGroupId="g1", + MessageBody="Message 2", + ) + resp = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("final-fifo-receive", resp) + + @markers.aws.needs_fixing + @pytest.mark.skip("Needs AWS fixing and is now failing against LocalStack") + def test_delete_message_batch_from_lambda( + self, sqs_create_queue, create_lambda_function, aws_sqs_client, aws_client + ): + # issue 3671 - not recreatable + # TODO: lambda creation does not work when testing against AWS + queue_url = sqs_create_queue() + + lambda_name = f"lambda-{short_uid()}" + create_lambda_function( + func_name=lambda_name, + handler_file=TEST_LAMBDA_PYTHON, + runtime=Runtime.python3_12, + ) + delete_batch_payload = {lambda_integration.MSG_BODY_DELETE_BATCH: queue_url} + batch = [] + for i in range(4): + batch.append({"Id": str(i), "MessageBody": str(i)}) + aws_sqs_client.send_message_batch(QueueUrl=queue_url, Entries=batch) + + aws_client.lambda_.invoke( + FunctionName=lambda_name, Payload=json.dumps(delete_batch_payload), LogType="Tail" + ) + + receive_result = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" in receive_result + assert receive_result["Messages"] == [] + + @markers.aws.validated + def test_invalid_receipt_handle_should_return_error_message( + self, sqs_create_queue, aws_sqs_client + ): + # issue 3619 + queue_url = sqs_create_queue() + with pytest.raises(Exception) as e: + aws_sqs_client.change_message_visibility( + QueueUrl=queue_url, ReceiptHandle="INVALID", VisibilityTimeout=60 + ) + e.match("ReceiptHandleIsInvalid") + + @markers.aws.validated + def test_message_with_attributes_should_be_enqueued(self, sqs_create_queue, aws_sqs_client): + # issue 3737 + queue_url = sqs_create_queue() + + message_body = "test" + timestamp_attribute = {"DataType": "Number", "StringValue": "1614717034367"} + message_attributes = {"timestamp": timestamp_attribute} + response_send = aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody=message_body, MessageAttributes=message_attributes + ) + response_receive = aws_sqs_client.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ) + message = response_receive["Messages"][0] + assert message["MessageId"] == response_send["MessageId"] + assert message["MessageAttributes"] == message_attributes + + @markers.aws.validated + def test_batch_send_with_invalid_char_should_succeed(self, sqs_create_queue, aws_sqs_client): + # issue 4135 + queue_url = sqs_create_queue() + + batch = [] + for i in range(0, 9): + batch.append({"Id": str(i), "MessageBody": str(i)}) + batch.append({"Id": "9", "MessageBody": "\x01"}) + + result_send = aws_sqs_client.send_message_batch(QueueUrl=queue_url, Entries=batch) + + # check the one failed message + assert len(result_send["Failed"]) == 1 + failed = result_send["Failed"][0] + assert failed["Id"] == "9" + assert failed["Code"] == "InvalidMessageContents" + + # check successful message bodies + messages = [] + + def collect_messages(): + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=10, WaitTimeSeconds=1 + ) + messages.extend(response.get("Messages", [])) + return len(messages) + + assert poll_condition(lambda: collect_messages() >= 9, timeout=10), ( + f"gave up waiting messages, got {len(messages)} from 9" + ) + + bodies = {message["Body"] for message in messages} + assert bodies == {"0", "1", "2", "3", "4", "5", "6", "7", "8"} + + @markers.aws.only_localstack + def test_external_endpoint(self, monkeypatch, sqs_create_queue, aws_sqs_client): + external_host = "external-host" + external_port = 12345 + + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "off") + monkeypatch.setattr( + config, "LOCALSTACK_HOST", config.HostAndPort(host=external_host, port=external_port) + ) + + queue_url = sqs_create_queue() + + assert f"{external_host}:{external_port}" in queue_url + + message_body = "external_host_test" + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody=message_body) + + receive_result = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert receive_result["Messages"][0]["Body"] == message_body + + @markers.aws.only_localstack + def test_external_hostname_via_host_header(self, monkeypatch, sqs_create_queue, region_name): + """test making a request with a different external hostname/port being returned""" + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "off") + + queue_name = f"queue-{short_uid()}" + sqs_create_queue(QueueName=queue_name) + + headers = mock_aws_request_headers( + "sqs", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + ) + payload = f"Action=GetQueueUrl&QueueName={queue_name}" + + # assert regular/default queue URL is returned + url = config.external_service_url() + result = requests.post(url, data=payload, headers=headers) + assert result + content = to_str(result.content) + kwargs = {"flags": re.MULTILINE | re.DOTALL} + assert re.match(rf".*\s*{url}/[^<]+.*", content, **kwargs) + + @pytest.mark.parametrize( + argnames="json_body", + argvalues=['{"foo": "ba\rr", "foo2": "ba"r""}', json.dumps('{"foo": "ba\rr"}')], + ) + @markers.aws.validated + def test_marker_serialization_json_protocol( + self, sqs_create_queue, aws_client, aws_http_client_factory, json_body + ): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + message_body = json_body + aws_client.sqs.send_message(QueueUrl=queue_name, MessageBody=message_body) + + client = aws_http_client_factory("sqs", region="us-east-1") + + if is_aws_cloud(): + endpoint_url = "https://queue.amazonaws.com" + else: + endpoint_url = config.internal_service_url() + + response = client.get( + endpoint_url, + params={ + "Action": "ReceiveMessage", + "QueueUrl": queue_url, + "Version": "2012-11-05", + "VisibilityTimeout": "0", + }, + headers={"Accept": "application/json"}, + ) + + parsed_content = json.loads(response.content.decode("utf-8")) + + if is_aws_cloud(): + assert ( + parsed_content["ReceiveMessageResponse"]["ReceiveMessageResult"]["messages"][0][ + "Body" + ] + == message_body + ) + + # TODO: this is an error in LocalStack. Usually it should be messages[0]['Body'] + else: + assert ( + parsed_content["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"]["Body"] + == message_body + ) + client_receive_response = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert client_receive_response["Messages"][0]["Body"] == message_body + + @markers.aws.only_localstack + def test_external_host_via_header_complete_message_lifecycle( + self, monkeypatch, account_id, region_name + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "off") + + queue_name = f"queue-{short_uid()}" + + edge_url = config.internal_service_url() + headers = mock_aws_request_headers( + "sqs", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + ) + port = 12345 + hostname = "aws-local" + + url = f"{hostname}:{port}" + payload = f"Action=CreateQueue&QueueName={queue_name}" + result = requests.post(edge_url, data=payload, headers=headers) + assert result.status_code == 200 + + queue_url = f"http://{url}/{account_id}/{queue_name}" + message_body = f"test message {short_uid()}" + payload = f"Action=SendMessage&QueueUrl={queue_url}&MessageBody={message_body}" + result = requests.post(edge_url, data=payload, headers=headers) + assert result.status_code == 200 + assert "MD5" in result.text + + payload = f"Action=ReceiveMessage&QueueUrl={queue_url}&VisibilityTimeout=0" + result = requests.post(edge_url, data=payload, headers=headers) + assert result.status_code == 200 + assert message_body in result.text + + # the customer said that he used to be able to access it via "127.0.0.1" instead of "aws-local" as well + queue_url = f"http://127.0.0.1/{account_id}/{queue_name}" + + payload = f"Action=SendMessage&QueueUrl={queue_url}&MessageBody={message_body}" + result = requests.post(edge_url, data=payload, headers=headers) + assert result.status_code == 200 + assert "MD5" in result.text + + queue_url = f"http://127.0.0.1/{account_id}/{queue_name}" + + payload = f"Action=ReceiveMessage&QueueUrl={queue_url}&VisibilityTimeout=0" + result = requests.post(edge_url, data=payload, headers=headers) + assert result.status_code == 200 + assert message_body in result.text + + @markers.aws.validated + def test_fifo_message_group_visibility(self, sqs_create_queue, aws_client): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "VisibilityTimeout": "60", + "ContentBasedDeduplication": "true", + }, + ) + + queue = Queue() + + def _receive_message(): + """Worker thread for receiving messages""" + response = aws_client.sqs.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=10 + ) + if not response.get("Messages"): + return None + queue.put(response["Messages"][0]) + + # start three concurrent listeners + threading.Thread(target=_receive_message).start() + threading.Thread(target=_receive_message).start() + threading.Thread(target=_receive_message).start() + + # send a message to the queue + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-1", MessageGroupId="1") + + # one worker should return immediately with the message and put the message group into "inflight" + message = queue.get(timeout=2) + assert message["Body"] == "message-1" + + # sending new messages to the message group should not modify its visibility, so the message group is still + # in inflight mode, even after waiting 2 seconds on the message. + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-2", MessageGroupId="1") + with pytest.raises(Empty): + # if the queue is not empty, it means one of the threads received a message when it shouldn't + queue.get(timeout=2) + + # now we delete the original message, which should make the group visible immediately + aws_client.sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"]) + message = queue.get(timeout=2) + assert message["Body"] == "message-2" + + @markers.aws.validated + def test_fifo_messages_in_order_after_timeout(self, sqs_create_queue, aws_sqs_client): + # issue 4287 + queue_name = f"queue-{short_uid()}.fifo" + timeout = 1 + attributes = {"FifoQueue": "true", "VisibilityTimeout": f"{timeout}"} + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + for i in range(3): + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=f"message-{i}", + MessageGroupId="1", + MessageDeduplicationId=f"{i}", + ) + + def receive_and_check_order(): + result_receive = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=10 + ) + for j in range(3): + assert result_receive["Messages"][j]["Body"] == f"message-{j}" + + receive_and_check_order() + time.sleep(timeout + 1) + receive_and_check_order() + + @markers.aws.validated + def test_fifo_receive_message_group_id_ordering(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + }, + ) + + # add message to group 1 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="group-1" + ) + # we interleave one message in group 2 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="group-2" + ) + # and then continue with group 1 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m3", MessageGroupId="group-1" + ) + + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=10) + + # even though we sent a message from group 2 in between messages of group 1, the messages arrive in + # order of the message groups + assert response["Messages"][0]["Body"] == "g1-m1" + assert response["Messages"][1]["Body"] == "g1-m2" + assert response["Messages"][2]["Body"] == "g1-m3" + assert response["Messages"][3]["Body"] == "g2-m1" + + @markers.aws.validated + def test_fifo_receive_message_visibility_timeout_shared_in_group( + self, sqs_create_queue, aws_sqs_client + ): + timeout = 1 + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "VisibilityTimeout": f"{timeout}", + "ContentBasedDeduplication": "true", + }, + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m3", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m4", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="group-2" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g3-m1", MessageGroupId="group-3" + ) + + # we receive 2 messages of the 3 in the first message group + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=2) + assert response["Messages"][0]["Body"] == "g1-m1" + assert response["Messages"][1]["Body"] == "g1-m2" + + # the entire group 1 is affected by the visibility timeout, so the next receive returns a message + # of group 2 + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1) + assert response["Messages"][0]["Body"] == "g2-m1" + + # let the visibility timeout expire + time.sleep(timeout + 1) + + # now we try to fetch all messages from the queue. since there are no guarantees about the ordering of + # message groups themselves, multiple orderings are valid now + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=10, VisibilityTimeout=0 + ) + ordering = [message["Body"] for message in response["Messages"]] + # there's a race that can cause both orderings to happen, but both are valid + assert ordering in ( + ["g3-m1", "g1-m1", "g1-m2", "g1-m3", "g1-m4", "g2-m1"], # aws and localstack + ["g3-m1", "g2-m1", "g1-m1", "g1-m2", "g1-m3", "g1-m4"], # localstack + ) + + @markers.aws.validated + def test_fifo_receive_message_with_zero_visibility_timeout( + self, sqs_create_queue, aws_sqs_client + ): + # this test shows that receiving messages from a fifo queue with visibility timeout = 0 terminates the + # group's visibility correctly. + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + }, + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="group-1" + ) + # interleave g2 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="group-2" + ) + # interleave g2 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m2", MessageGroupId="group-2" + ) + # interleave g3 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g3-m1", MessageGroupId="group-3" + ) + # send another message to g1 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + + # we receive messages from the first two groups with a visibility timeout of 0, ordering is preserved + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=3, VisibilityTimeout=0 + ) + assert response["Messages"][0]["Body"] == "g1-m1" + assert response["Messages"][1]["Body"] == "g1-m2" + assert response["Messages"][2]["Body"] == "g2-m1" + + # now we try to fetch all messages from the queue. since there are no guarantees about the ordering of + # message groups themselves, multiple orderings are valid now + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=10, VisibilityTimeout=0 + ) + ordering = [message["Body"] for message in response["Messages"]] + assert ordering == ["g3-m1", "g1-m1", "g1-m2", "g2-m1", "g2-m2"] + + @markers.aws.validated + def test_fifo_message_group_visibility_after_terminate_visibility_timeout( + self, sqs_create_queue, aws_sqs_client + ): + """ + This test demonstrates that + + 1. terminating the visibility timeout of a message in a group does not affect the visibility of + other messages in the group + 2. a message group becomes visible as soon as a message within it has become visible again + """ + queue_name = f"queue-{short_uid()}.fifo" + attributes = { + "FifoQueue": "true", + "VisibilityTimeout": "30", + "ContentBasedDeduplication": "true", + } + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="group-2" + ) + + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=2) + assert len(response["Messages"]) == 2 + assert response["Messages"][0]["Body"] == "g1-m1" + assert response["Messages"][1]["Body"] == "g1-m2" + + # both messages from group 1 have a visibility timeout of 30 + # we now terminate the visibility timeout of message 1 + aws_sqs_client.change_message_visibility( + QueueUrl=queue_url, + ReceiptHandle=response["Messages"][0]["ReceiptHandle"], + VisibilityTimeout=0, + ) + + # when we now try to receive 3 messages, we get messages from both groups, but only the visible ones + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=3, WaitTimeSeconds=2 + ) + assert response["Messages"][0]["Body"] == "g2-m1" + assert response["Messages"][1]["Body"] == "g1-m1" + assert len(response["Messages"]) == 2 + + @markers.aws.validated + def test_fifo_message_group_visibility_after_change_message_visibility( + self, sqs_create_queue, aws_sqs_client + ): + """ + This test demonstrates that + + 1. changing the visibility timeout of a message in a group does not affect the visibility of + other messages in the group + 2. a message group becomes visible as soon as a message within it has become visible again + """ + queue_name = f"queue-{short_uid()}.fifo" + attributes = { + "FifoQueue": "true", + "VisibilityTimeout": "30", + "ContentBasedDeduplication": "true", + } + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="group-2" + ) + + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=2) + assert len(response["Messages"]) == 2 + assert response["Messages"][0]["Body"] == "g1-m1" + assert response["Messages"][1]["Body"] == "g1-m2" + + # both messages from group 1 have a visibility timeout of 30 + # we now change the visibility timeout of message 1 to 1 + aws_sqs_client.change_message_visibility( + QueueUrl=queue_url, + ReceiptHandle=response["Messages"][0]["ReceiptHandle"], + VisibilityTimeout=1, + ) + + # let the updated visibility timeout expire + time.sleep(2) + + # when we now try to receive 3 messages, we get messages from both groups, but only the visible ones + # g1-m2 still has a visibility timeout of 30 + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=3, + ) + assert response["Messages"][0]["Body"] == "g2-m1" + assert response["Messages"][1]["Body"] == "g1-m1" + assert len(response["Messages"]) == 2 + + @markers.aws.validated + def test_fifo_message_group_visibility_after_delete(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + }, + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="group-1" + ) + # we interleave a message from group 2 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="group-2" + ) + # continue with group 1 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + # we interleave another a message from group 2 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m2", MessageGroupId="group-2" + ) + # continue with group 1 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m3", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m4", MessageGroupId="group-1" + ) + # add group 3 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g3-m1", MessageGroupId="group-3" + ) + + # we receive two messages from group 1 + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=2) + assert response["Messages"][0]["Body"] == "g1-m1" + assert response["Messages"][1]["Body"] == "g1-m2" + + # we delete all in-flight messages we received from group 1, which will make that group visible again + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][1]["ReceiptHandle"] + ) + + # draining the queue should include the message from group 1 we didn't delete earlier + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=10, + ) + ordering = [message["Body"] for message in response["Messages"]] + + # both are in principle valid orderings. it's not clear how this ordering in AWS happens + assert ordering in ( + ["g2-m1", "g2-m2", "g1-m3", "g1-m4", "g3-m1"], # aws + ["g2-m1", "g2-m2", "g3-m1", "g1-m3", "g1-m4"], # localstack + ) + + @markers.aws.validated + def test_fifo_message_group_visibility_after_partial_delete( + self, sqs_create_queue, aws_sqs_client + ): + # this is almost the same test case as the one above, only that it doesn't fully delete the messages + # it received so the message group remains in-flight. + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + }, + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="group-1" + ) + # we interleave a message from group 2 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="group-2" + ) + # continue with group 1 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + # we interleave another a message from group 2 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g2-m2", MessageGroupId="group-2" + ) + # continue with group 1 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m3", MessageGroupId="group-1" + ) + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g1-m4", MessageGroupId="group-1" + ) + # add group 3 + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="g3-m1", MessageGroupId="group-3" + ) + + # we receive two messages from group 1 (note that group 2 is not in there) + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=2) + assert response["Messages"][0]["Body"] == "g1-m1" + assert response["Messages"][1]["Body"] == "g1-m2" + + # we delete only one in-flight messages we received from group 1 + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + # draining the queue should now not the message from group 1, since it's not yet visible + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=10, + ) + ordering = [message["Body"] for message in response["Messages"]] + assert ordering == ["g2-m1", "g2-m2", "g3-m1"] + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_fifo_queue_send_message_with_delay_seconds_fails( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "true"}, + ) + + with pytest.raises(ClientError) as e: + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="message-1", MessageGroupId="1", DelaySeconds=2 + ) + + snapshot.match("send_message", e.value.response) + + @markers.aws.validated + def test_fifo_queue_send_message_with_delay_on_queue_works( + self, sqs_create_queue, aws_sqs_client + ): + delay_seconds = 2 + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + "DelaySeconds": str(delay_seconds), + }, + ) + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="message-1", MessageGroupId="1") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="message-2", MessageGroupId="1") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="message-3", MessageGroupId="1") + + response = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1) + assert response.get("Messages", []) == [] + + time.sleep(delay_seconds + 1) + + response = aws_sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=3) + messages = response["Messages"] + assert len(messages) == 3 + + assert messages[0]["Body"] == "message-1" + assert messages[1]["Body"] == "message-2" + assert messages[2]["Body"] == "message-3" + + @markers.aws.validated + def test_fifo_message_attributes(self, sqs_create_queue, snapshot, aws_sqs_client): + snapshot.add_transformer(snapshot.transform.sqs_api()) + + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + "VisibilityTimeout": "0", + "DeduplicationScope": "messageGroup", + "FifoThroughputLimit": "perMessageGroupId", + }, + ) + + response = aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="message-body-1", + MessageGroupId="group-1", + MessageDeduplicationId="dedup-1", + ) + snapshot.match("send_message", response) + + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, AttributeNames=["All"], WaitTimeSeconds=10 + ) + snapshot.match("receive_message_0", response) + # makes sure that attributes are mutated correctly + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, AttributeNames=["All"], WaitTimeSeconds=10 + ) + snapshot.match("receive_message_1", response) + + @markers.aws.validated + def test_fifo_approx_number_of_messages(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + }, + ) + + assert get_qsize(aws_sqs_client, queue_url) == 0 + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="1") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="g1-m2", MessageGroupId="1") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="g1-m3", MessageGroupId="1") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="g2-m1", MessageGroupId="2") + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="g3-m1", MessageGroupId="3") + + assert get_qsize(aws_sqs_client, queue_url) == 5 + + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=4, WaitTimeSeconds=1 + ) + + if is_aws_cloud(): + time.sleep(5) + + assert get_qsize(aws_sqs_client, queue_url) == 1 + + for message in response["Messages"]: + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"] + ) + + if is_aws_cloud(): + time.sleep(5) + + assert get_qsize(aws_sqs_client, queue_url) == 1 + + @markers.aws.validated + @pytest.mark.skip(reason="not implemented in localstack at the moment") + def test_fifo_delete_message_with_expired_receipt_handle( + self, sqs_create_queue, aws_sqs_client, snapshot + ): + timeout = 1 + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "VisibilityTimeout": f"{timeout}", + "ContentBasedDeduplication": "true", + }, + ) + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="g1-m1", MessageGroupId="1") + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + receipt_handle = response["Messages"][0]["ReceiptHandle"] + + snapshot.match("response", response) + + # let the timeout expire + time.sleep(timeout + 1) + # try to remove the message with the old receipt handle + with pytest.raises(ClientError) as e: + aws_sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + snapshot.match("error", e.value.response) + + @markers.aws.validated + def test_fifo_set_content_based_deduplication_strategy( + self, sqs_create_queue, aws_sqs_client, snapshot + ): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "SqsManagedSseEnabled": "true", + "ContentBasedDeduplication": "true", + }, + ) + + snapshot.match( + "before-update", + aws_sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["All"]), + ) + + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, Attributes={"ContentBasedDeduplication": "false"} + ) + + snapshot.match( + "after-update", + aws_sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["All"]), + ) + + @markers.aws.validated + def test_list_queue_tags(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + tags = {"testTag1": "test1", "testTag2": "test2"} + + aws_sqs_client.tag_queue(QueueUrl=queue_url, Tags=tags) + tag_list = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + assert tags == tag_list["Tags"] + + @markers.aws.validated + def test_queue_list_nonexistent_tags(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + tag_list = aws_sqs_client.list_queue_tags(QueueUrl=queue_url) + + assert "Tags" not in tag_list["ResponseMetadata"].keys() + + @markers.aws.validated + def test_publish_get_delete_message(self, sqs_create_queue, aws_sqs_client): + # visibility part handled by test_receive_terminate_visibility_timeout + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + message_body = "test message" + result_send = aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody=message_body) + + result_recv = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert result_recv["Messages"][0]["MessageId"] == result_send["MessageId"] + + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=result_recv["Messages"][0]["ReceiptHandle"] + ) + result_recv = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" not in result_recv or result_recv["Messages"] == [] + + @markers.aws.validated + def test_delete_message_deletes_with_change_visibility_timeout( + self, sqs_create_queue, aws_sqs_client + ): + # Old name: test_delete_message_deletes_visibility_agnostic + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + message_id = aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="test")[ + "MessageId" + ] + result_recv = aws_sqs_client.receive_message(QueueUrl=queue_url) + result_follow_up = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert result_recv["Messages"][0]["MessageId"] == message_id + assert "Messages" not in result_follow_up or result_follow_up["Messages"] == [] + + receipt_handle = result_recv["Messages"][0]["ReceiptHandle"] + aws_sqs_client.change_message_visibility( + QueueUrl=queue_url, ReceiptHandle=receipt_handle, VisibilityTimeout=0 + ) + + # check if the new timeout enables instant re-receiving, to ensure the message was deleted + result_recv = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert result_recv["Messages"][0]["MessageId"] == message_id + + receipt_handle = result_recv["Messages"][0]["ReceiptHandle"] + aws_sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + result_follow_up = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" not in result_follow_up or result_follow_up["Messages"] == [] + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_too_many_entries_in_batch_request(self, sqs_create_queue, snapshot, aws_sqs_client): + message_count = 20 + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + message_batch = [ + { + "Id": f"message-{i}", + "MessageBody": f"messageBody-{i}", + } + for i in range(message_count) + ] + + with pytest.raises(ClientError) as e: + aws_sqs_client.send_message_batch(QueueUrl=queue_url, Entries=message_batch) + snapshot.match("test_too_many_entries_in_batch_request", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_invalid_batch_id(self, sqs_create_queue, snapshot, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + message_batch = [ + { + "Id": f"message:{(batch_id := short_uid())}", + "MessageBody": f"messageBody-{batch_id}", + } + ] + with pytest.raises(ClientError) as e: + aws_sqs_client.send_message_batch(QueueUrl=queue_url, Entries=message_batch) + snapshot.match("test_invalid_batch_id", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_batch_missing_deduplication_id_for_fifo_queue( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "false", + }, + ) + message_batch = [ + { + "Id": f"message-{short_uid()}", + "MessageBody": "messageBody", + "MessageGroupId": "test-group", + "MessageDeduplicationId": f"dedup-{short_uid()}", + }, + { + "Id": f"message-{short_uid()}", + "MessageBody": "messageBody", + "MessageGroupId": "test-group", + }, + ] + with pytest.raises(ClientError) as e: + aws_sqs_client.send_message_batch(QueueUrl=queue_url, Entries=message_batch) + snapshot.match("test_missing_deduplication_id_for_fifo_queue", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Failed..Code", "$..Failed..Message"]) + def test_send_batch_message_size(self, sqs_create_queue, snapshot, aws_client): + # message and code are slightly different because of the way SQS exception serialization works + queue_url = sqs_create_queue( + Attributes={ + "MaximumMessageSize": "1024", + }, + ) + message_batch = [ + {"Id": "a4cff0d1-1961-44bd-ae53-c6d5ed71ed08", "MessageBody": "a" * 1024}, + {"Id": "35b535ed-b76a-4ebd-b749-6eb35cdb55ee", "MessageBody": "a" * 1025}, + ] + response = aws_client.sqs.send_message_batch(QueueUrl=queue_url, Entries=message_batch) + snapshot.match("send-message-batch-result", response) + + @markers.parity.aws_validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_batch_missing_message_group_id_for_fifo_queue( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "false", + }, + ) + message_batch = [ + { + "Id": f"message-{short_uid()}", + "MessageBody": "messageBody", + "MessageGroupId": "test-group", + "MessageDeduplicationId": f"dedup-{short_uid()}", + }, + { + "Id": f"message-{short_uid()}", + "MessageBody": "messageBody", + "MessageDeduplicationId": f"dedup-{short_uid()}", + }, + ] + with pytest.raises(ClientError) as e: + aws_sqs_client.send_message_batch(QueueUrl=queue_url, Entries=message_batch) + snapshot.match("test_missing_message_group_id_for_fifo_queue", e.value.response) + + @markers.aws.validated + def test_publish_get_delete_message_batch(self, sqs_create_queue, aws_sqs_client): + message_count = 10 + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + message_batch = [ + { + "Id": f"message-{i}", + "MessageBody": f"messageBody-{i}", + } + for i in range(message_count) + ] + + result_send_batch = aws_sqs_client.send_message_batch( + QueueUrl=queue_url, Entries=message_batch + ) + successful = result_send_batch["Successful"] + assert len(successful) == len(message_batch) + + result_recv = [] + i = 0 + while len(result_recv) < message_count and i < message_count: + result = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=message_count + ).get("Messages", []) + if result: + result_recv.extend(result) + i += 1 + + assert len(result_recv) == message_count + + ids_sent = set() + ids_received = set() + for i in range(message_count): + ids_sent.add(successful[i]["MessageId"]) + ids_received.add((result_recv[i]["MessageId"])) + + assert ids_sent == ids_received + + delete_entries = [ + {"Id": message["MessageId"], "ReceiptHandle": message["ReceiptHandle"]} + for message in result_recv + ] + aws_sqs_client.delete_message_batch(QueueUrl=queue_url, Entries=delete_entries) + confirmation = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=message_count + ) + assert "Messages" not in confirmation or confirmation["Messages"] == [] + + @markers.aws.validated + @pytest.mark.parametrize( + argnames="invalid_message_id", argvalues=["", "testLongId" * 10, "invalid:id"] + ) + def test_delete_message_batch_invalid_msg_id( + self, invalid_message_id, sqs_create_queue, snapshot, aws_sqs_client + ): + self._add_error_detail_transformer(snapshot) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + delete_entries = [{"Id": invalid_message_id, "ReceiptHandle": "testHandle1"}] + with pytest.raises(ClientError) as e: + aws_sqs_client.delete_message_batch(QueueUrl=queue_url, Entries=delete_entries) + snapshot.match("error_response", e.value.response) + + @markers.aws.validated + def test_delete_message_batch_with_too_large_batch( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + self._add_error_detail_transformer(snapshot) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + message_batch = [ + { + "Id": f"message-{i}", + "MessageBody": f"messageBody-{i}", + } + for i in range(MAX_NUMBER_OF_MESSAGES) + ] + + result_send_batch = aws_sqs_client.send_message_batch( + QueueUrl=queue_url, Entries=message_batch + ) + successful = result_send_batch["Successful"] + assert len(successful) == len(message_batch) + + result_send_batch = aws_sqs_client.send_message_batch( + QueueUrl=queue_url, Entries=message_batch + ) + successful = result_send_batch["Successful"] + assert len(successful) == len(message_batch) + + result_recv = [] + target_size = 2 * MAX_NUMBER_OF_MESSAGES + + def _receive_all_messages(): + result_recv.extend( + aws_sqs_client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=min(MAX_NUMBER_OF_MESSAGES, target_size - len(result_recv)), + )["Messages"] + ) + assert len(result_recv) == target_size + + retry(_receive_all_messages, retries=7, sleep=0.5) + + delete_entries = [ + {"Id": str(i), "ReceiptHandle": msg["ReceiptHandle"]} + for i, msg in enumerate(result_recv) + ] + with pytest.raises(ClientError) as e: + aws_sqs_client.delete_message_batch(QueueUrl=queue_url, Entries=delete_entries) + snapshot.match("error_response", e.value.response) + + @markers.aws.validated + def test_change_message_visibility_batch_with_too_large_batch( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + self._add_error_detail_transformer(snapshot) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + message_batch = [ + { + "Id": f"message-{i}", + "MessageBody": f"messageBody-{i}", + } + for i in range(MAX_NUMBER_OF_MESSAGES) + ] + + result_send_batch = aws_sqs_client.send_message_batch( + QueueUrl=queue_url, Entries=message_batch + ) + successful = result_send_batch["Successful"] + assert len(successful) == len(message_batch) + + result_send_batch = aws_sqs_client.send_message_batch( + QueueUrl=queue_url, Entries=message_batch + ) + successful = result_send_batch["Successful"] + assert len(successful) == len(message_batch) + + result_recv = [] + target_size = 2 * MAX_NUMBER_OF_MESSAGES + + def _receive_all_messages(): + result_recv.extend( + aws_sqs_client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=min(MAX_NUMBER_OF_MESSAGES, target_size - len(result_recv)), + )["Messages"] + ) + assert len(result_recv) == target_size + + retry(_receive_all_messages, retries=7, sleep=0.5) + + change_visibility_entries = [ + {"Id": str(i), "ReceiptHandle": msg["ReceiptHandle"], "VisibilityTimeout": 123} + for i, msg in enumerate(result_recv) + ] + with pytest.raises(ClientError) as e: + aws_sqs_client.change_message_visibility_batch( + QueueUrl=queue_url, Entries=change_visibility_entries + ) + snapshot.match("error_response", e.value.response) + + @markers.aws.validated + def test_create_and_send_to_fifo_queue(self, sqs_create_queue, aws_sqs_client): + # Old name: test_create_fifo_queue + queue_name = f"queue-{short_uid()}.fifo" + attributes = {"FifoQueue": "true"} + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + # it should preserve .fifo in the queue name + assert queue_name in queue_url + + message_id = aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="test", + MessageDeduplicationId=f"dedup-{short_uid()}", + MessageGroupId="test_group", + )["MessageId"] + + result_recv = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert result_recv["Messages"][0]["MessageId"] == message_id + + @markers.aws.validated + def test_fifo_queue_requires_suffix(self, sqs_create_queue): + queue_name = f"invalid-{short_uid()}" + attributes = {"FifoQueue": "true"} + + with pytest.raises(Exception) as e: + sqs_create_queue(QueueName=queue_name, Attributes=attributes) + e.match("InvalidParameterValue") + + @markers.aws.validated + def test_standard_queue_cannot_have_fifo_suffix(self, sqs_create_queue): + queue_name = f"queue-{short_uid()}.fifo" + with pytest.raises(Exception) as e: + sqs_create_queue(QueueName=queue_name) + e.match("InvalidParameterValue") + + @pytest.mark.skip + @markers.aws.validated + def test_redrive_policy_attribute_validity( + self, sqs_create_queue, sqs_get_queue_arn, aws_sqs_client + ): + dl_queue_name = f"dl-queue-{short_uid()}" + dl_queue_url = sqs_create_queue(QueueName=dl_queue_name) + dl_target_arn = sqs_get_queue_arn(dl_queue_url) + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + valid_max_receive_count = "42" + invalid_max_receive_count = "invalid" + + with pytest.raises(Exception) as e: + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, + Attributes={"RedrivePolicy": json.dumps({"deadLetterTargetArn": dl_target_arn})}, + ) + e.match("InvalidParameterValue") + + with pytest.raises(Exception) as e: + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, + Attributes={ + "RedrivePolicy": json.dumps({"maxReceiveCount": valid_max_receive_count}) + }, + ) + e.match("InvalidParameterValue") + + _invalid_redrive_policy = { + "deadLetterTargetArn": dl_target_arn, + "maxReceiveCount": invalid_max_receive_count, + } + + with pytest.raises(Exception) as e: + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, + Attributes={"RedrivePolicy": json.dumps(_invalid_redrive_policy)}, + ) + e.match("InvalidParameterValue") + + _valid_redrive_policy = { + "deadLetterTargetArn": dl_target_arn, + "maxReceiveCount": valid_max_receive_count, + } + + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, Attributes={"RedrivePolicy": json.dumps(_valid_redrive_policy)} + ) + + @markers.aws.validated + def test_set_empty_redrive_policy(self, sqs_create_queue, sqs_get_queue_arn, aws_sqs_client): + dl_queue_name = f"dl-queue-{short_uid()}" + dl_queue_url = sqs_create_queue(QueueName=dl_queue_name) + dl_target_arn = sqs_get_queue_arn(dl_queue_url) + + attributes = {"RedrivePolicy": ""} + aws_sqs_client.set_queue_attributes(QueueUrl=dl_queue_name, Attributes=attributes) + + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=dl_queue_name, AttributeNames=["All"] + )["Attributes"] + assert "RedrivePolicy" not in attributes.keys() + + # check if this behaviour holds on existing Policies as well + _valid_redrive_policy = { + "deadLetterTargetArn": dl_target_arn, + "maxReceiveCount": "42", + } + aws_sqs_client.set_queue_attributes( + QueueUrl=dl_queue_url, Attributes={"RedrivePolicy": json.dumps(_valid_redrive_policy)} + ) + + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=dl_queue_url, AttributeNames=["All"] + )["Attributes"] + assert dl_target_arn in attributes["RedrivePolicy"] + + attributes = {"RedrivePolicy": ""} + aws_sqs_client.set_queue_attributes(QueueUrl=dl_queue_url, Attributes=attributes) + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=dl_queue_url, AttributeNames=["All"] + )["Attributes"] + assert "RedrivePolicy" not in attributes.keys() + + @markers.aws.validated + @pytest.mark.skip(reason="behavior not implemented yet") + def test_invalid_dead_letter_arn_rejected_before_lookup(self, sqs_create_queue, snapshot): + dl_dummy_arn = "dummy" + max_receive_count = 42 + _redrive_policy = { + "deadLetterTargetArn": dl_dummy_arn, + "maxReceiveCount": max_receive_count, + } + with pytest.raises(ClientError) as e: + sqs_create_queue(Attributes={"RedrivePolicy": json.dumps(_redrive_policy)}) + + snapshot.match("error_response", e.value.response) + + @markers.aws.validated + def test_set_queue_policy(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + attributes = {"Policy": TEST_POLICY} + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + + # accessing the policy generally and specifically + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["All"] + )["Attributes"] + policy = json.loads(attributes["Policy"]) + assert "sqs:SendMessage" == policy["Statement"][0]["Action"] + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["Policy"] + )["Attributes"] + policy = json.loads(attributes["Policy"]) + assert "sqs:SendMessage" == policy["Statement"][0]["Action"] + + @markers.aws.validated + def test_set_empty_queue_policy(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + attributes = {"Policy": ""} + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["All"] + )["Attributes"] + assert "Policy" not in attributes.keys() + + # check if this behaviour holds on existing Policies as well + attributes = {"Policy": TEST_POLICY} + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["All"] + )["Attributes"] + assert "sqs:SendMessage" in attributes["Policy"] + + attributes = {"Policy": ""} + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["All"] + )["Attributes"] + assert "Policy" not in attributes.keys() + + @markers.aws.validated + def test_send_message_with_attributes(self, sqs_create_queue, aws_sqs_client): + # Old name: test_send_message_attributes + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + attributes = { + "attr1": {"StringValue": "test1", "DataType": "String"}, + "attr2": {"StringValue": "test2", "DataType": "String"}, + } + result_send = aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="test", MessageAttributes=attributes + ) + + result_receive = aws_sqs_client.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ) + messages = result_receive["Messages"] + + assert messages[0]["MessageId"] == result_send["MessageId"] + assert messages[0]["MessageAttributes"] == attributes + assert messages[0]["MD5OfMessageAttributes"] == result_send["MD5OfMessageAttributes"] + + @markers.aws.validated + def test_send_message_with_binary_attributes(self, sqs_create_queue, snapshot, aws_sqs_client): + # Old name: test_send_message_attributes + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + attributes = { + "attr1": { + "BinaryValue": b"traceparent\x1e00-774062d6c37081a5a0b9b5b88e30627c-2d2482211f6489da-01", + "DataType": "Binary", + }, + } + result_send = aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="test", MessageAttributes=attributes + ) + + result_receive = aws_sqs_client.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ) + messages = result_receive["Messages"] + snapshot.match("binary-attrs-msg", result_receive) + + assert messages[0]["MessageId"] == result_send["MessageId"] + assert messages[0]["MessageAttributes"] == attributes + assert messages[0]["MD5OfMessageAttributes"] == result_send["MD5OfMessageAttributes"] + + @markers.aws.validated + def test_sent_message_retains_attributes_after_receive(self, sqs_create_queue, aws_sqs_client): + # Old name: test_send_message_retains_attributes + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + attributes = {"attr1": {"StringValue": "test1", "DataType": "String"}} + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="test", MessageAttributes=attributes + ) + + # receive should not interfere with message attributes + aws_sqs_client.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, MessageAttributeNames=["All"] + ) + receive_result = aws_sqs_client.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ) + assert receive_result["Messages"][0]["MessageAttributes"] == attributes + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_send_message_with_empty_string_attribute(self, sqs_queue, aws_sqs_client, snapshot): + with pytest.raises(ClientError) as e: + aws_sqs_client.send_message( + QueueUrl=sqs_queue, + MessageBody="test", + MessageAttributes={"ErrorDetails": {"StringValue": "", "DataType": "String"}}, + ) + snapshot.match("empty-string-attr", e.value.response) + + @markers.aws.validated + def test_send_message_with_invalid_string_attributes(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + # base line against to detect general failure + valid_attribute = {"attr.1øßÀ": {"StringValue": "Valida", "DataType": "String"}} + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="test", MessageAttributes=valid_attribute + ) + + def send_invalid(attribute): + with pytest.raises(Exception) as e: + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="test", MessageAttributes=attribute + ) + e.match("Invalid") + + # String Attributes must not contain non-printable characters + # See: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html + invalid_attribute = { + "attr1": {"StringValue": f"Invalid-{chr(8)},{chr(11)}", "DataType": "String"} + } + send_invalid(invalid_attribute) + + invalid_name_prefixes = ["aWs.", "AMAZON.", "."] + for prefix in invalid_name_prefixes: + invalid_attribute = { + f"{prefix}-Invalid-attr": {"StringValue": "Valid", "DataType": "String"} + } + send_invalid(invalid_attribute) + + # Some illegal characters + invalid_name_characters = ["!", '"', "§", "(", "?"] + for char in invalid_name_characters: + invalid_attribute = { + f"Invalid-{char}-attr": {"StringValue": "Valid", "DataType": "String"} + } + send_invalid(invalid_attribute) + + # limit is 256 chars + too_long_name = "L" * 257 + invalid_attribute = {f"{too_long_name}": {"StringValue": "Valid", "DataType": "String"}} + send_invalid(invalid_attribute) + + # FIXME: no double periods should be allowed + # invalid_attribute = { + # "Invalid..Name": {"StringValue": "Valid", "DataType": "String"} + # } + # send_invalid(invalid_attribute) + + invalid_type = "Invalid" + invalid_attribute = { + "Attribute_name": {"StringValue": "Valid", "DataType": f"{invalid_type}"} + } + send_invalid(invalid_attribute) + + too_long_type = f"Number.{'L' * 256}" + invalid_attribute = { + "Attribute_name": {"StringValue": "Valid", "DataType": f"{too_long_type}"} + } + send_invalid(invalid_attribute) + + ends_with_dot = "Invalid." + invalid_attribute = {f"{ends_with_dot}": {"StringValue": "Valid", "DataType": "String"}} + send_invalid(invalid_attribute) + + @markers.aws.needs_fixing + @pytest.mark.skip + def test_send_message_with_invalid_fifo_parameters(self, sqs_create_queue, aws_sqs_client): + fifo_queue_name = f"queue-{short_uid()}.fifo" + queue_url = sqs_create_queue( + QueueName=fifo_queue_name, + Attributes={"FifoQueue": "true"}, + ) + if aws_sqs_client._client._service_model.protocol == "query": + expected_error = "Unable to parse response" + else: + expected_error = "InvalidParameterValue" + with pytest.raises(Exception) as e: + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="test", + MessageDeduplicationId=f"Invalid-{chr(8)}", + MessageGroupId="1", + ) + e.match(expected_error) + + with pytest.raises(Exception) as e: + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="test", + MessageDeduplicationId="1", + MessageGroupId=f"Invalid-{chr(8)}", + ) + e.match(expected_error) + + @markers.aws.validated + def test_send_message_with_invalid_payload_characters(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + invalid_message_body = f"Invalid-{chr(0)}-{chr(8)}-{chr(19)}-{chr(65535)}" + + with pytest.raises(Exception) as e: + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody=invalid_message_body) + e.match("InvalidMessageContents") + + @markers.aws.validated + def test_dead_letter_queue_config(self, sqs_create_queue, region_name): + queue_name = f"queue-{short_uid()}" + dead_letter_queue_name = f"dead_letter_queue-{short_uid()}" + + dl_queue_url = sqs_create_queue(QueueName=dead_letter_queue_name) + url_parts = dl_queue_url.split("/") + dl_target_arn = "arn:aws:sqs:{}:{}:{}".format( + region_name, url_parts[len(url_parts) - 2], url_parts[-1] + ) + + conf = {"deadLetterTargetArn": dl_target_arn, "maxReceiveCount": 50} + attributes = {"RedrivePolicy": json.dumps(conf)} + + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + + assert queue_url + + @markers.aws.validated + def test_dead_letter_queue_list_sources(self, sqs_create_queue, aws_sqs_client, region_name): + dl_queue_url = sqs_create_queue() + url_parts = dl_queue_url.split("/") + dl_target_arn = f"arn:{get_partition(region_name)}:sqs:{region_name}:{url_parts[len(url_parts) - 2]}:{url_parts[-1]}" + + conf = {"deadLetterTargetArn": dl_target_arn, "maxReceiveCount": 50} + attributes = {"RedrivePolicy": json.dumps(conf)} + + queue_url_1 = sqs_create_queue(Attributes=attributes) + queue_url_2 = sqs_create_queue(Attributes=attributes) + + assert queue_url_1 + assert queue_url_2 + + source_urls = aws_sqs_client.list_dead_letter_source_queues(QueueUrl=dl_queue_url) + assert len(source_urls) == 2 + assert queue_url_1 in source_urls["queueUrls"] + assert queue_url_2 in source_urls["queueUrls"] + + @markers.aws.validated + def test_dead_letter_queue_with_fifo_and_content_based_deduplication( + self, sqs_create_queue, sqs_get_queue_arn, aws_sqs_client + ): + dlq_url = sqs_create_queue( + QueueName=f"test-dlq-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + "MessageRetentionPeriod": "1209600", + }, + ) + dlq_arn = sqs_get_queue_arn(dlq_url) + + queue_url = sqs_create_queue( + QueueName=f"test-queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + "VisibilityTimeout": "60", + "RedrivePolicy": json.dumps({"deadLetterTargetArn": dlq_arn, "maxReceiveCount": 2}), + }, + ) + send_response_1 = aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="foobar", MessageGroupId="1" + ) + message_id_1 = send_response_1["MessageId"] + + # receive the messages twice, which is the maximum allowed + aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1, VisibilityTimeout=0) + aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1, VisibilityTimeout=0) + # after this receive call the message should be in the DLQ + aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1, VisibilityTimeout=0) + + # check the DLQ + dlq_receive_response = aws_sqs_client.receive_message(QueueUrl=dlq_url, WaitTimeSeconds=10) + assert len(dlq_receive_response["Messages"]) == 1, ( + f"invalid number of messages in DLQ response {dlq_receive_response}" + ) + message_1 = dlq_receive_response["Messages"][0] + assert message_1["MessageId"] == message_id_1 + assert message_1["Body"] == "foobar" + + # make sure the fifo queue stays operational: issue # 10107 + # send another message with the same messageGroupId + + send_response_2 = aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody="foobar2", MessageGroupId="1" + ) + message_id_2 = send_response_2["MessageId"] + # check if the new message arrived in the fifo queue + fifo_receive_response = aws_sqs_client.receive_message( + QueueUrl=queue_url, WaitTimeSeconds=1 + ) + message_2 = fifo_receive_response["Messages"][0] + assert message_2["MessageId"] == message_id_2 + assert message_2["Body"] == "foobar2" + + @markers.aws.validated + def test_dead_letter_queue_max_receive_count( + self, sqs_create_queue, aws_sqs_client, region_name + ): + queue_name = f"queue-{short_uid()}" + dead_letter_queue_name = f"dl-queue-{short_uid()}" + dl_queue_url = sqs_create_queue( + QueueName=dead_letter_queue_name, Attributes={"VisibilityTimeout": "0"} + ) + + # create arn + url_parts = dl_queue_url.split("/") + dl_target_arn = arns.sqs_queue_arn( + url_parts[-1], + account_id=url_parts[len(url_parts) - 2], + region_name=region_name, + ) + + policy = {"deadLetterTargetArn": dl_target_arn, "maxReceiveCount": 1} + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes={"RedrivePolicy": json.dumps(policy), "VisibilityTimeout": "0"}, + ) + result_send = aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="test") + + result_recv1_messages = aws_sqs_client.receive_message(QueueUrl=queue_url).get("Messages") + result_recv2_messages = aws_sqs_client.receive_message(QueueUrl=queue_url).get("Messages") + # only one request received a message + assert result_recv1_messages != result_recv2_messages + + assert poll_condition( + lambda: aws_sqs_client.receive_message(QueueUrl=dl_queue_url)["Messages"], 5.0, 1.0 + ) + assert ( + aws_sqs_client.receive_message(QueueUrl=dl_queue_url)["Messages"][0]["MessageId"] + == result_send["MessageId"] + ) + + @markers.aws.validated + def test_dead_letter_queue_message_attributes( + self, + aws_client, + sqs_create_queue, + sqs_get_queue_arn, + snapshot, + sqs_collect_messages, + ): + sqs = aws_client.sqs + + queue_name = f"queue-{short_uid()}" + dead_letter_queue_name = f"dl-queue-{short_uid()}" + dl_queue_url = sqs_create_queue(QueueName=dead_letter_queue_name) + dl_queue_arn = sqs_get_queue_arn(dl_queue_url) + queue_url = sqs_create_queue( + QueueName=queue_name, + Attributes={ + "RedrivePolicy": json.dumps( + {"deadLetterTargetArn": dl_queue_arn, "maxReceiveCount": 1} + ) + }, + ) + + snapshot.match("dlq-arn", dl_queue_arn) + snapshot.match("sourcen-arn", sqs_get_queue_arn(queue_url)) + + # check that attributes are retained + msg_attrs = {"MyAttribute": {"StringValue": "foobar", "DataType": "String"}} + msg_system_attrs = { + "AWSTraceHeader": { + "StringValue": "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1", + "DataType": "String", + } + } + # send a messages + sqs.send_message( + QueueUrl=queue_url, + MessageBody="message-1", + MessageSystemAttributes=msg_system_attrs, + MessageAttributes=msg_attrs, + ) + sqs.send_message(QueueUrl=queue_url, MessageBody="message-2") + + # receive each message two times to move them into the dlq + messages = [] + for i in range(4): + result = sqs.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + AttributeNames=["All"], + MessageAttributeNames=["All"], + ) + messages.extend(result.get("Messages", [])) + messages.sort(key=lambda m: m["Body"]) + for message in messages: + # FIXME: SenderId = account and is messing up the snapshot matching somehow + del message["Attributes"]["SenderId"] + + snapshot.match("rec-pre-dlq", messages) + + messages = sqs_collect_messages( + dl_queue_url, + expected=2, + timeout=10, + attribute_names=["All"], + message_attribute_names=["All"], + ) + messages.sort(key=lambda m: m["Body"]) + for message in messages: + del message["Attributes"]["SenderId"] + + snapshot.match("dlq-messages", messages) + + @markers.aws.needs_fixing + @pytest.mark.skip + def test_dead_letter_queue_chain( + self, sqs_create_queue, aws_sqs_client, account_id, region_name + ): + # test a chain of 3 queues, with DLQ flow q1 -> q2 -> q3 + + # create queues + queue_names = [f"q-{short_uid()}", f"q-{short_uid()}", f"q-{short_uid()}"] + queue_urls = [] + + for queue_name in queue_names: + url = sqs_create_queue(QueueName=queue_name, Attributes={"VisibilityTimeout": "0"}) + queue_urls.append(url) + + # set redrive policies + for idx, queue_name in enumerate(queue_names[:2]): + policy = { + "deadLetterTargetArn": arns.sqs_queue_arn( + queue_names[idx + 1], + account_id=account_id, + region_name=region_name, + ), + "maxReceiveCount": 1, + } + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_urls[idx], + Attributes={"RedrivePolicy": json.dumps(policy), "VisibilityTimeout": "0"}, + ) + + def _retry_receive(q_url): + def _receive(): + _result = aws_sqs_client.receive_message(QueueUrl=q_url) + assert _result.get("Messages") + return _result + + return retry(_receive, sleep=1, retries=5) + + # send message + result = aws_sqs_client.send_message(QueueUrl=queue_urls[0], MessageBody="test") + # retrieve message from q1 + result = _retry_receive(queue_urls[0]) + assert len(result.get("Messages")) == 1 + # Wait for VisibilityTimeout to expire + time.sleep(1.1) + # retrieve message from q1 again -> no message, should go to DLQ q2 + result = aws_sqs_client.receive_message(QueueUrl=queue_urls[0]) + assert not result.get("Messages") + # retrieve message from q2 + result = _retry_receive(queue_urls[1]) + assert len(result.get("Messages")) == 1 + # retrieve message from q2 again -> no message, should go to DLQ q3 + result = aws_sqs_client.receive_message(QueueUrl=queue_urls[1]) + assert not result.get("Messages") + # retrieve message from q3 + result = _retry_receive(queue_urls[2]) + assert len(result.get("Messages")) == 1 + + # TODO: check if test_set_queue_attribute_at_creation == test_create_queue_with_attributes + + @markers.aws.validated + def test_get_specific_queue_attribute_response( + self, sqs_create_queue, aws_sqs_client, region_name + ): + queue_name = f"queue-{short_uid()}" + dead_letter_queue_name = f"dead_letter_queue-{short_uid()}" + + dl_queue_url = sqs_create_queue(QueueName=dead_letter_queue_name) + dl_result = aws_sqs_client.get_queue_attributes( + QueueUrl=dl_queue_url, AttributeNames=["QueueArn"] + ) + + dl_queue_arn = dl_result["Attributes"]["QueueArn"] + + max_receive_count = 10 + _redrive_policy = { + "deadLetterTargetArn": dl_queue_arn, + "maxReceiveCount": max_receive_count, + } + message_retention_period = "604800" + attributes = { + "MessageRetentionPeriod": message_retention_period, + "DelaySeconds": "10", + "RedrivePolicy": json.dumps(_redrive_policy), + } + + queue_url = sqs_create_queue(QueueName=queue_name, Attributes=attributes) + url_parts = queue_url.split("/") + get_two_attributes = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=["MessageRetentionPeriod", "RedrivePolicy"], + ) + get_single_attribute = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=["QueueArn"], + ) + # asserts + constructed_arn = f"arn:{get_partition(region_name)}:sqs:{region_name}:{url_parts[len(url_parts) - 2]}:{url_parts[-1]}" + redrive_policy = json.loads(get_two_attributes.get("Attributes").get("RedrivePolicy")) + assert message_retention_period == get_two_attributes.get("Attributes").get( + "MessageRetentionPeriod" + ) + assert constructed_arn == get_single_attribute.get("Attributes").get("QueueArn") + assert max_receive_count == redrive_policy.get("maxReceiveCount") + + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail", "$..Error.Type"]) + @markers.aws.validated + def test_set_unsupported_attribute_standard(self, sqs_create_queue, aws_sqs_client, snapshot): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + with pytest.raises(ClientError) as e: + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, Attributes={"FifoQueue": "true"} + ) + snapshot.match("invalid-attr-name-1", e.value.response) + with pytest.raises(ClientError) as e: + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, Attributes={"FifoQueue": "false"} + ) + snapshot.match("invalid-attr-name-2", e.value.response) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail", "$..Error.Type"]) + @markers.aws.validated + def test_set_unsupported_attribute_fifo(self, sqs_create_queue, aws_sqs_client, snapshot): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + with pytest.raises(ClientError) as e: + aws_sqs_client.set_queue_attributes( + QueueUrl=queue_url, Attributes={"FifoQueue": "true"} + ) + snapshot.match("invalid-attr-name-1", e.value.response) + + fifo_queue_name = f"queue-{short_uid()}.fifo" + fifo_queue_url = sqs_create_queue( + QueueName=fifo_queue_name, Attributes={"FifoQueue": "true"} + ) + aws_sqs_client.set_queue_attributes( + QueueUrl=fifo_queue_url, Attributes={"FifoQueue": "true"} + ) + with pytest.raises(ClientError) as e: + aws_sqs_client.set_queue_attributes( + QueueUrl=fifo_queue_url, Attributes={"FifoQueue": "false"} + ) + snapshot.match("invalid-attr-name-2", e.value.response) + + @markers.aws.validated + def test_fifo_queue_send_multiple_messages_multiple_single_receives( + self, sqs_create_queue, aws_sqs_client + ): + fifo_queue_name = f"queue-{short_uid()}.fifo" + queue_url = sqs_create_queue( + QueueName=fifo_queue_name, + Attributes={"FifoQueue": "true"}, + ) + message_count = 4 + group_id = f"fifo_group-{short_uid()}" + sent_messages = [] + for i in range(message_count): + result = aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=f"message{i}", + MessageDeduplicationId=f"deduplication{i}", + MessageGroupId=group_id, + ) + sent_messages.append(result) + + for i in range(message_count): + result = aws_sqs_client.receive_message(QueueUrl=queue_url) + message = result["Messages"][0] + assert message["Body"] == f"message{i}" + assert message["MD5OfBody"] == sent_messages[i]["MD5OfMessageBody"] + assert message["MessageId"] == sent_messages[i]["MessageId"] + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"] + ) + + @markers.aws.validated + def test_fifo_content_based_message_deduplication_arrives_once( + self, sqs_create_queue, aws_sqs_client + ): + # created for https://github.com/localstack/localstack/issues/6327 + queue_url = sqs_create_queue( + QueueName=f"test-queue-{short_uid()}.fifo", + Attributes={"FifoQueue": "true", "ContentBasedDeduplication": "true"}, + ) + item = '{"foo": "bar"}' + group = "group-1" + + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody=item, MessageGroupId=group) + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody=item, MessageGroupId=group) + + # first receive has the item + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=2 + ) + assert len(response["Messages"]) == 1 + assert response["Messages"][0]["Body"] == item + + # second doesn't since the message has the same content + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=1 + ) + assert response.get("Messages", []) == [] + + @markers.aws.validated + @pytest.mark.parametrize("content_based_deduplication", [True, False]) + def test_fifo_deduplication_arrives_once_after_delete( + self, + sqs_create_queue, + aws_sqs_client, + content_based_deduplication, + snapshot, + ): + attributes = {"FifoQueue": "true"} + if content_based_deduplication: + attributes["ContentBasedDeduplication"] = "true" + queue_url = sqs_create_queue( + QueueName=f"test-queue-{short_uid()}.fifo", + Attributes=attributes, + ) + item = '{"foo": "bar"}' + group = "group-1" + kwargs = {} + if not content_based_deduplication: + kwargs["MessageDeduplicationId"] = "dedup1" + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody=item, MessageGroupId=group, **kwargs + ) + + # first receive has the item + response = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=3) + snapshot.match("get-messages", response) + assert len(response["Messages"]) == 1 + assert response["Messages"][0]["Body"] == item + # delete the item, we want to check that we don't receive a duplicate even after deletion + # see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessage.html + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + # republish the same message + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody=item, MessageGroupId=group, **kwargs + ) + + # second doesn't receive anything the deduplication id is the same + response = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1) + assert response.get("Messages", []) == [] + snapshot.match("get-messages-duplicate", response) + + @markers.aws.validated + @pytest.mark.parametrize("content_based_deduplication", [True, False]) + def test_fifo_deduplication_not_on_message_group_id( + self, + sqs_create_queue, + aws_sqs_client, + content_based_deduplication, + snapshot, + ): + attributes = {"FifoQueue": "true"} + if content_based_deduplication: + attributes["ContentBasedDeduplication"] = "true" + queue_url = sqs_create_queue( + QueueName=f"test-queue-{short_uid()}.fifo", + Attributes=attributes, + ) + item = '{"foo": "bar"}' + kwargs = {} + if not content_based_deduplication: + kwargs["MessageDeduplicationId"] = "dedup1" + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody=item, MessageGroupId="group-1", **kwargs + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody=item, MessageGroupId="group-2", **kwargs + ) + + # first receive has the item + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=2 + ) + assert len(response["Messages"]) == 1 + assert response["Messages"][0]["Body"] == item + snapshot.match("get-messages", response) + + # second doesn't since the message has the same content + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=1 + ) + assert response.get("Messages", []) == [] + snapshot.match("get-dedup-messages", response) + + @markers.aws.validated + @pytest.mark.skip( + reason="localstack allows queue names with slashes, but this should be deprecated" + ) + def test_disallow_queue_name_with_slashes(self, sqs_create_queue): + queue_name = f"queue/{short_uid()}/" + with pytest.raises(Exception) as e: + sqs_create_queue(QueueName=queue_name) + e.match("InvalidParameterValue") + + @markers.aws.validated + def test_get_list_queues_with_query_auth(self, aws_http_client_factory): + client = aws_http_client_factory("sqs", region="us-east-1") + + if is_aws_cloud(): + endpoint_url = "https://queue.amazonaws.com" + else: + endpoint_url = config.internal_service_url() + + # assert that AWS has some sort of content negotiation for query GET requests, even if not `json` protocol + response = client.get( + endpoint_url, + params={"Action": "ListQueues", "Version": "2012-11-05"}, + headers={"Accept": "application/json"}, + ) + + assert response.status_code == 200 + assert "ListQueuesResponse" in response.json() + + # assert the default response is still XML for a GET request + response = client.get( + endpoint_url, + params={"Action": "ListQueues", "Version": "2012-11-05"}, + ) + + assert response.status_code == 200 + assert b" 1 + + aws_sqs_client.purge_queue(QueueUrl=queue_url) + + receive_result = aws_sqs_client.receive_message(QueueUrl=queue_url) + assert "Messages" not in receive_result or receive_result["Messages"] == [] + + # test that adding messages after purge works + for i in range(3): + message_content = f"test-1-{i}" + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody=message_content) + + messages = [] + + def _collect(): + result = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1) + messages.extend(result.get("Messages", [])) + return len(messages) == 3 + + assert poll_condition(_collect, timeout=10) + assert {m["Body"] for m in messages} == {"test-1-0", "test-1-1", "test-1-2"} + + @markers.aws.validated + def test_purge_queue_deletes_inflight_messages(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue() + + for i in range(10): + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}") + + response = aws_sqs_client.receive_message( + QueueUrl=queue_url, VisibilityTimeout=3, WaitTimeSeconds=5, MaxNumberOfMessages=5 + ) + assert "Messages" in response + + aws_sqs_client.purge_queue(QueueUrl=queue_url) + + # wait for visibility timeout to expire + time.sleep(3) + + receive_result = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1) + assert "Messages" not in receive_result or receive_result["Messages"] == [] + + @markers.aws.validated + def test_purge_queue_deletes_delayed_messages(self, sqs_create_queue, aws_sqs_client): + queue_url = sqs_create_queue() + + for i in range(5): + aws_sqs_client.send_message( + QueueUrl=queue_url, MessageBody=f"message-{i}", DelaySeconds=2 + ) + + aws_sqs_client.purge_queue(QueueUrl=queue_url) + + # wait for delay to expire + time.sleep(2) + + receive_result = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1) + assert "Messages" not in receive_result or receive_result["Messages"] == [] + + @markers.aws.validated + def test_purge_queue_clears_fifo_deduplication_cache(self, sqs_create_queue, aws_sqs_client): + fifo_queue_name = f"test-queue-{short_uid()}.fifo" + queue_url = sqs_create_queue(QueueName=fifo_queue_name, Attributes={"FifoQueue": "true"}) + dedup_id = f"fifo_dedup-{short_uid()}" + group_id = f"fifo_group-{short_uid()}" + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="message-1", + MessageGroupId=group_id, + MessageDeduplicationId=dedup_id, + ) + + aws_sqs_client.purge_queue(QueueUrl=queue_url) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="message-2", + MessageGroupId=group_id, + MessageDeduplicationId=dedup_id, + ) + + receive_result = aws_sqs_client.receive_message(QueueUrl=queue_url, WaitTimeSeconds=1) + + assert len(receive_result["Messages"]) == 1 + message = receive_result["Messages"][0] + + assert message["Body"] == "message-2" + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_successive_purge_calls_fail( + self, sqs_create_queue, monkeypatch, snapshot, aws_sqs_client, aws_client + ): + monkeypatch.setattr(config, "SQS_DELAY_PURGE_RETRY", True) + queue_name = f"test-queue-{short_uid()}" + snapshot.add_transformer(snapshot.transform.regex(queue_name, "")) + + queue_url = sqs_create_queue(QueueName=queue_name) + + aws_sqs_client.purge_queue(QueueUrl=queue_url) + + with pytest.raises(ClientError) as e: + aws_sqs_client.purge_queue(QueueUrl=queue_url) + + snapshot.match("purge_queue_error", e.value.response) + + @markers.aws.validated + def test_remove_message_with_old_receipt_handle(self, sqs_create_queue, aws_sqs_client): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + aws_sqs_client.send_message(QueueUrl=queue_url, MessageBody="test") + result_receive = aws_sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=1) + time.sleep(2) + receipt_handle = result_receive["Messages"][0]["ReceiptHandle"] + aws_sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + # This is more suited to the check than receiving because it simply + # returns the number of elements in the queue, without further logic + approx_nr_of_messages = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages"] + ) + assert int(approx_nr_of_messages["Attributes"]["ApproximateNumberOfMessages"]) == 0 + + @markers.aws.only_localstack + def test_list_queues_multi_region_without_endpoint_strategy( + self, aws_client_factory, cleanups, monkeypatch + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "off") + + region1 = "us-east-1" + region2 = "eu-central-1" + region1_client = aws_client_factory(region_name=region1).sqs + region2_client = aws_client_factory(region_name=region2).sqs + + queue1_name = f"queue-region1-{short_uid()}" + queue2_name = f"queue-region2-{short_uid()}" + + queue1_url = region1_client.create_queue(QueueName=queue1_name)["QueueUrl"] + cleanups.append(lambda: region1_client.delete_queue(QueueUrl=queue1_url)) + queue2_url = region2_client.create_queue(QueueName=queue2_name)["QueueUrl"] + cleanups.append(lambda: region2_client.delete_queue(QueueUrl=queue2_url)) + + # region should not be in the queue url with endpoint strategy "off" + assert region1 not in queue1_url + assert region1 not in queue2_url + + assert queue1_url in region1_client.list_queues().get("QueueUrls", []) + assert queue2_url not in region1_client.list_queues().get("QueueUrls", []) + + assert queue1_url not in region2_client.list_queues().get("QueueUrls", []) + assert queue2_url in region2_client.list_queues().get("QueueUrls", []) + + @markers.aws.validated + def test_list_queues_multi_region_with_endpoint_strategy_standard( + self, aws_client_factory, cleanups, monkeypatch + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "standard") + + region1 = "us-east-1" + region2 = "eu-central-1" + + region1_client = aws_client_factory(region_name=region1).sqs + region2_client = aws_client_factory(region_name=region2).sqs + + queue_name = f"queue-{short_uid()}" + + queue1_url = region1_client.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: region1_client.delete_queue(QueueUrl=queue1_url)) + queue2_url = region2_client.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: region2_client.delete_queue(QueueUrl=queue2_url)) + + assert ( + f"sqs.{region1}." in queue1_url + ) # region is always included irrespective of whether it is us-east-1 + assert f"sqs.{region2}." in queue2_url + assert region1 not in queue2_url + assert region2 not in queue1_url + + assert queue1_url in region1_client.list_queues().get("QueueUrls", []) + assert queue2_url not in region1_client.list_queues().get("QueueUrls", []) + + assert queue1_url not in region2_client.list_queues().get("QueueUrls", []) + assert queue2_url in region2_client.list_queues().get("QueueUrls", []) + + @markers.aws.validated + def test_list_queues_multi_region_with_endpoint_strategy_domain( + self, aws_client_factory, cleanups, monkeypatch + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "domain") + + region1 = "us-east-1" + region2 = "eu-central-1" + + region1_client = aws_client_factory(region_name=region1).sqs + region2_client = aws_client_factory(region_name=region2).sqs + + queue_name = f"queue-{short_uid()}" + + queue1_url = region1_client.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: region1_client.delete_queue(QueueUrl=queue1_url)) + queue2_url = region2_client.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: region2_client.delete_queue(QueueUrl=queue2_url)) + + assert region1 not in queue1_url # us-east-1 is not included in the default region + assert region1 not in queue2_url + assert f"{region2}.queue" in queue2_url # all other regions are part of the endpoint-url + + assert queue1_url in region1_client.list_queues().get("QueueUrls", []) + assert queue2_url not in region1_client.list_queues().get("QueueUrls", []) + + assert queue1_url not in region2_client.list_queues().get("QueueUrls", []) + assert queue2_url in region2_client.list_queues().get("QueueUrls", []) + + @markers.aws.validated + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_get_queue_url_multi_region(self, strategy, aws_client_factory, cleanups, monkeypatch): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + region1_client = aws_client_factory(region_name="us-east-1").sqs + region2_client = aws_client_factory(region_name="eu-central-1").sqs + + queue_name = f"queue-{short_uid()}" + + queue1_url = region1_client.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: region1_client.delete_queue(QueueUrl=queue1_url)) + queue2_url = region2_client.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: region2_client.delete_queue(QueueUrl=queue2_url)) + + assert queue1_url != queue2_url + assert queue1_url == region1_client.get_queue_url(QueueName=queue_name)["QueueUrl"] + assert queue2_url == region2_client.get_queue_url(QueueName=queue_name)["QueueUrl"] + + @pytest.mark.skip(reason="this test requires 5 minutes to run. Only execute manually") + @markers.aws.validated + def test_deduplication_interval(self, sqs_create_queue, aws_sqs_client): + fifo_queue_name = f"queue-{short_uid()}.fifo" + queue_url = sqs_create_queue(QueueName=fifo_queue_name, Attributes={"FifoQueue": "true"}) + message_content = f"test{short_uid()}" + message_content_duplicate = f"{message_content}-duplicate" + message_content_half_time = f"{message_content}-half_time" + dedup_id = f"fifo_dedup-{short_uid()}" + group_id = f"fifo_group-{short_uid()}" + result_send = aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=message_content, + MessageGroupId=group_id, + MessageDeduplicationId=dedup_id, + ) + time.sleep(3) + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=message_content_duplicate, + MessageGroupId=group_id, + MessageDeduplicationId=dedup_id, + ) + result_receive = retry( + aws_sqs_client.receive_message, QueueUrl=queue_url, retries=5, sleep=1 + ) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=result_receive["Messages"][0]["ReceiptHandle"] + ) + result_receive_duplicate = retry( + aws_sqs_client.receive_message, QueueUrl=queue_url, retries=5, sleep=1 + ) + + assert result_send.get("MessageId") == result_receive.get("Messages")[0].get("MessageId") + assert result_send.get("MD5OfMessageBody") == result_receive.get("Messages")[0].get( + "MD5OfBody" + ) + assert "Messages" not in result_receive_duplicate + + dedup_id_2 = f"fifo_dedup-{short_uid()}" + + result_send = aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=message_content, + MessageGroupId=group_id, + MessageDeduplicationId=dedup_id_2, + ) + # ZZZZzzz... + # Fifo Deduplication Interval is 5 minutes at minimum, + there seems no way to change it. + # We give it a bit of leeway to avoid timing issues + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/using-messagededuplicationid-property.html + time.sleep(2) + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=message_content_half_time, + MessageGroupId=group_id, + MessageDeduplicationId=dedup_id_2, + ) + time.sleep(6 * 60) + + result_send_duplicate = aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody=message_content_duplicate, + MessageGroupId=group_id, + MessageDeduplicationId=dedup_id_2, + ) + result_receive = aws_sqs_client.receive_message(QueueUrl=queue_url) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=result_receive["Messages"][0]["ReceiptHandle"] + ) + result_receive_duplicate = aws_sqs_client.receive_message(QueueUrl=queue_url) + + assert result_send.get("MessageId") == result_receive.get("Messages")[0].get("MessageId") + assert result_send.get("MD5OfMessageBody") == result_receive.get("Messages")[0].get( + "MD5OfBody" + ) + assert result_send_duplicate.get("MessageId") == result_receive_duplicate.get("Messages")[ + 0 + ].get("MessageId") + assert result_send_duplicate.get("MD5OfMessageBody") == result_receive_duplicate.get( + "Messages" + )[0].get("MD5OfBody") + + @markers.aws.validated + def test_sqs_fifo_same_dedup_id_different_message_groups( + self, sqs_create_queue, aws_sqs_client, snapshot + ): + attributes = {"FifoQueue": "True", "ContentBasedDeduplication": "False"} + queue_url = sqs_create_queue(QueueName=f"queue-{short_uid()}.fifo", Attributes=attributes) + dedup_id = "same-dedup" + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test", + MessageGroupId="group1", + MessageDeduplicationId=dedup_id, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-1", response) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test", + MessageGroupId="group2", + MessageDeduplicationId=dedup_id, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-2", response) + + @markers.aws.validated + def test_fifo_high_throughput_ordering(self, aws_sqs_client, snapshot, sqs_create_queue): + attributes = { + "FifoQueue": "True", + "ContentBasedDeduplication": "False", + "DeduplicationScope": "messageGroup", + "FifoThroughputLimit": "perMessageGroupId", + } + queue_url = sqs_create_queue(QueueName=f"queue-{short_uid()}.fifo", Attributes=attributes) + dedup_id = "same-dedup" + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test1", + MessageGroupId="group1", + MessageDeduplicationId=dedup_id, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-1", response) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test2", + MessageGroupId="group2", + MessageDeduplicationId=dedup_id, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-2", response) + + @markers.aws.validated + def test_fifo_change_to_high_throughput_after_creation( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + attributes = { + "FifoQueue": "True", + "ContentBasedDeduplication": "False", + "DeduplicationScope": "queue", + } + queue_url = sqs_create_queue(QueueName=f"queue-{short_uid()}.fifo", Attributes=attributes) + dedup_id = "same-dedup" + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test1", + MessageGroupId="group1", + MessageDeduplicationId=dedup_id, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-1", response) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test2", + MessageGroupId="group2", + MessageDeduplicationId=dedup_id, + ) + # this should have been a duplicate + response = aws_sqs_client.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + snapshot.match("same-dedup-different-grp-2", response) + + attributes = { + "DeduplicationScope": "messageGroup", + "FifoThroughputLimit": "perMessageGroupId", + } + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + response = aws_sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["All"]) + snapshot.match("set-to-high-throughput", response) + # do we receive "old duplicates" now? + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-high-throughput", response) + + # send new message that should be no duplicate now (but would have been in regular fifo mode) + # however, behaviour does not seem to change on AWS + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test3", + MessageGroupId="group3", + MessageDeduplicationId=dedup_id, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-high-throughput-2", response) + + # the new dedup scope does not apply to newly received dedup ids either + dedup_id_2 = "dedup_2" + # send new message with new dedup id + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test4", + MessageGroupId="group4", + MessageDeduplicationId=dedup_id_2, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("new-dedup2-high-throughput", response) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + # send another message with the same, new dedup id + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test5", + MessageGroupId="group5", + MessageDeduplicationId=dedup_id_2, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup2-high-throughput", response) + + @markers.aws.validated + def test_fifo_change_to_regular_throughput_after_creation( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + attributes = { + "FifoQueue": "True", + "ContentBasedDeduplication": "False", + "DeduplicationScope": "messageGroup", + "FifoThroughputLimit": "perMessageGroupId", + } + queue_url = sqs_create_queue(QueueName=f"queue-{short_uid()}.fifo", Attributes=attributes) + dedup_id = "same-dedup" + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test1", + MessageGroupId="group1", + MessageDeduplicationId=dedup_id, + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-1", response) + receipt_handle = response["Messages"][0]["ReceiptHandle"] + aws_sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test2", + MessageGroupId="group2", + MessageDeduplicationId=dedup_id, + ) + # this should have been no duplicate + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + receipt_handle = response["Messages"][0]["ReceiptHandle"] + aws_sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt_handle) + snapshot.match("same-dedup-different-grp-2", response) + + attributes = { + "DeduplicationScope": "queue", + "FifoThroughputLimit": "perQueue", + } + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + response = aws_sqs_client.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["All"]) + snapshot.match("set-to-regular-throughput", response) + + # send new message that should be a duplicate now (but wasn't in high throughput mode) + # however, behaviour does not seem to change on AWS + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test3", + MessageGroupId="group3", + MessageDeduplicationId=dedup_id, + ) + + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-regular-throughput", response) + + @markers.aws.validated + def test_sqs_fifo_message_group_scope_no_throughput_setting( + self, sqs_create_queue, aws_sqs_client, snapshot + ): + # issue #10460 + # the 'messageGroup' deduplication scope is apparently not restricted to high throughput fifo queues. + attributes = { + "FifoQueue": "True", + "ContentBasedDeduplication": "True", + "DeduplicationScope": "messageGroup", + } + queue_url = sqs_create_queue(QueueName=f"queue-{short_uid()}.fifo", Attributes=attributes) + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test", + MessageGroupId="group1", + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-1", response) + aws_sqs_client.delete_message( + QueueUrl=queue_url, ReceiptHandle=response["Messages"][0]["ReceiptHandle"] + ) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="Test", + MessageGroupId="group2", + ) + response = aws_sqs_client.receive_message(QueueUrl=queue_url) + snapshot.match("same-dedup-different-grp-2", response) + + @markers.aws.validated + def test_sse_queue_attributes(self, sqs_create_queue, snapshot, aws_sqs_client): + # KMS server-side encryption (SSE) + queue_url = sqs_create_queue() + attributes = { + "KmsMasterKeyId": "testKeyId", + "KmsDataKeyReusePeriodSeconds": "6000", + "SqsManagedSseEnabled": "false", + } + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + response = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=[ + "KmsMasterKeyId", + "KmsDataKeyReusePeriodSeconds", + "SqsManagedSseEnabled", + ], + ) + snapshot.match("sse_kms_attributes", response) + + # SQS SSE + queue_url = sqs_create_queue() + attributes = { + "SqsManagedSseEnabled": "true", + } + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + response = aws_sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=[ + "KmsMasterKeyId", + "KmsDataKeyReusePeriodSeconds", + "SqsManagedSseEnabled", + ], + ) + snapshot.match("sse_sqs_attributes", response) + + @pytest.mark.skip(reason="validation currently not implemented in localstack") + @markers.aws.validated + def test_sse_kms_and_sqs_are_mutually_exclusive( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + queue_url = sqs_create_queue() + attributes = { + "KmsMasterKeyId": "testKeyId", + "SqsManagedSseEnabled": "true", + } + + with pytest.raises(ClientError) as e: + aws_sqs_client.set_queue_attributes(QueueUrl=queue_url, Attributes=attributes) + + snapshot.match("error", e.value) + + @markers.aws.validated + def test_receive_message_message_attribute_names_filters( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + """ + Receive message allows a list of filters to be passed with MessageAttributeNames. See: + https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sqs.html#SQS.Client.receive_message + """ + queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "0"}) + + response = aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="msg", + MessageAttributes={ + "Help.Me": {"DataType": "String", "StringValue": "Me"}, + "Hello": {"DataType": "String", "StringValue": "There"}, + "General": {"DataType": "String", "StringValue": "Kenobi"}, + }, + ) + assert snapshot.match("send_message_response", response) + + def receive_message(message_attribute_names): + return aws_sqs_client.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=5, + MessageAttributeNames=message_attribute_names, + ) + + # test empty filter + response = receive_message([]) + # do the first check with the entire response + assert snapshot.match("empty_filter", response) + + # test "All" + response = receive_message(["All"]) + assert snapshot.match("all_name", response) + + # test "*" + response = receive_message(["*"]) + assert snapshot.match("all_wildcard_asterisk", response["Messages"][0]) + + # test ".*" + response = receive_message([".*"]) + assert snapshot.match("all_wildcard", response["Messages"][0]) + + # test only non-existent names + response = receive_message(["Foo", "Help"]) + assert snapshot.match("only_non_existing_names", response["Messages"][0]) + + # test all existing + response = receive_message(["Hello", "General"]) + assert snapshot.match("only_existing", response["Messages"][0]) + + # test existing and non-existing + response = receive_message(["Foo", "Hello"]) + assert snapshot.match("existing_and_non_existing", response["Messages"][0]) + + # test prefix filters + response = receive_message(["Hel.*"]) + assert snapshot.match("prefix_filter", response["Messages"][0]) + + # test illegal names + response = receive_message(["AWS."]) + assert snapshot.match("illegal_name_1", response) + response = receive_message(["..foo"]) + assert snapshot.match("illegal_name_2", response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Attributes.SenderId"]) + def test_receive_message_attribute_names_filters( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + # TODO -> senderId in LS == account ID, but on AWS it looks quite different: [A-Z]{21}: + # account id is replaced with higher priority + + queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "0"}) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="msg", + MessageAttributes={ + "Foo": {"DataType": "String", "StringValue": "Bar"}, + }, + ) + + def receive_message(attribute_names, message_attribute_names=None): + return aws_sqs_client.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=5, + AttributeNames=attribute_names, + MessageAttributeNames=message_attribute_names or [], + ) + + response = receive_message(["All"]) + assert snapshot.match("all_attributes", response) + + response = receive_message(["All"], ["All"]) + assert snapshot.match("all_system_and_message_attributes", response) + + response = receive_message(["SenderId"]) + assert snapshot.match("single_attribute", response) + + response = receive_message(["SenderId", "SequenceNumber"]) + assert snapshot.match("multiple_attributes", response) + + @markers.aws.validated + def test_receive_message_message_system_attribute_names_filters( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "0"}) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="msg", + MessageAttributes={ + "Foo": {"DataType": "String", "StringValue": "Bar"}, + }, + ) + + def receive_message(message_system_attribute_names, message_attribute_names=None): + return aws_sqs_client.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=5, + MessageSystemAttributeNames=message_system_attribute_names, + MessageAttributeNames=message_attribute_names or [], + ) + + response = receive_message(["All"]) + assert snapshot.match("all_attributes", response) + + response = receive_message(["All"], ["All"]) + assert snapshot.match("all_system_and_message_attributes", response) + + response = receive_message(["SenderId"]) + assert snapshot.match("single_attribute", response) + + response = receive_message(["SenderId", "SequenceNumber"]) + assert snapshot.match("multiple_attributes", response) + + @markers.aws.validated + def test_message_system_attribute_names_with_attribute_names( + self, sqs_create_queue, snapshot, aws_sqs_client + ): + queue_url = sqs_create_queue(Attributes={"VisibilityTimeout": "0"}) + + aws_sqs_client.send_message( + QueueUrl=queue_url, + MessageBody="msg", + MessageAttributes={ + "Foo": {"DataType": "String", "StringValue": "Bar"}, + }, + ) + + def receive_message(message_system_attribute_names, attribute_names): + return aws_sqs_client.receive_message( + QueueUrl=queue_url, + WaitTimeSeconds=5, + MessageSystemAttributeNames=message_system_attribute_names, + AttributeNames=attribute_names, + ) + + response = receive_message(["All"], attribute_names=["All"]) + assert snapshot.match("same_values", response) + + response = receive_message(["All"], attribute_names=["SenderId"]) + assert snapshot.match("different_values", response) + + @markers.aws.validated + def test_change_visibility_on_deleted_message_raises_invalid_parameter_value( + self, sqs_queue, aws_sqs_client + ): + # prepare the fixture + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="foo") + response = aws_sqs_client.receive_message(QueueUrl=sqs_queue, WaitTimeSeconds=5) + handle = response["Messages"][0]["ReceiptHandle"] + + # check that it works as expected + aws_sqs_client.change_message_visibility( + QueueUrl=sqs_queue, ReceiptHandle=handle, VisibilityTimeout=42 + ) + + # delete the message, the handle becomes invalid + aws_sqs_client.delete_message(QueueUrl=sqs_queue, ReceiptHandle=handle) + + with pytest.raises(ClientError) as e: + aws_sqs_client.change_message_visibility( + QueueUrl=sqs_queue, ReceiptHandle=handle, VisibilityTimeout=42 + ) + + err = e.value.response["Error"] + assert err["Code"] == "InvalidParameterValue" + assert ( + err["Message"] + == f"Value {handle} for parameter ReceiptHandle is invalid. Reason: Message does not exist or is not " + f"available for visibility timeout change." + ) + + @markers.aws.validated + def test_delete_message_with_illegal_receipt_handle(self, sqs_queue, aws_sqs_client): + with pytest.raises(ClientError) as e: + aws_sqs_client.delete_message(QueueUrl=sqs_queue, ReceiptHandle="garbage") + + err = e.value.response["Error"] + assert err["Code"] == "ReceiptHandleIsInvalid" + assert err["Message"] == 'The input receipt handle "garbage" is not a valid receipt handle.' + + @markers.aws.validated + def test_delete_message_with_deleted_receipt_handle(self, sqs_queue, aws_sqs_client): + aws_sqs_client.send_message(QueueUrl=sqs_queue, MessageBody="foo") + response = aws_sqs_client.receive_message(QueueUrl=sqs_queue, WaitTimeSeconds=5) + handle = response["Messages"][0]["ReceiptHandle"] + + # does not raise errors even after successive calls + aws_sqs_client.delete_message(QueueUrl=sqs_queue, ReceiptHandle=handle) + aws_sqs_client.delete_message(QueueUrl=sqs_queue, ReceiptHandle=handle) + aws_sqs_client.delete_message(QueueUrl=sqs_queue, ReceiptHandle=handle) + + # TODO: test message attributes and message system attributes + + def _add_error_detail_transformer(self, snapshot): + """Adds a transformer to ignore {"Error": {"Detail": None, ...}} entries in snapshot error responses""" + + def _remove_error_details(snapshot_content: Dict, *args) -> Dict: + for response in snapshot_content.values(): + response.get("Error", {}).pop("Detail", None) + return snapshot_content + + snapshot.add_transformer(GenericTransformer(_remove_error_details)) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_sqs_permission_lifecycle(self, sqs_queue, aws_sqs_client, snapshot, account_id): + add_permission_response = aws_sqs_client.add_permission( + QueueUrl=sqs_queue, + AWSAccountIds=[account_id, "668614515564"], + Actions=["ReceiveMessage"], + Label="crossaccountpermission", + ) + snapshot.match("add-permission-response", add_permission_response) + + get_queue_policy_attribute = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["Policy"] + ) + # the order of the Principal.AWS field does not seem to set. Manually sort it by the hard-coded one, to not have + # differences while refreshing the snapshot + get_policy = json.loads(get_queue_policy_attribute["Attributes"]["Policy"]) + get_policy["Statement"][0]["Principal"]["AWS"].sort( + key=lambda x: 0 if "668614515564" in x else 1 + ) + + get_queue_policy_attribute["Attributes"]["Policy"] = json.dumps(get_policy) + + snapshot.match("get-queue-policy-attribute", get_queue_policy_attribute) + remove_permission_response = aws_sqs_client.remove_permission( + QueueUrl=sqs_queue, + Label="crossaccountpermission", + ) + snapshot.match("remove-permission-response", remove_permission_response) + get_queue_policy_attribute = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["Policy"] + ) + snapshot.match("get-queue-policy-attribute-after-removal", get_queue_policy_attribute) + + # test two permissions with the same label + aws_sqs_client.add_permission( + QueueUrl=sqs_queue, + AWSAccountIds=[account_id], + Actions=["ReceiveMessage"], + Label="crossaccountpermission", + ) + get_queue_policy_attribute = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["Policy"] + ) + snapshot.match( + "get-queue-policy-attribute-first-account-same-label", get_queue_policy_attribute + ) + + with pytest.raises(ClientError) as e: + aws_sqs_client.add_permission( + QueueUrl=sqs_queue, + AWSAccountIds=["668614515564"], + Actions=["ReceiveMessage"], + Label="crossaccountpermission", + ) + snapshot.match("get-queue-policy-attribute-second-account-same-label", e.value.response) + + aws_sqs_client.add_permission( + QueueUrl=sqs_queue, + AWSAccountIds=[account_id], + Actions=["ReceiveMessage"], + Label="crossaccountpermission2", + ) + get_queue_policy_attribute = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["Policy"] + ) + snapshot.match( + "get-queue-policy-attribute-second-account-different-label", get_queue_policy_attribute + ) + + aws_sqs_client.remove_permission(QueueUrl=sqs_queue, Label="crossaccountpermission") + get_queue_policy_attribute = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["Policy"] + ) + snapshot.match( + "get-queue-policy-attribute-delete-first-permission", get_queue_policy_attribute + ) + + aws_sqs_client.remove_permission(QueueUrl=sqs_queue, Label="crossaccountpermission2") + get_queue_policy_attribute = aws_sqs_client.get_queue_attributes( + QueueUrl=sqs_queue, AttributeNames=["Policy"] + ) + snapshot.match( + "get-queue-policy-attribute-delete-second-permission", get_queue_policy_attribute + ) + with pytest.raises(ClientError) as e: + aws_sqs_client.remove_permission(QueueUrl=sqs_queue, Label="crossaccountpermission2") + snapshot.match("get-queue-policy-attribute-delete-non-existent-label", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Detail"]) + def test_non_existent_queue(self, aws_client, sqs_create_queue, sqs_queue_exists, snapshot): + queue_name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + aws_client.sqs.delete_queue(QueueUrl=queue_url) + assert poll_condition(lambda: not sqs_queue_exists(queue_url), timeout=5) + + with pytest.raises(ClientError) as e: + aws_client.sqs.get_queue_attributes(QueueUrl=queue_url) + snapshot.match("queue-does-not-exist", e.value.response) + + # validate both the client exception handling in boto and GetQueueUrl + with pytest.raises(aws_client.sqs.exceptions.QueueDoesNotExist) as e: + aws_client.sqs.get_queue_url(QueueName=queue_name) + snapshot.match("queue-does-not-exist-url", e.value.response) + + with pytest.raises(ClientError) as e: + aws_client.sqs_query.get_queue_attributes(QueueUrl=queue_url) + snapshot.match("queue-does-not-exist-query", e.value.response) + + +@pytest.fixture() +def sqs_http_client(aws_http_client_factory, region_name): + yield aws_http_client_factory("sqs", region=region_name) + + +class TestSqsQueryApi: + @markers.aws.validated + def test_get_queue_attributes_all(self, sqs_create_queue, sqs_http_client, region_name): + queue_url = sqs_create_queue() + response = sqs_http_client.get( + queue_url, + params={ + "Action": "GetQueueAttributes", + "AttributeName.1": "All", + }, + ) + + assert response.ok + assert "QueueArnarn:{get_partition(region_name)}:sqs:" + in response.text + ) + assert "VisibilityTimeout30" in response.text + assert queue_url.split("/")[-1] in response.text + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_get_queue_attributes_works_without_authparams( + self, monkeypatch, sqs_create_queue, strategy, region_name + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + response = requests.get( + queue_url, + params={ + "Action": "GetQueueAttributes", + "AttributeName.1": "All", + }, + ) + + assert response.ok + assert "QueueArnarn:{get_partition(region_name)}:sqs:" + in response.text + ) + assert "VisibilityTimeout30" in response.text + assert queue_url.split("/")[-1] in response.text + + @markers.aws.validated + def test_get_queue_attributes_with_query_args( + self, sqs_create_queue, sqs_http_client, region_name + ): + queue_url = sqs_create_queue() + response = sqs_http_client.get( + queue_url, + params={ + "Action": "GetQueueAttributes", + "AttributeName.1": "QueueArn", + }, + ) + + assert response.ok + assert "QueueArnarn:{get_partition(region_name)}:sqs:" + in response.text + ) + assert "VisibilityTimeout" not in response.text + assert queue_url.split("/")[-1] in response.text + + @markers.aws.validated + def test_invalid_action_raises_exception(self, sqs_create_queue, sqs_http_client): + queue_url = sqs_create_queue() + response = sqs_http_client.get( + queue_url, + params={ + "Action": "FooBar", + "Version": "2012-11-05", + }, + ) + + assert not response.ok + assert "InvalidAction" in response.text + assert "Sender" in response.text + assert ( + "The action FooBar is not valid for this endpoint." in response.text + ) + + @markers.aws.validated + def test_valid_action_with_missing_parameter_raises_exception( + self, sqs_create_queue, sqs_http_client + ): + queue_url = sqs_create_queue() + response = sqs_http_client.get( + queue_url, + params={ + "Action": "SendMessage", + }, + ) + + assert not response.ok + assert "MissingParameter" in response.text + assert "Sender" in response.text + assert ( + "The request must contain the parameter MessageBody." + in response.text + ) + + @markers.aws.validated + def test_get_queue_attributes_of_fifo_queue(self, sqs_create_queue, sqs_http_client): + queue_name = f"queue-{short_uid()}.fifo" + queue_url = sqs_create_queue(QueueName=queue_name, Attributes={"FifoQueue": "true"}) + + assert ".fifo" in queue_url + + response = sqs_http_client.get( + queue_url, + params={ + "Action": "GetQueueAttributes", + "AttributeName.1": "All", + }, + ) + + assert response.ok + assert "FifoQueuetrue" in response.text + assert queue_name in response.text + + @markers.aws.validated + def test_get_queue_attributes_with_invalid_arg_returns_error( + self, sqs_create_queue, sqs_http_client + ): + queue_url = sqs_create_queue() + response = sqs_http_client.get( + queue_url, + params={ + "Action": "GetQueueAttributes", + "AttributeName.1": "Foobar", + }, + ) + + assert not response.ok + assert "Sender" in response.text + assert "InvalidAttributeName" in response.text + assert "Unknown Attribute Foobar." in response.text + + @markers.aws.validated + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_get_delete_queue( + self, monkeypatch, sqs_create_queue, sqs_http_client, sqs_queue_exists, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + response = sqs_http_client.get( + queue_url, + params={ + "Action": "DeleteQueue", + }, + ) + assert response.ok + assert "" in response.text.replace( + " />", "/>" + ) # expect response to be empty + + # get items from queue 1 + response = sqs_http_client.get( + queue1_url, + params={ + "Action": "ReceiveMessage", + }, + ) + + assert response.ok + assert "foobar" in response.text + assert "" in response.text + + @markers.aws.validated + def test_get_on_deleted_queue_fails( + self, sqs_create_queue, sqs_http_client, sqs_queue_exists, aws_sqs_client + ): + queue_url = sqs_create_queue() + + aws_sqs_client.delete_queue(QueueUrl=queue_url) + + assert poll_condition(lambda: not sqs_queue_exists(queue_url), timeout=5) + + response = sqs_http_client.get( + queue_url, + params={ + "Action": "GetQueueAttributes", + "AttributeName.1": "QueueArn", + }, + ) + + assert "AWS.SimpleQueueService.NonExistentQueue" in response.text + assert "The specified queue does not exist for this wsdl version" in response.text + assert response.status_code == 400 + + @markers.aws.validated + def test_get_without_query_returns_unknown_operation(self, sqs_create_queue, sqs_http_client): + queue_name = f"test-queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + assert queue_url.endswith(f"/{queue_name}") + + response = sqs_http_client.get(queue_url) + assert "InvalidAction" in response.text + assert "The action CreateQueue is not valid for this endpoint" in response.text + assert response.status_code == 400 + + @markers.aws.validated + def test_get_list_queues_fails(self, sqs_create_queue, sqs_http_client): + queue_url = sqs_create_queue() + + response = sqs_http_client.get( + queue_url, + params={ + "Action": "ListQueues", + }, + ) + assert "InvalidAction" in response.text + assert "The action ListQueues is not valid for this endpoint" in response.text + assert response.status_code == 400 + + @markers.aws.validated + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_get_queue_url_works_for_same_queue( + self, + monkeypatch, + sqs_create_queue, + sqs_http_client, + account_id, + strategy, + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + response = sqs_http_client.get( + queue_url, + params={ + "Action": "GetQueueUrl", + "QueueName": queue_url.split("/")[-1], + "QueueOwnerAWSAccountId": account_id, + }, + ) + assert f"{queue_url}" in response.text + assert response.status_code == 200 + + @markers.aws.validated + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_get_queue_url_work_for_different_queue( + self, monkeypatch, sqs_create_queue, sqs_http_client, account_id, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + # for some reason this is allowed 🀷 + queue1_url = sqs_create_queue() + queue2_url = sqs_create_queue() + + response = sqs_http_client.get( + queue1_url, + params={ + "Action": "GetQueueUrl", + "QueueName": queue2_url.split("/")[-1], + "QueueOwnerAWSAccountId": account_id, + }, + ) + assert f"{queue2_url}" in response.text + assert queue1_url not in response.text + assert response.status_code == 200 + + @markers.aws.validated + @pytest.mark.parametrize("strategy", ["standard", "domain", "path", "off"]) + def test_endpoint_strategy_with_multi_region( + self, + strategy, + sqs_http_client, + aws_client_factory, + aws_http_client_factory, + monkeypatch, + cleanups, + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_name = f"test-queue-{short_uid()}" + region1 = "us-west-1" + region2 = "eu-north-1" + + sqs_region1 = aws_client_factory(region_name=region1).sqs + sqs_region2 = aws_client_factory(region_name=region2).sqs + + queue_region1 = sqs_region1.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: sqs_region1.delete_queue(QueueUrl=queue_region1)) + queue_region2 = sqs_region2.create_queue(QueueName=queue_name)["QueueUrl"] + cleanups.append(lambda: sqs_region2.delete_queue(QueueUrl=queue_region2)) + + if strategy == "off": + assert queue_region1 == queue_region2 + else: + assert queue_region1 != queue_region2 + assert region2 in queue_region2 + # us-east-1 is the default region, so it's not necessarily part of the queue URL + + client_region1 = aws_http_client_factory("sqs_query", region1) + client_region2 = aws_http_client_factory("sqs_query", region2) + + response = client_region1.get( + queue_region1, params={"Action": "SendMessage", "MessageBody": "foobar"} + ) + assert response.ok + + # shouldn't return anything + response = client_region2.get( + queue_region2, params={"Action": "ReceiveMessage", "VisibilityTimeout": "0"} + ) + assert response.ok + assert "foobar" not in response.text + + # should return the message + response = client_region1.get( + queue_region1, params={"Action": "ReceiveMessage", "VisibilityTimeout": "0"} + ) + assert response.ok + assert "foobar" in response.text + + @markers.aws.only_localstack + def test_queue_url_format_path_strategy( + self, sqs_create_queue, account_id, region_name, monkeypatch + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "path") + queue_name = f"path_queue_{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + assert ( + f"localhost.localstack.cloud:4566/queue/{region_name}/{account_id}/{queue_name}" + in queue_url + ) + + @markers.aws.validated + def test_overwrite_queue_url_in_params(self, sqs_create_queue, sqs_http_client, region_name): + # here, queue1 url simply serves as AWS endpoint but we pass queue2 url in the request arg + queue1_url = sqs_create_queue() + queue2_url = sqs_create_queue() + + response = sqs_http_client.get( + queue1_url, + params={ + "Action": "GetQueueAttributes", + "QueueUrl": queue2_url, + "AttributeName.1": "QueueArn", + }, + ) + + assert response.ok + assert ( + f"QueueArnarn:{get_partition(region_name)}:sqs:" + in response.text + ) + assert queue1_url.split("/")[-1] not in response.text + assert queue2_url.split("/")[-1] in response.text + + @pytest.mark.skip(reason="json serialization not supported yet") + @markers.aws.validated + def test_get_list_queues_fails_json_format(self, sqs_create_queue, sqs_http_client): + queue_url = sqs_create_queue() + + response = sqs_http_client.get( + queue_url, + headers={"Accept": "application/json"}, + params={ + "Action": "ListQueues", + }, + ) + assert response.status_code == 400 + + doc = response.json() + assert doc["Error"]["Code"] == "InvalidAction" + assert doc["Error"]["Message"] == "The action ListQueues is not valid for this endpoint." + + @pytest.mark.skip(reason="json serialization not supported yet") + @markers.aws.validated + def test_get_queue_attributes_json_format(self, sqs_create_queue, sqs_http_client): + queue_url = sqs_create_queue() + response = sqs_http_client.get( + queue_url, + headers={"Accept": "application/json"}, + params={ + "Action": "GetQueueAttributes", + "AttributeName.1": "All", + }, + ) + + assert response.ok + doc = response.json() + assert "GetQueueAttributesResponse" in doc + attributes = doc["GetQueueAttributesResponse"]["GetQueueAttributesResult"]["Attributes"] + + for attribute in attributes: + if attribute["Name"] == "QueueArn": + assert "arn:aws:sqs" in attribute["Value"] + assert queue_url.split("/")[-1] in attribute["Value"] + return + + pytest.fail(f"no QueueArn attribute in attributes {attributes}") + + @markers.aws.validated + def test_get_without_query_json_format_returns_returns_xml( + self, sqs_create_queue, sqs_http_client + ): + queue_url = sqs_create_queue() + response = sqs_http_client.get(queue_url, headers={"Accept": "application/json"}) + assert "" + }, + { + "Id": "2", + "MD5OfMessageBody": "0cc175b9c0f1b6a831c399e269772661", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents_with_updated_maximum_message_size[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:10", + "recorded-content": { + "send_oversized_message_batch": { + "Successful": [ + { + "Id": "1", + "MD5OfMessageAttributes": "a45daa70926828a2f0a1db3418e6feb4", + "MD5OfMessageBody": "f44facf5a7ee0af446ecf3e0f854441e", + "MessageId": "" + }, + { + "Id": "2", + "MD5OfMessageBody": "0cc175b9c0f1b6a831c399e269772661", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_untag_queue[sqs]": { + "recorded-date": "30-04-2024, 13:35:30", + "recorded-content": { + "get-tag-1": { + "Tags": { + "tag1": "value1", + "tag2": "value2", + "tag3": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-tag-2": { + "Tags": { + "tag2": "value2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-tag-after-untag": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_untag_queue[sqs_query]": { + "recorded-date": "30-04-2024, 13:35:33", + "recorded-content": { + "get-tag-1": { + "Tags": { + "tag1": "value1", + "tag2": "value2", + "tag3": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-tag-2": { + "Tags": { + "tag2": "value2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-tag-after-untag": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_fifo_queue_with_different_attributes_raises_error[sqs]": { + "recorded-date": "30-04-2024, 13:33:13", + "recorded-content": { + "queue-already-exists": { + "Error": { + "Code": "QueueAlreadyExists", + "Message": "A queue already exists with the same name and a different value for attribute ContentBasedDeduplication", + "QueryErrorCode": "QueueNameExists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_fifo_queue_with_different_attributes_raises_error[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:14", + "recorded-content": { + "queue-already-exists": { + "Error": { + "Code": "QueueAlreadyExists", + "Message": "A queue already exists with the same name and a different value for attribute ContentBasedDeduplication", + "QueryErrorCode": "QueueNameExists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_deduplication_id_too_long": { + "recorded-date": "30-04-2024, 13:35:34", + "recorded-content": { + "error-response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa for parameter MessageDeduplicationId is invalid. Reason: MessageDeduplicationId can only include alphanumeric and punctuation characters. 1 to 128 in length.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_group_id_too_long": { + "recorded-date": "30-04-2024, 13:35:35", + "recorded-content": { + "error-response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa for parameter MessageGroupId is invalid. Reason: MessageGroupId can only include alphanumeric and punctuation characters. 1 to 128 in length.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_different_attributes_raises_exception[sqs]": { + "recorded-date": "30-04-2024, 13:33:18", + "recorded-content": { + "create_queue_01": { + "Error": { + "Code": "QueueAlreadyExists", + "Message": "A queue already exists with the same name and a different value for attribute DelaySeconds", + "QueryErrorCode": "QueueNameExists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_queue_02": { + "Error": { + "Code": "QueueAlreadyExists", + "Message": "A queue already exists with the same name and a different value for attribute DelaySeconds", + "QueryErrorCode": "QueueNameExists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_different_attributes_raises_exception[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:20", + "recorded-content": { + "create_queue_01": { + "Error": { + "Code": "QueueAlreadyExists", + "Message": "A queue already exists with the same name and a different value for attribute DelaySeconds", + "QueryErrorCode": "QueueNameExists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "create_queue_02": { + "Error": { + "Code": "QueueAlreadyExists", + "Message": "A queue already exists with the same name and a different value for attribute DelaySeconds", + "QueryErrorCode": "QueueNameExists", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_update_queue_attributes[sqs]": { + "recorded-date": "30-04-2024, 13:33:21", + "recorded-content": { + "get_queue_attributes": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "CreatedTimestamp": "timestamp", + "DelaySeconds": "0", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "604800", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "10", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "20" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_updated_queue_attributes": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "CreatedTimestamp": "timestamp", + "DelaySeconds": "420", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "2048", + "MessageRetentionPeriod": "604800", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "10", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "69" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_update_queue_attributes[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:22", + "recorded-content": { + "get_queue_attributes": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "CreatedTimestamp": "timestamp", + "DelaySeconds": "0", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "604800", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "10", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "20" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_updated_queue_attributes": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "CreatedTimestamp": "timestamp", + "DelaySeconds": "420", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "2048", + "MessageRetentionPeriod": "604800", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "10", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "69" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_after_visibility_timeout_expiration[sqs]": { + "recorded-date": "30-04-2024, 13:33:27", + "recorded-content": { + "visibility_timeout_expired": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_after_visibility_timeout_expiration[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:30", + "recorded-content": { + "visibility_timeout_expired": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_empty_message_groups_added_back_to_queue[sqs]": { + "recorded-date": "30-04-2024, 13:46:32", + "recorded-content": { + "inital-fifo-receive": { + "Messages": [ + { + "Body": "Message 1", + "MD5OfBody": "68390233272823b7adf13a1db79b2cd7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty-fifo-receive": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "final-fifo-receive": { + "Messages": [ + { + "Body": "Message 2", + "MD5OfBody": "88ef8f31ed540f1c4c03d5fdb06a7935", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_empty_message_groups_added_back_to_queue[sqs_query]": { + "recorded-date": "30-04-2024, 13:46:34", + "recorded-content": { + "inital-fifo-receive": { + "Messages": [ + { + "Body": "Message 1", + "MD5OfBody": "68390233272823b7adf13a1db79b2cd7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty-fifo-receive": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "final-fifo-receive": { + "Messages": [ + { + "Body": "Message 2", + "MD5OfBody": "88ef8f31ed540f1c4c03d5fdb06a7935", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_seconds_fails[sqs]": { + "recorded-date": "30-04-2024, 13:33:33", + "recorded-content": { + "send_message": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value 2 for parameter DelaySeconds is invalid. Reason: The request include parameter that is not valid for this queue type.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_seconds_fails[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:34", + "recorded-content": { + "send_message": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "Value 2 for parameter DelaySeconds is invalid. Reason: The request include parameter that is not valid for this queue type.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_attributes[sqs]": { + "recorded-date": "30-04-2024, 13:36:56", + "recorded-content": { + "send_message": { + "MD5OfMessageBody": "19c9e282d65f9733bc6b35d50062c7ee", + "MessageId": "", + "SequenceNumber": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_0": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "dedup-1", + "MessageGroupId": "group-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "message-body-1", + "MD5OfBody": "19c9e282d65f9733bc6b35d50062c7ee", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_1": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "MessageDeduplicationId": "dedup-1", + "MessageGroupId": "group-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "message-body-1", + "MD5OfBody": "19c9e282d65f9733bc6b35d50062c7ee", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_attributes[sqs_query]": { + "recorded-date": "30-04-2024, 13:36:57", + "recorded-content": { + "send_message": { + "MD5OfMessageBody": "19c9e282d65f9733bc6b35d50062c7ee", + "MessageId": "", + "SequenceNumber": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_0": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "MessageDeduplicationId": "dedup-1", + "MessageGroupId": "group-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "message-body-1", + "MD5OfBody": "19c9e282d65f9733bc6b35d50062c7ee", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "receive_message_1": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "MessageDeduplicationId": "dedup-1", + "MessageGroupId": "group-1", + "SenderId": "", + "SentTimestamp": "timestamp", + "SequenceNumber": "" + }, + "Body": "message-body-1", + "MD5OfBody": "19c9e282d65f9733bc6b35d50062c7ee", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_message_with_expired_receipt_handle[sqs]": { + "recorded-date": "30-04-2024, 13:39:23", + "recorded-content": {} + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_message_with_expired_receipt_handle[sqs_query]": { + "recorded-date": "30-04-2024, 13:39:25", + "recorded-content": {} + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_set_content_based_deduplication_strategy[sqs]": { + "recorded-date": "30-04-2024, 13:47:39", + "recorded-content": { + "before-update": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "true", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "after-update": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_set_content_based_deduplication_strategy[sqs_query]": { + "recorded-date": "30-04-2024, 13:47:41", + "recorded-content": { + "before-update": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "true", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "after-update": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_too_many_entries_in_batch_request[sqs]": { + "recorded-date": "30-04-2024, 13:33:39", + "recorded-content": { + "test_too_many_entries_in_batch_request": { + "Error": { + "Code": "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + "Message": "Maximum number of entries per request are 10. You have sent 20.", + "QueryErrorCode": "TooManyEntriesInBatchRequest", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_too_many_entries_in_batch_request[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:40", + "recorded-content": { + "test_too_many_entries_in_batch_request": { + "Error": { + "Code": "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + "Detail": null, + "Message": "Maximum number of entries per request are 10. You have sent 20.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_batch_id[sqs]": { + "recorded-date": "30-04-2024, 13:33:40", + "recorded-content": { + "test_invalid_batch_id": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "QueryErrorCode": "InvalidBatchEntryId", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_batch_id[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:41", + "recorded-content": { + "test_invalid_batch_id": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Detail": null, + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_deduplication_id_for_fifo_queue[sqs]": { + "recorded-date": "30-04-2024, 13:33:42", + "recorded-content": { + "test_missing_deduplication_id_for_fifo_queue": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_deduplication_id_for_fifo_queue[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:43", + "recorded-content": { + "test_missing_deduplication_id_for_fifo_queue": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_message_size": { + "recorded-date": "30-04-2024, 13:33:44", + "recorded-content": { + "send-message-batch-result": { + "Failed": [ + { + "Code": "InvalidParameterValue", + "Id": "", + "Message": "One or more parameters cannot be validated. Reason: Message must be shorter than 1024 bytes.", + "SenderFault": true + } + ], + "Successful": [ + { + "Id": "", + "MD5OfMessageBody": "c9a34cfc85d982698c6ac89f76071abd", + "MessageId": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_message_group_id_for_fifo_queue[sqs]": { + "recorded-date": "30-04-2024, 13:33:44", + "recorded-content": { + "test_missing_message_group_id_for_fifo_queue": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The request must contain the parameter MessageGroupId.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_to_standard_queue_with_empty_message_group_id": { + "recorded-date": "08-11-2024, 12:04:39", + "recorded-content": { + "error-response": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value for parameter MessageGroupId is invalid. Reason: The request include parameter that is not valid for this queue type.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_message_group_id_for_fifo_queue[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:45", + "recorded-content": { + "test_missing_message_group_id_for_fifo_queue": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "The request must contain the parameter MessageGroupId.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-]": { + "recorded-date": "30-04-2024, 13:48:34", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "QueryErrorCode": "InvalidBatchEntryId", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-testLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongId]": { + "recorded-date": "30-04-2024, 13:48:35", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "QueryErrorCode": "InvalidBatchEntryId", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-invalid:id]": { + "recorded-date": "30-04-2024, 13:48:36", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "QueryErrorCode": "InvalidBatchEntryId", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-]": { + "recorded-date": "30-04-2024, 13:48:37", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-testLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongId]": { + "recorded-date": "30-04-2024, 13:48:38", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-invalid:id]": { + "recorded-date": "30-04-2024, 13:48:38", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.InvalidBatchEntryId", + "Message": "A batch entry id can only contain alphanumeric characters, hyphens and underscores. It can be at most 80 letters long.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_with_too_large_batch[sqs]": { + "recorded-date": "30-04-2024, 13:49:26", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + "Message": "Maximum number of entries per request are 10. You have sent 20.", + "QueryErrorCode": "TooManyEntriesInBatchRequest", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_with_too_large_batch[sqs_query]": { + "recorded-date": "30-04-2024, 13:49:32", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + "Message": "Maximum number of entries per request are 10. You have sent 20.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_batch_with_too_large_batch[sqs]": { + "recorded-date": "30-04-2024, 13:50:34", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + "Message": "Maximum number of entries per request are 10. You have sent 20.", + "QueryErrorCode": "TooManyEntriesInBatchRequest", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_batch_with_too_large_batch[sqs_query]": { + "recorded-date": "30-04-2024, 13:50:41", + "recorded-content": { + "error_response": { + "Error": { + "Code": "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", + "Message": "Maximum number of entries per request are 10. You have sent 20.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_dead_letter_arn_rejected_before_lookup": { + "recorded-date": "30-04-2024, 13:33:47", + "recorded-content": {} + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_binary_attributes[sqs]": { + "recorded-date": "30-04-2024, 13:33:48", + "recorded-content": { + "binary-attrs-msg": { + "Messages": [ + { + "Body": "test", + "MD5OfBody": "098f6bcd4621d373cade4e832627b4f6", + "MD5OfMessageAttributes": "8cbe4db156db8a94db8b801b7addb984", + "MessageAttributes": { + "attr1": { + "BinaryValue": "b'traceparent\\x1e00-774062d6c37081a5a0b9b5b88e30627c-2d2482211f6489da-01'", + "DataType": "Binary" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_binary_attributes[sqs_query]": { + "recorded-date": "30-04-2024, 13:33:49", + "recorded-content": { + "binary-attrs-msg": { + "Messages": [ + { + "Body": "test", + "MD5OfBody": "098f6bcd4621d373cade4e832627b4f6", + "MD5OfMessageAttributes": "8cbe4db156db8a94db8b801b7addb984", + "MessageAttributes": { + "attr1": { + "BinaryValue": "b'traceparent\\x1e00-774062d6c37081a5a0b9b5b88e30627c-2d2482211f6489da-01'", + "DataType": "Binary" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_empty_string_attribute[sqs]": { + "recorded-date": "30-04-2024, 13:35:41", + "recorded-content": { + "empty-string-attr": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Message (user) attribute 'ErrorDetails' must contain a non-empty value of type 'String'.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_empty_string_attribute[sqs_query]": { + "recorded-date": "30-04-2024, 13:35:42", + "recorded-content": { + "empty-string-attr": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "Message (user) attribute 'ErrorDetails' must contain a non-empty value of type 'String'.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_message_attributes": { + "recorded-date": "30-04-2024, 13:33:55", + "recorded-content": { + "dlq-arn": "arn::sqs::111111111111:", + "sourcen-arn": "arn::sqs::111111111111:", + "rec-pre-dlq": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "3d6b824fd8c1520e9a047d21fee6fb1f", + "Body": "message-1", + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SentTimestamp": "timestamp", + "AWSTraceHeader": "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1" + }, + "MD5OfMessageAttributes": "adb59cd4678ea5a855436b949cd07ab6", + "MessageAttributes": { + "MyAttribute": { + "StringValue": "foobar", + "DataType": "String" + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "95ef155b66299d14edf7ed57c468c13b", + "Body": "message-2", + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SentTimestamp": "timestamp" + } + } + ], + "dlq-messages": [ + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "3d6b824fd8c1520e9a047d21fee6fb1f", + "Body": "message-1", + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SentTimestamp": "timestamp", + "AWSTraceHeader": "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1", + "DeadLetterQueueSourceArn": "arn::sqs::111111111111:" + }, + "MD5OfMessageAttributes": "adb59cd4678ea5a855436b949cd07ab6", + "MessageAttributes": { + "MyAttribute": { + "StringValue": "foobar", + "DataType": "String" + } + } + }, + { + "MessageId": "", + "ReceiptHandle": "", + "MD5OfBody": "95ef155b66299d14edf7ed57c468c13b", + "Body": "message-2", + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SentTimestamp": "timestamp", + "DeadLetterQueueSourceArn": "arn::sqs::111111111111:" + } + } + ] + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_fifo[sqs]": { + "recorded-date": "14-05-2024, 22:23:46", + "recorded-content": { + "invalid-attr-name-1": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "Unknown Attribute FifoQueue.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "Unknown Attribute FifoQueue.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attr-name-2": { + "Error": { + "Code": "InvalidAttributeValue", + "Message": "Invalid value for the parameter FifoQueue. Reason: Modifying queue type is not supported.", + "QueryErrorCode": "InvalidAttributeValue", + "Type": "Sender" + }, + "message": "Invalid value for the parameter FifoQueue. Reason: Modifying queue type is not supported.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_fifo[sqs_query]": { + "recorded-date": "14-05-2024, 22:23:47", + "recorded-content": { + "invalid-attr-name-1": { + "Error": { + "Code": "InvalidAttributeName", + "Detail": null, + "Message": "Unknown Attribute FifoQueue.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attr-name-2": { + "Error": { + "Code": "InvalidAttributeValue", + "Detail": null, + "Message": "Invalid value for the parameter FifoQueue. Reason: Modifying queue type is not supported.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs-True]": { + "recorded-date": "30-04-2024, 13:34:01", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-messages-duplicate": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs-False]": { + "recorded-date": "30-04-2024, 13:34:04", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-messages-duplicate": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs_query-True]": { + "recorded-date": "30-04-2024, 13:34:06", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-messages-duplicate": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs_query-False]": { + "recorded-date": "30-04-2024, 13:34:09", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-messages-duplicate": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs-True]": { + "recorded-date": "30-04-2024, 13:34:11", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs-False]": { + "recorded-date": "30-04-2024, 13:34:14", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs_query-True]": { + "recorded-date": "30-04-2024, 13:34:17", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs_query-False]": { + "recorded-date": "30-04-2024, 13:34:19", + "recorded-content": { + "get-messages": { + "Messages": [ + { + "Body": { + "foo": "bar" + }, + "MD5OfBody": "94232c5b8fc9272f6f73a1e36eb68fcf", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-dedup-messages": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_fifo_requires_deduplicationid_group_id[sqs]": { + "recorded-date": "30-04-2024, 13:34:21", + "recorded-content": { + "invalid-parameter-value": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing-parameter": { + "Error": { + "Code": "MissingParameter", + "Message": "The request must contain the parameter MessageGroupId.", + "QueryErrorCode": "MissingRequiredParameterException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-parameter-value-query": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing-parameter-query": { + "Error": { + "Code": "MissingParameter", + "Message": "The request must contain the parameter MessageGroupId.", + "QueryErrorCode": "MissingRequiredParameterException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_fifo_requires_deduplicationid_group_id[sqs_query]": { + "recorded-date": "30-04-2024, 13:34:22", + "recorded-content": { + "invalid-parameter-value": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing-parameter": { + "Error": { + "Code": "MissingParameter", + "Detail": null, + "Message": "The request must contain the parameter MessageGroupId.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-parameter-value-query": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "missing-parameter-query": { + "Error": { + "Code": "MissingParameter", + "Message": "The request must contain the parameter MessageGroupId.", + "QueryErrorCode": "MissingRequiredParameterException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_successive_purge_calls_fail[sqs]": { + "recorded-date": "30-04-2024, 13:37:02", + "recorded-content": { + "purge_queue_error": { + "Error": { + "Code": "AWS.SimpleQueueService.PurgeQueueInProgress", + "Message": "Only one PurgeQueue operation on is allowed every 60 seconds.", + "QueryErrorCode": "PurgeQueueInProgress", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_successive_purge_calls_fail[sqs_query]": { + "recorded-date": "30-04-2024, 13:37:04", + "recorded-content": { + "purge_queue_error": { + "Error": { + "Code": "AWS.SimpleQueueService.PurgeQueueInProgress", + "Detail": null, + "Message": "Only one PurgeQueue operation on is allowed every 60 seconds.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 403 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_same_dedup_id_different_message_groups[sqs]": { + "recorded-date": "30-04-2024, 13:51:17", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test", + "MD5OfBody": "0cbc6611f5540bd0809a388dc95a615b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_same_dedup_id_different_message_groups[sqs_query]": { + "recorded-date": "30-04-2024, 13:51:20", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test", + "MD5OfBody": "0cbc6611f5540bd0809a388dc95a615b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_high_throughput_ordering[sqs]": { + "recorded-date": "30-04-2024, 13:34:27", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test1", + "MD5OfBody": "e1b849f9631ffc1829b2e31402373e3c", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "Messages": [ + { + "Body": "Test2", + "MD5OfBody": "c454552d52d55d3ef56408742887362b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_high_throughput_ordering[sqs_query]": { + "recorded-date": "30-04-2024, 13:34:29", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test1", + "MD5OfBody": "e1b849f9631ffc1829b2e31402373e3c", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "Messages": [ + { + "Body": "Test2", + "MD5OfBody": "c454552d52d55d3ef56408742887362b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_high_throughput_after_creation[sqs]": { + "recorded-date": "24-05-2024, 09:41:13", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test1", + "MD5OfBody": "e1b849f9631ffc1829b2e31402373e3c", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-to-high-throughput": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "messageGroup", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perMessageGroupId", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-high-throughput": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-high-throughput-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new-dedup2-high-throughput": { + "Messages": [ + { + "Body": "Test4", + "MD5OfBody": "41d5e808720c8ee71257214e952a6721", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup2-high-throughput": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_high_throughput_after_creation[sqs_query]": { + "recorded-date": "24-05-2024, 09:41:19", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test1", + "MD5OfBody": "e1b849f9631ffc1829b2e31402373e3c", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-to-high-throughput": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "messageGroup", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perMessageGroupId", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-high-throughput": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-high-throughput-2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "new-dedup2-high-throughput": { + "Messages": [ + { + "Body": "Test4", + "MD5OfBody": "41d5e808720c8ee71257214e952a6721", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup2-high-throughput": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_regular_throughput_after_creation[sqs]": { + "recorded-date": "30-04-2024, 13:52:03", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test1", + "MD5OfBody": "e1b849f9631ffc1829b2e31402373e3c", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "Messages": [ + { + "Body": "Test2", + "MD5OfBody": "c454552d52d55d3ef56408742887362b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-to-regular-throughput": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-regular-throughput": { + "Messages": [ + { + "Body": "Test3", + "MD5OfBody": "b3f66ec1535de7702c38e94408fa4a17", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_regular_throughput_after_creation[sqs_query]": { + "recorded-date": "30-04-2024, 13:52:06", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test1", + "MD5OfBody": "e1b849f9631ffc1829b2e31402373e3c", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "Messages": [ + { + "Body": "Test2", + "MD5OfBody": "c454552d52d55d3ef56408742887362b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "set-to-regular-throughput": { + "Attributes": { + "ApproximateNumberOfMessages": "0", + "ApproximateNumberOfMessagesDelayed": "0", + "ApproximateNumberOfMessagesNotVisible": "0", + "ContentBasedDeduplication": "false", + "CreatedTimestamp": "timestamp", + "DeduplicationScope": "queue", + "DelaySeconds": "0", + "FifoQueue": "true", + "FifoThroughputLimit": "perQueue", + "LastModifiedTimestamp": "timestamp", + "MaximumMessageSize": "262144", + "MessageRetentionPeriod": "345600", + "QueueArn": "arn::sqs::111111111111:", + "ReceiveMessageWaitTimeSeconds": "0", + "SqsManagedSseEnabled": "true", + "VisibilityTimeout": "30" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-regular-throughput": { + "Messages": [ + { + "Body": "Test3", + "MD5OfBody": "b3f66ec1535de7702c38e94408fa4a17", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_queue_attributes[sqs]": { + "recorded-date": "30-04-2024, 13:34:43", + "recorded-content": { + "sse_kms_attributes": { + "Attributes": { + "KmsDataKeyReusePeriodSeconds": "6000", + "KmsMasterKeyId": "testKeyId", + "SqsManagedSseEnabled": "false" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sse_sqs_attributes": { + "Attributes": { + "SqsManagedSseEnabled": "true" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_queue_attributes[sqs_query]": { + "recorded-date": "30-04-2024, 13:34:47", + "recorded-content": { + "sse_kms_attributes": { + "Attributes": { + "KmsDataKeyReusePeriodSeconds": "6000", + "KmsMasterKeyId": "testKeyId", + "SqsManagedSseEnabled": "false" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "sse_sqs_attributes": { + "Attributes": { + "SqsManagedSseEnabled": "true" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_kms_and_sqs_are_mutually_exclusive[sqs]": { + "recorded-date": "30-04-2024, 13:34:48", + "recorded-content": { + "error": "An error occurred (InvalidAttributeName) when calling the SetQueueAttributes operation: You can use one type of server-side encryption (SSE) at one time. You can either enable KMS SSE or SQS SSE." + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_kms_and_sqs_are_mutually_exclusive[sqs_query]": { + "recorded-date": "30-04-2024, 13:34:48", + "recorded-content": { + "error": "An error occurred (InvalidAttributeName) when calling the SetQueueAttributes operation: You can use one type of server-side encryption (SSE) at one time. You can either enable KMS SSE or SQS SSE." + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_attribute_names_filters[sqs]": { + "recorded-date": "30-04-2024, 13:34:51", + "recorded-content": { + "send_message_response": { + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MD5OfMessageBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_filter": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_name": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_wildcard_asterisk": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "all_wildcard": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "only_non_existing_names": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + }, + "only_existing": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "fca026605781cb4126a1e9044df24232", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "existing_and_non_existing": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "a311262e065454b75da111d535b8dacd", + "MessageAttributes": { + "Hello": { + "DataType": "String", + "StringValue": "There" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "prefix_filter": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "83fee93c1bcd8b9a5a923ffacdc636c7", + "MessageAttributes": { + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "illegal_name_1": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "illegal_name_2": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_attribute_names_filters[sqs_query]": { + "recorded-date": "30-04-2024, 13:34:55", + "recorded-content": { + "send_message_response": { + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MD5OfMessageBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_filter": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_name": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_wildcard_asterisk": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "all_wildcard": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "4c360f3fdafd970e05fae2f149d997f5", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "only_non_existing_names": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + }, + "only_existing": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "fca026605781cb4126a1e9044df24232", + "MessageAttributes": { + "General": { + "DataType": "String", + "StringValue": "Kenobi" + }, + "Hello": { + "DataType": "String", + "StringValue": "There" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "existing_and_non_existing": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "a311262e065454b75da111d535b8dacd", + "MessageAttributes": { + "Hello": { + "DataType": "String", + "StringValue": "There" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "prefix_filter": { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "83fee93c1bcd8b9a5a923ffacdc636c7", + "MessageAttributes": { + "Hello": { + "DataType": "String", + "StringValue": "There" + }, + "Help.Me": { + "DataType": "String", + "StringValue": "Me" + } + }, + "MessageId": "", + "ReceiptHandle": "" + }, + "illegal_name_1": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "illegal_name_2": { + "Messages": [ + { + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attribute_names_filters[sqs]": { + "recorded-date": "04-06-2024, 11:54:31", + "recorded-content": { + "all_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_system_and_message_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "ae7155c6026991b6d54b11589678bf9c", + "MessageAttributes": { + "Foo": { + "DataType": "String", + "StringValue": "Bar" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "single_attribute": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multiple_attributes": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attribute_names_filters[sqs_query]": { + "recorded-date": "04-06-2024, 11:54:34", + "recorded-content": { + "all_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_system_and_message_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "ae7155c6026991b6d54b11589678bf9c", + "MessageAttributes": { + "Foo": { + "DataType": "String", + "StringValue": "Bar" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "single_attribute": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multiple_attributes": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_permission_lifecycle[sqs]": { + "recorded-date": "30-04-2024, 13:35:03", + "recorded-content": { + "add-permission-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission", + "Effect": "Allow", + "Principal": { + "AWS": [ + "arn::iam::668614515564:", + "arn::iam::111111111111:" + ] + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-permission-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-after-removal": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-first-account-same-label": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-second-account-same-label": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value crossaccountpermission for parameter Label is invalid. Reason: Already exists.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-queue-policy-attribute-second-account-different-label": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + }, + { + "Sid": "crossaccountpermission2", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-delete-first-permission": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission2", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-delete-second-permission": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-delete-non-existent-label": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value crossaccountpermission2 for parameter Label is invalid. Reason: can't find label.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_permission_lifecycle[sqs_query]": { + "recorded-date": "30-04-2024, 13:35:07", + "recorded-content": { + "add-permission-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission", + "Effect": "Allow", + "Principal": { + "AWS": [ + "arn::iam::668614515564:", + "arn::iam::111111111111:" + ] + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "remove-permission-response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-after-removal": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-first-account-same-label": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-second-account-same-label": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "Value crossaccountpermission for parameter Label is invalid. Reason: Already exists.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get-queue-policy-attribute-second-account-different-label": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + }, + { + "Sid": "crossaccountpermission2", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-delete-first-permission": { + "Attributes": { + "Policy": { + "Version": "2008-10-17", + "Id": "arn::sqs::111111111111:/", + "Statement": [ + { + "Sid": "crossaccountpermission2", + "Effect": "Allow", + "Principal": { + "AWS": "arn::iam::111111111111:" + }, + "Action": "SQS:ReceiveMessage", + "Resource": "arn::sqs::111111111111:" + } + ] + } + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-delete-second-permission": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-queue-policy-attribute-delete-non-existent-label": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "Value crossaccountpermission2 for parameter Label is invalid. Reason: can't find label.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_non_existent_queue": { + "recorded-date": "30-04-2024, 13:35:47", + "recorded-content": { + "queue-does-not-exist": { + "Error": { + "Code": "AWS.SimpleQueueService.NonExistentQueue", + "Message": "The specified queue does not exist.", + "QueryErrorCode": "QueueDoesNotExist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "queue-does-not-exist-url": { + "Error": { + "Code": "AWS.SimpleQueueService.NonExistentQueue", + "Message": "The specified queue does not exist.", + "QueryErrorCode": "QueueDoesNotExist", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "queue-does-not-exist-query": { + "Error": { + "Code": "AWS.SimpleQueueService.NonExistentQueue", + "Detail": null, + "Message": "The specified queue does not exist for this wsdl version.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_send_message_via_queue_url_with_json_protocol": { + "recorded-date": "30-04-2024, 13:35:11", + "recorded-content": { + "receive-json-on-queue-url": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_standard[sqs]": { + "recorded-date": "14-05-2024, 22:34:35", + "recorded-content": { + "invalid-attr-name-1": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "Unknown Attribute FifoQueue.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "Unknown Attribute FifoQueue.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attr-name-2": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "Unknown Attribute FifoQueue.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "Unknown Attribute FifoQueue.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_standard[sqs_query]": { + "recorded-date": "14-05-2024, 22:34:35", + "recorded-content": { + "invalid-attr-name-1": { + "Error": { + "Code": "InvalidAttributeName", + "Detail": null, + "Message": "Unknown Attribute FifoQueue.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attr-name-2": { + "Error": { + "Code": "InvalidAttributeName", + "Detail": null, + "Message": "Unknown Attribute FifoQueue.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_standard_queue_with_fifo_attribute_raises_error[sqs]": { + "recorded-date": "15-05-2024, 02:30:47", + "recorded-content": { + "invalid-attribute-fifo-queue": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "Unknown Attribute FifoQueue.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "Unknown Attribute FifoQueue.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attribute-content-based-deduplication": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "Unknown Attribute ContentBasedDeduplication.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "Unknown Attribute ContentBasedDeduplication.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attribute-deduplication-scope": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "You can specify the DeduplicationScope only when FifoQueue is set to true.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "You can specify the DeduplicationScope only when FifoQueue is set to true.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attribute-throughput-limit": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "You can specify the FifoThroughputLimit only when FifoQueue is set to true.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "You can specify the FifoThroughputLimit only when FifoQueue is set to true.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_standard_queue_with_fifo_attribute_raises_error[sqs_query]": { + "recorded-date": "15-05-2024, 02:30:48", + "recorded-content": { + "invalid-attribute-fifo-queue": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "Unknown Attribute FifoQueue.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "Unknown Attribute FifoQueue.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attribute-content-based-deduplication": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "Unknown Attribute ContentBasedDeduplication.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "Unknown Attribute ContentBasedDeduplication.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attribute-deduplication-scope": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "You can specify the DeduplicationScope only when FifoQueue is set to true.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "You can specify the DeduplicationScope only when FifoQueue is set to true.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid-attribute-throughput-limit": { + "Error": { + "Code": "InvalidAttributeName", + "Message": "You can specify the FifoThroughputLimit only when FifoQueue is set to true.", + "QueryErrorCode": "InvalidAttributeName", + "Type": "Sender" + }, + "message": "You can specify the FifoThroughputLimit only when FifoQueue is set to true.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_with_fifo_and_content_based_deduplication[sqs]": { + "recorded-date": "21-05-2024, 13:48:08", + "recorded-content": {} + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_with_fifo_and_content_based_deduplication[sqs_query]": { + "recorded-date": "21-05-2024, 13:48:14", + "recorded-content": {} + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_message_group_scope_no_throughput_setting[sqs]": { + "recorded-date": "24-05-2024, 09:03:33", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test", + "MD5OfBody": "0cbc6611f5540bd0809a388dc95a615b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "Messages": [ + { + "Body": "Test", + "MD5OfBody": "0cbc6611f5540bd0809a388dc95a615b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_message_group_scope_no_throughput_setting[sqs_query]": { + "recorded-date": "24-05-2024, 09:03:35", + "recorded-content": { + "same-dedup-different-grp-1": { + "Messages": [ + { + "Body": "Test", + "MD5OfBody": "0cbc6611f5540bd0809a388dc95a615b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-dedup-different-grp-2": { + "Messages": [ + { + "Body": "Test", + "MD5OfBody": "0cbc6611f5540bd0809a388dc95a615b", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_system_attribute_names_filters[sqs]": { + "recorded-date": "04-06-2024, 16:10:07", + "recorded-content": { + "all_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_system_and_message_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "ae7155c6026991b6d54b11589678bf9c", + "MessageAttributes": { + "Foo": { + "DataType": "String", + "StringValue": "Bar" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "single_attribute": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multiple_attributes": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_system_attribute_names_filters[sqs_query]": { + "recorded-date": "04-06-2024, 16:10:10", + "recorded-content": { + "all_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "all_system_and_message_attributes": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MD5OfMessageAttributes": "ae7155c6026991b6d54b11589678bf9c", + "MessageAttributes": { + "Foo": { + "DataType": "String", + "StringValue": "Bar" + } + }, + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "single_attribute": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "multiple_attributes": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_system_attribute_names_with_attribute_names[sqs]": { + "recorded-date": "04-06-2024, 17:43:13", + "recorded-content": { + "same_values": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "different_values": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "2", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_system_attribute_names_with_attribute_names[sqs_query]": { + "recorded-date": "04-06-2024, 17:43:15", + "recorded-content": { + "same_values": { + "Messages": [ + { + "Attributes": { + "ApproximateFirstReceiveTimestamp": "timestamp", + "ApproximateReceiveCount": "1", + "SenderId": "", + "SentTimestamp": "timestamp" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "different_values": { + "Messages": [ + { + "Attributes": { + "SenderId": "" + }, + "Body": "msg", + "MD5OfBody": "6e2baaf3b97dbeef01c0043275f9a0e7", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_redrive_policy[sqs]": { + "recorded-date": "20-08-2024, 14:14:08", + "recorded-content": {} + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_redrive_policy[sqs_query]": { + "recorded-date": "20-08-2024, 14:14:11", + "recorded-content": {} + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_wait_time_seconds[sqs]": { + "recorded-date": "10-02-2025, 13:22:29", + "recorded-content": { + "recieve_message_error_too_large": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value 21 for parameter WaitTimeSeconds is invalid. Reason: Must be >= 0 and <= 20, if provided.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "recieve_message_error_too_small": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value -1 for parameter WaitTimeSeconds is invalid. Reason: Must be >= 0 and <= 20, if provided.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "empty_short_poll_by_default_resp": { + "Messages": [ + { + "Body": "message", + "MD5OfBody": "78e731027d8fd50ed642340b7c9a63b3", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_short_poll_explicit_resp": { + "Messages": [ + { + "Body": "message", + "MD5OfBody": "78e731027d8fd50ed642340b7c9a63b3", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_wait_time_seconds[sqs_query]": { + "recorded-date": "10-02-2025, 13:22:32", + "recorded-content": { + "recieve_message_error_too_large": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "Value 21 for parameter WaitTimeSeconds is invalid. Reason: Must be >= 0 and <= 20, if provided.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "recieve_message_error_too_small": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "Value -1 for parameter WaitTimeSeconds is invalid. Reason: Must be >= 0 and <= 20, if provided.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "empty_short_poll_by_default_resp": { + "Messages": [ + { + "Body": "message", + "MD5OfBody": "78e731027d8fd50ed642340b7c9a63b3", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_short_poll_explicit_resp": { + "Messages": [ + { + "Body": "message", + "MD5OfBody": "78e731027d8fd50ed642340b7c9a63b3", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_empty_queue[sqs]": { + "recorded-date": "10-02-2025, 13:18:17", + "recorded-content": { + "empty_short_poll_resp_no_param": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_short_poll_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_long_poll_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_empty_queue[sqs_query]": { + "recorded-date": "10-02-2025, 13:18:20", + "recorded-content": { + "empty_short_poll_resp_no_param": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_short_poll_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "empty_long_poll_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_empty_message[sqs]": { + "recorded-date": "05-03-2025, 19:25:09", + "recorded-content": { + "send_empty_message": { + "Error": { + "Code": "MissingParameter", + "Message": "The request must contain the parameter MessageBody.", + "QueryErrorCode": "MissingRequiredParameterException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_empty_message[sqs_query]": { + "recorded-date": "05-03-2025, 19:25:09", + "recorded-content": { + "send_empty_message": { + "Error": { + "Code": "MissingParameter", + "Detail": null, + "Message": "The request must contain the parameter MessageBody.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues_pagination": { + "recorded-date": "19-03-2025, 21:04:35", + "recorded-content": { + "list_all": { + "QueueUrls": [ + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-0", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-1", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-2", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-3", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-4", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-5", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-6", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-7", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-8", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-9" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_two_max": { + "NextToken": "aHR0cDovL3Nxcy48cmVnaW9uPi5sb2NhbGhvc3QubG9jYWxzdGFjay5jbG91ZDo0NTY2LzExMTExMTExMTExMS83ZjdkZjBmNS10ZXN0LXF1ZXVlLTE=", + "QueueUrls": [ + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-0", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-1" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_remaining": { + "QueueUrls": [ + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-2", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-3", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-4", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-5", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-6", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-7", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-8", + "http://sqs..localhost.localstack.cloud:4566/111111111111/7f7df0f5-test-queue-9" + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_after_visibility_timeout[sqs]": { + "recorded-date": "28-03-2025, 13:46:28", + "recorded-content": { + "delete_after_timeout_queue_empty": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_after_visibility_timeout[sqs_query]": { + "recorded-date": "28-03-2025, 13:46:31", + "recorded-content": { + "delete_after_timeout_queue_empty": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_after_visibility_timeout[sqs]": { + "recorded-date": "28-03-2025, 13:28:19", + "recorded-content": { + "received_sqs_message": { + "Messages": [ + { + "Body": "foobar", + "MD5OfBody": "3858f62230ac3c915f300c664312c63f", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_after_timeout_fifo": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value for parameter ReceiptHandle is invalid. Reason: The receipt handle has expired.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_after_visibility_timeout[sqs_query]": { + "recorded-date": "28-03-2025, 13:28:23", + "recorded-content": { + "received_sqs_message": { + "Messages": [ + { + "Body": "foobar", + "MD5OfBody": "3858f62230ac3c915f300c664312c63f", + "MessageId": "", + "ReceiptHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_after_timeout_fifo": { + "Error": { + "Code": "InvalidParameterValue", + "Detail": null, + "Message": "Value for parameter ReceiptHandle is invalid. Reason: The receipt handle has expired.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_aws_trace_header_propagation[sqs]": { + "recorded-date": "27-06-2025, 10:55:55", + "recorded-content": { + "xray-msg": { + "Attributes": { + "AWSTraceHeader": "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1", + "SentTimestamp": "timestamp" + }, + "Body": "test", + "MD5OfBody": "098f6bcd4621d373cade4e832627b4f6", + "MD5OfMessageAttributes": "235c5c510d26fb653d073faed50ae77c", + "MessageAttributes": { + "timestamp": "timestamp" + }, + "MessageId": "", + "ReceiptHandle": "" + } + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_aws_trace_header_propagation[sqs_query]": { + "recorded-date": "27-06-2025, 10:55:56", + "recorded-content": { + "xray-msg": { + "Attributes": { + "AWSTraceHeader": "Root=1-3152b799-8954dae64eda91bc9a23a7e8;Parent=7fa8c0f79203be72;Sampled=1", + "SentTimestamp": "timestamp" + }, + "Body": "test", + "MD5OfBody": "098f6bcd4621d373cade4e832627b4f6", + "MD5OfMessageAttributes": "235c5c510d26fb653d073faed50ae77c", + "MessageAttributes": { + "timestamp": "timestamp" + }, + "MessageId": "", + "ReceiptHandle": "" + } + } + } +} diff --git a/tests/aws/services/sqs/test_sqs.validation.json b/tests/aws/services/sqs/test_sqs.validation.json new file mode 100644 index 0000000000000..c74eae7b6ad37 --- /dev/null +++ b/tests/aws/services/sqs/test_sqs.validation.json @@ -0,0 +1,458 @@ +{ + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_aws_trace_header_propagation[sqs]": { + "last_validated_date": "2025-06-27T10:55:55+00:00", + "durations_in_seconds": { + "setup": 0.47, + "call": 0.71, + "teardown": 0.15, + "total": 1.33 + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_aws_trace_header_propagation[sqs_query]": { + "last_validated_date": "2025-06-27T10:55:56+00:00", + "durations_in_seconds": { + "setup": 0.0, + "call": 0.68, + "teardown": 0.15, + "total": 0.83 + } + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_after_visibility_timeout_expiration[sqs]": { + "last_validated_date": "2024-04-30T13:33:26+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_after_visibility_timeout_expiration[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:30+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_batch_with_too_large_batch[sqs]": { + "last_validated_date": "2024-04-30T13:50:34+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_change_message_visibility_batch_with_too_large_batch[sqs_query]": { + "last_validated_date": "2024-04-30T13:50:41+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_update_queue_attributes[sqs]": { + "last_validated_date": "2024-04-30T13:33:21+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_and_update_queue_attributes[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:22+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_fifo_queue_with_different_attributes_raises_error[sqs]": { + "last_validated_date": "2024-04-30T13:33:13+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_fifo_queue_with_different_attributes_raises_error[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:14+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_and_get_attributes[sqs]": { + "last_validated_date": "2024-04-30T13:39:56+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_and_get_attributes[sqs_query]": { + "last_validated_date": "2024-04-30T13:39:58+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted[sqs]": { + "last_validated_date": "2024-04-30T13:39:58+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_recently_deleted[sqs_query]": { + "last_validated_date": "2024-04-30T13:39:59+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_different_attributes_raises_exception[sqs]": { + "last_validated_date": "2024-04-30T13:33:18+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_queue_with_different_attributes_raises_exception[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:20+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_standard_queue_with_fifo_attribute_raises_error[sqs]": { + "last_validated_date": "2024-05-15T02:30:47+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_create_standard_queue_with_fifo_attribute_raises_error[sqs_query]": { + "last_validated_date": "2024-05-15T02:30:48+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_execution_lambda_mapping_preserves_id[sqs]": { + "last_validated_date": "2024-05-29T14:12:29+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_execution_lambda_mapping_preserves_id[sqs_query]": { + "last_validated_date": "2024-05-29T14:14:14+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_message_attributes": { + "last_validated_date": "2024-04-30T13:33:55+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_with_fifo_and_content_based_deduplication[sqs]": { + "last_validated_date": "2024-05-21T13:58:10+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_dead_letter_queue_with_fifo_and_content_based_deduplication[sqs_query]": { + "last_validated_date": "2024-05-21T13:58:16+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_deduplication_interval[sqs]": { + "last_validated_date": "2024-05-29T13:41:13+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_deduplication_interval[sqs_query]": { + "last_validated_date": "2024-05-29T13:47:36+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_after_visibility_timeout[sqs]": { + "last_validated_date": "2025-03-28T13:46:27+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_after_visibility_timeout[sqs_query]": { + "last_validated_date": "2025-03-28T13:46:31+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-]": { + "last_validated_date": "2024-04-30T13:48:34+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-invalid:id]": { + "last_validated_date": "2024-04-30T13:48:36+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs-testLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongId]": { + "last_validated_date": "2024-04-30T13:48:35+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-]": { + "last_validated_date": "2024-04-30T13:48:37+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-invalid:id]": { + "last_validated_date": "2024-04-30T13:48:38+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_invalid_msg_id[sqs_query-testLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongIdtestLongId]": { + "last_validated_date": "2024-04-30T13:48:37+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_with_too_large_batch[sqs]": { + "last_validated_date": "2024-04-30T13:49:26+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_delete_message_batch_with_too_large_batch[sqs_query]": { + "last_validated_date": "2024-04-30T13:49:31+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_high_throughput_after_creation[sqs]": { + "last_validated_date": "2024-05-24T10:00:47+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_high_throughput_after_creation[sqs_query]": { + "last_validated_date": "2024-05-24T10:00:53+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_regular_throughput_after_creation[sqs]": { + "last_validated_date": "2024-04-30T13:52:02+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_change_to_regular_throughput_after_creation[sqs_query]": { + "last_validated_date": "2024-04-30T13:52:06+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs-False]": { + "last_validated_date": "2024-04-30T13:34:04+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs-True]": { + "last_validated_date": "2024-04-30T13:34:01+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs_query-False]": { + "last_validated_date": "2024-04-30T13:34:09+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_arrives_once_after_delete[sqs_query-True]": { + "last_validated_date": "2024-04-30T13:34:06+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs-False]": { + "last_validated_date": "2024-04-30T13:34:14+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs-True]": { + "last_validated_date": "2024-04-30T13:34:11+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs_query-False]": { + "last_validated_date": "2024-04-30T13:34:19+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_deduplication_not_on_message_group_id[sqs_query-True]": { + "last_validated_date": "2024-04-30T13:34:17+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_after_visibility_timeout[sqs]": { + "last_validated_date": "2025-03-28T13:37:10+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_delete_after_visibility_timeout[sqs_query]": { + "last_validated_date": "2025-03-28T13:37:13+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_empty_message_groups_added_back_to_queue[sqs]": { + "last_validated_date": "2024-04-30T13:46:32+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_empty_message_groups_added_back_to_queue[sqs_query]": { + "last_validated_date": "2024-04-30T13:46:34+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_high_throughput_ordering[sqs]": { + "last_validated_date": "2024-04-30T13:34:27+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_high_throughput_ordering[sqs_query]": { + "last_validated_date": "2024-04-30T13:34:28+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_attributes[sqs]": { + "last_validated_date": "2024-04-30T13:36:56+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_message_attributes[sqs_query]": { + "last_validated_date": "2024-04-30T13:36:57+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_seconds_fails[sqs]": { + "last_validated_date": "2024-04-30T13:33:33+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_queue_send_message_with_delay_seconds_fails[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:34+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_set_content_based_deduplication_strategy[sqs]": { + "last_validated_date": "2024-04-30T13:47:39+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_fifo_set_content_based_deduplication_strategy[sqs_query]": { + "last_validated_date": "2024-04-30T13:47:41+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_batch_id[sqs]": { + "last_validated_date": "2024-04-30T13:33:40+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_invalid_batch_id[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:41+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues": { + "last_validated_date": "2024-04-30T13:39:55+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_list_queues_pagination": { + "last_validated_date": "2025-03-19T21:04:33+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_marker_serialization_json_protocol[\"{\\\\\"foo\\\\\": \\\\\"ba\\\\rr\\\\\"}\"]": { + "last_validated_date": "2024-05-07T13:33:39+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_marker_serialization_json_protocol[{\"foo\": \"ba\\rr\", \"foo2\": \"ba"r"\"}]": { + "last_validated_date": "2024-05-07T13:33:34+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_marker_serialization_query_protocol": { + "last_validated_date": "2024-04-29T06:07:04+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_deduplication_id_too_long": { + "last_validated_date": "2024-04-30T13:35:34+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_group_id_too_long": { + "last_validated_date": "2024-04-30T13:35:35+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_groups_with_dead_letter_queue_and_fifo[sqs]": { + "last_validated_date": "2024-05-21T07:05:48+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_groups_with_dead_letter_queue_and_fifo[sqs_query]": { + "last_validated_date": "2024-05-21T07:05:53+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_system_attribute_names_with_attribute_names[sqs]": { + "last_validated_date": "2024-06-05T07:09:39+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_message_system_attribute_names_with_attribute_names[sqs_query]": { + "last_validated_date": "2024-06-05T07:09:40+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_non_existent_queue": { + "last_validated_date": "2024-04-30T13:35:47+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_fifo_requires_deduplicationid_group_id[sqs]": { + "last_validated_date": "2024-04-30T13:34:21+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_posting_to_fifo_requires_deduplicationid_group_id[sqs_query]": { + "last_validated_date": "2024-04-30T13:34:22+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_empty_queue[sqs]": { + "last_validated_date": "2025-02-10T13:18:17+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_empty_queue[sqs_query]": { + "last_validated_date": "2025-02-10T13:18:20+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attribute_names_filters[sqs]": { + "last_validated_date": "2024-06-04T11:54:31+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attribute_names_filters[sqs_query]": { + "last_validated_date": "2024-06-04T11:54:34+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attributes_timestamp_types[sqs]": { + "last_validated_date": "2024-04-30T13:40:03+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_attributes_timestamp_types[sqs_query]": { + "last_validated_date": "2024-04-30T13:40:03+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_attribute_names_filters[sqs]": { + "last_validated_date": "2024-04-30T13:34:51+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_attribute_names_filters[sqs_query]": { + "last_validated_date": "2024-04-30T13:34:55+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_system_attribute_names_filters[sqs]": { + "last_validated_date": "2024-06-04T16:10:07+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_receive_message_message_system_attribute_names_filters[sqs_query]": { + "last_validated_date": "2024-06-04T16:10:09+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_message_size": { + "last_validated_date": "2024-04-30T13:33:44+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_deduplication_id_for_fifo_queue[sqs]": { + "last_validated_date": "2024-04-30T13:33:42+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_deduplication_id_for_fifo_queue[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:43+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_message_group_id_for_fifo_queue[sqs]": { + "last_validated_date": "2024-04-30T13:33:44+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_missing_message_group_id_for_fifo_queue[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:45+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_receive_multiple[sqs]": { + "last_validated_date": "2024-04-30T13:40:10+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_batch_receive_multiple[sqs_query]": { + "last_validated_date": "2024-04-30T13:40:12+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_empty_message[sqs]": { + "last_validated_date": "2025-03-05T19:25:09+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_empty_message[sqs_query]": { + "last_validated_date": "2025-03-05T19:25:09+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch[sqs]": { + "last_validated_date": "2024-04-30T13:40:08+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch[sqs_query]": { + "last_validated_date": "2024-04-30T13:40:09+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_empty_list[sqs]": { + "last_validated_date": "2024-04-30T13:40:12+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents[sqs]": { + "last_validated_date": "2024-04-30T13:33:06+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:08+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents_with_updated_maximum_message_size[sqs]": { + "last_validated_date": "2024-04-30T13:33:09+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_batch_with_oversized_contents_with_updated_maximum_message_size[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:10+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_to_standard_queue_with_empty_message_group_id": { + "last_validated_date": "2024-11-08T12:08:17+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_binary_attributes[sqs]": { + "last_validated_date": "2024-04-30T13:33:48+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_binary_attributes[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:49+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_empty_string_attribute[sqs]": { + "last_validated_date": "2024-04-30T13:35:41+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_empty_string_attribute[sqs_query]": { + "last_validated_date": "2024-04-30T13:35:42+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_fifo_parameters[sqs]": { + "last_validated_date": "2024-05-28T14:27:55+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_invalid_fifo_parameters[sqs_query]": { + "last_validated_date": "2024-05-28T14:19:35+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_updated_maximum_message_size[sqs]": { + "last_validated_date": "2024-04-30T13:33:02+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_message_with_updated_maximum_message_size[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:04+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_oversized_message[sqs]": { + "last_validated_date": "2024-04-30T13:32:59+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_oversized_message[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:01+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_max_number_of_messages[sqs]": { + "last_validated_date": "2024-04-30T13:32:56+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_max_number_of_messages[sqs_query]": { + "last_validated_date": "2024-04-30T13:32:57+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message[sqs]": { + "last_validated_date": "2024-04-30T13:40:00+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message[sqs_query]": { + "last_validated_date": "2024-04-30T13:40:01+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message_encoded_content[sqs]": { + "last_validated_date": "2024-04-30T13:40:06+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message_encoded_content[sqs_query]": { + "last_validated_date": "2024-04-30T13:40:07+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_message_multiple_queues": { + "last_validated_date": "2024-04-30T13:40:05+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_wait_time_seconds[sqs]": { + "last_validated_date": "2025-02-10T13:22:29+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_send_receive_wait_time_seconds[sqs_query]": { + "last_validated_date": "2025-02-10T13:22:32+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_redrive_policy[sqs]": { + "last_validated_date": "2024-08-20T14:14:08+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_empty_redrive_policy[sqs_query]": { + "last_validated_date": "2024-08-20T14:14:11+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_fifo[sqs]": { + "last_validated_date": "2024-05-14T22:23:46+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_fifo[sqs_query]": { + "last_validated_date": "2024-05-14T22:23:47+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_standard[sqs]": { + "last_validated_date": "2024-05-14T22:34:35+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_set_unsupported_attribute_standard[sqs_query]": { + "last_validated_date": "2024-05-14T22:34:35+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_content_based_dedup_with_message_group_scope[sqs]": { + "last_validated_date": "2024-05-24T08:57:07+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_content_based_dedup_with_message_group_scope[sqs_query]": { + "last_validated_date": "2024-05-24T08:57:10+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_message_group_scope_no_throughput_setting[sqs]": { + "last_validated_date": "2024-05-24T09:03:32+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_message_group_scope_no_throughput_setting[sqs_query]": { + "last_validated_date": "2024-05-24T09:03:35+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_same_dedup_id_different_message_groups[sqs]": { + "last_validated_date": "2024-04-30T13:51:17+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_fifo_same_dedup_id_different_message_groups[sqs_query]": { + "last_validated_date": "2024-04-30T13:51:19+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_permission_lifecycle[sqs]": { + "last_validated_date": "2024-04-30T13:35:03+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sqs_permission_lifecycle[sqs_query]": { + "last_validated_date": "2024-04-30T13:35:07+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_kms_and_sqs_are_mutually_exclusive[sqs]": { + "last_validated_date": "2024-04-30T13:34:48+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_kms_and_sqs_are_mutually_exclusive[sqs_query]": { + "last_validated_date": "2024-04-30T13:34:48+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_queue_attributes[sqs]": { + "last_validated_date": "2024-04-30T13:34:43+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_sse_queue_attributes[sqs_query]": { + "last_validated_date": "2024-04-30T13:34:47+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_successive_purge_calls_fail[sqs]": { + "last_validated_date": "2024-04-30T13:37:02+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_successive_purge_calls_fail[sqs_query]": { + "last_validated_date": "2024-04-30T13:37:04+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_untag_queue[sqs]": { + "last_validated_date": "2024-04-30T13:35:30+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_tag_untag_queue[sqs_query]": { + "last_validated_date": "2024-04-30T13:35:33+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_too_many_entries_in_batch_request[sqs]": { + "last_validated_date": "2024-04-30T13:33:39+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_too_many_entries_in_batch_request[sqs_query]": { + "last_validated_date": "2024-04-30T13:33:40+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_wait_time_seconds_waits_correctly[sqs]": { + "last_validated_date": "2025-01-23T13:57:19+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsProvider::test_wait_time_seconds_waits_correctly[sqs_query]": { + "last_validated_date": "2025-01-23T13:57:30+00:00" + }, + "tests/aws/services/sqs/test_sqs.py::TestSqsQueryApi::test_send_message_via_queue_url_with_json_protocol": { + "last_validated_date": "2024-04-30T13:35:11+00:00" + } +} diff --git a/tests/aws/services/sqs/test_sqs_backdoor.py b/tests/aws/services/sqs/test_sqs_backdoor.py new file mode 100644 index 0000000000000..39669a61f51fd --- /dev/null +++ b/tests/aws/services/sqs/test_sqs_backdoor.py @@ -0,0 +1,485 @@ +import time +from threading import Timer + +import pytest +import requests +import xmltodict +from botocore.exceptions import ClientError + +from localstack import config +from localstack.services.sqs.constants import ( + HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT, + HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS, +) +from localstack.services.sqs.provider import MAX_NUMBER_OF_MESSAGES +from localstack.services.sqs.utils import parse_queue_url +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +def _parse_message_attributes(xml) -> list[dict]: + """ + Takes an XML document returned by a SQS ``ReceiveMessage`` call and returns a dictionary of the + message attributes for each message. + """ + d = xmltodict.parse(xml) + + return [ + {attr["Name"]: attr["Value"] for attr in msg["Attribute"]} + for msg in d["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"] + ] + + +def _parse_attribute_map(json_message: dict) -> dict[str, str]: + return {attr["Name"]: attr["Value"] for attr in json_message["Attribute"]} + + +# @pytest.mark.usefixtures("openapi_validate") +class TestSqsDeveloperEndpoints: + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_has_no_side_effects( + self, sqs_create_queue, monkeypatch, aws_client, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-2") + + # check that accessing messages for this queue URL does not affect `ApproximateReceiveCount` + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", params={"QueueUrl": queue_url} + ) + attributes = _parse_message_attributes(response.text) + assert attributes[0]["ApproximateReceiveCount"] == "0" + assert attributes[1]["ApproximateReceiveCount"] == "0" + + # do a real receive op that has a side effect + response = aws_client.sqs_query.receive_message( + QueueUrl=queue_url, VisibilityTimeout=0, MaxNumberOfMessages=1, AttributeNames=["All"] + ) + assert response["Messages"][0]["Body"] == "message-1" + assert response["Messages"][0]["Attributes"]["ApproximateReceiveCount"] == "1" + + # check backdoor access again + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", params={"QueueUrl": queue_url} + ) + attributes = _parse_message_attributes(response.text) + assert attributes[0]["ApproximateReceiveCount"] == "1" + assert attributes[1]["ApproximateReceiveCount"] == "0" + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + @pytest.mark.parametrize("protocol", ["query", "json"]) + def test_list_messages_as_botocore_endpoint_url( + self, sqs_create_queue, aws_client, aws_client_factory, monkeypatch, strategy, protocol + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + aws_client.sqs_query.send_message(QueueUrl=queue_url, MessageBody="message-1") + aws_client.sqs_query.send_message(QueueUrl=queue_url, MessageBody="message-2") + + # use the developer endpoint as boto client URL + factory = aws_client_factory(endpoint_url="http://localhost:4566/_aws/sqs/messages") + client = factory.sqs_query if protocol == "query" else factory.sqs + # max messages is ignored + response = client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1) + + assert len(response["Messages"]) == 2 + + assert response["Messages"][0]["Body"] == "message-1" + assert response["Messages"][1]["Body"] == "message-2" + assert response["Messages"][0]["Attributes"]["ApproximateReceiveCount"] == "0" + assert response["Messages"][1]["Attributes"]["ApproximateReceiveCount"] == "0" + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + @pytest.mark.parametrize("protocol", ["query", "json"]) + def test_fifo_list_messages_as_botocore_endpoint_url( + self, sqs_create_queue, aws_client, aws_client_factory, monkeypatch, strategy, protocol + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue( + QueueName=f"queue-{short_uid()}.fifo", + Attributes={ + "FifoQueue": "true", + "ContentBasedDeduplication": "true", + }, + ) + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-1", MessageGroupId="1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-2", MessageGroupId="1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-3", MessageGroupId="2") + + # use the developer endpoint as boto client URL + factory = aws_client_factory(endpoint_url="http://localhost:4566/_aws/sqs/messages") + client = factory.sqs_query if protocol == "query" else factory.sqs + # max messages is ignored + response = client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1) + + assert len(response["Messages"]) == 3 + + assert response["Messages"][0]["Body"] == "message-1" + assert response["Messages"][1]["Body"] == "message-2" + assert response["Messages"][2]["Body"] == "message-3" + assert response["Messages"][0]["Attributes"]["ApproximateReceiveCount"] == "0" + assert response["Messages"][1]["Attributes"]["ApproximateReceiveCount"] == "0" + assert response["Messages"][2]["Attributes"]["ApproximateReceiveCount"] == "0" + assert response["Messages"][0]["Attributes"]["MessageGroupId"] == "1" + assert response["Messages"][1]["Attributes"]["MessageGroupId"] == "1" + assert response["Messages"][2]["Attributes"]["MessageGroupId"] == "2" + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + @pytest.mark.parametrize("protocol", ["query", "json"]) + def test_list_messages_with_invalid_action_raises_error( + self, sqs_create_queue, aws_client_factory, monkeypatch, strategy, protocol + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + factory = aws_client_factory(endpoint_url="http://localhost:4566/_aws/sqs/messages") + client = factory.sqs_query if protocol == "query" else factory.sqs + + with pytest.raises(ClientError) as e: + client.send_message(QueueUrl=queue_url, MessageBody="foobar") + + assert e.value.response["Error"]["Code"] == "InvalidRequest" + assert ( + e.value.response["Error"]["Message"] + == "This endpoint only accepts ReceiveMessage calls" + ) + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_as_json( + self, sqs_create_queue, monkeypatch, aws_client, account_id, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-2") + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + params={"QueueUrl": queue_url}, + headers={"Accept": "application/json"}, + ) + doc = response.json() + + messages = doc["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"] + + assert len(messages) == 2 + assert messages[0]["Body"] == "message-1" + assert messages[0]["MD5OfBody"] == "3d6b824fd8c1520e9a047d21fee6fb1f" + + assert messages[1]["Body"] == "message-2" + assert messages[1]["MD5OfBody"] == "95ef155b66299d14edf7ed57c468c13b" + + # make sure attributes are returned + attributes = {a["Name"]: a["Value"] for a in messages[0]["Attribute"]} + assert attributes["SenderId"] == account_id + assert "ApproximateReceiveCount" in attributes + assert "ApproximateFirstReceiveTimestamp" in attributes + assert "SentTimestamp" in attributes + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_with_invisible_messages( + self, sqs_create_queue, aws_client, monkeypatch, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-2") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-3") + + # check out a messages + aws_client.sqs.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1) + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + params={"QueueUrl": queue_url, "ShowInvisible": False}, + headers={"Accept": "application/json"}, + ) + doc = response.json() + messages = doc["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"] + assert len(messages) == 2 + assert messages[0]["Body"] == "message-2" + assert messages[1]["Body"] == "message-3" + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + params={"QueueUrl": queue_url, "ShowInvisible": True}, + headers={"Accept": "application/json"}, + ) + doc = response.json() + messages = doc["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"] + assert len(messages) == 3 + assert messages[0]["Body"] == "message-1" + assert messages[1]["Body"] == "message-2" + assert messages[2]["Body"] == "message-3" + + assert _parse_attribute_map(messages[0])["IsVisible"] == "false" + assert _parse_attribute_map(messages[1])["IsVisible"] == "true" + assert _parse_attribute_map(messages[2])["IsVisible"] == "true" + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_with_delayed_messages( + self, sqs_create_queue, aws_client, monkeypatch, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-2", DelaySeconds=10) + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-3", DelaySeconds=10) + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + params={"QueueUrl": queue_url, "ShowDelayed": False}, + headers={"Accept": "application/json"}, + ) + doc = response.json() + messages = doc["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"] + assert messages["Body"] == "message-1" + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + params={"QueueUrl": queue_url, "ShowDelayed": True}, + headers={"Accept": "application/json"}, + ) + doc = response.json() + messages = doc["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"] + assert len(messages) == 3 + messages.sort(key=lambda k: k["Body"]) + assert messages[0]["Body"] == "message-1" + assert messages[1]["Body"] == "message-2" + assert messages[2]["Body"] == "message-3" + + assert _parse_attribute_map(messages[0])["IsDelayed"] == "false" + assert _parse_attribute_map(messages[1])["IsDelayed"] == "true" + assert _parse_attribute_map(messages[2])["IsDelayed"] == "true" + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_without_queue_url(self, aws_client, monkeypatch, strategy): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + # makes sure the service is loaded when running the test individually + aws_client.sqs.list_queues() + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + headers={"Accept": "application/json"}, + ) + assert not response.ok + assert ( + response.json()["ErrorResponse"]["Error"]["Code"] + == "AWS.SimpleQueueService.NonExistentQueue" + ), f"not a json {response.text}" + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_with_invalid_queue_url(self, aws_client, monkeypatch, strategy): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + # makes sure the service is loaded when running the test individually + aws_client.sqs.list_queues() + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + params={"QueueUrl": "http://localhost:4566/nonsense"}, + headers={"Accept": "application/json"}, + ) + assert response.status_code == 404 + assert response.json()["ErrorResponse"]["Error"]["Code"] == "InvalidAddress" + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_with_non_existent_queue(self, aws_client, monkeypatch, strategy): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + # makes sure the service is loaded when running the test individually + aws_client.sqs.list_queues() + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages/us-east-1/000000000000/hopefullydoesnotexist", + headers={"Accept": "application/json"}, + ) + assert ( + response.json()["ErrorResponse"]["Error"]["Code"] + == "AWS.SimpleQueueService.NonExistentQueue" + ) + + response = requests.get( + "http://localhost:4566/_aws/sqs/messages", + params={"QueueUrl": "http://localhost:4566/000000000000/hopefullydoesnotexist"}, + headers={"Accept": "application/json"}, + ) + assert ( + response.json()["ErrorResponse"]["Error"]["Code"] + == "AWS.SimpleQueueService.NonExistentQueue" + ) + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_list_messages_with_queue_url_in_path( + self, sqs_create_queue, aws_client, monkeypatch, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_url = sqs_create_queue() + + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-1") + aws_client.sqs.send_message(QueueUrl=queue_url, MessageBody="message-2") + + account, region, name = parse_queue_url(queue_url) + # sometimes the region cannot be determined from the queue url, we make no assumptions about this + # in this test + region = region or aws_client.sqs.meta.region_name + + response = requests.get( + f"http://localhost:4566/_aws/sqs/messages/{region}/{account}/{name}", + headers={"Accept": "application/json"}, + ) + doc = response.json() + messages = doc["ReceiveMessageResponse"]["ReceiveMessageResult"]["Message"] + assert len(messages) == 2 + + # check that multi-region works correctly + alt_region = "us-east-2" if region == "us-east-1" else "us-east-1" + response = requests.get( + f"http://localhost:4566/_aws/sqs/messages/{alt_region}/{account}/{name}", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 400 + doc = response.json() + assert doc["ErrorResponse"]["Error"]["Code"] == "AWS.SimpleQueueService.NonExistentQueue" + + +class TestSqsOverrideHeaders: + @markers.aws.only_localstack + def test_receive_message_override_max_number_of_messages( + self, sqs_create_queue, aws_client_factory + ): + # Create standalone boto3 client since registering hooks to the session-wide + # aws_client (from the fixture) will have side-effects. + sqs_client = aws_client_factory().sqs + + override_max_number_of_messages = 20 + queue_url = sqs_create_queue() + + for i in range(override_max_number_of_messages): + sqs_client.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}") + + with pytest.raises(ClientError): + sqs_client.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + MaxNumberOfMessages=override_max_number_of_messages, + AttributeNames=["All"], + ) + + def _handle_receive_message_override(params, context, **kwargs): + if not (requested_count := params.get("MaxNumberOfMessages")): + return + context[HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT] = str(requested_count) + params["MaxNumberOfMessages"] = min(MAX_NUMBER_OF_MESSAGES, requested_count) + + def _handler_inject_header(params, context, **kwargs): + if override_message_count := context.pop( + HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT, None + ): + params["headers"][HEADER_LOCALSTACK_SQS_OVERRIDE_MESSAGE_COUNT] = ( + override_message_count + ) + + sqs_client.meta.events.register( + "provide-client-params.sqs.ReceiveMessage", _handle_receive_message_override + ) + + sqs_client.meta.events.register("before-call.sqs.ReceiveMessage", _handler_inject_header) + + response = sqs_client.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=30, + MaxNumberOfMessages=override_max_number_of_messages, + AttributeNames=["All"], + ) + + messages = response.get("Messages", []) + assert len(messages) == 20 + + @markers.aws.only_localstack + def test_receive_message_override_message_wait_time_seconds( + self, sqs_create_queue, aws_client_factory + ): + sqs_client = aws_client_factory().sqs + override_message_wait_time_seconds = 30 + queue_url = sqs_create_queue() + + with pytest.raises(ClientError): + sqs_client.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=0, + MaxNumberOfMessages=MAX_NUMBER_OF_MESSAGES, + WaitTimeSeconds=override_message_wait_time_seconds, + AttributeNames=["All"], + ) + + def _handle_receive_message_override(params, context, **kwargs): + if not (requested_wait_time := params.get("WaitTimeSeconds")): + return + context[HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS] = str(requested_wait_time) + params["WaitTimeSeconds"] = min(20, requested_wait_time) + + def _handler_inject_header(params, context, **kwargs): + if override_wait_time := context.pop( + HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS, None + ): + params["headers"][HEADER_LOCALSTACK_SQS_OVERRIDE_WAIT_TIME_SECONDS] = ( + override_wait_time + ) + + sqs_client.meta.events.register( + "provide-client-params.sqs.ReceiveMessage", _handle_receive_message_override + ) + + sqs_client.meta.events.register("before-call.sqs.ReceiveMessage", _handler_inject_header) + + def _send_message(): + sqs_client.send_message(QueueUrl=queue_url, MessageBody=f"message-{short_uid()}") + + # Populate with 9 messages (1 below the MaxNumberOfMessages threshold). + # This should cause long-polling to exit since MaxNumberOfMessages is met. + for _ in range(9): + _send_message() + + Timer(25, _send_message).start() # send message asynchronously after 25 seconds + + start_t = time.perf_counter() + response = sqs_client.receive_message( + QueueUrl=queue_url, + VisibilityTimeout=30, + MaxNumberOfMessages=MAX_NUMBER_OF_MESSAGES, + WaitTimeSeconds=override_message_wait_time_seconds, + AttributeNames=["All"], + ) + assert time.perf_counter() - start_t >= 25 + + messages = response.get("Messages", []) + assert len(messages) == 10 diff --git a/tests/aws/services/sqs/test_sqs_move_task.py b/tests/aws/services/sqs/test_sqs_move_task.py new file mode 100644 index 0000000000000..5cc9d5841dbde --- /dev/null +++ b/tests/aws/services/sqs/test_sqs_move_task.py @@ -0,0 +1,542 @@ +import json +import time +import uuid + +import pytest +from botocore.exceptions import ClientError + +from localstack.services.sqs.utils import decode_move_task_handle, encode_move_task_handle +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.sync import retry + +from .utils import sqs_wait_queue_size + +QueueUrl = str + + +@pytest.fixture(autouse=True) +def sqs_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.sqs_api()) + + +@pytest.fixture() +def sqs_create_dlq_pipe(sqs_create_queue, region_name): + def _factory(max_receive_count: int = 1) -> tuple[QueueUrl, QueueUrl]: + dl_queue_url = sqs_create_queue() + + # create redrive policy + url_parts = dl_queue_url.split("/") + dl_target_arn = arns.sqs_queue_arn( + url_parts[-1], + account_id=url_parts[len(url_parts) - 2], + region_name=region_name, + ) + queue_url = sqs_create_queue( + Attributes={ + "RedrivePolicy": json.dumps( + { + "deadLetterTargetArn": dl_target_arn, + "maxReceiveCount": max_receive_count, + } + ) + }, + ) + return queue_url, dl_queue_url + + return _factory + + +@markers.aws.validated +def test_cancel_with_invalid_task_handle(aws_client, snapshot): + with pytest.raises(ClientError) as e: + aws_client.sqs.cancel_message_move_task(TaskHandle="foobared") + snapshot.match("error", e.value.response) + + +@markers.aws.validated +def test_cancel_with_invalid_source_arn_in_task_handle(aws_client, snapshot): + source_arn = "arn:aws:sqs:us-east-1:878966065785:test-queue-doesnt-exist-123456" + task_handle = encode_move_task_handle("10f57157-fc38-4da9-a113-4de7e12d05dd", source_arn) + + with pytest.raises(ClientError) as e: + aws_client.sqs.cancel_message_move_task(TaskHandle=task_handle) + snapshot.match("error", e.value.response) + + +@markers.aws.validated +def test_cancel_with_invalid_task_id_in_task_handle( + sqs_create_queue, sqs_get_queue_arn, aws_client, snapshot +): + source_queue = sqs_create_queue() + source_arn = sqs_get_queue_arn(source_queue) + + # this is just some non-existing task_id + task_handle = encode_move_task_handle("10f57157-fc38-4da9-a113-4de7e12d05aa", source_arn) + with pytest.raises(ClientError) as e: + aws_client.sqs.cancel_message_move_task(TaskHandle=task_handle) + snapshot.match("error", e.value.response) + + +@markers.aws.validated +def test_source_needs_redrive_policy( + sqs_create_queue, + sqs_get_queue_arn, + aws_client, + snapshot, +): + sqs = aws_client.sqs + + source_queue = sqs_create_queue() + source_arn = sqs_get_queue_arn(source_queue) + + destination_queue = sqs_create_queue() + destination_arn = sqs_get_queue_arn(destination_queue) + + with pytest.raises(ClientError) as e: + sqs.start_message_move_task(SourceArn=source_arn, DestinationArn=destination_arn) + + snapshot.match("error", e.value.response) + + +@markers.aws.validated +def test_destination_needs_to_exist( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + aws_client, + snapshot, +): + sqs = aws_client.sqs + queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + destination_queue = sqs_create_queue() + destination_arn = sqs_get_queue_arn(destination_queue) + destination_arn += "doesntexist" + + with pytest.raises(ClientError) as e: + sqs.start_message_move_task(SourceArn=source_arn, DestinationArn=destination_arn) + + snapshot.match("error", e.value.response) + + +@markers.aws.validated +def test_basic_move_task_workflow( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + sqs_collect_messages, + aws_client, + snapshot, +): + sqs = aws_client.sqs + + # create dlq pipe: some-queue -> dlq (source) -> destination + queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + destination_queue = sqs_create_queue() + destination_arn = sqs_get_queue_arn(destination_queue) + + # send two messages + sqs.send_message(QueueUrl=queue_url, MessageBody="message-1") + sqs.send_message(QueueUrl=queue_url, MessageBody="message-2") + + # receive each message two times to move them into the dlq + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + + # wait until the messages arrive in the DLQ + assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=2, timeout=10) == 2 + + response = aws_client.sqs.start_message_move_task( + SourceArn=source_arn, DestinationArn=destination_arn + ) + snapshot.match("start-message-move-task-response", response) + + # check task handle format + task_handle = response["TaskHandle"] + decoded_task_id, decoded_source_arn = decode_move_task_handle(task_handle) + assert uuid.UUID(decoded_task_id) + assert decoded_source_arn == source_arn + + # check that messages arrived in destination queue correctly + messages = sqs_collect_messages(destination_queue, expected=2, timeout=10) + assert {message["Body"] for message in messages} == {"message-1", "message-2"} + + # check move task completion (in AWS, approximate number of messages may take a while to update) + def _wait_for_task_completion(): + _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn) + # this test also covers a check that `ApproximateNumberOfMessagesMoved` is set correctly at some point + assert int(_response["Results"][0]["ApproximateNumberOfMessagesMoved"]) == 2 + return _response + + response = retry(_wait_for_task_completion, retries=30, sleep=1) + snapshot.match("list-message-move-task-response", response) + + # assert messages are no longer in DLQ + response = aws_client.sqs.receive_message(QueueUrl=dl_queue_url, WaitTimeSeconds=1) + assert not response.get("Messages") + + +@markers.aws.validated +def test_move_task_workflow_with_default_destination( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + sqs_collect_messages, + aws_client, + snapshot, +): + # tests that, if the destination arn is left blank, the messages will be redriven back to their + # respective original source queues. + sqs = aws_client.sqs + + # create dlq pipe: some-queue -> dlq (source) -> some-queue + queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + + snapshot.match("source-arn", source_arn) + snapshot.match("original-source", sqs_get_queue_arn(queue_url)) + + # send two messages + sqs.send_message(QueueUrl=queue_url, MessageBody="message-1") + sqs.send_message(QueueUrl=queue_url, MessageBody="message-2") + + # receive each message two times to move them into the dlq + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + + # wait until the messages arrive in the DLQ + assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=2, timeout=10) == 2 + + response = aws_client.sqs.start_message_move_task(SourceArn=source_arn) + snapshot.match("start-message-move-task-response", response) + + # check task handle format + task_handle = response["TaskHandle"] + decoded_task_id, decoded_source_arn = decode_move_task_handle(task_handle) + assert uuid.UUID(decoded_task_id) + assert decoded_source_arn == source_arn + + # check that messages arrived in destination queue correctly + messages = sqs_collect_messages(queue_url, expected=2, timeout=10) + assert {message["Body"] for message in messages} == {"message-1", "message-2"} + + # check move task completion (in AWS, approximate number of messages may take a while to update) + def _wait_for_task_completion(): + _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn) + # this test also covers a check that `ApproximateNumberOfMessagesMoved` is set correctly at some point + assert int(_response["Results"][0]["ApproximateNumberOfMessagesMoved"]) == 2 + return _response + + response = retry(_wait_for_task_completion, retries=30, sleep=1) + snapshot.match("list-message-move-task-response", response) + + # assert messages are no longer in DLQ + response = aws_client.sqs.receive_message(QueueUrl=dl_queue_url, WaitTimeSeconds=1) + assert not response.get("Messages") + + +@markers.aws.validated +def test_move_task_workflow_with_multiple_sources_as_default_destination( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + sqs_collect_messages, + aws_client, + snapshot, +): + # tests that, if the destination arn is left blank, the messages will be redriven back to their + # respective original source queues, where there is more than one source queue. + sqs = aws_client.sqs + + # create dlq pipe: some-queue -> dlq (source) -> some-queue + queue1_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + # create another DLQ pipe with the same DLQ (re + queue2_url = sqs_create_queue( + Attributes={ + "RedrivePolicy": json.dumps( + { + "deadLetterTargetArn": source_arn, + "maxReceiveCount": 1, + } + ) + }, + ) + + snapshot.match("source-arn", source_arn) + snapshot.match("original-source-1", sqs_get_queue_arn(queue1_url)) + snapshot.match("original-source-2", sqs_get_queue_arn(queue2_url)) + + # send two messages to q1 + sqs.send_message(QueueUrl=queue1_url, MessageBody="message-1-1") + sqs.send_message(QueueUrl=queue1_url, MessageBody="message-1-2") + + # send two messages to q2 + sqs.send_message(QueueUrl=queue2_url, MessageBody="message-2-1") + sqs.send_message(QueueUrl=queue2_url, MessageBody="message-2-2") + + # receive each message two times to move them into the dlq + sqs.receive_message(QueueUrl=queue1_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue1_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue1_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue1_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue2_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue2_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue2_url, VisibilityTimeout=0) + sqs.receive_message(QueueUrl=queue2_url, VisibilityTimeout=0) + + # wait until the messages arrive in the DLQ + assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=4, timeout=10) == 4 + + response = aws_client.sqs.start_message_move_task(SourceArn=source_arn) + snapshot.match("start-message-move-task-response", response) + + # check that messages arrived in destination queue correctly + messages = sqs_collect_messages(queue1_url, expected=2, timeout=10) + assert {message["Body"] for message in messages} == {"message-1-1", "message-1-2"} + + messages = sqs_collect_messages(queue2_url, expected=2, timeout=10) + assert {message["Body"] for message in messages} == {"message-2-1", "message-2-2"} + + # check move task completion (in AWS, approximate number of messages may take a while to update) + def _wait_for_task_completion(): + _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn) + # this test also covers a check that `ApproximateNumberOfMessagesMoved` is set correctly at some point + assert int(_response["Results"][0]["ApproximateNumberOfMessagesMoved"]) == 4 + return _response + + response = retry(_wait_for_task_completion, retries=30, sleep=1) + snapshot.match("list-message-move-task-response", response) + + # assert messages are no longer in DLQ + response = aws_client.sqs.receive_message(QueueUrl=dl_queue_url, WaitTimeSeconds=1) + assert not response.get("Messages") + + +@markers.aws.validated +def test_move_task_with_throughput_limit( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + sqs_collect_messages, + aws_client, + snapshot, +): + sqs = aws_client.sqs + + # create dlq pipe: some-queue -> dlq (source) -> destination + queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + destination_queue = sqs_create_queue() + destination_arn = sqs_get_queue_arn(destination_queue) + + n = 4 + + # send n messages and move them into the DLQ + for i in range(n): + sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}") + + assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n + + # receive each message two times to move them into the dlq + for i in range(n * 2): + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + + # wait until the messages arrive in the DLQ + assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n + + # start move task + response = aws_client.sqs.start_message_move_task( + SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1 + ) + snapshot.match("start-message-move-task-response", response) + started = time.time() + messages = sqs_collect_messages(destination_queue, n, 60) + assert {message["Body"] for message in messages} == { + "message-0", + "message-1", + "message-2", + "message-3", + } + + # we set the MaxNumberOfMessagesPerSecond to 1, so moving 4 messages should take at least 3 seconds (assuming + # that the first one is moved immediately, and the task terminates immediately after the last message has been + # moved) + assert time.time() - started >= 3 + + +@markers.aws.validated +@pytest.mark.skip_snapshot_verify( + paths=[ + # this is non-deterministic because of concurrency in AWS vs LocalStack + "$..ApproximateNumberOfMessagesMoved", + ] +) +def test_move_task_cancel( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + sqs_collect_messages, + aws_client, + snapshot, +): + sqs = aws_client.sqs + + # create dlq pipe: some-queue -> dlq (source) -> destination + queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + destination_queue = sqs_create_queue() + destination_arn = sqs_get_queue_arn(destination_queue) + + n = 10 + + # send n messages + for i in range(n): + sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}") + + assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n + + # receive each message two times to move them into the dlq + for i in range(n * 2): + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + + # wait until the messages arrive in the DLQ + assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n + + # start move task + response = aws_client.sqs.start_message_move_task( + SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1 + ) + task_handle = response["TaskHandle"] + + # wait for two messages to arrive, then cancel the task + messages = sqs_collect_messages(destination_queue, 2, 60) + assert len(messages) == 2 + + response = sqs.list_message_move_tasks(SourceArn=source_arn) + snapshot.match("list-while", response) + + response = sqs.cancel_message_move_task(TaskHandle=task_handle) + snapshot.match("cancel", response) + + # check move task completion (in AWS, approximate number of messages may take a while to update) + def _wait_for_task_cancellation(): + _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn) + assert _response["Results"][0]["Status"] == "CANCELLED" + return _response + + response = retry(_wait_for_task_cancellation, retries=30, sleep=1) + snapshot.match("list-after", response) + + # make sure that there are still messages left in the DLQ + assert aws_client.sqs.receive_message(QueueUrl=dl_queue_url)["Messages"] + + +@markers.aws.validated +@pytest.mark.skip_snapshot_verify( + paths=[ + # this is non-deterministic because of concurrency in AWS vs LocalStack + "$..Results..ApproximateNumberOfMessagesMoved", + # error serialization is still an issue ('AWS.SimpleQueueService.NonExistentQueue' vs + # 'QueueDoesNotExist') + "$..Results..FailureReason", + ] +) +def test_move_task_delete_destination_queue_while_running( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + aws_client, + snapshot, +): + sqs = aws_client.sqs + + # create dlq pipe: some-queue -> dlq (source) -> destination + queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + destination_queue = sqs_create_queue() + destination_arn = sqs_get_queue_arn(destination_queue) + + n = 10 + + # send n messages + for i in range(n): + sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}") + + assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n + + # receive each message two times to move them into the dlq + for i in range(n * 2): + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + + # wait until the messages arrive in the DLQ + assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n + + # start move task + aws_client.sqs.start_message_move_task( + SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1 + ) + + sqs.delete_queue(QueueUrl=destination_queue) + + # check move task completion (in AWS, approximate number of messages may take a while to update) + def _wait_for_task_cancellation(): + _response = aws_client.sqs.list_message_move_tasks(SourceArn=source_arn) + assert _response["Results"][0]["Status"] != "RUNNING" + return _response + + # should fail + response = retry(_wait_for_task_cancellation, retries=30, sleep=1) + print(response) + snapshot.match("list", response) + + # make sure that there are still messages left in the DLQ + assert aws_client.sqs.receive_message(QueueUrl=dl_queue_url)["Messages"] + + +@markers.aws.validated +def test_start_multiple_move_tasks( + sqs_create_queue, + sqs_create_dlq_pipe, + sqs_get_queue_arn, + aws_client, + snapshot, +): + sqs = aws_client.sqs + + # create dlq pipe: some-queue -> dlq (source) -> destination + queue_url, dl_queue_url = sqs_create_dlq_pipe(max_receive_count=1) + source_arn = sqs_get_queue_arn(dl_queue_url) + destination_queue = sqs_create_queue() + destination_arn = sqs_get_queue_arn(destination_queue) + + n = 10 + + # send n messages + for i in range(n): + sqs.send_message(QueueUrl=queue_url, MessageBody=f"message-{i}") + + assert sqs_wait_queue_size(sqs, queue_url, expected_num_messages=n, timeout=10) == n + + # receive each message two times to move them into the dlq + for i in range(n * 2): + sqs.receive_message(QueueUrl=queue_url, VisibilityTimeout=0) + + # wait until the messages arrive in the DLQ + assert sqs_wait_queue_size(sqs, dl_queue_url, expected_num_messages=n, timeout=10) == n + + # start move task + aws_client.sqs.start_message_move_task( + SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1 + ) + with pytest.raises(ClientError) as e: + aws_client.sqs.start_message_move_task( + SourceArn=source_arn, DestinationArn=destination_arn, MaxNumberOfMessagesPerSecond=1 + ) + snapshot.match("error", e.value.response) diff --git a/tests/aws/services/sqs/test_sqs_move_task.snapshot.json b/tests/aws/services/sqs/test_sqs_move_task.snapshot.json new file mode 100644 index 0000000000000..efb1c0f0b6453 --- /dev/null +++ b/tests/aws/services/sqs/test_sqs_move_task.snapshot.json @@ -0,0 +1,276 @@ +{ + "tests/aws/services/sqs/test_sqs_move_task.py::test_basic_move_task_workflow": { + "recorded-date": "30-04-2024, 10:22:14", + "recorded-content": { + "start-message-move-task-response": { + "TaskHandle": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-message-move-task-response": { + "Results": [ + { + "ApproximateNumberOfMessagesMoved": 2, + "ApproximateNumberOfMessagesToMove": 2, + "DestinationArn": "arn::sqs::111111111111:", + "SourceArn": "arn::sqs::111111111111:", + "StartedTimestamp": "timestamp", + "Status": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_source_needs_redrive_policy": { + "recorded-date": "30-04-2024, 10:22:04", + "recorded-content": { + "error": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Source queue must be configured as a Dead Letter Queue.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_handle": { + "recorded-date": "30-04-2024, 10:22:02", + "recorded-content": { + "error": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Value for parameter TaskHandle is invalid.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_source_arn_in_task_handle": { + "recorded-date": "30-04-2024, 10:22:02", + "recorded-content": { + "error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource that you specified for the SourceArn parameter doesn't exist.", + "QueryErrorCode": "ResourceNotFoundException", + "Type": "Sender" + }, + "message": "The resource that you specified for the SourceArn parameter doesn't exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_id_in_task_handle": { + "recorded-date": "30-04-2024, 10:22:03", + "recorded-content": { + "error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "Task does not exist.", + "QueryErrorCode": "ResourceNotFoundException", + "Type": "Sender" + }, + "message": "Task does not exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_with_throughput_limit": { + "recorded-date": "30-04-2024, 10:22:54", + "recorded-content": { + "start-message-move-task-response": { + "TaskHandle": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_destination_needs_to_exist": { + "recorded-date": "30-04-2024, 10:22:06", + "recorded-content": { + "error": { + "Error": { + "Code": "ResourceNotFoundException", + "Message": "The resource that you specified for the DestinationArn parameter doesn't exist.", + "QueryErrorCode": "ResourceNotFoundException", + "Type": "Sender" + }, + "message": "The resource that you specified for the DestinationArn parameter doesn't exist.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 404 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_cancel": { + "recorded-date": "30-04-2024, 10:23:25", + "recorded-content": { + "list-while": { + "Results": [ + { + "ApproximateNumberOfMessagesMoved": 2, + "ApproximateNumberOfMessagesToMove": 10, + "DestinationArn": "arn::sqs::111111111111:", + "MaxNumberOfMessagesPerSecond": 1, + "SourceArn": "arn::sqs::111111111111:", + "StartedTimestamp": "timestamp", + "Status": "RUNNING", + "TaskHandle": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "cancel": { + "ApproximateNumberOfMessagesMoved": 2, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-after": { + "Results": [ + { + "ApproximateNumberOfMessagesMoved": 8, + "ApproximateNumberOfMessagesToMove": 10, + "DestinationArn": "arn::sqs::111111111111:", + "MaxNumberOfMessagesPerSecond": 1, + "SourceArn": "arn::sqs::111111111111:", + "StartedTimestamp": "timestamp", + "Status": "CANCELLED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_delete_destination_queue_while_running": { + "recorded-date": "30-04-2024, 10:24:05", + "recorded-content": { + "list": { + "Results": [ + { + "ApproximateNumberOfMessagesMoved": 3, + "ApproximateNumberOfMessagesToMove": 10, + "DestinationArn": "arn::sqs::111111111111:", + "FailureReason": "AWS.SimpleQueueService.NonExistentQueue", + "MaxNumberOfMessagesPerSecond": 1, + "SourceArn": "arn::sqs::111111111111:", + "StartedTimestamp": "timestamp", + "Status": "FAILED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_start_multiple_move_tasks": { + "recorded-date": "30-04-2024, 10:24:13", + "recorded-content": { + "error": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "There is already a task running. Only one active task is allowed for a source queue arn at a given time.", + "QueryErrorCode": "InvalidParameterValueException", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_workflow_with_default_destination": { + "recorded-date": "30-04-2024, 10:22:20", + "recorded-content": { + "source-arn": "arn::sqs::111111111111:", + "original-source": "arn::sqs::111111111111:", + "start-message-move-task-response": { + "TaskHandle": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-message-move-task-response": { + "Results": [ + { + "ApproximateNumberOfMessagesMoved": 2, + "ApproximateNumberOfMessagesToMove": 2, + "SourceArn": "arn::sqs::111111111111:", + "StartedTimestamp": "timestamp", + "Status": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_workflow_with_multiple_sources_as_default_destination": { + "recorded-date": "30-04-2024, 10:22:29", + "recorded-content": { + "source-arn": "arn::sqs::111111111111:", + "original-source-1": "arn::sqs::111111111111:", + "original-source-2": "arn::sqs::111111111111:", + "start-message-move-task-response": { + "TaskHandle": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-message-move-task-response": { + "Results": [ + { + "ApproximateNumberOfMessagesMoved": 4, + "ApproximateNumberOfMessagesToMove": 4, + "SourceArn": "arn::sqs::111111111111:", + "StartedTimestamp": "timestamp", + "Status": "COMPLETED" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/sqs/test_sqs_move_task.validation.json b/tests/aws/services/sqs/test_sqs_move_task.validation.json new file mode 100644 index 0000000000000..b5bf2a05ed236 --- /dev/null +++ b/tests/aws/services/sqs/test_sqs_move_task.validation.json @@ -0,0 +1,38 @@ +{ + "tests/aws/services/sqs/test_sqs_move_task.py::test_basic_move_task_workflow": { + "last_validated_date": "2024-04-30T10:22:14+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_source_arn_in_task_handle": { + "last_validated_date": "2024-04-30T10:22:02+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_handle": { + "last_validated_date": "2024-04-30T10:22:02+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_cancel_with_invalid_task_id_in_task_handle": { + "last_validated_date": "2024-04-30T10:22:03+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_destination_needs_to_exist": { + "last_validated_date": "2024-04-30T10:22:06+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_cancel": { + "last_validated_date": "2024-04-30T10:23:24+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_delete_destination_queue_while_running": { + "last_validated_date": "2024-04-30T10:24:04+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_with_throughput_limit": { + "last_validated_date": "2024-04-30T10:22:53+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_workflow_with_default_destination": { + "last_validated_date": "2024-04-30T10:22:19+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_move_task_workflow_with_multiple_sources_as_default_destination": { + "last_validated_date": "2024-04-30T10:22:28+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_source_needs_redrive_policy": { + "last_validated_date": "2024-04-30T10:22:04+00:00" + }, + "tests/aws/services/sqs/test_sqs_move_task.py::test_start_multiple_move_tasks": { + "last_validated_date": "2024-04-30T10:24:12+00:00" + } +} diff --git a/tests/aws/services/sqs/utils.py b/tests/aws/services/sqs/utils.py new file mode 100644 index 0000000000000..6887d44f61d9a --- /dev/null +++ b/tests/aws/services/sqs/utils.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING + +from localstack.utils.sync import poll_condition + +if TYPE_CHECKING: + from mypy_boto3_sqs import SQSClient + + +def get_approx_number_of_messages( + sqs_client: "SQSClient", + queue_url: str, +) -> int: + response = sqs_client.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["ApproximateNumberOfMessages"] + ) + return int(response["Attributes"]["ApproximateNumberOfMessages"]) + + +def sqs_wait_queue_size( + sqs_client: "SQSClient", + queue_url: str, + expected_num_messages: int, + timeout: float = None, +) -> int: + def _check_num_messages(): + return get_approx_number_of_messages(sqs_client, queue_url) >= expected_num_messages + + if not poll_condition(_check_num_messages, timeout=timeout): + raise TimeoutError( + f"gave up waiting for messages (expected={expected_num_messages}, " + f"actual={get_approx_number_of_messages(sqs_client, queue_url)})" + ) + + return get_approx_number_of_messages(sqs_client, queue_url) diff --git a/tests/aws/services/ssm/__init__.py b/tests/aws/services/ssm/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/ssm/test_ssm.py b/tests/aws/services/ssm/test_ssm.py new file mode 100644 index 0000000000000..b9a7ff7ff79c5 --- /dev/null +++ b/tests/aws/services/ssm/test_ssm.py @@ -0,0 +1,265 @@ +import json + +import pytest + +from localstack import config +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.common import retry, short_uid +from localstack.utils.strings import to_str + + +def _assert(search_name: str, param_name: str, ssm_client): + def do_assert(result): + assert len(result) > 0 + assert param_name == result[0]["Name"] + assert "123" == result[0]["Value"] + + response = ssm_client.get_parameter(Name=search_name) + do_assert([response["Parameter"]]) + + response = ssm_client.get_parameters(Names=[search_name]) + do_assert(response["Parameters"]) + + +class TestSSM: + @markers.aws.validated + def test_describe_parameters(self, aws_client): + response = aws_client.ssm.describe_parameters() + assert "Parameters" in response + assert isinstance(response["Parameters"], list) + + @markers.aws.validated + def test_put_parameters(self, create_parameter, aws_client): + param_name = f"param-{short_uid()}" + create_parameter( + Name=param_name, + Description="test", + Value="123", + Type="String", + ) + + _assert(param_name, param_name, aws_client.ssm) + _assert(f"/{param_name}", f"/{param_name}", aws_client.ssm) + + @markers.aws.validated + @pytest.mark.parametrize("param_name_pattern", ["///b//c", "/b/c"]) + def test_hierarchical_parameter(self, create_parameter, param_name_pattern, aws_client): + param_a = short_uid() + create_parameter( + Name=f"/{param_a}/b/c", + Value="123", + Type="String", + ) + + _assert(f"/{param_a}/b/c", f"/{param_a}/b/c", aws_client.ssm) + pname = param_name_pattern.replace("", param_a) + with pytest.raises(Exception) as exc: + _assert(pname, f"/{param_a}/b/c", aws_client.ssm) + exc.match("ValidationException") + exc.match("sub-paths divided by slash symbol") + + @markers.aws.validated + def test_get_secret_parameter(self, create_secret, aws_client): + secret_name = f"test_secret-{short_uid()}" + create_secret( + Name=secret_name, + SecretString="my_secret", + Description="testing creation of secrets", + ) + + result = aws_client.ssm.get_parameter( + Name=f"/aws/reference/secretsmanager/{secret_name}", WithDecryption=True + ) + assert f"/aws/reference/secretsmanager/{secret_name}" == result.get("Parameter").get("Name") + assert "my_secret" == result.get("Parameter").get("Value") + + source_result = result.get("Parameter").get("SourceResult") + assert source_result + source_result = json.loads(to_str(source_result)) + assert source_result["name"] == secret_name + assert ":secretsmanager:" in source_result["ARN"] + + # negative test for https://github.com/localstack/localstack/issues/6551 + with pytest.raises(Exception): + aws_client.ssm.get_parameter(Name=secret_name, WithDecryption=True) + + @markers.aws.validated + def test_get_inexistent_secret(self, aws_client): + invalid_name = "/aws/reference/secretsmanager/inexistent" + with pytest.raises(aws_client.ssm.exceptions.ParameterNotFound) as exc: + aws_client.ssm.get_parameter(Name=invalid_name, WithDecryption=True) + exc.match("ParameterNotFound") + exc.match(f"Secret .*{invalid_name.lstrip('/')}.* not found.") + + @markers.aws.validated + def test_get_parameters_and_secrets(self, create_parameter, create_secret, aws_client): + param_name = f"param-{short_uid()}" + secret_path = "/aws/reference/secretsmanager/" + secret_name = f"test_secret_param_{short_uid()}" + complete_secret = secret_path + secret_name + + create_parameter( + Name=param_name, + Description="test", + Value="123", + Type="String", + ) + + create_secret( + Name=secret_name, + SecretString="my_secret", + Description="testing creation of secrets", + ) + + response = aws_client.ssm.get_parameters( + Names=[ + param_name, + complete_secret, + "inexistent_param", + secret_path + "inexistent_secret", + ], + WithDecryption=True, + ) + found = response.get("Parameters") + not_found = response.get("InvalidParameters") + + for param in found: + assert param["Name"] in [param_name, complete_secret] + for param in not_found: + assert param in ["inexistent_param", secret_path + "inexistent_secret"] + + @markers.aws.validated + def test_get_parameters_by_path_and_filter_by_labels(self, create_parameter, aws_client): + prefix = f"/prefix-{short_uid()}" + path = f"{prefix}/path" + value = "value" + param = create_parameter(Name=path, Value=value, Type="String") + aws_client.ssm.label_parameter_version( + Name=path, ParameterVersion=param["Version"], Labels=["latest"] + ) + list_of_params = aws_client.ssm.get_parameters_by_path( + Path=prefix, ParameterFilters=[{"Key": "Label", "Values": ["latest"]}] + ) + assert len(list_of_params["Parameters"]) == 1 + found_param = list_of_params["Parameters"][0] + assert path == found_param["Name"] + assert found_param["ARN"] + assert found_param["Type"] == "String" + assert found_param["Value"] == "value" + + @markers.aws.validated + def test_get_parameter_by_arn(self, create_parameter, aws_client, snapshot, cleanups): + param_name = f"param-{short_uid()}" + create_parameter(Name=param_name, Value="test", Type="String") + parameter_by_name = aws_client.ssm.get_parameter(Name=param_name)["Parameter"] + + parameter_by_arn = aws_client.ssm.get_parameter(Name=parameter_by_name["ARN"])["Parameter"] + snapshot.match("Parameter", parameter_by_arn) + snapshot.add_transformer(snapshot.transform.key_value("Name")) + + @markers.aws.validated + def test_get_inexistent_maintenance_window(self, aws_client): + invalid_name = "mw-00000000000000000" + with pytest.raises(aws_client.ssm.exceptions.DoesNotExistException) as exc: + aws_client.ssm.get_maintenance_window(WindowId=invalid_name) + exc.match("DoesNotExistException") + exc.match(f"Maintenance window {invalid_name} does not exist") + + @markers.aws.needs_fixing + # TODO: remove parameters, set correct parameter prefix name, use events_create_event_bus and events_put_rule fixture, + # remove clean_up, use sqs_as_events_target fixture, use snapshot + @pytest.mark.parametrize("strategy", ["standard", "domain", "path"]) + def test_trigger_event_on_systems_manager_change( + self, monkeypatch, aws_client, clean_up, strategy + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + rule_name = "rule-{}".format(short_uid()) + target_id = "target-{}".format(short_uid()) + + # create queue + queue_name = "queue-{}".format(short_uid()) + queue_url = aws_client.sqs.create_queue(QueueName=queue_name)["QueueUrl"] + queue_arn = arns.sqs_queue_arn(queue_name, TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME) + + # put rule listening on SSM changes + ssm_prefix = "/test/local/" + aws_client.events.put_rule( + Name=rule_name, + EventPattern=json.dumps( + { + "detail": { + "name": [{"prefix": ssm_prefix}], + "operation": [ + "Create", + "Update", + "Delete", + "LabelParameterVersion", + ], + }, + "detail-type": ["Parameter Store Change"], + "source": ["aws.ssm"], + } + ), + State="ENABLED", + Description="Trigger on SSM parameter changes", + ) + + # put target + aws_client.events.put_targets( + Rule=rule_name, + Targets=[{"Id": target_id, "Arn": queue_arn, "InputPath": "$.detail"}], + ) + + param_suffix = short_uid() + + # change SSM param to trigger event + aws_client.ssm.put_parameter( + Name=f"{ssm_prefix}/test-{param_suffix}", Value="value1", Type="String" + ) + + def assert_message(): + resp = aws_client.sqs.receive_message(QueueUrl=queue_url) + result = resp.get("Messages") + body = json.loads(result[0]["Body"]) + assert body == { + "name": f"/test/local/test-{param_suffix}", + "operation": "Create", + } + + # assert that message has been received + retry(assert_message, retries=7, sleep=0.3) + + # clean up + clean_up(rule_name=rule_name, target_ids=target_id) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Tier"]) + def test_parameters_with_path(self, create_parameter, aws_client, snapshot): + snapshot.add_transformer(snapshot.transform.key_value("Name")) + param_name = f"/test/param-{short_uid()}" + put_parameter_response = create_parameter( + Name=param_name, + Description="test", + Value="123", + Type="String", + ) + snapshot.match("put-parameter-response", put_parameter_response) + + get_parameter_response = aws_client.ssm.get_parameter(Name=param_name) + snapshot.match("get-parameter-response", get_parameter_response) + + get_parameter_by_arn_response = aws_client.ssm.get_parameter( + Name=get_parameter_response["Parameter"]["ARN"] + ) + snapshot.match("get-parameter-by-arn-response", get_parameter_by_arn_response) + + get_parameters_response = aws_client.ssm.get_parameters(Names=[param_name]) + snapshot.match("get-parameters-response", get_parameters_response) + + get_parameters_by_arn_response = aws_client.ssm.get_parameters( + Names=[get_parameter_response["Parameter"]["ARN"]] + ) + snapshot.match("get-parameters-by-arn-response", get_parameters_by_arn_response) diff --git a/tests/aws/services/ssm/test_ssm.snapshot.json b/tests/aws/services/ssm/test_ssm.snapshot.json new file mode 100644 index 0000000000000..6ae6852679247 --- /dev/null +++ b/tests/aws/services/ssm/test_ssm.snapshot.json @@ -0,0 +1,95 @@ +{ + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_parameter_by_arn": { + "recorded-date": "16-07-2024, 17:17:40", + "recorded-content": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter/", + "DataType": "text", + "LastModifiedDate": "", + "Name": "", + "Type": "String", + "Value": "test", + "Version": 1 + } + } + }, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_parameters_with_path": { + "recorded-date": "08-01-2025, 17:04:53", + "recorded-content": { + "put-parameter-response": { + "Tier": "Standard", + "Version": 1, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-parameter-response": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter", + "DataType": "text", + "LastModifiedDate": "", + "Name": "", + "Type": "String", + "Value": "123", + "Version": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-parameter-by-arn-response": { + "Parameter": { + "ARN": "arn::ssm::111111111111:parameter", + "DataType": "text", + "LastModifiedDate": "", + "Name": "", + "Type": "String", + "Value": "123", + "Version": 1 + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-parameters-response": { + "InvalidParameters": [], + "Parameters": [ + { + "ARN": "arn::ssm::111111111111:parameter", + "DataType": "text", + "LastModifiedDate": "", + "Name": "", + "Type": "String", + "Value": "123", + "Version": 1 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-parameters-by-arn-response": { + "InvalidParameters": [], + "Parameters": [ + { + "ARN": "arn::ssm::111111111111:parameter", + "DataType": "text", + "LastModifiedDate": "", + "Name": "", + "Type": "String", + "Value": "123", + "Version": 1 + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/ssm/test_ssm.validation.json b/tests/aws/services/ssm/test_ssm.validation.json new file mode 100644 index 0000000000000..a01fcaaceeb40 --- /dev/null +++ b/tests/aws/services/ssm/test_ssm.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_get_parameter_by_arn": { + "last_validated_date": "2024-07-16T17:17:40+00:00" + }, + "tests/aws/services/ssm/test_ssm.py::TestSSM::test_parameters_with_path": { + "last_validated_date": "2025-01-08T17:04:53+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/__init__.py b/tests/aws/services/stepfunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/lambda_functions/__init__.py b/tests/aws/services/stepfunctions/lambda_functions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/lambda_functions/base/__init__.py b/tests/aws/services/stepfunctions/lambda_functions/base/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/lambda_functions/base/echo_function.py b/tests/aws/services/stepfunctions/lambda_functions/base/echo_function.py new file mode 100644 index 0000000000000..159b2ac23c4ef --- /dev/null +++ b/tests/aws/services/stepfunctions/lambda_functions/base/echo_function.py @@ -0,0 +1,2 @@ +def handler(event, ctx): + return {"Return": "HelloWorld"} diff --git a/tests/aws/services/stepfunctions/lambda_functions/base/id_function.py b/tests/aws/services/stepfunctions/lambda_functions/base/id_function.py new file mode 100644 index 0000000000000..d6ab063e960dd --- /dev/null +++ b/tests/aws/services/stepfunctions/lambda_functions/base/id_function.py @@ -0,0 +1,3 @@ +# ID function which returns its input. +def handler(event, ctx): + return event diff --git a/tests/aws/services/stepfunctions/lambda_functions/lambda_functions.py b/tests/aws/services/stepfunctions/lambda_functions/lambda_functions.py new file mode 100644 index 0000000000000..f33eb85b99071 --- /dev/null +++ b/tests/aws/services/stepfunctions/lambda_functions/lambda_functions.py @@ -0,0 +1,5 @@ +import os + +_THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +BASE_ID_FUNCTION = os.path.join(_THIS_FOLDER, "base/id_function.py") +ECHO_FUNCTION = os.path.join(_THIS_FOLDER, "base/echo_function.py") diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mock_config_files/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mock_config_files/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mock_config_files/lambda_sqs_integration.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mock_config_files/lambda_sqs_integration.json5 new file mode 100644 index 0000000000000..7601785e51586 --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mock_config_files/lambda_sqs_integration.json5 @@ -0,0 +1,72 @@ +// Source: https://docs.aws.amazon.com/step-functions/latest/dg/sfn-local-test-sm-exec.html, April 2025 +{ + "StateMachines": { + "LambdaSQSIntegration": { + "TestCases": { + "HappyPath": { + "LambdaState": "MockedLambdaSuccess", + "SQSState": "MockedSQSSuccess" + }, + "RetryPath": { + "LambdaState": "MockedLambdaRetry", + "SQSState": "MockedSQSSuccess" + }, + "HybridPath": { + "LambdaState": "MockedLambdaSuccess" + } + } + } + }, + "MockedResponses": { + "MockedLambdaSuccess": { + "0": { + "Return": { + "StatusCode": 200, + "Payload": { + "StatusCode": 200, + "body": "Hello from Lambda!" + } + } + } + }, + "LambdaMockedResourceNotReady": { + "0": { + "Throw": { + "Error": "Lambda.ResourceNotReadyException", + "Cause": "Lambda resource is not ready." + } + } + }, + "MockedSQSSuccess": { + "0": { + "Return": { + "MD5OfMessageBody": "3bcb6e8e-7h85-4375-b0bc-1a59812c6e51", + "MessageId": "3bcb6e8e-8b51-4375-b0bc-1a59812c6e51" + } + } + }, + "MockedLambdaRetry": { + "0": { + "Throw": { + "Error": "Lambda.ResourceNotReadyException", + "Cause": "Lambda resource is not ready." + } + }, + "1-2": { + "Throw": { + "Error": "Lambda.TimeoutException", + "Cause": "Lambda timed out." + } + }, + "3": { + "Return": { + "StatusCode": 200, + "Payload": { + "StatusCode": 200, + "body": "Hello from Lambda!" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/task_failure.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/task_failure.json5 new file mode 100644 index 0000000000000..8c31560fbc3cc --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/task_failure.json5 @@ -0,0 +1,8 @@ +{ + "0": { + "Throw": { + "Error": "Failure error", + "Cause": "Failure cause", + } + } +} diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/task_success_string_literal.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/task_success_string_literal.json5 new file mode 100644 index 0000000000000..4cb6cb2f16e39 --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/callback/task_success_string_literal.json5 @@ -0,0 +1,5 @@ +{ + "0": { + "Return": "string-literal" + } +} diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/200_get_item.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/200_get_item.json5 new file mode 100644 index 0000000000000..c4839f6a89a9a --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/200_get_item.json5 @@ -0,0 +1,14 @@ +{ + "0": { + "Return": { + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + }, + } + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/200_put_item.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/200_put_item.json5 new file mode 100644 index 0000000000000..7bd24326ab518 --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/200_put_item.json5 @@ -0,0 +1,5 @@ +{ + "0": { + "Return": {} + } +} diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/dynamodb/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/events/200_put_events.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/events/200_put_events.json5 new file mode 100644 index 0000000000000..84ec05aff04e7 --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/events/200_put_events.json5 @@ -0,0 +1,12 @@ +{ + "0": { + "Return": { + "FailedEntryCount": 0, + "Entries": [ + { + "EventId": "11111111-2222-3333-4444-555555555555" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/events/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/events/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/200_string_body.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/200_string_body.json5 new file mode 100644 index 0000000000000..8fdb0ae4aecb4 --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/200_string_body.json5 @@ -0,0 +1,10 @@ +{ + "0": { + "Return": { + "StatusCode": 200, + "Payload": { + "body": "string body" + } + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/not_ready_timeout_200_string_body.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/not_ready_timeout_200_string_body.json5 new file mode 100644 index 0000000000000..d704190e3005e --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/lambda/not_ready_timeout_200_string_body.json5 @@ -0,0 +1,22 @@ +{ + "0": { + "Throw": { + "Error": "Lambda.ResourceNotReadyException", + "Cause": "This is a mocked lambda error" + } + }, + "1": { + "Throw": { + "Error": "Lambda.TimeoutException", + "Cause": "This is a mocked lambda error" + } + }, + "2": { + "Return": { + "Payload": { + "StatusCode": 200, + "body": "Hello from Lambda!" + } + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sns/200_publish.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sns/200_publish.json5 new file mode 100644 index 0000000000000..e341f70ec719b --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sns/200_publish.json5 @@ -0,0 +1,7 @@ +{ + "0": { + "Return": { + "MessageId": "11112222-3333-4444-5555-666677778888" + } + } +} diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sns/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sns/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sqs/200_send_message.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sqs/200_send_message.json5 new file mode 100644 index 0000000000000..e5dbcb270ca72 --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sqs/200_send_message.json5 @@ -0,0 +1,8 @@ +{ + "0": { + "Return": { + "MD5OfMessageBody": "3bcb6e8e-7h85-4375-b0bc-1a59812c6e51", + "MessageId": "11112222-3333-4444-5555-666677778888", + } + } +} diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sqs/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/sqs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/200_start_execution_sync.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/200_start_execution_sync.json5 new file mode 100644 index 0000000000000..da21dc9866b75 --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/200_start_execution_sync.json5 @@ -0,0 +1,20 @@ +{ + "0": { + "Return": { + "ExecutionArn": "arn:aws:states:us-east-1:111111111111:execution:Part:TestStartTarget", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": '{"Arg1":"argument1"}', + "OutputDetails": { + "Included": true + }, + "StateMachineArn": "arn:aws:states:us-east-1:111111111111:stateMachine:Part", + "StartDate": "1745486528077", + "StopDate": "1745486528078", + "Status": "SUCCEEDED" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/200_start_execution_sync2.json5 b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/200_start_execution_sync2.json5 new file mode 100644 index 0000000000000..c957f70edd2bc --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/200_start_execution_sync2.json5 @@ -0,0 +1,22 @@ +{ + "0": { + "Return": { + "ExecutionArn": "arn:aws:states:us-east-1:111111111111:execution:Part:TestStartTarget", + "Input": {}, + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": { + "Arg1": "argument1" + }, + "OutputDetails": { + "Included": true + }, + "StateMachineArn": "arn:aws:states:us-east-1:111111111111:stateMachine:Part", + "StartDate": "1745486528077", + "StopDate": "1745486528078", + "Status": "SUCCEEDED" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/__init__.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_responses/states/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_service_integrations.py b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_service_integrations.py new file mode 100644 index 0000000000000..2ab9f76a13f9a --- /dev/null +++ b/tests/aws/services/stepfunctions/mocked_service_integrations/mocked_service_integrations.py @@ -0,0 +1,58 @@ +import abc +import copy +import os +from typing import Final + +import json5 + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) +_LOAD_CACHE: Final[dict[str, dict]] = dict() + + +class MockedServiceIntegrationsLoader(abc.ABC): + MOCKED_RESPONSE_LAMBDA_200_STRING_BODY: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/lambda/200_string_body.json5" + ) + MOCKED_RESPONSE_LAMBDA_NOT_READY_TIMEOUT_200_STRING_BODY: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/lambda/not_ready_timeout_200_string_body.json5" + ) + MOCKED_RESPONSE_SQS_200_SEND_MESSAGE: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/sqs/200_send_message.json5" + ) + MOCKED_RESPONSE_SNS_200_PUBLISH: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/sns/200_publish.json5" + ) + MOCKED_RESPONSE_EVENTS_200_PUT_EVENTS: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/events/200_put_events.json5" + ) + MOCKED_RESPONSE_DYNAMODB_200_PUT_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/dynamodb/200_put_item.json5" + ) + MOCKED_RESPONSE_DYNAMODB_200_GET_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/dynamodb/200_get_item.json5" + ) + MOCKED_RESPONSE_STATES_200_START_EXECUTION_SYNC: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/states/200_start_execution_sync.json5" + ) + MOCKED_RESPONSE_STATES_200_START_EXECUTION_SYNC2: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/states/200_start_execution_sync2.json5" + ) + MOCKED_RESPONSE_CALLBACK_TASK_SUCCESS_STRING_LITERAL: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/callback/task_success_string_literal.json5" + ) + MOCKED_RESPONSE_CALLBACK_TASK_FAILURE: Final[str] = os.path.join( + _THIS_FOLDER, "mocked_responses/callback/task_failure.json5" + ) + + MOCK_CONFIG_FILE_LAMBDA_SQS_INTEGRATION: Final[str] = os.path.join( + _THIS_FOLDER, "mock_config_files/lambda_sqs_integration.json5" + ) + + @staticmethod + def load(file_path: str) -> dict: + template = _LOAD_CACHE.get(file_path) + if template is None: + with open(file_path, "r") as df: + template = json5.load(df) + _LOAD_CACHE[file_path] = template + return copy.deepcopy(template) diff --git a/tests/aws/services/stepfunctions/templates/__init__.py b/tests/aws/services/stepfunctions/templates/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/activities/__init__.py b/tests/aws/services/stepfunctions/templates/activities/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/activities/activity_templates.py b/tests/aws/services/stepfunctions/templates/activities/activity_templates.py new file mode 100644 index 0000000000000..6d49c03233ecb --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/activity_templates.py @@ -0,0 +1,30 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ActivityTemplate(TemplateLoader): + BASE_ACTIVITY_TASK: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_activity_task.json5" + ) + BASE_ACTIVITY_TASK_HEARTBEAT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_activity_task_heartbeat.json5" + ) + BASE_ACTIVITY_TASK_TIMEOUT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_activity_task_timeout.json5" + ) + BASE_ID_ACTIVITY_CONSUMER: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_id_activity_consumer.json5" + ) + BASE_ID_ACTIVITY_CONSUMER_FAIL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_id_activity_consumer_fail.json5" + ) + BASE_ID_ACTIVITY_CONSUMER_TIMEOUT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_id_activity_consumer_timeout.json5" + ) + HEARTBEAT_ID_ACTIVITY_CONSUMER: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/heartbeat_id_activity_consumer.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task.json5 b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task.json5 new file mode 100644 index 0000000000000..11c22a71e83d9 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "BASE_ACTIVITY_TASK", + "StartAt": "ActivityTask", + "States": { + "ActivityTask": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task_heartbeat.json5 b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task_heartbeat.json5 new file mode 100644 index 0000000000000..e81df1582c5a6 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task_heartbeat.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "BASE_ACTIVITY_TASK_HEARTBEAT", + "StartAt": "ActivityTask", + "States": { + "ActivityTask": { + "Type": "Task", + "TimeoutSeconds": 60, + "HeartbeatSeconds": 10, + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task_timeout.json5 b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task_timeout.json5 new file mode 100644 index 0000000000000..f18fc7b563d56 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_activity_task_timeout.json5 @@ -0,0 +1,12 @@ +{ + "Comment": "BASE_ACTIVITY_TASK_TIMEOUT", + "StartAt": "ActivityTask", + "States": { + "ActivityTask": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "TimeoutSeconds": 5, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer.json5 b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer.json5 new file mode 100644 index 0000000000000..55bfa75c8bb10 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer.json5 @@ -0,0 +1,43 @@ +{ + "Comment": "BASE_ID_ACTIVITY_CONSUMER", + "TimeoutSeconds": 600, + "StartAt": "GetActivityTask", + "States": { + "GetActivityTask": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:getActivityTask", + "Parameters": { + "ActivityArn.$": "$.ActivityArn", + "WorkerName": "BASE_ID_ACTIVITY_CONSUMER", + }, + "ResultPath": "$.GetActivityTaskOutput", + "Next": "CheckTaskToken" + }, + "CheckTaskToken": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.GetActivityTaskOutput.TaskToken", + "IsPresent": true, + "Next": "TaskReceived" + } + ], + "Default": "NoTaskReceived" + }, + "TaskReceived": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskSuccess", + "Parameters": { + "Output.$": "$.GetActivityTaskOutput.Input", + "TaskToken.$": "$.GetActivityTaskOutput.TaskToken" + }, + "End": true + }, + "NoTaskReceived": { + "Type": "Wait", + "Seconds": 1, + "OutputPath": "$.ActivityArn", + "Next": "GetActivityTask" + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer_fail.json5 b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer_fail.json5 new file mode 100644 index 0000000000000..9e020fc156c73 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer_fail.json5 @@ -0,0 +1,44 @@ +{ + "Comment": "BASE_ID_ACTIVITY_CONSUMER_FAIL", + "TimeoutSeconds": 600, + "StartAt": "GetActivityTask", + "States": { + "GetActivityTask": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:getActivityTask", + "Parameters": { + "ActivityArn.$": "$.ActivityArn", + "WorkerName": "BASE_ID_ACTIVITY_CONSUMER", + }, + "ResultPath": "$.GetActivityTaskOutput", + "Next": "CheckTaskToken" + }, + "CheckTaskToken": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.GetActivityTaskOutput.TaskToken", + "IsPresent": true, + "Next": "TaskReceived" + } + ], + "Default": "NoTaskReceived" + }, + "TaskReceived": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskFailure", + "Parameters": { + "Error": "Programmatic Error", + "Cause.$": "$.GetActivityTaskOutput.Input", + "TaskToken.$": "$.GetActivityTaskOutput.TaskToken" + }, + "End": true + }, + "NoTaskReceived": { + "Type": "Wait", + "Seconds": 1, + "OutputPath": "$.ActivityArn", + "Next": "GetActivityTask" + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer_timeout.json5 b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer_timeout.json5 new file mode 100644 index 0000000000000..aa4702731e885 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/statemachines/base_id_activity_consumer_timeout.json5 @@ -0,0 +1,38 @@ +{ + "Comment": "BASE_ID_ACTIVITY_CONSUMER_TIMEOUT", + "TimeoutSeconds": 600, + "StartAt": "GetActivityTask", + "States": { + "GetActivityTask": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:getActivityTask", + "Parameters": { + "ActivityArn.$": "$.ActivityArn", + "WorkerName": "BASE_ID_ACTIVITY_CONSUMER", + }, + "ResultPath": "$.GetActivityTaskOutput", + "Next": "CheckTaskToken" + }, + "CheckTaskToken": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.GetActivityTaskOutput.TaskToken", + "IsPresent": true, + "Next": "TaskReceived" + } + ], + "Default": "NoTaskReceived" + }, + "TaskReceived": { + "Type": "Pass", + "End": true + }, + "NoTaskReceived": { + "Type": "Wait", + "Seconds": 1, + "OutputPath": "$.ActivityArn", + "Next": "GetActivityTask" + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/activities/statemachines/heartbeat_id_activity_consumer.json5 b/tests/aws/services/stepfunctions/templates/activities/statemachines/heartbeat_id_activity_consumer.json5 new file mode 100644 index 0000000000000..d23f298b246ff --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/activities/statemachines/heartbeat_id_activity_consumer.json5 @@ -0,0 +1,62 @@ +{ + "Comment": "HEARTBEAT_ID_ACTIVITY_CONSUMER", + "TimeoutSeconds": 600, + "StartAt": "GetActivityTask", + "States": { + "GetActivityTask": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:getActivityTask", + "Parameters": { + "ActivityArn.$": "$.ActivityArn", + "WorkerName": "BASE_ID_ACTIVITY_CONSUMER", + }, + "ResultPath": "$.GetActivityTaskOutput", + "Next": "CheckTaskToken" + }, + "CheckTaskToken": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.GetActivityTaskOutput.TaskToken", + "IsPresent": true, + "Next": "TaskReceived" + } + ], + "Default": "NoTaskReceived" + }, + "TaskReceived": { + "Type": "Wait", + "Seconds": 2, + "Next": "SendHeartbeat" + }, + "SendHeartbeat": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskHeartbeat", + "Parameters": { + "TaskToken.$": "$.GetActivityTaskOutput.TaskToken" + }, + "ResultPath": "$.SendHeartbeatOutput", + "Next": "WaitBeforeSuccess" + }, + "WaitBeforeSuccess": { + "Type": "Wait", + "Seconds": 2, + "Next": "SendSuccess" + }, + "SendSuccess": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskSuccess", + "Parameters": { + "Output.$": "$.GetActivityTaskOutput.Input", + "TaskToken.$": "$.GetActivityTaskOutput.TaskToken" + }, + "End": true + }, + "NoTaskReceived": { + "Type": "Wait", + "Seconds": 1, + "OutputPath": "$.ActivityArn", + "Next": "GetActivityTask" + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/arguments/__init__.py b/tests/aws/services/stepfunctions/templates/arguments/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/arguments/arguments_templates.py b/tests/aws/services/stepfunctions/templates/arguments/arguments_templates.py new file mode 100644 index 0000000000000..f84491d894e96 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/arguments/arguments_templates.py @@ -0,0 +1,17 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ArgumentTemplates(TemplateLoader): + BASE_LAMBDA_EMPTY = os.path.join(_THIS_FOLDER, "statemachines/base_lambda_empty.json5") + BASE_LAMBDA_EMPTY_GLOBAL_QL_JSONATA = os.path.join( + _THIS_FOLDER, "statemachines/base_lambda_empty_global_ql_jsonata.json5" + ) + BASE_LAMBDA_EXPRESSION = os.path.join( + _THIS_FOLDER, "statemachines/base_lambda_expressions.json5" + ) + BASE_LAMBDA_LITERALS = os.path.join(_THIS_FOLDER, "statemachines/base_lambda_literals.json5") diff --git a/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_empty.json5 b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_empty.json5 new file mode 100644 index 0000000000000..0a85e74d5e361 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_empty.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "__tbd__", + "Arguments": {}, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_empty_global_ql_jsonata.json5 b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_empty_global_ql_jsonata.json5 new file mode 100644 index 0000000000000..be43101069edf --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_empty_global_ql_jsonata.json5 @@ -0,0 +1,12 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Task", + "Resource": "__tbd__", + "Arguments": {}, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_expressions.json5 b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_expressions.json5 new file mode 100644 index 0000000000000..ad0f49a3e6545 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_expressions.json5 @@ -0,0 +1,29 @@ +{ + "StartAt": "Init", + "States": { + "Init": { + "Type": "Pass", + "Assign": { + "var_input_value.$": "$.input_value", + "var_constant_1": 1 + }, + "Next": "State0" + }, + "State0": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "__tbd__", + "Arguments": { + // TODO: Expand with jsonpath, jsonpathcontext, varpath, intrinsicfuncs + "ja_states_input": "{% $states.input %}", + "ja_var_access": "{% $var_input_value %}", + "ja_expr": "{% $sum($states.input.input_values) + $var_constant_1 %}" + }, + "Assign": { + "ja_var_access": "{% $var_input_value %}", + "ja_expr": "{% $sum($states.input.input_values) + $var_constant_1 %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_literals.json5 b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_literals.json5 new file mode 100644 index 0000000000000..0c88ae6fb53dd --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/arguments/statemachines/base_lambda_literals.json5 @@ -0,0 +1,37 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "__tbd__", + "Arguments": { + // TODO: Expand with jsonpath, jsonpathcontext, intrinsicfuncs. + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_lst_empty": [], + "constant_lst": [null, 0, 0.1, true, [], {"constant": 0}, " {% states.input %} ", "$states.input", "$no.such.var.path"], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [null, 0, 0.1, true, [], {"constant": 0}, " {% states.input %} ", "$states.input", "$no.such.var.path"], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { "constant": 0 } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/__init__.py b/tests/aws/services/stepfunctions/templates/assign/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/assign/assign_templates.py b/tests/aws/services/stepfunctions/templates/assign/assign_templates.py new file mode 100644 index 0000000000000..01759fdc7f90c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/assign_templates.py @@ -0,0 +1,134 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class AssignTemplate(TemplateLoader): + TEMP = os.path.join(_THIS_FOLDER, "statemachines/temp.json5") + BASE_CONSTANT_LITERALS = os.path.join( + _THIS_FOLDER, "statemachines/base_constant_literals.json5" + ) + BASE_EMPTY = os.path.join(_THIS_FOLDER, "statemachines/base_empty.json5") + BASE_PATHS = os.path.join(_THIS_FOLDER, "statemachines/base_paths.json5") + BASE_SCOPE_MAP = os.path.join(_THIS_FOLDER, "statemachines/base_scope_map.json5") + BASE_SCOPE_PARALLEL = os.path.join(_THIS_FOLDER, "statemachines/base_scope_parallel.json5") + BASE_VAR = os.path.join(_THIS_FOLDER, "statemachines/base_var.json5") + BASE_UNDEFINED_ARGUMENTS_FIELD = os.path.join( + _THIS_FOLDER, "statemachines/base_undefined_arguments_field.json5" + ) + BASE_UNDEFINED_ARGUMENTS = os.path.join( + _THIS_FOLDER, "statemachines/base_undefined_arguments.json5" + ) + BASE_UNDEFINED_OUTPUT = os.path.join(_THIS_FOLDER, "statemachines/base_undefined_output.json5") + BASE_UNDEFINED_OUTPUT_FIELD = os.path.join( + _THIS_FOLDER, "statemachines/base_undefined_output_field.json5" + ) + BASE_UNDEFINED_OUTPUT_MULTIPLE_STATES = os.path.join( + _THIS_FOLDER, "statemachines/base_undefined_output_multiple_states.json5" + ) + BASE_UNDEFINED_ASSIGN = os.path.join(_THIS_FOLDER, "statemachines/base_undefined_assign.json5") + BASE_UNDEFINED_ARGUMENTS = os.path.join( + _THIS_FOLDER, "statemachines/base_undefined_arguments.json5" + ) + # Testing the evaluation order of state types + BASE_EVALUATION_ORDER_PASS_STATE = os.path.join( + _THIS_FOLDER, "statemachines/base_evaluation_order_pass_state.json5" + ) + + # Testing referencing assigned variables + BASE_REFERENCE_IN_PARAMETERS = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_parameters.json5" + ) + BASE_REFERENCE_IN_WAIT = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_wait.json5" + ) + BASE_REFERENCE_IN_CHOICE = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_choice.json5" + ) + BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_iterator_outer_scope.json5" + ) + BASE_REFERENCE_IN_INPUTPATH = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_inputpath.json5" + ) + BASE_REFERENCE_IN_OUTPUTPATH = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_outputpath.json5" + ) + BASE_REFERENCE_IN_INTRINSIC_FUNCTION = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_intrinsic_function.json5" + ) + BASE_REFERENCE_IN_FAIL = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_fail.json5" + ) + + # Requires 'FunctionName' and 'AccountID' as execution input + BASE_REFERENCE_IN_LAMBDA_TASK_FIELDS = os.path.join( + _THIS_FOLDER, "statemachines/base_reference_in_lambda_task_fields.json5" + ) + + # Testing assigning variables dynamically + BASE_ASSIGN_FROM_PARAMETERS = os.path.join( + _THIS_FOLDER, "statemachines/base_assign_from_parameters.json5" + ) + BASE_ASSIGN_FROM_RESULT = os.path.join( + _THIS_FOLDER, "statemachines/base_assign_from_result.json5" + ) + + BASE_ASSIGN_FROM_INTRINSIC_FUNCTION = os.path.join( + _THIS_FOLDER, "statemachines/base_assign_from_intrinsic_function.json5" + ) + + BASE_ASSIGN_FROM_LAMBDA_TASK_RESULT = os.path.join( + _THIS_FOLDER, "statemachines/base_assign_from_lambda_task_result.json5" + ) + + # Testing assigning variables dynamically + BASE_ASSIGN_IN_CHOICE = os.path.join(_THIS_FOLDER, "statemachines/base_assign_in_choice.json5") + + BASE_ASSIGN_IN_WAIT = os.path.join(_THIS_FOLDER, "statemachines/base_assign_in_wait.json5") + + BASE_ASSIGN_IN_CATCH = os.path.join(_THIS_FOLDER, "statemachines/base_assign_in_catch.json5") + + # Raises exceptions on creation + TASK_RETRY_REFERENCE_EXCEPTION = os.path.join( + _THIS_FOLDER, "statemachines/task_retry_reference_exception.json5" + ) + + # ---------------------------------- + # VARIABLE REFERENCING IN MAP STATES + # ---------------------------------- + + MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION = os.path.join( + _THIS_FOLDER, "statemachines/map_state_reference_in_intrinsic_function.json5" + ) + + MAP_STATE_REFERENCE_IN_ITEMS_PATH = os.path.join( + _THIS_FOLDER, "statemachines/map_state_reference_in_items_path.json5" + ) + + MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH = os.path.join( + _THIS_FOLDER, "statemachines/map_state_reference_in_max_concurrency_path.json5" + ) + + MAP_STATE_REFERENCE_IN_MAX_PER_BATCH_PATH = os.path.join( + _THIS_FOLDER, "statemachines/map_state_reference_in_max_per_batch_path.json5" + ) + + MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH = os.path.join( + _THIS_FOLDER, "statemachines/map_state_reference_in_max_items_path.json5" + ) + + MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH = os.path.join( + _THIS_FOLDER, "statemachines/map_state_reference_in_tolerated_failure_path.json5" + ) + + MAP_STATE_REFERENCE_IN_ITEM_SELECTOR = os.path.join( + _THIS_FOLDER, "statemachines/map_state_reference_in_item_selector.json5" + ) + + CHOICE_CONDITION_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_condition_jsonata.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_intrinsic_function.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_intrinsic_function.json5 new file mode 100644 index 0000000000000..df919cc017f69 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_intrinsic_function.json5 @@ -0,0 +1,85 @@ +{ + "Comment": "BASE_ASSIGN_FROM_INTRINSIC_FUNCTION", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "Assign": { + "arrayOps": { + "simpleArray.$": "States.Array('a', 'b', 'c')", + "partitionedArray.$": "States.ArrayPartition($.inputArray, 2)", + "containsElement.$": "States.ArrayContains($.inputArray, 5)", + "numberRange.$": "States.ArrayRange(1, 10, 2)", + "thirdElement.$": "States.ArrayGetItem($.inputArray, 2)", + "arraySize.$": "States.ArrayLength($.inputArray)", + "uniqueValues.$": "States.ArrayUnique($.duplicateArray)" + }, + "encodingOps": { + "encoded.$": "States.Base64Encode($.rawString)", + "decoded.$": "States.Base64Decode($.encodedString)" + }, + "hashOps": { + "hashValue.$": "States.Hash($.inputString, 'SHA-256')" + }, + "jsonOps": { + "mergedJson.$": "States.JsonMerge($.json1, $.json2, false)", + "parsedJson.$": "States.StringToJson($.jsonString)", + "stringifiedJson.$": "States.JsonToString($.jsonObject)" + }, + "mathOps": { + "sum.$": "States.MathAdd($.value1, $.value2)" + }, + "stringOps": { + "splitString.$": "States.StringSplit($.csvString, ',')", + "formattedString.$": "States.Format('Hello {}, welcome to {}!', $.name, $.place)" + }, + "uuidOp": { + "uniqueId.$": "States.UUID()" + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_lambda_task_result.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_lambda_task_result.json5 new file mode 100644 index 0000000000000..cb9c9b2cb2578 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_lambda_task_result.json5 @@ -0,0 +1,40 @@ +{ + "Comment" : "BASE_ASSIGN_FROM_LAMBDA_TASK_RESULT", + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Assign": { + "functionName.$": "$.FunctionName", + "inputData": { + "foo" : "oof", + "bar": "rab" + }, + "timeout": 300 + }, + "Next": "Task" + }, + "Task": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "TimeoutSecondsPath": "$timeout", + "Parameters": { + "Payload.$": "$inputData", + "FunctionName.$": "$functionName" + }, + "Assign": { + "result.$": "$.Payload" + }, + "OutputPath": "$.Payload", + "Next": "End" + }, + "End": { + "Type": "Pass", + "Assign": { + "previousResult.$": "$result", + "timeout": 150 + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_parameters.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_parameters.json5 new file mode 100644 index 0000000000000..4cec45d96626e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_parameters.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "BASE_ASSIGN_FROM_PARAMETERS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "input": "PENDING", + }, + "Assign": { + "result.$": "$.input" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Assign": { + "result": "SUCCESS", + "originalResult.$": "$.input" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_result.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_result.json5 new file mode 100644 index 0000000000000..708a7ea111a93 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_from_result.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "BASE_ASSIGN_FROM_RESULT", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "answer": 42 + }, + "Assign": { + "theAnswer.$": "$.answer" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_catch.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_catch.json5 new file mode 100644 index 0000000000000..abab7ac952e82 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_catch.json5 @@ -0,0 +1,36 @@ +{ + "Comment": "BASE_ASSIGN_IN_CATCH", + "StartAt": "Task", + "States": { + "Task": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Assign": { + "result": "SUCCESS" + }, + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "{% $states.input.input_value %}", + "Payload": { + "foo": "oof" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Assign": { + "result": "{% $states.errorOutput %}" + }, + "Next": "fallback" + } + ], + "End": true + }, + "fallback": { + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_choice.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_choice.json5 new file mode 100644 index 0000000000000..5cd097710f3fe --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_choice.json5 @@ -0,0 +1,39 @@ +{ + "Comment": "BASE_ASSIGN_IN_CHOICE", + "StartAt": "CheckInputState", + "States": { + "CheckInputState": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.input_value", + "IsPresent": false, + "Assign": { + "status": "UNKNOWN" + }, + "Next": "FinalState" + }, + { + "Variable": "$.input_value", + "NumericEquals": 42, + "Assign": { + "status": "CORRECT" + }, + "Next": "FinalState" + } + ], + "Assign": { + "status": "INCORRECT", + "guess.$": "$.input_value" + }, + "Default": "FinalState" + }, + "FinalState": { + "Type": "Pass", + "Parameters": { + "result.$": "$status" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_wait.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_wait.json5 new file mode 100644 index 0000000000000..7d33bdd6fd194 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_assign_in_wait.json5 @@ -0,0 +1,14 @@ +{ + "Comment": "BASE_ASSIGN_IN_WAIT", + "StartAt": "WaitState", + "States": { + "WaitState": { + "Type": "Wait", + "Seconds": 0, + "Assign": { + "foo": "oof" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_constant_literals.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_constant_literals.json5 new file mode 100644 index 0000000000000..677c7492d5046 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_constant_literals.json5 @@ -0,0 +1,76 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_str_path_root": "$", + "constant_str_path": "$.no.such.path", + "constant_str_contextpath_root": "$$", + "constant_str_contextpath": "$$.Execution.Id", + "constant_str_var": "$noSuchVar", + "constant_str_var_expr": "$noSuchVar.noSuchMember", + "constant_str_intrinsic_func": "States.Format('Format Func {}', $varname)", + "constant_str_jsonata_expr": "{% $varname.varfield %}", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + "$.no.such.path", + "$varname", + "{% $varname %}", + "$$", + "States.Format('{}', $varname)", + [], + { + "constant": 0 + } + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_str_path_root": "$", + "in_obj_constant_str_path": "$.no.such.path", + "in_obj_constant_str_contextpath_root": "$$", + "in_obj_constant_str_contextpath": "$$.Execution.Id", + "in_obj_constant_str_var": "$noSuchVar", + "in_obj_constant_str_var_expr": "$noSuchVar.noSuchMember", + "in_obj_constant_str_intrinsic_func": "States.Format('Format Func {}', $varname)", + "in_obj_constant_str_jsonata_expr": "{% $varname.varfield %}", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + "$.no.such.path", + "$varname", + "{% $varname %}", + "$$", + "States.Format('{}', $varname)", + [], + { + "constant": 0 + } + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_empty.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_empty.json5 new file mode 100644 index 0000000000000..558ae7aa1d437 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_empty.json5 @@ -0,0 +1,11 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_evaluation_order_pass_state.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_evaluation_order_pass_state.json5 new file mode 100644 index 0000000000000..c3a957e026a8c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_evaluation_order_pass_state.json5 @@ -0,0 +1,27 @@ +{ + "Comment": "BASE_EVALUATION_ORDER_PASS_STATE", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "theQuestion": "What is the answer to life the universe and everything?" + }, + "Assign": { + "question.$": "$.theQuestion", + "answer": 42 + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "InputPath": "$answer", + "ResultPath": "$.theAnswer", + "OutputPath": "$answer", + "Assign": { + "answer": "" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_paths.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_paths.json5 new file mode 100644 index 0000000000000..b3c2e73ea1c9d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_paths.json5 @@ -0,0 +1,16 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "str_path_root.$": "$", + "str_path.$": "$.input_value", + "str_contextpath_root.$": "$$", + "str_contextpath.$": "$$.Execution.Id", + "str_intrinsic_func.$": "States.Format('Format Func {}', $.input_value)", + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_choice.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_choice.json5 new file mode 100644 index 0000000000000..fe97595a9a0ea --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_choice.json5 @@ -0,0 +1,42 @@ +{ + "Comment": "BASE_REFERENCE_IN_CHOICE", + "StartAt": "Setup", + "States": { + "Setup": { + "Type": "Pass", + "Assign": { + "guess": "the_guess", + "answer": "the_answer" + }, + "Next": "CheckAnswer" + }, + "CheckAnswer": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$guess", + "StringEqualsPath": "$answer", + "Next": "CorrectAnswer" + } + ], + "Default": "WrongAnswer" + }, + "CorrectAnswer": { + "Type": "Pass", + "Result": { + "state": "CORRECT" + }, + "End": true + }, + "WrongAnswer": { + "Type": "Pass", + "Assign": { + "guess.$": "$answer" + }, + "Result": { + "state": "WRONG" + }, + "Next": "CheckAnswer" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_fail.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_fail.json5 new file mode 100644 index 0000000000000..b68cf98a72e13 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_fail.json5 @@ -0,0 +1,19 @@ +{ + "Comment": "BASE_REFERENCE_IN_FAIL", + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Assign": { + "errorVar": "Exception", + "causeVar": "An Exception was encountered" + }, + "Next": "Fail" + }, + "Fail": { + "Type": "Fail", + "CausePath": "$causeVar", + "ErrorPath": "$errorVar" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_inputpath.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_inputpath.json5 new file mode 100644 index 0000000000000..18e17d7e855ac --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_inputpath.json5 @@ -0,0 +1,18 @@ +{ + "Comment": "BASE_REFERENCE_IN_INPUTPATH", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "theAnswer": 42 + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "InputPath": "$theAnswer", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_intrinsic_function.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_intrinsic_function.json5 new file mode 100644 index 0000000000000..3397bcc538eca --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_intrinsic_function.json5 @@ -0,0 +1,90 @@ +{ + "Comment": "BASE_REFERENCE_IN_INTRINSIC_FUNCTION", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Parameters": { +// "arrayOps": { +// "partitionedArray.$": "States.ArrayPartition(States.Array($inputArray), 2)", +// "containsElement.$": "States.ArrayContains(States.Array($inputArray), 5)", +// "thirdElement.$": "States.ArrayGetItem(States.Array($inputArray), 2)", +// "arraySize.$": "States.ArrayLength(States.Array($inputArray))", +// "uniqueValues.$": "States.ArrayUnique(States.Array($duplicateArray))" +// }, + "encodingOps": { + "encoded.$": "States.Base64Encode($rawString)", + "decoded.$": "States.Base64Decode($encodedString)" + }, + "hashOps": { + "hashValue.$": "States.Hash($inputString, 'SHA-256')" + }, + "jsonOps": { + "mergedJson.$": "States.JsonMerge($json1, $json2, false)", + "parsedJson.$": "States.StringToJson($jsonString)", + "stringifiedJson.$": "States.JsonToString($jsonObject)" + }, + "mathOps": { + "sum.$": "States.MathAdd($value1, $value2)" + }, + "stringOps": { + "splitString.$": "States.StringSplit($csvString, ',')", + "formattedString.$": "States.Format('Hello {}, welcome to {}!', $name, $place)" + }, + "uuidOp": { + "uniqueId.$": "States.UUID()" + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_iterator_outer_scope.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_iterator_outer_scope.json5 new file mode 100644 index 0000000000000..518309be05a9f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_iterator_outer_scope.json5 @@ -0,0 +1,72 @@ +{ + "Comment": "BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE", + "StartAt": "Input", + "States": { + "Input": { + "Type": "Pass", + "Result": [ + [ + 9, + 44, + 6 + ], + [ + 82, + 25, + 76 + ], + [ + 18, + 42, + 2 + ] + ], + "Assign": { + "bias": 4.3 + }, + "Next": "IterateLevels" + }, + "IterateLevels": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "AssignCurrentVector", + "States": { + "AssignCurrentVector": { + "Type": "Pass", + "Assign": { + "xCurrent.$": "$[0]", + "yCurrent.$": "$[1]", + "zCurrent.$": "$[2]" + }, + "Next": "Calculate" + }, + "Calculate": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Summate", + "States": { + "Summate": { + "Type": "Pass", + "Assign": { + "Sum.$": "States.MathAdd(States.MathAdd(States.MathAdd($yCurrent, $xCurrent), $zCurrent), $bias)" + }, + "End": true + } + } + }, + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_lambda_task_fields.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_lambda_task_fields.json5 new file mode 100644 index 0000000000000..5f0842befd80c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_lambda_task_fields.json5 @@ -0,0 +1,50 @@ +{ + "Comment": "BASE_REFERENCE_IN_LAMBDA_TASK_FIELDS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "maxTimeout": 300, + "heartbeatInterval": 60, + "accountId.$": "$.AccountID", + "functionName.$": "$.FunctionName", + "intervalSeconds" : 2, + "maxAttempts" : 3, + "backoffRate" : 1.5, + "targetRole": "CrossAccountRole", + "jobParameters": { + "inputData": "sample data", + "configOption": "value1" + } + }, + "Next": "State1" + }, + "State1": { + "Comment" : "Here we reference all variables set in Assign when running our task state", + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$functionName", + "Payload": { + "data.$": "$jobParameters", + } + }, + "Credentials": { + "RoleArn.$": "States.Format('arn:aws:iam::{}:role/{}', $accountId, $targetRole)" + }, + "ResultSelector": { + "processedData.$": "$.Payload.result", + "executionDetails": { + "startTime.$": "$.Payload.startTime", + "status.$": "$.Payload.status" + } + }, + "ResultPath": "$.taskResult", + "OutputPath": "$.taskResult.processedData", + "TimeoutSecondsPath": "$maxTimeout", + "HeartbeatSecondsPath": "$heartbeatInterval", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_outputpath.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_outputpath.json5 new file mode 100644 index 0000000000000..0d03f92b06f38 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_outputpath.json5 @@ -0,0 +1,21 @@ +{ + "Comment": "BASE_REFERENCE_IN_OUTPUTPATH", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "answer": 42 + }, + "Assign": { + "theAnswer.$": "$.answer" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "OutputPath": "$theAnswer", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_parameters.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_parameters.json5 new file mode 100644 index 0000000000000..6545fac5b8d3d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_parameters.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "BASE_REFERENCE_IN_PARAMETERS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "result": "$result" + }, + "Assign": { + "result": "foobar" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "ResultPath": "$.result", + "Parameters": { + "result.$": "$result" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_wait.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_wait.json5 new file mode 100644 index 0000000000000..728f2b755d63b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_reference_in_wait.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "BASE_REFERENCE_IN_WAIT", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "waitTime": 0, + "startAt": "2024-11-06T09:42:03Z" + }, + "Next": "WaitSecondsState" + }, + "WaitSecondsState": { + "Type": "Wait", + "SecondsPath": "$waitTime", + "Next": "WaitUntilState" + }, + "WaitUntilState": { + "Type": "Wait", + "TimestampPath": "$startAt", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_scope_map.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_scope_map.json5 new file mode 100644 index 0000000000000..af14abb47aa02 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_scope_map.json5 @@ -0,0 +1,39 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "x": 42, + "items": [1,2,3] + }, + "Next": "State1" + }, + "State1": { + "Type": "Map", + "ItemsPath": "$items", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { "Mode": "INLINE" }, + "StartAt": "Inner", + "States": { + "Inner": { + "Type": "Pass", + "Assign": { + "innerX.$": "$x", + }, + "End": true, + }, + }, + }, + "Next": "State2" + }, + "State2": { + "Type": "Pass", + "Assign": { + "final.$": "$x" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_scope_parallel.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_scope_parallel.json5 new file mode 100644 index 0000000000000..400f4a73c420c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_scope_parallel.json5 @@ -0,0 +1,57 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "x": 42, + }, + "Next": "State1" + }, + "State1": { + "Type": "Parallel", + "End": true, + "Branches": [ + { + "StartAt": "Branch1", + "States": { + "Branch1": { + "Type": "Pass", + "Assign": { + "innerX.$": "$x", + "value.$": "$" + }, + "End": true + } + } + }, + { + "StartAt": "Branch21", + "States": { + "Branch21": { + "Type": "Pass", + "Assign": { + "innerX.$": "$x", + "value.$": "$" + }, + "End": true + } + } + }, + { + "StartAt": "Branch31", + "States": { + "Branch31": { + "Type": "Pass", + "Assign": { + "innerX.$": "$x", + "value.$": "$" + }, + "End": true + } + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_arguments.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_arguments.json5 new file mode 100644 index 0000000000000..7bb62569e9879 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_arguments.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "BASE_UNDEFINED_ARGUMENTS", + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": "{% $doesNotExist %}", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_arguments_field.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_arguments_field.json5 new file mode 100644 index 0000000000000..689a1646984b1 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_arguments_field.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "BASE_UNDEFINED_ARGUMENTS_FIELD", + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "{% $doesNotExist %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_assign.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_assign.json5 new file mode 100644 index 0000000000000..ca305314ed0b4 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_assign.json5 @@ -0,0 +1,14 @@ +{ + "Comment": "BASE_UNDEFINED_ASSIGN", + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "result": "{% $doesNotExist %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output.json5 new file mode 100644 index 0000000000000..9f2507335bb49 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output.json5 @@ -0,0 +1,12 @@ +{ + "Comment": "BASE_UNDEFINED_OUTPUT", + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Pass", + "Output": "{% $doesNotExist %}", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output_field.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output_field.json5 new file mode 100644 index 0000000000000..9acfba89e3e8b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output_field.json5 @@ -0,0 +1,14 @@ +{ + "Comment": "BASE_UNDEFINED_OUTPUT_FIELD", + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Pass", + "Output": { + "result": "{% $doesNotExist %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output_multiple_states.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output_multiple_states.json5 new file mode 100644 index 0000000000000..4a483ddfe61a7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_undefined_output_multiple_states.json5 @@ -0,0 +1,22 @@ +{ + "Comment": "BASE_UNDEFINED_MULTIPLE_STATES", + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Pass", + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Next": "State2" + }, + "State2": { + "Type": "Pass", + "Output": { + "result": "{% $doesNotExist %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/base_var.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_var.json5 new file mode 100644 index 0000000000000..41168acba5c0a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/base_var.json5 @@ -0,0 +1,102 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "varname.$": "$.input_value", + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_str_path_root": "$", + "constant_str_path": "$.no.such.path", + "constant_str_contextpath_root": "$$", + "constant_str_contextpath": "$$.Execution.Id", + "constant_str_var": "$noSuchVar", + "constant_str_var_expr": "$noSuchVar.noSuchMember", + "constant_str_intrinsic_func": "States.Format('Format Func {}', $varname)", + "constant_str_jsonata_expr": "{% $varname.varfield %}", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + "$.no.such.path", + "$varname", + "{% $varname %}", + "$$", + "States.Format('{}', $varname)", + [], + { + "constant": 0 + } + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_str_path_root": "$", + "in_obj_constant_str_path": "$.no.such.path", + "in_obj_constant_str_contextpath_root": "$$", + "in_obj_constant_str_contextpath": "$$.Execution.Id", + "in_obj_constant_str_var": "$noSuchVar", + "in_obj_constant_str_var_expr": "$noSuchVar.noSuchMember", + "in_obj_constant_str_intrinsic_func": "States.Format('Format Func {}', $varname)", + "in_obj_constant_str_jsonata_expr": "{% $varname.varfield %}", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + "$.no.such.path", + "$varname", + "{% $varname %}", + "$$", + "States.Format('{}', $varname)", + [], + { + "constant": 0 + } + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Assign": { + "varname2": "varname2", + "varnameconstant_null.$": "$constant_null", + "varnameconstant_int.$": "$constant_int", + "varnameconstant_float.$": "$constant_float", + "varnameconstant_bool.$": "$constant_bool", + "varnameconstant_str.$": "$constant_str", + "varnameconstant_str_path_root.$": "$constant_str_path_root", + "varnameconstant_str_path.$": "$constant_str_path", + "varnameconstant_str_contextpath_root.$": "$constant_str_contextpath_root", + "varnameconstant_str_contextpath.$": "$constant_str_contextpath", + "varnameconstant_str_var.$": "$constant_str_var", + "varnameconstant_str_var_expr.$": "$constant_str_var_expr", + "varnameconstant_str_intrinsic_func.$": "$constant_str_intrinsic_func", + "varnameconstant_str_jsonata_expr.$": "$constant_str_jsonata_expr", + "varnameconstant_lst_empty.$": "$constant_lst_empty", + "varnameconstant_lst.$": "$constant_lst", + "varnameconstant_obj_empty.$": "$constant_obj_empty", + "varnameconstant_obj.$": "$constant_obj", + "varnameconstant_obj_access.$": "$constant_obj.in_obj_constant_lst" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/choice_condition_jsonata.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/choice_condition_jsonata.json5 new file mode 100644 index 0000000000000..431a2d9ad9356 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/choice_condition_jsonata.json5 @@ -0,0 +1,30 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + { + "Condition": "{% $states.input.condition %}", + "Next": "ConditionTrue", + "Assign": { + "Assignment": "Condition assignment" + } + } + ], + "Default": "DefaultState", + "Assign": { + "Assignment": "Default Assignment" + } + }, + "ConditionTrue": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "Condition is false" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_intrinsic_function.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_intrinsic_function.json5 new file mode 100644 index 0000000000000..1886fc128d7ea --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_intrinsic_function.json5 @@ -0,0 +1,43 @@ +{ + "Comment": "MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "AnswerTemplate": "It's {}!", + "Question": "Who's that Pokemon?" + }, + "Result": [ + "Charizard", + "Pikachu", + "Squirtle", + ], + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemSelector": { + "AnnouncePokemon.$": "States.Format($AnswerTemplate, $$.Map.Item.Value)" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Parameters": { + "Question.$": "$Question", + "Answer.$": "$.AnnouncePokemon" + }, + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_item_selector.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_item_selector.json5 new file mode 100644 index 0000000000000..f34de3d142162 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_item_selector.json5 @@ -0,0 +1,39 @@ +{ + "Comment": "MAP_STATE_REFERENCE_IN_ITEM_SELECTOR", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "bucket": "test-name", + }, + "Result":{ + "Values": [1, 2, 3] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.Values", + "ItemSelector": { + "value.$": "$$.Map.Item.Value", + "bucketName": "$bucket" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "MapPass", + "States": { + "MapPass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_items_path.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_items_path.json5 new file mode 100644 index 0000000000000..7de4d88607b58 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_items_path.json5 @@ -0,0 +1,46 @@ +{ + "Comment": "MAP_STATE_REFERENCE_IN_ITEMS_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "Question": "Who's that Pokemon?", + "PokemonList": [ + "Charizard", + "Pikachu", + "Squirtle" + ] + }, + "Result": { + "AnswerTemplate": "It's {}!" + }, + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$PokemonList", + "ItemSelector": { + "AnnouncePokemon.$": "States.Format($.AnswerTemplate, $$.Map.Item.Value)" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Parameters": { + "Question.$": "$Question", + "Answer.$": "$.AnnouncePokemon" + }, + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_concurrency_path.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_concurrency_path.json5 new file mode 100644 index 0000000000000..fdf5f2d3baa17 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_concurrency_path.json5 @@ -0,0 +1,34 @@ +{ + "Comment": "MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "maxConcurrency": "1" + }, + "Result": { + "Values": [1, 2, 3] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ItemsPath": "$.Values", + "MaxConcurrencyPath": "$maxConcurrency", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_items_path.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_items_path.json5 new file mode 100644 index 0000000000000..61dbdb14a8d4f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_items_path.json5 @@ -0,0 +1,42 @@ +{ + "Comment": "MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "maxItems": "2" + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + "MaxItemsPath": "$maxItems" + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_per_batch_path.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_per_batch_path.json5 new file mode 100644 index 0000000000000..3d291ffff05af --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_max_per_batch_path.json5 @@ -0,0 +1,47 @@ +{ + "Comment": "MAP_STATE_REFERENCE_IN_MAX_PER_BATCH_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "maxItemsPerBatch": "2", + "maxBytesPerBatch": "15000" + }, + "Next": "BatchMapState" + }, + "BatchMapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + "MaxItems": 4 + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemBatcher": { + "MaxItemsPerBatchPath": "$maxItemsPerBatch", + "MaxInputBytesPerBatchPath": "$maxBytesPerBatch" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "ProcessBatch", + "States": { + "ProcessBatch": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_tolerated_failure_path.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_tolerated_failure_path.json5 new file mode 100644 index 0000000000000..d9046c8fdd400 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/map_state_reference_in_tolerated_failure_path.json5 @@ -0,0 +1,38 @@ +{ + "Comment": "MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "toleratedFailurePercentage": "1", + "toleratedFailureCount": "1", + }, + "Result": { + "Values": [1, 2, 3] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ItemsPath": "$.Values", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "ToleratedFailurePercentagePath": "$toleratedFailurePercentage", + "ToleratedFailureCountPath": "$toleratedFailureCount", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/assign/statemachines/task_retry_reference_exception.json5 b/tests/aws/services/stepfunctions/templates/assign/statemachines/task_retry_reference_exception.json5 new file mode 100644 index 0000000000000..a7ff62ad55635 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/assign/statemachines/task_retry_reference_exception.json5 @@ -0,0 +1,51 @@ +{ + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Assign": { + "intervalSeconds": 2, + "maxAttempts": 3, + "backoffRate": 1.5 + }, + "Next": "Task" + }, + "Task": { + "Type": "Task", + "Resource": "$.FunctionName", + "TimeoutSeconds": 300, + "Retry": [ + { + "ErrorEquals": [ + "Lambda.ServiceException" + ], + "IntervalSeconds": "$intervalSeconds", + "MaxAttempts": "$maxAttempts", + "BackoffRate": "$backoffRate" + }, + { + "ErrorEquals": [ + "States.Timeout", + ], + "IntervalSeconds": "$.intervalSeconds", + "MaxAttempts": "$.maxAttempts", + "BackoffRate": "$.backoffRate" + }, + ], + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "ResultPath": "$.error", + "Next": "Finish" + } + ], + "Next": "Finish" + }, + "Finish": { + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/base/__init__.py b/tests/aws/services/stepfunctions/templates/base/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/base/base_templates.py b/tests/aws/services/stepfunctions/templates/base/base_templates.py new file mode 100644 index 0000000000000..97784fa1a4336 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/base_templates.py @@ -0,0 +1,51 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class BaseTemplate(TemplateLoader): + BASE_INVALID_DER: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/invalid_der.json5") + BASE_PASS_RESULT: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/pass_result.json5") + BASE_TASK_SEQ_2: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/task_seq_2.json5") + BASE_WAIT_1_MIN: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/wait_1_min.json5") + BASE_RAISE_FAILURE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/raise_failure.json5") + BASE_RAISE_FAILURE_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/raise_failure_path.json5" + ) + BASE_RAISE_FAILURE_INTRINSIC: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/raise_failure_path_intrinsic.json5" + ) + DECL_VERSION_1_0: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/decl_version_1_0.json5" + ) + RAISE_EMPTY_FAILURE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/raise_empty_failure.json5" + ) + WAIT_AND_FAIL: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/wait_and_fail.json5") + QUERY_CONTEXT_OBJECT_VALUES: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/query_context_object_values.json5" + ) + PASS_RESULT_NULL_INPUT_OUTPUT_PATHS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/pass_result_null_input_output_paths.json5" + ) + PASS_RESULT_REGEX_JSON_PATH_BASE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/pass_result_regex_json_path_base.json5" + ) + PASS_RESULT_REGEX_JSON_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/pass_result_regex_json_path.json5" + ) + PASS_START_TIME: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/pass_start_time_format.json5" + ) + WAIT_TIMESTAMP_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/wait_timestamp_path.json5" + ) + WAIT_SECONDS_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/wait_seconds_path.json5" + ) + JSON_PATH_ARRAY_ACCESS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/json_path_array_access.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/decl_version_1_0.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/decl_version_1_0.json5 new file mode 100644 index 0000000000000..759a84b27eed2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/decl_version_1_0.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "DECL_VERSION_1_0", + "StartAt": "State_1", + "Version": "1.0", + "States": { + "State_1": { + "Type": "Pass", + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/invalid_der.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/invalid_der.json5 new file mode 100644 index 0000000000000..cb7806a3bf54f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/invalid_der.json5 @@ -0,0 +1,4 @@ +{ + "Comment": "BASE_INVALID_DER", + "StartAt": "State_1", +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/json_path_array_access.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/json_path_array_access.json5 new file mode 100644 index 0000000000000..326992e613d1b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/json_path_array_access.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "JSON_PATH_ARRAY_ACCESS", + "StartAt": "EntryState", + "States": { + "EntryState": { + "Type": "Pass", + "Parameters": { + "item.$": "$.items[0]" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result.json5 new file mode 100644 index 0000000000000..8937cf31426df --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1", + }, + "End": true, + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_null_input_output_paths.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_null_input_output_paths.json5 new file mode 100644 index 0000000000000..5620e078885e3 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_null_input_output_paths.json5 @@ -0,0 +1,28 @@ +{ + "Comment": "BASE_PASS_RESULT_NULL_INPUT_OUTPUT_PATHS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "Arg1": "argument1", + "FromInput.$": "$" + }, + "Next": "State1", + }, + "State1": { + "Type": "Pass", + "InputPath": null, + "Parameters": { + "Arg2": "argument2", + "FromInput.$": "$" + }, + "Next": "State2", + }, + "State2": { + "Type": "Pass", + "OutputPath": null, + "End": true, + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_regex_json_path.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_regex_json_path.json5 new file mode 100644 index 0000000000000..308871e0a2369 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_regex_json_path.json5 @@ -0,0 +1,36 @@ +{ + "Comment": "BASE_PASS_RESULT_REGEX_JSON_PATH", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "FromInput.$": "$.users[?(@.status == 0)]" + }, + "OutputPath": "$.FromInput", + "Next": "State1", + }, + "State1": { + "Type": "Pass", + "Parameters": { + "FromInput.$": "$[?(@.year <= 1999)]" + }, + "OutputPath": "$.FromInput", + "Next": "State2", + }, + "State2": { + "Type": "Pass", + "Parameters": { + "InputValue.$": "$", + "FromInput.$": "States.JsonToString($.[?(@.year <= 1998)])" + }, + "Next": "State3" + }, + "State3": { + "Type": "Pass", + "InputPath": "$.InputValue", + "OutputPath": "$.[?(@.year < 1999)].name", + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_regex_json_path_base.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_regex_json_path_base.json5 new file mode 100644 index 0000000000000..4bccf20705159 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_result_regex_json_path_base.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "BASE_PASS_RESULT_REGEX_JSON_PATH_BASE", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "FromInput.$": "$.users[?(@.status == 0)]" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/pass_start_time_format.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_start_time_format.json5 new file mode 100644 index 0000000000000..89271025b7553 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/pass_start_time_format.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "Used to check format of generated timestamp", + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "End": true, + "Parameters": { + "out1.$": "$$.Execution.StartTime" + } + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/query_context_object_values.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/query_context_object_values.json5 new file mode 100644 index 0000000000000..fafc30bc4db7d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/query_context_object_values.json5 @@ -0,0 +1,37 @@ +{ + "Comment": "QUERY_CONTEXT_OBJECT_VALUES", + "StartAt": "QueryValues1", + "States": { + "QueryValues1": { + "Type": "Pass", + "Next": "QueryValues2", + "ResultPath": "$.query_values_1", + "Parameters": { + "context_object.$": "$$", + + "execution.$": "$$.Execution", + "execution_id.$": "$$.Execution.Id", + "execution_input.$": "$$.Execution.Input", + "execution_name.$": "$$.Execution.Name", + "execution_rolearn.$": "$$.Execution.RoleArn", + "execution_starttime.$": "$$.Execution.StartTime", + + "state.$": "$$.State", + "state_enteredtime.$": "$$.State.EnteredTime", + "state_name.$": "$$.State.Name", + + "statemachine.$": "$$.StateMachine", + "statemachine_id.$": "$$.StateMachine.Id", + "statemachine_name.$": "$$.StateMachine.Name", + } + }, + "QueryValues2": { + "Type": "Pass", + "End": true, + "ResultPath": "$.query_values_2", + "Parameters": { + "context_object.$": "$$", + } + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/raise_empty_failure.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_empty_failure.json5 new file mode 100644 index 0000000000000..ed4beb8883a7c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_empty_failure.json5 @@ -0,0 +1,9 @@ +{ + "Comment": "RAISE_EMPTY_FAILURE", + "StartAt": "FailState", + "States": { + "FailState": { + "Type": "Fail" + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure.json5 new file mode 100644 index 0000000000000..a290875f39442 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "BASE_RAISE_FAILURE", + "StartAt": "FailState", + "States": { + "FailState": { + "Type": "Fail", + "Error": "SomeFailure", + "Cause": "This state machines raises a 'SomeFailure' failure." + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure_path.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure_path.json5 new file mode 100644 index 0000000000000..976ea9af2f203 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure_path.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "RAISE_FAILURE_PATH", + "StartAt": "FailState", + "States": { + "FailState": { + "Type": "Fail", + "ErrorPath": "$.Error", + "CausePath": "$.Cause" + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure_path_intrinsic.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure_path_intrinsic.json5 new file mode 100644 index 0000000000000..9205e0139128c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/raise_failure_path_intrinsic.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "RAISE_FAILURE_PATH_INTRINSIC", + "StartAt": "FailState", + "States": { + "FailState": { + "Type": "Fail", + "ErrorPath": "States.Format('{}', $.Error)", + "CausePath": "States.Format('This is a custom error message for {}, caused by {}.', $.Error, $.Cause)", + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/task_seq_2.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/task_seq_2.json5 new file mode 100644 index 0000000000000..b6de94bc2de71 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/task_seq_2.json5 @@ -0,0 +1,17 @@ +{ + "Comment": "Hello World example", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "Next": "State_2" + }, + "State_2": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "ResultPath": "$.result_value", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/wait_1_min.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_1_min.json5 new file mode 100644 index 0000000000000..9f30d7f17ebb4 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_1_min.json5 @@ -0,0 +1,18 @@ +{ + "Comment": "WAIT_1_MIN", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Wait", + "Seconds": 60, + "Next": "State_2" + }, + "State_2": { + "Type": "Pass", + "Result": { + "Arg1": "argument1", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/wait_and_fail.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_and_fail.json5 new file mode 100644 index 0000000000000..41b6b861d0e70 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_and_fail.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "WAIT_AND_FAIL", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Wait", + "Seconds": 60, + "Next": "State_2" + }, + "State_2": { + "Type": "Fail", + "Error": "SomeFailure", + "Cause": "This state machines raises a 'SomeFailure' failure." + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/wait_seconds_path.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_seconds_path.json5 new file mode 100644 index 0000000000000..9b4ccf3e11e8d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_seconds_path.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "WAIT_SECONDS_PATH", + "StartAt": "WaitState", + "States": { + "WaitState": { + "Type": "Wait", + "SecondsPath": "$.input.waitSeconds", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/base/statemachines/wait_timestamp_path.json5 b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_timestamp_path.json5 new file mode 100644 index 0000000000000..827af4bc0b61e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/base/statemachines/wait_timestamp_path.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "WAIT_1_MIN", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Wait", + "TimestampPath": "$.start_at", + "End": true + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/__init__.py b/tests/aws/services/stepfunctions/templates/callbacks/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/callbacks/callback_templates.py b/tests/aws/services/stepfunctions/templates/callbacks/callback_templates.py new file mode 100644 index 0000000000000..dd46dabdf7213 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/callback_templates.py @@ -0,0 +1,48 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class CallbackTemplates(TemplateLoader): + SFN_START_EXECUTION_SYNC: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync.json5" + ) + SFN_START_EXECUTION_SYNC2: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync2.json5" + ) + SFN_START_EXECUTION_SYNC_WITH_TASK_TOKEN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync_with_task_token.json5" + ) + SNS_PUBLIC_WAIT_FOR_TASK_TOKEN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sns_publish_wait_for_task_token.json5" + ) + SQS_SUCCESS_ON_TASK_TOKEN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_success_on_task_token.json5" + ) + SQS_FAILURE_ON_TASK_TOKEN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_failure_on_task_token.json5" + ) + SQS_WAIT_FOR_TASK_TOKEN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_wait_for_task_token.json5" + ) + SQS_WAIT_FOR_TASK_TOKEN_CALL_CHAIN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_wait_for_task_token_call_chain.json5" + ) + SQS_WAIT_FOR_TASK_TOKEN_WITH_TIMEOUT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_wait_for_task_token_with_timeout.json5" + ) + SQS_WAIT_FOR_TASK_TOKEN_NO_TOKEN_PARAMETER: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_wait_for_task_token_no_token_parameter.json5" + ) + SQS_HEARTBEAT_SUCCESS_ON_TASK_TOKEN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_hearbeat_success_on_task_token.json5" + ) + SQS_PARALLEL_WAIT_FOR_TASK_TOKEN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_parallel_wait_for_task_token.json5" + ) + SQS_WAIT_FOR_TASK_TOKEN_CATCH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_wait_for_task_token_catch.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync.json5 new file mode 100644 index 0000000000000..0d84daf72de5a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "SFN_START_EXECUTION_SYNC", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync2.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync2.json5 new file mode 100644 index 0000000000000..331d6134f07e3 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync2.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "SFN_START_EXECUTION_SYNC:2", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync:2", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync_with_task_token.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync_with_task_token.json5 new file mode 100644 index 0000000000000..b505a71fb2ffc --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sfn_start_execution_sync_with_task_token.json5 @@ -0,0 +1,19 @@ +{ + "Comment": "SFN_START_EXECUTION_SYNC_WITH_TASK_TOKEN", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Name.$": "$.Name", + "Input": { + "QueueUrl.$": "$.QueueUrl", + "TaskToken.$": "$$.Task.Token", + }, + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sns_publish_wait_for_task_token.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sns_publish_wait_for_task_token.json5 new file mode 100644 index 0000000000000..398d165d72e6a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sns_publish_wait_for_task_token.json5 @@ -0,0 +1,19 @@ +{ + "Comment": "SNS_PUBLISH_WAIT_FOR_TASK_TOKEN", + "StartAt": "Publish", + "States": { + "Publish": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish.waitForTaskToken", + "Parameters": { + "TopicArn.$": "$.TopicArn", + "Message": { + "TaskToken.$": "$$.Task.Token", + "arg1.$": "$.body.arg1", + "arg2.$": "$.body.arg2", + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_failure_on_task_token.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_failure_on_task_token.json5 new file mode 100644 index 0000000000000..164f10ffd05b3 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_failure_on_task_token.json5 @@ -0,0 +1,85 @@ +{ + "Comment": "sqs_failure_on_task_token", + "StartAt": "Iterate", + "States": { + "Iterate": { + "Type": "Pass", + "Parameters": { + "Count.$": "States.MathAdd($.Iterator.Count, -1)" + }, + "ResultPath": "$.Iterator", + "Next": "IterateStep" + }, + "IterateStep": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterator.Count", + "NumericLessThanEquals": 0, + "Next": "NoMoreCycles" + } + ], + "Default": "WaitAndReceive", + }, + "WaitAndReceive": { + "Type": "Wait", + "Seconds": 1, + "Next": "Receive" + }, + "Receive": { + "Type": "Task", + "Parameters": { + "QueueUrl.$": "$.QueueUrl" + }, + "Resource": "arn:aws:states:::aws-sdk:sqs:receiveMessage", + "ResultPath": "$.SQSOutput", + "Next": "CheckMessages", + }, + "CheckMessages": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.SQSOutput.Messages", + "IsPresent": true, + "Next": "SendFailure" + } + ], + "Default": "Iterate" + }, + "SendFailure": { + "Type": "Map", + "InputPath": "$.SQSOutput.Messages", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "ParseBody", + "States": { + "ParseBody": { + "Type": "Pass", + "Parameters": { + "Body.$": "States.StringToJson($.Body)" + }, + "Next": "Send" + }, + "Send": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskFailure", + "Parameters": { + "Error": "Failure error", + "Cause": "Failure cause", + "TaskToken.$": "$.Body.TaskToken" + }, + "End": true + } + }, + }, + "ResultPath": null, + "Next": "Iterate" + }, + "NoMoreCycles": { + "Type": "Pass", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_hearbeat_success_on_task_token.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_hearbeat_success_on_task_token.json5 new file mode 100644 index 0000000000000..766ecfef8acb0 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_hearbeat_success_on_task_token.json5 @@ -0,0 +1,98 @@ +{ + "Comment": "SQS_HEARTBEAT_SUCCESS_ON_TASK_TOKEN", + "StartAt": "Iterate", + "States": { + "Iterate": { + "Type": "Pass", + "Parameters": { + "Count.$": "States.MathAdd($.Iterator.Count, -1)" + }, + "ResultPath": "$.Iterator", + "Next": "IterateStep" + }, + "IterateStep": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterator.Count", + "NumericLessThanEquals": 0, + "Next": "NoMoreCycles" + } + ], + "Default": "WaitAndReceive", + }, + "WaitAndReceive": { + "Type": "Wait", + "Seconds": 1, + "Next": "Receive" + }, + "Receive": { + "Type": "Task", + "Parameters": { + "QueueUrl.$": "$.QueueUrl" + }, + "Resource": "arn:aws:states:::aws-sdk:sqs:receiveMessage", + "ResultPath": "$.SQSOutput", + "Next": "CheckMessages", + }, + "CheckMessages": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.SQSOutput.Messages", + "IsPresent": true, + "Next": "SendSuccesses" + } + ], + "Default": "Iterate" + }, + "SendSuccesses": { + "Type": "Map", + "InputPath": "$.SQSOutput.Messages", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "ParseBody", + "States": { + "ParseBody": { + "Type": "Pass", + "Parameters": { + "Body.$": "States.StringToJson($.Body)" + }, + "Next": "WaitBeforeHeartbeat" + }, + "WaitBeforeHeartbeat": { + "Type": "Wait", + "Seconds": 5, + "Next": "SendHeartbeat" + }, + "SendHeartbeat": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskHeartbeat", + "Parameters": { + "TaskToken.$": "$.Body.TaskToken" + }, + "ResultPath": null, + "Next": "SendSuccess" + }, + "SendSuccess": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskSuccess", + "Parameters": { + "Output.$": "States.JsonToString($.Body.Message)", + "TaskToken.$": "$.Body.TaskToken" + }, + "End": true + } + }, + }, + "ResultPath": null, + "Next": "Iterate" + }, + "NoMoreCycles": { + "Type": "Pass", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_parallel_wait_for_task_token.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_parallel_wait_for_task_token.json5 new file mode 100644 index 0000000000000..23815fa7a7f42 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_parallel_wait_for_task_token.json5 @@ -0,0 +1,53 @@ +{ + "Comment": "SQS_PARALLEL_WAIT_FOR_TASK_TOKEN", + "StartAt": "ParallelJob", + "States": { + "ParallelJob": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "SendMessageWithWait", + "States": { + "SendMessageWithWait": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Context.$": "$", + "TaskToken.$": "$$.Task.Token" + } + }, + "End": true + }, + } + } + ], + "Catch": [ + { + "ErrorEquals": [ + "States.Runtime" + ], + "ResultPath": "$.states_runtime_error", + "Next": "CaughtRuntimeError" + }, + { + "ErrorEquals": [ + "States.ALL" + ], + "ResultPath": "$.states_all_error", + "Next": "CaughtStatesALL" + } + ], + "End": true + }, + "CaughtRuntimeError": { + "Type": "Pass", + "End": true + }, + "CaughtStatesALL": { + "Type": "Pass", + "End": true + }, + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_success_on_task_token.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_success_on_task_token.json5 new file mode 100644 index 0000000000000..8946f82fb1a86 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_success_on_task_token.json5 @@ -0,0 +1,84 @@ +{ + "Comment": "sqs_success_on_task_token", + "StartAt": "Iterate", + "States": { + "Iterate": { + "Type": "Pass", + "Parameters": { + "Count.$": "States.MathAdd($.Iterator.Count, -1)" + }, + "ResultPath": "$.Iterator", + "Next": "IterateStep" + }, + "IterateStep": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterator.Count", + "NumericLessThanEquals": 0, + "Next": "NoMoreCycles" + } + ], + "Default": "WaitAndReceive", + }, + "WaitAndReceive": { + "Type": "Wait", + "Seconds": 1, + "Next": "Receive" + }, + "Receive": { + "Type": "Task", + "Parameters": { + "QueueUrl.$": "$.QueueUrl" + }, + "Resource": "arn:aws:states:::aws-sdk:sqs:receiveMessage", + "ResultPath": "$.SQSOutput", + "Next": "CheckMessages", + }, + "CheckMessages": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.SQSOutput.Messages", + "IsPresent": true, + "Next": "SendSuccesses" + } + ], + "Default": "Iterate" + }, + "SendSuccesses": { + "Type": "Map", + "InputPath": "$.SQSOutput.Messages", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "ParseBody", + "States": { + "ParseBody": { + "Type": "Pass", + "Parameters": { + "Body.$": "States.StringToJson($.Body)" + }, + "Next": "Send" + }, + "Send": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskSuccess", + "Parameters": { + "Output.$": "States.JsonToString($.Body.Message)", + "TaskToken.$": "$.Body.TaskToken" + }, + "End": true + } + }, + }, + "ResultPath": null, + "Next": "Iterate" + }, + "NoMoreCycles": { + "Type": "Pass", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token.json5 new file mode 100644 index 0000000000000..989483b3e0d63 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token.json5 @@ -0,0 +1,17 @@ +{ + "StartAt": "SendMessageWithWait", + "States": { + "SendMessageWithWait": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message.$": "$.Message", + "TaskToken.$": "$$.Task.Token" + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_call_chain.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_call_chain.json5 new file mode 100644 index 0000000000000..f77265a343514 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_call_chain.json5 @@ -0,0 +1,32 @@ +{ + "Comment": "SQS_WAIT_FOR_TASK_TOKEN_CALL_CHAIN", + "StartAt": "State1", + "States": { + "State1": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message.$": "$.Message", + "TaskToken.$": "$$.Task.Token" + } + }, + ResultPath: "$.State1Output", + "Next": "State2" + }, + "State2": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message.$": "$.Message", + "TaskToken.$": "$$.Task.Token" + } + }, + ResultPath: "$.State2Output", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_catch.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_catch.json5 new file mode 100644 index 0000000000000..f38a5033cf57d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_catch.json5 @@ -0,0 +1,42 @@ +{ + "Comment": "SQS_WAIT_FOR_TASK_TOKEN_CATCH", + "StartAt": "SendMessageWithWait", + "States": { + "SendMessageWithWait": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Context.$": "$", + "TaskToken.$": "$$.Task.Token" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.Runtime" + ], + "ResultPath": "$.states_runtime_error", + "Next": "CaughtRuntimeError" + }, + { + "ErrorEquals": [ + "States.ALL" + ], + "ResultPath": "$.states_all_error", + "Next": "CaughtStatesALL" + } + ], + "End": true + }, + "CaughtRuntimeError": { + "Type": "Pass", + "End": true + }, + "CaughtStatesALL": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_no_token_parameter.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_no_token_parameter.json5 new file mode 100644 index 0000000000000..463ab9d6b6930 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_no_token_parameter.json5 @@ -0,0 +1,18 @@ +{ + "Comment": "SQS_WAIT_FOR_TASK_TOKEN_NO_TOKEN_PARAMETER", + "StartAt": "State1", + "States": { + "State1": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "TimeoutSeconds": 5, + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Context.$": "$", + } + }, + "End": true + }, + } +} diff --git a/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_with_timeout.json5 b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_with_timeout.json5 new file mode 100644 index 0000000000000..92660898918ea --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/callbacks/statemachines/sqs_wait_for_task_token_with_timeout.json5 @@ -0,0 +1,18 @@ +{ + "StartAt": "SendMessageWithWaitAndTimeout", + "States": { + "SendMessageWithWaitAndTimeout": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "TimeoutSeconds": 5, + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message.$": "$.Message", + "TaskToken.$": "$$.Task.Token" + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/choiceoperators/__init__.py b/tests/aws/services/stepfunctions/templates/choiceoperators/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/choiceoperators/choice_operators_templates.py b/tests/aws/services/stepfunctions/templates/choiceoperators/choice_operators_templates.py new file mode 100644 index 0000000000000..ddff5810efb62 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/choiceoperators/choice_operators_templates.py @@ -0,0 +1,16 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ChoiceOperatorTemplate(TemplateLoader): + COMPARISON_OPERATOR_PLACEHOLDER = "%ComparisonOperatorType%" + VARIABLE_KEY: Final[str] = "Variable" + VALUE_KEY: Final[str] = "Value" + VALUE_PLACEHOLDER = '"$.Value"' + TEST_RESULT_KEY: Final[str] = "TestResult" + + BASE_TEMPLATE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/template.json5") diff --git a/tests/aws/services/stepfunctions/templates/choiceoperators/statemachines/template.json5 b/tests/aws/services/stepfunctions/templates/choiceoperators/statemachines/template.json5 new file mode 100644 index 0000000000000..bfd9aa24e2989 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/choiceoperators/statemachines/template.json5 @@ -0,0 +1,31 @@ +{ + "Comment": "BASE_TEMPLATE", + "StartAt": "CheckValues", + "States": { + "CheckValues": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Variable", + "%ComparisonOperatorType%": "$.Value", + "Next": "ConditionTrue" + }, + ], + "Default": "ConditionFalse", + }, + "ConditionTrue": { + "Type": "Pass", + "Result": { + "TestResult.$": "ConditionTrue" + }, + "End": true, + }, + "ConditionFalse": { + "Type": "Pass", + "Result": { + "TestResult.$": "ConditionFalse" + }, + "End": true, + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/comment/__init__.py b/tests/aws/services/stepfunctions/templates/comment/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/comment/comment_templates.py b/tests/aws/services/stepfunctions/templates/comment/comment_templates.py new file mode 100644 index 0000000000000..e28458f0c0fd3 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/comment/comment_templates.py @@ -0,0 +1,15 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class CommentTemplates(TemplateLoader): + COMMENTS_AS_PER_DOCS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/comments_as_per_docs.json5" + ) + COMMENT_IN_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/comment_in_parameters.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/comment/statemachines/comment_in_parameters.json5 b/tests/aws/services/stepfunctions/templates/comment/statemachines/comment_in_parameters.json5 new file mode 100644 index 0000000000000..8597ca79c44b6 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/comment/statemachines/comment_in_parameters.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "COMMENTS_IN_PARAMETERS", + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Parameters": { + "Comment": "Comment text", + "comment" : "comment text", + "constant": "constant text" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/comment/statemachines/comments_as_per_docs.json5 b/tests/aws/services/stepfunctions/templates/comment/statemachines/comments_as_per_docs.json5 new file mode 100644 index 0000000000000..ff82531cfaa55 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/comment/statemachines/comments_as_per_docs.json5 @@ -0,0 +1,84 @@ +{ + "Comment": "A state machine that initially fails a condition and then succeeds upon retry.", + "StartAt": "SetupInitialCondition", + "States": { + "SetupInitialCondition": { + "Type": "Pass", + "Comment": "Setup an initial failing condition for the HelloWorld task.", + "Result": { + "status": "incomplete" + }, + "ResultPath": "$", + "Next": "TaskStateCatchRetry" + }, + "TaskStateCatchRetry": { + "Type": "Task", + "Resource": "arn:aws:lambda:your-region:your-account-id:function:yourHelloWorldFunction", + "Comment": "Invoke a Lambda function that returns its input. Initially set to fail.", + "Next": "IsComplete", + "Catch": [ + { + "ErrorEquals": [ + "States.TaskFailed" + ], + "Next": "FailState", + "Comment": "Catch task failures and move to the FailState." + } + ], + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 3, + "MaxAttempts": 1, + "BackoffRate": 1.0, + "Comment": "Retry policy for any error. Adjusted for demonstration." + } + ] + }, + "IsComplete": { + "Type": "Choice", + "Comment": "Decide the next state based on the Lambda function's output.", + "Choices": [ + { + "And": [ + { + "Variable": "$.status", + "StringEquals": "complete", + "Comment": "If task is complete, move to the SuccessState." + } + ], + "Comment": "Set the next state as the SuccessState", + "Next": "SuccessState" + } + ], + "Default": "WaitState" + }, + "WaitState": { + "Type": "Wait", + "Seconds": 5, + "Comment": "Wait for a few seconds before correcting the condition and retrying.", + "Next": "CorrectCondition" + }, + "CorrectCondition": { + "Type": "Pass", + "Comment": "Correct the condition to ensure success in the next HelloWorld task attempt.", + "Result": { + "status": "complete" + }, + "ResultPath": "$", + "Next": "TaskStateCatchRetry" + }, + "SuccessState": { + "Type": "Succeed", + "Comment": "The state machine completes successfully." + }, + "FailState": { + "Type": "Fail", + "Error": "TaskFailed", + "Cause": "The Lambda function task failed.", + "Comment": "The state machine fails due to a task failure." + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/context_object/__init__.py b/tests/aws/services/stepfunctions/templates/context_object/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/context_object/context_object_templates.py b/tests/aws/services/stepfunctions/templates/context_object/context_object_templates.py new file mode 100644 index 0000000000000..056a921e02333 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/context_object/context_object_templates.py @@ -0,0 +1,29 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ContextObjectTemplates(TemplateLoader): + CONTEXT_OBJECT_LITERAL_PLACEHOLDER = "%CONTEXT_OBJECT_LITERAL_PLACEHOLDER%" + + CONTEXT_OBJECT_ERROR_CAUSE_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/context_object_error_cause_path.json5" + ) + CONTEXT_OBJECT_INPUT_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/context_object_input_path.json5" + ) + CONTEXT_OBJECT_ITEMS_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/context_object_items_path.json5" + ) + CONTEXT_OBJECT_OUTPUT_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/context_object_output_path.json5" + ) + CONTEXT_OBJECT_RESULT_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/context_object_result_selector.json5" + ) + CONTEXT_OBJECT_VARIABLE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/context_object_variable.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_error_cause_path.json5 b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_error_cause_path.json5 new file mode 100644 index 0000000000000..96cbb1956c17e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_error_cause_path.json5 @@ -0,0 +1,10 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Fail", + "ErrorPath": "$$.State.Name", + "CausePath": "$$.StateMachine.Name" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_input_path.json5 b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_input_path.json5 new file mode 100644 index 0000000000000..8f00b8b343e5e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_input_path.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "CONTEXT_OBJECT_INPUT_PATH", + "StartAt": "TestState", + "States": { + "TestState": { + "Type": "Pass", + "InputPath" : "%CONTEXT_OBJECT_LITERAL_PLACEHOLDER%", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_items_path.json5 b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_items_path.json5 new file mode 100644 index 0000000000000..7c4fd6e9bceb4 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_items_path.json5 @@ -0,0 +1,21 @@ +{ + "Comment": "CONTEXT_OBJECT_ITEMS_PATH", + "StartAt": "TestState", + "States": { + "TestState": { + "Type": "Map", + "ItemsPath": "%CONTEXT_OBJECT_LITERAL_PLACEHOLDER%", + "MaxConcurrency": 1, + "ItemProcessor": { + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true, + }, + } +} diff --git a/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_output_path.json5 b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_output_path.json5 new file mode 100644 index 0000000000000..364d657bc84f9 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_output_path.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "CONTEXT_OBJECT_OUTPUT_PATH", + "StartAt": "TestState", + "States": { + "TestState": { + "Type": "Pass", + "OutputPath" : "%CONTEXT_OBJECT_LITERAL_PLACEHOLDER%", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_result_selector.json5 b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_result_selector.json5 new file mode 100644 index 0000000000000..47a729d794886 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_result_selector.json5 @@ -0,0 +1,21 @@ +{ + "Comment": "CONTEXT_OBJECT_RESULT_SELECTOR", + "StartAt": "TestState", + "States": { + "TestState": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload": { + "Input.$": "$", + } + }, + "ResultSelector": { + "LambdaOutput.$": "$", + "ContextObjectValue.$": "%CONTEXT_OBJECT_LITERAL_PLACEHOLDER%" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_variable.json5 b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_variable.json5 new file mode 100644 index 0000000000000..beeb7cfbb40de --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/context_object/statemachines/context_object_variable.json5 @@ -0,0 +1,25 @@ +{ + "Comment": "CONTEXT_OBJECT_VARIABLE", + "StartAt": "TestState", + "States": { + "TestState": { + "Type": "Choice", + "Choices": [ + { + "Variable": "%CONTEXT_OBJECT_LITERAL_PLACEHOLDER%", + "NumericLessThanEquals": 0, + "Next": "QuitChoiceMatched", + } + ], + "Default": "QuitNoChoiceMatched" + }, + "QuitChoiceMatched": { + "Type": "Pass", + "End": true + }, + "QuitNoChoiceMatched": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/__init__.py b/tests/aws/services/stepfunctions/templates/credentials/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/credentials/credentials_templates.py b/tests/aws/services/stepfunctions/templates/credentials/credentials_templates.py new file mode 100644 index 0000000000000..2c02d6c2df823 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/credentials_templates.py @@ -0,0 +1,37 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class CredentialsTemplates(TemplateLoader): + EMPTY_CREDENTIALS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/empty_credentials.json5" + ) + INVALID_CREDENTIALS_FIELD: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_credentials_field.json5" + ) + LAMBDA_TASK: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/lambda_task.json5") + SERVICE_LAMBDA_INVOKE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/service_lambda_invoke.json5" + ) + SERVICE_LAMBDA_INVOKE_RETRY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/service_lambda_invoke_retry.json5" + ) + SFN_START_EXECUTION_SYNC_ROLE_ARN_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync_role_arn_jsonata.json5" + ) + SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync_role_arn_path.json5" + ) + SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH_CONTEXT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync_role_arn_path_context.json5" + ) + SFN_START_EXECUTION_SYNC_ROLE_ARN_VARIABLE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync_role_arn_variable.json5" + ) + SFN_START_EXECUTION_SYNC_ROLE_ARN_INTRINSIC: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution_sync_role_arn_intrinsic.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/empty_credentials.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/empty_credentials.json5 new file mode 100644 index 0000000000000..722e807803286 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/empty_credentials.json5 @@ -0,0 +1,13 @@ +{ + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": "{% $state.input %}", + "Credentials": {}, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/invalid_credentials_field.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/invalid_credentials_field.json5 new file mode 100644 index 0000000000000..59ce6fdef611b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/invalid_credentials_field.json5 @@ -0,0 +1,15 @@ +{ + "StartAt": "State0", + "QueryLanguage": "JSONata", + "States": { + "State0": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": "{% $state.input %}", + "Credentials": { + "InvalidField": "invalid" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/lambda_task.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/lambda_task.json5 new file mode 100644 index 0000000000000..c4e14524d7f2d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/lambda_task.json5 @@ -0,0 +1,14 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "LambdaTask", + "States": { + "LambdaTask": { + "Type": "Task", + "Resource": "__tbd__", + "Credentials": { + "RoleArn": "{% $states.input.CredentialsRoleArn %}" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/service_lambda_invoke.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/service_lambda_invoke.json5 new file mode 100644 index 0000000000000..6461e4af0c857 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/service_lambda_invoke.json5 @@ -0,0 +1,18 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "{% $states.input.FunctionName %}", + "Payload": "{% $states.input.Payload %}", + }, + "Credentials": { + "RoleArn": "{% $states.input.CredentialsRoleArn %}" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/service_lambda_invoke_retry.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/service_lambda_invoke_retry.json5 new file mode 100644 index 0000000000000..ccf84a6c2e54d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/service_lambda_invoke_retry.json5 @@ -0,0 +1,24 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "{% $states.input.FunctionName %}", + "Payload": "{% $states.input.Payload %}", + }, + "Credentials": { + "RoleArn": "{% $states.input.CredentialsRoleArn %}" + }, + "Retry": [ + { + "ErrorEquals": ["States.ALL"], + "MaxAttempts": 2, + } + ], + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_intrinsic.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_intrinsic.json5 new file mode 100644 index 0000000000000..f8cac57593648 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_intrinsic.json5 @@ -0,0 +1,18 @@ +{ + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name", + }, + "Credentials": { + "RoleArn.$": "States.Format('{}', $.CredentialsRoleArn)" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_jsonata.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_jsonata.json5 new file mode 100644 index 0000000000000..038054abc4b47 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_jsonata.json5 @@ -0,0 +1,19 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Arguments": { + "StateMachineArn": "{% $states.input.StateMachineArn %}", + "Input": "{% $states.input.Input %}", + "Name": "{% $states.input.Name %}", + }, + "Credentials": { + "RoleArn": "{% $states.input.CredentialsRoleArn %}", + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_path.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_path.json5 new file mode 100644 index 0000000000000..07d30f77b274c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_path.json5 @@ -0,0 +1,18 @@ +{ + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name", + }, + "Credentials": { + "RoleArn.$": "$.CredentialsRoleArn" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_path_context.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_path_context.json5 new file mode 100644 index 0000000000000..2427a00da1f2d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_path_context.json5 @@ -0,0 +1,25 @@ +{ + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Pass", + "Assign": { + "roleArn.$": "$.CredentialsRoleArn" + }, + "Next": "RunTask" + }, + "RunTask": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name", + }, + "Credentials": { + "RoleArn.$": "$$.Execution.Input.CredentialsRoleArn" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_variable.json5 b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_variable.json5 new file mode 100644 index 0000000000000..982d3807380e5 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/credentials/statemachines/sfn_start_execution_sync_role_arn_variable.json5 @@ -0,0 +1,25 @@ +{ + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Pass", + "Assign": { + "roleArn.$": "$.CredentialsRoleArn" + }, + "Next": "RunTask" + }, + "RunTask": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name", + }, + "Credentials": { + "RoleArn.$": "$roleArn" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/__init__.py b/tests/aws/services/stepfunctions/templates/errorhandling/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/error_handling_templates.py b/tests/aws/services/stepfunctions/templates/errorhandling/error_handling_templates.py new file mode 100644 index 0000000000000..d7ef0a28d6839 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/error_handling_templates.py @@ -0,0 +1,87 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ErrorHandlingTemplate(TemplateLoader): + AWS_SDK_TASK_FAILED_S3_LIST_OBJECTS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_task_error_s3_list_objects.json5" + ) + + AWS_SDK_TASK_FAILED_S3_NO_SUCH_KEY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_task_error_s3_no_such_key.json5" + ) + + AWS_SDK_TASK_FAILED_SECRETSMANAGER_CREATE_SECRET: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_task_error_secretsmanager_crate_secret.json5" + ) + + AWS_SDK_TASK_DYNAMODB_PUT_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_aws_sdk_dynamodb_put_item.json5" + ) + + AWS_SERVICE_DYNAMODB_PUT_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_dynamodb_put_item.json5" + ) + + AWS_LAMBDA_INVOKE_CATCH_UNKNOWN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_lambda_invoke_catch_unknown.json5" + ) + + AWS_LAMBDA_INVOKE_CATCH_TBD: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_lambda_invoke_catch_tbd.json5" + ) + + AWS_LAMBDA_INVOKE_CATCH_RELEVANT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_lambda_invoke_catch_relevant.json5" + ) + + AWS_SERVICE_LAMBDA_INVOKE_CATCH_ALL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_lambda_invoke_catch_all.json5" + ) + + AWS_SERVICE_LAMBDA_INVOKE_CATCH_ALL_OUTPUT_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_lambda_invoke_catch_all_output_path.json5" + ) + + AWS_SERVICE_LAMBDA_INVOKE_CATCH_DATA_LIMIT_EXCEEDED: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_lambda_invoke_catch_data_limit_exceeded.json5" + ) + + AWS_SERVICE_LAMBDA_INVOKE_CATCH_UNKNOWN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_lambda_invoke_catch_unknown.json5" + ) + + AWS_SERVICE_LAMBDA_INVOKE_CATCH_TIMEOUT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_lambda_invoke_catch_timeout.json5" + ) + + AWS_SERVICE_LAMBDA_INVOKE_CATCH_RELEVANT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_lambda_invoke_catch_relevant.json5" + ) + + AWS_SERVICE_LAMBDA_INVOKE_CATCH_TBD: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_lambda_invoke_catch_tbd.json5" + ) + + AWS_SERVICE_SQS_SEND_MSG_CATCH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/task_service_sqs_send_msg_catch.json5" + ) + + AWS_SERVICE_SQS_SEND_MSG_CATCH_TOKEN_FAILURE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_service_sqs_send_msg_catch_token_failure.json5" + ) + + # Lambda Functions. + LAMBDA_FUNC_LARGE_OUTPUT_STRING: Final[str] = os.path.join( + _THIS_FOLDER, "lambdafunctions/large_output_string.py" + ) + LAMBDA_FUNC_RAISE_EXCEPTION: Final[str] = os.path.join( + _THIS_FOLDER, "lambdafunctions/raise_exception.py" + ) + LAMBDA_FUNC_RAISE_CUSTOM_EXCEPTION: Final[str] = os.path.join( + _THIS_FOLDER, "lambdafunctions/raise_custom_exception.py" + ) diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/__init__.py b/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/large_output_string.py b/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/large_output_string.py new file mode 100644 index 0000000000000..a7da9f8dbb979 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/large_output_string.py @@ -0,0 +1,6 @@ +def handler(event, context): + # Returns > 256 KB of data as a UTF-8 encoded string. + size_in_bytes = 257 * 1024 + ascii_string = "a" * size_in_bytes + utf8_encoded_string = ascii_string.encode("utf-8") + return utf8_encoded_string diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/raise_custom_exception.py b/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/raise_custom_exception.py new file mode 100644 index 0000000000000..38f24d3782a68 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/raise_custom_exception.py @@ -0,0 +1,9 @@ +class CustomException(Exception): + message: str + + def __init__(self): + self.message = "CustomException message" + + +def handler(event, context): + raise CustomException() diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/raise_exception.py b/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/raise_exception.py new file mode 100644 index 0000000000000..f245acaf23fd4 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/lambdafunctions/raise_exception.py @@ -0,0 +1,2 @@ +def handler(event, context): + raise Exception("Some exception was raised.") diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_s3_list_objects.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_s3_list_objects.json5 new file mode 100644 index 0000000000000..1b768ccdca42e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_s3_list_objects.json5 @@ -0,0 +1,27 @@ +{ + "Comment": "AWS_SDK_TASK_ERROR_S3_LIST_OBJECTS", + "StartAt": "CreateSecret", + "States": { + "CreateSecret": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:s3:listObjects", + "Parameters": { + "Bucket.$": "$.Bucket" + }, + "Catch": [ + { + "ErrorEquals": [ + "States.TaskFailed" + ], + "ResultPath": "$.TaskFailedError", + "Next": "TaskFailedHandler" + } + ], + "End": true + }, + "TaskFailedHandler": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_s3_no_such_key.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_s3_no_such_key.json5 new file mode 100644 index 0000000000000..20123f91e9540 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_s3_no_such_key.json5 @@ -0,0 +1,30 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:s3:getObject", + "Arguments": { + "Bucket": "{% $states.input.Bucket %}", + "Key": "no_such_key.json" + }, + "Catch": [ + { + "ErrorEquals": [ + "S3.NoSuchKeyException" + ], + "Output": "{% $states.errorOutput %}", + "Next": "NoSuchKeyState" + } + ], + "Next": "TerminalState" + }, + "TerminalState": { + "Type": "Succeed" + }, + "NoSuchKeyState": { + "Type": "Fail" + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_secretsmanager_crate_secret.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_secretsmanager_crate_secret.json5 new file mode 100644 index 0000000000000..a69eb2090e4d5 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_sdk_task_error_secretsmanager_crate_secret.json5 @@ -0,0 +1,28 @@ +{ + "Comment": "AWS_SDK_TASK_ERROR_SECRETSMANAGER", + "StartAt": "CreateSecret", + "States": { + "CreateSecret": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:secretsmanager:createSecret", + "Parameters": { + "Name.$": "$.Name", + "SecretString.$": "$.SecretString" + }, + "Catch": [ + { + "ErrorEquals": [ + "States.TaskFailed" + ], + "ResultPath": "$.TaskFailedError", + "Next": "TaskFailedHandler" + } + ], + "End": true + }, + "TaskFailedHandler": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_service_sqs_send_msg_catch_token_failure.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_service_sqs_send_msg_catch_token_failure.json5 new file mode 100644 index 0000000000000..cf45fc524342d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/aws_service_sqs_send_msg_catch_token_failure.json5 @@ -0,0 +1,35 @@ +{ + "StartAt": "SendMessageWithWait", + "States": { + "SendMessageWithWait": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message.$": "$.Message", + "TaskToken.$": "$$.Task.Token" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "<%WaitForTaskTokenFailureErrorName%>", + ], + "Next": "EndWithCaught" + }, + ], + "Next": "EndWithFinal", + }, + "EndWithCaught": { + "Type": "Pass", + "ResultPath": "$.caught", + "End": true + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_all.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_all.json5 new file mode 100644 index 0000000000000..aec4f854381f0 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_all.json5 @@ -0,0 +1,27 @@ +{ + "Comment": "TASK_LAMBDA_INVOKE_CATCH_ALL", + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "__tbd__", + "Next": "ProcessResult", + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "HandleGeneralError" + } + ] + }, + "ProcessResult": { + "Type": "Pass", + "End": true + }, + "HandleGeneralError": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_data_limit_exceeded.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_data_limit_exceeded.json5 new file mode 100644 index 0000000000000..fc064a9764e56 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_data_limit_exceeded.json5 @@ -0,0 +1,38 @@ +{ + "Comment": "TASK_LAMBDA_INVOKE_CATCH_DATA_LIMIT_EXCEEDED", + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "__tbd__", + "Next": "ProcessResult", + "Catch": [ + { + // Note: DataLimitExceeded is a terminal error that can't be caught by the States.ALL error type. + "ErrorEquals": [ + "States.DataLimitExceeded" + ], + "Next": "HandleDataLimitExceeded" + }, + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "HandleGeneralError" + } + ] + }, + "ProcessResult": { + "Type": "Pass", + "End": true + }, + "HandleDataLimitExceeded": { + "Type": "Pass", + "End": true + }, + "HandleGeneralError": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_relevant.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_relevant.json5 new file mode 100644 index 0000000000000..6b700a289acff --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_relevant.json5 @@ -0,0 +1,55 @@ +{ + "Comment": "TASK_LAMBDA_CATCH_RELEVANT", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "__tbd__", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Catch": [ + { + "ErrorEquals": [ + "Lambda.Unknown", + ], + "Next": "EndWithLambdaUnknownHandler" + }, + { + "ErrorEquals": [ + "States.TaskFailed", + ], + "Next": "EndWithStateTaskFailedHandler" + }, + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "EndWithAllHandler" + } + ], + "Next": "EndWithFinal", + }, + "EndWithLambdaUnknownHandler": { + "Type": "Pass", + "ResultPath": "$.lambda_unknown", + "End": true + }, + "EndWithStateTaskFailedHandler": { + "Type": "Pass", + "ResultPath": "$.task_failed_error", + "End": true + }, + "EndWithAllHandler": { + "Type": "Pass", + "ResultPath": "$.all_error", + "End": true + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_tbd.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_tbd.json5 new file mode 100644 index 0000000000000..ef3f2e24cc45b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_tbd.json5 @@ -0,0 +1,24 @@ +{ + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "_tbd_", + "Next": "ProcessResult", + "Catch": [ + { + "ErrorEquals": [], + "Next": "ErrorMatched" + } + ] + }, + "ProcessResult": { + "Type": "Pass", + "End": true + }, + "ErrorMatched": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_unknown.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_unknown.json5 new file mode 100644 index 0000000000000..962be24e1558f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_lambda_invoke_catch_unknown.json5 @@ -0,0 +1,33 @@ +{ + "Comment": "AWS_SDK_TASK_LAMBDA_CATCH_UNKNOWN", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Catch": [ + { + "ErrorEquals": [ + "Lambda.Unknown" + ], + "Next": "EndWithHandler" + } + ], + "Next": "EndWithFinal", + }, + "EndWithHandler": { + "Type": "Pass", + "ResultPath": "$.error", + "End": true + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_aws_sdk_dynamodb_put_item.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_aws_sdk_dynamodb_put_item.json5 new file mode 100644 index 0000000000000..7030c8366f151 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_aws_sdk_dynamodb_put_item.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "TASK_SERVICE_DYNAMODB_PUT_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_dynamodb_put_item.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_dynamodb_put_item.json5 new file mode 100644 index 0000000000000..34c8545de5387 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_dynamodb_put_item.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "TASK_SERVICE_DYNAMODB_PUT_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_all.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_all.json5 new file mode 100644 index 0000000000000..b353063a84a93 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_all.json5 @@ -0,0 +1,31 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_INVOKE_CATCH_ALL", + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Next": "ProcessResult", + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "HandleGeneralError" + } + ] + }, + "ProcessResult": { + "Type": "Pass", + "End": true + }, + "HandleGeneralError": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_all_output_path.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_all_output_path.json5 new file mode 100644 index 0000000000000..69c71220f8694 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_all_output_path.json5 @@ -0,0 +1,36 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_INVOKE_CATCH_ALL_OUTPUT_PATH", + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "ResultPath": "$.Payload", + "OutputPath": "$.Payload", + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "HandleGeneralError" + } + ], + "Next": "ProcessResult" + }, + "ProcessResult": { + "Type": "Pass", + "End": true + }, + "HandleGeneralError": { + "Type": "Pass", + "Parameters": { + "InputValue.$": "$" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_data_limit_exceeded.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_data_limit_exceeded.json5 new file mode 100644 index 0000000000000..8089aca8b3e84 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_data_limit_exceeded.json5 @@ -0,0 +1,42 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_INVOKE_CATCH_DATA_LIMIT_EXCEEDED", + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Next": "ProcessResult", + "Catch": [ + { + // Note: DataLimitExceeded is a terminal error that can't be caught by the States.ALL error type. + "ErrorEquals": [ + "States.DataLimitExceeded" + ], + "Next": "HandleDataLimitExceeded" + }, + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "HandleGeneralError" + } + ] + }, + "ProcessResult": { + "Type": "Pass", + "End": true + }, + "HandleDataLimitExceeded": { + "Type": "Pass", + "End": true + }, + "HandleGeneralError": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_relevant.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_relevant.json5 new file mode 100644 index 0000000000000..b5b757840c94f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_relevant.json5 @@ -0,0 +1,55 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_CATCH_RELEVANT", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Catch": [ + { + "ErrorEquals": [ + "Lambda.Unknown", + ], + "Next": "EndWithLambdaUnknownHandler" + }, + { + "ErrorEquals": [ + "States.TaskFailed", + ], + "Next": "EndWithStateTaskFailedHandler" + }, + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "EndWithAllHandler" + } + ], + "Next": "EndWithFinal", + }, + "EndWithLambdaUnknownHandler": { + "Type": "Pass", + "ResultPath": "$.lambda_unknown", + "End": true + }, + "EndWithStateTaskFailedHandler": { + "Type": "Pass", + "ResultPath": "$.task_failed_error", + "End": true + }, + "EndWithAllHandler": { + "Type": "Pass", + "ResultPath": "$.all_error", + "End": true + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_tbd.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_tbd.json5 new file mode 100644 index 0000000000000..02c08d1b053f1 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_tbd.json5 @@ -0,0 +1,28 @@ +{ + "StartAt": "InvokeLambda", + "States": { + "InvokeLambda": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Next": "ProcessResult", + "Catch": [ + { + "ErrorEquals": [], + "Next": "ErrorMatched" + } + ] + }, + "ProcessResult": { + "Type": "Pass", + "End": true + }, + "ErrorMatched": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_timeout.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_timeout.json5 new file mode 100644 index 0000000000000..acdd101f32f9c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_timeout.json5 @@ -0,0 +1,34 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_CATCH_TIMEOUT", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "TimeoutSeconds": 5, + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Catch": [ + { + "ErrorEquals": [ + "States.Timeout" + ], + "Next": "EndWithHandler" + } + ], + "Next": "EndWithFinal", + }, + "EndWithHandler": { + "Type": "Pass", + "ResultPath": "$.error", + "End": true + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_unknown.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_unknown.json5 new file mode 100644 index 0000000000000..6a96ab588a34a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_lambda_invoke_catch_unknown.json5 @@ -0,0 +1,33 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_CATCH_UNKNOWN", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Catch": [ + { + "ErrorEquals": [ + "Lambda.Unknown" + ], + "Next": "EndWithHandler" + } + ], + "Next": "EndWithFinal", + }, + "EndWithHandler": { + "Type": "Pass", + "ResultPath": "$.error", + "End": true + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_sqs_send_msg_catch.json5 b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_sqs_send_msg_catch.json5 new file mode 100644 index 0000000000000..062b934581b1f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/errorhandling/statemachines/task_service_sqs_send_msg_catch.json5 @@ -0,0 +1,44 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_CATCH_UNKNOWN", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody.$": "$.MessageBody" + }, + "Catch": [ + { + "ErrorEquals": [ + "SQS.SdkClientException" + ], + "Next": "EndWithClientHandler" + }, + { + "ErrorEquals": [ + "SQS.AmazonSQSException" + ], + "Next": "EndWithSQSException" + } + ], + "Next": "EndWithFinal", + }, + "EndWithClientHandler": { + "Type": "Pass", + "ResultPath": "$.client_error", + "End": true + }, + "EndWithSQSException" : { + "Type": "Pass", + "ResultPath": "$.aws_error", + "End": true + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/evaluatejsonata/__init__.py b/tests/aws/services/stepfunctions/templates/evaluatejsonata/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/evaluatejsonata/evaluate_jsonata_templates.py b/tests/aws/services/stepfunctions/templates/evaluatejsonata/evaluate_jsonata_templates.py new file mode 100644 index 0000000000000..badc419a74228 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/evaluatejsonata/evaluate_jsonata_templates.py @@ -0,0 +1,16 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class EvaluateJsonataTemplate(TemplateLoader): + JSONATA_NUMBER_EXPRESSION = "{% $variable := 1 %}" + JSONATA_ARRAY_ELEMENT_EXPRESSION_DOUBLE_QUOTES = [1, "{% $number('2') %}", 3] + JSONATA_ARRAY_ELEMENT_EXPRESSION = [1, '{% $number("2") %}', 3] + JSONATA_STATE_INPUT_EXPRESSION = "{% $states.input.input_value %}" + + BASE_MAP = os.path.join(_THIS_FOLDER, "statemachines/base_map.json5") + BASE_TASK = os.path.join(_THIS_FOLDER, "statemachines/base_task.json5") diff --git a/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_map.json5 b/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_map.json5 new file mode 100644 index 0000000000000..63a9db1dd5542 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_map.json5 @@ -0,0 +1,22 @@ +{ + "Comment": "BASE_MAP", + "QueryLanguage": "JSONata", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Map", + "Items": [1], + "MaxConcurrency": 1, + "ItemProcessor": { + "StartAt": "Process", + "States": { + "Process": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_task.json5 b/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_task.json5 new file mode 100644 index 0000000000000..dcb39df2db49c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_task.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "BASE_TASK", + "QueryLanguage": "JSONata", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "Payload": {}, + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%" + }, + "End": true + }, + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_wait.json5 b/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_wait.json5 new file mode 100644 index 0000000000000..63a9db1dd5542 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/evaluatejsonata/statemachines/base_wait.json5 @@ -0,0 +1,22 @@ +{ + "Comment": "BASE_MAP", + "QueryLanguage": "JSONata", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Map", + "Items": [1], + "MaxConcurrency": 1, + "ItemProcessor": { + "StartAt": "Process", + "States": { + "Process": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/__init__.py b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/intrinsic_functions_templates.py b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/intrinsic_functions_templates.py new file mode 100644 index 0000000000000..59fe7b74d3e2f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/intrinsic_functions_templates.py @@ -0,0 +1,100 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class IntrinsicFunctionTemplate(TemplateLoader): + FUNCTION_INPUT_KEY: Final[str] = "FunctionInput" + FUNCTION_OUTPUT_KEY: Final[str] = "FunctionResult" + + # Array. + ARRAY_0: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/array/array_0.json5") + ARRAY_2: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/array/array_2.json5") + UUID: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/unique_id_generation/uuid.json5") + ARRAY_PARTITION: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/array/array_partition.json5" + ) + ARRAY_PARTITION_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/array/array_partition_jsonata.json5" + ) + ARRAY_CONTAINS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/array/array_contains.json5" + ) + ARRAY_RANGE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/array/array_range.json5") + ARRAY_RANGE_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/array/array_range_jsonata.json5" + ) + ARRAY_GET_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/array/array_get_item.json5" + ) + ARRAY_LENGTH: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/array/array_length.json5") + ARRAY_UNIQUE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/array/array_unique.json5") + + # JSON Manipulation. + STRING_TO_JSON: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/json_manipulation/string_to_json.json5" + ) + JSON_TO_STRING: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/json_manipulation/json_to_string.json5" + ) + JSON_MERGE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/json_manipulation/json_merge.json5" + ) + JSON_MERGE_ESCAPED_ARGUMENT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/json_manipulation/json_merge_escaped_argument.json5" + ) + PARSE_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/json_manipulation/parse_jsonata.json5" + ) + + # String Operations. + STRING_SPLIT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/string_operations/string_split.json5" + ) + STRING_SPLIT_CONTEXT_OBJECT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/string_operations/string_split_context_object.json5" + ) + + # Encode and Decode. + BASE_64_ENCODE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/encode_decode/base64encode.json5" + ) + BASE_64_DECODE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/encode_decode/base64decode.json5" + ) + + # Hash Calculations. + HASH: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/hash_calculations/hash.json5") + + # Math Operations. + MATH_RANDOM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/math_operations/math_random.json5" + ) + MATH_RANDOM_SEEDED: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/math_operations/math_random_seeded.json5" + ) + MATH_RANDOM_SEEDED_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/math_operations/math_random_seeded_jsonata.json5" + ) + MATH_ADD: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/math_operations/math_add.json5" + ) + + # Generic. + FORMAT_1: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/generic/format_1.json5") + FORMAT_2: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/generic/format_2.json5") + FORMAT_CONTEXT_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/generic/format_context_path.json5" + ) + NESTED_CALLS_1: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/generic/nested_calls_1.json5" + ) + NESTED_CALLS_2: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/generic/nested_calls_2.json5" + ) + ESCAPE_SEQUENCE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/generic/escape_sequence.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_0.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_0.json5 new file mode 100644 index 0000000000000..5092cdae17ac7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_0.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_0", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Array()" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_2.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_2.json5 new file mode 100644 index 0000000000000..5c99b245ec1a0 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_2.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_2", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Array($.FunctionInput.fst, $.FunctionInput.snd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_contains.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_contains.json5 new file mode 100644 index 0000000000000..50642eed710c5 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_contains.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_CONTAINS", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayContains($.FunctionInput.fst, $.FunctionInput.snd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_get_item.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_get_item.json5 new file mode 100644 index 0000000000000..b4a4c9b0c5f45 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_get_item.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_GET_ITEM", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayGetItem($.FunctionInput.fst, $.FunctionInput.snd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_length.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_length.json5 new file mode 100644 index 0000000000000..e7830b2136ee9 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_length.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_LENGTH", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayLength($.FunctionInput)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_partition.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_partition.json5 new file mode 100644 index 0000000000000..862f6ddd23d3d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_partition.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_PARTITION", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayPartition($.FunctionInput.fst, $.FunctionInput.snd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_partition_jsonata.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_partition_jsonata.json5 new file mode 100644 index 0000000000000..8a154d2d049cd --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_partition_jsonata.json5 @@ -0,0 +1,11 @@ +{ + "StartAt": "State_0", + "States": { + "State_0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": "{% $partition($states.input.FunctionInput.fst, $states.input.FunctionInput.snd) %}", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_range.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_range.json5 new file mode 100644 index 0000000000000..4231fea97c76d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_range.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_RANGE", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayRange($.FunctionInput.fst, $.FunctionInput.snd, $.FunctionInput.trd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_range_jsonata.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_range_jsonata.json5 new file mode 100644 index 0000000000000..0bc010ede5809 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_range_jsonata.json5 @@ -0,0 +1,11 @@ +{ + "StartAt": "State_0", + "States": { + "State_0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": "{% $range($states.input.FunctionInput.fst, $states.input.FunctionInput.snd, $states.input.FunctionInput.trd) %}", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_unique.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_unique.json5 new file mode 100644 index 0000000000000..90f474a483259 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/array/array_unique.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ARRAY_UNIQUE", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayUnique($.FunctionInput)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/encode_decode/base64decode.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/encode_decode/base64decode.json5 new file mode 100644 index 0000000000000..565744683bbe8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/encode_decode/base64decode.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "BASE_64_DECODE", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Base64Decode($.FunctionInput)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/encode_decode/base64encode.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/encode_decode/base64encode.json5 new file mode 100644 index 0000000000000..a3a16f4089717 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/encode_decode/base64encode.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "BASE_64_ENCODE", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Base64Encode($.FunctionInput)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/escape_sequence.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/escape_sequence.json5 new file mode 100644 index 0000000000000..f06ddb07b07bf --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/escape_sequence.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "ESCAPE_SEQUENCE", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.StringSplit('Hello\nWorld', '\n')" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_1.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_1.json5 new file mode 100644 index 0000000000000..261f75dd9fc11 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_1.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "FORMAT_1", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Format('Formatting argument {}.', $.FunctionInput)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_2.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_2.json5 new file mode 100644 index 0000000000000..ddb1bd45c384e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_2.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "FORMAT_2", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Format('Formatting arguments fst={} and snd={}.', $.FunctionInput.fst, $.FunctionInput.snd)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_context_path.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_context_path.json5 new file mode 100644 index 0000000000000..efabbcbf92529 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/format_context_path.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "FORMAT_CONTEXT_PATH", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Format('Formatting arguments fst={} and snd={}.', $$.Execution.Name, $$.StateMachine)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/nested_calls_1.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/nested_calls_1.json5 new file mode 100644 index 0000000000000..8e1c621caa209 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/nested_calls_1.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "NESTED_CALLS_1", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayGetItem(States.StringSplit($$.StateMachine.Name, '-'), 0)" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/nested_calls_2.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/nested_calls_2.json5 new file mode 100644 index 0000000000000..5723d94f91dae --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/generic/nested_calls_2.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "NESTED_CALLS_2", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.ArrayGetItem(States.StringSplit($$.StateMachine.Name, '-'), States.MathAdd(States.ArrayLength(States.StringSplit($$.StateMachine.Name, '-')), -1))" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/hash_calculations/hash.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/hash_calculations/hash.json5 new file mode 100644 index 0000000000000..2215dc5ba231d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/hash_calculations/hash.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "HASH", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.Hash($.FunctionInput.fst, $.FunctionInput.snd)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_merge.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_merge.json5 new file mode 100644 index 0000000000000..469071370abe5 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_merge.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "JSON_MERGE", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.JsonMerge($.FunctionInput.fst, $.FunctionInput.snd, false)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_merge_escaped_argument.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_merge_escaped_argument.json5 new file mode 100644 index 0000000000000..1be7d8062c53c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_merge_escaped_argument.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "JSON_MERGE_ESCAPED_ARGUMENT", + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "ResultPath": "$.result_path", + "Parameters": { + "merged.$": "States.JsonMerge(States.StringToJson('{\"constant_in_literal\": \"false\"}'), $$.Execution.Input.input_field, false)" + }, + "OutputPath": "$.result_path.merged", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_to_string.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_to_string.json5 new file mode 100644 index 0000000000000..94c4b80949d26 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/json_to_string.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "JSON_TO_STRING", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.JsonToString($.FunctionInput)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/parse_jsonata.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/parse_jsonata.json5 new file mode 100644 index 0000000000000..60c183b19e076 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/parse_jsonata.json5 @@ -0,0 +1,11 @@ +{ + "StartAt": "State_0", + "States": { + "State_0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": "{% $parse($states.input.FunctionInput) %}", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/string_to_json.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/string_to_json.json5 new file mode 100644 index 0000000000000..d26e67b86646c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/json_manipulation/string_to_json.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "STRING_TO_JSON", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.StringToJson($.FunctionInput)", + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_add.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_add.json5 new file mode 100644 index 0000000000000..f1720fd2cb90e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_add.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "MATH_ADD", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.MathAdd($.FunctionInput.fst, $.FunctionInput.snd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random.json5 new file mode 100644 index 0000000000000..93c21b621f29e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "MATH_RANDOM", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.MathRandom($.FunctionInput.fst, $.FunctionInput.snd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random_seeded.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random_seeded.json5 new file mode 100644 index 0000000000000..f028cf88bc3dc --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random_seeded.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "MATH_RANDOM", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.MathRandom($.FunctionInput.fst, $.FunctionInput.snd, $.FunctionInput.trd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random_seeded_jsonata.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random_seeded_jsonata.json5 new file mode 100644 index 0000000000000..72c0ab6fa46e3 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/math_operations/math_random_seeded_jsonata.json5 @@ -0,0 +1,13 @@ +{ + "StartAt": "State_0", + "States": { + "State_0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": { + "FunctionResult": "{% $randomSeeded($states.input.FunctionInput.fst) %}" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/string_operations/string_split.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/string_operations/string_split.json5 new file mode 100644 index 0000000000000..973fd0e2c4fbe --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/string_operations/string_split.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "STRING_SPLIT", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.StringSplit($.FunctionInput.fst, $.FunctionInput.snd)" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/string_operations/string_split_context_object.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/string_operations/string_split_context_object.json5 new file mode 100644 index 0000000000000..2e444ab913b12 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/string_operations/string_split_context_object.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "STRING_SPLIT_CONTEXT_OBJECT", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "End": true, + "Parameters": { + "FunctionResult.$": "States.StringSplit($$.Execution.Input.FunctionInput, ',')" + } + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/unique_id_generation/uuid.json5 b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/unique_id_generation/uuid.json5 new file mode 100644 index 0000000000000..e7246acd8a6e7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/intrinsicfunctions/statemachines/unique_id_generation/uuid.json5 @@ -0,0 +1,13 @@ +{ + "Comment": "UUID", + "StartAt": "State_0", + "States": { + "State_0": { + "Type": "Pass", + "Parameters": { + "FunctionResult.$": "States.UUID()" + }, + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/mocked/__init__.py b/tests/aws/services/stepfunctions/templates/mocked/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/mocked/mocked_templates.py b/tests/aws/services/stepfunctions/templates/mocked/mocked_templates.py new file mode 100644 index 0000000000000..6603956558911 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/mocked/mocked_templates.py @@ -0,0 +1,12 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class MockedTemplates(TemplateLoader): + LAMBDA_SQS_INTEGRATION: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_sqs_integration.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/mocked/statemachines/__init__.py b/tests/aws/services/stepfunctions/templates/mocked/statemachines/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/mocked/statemachines/lambda_sqs_integration.json5 b/tests/aws/services/stepfunctions/templates/mocked/statemachines/lambda_sqs_integration.json5 new file mode 100644 index 0000000000000..466f000e8dafb --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/mocked/statemachines/lambda_sqs_integration.json5 @@ -0,0 +1,37 @@ +// Source: https://docs.aws.amazon.com/step-functions/latest/dg/sfn-local-test-sm-exec.html, April 2025 +{ + "Comment": "This state machine is called: LambdaSQSIntegration", + "StartAt": "LambdaState", + "States": { + "LambdaState": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "Payload.$": "$", + "FunctionName": "HelloWorldFunction" + }, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + // The aws demo calls for "MaxAttempts: 3" and 4 retry outcomes in "RetryPath" test case. + // However, through snapshot testing, we know that this is 1 too many retry outcomes for + // this definition. Hence, in an effort to keep parity with AWS Step Functions, the + // attempts numbers was adjusted to 4. + "MaxAttempts": 4 + } + ], + "Next": "SQSState" + }, + "SQSState": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage", + "Parameters": { + "QueueUrl": "https://sqs.us-east-1.amazonaws.com/account-id/myQueue", + "MessageBody.$": "$" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/__init__.py b/tests/aws/services/stepfunctions/templates/outputdecl/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/output_templates.py b/tests/aws/services/stepfunctions/templates/outputdecl/output_templates.py new file mode 100644 index 0000000000000..f248612a9117a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/output_templates.py @@ -0,0 +1,19 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class OutputTemplates(TemplateLoader): + BASE_EMPTY = os.path.join(_THIS_FOLDER, "statemachines/base_empty.json5") + BASE_LITERALS = os.path.join(_THIS_FOLDER, "statemachines/base_literals.json5") + BASE_EXPR = os.path.join(_THIS_FOLDER, "statemachines/base_expr.json5") + BASE_DIRECT_EXPR = os.path.join(_THIS_FOLDER, "statemachines/base_direct_expr.json5") + BASE_LAMBDA = os.path.join(_THIS_FOLDER, "statemachines/base_lambda.json5") + BASE_TASK_LAMBDA = os.path.join(_THIS_FOLDER, "statemachines/base_task_lambda.json5") + BASE_OUTPUT_ANY = os.path.join(_THIS_FOLDER, "statemachines/base_output_any.json5") + CHOICE_CONDITION_JSONATA = os.path.join( + _THIS_FOLDER, "statemachines/choice_condition_jsonata.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_direct_expr.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_direct_expr.json5 new file mode 100644 index 0000000000000..97f82a867b068 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_direct_expr.json5 @@ -0,0 +1,18 @@ +{ + "StartAt": "Init", + "States": { + "Init": { + "Type": "Pass", + "Assign": { + "var_constant_1": 1 + }, + "Next": "State0" + }, + "State0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": "{% $sum($states.input.input_values) + $var_constant_1 %}", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_empty.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_empty.json5 new file mode 100644 index 0000000000000..f76c72fcdb8b4 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_empty.json5 @@ -0,0 +1,11 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": {}, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_expr.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_expr.json5 new file mode 100644 index 0000000000000..d675b285037bf --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_expr.json5 @@ -0,0 +1,24 @@ +{ + "StartAt": "Init", + "States": { + "Init": { + "Type": "Pass", + "Assign": { + "var_input_value.$": "$.input_value", + "var_constant_1": 1 + }, + "Next": "State0" + }, + "State0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": { + "ja_states_context": "{% $states.context %}", + "ja_states_input": "{% $states.input %}", + "ja_var_access": "{% $var_input_value %}", + "ja_expr": "{% $sum($states.input.input_values) + $var_constant_1 %}" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_lambda.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_lambda.json5 new file mode 100644 index 0000000000000..35682744d3d86 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_lambda.json5 @@ -0,0 +1,31 @@ +{ + "StartAt": "Init", + "States": { + "Init": { + "Type": "Pass", + "Assign": { + "var_input_value.$": "$.input_value", + "var_constant_1": 1 + }, + "Next": "State0" + }, + "State0": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "__tbd__", + "Arguments": { + "ja_states_input": "{% $states.input %}", + "ja_var_access": "{% $var_input_value %}", + "ja_expr": "{% $sum($states.input.input_values) + $var_constant_1 %}" + }, + "Output": { + "ja_var_access": "{% $var_input_value %}", + "ja_expr": "{% $sum($states.input.input_values) + $var_constant_1 %}", + "ja_states_input": "{% $states.input %}", + "ja_states_result": "{% $states.result %}", + "ja_states_result_access": "{% $states.result.ja_expr %}" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_literals.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_literals.json5 new file mode 100644 index 0000000000000..1ea70ef6135d5 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_literals.json5 @@ -0,0 +1,40 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_jp_input": "$", + "constant_jp_input.$": "$", + "constant_jp_input_path": "$.input_value", + "constant_jp_context": "$$", + "constant_if": "States.Format('Format:{}', 101)", + "constant_lst_empty": [], + "constant_lst": [null, 0, 0.1, true, [], {"constant": 0}, " {% states.input %} ", "$states.input", "$no.such.var.path"], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [null, 0, 0.1, true, [], {"constant": 0}, " {% states.input %} ", "$states.input", "$no.such.var.path"], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { "constant": 0 } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_output_any.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_output_any.json5 new file mode 100644 index 0000000000000..4d8cb9d3d5d77 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_output_any.json5 @@ -0,0 +1,11 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": "_tbd_", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_task_lambda.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_task_lambda.json5 new file mode 100644 index 0000000000000..4254b6a9ebb9b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/base_task_lambda.json5 @@ -0,0 +1,19 @@ +{ + "StartAt": "State0", + "States": { + "State0": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "{% $states.input.FunctionName %}", + "Payload": "{% $states.input.Payload %}", + }, + "Output": { + "ja_states_input": "{% $states.input %}", + "ja_states_result": "{% $states.result %}", + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/choice_condition_jsonata.json5 b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/choice_condition_jsonata.json5 new file mode 100644 index 0000000000000..fc04f5ff4b5d9 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/outputdecl/statemachines/choice_condition_jsonata.json5 @@ -0,0 +1,26 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "Choices": [ + { + "Condition": "{% $states.input.condition %}", + "Next": "ConditionTrue", + "Output": "Condition Output block" + } + ], + "Default": "DefaultState", + "Output": "Default Output block" + }, + "ConditionTrue": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "Condition is false" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/__init__.py b/tests/aws/services/stepfunctions/templates/querylanguage/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/query_language_templates.py b/tests/aws/services/stepfunctions/templates/querylanguage/query_language_templates.py new file mode 100644 index 0000000000000..1e4b2ee1738ea --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/query_language_templates.py @@ -0,0 +1,53 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class QueryLanguageTemplate(TemplateLoader): + LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER = "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%" + + BASE_PASS_JSONATA = os.path.join(_THIS_FOLDER, "statemachines/base_pass_jsonata.json5") + BASE_PASS_JSONATA_OVERRIDE = os.path.join( + _THIS_FOLDER, "statemachines/base_pass_jsonata_override.json5" + ) + BASE_PASS_JSONATA_OVERRIDE_DEFAULT = os.path.join( + _THIS_FOLDER, "statemachines/base_pass_jsonata_override_default.json5" + ) + BASE_PASS_JSONPATH = os.path.join(_THIS_FOLDER, "statemachines/base_pass_jsonpath.json5") + + JSONATA_ASSIGN_JSONPATH_REF = os.path.join( + _THIS_FOLDER, "statemachines/jsonata_assign_jsonpath_reference.json5" + ) + JSONPATH_ASSIGN_JSONATA_REF = os.path.join( + _THIS_FOLDER, "statemachines/jsonpath_assign_jsonata_reference.json5" + ) + + JSONPATH_OUTPUT_TO_JSONATA = os.path.join( + _THIS_FOLDER, "statemachines/jsonpath_output_to_jsonata.json5" + ) + JSONATA_OUTPUT_TO_JSONPATH = os.path.join( + _THIS_FOLDER, "statemachines/jsonata_output_to_jsonpath.json5" + ) + + JSONPATH_TO_JSONATA_DATAFLOW = os.path.join( + _THIS_FOLDER, "statemachines/jsonpath_to_jsonata_dataflow.json5" + ) + + TASK_LAMBDA_SDK_RESOURCE_JSONATA_TO_JSONPATH = os.path.join( + _THIS_FOLDER, "statemachines/task_lambda_sdk_resource_from_jsonata_to_jsonpath.json5" + ) + + TASK_LAMBDA_LEGACY_RESOURCE_JSONATA_TO_JSONPATH = os.path.join( + _THIS_FOLDER, "statemachines/task_lambda_legacy_resource_from_jsonata_to_jsonpath.json5" + ) + + TASK_LAMBDA_SDK_RESOURCE_JSONPATH_TO_JSONATA = os.path.join( + _THIS_FOLDER, "statemachines/task_lambda_sdk_resource_from_jsonpath_to_jsonata.json5" + ) + + TASK_LAMBDA_LEGACY_RESOURCE_JSONPATH_TO_JSONATA = os.path.join( + _THIS_FOLDER, "statemachines/task_lambda_legacy_resource_from_jsonpath_to_jsonata.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata.json5 new file mode 100644 index 0000000000000..f424855a190b4 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata.json5 @@ -0,0 +1,10 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "End": true + }, + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata_override.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata_override.json5 new file mode 100644 index 0000000000000..7203601ef8b0c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata_override.json5 @@ -0,0 +1,11 @@ +{ + "QueryLanguage": "JSONPath", + "StartAt": "StartState", + "States": { + "StartState": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "End": true + }, + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata_override_default.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata_override_default.json5 new file mode 100644 index 0000000000000..f1cc54010bc8b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonata_override_default.json5 @@ -0,0 +1,10 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "End": true + }, + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonpath.json5 new file mode 100644 index 0000000000000..44283ad9397ed --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/base_pass_jsonpath.json5 @@ -0,0 +1,10 @@ +{ + "QueryLanguage": "JSONPath", + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "End": true + }, + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonata_assign_jsonpath_reference.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonata_assign_jsonpath_reference.json5 new file mode 100644 index 0000000000000..ed7388583ac37 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonata_assign_jsonpath_reference.json5 @@ -0,0 +1,23 @@ +{ + "Comment": "JSONATA_ASSIGN_JSONPATH_REF", + "StartAt": "JSONataState", + "States": { + "JSONataState": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "Assign": { + "theAnswerVar": 42 + }, + "Next": "JSONPathState" + }, + "JSONPathState": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "Assign": { + "theAnswerVar": 18, + "oldAnswerVar.$": "$theAnswerVar" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonata_output_to_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonata_output_to_jsonpath.json5 new file mode 100644 index 0000000000000..86eaa302c65ba --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonata_output_to_jsonpath.json5 @@ -0,0 +1,20 @@ +{ + "Comment": "JSONATA_OUTPUT_TO_JSONPATH", + "StartAt": "JSONataState", + "States": { + "JSONataState": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": { + "foo": "foobar", + "bar": "{% $states.input %}" + }, + "Next": "JSONPathState" + }, + "JSONPathState": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_assign_jsonata_reference.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_assign_jsonata_reference.json5 new file mode 100644 index 0000000000000..8455ef3b8e689 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_assign_jsonata_reference.json5 @@ -0,0 +1,23 @@ +{ + "Comment": "JSONPATH_ASSIGN_JSONATA_REF", + "StartAt": "JSONPathState", + "States": { + "JSONPathState": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "Assign": { + "theAnswer": 42 + }, + "Next": "JSONataState" + }, + "JSONataState": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Assign": { + "theAnswer": 18, + "oldAnswer": "{% $theAnswer %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_output_to_jsonata.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_output_to_jsonata.json5 new file mode 100644 index 0000000000000..2fb0308e0be74 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_output_to_jsonata.json5 @@ -0,0 +1,20 @@ +{ + "Comment": "JSONPATH_OUTPUT_TO_JSONATA", + "StartAt": "JSONataState", + "States": { + "JSONataState": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "Parameters": { + "foo": "foobar", + "bar.$": "$" + }, + "Next": "JSONPathState" + }, + "JSONPathState": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_to_jsonata_dataflow.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_to_jsonata_dataflow.json5 new file mode 100644 index 0000000000000..608d3ac7481b8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/jsonpath_to_jsonata_dataflow.json5 @@ -0,0 +1,40 @@ +{ + "Comment": "JSONPATH_TO_JSONATA_DATAFLOW", + "StartAt": "StateJsonPath", + "States": { + "StateJsonPath": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "Result": { + "riddle": "What is the answer to life, the universe, and everything?" + }, + "Assign": { + "answer": 42, + "inputData.$": "$" + }, + "OutputPath": "$", + "ResultPath": "$.enigma.mystery", + "Next": "StateJsonata" + }, + "StateJsonata": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "{% $states.input.functionName %}", + "Payload": { + "theQuestion": "{% $states.input.enigma.mystery.riddle %}", + "theAnswer": "{% $answer %}" + } + }, + "Assign": { + "answer": "", + "message": "{% $states.result %}" + }, + "Output": { + "result": "{% $states.result.Payload %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_legacy_resource_from_jsonata_to_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_legacy_resource_from_jsonata_to_jsonpath.json5 new file mode 100644 index 0000000000000..5f05faf298541 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_legacy_resource_from_jsonata_to_jsonpath.json5 @@ -0,0 +1,27 @@ +{ + "Comment": "TASK_LAMBDA_LEGACY_RESOURCE_JSONATA_TO_JSONPATH", + "StartAt": "JsonataState", + "States": { + "JsonataState": { + "Comment": "JSONata does not allow the Resource field to be dynamically set", + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Arguments": { + "Payload": {"foo": "foo-1"} + }, + "Assign": { + "resultsVar": "{% $states.result %}" + }, + "Output": { + "results": "{% $states.result %}" + }, + "Next": "JsonPathState" + }, + "JsonPathState": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_legacy_resource_from_jsonpath_to_jsonata.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_legacy_resource_from_jsonpath_to_jsonata.json5 new file mode 100644 index 0000000000000..62145e5f27917 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_legacy_resource_from_jsonpath_to_jsonata.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "TASK_LAMBDA_LEGACY_RESOURCE_JSONPATH_TO_JSONATA", + "StartAt": "JsonPathState", + "States": { + "JsonPathState": { + "QueryLanguage": "JSONPath", + "Type": "Task", + "Resource": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Parameters": { + "Payload": {"foo": "foo-1"} + }, + "Assign": { + "resultsVar.$": "$" + }, + "OutputPath": "$", + "Next": "JsonataState" + }, + "JsonataState": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_sdk_resource_from_jsonata_to_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_sdk_resource_from_jsonata_to_jsonpath.json5 new file mode 100644 index 0000000000000..6cc03edfaa30d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_sdk_resource_from_jsonata_to_jsonpath.json5 @@ -0,0 +1,27 @@ +{ + "Comment": "TASK_LAMBDA_SDK_RESOURCE_JSONATA_TO_JSONPATH", + "StartAt": "JsonataState", + "States": { + "JsonataState": { + "QueryLanguage": "JSONata", + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "Payload": {"foo": "foo-1"}, + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%" + }, + "Assign": { + "resultsVar": "{% $states.result %}" + }, + "Output": { + "results": "{% $states.result %}" + }, + "Next": "JsonPathState" + }, + "JsonPathState": { + "QueryLanguage": "JSONPath", + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_sdk_resource_from_jsonpath_to_jsonata.json5 b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_sdk_resource_from_jsonpath_to_jsonata.json5 new file mode 100644 index 0000000000000..6724dc7a6ffb8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/querylanguage/statemachines/task_lambda_sdk_resource_from_jsonpath_to_jsonata.json5 @@ -0,0 +1,25 @@ +{ + "Comment": "TASK_LAMBDA_SDK_RESOURCE_JSONPATH_TO_JSONATA", + "StartAt": "JsonPathState", + "States": { + "JsonPathState": { + "QueryLanguage": "JSONPath", + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "Payload": {"foo": "foo-1"}, + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%" + }, + "Assign": { + "resultsVar.$": "$" + }, + "OutputPath": "$", + "Next": "JsonataState" + }, + "JsonataState": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/__init__.py b/tests/aws/services/stepfunctions/templates/scenarios/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/scenarios/scenarios_templates.py b/tests/aws/services/stepfunctions/templates/scenarios/scenarios_templates.py new file mode 100644 index 0000000000000..29a4a77473035 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/scenarios_templates.py @@ -0,0 +1,310 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ScenariosTemplate(TemplateLoader): + CATCH_EMPTY: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/catch_empty.json5") + CATCH_STATES_RUNTIME: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/catch_states_runtime.json5" + ) + PARALLEL_STATE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/parallel_state.json5") + PARALLEL_STATE_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/parallel_state_parameters.json5" + ) + MAX_CONCURRENCY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/max_concurrency_path.json5" + ) + PARALLEL_STATE_FAIL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/parallel_state_fail.json5" + ) + PARALLEL_NESTED_NESTED: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/parallel_state_nested.json5" + ) + PARALLEL_STATE_CATCH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/parallel_state_catch.json5" + ) + PARALLEL_STATE_RETRY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/parallel_state_retry.json5" + ) + PARALLEL_STATE_ORDER: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/parallel_state_order.json5" + ) + PARALLEL_STATE_SERVICE_LAMBDA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/parallel_state_service_lambda.json5" + ) + MAP_STATE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/map_state.json5") + MAP_STATE_LEGACY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy.json5" + ) + MAP_STATE_LEGACY_CONFIG_INLINE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_config_inline.json5" + ) + MAP_STATE_LEGACY_CONFIG_DISTRIBUTED: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_config_distributed.json5" + ) + MAP_STATE_LEGACY_CONFIG_DISTRIBUTED_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_config_distributed_parameters.json5" + ) + MAP_STATE_LEGACY_CONFIG_DISTRIBUTED_ITEM_SELECTOR: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_config_distributed_item_selector.json5" + ) + MAP_STATE_LEGACY_CONFIG_INLINE_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_config_inline_parameters.json5" + ) + MAP_STATE_LEGACY_CONFIG_INLINE_ITEM_SELECTOR: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_config_inline_item_selector.json5" + ) + MAP_STATE_CONFIG_DISTRIBUTED_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_distributed_parameters.json5" + ) + MAP_STATE_CONFIG_DISTRIBUTED_ITEM_SELECTOR: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_distributed_item_selector.json5" + ) + MAP_STATE_CONFIG_DISTRIBUTED_ITEMS_PATH_FROM_PREVIOUS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_distributed_items_path_from_previous.json5" + ) + MAP_STATE_CONFIG_DISTRIBUTED_ITEM_SELECTOR_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_distributed_item_selector_parameters.json5" + ) + MAP_STATE_LEGACY_REENTRANT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_reentrant.json5" + ) + MAP_STATE_CONFIG_DISTRIBUTED_REENTRANT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_distributed_reentrant.json5" + ) + MAP_STATE_CONFIG_DISTRIBUTED_REENTRANT_LAMBDA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_distributed_reentrant_lambda.json5" + ) + MAP_STATE_CONFIG_INLINE_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_inline_parameters.json5" + ) + MAP_STATE_CONFIG_INLINE_ITEM_SELECTOR: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_config_inline_item_selector.json5" + ) + MAP_STATE_NESTED: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_nested.json5" + ) + MAP_STATE_NESTED_CONFIG_DISTRIBUTED: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_nested_config_distributed.json5" + ) + MAP_STATE_NESTED_CONFIG_DISTRIBUTED_NO_MAX_CONCURRENCY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_nested_config_distributed_no_max_concurrency.json5" + ) + MAP_STATE_NO_PROCESSOR_CONFIG: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_no_processor_config.json5" + ) + MAP_ITEM_READER_BASE_LIST_OBJECTS_V2: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_list_objects_v2.json5" + ) + MAP_ITEM_READER_BASE_CSV_HEADERS_FIRST_LINE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_csv_headers_first_line.json5" + ) + MAP_ITEM_READER_BASE_CSV_MAX_ITEMS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_csv_max_items.json5" + ) + MAP_ITEM_READER_BASE_CSV_MAX_ITEMS_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_csv_max_items_path.json5" + ) + MAP_ITEM_READER_BASE_CSV_HEADERS_DECL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_csv_headers_decl.json5" + ) + MAP_ITEM_READER_BASE_JSON: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_json.json5" + ) + MAP_ITEM_READER_BASE_JSON_MAX_ITEMS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_json_max_items.json5" + ) + MAP_ITEM_READER_BASE_JSON_MAX_ITEMS_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_json_max_items_jsonata.json5" + ) + MAP_ITEM_READER_BASE_JSON_WITH_ITEMS_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_reader_base_json_with_items_path.json5" + ) + MAP_ITEM_BATCHER_BASE_JSON_MAX_PER_BATCH_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_item_batcher_base_max_per_batch_jsonata.json5" + ) + MAP_STATE_ITEM_SELECTOR: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_item_selector.json5" + ) + MAP_STATE_ITEM_SELECTOR_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_item_selector_jsonata.json5" + ) + MAP_STATE_ITEM_SELECTOR_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_item_selector_parameters.json5" + ) + MAP_STATE_ITEMS: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/map_state_items.json5") + MAP_STATE_ITEMS_VARIABLE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_items_variable.json5" + ) + MAP_STATE_ITEMS_LITERAL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_items_literal.json5" + ) + MAP_STATE_PARAMETERS_LEGACY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_parameters_legacy.json5" + ) + MAP_STATE_ITEM_SELECTOR_SINGLETON: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_item_selector_singletons.json5" + ) + MAP_STATE_PARAMETERS_SINGLETON_LEGACY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_parameters_singletons_legacy.json5" + ) + MAP_STATE_CATCH: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/map_state_catch.json5") + MAP_STATE_CATCH_EMPTY_FAIL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_catch_empty_fail.json5" + ) + MAP_STATE_CATCH_LEGACY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_catch_legacy.json5" + ) + MAP_STATE_LEGACY_REENTRANT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_legacy_reentrant.json5" + ) + MAP_STATE_RETRY: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/map_state_retry.json5") + MAP_STATE_RETRY_LEGACY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_retry_legacy.json5" + ) + MAP_STATE_RETRY_MULTIPLE_RETRIERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_retry_multiple_retriers.json5" + ) + MAP_STATE_BREAK_CONDITION: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_break_condition.json5" + ) + MAP_STATE_BREAK_CONDITION_LEGACY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_break_condition_legacy.json5" + ) + MAP_STATE_TOLERATED_FAILURE_COUNT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_tolerated_failure_count.json5" + ) + MAP_STATE_TOLERATED_FAILURE_COUNT_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_tolerated_failure_count_path.json5" + ) + MAP_STATE_TOLERATED_FAILURE_PERCENTAGE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_tolerated_failure_percentage.json5" + ) + MAP_STATE_TOLERATED_FAILURE_PERCENTAGE_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_tolerated_failure_percentage_path.json5" + ) + MAP_STATE_LABEL: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/map_state_label.json5") + MAP_STATE_LABEL_EMPTY_FAIL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_label_empty_fail.json5" + ) + MAP_STATE_LABEL_INVALID_CHAR_FAIL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_label_invalid_char_fail.json5" + ) + MAP_STATE_LABEL_TOO_LONG_FAIL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_label_too_long_fail.json5" + ) + MAP_STATE_RESULT_WRITER: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/map_state_result_writer.json5" + ) + CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_unsorted_choice_parameters.json5" + ) + CHOICE_CONDITION_CONSTANT_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_condition_constant_jsonata.json5" + ) + CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_unsorted_choice_parameters_jsonata.json5" + ) + CHOICE_STATE_SINGLETON_COMPOSITE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_singleton_composite.json5" + ) + CHOICE_STATE_SINGLETON_COMPOSITE_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_singleton_composite_jsonata.json5" + ) + CHOICE_STATE_SINGLETON_COMPOSITE_LITERAL_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_singleton_composite_literal_string_jsonata.json5" + ) + CHOICE_STATE_AWS_SCENARIO: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_aws_scenario.json5" + ) + CHOICE_STATE_AWS_SCENARIO_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/choice_state_aws_scenario_jsonata.json5" + ) + LAMBDA_EMPTY_RETRY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_empty_retry.json5" + ) + LAMBDA_INVOKE_WITH_RETRY_BASE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_invoke_with_retry_base.json5" + ) + LAMBDA_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_invoke_with_retry_extended_input.json5" + ) + LAMBDA_SERVICE_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_service_invoke_with_retry_extended_input.json5" + ) + RETRY_INTERVAL_FEATURES: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/retry_interval_features.json5" + ) + RETRY_INTERVAL_FEATURES_JITTER_NONE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/retry_interval_features_jitter_none.json5" + ) + RETRY_INTERVAL_FEATURES_MAX_ATTEMPTS_ZERO: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/retry_interval_features_max_attempts_zero.json5" + ) + WAIT_TIMESTAMP: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/wait_timestamp.json5") + WAIT_TIMESTAMP_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/wait_timestamp_path.json5" + ) + WAIT_TIMESTAMP_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/wait_timestamp_jsonata.json5" + ) + WAIT_SECONDS_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/wait_seconds_jsonata.json5" + ) + DIRECT_ACCESS_CONTEXT_OBJECT_CHILD_FIELD: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/direct_access_context_object_child_field.json5" + ) + + RAISE_FAILURE_ERROR_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/fail_error_jsonata.json5" + ) + RAISE_FAILURE_CAUSE_JSONATA: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/fail_cause_jsonata.json5" + ) + + INVALID_JSONPATH_IN_ERRORPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_errorpath.json5" + ) + INVALID_JSONPATH_IN_CAUSEPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_causepath.json5" + ) + INVALID_JSONPATH_IN_STRING_EXPR_JSONPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_string_expr_jsonpath.json5" + ) + INVALID_JSONPATH_IN_STRING_EXPR_CONTEXTPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_string_expr_contextpath.json5" + ) + INVALID_JSONPATH_IN_HEARTBEATSECONDSPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_heartbeatsecondspath.json5" + ) + INVALID_JSONPATH_IN_TIMEOUTSECONDSPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_timeoutsecondspath.json5" + ) + INVALID_JSONPATH_IN_INPUTPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_inputpath.json5" + ) + INVALID_JSONPATH_IN_OUTPUTPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_jsonpath_in_outputpath.json5" + ) + ESCAPE_SEQUENCES_STRING_LITERALS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/escape_sequences_string_literals.json5" + ) + ESCAPE_SEQUENCES_JSONPATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/escape_sequences_jsonpath.json5" + ) + ESCAPE_SEQUENCES_JSONATA_COMPARISON_OUTPUT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/escape_sequences_jsonata_comparison_output.json5" + ) + ESCAPE_SEQUENCES_JSONATA_COMPARISON_ASSIGN: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/escape_sequences_jsonata_comparison_assign.json5" + ) + ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/escape_sequences_illegal_intrinsic_function.json5" + ) + ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION_2: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/escape_sequences_illegal_intrinsic_function_2.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/catch_empty.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/catch_empty.json5 new file mode 100644 index 0000000000000..ea2340a6376aa --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/catch_empty.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "CATCH_EMPTY", + "StartAt": "StartTask", + "States": { + "StartTask": { + "Type": "Task", + "Resource": "_tbd_", + "Catch": [], + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/catch_states_runtime.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/catch_states_runtime.json5 new file mode 100644 index 0000000000000..b3d7607a0c44f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/catch_states_runtime.json5 @@ -0,0 +1,28 @@ +{ + "Comment": "CATCH_STATE_RUNTIME", + "StartAt": "RaiseRuntime", + "States": { + "RaiseRuntime": { + "Type": "Task", + "Resource": "_tbd_", + "OutputPath": "$.no.such.result.path", + "Catch": [ + { + "ErrorEquals": [ + "States.Runtime", + ], + "Next": "CaughtRuntime" + } + ], + "Next": "Final", + }, + "CaughtRuntime": { + "Type": "Pass", + "End": true + }, + "Final": { + "Type": "Pass", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_aws_scenario.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_aws_scenario.json5 new file mode 100644 index 0000000000000..75b4a88cd42c2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_aws_scenario.json5 @@ -0,0 +1,52 @@ +{ + "StartAt": "ChoiceStateX", + "States": { + "ChoiceStateX": { + "Type": "Choice", + "Choices": [ + { + "Not": { + "Variable": "$.type", + "StringEquals": "Private" + }, + "Next": "Public" + }, + { + "Variable": "$.value", + "NumericEquals": 0, + "Next": "ValueIsZero" + }, + { + "And": [ + { + "Variable": "$.value", + "NumericGreaterThanEquals": 20 + }, + { + "Variable": "$.value", + "NumericLessThan": 30 + } + ], + "Next": "ValueInTwenties" + } + ], + "Default": "DefaultState" + }, + "Public": { + "Type": "Pass", + "End": true + }, + "ValueIsZero": { + "Type": "Pass", + "End": true + }, + "ValueInTwenties": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "No Matches!" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_aws_scenario_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_aws_scenario_jsonata.json5 new file mode 100644 index 0000000000000..82de0ccab7f80 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_aws_scenario_jsonata.json5 @@ -0,0 +1,40 @@ +{ + "StartAt": "ChoiceStateX", + "States": { + "ChoiceStateX": { + "Type": "Choice", + "QueryLanguage": "JSONata", + "Choices": [ + { + "Condition": "{% $not($states.input.type = 'Private') %}", + "Next": "Public" + }, + { + "Condition": "{% $states.input.value = 0 %}", + "Next": "ValueIsZero" + }, + { + "Condition": "{% $states.input.value >= 20 and $states.input.value < 30 %}", + "Next": "ValueInTwenties" + } + ], + "Default": "DefaultState" + }, + "Public": { + "Type": "Pass", + "End": true + }, + "ValueIsZero": { + "Type": "Pass", + "End": true + }, + "ValueInTwenties": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "No Matches!" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_condition_constant_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_condition_constant_jsonata.json5 new file mode 100644 index 0000000000000..1ed1c66b6a2e9 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_condition_constant_jsonata.json5 @@ -0,0 +1,24 @@ +{ + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "QueryLanguage": "JSONata", + "Choices": [ + { + "Condition": true, + "Next": "ConditionTrue" + } + ], + "Default": "DefaultState" + }, + "ConditionTrue": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "Condition is false" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite.json5 new file mode 100644 index 0000000000000..e3549c1fd9934 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite.json5 @@ -0,0 +1,28 @@ +{ + "StartAt": "ChoiceStateX", + "States": { + "ChoiceStateX": { + "Type": "Choice", + "Choices": [ + { + "And": [ + { + "Variable": "$.type", + "StringEquals": "Public" + } + ], + "Next": "Public" + } + ], + "Default": "DefaultState" + }, + "Public": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "No Matches!" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite_jsonata.json5 new file mode 100644 index 0000000000000..54ef446259e0b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite_jsonata.json5 @@ -0,0 +1,24 @@ +{ + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "QueryLanguage": "JSONata", + "Choices": [ + { + "Condition": "{% $states.input.type = 'Public' %}", + "Next": "Public" + } + ], + "Default": "DefaultState" + }, + "Public": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "No Matches!" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite_literal_string_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite_literal_string_jsonata.json5 new file mode 100644 index 0000000000000..b52da7d9877b5 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_singleton_composite_literal_string_jsonata.json5 @@ -0,0 +1,29 @@ +{ + "StartAt": "Pass", + "QueryLanguage": "JSONata", + "States": { + "Pass": { + "Type": "Pass", + "Output": { + "str_value": "string literal", + }, + "Next": "Choice" + }, + "Choice": { + "Type": "Choice", + "Choices": [ + { + "Next": "Success", + "Condition": "{% $states.input.str_value = \"string literal\" %}" + } + ], + "Default": "Fail" + }, + "Success": { + "Type": "Succeed" + }, + "Fail": { + "Type": "Fail" + }, + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_unsorted_choice_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_unsorted_choice_parameters.json5 new file mode 100644 index 0000000000000..613145db07d3b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_unsorted_choice_parameters.json5 @@ -0,0 +1,28 @@ +{ + "StartAt": "CheckResult", + "States": { + "CheckResult": { + "Choices": [ + { + "BooleanEquals": true, + "Next": "FinishTrue", + "Variable": "$.result.done" + }, + { + "BooleanEquals": false, + "Next": "FinishFalse", + "Variable": "$.result.done" + } + ], + "Type": "Choice" + }, + "FinishTrue": { + "End": true, + "Type": "Pass" + }, + "FinishFalse": { + "End": true, + "Type": "Pass" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_unsorted_choice_parameters_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_unsorted_choice_parameters_jsonata.json5 new file mode 100644 index 0000000000000..cdc93994cec7f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/choice_state_unsorted_choice_parameters_jsonata.json5 @@ -0,0 +1,27 @@ +{ + "StartAt": "CheckResult", + "States": { + "CheckResult": { + "Type": "Choice", + "QueryLanguage": "JSONata", + "Choices": [ + { + "Condition": "{% $states.input.result.done %}", + "Next": "FinishTrue", + }, + { + "Condition": "{% $not($states.input.result.done) %}", + "Next": "FinishFalse", + } + ], + }, + "FinishTrue": { + "End": true, + "Type": "Pass" + }, + "FinishFalse": { + "End": true, + "Type": "Pass" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_illegal_intrinsic_function.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_illegal_intrinsic_function.json5 new file mode 100644 index 0000000000000..cf365ce4ed504 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_illegal_intrinsic_function.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "IntrinsicEscape", + "States": { + "IntrinsicEscape": { + "Type": "Pass", + "Parameters": { + "parsed.$": "States.StringToJson('{\"text\":\"An \\\"escaped double quote\\\" here\"}')" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_illegal_intrinsic_function_2.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_illegal_intrinsic_function_2.json5 new file mode 100644 index 0000000000000..6fc2644cf5f03 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_illegal_intrinsic_function_2.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "IntrinsicEscape", + "States": { + "IntrinsicEscape": { + "Type": "Pass", + "Parameters": { + "parsed.$": "States.Format('He said, \\\"Hello, {}!\\\"', 'Test \\\"Name\\\" Here')" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonata_comparison_assign.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonata_comparison_assign.json5 new file mode 100644 index 0000000000000..05b45a6b84a6c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonata_comparison_assign.json5 @@ -0,0 +1,18 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Assign": { + "var": "\"" + }, + "Next": "Check" + }, + "Check": { + "Type": "Pass", + "Output": "{% $var = '\"' %}", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonata_comparison_output.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonata_comparison_output.json5 new file mode 100644 index 0000000000000..3b2a66d7816a8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonata_comparison_output.json5 @@ -0,0 +1,16 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Output": "\"", + "Next": "Check" + }, + "Check": { + "Type": "Pass", + "Output": "{% $states.input = '\"' %}", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonpath.json5 new file mode 100644 index 0000000000000..73de2bfee0d06 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_jsonpath.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "JsonPathEscapeTest", + "States": { + "JsonPathEscapeTest": { + "Type": "Pass", + "Parameters": { + "value.$": "$['Test\\\"\"Name\"']" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_string_literals.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_string_literals.json5 new file mode 100644 index 0000000000000..9f9765569f5db --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/escape_sequences_string_literals.json5 @@ -0,0 +1,36 @@ +{ + "StartAt": "TestEscapesParameters", + "States": { + "TestEscapesParameters": { + "Type": "Pass", + "Parameters": { + "literal_single_quote": "'", + "literal_double_quote": "\"", + "mixed_quotes": "Text with both \"double\" and 'single' quotes.", + "escaped_double_in_string": "An escaped quote: \\\"hello\\\"", + "escaped_backslash": "Backslash \\\\ in string", + "unicode_double_quote": "\u0022unicode-quote\u0022", + "whitespace_escapes": "Line1\nLine2\tTabbed", + "emoji": "\uD83C\uDD97", + }, + "Next": "TestEscapesResult" + }, + "TestEscapesResult": { + "Type": "Pass", + "Result": { + "literal_single_quote": "'", + "literal_double_quote": "\"", + "mixed_quotes": "Text with both \"double\" and 'single' quotes.", + "escaped_double_in_string": "An escaped quote: \\\"hello\\\"", + "escaped_backslash": "Backslash \\\\ in string", + "unicode_double_quote": "\u0022unicode-quote\u0022", + "whitespace_escapes": "Line1\nLine2\tTabbed", + "emoji": "\uD83C\uDD97", + // This tests the lexer in binding a string starting with States. + // to a string literal whenever escape sequences are detected. + "not_an_intrinsic_function.$": "States.StringToJson('{\"text\":\"An \\\"escaped double quote\\\" here\"}')" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/fail_cause_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/fail_cause_jsonata.json5 new file mode 100644 index 0000000000000..a2649aa03ee63 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/fail_cause_jsonata.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "RAISE_FAILURE_CAUSE_JSONATA", + "QueryLanguage": "JSONata", + "StartAt": "FailState", + "States": { + "FailState": { + "Type": "Fail", + "Cause": "{% $states.input.cause %}" + }, + }, +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/fail_error_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/fail_error_jsonata.json5 new file mode 100644 index 0000000000000..65180534fc398 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/fail_error_jsonata.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "RAISE_FAILURE_ERROR_JSONATA", + "QueryLanguage": "JSONata", + "StartAt": "FailState", + "States": { + "FailState": { + "Type": "Fail", + "Error": "{% $states.input.error %}" + }, + }, +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_causepath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_causepath.json5 new file mode 100644 index 0000000000000..3ffc36d42b93e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_causepath.json5 @@ -0,0 +1,17 @@ +{ + "StartAt": "pass", + "States": { + "pass": { + "Type": "Pass", + "Next": "fail", + "Parameters": { + "Error": "error-value", + } + }, + "fail": { + "Type": "Fail", + "ErrorPath": "$.Error", + "CausePath": "$.NoSuchCausePath" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_errorpath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_errorpath.json5 new file mode 100644 index 0000000000000..21654f7a90da2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_errorpath.json5 @@ -0,0 +1,16 @@ +{ + "StartAt": "pass", + "States": { + "pass": { + "Type": "Pass", + "Next": "fail", + "Parameters": { + "Error": "error-value", + } + }, + "fail": { + "Type": "Fail", + "ErrorPath": "$.ErrorX", + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_heartbeatsecondspath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_heartbeatsecondspath.json5 new file mode 100644 index 0000000000000..95079504148bd --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_heartbeatsecondspath.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "task", + "States": { + "task": { + "Type": "Task", + "HeartbeatSecondsPath": "$.NoSuchTimeoutSecondsPath", + "Resource": "arn:aws:states:::aws-sdk:s3:listBuckets.waitForTaskToken", + "Parameters": {}, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_inputpath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_inputpath.json5 new file mode 100644 index 0000000000000..20a284be00449 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_inputpath.json5 @@ -0,0 +1,10 @@ +{ + "StartAt": "pass", + "States": { + "pass": { + "Type": "Pass", + "InputPath": "$.NoSuchInputPath", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_outputpath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_outputpath.json5 new file mode 100644 index 0000000000000..50aaa7d51dc5a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_outputpath.json5 @@ -0,0 +1,10 @@ +{ + "StartAt": "pass", + "States": { + "pass": { + "Type": "Pass", + "OutputPath": "$.NoSuchOutputPath", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_string_expr_contextpath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_string_expr_contextpath.json5 new file mode 100644 index 0000000000000..ba6fced5d5a87 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_string_expr_contextpath.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "pass", + "States": { + "pass": { + "Type": "Pass", + "Parameters": { + "value.$": "$$.Execution.Input.no_such_jsonpath", + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_string_expr_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_string_expr_jsonpath.json5 new file mode 100644 index 0000000000000..ce2cab38fa13f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_string_expr_jsonpath.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "pass", + "States": { + "pass": { + "Type": "Pass", + "Parameters": { + "value.$": "$.no_such_jsonpath", + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_timeoutsecondspath.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_timeoutsecondspath.json5 new file mode 100644 index 0000000000000..3d74ca5685073 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/invalid_jsonpath_in_timeoutsecondspath.json5 @@ -0,0 +1,12 @@ +{ + "StartAt": "task", + "States": { + "task": { + "Type": "Task", + "TimeoutSecondsPath": "$.NoSuchTimeoutSecondsPath", + "Resource": "arn:aws:states:::aws-sdk:s3:listBuckets", + "Parameters": {}, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_empty_retry.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_empty_retry.json5 new file mode 100644 index 0000000000000..200ab73ad2296 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_empty_retry.json5 @@ -0,0 +1,12 @@ +{ + "Comment": "LAMBDA_EMPTY_RETRY", + "StartAt": "LambdaTask", + "States": { + "LambdaTask": { + "Type": "Task", + "Resource": "_tbd_", + "End": true, + "Retry": [] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_invoke_with_retry_base.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_invoke_with_retry_base.json5 new file mode 100644 index 0000000000000..ac8cbf9a341b0 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_invoke_with_retry_base.json5 @@ -0,0 +1,30 @@ +{ + "Comment": "LAMBDA_INVOKE_WITH_RETRY_BASE", + "StartAt": "InvokeLambdaWithRetry", + "States": { + "InvokeLambdaWithRetry": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "Retry": [ + { + "ErrorEquals": ["States.ALL"], + "MaxAttempts": 3, + } + ], + "Catch": [ + { + "ErrorEquals": ["States.ALL"], + "Next": "HandleError" + } + ], + "End": true, + "ResultSelector": { + "Retries.$": "$$.State.RetryCount" + } + }, + "HandleError": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_invoke_with_retry_extended_input.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_invoke_with_retry_extended_input.json5 new file mode 100644 index 0000000000000..e29be2c49d3c3 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_invoke_with_retry_extended_input.json5 @@ -0,0 +1,35 @@ + { + "Comment": "LAMBDA_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT", + "StartAt": "InvokeLambdaWithRetry", + "States": { + "InvokeLambdaWithRetry": { + "Type": "Task", + "Resource": "_tbd_", + "Parameters": { + "ThisContextObject.$": "$$", + "ThisState.$": "$" + }, + "Retry": [ + { + "ErrorEquals": ["States.ALL"], + "MaxAttempts": 3, + } + ], + "Catch": [ + { + "ErrorEquals": ["States.ALL"], + "Next": "HandleError" + } + ], + "ResultPath": "$.state_output", + "ResultSelector": { + "FinalRetryCount.$": "$$.State.RetryCount" + }, + "End": true + }, + "HandleError": { + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_service_invoke_with_retry_extended_input.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_service_invoke_with_retry_extended_input.json5 new file mode 100644 index 0000000000000..55b08f44c2cc8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/lambda_service_invoke_with_retry_extended_input.json5 @@ -0,0 +1,38 @@ + { + "Comment": "LAMBDA_SERVICE_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT", + "StartAt": "InvokeLambdaWithRetry", + "States": { + "InvokeLambdaWithRetry": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload": { + "ThisContextObject.$": "$$", + "ThisState.$": "$" + } + }, + "Retry": [ + { + "ErrorEquals": ["States.ALL"], + "MaxAttempts": 3, + } + ], + "Catch": [ + { + "ErrorEquals": ["States.ALL"], + "Next": "HandleError" + } + ], + "ResultPath": "$.state_output", + "ResultSelector": { + "FinalRetryCount.$": "$$.State.RetryCount" + }, + "End": true + }, + "HandleError": { + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_batcher_base_max_per_batch_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_batcher_base_max_per_batch_jsonata.json5 new file mode 100644 index 0000000000000..ab42b2fa991b4 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_batcher_base_max_per_batch_jsonata.json5 @@ -0,0 +1,42 @@ +{ + "Comment": "MAP_ITEM_BATCHER_BASE_JSON_MAX_PER_BATCH_JSONATA", + "StartAt": "BatchMapState", + "QueryLanguage": "JSONata", + "States": { + "BatchMapState": { + "Type": "Map", + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + "MaxItems": 2 + }, + "Resource": "arn:aws:states:::s3:getObject", + "Arguments": { + "Bucket": "{% $states.input.Bucket %}", + "Key":"{% $states.input.Key %}" + } + }, + "ItemBatcher": { + "MaxItemsPerBatch": "{% $states.input.MaxItemsPerBatch %}", + "MaxInputBytesPerBatch": "{% $states.input.MaxInputBytesPerBatch %}", + "BatchInput": { + "BatchTimestamp": "{% $states.context.State.EnteredTime %}" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "ProcessBatch", + "States": { + "ProcessBatch": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_headers_decl.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_headers_decl.json5 new file mode 100644 index 0000000000000..e7717b5c88c51 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_headers_decl.json5 @@ -0,0 +1,36 @@ +{ + "Comment": "MAP_ITEM_READER_BASE_CSV_HEADERS_DECL", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "CSV", + "CSVHeaderLocation": "GIVEN", + "CSVHeaders": "__TBD__" + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_headers_first_line.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_headers_first_line.json5 new file mode 100644 index 0000000000000..fc13fd967b57d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_headers_first_line.json5 @@ -0,0 +1,35 @@ +{ + "Comment": "MAP_ITEM_READER_BASE_CSV_HEADERS_FIRST_LINE", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "CSV", + "CSVHeaderLocation": "FIRST_ROW" + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_max_items.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_max_items.json5 new file mode 100644 index 0000000000000..dff48f069d758 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_max_items.json5 @@ -0,0 +1,36 @@ +{ + "Comment": "MAP_ITEM_READER_BASE_CSV_MAX_ITEMS", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "CSV", + "CSVHeaderLocation": "FIRST_ROW", + "MaxItems": 2, + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_max_items_path.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_max_items_path.json5 new file mode 100644 index 0000000000000..c54f5591f3956 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_csv_max_items_path.json5 @@ -0,0 +1,36 @@ +{ + "Comment": "MAP_ITEM_READER_BASE_CSV_MAX_ITEMS_PATH", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "CSV", + "CSVHeaderLocation": "FIRST_ROW", + "MaxItemsPath": "$.MaxItems", + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json.json5 new file mode 100644 index 0000000000000..70f5049909994 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json.json5 @@ -0,0 +1,34 @@ +{ + "Comment": "MAP_ITEM_READER_BASE_JSON", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_max_items.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_max_items.json5 new file mode 100644 index 0000000000000..407d0bf91c44d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_max_items.json5 @@ -0,0 +1,35 @@ +{ + "Comment": "MAP_ITEM_READER_BASE_JSON_MAX_ITEMS", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + "MaxItems": 2 + }, + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_max_items_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_max_items_jsonata.json5 new file mode 100644 index 0000000000000..cdf42e7a90b95 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_max_items_jsonata.json5 @@ -0,0 +1,36 @@ +{ + "Comment": "MAP_ITEM_READER_BASE_JSON_MAX_ITEMS_JSONATA", + "QueryLanguage": "JSONata", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + "MaxItems": "{% $maxItems := 2 %}" + }, + "Resource": "arn:aws:states:::s3:getObject", + "Arguments": { + "Bucket": "{% $states.input.Bucket %}", + "Key":"{% $states.input.Key %}" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_with_items_path.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_with_items_path.json5 new file mode 100644 index 0000000000000..c149bf7f5191f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_json_with_items_path.json5 @@ -0,0 +1,43 @@ +{ + "StartAt": "LoadState", + "States": { + "LoadState": { + "Type": "Pass", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key", + "from_previous": ["from-previous-item-0"] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.from_previous", + "ItemReader": { + "Resource": "arn:aws:states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + }, + "ReaderConfig": { + "InputType": "JSON" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassState", + "States": { + "PassState": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_list_objects_v2.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_list_objects_v2.json5 new file mode 100644 index 0000000000000..aa1420c19bd17 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_item_reader_base_list_objects_v2.json5 @@ -0,0 +1,30 @@ +{ + "Comment": "MAP_ITEM_READER_LIST_OBJECTS_V2", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "Resource": "arn:aws:states:::s3:listObjectsV2", + "Parameters": { + "Bucket.$": "$.Bucket", + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + }, + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state.json5 new file mode 100644 index 0000000000000..10d13ed1c973b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state.json5 @@ -0,0 +1,43 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_break_condition.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_break_condition.json5 new file mode 100644 index 0000000000000..9b4eec59d4f25 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_break_condition.json5 @@ -0,0 +1,74 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1, + "Hello", + {}, + 3.3, + 3, + 4, + 5 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Equals3", + "States": { + "Equals3": { + "Choices": [ + { + "And": [ + { + "IsPresent": true, + "Variable": "$" + }, + { + "And": [ + { + "IsNumeric": true, + "Variable": "$" + }, + { + "NumericEquals": 3, + "Variable": "$" + } + ] + } + ], + "Next": "Break" + } + ], + "Default": "Pass", + "Type": "Choice" + }, + "Break": { + "Type": "Fail", + "Error": "SomeFailure", + "Cause": "This state machines raises a 'SomeFailure' failure." + }, + "Pass": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_break_condition_legacy.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_break_condition_legacy.json5 new file mode 100644 index 0000000000000..00b1d6b3cacae --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_break_condition_legacy.json5 @@ -0,0 +1,71 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1, + "Hello", + {}, + 3.3, + 3, + 4, + 5 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "Iterator": { + "StartAt": "Equals3", + "States": { + "Equals3": { + "Choices": [ + { + "And": [ + { + "IsPresent": true, + "Variable": "$" + }, + { + "And": [ + { + "IsNumeric": true, + "Variable": "$" + }, + { + "NumericEquals": 3, + "Variable": "$" + } + ] + } + ], + "Next": "Break" + } + ], + "Default": "Pass", + "Type": "Choice" + }, + "Break": { + "Type": "Fail", + "Error": "SomeFailure", + "Cause": "This state machines raises a 'SomeFailure' failure." + }, + "Pass": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch.json5 new file mode 100644 index 0000000000000..2526c278692be --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch.json5 @@ -0,0 +1,49 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItemFail", + "States": { + "HandleItemFail": { + "Type": "Fail", + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + } + } + }, + "Catch": [ + { + "ErrorEquals": [ + "HandleItemFailError" + ], + "Next": "FinalCaught", + }, + ], + "Next": "Final", + }, + "FinalCaught": { + "Type": "Pass", + "End": true + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch_empty_fail.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch_empty_fail.json5 new file mode 100644 index 0000000000000..36d8d7dc6291e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch_empty_fail.json5 @@ -0,0 +1,57 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItemFail", + "States": { + "HandleItemFail": { + "Type": "Fail" + } + } + }, + "Catch": [ + { + "ErrorEquals": [ + "Internal Error" + ], + "Next": "CaughtInternalError", + }, + { + "ErrorEquals": [ + "State.ALL" + ], + "Next": "CaughtStatesALL", + } + ], + "Next": "CaughtNone", + }, + "CaughtInternalError": { + "Type": "Pass", + "End": true + }, + "CaughtStatesALL": { + "Type": "Pass", + "End": true + }, + "CaughtNone": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch_legacy.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch_legacy.json5 new file mode 100644 index 0000000000000..2526c278692be --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_catch_legacy.json5 @@ -0,0 +1,49 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItemFail", + "States": { + "HandleItemFail": { + "Type": "Fail", + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + } + } + }, + "Catch": [ + { + "ErrorEquals": [ + "HandleItemFailError" + ], + "Next": "FinalCaught", + }, + ], + "Next": "Final", + }, + "FinalCaught": { + "Type": "Pass", + "End": true + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_item_selector.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_item_selector.json5 new file mode 100644 index 0000000000000..d4761b0c55818 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_item_selector.json5 @@ -0,0 +1,31 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemSelector": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "ItemProcessor": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_item_selector_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_item_selector_parameters.json5 new file mode 100644 index 0000000000000..c7e80886e0cf6 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_item_selector_parameters.json5 @@ -0,0 +1,45 @@ +{ + "Comment": "MAP_STATE_CONFIG_DISTRIBUTED_ITEM_SELECTOR_PARAMETERS", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Parameters": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + }, + "ResultPath": "$.content", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.content.values", + "ItemSelector": { + "bucketName.$": "$.content.bucket", + "value.$": "$$.Map.Item.Value" + }, + "ItemProcessor": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_items_path_from_previous.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_items_path_from_previous.json5 new file mode 100644 index 0000000000000..04a3b802a5b53 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_items_path_from_previous.json5 @@ -0,0 +1,29 @@ +{ + "StartAt": "PreviousState", + "States": { + "PreviousState": { + "Type": "Pass", + "Result": { "result_value": ["item-value-from-previous"] }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.result_value", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassState", + "States": { + "PassState": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_parameters.json5 new file mode 100644 index 0000000000000..55925c884ce91 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_parameters.json5 @@ -0,0 +1,31 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "Parameters": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "ItemProcessor": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_reentrant.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_reentrant.json5 new file mode 100644 index 0000000000000..2722f9f06ae7c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_reentrant.json5 @@ -0,0 +1,61 @@ +{ + "Comment": "MAP_STATE_CONFIG_DISTRIBUTED_REENTRANT", + "StartAt": "StartState", + "States": { + // Populate memory with two fields: number of iterations left, and input values. + "StartState": { + "Type": "Pass", + "Parameters": { + "Iterations": 3, + "Values.$": "States.ArrayRange(0, 3, 1)" + }, + "Next": "BeforeIteration" + }, + // Prepare the iteration by updating the iterations count. + "BeforeIteration": { + "Type": "Pass", + "Parameters": { + "Iterations.$": "States.MathAdd($.Iterations, -1)", + "Values.$": "$.Values" + }, + "Next": "IterationBody" + }, + // Run a distributed map state on the values field. + "IterationBody": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.Values", + "ItemProcessor": { // Use ItemProcessor over legacy's Iterator. + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "ProcessValue", + "States": { + "ProcessValue": { + "Type": "Pass", + "End": true + } + } + }, + "ResultPath": "$.Values", + "Next": "CheckIteration" + }, + // Check the number of iterations is zero and terminate, otherwise run another iteration. + "CheckIteration": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterations", + "NumericEquals": 0, + "Next": "Terminate" + } + ], + "Default": "BeforeIteration" + }, + // Terminate the execution. + "Terminate": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_reentrant_lambda.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_reentrant_lambda.json5 new file mode 100644 index 0000000000000..4360254731f60 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_distributed_reentrant_lambda.json5 @@ -0,0 +1,65 @@ +{ + "Comment": "MAP_STATE_CONFIG_DISTRIBUTED_REENTRANT_LAMBDA", + "StartAt": "StartState", + "States": { + // Populate memory with two fields: number of iterations left, and input values. + "StartState": { + "Type": "Pass", + "Parameters": { + "Iterations": 2, + "Values": [ + "HelloWorld" + ] + }, + "Next": "BeforeIteration" + }, + // Prepare the iteration by updating the iterations count. + "BeforeIteration": { + "Type": "Pass", + "Parameters": { + "Iterations.$": "States.MathAdd($.Iterations, -1)", + "Values.$": "$.Values" + }, + "Next": "IterationBody" + }, + // Run a distributed map state on the values field. + "IterationBody": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.Values", + "ItemProcessor": { // Use ItemProcessor over legacy's Iterator. + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "ProcessValue", + "States": { + // Delegate the input to a task state, allowing the resource to be configured on creation. + "ProcessValue": { + "Type": "Task", + "Resource": "_tbd_", + "End": true + } + } + }, + "ResultPath": "$.Values", + "Next": "CheckIteration" + }, + // Check the number of iterations is zero and terminate, otherwise run another iteration. + "CheckIteration": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterations", + "NumericEquals": 0, + "Next": "Terminate" + } + ], + "Default": "BeforeIteration" + }, + // Terminate the execution. + "Terminate": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_inline_item_selector.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_inline_item_selector.json5 new file mode 100644 index 0000000000000..683cb95e3e745 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_inline_item_selector.json5 @@ -0,0 +1,30 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemSelector": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "ItemProcessor": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "INLINE", + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_inline_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_inline_parameters.json5 new file mode 100644 index 0000000000000..7bb8ecc3fd6af --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_config_inline_parameters.json5 @@ -0,0 +1,30 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "Parameters": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "ItemProcessor": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "INLINE", + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector.json5 new file mode 100644 index 0000000000000..d38b968acd911 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector.json5 @@ -0,0 +1,72 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": "$.detail.shipped_output", + "InputPath": "$.detail", + "ItemsPath": "$.shipped", + "MaxConcurrency": 1, + "ItemSelector": { + "original_input.$": "$", + "iteration_input_value.$": "$$.Map.Item.Value", + "iteration_index.$": "$$.Map.Item.Index", + "from_input_constant.$": "$.delivery-partner", + "constant_value": "HelloWorld" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_jsonata.json5 new file mode 100644 index 0000000000000..47b31d274cf91 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_jsonata.json5 @@ -0,0 +1,36 @@ +{ + "QueryLanguage": "JSONata", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "ItemsVar": ["Item1", "Item2"] + }, + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "Items": "{% $ItemsVar %}", + "ItemSelector": { + "map_item_value": "{% $states.context.Map.Item.Value %}", + "var_sample": "{% $ItemsVar %}", + "string_literal": "string literal" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_parameters.json5 new file mode 100644 index 0000000000000..27747ec85fa6e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_parameters.json5 @@ -0,0 +1,45 @@ +{ + "Comment": "MAP_STATE_ITEM_SELECTOR_PARAMETERS", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Parameters": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + }, + "ResultPath": "$.content", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.content.values", + "ItemSelector": { + "bucketName.$": "$.content.bucket", + "value.$": "$$.Map.Item.Value" + }, + "ItemProcessor": { + "StartAt": "EndState", + "ProcessorConfig": { + "Mode": "INLINE" + }, + "States": { + "EndState": { + "Type": "Pass", + "Parameters": { + "message": "Processing item completed" + }, + "End": true + } + } + }, + "ResultPath": null, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_singletons.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_singletons.json5 new file mode 100644 index 0000000000000..268aa6d6d6a8c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_item_selector_singletons.json5 @@ -0,0 +1,48 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", "B", "C" + ] + } + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": "$.detail.shipped_output", + "InputPath": "$.detail", + "ItemsPath": "$.shipped", + "MaxConcurrency": 1, + "ItemSelector": { + "original_input.$": "$", + "iteration_input_value.$": "$$.Map.Item.Value", + "iteration_index.$": "$$.Map.Item.Index", + "from_input_constant.$": "$.delivery-partner", + "constant_value": "HelloWorld" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items.json5 new file mode 100644 index 0000000000000..f308d89c9b36c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items.json5 @@ -0,0 +1,29 @@ +{ + "Comment": "MAP_STATE_ITEMS", + "QueryLanguage": "JSONata", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Output": "{% $states.input.items %}", + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items_literal.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items_literal.json5 new file mode 100644 index 0000000000000..8a756a310927f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items_literal.json5 @@ -0,0 +1,25 @@ +{ + "Comment": "MAP_STATE_ITEMS_LITERAL", + "QueryLanguage": "JSONata", + "StartAt": "MapIterateState", + "States": { + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "Items": "_tbd_", // Resource field to be replaced dynamically. + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items_variable.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items_variable.json5 new file mode 100644 index 0000000000000..45041d863c87a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_items_variable.json5 @@ -0,0 +1,32 @@ +{ + "Comment": "MAP_STATE_ITEMS_VARIABLE", + "QueryLanguage": "JSONata", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "ItemsVar": "_tbd_", // Resource field to be replaced dynamically. + }, + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "Items": "{% $ItemsVar %}", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label.json5 new file mode 100644 index 0000000000000..070e18f0c9058 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "MAP_STATE_LABEL_JSON", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + }, + "Label": "TestMap", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_empty_fail.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_empty_fail.json5 new file mode 100644 index 0000000000000..b0bf279fea17a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_empty_fail.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "MAP_STATE_LABEL_JSON", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + }, + "Label": "", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_invalid_char_fail.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_invalid_char_fail.json5 new file mode 100644 index 0000000000000..597af4ebd90e7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_invalid_char_fail.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "MAP_STATE_LABEL_JSON", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + }, + "Label": "label_{invalid_char}", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_too_long_fail.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_too_long_fail.json5 new file mode 100644 index 0000000000000..6669618d96469 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_label_too_long_fail.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "MAP_STATE_LABEL_JSON", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + }, + "Label": "12345678901234567890123456789012345678901", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy.json5 new file mode 100644 index 0000000000000..45f0160c2b677 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy.json5 @@ -0,0 +1,40 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "Iterator": { + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed.json5 new file mode 100644 index 0000000000000..04731e018d8bb --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed.json5 @@ -0,0 +1,26 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "Iterator": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed_item_selector.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed_item_selector.json5 new file mode 100644 index 0000000000000..514edfc95cb0b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed_item_selector.json5 @@ -0,0 +1,31 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemSelector": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "Iterator": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed_parameters.json5 new file mode 100644 index 0000000000000..b336b4070d727 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_distributed_parameters.json5 @@ -0,0 +1,31 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "Parameters": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "Iterator": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline.json5 new file mode 100644 index 0000000000000..cb7707f67706d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline.json5 @@ -0,0 +1,25 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "Iterator": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "INLINE", + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline_item_selector.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline_item_selector.json5 new file mode 100644 index 0000000000000..bdf44bf9199df --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline_item_selector.json5 @@ -0,0 +1,30 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemSelector": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "Iterator": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "INLINE", + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline_parameters.json5 new file mode 100644 index 0000000000000..467b48c0d36da --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_config_inline_parameters.json5 @@ -0,0 +1,30 @@ +{ + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "Parameters": { + "constantValue": "constant", + "mapValue.$": "$$.Map.Item.Value", + "fromInput.$": "$", + }, + "Iterator": { + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + "ProcessorConfig": { + "Mode": "INLINE", + } + }, + "Next": "Finish", + }, + "Finish": { + "Type": "Succeed" + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_reentrant.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_reentrant.json5 new file mode 100644 index 0000000000000..a0ab87551d309 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_legacy_reentrant.json5 @@ -0,0 +1,57 @@ +{ + "Comment": "MAP_STATE_LEGACY_REENTRANT", + "StartAt": "StartState", + "States": { + // Populate memory with two fields: number of iterations left, and input values. + "StartState": { + "Type": "Pass", + "Parameters": { + "Iterations": 3, + "Values.$": "States.ArrayRange(0, 3, 1)" + }, + "Next": "BeforeIteration" + }, + // Prepare the iteration by updating the iterations count. + "BeforeIteration": { + "Type": "Pass", + "Parameters": { + "Iterations.$": "States.MathAdd($.Iterations, -1)", + "Values.$": "$.Values" + }, + "Next": "IterationBody" + }, + // Run a distributed map state on the values field. + "IterationBody": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.Values", + "Iterator": { + "StartAt": "ProcessValue", + "States": { + "ProcessValue": { + "Type": "Pass", + "End": true + } + } + }, + "ResultPath": "$.Values", + "Next": "CheckIteration" + }, + // Check the number of iterations is zero and terminate, otherwise run another iteration. + "CheckIteration": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.Iterations", + "NumericEquals": 0, + "Next": "Terminate" + } + ], + "Default": "BeforeIteration" + }, + // Terminate the execution. + "Terminate": { + "Type": "Succeed" + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested.json5 new file mode 100644 index 0000000000000..49e4cac930b80 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested.json5 @@ -0,0 +1,31 @@ +{ + "StartAt": "MapL1", + "States": { + "MapL1": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { "Mode": "INLINE" }, + "StartAt": "MapL2", + "States": { + "MapL2": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { "Mode": "INLINE" }, + "StartAt": "MapL2Pass", + "States": { + "MapL2Pass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested_config_distributed.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested_config_distributed.json5 new file mode 100644 index 0000000000000..1602a74a9e7bc --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested_config_distributed.json5 @@ -0,0 +1,59 @@ +{ + "StartAt": "SetupState", + "States": { + "SetupState": { + "Type": "Pass", + "Result": { + "values": [ + { + "sub-values": [ + { + "num": 1, + "str": "A" + }, + { + "num": 2, + "str": "B" + } + ] + } + ] + }, + "Next": "MapState", + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.values", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD", + }, + "StartAt": "SubMapState", + "States": { + "SubMapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.sub-values", + "ResultPath": "$.result", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD", + }, + "StartAt": "SubMapStateSuccess", + "States": { + "SubMapStateSuccess": { + "Type": "Succeed" + } + }, + }, + "End": true, + } + }, + }, + "End": true, + }, + }, +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested_config_distributed_no_max_concurrency.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested_config_distributed_no_max_concurrency.json5 new file mode 100644 index 0000000000000..468fa415f8c35 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_nested_config_distributed_no_max_concurrency.json5 @@ -0,0 +1,79 @@ +{ + "StartAt": "InputValue", + "States": { + "InputValue": { + "Type": "Pass", + "Result": { + "outerJobs": [ + { + "innerJobs": [0, 1, 2, 3, 4] + }, + { + "innerJobs": [0, 1, 2, 3, 4] + }, + { + "innerJobs": [0, 1, 2, 3, 4] + }, + { + "innerJobs": [0, 1, 2, 3, 4] + }, + ] + }, + "Next": "OuterMap" + }, + "OuterMap": { + "Type": "Map", + "ResultPath": null, + "Next": "FinalState", + "ItemsPath": "$.outerJobs", + "ItemSelector": { + "innerJobs.$": "$$.Map.Item.Value.innerJobs" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "InnerMap", + "States": { + "InnerMap": { + "Type": "Map", + "ResultPath": null, + "End": true, + "ItemsPath": "$.innerJobs", + "ItemSelector": { + "job.$": "$$.Map.Item.Value" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "ProcessJob", + "States": { + "ProcessJob": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName": "__tbd__", + "Payload": { + "job.$": "$.job" + } + }, + "End": true + } + } + }, + "MaxConcurrency": 9 + } + } + }, + "MaxConcurrency": 50 + }, + "FinalState": { + "Type": "Pass", + "End": true + } + } +} + diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_no_processor_config.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_no_processor_config.json5 new file mode 100644 index 0000000000000..e60f8b6cfa726 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_no_processor_config.json5 @@ -0,0 +1,31 @@ +{ + "Comment": "MAP_STATE_NO_PROCESSOR_CONFIG", + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1, + 2, + 3, + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ItemsPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true, + }, + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_parameters_legacy.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_parameters_legacy.json5 new file mode 100644 index 0000000000000..f20ce9536e2a8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_parameters_legacy.json5 @@ -0,0 +1,69 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": "$.detail.shipped_output", + "InputPath": "$.detail", + "ItemsPath": "$.shipped", + "MaxConcurrency": 1, + "Parameters": { + "original_input.$": "$", + "iteration_input_value.$": "$$.Map.Item.Value", + "iteration_index.$": "$$.Map.Item.Index", + "from_input_constant.$": "$.delivery-partner", + "constant_value": "HelloWorld" + }, + "Iterator": { + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_parameters_singletons_legacy.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_parameters_singletons_legacy.json5 new file mode 100644 index 0000000000000..13090e810deef --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_parameters_singletons_legacy.json5 @@ -0,0 +1,45 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", "B", "C" + ] + } + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": "$.detail.shipped_output", + "InputPath": "$.detail", + "ItemsPath": "$.shipped", + "MaxConcurrency": 1, + "Parameters": { + "original_input.$": "$", + "iteration_input_value.$": "$$.Map.Item.Value", + "iteration_index.$": "$$.Map.Item.Index", + "from_input_constant.$": "$.delivery-partner", + "constant_value": "HelloWorld" + }, + "Iterator": { + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_result_writer.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_result_writer.json5 new file mode 100644 index 0000000000000..911b5b02e36f3 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_result_writer.json5 @@ -0,0 +1,31 @@ +{ + "Comment": "MAP_STATE_RESULT_WRITER_JSON", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "IteratorInner", + "States": { + "IteratorInner": { + "Type": "Pass", + "End": true + } + }, + }, + "ResultWriter": { + "Resource": "arn:aws:states:::s3:putObject", + "Parameters": { + "Bucket": "result-bucket", + "Prefix": "mapJobs" + } + }, + "Label": "TestMap", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry.json5 new file mode 100644 index 0000000000000..1fa421c60147a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry.json5 @@ -0,0 +1,45 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItemFail", + "States": { + "HandleItemFail": { + "Type": "Fail", + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + } + } + }, + "Retry": [ + { + "ErrorEquals": [ + "HandleItemFailError" + ], + "MaxAttempts": 2, + }, + ], + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry_legacy.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry_legacy.json5 new file mode 100644 index 0000000000000..f24494156787f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry_legacy.json5 @@ -0,0 +1,42 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "Iterator": { + "StartAt": "HandleItemFail", + "States": { + "HandleItemFail": { + "Type": "Fail", + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + } + } + }, + "Retry": [ + { + "ErrorEquals": [ + "HandleItemFailError" + ], + "MaxAttempts": 2, + }, + ], + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry_multiple_retriers.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry_multiple_retriers.json5 new file mode 100644 index 0000000000000..4edefca8af65a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_retry_multiple_retriers.json5 @@ -0,0 +1,57 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "Result": [ + 1 + ], + "ResultPath": "$.arr", + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ResultPath": null, + "InputPath": "$.arr", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItemFail", + "States": { + "HandleItemFail": { + "Type": "Fail", + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + } + } + }, + "Retry": [ + { + "ErrorEquals": [ + "NoSuchError" + ], + "MaxAttempts": 1, + }, + { + "ErrorEquals": [ + "State.ALL" + ], + "MaxAttempts": 2, + }, + { + "ErrorEquals": [ + "HandleItemFailError" + ], + "MaxAttempts": 3, + }, + ], + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_count.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_count.json5 new file mode 100644 index 0000000000000..5aecf501a7dd2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_count.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "MAP_STATE_TOLERATED_FAILURE_COUNT", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "InputPath": "$", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "ToleratedFailureCount": 10, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_count_path.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_count_path.json5 new file mode 100644 index 0000000000000..b051aade3ca80 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_count_path.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "MAP_STATE_TOLERATED_FAILURE_COUNT_PATH", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemsPath": "$.Items", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "ToleratedFailureCountPath": "$.ToleratedFailureCount", + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_percentage.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_percentage.json5 new file mode 100644 index 0000000000000..56d8e7d27a636 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_percentage.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "MAP_STATE_TOLERATED_FAILURE_PERCENTAGE", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "InputPath": "$", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "ToleratedFailurePercentage": 0.5, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_percentage_path.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_percentage_path.json5 new file mode 100644 index 0000000000000..51653cf08fd9f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/map_state_tolerated_failure_percentage_path.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "MAP_STATE_TOLERATED_FAILURE_PERCENTAGE_PATH", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemsPath": "$.Items", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "ToleratedFailurePercentagePath": "$.ToleratedFailurePercentage", + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/max_concurrency_path.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/max_concurrency_path.json5 new file mode 100644 index 0000000000000..8c1b70c8b42bf --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/max_concurrency_path.json5 @@ -0,0 +1,28 @@ +{ + "Comment": "MAX_CONCURRENCY_PATH", + "StartAt": "MapState", + "States": { + "MapState": { + "Type": "Map", + "ItemsPath": "$.Values", + "MaxConcurrencyPath": "$.MaxConcurrencyValue", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state.json5 new file mode 100644 index 0000000000000..50522b2153119 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state.json5 @@ -0,0 +1,56 @@ +{ + "Comment": "PARALLEL_STATE", + "StartAt": "LoadInput", + "States": { + "LoadInput": { + "Type": "Pass", + "Result": [1, 2, 3, 4], + "Next": "ParallelState" + }, + "ParallelState": { + "Type": "Parallel", + "End": true, + "Branches": [ + { + "StartAt": "Branch1", + "States": { + "Branch1": { + "Type": "Pass", + "End": true + } + } + }, + { + "StartAt": "Branch21", + "States": { + "Branch21": { + "Type": "Pass", + "Next": "Branch22" + }, + "Branch22": { + "Type": "Pass", + "End": true + } + } + }, + { + "StartAt": "Branch31", + "States": { + "Branch31": { + "Type": "Pass", + "Next": "Branch32" + }, + "Branch32": { + "Type": "Pass", + "Next": "Branch33" + }, + "Branch33": { + "Type": "Pass", + "End": true + } + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_catch.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_catch.json5 new file mode 100644 index 0000000000000..a05c8147d2862 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_catch.json5 @@ -0,0 +1,38 @@ +{ + "Comment": "PARALLEL_STATE_CATCH", + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "Branch1", + "States": { + "Branch1": { + "Type": "Fail", + "Error": "FailureType", + "Cause": "Cause description" + } + } + }, + ], + "Catch": [ + { + "ErrorEquals": [ + "FailureType" + ], + "Next": "FinalCaught", + }, + ], + "Next": "Final" + }, + "FinalCaught": { + "Type": "Pass", + "End": true + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_fail.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_fail.json5 new file mode 100644 index 0000000000000..4f9e9851e0110 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_fail.json5 @@ -0,0 +1,21 @@ +{ + "Comment": "PARALLEL_STATE_FAIL", + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "End": true, + "Branches": [ + { + "StartAt": "Branch1", + "States": { + "Branch1": { + "Type": "Fail", + "Error": "FailureType" + } + } + }, + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_nested.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_nested.json5 new file mode 100644 index 0000000000000..910365f8b004c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_nested.json5 @@ -0,0 +1,31 @@ +{ + "StartAt": "ParallelStateL1", + "States": { + "ParallelStateL1": { + "Type": "Parallel", + "End": true, + "Branches": [ + { + "StartAt": "ParallelStateL2", + "States": { + "ParallelStateL2": { + "Type": "Parallel", + "End": true, + "Branches": [ + { + "StartAt": "BranchL2", + "States": { + "BranchL2": { + "Type": "Pass", + "End": true + } + } + } + ] + } + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_order.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_order.json5 new file mode 100644 index 0000000000000..e9f7ffde12fe8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_order.json5 @@ -0,0 +1,60 @@ +{ + "Comment": "PARALLEL_STATE_ORDER", + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "Branch0", + "States": { + "Branch0": { + "Type": "Pass", + "Result": { + "branch": 0 + }, + "End": true + } + } + }, + { + "StartAt": "Branch1", + "States": { + "Branch1": { + "Type": "Pass", + "Result": { + "branch": 1 + }, + "End": true + } + } + }, + { + "StartAt": "Branch2", + "States": { + "Branch2": { + "Type": "Pass", + "Result": { + "branch": 2 + }, + "End": true + } + } + }, + { + "StartAt": "Branch3", + "States": { + "Branch3": { + "Type": "Pass", + "Result": { + "branch": 3 + }, + "End": true + } + } + } + ], + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_parameters.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_parameters.json5 new file mode 100644 index 0000000000000..19f68292db48b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_parameters.json5 @@ -0,0 +1,33 @@ +{ + "StartAt":"TestState", + "States":{ + "TestState":{ + "Type":"Parallel", + "Parameters":{ + "static-input-0": 0, + "static-input-1": [1] + }, + "End":true, + "Branches":[ + { + "StartAt":"Branch0State0", + "States":{ + "Branch0State0":{ + "Type":"Pass", + "End":true + } + } + }, + { + "StartAt":"Branch1State0", + "States":{ + "Branch1State0":{ + "Type":"Pass", + "End":true + } + } + } + ], + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_retry.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_retry.json5 new file mode 100644 index 0000000000000..d2671863f1041 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_retry.json5 @@ -0,0 +1,34 @@ +{ + "Comment": "PARALLEL_STATE_RETRY", + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Branches": [ + { + "StartAt": "Branch1", + "States": { + "Branch1": { + "Type": "Fail", + "Error": "FailureType", + "Cause": "Cause description" + } + } + }, + ], + "Retry": [ + { + "ErrorEquals": [ + "FailureType" + ], + "MaxAttempts": 2, + }, + ], + "Next": "Final", + }, + "Final": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_service_lambda.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_service_lambda.json5 new file mode 100644 index 0000000000000..11bef9148c5e7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/parallel_state_service_lambda.json5 @@ -0,0 +1,39 @@ +{ + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "End": true, + "Branches": [ + { + "StartAt": "Branch1", + "States": { + "Branch1": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionNameBranch1", + "Payload.$": "$.Payload" + }, + "End": true + } + } + }, + { + "StartAt": "Branch2", + "States": { + "Branch2": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionNameBranch2", + "Payload.$": "$.Payload" + }, + "End": true + } + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features.json5 new file mode 100644 index 0000000000000..dbb369ceef6bf --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "RETRY_INTERVAL_FEATURES", + "StartAt": "LambdaTask", + "States": { + "LambdaTask": { + "Type": "Task", + "Resource": "_tbd_", + "End": true, + "Retry": [ + { + "Comment": "Includes all retry langauge features.", + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 3, + "MaxAttempts": 2, + "BackoffRate": 2, + "MaxDelaySeconds": 5, + "JitterStrategy": "FULL", + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features_jitter_none.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features_jitter_none.json5 new file mode 100644 index 0000000000000..2158def62c2d6 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features_jitter_none.json5 @@ -0,0 +1,24 @@ +{ + "Comment": "RETRY_INTERVAL_FEATURES_JITTER_NONE", + "StartAt": "LambdaTask", + "States": { + "LambdaTask": { + "Type": "Task", + "Resource": "_tbd_", + "End": true, + "Retry": [ + { + "Comment": "Includes all retry langauge features with JitterStrategy NONE.", + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 2, + "BackoffRate": 1, + "MaxDelaySeconds": 3, + "JitterStrategy": "NONE", + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features_max_attempts_zero.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features_max_attempts_zero.json5 new file mode 100644 index 0000000000000..62e7b11535025 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/retry_interval_features_max_attempts_zero.json5 @@ -0,0 +1,30 @@ +{ + "Comment": "RETRY_INTERVAL_FEATURES_MAX_ATTEMPTS_ZERO", + "StartAt": "LambdaTask", + "States": { + "LambdaTask": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload": { + "RetryAttempt.$": "$$.State.RetryCount" + } + }, + "Retry": [ + { + "Comment": "Includes all retry language features with zero max attempts.", + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 3, + "MaxAttempts": 0, + "BackoffRate": 2, + "MaxDelaySeconds": 5, + "JitterStrategy": "FULL" + } + ], + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_seconds_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_seconds_jsonata.json5 new file mode 100644 index 0000000000000..c61073e42fd68 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_seconds_jsonata.json5 @@ -0,0 +1,12 @@ +{ + "Comment": "WAIT_SECONDS_JSONATA", + "QueryLanguage": "JSONata", + "StartAt": "WaitState", + "States": { + "WaitState": { + "Type": "Wait", + "Seconds": "{% $states.input.waitSeconds %}", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp.json5 new file mode 100644 index 0000000000000..9853ed05c7eb9 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "WAIT_TIMESTAMP", + "StartAt": "WaitUntil", + "States": { + "WaitUntil": { + "Type": "Wait", + "Timestamp": "2016-03-14T01:59:00Z", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp_jsonata.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp_jsonata.json5 new file mode 100644 index 0000000000000..7744f5507ce2c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp_jsonata.json5 @@ -0,0 +1,12 @@ +{ + "Comment": "WAIT_TIMESTAMP_JSONATA", + "QueryLanguage": "JSONata", + "StartAt": "WaitState", + "States": { + "WaitState": { + "Type": "Wait", + "Timestamp": "{% $states.input.TimestampValue %}", + "End": true + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp_path.json5 b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp_path.json5 new file mode 100644 index 0000000000000..11c507e0b785b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/scenarios/statemachines/wait_timestamp_path.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "WAIT_TIMESTAMP_PATH", + "StartAt": "WaitUntil", + "States": { + "WaitUntil": { + "Type": "Wait", + "TimestampPath": "$.TimestampValue", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/__init__.py b/tests/aws/services/stepfunctions/templates/services/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/services/lambdafunctions/__init__.py b/tests/aws/services/stepfunctions/templates/services/lambdafunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/services/lambdafunctions/id_function.py b/tests/aws/services/stepfunctions/templates/services/lambdafunctions/id_function.py new file mode 100644 index 0000000000000..5ff82cf6f4ae2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/lambdafunctions/id_function.py @@ -0,0 +1,2 @@ +def handler(event, context): + return event diff --git a/tests/aws/services/stepfunctions/templates/services/lambdafunctions/return_bytes_str.py b/tests/aws/services/stepfunctions/templates/services/lambdafunctions/return_bytes_str.py new file mode 100644 index 0000000000000..3082bc713fda1 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/lambdafunctions/return_bytes_str.py @@ -0,0 +1,2 @@ +def handler(event, context): + return b'"HelloWorld!"' diff --git a/tests/aws/services/stepfunctions/templates/services/lambdafunctions/return_decorated_input.py b/tests/aws/services/stepfunctions/templates/services/lambdafunctions/return_decorated_input.py new file mode 100644 index 0000000000000..b02e0471301df --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/lambdafunctions/return_decorated_input.py @@ -0,0 +1,2 @@ +def handler(event, context): + return f"input-event-{event}" diff --git a/tests/aws/services/stepfunctions/templates/services/services_templates.py b/tests/aws/services/stepfunctions/templates/services/services_templates.py new file mode 100644 index 0000000000000..847acd6185a0e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/services_templates.py @@ -0,0 +1,114 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ServicesTemplates(TemplateLoader): + # State Machines. + AWSSDK_LIST_SECRETS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_secrestsmaager_list_secrets.json5" + ) + AWS_SDK_DYNAMODB_PUT_GET_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_dynamodb_put_get_item.json5" + ) + AWS_SDK_DYNAMODB_PUT_DELETE_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_dynamodb_put_delete_item.json5" + ) + AWS_SDK_DYNAMODB_PUT_UPDATE_GET_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_dynamodb_put_update_get_item.json5" + ) + AWS_SDK_SFN_SEND_TASK_FAILURE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_sfn_send_task_failure.json5" + ) + AWS_SDK_SFN_SEND_TASK_SUCCESS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_sfn_send_task_success.json5" + ) + AWS_SDK_SFN_START_EXECUTION: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_sfn_start_execution.json5" + ) + AWS_SDK_SFN_START_EXECUTION_IMPLICIT_JSON_SERIALISATION: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_sfn_start_execution_implicit_json_serialisation.json5" + ) + AWS_SDK_S3_GET_OBJECT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_s3_get_object.json5" + ) + AWS_SDK_S3_PUT_OBJECT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/aws_sdk_s3_put_object.json5" + ) + API_GATEWAY_INVOKE_BASE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/api_gateway_invoke_base.json5" + ) + API_GATEWAY_INVOKE_WITH_BODY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/api_gateway_invoke_with_body.json5" + ) + API_GATEWAY_INVOKE_WITH_HEADERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/api_gateway_invoke_with_headers.json5" + ) + API_GATEWAY_INVOKE_WITH_QUERY_PARAMETERS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/api_gateway_invoke_with_query_parameters.json5" + ) + EVENTS_PUT_EVENTS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/events_put_events.json5" + ) + SQS_SEND_MESSAGE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/sqs_send_msg.json5") + SQS_SEND_MESSAGE_AND_WAIT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_send_msg_and_wait.json5" + ) + SQS_SEND_MESSAGE_ATTRIBUTES: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sqs_send_msg_attributes.json5" + ) + SNS_PUBLISH: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/sns_publish.json5") + SNS_FIFO_PUBLISH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sns_fifo_publish.json5" + ) + SNS_FIFO_PUBLISH_FAIL: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sns_fifo_publish_fail.json5" + ) + SNS_PUBLISH_MESSAGE_ATTRIBUTES: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sns_publish_message_attributes.json5" + ) + LAMBDA_INVOKE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/lambda_invoke.json5") + LAMBDA_INVOKE_PIPE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_invoke_pipe.json5" + ) + LAMBDA_INVOKE_RESOURCE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_invoke_resource.json5" + ) + LAMBDA_INVOKE_LOG_TYPE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_invoke_log_type.json5" + ) + LAMBDA_LIST_FUNCTIONS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_list_functions.json5" + ) + LAMBDA_INPUT_PARAMETERS_FILTER: Final[str] = os.path.join( + _THIS_FOLDER, "../services/statemachines/lambda_input_parameters_filter.json5" + ) + SFN_START_EXECUTION: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/sfn_start_execution.json5" + ) + DYNAMODB_PUT_GET_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/dynamodb_put_get_item.json5" + ) + DYNAMODB_PUT_DELETE_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/dynamodb_put_delete_item.json5" + ) + DYNAMODB_PUT_UPDATE_GET_ITEM: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/dynamodb_put_update_get_item.json5" + ) + DYNAMODB_PUT_QUERY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/dynamodb_put_query.json5" + ) + INVALID_INTEGRATION_DYNAMODB_QUERY: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_integration_dynamodb_query.json5" + ) + # Lambda Functions. + LAMBDA_ID_FUNCTION: Final[str] = os.path.join(_THIS_FOLDER, "lambdafunctions/id_function.py") + LAMBDA_RETURN_BYTES_STR: Final[str] = os.path.join( + _THIS_FOLDER, "lambdafunctions/return_bytes_str.py" + ) + LAMBDA_RETURN_DECORATED_INPUT: Final[str] = os.path.join( + _THIS_FOLDER, "lambdafunctions/return_decorated_input.py" + ) diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_base.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_base.json5 new file mode 100644 index 0000000000000..ce80095e197c2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_base.json5 @@ -0,0 +1,17 @@ +{ + "Comment": "API_GATEWAY_INVOKE_BASE", + "StartAt": "ApiGatewayInvoke", + "States": { + "ApiGatewayInvoke": { + "Type": "Task", + "Resource": "arn:aws:states:::apigateway:invoke", + "Parameters": { + "ApiEndpoint.$": "$.ApiEndpoint", + "Method.$": "$.Method", + "Path.$": "$.Path", + "Stage.$": "$.Stage" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_body.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_body.json5 new file mode 100644 index 0000000000000..a67a33ae525aa --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_body.json5 @@ -0,0 +1,18 @@ +{ + "Comment": "API_GATEWAY_INVOKE_WITH_BODY", + "StartAt": "ApiGatewayInvoke", + "States": { + "ApiGatewayInvoke": { + "Type": "Task", + "Resource": "arn:aws:states:::apigateway:invoke", + "Parameters": { + "ApiEndpoint.$": "$.ApiEndpoint", + "Method.$": "$.Method", + "Path.$": "$.Path", + "Stage.$": "$.Stage", + "RequestBody.$": "$.RequestBody" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_headers.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_headers.json5 new file mode 100644 index 0000000000000..2cfc4f724d70b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_headers.json5 @@ -0,0 +1,19 @@ +{ + "Comment": "API_GATEWAY_INVOKE_WITH_HEADERS", + "StartAt": "ApiGatewayInvoke", + "States": { + "ApiGatewayInvoke": { + "Type": "Task", + "Resource": "arn:aws:states:::apigateway:invoke", + "Parameters": { + "ApiEndpoint.$": "$.ApiEndpoint", + "Method.$": "$.Method", + "Path.$": "$.Path", + "Stage.$": "$.Stage", + "RequestBody.$": "$.RequestBody", + "Headers.$": "$.Headers", + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_query_parameters.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_query_parameters.json5 new file mode 100644 index 0000000000000..8246a86b80efa --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/api_gateway_invoke_with_query_parameters.json5 @@ -0,0 +1,20 @@ +{ + "Comment": "API_GATEWAY_INVOKE_WITH_QUERY_PARAMETERS", + "StartAt": "ApiGatewayInvoke", + "States": { + "ApiGatewayInvoke": { + "Type": "Task", + "Resource": "arn:aws:states:::apigateway:invoke", + "Parameters": { + "ApiEndpoint.$": "$.ApiEndpoint", + "Method.$": "$.Method", + "Path.$": "$.Path", + "Stage.$": "$.Stage", + "RequestBody.$": "$.RequestBody", + "QueryParameters.$": "$.QueryParameters", + "AllowNullValues.$": "$.AllowNullValues" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_delete_item.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_delete_item.json5 new file mode 100644 index 0000000000000..591e131d35b78 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_delete_item.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "AWS_SDK_DYNAMODB_PUT_DELETE_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "ResultPath": "$.putItemOutput", + "Next": "DeleteItem" + }, + "DeleteItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:deleteItem", + "ResultPath": "$.deleteItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_get_item.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_get_item.json5 new file mode 100644 index 0000000000000..b8038e6fa07ab --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_get_item.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "AWS_SDK_DYNAMODB_PUT_GET_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "ResultPath": "$.putItemOutput", + "Next": "GetItem" + }, + "GetItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:getItem", + "ResultPath": "$.getItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_update_get_item.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_update_get_item.json5 new file mode 100644 index 0000000000000..569b841fe72fb --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_dynamodb_put_update_get_item.json5 @@ -0,0 +1,39 @@ +{ + "Comment": "AWS_SDK_DYNAMODB_PUT_UPDATE_GET_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "ResultPath": "$.putItemOutput", + "Next": "UpdateItem" + }, + "UpdateItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:updateItem", + "ResultPath": "$.updateItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key", + "UpdateExpression.$": "$.UpdateExpression", + "ExpressionAttributeValues.$": "$.ExpressionAttributeValues", + "ReturnValues": "UPDATED_NEW", + }, + "Next": "GetItem" + }, + "GetItem": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:dynamodb:getItem", + "ResultPath": "$.getItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_s3_get_object.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_s3_get_object.json5 new file mode 100644 index 0000000000000..649efe529cdac --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_s3_get_object.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "AWS_SDK_S3_GET_OBJECT", + "StartAt": "S3GetObject", + "States": { + "S3GetObject": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_s3_put_object.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_s3_put_object.json5 new file mode 100644 index 0000000000000..56ff563c44562 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_s3_put_object.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "AWS_SDK_S3_PUT_OBJECT", + "StartAt": "S3PutObject", + "States": { + "S3PutObject": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:s3:putObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key", + "Body.$": "$.Body" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_secrestsmaager_list_secrets.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_secrestsmaager_list_secrets.json5 new file mode 100644 index 0000000000000..82004eb1d0d6e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_secrestsmaager_list_secrets.json5 @@ -0,0 +1,17 @@ +{ + "Comment": "TASK_SERVICE_SECRETSMANAGER_LISTSECRETS", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:secretsmanager:listSecrets", + "Parameters": {}, + "Next": "EndWithFinal" + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_send_task_failure.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_send_task_failure.json5 new file mode 100644 index 0000000000000..ac7477e0a1ac6 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_send_task_failure.json5 @@ -0,0 +1,14 @@ +{ + "Comment": "AWS_SDK_SFN_SEND_TASK_FAILURE", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskFailure", + "Parameters": { + "TaskToken.$": "$$.Execution.Input.TaskToken" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_send_task_success.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_send_task_success.json5 new file mode 100644 index 0000000000000..81ba4b82542f7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_send_task_success.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "AWS_SDK_SFN_SEND_TASK_SUCCESS", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:sendTaskSuccess", + "Parameters": { + "Output": "ParameterOutput", + "TaskToken.$": "$$.Execution.Input.TaskToken" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_start_execution.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_start_execution.json5 new file mode 100644 index 0000000000000..c708fd096deeb --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_start_execution.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "AWS_SDK_SFN_START_EXECUTION", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:startExecution", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_start_execution_implicit_json_serialisation.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_start_execution_implicit_json_serialisation.json5 new file mode 100644 index 0000000000000..2c7569ba06f21 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/aws_sdk_sfn_start_execution_implicit_json_serialisation.json5 @@ -0,0 +1,23 @@ +{ + "StartAt": "SetupVariables", + "States": { + "SetupVariables": { + "Type": "Pass", + "Parameters": { + "Input": { + "key": "value" + } + }, + "Next": "StartTarget" + }, + "StartTarget": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sfn:startExecution", + "Parameters": { + "StateMachineArn": "__tbd__", // Field to be replaced dynamically. + "Input.$": "$.Input" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_delete_item.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_delete_item.json5 new file mode 100644 index 0000000000000..3da9640e6861f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_delete_item.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "DYNAMODB_PUT_DELETE_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "ResultPath": "$.putItemOutput", + "Next": "DeleteItem" + }, + "DeleteItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:deleteItem", + "ResultPath": "$.deleteItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_get_item.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_get_item.json5 new file mode 100644 index 0000000000000..7b498d4b706af --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_get_item.json5 @@ -0,0 +1,26 @@ +{ + "Comment": "DYNAMODB_PUT_GET_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "ResultPath": "$.putItemOutput", + "Next": "GetItem" + }, + "GetItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:getItem", + "ResultPath": "$.getItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_query.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_query.json5 new file mode 100644 index 0000000000000..9bfbc8f93281f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_query.json5 @@ -0,0 +1,30 @@ +{ + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "ResultPath": "$.putItemOutput", + "Next": "QueryItems" + }, + "QueryItems": { + "Type": "Task", + // Use aws-sdk for the query call: see AWS's limitations + // of the ddb optimised service integration. + "Resource": "arn:aws:states:::aws-sdk:dynamodb:query", + "ResultPath": "$.queryOutput", + "Parameters": { + "TableName.$": "$.TableName", + "KeyConditionExpression": "id = :id", + "ExpressionAttributeValues": { + ":id.$": "$.Item.id" + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_update_get_item.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_update_get_item.json5 new file mode 100644 index 0000000000000..900edb9f4f875 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/dynamodb_put_update_get_item.json5 @@ -0,0 +1,39 @@ +{ + "Comment": "DYNAMODB_PUT_UPDATE_GET_ITEM", + "StartAt": "PutItem", + "States": { + "PutItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:putItem", + "Parameters": { + "TableName.$": "$.TableName", + "Item.$": "$.Item" + }, + "ResultPath": "$.putItemOutput", + "Next": "UpdateItem" + }, + "UpdateItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:updateItem", + "ResultPath": "$.updateItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key", + "UpdateExpression.$": "$.UpdateExpression", + "ExpressionAttributeValues.$": "$.ExpressionAttributeValues", + "ReturnValues": "UPDATED_NEW", + }, + "Next": "GetItem" + }, + "GetItem": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:getItem", + "ResultPath": "$.getItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "Key.$": "$.Key" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/events_put_events.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/events_put_events.json5 new file mode 100644 index 0000000000000..6f6e89e384b85 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/events_put_events.json5 @@ -0,0 +1,14 @@ +{ + "Comment": "EVENTS_PUT_EVENTS", + "StartAt": "PutEvents", + "States": { + "PutEvents": { + "Type": "Task", + "Resource": "arn:aws:states:::events:putEvents", + "Parameters": { + "Entries.$": "$.Entries" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/invalid_integration_dynamodb_query.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/invalid_integration_dynamodb_query.json5 new file mode 100644 index 0000000000000..ab28d80b7ed39 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/invalid_integration_dynamodb_query.json5 @@ -0,0 +1,20 @@ +{ + "StartAt": "Query", + "States": { + "Query": { + "Type": "Task", + "Resource": "arn:aws:states:::dynamodb:query", + "ResultPath": "$.queryItemOutput", + "Parameters": { + "TableName.$": "$.TableName", + "KeyConditionExpression": "id = :id", + "ExpressionAttributeValues": { + ":id": { + "S.$": "$.Item.id.S" + } + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_input_parameters_filter.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_input_parameters_filter.json5 new file mode 100644 index 0000000000000..2f06e9a8eb83b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_input_parameters_filter.json5 @@ -0,0 +1,23 @@ +{ + "Comment": "LAMBDA_INPUT_PARAMETERS_FILTER", + "StartAt": "StartCheckLoop", + "States": { + "StartCheckLoop": { + "Type": "Pass", + "Result": { + "count": 0 + }, + "ResultPath": "$.loop", + "Next": "CheckComplete" + }, + "CheckComplete": { + "Type": "Task", + "Resource": "_TBD_", + "Parameters": { + "loop.$": "$.loop" + }, + "ResultPath": "$.result", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke.json5 new file mode 100644 index 0000000000000..875ed6d6e0b6a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke.json5 @@ -0,0 +1,20 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_INVOKE", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "Next": "EndWithFinal" + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_log_type.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_log_type.json5 new file mode 100644 index 0000000000000..f08a638aa86ca --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_log_type.json5 @@ -0,0 +1,21 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_INVOKE_LOG_TYPE", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + "LogType.$": "$.LogType" + }, + "Next": "EndWithFinal" + }, + "EndWithFinal": { + "Type": "Pass", + "ResultPath": "$.final", + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_pipe.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_pipe.json5 new file mode 100644 index 0000000000000..52bd01b4fb66e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_pipe.json5 @@ -0,0 +1,17 @@ +{ + "Comment": "LAMBDA_INVOKE_PIPE", + "StartAt": "step1", + "States": { + "step1": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "Next": "step2" + }, + "step2": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "ResultPath": "$.Return", + "End": true, + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_resource.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_resource.json5 new file mode 100644 index 0000000000000..99a7bc7b35b31 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_invoke_resource.json5 @@ -0,0 +1,11 @@ +{ + "Comment": "LAMBDA_INVOKE_RESOURCE", + "StartAt": "step1", + "States": { + "step1": { + "Type": "Task", + "Resource": "__tbd__", // Resource field to be replaced dynamically. + "End": true + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_list_functions.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_list_functions.json5 new file mode 100644 index 0000000000000..9ee2ff7b6267f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/lambda_list_functions.json5 @@ -0,0 +1,12 @@ +{ + "Comment": "TASK_SERVICE_LAMBDA_LIST_FUNCTIONS", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:listFunctions", + "Parameters": {}, + "End": true, + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sfn_start_execution.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sfn_start_execution.json5 new file mode 100644 index 0000000000000..e97123c098e78 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sfn_start_execution.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "SFN_START_EXECUTION", + "StartAt": "StartExecution", + "States": { + "StartExecution": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution", + "Parameters": { + "StateMachineArn.$": "$.StateMachineArn", + "Input.$": "$.Input", + "Name.$": "$.Name" + }, + "End": true, + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sns_fifo_publish.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_fifo_publish.json5 new file mode 100644 index 0000000000000..b6a5c7c8e5bee --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_fifo_publish.json5 @@ -0,0 +1,17 @@ +{ + "Comment": "SNS_FIFO_PUBLISH", + "StartAt": "Publish", + "States": { + "Publish": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "TopicArn.$": "$.TopicArn", + "Message.$": "$.Message", + "MessageGroupId.$": "$.MessageGroupId", + "MessageDeduplicationId.$": "$.MessageDeduplicationId" + }, + "End": true + } + }, +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sns_fifo_publish_fail.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_fifo_publish_fail.json5 new file mode 100644 index 0000000000000..dbff27ab9f3bc --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_fifo_publish_fail.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "SNS_FIFO_PUBLISH", + "StartAt": "Publish", + "States": { + "Publish": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "TopicArn.$": "$.TopicArn", + "Message.$": "$.Message", + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sns_publish.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_publish.json5 new file mode 100644 index 0000000000000..a3f2e8cab8090 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_publish.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "SNS_PUBLISH", + "StartAt": "Publish", + "States": { + "Publish": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "TopicArn.$": "$.TopicArn", + "Message.$": "$.Message", + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sns_publish_message_attributes.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_publish_message_attributes.json5 new file mode 100644 index 0000000000000..9cc4c0040de3e --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sns_publish_message_attributes.json5 @@ -0,0 +1,30 @@ +{ + "Comment": "SNS_PUBLISH_MESSAGE_ATTRIBUTES", + "StartAt": "Publish", + "States": { + "Publish": { + "Type": "Task", + "Resource": "arn:aws:states:::sns:publish", + "Parameters": { + "TopicArn.$": "$.TopicArn", + "Message.$": "$.Message", + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue.$": "$.MessageAttributeValue1" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue.$": "$.MessageAttributeValue2" + }, + // Test the parsing of soft-keywords as payload templates key bindings. + "Version": { + "DataType": "String", + "StringValue": "string value literal" + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg.json5 new file mode 100644 index 0000000000000..3b0effcc96f4f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "SQS_SEND_MESSAGE", + "StartAt": "SendSQS", + "States": { + "SendSQS": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody.$": "$.MessageBody" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg_and_wait.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg_and_wait.json5 new file mode 100644 index 0000000000000..7c210b9566997 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg_and_wait.json5 @@ -0,0 +1,23 @@ +{ + "Comment": "SQS_SEND_MESSAGE_AND_WAIT", + "StartAt": "SendSQS", + "States": { + "SendSQS": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message": "SQS_SEND_MESSAGE_AND_WAIT", + "TaskToken.$": "$.TaskToken" + } + }, + "Next": "WaitState" + }, + "WaitState": { + "Type": "Wait", + "Seconds": 60, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg_attributes.json5 b/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg_attributes.json5 new file mode 100644 index 0000000000000..75cddfd4aefbf --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/services/statemachines/sqs_send_msg_attributes.json5 @@ -0,0 +1,25 @@ +{ + "Comment": "SQS_SEND_MESSAGE_ATTRIBUTES", + "StartAt": "SendSQS", + "States": { + "SendSQS": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody.$": "$.Message", + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue.$": "$.MessageAttributeValue1" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue.$": "$.MessageAttributeValue2" + } + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/statevariables/__init__.py b/tests/aws/services/stepfunctions/templates/statevariables/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/statevariables/state_variables_template.py b/tests/aws/services/stepfunctions/templates/statevariables/state_variables_template.py new file mode 100644 index 0000000000000..879236180ac0c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/state_variables_template.py @@ -0,0 +1,57 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class StateVariablesTemplate(TemplateLoader): + LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER = "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%" + + TASK_CATCH_ERROR_OUTPUT_TO_JSONPATH = os.path.join( + _THIS_FOLDER, "statemachines/task_catch_error_output_to_jsonpath.json5" + ) + TASK_CATCH_ERROR_OUTPUT = os.path.join( + _THIS_FOLDER, "statemachines/task_catch_error_output.json5" + ) + + TASK_CATCH_ERROR_VARIABLE_SAMPLING_TO_JSONPATH = os.path.join( + _THIS_FOLDER, "statemachines/task_catch_error_variable_sampling_to_jsonpath.json5" + ) + + TASK_CATCH_ERROR_VARIABLE_SAMPLING = os.path.join( + _THIS_FOLDER, "statemachines/task_catch_error_variable_sampling.json5" + ) + + TASK_CATCH_ERROR_OUTPUT_WITH_RETRY_TO_JSONPATH = os.path.join( + _THIS_FOLDER, "statemachines/task_catch_error_output_with_retry_to_jsonpath.json5" + ) + + TASK_CATCH_ERROR_OUTPUT_WITH_RETRY = os.path.join( + _THIS_FOLDER, "statemachines/task_catch_error_output_with_retry.json5" + ) + + MAP_CATCH_ERROR_OUTPUT = os.path.join( + _THIS_FOLDER, "statemachines/map_catch_error_output.json5" + ) + + MAP_CATCH_ERROR_OUTPUT_WITH_RETRY = os.path.join( + _THIS_FOLDER, "statemachines/map_catch_error_output_with_retry.json5" + ) + + MAP_CATCH_ERROR_VARIABLE_SAMPLING = os.path.join( + _THIS_FOLDER, "statemachines/map_catch_error_variable_sampling.json5" + ) + + PARALLEL_CATCH_ERROR_OUTPUT = os.path.join( + _THIS_FOLDER, "statemachines/parallel_catch_error_output.json5" + ) + + PARALLEL_CATCH_ERROR_VARIABLE_SAMPLING = os.path.join( + _THIS_FOLDER, "statemachines/parallel_catch_error_variable_sampling.json5" + ) + + PARALLEL_CATCH_ERROR_OUTPUT_WITH_RETRY = os.path.join( + _THIS_FOLDER, "statemachines/parallel_catch_error_output_with_retry.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_output.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_output.json5 new file mode 100644 index 0000000000000..1b39a4b8abc47 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_output.json5 @@ -0,0 +1,62 @@ +{ + "Comment": "MAP_CATCH_ERROR_OUTPUT", + "QueryLanguage": "JSONata", + "StartAt": "ProcessItems", + "States": { + "ProcessItems": { + "Type": "Map", + "Items": "{% $states.input.items %}", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "ProcessItem", + "States": { + "ProcessItem": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateResult": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_output_with_retry.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_output_with_retry.json5 new file mode 100644 index 0000000000000..c65162ca211a0 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_output_with_retry.json5 @@ -0,0 +1,71 @@ +{ + "Comment": "MAP_CATCH_ERROR_OUTPUT_WITH_RETRY", + "QueryLanguage": "JSONata", + "StartAt": "ProcessItems", + "States": { + "ProcessItems": { + "Type": "Map", + "Items": "{% $states.input.items %}", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "ProcessItem", + "States": { + "ProcessItem": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 1 + } + ], + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateResult": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "Output": { + "error": "{% $stateError %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "Output": { + "result": "{% $stateResult %}" + }, + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_variable_sampling.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_variable_sampling.json5 new file mode 100644 index 0000000000000..60e79a92aadb1 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/map_catch_error_variable_sampling.json5 @@ -0,0 +1,62 @@ +{ + "Comment": "MAP_CATCH_ERROR_VARIABLE_SAMPLING", + "QueryLanguage": "JSONata", + "StartAt": "ProcessItems", + "States": { + "ProcessItems": { + "Type": "Map", + "Items": "{% $states.input.items %}", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "ProcessItem", + "States": { + "ProcessItem": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Assign": { + "inputVar": "{% $states.input %}", + "errorVar": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Assign": { + "inputVar": "{% $states.input %}", + "resultVar": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "Assign": { + "error": "{% $errorVar %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "Assign": { + "result": "{% $resultVar %}" + }, + "End": true + } + } + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_output.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_output.json5 new file mode 100644 index 0000000000000..c55729d533678 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_output.json5 @@ -0,0 +1,59 @@ +{ + "Comment": "PARALLEL_CATCH_ERROR_OUTPUT", + "QueryLanguage": "JSONata", + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Next": "Finish", + "Branches": [ + { + "StartAt": "ExecuteLambdaTask", + "States": { + "ExecuteLambdaTask": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Output": { + "InnerStateInput": "{% $states.input %}", + "InnerStateResult": "{% $states.result %}" + }, + "End": true + } + } + } + ], + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ] + }, + "Fallback": { + "Type": "Pass", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_output_with_retry.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_output_with_retry.json5 new file mode 100644 index 0000000000000..3a5937656278d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_output_with_retry.json5 @@ -0,0 +1,68 @@ +{ + "Comment": "PARALLEL_CATCH_ERROR_OUTPUT_WITH_RETRY", + "QueryLanguage": "JSONata", + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Next": "Finish", + "Branches": [ + { + "StartAt": "ExecuteLambdaTask", + "States": { + "ExecuteLambdaTask": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Output": { + "InnerStateInput": "{% $states.input %}", + "InnerStateResult": "{% $states.result %}" + }, + "End": true + } + } + } + ], + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 1 + } + ], + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ] + }, + "Fallback": { + "Type": "Pass", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_variable_sampling.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_variable_sampling.json5 new file mode 100644 index 0000000000000..133ba120eaed1 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/parallel_catch_error_variable_sampling.json5 @@ -0,0 +1,59 @@ +{ + "Comment": "PARALLEL_CATCH_ERROR_VARIABLE_SAMPLING", + "QueryLanguage": "JSONata", + "StartAt": "ParallelState", + "States": { + "ParallelState": { + "Type": "Parallel", + "Next": "Finish", + "Branches": [ + { + "StartAt": "ExecuteLambdaTask", + "States": { + "ExecuteLambdaTask": { + "Assign": { + "inputVar": "{% $states.input %}", + "resultVar": "{% $states.result %}" + }, + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "End": true + } + } + } + ], + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Assign": { + "inputVar": "{% $states.input %}", + "errorVar": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ] + }, + "Fallback": { + "Type": "Pass", + "Output": { + "error": "{% $errorVar %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "Output": { + "result": "{% $resultVar %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output.json5 new file mode 100644 index 0000000000000..202be17982020 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output.json5 @@ -0,0 +1,50 @@ +{ + "Comment": "TASK_CATCH_ERROR_OUTPUT", + "StartAt": "Task", + "States": { + "Task": { + "Type": "Task", + "QueryLanguage": "JSONata", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateResult": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "QueryLanguage": "JSONata", + "Output": { + "error": "{% $states.input %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "QueryLanguage": "JSONata", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_to_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_to_jsonpath.json5 new file mode 100644 index 0000000000000..d36dbf402cb85 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_to_jsonpath.json5 @@ -0,0 +1,42 @@ +{ + "Comment": "TASK_CATCH_ERROR_OUTPUT_TO_JSONPATH", + "StartAt": "Task", + "States": { + "Task": { + "Type": "Task", + "QueryLanguage": "JSONata", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateResult": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "End": true + }, + "Finish": { + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_with_retry.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_with_retry.json5 new file mode 100644 index 0000000000000..cdb99df12e689 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_with_retry.json5 @@ -0,0 +1,59 @@ +{ + "Comment": "TASK_CATCH_ERROR_OUTPUT_WITH_RETRY", + "StartAt": "Task", + "States": { + "Task": { + "Type": "Task", + "QueryLanguage": "JSONata", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 1 + } + ], + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateResult": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "QueryLanguage": "JSONata", + "Output": { + "error": "{% $states.input %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "QueryLanguage": "JSONata", + "Output": { + "result": "{% $states.input %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_with_retry_to_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_with_retry_to_jsonpath.json5 new file mode 100644 index 0000000000000..62d6e3aa6d9be --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_output_with_retry_to_jsonpath.json5 @@ -0,0 +1,51 @@ +{ + "Comment": "TASK_CATCH_ERROR_OUTPUT_WITH_RETRY_TO_JSONPATH", + "StartAt": "Task", + "States": { + "Task": { + "Type": "Task", + "QueryLanguage": "JSONata", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Retry": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "IntervalSeconds": 1, + "MaxAttempts": 1 + } + ], + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateError": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Output": { + "stateInput": "{% $states.input %}", + "stateResult": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "End": true + }, + "Finish": { + "Type": "Pass", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_variable_sampling.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_variable_sampling.json5 new file mode 100644 index 0000000000000..b443b83559e5d --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_variable_sampling.json5 @@ -0,0 +1,48 @@ +{ + "Comment": "TASK_CATCH_ERROR_VARIABLE_SAMPLING", + "QueryLanguage": "JSONata", + "StartAt": "Task", + "States": { + "Task": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Assign": { + "inputVar": "{% $states.input %}", + "errorVar": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Assign": { + "inputVar": "{% $states.input %}", + "resultVar": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "Output": { + "error": "{% $errorVar %}" + }, + "End": true + }, + "Finish": { + "Type": "Pass", + "Output": { + "result": "{% $resultVar %}" + }, + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_variable_sampling_to_jsonpath.json5 b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_variable_sampling_to_jsonpath.json5 new file mode 100644 index 0000000000000..c5a7aac386044 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/statevariables/statemachines/task_catch_error_variable_sampling_to_jsonpath.json5 @@ -0,0 +1,44 @@ +{ + "Comment": "TASK_CATCH_ERROR_VARIABLE_SAMPLING_TO_JSONPATH", + "StartAt": "Task", + "States": { + "Task": { + "Type": "Task", + "QueryLanguage": "JSONata", + "Resource": "arn:aws:states:::lambda:invoke", + "Arguments": { + "FunctionName": "%LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER%", + "Payload": { + "foo": "foobar" + } + }, + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Assign": { + "inputVar": "{% $states.input %}", + "errorVar": "{% $states.errorOutput %}" + }, + "Next": "Fallback" + } + ], + "Assign": { + "inputVar": "{% $states.input %}", + "resultVar": "{% $states.result %}" + }, + "Next": "Finish" + }, + "Fallback": { + "Type": "Pass", + "OutputPath": "$errorVar", + "End": true + }, + "Finish": { + "Type": "Pass", + "OutputPath": "$resultVar", + "End": true + } + } +} \ No newline at end of file diff --git a/tests/aws/services/stepfunctions/templates/template_loader.py b/tests/aws/services/stepfunctions/templates/template_loader.py new file mode 100644 index 0000000000000..d7e30ac48f4d8 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/template_loader.py @@ -0,0 +1,18 @@ +import abc +import copy +from typing import Final + +import json5 + +_LOAD_CACHE: Final[dict[str, dict]] = dict() + + +class TemplateLoader(abc.ABC): + @staticmethod + def load_sfn_template(file_path: str) -> dict: + template = _LOAD_CACHE.get(file_path) + if template is None: + with open(file_path, "r") as df: + template = json5.load(df) + _LOAD_CACHE[file_path] = template + return copy.deepcopy(template) diff --git a/tests/aws/services/stepfunctions/templates/test_state/__init__.py b/tests/aws/services/stepfunctions/templates/test_state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_choice_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_choice_state.json5 new file mode 100644 index 0000000000000..831c39d41158b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_choice_state.json5 @@ -0,0 +1,31 @@ +{ + "Type": "Choice", + "Choices": [ + { + "Not": { + "Variable": "$.type", + "StringEquals": "Private" + }, + "Next": "Public" + }, + { + "Variable": "$.value", + "NumericEquals": 0, + "Next": "ValueIsZero" + }, + { + "And": [ + { + "Variable": "$.value", + "NumericGreaterThanEquals": 20 + }, + { + "Variable": "$.value", + "NumericLessThan": 30 + } + ], + "Next": "ValueInTwenties" + } + ], + "Default": "DefaultState" +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_fail_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_fail_state.json5 new file mode 100644 index 0000000000000..0ffdf1f6c40df --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_fail_state.json5 @@ -0,0 +1,5 @@ +{ + "Type": "Fail", + "Error": "SomeFailure", + "Cause": "This state machines raises a 'SomeFailure' failure." +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_lambda_service_task_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_lambda_service_task_state.json5 new file mode 100644 index 0000000000000..ca37fca1a5f41 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_lambda_service_task_state.json5 @@ -0,0 +1,9 @@ +{ + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload" + }, + "End": true, +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_lambda_task_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_lambda_task_state.json5 new file mode 100644 index 0000000000000..a3494c8c354bf --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_lambda_task_state.json5 @@ -0,0 +1,5 @@ +{ + "Type": "Task", + "Resource": "__tbd__", + "End": true, +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_pass_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_pass_state.json5 new file mode 100644 index 0000000000000..ba1658ee703de --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_pass_state.json5 @@ -0,0 +1,4 @@ +{ + "Type": "Pass", + "End": true, +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_result_pass_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_result_pass_state.json5 new file mode 100644 index 0000000000000..0fb258fc8e9bc --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_result_pass_state.json5 @@ -0,0 +1,7 @@ +{ + "Type": "Pass", + "Result": { + "resultKey": "result value" + }, + "End": true, +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_succeed_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_succeed_state.json5 new file mode 100644 index 0000000000000..a0f3411efdd35 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_succeed_state.json5 @@ -0,0 +1,3 @@ +{ + "Type": "Succeed", +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_wait_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_wait_state.json5 new file mode 100644 index 0000000000000..a38b9e41e2fb1 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/base_wait_state.json5 @@ -0,0 +1,5 @@ +{ + "Type": "Wait", + "Seconds": 1, + "End": true, +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_lambda_service_task_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_lambda_service_task_state.json5 new file mode 100644 index 0000000000000..91c2777a1dc5a --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_lambda_service_task_state.json5 @@ -0,0 +1,17 @@ +{ + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "InputPath": "$.inputPathField", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload": { + "Input.$": "$", + "AdditionalParam": "Value" + } + }, + "ResultSelector": { + "LambdaOutput.$": "$" + }, + "ResultPath": "$.resultPathField", + "End": true, +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_pass_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_pass_state.json5 new file mode 100644 index 0000000000000..bfe343493868b --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_pass_state.json5 @@ -0,0 +1,10 @@ +{ + "Type": "Pass", + "InputPath": "$.initialData", + "Parameters": { + "staticValue": "some value", + "inputValue.$": "$.fieldFromInput" + }, + "ResultPath": "$.modifiedData", + "End": true +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_result_pass_state.json5 b/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_result_pass_state.json5 new file mode 100644 index 0000000000000..128d861017955 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/statemachines/io_result_pass_state.json5 @@ -0,0 +1,13 @@ +{ + "Type": "Pass", + "InputPath": "$.initialData", + "Parameters": { + "staticValue": "some value", + "inputValue.$": "$.fieldFromInput" + }, + "Result": { + "resultKey": "result value" + }, + "ResultPath": "$.modifiedData", + "End": true +} diff --git a/tests/aws/services/stepfunctions/templates/test_state/test_state_templates.py b/tests/aws/services/stepfunctions/templates/test_state/test_state_templates.py new file mode 100644 index 0000000000000..18b5aa888ffd2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/test_state/test_state_templates.py @@ -0,0 +1,35 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class TestStateTemplate(TemplateLoader): + BASE_FAIL_STATE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/base_fail_state.json5") + BASE_SUCCEED_STATE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_succeed_state.json5" + ) + BASE_WAIT_STATE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/base_wait_state.json5") + BASE_PASS_STATE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/base_pass_state.json5") + BASE_CHOICE_STATE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_choice_state.json5" + ) + BASE_RESULT_PASS_STATE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_result_pass_state.json5" + ) + IO_PASS_STATE: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/io_pass_state.json5") + IO_RESULT_PASS_STATE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/io_result_pass_state.json5" + ) + + BASE_LAMBDA_TASK_STATE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_lambda_task_state.json5" + ) + BASE_LAMBDA_SERVICE_TASK_STATE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/base_lambda_service_task_state.json5" + ) + IO_LAMBDA_SERVICE_TASK_STATE: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/io_lambda_service_task_state.json5" + ) diff --git a/tests/aws/services/stepfunctions/templates/timeouts/__init__.py b/tests/aws/services/stepfunctions/templates/timeouts/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/timeouts/lambdafunctions/__init__.py b/tests/aws/services/stepfunctions/templates/timeouts/lambdafunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/timeouts/lambdafunctions/wait_60_seconds.py b/tests/aws/services/stepfunctions/templates/timeouts/lambdafunctions/wait_60_seconds.py new file mode 100644 index 0000000000000..6f794b88d5c14 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/lambdafunctions/wait_60_seconds.py @@ -0,0 +1,6 @@ +import time + + +def handler(event, context): + time.sleep(60) + return event diff --git a/tests/aws/services/stepfunctions/templates/timeouts/statemachines/lambda_wait_with_timeout_seconds.json5 b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/lambda_wait_with_timeout_seconds.json5 new file mode 100644 index 0000000000000..1fc550391c068 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/lambda_wait_with_timeout_seconds.json5 @@ -0,0 +1,15 @@ +{ + "Comment": "LAMBDA_WAIT_WITH_TIMEOUT_SECONDS", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "TimeoutSeconds": 5, + "Resource": "__tbc__", + "Parameters": { + "Payload.$": "$.Payload", + }, + "End": true + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_map_function_invoke_with_timeout_seconds.json5 b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_map_function_invoke_with_timeout_seconds.json5 new file mode 100644 index 0000000000000..e6d72c026d1a7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_map_function_invoke_with_timeout_seconds.json5 @@ -0,0 +1,29 @@ +{ + "Comment": "SERVICE_LAMBDA_MAP_FUNCTION_INVOKE_WITH_TIMEOUT_SECONDS_5", + "StartAt": "MapWait", + "States": { + "MapWait": { + "Type": "Map", + "InputPath": "$.Inputs", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "LambdaInvoke", + "States": { + "LambdaInvoke": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "TimeoutSeconds": 5, + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "End": true + } + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_wait_with_timeout_seconds.json5 b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_wait_with_timeout_seconds.json5 new file mode 100644 index 0000000000000..cf5eb73a2f8f7 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_wait_with_timeout_seconds.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "TIMEOUT_ON_TASK_SERVICE_LAMBDA_INVOKE", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "TimeoutSeconds": 5, + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload", + }, + "End": true + }, + }, +} diff --git a/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_wait_with_timeout_seconds_path.json5 b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_wait_with_timeout_seconds_path.json5 new file mode 100644 index 0000000000000..64ddb218c4315 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_lambda_wait_with_timeout_seconds_path.json5 @@ -0,0 +1,16 @@ +{ + "Comment": "TIMEOUT_ON_TASK_SERVICE_LAMBDA_INVOKE_WITH_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "TimeoutSecondsPath": "$.TimeoutSecondsValue", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.FunctionName", + "Payload.$": "$.Payload" + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat.json5 b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat.json5 new file mode 100644 index 0000000000000..4683be431389f --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat.json5 @@ -0,0 +1,20 @@ +{ + "Comment": "SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT", + "StartAt": "SendMessageWithWait", + "States": { + "SendMessageWithWait": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "TimeoutSeconds": 600, + "HeartbeatSeconds": 5, + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message.$": "$.Message", + "TaskToken.$": "$$.Task.Token" + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat_path.json5 b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat_path.json5 new file mode 100644 index 0000000000000..c0b519b560fec --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat_path.json5 @@ -0,0 +1,20 @@ +{ + "Comment": "SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT_PATH", + "StartAt": "SendMessageWithWait", + "States": { + "SendMessageWithWait": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "TimeoutSeconds": 600, + "HeartbeatSecondsPath": "$.HeartbeatSecondsPath", + "Parameters": { + "QueueUrl.$": "$.QueueUrl", + "MessageBody": { + "Message.$": "$.Message", + "TaskToken.$": "$$.Task.Token" + } + }, + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/timeouts/timeout_templates.py b/tests/aws/services/stepfunctions/templates/timeouts/timeout_templates.py new file mode 100644 index 0000000000000..419bdde2c938c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/timeouts/timeout_templates.py @@ -0,0 +1,35 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class TimeoutTemplates(TemplateLoader): + # State Machines. + LAMBDA_WAIT_WITH_TIMEOUT_SECONDS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/lambda_wait_with_timeout_seconds.json5" + ) + SERVICE_LAMBDA_WAIT_WITH_TIMEOUT_SECONDS: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/service_lambda_wait_with_timeout_seconds.json5" + ) + SERVICE_LAMBDA_MAP_FUNCTION_INVOKE_WITH_TIMEOUT_SECONDS: Final[str] = os.path.join( + _THIS_FOLDER, + "statemachines/service_lambda_map_function_invoke_with_timeout_seconds.json5", + ) + SERVICE_LAMBDA_MAP_FUNCTION_INVOKE_WITH_TIMEOUT_SECONDS_PATH: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/service_lambda_wait_with_timeout_seconds_path.json5" + ) + SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat.json5" + ) + SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT_PATH: Final[str] = os.path.join( + _THIS_FOLDER, + "statemachines/service_sqs_send_and_wait_for_task_token_with_heartbeat_path.json5", + ) + + # Lambda Functions. + LAMBDA_WAIT_60_SECONDS: Final[str] = os.path.join( + _THIS_FOLDER, "lambdafunctions/wait_60_seconds.py" + ) diff --git a/tests/aws/services/stepfunctions/templates/validation/__init__.py b/tests/aws/services/stepfunctions/templates/validation/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/templates/validation/statemachines/invalid_base_no_startat.json5 b/tests/aws/services/stepfunctions/templates/validation/statemachines/invalid_base_no_startat.json5 new file mode 100644 index 0000000000000..84baf804a8ba2 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/validation/statemachines/invalid_base_no_startat.json5 @@ -0,0 +1,8 @@ +{ + "States": { + "StartState": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/validation/statemachines/valid_base_pass.json5 b/tests/aws/services/stepfunctions/templates/validation/statemachines/valid_base_pass.json5 new file mode 100644 index 0000000000000..50f6679db6b98 --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/validation/statemachines/valid_base_pass.json5 @@ -0,0 +1,9 @@ +{ + "StartAt": "StartState", + "States": { + "StartState": { + "Type": "Pass", + "End": true + } + } +} diff --git a/tests/aws/services/stepfunctions/templates/validation/validation_templates.py b/tests/aws/services/stepfunctions/templates/validation/validation_templates.py new file mode 100644 index 0000000000000..ec4243c08e07c --- /dev/null +++ b/tests/aws/services/stepfunctions/templates/validation/validation_templates.py @@ -0,0 +1,13 @@ +import os +from typing import Final + +from tests.aws.services.stepfunctions.templates.template_loader import TemplateLoader + +_THIS_FOLDER: Final[str] = os.path.dirname(os.path.realpath(__file__)) + + +class ValidationTemplate(TemplateLoader): + INVALID_BASE_NO_STARTAT: Final[str] = os.path.join( + _THIS_FOLDER, "statemachines/invalid_base_no_startat.json5" + ) + VALID_BASE_PASS: Final[str] = os.path.join(_THIS_FOLDER, "statemachines/valid_base_pass.json5") diff --git a/tests/aws/services/stepfunctions/v2/__init__.py b/tests/aws/services/stepfunctions/v2/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/activities/__init__.py b/tests/aws/services/stepfunctions/v2/activities/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/activities/test_activities.py b/tests/aws/services/stepfunctions/v2/activities/test_activities.py new file mode 100644 index 0000000000000..342a9d2ea8b06 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/activities/test_activities.py @@ -0,0 +1,233 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.activities.activity_templates import ( + ActivityTemplate, +) + + +class TestActivities: + @markers.aws.validated + def test_activity_task( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_activity, + sfn_activity_consumer, + sfn_snapshot, + ): + activity_name = f"activity-{short_uid()}" + create_activity_output = create_activity(name=activity_name) + activity_arn = create_activity_output["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_output", create_activity_output) + + sfn_activity_consumer( + template=ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ID_ACTIVITY_CONSUMER), + activity_arn=activity_arn, + ) + + template = ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ACTIVITY_TASK) + template["States"]["ActivityTask"]["Resource"] = activity_arn + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_activity_task_no_worker_name( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_activity, + sfn_activity_consumer, + sfn_snapshot, + ): + activity_name = f"activity-{short_uid()}" + create_activity_output = create_activity(name=activity_name) + activity_arn = create_activity_output["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_output", create_activity_output) + + template_consumer = ActivityTemplate.load_sfn_template( + ActivityTemplate.BASE_ID_ACTIVITY_CONSUMER + ) + del template_consumer["States"]["GetActivityTask"]["Parameters"]["WorkerName"] + sfn_activity_consumer(template=template_consumer, activity_arn=activity_arn) + + template = ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ACTIVITY_TASK) + template["States"]["ActivityTask"]["Resource"] = activity_arn + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_activity_task_on_deleted( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_activity, + sfn_snapshot, + ): + activity_name = f"activity-{short_uid()}" + create_activity_output = create_activity(name=activity_name) + activity_arn = create_activity_output["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_output", create_activity_output) + + aws_client.stepfunctions.delete_activity(activityArn=activity_arn) + + template = ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ACTIVITY_TASK) + template["States"]["ActivityTask"]["Resource"] = activity_arn + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_activity_task_failure( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_activity, + sfn_activity_consumer, + sfn_snapshot, + ): + activity_name = f"activity-{short_uid()}" + create_activity_output = create_activity(name=activity_name) + activity_arn = create_activity_output["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_output", create_activity_output) + + sfn_activity_consumer( + template=ActivityTemplate.load_sfn_template( + ActivityTemplate.BASE_ID_ACTIVITY_CONSUMER_FAIL + ), + activity_arn=activity_arn, + ) + + template = ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ACTIVITY_TASK) + template["States"]["ActivityTask"]["Resource"] = activity_arn + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_activity_task_with_heartbeat( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_activity, + sfn_activity_consumer, + sfn_snapshot, + ): + activity_name = f"activity-{short_uid()}" + create_activity_output = create_activity(name=activity_name) + activity_arn = create_activity_output["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_output", create_activity_output) + + sfn_activity_consumer( + template=ActivityTemplate.load_sfn_template( + ActivityTemplate.HEARTBEAT_ID_ACTIVITY_CONSUMER + ), + activity_arn=activity_arn, + ) + + template = ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ACTIVITY_TASK_HEARTBEAT) + template["States"]["ActivityTask"]["Resource"] = activity_arn + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_activity_task_start_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_activity, + sfn_activity_consumer, + sfn_snapshot, + ): + activity_name = f"activity-{short_uid()}" + create_activity_output = create_activity(name=activity_name) + activity_arn = create_activity_output["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_output", create_activity_output) + + sfn_activity_consumer( + template=ActivityTemplate.load_sfn_template( + ActivityTemplate.BASE_ID_ACTIVITY_CONSUMER_TIMEOUT + ), + activity_arn=activity_arn, + ) + + template = ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ACTIVITY_TASK_TIMEOUT) + template["States"]["ActivityTask"]["Resource"] = activity_arn + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/activities/test_activities.snapshot.json b/tests/aws/services/stepfunctions/v2/activities/test_activities.snapshot.json new file mode 100644 index 0000000000000..2ba9c3bd4372d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/activities/test_activities.snapshot.json @@ -0,0 +1,610 @@ +{ + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task": { + "recorded-date": "17-03-2024, 11:09:25", + "recorded-content": { + "create_activity_output": { + "activityArn": "activity_arn", + "creationDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ActivityTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "activityScheduledEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "resource": "activity_arn" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ActivityScheduled" + }, + { + "activityStartedEventDetails": { + "workerName": "BASE_ID_ACTIVITY_CONSUMER" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ActivityStarted" + }, + { + "activitySucceededEventDetails": { + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ActivitySucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ActivityTask", + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_failure": { + "recorded-date": "17-03-2024, 11:10:40", + "recorded-content": { + "create_activity_output": { + "activityArn": "activity_arn", + "creationDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ActivityTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "activityScheduledEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "resource": "activity_arn" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ActivityScheduled" + }, + { + "activityStartedEventDetails": { + "workerName": "BASE_ID_ACTIVITY_CONSUMER" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ActivityStarted" + }, + { + "activityFailedEventDetails": { + "cause": { + "Value1": "HelloWorld" + }, + "error": "Programmatic Error" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ActivityFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "Value1": "HelloWorld" + }, + "error": "Programmatic Error" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_with_heartbeat": { + "recorded-date": "17-03-2024, 11:11:13", + "recorded-content": { + "create_activity_output": { + "activityArn": "activity_arn", + "creationDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ActivityTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "activityScheduledEventDetails": { + "heartbeatInSeconds": 10, + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "resource": "activity_arn", + "timeoutInSeconds": 60 + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ActivityScheduled" + }, + { + "activityStartedEventDetails": { + "workerName": "BASE_ID_ACTIVITY_CONSUMER" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ActivityStarted" + }, + { + "activitySucceededEventDetails": { + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ActivitySucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ActivityTask", + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_on_deleted": { + "recorded-date": "17-03-2024, 11:10:13", + "recorded-content": { + "create_activity_output": { + "activityArn": "activity_arn", + "creationDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ActivityTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'ActivityTask' (entered at the event id #2). The activity activity_arn does not exist.", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_no_worker_name": { + "recorded-date": "17-03-2024, 11:09:58", + "recorded-content": { + "create_activity_output": { + "activityArn": "activity_arn", + "creationDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ActivityTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "activityScheduledEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "resource": "activity_arn" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ActivityScheduled" + }, + { + "activityStartedEventDetails": {}, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ActivityStarted" + }, + { + "activitySucceededEventDetails": { + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ActivitySucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ActivityTask", + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Value1": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_start_timeout": { + "recorded-date": "17-03-2024, 11:48:21", + "recorded-content": { + "create_activity_output": { + "activityArn": "activity_arn", + "creationDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ActivityTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "activityScheduledEventDetails": { + "input": { + "Value1": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "resource": "activity_arn", + "timeoutInSeconds": 5 + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ActivityScheduled" + }, + { + "activityStartedEventDetails": { + "workerName": "BASE_ID_ACTIVITY_CONSUMER" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ActivityStarted" + }, + { + "activityTimedOutEventDetails": { + "error": "States.Timeout" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ActivityTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/activities/test_activities.validation.json b/tests/aws/services/stepfunctions/v2/activities/test_activities.validation.json new file mode 100644 index 0000000000000..e1e8cb97d015f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/activities/test_activities.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task": { + "last_validated_date": "2024-03-17T11:09:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_failure": { + "last_validated_date": "2024-03-17T11:10:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_no_worker_name": { + "last_validated_date": "2024-03-17T11:09:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_on_deleted": { + "last_validated_date": "2024-03-17T11:10:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_start_timeout": { + "last_validated_date": "2024-03-17T11:48:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/activities/test_activities.py::TestActivities::test_activity_task_with_heartbeat": { + "last_validated_date": "2024-03-17T11:11:13+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/arguments/__init__.py b/tests/aws/services/stepfunctions/v2/arguments/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/arguments/test_arguments.py b/tests/aws/services/stepfunctions/v2/arguments/test_arguments.py new file mode 100644 index 0000000000000..167917219bed9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/arguments/test_arguments.py @@ -0,0 +1,72 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.arguments.arguments_templates import ( + ArgumentTemplates, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as SerT, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..redriveCount", + "$..redriveStatus", + "$..RedriveCount", + ] +) +class TestArgumentsBase: + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + ArgumentTemplates.BASE_LAMBDA_EMPTY, + ArgumentTemplates.BASE_LAMBDA_LITERALS, + ArgumentTemplates.BASE_LAMBDA_EXPRESSION, + ArgumentTemplates.BASE_LAMBDA_EMPTY_GLOBAL_QL_JSONATA, + ], + ids=[ + "BASE_LAMBDA_EMPTY", + "BASE_LAMBDA_LITERALS", + "BASE_LAMBDA_EXPRESSION", + "BASE_LAMBDA_EMPTY_GLOBAL_QL_JSONATA", + ], + ) + def test_base_cases( + self, + sfn_snapshot, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + template_path, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=SerT.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + template = ArgumentTemplates.load_sfn_template(template_path) + template["States"]["State0"]["Resource"] = function_arn + definition = json.dumps(template) + exec_input = json.dumps({"input_value": "string literal", "input_values": [1, 2, 3]}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/arguments/test_arguments.snapshot.json b/tests/aws/services/stepfunctions/v2/arguments/test_arguments.snapshot.json new file mode 100644 index 0000000000000..f5daf9035f309 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/arguments/test_arguments.snapshot.json @@ -0,0 +1,736 @@ +{ + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EMPTY]": { + "recorded-date": "04-11-2024, 11:40:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_name" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "State0", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_LITERALS]": { + "recorded-date": "04-11-2024, 11:41:09", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_name" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EXPRESSION]": { + "recorded-date": "04-11-2024, 11:41:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Init" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "var_constant_1": "1", + "var_input_value": "\"string literal\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Init", + "output": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "lambdaFunctionScheduledEventDetails": { + "input": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_name" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 7, + "lambdaFunctionSucceededEventDetails": { + "output": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "assignedVariables": { + "ja_expr": "7", + "ja_var_access": "\"string literal\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EMPTY_GLOBAL_QL_JSONATA]": { + "recorded-date": "04-11-2024, 11:41:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_name" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "State0", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/arguments/test_arguments.validation.json b/tests/aws/services/stepfunctions/v2/arguments/test_arguments.validation.json new file mode 100644 index 0000000000000..f2e40118cb109 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/arguments/test_arguments.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EMPTY]": { + "last_validated_date": "2024-11-04T11:40:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EMPTY_GLOBAL_QL_JSONATA]": { + "last_validated_date": "2024-11-04T11:41:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_EXPRESSION]": { + "last_validated_date": "2024-11-04T11:41:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/arguments/test_arguments.py::TestArgumentsBase::test_base_cases[BASE_LAMBDA_LITERALS]": { + "last_validated_date": "2024-11-04T11:41:06+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/assign/__init__.py b/tests/aws/services/stepfunctions/v2/assign/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/assign/test_assign_base.py b/tests/aws/services/stepfunctions/v2/assign/test_assign_base.py new file mode 100644 index 0000000000000..6fd59082dc741 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/assign/test_assign_base.py @@ -0,0 +1,125 @@ +import json + +import pytest + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + SfnNoneRecursiveParallelTransformer, + create_and_record_execution, +) +from tests.aws.services.stepfunctions.templates.assign.assign_templates import AssignTemplate + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..redriveCount", + "$..redriveStatus", + "$..RedriveCount", + ] +) +class TestAssignBase: + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + AssignTemplate.BASE_EMPTY, + AssignTemplate.BASE_CONSTANT_LITERALS, + AssignTemplate.BASE_PATHS, + AssignTemplate.BASE_VAR, + AssignTemplate.BASE_SCOPE_MAP, + ], + ids=[ + "BASE_EMPTY", + "BASE_CONSTANT_LITERALS", + "BASE_PATHS", + "BASE_VAR", + "BASE_SCOPE_MAP", + ], + ) + def test_base_cases( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = AssignTemplate.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({"input_value": "input_value_literal"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + [ + # TODO: introduce json response formatting to ensure value compatibility, there are some + # inconsistencies wrt the separators being used and no trivial reusable logic + "$..events..executionSucceededEventDetails.output", + "$..events..stateExitedEventDetails.output", + ] + ) + @pytest.mark.parametrize( + "template_path", + [AssignTemplate.BASE_SCOPE_PARALLEL], + ids=["BASE_SCOPE_PARALLEL"], + ) + def test_base_parallel_cases( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + sfn_snapshot.add_transformer(SfnNoneRecursiveParallelTransformer()) + template = AssignTemplate.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "input_value", + [ + {"condition": True}, + {"condition": False}, + ], + ids=[ + "CONDITION_TRUE", + "CONDITION_FALSE", + ], + ) + def test_assign_in_choice( + self, + sfn_snapshot, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + input_value, + ): + template = AssignTemplate.load_sfn_template(AssignTemplate.CHOICE_CONDITION_JSONATA) + definition = json.dumps(template) + exec_input = json.dumps(input_value) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/assign/test_assign_base.snapshot.json b/tests/aws/services/stepfunctions/v2/assign/test_assign_base.snapshot.json new file mode 100644 index 0000000000000..8ef87eeec4916 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/assign/test_assign_base.snapshot.json @@ -0,0 +1,1239 @@ +{ + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_EMPTY]": { + "recorded-date": "04-11-2024, 11:02:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_CONSTANT_LITERALS]": { + "recorded-date": "04-11-2024, 11:02:43", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "constant_bool": "true", + "constant_float": "0.1", + "constant_int": "0", + "constant_lst": "[null,0,0.1,true,\"$.no.such.path\",\"$varname\",\"{% $varname %}\",\"$$\",\"States.Format('{}', $varname)\",[],{\"constant\":0}]", + "constant_lst_empty": "[]", + "constant_null": "null", + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_str_path_root": "$", + "in_obj_constant_str_path": "$.no.such.path", + "in_obj_constant_str_contextpath_root": "$$", + "in_obj_constant_str_contextpath": "$$.Execution.Id", + "in_obj_constant_str_var": "$noSuchVar", + "in_obj_constant_str_var_expr": "$noSuchVar.noSuchMember", + "in_obj_constant_str_intrinsic_func": "States.Format('Format Func {}', $varname)", + "in_obj_constant_str_jsonata_expr": "{% $varname.varfield %}", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + "$.no.such.path", + "$varname", + "{% $varname %}", + "$$", + "States.Format('{}', $varname)", + [], + { + "constant": 0 + } + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + }, + "constant_obj_empty": {}, + "constant_str": "\"constant string\"", + "constant_str_contextpath": "\"$$.Execution.Id\"", + "constant_str_contextpath_root": "\"$$\"", + "constant_str_intrinsic_func": "\"States.Format('Format Func {}', $varname)\"", + "constant_str_jsonata_expr": "\"{% $varname.varfield %}\"", + "constant_str_path": "\"$.no.such.path\"", + "constant_str_path_root": "\"$\"", + "constant_str_var": "\"$noSuchVar\"", + "constant_str_var_expr": "\"$noSuchVar.noSuchMember\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_PATHS]": { + "recorded-date": "04-11-2024, 11:03:09", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "str_contextpath": "\"arn::states::111111111111:execution::\"", + "str_contextpath_root": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "input_value": "input_value_literal" + }, + "StartTime": "date", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "State0", + "EnteredTime": "date" + } + }, + "str_intrinsic_func": "\"Format Func input_value_literal\"", + "str_path": "\"input_value_literal\"", + "str_path_root": { + "input_value": "input_value_literal" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_VAR]": { + "recorded-date": "04-11-2024, 11:03:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "constant_bool": "true", + "constant_float": "0.1", + "constant_int": "0", + "constant_lst": "[null,0,0.1,true,\"$.no.such.path\",\"$varname\",\"{% $varname %}\",\"$$\",\"States.Format('{}', $varname)\",[],{\"constant\":0}]", + "constant_lst_empty": "[]", + "constant_null": "null", + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_str_path_root": "$", + "in_obj_constant_str_path": "$.no.such.path", + "in_obj_constant_str_contextpath_root": "$$", + "in_obj_constant_str_contextpath": "$$.Execution.Id", + "in_obj_constant_str_var": "$noSuchVar", + "in_obj_constant_str_var_expr": "$noSuchVar.noSuchMember", + "in_obj_constant_str_intrinsic_func": "States.Format('Format Func {}', $varname)", + "in_obj_constant_str_jsonata_expr": "{% $varname.varfield %}", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + "$.no.such.path", + "$varname", + "{% $varname %}", + "$$", + "States.Format('{}', $varname)", + [], + { + "constant": 0 + } + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + }, + "constant_obj_empty": {}, + "constant_str": "\"constant string\"", + "constant_str_contextpath": "\"$$.Execution.Id\"", + "constant_str_contextpath_root": "\"$$\"", + "constant_str_intrinsic_func": "\"States.Format('Format Func {}', $varname)\"", + "constant_str_jsonata_expr": "\"{% $varname.varfield %}\"", + "constant_str_path": "\"$.no.such.path\"", + "constant_str_path_root": "\"$\"", + "constant_str_var": "\"$noSuchVar\"", + "constant_str_var_expr": "\"$noSuchVar.noSuchMember\"", + "varname": "\"input_value_literal\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "assignedVariables": { + "varname2": "\"varname2\"", + "varnameconstant_bool": "true", + "varnameconstant_float": "0.1", + "varnameconstant_int": "0", + "varnameconstant_lst": "[null,0,0.1,true,\"$.no.such.path\",\"$varname\",\"{% $varname %}\",\"$$\",\"States.Format('{}', $varname)\",[],{\"constant\":0}]", + "varnameconstant_lst_empty": "[]", + "varnameconstant_null": "null", + "varnameconstant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_str_path_root": "$", + "in_obj_constant_str_path": "$.no.such.path", + "in_obj_constant_str_contextpath_root": "$$", + "in_obj_constant_str_contextpath": "$$.Execution.Id", + "in_obj_constant_str_var": "$noSuchVar", + "in_obj_constant_str_var_expr": "$noSuchVar.noSuchMember", + "in_obj_constant_str_intrinsic_func": "States.Format('Format Func {}', $varname)", + "in_obj_constant_str_jsonata_expr": "{% $varname.varfield %}", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + "$.no.such.path", + "$varname", + "{% $varname %}", + "$$", + "States.Format('{}', $varname)", + [], + { + "constant": 0 + } + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + }, + "varnameconstant_obj_access": "[null,0,0.1,true,\"$.no.such.path\",\"$varname\",\"{% $varname %}\",\"$$\",\"States.Format('{}', $varname)\",[],{\"constant\":0}]", + "varnameconstant_obj_empty": {}, + "varnameconstant_str": "\"constant string\"", + "varnameconstant_str_contextpath": "\"$$.Execution.Id\"", + "varnameconstant_str_contextpath_root": "\"$$\"", + "varnameconstant_str_intrinsic_func": "\"States.Format('Format Func {}', $varname)\"", + "varnameconstant_str_jsonata_expr": "\"{% $varname.varfield %}\"", + "varnameconstant_str_path": "\"$.no.such.path\"", + "varnameconstant_str_path_root": "\"$\"", + "varnameconstant_str_var": "\"$noSuchVar\"", + "varnameconstant_str_var_expr": "\"$noSuchVar.noSuchMember\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State1", + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_SCOPE_MAP]": { + "recorded-date": "04-11-2024, 11:03:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "items": "[1,2,3]", + "x": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "input_value": "input_value_literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "input_value_literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "State1" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Inner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "assignedVariables": { + "innerX": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Inner", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "State1" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "State1" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "Inner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "assignedVariables": { + "innerX": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Inner", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "State1" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "State1" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Inner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "assignedVariables": { + "innerX": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Inner", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "State1" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "State1", + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 20, + "previousEventId": 19, + "stateEnteredEventDetails": { + "input": "[1,2,3]", + "inputDetails": { + "truncated": false + }, + "name": "State2" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 21, + "previousEventId": 20, + "stateExitedEventDetails": { + "assignedVariables": { + "final": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State2", + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "id": 22, + "previousEventId": 21, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_parallel_cases[BASE_SCOPE_PARALLEL]": { + "recorded-date": "04-11-2024, 11:03:53", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "x": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch21" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch31" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "assignedVariables": { + "innerX": "42", + "value": {} + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Branch1", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "assignedVariables": { + "innerX": "42", + "value": {} + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Branch21", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "assignedVariables": { + "innerX": "42", + "value": {} + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Branch31", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "State1", + "output": "[{},{},{}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{},{},{}]", + "outputDetails": { + "truncated": false + } + }, + "id": 14, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_assign_in_choice[CONDITION_TRUE]": { + "recorded-date": "27-12-2024, 16:02:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "condition": true + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "condition": true + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "Assignment": "\"Condition assignment\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "ChoiceState", + "output": { + "condition": true + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "condition": true + }, + "inputDetails": { + "truncated": false + }, + "name": "ConditionTrue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "ConditionTrue", + "output": { + "condition": true + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "condition": true + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_assign_in_choice[CONDITION_FALSE]": { + "recorded-date": "27-12-2024, 16:02:37", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "condition": false + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "condition": false + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "Assignment": "\"Default Assignment\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "ChoiceState", + "output": { + "condition": false + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "condition": false + }, + "inputDetails": { + "truncated": false + }, + "name": "DefaultState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "Condition is false" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/assign/test_assign_base.validation.json b/tests/aws/services/stepfunctions/v2/assign/test_assign_base.validation.json new file mode 100644 index 0000000000000..ddb446aec1b0f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/assign/test_assign_base.validation.json @@ -0,0 +1,26 @@ +{ + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_assign_in_choice[CONDITION_FALSE]": { + "last_validated_date": "2024-12-27T16:02:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_assign_in_choice[CONDITION_TRUE]": { + "last_validated_date": "2024-12-27T16:02:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_CONSTANT_LITERALS]": { + "last_validated_date": "2024-11-04T11:02:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_EMPTY]": { + "last_validated_date": "2024-11-04T11:02:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_PATHS]": { + "last_validated_date": "2024-11-04T11:03:09+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_SCOPE_MAP]": { + "last_validated_date": "2024-11-04T11:03:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_cases[BASE_VAR]": { + "last_validated_date": "2024-11-04T11:03:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_base.py::TestAssignBase::test_base_parallel_cases[BASE_SCOPE_PARALLEL]": { + "last_validated_date": "2024-11-04T11:03:53+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py b/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py new file mode 100644 index 0000000000000..95ba152a1cfb1 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py @@ -0,0 +1,434 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import GenericTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_record_execution +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.assign.assign_templates import ( + AssignTemplate as AT, +) +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..RedriveCount", + "$..SdkResponseMetadata", + ] +) +class TestAssignReferenceVariables: + @pytest.mark.parametrize( + "template_path", + [ + AT.BASE_REFERENCE_IN_PARAMETERS, + AT.BASE_REFERENCE_IN_CHOICE, + AT.BASE_REFERENCE_IN_WAIT, + AT.BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE, + AT.BASE_REFERENCE_IN_INPUTPATH, + AT.BASE_REFERENCE_IN_OUTPUTPATH, + AT.BASE_REFERENCE_IN_INTRINSIC_FUNCTION, + AT.BASE_REFERENCE_IN_FAIL, + ], + ids=[ + "BASE_REFERENCE_IN_PARAMETERS", + "BASE_REFERENCE_IN_CHOICE", + "BASE_REFERENCE_IN_WAIT", + "BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE", + "BASE_REFERENCE_IN_INPUTPATH", + "BASE_REFERENCE_IN_OUTPUTPATH", + "BASE_REFERENCE_IN_INTRINSIC_FUNCTION", + "BASE_REFERENCE_IN_FAIL", + ], + ) + @markers.aws.validated + def test_reference_assign( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = AT.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..events..evaluationFailedEventDetails.cause", + "$..events..evaluationFailedEventDetails.location", + "$..events..executionFailedEventDetails.cause", + "$..events..previousEventId", + ] + ) + @pytest.mark.parametrize( + "template", + [ + AT.load_sfn_template(AT.BASE_UNDEFINED_OUTPUT), + AT.load_sfn_template(AT.BASE_UNDEFINED_OUTPUT_FIELD), + AT.load_sfn_template(AT.BASE_UNDEFINED_OUTPUT_MULTIPLE_STATES), + AT.load_sfn_template(AT.BASE_UNDEFINED_ASSIGN), + pytest.param( + AT.load_sfn_template(AT.BASE_UNDEFINED_ARGUMENTS), + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Not reached full parity yet." + ), + ), + pytest.param( + AT.load_sfn_template(AT.BASE_UNDEFINED_ARGUMENTS_FIELD), + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Not reached full parity yet." + ), + ), + ], + ids=[ + "BASE_UNDEFINED_OUTPUT", + "BASE_UNDEFINED_OUTPUT_FIELD", + "BASE_UNDEFINED_OUTPUT_MULTIPLE_STATES", + "BASE_UNDEFINED_ASSIGN", + "BASE_UNDEFINED_ARGUMENTS", + "BASE_UNDEFINED_ARGUMENTS_FIELD", + ], + ) + @markers.aws.validated + def test_undefined_reference( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template_path", + [ + AT.BASE_ASSIGN_FROM_PARAMETERS, + AT.BASE_ASSIGN_FROM_RESULT, + AT.BASE_ASSIGN_FROM_INTRINSIC_FUNCTION, + ], + ids=[ + "BASE_ASSIGN_FROM_PARAMETERS", + "BASE_ASSIGN_FROM_RESULT", + "BASE_ASSIGN_FROM_INTRINSIC_FUNCTION", + ], + ) + @markers.aws.validated + def test_assign_from_value( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = AT.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.skip(reason="Flaky when run in test suite") + @pytest.mark.parametrize( + "template_path", + [ + AT.BASE_EVALUATION_ORDER_PASS_STATE, + ], + ids=[ + "BASE_EVALUATION_ORDER_PASS_STATE", + ], + ) + @markers.aws.validated + def test_state_assign_evaluation_order( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = AT.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize("input_value", ["42", "0"], ids=["CORRECT", "INCORRECT"]) + @markers.aws.validated + def test_assign_in_choice_state( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + input_value, + ): + template = AT.load_sfn_template(AT.BASE_ASSIGN_IN_CHOICE) + definition = json.dumps(template) + exec_input = json.dumps({"input_value": input_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + def test_assign_in_wait_state( + self, aws_client, create_state_machine_iam_role, create_state_machine, sfn_snapshot + ): + template = AT.load_sfn_template(AT.BASE_ASSIGN_IN_WAIT) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + def test_assign_in_catch_state( + self, + aws_client, + create_state_machine_iam_role, + create_lambda_function, + create_state_machine, + sfn_snapshot, + ): + function_name = f"fn-timeout-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = AT.load_sfn_template(AT.BASE_ASSIGN_IN_CATCH) + definition = json.dumps(template) + exec_input = json.dumps({"input_value": function_arn}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template_path", + [ + # FIXME: BASE_REFERENCE_IN_LAMBDA_TASK_FIELDS provides invalid credentials to lambda::invoke + # AT.BASE_REFERENCE_IN_LAMBDA_TASK_FIELDS, + AT.BASE_ASSIGN_FROM_LAMBDA_TASK_RESULT, + ], + ids=[ + "BASE_ASSIGN_FROM_LAMBDA_TASK_RESULT", + ], + ) + @markers.aws.validated + def test_variables_in_lambda_task( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + account_id, + sfn_snapshot, + template_path, + ): + function_name = f"fn-ref-var-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_RETURN_BYTES_STR, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = AT.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({"FunctionName": function_arn, "AccountID": account_id}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template", + [ + AT.load_sfn_template(AT.MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION), + AT.load_sfn_template(AT.MAP_STATE_REFERENCE_IN_ITEMS_PATH), + AT.load_sfn_template(AT.MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH), + pytest.param( + AT.load_sfn_template(AT.MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH), + marks=pytest.mark.skip_snapshot_verify(paths=["$..events[8].previousEventId"]), + ), + pytest.param( + AT.load_sfn_template(AT.MAP_STATE_REFERENCE_IN_ITEM_SELECTOR), + marks=pytest.mark.skip_snapshot_verify(paths=["$..events[8].previousEventId"]), + ), + ], + ids=[ + "MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION", + "MAP_STATE_REFERENCE_IN_ITEMS_PATH", + "MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH", + "MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH", + "MAP_STATE_REFERENCE_IN_ITEM_SELECTOR", + ], + ) + @markers.aws.validated + def test_reference_in_map_state( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + def _convert_output_to_json(snapshot_content: dict, *args) -> dict: + """Recurse through all elements in the snapshot and convert the json-string `output` to a dict""" + for _, v in snapshot_content.items(): + if isinstance(v, dict): + if "output" in v: + try: + if isinstance(v["output"], str): + v["output"] = json.loads(v["output"]) + return + except json.JSONDecodeError: + pass + v = _convert_output_to_json(v) + elif isinstance(v, list): + v = [ + _convert_output_to_json(item) if isinstance(item, dict) else item + for item in v + ] + return snapshot_content + + sfn_snapshot.add_transformer(GenericTransformer(_convert_output_to_json)) + + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "template", + [ + pytest.param( + AT.load_sfn_template(AT.MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH), + marks=pytest.mark.skip_snapshot_verify(paths=["$..events[8].previousEventId"]), + ), + # TODO: Add JSONata support for ItemBatcher's MaxItemsPerBatch and MaxInputBytesPerBatch fields + pytest.param( + AT.load_sfn_template(AT.MAP_STATE_REFERENCE_IN_MAX_PER_BATCH_PATH), + marks=pytest.mark.skip( + reason="TODO: Add JSONata support for ItemBatcher's MaxItemsPerBatch and MaxInputBytesPerBatch fields" + ), + ), + ], + ids=[ + "MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH", + "MAP_STATE_REFERENCE_IN_MAX_PER_BATCH_PATH", + ], + ) + @markers.aws.validated + def test_reference_in_map_state_max_items_path( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.json" + json_file = json.dumps( + [ + {"verdict": "true", "statement_date": "6/11/2008", "statement_source": "speech"}, + { + "verdict": "false", + "statement_date": "6/7/2022", + "statement_source": "television", + }, + { + "verdict": "mostly-true", + "statement_date": "5/18/2016", + "statement_source": "news", + }, + {"verdict": "false", "statement_date": "5/18/2024", "statement_source": "x"}, + ] + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=json_file) + + definition = json.dumps(template) + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.snapshot.json b/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.snapshot.json new file mode 100644 index 0000000000000..7a2b2290028a2 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.snapshot.json @@ -0,0 +1,4978 @@ +{ + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_PARAMETERS]": { + "recorded-date": "06-11-2024, 23:17:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "result": "\"foobar\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "result": "$result" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result": "$result" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State1", + "output": { + "result": { + "result": "foobar" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "result": "foobar" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_CHOICE]": { + "recorded-date": "12-11-2024, 14:43:37", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Setup" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "answer": "\"the_answer\"", + "guess": "\"the_guess\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Setup", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "CheckAnswer" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "CheckAnswer", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WrongAnswer" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "assignedVariables": { + "guess": "\"the_answer\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "WrongAnswer", + "output": { + "state": "WRONG" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "state": "WRONG" + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckAnswer" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "CheckAnswer", + "output": { + "state": "WRONG" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": { + "state": "WRONG" + }, + "inputDetails": { + "truncated": false + }, + "name": "CorrectAnswer" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "CorrectAnswer", + "output": { + "state": "CORRECT" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "state": "CORRECT" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE]": { + "recorded-date": "13-11-2024, 08:44:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Input" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "bias": "4.3" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Input", + "output": "[[9,44,6],[82,25,76],[18,42,2]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "[[9,44,6],[82,25,76],[18,42,2]]", + "inputDetails": { + "truncated": false + }, + "name": "IterateLevels" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "IterateLevels" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "[9,44,6]", + "inputDetails": { + "truncated": false + }, + "name": "AssignCurrentVector" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "assignedVariables": { + "xCurrent": "9", + "yCurrent": "44", + "zCurrent": "6" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "AssignCurrentVector", + "output": "[9,44,6]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "[9,44,6]", + "inputDetails": { + "truncated": false + }, + "name": "Calculate" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 10, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 11, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Calculate" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": "9", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "63" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "9", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 14, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Calculate" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 15, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "Calculate" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": "44", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 17, + "previousEventId": 16, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "63" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "44", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 18, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "Calculate" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 19, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "Calculate" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 20, + "previousEventId": 19, + "stateEnteredEventDetails": { + "input": "6", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 21, + "previousEventId": 20, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "63" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "6", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 22, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "Calculate" + }, + "previousEventId": 21, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 23, + "previousEventId": 22, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 24, + "previousEventId": 22, + "stateExitedEventDetails": { + "name": "Calculate", + "output": "[9,44,6]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 25, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "IterateLevels" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 26, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "IterateLevels" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 27, + "previousEventId": 26, + "stateEnteredEventDetails": { + "input": "[82,25,76]", + "inputDetails": { + "truncated": false + }, + "name": "AssignCurrentVector" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 28, + "previousEventId": 27, + "stateExitedEventDetails": { + "assignedVariables": { + "xCurrent": "82", + "yCurrent": "25", + "zCurrent": "76" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "AssignCurrentVector", + "output": "[82,25,76]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 29, + "previousEventId": 28, + "stateEnteredEventDetails": { + "input": "[82,25,76]", + "inputDetails": { + "truncated": false + }, + "name": "Calculate" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 30, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 29, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 31, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Calculate" + }, + "previousEventId": 30, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 32, + "previousEventId": 31, + "stateEnteredEventDetails": { + "input": "82", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 33, + "previousEventId": 32, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "187" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "82", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 34, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Calculate" + }, + "previousEventId": 33, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 35, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "Calculate" + }, + "previousEventId": 33, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 36, + "previousEventId": 35, + "stateEnteredEventDetails": { + "input": "25", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 37, + "previousEventId": 36, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "187" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "25", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 38, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "Calculate" + }, + "previousEventId": 37, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 39, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "Calculate" + }, + "previousEventId": 37, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 40, + "previousEventId": 39, + "stateEnteredEventDetails": { + "input": "76", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 41, + "previousEventId": 40, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "187" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "76", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 42, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "Calculate" + }, + "previousEventId": 41, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 43, + "previousEventId": 42, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 44, + "previousEventId": 42, + "stateExitedEventDetails": { + "name": "Calculate", + "output": "[82,25,76]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 45, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "IterateLevels" + }, + "previousEventId": 44, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 46, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "IterateLevels" + }, + "previousEventId": 44, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 47, + "previousEventId": 46, + "stateEnteredEventDetails": { + "input": "[18,42,2]", + "inputDetails": { + "truncated": false + }, + "name": "AssignCurrentVector" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 48, + "previousEventId": 47, + "stateExitedEventDetails": { + "assignedVariables": { + "xCurrent": "18", + "yCurrent": "42", + "zCurrent": "2" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "AssignCurrentVector", + "output": "[18,42,2]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 49, + "previousEventId": 48, + "stateEnteredEventDetails": { + "input": "[18,42,2]", + "inputDetails": { + "truncated": false + }, + "name": "Calculate" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 50, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 49, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 51, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Calculate" + }, + "previousEventId": 50, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 52, + "previousEventId": 51, + "stateEnteredEventDetails": { + "input": "18", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 53, + "previousEventId": 52, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "66" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "18", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 54, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Calculate" + }, + "previousEventId": 53, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 55, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "Calculate" + }, + "previousEventId": 53, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 56, + "previousEventId": 55, + "stateEnteredEventDetails": { + "input": "42", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 57, + "previousEventId": 56, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "66" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "42", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 58, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "Calculate" + }, + "previousEventId": 57, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 59, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "Calculate" + }, + "previousEventId": 57, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 60, + "previousEventId": 59, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "Summate" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 61, + "previousEventId": 60, + "stateExitedEventDetails": { + "assignedVariables": { + "Sum": "66" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Summate", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 62, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "Calculate" + }, + "previousEventId": 61, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 63, + "previousEventId": 62, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 64, + "previousEventId": 62, + "stateExitedEventDetails": { + "name": "Calculate", + "output": "[18,42,2]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 65, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "IterateLevels" + }, + "previousEventId": 64, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 66, + "previousEventId": 65, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 67, + "previousEventId": 65, + "stateExitedEventDetails": { + "name": "IterateLevels", + "output": "[[9,44,6],[82,25,76],[18,42,2]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[9,44,6],[82,25,76],[18,42,2]]", + "outputDetails": { + "truncated": false + } + }, + "id": 68, + "previousEventId": 67, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_INPUTPATH]": { + "recorded-date": "06-11-2024, 23:18:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "theAnswer": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State1", + "output": "42", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "42", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_OUTPUTPATH]": { + "recorded-date": "06-11-2024, 23:18:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "theAnswer": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "answer": 42 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "answer": 42 + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State1", + "output": "42", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "42", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_PARAMETERS]": { + "recorded-date": "06-11-2024, 22:54:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "result": "\"PENDING\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "input": "PENDING" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input": "PENDING" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "assignedVariables": { + "originalResult": "\"PENDING\"", + "result": "\"SUCCESS\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State1", + "output": { + "input": "PENDING" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input": "PENDING" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_RESULT]": { + "recorded-date": "06-11-2024, 22:54:27", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "theAnswer": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "answer": 42 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "answer": 42 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_choice_state[CORRECT]": { + "recorded-date": "12-11-2024, 14:40:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "42" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "42" + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckInputState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "guess": "\"42\"", + "status": "\"INCORRECT\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "CheckInputState", + "output": { + "input_value": "42" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "42" + }, + "inputDetails": { + "truncated": false + }, + "name": "FinalState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinalState", + "output": { + "result": "INCORRECT" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": "INCORRECT" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_choice_state[INCORRECT]": { + "recorded-date": "12-11-2024, 14:40:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "0" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "0" + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckInputState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "guess": "\"0\"", + "status": "\"INCORRECT\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "CheckInputState", + "output": { + "input_value": "0" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "0" + }, + "inputDetails": { + "truncated": false + }, + "name": "FinalState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinalState", + "output": { + "result": "INCORRECT" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": "INCORRECT" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_wait_state": { + "recorded-date": "06-11-2024, 11:01:35", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "foo": "\"oof\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "WaitState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_catch_state": { + "recorded-date": "06-11-2024, 12:26:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "arn::lambda::111111111111:function:" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "arn::lambda::111111111111:function:" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "oof" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "assignedVariables": { + "result": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Task", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "fallback", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_state_assign_evaluation_order[BASE_EVALUATION_ORDER_PASS_STATE]": { + "recorded-date": "06-11-2024, 23:28:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "answer": "42", + "question": "\"What is the answer to life the universe and everything?\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "theQuestion": "What is the answer to life the universe and everything?" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "theQuestion": "What is the answer to life the universe and everything?" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "assignedVariables": { + "answer": "\"\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State1", + "output": "42", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "42", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "recorded-date": "06-11-2024, 23:18:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "additionalInfo": { + "age": 30, + "role": "developer" + }, + "csvString": "\"a,b,c,d,e\"", + "duplicateArray": "[1,2,2,3,3,4]", + "encodedString": "\"SGVsbG8gV29ybGQ=\"", + "inputArray": "[1,2,3,4,5,6,7,8,9]", + "inputString": "\"Hash this string\"", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonObject": { + "test": "object" + }, + "jsonString": "\"{\\\"key\\\":\\\"value\\\"}\"", + "name": "\"John\"", + "place": "\"LocalStack\"", + "rawString": "\"Hello World\"", + "value1": "5", + "value2": "3" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State1", + "output": { + "encodingOps": { + "decoded": "Hello World", + "encoded": "SGVsbG8gV29ybGQ=" + }, + "hashOps": { + "hashValue": "1cac63f39fd68d8c531f27b807610fb3d50f0fc3f186995767fb6316e7200a3e" + }, + "jsonOps": { + "parsedJson": { + "key": "value" + }, + "mergedJson": { + "a": 1, + "b": 2, + "c": 3, + "d": 4 + }, + "stringifiedJson": "{\"test\":\"object\"}" + }, + "mathOps": { + "sum": 8 + }, + "stringOps": { + "formattedString": "Hello John, welcome to LocalStack!", + "splitString": [ + "a", + "b", + "c", + "d", + "e" + ] + }, + "uuidOp": { + "uniqueId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "encodingOps": { + "decoded": "Hello World", + "encoded": "SGVsbG8gV29ybGQ=" + }, + "hashOps": { + "hashValue": "1cac63f39fd68d8c531f27b807610fb3d50f0fc3f186995767fb6316e7200a3e" + }, + "jsonOps": { + "parsedJson": { + "key": "value" + }, + "mergedJson": { + "a": 1, + "b": 2, + "c": 3, + "d": 4 + }, + "stringifiedJson": "{\"test\":\"object\"}" + }, + "mathOps": { + "sum": 8 + }, + "stringOps": { + "formattedString": "Hello John, welcome to LocalStack!", + "splitString": [ + "a", + "b", + "c", + "d", + "e" + ] + }, + "uuidOp": { + "uniqueId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_INTRINSIC_FUNCTION]": { + "recorded-date": "06-11-2024, 22:54:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "arrayOps": { + "uniqueValues": [ + 1, + 2, + 3, + 4 + ], + "simpleArray": [ + "a", + "b", + "c" + ], + "thirdElement": 3, + "partitionedArray": [ + [ + 1, + 2 + ], + [ + 3, + 4 + ], + [ + 5, + 6 + ] + ], + "arraySize": 6, + "containsElement": true, + "numberRange": [ + 1, + 3, + 5, + 7, + 9 + ] + }, + "encodingOps": { + "decoded": "Hello World", + "encoded": "SGVsbG8gV29ybGQ=" + }, + "hashOps": { + "hashValue": "1cac63f39fd68d8c531f27b807610fb3d50f0fc3f186995767fb6316e7200a3e" + }, + "jsonOps": { + "parsedJson": { + "key": "value" + }, + "mergedJson": { + "a": 1, + "b": 2, + "c": 3, + "d": 4 + }, + "stringifiedJson": "{\"test\":\"object\"}" + }, + "mathOps": { + "sum": 8 + }, + "stringOps": { + "formattedString": "Hello John, welcome to LocalStack!", + "splitString": [ + "a", + "b", + "c", + "d", + "e" + ] + }, + "uuidOp": { + "uniqueId": "" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_variables_in_lambda_task[BASE_REFERENCE_IN_LAMBDA_TASK_FIELDS]": { + "recorded-date": "07-11-2024, 10:03:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "accountId": "\"111111111111\"", + "backoffRate": "1.5", + "functionName": "\"arn::lambda::111111111111:function:\"", + "heartbeatInterval": "60", + "intervalSeconds": "2", + "jobParameters": { + "inputData": "sample data", + "configOption": "value1" + }, + "maxAttempts": "3", + "maxTimeout": "300", + "targetRole": "\"CrossAccountRole\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "State0", + "output": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 60, + "parameters": { + "Payload": { + "data": { + "inputData": "sample data", + "configOption": "value1" + } + }, + "FunctionName": "arn::lambda::111111111111:function:" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "taskCredentials": { + "roleArn": "arn::iam::111111111111:role/CrossAccountRole" + }, + "timeoutInSeconds": 300 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 7, + "previousEventId": 6, + "taskFailedEventDetails": { + "cause": "The role snf_role_arn is not authorized to assume the task state's role, arn::iam::111111111111:role/CrossAccountRole.", + "error": "States.TaskFailed", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "The role snf_role_arn is not authorized to assume the task state's role, arn::iam::111111111111:role/CrossAccountRole.", + "error": "States.TaskFailed" + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_WAIT]": { + "recorded-date": "06-11-2024, 23:17:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "startAt": "\"date\"", + "waitTime": "0" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitSecondsState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "WaitSecondsState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntilState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "WaitUntilState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_variables_in_lambda_task[BASE_ASSIGN_FROM_LAMBDA_TASK_RESULT]": { + "recorded-date": "07-11-2024, 10:03:33", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "functionName": "\"arn::lambda::111111111111:function:\"", + "inputData": { + "foo": "oof", + "bar": "rab" + }, + "timeout": "300" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Pass", + "output": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "arn::lambda::111111111111:function:", + "AccountID": "111111111111" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "oof", + "bar": "rab" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 300 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 7, + "previousEventId": 6, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "assignedVariables": { + "result": "\"HelloWorld!\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Task", + "output": "\"HelloWorld!\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "\"HelloWorld!\"", + "inputDetails": { + "truncated": false + }, + "name": "End" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "assignedVariables": { + "previousResult": "\"HelloWorld!\"", + "timeout": "150" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "End", + "output": "\"HelloWorld!\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"HelloWorld!\"", + "outputDetails": { + "truncated": false + } + }, + "id": 11, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state_max_items_path[MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH]": { + "recorded-date": "14-11-2024, 16:32:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "maxItems": "\"2\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state_max_items_path[MAP_STATE_REFERENCE_IN_MAX_PER_BATCH_PATH]": { + "recorded-date": "14-11-2024, 16:33:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "maxBytesPerBatch": "\"15000\"", + "maxItemsPerBatch": "\"2\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "BatchMapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "BatchMapState", + "output": "[{\"Items\":[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]},{\"Items\":[{\"verdict\":\"mostly-true\",\"statement_date\":\"5/18/2016\",\"statement_source\":\"news\"},{\"verdict\":\"false\",\"statement_date\":\"5/18/2024\",\"statement_source\":\"x\"}]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Items\":[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]},{\"Items\":[{\"verdict\":\"mostly-true\",\"statement_date\":\"5/18/2016\",\"statement_source\":\"news\"},{\"verdict\":\"false\",\"statement_date\":\"5/18/2024\",\"statement_source\":\"x\"}]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_FAIL]": { + "recorded-date": "14-11-2024, 13:15:13", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "causeVar": "\"An Exception was encountered\"", + "errorVar": "\"Exception\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Pass", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Fail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An Exception was encountered", + "error": "Exception" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "recorded-date": "18-11-2024, 14:52:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "AnswerTemplate": "\"It's {}!\"", + "Question": "\"Who's that Pokemon?\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": [ + "Charizard", + "Pikachu", + "Squirtle" + ], + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "[\"Charizard\",\"Pikachu\",\"Squirtle\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "AnnouncePokemon": "It's Charizard!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "Answer": "It's Charizard!", + "Question": "Who's that Pokemon?" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "AnnouncePokemon": "It's Pikachu!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "Answer": "It's Pikachu!", + "Question": "Who's that Pokemon?" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "AnnouncePokemon": "It's Squirtle!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "Answer": "It's Squirtle!", + "Question": "Who's that Pokemon?" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": [ + { + "Answer": "It's Charizard!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Pikachu!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Squirtle!", + "Question": "Who's that Pokemon?" + } + ], + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": [ + { + "Answer": "It's Charizard!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Pikachu!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Squirtle!", + "Question": "Who's that Pokemon?" + } + ], + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_ITEMS_PATH]": { + "recorded-date": "18-11-2024, 14:52:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "PokemonList": "[\"Charizard\",\"Pikachu\",\"Squirtle\"]", + "Question": "\"Who's that Pokemon?\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": { + "AnswerTemplate": "It's {}!" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "AnswerTemplate": "It's {}!" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "AnnouncePokemon": "It's Charizard!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "Answer": "It's Charizard!", + "Question": "Who's that Pokemon?" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "AnnouncePokemon": "It's Pikachu!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "Answer": "It's Pikachu!", + "Question": "Who's that Pokemon?" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "AnnouncePokemon": "It's Squirtle!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "Answer": "It's Squirtle!", + "Question": "Who's that Pokemon?" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": [ + { + "Answer": "It's Charizard!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Pikachu!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Squirtle!", + "Question": "Who's that Pokemon?" + } + ], + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": [ + { + "Answer": "It's Charizard!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Pikachu!", + "Question": "Who's that Pokemon?" + }, + { + "Answer": "It's Squirtle!", + "Question": "Who's that Pokemon?" + } + ], + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH]": { + "recorded-date": "18-11-2024, 14:53:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "maxConcurrency": "\"1\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": { + "Values": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": 1, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": 2, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": 3, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapState", + "output": [ + 1, + 2, + 3 + ], + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": [ + 1, + 2, + 3 + ], + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH]": { + "recorded-date": "18-11-2024, 14:53:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "toleratedFailureCount": "\"1\"", + "toleratedFailurePercentage": "\"1\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": { + "Values": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": [ + 1, + 2, + 3 + ], + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": [ + 1, + 2, + 3 + ], + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_ITEM_SELECTOR]": { + "recorded-date": "18-11-2024, 14:54:07", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "bucket": "\"test-name\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": { + "Values": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": [ + { + "bucketName": "$bucket", + "value": 1 + }, + { + "bucketName": "$bucket", + "value": 2 + }, + { + "bucketName": "$bucket", + "value": 3 + } + ], + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": [ + { + "bucketName": "$bucket", + "value": 1 + }, + { + "bucketName": "$bucket", + "value": 2 + }, + { + "bucketName": "$bucket", + "value": 3 + } + ], + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT]": { + "recorded-date": "21-11-2024, 11:07:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Output' returned nothing (undefined).", + "error": "States.QueryEvaluationError", + "location": "Output", + "state": "State0" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Output' returned nothing (undefined).", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT_FIELD]": { + "recorded-date": "21-11-2024, 11:07:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Output/result' returned nothing (undefined).", + "error": "States.QueryEvaluationError", + "location": "Output/result", + "state": "State0" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Output/result' returned nothing (undefined).", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT_MULTIPLE_STATES]": { + "recorded-date": "21-11-2024, 11:08:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State1", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State2" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'State2' (entered at the event id #6). The JSONata expression '$doesNotExist' specified for the field 'Output/result' returned nothing (undefined).", + "error": "States.QueryEvaluationError", + "location": "Output/result", + "state": "State2" + }, + "id": 7, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State2' (entered at the event id #6). The JSONata expression '$doesNotExist' specified for the field 'Output/result' returned nothing (undefined).", + "error": "States.QueryEvaluationError" + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ASSIGN]": { + "recorded-date": "21-11-2024, 11:08:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Assign/result' returned nothing (undefined).", + "error": "States.QueryEvaluationError", + "location": "Assign/result", + "state": "State0" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Assign/result' returned nothing (undefined).", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ARGUMENTS]": { + "recorded-date": "21-11-2024, 11:19:43", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Arguments' returned nothing (undefined).", + "error": "States.QueryEvaluationError", + "location": "Arguments", + "state": "State0" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Arguments' returned nothing (undefined).", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ARGUMENTS_FIELD]": { + "recorded-date": "21-11-2024, 11:20:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Arguments/FunctionName' returned nothing (undefined).", + "error": "States.QueryEvaluationError", + "location": "Arguments/FunctionName", + "state": "State0" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State0' (entered at the event id #2). The JSONata expression '$doesNotExist' specified for the field 'Arguments/FunctionName' returned nothing (undefined).", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.validation.json b/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.validation.json new file mode 100644 index 0000000000000..8015b6936b73a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.validation.json @@ -0,0 +1,95 @@ +{ + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_INTRINSIC_FUNCTION]": { + "last_validated_date": "2024-11-06T22:54:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_PARAMETERS]": { + "last_validated_date": "2024-11-06T22:54:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_from_value[BASE_ASSIGN_FROM_RESULT]": { + "last_validated_date": "2024-11-06T22:54:27+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_catch_state": { + "last_validated_date": "2024-11-06T12:26:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_choice_state[CORRECT]": { + "last_validated_date": "2024-11-12T14:40:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_choice_state[INCORRECT]": { + "last_validated_date": "2024-11-12T14:40:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_assign_in_wait_state": { + "last_validated_date": "2024-11-06T11:46:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_CHOICE]": { + "last_validated_date": "2024-11-12T14:43:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_FAIL]": { + "last_validated_date": "2024-11-14T13:15:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_INPUTPATH]": { + "last_validated_date": "2024-11-12T14:42:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "last_validated_date": "2024-11-12T14:42:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE]": { + "last_validated_date": "2024-11-13T08:58:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_OUTPUTPATH]": { + "last_validated_date": "2024-11-12T14:42:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_PARAMETERS]": { + "last_validated_date": "2024-11-12T14:41:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_assign[BASE_REFERENCE_IN_WAIT]": { + "last_validated_date": "2024-11-12T14:41:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "last_validated_date": "2024-11-18T14:56:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_ITEMS_PATH]": { + "last_validated_date": "2024-11-18T14:56:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_ITEM_SELECTOR]": { + "last_validated_date": "2024-11-18T14:57:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH]": { + "last_validated_date": "2024-11-18T14:56:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state[MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH]": { + "last_validated_date": "2024-11-18T14:57:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state_max_items_path[MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH]": { + "last_validated_date": "2024-11-14T16:33:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_reference_in_map_state_max_items_path[MAP_STATE_REFERENCE_IN_MAX_PER_BATCH_PATH]": { + "last_validated_date": "2024-11-14T16:34:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_state_assign_evaluation_order[BASE_EVALUATION_ORDER_PASS_STATE]": { + "last_validated_date": "2024-11-06T23:28:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ARGUMENTS]": { + "last_validated_date": "2024-11-21T11:19:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ARGUMENTS_FIELD]": { + "last_validated_date": "2024-11-21T11:20:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_ASSIGN]": { + "last_validated_date": "2024-11-21T11:08:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT]": { + "last_validated_date": "2024-11-21T11:07:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT_FIELD]": { + "last_validated_date": "2024-11-21T11:07:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_undefined_reference[BASE_UNDEFINED_OUTPUT_MULTIPLE_STATES]": { + "last_validated_date": "2024-11-21T11:08:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_variables_in_lambda_task[BASE_ASSIGN_FROM_LAMBDA_TASK_RESULT]": { + "last_validated_date": "2024-11-07T10:03:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/assign/test_assign_reference_variables.py::TestAssignReferenceVariables::test_variables_in_lambda_task[BASE_REFERENCE_IN_LAMBDA_TASK_FIELDS]": { + "last_validated_date": "2024-11-07T10:03:14+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/base/__init__.py b/tests/aws/services/stepfunctions/v2/base/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/base/test_base.py b/tests/aws/services/stepfunctions/v2/base/test_base.py new file mode 100644 index 0000000000000..a124678cd42a5 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/base/test_base.py @@ -0,0 +1,471 @@ +import datetime +import json +import re + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_success, + create_and_record_events, + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate + + +class TestSnfBase: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..redriveCount", "$..redriveStatus"]) + def test_state_fail( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_RAISE_FAILURE) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + verify_execution_description=True, + ) + + @markers.aws.validated + def test_state_fail_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_RAISE_FAILURE_PATH) + definition = json.dumps(template) + + exec_input = json.dumps({"Error": "error string", "Cause": "cause string"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_state_fail_intrinsic( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_RAISE_FAILURE_INTRINSIC) + definition = json.dumps(template) + + exec_input = json.dumps({"Error": "error string", "Cause": "cause string"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..redriveCount", "$..redriveStatus"]) + def test_state_fail_empty( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.RAISE_EMPTY_FAILURE) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + verify_execution_description=True, + ) + + @markers.aws.validated + def test_state_pass_result( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_state_pass_result_jsonpaths( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + template["States"]["State_1"]["Result"] = { + "unsupported1.$": "$.jsonpath", + "unsupported2.$": "$$", + } + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..detail.redriveCount", + "$..detail.redriveDate", + "$..detail.redriveStatus", + "$..detail.redriveStatusReason", + ] + ) + def test_event_bridge_events_base( + self, + create_state_machine_iam_role, + create_state_machine, + events_to_sqs_queue, + sfn_events_to_sqs_queue, + aws_client, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_WAIT_1_MIN) + template["States"]["State_1"]["Seconds"] = 60 if is_aws_cloud() else 1 + definition = json.dumps(template) + execution_input = json.dumps(dict()) + create_and_record_events( + create_state_machine_iam_role, + create_state_machine, + sfn_events_to_sqs_queue, + aws_client, + sfn_snapshot, + definition, + execution_input, + ) + + @markers.aws.validated + def test_decl_version_1_0( + self, + create_state_machine_iam_role, + create_state_machine, + aws_client, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.DECL_VERSION_1_0) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skip(reason="flaky") # FIXME + @markers.aws.needs_fixing + def test_event_bridge_events_failure( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_events_to_sqs_queue, + aws_client, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.WAIT_AND_FAIL) + template["States"]["State_1"]["Seconds"] = 60 if is_aws_cloud() else 1 + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_events( + create_state_machine_iam_role, + create_state_machine, + sfn_events_to_sqs_queue, + aws_client, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..RedriveCount"]) + def test_query_context_object_values( + self, + create_state_machine_iam_role, + create_state_machine, + aws_client, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..StartTime", replacement="start-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..execution_starttime", replacement="start-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..EnteredTime", replacement="entered-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..state_enteredtime", replacement="entered-time", replace_reference=False + ) + ) + + template = BaseTemplate.load_sfn_template(BaseTemplate.QUERY_CONTEXT_OBJECT_VALUES) + definition = json.dumps(template) + + exec_input = json.dumps({"message": "TestMessage"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_state_pass_result_null_input_output_paths( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.PASS_RESULT_NULL_INPUT_OUTPUT_PATHS) + definition = json.dumps(template) + + exec_input = json.dumps({"InputValue": 0}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_execution_dateformat( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + ): + """ + When returning timestamps as strings, StepFunctions uses a format like this: + "2023-11-21T06:27:31.545Z" + + It's similar to the one used for Wait and Choice states but with the small difference of including milliseconds. + + It has 3-digit precision on the second (i.e. millisecond precision) and is *always* terminated by a Z. + When testing, it seems to always return a UTC time zone (signaled by the Z character at the end). + """ + template = BaseTemplate.load_sfn_template(BaseTemplate.PASS_START_TIME) + definition = json.dumps(template) + + sm_name = f"test-dateformat-machine-{short_uid()}" + sm = create_state_machine( + aws_client, + name=sm_name, + definition=definition, + roleArn=create_state_machine_iam_role(aws_client), + ) + + sm_arn = sm["stateMachineArn"] + execution = aws_client.stepfunctions.start_execution(stateMachineArn=sm_arn, input="{}") + execution_arn = execution["executionArn"] + await_execution_success(aws_client.stepfunctions, execution_arn) + execution_done = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + + # Since snapshots currently transform any timestamp-like value to a generic token, + # we handle the assertions here manually + + # check that date format conforms to AWS spec e.g. "2023-11-21T06:27:31.545Z" + date_regex = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z" + context_start_time = json.loads(execution_done["output"])["out1"] + assert re.match(date_regex, context_start_time) + + # make sure execution start time on the API side is the same as the one returned internally when accessing the context object + d = execution_done["startDate"].astimezone(datetime.UTC) + serialized_date = f"{d.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]}Z" + assert context_start_time == serialized_date + + @markers.aws.validated + def test_state_pass_regex_json_path_base( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.PASS_RESULT_REGEX_JSON_PATH_BASE) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "users": [ + {"year": 1997, "status": 0}, + {"year": 1998, "status": 1}, + ] + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skip(reason="Unsupported jsonpath derivation.") + @markers.aws.validated + def test_state_pass_regex_json_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.PASS_RESULT_REGEX_JSON_PATH) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "users": [ + {"year": 1997, "name": "User1997", "status": 0}, + {"year": 1998, "name": "User1998", "status": 0}, + {"year": 1999, "last": "User1999", "status": 0}, + {"year": 2000, "last": "User2000", "status": 1}, + {"year": 2001, "last": "User2001", "status": 2}, + ] + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + # TODO: the jsonpath library used in the SFN v2 interpreter does not support indexing features available in + # AWS SFN such as: negative ([-1]) and list ([1,2]) indexing. + @markers.aws.validated + @pytest.mark.parametrize( + "json_path_string", + ["$.items[0]", "$.items[10]"], + ) + def test_json_path_array_access( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + json_path_string, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.JSON_PATH_ARRAY_ACCESS) + template["States"]["EntryState"]["Parameters"]["item.$"] = json_path_string + definition = json.dumps(template) + + exec_input = json.dumps({"items": [{"item_key": i} for i in range(11)]}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + # These json_path_strings are handled gracefully in AWS by returning an empty array, + # although there are some exceptions like "$[1:5]", "$[1:], "$[:1] + @markers.aws.validated + @pytest.mark.parametrize( + "json_path_string", + [ + "$[*]", + "$.items[*]", + "$.items[1:]", + "$.items[:1]", + "$.item.items[*]", + "$.item.items[1:]", + "$.item.items[:1]", + "$.item.items[1:5]", + "$.items[*].itemValue", + "$.items[1:].itemValue", + "$.items[:1].itemValue", + "$.item.items[1:5].itemValue", + ], + ) + def test_json_path_array_wildcard_or_slice_with_no_input( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + json_path_string, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.JSON_PATH_ARRAY_ACCESS) + template["States"]["EntryState"]["Parameters"]["item.$"] = json_path_string + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/base/test_base.snapshot.json b/tests/aws/services/stepfunctions/v2/base/test_base.snapshot.json new file mode 100644 index 0000000000000..9a1c0a80f39d3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/base/test_base.snapshot.json @@ -0,0 +1,2216 @@ +{ + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail": { + "recorded-date": "06-03-2024, 10:29:14", + "recorded-content": { + "describe_execution": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure", + "executionArn": "arn::states::111111111111:execution::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "redriveCount": 0, + "redriveStatus": "REDRIVABLE", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "FAILED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_path": { + "recorded-date": "07-02-2024, 13:49:36", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Error": "error string", + "Cause": "cause string" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Error": "error string", + "Cause": "cause string" + }, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "cause string", + "error": "error string" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_intrinsic": { + "recorded-date": "07-02-2024, 13:49:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Error": "error string", + "Cause": "cause string" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Error": "error string", + "Cause": "cause string" + }, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This is a custom error message for error string, caused by cause string.", + "error": "error string" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_empty": { + "recorded-date": "06-03-2024, 10:24:13", + "recorded-content": { + "describe_execution": { + "executionArn": "arn::states::111111111111:execution::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "redriveCount": 0, + "redriveStatus": "REDRIVABLE", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "FAILED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": {}, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result": { + "recorded-date": "07-02-2024, 13:50:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result_jsonpaths": { + "recorded-date": "07-02-2024, 13:50:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "unsupported1.$": "$.jsonpath", + "unsupported2.$": "$$" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "unsupported1.$": "$.jsonpath", + "unsupported2.$": "$$" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_event_bridge_events_base": { + "recorded-date": "07-02-2024, 13:56:58", + "recorded-content": { + "stepfunctions_events": [ + { + "version": "0", + "id": "", + "detail-type": "Step Functions Execution Status Change", + "source": "aws.states", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::states::111111111111:execution::" + ], + "detail": { + "executionArn": "arn::states::111111111111:execution::", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "test_event_bridge_events-{short_uid()}", + "status": "RUNNING", + "startDate": "start-date", + "stopDate": "stop-date", + "input": {}, + "output": null, + "stateMachineVersionArn": null, + "stateMachineAliasArn": null, + "redriveCount": 0, + "redriveDate": null, + "redriveStatus": "NOT_REDRIVABLE", + "redriveStatusReason": "Execution is RUNNING and cannot be redriven", + "inputDetails": { + "included": true + }, + "outputDetails": null, + "error": null, + "cause": null + } + }, + { + "version": "0", + "id": "", + "detail-type": "Step Functions Execution Status Change", + "source": "aws.states", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::states::111111111111:execution::" + ], + "detail": { + "executionArn": "arn::states::111111111111:execution::", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "test_event_bridge_events-{short_uid()}", + "status": "SUCCEEDED", + "startDate": "start-date", + "stopDate": "stop-date", + "input": {}, + "output": { + "Arg1": "argument1" + }, + "stateMachineVersionArn": null, + "stateMachineAliasArn": null, + "redriveCount": 0, + "redriveDate": null, + "redriveStatus": "NOT_REDRIVABLE", + "redriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "inputDetails": { + "included": true + }, + "outputDetails": { + "included": true + }, + "error": null, + "cause": null + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_decl_version_1_0": { + "recorded-date": "07-02-2024, 13:52:02", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_query_context_object_values": { + "recorded-date": "15-07-2024, 13:00:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "message": "TestMessage" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "message": "TestMessage" + }, + "inputDetails": { + "truncated": false + }, + "name": "QueryValues1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "QueryValues1", + "output": { + "message": "TestMessage", + "query_values_1": { + "execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "execution_rolearn": "snf_role_arn", + "execution_name": "", + "statemachine_id": "arn::states::111111111111:stateMachine:", + "execution_id": "arn::states::111111111111:execution::", + "execution_input": { + "message": "TestMessage" + }, + "execution_starttime": "start-time", + "state_name": "QueryValues1", + "state_enteredtime": "entered-time", + "statemachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "state": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + }, + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + } + }, + "statemachine_name": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "message": "TestMessage", + "query_values_1": { + "execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "execution_rolearn": "snf_role_arn", + "execution_name": "", + "statemachine_id": "arn::states::111111111111:stateMachine:", + "execution_id": "arn::states::111111111111:execution::", + "execution_input": { + "message": "TestMessage" + }, + "execution_starttime": "start-time", + "state_name": "QueryValues1", + "state_enteredtime": "entered-time", + "statemachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "state": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + }, + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + } + }, + "statemachine_name": "" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "QueryValues2" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "QueryValues2", + "output": { + "message": "TestMessage", + "query_values_1": { + "execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "execution_rolearn": "snf_role_arn", + "execution_name": "", + "statemachine_id": "arn::states::111111111111:stateMachine:", + "execution_id": "arn::states::111111111111:execution::", + "execution_input": { + "message": "TestMessage" + }, + "execution_starttime": "start-time", + "state_name": "QueryValues1", + "state_enteredtime": "entered-time", + "statemachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "state": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + }, + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + } + }, + "statemachine_name": "" + }, + "query_values_2": { + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues2", + "EnteredTime": "entered-time" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "message": "TestMessage", + "query_values_1": { + "execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "execution_rolearn": "snf_role_arn", + "execution_name": "", + "statemachine_id": "arn::states::111111111111:stateMachine:", + "execution_id": "arn::states::111111111111:execution::", + "execution_input": { + "message": "TestMessage" + }, + "execution_starttime": "start-time", + "state_name": "QueryValues1", + "state_enteredtime": "entered-time", + "statemachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "state": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + }, + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + } + }, + "statemachine_name": "" + }, + "query_values_2": { + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "message": "TestMessage" + }, + "StartTime": "start-time", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues2", + "EnteredTime": "entered-time" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result_null_input_output_paths": { + "recorded-date": "07-02-2024, 13:52:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "InputValue": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "InputValue": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "Arg1": "argument1", + "FromInput": { + "InputValue": 0 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Arg1": "argument1", + "FromInput": { + "InputValue": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State1", + "output": { + "Arg2": "argument2", + "FromInput": {} + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": { + "Arg2": "argument2", + "FromInput": {} + }, + "inputDetails": { + "truncated": false + }, + "name": "State2" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "State2", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_regex_json_path_base": { + "recorded-date": "07-02-2024, 13:52:55", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "users": [ + { + "year": 1997, + "status": 0 + }, + { + "year": 1998, + "status": 1 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "users": [ + { + "year": 1997, + "status": 0 + }, + { + "year": 1998, + "status": 1 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "FromInput": [ + { + "year": 1997, + "status": 0 + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FromInput": [ + { + "year": 1997, + "status": 0 + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_access[$.items[0]]": { + "recorded-date": "16-08-2024, 15:52:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [ + { + "item_key": 0 + }, + { + "item_key": 1 + }, + { + "item_key": 2 + }, + { + "item_key": 3 + }, + { + "item_key": 4 + }, + { + "item_key": 5 + }, + { + "item_key": 6 + }, + { + "item_key": 7 + }, + { + "item_key": 8 + }, + { + "item_key": 9 + }, + { + "item_key": 10 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [ + { + "item_key": 0 + }, + { + "item_key": 1 + }, + { + "item_key": 2 + }, + { + "item_key": 3 + }, + { + "item_key": 4 + }, + { + "item_key": 5 + }, + { + "item_key": 6 + }, + { + "item_key": 7 + }, + { + "item_key": 8 + }, + { + "item_key": 9 + }, + { + "item_key": 10 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": { + "item_key": 0 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": { + "item_key": 0 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_access[$.items[10]]": { + "recorded-date": "16-08-2024, 15:53:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [ + { + "item_key": 0 + }, + { + "item_key": 1 + }, + { + "item_key": 2 + }, + { + "item_key": 3 + }, + { + "item_key": 4 + }, + { + "item_key": 5 + }, + { + "item_key": 6 + }, + { + "item_key": 7 + }, + { + "item_key": 8 + }, + { + "item_key": 9 + }, + { + "item_key": 10 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [ + { + "item_key": 0 + }, + { + "item_key": 1 + }, + { + "item_key": 2 + }, + { + "item_key": 3 + }, + { + "item_key": 4 + }, + { + "item_key": 5 + }, + { + "item_key": 6 + }, + { + "item_key": 7 + }, + { + "item_key": 8 + }, + { + "item_key": 9 + }, + { + "item_key": 10 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": { + "item_key": 10 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": { + "item_key": 10 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$[*]]": { + "recorded-date": "01-04-2025, 20:52:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[*]]": { + "recorded-date": "01-04-2025, 20:52:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[1:]]": { + "recorded-date": "01-04-2025, 20:52:33", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[:1]]": { + "recorded-date": "01-04-2025, 20:52:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[*]]": { + "recorded-date": "01-04-2025, 20:52:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:]]": { + "recorded-date": "01-04-2025, 20:53:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[:1]]": { + "recorded-date": "01-04-2025, 20:53:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:5]]": { + "recorded-date": "01-04-2025, 20:53:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[*].itemValue]": { + "recorded-date": "01-04-2025, 20:53:51", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[1:].itemValue]": { + "recorded-date": "01-04-2025, 20:54:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[:1].itemValue]": { + "recorded-date": "01-04-2025, 20:54:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:5].itemValue]": { + "recorded-date": "01-04-2025, 20:54:31", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "EntryState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "EntryState", + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "item": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/base/test_base.validation.json b/tests/aws/services/stepfunctions/v2/base/test_base.validation.json new file mode 100644 index 0000000000000..336b2b526c88a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/base/test_base.validation.json @@ -0,0 +1,77 @@ +{ + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_decl_version_1_0": { + "last_validated_date": "2024-02-07T13:52:02+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_event_bridge_events_base": { + "last_validated_date": "2024-02-07T13:56:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_access[$.items[0]]": { + "last_validated_date": "2024-08-16T15:52:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_access[$.items[10]]": { + "last_validated_date": "2024-08-16T15:53:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[*]]": { + "last_validated_date": "2025-04-01T20:52:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:5].itemValue]": { + "last_validated_date": "2025-04-01T20:54:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:5]]": { + "last_validated_date": "2025-04-01T20:53:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[1:]]": { + "last_validated_date": "2025-04-01T20:53:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.item.items[:1]]": { + "last_validated_date": "2025-04-01T20:53:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[*].itemValue]": { + "last_validated_date": "2025-04-01T20:53:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[*]]": { + "last_validated_date": "2025-04-01T20:52:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[1:].itemValue]": { + "last_validated_date": "2025-04-01T20:54:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[1:]]": { + "last_validated_date": "2025-04-01T20:52:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[:1].itemValue]": { + "last_validated_date": "2025-04-01T20:54:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$.items[:1]]": { + "last_validated_date": "2025-04-01T20:52:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_json_path_array_wildcard_or_slice_with_no_input[$[*]]": { + "last_validated_date": "2025-04-01T20:52:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_query_context_object_values": { + "last_validated_date": "2024-07-15T13:00:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail": { + "last_validated_date": "2024-03-06T10:29:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_empty": { + "last_validated_date": "2024-03-06T10:24:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_intrinsic": { + "last_validated_date": "2024-02-07T13:49:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_fail_path": { + "last_validated_date": "2024-02-07T13:49:36+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_regex_json_path_base": { + "last_validated_date": "2024-02-07T13:52:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result": { + "last_validated_date": "2024-02-07T13:50:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result_jsonpaths": { + "last_validated_date": "2024-02-07T13:50:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_base.py::TestSnfBase::test_state_pass_result_null_input_output_paths": { + "last_validated_date": "2024-02-07T13:52:29+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/base/test_wait.py b/tests/aws/services/stepfunctions/v2/base/test_wait.py new file mode 100644 index 0000000000000..b9c1f4a243b2e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/base/test_wait.py @@ -0,0 +1,120 @@ +import datetime +import json + +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate + + +# TODO: add tests for seconds, secondspath, timestamp +# TODO: add tests that actually validate waiting time (e.g. x minutes) BUT mark them accordingly and skip them by default! +class TestSfnWait: + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="not implemented") + @markers.aws.validated + @pytest.mark.parametrize("days", [24855, 24856]) + def test_timestamp_too_far_in_future_boundary( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + days, + ): + """ + seems this seems to correlate with "2147483648" as the maximum integer value for the seconds stepfunctions internally uses to represent dates + => 24855 days succeeds + => 24856 days fails + + This isn't as important though since a statemachine can't run for more than a year anyway. + Docs for Standard workflows: "If an execution runs for more than the 1-year maximum, it will fail with a States.Timeout error and emit a ExecutionsTimedOut CloudWatch metric." + """ + template = BaseTemplate.load_sfn_template(BaseTemplate.WAIT_TIMESTAMP_PATH) + definition = json.dumps(template) + + wait_timestamp = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta( + days=days + ) + timestamp = wait_timestamp.strftime("%Y-%m-%dT%H:%M:%S") + + full_timestamp = f"{timestamp}.000Z" + sfn_snapshot.add_transformer(sfn_snapshot.transform.regex(full_timestamp, "")) + exec_input = json.dumps({"start_at": full_timestamp}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "timestamp_suffix", + [ + # valid formats + "Z", + ".000000Z", + ".00Z", + # invalid formats + "", + ".000000", + ], + ) + @markers.aws.validated + def test_wait_timestamppath( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + timestamp_suffix, + ): + """ + - Timestamp needs to be in UTC (have a Z suffix) + - Timestamp can be in the past (succeeds immediately) + - Fractional seconds are optional and there's no specific number enforced (e.g. milliseconds vs. microseconds) + """ + template = BaseTemplate.load_sfn_template(BaseTemplate.WAIT_TIMESTAMP_PATH) + definition = json.dumps(template) + + wait_timestamp = datetime.datetime.now(tz=datetime.timezone.utc) + timestamp = wait_timestamp.strftime("%Y-%m-%dT%H:%M:%S") + + full_timestamp = f"{timestamp}{timestamp_suffix}" + sfn_snapshot.add_transformer(sfn_snapshot.transform.regex(full_timestamp, "")) + exec_input = json.dumps({"start_at": full_timestamp}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize("seconds_value", [-1, -1.5, 0, 1, 1.5]) + def test_base_wait_seconds_path( + self, + create_state_machine_iam_role, + create_state_machine, + aws_client, + sfn_snapshot, + seconds_value, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.WAIT_SECONDS_PATH) + definition = json.dumps(template) + execution_input = json.dumps({"input": {"waitSeconds": seconds_value}}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + execution_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/base/test_wait.snapshot.json b/tests/aws/services/stepfunctions/v2/base/test_wait.snapshot.json new file mode 100644 index 0000000000000..f84d404f824f5 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/base/test_wait.snapshot.json @@ -0,0 +1,831 @@ +{ + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_timestamp_too_far_in_future_boundary[24855]": { + "recorded-date": "13-12-2023, 07:09:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_timestamp_too_far_in_future_boundary[24856]": { + "recorded-date": "13-12-2023, 07:09:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State_1' (entered at the event id #2). Wait state delay is too long.", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[Z]": { + "recorded-date": "13-12-2023, 07:09:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "start_at": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "start_at": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[.000000Z]": { + "recorded-date": "13-12-2023, 07:09:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "start_at": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "start_at": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[.00Z]": { + "recorded-date": "13-12-2023, 07:10:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "start_at": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "start_at": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[]": { + "recorded-date": "13-12-2023, 07:10:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State_1' (entered at the event id #2). The TimestampPath parameter does not reference a valid ISO-8601 extended offset date-time format string: $.start_at == ", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_wait_timestamppath[.000000]": { + "recorded-date": "13-12-2023, 07:10:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "start_at": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State_1' (entered at the event id #2). The TimestampPath parameter does not reference a valid ISO-8601 extended offset date-time format string: $.start_at == ", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path": { + "recorded-date": "22-03-2024, 17:32:55", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input": { + "waitSeconds": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input": { + "waitSeconds": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "input": { + "waitSeconds": 1 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input": { + "waitSeconds": 1 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[-1]": { + "recorded-date": "22-03-2024, 21:37:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input": { + "waitSeconds": -1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input": { + "waitSeconds": -1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter references a negative value: $.input.waitSeconds == -1", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[0]": { + "recorded-date": "22-03-2024, 21:37:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input": { + "waitSeconds": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input": { + "waitSeconds": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "input": { + "waitSeconds": 0 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input": { + "waitSeconds": 0 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[1]": { + "recorded-date": "22-03-2024, 21:38:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input": { + "waitSeconds": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input": { + "waitSeconds": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "input": { + "waitSeconds": 1 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input": { + "waitSeconds": 1 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[1.5]": { + "recorded-date": "22-03-2024, 21:38:15", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input": { + "waitSeconds": 1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input": { + "waitSeconds": 1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter cannot be parsed as a long value: $.input.waitSeconds == 1.5", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[-1.5]": { + "recorded-date": "22-03-2024, 21:37:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input": { + "waitSeconds": -1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input": { + "waitSeconds": -1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter cannot be parsed as a long value: $.input.waitSeconds == -1.5", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/base/test_wait.validation.json b/tests/aws/services/stepfunctions/v2/base/test_wait.validation.json new file mode 100644 index 0000000000000..664aa5d7ba77b --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/base/test_wait.validation.json @@ -0,0 +1,26 @@ +{ + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds[0]": { + "last_validated_date": "2024-03-22T21:22:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds[1]": { + "last_validated_date": "2024-03-22T21:22:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path": { + "last_validated_date": "2024-03-22T17:32:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[-1.5]": { + "last_validated_date": "2024-03-22T21:37:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[-1]": { + "last_validated_date": "2024-03-22T21:37:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[0]": { + "last_validated_date": "2024-03-22T21:37:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[1.5]": { + "last_validated_date": "2024-03-22T21:38:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/base/test_wait.py::TestSfnWait::test_base_wait_seconds_path[1]": { + "last_validated_date": "2024-03-22T21:38:00+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/callback/__init__.py b/tests/aws/services/stepfunctions/v2/callback/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/callback/test_callback.py b/tests/aws/services/stepfunctions/v2/callback/test_callback.py new file mode 100644 index 0000000000000..90879273d2d28 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/callback/test_callback.py @@ -0,0 +1,873 @@ +import json +import threading + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.stepfunctions.asl.eval.count_down_latch import CountDownLatch +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_terminated, + create_and_record_execution, + create_state_machine_with_iam_role, +) +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate as BT +from tests.aws.services.stepfunctions.templates.callbacks.callback_templates import ( + CallbackTemplates as CT, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) +from tests.aws.services.stepfunctions.templates.timeouts.timeout_templates import ( + TimeoutTemplates as TT, +) +from tests.aws.test_notifications import PUBLICATION_RETRIES, PUBLICATION_TIMEOUT + + +def _handle_sqs_task_token_with_heartbeats_and_success(aws_client, queue_url) -> None: + # Handle the state machine task token published in the sqs queue, by submitting 10 heartbeat + # notifications and a task success notification. Snapshot the response of each call. + + # Read the expected sqs message and extract the body. + def _get_message_body(): + receive_message_response = aws_client.sqs.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1 + ) + return receive_message_response["Messages"][0]["Body"] + + message_body_str = retry(_get_message_body, retries=100, sleep=1) + message_body = json.loads(message_body_str) + + # Send the heartbeat notifications. + task_token = message_body["TaskToken"] + for i in range(10): + aws_client.stepfunctions.send_task_heartbeat(taskToken=task_token) + + # Send the task success notification. + aws_client.stepfunctions.send_task_success(taskToken=task_token, output=message_body_str) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestCallback: + @markers.aws.needs_fixing + def test_sqs_wait_for_task_token( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + sqs_send_task_success_state_machine(queue_url) + + template = CT.load_sfn_template(CT.SQS_WAIT_FOR_TASK_TOKEN) + definition = json.dumps(template) + + message_txt = "test_message_txt" + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message_txt}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.needs_fixing + def test_sqs_wait_for_task_token_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = CT.load_sfn_template(CT.SQS_WAIT_FOR_TASK_TOKEN_WITH_TIMEOUT) + definition = json.dumps(template) + + message_txt = "test_message_txt" + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message_txt}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.needs_fixing + def test_sqs_failure_in_wait_for_task_token( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_failure_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + sqs_send_task_failure_state_machine(queue_url) + + template = CT.load_sfn_template(CT.SQS_WAIT_FOR_TASK_TOKEN) + definition = json.dumps(template) + + message_txt = "test_message_txt" + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message_txt}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.needs_fixing + def test_sqs_wait_for_task_tok_with_heartbeat( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_heartbeat_and_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + sqs_send_heartbeat_and_task_success_state_machine(queue_url) + + template = CT.load_sfn_template(TT.SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT) + template["States"]["SendMessageWithWait"]["HeartbeatSeconds"] = 60 + definition = json.dumps(template) + + message_txt = "test_message_txt" + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message_txt}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_sns_publish_wait_for_task_token( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_receive_num_messages, + sns_create_topic, + sns_allow_topic_sqs_queue, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sns_api()) + + topic_info = sns_create_topic() + topic_arn = topic_info["TopicArn"] + queue_url = sqs_create_queue() + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + aws_client.sns.subscribe( + TopicArn=topic_arn, + Protocol="sqs", + Endpoint=queue_arn, + ) + sns_allow_topic_sqs_queue(queue_url, queue_arn, topic_arn) + + template = CT.load_sfn_template(CT.SNS_PUBLIC_WAIT_FOR_TASK_TOKEN) + definition = json.dumps(template) + + exec_input = json.dumps({"TopicArn": topic_arn, "body": {"arg1": "Hello", "arg2": "World"}}) + + messages = [] + + def record_messages_and_send_task_success(): + messages.clear() + messages.extend(sqs_receive_num_messages(queue_url, expected_messages=1)) + task_token = json.loads(messages[0]["Message"])["TaskToken"] + aws_client.stepfunctions.send_task_success(taskToken=task_token, output=json.dumps({})) + + threading.Thread( + target=retry, + args=(record_messages_and_send_task_success,), + kwargs={"retries": PUBLICATION_RETRIES, "sleep": PUBLICATION_TIMEOUT}, + ).start() + + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + sfn_snapshot.match("messages", messages) + + @markers.aws.validated + def test_start_execution_sync( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StopDate", + replacement="stop-date", + replace_reference=False, + ) + ) + + template_target = BT.load_sfn_template(BT.BASE_PASS_RESULT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = CT.load_sfn_template(CT.SFN_START_EXECUTION_SYNC) + definition = json.dumps(template) + + exec_input = json.dumps( + {"StateMachineArn": state_machine_arn_target, "Input": None, "Name": "TestStartTarget"} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_start_execution_sync2( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StopDate", + replacement="stop-date", + replace_reference=False, + ) + ) + + template_target = BT.load_sfn_template(BT.BASE_PASS_RESULT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = CT.load_sfn_template(CT.SFN_START_EXECUTION_SYNC2) + definition = json.dumps(template) + + exec_input = json.dumps( + {"StateMachineArn": state_machine_arn_target, "Input": None, "Name": "TestStartTarget"} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_start_execution_sync_delegate_failure( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..cause.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..cause.StopDate", + replacement="stop-date", + replace_reference=False, + ) + ) + + template_target = BT.load_sfn_template(BT.BASE_RAISE_FAILURE) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = CT.load_sfn_template(CT.SFN_START_EXECUTION_SYNC) + definition = json.dumps(template) + + exec_input = json.dumps( + {"StateMachineArn": state_machine_arn_target, "Input": None, "Name": "TestStartTarget"} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_start_execution_sync_delegate_timeout( + self, + aws_client, + create_lambda_function, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..cause.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..cause.StopDate", + replacement="stop-date", + replace_reference=False, + ) + ) + + function_name = f"lambda_1_func_{short_uid()}" + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=TT.LAMBDA_WAIT_60_SECONDS, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + + template_target = TT.load_sfn_template(TT.LAMBDA_WAIT_WITH_TIMEOUT_SECONDS) + template_target["States"]["Start"]["Resource"] = lambda_arn + definition_target = json.dumps(template_target) + + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = CT.load_sfn_template(CT.SFN_START_EXECUTION_SYNC) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "StateMachineArn": state_machine_arn_target, + "Input": {"Payload": None}, + "Name": "TestStartTarget", + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.skip(reason="Skipped until flaky behaviour can be rectified.") + def test_multiple_heartbeat_notifications( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "sqs_queue_name")) + + task_token_consumer_thread = threading.Thread( + target=_handle_sqs_task_token_with_heartbeats_and_success, + args=(aws_client, queue_url), + ) + task_token_consumer_thread.start() + + template = CT.load_sfn_template( + TT.SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT_PATH + ) + definition = json.dumps(template) + + exec_input = json.dumps( + {"QueueUrl": queue_url, "Message": "txt", "HeartbeatSecondsPath": 120} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + task_token_consumer_thread.join(timeout=300) + + @markers.aws.validated + @pytest.mark.skip(reason="Skipped until flaky behaviour can be rectified.") + def test_multiple_executions_and_heartbeat_notifications( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="a_task_token", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..MessageId", + replacement="a_message_id", + replace_reference=False, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "sqs_queue_name")) + + sfn_role_arn = create_state_machine_iam_role(aws_client) + + template = CT.load_sfn_template( + TT.SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT_PATH + ) + definition = json.dumps(template) + + creation_response = create_state_machine( + aws_client, + name=f"state_machine_{short_uid()}", + definition=definition, + roleArn=sfn_role_arn, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_response, 0)) + state_machine_arn = creation_response["stateMachineArn"] + + exec_input = json.dumps( + {"QueueUrl": queue_url, "Message": "txt", "HeartbeatSecondsPath": 120} + ) + + # Launch multiple execution of the same state machine. + execution_count = 6 + execution_arns = list() + for _ in range(execution_count): + execution_arn = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=exec_input + )["executionArn"] + execution_arns.append(execution_arn) + + # Launch one sqs task token handler per each execution, and await for all the terminate handling the task. + task_token_handler_latch = CountDownLatch(execution_count) + + def _sqs_task_token_handler(): + _handle_sqs_task_token_with_heartbeats_and_success(aws_client, queue_url) + task_token_handler_latch.count_down() + + for _ in range(execution_count): + inner_handler_thread = threading.Thread(target=_sqs_task_token_handler, args=()) + inner_handler_thread.start() + + task_token_handler_latch.wait() + + # For each execution, await terminate and record the event executions. + for i, execution_arn in enumerate(execution_arns): + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + execution_history = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn + ) + sfn_snapshot.match(f"execution_history_{i}", execution_history) + + @markers.aws.validated + def test_sqs_wait_for_task_token_call_chain( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..MessageId", + replacement="message_id", + replace_reference=True, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "sqs_queue_name")) + + sqs_send_task_success_state_machine(queue_url) + + template = CT.load_sfn_template(CT.SQS_WAIT_FOR_TASK_TOKEN_CALL_CHAIN) + definition = json.dumps(template) + + exec_input = json.dumps({"QueueUrl": queue_url, "Message": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_sqs_wait_for_task_token_no_token_parameter( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "sqs_queue_name")) + + template = CT.load_sfn_template(CT.SQS_WAIT_FOR_TASK_TOKEN_NO_TOKEN_PARAMETER) + definition = json.dumps(template) + + exec_input = json.dumps({"QueueUrl": queue_url}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template", + [CT.SQS_PARALLEL_WAIT_FOR_TASK_TOKEN, CT.SQS_WAIT_FOR_TASK_TOKEN_CATCH], + ids=["SQS_PARALLEL_WAIT_FOR_TASK_TOKEN", "SQS_WAIT_FOR_TASK_TOKEN_CATCH"], + ) + def test_sqs_failure_in_wait_for_task_tok_no_error_field( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + template, + request, + ): + if ( + not is_aws_cloud() + and request.node.name + == "test_sqs_failure_in_wait_for_task_tok_no_error_field[SQS_PARALLEL_WAIT_FOR_TASK_TOKEN]" + ): + # TODO: The conditions in which TaskStateAborted error events are logged requires further investigations. + # These appear to be logged for Task state workers but only within Parallel states. The behaviour with + # other 'Abort' errors should also be investigated. + pytest.skip("Investigate occurrence logic of 'TaskStateAborted' errors") + + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + def _empty_send_task_failure_on_sqs_message(): + def _get_message_body(): + receive_message_response = aws_client.sqs.receive_message( + QueueUrl=queue_url, MaxNumberOfMessages=1 + ) + return receive_message_response["Messages"][0]["Body"] + + message_body_str = retry(_get_message_body, retries=60, sleep=1) + message_body = json.loads(message_body_str) + task_token = message_body["TaskToken"] + aws_client.stepfunctions.send_task_failure(taskToken=task_token) + + thread_send_task_failure = threading.Thread( + target=_empty_send_task_failure_on_sqs_message, + args=(), + name="Thread_empty_send_task_failure_on_sqs_message", + ) + thread_send_task_failure.daemon = True + thread_send_task_failure.start() + + template = CT.load_sfn_template(template) + definition = json.dumps(template) + + exec_input = json.dumps({"QueueUrl": queue_url, "Message": "test_message_txt"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_sync_with_task_token( + self, + aws_client, + sqs_create_queue, + sqs_send_task_success_state_machine, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + # This tests simulates a sync integration pattern interrupt via a manual + # SendTaskSuccess command about the task's TaskToken. + + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StopDate", + replacement="stop-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..MessageId", + replacement="message_id", + replace_reference=True, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + # Set up the queue on which the worker sending SendTaskSuccess requests will be listening for + # TaskToken values to accept. + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "sqs_queue_name")) + # Start the worker which requests SendTaskSuccess about the incoming TaskToken values on the queue. + sqs_send_task_success_state_machine(queue_url) + + # Create the child state machine, which receives the parent's TaskToken, forwards it to the SendTaskSuccess + # worker and simulates a long-lasting task by waiting. + template_target = BT.load_sfn_template(ST.SQS_SEND_MESSAGE_AND_WAIT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + # Create the parent state machine, which starts the child state machine with a sync integration pattern. + template = CT.load_sfn_template(CT.SFN_START_EXECUTION_SYNC_WITH_TASK_TOKEN) + definition = json.dumps(template) + + # Start the stack and record the behaviour of the parent state machine. The events recorded + # should show the sync integration pattern about the child state machine being interrupted + # by the SendTaskSuccess state machine. + exec_input = json.dumps( + { + "StateMachineArn": state_machine_arn_target, + "Name": "TestStartTarget", + "QueueUrl": queue_url, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/callback/test_callback.snapshot.json b/tests/aws/services/stepfunctions/v2/callback/test_callback.snapshot.json new file mode 100644 index 0000000000000..1c28f5a96d67d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/callback/test_callback.snapshot.json @@ -0,0 +1,3728 @@ +{ + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token": { + "recorded-date": "18-04-2024, 06:19:31", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "<:1>" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": "\"test_message_txt\"", + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": "\"test_message_txt\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"test_message_txt\"", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_timeout": { + "recorded-date": "18-04-2024, 06:20:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWaitAndTimeout" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "<:1>" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_token": { + "recorded-date": "18-04-2024, 06:24:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "cause": "Failure cause", + "error": "Failure error", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Failure cause", + "error": "Failure error" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_tok_with_heartbeat": { + "recorded-date": "18-04-2024, 06:25:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 60, + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "<:1>" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": "\"test_message_txt\"", + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": "\"test_message_txt\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"test_message_txt\"", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync": { + "recorded-date": "30-06-2023, 14:42:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "161" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "161", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync_delegate_failure": { + "recorded-date": "05-07-2023, 15:55:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "161" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "161", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "cause": { + "Cause": "This state machines raises a 'SomeFailure' failure.", + "Error": "SomeFailure", + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "FAILED", + "StopDate": "stop-date" + }, + "error": "States.TaskFailed", + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "Cause": "This state machines raises a 'SomeFailure' failure.", + "Error": "SomeFailure", + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "FAILED", + "StopDate": "stop-date" + }, + "error": "States.TaskFailed" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync_delegate_timeout": { + "recorded-date": "05-07-2023, 16:16:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": { + "Payload": null + }, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": { + "Payload": null + }, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": { + "Payload": null + }, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "161" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "161", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "cause": { + "Error": "States.Timeout", + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": "{\"Payload\":null}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "FAILED", + "StopDate": "stop-date" + }, + "error": "States.TaskFailed", + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "Error": "States.Timeout", + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": "{\"Payload\":null}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "FAILED", + "StopDate": "stop-date" + }, + "error": "States.TaskFailed" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync2": { + "recorded-date": "12-08-2023, 16:45:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "161" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "161", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": {}, + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": { + "Arg1": "argument1" + }, + "OutputDetails": { + "Included": true + }, + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": {}, + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": { + "Arg1": "argument1" + }, + "OutputDetails": { + "Included": true + }, + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "Input": {}, + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": { + "Arg1": "argument1" + }, + "OutputDetails": { + "Included": true + }, + "StartDate": "start-date", + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sns_publish_wait_for_task_token": { + "recorded-date": "01-02-2024, 20:51:35", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "body": { + "arg1": "Hello", + "arg2": "World" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "body": { + "arg1": "Hello", + "arg2": "World" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Message": { + "arg2": "World", + "arg1": "Hello", + "TaskToken": "" + }, + "TopicArn": "arn::sns::111111111111:" + }, + "region": "", + "resource": "publish.waitForTaskToken", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish.waitForTaskToken", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish.waitForTaskToken", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + }, + "resource": "publish.waitForTaskToken", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "Publish", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": { + "arg2": "World", + "arg1": "Hello", + "TaskToken": "" + }, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_multiple_heartbeat_notifications": { + "recorded-date": "18-04-2024, 06:17:37", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 120, + "parameters": { + "MessageBody": { + "Message": "txt", + "TaskToken": "" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Message": "txt", + "TaskToken": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_multiple_executions_and_heartbeat_notifications": { + "recorded-date": "18-04-2024, 06:18:29", + "recorded-content": { + "execution_history_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 120, + "parameters": { + "MessageBody": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "a_message_id", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_history_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 120, + "parameters": { + "MessageBody": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "a_message_id", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_history_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 120, + "parameters": { + "MessageBody": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "a_message_id", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_history_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 120, + "parameters": { + "MessageBody": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "a_message_id", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_history_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 120, + "parameters": { + "MessageBody": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "a_message_id", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_history_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "txt", + "HeartbeatSecondsPath": 120 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 120, + "parameters": { + "MessageBody": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "a_message_id", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Message": "txt", + "TaskToken": "a_task_token" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_call_chain": { + "recorded-date": "22-04-2024, 14:04:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Message": "HelloWorld", + "TaskToken": "" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "State1", + "output": { + "QueueUrl": "sqs_queue_url", + "Message": "HelloWorld", + "State1Output": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "HelloWorld", + "State1Output": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "State2" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Message": "HelloWorld", + "TaskToken": "" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 11, + "previousEventId": 10, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 12, + "previousEventId": 11, + "taskSucceededEventDetails": { + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 13, + "previousEventId": 12, + "stateExitedEventDetails": { + "name": "State2", + "output": { + "QueueUrl": "sqs_queue_url", + "Message": "HelloWorld", + "State1Output": "HelloWorld", + "State2Output": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "QueueUrl": "sqs_queue_url", + "Message": "HelloWorld", + "State1Output": "HelloWorld", + "State2Output": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 14, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_no_token_parameter": { + "recorded-date": "22-04-2024, 17:15:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url" + }, + "inputDetails": { + "truncated": false + }, + "name": "State1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Context": { + "QueueUrl": "sqs_queue_url" + } + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_tok_no_error_field[SQS_PARALLEL_WAIT_FOR_TASK_TOKEN]": { + "recorded-date": "29-04-2024, 10:07:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "ParallelJob" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Context": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "TaskToken": "" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 7, + "previousEventId": 6, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 10, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ParallelStateFailed" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "ParallelJob", + "output": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CaughtStatesALL" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "stateExitedEventDetails": { + "name": "CaughtStatesALL", + "output": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 14, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_tok_no_error_field[SQS_WAIT_FOR_TASK_TOKEN_CATCH]": { + "recorded-date": "29-04-2024, 10:07:27", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Context": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "TaskToken": "" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CaughtStatesALL" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "CaughtStatesALL", + "output": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "QueueUrl": "", + "Message": "test_message_txt", + "states_all_error": { + "Error": null, + "Cause": null + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sync_with_task_token": { + "recorded-date": "12-07-2024, 07:34:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "", + "QueueUrl": "sqs_queue_url" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "", + "QueueUrl": "sqs_queue_url" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": { + "TaskToken": "", + "QueueUrl": "sqs_queue_url" + }, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "164" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "164", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": "\"SQS_SEND_MESSAGE_AND_WAIT\"", + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": "\"SQS_SEND_MESSAGE_AND_WAIT\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"SQS_SEND_MESSAGE_AND_WAIT\"", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/callback/test_callback.validation.json b/tests/aws/services/stepfunctions/v2/callback/test_callback.validation.json new file mode 100644 index 0000000000000..35ac142204a4d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/callback/test_callback.validation.json @@ -0,0 +1,50 @@ +{ + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_multiple_executions_and_heartbeat_notifications": { + "last_validated_date": "2024-04-18T06:18:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_multiple_heartbeat_notifications": { + "last_validated_date": "2024-04-18T06:17:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sns_publish_wait_for_task_token": { + "last_validated_date": "2024-02-01T20:51:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_tok_no_error_field[SQS_PARALLEL_WAIT_FOR_TASK_TOKEN]": { + "last_validated_date": "2024-04-29T10:07:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_tok_no_error_field[SQS_WAIT_FOR_TASK_TOKEN_CATCH]": { + "last_validated_date": "2024-04-29T10:07:27+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_failure_in_wait_for_task_token": { + "last_validated_date": "2024-04-18T06:24:24+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_tok_with_heartbeat": { + "last_validated_date": "2024-04-18T06:25:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token": { + "last_validated_date": "2024-04-18T06:19:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_call_chain": { + "last_validated_date": "2024-04-22T14:04:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_no_token_parameter": { + "last_validated_date": "2024-04-22T17:15:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sqs_wait_for_task_token_timeout": { + "last_validated_date": "2024-04-18T06:20:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync": { + "last_validated_date": "2023-06-30T12:42:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync2": { + "last_validated_date": "2023-08-12T14:45:39+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync_delegate_failure": { + "last_validated_date": "2023-07-05T13:55:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_start_execution_sync_delegate_timeout": { + "last_validated_date": "2023-07-05T14:16:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/callback/test_callback.py::TestCallback::test_sync_with_task_token": { + "last_validated_date": "2024-07-12T07:34:57+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/__init__.py b/tests/aws/services/stepfunctions/v2/choice_operators/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py b/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py new file mode 100644 index 0000000000000..f7f4ec6ef80af --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py @@ -0,0 +1,36 @@ +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.v2.choice_operators.utils import ( + TYPE_COMPARISONS, + create_and_test_comparison_function, +) + +# TODO: test for validation errors, and boundary testing. + + +class TestBooleanEquals: + @markers.aws.validated + def test_boolean_equals( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "BooleanEquals", + comparisons=TYPE_COMPARISONS, + ) + + @markers.aws.validated + def test_boolean_equals_path( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "BooleanEqualsPath", + comparisons=TYPE_COMPARISONS, + add_literal_value=False, + ) diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.snapshot.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.snapshot.json new file mode 100644 index 0000000000000..67a5d3b4baae4 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.snapshot.json @@ -0,0 +1,680 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py::TestBooleanEquals::test_boolean_equals": { + "recorded-date": "22-06-2023, 13:06:32", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py::TestBooleanEquals::test_boolean_equals_path": { + "recorded-date": "22-06-2023, 13:07:20", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.validation.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.validation.json new file mode 100644 index 0000000000000..be8dd1719e7d9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py::TestBooleanEquals::test_boolean_equals": { + "last_validated_date": "2023-06-22T11:06:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_boolean_equals.py::TestBooleanEquals::test_boolean_equals_path": { + "last_validated_date": "2023-06-22T11:07:20+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py b/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py new file mode 100644 index 0000000000000..3c14efbaadbd0 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py @@ -0,0 +1,93 @@ +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.v2.choice_operators.utils import ( + TYPE_COMPARISONS, + create_and_test_comparison_function, +) + +# TODO: test for validation errors, and boundary testing. + + +class TestIsOperators: + @markers.aws.validated + def test_is_boolean( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "IsBoolean", + comparisons=TYPE_COMPARISONS, + ) + + @markers.aws.validated + def test_is_null( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "IsNull", + comparisons=TYPE_COMPARISONS, + ) + + @markers.aws.validated + def test_is_numeric( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "IsNumeric", + comparisons=TYPE_COMPARISONS, + ) + + @markers.aws.validated + def test_is_present( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "IsPresent", + comparisons=TYPE_COMPARISONS, + ) + + @markers.aws.validated + def test_is_string( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "IsString", + comparisons=TYPE_COMPARISONS, + ) + + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="TODO: investigate IsTimestamp behaviour." + ) + @markers.aws.needs_fixing + def test_is_timestamp( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "IsTimestamp", + comparisons=TYPE_COMPARISONS, + ) diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.snapshot.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.snapshot.json new file mode 100644 index 0000000000000..65cd95aec234f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.snapshot.json @@ -0,0 +1,1697 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_boolean": { + "recorded-date": "22-06-2023, 13:08:16", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1.1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": " ", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": {}, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": true, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_null": { + "recorded-date": "22-06-2023, 13:09:08", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": null, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1.1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": " ", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": {}, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": true, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_numeric": { + "recorded-date": "22-06-2023, 13:09:58", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1.1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": " ", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": {}, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": true, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_present": { + "recorded-date": "22-06-2023, 13:11:07", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": null, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1.1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": " ", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [], + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": {}, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": false, + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_string": { + "recorded-date": "22-06-2023, 13:12:11", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1.1, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": " ", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "HelloWorld", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2012-10-09T19:00:55", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": true + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "2023-02-24", + "Value": false + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": {}, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": true, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": true + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": false + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.validation.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.validation.json new file mode 100644 index 0000000000000..d44401d16910e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_boolean": { + "last_validated_date": "2023-06-22T11:08:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_null": { + "last_validated_date": "2023-06-22T11:09:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_numeric": { + "last_validated_date": "2023-06-22T11:09:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_present": { + "last_validated_date": "2023-06-22T11:11:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_is_operators.py::TestIsOperators::test_is_string": { + "last_validated_date": "2023-06-22T11:12:11+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py b/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py new file mode 100644 index 0000000000000..70bd1954588db --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py @@ -0,0 +1,215 @@ +from typing import Any, Final + +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.v2.choice_operators.utils import ( + create_and_test_comparison_function, +) + +# TODO: test for validation errors, and boundary testing. + +TYPE_COMPARISONS_VARS: Final[list[Any]] = [ + None, + 0, + 0.0, + 1, + 1.1, + "", + " ", + [], + [""], + {}, + {"A": 0}, + False, + True, +] + + +class TestNumerics: + @markers.aws.validated + def test_numeric_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + type_equals = [] + for var in TYPE_COMPARISONS_VARS: + type_equals.append((var, 0)) + type_equals.append((var, 0.0)) + type_equals.append((var, 1)) + type_equals.append((var, 1.0)) + + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericEquals", + comparisons=[*type_equals, (-0, 0), (0.0, 0), (2.22, 2.22)], + ) + + @markers.aws.validated + def test_numeric_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + type_equals = [] + for var in TYPE_COMPARISONS_VARS: + type_equals.append((var, 0)) + type_equals.append((var, 0.0)) + type_equals.append((var, 1)) + type_equals.append((var, 1.0)) + + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericEqualsPath", + comparisons=[*type_equals, (-0, 0), (0.0, 0), (2.22, 2.22)], + add_literal_value=False, + ) + + @markers.aws.validated + def test_numeric_greater_than( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericGreaterThan", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + ) + + @markers.aws.validated + def test_numeric_greater_than_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericGreaterThanPath", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + add_literal_value=False, + ) + + @markers.aws.validated + def test_numeric_greater_than_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericGreaterThanEquals", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + ) + + @markers.aws.validated + def test_numeric_greater_than_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericGreaterThanEqualsPath", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + add_literal_value=False, + ) + + @markers.aws.validated + def test_numeric_less_than( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericLessThan", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + ) + + @markers.aws.validated + def test_numeric_less_than_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericLessThanPath", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + add_literal_value=False, + ) + + @markers.aws.validated + def test_numeric_less_than_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericLessThanEquals", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + ) + + @markers.aws.validated + def test_numeric_less_than_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "NumericLessThanEqualsPath", + comparisons=[(-0, 0), (0.0, 0), (0, 1), (1, 1), (1, 0), (0, 1)], + add_literal_value=False, + ) diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.snapshot.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.snapshot.json new file mode 100644 index 0000000000000..a0a9915e6a79d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.snapshot.json @@ -0,0 +1,1526 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_equals": { + "recorded-date": "22-06-2023, 13:14:08", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 2.22, + "Value": 2.22 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_equals_path": { + "recorded-date": "01-03-2023, 15:29:11", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": null, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 0.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": 1.0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 2.22, + "Value": 2.22 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than": { + "recorded-date": "01-03-2023, 15:30:11", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_path": { + "recorded-date": "01-03-2023, 15:30:18", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_equals": { + "recorded-date": "01-03-2023, 15:30:31", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_equals_path": { + "recorded-date": "01-03-2023, 15:30:46", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than": { + "recorded-date": "01-03-2023, 15:30:53", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_path": { + "recorded-date": "01-03-2023, 15:31:00", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_equals": { + "recorded-date": "01-03-2023, 15:31:10", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_equals_path": { + "recorded-date": "01-03-2023, 15:31:18", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": 0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0.0, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": 1, + "Value": 0 + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": 1 + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.validation.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.validation.json new file mode 100644 index 0000000000000..00e7e59d0b3a9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.validation.json @@ -0,0 +1,32 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_equals": { + "last_validated_date": "2023-06-22T11:14:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_equals_path": { + "last_validated_date": "2023-03-01T14:29:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than": { + "last_validated_date": "2023-03-01T14:30:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_equals": { + "last_validated_date": "2023-03-01T14:30:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_equals_path": { + "last_validated_date": "2023-03-01T14:30:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_greater_than_path": { + "last_validated_date": "2023-03-01T14:30:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than": { + "last_validated_date": "2023-03-01T14:30:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_equals": { + "last_validated_date": "2023-03-01T14:31:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_equals_path": { + "last_validated_date": "2023-03-01T14:31:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_numeric.py::TestNumerics::test_numeric_less_than_path": { + "last_validated_date": "2023-03-01T14:31:00+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py b/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py new file mode 100644 index 0000000000000..0f7b05fb669b3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py @@ -0,0 +1,212 @@ +from typing import Any, Final + +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.v2.choice_operators.utils import ( + create_and_test_comparison_function, +) + +# TODO: test for validation errors, and boundary testing. + +TYPE_COMPARISONS_VARS: Final[list[Any]] = [ + None, + 0, + 0.0, + 1, + 1.1, + "", + " ", + [], + [""], + {}, + {"A": 0}, + False, + True, +] + + +class TestStrings: + @markers.aws.validated + def test_string_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + type_equals = [] + for var in TYPE_COMPARISONS_VARS: + type_equals.append((var, "HelloWorld")) + + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringEquals", + comparisons=[*type_equals, (" ", " "), ("\t\n", "\t\r\n"), ("Hello", "Hello")], + ) + + @markers.aws.validated + def test_string_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + type_equals = [] + for var in TYPE_COMPARISONS_VARS: + type_equals.append((var, 0)) + type_equals.append((var, 0.0)) + type_equals.append((var, 1)) + type_equals.append((var, 1.0)) + + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringEqualsPath", + comparisons=[(" ", " "), ("\t\n", "\t\r\n"), ("Hello", "Hello")], + add_literal_value=False, + ) + + @markers.aws.validated + def test_string_greater_than( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringGreaterThan", + comparisons=[("", ""), ("A", "A "), ("A", "A\t\n\r"), ("AB", "ABC")], + ) + + @markers.aws.validated + def test_string_greater_than_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringGreaterThanPath", + comparisons=[("", ""), ("A", "A "), ("A", "A\t\n\r"), ("AB", "ABC")], + add_literal_value=False, + ) + + @markers.aws.validated + def test_string_greater_than_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringGreaterThanEquals", + comparisons=[("", ""), ("A", "AB"), ("AB", "A")], + ) + + @markers.aws.validated + def test_string_greater_than_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringGreaterThanEqualsPath", + comparisons=[("", ""), ("A", "AB"), ("AB", "A")], + add_literal_value=False, + ) + + @markers.aws.validated + def test_string_less_than( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringLessThan", + comparisons=[("", ""), ("A", "AB"), ("AB", "A")], + ) + + @markers.aws.validated + def test_string_less_than_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringLessThanPath", + comparisons=[("", ""), ("A", "AB"), ("AB", "A")], + add_literal_value=False, + ) + + @markers.aws.validated + def test_string_less_than_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringLessThanEquals", + comparisons=[("", ""), ("A", "AB"), ("AB", "A")], + ) + + @markers.aws.validated + def test_string_less_than_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "StringLessThanEqualsPath", + comparisons=[("", ""), ("A", "AB"), ("AB", "A")], + add_literal_value=False, + ) diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.snapshot.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.snapshot.json new file mode 100644 index 0000000000000..7b27f200389b9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.snapshot.json @@ -0,0 +1,481 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than": { + "recorded-date": "22-06-2023, 13:16:03", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "A", + "Value": "A " + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "A", + "Value": "A\t\n\r" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "AB", + "Value": "ABC" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_equals": { + "recorded-date": "22-06-2023, 13:15:27", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": "HelloWorld" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": " " + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "\t\n", + "Value": "\t\r\n" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "Hello", + "Value": "Hello" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_equals_path": { + "recorded-date": "22-06-2023, 13:15:46", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": " ", + "Value": " " + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "\t\n", + "Value": "\t\r\n" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "Hello", + "Value": "Hello" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_path": { + "recorded-date": "22-06-2023, 13:16:20", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "A", + "Value": "A " + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "A", + "Value": "A\t\n\r" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "AB", + "Value": "ABC" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_equals": { + "recorded-date": "22-06-2023, 13:16:37", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "A", + "Value": "AB" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "AB", + "Value": "A" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_equals_path": { + "recorded-date": "22-06-2023, 13:16:54", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "A", + "Value": "AB" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "AB", + "Value": "A" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than": { + "recorded-date": "22-06-2023, 13:17:10", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "A", + "Value": "AB" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "AB", + "Value": "A" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_path": { + "recorded-date": "22-06-2023, 13:17:26", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "A", + "Value": "AB" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "AB", + "Value": "A" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_equals": { + "recorded-date": "22-06-2023, 13:17:43", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "A", + "Value": "AB" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "AB", + "Value": "A" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_equals_path": { + "recorded-date": "22-06-2023, 13:17:59", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "", + "Value": "" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "A", + "Value": "AB" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "AB", + "Value": "A" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.validation.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.validation.json new file mode 100644 index 0000000000000..31baba6cbb0cb --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.validation.json @@ -0,0 +1,32 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_equals": { + "last_validated_date": "2023-06-22T11:15:27+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_equals_path": { + "last_validated_date": "2023-06-22T11:15:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than": { + "last_validated_date": "2023-06-22T11:16:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_equals": { + "last_validated_date": "2023-06-22T11:16:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_equals_path": { + "last_validated_date": "2023-06-22T11:16:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_greater_than_path": { + "last_validated_date": "2023-06-22T11:16:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than": { + "last_validated_date": "2023-06-22T11:17:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_equals": { + "last_validated_date": "2023-06-22T11:17:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_equals_path": { + "last_validated_date": "2023-06-22T11:17:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_string_operators.py::TestStrings::test_string_less_than_path": { + "last_validated_date": "2023-06-22T11:17:26+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py b/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py new file mode 100644 index 0000000000000..859cd1d1d6467 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py @@ -0,0 +1,210 @@ +from typing import Any, Final + +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.v2.choice_operators.utils import ( + create_and_test_comparison_function, +) + +# TODO: test for validation errors, and boundary testing. + +TYPE_COMPARISONS_VARS: Final[list[Any]] = [ + None, + 0, + 0.0, + 1, + 1.1, + "", + " ", + "2023-02-24 12:15:56.832957", + [], + [""], + {}, + {"A": 0}, + False, + True, +] + +T0: Final[str] = "2012-10-09T19:00:55Z" +T1: Final[str] = "2012-10-09T19:00:56Z" +BASE_COMPARISONS: Final[list[tuple[str, str]]] = [(T0, T0), (T0, T1), (T1, T0)] + + +class TestTimestamps: + @markers.aws.validated + def test_timestamp_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + type_equals = [] + for var in TYPE_COMPARISONS_VARS: + type_equals.append((var, T0)) + + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampEquals", + comparisons=[*type_equals, *BASE_COMPARISONS], + ) + + @markers.aws.validated + def test_timestamp_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampEqualsPath", + comparisons=BASE_COMPARISONS, + add_literal_value=False, + ) + + @markers.aws.validated + def test_timestamp_greater_than( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampGreaterThan", + comparisons=BASE_COMPARISONS, + ) + + @markers.aws.validated + def test_timestamp_greater_than_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampGreaterThanPath", + comparisons=[(T0, T1)], + add_literal_value=False, + ) + + @markers.aws.validated + def test_timestamp_greater_than_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampGreaterThanEquals", + comparisons=BASE_COMPARISONS, + ) + + @markers.aws.validated + def test_timestamp_greater_than_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampGreaterThanEqualsPath", + comparisons=[(T0, T1)], + add_literal_value=False, + ) + + @markers.aws.validated + def test_timestamp_less_than( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampLessThan", + comparisons=BASE_COMPARISONS, + ) + + @markers.aws.validated + def test_timestamp_less_than_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampLessThanPath", + comparisons=[(T1, T0)], + add_literal_value=False, + ) + + @markers.aws.validated + def test_timestamp_less_than_equals( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampLessThanEquals", + comparisons=BASE_COMPARISONS, + ) + + @markers.aws.validated + def test_timestamp_less_than_equals_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + create_and_test_comparison_function( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + "TimestampLessThanEqualsPath", + comparisons=[(T1, T0)], + add_literal_value=False, + ) diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.snapshot.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.snapshot.json new file mode 100644 index 0000000000000..e1c5b7802ed1a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.snapshot.json @@ -0,0 +1,400 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than": { + "recorded-date": "22-06-2023, 13:22:29", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_equals": { + "recorded-date": "22-06-2023, 13:21:55", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": null, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 0.0, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": 1.1, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": " ", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "2023-02-24 12:15:56.832957", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [], + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": [ + "" + ], + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": {}, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": { + "A": 0 + }, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": false, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": true, + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_equals_path": { + "recorded-date": "22-06-2023, 13:22:14", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_path": { + "recorded-date": "22-06-2023, 13:22:44", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_equals": { + "recorded-date": "22-06-2023, 13:23:00", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_equals_path": { + "recorded-date": "22-06-2023, 13:23:14", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than": { + "recorded-date": "22-06-2023, 13:23:29", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_path": { + "recorded-date": "22-06-2023, 13:23:44", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_equals": { + "recorded-date": "22-06-2023, 13:24:00", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionTrue" + } + }, + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_equals_path": { + "recorded-date": "22-06-2023, 13:24:14", + "recorded-content": { + "cases": [ + { + "input": { + "Variable": "date", + "Value": "date" + }, + "output": { + "TestResult.$": "ConditionFalse" + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.validation.json b/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.validation.json new file mode 100644 index 0000000000000..64e47f9643872 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.validation.json @@ -0,0 +1,32 @@ +{ + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_equals": { + "last_validated_date": "2023-06-22T11:21:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_equals_path": { + "last_validated_date": "2023-06-22T11:22:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than": { + "last_validated_date": "2023-06-22T11:22:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_equals": { + "last_validated_date": "2023-06-22T11:23:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_equals_path": { + "last_validated_date": "2023-06-22T11:23:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_greater_than_path": { + "last_validated_date": "2023-06-22T11:22:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than": { + "last_validated_date": "2023-06-22T11:23:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_equals": { + "last_validated_date": "2023-06-22T11:24:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_equals_path": { + "last_validated_date": "2023-06-22T11:24:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/choice_operators/test_timestamp_operators.py::TestTimestamps::test_timestamp_less_than_path": { + "last_validated_date": "2023-06-22T11:23:44+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/choice_operators/utils.py b/tests/aws/services/stepfunctions/v2/choice_operators/utils.py new file mode 100644 index 0000000000000..55d8830cc4c11 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/choice_operators/utils.py @@ -0,0 +1,103 @@ +import json +from typing import Any, Final + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.stepfunctions.asl.utils.json_path import extract_json +from localstack.testing.pytest.stepfunctions.utils import await_execution_success +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.choiceoperators.choice_operators_templates import ( + ChoiceOperatorTemplate as COT, +) + +TYPE_COMPARISONS: Final[list[tuple[Any, bool]]] = [ + (None, True), # 0 + (None, False), # 1 + (0, True), # 2 + (0, False), # 3 + (0.0, True), # 4 + (0.0, False), # 5 + (1, True), # 6 + (1, False), # 7 + (1.1, True), # 8 + (1.1, False), # 9 + ("", True), # 10 + ("", False), # 11 + (" ", True), # 12 + (" ", False), # 13 + ("HelloWorld", True), # 14 + ("HelloWorld", False), # 15 + ("2012-10-09T19:00:55", True), # 16 + ("2012-10-09T19:00:55", False), # 17 + ("2012-10-09T19:00:55Z", True), # 18 + ("2012-10-09T19:00:55Z", False), # 19 + ("2012-10-09T19:00:55+01:00", True), # 20 + ("2012-10-09T19:00:55+01:00", False), # 21 + ("2023-02-24", True), # 22 + ("2023-02-24", False), # 23 + ([], True), # 24 + ([], False), # 25 + ([""], True), # 26 + ([""], False), # 27 + ({}, True), # 28 + ({}, False), # 29 + ({"A": 0}, True), # 30 + ({"A": 0}, False), # 31 + (True, True), # 32 + (False, True), # 33 + (False, True), # 34 + (False, False), # 35 +] + + +def create_and_test_comparison_function( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + comparison_func_name: str, + comparisons: list[tuple[Any, Any]], + add_literal_value: bool = True, +): + stepfunctions_client = target_aws_client.stepfunctions + snf_role_arn = create_state_machine_iam_role(target_aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + base_sm_name: str = f"statemachine_{short_uid()}" + + definition = COT.load_sfn_template(COT.BASE_TEMPLATE) + definition_str = json.dumps(definition) + definition_str = definition_str.replace( + COT.COMPARISON_OPERATOR_PLACEHOLDER, comparison_func_name + ) + + input_output_cases: list[dict[str, Any]] = list() + for i, (variable, value) in enumerate(comparisons): + exec_input = json.dumps({COT.VARIABLE_KEY: variable, COT.VALUE_KEY: value}) + + if add_literal_value: + new_definition_str = definition_str.replace(COT.VALUE_PLACEHOLDER, json.dumps(value)) + else: + new_definition_str = definition_str + + creation_resp = create_state_machine( + target_aws_client, + name=f"{base_sm_name}_{i}", + definition=new_definition_str, + roleArn=snf_role_arn, + ) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = stepfunctions_client.start_execution( + stateMachineArn=state_machine_arn, input=exec_input + ) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=stepfunctions_client, execution_arn=execution_arn + ) + + exec_hist_resp = stepfunctions_client.get_execution_history(executionArn=execution_arn) + output = extract_json("$.events[*].executionSucceededEventDetails.output", exec_hist_resp) + input_output_cases.append({"input": exec_input, "output": output}) + sfn_snapshot.match("cases", input_output_cases) diff --git a/tests/aws/services/stepfunctions/v2/comments/__init__.py b/tests/aws/services/stepfunctions/v2/comments/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/comments/test_comments.py b/tests/aws/services/stepfunctions/v2/comments/test_comments.py new file mode 100644 index 0000000000000..d7e447699f2fd --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/comments/test_comments.py @@ -0,0 +1,70 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_record_execution +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.comment.comment_templates import ( + CommentTemplates as CT, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +class TestComments: + @markers.aws.validated + def test_comments_as_per_docs( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_1_name = f"lambda_1_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_1_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "lambda_function_1_name")) + + template = CT.load_sfn_template(CT.COMMENTS_AS_PER_DOCS) + template["States"]["TaskStateCatchRetry"]["Resource"] = create_1_res[ + "CreateFunctionResponse" + ]["FunctionArn"] + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_comment_in_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = CT.load_sfn_template(CT.COMMENT_IN_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/comments/test_comments.snapshot.json b/tests/aws/services/stepfunctions/v2/comments/test_comments.snapshot.json new file mode 100644 index 0000000000000..4a912fc31db05 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/comments/test_comments.snapshot.json @@ -0,0 +1,422 @@ +{ + "tests/aws/services/stepfunctions/v2/comments/test_comments.py::TestComments::test_comments_as_per_docs": { + "recorded-date": "28-11-2024, 10:33:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "SetupInitialCondition" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "SetupInitialCondition", + "output": { + "status": "incomplete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "status": "incomplete" + }, + "inputDetails": { + "truncated": false + }, + "name": "TaskStateCatchRetry" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "lambdaFunctionScheduledEventDetails": { + "input": { + "status": "incomplete" + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_1_name" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 7, + "lambdaFunctionSucceededEventDetails": { + "output": { + "status": "incomplete" + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "TaskStateCatchRetry", + "output": { + "status": "incomplete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": { + "status": "incomplete" + }, + "inputDetails": { + "truncated": false + }, + "name": "IsComplete" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "IsComplete", + "output": { + "status": "incomplete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "status": "incomplete" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "status": "incomplete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": { + "status": "incomplete" + }, + "inputDetails": { + "truncated": false + }, + "name": "CorrectCondition" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "CorrectCondition", + "output": { + "status": "complete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "status": "complete" + }, + "inputDetails": { + "truncated": false + }, + "name": "TaskStateCatchRetry" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 16, + "lambdaFunctionScheduledEventDetails": { + "input": { + "status": "complete" + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_1_name" + }, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 17, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 18, + "lambdaFunctionSucceededEventDetails": { + "output": { + "status": "complete" + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 19, + "previousEventId": 18, + "stateExitedEventDetails": { + "name": "TaskStateCatchRetry", + "output": { + "status": "complete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 20, + "previousEventId": 19, + "stateEnteredEventDetails": { + "input": { + "status": "complete" + }, + "inputDetails": { + "truncated": false + }, + "name": "IsComplete" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 21, + "previousEventId": 20, + "stateExitedEventDetails": { + "name": "IsComplete", + "output": { + "status": "complete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 22, + "previousEventId": 21, + "stateEnteredEventDetails": { + "input": { + "status": "complete" + }, + "inputDetails": { + "truncated": false + }, + "name": "SuccessState" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 23, + "previousEventId": 22, + "stateExitedEventDetails": { + "name": "SuccessState", + "output": { + "status": "complete" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "status": "complete" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 24, + "previousEventId": 23, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/comments/test_comments.py::TestComments::test_comment_in_parameters": { + "recorded-date": "28-11-2024, 10:33:37", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "Comment": "Comment text", + "comment": "comment text", + "constant": "constant text" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Comment": "Comment text", + "comment": "comment text", + "constant": "constant text" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/comments/test_comments.validation.json b/tests/aws/services/stepfunctions/v2/comments/test_comments.validation.json new file mode 100644 index 0000000000000..e16a0a8487ed7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/comments/test_comments.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/stepfunctions/v2/comments/test_comments.py::TestComments::test_comment_in_parameters": { + "last_validated_date": "2024-11-28T10:33:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/comments/test_comments.py::TestComments::test_comments_as_per_docs": { + "last_validated_date": "2024-11-28T10:33:23+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/context_object/__init__.py b/tests/aws/services/stepfunctions/v2/context_object/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/context_object/test_context_object.py b/tests/aws/services/stepfunctions/v2/context_object/test_context_object.py new file mode 100644 index 0000000000000..e8a9b1fbb1ab9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/context_object/test_context_object.py @@ -0,0 +1,164 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.context_object.context_object_templates import ( + ContextObjectTemplates, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..RedriveCount", + "$..RedriveStatus", + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestSnfBase: + @markers.aws.validated + @pytest.mark.parametrize("context_object_literal", ["$$", "$$.Execution.Input"]) + def test_input_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + context_object_literal, + ): + template = ContextObjectTemplates.load_sfn_template( + ContextObjectTemplates.CONTEXT_OBJECT_INPUT_PATH + ) + definition = json.dumps(template) + definition = definition.replace( + ContextObjectTemplates.CONTEXT_OBJECT_LITERAL_PLACEHOLDER, context_object_literal + ) + exec_input = json.dumps({"input-value": 0}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize("context_object_literal", ["$$", "$$.Execution.Input"]) + def test_output_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + context_object_literal, + ): + template = ContextObjectTemplates.load_sfn_template( + ContextObjectTemplates.CONTEXT_OBJECT_OUTPUT_PATH + ) + definition = json.dumps(template) + definition = definition.replace( + ContextObjectTemplates.CONTEXT_OBJECT_LITERAL_PLACEHOLDER, context_object_literal + ) + exec_input = json.dumps({"input-value": 0}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_result_selector( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = ContextObjectTemplates.load_sfn_template( + ContextObjectTemplates.CONTEXT_OBJECT_RESULT_PATH + ) + definition = json.dumps(template) + definition = definition.replace( + ContextObjectTemplates.CONTEXT_OBJECT_LITERAL_PLACEHOLDER, "$$.Execution.Input" + ) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": {"input-value": 0}}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_variable( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ContextObjectTemplates.load_sfn_template( + ContextObjectTemplates.CONTEXT_OBJECT_VARIABLE + ) + definition = json.dumps(template) + definition = definition.replace( + ContextObjectTemplates.CONTEXT_OBJECT_LITERAL_PLACEHOLDER, + "$$.Execution.Input.input-value", + ) + exec_input = json.dumps({"input-value": 0}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_error_cause_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ContextObjectTemplates.load_sfn_template( + ContextObjectTemplates.CONTEXT_OBJECT_ERROR_CAUSE_PATH + ) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/context_object/test_context_object.snapshot.json b/tests/aws/services/stepfunctions/v2/context_object/test_context_object.snapshot.json new file mode 100644 index 0000000000000..e5b725260d38d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/context_object/test_context_object.snapshot.json @@ -0,0 +1,925 @@ +{ + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_input_path[$$]": { + "recorded-date": "11-09-2024, 12:46:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "TestState", + "output": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "input-value": 0 + }, + "StartTime": "date", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "TestState", + "EnteredTime": "date" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "input-value": 0 + }, + "StartTime": "date", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "TestState", + "EnteredTime": "date" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_input_path[$$.Execution.Input]": { + "recorded-date": "11-09-2024, 12:47:13", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "TestState", + "output": { + "input-value": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input-value": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_output_path[$$]": { + "recorded-date": "11-09-2024, 12:47:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "TestState", + "output": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "input-value": 0 + }, + "StartTime": "date", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "TestState", + "EnteredTime": "date" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "input-value": 0 + }, + "StartTime": "date", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "TestState", + "EnteredTime": "date" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_output_path[$$.Execution.Input]": { + "recorded-date": "11-09-2024, 12:47:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "TestState", + "output": { + "input-value": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input-value": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_result_selector": { + "recorded-date": "11-09-2024, 12:48:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "Input": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + } + }, + "FunctionName": "" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "Input": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "82" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "82", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "TestState", + "output": { + "ContextObjectValue": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + }, + "LambdaOutput": { + "ExecutedVersion": "$LATEST", + "Payload": { + "Input": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "82" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "82", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ContextObjectValue": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + }, + "LambdaOutput": { + "ExecutedVersion": "$LATEST", + "Payload": { + "Input": { + "FunctionName": "", + "Payload": { + "input-value": 0 + } + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "82" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "82", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_variable": { + "recorded-date": "11-09-2024, 12:48:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "TestState", + "output": { + "input-value": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input-value": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "QuitChoiceMatched" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "QuitChoiceMatched", + "output": { + "input-value": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input-value": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_items_path": { + "recorded-date": "11-09-2024, 12:48:37", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input-values": [ + "item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input-values": [ + "item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "TestState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "\"item-0\"", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "\"item-0\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "TestState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "TestState", + "output": "[\"item-0\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[\"item-0\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_error_cause_path": { + "recorded-date": "28-11-2024, 14:56:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "", + "error": "StartState" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/context_object/test_context_object.validation.json b/tests/aws/services/stepfunctions/v2/context_object/test_context_object.validation.json new file mode 100644 index 0000000000000..c65f96577476e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/context_object/test_context_object.validation.json @@ -0,0 +1,26 @@ +{ + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_error_cause_path": { + "last_validated_date": "2024-11-28T14:56:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_input_path[$$.Execution.Input]": { + "last_validated_date": "2024-09-11T12:47:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_input_path[$$]": { + "last_validated_date": "2024-09-11T12:46:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_items_path": { + "last_validated_date": "2024-09-11T12:48:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_output_path[$$.Execution.Input]": { + "last_validated_date": "2024-09-11T12:47:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_output_path[$$]": { + "last_validated_date": "2024-09-11T12:47:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_result_selector": { + "last_validated_date": "2024-09-11T12:48:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/context_object/test_context_object.py::TestSnfBase::test_variable": { + "last_validated_date": "2024-09-11T12:48:23+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/credentials/__init__.py b/tests/aws/services/stepfunctions/v2/credentials/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py b/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py new file mode 100644 index 0000000000000..4381de35a3ef3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py @@ -0,0 +1,288 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.config import SECONDARY_TEST_AWS_ACCOUNT_ID, TEST_AWS_ACCOUNT_ID +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, + create_state_machine_with_iam_role, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import ( + BaseTemplate as BT, +) +from tests.aws.services.stepfunctions.templates.credentials.credentials_templates import ( + CredentialsTemplates as CT, +) +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + "$..RedriveCount", + "$..RedriveStatus", + "$..RedriveStatusReason", + ] +) +class TestCredentialsBase: + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [CT.EMPTY_CREDENTIALS, CT.INVALID_CREDENTIALS_FIELD], + ids=["EMPTY_CREDENTIALS", "INVALID_CREDENTIALS_FIELD"], + ) + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + def test_invalid_credentials_field( + self, + aws_client, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "sfn_role_arn")) + + definition = CT.load_sfn_template(template_path) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + + with pytest.raises(Exception) as ex: + create_state_machine( + aws_client_no_retry, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.match("invalid_definition", ex.value.response) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + CT.SFN_START_EXECUTION_SYNC_ROLE_ARN_JSONATA, + CT.SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH, + CT.SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH_CONTEXT, + CT.SFN_START_EXECUTION_SYNC_ROLE_ARN_VARIABLE, + CT.SFN_START_EXECUTION_SYNC_ROLE_ARN_INTRINSIC, + ], + ids=[ + "SFN_START_EXECUTION_SYNC_ROLE_ARN_JSONATA", + "SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH", + "SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH_CONTEXT", + "SFN_START_EXECUTION_SYNC_ROLE_ARN_VARIABLE", + "SFN_START_EXECUTION_SYNC_ROLE_ARN_INTRINSIC", + ], + ) + def test_cross_account_states_start_sync_execution( + self, + aws_client, + secondary_aws_client, + create_state_machine_iam_role, + create_state_machine, + create_cross_account_admin_role_and_policy, + sfn_snapshot, + template_path, + ): + trusted_role_arn = create_cross_account_admin_role_and_policy( + trusted_aws_client=aws_client, + trusting_aws_client=secondary_aws_client, + trusted_account_id=TEST_AWS_ACCOUNT_ID, + ) + sfn_snapshot.add_transformer(RegexTransformer(trusted_role_arn, "")) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StopDate", + replacement="", + replace_reference=False, + ) + ) + target_definition = json.dumps(BT.load_sfn_template(BT.BASE_PASS_RESULT)) + target_state_machine_arn = create_state_machine_with_iam_role( + secondary_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + target_definition, + ) + definition = json.dumps(CT.load_sfn_template(template_path)) + exec_input = json.dumps( + { + "StateMachineArn": target_state_machine_arn, + "Input": json.dumps("InputFromTrustedAccount"), + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": trusted_role_arn, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + sfn_snapshot.add_transformers_list( + [ + RegexTransformer(TEST_AWS_ACCOUNT_ID, ""), + RegexTransformer(SECONDARY_TEST_AWS_ACCOUNT_ID, ""), + ] + ) + + @markers.aws.validated + def test_cross_account_lambda_task( + self, + aws_client, + secondary_aws_client, + create_lambda_function, + create_state_machine_iam_role, + create_state_machine, + create_cross_account_admin_role_and_policy, + sfn_snapshot, + ): + trusted_role_arn = create_cross_account_admin_role_and_policy( + trusted_aws_client=secondary_aws_client, + trusting_aws_client=aws_client, + trusted_account_id=SECONDARY_TEST_AWS_ACCOUNT_ID, + ) + sfn_snapshot.add_transformer(RegexTransformer(trusted_role_arn, "")) + function_name = f"lambda_func_{short_uid()}" + create_lambda_response = create_lambda_function( + func_name=function_name, handler_file=ST.LAMBDA_ID_FUNCTION, runtime=Runtime.python3_12 + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + template = CT.load_sfn_template(CT.LAMBDA_TASK) + template["States"]["LambdaTask"]["Resource"] = create_lambda_response[ + "CreateFunctionResponse" + ]["FunctionArn"] + definition = json.dumps(template) + exec_input = json.dumps( + { + "Payload": json.dumps("PayloadFromTrustedAccount"), + "CredentialsRoleArn": trusted_role_arn, + } + ) + create_and_record_execution( + secondary_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + sfn_snapshot.add_transformers_list( + [ + RegexTransformer(TEST_AWS_ACCOUNT_ID, ""), + RegexTransformer(SECONDARY_TEST_AWS_ACCOUNT_ID, ""), + ] + ) + + @markers.aws.validated + def test_cross_account_service_lambda_invoke( + self, + aws_client, + secondary_aws_client, + create_lambda_function, + create_state_machine_iam_role, + create_state_machine, + create_cross_account_admin_role_and_policy, + sfn_snapshot, + ): + trusted_role_arn = create_cross_account_admin_role_and_policy( + trusted_aws_client=secondary_aws_client, + trusting_aws_client=aws_client, + trusted_account_id=SECONDARY_TEST_AWS_ACCOUNT_ID, + ) + sfn_snapshot.add_transformer(RegexTransformer(trusted_role_arn, "")) + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, handler_file=ST.LAMBDA_ID_FUNCTION, runtime=Runtime.python3_12 + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + template = CT.load_sfn_template(CT.SERVICE_LAMBDA_INVOKE) + definition = json.dumps(template) + exec_input = json.dumps( + { + "FunctionName": function_name, + "Payload": json.dumps("PayloadFromTrustedAccount"), + "CredentialsRoleArn": trusted_role_arn, + } + ) + create_and_record_execution( + secondary_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + sfn_snapshot.add_transformers_list( + [ + RegexTransformer(TEST_AWS_ACCOUNT_ID, ""), + RegexTransformer(SECONDARY_TEST_AWS_ACCOUNT_ID, ""), + ] + ) + + @markers.aws.validated + def test_cross_account_service_lambda_invoke_retry( + self, + aws_client, + secondary_aws_client, + create_lambda_function, + create_state_machine_iam_role, + create_state_machine, + create_cross_account_admin_role_and_policy, + sfn_snapshot, + ): + trusted_role_arn = create_cross_account_admin_role_and_policy( + trusted_aws_client=secondary_aws_client, + trusting_aws_client=aws_client, + trusted_account_id=SECONDARY_TEST_AWS_ACCOUNT_ID, + ) + sfn_snapshot.add_transformer(RegexTransformer(trusted_role_arn, "")) + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + template = CT.load_sfn_template(CT.SERVICE_LAMBDA_INVOKE_RETRY) + definition = json.dumps(template) + exec_input = json.dumps( + { + "FunctionName": function_name, + "Payload": json.dumps("PayloadFromTrustedAccount"), + "CredentialsRoleArn": trusted_role_arn, + } + ) + create_and_record_execution( + secondary_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + sfn_snapshot.add_transformers_list( + [ + RegexTransformer(TEST_AWS_ACCOUNT_ID, ""), + RegexTransformer(SECONDARY_TEST_AWS_ACCOUNT_ID, ""), + ] + ) diff --git a/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.snapshot.json b/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.snapshot.json new file mode 100644 index 0000000000000..7641c050c404b --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.snapshot.json @@ -0,0 +1,1759 @@ +{ + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_JSONATA]": { + "recorded-date": "04-12-2024, 17:11:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "178" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "178", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH]": { + "recorded-date": "04-12-2024, 17:12:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": "\"InputFromTrustedAccount\"", + "StateMachineArn": "arn::states:::stateMachine:", + "Name": "TestTaskTargetWithCredentials" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "178" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "178", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH_CONTEXT]": { + "recorded-date": "04-12-2024, 17:13:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "roleArn": "\"\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "StartExecution", + "output": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "RunTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "taskScheduledEventDetails": { + "parameters": { + "Input": "\"InputFromTrustedAccount\"", + "StateMachineArn": "arn::states:::stateMachine:", + "Name": "TestTaskTargetWithCredentials" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 7, + "previousEventId": 6, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "177" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "177", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 8, + "previousEventId": 7, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "RunTask", + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_VARIABLE]": { + "recorded-date": "04-12-2024, 17:14:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "roleArn": "\"\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "StartExecution", + "output": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "RunTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "taskScheduledEventDetails": { + "parameters": { + "Input": "\"InputFromTrustedAccount\"", + "StateMachineArn": "arn::states:::stateMachine:", + "Name": "TestTaskTargetWithCredentials" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 7, + "previousEventId": 6, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "178" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "178", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 8, + "previousEventId": 7, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "RunTask", + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_invalid_credentials_field[EMPTY_CREDENTIALS]": { + "recorded-date": "04-12-2024, 14:50:43", + "recorded-content": { + "invalid_definition": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The field 'RoleArn' is required but was missing at /States/State0/Credentials'" + }, + "message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The field 'RoleArn' is required but was missing at /States/State0/Credentials'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_invalid_credentials_field[INVALID_CREDENTIALS_FIELD]": { + "recorded-date": "04-12-2024, 14:51:02", + "recorded-content": { + "invalid_definition": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The field 'RoleArn' is required but was missing at /States/State0/Credentials'" + }, + "message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The field 'RoleArn' is required but was missing at /States/State0/Credentials'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_INTRINSIC]": { + "recorded-date": "04-12-2024, 17:15:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states:::stateMachine:", + "Input": "\"InputFromTrustedAccount\"", + "Name": "TestTaskTargetWithCredentials", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": "\"InputFromTrustedAccount\"", + "StateMachineArn": "arn::states:::stateMachine:", + "Name": "TestTaskTargetWithCredentials" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "178" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "178", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states:::execution::TestTaskTargetWithCredentials", + "Input": "\"InputFromTrustedAccount\"", + "InputDetails": { + "Included": true + }, + "Name": "TestTaskTargetWithCredentials", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "", + "StateMachineArn": "arn::states:::stateMachine:", + "Status": "SUCCEEDED", + "StopDate": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_service_lambda_invoke": { + "recorded-date": "04-12-2024, 20:28:21", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "PayloadFromTrustedAccount", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "27" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "27", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "PayloadFromTrustedAccount", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "27" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "27", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "PayloadFromTrustedAccount", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "27" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "27", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_service_lambda_invoke_retry": { + "recorded-date": "04-12-2024, 20:34:05", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "\"PayloadFromTrustedAccount\"" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "taskCredentials": { + "roleArn": "" + } + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 11, + "previousEventId": 10, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_lambda_task": { + "recorded-date": "04-12-2024, 20:43:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "LambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda:::function:", + "taskCredentials": { + "roleArn": "" + } + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": { + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "LambdaTask", + "output": { + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Payload": "\"PayloadFromTrustedAccount\"", + "CredentialsRoleArn": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.validation.json b/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.validation.json new file mode 100644 index 0000000000000..30d54ee45b5f9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.validation.json @@ -0,0 +1,32 @@ +{ + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_lambda_task": { + "last_validated_date": "2024-12-04T20:43:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_service_lambda_invoke": { + "last_validated_date": "2024-12-04T20:28:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_service_lambda_invoke_retry": { + "last_validated_date": "2024-12-04T20:34:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_INTRINSIC]": { + "last_validated_date": "2024-12-04T17:15:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_JSONATA]": { + "last_validated_date": "2024-12-04T17:11:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH]": { + "last_validated_date": "2024-12-04T17:12:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_PATH_CONTEXT]": { + "last_validated_date": "2024-12-04T17:13:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_cross_account_states_start_sync_execution[SFN_START_EXECUTION_SYNC_ROLE_ARN_VARIABLE]": { + "last_validated_date": "2024-12-04T17:14:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_invalid_credentials_field[EMPTY_CREDENTIALS]": { + "last_validated_date": "2024-12-04T14:50:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/credentials/test_credentials_base.py::TestCredentialsBase::test_invalid_credentials_field[INVALID_CREDENTIALS_FIELD]": { + "last_validated_date": "2024-12-04T14:51:02+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/__init__.py b/tests/aws/services/stepfunctions/v2/error_handling/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py b/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py new file mode 100644 index 0000000000000..2118440e7f338 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py @@ -0,0 +1,135 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) + + +class TestAwsSdk: + @markers.aws.validated + def test_invalid_secret_name( + self, aws_client, create_state_machine_iam_role, create_state_machine, sfn_snapshot + ): + template = EHT.load_sfn_template(EHT.AWS_SDK_TASK_FAILED_SECRETSMANAGER_CREATE_SECRET) + definition = json.dumps(template) + exec_input = json.dumps({"Name": "Invalid Name", "SecretString": "HelloWorld"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_no_such_bucket( + self, aws_client, create_state_machine_iam_role, create_state_machine, sfn_snapshot + ): + template = EHT.load_sfn_template(EHT.AWS_SDK_TASK_FAILED_S3_LIST_OBJECTS) + definition = json.dumps(template) + bucket_name = f"someNonexistentBucketName{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "someNonexistentBucketName")) + exec_input = json.dumps({"Bucket": bucket_name}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_s3_no_such_key( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + template = EHT.load_sfn_template(EHT.AWS_SDK_TASK_FAILED_S3_NO_SUCH_KEY) + definition = json.dumps(template) + exec_input = json.dumps({"Bucket": bucket_name}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="No parameters validation for dynamodb api calls being returned.", + ) + @markers.snapshot.skip_snapshot_verify(paths=["$..cause"]) + @markers.aws.validated + def test_dynamodb_invalid_param( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + dynamodb_create_table, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.dynamodb_api()) + + template = EHT.load_sfn_template(EHT.AWS_SDK_TASK_DYNAMODB_PUT_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + {"TableName": f"no_such_sfn_test_table_{short_uid()}", "Key": None, "Item": None} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..cause"]) + @markers.aws.validated + def test_dynamodb_put_item_no_such_table( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.dynamodb_api()) + + table_name = f"no_such_sfn_test_table_{short_uid()}" + + template = EHT.load_sfn_template(EHT.AWS_SDK_TASK_DYNAMODB_PUT_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "HelloWorld"}, "id": {"S": "id1"}}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.snapshot.json b/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.snapshot.json new file mode 100644 index 0000000000000..5c9c8a9492767 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.snapshot.json @@ -0,0 +1,643 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_invalid_secret_name": { + "recorded-date": "22-06-2023, 13:25:51", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Name": "Invalid Name", + "SecretString": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Name": "Invalid Name", + "SecretString": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "CreateSecret" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "SecretString": "HelloWorld", + "Name": "Invalid Name" + }, + "region": "", + "resource": "createSecret", + "resourceType": "aws-sdk:secretsmanager" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "createSecret", + "resourceType": "aws-sdk:secretsmanager" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@! (Service: SecretsManager, Status Code: 400, Request ID: )", + "error": "SecretsManager.SecretsManagerException", + "resource": "createSecret", + "resourceType": "aws-sdk:secretsmanager" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "CreateSecret", + "output": { + "Name": "Invalid Name", + "SecretString": "HelloWorld", + "TaskFailedError": { + "Error": "SecretsManager.SecretsManagerException", + "Cause": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@! (Service: SecretsManager, Status Code: 400, Request ID: )" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Name": "Invalid Name", + "SecretString": "HelloWorld", + "TaskFailedError": { + "Error": "SecretsManager.SecretsManagerException", + "Cause": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@! (Service: SecretsManager, Status Code: 400, Request ID: )" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "TaskFailedHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "TaskFailedHandler", + "output": { + "Name": "Invalid Name", + "SecretString": "HelloWorld", + "TaskFailedError": { + "Error": "SecretsManager.SecretsManagerException", + "Cause": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@! (Service: SecretsManager, Status Code: 400, Request ID: )" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Name": "Invalid Name", + "SecretString": "HelloWorld", + "TaskFailedError": { + "Error": "SecretsManager.SecretsManagerException", + "Cause": "Invalid name. Must be a valid name containing alphanumeric characters, or any of the following: -/_+=.@! (Service: SecretsManager, Status Code: 400, Request ID: )" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_no_such_bucket": { + "recorded-date": "22-06-2023, 13:26:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "someNonexistentBucketName" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "someNonexistentBucketName" + }, + "inputDetails": { + "truncated": false + }, + "name": "CreateSecret" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "someNonexistentBucketName" + }, + "region": "", + "resource": "listObjects", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "listObjects", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "The specified bucket does not exist (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )", + "error": "S3.NoSuchBucketException", + "resource": "listObjects", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "CreateSecret", + "output": { + "Bucket": "someNonexistentBucketName", + "TaskFailedError": { + "Error": "S3.NoSuchBucketException", + "Cause": "The specified bucket does not exist (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Bucket": "someNonexistentBucketName", + "TaskFailedError": { + "Error": "S3.NoSuchBucketException", + "Cause": "The specified bucket does not exist (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "TaskFailedHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "TaskFailedHandler", + "output": { + "Bucket": "someNonexistentBucketName", + "TaskFailedError": { + "Error": "S3.NoSuchBucketException", + "Cause": "The specified bucket does not exist (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Bucket": "someNonexistentBucketName", + "TaskFailedError": { + "Error": "S3.NoSuchBucketException", + "Cause": "The specified bucket does not exist (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_dynamodb_invalid_param": { + "recorded-date": "27-07-2023, 19:06:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "", + "Key": null, + "Item": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Key": null, + "Item": null + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Item": null + }, + "region": "", + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "1 validation error detected: Value null at 'item' failed to satisfy constraint: Member must not be null (Service: DynamoDb, Status Code: 400, Request ID: )", + "error": "DynamoDb.DynamoDbException", + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "1 validation error detected: Value null at 'item' failed to satisfy constraint: Member must not be null (Service: DynamoDb, Status Code: 400, Request ID: )", + "error": "DynamoDb.DynamoDbException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_dynamodb_put_item_no_such_table": { + "recorded-date": "27-07-2023, 19:06:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Requested resource not found (Service: DynamoDb, Status Code: 400, Request ID: )", + "error": "DynamoDb.ResourceNotFoundException", + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Requested resource not found (Service: DynamoDb, Status Code: 400, Request ID: )", + "error": "DynamoDb.ResourceNotFoundException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_s3_no_such_key": { + "recorded-date": "22-01-2025, 13:27:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Key": "no_such_key.json" + }, + "region": "", + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "The specified key does not exist. (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )", + "error": "S3.NoSuchKeyException", + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "Error": "S3.NoSuchKeyException", + "Cause": "The specified key does not exist. (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "S3.NoSuchKeyException", + "Cause": "The specified key does not exist. (Service: S3, Status Code: 404, Request ID: , Extended Request ID: )" + }, + "inputDetails": { + "truncated": false + }, + "name": "NoSuchKeyState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": {}, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.validation.json b/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.validation.json new file mode 100644 index 0000000000000..d6e69325c9cb1 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_dynamodb_invalid_param": { + "last_validated_date": "2023-07-27T17:06:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_dynamodb_put_item_no_such_table": { + "last_validated_date": "2023-07-27T17:06:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_invalid_secret_name": { + "last_validated_date": "2023-06-22T11:25:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_no_such_bucket": { + "last_validated_date": "2023-06-22T11:26:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_aws_sdk.py::TestAwsSdk::test_s3_no_such_key": { + "last_validated_date": "2025-01-22T13:27:57+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py b/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py new file mode 100644 index 0000000000000..125016f96ee55 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py @@ -0,0 +1,210 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) + + +class TestStatesErrors: + @markers.aws.validated + def test_service_task_lambada_data_limit_exceeded_on_large_utf8_response( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + """ + This test checks the 'DataLimitExceeded' error when a service lambda task returns a large UTF-8 response. + It creates a lambda function with a large output string, then creates and records an execution of a + state machine that invokes the lambda function. The test verifies that the state machine correctly + raises the 'DataLimitExceeded' error. + """ + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_LARGE_OUTPUT_STRING, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_DATA_LIMIT_EXCEEDED) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_service_task_lambada_catch_state_all_data_limit_exceeded_on_large_utf8_response( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + """ + This test checks the 'DataLimitExceeded' error when a service lambda task returns a large UTF-8 response. + It creates a lambda function with a large output string, then creates and records an execution of a + state machine that invokes the lambda function. The test verifies that the state machine correctly + raises and handles the 'DataLimitExceeded' error. + """ + + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_LARGE_OUTPUT_STRING, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_ALL) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_task_lambda_data_limit_exceeded_on_large_utf8_response( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + """ + This test checks the 'DataLimitExceeded' error when a legacy lambda task returns a large UTF-8 response. + This is different from a service lambda task as the state machine invokes the lambda function directly using + its arn, rather than passing the parameters results to the states invoke call. + It creates a lambda function with a large output string, then creates and records an execution of a + state machine that invokes the lambda function. The test verifies that the state machine correctly + raises the 'DataLimitExceeded' error. + """ + + function_name = f"lambda_func_{short_uid()}" + create_lambda_response = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_LARGE_OUTPUT_STRING, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_DATA_LIMIT_EXCEEDED) + template["States"]["InvokeLambda"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_task_lambda_catch_state_all_data_limit_exceeded_on_large_utf8_response( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + """ + This test checks the 'DataLimitExceeded' error when a legacy lambda task returns a large UTF-8 response. + This is different from a service lambda task as the state machine invokes the lambda function directly using + its arn, rather than passing the parameters results to the states invoke call. + It creates a lambda function with a large output string, then creates and records an execution of a + state machine that invokes the lambda function. The test verifies that the state machine correctly + raises and handles the 'DataLimitExceeded' error. + """ + + function_name = f"lambda_func_{short_uid()}" + create_lambda_response = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_LARGE_OUTPUT_STRING, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + function_arn = create_lambda_response["CreateFunctionResponse"]["FunctionArn"] + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_ALL) + template["States"]["InvokeLambda"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_start_large_input( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + """ + This test checks the 'DataLimitExceeded' error from a non-task state. + In this case, it defines a 'Pass' state with a result that exceeds the given quota. + The test verifies that the state machine correctly raises the 'DataLimitExceeded' error. + """ + + two_bytes_utf8_char = "a" + template = { + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": two_bytes_utf8_char + * (257 * 1024 // len(two_bytes_utf8_char.encode("utf-8"))) + }, + "End": True, + } + }, + } + definition = json.dumps(template) + + exec_input = json.dumps(dict()) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.snapshot.json b/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.snapshot.json new file mode 100644 index 0000000000000..98823b88ed544 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.snapshot.json @@ -0,0 +1,621 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_service_task_lambada_data_limit_exceeded_on_large_utf8_response": { + "recorded-date": "21-03-2024, 15:34:54", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit.", + "error": "States.DataLimitExceeded", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleDataLimitExceeded" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleDataLimitExceeded", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_service_task_lambada_catch_state_all_data_limit_exceeded_on_large_utf8_response": { + "recorded-date": "21-03-2024, 15:35:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit.", + "error": "States.DataLimitExceeded", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleGeneralError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleGeneralError", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'lambda' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_task_lambda_data_limit_exceeded_on_large_utf8_response": { + "recorded-date": "21-03-2024, 15:35:27", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit.", + "error": "States.DataLimitExceeded" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleDataLimitExceeded" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleDataLimitExceeded", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_task_lambda_catch_state_all_data_limit_exceeded_on_large_utf8_response": { + "recorded-date": "21-03-2024, 15:35:43", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit.", + "error": "States.DataLimitExceeded" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleGeneralError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleGeneralError", + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "States.DataLimitExceeded", + "Cause": "The state/task 'arn::lambda::111111111111:function:' returned a result with a size exceeding the maximum number of bytes service limit." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_start_large_input": { + "recorded-date": "21-03-2024, 16:07:09", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "The state/task 'State_1' returned a result with a size exceeding the maximum number of bytes service limit.", + "error": "States.DataLimitExceeded" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.validation.json b/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.validation.json new file mode 100644 index 0000000000000..edd0abbe2dc9a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_service_task_lambada_catch_state_all_data_limit_exceeded_on_large_utf8_response": { + "last_validated_date": "2024-03-21T15:35:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_service_task_lambada_data_limit_exceeded_on_large_utf8_response": { + "last_validated_date": "2024-03-21T15:34:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_start_large_input": { + "last_validated_date": "2024-03-21T16:07:09+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_task_lambda_catch_state_all_data_limit_exceeded_on_large_utf8_response": { + "last_validated_date": "2024-03-21T15:35:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_states_errors.py::TestStatesErrors::test_task_lambda_data_limit_exceeded_on_large_utf8_response": { + "last_validated_date": "2024-03-21T15:35:27+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py b/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py new file mode 100644 index 0000000000000..d89fa5b28e767 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py @@ -0,0 +1,182 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) + + +@markers.snapshot.skip_snapshot_verify(paths=["$..Cause"]) +class TestTaskLambda: + @markers.aws.validated + def test_raise_exception( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_LAMBDA_INVOKE_CATCH_UNKNOWN) + template["States"]["Start"]["Resource"] = create_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_raise_custom_exception( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_CUSTOM_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_LAMBDA_INVOKE_CATCH_TBD) + template["States"]["InvokeLambda"]["Resource"] = create_res["CreateFunctionResponse"][ + "FunctionArn" + ] + template["States"]["InvokeLambda"]["Catch"][0]["ErrorEquals"].append("CustomException") + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_raise_exception_catch( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_LAMBDA_INVOKE_CATCH_RELEVANT) + template["States"]["Start"]["Resource"] = create_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_no_such_function( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_LAMBDA_INVOKE_CATCH_UNKNOWN) + template["States"]["Start"]["Resource"] = create_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": f"no_such_{function_name}", "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_no_such_function_catch( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_LAMBDA_INVOKE_CATCH_RELEVANT) + template["States"]["Start"]["Resource"] = create_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": f"no_such_{function_name}", "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.snapshot.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.snapshot.json new file mode 100644 index 0000000000000..b5271adfebb1e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.snapshot.json @@ -0,0 +1,661 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_exception": { + "recorded-date": "28-11-2024, 12:40:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_custom_exception": { + "recorded-date": "28-11-2024, 12:41:13", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "", + "errorType": "CustomException", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 9, in handler\n raise CustomException()\n" + ] + }, + "error": "CustomException" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "CustomException", + "Cause": "{\"errorMessage\": \"\", \"errorType\": \"CustomException\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "CustomException", + "Cause": "{\"errorMessage\": \"\", \"errorType\": \"CustomException\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "ErrorMatched" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "ErrorMatched", + "output": { + "Error": "CustomException", + "Cause": "{\"errorMessage\": \"\", \"errorType\": \"CustomException\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "CustomException", + "Cause": "{\"errorMessage\": \"\", \"errorType\": \"CustomException\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_exception_catch": { + "recorded-date": "28-11-2024, 12:41:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithStateTaskFailedHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithStateTaskFailedHandler", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}", + "task_failed_error": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}", + "task_failed_error": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_no_such_function": { + "recorded-date": "28-11-2024, 12:41:47", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_no_such_function_catch": { + "recorded-date": "28-11-2024, 12:42:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithStateTaskFailedHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithStateTaskFailedHandler", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}", + "task_failed_error": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}", + "task_failed_error": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.validation.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.validation.json new file mode 100644 index 0000000000000..f4e6010ce6341 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_no_such_function": { + "last_validated_date": "2024-11-28T12:41:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_no_such_function_catch": { + "last_validated_date": "2024-11-28T12:42:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_custom_exception": { + "last_validated_date": "2024-11-28T12:41:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_exception": { + "last_validated_date": "2024-11-28T12:40:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_lambda.py::TestTaskLambda::test_raise_exception_catch": { + "last_validated_date": "2024-11-28T12:41:30+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py new file mode 100644 index 0000000000000..61edd8aa9c2ba --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py @@ -0,0 +1,113 @@ +import json + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + # TODO: review LS's dynamodb error messages. + "$..Cause", + "$..cause", + ] +) +class TestTaskServiceDynamoDB: + @markers.aws.validated + def test_invalid_param( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + dynamodb_create_table, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_DYNAMODB_PUT_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + {"TableName": f"no_such_sfn_test_table_{short_uid()}", "Key": None, "Item": None} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_put_item_no_such_table( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + table_name = f"no_such_sfn_test_table_{short_uid()}" + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_DYNAMODB_PUT_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "HelloWorld"}, "id": {"S": "id1"}}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..error" # TODO: LS returns a ResourceNotFoundException instead of reflecting the validation error + ] + ) + @markers.aws.validated + def test_put_item_invalid_table_name( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + table_name = f"/invalid_test_table_{short_uid()}" + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_DYNAMODB_PUT_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "HelloWorld"}, "id": {"S": "id1"}}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.snapshot.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.snapshot.json new file mode 100644 index 0000000000000..9c3508b055fc2 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.snapshot.json @@ -0,0 +1,322 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_put_item_no_such_table": { + "recorded-date": "12-05-2023, 15:39:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Requested resource not found (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)", + "error": "DynamoDB.ResourceNotFoundException", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Requested resource not found (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)", + "error": "DynamoDB.ResourceNotFoundException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_put_item_invalid_table_name": { + "recorded-date": "12-05-2023, 17:15:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "/", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "/", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "/", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "1 validation error detected: Value '/' at 'tableName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+ (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: ; Proxy: null)", + "error": "DynamoDB.AmazonDynamoDBException", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "1 validation error detected: Value '/' at 'tableName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+ (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: ; Proxy: null)", + "error": "DynamoDB.AmazonDynamoDBException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_invalid_param": { + "recorded-date": "16-05-2023, 22:11:36", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "", + "Key": null, + "Item": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Key": null, + "Item": null + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Item": null + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "1 validation error detected: Value null at 'item' failed to satisfy constraint: Member must not be null (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: ; Proxy: null)", + "error": "DynamoDB.AmazonDynamoDBException", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "1 validation error detected: Value null at 'item' failed to satisfy constraint: Member must not be null (Service: AmazonDynamoDBv2; Status Code: 400; Error Code: ValidationException; Request ID: ; Proxy: null)", + "error": "DynamoDB.AmazonDynamoDBException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.validation.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.validation.json new file mode 100644 index 0000000000000..3482e797826e0 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_invalid_param": { + "last_validated_date": "2023-05-16T20:11:36+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_put_item_invalid_table_name": { + "last_validated_date": "2023-05-12T15:15:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_dynamodb.py::TestTaskServiceDynamoDB::test_put_item_no_such_table": { + "last_validated_date": "2023-05-12T13:39:16+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py new file mode 100644 index 0000000000000..790a2763d8b72 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py @@ -0,0 +1,235 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) +from tests.aws.services.stepfunctions.templates.timeouts.timeout_templates import ( + TimeoutTemplates as TT, +) + + +class TestTaskServiceLambda: + @markers.aws.validated + def test_raise_exception( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_UNKNOWN) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_raise_custom_exception( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_CUSTOM_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_TBD) + template["States"]["InvokeLambda"]["Catch"][0]["ErrorEquals"].append("CustomException") + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_raise_exception_catch( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_RELEVANT) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize("output_path_value", [None, "$.Payload", "$.no.such.path"]) + def test_raise_exception_catch_output_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + output_path_value, + ): + function_name = f"function_name_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_ALL_OUTPUT_PATH) + template["States"]["InvokeLambda"]["OutputPath"] = output_path_value + definition = json.dumps(template) + + exec_input = json.dumps( + {"FunctionName": function_name, "Payload": {"payload_input_value_0": 0}} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_no_such_function( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_UNKNOWN) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": f"no_such_{function_name}", "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_no_such_function_catch( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_RELEVANT) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": f"no_such_{function_name}", "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_invoke_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_1_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TT.LAMBDA_WAIT_60_SECONDS, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = TT.load_sfn_template(EHT.AWS_SERVICE_LAMBDA_INVOKE_CATCH_TIMEOUT) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.snapshot.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.snapshot.json new file mode 100644 index 0000000000000..33131f7120100 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.snapshot.json @@ -0,0 +1,1304 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception": { + "recorded-date": "28-11-2024, 13:03:36", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_custom_exception": { + "recorded-date": "28-11-2024, 13:03:53", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "", + "errorType": "CustomException", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 9, in handler\n raise CustomException()\n" + ] + }, + "error": "CustomException", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "CustomException", + "Cause": "{\"errorMessage\":\"\",\"errorType\":\"CustomException\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "CustomException", + "Cause": "{\"errorMessage\":\"\",\"errorType\":\"CustomException\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "ErrorMatched" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "ErrorMatched", + "output": { + "Error": "CustomException", + "Cause": "{\"errorMessage\":\"\",\"errorType\":\"CustomException\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "CustomException", + "Cause": "{\"errorMessage\":\"\",\"errorType\":\"CustomException\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 9, in handler\\n raise CustomException()\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch": { + "recorded-date": "28-11-2024, 13:04:09", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithStateTaskFailedHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithStateTaskFailedHandler", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}", + "task_failed_error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}", + "task_failed_error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[None]": { + "recorded-date": "28-11-2024, 13:08:15", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleGeneralError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleGeneralError", + "output": { + "InputValue": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "InputValue": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[$.Payload]": { + "recorded-date": "28-11-2024, 13:08:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleGeneralError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleGeneralError", + "output": { + "InputValue": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "InputValue": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[$.no.such.path]": { + "recorded-date": "28-11-2024, 13:08:43", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambda" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "lambda_function_name", + "Payload": { + "payload_input_value_0": 0 + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "InvokeLambda", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleGeneralError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleGeneralError", + "output": { + "InputValue": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "InputValue": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_no_such_function": { + "recorded-date": "28-11-2024, 13:05:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "no_such_", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)", + "error": "Lambda.ResourceNotFoundException", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)", + "error": "Lambda.ResourceNotFoundException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_no_such_function_catch": { + "recorded-date": "28-11-2024, 13:05:33", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "no_such_", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "no_such_", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)", + "error": "Lambda.ResourceNotFoundException", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "Error": "Lambda.ResourceNotFoundException", + "Cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Lambda.ResourceNotFoundException", + "Cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithStateTaskFailedHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithStateTaskFailedHandler", + "output": { + "Error": "Lambda.ResourceNotFoundException", + "Cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)", + "task_failed_error": { + "Error": "Lambda.ResourceNotFoundException", + "Cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Lambda.ResourceNotFoundException", + "Cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)", + "task_failed_error": { + "Error": "Lambda.ResourceNotFoundException", + "Cause": "Function not found: arn::lambda::111111111111:function:no_such_ (Service: AWSLambda; Status Code: 404; Error Code: ResourceNotFoundException; Request ID: ; Proxy: null)" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_invoke_timeout": { + "recorded-date": "28-11-2024, 13:05:54", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "Error": "States.Timeout", + "Cause": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "States.Timeout", + "Cause": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithHandler", + "output": { + "Error": "States.Timeout", + "Cause": "", + "error": { + "Error": "States.Timeout", + "Cause": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "States.Timeout", + "Cause": "", + "error": { + "Error": "States.Timeout", + "Cause": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.validation.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.validation.json new file mode 100644 index 0000000000000..8d7be471fc48c --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.validation.json @@ -0,0 +1,29 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_invoke_timeout": { + "last_validated_date": "2024-11-28T13:05:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_no_such_function": { + "last_validated_date": "2024-11-28T13:05:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_no_such_function_catch": { + "last_validated_date": "2024-11-28T13:05:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_custom_exception": { + "last_validated_date": "2024-11-28T13:03:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception": { + "last_validated_date": "2024-11-28T13:03:36+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch": { + "last_validated_date": "2024-11-28T13:04:09+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[$.Payload]": { + "last_validated_date": "2024-11-28T13:08:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[$.no.such.path]": { + "last_validated_date": "2024-11-28T13:08:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_lambda.py::TestTaskServiceLambda::test_raise_exception_catch_output_path[None]": { + "last_validated_date": "2024-11-28T13:08:15+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.py b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.py new file mode 100644 index 0000000000000..35a4d74cd3328 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.py @@ -0,0 +1,72 @@ +import json + +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, + create_state_machine_with_iam_role, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate as BT +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestTaskServiceSfn: + @markers.aws.validated + def test_start_execution_no_such_arn( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + + template_target = BT.load_sfn_template(BT.BASE_PASS_RESULT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + aws_client.stepfunctions.delete_state_machine(stateMachineArn=state_machine_arn_target) + + template = ST.load_sfn_template(ST.SFN_START_EXECUTION) + definition = json.dumps(template) + + random_arn_part = f"NoSuchArn{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(random_arn_part, "")) + + exec_input = json.dumps( + { + "StateMachineArn": f"{state_machine_arn_target}{random_arn_part}", + "Input": None, + "Name": "TestStartTarget", + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.snapshot.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.snapshot.json new file mode 100644 index 0000000000000..327cdd70fd424 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.snapshot.json @@ -0,0 +1,97 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.py::TestTaskServiceSfn::test_start_execution_no_such_arn": { + "recorded-date": "30-06-2023, 09:50:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine:' (Service: AWSStepFunctions; Status Code: 400; Error Code: StateMachineDoesNotExist; Request ID: ; Proxy: null)", + "error": "StepFunctions.StateMachineDoesNotExistException", + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine:' (Service: AWSStepFunctions; Status Code: 400; Error Code: StateMachineDoesNotExist; Request ID: ; Proxy: null)", + "error": "StepFunctions.StateMachineDoesNotExistException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.validation.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.validation.json new file mode 100644 index 0000000000000..3653582571822 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sfn.py::TestTaskServiceSfn::test_start_execution_no_such_arn": { + "last_validated_date": "2023-06-30T07:50:17+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py new file mode 100644 index 0000000000000..8351e0bfbba54 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py @@ -0,0 +1,160 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + # TODO: investigate `cause` construction issues with reported LS's SQS errors. + "$..cause", + "$..Cause", + ] +) +class TestTaskServiceSqs: + @markers.aws.needs_fixing + def test_send_message_no_such_queue( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = f"http://no-such-queue-{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_SQS_SEND_MSG_CATCH) + definition = json.dumps(template) + + message_body = "test_message_body" + exec_input = json.dumps({"QueueUrl": queue_url, "MessageBody": message_body}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.needs_fixing + def test_send_message_no_such_queue_no_catch( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = f"http://no-such-queue-{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = ST.load_sfn_template(ST.SQS_SEND_MESSAGE) + definition = json.dumps(template) + + message_body = "test_message_body" + exec_input = json.dumps({"QueueUrl": queue_url, "MessageBody": message_body}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="SQS does not raise error on empty body." + ) + @markers.aws.validated + def test_send_message_empty_body( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_SQS_SEND_MSG_CATCH) + definition = json.dumps(template) + + exec_input = json.dumps({"QueueUrl": queue_url, "MessageBody": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_sqs_failure_in_wait_for_task_tok( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_failure_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + sqs_send_task_failure_state_machine(queue_url) + + template = EHT.load_sfn_template(EHT.AWS_SERVICE_SQS_SEND_MSG_CATCH_TOKEN_FAILURE) + definition = json.dumps(template) + definition = definition.replace("<%WaitForTaskTokenFailureErrorName%>", "Failure error") + + message_txt = "test_message_txt" + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message_txt}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.snapshot.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.snapshot.json new file mode 100644 index 0000000000000..cf273a8436f70 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.snapshot.json @@ -0,0 +1,603 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_no_such_queue": { + "recorded-date": "22-06-2023, 13:31:36", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "region": "", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Unable to execute HTTP request: no-such-queue-73e08fde: Name or service not known", + "error": "SQS.SdkClientException", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "Error": "SQS.SdkClientException", + "Cause": "Unable to execute HTTP request: no-such-queue-73e08fde: Name or service not known" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "SQS.SdkClientException", + "Cause": "Unable to execute HTTP request: no-such-queue-73e08fde: Name or service not known" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithClientHandler" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithClientHandler", + "output": { + "Error": "SQS.SdkClientException", + "Cause": "Unable to execute HTTP request: no-such-queue-73e08fde: Name or service not known", + "client_error": { + "Error": "SQS.SdkClientException", + "Cause": "Unable to execute HTTP request: no-such-queue-73e08fde: Name or service not known" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "SQS.SdkClientException", + "Cause": "Unable to execute HTTP request: no-such-queue-73e08fde: Name or service not known", + "client_error": { + "Error": "SQS.SdkClientException", + "Cause": "Unable to execute HTTP request: no-such-queue-73e08fde: Name or service not known" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_empty_body": { + "recorded-date": "22-06-2023, 13:32:07", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "", + "MessageBody": null + }, + "region": "", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "The request must contain the parameter MessageBody. (Service: AmazonSQS; Status Code: 400; Error Code: MissingParameter; Request ID: ; Proxy: null)", + "error": "SQS.AmazonSQSException", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "Error": "SQS.AmazonSQSException", + "Cause": "The request must contain the parameter MessageBody. (Service: AmazonSQS; Status Code: 400; Error Code: MissingParameter; Request ID: ; Proxy: null)" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "SQS.AmazonSQSException", + "Cause": "The request must contain the parameter MessageBody. (Service: AmazonSQS; Status Code: 400; Error Code: MissingParameter; Request ID: ; Proxy: null)" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithSQSException" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithSQSException", + "output": { + "Error": "SQS.AmazonSQSException", + "Cause": "The request must contain the parameter MessageBody. (Service: AmazonSQS; Status Code: 400; Error Code: MissingParameter; Request ID: ; Proxy: null)", + "aws_error": { + "Error": "SQS.AmazonSQSException", + "Cause": "The request must contain the parameter MessageBody. (Service: AmazonSQS; Status Code: 400; Error Code: MissingParameter; Request ID: ; Proxy: null)" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "SQS.AmazonSQSException", + "Cause": "The request must contain the parameter MessageBody. (Service: AmazonSQS; Status Code: 400; Error Code: MissingParameter; Request ID: ; Proxy: null)", + "aws_error": { + "Error": "SQS.AmazonSQSException", + "Cause": "The request must contain the parameter MessageBody. (Service: AmazonSQS; Status Code: 400; Error Code: MissingParameter; Request ID: ; Proxy: null)" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_no_such_queue_no_catch": { + "recorded-date": "22-06-2023, 13:31:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendSQS" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "region": "", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Unable to execute HTTP request: no-such-queue-fa484d45: Name or service not known", + "error": "SQS.SdkClientException", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Unable to execute HTTP request: no-such-queue-fa484d45: Name or service not known", + "error": "SQS.SdkClientException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_sqs_failure_in_wait_for_task_tok": { + "recorded-date": "18-04-2024, 06:27:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": [ + "Thu, 18 Apr 2024 06:27:01 GMT" + ], + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "Thu, 18 Apr 2024 06:27:01 GMT", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "cause": "Failure cause", + "error": "Failure error", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "Error": "Failure error", + "Cause": "Failure cause" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "Error": "Failure error", + "Cause": "Failure cause" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithCaught" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "EndWithCaught", + "output": { + "Error": "Failure error", + "Cause": "Failure cause", + "caught": { + "Error": "Failure error", + "Cause": "Failure cause" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Failure error", + "Cause": "Failure cause", + "caught": { + "Error": "Failure error", + "Cause": "Failure cause" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.validation.json b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.validation.json new file mode 100644 index 0000000000000..02d0be8bbb02f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_empty_body": { + "last_validated_date": "2023-06-22T11:32:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_no_such_queue": { + "last_validated_date": "2023-06-22T11:31:36+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_send_message_no_such_queue_no_catch": { + "last_validated_date": "2023-06-22T11:31:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/error_handling/test_task_service_sqs.py::TestTaskServiceSqs::test_sqs_failure_in_wait_for_task_tok": { + "last_validated_date": "2024-04-18T06:27:04+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/error_handling/utils.py b/tests/aws/services/stepfunctions/v2/error_handling/utils.py new file mode 100644 index 0000000000000..bd922531c4543 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/error_handling/utils.py @@ -0,0 +1,45 @@ +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.pytest.stepfunctions.utils import await_execution_success +from localstack.utils.strings import short_uid + + +@staticmethod +def _test_sfn_scenario( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + execution_input, +): + stepfunctions_client = target_aws_client.stepfunctions + snf_role_arn = create_state_machine_iam_role(target_aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + sfn_snapshot.add_transformer( + RegexTransformer( + "Extended Request ID: [a-zA-Z0-9-/=+]+", + "Extended Request ID: ", + ) + ) + sfn_snapshot.add_transformer( + RegexTransformer("Request ID: [a-zA-Z0-9-]+", "Request ID: ") + ) + + sm_name: str = f"statemachine_{short_uid()}" + creation_resp = create_state_machine( + target_aws_client, name=sm_name, definition=definition, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = stepfunctions_client.start_execution( + stateMachineArn=state_machine_arn, input=execution_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_success(stepfunctions_client=stepfunctions_client, execution_arn=execution_arn) + + get_execution_history = stepfunctions_client.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match("get_execution_history", get_execution_history) diff --git a/tests/aws/services/stepfunctions/v2/evaluate_jsonata/__init__.py b/tests/aws/services/stepfunctions/v2/evaluate_jsonata/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py b/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py new file mode 100644 index 0000000000000..fc1dea31cf5fc --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py @@ -0,0 +1,232 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.evaluatejsonata.evaluate_jsonata_templates import ( + EvaluateJsonataTemplate as EJT, +) +from tests.aws.services.stepfunctions.templates.querylanguage.query_language_templates import ( + QueryLanguageTemplate as QLT, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..redriveCount", + "$..redriveStatus", + "$..RedriveCount", + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestBaseEvaluateJsonata: + @markers.aws.validated + @pytest.mark.parametrize( + "expression_dict", + [ + pytest.param( + {"TimeoutSeconds": EJT.JSONATA_NUMBER_EXPRESSION}, + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="Polling and timeouts are flakey.", + ), + ), + {"HeartbeatSeconds": EJT.JSONATA_NUMBER_EXPRESSION}, + ], + ids=[ + "TIMEOUT_SECONDS", + "HEARTBEAT_SECONDS", + ], + ) + def test_base_task( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + expression_dict, + ): + function_name = f"fn-eval-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EJT.load_sfn_template(EJT.BASE_TASK) + template["States"]["Start"].update(expression_dict) + definition = json.dumps(template) + definition = definition.replace( + QLT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "expression_dict", + [ + {"Items": EJT.JSONATA_ARRAY_ELEMENT_EXPRESSION}, + {"Items": EJT.JSONATA_ARRAY_ELEMENT_EXPRESSION_DOUBLE_QUOTES}, + {"MaxConcurrency": EJT.JSONATA_NUMBER_EXPRESSION}, + {"ToleratedFailurePercentage": EJT.JSONATA_NUMBER_EXPRESSION}, + {"ToleratedFailureCount": EJT.JSONATA_NUMBER_EXPRESSION}, + ], + ids=[ + "ITEMS", + "ITEMS_DOUBLE_QUOTES", + "MAX_CONCURRENCY", + "TOLERATED_FAILURE_PERCENTAGE", + "TOLERATED_FAILURE_COUNT", + ], + ) + def test_base_map( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + expression_dict, + ): + template = EJT.load_sfn_template(EJT.BASE_MAP) + template["States"]["Start"].update(expression_dict) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "field,input_value", + [ + pytest.param( + "TimeoutSeconds", 1, id="TIMEOUT_SECONDS", marks=pytest.mark.skip(reason="flaky") + ), + pytest.param("HeartbeatSeconds", 1, id="HEARTBEAT_SECONDS"), + ], + ) + def test_base_task_from_input( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + field, + input_value, + ): + function_name = f"fn-eval-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EJT.load_sfn_template(EJT.BASE_TASK) + template["States"]["Start"][field] = EJT.JSONATA_STATE_INPUT_EXPRESSION + definition = json.dumps(template) + definition = definition.replace( + QLT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + + exec_input = json.dumps({"input_value": input_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "field,input_value", + [ + pytest.param("Items", [1, 2, 3], id="ITEMS"), + pytest.param("MaxConcurrency", 1, id="MAX_CONCURRENCY"), + pytest.param("ToleratedFailurePercentage", 100, id="TOLERATED_FAILURE_PERCENTAGE"), + pytest.param("ToleratedFailureCount", 1, id="TOLERATED_FAILURE_COUNT"), + ], + ids=[ + "ITEMS", + "MAX_CONCURRENCY", + "TOLERATED_FAILURE_PERCENTAGE", + "TOLERATED_FAILURE_COUNT", + ], + ) + def test_base_map_from_input( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + field, + input_value, + ): + function_name = f"fn-eval-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = EJT.load_sfn_template(EJT.BASE_MAP) + template["States"]["Start"][field] = EJT.JSONATA_STATE_INPUT_EXPRESSION + definition = json.dumps(template) + definition = definition.replace( + QLT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + + exec_input = json.dumps({"input_value": input_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.snapshot.json b/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.snapshot.json new file mode 100644 index 0000000000000..522ad54d0348d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.snapshot.json @@ -0,0 +1,2355 @@ +{ + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task[TIMEOUT_SECONDS]": { + "recorded-date": "13-11-2024, 15:36:52", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Payload": {}, + "FunctionName": "arn::lambda::111111111111:function:" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 1 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task[HEARTBEAT_SECONDS]": { + "recorded-date": "13-11-2024, 15:37:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 1, + "parameters": { + "Payload": {}, + "FunctionName": "arn::lambda::111111111111:function:" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[ITEMS]": { + "recorded-date": "13-11-2024, 15:50:15", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "Process", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "Start" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "Start" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "Process", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 15, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "Start" + }, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 17, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[MAX_CONCURRENCY]": { + "recorded-date": "13-11-2024, 15:37:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[TOLERATED_FAILURE_PERCENTAGE]": { + "recorded-date": "13-11-2024, 15:37:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[TOLERATED_FAILURE_COUNT]": { + "recorded-date": "13-11-2024, 15:38:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task_from_input[TIMEOUT_SECONDS]": { + "recorded-date": "13-11-2024, 15:38:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Payload": {}, + "FunctionName": "arn::lambda::111111111111:function:" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 1 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task_from_input[HEARTBEAT_SECONDS]": { + "recorded-date": "13-11-2024, 15:53:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 1, + "parameters": { + "Payload": {}, + "FunctionName": "arn::lambda::111111111111:function:" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[ITEMS]": { + "recorded-date": "13-11-2024, 15:39:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "Process", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "Start" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "Start" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "Process", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 15, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "Start" + }, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 17, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[MAX_CONCURRENCY]": { + "recorded-date": "13-11-2024, 15:39:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[TOLERATED_FAILURE_PERCENTAGE]": { + "recorded-date": "13-11-2024, 15:40:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": 100 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": 100 + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[TOLERATED_FAILURE_COUNT]": { + "recorded-date": "13-11-2024, 15:40:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[ITEMS_DOUBLE_QUOTES]": { + "recorded-date": "18-11-2024, 09:08:27", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Process", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "Start" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "Process", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "Start" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "Start" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Process" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "Process", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 15, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "Start" + }, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 17, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.validation.json b/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.validation.json new file mode 100644 index 0000000000000..6827732e56c1f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.validation.json @@ -0,0 +1,41 @@ +{ + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[ITEMS]": { + "last_validated_date": "2024-11-18T09:18:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[ITEMS_DOUBLE_QUOTES]": { + "last_validated_date": "2024-11-18T09:18:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[MAX_CONCURRENCY]": { + "last_validated_date": "2024-11-18T09:19:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[TOLERATED_FAILURE_COUNT]": { + "last_validated_date": "2024-11-18T09:19:39+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map[TOLERATED_FAILURE_PERCENTAGE]": { + "last_validated_date": "2024-11-18T09:19:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[ITEMS]": { + "last_validated_date": "2024-11-13T15:39:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[MAX_CONCURRENCY]": { + "last_validated_date": "2024-11-13T15:39:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[TOLERATED_FAILURE_COUNT]": { + "last_validated_date": "2024-11-13T15:40:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_map_from_input[TOLERATED_FAILURE_PERCENTAGE]": { + "last_validated_date": "2024-11-13T15:39:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task[HEARTBEAT_SECONDS]": { + "last_validated_date": "2024-11-13T15:37:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task[TIMEOUT_SECONDS]": { + "last_validated_date": "2024-11-13T15:36:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task_from_input[HEARTBEAT_SECONDS]": { + "last_validated_date": "2024-11-13T15:53:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/evaluate_jsonata/test_base_evaluate_expressions.py::TestBaseEvaluateJsonata::test_base_task_from_input[TIMEOUT_SECONDS]": { + "last_validated_date": "2024-11-13T15:38:29+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/express/__init__.py b/tests/aws/services/stepfunctions/v2/express/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/express/test_express_async.py b/tests/aws/services/stepfunctions/v2/express/test_express_async.py new file mode 100644 index 0000000000000..4cc322ba3926c --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/express/test_express_async.py @@ -0,0 +1,178 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_record_express_async_execution +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate, +) +from tests.aws.services.stepfunctions.templates.scenarios.scenarios_templates import ( + ScenariosTemplate, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..billingDetails", + "$..redrive_count", + "$..event_timestamp", + "$..output.Cause", + ] +) +class TestExpressAsync: + @markers.aws.validated + @pytest.mark.parametrize( + "template", + [BaseTemplate.BASE_PASS_RESULT, BaseTemplate.BASE_RAISE_FAILURE], + ids=["BASE_PASS_RESULT", "BASE_RAISE_FAILURE"], + ) + def test_base( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + template, + ): + definition = json.dumps(BaseTemplate.load_sfn_template(template)) + exec_input = json.dumps({}) + create_and_record_express_async_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..RedriveCount"]) + @markers.aws.validated + def test_query_runtime_memory( + self, + create_state_machine_iam_role, + sfn_create_log_group, + create_state_machine, + aws_client, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..StartTime", replacement="start-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..execution_starttime", replacement="start-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..EnteredTime", replacement="entered-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..state_enteredtime", replacement="entered-time", replace_reference=False + ) + ) + + template = BaseTemplate.load_sfn_template(BaseTemplate.QUERY_CONTEXT_OBJECT_VALUES) + definition = json.dumps(template) + + exec_input = json.dumps({"message": "TestMessage"}) + create_and_record_express_async_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_catch( + self, + aws_client, + create_state_machine_iam_role, + sfn_create_log_group, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ErrorHandlingTemplate.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + sfn_snapshot.add_transformer( + RegexTransformer( + r'\\"requestId\\":\\"([a-f0-9\-]+)\\"', '\\"requestId\\":\\"' + ) + ) + + template = ErrorHandlingTemplate.load_sfn_template( + ErrorHandlingTemplate.AWS_SERVICE_LAMBDA_INVOKE_CATCH_ALL + ) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_express_async_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_retry( + self, + aws_client, + create_state_machine_iam_role, + sfn_create_log_group, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ErrorHandlingTemplate.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + sfn_snapshot.add_transformer( + RegexTransformer( + r'\\"requestId\\": \\"([a-f0-9\-]+)\\"', '\\"requestId\\": \\"\\"' + ) + ) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ScenariosTemplate.load_sfn_template( + ScenariosTemplate.LAMBDA_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT + ) + template["States"]["InvokeLambdaWithRetry"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_express_async_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/express/test_express_async.snapshot.json b/tests/aws/services/stepfunctions/v2/express/test_express_async.snapshot.json new file mode 100644 index 0000000000000..7a1dad181f9db --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/express/test_express_async.snapshot.json @@ -0,0 +1,218 @@ +{ + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_base[BASE_PASS_RESULT]": { + "recorded-date": "03-07-2024, 16:26:21", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "end_event": { + "details": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:express:::", + "id": "4", + "previous_event_id": "3", + "redrive_count": "0", + "type": "ExecutionSucceeded" + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_base[BASE_RAISE_FAILURE]": { + "recorded-date": "03-07-2024, 16:27:05", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "end_event": { + "details": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:express:::", + "id": "3", + "previous_event_id": "2", + "redrive_count": "0", + "type": "ExecutionFailed" + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_query_runtime_memory": { + "recorded-date": "15-07-2024, 12:58:13", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "end_event": { + "details": { + "output": { + "message": "TestMessage", + "query_values_1": { + "execution": { + "Id": "arn::states::111111111111:express:::", + "Input": { + "message": "TestMessage" + }, + "Name": "", + "RoleArn": "sfn_role_arn", + "StartTime": "start-time", + "RedriveCount": 0 + }, + "execution_rolearn": "sfn_role_arn", + "execution_name": "", + "statemachine_id": "arn::states::111111111111:stateMachine:", + "execution_id": "arn::states::111111111111:express:::", + "execution_input": { + "message": "TestMessage" + }, + "execution_starttime": "start-time", + "state_name": "QueryValues1", + "state_enteredtime": "entered-time", + "statemachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "state": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + }, + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:express:::", + "Input": { + "message": "TestMessage" + }, + "Name": "", + "RoleArn": "sfn_role_arn", + "StartTime": "start-time", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + } + }, + "statemachine_name": "" + }, + "query_values_2": { + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:express:::", + "Input": { + "message": "TestMessage" + }, + "Name": "", + "RoleArn": "sfn_role_arn", + "StartTime": "start-time", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues2", + "EnteredTime": "entered-time" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:express:::", + "id": "6", + "previous_event_id": "5", + "redrive_count": "0", + "type": "ExecutionSucceeded" + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_catch": { + "recorded-date": "15-07-2024, 13:31:21", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "end_event": { + "details": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:express:::", + "id": "9", + "previous_event_id": "8", + "redrive_count": "0", + "type": "ExecutionSucceeded" + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_retry": { + "recorded-date": "15-07-2024, 13:37:47", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "end_event": { + "details": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:express:::", + "id": "18", + "previous_event_id": "17", + "redrive_count": "0", + "type": "ExecutionSucceeded" + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/express/test_express_async.validation.json b/tests/aws/services/stepfunctions/v2/express/test_express_async.validation.json new file mode 100644 index 0000000000000..03aa89b855ccd --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/express/test_express_async.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_base[BASE_PASS_RESULT]": { + "last_validated_date": "2024-07-03T16:26:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_base[BASE_RAISE_FAILURE]": { + "last_validated_date": "2024-07-03T16:27:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_catch": { + "last_validated_date": "2024-07-15T13:31:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_query_runtime_memory": { + "last_validated_date": "2024-07-15T12:58:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_async.py::TestExpressAsync::test_retry": { + "last_validated_date": "2024-07-15T13:37:46+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/express/test_express_sync.py b/tests/aws/services/stepfunctions/v2/express/test_express_sync.py new file mode 100644 index 0000000000000..cc769cc9e28a3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/express/test_express_sync.py @@ -0,0 +1,170 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_record_express_sync_execution +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate, +) +from tests.aws.services.stepfunctions.templates.scenarios.scenarios_templates import ( + ScenariosTemplate, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..billingDetails", + "$..output.Cause", + ] +) +class TestExpressSync: + @markers.aws.validated + @pytest.mark.parametrize( + "template", + [BaseTemplate.BASE_PASS_RESULT, BaseTemplate.BASE_RAISE_FAILURE], + ids=["BASE_PASS_RESULT", "BASE_RAISE_FAILURE"], + ) + def test_base( + self, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + aws_client_no_sync_prefix, + template, + ): + definition = json.dumps(BaseTemplate.load_sfn_template(template)) + exec_input = json.dumps({}) + create_and_record_express_sync_execution( + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..RedriveCount"]) + @markers.aws.validated + def test_query_runtime_memory( + self, + create_state_machine_iam_role, + create_state_machine, + aws_client_no_sync_prefix, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..StartTime", replacement="start-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..execution_starttime", replacement="start-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..EnteredTime", replacement="entered-time", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..state_enteredtime", replacement="entered-time", replace_reference=False + ) + ) + + template = BaseTemplate.load_sfn_template(BaseTemplate.QUERY_CONTEXT_OBJECT_VALUES) + definition = json.dumps(template) + + exec_input = json.dumps({"message": "TestMessage"}) + create_and_record_express_sync_execution( + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_catch( + self, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ErrorHandlingTemplate.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + sfn_snapshot.add_transformer( + RegexTransformer( + r'\\"requestId\\":\\"([a-f0-9\-]+)\\"', '\\"requestId\\":\\"' + ) + ) + + template = ErrorHandlingTemplate.load_sfn_template( + ErrorHandlingTemplate.AWS_SERVICE_LAMBDA_INVOKE_CATCH_ALL + ) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_express_sync_execution( + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_retry( + self, + aws_client, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ErrorHandlingTemplate.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + sfn_snapshot.add_transformer( + RegexTransformer( + r'\\"requestId\\": \\"([a-f0-9\-]+)\\"', '\\"requestId\\": \\"\\"' + ) + ) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ScenariosTemplate.load_sfn_template( + ScenariosTemplate.LAMBDA_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT + ) + template["States"]["InvokeLambdaWithRetry"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_express_sync_execution( + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/express/test_express_sync.snapshot.json b/tests/aws/services/stepfunctions/v2/express/test_express_sync.snapshot.json new file mode 100644 index 0000000000000..79c57614e5f49 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/express/test_express_sync.snapshot.json @@ -0,0 +1,273 @@ +{ + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_base[BASE_PASS_RESULT]": { + "recorded-date": "26-06-2024, 19:08:30", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_sync_response": { + "billingDetails": { + "billedDurationInMilliseconds": 100, + "billedMemoryUsedInMB": 64 + }, + "executionArn": "arn::states::111111111111:express:::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_base[BASE_RAISE_FAILURE]": { + "recorded-date": "26-06-2024, 19:08:43", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_sync_response": { + "billingDetails": { + "billedDurationInMilliseconds": 100, + "billedMemoryUsedInMB": 64 + }, + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure", + "executionArn": "arn::states::111111111111:express:::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "FAILED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_query_runtime_memory": { + "recorded-date": "15-07-2024, 12:59:18", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_sync_response": { + "billingDetails": { + "billedDurationInMilliseconds": 100, + "billedMemoryUsedInMB": 64 + }, + "executionArn": "arn::states::111111111111:express:::", + "input": { + "message": "TestMessage" + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "message": "TestMessage", + "query_values_1": { + "execution": { + "Id": "arn::states::111111111111:express:::", + "Input": { + "message": "TestMessage" + }, + "Name": "", + "RoleArn": "sfn_role_arn", + "StartTime": "start-time" + }, + "execution_rolearn": "sfn_role_arn", + "execution_name": "", + "statemachine_id": "arn::states::111111111111:stateMachine:", + "execution_id": "arn::states::111111111111:express:::", + "execution_input": { + "message": "TestMessage" + }, + "execution_starttime": "start-time", + "state_name": "QueryValues1", + "state_enteredtime": "entered-time", + "statemachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "state": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + }, + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:express:::", + "Input": { + "message": "TestMessage" + }, + "Name": "", + "RoleArn": "sfn_role_arn", + "StartTime": "start-time" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues1", + "EnteredTime": "entered-time" + } + }, + "statemachine_name": "" + }, + "query_values_2": { + "context_object": { + "Execution": { + "Id": "arn::states::111111111111:express:::", + "Input": { + "message": "TestMessage" + }, + "Name": "", + "RoleArn": "sfn_role_arn", + "StartTime": "start-time" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "QueryValues2", + "EnteredTime": "entered-time" + } + } + } + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_catch": { + "recorded-date": "15-07-2024, 13:29:30", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_sync_response": { + "billingDetails": { + "billedDurationInMilliseconds": 300, + "billedMemoryUsedInMB": 64 + }, + "executionArn": "arn::states::111111111111:express:::", + "input": { + "FunctionName": "lambda_function_name", + "Payload": null + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_retry": { + "recorded-date": "15-07-2024, 13:37:02", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_sync_response": { + "billingDetails": { + "billedDurationInMilliseconds": 7600, + "billedMemoryUsedInMB": 64 + }, + "executionArn": "arn::states::111111111111:express:::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/express/test_express_sync.validation.json b/tests/aws/services/stepfunctions/v2/express/test_express_sync.validation.json new file mode 100644 index 0000000000000..b9eb0a3afce08 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/express/test_express_sync.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_base[BASE_PASS_RESULT]": { + "last_validated_date": "2024-06-26T19:08:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_base[BASE_RAISE_FAILURE]": { + "last_validated_date": "2024-06-26T19:08:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_catch": { + "last_validated_date": "2024-07-15T13:29:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_query_runtime_memory": { + "last_validated_date": "2024-07-15T12:59:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/express/test_express_sync.py::TestExpressSync::test_retry": { + "last_validated_date": "2024-07-15T13:37:02+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/__init__.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py new file mode 100644 index 0000000000000..6dd745757fb3e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py @@ -0,0 +1,182 @@ +import json + +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + +# TODO: test for validation errors, and boundary testing. + + +class TestArray: + @markers.aws.validated + def test_array_0( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_0, + ["HelloWorld"], + ) + + @markers.aws.validated + def test_array_2( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + values = [ + "", + " ", + "HelloWorld", + None, + 1, + 1.1, + '{"Arg1": 1, "Arg2": []}', + json.loads('{"Arg1": 1, "Arg2": []}'), + ] + input_values = list() + for value in values: + input_values.append({"fst": value, "snd": value}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_2, + input_values, + ) + + @markers.aws.validated + def test_array_partition( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + arrays = [list(range(i)) for i in range(5)] + input_values = list() + for array in arrays: + for chunk_size in range(1, 6): + input_values.append({"fst": array, "snd": chunk_size}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_PARTITION, + input_values, + ) + + @markers.aws.validated + def test_array_contains( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + search_bindings = [ + ([], None), + ([], []), + ([], 1), + ([[1, 2, 3], 2], None), + ([[1, 2, 3], 2], [1, 2, 3]), + ([{1: 2, 2: []}], []), + ([{1: 2, 2: []}], {1: 2, 2: []}), + ([True, False], True), + ([True, False], False), + ] + input_values = list() + for array, value in search_bindings: + input_values.append({"fst": array, "snd": value}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_CONTAINS, + input_values, + ) + + @markers.aws.validated + def test_array_range( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + ranges = [ + (0, 9, 3), + (0, 10, 3), + (1, 9, 9), + (1, 9, 2), + ] + input_values = list() + for fst, lst, step in ranges: + input_values.append({"fst": fst, "snd": lst, "trd": step}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_RANGE, + input_values, + ) + + @markers.aws.validated + def test_array_get_item( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [{"fst": [1, 2, 3, 4, 5, 6, 7, 8, 9], "snd": 5}] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_GET_ITEM, + input_values, + ) + + @markers.aws.validated + def test_array_length( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [[1, 2, 3, 4, 5, 6, 7, 8, 9]] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_LENGTH, + input_values, + ) + + @markers.aws.validated + def test_array_unique( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [ + [ + None, + None, + True, + True, + False, + False, + 1, + 1, + 1.1, + 0, + -0, + "HelloWorld", + "HelloWorld", + [], + [], + [None], + [None], + {"a": 1, "b": 2}, + {"a": 1, "b": 2}, + {"a": 1, "b": 1}, + ] + ] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_LENGTH, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.snapshot.json new file mode 100644 index 0000000000000..0dd85cdaf4070 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.snapshot.json @@ -0,0 +1,4698 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestSfnIntrinsicFunctionsArray::test_array_get_item": { + "recorded-date": "09-02-2023, 09:58:24", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 6 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 6 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestSfnIntrinsicFunctionsArray::test_array_length": { + "recorded-date": "09-02-2023, 10:04:55", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 9 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 9 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestSfnIntrinsicFunctionsArray::test_array_unique": { + "recorded-date": "09-02-2023, 10:19:29", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": [ + null, + null, + true, + true, + false, + false, + 1, + 1, + 1.1, + 0, + 0, + "HelloWorld", + "HelloWorld", + [], + [], + [ + null + ], + [ + null + ], + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 1 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": [ + null, + null, + true, + true, + false, + false, + 1, + 1, + 1.1, + 0, + 0, + "HelloWorld", + "HelloWorld", + [], + [], + [ + null + ], + [ + null + ], + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 1 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 20 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 20 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_0": { + "recorded-date": "09-02-2023, 10:20:14", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_2": { + "recorded-date": "31-03-2023, 23:29:25", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "", + "snd": "" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "", + "snd": "" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + "", + "" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "", + "" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": " ", + "snd": " " + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": " ", + "snd": " " + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + " ", + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + " ", + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "HelloWorld", + "snd": "HelloWorld" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "HelloWorld", + "snd": "HelloWorld" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + "HelloWorld", + "HelloWorld" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "HelloWorld", + "HelloWorld" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": null, + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": null, + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + null, + null + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + null, + null + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + 1, + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + 1, + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.1, + "snd": 1.1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.1, + "snd": 1.1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + 1.1, + 1.1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + 1.1, + 1.1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "{\"Arg1\": 1, \"Arg2\": []}", + "snd": "{\"Arg1\": 1, \"Arg2\": []}" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "{\"Arg1\": 1, \"Arg2\": []}", + "snd": "{\"Arg1\": 1, \"Arg2\": []}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + "{\"Arg1\": 1, \"Arg2\": []}", + "{\"Arg1\": 1, \"Arg2\": []}" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "{\"Arg1\": 1, \"Arg2\": []}", + "{\"Arg1\": 1, \"Arg2\": []}" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": { + "Arg1": 1, + "Arg2": [] + }, + "snd": { + "Arg1": 1, + "Arg2": [] + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": { + "Arg1": 1, + "Arg2": [] + }, + "snd": { + "Arg1": 1, + "Arg2": [] + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + { + "Arg1": 1, + "Arg2": [] + }, + { + "Arg1": 1, + "Arg2": [] + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + { + "Arg1": 1, + "Arg2": [] + }, + { + "Arg1": 1, + "Arg2": [] + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_partition": { + "recorded-date": "09-02-2023, 10:20:32", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_8": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_9": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_10": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ], + [ + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ], + [ + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_11": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_12": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_13": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_14": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_15": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ], + [ + 1 + ], + [ + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ], + [ + 1 + ], + [ + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_16": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1 + ], + [ + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1 + ], + [ + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_17": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_18": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_19": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_20": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0 + ], + [ + 1 + ], + [ + 2 + ], + [ + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0 + ], + [ + 1 + ], + [ + 2 + ], + [ + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_21": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1 + ], + [ + 2, + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1 + ], + [ + 2, + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_22": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ], + [ + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2 + ], + [ + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_23": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2, + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2, + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_24": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2, + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + [ + 0, + 1, + 2, + 3 + ] + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_contains": { + "recorded-date": "09-02-2023, 10:20:37", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": [] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": [] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + [ + 1, + 2, + 3 + ], + 2 + ], + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + [ + 1, + 2, + 3 + ], + 2 + ], + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + [ + 1, + 2, + 3 + ], + 2 + ], + "snd": [ + 1, + 2, + 3 + ] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + [ + 1, + 2, + 3 + ], + 2 + ], + "snd": [ + 1, + 2, + 3 + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + { + "1": 2, + "2": [] + } + ], + "snd": [] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + { + "1": 2, + "2": [] + } + ], + "snd": [] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": false + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + { + "1": 2, + "2": [] + } + ], + "snd": { + "1": 2, + "2": [] + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + { + "1": 2, + "2": [] + } + ], + "snd": { + "1": 2, + "2": [] + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + true, + false + ], + "snd": true + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + true, + false + ], + "snd": true + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_8": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + true, + false + ], + "snd": false + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + true, + false + ], + "snd": false + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_range": { + "recorded-date": "09-02-2023, 10:20:40", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 9, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 9, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + 0, + 3, + 6, + 9 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + 0, + 3, + 6, + 9 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 10, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 10, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + 0, + 3, + 6, + 9 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + 0, + 3, + 6, + 9 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 9 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 9 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + 1, + 3, + 5, + 7, + 9 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + 1, + 3, + 5, + 7, + 9 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_get_item": { + "recorded-date": "09-02-2023, 10:20:42", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 6 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 6 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_length": { + "recorded-date": "09-02-2023, 10:20:43", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 9 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 9 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_unique": { + "recorded-date": "09-02-2023, 10:20:45", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": [ + null, + null, + true, + true, + false, + false, + 1, + 1, + 1.1, + 0, + 0, + "HelloWorld", + "HelloWorld", + [], + [], + [ + null + ], + [ + null + ], + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 1 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": [ + null, + null, + true, + true, + false, + false, + 1, + 1, + 1.1, + 0, + 0, + "HelloWorld", + "HelloWorld", + [], + [], + [ + null + ], + [ + null + ], + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 2 + }, + { + "a": 1, + "b": 1 + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 20 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 20 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.validation.json new file mode 100644 index 0000000000000..f14ebdd9722a4 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.validation.json @@ -0,0 +1,26 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_0": { + "last_validated_date": "2023-02-09T09:20:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_2": { + "last_validated_date": "2023-03-31T21:29:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_contains": { + "last_validated_date": "2023-02-09T09:20:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_get_item": { + "last_validated_date": "2023-02-09T09:20:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_length": { + "last_validated_date": "2023-02-09T09:20:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_partition": { + "last_validated_date": "2023-02-09T09:20:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_range": { + "last_validated_date": "2023-02-09T09:20:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array.py::TestArray::test_array_unique": { + "last_validated_date": "2023-02-09T09:20:45+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py new file mode 100644 index 0000000000000..23d325683e2c7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py @@ -0,0 +1,48 @@ +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + + +class TestArrayJSONata: + @markers.aws.validated + def test_array_partition( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + # TODO: test and add support for raising exception on empty array. + arrays = [list(range(i)) for i in range(1, 5)] + input_values = list() + for array in arrays: + for chunk_size in range(1, 6): + input_values.append({"fst": array, "snd": chunk_size}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_PARTITION_JSONATA, + input_values, + ) + + @markers.aws.validated + def test_array_range( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + ranges = [ + (0, 9, 3), + (0, 10, 3), + (1, 9, 9), + (1, 9, 2), + ] + input_values = list() + for fst, lst, step in ranges: + input_values.append({"fst": fst, "snd": lst, "trd": step}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ARRAY_RANGE_JSONATA, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.snapshot.json new file mode 100644 index 0000000000000..b200845b0001a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.snapshot.json @@ -0,0 +1,1816 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py::TestArrayJSONata::test_array_partition": { + "recorded-date": "15-11-2024, 16:16:35", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0],[1]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0],[1]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_8": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_9": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_10": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0],[1],[2]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0],[1],[2]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_11": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1],[2]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1],[2]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_12": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1,2]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1,2]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_13": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1,2]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1,2]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_14": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1,2]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1,2]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_15": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0],[1],[2],[3]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0],[1],[2],[3]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_16": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1],[2,3]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1],[2,3]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_17": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1,2],[3]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1,2],[3]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_18": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 4 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1,2,3]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1,2,3]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_19": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": [ + 0, + 1, + 2, + 3 + ], + "snd": 5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[[0,1,2,3]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0,1,2,3]]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py::TestArrayJSONata::test_array_range": { + "recorded-date": "15-11-2024, 16:28:14", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 9, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 9, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[0,3,6,9]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0,3,6,9]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 10, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 10, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[0,3,6,9]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0,3,6,9]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 9 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 9 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 9, + "trd": 2 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[1,3,5,7,9]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,3,5,7,9]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.validation.json new file mode 100644 index 0000000000000..e95046ecf1d68 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py::TestArrayJSONata::test_array_partition": { + "last_validated_date": "2024-11-15T16:16:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_array_jsonata.py::TestArrayJSONata::test_array_range": { + "last_validated_date": "2024-11-15T16:28:14+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py new file mode 100644 index 0000000000000..7e81435856da1 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py @@ -0,0 +1,37 @@ +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + +# TODO: test for validation errors, and boundary testing. + + +class TestEncodeDecode: + @markers.aws.validated + def test_base_64_encode( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = ["", "Data to encode"] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.BASE_64_ENCODE, + input_values, + ) + + @markers.aws.validated + def test_base_64_decode( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = ["", "RGF0YSB0byBlbmNvZGU="] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.BASE_64_DECODE, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.snapshot.json new file mode 100644 index 0000000000000..c7478a4d112d8 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.snapshot.json @@ -0,0 +1,280 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py::TestEncodeDecode::test_base_64_encode": { + "recorded-date": "17-02-2023, 13:07:48", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "Data to encode" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "Data to encode" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "RGF0YSB0byBlbmNvZGU=" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "RGF0YSB0byBlbmNvZGU=" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py::TestEncodeDecode::test_base_64_decode": { + "recorded-date": "17-02-2023, 13:42:08", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "RGF0YSB0byBlbmNvZGU=" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "RGF0YSB0byBlbmNvZGU=" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Data to encode" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Data to encode" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.validation.json new file mode 100644 index 0000000000000..136d2148330ff --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py::TestEncodeDecode::test_base_64_decode": { + "last_validated_date": "2023-02-17T12:42:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_encode_decode.py::TestEncodeDecode::test_base_64_encode": { + "last_validated_date": "2023-02-17T12:07:48+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py new file mode 100644 index 0000000000000..b480ecf4725ee --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py @@ -0,0 +1,108 @@ +import json + +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + +# TODO: test for validation errors, and boundary testing. + + +class TestGeneric: + @markers.aws.validated + def test_format_1( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = ["", " ", "HelloWorld", None, 1, 1.1, '{"Arg1": 1, "Arg2": []}'] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.FORMAT_1, + input_values, + ) + + @markers.aws.validated + def test_format_2( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + values = [ + "", + " ", + "HelloWorld", + None, + 1, + 1.1, + '{"Arg1": 1, "Arg2": []}', + json.loads('{"Arg1": 1, "Arg2": []}'), + ] + input_values = list() + for value in values: + input_values.append({"fst": value, "snd": value}) + + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.FORMAT_2, + input_values, + ) + + @markers.aws.validated + def test_context_json_path( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [None] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.FORMAT_CONTEXT_PATH, + input_values, + ) + + @markers.aws.validated + def test_nested_calls_1( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [None] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.NESTED_CALLS_1, + input_values, + ) + + @markers.aws.validated + def test_nested_calls_2( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [None] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.NESTED_CALLS_2, + input_values, + ) + + @markers.aws.validated + def test_escape_sequence( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [None] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.ESCAPE_SEQUENCE, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.snapshot.json new file mode 100644 index 0000000000000..71b0382dfe862 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.snapshot.json @@ -0,0 +1,1371 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_format_1": { + "recorded-date": "09-02-2023, 10:21:18", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting argument ." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting argument ." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": " " + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": " " + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting argument ." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting argument ." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting argument HelloWorld." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting argument HelloWorld." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting argument null." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting argument null." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting argument 1." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting argument 1." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": 1.1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": 1.1 + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting argument 1.1." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting argument 1.1." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "{\"Arg1\": 1, \"Arg2\": []}" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "{\"Arg1\": 1, \"Arg2\": []}" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting argument {\"Arg1\": 1, \"Arg2\": []}." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting argument {\"Arg1\": 1, \"Arg2\": []}." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_format_2": { + "recorded-date": "09-02-2023, 10:21:23", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "", + "snd": "" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "", + "snd": "" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst= and snd=." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst= and snd=." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": " ", + "snd": " " + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": " ", + "snd": " " + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst= and snd= ." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst= and snd= ." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "HelloWorld", + "snd": "HelloWorld" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "HelloWorld", + "snd": "HelloWorld" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst=HelloWorld and snd=HelloWorld." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst=HelloWorld and snd=HelloWorld." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": null, + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": null, + "snd": null + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst=null and snd=null." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst=null and snd=null." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1, + "snd": 1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst=1 and snd=1." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst=1 and snd=1." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.1, + "snd": 1.1 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.1, + "snd": 1.1 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst=1.1 and snd=1.1." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst=1.1 and snd=1.1." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "{\"Arg1\": 1, \"Arg2\": []}", + "snd": "{\"Arg1\": 1, \"Arg2\": []}" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "{\"Arg1\": 1, \"Arg2\": []}", + "snd": "{\"Arg1\": 1, \"Arg2\": []}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst={\"Arg1\": 1, \"Arg2\": []} and snd={\"Arg1\": 1, \"Arg2\": []}." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst={\"Arg1\": 1, \"Arg2\": []} and snd={\"Arg1\": 1, \"Arg2\": []}." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": { + "Arg1": 1, + "Arg2": [] + }, + "snd": { + "Arg1": 1, + "Arg2": [] + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": { + "Arg1": 1, + "Arg2": [] + }, + "snd": { + "Arg1": 1, + "Arg2": [] + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst={Arg1=1, Arg2=[]} and snd={Arg1=1, Arg2=[]}." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst={Arg1=1, Arg2=[]} and snd={Arg1=1, Arg2=[]}." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_context_json_path": { + "recorded-date": "26-11-2023, 15:15:56", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "Formatting arguments fst= and snd={Id=arn::states::111111111111:stateMachine:, Name=}." + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "Formatting arguments fst= and snd={Id=arn::states::111111111111:stateMachine:, Name=}." + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_nested_calls_1": { + "recorded-date": "30-11-2023, 17:29:44", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_nested_calls_2": { + "recorded-date": "30-11-2023, 17:34:01", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_escape_sequence": { + "recorded-date": "30-11-2023, 17:58:53", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "FunctionResult": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.validation.json new file mode 100644 index 0000000000000..12cd17a6236aa --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_context_json_path": { + "last_validated_date": "2023-11-26T14:15:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_escape_sequence": { + "last_validated_date": "2023-11-30T16:58:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_format_1": { + "last_validated_date": "2023-02-09T09:21:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_format_2": { + "last_validated_date": "2023-02-09T09:21:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_nested_calls_1": { + "last_validated_date": "2023-11-30T16:29:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_generic.py::TestGeneric::test_nested_calls_2": { + "last_validated_date": "2023-11-30T16:34:01+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.py new file mode 100644 index 0000000000000..cc259fabb9eb7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.py @@ -0,0 +1,30 @@ +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + +# TODO: test for validation errors, and boundary testing. + + +class TestHashCalculations: + @markers.aws.validated + def test_hash( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + hash_bindings = [ + ("input data", "MD5"), + ("input data", "SHA-1"), + ("input data", "SHA-256"), + ("input data", "SHA-384"), + ("input data", "SHA-512"), + ] + input_values = [{"fst": inp, "snd": algo} for inp, algo in hash_bindings] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.HASH, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.snapshot.json new file mode 100644 index 0000000000000..c5185b7d19704 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.snapshot.json @@ -0,0 +1,372 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.py::TestHashCalculations::test_hash": { + "recorded-date": "14-02-2023, 21:48:01", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "MD5" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "MD5" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "812f45842bc6d66ee14572ce20db8e86" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "812f45842bc6d66ee14572ce20db8e86" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-1" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-1" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "aaff4a450a104cd177d28d18d74485e8cae074b7" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "aaff4a450a104cd177d28d18d74485e8cae074b7" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-256" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-256" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "b4a697a057313163aee33cd8d40c66e9f0f177e00cac2de32475ffff6169c3e3" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "b4a697a057313163aee33cd8d40c66e9f0f177e00cac2de32475ffff6169c3e3" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-384" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-384" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "d28a7d5cf25a74f11a50a18452b75e04bb3d70c9dd0510d6123aa008c756511b87525bdc835ebb27e1fb9e9374a15562" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "d28a7d5cf25a74f11a50a18452b75e04bb3d70c9dd0510d6123aa008c756511b87525bdc835ebb27e1fb9e9374a15562" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-512" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "input data", + "snd": "SHA-512" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "6ce4adb348546d4f449c4d25aad9a7c9cb711d9e91982d3f0b29ca2f3f47d4ce2deba23bf2954f0f1d593fc50283731a533d30d425402d4f91316d871303aac4" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "6ce4adb348546d4f449c4d25aad9a7c9cb711d9e91982d3f0b29ca2f3f47d4ce2deba23bf2954f0f1d593fc50283731a533d30d425402d4f91316d871303aac4" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.validation.json new file mode 100644 index 0000000000000..8b2514ccccfe6 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_hash_calculations.py::TestHashCalculations::test_hash": { + "last_validated_date": "2023-02-14T20:48:01+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py new file mode 100644 index 0000000000000..d3b37ad599872 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py @@ -0,0 +1,101 @@ +import json + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_record_execution +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + +# TODO: test for validation errors, and boundary testing. + + +class TestJsonManipulation: + @markers.aws.validated + def test_string_to_json( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [ + "", + " ", + "null", + "-0", + "1", + "1.1", + "true", + '"HelloWorld"', + '[1, 2, "HelloWorld"]', + '{"Arg1": 1, "Arg2": []}', + ] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.STRING_TO_JSON, + input_values, + ) + + @markers.aws.validated + def test_json_to_string( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [ + "null", + "-0", + "1", + "1.1", + "true", + '"HelloWorld"', + '[1, 2, "HelloWorld"]', + '{"Arg1": 1, "Arg2": []}', + ] + input_values_jsons = list(map(json.loads, input_values)) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.JSON_TO_STRING, + input_values_jsons, + ) + + @markers.aws.validated + def test_json_merge( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + merge_bindings = [ + ({"a": {"a1": 1, "a2": 2}, "b": 2, "d": 3}, {"a": {"a3": 1, "a4": 2}, "c": 3, "d": 4}), + ] + input_values = list() + for fst, snd in merge_bindings: + input_values.append({"fst": fst, "snd": snd}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.JSON_MERGE, + input_values, + ) + + @markers.aws.validated + def test_json_merge_escaped_argument( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = IFT.load_sfn_template(IFT.JSON_MERGE_ESCAPED_ARGUMENT) + definition = json.dumps(template) + + exec_input = json.dumps({"input_field": {"constant_input_field": "constant_value"}}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.snapshot.json new file mode 100644 index 0000000000000..cc5611726a902 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.snapshot.json @@ -0,0 +1,1446 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_string_to_json": { + "recorded-date": "09-02-2023, 10:21:46", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": null + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": null + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": " " + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": " " + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": null + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": null + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "null" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "null" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": null + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": null + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "-0" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "-0" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "1" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "1" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "1.1" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "1.1" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 1.1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 1.1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "true" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "true" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": true + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_8": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "[1, 2, \"HelloWorld\"]" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "[1, 2, \"HelloWorld\"]" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + 1, + 2, + "HelloWorld" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + 1, + 2, + "HelloWorld" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_9": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "{\"Arg1\": 1, \"Arg2\": []}" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "{\"Arg1\": 1, \"Arg2\": []}" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": { + "Arg1": 1, + "Arg2": [] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": { + "Arg1": 1, + "Arg2": [] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_to_string": { + "recorded-date": "09-02-2023, 10:21:51", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": null + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "null" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "null" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "0" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "0" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": 1.1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": 1.1 + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "1.1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "1.1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": true + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": true + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "true" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "true" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "\"HelloWorld\"" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "\"HelloWorld\"" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": [ + 1, + 2, + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": [ + 1, + 2, + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "[1,2,\"HelloWorld\"]" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "[1,2,\"HelloWorld\"]" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "Arg1": 1, + "Arg2": [] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "Arg1": 1, + "Arg2": [] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "{\"Arg1\":1,\"Arg2\":[]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "{\"Arg1\":1,\"Arg2\":[]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_merge": { + "recorded-date": "13-02-2023, 12:49:30", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": { + "a": { + "a1": 1, + "a2": 2 + }, + "b": 2, + "d": 3 + }, + "snd": { + "a": { + "a3": 1, + "a4": 2 + }, + "c": 3, + "d": 4 + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": { + "a": { + "a1": 1, + "a2": 2 + }, + "b": 2, + "d": 3 + }, + "snd": { + "a": { + "a3": 1, + "a4": 2 + }, + "c": 3, + "d": 4 + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": { + "a": { + "a3": 1, + "a4": 2 + }, + "b": 2, + "c": 3, + "d": 4 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": { + "a": { + "a3": 1, + "a4": 2 + }, + "b": 2, + "c": 3, + "d": 4 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_merge_escaped_argument": { + "recorded-date": "05-07-2024, 14:24:10", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_field": { + "constant_input_field": "constant_value" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_field": { + "constant_input_field": "constant_value" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "constant_input_field": "constant_value", + "constant_in_literal": "false" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "constant_input_field": "constant_value", + "constant_in_literal": "false" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.validation.json new file mode 100644 index 0000000000000..578812afade96 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_merge": { + "last_validated_date": "2023-02-13T11:49:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_merge_escaped_argument": { + "last_validated_date": "2024-07-05T14:24:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_json_to_string": { + "last_validated_date": "2023-02-09T09:21:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation.py::TestJsonManipulation::test_string_to_json": { + "last_validated_date": "2023-02-09T09:21:46+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.py new file mode 100644 index 0000000000000..ec69ca6638ec0 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.py @@ -0,0 +1,30 @@ +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + + +class TestJsonManipulationJSONata: + @markers.aws.validated + def test_parse( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [ + # "null", TODO: Skip as this is failing on the $eval/$parse + "-0", + "1", + "1.1", + "true", + '"HelloWorld"', + '[1, 2, "HelloWorld"]', + '{"Arg1": 1, "Arg2": []}', + ] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.PARSE_JSONATA, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.snapshot.json new file mode 100644 index 0000000000000..e21561043d6ee --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.snapshot.json @@ -0,0 +1,454 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.py::TestJsonManipulationJSONata::test_parse": { + "recorded-date": "21-11-2024, 13:16:36", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "-0" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "-0" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "1" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "1" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "1.1" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "1.1" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "1.1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "1.1", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "true" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "true" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "[1, 2, \"HelloWorld\"]" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "[1, 2, \"HelloWorld\"]" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": "[1,2,\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,2,\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "{\"Arg1\": 1, \"Arg2\": []}" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "{\"Arg1\": 1, \"Arg2\": []}" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "Arg1": 1, + "Arg2": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": 1, + "Arg2": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.validation.json new file mode 100644 index 0000000000000..769a195931ff6 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_json_manipulation_jsonata.py::TestJsonManipulationJSONata::test_parse": { + "last_validated_date": "2024-11-21T13:16:36+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py new file mode 100644 index 0000000000000..61d10ce646811 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py @@ -0,0 +1,157 @@ +import json + +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import await_execution_success +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + +# TODO: test for validation errors, and boundary testing. + + +class TestMathOperations: + @markers.aws.validated + def test_math_random( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + sfn_snapshot.add_transformer( + JsonpathTransformer( + "$..events..executionSucceededEventDetails.output.FunctionResult", + "RandomNumberGenerated", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + "$..events..stateExitedEventDetails.output.FunctionResult", + "RandomNumberGenerated", + replace_reference=False, + ) + ) + + sm_name: str = f"statemachine_{short_uid()}" + definition = IFT.load_sfn_template(IFT.MATH_RANDOM) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + + start_end_tuples = [(12.50, 44.51), (9999, 99999), (-99999, -9999)] + input_values = [{"fst": start, "snd": end} for start, end in start_end_tuples] + + for i, input_value in enumerate(input_values): + exec_input_dict = {IFT.FUNCTION_INPUT_KEY: input_value} + exec_input = json.dumps(exec_input_dict) + + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=exec_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, i)) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + exec_hist_resp = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn + ) + sfn_snapshot.match(f"exec_hist_resp_{i}", exec_hist_resp) + + @markers.aws.validated + def test_math_random_seeded( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + sfn_snapshot.add_transformer( + JsonpathTransformer( + "$..events..executionSucceededEventDetails.output.FunctionResult", + "RandomNumberGenerated", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + "$..events..stateExitedEventDetails.output.FunctionResult", + "RandomNumberGenerated", + replace_reference=False, + ) + ) + + sm_name: str = f"statemachine_{short_uid()}" + definition = IFT.load_sfn_template(IFT.MATH_RANDOM_SEEDED) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + + input_value = {"fst": 0, "snd": 999, "trd": 3} + exec_input_dict = {IFT.FUNCTION_INPUT_KEY: input_value} + exec_input = json.dumps(exec_input_dict) + + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=exec_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + exec_hist_resp = aws_client.stepfunctions.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match("exec_hist_resp", exec_hist_resp) + + @markers.aws.validated + def test_math_add( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + add_tuples = [ + (-9, 3), + (1.49, 1.50), + (1.50, 1.51), + (-1.49, -1.50), + (-1.50, -1.51), + (1.49, 0), + (1.49, -1.49), + (1.50, 0), + (1.51, 0), + (-1.49, 0), + (-1.50, 0), + (-1.51, 0), + # below are cases specifically to verify java vs. python rounding + # python by default would round to even + (0.5, 0), # python: 0, # java: 1 + (1.5, 0), # python: 2, # java: 2 + (2.5, 0), # python: 2, # java: 3 + (3.5, 0), # python: 4, # java: 4 + (-0.5, 0.5), # python: 0, # java: 1 + (-0.5, 0), # python: 0, # java: -1 + (-1.5, 0), # python: -2, # java: -2 + (-2.5, 0), # python: -2, # java: -3 + (-3.5, 0), # python: -4, # java: -4 + ] + input_values = list() + for fst, snd in add_tuples: + input_values.append({"fst": fst, "snd": snd}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.MATH_ADD, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.snapshot.json new file mode 100644 index 0000000000000..eae19b6b4313b --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.snapshot.json @@ -0,0 +1,1844 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_random": { + "recorded-date": "13-02-2023, 14:35:48", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 12.5, + "snd": 44.51 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 12.5, + "snd": 44.51 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 9999, + "snd": 99999 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 9999, + "snd": 99999 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -99999, + "snd": -9999 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -99999, + "snd": -9999 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_random_seeded": { + "recorded-date": "13-02-2023, 14:35:50", + "recorded-content": { + "exec_hist_resp": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 999, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 0, + "snd": 999, + "trd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "RandomNumberGenerated" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_add": { + "recorded-date": "13-09-2023, 23:07:34", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -9, + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -9, + "snd": 3 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -6 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -6 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.49, + "snd": 1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.49, + "snd": 1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.5, + "snd": 1.51 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.5, + "snd": 1.51 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 4 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 4 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.49, + "snd": -1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.49, + "snd": -1.5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -2 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.5, + "snd": -1.51 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.5, + "snd": -1.51 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -3 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.49, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.49, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.49, + "snd": -1.49 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.49, + "snd": -1.49 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_7": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_8": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.51, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.51, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_9": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.49, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.49, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_10": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_11": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.51, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.51, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -2 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_12": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 0.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 0.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_13": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_14": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 2.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 2.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_15": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": 3.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": 3.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 4 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 4 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_16": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -0.5, + "snd": 0.5 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -0.5, + "snd": 0.5 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_17": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -0.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -0.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_18": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -1.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -1 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_19": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -2.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -2.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -2 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_20": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": -3.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": -3.5, + "snd": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": -3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": -3 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.validation.json new file mode 100644 index 0000000000000..fc65cdb7fce23 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_add": { + "last_validated_date": "2023-09-13T21:07:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_random": { + "last_validated_date": "2023-02-13T13:35:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations.py::TestMathOperations::test_math_random_seeded": { + "last_validated_date": "2023-02-13T13:35:50+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.py new file mode 100644 index 0000000000000..e18fc4eba08a7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.py @@ -0,0 +1,41 @@ +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + + +class TestMathOperationsJSONata: + @pytest.mark.skip(reason="AWS does not compute function randomSeeded") + @markers.aws.validated + def test_math_random_seeded( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + sfn_snapshot.add_transformer( + JsonpathTransformer( + "$..FunctionResult", + "RandomNumberGenerated", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + "$..FunctionResult", + "RandomNumberGenerated", + replace_reference=False, + ) + ) + input_values = list({"fst": 3}) + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.MATH_RANDOM_SEEDED_JSONATA, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.snapshot.json new file mode 100644 index 0000000000000..1e37ee2fa786a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.snapshot.json @@ -0,0 +1,67 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.py::TestMathOperationsJSONata::test_math_random_seeded": { + "recorded-date": "15-11-2024, 17:12:32", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "fst" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "fst" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'State_0' (entered at the event id #2). The JSONata expression '$randomSeeded($states.input.FunctionInput.fst)' specified for the field 'Output/FunctionResult' threw an error during evaluation. T1006: Attempted to invoke a non-function", + "error": "States.QueryEvaluationError", + "location": "Output/FunctionResult", + "state": "State_0" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'State_0' (entered at the event id #2). The JSONata expression '$randomSeeded($states.input.FunctionInput.fst)' specified for the field 'Output/FunctionResult' threw an error during evaluation. T1006: Attempted to invoke a non-function", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.validation.json new file mode 100644 index 0000000000000..8d528727b6248 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_math_operations_jsonata.py::TestMathOperationsJSONata::test_math_random_seeded": { + "last_validated_date": "2024-11-15T17:12:32+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py new file mode 100644 index 0000000000000..f0ff91dfc5fc3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py @@ -0,0 +1,54 @@ +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) +from tests.aws.services.stepfunctions.v2.intrinsic_functions.utils import create_and_test_on_inputs + +# TODO: test for validation errors, and boundary testing. + + +class TestStringOperations: + @markers.aws.validated + def test_string_split( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [ + {"fst": " ", "snd": ","}, + {"fst": " , ", "snd": ","}, + {"fst": ", , ,", "snd": ","}, + {"fst": ",,,,", "snd": ","}, + {"fst": "1,2,3,4,5", "snd": ","}, + {"fst": "This.is+a,test=string", "snd": ".+,="}, + {"fst": "split on T and \nnew line", "snd": "T\n"}, + ] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.STRING_SPLIT, + input_values, + ) + + @markers.aws.validated + def test_string_split_context_object( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + input_values = [ + ( + "Value1,Value2,Value3\n" + "Value4,Value5,Value6\n" + ",,,\n" + "true,1,'HelloWorld'\n" + "Null,None,\n" + " \n" + ) + ] + create_and_test_on_inputs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + IFT.STRING_SPLIT_CONTEXT_OBJECT, + input_values, + ) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.snapshot.json new file mode 100644 index 0000000000000..7de9ddef2ee85 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.snapshot.json @@ -0,0 +1,660 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py::TestStringOperations::test_string_split": { + "recorded-date": "05-12-2024, 20:34:43", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": " ", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": " ", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_1": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": " , ", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": " , ", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + " ", + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + " ", + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_2": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": ", , ,", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": ", , ,", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + " ", + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + " ", + " " + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_3": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": ",,,,", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": ",,,,", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_4": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "1,2,3,4,5", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "1,2,3,4,5", + "snd": "," + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + "1", + "2", + "3", + "4", + "5" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "1", + "2", + "3", + "4", + "5" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_5": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "This.is+a,test=string", + "snd": ".+,=" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "This.is+a,test=string", + "snd": ".+,=" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + "This", + "is", + "a", + "test", + "string" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "This", + "is", + "a", + "test", + "string" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp_6": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": { + "fst": "split on T and \nnew line", + "snd": "T\n" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": { + "fst": "split on T and \nnew line", + "snd": "T\n" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + "split on ", + " and ", + "new line" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "split on ", + " and ", + "new line" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py::TestStringOperations::test_string_split_context_object": { + "recorded-date": "28-11-2023, 10:25:42", + "recorded-content": { + "exec_hist_resp_0": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionInput": "Value1,Value2,Value3\nValue4,Value5,Value6\n,,,\ntrue,1,'HelloWorld'\nNull,None,\n \n" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionInput": "Value1,Value2,Value3\nValue4,Value5,Value6\n,,,\ntrue,1,'HelloWorld'\nNull,None,\n \n" + }, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": [ + "Value1", + "Value2", + "Value3\nValue4", + "Value5", + "Value6\n", + "\ntrue", + "1", + "'HelloWorld'\nNull", + "None", + "\n \n" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": [ + "Value1", + "Value2", + "Value3\nValue4", + "Value5", + "Value6\n", + "\ntrue", + "1", + "'HelloWorld'\nNull", + "None", + "\n \n" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.validation.json new file mode 100644 index 0000000000000..5facb1e823a41 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py::TestStringOperations::test_string_split": { + "last_validated_date": "2024-12-05T20:34:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_string_operations.py::TestStringOperations::test_string_split_context_object": { + "last_validated_date": "2023-11-28T09:25:42+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.py new file mode 100644 index 0000000000000..0e9e93ba713ab --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.py @@ -0,0 +1,45 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.services.stepfunctions.asl.utils.json_path import extract_json +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import await_execution_success +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) + + +class TestUniqueIdGeneration: + @markers.aws.validated + def test_uuid( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = IFT.load_sfn_template(IFT.UUID) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + exec_hist_resp = aws_client.stepfunctions.get_execution_history(executionArn=execution_arn) + output = extract_json("$..executionSucceededEventDetails..output", exec_hist_resp) + uuid = json.loads(output)[IFT.FUNCTION_OUTPUT_KEY] + sfn_snapshot.add_transformer(RegexTransformer(uuid, "generated-uuid")) + + sfn_snapshot.match("exec_hist_resp", exec_hist_resp) diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.snapshot.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.snapshot.json new file mode 100644 index 0000000000000..759ba678dadd7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.snapshot.json @@ -0,0 +1,70 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.py::TestUniqueIdGeneration::test_uuid": { + "recorded-date": "09-02-2023, 10:22:06", + "recorded-content": { + "exec_hist_resp": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_0", + "output": { + "FunctionResult": "generated-uuid" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "FunctionResult": "generated-uuid" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.validation.json b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.validation.json new file mode 100644 index 0000000000000..dfa143973b469 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/stepfunctions/v2/intrinsic_functions/test_unique_id_generation.py::TestUniqueIdGeneration::test_uuid": { + "last_validated_date": "2023-02-09T09:22:06+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/intrinsic_functions/utils.py b/tests/aws/services/stepfunctions/v2/intrinsic_functions/utils.py new file mode 100644 index 0000000000000..278ce973ea4c2 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/intrinsic_functions/utils.py @@ -0,0 +1,51 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_terminated, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.intrinsicfunctions.intrinsic_functions_templates import ( + IntrinsicFunctionTemplate as IFT, +) + + +def create_and_test_on_inputs( + target_aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ift_template, + input_values, +): + stepfunctions_client = target_aws_client.stepfunctions + snf_role_arn = create_state_machine_iam_role(target_aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = IFT.load_sfn_template(ift_template) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + target_aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + + for i, input_value in enumerate(input_values): + exec_input_dict = {IFT.FUNCTION_INPUT_KEY: input_value} + exec_input = json.dumps(exec_input_dict) + + exec_resp = stepfunctions_client.start_execution( + stateMachineArn=state_machine_arn, input=exec_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, i)) + execution_arn = exec_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=stepfunctions_client, execution_arn=execution_arn + ) + + exec_hist_resp = stepfunctions_client.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match(f"exec_hist_resp_{i}", exec_hist_resp) diff --git a/tests/aws/services/stepfunctions/v2/logs/__init__.py b/tests/aws/services/stepfunctions/v2/logs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/logs/test_logs.py b/tests/aws/services/stepfunctions/v2/logs/test_logs.py new file mode 100644 index 0000000000000..0948cdbb54fd5 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/logs/test_logs.py @@ -0,0 +1,293 @@ +import itertools +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer +from rolo.testing.pytest import poll_condition + +from localstack.aws.api.stepfunctions import ( + CloudWatchLogsLogGroup, + LogDestination, + LoggingConfiguration, + LogLevel, +) +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_terminated, + create_and_record_logs, + create_state_machine_with_iam_role, + launch_and_record_execution, + launch_and_record_logs, +) +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate + +_TEST_BASE_CONFIGURATIONS = list( + itertools.product( + # test case: + [ + BaseTemplate.BASE_PASS_RESULT, + BaseTemplate.BASE_RAISE_FAILURE, + BaseTemplate.WAIT_SECONDS_PATH, + ], + # log level: + [LogLevel.ALL], + # include execution data + [False, True], + ) +) +_TEST_BASE_CONFIGURATIONS_IDS = [ + f"{config[0].split('/')[-1]}_{config[1]}_{config[2]}" for config in _TEST_BASE_CONFIGURATIONS +] + +_TEST_PARTIAL_LOG_LEVEL_CONFIGURATIONS = list( + itertools.product( + # test case: + [ + BaseTemplate.BASE_PASS_RESULT, + BaseTemplate.BASE_RAISE_FAILURE, + BaseTemplate.WAIT_SECONDS_PATH, + ], + # log level: + [LogLevel.ERROR, LogLevel.FATAL, LogLevel.OFF], + # include execution data + [False, True], + ) +) +_TEST_PARTIAL_LOG_LEVEL_CONFIGURATIONS_IDS = [ + f"{config[0].split('/')[-1]}_{config[1]}_{config[2]}" + for config in _TEST_PARTIAL_LOG_LEVEL_CONFIGURATIONS +] + + +@markers.snapshot.skip_snapshot_verify( + paths=["$..redriveCount", "$..redrive_count", "$..redriveStatus"] +) +class TestLogs: + @markers.aws.validated + @pytest.mark.parametrize( + "template_path,log_level,include_flag", + _TEST_BASE_CONFIGURATIONS, + ids=_TEST_BASE_CONFIGURATIONS_IDS, + ) + def test_base( + self, + aws_client, + create_state_machine_iam_role, + sfn_create_log_group, + create_state_machine, + sfn_snapshot, + template_path, + log_level, + include_flag, + ): + template = BaseTemplate.load_sfn_template(template_path) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_logs( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition, + exec_input, + log_level, + include_flag, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path,log_level,include_flag", + _TEST_PARTIAL_LOG_LEVEL_CONFIGURATIONS, + ids=_TEST_PARTIAL_LOG_LEVEL_CONFIGURATIONS_IDS, + ) + def test_partial_log_levels( + self, + aws_client, + create_state_machine_iam_role, + sfn_create_log_group, + create_state_machine, + sfn_snapshot, + template_path, + log_level, + include_flag, + ): + log_group_name = sfn_create_log_group() + log_group_arn = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration = LoggingConfiguration( + level=log_level, + includeExecutionData=include_flag, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + + template = BaseTemplate.load_sfn_template(template_path) + definition = json.dumps(template) + + state_machine_arn = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + logging_configuration, + ) + + execution_input = json.dumps({}) + + launch_and_record_logs( + aws_client, + state_machine_arn, + execution_input, + log_level, + log_group_name, + sfn_snapshot, + ) + + @markers.aws.validated + def test_deleted_log_group( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + ): + logs_client = aws_client.logs + log_group_name = sfn_create_log_group() + log_group_arn = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration = LoggingConfiguration( + level=LogLevel.ALL, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition = json.dumps(template) + + state_machine_arn = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + logging_configuration, + ) + + logs_client.delete_log_group(logGroupName=log_group_name) + + def _log_group_is_deleted() -> bool: + return not logs_client.describe_log_groups(logGroupNamePrefix=log_group_name).get( + "logGroups", None + ) + + assert poll_condition(condition=_log_group_is_deleted) + + execution_input = json.dumps({}) + launch_and_record_execution( + aws_client, + sfn_snapshot, + state_machine_arn, + execution_input, + ) + + @markers.aws.validated + def test_log_group_with_multiple_runs( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..event_timestamp", + replacement="timestamp", + replace_reference=False, + ) + ) + + logs_client = aws_client.logs + log_group_name = sfn_create_log_group() + log_group_arn = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration = LoggingConfiguration( + level=LogLevel.ALL, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition = json.dumps(template) + + state_machine_arn = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + logging_configuration, + ) + + expected_events_count = 0 + for i in range(3): + execution_input = json.dumps({"ExecutionNumber": i}) + start_execution_response = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=execution_input + ) + execution_arn = start_execution_response["executionArn"] + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_exec_arn(start_execution_response, i) + ) + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + execution_history = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn + ) + expected_events_count += len(execution_history["events"]) + + logs_client = aws_client.logs + log_events = list() + + def _collect_log_events(): + log_streams = logs_client.describe_log_streams(logGroupName=log_group_name)[ + "logStreams" + ] + if len(log_streams) < 2: + return False + + log_stream_name = log_streams[-1]["logStreamName"] + nonlocal log_events + log_events.clear() + log_events = logs_client.get_log_events( + logGroupName=log_group_name, logStreamName=log_stream_name, startFromHead=True + )["events"] + return len(log_events) == expected_events_count + + assert poll_condition(condition=_collect_log_events) + + logged_execution_events = [json.loads(e["message"]) for e in log_events] + sfn_snapshot.match("logged_execution_events", logged_execution_events) diff --git a/tests/aws/services/stepfunctions/v2/logs/test_logs.snapshot.json b/tests/aws/services/stepfunctions/v2/logs/test_logs.snapshot.json new file mode 100644 index 0000000000000..8ad9ae1bef99a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/logs/test_logs.snapshot.json @@ -0,0 +1,1938 @@ +{ + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[pass_result.json5_ALL_False]": { + "recorded-date": "10-06-2024, 13:36:31", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "PassStateEntered", + "details": { + "name": "State_1" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "PassStateExited", + "details": { + "name": "State_1" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "4", + "type": "ExecutionSucceeded", + "details": {}, + "previous_event_id": "3", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[pass_result.json5_ALL_True]": { + "recorded-date": "10-06-2024, 13:44:09", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "PassStateEntered", + "details": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "PassStateExited", + "details": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "4", + "type": "ExecutionSucceeded", + "details": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "previous_event_id": "3", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[raise_failure.json5_ALL_False]": { + "recorded-date": "10-06-2024, 13:35:15", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "FailStateEntered", + "details": { + "name": "FailState" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[raise_failure.json5_ALL_True]": { + "recorded-date": "10-06-2024, 13:39:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "FailStateEntered", + "details": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[wait_seconds_path.json5_ALL_False]": { + "recorded-date": "10-06-2024, 14:28:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "WaitStateEntered", + "details": { + "name": "WaitState" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[wait_seconds_path.json5_ALL_True]": { + "recorded-date": "10-06-2024, 14:30:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "WaitStateEntered", + "details": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_deleted_log_group": { + "recorded-date": "05-06-2024, 20:42:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_ERROR_False]": { + "recorded-date": "10-06-2024, 12:17:10", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_ERROR_True]": { + "recorded-date": "10-06-2024, 12:17:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_FATAL_False]": { + "recorded-date": "10-06-2024, 12:17:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_FATAL_True]": { + "recorded-date": "10-06-2024, 12:17:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_OFF_False]": { + "recorded-date": "10-06-2024, 12:18:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_OFF_True]": { + "recorded-date": "10-06-2024, 12:18:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_ERROR_False]": { + "recorded-date": "10-06-2024, 12:18:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "2", + "type": "FailStateEntered", + "details": { + "name": "FailState" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_ERROR_True]": { + "recorded-date": "10-06-2024, 12:19:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "2", + "type": "FailStateEntered", + "details": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_FATAL_False]": { + "recorded-date": "10-06-2024, 12:19:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_FATAL_True]": { + "recorded-date": "10-06-2024, 12:19:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_OFF_False]": { + "recorded-date": "10-06-2024, 12:20:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_OFF_True]": { + "recorded-date": "10-06-2024, 12:20:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_ERROR_False]": { + "recorded-date": "10-06-2024, 12:20:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_ERROR_True]": { + "recorded-date": "10-06-2024, 12:21:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_FATAL_False]": { + "recorded-date": "10-06-2024, 12:21:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_FATAL_True]": { + "recorded-date": "10-06-2024, 12:22:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "logged_execution_events": [ + { + "id": "3", + "type": "ExecutionFailed", + "details": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_OFF_False]": { + "recorded-date": "10-06-2024, 12:22:27", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_OFF_True]": { + "recorded-date": "10-06-2024, 12:22:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The SecondsPath parameter does not reference an input value: $.input.waitSeconds", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_log_group_with_multiple_runs": { + "recorded-date": "10-06-2024, 14:19:04", + "recorded-content": { + "logged_execution_events": [ + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "PassStateEntered", + "details": { + "name": "State_1" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "PassStateExited", + "details": { + "name": "State_1" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "4", + "type": "ExecutionSucceeded", + "details": {}, + "previous_event_id": "3", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "PassStateEntered", + "details": { + "name": "State_1" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "PassStateExited", + "details": { + "name": "State_1" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "4", + "type": "ExecutionSucceeded", + "details": {}, + "previous_event_id": "3", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "1", + "type": "ExecutionStarted", + "details": { + "roleArn": "snf_role_arn" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "2", + "type": "PassStateEntered", + "details": { + "name": "State_1" + }, + "previous_event_id": "0", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "3", + "type": "PassStateExited", + "details": { + "name": "State_1" + }, + "previous_event_id": "2", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + }, + { + "id": "4", + "type": "ExecutionSucceeded", + "details": {}, + "previous_event_id": "3", + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:execution::", + "redrive_count": "0" + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/logs/test_logs.validation.json b/tests/aws/services/stepfunctions/v2/logs/test_logs.validation.json new file mode 100644 index 0000000000000..70ee33ed968af --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/logs/test_logs.validation.json @@ -0,0 +1,80 @@ +{ + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[pass_result.json5_ALL_False]": { + "last_validated_date": "2024-06-10T13:36:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[pass_result.json5_ALL_True]": { + "last_validated_date": "2024-06-10T13:44:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[raise_failure.json5_ALL_False]": { + "last_validated_date": "2024-06-10T13:35:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[raise_failure.json5_ALL_True]": { + "last_validated_date": "2024-06-10T13:39:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[wait_seconds_path.json5_ALL_False]": { + "last_validated_date": "2024-06-10T14:28:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_base[wait_seconds_path.json5_ALL_True]": { + "last_validated_date": "2024-06-10T14:30:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_deleted_log_group": { + "last_validated_date": "2024-06-05T20:42:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_log_group_with_multiple_runs": { + "last_validated_date": "2024-06-10T14:19:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_ERROR_False]": { + "last_validated_date": "2024-06-10T12:17:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_ERROR_True]": { + "last_validated_date": "2024-06-10T12:17:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_FATAL_False]": { + "last_validated_date": "2024-06-10T12:17:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_FATAL_True]": { + "last_validated_date": "2024-06-10T12:17:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_OFF_False]": { + "last_validated_date": "2024-06-10T12:18:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[pass_result.json5_OFF_True]": { + "last_validated_date": "2024-06-10T12:18:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_ERROR_False]": { + "last_validated_date": "2024-06-10T12:18:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_ERROR_True]": { + "last_validated_date": "2024-06-10T12:19:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_FATAL_False]": { + "last_validated_date": "2024-06-10T12:19:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_FATAL_True]": { + "last_validated_date": "2024-06-10T12:19:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_OFF_False]": { + "last_validated_date": "2024-06-10T12:20:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[raise_failure.json5_OFF_True]": { + "last_validated_date": "2024-06-10T12:20:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_ERROR_False]": { + "last_validated_date": "2024-06-10T12:20:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_ERROR_True]": { + "last_validated_date": "2024-06-10T12:21:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_FATAL_False]": { + "last_validated_date": "2024-06-10T12:21:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_FATAL_True]": { + "last_validated_date": "2024-06-10T12:22:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_OFF_False]": { + "last_validated_date": "2024-06-10T12:22:27+00:00" + }, + "tests/aws/services/stepfunctions/v2/logs/test_logs.py::TestLogs::test_partial_log_levels[wait_seconds_path.json5_OFF_True]": { + "last_validated_date": "2024-06-10T12:22:42+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/mocking/__init__.py b/tests/aws/services/stepfunctions/v2/mocking/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_aws_scenarios.py b/tests/aws/services/stepfunctions/v2/mocking/test_aws_scenarios.py new file mode 100644 index 0000000000000..0ced66200e798 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_aws_scenarios.py @@ -0,0 +1,156 @@ +import json + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_run_mock +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.mocked_service_integrations.mocked_service_integrations import ( + MockedServiceIntegrationsLoader, +) +from tests.aws.services.stepfunctions.templates.mocked.mocked_templates import MockedTemplates + + +class TestBaseScenarios: + @markers.aws.only_localstack + def test_lambda_sqs_integration_happy_path( + self, + aws_client, + monkeypatch, + mock_config_file, + ): + execution_arn = create_and_run_mock( + target_aws_client=aws_client, + monkeypatch=monkeypatch, + mock_config_file=mock_config_file, + mock_config=MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCK_CONFIG_FILE_LAMBDA_SQS_INTEGRATION + ), + state_machine_name="LambdaSQSIntegration", + definition_template=MockedTemplates.load_sfn_template( + MockedTemplates.LAMBDA_SQS_INTEGRATION + ), + execution_input="{}", + test_name="HappyPath", + ) + + execution_history = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn, includeExecutionData=True + ) + events = execution_history["events"] + + event_4 = events[4] + assert json.loads(event_4["taskSucceededEventDetails"]["output"]) == { + "StatusCode": 200, + "Payload": {"StatusCode": 200, "body": "Hello from Lambda!"}, + } + + event_last = events[-1] + assert event_last["type"] == "ExecutionSucceeded" + + @markers.aws.only_localstack + def test_lambda_sqs_integration_retry_path( + self, + aws_client, + monkeypatch, + mock_config_file, + ): + execution_arn = create_and_run_mock( + target_aws_client=aws_client, + monkeypatch=monkeypatch, + mock_config_file=mock_config_file, + mock_config=MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCK_CONFIG_FILE_LAMBDA_SQS_INTEGRATION + ), + state_machine_name="LambdaSQSIntegration", + definition_template=MockedTemplates.load_sfn_template( + MockedTemplates.LAMBDA_SQS_INTEGRATION + ), + execution_input="{}", + test_name="RetryPath", + ) + + execution_history = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn, includeExecutionData=True + ) + events = execution_history["events"] + + event_4 = events[4] + assert event_4["taskFailedEventDetails"] == { + "error": "Lambda.ResourceNotReadyException", + "cause": "Lambda resource is not ready.", + } + assert event_4["type"] == "TaskFailed" + + event_7 = events[7] + assert event_7["taskFailedEventDetails"] == { + "error": "Lambda.TimeoutException", + "cause": "Lambda timed out.", + } + assert event_7["type"] == "TaskFailed" + + event_10 = events[10] + assert event_10["taskFailedEventDetails"] == { + "error": "Lambda.TimeoutException", + "cause": "Lambda timed out.", + } + assert event_10["type"] == "TaskFailed" + + event_13 = events[13] + assert json.loads(event_13["taskSucceededEventDetails"]["output"]) == { + "StatusCode": 200, + "Payload": {"StatusCode": 200, "body": "Hello from Lambda!"}, + } + + event_last = events[-1] + assert event_last["type"] == "ExecutionSucceeded" + + @markers.aws.only_localstack + def test_lambda_sqs_integration_hybrid_path( + self, + aws_client, + sqs_create_queue, + monkeypatch, + mock_config_file, + ): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + definition_template = MockedTemplates.load_sfn_template( + MockedTemplates.LAMBDA_SQS_INTEGRATION + ) + definition_template["States"]["SQSState"]["Parameters"]["QueueUrl"] = queue_url + execution_arn = create_and_run_mock( + target_aws_client=aws_client, + monkeypatch=monkeypatch, + mock_config_file=mock_config_file, + mock_config=MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCK_CONFIG_FILE_LAMBDA_SQS_INTEGRATION + ), + state_machine_name="LambdaSQSIntegration", + definition_template=definition_template, + execution_input="{}", + test_name="HybridPath", + ) + + execution_history = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn, includeExecutionData=True + ) + events = execution_history["events"] + + event_4 = events[4] + assert json.loads(event_4["taskSucceededEventDetails"]["output"]) == { + "StatusCode": 200, + "Payload": {"StatusCode": 200, "body": "Hello from Lambda!"}, + } + + event_last = events[-1] + assert event_last["type"] == "ExecutionSucceeded" + receive_message_res = aws_client.sqs.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ) + assert len(receive_message_res["Messages"]) == 1 + + sqs_message = receive_message_res["Messages"][0] + print(sqs_message) + assert json.loads(sqs_message["Body"]) == { + "StatusCode": 200, + "Payload": {"StatusCode": 200, "body": "Hello from Lambda!"}, + } diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py b/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py new file mode 100644 index 0000000000000..7273954337d03 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py @@ -0,0 +1,304 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack import config +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, + create_and_record_mocked_execution, + create_state_machine_with_iam_role, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.mocked_service_integrations.mocked_service_integrations import ( + MockedServiceIntegrationsLoader, +) +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate +from tests.aws.services.stepfunctions.templates.callbacks.callback_templates import ( + CallbackTemplates, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + "$..ExecutedVersion", + "$..RedriveCount", + "$..redriveCount", + "$..RedriveStatus", + "$..redriveStatus", + "$..RedriveStatusReason", + "$..redriveStatusReason", + # In an effort to comply with SFN Local's lack of handling of sync operations, + # we are unable to produce valid TaskSubmittedEventDetails output field, which + # must include the provided mocked response in the output: + "$..events..taskSubmittedEventDetails.output", + ] +) +class TestBaseScenarios: + @markers.aws.validated + @pytest.mark.parametrize( + "template_file_path, mocked_response_filepath", + [ + ( + CallbackTemplates.SFN_START_EXECUTION_SYNC, + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_STATES_200_START_EXECUTION_SYNC, + ), + ( + CallbackTemplates.SFN_START_EXECUTION_SYNC2, + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_STATES_200_START_EXECUTION_SYNC2, + ), + ], + ids=["SFN_SYNC", "SFN_SYNC2"], + ) + def test_sfn_start_execution_sync( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + monkeypatch, + mock_config_file, + sfn_snapshot, + template_file_path, + mocked_response_filepath, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StopDate", + replacement="stop-date", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..StateMachineArn", + replacement="state-machine-arn", + replace_reference=False, + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..ExecutionArn", + replacement="execution-arn", + replace_reference=False, + ) + ) + + template = CallbackTemplates.load_sfn_template(template_file_path) + definition = json.dumps(template) + + if is_aws_cloud(): + template_target = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + exec_input = json.dumps( + { + "StateMachineArn": state_machine_arn_target, + "Input": None, + "Name": "TestStartTarget", + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + mocked_response = MockedServiceIntegrationsLoader.load(mocked_response_filepath) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"StartExecution": "mocked_response"}} + } + }, + "MockedResponses": {"mocked_response": mocked_response}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + exec_input = json.dumps( + {"StateMachineArn": "state-machine-arn", "Input": None, "Name": "TestStartTarget"} + ) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + def test_sqs_wait_for_task_token( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + mock_config_file, + monkeypatch, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + template = CallbackTemplates.load_sfn_template(CallbackTemplates.SQS_WAIT_FOR_TASK_TOKEN) + definition = json.dumps(template) + message = "string-literal" + + if is_aws_cloud(): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + sqs_send_task_success_state_machine(queue_url) + + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + task_success = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_CALLBACK_TASK_SUCCESS_STRING_LITERAL + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"SendMessageWithWait": "task_success"}} + } + }, + "MockedResponses": {"task_success": task_success}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + exec_input = json.dumps({"QueueUrl": "sqs_queue_url", "Message": message}) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Reason: skipping events validation because in mock‐failure mode the + # TaskSubmitted event is never emitted; this causes the events sequence + # to be shifted by one. Nevertheless, the evaluation of the state machine + # is still successful. + "$..events" + ] + ) + def test_sqs_wait_for_task_token_task_failure( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_failure_state_machine, + sfn_snapshot, + mock_config_file, + monkeypatch, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + template = CallbackTemplates.load_sfn_template( + CallbackTemplates.SQS_WAIT_FOR_TASK_TOKEN_CATCH + ) + definition = json.dumps(template) + message = "string-literal" + + if is_aws_cloud(): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + sqs_send_task_failure_state_machine(queue_url) + + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message}) + execution_arn = create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + task_failure = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_CALLBACK_TASK_FAILURE + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"SendMessageWithWait": "task_failure"}} + } + }, + "MockedResponses": {"task_failure": task_failure}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + exec_input = json.dumps({"QueueUrl": "sqs_queue_url", "Message": message}) + execution_arn = create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + describe_execution_response = aws_client.stepfunctions.describe_execution( + executionArn=execution_arn + ) + sfn_snapshot.match("describe_execution_response", describe_execution_response) diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.snapshot.json b/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.snapshot.json new file mode 100644 index 0000000000000..4628554c854af --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.snapshot.json @@ -0,0 +1,824 @@ +{ + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sfn_start_execution_sync[SFN_SYNC]": { + "recorded-date": "24-04-2025, 10:05:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "state-machine-arn", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "state-machine-arn", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "state-machine-arn", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "execution-arn", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "164" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "164", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "execution-arn", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "start-date", + "StateMachineArn": "state-machine-arn", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "execution-arn", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "start-date", + "StateMachineArn": "state-machine-arn", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "execution-arn", + "Input": "{}", + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": "{\"Arg1\":\"argument1\"}", + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "start-date", + "StateMachineArn": "state-machine-arn", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sfn_start_execution_sync[SFN_SYNC2]": { + "recorded-date": "24-04-2025, 10:06:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "state-machine-arn", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "state-machine-arn", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "state-machine-arn", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "ExecutionArn": "execution-arn", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "164" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "164", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "execution-arn", + "Input": {}, + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": { + "Arg1": "argument1" + }, + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "start-date", + "StateMachineArn": "state-machine-arn", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution.sync:2", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "execution-arn", + "Input": {}, + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": { + "Arg1": "argument1" + }, + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "start-date", + "StateMachineArn": "state-machine-arn", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "execution-arn", + "Input": {}, + "InputDetails": { + "Included": true + }, + "Name": "TestStartTarget", + "Output": { + "Arg1": "argument1" + }, + "OutputDetails": { + "Included": true + }, + "RedriveCount": 0, + "RedriveStatus": "NOT_REDRIVABLE", + "RedriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "StartDate": "start-date", + "StateMachineArn": "state-machine-arn", + "Status": "SUCCEEDED", + "StopDate": "stop-date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sqs_wait_for_task_token": { + "recorded-date": "29-04-2025, 10:17:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Message": "string-literal", + "TaskToken": "" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": "\"string-literal\"", + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": "\"string-literal\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"string-literal\"", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sqs_wait_for_task_token_task_failure": { + "recorded-date": "29-04-2025, 11:15:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageBody": { + "Context": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal" + }, + "TaskToken": "" + }, + "QueueUrl": "sqs_queue_url" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "cause": "Failure cause", + "error": "Failure error", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "SendMessageWithWait", + "output": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal", + "states_all_error": { + "Error": "Failure error", + "Cause": "Failure cause" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal", + "states_all_error": { + "Error": "Failure error", + "Cause": "Failure cause" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CaughtStatesALL" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "CaughtStatesALL", + "output": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal", + "states_all_error": { + "Error": "Failure error", + "Cause": "Failure cause" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal", + "states_all_error": { + "Error": "Failure error", + "Cause": "Failure cause" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_execution_response": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal" + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "QueueUrl": "sqs_queue_url", + "Message": "string-literal", + "states_all_error": { + "Error": "Failure error", + "Cause": "Failure cause" + } + }, + "outputDetails": { + "included": true + }, + "redriveCount": 0, + "redriveStatus": "NOT_REDRIVABLE", + "redriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.validation.json b/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.validation.json new file mode 100644 index 0000000000000..1151f58cdcd1e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sfn_start_execution_sync[SFN_SYNC2]": { + "last_validated_date": "2025-04-24T10:06:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sfn_start_execution_sync[SFN_SYNC]": { + "last_validated_date": "2025-04-24T10:05:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sqs_wait_for_task_token": { + "last_validated_date": "2025-04-29T10:17:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_callbacks.py::TestBaseScenarios::test_sqs_wait_for_task_token_task_failure": { + "last_validated_date": "2025-04-29T11:15:14+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py b/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py new file mode 100644 index 0000000000000..a267d59cf91dc --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py @@ -0,0 +1,728 @@ +import json + +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +import localstack.testing.config +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.api.stepfunctions import HistoryEventType +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + SfnNoneRecursiveParallelTransformer, + await_execution_terminated, + create_and_record_execution, + create_and_record_express_sync_execution, + create_and_record_mocked_execution, + create_and_record_mocked_sync_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.mocked_service_integrations.mocked_service_integrations import ( + MockedServiceIntegrationsLoader, +) +from tests.aws.services.stepfunctions.templates.scenarios.scenarios_templates import ( + ScenariosTemplate, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ServicesTemplates + + +@markers.snapshot.skip_snapshot_verify( + paths=["$..SdkHttpMetadata", "$..SdkResponseMetadata", "$..ExecutedVersion"] +) +class TestBaseScenarios: + @markers.aws.validated + def test_lambda_invoke( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + function_name = f"lambda_{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + + template = ServicesTemplates.load_sfn_template(ServicesTemplates.LAMBDA_INVOKE_RESOURCE) + exec_input = json.dumps({"body": "string body"}) + + if is_aws_cloud(): + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=ServicesTemplates.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + template["States"]["step1"]["Resource"] = lambda_arn + definition = json.dumps(template) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + lambda_200_string_body = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_LAMBDA_200_STRING_BODY + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"step1": "lambda_200_string_body"}} + } + }, + "MockedResponses": {"lambda_200_string_body": lambda_200_string_body}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + # Insert the test environment's region name into this mock ARN + # to maintain snapshot compatibility across multi-region tests. + test_region_name = localstack.testing.config.TEST_AWS_REGION_NAME + template["States"]["step1"]["Resource"] = ( + f"arn:aws:lambda:{test_region_name}:111111111111:function:{function_name}" + ) + definition = json.dumps(template) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.only_localstack + def test_lambda_invoke_retries( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + monkeypatch, + mock_config_file, + ): + template = ScenariosTemplate.load_sfn_template( + ScenariosTemplate.LAMBDA_INVOKE_WITH_RETRY_BASE + ) + template["States"]["InvokeLambdaWithRetry"]["Resource"] = ( + "arn:aws:lambda:us-east-1:111111111111:function:nosuchfunction" + ) + definition = json.dumps(template) + + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + lambda_not_ready_timeout_200_string_body = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_LAMBDA_NOT_READY_TIMEOUT_200_STRING_BODY + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": { + test_name: { + "InvokeLambdaWithRetry": "lambda_not_ready_timeout_200_string_body" + } + } + } + }, + "MockedResponses": { + "lambda_not_ready_timeout_200_string_body": lambda_not_ready_timeout_200_string_body + }, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + + role_arn = create_state_machine_iam_role(target_aws_client=aws_client) + + state_machine = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=definition, + roleArn=role_arn, + ) + state_machine_arn = state_machine["stateMachineArn"] + + sfn_client = aws_client.stepfunctions + execution = sfn_client.start_execution( + stateMachineArn=f"{state_machine_arn}#{test_name}", input="{}" + ) + execution_arn = execution["executionArn"] + + await_execution_terminated(stepfunctions_client=sfn_client, execution_arn=execution_arn) + + execution_history = sfn_client.get_execution_history( + executionArn=execution_arn, includeExecutionData=True + ) + events = execution_history["events"] + + event_4 = events[4] + assert event_4["taskFailedEventDetails"] == { + "error": "Lambda.ResourceNotReadyException", + "cause": "This is a mocked lambda error", + } + + event_7 = events[7] + assert event_7["taskFailedEventDetails"] == { + "error": "Lambda.TimeoutException", + "cause": "This is a mocked lambda error", + } + + last_event = events[-1] + assert last_event["type"] == HistoryEventType.ExecutionSucceeded + assert last_event["executionSucceededEventDetails"]["output"] == '{"Retries":2}' + + @markers.aws.validated + def test_lambda_service_invoke( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + template = ServicesTemplates.load_sfn_template(ServicesTemplates.LAMBDA_INVOKE) + definition = json.dumps(template) + + function_name = f"lambda_{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + exec_input = json.dumps({"FunctionName": function_name, "Payload": {"body": "string body"}}) + + if is_aws_cloud(): + create_lambda_function( + func_name=function_name, + handler_file=ServicesTemplates.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + lambda_200_string_body = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_LAMBDA_200_STRING_BODY + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"Start": "lambda_200_string_body"}} + } + }, + "MockedResponses": {"lambda_200_string_body": lambda_200_string_body}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..billingDetails", + ] + ) + def test_lambda_service_invoke_sync_execution( + self, + aws_client, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + template = ServicesTemplates.load_sfn_template(ServicesTemplates.LAMBDA_INVOKE) + definition = json.dumps(template) + + function_name = f"lambda_{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + exec_input = json.dumps({"FunctionName": function_name, "Payload": {"body": "string body"}}) + + if is_aws_cloud(): + create_lambda_function( + func_name=function_name, + handler_file=ServicesTemplates.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + create_and_record_express_sync_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + lambda_200_string_body = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_LAMBDA_200_STRING_BODY + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"Start": "lambda_200_string_body"}} + } + }, + "MockedResponses": {"lambda_200_string_body": lambda_200_string_body}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + + create_and_record_mocked_sync_execution( + target_aws_client=aws_client_no_sync_prefix, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + state_machine_name=state_machine_name, + test_name=test_name, + ) + + @markers.aws.validated + def test_sqs_send_message( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sqs_create_queue, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + template = ServicesTemplates.load_sfn_template(ServicesTemplates.SQS_SEND_MESSAGE) + definition = json.dumps(template) + message_body = "test_message_body" + + if is_aws_cloud(): + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "sqs-queue-name")) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs-queue-url")) + + exec_input = json.dumps({"QueueUrl": queue_url, "MessageBody": message_body}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + sqs_200_send_message = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_SQS_200_SEND_MESSAGE + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"SendSQS": "sqs_200_send_message"}} + } + }, + "MockedResponses": {"sqs_200_send_message": sqs_200_send_message}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + exec_input = json.dumps({"QueueUrl": "sqs-queue-url", "MessageBody": message_body}) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + def test_sns_publish_base( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sns_create_topic, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + template = ServicesTemplates.load_sfn_template(ServicesTemplates.SNS_PUBLISH) + definition = json.dumps(template) + message_body = {"message": "string-literal"} + + if is_aws_cloud(): + topic = sns_create_topic() + topic_arn = topic["TopicArn"] + sfn_snapshot.add_transformer(RegexTransformer(topic_arn, "topic-arn")) + exec_input = json.dumps({"TopicArn": topic_arn, "Message": message_body}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + sns_200_publish = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_SNS_200_PUBLISH + ) + mock_config = { + "StateMachines": { + state_machine_name: {"TestCases": {test_name: {"Publish": "sns_200_publish"}}} + }, + "MockedResponses": {"sns_200_publish": sns_200_publish}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + exec_input = json.dumps({"TopicArn": "topic-arn", "Message": message_body}) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + def test_events_put_events( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + events_to_sqs_queue, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + detail_type = f"detail_type_{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(detail_type, "detail-type")) + entries = [ + { + "Detail": json.dumps({"Message": "string-literal"}), + "DetailType": detail_type, + "Source": "some.source", + } + ] + + template = ServicesTemplates.load_sfn_template(ServicesTemplates.EVENTS_PUT_EVENTS) + definition = json.dumps(template) + + exec_input = json.dumps({"Entries": entries}) + + if is_aws_cloud(): + event_pattern = {"detail-type": [detail_type]} + queue_url = events_to_sqs_queue(event_pattern) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + events_200_put_events = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_EVENTS_200_PUT_EVENTS + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"PutEvents": "events_200_put_events"}} + } + }, + "MockedResponses": {"events_200_put_events": events_200_put_events}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + def test_dynamodb_put_get_item( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + events_to_sqs_queue, + dynamodb_create_table, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + template = ServicesTemplates.load_sfn_template(ServicesTemplates.DYNAMODB_PUT_GET_ITEM) + definition = json.dumps(template) + + table_name = f"sfn_test_table_{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(table_name, "table-name")) + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "string-literal"}, "id": {"S": "id1"}}, + "Key": {"id": {"S": "id1"}}, + } + ) + + if is_aws_cloud(): + dynamodb_create_table( + table_name=table_name, partition_key="id", client=aws_client.dynamodb + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + dynamodb_200_put_item = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_DYNAMODB_200_PUT_ITEM + ) + dynamodb_200_get_item = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_DYNAMODB_200_GET_ITEM + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": { + test_name: { + "PutItem": "dynamodb_200_put_item", + "GetItem": "dynamodb_200_get_item", + } + } + } + }, + "MockedResponses": { + "dynamodb_200_put_item": dynamodb_200_put_item, + "dynamodb_200_get_item": dynamodb_200_get_item, + }, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..events..previousEventId"]) + def test_map_state_lambda( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + mock_config_file, + monkeypatch, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..mapRunArn", replacement="map_run_arn", replace_reference=False + ) + ) + + template = ScenariosTemplate.load_sfn_template( + ScenariosTemplate.MAP_STATE_CONFIG_DISTRIBUTED_REENTRANT_LAMBDA + ) + # Update the lambda function's return value. + template["States"]["StartState"]["Parameters"]["Values"][0] = {"body": "string body"} + definition = json.dumps(template) + + exec_input = json.dumps({}) + if is_aws_cloud(): + function_name = f"sfn_lambda_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ServicesTemplates.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + definition = definition.replace("_tbd_", function_arn) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + lambda_200_string_body = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_LAMBDA_200_STRING_BODY + ) + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": {test_name: {"ProcessValue": "lambda_200_string_body"}} + } + }, + "MockedResponses": {"lambda_200_string_body": lambda_200_string_body}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + definition = definition.replace( + "_tbd_", "arn:aws:lambda:us-east-1:111111111111:function:nosuchfunction" + ) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..stateExitedEventDetails.output", "$..executionSucceededEventDetails.output"] + ) + def test_parallel_state_lambda( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + monkeypatch, + mock_config_file, + ): + sfn_snapshot.add_transformer(SfnNoneRecursiveParallelTransformer()) + template = ScenariosTemplate.load_sfn_template( + ScenariosTemplate.PARALLEL_STATE_SERVICE_LAMBDA + ) + definition = json.dumps(template) + + function_name_branch1 = f"lambda_branch1_{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(function_name_branch1, "function_name_branch1") + ) + function_name_branch2 = f"lambda_branch2_{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(function_name_branch2, "function_name_branch2") + ) + + exec_input = json.dumps( + { + "FunctionNameBranch1": function_name_branch1, + "FunctionNameBranch2": function_name_branch2, + "Payload": ["string-literal"], + } + ) + + if is_aws_cloud(): + create_lambda_function( + func_name=function_name_branch1, + handler_file=ServicesTemplates.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + create_lambda_function( + func_name=function_name_branch2, + handler_file=ServicesTemplates.LAMBDA_RETURN_DECORATED_INPUT, + runtime=Runtime.python3_12, + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + else: + state_machine_name = f"mocked_state_machine_{short_uid()}" + test_name = "TestCaseName" + mock_config = { + "StateMachines": { + state_machine_name: { + "TestCases": { + test_name: { + "Branch1": "MockedBranch1", + "Branch2": "MockedBranch2", + } + } + } + }, + "MockedResponses": { + "MockedBranch1": { + "0": {"Return": {"StatusCode": 200, "Payload": ["string-literal"]}} + }, + "MockedBranch2": { + "0": { + "Return": { + "StatusCode": 200, + "Payload": "input-event-['string-literal']", + } + } + }, + }, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + create_and_record_mocked_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + state_machine_name, + test_name, + ) diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.snapshot.json b/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.snapshot.json new file mode 100644 index 0000000000000..739ec3945461f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.snapshot.json @@ -0,0 +1,2606 @@ +{ + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke": { + "recorded-date": "14-04-2025, 18:51:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "body": "string body" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "body": "string body" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "lambda_function_name", + "Payload": { + "body": "string body" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke": { + "recorded-date": "22-04-2025, 10:30:21", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "body": "string body" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "body": "string body" + }, + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "body": "string body" + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_name" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": { + "body": "string body" + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": { + "body": "string body" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "body": "string body" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_sqs_send_message": { + "recorded-date": "22-04-2025, 19:39:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "sqs-queue-url", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "sqs-queue-url", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendSQS" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "sqs-queue-url", + "MessageBody": "test_message_body" + }, + "region": "", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "SendSQS", + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_sns_publish_base": { + "recorded-date": "23-04-2025, 13:52:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "topic-arn", + "Message": { + "message": "string-literal" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "topic-arn", + "Message": { + "message": "string-literal" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "topic-arn", + "Message": { + "message": "string-literal" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": "x-amzn-RequestId", + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_events_put_events": { + "recorded-date": "23-04-2025, 14:28:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Entries": [ + { + "Detail": "{\"Message\": \"string-literal\"}", + "DetailType": "detail-type", + "Source": "some.source" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Entries": [ + { + "Detail": "{\"Message\": \"string-literal\"}", + "DetailType": "detail-type", + "Source": "some.source" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "PutEvents" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Entries": [ + { + "Detail": "{\"Message\": \"string-literal\"}", + "DetailType": "detail-type", + "Source": "some.source" + } + ] + }, + "region": "", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0 + }, + "outputDetails": { + "truncated": false + }, + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutEvents", + "output": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Entries": [ + { + "EventId": "" + } + ], + "FailedEntryCount": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_dynamodb_put_get_item": { + "recorded-date": "23-04-2025, 15:32:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "GetItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Key": { + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": { + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "57" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "57", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "GetItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "getItemOutput": { + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "57" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "57", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "getItemOutput": { + "Item": { + "data": { + "S": "string-literal" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "57" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "57", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_map_state_lambda": { + "recorded-date": "24-04-2025, 11:11:05", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "Iterations": 2, + "Values": [ + { + "body": "string body" + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Iterations": 2, + "Values": [ + { + "body": "string body" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 7, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 8, + "mapRunStartedEventDetails": { + "mapRunArn": "map_run_arn" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 11, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 17, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 18, + "mapRunStartedEventDetails": { + "mapRunArn": "map_run_arn" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 19, + "previousEventId": 18, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 21, + "previousEventId": 18, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 22, + "previousEventId": 21, + "stateEnteredEventDetails": { + "input": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 23, + "previousEventId": 22, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 24, + "previousEventId": 23, + "stateEnteredEventDetails": { + "input": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "Terminate" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 25, + "previousEventId": 24, + "stateExitedEventDetails": { + "name": "Terminate", + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Values": [ + { + "body": "string body" + } + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 26, + "previousEventId": 25, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_lambda": { + "recorded-date": "28-04-2025, 12:36:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionNameBranch1": "function_name_branch1", + "FunctionNameBranch2": "function_name_branch2", + "Payload": [ + "string-literal" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionNameBranch1": "function_name_branch1", + "FunctionNameBranch2": "function_name_branch2", + "Payload": [ + "string-literal" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionNameBranch1": "function_name_branch1", + "FunctionNameBranch2": "function_name_branch2", + "Payload": [ + "string-literal" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionNameBranch1": "function_name_branch1", + "FunctionNameBranch2": "function_name_branch2", + "Payload": [ + "string-literal" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Branch2" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch1", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [ + "string-literal" + ], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "18" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "18", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch2", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "input-event-['string-literal']", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "32" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "32", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "function_name_branch1", + "Payload": [ + "string-literal" + ] + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "function_name_branch2", + "Payload": [ + "string-literal" + ] + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "input-event-['string-literal']", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "32" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "32", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [ + "string-literal" + ], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "18" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "18", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 15, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "ParallelState", + "output": "[{\"ExecutedVersion\":\"$LATEST\",\"Payload\":[\"string-literal\"],\"SdkHttpMetadata\":{\"AllHttpHeaders\":{\"X-Amz-Executed-Version\":[\"$LATEST\"],\"x-amzn-Remapped-Content-Length\":[\"0\"],\"Connection\":[\"keep-alive\"],\"x-amzn-RequestId\":[\"c455c86f-6cb5-4d5d-8a71-4f0a98b33059\"],\"Content-Length\":[\"18\"],\"Date\":[\"Mon, 28 Apr 2025 12:36:05 GMT\"],\"X-Amzn-Trace-Id\":[\"Root=1-680f7635-0182b6d14f9778376d202e25;Parent=7880c132e0e2009b;Sampled=0;Lineage=1:816810b0:0\"],\"Content-Type\":[\"application/json\"]},\"HttpHeaders\":{\"Connection\":\"keep-alive\",\"Content-Length\":\"18\",\"Content-Type\":\"application/json\",\"Date\":\"Mon, 28 Apr 2025 12:36:05 GMT\",\"X-Amz-Executed-Version\":\"$LATEST\",\"x-amzn-Remapped-Content-Length\":\"0\",\"x-amzn-RequestId\":\"c455c86f-6cb5-4d5d-8a71-4f0a98b33059\",\"X-Amzn-Trace-Id\":\"Root=1-680f7635-0182b6d14f9778376d202e25;Parent=7880c132e0e2009b;Sampled=0;Lineage=1:816810b0:0\"},\"HttpStatusCode\":200},\"SdkResponseMetadata\":{\"RequestId\":\"c455c86f-6cb5-4d5d-8a71-4f0a98b33059\"},\"StatusCode\":200},{\"ExecutedVersion\":\"$LATEST\",\"Payload\":\"input-event-['string-literal']\",\"SdkHttpMetadata\":{\"AllHttpHeaders\":{\"X-Amz-Executed-Version\":[\"$LATEST\"],\"x-amzn-Remapped-Content-Length\":[\"0\"],\"Connection\":[\"keep-alive\"],\"x-amzn-RequestId\":[\"bee07bb2-41e2-4c9f-9f6f-1a70696cc112\"],\"Content-Length\":[\"32\"],\"Date\":[\"Mon, 28 Apr 2025 12:36:05 GMT\"],\"X-Amzn-Trace-Id\":[\"Root=1-680f7635-5bd01853792e73951459090c;Parent=56aa9955dca169e9;Sampled=0;Lineage=1:786b2a01:0\"],\"Content-Type\":[\"application/json\"]},\"HttpHeaders\":{\"Connection\":\"keep-alive\",\"Content-Length\":\"32\",\"Content-Type\":\"application/json\",\"Date\":\"Mon, 28 Apr 2025 12:36:05 GMT\",\"X-Amz-Executed-Version\":\"$LATEST\",\"x-amzn-Remapped-Content-Length\":\"0\",\"x-amzn-RequestId\":\"bee07bb2-41e2-4c9f-9f6f-1a70696cc112\",\"X-Amzn-Trace-Id\":\"Root=1-680f7635-5bd01853792e73951459090c;Parent=56aa9955dca169e9;Sampled=0;Lineage=1:786b2a01:0\"},\"HttpStatusCode\":200},\"SdkResponseMetadata\":{\"RequestId\":\"bee07bb2-41e2-4c9f-9f6f-1a70696cc112\"},\"StatusCode\":200}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"ExecutedVersion\":\"$LATEST\",\"Payload\":[\"string-literal\"],\"SdkHttpMetadata\":{\"AllHttpHeaders\":{\"X-Amz-Executed-Version\":[\"$LATEST\"],\"x-amzn-Remapped-Content-Length\":[\"0\"],\"Connection\":[\"keep-alive\"],\"x-amzn-RequestId\":[\"c455c86f-6cb5-4d5d-8a71-4f0a98b33059\"],\"Content-Length\":[\"18\"],\"Date\":[\"Mon, 28 Apr 2025 12:36:05 GMT\"],\"X-Amzn-Trace-Id\":[\"Root=1-680f7635-0182b6d14f9778376d202e25;Parent=7880c132e0e2009b;Sampled=0;Lineage=1:816810b0:0\"],\"Content-Type\":[\"application/json\"]},\"HttpHeaders\":{\"Connection\":\"keep-alive\",\"Content-Length\":\"18\",\"Content-Type\":\"application/json\",\"Date\":\"Mon, 28 Apr 2025 12:36:05 GMT\",\"X-Amz-Executed-Version\":\"$LATEST\",\"x-amzn-Remapped-Content-Length\":\"0\",\"x-amzn-RequestId\":\"c455c86f-6cb5-4d5d-8a71-4f0a98b33059\",\"X-Amzn-Trace-Id\":\"Root=1-680f7635-0182b6d14f9778376d202e25;Parent=7880c132e0e2009b;Sampled=0;Lineage=1:816810b0:0\"},\"HttpStatusCode\":200},\"SdkResponseMetadata\":{\"RequestId\":\"c455c86f-6cb5-4d5d-8a71-4f0a98b33059\"},\"StatusCode\":200},{\"ExecutedVersion\":\"$LATEST\",\"Payload\":\"input-event-['string-literal']\",\"SdkHttpMetadata\":{\"AllHttpHeaders\":{\"X-Amz-Executed-Version\":[\"$LATEST\"],\"x-amzn-Remapped-Content-Length\":[\"0\"],\"Connection\":[\"keep-alive\"],\"x-amzn-RequestId\":[\"bee07bb2-41e2-4c9f-9f6f-1a70696cc112\"],\"Content-Length\":[\"32\"],\"Date\":[\"Mon, 28 Apr 2025 12:36:05 GMT\"],\"X-Amzn-Trace-Id\":[\"Root=1-680f7635-5bd01853792e73951459090c;Parent=56aa9955dca169e9;Sampled=0;Lineage=1:786b2a01:0\"],\"Content-Type\":[\"application/json\"]},\"HttpHeaders\":{\"Connection\":\"keep-alive\",\"Content-Length\":\"32\",\"Content-Type\":\"application/json\",\"Date\":\"Mon, 28 Apr 2025 12:36:05 GMT\",\"X-Amz-Executed-Version\":\"$LATEST\",\"x-amzn-Remapped-Content-Length\":\"0\",\"x-amzn-RequestId\":\"bee07bb2-41e2-4c9f-9f6f-1a70696cc112\",\"X-Amzn-Trace-Id\":\"Root=1-680f7635-5bd01853792e73951459090c;Parent=56aa9955dca169e9;Sampled=0;Lineage=1:786b2a01:0\"},\"HttpStatusCode\":200},\"SdkResponseMetadata\":{\"RequestId\":\"bee07bb2-41e2-4c9f-9f6f-1a70696cc112\"},\"StatusCode\":200}]", + "outputDetails": { + "truncated": false + } + }, + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke_sync_execution": { + "recorded-date": "03-06-2025, 18:47:04", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_sync_response": { + "billingDetails": { + "billedDurationInMilliseconds": 300, + "billedMemoryUsedInMB": 64 + }, + "executionArn": "arn::states::111111111111:express:::", + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "body": "string body" + } + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": { + "body": "string body" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "Content-Length": [ + "23" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "23", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "x-amzn-RequestId", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.validation.json b/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.validation.json new file mode 100644 index 0000000000000..5836f11a2ed34 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.validation.json @@ -0,0 +1,29 @@ +{ + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_dynamodb_put_get_item": { + "last_validated_date": "2025-04-23T15:32:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_events_put_events": { + "last_validated_date": "2025-04-23T14:28:24+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke": { + "last_validated_date": "2025-04-22T10:30:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke": { + "last_validated_date": "2025-04-14T18:51:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke_sync_execution": { + "last_validated_date": "2025-06-03T18:47:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_map_state_lambda": { + "last_validated_date": "2025-04-24T11:11:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_lambda": { + "last_validated_date": "2025-04-28T12:36:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_sns_publish_base": { + "last_validated_date": "2025-04-23T13:52:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/mocking/test_base_scenarios.py::TestBaseScenarios::test_sqs_send_message": { + "last_validated_date": "2025-04-22T19:39:14+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/mocking/test_mock_config_file.py b/tests/aws/services/stepfunctions/v2/mocking/test_mock_config_file.py new file mode 100644 index 0000000000000..931d66512936e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/mocking/test_mock_config_file.py @@ -0,0 +1,37 @@ +from localstack import config +from localstack.services.stepfunctions.mocking.mock_config import ( + MockTestCase, + load_mock_test_case_for, +) +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.mocked_service_integrations.mocked_service_integrations import ( + MockedServiceIntegrationsLoader, +) + + +class TestMockConfigFile: + @markers.aws.only_localstack + def test_is_mock_config_flag_detected_unset(self, mock_config_file): + mock_test_case = load_mock_test_case_for( + state_machine_name="state_machine_name", test_case_name="test_case_name" + ) + assert mock_test_case is None + + @markers.aws.only_localstack + def test_is_mock_config_flag_detected_set(self, mock_config_file, monkeypatch): + lambda_200_string_body = MockedServiceIntegrationsLoader.load( + MockedServiceIntegrationsLoader.MOCKED_RESPONSE_LAMBDA_200_STRING_BODY + ) + # TODO: add typing for MockConfigFile.json components + mock_config = { + "StateMachines": { + "S0": {"TestCases": {"BaseTestCase": {"LambdaState": "lambda_200_string_body"}}} + }, + "MockedResponses": {"lambda_200_string_body": lambda_200_string_body}, + } + mock_config_file_path = mock_config_file(mock_config) + monkeypatch.setattr(config, "SFN_MOCK_CONFIG", mock_config_file_path) + mock_test_case: MockTestCase = load_mock_test_case_for( + state_machine_name="S0", test_case_name="BaseTestCase" + ) + assert mock_test_case is not None diff --git a/tests/aws/services/stepfunctions/v2/outputdecl/__init__.py b/tests/aws/services/stepfunctions/v2/outputdecl/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/outputdecl/test_output.py b/tests/aws/services/stepfunctions/v2/outputdecl/test_output.py new file mode 100644 index 0000000000000..6d477bab604f9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/outputdecl/test_output.py @@ -0,0 +1,237 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.outputdecl.output_templates import OutputTemplates +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as SerT, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..RedriveCount", + "$..SdkResponseMetadata", + ] +) +class TestArgumentsBase: + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + OutputTemplates.BASE_EMPTY, + OutputTemplates.BASE_LITERALS, + OutputTemplates.BASE_EXPR, + OutputTemplates.BASE_DIRECT_EXPR, + ], + ids=[ + "BASE_EMPTY", + "BASE_LITERALS", + "BASE_EXPR", + "BASE_DIRECT_EXPR", + ], + ) + def test_base_cases( + self, + sfn_snapshot, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + template_path, + ): + template = OutputTemplates.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({"input_value": "string literal", "input_values": [1, 2, 3]}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + OutputTemplates.BASE_LAMBDA, + ], + ids=[ + "BASE_LAMBDA", + ], + ) + def test_base_lambda( + self, + sfn_snapshot, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + template_path, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=SerT.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + template = OutputTemplates.load_sfn_template(template_path) + template["States"]["State0"]["Resource"] = function_arn + definition = json.dumps(template) + exec_input = json.dumps({"input_value": "string literal", "input_values": [1, 2, 3]}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + OutputTemplates.BASE_TASK_LAMBDA, + ], + ids=[ + "BASE_TASK_LAMBDA", + ], + ) + def test_base_task_lambda( + self, + sfn_snapshot, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + template_path, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=SerT.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + + template = OutputTemplates.load_sfn_template(template_path) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "FunctionName": function_name, + "Payload": {"input_value": "string literal", "input_values": [1, 2, 3]}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "output_value", + [ + None, + 0, + 0.1, + True, + "string literal", + "{% $states.input %}", + [], + [ + None, + 0, + 0.1, + True, + "string", + [], + "$nosuchvar", + "$.no.such.path", + "{% $states.input %}", + {"key": "{% true %}"}, + ], + ], + ids=[ + "NULL", + "INT", + "FLOAT", + "BOOL", + "STR_LIT", + "JSONATA_EXPR", + "LIST_EMPY", + "LIST_RICH", + ], + ) + def test_base_output_any_non_dict( + self, + sfn_snapshot, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + output_value, + ): + template = OutputTemplates.load_sfn_template(OutputTemplates.BASE_OUTPUT_ANY) + template["States"]["State0"]["Output"] = output_value + definition = json.dumps(template) + + exec_input = json.dumps({"input_value": "stringliteral"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "input_value", + [ + {"condition": True}, + {"condition": False}, + ], + ids=[ + "CONDITION_TRUE", + "CONDITION_FALSE", + ], + ) + def test_output_in_choice( + self, + sfn_snapshot, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + input_value, + ): + template = OutputTemplates.load_sfn_template(OutputTemplates.CHOICE_CONDITION_JSONATA) + definition = json.dumps(template) + exec_input = json.dumps(input_value) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/outputdecl/test_output.snapshot.json b/tests/aws/services/stepfunctions/v2/outputdecl/test_output.snapshot.json new file mode 100644 index 0000000000000..3e3b4ce45dc52 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/outputdecl/test_output.snapshot.json @@ -0,0 +1,1838 @@ +{ + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_EMPTY]": { + "recorded-date": "04-11-2024, 13:15:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_LITERALS]": { + "recorded-date": "04-11-2024, 13:16:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_jp_input": "$", + "constant_jp_input.$": "$", + "constant_jp_input_path": "$.input_value", + "constant_jp_context": "$$", + "constant_if": "States.Format('Format:{}', 101)", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "constant_null": null, + "constant_int": 0, + "constant_float": 0.1, + "constant_bool": true, + "constant_str": "constant string", + "constant_not_jsonata": " {% states.input %} ", + "constant_varpath_states": "$states.input", + "constant_varpath": "$no.such.var.path", + "constant_jp_input": "$", + "constant_jp_input.$": "$", + "constant_jp_input_path": "$.input_value", + "constant_jp_context": "$$", + "constant_if": "States.Format('Format:{}', 101)", + "constant_lst_empty": [], + "constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "constant_obj_empty": {}, + "constant_obj": { + "in_obj_constant_null": null, + "in_obj_constant_int": 0, + "in_obj_constant_float": 0.1, + "in_obj_constant_bool": true, + "in_obj_constant_str": "constant string", + "in_obj_constant_not_jsonata": " {% states.input %} ", + "in_obj_constant_lst_empty": [], + "in_obj_constant_lst": [ + null, + 0, + 0.1, + true, + [], + { + "constant": 0 + }, + " {% states.input %} ", + "$states.input", + "$no.such.var.path" + ], + "in_obj_constant_obj_empty": {}, + "in_obj_constant_obj": { + "constant": 0 + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_EXPR]": { + "recorded-date": "04-11-2024, 13:16:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Init" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "var_constant_1": "1", + "var_input_value": "\"string literal\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Init", + "output": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "ja_states_context": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "StartTime": "date", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "State0", + "EnteredTime": "date" + } + }, + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ja_states_context": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "StartTime": "date", + "Name": "", + "RoleArn": "snf_role_arn", + "RedriveCount": 0 + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "State0", + "EnteredTime": "date" + } + }, + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_DIRECT_EXPR]": { + "recorded-date": "04-11-2024, 13:16:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Init" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "var_constant_1": "1" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Init", + "output": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "State0", + "output": "7", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "7", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_lambda[BASE_LAMBDA]": { + "recorded-date": "04-11-2024, 14:01:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Init" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "var_constant_1": "1", + "var_input_value": "\"string literal\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Init", + "output": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "lambdaFunctionScheduledEventDetails": { + "input": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_name" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 7, + "lambdaFunctionSucceededEventDetails": { + "output": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "ja_var_access": "string literal", + "ja_expr": 7, + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_states_result": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "ja_states_result_access": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ja_var_access": "string literal", + "ja_expr": 7, + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_states_result": { + "ja_states_input": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "ja_var_access": "string literal", + "ja_expr": 7 + }, + "ja_states_result_access": 7 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_task_lambda[BASE_TASK_LAMBDA]": { + "recorded-date": "04-11-2024, 14:15:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "lambda_function_name", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "lambda_function_name", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "60" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "60", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "ja_states_input": { + "FunctionName": "lambda_function_name", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + } + }, + "ja_states_result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "60" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "60", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ja_states_input": { + "FunctionName": "lambda_function_name", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + } + }, + "ja_states_result": { + "ExecutedVersion": "$LATEST", + "Payload": { + "input_value": "string literal", + "input_values": [ + 1, + 2, + 3 + ] + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "60" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "60", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[NULL]": { + "recorded-date": "20-11-2024, 18:24:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[INT]": { + "recorded-date": "20-11-2024, 18:24:15", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[FLOAT]": { + "recorded-date": "20-11-2024, 18:24:31", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": "0.1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "0.1", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[BOOL]": { + "recorded-date": "20-11-2024, 18:24:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[STR_LIT]": { + "recorded-date": "20-11-2024, 18:25:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": "\"string literal\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"string literal\"", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[JSONATA_EXPR]": { + "recorded-date": "20-11-2024, 18:25:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": { + "input_value": "stringliteral" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "input_value": "stringliteral" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[LIST_EMPY]": { + "recorded-date": "20-11-2024, 18:25:31", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[LIST_RICH]": { + "recorded-date": "20-11-2024, 18:25:47", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_value": "stringliteral" + }, + "inputDetails": { + "truncated": false + }, + "name": "State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State0", + "output": "[null,0,0.1,true,\"string\",[],\"$nosuchvar\",\"$.no.such.path\",{\"input_value\":\"stringliteral\"},{\"key\":true}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[null,0,0.1,true,\"string\",[],\"$nosuchvar\",\"$.no.such.path\",{\"input_value\":\"stringliteral\"},{\"key\":true}]", + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_output_in_choice[CONDITION_TRUE]": { + "recorded-date": "27-12-2024, 14:50:09", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "condition": true + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "condition": true + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceState", + "output": "\"Condition Output block\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "\"Condition Output block\"", + "inputDetails": { + "truncated": false + }, + "name": "ConditionTrue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "ConditionTrue", + "output": "\"Condition Output block\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"Condition Output block\"", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_output_in_choice[CONDITION_FALSE]": { + "recorded-date": "27-12-2024, 14:50:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "condition": false + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "condition": false + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceState", + "output": "\"Default Output block\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "\"Default Output block\"", + "inputDetails": { + "truncated": false + }, + "name": "DefaultState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "Condition is false" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/outputdecl/test_output.validation.json b/tests/aws/services/stepfunctions/v2/outputdecl/test_output.validation.json new file mode 100644 index 0000000000000..42c2f4701dbb8 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/outputdecl/test_output.validation.json @@ -0,0 +1,50 @@ +{ + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_DIRECT_EXPR]": { + "last_validated_date": "2024-11-04T13:16:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_EMPTY]": { + "last_validated_date": "2024-11-04T13:15:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_EXPR]": { + "last_validated_date": "2024-11-04T13:16:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_cases[BASE_LITERALS]": { + "last_validated_date": "2024-11-04T13:16:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_lambda[BASE_LAMBDA]": { + "last_validated_date": "2024-11-04T14:00:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[BOOL]": { + "last_validated_date": "2024-11-20T18:24:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[FLOAT]": { + "last_validated_date": "2024-11-20T18:24:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[INT]": { + "last_validated_date": "2024-11-20T18:24:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[JSONATA_EXPR]": { + "last_validated_date": "2024-11-20T18:25:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[LIST_EMPY]": { + "last_validated_date": "2024-11-20T18:25:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[LIST_RICH]": { + "last_validated_date": "2024-11-20T18:25:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[NULL]": { + "last_validated_date": "2024-11-20T18:23:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_output_any_non_dict[STR_LIT]": { + "last_validated_date": "2024-11-20T18:24:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_base_task_lambda[BASE_TASK_LAMBDA]": { + "last_validated_date": "2024-11-04T14:15:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_output_in_choice[CONDITION_FALSE]": { + "last_validated_date": "2024-12-27T14:50:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/outputdecl/test_output.py::TestArgumentsBase::test_output_in_choice[CONDITION_TRUE]": { + "last_validated_date": "2024-12-27T14:50:08+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/query_language/__init__.py b/tests/aws/services/stepfunctions/v2/query_language/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py b/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py new file mode 100644 index 0000000000000..dbc0aeac833f9 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py @@ -0,0 +1,99 @@ +import json + +import pytest + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_record_execution +from tests.aws.services.stepfunctions.templates.querylanguage.query_language_templates import ( + QueryLanguageTemplate as QLT, +) + +QUERY_LANGUAGE_JSON_PATH_TYPE = "JSONPath" +QUERY_LANGUAGE_JSONATA_TYPE = "JSONata" + + +class TestBaseQueryLanguage: + @pytest.mark.parametrize( + "template", + [ + QLT.load_sfn_template(QLT.BASE_PASS_JSONPATH), + QLT.load_sfn_template(QLT.BASE_PASS_JSONATA), + ], + ids=["JSON_PATH", "JSONATA"], + ) + @markers.aws.validated + def test_base_query_language_field( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template", + [ + QLT.load_sfn_template(QLT.BASE_PASS_JSONATA_OVERRIDE), + QLT.load_sfn_template(QLT.BASE_PASS_JSONATA_OVERRIDE_DEFAULT), + ], + ids=["JSONATA_OVERRIDE", "JSONATA_OVERRIDE_DEFAULT"], + ) + @markers.aws.validated + def test_query_language_field_override( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.skip(reason="Skipped until we have more context on more handling error cases") + @markers.aws.unknown + def test_jsonata_query_language_field_downgrade_exception( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = QLT.load_sfn_template(QLT.BASE_PASS_JSONATA) + + # Cannot set a state-level Query Language to 'JSONPath' when the top-level field is 'JSONata' + template["States"]["StartState"]["QueryLanguage"] = QUERY_LANGUAGE_JSON_PATH_TYPE + definition = json.dumps(template) + exec_input = json.dumps({}) + + try: + create_and_record_execution( + stepfunctions_client=aws_client.stepfunctions, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + except Exception as e: + # Will this raise an exception since downgrades to JSONPath are not supported? + sfn_snapshot.snapshot("incompatible_state_level_query_language_field", e) diff --git a/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.snapshot.json b/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.snapshot.json new file mode 100644 index 0000000000000..8e4dbe3ff651d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.snapshot.json @@ -0,0 +1,258 @@ +{ + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_base_query_language_field[JSON_PATH]": { + "recorded-date": "04-11-2024, 11:11:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_base_query_language_field[JSONATA]": { + "recorded-date": "04-11-2024, 11:11:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_query_language_field_override[JSONATA_OVERRIDE]": { + "recorded-date": "04-11-2024, 11:11:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_query_language_field_override[JSONATA_OVERRIDE_DEFAULT]": { + "recorded-date": "04-11-2024, 11:11:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.validation.json b/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.validation.json new file mode 100644 index 0000000000000..ae56677e44572 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_base_query_language_field[JSONATA]": { + "last_validated_date": "2024-11-04T11:11:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_base_query_language_field[JSON_PATH]": { + "last_validated_date": "2024-11-04T11:11:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_query_language_field_override[JSONATA_OVERRIDE]": { + "last_validated_date": "2024-11-04T11:11:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_base_query_language.py::TestBaseQueryLanguage::test_query_language_field_override[JSONATA_OVERRIDE_DEFAULT]": { + "last_validated_date": "2024-11-04T11:11:42+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py b/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py new file mode 100644 index 0000000000000..8c3e69c0e7884 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py @@ -0,0 +1,163 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_and_record_execution +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.assign.assign_templates import AssignTemplate +from tests.aws.services.stepfunctions.templates.querylanguage.query_language_templates import ( + QueryLanguageTemplate as QLT, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestMixedQueryLanguageFlow: + @pytest.mark.parametrize( + "template", + [ + QLT.load_sfn_template(QLT.JSONATA_ASSIGN_JSONPATH_REF), + QLT.load_sfn_template(QLT.JSONPATH_ASSIGN_JSONATA_REF), + ], + ids=["JSONATA_ASSIGN_JSONPATH_REF", "JSONPATH_ASSIGN_JSONATA_REF"], + ) + @markers.aws.validated + def test_variable_sampling( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template", + [ + QLT.load_sfn_template(QLT.JSONATA_OUTPUT_TO_JSONPATH), + QLT.load_sfn_template(QLT.JSONPATH_OUTPUT_TO_JSONATA), + ], + ids=["JSONATA_OUTPUT_TO_JSONPATH", "JSONPATH_OUTPUT_TO_JSONATA"], + ) + @markers.aws.validated + def test_output_to_state( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + definition = json.dumps(template) + exec_input = json.dumps({"input_data": "test"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @markers.aws.validated + def test_task_dataflow_to_state( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"fn-data-flow-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = AssignTemplate.load_sfn_template(QLT.JSONPATH_TO_JSONATA_DATAFLOW) + definition = json.dumps(template) + exec_input = json.dumps({"functionName": function_arn}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template", + [ + QLT.load_sfn_template(QLT.TASK_LAMBDA_LEGACY_RESOURCE_JSONATA_TO_JSONPATH), + QLT.load_sfn_template(QLT.TASK_LAMBDA_SDK_RESOURCE_JSONATA_TO_JSONPATH), + QLT.load_sfn_template(QLT.TASK_LAMBDA_LEGACY_RESOURCE_JSONPATH_TO_JSONATA), + QLT.load_sfn_template(QLT.TASK_LAMBDA_SDK_RESOURCE_JSONPATH_TO_JSONATA), + ], + ids=[ + "TASK_LAMBDA_LEGACY_RESOURCE_JSONATA_TO_JSONPATH", + "TASK_LAMBDA_SDK_RESOURCE_JSONATA_TO_JSONPATH", + "TASK_LAMBDA_LEGACY_RESOURCE_JSONPATH_TO_JSONATA", + "TASK_LAMBDA_SDK_RESOURCE_JSONPATH_TO_JSONATA", + ], + ) + @markers.aws.validated + def test_lambda_task_resource_data_flow( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + template, + ): + function_name = f"fn-data-flow-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + definition = json.dumps(template) + definition = definition.replace( + QLT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.snapshot.json b/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.snapshot.json new file mode 100644 index 0000000000000..be1811bc47a63 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.snapshot.json @@ -0,0 +1,1830 @@ +{ + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_variable_sampling[JSONATA_ASSIGN_JSONPATH_REF]": { + "recorded-date": "07-11-2024, 17:38:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JSONataState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "theAnswerVar": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JSONataState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JSONPathState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "assignedVariables": { + "oldAnswerVar": "42", + "theAnswerVar": "18" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JSONPathState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_variable_sampling[JSONPATH_ASSIGN_JSONATA_REF]": { + "recorded-date": "07-11-2024, 17:38:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JSONPathState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "theAnswer": "42" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JSONPathState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JSONataState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "assignedVariables": { + "oldAnswer": "42", + "theAnswer": "18" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JSONataState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_output_to_state[JSONATA_OUTPUT_TO_JSONPATH]": { + "recorded-date": "07-11-2024, 17:38:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_data": "test" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_data": "test" + }, + "inputDetails": { + "truncated": false + }, + "name": "JSONataState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "JSONataState", + "output": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "JSONPathState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "JSONPathState", + "output": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_output_to_state[JSONPATH_OUTPUT_TO_JSONATA]": { + "recorded-date": "07-11-2024, 17:39:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "input_data": "test" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "input_data": "test" + }, + "inputDetails": { + "truncated": false + }, + "name": "JSONataState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "JSONataState", + "output": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "JSONPathState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "JSONPathState", + "output": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "foo": "foobar", + "bar": { + "input_data": "test" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_task_dataflow_to_state": { + "recorded-date": "07-11-2024, 21:40:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "functionName": "arn::lambda::111111111111:function:" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "functionName": "arn::lambda::111111111111:function:" + }, + "inputDetails": { + "truncated": false + }, + "name": "StateJsonPath" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "answer": "42", + "inputData": { + "riddle": "What is the answer to life, the universe, and everything?" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "StateJsonPath", + "output": { + "functionName": "arn::lambda::111111111111:function:", + "enigma": { + "mystery": { + "riddle": "What is the answer to life, the universe, and everything?" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "functionName": "arn::lambda::111111111111:function:", + "enigma": { + "mystery": { + "riddle": "What is the answer to life, the universe, and everything?" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "StateJsonata" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "theQuestion": "What is the answer to life, the universe, and everything?", + "theAnswer": 42 + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 7, + "previousEventId": 6, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "theQuestion": "What is the answer to life, the universe, and everything?", + "theAnswer": 42 + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "93" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "93", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "assignedVariables": { + "answer": "\"\"", + "message": { + "ExecutedVersion": "$LATEST", + "Payload": { + "theQuestion": "What is the answer to life, the universe, and everything?", + "theAnswer": 42 + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "93" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "93", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "StateJsonata", + "output": { + "result": { + "theQuestion": "What is the answer to life, the universe, and everything?", + "theAnswer": 42 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "theQuestion": "What is the answer to life, the universe, and everything?", + "theAnswer": 42 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_LEGACY_RESOURCE_JSONATA_TO_JSONPATH]": { + "recorded-date": "08-11-2024, 12:18:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JsonataState" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Payload": { + "foo": "foo-1" + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": { + "Payload": { + "foo": "foo-1" + } + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "assignedVariables": { + "resultsVar": { + "Payload": { + "foo": "foo-1" + } + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JsonataState", + "output": { + "results": { + "Payload": { + "foo": "foo-1" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "results": { + "Payload": { + "foo": "foo-1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "JsonPathState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "JsonPathState", + "output": { + "results": { + "Payload": { + "foo": "foo-1" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "results": { + "Payload": { + "foo": "foo-1" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_SDK_RESOURCE_JSONATA_TO_JSONPATH]": { + "recorded-date": "08-11-2024, 12:18:54", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JsonataState" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "foo": "foo-1" + }, + "FunctionName": "arn::lambda::111111111111:function:" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "assignedVariables": { + "resultsVar": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JsonataState", + "output": { + "results": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "results": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "JsonPathState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "JsonPathState", + "output": { + "results": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "results": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_LEGACY_RESOURCE_JSONPATH_TO_JSONATA]": { + "recorded-date": "08-11-2024, 15:10:55", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JsonPathState" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Payload": { + "foo": "foo-1" + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": { + "Payload": { + "foo": "foo-1" + } + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "assignedVariables": { + "resultsVar": { + "Payload": { + "foo": "foo-1" + } + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JsonPathState", + "output": { + "Payload": { + "foo": "foo-1" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Payload": { + "foo": "foo-1" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "JsonataState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "JsonataState", + "output": { + "Payload": { + "foo": "foo-1" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Payload": { + "foo": "foo-1" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_SDK_RESOURCE_JSONPATH_TO_JSONATA]": { + "recorded-date": "08-11-2024, 15:09:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "JsonPathState" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "foo": "foo-1" + }, + "FunctionName": "arn::lambda::111111111111:function:" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "assignedVariables": { + "resultsVar": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "JsonPathState", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "JsonataState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "JsonataState", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": { + "foo": "foo-1" + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "16" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "16", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.validation.json b/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.validation.json new file mode 100644 index 0000000000000..49b9d7017a538 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.validation.json @@ -0,0 +1,29 @@ +{ + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_LEGACY_RESOURCE_JSONATA_TO_JSONPATH]": { + "last_validated_date": "2024-11-08T12:28:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_LEGACY_RESOURCE_JSONPATH_TO_JSONATA]": { + "last_validated_date": "2024-11-08T15:10:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_SDK_RESOURCE_JSONATA_TO_JSONPATH]": { + "last_validated_date": "2024-11-08T12:28:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_lambda_task_resource_data_flow[TASK_LAMBDA_SDK_RESOURCE_JSONPATH_TO_JSONATA]": { + "last_validated_date": "2024-11-08T15:09:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_output_to_state[JSONATA_OUTPUT_TO_JSONPATH]": { + "last_validated_date": "2024-11-07T17:38:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_output_to_state[JSONPATH_OUTPUT_TO_JSONATA]": { + "last_validated_date": "2024-11-07T17:39:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_task_dataflow_to_state": { + "last_validated_date": "2024-11-07T21:40:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_variable_sampling[JSONATA_ASSIGN_JSONPATH_REF]": { + "last_validated_date": "2024-11-07T17:38:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/query_language/test_mixed_query_language.py::TestMixedQueryLanguageFlow::test_variable_sampling[JSONPATH_ASSIGN_JSONATA_REF]": { + "last_validated_date": "2024-11-07T17:38:29+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/scenarios/__init__.py b/tests/aws/services/stepfunctions/v2/scenarios/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/scenarios/templates/path-based-on-data.yaml b/tests/aws/services/stepfunctions/v2/scenarios/templates/path-based-on-data.yaml new file mode 100644 index 0000000000000..8ca27a18e7e0d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/templates/path-based-on-data.yaml @@ -0,0 +1,108 @@ +Resources: + statemachineRole52044F93: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: + Fn::FindInMap: + - ServiceprincipalMap + - Ref: AWS::Region + - states + Version: "2012-10-17" + statemachineC5962F3E: + Type: AWS::StepFunctions::StateMachine + Properties: + RoleArn: + Fn::GetAtt: + - statemachineRole52044F93 + - Arn + DefinitionString: '{"StartAt":"Choice State","States":{"Choice State":{"Type":"Choice","Choices":[{"Not":{"Variable":"$.type","StringEquals":"Private"},"Next":"NEXT_STATE_ONE"},{"Variable":"$.value","NumericEquals":0,"Next":"NEXT_STATE_TWO"},{"And":[{"Variable":"$.value","NumericGreaterThanEquals":20},{"Variable":"$.value","NumericLessThan":30}],"Next":"NEXT_STATE_TWO"}],"Default":"DEFAULT_STATE"},"DEFAULT_STATE":{"Type":"Pass","End":true},"NEXT_STATE_ONE":{"Type":"Pass","End":true},"NEXT_STATE_TWO":{"Type":"Pass","End":true}}}' + DependsOn: + - statemachineRole52044F93 +Outputs: + StateMachineArn: + Value: + Ref: statemachineC5962F3E + StateMachineName: + Value: + Fn::GetAtt: + - statemachineC5962F3E + - Name + RoleArn: + Value: + Fn::GetAtt: + - statemachineRole52044F93 + - Arn + RoleName: + Value: + Ref: statemachineRole52044F93 +Mappings: + ServiceprincipalMap: + af-south-1: + states: states.af-south-1.amazonaws.com + ap-east-1: + states: states.ap-east-1.amazonaws.com + ap-northeast-1: + states: states.ap-northeast-1.amazonaws.com + ap-northeast-2: + states: states.ap-northeast-2.amazonaws.com + ap-northeast-3: + states: states.ap-northeast-3.amazonaws.com + ap-south-1: + states: states.ap-south-1.amazonaws.com + ap-south-2: + states: states.ap-south-2.amazonaws.com + ap-southeast-1: + states: states.ap-southeast-1.amazonaws.com + ap-southeast-2: + states: states.ap-southeast-2.amazonaws.com + ap-southeast-3: + states: states.ap-southeast-3.amazonaws.com + ca-central-1: + states: states.ca-central-1.amazonaws.com + cn-north-1: + states: states.cn-north-1.amazonaws.com + cn-northwest-1: + states: states.cn-northwest-1.amazonaws.com + eu-central-1: + states: states.eu-central-1.amazonaws.com + eu-north-1: + states: states.eu-north-1.amazonaws.com + eu-south-1: + states: states.eu-south-1.amazonaws.com + eu-south-2: + states: states.eu-south-2.amazonaws.com + eu-west-1: + states: states.eu-west-1.amazonaws.com + eu-west-2: + states: states.eu-west-2.amazonaws.com + eu-west-3: + states: states.eu-west-3.amazonaws.com + me-central-1: + states: states.me-central-1.amazonaws.com + me-south-1: + states: states.me-south-1.amazonaws.com + sa-east-1: + states: states.sa-east-1.amazonaws.com + us-east-1: + states: states.us-east-1.amazonaws.com + us-east-2: + states: states.us-east-2.amazonaws.com + us-gov-east-1: + states: states.us-gov-east-1.amazonaws.com + us-gov-west-1: + states: states.us-gov-west-1.amazonaws.com + us-iso-east-1: + states: states.amazonaws.com + us-iso-west-1: + states: states.amazonaws.com + us-isob-east-1: + states: states.amazonaws.com + us-west-1: + states: states.us-west-1.amazonaws.com + us-west-2: + states: states.us-west-2.amazonaws.com diff --git a/tests/aws/services/stepfunctions/v2/scenarios/templates/step-functions-calling-api-gateway.yaml b/tests/aws/services/stepfunctions/v2/scenarios/templates/step-functions-calling-api-gateway.yaml new file mode 100644 index 0000000000000..a6397ab3c6f6c --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/templates/step-functions-calling-api-gateway.yaml @@ -0,0 +1,387 @@ +Resources: + waitFnServiceRole0216D1B5: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + waitFnF267ED7D: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + import json + + + def handler(event, context): + print(event) + + body = json.loads(event["body"]) + + if body["fail"]: + return { + "statusCode": 200, + "body": json.dumps({"errors": ["Errors.Failure"]}) + } + + return { + "statusCode": 200, + "body": json.dumps({"hello": "world"}) + } + Role: + Fn::GetAtt: + - waitFnServiceRole0216D1B5 + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - waitFnServiceRole0216D1B5 + waitFnEventInvokeConfig11670229: + Type: AWS::Lambda::EventInvokeConfig + Properties: + FunctionName: + Ref: waitFnF267ED7D + Qualifier: $LATEST + MaximumRetryAttempts: 0 + apiC8550315: + Type: AWS::ApiGateway::RestApi + Properties: + Name: api + apiCloudWatchRoleAC81D93E: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + apiAccount57E28B43: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: + Fn::GetAtt: + - apiCloudWatchRoleAC81D93E + - Arn + DependsOn: + - apiC8550315 + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + apiDeployment149F1294de73d8965f6482f8e9c53d63c8688161: + Type: AWS::ApiGateway::Deployment + Properties: + RestApiId: + Ref: apiC8550315 + Description: Automatically created by the RestApi construct + DependsOn: + - apiproxyANY7F13F09C + - apiproxy4EA44110 + - apiANYB3DF8C3C + apiDeploymentStagedev96712F43: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: + Ref: apiC8550315 + DeploymentId: + Ref: apiDeployment149F1294de73d8965f6482f8e9c53d63c8688161 + StageName: dev + DependsOn: + - apiAccount57E28B43 + apiproxy4EA44110: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: + Fn::GetAtt: + - apiC8550315 + - RootResourceId + PathPart: "{proxy+}" + RestApiId: + Ref: apiC8550315 + apiproxyANYApiPermissionStepFunctionsCallingApiGatewayapi087EEC13ANYproxyAAE92B1A: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - waitFnF267ED7D + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: apiC8550315 + - / + - Ref: apiDeploymentStagedev96712F43 + - /*/* + apiproxyANYApiPermissionTestStepFunctionsCallingApiGatewayapi087EEC13ANYproxy4CE90A2A: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - waitFnF267ED7D + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: apiC8550315 + - /test-invoke-stage/*/* + apiproxyANY7F13F09C: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: ANY + ResourceId: + Ref: apiproxy4EA44110 + RestApiId: + Ref: apiC8550315 + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - waitFnF267ED7D + - Arn + - /invocations + apiANYApiPermissionStepFunctionsCallingApiGatewayapi087EEC13ANYC9378D12: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - waitFnF267ED7D + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: apiC8550315 + - / + - Ref: apiDeploymentStagedev96712F43 + - /*/ + apiANYApiPermissionTestStepFunctionsCallingApiGatewayapi087EEC13ANY9D250CDE: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - waitFnF267ED7D + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: apiC8550315 + - /test-invoke-stage/*/ + apiANYB3DF8C3C: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: ANY + ResourceId: + Fn::GetAtt: + - apiC8550315 + - RootResourceId + RestApiId: + Ref: apiC8550315 + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - waitFnF267ED7D + - Arn + - /invocations + statemachineRole52044F93: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: + Fn::FindInMap: + - ServiceprincipalMap + - Ref: AWS::Region + - states + Version: "2012-10-17" + statemachineC5962F3E: + Type: AWS::StepFunctions::StateMachine + Properties: + RoleArn: + Fn::GetAtt: + - statemachineRole52044F93 + - Arn + DefinitionString: + Fn::Join: + - "" + - - '{"StartAt":"Add Pet to Store","States":{"Add Pet to Store":{"Next":"Pet was Added Successfully?","Type":"Task","Resource":"arn:' + - Ref: AWS::Partition + - :states:::apigateway:invoke","Parameters":{"ApiEndpoint":" + - Ref: apiC8550315 + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - '","Method":"POST","Stage":"dev","RequestBody.$":"$","AuthType":"NO_AUTH"}},"Pet was Added Successfully?":{"Type":"Choice","Choices":[{"Variable":"$.ResponseBody.errors","IsPresent":true,"Next":"Failure"}],"Default":"Success"},"Success":{"Type":"Succeed"},"Failure":{"Type":"Fail"}}}' + DependsOn: + - statemachineRole52044F93 +Outputs: + apiEndpoint9349E63C: + Value: + Fn::Join: + - "" + - - https:// + - Ref: apiC8550315 + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - / + - Ref: apiDeploymentStagedev96712F43 + - / + StateMachineArn: + Value: + Ref: statemachineC5962F3E + StateMachineName: + Value: + Fn::GetAtt: + - statemachineC5962F3E + - Name + RoleArn: + Value: + Fn::GetAtt: + - statemachineRole52044F93 + - Arn + RoleName: + Value: + Ref: statemachineRole52044F93 +Mappings: + ServiceprincipalMap: + af-south-1: + states: states.af-south-1.amazonaws.com + ap-east-1: + states: states.ap-east-1.amazonaws.com + ap-northeast-1: + states: states.ap-northeast-1.amazonaws.com + ap-northeast-2: + states: states.ap-northeast-2.amazonaws.com + ap-northeast-3: + states: states.ap-northeast-3.amazonaws.com + ap-south-1: + states: states.ap-south-1.amazonaws.com + ap-south-2: + states: states.ap-south-2.amazonaws.com + ap-southeast-1: + states: states.ap-southeast-1.amazonaws.com + ap-southeast-2: + states: states.ap-southeast-2.amazonaws.com + ap-southeast-3: + states: states.ap-southeast-3.amazonaws.com + ca-central-1: + states: states.ca-central-1.amazonaws.com + cn-north-1: + states: states.cn-north-1.amazonaws.com + cn-northwest-1: + states: states.cn-northwest-1.amazonaws.com + eu-central-1: + states: states.eu-central-1.amazonaws.com + eu-north-1: + states: states.eu-north-1.amazonaws.com + eu-south-1: + states: states.eu-south-1.amazonaws.com + eu-south-2: + states: states.eu-south-2.amazonaws.com + eu-west-1: + states: states.eu-west-1.amazonaws.com + eu-west-2: + states: states.eu-west-2.amazonaws.com + eu-west-3: + states: states.eu-west-3.amazonaws.com + me-central-1: + states: states.me-central-1.amazonaws.com + me-south-1: + states: states.me-south-1.amazonaws.com + sa-east-1: + states: states.sa-east-1.amazonaws.com + us-east-1: + states: states.us-east-1.amazonaws.com + us-east-2: + states: states.us-east-2.amazonaws.com + us-gov-east-1: + states: states.us-gov-east-1.amazonaws.com + us-gov-west-1: + states: states.us-gov-west-1.amazonaws.com + us-iso-east-1: + states: states.amazonaws.com + us-iso-west-1: + states: states.amazonaws.com + us-isob-east-1: + states: states.amazonaws.com + us-west-1: + states: states.us-west-1.amazonaws.com + us-west-2: + states: states.us-west-2.amazonaws.com diff --git a/tests/aws/services/stepfunctions/v2/scenarios/templates/wait-for-callback.yaml b/tests/aws/services/stepfunctions/v2/scenarios/templates/wait-for-callback.yaml new file mode 100644 index 0000000000000..7085e56a797e5 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/templates/wait-for-callback.yaml @@ -0,0 +1,238 @@ +Resources: + queue276F7297: + Type: AWS::SQS::Queue + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + waitFnServiceRole0216D1B5: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + waitFnServiceRoleDefaultPolicy76952F54: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:ReceiveMessage + - sqs:ChangeMessageVisibility + - sqs:GetQueueUrl + - sqs:DeleteMessage + - sqs:GetQueueAttributes + Effect: Allow + Resource: + Fn::GetAtt: + - queue276F7297 + - Arn + - Action: + - states:SendTaskSuccess + - states:SendTaskFailure + - states:SendTaskHeartbeat + Effect: Allow + Resource: + Ref: statemachineC5962F3E + Version: "2012-10-17" + PolicyName: waitFnServiceRoleDefaultPolicy76952F54 + Roles: + - Ref: waitFnServiceRole0216D1B5 + waitFnF267ED7D: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + import json + import os + + import boto3 + + + def create_client(svc_name: str): + if "AWS_ENDPOINT_URL" in os.environ: + return boto3.client( + svc_name, endpoint_url=os.environ["AWS_ENDPOINT_URL"] + ) + else: + return boto3.client(svc_name) + + + sfn_client = create_client("stepfunctions") + + + def handler(event, context): + print(event) + + records = event["Records"] + record = records[0] + data = json.loads(record["body"]) + + if data["ShouldFail"] == "yes": + sfn_client.send_task_failure(taskToken=data["TaskToken"], error="IntentionalFail", cause="Intentional failure") + else: + sfn_client.send_task_success(taskToken=data["TaskToken"], output=json.dumps({"message": "Successfully executed lambda"})) + Role: + Fn::GetAtt: + - waitFnServiceRole0216D1B5 + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - waitFnServiceRoleDefaultPolicy76952F54 + - waitFnServiceRole0216D1B5 + waitFnEventInvokeConfig11670229: + Type: AWS::Lambda::EventInvokeConfig + Properties: + FunctionName: + Ref: waitFnF267ED7D + Qualifier: $LATEST + MaximumRetryAttempts: 0 + waitFnSqsEventSourceWaitForCallbackqueue5A0C181CF1A9D0B1: + Type: AWS::Lambda::EventSourceMapping + Properties: + FunctionName: + Ref: waitFnF267ED7D + BatchSize: 1 + EventSourceArn: + Fn::GetAtt: + - queue276F7297 + - Arn + statemachineRole52044F93: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: + Fn::FindInMap: + - ServiceprincipalMap + - Ref: AWS::Region + - states + Version: "2012-10-17" + statemachineRoleDefaultPolicy9AE064E2: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: sqs:SendMessage + Effect: Allow + Resource: + Fn::GetAtt: + - queue276F7297 + - Arn + Version: "2012-10-17" + PolicyName: statemachineRoleDefaultPolicy9AE064E2 + Roles: + - Ref: statemachineRole52044F93 + statemachineC5962F3E: + Type: AWS::StepFunctions::StateMachine + Properties: + RoleArn: + Fn::GetAtt: + - statemachineRole52044F93 + - Arn + DefinitionString: + Fn::Join: + - "" + - - '{"StartAt":"Start Task And Wait For Callback","States":{"Start Task And Wait For Callback":{"Next":"Succeed State","Catch":[{"ErrorEquals":["States.ALL"],"Next":"Fail State"}],"Type":"Task","Resource":"arn:' + - Ref: AWS::Partition + - :states:::sqs:sendMessage.waitForTaskToken","Parameters":{"QueueUrl":" + - Ref: queue276F7297 + - '","MessageBody":{"MessageTitle":"Task started by Step Functions. Waiting for callback with task token.","ShouldFail.$":"$.shouldfail","TaskToken.$":"$$.Task.Token"}}},"Succeed State":{"Type":"Succeed"},"Fail State":{"Type":"Fail"}}}' + DependsOn: + - statemachineRoleDefaultPolicy9AE064E2 + - statemachineRole52044F93 +Outputs: + StateMachineArn: + Value: + Ref: statemachineC5962F3E + StateMachineName: + Value: + Fn::GetAtt: + - statemachineC5962F3E + - Name + RoleArn: + Value: + Fn::GetAtt: + - statemachineRole52044F93 + - Arn + RoleName: + Value: + Ref: statemachineRole52044F93 +Mappings: + ServiceprincipalMap: + af-south-1: + states: states.af-south-1.amazonaws.com + ap-east-1: + states: states.ap-east-1.amazonaws.com + ap-northeast-1: + states: states.ap-northeast-1.amazonaws.com + ap-northeast-2: + states: states.ap-northeast-2.amazonaws.com + ap-northeast-3: + states: states.ap-northeast-3.amazonaws.com + ap-south-1: + states: states.ap-south-1.amazonaws.com + ap-south-2: + states: states.ap-south-2.amazonaws.com + ap-southeast-1: + states: states.ap-southeast-1.amazonaws.com + ap-southeast-2: + states: states.ap-southeast-2.amazonaws.com + ap-southeast-3: + states: states.ap-southeast-3.amazonaws.com + ca-central-1: + states: states.ca-central-1.amazonaws.com + cn-north-1: + states: states.cn-north-1.amazonaws.com + cn-northwest-1: + states: states.cn-northwest-1.amazonaws.com + eu-central-1: + states: states.eu-central-1.amazonaws.com + eu-north-1: + states: states.eu-north-1.amazonaws.com + eu-south-1: + states: states.eu-south-1.amazonaws.com + eu-south-2: + states: states.eu-south-2.amazonaws.com + eu-west-1: + states: states.eu-west-1.amazonaws.com + eu-west-2: + states: states.eu-west-2.amazonaws.com + eu-west-3: + states: states.eu-west-3.amazonaws.com + me-central-1: + states: states.me-central-1.amazonaws.com + me-south-1: + states: states.me-south-1.amazonaws.com + sa-east-1: + states: states.sa-east-1.amazonaws.com + us-east-1: + states: states.us-east-1.amazonaws.com + us-east-2: + states: states.us-east-2.amazonaws.com + us-gov-east-1: + states: states.us-gov-east-1.amazonaws.com + us-gov-west-1: + states: states.us-gov-west-1.amazonaws.com + us-iso-east-1: + states: states.amazonaws.com + us-iso-west-1: + states: states.amazonaws.com + us-isob-east-1: + states: states.amazonaws.com + us-west-1: + states: states.us-west-1.amazonaws.com + us-west-2: + states: states.us-west-2.amazonaws.com diff --git a/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py b/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py new file mode 100644 index 0000000000000..568ab840aafbb --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py @@ -0,0 +1,2840 @@ +import json +from collections import OrderedDict + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.stepfunctions.asl.utils.json_path import extract_json +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + SfnNoneRecursiveParallelTransformer, + await_execution_terminated, + create_and_record_execution, + create_state_machine_with_iam_role, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) +from tests.aws.services.stepfunctions.templates.scenarios.scenarios_templates import ( + ScenariosTemplate as ST, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as SerT, +) + + +class TestBaseScenarios: + @markers.snapshot.skip_snapshot_verify(paths=["$..cause"]) + @markers.aws.validated + def test_catch_states_runtime( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=SerT.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ST.load_sfn_template(ST.CATCH_STATES_RUNTIME) + template["States"]["RaiseRuntime"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_catch_empty( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=SerT.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ST.load_sfn_template(ST.CATCH_EMPTY) + template["States"]["StartTask"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template", + [ + ST.load_sfn_template(ST.PARALLEL_STATE), + ST.load_sfn_template(ST.PARALLEL_STATE_PARAMETERS), + ], + ids=["PARALLEL_STATE", "PARALLEL_STATE_PARAMETERS"], + ) + def test_parallel_state( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + sfn_snapshot.add_transformer(SfnNoneRecursiveParallelTransformer()) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize("max_concurrency_value", [dict(), "NoNumber", 0, 1]) + def test_max_concurrency_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + max_concurrency_value, + ): + # TODO: Investigate AWS's behaviour with stringified integer values such as "1", as when passed as + # execution inputs these are casted to integers. Future efforts should record more snapshot tests to assert + # the behaviour of such stringification on execution inputs + template = ST.load_sfn_template(ST.MAX_CONCURRENCY) + definition = json.dumps(template) + + exec_input = json.dumps( + {"MaxConcurrencyValue": max_concurrency_value, "Values": ["HelloWorld"]} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS consistently appears to stall after startup when a negative MaxConcurrency value is given. + # Instead, the Provider V2 raises a State.Runtime exception and terminates. In the future we should + # reevaluate AWS's behaviour in these circumstances and choose whether too also 'hang'. + "$..events" + ] + ) + def test_max_concurrency_path_negative( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAX_CONCURRENCY) + definition = json.dumps(template) + + exec_input = json.dumps({"MaxConcurrencyValue": -1, "Values": ["HelloWorld"]}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_parallel_state_order( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(SfnNoneRecursiveParallelTransformer()) + template = ST.load_sfn_template(ST.PARALLEL_STATE_ORDER) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_parallel_state_fail( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.PARALLEL_STATE_FAIL) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input", + "$..events..stateExitedEventDetails.output", + "$..events..executionSucceededEventDetails.output", + ] + ) + def test_parallel_state_nested( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(SfnNoneRecursiveParallelTransformer()) + template = ST.load_sfn_template(ST.PARALLEL_NESTED_NESTED) + definition = json.dumps(template) + + exec_input = json.dumps([[1, 2, 3], [4, 5, 6]]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_parallel_state_catch( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.PARALLEL_STATE_CATCH) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_parallel_state_retry( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.PARALLEL_STATE_RETRY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_nested( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_NESTED) + definition = json.dumps(template) + + exec_input = json.dumps( + [ + [1, 2, 3], + [4, 5, 6], + ] + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_no_processor_config( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_NO_PROCESSOR_CONFIG) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_legacy( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_legacy_config_inline( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY_CONFIG_INLINE) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_legacy_config_distributed( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY_CONFIG_DISTRIBUTED) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_legacy_config_distributed_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY_CONFIG_DISTRIBUTED_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_legacy_config_distributed_item_selector( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY_CONFIG_DISTRIBUTED_ITEM_SELECTOR) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_legacy_config_inline_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY_CONFIG_INLINE_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_legacy_config_inline_item_selector( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY_CONFIG_INLINE_ITEM_SELECTOR) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_config_distributed_item_selector( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_DISTRIBUTED_ITEM_SELECTOR) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # FIXME: AWS appears to have the state prior to MapStateExited as MapRunStarted. + # LocalStack currently has this previous state as MapRunSucceeded. + "$..events[8].previousEventId" + ] + ) + def test_map_state_config_distributed_item_selector_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_DISTRIBUTED_ITEM_SELECTOR_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_legacy_reentrant( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LEGACY_REENTRANT) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_config_distributed_reentrant( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + # Replace MapRunArns with fixed values to circumvent random ordering issues. + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..mapRunArn", replacement="map_run_arn", replace_reference=False + ) + ) + + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_DISTRIBUTED_REENTRANT) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_config_distributed_reentrant_lambda( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + # Replace MapRunArns with fixed values to circumvent random ordering issues. + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..mapRunArn", replacement="map_run_arn", replace_reference=False + ) + ) + + function_name = f"sfn_lambda_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=SerT.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_DISTRIBUTED_REENTRANT_LAMBDA) + definition = json.dumps(template) + definition = definition.replace("_tbd_", function_arn) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_config_distributed_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_DISTRIBUTED_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_config_inline_item_selector( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_DISTRIBUTED_ITEM_SELECTOR) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: AWS appears to have changed json encoding to include spaces after separators, + # other v2 test suite snapshots need to be re-recorded + "$..events..stateEnteredEventDetails.input" + ] + ) + def test_map_state_config_inline_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_INLINE_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ST.MAP_STATE_ITEM_SELECTOR, ST.MAP_STATE_ITEM_SELECTOR_JSONATA], + ids=["MAP_STATE_ITEM_SELECTOR", "MAP_STATE_ITEM_SELECTOR_JSONATA"], + ) + def test_map_state_item_selector( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + # FIXME: The previousEventId in the event history is incorrectly being set to the previous state + @markers.snapshot.skip_snapshot_verify(paths=["$..events[2].previousEventId"]) + @pytest.mark.parametrize( + "items_literal", + [ + 1, + "'string'", + "true", + "{'foo': 'bar'}", + "null", + pytest.param( + "$fn := function($x){$x}", + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="LocalStack does not correctly handle when a higher-order function is passed as a parameter.", + ), + ), + ], + ids=["number", "string", "boolean", "object", "null", "function"], + ) + @markers.aws.validated + def test_map_state_items_eval_jsonata_fail( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + items_literal, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEMS_LITERAL) + definition = json.dumps(template) + definition = definition.replace("_tbd_", f"{{% {items_literal} %}}") + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "items_literal", + [[], [0], [1, "two", 3]], + ids=["empty", "singleton", "mixed"], + ) + @markers.aws.validated + def test_map_state_items_eval_jsonata( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + items_literal, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEMS_LITERAL) + definition = json.dumps(template) + definition = definition.replace("_tbd_", f"{{% {items_literal} %}}") + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + # FIXME: The previousEventId in the event history is incorrectly being set to the previous state + @markers.snapshot.skip_snapshot_verify(paths=["$..events[4].previousEventId"]) + @pytest.mark.parametrize( + "items_literal", + [1, "'string'", "true", "{'foo': 'bar'}", "null"], + ids=["number", "string", "boolean", "object", "null"], + ) + @markers.aws.validated + def test_map_state_items_eval_jsonata_variable_sampling_fail( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + items_literal, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEMS_VARIABLE) + definition = json.dumps(template) + definition = definition.replace("_tbd_", f"{{% {items_literal} %}}") + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: Create a function that maps Python types to JSONata type-strings + "$..events[4].evaluationFailedEventDetails.cause", + "$..events[6].executionFailedEventDetails.cause", + # FIXME: The previousEventId in the event history is incorrectly being set to the previous state + "$..events[4].previousEventId", + ] + ) + @pytest.mark.parametrize( + "items_value", + [1, "string", True, {"foo": "bar"}, None], + ids=["number", "string", "boolean", "object", "null"], + ) + @markers.aws.validated + def test_map_state_items_input_types( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + items_value, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEMS) + definition = json.dumps(template) + + exec_input = json.dumps({"items": items_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "items_value", + [[], [0], [1, "two", True]], + ids=["empty", "singleton", "mixed"], + ) + @markers.aws.validated + def test_map_state_items_input_array( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + items_value, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEMS) + definition = json.dumps(template) + + exec_input = json.dumps({"items": items_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..events[4].previousEventId"]) + @pytest.mark.parametrize( + "items_literal", + ["1", '"string"', "true", '{"foo": "bar"}', "null"], + ids=["number", "string", "boolean", "object", "null"], + ) + @markers.aws.validated + def test_map_state_items_variable_sampling( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + items_literal, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEMS_VARIABLE) + definition = json.dumps(template) + definition = definition.replace('"_tbd_"', items_literal) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_item_selector_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEM_SELECTOR_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_parameters_legacy( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_PARAMETERS_LEGACY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_item_selector_singleton( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_ITEM_SELECTOR_SINGLETON) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_parameters_singleton_legacy( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_PARAMETERS_SINGLETON_LEGACY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_catch( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CATCH) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_catch_empty_fail( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CATCH_EMPTY_FAIL) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_catch_legacy( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CATCH_LEGACY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_retry( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_RETRY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_retry_multiple_retriers( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_RETRY_MULTIPLE_RETRIERS) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_retry_legacy( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_RETRY_LEGACY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_break_condition( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_BREAK_CONDITION) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_break_condition_legacy( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_BREAK_CONDITION_LEGACY) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "tolerance_template", + [ST.MAP_STATE_TOLERATED_FAILURE_COUNT, ST.MAP_STATE_TOLERATED_FAILURE_PERCENTAGE], + ids=["count_literal", "percentage_literal"], + ) + def test_map_state_tolerated_failure_values( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + tolerance_template, + ): + template = ST.load_sfn_template(tolerance_template) + definition = json.dumps(template) + + exec_input = json.dumps([0]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize("tolerated_failure_count_value", [dict(), "NoNumber", -1, 0, 1]) + def test_map_state_tolerated_failure_count_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + tolerated_failure_count_value, + ): + template = ST.load_sfn_template(ST.MAP_STATE_TOLERATED_FAILURE_COUNT_PATH) + definition = json.dumps(template) + + exec_input = json.dumps( + {"Items": [0], "ToleratedFailureCount": tolerated_failure_count_value} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "tolerated_failure_percentage_value", [dict(), "NoNumber", -1.1, -1, 0, 1, 1.1, 100, 100.1] + ) + def test_map_state_tolerated_failure_percentage_path( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + tolerated_failure_percentage_value, + ): + template = ST.load_sfn_template(ST.MAP_STATE_TOLERATED_FAILURE_PERCENTAGE_PATH) + definition = json.dumps(template) + + exec_input = json.dumps( + {"Items": [0], "ToleratedFailurePercentage": tolerated_failure_percentage_value} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_label( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LABEL) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..events[8].previousEventId"]) + def test_map_state_nested_config_distributed( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_NESTED_CONFIG_DISTRIBUTED) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..events[8].previousEventId"]) + def test_map_state_nested_config_distributed_no_max_max_concurrency( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=SerT.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = ST.load_sfn_template(ST.MAP_STATE_NESTED_CONFIG_DISTRIBUTED_NO_MAX_CONCURRENCY) + definition = json.dumps(template) + definition = definition.replace("__tbd__", function_name) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_state_result_writer( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = "result-bucket" + s3_create_bucket(Bucket=bucket_name) + + template = ST.load_sfn_template(ST.MAP_STATE_RESULT_WRITER) + definition = json.dumps(template) + + exec_input = json.dumps(["Hello", "World"]) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + # Validate the manifest file + # TODO: consider a better way to get MapRunArn + map_run_arn = json.loads( + sfn_snapshot.observed_state["get_execution_history"]["events"][-1][ + "executionSucceededEventDetails" + ]["output"] + )["MapRunArn"] + map_run_uuid = map_run_arn.split(":")[-1] + resp = aws_client.s3.get_object( + Bucket=bucket_name, Key=f"mapJobs/{map_run_uuid}/manifest.json" + ) + manifest_data = json.loads(resp["Body"].read().decode("utf-8")) + assert manifest_data["DestinationBucket"] == bucket_name + assert manifest_data["MapRunArn"] == map_run_arn + assert manifest_data["ResultFiles"]["FAILED"] == [] + assert manifest_data["ResultFiles"]["PENDING"] == [] + assert manifest_data["ResultFiles"]["SUCCEEDED"] == [] + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + ST.CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS, + ST.CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA, + ], + ids=[ + "CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS", + "CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA", + ], + ) + def test_choice_unsorted_parameters_positive( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + json.dumps({"result": {"done": True}}), + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + ST.CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS, + ST.CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA, + ], + ids=[ + "CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS", + "CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA", + ], + ) + def test_choice_unsorted_parameters_negative( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + json.dumps({"result": {"done": False}}), + ) + + @markers.aws.validated + def test_choice_condition_constant_jsonata( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.CHOICE_CONDITION_CONSTANT_JSONATA) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ST.CHOICE_STATE_AWS_SCENARIO, ST.CHOICE_STATE_AWS_SCENARIO_JSONATA], + ids=["CHOICE_STATE_AWS_SCENARIO", "CHOICE_STATE_AWS_SCENARIO_JSONATA"], + ) + def test_choice_aws_docs_scenario( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({"type": "Private", "value": 22}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + ST.CHOICE_STATE_SINGLETON_COMPOSITE, + ST.CHOICE_STATE_SINGLETON_COMPOSITE_JSONATA, + ST.CHOICE_STATE_SINGLETON_COMPOSITE_LITERAL_JSONATA, + ], + ids=[ + "CHOICE_STATE_SINGLETON_COMPOSITE", + "CHOICE_STATE_SINGLETON_COMPOSITE_JSONATA", + "CHOICE_STATE_SINGLETON_COMPOSITE_LITERAL_JSONATA", + ], + ) + def test_choice_singleton_composite( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({"type": "Public", "value": 22}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_base_list_objects_v2( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket_name")) + for i in range(3): + aws_client.s3.put_object( + Bucket=bucket_name, Key=f"file_{i}.txt", Body=f"{i}HelloWorld!" + ) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_LIST_OBJECTS_V2) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name}) + + state_machine_arn = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=exec_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + execution_history = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn + ) + map_run_arn = extract_json("$..mapRunStartedEventDetails.mapRunArn", execution_history) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_map_run_arn(map_run_arn, 0)) + + # Normalise s3 ListObjectV2 response in the execution events output to ensure variable fields such as + # Etag and LastModified are mapped to repeatable static values. Such normalisation is only necessary in + # ItemReader calls invoking s3:ListObjectV2, of which result is directly mapped to the output of the iteration. + output_str = execution_history["events"][-1]["executionSucceededEventDetails"]["output"] + output_json = json.loads(output_str) + output_norm = [] + for output_value in output_json: + norm_output_value = OrderedDict() + norm_output_value["Etag"] = f"" + norm_output_value["LastModified"] = "" + norm_output_value["Key"] = output_value["Key"] + norm_output_value["Size"] = output_value["Size"] + norm_output_value["StorageClass"] = output_value["StorageClass"] + output_norm.append(norm_output_value) + output_norm.sort(key=lambda value: value["Key"]) + output_norm_str = json.dumps(output_norm) + execution_history["events"][-2]["stateExitedEventDetails"]["output"] = output_norm_str + execution_history["events"][-1]["executionSucceededEventDetails"]["output"] = ( + output_norm_str + ) + + sfn_snapshot.match("get_execution_history", execution_history) + + @markers.aws.validated + def test_map_item_reader_base_csv_headers_first_line( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_file = ( + "Col1,Col2,Col3\n" + "Value1,Value2,Value3\n" + "Value4,Value5,Value6\n" + ",,,\n" + "true,1,'HelloWorld'\n" + "Null,None,\n" + " \n" + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_HEADERS_FIRST_LINE) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "max_items_value", + [0, 2, 100_000_000], # Linter on creation filters for valid input integers (0 - 100000000). + ) + def test_map_item_reader_csv_max_items( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + max_items_value, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_file = "Col1,Col2\nValue1,Value2\nValue3,Value4\nValue5,Value6\nValue7,Value8\n" + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_MAX_ITEMS) + template["States"]["MapState"]["ItemReader"]["ReaderConfig"]["MaxItems"] = max_items_value + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "max_items_value", [-1, 0, 1.5, 2, 100_000_000, 100_000_001] + ) # The Distributed Map state stops reading items beyond 100_000_000. + def test_map_item_reader_csv_max_items_paths( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + max_items_value, + ): + if max_items_value == 1.5: + pytest.skip( + "Validation of non integer max items value is performed at a higher depth than that of negative " + "values in AWS StepFunctions. The SFN v2 interpreter runs this check at the same depth." + ) + + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_file = "Col1,Col2\nValue1,Value2\nValue3,Value4\nValue5,Value6\nValue7,Value8\n" + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_MAX_ITEMS_PATH) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key, "MaxItems": max_items_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skip_snapshot_verify(paths=["$..events[6].previousEventId"]) + @markers.aws.validated + def test_map_item_reader_base_json_max_items_jsonata( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.json" + json_file = json.dumps( + [ + {"verdict": "true", "statement_date": "6/11/2008", "statement_source": "speech"}, + { + "verdict": "false", + "statement_date": "6/7/2022", + "statement_source": "television", + }, + { + "verdict": "mostly-true", + "statement_date": "5/18/2016", + "statement_source": "news", + }, + ] + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=json_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_JSON_MAX_ITEMS_JSONATA) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key, "MaxItems": 2}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skip( + reason="TODO: Add JSONata support for ItemBatcher's MaxItemsPerBatch and MaxInputBytesPerBatch fields" + ) + @markers.aws.validated + def test_map_item_batching_base_json_max_per_batch_jsonata( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.json" + json_file = json.dumps( + [ + {"verdict": "true", "statement_date": "6/11/2008", "statement_source": "speech"}, + { + "verdict": "false", + "statement_date": "6/7/2022", + "statement_source": "television", + }, + { + "verdict": "mostly-true", + "statement_date": "5/18/2016", + "statement_source": "news", + }, + ] + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=json_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_BATCHER_BASE_JSON_MAX_PER_BATCH_JSONATA) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "Bucket": bucket_name, + "Key": key, + "MaxItemsPerBatch": 2, + "MaxInputBytesPerBatch": 150_000, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_base_csv_headers_decl( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_headers = ["H1", "H2", "H3"] + csv_file = ( + "Value1,Value2,Value3\n" + "Value4,Value5,Value6\n" + ",,,\n" + "true,1,'HelloWorld'\n" + "Null,None,\n" + " \n" + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_HEADERS_DECL) + template["States"]["MapState"]["ItemReader"]["ReaderConfig"]["CSVHeaders"] = csv_headers + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_csv_headers_decl_duplicate_headers( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_headers = ["H1", "H1", "H3"] + csv_file = ( + "Value1,Value2,Value3\n" + "Value4,Value5,Value6\n" + ",,,\n" + "true,1,'HelloWorld'\n" + "Null,None,\n" + " \n" + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_HEADERS_DECL) + template["States"]["MapState"]["ItemReader"]["ReaderConfig"]["CSVHeaders"] = csv_headers + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_csv_headers_first_row_typed_headers( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_file = "0,True,{}\nValue4,Value5,Value6\n,,,\ntrue,1,'HelloWorld'\nNull,None,\n \n" + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_HEADERS_FIRST_LINE) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_csv_headers_decl_extra_fields( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_headers = ["H1"] + csv_file = ( + "Value1,Value2,Value3\n" + "Value4,Value5,Value6\n" + ",,,\n" + "true,1,'HelloWorld'\n" + "Null,None,\n" + " \n" + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_HEADERS_DECL) + template["States"]["MapState"]["ItemReader"]["ReaderConfig"]["CSVHeaders"] = csv_headers + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_csv_first_row_extra_fields( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.csv" + csv_file = "H1,\nValue4,Value5,Value6\n,,,\ntrue,1,'HelloWorld'\nNull,None,\n \n" + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_HEADERS_FIRST_LINE) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_base_json( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.json" + json_file = json.dumps( + [ + {"verdict": "true", "statement_date": "6/11/2008", "statement_source": "speech"}, + { + "verdict": "false", + "statement_date": "6/7/2022", + "statement_source": "television", + }, + { + "verdict": "mostly-true", + "statement_date": "5/18/2016", + "statement_source": "news", + }, + ] + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=json_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_JSON) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "items_path", + [ + "$.from_previous", + "$[0]", + "$.no_such_path_in_bucket_result", + ], + ids=[ + "VALID_ITEMS_PATH_FROM_PREVIOUS", + "VALID_ITEMS_PATH_FROM_ITEM_READER", + "INVALID_ITEMS_PATH", + ], + ) + @markers.snapshot.skip_snapshot_verify(paths=["$..previousEventId"]) + def test_map_item_reader_base_json_with_items_path( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + items_path, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.json" + json_file = json.dumps([["from-bucket-item-0"]]) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=json_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_JSON_WITH_ITEMS_PATH) + template["States"]["MapState"]["ItemsPath"] = items_path + definition = json.dumps(template) + + exec_input = json.dumps( + {"Bucket": bucket_name, "Key": key, "from_input_items": ["input-item-0"]} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..previousEventId"]) + def test_map_state_config_distributed_items_path_from_previous( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.MAP_STATE_CONFIG_DISTRIBUTED_ITEMS_PATH_FROM_PREVIOUS) + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_json_no_json_list_object( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.json" + json_file = json.dumps({"Hello": "world"}) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=json_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_JSON) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_map_item_reader_base_json_max_items( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + key = "file.json" + json_file = json.dumps( + [ + {"verdict": "true", "statement_date": "6/11/2008", "statement_source": "speech"}, + ] + * 3 + ) + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=json_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_JSON_MAX_ITEMS) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Cause"]) + @markers.aws.validated + def test_lambda_empty_retry( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ST.load_sfn_template(ST.LAMBDA_EMPTY_RETRY) + template["States"]["LambdaTask"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Cause"]) + @markers.aws.validated + def test_lambda_invoke_with_retry_base( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_1_name = f"lambda_1_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_1_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE_WITH_RETRY_BASE) + template["States"]["InvokeLambdaWithRetry"]["Resource"] = create_1_res[ + "CreateFunctionResponse" + ]["FunctionArn"] + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld!", "Value2": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Cause"]) + @markers.aws.validated + def test_lambda_invoke_with_retry_extended_input( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..StartTime", replacement="", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..EnteredTime", replacement="", replace_reference=False + ) + ) + + function_1_name = f"lambda_1_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_1_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT) + template["States"]["InvokeLambdaWithRetry"]["Resource"] = create_1_res[ + "CreateFunctionResponse" + ]["FunctionArn"] + definition = json.dumps(template) + + exec_input = json.dumps({"Value1": "HelloWorld!", "Value2": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Cause"]) + @markers.aws.validated + def test_lambda_service_invoke_with_retry_extended_input( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..StartTime", replacement="", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..EnteredTime", replacement="", replace_reference=False + ) + ) + + function_1_name = f"lambda_1_func_{short_uid()}" + create_lambda_function( + func_name=function_1_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_SERVICE_INVOKE_WITH_RETRY_BASE_EXTENDED_INPUT) + definition = json.dumps(template) + + exec_input = json.dumps( + {"FunctionName": function_1_name, "Value1": "HelloWorld!", "Value2": None} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_retry_interval_features( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ST.load_sfn_template(ST.RETRY_INTERVAL_FEATURES) + template["States"]["LambdaTask"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_retry_interval_features_jitter_none( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ST.load_sfn_template(ST.RETRY_INTERVAL_FEATURES_JITTER_NONE) + template["States"]["LambdaTask"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_retry_interval_features_max_attempts_zero( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + + template = ST.load_sfn_template(ST.RETRY_INTERVAL_FEATURES_MAX_ATTEMPTS_ZERO) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "timestamp_value", + [ + "2016-03-14T01:59:00Z", + "2016-03-05T21:29:29.243167252Z", + ], + ids=["SECONDS", "NANOSECONDS"], + ) + def test_wait_timestamp( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + timestamp_value, + ): + template = ST.load_sfn_template(ST.WAIT_TIMESTAMP) + template["States"]["WaitUntil"]["Timestamp"] = timestamp_value + definition = json.dumps(template) + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + @pytest.mark.parametrize( + "timestamp_value", + [ + "2016-12-05 21:29:29Z", + "2016-12-05T21:29:29", + "2016-13-05T21:29:29Z", + "2016-12-05T25:29:29Z", + "05-12-2016T21:29:29Z", + "{% '2016-03-14T01:59:00Z' %}", + ], + ids=["NO_T", "NO_Z", "INVALID_DATE", "INVALID_TIME", "INVALID_ISO", "JSONATA"], + ) + def test_wait_timestamp_invalid( + self, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + timestamp_value, + ): + template = ST.load_sfn_template(ST.WAIT_TIMESTAMP) + template["States"]["WaitUntil"]["Timestamp"] = timestamp_value + definition = json.dumps(template) + with pytest.raises(Exception) as ex: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + sfn_snapshot.match( + "exception", {"exception_typename": ex.typename, "exception_value": ex.value} + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "timestamp_value", + [ + "2016-03-14T01:59:00Z", + "2016-03-05T21:29:29.243167252Z", + "2016-12-05 21:29:29Z", + "2016-12-05T21:29:29", + "2016-13-05T21:29:29Z", + "2016-12-05T25:29:29Z", + "05-12-2016T21:29:29Z", + ], + ids=[ + "SECONDS", + "NANOSECONDS", + "NO_T", + "NO_Z", + "INVALID_DATE", + "INVALID_TIME", + "INVALID_ISO", + ], + ) + def test_wait_timestamp_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + timestamp_value, + ): + template = ST.load_sfn_template(ST.WAIT_TIMESTAMP_PATH) + definition = json.dumps(template) + exec_input = json.dumps({"TimestampValue": timestamp_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "timestamp_value", + [ + "2016-03-14T01:59:00Z", + "2016-03-05T21:29:29.243167252Z", + pytest.param( + "2016-12-05 21:29:29Z", + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), reason="depends on JSONata outcome validation" + ), + ), + pytest.param( + "2016-12-05T21:29:29", + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), reason="depends on JSONata outcome validation" + ), + ), + pytest.param( + "2016-13-05T21:29:29Z", + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), reason="depends on JSONata outcome validation" + ), + ), + pytest.param( + "2016-12-05T25:29:29Z", + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), reason="depends on JSONata outcome validation" + ), + ), + pytest.param( + "05-12-2016T21:29:29Z", + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), reason="depends on JSONata outcome validation" + ), + ), + ], + ids=[ + "SECONDS", + "NANOSECONDS", + "NO_T", + "NO_Z", + "INVALID_DATE", + "INVALID_TIME", + "INVALID_ISO", + ], + ) + def test_wait_timestamp_jsonata( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + timestamp_value, + ): + template = ST.load_sfn_template(ST.WAIT_TIMESTAMP_JSONATA) + definition = json.dumps(template) + exec_input = json.dumps({"TimestampValue": timestamp_value}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_wait_seconds_jsonata( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.WAIT_SECONDS_JSONATA) + definition = json.dumps(template) + + exec_input = json.dumps({"waitSeconds": 0}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_fail_error_jsonata( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.RAISE_FAILURE_ERROR_JSONATA) + definition = json.dumps(template) + + exec_input = json.dumps({"error": "Exception"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_fail_cause_jsonata( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.RAISE_FAILURE_CAUSE_JSONATA) + definition = json.dumps(template) + + exec_input = json.dumps({"cause": "This failed to due an Exception."}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template", + [ + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_ERRORPATH), + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_STRING_EXPR_JSONPATH), + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_STRING_EXPR_CONTEXTPATH), + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_CAUSEPATH), + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_INPUTPATH), + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_OUTPUTPATH), + pytest.param( + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_TIMEOUTSECONDSPATH), + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="timeout computation is run at the state's level", + ), + ), + pytest.param( + ST.load_sfn_template(ST.INVALID_JSONPATH_IN_HEARTBEATSECONDSPATH), + marks=pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="heartbeat computation is run at the state's level", + ), + ), + ], + ids=[ + "INVALID_JSONPATH_IN_ERRORPATH", + "INVALID_JSONPATH_IN_STRING_EXPR_JSONPATH", + "INVALID_JSONPATH_IN_STRING_EXPR_CONTEXTPATH", + "ST.INVALID_JSONPATH_IN_CAUSEPATH", + "ST.INVALID_JSONPATH_IN_INPUTPATH", + "ST.INVALID_JSONPATH_IN_OUTPUTPATH", + "ST.INVALID_JSONPATH_IN_TIMEOUTSECONDSPATH", + "ST.INVALID_JSONPATH_IN_HEARTBEATSECONDSPATH", + ], + ) + def test_invalid_jsonpath( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + definition = json.dumps(template) + exec_input = json.dumps({"int-literal": 0}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + ST.ESCAPE_SEQUENCES_STRING_LITERALS, + ST.ESCAPE_SEQUENCES_JSONPATH, + ST.ESCAPE_SEQUENCES_JSONATA_COMPARISON_OUTPUT, + ST.ESCAPE_SEQUENCES_JSONATA_COMPARISON_ASSIGN, + ], + ids=[ + "ESCAPE_SEQUENCES_STRING_LITERALS", + "ESCAPE_SEQUENCES_JSONPATH", + "ESCAPE_SEQUENCES_JSONATA_COMPARISON_OUTPUT", + "ESCAPE_SEQUENCES_JSONATA_COMPARISON_ASSIGN", + ], + ) + def test_escape_sequence_parsing( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + exec_input = json.dumps({'Test\\""Name"': 'Value"\\'}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.skip( + reason=( + "Lack of generalisable approach to escape sequences support " + "in intrinsic functions literals; see backlog item." + ) + ) + @pytest.mark.parametrize( + "template_path", + [ + ST.ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION, + ST.ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION_2, + ], + ids=[ + "ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION", + "ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION_2", + ], + ) + def test_illegal_escapes( + self, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template_path, + ): + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + with pytest.raises(Exception) as ex: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + sfn_snapshot.match( + "exception", {"exception_typename": ex.typename, "exception_value": ex.value} + ) diff --git a/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.snapshot.json b/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.snapshot.json new file mode 100644 index 0000000000000..107ddce5d6adb --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.snapshot.json @@ -0,0 +1,30207 @@ +{ + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state": { + "recorded-date": "17-07-2023, 12:41:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "LoadInput" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "LoadInput", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch21" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch22" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch31" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch32" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch33" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch1", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch21", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch22", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch31", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch32", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch33", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "ParallelState", + "output": "[[1,2,3,4],[1,2,3,4],[1,2,3,4]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[1,2,3,4],[1,2,3,4],[1,2,3,4]]", + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_break_condition": { + "recorded-date": "21-09-2023, 17:39:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1, + "Hello", + {}, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1, + "Hello", + {}, + 3.3, + 3, + 4, + 5 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 7 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "Pass", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": "\"Hello\"", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "\"Hello\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "\"Hello\"", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Pass", + "output": "\"Hello\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "Equals3", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 21, + "previousEventId": 20, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 22, + "previousEventId": 21, + "stateExitedEventDetails": { + "name": "Pass", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 23, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 22, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 24, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 22, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 25, + "previousEventId": 24, + "stateEnteredEventDetails": { + "input": "3.3", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 26, + "previousEventId": 25, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "3.3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 27, + "previousEventId": 26, + "stateEnteredEventDetails": { + "input": "3.3", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 28, + "previousEventId": 27, + "stateExitedEventDetails": { + "name": "Pass", + "output": "3.3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 29, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 30, + "mapIterationStartedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 31, + "previousEventId": 30, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 32, + "previousEventId": 31, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 33, + "previousEventId": 32, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Break" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 34, + "mapIterationFailedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 33, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 35, + "previousEventId": 33, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 36, + "previousEventId": 35, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state": { + "recorded-date": "17-07-2023, 16:35:51", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 8 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "\"Hello\"", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "\"Hello\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": "null", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": "3.3", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 24, + "previousEventId": 23, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "3.3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 25, + "mapIterationSucceededEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 26, + "mapIterationStartedEventDetails": { + "index": 5, + "name": "MapState" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 27, + "previousEventId": 26, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 28, + "previousEventId": 27, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 29, + "mapIterationSucceededEventDetails": { + "index": 5, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 30, + "mapIterationStartedEventDetails": { + "index": 6, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 31, + "previousEventId": 30, + "stateEnteredEventDetails": { + "input": "4", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 32, + "previousEventId": 31, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "4", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 33, + "mapIterationSucceededEventDetails": { + "index": 6, + "name": "MapState" + }, + "previousEventId": 32, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 34, + "mapIterationStartedEventDetails": { + "index": 7, + "name": "MapState" + }, + "previousEventId": 32, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 35, + "previousEventId": 34, + "stateEnteredEventDetails": { + "input": "5", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 36, + "previousEventId": 35, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "5", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 37, + "mapIterationSucceededEventDetails": { + "index": 7, + "name": "MapState" + }, + "previousEventId": 36, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 38, + "previousEventId": 37, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 39, + "previousEventId": 37, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 40, + "previousEventId": 39, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 41, + "previousEventId": 40, + "stateExitedEventDetails": { + "name": "Final", + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 42, + "previousEventId": 41, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch": { + "recorded-date": "18-07-2023, 18:07:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "inputDetails": { + "truncated": false + }, + "name": "FinalCaught" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "FinalCaught", + "output": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 13, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry": { + "recorded-date": "18-07-2023, 18:44:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 12, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 13, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 16, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 17, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "HandleItemFailCause", + "error": "HandleItemFailError" + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector": { + "recorded-date": "19-07-2023, 13:47:54", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 5 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 24, + "previousEventId": 23, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 25, + "mapIterationSucceededEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 26, + "previousEventId": 25, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 27, + "previousEventId": 25, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 28, + "previousEventId": 27, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 29, + "previousEventId": 28, + "stateExitedEventDetails": { + "name": "Final", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 30, + "previousEventId": 29, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector_singleton": { + "recorded-date": "19-07-2023, 14:11:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 20, + "previousEventId": 19, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 21, + "previousEventId": 20, + "stateExitedEventDetails": { + "name": "Final", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 22, + "previousEventId": 21, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy": { + "recorded-date": "23-07-2023, 20:46:31", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 8 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "\"Hello\"", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "\"Hello\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": "null", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": "3.3", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 24, + "previousEventId": 23, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "3.3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 25, + "mapIterationSucceededEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 26, + "mapIterationStartedEventDetails": { + "index": 5, + "name": "MapState" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 27, + "previousEventId": 26, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 28, + "previousEventId": 27, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 29, + "mapIterationSucceededEventDetails": { + "index": 5, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 30, + "mapIterationStartedEventDetails": { + "index": 6, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 31, + "previousEventId": 30, + "stateEnteredEventDetails": { + "input": "4", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 32, + "previousEventId": 31, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "4", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 33, + "mapIterationSucceededEventDetails": { + "index": 6, + "name": "MapState" + }, + "previousEventId": 32, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 34, + "mapIterationStartedEventDetails": { + "index": 7, + "name": "MapState" + }, + "previousEventId": 32, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 35, + "previousEventId": 34, + "stateEnteredEventDetails": { + "input": "5", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 36, + "previousEventId": 35, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "5", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 37, + "mapIterationSucceededEventDetails": { + "index": 7, + "name": "MapState" + }, + "previousEventId": 36, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 38, + "previousEventId": 37, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 39, + "previousEventId": 37, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 40, + "previousEventId": 39, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 41, + "previousEventId": 40, + "stateExitedEventDetails": { + "name": "Final", + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "arr": [ + 1, + "Hello", + {}, + null, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 42, + "previousEventId": 41, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_break_condition_legacy": { + "recorded-date": "24-07-2023, 10:38:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1, + "Hello", + {}, + 3.3, + 3, + 4, + 5 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1, + "Hello", + {}, + 3.3, + 3, + 4, + 5 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 7 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "Pass", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": "\"Hello\"", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "\"Hello\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "\"Hello\"", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Pass", + "output": "\"Hello\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "Equals3", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 21, + "previousEventId": 20, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 22, + "previousEventId": 21, + "stateExitedEventDetails": { + "name": "Pass", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 23, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 22, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 24, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 22, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 25, + "previousEventId": 24, + "stateEnteredEventDetails": { + "input": "3.3", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 26, + "previousEventId": 25, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "3.3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 27, + "previousEventId": 26, + "stateEnteredEventDetails": { + "input": "3.3", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 28, + "previousEventId": 27, + "stateExitedEventDetails": { + "name": "Pass", + "output": "3.3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 29, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 30, + "mapIterationStartedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 31, + "previousEventId": 30, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Equals3" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 32, + "previousEventId": 31, + "stateExitedEventDetails": { + "name": "Equals3", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 33, + "previousEventId": 32, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Break" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 34, + "mapIterationFailedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 33, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 35, + "previousEventId": 33, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure" + }, + "id": 36, + "previousEventId": 35, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_parameters_legacy": { + "recorded-date": "24-07-2023, 10:53:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 5 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 24, + "previousEventId": 23, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 25, + "mapIterationSucceededEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 26, + "previousEventId": 25, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 27, + "previousEventId": 25, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 28, + "previousEventId": 27, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 29, + "previousEventId": 28, + "stateExitedEventDetails": { + "name": "Final", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 30, + "previousEventId": 29, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_parameters_singleton_legacy": { + "recorded-date": "24-07-2023, 10:56:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 20, + "previousEventId": 19, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 21, + "previousEventId": 20, + "stateExitedEventDetails": { + "name": "Final", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": "A", + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "B", + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": "C", + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + "A", + "B", + "C" + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 22, + "previousEventId": 21, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch_legacy": { + "recorded-date": "24-07-2023, 11:19:21", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "inputDetails": { + "truncated": false + }, + "name": "FinalCaught" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "FinalCaught", + "output": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "HandleItemFailError", + "Cause": "HandleItemFailCause" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 13, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry_legacy": { + "recorded-date": "24-07-2023, 11:22:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 12, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 13, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 16, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 17, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "HandleItemFailCause", + "error": "HandleItemFailError" + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/integration/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry_multiple_retriers": { + "recorded-date": "04-08-2023, 21:48:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 12, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 13, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 16, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 17, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 20, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 21, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "HandleItemFailCause", + "error": "HandleItemFailError" + }, + "id": 22, + "previousEventId": 21, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/integration/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch_empty_fail": { + "recorded-date": "04-08-2023, 21:48:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": {}, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch_empty_fail": { + "recorded-date": "08-08-2023, 13:18:54", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": {}, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry_multiple_retriers": { + "recorded-date": "08-08-2023, 13:20:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 8, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 9, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 12, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 13, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 16, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 17, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItemFail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 20, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 21, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "HandleItemFailCause", + "error": "HandleItemFailError" + }, + "id": 22, + "previousEventId": 21, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters[{\"result\": {\"done\": true}}]": { + "recorded-date": "04-09-2023, 23:33:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckResult" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "CheckResult", + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "name": "FinishTrue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinishTrue", + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters[{\"result\": {\"done\": false}}]": { + "recorded-date": "04-09-2023, 23:33:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckResult" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "CheckResult", + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "name": "FinishFalse" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinishFalse", + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_aws_docs_scenario": { + "recorded-date": "04-09-2023, 23:33:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceStateX" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceStateX", + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ValueInTwenties" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "ValueInTwenties", + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_list_objects_v2": { + "recorded-date": "21-09-2023, 13:54:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket_name" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket_name" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Etag\": \"\", \"LastModified\": \"\", \"Key\": \"file_0.txt\", \"Size\": 12, \"StorageClass\": \"STANDARD\"}, {\"Etag\": \"\", \"LastModified\": \"\", \"Key\": \"file_1.txt\", \"Size\": 12, \"StorageClass\": \"STANDARD\"}, {\"Etag\": \"\", \"LastModified\": \"\", \"Key\": \"file_2.txt\", \"Size\": 12, \"StorageClass\": \"STANDARD\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Etag\": \"\", \"LastModified\": \"\", \"Key\": \"file_0.txt\", \"Size\": 12, \"StorageClass\": \"STANDARD\"}, {\"Etag\": \"\", \"LastModified\": \"\", \"Key\": \"file_1.txt\", \"Size\": 12, \"StorageClass\": \"STANDARD\"}, {\"Etag\": \"\", \"LastModified\": \"\", \"Key\": \"file_2.txt\", \"Size\": 12, \"StorageClass\": \"STANDARD\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_csv_headers_first_line": { + "recorded-date": "21-09-2023, 14:07:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\",\"Col3\":\"Value3\"},{\"Col1\":\"Value4\",\"Col2\":\"Value5\",\"Col3\":\"Value6\"},{\"Col1\":\"\",\"Col2\":\"\",\"Col3\":\"\"},{\"Col1\":\"true\",\"Col2\":\"1\",\"Col3\":\"'HelloWorld'\"},{\"Col1\":\"Null\",\"Col2\":\"None\",\"Col3\":\"\"},{\"Col1\":\" \",\"Col2\":\"\",\"Col3\":\"\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\",\"Col3\":\"Value3\"},{\"Col1\":\"Value4\",\"Col2\":\"Value5\",\"Col3\":\"Value6\"},{\"Col1\":\"\",\"Col2\":\"\",\"Col3\":\"\"},{\"Col1\":\"true\",\"Col2\":\"1\",\"Col3\":\"'HelloWorld'\"},{\"Col1\":\"Null\",\"Col2\":\"None\",\"Col3\":\"\"},{\"Col1\":\" \",\"Col2\":\"\",\"Col3\":\"\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_csv_headers_decl": { + "recorded-date": "21-09-2023, 14:07:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"H1\":\"Value1\",\"H2\":\"Value2\",\"H3\":\"Value3\"},{\"H1\":\"Value4\",\"H2\":\"Value5\",\"H3\":\"Value6\"},{\"H1\":\"\",\"H2\":\"\",\"H3\":\"\"},{\"H1\":\"true\",\"H2\":\"1\",\"H3\":\"'HelloWorld'\"},{\"H1\":\"Null\",\"H2\":\"None\",\"H3\":\"\"},{\"H1\":\" \",\"H2\":\"\",\"H3\":\"\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"H1\":\"Value1\",\"H2\":\"Value2\",\"H3\":\"Value3\"},{\"H1\":\"Value4\",\"H2\":\"Value5\",\"H3\":\"Value6\"},{\"H1\":\"\",\"H2\":\"\",\"H3\":\"\"},{\"H1\":\"true\",\"H2\":\"1\",\"H3\":\"'HelloWorld'\"},{\"H1\":\"Null\",\"H2\":\"None\",\"H3\":\"\"},{\"H1\":\" \",\"H2\":\"\",\"H3\":\"\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json": { + "recorded-date": "21-09-2023, 14:08:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"},{\"verdict\":\"mostly-true\",\"statement_date\":\"5/18/2016\",\"statement_source\":\"news\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"},{\"verdict\":\"mostly-true\",\"statement_date\":\"5/18/2016\",\"statement_source\":\"news\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_decl_duplicate_headers": { + "recorded-date": "21-09-2023, 14:16:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "mapRunFailedEventDetails": { + "cause": "CSV headers cannot contain duplicates.", + "error": "States.ItemReaderFailed" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "CSV headers cannot contain duplicates.", + "error": "States.ItemReaderFailed" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_decl_extra_fields": { + "recorded-date": "21-09-2023, 14:25:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"H1\":\"Value1\"},{\"H1\":\"Value4\"},{\"H1\":\"\"},{\"H1\":\"true\"},{\"H1\":\"Null\"},{\"H1\":\" \"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"H1\":\"Value1\"},{\"H1\":\"Value4\"},{\"H1\":\"\"},{\"H1\":\"true\"},{\"H1\":\"Null\"},{\"H1\":\" \"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_first_row_typed_headers": { + "recorded-date": "21-09-2023, 14:26:55", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"0\":\"Value4\",\"{}\":\"Value6\",\"True\":\"Value5\"},{\"0\":\"\",\"{}\":\"\",\"True\":\"\"},{\"0\":\"true\",\"{}\":\"'HelloWorld'\",\"True\":\"1\"},{\"0\":\"Null\",\"{}\":\"\",\"True\":\"None\"},{\"0\":\" \",\"{}\":\"\",\"True\":\"\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"0\":\"Value4\",\"{}\":\"Value6\",\"True\":\"Value5\"},{\"0\":\"\",\"{}\":\"\",\"True\":\"\"},{\"0\":\"true\",\"{}\":\"'HelloWorld'\",\"True\":\"1\"},{\"0\":\"Null\",\"{}\":\"\",\"True\":\"None\"},{\"0\":\" \",\"{}\":\"\",\"True\":\"\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_first_row_extra_fields": { + "recorded-date": "21-09-2023, 14:27:49", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"\":\"Value5\",\"H1\":\"Value4\"},{\"\":\"\",\"H1\":\"\"},{\"\":\"1\",\"H1\":\"true\"},{\"\":\"None\",\"H1\":\"Null\"},{\"\":\"\",\"H1\":\" \"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"\":\"Value5\",\"H1\":\"Value4\"},{\"\":\"\",\"H1\":\"\"},{\"\":\"1\",\"H1\":\"true\"},{\"\":\"None\",\"H1\":\"Null\"},{\"\":\"\",\"H1\":\" \"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_json_no_json_list_object": { + "recorded-date": "21-09-2023, 14:30:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "mapRunFailedEventDetails": { + "cause": "Attempting to map over non-iterable node.", + "error": "States.ItemReaderFailed" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Attempting to map over non-iterable node.", + "error": "States.ItemReaderFailed" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke_with_retry_base": { + "recorded-date": "24-10-2023, 12:52:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambdaWithRetry" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 8, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 9, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 11, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 12, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 13, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 14, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "InvokeLambdaWithRetry", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 17, + "previousEventId": 16, + "stateExitedEventDetails": { + "name": "HandleError", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke_with_retry_extended_input": { + "recorded-date": "24-10-2023, 17:18:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambdaWithRetry" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 0 + } + }, + "ThisState": { + "Value1": "HelloWorld!", + "Value2": null + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "lambdaFunctionScheduledEventDetails": { + "input": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 1 + } + }, + "ThisState": { + "Value1": "HelloWorld!", + "Value2": null + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 8, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 9, + "lambdaFunctionScheduledEventDetails": { + "input": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 2 + } + }, + "ThisState": { + "Value1": "HelloWorld!", + "Value2": null + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 11, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 12, + "lambdaFunctionScheduledEventDetails": { + "input": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 3 + } + }, + "ThisState": { + "Value1": "HelloWorld!", + "Value2": null + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 13, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 14, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "InvokeLambdaWithRetry", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 17, + "previousEventId": 16, + "stateExitedEventDetails": { + "name": "HandleError", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\": \"Some exception was raised.\", \"errorType\": \"Exception\", \"requestId\": \"\", \"stackTrace\": [\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke_with_retry_extended_input": { + "recorded-date": "24-10-2023, 17:26:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + }, + "inputDetails": { + "truncated": false + }, + "name": "InvokeLambdaWithRetry" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 0 + } + }, + "ThisState": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + } + }, + "FunctionName": "" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 1 + } + }, + "ThisState": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + } + }, + "FunctionName": "" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 2 + } + }, + "ThisState": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + } + }, + "FunctionName": "" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 11, + "previousEventId": 10, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 12, + "previousEventId": 11, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "ThisContextObject": { + "Execution": { + "Id": "arn::states::111111111111:execution::", + "Input": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + }, + "StartTime": "", + "Name": "", + "RoleArn": "snf_role_arn" + }, + "StateMachine": { + "Id": "arn::states::111111111111:stateMachine:", + "Name": "" + }, + "State": { + "Name": "InvokeLambdaWithRetry", + "EnteredTime": "", + "RetryCount": 3 + } + }, + "ThisState": { + "FunctionName": "", + "Value1": "HelloWorld!", + "Value2": null + } + }, + "FunctionName": "" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 13, + "previousEventId": 12, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 14, + "previousEventId": 13, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "InvokeLambdaWithRetry", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleError" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 17, + "previousEventId": 16, + "stateExitedEventDetails": { + "name": "HandleError", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path": { + "recorded-date": "31-10-2023, 18:57:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitUntil", + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp": { + "recorded-date": "31-10-2023, 19:01:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitUntil", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_empty_retry": { + "recorded-date": "23-11-2023, 18:08:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "LambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite": { + "recorded-date": "23-11-2023, 18:28:31", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceStateX" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceStateX", + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "Public" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "Public", + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_catch_states_runtime": { + "recorded-date": "23-11-2023, 21:56:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "RaiseRuntime" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'RaiseRuntime' (entered at the event id #2). Invalid path '$.no.such.result.path' : Missing property in path $['no']", + "error": "States.Runtime" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_catch_empty": { + "recorded-date": "24-11-2023, 08:01:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "StartTask", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Final", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_no_processor_config": { + "recorded-date": "15-12-2023, 22:25:27", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "arr": [ + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "arr": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_order": { + "recorded-date": "15-12-2023, 23:15:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch2" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch3" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch0", + "output": { + "branch": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch1", + "output": { + "branch": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch2", + "output": { + "branch": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch3", + "output": { + "branch": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "StartState", + "output": "[{\"branch\":0},{\"branch\":1},{\"branch\":2},{\"branch\":3}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"branch\":0},{\"branch\":1},{\"branch\":2},{\"branch\":3}]", + "outputDetails": { + "truncated": false + } + }, + "id": 14, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_fail": { + "recorded-date": "16-12-2023, 18:52:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ParallelStateFailed" + }, + { + "executionFailedEventDetails": { + "error": "FailureType" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_catch": { + "recorded-date": "16-12-2023, 21:45:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ParallelStateFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ParallelState", + "output": { + "Error": "FailureType", + "Cause": "Cause description" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "FailureType", + "Cause": "Cause description" + }, + "inputDetails": { + "truncated": false + }, + "name": "FinalCaught" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "FinalCaught", + "output": { + "Error": "FailureType", + "Cause": "Cause description" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "FailureType", + "Cause": "Cause description" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_retry": { + "recorded-date": "16-12-2023, 21:58:47", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ParallelStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Cause description", + "error": "FailureType" + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline": { + "recorded-date": "08-02-2024, 11:26:05", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "\"Hello\"", + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": "\"Hello\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "\"World\"", + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": "\"World\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 13, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": "[\"Hello\",\"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed": { + "recorded-date": "08-02-2024, 11:27:15", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": "[\"Hello\",\"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed_parameters": { + "recorded-date": "08-02-2024, 20:22:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed_item_selector": { + "recorded-date": "08-02-2024, 20:28:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline_parameters": { + "recorded-date": "08-02-2024, 21:07:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": { + "constantValue": "constant", + "mapValue": "Hello", + "fromInput": [ + "Hello", + "World" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": { + "constantValue": "constant", + "mapValue": "Hello", + "fromInput": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": { + "constantValue": "constant", + "mapValue": "World", + "fromInput": [ + "Hello", + "World" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": { + "constantValue": "constant", + "mapValue": "World", + "fromInput": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 13, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline_item_selector": { + "recorded-date": "08-02-2024, 21:08:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": { + "constantValue": "constant", + "mapValue": "Hello", + "fromInput": [ + "Hello", + "World" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": { + "constantValue": "constant", + "mapValue": "Hello", + "fromInput": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": { + "constantValue": "constant", + "mapValue": "World", + "fromInput": [ + "Hello", + "World" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": { + "constantValue": "constant", + "mapValue": "World", + "fromInput": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 13, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_item_selector": { + "recorded-date": "08-02-2024, 21:44:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_parameters": { + "recorded-date": "08-02-2024, 21:44:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_inline_parameters": { + "recorded-date": "08-02-2024, 21:46:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": { + "constantValue": "constant", + "mapValue": "Hello", + "fromInput": [ + "Hello", + "World" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": { + "constantValue": "constant", + "mapValue": "Hello", + "fromInput": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": { + "constantValue": "constant", + "mapValue": "World", + "fromInput": [ + "Hello", + "World" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "IteratorInner" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "IteratorInner", + "output": { + "constantValue": "constant", + "mapValue": "World", + "fromInput": [ + "Hello", + "World" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 13, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_inline_item_selector": { + "recorded-date": "08-02-2024, 21:47:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"constantValue\":\"constant\",\"mapValue\":\"Hello\",\"fromInput\":[\"Hello\",\"World\"]},{\"constantValue\":\"constant\",\"mapValue\":\"World\",\"fromInput\":[\"Hello\",\"World\"]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features": { + "recorded-date": "23-02-2024, 08:36:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "LambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 8, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 9, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 11, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features_jitter_none": { + "recorded-date": "23-02-2024, 08:37:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "LambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 6, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 8, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "id": 9, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 11, + "lambdaFunctionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "LambdaFunctionFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[0]": { + "recorded-date": "25-03-2024, 15:36:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[2]": { + "recorded-date": "25-03-2024, 15:37:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[100000000]": { + "recorded-date": "25-03-2024, 15:37:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[-1]": { + "recorded-date": "25-03-2024, 17:38:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": -1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": -1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "mapRunFailedEventDetails": { + "cause": "field MaxItems must be positive", + "error": "States.ItemReaderFailed" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "field MaxItems must be positive", + "error": "States.ItemReaderFailed" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[0]": { + "recorded-date": "25-03-2024, 17:39:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[2]": { + "recorded-date": "25-03-2024, 17:39:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 2 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[1.5]": { + "recorded-date": "25-03-2024, 17:40:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 1.5 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 1.5 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). The MaxItemsPath field refers to value \"1.5\" which is not a valid integer: $.MaxItems", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[100000000]": { + "recorded-date": "25-03-2024, 17:40:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 100000000 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 100000000 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[100000001]": { + "recorded-date": "25-03-2024, 17:40:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 100000001 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.csv", + "MaxItems": 100000001 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Col1\":\"Value1\",\"Col2\":\"Value2\"},{\"Col1\":\"Value3\",\"Col2\":\"Value4\"},{\"Col1\":\"Value5\",\"Col2\":\"Value6\"},{\"Col1\":\"Value7\",\"Col2\":\"Value8\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_max_items": { + "recorded-date": "25-03-2024, 18:19:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features_max_attempts_zero": { + "recorded-date": "27-03-2024, 09:30:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "lambda_function_name" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "lambda_function_name" + }, + "inputDetails": { + "truncated": false + }, + "name": "LambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Payload": { + "RetryAttempt": 0 + }, + "FunctionName": "lambda_function_name" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested": { + "recorded-date": "29-03-2024, 16:26:02", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[[1, 2, 3], [4, 5, 6]]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[[1, 2, 3], [4, 5, 6]]", + "inputDetails": { + "truncated": false + }, + "name": "MapL1" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapL1" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "[1,2,3]", + "inputDetails": { + "truncated": false + }, + "name": "MapL2" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 6, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 7, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapL2" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "MapL2Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "MapL2Pass", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 10, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapL2" + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 11, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapL2" + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "MapL2Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "stateExitedEventDetails": { + "name": "MapL2Pass", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 14, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapL2" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 15, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapL2" + }, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "MapL2Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 17, + "previousEventId": 16, + "stateExitedEventDetails": { + "name": "MapL2Pass", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 18, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapL2" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 19, + "previousEventId": 18, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 20, + "previousEventId": 18, + "stateExitedEventDetails": { + "name": "MapL2", + "output": "[1,2,3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapL1" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapL1" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": "[4,5,6]", + "inputDetails": { + "truncated": false + }, + "name": "MapL2" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 24, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 23, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 25, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapL2" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 26, + "previousEventId": 25, + "stateEnteredEventDetails": { + "input": "4", + "inputDetails": { + "truncated": false + }, + "name": "MapL2Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 27, + "previousEventId": 26, + "stateExitedEventDetails": { + "name": "MapL2Pass", + "output": "4", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 28, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapL2" + }, + "previousEventId": 27, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 29, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapL2" + }, + "previousEventId": 27, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 30, + "previousEventId": 29, + "stateEnteredEventDetails": { + "input": "5", + "inputDetails": { + "truncated": false + }, + "name": "MapL2Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 31, + "previousEventId": 30, + "stateExitedEventDetails": { + "name": "MapL2Pass", + "output": "5", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 32, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapL2" + }, + "previousEventId": 31, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 33, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapL2" + }, + "previousEventId": 31, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 34, + "previousEventId": 33, + "stateEnteredEventDetails": { + "input": "6", + "inputDetails": { + "truncated": false + }, + "name": "MapL2Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 35, + "previousEventId": 34, + "stateExitedEventDetails": { + "name": "MapL2Pass", + "output": "6", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 36, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapL2" + }, + "previousEventId": 35, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 37, + "previousEventId": 36, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 38, + "previousEventId": 36, + "stateExitedEventDetails": { + "name": "MapL2", + "output": "[4,5,6]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 39, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapL1" + }, + "previousEventId": 38, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 40, + "previousEventId": 39, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 41, + "previousEventId": 39, + "stateExitedEventDetails": { + "name": "MapL1", + "output": "[[1,2,3],[4,5,6]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[1,2,3],[4,5,6]]", + "outputDetails": { + "truncated": false + } + }, + "id": 42, + "previousEventId": 41, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_nested": { + "recorded-date": "29-03-2024, 17:05:02", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[[1, 2, 3], [4, 5, 6]]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[[1, 2, 3], [4, 5, 6]]", + "inputDetails": { + "truncated": false + }, + "name": "ParallelStateL1" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[[1, 2, 3], [4, 5, 6]]", + "inputDetails": { + "truncated": false + }, + "name": "ParallelStateL2" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[[1, 2, 3], [4, 5, 6]]", + "inputDetails": { + "truncated": false + }, + "name": "BranchL2" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "BranchL2", + "output": "[[1, 2, 3], [4, 5, 6]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "ParallelStateL1", + "output": "[[[[1, 2, 3], [4, 5, 6]]]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[[[1, 2, 3], [4, 5, 6]]]]", + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[max_concurrency_value0]": { + "recorded-date": "19-06-2024, 13:49:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "MaxConcurrencyValue": {}, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "MaxConcurrencyValue": {}, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). The MaxConcurrencyPath field refers to value \"{}\" which is not a valid integer: $.MaxConcurrencyValue", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[0]": { + "recorded-date": "19-06-2024, 13:49:47", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "MaxConcurrencyValue": 0, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "MaxConcurrencyValue": 0, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": "[\"HelloWorld\"]", + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "Final", + "output": "[\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[1]": { + "recorded-date": "19-06-2024, 13:50:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "MaxConcurrencyValue": 1, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "MaxConcurrencyValue": 1, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": "[\"HelloWorld\"]", + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "Final", + "output": "[\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[\"HelloWorld\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path_negative": { + "recorded-date": "22-04-2024, 12:40:52", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "MaxConcurrencyValue": -1, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[NoNumber]": { + "recorded-date": "19-06-2024, 13:49:33", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "MaxConcurrencyValue": "NoNumber", + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "MaxConcurrencyValue": "NoNumber", + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). The MaxConcurrencyPath field refers to value \"NoNumber\" which is not a valid integer: $.MaxConcurrencyValue", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_reentrant": { + "recorded-date": "03-05-2024, 13:45:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "Iterations": 3, + "Values": [ + 0, + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Iterations": 3, + "Values": [ + 0, + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 7, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 8, + "mapRunStartedEventDetails": { + "mapRunArn": "map_run_arn" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 11, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 17, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 18, + "mapRunStartedEventDetails": { + "mapRunArn": "map_run_arn" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 19, + "previousEventId": 18, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 21, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 22, + "previousEventId": 21, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 23, + "previousEventId": 22, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 24, + "previousEventId": 23, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 25, + "previousEventId": 24, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 26, + "previousEventId": 25, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 27, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 26, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 28, + "mapRunStartedEventDetails": { + "mapRunArn": "map_run_arn" + }, + "previousEventId": 27, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 29, + "previousEventId": 28, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 30, + "previousEventId": 29, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 31, + "previousEventId": 29, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 32, + "previousEventId": 31, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 33, + "previousEventId": 32, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 34, + "previousEventId": 33, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "Terminate" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 35, + "previousEventId": 34, + "stateExitedEventDetails": { + "name": "Terminate", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 36, + "previousEventId": 35, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_reentrant_lambda": { + "recorded-date": "03-05-2024, 13:44:53", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "Iterations": 2, + "Values": [ + "HelloWorld" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Iterations": 2, + "Values": [ + "HelloWorld" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": { + "Values": [ + "HelloWorld" + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 7, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 8, + "mapRunStartedEventDetails": { + "mapRunArn": "map_run_arn" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 11, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": { + "Values": [ + "HelloWorld" + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": { + "Values": [ + "HelloWorld" + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 16, + "previousEventId": 15, + "stateEnteredEventDetails": { + "input": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 17, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 18, + "mapRunStartedEventDetails": { + "mapRunArn": "map_run_arn" + }, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 19, + "previousEventId": 18, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 21, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 22, + "previousEventId": 21, + "stateEnteredEventDetails": { + "input": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 23, + "previousEventId": 22, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 24, + "previousEventId": 23, + "stateEnteredEventDetails": { + "input": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "Terminate" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 25, + "previousEventId": 24, + "stateExitedEventDetails": { + "name": "Terminate", + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Values": [ + "HelloWorld" + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 26, + "previousEventId": 25, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_reentrant": { + "recorded-date": "03-05-2024, 13:41:47", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "Iterations": 3, + "Values": [ + 0, + 1, + 2, + 3 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Iterations": 3, + "Values": [ + 0, + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 7, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "IterationBody" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "IterationBody" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "IterationBody" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 15, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "IterationBody" + }, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 16, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "IterationBody" + }, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 17, + "previousEventId": 16, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 18, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 19, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "IterationBody" + }, + "previousEventId": 18, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 20, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "IterationBody" + }, + "previousEventId": 18, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 21, + "previousEventId": 20, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 22, + "previousEventId": 21, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 23, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "IterationBody" + }, + "previousEventId": 22, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 24, + "previousEventId": 23, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 25, + "previousEventId": 23, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 26, + "previousEventId": 25, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 27, + "previousEventId": 26, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 28, + "previousEventId": 27, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 29, + "previousEventId": 28, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 30, + "previousEventId": 29, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 31, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 30, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 32, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "IterationBody" + }, + "previousEventId": 31, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 33, + "previousEventId": 32, + "stateEnteredEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 34, + "previousEventId": 33, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 35, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "IterationBody" + }, + "previousEventId": 34, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 36, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "IterationBody" + }, + "previousEventId": 34, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 37, + "previousEventId": 36, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 38, + "previousEventId": 37, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 39, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "IterationBody" + }, + "previousEventId": 38, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 40, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "IterationBody" + }, + "previousEventId": 38, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 41, + "previousEventId": 40, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 42, + "previousEventId": 41, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 43, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "IterationBody" + }, + "previousEventId": 42, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 44, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "IterationBody" + }, + "previousEventId": 42, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 45, + "previousEventId": 44, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 46, + "previousEventId": 45, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 47, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "IterationBody" + }, + "previousEventId": 46, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 48, + "previousEventId": 47, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 49, + "previousEventId": 47, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 50, + "previousEventId": 49, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 51, + "previousEventId": 50, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 52, + "previousEventId": 51, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "BeforeIteration" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 53, + "previousEventId": 52, + "stateExitedEventDetails": { + "name": "BeforeIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 54, + "previousEventId": 53, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "IterationBody" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 55, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 54, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 56, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "IterationBody" + }, + "previousEventId": 55, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 57, + "previousEventId": 56, + "stateEnteredEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 58, + "previousEventId": 57, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 59, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "IterationBody" + }, + "previousEventId": 58, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 60, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "IterationBody" + }, + "previousEventId": 58, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 61, + "previousEventId": 60, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 62, + "previousEventId": 61, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 63, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "IterationBody" + }, + "previousEventId": 62, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 64, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "IterationBody" + }, + "previousEventId": 62, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 65, + "previousEventId": 64, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 66, + "previousEventId": 65, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "2", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 67, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "IterationBody" + }, + "previousEventId": 66, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 68, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "IterationBody" + }, + "previousEventId": 66, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 69, + "previousEventId": 68, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "ProcessValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 70, + "previousEventId": 69, + "stateExitedEventDetails": { + "name": "ProcessValue", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 71, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "IterationBody" + }, + "previousEventId": 70, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 72, + "previousEventId": 71, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 73, + "previousEventId": 71, + "stateExitedEventDetails": { + "name": "IterationBody", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 74, + "previousEventId": 73, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckIteration" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 75, + "previousEventId": 74, + "stateExitedEventDetails": { + "name": "CheckIteration", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 76, + "previousEventId": 75, + "stateEnteredEventDetails": { + "input": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "Terminate" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 77, + "previousEventId": 76, + "stateExitedEventDetails": { + "name": "Terminate", + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Values": [ + 0, + 1, + 2, + 3 + ], + "Iterations": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 78, + "previousEventId": 77, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_values[count_literal]": { + "recorded-date": "19-06-2024, 12:31:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[0]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[0]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_values[percentage_literal]": { + "recorded-date": "19-06-2024, 12:31:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[0]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[0]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[tolerated_failure_count_value0]": { + "recorded-date": "19-06-2024, 12:38:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": {} + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": {} + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). The ToleratedFailureCountPath field refers to value \"{}\" which is not a valid integer: $.ToleratedFailureCount", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[NoNumber]": { + "recorded-date": "19-06-2024, 12:38:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": "NoNumber" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": "NoNumber" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). The ToleratedFailureCountPath field refers to value \"NoNumber\" which is not a valid integer: $.ToleratedFailureCount", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[-1]": { + "recorded-date": "19-06-2024, 12:38:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": -1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": -1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). ToleratedFailureCount cannot be negative.", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[0]": { + "recorded-date": "19-06-2024, 12:38:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[1]": { + "recorded-date": "19-06-2024, 12:39:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailureCount": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[tolerated_failure_percentage_value0]": { + "recorded-date": "19-06-2024, 14:36:53", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": {} + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": {} + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). The ToleratedFailurePercentagePath field refers to value \"{}\" which is not a valid float: $.ToleratedFailurePercentage", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[NoNumber]": { + "recorded-date": "19-06-2024, 14:37:07", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": "NoNumber" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": "NoNumber" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). The ToleratedFailurePercentagePath field refers to value \"NoNumber\" which is not a valid float: $.ToleratedFailurePercentage", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[-1.1]": { + "recorded-date": "19-06-2024, 14:37:21", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": -1.1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": -1.1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). ToleratedFailurePercentage must be between 0 and 100.", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[-1]": { + "recorded-date": "19-06-2024, 14:37:35", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": -1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": -1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). ToleratedFailurePercentage must be between 0 and 100.", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[0]": { + "recorded-date": "19-06-2024, 14:37:51", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[1]": { + "recorded-date": "19-06-2024, 14:38:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[1.1]": { + "recorded-date": "19-06-2024, 14:38:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 1.1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 1.1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[100]": { + "recorded-date": "19-06-2024, 14:38:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 100 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 100 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[100.1]": { + "recorded-date": "19-06-2024, 14:38:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 100.1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Items": [ + 0 + ], + "ToleratedFailurePercentage": 100.1 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapState' (entered at the event id #2). ToleratedFailurePercentage must be between 0 and 100.", + "error": "States.Runtime" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_label": { + "recorded-date": "20-07-2024, 08:30:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\",\"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/TestMap:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[\"Hello\",\"World\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state[PARALLEL_STATE]": { + "recorded-date": "12-09-2024, 20:48:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "LoadInput" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "LoadInput", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch21" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch22" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch31" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch32" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[1,2,3,4]", + "inputDetails": { + "truncated": false + }, + "name": "Branch33" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch1", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch21", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch22", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch31", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch32", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch33", + "output": "[1,2,3,4]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "ParallelState", + "output": "[[1,2,3,4],[1,2,3,4],[1,2,3,4]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[1,2,3,4],[1,2,3,4],[1,2,3,4]]", + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state[PARALLEL_STATE_PARAMETERS]": { + "recorded-date": "12-09-2024, 20:49:10", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "TestState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "static-input-0": 0, + "static-input-1": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Branch0State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "static-input-0": 0, + "static-input-1": [ + 1 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Branch1State0" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch0State0", + "output": { + "static-input-0": 0, + "static-input-1": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateExitedEventDetails": { + "name": "Branch1State0", + "output": { + "static-input-0": 0, + "static-input-1": [ + 1 + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "TestState", + "output": "[{\"static-input-0\":0,\"static-input-1\":[1]},{\"static-input-0\":0,\"static-input-1\":[1]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"static-input-0\":0,\"static-input-1\":[1]},{\"static-input-0\":0,\"static-input-1\":[1]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_result_writer": { + "recorded-date": "19-08-2024, 08:59:42", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[\"Hello\", \"World\"]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[\"Hello\",\"World\"]", + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/TestMap:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "MapRunArn": "arn::states::111111111111:mapRun:/TestMap:", + "ResultWriterDetails": { + "Bucket": "result-bucket", + "Key": "mapJobs//manifest.json" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MapRunArn": "arn::states::111111111111:mapRun:/TestMap:", + "ResultWriterDetails": { + "Bucket": "result-bucket", + "Key": "mapJobs//manifest.json" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_max_items_jsonata": { + "recorded-date": "13-11-2024, 15:09:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "MaxItems": 2 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "MaxItems": 2 + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_batching_base_json_max_per_batch_jsonata": { + "recorded-date": "13-11-2024, 15:21:53", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "MaxItemsPerBatch": 2, + "MaxInputBytesPerBatch": 150000 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "MaxItemsPerBatch": 2, + "MaxInputBytesPerBatch": 150000 + }, + "inputDetails": { + "truncated": false + }, + "name": "BatchMapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "BatchMapState", + "output": "[{\"BatchInput\":{\"BatchTimestamp\":\"date\"},\"Items\":[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"BatchInput\":{\"BatchTimestamp\":\"date\"},\"Items\":[{\"verdict\":\"true\",\"statement_date\":\"6/11/2008\",\"statement_source\":\"speech\"},{\"verdict\":\"false\",\"statement_date\":\"6/7/2022\",\"statement_source\":\"television\"}]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata": { + "recorded-date": "13-11-2024, 16:19:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_seconds_jsonata": { + "recorded-date": "13-11-2024, 16:20:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "waitSeconds": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "waitSeconds": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "waitSeconds": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "waitSeconds": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_fail_error_jsonata": { + "recorded-date": "13-11-2024, 16:35:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "error": "Exception" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "error": "Exception" + }, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "error": "Exception" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_fail_cause_jsonata": { + "recorded-date": "13-11-2024, 16:36:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "cause": "This failed to due an Exception." + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "cause": "This failed to due an Exception." + }, + "inputDetails": { + "truncated": false + }, + "name": "FailState" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "This failed to due an Exception." + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_aws_docs_scenario[CHOICE_STATE_AWS_SCENARIO]": { + "recorded-date": "18-11-2024, 11:14:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceStateX" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceStateX", + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ValueInTwenties" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "ValueInTwenties", + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_aws_docs_scenario[CHOICE_STATE_AWS_SCENARIO_JSONATA]": { + "recorded-date": "18-11-2024, 11:14:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceStateX" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceStateX", + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ValueInTwenties" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "ValueInTwenties", + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Private", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE]": { + "recorded-date": "24-12-2024, 16:59:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceStateX" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceStateX", + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "Public" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "Public", + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE_JSONATA]": { + "recorded-date": "24-12-2024, 17:00:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceState", + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "Public" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "Public", + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Public", + "value": 22 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_positive[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS]": { + "recorded-date": "18-11-2024, 11:30:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckResult" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "CheckResult", + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "name": "FinishTrue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinishTrue", + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_positive[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": { + "recorded-date": "18-11-2024, 11:30:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckResult" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "CheckResult", + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": true + } + }, + "inputDetails": { + "truncated": false + }, + "name": "FinishTrue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinishTrue", + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "done": true + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_negative[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS]": { + "recorded-date": "18-11-2024, 11:32:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckResult" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "CheckResult", + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "name": "FinishFalse" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinishFalse", + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_negative[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": { + "recorded-date": "18-11-2024, 11:32:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckResult" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "CheckResult", + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result": { + "done": false + } + }, + "inputDetails": { + "truncated": false + }, + "name": "FinishFalse" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "FinishFalse", + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "done": false + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_condition_constant_jsonata": { + "recorded-date": "18-11-2024, 12:19:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ChoiceState" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "ChoiceState", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ConditionTrue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "ConditionTrue", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_item_selector_parameters": { + "recorded-date": "15-11-2024, 13:56:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "content": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "content": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"bucketName\":\"test-bucket\",\"value\":\"1\"},{\"bucketName\":\"test-bucket\",\"value\":\"2\"},{\"bucketName\":\"test-bucket\",\"value\":\"3\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": "[{\"bucketName\":\"test-bucket\",\"value\":\"1\"},{\"bucketName\":\"test-bucket\",\"value\":\"2\"},{\"bucketName\":\"test-bucket\",\"value\":\"3\"}]", + "inputDetails": { + "truncated": false + }, + "name": "Finish" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "Finish", + "output": "[{\"bucketName\":\"test-bucket\",\"value\":\"1\"},{\"bucketName\":\"test-bucket\",\"value\":\"2\"},{\"bucketName\":\"test-bucket\",\"value\":\"3\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"bucketName\":\"test-bucket\",\"value\":\"1\"},{\"bucketName\":\"test-bucket\",\"value\":\"2\"},{\"bucketName\":\"test-bucket\",\"value\":\"3\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector_parameters": { + "recorded-date": "15-11-2024, 11:20:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "content": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "content": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "bucketName": "test-bucket", + "value": "1" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndState", + "output": { + "message": "Processing item completed" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "bucketName": "test-bucket", + "value": "2" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "EndState", + "output": { + "message": "Processing item completed" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "bucketName": "test-bucket", + "value": "3" + }, + "inputDetails": { + "truncated": false + }, + "name": "EndState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "EndState", + "output": { + "message": "Processing item completed" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "content": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "content": { + "bucket": "test-bucket", + "values": [ + "1", + "2", + "3" + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[number]": { + "recorded-date": "19-11-2024, 13:10:55", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression '1' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'number' for value: 1", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression '1' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'number' for value: 1", + "error": "States.QueryEvaluationError" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[string]": { + "recorded-date": "19-11-2024, 13:11:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression ''string'' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'string' for value: \"string\"", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression ''string'' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'string' for value: \"string\"", + "error": "States.QueryEvaluationError" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[boolean]": { + "recorded-date": "19-11-2024, 13:11:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression 'true' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'boolean' for value: true", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression 'true' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'boolean' for value: true", + "error": "States.QueryEvaluationError" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[object]": { + "recorded-date": "19-11-2024, 13:15:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression '{'foo': 'bar'}' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'object' for value: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression '{'foo': 'bar'}' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'object' for value: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[null]": { + "recorded-date": "19-11-2024, 13:11:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression 'null' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'null' for value: null", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression 'null' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'null' for value: null", + "error": "States.QueryEvaluationError" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[number]": { + "recorded-date": "19-11-2024, 13:22:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "1" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'number' for value: 1", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'number' for value: 1", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[string]": { + "recorded-date": "19-11-2024, 13:22:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "\"string\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'string' for value: \"string\"", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'string' for value: \"string\"", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[boolean]": { + "recorded-date": "19-11-2024, 13:22:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "true" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'boolean' for value: true", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'boolean' for value: true", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[object]": { + "recorded-date": "19-11-2024, 13:23:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": { + "foo": "bar" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'object' for value: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'object' for value: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[null]": { + "recorded-date": "19-11-2024, 13:23:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "null" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'null' for value: null", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'null' for value: null", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[number]": { + "recorded-date": "19-11-2024, 13:24:50", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": 1 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": 1 + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: 1", + "error": "States.QueryEvaluationError", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: 1", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[string]": { + "recorded-date": "19-11-2024, 13:25:07", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": "string" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": "string" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": "\"string\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "\"string\"", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: \"string\"", + "error": "States.QueryEvaluationError", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: \"string\"", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[boolean]": { + "recorded-date": "19-11-2024, 13:25:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": true + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": true + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "true", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: true", + "error": "States.QueryEvaluationError", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: true", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[object]": { + "recorded-date": "19-11-2024, 13:25:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": { + "foo": "bar" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": { + "foo": "bar" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "foo": "bar" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "foo": "bar" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[null]": { + "recorded-date": "19-11-2024, 13:25:52", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "null", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: null", + "error": "States.QueryEvaluationError", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). Map state input must be an array but was: null", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[empty]": { + "recorded-date": "19-11-2024, 13:28:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [] + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "[]", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 7, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[singleton]": { + "recorded-date": "19-11-2024, 13:28:34", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [ + 0 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [ + 0 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "[0]", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Pass", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 11, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[mixed]": { + "recorded-date": "19-11-2024, 13:28:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [ + 1, + "two", + true + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [ + 1, + "two", + true + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Start", + "output": "[1,\"two\",true]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "[1,\"two\",true]", + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Pass", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": "\"two\"", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "Pass", + "output": "\"two\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": "true", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "Pass", + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 19, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": "[1,\"two\",true]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,\"two\",true]", + "outputDetails": { + "truncated": false + } + }, + "id": 20, + "previousEventId": 19, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[number]": { + "recorded-date": "19-11-2024, 13:46:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "1" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'number' for value: 1", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'number' for value: 1", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[string]": { + "recorded-date": "19-11-2024, 13:47:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "\"string\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'string' for value: \"string\"", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'string' for value: \"string\"", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[boolean]": { + "recorded-date": "19-11-2024, 13:47:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "true" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'boolean' for value: true", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'boolean' for value: true", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[object]": { + "recorded-date": "19-11-2024, 13:47:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": { + "foo": "bar" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'object' for value: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'object' for value: {\"foo\":\"bar\"}", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[null]": { + "recorded-date": "19-11-2024, 13:47:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "null" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'null' for value: null", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 5, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #4). The JSONata expression '$ItemsVar' specified for the field 'Items' returned an unexpected result type. Expected 'array', but was 'null' for value: null", + "error": "States.QueryEvaluationError" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[function]": { + "recorded-date": "19-11-2024, 16:31:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression '$fn := function($x){$x}' specified for the field 'Items' returned an unsupported result type.", + "error": "States.QueryEvaluationError", + "location": "Items", + "state": "MapIterateState" + }, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'MapIterateState' (entered at the event id #2). The JSONata expression '$fn := function($x){$x}' specified for the field 'Items' returned an unsupported result type.", + "error": "States.QueryEvaluationError" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[empty]": { + "recorded-date": "20-11-2024, 16:11:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 5, + "previousEventId": 3, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[singleton]": { + "recorded-date": "20-11-2024, 16:11:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Pass", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[0]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[mixed]": { + "recorded-date": "20-11-2024, 16:12:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Pass", + "output": "1", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 7, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 8, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": "\"two\"", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "Pass", + "output": "\"two\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 11, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 12, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 14, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "Pass", + "output": "3", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 15, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapIterateState" + }, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 17, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": "[1,\"two\",3]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[1,\"two\",3]", + "outputDetails": { + "truncated": false + } + }, + "id": 18, + "previousEventId": 17, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested_config_distributed": { + "recorded-date": "13-12-2024, 13:38:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "SetupState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "SetupState", + "output": { + "values": [ + { + "sub-values": [ + { + "num": 1, + "str": "A" + }, + { + "num": 2, + "str": "B" + } + ] + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "values": [ + { + "sub-values": [ + { + "num": 1, + "str": "A" + }, + { + "num": 2, + "str": "B" + } + ] + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[{\"sub-values\":[{\"num\":1,\"str\":\"A\"},{\"num\":2,\"str\":\"B\"}],\"result\":[{\"num\":1,\"str\":\"A\"},{\"num\":2,\"str\":\"B\"}]}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"sub-values\":[{\"num\":1,\"str\":\"A\"},{\"num\":2,\"str\":\"B\"}],\"result\":[{\"num\":1,\"str\":\"A\"},{\"num\":2,\"str\":\"B\"}]}]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE_LITERAL_JSONATA]": { + "recorded-date": "24-12-2024, 17:00:22", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 22 + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "str_value": "string literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "str_value": "string literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "Choice" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "Choice", + "output": { + "str_value": "string literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 6, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": { + "str_value": "string literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "Success" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "Success", + "output": { + "str_value": "string literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "str_value": "string literal" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[SECONDS]": { + "recorded-date": "27-12-2024, 09:57:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NANOSECONDS]": { + "recorded-date": "27-12-2024, 09:57:10", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitState", + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NO_T]": { + "recorded-date": "27-12-2024, 09:57:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-12-05 21:29:29Z", + "error": "States.QueryEvaluationError", + "location": "Timestamp", + "state": "WaitState" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-12-05 21:29:29Z", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NO_Z]": { + "recorded-date": "27-12-2024, 09:57:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-12-05T21:29:29", + "error": "States.QueryEvaluationError", + "location": "Timestamp", + "state": "WaitState" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-12-05T21:29:29", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_DATE]": { + "recorded-date": "27-12-2024, 09:57:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-13-05T21:29:29Z", + "error": "States.QueryEvaluationError", + "location": "Timestamp", + "state": "WaitState" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-13-05T21:29:29Z", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_TIME]": { + "recorded-date": "27-12-2024, 09:58:11", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-12-05T25:29:29Z", + "error": "States.QueryEvaluationError", + "location": "Timestamp", + "state": "WaitState" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 2016-12-05T25:29:29Z", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_ISO]": { + "recorded-date": "27-12-2024, 09:58:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitState" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 05-12-2016T21:29:29Z", + "error": "States.QueryEvaluationError", + "location": "Timestamp", + "state": "WaitState" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitState' (entered at the event id #2). The Timestamp field cannot be parsed as an ISO-8601 extended offset date-time format string: 05-12-2016T21:29:29Z", + "error": "States.QueryEvaluationError" + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[SECONDS]": { + "recorded-date": "27-12-2024, 10:02:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitUntil", + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NANOSECONDS]": { + "recorded-date": "27-12-2024, 10:02:43", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "date" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitUntil", + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TimestampValue": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NO_T]": { + "recorded-date": "27-12-2024, 10:02:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitUntil' (entered at the event id #2). The TimestampPath parameter does not reference a valid ISO-8601 extended offset date-time format string: $.TimestampValue == 2016-12-05 21:29:29Z", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NO_Z]": { + "recorded-date": "27-12-2024, 10:03:13", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitUntil' (entered at the event id #2). The TimestampPath parameter does not reference a valid ISO-8601 extended offset date-time format string: $.TimestampValue == 2016-12-05T21:29:29", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_DATE]": { + "recorded-date": "27-12-2024, 10:03:33", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitUntil' (entered at the event id #2). The TimestampPath parameter does not reference a valid ISO-8601 extended offset date-time format string: $.TimestampValue == 2016-13-05T21:29:29Z", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_TIME]": { + "recorded-date": "27-12-2024, 10:03:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitUntil' (entered at the event id #2). The TimestampPath parameter does not reference a valid ISO-8601 extended offset date-time format string: $.TimestampValue == 2016-12-05T25:29:29Z", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_ISO]": { + "recorded-date": "27-12-2024, 10:04:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimestampValue": "timestamp" + }, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'WaitUntil' (entered at the event id #2). The TimestampPath parameter does not reference a valid ISO-8601 extended offset date-time format string: $.TimestampValue == 05-12-2016T21:29:29Z", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp[SECONDS]": { + "recorded-date": "27-12-2024, 10:04:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitUntil", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp[NANOSECONDS]": { + "recorded-date": "27-12-2024, 10:04:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "WaitUntil" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "WaitUntil", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "WaitStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[NO_T]": { + "recorded-date": "27-12-2024, 10:12:33", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: String does not match RFC3339 timestamp at /States/WaitUntil/Timestamp'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[NO_Z]": { + "recorded-date": "27-12-2024, 10:12:47", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: String does not match RFC3339 timestamp at /States/WaitUntil/Timestamp'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_DATE]": { + "recorded-date": "27-12-2024, 10:13:01", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: String does not match RFC3339 timestamp at /States/WaitUntil/Timestamp'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_TIME]": { + "recorded-date": "27-12-2024, 10:13:15", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: String does not match RFC3339 timestamp at /States/WaitUntil/Timestamp'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_ISO]": { + "recorded-date": "27-12-2024, 10:13:29", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: String does not match RFC3339 timestamp at /States/WaitUntil/Timestamp'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[JSONATA]": { + "recorded-date": "27-12-2024, 10:13:43", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: String does not match RFC3339 timestamp at /States/WaitUntil/Timestamp'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_ERRORPATH]": { + "recorded-date": "02-01-2025, 13:44:29", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "pass", + "output": { + "Error": "error-value" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Error": "error-value" + }, + "inputDetails": { + "truncated": false + }, + "name": "fail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'fail' (entered at the event id #4). The JSONPath '$.ErrorX' specified for the field 'ErrorPath' could not be found in the input ''", + "error": "States.Runtime" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_STRING_EXPR_JSONPATH]": { + "recorded-date": "02-01-2025, 13:44:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'pass' (entered at the event id #2). The JSONPath '$.no_such_jsonpath' specified for the field 'value.$' could not be found in the input ''", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_STRING_EXPR_CONTEXTPATH]": { + "recorded-date": "02-01-2025, 13:45:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'pass' (entered at the event id #2). The JSONPath '$$.Execution.Input.no_such_jsonpath' specified for the field 'value.$' could not be found in the input ''", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_CAUSEPATH]": { + "recorded-date": "02-01-2025, 14:21:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "pass", + "output": { + "Error": "error-value" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Error": "error-value" + }, + "inputDetails": { + "truncated": false + }, + "name": "fail" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'fail' (entered at the event id #4). The JSONPath '$.NoSuchCausePath' specified for the field 'CausePath' could not be found in the input ''", + "error": "States.Runtime" + }, + "id": 5, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_INPUTPATH]": { + "recorded-date": "02-01-2025, 14:21:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'pass' (entered at the event id #2). Invalid path '$.NoSuchInputPath' : No results for path: $['NoSuchInputPath']", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_OUTPUTPATH]": { + "recorded-date": "02-01-2025, 14:21:35", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'pass' (entered at the event id #2). Invalid path '$.NoSuchOutputPath' : No results for path: $['NoSuchOutputPath']", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_TIMEOUTSECONDSPATH]": { + "recorded-date": "02-01-2025, 14:26:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'task' (entered at the event id #2). Invalid path '$.NoSuchTimeoutSecondsPath' : No results for path: $['NoSuchTimeoutSecondsPath']", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_HEARTBEATSECONDSPATH]": { + "recorded-date": "02-01-2025, 14:26:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "int-literal": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'task' (entered at the event id #2). Invalid path '$.NoSuchTimeoutSecondsPath' : No results for path: $['NoSuchTimeoutSecondsPath']", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_INPUT]": { + "recorded-date": "09-01-2025, 10:21:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "LoadState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "LoadState", + "output": { + "from_previous": [ + "load-state-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "from_previous": [ + "load-state-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[0]]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_ITEM_READER]": { + "recorded-date": "09-01-2025, 10:27:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "LoadState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "LoadState", + "output": { + "from_previous": [ + "from-previous-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "from_previous": [ + "from-previous-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[[\"from-bucket-item-0\"]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[\"from-bucket-item-0\"]]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[INVALID_ITEMS_PATH]": { + "recorded-date": "09-01-2025, 10:28:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "LoadState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "LoadState", + "output": { + "from_previous": [ + "from-previous-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "from_previous": [ + "from-previous-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[[\"from-bucket-item-0\"]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[\"from-bucket-item-0\"]]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_PREVIOUS]": { + "recorded-date": "09-01-2025, 10:27:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file.json", + "from_input_items": [ + "input-item-0" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "LoadState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "LoadState", + "output": { + "from_previous": [ + "from-previous-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "from_previous": [ + "from-previous-item-0" + ], + "Bucket": "bucket-name", + "Key": "file.json" + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 0 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[[\"from-bucket-item-0\"]]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[[\"from-bucket-item-0\"]]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_items_path_from_previous": { + "recorded-date": "09-01-2025, 14:02:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "PreviousState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "PreviousState", + "output": { + "result_value": [ + "item-value-from-previous" + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "result_value": [ + "item-value-from-previous" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 1 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "MapState", + "output": "[\"item-value-from-previous\"]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[\"item-value-from-previous\"]", + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONATA_COMPARISON_ASSIGN]": { + "recorded-date": "02-02-2025, 15:45:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "var": "\"\\\"\"" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Pass", + "output": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "name": "Check" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "Check", + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_STRING_LITERALS]": { + "recorded-date": "02-02-2025, 15:44:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "name": "TestEscapesParameters" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "TestEscapesParameters", + "output": { + "literal_single_quote": "'", + "literal_double_quote": "\"", + "mixed_quotes": "Text with both \"double\" and 'single' quotes.", + "escaped_double_in_string": "An escaped quote: \\\"hello\\\"", + "escaped_backslash": "Backslash \\\\ in string", + "unicode_double_quote": "\"unicode-quote\"", + "whitespace_escapes": "Line1\nLine2\tTabbed", + "emoji": "\ud83c\udd97" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "literal_single_quote": "'", + "literal_double_quote": "\"", + "mixed_quotes": "Text with both \"double\" and 'single' quotes.", + "escaped_double_in_string": "An escaped quote: \\\"hello\\\"", + "escaped_backslash": "Backslash \\\\ in string", + "unicode_double_quote": "\"unicode-quote\"", + "whitespace_escapes": "Line1\nLine2\tTabbed", + "emoji": "\ud83c\udd97" + }, + "inputDetails": { + "truncated": false + }, + "name": "TestEscapesResult" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "TestEscapesResult", + "output": { + "literal_single_quote": "'", + "literal_double_quote": "\"", + "mixed_quotes": "Text with both \"double\" and 'single' quotes.", + "escaped_double_in_string": "An escaped quote: \\\"hello\\\"", + "escaped_backslash": "Backslash \\\\ in string", + "unicode_double_quote": "\"unicode-quote\"", + "whitespace_escapes": "Line1\nLine2\tTabbed", + "emoji": "\ud83c\udd97", + "not_an_intrinsic_function.$": "States.StringToJson('{\"text\":\"An \\\"escaped double quote\\\" here\"}')" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "literal_single_quote": "'", + "literal_double_quote": "\"", + "mixed_quotes": "Text with both \"double\" and 'single' quotes.", + "escaped_double_in_string": "An escaped quote: \\\"hello\\\"", + "escaped_backslash": "Backslash \\\\ in string", + "unicode_double_quote": "\"unicode-quote\"", + "whitespace_escapes": "Line1\nLine2\tTabbed", + "emoji": "\ud83c\udd97", + "not_an_intrinsic_function.$": "States.StringToJson('{\"text\":\"An \\\"escaped double quote\\\" here\"}')" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONPATH]": { + "recorded-date": "02-02-2025, 15:44:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "name": "JsonPathEscapeTest" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "JsonPathEscapeTest", + "output": { + "value": "Value\"\\" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "value": "Value\"\\" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONATA_COMPARISON_OUTPUT]": { + "recorded-date": "02-02-2025, 15:44:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Test\\\"\"Name\"": "Value\"\\" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Pass", + "output": "\"\\\"\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": "\"\\\"\"", + "inputDetails": { + "truncated": false + }, + "name": "Check" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "Check", + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_illegal_escapes[ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION]": { + "recorded-date": "02-02-2025, 15:46:00", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The value for the field 'parsed.$' must be a valid JSONPath or a valid intrinsic function call at /States/IntrinsicEscape/Parameters'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_illegal_escapes[ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION_2]": { + "recorded-date": "02-02-2025, 15:46:15", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The value for the field 'parsed.$' must be a valid JSONPath or a valid intrinsic function call at /States/IntrinsicEscape/Parameters'" + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested_config_distributed_no_max_max_concurrency": { + "recorded-date": "05-03-2025, 12:09:21", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "InputValue" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "InputValue", + "output": { + "outerJobs": [ + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "outerJobs": [ + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "OuterMap" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapRunStartedEventDetails": { + "mapRunArn": "arn::states::111111111111:mapRun:/:" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapRunStarted" + }, + { + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "MapRunSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 9, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "OuterMap", + "output": { + "outerJobs": [ + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": { + "outerJobs": [ + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "FinalState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "FinalState", + "output": { + "outerJobs": [ + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "outerJobs": [ + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "innerJobs": [ + 0, + 1, + 2, + 3, + 4 + ] + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector[MAP_STATE_ITEM_SELECTOR]": { + "recorded-date": "03-03-2025, 16:07:53", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartState" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "MapState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 5 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 15, + "previousEventId": 14, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 17, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 18, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 3, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "HandleItem" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 24, + "previousEventId": 23, + "stateExitedEventDetails": { + "name": "HandleItem", + "output": { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 25, + "mapIterationSucceededEventDetails": { + "index": 4, + "name": "MapState" + }, + "previousEventId": 24, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 26, + "previousEventId": 25, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 27, + "previousEventId": 25, + "stateExitedEventDetails": { + "name": "MapState", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "id": 28, + "previousEventId": 27, + "stateEnteredEventDetails": { + "input": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Final" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 29, + "previousEventId": 28, + "stateExitedEventDetails": { + "name": "Final", + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "detail": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ], + "shipped_output": [ + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + "from_input_constant": "UQS", + "iteration_index": 0, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + "from_input_constant": "UQS", + "iteration_index": 1, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + "from_input_constant": "UQS", + "iteration_index": 2, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + "from_input_constant": "UQS", + "iteration_index": 3, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + }, + { + "constant_value": "HelloWorld", + "iteration_input_value": { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + }, + "from_input_constant": "UQS", + "iteration_index": 4, + "original_input": { + "delivery-partner": "UQS", + "shipped": [ + { + "prod": "R31", + "dest-code": 9511, + "quantity": 1344 + }, + { + "prod": "S39", + "dest-code": 9511, + "quantity": 40 + }, + { + "prod": "R31", + "dest-code": 9833, + "quantity": 12 + }, + { + "prod": "R40", + "dest-code": 9860, + "quantity": 887 + }, + { + "prod": "R40", + "dest-code": 9511, + "quantity": 1220 + } + ] + } + } + ] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 30, + "previousEventId": 29, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector[MAP_STATE_ITEM_SELECTOR_JSONATA]": { + "recorded-date": "03-03-2025, 16:08:13", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "assignedVariables": { + "ItemsVar": "[\"Item1\",\"Item2\"]" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Start", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "MapIterateState" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 5, + "mapStateStartedEventDetails": { + "length": 2 + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "map_item_value": "Item1", + "var_sample": [ + "Item1", + "Item2" + ], + "string_literal": "string literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "map_item_value": "Item1", + "var_sample": [ + "Item1", + "Item2" + ], + "string_literal": "string literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 9, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 10, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "map_item_value": "Item2", + "var_sample": [ + "Item1", + "Item2" + ], + "string_literal": "string literal" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pass" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "Pass", + "output": { + "map_item_value": "Item2", + "var_sample": [ + "Item1", + "Item2" + ], + "string_literal": "string literal" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 13, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "MapIterateState" + }, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 14, + "previousEventId": 13, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 15, + "previousEventId": 13, + "stateExitedEventDetails": { + "name": "MapIterateState", + "output": "[{\"map_item_value\":\"Item1\",\"var_sample\":[\"Item1\",\"Item2\"],\"string_literal\":\"string literal\"},{\"map_item_value\":\"Item2\",\"var_sample\":[\"Item1\",\"Item2\"],\"string_literal\":\"string literal\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"map_item_value\":\"Item1\",\"var_sample\":[\"Item1\",\"Item2\"],\"string_literal\":\"string literal\"},{\"map_item_value\":\"Item2\",\"var_sample\":[\"Item1\",\"Item2\"],\"string_literal\":\"string literal\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 16, + "previousEventId": 15, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.validation.json b/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.validation.json new file mode 100644 index 0000000000000..84690544c58f4 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.validation.json @@ -0,0 +1,536 @@ +{ + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_catch_empty": { + "last_validated_date": "2023-11-24T07:01:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_catch_states_runtime": { + "last_validated_date": "2023-11-23T20:56:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_aws_docs_scenario[CHOICE_STATE_AWS_SCENARIO]": { + "last_validated_date": "2024-11-18T11:14:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_aws_docs_scenario[CHOICE_STATE_AWS_SCENARIO_JSONATA]": { + "last_validated_date": "2024-11-18T11:14:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_condition_constant_jsonata": { + "last_validated_date": "2024-11-18T12:19:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE]": { + "last_validated_date": "2024-12-24T16:59:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE_JSONATA]": { + "last_validated_date": "2024-12-24T17:00:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_singleton_composite[CHOICE_STATE_SINGLETON_COMPOSITE_LITERAL_JSONATA]": { + "last_validated_date": "2024-12-24T17:00:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_negative[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS]": { + "last_validated_date": "2024-11-18T11:32:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_negative[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": { + "last_validated_date": "2024-11-18T11:32:24+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_positive[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS]": { + "last_validated_date": "2024-11-18T11:30:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_choice_unsorted_parameters_positive[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": { + "last_validated_date": "2024-11-18T11:30:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONATA_COMPARISON_ASSIGN]": { + "last_validated_date": "2025-02-02T15:45:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONATA_COMPARISON_OUTPUT]": { + "last_validated_date": "2025-02-02T15:44:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_JSONPATH]": { + "last_validated_date": "2025-02-02T15:44:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_escape_sequence_parsing[ESCAPE_SEQUENCES_STRING_LITERALS]": { + "last_validated_date": "2025-02-02T15:44:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_fail_cause_jsonata": { + "last_validated_date": "2024-11-13T16:36:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_fail_error_jsonata": { + "last_validated_date": "2024-11-13T16:35:39+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_illegal_escapes[ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION]": { + "last_validated_date": "2025-02-02T15:46:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_illegal_escapes[ESCAPE_SEQUENCES_ILLEGAL_INTRINSIC_FUNCTION_2]": { + "last_validated_date": "2025-02-02T15:46:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_ERRORPATH]": { + "last_validated_date": "2025-01-02T13:44:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_STRING_EXPR_CONTEXTPATH]": { + "last_validated_date": "2025-01-02T13:45:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[INVALID_JSONPATH_IN_STRING_EXPR_JSONPATH]": { + "last_validated_date": "2025-01-02T13:44:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_CAUSEPATH]": { + "last_validated_date": "2025-01-02T14:21:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_HEARTBEATSECONDSPATH]": { + "last_validated_date": "2025-01-02T14:26:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_INPUTPATH]": { + "last_validated_date": "2025-01-02T14:21:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_OUTPUTPATH]": { + "last_validated_date": "2025-01-02T14:21:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_invalid_jsonpath[ST.INVALID_JSONPATH_IN_TIMEOUTSECONDSPATH]": { + "last_validated_date": "2025-01-02T14:26:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_empty_retry": { + "last_validated_date": "2023-11-23T17:08:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke_with_retry_base": { + "last_validated_date": "2023-10-24T10:52:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_invoke_with_retry_extended_input": { + "last_validated_date": "2023-10-24T15:18:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_lambda_service_invoke_with_retry_extended_input": { + "last_validated_date": "2023-10-24T15:26:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_batching_base_json_max_per_batch_jsonata": { + "last_validated_date": "2024-11-13T15:21:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_csv_headers_decl": { + "last_validated_date": "2023-09-21T12:07:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_csv_headers_first_line": { + "last_validated_date": "2023-09-21T12:07:17+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json": { + "last_validated_date": "2023-09-21T12:08:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_max_items": { + "last_validated_date": "2024-03-25T18:19:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_max_items_jsonata": { + "last_validated_date": "2024-11-18T09:39:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[INVALID_ITEMS_PATH]": { + "last_validated_date": "2025-01-09T10:28:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_INPUT]": { + "last_validated_date": "2025-01-09T10:21:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_ITEM_READER]": { + "last_validated_date": "2025-01-09T10:27:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_json_with_items_path[VALID_ITEMS_PATH_FROM_PREVIOUS]": { + "last_validated_date": "2025-01-09T10:27:24+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_base_list_objects_v2": { + "last_validated_date": "2023-09-21T11:54:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_first_row_extra_fields": { + "last_validated_date": "2023-09-21T12:27:49+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_decl_duplicate_headers": { + "last_validated_date": "2023-09-21T12:16:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_decl_extra_fields": { + "last_validated_date": "2023-09-21T12:25:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_headers_first_row_typed_headers": { + "last_validated_date": "2023-09-21T12:26:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[-1]": { + "last_validated_date": "2024-03-22T21:03:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[0]": { + "last_validated_date": "2024-03-25T15:36:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[1.5]": { + "last_validated_date": "2024-03-22T21:04:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[100000000]": { + "last_validated_date": "2024-03-25T15:37:24+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[100000001]": { + "last_validated_date": "2024-03-22T21:05:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items[2]": { + "last_validated_date": "2024-03-25T15:37:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[-1]": { + "last_validated_date": "2024-03-25T17:38:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[0]": { + "last_validated_date": "2024-03-25T17:39:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[1.5]": { + "last_validated_date": "2024-03-25T17:40:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[100000000]": { + "last_validated_date": "2024-03-25T17:40:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[100000001]": { + "last_validated_date": "2024-03-25T17:40:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_csv_max_items_paths[2]": { + "last_validated_date": "2024-03-25T17:39:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_item_reader_json_no_json_list_object": { + "last_validated_date": "2023-09-21T12:30:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state": { + "last_validated_date": "2023-07-17T14:35:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_break_condition": { + "last_validated_date": "2023-09-21T15:39:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_break_condition_legacy": { + "last_validated_date": "2023-07-24T08:38:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch": { + "last_validated_date": "2023-07-18T16:07:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch_empty_fail": { + "last_validated_date": "2023-08-08T11:18:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_catch_legacy": { + "last_validated_date": "2023-07-24T09:19:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_item_selector": { + "last_validated_date": "2024-02-08T21:44:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_item_selector_parameters": { + "last_validated_date": "2024-11-15T13:56:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_items_path_from_previous": { + "last_validated_date": "2025-01-09T14:02:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_parameters": { + "last_validated_date": "2024-02-08T21:44:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_reentrant": { + "last_validated_date": "2024-05-03T13:45:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_distributed_reentrant_lambda": { + "last_validated_date": "2024-05-03T13:44:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_inline_item_selector": { + "last_validated_date": "2024-02-08T21:47:17+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_config_inline_parameters": { + "last_validated_date": "2024-02-08T21:46:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector": { + "last_validated_date": "2023-07-19T11:47:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector[MAP_STATE_ITEM_SELECTOR]": { + "last_validated_date": "2025-03-03T16:07:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector[MAP_STATE_ITEM_SELECTOR_JSONATA]": { + "last_validated_date": "2025-03-03T16:08:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector_parameters": { + "last_validated_date": "2024-11-15T11:20:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_item_selector_singleton": { + "last_validated_date": "2023-07-19T12:11:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[empty]": { + "last_validated_date": "2024-11-20T16:13:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[mixed]": { + "last_validated_date": "2024-11-20T16:13:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata[singleton]": { + "last_validated_date": "2024-11-20T16:13:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[boolean]": { + "last_validated_date": "2024-11-19T13:11:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[function]": { + "last_validated_date": "2024-11-19T16:31:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[null]": { + "last_validated_date": "2024-11-19T13:11:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[number]": { + "last_validated_date": "2024-11-19T13:10:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[object]": { + "last_validated_date": "2024-11-19T13:15:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_fail[string]": { + "last_validated_date": "2024-11-19T13:11:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[boolean]": { + "last_validated_date": "2024-11-19T13:22:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[null]": { + "last_validated_date": "2024-11-19T13:23:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[number]": { + "last_validated_date": "2024-11-19T13:22:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[object]": { + "last_validated_date": "2024-11-19T13:23:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_eval_jsonata_variable_sampling_fail[string]": { + "last_validated_date": "2024-11-19T13:22:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[empty]": { + "last_validated_date": "2024-11-19T13:28:17+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[mixed]": { + "last_validated_date": "2024-11-19T13:28:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_array[singleton]": { + "last_validated_date": "2024-11-19T13:28:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[boolean]": { + "last_validated_date": "2024-11-19T13:25:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[null]": { + "last_validated_date": "2024-11-19T13:25:52+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[number]": { + "last_validated_date": "2024-11-19T13:24:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[object]": { + "last_validated_date": "2024-11-19T13:25:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_input_types[string]": { + "last_validated_date": "2024-11-19T13:25:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[boolean]": { + "last_validated_date": "2024-11-19T13:47:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[null]": { + "last_validated_date": "2024-11-19T13:47:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[number]": { + "last_validated_date": "2024-11-19T13:46:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[object]": { + "last_validated_date": "2024-11-19T13:47:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_items_variable_sampling[string]": { + "last_validated_date": "2024-11-19T13:47:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy": { + "last_validated_date": "2023-07-23T18:46:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed": { + "last_validated_date": "2024-02-08T11:27:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed_item_selector": { + "last_validated_date": "2024-02-08T20:28:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_distributed_parameters": { + "last_validated_date": "2024-02-08T20:22:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline": { + "last_validated_date": "2024-02-08T11:26:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline_item_selector": { + "last_validated_date": "2024-02-08T21:08:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_config_inline_parameters": { + "last_validated_date": "2024-02-08T21:07:39+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_legacy_reentrant": { + "last_validated_date": "2024-05-03T13:41:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested": { + "last_validated_date": "2024-03-29T16:26:02+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested_config_distributed": { + "last_validated_date": "2024-12-13T13:38:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_nested_config_distributed_no_max_max_concurrency": { + "last_validated_date": "2025-03-05T12:09:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_no_processor_config": { + "last_validated_date": "2023-12-15T21:25:27+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_parameters_legacy": { + "last_validated_date": "2023-07-24T08:53:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_parameters_singleton_legacy": { + "last_validated_date": "2023-07-24T08:56:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry": { + "last_validated_date": "2023-07-18T16:44:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry_legacy": { + "last_validated_date": "2023-07-24T09:22:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_retry_multiple_retriers": { + "last_validated_date": "2023-08-08T11:20:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[-1]": { + "last_validated_date": "2024-06-19T12:38:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[0]": { + "last_validated_date": "2024-06-19T12:38:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[1]": { + "last_validated_date": "2024-06-19T12:39:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[NoNumber]": { + "last_validated_date": "2024-06-19T12:38:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_count_path[tolerated_failure_count_value0]": { + "last_validated_date": "2024-06-19T12:38:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[-1.1]": { + "last_validated_date": "2024-06-19T14:37:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[-1]": { + "last_validated_date": "2024-06-19T14:37:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[0]": { + "last_validated_date": "2024-06-19T14:37:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[1.1]": { + "last_validated_date": "2024-06-19T14:38:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[100.1]": { + "last_validated_date": "2024-06-19T14:38:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[100]": { + "last_validated_date": "2024-06-19T14:38:39+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[1]": { + "last_validated_date": "2024-06-19T14:38:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[NoNumber]": { + "last_validated_date": "2024-06-19T14:37:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_percentage_path[tolerated_failure_percentage_value0]": { + "last_validated_date": "2024-06-19T14:36:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_values[count_literal]": { + "last_validated_date": "2024-06-19T12:31:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_map_state_tolerated_failure_values[percentage_literal]": { + "last_validated_date": "2024-06-19T12:31:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[0]": { + "last_validated_date": "2024-06-19T13:49:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[1]": { + "last_validated_date": "2024-06-19T13:50:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[NoNumber]": { + "last_validated_date": "2024-06-19T13:49:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path[max_concurrency_value0]": { + "last_validated_date": "2024-06-19T13:49:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_max_concurrency_path_negative": { + "last_validated_date": "2024-04-22T12:40:52+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state": { + "last_validated_date": "2023-07-17T10:41:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state[PARALLEL_STATE]": { + "last_validated_date": "2024-09-12T20:48:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state[PARALLEL_STATE_PARAMETERS]": { + "last_validated_date": "2024-09-12T20:49:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_nested": { + "last_validated_date": "2024-03-29T17:05:02+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_parallel_state_order": { + "last_validated_date": "2023-12-15T22:15:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features": { + "last_validated_date": "2024-02-23T08:36:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features_jitter_none": { + "last_validated_date": "2024-02-23T08:37:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_retry_interval_features_max_attempts_zero": { + "last_validated_date": "2024-03-27T09:30:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_seconds_jsonata": { + "last_validated_date": "2024-11-13T16:20:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp": { + "last_validated_date": "2023-10-31T18:01:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp[NANOSECONDS]": { + "last_validated_date": "2024-12-27T10:04:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp[SECONDS]": { + "last_validated_date": "2024-12-27T10:04:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_DATE]": { + "last_validated_date": "2024-12-27T10:13:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_ISO]": { + "last_validated_date": "2024-12-27T10:13:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[INVALID_TIME]": { + "last_validated_date": "2024-12-27T10:13:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[JSONATA]": { + "last_validated_date": "2024-12-27T10:13:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[NO_T]": { + "last_validated_date": "2024-12-27T10:12:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_invalid[NO_Z]": { + "last_validated_date": "2024-12-27T10:12:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata": { + "last_validated_date": "2024-11-13T16:19:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_DATE]": { + "last_validated_date": "2024-12-27T09:57:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_ISO]": { + "last_validated_date": "2024-12-27T09:58:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[INVALID_TIME]": { + "last_validated_date": "2024-12-27T09:58:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NANOSECONDS]": { + "last_validated_date": "2024-12-27T09:57:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NO_T]": { + "last_validated_date": "2024-12-27T09:57:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[NO_Z]": { + "last_validated_date": "2024-12-27T09:57:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_jsonata[SECONDS]": { + "last_validated_date": "2024-12-27T09:57:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path": { + "last_validated_date": "2023-10-31T17:57:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_DATE]": { + "last_validated_date": "2024-12-27T10:03:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_ISO]": { + "last_validated_date": "2024-12-27T10:04:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[INVALID_TIME]": { + "last_validated_date": "2024-12-27T10:03:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NANOSECONDS]": { + "last_validated_date": "2024-12-27T10:02:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NO_T]": { + "last_validated_date": "2024-12-27T10:02:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[NO_Z]": { + "last_validated_date": "2024-12-27T10:03:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_base_scenarios.py::TestBaseScenarios::test_wait_timestamp_path[SECONDS]": { + "last_validated_date": "2024-12-27T10:02:23+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py b/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py new file mode 100644 index 0000000000000..e276545abe22c --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py @@ -0,0 +1,239 @@ +import json +import os.path +from pathlib import Path +from typing import Any, TypedDict + +from localstack.aws.api.stepfunctions import ExecutionStatus +from localstack.testing.pytest import markers +from localstack.utils.sync import wait_until + +THIS_FOLDER = Path(os.path.dirname(__file__)) + + +class RunConfig(TypedDict): + name: str + input: Any + terminal_state: ExecutionStatus | None + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..tracingConfiguration", + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ], +) +class TestFundamental: + @staticmethod + def _record_execution( + stepfunctions_client, sfn_snapshot, statemachine_arn, run_config: RunConfig + ): + """ + This pattern is used throughout all stepfunctions scenario tests. + It starts a single state machine execution and snapshots all related information for the execution. + Make sure the "name" in the run_config is unique in the run. + """ + name = run_config["name"] + start_execution_result = stepfunctions_client.start_execution( + stateMachineArn=statemachine_arn, input=json.dumps(run_config["input"]) + ) + execution_arn = start_execution_result["executionArn"] + execution_id = execution_arn.split(":")[-1] + sfn_snapshot.add_transformer( + sfn_snapshot.transform.regex(execution_id, f"") + ) + sfn_snapshot.match(f"{name}__start_execution_result", start_execution_result) + + def execution_done(): + # wait until execution is successful (or a different terminal state) + return ( + stepfunctions_client.describe_execution(executionArn=execution_arn)["status"] + != ExecutionStatus.RUNNING + ) + + wait_until(execution_done) + describe_ex_done = stepfunctions_client.describe_execution(executionArn=execution_arn) + sfn_snapshot.match(f"{name}__describe_ex_done", describe_ex_done) + execution_history = stepfunctions_client.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match(f"{name}__execution_history", execution_history) + + assert_state = run_config.get("terminal_state") + if assert_state: + assert describe_ex_done["status"] == assert_state + + @markers.aws.validated + def test_path_based_on_data(self, deploy_cfn_template, sfn_snapshot, aws_client): + """ + Based on the "path-based-on-data" sample workflow on serverlessland.com + + choice state with 3 paths + 1. input "type" is not "Private" + 2. value is >= 20 and < 30 + 3. default path + """ + deployment = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "templates/path-based-on-data.yaml") + ) + statemachine_arn = deployment.outputs["StateMachineArn"] + statemachine_name = deployment.outputs["StateMachineName"] + role_name = deployment.outputs["RoleName"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.regex(role_name, "")) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.regex(statemachine_name, "") + ) + + describe_statemachine = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match("describe_statemachine", describe_statemachine) + + run_configs = [ + { + "name": "first_path", + "input": {"type": "Public", "value": 3}, + "terminal_state": ExecutionStatus.SUCCEEDED, + }, + { + "name": "second_path", + "input": {"type": "Private", "value": 25}, + "terminal_state": ExecutionStatus.SUCCEEDED, + }, + { + "name": "default_path", + "input": {"type": "Private", "value": 3}, + "terminal_state": ExecutionStatus.SUCCEEDED, + }, + ] + + for run_config in run_configs: + self._record_execution( + aws_client.stepfunctions, sfn_snapshot, statemachine_arn, run_config + ) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..taskFailedEventDetails.resource", + "$..taskFailedEventDetails.resourceType", + "$..taskSubmittedEventDetails.output", + "$..previousEventId", + "$..MessageId", + ], + ) + @markers.aws.validated + def test_wait_for_callback(self, deploy_cfn_template, sfn_snapshot, aws_client): + """ + Based on the "wait-for-callback" sample workflow on serverlessland.com + """ + deployment = deploy_cfn_template( + template_path=os.path.join(THIS_FOLDER, "templates/wait-for-callback.yaml"), + max_wait=240, + ) + statemachine_arn = deployment.outputs["StateMachineArn"] + statemachine_name = deployment.outputs["StateMachineName"] + role_name = deployment.outputs["RoleName"] + + sfn_snapshot.add_transformer(sfn_snapshot.transform.regex(role_name, "")) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.regex(statemachine_name, "") + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("QueueUrl"), priority=-1) + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("TaskToken")) + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("MD5OfMessageBody")) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value( + "Date", value_replacement="", reference_replacement=False + ) + ) + + describe_statemachine = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match("describe_statemachine", describe_statemachine) + + run_configs = [ + { + "name": "success", + "input": {"shouldfail": "no"}, + "terminal_state": ExecutionStatus.SUCCEEDED, + }, + { + "name": "failure", + "input": {"shouldfail": "yes"}, + "terminal_state": ExecutionStatus.FAILED, + }, + ] + + for run_config in run_configs: + self._record_execution( + aws_client.stepfunctions, sfn_snapshot, statemachine_arn, run_config + ) + + @markers.snapshot.skip_snapshot_verify( + paths=["$..content-type"], # FIXME: v2 includes extra content-type fields in Header fields. + ) + @markers.aws.validated + def test_step_functions_calling_api_gateway( + self, deploy_cfn_template, sfn_snapshot, aws_client + ): + """ + Based on the "step-functions-calling-api-gateway" sample workflow on serverlessland.com + """ + deployment = deploy_cfn_template( + template_path=os.path.join( + THIS_FOLDER, "templates/step-functions-calling-api-gateway.yaml" + ), + max_wait=240, + ) + statemachine_arn = deployment.outputs["StateMachineArn"] + statemachine_name = deployment.outputs["StateMachineName"] + role_name = deployment.outputs["RoleName"] + + sfn_snapshot.add_transformer(sfn_snapshot.transform.regex(role_name, "")) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.regex(statemachine_name, "") + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value("X-Amz-Cf-Pop", reference_replacement=False) + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value("X-Amz-Cf-Id", reference_replacement=False) + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value("X-Amzn-Trace-Id", reference_replacement=False) + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value("x-amz-apigw-id", reference_replacement=False) + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value("x-amzn-RequestId", reference_replacement=False) + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value("Date", reference_replacement=False) + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.key_value("Via", reference_replacement=False) + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("ApiEndpoint"), priority=-1) + + describe_statemachine = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=statemachine_arn + ) + sfn_snapshot.match("describe_statemachine", describe_statemachine) + + run_configs = [ + { + "name": "success", + "input": {"fail": True}, + "terminal_state": ExecutionStatus.FAILED, + }, + { + "name": "failure", + "input": {"fail": False}, + "terminal_state": ExecutionStatus.SUCCEEDED, + }, + ] + + for run_config in run_configs: + self._record_execution( + aws_client.stepfunctions, sfn_snapshot, statemachine_arn, run_config + ) diff --git a/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.snapshot.json b/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.snapshot.json new file mode 100644 index 0000000000000..00aaac28ddfed --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.snapshot.json @@ -0,0 +1,1716 @@ +{ + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_path_based_on_data": { + "recorded-date": "04-03-2023, 10:00:55", + "recorded-content": { + "describe_statemachine": { + "creationDate": "datetime", + "definition": { + "StartAt": "Choice State", + "States": { + "Choice State": { + "Type": "Choice", + "Choices": [ + { + "Not": { + "Variable": "$.type", + "StringEquals": "Private" + }, + "Next": "NEXT_STATE_ONE" + }, + { + "Variable": "$.value", + "NumericEquals": 0, + "Next": "NEXT_STATE_TWO" + }, + { + "And": [ + { + "Variable": "$.value", + "NumericGreaterThanEquals": 20 + }, + { + "Variable": "$.value", + "NumericLessThan": 30 + } + ], + "Next": "NEXT_STATE_TWO" + } + ], + "Default": "DEFAULT_STATE" + }, + "DEFAULT_STATE": { + "Type": "Pass", + "End": true + }, + "NEXT_STATE_ONE": { + "Type": "Pass", + "End": true + }, + "NEXT_STATE_TWO": { + "Type": "Pass", + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "arn::iam::111111111111:role/", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_path__start_execution_result": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_path__describe_ex_done": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "type": "Public", + "value": 3 + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "type": "Public", + "value": 3 + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "first_path__execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Public", + "value": 3 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 3 + }, + "inputDetails": { + "truncated": false + }, + "name": "Choice State" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Choice State", + "output": { + "type": "Public", + "value": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Public", + "value": 3 + }, + "inputDetails": { + "truncated": false + }, + "name": "NEXT_STATE_ONE" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "NEXT_STATE_ONE", + "output": { + "type": "Public", + "value": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Public", + "value": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_path__start_execution_result": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_path__describe_ex_done": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "type": "Private", + "value": 25 + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "type": "Private", + "value": 25 + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "second_path__execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Private", + "value": 25 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 25 + }, + "inputDetails": { + "truncated": false + }, + "name": "Choice State" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Choice State", + "output": { + "type": "Private", + "value": 25 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 25 + }, + "inputDetails": { + "truncated": false + }, + "name": "NEXT_STATE_TWO" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "NEXT_STATE_TWO", + "output": { + "type": "Private", + "value": 25 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Private", + "value": 25 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "default_path__start_execution_result": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "default_path__describe_ex_done": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "type": "Private", + "value": 3 + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "type": "Private", + "value": 3 + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "default_path__execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "type": "Private", + "value": 3 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 3 + }, + "inputDetails": { + "truncated": false + }, + "name": "Choice State" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "Choice State", + "output": { + "type": "Private", + "value": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "type": "Private", + "value": 3 + }, + "inputDetails": { + "truncated": false + }, + "name": "DEFAULT_STATE" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "stateExitedEventDetails": { + "name": "DEFAULT_STATE", + "output": { + "type": "Private", + "value": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "type": "Private", + "value": 3 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_wait_for_callback": { + "recorded-date": "04-03-2023, 10:05:28", + "recorded-content": { + "describe_statemachine": { + "creationDate": "datetime", + "definition": { + "StartAt": "Start Task And Wait For Callback", + "States": { + "Start Task And Wait For Callback": { + "Next": "Succeed State", + "Catch": [ + { + "ErrorEquals": [ + "States.ALL" + ], + "Next": "Fail State" + } + ], + "Type": "Task", + "Resource": "arn::states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl": "", + "MessageBody": { + "MessageTitle": "Task started by Step Functions. Waiting for callback with task token.", + "ShouldFail.$": "$.shouldfail", + "TaskToken.$": "$$.Task.Token" + } + } + }, + "Succeed State": { + "Type": "Succeed" + }, + "Fail State": { + "Type": "Fail" + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "arn::iam::111111111111:role/", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "success__start_execution_result": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "success__describe_ex_done": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "shouldfail": "no" + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "message": "Successfully executed lambda" + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "success__execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "shouldfail": "no" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "shouldfail": "no" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start Task And Wait For Callback" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "", + "MessageBody": { + "MessageTitle": "Task started by Step Functions. Waiting for callback with task token.", + "ShouldFail": "no", + "TaskToken": "" + } + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "378" + ], + "Date": "", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "378", + "Content-Type": "text/xml", + "Date": "", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "message": "Successfully executed lambda" + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "Start Task And Wait For Callback", + "output": { + "message": "Successfully executed lambda" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "message": "Successfully executed lambda" + }, + "inputDetails": { + "truncated": false + }, + "name": "Succeed State" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Succeed State", + "output": { + "message": "Successfully executed lambda" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "message": "Successfully executed lambda" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "failure__start_execution_result": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "failure__describe_ex_done": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "shouldfail": "yes" + }, + "inputDetails": { + "included": true + }, + "name": "", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "FAILED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "failure__execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "shouldfail": "yes" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "shouldfail": "yes" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start Task And Wait For Callback" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "", + "MessageBody": { + "MessageTitle": "Task started by Step Functions. Waiting for callback with task token.", + "ShouldFail": "yes", + "TaskToken": "" + } + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "378" + ], + "Date": "", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "378", + "Content-Type": "text/xml", + "Date": "", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "cause": "Intentional failure", + "error": "IntentionalFail", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "Start Task And Wait For Callback", + "output": { + "Error": "IntentionalFail", + "Cause": "Intentional failure" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 8, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "Error": "IntentionalFail", + "Cause": "Intentional failure" + }, + "inputDetails": { + "truncated": false + }, + "name": "Fail State" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": {}, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_step_functions_calling_api_gateway": { + "recorded-date": "04-03-2023, 10:06:48", + "recorded-content": { + "describe_statemachine": { + "creationDate": "datetime", + "definition": { + "StartAt": "Add Pet to Store", + "States": { + "Add Pet to Store": { + "Next": "Pet was Added Successfully?", + "Type": "Task", + "Resource": "arn::states:::apigateway:invoke", + "Parameters": { + "ApiEndpoint": "", + "Method": "POST", + "Stage": "dev", + "RequestBody.$": "$", + "AuthType": "NO_AUTH" + } + }, + "Pet was Added Successfully?": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.ResponseBody.errors", + "IsPresent": true, + "Next": "Failure" + } + ], + "Default": "Success" + }, + "Success": { + "Type": "Succeed" + }, + "Failure": { + "Type": "Fail" + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "arn::iam::111111111111:role/", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "success__start_execution_result": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "success__describe_ex_done": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "fail": true + }, + "inputDetails": { + "included": true + }, + "name": "", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "FAILED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "success__execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "fail": true + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "fail": true + }, + "inputDetails": { + "truncated": false + }, + "name": "Add Pet to Store" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "ApiEndpoint": "", + "Method": "POST", + "Stage": "dev", + "AuthType": "NO_AUTH", + "RequestBody": { + "fail": true + } + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "30" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "errors": [ + "Errors.Failure" + ] + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Add Pet to Store", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "30" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "errors": [ + "Errors.Failure" + ] + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "30" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "errors": [ + "Errors.Failure" + ] + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pet was Added Successfully?" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Pet was Added Successfully?", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "30" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "errors": [ + "Errors.Failure" + ] + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "30" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "errors": [ + "Errors.Failure" + ] + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "inputDetails": { + "truncated": false + }, + "name": "Failure" + }, + "timestamp": "timestamp", + "type": "FailStateEntered" + }, + { + "executionFailedEventDetails": {}, + "id": 10, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "failure__start_execution_result": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "failure__describe_ex_done": { + "executionArn": "arn::states::111111111111:execution::", + "input": { + "fail": false + }, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "failure__execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "fail": false + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "fail": false + }, + "inputDetails": { + "truncated": false + }, + "name": "Add Pet to Store" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "ApiEndpoint": "", + "Method": "POST", + "Stage": "dev", + "AuthType": "NO_AUTH", + "RequestBody": { + "fail": false + } + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Add Pet to Store", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "inputDetails": { + "truncated": false + }, + "name": "Pet was Added Successfully?" + }, + "timestamp": "timestamp", + "type": "ChoiceStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Pet was Added Successfully?", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ChoiceStateExited" + }, + { + "id": 9, + "previousEventId": 8, + "stateEnteredEventDetails": { + "input": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "inputDetails": { + "truncated": false + }, + "name": "Success" + }, + "timestamp": "timestamp", + "type": "SucceedStateEntered" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "Success", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "SucceedStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "18" + ], + "Content-Type": [ + "application/json" + ], + "Date": "date", + "Via": "via", + "x-amz-apigw-id": "x-amz-apigw-id", + "X-Amz-Cf-Id": "x--amz--cf--id", + "X-Amz-Cf-Pop": "x--amz--cf--pop", + "x-amzn-RequestId": "x-amzn--request-id", + "X-Amzn-Trace-Id": "x--amzn--trace--id", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "hello": "world" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 11, + "previousEventId": 10, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.validation.json b/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.validation.json new file mode 100644 index 0000000000000..e84337755400f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_path_based_on_data": { + "last_validated_date": "2023-03-04T09:00:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_step_functions_calling_api_gateway": { + "last_validated_date": "2023-03-04T09:06:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/scenarios/test_sfn_scenarios.py::TestFundamental::test_wait_for_callback": { + "last_validated_date": "2023-03-04T09:05:28+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/__init__.py b/tests/aws/services/stepfunctions/v2/services/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py new file mode 100644 index 0000000000000..ecdff5fa6845d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py @@ -0,0 +1,453 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.aws import arns, aws_stack +from localstack.utils.strings import short_uid +from tests.aws.services.apigateway.apigateway_fixtures import create_rest_resource +from tests.aws.services.apigateway.conftest import APIGATEWAY_ASSUME_ROLE_POLICY +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + # TODO: add support for response headers, review: + # localstack.services.stepfunctions.asl.component.state.state_execution.state_task.service.state_task_service_api_gateway.StateTaskServiceApiGateway._invoke_output_of + "$..Headers.Content-Length", + ] +) +class TestTaskApiGateway: + @staticmethod + def _add_api_gateway_transformers(snapshot) -> None: + snapshot.add_transformers_list( + [ + JsonpathTransformer( + jsonpath="$..ApiEndpoint", + replacement="", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..Headers.Date", + replacement="", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..Headers.Via", + replacement="", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..Headers.x-amz-apigw-id", + replacement="", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..Headers.X-Amz-Cf-Id", + replacement="", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..Headers.X-Amz-Cf-Pop", + replacement="", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..Headers.x-amzn-RequestId", + replacement="", + replace_reference=False, + ), + JsonpathTransformer( + jsonpath="$..Headers.X-Amzn-Trace-Id", + replacement="", + replace_reference=False, + ), + ] + ) + + @staticmethod + def _create_lambda_api_response( + apigw_client, + create_rest_apigw, + create_lambda_function, + create_role_with_policy, + lambda_function_filename, + http_method, + part_path, + pipe_query_parameters=False, + ): + function_name = f"sfn-apigw-test-{short_uid()}" + stage_name = "sfn-apigw-api" + + create_function_response = create_lambda_function( + func_name=function_name, + handler_file=lambda_function_filename, + runtime=Runtime.python3_12, + ) + + _, role_arn = create_role_with_policy( + "Allow", "lambda:InvokeFunction", json.dumps(APIGATEWAY_ASSUME_ROLE_POLICY), "*" + ) + lambda_arn = create_function_response["CreateFunctionResponse"]["FunctionArn"] + target_uri = arns.apigateway_invocations_arn(lambda_arn, apigw_client.meta.region_name) + + api_id, _, root = create_rest_apigw(name=f"sfn-test-api-{short_uid()}") + resource_id, _ = create_rest_resource( + apigw_client, restApiId=api_id, parentId=root, pathPart=part_path + ) + + if pipe_query_parameters: + apigw_client.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + authorizationType="NONE", + requestParameters={ + "method.request.path.param1": False, + "method.request.path.param2": False, + }, + ) + else: + apigw_client.put_method( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + authorizationType="NONE", + ) + + if pipe_query_parameters: + apigw_client.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + type="AWS", + integrationHttpMethod=http_method, + uri=target_uri, + credentials=role_arn, + requestParameters={ + "integration.request.querystring.param1": "method.request.querystring.param1", + "integration.request.querystring.param2": "method.request.querystring.param2", + }, + requestTemplates={ + "application/json": """ + { + "body": $input.json('$'), + "queryStringParameters": "$input.params().querystring" + } + """ + }, + ) + else: + apigw_client.put_integration( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + type="AWS", + integrationHttpMethod=http_method, + uri=target_uri, + credentials=role_arn, + ) + + apigw_client.put_integration_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + ) + + apigw_client.put_method_response( + restApiId=api_id, + resourceId=resource_id, + httpMethod=http_method, + statusCode="200", + ) + + apigw_client.create_deployment(restApiId=api_id, stageName=stage_name) + if is_aws_cloud(): + invocation_url = f"{api_id}.execute-api.{aws_stack.get_boto3_region()}.amazonaws.com" + else: + invocation_url = f"{config.internal_service_url()}/restapis/{api_id}" + return invocation_url, stage_name + + @markers.aws.validated + def test_invoke_base( + self, + aws_client, + create_lambda_function, + create_role_with_policy, + create_state_machine_iam_role, + create_state_machine, + create_rest_apigw, + sfn_snapshot, + ): + self._add_api_gateway_transformers(sfn_snapshot) + + http_method = "POST" + part_path = "get_constant" + + api_url, api_stage = self._create_lambda_api_response( + apigw_client=aws_client.apigateway, + create_lambda_function=create_lambda_function, + create_role_with_policy=create_role_with_policy, + lambda_function_filename=ST.LAMBDA_ID_FUNCTION, + create_rest_apigw=create_rest_apigw, + http_method=http_method, + part_path=part_path, + ) + + template = ST.load_sfn_template(ST.API_GATEWAY_INVOKE_BASE) + definition = json.dumps(template) + + exec_input = json.dumps( + {"ApiEndpoint": api_url, "Method": http_method, "Path": part_path, "Stage": api_stage} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "request_body", + [ + None, + "", + "HelloWorld", + {"message": "HelloWorld!"}, + ], + ) + @markers.aws.validated + def test_invoke_with_body_post( + self, + aws_client, + create_lambda_function, + create_role_with_policy, + create_state_machine_iam_role, + create_state_machine, + create_rest_apigw, + sfn_snapshot, + request_body, + ): + self._add_api_gateway_transformers(sfn_snapshot) + + http_method = "POST" + part_path = "id_func" + + api_url, api_stage = self._create_lambda_api_response( + apigw_client=aws_client.apigateway, + create_lambda_function=create_lambda_function, + create_role_with_policy=create_role_with_policy, + lambda_function_filename=ST.LAMBDA_ID_FUNCTION, + create_rest_apigw=create_rest_apigw, + http_method=http_method, + part_path=part_path, + ) + + template = ST.load_sfn_template(ST.API_GATEWAY_INVOKE_WITH_BODY) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "ApiEndpoint": api_url, + "Method": http_method, + "Path": part_path, + "Stage": api_stage, + "RequestBody": request_body, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "custom_header", + [ + ## TODO: Implement checks for singleStringHeader case to cause exception + pytest.param( + "singleStringHeader", + marks=pytest.mark.skip(reason="Behavior parity not implemented"), + ), + ["arrayHeader0"], + ["arrayHeader0", "arrayHeader1"], + ], + ) + @markers.aws.validated + def test_invoke_with_headers( + self, + aws_client, + create_lambda_function, + create_role_with_policy, + create_state_machine_iam_role, + create_state_machine, + create_rest_apigw, + sfn_snapshot, + custom_header, + ): + self._add_api_gateway_transformers(sfn_snapshot) + + http_method = "POST" + part_path = "id_func" + + api_url, api_stage = self._create_lambda_api_response( + apigw_client=aws_client.apigateway, + create_lambda_function=create_lambda_function, + create_role_with_policy=create_role_with_policy, + lambda_function_filename=ST.LAMBDA_ID_FUNCTION, + create_rest_apigw=create_rest_apigw, + http_method=http_method, + part_path=part_path, + ) + + template = ST.load_sfn_template(ST.API_GATEWAY_INVOKE_WITH_HEADERS) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "ApiEndpoint": api_url, + "Method": http_method, + "Path": part_path, + "Stage": api_stage, + "RequestBody": {"message": "HelloWorld!"}, + "Headers": {"custom_header": custom_header}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: ApiGateway return incorrect output type (string instead of json) either here or in other scenarios, + # the formatting of query parameters is also incorrect, using ": " assignment separators instead of "=". + "$..output.ResponseBody" + ] + ) + @markers.aws.validated + def test_invoke_with_query_parameters( + self, + aws_client, + create_lambda_function, + create_role_with_policy, + create_state_machine_iam_role, + create_state_machine, + create_rest_apigw, + sfn_snapshot, + ): + self._add_api_gateway_transformers(sfn_snapshot) + + http_method = "POST" + part_path = "id_func" + + api_url, api_stage = self._create_lambda_api_response( + apigw_client=aws_client.apigateway, + create_lambda_function=create_lambda_function, + create_role_with_policy=create_role_with_policy, + lambda_function_filename=ST.LAMBDA_ID_FUNCTION, + create_rest_apigw=create_rest_apigw, + http_method=http_method, + part_path=part_path, + pipe_query_parameters=True, + ) + + template = ST.load_sfn_template(ST.API_GATEWAY_INVOKE_WITH_QUERY_PARAMETERS) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "ApiEndpoint": api_url, + "Method": http_method, + "Path": part_path, + "Stage": api_stage, + "RequestBody": {"message": ["Hello", "World!"]}, + "AllowNullValues": True, + "QueryParameters": {"param1": ["Hello"], "param2": ["World"]}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: apigw should return an authorisation error (403) but 404 is returned instead. + "$..error", + # TODO: add support for error decoration. + "$..cause", + ] + ) + @markers.aws.validated + def test_invoke_error( + self, + aws_client, + create_lambda_function, + create_role_with_policy, + create_state_machine_iam_role, + create_state_machine, + create_rest_apigw, + sfn_snapshot, + ): + self._add_api_gateway_transformers(sfn_snapshot) + + http_method = "POST" + part_path = "id_func" + + api_url, api_stage = self._create_lambda_api_response( + apigw_client=aws_client.apigateway, + create_lambda_function=create_lambda_function, + create_role_with_policy=create_role_with_policy, + lambda_function_filename=ST.LAMBDA_ID_FUNCTION, + create_rest_apigw=create_rest_apigw, + http_method=http_method, + part_path=part_path, + ) + + template = ST.load_sfn_template(ST.API_GATEWAY_INVOKE_WITH_BODY) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "ApiEndpoint": api_url, + "Method": http_method, + "Path": part_path + "invalid", + "Stage": api_stage, + "RequestBody": "HelloWorld", + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.snapshot.json new file mode 100644 index 0000000000000..ee88f11d33538 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.snapshot.json @@ -0,0 +1,2626 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_query_parameters": { + "recorded-date": "20-08-2023, 15:18:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": [ + "Hello", + "World!" + ] + }, + "AllowNullValues": true, + "QueryParameters": { + "param1": [ + "Hello" + ], + "param2": [ + "World" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": [ + "Hello", + "World!" + ] + }, + "AllowNullValues": true, + "QueryParameters": { + "param1": [ + "Hello" + ], + "param2": [ + "World" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "AllowNullValues": true, + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "QueryParameters": { + "param1": [ + "Hello" + ], + "param2": [ + "World" + ] + }, + "RequestBody": { + "message": [ + "Hello", + "World!" + ] + } + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "99" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "body": { + "message": [ + "Hello", + "World!" + ] + }, + "queryStringParameters": "{param1=Hello, param2=World}" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "99" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "body": { + "message": [ + "Hello", + "World!" + ] + }, + "queryStringParameters": "{param1=Hello, param2=World}" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "99" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "body": { + "message": [ + "Hello", + "World!" + ] + }, + "queryStringParameters": "{param1=Hello, param2=World}" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_base": { + "recorded-date": "20-08-2023, 15:26:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "get_constant", + "Stage": "sfn-apigw-api" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "get_constant", + "Stage": "sfn-apigw-api" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "get_constant", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": {}, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": {}, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": {}, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[None]": { + "recorded-date": "25-08-2023, 12:40:49", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": null + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": null + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": {}, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": {}, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": {}, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[]": { + "recorded-date": "25-08-2023, 12:41:13", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": "" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "2" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[HelloWorld]": { + "recorded-date": "25-08-2023, 12:41:49", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": "HelloWorld" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "12" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "HelloWorld", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "12" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "HelloWorld", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "12" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "HelloWorld", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[\"HelloWorld\"]": { + "recorded-date": "20-08-2023, 15:23:33", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": "\"HelloWorld\"" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "16" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "\"HelloWorld\"", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "16" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "\"HelloWorld\"", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "16" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "\"HelloWorld\"", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[0]": { + "recorded-date": "20-08-2023, 15:24:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "0" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "0" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": "0" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "3" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "0", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "3" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "0", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "3" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "0", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[true]": { + "recorded-date": "20-08-2023, 15:24:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "true" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "true" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": "true" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "6" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "true", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "6" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "true", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "6" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "true", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[{\"message\": \"HelloWorld!\"}]": { + "recorded-date": "20-08-2023, 15:24:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "{\"message\": \"HelloWorld!\"}" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": "{\"message\": \"HelloWorld!\"}" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": "{\"message\": \"HelloWorld!\"}" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "32" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "{\"message\": \"HelloWorld!\"}", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "32" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "{\"message\": \"HelloWorld!\"}", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "32" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": "{\"message\": \"HelloWorld!\"}", + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_error": { + "recorded-date": "20-08-2023, 16:15:34", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_funcinvalid", + "Stage": "sfn-apigw-api", + "RequestBody": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_funcinvalid", + "Stage": "sfn-apigw-api", + "RequestBody": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_funcinvalid", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": "HelloWorld" + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "message": "Missing Authentication Token" + }, + "error": "ApiGateway.403", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "message": "Missing Authentication Token" + }, + "error": "ApiGateway.403" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[request_body3]": { + "recorded-date": "25-08-2023, 12:42:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": { + "message": "HelloWorld!" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[singleStringHeader]": { + "recorded-date": "06-10-2024, 14:50:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + }, + "Headers": { + "custom_header": "singleStringHeader" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + }, + "Headers": { + "custom_header": "singleStringHeader" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'ApiGatewayInvoke' (entered at the event id #2). The Parameters '' could not be used to start the Task: [The value of the field 'Headers' has an invalid format]", + "error": "States.Runtime" + }, + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[custom_header1]": { + "recorded-date": "06-10-2024, 14:50:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + }, + "Headers": { + "custom_header": [ + "arrayHeader0" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + }, + "Headers": { + "custom_header": [ + "arrayHeader0" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Headers": { + "custom_header": [ + "arrayHeader0" + ] + }, + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": { + "message": "HelloWorld!" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[custom_header2]": { + "recorded-date": "06-10-2024, 14:51:07", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + }, + "Headers": { + "custom_header": [ + "arrayHeader0", + "arrayHeader1" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "ApiEndpoint": "", + "Method": "POST", + "Path": "id_func", + "Stage": "sfn-apigw-api", + "RequestBody": { + "message": "HelloWorld!" + }, + "Headers": { + "custom_header": [ + "arrayHeader0", + "arrayHeader1" + ] + } + }, + "inputDetails": { + "truncated": false + }, + "name": "ApiGatewayInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Path": "id_func", + "Headers": { + "custom_header": [ + "arrayHeader0", + "arrayHeader1" + ] + }, + "Stage": "sfn-apigw-api", + "ApiEndpoint": "", + "Method": "POST", + "RequestBody": { + "message": "HelloWorld!" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "apigateway" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ApiGatewayInvoke", + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Headers": { + "Connection": [ + "keep-alive" + ], + "Content-Length": [ + "26" + ], + "Content-Type": [ + "application/json" + ], + "Date": "", + "Via": "", + "x-amz-apigw-id": "", + "X-Amz-Cf-Id": "", + "X-Amz-Cf-Pop": "", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "", + "X-Cache": [ + "Miss from cloudfront" + ] + }, + "ResponseBody": { + "message": "HelloWorld!" + }, + "StatusCode": 200, + "StatusText": "OK" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.validation.json new file mode 100644 index 0000000000000..22773c1a8de00 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.validation.json @@ -0,0 +1,32 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_base": { + "last_validated_date": "2023-08-20T13:26:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_error": { + "last_validated_date": "2023-08-20T14:15:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[HelloWorld]": { + "last_validated_date": "2023-08-25T10:41:49+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[None]": { + "last_validated_date": "2023-08-25T10:40:49+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[]": { + "last_validated_date": "2023-08-25T10:41:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_body_post[request_body3]": { + "last_validated_date": "2023-08-25T10:42:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[custom_header1]": { + "last_validated_date": "2024-10-06T14:50:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[custom_header2]": { + "last_validated_date": "2024-10-06T14:51:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_headers[singleStringHeader]": { + "last_validated_date": "2024-10-06T14:50:24+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_apigetway_task_service.py::TestTaskApiGateway::test_invoke_with_query_parameters": { + "last_validated_date": "2023-08-20T13:18:59+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py new file mode 100644 index 0000000000000..0c30e5af18d58 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py @@ -0,0 +1,336 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, + create_state_machine_with_iam_role, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate as BT +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestTaskServiceAwsSdk: + @markers.snapshot.skip_snapshot_verify(paths=["$..SecretList"]) + @markers.aws.validated + def test_list_secrets( + self, aws_client, create_state_machine_iam_role, create_state_machine, sfn_snapshot + ): + template = ST.load_sfn_template(ST.AWSSDK_LIST_SECRETS) + definition = json.dumps(template) + exec_input = json.dumps(dict()) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_dynamodb_put_get_item( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + dynamodb_create_table, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + table_name = f"sfn_test_table_{short_uid()}" + dynamodb_create_table(table_name=table_name, partition_key="id", client=aws_client.dynamodb) + + template = ST.load_sfn_template(ST.AWS_SDK_DYNAMODB_PUT_GET_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "HelloWorld"}, "id": {"S": "id1"}}, + "Key": {"id": {"S": "id1"}}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_dynamodb_put_delete_item( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + dynamodb_create_table, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + table_name = f"sfn_test_table_{short_uid()}" + dynamodb_create_table(table_name=table_name, partition_key="id", client=aws_client.dynamodb) + + template = ST.load_sfn_template(ST.AWS_SDK_DYNAMODB_PUT_DELETE_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "HelloWorld"}, "id": {"S": "id1"}}, + "Key": {"id": {"S": "id1"}}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_dynamodb_put_update_get_item( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + dynamodb_create_table, + snapshot, + ): + snapshot.add_transformer(snapshot.transform.dynamodb_api()) + + table_name = f"sfn_test_table_{short_uid()}" + dynamodb_create_table(table_name=table_name, partition_key="id", client=aws_client.dynamodb) + + template = ST.load_sfn_template(ST.AWS_SDK_DYNAMODB_PUT_UPDATE_GET_ITEM) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "HelloWorld"}, "id": {"S": "id1"}}, + "Key": {"id": {"S": "id1"}}, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": {":r": {"S": "HelloWorldUpdated"}}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + snapshot, + definition, + exec_input, + ) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: aws-sdk SFN integration now appears to be inserting decorated error names into the cause messages. + # Upcoming work should collect more failure snapshot involving other aws-sdk integrations and trace a + # picture of generalisability of this behaviour. + # Hence insert this into the logic of the aws-sdk integration. + "$..cause" + ] + ) + @pytest.mark.parametrize( + "state_machine_template", + [ + ST.load_sfn_template(ST.AWS_SDK_SFN_SEND_TASK_SUCCESS), + ST.load_sfn_template(ST.AWS_SDK_SFN_SEND_TASK_FAILURE), + ], + ) + @markers.aws.validated + def test_sfn_send_task_outcome_with_no_such_token( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + state_machine_template, + ): + definition = json.dumps(state_machine_template) + + exec_input = json.dumps({"TaskToken": "NoSuchTaskToken"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_sfn_start_execution( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template_target = BT.load_sfn_template(BT.BASE_RAISE_FAILURE) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = ST.load_sfn_template(ST.AWS_SDK_SFN_START_EXECUTION) + definition = json.dumps(template) + + exec_input = json.dumps( + {"StateMachineArn": state_machine_arn_target, "Input": None, "Name": "TestStartTarget"} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_sfn_start_execution_implicit_json_serialisation( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.ExecutionArn", + replacement="execution-arn", + replace_reference=True, + ) + ) + + template_target = BT.load_sfn_template(BT.BASE_PASS_RESULT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = ST.load_sfn_template(ST.AWS_SDK_SFN_START_EXECUTION_IMPLICIT_JSON_SERIALISATION) + template["States"]["StartTarget"]["Parameters"]["StateMachineArn"] = ( + state_machine_arn_target + ) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "file_body", + ["", "text data", b"", b"binary data", bytearray(b"byte array data")], + ids=["empty_str", "str", "empty_binary", "binary", "bytearray"], + ) + # it seems the SFn internal client does not return the checksum values from the object yet, maybe it hasn't + # been updated to parse those fields? + @markers.snapshot.skip_snapshot_verify(paths=["$..ChecksumCrc32", "$..ChecksumType"]) + def test_s3_get_object( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + file_body, + ): + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + file_key = "file_key" + aws_client.s3.put_object(Bucket=bucket_name, Key=file_key, Body=file_body) + + template = ST.load_sfn_template(ST.AWS_SDK_S3_GET_OBJECT) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": file_key}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..ContentType", # TODO: update the default ContentType + # it seems the SFn internal client does not return the checksum values from the object yet, maybe it hasn't + # been updated to parse those fields? + "$..ChecksumCrc32", # returned by LocalStack, casing issue + "$..ChecksumCRC32", # returned by AWS + ] + ) + @pytest.mark.parametrize( + "body", + ["text data", {"Dict": "Value"}, ["List", "Data"], False, 0], + ids=["str", "dict", "list", "bool", "num"], + ) + def test_s3_put_object( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + body, + ): + file_key = f"file-key-{short_uid()}" + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(file_key, "file-key")) + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket-name")) + + template = ST.load_sfn_template(ST.AWS_SDK_S3_PUT_OBJECT) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": file_key, "Body": body}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + get_object_response = aws_client.s3.get_object(Bucket=bucket_name, Key=file_key) + + sfn_snapshot.match("get-s3-object", get_object_response) diff --git a/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.snapshot.json new file mode 100644 index 0000000000000..7fd6cff9f6673 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.snapshot.json @@ -0,0 +1,3082 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_list_secrets": { + "recorded-date": "22-06-2023, 13:59:49", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": {}, + "region": "", + "resource": "listSecrets", + "resourceType": "aws-sdk:secretsmanager" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "listSecrets", + "resourceType": "aws-sdk:secretsmanager" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "SecretList": [] + }, + "outputDetails": { + "truncated": false + }, + "resource": "listSecrets", + "resourceType": "aws-sdk:secretsmanager" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "SecretList": [] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "SecretList": [] + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "SecretList": [], + "final": { + "SecretList": [] + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "SecretList": [], + "final": { + "SecretList": [] + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_get_item": { + "recorded-date": "16-05-2023, 22:27:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {} + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {} + }, + "inputDetails": { + "truncated": false + }, + "name": "GetItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Key": { + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "getItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "getItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": { + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "getItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "GetItem", + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {}, + "getItemOutput": { + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {}, + "getItemOutput": { + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_delete_item": { + "recorded-date": "16-05-2023, 22:28:05", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {} + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {} + }, + "inputDetails": { + "truncated": false + }, + "name": "DeleteItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Key": { + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "deleteItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "deleteItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + }, + "resource": "deleteItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "DeleteItem", + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {}, + "deleteItemOutput": {} + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "putItemOutput": {}, + "deleteItemOutput": {} + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_update_get_item": { + "recorded-date": "16-05-2023, 22:28:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": {} + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": {} + }, + "inputDetails": { + "truncated": false + }, + "name": "UpdateItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "ReturnValues": "UPDATED_NEW", + "TableName": "", + "UpdateExpression": "set S=:r", + "Key": { + "id": { + "S": "id1" + } + }, + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "region": "", + "resource": "updateItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "updateItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "updateItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "UpdateItem", + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": {}, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": {}, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "GetItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "", + "Key": { + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "getItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 14, + "previousEventId": 13, + "taskStartedEventDetails": { + "resource": "getItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 15, + "previousEventId": 14, + "taskSucceededEventDetails": { + "output": { + "Item": { + "S": { + "S": "HelloWorldUpdated" + }, + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "getItem", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "GetItem", + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": {}, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + } + }, + "getItemOutput": { + "Item": { + "S": { + "S": "HelloWorldUpdated" + }, + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": {}, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + } + }, + "getItemOutput": { + "Item": { + "S": { + "S": "HelloWorldUpdated" + }, + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 17, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_start_execution": { + "recorded-date": "18-12-2023, 14:22:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "StartDate": "date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "StartDate": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "StartDate": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_start_execution_implicit_json_serialisation": { + "recorded-date": "05-02-2024, 11:29:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "SetupVariables" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "SetupVariables", + "output": { + "Input": { + "key": "value" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "Input": { + "key": "value" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "StartTarget" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "previousEventId": 4, + "taskScheduledEventDetails": { + "parameters": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": { + "key": "value" + } + }, + "region": "", + "resource": "startExecution", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "taskStartedEventDetails": { + "resource": "startExecution", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 7, + "previousEventId": 6, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "", + "StartDate": "date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "StartTarget", + "output": { + "ExecutionArn": "", + "StartDate": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "", + "StartDate": "date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_send_task_outcome_with_no_such_token[state_machine_template0]": { + "recorded-date": "10-04-2024, 18:55:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TaskToken": "NoSuchTaskToken" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TaskToken": "NoSuchTaskToken" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Output": "ParameterOutput", + "TaskToken": "NoSuchTaskToken" + }, + "region": "", + "resource": "sendTaskSuccess", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendTaskSuccess", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Invalid Token: 'Invalid token' (Service: Sfn, Status Code: 400, Request ID: )", + "error": "Sfn.InvalidTokenException", + "resource": "sendTaskSuccess", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Invalid Token: 'Invalid token' (Service: Sfn, Status Code: 400, Request ID: )", + "error": "Sfn.InvalidTokenException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_send_task_outcome_with_no_such_token[state_machine_template1]": { + "recorded-date": "10-04-2024, 18:55:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TaskToken": "NoSuchTaskToken" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TaskToken": "NoSuchTaskToken" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TaskToken": "NoSuchTaskToken" + }, + "region": "", + "resource": "sendTaskFailure", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendTaskFailure", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Invalid Token: 'Invalid token' (Service: Sfn, Status Code: 400, Request ID: )", + "error": "Sfn.InvalidTokenException", + "resource": "sendTaskFailure", + "resourceType": "aws-sdk:sfn" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Invalid Token: 'Invalid token' (Service: Sfn, Status Code: 400, Request ID: )", + "error": "Sfn.InvalidTokenException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[empty_str]": { + "recorded-date": "27-01-2025, 10:17:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "name": "S3GetObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "region": "", + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 0, + "ContentType": "binary/octet-stream", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3GetObject", + "output": { + "AcceptRanges": "bytes", + "ContentLength": 0, + "ContentType": "binary/octet-stream", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 0, + "ContentType": "binary/octet-stream", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[str]": { + "recorded-date": "27-01-2025, 10:18:02", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "name": "S3GetObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "region": "", + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"332eec544317a6d340b8e77da7fe54ad\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "text data" + }, + "outputDetails": { + "truncated": false + }, + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3GetObject", + "output": { + "AcceptRanges": "bytes", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"332eec544317a6d340b8e77da7fe54ad\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "text data" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 9, + "ContentType": "binary/octet-stream", + "ETag": "\"332eec544317a6d340b8e77da7fe54ad\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "text data" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[empty_binary]": { + "recorded-date": "27-01-2025, 10:18:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "name": "S3GetObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "region": "", + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 0, + "ContentType": "binary/octet-stream", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3GetObject", + "output": { + "AcceptRanges": "bytes", + "ContentLength": 0, + "ContentType": "binary/octet-stream", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 0, + "ContentType": "binary/octet-stream", + "ETag": "\"d41d8cd98f00b204e9800998ecf8427e\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[binary]": { + "recorded-date": "27-01-2025, 10:18:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "name": "S3GetObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "region": "", + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e1a49b59e0c42e4fd3735ad644f25d57\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "binary data" + }, + "outputDetails": { + "truncated": false + }, + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3GetObject", + "output": { + "AcceptRanges": "bytes", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e1a49b59e0c42e4fd3735ad644f25d57\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "binary data" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 11, + "ContentType": "binary/octet-stream", + "ETag": "\"e1a49b59e0c42e4fd3735ad644f25d57\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "binary data" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[bytearray]": { + "recorded-date": "27-01-2025, 10:18:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "inputDetails": { + "truncated": false + }, + "name": "S3GetObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Key": "file_key" + }, + "region": "", + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 15, + "ContentType": "binary/octet-stream", + "ETag": "\"383fd42bdf94a2ab38cbeb1d8eb7e53f\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "byte array data" + }, + "outputDetails": { + "truncated": false + }, + "resource": "getObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3GetObject", + "output": { + "AcceptRanges": "bytes", + "ContentLength": 15, + "ContentType": "binary/octet-stream", + "ETag": "\"383fd42bdf94a2ab38cbeb1d8eb7e53f\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "byte array data" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "AcceptRanges": "bytes", + "ContentLength": 15, + "ContentType": "binary/octet-stream", + "ETag": "\"383fd42bdf94a2ab38cbeb1d8eb7e53f\"", + "LastModified": "date", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "Body": "byte array data" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[str]": { + "recorded-date": "27-01-2025, 10:29:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": "text data" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": "text data" + }, + "inputDetails": { + "truncated": false + }, + "name": "S3PutObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Body": "text data", + "Key": "file-key" + }, + "region": "", + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ChecksumCRC32": "KUmHvQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2b5df3712557f503a9977a8ea893b2c9\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + }, + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3PutObject", + "output": { + "ChecksumCRC32": "KUmHvQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2b5df3712557f503a9977a8ea893b2c9\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ChecksumCRC32": "KUmHvQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"2b5df3712557f503a9977a8ea893b2c9\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-s3-object": { + "AcceptRanges": "bytes", + "Body": "\"text data\"", + "ChecksumCRC32": "KUmHvQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 11, + "ContentType": "text/plain; charset=UTF-8", + "ETag": "\"2b5df3712557f503a9977a8ea893b2c9\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[dict]": { + "recorded-date": "27-01-2025, 10:29:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": { + "Dict": "Value" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": { + "Dict": "Value" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "S3PutObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Body": { + "Dict": "Value" + }, + "Key": "file-key" + }, + "region": "", + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ChecksumCRC32": "hnEfWw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"49e31cee5aec8faf3345893addb14346\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + }, + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3PutObject", + "output": { + "ChecksumCRC32": "hnEfWw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"49e31cee5aec8faf3345893addb14346\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ChecksumCRC32": "hnEfWw==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"49e31cee5aec8faf3345893addb14346\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-s3-object": { + "AcceptRanges": "bytes", + "Body": { + "Dict": "Value" + }, + "ChecksumCRC32": "hnEfWw==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 16, + "ContentType": "text/plain; charset=UTF-8", + "ETag": "\"49e31cee5aec8faf3345893addb14346\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[list]": { + "recorded-date": "27-01-2025, 10:29:44", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": [ + "List", + "Data" + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": [ + "List", + "Data" + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "S3PutObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Body": [ + "List", + "Data" + ], + "Key": "file-key" + }, + "region": "", + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ChecksumCRC32": "1F2MPA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"6b23860357f6e63a0272b7f1143a663a\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + }, + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3PutObject", + "output": { + "ChecksumCRC32": "1F2MPA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"6b23860357f6e63a0272b7f1143a663a\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ChecksumCRC32": "1F2MPA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"6b23860357f6e63a0272b7f1143a663a\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-s3-object": { + "AcceptRanges": "bytes", + "Body": "[\"List\",\"Data\"]", + "ChecksumCRC32": "1F2MPA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 15, + "ContentType": "text/plain; charset=UTF-8", + "ETag": "\"6b23860357f6e63a0272b7f1143a663a\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[bool]": { + "recorded-date": "27-01-2025, 10:30:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": false + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": false + }, + "inputDetails": { + "truncated": false + }, + "name": "S3PutObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Body": false, + "Key": "file-key" + }, + "region": "", + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ChecksumCRC32": "K81oMA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"68934a3e9455fa72420237eb05902327\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + }, + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3PutObject", + "output": { + "ChecksumCRC32": "K81oMA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"68934a3e9455fa72420237eb05902327\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ChecksumCRC32": "K81oMA==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"68934a3e9455fa72420237eb05902327\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-s3-object": { + "AcceptRanges": "bytes", + "Body": "false", + "ChecksumCRC32": "K81oMA==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 5, + "ContentType": "text/plain; charset=UTF-8", + "ETag": "\"68934a3e9455fa72420237eb05902327\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[num]": { + "recorded-date": "27-01-2025, 10:30:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": 0 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Bucket": "bucket-name", + "Key": "file-key", + "Body": 0 + }, + "inputDetails": { + "truncated": false + }, + "name": "S3PutObject" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Bucket": "bucket-name", + "Body": 0, + "Key": "file-key" + }, + "region": "", + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ChecksumCRC32": "9NvfIQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"cfcd208495d565ef66e7dff9f98764da\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + }, + "resource": "putObject", + "resourceType": "aws-sdk:s3" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "S3PutObject", + "output": { + "ChecksumCRC32": "9NvfIQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"cfcd208495d565ef66e7dff9f98764da\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ChecksumCRC32": "9NvfIQ==", + "ChecksumType": "FULL_OBJECT", + "ETag": "\"cfcd208495d565ef66e7dff9f98764da\"", + "ServerSideEncryption": "AES256" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get-s3-object": { + "AcceptRanges": "bytes", + "Body": "0", + "ChecksumCRC32": "9NvfIQ==", + "ChecksumType": "FULL_OBJECT", + "ContentLength": 1, + "ContentType": "text/plain; charset=UTF-8", + "ETag": "\"cfcd208495d565ef66e7dff9f98764da\"", + "LastModified": "datetime", + "Metadata": {}, + "ServerSideEncryption": "AES256", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.validation.json new file mode 100644 index 0000000000000..53dcdf9b58d8d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.validation.json @@ -0,0 +1,53 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_delete_item": { + "last_validated_date": "2023-05-16T20:28:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_get_item": { + "last_validated_date": "2023-05-16T20:27:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_dynamodb_put_update_get_item": { + "last_validated_date": "2023-05-16T20:28:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_list_secrets": { + "last_validated_date": "2023-06-22T11:59:49+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[binary]": { + "last_validated_date": "2025-01-27T10:18:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[bytearray]": { + "last_validated_date": "2025-01-27T10:18:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[empty_binary]": { + "last_validated_date": "2025-01-27T10:18:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[empty_str]": { + "last_validated_date": "2025-01-27T10:17:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_get_object[str]": { + "last_validated_date": "2025-01-27T10:18:02+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[bool]": { + "last_validated_date": "2025-01-27T10:30:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[dict]": { + "last_validated_date": "2025-01-27T10:29:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[list]": { + "last_validated_date": "2025-01-27T10:29:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[num]": { + "last_validated_date": "2025-01-27T10:30:17+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_s3_put_object[str]": { + "last_validated_date": "2025-01-27T10:29:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_send_task_outcome_with_no_such_token[state_machine_template0]": { + "last_validated_date": "2024-04-10T18:55:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_send_task_outcome_with_no_such_token[state_machine_template1]": { + "last_validated_date": "2024-04-10T18:55:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_aws_sdk_task_service.py::TestTaskServiceAwsSdk::test_sfn_start_execution_implicit_json_serialisation": { + "last_validated_date": "2024-02-05T11:29:16+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py new file mode 100644 index 0000000000000..b198625b0f1b4 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py @@ -0,0 +1,96 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, + create_state_machine_with_iam_role, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestTaskServiceDynamoDB: + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + ST.DYNAMODB_PUT_GET_ITEM, + ST.DYNAMODB_PUT_DELETE_ITEM, + ST.DYNAMODB_PUT_UPDATE_GET_ITEM, + ST.DYNAMODB_PUT_QUERY, + ], + ids=[ + "DYNAMODB_PUT_GET_ITEM", + "DYNAMODB_PUT_DELETE_ITEM", + "DYNAMODB_PUT_UPDATE_GET_ITEM", + "DYNAMODB_PUT_QUERY", + ], + ) + def test_base_integrations( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + dynamodb_create_table, + sfn_snapshot, + template_path, + ): + table_name = f"sfn_test_table_{short_uid()}" + dynamodb_create_table(table_name=table_name, partition_key="id", client=aws_client.dynamodb) + sfn_snapshot.add_transformer(RegexTransformer(table_name, "table-name")) + + template = ST.load_sfn_template(template_path) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "TableName": table_name, + "Item": {"data": {"S": "HelloWorld"}, "id": {"S": "id1"}}, + "Key": {"id": {"S": "id1"}}, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": {":r": {"S": "HelloWorldUpdated"}}, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_invalid_integration( + self, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.INVALID_INTEGRATION_DYNAMODB_QUERY) + definition = json.dumps(template) + with pytest.raises(Exception) as ex: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + sfn_snapshot.match( + "exception", {"exception_typename": ex.typename, "exception_value": ex.value} + ) diff --git a/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.snapshot.json new file mode 100644 index 0000000000000..a3014785e3f0e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.snapshot.json @@ -0,0 +1,2689 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_GET_ITEM]": { + "recorded-date": "03-02-2025, 16:31:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "GetItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Key": { + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": { + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "53" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "53", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "GetItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "getItemOutput": { + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "53" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "53", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "getItemOutput": { + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "53" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "53", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_DELETE_ITEM]": { + "recorded-date": "03-02-2025, 16:31:51", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "DeleteItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Key": { + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "deleteItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "deleteItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "deleteItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "DeleteItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "deleteItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "deleteItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_UPDATE_GET_ITEM]": { + "recorded-date": "03-02-2025, 16:34:47", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "UpdateItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "ReturnValues": "UPDATED_NEW", + "TableName": "table-name", + "UpdateExpression": "set S=:r", + "Key": { + "id": { + "S": "id1" + } + }, + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "region": "", + "resource": "updateItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "updateItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "46" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "46", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "updateItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "UpdateItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "46" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "46", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 12, + "previousEventId": 11, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "46" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "46", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "GetItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Key": { + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 14, + "previousEventId": 13, + "taskStartedEventDetails": { + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 15, + "previousEventId": 14, + "taskSucceededEventDetails": { + "output": { + "Item": { + "S": { + "S": "HelloWorldUpdated" + }, + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "83" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "83", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "getItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "GetItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "46" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "46", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "getItemOutput": { + "Item": { + "S": { + "S": "HelloWorldUpdated" + }, + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "83" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "83", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "updateItemOutput": { + "Attributes": { + "S": { + "S": "HelloWorldUpdated" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "46" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "46", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "getItemOutput": { + "Item": { + "S": { + "S": "HelloWorldUpdated" + }, + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "83" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "83", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 17, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_invalid_integration": { + "recorded-date": "03-02-2025, 16:35:03", + "recorded-content": { + "exception": { + "exception_typename": "InvalidDefinition", + "exception_value": "An error occurred (InvalidDefinition) when calling the CreateStateMachine operation: Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: The resource provided arn::states:::dynamodb:query is not recognized. The value is not a valid resource ARN, or the resource is not available in this region. at /States/Query/Resource'" + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_QUERY]": { + "recorded-date": "05-02-2025, 09:50:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "PutItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + }, + "region": "", + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "putItem", + "resourceType": "dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutItem", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + } + }, + "inputDetails": { + "truncated": false + }, + "name": "QueryItems" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "taskScheduledEventDetails": { + "parameters": { + "KeyConditionExpression": "id = :id", + "ExpressionAttributeValues": { + ":id": { + "S": "id1" + } + }, + "TableName": "table-name" + }, + "region": "", + "resource": "query", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "taskStartedEventDetails": { + "resource": "query", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 10, + "previousEventId": 9, + "taskSucceededEventDetails": { + "output": { + "Count": 1, + "Items": [ + { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + ], + "ScannedCount": 1 + }, + "outputDetails": { + "truncated": false + }, + "resource": "query", + "resourceType": "aws-sdk:dynamodb" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "QueryItems", + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "queryOutput": { + "Count": 1, + "Items": [ + { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + ], + "ScannedCount": 1 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "TableName": "table-name", + "Item": { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + }, + "Key": { + "id": { + "S": "id1" + } + }, + "UpdateExpression": "set S=:r", + "ExpressionAttributeValues": { + ":r": { + "S": "HelloWorldUpdated" + } + }, + "putItemOutput": { + "SdkHttpMetadata": { + "AllHttpHeaders": { + "Server": [ + "Server" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": "x-amzn-RequestId", + "x-amz-crc32": "x-amz-crc32", + "Content-Length": [ + "2" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "Server": "Server", + "x-amz-crc32": "x-amz-crc32", + "x-amzn-RequestId": "x-amzn-RequestId" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "RequestId" + } + }, + "queryOutput": { + "Count": 1, + "Items": [ + { + "data": { + "S": "HelloWorld" + }, + "id": { + "S": "id1" + } + } + ], + "ScannedCount": 1 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.validation.json new file mode 100644 index 0000000000000..690385c45fd30 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_DELETE_ITEM]": { + "last_validated_date": "2025-02-03T16:31:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_GET_ITEM]": { + "last_validated_date": "2025-02-03T16:31:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_QUERY]": { + "last_validated_date": "2025-02-05T09:50:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_base_integrations[DYNAMODB_PUT_UPDATE_GET_ITEM]": { + "last_validated_date": "2025-02-03T16:34:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_dynamodb_task_service.py::TestTaskServiceDynamoDB::test_invalid_integration": { + "last_validated_date": "2025-02-03T16:35:03+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py new file mode 100644 index 0000000000000..94cd574279739 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py @@ -0,0 +1,397 @@ +import json + +import aws_cdk as cdk +import aws_cdk.aws_ecs as ecs +import aws_cdk.aws_stepfunctions as sfn +import aws_cdk.aws_stepfunctions_tasks as tasks +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import launch_and_record_execution +from localstack.utils.analytics.metadata import is_license_activated + +_ECS_SNAPSHOT_SKIP_PATHS: [list[str]] = [ + "$..Attachments..Details", + "$..Attachments..Id", + "$..Attachments..Status", + "$..Attachments..Type", + "$..AvailabilityZone", + "$..ClusterArn", + "$..Connectivity", + "$..ConnectivityAt", + "$..Cpu", + "$..DesiredStatus", + "$..ExecutionStoppedAt", + "$..GpuIds", + "$..Group", + "$..HealthStatus", + "$..ImageDigest", + "$..InferenceAccelerators", + "$..LastStatus", + "$..ManagedAgents", + "$..Memory", + "$..NetworkInterfaces", + "$..Overrides.ContainerOverrides", + "$..Overrides.InferenceAcceleratorOverrides", + "$..PlatformFamily", + "$..PullStartedAt", + "$..PullStoppedAt", + "$..RuntimeId", + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + "$..StartedAt", + "$..StopCode", + "$..StoppedAt", + "$..StoppedReason", + "$..StoppingAt", + "$..TaskDefinitionArn", + "$..Version", + "$..parameters.Cluster", +] + + +# TODO: figure out a better way, maybe via marker? e.g. @markers.localstack.ext +@pytest.mark.skipif(condition=not is_license_activated(), reason="integration test with pro") +class TestTaskServiceECS: + STACK_NAME = "StepFunctionsEcsTaskStack" + + @pytest.fixture(scope="class", autouse=False) + def infrastructure_test_run_task(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="StepFunctionsEcsTask", force_synth=False) + stack = cdk.Stack(infra.cdk_app, self.STACK_NAME) + + # cluster setup + cluster = ecs.Cluster(stack, "cluster") + + # task setup + launch_target = tasks.EcsFargateLaunchTarget( + platform_version=ecs.FargatePlatformVersion.VERSION1_4 + ) + task_def = ecs.FargateTaskDefinition(stack, "taskdef", cpu=256, memory_limit_mib=512) + task_def.add_container( + "maincontainer", + image=ecs.ContainerImage.from_registry("busybox"), + entry_point=["echo", "hello"], + essential=True, + ) + + # state machine setup + run_task = tasks.EcsRunTask( + stack, + "ecstask", + cluster=cluster, + launch_target=launch_target, # noqa + task_definition=task_def, + integration_pattern=sfn.IntegrationPattern.REQUEST_RESPONSE, + ) + definition_body = sfn.DefinitionBody.from_chainable(run_task) + statemachine = sfn.StateMachine(stack, "statemachine", definition_body=definition_body) + + # stack outputs + cdk.CfnOutput(stack, "TaskDefinitionArn", value=task_def.task_definition_arn) + cdk.CfnOutput(stack, "ClusterArn", value=cluster.cluster_arn) + cdk.CfnOutput(stack, "StateMachineArn", value=statemachine.state_machine_arn) + cdk.CfnOutput(stack, "ClusterName", value=cluster.cluster_name) + cdk.CfnOutput(stack, "StateMachineRoleArn", value=statemachine.role.role_arn) + cdk.CfnOutput(stack, "TaskDefinitionFamily", value=task_def.family) + cdk.CfnOutput( + stack, "TaskDefinitionContainerName", value=task_def.default_container.container_name + ) + + # provisioning + with infra.provisioner(skip_deployment=False, skip_teardown=False) as prov: + yield prov + + @pytest.fixture(scope="class", autouse=False) + def infrastructure_test_run_task_raise_failure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="StepFunctionsEcsTask", force_synth=False) + stack = cdk.Stack(infra.cdk_app, self.STACK_NAME) + + # cluster setup + cluster = ecs.Cluster(stack, "cluster") + + # task setup + launch_target = tasks.EcsFargateLaunchTarget( + platform_version=ecs.FargatePlatformVersion.VERSION1_4 + ) + task_def = ecs.FargateTaskDefinition(stack, "taskdef", cpu=256, memory_limit_mib=512) + task_def.add_container( + "maincontainer", + image=ecs.ContainerImage.from_registry("no_such_image"), + entry_point=["echo", "hello"], + essential=True, + ) + + # state machine setup + run_task = tasks.EcsRunTask( + stack, + "ecstask", + cluster=cluster, + launch_target=launch_target, # noqa + task_definition=task_def, + integration_pattern=sfn.IntegrationPattern.REQUEST_RESPONSE, + ) + definition_body = sfn.DefinitionBody.from_chainable(run_task) + statemachine = sfn.StateMachine(stack, "statemachine", definition_body=definition_body) + + # stack outputs + cdk.CfnOutput(stack, "TaskDefinitionArn", value=task_def.task_definition_arn) + cdk.CfnOutput(stack, "ClusterArn", value=cluster.cluster_arn) + cdk.CfnOutput(stack, "StateMachineArn", value=statemachine.state_machine_arn) + cdk.CfnOutput(stack, "ClusterName", value=cluster.cluster_name) + cdk.CfnOutput(stack, "StateMachineRoleArn", value=statemachine.role.role_arn) + cdk.CfnOutput(stack, "TaskDefinitionFamily", value=task_def.family) + cdk.CfnOutput( + stack, "TaskDefinitionContainerName", value=task_def.default_container.container_name + ) + + # provisioning + with infra.provisioner(skip_deployment=False, skip_teardown=False) as prov: + yield prov + + @pytest.fixture(scope="class", autouse=False) + def infrastructure_test_run_task_sync(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="StepFunctionsEcsTask", force_synth=False) + stack = cdk.Stack(infra.cdk_app, self.STACK_NAME) + + # cluster setup + cluster = ecs.Cluster(stack, "cluster") + + # task setup + launch_target = tasks.EcsFargateLaunchTarget( + platform_version=ecs.FargatePlatformVersion.VERSION1_4 + ) + task_def = ecs.FargateTaskDefinition(stack, "taskdef", cpu=256, memory_limit_mib=512) + task_def.add_container( + "maincontainer", + image=ecs.ContainerImage.from_registry("busybox"), + entry_point=["echo", "hello"], + essential=True, + ) + + # state machine setup + run_task = tasks.EcsRunTask( + stack, + "ecstask", + cluster=cluster, + launch_target=launch_target, # noqa + task_definition=task_def, + integration_pattern=sfn.IntegrationPattern.RUN_JOB, + ) + definition_body = sfn.DefinitionBody.from_chainable(run_task) + statemachine = sfn.StateMachine(stack, "statemachine", definition_body=definition_body) + + # stack outputs + cdk.CfnOutput(stack, "TaskDefinitionArn", value=task_def.task_definition_arn) + cdk.CfnOutput(stack, "ClusterArn", value=cluster.cluster_arn) + cdk.CfnOutput(stack, "StateMachineArn", value=statemachine.state_machine_arn) + cdk.CfnOutput(stack, "ClusterName", value=cluster.cluster_name) + cdk.CfnOutput(stack, "StateMachineRoleArn", value=statemachine.role.role_arn) + cdk.CfnOutput(stack, "TaskDefinitionFamily", value=task_def.family) + cdk.CfnOutput( + stack, "TaskDefinitionContainerName", value=task_def.default_container.container_name + ) + + # provisioning + with infra.provisioner(skip_deployment=False, skip_teardown=False) as prov: + yield prov + + @pytest.fixture(scope="class", autouse=False) + def infrastructure_test_run_task_sync_raise_failure(self, aws_client, infrastructure_setup): + infra = infrastructure_setup(namespace="StepFunctionsEcsTask", force_synth=False) + stack = cdk.Stack(infra.cdk_app, self.STACK_NAME) + + # cluster setup + cluster = ecs.Cluster(stack, "cluster") + + # task setup + launch_target = tasks.EcsFargateLaunchTarget( + platform_version=ecs.FargatePlatformVersion.VERSION1_4 + ) + task_def = ecs.FargateTaskDefinition(stack, "taskdef", cpu=256, memory_limit_mib=512) + task_def.add_container( + "maincontainer", + image=ecs.ContainerImage.from_registry("no_such_image"), + entry_point=["echo", "hello"], + essential=True, + ) + + # state machine setup + run_task = tasks.EcsRunTask( + stack, + "ecstask", + cluster=cluster, + launch_target=launch_target, # noqa + task_definition=task_def, + integration_pattern=sfn.IntegrationPattern.RUN_JOB, + ) + definition_body = sfn.DefinitionBody.from_chainable(run_task) + statemachine = sfn.StateMachine(stack, "statemachine", definition_body=definition_body) + + # stack outputs + cdk.CfnOutput(stack, "TaskDefinitionArn", value=task_def.task_definition_arn) + cdk.CfnOutput(stack, "ClusterArn", value=cluster.cluster_arn) + cdk.CfnOutput(stack, "StateMachineArn", value=statemachine.state_machine_arn) + cdk.CfnOutput(stack, "ClusterName", value=cluster.cluster_name) + cdk.CfnOutput(stack, "StateMachineRoleArn", value=statemachine.role.role_arn) + cdk.CfnOutput(stack, "TaskDefinitionFamily", value=task_def.family) + cdk.CfnOutput( + stack, "TaskDefinitionContainerName", value=task_def.default_container.container_name + ) + + # provisioning + with infra.provisioner(skip_deployment=False, skip_teardown=False) as prov: + yield prov + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=[*_ECS_SNAPSHOT_SKIP_PATHS, "$..StartedBy"]) + def test_run_task(self, aws_client, infrastructure_test_run_task, sfn_ecs_snapshot): + stack_outputs = infrastructure_test_run_task.get_stack_outputs(stack_name=self.STACK_NAME) + state_machine_arn = stack_outputs["StateMachineArn"] + + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionArn"], "task_definition_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["StateMachineRoleArn"], "state_machine_role_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterArn"], "cluster_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer( + stack_outputs["TaskDefinitionContainerName"], "task_definition_container_name" + ) + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionFamily"], "task_definition_family") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterName"], "cluster_name") + ) + sfn_ecs_snapshot.add_transformer(RegexTransformer(state_machine_arn, "state_machine_arn")) + + launch_and_record_execution( + target_aws_client=aws_client, + sfn_snapshot=sfn_ecs_snapshot, + state_machine_arn=state_machine_arn, + execution_input=json.dumps({}), + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=_ECS_SNAPSHOT_SKIP_PATHS) + @pytest.mark.skip(reason="ECS Provider doesn't raise failure on invalid image.") + def test_run_task_raise_failure( + self, aws_client, infrastructure_test_run_task_raise_failure, sfn_ecs_snapshot + ): + stack_outputs = infrastructure_test_run_task_raise_failure.get_stack_outputs( + stack_name=self.STACK_NAME + ) + state_machine_arn = stack_outputs["StateMachineArn"] + + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionArn"], "task_definition_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["StateMachineRoleArn"], "state_machine_role_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterArn"], "cluster_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer( + stack_outputs["TaskDefinitionContainerName"], "task_definition_container_name" + ) + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionFamily"], "task_definition_family") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterName"], "cluster_name") + ) + sfn_ecs_snapshot.add_transformer(RegexTransformer(state_machine_arn, "state_machine_arn")) + + launch_and_record_execution( + target_aws_client=aws_client, + sfn_snapshot=sfn_ecs_snapshot, + state_machine_arn=state_machine_arn, + execution_input=json.dumps({}), + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=_ECS_SNAPSHOT_SKIP_PATHS) + def test_run_task_sync(self, aws_client, infrastructure_test_run_task_sync, sfn_ecs_snapshot): + stack_outputs = infrastructure_test_run_task_sync.get_stack_outputs( + stack_name=self.STACK_NAME + ) + state_machine_arn = stack_outputs["StateMachineArn"] + + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionArn"], "task_definition_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["StateMachineRoleArn"], "state_machine_role_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterArn"], "cluster_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer( + stack_outputs["TaskDefinitionContainerName"], "task_definition_container_name" + ) + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionFamily"], "task_definition_family") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterName"], "cluster_name") + ) + sfn_ecs_snapshot.add_transformer(RegexTransformer(state_machine_arn, "state_machine_arn")) + + launch_and_record_execution( + target_aws_client=aws_client, + sfn_snapshot=sfn_ecs_snapshot, + state_machine_arn=state_machine_arn, + execution_input=json.dumps({}), + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=_ECS_SNAPSHOT_SKIP_PATHS) + @pytest.mark.skip(reason="ECS Provider doesn't raise failure on invalid image.") + def test_run_task_sync_raise_failure( + self, aws_client, infrastructure_test_run_task_sync_raise_failure, sfn_ecs_snapshot + ): + stack_outputs = infrastructure_test_run_task_sync_raise_failure.get_stack_outputs( + stack_name=self.STACK_NAME + ) + state_machine_arn = stack_outputs["StateMachineArn"] + + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionArn"], "task_definition_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["StateMachineRoleArn"], "state_machine_role_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterArn"], "cluster_arn") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer( + stack_outputs["TaskDefinitionContainerName"], "task_definition_container_name" + ) + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["TaskDefinitionFamily"], "task_definition_family") + ) + sfn_ecs_snapshot.add_transformer( + RegexTransformer(stack_outputs["ClusterName"], "cluster_name") + ) + sfn_ecs_snapshot.add_transformer(RegexTransformer(state_machine_arn, "state_machine_arn")) + + launch_and_record_execution( + target_aws_client=aws_client, + sfn_snapshot=sfn_ecs_snapshot, + state_machine_arn=state_machine_arn, + execution_input=json.dumps({}), + ) diff --git a/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.snapshot.json new file mode 100644 index 0000000000000..6b22c7ea11f76 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.snapshot.json @@ -0,0 +1,1731 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_sync": { + "recorded-date": "23-02-2024, 19:20:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "state_machine_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ecstask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Cluster": "cluster_arn", + "TaskDefinition": "task_definition_family", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "Subnets": [ + "subnet_value", + "subnet_value" + ], + "SecurityGroups": [ + "sg_value" + ] + } + }, + "LaunchType": "FARGATE", + "PlatformVersion": "1.4.0", + "StartedBy": "AWS Step Functions" + }, + "region": "", + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1595" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1595", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "cluster_arn", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "busybox", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "StartedBy": "AWS Step Functions", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "task_definition_arn", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + }, + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskSucceededEventDetails": { + "output": { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + }, + { + "Name": "networkInterfaceId", + "Value": "eni_value" + }, + { + "Name": "macAddress", + "Value": "mac_value" + }, + { + "Name": "privateDnsName", + "Value": "ip_value..compute.internal" + }, + { + "Name": "privateIPv4Address", + "Value": "" + } + ], + "Id": "", + "Status": "DELETED", + "Type": "eni" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "cluster_arn", + "Connectivity": "CONNECTED", + "ConnectivityAt": "time", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "ExitCode": 0, + "GpuIds": [], + "Image": "busybox", + "ImageDigest": "", + "LastStatus": "STOPPED", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [ + { + "AttachmentId": "", + "PrivateIpv4Address": "" + } + ], + "RuntimeId": "", + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "STOPPED", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "ExecutionStoppedAt": "time", + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "STOPPED", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformVersion": "1.4.0", + "PullStartedAt": "time", + "PullStoppedAt": "time", + "StartedAt": "time", + "StartedBy": "AWS Step Functions", + "StopCode": "EssentialContainerExited", + "StoppedAt": "time", + "StoppedReason": "Essential container in task exited", + "StoppingAt": "time", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "task_definition_arn", + "Version": 5 + }, + "outputDetails": { + "truncated": false + }, + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 7, + "previousEventId": 6, + "stateExitedEventDetails": { + "name": "ecstask", + "output": { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + }, + { + "Name": "networkInterfaceId", + "Value": "eni_value" + }, + { + "Name": "macAddress", + "Value": "mac_value" + }, + { + "Name": "privateDnsName", + "Value": "ip_value..compute.internal" + }, + { + "Name": "privateIPv4Address", + "Value": "" + } + ], + "Id": "", + "Status": "DELETED", + "Type": "eni" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "cluster_arn", + "Connectivity": "CONNECTED", + "ConnectivityAt": "time", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "ExitCode": 0, + "GpuIds": [], + "Image": "busybox", + "ImageDigest": "", + "LastStatus": "STOPPED", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [ + { + "AttachmentId": "", + "PrivateIpv4Address": "" + } + ], + "RuntimeId": "", + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "STOPPED", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "ExecutionStoppedAt": "time", + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "STOPPED", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformVersion": "1.4.0", + "PullStartedAt": "time", + "PullStoppedAt": "time", + "StartedAt": "time", + "StartedBy": "AWS Step Functions", + "StopCode": "EssentialContainerExited", + "StoppedAt": "time", + "StoppedReason": "Essential container in task exited", + "StoppingAt": "time", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "task_definition_arn", + "Version": 5 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + }, + { + "Name": "networkInterfaceId", + "Value": "eni_value" + }, + { + "Name": "macAddress", + "Value": "mac_value" + }, + { + "Name": "privateDnsName", + "Value": "ip_value..compute.internal" + }, + { + "Name": "privateIPv4Address", + "Value": "" + } + ], + "Id": "", + "Status": "DELETED", + "Type": "eni" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "cluster_arn", + "Connectivity": "CONNECTED", + "ConnectivityAt": "time", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "ExitCode": 0, + "GpuIds": [], + "Image": "busybox", + "ImageDigest": "", + "LastStatus": "STOPPED", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [ + { + "AttachmentId": "", + "PrivateIpv4Address": "" + } + ], + "RuntimeId": "", + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "STOPPED", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "ExecutionStoppedAt": "time", + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "STOPPED", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformVersion": "1.4.0", + "PullStartedAt": "time", + "PullStoppedAt": "time", + "StartedAt": "time", + "StartedBy": "AWS Step Functions", + "StopCode": "EssentialContainerExited", + "StoppedAt": "time", + "StoppedReason": "Essential container in task exited", + "StoppingAt": "time", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "task_definition_arn", + "Version": 5 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 8, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task": { + "recorded-date": "25-02-2024, 23:19:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "state_machine_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ecstask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Cluster": "ip_value-central-1:111111111111:cluster/cluster_name", + "TaskDefinition": "task_definition_family", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "Subnets": [ + "subnet_value", + "subnet_value" + ], + "SecurityGroups": [ + "sg_value" + ] + } + }, + "LaunchType": "FARGATE", + "PlatformVersion": "1.4.0" + }, + "region": "", + "resource": "runTask", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "runTask", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1562" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1562", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "busybox", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:17", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + }, + "resource": "runTask", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ecstask", + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1562" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1562", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "busybox", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:17", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1562" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1562", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "busybox", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:17", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_raise_failure": { + "recorded-date": "25-02-2024, 23:40:03", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "state_machine_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ecstask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Cluster": "ip_value-central-1:111111111111:cluster/cluster_name", + "TaskDefinition": "task_definition_family", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "Subnets": [ + "subnet_value", + "subnet_value" + ], + "SecurityGroups": [ + "sg_value" + ] + } + }, + "LaunchType": "FARGATE", + "PlatformVersion": "1.4.0" + }, + "region": "", + "resource": "runTask", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "runTask", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1568" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1568", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "a", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "no_such_image", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:18", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + }, + "resource": "runTask", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "ecstask", + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1568" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1568", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "a", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "no_such_image", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:18", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1568" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1568", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "a", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "no_such_image", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:18", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_sync_raise_failure": { + "recorded-date": "25-02-2024, 23:53:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "state_machine_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "ecstask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Cluster": "ip_value-central-1:111111111111:cluster/cluster_name", + "TaskDefinition": "task_definition_family", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "Subnets": [ + "subnet_value", + "subnet_value" + ], + "SecurityGroups": [ + "sg_value" + ] + } + }, + "LaunchType": "FARGATE", + "PlatformVersion": "1.4.0", + "StartedBy": "AWS Step Functions" + }, + "region": "", + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "Failures": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1601" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.1" + ] + }, + "HttpHeaders": { + "Content-Length": "1601", + "Content-Type": "application/x-amz-json-1.1", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "Tasks": [ + { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + } + ], + "Id": "", + "Status": "PRECREATED", + "Type": "ElasticNetworkInterface" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "no_such_image", + "LastStatus": "PENDING", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [], + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "RUNNING", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "PROVISIONING", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformFamily": "Linux", + "PlatformVersion": "1.4.0", + "StartedBy": "AWS Step Functions", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:19", + "Version": 1 + } + ] + }, + "outputDetails": { + "truncated": false + }, + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskFailedEventDetails": { + "cause": { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + }, + { + "Name": "networkInterfaceId", + "Value": "eni_value" + }, + { + "Name": "macAddress", + "Value": "ip_value:d2:a7" + }, + { + "Name": "privateDnsName", + "Value": "ip_value..compute.internal" + }, + { + "Name": "privateIPv4Address", + "Value": "" + } + ], + "Id": "", + "Status": "DELETED", + "Type": "eni" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Connectivity": "CONNECTED", + "ConnectivityAt": "time", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "no_such_image", + "LastStatus": "STOPPED", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [ + { + "AttachmentId": "", + "PrivateIpv4Address": "" + } + ], + "RuntimeId": "", + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "STOPPED", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "ExecutionStoppedAt": "time", + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "STOPPED", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformVersion": "1.4.0", + "StartedBy": "AWS Step Functions", + "StopCode": "TaskFailedToStart", + "StoppedAt": "time", + "StoppedReason": "CannotPullContainerError: pull image manifest has been retried 1 time(s): failed to resolve ref docker.io/library/no_such_image:latest: pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed", + "StoppingAt": "time", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:19", + "Version": 4 + }, + "error": "States.TaskFailed", + "resource": "runTask.sync", + "resourceType": "ecs" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "Attachments": [ + { + "Details": [ + { + "Name": "subnetId", + "Value": "subnet_value" + }, + { + "Name": "networkInterfaceId", + "Value": "eni_value" + }, + { + "Name": "macAddress", + "Value": "ip_value:d2:a7" + }, + { + "Name": "privateDnsName", + "Value": "ip_value..compute.internal" + }, + { + "Name": "privateIPv4Address", + "Value": "" + } + ], + "Id": "", + "Status": "DELETED", + "Type": "eni" + } + ], + "Attributes": [ + { + "Name": "ecs.cpu-architecture", + "Value": "x86_64" + } + ], + "AvailabilityZone": "b", + "ClusterArn": "ip_value-central-1:111111111111:cluster/cluster_name", + "Connectivity": "CONNECTED", + "ConnectivityAt": "time", + "Containers": [ + { + "ContainerArn": "", + "Cpu": "0", + "GpuIds": [], + "Image": "no_such_image", + "LastStatus": "STOPPED", + "ManagedAgents": [], + "Name": "task_definition_container_name", + "NetworkBindings": [], + "NetworkInterfaces": [ + { + "AttachmentId": "", + "PrivateIpv4Address": "" + } + ], + "RuntimeId": "", + "TaskArn": "" + } + ], + "Cpu": "256", + "CreatedAt": "time", + "DesiredStatus": "STOPPED", + "EnableExecuteCommand": false, + "EphemeralStorage": { + "SizeInGiB": 20 + }, + "ExecutionStoppedAt": "time", + "Group": "family:task_definition_family", + "InferenceAccelerators": [], + "LastStatus": "STOPPED", + "LaunchType": "FARGATE", + "Memory": "512", + "Overrides": { + "ContainerOverrides": [ + { + "Command": [], + "Environment": [], + "EnvironmentFiles": [], + "Name": "task_definition_container_name", + "ResourceRequirements": [] + } + ], + "InferenceAcceleratorOverrides": [] + }, + "PlatformVersion": "1.4.0", + "StartedBy": "AWS Step Functions", + "StopCode": "TaskFailedToStart", + "StoppedAt": "time", + "StoppedReason": "CannotPullContainerError: pull image manifest has been retried 1 time(s): failed to resolve ref docker.io/library/no_such_image:latest: pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed", + "StoppingAt": "time", + "Tags": [], + "TaskArn": "", + "TaskDefinitionArn": "ip_value-central-1:111111111111:task-definition/task_definition_family:19", + "Version": 4 + }, + "error": "States.TaskFailed" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.validation.json new file mode 100644 index 0000000000000..42e44eb5df235 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_machine_sync": { + "last_validated_date": "2024-02-23T19:20:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task": { + "last_validated_date": "2024-02-25T23:19:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_raise_failure": { + "last_validated_date": "2024-02-25T23:40:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_ecs_task_service.py::TestTaskServiceECS::test_run_task_sync_raise_failure": { + "last_validated_date": "2024-02-25T23:53:39+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_events_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_events_task_service.py new file mode 100644 index 0000000000000..14424fd3f5bd1 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_events_task_service.py @@ -0,0 +1,185 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, + record_sqs_events, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestTaskServiceEvents: + @markers.aws.validated + def test_put_events_base( + self, + create_state_machine_iam_role, + create_state_machine, + events_to_sqs_queue, + aws_client, + sfn_snapshot, + ): + detail_type = f"detail_type_{short_uid()}" + event_pattern = {"detail-type": [detail_type]} + queue_url = events_to_sqs_queue(event_pattern) + sfn_snapshot.add_transformer(RegexTransformer(detail_type, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + + template = ST.load_sfn_template(ST.EVENTS_PUT_EVENTS) + definition = json.dumps(template) + + entries = [ + { + "Detail": json.dumps({"Message": "HelloWorld0"}), + "DetailType": detail_type, + "Source": "some.source", + }, + { + "Detail": {"Message": "HelloWorld1"}, + "DetailType": detail_type, + "Source": "some.source", + }, + { + "Detail": {"Message": "HelloWorld2"}, + "DetailType": detail_type, + "Source": "some.source", + "Resources": [queue_url], + }, + ] + exec_input = json.dumps({"Entries": entries}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + record_sqs_events(aws_client, queue_url, sfn_snapshot, len(entries)) + + @markers.aws.validated + def test_put_events_malformed_detail( + self, + create_state_machine_iam_role, + create_state_machine, + events_to_sqs_queue, + aws_client, + sfn_snapshot, + ): + detail_type = f"detail_type_{short_uid()}" + event_pattern = {"detail-type": [detail_type]} + queue_url = events_to_sqs_queue(event_pattern) + sfn_snapshot.add_transformer(RegexTransformer(detail_type, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + + template = ST.load_sfn_template(ST.EVENTS_PUT_EVENTS) + definition = json.dumps(template) + + entries = [ + { + "Detail": json.dumps("jsonstring"), + "DetailType": detail_type, + "Source": "some.source", + } + ] + exec_input = json.dumps({"Entries": entries}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_put_events_no_source( + self, + create_state_machine_iam_role, + create_state_machine, + events_to_sqs_queue, + aws_client, + sfn_snapshot, + ): + detail_type = f"detail_type_{short_uid()}" + event_pattern = {"detail-type": [detail_type]} + queue_url = events_to_sqs_queue(event_pattern) + sfn_snapshot.add_transformer(RegexTransformer(detail_type, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + + template = ST.load_sfn_template(ST.EVENTS_PUT_EVENTS) + definition = json.dumps(template) + + entries = [ + { + "Detail": {"Message": "HelloWorld1"}, + "DetailType": detail_type, + "Source": "some.source", + }, + { + "Detail": {"Message": "HelloWorld"}, + "DetailType": detail_type, + }, + ] + exec_input = json.dumps({"Entries": entries}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + record_sqs_events(aws_client, queue_url, sfn_snapshot, len(entries)) + + @markers.aws.validated + def test_put_events_mixed_malformed_detail( + self, + create_state_machine_iam_role, + create_state_machine, + events_to_sqs_queue, + aws_client, + sfn_snapshot, + ): + detail_type = f"detail_type_{short_uid()}" + event_pattern = {"detail-type": [detail_type]} + queue_url = events_to_sqs_queue(event_pattern) + sfn_snapshot.add_transformer(RegexTransformer(detail_type, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + + template = ST.load_sfn_template(ST.EVENTS_PUT_EVENTS) + definition = json.dumps(template) + + entries = [ + { + "Detail": json.dumps({"Message": "HelloWorld0"}), + "DetailType": detail_type, + "Source": "some.source", + }, + { + "Detail": json.dumps("jsonstring"), + "DetailType": detail_type, + "Source": "some.source", + }, + ] + exec_input = json.dumps({"Entries": entries}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + record_sqs_events(aws_client, queue_url, sfn_snapshot, 1) diff --git a/tests/aws/services/stepfunctions/v2/services/test_events_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_events_task_service.snapshot.json new file mode 100644 index 0000000000000..70c23a96ec4fb --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_events_task_service.snapshot.json @@ -0,0 +1,740 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_base": { + "recorded-date": "12-09-2023, 10:45:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Entries": [ + { + "Detail": "{\"Message\": \"HelloWorld0\"}", + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld1" + }, + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld2" + }, + "DetailType": "", + "Source": "some.source", + "Resources": [ + "" + ] + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Entries": [ + { + "Detail": "{\"Message\": \"HelloWorld0\"}", + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld1" + }, + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld2" + }, + "DetailType": "", + "Source": "some.source", + "Resources": [ + "" + ] + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "PutEvents" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Entries": [ + { + "Detail": "{\"Message\": \"HelloWorld0\"}", + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld1" + }, + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld2" + }, + "DetailType": "", + "Source": "some.source", + "Resources": [ + "" + ] + } + ] + }, + "region": "", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "Entries": [ + { + "EventId": "" + }, + { + "EventId": "" + }, + { + "EventId": "" + } + ], + "FailedEntryCount": 0 + }, + "outputDetails": { + "truncated": false + }, + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "PutEvents", + "output": { + "Entries": [ + { + "EventId": "" + }, + { + "EventId": "" + }, + { + "EventId": "" + } + ], + "FailedEntryCount": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Entries": [ + { + "EventId": "" + }, + { + "EventId": "" + }, + { + "EventId": "" + } + ], + "FailedEntryCount": 0 + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stepfunctions_events": [ + { + "version": "0", + "id": "", + "detail-type": "", + "source": "some.source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::states::111111111111:stateMachine:", + "arn::states::111111111111:execution::" + ], + "detail": { + "Message": "HelloWorld0" + } + }, + { + "version": "0", + "id": "", + "detail-type": "", + "source": "some.source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::states::111111111111:stateMachine:", + "arn::states::111111111111:execution::" + ], + "detail": { + "Message": "HelloWorld1" + } + }, + { + "version": "0", + "id": "", + "detail-type": "", + "source": "some.source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "", + "arn::states::111111111111:stateMachine:", + "arn::states::111111111111:execution::" + ], + "detail": { + "Message": "HelloWorld2" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_malformed_detail": { + "recorded-date": "12-09-2023, 10:51:57", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Entries": [ + { + "Detail": "\"jsonstring\"", + "DetailType": "", + "Source": "some.source" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Entries": [ + { + "Detail": "\"jsonstring\"", + "DetailType": "", + "Source": "some.source" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "PutEvents" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Entries": [ + { + "Detail": "\"jsonstring\"", + "DetailType": "", + "Source": "some.source" + } + ] + }, + "region": "", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "Entries": [ + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1 + }, + "error": "EventBridge.FailedEntry", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "Entries": [ + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1 + }, + "error": "EventBridge.FailedEntry" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stepfunctions_events": [] + } + }, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_no_source": { + "recorded-date": "12-09-2023, 13:17:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Entries": [ + { + "Detail": { + "Message": "HelloWorld1" + }, + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld" + }, + "DetailType": "" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Entries": [ + { + "Detail": { + "Message": "HelloWorld1" + }, + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld" + }, + "DetailType": "" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "PutEvents" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Entries": [ + { + "Detail": { + "Message": "HelloWorld1" + }, + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": { + "Message": "HelloWorld" + }, + "DetailType": "" + } + ] + }, + "region": "", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "Entries": [ + { + "EventId": "" + }, + { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter Source is not valid. Reason: Source is a required argument." + } + ], + "FailedEntryCount": 1 + }, + "error": "EventBridge.FailedEntry", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "Entries": [ + { + "EventId": "" + }, + { + "ErrorCode": "InvalidArgument", + "ErrorMessage": "Parameter Source is not valid. Reason: Source is a required argument." + } + ], + "FailedEntryCount": 1 + }, + "error": "EventBridge.FailedEntry" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stepfunctions_events": [ + { + "version": "0", + "id": "", + "detail-type": "", + "source": "some.source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::states::111111111111:stateMachine:", + "arn::states::111111111111:execution::" + ], + "detail": { + "Message": "HelloWorld1" + } + }, + { + "version": "0", + "id": "", + "detail-type": "", + "source": "some.source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::states::111111111111:stateMachine:", + "arn::states::111111111111:execution::" + ], + "detail": { + "Message": "HelloWorld1" + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_mixed_malformed_detail": { + "recorded-date": "05-12-2024, 13:56:38", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Entries": [ + { + "Detail": "{\"Message\": \"HelloWorld0\"}", + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": "\"jsonstring\"", + "DetailType": "", + "Source": "some.source" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Entries": [ + { + "Detail": "{\"Message\": \"HelloWorld0\"}", + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": "\"jsonstring\"", + "DetailType": "", + "Source": "some.source" + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "PutEvents" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Entries": [ + { + "Detail": "{\"Message\": \"HelloWorld0\"}", + "DetailType": "", + "Source": "some.source" + }, + { + "Detail": "\"jsonstring\"", + "DetailType": "", + "Source": "some.source" + } + ] + }, + "region": "", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "Entries": [ + { + "EventId": "" + }, + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1 + }, + "error": "EventBridge.FailedEntry", + "resource": "putEvents", + "resourceType": "events" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": { + "Entries": [ + { + "EventId": "" + }, + { + "ErrorCode": "MalformedDetail", + "ErrorMessage": "Detail is malformed." + } + ], + "FailedEntryCount": 1 + }, + "error": "EventBridge.FailedEntry" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stepfunctions_events": [ + { + "version": "0", + "id": "", + "detail-type": "", + "source": "some.source", + "account": "111111111111", + "time": "date", + "region": "", + "resources": [ + "arn::states::111111111111:stateMachine:", + "arn::states::111111111111:execution::" + ], + "detail": { + "Message": "HelloWorld0" + } + } + ] + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_events_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_events_task_service.validation.json new file mode 100644 index 0000000000000..c3a3a74b82377 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_events_task_service.validation.json @@ -0,0 +1,14 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_base": { + "last_validated_date": "2023-09-12T08:45:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_malformed_detail": { + "last_validated_date": "2024-12-05T11:30:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_mixed_malformed_detail": { + "last_validated_date": "2024-12-05T13:56:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_events_task_service.py::TestTaskServiceEvents::test_put_events_no_source": { + "last_validated_date": "2023-09-12T11:17:16+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_lambda_task.py b/tests/aws/services/stepfunctions/v2/services/test_lambda_task.py new file mode 100644 index 0000000000000..b52ed61556e0c --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_lambda_task.py @@ -0,0 +1,205 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.lambda_functions import lambda_functions +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +class TestTaskLambda: + @markers.aws.validated + def test_invoke_bytes_payload( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_1_name = f"lambda_1_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_1_name, + handler_file=ST.LAMBDA_RETURN_BYTES_STR, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE_RESOURCE) + template["States"]["step1"]["Resource"] = create_1_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_invoke_string_payload( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_1_name = f"lambda_1_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_1_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE_RESOURCE) + template["States"]["step1"]["Resource"] = create_1_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps("HelloWorld") + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "json_value", + [ + "HelloWorld", + 0.0, + 0, + -0, + True, + {}, + [], + ], + ) + @markers.aws.validated + def test_invoke_json_values( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + json_value, + ): + function_1_name = f"lambda_1_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_1_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE_RESOURCE) + template["States"]["step1"]["Resource"] = create_1_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps(json_value) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_invoke_pipe( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_1_name = f"lambda_1_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_1_name, + handler_file=lambda_functions.ECHO_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_1_name, "")) + + function_2_name = f"lambda_2_func_{short_uid()}" + create_2_res = create_lambda_function( + func_name=function_2_name, + handler_file=lambda_functions.ECHO_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_2_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE_PIPE) + template["States"]["step1"]["Resource"] = create_1_res["CreateFunctionResponse"][ + "FunctionArn" + ] + template["States"]["step2"]["Resource"] = create_2_res["CreateFunctionResponse"][ + "FunctionArn" + ] + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_lambda_task_filter_parameters_input( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "lambda_function_name")) + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + template = ST.load_sfn_template(ST.LAMBDA_INPUT_PARAMETERS_FILTER) + template["States"]["CheckComplete"]["Resource"] = function_arn + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/services/test_lambda_task.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_lambda_task.snapshot.json new file mode 100644 index 0000000000000..807a6c562aab1 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_lambda_task.snapshot.json @@ -0,0 +1,1372 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_pipe": { + "recorded-date": "22-06-2023, 13:34:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": { + "Return": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": { + "Return": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Return": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "name": "step2" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 8, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Return": "HelloWorld" + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 10, + "lambdaFunctionSucceededEventDetails": { + "output": { + "Return": "HelloWorld" + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 9, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "step2", + "output": { + "Return": { + "Return": "HelloWorld" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Return": { + "Return": "HelloWorld" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_bytes_payload": { + "recorded-date": "04-08-2023, 10:45:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "\"HelloWorld!\"", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "\"HelloWorld!\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"HelloWorld!\"", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[None]": { + "recorded-date": "04-08-2023, 14:57:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "null", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "null", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "null", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "null", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[HelloWorld]": { + "recorded-date": "04-08-2023, 16:13:17", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0.0]": { + "recorded-date": "04-08-2023, 16:13:35", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "0.0", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "0.0", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "0.0", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "0.0", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "0.0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "0.0", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0_0]": { + "recorded-date": "04-08-2023, 16:13:53", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0_1]": { + "recorded-date": "04-08-2023, 16:14:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "0", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "0", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[True]": { + "recorded-date": "04-08-2023, 16:14:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "true", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "true", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "true", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "true", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[json_value6]": { + "recorded-date": "04-08-2023, 16:15:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[]", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "[]", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[json_value7]": { + "recorded-date": "04-08-2023, 14:59:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "[]", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "[]", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "[]", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[]", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[json_value5]": { + "recorded-date": "04-08-2023, 16:14:48", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": {}, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_lambda_task_filter_parameters_input": { + "recorded-date": "22-09-2023, 22:36:51", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "StartCheckLoop" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "StartCheckLoop", + "output": { + "loop": { + "count": 0 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 4, + "previousEventId": 3, + "stateEnteredEventDetails": { + "input": { + "loop": { + "count": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "name": "CheckComplete" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 5, + "lambdaFunctionScheduledEventDetails": { + "input": { + "loop": { + "count": 0 + } + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:lambda_function_name" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 7, + "lambdaFunctionSucceededEventDetails": { + "output": { + "loop": { + "count": 0 + } + }, + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "CheckComplete", + "output": { + "loop": { + "count": 0 + }, + "result": { + "loop": { + "count": 0 + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "loop": { + "count": 0 + }, + "result": { + "loop": { + "count": 0 + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_string_payload": { + "recorded-date": "25-03-2024, 22:25:45", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "name": "step1" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": "\"HelloWorld\"", + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:" + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionSucceededEventDetails": { + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "step1", + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "\"HelloWorld\"", + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_lambda_task.validation.json b/tests/aws/services/stepfunctions/v2/services/test_lambda_task.validation.json new file mode 100644 index 0000000000000..72dad53a757ec --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_lambda_task.validation.json @@ -0,0 +1,35 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_bytes_payload": { + "last_validated_date": "2023-08-04T08:45:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0.0]": { + "last_validated_date": "2023-08-04T14:13:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0_0]": { + "last_validated_date": "2023-08-04T14:13:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[0_1]": { + "last_validated_date": "2023-08-04T14:14:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[HelloWorld]": { + "last_validated_date": "2023-08-04T14:13:17+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[True]": { + "last_validated_date": "2023-08-04T14:14:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[json_value5]": { + "last_validated_date": "2023-08-04T14:14:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_json_values[json_value6]": { + "last_validated_date": "2023-08-04T14:15:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_pipe": { + "last_validated_date": "2023-06-22T11:34:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_invoke_string_payload": { + "last_validated_date": "2024-03-25T22:25:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task.py::TestTaskLambda::test_lambda_task_filter_parameters_input": { + "last_validated_date": "2023-09-22T20:36:51+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py new file mode 100644 index 0000000000000..8da727e29938e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py @@ -0,0 +1,193 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import LogType, Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestTaskServiceLambda: + @markers.aws.validated + def test_invoke( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_invoke_bytes_payload( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_RETURN_BYTES_STR, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE) + definition = json.dumps(template) + + exec_input = json.dumps( + {"FunctionName": function_name, "Payload": json.dumps("'{'Hello':'World'}'")} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + # AWS's stepfuctions documentation seems to incorrectly classify LogType parameters as unsupported. + @markers.aws.validated + def test_invoke_unsupported_param( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + sfn_snapshot.add_transformer( + JsonpathTransformer("$..LogResult", "LogResult", replace_reference=True) + ) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE_LOG_TYPE) + definition = json.dumps(template) + + exec_input = json.dumps( + {"FunctionName": function_name, "Payload": None, "LogType": LogType.Tail} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.parametrize( + "json_value", + [ + "HelloWorld", + 0.0, + 0, + -0, + True, + {}, + [], + ], + ) + @markers.aws.validated + def test_invoke_json_values( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + json_value, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + sfn_snapshot.add_transformer( + JsonpathTransformer("$..LogResult", "LogResult", replace_reference=True) + ) + + template = ST.load_sfn_template(ST.LAMBDA_INVOKE) + definition = json.dumps(template) + + exec_input = json.dumps({"FunctionName": function_name, "Payload": json.dumps(json_value)}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skipif( + condition=not is_aws_cloud(), + reason="Add support for Invalid State Machine Definition errors", + ) + @markers.aws.needs_fixing + def test_list_functions( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + template = ST.load_sfn_template(ST.LAMBDA_LIST_FUNCTIONS) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.snapshot.json new file mode 100644 index 0000000000000..3bb44279ebb34 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.snapshot.json @@ -0,0 +1,5216 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke": { + "recorded-date": "22-06-2023, 13:39:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_unsupported_param": { + "recorded-date": "22-06-2023, 14:15:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null, + "LogType": "Tail" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null, + "LogType": "Tail" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "LogType": "Tail", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "LogResult": "", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Log-Result": [ + "" + ], + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "X-Amz-Log-Result": "", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "LogResult": "", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Log-Result": [ + "" + ], + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "X-Amz-Log-Result": "", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "LogResult": "", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Log-Result": [ + "" + ], + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "X-Amz-Log-Result": "", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "LogResult": "", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Log-Result": [ + "" + ], + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "X-Amz-Log-Result": "", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "LogResult": "", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Log-Result": [ + "" + ], + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "X-Amz-Log-Result": "", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "LogResult": "", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Log-Result": [ + "" + ], + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "X-Amz-Log-Result": "", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "LogResult": "", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Log-Result": [ + "" + ], + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "X-Amz-Log-Result": "", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_list_functions": { + "recorded-date": "11-05-2023, 11:29:56", + "recorded-content": {} + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_bytes_payload": { + "recorded-date": "04-08-2023, 10:48:27", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"'{'Hello':'World'}'\"" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"'{'Hello':'World'}'\"" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "\"'{'Hello':'World'}'\"" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld!", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "13" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "13", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[None]": { + "recorded-date": "04-08-2023, 15:38:08", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "null" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "null" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "null" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": null, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": null, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": null, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": null, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": null, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": null, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": null, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[HelloWorld]": { + "recorded-date": "04-08-2023, 16:04:28", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "\"HelloWorld\"" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "\"HelloWorld\"" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "12" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "12", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "12" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "12", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "12" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "12", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "12" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "12", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "12" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "12", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "12" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "12", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": "HelloWorld", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "12" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "12", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0.0]": { + "recorded-date": "04-08-2023, 16:04:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "0.0" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "0.0" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "0.0" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0.0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "3" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "3", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0.0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "3" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "3", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": 0.0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "3" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "3", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0.0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "3" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "3", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": 0.0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "3" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "3", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0.0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "3" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "3", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": 0.0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "3" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "3", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0_0]": { + "recorded-date": "04-08-2023, 16:05:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "0" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "0" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "0" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0_1]": { + "recorded-date": "04-08-2023, 16:05:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "0" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "0" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "0" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": 0, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "1" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "1", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[True]": { + "recorded-date": "04-08-2023, 16:05:41", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "true" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "true" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "true" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": true, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": true, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": true, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": true, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": true, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": true, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": true, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "4" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "4", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[json_value6]": { + "recorded-date": "04-08-2023, 16:06:16", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "[]" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "[]" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "[]" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[json_value7]": { + "recorded-date": "04-08-2023, 15:40:12", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "[]" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "[]" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "[]" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": [], + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[json_value5]": { + "recorded-date": "04-08-2023, 16:05:59", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": "{}" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": "{}" + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": "{}" + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + }, + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Start", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "inputDetails": { + "truncated": false + }, + "name": "EndWithFinal" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "EndWithFinal", + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200, + "final": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.validation.json new file mode 100644 index 0000000000000..b35e29ce0760f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.validation.json @@ -0,0 +1,35 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke": { + "last_validated_date": "2023-06-22T11:39:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_bytes_payload": { + "last_validated_date": "2023-08-04T08:48:27+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0.0]": { + "last_validated_date": "2023-08-04T14:04:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0_0]": { + "last_validated_date": "2023-08-04T14:05:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[0_1]": { + "last_validated_date": "2023-08-04T14:05:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[HelloWorld]": { + "last_validated_date": "2023-08-04T14:04:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[True]": { + "last_validated_date": "2023-08-04T14:05:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[json_value5]": { + "last_validated_date": "2023-08-04T14:05:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_json_values[json_value6]": { + "last_validated_date": "2023-08-04T14:06:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_invoke_unsupported_param": { + "last_validated_date": "2023-06-22T12:15:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_lambda_task_service.py::TestTaskServiceLambda::test_list_functions": { + "last_validated_date": "2023-05-11T09:29:56+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py new file mode 100644 index 0000000000000..d63de14c1bb96 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py @@ -0,0 +1,108 @@ +import json + +from localstack_snapshot.snapshots.transformer import JsonpathTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, + create_state_machine_with_iam_role, +) +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate as BT +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestTaskServiceSfn: + @markers.aws.needs_fixing + def test_start_execution( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + + template_target = BT.load_sfn_template(BT.BASE_PASS_RESULT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = ST.load_sfn_template(ST.SFN_START_EXECUTION) + definition = json.dumps(template) + + exec_input = json.dumps( + {"StateMachineArn": state_machine_arn_target, "Input": None, "Name": "TestStartTarget"} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_start_execution_input_json( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..output.StartDate", + replacement="start-date", + replace_reference=False, + ) + ) + + template_target = BT.load_sfn_template(BT.BASE_PASS_RESULT) + definition_target = json.dumps(template_target) + state_machine_arn_target = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition_target, + ) + + template = ST.load_sfn_template(ST.SFN_START_EXECUTION) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "StateMachineArn": state_machine_arn_target, + "Input": {"Hello": "World"}, + "Name": "TestStartTarget", + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.snapshot.json new file mode 100644 index 0000000000000..6bdef2214ab02 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.snapshot.json @@ -0,0 +1,418 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py::TestTaskServiceSfn::test_start_execution": { + "recorded-date": "28-06-2023, 11:07:54", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": null, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": null, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "160" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "160", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "160" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "160", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "160" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "160", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py::TestTaskServiceSfn::test_start_execution_input_json": { + "recorded-date": "28-06-2023, 11:12:13", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": { + "Hello": "World" + }, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Input": { + "Hello": "World" + }, + "Name": "TestStartTarget" + }, + "inputDetails": { + "truncated": false + }, + "name": "StartExecution" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "Input": { + "Hello": "World" + }, + "StateMachineArn": "arn::states::111111111111:stateMachine:", + "Name": "TestStartTarget" + }, + "region": "", + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "161" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "161", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + }, + "resource": "startExecution", + "resourceType": "states" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "StartExecution", + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "161" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "161", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "ExecutionArn": "arn::states::111111111111:execution::TestStartTarget", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "161" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "161", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StartDate": "start-date" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.validation.json new file mode 100644 index 0000000000000..523e1dd819bc6 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.validation.json @@ -0,0 +1,8 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py::TestTaskServiceSfn::test_start_execution": { + "last_validated_date": "2023-06-28T09:07:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sfn_task_service.py::TestTaskServiceSfn::test_start_execution_input_json": { + "last_validated_date": "2023-06-28T09:12:13+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py new file mode 100644 index 0000000000000..1e9aa553bcfd5 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py @@ -0,0 +1,201 @@ +import json +import threading + +import pytest + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) +from tests.aws.test_notifications import PUBLICATION_RETRIES, PUBLICATION_TIMEOUT + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestTaskServiceSns: + @markers.aws.validated + @pytest.mark.parametrize( + "input_params, fail_template", + [ + ( + { + "Message": {"Message": "HelloWorld!"}, + }, + True, + ), + ( + { + "Message": {"Message": "HelloWorld!"}, + "MessageGroupId": "a-group", + "MessageDeduplicationId": "dedup-1", + }, + False, + ), + ], + ) + def test_fifo_message_attribute( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + input_params, + fail_template, + sns_create_topic, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + fifo_topic_name = f"topic-{short_uid()}.fifo" + sns_topic = sns_create_topic(Name=fifo_topic_name, Attributes={"FifoTopic": "true"}) + topic_arn = sns_topic["TopicArn"] + + template = ST.load_sfn_template( + ST.SNS_FIFO_PUBLISH_FAIL if fail_template else ST.SNS_FIFO_PUBLISH + ) + + definition = json.dumps(template) + input_params["TopicArn"] = topic_arn + + exec_input = json.dumps(input_params) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "message", ["HelloWorld", {"message": "HelloWorld"}, 1, True, None, ""] + ) + def test_publish_base( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sns_create_topic, + sfn_snapshot, + message, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + sns_topic = sns_create_topic() + topic_arn = sns_topic["TopicArn"] + + template = ST.load_sfn_template(ST.SNS_PUBLISH) + definition = json.dumps(template) + + exec_input = json.dumps({"TopicArn": topic_arn, "Message": {"Message": "HelloWorld!"}}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @pytest.mark.parametrize( + "message_value", ["HelloWorld", json.dumps("HelloWorld"), json.dumps({}), {}] + ) + def test_publish_message_attributes( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_receive_num_messages, + sns_create_topic, + sns_allow_topic_sqs_queue, + sfn_snapshot, + message_value, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sns_api()) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + topic_info = sns_create_topic() + topic_arn = topic_info["TopicArn"] + queue_url = sqs_create_queue() + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + aws_client.sns.subscribe( + TopicArn=topic_arn, + Protocol="sqs", + Endpoint=queue_arn, + ) + sns_allow_topic_sqs_queue(queue_url, queue_arn, topic_arn) + + template = ST.load_sfn_template(ST.SNS_PUBLISH_MESSAGE_ATTRIBUTES) + definition = json.dumps(template) + + messages = [] + + def record_messages(): + messages.clear() + messages.extend(sqs_receive_num_messages(queue_url, expected_messages=1)) + + threading.Thread( + target=retry, + args=(record_messages,), + kwargs={"retries": PUBLICATION_RETRIES, "sleep": PUBLICATION_TIMEOUT}, + ).start() + + exec_input = json.dumps( + { + "TopicArn": topic_arn, + "Message": message_value, + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!", + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + sfn_snapshot.match("messages", messages) + + @markers.aws.validated + def test_publish_base_error_topic_arn( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sns_create_topic, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + sns_topic = sns_create_topic() + topic_arn = sns_topic["TopicArn"] + aws_client.sns.delete_topic(TopicArn=topic_arn) + + template = ST.load_sfn_template(ST.SNS_PUBLISH) + definition = json.dumps(template) + + exec_input = json.dumps({"TopicArn": topic_arn, "Message": {"Message": "HelloWorld!"}}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.snapshot.json new file mode 100644 index 0000000000000..37813b499bd00 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.snapshot.json @@ -0,0 +1,2747 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[HelloWorld]": { + "recorded-date": "03-09-2023, 13:33:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[message1]": { + "recorded-date": "03-09-2023, 13:33:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[1]": { + "recorded-date": "03-09-2023, 13:33:55", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[True]": { + "recorded-date": "03-09-2023, 13:34:10", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[None]": { + "recorded-date": "03-09-2023, 13:34:25", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[]": { + "recorded-date": "03-09-2023, 13:34:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes": { + "recorded-date": "03-09-2023, 13:34:55", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "HelloWorld!", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "HelloWorld!", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue": "Hello" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue": "World!" + } + }, + "TopicArn": "arn::sns::111111111111:", + "Message": "HelloWorld!" + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base_error_topic_arn": { + "recorded-date": "03-09-2023, 13:35:10", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Topic does not exist (Service: AmazonSNS; Status Code: 404; Error Code: NotFound; Request ID: ; Proxy: null)", + "error": "SNS.NotFoundException", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Topic does not exist (Service: AmazonSNS; Status Code: 404; Error Code: NotFound; Request ID: ; Proxy: null)", + "error": "SNS.NotFoundException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[HelloWorld]": { + "recorded-date": "27-01-2025, 07:07:04", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "HelloWorld", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "HelloWorld", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue": "Hello" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue": "World!" + }, + "Version": { + "DataType": "String", + "StringValue": "string value literal" + } + }, + "TopicArn": "arn::sns::111111111111:", + "Message": "HelloWorld" + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "HelloWorld", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "Version": { + "Type": "String", + "Value": "string value literal" + }, + "my_attribute_no_2": { + "Type": "String", + "Value": "World!" + }, + "my_attribute_no_1": { + "Type": "String", + "Value": "Hello" + } + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[\"HelloWorld\"]": { + "recorded-date": "27-01-2025, 07:07:20", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "\"HelloWorld\"", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "\"HelloWorld\"", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue": "Hello" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue": "World!" + }, + "Version": { + "DataType": "String", + "StringValue": "string value literal" + } + }, + "TopicArn": "arn::sns::111111111111:", + "Message": "\"HelloWorld\"" + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": "\"HelloWorld\"", + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "Version": { + "Type": "String", + "Value": "string value literal" + }, + "my_attribute_no_2": { + "Type": "String", + "Value": "World!" + }, + "my_attribute_no_1": { + "Type": "String", + "Value": "Hello" + } + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[{}]": { + "recorded-date": "27-01-2025, 07:07:36", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "{}", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": "{}", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue": "Hello" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue": "World!" + }, + "Version": { + "DataType": "String", + "StringValue": "string value literal" + } + }, + "TopicArn": "arn::sns::111111111111:", + "Message": "{}" + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": {}, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "Version": { + "Type": "String", + "Value": "string value literal" + }, + "my_attribute_no_2": { + "Type": "String", + "Value": "World!" + }, + "my_attribute_no_1": { + "Type": "String", + "Value": "Hello" + } + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[message_value3]": { + "recorded-date": "27-01-2025, 07:07:54", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": {}, + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TopicArn": "arn::sns::111111111111:", + "Message": {}, + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World!" + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue": "Hello" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue": "World!" + }, + "Version": { + "DataType": "String", + "StringValue": "string value literal" + } + }, + "TopicArn": "arn::sns::111111111111:", + "Message": {} + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "294" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "294", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "messages": [ + { + "Type": "Notification", + "MessageId": "", + "TopicArn": "arn::sns::111111111111:", + "Message": {}, + "Timestamp": "date", + "SignatureVersion": "1", + "Signature": "", + "SigningCertURL": "/SimpleNotificationService-", + "UnsubscribeURL": "/?Action=Unsubscribe&SubscriptionArn=arn::sns::111111111111::", + "MessageAttributes": { + "Version": { + "Type": "String", + "Value": "string value literal" + }, + "my_attribute_no_2": { + "Type": "String", + "Value": "World!" + }, + "my_attribute_no_1": { + "Type": "String", + "Value": "Hello" + } + } + } + ] + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_fifo_message_attribute[input_params0-True]": { + "recorded-date": "08-02-2024, 10:06:30", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Message": { + "Message": "HelloWorld!" + }, + "TopicArn": "arn::sns::111111111111:" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Message": { + "Message": "HelloWorld!" + }, + "TopicArn": "arn::sns::111111111111:" + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + } + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": "Invalid parameter: The MessageGroupId parameter is required for FIFO topics (Service: AmazonSNS; Status Code: 400; Error Code: InvalidParameter; Request ID: ; Proxy: null)", + "error": "SNS.InvalidParameterException", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "executionFailedEventDetails": { + "cause": "Invalid parameter: The MessageGroupId parameter is required for FIFO topics (Service: AmazonSNS; Status Code: 400; Error Code: InvalidParameter; Request ID: ; Proxy: null)", + "error": "SNS.InvalidParameterException" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_fifo_message_attribute[input_params1-False]": { + "recorded-date": "08-02-2024, 10:06:49", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Message": { + "Message": "HelloWorld!" + }, + "MessageGroupId": "a-group", + "MessageDeduplicationId": "dedup-1", + "TopicArn": "arn::sns::111111111111:" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Message": { + "Message": "HelloWorld!" + }, + "MessageGroupId": "a-group", + "MessageDeduplicationId": "dedup-1", + "TopicArn": "arn::sns::111111111111:" + }, + "inputDetails": { + "truncated": false + }, + "name": "Publish" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "TopicArn": "arn::sns::111111111111:", + "Message": { + "Message": "HelloWorld!" + }, + "MessageGroupId": "a-group", + "MessageDeduplicationId": "dedup-1" + }, + "region": "", + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "352" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "352", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "SequenceNumber": "" + }, + "outputDetails": { + "truncated": false + }, + "resource": "publish", + "resourceType": "sns" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Publish", + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "352" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "352", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "SequenceNumber": "" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "352" + ], + "Date": "date", + "Content-Type": [ + "text/xml" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "352", + "Content-Type": "text/xml", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "SequenceNumber": "" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.validation.json new file mode 100644 index 0000000000000..a53bb3fba34a7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sns_task_service.validation.json @@ -0,0 +1,44 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_fifo_message_attribute[input_params0-True]": { + "last_validated_date": "2024-02-08T10:06:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_fifo_message_attribute[input_params1-False]": { + "last_validated_date": "2024-02-08T10:06:49+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[1]": { + "last_validated_date": "2023-09-03T11:33:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[HelloWorld]": { + "last_validated_date": "2023-09-03T11:33:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[None]": { + "last_validated_date": "2023-09-03T11:34:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[True]": { + "last_validated_date": "2023-09-03T11:34:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[]": { + "last_validated_date": "2023-09-03T11:34:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base[message1]": { + "last_validated_date": "2023-09-03T11:33:39+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_base_error_topic_arn": { + "last_validated_date": "2023-09-03T11:35:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes": { + "last_validated_date": "2023-09-03T11:34:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[\"HelloWorld\"]": { + "last_validated_date": "2025-01-27T07:07:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[HelloWorld]": { + "last_validated_date": "2025-01-27T07:07:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[message_value3]": { + "last_validated_date": "2025-01-27T07:07:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sns_task_service.py::TestTaskServiceSns::test_publish_message_attributes[{}]": { + "last_validated_date": "2025-01-27T07:07:36+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py b/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py new file mode 100644 index 0000000000000..9b308d858c266 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py @@ -0,0 +1,153 @@ +import json + +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.sqs import MessageSystemAttributeNameForSends +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add support for Sdk Http metadata. + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + # TODO: investigate `cause` construction issues with reported LS's SQS errors. + "$..cause", + "$..Cause", + ] +) +class TestTaskServiceSqs: + @markers.aws.needs_fixing + def test_send_message( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = ST.load_sfn_template(ST.SQS_SEND_MESSAGE) + definition = json.dumps(template) + + message_body = "test_message_body" + exec_input = json.dumps({"QueueUrl": queue_url, "MessageBody": message_body}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + receive_message_res = aws_client.sqs.receive_message(QueueUrl=queue_url) + assert len(receive_message_res["Messages"]) == 1 + assert receive_message_res["Messages"][0]["Body"] == message_body + + @markers.aws.needs_fixing + def test_send_message_unsupported_parameters( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = ST.load_sfn_template(ST.SQS_SEND_MESSAGE) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "QueueUrl": queue_url, + "MessageBody": "test", + "MessageSystemAttribute": { + MessageSystemAttributeNameForSends.AWSTraceHeader: "test" + }, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_send_message_attributes( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = ST.load_sfn_template(ST.SQS_SEND_MESSAGE_ATTRIBUTES) + definition = json.dumps(template) + + message_body = "test_message_body" + message_attr_1 = "Hello" + message_attr_2 = "World" + + exec_input = json.dumps( + { + "QueueUrl": queue_url, + "Message": message_body, + "MessageAttributeValue1": message_attr_1, + "MessageAttributeValue2": message_attr_2, + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + receive_message_res = aws_client.sqs.receive_message( + QueueUrl=queue_url, MessageAttributeNames=["All"] + ) + assert len(receive_message_res["Messages"]) == 1 + + sqs_message = receive_message_res["Messages"][0] + assert sqs_message["Body"] == message_body + + sqs_message_attributes = sqs_message["MessageAttributes"] + assert len(sqs_message_attributes) == 2 + + assert sqs_message_attributes["my_attribute_no_1"]["StringValue"] == message_attr_1 + assert sqs_message_attributes["my_attribute_no_1"]["DataType"] == "String" + + assert sqs_message_attributes["my_attribute_no_2"]["StringValue"] == message_attr_2 + assert sqs_message_attributes["my_attribute_no_2"]["DataType"] == "String" diff --git a/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.snapshot.json b/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.snapshot.json new file mode 100644 index 0000000000000..6fb95be414ef4 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.snapshot.json @@ -0,0 +1,631 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message": { + "recorded-date": "18-04-2024, 06:37:09", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendSQS" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "region": "", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "SendSQS", + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message_unsupported_parameters": { + "recorded-date": "18-04-2024, 06:37:24", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test", + "MessageSystemAttribute": { + "AWSTraceHeader": "test" + } + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "MessageBody": "test", + "MessageSystemAttribute": { + "AWSTraceHeader": "test" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "SendSQS" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "QueueUrl": "", + "MessageBody": "test" + }, + "region": "", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "SendSQS", + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message_attributes": { + "recorded-date": "22-08-2024, 10:04:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_body", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_body", + "MessageAttributeValue1": "Hello", + "MessageAttributeValue2": "World" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendSQS" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "MessageAttributes": { + "my_attribute_no_1": { + "DataType": "String", + "StringValue": "Hello" + }, + "my_attribute_no_2": { + "DataType": "String", + "StringValue": "World" + } + }, + "QueueUrl": "", + "MessageBody": "test_message_body" + }, + "region": "", + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSucceededEventDetails": { + "output": { + "MD5OfMessageAttributes": "", + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "166" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "166", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSucceeded" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "SendSQS", + "output": { + "MD5OfMessageAttributes": "", + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "166" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "166", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "MD5OfMessageAttributes": "", + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "166" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "166", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.validation.json b/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.validation.json new file mode 100644 index 0000000000000..24c2546956eb3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message": { + "last_validated_date": "2024-08-21T15:47:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message_attributes": { + "last_validated_date": "2024-08-22T10:04:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/services/test_sqs_task_service.py::TestTaskServiceSqs::test_send_message_unsupported_parameters": { + "last_validated_date": "2024-04-18T06:37:24+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/states_variables/__init__.py b/tests/aws/services/stepfunctions/v2/states_variables/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py b/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py new file mode 100644 index 0000000000000..d109a71965896 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py @@ -0,0 +1,268 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + SfnNoneRecursiveParallelTransformer, + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.errorhandling.error_handling_templates import ( + ErrorHandlingTemplate as EHT, +) +from tests.aws.services.stepfunctions.templates.statevariables.state_variables_template import ( + StateVariablesTemplate as SVT, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestStateVariablesTemplate: + @pytest.mark.parametrize( + "template", + [ + SVT.load_sfn_template(SVT.TASK_CATCH_ERROR_OUTPUT), + SVT.load_sfn_template(SVT.TASK_CATCH_ERROR_OUTPUT_TO_JSONPATH), + ], + ids=[ + "TASK_CATCH_ERROR_OUTPUT", + "TASK_CATCH_ERROR_OUTPUT_TO_JSONPATH", + ], + ) + @markers.aws.validated + def test_task_catch_error_output( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + template, + ): + function_name = f"fn-exception-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + definition = json.dumps(template) + definition = definition.replace( + SVT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + exec_input = json.dumps({"inputData": "dummy"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template", + [ + SVT.load_sfn_template(SVT.TASK_CATCH_ERROR_VARIABLE_SAMPLING), + SVT.load_sfn_template(SVT.TASK_CATCH_ERROR_VARIABLE_SAMPLING_TO_JSONPATH), + ], + ids=[ + "TASK_CATCH_ERROR_VARIABLE_SAMPLING", + "TASK_CATCH_ERROR_VARIABLE_SAMPLING_TO_JSONPATH", + ], + ) + @markers.aws.validated + def test_catch_error_variable_sampling( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + template, + ): + function_name = f"fn-exception-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + definition = json.dumps(template) + definition = definition.replace( + SVT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + exec_input = json.dumps({"inputData": "dummy"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.parametrize( + "template", + [ + SVT.load_sfn_template(SVT.TASK_CATCH_ERROR_OUTPUT_WITH_RETRY), + SVT.load_sfn_template(SVT.TASK_CATCH_ERROR_OUTPUT_WITH_RETRY_TO_JSONPATH), + ], + ids=[ + "TASK_CATCH_ERROR_OUTPUT_WITH_RETRY", + "TASK_CATCH_ERROR_OUTPUT_WITH_RETRY_TO_JSONPATH", + ], + ) + @markers.aws.validated + def test_task_catch_error_with_retry( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + template, + ): + function_name = f"fn-exception-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + definition = json.dumps(template) + definition = definition.replace( + SVT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + exec_input = json.dumps({"inputData": "dummy"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.skip(reason="Items declarations is currently unsupported.") + @pytest.mark.parametrize( + "template", + [ + SVT.load_sfn_template(SVT.MAP_CATCH_ERROR_OUTPUT), + SVT.load_sfn_template(SVT.MAP_CATCH_ERROR_OUTPUT_WITH_RETRY), + SVT.load_sfn_template(SVT.MAP_CATCH_ERROR_VARIABLE_SAMPLING), + ], + ids=[ + "MAP_CATCH_ERROR_OUTPUT", + "MAP_CATCH_ERROR_OUTPUT_WITH_RETRY", + "MAP_CATCH_ERROR_VARIABLE_SAMPLING", + ], + ) + @markers.aws.validated + def test_map_catch_error( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + template, + ): + function_name = f"fn-exception-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + definition = json.dumps(template) + definition = definition.replace( + SVT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + exec_input = json.dumps({"items": [1, 2, 3]}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) + + @pytest.mark.skip(reason="Review error workflow handling for parallel states.") + @pytest.mark.parametrize( + "template", + [ + SVT.load_sfn_template(SVT.PARALLEL_CATCH_ERROR_OUTPUT), + SVT.load_sfn_template(SVT.PARALLEL_CATCH_ERROR_VARIABLE_SAMPLING), + SVT.load_sfn_template(SVT.PARALLEL_CATCH_ERROR_OUTPUT_WITH_RETRY), + ], + ids=[ + "PARALLEL_CATCH_ERROR_OUTPUT", + "PARALLEL_CATCH_ERROR_VARIABLE_SAMPLING", + "PARALLEL_CATCH_ERROR_OUTPUT_WITH_RETRY", + ], + ) + @markers.aws.validated + def test_parallel_catch_error( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + create_lambda_function, + template, + ): + sfn_snapshot.add_transformer(SfnNoneRecursiveParallelTransformer()) + function_name = f"fn-exception-{short_uid()}" + create_res = create_lambda_function( + func_name=function_name, + handler_file=EHT.LAMBDA_FUNC_RAISE_EXCEPTION, + runtime=Runtime.python3_12, + ) + + function_arn = create_res["CreateFunctionResponse"]["FunctionArn"] + + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + definition = json.dumps(template) + definition = definition.replace( + SVT.LAMBDA_FUNCTION_ARN_LITERAL_PLACEHOLDER, + function_arn, + ) + exec_input = json.dumps({"inputData": "dummy"}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role=create_state_machine_iam_role, + create_state_machine=create_state_machine, + sfn_snapshot=sfn_snapshot, + definition=definition, + execution_input=exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.snapshot.json b/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.snapshot.json new file mode 100644 index 0000000000000..c67c307c4ebb3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.snapshot.json @@ -0,0 +1,3121 @@ +{ + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_output[TASK_CATCH_ERROR_OUTPUT]": { + "recorded-date": "12-11-2024, 12:38:02", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Task", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "error": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "error": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_output[TASK_CATCH_ERROR_OUTPUT_TO_JSONPATH]": { + "recorded-date": "12-11-2024, 12:38:18", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "name": "Task", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_catch_error_variable_sampling[TASK_CATCH_ERROR_VARIABLE_SAMPLING]": { + "recorded-date": "12-11-2024, 12:38:39", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "assignedVariables": { + "errorVar": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputVar": { + "inputData": "dummy" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Task", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_catch_error_variable_sampling[TASK_CATCH_ERROR_VARIABLE_SAMPLING_TO_JSONPATH]": { + "recorded-date": "12-11-2024, 12:39:01", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "stateExitedEventDetails": { + "assignedVariables": { + "errorVar": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputVar": { + "inputData": "dummy" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Task", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 7, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 8, + "previousEventId": 7, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 9, + "previousEventId": 8, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_with_retry[TASK_CATCH_ERROR_OUTPUT_WITH_RETRY]": { + "recorded-date": "12-11-2024, 12:39:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Task", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "error": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "error": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_with_retry[TASK_CATCH_ERROR_OUTPUT_WITH_RETRY_TO_JSONPATH]": { + "recorded-date": "12-11-2024, 12:39:43", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "Task" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 6, + "previousEventId": 5, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "Task", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 12, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_OUTPUT]": { + "recorded-date": "12-11-2024, 13:03:14", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "ProcessItems" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "ProcessItems" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "ProcessItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "name": "ProcessItem", + "output": { + "stateInput": 1, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": { + "stateInput": 1, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "result": { + "stateInput": 1, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 12, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "ProcessItems" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 13, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "ProcessItems" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "ProcessItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 16, + "previousEventId": 15, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 17, + "previousEventId": 16, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 18, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "ProcessItem", + "output": { + "stateInput": 2, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": { + "stateInput": 2, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "result": { + "stateInput": 2, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "ProcessItems" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "ProcessItems" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "ProcessItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 24, + "previousEventId": 23, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 25, + "previousEventId": 24, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 26, + "previousEventId": 25, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 27, + "previousEventId": 26, + "stateExitedEventDetails": { + "name": "ProcessItem", + "output": { + "stateInput": 3, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 28, + "previousEventId": 27, + "stateEnteredEventDetails": { + "input": { + "stateInput": 3, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 29, + "previousEventId": 28, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "result": { + "stateInput": 3, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 30, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "ProcessItems" + }, + "previousEventId": 29, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 31, + "previousEventId": 30, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 32, + "previousEventId": 30, + "stateExitedEventDetails": { + "name": "ProcessItems", + "output": "[{\"result\":{\"stateInput\":1,\"stateError\":{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}}},{\"result\":{\"stateInput\":2,\"stateError\":{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}}},{\"result\":{\"stateInput\":3,\"stateError\":{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}}}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"result\":{\"stateInput\":1,\"stateError\":{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}}},{\"result\":{\"stateInput\":2,\"stateError\":{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}}},{\"result\":{\"stateInput\":3,\"stateError\":{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}}}]", + "outputDetails": { + "truncated": false + } + }, + "id": 33, + "previousEventId": 32, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_OUTPUT_WITH_RETRY]": { + "recorded-date": "12-11-2024, 13:00:32", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "ProcessItems" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "ProcessItems" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "ProcessItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 10, + "previousEventId": 9, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 11, + "previousEventId": 10, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "ProcessItem", + "output": { + "stateInput": 1, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 13, + "previousEventId": 12, + "stateEnteredEventDetails": { + "input": { + "stateInput": 1, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "evaluationFailedEventDetails": { + "cause": "An error occurred while executing the state 'Fallback' (entered at the event id #13). The JSONata expression '$stateError' specified for the field 'Output/error' returned nothing (undefined).", + "error": "States.QueryEvaluationError", + "location": "Output/error", + "state": "Fallback" + }, + "id": 14, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "EvaluationFailed" + }, + { + "id": 15, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "ProcessItems" + }, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 16, + "previousEventId": 14, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "cause": "An error occurred while executing the state 'Fallback' (entered at the event id #13). The JSONata expression '$stateError' specified for the field 'Output/error' returned nothing (undefined).", + "error": "States.QueryEvaluationError" + }, + "id": 17, + "previousEventId": 16, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_VARIABLE_SAMPLING]": { + "recorded-date": "12-11-2024, 13:01:00", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "items": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "items": [ + 1, + 2, + 3 + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "ProcessItems" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 3 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "ProcessItems" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": "1", + "inputDetails": { + "truncated": false + }, + "name": "ProcessItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 6, + "previousEventId": 5, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 7, + "previousEventId": 6, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 8, + "previousEventId": 7, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 9, + "previousEventId": 8, + "stateExitedEventDetails": { + "assignedVariables": { + "errorVar": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputVar": "1" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "ProcessItem", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 10, + "previousEventId": 9, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "stateExitedEventDetails": { + "assignedVariables": { + "error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Fallback", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 12, + "mapIterationSucceededEventDetails": { + "index": 0, + "name": "ProcessItems" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 13, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "ProcessItems" + }, + "previousEventId": 11, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 14, + "previousEventId": 13, + "stateEnteredEventDetails": { + "input": "2", + "inputDetails": { + "truncated": false + }, + "name": "ProcessItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 16, + "previousEventId": 15, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 17, + "previousEventId": 16, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 18, + "previousEventId": 17, + "stateExitedEventDetails": { + "assignedVariables": { + "errorVar": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputVar": "2" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "ProcessItem", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 19, + "previousEventId": 18, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 20, + "previousEventId": 19, + "stateExitedEventDetails": { + "assignedVariables": { + "error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Fallback", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 21, + "mapIterationSucceededEventDetails": { + "index": 1, + "name": "ProcessItems" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 22, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "ProcessItems" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 23, + "previousEventId": 22, + "stateEnteredEventDetails": { + "input": "3", + "inputDetails": { + "truncated": false + }, + "name": "ProcessItem" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 24, + "previousEventId": 23, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 25, + "previousEventId": 24, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 26, + "previousEventId": 25, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": 27, + "previousEventId": 26, + "stateExitedEventDetails": { + "assignedVariables": { + "errorVar": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputVar": "3" + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "ProcessItem", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "TaskStateExited" + }, + { + "id": 28, + "previousEventId": 27, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 29, + "previousEventId": 28, + "stateExitedEventDetails": { + "assignedVariables": { + "error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "Fallback", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 30, + "mapIterationSucceededEventDetails": { + "index": 2, + "name": "ProcessItems" + }, + "previousEventId": 29, + "timestamp": "timestamp", + "type": "MapIterationSucceeded" + }, + { + "id": 31, + "previousEventId": 30, + "timestamp": "timestamp", + "type": "MapStateSucceeded" + }, + { + "id": 32, + "previousEventId": 30, + "stateExitedEventDetails": { + "name": "ProcessItems", + "output": "[{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"},{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"},{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}]", + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "MapStateExited" + }, + { + "executionSucceededEventDetails": { + "output": "[{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"},{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"},{\"Error\":\"Exception\",\"Cause\":\"{\\\"errorMessage\\\":\\\"Some exception was raised.\\\",\\\"errorType\\\":\\\"Exception\\\",\\\"requestId\\\":\\\"\\\",\\\"stackTrace\\\":[\\\" File \\\\\\\"/var/task/handler.py\\\\\\\", line 2, in handler\\\\n raise Exception(\\\\\\\"Some exception was raised.\\\\\\\")\\\\n\\\"]}\"}]", + "outputDetails": { + "truncated": false + } + }, + "id": 33, + "previousEventId": 32, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_OUTPUT]": { + "recorded-date": "13-11-2024, 09:47:56", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "ExecuteLambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "name": "ParallelState", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "result": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 13, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_VARIABLE_SAMPLING]": { + "recorded-date": "13-11-2024, 09:48:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "ExecuteLambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 10, + "previousEventId": 9, + "stateExitedEventDetails": { + "assignedVariables": { + "errorVar": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputVar": { + "inputData": "dummy" + } + }, + "assignedVariablesDetails": { + "truncated": false + }, + "name": "ParallelState", + "output": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "id": 11, + "previousEventId": 10, + "stateEnteredEventDetails": { + "input": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 12, + "previousEventId": 11, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "error": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 13, + "previousEventId": 12, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_OUTPUT_WITH_RETRY]": { + "recorded-date": "13-11-2024, 09:48:43", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "ParallelState" + }, + "timestamp": "timestamp", + "type": "ParallelStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "ExecuteLambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 9, + "previousEventId": 7, + "timestamp": "timestamp", + "type": "ParallelStateStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "inputData": "dummy" + }, + "inputDetails": { + "truncated": false + }, + "name": "ExecuteLambdaTask" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskFailedEventDetails": { + "cause": { + "errorMessage": "Some exception was raised.", + "errorType": "Exception", + "requestId": "", + "stackTrace": [ + " File \"/var/task/handler.py\", line 2, in handler\n raise Exception(\"Some exception was raised.\")\n" + ] + }, + "error": "Exception", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskFailed" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "arn::lambda::111111111111:function:", + "Payload": { + "foo": "foobar" + } + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": [ + 0 + ], + "previousEventId": 0, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 16, + "previousEventId": 15, + "stateExitedEventDetails": { + "name": "ParallelState", + "output": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "ParallelStateExited" + }, + { + "id": 17, + "previousEventId": 16, + "stateEnteredEventDetails": { + "input": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + }, + "inputDetails": { + "truncated": false + }, + "name": "Fallback" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 18, + "previousEventId": 17, + "stateExitedEventDetails": { + "name": "Fallback", + "output": { + "result": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "result": { + "stateInput": { + "inputData": "dummy" + }, + "stateError": { + "Error": "Exception", + "Cause": "{\"errorMessage\":\"Some exception was raised.\",\"errorType\":\"Exception\",\"requestId\":\"\",\"stackTrace\":[\" File \\\"/var/task/handler.py\\\", line 2, in handler\\n raise Exception(\\\"Some exception was raised.\\\")\\n\"]}" + } + } + }, + "outputDetails": { + "truncated": false + } + }, + "id": 19, + "previousEventId": 18, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.validation.json b/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.validation.json new file mode 100644 index 0000000000000..77a6ce0d00052 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/states_variables/test_error_output.validation.json @@ -0,0 +1,38 @@ +{ + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_catch_error_variable_sampling[TASK_CATCH_ERROR_VARIABLE_SAMPLING]": { + "last_validated_date": "2024-11-12T12:38:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_catch_error_variable_sampling[TASK_CATCH_ERROR_VARIABLE_SAMPLING_TO_JSONPATH]": { + "last_validated_date": "2024-11-12T12:39:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_OUTPUT]": { + "last_validated_date": "2024-11-12T13:03:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_OUTPUT_WITH_RETRY]": { + "last_validated_date": "2024-11-12T13:00:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_map_catch_error[MAP_CATCH_ERROR_VARIABLE_SAMPLING]": { + "last_validated_date": "2024-11-12T13:00:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_OUTPUT]": { + "last_validated_date": "2024-11-13T09:49:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_OUTPUT_WITH_RETRY]": { + "last_validated_date": "2024-11-13T09:50:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_parallel_catch_error[PARALLEL_CATCH_ERROR_VARIABLE_SAMPLING]": { + "last_validated_date": "2024-11-13T09:49:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_output[TASK_CATCH_ERROR_OUTPUT]": { + "last_validated_date": "2024-11-12T12:38:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_output[TASK_CATCH_ERROR_OUTPUT_TO_JSONPATH]": { + "last_validated_date": "2024-11-12T12:38:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_with_retry[TASK_CATCH_ERROR_OUTPUT_WITH_RETRY]": { + "last_validated_date": "2024-11-12T12:39:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/states_variables/test_error_output.py::TestStateVariablesTemplate::test_task_catch_error_with_retry[TASK_CATCH_ERROR_OUTPUT_WITH_RETRY_TO_JSONPATH]": { + "last_validated_date": "2024-11-12T12:39:41+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api.py b/tests/aws/services/stepfunctions/v2/test_sfn_api.py new file mode 100644 index 0000000000000..b46ec48bd3361 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api.py @@ -0,0 +1,1660 @@ +import json + +import pytest +import yaml +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.api.stepfunctions import HistoryEventList, StateMachineType +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_aborted, + await_execution_started, + await_execution_success, + await_execution_terminated, + await_list_execution_status, + await_on_execution_events, + await_state_machine_listed, + await_state_machine_not_listed, + await_state_machine_version_listed, +) +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry, wait_until +from tests.aws.services.stepfunctions.lambda_functions import lambda_functions +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate +from tests.aws.services.stepfunctions.templates.callbacks.callback_templates import ( + CallbackTemplates as CT, +) + + +@markers.snapshot.skip_snapshot_verify(paths=["$..tracingConfiguration"]) +class TestSnfApi: + @markers.aws.validated + def test_create_delete_valid_sm( + self, + create_state_machine_iam_role, + create_lambda_function, + create_state_machine, + sfn_snapshot, + aws_client, + ): + create_lambda_1 = create_lambda_function( + handler_file=lambda_functions.BASE_ID_FUNCTION, + func_name="id_function", + runtime=Runtime.python3_12, + ) + lambda_arn_1 = create_lambda_1["CreateFunctionResponse"]["FunctionArn"] + + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_TASK_SEQ_2) + definition["States"]["State_1"]["Resource"] = lambda_arn_1 + definition["States"]["State_2"]["Resource"] = lambda_arn_1 + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + state_machine_arn = creation_resp_1["stateMachineArn"] + + deletion_resp_1 = aws_client.stepfunctions.delete_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("deletion_resp_1", deletion_resp_1) + + @markers.snapshot.skip_snapshot_verify( + paths=[ + # TODO: add static analyser support. + "$..Message", + "$..message", + ] + ) + @markers.aws.validated + def test_create_delete_invalid_sm( + self, + aws_client, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_INVALID_DER) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + + with pytest.raises(Exception) as resource_not_found: + create_state_machine( + aws_client_no_retry, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.match("invalid_definition_1", resource_not_found.value.response) + + @markers.aws.validated + def test_delete_nonexistent_sm( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn: str = creation_resp_1["stateMachineArn"] + + sm_nonexistent_name = f"statemachine_{short_uid()}" + sm_nonexistent_arn = state_machine_arn.replace(sm_name, sm_nonexistent_name) + + deletion_resp_1 = aws_client.stepfunctions.delete_state_machine( + stateMachineArn=sm_nonexistent_arn + ) + sfn_snapshot.match("deletion_resp_1", deletion_resp_1) + + @markers.aws.validated + def test_describe_nonexistent_sm( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn: str = creation_resp_1["stateMachineArn"] + + sm_nonexistent_name = f"statemachine_{short_uid()}" + sm_nonexistent_arn = state_machine_arn.replace(sm_name, sm_nonexistent_name) + sfn_snapshot.add_transformer(RegexTransformer(sm_nonexistent_arn, "sm_nonexistent_arn")) + + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.describe_state_machine( + stateMachineArn=sm_nonexistent_arn + ) + sfn_snapshot.match("describe_nonexistent_sm", exc.value) + + @markers.aws.validated + def test_describe_sm_arn_containing_punctuation( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + # ARN will contain a punctuation symbol + sm_name = f"state.machine_{short_uid()}" + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + + describe_resp = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=creation_resp["stateMachineArn"] + ) + sfn_snapshot.match("describe_resp", describe_resp) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_describe_invalid_arn_sm(self, sfn_snapshot, aws_client_no_retry): + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.describe_state_machine( + stateMachineArn="not_a_valid_arn" + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_create_exact_duplicate_sm( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn_1 = creation_resp_1["stateMachineArn"] + + describe_resp_1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn_1 + ) + sfn_snapshot.match("describe_resp_1", describe_resp_1) + + creation_resp_2 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_2, 1)) + sfn_snapshot.match("creation_resp_2", creation_resp_2) + state_machine_arn_2 = creation_resp_2["stateMachineArn"] + + describe_resp_2 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn_2 + ) + sfn_snapshot.match("describe_resp_2", describe_resp_2) + + describe_resp_1_2 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn_1 + ) + sfn_snapshot.match("describe_resp_1_2", describe_resp_1_2) + + @markers.aws.validated + def test_create_duplicate_definition_format_sm( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn_1 = creation_resp_1["stateMachineArn"] + + describe_resp_1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn_1 + ) + sfn_snapshot.match("describe_resp_1", describe_resp_1) + + definition_str_2 = json.dumps(definition, indent=4) + with pytest.raises(Exception) as resource_not_found: + create_state_machine( + aws_client_no_retry, name=sm_name, definition=definition_str_2, roleArn=snf_role_arn + ) + sfn_snapshot.match("already_exists_1", resource_not_found.value.response) + + @markers.aws.validated + def test_create_duplicate_sm_name( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition_1 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str_1 = json.dumps(definition_1) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str_1, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn_1 = creation_resp_1["stateMachineArn"] + + describe_resp_1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn_1 + ) + sfn_snapshot.match("describe_resp_1", describe_resp_1) + + definition_2 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_2["States"]["State_1"]["Result"].update({"Arg2": "Argument2"}) + definition_str_2 = json.dumps(definition_2) + + with pytest.raises(Exception) as resource_not_found: + create_state_machine( + aws_client_no_retry, name=sm_name, definition=definition_str_2, roleArn=snf_role_arn + ) + sfn_snapshot.match("already_exists_1", resource_not_found.value.response) + + @markers.aws.needs_fixing + def test_list_sms( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_names = [ + f"statemachine_1_{short_uid()}", + f"statemachine_2_{short_uid()}", + f"statemachine_3_{short_uid()}", + ] + state_machine_arns = list() + + for i, sm_name in enumerate(sm_names): + creation_resp = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, i)) + sfn_snapshot.match(f"creation_resp_{i}", creation_resp) + state_machine_arn: str = creation_resp["stateMachineArn"] + state_machine_arns.append(state_machine_arn) + + await_state_machine_listed( + stepfunctions_client=aws_client.stepfunctions, + state_machine_arn=state_machine_arn, + ) + + lst_resp = aws_client.stepfunctions.list_state_machines() + lst_resp_filter = [sm for sm in lst_resp["stateMachines"] if sm["name"] in sm_names] + sfn_snapshot.match("lst_resp_filter", lst_resp_filter) + + for i, state_machine_arn in enumerate(state_machine_arns): + deletion_resp = aws_client.stepfunctions.delete_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match(f"deletion_resp_{i}", deletion_resp) + + await_state_machine_not_listed( + stepfunctions_client=aws_client.stepfunctions, + state_machine_arn=state_machine_arn, + ) + + lst_resp = aws_client.stepfunctions.list_state_machines() + lst_resp_filter = [sm for sm in lst_resp["stateMachines"] if sm["name"] in sm_names] + sfn_snapshot.match("lst_resp_del_filter", lst_resp_filter) + + @markers.aws.validated + def test_list_sms_pagination( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_names = [f"statemachine_{i}_{short_uid()}" for i in range(13)] + state_machine_arns = list() + + for i, sm_name in enumerate(sm_names): + creation_resp = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + ) + + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, i)) + + state_machine_arn: str = creation_resp["stateMachineArn"] + state_machine_arns.append(state_machine_arn) + + def _list_state_machines(expected_results_count: int, **kwargs): + """Returns a filtered list of relevant State Machines""" + state_machines = aws_client.stepfunctions.list_state_machines(**kwargs) + filtered_sms = [sm for sm in state_machines["stateMachines"] if sm["name"] in sm_names] + + assert len(filtered_sms) == expected_results_count + return filtered_sms + + # expect all state machines to be present + wait_until(lambda: _list_state_machines(expected_results_count=13), max_retries=20) + + paginator = aws_client.stepfunctions.get_paginator("list_state_machines") + page_iterator = paginator.paginate(maxResults=5) + + # Paginates across all results and filters out any StateMachines not relevant to the test + def _verify_paginate_results() -> list: + filtered_state_machines = [] + for page in page_iterator: + assert 0 < len(page["stateMachines"]) <= 5 + + filtered_page = [sm for sm in page["stateMachines"] if sm["name"] in sm_names] + if filtered_page: + sm_name_set = {sm.get("name") for sm in filtered_state_machines} + # assert that none of the State Machines being added are already present + assert not any(sm.get("name") in sm_name_set for sm in filtered_page) + + filtered_state_machines.extend(filtered_page) + + assert len(filtered_state_machines) == len(sm_names) + return filtered_state_machines + + # Since ListStateMachines is eventually consistent, we should re-attempt pagination + listed_state_machines = retry(_verify_paginate_results, retries=20, sleep=1) + sfn_snapshot.match("list-state-machines-page-1", listed_state_machines[:10]) + sfn_snapshot.match("list-state-machines-page-2", listed_state_machines[10:]) + + # maxResults value is out of bounds + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_state_machines(maxResults=1001) + sfn_snapshot.match("list-state-machines-invalid-param-too-large", err.value.response) + + # nextToken is too short + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_state_machines(nextToken="") + sfn_snapshot.match( + "list-state-machines-invalid-param-short-nextToken", + {"exception_typename": err.typename, "exception_value": err.value}, + ) + + # nextToken is too long + invalid_long_token = "x" * 1025 + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_state_machines(nextToken=invalid_long_token) + sfn_snapshot.add_transformer( + RegexTransformer(invalid_long_token, f"") + ) + sfn_snapshot.match("list-state-machines-invalid-param-long-nextToken", err.value.response) + + # where maxResults is 0, the default of 100 is used + retry( + lambda: _list_state_machines(expected_results_count=13, maxResults=0), + retries=20, + sleep=1, + ) + + for state_machine_arn in state_machine_arns: + aws_client.stepfunctions.delete_state_machine(stateMachineArn=state_machine_arn) + + # expect no state machines created in this test to be leftover after deletion + wait_until(lambda: not _list_state_machines(expected_results_count=0), max_retries=20) + + @markers.aws.validated + def test_start_execution_idempotent( + self, + create_state_machine_iam_role, + create_state_machine, + sqs_send_task_success_state_machine, + sqs_create_queue, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + execution_name: str = f"execution_name_{short_uid()}" + + template = BaseTemplate.load_sfn_template(CT.SQS_WAIT_FOR_TASK_TOKEN) + definition = json.dumps(template) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + + input_data = json.dumps({"QueueUrl": queue_url, "Message": "test_message_txt"}) + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=input_data, name=execution_name + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + sfn_snapshot.match("exec_resp", exec_resp) + execution_arn = exec_resp["executionArn"] + + await_execution_started( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + exec_resp_idempotent = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=input_data, name=execution_name + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp_idempotent, 0) + ) + sfn_snapshot.match("exec_resp_idempotent", exec_resp_idempotent) + + # Should fail because the execution has the same 'name' as another but a different 'input'. + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, + input='{"body" : "different-data"}', + name=execution_name, + ) + sfn_snapshot.match("start_exec_already_exists", err.value.response) + + stop_res = aws_client.stepfunctions.stop_execution(executionArn=execution_arn) + sfn_snapshot.match("stop_res", stop_res) + + sqs_send_task_success_state_machine(queue_name) + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + assert exec_resp_idempotent["executionArn"] == execution_arn + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..redriveCount"]) + def test_start_execution( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + sfn_snapshot.match("exec_resp", exec_resp) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + exec_list_resp = aws_client.stepfunctions.list_executions(stateMachineArn=state_machine_arn) + sfn_snapshot.match("exec_list_resp", exec_list_resp) + + exec_hist_resp = aws_client.stepfunctions.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match("exec_hist_resp", exec_hist_resp) + + @markers.aws.validated + def test_list_execution_no_such_state_machine( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn: str = creation_resp_1["stateMachineArn"] + + sm_nonexistent_name = f"statemachine_{short_uid()}" + sm_nonexistent_arn = state_machine_arn.replace(sm_name, sm_nonexistent_name) + sfn_snapshot.add_transformer(RegexTransformer(sm_nonexistent_arn, "ssm_nonexistent_arn")) + + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.list_executions(stateMachineArn=sm_nonexistent_arn) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_list_execution_invalid_arn(self, sfn_snapshot, aws_client, aws_client_no_retry): + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn="invalid_state_machine_arn" + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value", "$..redriveCount"]) + def test_list_executions_pagination( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + + state_machine_arn = creation_resp["stateMachineArn"] + + await_state_machine_listed( + stepfunctions_client=aws_client.stepfunctions, + state_machine_arn=state_machine_arn, + ) + + execution_arns = list() + for i in range(13): + input_data = json.dumps(dict()) + + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=input_data + ) + + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, i)) + + execution_arn = exec_resp["executionArn"] + execution_arns.append(execution_arn) + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + page_1_executions = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, maxResults=10 + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("nextToken")) + sfn_snapshot.match("list-executions-page-1", page_1_executions) + + page_2_executions = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, + maxResults=3, + nextToken=page_1_executions["nextToken"], + ) + + sfn_snapshot.match("list-executions-page-2", page_2_executions) + + assert all( + sm not in page_1_executions["executions"] for sm in page_2_executions["executions"] + ) + + # maxResults value is out of bounds + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, maxResults=1001 + ) + sfn_snapshot.match("list-executions-invalid-param-too-large", err.value.response) + + # nextToken is too short + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, nextToken="" + ) + sfn_snapshot.match( + "list-executions-invalid-param-short-nextToken", + {"exception_typename": err.typename, "exception_value": err.value}, + ) + + # nextToken is too long + invalid_long_token = "x" * 3097 + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, nextToken=invalid_long_token + ) + sfn_snapshot.add_transformer( + RegexTransformer(invalid_long_token, f"") + ) + sfn_snapshot.match("list-executions-invalid-param-long-nextToken", err.value.response) + + # where maxResults is 0, the default of 100 should be returned + executions_default_all_returned = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, maxResults=0 + ) + assert len(executions_default_all_returned["executions"]) == 13 + assert "nextToken" not in executions_default_all_returned + + deletion_resp = aws_client.stepfunctions.delete_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("deletion_resp", deletion_resp) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value", "$..redriveCount"]) + def test_list_executions_versions_pagination( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + + creation_resp = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + + state_machine_arn = creation_resp["stateMachineArn"] + state_machine_version_arn = creation_resp["stateMachineVersionArn"] + + await_state_machine_version_listed( + stepfunctions_client=aws_client.stepfunctions, + state_machine_arn=state_machine_arn, + state_machine_version_arn=state_machine_version_arn, + ) + + execution_arns = list() + for i in range(13): + input_data = json.dumps(dict()) + + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_version_arn, input=input_data + ) + + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, i)) + + execution_arn = exec_resp["executionArn"] + execution_arns.append(execution_arn) + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + page_1_executions = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_version_arn, maxResults=10 + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("nextToken")) + sfn_snapshot.match("list-executions-page-1", page_1_executions) + + page_2_executions = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_version_arn, + maxResults=3, + nextToken=page_1_executions["nextToken"], + ) + + sfn_snapshot.match("list-execution-page-2", page_2_executions) + + assert all( + sm not in page_1_executions["executions"] for sm in page_2_executions["executions"] + ) + + # maxResults value is out of bounds + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn=state_machine_version_arn, maxResults=1001 + ) + sfn_snapshot.match("list-executions-invalid-param-too-large", err.value.response) + + # nextToken is too short + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn=state_machine_version_arn, nextToken="" + ) + sfn_snapshot.match( + "list-executions-invalid-param-short-nextToken", + {"exception_typename": err.typename, "exception_value": err.value}, + ) + + # nextToken is too long + invalid_long_token = "x" * 3097 + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn=state_machine_version_arn, nextToken=invalid_long_token + ) + sfn_snapshot.add_transformer( + RegexTransformer(invalid_long_token, f"") + ) + sfn_snapshot.match("list-executions-invalid-param-long-nextToken", err.value.response) + + # where maxResults is 0, the default of 100 should be returned + executions_default_all_returned = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_version_arn, maxResults=0 + ) + assert len(executions_default_all_returned["executions"]) == 13 + assert "nextToken" not in executions_default_all_returned + + deletion_resp = aws_client.stepfunctions.delete_state_machine_version( + stateMachineVersionArn=state_machine_version_arn + ) + sfn_snapshot.match("deletion_resp", deletion_resp) + + @markers.aws.validated + def test_get_execution_history_reversed( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + execution_arn = exec_resp["executionArn"] + + await_execution_terminated(aws_client.stepfunctions, execution_arn) + + exec_hist_resp = aws_client.stepfunctions.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match("get_execution_history_reverseOrder[False]", exec_hist_resp) + + exec_hist_rev_resp = aws_client.stepfunctions.get_execution_history( + executionArn=execution_arn, reverseOrder=True + ) + sfn_snapshot.match("get_execution_history_reverseOrder[True]", exec_hist_rev_resp) + + @markers.aws.validated + def test_invalid_start_execution_arn( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + state_machine_arn_invalid = state_machine_arn.replace( + sm_name, f"statemachine_invalid_{sm_name}" + ) + + aws_client.stepfunctions.delete_state_machine(stateMachineArn=state_machine_arn) + + with pytest.raises(Exception) as resource_not_found: + aws_client_no_retry.stepfunctions.start_execution( + stateMachineArn=state_machine_arn_invalid + ) + sfn_snapshot.match("start_exec_of_deleted", resource_not_found.value.response) + + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + @markers.aws.validated + def test_invalid_start_execution_input( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input="not some json" + ) + sfn_snapshot.match("start_exec_str_inp", err.value.response) + + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input="{'not': 'json'" + ) + sfn_snapshot.match("start_exec_not_json_inp", err.value.response) + + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input="" + ) + sfn_snapshot.match("start_res_empty", err.value.response) + + start_res_num = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input="2" + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(start_res_num, 0)) + sfn_snapshot.match("start_res_num", start_res_num) + + start_res_str = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input='"some text"' + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(start_res_str, 1)) + sfn_snapshot.match("start_res_str", start_res_str) + + start_res_null = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input="null" + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(start_res_null, 2)) + sfn_snapshot.match("start_res_null", start_res_null) + + @markers.aws.validated + def test_stop_execution( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_WAIT_1_MIN) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + sfn_snapshot.match("exec_resp", exec_resp) + execution_arn = exec_resp["executionArn"] + + def _check_stated_entered(events: HistoryEventList) -> bool: + # Check the evaluation entered the wait state, called State_1. + for event in events: + event_details = event.get("stateEnteredEventDetails") + if event_details: + return event_details.get("name") == "State_1" + return False + + # Wait until the state machine enters the wait state. + await_on_execution_events( + stepfunctions_client=aws_client.stepfunctions, + execution_arn=execution_arn, + check_func=_check_stated_entered, + ) + + stop_res = aws_client.stepfunctions.stop_execution(executionArn=execution_arn) + sfn_snapshot.match("stop_res", stop_res) + + await_execution_aborted( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + exec_hist_resp = aws_client.stepfunctions.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match("exec_hist_resp", exec_hist_resp) + + @markers.aws.validated + def test_create_update_state_machine_base_definition( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition_t0 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str_t0 = json.dumps(definition_t0) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_t0 = create_state_machine( + aws_client, name=sm_name, definition=definition_str_t0, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_t0, 0)) + sfn_snapshot.match("creation_resp_t0", creation_resp_t0) + state_machine_arn = creation_resp_t0["stateMachineArn"] + + describe_resp_t0 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t0", describe_resp_t0) + + definition_t1 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_t1["States"]["State_1"]["Result"].update({"Arg1": "AfterUpdate1"}) + definition_str_t1 = json.dumps(definition_t1) + + update_state_machine_res = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str_t1 + ) + sfn_snapshot.match("update_state_machine_res", update_state_machine_res) + + describe_resp_t1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t1", describe_resp_t1) + + definition_t2 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_t2["States"]["State_1"]["Result"].update({"Arg1": "AfterUpdate2"}) + definition_str_t2 = json.dumps(definition_t2) + + update_state_machine_res_t2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str_t2 + ) + sfn_snapshot.match("update_state_machine_res_t2", update_state_machine_res_t2) + + describe_resp_t2 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t2", describe_resp_t2) + + @markers.aws.validated + def test_create_update_state_machine_base_role_arn( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn_t0 = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn_t0, "snf_role_arn_t0")) + + definition_t0 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str_t0 = json.dumps(definition_t0) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_t0 = create_state_machine( + aws_client, name=sm_name, definition=definition_str_t0, roleArn=snf_role_arn_t0 + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_t0, 0)) + sfn_snapshot.match("creation_resp_t0", creation_resp_t0) + state_machine_arn = creation_resp_t0["stateMachineArn"] + + describe_resp_t0 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t0", describe_resp_t0) + + snf_role_arn_t1 = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn_t1, "snf_role_arn_t1")) + + update_state_machine_res_t1 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, roleArn=snf_role_arn_t1 + ) + sfn_snapshot.match("update_state_machine_res_t1", update_state_machine_res_t1) + + describe_resp_t1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t1", describe_resp_t1) + + snf_role_arn_t2 = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn_t2, "snf_role_arn_t2")) + + update_state_machine_res_t2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, roleArn=snf_role_arn_t2 + ) + sfn_snapshot.match("update_state_machine_res_t2", update_state_machine_res_t2) + + describe_resp_t2 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t2", describe_resp_t2) + + @markers.aws.validated + def test_create_update_state_machine_base_definition_and_role( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition_t0 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str_t0 = json.dumps(definition_t0) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_t0 = create_state_machine( + aws_client, name=sm_name, definition=definition_str_t0, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_t0, 0)) + sfn_snapshot.match("creation_resp_t0", creation_resp_t0) + state_machine_arn = creation_resp_t0["stateMachineArn"] + + describe_resp_t0 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t0", describe_resp_t0) + + definition_t1 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_t1["States"]["State_1"]["Result"].update({"Arg1": "AfterUpdate1"}) + definition_str_t1 = json.dumps(definition_t1) + + snf_role_arn_t1 = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn_t1, "snf_role_arn_t1")) + + update_state_machine_res_t1 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str_t1, roleArn=snf_role_arn_t1 + ) + sfn_snapshot.match("update_state_machine_res_t1", update_state_machine_res_t1) + + describe_resp_t1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t1", describe_resp_t1) + + definition_t2 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_t2["States"]["State_1"]["Result"].update({"Arg1": "AfterUpdate2"}) + definition_str_t2 = json.dumps(definition_t2) + + snf_role_arn_t2 = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn_t2, "snf_role_arn_t2")) + + update_state_machine_res_t2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str_t2, roleArn=snf_role_arn_t2 + ) + sfn_snapshot.match("update_state_machine_res_t2", update_state_machine_res_t2) + + describe_resp_t2 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t2", describe_resp_t2) + + @markers.aws.validated + def test_create_update_state_machine_base_update_none( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition_t0 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str_t0 = json.dumps(definition_t0) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_t0 = create_state_machine( + aws_client, name=sm_name, definition=definition_str_t0, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_t0, 0)) + sfn_snapshot.match("creation_resp_t0", creation_resp_t0) + state_machine_arn = creation_resp_t0["stateMachineArn"] + + describe_resp_t0 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t0", describe_resp_t0) + + with pytest.raises(Exception) as missing_required_parameter: + aws_client_no_retry.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("missing_required_parameter", missing_required_parameter.value.response) + + with pytest.raises(Exception) as null_required_parameter: + aws_client_no_retry.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=None, roleArn=None + ) + sfn_snapshot.match("null_required_parameter", null_required_parameter.value) + + @markers.aws.validated + def test_create_update_state_machine_same_parameters( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn_t0 = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn_t0, "snf_role_arn_t0")) + + definition_t0 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str_t0 = json.dumps(definition_t0) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_t0 = create_state_machine( + aws_client, name=sm_name, definition=definition_str_t0, roleArn=snf_role_arn_t0 + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_t0, 0)) + sfn_snapshot.match("creation_resp_t0", creation_resp_t0) + state_machine_arn = creation_resp_t0["stateMachineArn"] + + describe_resp_t0 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t0", describe_resp_t0) + + snf_role_arn_t1 = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn_t1, "snf_role_arn_t1")) + + update_state_machine_res_t1 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, roleArn=snf_role_arn_t1 + ) + sfn_snapshot.match("update_state_machine_res_t1", update_state_machine_res_t1) + + describe_resp_t1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t1", describe_resp_t1) + + update_state_machine_res_t2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str_t0, roleArn=snf_role_arn_t1 + ) + sfn_snapshot.match("update_state_machine_res_t2", update_state_machine_res_t2) + + describe_resp_t2 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_t2", describe_resp_t2) + + @markers.aws.validated + def test_describe_state_machine_for_execution( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + sfn_snapshot.match("exec_resp", exec_resp) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + describe_resp = aws_client.stepfunctions.describe_state_machine_for_execution( + executionArn=execution_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) + + @markers.aws.validated + @pytest.mark.parametrize("encoder_function", [json.dumps, yaml.dump]) + def test_cloudformation_definition_create_describe( + self, + create_state_machine_iam_role, + sfn_snapshot, + aws_client, + encoder_function, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + state_machine_name = f"statemachine{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(state_machine_name, "state_machine_name")) + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + stack_name = f"test-create-describe-yaml-{short_uid()}" + cloudformation_template = { + "Resources": { + "MyStateMachine": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "StateMachineName": state_machine_name, + "Definition": definition, + "RoleArn": snf_role_arn, + }, + } + } + } + cloudformation_template = encoder_function(cloudformation_template) + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=cloudformation_template, + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + list_state_machines_response = aws_client.stepfunctions.list_state_machines() + state_machine = next( + ( + sm + for sm in list_state_machines_response["stateMachines"] + if sm["name"] == state_machine_name + ), + None, + ) + state_machine_arn = state_machine["stateMachineArn"] + sfn_snapshot.add_transformer(RegexTransformer(state_machine_arn, "state_machine_arn")) + + describe_state_machine_response = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_state_machine_response", describe_state_machine_response) + + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + aws_client.stepfunctions.delete_state_machine(stateMachineArn=state_machine_arn) + + @markers.aws.validated + @pytest.mark.parametrize("encoder_function", [json.dumps, yaml.dump]) + def test_cloudformation_definition_string_create_describe( + self, + create_state_machine_iam_role, + sfn_snapshot, + aws_client, + encoder_function, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + state_machine_name = f"statemachine{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(state_machine_name, "state_machine_name")) + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_string = json.dumps(definition) + stack_name = f"test-create-describe-yaml-{short_uid()}" + cloudformation_template = { + "Resources": { + "MyStateMachine": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "StateMachineName": state_machine_name, + "DefinitionString": definition_string, + "RoleArn": snf_role_arn, + }, + } + } + } + cloudformation_template = encoder_function(cloudformation_template) + + aws_client.cloudformation.create_stack( + StackName=stack_name, + TemplateBody=cloudformation_template, + ) + aws_client.cloudformation.get_waiter("stack_create_complete").wait(StackName=stack_name) + + list_state_machines_response = aws_client.stepfunctions.list_state_machines() + state_machine = next( + ( + sm + for sm in list_state_machines_response["stateMachines"] + if sm["name"] == state_machine_name + ), + None, + ) + state_machine_arn = state_machine["stateMachineArn"] + sfn_snapshot.add_transformer(RegexTransformer(state_machine_arn, "state_machine_arn")) + + describe_state_machine_response = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_state_machine_response", describe_state_machine_response) + + aws_client.cloudformation.delete_stack(StackName=stack_name) + aws_client.cloudformation.get_waiter("stack_delete_complete").wait(StackName=stack_name) + + aws_client.stepfunctions.delete_state_machine(stateMachineArn=state_machine_arn) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..redriveCount", "$..redriveStatus", "$..redriveStatusReason"] + ) + def test_describe_execution( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + sfn_snapshot.match("exec_resp", exec_resp) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + describe_execution = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + sfn_snapshot.match("describe_execution", describe_execution) + + @markers.aws.validated + def test_describe_execution_no_such_state_machine( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + invalid_execution_arn = execution_arn[:-4] + "0000" + sfn_snapshot.add_transformer( + RegexTransformer(invalid_execution_arn, "invalid_execution_arn") + ) + + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.describe_execution(executionArn=invalid_execution_arn) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_describe_execution_invalid_arn(self, sfn_snapshot, aws_client_no_retry): + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.describe_execution( + executionArn="invalid_state_machine_arn" + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..redriveCount", "$..redriveStatus", "$..redriveStatusReason"] + ) + def test_describe_execution_arn_containing_punctuation( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"state.machine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + + # ARN will contain a punctuation symbol + exec_name: str = f"state.machine.execution_{short_uid()}" + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=creation_resp["stateMachineArn"], name=exec_name + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + sfn_snapshot.match("exec_resp", exec_resp) + execution_arn = exec_resp["executionArn"] + + await_execution_success( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + describe_execution = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + sfn_snapshot.match("describe_execution", describe_execution) + + @markers.aws.needs_fixing + def test_get_execution_history_no_such_execution( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name: str = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn = creation_resp["stateMachineArn"] + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + execution_arn = exec_resp["executionArn"] + + invalid_execution_arn = execution_arn[:-4] + "0000" + sfn_snapshot.add_transformer( + RegexTransformer(invalid_execution_arn, "invalid_execution_arn") + ) + + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.get_execution_history( + executionArn=invalid_execution_arn + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_get_execution_history_invalid_arn(self, sfn_snapshot, aws_client_no_retry): + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.get_execution_history( + executionArn="invalid_state_machine_arn" + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.snapshot.skip_snapshot_verify(paths=["$..redriveCount"]) + @markers.aws.validated + def test_state_machine_status_filter( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + sm_name = f"statemachine_{short_uid()}" + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_resp = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + state_machine_arn = creation_resp["stateMachineArn"] + + list_response = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, statusFilter="SUCCEEDED" + ) + sfn_snapshot.match("list_before_execution", list_response) + + exec_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + sfn_snapshot.match("exec_resp", exec_resp) + execution_arn = exec_resp["executionArn"] + + await_list_execution_status( + stepfunctions_client=aws_client.stepfunctions, + state_machine_arn=state_machine_arn, + execution_arn=execution_arn, + status="SUCCEEDED", + ) + + list_response = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, statusFilter="SUCCEEDED" + ) + sfn_snapshot.match("list_succeeded_when_complete", list_response) + + list_response = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, statusFilter="RUNNING" + ) + sfn_snapshot.match("list_running_when_complete", list_response) + + with pytest.raises(ClientError) as e: + aws_client_no_retry.stepfunctions.list_executions( + stateMachineArn=state_machine_arn, statusFilter="succeeded" + ) + sfn_snapshot.match("list_executions_filter_exc", e.value.response) + + @markers.aws.validated + def test_start_sync_execution( + self, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sfn_snapshot, + aws_client, + aws_client_no_sync_prefix, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + queue_url = sqs_create_queue(QueueName=f"queue-{short_uid()}") + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "sqs_queue_url")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_response = create_state_machine( + aws_client, + name=f"statemachine_{short_uid()}", + definition=definition_str, + roleArn=snf_role_arn, + type=StateMachineType.STANDARD, + ) + state_machine_arn = creation_response["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_response, 0)) + sfn_snapshot.match("creation_response", creation_response) + + with pytest.raises(Exception) as ex: + aws_client_no_sync_prefix.stepfunctions.start_sync_execution( + stateMachineArn=state_machine_arn, input=json.dumps({}), name="SyncExecution" + ) + sfn_snapshot.match("start_sync_execution_error", ex.value.response) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api.snapshot.json new file mode 100644 index 0000000000000..da8b0103552eb --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api.snapshot.json @@ -0,0 +1,2312 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_delete_valid_sm": { + "recorded-date": "22-06-2023, 13:47:21", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deletion_resp_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_delete_invalid_sm": { + "recorded-date": "04-08-2023, 16:47:10", + "recorded-content": { + "invalid_definition_1": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: These fields are required: [States] at /'" + }, + "message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: These fields are required: [States] at /'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_delete_nonexistent_sm": { + "recorded-date": "22-06-2023, 13:47:36", + "recorded-content": { + "deletion_resp_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_exact_duplicate_sm": { + "recorded-date": "22-06-2023, 13:48:01", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "creation_resp_2": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_2": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_1_2": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_duplicate_definition_format_sm": { + "recorded-date": "22-06-2023, 13:48:15", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "already_exists_1": { + "Error": { + "Code": "StateMachineAlreadyExists", + "Message": "State Machine Already Exists: 'arn::states::111111111111:stateMachine:'" + }, + "message": "State Machine Already Exists: 'arn::states::111111111111:stateMachine:'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_duplicate_sm_name": { + "recorded-date": "22-06-2023, 13:48:28", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "already_exists_1": { + "Error": { + "Code": "StateMachineAlreadyExists", + "Message": "State Machine Already Exists: 'arn::states::111111111111:stateMachine:'" + }, + "message": "State Machine Already Exists: 'arn::states::111111111111:stateMachine:'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_execution": { + "recorded-date": "30-06-2024, 16:12:59", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_list_resp": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_invalid_start_execution_arn": { + "recorded-date": "22-06-2023, 13:52:53", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_exec_of_deleted": { + "Error": { + "Code": "StateMachineDoesNotExist", + "Message": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine:statemachine_invalid_'" + }, + "message": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine:statemachine_invalid_'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_invalid_start_execution_input": { + "recorded-date": "22-06-2023, 13:53:08", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_exec_str_inp": { + "Error": { + "Code": "InvalidExecutionInput", + "Message": "Invalid State Machine Execution Input: 'Unrecognized token 'not': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')'" + }, + "message": "Invalid State Machine Execution Input: 'Unrecognized token 'not': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "start_exec_not_json_inp": { + "Error": { + "Code": "InvalidExecutionInput", + "Message": "Invalid State Machine Execution Input: 'Unexpected character (''' (code 39)): was expecting double-quote to start field name'" + }, + "message": "Invalid State Machine Execution Input: 'Unexpected character (''' (code 39)): was expecting double-quote to start field name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "start_res_empty": { + "Error": { + "Code": "InvalidExecutionInput", + "Message": "Invalid State Machine Execution Input: 'Invalid execution input.'" + }, + "message": "Invalid State Machine Execution Input: 'Invalid execution input.'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "start_res_num": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_res_str": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_res_null": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_stop_execution": { + "recorded-date": "06-05-2024, 12:59:34", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "stop_res": { + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_hist_resp": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "WaitStateEntered" + }, + { + "executionAbortedEventDetails": {}, + "id": 3, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionAborted" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_sms": { + "recorded-date": "29-08-2023, 19:40:19", + "recorded-content": { + "creation_resp_0": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "creation_resp_2": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "lst_resp_filter": [ + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + } + ], + "deletion_resp_0": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deletion_resp_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deletion_resp_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "lst_resp_del_filter": [] + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_definition": { + "recorded-date": "08-08-2023, 12:43:56", + "recorded-content": { + "creation_resp_t0": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t0": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "AfterUpdate1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res_t2": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t2": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "AfterUpdate2" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_role_arn": { + "recorded-date": "08-08-2023, 12:44:40", + "recorded-content": { + "creation_resp_t0": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t0": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn_t0", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res_t1": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn_t1", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res_t2": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t2": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn_t2", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_definition_and_role": { + "recorded-date": "08-08-2023, 12:45:23", + "recorded-content": { + "creation_resp_t0": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t0": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res_t1": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "AfterUpdate1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn_t1", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res_t2": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t2": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "AfterUpdate2" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn_t2", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_update_none": { + "recorded-date": "08-08-2023, 12:45:45", + "recorded-content": { + "creation_resp_t0": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t0": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "missing_required_parameter": { + "Error": { + "Code": "MissingRequiredParameter", + "Message": "Either the definition, the role ARN, the LoggingConfiguration, or the TracingConfiguration must be specified" + }, + "message": "Either the definition, the role ARN, the LoggingConfiguration, or the TracingConfiguration must be specified", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "null_required_parameter": "Parameter validation failed:\nInvalid type for parameter definition, value: None, type: , valid types: \nInvalid type for parameter roleArn, value: None, type: , valid types: " + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_same_parameters": { + "recorded-date": "08-08-2023, 12:46:16", + "recorded-content": { + "creation_resp_t0": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t0": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn_t0", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res_t1": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn_t1", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_res_t2": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_t2": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn_t1", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_state_machine_for_execution": { + "recorded-date": "21-08-2023, 17:20:20", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_nonexistent_sm": { + "recorded-date": "26-10-2023, 17:10:22", + "recorded-content": { + "describe_nonexistent_sm": "An error occurred (StateMachineDoesNotExist) when calling the DescribeStateMachine operation: State Machine Does Not Exist: 'sm_nonexistent_arn'" + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_create_describe[dumps]": { + "recorded-date": "17-11-2023, 17:01:05", + "recorded-content": { + "describe_state_machine_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "End": true, + "Result": { + "Arg1": "argument1" + }, + "Type": "Pass" + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "state_machine_name", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:state_machine_name", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_create_describe[dump]": { + "recorded-date": "17-11-2023, 17:02:36", + "recorded-content": { + "describe_state_machine_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "End": true, + "Result": { + "Arg1": "argument1" + }, + "Type": "Pass" + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "state_machine_name", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:state_machine_name", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_string_create_describe[dumps]": { + "recorded-date": "17-11-2023, 17:06:22", + "recorded-content": { + "describe_state_machine_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "state_machine_name", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:state_machine_name", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_string_create_describe[dump]": { + "recorded-date": "17-11-2023, 17:07:38", + "recorded-content": { + "describe_state_machine_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "state_machine_name", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:state_machine_name", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_reversed": { + "recorded-date": "28-01-2024, 19:44:43", + "recorded-content": { + "get_execution_history_reverseOrder[False]": { + "events": [ + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "get_execution_history_reverseOrder[True]": { + "events": [ + { + "executionSucceededEventDetails": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "ExecutionSucceeded" + }, + { + "id": 3, + "previousEventId": 2, + "stateExitedEventDetails": { + "name": "State_1", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "timestamp": "timestamp", + "type": "PassStateExited" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "name": "State_1" + }, + "timestamp": "timestamp", + "type": "PassStateEntered" + }, + { + "executionStartedEventDetails": { + "input": {}, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_invalid_arn_sm": { + "recorded-date": "08-03-2024, 10:34:51", + "recorded-content": { + "exception": { + "exception_typename": "InvalidArn", + "exception_value": "An error occurred (InvalidArn) when calling the DescribeStateMachine operation: Invalid Arn: 'Invalid ARN prefix: not_a_valid_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_execution_no_such_state_machine": { + "recorded-date": "08-03-2024, 10:51:46", + "recorded-content": { + "exception": { + "exception_typename": "StateMachineDoesNotExist", + "exception_value": "An error occurred (StateMachineDoesNotExist) when calling the ListExecutions operation: State Machine Does Not Exist: 'ssm_nonexistent_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_execution_invalid_arn": { + "recorded-date": "08-03-2024, 12:37:36", + "recorded-content": { + "exception": { + "exception_typename": "InvalidArn", + "exception_value": "An error occurred (InvalidArn) when calling the ListExecutions operation: Invalid Arn: 'Invalid ARN prefix: invalid_state_machine_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution": { + "recorded-date": "21-08-2024, 09:14:30", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_execution": { + "executionArn": "arn::states::111111111111:execution::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "included": true + }, + "redriveCount": 0, + "redriveStatus": "NOT_REDRIVABLE", + "redriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_no_such_state_machine": { + "recorded-date": "08-03-2024, 12:05:10", + "recorded-content": { + "exception": { + "exception_typename": "ExecutionDoesNotExist", + "exception_value": "An error occurred (ExecutionDoesNotExist) when calling the DescribeExecution operation: Execution Does Not Exist: 'invalid_execution_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_no_such_execution": { + "recorded-date": "08-03-2024, 12:34:03", + "recorded-content": { + "exception": { + "exception_typename": "ExecutionDoesNotExist", + "exception_value": "An error occurred (ExecutionDoesNotExist) when calling the GetExecutionHistory operation: Execution Does Not Exist: 'invalid_execution_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_invalid_arn": { + "recorded-date": "08-03-2024, 12:36:35", + "recorded-content": { + "exception": { + "exception_typename": "InvalidArn", + "exception_value": "An error occurred (InvalidArn) when calling the GetExecutionHistory operation: Invalid Arn: 'Invalid ARN prefix: invalid_state_machine_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_invalid_arn": { + "recorded-date": "08-03-2024, 12:37:23", + "recorded-content": { + "exception": { + "exception_typename": "InvalidArn", + "exception_value": "An error occurred (InvalidArn) when calling the DescribeExecution operation: Invalid Arn: 'Invalid ARN prefix: invalid_state_machine_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_state_machine_status_filter": { + "recorded-date": "14-03-2024, 21:58:24", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_before_execution": { + "executions": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_succeeded_when_complete": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_running_when_complete": { + "executions": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_executions_filter_exc": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value 'succeeded' at 'statusFilter' failed to satisfy constraint: Member must satisfy enum value set: [SUCCEEDED, TIMED_OUT, PENDING_REDRIVE, ABORTED, FAILED, RUNNING]" + }, + "message": "1 validation error detected: Value 'succeeded' at 'statusFilter' failed to satisfy constraint: Member must satisfy enum value set: [SUCCEEDED, TIMED_OUT, PENDING_REDRIVE, ABORTED, FAILED, RUNNING]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_arn_containing_punctuation": { + "recorded-date": "11-06-2024, 14:54:08", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_execution": { + "executionArn": "arn::states::111111111111:execution::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "included": true + }, + "redriveCount": 0, + "redriveStatus": "NOT_REDRIVABLE", + "redriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_sm_arn_containing_punctuation": { + "recorded-date": "11-06-2024, 14:55:41", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_sync_execution": { + "recorded-date": "03-07-2024, 17:10:25", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_sync_execution_error": { + "Error": { + "Code": "StateMachineTypeNotSupported", + "Message": "This operation is not supported by this type of state machine" + }, + "message": "This operation is not supported by this type of state machine", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_sms_pagination": { + "recorded-date": "01-07-2024, 12:04:17", + "recorded-content": { + "list-state-machines-page-1": [ + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + } + ], + "list-state-machines-page-2": [ + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + }, + { + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "name": "", + "type": "STANDARD", + "creationDate": "datetime" + } + ], + "list-state-machines-invalid-param-too-large": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000" + }, + "message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-state-machines-invalid-param-short-nextToken": { + "exception_typename": "ParamValidationError", + "exception_value": "Parameter validation failed:\nInvalid length for parameter nextToken, value: 0, valid min length: 1" + }, + "list-state-machines-invalid-param-long-nextToken": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 1024" + }, + "message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 1024", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_executions_versions_pagination": { + "recorded-date": "01-07-2024, 12:54:55", + "recorded-content": { + "list-executions-page-1": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "nextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-execution-page-2": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-executions-invalid-param-too-large": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000" + }, + "message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-executions-invalid-param-short-nextToken": { + "exception_typename": "ParamValidationError", + "exception_value": "Parameter validation failed:\nInvalid length for parameter nextToken, value: 0, valid min length: 1" + }, + "list-executions-invalid-param-long-nextToken": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 3096" + }, + "message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 3096", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deletion_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_executions_pagination": { + "recorded-date": "01-07-2024, 12:33:00", + "recorded-content": { + "list-executions-page-1": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "nextToken": "", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-executions-page-2": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-executions-invalid-param-too-large": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000" + }, + "message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-executions-invalid-param-short-nextToken": { + "exception_typename": "ParamValidationError", + "exception_value": "Parameter validation failed:\nInvalid length for parameter nextToken, value: 0, valid min length: 1" + }, + "list-executions-invalid-param-long-nextToken": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 3096" + }, + "message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 3096", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "deletion_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_execution_idempotent": { + "recorded-date": "21-08-2024, 09:15:48", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_resp_idempotent": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_exec_already_exists": { + "Error": { + "Code": "ExecutionAlreadyExists", + "Message": "Execution Already Exists: 'arn::states::111111111111:execution::'" + }, + "message": "Execution Already Exists: 'arn::states::111111111111:execution::'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "stop_res": { + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api.validation.json new file mode 100644 index 0000000000000..18ae823d6591f --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api.validation.json @@ -0,0 +1,119 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_create_describe[dump]": { + "last_validated_date": "2023-11-17T16:02:36+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_create_describe[dumps]": { + "last_validated_date": "2023-11-17T16:01:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_string_create_describe[dump]": { + "last_validated_date": "2023-11-17T16:07:38+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_cloudformation_definition_string_create_describe[dumps]": { + "last_validated_date": "2023-11-17T16:06:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_delete_invalid_sm": { + "last_validated_date": "2023-08-04T14:47:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_delete_valid_sm": { + "last_validated_date": "2023-06-22T11:47:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_duplicate_definition_format_sm": { + "last_validated_date": "2023-06-22T11:48:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_duplicate_sm_name": { + "last_validated_date": "2023-06-22T11:48:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_exact_duplicate_sm": { + "last_validated_date": "2023-06-22T11:48:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_definition": { + "last_validated_date": "2023-08-08T10:43:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_definition_and_role": { + "last_validated_date": "2023-08-08T10:45:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_role_arn": { + "last_validated_date": "2023-08-08T10:44:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_base_update_none": { + "last_validated_date": "2023-08-08T10:45:45+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_create_update_state_machine_same_parameters": { + "last_validated_date": "2023-08-08T10:46:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_delete_nonexistent_sm": { + "last_validated_date": "2023-06-22T11:47:36+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution": { + "last_validated_date": "2024-08-21T09:14:30+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_arn_containing_punctuation": { + "last_validated_date": "2024-06-11T14:54:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_invalid_arn": { + "last_validated_date": "2024-03-08T12:37:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_execution_no_such_state_machine": { + "last_validated_date": "2024-03-08T12:05:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_invalid_arn_sm": { + "last_validated_date": "2024-03-08T10:34:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_nonexistent_sm": { + "last_validated_date": "2023-10-26T15:10:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_sm_arn_containing_punctuation": { + "last_validated_date": "2024-06-11T14:55:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_describe_state_machine_for_execution": { + "last_validated_date": "2023-08-21T15:20:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_invalid_arn": { + "last_validated_date": "2024-03-08T12:36:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_no_such_execution": { + "last_validated_date": "2024-03-08T12:34:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_get_execution_history_reversed": { + "last_validated_date": "2024-01-28T19:44:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_invalid_start_execution_arn": { + "last_validated_date": "2023-06-22T11:52:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_invalid_start_execution_input": { + "last_validated_date": "2023-06-22T11:53:08+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_execution_invalid_arn": { + "last_validated_date": "2024-03-08T12:37:36+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_execution_no_such_state_machine": { + "last_validated_date": "2024-03-08T10:51:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_executions_pagination": { + "last_validated_date": "2024-07-01T12:33:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_executions_versions_pagination": { + "last_validated_date": "2024-07-01T12:54:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_sms": { + "last_validated_date": "2023-08-29T17:40:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_list_sms_pagination": { + "last_validated_date": "2024-07-01T12:04:17+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_execution": { + "last_validated_date": "2024-06-30T16:12:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_execution_idempotent": { + "last_validated_date": "2024-08-21T09:15:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_start_sync_execution": { + "last_validated_date": "2024-07-03T17:10:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_state_machine_status_filter": { + "last_validated_date": "2024-03-14T21:59:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api.py::TestSnfApi::test_stop_execution": { + "last_validated_date": "2024-05-06T12:59:34+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py new file mode 100644 index 0000000000000..8dcee8bceeac7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py @@ -0,0 +1,181 @@ +import pytest +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +@markers.snapshot.skip_snapshot_verify(paths=["$..encryptionConfiguration"]) +class TestSnfApiActivities: + @markers.aws.validated + @pytest.mark.parametrize( + "activity_base_name", + [ + "Activity1", + "activity-name_123", + "ACTIVITY_NAME_ABC", + "activity.name", + "activity.name.v2", + "activityName.with.dots", + "activity-name.1", + "activity_123.name", + "a" * 71, # this plus the uuid postfix is a name of length 80 + ], + ) + def test_create_describe_delete_activity( + self, + create_activity, + sfn_snapshot, + aws_client, + activity_base_name, + ): + activity_name = f"{activity_base_name}-{short_uid()}" + create_activity_response = aws_client.stepfunctions.create_activity(name=activity_name) + activity_arn = create_activity_response["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_response", create_activity_response) + + create_activity_response_duplicate = aws_client.stepfunctions.create_activity( + name=activity_name + ) + sfn_snapshot.match("create_activity_response_duplicate", create_activity_response_duplicate) + + describe_activity_response = aws_client.stepfunctions.describe_activity( + activityArn=activity_arn + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..creationDate", replacement="creation-date", replace_reference=False + ) + ) + sfn_snapshot.match("describe_activity_response", describe_activity_response) + + delete_activity_response = aws_client.stepfunctions.delete_activity( + activityArn=activity_arn + ) + sfn_snapshot.match("delete_activity_response", delete_activity_response) + + delete_activity_response_2 = aws_client.stepfunctions.delete_activity( + activityArn=activity_arn + ) + sfn_snapshot.match("delete_activity_response_2", delete_activity_response_2) + + @markers.aws.validated + @pytest.mark.parametrize( + "activity_name", + [ + "activity name", + "activityname", + "activity{name", + "activity}name", + "activity[name", + "activity]name", + "activity?name", + "activity*name", + 'activity"name', + "activity#name", + "activity%name", + "activity\\name", + "activity^name", + "activity|name", + "activity~name", + "activity`name", + "activity$name", + "activity&name", + "activity,name", + "activity;name", + "activity:name", + "activity/name", + chr(0) + "activity", + "activity" + chr(31), + "activity" + chr(127), + ], + ) + def test_create_activity_invalid_name( + self, create_activity, sfn_snapshot, aws_client_no_retry, activity_name + ): + with pytest.raises(ClientError) as e: + aws_client_no_retry.stepfunctions.create_activity(name=activity_name) + sfn_snapshot.match("invalid_name", e.value.response) + + @markers.aws.validated + def test_describe_deleted_activity( + self, create_activity, sfn_snapshot, aws_client, aws_client_no_retry + ): + create_activity_response = aws_client.stepfunctions.create_activity( + name=f"TestActivity-{short_uid()}" + ) + activity_arn = create_activity_response["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + aws_client.stepfunctions.delete_activity(activityArn=activity_arn) + with pytest.raises(ClientError) as e: + aws_client_no_retry.stepfunctions.describe_activity(activityArn=activity_arn) + sfn_snapshot.match("no_such_activity", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_describe_activity_invalid_arn( + self, + sfn_snapshot, + aws_client_no_retry, + ): + with pytest.raises(ClientError) as exc: + aws_client_no_retry.stepfunctions.describe_activity(activityArn="no_an_activity_arn") + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_get_activity_task_deleted( + self, create_activity, sfn_snapshot, aws_client, aws_client_no_retry + ): + create_activity_response = aws_client.stepfunctions.create_activity( + name=f"TestActivity-{short_uid()}" + ) + activity_arn = create_activity_response["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + aws_client.stepfunctions.delete_activity(activityArn=activity_arn) + with pytest.raises(ClientError) as e: + aws_client_no_retry.stepfunctions.get_activity_task(activityArn=activity_arn) + sfn_snapshot.match("no_such_activity", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_get_activity_task_invalid_arn( + self, + sfn_snapshot, + aws_client_no_retry, + ): + with pytest.raises(ClientError) as exc: + aws_client_no_retry.stepfunctions.get_activity_task(activityArn="no_an_activity_arn") + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_list_activities( + self, + create_activity, + sfn_snapshot, + aws_client, + ): + activity_arns = set() + for i in range(3): + activity_name = f"TestActivity-{i}-{short_uid()}" + create_activity_response = aws_client.stepfunctions.create_activity(name=activity_name) + activity_arn = create_activity_response["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, f"activity_arn_{i}")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, f"activity_name_{i}")) + activity_arns.add(activity_arn) + + list_activities_response = aws_client.stepfunctions.list_activities() + activities = list_activities_response["activities"] + activities = list( + filter(lambda activity: activity["activityArn"] in activity_arns, activities) + ) + list_activities_response["activities"] = activities + + sfn_snapshot.match("list_activities_response", list_activities_response) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.snapshot.json new file mode 100644 index 0000000000000..3ea059b17717d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.snapshot.json @@ -0,0 +1,901 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_describe_deleted_activity": { + "recorded-date": "17-03-2024, 10:33:44", + "recorded-content": { + "no_such_activity": { + "Error": { + "Code": "ActivityDoesNotExist", + "Message": "Activity Does Not Exist: 'activity_arn'" + }, + "message": "Activity Does Not Exist: 'activity_arn'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_get_activity_task_deleted": { + "recorded-date": "17-03-2024, 10:34:59", + "recorded-content": { + "no_such_activity": { + "Error": { + "Code": "ActivityDoesNotExist", + "Message": "Activity Does Not Exist: 'activity_arn'" + }, + "message": "Activity Does Not Exist: 'activity_arn'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_describe_activity_invalid_arn": { + "recorded-date": "11-03-2024, 20:38:07", + "recorded-content": { + "exception": { + "exception_typename": "InvalidArn", + "exception_value": "An error occurred (InvalidArn) when calling the DescribeActivity operation: Invalid Arn: 'Invalid ARN prefix: no_an_activity_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_get_activity_task_invalid_arn": { + "recorded-date": "11-03-2024, 20:40:26", + "recorded-content": { + "exception": { + "exception_typename": "InvalidArn", + "exception_value": "An error occurred (InvalidArn) when calling the GetActivityTask operation: Invalid Arn: 'Invalid ARN prefix: no_an_activity_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_list_activities": { + "recorded-date": "17-03-2024, 11:26:07", + "recorded-content": { + "list_activities_response": { + "activities": [ + { + "activityArn": "activity_arn_0", + "creationDate": "datetime", + "name": "activity_name_0" + }, + { + "activityArn": "activity_arn_1", + "creationDate": "datetime", + "name": "activity_name_1" + }, + { + "activityArn": "activity_arn_2", + "creationDate": "datetime", + "name": "activity_name_2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[Activity1]": { + "recorded-date": "25-11-2024, 19:02:40", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity-name_123]": { + "recorded-date": "25-11-2024, 19:02:40", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[ACTIVITY_NAME_ABC]": { + "recorded-date": "25-11-2024, 19:02:41", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity.name]": { + "recorded-date": "25-11-2024, 19:02:42", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity.name.v2]": { + "recorded-date": "25-11-2024, 19:02:42", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activityName.with.dots]": { + "recorded-date": "25-11-2024, 19:02:42", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity-name.1]": { + "recorded-date": "25-11-2024, 19:02:43", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity_123.name]": { + "recorded-date": "25-11-2024, 19:02:43", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]": { + "recorded-date": "25-11-2024, 19:02:44", + "recorded-content": { + "create_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_activity_response_duplicate": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_activity_response": { + "activityArn": "activity_arn", + "creationDate": "creation-date", + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "name": "activity_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_activity_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity name]": { + "recorded-date": "25-11-2024, 19:07:31", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity name'" + }, + "message": "Invalid Name: 'activity name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activityname]": { + "recorded-date": "25-11-2024, 19:07:31", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity>name'" + }, + "message": "Invalid Name: 'activity>name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity{name]": { + "recorded-date": "25-11-2024, 19:07:31", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity{name'" + }, + "message": "Invalid Name: 'activity{name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity}name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity}name'" + }, + "message": "Invalid Name: 'activity}name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity[name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity[name'" + }, + "message": "Invalid Name: 'activity[name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity]name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity]name'" + }, + "message": "Invalid Name: 'activity]name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity?name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity?name'" + }, + "message": "Invalid Name: 'activity?name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity*name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity*name'" + }, + "message": "Invalid Name: 'activity*name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\"name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity\"name'" + }, + "message": "Invalid Name: 'activity\"name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity#name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity#name'" + }, + "message": "Invalid Name: 'activity#name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity%name]": { + "recorded-date": "25-11-2024, 19:07:32", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity%name'" + }, + "message": "Invalid Name: 'activity%name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\\\name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity\\name'" + }, + "message": "Invalid Name: 'activity\\name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity^name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity^name'" + }, + "message": "Invalid Name: 'activity^name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity|name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity|name'" + }, + "message": "Invalid Name: 'activity|name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity~name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity~name'" + }, + "message": "Invalid Name: 'activity~name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity`name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity`name'" + }, + "message": "Invalid Name: 'activity`name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity$name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity$name'" + }, + "message": "Invalid Name: 'activity$name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity&name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity&name'" + }, + "message": "Invalid Name: 'activity&name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity,name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity,name'" + }, + "message": "Invalid Name: 'activity,name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity;name]": { + "recorded-date": "25-11-2024, 19:07:33", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity;name'" + }, + "message": "Invalid Name: 'activity;name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity:name]": { + "recorded-date": "25-11-2024, 19:07:34", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity:name'" + }, + "message": "Invalid Name: 'activity:name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity/name]": { + "recorded-date": "25-11-2024, 19:07:34", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity/name'" + }, + "message": "Invalid Name: 'activity/name'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[\\x00activity]": { + "recorded-date": "25-11-2024, 19:07:34", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: '\u0000activity'" + }, + "message": "Invalid Name: '\u0000activity'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\x1f]": { + "recorded-date": "25-11-2024, 19:07:34", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity\u001f'" + }, + "message": "Invalid Name: 'activity\u001f'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\x7f]": { + "recorded-date": "25-11-2024, 19:07:34", + "recorded-content": { + "invalid_name": { + "Error": { + "Code": "InvalidName", + "Message": "Invalid Name: 'activity\u007f'" + }, + "message": "Invalid Name: 'activity\u007f'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.validation.json new file mode 100644 index 0000000000000..c0e5007ee3626 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_activities.validation.json @@ -0,0 +1,122 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[\\x00activity]": { + "last_validated_date": "2024-11-25T19:07:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity name]": { + "last_validated_date": "2024-11-25T19:07:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\"name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity#name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity$name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity%name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity&name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity*name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity,name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity/name]": { + "last_validated_date": "2024-11-25T19:07:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity:name]": { + "last_validated_date": "2024-11-25T19:07:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity;name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activityname]": { + "last_validated_date": "2024-11-25T19:07:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity?name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity[name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\\\name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\x1f]": { + "last_validated_date": "2024-11-25T19:07:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity\\x7f]": { + "last_validated_date": "2024-11-25T19:07:34+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity]name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity^name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity`name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity{name]": { + "last_validated_date": "2024-11-25T19:07:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity|name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity}name]": { + "last_validated_date": "2024-11-25T19:07:32+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_activity_invalid_name[activity~name]": { + "last_validated_date": "2024-11-25T19:07:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[ACTIVITY_NAME_ABC]": { + "last_validated_date": "2024-11-25T19:02:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[Activity1]": { + "last_validated_date": "2024-11-25T19:02:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]": { + "last_validated_date": "2024-11-25T19:02:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity-name.1]": { + "last_validated_date": "2024-11-25T19:02:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity-name_123]": { + "last_validated_date": "2024-11-25T19:02:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity.name.v2]": { + "last_validated_date": "2024-11-25T19:02:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity.name]": { + "last_validated_date": "2024-11-25T19:02:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activityName.with.dots]": { + "last_validated_date": "2024-11-25T19:02:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_create_describe_delete_activity[activity_123.name]": { + "last_validated_date": "2024-11-25T19:02:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_describe_activity_invalid_arn": { + "last_validated_date": "2024-03-11T20:38:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_describe_deleted_activity": { + "last_validated_date": "2024-03-17T10:33:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_get_activity_task_deleted": { + "last_validated_date": "2024-03-17T10:34:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_get_activity_task_invalid_arn": { + "last_validated_date": "2024-03-11T20:40:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_activities.py::TestSnfApiActivities::test_list_activities": { + "last_validated_date": "2024-03-17T11:26:07+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py new file mode 100644 index 0000000000000..b1c1b100a9316 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py @@ -0,0 +1,1261 @@ +import json +import time + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.stepfunctions import Arn, RoutingConfigurationListItem +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_terminated, + await_state_machine_alias_is_created, + await_state_machine_alias_is_deleted, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..tracingConfiguration", + "$..redriveCount", + "$..redriveStatus", + "$..redriveStatusReason", + ] +) +class TestSfnApiAliasing: + @markers.aws.validated + def test_base_create_alias_single_router_config( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=definition_str, + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + + @markers.aws.validated + def test_error_create_alias_with_state_machine_arn( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=definition_str, + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + description="create state machine alias description", + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_error_create_alias_not_idempotent( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + state_machine_name = f"state_machine_{short_uid()}" + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn_v0 = create_state_machine_response["stateMachineVersionArn"] + + definition["Comment"] = "Definition v1" + update_state_machine_response = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(definition), publish=True + ) + state_machine_version_arn_v1 = update_state_machine_response["stateMachineVersionArn"] + + state_machine_alias_name = f"AliasName-{short_uid()}" + state_machine_alias_description = "create state machine alias description" + state_machine_alias_routing_configuration = [ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn_v0, weight=100 + ) + ] + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description=state_machine_alias_description, + name=state_machine_alias_name, + routingConfiguration=state_machine_alias_routing_configuration, + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + description="This is a different description", + name=state_machine_alias_name, + routingConfiguration=state_machine_alias_routing_configuration, + ) + sfn_snapshot.match( + "not_idempotent_description", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + description=state_machine_alias_description, + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn_v0, weight=50 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn_v1, weight=50 + ), + ], + ) + sfn_snapshot.match( + "not_idempotent_routing_configuration", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + @markers.aws.validated + def test_idempotent_create_alias( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=definition_str, + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + for attempt_number in range(2): + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + f"create_state_machine_alias_response_attempt_{attempt_number}", + create_state_machine_alias_response, + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=f"{state_machine_alias_name}-second", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response_different_name", + create_state_machine_alias_response, + ) + + list_state_machine_aliases_response = aws_client.stepfunctions.list_state_machine_aliases( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match( + "list_state_machine_aliases_response", list_state_machine_aliases_response + ) + + @markers.aws.validated + def test_error_create_alias_invalid_router_configs( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + + state_machine_version_arns: list[Arn] = list() + state_machine_version_arns.append(create_state_machine_response["stateMachineVersionArn"]) + for version_number in range(2): + definition["Comment"] = f"Definition for version {version_number}" + update_state_machine_response = sfn_client.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(definition), publish=True + ) + state_machine_version_arn = update_state_machine_response["stateMachineVersionArn"] + state_machine_version_arns.append(state_machine_version_arn) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[], + ) + sfn_snapshot.match( + "no_routing", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=50 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[1], weight=30 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[2], weight=20 + ), + ], + ) + sfn_snapshot.match( + "too_many_routing", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=50 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=50 + ), + ], + ) + sfn_snapshot.match( + "duplicate_routing", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_arn, weight=70 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[1], weight=30 + ), + ], + ) + sfn_snapshot.match( + "invalid_arn", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=101 + ) + ], + ) + sfn_snapshot.match( + "weight_too_large", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=-1 + ) + ], + ) + sfn_snapshot.match( + "weight_too_small", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=70 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[1], weight=29 + ), + ], + ) + sfn_snapshot.match( + "sum_weights_less_than_100", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + name=f"AliasName-{short_uid()}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=70 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[1], weight=31 + ), + ], + ) + sfn_snapshot.match( + "sum_weights_more_than_100", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + @markers.aws.validated + def test_error_create_alias_invalid_name( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=definition_str, + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + invalid_names = ["123", "", "A" * 81, "INVALID ALIAS", "!INVALID", "ALIAS@"] + for invalid_name in invalid_names: + with pytest.raises(Exception) as exc: + create_state_machine_alias( + target_aws_client=aws_client_no_retry, + description="create state machine alias description", + name=invalid_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + f"exception_for_name{invalid_name}", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + @markers.aws.validated + def test_base_lifecycle_create_delete_list( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=definition_str, + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + list_state_machine_aliases_response = sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match( + "list_state_machine_aliases_response_empty", list_state_machine_aliases_response + ) + + state_machine_alias_base_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_base_name, "state_machine_alias_base_name") + ) + state_machine_alias_arns: list[str] = list() + for num in range(3): + state_machine_alias_name = f"{state_machine_alias_base_name}-{num}" + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + f"create_state_machine_alias_response_num_{num}", + create_state_machine_alias_response, + ) + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + state_machine_alias_arns.append(state_machine_alias_arn) + + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + list_state_machine_aliases_response = sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match( + f"list_state_machine_aliases_response_after_creation_{num}", + list_state_machine_aliases_response, + ) + + for num, state_machine_alias_arn in enumerate(state_machine_alias_arns): + delete_state_machine_alias_response = sfn_client.delete_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn + ) + sfn_snapshot.match( + f"delete_state_machine_alias_response_{num}", + delete_state_machine_alias_response, + ) + + await_state_machine_alias_is_deleted( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + list_state_machine_aliases_response = sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match( + f"list_state_machine_aliases_response_after_deletion_{num}", + list_state_machine_aliases_response, + ) + + @markers.aws.validated + def test_update_no_such_alias_arn( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + sfn_client.delete_state_machine_alias(stateMachineAliasArn=state_machine_alias_arn) + await_state_machine_alias_is_deleted( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.update_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn, + description="Updated state machine alias description", + ) + sfn_snapshot.match( + "update_no_such_alias_arn", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + @markers.aws.validated + def test_base_lifecycle_create_invoke_describe_list( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=definition_str, + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + list_state_machine_aliases_response = sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match( + "list_state_machine_aliases_response_empty", list_state_machine_aliases_response + ) + + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + start_execution_response = sfn_client.start_execution( + stateMachineArn=state_machine_alias_arn, input="{}" + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_exec_arn(start_execution_response, 0) + ) + execution_arn = start_execution_response["executionArn"] + sfn_snapshot.match("start_execution_response_through_alias", start_execution_response) + + await_execution_terminated(stepfunctions_client=sfn_client, execution_arn=execution_arn) + + describe_execution_response = sfn_client.describe_execution(executionArn=execution_arn) + sfn_snapshot.match("describe_execution_response_through_alias", describe_execution_response) + + start_execution_response = sfn_client.start_execution( + stateMachineArn=state_machine_version_arn, input="{}" + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_exec_arn(start_execution_response, 1) + ) + execution_arn = start_execution_response["executionArn"] + sfn_snapshot.match("start_execution_response_through_version_arn", start_execution_response) + + await_execution_terminated(stepfunctions_client=sfn_client, execution_arn=execution_arn) + + describe_execution_response = sfn_client.describe_execution(executionArn=execution_arn) + sfn_snapshot.match( + "describe_execution_response_through_version_arn", describe_execution_response + ) + + list_executions_response = sfn_client.list_executions(stateMachineArn=state_machine_arn) + sfn_snapshot.match("list_executions_response", list_executions_response) + + @markers.aws.validated + def test_base_lifecycle_create_update_describe( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + + state_machine_version_arns: list[Arn] = list() + state_machine_version_arns.append(create_state_machine_response["stateMachineVersionArn"]) + for version_number in range(2): + definition["Comment"] = f"Definition for version {version_number}" + update_state_machine_response = sfn_client.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(definition), publish=True + ) + state_machine_version_arn = update_state_machine_response["stateMachineVersionArn"] + state_machine_version_arns.append(state_machine_version_arn) + + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + + update_state_machine_alias_response = sfn_client.update_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn, + description="new description", + ) + sfn_snapshot.match( + "update_state_machine_alias_description_response", update_state_machine_alias_response + ) + if is_aws_cloud(): + time.sleep(30) + describe_state_machine_alias_response = sfn_client.describe_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn + ) + sfn_snapshot.match( + "describe_state_machine_alias_update_description_response", + describe_state_machine_alias_response, + ) + + update_state_machine_alias_response = sfn_client.update_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[0], weight=50 + ), + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arns[1], weight=50 + ), + ], + ) + sfn_snapshot.match( + "update_state_machine_alias_routing_configuration_response", + update_state_machine_alias_response, + ) + if is_aws_cloud(): + time.sleep(30) + describe_state_machine_alias_response = sfn_client.describe_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn + ) + sfn_snapshot.match( + "describe_state_machine_alias_update_routing_configuration_response", + describe_state_machine_alias_response, + ) + + @markers.aws.validated + def test_delete_version_with_alias( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.delete_state_machine_version( + stateMachineVersionArn=state_machine_version_arn + ) + sfn_snapshot.match( + "exception_delete_version_with_alias_reference", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + definition["Comment"] = "Definition v1" + update_state_machine_response = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(definition), publish=True + ) + state_machine_version_arn_v1 = update_state_machine_response["stateMachineVersionArn"] + + sfn_client.update_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn_v1, weight=100 + ) + ], + ) + if is_aws_cloud(): + time.sleep(30) + describe_state_machine_alias_response = sfn_client.describe_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn + ) + sfn_snapshot.match( + "describe_state_machine_alias_response", describe_state_machine_alias_response + ) + + delete_version_response = sfn_client.delete_state_machine_version( + stateMachineVersionArn=state_machine_version_arn + ) + sfn_snapshot.match("delete_version_response", delete_version_response) + + @markers.aws.validated + def test_delete_revision_with_alias( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + delete_state_machine_response = sfn_client.delete_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("delete_state_machine_response", delete_state_machine_response) + + @markers.aws.validated + def test_delete_no_such_alias_arn( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + state_machine_alias_name = f"AliasName-{short_uid()}" + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + sfn_client.delete_state_machine_alias(stateMachineAliasArn=state_machine_alias_arn) + await_state_machine_alias_is_deleted( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + delete_state_machine_alias_response = sfn_client.delete_state_machine_alias( + stateMachineAliasArn=state_machine_alias_arn + ) + sfn_snapshot.match( + "delete_state_machine_alias_response", delete_state_machine_alias_response + ) + + @markers.aws.validated + def test_list_state_machine_aliases_pagination_invalid_next_token( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + sfn_snapshot, + aws_client, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + + state_machine_name = f"state_machine_{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + state_machine_alias_name = f"AliasName-{short_uid()}" + + sfn_snapshot.add_transformer( + RegexTransformer(state_machine_alias_name, "state_machine_alias_name") + ) + + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description="create state machine alias description", + name=state_machine_alias_name, + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + + sfn_snapshot.match( + "create_state_machine_alias_response", create_state_machine_alias_response + ) + + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + with pytest.raises(Exception) as exc: + sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn, nextToken="InvalidToken" + ) + + sfn_snapshot.match( + "invalidTokenException", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + @markers.aws.validated + @pytest.mark.parametrize("max_results", [0, 1]) + def test_list_state_machine_aliases_pagination_max_results( + self, + create_state_machine_iam_role, + create_state_machine, + create_state_machine_alias, + max_results, + sfn_snapshot, + aws_client, + ): + sfn_client = aws_client.stepfunctions + + sfn_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(sfn_role_arn, "sfn_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + + state_machine_name = f"state_machine_test-{short_uid()}" + create_state_machine_response = create_state_machine( + target_aws_client=aws_client, + name=state_machine_name, + definition=json.dumps(definition), + roleArn=sfn_role_arn, + publish=True, + ) + + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_create_arn(create_state_machine_response, 0) + ) + + state_machine_arn = create_state_machine_response["stateMachineArn"] + state_machine_version_arn = create_state_machine_response["stateMachineVersionArn"] + + for i in range(3): + create_state_machine_alias_response = create_state_machine_alias( + target_aws_client=aws_client, + description=f"Description {i + 1} - create state machine alias", + name=f"AliasName-{i + 1}", + routingConfiguration=[ + RoutingConfigurationListItem( + stateMachineVersionArn=state_machine_version_arn, weight=100 + ) + ], + ) + + sfn_snapshot.match( + f"create_state_machine_alias_response-{i + 1}", create_state_machine_alias_response + ) + + definition["Comment"] = f"Comment {i + 1}" + sfn_client.update_state_machine( + stateMachineArn=state_machine_arn, definition=json.dumps(definition) + ) + + state_machine_alias_arn = create_state_machine_alias_response["stateMachineAliasArn"] + await_state_machine_alias_is_created( + stepfunctions_client=sfn_client, + state_machine_arn=state_machine_arn, + state_machine_alias_arn=state_machine_alias_arn, + ) + + with pytest.raises(Exception) as err: + sfn_client.list_state_machine_aliases(stateMachineArn=state_machine_arn, maxResults=-1) + + sfn_snapshot.match("list_state_machine_aliases_max_results_-1_response", err.value) + + with pytest.raises(Exception) as err: + sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn, maxResults=1001 + ) + + sfn_snapshot.match( + "list_state_machine_aliases_max_results_1001_response", err.value.response + ) + + if max_results == 0: + list_state_machine_aliases_response = sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn, maxResults=0 + ) + + sfn_snapshot.match( + "list_state_machine_aliases_max_results_0_response", + list_state_machine_aliases_response, + ) + + else: + list_state_machine_aliases_response = sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn, maxResults=1 + ) + + sfn_snapshot.match( + "list_state_machine_aliases_max_results_1_response", + list_state_machine_aliases_response, + ) + + list_state_machine_aliases_response = sfn_client.list_state_machine_aliases( + stateMachineArn=state_machine_arn, + nextToken=list_state_machine_aliases_response.get("nextToken"), + ) + + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("nextToken")) + + sfn_snapshot.match( + "list_state_machine_aliases_next_token_response", + list_state_machine_aliases_response, + ) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.snapshot.json new file mode 100644 index 0000000000000..2e98c0a3f7842 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.snapshot.json @@ -0,0 +1,777 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_create_alias_single_router_config": { + "recorded-date": "09-04-2025, 20:23:57", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_with_state_machine_arn": { + "recorded-date": "09-04-2025, 20:24:12", + "recorded-content": { + "exception": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: Routing configuration must contain state machine version ARNs. Received: [arn::states::111111111111:stateMachine:]" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_not_idempotent": { + "recorded-date": "09-04-2025, 20:24:29", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "not_idempotent_description": { + "exception_typename": "ConflictException", + "exception_value": "An error occurred (ConflictException) when calling the CreateStateMachineAlias operation: Failed to create alias because an alias with the same name and a different routing configuration already exists." + }, + "not_idempotent_routing_configuration": { + "exception_typename": "ConflictException", + "exception_value": "An error occurred (ConflictException) when calling the CreateStateMachineAlias operation: Failed to create alias because an alias with the same name and a different routing configuration already exists." + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_idempotent_create_alias": { + "recorded-date": "09-04-2025, 20:24:44", + "recorded-content": { + "create_state_machine_alias_response_attempt_0": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response_attempt_1": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response_different_name": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name-second", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_response": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name-second" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_invalid_router_configs": { + "recorded-date": "09-04-2025, 20:25:01", + "recorded-content": { + "no_routing": { + "exception_typename": "ParamValidationError", + "exception_value": "Parameter validation failed:\nInvalid length for parameter routingConfiguration, value: 0, valid min length: 1" + }, + "too_many_routing": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: 1 validation error detected: Value '[RoutingConfigurationListItem(stateMachineVersionArn=arn::states::111111111111:stateMachine::1, weight=50), RoutingConfigurationListItem(stateMachineVersionArn=arn::states::111111111111:stateMachine::2, weight=30), RoutingConfigurationListItem(stateMachineVersionArn=arn::states::111111111111:stateMachine::3, weight=20)]' at 'routingConfiguration' failed to satisfy constraint: Member must have length less than or equal to 2" + }, + "duplicate_routing": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: Routing configuration must contain distinct state machine version ARNs. Received: [arn::states::111111111111:stateMachine::1, arn::states::111111111111:stateMachine::1]" + }, + "invalid_arn": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: Routing configuration must contain state machine version ARNs. Received: [arn::states::111111111111:stateMachine:, arn::states::111111111111:stateMachine::2]" + }, + "weight_too_large": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: 1 validation error detected: Value '101' at 'routingConfiguration.1.member.weight' failed to satisfy constraint: Member must have value less than or equal to 100" + }, + "weight_too_small": { + "exception_typename": "ParamValidationError", + "exception_value": "Parameter validation failed:\nInvalid value for parameter routingConfiguration[0].weight, value: -1, valid min value: 0" + }, + "sum_weights_less_than_100": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: Sum of routing configuration weights must equal 100. Received: [70, 29]" + }, + "sum_weights_more_than_100": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: Sum of routing configuration weights must equal 100. Received: [70, 31]" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_invalid_name": { + "recorded-date": "09-04-2025, 20:25:16", + "recorded-content": { + "exception_for_name123": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: 1 validation error detected: Value '123' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: ^(?=.*[a-zA-Z_\\-\\.])[a-zA-Z0-9_\\-\\.]+$" + }, + "exception_for_name": { + "exception_typename": "ParamValidationError", + "exception_value": "Parameter validation failed:\nInvalid length for parameter name, value: 0, valid min length: 1" + }, + "exception_for_nameAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: 1 validation error detected: Value 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' at 'name' failed to satisfy constraint: Member must have length less than or equal to 80" + }, + "exception_for_nameINVALID ALIAS": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: 1 validation error detected: Value 'INVALID ALIAS' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: ^(?=.*[a-zA-Z_\\-\\.])[a-zA-Z0-9_\\-\\.]+$" + }, + "exception_for_name!INVALID": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: 1 validation error detected: Value '!INVALID' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: ^(?=.*[a-zA-Z_\\-\\.])[a-zA-Z0-9_\\-\\.]+$" + }, + "exception_for_nameALIAS@": { + "exception_typename": "ValidationException", + "exception_value": "An error occurred (ValidationException) when calling the CreateStateMachineAlias operation: 1 validation error detected: Value 'ALIAS@' at 'name' failed to satisfy constraint: Member must satisfy regular expression pattern: ^(?=.*[a-zA-Z_\\-\\.])[a-zA-Z0-9_\\-\\.]+$" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_delete_list": { + "recorded-date": "09-04-2025, 20:25:46", + "recorded-content": { + "list_state_machine_aliases_response_empty": { + "stateMachineAliases": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response_num_0": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-0", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_response_after_creation_0": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-0" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response_num_1": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_response_after_creation_1": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-0" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response_num_2": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_response_after_creation_2": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-0" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-1" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_state_machine_alias_response_0": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_response_after_deletion_0": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-1" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_state_machine_alias_response_1": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_response_after_deletion_1": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_base_name-2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_state_machine_alias_response_2": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_response_after_deletion_2": { + "stateMachineAliases": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_invoke_describe_list": { + "recorded-date": "09-04-2025, 20:26:23", + "recorded-content": { + "list_state_machine_aliases_response_empty": { + "stateMachineAliases": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_response_through_alias": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_execution_response_through_alias": { + "executionArn": "arn::states::111111111111:execution::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "included": true + }, + "redriveCount": 0, + "redriveStatus": "NOT_REDRIVABLE", + "redriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "startDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_response_through_version_arn": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_execution_response_through_version_arn": { + "executionArn": "arn::states::111111111111:execution::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "included": true + }, + "redriveCount": 0, + "redriveStatus": "NOT_REDRIVABLE", + "redriveStatusReason": "Execution is SUCCEEDED and cannot be redriven", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_executions_response": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + }, + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "redriveCount": 0, + "startDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_update_describe": { + "recorded-date": "09-04-2025, 20:27:40", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_alias_description_response": { + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_state_machine_alias_update_description_response": { + "creationDate": "datetime", + "description": "new description", + "name": "state_machine_alias_name", + "routingConfiguration": [ + { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "weight": 100 + } + ], + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_state_machine_alias_routing_configuration_response": { + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_state_machine_alias_update_routing_configuration_response": { + "creationDate": "datetime", + "description": "new description", + "name": "state_machine_alias_name", + "routingConfiguration": [ + { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "weight": 50 + }, + { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::2", + "weight": 50 + } + ], + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_version_with_alias": { + "recorded-date": "09-04-2025, 20:28:26", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exception_delete_version_with_alias_reference": { + "exception_typename": "ConflictException", + "exception_value": "An error occurred (ConflictException) when calling the DeleteStateMachineVersion operation: Version to be deleted must not be referenced by an alias. Current list of aliases referencing this version: [state_machine_alias_name]" + }, + "describe_state_machine_alias_response": { + "creationDate": "datetime", + "description": "create state machine alias description", + "name": "state_machine_alias_name", + "routingConfiguration": [ + { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::2", + "weight": 100 + } + ], + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_version_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_revision_with_alias": { + "recorded-date": "09-04-2025, 20:28:41", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_state_machine_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_no_such_alias_arn": { + "recorded-date": "09-04-2025, 20:28:58", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_state_machine_alias_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_update_no_such_alias_arn": { + "recorded-date": "09-04-2025, 20:26:04", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_no_such_alias_arn": { + "exception_typename": "ResourceNotFound", + "exception_value": "An error occurred (ResourceNotFound) when calling the UpdateStateMachineAlias operation: Request references a resource that does not exist." + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_invalid_next_token": { + "recorded-date": "09-04-2025, 20:29:13", + "recorded-content": { + "create_state_machine_alias_response": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::state_machine_alias_name", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalidTokenException": { + "exception_typename": "InvalidToken", + "exception_value": "An error occurred (InvalidToken) when calling the ListStateMachineAliases operation: Invalid Token: 'Invalid token'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results_1_next_token": { + "recorded-date": "09-04-2025, 18:46:18", + "recorded-content": { + "create_state_machine_alias_response-1": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response-2": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response-3": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_max_results_1_response": { + "nextToken": "", + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_next_token_response": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-2" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-3" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results[0]": { + "recorded-date": "09-04-2025, 20:29:33", + "recorded-content": { + "create_state_machine_alias_response-1": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response-2": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response-3": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_max_results_-1_response": "Parameter validation failed:\nInvalid value for parameter maxResults, value: -1, valid min value: 0", + "list_state_machine_aliases_max_results_1001_response": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000" + }, + "message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_state_machine_aliases_max_results_0_response": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-1" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-2" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-3" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results[1]": { + "recorded-date": "09-04-2025, 20:29:54", + "recorded-content": { + "create_state_machine_alias_response-1": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response-2": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "create_state_machine_alias_response-3": { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_max_results_-1_response": "Parameter validation failed:\nInvalid value for parameter maxResults, value: -1, valid min value: 0", + "list_state_machine_aliases_max_results_1001_response": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000" + }, + "message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list_state_machine_aliases_max_results_1_response": { + "nextToken": "", + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_state_machine_aliases_next_token_response": { + "stateMachineAliases": [ + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-2" + }, + { + "creationDate": "datetime", + "stateMachineAliasArn": "arn::states::111111111111:stateMachine::AliasName-3" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.validation.json new file mode 100644 index 0000000000000..8768d7579d5d2 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.validation.json @@ -0,0 +1,59 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_create_alias_single_router_config": { + "last_validated_date": "2025-04-09T20:23:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_delete_list": { + "last_validated_date": "2025-04-09T20:25:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_invoke_describe_list": { + "last_validated_date": "2025-04-09T20:26:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_base_lifecycle_create_update_describe": { + "last_validated_date": "2025-04-09T20:27:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_no_such_alias_arn": { + "last_validated_date": "2025-04-09T20:28:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_revision_with_alias": { + "last_validated_date": "2025-04-09T20:28:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_delete_version_with_alias": { + "last_validated_date": "2025-04-09T20:28:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_invalid_name": { + "last_validated_date": "2025-04-09T20:25:16+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_invalid_router_configs": { + "last_validated_date": "2025-04-09T20:25:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_not_idempotent": { + "last_validated_date": "2025-04-09T20:24:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_error_create_alias_with_state_machine_arn": { + "last_validated_date": "2025-04-09T20:24:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_idempotent_create_alias": { + "last_validated_date": "2025-04-09T20:24:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination": { + "last_validated_date": "2025-04-07T16:51:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_invalid_next_token": { + "last_validated_date": "2025-04-09T20:29:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results[0]": { + "last_validated_date": "2025-04-09T20:29:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results[1]": { + "last_validated_date": "2025-04-09T20:29:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results_0": { + "last_validated_date": "2025-04-09T17:36:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_list_state_machine_aliases_pagination_max_results_1_next_token": { + "last_validated_date": "2025-04-09T18:46:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_aliasing.py::TestSfnApiAliasing::test_update_no_such_alias_arn": { + "last_validated_date": "2025-04-09T20:26:04+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_express.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_express.py new file mode 100644 index 0000000000000..c193ee4432cb7 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_express.py @@ -0,0 +1,197 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.stepfunctions import StateMachineType +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_express_async_execution, + create_and_record_express_sync_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.activities.activity_templates import ( + ActivityTemplate, +) +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate +from tests.aws.services.stepfunctions.templates.callbacks.callback_templates import ( + CallbackTemplates, +) +from tests.aws.services.stepfunctions.templates.services.services_templates import ServicesTemplates + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..tracingConfiguration", + "$..billingDetails", + "$..redrive_count", + "$..event_timestamp", + "$..Error.Message", + ] +) +class TestSfnApiExpress: + @markers.aws.validated + def test_create_describe_delete( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + creation_response = create_state_machine( + aws_client, + name=f"statemachine_{short_uid()}", + definition=definition_str, + roleArn=snf_role_arn, + type=StateMachineType.EXPRESS, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_response, 0)) + sfn_snapshot.match("creation_response", creation_response) + + state_machine_arn = creation_response["stateMachineArn"] + describe_response = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_response", describe_response) + + deletion_response = aws_client.stepfunctions.delete_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("deletion_response", deletion_response) + + @markers.aws.validated + def test_start_async_describe_history_execution( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + definition = ServicesTemplates.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + execution_input = json.dumps(dict()) + state_machine_arn, execution_arn = create_and_record_express_async_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + definition_str, + execution_input, + ) + + with pytest.raises(Exception) as ex: + aws_client_no_retry.stepfunctions.list_executions(stateMachineArn=state_machine_arn) + sfn_snapshot.match("list_executions_error", ex.value.response) + + with pytest.raises(Exception) as ex: + aws_client_no_retry.stepfunctions.describe_execution(executionArn=execution_arn) + sfn_snapshot.match("describe_execution_error", ex.value.response) + + with pytest.raises(Exception) as ex: + aws_client_no_retry.stepfunctions.stop_execution(executionArn=execution_arn) + sfn_snapshot.match("stop_execution_error", ex.value.response) + + with pytest.raises(Exception) as ex: + aws_client_no_retry.stepfunctions.get_execution_history(executionArn=execution_arn) + sfn_snapshot.match("get_execution_history_error", ex.value.response) + + @markers.aws.validated + def test_start_sync_execution( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client_no_sync_prefix, + ): + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition = json.dumps(template) + + exec_input = json.dumps({}) + create_and_record_express_sync_execution( + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + @pytest.mark.parametrize( + "template", + [ + CallbackTemplates.SNS_PUBLIC_WAIT_FOR_TASK_TOKEN, + CallbackTemplates.SFN_START_EXECUTION_SYNC, + ], + ids=["WAIT_FOR_TASK_TOKEN", "SYNC"], + ) + def test_illegal_callbacks( + self, + aws_client, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + template, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "sfn_role_arn")) + + template = CallbackTemplates.load_sfn_template(template) + definition = json.dumps(template) + + with pytest.raises(Exception) as ex: + create_state_machine( + aws_client_no_retry, + name=f"express_statemachine_{short_uid()}", + definition=definition, + roleArn=snf_role_arn, + type=StateMachineType.EXPRESS, + ) + sfn_snapshot.match("creation_error", ex.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error.Message", "$..message"]) + def test_illegal_activity_task( + self, + aws_client, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + create_activity, + sfn_activity_consumer, + sfn_snapshot, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "sfn_role_arn")) + + activity_name = f"activity-{short_uid()}" + create_activity_output = create_activity(name=activity_name) + activity_arn = create_activity_output["activityArn"] + sfn_snapshot.add_transformer(RegexTransformer(activity_arn, "activity_arn")) + sfn_snapshot.add_transformer(RegexTransformer(activity_name, "activity_name")) + sfn_snapshot.match("create_activity_output", create_activity_output) + + template = ActivityTemplate.load_sfn_template(ActivityTemplate.BASE_ACTIVITY_TASK) + template["States"]["ActivityTask"]["Resource"] = activity_arn + definition = json.dumps(template) + + with pytest.raises(Exception) as ex: + create_state_machine( + aws_client_no_retry, + name=f"express_statemachine_{short_uid()}", + definition=definition, + roleArn=snf_role_arn, + type=StateMachineType.EXPRESS, + ) + sfn_snapshot.match("creation_error", ex.value.response) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_express.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_express.snapshot.json new file mode 100644 index 0000000000000..c5d889def1c70 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_express.snapshot.json @@ -0,0 +1,221 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_create_describe_delete": { + "recorded-date": "26-06-2024, 19:06:21", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "EXPRESS", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "deletion_response": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_start_async_describe_history_execution": { + "recorded-date": "03-07-2024, 17:14:50", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "end_event": { + "details": { + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "truncated": false + } + }, + "event_timestamp": "timestamp", + "execution_arn": "arn::states::111111111111:express:::", + "id": "4", + "previous_event_id": "3", + "redrive_count": "0", + "type": "ExecutionSucceeded" + }, + "list_executions_error": { + "Error": { + "Code": "StateMachineTypeNotSupported", + "Message": "This operation is not supported by this type of state machine" + }, + "message": "This operation is not supported by this type of state machine", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "describe_execution_error": { + "Error": { + "Code": "InvalidArn", + "Message": "Invalid Arn: 'Resource type not valid in this context: express'" + }, + "message": "Invalid Arn: 'Resource type not valid in this context: express'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "stop_execution_error": { + "Error": { + "Code": "InvalidArn", + "Message": "Invalid Arn: 'Resource type not valid in this context: express'" + }, + "message": "Invalid Arn: 'Resource type not valid in this context: express'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "get_execution_history_error": { + "Error": { + "Code": "InvalidArn", + "Message": "Invalid Arn: 'Resource type not valid in this context: express'" + }, + "message": "Invalid Arn: 'Resource type not valid in this context: express'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_start_sync_execution": { + "recorded-date": "26-06-2024, 19:06:54", + "recorded-content": { + "creation_response": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "start_execution_sync_response": { + "billingDetails": { + "billedDurationInMilliseconds": 100, + "billedMemoryUsedInMB": 64 + }, + "executionArn": "arn::states::111111111111:express:::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "output": { + "Arg1": "argument1" + }, + "outputDetails": { + "included": true + }, + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_callbacks[WAIT_FOR_TASK_TOKEN]": { + "recorded-date": "03-07-2024, 19:43:10", + "recorded-content": { + "creation_error": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Express state machine does not support '.waitForTaskToken' service integration at /States/Publish/Resource'" + }, + "message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Express state machine does not support '.waitForTaskToken' service integration at /States/Publish/Resource'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_callbacks[SYNC]": { + "recorded-date": "03-07-2024, 19:43:23", + "recorded-content": { + "creation_error": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Express state machine does not support '.sync' service integration at /States/StartExecution/Resource'" + }, + "message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Express state machine does not support '.sync' service integration at /States/StartExecution/Resource'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_activity_task": { + "recorded-date": "03-07-2024, 19:27:25", + "recorded-content": { + "create_activity_output": { + "activityArn": "activity_arn", + "creationDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "creation_error": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Express state machine does not support Activity ARN at /States/ActivityTask/Resource'" + }, + "message": "Invalid State Machine Definition: 'SCHEMA_VALIDATION_FAILED: Express state machine does not support Activity ARN at /States/ActivityTask/Resource'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_express.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_express.validation.json new file mode 100644 index 0000000000000..fd7fd04acb9e2 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_express.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_create_describe_delete": { + "last_validated_date": "2024-06-26T19:06:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_activity_task": { + "last_validated_date": "2024-07-03T19:27:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_callbacks[SYNC]": { + "last_validated_date": "2024-07-03T19:43:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_illegal_callbacks[WAIT_FOR_TASK_TOKEN]": { + "last_validated_date": "2024-07-03T19:43:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_start_async_describe_history_execution": { + "last_validated_date": "2024-07-03T17:14:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_express.py::TestSfnApiExpress::test_start_sync_execution": { + "last_validated_date": "2024-06-26T19:06:54+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py new file mode 100644 index 0000000000000..75d6af6532d87 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py @@ -0,0 +1,353 @@ +import itertools +import json + +import pytest +from botocore.config import Config +from botocore.exceptions import ClientError +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.stepfunctions import ( + CloudWatchLogsLogGroup, + LogDestination, + LoggingConfiguration, + LogLevel, +) +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import create_state_machine_with_iam_role +from localstack.utils.strings import short_uid +from localstack.utils.sync import poll_condition +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate + +_TEST_LOGGING_CONFIGURATIONS = list( + itertools.product( + # log level: + [LogLevel.ALL, LogLevel.FATAL, LogLevel.ERROR, LogLevel.OFF], + # include execution data + [False, True], + ) +) +_TEST_INVALID_LOGGING_CONFIGURATIONS = [ + LoggingConfiguration(level=LogLevel.ALL), + LoggingConfiguration(level=LogLevel.FATAL), + LoggingConfiguration(level=LogLevel.ERROR), +] +_TEST_INCOMPLETE_LOGGING_CONFIGURATIONS = [ + LoggingConfiguration(), + LoggingConfiguration(destinations=list()), +] + + +@markers.snapshot.skip_snapshot_verify(paths=["$..tracingConfiguration"]) +class TestSnfApiLogs: + @markers.aws.validated + @pytest.mark.parametrize("logging_level,include_execution_data", _TEST_LOGGING_CONFIGURATIONS) + def test_logging_configuration( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + logging_level, + include_execution_data, + ): + log_group_name = sfn_create_log_group() + log_group_arn = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration = LoggingConfiguration( + level=logging_level, + includeExecutionData=include_execution_data, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + loggingConfiguration=logging_configuration, + ) + state_machine_arn = creation_resp["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + + describe_resp = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) + + @markers.aws.validated + @pytest.mark.parametrize("logging_configuration", _TEST_INCOMPLETE_LOGGING_CONFIGURATIONS) + def test_incomplete_logging_configuration( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + logging_configuration, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + loggingConfiguration=logging_configuration, + ) + state_machine_arn = creation_resp["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + + describe_resp = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) + + @markers.aws.validated + @pytest.mark.parametrize("logging_configuration", _TEST_INVALID_LOGGING_CONFIGURATIONS) + def test_invalid_logging_configuration( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + aws_client_no_retry, + logging_configuration, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition = json.dumps(template) + + sm_name = f"statemachine_{short_uid()}" + + with pytest.raises(ClientError) as exc: + aws_client_no_retry.stepfunctions.create_state_machine( + name=sm_name, + definition=definition, + roleArn=snf_role_arn, + loggingConfiguration=logging_configuration, + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_deleted_log_group( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + logs_client = aws_client.logs + log_group_name = sfn_create_log_group() + log_group_arn = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration = LoggingConfiguration( + level=LogLevel.ALL, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + logs_client.delete_log_group(logGroupName=log_group_name) + + def _log_group_is_deleted() -> bool: + return not logs_client.describe_log_groups(logGroupNamePrefix=log_group_name).get( + "logGroups", None + ) + + assert poll_condition(condition=_log_group_is_deleted) + + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition = json.dumps(template) + + with pytest.raises(ClientError) as exc: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + logging_configuration, + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_multiple_destinations( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + logging_configuration = LoggingConfiguration(level=LogLevel.ALL, destinations=[]) + for i in range(2): + log_group_name = sfn_create_log_group() + log_group_arn = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + logging_configuration["destinations"].append( + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ) + ) + + template = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition = json.dumps(template) + + with pytest.raises(ClientError) as exc: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + logging_configuration, + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_update_logging_configuration( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_create_log_group, + sfn_snapshot, + aws_client, + aws_client_factory, + aws_client_no_retry, + ): + stepfunctions_client = aws_client_factory( + config=Config(parameter_validation=False) + ).stepfunctions + + log_group_name = sfn_create_log_group() + log_group_arn = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name)[ + "logGroups" + ][0]["arn"] + base_logging_configuration = LoggingConfiguration( + level=LogLevel.ALL, + includeExecutionData=True, + destinations=[ + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn) + ), + ], + ) + + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + loggingConfiguration=base_logging_configuration, + ) + state_machine_arn = creation_resp["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + sfn_snapshot.match("creation_resp", creation_resp) + + describe_resp = stepfunctions_client.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) + + # Update LogLevel Value. + base_logging_configuration["level"] = LogLevel.FATAL + stepfunctions_client.update_state_machine( + stateMachineArn=state_machine_arn, loggingConfiguration=base_logging_configuration + ) + describe_resp_log_level = stepfunctions_client.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_log_level", describe_resp_log_level) + + # Empty update + stepfunctions_client.update_state_machine( + stateMachineArn=state_machine_arn, loggingConfiguration=base_logging_configuration + ) + describe_resp_no_change = stepfunctions_client.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_no_change", describe_resp_no_change) + + # Update inclusion flag. + base_logging_configuration["includeExecutionData"] = False + stepfunctions_client.update_state_machine( + stateMachineArn=state_machine_arn, loggingConfiguration=base_logging_configuration + ) + describe_resp_flag = stepfunctions_client.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp_flag", describe_resp_flag) + + # Add logging endpoints. + log_group_name_2 = sfn_create_log_group() + log_group_arn_2 = aws_client.logs.describe_log_groups(logGroupNamePrefix=log_group_name_2)[ + "logGroups" + ][0]["arn"] + base_logging_configuration["destinations"].append( + LogDestination( + cloudWatchLogsLogGroup=CloudWatchLogsLogGroup(logGroupArn=log_group_arn_2) + ) + ) + with pytest.raises(ClientError) as exc: + aws_client_no_retry.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, loggingConfiguration=base_logging_configuration + ) + sfn_snapshot.match( + "exception_multiple_endpoints", + {"exception_typename": exc.typename, "exception_value": exc.value}, + ) + + # Set invalid configuration. + with pytest.raises(ClientError) as exc: + stepfunctions_client.update_state_machine( + stateMachineArn=state_machine_arn, + loggingConfiguration=LoggingConfiguration(level=LogLevel.ALL), + ) + sfn_snapshot.match( + "exception_invalid", {"exception_typename": exc.typename, "exception_value": exc.value} + ) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.snapshot.json new file mode 100644 index 0000000000000..3e8ccd3dd65f0 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.snapshot.json @@ -0,0 +1,734 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ALL-False]": { + "recorded-date": "01-06-2024, 10:17:53", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": false, + "level": "ALL" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ALL-True]": { + "recorded-date": "01-06-2024, 10:18:07", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": true, + "level": "ALL" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[FATAL-False]": { + "recorded-date": "01-06-2024, 10:18:21", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": false, + "level": "FATAL" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[FATAL-True]": { + "recorded-date": "01-06-2024, 10:18:35", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": true, + "level": "FATAL" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ERROR-False]": { + "recorded-date": "01-06-2024, 10:18:50", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": false, + "level": "ERROR" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ERROR-True]": { + "recorded-date": "01-06-2024, 10:19:06", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": true, + "level": "ERROR" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[OFF-False]": { + "recorded-date": "01-06-2024, 10:19:20", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[OFF-True]": { + "recorded-date": "01-06-2024, 10:19:34", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": true, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_incomplete_logging_configuration[logging_configuration0]": { + "recorded-date": "01-06-2024, 10:19:47", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_incomplete_logging_configuration[logging_configuration1]": { + "recorded-date": "01-06-2024, 10:20:01", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [], + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration0]": { + "recorded-date": "01-06-2024, 10:20:15", + "recorded-content": { + "exception": { + "exception_typename": "InvalidLoggingConfiguration", + "exception_value": "An error occurred (InvalidLoggingConfiguration) when calling the CreateStateMachine operation: Invalid Logging Configuration: Must specify exactly one Log Destination." + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration1]": { + "recorded-date": "01-06-2024, 10:20:28", + "recorded-content": { + "exception": { + "exception_typename": "InvalidLoggingConfiguration", + "exception_value": "An error occurred (InvalidLoggingConfiguration) when calling the CreateStateMachine operation: Invalid Logging Configuration: Must specify exactly one Log Destination." + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration2]": { + "recorded-date": "01-06-2024, 10:20:37", + "recorded-content": { + "exception": { + "exception_typename": "InvalidLoggingConfiguration", + "exception_value": "An error occurred (InvalidLoggingConfiguration) when calling the CreateStateMachine operation: Invalid Logging Configuration: Must specify exactly one Log Destination." + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_deleted_log_group": { + "recorded-date": "01-06-2024, 10:21:03", + "recorded-content": { + "exception": { + "exception_typename": "InvalidLoggingConfiguration", + "exception_value": "An error occurred (InvalidLoggingConfiguration) when calling the CreateStateMachine operation: Invalid Logging Configuration: Log Destination not found." + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_multiple_destinations": { + "recorded-date": "01-06-2024, 10:21:19", + "recorded-content": { + "exception": { + "exception_typename": "InvalidLoggingConfiguration", + "exception_value": "An error occurred (InvalidLoggingConfiguration) when calling the CreateStateMachine operation: Invalid Logging Configuration: Must specify exactly one Log Destination." + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_update_logging_configuration": { + "recorded-date": "03-06-2024, 08:40:57", + "recorded-content": { + "creation_resp": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": true, + "level": "ALL" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_log_level": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": true, + "level": "FATAL" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_no_change": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": true, + "level": "FATAL" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_flag": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "destinations": [ + { + "cloudWatchLogsLogGroup": { + "logGroupArn": "arn::logs::111111111111:log-group:log_group_name:*" + } + } + ], + "includeExecutionData": false, + "level": "FATAL" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exception_multiple_endpoints": { + "exception_typename": "InvalidLoggingConfiguration", + "exception_value": "An error occurred (InvalidLoggingConfiguration) when calling the UpdateStateMachine operation: Invalid Logging Configuration: Must specify exactly one Log Destination." + }, + "exception_invalid": { + "exception_typename": "InvalidLoggingConfiguration", + "exception_value": "An error occurred (InvalidLoggingConfiguration) when calling the UpdateStateMachine operation: Invalid Logging Configuration: Must specify exactly one Log Destination." + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.validation.json new file mode 100644 index 0000000000000..598c1070f2d0a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_logs.validation.json @@ -0,0 +1,50 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_deleted_log_group": { + "last_validated_date": "2024-06-01T10:21:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_incomplete_logging_configuration[logging_configuration0]": { + "last_validated_date": "2024-06-01T10:19:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_incomplete_logging_configuration[logging_configuration1]": { + "last_validated_date": "2024-06-01T10:20:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration0]": { + "last_validated_date": "2024-06-01T10:20:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration1]": { + "last_validated_date": "2024-06-01T10:20:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_invalid_logging_configuration[logging_configuration2]": { + "last_validated_date": "2024-06-01T10:20:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ALL-False]": { + "last_validated_date": "2024-06-01T10:17:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ALL-True]": { + "last_validated_date": "2024-06-01T10:18:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ERROR-False]": { + "last_validated_date": "2024-06-01T10:18:49+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[ERROR-True]": { + "last_validated_date": "2024-06-01T10:19:05+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[FATAL-False]": { + "last_validated_date": "2024-06-01T10:18:21+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[FATAL-True]": { + "last_validated_date": "2024-06-01T10:18:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[OFF-False]": { + "last_validated_date": "2024-06-01T10:19:20+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_logging_configuration[OFF-True]": { + "last_validated_date": "2024-06-01T10:19:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_multiple_destinations": { + "last_validated_date": "2024-06-01T10:21:18+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_logs.py::TestSnfApiLogs::test_update_logging_configuration": { + "last_validated_date": "2024-06-03T08:40:56+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py new file mode 100644 index 0000000000000..c68a5c5d9828d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py @@ -0,0 +1,141 @@ +import json +from itertools import chain + +import pytest +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_terminated, + create_state_machine_with_iam_role, +) +from tests.aws.services.stepfunctions.templates.scenarios.scenarios_templates import ( + ScenariosTemplate as ST, +) + + +class TestSnfApiMapRun: + @markers.aws.validated + def test_list_map_runs_and_describe_map_run( + self, + aws_client, + s3_create_bucket, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..stopDate", replacement="stop-date", replace_reference=False + ) + ) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..startDate", replacement="start-date", replace_reference=False + ) + ) + + bucket_name = s3_create_bucket() + sfn_snapshot.add_transformer(RegexTransformer(bucket_name, "bucket_name")) + + key = "file.csv" + csv_file = "Col1,Col2,Col3\nValue1,Value2,Value3\nValue4,Value5,Value6" + aws_client.s3.put_object(Bucket=bucket_name, Key=key, Body=csv_file) + + template = ST.load_sfn_template(ST.MAP_ITEM_READER_BASE_CSV_HEADERS_FIRST_LINE) + definition = json.dumps(template) + + exec_input = json.dumps({"Bucket": bucket_name, "Key": key}) + + state_machine_arn = create_state_machine_with_iam_role( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, input=exec_input + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(exec_resp, 0)) + execution_arn = exec_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + list_map_runs = aws_client.stepfunctions.list_map_runs(executionArn=execution_arn) + + for i, map_run in enumerate(list_map_runs["mapRuns"]): + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_map_run_arn(map_run["mapRunArn"], 0) + ) + + sfn_snapshot.match("list_map_runs", list_map_runs) + + map_run_arn = list_map_runs["mapRuns"][0]["mapRunArn"] + describe_map_run = aws_client.stepfunctions.describe_map_run(mapRunArn=map_run_arn) + sfn_snapshot.match("describe_map_run", describe_map_run) + + @markers.aws.validated + @pytest.mark.parametrize( + "invalid_char", + list(' ?*<>{}[]:;,\\|^~$#%&`"') + + [chr(i) for i in chain(range(0x00, 0x20), range(0x7F, 0xA0))], + ) + def test_map_state_label_invalid_char_fail( + self, + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + invalid_char, + ): + template = ST.load_sfn_template(ST.MAP_STATE_LABEL_INVALID_CHAR_FAIL) + template["States"]["MapState"]["Label"] = f"label_{invalid_char}" + definition = json.dumps(template) + + with pytest.raises(Exception) as err: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + sfn_snapshot.match("map_state_label_invalid_char_fail", err.value.response) + + @markers.aws.validated + def test_map_state_label_empty_fail( + self, aws_client_no_retry, create_state_machine_iam_role, create_state_machine, sfn_snapshot + ): + template = ST.load_sfn_template(ST.MAP_STATE_LABEL_EMPTY_FAIL) + definition = json.dumps(template) + + with pytest.raises(Exception) as err: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + sfn_snapshot.match("map_state_label_empty_fail", err.value.response) + + @markers.aws.validated + def test_map_state_label_too_long_fail( + self, aws_client_no_retry, create_state_machine_iam_role, create_state_machine, sfn_snapshot + ): + template = ST.load_sfn_template(ST.MAP_STATE_LABEL_TOO_LONG_FAIL) + definition = json.dumps(template) + + with pytest.raises(Exception) as err: + create_state_machine_with_iam_role( + aws_client_no_retry, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + ) + sfn_snapshot.match("map_state_label_too_long_fail", err.value.response) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.snapshot.json new file mode 100644 index 0000000000000..99b38a3fc16ba --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.snapshot.json @@ -0,0 +1,1480 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_list_map_runs_and_describe_map_run": { + "recorded-date": "21-09-2023, 21:21:35", + "recorded-content": { + "list_map_runs": { + "mapRuns": [ + { + "executionArn": "arn::states::111111111111:execution::", + "mapRunArn": "arn::states::111111111111:mapRun:/:", + "startDate": "start-date", + "stateMachineArn": "arn::states::111111111111:stateMachine:/", + "stopDate": "stop-date" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_map_run": { + "executionArn": "arn::states::111111111111:execution::", + "executionCounts": { + "aborted": 0, + "failed": 0, + "pending": 0, + "resultsWritten": 2, + "running": 0, + "succeeded": 2, + "timedOut": 0, + "total": 2 + }, + "itemCounts": { + "aborted": 0, + "failed": 0, + "pending": 0, + "resultsWritten": 2, + "running": 0, + "succeeded": 2, + "timedOut": 0, + "total": 2 + }, + "mapRunArn": "arn::states::111111111111:mapRun:/:", + "maxConcurrency": 1, + "startDate": "start-date", + "status": "SUCCEEDED", + "stopDate": "stop-date", + "toleratedFailureCount": 0, + "toleratedFailurePercentage": 0.0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[ ]": { + "recorded-date": "20-07-2024, 15:10:57", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: ' '\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_ \", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: ' '\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_ \", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[?]": { + "recorded-date": "20-07-2024, 15:10:57", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '?'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_?\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '?'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_?\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[*]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '*'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_*\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '*'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_*\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[<]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '<'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_<\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '<'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_<\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[>]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '>'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_>\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '>'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_>\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[{]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '{'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_{\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '{'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_{\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[}]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '}'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_}\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '}'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_}\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[[]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '['\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_[\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '['\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_[\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[]]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: ']'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_]\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: ']'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_]\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[:]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: ':'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_:\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: ':'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_:\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[;]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: ';'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_;\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: ';'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_;\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[,]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: ','\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_,\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: ','\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_,\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\\\]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\\\\\'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\\\\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\\\\\'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\\\\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[|]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '|'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_|\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '|'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_|\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[^]": { + "recorded-date": "20-07-2024, 15:10:58", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '^'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_^\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '^'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_^\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[~]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '~'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_~\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '~'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_~\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[$]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '$'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_$\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '$'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_$\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[#]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '#'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_#\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '#'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_#\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[%]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '%'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_%\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '%'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_%\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[&]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '&'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_&\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '&'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_&\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[`]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '`'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_`\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '`'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_`\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\"]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=['Label contains invalid character: \\'\"\\''] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\\"\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=['Label contains invalid character: \\'\"\\''] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\\"\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x00]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x00'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0000\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x00'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0000\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x01]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x01'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0001\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x01'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0001\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x02]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x02'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0002\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x02'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0002\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x03]": { + "recorded-date": "20-07-2024, 15:10:59", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x03'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0003\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x03'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0003\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x04]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x04'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0004\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x04'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0004\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x05]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x05'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0005\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x05'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0005\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x06]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x06'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0006\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x06'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0006\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x07]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x07'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0007\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x07'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0007\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x08]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x08'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\b\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x08'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\b\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\t]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\t'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\t\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\t'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\t\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\n]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\n'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\n\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\n'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\n\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0b]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u000b\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u000b\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0c]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\f\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\f\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\r]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\r'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\r\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\r'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\r\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0e]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u000e\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u000e\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x0f]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u000f\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x0f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u000f\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x10]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x10'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0010\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x10'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0010\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x11]": { + "recorded-date": "20-07-2024, 15:11:00", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x11'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0011\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x11'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0011\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x12]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x12'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0012\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x12'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0012\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x13]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x13'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0013\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x13'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0013\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x14]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x14'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0014\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x14'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0014\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x15]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x15'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0015\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x15'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0015\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x16]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x16'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0016\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x16'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0016\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x17]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x17'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0017\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x17'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0017\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x18]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x18'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0018\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x18'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0018\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x19]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x19'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0019\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x19'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0019\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1a]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1a'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001a\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1a'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001a\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1b]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001b\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001b\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1c]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001c\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001c\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1d]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1d'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001d\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1d'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001d\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1e]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001e\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001e\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x1f]": { + "recorded-date": "20-07-2024, 15:11:01", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001f\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x1f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u001f\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x7f]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x7f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u007f\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x7f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u007f\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x80]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x80'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0080\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x80'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0080\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x81]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x81'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0081\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x81'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0081\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x82]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x82'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0082\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x82'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0082\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x83]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x83'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0083\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x83'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0083\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x84]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x84'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0084\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x84'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0084\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x85]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x85'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0085\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x85'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0085\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x86]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x86'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0086\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x86'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0086\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x87]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x87'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0087\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x87'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0087\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x88]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x88'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0088\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x88'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0088\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x89]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x89'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0089\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x89'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0089\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8a]": { + "recorded-date": "20-07-2024, 15:11:02", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8a'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008a\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8a'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008a\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8b]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008b\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008b\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8c]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008c\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008c\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8d]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8d'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008d\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8d'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008d\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8e]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008e\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008e\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x8f]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008f\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x8f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u008f\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x90]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x90'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0090\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x90'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0090\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x91]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x91'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0091\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x91'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0091\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x92]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x92'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0092\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x92'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0092\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x93]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x93'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0093\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x93'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0093\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x94]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x94'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0094\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x94'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0094\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x95]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x95'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0095\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x95'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0095\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x96]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x96'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0096\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x96'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0096\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x97]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x97'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0097\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x97'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0097\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x98]": { + "recorded-date": "20-07-2024, 15:11:03", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x98'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0098\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x98'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0098\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x99]": { + "recorded-date": "20-07-2024, 15:11:04", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x99'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0099\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x99'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u0099\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9a]": { + "recorded-date": "20-07-2024, 15:11:04", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9a'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009a\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9a'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009a\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9b]": { + "recorded-date": "20-07-2024, 15:11:04", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009b\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9b'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009b\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9c]": { + "recorded-date": "20-07-2024, 15:11:04", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009c\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9c'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009c\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9d]": { + "recorded-date": "20-07-2024, 15:11:04", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9d'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009d\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9d'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009d\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9e]": { + "recorded-date": "20-07-2024, 15:11:04", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009e\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9e'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009e\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_invalid_char_fail[\\x9f]": { + "recorded-date": "20-07-2024, 15:11:04", + "recorded-content": { + "map_state_label_invalid_char_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009f\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=[\"Label contains invalid character: '\\\\x9f'\"] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"label_\\u009f\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_empty_fail": { + "recorded-date": "20-07-2024, 15:14:54", + "recorded-content": { + "map_state_label_empty_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=['Label cannot be empty'] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=['Label cannot be empty'] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_map_state_label_too_long_fail": { + "recorded-date": "20-07-2024, 15:15:52", + "recorded-content": { + "map_state_label_too_long_fail": { + "Error": { + "Code": "InvalidDefinition", + "Message": "Error=ValueError Args=['Label cannot exceed 40 characters'] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"12345678901234567890123456789012345678901\", \"End\": true}}}'." + }, + "message": "Error=ValueError Args=['Label cannot exceed 40 characters'] in definition '{\"Comment\": \"MAP_STATE_LABEL_JSON\", \"StartAt\": \"MapState\", \"States\": {\"MapState\": {\"Type\": \"Map\", \"ItemProcessor\": {\"ProcessorConfig\": {\"Mode\": \"DISTRIBUTED\", \"ExecutionType\": \"STANDARD\"}, \"StartAt\": \"IteratorInner\", \"States\": {\"IteratorInner\": {\"Type\": \"Pass\", \"End\": true}}}, \"Label\": \"12345678901234567890123456789012345678901\", \"End\": true}}}'.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.validation.json new file mode 100644 index 0000000000000..077467d6d32e4 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_map_run.py::TestSnfApiMapRun::test_list_map_runs_and_describe_map_run": { + "last_validated_date": "2023-09-21T19:21:35+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py new file mode 100644 index 0000000000000..a08fc22a8691a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py @@ -0,0 +1,202 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.stepfunctions import Tag +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate + + +class TestSnfApiTagging: + @markers.aws.validated + @pytest.mark.parametrize( + "tag_list", + [ + [], + [Tag(key="key1", value="value1")], + [Tag(key="key1", value="")], + [Tag(key="key1", value="value1"), Tag(key="key1", value="value1")], + [Tag(key="key1", value="value1"), Tag(key="key2", value="value2")], + ], + ) + def test_tag_state_machine( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + tag_list, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn = creation_resp_1["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + tag_resource_resp = aws_client.stepfunctions.tag_resource( + resourceArn=state_machine_arn, tags=tag_list + ) + sfn_snapshot.match("tag_resource_resp", tag_resource_resp) + + list_resources_res = aws_client.stepfunctions.list_tags_for_resource( + resourceArn=state_machine_arn + ) + sfn_snapshot.match("list_resources_res", list_resources_res) + + @markers.aws.validated + @pytest.mark.parametrize( + "tag_list", + [ + None, + [Tag(key="", value="value")], + [Tag(key=None, value="value")], + [Tag(key="key1", value=None)], + ], + ) + def test_tag_invalid_state_machine( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + tag_list, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn = creation_resp_1["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + with pytest.raises(Exception) as error: + aws_client_no_retry.stepfunctions.tag_resource( + resourceArn=state_machine_arn, tags=tag_list + ) + sfn_snapshot.match("error", error.value) + + @markers.aws.validated + def test_tag_state_machine_version( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn = creation_resp_1["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + publish_resp = aws_client.stepfunctions.publish_state_machine_version( + stateMachineArn=state_machine_arn + ) + state_machine_version_arn = publish_resp["stateMachineVersionArn"] + sfn_snapshot.match("publish_resp", publish_resp) + + with pytest.raises(Exception) as error: + aws_client_no_retry.stepfunctions.tag_resource( + resourceArn=state_machine_version_arn, tags=[Tag(key="key1", value="value1")] + ) + sfn_snapshot.match("error", error.value) + + @markers.aws.validated + @pytest.mark.parametrize( + "tag_keys", + [ + [], + ["key1"], + ["key1", "key1"], + ["key1", "key2"], + ], + ) + def test_untag_state_machine( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + tag_keys, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn = creation_resp_1["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + tag_resource_resp = aws_client.stepfunctions.tag_resource( + resourceArn=state_machine_arn, tags=[Tag(key="key1", value="value1")] + ) + sfn_snapshot.match("tag_resource_resp", tag_resource_resp) + + untag_resource_resp = aws_client.stepfunctions.untag_resource( + resourceArn=state_machine_arn, tagKeys=tag_keys + ) + sfn_snapshot.match("untag_resource_resp", untag_resource_resp) + + list_resources_res = aws_client.stepfunctions.list_tags_for_resource( + resourceArn=state_machine_arn + ) + sfn_snapshot.match("list_resources_res", list_resources_res) + + @markers.aws.validated + def test_create_state_machine( + self, create_state_machine_iam_role, create_state_machine, sfn_snapshot, aws_client + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + tags=[Tag(key="key1", value="value1"), Tag(key="key2", value="value2")], + ) + state_machine_arn = creation_resp_1["stateMachineArn"] + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + list_resources_res = aws_client.stepfunctions.list_tags_for_resource( + resourceArn=state_machine_arn + ) + sfn_snapshot.match("list_resources_res", list_resources_res) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.snapshot.json new file mode 100644 index 0000000000000..c9d5d011f6c43 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.snapshot.json @@ -0,0 +1,543 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_keys0]": { + "recorded-date": "25-08-2023, 19:28:20", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [ + { + "key": "key1", + "value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_keys1]": { + "recorded-date": "25-08-2023, 19:28:35", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_keys2]": { + "recorded-date": "25-08-2023, 19:28:50", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_keys3]": { + "recorded-date": "25-08-2023, 19:29:05", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list0]": { + "recorded-date": "25-08-2023, 20:18:59", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list2]": { + "recorded-date": "25-08-2023, 20:19:28", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [ + { + "key": "key1", + "value": "" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list3]": { + "recorded-date": "25-08-2023, 20:19:41", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [ + { + "key": "key1", + "value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list0]": { + "recorded-date": "25-08-2023, 19:37:31", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "resource_not_found": "Parameter validation failed:\nInvalid length for parameter tags[0].key, value: 0, valid min length: 1" + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list1]": { + "recorded-date": "25-08-2023, 20:20:27", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": "Parameter validation failed:\nInvalid length for parameter tags[0].key, value: 0, valid min length: 1" + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list3]": { + "recorded-date": "25-08-2023, 20:20:55", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": "Parameter validation failed:\nInvalid type for parameter tags[0].value, value: None, type: , valid types: " + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list1]": { + "recorded-date": "25-08-2023, 20:19:13", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [ + { + "key": "key1", + "value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list4]": { + "recorded-date": "25-08-2023, 20:19:55", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [ + { + "key": "key1", + "value": "value1" + }, + { + "key": "key2", + "value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list2]": { + "recorded-date": "25-08-2023, 20:20:41", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": "Parameter validation failed:\nInvalid type for parameter tags[0].key, value: None, type: , valid types: " + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine_version": { + "recorded-date": "25-08-2023, 20:21:09", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_resp": { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": "An error occurred (ResourceNotFound) when calling the TagResource operation: Resource not found: 'arn::states::111111111111:stateMachine::1'" + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys0]": { + "recorded-date": "25-08-2023, 20:21:22", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [ + { + "key": "key1", + "value": "value1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys1]": { + "recorded-date": "25-08-2023, 20:21:37", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys2]": { + "recorded-date": "25-08-2023, 20:21:51", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys3]": { + "recorded-date": "25-08-2023, 20:22:05", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "tag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "untag_resource_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[None]": { + "recorded-date": "25-08-2023, 20:20:14", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "error": "Parameter validation failed:\nInvalid type for parameter tags, value: None, type: , valid types: , " + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_create_state_machine": { + "recorded-date": "25-08-2023, 20:35:47", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_resources_res": { + "tags": [ + { + "key": "key1", + "value": "value1" + }, + { + "key": "key2", + "value": "value2" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.validation.json new file mode 100644 index 0000000000000..ce49257c15494 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.validation.json @@ -0,0 +1,47 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_create_state_machine": { + "last_validated_date": "2023-08-25T18:35:47+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[None]": { + "last_validated_date": "2023-08-25T18:20:14+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list1]": { + "last_validated_date": "2023-08-25T18:20:27+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list2]": { + "last_validated_date": "2023-08-25T18:20:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_invalid_state_machine[tag_list3]": { + "last_validated_date": "2023-08-25T18:20:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list0]": { + "last_validated_date": "2023-08-25T18:18:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list1]": { + "last_validated_date": "2023-08-25T18:19:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list2]": { + "last_validated_date": "2023-08-25T18:19:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list3]": { + "last_validated_date": "2023-08-25T18:19:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine[tag_list4]": { + "last_validated_date": "2023-08-25T18:19:55+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_tag_state_machine_version": { + "last_validated_date": "2023-08-25T18:21:09+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys0]": { + "last_validated_date": "2023-08-25T18:21:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys1]": { + "last_validated_date": "2023-08-25T18:21:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys2]": { + "last_validated_date": "2023-08-25T18:21:51+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_tagging.py::TestSnfApiTagging::test_untag_state_machine[tag_keys3]": { + "last_validated_date": "2023-08-25T18:22:05+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py new file mode 100644 index 0000000000000..01bbd45143709 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py @@ -0,0 +1,69 @@ +import json + +import pytest + +from localstack.aws.api.stepfunctions import StateMachineType +from localstack.testing.pytest import markers +from tests.aws.services.stepfunctions.templates.callbacks.callback_templates import ( + CallbackTemplates, +) +from tests.aws.services.stepfunctions.templates.validation.validation_templates import ( + ValidationTemplate, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..diagnostics", # TODO: add support for diagnostics + ] +) +class TestSfnApiValidation: + @pytest.mark.parametrize( + "definition_string", + [" ", "not a definition", "{}"], + ids=["EMPTY_STRING", "NOT_A_DEF", "EMPTY_DICT"], + ) + @markers.aws.validated + def test_validate_state_machine_definition_not_a_definition( + self, sfn_snapshot, aws_client, definition_string + ): + validation_response = aws_client.stepfunctions.validate_state_machine_definition( + definition=definition_string, type=StateMachineType.STANDARD + ) + sfn_snapshot.match("validation_response", validation_response) + + @pytest.mark.parametrize( + "validation_template", + [ValidationTemplate.VALID_BASE_PASS, ValidationTemplate.INVALID_BASE_NO_STARTAT], + ids=["VALID_BASE_PASS", "INVALID_BASE_NO_STARTAT"], + ) + @markers.aws.validated + def test_validate_state_machine_definition_type_standard( + self, sfn_snapshot, aws_client, validation_template + ): + definition = ValidationTemplate.load_sfn_template(validation_template) + definition_str = json.dumps(definition) + validation_response = aws_client.stepfunctions.validate_state_machine_definition( + definition=definition_str, type=StateMachineType.STANDARD + ) + sfn_snapshot.match("validation_response", validation_response) + + @pytest.mark.parametrize( + "validation_template", + [ + ValidationTemplate.VALID_BASE_PASS, + ValidationTemplate.INVALID_BASE_NO_STARTAT, + CallbackTemplates.SQS_WAIT_FOR_TASK_TOKEN, + ], + ids=["VALID_BASE_PASS", "INVALID_BASE_NO_STARTAT", "ILLEGAL_WFTT"], + ) + @markers.aws.validated + def test_validate_state_machine_definition_type_express( + self, sfn_snapshot, aws_client, validation_template + ): + definition = ValidationTemplate.load_sfn_template(validation_template) + definition_str = json.dumps(definition) + validation_response = aws_client.stepfunctions.validate_state_machine_definition( + definition=definition_str, type=StateMachineType.EXPRESS + ) + sfn_snapshot.match("validation_response", validation_response) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.snapshot.json new file mode 100644 index 0000000000000..1a66526efd53b --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.snapshot.json @@ -0,0 +1,155 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[EMPTY_STRING]": { + "recorded-date": "09-10-2024, 08:51:57", + "recorded-content": { + "validation_response": { + "diagnostics": [ + { + "code": "SCHEMA_VALIDATION_FAILED", + "message": "Definition must be a valid JSON object", + "severity": "ERROR" + } + ], + "result": "FAIL", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[NOT_A_DEF]": { + "recorded-date": "09-10-2024, 08:51:58", + "recorded-content": { + "validation_response": { + "diagnostics": [ + { + "code": "INVALID_JSON_DESCRIPTION", + "location": "[Source: (String)\"not a definition\"; line: 1, column: 4]", + "message": "Unrecognized token 'not': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (String)\"not a definition\"; line: 1, column: 4]", + "severity": "ERROR" + } + ], + "result": "FAIL", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[EMPTY_DICT]": { + "recorded-date": "09-10-2024, 08:51:58", + "recorded-content": { + "validation_response": { + "diagnostics": [ + { + "code": "SCHEMA_VALIDATION_FAILED", + "location": "/", + "message": "These fields are required: [States, StartAt]", + "severity": "ERROR" + } + ], + "result": "FAIL", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_standard[VALID_BASE_PASS]": { + "recorded-date": "09-10-2024, 08:51:58", + "recorded-content": { + "validation_response": { + "diagnostics": [], + "result": "OK", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_standard[INVALID_BASE_NO_STARTAT]": { + "recorded-date": "09-10-2024, 08:51:58", + "recorded-content": { + "validation_response": { + "diagnostics": [ + { + "code": "SCHEMA_VALIDATION_FAILED", + "location": "/", + "message": "These fields are required: [StartAt]", + "severity": "ERROR" + } + ], + "result": "FAIL", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[VALID_BASE_PASS]": { + "recorded-date": "09-10-2024, 08:51:58", + "recorded-content": { + "validation_response": { + "diagnostics": [], + "result": "OK", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[INVALID_BASE_NO_STARTAT]": { + "recorded-date": "09-10-2024, 08:51:58", + "recorded-content": { + "validation_response": { + "diagnostics": [ + { + "code": "SCHEMA_VALIDATION_FAILED", + "location": "/", + "message": "These fields are required: [StartAt]", + "severity": "ERROR" + } + ], + "result": "FAIL", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[ILLEGAL_WFTT]": { + "recorded-date": "09-10-2024, 08:51:58", + "recorded-content": { + "validation_response": { + "diagnostics": [ + { + "code": "SCHEMA_VALIDATION_FAILED", + "location": "/States/SendMessageWithWait/Resource", + "message": "Express state machine does not support '.waitForTaskToken' service integration ", + "severity": "ERROR" + } + ], + "result": "FAIL", + "truncated": false, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.validation.json new file mode 100644 index 0000000000000..5e40a3ce68487 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_validation.validation.json @@ -0,0 +1,26 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[EMPTY_DICT]": { + "last_validated_date": "2024-10-09T08:51:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[EMPTY_STRING]": { + "last_validated_date": "2024-10-09T08:51:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_not_a_definition[NOT_A_DEF]": { + "last_validated_date": "2024-10-09T08:51:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[ILLEGAL_WFTT]": { + "last_validated_date": "2024-10-09T08:51:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[INVALID_BASE_NO_STARTAT]": { + "last_validated_date": "2024-10-09T08:51:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_express[VALID_BASE_PASS]": { + "last_validated_date": "2024-10-09T08:51:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_standard[INVALID_BASE_NO_STARTAT]": { + "last_validated_date": "2024-10-09T08:51:58+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_validation.py::TestSfnApiValidation::test_validate_state_machine_definition_type_standard[VALID_BASE_PASS]": { + "last_validated_date": "2024-10-09T08:51:58+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py new file mode 100644 index 0000000000000..9931487a2a077 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py @@ -0,0 +1,162 @@ +import json + +import pytest +from jsonpath_ng.ext import parse +from localstack_snapshot.snapshots.transformer import RegexTransformer, TransformContext + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import await_execution_terminated +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.assign.assign_templates import ( + AssignTemplate as AT, +) +from tests.aws.services.stepfunctions.templates.scenarios.scenarios_templates import ( + ScenariosTemplate as ST, +) + + +class _SfnSortVariableReferences: + # TODO: adjust intrinsic functions' variable references ordering and remove this normalisation logic. + + def transform(self, input_data: dict, *, ctx: TransformContext) -> dict: + pattern = parse("$..variableReferences") + variable_references = pattern.find(input_data) + for variable_reference in variable_references: + for variable_name_list in variable_reference.value.values(): + variable_name_list.sort() + return input_data + + +@markers.snapshot.skip_snapshot_verify( + paths=["$..tracingConfiguration", "$..encryptionConfiguration"] +) +class TestSfnApiVariableReferences: + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + AT.BASE_REFERENCE_IN_PARAMETERS, + AT.BASE_REFERENCE_IN_CHOICE, + AT.BASE_REFERENCE_IN_WAIT, + AT.BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE, + AT.BASE_REFERENCE_IN_INPUTPATH, + AT.BASE_REFERENCE_IN_OUTPUTPATH, + AT.BASE_REFERENCE_IN_INTRINSIC_FUNCTION, + AT.BASE_REFERENCE_IN_FAIL, + AT.BASE_ASSIGN_FROM_PARAMETERS, + AT.BASE_ASSIGN_FROM_RESULT, + AT.BASE_ASSIGN_FROM_INTRINSIC_FUNCTION, + AT.BASE_EVALUATION_ORDER_PASS_STATE, + AT.MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION, + AT.MAP_STATE_REFERENCE_IN_ITEMS_PATH, + AT.MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH, + AT.MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH, + AT.MAP_STATE_REFERENCE_IN_ITEM_SELECTOR, + AT.MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH, + ], + ids=[ + "BASE_REFERENCE_IN_PARAMETERS", + "BASE_REFERENCE_IN_CHOICE", + "BASE_REFERENCE_IN_WAIT", + "BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE", + "BASE_REFERENCE_IN_INPUTPATH", + "BASE_REFERENCE_IN_OUTPUTPATH", + "BASE_REFERENCE_IN_INTRINSIC_FUNCTION", + "BASE_REFERENCE_IN_FAIL", + "BASE_ASSIGN_FROM_PARAMETERS", + "BASE_ASSIGN_FROM_RESULT", + "BASE_ASSIGN_FROM_INTRINSIC_FUNCTION", + "BASE_EVALUATION_ORDER_PASS_STATE", + "MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION", + "MAP_STATE_REFERENCE_IN_ITEMS_PATH", + "MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH", + "MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH", + "MAP_STATE_REFERENCE_IN_ITEM_SELECTOR", + "MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH", + ], + ) + def test_base_variable_references_in_assign_templates( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + template_path, + ): + sfn_snapshot.add_transformer(_SfnSortVariableReferences()) + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "sfn_role_arn")) + + definition = AT.load_sfn_template(template_path) + definition_str = json.dumps(definition) + + creation_response = create_state_machine( + aws_client, + name=f"sm-{short_uid()}", + definition=definition_str, + roleArn=snf_role_arn, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_response, 0)) + state_machine_arn = creation_response["stateMachineArn"] + + describe_response = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=creation_response["stateMachineArn"] + ) + sfn_snapshot.match("describe_response", describe_response) + + execution_response = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(execution_response, 0)) + execution_arn = execution_response["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + describe_for_execution_response = ( + aws_client.stepfunctions.describe_state_machine_for_execution( + executionArn=execution_arn + ) + ) + sfn_snapshot.match("describe_for_execution_response", describe_for_execution_response) + + @markers.aws.validated + @pytest.mark.parametrize( + "template_path", + [ + ST.CHOICE_CONDITION_CONSTANT_JSONATA, + ST.CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA, + ], + ids=[ + "CHOICE_CONDITION_CONSTANT_JSONATA", + "CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA", + ], + ) + def test_base_variable_references_in_jsonata_template( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + template_path, + ): + # This test checks that variable references within jsonata expression are not included. + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "sfn_role_arn")) + + definition = AT.load_sfn_template(template_path) + definition_str = json.dumps(definition) + + creation_response = create_state_machine( + aws_client, + name=f"sm-{short_uid()}", + definition=definition_str, + roleArn=snf_role_arn, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_response, 0)) + + describe_response = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=creation_response["stateMachineArn"] + ) + sfn_snapshot.match("describe_response", describe_response) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.snapshot.json new file mode 100644 index 0000000000000..9892afbca0ef3 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.snapshot.json @@ -0,0 +1,2569 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_PARAMETERS]": { + "recorded-date": "20-11-2024, 14:37:01", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_PARAMETERS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "result": "$result" + }, + "Assign": { + "result": "foobar" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "ResultPath": "$.result", + "Parameters": { + "result.$": "$result" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "State1": [ + "result" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_PARAMETERS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "result": "$result" + }, + "Assign": { + "result": "foobar" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "ResultPath": "$.result", + "Parameters": { + "result.$": "$result" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "State1": [ + "result" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_CHOICE]": { + "recorded-date": "20-11-2024, 14:37:12", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_CHOICE", + "StartAt": "Setup", + "States": { + "Setup": { + "Type": "Pass", + "Assign": { + "guess": "the_guess", + "answer": "the_answer" + }, + "Next": "CheckAnswer" + }, + "CheckAnswer": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$guess", + "StringEqualsPath": "$answer", + "Next": "CorrectAnswer" + } + ], + "Default": "WrongAnswer" + }, + "CorrectAnswer": { + "Type": "Pass", + "Result": { + "state": "CORRECT" + }, + "End": true + }, + "WrongAnswer": { + "Type": "Pass", + "Assign": { + "guess.$": "$answer" + }, + "Result": { + "state": "WRONG" + }, + "Next": "CheckAnswer" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "CheckAnswer": [ + "answer", + "guess" + ], + "WrongAnswer": [ + "answer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_CHOICE", + "StartAt": "Setup", + "States": { + "Setup": { + "Type": "Pass", + "Assign": { + "guess": "the_guess", + "answer": "the_answer" + }, + "Next": "CheckAnswer" + }, + "CheckAnswer": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$guess", + "StringEqualsPath": "$answer", + "Next": "CorrectAnswer" + } + ], + "Default": "WrongAnswer" + }, + "CorrectAnswer": { + "Type": "Pass", + "Result": { + "state": "CORRECT" + }, + "End": true + }, + "WrongAnswer": { + "Type": "Pass", + "Assign": { + "guess.$": "$answer" + }, + "Result": { + "state": "WRONG" + }, + "Next": "CheckAnswer" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "CheckAnswer": [ + "answer", + "guess" + ], + "WrongAnswer": [ + "answer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_WAIT]": { + "recorded-date": "20-11-2024, 14:37:26", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_WAIT", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "waitTime": 0, + "startAt": "date" + }, + "Next": "WaitSecondsState" + }, + "WaitSecondsState": { + "Type": "Wait", + "SecondsPath": "$waitTime", + "Next": "WaitUntilState" + }, + "WaitUntilState": { + "Type": "Wait", + "TimestampPath": "timestamp", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "WaitSecondsState": [ + "waitTime" + ], + "WaitUntilState": [ + "startAt" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_WAIT", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "waitTime": 0, + "startAt": "date" + }, + "Next": "WaitSecondsState" + }, + "WaitSecondsState": { + "Type": "Wait", + "SecondsPath": "$waitTime", + "Next": "WaitUntilState" + }, + "WaitUntilState": { + "Type": "Wait", + "TimestampPath": "timestamp", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "WaitSecondsState": [ + "waitTime" + ], + "WaitUntilState": [ + "startAt" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE]": { + "recorded-date": "20-11-2024, 14:37:42", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE", + "StartAt": "Input", + "States": { + "Input": { + "Type": "Pass", + "Result": [ + [ + 9, + 44, + 6 + ], + [ + 82, + 25, + 76 + ], + [ + 18, + 42, + 2 + ] + ], + "Assign": { + "bias": 4.3 + }, + "Next": "IterateLevels" + }, + "IterateLevels": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "AssignCurrentVector", + "States": { + "AssignCurrentVector": { + "Type": "Pass", + "Assign": { + "xCurrent.$": "$[0]", + "yCurrent.$": "$[1]", + "zCurrent.$": "$[2]" + }, + "Next": "Calculate" + }, + "Calculate": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Summate", + "States": { + "Summate": { + "Type": "Pass", + "Assign": { + "Sum.$": "States.MathAdd(States.MathAdd(States.MathAdd($yCurrent, $xCurrent), $zCurrent), $bias)" + }, + "End": true + } + } + }, + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "Summate": [ + "bias", + "xCurrent", + "yCurrent", + "zCurrent" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE", + "StartAt": "Input", + "States": { + "Input": { + "Type": "Pass", + "Result": [ + [ + 9, + 44, + 6 + ], + [ + 82, + 25, + 76 + ], + [ + 18, + 42, + 2 + ] + ], + "Assign": { + "bias": 4.3 + }, + "Next": "IterateLevels" + }, + "IterateLevels": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "AssignCurrentVector", + "States": { + "AssignCurrentVector": { + "Type": "Pass", + "Assign": { + "xCurrent.$": "$[0]", + "yCurrent.$": "$[1]", + "zCurrent.$": "$[2]" + }, + "Next": "Calculate" + }, + "Calculate": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Summate", + "States": { + "Summate": { + "Type": "Pass", + "Assign": { + "Sum.$": "States.MathAdd(States.MathAdd(States.MathAdd($yCurrent, $xCurrent), $zCurrent), $bias)" + }, + "End": true + } + } + }, + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "Summate": [ + "bias", + "xCurrent", + "yCurrent", + "zCurrent" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_INPUTPATH]": { + "recorded-date": "20-11-2024, 14:37:57", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_INPUTPATH", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "theAnswer": 42 + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "InputPath": "$theAnswer", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "State1": [ + "theAnswer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_INPUTPATH", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "theAnswer": 42 + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "InputPath": "$theAnswer", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "State1": [ + "theAnswer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_OUTPUTPATH]": { + "recorded-date": "20-11-2024, 14:38:12", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_OUTPUTPATH", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "answer": 42 + }, + "Assign": { + "theAnswer.$": "$.answer" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "OutputPath": "$theAnswer", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "State1": [ + "theAnswer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_OUTPUTPATH", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "answer": 42 + }, + "Assign": { + "theAnswer.$": "$.answer" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "OutputPath": "$theAnswer", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "State1": [ + "theAnswer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "recorded-date": "20-11-2024, 14:38:29", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_INTRINSIC_FUNCTION", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Parameters": { + "encodingOps": { + "encoded.$": "States.Base64Encode($rawString)", + "decoded.$": "States.Base64Decode($encodedString)" + }, + "hashOps": { + "hashValue.$": "States.Hash($inputString, 'SHA-256')" + }, + "jsonOps": { + "mergedJson.$": "States.JsonMerge($json1, $json2, false)", + "parsedJson.$": "States.StringToJson($jsonString)", + "stringifiedJson.$": "States.JsonToString($jsonObject)" + }, + "mathOps": { + "sum.$": "States.MathAdd($value1, $value2)" + }, + "stringOps": { + "splitString.$": "States.StringSplit($csvString, ',')", + "formattedString.$": "States.Format('Hello {}, welcome to {}!', $name, $place)" + }, + "uuidOp": { + "uniqueId.$": "States.UUID()" + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "State1": [ + "csvString", + "encodedString", + "inputString", + "json1", + "json2", + "jsonObject", + "jsonString", + "name", + "place", + "rawString", + "value1", + "value2" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_INTRINSIC_FUNCTION", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Assign": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Parameters": { + "encodingOps": { + "encoded.$": "States.Base64Encode($rawString)", + "decoded.$": "States.Base64Decode($encodedString)" + }, + "hashOps": { + "hashValue.$": "States.Hash($inputString, 'SHA-256')" + }, + "jsonOps": { + "mergedJson.$": "States.JsonMerge($json1, $json2, false)", + "parsedJson.$": "States.StringToJson($jsonString)", + "stringifiedJson.$": "States.JsonToString($jsonObject)" + }, + "mathOps": { + "sum.$": "States.MathAdd($value1, $value2)" + }, + "stringOps": { + "splitString.$": "States.StringSplit($csvString, ',')", + "formattedString.$": "States.Format('Hello {}, welcome to {}!', $name, $place)" + }, + "uuidOp": { + "uniqueId.$": "States.UUID()" + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "State1": [ + "csvString", + "encodedString", + "inputString", + "json1", + "json2", + "jsonObject", + "jsonString", + "name", + "place", + "rawString", + "value1", + "value2" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_FAIL]": { + "recorded-date": "20-11-2024, 14:38:43", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_REFERENCE_IN_FAIL", + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Assign": { + "errorVar": "Exception", + "causeVar": "An Exception was encountered" + }, + "Next": "Fail" + }, + "Fail": { + "Type": "Fail", + "CausePath": "$causeVar", + "ErrorPath": "$errorVar" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "Fail": [ + "causeVar", + "errorVar" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_REFERENCE_IN_FAIL", + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Assign": { + "errorVar": "Exception", + "causeVar": "An Exception was encountered" + }, + "Next": "Fail" + }, + "Fail": { + "Type": "Fail", + "CausePath": "$causeVar", + "ErrorPath": "$errorVar" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "Fail": [ + "causeVar", + "errorVar" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_PARAMETERS]": { + "recorded-date": "20-11-2024, 14:38:57", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_ASSIGN_FROM_PARAMETERS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "input": "PENDING" + }, + "Assign": { + "result.$": "$.input" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Assign": { + "result": "SUCCESS", + "originalResult.$": "$.input" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_ASSIGN_FROM_PARAMETERS", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Parameters": { + "input": "PENDING" + }, + "Assign": { + "result.$": "$.input" + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "Assign": { + "result": "SUCCESS", + "originalResult.$": "$.input" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_RESULT]": { + "recorded-date": "20-11-2024, 14:39:12", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_ASSIGN_FROM_RESULT", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "answer": 42 + }, + "Assign": { + "theAnswer.$": "$.answer" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_ASSIGN_FROM_RESULT", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "answer": 42 + }, + "Assign": { + "theAnswer.$": "$.answer" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_INTRINSIC_FUNCTION]": { + "recorded-date": "20-11-2024, 14:39:31", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_ASSIGN_FROM_INTRINSIC_FUNCTION", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "Assign": { + "arrayOps": { + "simpleArray.$": "States.Array('a', 'b', 'c')", + "partitionedArray.$": "States.ArrayPartition($.inputArray, 2)", + "containsElement.$": "States.ArrayContains($.inputArray, 5)", + "numberRange.$": "States.ArrayRange(1, 10, 2)", + "thirdElement.$": "States.ArrayGetItem($.inputArray, 2)", + "arraySize.$": "States.ArrayLength($.inputArray)", + "uniqueValues.$": "States.ArrayUnique($.duplicateArray)" + }, + "encodingOps": { + "encoded.$": "States.Base64Encode($.rawString)", + "decoded.$": "States.Base64Decode($.encodedString)" + }, + "hashOps": { + "hashValue.$": "States.Hash($.inputString, 'SHA-256')" + }, + "jsonOps": { + "mergedJson.$": "States.JsonMerge($.json1, $.json2, false)", + "parsedJson.$": "States.StringToJson($.jsonString)", + "stringifiedJson.$": "States.JsonToString($.jsonObject)" + }, + "mathOps": { + "sum.$": "States.MathAdd($.value1, $.value2)" + }, + "stringOps": { + "splitString.$": "States.StringSplit($.csvString, ',')", + "formattedString.$": "States.Format('Hello {}, welcome to {}!', $.name, $.place)" + }, + "uuidOp": { + "uniqueId.$": "States.UUID()" + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_ASSIGN_FROM_INTRINSIC_FUNCTION", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "inputArray": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "duplicateArray": [ + 1, + 2, + 2, + 3, + 3, + 4 + ], + "rawString": "Hello World", + "encodedString": "SGVsbG8gV29ybGQ=", + "inputString": "Hash this string", + "json1": { + "a": 1, + "b": 2 + }, + "json2": { + "c": 3, + "d": 4 + }, + "jsonString": "{\"key\":\"value\"}", + "jsonObject": { + "test": "object" + }, + "value1": 5, + "value2": 3, + "csvString": "a,b,c,d,e", + "name": "John", + "place": "LocalStack", + "additionalInfo": { + "age": 30, + "role": "developer" + } + }, + "Assign": { + "arrayOps": { + "simpleArray.$": "States.Array('a', 'b', 'c')", + "partitionedArray.$": "States.ArrayPartition($.inputArray, 2)", + "containsElement.$": "States.ArrayContains($.inputArray, 5)", + "numberRange.$": "States.ArrayRange(1, 10, 2)", + "thirdElement.$": "States.ArrayGetItem($.inputArray, 2)", + "arraySize.$": "States.ArrayLength($.inputArray)", + "uniqueValues.$": "States.ArrayUnique($.duplicateArray)" + }, + "encodingOps": { + "encoded.$": "States.Base64Encode($.rawString)", + "decoded.$": "States.Base64Decode($.encodedString)" + }, + "hashOps": { + "hashValue.$": "States.Hash($.inputString, 'SHA-256')" + }, + "jsonOps": { + "mergedJson.$": "States.JsonMerge($.json1, $.json2, false)", + "parsedJson.$": "States.StringToJson($.jsonString)", + "stringifiedJson.$": "States.JsonToString($.jsonObject)" + }, + "mathOps": { + "sum.$": "States.MathAdd($.value1, $.value2)" + }, + "stringOps": { + "splitString.$": "States.StringSplit($.csvString, ',')", + "formattedString.$": "States.Format('Hello {}, welcome to {}!', $.name, $.place)" + }, + "uuidOp": { + "uniqueId.$": "States.UUID()" + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_EVALUATION_ORDER_PASS_STATE]": { + "recorded-date": "20-11-2024, 14:39:46", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_EVALUATION_ORDER_PASS_STATE", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "theQuestion": "What is the answer to life the universe and everything?" + }, + "Assign": { + "question.$": "$.theQuestion", + "answer": 42 + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "InputPath": "$answer", + "ResultPath": "$.theAnswer", + "OutputPath": "$answer", + "Assign": { + "answer": "" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "State1": [ + "answer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "BASE_EVALUATION_ORDER_PASS_STATE", + "StartAt": "State0", + "States": { + "State0": { + "Type": "Pass", + "Result": { + "theQuestion": "What is the answer to life the universe and everything?" + }, + "Assign": { + "question.$": "$.theQuestion", + "answer": 42 + }, + "Next": "State1" + }, + "State1": { + "Type": "Pass", + "InputPath": "$answer", + "ResultPath": "$.theAnswer", + "OutputPath": "$answer", + "Assign": { + "answer": "" + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "State1": [ + "answer" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "recorded-date": "20-11-2024, 14:40:01", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "AnswerTemplate": "It's {}!", + "Question": "Who's that Pokemon?" + }, + "Result": [ + "Charizard", + "Pikachu", + "Squirtle" + ], + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemSelector": { + "AnnouncePokemon.$": "States.Format($AnswerTemplate, $$.Map.Item.Value)" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Parameters": { + "Question.$": "$Question", + "Answer.$": "$.AnnouncePokemon" + }, + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "MapIterateState": [ + "AnswerTemplate" + ], + "Pass": [ + "Question" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "AnswerTemplate": "It's {}!", + "Question": "Who's that Pokemon?" + }, + "Result": [ + "Charizard", + "Pikachu", + "Squirtle" + ], + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemSelector": { + "AnnouncePokemon.$": "States.Format($AnswerTemplate, $$.Map.Item.Value)" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Parameters": { + "Question.$": "$Question", + "Answer.$": "$.AnnouncePokemon" + }, + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "MapIterateState": [ + "AnswerTemplate" + ], + "Pass": [ + "Question" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_ITEMS_PATH]": { + "recorded-date": "20-11-2024, 14:40:15", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_ITEMS_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "Question": "Who's that Pokemon?", + "PokemonList": [ + "Charizard", + "Pikachu", + "Squirtle" + ] + }, + "Result": { + "AnswerTemplate": "It's {}!" + }, + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$PokemonList", + "ItemSelector": { + "AnnouncePokemon.$": "States.Format($.AnswerTemplate, $$.Map.Item.Value)" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Parameters": { + "Question.$": "$Question", + "Answer.$": "$.AnnouncePokemon" + }, + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "MapIterateState": [ + "PokemonList" + ], + "Pass": [ + "Question" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_ITEMS_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "Question": "Who's that Pokemon?", + "PokemonList": [ + "Charizard", + "Pikachu", + "Squirtle" + ] + }, + "Result": { + "AnswerTemplate": "It's {}!" + }, + "Next": "MapIterateState" + }, + "MapIterateState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$PokemonList", + "ItemSelector": { + "AnnouncePokemon.$": "States.Format($.AnswerTemplate, $$.Map.Item.Value)" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "Pass", + "States": { + "Pass": { + "Type": "Pass", + "Parameters": { + "Question.$": "$Question", + "Answer.$": "$.AnnouncePokemon" + }, + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "MapIterateState": [ + "PokemonList" + ], + "Pass": [ + "Question" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH]": { + "recorded-date": "20-11-2024, 14:40:33", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "maxConcurrency": "1" + }, + "Result": { + "Values": [ + 1, + 2, + 3 + ] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ItemsPath": "$.Values", + "MaxConcurrencyPath": "$maxConcurrency", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "MapState": [ + "maxConcurrency" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "maxConcurrency": "1" + }, + "Result": { + "Values": [ + 1, + 2, + 3 + ] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ItemsPath": "$.Values", + "MaxConcurrencyPath": "$maxConcurrency", + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "INLINE" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "MapState": [ + "maxConcurrency" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH]": { + "recorded-date": "20-11-2024, 14:40:53", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "toleratedFailurePercentage": "1", + "toleratedFailureCount": "1" + }, + "Result": { + "Values": [ + 1, + 2, + 3 + ] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ItemsPath": "$.Values", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "ToleratedFailurePercentagePath": "$toleratedFailurePercentage", + "ToleratedFailureCountPath": "$toleratedFailureCount", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "MapState": [ + "toleratedFailureCount", + "toleratedFailurePercentage" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "toleratedFailurePercentage": "1", + "toleratedFailureCount": "1" + }, + "Result": { + "Values": [ + 1, + 2, + 3 + ] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "ItemsPath": "$.Values", + "MaxConcurrency": 1, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "HandleItem", + "States": { + "HandleItem": { + "Type": "Pass", + "End": true + } + } + }, + "ToleratedFailurePercentagePath": "$toleratedFailurePercentage", + "ToleratedFailureCountPath": "$toleratedFailureCount", + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "MapState": [ + "toleratedFailureCount", + "toleratedFailurePercentage" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_ITEM_SELECTOR]": { + "recorded-date": "20-11-2024, 14:41:13", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_ITEM_SELECTOR", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "bucket": "test-name" + }, + "Result": { + "Values": [ + 1, + 2, + 3 + ] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.Values", + "ItemSelector": { + "value.$": "$$.Map.Item.Value", + "bucketName": "$bucket" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "MapPass", + "States": { + "MapPass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_ITEM_SELECTOR", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "bucket": "test-name" + }, + "Result": { + "Values": [ + 1, + 2, + 3 + ] + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemsPath": "$.Values", + "ItemSelector": { + "value.$": "$$.Map.Item.Value", + "bucketName": "$bucket" + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "MapPass", + "States": { + "MapPass": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH]": { + "recorded-date": "20-11-2024, 14:41:28", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "maxItems": "2" + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + "MaxItemsPath": "$maxItems" + }, + "Resource": "arn::states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "variableReferences": { + "MapState": [ + "maxItems" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_for_execution_response": { + "definition": { + "Comment": "MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH", + "StartAt": "Start", + "States": { + "Start": { + "Type": "Pass", + "Assign": { + "maxItems": "2" + }, + "Next": "MapState" + }, + "MapState": { + "Type": "Map", + "MaxConcurrency": 1, + "ItemReader": { + "ReaderConfig": { + "InputType": "JSON", + "MaxItemsPath": "$maxItems" + }, + "Resource": "arn::states:::s3:getObject", + "Parameters": { + "Bucket.$": "$.Bucket", + "Key.$": "$.Key" + } + }, + "ItemProcessor": { + "ProcessorConfig": { + "Mode": "DISTRIBUTED", + "ExecutionType": "STANDARD" + }, + "StartAt": "PassItem", + "States": { + "PassItem": { + "Type": "Pass", + "End": true + } + } + }, + "End": true + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "variableReferences": { + "MapState": [ + "maxItems" + ] + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_jsonata_template[CHOICE_CONDITION_CONSTANT_JSONATA]": { + "recorded-date": "20-11-2024, 14:41:42", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "StartAt": "ChoiceState", + "States": { + "ChoiceState": { + "Type": "Choice", + "QueryLanguage": "JSONata", + "Choices": [ + { + "Condition": true, + "Next": "ConditionTrue" + } + ], + "Default": "DefaultState" + }, + "ConditionTrue": { + "Type": "Pass", + "End": true + }, + "DefaultState": { + "Type": "Fail", + "Cause": "Condition is false" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_jsonata_template[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": { + "recorded-date": "20-11-2024, 14:42:01", + "recorded-content": { + "describe_response": { + "creationDate": "datetime", + "definition": { + "StartAt": "CheckResult", + "States": { + "CheckResult": { + "Type": "Choice", + "QueryLanguage": "JSONata", + "Choices": [ + { + "Condition": "{% $states.input.result.done %}", + "Next": "FinishTrue" + }, + { + "Condition": "{% $not($states.input.result.done) %}", + "Next": "FinishFalse" + } + ] + }, + "FinishTrue": { + "End": true, + "Type": "Pass" + }, + "FinishFalse": { + "End": true, + "Type": "Pass" + } + } + }, + "encryptionConfiguration": { + "type": "AWS_OWNED_KEY" + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "sfn_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.validation.json new file mode 100644 index 0000000000000..e73e99a71a815 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.validation.json @@ -0,0 +1,62 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_INTRINSIC_FUNCTION]": { + "last_validated_date": "2024-11-20T14:39:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_PARAMETERS]": { + "last_validated_date": "2024-11-20T14:38:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_ASSIGN_FROM_RESULT]": { + "last_validated_date": "2024-11-20T14:39:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_EVALUATION_ORDER_PASS_STATE]": { + "last_validated_date": "2024-11-20T14:39:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_CHOICE]": { + "last_validated_date": "2024-11-20T14:37:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_FAIL]": { + "last_validated_date": "2024-11-20T14:38:43+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_INPUTPATH]": { + "last_validated_date": "2024-11-20T14:37:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "last_validated_date": "2024-11-20T14:38:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_ITERATOR_OUTER_SCOPE]": { + "last_validated_date": "2024-11-20T14:37:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_OUTPUTPATH]": { + "last_validated_date": "2024-11-20T14:38:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_PARAMETERS]": { + "last_validated_date": "2024-11-20T14:37:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[BASE_REFERENCE_IN_WAIT]": { + "last_validated_date": "2024-11-20T14:37:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_INTRINSIC_FUNCTION]": { + "last_validated_date": "2024-11-20T14:40:01+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_ITEMS_PATH]": { + "last_validated_date": "2024-11-20T14:40:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_ITEM_SELECTOR]": { + "last_validated_date": "2024-11-20T14:41:13+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_MAX_CONCURRENCY_PATH]": { + "last_validated_date": "2024-11-20T14:40:33+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_MAX_ITEMS_PATH]": { + "last_validated_date": "2024-11-20T14:41:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_assign_templates[MAP_STATE_REFERENCE_IN_TOLERATED_FAILURE_PATH]": { + "last_validated_date": "2024-11-20T14:40:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_jsonata_template[CHOICE_CONDITION_CONSTANT_JSONATA]": { + "last_validated_date": "2024-11-20T14:41:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_variable_references.py::TestSfnApiVariableReferences::test_base_variable_references_in_jsonata_template[CHOICE_STATE_UNSORTED_CHOICE_PARAMETERS_JSONATA]": { + "last_validated_date": "2024-11-20T14:42:01+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py b/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py new file mode 100644 index 0000000000000..2a9e7dd020e8e --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py @@ -0,0 +1,895 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.stepfunctions import StateMachineType +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_lists_terminated, + await_execution_terminated, + await_state_machine_version_listed, + await_state_machine_version_not_listed, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate + + +@markers.snapshot.skip_snapshot_verify(paths=["$..tracingConfiguration"]) +class TestSnfApiVersioning: + @markers.aws.validated + def test_create_with_publish( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + @markers.aws.validated + def test_create_express_with_publish( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + type=StateMachineType.EXPRESS, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + + @markers.aws.validated + def test_create_with_version_description_no_publish( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + with pytest.raises(Exception) as validation_exception: + sm_name = f"statemachine_{short_uid()}" + create_state_machine( + aws_client_no_retry, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + versionDescription="HelloWorld!", + ) + sfn_snapshot.match("validation_exception", validation_exception.value.response) + + @markers.aws.validated + def test_create_publish_describe_no_version_description( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + state_machine_version_arn = creation_resp_1["stateMachineVersionArn"] + + describe_resp_version = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_version_arn + ) + sfn_snapshot.match("describe_resp_version", describe_resp_version) + + describe_resp = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) + + @markers.aws.validated + def test_create_publish_describe_with_version_description( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + versionDescription="HelloWorld!", + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + state_machine_version_arn = creation_resp_1["stateMachineVersionArn"] + + describe_resp_version = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_version_arn + ) + sfn_snapshot.match("describe_resp_version", describe_resp_version) + + describe_resp = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) + + @markers.aws.validated + def test_list_state_machine_versions_pagination( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + + state_machine_version_arns = list() + for revision_no in range(1, 14): + definition["Comment"] = f"{definition['Comment']}-R{revision_no}" + definition_raw_str = json.dumps(definition) + + update_resp_1 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_raw_str, publish=True + ) + + state_machine_version_arn: str = update_resp_1["stateMachineVersionArn"] + assert state_machine_version_arn == f"{state_machine_arn}:{revision_no}" + + state_machine_version_arns.append(state_machine_version_arn) + + await_state_machine_version_listed( + aws_client.stepfunctions, + state_machine_arn, + update_resp_1["stateMachineVersionArn"], + ) + + page_1_state_machine_versions = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn, + maxResults=10, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.key_value("nextToken")) + sfn_snapshot.match("list-state-machine-versions-page-1", page_1_state_machine_versions) + + page_2_state_machine_versions = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn, + maxResults=3, + nextToken=page_1_state_machine_versions["nextToken"], + ) + sfn_snapshot.match("list-state-machine-versions-page-2", page_2_state_machine_versions) + + assert all( + sm not in page_1_state_machine_versions["stateMachineVersions"] + for sm in page_2_state_machine_versions["stateMachineVersions"] + ) + + # maxResults value is out of bounds + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn, maxResults=1001 + ) + sfn_snapshot.match( + "list-state-machine-versions-invalid-param-too-large", err.value.response + ) + + # nextToken is too short + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn, nextToken="" + ) + sfn_snapshot.match( + "list-state-machine-versions-param-short-nextToken", + {"exception_typename": err.typename, "exception_value": err.value}, + ) + + # nextToken is too long + invalid_long_token = "x" * 1025 + with pytest.raises(Exception) as err: + aws_client_no_retry.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn, nextToken=invalid_long_token + ) + sfn_snapshot.add_transformer( + RegexTransformer(invalid_long_token, f"") + ) + sfn_snapshot.match( + "list-state-machine-versions-invalid-param-long-nextToken", err.value.response + ) + + # where maxResults is 0, the default of 100 should be returned + state_machines_default_all_returned = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn, maxResults=0 + ) + assert len(state_machines_default_all_returned["stateMachineVersions"]) == 13 + assert "nextToken" not in state_machines_default_all_returned + + for state_machine_version_arn in state_machine_version_arns: + aws_client.stepfunctions.delete_state_machine_version( + stateMachineVersionArn=state_machine_version_arn, + ) + + for state_machine_version_arn in state_machine_version_arns: + await_state_machine_version_not_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_version_arn + ) + + ls_with_no_state_machine_versions_present = ( + aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn, maxResults=len(state_machine_version_arns) + ) + ) + + assert len(ls_with_no_state_machine_versions_present["stateMachineVersions"]) == 0 + + @markers.aws.validated + def test_list_delete_version( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + state_machine_version_arn = creation_resp_1["stateMachineVersionArn"] + + describe_resp_version = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_version_arn + ) + sfn_snapshot.match("describe_resp_version", describe_resp_version) + + await_state_machine_version_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_version_arn + ) + + list_versions_resp_1 = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("list_versions_resp_1", list_versions_resp_1) + + delete_version_resp = aws_client.stepfunctions.delete_state_machine_version( + stateMachineVersionArn=state_machine_version_arn + ) + sfn_snapshot.match("delete_version_resp", delete_version_resp) + + await_state_machine_version_not_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_version_arn + ) + + list_versions_resp_2 = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("list_versions_resp_2", list_versions_resp_2) + + delete_version_resp_after_del = aws_client.stepfunctions.delete_state_machine_version( + stateMachineVersionArn=state_machine_version_arn + ) + sfn_snapshot.match("delete_version_resp_after_del", delete_version_resp_after_del) + + @markers.aws.validated + def test_update_state_machine( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + + definition_r1 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r1["Comment"] = f"{definition_r1['Comment']}-R1" + definition_r1_str = json.dumps(definition_r1) + + update_resp_1 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_r1_str, publish=True + ) + sfn_snapshot.match("update_resp_1", update_resp_1) + + await_state_machine_version_listed( + aws_client.stepfunctions, + state_machine_arn, + update_resp_1["stateMachineVersionArn"], + ) + + list_versions_resp_1 = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("list_versions_resp_1", list_versions_resp_1) + + definition_r2 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r2["Comment"] = f"{definition_r2['Comment']}-R2" + definition_r2_str = json.dumps(definition_r2) + + update_resp_2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_r2_str, publish=True + ) + sfn_snapshot.match("update_resp_2", update_resp_2) + state_machine_version_2_arn = update_resp_2["stateMachineVersionArn"] + + await_state_machine_version_listed( + aws_client.stepfunctions, + state_machine_arn, + update_resp_2["stateMachineVersionArn"], + ) + + list_versions_resp_2 = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("list_versions_resp_2", list_versions_resp_2) + + definition_r3 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r3["Comment"] = f"{definition_r3['Comment']}-R3" + definition_r3_str = json.dumps(definition_r3) + + with pytest.raises(Exception) as invalid_arn_1: + aws_client_no_retry.stepfunctions.update_state_machine( + stateMachineArn=state_machine_version_2_arn, definition=definition_r3_str + ) + sfn_snapshot.match("invalid_arn_1", invalid_arn_1.value.response) + + with pytest.raises(Exception) as invalid_arn_2: + aws_client_no_retry.stepfunctions.update_state_machine( + stateMachineArn=state_machine_version_2_arn, + definition=definition_r3_str, + publish=True, + ) + sfn_snapshot.match("invalid_arn_2", invalid_arn_2.value.response) + + @markers.aws.validated + def test_publish_state_machine_version( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + + definition_r1 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r1["Comment"] = f"{definition_r1['Comment']}-R1" + definition_r1_str = json.dumps(definition_r1) + + update_resp_1 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_r1_str + ) + sfn_snapshot.match("update_resp_1", update_resp_1) + + publish_v1 = aws_client.stepfunctions.publish_state_machine_version( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("publish_v1", publish_v1) + state_machine_v1_arn = publish_v1["stateMachineVersionArn"] + + await_state_machine_version_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_v1_arn + ) + + list_versions_resp_1 = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("list_versions_resp_1", list_versions_resp_1) + + describe_v1 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_v1_arn + ) + sfn_snapshot.match("describe_v1", describe_v1) + + definition_r2 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r2["Comment"] = f"{definition_r2['Comment']}-R2" + definition_r2_str = json.dumps(definition_r2) + + update_resp_2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_r2_str + ) + sfn_snapshot.match("update_resp_2", update_resp_2) + revision_id_r2 = update_resp_2["revisionId"] + + publish_v2 = aws_client.stepfunctions.publish_state_machine_version( + stateMachineArn=state_machine_arn, description="PublishedV2Description" + ) + sfn_snapshot.match("publish_v2", publish_v2) + state_machine_v2_arn = publish_v2["stateMachineVersionArn"] + + await_state_machine_version_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_v2_arn + ) + + list_versions_resp_2 = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("list_versions_resp_2", list_versions_resp_2) + + describe_v2 = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=state_machine_v2_arn + ) + sfn_snapshot.match("describe_v2", describe_v2) + + definition_r3 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r3["Comment"] = f"{definition_r3['Comment']}-R3" + definition_r3_str = json.dumps(definition_r3) + + update_resp_3 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_r3_str + ) + sfn_snapshot.match("update_resp_3", update_resp_3) + + with pytest.raises(Exception) as conflict_exception: + aws_client_no_retry.stepfunctions.publish_state_machine_version( + stateMachineArn=state_machine_arn, revisionId=revision_id_r2 + ) + sfn_snapshot.match("conflict_exception", conflict_exception.value) + + @markers.aws.validated + def test_start_version_execution( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + state_machine_version_arn = creation_resp_1["stateMachineVersionArn"] + + execution_resp = aws_client.stepfunctions.start_execution(stateMachineArn=state_machine_arn) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(execution_resp, 0)) + sfn_snapshot.match("execution_resp", execution_resp) + execution_arn = execution_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + await_execution_lists_terminated( + stepfunctions_client=aws_client.stepfunctions, + state_machine_arn=state_machine_arn, + execution_arn=execution_arn, + ) + + exec_list_resp = aws_client.stepfunctions.list_executions(stateMachineArn=state_machine_arn) + sfn_snapshot.match("exec_list_resp", exec_list_resp) + + execution_version_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_version_arn + ) + sfn_snapshot.add_transformer( + sfn_snapshot.transform.sfn_sm_exec_arn(execution_version_resp, 1) + ) + sfn_snapshot.match("execution_version_resp", execution_version_resp) + version_execution_arn = execution_version_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, + execution_arn=version_execution_arn, + ) + + await_execution_lists_terminated( + stepfunctions_client=aws_client.stepfunctions, + state_machine_arn=state_machine_version_arn, + execution_arn=version_execution_arn, + ) + + exec_version_list_resp = aws_client.stepfunctions.list_executions( + stateMachineArn=state_machine_version_arn + ) + sfn_snapshot.match("exec_version_list_resp", exec_version_list_resp) + + @markers.aws.validated + def test_version_ids_between_deletions( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + state_machine_arn_v1 = f"{state_machine_arn}:1" + state_machine_arn_v2 = f"{state_machine_arn}:2" + await_state_machine_version_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_arn_v1 + ) + + definition_r2 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r2["Comment"] = f"{definition_r2['Comment']}-R2" + definition_r2_str = json.dumps(definition_r2) + aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_r2_str, publish=True + ) + await_state_machine_version_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_arn_v2 + ) + + aws_client.stepfunctions.delete_state_machine_version( + stateMachineVersionArn=state_machine_arn_v2 + ) + await_state_machine_version_not_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_arn_v2 + ) + + publish_res_v2_2 = aws_client.stepfunctions.publish_state_machine_version( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("publish_res_v2_2", publish_res_v2_2) + + @markers.aws.validated + def test_idempotent_publish( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + + publish_v1_1 = aws_client.stepfunctions.publish_state_machine_version( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("publish_v1_1", publish_v1_1) + await_state_machine_version_listed( + aws_client.stepfunctions, state_machine_arn, f"{state_machine_arn}:1" + ) + + publish_v1_2 = aws_client.stepfunctions.publish_state_machine_version( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("publish_v1_2", publish_v1_2) + + list_versions_resp = aws_client.stepfunctions.list_state_machine_versions( + stateMachineArn=state_machine_arn + ) + sfn_snapshot.match("list_versions_resp", list_versions_resp) + + @markers.aws.validated + def test_publish_state_machine_version_no_such_machine( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + aws_client_no_retry, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + sm_name = f"statemachine_{short_uid()}" + + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + state_machine_arn: str = creation_resp_1["stateMachineArn"] + + sm_nonexistent_name = f"statemachine_{short_uid()}" + sm_nonexistent_arn = state_machine_arn.replace(sm_name, sm_nonexistent_name) + sfn_snapshot.add_transformer(RegexTransformer(sm_nonexistent_arn, "ssm_nonexistent_arn")) + + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.publish_state_machine_version( + stateMachineArn=sm_nonexistent_arn + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..exception_value"]) + def test_publish_state_machine_version_invalid_arn(self, sfn_snapshot, aws_client_no_retry): + with pytest.raises(Exception) as exc: + aws_client_no_retry.stepfunctions.publish_state_machine_version( + stateMachineArn="invalid_arn" + ) + sfn_snapshot.match( + "exception", {"exception_typename": exc.typename, "exception_value": exc.value} + ) + + @markers.aws.validated + def test_empty_revision_with_publish_and_publish_on_creation( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + + update_resp = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str, publish=True + ) + sfn_snapshot.match("update_resp_1", update_resp) + + update_resp_2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str, publish=True + ) + sfn_snapshot.match("update_resp_2", update_resp_2) + + @markers.aws.validated + def test_empty_revision_with_publish_and_no_publish_on_creation( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + + update_resp = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str, publish=True + ) + sfn_snapshot.match("update_resp_1", update_resp) + + update_resp_2 = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_str, publish=True + ) + sfn_snapshot.match("update_resp_2", update_resp_2) + + @markers.aws.validated + def test_describe_state_machine_for_execution_of_version( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, + name=sm_name, + definition=definition_str, + roleArn=snf_role_arn, + publish=True, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_version_arn = creation_resp_1["stateMachineVersionArn"] + + execution_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_version_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(execution_resp, 0)) + sfn_snapshot.match("execution_resp", execution_resp) + execution_arn = execution_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + describe_resp = aws_client.stepfunctions.describe_state_machine_for_execution( + executionArn=execution_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) + + @markers.aws.validated + def test_describe_state_machine_for_execution_of_version_with_revision( + self, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + aws_client, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + sfn_snapshot.add_transformer(RegexTransformer(snf_role_arn, "snf_role_arn")) + + definition = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_str = json.dumps(definition) + + sm_name = f"statemachine_{short_uid()}" + creation_resp_1 = create_state_machine( + aws_client, name=sm_name, definition=definition_str, roleArn=snf_role_arn + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp_1, 0)) + sfn_snapshot.match("creation_resp_1", creation_resp_1) + state_machine_arn = creation_resp_1["stateMachineArn"] + + definition_r1 = BaseTemplate.load_sfn_template(BaseTemplate.BASE_PASS_RESULT) + definition_r1["Comment"] = f"{definition_r1['Comment']}-R2" + definition_r1_str = json.dumps(definition_r1) + state_machine_arn_v1 = f"{state_machine_arn}:1" + update_resp = aws_client.stepfunctions.update_state_machine( + stateMachineArn=state_machine_arn, definition=definition_r1_str, publish=True + ) + sfn_snapshot.match("update_resp", update_resp) + await_state_machine_version_listed( + aws_client.stepfunctions, state_machine_arn, state_machine_arn_v1 + ) + + execution_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn_v1 + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_exec_arn(execution_resp, 0)) + sfn_snapshot.match("execution_resp", execution_resp) + execution_arn = execution_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + describe_resp = aws_client.stepfunctions.describe_state_machine_for_execution( + executionArn=execution_arn + ) + sfn_snapshot.match("describe_resp", describe_resp) diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.snapshot.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.snapshot.json new file mode 100644 index 0000000000000..283a4139cdbbf --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.snapshot.json @@ -0,0 +1,934 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_with_publish": { + "recorded-date": "11-08-2023, 12:09:12", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_with_version_description_no_publish": { + "recorded-date": "11-08-2023, 12:09:25", + "recorded-content": { + "validation_exception": { + "Error": { + "Code": "ValidationException", + "Message": "Version description can only be set when publish is true" + }, + "message": "Version description can only be set when publish is true", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_publish_describe_no_version_description": { + "recorded-date": "11-08-2023, 12:09:39", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_version": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine::1", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_publish_describe_with_version_description": { + "recorded-date": "11-08-2023, 12:09:54", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_version": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "description": "HelloWorld!", + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine::1", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_update_state_machine": { + "recorded-date": "11-08-2023, 18:02:02", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_1": { + "revisionId": "", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_resp_1": { + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_2": { + "revisionId": "", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::2", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_resp_2": { + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::2" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalid_arn_1": { + "Error": { + "Code": "StateMachineDoesNotExist", + "Message": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine::2'" + }, + "message": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine::2'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "invalid_arn_2": { + "Error": { + "Code": "StateMachineDoesNotExist", + "Message": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine::2'" + }, + "message": "State Machine Does Not Exist: 'arn::states::111111111111:stateMachine::2'", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version": { + "recorded-date": "11-08-2023, 12:10:42", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_1": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_v1": { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_resp_1": { + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_v1": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT-R1", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine::1", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_2": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_v2": { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::2", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_resp_2": { + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::2" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_v2": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT-R2", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "description": "PublishedV2Description", + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine::2", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_3": { + "revisionId": "", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "conflict_exception": "An error occurred (ConflictException) when calling the PublishStateMachineVersion operation: Failed to publish the State Machine version for revision . The current State Machine revision is ." + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_start_version_execution": { + "recorded-date": "17-08-2023, 09:53:53", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_list_resp": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_version_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "exec_version_list_resp": { + "executions": [ + { + "executionArn": "arn::states::111111111111:execution::", + "name": "", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "status": "SUCCEEDED", + "stopDate": "datetime" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_list_delete_version": { + "recorded-date": "11-08-2023, 12:10:11", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp_version": { + "creationDate": "datetime", + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine::1", + "status": "ACTIVE", + "tracingConfiguration": { + "enabled": false + }, + "type": "STANDARD", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_resp_1": { + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_version_resp": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_resp_2": { + "stateMachineVersions": [], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete_version_resp_after_del": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_version_ids_between_deletions": { + "recorded-date": "11-08-2023, 15:46:39", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_res_v2_2": { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::3", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_idempotent_publish": { + "recorded-date": "11-08-2023, 15:57:54", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_v1_1": { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "publish_v1_2": { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_versions_resp": { + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_empty_revision_with_publish_and_no_publish_on_creation": { + "recorded-date": "11-08-2023, 16:53:59", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_1": { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_2": { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_empty_revision_with_publish_and_publish_on_creation": { + "recorded-date": "11-08-2023, 16:55:57", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_1": { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp_2": { + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_describe_state_machine_for_execution_of_version": { + "recorded-date": "21-08-2023, 21:00:57", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "definition": { + "Comment": "BASE_PASS_RESULT", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_describe_state_machine_for_execution_of_version_with_revision": { + "recorded-date": "21-08-2023, 21:19:46", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "update_resp": { + "revisionId": "", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "execution_resp": { + "executionArn": "arn::states::111111111111:execution::", + "startDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "describe_resp": { + "definition": { + "Comment": "BASE_PASS_RESULT-R2", + "StartAt": "State_1", + "States": { + "State_1": { + "Type": "Pass", + "Result": { + "Arg1": "argument1" + }, + "End": true + } + } + }, + "loggingConfiguration": { + "includeExecutionData": false, + "level": "OFF" + }, + "name": "", + "revisionId": "", + "roleArn": "snf_role_arn", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "tracingConfiguration": { + "enabled": false + }, + "updateDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version_no_such_machine": { + "recorded-date": "08-03-2024, 11:05:48", + "recorded-content": { + "exception": { + "exception_typename": "StateMachineDoesNotExist", + "exception_value": "An error occurred (StateMachineDoesNotExist) when calling the PublishStateMachineVersion operation: State Machine Does Not Exist: 'ssm_nonexistent_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version_invalid_arn": { + "recorded-date": "08-03-2024, 11:10:04", + "recorded-content": { + "exception": { + "exception_typename": "InvalidArn", + "exception_value": "An error occurred (InvalidArn) when calling the PublishStateMachineVersion operation: Invalid Arn: 'Invalid ARN prefix: invalid_arn'" + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_express_with_publish": { + "recorded-date": "05-04-2024, 19:53:29", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_list_state_machine_versions_pagination": { + "recorded-date": "01-07-2024, 12:20:00", + "recorded-content": { + "creation_resp_1": { + "creationDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-state-machine-versions-page-1": { + "nextToken": "", + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::13" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::12" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::11" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::10" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::9" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::8" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::7" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::6" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::5" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::4" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-state-machine-versions-page-2": { + "stateMachineVersions": [ + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::3" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::2" + }, + { + "creationDate": "datetime", + "stateMachineVersionArn": "arn::states::111111111111:stateMachine::1" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list-state-machine-versions-invalid-param-too-large": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000" + }, + "message": "1 validation error detected: Value '1001' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 1000", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "list-state-machine-versions-param-short-nextToken": { + "exception_typename": "ParamValidationError", + "exception_value": "Parameter validation failed:\nInvalid length for parameter nextToken, value: 0, valid min length: 1" + }, + "list-state-machine-versions-invalid-param-long-nextToken": { + "Error": { + "Code": "ValidationException", + "Message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 1024" + }, + "message": "1 validation error detected: Value '' at 'nextToken' failed to satisfy constraint: Member must have length less than or equal to 1024", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.validation.json b/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.validation.json new file mode 100644 index 0000000000000..7ed6857372934 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.validation.json @@ -0,0 +1,56 @@ +{ + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_express_with_publish": { + "last_validated_date": "2024-04-05T19:53:29+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_publish_describe_no_version_description": { + "last_validated_date": "2023-08-11T10:09:39+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_publish_describe_with_version_description": { + "last_validated_date": "2023-08-11T10:09:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_with_publish": { + "last_validated_date": "2023-08-11T10:09:12+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_create_with_version_description_no_publish": { + "last_validated_date": "2023-08-11T10:09:25+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_describe_state_machine_for_execution_of_version": { + "last_validated_date": "2023-08-21T19:00:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_describe_state_machine_for_execution_of_version_with_revision": { + "last_validated_date": "2023-08-21T19:19:46+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_empty_revision_with_publish_and_no_publish_on_creation": { + "last_validated_date": "2023-08-11T14:53:59+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_empty_revision_with_publish_and_publish_on_creation": { + "last_validated_date": "2023-08-11T14:55:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_idempotent_publish": { + "last_validated_date": "2023-08-11T13:57:54+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_list_delete_version": { + "last_validated_date": "2023-08-11T10:10:11+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_list_state_machine_versions_pagination": { + "last_validated_date": "2024-07-01T12:20:00+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version": { + "last_validated_date": "2023-08-11T10:10:42+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version_invalid_arn": { + "last_validated_date": "2024-03-08T11:10:04+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_publish_state_machine_version_no_such_machine": { + "last_validated_date": "2024-03-08T11:05:48+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_start_version_execution": { + "last_validated_date": "2023-08-17T07:53:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_update_state_machine": { + "last_validated_date": "2023-08-11T16:02:02+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_sfn_api_versioning.py::TestSnfApiVersioning::test_version_ids_between_deletions": { + "last_validated_date": "2023-08-11T13:46:39+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_state/__init__.py b/tests/aws/services/stepfunctions/v2/test_state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py b/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py new file mode 100644 index 0000000000000..facf99bd57c7a --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py @@ -0,0 +1,252 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.aws.api.stepfunctions import InspectionLevel +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.services.services_templates import ( + ServicesTemplates as ST, +) +from tests.aws.services.stepfunctions.templates.test_state.test_state_templates import ( + TestStateTemplate as TST, +) + +HELLO_WORLD_INPUT = json.dumps({"Value": "HelloWorld"}) +NESTED_DICT_INPUT = json.dumps( + { + "initialData": {"fieldFromInput": "value from input", "otherField": "other"}, + "unrelatedData": {"someOtherField": 1234}, + } +) +BASE_CHOICE_STATE_INPUT = json.dumps({"type": "Private", "value": 22}) + +BASE_TEMPLATE_INPUT_BINDINGS: list[tuple[str, str]] = [ + (TST.BASE_PASS_STATE, HELLO_WORLD_INPUT), + (TST.BASE_RESULT_PASS_STATE, HELLO_WORLD_INPUT), + (TST.IO_PASS_STATE, NESTED_DICT_INPUT), + (TST.IO_RESULT_PASS_STATE, NESTED_DICT_INPUT), + (TST.BASE_FAIL_STATE, HELLO_WORLD_INPUT), + (TST.BASE_SUCCEED_STATE, HELLO_WORLD_INPUT), + (TST.BASE_CHOICE_STATE, BASE_CHOICE_STATE_INPUT), +] +IDS_BASE_TEMPLATE_INPUT_BINDINGS: list[str] = [ + "BASE_PASS_STATE", + "BASE_RESULT_PASS_STATE", + "IO_PASS_STATE", + "IO_RESULT_PASS_STATE", + "BASE_FAIL_STATE", + "BASE_SUCCEED_STATE", + "BASE_CHOICE_STATE", +] + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestStateCaseScenarios: + # TODO: consider aggregating all `test_base_inspection_level_*` into a single parametrised function, and evaluate + # solutions for snapshot skips and parametrisation complexity. + + @markers.aws.validated + @pytest.mark.parametrize( + "tct_template,execution_input", + BASE_TEMPLATE_INPUT_BINDINGS, + ids=IDS_BASE_TEMPLATE_INPUT_BINDINGS, + ) + def test_base_inspection_level_info( + self, + aws_client, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + tct_template, + execution_input, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + + template = TST.load_sfn_template(tct_template) + definition = json.dumps(template) + + test_case_response = aws_client_no_sync_prefix.stepfunctions.test_state( + definition=definition, + roleArn=sfn_role_arn, + input=execution_input, + inspectionLevel=InspectionLevel.INFO, + ) + sfn_snapshot.match("test_case_response", test_case_response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Unknown generalisable behaviour by AWS leads to the outputting of undeclared and + # unsupported state modifiers. Such as ResultSelector, which is neither defined in + # this Pass state, nor supported by Pass states. + "$..inspectionData.afterInputPath", + "$..inspectionData.afterParameters", + "$..inspectionData.afterResultPath", + "$..inspectionData.afterResultSelector", + ] + ) + @pytest.mark.parametrize( + "tct_template,execution_input", + BASE_TEMPLATE_INPUT_BINDINGS, + ids=IDS_BASE_TEMPLATE_INPUT_BINDINGS, + ) + def test_base_inspection_level_debug( + self, + aws_client, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + tct_template, + execution_input, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + + template = TST.load_sfn_template(tct_template) + definition = json.dumps(template) + + test_case_response = aws_client_no_sync_prefix.stepfunctions.test_state( + definition=definition, + roleArn=sfn_role_arn, + input=execution_input, + inspectionLevel=InspectionLevel.DEBUG, + ) + sfn_snapshot.match("test_case_response", test_case_response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Unknown generalisable behaviour by AWS leads to the outputting of undeclared and + # unsupported state modifiers. Such as ResultSelector, which is neither defined in + # this Pass state, nor supported by Pass states. + "$..inspectionData.afterInputPath", + "$..inspectionData.afterParameters", + "$..inspectionData.afterResultPath", + "$..inspectionData.afterResultSelector", + ] + ) + @pytest.mark.parametrize( + "tct_template,execution_input", + BASE_TEMPLATE_INPUT_BINDINGS, + ids=IDS_BASE_TEMPLATE_INPUT_BINDINGS, + ) + def test_base_inspection_level_trace( + self, + aws_client, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + tct_template, + execution_input, + ): + sfn_role_arn = create_state_machine_iam_role(aws_client) + + template = TST.load_sfn_template(tct_template) + definition = json.dumps(template) + + test_case_response = aws_client_no_sync_prefix.stepfunctions.test_state( + definition=definition, + roleArn=sfn_role_arn, + input=execution_input, + inspectionLevel=InspectionLevel.TRACE, + ) + sfn_snapshot.match("test_case_response", test_case_response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Unknown generalisable behaviour by AWS leads to the outputting of undeclared and + # unsupported state modifiers. + "$..inspectionData.afterInputPath", + "$..inspectionData.afterParameters", + "$..inspectionData.afterResultPath", + "$..inspectionData.afterResultSelector", + ] + ) + @pytest.mark.parametrize( + "inspection_level", [InspectionLevel.INFO, InspectionLevel.DEBUG, InspectionLevel.TRACE] + ) + def test_base_lambda_task_state( + self, + aws_client, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + inspection_level, + ): + function_name = f"lambda_func_{short_uid()}" + create_1_res = create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_RETURN_BYTES_STR, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = TST.load_sfn_template(TST.BASE_LAMBDA_TASK_STATE) + template["Resource"] = create_1_res["CreateFunctionResponse"]["FunctionArn"] + definition = json.dumps(template) + exec_input = json.dumps({"inputData": "HelloWorld"}) + + sfn_role_arn = create_state_machine_iam_role(aws_client) + test_case_response = aws_client_no_sync_prefix.stepfunctions.test_state( + definition=definition, + roleArn=sfn_role_arn, + input=exec_input, + inspectionLevel=inspection_level, + ) + sfn_snapshot.match("test_case_response", test_case_response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + # Unknown generalisable behaviour by AWS leads to the outputting of undeclared state modifiers. + "$..inspectionData.afterInputPath", + "$..inspectionData.afterResultPath", + "$..inspectionData.afterResultSelector", + ] + ) + @pytest.mark.parametrize( + "inspection_level", [InspectionLevel.INFO, InspectionLevel.DEBUG, InspectionLevel.TRACE] + ) + def test_base_lambda_service_task_state( + self, + aws_client, + aws_client_no_sync_prefix, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + inspection_level, + ): + function_name = f"lambda_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=ST.LAMBDA_ID_FUNCTION, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = TST.load_sfn_template(TST.BASE_LAMBDA_SERVICE_TASK_STATE) + definition = json.dumps(template) + exec_input = json.dumps({"FunctionName": function_name, "Payload": None}) + + sfn_role_arn = create_state_machine_iam_role(aws_client) + test_case_response = aws_client_no_sync_prefix.stepfunctions.test_state( + definition=definition, + roleArn=sfn_role_arn, + input=exec_input, + inspectionLevel=inspection_level, + ) + sfn_snapshot.match("test_case_response", test_case_response) diff --git a/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.snapshot.json b/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.snapshot.json new file mode 100644 index 0000000000000..1e11cdcc339f2 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.snapshot.json @@ -0,0 +1,1139 @@ +{ + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:45:35", + "recorded-content": { + "test_case_response": { + "output": { + "Value": "HelloWorld" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_RESULT_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:45:49", + "recorded-content": { + "test_case_response": { + "output": { + "resultKey": "result value" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[IO_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:46:03", + "recorded-content": { + "test_case_response": { + "output": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "staticValue": "some value", + "inputValue": "value from input" + } + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[IO_RESULT_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:46:15", + "recorded-content": { + "test_case_response": { + "output": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "resultKey": "result value" + } + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_FAIL_STATE]": { + "recorded-date": "12-04-2024, 20:46:28", + "recorded-content": { + "test_case_response": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure", + "status": "FAILED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_SUCCEED_STATE]": { + "recorded-date": "12-04-2024, 20:46:41", + "recorded-content": { + "test_case_response": { + "output": { + "Value": "HelloWorld" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_CHOICE_STATE]": { + "recorded-date": "12-04-2024, 20:46:53", + "recorded-content": { + "test_case_response": { + "nextState": "ValueInTwenties", + "output": { + "type": "Private", + "value": 22 + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:47:06", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "Value": "HelloWorld" + }, + "afterParameters": { + "Value": "HelloWorld" + }, + "afterResultPath": { + "Value": "HelloWorld" + }, + "afterResultSelector": { + "Value": "HelloWorld" + }, + "input": { + "Value": "HelloWorld" + } + }, + "output": { + "Value": "HelloWorld" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_RESULT_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:47:19", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterResultPath": { + "resultKey": "result value" + }, + "afterResultSelector": { + "resultKey": "result value" + }, + "input": { + "Value": "HelloWorld" + }, + "result": { + "resultKey": "result value" + } + }, + "output": { + "resultKey": "result value" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[IO_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:47:31", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "afterParameters": { + "staticValue": "some value", + "inputValue": "value from input" + }, + "afterResultPath": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "staticValue": "some value", + "inputValue": "value from input" + } + }, + "afterResultSelector": { + "staticValue": "some value", + "inputValue": "value from input" + }, + "input": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + } + } + }, + "output": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "staticValue": "some value", + "inputValue": "value from input" + } + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[IO_RESULT_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:47:44", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterResultPath": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "resultKey": "result value" + } + }, + "afterResultSelector": { + "resultKey": "result value" + }, + "input": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + } + }, + "result": { + "resultKey": "result value" + } + }, + "output": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "resultKey": "result value" + } + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_FAIL_STATE]": { + "recorded-date": "12-04-2024, 20:47:56", + "recorded-content": { + "test_case_response": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure", + "inspectionData": { + "afterInputPath": { + "Value": "HelloWorld" + }, + "input": { + "Value": "HelloWorld" + } + }, + "status": "FAILED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_SUCCEED_STATE]": { + "recorded-date": "12-04-2024, 20:48:10", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "Value": "HelloWorld" + }, + "input": { + "Value": "HelloWorld" + } + }, + "output": { + "Value": "HelloWorld" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_CHOICE_STATE]": { + "recorded-date": "12-04-2024, 20:48:24", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "type": "Private", + "value": 22 + }, + "input": { + "type": "Private", + "value": 22 + } + }, + "nextState": "ValueInTwenties", + "output": { + "type": "Private", + "value": 22 + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:48:37", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "Value": "HelloWorld" + }, + "afterParameters": { + "Value": "HelloWorld" + }, + "afterResultPath": { + "Value": "HelloWorld" + }, + "afterResultSelector": { + "Value": "HelloWorld" + }, + "input": { + "Value": "HelloWorld" + } + }, + "output": { + "Value": "HelloWorld" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_RESULT_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:48:50", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterResultPath": { + "resultKey": "result value" + }, + "afterResultSelector": { + "resultKey": "result value" + }, + "input": { + "Value": "HelloWorld" + }, + "result": { + "resultKey": "result value" + } + }, + "output": { + "resultKey": "result value" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[IO_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:49:03", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "afterParameters": { + "staticValue": "some value", + "inputValue": "value from input" + }, + "afterResultPath": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "staticValue": "some value", + "inputValue": "value from input" + } + }, + "afterResultSelector": { + "staticValue": "some value", + "inputValue": "value from input" + }, + "input": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + } + } + }, + "output": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "staticValue": "some value", + "inputValue": "value from input" + } + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[IO_RESULT_PASS_STATE]": { + "recorded-date": "12-04-2024, 20:49:22", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterResultPath": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "resultKey": "result value" + } + }, + "afterResultSelector": { + "resultKey": "result value" + }, + "input": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + } + }, + "result": { + "resultKey": "result value" + } + }, + "output": { + "initialData": { + "fieldFromInput": "value from input", + "otherField": "other" + }, + "unrelatedData": { + "someOtherField": 1234 + }, + "modifiedData": { + "resultKey": "result value" + } + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_FAIL_STATE]": { + "recorded-date": "12-04-2024, 20:49:31", + "recorded-content": { + "test_case_response": { + "cause": "This state machines raises a 'SomeFailure' failure.", + "error": "SomeFailure", + "inspectionData": { + "afterInputPath": { + "Value": "HelloWorld" + }, + "input": { + "Value": "HelloWorld" + } + }, + "status": "FAILED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_SUCCEED_STATE]": { + "recorded-date": "12-04-2024, 20:49:44", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "Value": "HelloWorld" + }, + "input": { + "Value": "HelloWorld" + } + }, + "output": { + "Value": "HelloWorld" + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_CHOICE_STATE]": { + "recorded-date": "12-04-2024, 20:49:57", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "type": "Private", + "value": 22 + }, + "input": { + "type": "Private", + "value": 22 + } + }, + "nextState": "ValueInTwenties", + "output": { + "type": "Private", + "value": 22 + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[INFO]": { + "recorded-date": "12-04-2024, 20:50:22", + "recorded-content": { + "test_case_response": { + "output": "\"HelloWorld!\"", + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[DEBUG]": { + "recorded-date": "12-04-2024, 20:50:37", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "inputData": "HelloWorld" + }, + "afterParameters": { + "inputData": "HelloWorld" + }, + "afterResultPath": "\"HelloWorld!\"", + "afterResultSelector": "\"HelloWorld!\"", + "input": { + "inputData": "HelloWorld" + }, + "result": "\"HelloWorld!\"" + }, + "output": "\"HelloWorld!\"", + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[TRACE]": { + "recorded-date": "12-04-2024, 20:50:52", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "inputData": "HelloWorld" + }, + "afterParameters": { + "inputData": "HelloWorld" + }, + "afterResultPath": "\"HelloWorld!\"", + "afterResultSelector": "\"HelloWorld!\"", + "input": { + "inputData": "HelloWorld" + }, + "result": "\"HelloWorld!\"" + }, + "output": "\"HelloWorld!\"", + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[INFO]": { + "recorded-date": "12-04-2024, 20:51:07", + "recorded-content": { + "test_case_response": { + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[DEBUG]": { + "recorded-date": "12-04-2024, 20:51:22", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "FunctionName": "", + "Payload": null + }, + "afterParameters": { + "FunctionName": "", + "Payload": null + }, + "afterResultPath": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "afterResultSelector": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "input": { + "FunctionName": "", + "Payload": null + }, + "result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[TRACE]": { + "recorded-date": "12-04-2024, 20:51:37", + "recorded-content": { + "test_case_response": { + "inspectionData": { + "afterInputPath": { + "FunctionName": "", + "Payload": null + }, + "afterParameters": { + "FunctionName": "", + "Payload": null + }, + "afterResultPath": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "afterResultSelector": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "input": { + "FunctionName": "", + "Payload": null + }, + "result": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + } + }, + "output": { + "ExecutedVersion": "$LATEST", + "Payload": {}, + "SdkHttpMetadata": { + "AllHttpHeaders": { + "X-Amz-Executed-Version": [ + "$LATEST" + ], + "x-amzn-Remapped-Content-Length": [ + "0" + ], + "Connection": [ + "keep-alive" + ], + "x-amzn-RequestId": [ + "" + ], + "Content-Length": [ + "2" + ], + "Date": "date", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id", + "Content-Type": [ + "application/json" + ] + }, + "HttpHeaders": { + "Connection": "keep-alive", + "Content-Length": "2", + "Content-Type": "application/json", + "Date": "date", + "X-Amz-Executed-Version": "$LATEST", + "x-amzn-Remapped-Content-Length": "0", + "x-amzn-RequestId": "", + "X-Amzn-Trace-Id": "X-Amzn-Trace-Id" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + }, + "StatusCode": 200 + }, + "status": "SUCCEEDED", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.validation.json b/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.validation.json new file mode 100644 index 0000000000000..6ee1aeac5b542 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.validation.json @@ -0,0 +1,83 @@ +{ + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_CHOICE_STATE]": { + "last_validated_date": "2024-04-12T20:48:24+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_FAIL_STATE]": { + "last_validated_date": "2024-04-12T20:47:56+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:47:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_RESULT_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:47:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[BASE_SUCCEED_STATE]": { + "last_validated_date": "2024-04-12T20:48:10+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[IO_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:47:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_debug[IO_RESULT_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:47:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_CHOICE_STATE]": { + "last_validated_date": "2024-04-12T20:46:53+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_FAIL_STATE]": { + "last_validated_date": "2024-04-12T20:46:28+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:45:35+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_RESULT_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:45:49+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[BASE_SUCCEED_STATE]": { + "last_validated_date": "2024-04-12T20:46:41+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[IO_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:46:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_info[IO_RESULT_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:46:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_CHOICE_STATE]": { + "last_validated_date": "2024-04-12T20:49:57+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_FAIL_STATE]": { + "last_validated_date": "2024-04-12T20:49:31+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:48:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_RESULT_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:48:50+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[BASE_SUCCEED_STATE]": { + "last_validated_date": "2024-04-12T20:49:44+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[IO_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:49:03+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_inspection_level_trace[IO_RESULT_PASS_STATE]": { + "last_validated_date": "2024-04-12T20:49:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[DEBUG]": { + "last_validated_date": "2024-04-12T20:51:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[INFO]": { + "last_validated_date": "2024-04-12T20:51:07+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_service_task_state[TRACE]": { + "last_validated_date": "2024-04-12T20:51:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[DEBUG]": { + "last_validated_date": "2024-04-12T20:50:37+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[INFO]": { + "last_validated_date": "2024-04-12T20:50:22+00:00" + }, + "tests/aws/services/stepfunctions/v2/test_state/test_test_state_scenarios.py::TestStateCaseScenarios::test_base_lambda_task_state[TRACE]": { + "last_validated_date": "2024-04-12T20:50:52+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py b/tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py new file mode 100644 index 0000000000000..198f9e5bb2f02 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/test_stepfunctions_v2.py @@ -0,0 +1,836 @@ +import json +import logging +import os + +import pytest + +from localstack.services.events.v1.provider import TEST_EVENTS_CACHE +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.config import ( + SECONDARY_TEST_AWS_ACCESS_KEY_ID, + SECONDARY_TEST_AWS_SECRET_ACCESS_KEY, +) +from localstack.testing.pytest import markers +from localstack.utils import testutil +from localstack.utils.aws import arns +from localstack.utils.files import load_file +from localstack.utils.json import clone +from localstack.utils.strings import short_uid +from localstack.utils.sync import ShortCircuitWaitException, retry, wait_until +from localstack.utils.threads import parallelize +from tests.aws.services.lambda_.functions import lambda_environment +from tests.aws.services.lambda_.test_lambda import TEST_LAMBDA_ENV, TEST_LAMBDA_PYTHON_ECHO + +THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) +TEST_LAMBDA_NAME_1 = "lambda_sfn_1" +TEST_LAMBDA_NAME_2 = "lambda_sfn_2" +TEST_RESULT_VALUE = "testresult1" +TEST_RESULT_VALUE_2 = "testresult2" +TEST_RESULT_VALUE_4 = "testresult4" +STATE_MACHINE_BASIC = { + "Comment": "Hello World example", + "StartAt": "step1", + "States": { + "step1": {"Type": "Task", "Resource": "__tbd__", "Next": "step2"}, + "step2": { + "Type": "Task", + "Resource": "__tbd__", + "ResultPath": "$.result_value", + "End": True, + }, + }, +} +TEST_LAMBDA_NAME_3 = "lambda_map_sfn_3" +STATE_MACHINE_MAP = { + "Comment": "Hello Map State", + "StartAt": "ExampleMapState", + "States": { + "ExampleMapState": { + "Type": "Map", + "ItemProcessor": { + "ProcessorConfig": {"Mode": "INLINE"}, + "StartAt": "CallLambda", + "States": {"CallLambda": {"Type": "Task", "Resource": "__tbd__", "End": True}}, + }, + "End": True, + } + }, +} +TEST_LAMBDA_NAME_4 = "lambda_choice_sfn_4" +STATE_MACHINE_CHOICE = { + "StartAt": "CheckValues", + "States": { + "CheckValues": { + "Type": "Choice", + "Choices": [ + { + "And": [ + {"Variable": "$.x", "IsPresent": True}, + {"Variable": "$.y", "IsPresent": True}, + ], + "Next": "Add", + } + ], + "Default": "MissingValue", + }, + "MissingValue": {"Type": "Fail", "Cause": "test"}, + "Add": { + "Type": "Task", + "Resource": "__tbd__", + "ResultPath": "$.added", + "TimeoutSeconds": 10, + "End": True, + }, + }, +} +STATE_MACHINE_CATCH = { + "StartAt": "Start", + "States": { + "Start": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName": "__tbd__", + "Payload": {lambda_environment.MSG_BODY_RAISE_ERROR_FLAG: 1}, + }, + "Catch": [ + { + "ErrorEquals": [ + "Exception", + "Lambda.Unknown", + "ValueError", + ], + "ResultPath": "$.error", + "Next": "ErrorHandler", + } + ], + "Next": "Final", + }, + "ErrorHandler": { + "Type": "Task", + "Resource": "__tbd__", + "ResultPath": "$.handled", + "Next": "Final", + }, + "Final": { + "Type": "Task", + "Resource": "__tbd__", + "ResultPath": "$.final", + "End": True, + }, + }, +} +TEST_LAMBDA_NAME_5 = "lambda_intrinsic_sfn_5" +STATE_MACHINE_INTRINSIC_FUNCS = { + "StartAt": "state0", + "States": { + "state0": { + "Type": "Pass", + "Result": {"v1": 1, "v2": "v2"}, + "ResultPath": "$", + "Next": "state1", + }, + "state1": { + "Type": "Pass", + "Parameters": { + "lambda_params": { + "FunctionName": "__tbd__", + "Payload": {"values.$": "States.Array($.v1, $.v2)"}, + } + }, + "Next": "state2", + }, + "state2": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { + "FunctionName.$": "$.lambda_params.FunctionName", + "Payload.$": "States.StringToJson(States.JsonToString($.lambda_params.Payload))", + }, + "Next": "state3", + }, + "state3": { + "Type": "Task", + "Resource": "__tbd__", + "ResultSelector": {"payload.$": "$"}, + "ResultPath": "$.result_value", + "End": True, + }, + }, +} +STATE_MACHINE_EVENTS = { + "StartAt": "step1", + "States": { + "step1": { + "Type": "Task", + "Resource": "arn:aws:states:::events:putEvents", + "Parameters": { + "Entries": [ + { + "DetailType": "TestMessage", + "Source": "TestSource", + "EventBusName": "__tbd__", + "Detail": {"Message": "Hello from Step Functions!"}, + } + ] + }, + "End": True, + }, + }, +} + +LOG = logging.getLogger(__name__) + + +@pytest.fixture(scope="module") +def setup_and_tear_down(aws_client): + lambda_client = aws_client.lambda_ + + zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_ENV), get_content=True) + zip_file2 = testutil.create_lambda_archive(load_file(TEST_LAMBDA_PYTHON_ECHO), get_content=True) + testutil.create_lambda_function( + func_name=TEST_LAMBDA_NAME_1, + zip_file=zip_file, + envvars={"Hello": TEST_RESULT_VALUE}, + client=aws_client.lambda_, + s3_client=aws_client.s3, + ) + testutil.create_lambda_function( + func_name=TEST_LAMBDA_NAME_2, + zip_file=zip_file, + envvars={"Hello": TEST_RESULT_VALUE_2}, + client=aws_client.lambda_, + s3_client=aws_client.s3, + ) + testutil.create_lambda_function( + func_name=TEST_LAMBDA_NAME_3, + zip_file=zip_file, + envvars={"Hello": "Replace Value"}, + client=aws_client.lambda_, + s3_client=aws_client.s3, + ) + testutil.create_lambda_function( + func_name=TEST_LAMBDA_NAME_4, + zip_file=zip_file, + envvars={"Hello": TEST_RESULT_VALUE_4}, + client=aws_client.lambda_, + s3_client=aws_client.s3, + ) + testutil.create_lambda_function( + func_name=TEST_LAMBDA_NAME_5, + zip_file=zip_file2, + client=aws_client.lambda_, + s3_client=aws_client.s3, + ) + + active_waiter = lambda_client.get_waiter("function_active_v2") + active_waiter.wait(FunctionName=TEST_LAMBDA_NAME_1) + active_waiter.wait(FunctionName=TEST_LAMBDA_NAME_2) + active_waiter.wait(FunctionName=TEST_LAMBDA_NAME_3) + active_waiter.wait(FunctionName=TEST_LAMBDA_NAME_4) + active_waiter.wait(FunctionName=TEST_LAMBDA_NAME_5) + + yield + + aws_client.lambda_.delete_function(FunctionName=TEST_LAMBDA_NAME_1) + aws_client.lambda_.delete_function(FunctionName=TEST_LAMBDA_NAME_2) + aws_client.lambda_.delete_function(FunctionName=TEST_LAMBDA_NAME_3) + aws_client.lambda_.delete_function(FunctionName=TEST_LAMBDA_NAME_4) + aws_client.lambda_.delete_function(FunctionName=TEST_LAMBDA_NAME_5) + + +def _assert_machine_instances(expected_instances, sfn_client): + def check(): + state_machines_after = sfn_client.list_state_machines()["stateMachines"] + assert expected_instances == len(state_machines_after) + return state_machines_after + + return retry(check, sleep=1, retries=4) + + +def _get_execution_results(sm_arn, sfn_client): + response = sfn_client.list_executions(stateMachineArn=sm_arn) + executions = sorted(response["executions"], key=lambda x: x["startDate"]) + execution = executions[-1] + result = sfn_client.get_execution_history(executionArn=execution["executionArn"]) + events = sorted(result["events"], key=lambda event: event["timestamp"]) + result = json.loads(events[-1]["executionSucceededEventDetails"]["output"]) + return result + + +def assert_machine_deleted(state_machines_before, sfn_client): + return _assert_machine_instances(len(state_machines_before), sfn_client) + + +def assert_machine_created(state_machines_before, sfn_client): + return _assert_machine_instances(len(state_machines_before) + 1, sfn_client=sfn_client) + + +def cleanup(sm_arn, state_machines_before, sfn_client): + sfn_client.delete_state_machine(stateMachineArn=sm_arn) + assert_machine_deleted(state_machines_before, sfn_client=sfn_client) + + +def get_machine_arn(sm_name, sfn_client): + state_machines = sfn_client.list_state_machines()["stateMachines"] + return [m["stateMachineArn"] for m in state_machines if m["name"] == sm_name][0] + + +@pytest.mark.usefixtures("setup_and_tear_down") +class TestStateMachine: + @markers.aws.needs_fixing + def test_create_choice_state_machine(self, aws_client, account_id, region_name): + state_machines_before = aws_client.stepfunctions.list_state_machines()["stateMachines"] + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + + definition = clone(STATE_MACHINE_CHOICE) + lambda_arn_4 = arns.lambda_function_arn(TEST_LAMBDA_NAME_4, account_id, region_name) + definition["States"]["Add"]["Resource"] = lambda_arn_4 + definition = json.dumps(definition) + sm_name = f"choice-{short_uid()}" + aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + + # assert that the SM has been created + assert_machine_created(state_machines_before, aws_client.stepfunctions) + + # run state machine + sm_arn = get_machine_arn(sm_name, aws_client.stepfunctions) + input = {"x": "1", "y": "2"} + result = aws_client.stepfunctions.start_execution( + stateMachineArn=sm_arn, input=json.dumps(input) + ) + assert result.get("executionArn") + + # define expected output + test_output = {**input, "added": {"Hello": TEST_RESULT_VALUE_4}} + + def check_result(): + result = _get_execution_results(sm_arn, aws_client.stepfunctions) + assert test_output == result + + # assert that the result is correct + retry(check_result, sleep=2, retries=10) + + # clean up + cleanup(sm_arn, state_machines_before, sfn_client=aws_client.stepfunctions) + + @markers.aws.needs_fixing + def test_create_run_map_state_machine(self, aws_client, account_id, region_name): + names = ["Bob", "Meg", "Joe"] + test_input = [{"map": name} for name in names] + test_output = [{"Hello": name} for name in names] + state_machines_before = aws_client.stepfunctions.list_state_machines()["stateMachines"] + + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + definition = clone(STATE_MACHINE_MAP) + lambda_arn_3 = arns.lambda_function_arn(TEST_LAMBDA_NAME_3, account_id, region_name) + definition["States"]["ExampleMapState"]["ItemProcessor"]["States"]["CallLambda"][ + "Resource" + ] = lambda_arn_3 + definition = json.dumps(definition) + sm_name = f"map-{short_uid()}" + aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + + # assert that the SM has been created + assert_machine_created(state_machines_before, aws_client.stepfunctions) + + # run state machine + sm_arn = get_machine_arn(sm_name, aws_client.stepfunctions) + result = aws_client.stepfunctions.start_execution( + stateMachineArn=sm_arn, input=json.dumps(test_input) + ) + assert result.get("executionArn") + + def check_invocations(): + # assert that the result is correct + result = _get_execution_results(sm_arn, aws_client.stepfunctions) + assert result == test_output + + # assert that the lambda has been invoked by the SM execution + retry(check_invocations, sleep=1, retries=10) + + # clean up + cleanup(sm_arn, state_machines_before, aws_client.stepfunctions) + + @markers.aws.needs_fixing + def test_create_run_state_machine(self, aws_client, account_id, region_name): + state_machines_before = aws_client.stepfunctions.list_state_machines()["stateMachines"] + + # create state machine + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + definition = clone(STATE_MACHINE_BASIC) + lambda_arn_1 = arns.lambda_function_arn(TEST_LAMBDA_NAME_1, account_id, region_name) + lambda_arn_2 = arns.lambda_function_arn(TEST_LAMBDA_NAME_2, account_id, region_name) + definition["States"]["step1"]["Resource"] = lambda_arn_1 + definition["States"]["step2"]["Resource"] = lambda_arn_2 + definition = json.dumps(definition) + sm_name = f"basic-{short_uid()}" + aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + + # assert that the SM has been created + assert_machine_created(state_machines_before, aws_client.stepfunctions) + + # run state machine + sm_arn = get_machine_arn(sm_name, aws_client.stepfunctions) + result = aws_client.stepfunctions.start_execution(stateMachineArn=sm_arn) + assert result.get("executionArn") + + def check_invocations(): + # assert that the result is correct + result = _get_execution_results(sm_arn, aws_client.stepfunctions) + assert {"Hello": TEST_RESULT_VALUE_2} == result["result_value"] + + # assert that the lambda has been invoked by the SM execution + retry(check_invocations, sleep=0.7, retries=25) + + # clean up + cleanup(sm_arn, state_machines_before, aws_client.stepfunctions) + + @markers.aws.needs_fixing + def test_try_catch_state_machine(self, aws_client, account_id, region_name): + state_machines_before = aws_client.stepfunctions.list_state_machines()["stateMachines"] + + # create state machine + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + definition = clone(STATE_MACHINE_CATCH) + lambda_arn_1 = arns.lambda_function_arn(TEST_LAMBDA_NAME_1, account_id, region_name) + lambda_arn_2 = arns.lambda_function_arn(TEST_LAMBDA_NAME_2, account_id, region_name) + definition["States"]["Start"]["Parameters"]["FunctionName"] = lambda_arn_1 + definition["States"]["ErrorHandler"]["Resource"] = lambda_arn_2 + definition["States"]["Final"]["Resource"] = lambda_arn_2 + definition = json.dumps(definition) + sm_name = f"catch-{short_uid()}" + aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + + # run state machine + sm_arn = get_machine_arn(sm_name, aws_client.stepfunctions) + result = aws_client.stepfunctions.start_execution(stateMachineArn=sm_arn) + assert result.get("executionArn") + + def check_invocations(): + # assert that the result is correct + result = _get_execution_results(sm_arn, aws_client.stepfunctions) + assert {"Hello": TEST_RESULT_VALUE_2} == result.get("handled") + + # assert that the lambda has been invoked by the SM execution + retry(check_invocations, sleep=10, retries=1000000) + + # clean up + cleanup(sm_arn, state_machines_before, aws_client.stepfunctions) + + @markers.aws.needs_fixing + def test_intrinsic_functions(self, aws_client, account_id, region_name): + state_machines_before = aws_client.stepfunctions.list_state_machines()["stateMachines"] + + # create state machine + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + definition = clone(STATE_MACHINE_INTRINSIC_FUNCS) + lambda_arn_1 = arns.lambda_function_arn(TEST_LAMBDA_NAME_5, account_id, region_name) + lambda_arn_2 = arns.lambda_function_arn(TEST_LAMBDA_NAME_5, account_id, region_name) + if isinstance(definition["States"]["state1"].get("Parameters"), dict): + definition["States"]["state1"]["Parameters"]["lambda_params"]["FunctionName"] = ( + lambda_arn_1 + ) + definition["States"]["state3"]["Resource"] = lambda_arn_2 + definition = json.dumps(definition) + sm_name = f"intrinsic-{short_uid()}" + aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + + # run state machine + sm_arn = get_machine_arn(sm_name, aws_client.stepfunctions) + input = {} + result = aws_client.stepfunctions.start_execution( + stateMachineArn=sm_arn, input=json.dumps(input) + ) + assert result.get("executionArn") + + def check_invocations(): + # assert that the result is correct + result = _get_execution_results(sm_arn, aws_client.stepfunctions) + assert result.get("Payload") == {"values": [1, "v2"]} + + # assert that the lambda has been invoked by the SM execution + retry(check_invocations, sleep=1, retries=10) + + # clean up + cleanup(sm_arn, state_machines_before, aws_client.stepfunctions) + + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Accurate events reporting not yet supported." + ) + @markers.aws.needs_fixing + def test_events_state_machine(self, aws_client, account_id, region_name): + events = aws_client.events + state_machines_before = aws_client.stepfunctions.list_state_machines()["stateMachines"] + + # create event bus + bus_name = f"bus-{short_uid()}" + events.create_event_bus(Name=bus_name) + + # create state machine + definition = clone(STATE_MACHINE_EVENTS) + definition["States"]["step1"]["Parameters"]["Entries"][0]["EventBusName"] = bus_name + definition = json.dumps(definition) + sm_name = f"events-{short_uid()}" + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + + # run state machine + events_before = len(TEST_EVENTS_CACHE) + sm_arn = get_machine_arn(sm_name, aws_client.stepfunctions) + result = aws_client.stepfunctions.start_execution(stateMachineArn=sm_arn) + assert result.get("executionArn") + + def check_invocations(): + # assert that the event is received + assert events_before + 1 == len(TEST_EVENTS_CACHE) + last_event = TEST_EVENTS_CACHE[-1] + assert bus_name == last_event["EventBusName"] + assert "TestSource" == last_event["Source"] + assert "TestMessage" == last_event["DetailType"] + assert {"Message": "Hello from Step Functions!"} == json.loads(last_event["Detail"]) + + # assert that the event bus has received an event from the SM execution + retry(check_invocations, sleep=1, retries=10) + + # clean up + cleanup(sm_arn, state_machines_before, aws_client.stepfunctions) + events.delete_event_bus(Name=bus_name) + + @markers.aws.needs_fixing + def test_create_state_machines_in_parallel(self, cleanups, aws_client, account_id, region_name): + """ + Perform a test that creates a series of state machines in parallel. Without concurrency control, using + StepFunctions-Local, the following error is pretty consistently reproducible: + + botocore.errorfactory.InvalidDefinition: An error occurred (InvalidDefinition) when calling the + CreateStateMachine operation: Invalid State Machine Definition: ''DUPLICATE_STATE_NAME: Duplicate State name: + MissingValue at /States/MissingValue', 'DUPLICATE_STATE_NAME: Duplicate State name: Add at /States/Add'' + """ + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + definition = clone(STATE_MACHINE_CHOICE) + lambda_arn_4 = arns.lambda_function_arn(TEST_LAMBDA_NAME_4, account_id, region_name) + definition["States"]["Add"]["Resource"] = lambda_arn_4 + definition = json.dumps(definition) + results = [] + + def _create_sm(*_): + sm_name = f"sm-{short_uid()}" + result = aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + assert result["ResponseMetadata"]["HTTPStatusCode"] == 200 + cleanups.append( + lambda: aws_client.stepfunctions.delete_state_machine( + stateMachineArn=result["stateMachineArn"] + ) + ) + results.append(result) + aws_client.stepfunctions.describe_state_machine( + stateMachineArn=result["stateMachineArn"] + ) + # TODO: implement list_tags_for_resource + # stepfunctions_client.list_tags_for_resource(resourceArn=result["stateMachineArn"]) + + num_machines = 30 + parallelize(_create_sm, list(range(num_machines)), size=2) + assert len(results) == num_machines + + +TEST_STATE_MACHINE = { + "StartAt": "s0", + "States": {"s0": {"Type": "Pass", "Result": {}, "End": True}}, +} + +TEST_STATE_MACHINE_2 = { + "StartAt": "s1", + "States": { + "s1": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "Input": {"Comment": "Hello world!"}, + "StateMachineArn": "__machine_arn__", + "Name": "ExecutionName", + }, + "End": True, + } + }, +} + +TEST_STATE_MACHINE_3 = { + "StartAt": "s1", + "States": { + "s1": { + "Type": "Task", + "Resource": "arn:aws:states:::states:startExecution.sync", + "Parameters": { + "Input": {"Comment": "Hello world!"}, + "StateMachineArn": "__machine_arn__", + "Name": "ExecutionName", + }, + "End": True, + } + }, +} + +STS_ROLE_POLICY_DOC = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": ["states.amazonaws.com"]}, + "Action": "sts:AssumeRole", + } + ], +} + + +@pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Investigate error around states:startExecution.sync" +) +@pytest.mark.parametrize("region_name", ("us-east-1", "us-east-2", "eu-west-1", "eu-central-1")) +@pytest.mark.parametrize("statemachine_definition", (TEST_STATE_MACHINE_3,)) # TODO: add sync2 test +@markers.aws.needs_fixing +def test_multiregion_nested(aws_client_factory, account_id, region_name, statemachine_definition): + client1 = aws_client_factory( + region_name=region_name, + aws_access_key_id=SECONDARY_TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=SECONDARY_TEST_AWS_SECRET_ACCESS_KEY, + ) + # create state machine + child_machine_name = f"sf-child-{short_uid()}" + role = arns.iam_role_arn("sfn_role", account_id, region_name) + child_machine_result = client1.create_state_machine( + name=child_machine_name, definition=json.dumps(TEST_STATE_MACHINE), roleArn=role + ) + child_machine_arn = child_machine_result["stateMachineArn"] + + # create parent state machine + name = f"sf-parent-{short_uid()}" + role = arns.iam_role_arn("sfn_role", account_id, region_name) + result = client1.create_state_machine( + name=name, + definition=json.dumps(statemachine_definition).replace( + "__machine_arn__", child_machine_arn + ), + roleArn=role, + ) + machine_arn = result["stateMachineArn"] + try: + # list state machine + result = client1.list_state_machines()["stateMachines"] + assert len(result) > 0 + assert len([sm for sm in result if sm["name"] == name]) == 1 + assert len([sm for sm in result if sm["name"] == child_machine_name]) == 1 + + # start state machine execution + result = client1.start_execution(stateMachineArn=machine_arn) + + execution = client1.describe_execution(executionArn=result["executionArn"]) + assert execution["stateMachineArn"] == machine_arn + assert execution["status"] in ["RUNNING", "SUCCEEDED"] + + def assert_success(): + return ( + client1.describe_execution(executionArn=result["executionArn"])["status"] + == "SUCCEEDED" + ) + + wait_until(assert_success) + + result = client1.describe_state_machine_for_execution(executionArn=result["executionArn"]) + assert result["stateMachineArn"] == machine_arn + + finally: + client1.delete_state_machine(stateMachineArn=machine_arn) + client1.delete_state_machine(stateMachineArn=child_machine_arn) + + +@markers.aws.validated +def test_default_logging_configuration(create_state_machine, aws_client): + role_name = f"role_name-{short_uid()}" + try: + role_arn = aws_client.iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument=json.dumps(STS_ROLE_POLICY_DOC), + )["Role"]["Arn"] + + definition = clone(TEST_STATE_MACHINE) + definition = json.dumps(definition) + + sm_name = f"sts-logging-{short_uid()}" + result = create_state_machine( + aws_client, name=sm_name, definition=definition, roleArn=role_arn + ) + + assert result["ResponseMetadata"]["HTTPStatusCode"] == 200 + result = aws_client.stepfunctions.describe_state_machine( + stateMachineArn=result["stateMachineArn"] + ) + assert result["ResponseMetadata"]["HTTPStatusCode"] == 200 + + # TODO: add support for loggingConfiguration. + # assert result["loggingConfiguration"] == {"level": "OFF", "includeExecutionData": False} + finally: + aws_client.iam.delete_role(RoleName=role_name) + + +@markers.aws.validated +def test_aws_sdk_task(aws_client): + statemachine_definition = { + "StartAt": "CreateTopicTask", + "States": { + "CreateTopicTask": { + "End": True, + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:sns:createTopic", + "Parameters": {"Name.$": "$.Name"}, + } + }, + } + + # create parent state machine + name = f"statemachine-{short_uid()}" + policy_name = f"policy-{short_uid()}" + role_name = f"role-{short_uid()}" + topic_name = f"topic-{short_uid()}" + + role = aws_client.iam.create_role( + RoleName=role_name, + AssumeRolePolicyDocument='{"Version": "2012-10-17", "Statement": {"Action": "sts:AssumeRole", "Effect": "Allow", "Principal": {"Service": "states.amazonaws.com"}}}', + ) + policy = aws_client.iam.create_policy( + PolicyDocument='{"Version": "2012-10-17", "Statement": {"Action": "sns:createTopic", "Effect": "Allow", "Resource": "*"}}', + PolicyName=policy_name, + ) + aws_client.iam.attach_role_policy( + RoleName=role["Role"]["RoleName"], PolicyArn=policy["Policy"]["Arn"] + ) + + result = aws_client.stepfunctions.create_state_machine( + name=name, + definition=json.dumps(statemachine_definition), + roleArn=role["Role"]["Arn"], + ) + machine_arn = result["stateMachineArn"] + + try: + result = aws_client.stepfunctions.list_state_machines()["stateMachines"] + assert len(result) > 0 + assert len([sm for sm in result if sm["name"] == name]) == 1 + + def assert_execution_success(executionArn: str): + def _assert_execution_success(): + status = aws_client.stepfunctions.describe_execution(executionArn=executionArn)[ + "status" + ] + if status == "FAILED": + raise ShortCircuitWaitException("Statemachine execution failed") + else: + return status == "SUCCEEDED" + + return _assert_execution_success + + def _retry_execution(): + # start state machine execution + # AWS initially straight up fails until the permissions seem to take effect + # so we wait until the statemachine is at least running + result = aws_client.stepfunctions.start_execution( + stateMachineArn=machine_arn, input=f'{{"Name": "{topic_name}"}}' + ) + assert wait_until(assert_execution_success(result["executionArn"])) + describe_result = aws_client.stepfunctions.describe_execution( + executionArn=result["executionArn"] + ) + output = describe_result["output"] + assert topic_name in output + + # TODO: implement stepfunction's 'describe_state_machine_for_execution'. + # result = stepfunctions_client.describe_state_machine_for_execution( + # executionArn=result["executionArn"] + # ) + # assert result["stateMachineArn"] == machine_arn + + topic_arn = json.loads(describe_result["output"])["TopicArn"] + topics = aws_client.sns.list_topics() + assert topic_arn in [t["TopicArn"] for t in topics["Topics"]] + aws_client.sns.delete_topic(TopicArn=topic_arn) + return True + + assert wait_until(_retry_execution, max_retries=3, strategy="linear", wait=3.0) + + finally: + aws_client.iam.detach_role_policy(RoleName=role_name, PolicyArn=policy["Policy"]["Arn"]) + aws_client.iam.delete_role(RoleName=role_name) + aws_client.iam.delete_policy(PolicyArn=policy["Policy"]["Arn"]) + aws_client.stepfunctions.delete_state_machine(stateMachineArn=machine_arn) + + +@markers.aws.needs_fixing +def test_run_aws_sdk_secrets_manager(aws_client, account_id, region_name): + state_machines_before = aws_client.stepfunctions.list_state_machines()["stateMachines"] + + # create state machine + role_arn = arns.iam_role_arn("sfn_role", account_id, region_name) + definition = { + "StartAt": "StateCreateSecret", + "States": { + "StateCreateSecret": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:secretsmanager:CreateSecret", + "Parameters": { + "Name": "MyTestDatabaseSecret", + "Description": "My test database secret created with the CLI", + "SecretString": "Something", + }, + "Next": "StateGetSecretValue", + }, + "StateGetSecretValue": { + "Type": "Task", + "Resource": "arn:aws:states:::aws-sdk:secretsmanager:GetSecretValue", + "Parameters": { + "SecretId": "MyTestDatabaseSecret", + }, + "End": True, + }, + }, + } + definition = json.dumps(definition) + sm_name = f"basic-{short_uid()}" + aws_client.stepfunctions.create_state_machine( + name=sm_name, definition=definition, roleArn=role_arn + ) + + # assert that the SM has been created + assert_machine_created(state_machines_before, aws_client.stepfunctions) + + # run state machine + sm_arn = get_machine_arn(sm_name, aws_client.stepfunctions) + result = aws_client.stepfunctions.start_execution(stateMachineArn=sm_arn) + assert result.get("executionArn") + + def check_invocations(): + # assert that the result is correct + result = _get_execution_results(sm_arn, aws_client.stepfunctions) + assert result["SecretString"] == "Something" + return True + + # assert that the lambda has been invoked by the SM execution + wait_until(check_invocations, max_retries=3, strategy="linear", wait=3.0) + + # clean up + cleanup(sm_arn, state_machines_before, aws_client.stepfunctions) + # TODO also clean up other resources (like secrets) diff --git a/tests/aws/services/stepfunctions/v2/timeouts/__init__.py b/tests/aws/services/stepfunctions/v2/timeouts/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py b/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py new file mode 100644 index 0000000000000..fb63b3138d608 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py @@ -0,0 +1,139 @@ +import json + +from localstack_snapshot.snapshots.transformer import JsonpathTransformer, RegexTransformer + +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.timeouts.timeout_templates import ( + TimeoutTemplates as TT, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..SdkHttpMetadata", + "$..SdkResponseMetadata", + ] +) +class TestHeartbeats: + @markers.aws.validated + def test_heartbeat_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = TT.load_sfn_template(TT.SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT) + definition = json.dumps(template) + + message_txt = "test_message_txt" + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message_txt}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_heartbeat_path_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = TT.load_sfn_template( + TT.SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT_PATH + ) + definition = json.dumps(template) + + message_txt = "test_message_txt" + exec_input = json.dumps( + {"QueueUrl": queue_url, "Message": message_txt, "HeartbeatSecondsPath": 5} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_heartbeat_no_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sqs_create_queue, + sqs_send_task_success_state_machine, + sfn_snapshot, + ): + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sqs_integration()) + sfn_snapshot.add_transformer( + JsonpathTransformer( + jsonpath="$..TaskToken", + replacement="task_token", + replace_reference=True, + ) + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + sfn_snapshot.add_transformer(RegexTransformer(queue_url, "")) + sfn_snapshot.add_transformer(RegexTransformer(queue_name, "")) + + template = TT.load_sfn_template(TT.SERVICE_SQS_SEND_AND_WAIT_FOR_TASK_TOKEN_WITH_HEARTBEAT) + del template["States"]["SendMessageWithWait"]["TimeoutSeconds"] + definition = json.dumps(template) + + message_txt = "test_message_txt" + exec_input = json.dumps({"QueueUrl": queue_url, "Message": message_txt}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.snapshot.json b/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.snapshot.json new file mode 100644 index 0000000000000..85bf57cf66a72 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.snapshot.json @@ -0,0 +1,423 @@ +{ + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_timeout": { + "recorded-date": "18-04-2024, 06:27:58", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 5, + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_path_timeout": { + "recorded-date": "18-04-2024, 06:28:23", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt", + "HeartbeatSecondsPath": 5 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt", + "HeartbeatSecondsPath": 5 + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 5, + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs", + "timeoutInSeconds": 600 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_no_timeout": { + "recorded-date": "18-04-2024, 06:29:19", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "arn::iam::111111111111:role/" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "QueueUrl": "", + "Message": "test_message_txt" + }, + "inputDetails": { + "truncated": false + }, + "name": "SendMessageWithWait" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "heartbeatInSeconds": 5, + "parameters": { + "MessageBody": { + "Message": "test_message_txt", + "TaskToken": "" + }, + "QueueUrl": "" + }, + "region": "", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskSubmittedEventDetails": { + "output": { + "MD5OfMessageBody": "", + "MessageId": "", + "SdkHttpMetadata": { + "AllHttpHeaders": { + "x-amzn-RequestId": [ + "" + ], + "connection": [ + "keep-alive" + ], + "Content-Length": [ + "106" + ], + "Date": "date", + "Content-Type": [ + "application/x-amz-json-1.0" + ] + }, + "HttpHeaders": { + "connection": "keep-alive", + "Content-Length": "106", + "Content-Type": "application/x-amz-json-1.0", + "Date": "date", + "x-amzn-RequestId": "" + }, + "HttpStatusCode": 200 + }, + "SdkResponseMetadata": { + "RequestId": "" + } + }, + "outputDetails": { + "truncated": false + }, + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskSubmitted" + }, + { + "id": 6, + "previousEventId": 5, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "sendMessage.waitForTaskToken", + "resourceType": "sqs" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 7, + "previousEventId": 6, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.validation.json b/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.validation.json new file mode 100644 index 0000000000000..926efd4d3401d --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.validation.json @@ -0,0 +1,11 @@ +{ + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_no_timeout": { + "last_validated_date": "2024-04-18T06:29:19+00:00" + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_path_timeout": { + "last_validated_date": "2024-04-18T06:28:23+00:00" + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_heartbeats.py::TestHeartbeats::test_heartbeat_timeout": { + "last_validated_date": "2024-04-18T06:27:58+00:00" + } +} diff --git a/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py b/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py new file mode 100644 index 0000000000000..0d0c786c54436 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py @@ -0,0 +1,203 @@ +import json + +import pytest +from localstack_snapshot.snapshots.transformer import RegexTransformer + +from localstack.aws.api.lambda_ import Runtime +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.testing.pytest.stepfunctions.utils import ( + await_execution_terminated, + create_and_record_execution, +) +from localstack.utils.strings import short_uid +from tests.aws.services.stepfunctions.templates.base.base_templates import BaseTemplate +from tests.aws.services.stepfunctions.templates.timeouts.timeout_templates import ( + TimeoutTemplates as TT, +) + + +@markers.snapshot.skip_snapshot_verify( + paths=[ + "$..redriveCount", + "$..redriveStatus", + ] +) +class TestTimeouts: + @markers.aws.validated + def test_global_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + ): + snf_role_arn = create_state_machine_iam_role(aws_client) + + template = TT.load_sfn_template(BaseTemplate.BASE_WAIT_1_MIN) + template["TimeoutSeconds"] = 5 + definition = json.dumps(template) + + creation_resp = create_state_machine( + aws_client, + name=f"test_global_timeout-{short_uid()}", + definition=definition, + roleArn=snf_role_arn, + ) + sfn_snapshot.add_transformer(sfn_snapshot.transform.sfn_sm_create_arn(creation_resp, 0)) + state_machine_arn = creation_resp["stateMachineArn"] + + execution_name = f"exec_of-test_global_timeout-{short_uid()}" + sfn_snapshot.add_transformer(RegexTransformer(execution_name, "")) + + exec_resp = aws_client.stepfunctions.start_execution( + stateMachineArn=state_machine_arn, name=execution_name + ) + execution_arn = exec_resp["executionArn"] + + await_execution_terminated( + stepfunctions_client=aws_client.stepfunctions, execution_arn=execution_arn + ) + + describe_execution = aws_client.stepfunctions.describe_execution(executionArn=execution_arn) + sfn_snapshot.match("describe_execution", describe_execution) + + @markers.aws.validated + def test_fixed_timeout_service_lambda( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_1_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TT.LAMBDA_WAIT_60_SECONDS, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = TT.load_sfn_template(TT.SERVICE_LAMBDA_WAIT_WITH_TIMEOUT_SECONDS) + definition = json.dumps(template) + + exec_input = json.dumps( + {"FunctionName": function_name, "Payload": None, "TimeoutSecondsValue": 5} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_fixed_timeout_service_lambda_with_path( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_1_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TT.LAMBDA_WAIT_60_SECONDS, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = TT.load_sfn_template( + TT.SERVICE_LAMBDA_MAP_FUNCTION_INVOKE_WITH_TIMEOUT_SECONDS_PATH + ) + definition = json.dumps(template) + + exec_input = json.dumps( + {"TimeoutSecondsValue": 5, "FunctionName": function_name, "Payload": None} + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @markers.aws.validated + def test_fixed_timeout_lambda( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_1_func_{short_uid()}" + lambda_creation_response = create_lambda_function( + func_name=function_name, + handler_file=TT.LAMBDA_WAIT_60_SECONDS, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + lambda_arn = lambda_creation_response["CreateFunctionResponse"]["FunctionArn"] + + template = TT.load_sfn_template(TT.LAMBDA_WAIT_WITH_TIMEOUT_SECONDS) + template["States"]["Start"]["Resource"] = lambda_arn + definition = json.dumps(template) + + exec_input = json.dumps({"Payload": None}) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) + + @pytest.mark.skipif( + condition=not is_aws_cloud(), reason="Add support for State Map event history first." + ) + @markers.aws.needs_fixing + def test_service_lambda_map_timeout( + self, + aws_client, + create_state_machine_iam_role, + create_state_machine, + create_lambda_function, + sfn_snapshot, + ): + function_name = f"lambda_1_func_{short_uid()}" + create_lambda_function( + func_name=function_name, + handler_file=TT.LAMBDA_WAIT_60_SECONDS, + runtime=Runtime.python3_12, + ) + sfn_snapshot.add_transformer(RegexTransformer(function_name, "")) + + template = TT.load_sfn_template(TT.SERVICE_LAMBDA_MAP_FUNCTION_INVOKE_WITH_TIMEOUT_SECONDS) + definition = json.dumps(template) + + exec_input = json.dumps( + { + "Inputs": [ + {"FunctionName": function_name, "Payload": None}, + {"FunctionName": function_name, "Payload": None}, + {"FunctionName": function_name, "Payload": None}, + {"FunctionName": function_name, "Payload": None}, + ] + } + ) + create_and_record_execution( + aws_client, + create_state_machine_iam_role, + create_state_machine, + sfn_snapshot, + definition, + exec_input, + ) diff --git a/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.snapshot.json b/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.snapshot.json new file mode 100644 index 0000000000000..de839fa915061 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.snapshot.json @@ -0,0 +1,711 @@ +{ + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_lambda": { + "recorded-date": "10-03-2024, 16:23:26", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "lambdaFunctionScheduledEventDetails": { + "input": { + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "resource": "arn::lambda::111111111111:function:", + "timeoutInSeconds": 5 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "LambdaFunctionScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "LambdaFunctionStarted" + }, + { + "id": 5, + "lambdaFunctionTimedOutEventDetails": { + "error": "States.Timeout" + }, + "previousEventId": 4, + "timestamp": "timestamp", + "type": "LambdaFunctionTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_service_lambda": { + "recorded-date": "10-03-2024, 16:47:40", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "FunctionName": "", + "Payload": null, + "TimeoutSecondsValue": 5 + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null, + "TimeoutSecondsValue": 5 + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_service_lambda_map_timeout": { + "recorded-date": "10-03-2024, 16:23:46", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "Inputs": [ + { + "FunctionName": "", + "Payload": null + }, + { + "FunctionName": "", + "Payload": null + }, + { + "FunctionName": "", + "Payload": null + }, + { + "FunctionName": "", + "Payload": null + } + ] + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "Inputs": [ + { + "FunctionName": "", + "Payload": null + }, + { + "FunctionName": "", + "Payload": null + }, + { + "FunctionName": "", + "Payload": null + }, + { + "FunctionName": "", + "Payload": null + } + ] + }, + "inputDetails": { + "truncated": false + }, + "name": "MapWait" + }, + "timestamp": "timestamp", + "type": "MapStateEntered" + }, + { + "id": 3, + "mapStateStartedEventDetails": { + "length": 4 + }, + "previousEventId": 2, + "timestamp": "timestamp", + "type": "MapStateStarted" + }, + { + "id": 4, + "mapIterationStartedEventDetails": { + "index": 0, + "name": "MapWait" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 5, + "mapIterationStartedEventDetails": { + "index": 1, + "name": "MapWait" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 6, + "mapIterationStartedEventDetails": { + "index": 2, + "name": "MapWait" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 7, + "mapIterationStartedEventDetails": { + "index": 3, + "name": "MapWait" + }, + "previousEventId": 3, + "timestamp": "timestamp", + "type": "MapIterationStarted" + }, + { + "id": 8, + "previousEventId": 4, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "LambdaInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 9, + "previousEventId": 8, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 10, + "previousEventId": 5, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "LambdaInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 11, + "previousEventId": 10, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 12, + "previousEventId": 6, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "LambdaInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 13, + "previousEventId": 12, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 14, + "previousEventId": 7, + "stateEnteredEventDetails": { + "input": { + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "LambdaInvoke" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 15, + "previousEventId": 14, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 16, + "previousEventId": 9, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 17, + "previousEventId": 11, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 18, + "previousEventId": 13, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 19, + "previousEventId": 15, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 20, + "previousEventId": 16, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "id": 21, + "previousEventId": 18, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "id": 22, + "previousEventId": 17, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "id": 23, + "previousEventId": 19, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "id": 24, + "mapIterationFailedEventDetails": { + "index": 0, + "name": "MapWait" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationFailed" + }, + { + "id": 25, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 26, + "mapIterationAbortedEventDetails": { + "index": 1, + "name": "MapWait" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationAborted" + }, + { + "id": 27, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 28, + "mapIterationAbortedEventDetails": { + "index": 2, + "name": "MapWait" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationAborted" + }, + { + "id": 29, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 30, + "mapIterationAbortedEventDetails": { + "index": 3, + "name": "MapWait" + }, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapIterationAborted" + }, + { + "id": 31, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "TaskStateAborted" + }, + { + "id": 32, + "previousEventId": 20, + "timestamp": "timestamp", + "type": "MapStateFailed" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 33, + "previousEventId": 32, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_service_lambda_with_path": { + "recorded-date": "10-03-2024, 16:23:06", + "recorded-content": { + "get_execution_history": { + "events": [ + { + "executionStartedEventDetails": { + "input": { + "TimeoutSecondsValue": 5, + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "roleArn": "snf_role_arn" + }, + "id": 1, + "previousEventId": 0, + "timestamp": "timestamp", + "type": "ExecutionStarted" + }, + { + "id": 2, + "previousEventId": 0, + "stateEnteredEventDetails": { + "input": { + "TimeoutSecondsValue": 5, + "FunctionName": "", + "Payload": null + }, + "inputDetails": { + "truncated": false + }, + "name": "Start" + }, + "timestamp": "timestamp", + "type": "TaskStateEntered" + }, + { + "id": 3, + "previousEventId": 2, + "taskScheduledEventDetails": { + "parameters": { + "FunctionName": "", + "Payload": null + }, + "region": "", + "resource": "invoke", + "resourceType": "lambda", + "timeoutInSeconds": 5 + }, + "timestamp": "timestamp", + "type": "TaskScheduled" + }, + { + "id": 4, + "previousEventId": 3, + "taskStartedEventDetails": { + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskStarted" + }, + { + "id": 5, + "previousEventId": 4, + "taskTimedOutEventDetails": { + "error": "States.Timeout", + "resource": "invoke", + "resourceType": "lambda" + }, + "timestamp": "timestamp", + "type": "TaskTimedOut" + }, + { + "executionFailedEventDetails": { + "error": "States.Timeout" + }, + "id": 6, + "previousEventId": 5, + "timestamp": "timestamp", + "type": "ExecutionFailed" + } + ], + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_global_timeout": { + "recorded-date": "10-03-2024, 16:22:15", + "recorded-content": { + "describe_execution": { + "executionArn": "arn::states::111111111111:execution::", + "input": {}, + "inputDetails": { + "included": true + }, + "name": "", + "redriveCount": 0, + "redriveStatus": "REDRIVABLE", + "startDate": "datetime", + "stateMachineArn": "arn::states::111111111111:stateMachine:", + "status": "TIMED_OUT", + "stopDate": "datetime", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.validation.json b/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.validation.json new file mode 100644 index 0000000000000..b2a6099ec2654 --- /dev/null +++ b/tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.validation.json @@ -0,0 +1,17 @@ +{ + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_lambda": { + "last_validated_date": "2024-03-10T16:23:26+00:00" + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_service_lambda": { + "last_validated_date": "2024-03-10T16:47:40+00:00" + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_fixed_timeout_service_lambda_with_path": { + "last_validated_date": "2024-03-10T16:23:06+00:00" + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_global_timeout": { + "last_validated_date": "2024-03-10T16:22:15+00:00" + }, + "tests/aws/services/stepfunctions/v2/timeouts/test_timeouts.py::TestTimeouts::test_service_lambda_map_timeout": { + "last_validated_date": "2024-03-10T16:23:46+00:00" + } +} diff --git a/tests/aws/services/sts/__init__.py b/tests/aws/services/sts/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/sts/test_sts.py b/tests/aws/services/sts/test_sts.py new file mode 100644 index 0000000000000..9e430bbe8b489 --- /dev/null +++ b/tests/aws/services/sts/test_sts.py @@ -0,0 +1,481 @@ +import json +from base64 import b64encode + +import pytest +import requests +from botocore.exceptions import ClientError + +from localstack import config +from localstack.constants import APPLICATION_JSON +from localstack.testing.aws.util import create_client_with_keys +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID +from localstack.testing.pytest import markers +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.numbers import is_number +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import retry + +TEST_SAML_ASSERTION = """ + + + http://localhost/ + + + + + http://localhost:3000/ + + + + + + + + + + + + NTIyMzk0ZGI4MjI0ZjI5ZGNhYjkyOGQyZGQ1NTZjODViZjk5YTY4ODFjOWRjNjkyYzZmODY2ZDQ4NjlkZjY3YSAgLQo= + + + + + NTIyMzk0ZGI4MjI0ZjI5ZGNhYjkyOGQyZGQ1NTZjODViZjk5YTY4ODFjOWRjNjkyYzZmODY2ZDQ4NjlkZjY3YSAgLQo= + + + + + NTIyMzk0ZGI4MjI0ZjI5ZGNhYjkyOGQyZGQ1NTZjODViZjk5YTY4ODFjOWRjNjkyYzZmODY2ZDQ4NjlkZjY3YSAgLQo= + + + + + + + 7ca82df9-1bad-4dd3-9b2b-adb68b554282 + + + + + + + + urn:amazon:webservices + + + + + {fed_name} + + + + arn:aws:iam::{account_id}:saml-provider/{provider_name},arn:aws:iam::{account_id}:role/{role_name} + + + + 900 + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + +""" + + +class TestSTSIntegrations: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..PackedPolicySize", + "$..Role.Tags", # Moto returns an empty list for no tags + ], + ) + def test_assume_role(self, aws_client, create_role, account_id, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.resource_name(), + snapshot.transform.key_value("RoleId"), + snapshot.transform.key_value("AccessKeyId"), + snapshot.transform.key_value("SecretAccessKey"), + snapshot.transform.key_value("SessionToken"), + ] + ) + snapshot.add_transformer(snapshot.transform.key_value("RoleSessionName"), priority=-1) + + test_role_session_name = f"test-assume-role-{short_uid()}" + # we snapshot the test role session name with a transformer in order to validate its presence in the + # `AssumedRoleId` and Γ€rn` of the `AssumedRoleUser` + snapshot.match("role-session-name", {"RoleSessionName": test_role_session_name}) + test_role_name = f"role-{short_uid()}" + assume_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"AWS": account_id}, + "Effect": "Allow", + } + ], + } + created_role = create_role( + RoleName=test_role_name, AssumeRolePolicyDocument=json.dumps(assume_policy_doc) + ) + snapshot.match("create-role", created_role) + + def assume_role(): + assume_role_resp = aws_client.sts.assume_role( + RoleArn=created_role["Role"]["Arn"], RoleSessionName=test_role_session_name + ) + return assume_role_resp + + response = retry(assume_role, sleep=5, retries=4) + snapshot.match("assume-role", response) + + @markers.aws.only_localstack + def test_assume_non_existent_role(self, aws_client): + test_role_session_name = "s3-access-example" + test_role_arn = "arn:aws:sts::000000000000:role/rd_role" + response = aws_client.sts.assume_role( + RoleArn=test_role_arn, RoleSessionName=test_role_session_name + ) + + assert response["Credentials"] + assert response["Credentials"]["SecretAccessKey"] + if response["AssumedRoleUser"]["AssumedRoleId"]: + assume_role_id_parts = response["AssumedRoleUser"]["AssumedRoleId"].split(":") + assert assume_role_id_parts[1] == test_role_session_name + + @markers.aws.only_localstack + def test_assume_role_with_web_identity(self, aws_client): + test_role_session_name = "web_token" + test_role_arn = "arn:aws:sts::000000000000:role/rd_role" + test_web_identity_token = "token" + response = aws_client.sts.assume_role_with_web_identity( + RoleArn=test_role_arn, + RoleSessionName=test_role_session_name, + WebIdentityToken=test_web_identity_token, + ) + + assert response["Credentials"] + assert response["Credentials"]["SecretAccessKey"] + if response["AssumedRoleUser"]["AssumedRoleId"]: + assume_role_id_parts = response["AssumedRoleUser"]["AssumedRoleId"].split(":") + assert assume_role_id_parts[1] == test_role_session_name + + @markers.aws.only_localstack + def test_assume_role_with_saml(self, aws_client): + account_id = "000000000000" + role_name = "test-role" + provider_name = "TestProvFed" + fed_name = "testuser" + + saml_assertion = TEST_SAML_ASSERTION.format( + account_id=account_id, + role_name=role_name, + provider_name=provider_name, + fed_name=fed_name, + ).replace("\n", "") + + role_arn = "arn:aws:iam::{account_id}:role/{role_name}".format( + account_id=account_id, role_name=role_name + ) + principal_arn = "arn:aws:iam:{account_id}:saml-provider/{provider_name}".format( + account_id=account_id, provider_name=provider_name + ) + base64_saml_assertion = b64encode(saml_assertion.encode("utf-8")).decode("utf-8") + response = aws_client.sts.assume_role_with_saml( + RoleArn=role_arn, + PrincipalArn=principal_arn, + SAMLAssertion=base64_saml_assertion, + ) + + assert response["Credentials"] + assert response["Credentials"]["SecretAccessKey"] + if response["AssumedRoleUser"]["AssumedRoleId"]: + assume_role_id_parts = response["AssumedRoleUser"]["AssumedRoleId"].split(":") + assert assume_role_id_parts[1] == fed_name + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..PackedPolicySize"], + ) + def test_get_federation_token(self, aws_client, snapshot): + snapshot.add_transformers_list( + [ + snapshot.transform.resource_name(), + snapshot.transform.key_value("AccessKeyId"), + snapshot.transform.key_value("SecretAccessKey"), + snapshot.transform.key_value("SessionToken"), + ] + ) + token_name = f"TestName{short_uid()}" + response = aws_client.sts.get_federation_token(Name=token_name, DurationSeconds=900) + snapshot.match("get-federation-token", response) + + federated_user_info = response["FederatedUser"]["FederatedUserId"].split(":") + assert federated_user_info[1] == token_name + + @markers.aws.only_localstack + def test_get_caller_identity_root(self, monkeypatch, aws_client): + response = aws_client.sts.get_caller_identity() + account_id = response["Account"] + assert f"arn:aws:iam::{account_id}:root" == response["Arn"] + + @markers.aws.only_localstack + def test_expiration_date_format(self, region_name): + url = config.internal_service_url() + data = {"Action": "GetSessionToken", "Version": "2011-06-15"} + headers = mock_aws_request_headers( + "sts", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + region_name=region_name, + ) + headers["Accept"] = APPLICATION_JSON + response = requests.post(url, data=data, headers=headers) + assert response + content = json.loads(to_str(response.content)) + # Expiration field should be numeric (tested against AWS) + result = content["GetSessionTokenResponse"]["GetSessionTokenResult"] + assert is_number(result["Credentials"]["Expiration"]) + + @markers.aws.only_localstack + @pytest.mark.parametrize("use_aws_creds", [True, False]) + def test_get_caller_identity_user_access_key( + self, cleanups, use_aws_creds, monkeypatch, region_name + ): + """Check whether the correct account id is returned for requests by other users access keys""" + monkeypatch.setattr(config, "PARITY_AWS_ACCESS_KEY_ID", use_aws_creds) + account_id = "123123123123" + account_creds = {"AccessKeyId": account_id, "SecretAccessKey": "test"} + iam_account_client = create_client_with_keys("iam", account_creds, region_name=region_name) + user = iam_account_client.create_user(UserName=f"test-user-{short_uid()}")["User"] + user_name = user["UserName"] + user_arn = user["Arn"] + cleanups.append(lambda: iam_account_client.delete_user(UserName=user_name)) + access_key_response = iam_account_client.create_access_key(UserName=user_name)["AccessKey"] + cleanups.append( + lambda: iam_account_client.delete_access_key( + AccessKeyId=access_key_response["AccessKeyId"], UserName=user_name + ) + ) + + sts_user_client = create_client_with_keys( + "sts", access_key_response, region_name=region_name + ) + response = sts_user_client.get_caller_identity() + assert account_id == response["Account"] + assert user_arn == response["Arn"] + + @markers.aws.only_localstack + @pytest.mark.parametrize("use_aws_creds", [True, False]) + def test_get_caller_identity_role_access_key( + self, aws_client, account_id, cleanups, use_aws_creds, monkeypatch, region_name + ): + """Check whether the correct account id is returned for roles for other accounts""" + monkeypatch.setattr(config, "PARITY_AWS_ACCESS_KEY_ID", use_aws_creds) + fake_account_id = "123123123123" + account_creds = {"AccessKeyId": fake_account_id, "SecretAccessKey": "test"} + iam_account_client = create_client_with_keys("iam", account_creds, region_name=region_name) + sts_account_client = create_client_with_keys("sts", account_creds, region_name=region_name) + assume_policy_doc = { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Principal": {"AWS": [account_id, fake_account_id]}, + "Effect": "Allow", + } + ], + } + role_name = f"test-role-{short_uid()}" + role_arn = iam_account_client.create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(assume_policy_doc) + )["Role"]["Arn"] + cleanups.append(lambda: iam_account_client.delete_role(RoleName=role_name)) + + # assume the role and check if account id is correct + assume_role_response = sts_account_client.assume_role( + RoleArn=role_arn, RoleSessionName=f"test-session-{short_uid()}" + ) + credentials = assume_role_response["Credentials"] + sts_role_client = create_client_with_keys("sts", credentials, region_name=region_name) + response = sts_role_client.get_caller_identity() + assert fake_account_id == response["Account"] + assert assume_role_response["AssumedRoleUser"]["Arn"] == response["Arn"] + + # assume the role coming from another account, to check if the account id is handled properly + assume_role_response_other_account = aws_client.sts.assume_role( + RoleArn=role_arn, RoleSessionName=f"test-session-{short_uid()}" + ) + credentials_other_account = assume_role_response_other_account["Credentials"] + sts_role_client_2 = create_client_with_keys( + "sts", credentials_other_account, region_name=region_name + ) + response = sts_role_client_2.get_caller_identity() + assert fake_account_id == response["Account"] + assert assume_role_response_other_account["AssumedRoleUser"]["Arn"] == response["Arn"] + + +class TestSTSAssumeRoleTagging: + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + def test_iam_role_chaining_override_transitive_tags( + self, + aws_client, + aws_client_factory, + create_role, + snapshot, + region_name, + account_id, + wait_and_assume_role, + ): + snapshot.add_transformer(snapshot.transform.iam_api()) + role_name_1 = f"role-1-{short_uid()}" + role_name_2 = f"role-2-{short_uid()}" + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Principal": {"AWS": account_id}, + } + ], + } + + role_1 = create_role( + RoleName=role_name_1, AssumeRolePolicyDocument=json.dumps(assume_role_policy_document) + ) + snapshot.match("role-1", role_1) + role_2 = create_role( + RoleName=role_name_2, + AssumeRolePolicyDocument=json.dumps(assume_role_policy_document), + ) + snapshot.match("role-2", role_2) + aws_client.iam.put_role_policy( + RoleName=role_name_1, + PolicyName=f"policy-{short_uid()}", + PolicyDocument=json.dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Resource": [role_2["Role"]["Arn"]], + } + ], + } + ), + ) + + # assume role 1 with transitive tags + keys = wait_and_assume_role( + role_arn=role_1["Role"]["Arn"], + session_name="Session1", + Tags=[{"Key": "SessionTag1", "Value": "SessionValue1"}], + TransitiveTagKeys=["SessionTag1"], + ) + role_1_clients = aws_client_factory( + aws_access_key_id=keys["AccessKeyId"], + aws_secret_access_key=keys["SecretAccessKey"], + aws_session_token=keys["SessionToken"], + ) + + # try to assume role 2 by overriding transitive session tags + with pytest.raises(ClientError) as e: + role_1_clients.sts.assume_role( + RoleArn=role_2["Role"]["Arn"], + RoleSessionName="Session2SessionTagOverride", + Tags=[{"Key": "SessionTag1", "Value": "SessionValue2"}], + ) + snapshot.match("override-transitive-tag-error", e.value.response) + + # try to assume role 2 by overriding transitive session tags but with different casing + with pytest.raises(ClientError) as e: + role_1_clients.sts.assume_role( + RoleArn=role_2["Role"]["Arn"], + RoleSessionName="Session2SessionTagOverride", + Tags=[{"Key": "sessiontag1", "Value": "SessionValue2"}], + ) + snapshot.match("override-transitive-tag-case-ignore-error", e.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..Role.Tags"] + ) # Moto returns an empty list for no tags + def test_assume_role_tag_validation( + self, + aws_client, + aws_client_factory, + create_role, + snapshot, + region_name, + account_id, + wait_and_assume_role, + ): + snapshot.add_transformer(snapshot.transform.iam_api()) + role_name_1 = f"role-1-{short_uid()}" + assume_role_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["sts:AssumeRole", "sts:TagSession"], + "Principal": {"AWS": account_id}, + } + ], + } + + role_1 = create_role( + RoleName=role_name_1, AssumeRolePolicyDocument=json.dumps(assume_role_policy_document) + ) + snapshot.match("role-1", role_1) + + # wait until role 1 is ready to be assumed + wait_and_assume_role( + role_arn=role_1["Role"]["Arn"], + session_name="Session1", + ) + with pytest.raises(ClientError) as e: + aws_client.sts.assume_role( + RoleArn=role_1["Role"]["Arn"], + RoleSessionName="SessionInvalidTransitiveKeys", + Tags=[{"Key": "SessionTag1", "Value": "SessionValue1"}], + TransitiveTagKeys=["InvalidKey"], + ) + snapshot.match("invalid-transitive-tag-keys", e.value.response) + + # transitive tags are case insensitive + aws_client.sts.assume_role( + RoleArn=role_1["Role"]["Arn"], + RoleSessionName="SessionInvalidCasingTransitiveKeys", + Tags=[{"Key": "SessionTag1", "Value": "SessionValue1"}], + TransitiveTagKeys=["sessiontag1"], + ) + + # identical tags with different casing in key names are invalid + with pytest.raises(ClientError) as e: + aws_client.sts.assume_role( + RoleArn=role_1["Role"]["Arn"], + RoleSessionName="SessionInvalidCasingTransitiveKeys", + Tags=[ + {"Key": "SessionTag1", "Value": "SessionValue1"}, + {"Key": "sessiontag1", "Value": "SessionValue2"}, + ], + TransitiveTagKeys=["sessiontag1"], + ) + snapshot.match("duplicate-tag-keys-different-casing", e.value.response) diff --git a/tests/aws/services/sts/test_sts.snapshot.json b/tests/aws/services/sts/test_sts.snapshot.json new file mode 100644 index 0000000000000..b9c07c65bc9d5 --- /dev/null +++ b/tests/aws/services/sts/test_sts.snapshot.json @@ -0,0 +1,211 @@ +{ + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_federation_token": { + "recorded-date": "05-06-2024, 13:39:17", + "recorded-content": { + "get-federation-token": { + "Credentials": { + "AccessKeyId": "", + "Expiration": "", + "SecretAccessKey": "", + "SessionToken": "" + }, + "FederatedUser": { + "Arn": "arn::sts::111111111111:federated-user/", + "FederatedUserId": "111111111111:" + }, + "PackedPolicySize": 0, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_role": { + "recorded-date": "05-06-2024, 17:23:49", + "recorded-content": { + "role-session-name": { + "RoleSessionName": "" + }, + "create-role": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": "111111111111" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "/", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "assume-role": { + "AssumedRoleUser": { + "Arn": "arn::sts::111111111111:assumed-role//", + "AssumedRoleId": ":" + }, + "Credentials": { + "AccessKeyId": "", + "Expiration": "", + "SecretAccessKey": "", + "SessionToken": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/sts/test_sts.py::TestSTSAssumeRoleTagging::test_assume_role_tag_validation": { + "recorded-date": "10-04-2025, 08:53:12", + "recorded-content": { + "role-1": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + "sts:TagSession" + ], + "Effect": "Allow", + "Principal": { + "AWS": "111111111111" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "/", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "invalid-transitive-tag-keys": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "The specified transitive tag key must be included in the requested tags.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "duplicate-tag-keys-different-casing": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "Duplicate tag keys found. Please note that Tag keys are case insensitive.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/sts/test_sts.py::TestSTSAssumeRoleTagging::test_iam_role_chaining_override_transitive_tags": { + "recorded-date": "10-04-2025, 08:53:00", + "recorded-content": { + "role-1": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + "sts:TagSession" + ], + "Effect": "Allow", + "Principal": { + "AWS": "111111111111" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "/", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "role-2": { + "Role": { + "Arn": "arn::iam::111111111111:role/", + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole", + "sts:TagSession" + ], + "Effect": "Allow", + "Principal": { + "AWS": "111111111111" + } + } + ], + "Version": "2012-10-17" + }, + "CreateDate": "", + "Path": "/", + "RoleId": "", + "RoleName": "" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "override-transitive-tag-error": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "One of the specified transitive tag keys can't be set because it conflicts with a transitive tag key from the calling session.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "override-transitive-tag-case-ignore-error": { + "Error": { + "Code": "InvalidParameterValue", + "Message": "One of the specified transitive tag keys can't be set because it conflicts with a transitive tag key from the calling session.", + "Type": "Sender" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + } +} diff --git a/tests/aws/services/sts/test_sts.validation.json b/tests/aws/services/sts/test_sts.validation.json new file mode 100644 index 0000000000000..e651d68a58e60 --- /dev/null +++ b/tests/aws/services/sts/test_sts.validation.json @@ -0,0 +1,23 @@ +{ + "tests/aws/services/sts/test_sts.py::TestSTSAssumeRoleTagging::test_assume_role_tag_validation": { + "last_validated_date": "2025-04-10T08:53:12+00:00" + }, + "tests/aws/services/sts/test_sts.py::TestSTSAssumeRoleTagging::test_iam_role_chaining_override_transitive_tags": { + "last_validated_date": "2025-04-10T08:53:00+00:00" + }, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_role": { + "last_validated_date": "2024-06-05T17:23:49+00:00" + }, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_role_invalid_tags": { + "last_validated_date": "2025-04-09T14:30:56+00:00" + }, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_assume_role_tag_validation": { + "last_validated_date": "2025-04-10T08:31:58+00:00" + }, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_get_federation_token": { + "last_validated_date": "2024-06-05T13:39:17+00:00" + }, + "tests/aws/services/sts/test_sts.py::TestSTSIntegrations::test_iam_role_chaining_override_transitive_tags": { + "last_validated_date": "2025-04-10T08:08:37+00:00" + } +} diff --git a/tests/aws/services/support/__init__.py b/tests/aws/services/support/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/support/test_support.py b/tests/aws/services/support/test_support.py new file mode 100644 index 0000000000000..fb52b883f78d5 --- /dev/null +++ b/tests/aws/services/support/test_support.py @@ -0,0 +1,74 @@ +import pytest + +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.testing.pytest import markers + +TEST_SUPPORT_CASE = { + "subject": "TEST CASE-Please ignore", + "serviceCode": "general-info", + "categoryCode": "Service is down", + "ccEmailAddresses": ["my-email-address@example.com"], + "language": "en", +} + + +class TestConfigService: + """ + https://docs.aws.amazon.com/awssupport/latest/user/about-support-api.html + > Important + If you call the CreateCase operation to create test support cases, we recommend that you include a subject line, + such as TEST CASE-Please ignore. After you're done with your test support case, call the ResolveCase operation + to resolve it. + To call the AWS Trusted Advisor operations in the AWS Support API, you must use the US East (N. Virginia) endpoint. + Currently, the US West (Oregon) and Europe (Ireland) endpoints don't support the Trusted Advisor operations. + """ + + @pytest.fixture + def support_client(self, aws_client_factory): + # support is only available in us-east-1 + return aws_client_factory(region_name=AWS_REGION_US_EAST_1).support + + @pytest.fixture + def create_case(self, support_client): + cases = [] + + def _create_case(**kwargs): + response = support_client.create_case(**kwargs) + cases.append(response["caseId"]) + return response + + yield _create_case + + # DescribeCases does not include resolved cases by default + describe_cases = support_client.describe_cases() + open_cases_id = [case["caseId"] for case in describe_cases["cases"]] + for case_id in cases: + if case_id in open_cases_id: + support_client.resolve_case(caseId=case_id) + + @markers.aws.needs_fixing + # we cannot use APIs from AWS Support due to the following: + # An error occurred (SubscriptionRequiredException) when calling the DescribeCases/CreateCase operation: + # Amazon Web Services Premium Support Subscription is required to use this service. + def test_support_case_lifecycle(self, support_client, create_case): + create_case = create_case( + subject=TEST_SUPPORT_CASE["subject"], + serviceCode=TEST_SUPPORT_CASE["serviceCode"], + severityCode="low", + categoryCode=TEST_SUPPORT_CASE["categoryCode"], + communicationBody="Testing support case", + ccEmailAddresses=TEST_SUPPORT_CASE["ccEmailAddresses"], + language=TEST_SUPPORT_CASE["language"], + issueType="technical", + ) + case_id = create_case["caseId"] + + # DescribeCases does not include resolved cases by default + describe_cases = support_client.describe_cases() + cases = describe_cases["cases"] + assert cases[0]["caseId"] == case_id + for key in TEST_SUPPORT_CASE.keys(): + assert cases[0][key] == TEST_SUPPORT_CASE[key] + + response = support_client.resolve_case(caseId=case_id) + assert response["finalCaseStatus"] == "resolved" diff --git a/tests/aws/services/swf/__init__.py b/tests/aws/services/swf/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/swf/test_swf.py b/tests/aws/services/swf/test_swf.py new file mode 100644 index 0000000000000..ba42346656a4e --- /dev/null +++ b/tests/aws/services/swf/test_swf.py @@ -0,0 +1,116 @@ +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + +DEFAULT_TASK_LIST = {"name": "default"} +SWF_VERSION = "1.0" + + +class TestSwf: + # FIXME: This test does not clean up after itself, and does not use fixtures + # It seems you cannot delete an AWS SWF `Domain` after its been registered, only deprecate it + # The `Domain` resource will deprecate all `Workflow` and `Activity` it holds, so this might be useful. + # You cannot delete `Workflow` and `Activity` if they're not deprecated first. + @markers.aws.needs_fixing + def test_run_workflow(self, aws_client): + swf_client = aws_client.swf + + swf_unique_id = short_uid() + workflow_domain_name = "test-swf-domain-{}".format(swf_unique_id) + workflow_type_name = "test-swf-workflow-{}".format(swf_unique_id) + workflow_activity_name = "test-swf-activity-{}".format(swf_unique_id) + + swf_client.register_domain( + name=workflow_domain_name, workflowExecutionRetentionPeriodInDays="1" + ) + + # Given a workflow + swf_client.register_workflow_type( + domain=workflow_domain_name, + name=workflow_type_name, + version=SWF_VERSION, + defaultExecutionStartToCloseTimeout="500", + defaultTaskStartToCloseTimeout="300", + defaultTaskList=DEFAULT_TASK_LIST, + defaultChildPolicy="TERMINATE", + ) + + workflow_types = swf_client.list_workflow_types( + domain=workflow_domain_name, registrationStatus="REGISTERED" + ) + + assert workflow_type_name in ( + workflow_type["workflowType"]["name"] for workflow_type in workflow_types["typeInfos"] + ) + + swf_client.register_activity_type( + domain=workflow_domain_name, + name=workflow_activity_name, + version=SWF_VERSION, + defaultTaskList=DEFAULT_TASK_LIST, + defaultTaskStartToCloseTimeout="NONE", + defaultTaskScheduleToStartTimeout="NONE", + defaultTaskScheduleToCloseTimeout="NONE", + defaultTaskHeartbeatTimeout="100", + ) + + # When workflow is started + workflow_execution = swf_client.start_workflow_execution( + domain=workflow_domain_name, + workflowId=swf_unique_id, + workflowType={"name": workflow_type_name, "version": SWF_VERSION}, + ) + + # Then workflow components execute + decision_task = swf_client.poll_for_decision_task( + domain=workflow_domain_name, taskList=DEFAULT_TASK_LIST + ) + swf_client.respond_decision_task_completed( + taskToken=decision_task["taskToken"], + decisions=[ + { + "decisionType": "ScheduleActivityTask", + "scheduleActivityTaskDecisionAttributes": { + "activityType": { + "name": workflow_activity_name, + "version": SWF_VERSION, + }, + "activityId": "10", + }, + } + ], + ) + activity_task = swf_client.poll_for_activity_task( + domain=workflow_domain_name, taskList=DEFAULT_TASK_LIST + ) + swf_client.respond_activity_task_completed( + taskToken=activity_task["taskToken"], result="activity success" + ) + decision_task = swf_client.poll_for_decision_task( + domain=workflow_domain_name, taskList=DEFAULT_TASK_LIST + ) + swf_client.respond_decision_task_completed( + taskToken=decision_task["taskToken"], + decisions=[ + { + "decisionType": "CompleteWorkflowExecution", + "completeWorkflowExecutionDecisionAttributes": {"result": "workflow success"}, + } + ], + ) + + # Then workflow history has expected events + history = swf_client.get_workflow_execution_history( + domain=workflow_domain_name, + execution={ + "workflowId": swf_unique_id, + "runId": workflow_execution["runId"], + }, + ) + events = (event["eventType"] for event in history["events"]) + for event_type in [ + "WorkflowExecutionStarted", + "DecisionTaskCompleted", + "ActivityTaskCompleted", + "WorkflowExecutionCompleted", + ]: + assert event_type in events diff --git a/tests/aws/services/transcribe/__init__.py b/tests/aws/services/transcribe/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/aws/services/transcribe/test_transcribe.py b/tests/aws/services/transcribe/test_transcribe.py new file mode 100644 index 0000000000000..e3235ade6ce8b --- /dev/null +++ b/tests/aws/services/transcribe/test_transcribe.py @@ -0,0 +1,476 @@ +import logging +import os +import tempfile +import threading +import time +from urllib.parse import urlparse + +import pytest +import requests +from botocore.exceptions import ClientError, ParamValidationError + +from localstack.aws.api.transcribe import BadRequestException, ConflictException, NotFoundException +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.packages.ffmpeg import ffmpeg_package +from localstack.services.transcribe.packages import vosk_package +from localstack.services.transcribe.provider import LANGUAGE_MODELS, TranscribeProvider +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.pytest import markers +from localstack.utils.files import new_tmp_file +from localstack.utils.run import run +from localstack.utils.strings import short_uid, to_str +from localstack.utils.sync import poll_condition, retry +from localstack.utils.threads import start_worker_thread + +BASEDIR = os.path.abspath(os.path.dirname(__file__)) + +LOG = logging.getLogger(__name__) + +# Lock and event to ensure that the installation is executed before the tests +vosk_installed = threading.Event() +ffmpeg_installed = threading.Event() +installation_errored = threading.Event() + +INSTALLATION_TIMEOUT = 5 * 60 +PRE_DOWNLOAD_LANGUAGE_CODE_MODELS = ["en-GB"] + + +def install_async(): + """ + Installs the default ffmpeg and vosk versions in a worker thread. + """ + if vosk_installed.is_set() and ffmpeg_installed.is_set(): + return + + def install_vosk(*args): + if vosk_installed.is_set(): + return + try: + LOG.info("installing Vosk default version") + vosk_package.install() + LOG.info("done installing Vosk default version") + LOG.info("downloading Vosk models used in test: %s", PRE_DOWNLOAD_LANGUAGE_CODE_MODELS) + for language_code in PRE_DOWNLOAD_LANGUAGE_CODE_MODELS: + model_name = LANGUAGE_MODELS[language_code] + # downloading the model takes quite a while sometimes + TranscribeProvider.download_model(model_name) + LOG.info( + "done downloading Vosk model '%s' for language code '%s'", + model_name, + language_code, + ) + LOG.info("done downloading all Vosk models used in test") + except Exception: + LOG.exception("Error during installation of Vosk dependencies") + installation_errored.set() + # we also set the other event to quickly stop the polling + ffmpeg_installed.set() + finally: + vosk_installed.set() + + def install_ffmpeg(*args): + if ffmpeg_installed.is_set(): + return + try: + LOG.info("installing ffmpeg default version") + ffmpeg_package.install() + LOG.info("done ffmpeg default version") + except Exception: + LOG.exception("Error during installation of Vosk dependencies") + installation_errored.set() + # we also set the other event to quickly stop the polling + vosk_installed.set() + finally: + ffmpeg_installed.set() + + # we parallelize the installation of the dependencies + # TODO: we could maybe use a ThreadPoolExecutor to use Future instead of manually checking + start_worker_thread(install_vosk, name="vosk-install-async") + start_worker_thread(install_ffmpeg, name="ffmpeg-install-async") + + +@pytest.fixture(autouse=True) +def transcribe_snapshot_transformer(snapshot): + snapshot.add_transformer(snapshot.transform.transcribe_api()) + + +class TestTranscribe: + @pytest.fixture(scope="class", autouse=True) + def pre_install_dependencies(self): + if not ffmpeg_installed.is_set() or not vosk_installed.is_set(): + install_async() + + start = int(time.time()) + assert vosk_installed.wait(timeout=INSTALLATION_TIMEOUT), ( + "gave up waiting for Vosk to install" + ) + elapsed = int(time.time() - start) + assert ffmpeg_installed.wait(timeout=INSTALLATION_TIMEOUT - elapsed), ( + "gave up waiting for ffmpeg to install" + ) + LOG.info("Spent %s seconds downloading transcribe dependencies", int(time.time() - start)) + + assert not installation_errored.is_set(), "installation of transcribe dependencies failed" + yield + + @staticmethod + def _wait_transcription_job( + transcribe_client: ServiceLevelClientFactory, transcribe_job_name: str + ) -> bool: + def is_transcription_done(): + transcription_job = transcribe_client.get_transcription_job( + TranscriptionJobName=transcribe_job_name + ) + return transcription_job["TranscriptionJob"]["TranscriptionJobStatus"] == "COMPLETED" + + if not poll_condition(condition=is_transcription_done, timeout=60, interval=2): + LOG.warning( + "Timed out while awaiting for transcription of job with transcription job name:'%s'.", + transcribe_job_name, + ) + return False + else: + return True + + @markers.skip_offline + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TranscriptionJob..Settings", + "$..Error..Code", + ] + ) + def test_transcribe_happy_path(self, transcribe_create_job, snapshot, aws_client): + file_path = os.path.join(BASEDIR, "../../files/en-gb.wav") + job_name = transcribe_create_job(audio_file=file_path) + aws_client.transcribe.get_transcription_job(TranscriptionJobName=job_name) + + def is_transcription_done(): + transcription_status = aws_client.transcribe.get_transcription_job( + TranscriptionJobName=job_name + ) + return transcription_status["TranscriptionJob"]["TranscriptionJobStatus"] == "COMPLETED" + + # empirically it takes around + # <5sec for a vosk transcription + # ~100sec for an AWS transcription -> adjust timeout accordingly + assert poll_condition(is_transcription_done, timeout=100), ( + f"could not finish transcription job: {job_name} in time" + ) + + job = aws_client.transcribe.get_transcription_job(TranscriptionJobName=job_name) + snapshot.match("TranscriptionJob", job) + + # delete the job again + aws_client.transcribe.delete_transcription_job(TranscriptionJobName=job_name) + + # check if job is gone + with pytest.raises((ClientError, NotFoundException)) as e_info: + aws_client.transcribe.get_transcription_job(TranscriptionJobName=job_name) + + snapshot.match("GetError", e_info.value.response) + + @pytest.mark.parametrize( + "media_file,speech", + [ + ("../../files/en-gb.amr", "hello my name is"), + ("../../files/en-gb.flac", "hello my name is"), + ("../../files/en-gb.mp3", "hello my name is"), + ("../../files/en-gb.mp4", "hello my name is"), + ("../../files/en-gb.ogg", "hello my name is"), + ("../../files/en-gb.webm", "hello my name is"), + ("../../files/en-us_video.mkv", "one of the most vital"), + ("../../files/en-us_video.mp4", "one of the most vital"), + ], + ) + @markers.aws.needs_fixing + def test_transcribe_supported_media_formats( + self, transcribe_create_job, media_file, speech, aws_client + ): + file_path = os.path.join(BASEDIR, media_file) + job_name = transcribe_create_job(audio_file=file_path) + + def _assert_transcript(): + transcription_status = aws_client.transcribe.get_transcription_job( + TranscriptionJobName=job_name + ) + assert transcription_status["TranscriptionJob"]["TranscriptionJobStatus"] == "COMPLETED" + # Ensure transcript can be retrieved from S3 + s3_uri = urlparse( + transcription_status["TranscriptionJob"]["Transcript"]["TranscriptFileUri"], + allow_fragments=False, + ) + data = aws_client.s3.get_object( + Bucket=s3_uri.path.split("/")[1], + Key="/".join(s3_uri.path.split("/")[2:]).split("?")[0], + ) + content = to_str(data["Body"].read()) + assert speech in content + + retry(_assert_transcript, retries=30, sleep=2) + + @markers.aws.needs_fixing + def test_transcribe_unsupported_media_format_failure(self, transcribe_create_job, aws_client): + # Ensure transcribing an empty file fails + file_path = new_tmp_file() + job_name = transcribe_create_job(audio_file=file_path) + + def _assert_transcript(): + transcription_status = aws_client.transcribe.get_transcription_job( + TranscriptionJobName=job_name + ) + assert transcription_status["TranscriptionJob"]["TranscriptionJobStatus"] == "FAILED" + + retry(_assert_transcript, retries=10, sleep=3) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..TranscriptionJob..Settings", "$..TranscriptionJob..Transcript", "$..Error..Code"] + ) + def test_get_transcription_job(self, transcribe_create_job, snapshot, aws_client): + file_path = os.path.join(BASEDIR, "../../files/en-gb.wav") + job_name = transcribe_create_job(audio_file=file_path) + + job = aws_client.transcribe.get_transcription_job(TranscriptionJobName=job_name) + + snapshot.match("GetJob", job) + + with pytest.raises((ClientError, NotFoundException)) as e_info: + aws_client.transcribe.get_transcription_job(TranscriptionJobName="non-existent") + + snapshot.match("GetError", e_info.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..NextToken", "$..TranscriptionJobSummaries..OutputLocationType"] + ) + def test_list_transcription_jobs(self, transcribe_create_job, snapshot, aws_client): + file_path = os.path.join(BASEDIR, "../../files/en-gb.wav") + transcribe_create_job(audio_file=file_path) + + jobs = aws_client.transcribe.list_transcription_jobs() + + # there are potentially multiple transcription jobs on AWS - ordered by creation date + # we only care about the newest one that we just created + jobs["TranscriptionJobSummaries"] = jobs["TranscriptionJobSummaries"][0] + + snapshot.match("ListJobs", jobs) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..Error..Code"]) + def test_failing_deletion(self, snapshot, aws_client): + # successful deletion is tested in the happy path test + # this tests a failed deletion + with pytest.raises((ClientError, NotFoundException)) as e_info: + aws_client.transcribe.delete_transcription_job(TranscriptionJobName="non-existent") + + snapshot.match("MissingLanguageCode", e_info.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..MissingLanguageCode..Message", "$..MalformedLanguageCode..Message"] + ) + def test_failing_start_transcription_job(self, s3_bucket, snapshot, aws_client): + transcription_job = f"test-transcribe-{short_uid()}" + test_key = "test-clip.wav" + file_path = os.path.join(BASEDIR, "../../files/en-gb.wav") + + with open(file_path, "rb") as f: + aws_client.s3.upload_fileobj(f, s3_bucket, test_key) + + # missing language code + with pytest.raises((ClientError, BadRequestException)) as e_info: + aws_client.transcribe.start_transcription_job( + TranscriptionJobName=transcription_job, + Media={"MediaFileUri": f"s3://{s3_bucket}/{test_key}"}, + ) + + snapshot.match("MissingLanguageCode", e_info.value.response) + + # malformed language code + language_code = "non-existent" + with pytest.raises((ClientError, BadRequestException)) as e_info: + aws_client.transcribe.start_transcription_job( + TranscriptionJobName=transcription_job, + LanguageCode=language_code, + Media={"MediaFileUri": f"s3://{s3_bucket}/{test_key}"}, + ) + snapshot.match("MalformedLanguageCode", e_info.value.response) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=["$..TranscriptionJob..Settings", "$..TranscriptionJob..Transcript"] + ) + @pytest.mark.parametrize( + "output_bucket,output_key", + [ + ("test-output-bucket-2", None), # with output bucket and without output key + ( + "test-output-bucket-3", + "test-output", + ), # with output bucket and output key without .json + ( + "test-output-bucket-4", + "test-output.json", + ), # with output bucket and output key with .json + ( + "test-output-bucket-5", + "test-files/test-output.json", + ), # with output bucket and with folder key with .json + ( + "test-output-bucket-6", + "test-files/test-output", + ), # with output bucket and with folder key without .json + (None, None), # without output bucket and output key + ], + ) + def test_transcribe_start_job( + self, + output_bucket, + output_key, + s3_bucket, + s3_create_bucket, + cleanups, + snapshot, + aws_client, + ): + file_path = os.path.join(BASEDIR, "../../files/en-gb.wav") + test_key = "test-clip.wav" + transcribe_job_name = f"test-transcribe-job-{short_uid()}" + params = { + "TranscriptionJobName": transcribe_job_name, + "LanguageCode": "en-GB", + "Media": {"MediaFileUri": f"s3://{s3_bucket}/{test_key}"}, + } + + def _cleanup(): + objects = aws_client.s3.list_objects_v2(Bucket=output_bucket) + if "Contents" in objects: + for obj in objects["Contents"]: + aws_client.s3.delete_object(Bucket=output_bucket, Key=obj["Key"]) + aws_client.s3.delete_bucket(Bucket=output_bucket) + + if output_bucket is not None: + params["OutputBucketName"] = output_bucket + s3_create_bucket(Bucket=output_bucket) + cleanups.append(_cleanup) + if output_key is not None: + params["OutputKey"] = output_key + + with open(file_path, "rb") as f: + aws_client.s3.upload_fileobj(f, s3_bucket, test_key) + + response_start_job = aws_client.transcribe.start_transcription_job(**params) + self._wait_transcription_job(aws_client.transcribe, params["TranscriptionJobName"]) + snapshot.match("response-start-job", response_start_job) + response_get_transcribe_job = aws_client.transcribe.get_transcription_job( + TranscriptionJobName=transcribe_job_name + ) + snapshot.match("response-get-transcribe-job", response_get_transcribe_job) + + res_delete_transcription_job = aws_client.transcribe.delete_transcription_job( + TranscriptionJobName=transcribe_job_name + ) + snapshot.match("delete-transcription-job", res_delete_transcription_job) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify(paths=["$..TranscriptionJob..Transcript"]) + def test_transcribe_start_job_same_name( + self, + s3_bucket, + snapshot, + aws_client, + ): + file_path = os.path.join(BASEDIR, "../../files/en-gb.wav") + test_key = "test-clip.wav" + transcribe_job_name = f"test-transcribe-job-{short_uid()}" + params = { + "TranscriptionJobName": transcribe_job_name, + "LanguageCode": "en-GB", + "Media": {"MediaFileUri": f"s3://{s3_bucket}/{test_key}"}, + } + + with open(file_path, "rb") as f: + aws_client.s3.upload_fileobj(f, s3_bucket, test_key) + + response_start_job = aws_client.transcribe.start_transcription_job(**params) + snapshot.match("response-start-job", response_start_job) + + self._wait_transcription_job(aws_client.transcribe, params["TranscriptionJobName"]) + + with pytest.raises((ClientError, ConflictException)) as e: + aws_client.transcribe.start_transcription_job(**params) + + snapshot.match("same-transcription-job-name", e.value.response) + + res_delete_transcription_job = aws_client.transcribe.delete_transcription_job( + TranscriptionJobName=transcribe_job_name + ) + snapshot.match("delete-transcription-job", res_delete_transcription_job) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Not yet implemented on LS") + def test_transcribe_speaker_diarization(self, transcribe_create_job, aws_client, snapshot): + media_file = "../../files/multi-speaker.wav" + file_path = os.path.join(BASEDIR, media_file) + max_speakers = 2 + settings = {"Settings": {"MaxSpeakerLabels": max_speakers, "ShowSpeakerLabels": True}} + + job_name = transcribe_create_job(audio_file=file_path, params=settings) + + def _is_transcription_done(): + resp = aws_client.transcribe.get_transcription_job(TranscriptionJobName=job_name) + assert resp["TranscriptionJob"]["TranscriptionJobStatus"] == "COMPLETED" + return resp + + resp = retry(_is_transcription_done, retries=50, sleep=2) + + response = requests.get(resp["TranscriptionJob"]["Transcript"]["TranscriptFileUri"]) + response.raise_for_status() + content = response.json() + snapshot.match("transcribe_speaker_diarization", content) + + @markers.aws.validated + @pytest.mark.skipif(condition=not is_aws_cloud(), reason="Not yet implemented on LS") + def test_transcribe_error_speaker_labels(self, transcribe_create_job, aws_client, snapshot): + media_file = "../../files/multi-speaker.wav" + max_speakers = 1 + file_path = os.path.join(BASEDIR, media_file) + settings = {"Settings": {"MaxSpeakerLabels": max_speakers, "ShowSpeakerLabels": True}} + + with pytest.raises(ParamValidationError) as e: + transcribe_create_job(audio_file=file_path, params=settings) + snapshot.match("err_speaker_labels_diarization", e.value) + + @markers.aws.validated + @markers.snapshot.skip_snapshot_verify( + paths=[ + "$..TranscriptionJob..Settings", + "$..TranscriptionJob..Transcript", + "$..TranscriptionJob..MediaFormat", + ] + ) + def test_transcribe_error_invalid_length(self, transcribe_create_job, aws_client, snapshot): + ffmpeg_bin = ffmpeg_package.get_installer().get_ffmpeg_path() + media_file = os.path.join(tempfile.gettempdir(), "audio_4h.mp3") + + run( + f"{ffmpeg_bin} -f lavfi -i anullsrc=r=44100:cl=mono -t 14400 -q:a 9 -acodec libmp3lame {media_file}" + ) + job_name = transcribe_create_job(audio_file=media_file) + + def _is_transcription_done(): + transcription_status = aws_client.transcribe.get_transcription_job( + TranscriptionJobName=job_name + ) + return transcription_status["TranscriptionJob"]["TranscriptionJobStatus"] == "FAILED" + + # empirically it takes around + # <5sec for a vosk transcription + # ~100sec for an AWS transcription -> adjust timeout accordingly + assert poll_condition(_is_transcription_done, timeout=100), ( + f"could not finish transcription job: {job_name} in time" + ) + + job = aws_client.transcribe.get_transcription_job(TranscriptionJobName=job_name) + snapshot.match("TranscribeErrorInvalidLength", job) diff --git a/tests/aws/services/transcribe/test_transcribe.snapshot.json b/tests/aws/services/transcribe/test_transcribe.snapshot.json new file mode 100644 index 0000000000000..8a879cea33edd --- /dev/null +++ b/tests/aws/services/transcribe/test_transcribe.snapshot.json @@ -0,0 +1,924 @@ +{ + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_get_transcription_job": { + "recorded-date": "06-10-2023, 17:11:58", + "recorded-content": { + "GetJob": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": {}, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "GetError": { + "Error": { + "Code": "BadRequestException", + "Message": "The requested job couldn't be found. Check the job name and try your request again." + }, + "Message": "The requested job couldn't be found. Check the job name and try your request again.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_failing_start_transcription_job": { + "recorded-date": "06-10-2023, 17:07:27", + "recorded-content": { + "MissingLanguageCode": { + "Error": { + "Code": "BadRequestException", + "Message": "The language code is missing. Either add a language code or turn on language identification." + }, + "Message": "The language code is missing. Either add a language code or turn on language identification.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "MalformedLanguageCode": { + "Error": { + "Code": "BadRequestException", + "Message": "1 validation error detected: Value 'non-existent' at 'languageCode' failed to satisfy constraint: Member must satisfy enum value set: [en-IE, ar-AE, te-IN, zh-TW, en-US, ta-IN, en-AB, en-IN, zh-CN, ar-SA, en-ZA, gd-GB, th-TH, tr-TR, ru-RU, pt-PT, nl-NL, it-IT, id-ID, fr-FR, es-ES, de-DE, ga-IE, af-ZA, en-NZ, ko-KR, hi-IN, de-CH, vi-VN, cy-GB, ms-MY, he-IL, da-DK, en-AU, pt-BR, en-WL, fa-IR, sv-SE, ja-JP, es-US, fr-CA, en-GB]" + }, + "Message": "1 validation error detected: Value 'non-existent' at 'languageCode' failed to satisfy constraint: Member must satisfy enum value set: [en-IE, ar-AE, te-IN, zh-TW, en-US, ta-IN, en-AB, en-IN, zh-CN, ar-SA, en-ZA, gd-GB, th-TH, tr-TR, ru-RU, pt-PT, nl-NL, it-IT, id-ID, fr-FR, es-ES, de-DE, ga-IE, af-ZA, en-NZ, ko-KR, hi-IN, de-CH, vi-VN, cy-GB, ms-MY, he-IL, da-DK, en-AU, pt-BR, en-WL, fa-IR, sv-SE, ja-JP, es-US, fr-CA, en-GB]", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_failing_deletion": { + "recorded-date": "06-10-2023, 17:10:47", + "recorded-content": { + "MissingLanguageCode": { + "Error": { + "Code": "BadRequestException", + "Message": "The requested job couldn't be found. Check the job name and try your request again." + }, + "Message": "The requested job couldn't be found. Check the job name and try your request again.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_happy_path": { + "recorded-date": "06-10-2023, 17:09:11", + "recorded-content": { + "TranscriptionJob": { + "TranscriptionJob": { + "CompletionTime": "datetime", + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "MediaFormat": "wav", + "MediaSampleRateHertz": 22050, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": { + "TranscriptFileUri": "" + }, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "COMPLETED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "GetError": { + "Error": { + "Code": "BadRequestException", + "Message": "The requested job couldn't be found. Check the job name and try your request again." + }, + "Message": "The requested job couldn't be found. Check the job name and try your request again.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_list_transcription_jobs": { + "recorded-date": "06-10-2023, 17:11:25", + "recorded-content": { + "ListJobs": { + "TranscriptionJobSummaries": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "OutputLocationType": "SERVICE_BUCKET", + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[None-None]": { + "recorded-date": "03-05-2023, 20:04:18", + "recorded-content": { + "response-start-job": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-get-transcribe-job": { + "TranscriptionJob": { + "CompletionTime": "datetime", + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "MediaFormat": "wav", + "MediaSampleRateHertz": 22050, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": { + "TranscriptFileUri": "" + }, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "COMPLETED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-transcription-job": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-2-None]": { + "recorded-date": "03-05-2023, 20:01:19", + "recorded-content": { + "response-start-job": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-get-transcribe-job": { + "TranscriptionJob": { + "CompletionTime": "datetime", + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "MediaFormat": "wav", + "MediaSampleRateHertz": 22050, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": { + "TranscriptFileUri": "" + }, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "COMPLETED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-transcription-job": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-3-test-output]": { + "recorded-date": "03-05-2023, 20:01:44", + "recorded-content": { + "response-start-job": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-get-transcribe-job": { + "TranscriptionJob": { + "CompletionTime": "datetime", + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "MediaFormat": "wav", + "MediaSampleRateHertz": 22050, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": { + "TranscriptFileUri": "" + }, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "COMPLETED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-transcription-job": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-4-test-output.json]": { + "recorded-date": "03-05-2023, 20:02:06", + "recorded-content": { + "response-start-job": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-get-transcribe-job": { + "TranscriptionJob": { + "CompletionTime": "datetime", + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "MediaFormat": "wav", + "MediaSampleRateHertz": 22050, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": { + "TranscriptFileUri": "" + }, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "COMPLETED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-transcription-job": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-5-test-files/test-output.json]": { + "recorded-date": "03-05-2023, 20:03:12", + "recorded-content": { + "response-start-job": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-get-transcribe-job": { + "TranscriptionJob": { + "CompletionTime": "datetime", + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "MediaFormat": "wav", + "MediaSampleRateHertz": 22050, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": { + "TranscriptFileUri": "" + }, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "COMPLETED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-transcription-job": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-6-test-files/test-output]": { + "recorded-date": "03-05-2023, 20:03:34", + "recorded-content": { + "response-start-job": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "response-get-transcribe-job": { + "TranscriptionJob": { + "CompletionTime": "datetime", + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "MediaFormat": "wav", + "MediaSampleRateHertz": 22050, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": { + "TranscriptFileUri": "" + }, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "COMPLETED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "delete-transcription-job": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job_same_name": { + "recorded-date": "09-05-2023, 22:50:14", + "recorded-content": { + "response-start-job": { + "TranscriptionJob": { + "CreationTime": "datetime", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "StartTime": "datetime", + "TranscriptionJobName": "", + "TranscriptionJobStatus": "IN_PROGRESS" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "same-transcription-job-name": { + "Error": { + "Code": "ConflictException", + "Message": "The requested job name already exists. Use a different job name." + }, + "Message": "The requested job name already exists. Use a different job name.", + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 400 + } + }, + "delete-transcription-job": { + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_speaker_diarization": { + "recorded-date": "20-03-2025, 04:44:53", + "recorded-content": { + "transcribe_speaker_diarization": { + "accountId": "111111111111", + "jobName": "", + "results": { + "audio_segments": [ + { + "end_time": "2.19", + "id": 0, + "items": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "speaker_label": "spk_0", + "start_time": "0.0", + "transcript": "Hey, I am using LocalStack." + }, + { + "end_time": "5.86", + "id": 1, + "items": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "speaker_label": "spk_1", + "start_time": "2.559", + "transcript": "Yeah, it's a great tool to emulate the cloud services." + } + ], + "items": [ + { + "alternatives": [ + { + "confidence": "0.998", + "content": "Hey" + } + ], + "end_time": "0.389", + "id": 0, + "speaker_label": "spk_0", + "start_time": "0.119", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.0", + "content": "," + } + ], + "id": 1, + "speaker_label": "spk_0", + "type": "punctuation" + }, + { + "alternatives": [ + { + "confidence": "0.999", + "content": "I" + } + ], + "end_time": "0.68", + "id": 2, + "speaker_label": "spk_0", + "start_time": "0.479", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.997", + "content": "am" + } + ], + "end_time": "0.879", + "id": 3, + "speaker_label": "spk_0", + "start_time": "0.68", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.999", + "content": "using" + } + ], + "end_time": "1.279", + "id": 4, + "speaker_label": "spk_0", + "start_time": "0.879", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.438", + "content": "LocalStack" + } + ], + "end_time": "2.19", + "id": 5, + "speaker_label": "spk_0", + "start_time": "1.279", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.0", + "content": "." + } + ], + "id": 6, + "speaker_label": "spk_0", + "type": "punctuation" + }, + { + "alternatives": [ + { + "confidence": "0.997", + "content": "Yeah" + } + ], + "end_time": "2.759", + "id": 7, + "speaker_label": "spk_1", + "start_time": "2.559", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.0", + "content": "," + } + ], + "id": 8, + "speaker_label": "spk_1", + "type": "punctuation" + }, + { + "alternatives": [ + { + "confidence": "0.993", + "content": "it's" + } + ], + "end_time": "3.079", + "id": 9, + "speaker_label": "spk_1", + "start_time": "2.92", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.999", + "content": "a" + } + ], + "end_time": "3.279", + "id": 10, + "speaker_label": "spk_1", + "start_time": "3.079", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.999", + "content": "great" + } + ], + "end_time": "3.64", + "id": 11, + "speaker_label": "spk_1", + "start_time": "3.279", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.998", + "content": "tool" + } + ], + "end_time": "3.92", + "id": 12, + "speaker_label": "spk_1", + "start_time": "3.64", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.999", + "content": "to" + } + ], + "end_time": "4.079", + "id": 13, + "speaker_label": "spk_1", + "start_time": "3.92", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.999", + "content": "emulate" + } + ], + "end_time": "4.559", + "id": 14, + "speaker_label": "spk_1", + "start_time": "4.079", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.998", + "content": "the" + } + ], + "end_time": "4.679", + "id": 15, + "speaker_label": "spk_1", + "start_time": "4.559", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.994", + "content": "cloud" + } + ], + "end_time": "5.159", + "id": 16, + "speaker_label": "spk_1", + "start_time": "4.679", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.998", + "content": "services" + } + ], + "end_time": "5.76", + "id": 17, + "speaker_label": "spk_1", + "start_time": "5.159", + "type": "pronunciation" + }, + { + "alternatives": [ + { + "confidence": "0.0", + "content": "." + } + ], + "id": 18, + "speaker_label": "spk_1", + "type": "punctuation" + } + ], + "speaker_labels": { + "channel_label": "ch_0", + "segments": [ + { + "end_time": "2.19", + "items": [ + { + "end_time": "0.389", + "speaker_label": "spk_0", + "start_time": "0.119" + }, + { + "end_time": "0.68", + "speaker_label": "spk_0", + "start_time": "0.479" + }, + { + "end_time": "0.879", + "speaker_label": "spk_0", + "start_time": "0.68" + }, + { + "end_time": "1.279", + "speaker_label": "spk_0", + "start_time": "0.879" + }, + { + "end_time": "2.19", + "speaker_label": "spk_0", + "start_time": "1.279" + } + ], + "speaker_label": "spk_0", + "start_time": "0.0" + }, + { + "end_time": "5.86", + "items": [ + { + "end_time": "2.759", + "speaker_label": "spk_1", + "start_time": "2.559" + }, + { + "end_time": "3.079", + "speaker_label": "spk_1", + "start_time": "2.92" + }, + { + "end_time": "3.279", + "speaker_label": "spk_1", + "start_time": "3.079" + }, + { + "end_time": "3.64", + "speaker_label": "spk_1", + "start_time": "3.279" + }, + { + "end_time": "3.92", + "speaker_label": "spk_1", + "start_time": "3.64" + }, + { + "end_time": "4.079", + "speaker_label": "spk_1", + "start_time": "3.92" + }, + { + "end_time": "4.559", + "speaker_label": "spk_1", + "start_time": "4.079" + }, + { + "end_time": "4.679", + "speaker_label": "spk_1", + "start_time": "4.559" + }, + { + "end_time": "5.159", + "speaker_label": "spk_1", + "start_time": "4.679" + }, + { + "end_time": "5.76", + "speaker_label": "spk_1", + "start_time": "5.159" + } + ], + "speaker_label": "spk_1", + "start_time": "2.559" + } + ], + "speakers": 2 + }, + "transcripts": [ + { + "transcript": "Hey, I am using LocalStack. Yeah, it's a great tool to emulate the cloud services." + } + ] + }, + "status": "COMPLETED" + } + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_error_speaker_labels": { + "recorded-date": "19-03-2025, 15:42:09", + "recorded-content": { + "err_speaker_labels_diarization": "Parameter validation failed:\nInvalid value for parameter Settings.MaxSpeakerLabels, value: 1, valid min value: 2" + } + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_error_invalid_length": { + "recorded-date": "12-04-2025, 16:02:39", + "recorded-content": { + "TranscribeErrorInvalidLength": { + "TranscriptionJob": { + "CreationTime": "datetime", + "FailureReason": "Invalid file size: file size too large. Maximum audio duration is 4.000000 hours.Check the length of the file and try your request again.", + "LanguageCode": "en-GB", + "Media": { + "MediaFileUri": "s3://test-clip.wav" + }, + "Settings": { + "ChannelIdentification": false, + "ShowAlternatives": false + }, + "StartTime": "datetime", + "Transcript": {}, + "TranscriptionJobName": "", + "TranscriptionJobStatus": "FAILED" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } + } +} diff --git a/tests/aws/services/transcribe/test_transcribe.validation.json b/tests/aws/services/transcribe/test_transcribe.validation.json new file mode 100644 index 0000000000000..d013e9960e42d --- /dev/null +++ b/tests/aws/services/transcribe/test_transcribe.validation.json @@ -0,0 +1,53 @@ +{ + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_failing_deletion": { + "last_validated_date": "2023-10-06T15:10:47+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_failing_start_transcription_job": { + "last_validated_date": "2023-10-06T15:07:27+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_get_transcription_job": { + "last_validated_date": "2023-10-06T15:11:58+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_list_transcription_jobs": { + "last_validated_date": "2023-10-06T15:11:25+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_error_invalid_length": { + "last_validated_date": "2025-04-12T16:02:38+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_error_speaker_labels": { + "last_validated_date": "2025-03-19T15:42:06+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_happy_path": { + "last_validated_date": "2023-10-06T15:09:11+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_speaker_diarization": { + "last_validated_date": "2025-03-20T04:44:50+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_speaker_diarization[../../files/multi-speaker.wav-1]": { + "last_validated_date": "2025-03-19T10:27:09+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_speaker_diarization[../../files/multi-speaker.wav-2]": { + "last_validated_date": "2025-03-19T10:26:57+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[None-None]": { + "last_validated_date": "2023-05-03T18:04:18+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-2-None]": { + "last_validated_date": "2023-05-03T18:01:19+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-3-test-output]": { + "last_validated_date": "2023-05-03T18:01:44+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-4-test-output.json]": { + "last_validated_date": "2023-05-03T18:02:06+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-5-test-files/test-output.json]": { + "last_validated_date": "2023-05-03T18:03:12+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job[test-output-bucket-6-test-files/test-output]": { + "last_validated_date": "2023-05-03T18:03:34+00:00" + }, + "tests/aws/services/transcribe/test_transcribe.py::TestTranscribe::test_transcribe_start_job_same_name": { + "last_validated_date": "2023-05-09T20:50:14+00:00" + } +} diff --git a/tests/aws/templates/apigateway-url-output.yaml b/tests/aws/templates/apigateway-url-output.yaml new file mode 100644 index 0000000000000..44c31edae46af --- /dev/null +++ b/tests/aws/templates/apigateway-url-output.yaml @@ -0,0 +1,78 @@ +Resources: + apiC8550315: + Type: AWS::ApiGateway::RestApi + Properties: + Name: {{ api_name }} + apiCloudWatchRoleAC81D93E: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + apiAccount57E28B43: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: + Fn::GetAtt: + - apiCloudWatchRoleAC81D93E + - Arn + DependsOn: + - apiC8550315 + apiDeployment149F1294451e98552b666b9a7a9c18bdf7f51246: + Type: AWS::ApiGateway::Deployment + Properties: + RestApiId: + Ref: apiC8550315 + Description: Automatically created by the RestApi construct + DependsOn: + - apiGETECF0BD67 + apiDeploymentStageprod896C8101: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: + Ref: apiC8550315 + DeploymentId: + Ref: apiDeployment149F1294451e98552b666b9a7a9c18bdf7f51246 + StageName: prod + apiGETECF0BD67: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: GET + ResourceId: + Fn::GetAtt: + - apiC8550315 + - RootResourceId + RestApiId: + Ref: apiC8550315 + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: GET + Type: HTTP_PROXY + Uri: {{ integration_uri }} +Outputs: + ApiV1UrlOutput: + Value: + Fn::Join: + - "" + - - https:// + - Ref: apiC8550315 + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - / + - Ref: apiDeploymentStageprod896C8101 + - / + ApiV1IdOutput: + Value: + Ref: apiC8550315 diff --git a/tests/aws/templates/apigateway.json b/tests/aws/templates/apigateway.json new file mode 100644 index 0000000000000..5b15fa054e39d --- /dev/null +++ b/tests/aws/templates/apigateway.json @@ -0,0 +1,130 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Metadata": { + "AWS::CloudFormation::Designer": { + "4e9c399b-368e-4495-a045-96af5bbee06f": { + "size": { + "width": 150, + "height": 150 + }, + "position": { + "x": 30, + "y": 150 + }, + "z": 1, + "embeds": [] + }, + "142518f2-8391-448a-a136-2ebef0aec574": { + "size": { + "width": 60, + "height": 60 + }, + "position": { + "x": 70, + "y": 30 + }, + "z": 1, + "embeds": [], + "iscontainedinside": [ + "4e9c399b-368e-4495-a045-96af5bbee06f" + ] + }, + "2aab1c39-8387-4dce-8c2f-7e23dd8b15bc": { + "source": { + "id": "142518f2-8391-448a-a136-2ebef0aec574" + }, + "target": { + "id": "4e9c399b-368e-4495-a045-96af5bbee06f" + }, + "z": 11 + } + } + }, + "Resources": { + "AGRA16EMH": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": { + "Fn::Join": [ + "_", + [ + "DemoApi", + { + "Ref": "StageName" + } + ] + ] + }, + "BinaryMediaTypes": [ + "image/jpg", + "image/png" + ] + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "4e9c399b-368e-4495-a045-96af5bbee06f" + } + }, + "Condition": "Create" + }, + "AGM1P07R": { + "Type": "AWS::ApiGateway::Model", + "Properties": { + "RestApiId": { + "Ref": "AGRA16EMH" + }, + "ContentType": "application/json", + "Schema": { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "PetsModelNoFlatten", + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "integer" + }, + "class": { + "type": "string" + }, + "salesPrice": { + "type": "number" + } + } + } + } + }, + "Metadata": { + "AWS::CloudFormation::Designer": { + "id": "142518f2-8391-448a-a136-2ebef0aec574" + } + }, + "Condition": "Create" + } + }, + "Conditions": { + "Create": { + "Fn::Equals": [ + { + "Ref": "Create" + }, + "True" + ] + } + }, + "Parameters": { + "Create": { + "Type": "String", + "Default": "False", + "AllowedValues": [ + "True", + "False" + ], + "Description": "True to create all of the things." + }, + "StageName": { + "Type": "String", + "Default": "dev" + } + } +} diff --git a/tests/aws/templates/apigateway_account.yml b/tests/aws/templates/apigateway_account.yml new file mode 100644 index 0000000000000..cadeec6b1b4d0 --- /dev/null +++ b/tests/aws/templates/apigateway_account.yml @@ -0,0 +1,35 @@ +Resources: + CloudWatchRole: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - apigateway.amazonaws.com + Action: 'sts:AssumeRole' + Path: / + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + Account: + Type: 'AWS::ApiGateway::Account' + Properties: + CloudWatchRoleArn: !GetAtt + - CloudWatchRole + - Arn + +Outputs: + RoleArn: + Value: + Fn::GetAtt: + - CloudWatchRole + - Arn + AccountId: + Value: + Ref: Account diff --git a/tests/aws/templates/apigateway_integration_from_s3.yml b/tests/aws/templates/apigateway_integration_from_s3.yml new file mode 100644 index 0000000000000..e8a6ef7c42963 --- /dev/null +++ b/tests/aws/templates/apigateway_integration_from_s3.yml @@ -0,0 +1,62 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: The AWS CloudFormation template for this Serverless application +Parameters: + S3BodyBucket: + Type: String + S3BodyKey: + Type: String + S3BodyETag: + Type: String + +Resources: + ApiGatewayRestApi: + Type: AWS::ApiGateway::RestApi + Properties: + BinaryMediaTypes: + - "image/gif" + - "application/pdf" + BodyS3Location: + Bucket: + Ref: S3BodyBucket + Key: + Ref: S3BodyKey + ETag: + Ref: S3BodyETag + + EndpointConfiguration: + Types: + - REGIONAL + + ApiGWDeployment: + Type: AWS::ApiGateway::Deployment + Properties: + Description: foobar + RestApiId: + Ref: ApiGatewayRestApi + + ApiGWStage: + Type: AWS::ApiGateway::Stage + Properties: + Description: Test Stage 123 + DeploymentId: + Ref: ApiGWDeployment + RestApiId: + Ref: ApiGatewayRestApi + StageName: local + TracingEnabled: true + MethodSettings: + - LoggingLevel: ERROR + DataTraceEnabled: true + HttpMethod: "*" + MetricsEnabled: true + ResourcePath: "/*" + Variables: + TestCasing: "myvar" + testCasingTwo: "myvar2" + testlowcasing: "myvar3" + + +Outputs: + RestApiId: + Value: + Ref: ApiGatewayRestApi diff --git a/tests/aws/templates/apigateway_integration_no_authorizer.yml b/tests/aws/templates/apigateway_integration_no_authorizer.yml new file mode 100644 index 0000000000000..11c80b8f6f646 --- /dev/null +++ b/tests/aws/templates/apigateway_integration_no_authorizer.yml @@ -0,0 +1,105 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: The AWS CloudFormation template for this Serverless application +Parameters: + RestApiName: + Type: String + Default: ApiGatewayRestApi + +Resources: + ApiGatewayApiKey: + Type: AWS::ApiGateway::ApiKey + Properties: + Name: ApiGatewayApiKey421 + Value: test123test123test123 + ApiGatewayUsagePlan: + Type: AWS::ApiGateway::UsagePlan + Properties: + Quota: + Limit: '5000' + Period: MONTH + ApiStages: + - ApiId: + Ref: ApiGatewayRestApi + Stage: + Ref: ApiGWStage + Throttle: + BurstLimit: '500' + RateLimit: '1000' + ApiGatewayUsagePlanKey: + Type: AWS::ApiGateway::UsagePlanKey + Properties: + KeyId: + Ref: ApiGatewayApiKey + KeyType: API_KEY + UsagePlanId: + Ref: ApiGatewayUsagePlan + ApiGatewayRestApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: + Ref: RestApiName + EndpointConfiguration: + Types: + - EDGE + ProxyResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: + Fn::GetAtt: + - ApiGatewayRestApi + - RootResourceId + PathPart: testproxy + RestApiId: + Ref: ApiGatewayRestApi + ProxyMethod: + Type: AWS::ApiGateway::Method + Properties: + AuthorizationType: NONE + ResourceId: + Ref: ProxyResource + RestApiId: + Ref: ApiGatewayRestApi + HttpMethod: GET + MethodResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Origin: true + method.response.header.Access-Control-Allow-Headers: true + method.response.header.Access-Control-Allow-Methods: true + Integration: + IntegrationHttpMethod: GET + Type: HTTP_PROXY + Uri: http://www.example.com + IntegrationResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Origin: "'*'" + method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent,X-Amzn-Trace-Id'" + method.response.header.Access-Control-Allow-Methods: "'OPTIONS,GET,POST'" + + ApiGWDeployment: + Type: AWS::ApiGateway::Deployment + Properties: + Description: foobar + RestApiId: + Ref: ApiGatewayRestApi + StageName: local + DependsOn: + - ProxyMethod + ApiGWStage: + Type: AWS::ApiGateway::Stage + Properties: + Description: Test Stage 123 + DeploymentId: + Ref: ApiGWDeployment + RestApiId: + Ref: ApiGatewayRestApi + DependsOn: + - ProxyMethod +Outputs: + RestApiId: + Value: + Ref: ApiGatewayRestApi + ResourceId: + Value: + Ref: ProxyResource diff --git a/tests/aws/templates/apigateway_models.json b/tests/aws/templates/apigateway_models.json new file mode 100644 index 0000000000000..a3b4da06c1d88 --- /dev/null +++ b/tests/aws/templates/apigateway_models.json @@ -0,0 +1,155 @@ +{ + "Resources": { + "apiC8550315": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "test-cdk-models-json" + }, + "Metadata": { + "aws:cdk:path": "ValidatorExampleStack/api/Resource" + } + }, + "apiDeployment149F12947655b29bb65dd29c4fa41b0e0ff8358d": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "apiC8550315" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "apivalidatedANY5D963C0D", + "apivalidatedA453B5EB", + "jsonmodelCE5E769A", + "requestvalidatorBE19E57D" + ], + "Metadata": { + "aws:cdk:path": "ValidatorExampleStack/api/Deployment/Resource" + } + }, + "apiDeploymentStagelocal2DF7037D": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "apiC8550315" + }, + "DeploymentId": { + "Ref": "apiDeployment149F12947655b29bb65dd29c4fa41b0e0ff8358d" + }, + "StageName": "local" + }, + "Metadata": { + "aws:cdk:path": "ValidatorExampleStack/api/DeploymentStage.local/Resource" + } + }, + "apivalidatedA453B5EB": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "apiC8550315", + "RootResourceId" + ] + }, + "PathPart": "validated", + "RestApiId": { + "Ref": "apiC8550315" + } + }, + "Metadata": { + "aws:cdk:path": "ValidatorExampleStack/api/Default/validated/Resource" + } + }, + "apivalidatedANY5D963C0D": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Ref": "apivalidatedA453B5EB" + }, + "RestApiId": { + "Ref": "apiC8550315" + }, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationResponses": [ + { + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{ \"statusCode\": 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "StatusCode": "200" + } + ], + "RequestModels": { + "application/json": { + "Ref": "jsonmodelCE5E769A" + } + }, + "RequestValidatorId": { + "Ref": "requestvalidatorBE19E57D" + } + }, + "Metadata": { + "aws:cdk:path": "ValidatorExampleStack/api/Default/validated/ANY/Resource" + } + }, + "requestvalidatorBE19E57D": { + "Type": "AWS::ApiGateway::RequestValidator", + "Properties": { + "RestApiId": { + "Ref": "apiC8550315" + }, + "Name": "require-valid-body", + "ValidateRequestBody": true + }, + "Metadata": { + "aws:cdk:path": "ValidatorExampleStack/request-validator/Resource" + } + }, + "jsonmodelCE5E769A": { + "Type": "AWS::ApiGateway::Model", + "Properties": { + "RestApiId": { + "Ref": "apiC8550315" + }, + "ContentType": "application/json", + "Name": "TestModel", + "Schema": { + "title": "TestModel", + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "string_field": { + "type": "string" + }, + "integer_field": { + "type": "number" + } + }, + "required": [ + "string_field", + "integer_field" + ] + } + }, + "Metadata": { + "aws:cdk:path": "ValidatorExampleStack/json-model/Resource" + } + } + }, + "Outputs": { + "RestApiId": { + "Value": { + "Ref": "apiC8550315" + } + } + } +} diff --git a/tests/aws/templates/apigateway_serverless_api_resolving.yml b/tests/aws/templates/apigateway_serverless_api_resolving.yml new file mode 100644 index 0000000000000..f7c2b50e7b6c6 --- /dev/null +++ b/tests/aws/templates/apigateway_serverless_api_resolving.yml @@ -0,0 +1,48 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template with a simple API definition +Parameters: + AllowedOrigin: + Type: 'String' + TestSSMParameter: + Type: 'AWS::SSM::Parameter::Value' + Default: '/test-stack/testssm/random-value' + TestSSMParameterLambdaArn: + Type: 'AWS::SSM::Parameter::Value' + Default: '/test-stack/testssm/lambda-arn' + +Resources: + ApiGatewayApi: + Type: AWS::Serverless::Api + Properties: + StageName: local + OpenApiVersion: '2.0' + Cors: + AllowMethods: "'OPTIONS,POST,GET,PUT'" + AllowHeaders: !Sub "'Content-Type,Authorization,${TestSSMParameter}'" + AllowCredentials: true + AllowOrigin: !Sub "'${AllowedOrigin}'" + Auth: + Authorizers: + LambdaTokenAuthorizer: + FunctionArn: !Ref TestSSMParameterLambdaArn + + ApiFunction: # Adds a GET api endpoint at "/" to the ApiGatewayApi via an Api event + Type: AWS::Serverless::Function + Properties: + Events: + ApiEvent: + Type: Api + Properties: + Path: / + Method: get + RestApiId: + Ref: ApiGatewayApi + Runtime: python3.9 + Handler: index.handler + InlineCode: | + def handler(event, context): + return {'body': 'Hello World!', 'statusCode': 200} +Outputs: + ApiGatewayApiId: + Value: !Ref ApiGatewayApi diff --git a/tests/aws/templates/apigateway_update_stage.yml b/tests/aws/templates/apigateway_update_stage.yml new file mode 100644 index 0000000000000..e5fa5ec0a1718 --- /dev/null +++ b/tests/aws/templates/apigateway_update_stage.yml @@ -0,0 +1,46 @@ +Parameters: + Description: + Type: String + Default: "Original description" + Method: + Type: String + Default: GET + RestApiName: + Type: String + +Resources: + RestApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Ref RestApiName + Stage: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: + Ref: RestApi + DeploymentId: + Ref: ApiDeployment + StageName: dev + MockMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + RestApiId: !Ref RestApi + ResourceId: !GetAtt + - RestApi + - RootResourceId + HttpMethod: !Ref Method + AuthorizationType: NONE + Integration: + Type: MOCK + ApiDeployment: + Type: AWS::ApiGateway::Deployment + Properties: + RestApiId: + Ref: RestApi + Description: !Ref Description + DependsOn: + - MockMethod + +Outputs: + RestApiId: + Value: !GetAtt RestApi.RestApiId diff --git a/tests/aws/templates/apigateway_usage_plan.yml b/tests/aws/templates/apigateway_usage_plan.yml new file mode 100644 index 0000000000000..c488e7a572d12 --- /dev/null +++ b/tests/aws/templates/apigateway_usage_plan.yml @@ -0,0 +1,64 @@ +Parameters: + QuotaLimit: + Type: Number + TagValue: + Type: String + RestApiName: + Type: String +Resources: + RestApi: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Ref RestApiName + Stage: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: + Ref: RestApi + DeploymentId: + Ref: ApiDeployment + UsagePlan: + Type: AWS::ApiGateway::UsagePlan + Properties: + ApiStages: + - ApiId: + Ref: RestApi + Stage: + Ref: Stage + Quota: + Limit: + Ref: QuotaLimit + Period: MONTH + Tags: + - Key: test + Value: + Ref: TagValue + - Key: test2 + Value: hardcoded + DependsOn: + - Stage + MockMethod: + Type: 'AWS::ApiGateway::Method' + Properties: + RestApiId: !Ref RestApi + ResourceId: !GetAtt + - RestApi + - RootResourceId + HttpMethod: GET + AuthorizationType: NONE + Integration: + Type: MOCK + ApiDeployment: + Type: AWS::ApiGateway::Deployment + Properties: + RestApiId: + Ref: RestApi + Description: Automatically created by the RestApi construct + DependsOn: + - MockMethod + +Outputs: + UsagePlanId: + Value: !Ref UsagePlan + RestApiId: + Value: !GetAtt RestApi.RestApiId diff --git a/tests/aws/templates/apigw-awsintegration-request-parameters.yaml b/tests/aws/templates/apigw-awsintegration-request-parameters.yaml new file mode 100644 index 0000000000000..8d8a4eb05b20b --- /dev/null +++ b/tests/aws/templates/apigw-awsintegration-request-parameters.yaml @@ -0,0 +1,173 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + ApiName: + Type: String + CustomTagKey: + Type: String + CustomTagValue: + Type: String +Resources: + Bucket83908E77: + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + MyRoleF48FFE04: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: "*" + Version: "2012-10-17" + MyRoleDefaultPolicyA36BE1DD: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:DeleteObject* + - s3:PutObject + - s3:Abort* + Effect: Allow + Resource: + - Fn::GetAtt: + - Bucket83908E77 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - Bucket83908E77 + - Arn + - /* + Version: "2012-10-17" + PolicyName: MyRoleDefaultPolicyA36BE1DD + Roles: + - Ref: MyRoleF48FFE04 + RestApi0C43BF4B: + Type: AWS::ApiGateway::RestApi + Properties: + Name: + Ref: ApiName + Tags: + - Key: + Ref: CustomTagKey + Value: + Ref: CustomTagValue + RestApiCloudWatchRoleE3ED6605: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + Tags: + - Key: _custom_id_ + Value: api + RestApiAccount7C83CF5A: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: + Fn::GetAtt: + - RestApiCloudWatchRoleE3ED6605 + - Arn + DependsOn: + - RestApi0C43BF4B + RestApiDeployment180EC50356429cbae887bcd485c89adef8803ba0: + Type: AWS::ApiGateway::Deployment + Properties: + RestApiId: + Ref: RestApi0C43BF4B + Description: Automatically created by the RestApi construct + Tags: + - Key: _custom_id_ + Value: api + DependsOn: + - RestApiGET0F59260B + RestApiDeploymentStageprod3855DE66: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: + Ref: RestApi0C43BF4B + DeploymentId: + Ref: RestApiDeployment180EC50356429cbae887bcd485c89adef8803ba0 + StageName: prod + RestApiGET0F59260B: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: GET + ResourceId: + Fn::GetAtt: + - RestApi0C43BF4B + - RootResourceId + RestApiId: + Ref: RestApi0C43BF4B + AuthorizationType: NONE + Integration: + Credentials: + Fn::GetAtt: + - MyRoleF48FFE04 + - Arn + IntegrationHttpMethod: GET + RequestParameters: + integration.request.path.object: method.request.path.id + Type: AWS + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :apigateway:eu-west-1:s3:path/ + - Ref: Bucket83908E77 + - /{object}.json + RequestParameters: + method.request.path.id: false + RestApiCustomDomainBA087742: + Type: AWS::ApiGateway::DomainName + Properties: + DomainName: cfn5632.localstack.cloud + EndpointConfiguration: + Types: + - REGIONAL + RegionalCertificateArn: arn:aws:acm:us-east-1:000000000000:certificate/00000000-0000-0000-0000-000000000000 + Tags: + - Key: + Ref: CustomTagKey + Value: + Ref: CustomTagValue + RestApiCustomDomainMapMyStackLocalRestApiE67E15D45F8B292A: + Type: AWS::ApiGateway::BasePathMapping + Properties: + DomainName: + Ref: RestApiCustomDomainBA087742 + RestApiId: + Ref: RestApi0C43BF4B + Stage: + Ref: RestApiDeploymentStageprod3855DE66 +Outputs: + RestApiEndpoint0551178A: + Value: + Fn::Join: + - "" + - - https:// + - Ref: RestApi0C43BF4B + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - / + - Ref: RestApiDeploymentStageprod3855DE66 + - / diff --git a/tests/aws/templates/asset/index.js b/tests/aws/templates/asset/index.js new file mode 100644 index 0000000000000..6fc1c661687d1 --- /dev/null +++ b/tests/aws/templates/asset/index.js @@ -0,0 +1,10 @@ +'use strict'; + +async function handler() { + return 'Hi Localstack'; +} + +module.exports = { + createUserHandler: handler, + authenticateUserHandler: handler +}; diff --git a/tests/aws/templates/cdk_bootstrap.yml b/tests/aws/templates/cdk_bootstrap.yml new file mode 100644 index 0000000000000..19e9bf70f74a0 --- /dev/null +++ b/tests/aws/templates/cdk_bootstrap.yml @@ -0,0 +1,617 @@ +Description: This stack includes resources needed to deploy AWS CDK apps into this environment +Parameters: + TrustedAccounts: + Description: List of AWS accounts that are trusted to publish assets and deploy stacks to this environment + Default: "" + Type: CommaDelimitedList + TrustedAccountsForLookup: + Description: List of AWS accounts that are trusted to look up values in this environment + Default: "" + Type: CommaDelimitedList + CloudFormationExecutionPolicies: + Description: List of the ManagedPolicy ARN(s) to attach to the CloudFormation deployment role + Default: "" + Type: CommaDelimitedList + FileAssetsBucketName: + Description: The name of the S3 bucket used for file assets + Default: "" + Type: String + FileAssetsBucketKmsKeyId: + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed S3 key, or the ID/ARN of an existing key. + Default: "" + Type: String + ContainerAssetsRepositoryName: + Description: A user-provided custom name to use for the container assets ECR repository + Default: "" + Type: String + Qualifier: + Description: An identifier to distinguish multiple bootstrap stacks in the same environment + Default: hnb659fds + Type: String + AllowedPattern: "[A-Za-z0-9_-]{1,10}" + ConstraintDescription: Qualifier must be an alphanumeric identifier of at most 10 characters + PublicAccessBlockConfiguration: + Description: Whether or not to enable S3 Staging Bucket Public Access Block Configuration + Default: "true" + Type: String + AllowedValues: + - "true" + - "false" + InputPermissionsBoundary: + Description: Whether or not to use either the CDK supplied or custom permissions boundary + Default: "" + Type: String + UseExamplePermissionsBoundary: + Default: "false" + AllowedValues: + - "true" + - "false" + Type: String + BootstrapVariant: + Type: String + Default: "AWS CDK: Default Resources" + Description: Describe the provenance of the resources in this bootstrap stack. Change this when you customize the template. To prevent accidents, the CDK CLI will not overwrite bootstrap stacks with a different variant. +Conditions: + HasTrustedAccounts: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccounts + HasTrustedAccountsForLookup: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccountsForLookup + HasCloudFormationExecutionPolicies: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: CloudFormationExecutionPolicies + HasCustomFileAssetsBucketName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: FileAssetsBucketName + CreateNewKey: + Fn::Equals: + - "" + - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - AWS_MANAGED_KEY + - Ref: FileAssetsBucketKmsKeyId + ShouldCreatePermissionsBoundary: + Fn::Equals: + - "true" + - Ref: UseExamplePermissionsBoundary + PermissionsBoundarySet: + Fn::Not: + - Fn::Equals: + - "" + - Ref: InputPermissionsBoundary + HasCustomContainerAssetsRepositoryName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: ContainerAssetsRepositoryName + UsePublicAccessBlockConfiguration: + Fn::Equals: + - "true" + - Ref: PublicAccessBlockConfiguration +Resources: + FileAssetsBucketEncryptionKey: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + - kms:TagResource + - kms:UntagResource + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + Resource: "*" + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: + Ref: AWS::AccountId + kms:ViaService: + - Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: + Fn::Sub: ${FilePublishingRole.Arn} + Resource: "*" + Condition: CreateNewKey + FileAssetsBucketEncryptionKeyAlias: + Condition: CreateNewKey + Type: AWS::KMS::Alias + Properties: + AliasName: + Fn::Sub: alias/cdk-${Qualifier}-assets-key + TargetKeyId: + Ref: FileAssetsBucketEncryptionKey + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::If: + - HasCustomFileAssetsBucketName + - Fn::Sub: ${FileAssetsBucketName} + - Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: aws:kms + KMSMasterKeyID: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + PublicAccessBlockConfiguration: + Fn::If: + - UsePublicAccessBlockConfiguration + - BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + - Ref: AWS::NoValue + VersioningConfiguration: + Status: Enabled + LifecycleConfiguration: + Rules: + - Id: CleanupOldVersions + Status: Enabled + NoncurrentVersionExpiration: + NoncurrentDays: 365 + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + StagingBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: StagingBucket + PolicyDocument: + Id: AccessControl + Version: "2012-10-17" + Statement: + - Sid: AllowSSLRequestsOnly + Action: s3:* + Effect: Deny + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + Bool: + aws:SecureTransport: "false" + Principal: "*" + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + ImageTagMutability: IMMUTABLE + LifecyclePolicy: + LifecyclePolicyText: | + { + "rules": [ + { + "rulePriority": 1, + "description": "Untagged images should not exist, but expire any older than one year", + "selection": { + "tagStatus": "untagged", + "countType": "sinceImagePushed", + "countUnit": "days", + "countNumber": 365 + }, + "action": { "type": "expire" } + } + ] + } + RepositoryName: + Fn::If: + - HasCustomContainerAssetsRepositoryName + - Fn::Sub: ${ContainerAssetsRepositoryName} + - Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + RepositoryPolicyText: + Version: "2012-10-17" + Statement: + - Sid: LambdaECRImageRetrievalPolicy + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Condition: + StringLike: + aws:sourceArn: + Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:* + FilePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: file-publishing + ImagePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: image-publishing + LookupRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccountsForLookup + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccountsForLookup + - Ref: AWS::NoValue + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-lookup-role-${AWS::AccountId}-${AWS::Region} + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess + Policies: + - PolicyDocument: + Statement: + - Sid: DontReadSecrets + Effect: Deny + Action: + - kms:Decrypt + Resource: "*" + Version: "2012-10-17" + PolicyName: LookupRolePolicy + Tags: + - Key: aws-cdk:bootstrap-role + Value: lookup + FilePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:GetEncryptionConfiguration + - s3:List* + - s3:DeleteObject* + - s3:PutObject* + - s3:Abort* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Effect: Allow + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Resource: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/${FileAssetsBucketKmsKeyId} + Version: "2012-10-17" + Roles: + - Ref: FilePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + ImagePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - ecr:BatchCheckLayerAvailability + - ecr:DescribeRepositories + - ecr:DescribeImages + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: ${ContainerAssetsRepository.Arn} + Effect: Allow + - Action: + - ecr:GetAuthorizationToken + Resource: "*" + Effect: Allow + Version: "2012-10-17" + Roles: + - Ref: ImagePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + DeploymentActionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + Policies: + - PolicyDocument: + Statement: + - Sid: CloudFormationPermissions + Effect: Allow + Action: + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:DescribeStacks + - cloudformation:ExecuteChangeSet + - cloudformation:CreateStack + - cloudformation:UpdateStack + Resource: "*" + - Sid: PipelineCrossAccountArtifactsBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:Abort* + - s3:DeleteObject* + - s3:PutObject* + Resource: "*" + Condition: + StringNotEquals: + s3:ResourceAccount: + Ref: AWS::AccountId + - Sid: PipelineCrossAccountArtifactsKey + Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + kms:ViaService: + Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: iam:PassRole + Resource: + Fn::Sub: ${CloudFormationExecutionRole.Arn} + Effect: Allow + - Sid: CliPermissions + Action: + - cloudformation:DescribeStackEvents + - cloudformation:GetTemplate + - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection + - sts:GetCallerIdentity + - cloudformation:GetTemplateSummary + Resource: "*" + Effect: Allow + - Sid: CliStagingBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion} + Version: "2012-10-17" + PolicyName: default + RoleName: + Fn::Sub: cdk-${Qualifier}-deploy-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: deploy + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + Fn::If: + - HasCloudFormationExecutionPolicies + - Ref: CloudFormationExecutionPolicies + - Fn::If: + - HasTrustedAccounts + - Ref: AWS::NoValue + - - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + PermissionsBoundary: + Fn::If: + - PermissionsBoundarySet + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/${InputPermissionsBoundary} + - Ref: AWS::NoValue + CdkBoostrapPermissionsBoundaryPolicy: + Condition: ShouldCreatePermissionsBoundary + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Statement: + - Sid: ExplicitAllowAll + Action: + - "*" + Effect: Allow + Resource: "*" + - Sid: DenyAccessIfRequiredPermBoundaryIsNotBeingApplied + Action: + - iam:CreateUser + - iam:CreateRole + - iam:PutRolePermissionsBoundary + - iam:PutUserPermissionsBoundary + Condition: + StringNotEquals: + iam:PermissionsBoundary: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Effect: Deny + Resource: "*" + - Sid: DenyPermBoundaryIAMPolicyAlteration + Action: + - iam:CreatePolicyVersion + - iam:DeletePolicy + - iam:DeletePolicyVersion + - iam:SetDefaultPolicyVersion + Effect: Deny + Resource: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + - Sid: DenyRemovalOfPermBoundaryFromAnyUserOrRole + Action: + - iam:DeleteUserPermissionsBoundary + - iam:DeleteRolePermissionsBoundary + Effect: Deny + Resource: "*" + Version: "2012-10-17" + Description: Bootstrap Permission Boundary + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Path: / + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: "20" +Outputs: + BucketName: + Description: The name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket} + BucketDomainName: + Description: The domain name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket.RegionalDomainName} + FileAssetKeyArn: + Description: The ARN of the KMS key used to encrypt the asset bucket (deprecated) + Value: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + Export: + Name: + Fn::Sub: CdkBootstrap-${Qualifier}-FileAssetKeyArn + ImageRepositoryName: + Description: The name of the ECR repository which hosts docker image assets + Value: + Fn::Sub: ${ContainerAssetsRepository} + BootstrapVersion: + Description: The version of the bootstrap resources that are currently mastered in this stack + Value: + Fn::GetAtt: + - CdkBootstrapVersion + - Value diff --git a/tests/aws/templates/cdk_bootstrap_v10.yaml b/tests/aws/templates/cdk_bootstrap_v10.yaml new file mode 100644 index 0000000000000..323a3ec010e98 --- /dev/null +++ b/tests/aws/templates/cdk_bootstrap_v10.yaml @@ -0,0 +1,506 @@ +Description: This stack includes resources needed to deploy AWS CDK apps into this environment +Parameters: + TrustedAccounts: + Description: List of AWS accounts that are trusted to publish assets and deploy stacks to this environment + Default: "" + Type: CommaDelimitedList + TrustedAccountsForLookup: + Description: List of AWS accounts that are trusted to look up values in this environment + Default: "" + Type: CommaDelimitedList + CloudFormationExecutionPolicies: + Description: List of the ManagedPolicy ARN(s) to attach to the CloudFormation deployment role + Default: "" + Type: CommaDelimitedList + FileAssetsBucketName: + Description: The name of the S3 bucket used for file assets + Default: "" + Type: String + FileAssetsBucketKmsKeyId: + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed S3 key, or the ID/ARN of an existing key. + Default: "" + Type: String + ContainerAssetsRepositoryName: + Description: A user-provided custom name to use for the container assets ECR repository + Default: "" + Type: String + Qualifier: + Description: An identifier to distinguish multiple bootstrap stacks in the same environment + Default: hnb659fds + Type: String + AllowedPattern: "[A-Za-z0-9_-]{1,10}" + ConstraintDescription: Qualifier must be an alphanumeric identifier of at most 10 characters + PublicAccessBlockConfiguration: + Description: Whether or not to enable S3 Staging Bucket Public Access Block Configuration + Default: "true" + Type: String + AllowedValues: + - "true" + - "false" +Conditions: + HasTrustedAccounts: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccounts + HasTrustedAccountsForLookup: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccountsForLookup + HasCloudFormationExecutionPolicies: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: CloudFormationExecutionPolicies + HasCustomFileAssetsBucketName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: FileAssetsBucketName + CreateNewKey: + Fn::Equals: + - "" + - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - AWS_MANAGED_KEY + - Ref: FileAssetsBucketKmsKeyId + HasCustomContainerAssetsRepositoryName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: ContainerAssetsRepositoryName + UsePublicAccessBlockConfiguration: + Fn::Equals: + - "true" + - Ref: PublicAccessBlockConfiguration +Resources: + FileAssetsBucketEncryptionKey: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + Resource: "*" + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: + Ref: AWS::AccountId + kms:ViaService: + - Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: + Fn::Sub: ${FilePublishingRole.Arn} + Resource: "*" + Condition: CreateNewKey + FileAssetsBucketEncryptionKeyAlias: + Condition: CreateNewKey + Type: AWS::KMS::Alias + Properties: + AliasName: + Fn::Sub: alias/cdk-${Qualifier}-assets-key + TargetKeyId: + Ref: FileAssetsBucketEncryptionKey + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::If: + - HasCustomFileAssetsBucketName + - Fn::Sub: ${FileAssetsBucketName} + - Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: aws:kms + KMSMasterKeyID: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + PublicAccessBlockConfiguration: + Fn::If: + - UsePublicAccessBlockConfiguration + - BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + - Ref: AWS::NoValue + VersioningConfiguration: + Status: Enabled + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + StagingBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: StagingBucket + PolicyDocument: + Id: AccessControl + Version: "2012-10-17" + Statement: + - Sid: AllowSSLRequestsOnly + Action: s3:* + Effect: Deny + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + Bool: + aws:SecureTransport: "false" + Principal: "*" + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + ImageScanningConfiguration: + ScanOnPush: true + RepositoryName: + Fn::If: + - HasCustomContainerAssetsRepositoryName + - Fn::Sub: ${ContainerAssetsRepositoryName} + - Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + FilePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: file-publishing + ImagePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: image-publishing + LookupRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccountsForLookup + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccountsForLookup + - Ref: AWS::NoValue + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-lookup-role-${AWS::AccountId}-${AWS::Region} + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess + Policies: + - PolicyDocument: + Statement: + - Sid: DontReadSecrets + Effect: Deny + Action: + - kms:Decrypt + Resource: "*" + Version: "2012-10-17" + PolicyName: LookupRolePolicy + Tags: + - Key: aws-cdk:bootstrap-role + Value: lookup + FilePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:GetEncryptionConfiguration + - s3:List* + - s3:DeleteObject* + - s3:PutObject* + - s3:Abort* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Effect: Allow + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Resource: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/${FileAssetsBucketKmsKeyId} + Version: "2012-10-17" + Roles: + - Ref: FilePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + ImagePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - ecr:BatchCheckLayerAvailability + - ecr:DescribeRepositories + - ecr:DescribeImages + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: ${ContainerAssetsRepository.Arn} + Effect: Allow + - Action: + - ecr:GetAuthorizationToken + Resource: "*" + Effect: Allow + Version: "2012-10-17" + Roles: + - Ref: ImagePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + DeploymentActionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + Policies: + - PolicyDocument: + Statement: + - Sid: CloudFormationPermissions + Effect: Allow + Action: + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:DescribeStacks + - cloudformation:ExecuteChangeSet + - cloudformation:CreateStack + - cloudformation:UpdateStack + Resource: "*" + - Sid: PipelineCrossAccountArtifactsBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:Abort* + - s3:DeleteObject* + - s3:PutObject* + Resource: "*" + Condition: + StringNotEquals: + s3:ResourceAccount: + Ref: AWS::AccountId + - Sid: PipelineCrossAccountArtifactsKey + Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + kms:ViaService: + Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: iam:PassRole + Resource: + Fn::Sub: ${CloudFormationExecutionRole.Arn} + Effect: Allow + - Sid: CliPermissions + Action: + - cloudformation:DescribeStackEvents + - cloudformation:GetTemplate + - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection + - sts:GetCallerIdentity + Resource: "*" + Effect: Allow + - Sid: CliStagingBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion} + Version: "2012-10-17" + PolicyName: default + RoleName: + Fn::Sub: cdk-${Qualifier}-deploy-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: deploy + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + Fn::If: + - HasCloudFormationExecutionPolicies + - Ref: CloudFormationExecutionPolicies + - Fn::If: + - HasTrustedAccounts + - Ref: AWS::NoValue + - - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: "10" +Outputs: + BucketName: + Description: The name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket} + BucketDomainName: + Description: The domain name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket.RegionalDomainName} + FileAssetKeyArn: + Description: The ARN of the KMS key used to encrypt the asset bucket (deprecated) + Value: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + Export: + Name: + Fn::Sub: CdkBootstrap-${Qualifier}-FileAssetKeyArn + ImageRepositoryName: + Description: The name of the ECR repository which hosts docker image assets + Value: + Fn::Sub: ${ContainerAssetsRepository} + BootstrapVersion: + Description: The version of the bootstrap resources that are currently mastered in this stack + Value: + Fn::GetAtt: + - CdkBootstrapVersion + - Value diff --git a/tests/aws/templates/cdk_bootstrap_v11.yaml b/tests/aws/templates/cdk_bootstrap_v11.yaml new file mode 100644 index 0000000000000..a0d0703a7bc04 --- /dev/null +++ b/tests/aws/templates/cdk_bootstrap_v11.yaml @@ -0,0 +1,520 @@ +Description: This stack includes resources needed to deploy AWS CDK apps into this environment +Parameters: + TrustedAccounts: + Description: List of AWS accounts that are trusted to publish assets and deploy stacks to this environment + Default: "" + Type: CommaDelimitedList + TrustedAccountsForLookup: + Description: List of AWS accounts that are trusted to look up values in this environment + Default: "" + Type: CommaDelimitedList + CloudFormationExecutionPolicies: + Description: List of the ManagedPolicy ARN(s) to attach to the CloudFormation deployment role + Default: "" + Type: CommaDelimitedList + FileAssetsBucketName: + Description: The name of the S3 bucket used for file assets + Default: "" + Type: String + FileAssetsBucketKmsKeyId: + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed S3 key, or the ID/ARN of an existing key. + Default: "" + Type: String + ContainerAssetsRepositoryName: + Description: A user-provided custom name to use for the container assets ECR repository + Default: "" + Type: String + Qualifier: + Description: An identifier to distinguish multiple bootstrap stacks in the same environment + Default: hnb659fds + Type: String + AllowedPattern: "[A-Za-z0-9_-]{1,10}" + ConstraintDescription: Qualifier must be an alphanumeric identifier of at most 10 characters + PublicAccessBlockConfiguration: + Description: Whether or not to enable S3 Staging Bucket Public Access Block Configuration + Default: "true" + Type: String + AllowedValues: + - "true" + - "false" +Conditions: + HasTrustedAccounts: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccounts + HasTrustedAccountsForLookup: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccountsForLookup + HasCloudFormationExecutionPolicies: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: CloudFormationExecutionPolicies + HasCustomFileAssetsBucketName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: FileAssetsBucketName + CreateNewKey: + Fn::Equals: + - "" + - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - AWS_MANAGED_KEY + - Ref: FileAssetsBucketKmsKeyId + HasCustomContainerAssetsRepositoryName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: ContainerAssetsRepositoryName + UsePublicAccessBlockConfiguration: + Fn::Equals: + - "true" + - Ref: PublicAccessBlockConfiguration +Resources: + FileAssetsBucketEncryptionKey: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + Resource: "*" + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: + Ref: AWS::AccountId + kms:ViaService: + - Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: + Fn::Sub: ${FilePublishingRole.Arn} + Resource: "*" + Condition: CreateNewKey + FileAssetsBucketEncryptionKeyAlias: + Condition: CreateNewKey + Type: AWS::KMS::Alias + Properties: + AliasName: + Fn::Sub: alias/cdk-${Qualifier}-assets-key + TargetKeyId: + Ref: FileAssetsBucketEncryptionKey + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::If: + - HasCustomFileAssetsBucketName + - Fn::Sub: ${FileAssetsBucketName} + - Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: aws:kms + KMSMasterKeyID: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + PublicAccessBlockConfiguration: + Fn::If: + - UsePublicAccessBlockConfiguration + - BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + - Ref: AWS::NoValue + VersioningConfiguration: + Status: Enabled + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + StagingBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: StagingBucket + PolicyDocument: + Id: AccessControl + Version: "2012-10-17" + Statement: + - Sid: AllowSSLRequestsOnly + Action: s3:* + Effect: Deny + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + Bool: + aws:SecureTransport: "false" + Principal: "*" + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + ImageScanningConfiguration: + ScanOnPush: true + RepositoryName: + Fn::If: + - HasCustomContainerAssetsRepositoryName + - Fn::Sub: ${ContainerAssetsRepositoryName} + - Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + RepositoryPolicyText: + Version: "2012-10-17" + Statement: + - Sid: LambdaECRImageRetrievalPolicy + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Condition: + StringLike: + aws:sourceArn: + Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:* + FilePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: file-publishing + ImagePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: image-publishing + LookupRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccountsForLookup + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccountsForLookup + - Ref: AWS::NoValue + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-lookup-role-${AWS::AccountId}-${AWS::Region} + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess + Policies: + - PolicyDocument: + Statement: + - Sid: DontReadSecrets + Effect: Deny + Action: + - kms:Decrypt + Resource: "*" + Version: "2012-10-17" + PolicyName: LookupRolePolicy + Tags: + - Key: aws-cdk:bootstrap-role + Value: lookup + FilePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:GetEncryptionConfiguration + - s3:List* + - s3:DeleteObject* + - s3:PutObject* + - s3:Abort* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Effect: Allow + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Resource: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/${FileAssetsBucketKmsKeyId} + Version: "2012-10-17" + Roles: + - Ref: FilePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + ImagePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - ecr:BatchCheckLayerAvailability + - ecr:DescribeRepositories + - ecr:DescribeImages + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: ${ContainerAssetsRepository.Arn} + Effect: Allow + - Action: + - ecr:GetAuthorizationToken + Resource: "*" + Effect: Allow + Version: "2012-10-17" + Roles: + - Ref: ImagePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + DeploymentActionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + Policies: + - PolicyDocument: + Statement: + - Sid: CloudFormationPermissions + Effect: Allow + Action: + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:DescribeStacks + - cloudformation:ExecuteChangeSet + - cloudformation:CreateStack + - cloudformation:UpdateStack + Resource: "*" + - Sid: PipelineCrossAccountArtifactsBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:Abort* + - s3:DeleteObject* + - s3:PutObject* + Resource: "*" + Condition: + StringNotEquals: + s3:ResourceAccount: + Ref: AWS::AccountId + - Sid: PipelineCrossAccountArtifactsKey + Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + kms:ViaService: + Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: iam:PassRole + Resource: + Fn::Sub: ${CloudFormationExecutionRole.Arn} + Effect: Allow + - Sid: CliPermissions + Action: + - cloudformation:DescribeStackEvents + - cloudformation:GetTemplate + - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection + - sts:GetCallerIdentity + Resource: "*" + Effect: Allow + - Sid: CliStagingBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion} + Version: "2012-10-17" + PolicyName: default + RoleName: + Fn::Sub: cdk-${Qualifier}-deploy-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: deploy + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + Fn::If: + - HasCloudFormationExecutionPolicies + - Ref: CloudFormationExecutionPolicies + - Fn::If: + - HasTrustedAccounts + - Ref: AWS::NoValue + - - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: "11" +Outputs: + BucketName: + Description: The name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket} + BucketDomainName: + Description: The domain name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket.RegionalDomainName} + FileAssetKeyArn: + Description: The ARN of the KMS key used to encrypt the asset bucket (deprecated) + Value: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + Export: + Name: + Fn::Sub: CdkBootstrap-${Qualifier}-FileAssetKeyArn + ImageRepositoryName: + Description: The name of the ECR repository which hosts docker image assets + Value: + Fn::Sub: ${ContainerAssetsRepository} + BootstrapVersion: + Description: The version of the bootstrap resources that are currently mastered in this stack + Value: + Fn::GetAtt: + - CdkBootstrapVersion + - Value diff --git a/tests/aws/templates/cdk_bootstrap_v12.yaml b/tests/aws/templates/cdk_bootstrap_v12.yaml new file mode 100644 index 0000000000000..2451bd1e62223 --- /dev/null +++ b/tests/aws/templates/cdk_bootstrap_v12.yaml @@ -0,0 +1,521 @@ +Description: This stack includes resources needed to deploy AWS CDK apps into this environment +Parameters: + TrustedAccounts: + Description: List of AWS accounts that are trusted to publish assets and deploy stacks to this environment + Default: "" + Type: CommaDelimitedList + TrustedAccountsForLookup: + Description: List of AWS accounts that are trusted to look up values in this environment + Default: "" + Type: CommaDelimitedList + CloudFormationExecutionPolicies: + Description: List of the ManagedPolicy ARN(s) to attach to the CloudFormation deployment role + Default: "" + Type: CommaDelimitedList + FileAssetsBucketName: + Description: The name of the S3 bucket used for file assets + Default: "" + Type: String + FileAssetsBucketKmsKeyId: + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed S3 key, or the ID/ARN of an existing key. + Default: "" + Type: String + ContainerAssetsRepositoryName: + Description: A user-provided custom name to use for the container assets ECR repository + Default: "" + Type: String + Qualifier: + Description: An identifier to distinguish multiple bootstrap stacks in the same environment + Default: hnb659fds + Type: String + AllowedPattern: "[A-Za-z0-9_-]{1,10}" + ConstraintDescription: Qualifier must be an alphanumeric identifier of at most 10 characters + PublicAccessBlockConfiguration: + Description: Whether or not to enable S3 Staging Bucket Public Access Block Configuration + Default: "true" + Type: String + AllowedValues: + - "true" + - "false" +Conditions: + HasTrustedAccounts: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccounts + HasTrustedAccountsForLookup: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccountsForLookup + HasCloudFormationExecutionPolicies: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: CloudFormationExecutionPolicies + HasCustomFileAssetsBucketName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: FileAssetsBucketName + CreateNewKey: + Fn::Equals: + - "" + - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - AWS_MANAGED_KEY + - Ref: FileAssetsBucketKmsKeyId + HasCustomContainerAssetsRepositoryName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: ContainerAssetsRepositoryName + UsePublicAccessBlockConfiguration: + Fn::Equals: + - "true" + - Ref: PublicAccessBlockConfiguration +Resources: + FileAssetsBucketEncryptionKey: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + Resource: "*" + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: + Ref: AWS::AccountId + kms:ViaService: + - Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: + Fn::Sub: ${FilePublishingRole.Arn} + Resource: "*" + Condition: CreateNewKey + FileAssetsBucketEncryptionKeyAlias: + Condition: CreateNewKey + Type: AWS::KMS::Alias + Properties: + AliasName: + Fn::Sub: alias/cdk-${Qualifier}-assets-key + TargetKeyId: + Ref: FileAssetsBucketEncryptionKey + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::If: + - HasCustomFileAssetsBucketName + - Fn::Sub: ${FileAssetsBucketName} + - Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: aws:kms + KMSMasterKeyID: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + PublicAccessBlockConfiguration: + Fn::If: + - UsePublicAccessBlockConfiguration + - BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + - Ref: AWS::NoValue + VersioningConfiguration: + Status: Enabled + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + StagingBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: StagingBucket + PolicyDocument: + Id: AccessControl + Version: "2012-10-17" + Statement: + - Sid: AllowSSLRequestsOnly + Action: s3:* + Effect: Deny + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + Bool: + aws:SecureTransport: "false" + Principal: "*" + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + ImageScanningConfiguration: + ScanOnPush: true + RepositoryName: + Fn::If: + - HasCustomContainerAssetsRepositoryName + - Fn::Sub: ${ContainerAssetsRepositoryName} + - Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + RepositoryPolicyText: + Version: "2012-10-17" + Statement: + - Sid: LambdaECRImageRetrievalPolicy + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Condition: + StringLike: + aws:sourceArn: + Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:* + FilePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: file-publishing + ImagePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: image-publishing + LookupRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccountsForLookup + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccountsForLookup + - Ref: AWS::NoValue + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-lookup-role-${AWS::AccountId}-${AWS::Region} + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess + Policies: + - PolicyDocument: + Statement: + - Sid: DontReadSecrets + Effect: Deny + Action: + - kms:Decrypt + Resource: "*" + Version: "2012-10-17" + PolicyName: LookupRolePolicy + Tags: + - Key: aws-cdk:bootstrap-role + Value: lookup + FilePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:GetEncryptionConfiguration + - s3:List* + - s3:DeleteObject* + - s3:PutObject* + - s3:Abort* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Effect: Allow + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Resource: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/${FileAssetsBucketKmsKeyId} + Version: "2012-10-17" + Roles: + - Ref: FilePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + ImagePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - ecr:BatchCheckLayerAvailability + - ecr:DescribeRepositories + - ecr:DescribeImages + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: ${ContainerAssetsRepository.Arn} + Effect: Allow + - Action: + - ecr:GetAuthorizationToken + Resource: "*" + Effect: Allow + Version: "2012-10-17" + Roles: + - Ref: ImagePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + DeploymentActionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: TrustedAccounts + - Ref: AWS::NoValue + Policies: + - PolicyDocument: + Statement: + - Sid: CloudFormationPermissions + Effect: Allow + Action: + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:DescribeStacks + - cloudformation:ExecuteChangeSet + - cloudformation:CreateStack + - cloudformation:UpdateStack + Resource: "*" + - Sid: PipelineCrossAccountArtifactsBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:Abort* + - s3:DeleteObject* + - s3:PutObject* + Resource: "*" + Condition: + StringNotEquals: + s3:ResourceAccount: + Ref: AWS::AccountId + - Sid: PipelineCrossAccountArtifactsKey + Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + kms:ViaService: + Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: iam:PassRole + Resource: + Fn::Sub: ${CloudFormationExecutionRole.Arn} + Effect: Allow + - Sid: CliPermissions + Action: + - cloudformation:DescribeStackEvents + - cloudformation:GetTemplate + - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection + - sts:GetCallerIdentity + - cloudformation:GetTemplateSummary + Resource: "*" + Effect: Allow + - Sid: CliStagingBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion} + Version: "2012-10-17" + PolicyName: default + RoleName: + Fn::Sub: cdk-${Qualifier}-deploy-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: deploy + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + Fn::If: + - HasCloudFormationExecutionPolicies + - Ref: CloudFormationExecutionPolicies + - Fn::If: + - HasTrustedAccounts + - Ref: AWS::NoValue + - - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: "12" +Outputs: + BucketName: + Description: The name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket} + BucketDomainName: + Description: The domain name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket.RegionalDomainName} + FileAssetKeyArn: + Description: The ARN of the KMS key used to encrypt the asset bucket (deprecated) + Value: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + Export: + Name: + Fn::Sub: CdkBootstrap-${Qualifier}-FileAssetKeyArn + ImageRepositoryName: + Description: The name of the ECR repository which hosts docker image assets + Value: + Fn::Sub: ${ContainerAssetsRepository} + BootstrapVersion: + Description: The version of the bootstrap resources that are currently mastered in this stack + Value: + Fn::GetAtt: + - CdkBootstrapVersion + - Value diff --git a/tests/aws/templates/cdk_init_template.yaml b/tests/aws/templates/cdk_init_template.yaml new file mode 100644 index 0000000000000..ede41019c4725 --- /dev/null +++ b/tests/aws/templates/cdk_init_template.yaml @@ -0,0 +1,102 @@ +Resources: + CDKMetadata: + Type: AWS::CDK::Metadata + Properties: + Analytics: v2:deflate64:H4sIAAABACAA/zPSMzTTO1BMLC/WTU8J1s3JTNKrDo5JTM7WcU7LC0otzi8tSk4FsZ3z81IySzIz82p18vJTUvWygvXLDE30DE31jBWzijMzdYtK10oyc3P1giA0AIArdnxZAAPA + Metadata: + aws:cdk:path: Tmp2Stack/CDKMetadata/Default + Condition: CDKMetadataAvailable +Conditions: + CDKMetadataAvailable: + Fn::Or: + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - af-south-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-east-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-northeast-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-northeast-2 + - Fn::Equals: + - Ref: AWS::Region + - ap-south-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-southeast-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-southeast-2 + - Fn::Equals: + - Ref: AWS::Region + - ca-central-1 + - Fn::Equals: + - Ref: AWS::Region + - cn-north-1 + - Fn::Equals: + - Ref: AWS::Region + - cn-northwest-1 + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - eu-central-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-north-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-south-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-2 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-3 + - Fn::Equals: + - Ref: AWS::Region + - me-south-1 + - Fn::Equals: + - Ref: AWS::Region + - sa-east-1 + - Fn::Equals: + - Ref: AWS::Region + - us-east-1 + - Fn::Equals: + - Ref: AWS::Region + - us-east-2 + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - us-west-1 + - Fn::Equals: + - Ref: AWS::Region + - us-west-2 +Parameters: + BootstrapVersion: + Type: AWS::SSM::Parameter::Value + Default: /cdk-bootstrap/hnb659fds/version + Description: Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip] +Outputs: + BootstrapVersionOutput: + Value: + Fn::Sub: ${BootstrapVersion} +Rules: + CheckBootstrapVersion: + Assertions: + - Assert: + Fn::Not: + - Fn::Contains: + - - "1" + - "2" + - "3" + - "4" + - "5" + - Ref: BootstrapVersion + AssertDescription: CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI. diff --git a/tests/aws/templates/cdkmetadata.yaml b/tests/aws/templates/cdkmetadata.yaml new file mode 100644 index 0000000000000..36bb35e415063 --- /dev/null +++ b/tests/aws/templates/cdkmetadata.yaml @@ -0,0 +1,7 @@ +Resources: + CDKMetadata: + Type: AWS::CDK::Metadata + Properties: + Analytics: v2:deflate64:H4sIAAAAAAAA/zPSMzbTM1BMLC/WTU7J1s3JTNKrDi5JTM7WcU7LC0otzi8tSk4FsZ3z81IySzLz82p18vJTUvWyivXLDM30DE30jBSzijMzdYtK80oyc1P1giA0AExi6LdZAAAA + Metadata: + aws:cdk:path: CdkTestStack/CDKMetadata/Default diff --git a/tests/aws/templates/cfn_cdk_sample_app.yaml b/tests/aws/templates/cfn_cdk_sample_app.yaml new file mode 100644 index 0000000000000..017d7f906eed5 --- /dev/null +++ b/tests/aws/templates/cfn_cdk_sample_app.yaml @@ -0,0 +1,48 @@ +Resources: + CdksampleQueue3139C8CD: + Type: AWS::SQS::Queue + Properties: + VisibilityTimeout: 300 + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + CdksampleQueuePolicyFA91005A: + Type: AWS::SQS::QueuePolicy + Properties: + PolicyDocument: + Statement: + - Action: sqs:SendMessage + Condition: + ArnEquals: + aws:SourceArn: + Ref: CdksampleTopic7AD235A4 + Effect: Allow + Principal: + Service: sns.amazonaws.com + Resource: + Fn::GetAtt: + - CdksampleQueue3139C8CD + - Arn + Version: "2012-10-17" + Queues: + - Ref: CdksampleQueue3139C8CD + CdksampleQueueCdksampleStackCdksampleTopicCB3FDFDDC0BCF47C: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: + Ref: CdksampleTopic7AD235A4 + Endpoint: + Fn::GetAtt: + - CdksampleQueue3139C8CD + - Arn + DependsOn: + - CdksampleQueuePolicyFA91005A + CdksampleTopic7AD235A4: + Type: AWS::SNS::Topic +Outputs: + QueueUrl: + Value: + Ref: CdksampleQueue3139C8CD + TopicArn: + Value: + Ref: CdksampleTopic7AD235A4 diff --git a/tests/aws/templates/cfn_condition_update_1.yml b/tests/aws/templates/cfn_condition_update_1.yml new file mode 100644 index 0000000000000..ad56751ef85a2 --- /dev/null +++ b/tests/aws/templates/cfn_condition_update_1.yml @@ -0,0 +1,9 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + OriginalBucketName: + Type: String +Resources: + S3Setup: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref OriginalBucketName diff --git a/tests/aws/templates/cfn_condition_update_2.yml b/tests/aws/templates/cfn_condition_update_2.yml new file mode 100644 index 0000000000000..8a11904b1d8a6 --- /dev/null +++ b/tests/aws/templates/cfn_condition_update_2.yml @@ -0,0 +1,32 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + OriginalBucketName: + Type: String + FirstBucket: + Type: String + SecondBucket: + Type: String +Resources: + S3Setup: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref OriginalBucketName + ToBeCreated: + Type: AWS::S3::Bucket + Condition: TrueCondition + Properties: + BucketName: !Ref FirstBucket + NotToBeCreated: + Type: AWS::S3::Bucket + Condition: FalseCondition + Properties: + BucketName: !Ref SecondBucket +Conditions: + TrueCondition: + Fn::Equals: + - same + - same + FalseCondition: + Fn::Equals: + - this + - other diff --git a/tests/aws/templates/cfn_cw_composite_alarm.yml b/tests/aws/templates/cfn_cw_composite_alarm.yml new file mode 100644 index 0000000000000..0172a8ef8dda1 --- /dev/null +++ b/tests/aws/templates/cfn_cw_composite_alarm.yml @@ -0,0 +1,50 @@ +Resources: + SNS: + Type: AWS::SNS::Topic + HighResourceUsage: + Type: AWS::CloudWatch::CompositeAlarm + Properties: + AlarmName: HighResourceUsage + AlarmRule: (ALARM(HighCPUUsage) OR ALARM(HighMemoryUsage)) + AlarmActions: + - Ref: SNS + AlarmDescription: Indicates that the system resource usage is high while no known deployment is in progress + DependsOn: + - HighCPUUsage + - HighMemoryUsage + HighCPUUsage: + Type: AWS::CloudWatch::Alarm + Properties: + AlarmDescription: CPU usage is high + AlarmName: HighCPUUsage + ComparisonOperator: GreaterThanThreshold + EvaluationPeriods: 1 + MetricName: CPUUsage + Namespace: CustomNamespace + Period: 60 + Statistic: Average + Threshold: 70 + TreatMissingData: notBreaching + HighMemoryUsage: + Type: AWS::CloudWatch::Alarm + Properties: + AlarmDescription: Memory usage is high + AlarmName: HighMemoryUsage + ComparisonOperator: GreaterThanThreshold + EvaluationPeriods: 1 + MetricName: MemoryUsage + Namespace: CustomNamespace + Period: 60 + Statistic: Average + Threshold: 65 + TreatMissingData: breaching + +Outputs: + CompositeAlarmName: + Description: Composite Alarm + Value: + Ref: HighResourceUsage + MetricAlarmName: + Description: Memory Alarm + Value: + Ref: HighMemoryUsage diff --git a/tests/aws/templates/cfn_cw_simple_alarm.yml b/tests/aws/templates/cfn_cw_simple_alarm.yml new file mode 100644 index 0000000000000..952ab8ce17748 --- /dev/null +++ b/tests/aws/templates/cfn_cw_simple_alarm.yml @@ -0,0 +1,24 @@ +Resources: + MetricAlarm: + Type: AWS::CloudWatch::Alarm + Properties: + AlarmDescription: "uses extended statistic" + ComparisonOperator: GreaterThanOrEqualToThreshold + DatapointsToAlarm: 3 + Dimensions: + - Name: FunctionName + Value: "my-function" + EvaluationPeriods: 3 + ExtendedStatistic: p99 + MetricName: Duration + Namespace: AWS/Lambda + Period: 300 + Threshold: 10 + TreatMissingData: ignore + Unit: Count + +Outputs: + MetricAlarmName: + Description: Memory Alarm + Value: + Ref: MetricAlarm diff --git a/tests/aws/templates/cfn_failed_nested_stack_child.yml b/tests/aws/templates/cfn_failed_nested_stack_child.yml new file mode 100644 index 0000000000000..7b638c5775b9f --- /dev/null +++ b/tests/aws/templates/cfn_failed_nested_stack_child.yml @@ -0,0 +1,34 @@ +Resources: + MyLambdaFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: testFunction + Handler: index.handler + Runtime: python3.8 + Role: !GetAtt LambdaExecutionRole.Arn + Code: + S3Bucket: non-existent-bucket-name # Invalid S3 bucket that does not exist + S3Key: non-existent-key.zip + + LambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: LambdaExecutionRole + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: LambdaBasicExecution + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* diff --git a/tests/aws/templates/cfn_failed_nested_stack_parent.yml b/tests/aws/templates/cfn_failed_nested_stack_parent.yml new file mode 100644 index 0000000000000..f685b2566f37c --- /dev/null +++ b/tests/aws/templates/cfn_failed_nested_stack_parent.yml @@ -0,0 +1,14 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + TemplateUri: + Type: String + +Resources: + ChildStack: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: !Ref TemplateUri + +Outputs: + BucketStackId: + Value: !Ref ChildStack diff --git a/tests/aws/templates/cfn_function_export.yml b/tests/aws/templates/cfn_function_export.yml new file mode 100644 index 0000000000000..11671b86363fc --- /dev/null +++ b/tests/aws/templates/cfn_function_export.yml @@ -0,0 +1,12 @@ +Parameters: + BucketExportName: + Type: String +Resources: + Bucket1: + Type: AWS::S3::Bucket + Properties: {} +Outputs: + BucketName1: + Value: !Ref Bucket1 + Export: + Name: !Ref BucketExportName diff --git a/tests/aws/templates/cfn_function_import.yml b/tests/aws/templates/cfn_function_import.yml new file mode 100644 index 0000000000000..6fe544062bf4d --- /dev/null +++ b/tests/aws/templates/cfn_function_import.yml @@ -0,0 +1,14 @@ +Parameters: + BucketExportName: + Type: String +Resources: + Bucket2: + Type: AWS::S3::Bucket + Properties: + Tags: + - Key: test + Value: !ImportValue + 'Fn::Sub': '${BucketExportName}' +Outputs: + BucketName2: + Value: !Ref Bucket2 \ No newline at end of file diff --git a/tests/aws/templates/cfn_getatt_ref.yaml b/tests/aws/templates/cfn_getatt_ref.yaml new file mode 100644 index 0000000000000..89604b5728ab5 --- /dev/null +++ b/tests/aws/templates/cfn_getatt_ref.yaml @@ -0,0 +1,25 @@ +Parameters: + MyParam: + Type: String + + CustomOutputName: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref MyParam + +Outputs: + MyTopicRef: + Value: !Ref MyTopic + MyTopicName: + Value: !GetAtt MyTopic.TopicName + MyTopicArn: + Value: !GetAtt MyTopic.TopicArn + MyTopicCustom: + Value: + Fn::GetAtt: + - MyTopic + - !Ref CustomOutputName diff --git a/tests/aws/templates/cfn_if_attribute_none.yml b/tests/aws/templates/cfn_if_attribute_none.yml new file mode 100644 index 0000000000000..76aa1cfd276d1 --- /dev/null +++ b/tests/aws/templates/cfn_if_attribute_none.yml @@ -0,0 +1,42 @@ +Parameters: + Value1: + Type: String + Default: "Value1" + Value2: + Type: String + Default: "Value2" + CreateParameter: + Type: String + Default: false + AllowedValues: + - true + - false + +Conditions: + UseParameter1: !Equals [!Ref CreateParameter, "false"] + UseParameter2: !Equals [!Ref CreateParameter, "true"] + +Resources: + Parameter1: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Ref Value1 + + Parameter2: + Condition: UseParameter2 + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Ref Value2 + + Parameter3: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !If [UseParameter1, !Ref Value1, !Ref Value2] + +Outputs: + Result: + Value: + Fn::GetAtt: [Parameter3, Value] diff --git a/tests/aws/templates/cfn_intrinsic_functions.yaml b/tests/aws/templates/cfn_intrinsic_functions.yaml new file mode 100644 index 0000000000000..7ce2abff689e0 --- /dev/null +++ b/tests/aws/templates/cfn_intrinsic_functions.yaml @@ -0,0 +1,26 @@ +Parameters: + Param1: + Type: String + Param2: + Type: String + BucketName: + Type: String +Conditions: + condition1: + Fn::Equals: + - Ref: Param1 + - "1" + condition2: + Fn::Equals: + - Ref: Param2 + - "1" + condition3: + {{ intrinsic_fn }}: + - Condition: condition1 + - Condition: condition2 +Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName + Condition: condition3 diff --git a/tests/aws/templates/cfn_kinesis_dynamodb.yml b/tests/aws/templates/cfn_kinesis_dynamodb.yml new file mode 100644 index 0000000000000..81ba1adc870b0 --- /dev/null +++ b/tests/aws/templates/cfn_kinesis_dynamodb.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + TableName: + Type: String + Default: EventTable +Resources: + EventStream: + Type: AWS::Kinesis::Stream + Properties: + Name: EventStream + ShardCount: 1 + EventTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Ref TableName + AttributeDefinitions: + - AttributeName: pkey + AttributeType: S + KeySchema: + - AttributeName: pkey + KeyType: HASH + BillingMode: PAY_PER_REQUEST + KinesisStreamSpecification: + StreamArn: !GetAtt EventStream.Arn diff --git a/tests/aws/templates/cfn_kinesis_stream.yaml b/tests/aws/templates/cfn_kinesis_stream.yaml new file mode 100644 index 0000000000000..be0b629d645dd --- /dev/null +++ b/tests/aws/templates/cfn_kinesis_stream.yaml @@ -0,0 +1,44 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + KinesisStreamName: + Type: String + DeliveryStreamName: + Type: String + KinesisRoleName: + Type: String +Resources: + MyRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref KinesisRoleName + AssumeRolePolicyDocument: + Statement: + - Effect: Allow + Action: "sts:AssumeRole" + Principal: + Service: firehose.amazonaws.com + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref "DeliveryStreamName" + KinesisStream: + Type: AWS::Kinesis::Stream + Properties: + Name : !Ref "KinesisStreamName" + ShardCount : 5 + DeliveryStream: + Type: AWS::KinesisFirehose::DeliveryStream + Properties: + DeliveryStreamName: !Ref "DeliveryStreamName" + DeliveryStreamType: DirectPut + S3DestinationConfiguration: + BucketARN: !GetAtt "MyBucket.Arn" + BufferingHints: + IntervalInSeconds: 600 + SizeInMBs: 50 + CompressionFormat: UNCOMPRESSED + Prefix: raw/ + RoleARN: !GetAtt "MyRole.Arn" +Outputs: + MyStreamArn: + Value: !GetAtt "DeliveryStream.Arn" diff --git a/tests/aws/templates/cfn_kms_key.yml b/tests/aws/templates/cfn_kms_key.yml new file mode 100644 index 0000000000000..eeaad98b96dc7 --- /dev/null +++ b/tests/aws/templates/cfn_kms_key.yml @@ -0,0 +1,39 @@ +Resources: + kmskeystack8A5DBE89: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + - kms:TagResource + - kms:UntagResource + Effect: Allow + Principal: + AWS: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":iam::" + - Ref: AWS::AccountId + - ":root" + Resource: "*" + Version: "2012-10-17" + +Outputs: + KeyId: + Value: + Ref: kmskeystack8A5DBE89 diff --git a/tests/aws/templates/cfn_lambda_alias.yml b/tests/aws/templates/cfn_lambda_alias.yml new file mode 100644 index 0000000000000..05a831835c0e3 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_alias.yml @@ -0,0 +1,66 @@ +AWSTemplateFormatVersion: 2010-09-09 + +Parameters: + FunctionName: + Type: String + AliasName: + Type: String + +Resources: + MyFnServiceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + + LambdaFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Ref FunctionName + Code: + ZipFile: | + import os + + def handler(event, context): + function_version = os.environ["AWS_LAMBDA_FUNCTION_VERSION"] + print(f"{function_version=}") + init_type = os.environ.get("_XRAY_SDK_LAMBDA_PLACEMENT_INIT_TYPE", None) + print(f"{init_type=}") + return {"function_version": function_version, "initialization_type": init_type} + Role: + Fn::GetAtt: + - MyFnServiceRole + - Arn + Handler: index.handler + Runtime: python3.12 + DependsOn: + - MyFnServiceRole + + Version: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref LambdaFunction + Description: v1 + + FunctionAlias: + Type: AWS::Lambda::Alias + Properties: + FunctionName: !Ref FunctionName + FunctionVersion: !GetAtt Version.Version + Name: !Ref AliasName + ProvisionedConcurrencyConfig: + ProvisionedConcurrentExecutions: 1 + + DependsOn: + - LambdaFunction diff --git a/tests/aws/templates/cfn_lambda_code_signing_config.yml b/tests/aws/templates/cfn_lambda_code_signing_config.yml new file mode 100644 index 0000000000000..fbc9e7d79730d --- /dev/null +++ b/tests/aws/templates/cfn_lambda_code_signing_config.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: 2010-09-09 + +Parameters: + SignerArn: + Type: String + +Resources: + CodeSigningConfig: + Type: "AWS::Lambda::CodeSigningConfig" + Properties: + Description: "Code Signing" + AllowedPublishers: + SigningProfileVersionArns: + - !Ref SignerArn + CodeSigningPolicies: + UntrustedArtifactOnDeployment: Enforce + +Outputs: + RefValue: + Value: !Ref CodeSigningConfig + Arn: + Value: !GetAtt [CodeSigningConfig, CodeSigningConfigArn] + Id: + Value: !GetAtt [CodeSigningConfig, CodeSigningConfigId] diff --git a/tests/aws/templates/cfn_lambda_destinations.yaml b/tests/aws/templates/cfn_lambda_destinations.yaml new file mode 100644 index 0000000000000..c08c5c862159c --- /dev/null +++ b/tests/aws/templates/cfn_lambda_destinations.yaml @@ -0,0 +1,271 @@ +Parameters: + RetryParam: + Type: Number + Default: 0 + MaxEventAgeSecondsParam: + Type: Number + Default: 60 + QualifierParameter: + Type: String + Default: $LATEST + OnSuccessSwitch: + Type: Number + Default: 1 + MaxValue: 3 + MinValue: 0 + OnFailureSwitch: + Type: Number + Default: 1 + MaxValue: 3 + MinValue: 0 + +Resources: + FunctionServiceRole675BB04A: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + FunctionServiceRoleDefaultPolicy2F49994A: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: "*" + Effect: Allow + Resource: "*" + Version: "2012-10-17" + PolicyName: FunctionServiceRoleDefaultPolicy2F49994A + Roles: + - Ref: FunctionServiceRole675BB04A + Function76856677: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event, context): + should_fail = event.get("should_fail", "0") == "1" + message = event.get("message", "no message received") + + if should_fail: + raise Exception(message) + + return {"lstest_message": message} + Role: + Fn::GetAtt: + - FunctionServiceRole675BB04A + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - FunctionServiceRoleDefaultPolicy2F49994A + - FunctionServiceRole675BB04A + DestinationQueueCFE59110: + Type: AWS::SQS::Queue + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + DestinationTopicBA438545: + Type: AWS::SNS::Topic + DestinationBus49F1CD08: + Type: AWS::Events::EventBus + Properties: + Name: LambdaDestinationsStackDestinationBus6871B0FD + CollectFnServiceRoleF762C82B: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + CollectFnServiceRoleDefaultPolicy62B683B3: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:ReceiveMessage + - sqs:ChangeMessageVisibility + - sqs:GetQueueUrl + - sqs:DeleteMessage + - sqs:GetQueueAttributes + Effect: Allow + Resource: + Fn::GetAtt: + - DestinationQueueCFE59110 + - Arn + Version: "2012-10-17" + PolicyName: CollectFnServiceRoleDefaultPolicy62B683B3 + Roles: + - Ref: CollectFnServiceRoleF762C82B + CollectFn65CC4EC9: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + import json + + def handler(event, context): + print(json.dumps(event)) + return {"hello": "world"} # the return value here doesn't really matter + Role: + Fn::GetAtt: + - CollectFnServiceRoleF762C82B + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - CollectFnServiceRoleDefaultPolicy62B683B3 + - CollectFnServiceRoleF762C82B + CollectFnSqsEventSourceLambdaDestinationsStackDestinationQueueAC30B78B1A1DF4A5: + Type: AWS::Lambda::EventSourceMapping + Properties: + FunctionName: + Ref: CollectFn65CC4EC9 + BatchSize: 1 + EventSourceArn: + Fn::GetAtt: + - DestinationQueueCFE59110 + - Arn + CollectFnAllowInvokeLambdaDestinationsStackDestinationTopic1C03E78E417BD695: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - CollectFn65CC4EC9 + - Arn + Principal: sns.amazonaws.com + SourceArn: + Ref: DestinationTopicBA438545 + CollectFnDestinationTopicE5523079: + Type: AWS::SNS::Subscription + Properties: + Protocol: lambda + TopicArn: + Ref: DestinationTopicBA438545 + Endpoint: + Fn::GetAtt: + - CollectFn65CC4EC9 + - Arn + eic: + Type: AWS::Lambda::EventInvokeConfig + Properties: + FunctionName: + Ref: Function76856677 + Qualifier: + Ref: QualifierParameter + DestinationConfig: + OnFailure: + Destination: + Fn::Select: + - Ref: OnFailureSwitch + - - Fn::GetAtt: + - DestinationQueueCFE59110 + - Arn + - Ref: DestinationTopicBA438545 + - Fn::GetAtt: + - CollectFn65CC4EC9 + - Arn + - Fn::GetAtt: + - DestinationBus49F1CD08 + - Arn + OnSuccess: + Destination: + Fn::Select: + - Ref: OnSuccessSwitch + - - Fn::GetAtt: + - DestinationQueueCFE59110 + - Arn + - Ref: DestinationTopicBA438545 + - Fn::GetAtt: + - CollectFn65CC4EC9 + - Arn + - Fn::GetAtt: + - DestinationBus49F1CD08 + - Arn + MaximumEventAgeInSeconds: + Ref: MaxEventAgeSecondsParam + MaximumRetryAttempts: + Ref: RetryParam + ruleF2C1DCDC: + Type: AWS::Events::Rule + Properties: + EventBusName: + Ref: DestinationBus49F1CD08 + EventPattern: + resources: + - "*" + State: ENABLED + Targets: + - Arn: + Fn::GetAtt: + - CollectFn65CC4EC9 + - Arn + Id: Target0 + ruleAllowEventRuleLambdaDestinationsStackCollectFnE5027CC0363B5D5F: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - CollectFn65CC4EC9 + - Arn + Principal: events.amazonaws.com + SourceArn: + Fn::GetAtt: + - ruleF2C1DCDC + - Arn +Outputs: + LambdaName: + Value: + Ref: Function76856677 + LambdaArn: + Value: + Fn::GetAtt: + - Function76856677 + - Arn + DestinationQueueUrl: + Value: + Ref: DestinationQueueCFE59110 + DestinationQueueArn: + Value: + Fn::GetAtt: + - DestinationQueueCFE59110 + - Arn + DestinationTopicName: + Value: + Fn::GetAtt: + - DestinationTopicBA438545 + - TopicName + DestinationTopicArn: + Value: + Ref: DestinationTopicBA438545 + CollectLambdaName: + Value: + Ref: CollectFn65CC4EC9 + CollectLambdaArn: + Value: + Fn::GetAtt: + - CollectFn65CC4EC9 + - Arn diff --git a/tests/aws/templates/cfn_lambda_dynamodb_source.yaml b/tests/aws/templates/cfn_lambda_dynamodb_source.yaml new file mode 100644 index 0000000000000..317647c3f2752 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_dynamodb_source.yaml @@ -0,0 +1,99 @@ +Resources: + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fnServiceRoleDefaultPolicy0ED5D3E5: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: dynamodb:ListStreams + Effect: Allow + Resource: "*" + - Action: + - dynamodb:DescribeStream + - dynamodb:GetRecords + - dynamodb:GetShardIterator + Effect: Allow + Resource: + Fn::GetAtt: + - table8235A42E + - StreamArn + Version: "2012-10-17" + PolicyName: fnServiceRoleDefaultPolicy0ED5D3E5 + Roles: + - Ref: fnServiceRole5D180AFD + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - fnServiceRoleDefaultPolicy0ED5D3E5 + - fnServiceRole5D180AFD + fnDynamoDBEventSourceLambdaDynamodbSourceStacktable153BBA79064FDF1D: + Type: AWS::Lambda::EventSourceMapping + Properties: + FunctionName: + Ref: fn5FF616E3 + BatchSize: 1 + Enabled: true + EventSourceArn: + Fn::GetAtt: + - table8235A42E + - StreamArn + StartingPosition: TRIM_HORIZON + table8235A42E: + Type: AWS::DynamoDB::Table + Properties: + KeySchema: + - AttributeName: id + KeyType: HASH + AttributeDefinitions: + - AttributeName: id + AttributeType: S + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + UpdateReplacePolicy: Retain + DeletionPolicy: Retain +Outputs: + TableName: + Value: + Ref: table8235A42E + StreamArn: + Value: + Fn::GetAtt: + - table8235A42E + - StreamArn + FunctionName: + Value: + Ref: fn5FF616E3 + ESMId: + Value: + Ref: fnDynamoDBEventSourceLambdaDynamodbSourceStacktable153BBA79064FDF1D diff --git a/tests/aws/templates/cfn_lambda_event_invoke_config.yml b/tests/aws/templates/cfn_lambda_event_invoke_config.yml new file mode 100644 index 0000000000000..5faa67053efe3 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_event_invoke_config.yml @@ -0,0 +1,55 @@ +Resources: + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + function: + Type: AWS::Lambda::Function + Properties: + Handler: index.handler + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Code: + ZipFile: | + exports.handler = async (event) => { + console.log(JSON.stringify(event, null, 2)); + const response = { + statusCode: 200, + body: JSON.stringify('Hello from Lambda!'), + }; + return response; + }; + Runtime: nodejs20.x + TracingConfig: + Mode: Active + version: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref function + asyncconfig: + Type: AWS::Lambda::EventInvokeConfig + Properties: + FunctionName: !Ref function + MaximumEventAgeInSeconds: 300 + MaximumRetryAttempts: 1 + Qualifier: !GetAtt version.Version + +Outputs: + FunctionName: + Value: !Ref function + FunctionQualifier: + Value: !GetAtt version.Version diff --git a/tests/aws/templates/cfn_lambda_kinesis_source.yaml b/tests/aws/templates/cfn_lambda_kinesis_source.yaml new file mode 100644 index 0000000000000..9463c8d84b04f --- /dev/null +++ b/tests/aws/templates/cfn_lambda_kinesis_source.yaml @@ -0,0 +1,98 @@ +Resources: + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fnServiceRoleDefaultPolicy0ED5D3E5: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - kinesis:DescribeStreamSummary + - kinesis:GetRecords + - kinesis:GetShardIterator + - kinesis:ListShards + - kinesis:SubscribeToShard + - kinesis:DescribeStream + - kinesis:ListStreams + Effect: Allow + Resource: + Fn::GetAtt: + - stream19075594 + - Arn + - Action: kinesis:DescribeStream + Effect: Allow + Resource: + Fn::GetAtt: + - stream19075594 + - Arn + Version: "2012-10-17" + PolicyName: fnServiceRoleDefaultPolicy0ED5D3E5 + Roles: + - Ref: fnServiceRole5D180AFD + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - fnServiceRoleDefaultPolicy0ED5D3E5 + - fnServiceRole5D180AFD + fnKinesisEventSourceLambdaKinesisSourceStackstream996A3395ED86A30E: + Type: AWS::Lambda::EventSourceMapping + Properties: + FunctionName: + Ref: fn5FF616E3 + BatchSize: 1 + Enabled: true + EventSourceArn: + Fn::GetAtt: + - stream19075594 + - Arn + MaximumBatchingWindowInSeconds: 10 + StartingPosition: TRIM_HORIZON + stream19075594: + Type: AWS::Kinesis::Stream + Properties: + RetentionPeriodHours: 24 + ShardCount: 1 + StreamModeDetails: + StreamMode: PROVISIONED +Outputs: + StreamName: + Value: + Ref: stream19075594 + StreamArn: + Value: + Fn::GetAtt: + - stream19075594 + - Arn + FunctionName: + Value: + Ref: fn5FF616E3 + ESMId: + Value: + Ref: fnKinesisEventSourceLambdaKinesisSourceStackstream996A3395ED86A30E diff --git a/tests/aws/templates/cfn_lambda_logging_config.yaml b/tests/aws/templates/cfn_lambda_logging_config.yaml new file mode 100644 index 0000000000000..547b60f9466c9 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_logging_config.yaml @@ -0,0 +1,52 @@ +AWSTemplateFormatVersion: 2010-09-09 + +Parameters: + FunctionName: + Type: String + +Resources: + MyFnServiceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + + LambdaFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Ref FunctionName + Code: + ZipFile: | + def handler(event, context): + return { + statusCode: 200, + body: "Hello, World!" + } + Role: + Fn::GetAtt: + - MyFnServiceRole + - Arn + Handler: index.handler + Runtime: python3.12 + LoggingConfig: + LogFormat: JSON + DependsOn: + - MyFnServiceRole + + Version: + Type: AWS::Lambda::Version + Properties: + FunctionName: !Ref LambdaFunction + Description: v1 + diff --git a/tests/aws/templates/cfn_lambda_permission.yml b/tests/aws/templates/cfn_lambda_permission.yml new file mode 100644 index 0000000000000..e939983aec4e0 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_permission.yml @@ -0,0 +1,54 @@ +Parameters: + PrincipalForPermission: + Type: String + Default: '*' + +Resources: + topic69831491: + Type: AWS::SNS::Topic + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - fnServiceRole5D180AFD + fnAllowInvokeLambdaPermissionsStacktopicF723B1A748672DB5: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - fn5FF616E3 + - Arn + Principal: !Ref PrincipalForPermission + +Outputs: + FunctionName: + Value: !Ref fn5FF616E3 \ No newline at end of file diff --git a/tests/aws/templates/cfn_lambda_permission_multiple.yaml b/tests/aws/templates/cfn_lambda_permission_multiple.yaml new file mode 100644 index 0000000000000..985d297baa41f --- /dev/null +++ b/tests/aws/templates/cfn_lambda_permission_multiple.yaml @@ -0,0 +1,65 @@ +Resources: + FunctionServiceRole675BB04A: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Function76856677: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + def handler(event, context): + return {"hello": "world"} + Role: + Fn::GetAtt: + - FunctionServiceRole675BB04A + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - FunctionServiceRole675BB04A + FunctionInvoketnlpEgfIKpZyEIAINyVhXFSIfV5EJ7IaMp0cNGOcC4227DF5: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - Function76856677 + - Arn + Principal: states.amazonaws.com + FunctionInvokearOgSVH4Ts0qbAOHNp0VMi6g5gzMbVQFH2pumFohAQA578E6CA: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - Function76856677 + - Arn + Principal: lambda.amazonaws.com +Outputs: + LambdaName: + Value: + Ref: Function76856677 + LambdaArn: + Value: + Fn::GetAtt: + - Function76856677 + - Arn + PermissionLambda: + Value: + Ref: FunctionInvokearOgSVH4Ts0qbAOHNp0VMi6g5gzMbVQFH2pumFohAQA578E6CA + PermissionStates: + Value: + Ref: FunctionInvoketnlpEgfIKpZyEIAINyVhXFSIfV5EJ7IaMp0cNGOcC4227DF5 diff --git a/tests/aws/templates/cfn_lambda_s3_code.yaml b/tests/aws/templates/cfn_lambda_s3_code.yaml new file mode 100644 index 0000000000000..a6b57984f0c0e --- /dev/null +++ b/tests/aws/templates/cfn_lambda_s3_code.yaml @@ -0,0 +1,48 @@ +Parameters: + LambdaCodeBucket: + Type: String + LambdaRuntime: + Type: String + LambdaHandler: + Type: String +Resources: + FunctionServiceRole675BB04A: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Function76856677: + Type: AWS::Lambda::Function + Properties: + Code: + S3Bucket: + Ref: LambdaCodeBucket + S3Key: handler.zip + Role: + Fn::GetAtt: + - FunctionServiceRole675BB04A + - Arn + Handler: !Ref LambdaHandler + Runtime: !Ref LambdaRuntime + DependsOn: + - FunctionServiceRole675BB04A +Outputs: + LambdaName: + Value: + Ref: Function76856677 + LambdaArn: + Value: + Fn::GetAtt: + - Function76856677 + - Arn diff --git a/tests/aws/templates/cfn_lambda_serverless.yml b/tests/aws/templates/cfn_lambda_serverless.yml new file mode 100644 index 0000000000000..defb61edef584 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_serverless.yml @@ -0,0 +1,243 @@ +{ + "Parameters": { + "LambdaCodeBucket": { + "Type": "String" + } + }, + "Resources": { + "ServerlessDeploymentBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "LambdaCodeBucket" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:*", + "Effect": "Deny", + "Principal": "*", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "LambdaCodeBucket" + }, + "/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "LambdaCodeBucket" + } + ] + ] + } + ], + "Condition": { + "Bool": { + "aws:SecureTransport": false + } + } + } + ] + } + } + }, + "FailingLambdaLogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": "/aws/lambda/dlq-local-failingLambda" + } + }, + "IamRoleLambdaExecution": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + } + ] + }, + "Policies": [ + { + "PolicyName": { + "Fn::Join": [ + "-", + [ + "dlq", + "local", + "lambda" + ] + ] + }, + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:TagResource" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/dlq-local*:*" + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "logs:PutLogEvents" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/dlq-local*:*:*" + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "sqs:SendMessage" + ], + "Resource": "*" + } + ] + } + } + ], + "Path": "/", + "RoleName": { + "Fn::Join": [ + "-", + [ + "dlq", + "local", + { + "Ref": "AWS::Region" + }, + "lambdaRole" + ] + ] + } + } + }, + "FailingLambdaLambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "LambdaCodeBucket" + }, + "S3Key": "serverless/dlq/local/1701682216701-2023-12-04T09:30:16.701Z/dlq.zip" + }, + "Handler": "handler.handler", + "Runtime": "python3.10", + "FunctionName": "dlq-local-failingLambda", + "MemorySize": 1024, + "Timeout": 6, + "DeadLetterConfig": { + "TargetArn": { + "Fn::GetAtt": [ + "lambdaDlq", + "Arn" + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "IamRoleLambdaExecution", + "Arn" + ] + } + }, + "DependsOn": [ + "FailingLambdaLogGroup" + ] + }, + "FailingLambdaLambdaVersionnC6v2YCTPSMh0eRfuXJo0SItS8bZeDb7nxV6QJyTSg": { + "Type": "AWS::Lambda::Version", + "DeletionPolicy": "Retain", + "Properties": { + "FunctionName": { + "Ref": "FailingLambdaLambdaFunction" + } + } + }, + "FailingLambdaLambdaEvConf": { + "Type": "AWS::Lambda::EventInvokeConfig", + "Properties": { + "FunctionName": { + "Ref": "FailingLambdaLambdaFunction" + }, + "DestinationConfig": {}, + "Qualifier": "$LATEST", + "MaximumRetryAttempts": 0 + } + }, + "lambdaDlq": { + "Type": "AWS::SQS::Queue", + "Properties": { + "QueueName": "lambdaDlq" + } + }, + }, + "Outputs": { + "FailingLambdaLambdaFunctionQualifiedArn": { + "Description": "Current Lambda function version", + "Value": { + "Ref": "FailingLambdaLambdaVersionnC6v2YCTPSMh0eRfuXJo0SItS8bZeDb7nxV6QJyTSg" + }, + "Export": { + "Name": "sls-dlq-local-FailingLambdaLambdaFunctionQualifiedArn" + } + }, + "LambdaName": { + "Description": "Current lambda function name", + "Value": { + "Ref": "FailingLambdaLambdaFunction" + }, + "Export": { + "Name": "LambdaName" + } + }, + "DLQName": { + "Description": "Current DLQ name", + "Value": { + "Ref": "lambdaDlq" + }, + "Export": { + "Name": "DLQName" + } + }, + } +} diff --git a/tests/aws/templates/cfn_lambda_simple.yaml b/tests/aws/templates/cfn_lambda_simple.yaml new file mode 100644 index 0000000000000..23f974461cfea --- /dev/null +++ b/tests/aws/templates/cfn_lambda_simple.yaml @@ -0,0 +1,37 @@ +Resources: + SimpleFnServiceRole6574647D: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + SimpleFn7D0601E0: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - SimpleFnServiceRole6574647D + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - SimpleFnServiceRole6574647D +Outputs: + FunctionName: + Value: + Ref: SimpleFn7D0601E0 diff --git a/tests/aws/templates/cfn_lambda_sns_permissions.yaml b/tests/aws/templates/cfn_lambda_sns_permissions.yaml new file mode 100644 index 0000000000000..0f0dd3605a54e --- /dev/null +++ b/tests/aws/templates/cfn_lambda_sns_permissions.yaml @@ -0,0 +1,69 @@ +Resources: + topic69831491: + Type: AWS::SNS::Topic + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - fnServiceRole5D180AFD + fnAllowInvokeLambdaPermissionsStacktopicF723B1A748672DB5: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - fn5FF616E3 + - Arn + Principal: sns.amazonaws.com + SourceArn: + Ref: topic69831491 + fntopic09ED913A: + Type: AWS::SNS::Subscription + Properties: + Protocol: lambda + TopicArn: + Ref: topic69831491 + Endpoint: + Fn::GetAtt: + - fn5FF616E3 + - Arn +Outputs: + FunctionName: + Value: + Ref: fn5FF616E3 + TopicName: + Value: + Fn::GetAtt: + - topic69831491 + - TopicName + TopicArn: + Value: + Ref: topic69831491 diff --git a/tests/aws/templates/cfn_lambda_sqs_source.yaml b/tests/aws/templates/cfn_lambda_sqs_source.yaml new file mode 100644 index 0000000000000..8cfe496e28d58 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_sqs_source.yaml @@ -0,0 +1,80 @@ +Resources: + q14836DC8: + Type: AWS::SQS::Queue + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fnServiceRoleDefaultPolicy0ED5D3E5: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:ReceiveMessage + - sqs:ChangeMessageVisibility + - sqs:GetQueueUrl + - sqs:DeleteMessage + - sqs:GetQueueAttributes + Effect: Allow + Resource: + Fn::GetAtt: + - q14836DC8 + - Arn + Version: "2012-10-17" + PolicyName: fnServiceRoleDefaultPolicy0ED5D3E5 + Roles: + - Ref: fnServiceRole5D180AFD + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - fnServiceRoleDefaultPolicy0ED5D3E5 + - fnServiceRole5D180AFD + fnSqsEventSourceLambdaSqsSourceStackq2097017B53C3FF8C: + Type: AWS::Lambda::EventSourceMapping + Properties: + FunctionName: + Ref: fn5FF616E3 + BatchSize: 1 + Enabled: true + EventSourceArn: + Fn::GetAtt: + - q14836DC8 + - Arn +Outputs: + QueueUrl: + Value: + Ref: q14836DC8 + FunctionName: + Value: + Ref: fn5FF616E3 + ESMId: + Value: + Ref: fnSqsEventSourceLambdaSqsSourceStackq2097017B53C3FF8C diff --git a/tests/aws/templates/cfn_lambda_version.yaml b/tests/aws/templates/cfn_lambda_version.yaml new file mode 100644 index 0000000000000..be448001e1e14 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_version.yaml @@ -0,0 +1,51 @@ +Resources: + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + import os + + def handler(event, context): + function_version = os.environ["AWS_LAMBDA_FUNCTION_VERSION"] + print(f"{function_version=}") + return {"function_version": function_version} + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.12 + DependsOn: + - fnServiceRole5D180AFD + fnVersion7BF8AE5A: + Type: AWS::Lambda::Version + Properties: + FunctionName: + Ref: fn5FF616E3 + Description: test description +Outputs: + FunctionName: + Value: + Ref: fn5FF616E3 + FunctionVersion: + Value: + Fn::GetAtt: + - fnVersion7BF8AE5A + - Version diff --git a/tests/aws/templates/cfn_lambda_version_provisioned_concurrency.yaml b/tests/aws/templates/cfn_lambda_version_provisioned_concurrency.yaml new file mode 100644 index 0000000000000..b6461d6f1df8d --- /dev/null +++ b/tests/aws/templates/cfn_lambda_version_provisioned_concurrency.yaml @@ -0,0 +1,54 @@ +Resources: + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + import os + + def handler(event, context): + init_type = os.environ["AWS_LAMBDA_INITIALIZATION_TYPE"] + print(f"{init_type=}") + return {"initialization_type": init_type} + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.12 + DependsOn: + - fnServiceRole5D180AFD + fnVersion7BF8AE5A: + Type: AWS::Lambda::Version + Properties: + FunctionName: + Ref: fn5FF616E3 + Description: test description + ProvisionedConcurrencyConfig: + ProvisionedConcurrentExecutions: 1 + +Outputs: + FunctionName: + Value: + Ref: fn5FF616E3 + FunctionVersion: + Value: + Fn::GetAtt: + - fnVersion7BF8AE5A + - Version diff --git a/tests/aws/templates/cfn_lambda_vpc.yaml b/tests/aws/templates/cfn_lambda_vpc.yaml new file mode 100644 index 0000000000000..b3b1995ee1428 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_vpc.yaml @@ -0,0 +1,311 @@ +Resources: + vpcA2121C38: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsHostnames: true + EnableDnsSupport: true + InstanceTenancy: default + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc + vpcPublicSubnet1Subnet2E65531E: + Type: AWS::EC2::Subnet + Properties: + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: "" + CidrBlock: 10.0.0.0/18 + MapPublicIpOnLaunch: true + Tags: + - Key: aws-cdk:subnet-name + Value: Public + - Key: aws-cdk:subnet-type + Value: Public + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet1 + vpcPublicSubnet1RouteTable48A2DF9B: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet1 + vpcPublicSubnet1RouteTableAssociation5D3F4579: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcPublicSubnet1RouteTable48A2DF9B + SubnetId: + Ref: vpcPublicSubnet1Subnet2E65531E + vpcPublicSubnet1DefaultRoute10708846: + Type: AWS::EC2::Route + Properties: + RouteTableId: + Ref: vpcPublicSubnet1RouteTable48A2DF9B + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: + Ref: vpcIGWE57CBDCA + DependsOn: + - vpcVPCGW7984C166 + vpcPublicSubnet1EIPDA49DCBE: + Type: AWS::EC2::EIP + Properties: + Domain: vpc + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet1 + vpcPublicSubnet1NATGateway9C16659E: + Type: AWS::EC2::NatGateway + Properties: + SubnetId: + Ref: vpcPublicSubnet1Subnet2E65531E + AllocationId: + Fn::GetAtt: + - vpcPublicSubnet1EIPDA49DCBE + - AllocationId + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet1 + DependsOn: + - vpcPublicSubnet1DefaultRoute10708846 + - vpcPublicSubnet1RouteTableAssociation5D3F4579 + vpcPublicSubnet2Subnet009B674F: + Type: AWS::EC2::Subnet + Properties: + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 1 + - Fn::GetAZs: "" + CidrBlock: 10.0.64.0/18 + MapPublicIpOnLaunch: true + Tags: + - Key: aws-cdk:subnet-name + Value: Public + - Key: aws-cdk:subnet-type + Value: Public + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet2 + vpcPublicSubnet2RouteTableEB40D4CB: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet2 + vpcPublicSubnet2RouteTableAssociation21F81B59: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcPublicSubnet2RouteTableEB40D4CB + SubnetId: + Ref: vpcPublicSubnet2Subnet009B674F + vpcPublicSubnet2DefaultRouteA1EC0F60: + Type: AWS::EC2::Route + Properties: + RouteTableId: + Ref: vpcPublicSubnet2RouteTableEB40D4CB + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: + Ref: vpcIGWE57CBDCA + DependsOn: + - vpcVPCGW7984C166 + vpcPublicSubnet2EIP9B3743B1: + Type: AWS::EC2::EIP + Properties: + Domain: vpc + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet2 + vpcPublicSubnet2NATGateway9B8AE11A: + Type: AWS::EC2::NatGateway + Properties: + SubnetId: + Ref: vpcPublicSubnet2Subnet009B674F + AllocationId: + Fn::GetAtt: + - vpcPublicSubnet2EIP9B3743B1 + - AllocationId + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PublicSubnet2 + DependsOn: + - vpcPublicSubnet2DefaultRouteA1EC0F60 + - vpcPublicSubnet2RouteTableAssociation21F81B59 + vpcPrivateSubnet1Subnet934893E8: + Type: AWS::EC2::Subnet + Properties: + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: "" + CidrBlock: 10.0.128.0/18 + MapPublicIpOnLaunch: false + Tags: + - Key: aws-cdk:subnet-name + Value: Private + - Key: aws-cdk:subnet-type + Value: Private + - Key: Name + Value: LambdaCfnStatesStack/vpc/PrivateSubnet1 + vpcPrivateSubnet1RouteTableB41A48CC: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PrivateSubnet1 + vpcPrivateSubnet1RouteTableAssociation67945127: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcPrivateSubnet1RouteTableB41A48CC + SubnetId: + Ref: vpcPrivateSubnet1Subnet934893E8 + vpcPrivateSubnet1DefaultRoute1AA8E2E5: + Type: AWS::EC2::Route + Properties: + RouteTableId: + Ref: vpcPrivateSubnet1RouteTableB41A48CC + DestinationCidrBlock: 0.0.0.0/0 + NatGatewayId: + Ref: vpcPublicSubnet1NATGateway9C16659E + vpcPrivateSubnet2Subnet7031C2BA: + Type: AWS::EC2::Subnet + Properties: + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 1 + - Fn::GetAZs: "" + CidrBlock: 10.0.192.0/18 + MapPublicIpOnLaunch: false + Tags: + - Key: aws-cdk:subnet-name + Value: Private + - Key: aws-cdk:subnet-type + Value: Private + - Key: Name + Value: LambdaCfnStatesStack/vpc/PrivateSubnet2 + vpcPrivateSubnet2RouteTable7280F23E: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc/PrivateSubnet2 + vpcPrivateSubnet2RouteTableAssociation007E94D3: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcPrivateSubnet2RouteTable7280F23E + SubnetId: + Ref: vpcPrivateSubnet2Subnet7031C2BA + vpcPrivateSubnet2DefaultRouteB0E07F99: + Type: AWS::EC2::Route + Properties: + RouteTableId: + Ref: vpcPrivateSubnet2RouteTable7280F23E + DestinationCidrBlock: 0.0.0.0/0 + NatGatewayId: + Ref: vpcPublicSubnet2NATGateway9B8AE11A + vpcIGWE57CBDCA: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - Key: Name + Value: LambdaCfnStatesStack/vpc + vpcVPCGW7984C166: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: + Ref: vpcA2121C38 + InternetGatewayId: + Ref: vpcIGWE57CBDCA + SimpleFnServiceRole6574647D: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole + DependsOn: + - vpcPrivateSubnet1DefaultRoute1AA8E2E5 + - vpcPrivateSubnet1RouteTableAssociation67945127 + - vpcPrivateSubnet2DefaultRouteB0E07F99 + - vpcPrivateSubnet2RouteTableAssociation007E94D3 + SimpleFnSecurityGroup54272DA3: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Automatic security group for Lambda Function LambdaCfnStatesStackSimpleFnBE5FE3BD + SecurityGroupEgress: + - CidrIp: 0.0.0.0/0 + Description: Allow all outbound traffic by default + IpProtocol: "-1" + VpcId: + Ref: vpcA2121C38 + DependsOn: + - vpcPrivateSubnet1DefaultRoute1AA8E2E5 + - vpcPrivateSubnet1RouteTableAssociation67945127 + - vpcPrivateSubnet2DefaultRouteB0E07F99 + - vpcPrivateSubnet2RouteTableAssociation007E94D3 + SimpleFn7D0601E0: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - SimpleFnServiceRole6574647D + - Arn + FunctionName: + Ref: FunctionNameParam + Handler: index.handler + Runtime: python3.9 + VpcConfig: + SecurityGroupIds: + - Fn::GetAtt: + - SimpleFnSecurityGroup54272DA3 + - GroupId + SubnetIds: + - Ref: vpcPrivateSubnet1Subnet934893E8 + - Ref: vpcPrivateSubnet2Subnet7031C2BA + DependsOn: + - SimpleFnServiceRole6574647D + - vpcPrivateSubnet1DefaultRoute1AA8E2E5 + - vpcPrivateSubnet1RouteTableAssociation67945127 + - vpcPrivateSubnet2DefaultRouteB0E07F99 + - vpcPrivateSubnet2RouteTableAssociation007E94D3 +Parameters: + FunctionNameParam: + Type: String diff --git a/tests/aws/templates/cfn_lambda_with_external_api_paths_in_env_vars.yaml b/tests/aws/templates/cfn_lambda_with_external_api_paths_in_env_vars.yaml new file mode 100644 index 0000000000000..21161124d0ed0 --- /dev/null +++ b/tests/aws/templates/cfn_lambda_with_external_api_paths_in_env_vars.yaml @@ -0,0 +1,43 @@ +Resources: + SimpleFnServiceRole6574647D: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + SimpleFn7D0601E0: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + def handler(event, context): + print(event) + return "hello" + Role: + Fn::GetAtt: + - SimpleFnServiceRole6574647D + - Arn + Handler: index.handler + Runtime: python3.9 + Environment: + Variables: + API_URL_1: https://api.example.com + API_URL_2: https://storage.execute-api.us-east-2.amazonaws.com/test-resource + API_URL_3: https://reporting.execute-api.us-east-1.amazonaws.com/test-resource + API_URL_4: https://blockchain.execute-api.us-west-1.amazonaws.com/test-resource + DependsOn: + - SimpleFnServiceRole6574647D +Outputs: + FunctionName: + Value: + Ref: SimpleFn7D0601E0 diff --git a/tests/aws/templates/cfn_lambda_with_tags.yml b/tests/aws/templates/cfn_lambda_with_tags.yml new file mode 100644 index 0000000000000..b91aad4e5e67a --- /dev/null +++ b/tests/aws/templates/cfn_lambda_with_tags.yml @@ -0,0 +1,25 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 + +Parameters: + FunctionName: + Type: String + Environment: + Type: String + +Resources: + TestFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Ref FunctionName + InlineCode: | + def handler(event, context): + return {'body': 'Hello World!', 'statusCode': 200} + Handler: index.handler + Runtime: python3.11 + Tags: + Environment: !Ref Environment + +Outputs: + FunctionName: + Value: !Ref TestFunction diff --git a/tests/aws/templates/cfn_languageextensions_foreach.yml b/tests/aws/templates/cfn_languageextensions_foreach.yml new file mode 100644 index 0000000000000..f84d89dad543c --- /dev/null +++ b/tests/aws/templates/cfn_languageextensions_foreach.yml @@ -0,0 +1,22 @@ +# Example taken from AWS documentation +# https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/intrinsic-function-reference-foreach-example-resource.html#intrinsic-function-reference-foreach-example-replicate-resource + +AWSTemplateFormatVersion: 2010-09-09 +Transform: 'AWS::LanguageExtensions' +Parameters: + pRepoARNs: + Description: ARN of SSO instance + Type: CommaDelimitedList +Resources: + 'Fn::ForEach::Topics': + - TopicName + - !Ref pRepoARNs + - 'SnsTopic${TopicName}': + Type: 'AWS::SNS::Topic' + Properties: + TopicName: + 'Fn::Join': + - '.' + - - !Ref TopicName + - fifo + FifoTopic: true \ No newline at end of file diff --git a/tests/aws/templates/cfn_languageextensions_foreach_multiple_resources.yml b/tests/aws/templates/cfn_languageextensions_foreach_multiple_resources.yml new file mode 100644 index 0000000000000..eb42ef9404986 --- /dev/null +++ b/tests/aws/templates/cfn_languageextensions_foreach_multiple_resources.yml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: 2010-09-09 +Transform: 'AWS::LanguageExtensions' +Resources: + 'Fn::ForEach::TopLevel': + - Prefix + - [ Foo, Bar ] + - 'My${Prefix}Parameter': + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Sub "my value ${Prefix}" + 'Fn::ForEach::LowerLevel': + - Suffix + - [ A, B, C ] + - 'My${Prefix}Parameter${Suffix}': + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Sub "my value ${Suffix}" diff --git a/tests/aws/templates/cfn_languageextensions_length.yml b/tests/aws/templates/cfn_languageextensions_length.yml new file mode 100644 index 0000000000000..76395b3c9b5b3 --- /dev/null +++ b/tests/aws/templates/cfn_languageextensions_length.yml @@ -0,0 +1,14 @@ +Transform: AWS::LanguageExtensions +Parameters: + QueueList: + Type: CommaDelimitedList +Resources: + MyParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + 'Fn::Length': !Ref QueueList +Outputs: + ParameterName: + Value: !Ref MyParameter diff --git a/tests/aws/templates/cfn_languageextensions_ryanair.yml b/tests/aws/templates/cfn_languageextensions_ryanair.yml new file mode 100644 index 0000000000000..d07a39f53d569 --- /dev/null +++ b/tests/aws/templates/cfn_languageextensions_ryanair.yml @@ -0,0 +1,98 @@ +Transform: AWS::LanguageExtensions +Parameters: + AppSyncSubscriptionFilterNames: + Type: CommaDelimitedList + AppSyncServerEventNames: + Type: CommaDelimitedList +Resources: + GraphQLApi: + Type: AWS::AppSync::GraphQLApi + Properties: + Name: !Sub ${AWS::StackName}_api + AuthenticationType: API_KEY + + GraphQLNoneDataSource: + Type: AWS::AppSync::DataSource + Properties: + ApiId: !GetAtt GraphQLApi.ApiId + Name: noneds + Type: NONE + + GraphQLApiSchema: + Type: AWS::AppSync::GraphQLSchema + Properties: + ApiId: !GetAtt GraphQLApi.ApiId + Definition: | + + input PublishServerEvent1Input { + value: String! + } + + input PublishServerEvent2Input { + value: String! + } + + type Query { + _empty: String + } + + type Subscription { + onEvent1: String + @aws_subscribe(mutations: ["publishServerEvent1"]) + onEvent2: String + @aws_subscribe(mutations: ["publishServerEvent2"]) + } + + type Mutation { + publishServerEvent1(input: PublishServerEvent1Input!): String + publishServerEvent2(input: PublishServerEvent2Input!): String + } + + schema { + query: Query + mutation: Mutation + subscription: Subscription + } + + Fn::ForEach::Subscriptions: + - EventName + - !Ref AppSyncSubscriptionFilterNames + - GraphQLResolverPublish${EventName}Subscription: + Type: AWS::AppSync::Resolver + DependsOn: + - GraphQLApiSchema + Properties: + ApiId: !GetAtt GraphQLApi.ApiId + DataSourceName: !GetAtt GraphQLNoneDataSource.Name + TypeName: Subscription + FieldName: !Sub "on${EventName}" + Runtime: + Name: APPSYNC_JS + RuntimeVersion: 1.0.0 + Code: | + export function request(ctx) {} + + export function response(ctx) {} + + Fn::ForEach::Mutations: + - EventName + - !Ref AppSyncServerEventNames + - GraphQLResolverPublish${EventName}Mutation: + Type: AWS::AppSync::Resolver + DependsOn: + - GraphQLApiSchema + Properties: + ApiId: !GetAtt GraphQLApi.ApiId + DataSourceName: !GetAtt GraphQLNoneDataSource.Name + TypeName: Mutation + FieldName: !Sub "publish${EventName}" + RequestMappingTemplate: | + { + "version": "2017-02-28", + "payload": $util.toJson($context.arguments) + } + ResponseMappingTemplate: | + $util.toJson($context.result) +Outputs: + GraphQLApiArn: + Value: !Ref GraphQLApi \ No newline at end of file diff --git a/tests/aws/templates/cfn_languageextensions_tojsonstring.yml b/tests/aws/templates/cfn_languageextensions_tojsonstring.yml new file mode 100644 index 0000000000000..a4430321abef6 --- /dev/null +++ b/tests/aws/templates/cfn_languageextensions_tojsonstring.yml @@ -0,0 +1,24 @@ +Transform: AWS::LanguageExtensions +Resources: + MyObjectParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::ToJsonString: + a: foo + b: bar + MyArrayParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::ToJsonString: + - a + - b + - c +Outputs: + ObjectName: + Value: !Ref MyObjectParameter + ArrayName: + Value: !Ref MyArrayParameter diff --git a/tests/aws/templates/cfn_macro_languageextensions.yaml b/tests/aws/templates/cfn_macro_languageextensions.yaml new file mode 100644 index 0000000000000..6bfc3ae250e7e --- /dev/null +++ b/tests/aws/templates/cfn_macro_languageextensions.yaml @@ -0,0 +1,16 @@ +# from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/transform-aws-languageextensions.html +AWSTemplateFormatVersion: 2010-09-09 +Transform: 'AWS::LanguageExtensions' +Parameters: + QueueList: + Type: CommaDelimitedList + QueueNameParam: + Description: Name for your SQS queue + Type: String +Resources: + Queue: + Type: 'AWS::SQS::Queue' + Properties: + QueueName: !Ref QueueNameParam + DelaySeconds: + 'Fn::Length': !Ref QueueList diff --git a/tests/aws/templates/cfn_no_echo.yml b/tests/aws/templates/cfn_no_echo.yml new file mode 100644 index 0000000000000..0442707ad09c3 --- /dev/null +++ b/tests/aws/templates/cfn_no_echo.yml @@ -0,0 +1,32 @@ +AWSTemplateFormatVersion: "2010-09-09" + +Parameters: + NormalParameter: + Type: String + Description: "Some normal parameter here" + Default: "Some default value here" + SecretParameter: + Type: String + NoEcho: true + Description: "Secret value here" + SecretParameterWithDefault: + Type: String + NoEcho: true + Description: "Secret value here" + Default: "Default secret value here" + +Resources: + LocalBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: cfn-noecho-bucket + Tags: + - Key: SecretTag + Value: !Ref SecretParameter + Metadata: + SensitiveData: !Ref SecretParameter + +Outputs: + SecretValue: + Description: "Secret value from parameter" + Value: !Ref SecretParameter diff --git a/tests/aws/templates/cfn_number_in_sub.yml b/tests/aws/templates/cfn_number_in_sub.yml new file mode 100644 index 0000000000000..bd2c1e20fccf6 --- /dev/null +++ b/tests/aws/templates/cfn_number_in_sub.yml @@ -0,0 +1,20 @@ +Parameters: + ParameterName: + Type: String + + MyNumber: + Type: Number + Default: 3 + +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Name: + Ref: ParameterName + Type: String + Value: + Fn::Sub: + - "my number is ${numberRef}" + - numberRef: + Ref: MyNumber diff --git a/tests/aws/templates/cfn_parameter_list_type.yaml b/tests/aws/templates/cfn_parameter_list_type.yaml new file mode 100644 index 0000000000000..f25aebb03bd27 --- /dev/null +++ b/tests/aws/templates/cfn_parameter_list_type.yaml @@ -0,0 +1,14 @@ +Parameters: + ParamsList: + # fun fact: this type works but is not documented in the docs + # see: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html + Type: List +Resources: + MyParam: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Join ["|", !Ref ParamsList] +Outputs: + ParamValue: + Value: !GetAtt MyParam.Value diff --git a/tests/aws/templates/cfn_redshift.yaml b/tests/aws/templates/cfn_redshift.yaml new file mode 100644 index 0000000000000..3f08ebf8ad0f2 --- /dev/null +++ b/tests/aws/templates/cfn_redshift.yaml @@ -0,0 +1,18 @@ +Resources: + Cluster: + Type: AWS::Redshift::Cluster + Properties: + ClusterIdentifier: mysamplecluster + ClusterType: single-node + DBName: db + MasterUserPassword: MasterPassword123 + MasterUsername: masteruser + NodeType: ra3.xlplus + +Outputs: + ClusterRef: + Value: !Ref Cluster + ClusterAttEndpointPort: + Value: !GetAtt Cluster.Endpoint.Port + ClusterAttEndpointAddress: + Value: !GetAtt Cluster.Endpoint.Address diff --git a/tests/aws/templates/cfn_ref_unsupported.yml b/tests/aws/templates/cfn_ref_unsupported.yml new file mode 100644 index 0000000000000..d8bf9958262a0 --- /dev/null +++ b/tests/aws/templates/cfn_ref_unsupported.yml @@ -0,0 +1,21 @@ +Resources: + UnknownResource: + Type: AWS::LocalStack::Unknown + Properties: + ComputePlatform: Lambda + + Parameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::Sub: + - "The value of the attribute is: ${value}" + - value: !GetAtt UnknownResource.NotReal + +Outputs: + reference: + Value: !Ref UnknownResource + + parameter: + Value: !GetAtt Parameter.Value diff --git a/tests/aws/templates/cfn_reuse_param.yaml b/tests/aws/templates/cfn_reuse_param.yaml new file mode 100644 index 0000000000000..c67032468565d --- /dev/null +++ b/tests/aws/templates/cfn_reuse_param.yaml @@ -0,0 +1,35 @@ +Parameters: + DeployParam: + Type: String + Default: "yes" + CustomTag: + Type: String + Default: CustomValue +Resources: + requiredTopic979DB646: + Type: AWS::SNS::Topic + Properties: + Tags: + - Key: CustomTag + Value: + Ref: CustomTag + optionalTopicC9EB7872: + Type: AWS::SNS::Topic + Condition: ShouldHaveTopic +Outputs: + RequiredTopicOutput: + Value: + Fn::GetAtt: + - requiredTopic979DB646 + - TopicName + TopicNameOutput: + Value: + Fn::GetAtt: + - optionalTopicC9EB7872 + - TopicName + Condition: ShouldHaveTopic +Conditions: + ShouldHaveTopic: + Fn::Equals: + - Ref: DeployParam + - "yes" diff --git a/tests/aws/templates/cfn_sub_resovling.yaml b/tests/aws/templates/cfn_sub_resovling.yaml new file mode 100644 index 0000000000000..101a5869d1651 --- /dev/null +++ b/tests/aws/templates/cfn_sub_resovling.yaml @@ -0,0 +1,26 @@ +Parameters: + MyParam: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref MyParam + +Outputs: + MyTopicRef: + Value: !Ref MyTopic + MyTopicName: + Value: !GetAtt MyTopic.TopicName + MyTopicArn: + Value: !GetAtt MyTopic.TopicArn + MyTopicSub: + Value: !Sub '${MyParam}|${MyTopic}|${MyTopic.TopicName}|${MyTopic.TopicArn}' + MyTopicSubWithMap: + Value: + Fn::Sub: + - '${AttInMap}|${RefInMap}|${StaticInMap}' + - AttInMap: !GetAtt MyTopic.TopicName + RefInMap: !Ref MyTopic + StaticInMap: something diff --git a/tests/aws/templates/cfn_unsupported.yaml b/tests/aws/templates/cfn_unsupported.yaml new file mode 100644 index 0000000000000..1c72f7fac5605 --- /dev/null +++ b/tests/aws/templates/cfn_unsupported.yaml @@ -0,0 +1,10 @@ +AWSTemplateFormatVersion: 2010-09-09 +Resources: + WAFRule: + Type: AWS::WAF::Rule + Properties: + MetricName: foo + Name: bar + + MyTopic: + Type: AWS::SNS::Topic diff --git a/tests/aws/templates/cfn_waitcondition.yaml b/tests/aws/templates/cfn_waitcondition.yaml new file mode 100644 index 0000000000000..9ea7d0ed514f1 --- /dev/null +++ b/tests/aws/templates/cfn_waitcondition.yaml @@ -0,0 +1,33 @@ +Parameters: + ParameterName: + Type: String + +Resources: + WaitHandle: + Type: AWS::CloudFormation::WaitConditionHandle + + WaitHandleParameter: + Type: AWS::SSM::Parameter + DependsOn: WaitHandle + Properties: + Name: + Ref: ParameterName + Value: + Ref: WaitHandle + Type: String + + WaitCondition: + Type: AWS::CloudFormation::WaitCondition + Properties: + Handle: + Ref: WaitHandle + Timeout: 300 + +Outputs: + WaitHandleId: + Value: + Ref: WaitHandle + + WaitConditionRef: + Value: + Ref: WaitCondition diff --git a/tests/aws/templates/code_artifact_remove_template.yaml b/tests/aws/templates/code_artifact_remove_template.yaml new file mode 100644 index 0000000000000..8a71dc0b657f7 --- /dev/null +++ b/tests/aws/templates/code_artifact_remove_template.yaml @@ -0,0 +1,134 @@ +Resources: + TestBucket560B80BC: + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + Metadata: + aws:cdk:path: UpdateCdkStack/TestBucket/Resource + TestBucketPolicyBA12ED38: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: TestBucket560B80BC + PolicyDocument: + Statement: + - Action: s3:GetObject + Effect: Allow + Principal: + AWS: "*" + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - TestBucket560B80BC + - Arn + - /* + Version: "2012-10-17" + Metadata: + aws:cdk:path: UpdateCdkStack/TestBucket/Policy/Resource + HestBucketABE4AE1C: + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + Metadata: + aws:cdk:path: UpdateCdkStack/HestBucket/Resource + HestBucketPolicy276ECA48: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: HestBucketABE4AE1C + PolicyDocument: + Statement: + - Action: s3:GetObject + Effect: Allow + Principal: + AWS: "*" + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - HestBucketABE4AE1C + - Arn + - /* + Version: "2012-10-17" + Metadata: + aws:cdk:path: UpdateCdkStack/HestBucket/Policy/Resource + CDKMetadata: + Type: AWS::CDK::Metadata + Properties: + Analytics: v2:deflate64:H4sIAAAAAAAA/0XIQQqDMBCF4bO4T6ZaKXRdewDRA5R0jHSqzkAyUiR49yoWunr/+85QXCDP3Cda7AY70hNSqw4Hs9EjoXTeBaXeoULV810mR2xiCek24+DVbPirY2oZCZc/H39dd2l8lDmg37sS7khJeDX1oi/hUwlXKPLsHYlsmFlp8tAc+wUWGrSMpgAAAA== + Metadata: + aws:cdk:path: UpdateCdkStack/CDKMetadata/Default + Condition: CDKMetadataAvailable +Conditions: + CDKMetadataAvailable: + Fn::Or: + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - af-south-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-east-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-northeast-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-northeast-2 + - Fn::Equals: + - Ref: AWS::Region + - ap-south-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-southeast-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-southeast-2 + - Fn::Equals: + - Ref: AWS::Region + - ca-central-1 + - Fn::Equals: + - Ref: AWS::Region + - cn-north-1 + - Fn::Equals: + - Ref: AWS::Region + - cn-northwest-1 + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - eu-central-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-north-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-south-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-2 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-3 + - Fn::Equals: + - Ref: AWS::Region + - me-south-1 + - Fn::Equals: + - Ref: AWS::Region + - sa-east-1 + - Fn::Equals: + - Ref: AWS::Region + - us-east-1 + - Fn::Equals: + - Ref: AWS::Region + - us-east-2 + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - us-west-1 + - Fn::Equals: + - Ref: AWS::Region + - us-west-2 diff --git a/tests/aws/templates/code_artifact_template.yaml b/tests/aws/templates/code_artifact_template.yaml new file mode 100644 index 0000000000000..e5e7980ede413 --- /dev/null +++ b/tests/aws/templates/code_artifact_template.yaml @@ -0,0 +1,145 @@ +Parameters: + CADomainName: + Description: Name of the CodeArtifact domain + Type: String + Default: testingDomainName +Resources: + TestCodeArtifactDomain: + Type: AWS::CodeArtifact::Domain + Properties: + DomainName: !Ref CADomainName + Metadata: + aws:cdk:path: UpdateCdkStack/TestCodeArtifactDomain + TestBucket560B80BC: + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + Metadata: + aws:cdk:path: UpdateCdkStack/TestBucket/Resource + TestBucketPolicyBA12ED38: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: TestBucket560B80BC + PolicyDocument: + Statement: + - Action: s3:GetObject + Effect: Allow + Principal: + AWS: "*" + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - TestBucket560B80BC + - Arn + - /* + Version: "2012-10-17" + Metadata: + aws:cdk:path: UpdateCdkStack/TestBucket/Policy/Resource + HestBucketABE4AE1C: + Type: AWS::S3::Bucket + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + Metadata: + aws:cdk:path: UpdateCdkStack/HestBucket/Resource + HestBucketPolicy276ECA48: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: HestBucketABE4AE1C + PolicyDocument: + Statement: + - Action: s3:GetObject + Effect: Allow + Principal: + AWS: "*" + Resource: + Fn::Join: + - "" + - - Fn::GetAtt: + - HestBucketABE4AE1C + - Arn + - /* + Version: "2012-10-17" + Metadata: + aws:cdk:path: UpdateCdkStack/HestBucket/Policy/Resource + CDKMetadata: + Type: AWS::CDK::Metadata + Properties: + Analytics: v2:deflate64:H4sIAAAAAAAA/0XIQQqDMBCF4bO4T6ZaKXRdewDRA5R0jHSqzkAyUiR49yoWunr/+85QXCDP3Cda7AY70hNSqw4Hs9EjoXTeBaXeoULV810mR2xiCek24+DVbPirY2oZCZc/H39dd2l8lDmg37sS7khJeDX1oi/hUwlXKPLsHYlsmFlp8tAc+wUWGrSMpgAAAA== + Metadata: + aws:cdk:path: UpdateCdkStack/CDKMetadata/Default + Condition: CDKMetadataAvailable +Conditions: + CDKMetadataAvailable: + Fn::Or: + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - af-south-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-east-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-northeast-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-northeast-2 + - Fn::Equals: + - Ref: AWS::Region + - ap-south-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-southeast-1 + - Fn::Equals: + - Ref: AWS::Region + - ap-southeast-2 + - Fn::Equals: + - Ref: AWS::Region + - ca-central-1 + - Fn::Equals: + - Ref: AWS::Region + - cn-north-1 + - Fn::Equals: + - Ref: AWS::Region + - cn-northwest-1 + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - eu-central-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-north-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-south-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-1 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-2 + - Fn::Equals: + - Ref: AWS::Region + - eu-west-3 + - Fn::Equals: + - Ref: AWS::Region + - me-south-1 + - Fn::Equals: + - Ref: AWS::Region + - sa-east-1 + - Fn::Equals: + - Ref: AWS::Region + - us-east-1 + - Fn::Equals: + - Ref: AWS::Region + - us-east-2 + - Fn::Or: + - Fn::Equals: + - Ref: AWS::Region + - us-west-1 + - Fn::Equals: + - Ref: AWS::Region + - us-west-2 diff --git a/tests/aws/templates/conditions/conditional-in-conditional.yml b/tests/aws/templates/conditions/conditional-in-conditional.yml new file mode 100644 index 0000000000000..d88b405ac8914 --- /dev/null +++ b/tests/aws/templates/conditions/conditional-in-conditional.yml @@ -0,0 +1,35 @@ +Parameters: + SelectedRegion: + Type: String + Environment: + Type: String + AllowedValues: + - "production" + - "staging" + - "dev" +Conditions: + IsUSEast: !Equals + - !Ref SelectedRegion + - "us-east-1" + IsProduction: !Equals + - !Ref Environment + - "production" + IsUSEastProduction: !And + - !Condition IsProduction + - !Condition IsUSEast + +Resources: + ResultParameter: + Type: AWS::SSM::Parameter + Properties: + Name: /aml/patching/patching-result + Type: String + Value: !If + - IsUSEastProduction + - "true" + - "false" + +Outputs: + Result: + Description: "Result parameter" + Value: !GetAtt ResultParameter.Value \ No newline at end of file diff --git a/tests/aws/templates/conditions/conditional-with-select.yml b/tests/aws/templates/conditions/conditional-with-select.yml new file mode 100644 index 0000000000000..f60e759118320 --- /dev/null +++ b/tests/aws/templates/conditions/conditional-with-select.yml @@ -0,0 +1,26 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Conditions: + IsGrapes: !Equals [!Select [ 1, ['apples', 'grapes', 'bananas']], 'grapes'] + +Resources: + StreamWriterPolicy2: + Type: 'AWS::IAM::ManagedPolicy' + Condition: IsGrapes + Properties: + ManagedPolicyName: Test2 + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: "kinesis:PutRecord" + Resource: !Join + - ':' + - - arn:aws:kinesis + - !Ref AWS::Region + - !Ref AWS::AccountId + - !Sub stream/${AWS::StackName}-* +Outputs: + PolicyArn: + Description: StreamWriterPolicy2 + Value: !Ref StreamWriterPolicy2 \ No newline at end of file diff --git a/tests/aws/templates/conditions/intrinsic-functions-in-conditions.yaml b/tests/aws/templates/conditions/intrinsic-functions-in-conditions.yaml new file mode 100644 index 0000000000000..bf580cabd1344 --- /dev/null +++ b/tests/aws/templates/conditions/intrinsic-functions-in-conditions.yaml @@ -0,0 +1,39 @@ +Parameters: + TopicName: + Type: String + TopicPrefix: + Type: String + TopicNameWithSuffix: + Type: String + TopicNameSuffix: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Condition: ShouldCreateTopic + Properties: + TopicName: !Ref TopicName + + MyTopicWithSuffix: + Type: AWS::SNS::Topic + Condition: ShouldCreateTopic2 + Properties: + TopicName: !Ref TopicNameWithSuffix + + +Conditions: + ShouldCreateTopic: !Equals + - !Ref TopicName + - !Sub "${TopicPrefix}-${AWS::Region}" + ShouldCreateTopic2: !Equals + - !Ref TopicNameWithSuffix + - "Fn::Sub": + - "${TopicPrefix}-${AWS::Region}-${Suffix}" + - Suffix: !Ref TopicNameSuffix + +Outputs: + TopicRef: + Value: !Ref MyTopic + TopicWithSuffixRef: + Value: !Ref MyTopicWithSuffix diff --git a/tests/aws/templates/conditions/nested-conditions.yaml b/tests/aws/templates/conditions/nested-conditions.yaml new file mode 100644 index 0000000000000..1e93c1526f4a9 --- /dev/null +++ b/tests/aws/templates/conditions/nested-conditions.yaml @@ -0,0 +1,45 @@ +# sample from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html +Parameters: + EnvType: + Type: String + AllowedValues: + - prod + - test + BucketName: + Default: '' + Type: String +Conditions: + IsProduction: !Equals + - !Ref EnvType + - prod + CreateBucket: !Not + - !Equals + - !Ref BucketName + - '' + CreateBucketPolicy: !And + - !Condition IsProduction + - !Condition CreateBucket +Resources: + Bucket: + Type: 'AWS::S3::Bucket' + Condition: CreateBucket + Properties: + BucketName: !Ref BucketName + Policy: + Type: 'AWS::S3::BucketPolicy' + Condition: CreateBucketPolicy + Properties: + Bucket: !Ref Bucket + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - s3:GetObject + Resource: + - !Join + - "" + - - !GetAtt Bucket.Arn + - "/*" diff --git a/tests/aws/templates/conditions/ref-condition-intrinsic-condition.yaml b/tests/aws/templates/conditions/ref-condition-intrinsic-condition.yaml new file mode 100644 index 0000000000000..ae7031b5530e4 --- /dev/null +++ b/tests/aws/templates/conditions/ref-condition-intrinsic-condition.yaml @@ -0,0 +1,32 @@ +Parameters: + TopicName: + Type: String + SsmParamName: + Type: String + OptionParameter: + Type: String + AllowedValues: + - option-a + - option-b +Resources: + MyTopic: + Type: AWS::SNS::Topic + Condition: ShouldCreateTopic + Properties: + TopicName: !Ref TopicName + + MyOtherThing: + Type: AWS::SSM::Parameter + Properties: + Name: !Ref SsmParamName + Value: something + Type: String + Description: !If + - ShouldCreateTopic + - !Ref MyTopic + - "fallback" + +Conditions: + ShouldCreateTopic: !Equals + - !Ref OptionParameter + - option-a diff --git a/tests/aws/templates/conditions/ref-condition-macro-def.yaml b/tests/aws/templates/conditions/ref-condition-macro-def.yaml new file mode 100644 index 0000000000000..eac96098e45c3 --- /dev/null +++ b/tests/aws/templates/conditions/ref-condition-macro-def.yaml @@ -0,0 +1,47 @@ +Parameters: + FnRole: + Type: String + # TODO: verify if the log group needs to exist for logs to be added + LogGroupName: + Type: String + LogRoleARN: + Type: String + +Resources: + MyFunction: + Type: AWS::Lambda::Function + Properties: + Role: !Ref FnRole + Runtime: python3.9 + Handler: index.handler + Code: + ZipFile: | + import json + + def handler(event, context): + print(f"{event=}") + + fragment = event["fragment"] + + fragment["Resources"] = { + "MyNewTopic": { + "Type": "AWS::SNS::Topic", + "Properties": {} + } + } + fragment.pop("Conditions", None) + fragment.pop("Mappings", None) + + return { + "requestId": event["requestId"], + "status": "SUCCESS", + "fragment": fragment, + } + + MyMacro: + Type: AWS::CloudFormation::Macro + Properties: + FunctionName: !GetAtt MyFunction.Arn + Name: MyMacro + LogGroupName: !Ref LogGroupName + LogRoleARN: !Ref LogRoleARN \ No newline at end of file diff --git a/tests/aws/templates/conditions/ref-condition-macro.yaml b/tests/aws/templates/conditions/ref-condition-macro.yaml new file mode 100644 index 0000000000000..a8a7fade46815 --- /dev/null +++ b/tests/aws/templates/conditions/ref-condition-macro.yaml @@ -0,0 +1,32 @@ +# same as ref-condition.yaml but with an additional transform +Transform: + - MyMacro + +Parameters: + TopicName: + Type: String + SsmParamName: + Type: String + OptionParameter: + Type: String + AllowedValues: + - option-a + - option-b +Resources: + MyTopic: + Type: AWS::SNS::Topic + Condition: ShouldCreateTopic + Properties: + TopicName: !Ref TopicName + + MySsmParam: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: !Ref SsmParamName + Value: !Ref MyTopic + +Conditions: + ShouldCreateTopic: !Equals + - !Ref OptionParameter + - option-a \ No newline at end of file diff --git a/tests/aws/templates/conditions/ref-condition-output.yaml b/tests/aws/templates/conditions/ref-condition-output.yaml new file mode 100644 index 0000000000000..90ef8e7033f67 --- /dev/null +++ b/tests/aws/templates/conditions/ref-condition-output.yaml @@ -0,0 +1,19 @@ +Parameters: + OptionParameter: + Type: String + AllowedValues: + - option-a + - option-b +Resources: + MyTopic: + Type: AWS::SNS::Topic + Condition: ShouldCreateTopic + +Conditions: + ShouldCreateTopic: !Equals + - !Ref OptionParameter + - option-a + +Outputs: + TopicRef: + Value: !Ref MyTopic diff --git a/tests/aws/templates/conditions/ref-condition.yaml b/tests/aws/templates/conditions/ref-condition.yaml new file mode 100644 index 0000000000000..81e1b915d320e --- /dev/null +++ b/tests/aws/templates/conditions/ref-condition.yaml @@ -0,0 +1,28 @@ +Parameters: + TopicName: + Type: String + SsmParamName: + Type: String + OptionParameter: + Type: String + AllowedValues: + - option-a + - option-b +Resources: + MyTopic: + Type: AWS::SNS::Topic + Condition: ShouldCreateTopic + Properties: + TopicName: !Ref TopicName + + MySsmParam: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: !Ref SsmParamName + Value: !Ref MyTopic + +Conditions: + ShouldCreateTopic: !Equals + - !Ref OptionParameter + - option-a \ No newline at end of file diff --git a/tests/aws/templates/conditions/simple-condition.yaml b/tests/aws/templates/conditions/simple-condition.yaml new file mode 100644 index 0000000000000..2225ca5190dcf --- /dev/null +++ b/tests/aws/templates/conditions/simple-condition.yaml @@ -0,0 +1,19 @@ +Parameters: + TopicName: + Type: String + OptionParameter: + Type: String + AllowedValues: + - option-a + - option-b +Resources: + MyTopic: + Type: AWS::SNS::Topic + Condition: ShouldCreateTopic + Properties: + TopicName: !Ref TopicName + +Conditions: + ShouldCreateTopic: !Equals + - !Ref OptionParameter + - option-a \ No newline at end of file diff --git a/tests/aws/templates/conditions/simple-intrinsic-condition-name-conflict.yaml b/tests/aws/templates/conditions/simple-intrinsic-condition-name-conflict.yaml new file mode 100644 index 0000000000000..208febf68ee83 --- /dev/null +++ b/tests/aws/templates/conditions/simple-intrinsic-condition-name-conflict.yaml @@ -0,0 +1,22 @@ +Parameters: + TopicName: + Type: String + ShouldSetCustomName: + Type: String + AllowedValues: + - yep + - nope +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !If [ShouldSetCustomName, !Ref TopicName, !Ref AWS::NoValue] + +Conditions: + ShouldSetCustomName: !Equals + - !Ref ShouldSetCustomName + - yep + +Outputs: + TopicArn: + Value: !GetAtt MyTopic.TopicArn diff --git a/tests/aws/templates/conditions/simple-intrinsic-condition.yaml b/tests/aws/templates/conditions/simple-intrinsic-condition.yaml new file mode 100644 index 0000000000000..6ab732f726b60 --- /dev/null +++ b/tests/aws/templates/conditions/simple-intrinsic-condition.yaml @@ -0,0 +1,22 @@ +Parameters: + TopicName: + Type: String + ShouldSetCustomName: + Type: String + AllowedValues: + - yep + - nope +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !If [ShouldSetCustomNameCondition, !Ref TopicName, !Ref AWS::NoValue] + +Conditions: + ShouldSetCustomNameCondition: !Equals + - !Ref ShouldSetCustomName + - yep + +Outputs: + TopicArn: + Value: !GetAtt MyTopic.TopicArn diff --git a/tests/aws/templates/deploy_template_2.yaml b/tests/aws/templates/deploy_template_2.yaml new file mode 100644 index 0000000000000..3c166b72a4f67 --- /dev/null +++ b/tests/aws/templates/deploy_template_2.yaml @@ -0,0 +1,69 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: + SNS Topics for stuff + +Parameters: + CompanyName: + Type: String + Description: 'Customer/Company name, commonly known-by name' + AllowedPattern: '[A-Za-z0-9-]{5,}' + ConstraintDescription: 'String must be 5 or more characters, letters, numbers and -' + + MyEmail1: + Type: String + Description: Email address for stuff + Default: "" + + MyEmail2: + Type: String + Description: Email address for stuff + Default: "" + +Conditions: + HasMyEmail1: !Not [!Equals [!Ref MyEmail1, '']] + HasMyEmail2: !Not [!Equals [!Ref MyEmail2, '']] + + SetupMy: !Or + - Condition: HasMyEmail1 + - Condition: HasMyEmail2 + +Resources: + MyTopic: + Condition: SetupMy + Type: AWS::SNS::Topic + Properties: + DisplayName: !Sub "${CompanyName} AWS MyTopic" + Subscription: + - !If + - HasMyEmail1 + - + Endpoint: !Ref MyEmail1 + Protocol: email + - !Ref AWS::NoValue + - !If + - HasMyEmail2 + - + Endpoint: !Ref MyEmail2 + Protocol: email + - !Ref AWS::NoValue + +Outputs: + StackName: + Description: 'Stack name' + Value: !Sub '${AWS::StackName}' + Export: + Name: !Sub '${AWS::StackName}-StackName' + + MyTopic: + Condition: SetupMy + Description: 'My arn' + Value: !Ref MyTopic + Export: + Name: !Sub '${AWS::StackName}-MyTopicArn' + + MyTopicName: + Condition: SetupMy + Description: 'My Name' + Value: !GetAtt MyTopic.TopicName + Export: + Name: !Sub '${AWS::StackName}-MyTopicName' diff --git a/tests/aws/templates/deploy_template_3.yaml b/tests/aws/templates/deploy_template_3.yaml new file mode 100644 index 0000000000000..9e3f19100ebdf --- /dev/null +++ b/tests/aws/templates/deploy_template_3.yaml @@ -0,0 +1,99 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: DynamoDB resource stack creation using Amplify CLI +Parameters: + partitionKeyName: + Type: String + Default: startTime + partitionKeyType: + Type: String + Default: String + env: + Type: String + Default: Staging + sortKeyName: + Type: String + Default: name + sortKeyType: + Type: String + Default: String + tableName: + Type: String + Default: ddb1 +Conditions: + ShouldNotCreateEnvResources: + Fn::Equals: + - Ref: env + - NONE +Resources: + DynamoDBTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: + Fn::If: + - ShouldNotCreateEnvResources + - Ref: tableName + - Fn::Join: + - '' + - - Ref: tableName + - "-" + - Ref: env + AttributeDefinitions: + - AttributeName: name + AttributeType: S + - AttributeName: startTime + AttributeType: S + - AttributeName: externalUserID + AttributeType: S + KeySchema: + - AttributeName: name + KeyType: HASH + - AttributeName: startTime + KeyType: RANGE + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + StreamSpecification: + StreamViewType: NEW_IMAGE + GlobalSecondaryIndexes: + - IndexName: byUser + KeySchema: + - AttributeName: externalUserID + KeyType: HASH + - AttributeName: startTime + KeyType: RANGE + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + ContributorInsightsSpecification: + Enabled: True +Outputs: + Name: + Value: + Ref: DynamoDBTable + Arn: + Value: + Fn::GetAtt: + - DynamoDBTable + - Arn + StreamArn: + Value: + Fn::GetAtt: + - DynamoDBTable + - StreamArn + PartitionKeyName: + Value: + Ref: partitionKeyName + PartitionKeyType: + Value: + Ref: partitionKeyType + SortKeyName: + Value: + Ref: sortKeyName + SortKeyType: + Value: + Ref: sortKeyType + Region: + Value: + Ref: AWS::Region diff --git a/tests/aws/templates/dhcp_options.yml b/tests/aws/templates/dhcp_options.yml new file mode 100644 index 0000000000000..ac3f5ddc22fde --- /dev/null +++ b/tests/aws/templates/dhcp_options.yml @@ -0,0 +1,20 @@ +Resources: + myDhcpOptions: + Type: AWS::EC2::DHCPOptions + Properties: + DomainName: example.com + DomainNameServers: + - AmazonProvidedDNS + NtpServers: + - 10.2.5.1 + NetbiosNameServers: + - 10.2.5.1 + NetbiosNodeType: 2 + Tags: + - Key: project + Value: 123 + +Outputs: + RefDhcpOptions: + Value: + Ref: myDhcpOptions \ No newline at end of file diff --git a/tests/aws/templates/dynamicparameter_ssm_list.yaml b/tests/aws/templates/dynamicparameter_ssm_list.yaml new file mode 100644 index 0000000000000..2a3350399c828 --- /dev/null +++ b/tests/aws/templates/dynamicparameter_ssm_list.yaml @@ -0,0 +1,27 @@ +Parameters: + parameter123: + Type: AWS::SSM::Parameter::Value + +Resources: + role123: + Type: AWS::IAM::Role + Properties: + RoleName: {{role_name}} + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - sts:AssumeRole + Path: / + Policies: + - PolicyName: policy-123 + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: "*" + Resource: !Ref parameter123 diff --git a/tests/aws/templates/dynamicparameter_ssm_string.yaml b/tests/aws/templates/dynamicparameter_ssm_string.yaml new file mode 100644 index 0000000000000..cd95e97f03365 --- /dev/null +++ b/tests/aws/templates/dynamicparameter_ssm_string.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + parameter123: + Type: AWS::SSM::Parameter::Value + Default: {{parameter_name}} +Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: + Ref: parameter123 + Tags: + - Key: param-value + Value: !Sub "param ${parameter123}" + UpdateReplacePolicy: Delete + DeletionPolicy: Delete diff --git a/tests/aws/templates/dynamodb_billing_conditional.yml b/tests/aws/templates/dynamodb_billing_conditional.yml new file mode 100644 index 0000000000000..37fee9016df8d --- /dev/null +++ b/tests/aws/templates/dynamodb_billing_conditional.yml @@ -0,0 +1,34 @@ +Parameters: + BillingModeParameter: + Type: String + +Resources: + DynamoDBTable: + Type: "AWS::DynamoDB::Table" + Properties: + AttributeDefinitions: + - AttributeName: "id" + AttributeType: "S" + KeySchema: + - AttributeName: "id" + KeyType: "HASH" + ProvisionedThroughput: + Fn::If: + - ShouldUsePayPerRequestBilling + - !Ref "AWS::NoValue" + - ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + BillingMode: + Fn::If: + - ShouldUsePayPerRequestBilling + - PAY_PER_REQUEST + - !Ref "AWS::NoValue" + StreamSpecification: + StreamViewType: "NEW_AND_OLD_IMAGES" + +Conditions: + ShouldUsePayPerRequestBilling: !Equals [!Ref BillingModeParameter, "PAY_PER_REQUEST"] + +Outputs: + TableName: + Value: !Ref DynamoDBTable diff --git a/tests/aws/templates/dynamodb_global_table.yml b/tests/aws/templates/dynamodb_global_table.yml new file mode 100644 index 0000000000000..eaeb6ef2cbf17 --- /dev/null +++ b/tests/aws/templates/dynamodb_global_table.yml @@ -0,0 +1,18 @@ +Resources: + Table: + Type: AWS::DynamoDB::GlobalTable + Properties: + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: keyName + KeyType: HASH + AttributeDefinitions: + - AttributeName: keyName + AttributeType: S + Replicas: + - Region: us-east-1 + + +Outputs: + TableName: + Value: !Ref Table diff --git a/tests/aws/templates/dynamodb_global_table_sse_enabled.yml b/tests/aws/templates/dynamodb_global_table_sse_enabled.yml new file mode 100644 index 0000000000000..f83ce18a69625 --- /dev/null +++ b/tests/aws/templates/dynamodb_global_table_sse_enabled.yml @@ -0,0 +1,44 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + MyDynamoDBTable: + Type: 'AWS::DynamoDB::GlobalTable' + Properties: + TableName: MyTable + BillingMode: PAY_PER_REQUEST + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: gsi1pk + KeyType: HASH + - AttributeName: gsi1sk + KeyType: RANGE + Projection: + ProjectionType: ALL + AttributeDefinitions: + - AttributeName: pk + AttributeType: S + - AttributeName: sk + AttributeType: S + - AttributeName: gsi1pk + AttributeType: S + - AttributeName: gsi1sk + AttributeType: S + KeySchema: + - AttributeName: pk + KeyType: HASH + - AttributeName: sk + KeyType: RANGE + SSESpecification: + SSEEnabled: True + SSEType: KMS + TimeToLiveSpecification: + AttributeName: expire_at + Enabled: true + Replicas: + - PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: true + Region: !Ref "AWS::Region" + TableClass: STANDARD +Outputs: + TableName: + Value: !Ref MyDynamoDBTable \ No newline at end of file diff --git a/tests/aws/templates/dynamodb_iam.yaml b/tests/aws/templates/dynamodb_iam.yaml new file mode 100644 index 0000000000000..766ef1ee5ba97 --- /dev/null +++ b/tests/aws/templates/dynamodb_iam.yaml @@ -0,0 +1,54 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + tableName: + Type: String + Default: name + policyName: + Type: String + Default: name +Resources: + DynamoDBTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Ref tableName + AttributeDefinitions: + - AttributeName: ExKey + AttributeType: S + KeySchema: + - AttributeName: ExKey + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 2 + WriteCapacityUnits: 2 + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + + ManagedPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + ManagedPolicyName: !Ref policyName + PolicyDocument: + Version: "2012-10-17" + Statement: + - Action: "dynamodb:DescribeTable" + Effect: "Allow" + Resource: + - Fn::GetAtt: + - DynamoDBTable + - Arn + - Ref: AWS::NoValue + DependsOn: DynamoDBTable + +Outputs: + TableName: + Value: + Ref: DynamoDBTable + TableARN: + Value: + "Fn::GetAtt": "DynamoDBTable.Arn" + StreamARN: + Value: + "Fn::GetAtt": "DynamoDBTable.StreamArn" + PolicyArn: + Value: + Ref: ManagedPolicy diff --git a/tests/aws/templates/dynamodb_table_defaults.yml b/tests/aws/templates/dynamodb_table_defaults.yml new file mode 100644 index 0000000000000..fb45641fe22de --- /dev/null +++ b/tests/aws/templates/dynamodb_table_defaults.yml @@ -0,0 +1,24 @@ +Resources: + Table: + Type: AWS::DynamoDB::Table + Properties: + KeySchema: + - AttributeName: keyName + KeyType: HASH + AttributeDefinitions: + - AttributeName: keyName + AttributeType: S + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + Tags: + - Key: TagKey1 + Value: TagValue1 + - Key: TagKey2 + Value: TagValue2 + +Outputs: + TableName: + Value: !Ref Table + TableArn: + Value: !GetAtt Table.Arn diff --git a/tests/aws/templates/dynamodb_table_sse_enabled.yml b/tests/aws/templates/dynamodb_table_sse_enabled.yml new file mode 100644 index 0000000000000..91e4eeaede7ac --- /dev/null +++ b/tests/aws/templates/dynamodb_table_sse_enabled.yml @@ -0,0 +1,28 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + MyDynamoDBTable: + Type: 'AWS::DynamoDB::Table' + Properties: + TableName: MyTable + AttributeDefinitions: + - AttributeName: pk + AttributeType: S + - AttributeName: sk + AttributeType: S + KeySchema: + - AttributeName: pk + KeyType: HASH + - AttributeName: sk + KeyType: RANGE + SSESpecification: + SSEEnabled: True + SSEType: KMS + TimeToLiveSpecification: + AttributeName: expire_at + Enabled: true + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 +Outputs: + TableName: + Value: !Ref MyDynamoDBTable \ No newline at end of file diff --git a/tests/aws/templates/ec2_import_keypair.yaml b/tests/aws/templates/ec2_import_keypair.yaml new file mode 100644 index 0000000000000..d62ea3e3686ec --- /dev/null +++ b/tests/aws/templates/ec2_import_keypair.yaml @@ -0,0 +1,33 @@ +Parameters: + GeneratedKeyName: + Type: String + ImportedKeyName: + Type: String + +Resources: + + GeneratedKeyPair: + Type: AWS::EC2::KeyPair + Properties: + KeyName: !Ref GeneratedKeyName + KeyFormat: pem + KeyType: rsa + + ImportedKeyPair: + Type: AWS::EC2::KeyPair + Properties: + KeyName: !Ref ImportedKeyName + KeyFormat: pem + KeyType: rsa + # generated from a throwaway key + PublicKeyMaterial: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILR+zNQbYY5ORuhYXGMTSnjRLzS9C5dUdWRKdlqi20q2 + +Outputs: + GeneratedKeyPairName: + Value: !Ref GeneratedKeyPair + GeneratedKeyPairFingerprint: + Value: !GetAtt GeneratedKeyPair.KeyFingerprint + ImportedKeyPairName: + Value: !Ref ImportedKeyPair + ImportedKeyPairFingerprint: + Value: !GetAtt ImportedKeyPair.KeyFingerprint diff --git a/tests/aws/templates/ec2_instance.yml b/tests/aws/templates/ec2_instance.yml new file mode 100644 index 0000000000000..637ba5244b4ac --- /dev/null +++ b/tests/aws/templates/ec2_instance.yml @@ -0,0 +1,36 @@ +Parameters: + KeyName: + Type: String + Description: Name of an existing EC2 KeyPair to enable SSH access to the instances + InstanceType: + Type: String + Description: WebServer EC2 instance type + Default: t2.micro + ImageId: + Type: String + Description: WebServer EC2 instance type + Default: ami-0a70b9d193ae8a799 +Resources: + myInstance: + Type: 'AWS::EC2::Instance' + Properties: + ImageId: !Ref ImageId + InstanceType: !Ref InstanceType + KeyName: !Ref KeyName + SecurityGroupIds: + - !Ref securityGroup + + + securityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: Enable SSH access via port 22 + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: '22' + ToPort: '22' + CidrIp: 0.0.0.0/0 + +Outputs: + InstanceId: + Value: !Ref myInstance diff --git a/tests/aws/templates/ec2_keypair.yml b/tests/aws/templates/ec2_keypair.yml new file mode 100644 index 0000000000000..b376e728de996 --- /dev/null +++ b/tests/aws/templates/ec2_keypair.yml @@ -0,0 +1,9 @@ +Resources: + NewKeyPair: + Type: 'AWS::EC2::KeyPair' + Properties: + KeyName: !Sub 'keypair-${AWS::StackName}' + +Outputs: + KeyPairName: + Value: !Ref NewKeyPair diff --git a/tests/aws/templates/ec2_prefixlist.yml b/tests/aws/templates/ec2_prefixlist.yml new file mode 100644 index 0000000000000..1cb2e7dac7ed8 --- /dev/null +++ b/tests/aws/templates/ec2_prefixlist.yml @@ -0,0 +1,23 @@ +Resources: + NewPrefixList: + Type: AWS::EC2::PrefixList + Properties: + PrefixListName: "vpc-1-servers" + AddressFamily: "IPv4" + MaxEntries: 10 + Entries: + - Cidr: "10.0.0.5/32" + Description: "Server 1" + - Cidr: "10.0.0.10/32" + Description: "Server 2" + Tags: + - Key: "Name" + Value: "VPC-1-Servers" + +Outputs: + PrefixRef: + Value: !Ref NewPrefixList + PrefixArn: + Value: !GetAtt NewPrefixList.Arn + PrefixId: + Value: !GetAtt NewPrefixList.PrefixListId diff --git a/tests/aws/templates/ec2_route_table_isolated.yaml b/tests/aws/templates/ec2_route_table_isolated.yaml new file mode 100644 index 0000000000000..81422805dfd20 --- /dev/null +++ b/tests/aws/templates/ec2_route_table_isolated.yaml @@ -0,0 +1,21 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: A stack that takes a VPC ID as an input and creates a route table in it + +Resources: + myVpc: + Type: 'AWS::EC2::VPC' + Properties: + CidrBlock: "10.0.0.0/16" + + myRouteTable: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref myVpc + Tags: + - Key: Name + Value: Suspicious Route Table + +Outputs: + RouteTableId: + Description: The ID of the created route table + Value: !Ref myRouteTable diff --git a/tests/aws/templates/ec2_route_table_simple.yaml b/tests/aws/templates/ec2_route_table_simple.yaml new file mode 100644 index 0000000000000..39ea7467c254e --- /dev/null +++ b/tests/aws/templates/ec2_route_table_simple.yaml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + myVPC: + Type: 'AWS::EC2::VPC' + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsHostnames: true + EnableDnsSupport: true + InstanceTenancy: default + Tags: + - Key: Name + Value: RdsTestStack/vpc + myRouteTable: + Type: 'AWS::EC2::RouteTable' + Properties: + VpcId: !Ref myVPC + Tags: + - Key: Name + Value: Suspicious Route table +Outputs: + RouteTableId: + Description: The ID of the created route table + Value: !Ref myRouteTable \ No newline at end of file diff --git a/tests/aws/templates/ec2_security_group_with_tags.yml b/tests/aws/templates/ec2_security_group_with_tags.yml new file mode 100644 index 0000000000000..758aa7c6bb0df --- /dev/null +++ b/tests/aws/templates/ec2_security_group_with_tags.yml @@ -0,0 +1,14 @@ +Resources: + SecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security Group + Tags: + - Key: key1 + Value: value1 + - Key: key2 + Value: value2 + +Outputs: + SecurityGroupId: + Value: !GetAtt SecurityGroup.GroupId diff --git a/tests/aws/templates/ec2_vpc_default_sg.yaml b/tests/aws/templates/ec2_vpc_default_sg.yaml new file mode 100644 index 0000000000000..441a3daa8559b --- /dev/null +++ b/tests/aws/templates/ec2_vpc_default_sg.yaml @@ -0,0 +1,190 @@ +Resources: + vpcA2121C38: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsHostnames: true + EnableDnsSupport: true + InstanceTenancy: default + Tags: + - Key: Name + Value: RdsTestStack/vpc + vpcPublicSubnet1Subnet2E65531E: + Type: AWS::EC2::Subnet + Properties: + CidrBlock: 10.0.0.0/18 + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: "" + MapPublicIpOnLaunch: true + Tags: + - Key: aws-cdk:subnet-name + Value: Public + - Key: aws-cdk:subnet-type + Value: Public + - Key: Name + Value: RdsTestStack/vpc/PublicSubnet1 + vpcPublicSubnet1RouteTable48A2DF9B: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: RdsTestStack/vpc/PublicSubnet1 + vpcPublicSubnet1RouteTableAssociation5D3F4579: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcPublicSubnet1RouteTable48A2DF9B + SubnetId: + Ref: vpcPublicSubnet1Subnet2E65531E + vpcPublicSubnet1DefaultRoute10708846: + Type: AWS::EC2::Route + Properties: + RouteTableId: + Ref: vpcPublicSubnet1RouteTable48A2DF9B + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: + Ref: vpcIGWE57CBDCA + DependsOn: + - vpcVPCGW7984C166 + vpcPublicSubnet2Subnet009B674F: + Type: AWS::EC2::Subnet + Properties: + CidrBlock: 10.0.64.0/18 + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 1 + - Fn::GetAZs: "" + MapPublicIpOnLaunch: true + Tags: + - Key: aws-cdk:subnet-name + Value: Public + - Key: aws-cdk:subnet-type + Value: Public + - Key: Name + Value: RdsTestStack/vpc/PublicSubnet2 + vpcPublicSubnet2RouteTableEB40D4CB: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: RdsTestStack/vpc/PublicSubnet2 + vpcPublicSubnet2RouteTableAssociation21F81B59: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcPublicSubnet2RouteTableEB40D4CB + SubnetId: + Ref: vpcPublicSubnet2Subnet009B674F + vpcPublicSubnet2DefaultRouteA1EC0F60: + Type: AWS::EC2::Route + Properties: + RouteTableId: + Ref: vpcPublicSubnet2RouteTableEB40D4CB + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: + Ref: vpcIGWE57CBDCA + DependsOn: + - vpcVPCGW7984C166 + vpcIsolatedSubnet1Subnet8B28CEB3: + Type: AWS::EC2::Subnet + Properties: + CidrBlock: 10.0.128.0/18 + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: "" + MapPublicIpOnLaunch: false + Tags: + - Key: aws-cdk:subnet-name + Value: Isolated + - Key: aws-cdk:subnet-type + Value: Isolated + - Key: Name + Value: RdsTestStack/vpc/IsolatedSubnet1 + vpcIsolatedSubnet1RouteTable0D6B2D3D: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: RdsTestStack/vpc/IsolatedSubnet1 + vpcIsolatedSubnet1RouteTableAssociation172210D4: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcIsolatedSubnet1RouteTable0D6B2D3D + SubnetId: + Ref: vpcIsolatedSubnet1Subnet8B28CEB3 + vpcIsolatedSubnet2Subnet2C6B375C: + Type: AWS::EC2::Subnet + Properties: + CidrBlock: 10.0.192.0/18 + VpcId: + Ref: vpcA2121C38 + AvailabilityZone: + Fn::Select: + - 1 + - Fn::GetAZs: "" + MapPublicIpOnLaunch: false + Tags: + - Key: aws-cdk:subnet-name + Value: Isolated + - Key: aws-cdk:subnet-type + Value: Isolated + - Key: Name + Value: RdsTestStack/vpc/IsolatedSubnet2 + vpcIsolatedSubnet2RouteTable3455CBFC: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: vpcA2121C38 + Tags: + - Key: Name + Value: RdsTestStack/vpc/IsolatedSubnet2 + vpcIsolatedSubnet2RouteTableAssociation8A8FAF70: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: vpcIsolatedSubnet2RouteTable3455CBFC + SubnetId: + Ref: vpcIsolatedSubnet2Subnet2C6B375C + vpcIGWE57CBDCA: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - Key: Name + Value: RdsTestStack/vpc + vpcVPCGW7984C166: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: + Ref: vpcA2121C38 + InternetGatewayId: + Ref: vpcIGWE57CBDCA +Outputs: + VpcDefaultSG: + Value: + Fn::GetAtt: + - vpcA2121C38 + - DefaultSecurityGroup + VpcId: + Value: + Ref: vpcA2121C38 + VpcDefaultAcl: + Value: + Fn::GetAtt: + - vpcA2121C38 + - DefaultNetworkAcl diff --git a/tests/aws/templates/ec2_vpc_endpoint.yml b/tests/aws/templates/ec2_vpc_endpoint.yml new file mode 100644 index 0000000000000..901eed3e229d7 --- /dev/null +++ b/tests/aws/templates/ec2_vpc_endpoint.yml @@ -0,0 +1,58 @@ +Resources: + CWLInterfaceEndpoint: + Type: 'AWS::EC2::VPCEndpoint' + Properties: + VpcEndpointType: 'Interface' + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.logs' + VpcId: !Ref myVPC + PrivateDnsEnabled: true + SubnetIds: + - !Ref subnetA + - !Ref subnetB + SecurityGroupIds: + - !Ref mySecurityGroup + myVPC: + Type: 'AWS::EC2::VPC' + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsSupport: true + EnableDnsHostnames: true + Tags: + - Key: 'Name' + Value: 'myVPC' + subnetA: + Type: 'AWS::EC2::Subnet' + Properties: + VpcId: !Ref myVPC + CidrBlock: '10.0.1.0/24' + AvailabilityZone: !Select [ 0, !GetAZs ] + subnetB: + Type: 'AWS::EC2::Subnet' + Properties: + VpcId: !Ref myVPC + CidrBlock: '10.0.2.0/24' + AvailabilityZone: !Select [ 1, !GetAZs ] + mySecurityGroup: + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: 'Allow HTTPS traffic from the VPC' + VpcId: !Ref myVPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: !GetAtt myVPC.CidrBlock + +Outputs: + EndpointRef: + Value: !Ref CWLInterfaceEndpoint + EndpointCreationTimestamp: + Value: !GetAtt CWLInterfaceEndpoint.CreationTimestamp + Id: + Value: !GetAtt CWLInterfaceEndpoint.Id + VpcId: + Value: !Ref myVPC + SubnetAId: + Value: !Ref subnetA + SubnetBId: + Value: !Ref subnetB diff --git a/tests/aws/templates/ec2_vpc_securitygroup.yml b/tests/aws/templates/ec2_vpc_securitygroup.yml new file mode 100644 index 0000000000000..b32b9821f9c79 --- /dev/null +++ b/tests/aws/templates/ec2_vpc_securitygroup.yml @@ -0,0 +1,35 @@ +Resources: + Vpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/24 + + SecurityGroupWithoutVpc: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Group without assigned VPC + + SecurityGroupWithVpc: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Group with assigned VPC + VpcId: + Ref: Vpc + +Outputs: + SGWithoutVpcIdRef: + Value: + Ref: SecurityGroupWithoutVpc + SGWithVpcIdRef: + Value: + Ref: SecurityGroupWithVpc + SGWithoutVpcIdGroupId: + Value: + Fn::GetAtt: + - SecurityGroupWithoutVpc + - GroupId + SGWithVpcIdGroupId: + Value: + Fn::GetAtt: + - SecurityGroupWithVpc + - GroupId diff --git a/tests/aws/templates/elasticsearch_domain.yml b/tests/aws/templates/elasticsearch_domain.yml new file mode 100644 index 0000000000000..99875726daeca --- /dev/null +++ b/tests/aws/templates/elasticsearch_domain.yml @@ -0,0 +1,40 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + DomainName: + Type: String + Default: dev +Resources: + MyElasticsearchDomain: + Type: AWS::Elasticsearch::Domain + Properties: + DomainName: !Ref "DomainName" + Tags: + - Key: k1 + Value: v1 + - Key: k2 + Value: v2 + ElasticsearchVersion: '7.10' + ElasticsearchClusterConfig: + DedicatedMasterEnabled: true + InstanceCount: '2' + ZoneAwarenessEnabled: true + InstanceType: 'm3.medium.elasticsearch' + DedicatedMasterType: 'm3.medium.elasticsearch' + DedicatedMasterCount: '3' + EBSOptions: + EBSEnabled: true + Iops: '0' + VolumeSize: '20' + VolumeType: 'gp2' +Outputs: + MyElasticsearchDomainEndpoint: + Value: !GetAtt MyElasticsearchDomain.DomainEndpoint + + MyElasticsearchArn: + Value: !GetAtt MyElasticsearchDomain.Arn + + MyElasticsearchDomainArn: + Value: !GetAtt MyElasticsearchDomain.DomainArn + + MyElasticsearchRef: + Value: !Ref MyElasticsearchDomain diff --git a/tests/aws/templates/empty_policy.json b/tests/aws/templates/empty_policy.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/tests/aws/templates/empty_policy.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/aws/templates/engine/cfn_dependson_nonexisting_resource.yaml b/tests/aws/templates/engine/cfn_dependson_nonexisting_resource.yaml new file mode 100644 index 0000000000000..b1733122e070c --- /dev/null +++ b/tests/aws/templates/engine/cfn_dependson_nonexisting_resource.yaml @@ -0,0 +1,8 @@ +Resources: + MyResource: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: Something + DependsOn: + - NonExistingResource diff --git a/tests/aws/templates/engine/cfn_exports.yml b/tests/aws/templates/engine/cfn_exports.yml new file mode 100644 index 0000000000000..9a4f9c4d0233f --- /dev/null +++ b/tests/aws/templates/engine/cfn_exports.yml @@ -0,0 +1,22 @@ +AWSTemplateFormatVersion: "2010-09-09" +Resources: + TestParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: "test" + Name: "/test/parameter" + +Outputs: + TestExport: + Value: "test" + Export: + Name: !Sub "${AWS::StackName}-TestExport-0" + TestExport1: + Value: "test" + Export: + Name: !Sub "${AWS::StackName}-TestExport-1" + TestExport2: + Value: "test" + Export: + Name: !Sub "${AWS::StackName}-TestExport-2" diff --git a/tests/aws/templates/engine/cfn_fn_sub.yaml b/tests/aws/templates/engine/cfn_fn_sub.yaml new file mode 100644 index 0000000000000..1ca9e748920a1 --- /dev/null +++ b/tests/aws/templates/engine/cfn_fn_sub.yaml @@ -0,0 +1,68 @@ +Parameters: + ParameterName: + Type: String + Param1: + Type: String + Default: Param1Value + +# we need to have at least resource for the stack to deploy +Resources: + MyResource: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: unimportant + Name: !Ref Param1 + + +Outputs: + # String format + StringStatic: + Value: !Sub 'this is a static string' + StringRefParam: + Value: !Sub "${Param1}" + StringRefResource: + Value: !Sub "${MyResource}" + StringRefPseudoParam: + Value: !Sub "${AWS::Region}" + StringRefMultiple: + Value: !Sub "${Param1} - ${MyResource}" + StringRefGetAtt: + Value: !Sub "${MyResource.Value}" + + # List format with mapping + ListStatic: + Value: !Sub + - 'this is a static string' + - somekey: somevalue # need to add at least one key-value pair here otherwise CFn complains + ListRefParam: + Value: !Sub + - "${Param1}" + - somekey: somevalue + ListRefResourceDirect: + Value: !Sub + - "${MyResource}" + - somekey: somevalue + ListRefResourceMappingRef: + Value: !Sub + - "${MyResourceRef}" + - MyResourceRef: !Ref MyResource + ListRefPseudoParam: + Value: !Sub + - "${AWS::Region}" + - somekey: somevalue + ListRefMultipleMix: + Value: !Sub + - "${MyResourceRef}-${AWS::Region}-${Param1}" + - MyResourceRef: !Ref MyResource + ListRefGetAtt: + Value: !Sub + - "${MyResource.Value}" + - somekey: somevalue + ListRefGetAttMapping: + Value: !Sub + - "${ParamValue}" + - ParamValue: !GetAtt MyResource.Value + UrlSuffixPseudoParam: + Value: + "Fn::Sub": "${AWS::URLSuffix}" diff --git a/tests/aws/templates/engine/cfn_getatt_dot_dependency.yml b/tests/aws/templates/engine/cfn_getatt_dot_dependency.yml new file mode 100644 index 0000000000000..8d116fe656e83 --- /dev/null +++ b/tests/aws/templates/engine/cfn_getatt_dot_dependency.yml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: 2010-09-09 + +Resources: + SQSDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: test-dead + + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: + Fn::GetAtt: SQSDeadLetterQueue.Arn + Type: String + +Outputs: + DeadArn: + Value: + Fn::GetAtt: SQSDeadLetterQueue.Arn diff --git a/tests/aws/templates/engine/cfn_if_conditional_reference.yaml b/tests/aws/templates/engine/cfn_if_conditional_reference.yaml new file mode 100644 index 0000000000000..056198cb771c5 --- /dev/null +++ b/tests/aws/templates/engine/cfn_if_conditional_reference.yaml @@ -0,0 +1,39 @@ +Parameters: + ShouldUseFallbackParameter: + Type: String + AllowedValues: [true, false] + Default: true + +Conditions: + ShouldUseFallback: !Equals [true, !Ref ShouldUseFallbackParameter] + # both are equivalent +# ShouldNotUseFallback: !Equals [false, !Ref ShouldUseFallbackParameter] + ShouldNotUseFallback: !Not [Condition: ShouldUseFallback] + +Resources: + DependentParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !If + - ShouldUseFallback + - !GetAtt FallbackParameter.Value + - !GetAtt DefaultParameter.Value + + FallbackParameter: + Type: AWS::SSM::Parameter + Condition: ShouldUseFallback + Properties: + Type: String + Value: "FallbackParamValue" + + DefaultParameter: + Type: AWS::SSM::Parameter + Condition: ShouldNotUseFallback + Properties: + Type: String + Value: "DefaultParamValue" + +Outputs: + ParameterName: + Value: !Ref DependentParameter diff --git a/tests/aws/templates/engine/cfn_invalid_getatt.yaml b/tests/aws/templates/engine/cfn_invalid_getatt.yaml new file mode 100644 index 0000000000000..90a8410fa85ba --- /dev/null +++ b/tests/aws/templates/engine/cfn_invalid_getatt.yaml @@ -0,0 +1,10 @@ +Resources: + MyResource: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: unimportant + +Outputs: + InvalidOutput: + Value: !GetAtt MyResource.Invalid diff --git a/tests/aws/templates/engine/cfn_short_sub.yml b/tests/aws/templates/engine/cfn_short_sub.yml new file mode 100644 index 0000000000000..a621225b1c18d --- /dev/null +++ b/tests/aws/templates/engine/cfn_short_sub.yml @@ -0,0 +1,22 @@ +Parameters: + TestValue: + Type: String + Default: "test" + Description: "Test value" + +Resources: + TestParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + !Sub + - |- + ${TestValue} + - { + TestValue: !Ref TestValue + } + +Outputs: + Result: + Value: !GetAtt TestParameter.Value diff --git a/tests/aws/templates/engine/implicit_type_conversion.yml b/tests/aws/templates/engine/implicit_type_conversion.yml new file mode 100644 index 0000000000000..d7151b1a88aaa --- /dev/null +++ b/tests/aws/templates/engine/implicit_type_conversion.yml @@ -0,0 +1,19 @@ +Resources: + blaBE223B94: + Type: AWS::SNS::Topic + queue276F7297: + Type: AWS::SQS::Queue + Properties: + DelaySeconds: "2" + FifoQueue: "true" + UpdateReplacePolicy: Delete + DeletionPolicy: Delete +Outputs: + QueueName: + Value: + Fn::GetAtt: + - queue276F7297 + - QueueName + QueueUrl: + Value: + Ref: queue276F7297 diff --git a/tests/aws/templates/engine/join_no_value.yml b/tests/aws/templates/engine/join_no_value.yml new file mode 100644 index 0000000000000..c974faff820f0 --- /dev/null +++ b/tests/aws/templates/engine/join_no_value.yml @@ -0,0 +1,34 @@ +Conditions: + active: !Equals [ true, true ] + inactive: !Equals [ true, false ] + +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: Sample + Name: commands + +Outputs: + JoinWithNoValue: + Value: + Fn::Join: + - "," + - - !GetAtt Parameter.Value + - !Ref AWS::NoValue + + JoinOnlyNoValue: + Value: + Fn::Join: + - "," + - - !Ref AWS::NoValue + + JoinConditionalNoValue: + Value: + Fn::Join: + - "," + - - Fn::If: + - active + - !Ref AWS::NoValue + - !Ref AWS::NoValue diff --git a/tests/aws/templates/event_source_mapping_tags.yml b/tests/aws/templates/event_source_mapping_tags.yml new file mode 100644 index 0000000000000..22af10ddb9f60 --- /dev/null +++ b/tests/aws/templates/event_source_mapping_tags.yml @@ -0,0 +1,120 @@ +Parameters: + OutputKey: + Type: String + +Resources: + Queue: + Type: AWS::SQS::Queue + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + + FunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: '2012-10-17' + ManagedPolicyArns: + - Fn::Join: + - '' + - - 'arn:' + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: my + Value: tag + + FunctionRolePolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:ChangeMessageVisibility + - sqs:DeleteMessage + - sqs:GetQueueAttributes + - sqs:GetQueueUrl + - sqs:ReceiveMessage + Effect: Allow + Resource: + Fn::GetAtt: + - Queue + - Arn + - Action: + - s3:PutObject + Effect: Allow + Resource: + Fn::Sub: + - "${bucketArn}/${key}" + - bucketArn: !GetAtt OutputBucket.Arn + key: !Ref OutputKey + Version: '2012-10-17' + PolicyName: FunctionRolePolicy + Roles: + - Ref: FunctionRole + + OutputBucket: + Type: AWS::S3::Bucket + + Function: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + import os + import boto3 + + BUCKET = os.environ["BUCKET"] + KEY = os.environ["KEY"] + + def handler(event, context): + client = boto3.client("s3") + client.put_object(Bucket=BUCKET, Key=KEY, Body=b"ok") + return "ok" + Handler: index.handler + Environment: + Variables: + BUCKET: !Ref OutputBucket + KEY: !Ref OutputKey + + Role: + Fn::GetAtt: + - FunctionRole + - Arn + Runtime: python3.11 + Tags: + - Key: my + Value: tag + DependsOn: + - FunctionRolePolicy + - FunctionRole + + EventSourceMapping: + Type: AWS::Lambda::EventSourceMapping + Properties: + EventSourceArn: + Fn::GetAtt: + - Queue + - Arn + FunctionName: + Ref: Function + Tags: + - Key: my + Value: tag + +Outputs: + QueueUrl: + Value: !Ref Queue + + EventSourceMappingArn: + Value: !GetAtt EventSourceMapping.EventSourceMappingArn + + FunctionName: + Value: !Ref Function + + OutputBucketName: + Value: !Ref OutputBucket \ No newline at end of file diff --git a/tests/aws/templates/eventbridge_policy.yaml b/tests/aws/templates/eventbridge_policy.yaml new file mode 100644 index 0000000000000..824bf1d600b17 --- /dev/null +++ b/tests/aws/templates/eventbridge_policy.yaml @@ -0,0 +1,25 @@ +Parameters: + EventBusName: + Type: String + +Resources: + bus707364D1: + Type: AWS::Events::EventBus + Properties: + Name: !Ref EventBusName + eventPolicy: + Type: AWS::Events::EventBusPolicy + Properties: + StatementId: pol1 + Action: events:PutEvents + EventBusName: + Ref: bus707364D1 + Principal: "111122223333" + eventPolicy2: + Type: AWS::Events::EventBusPolicy + Properties: + StatementId: pol2 + Action: events:PutEvents + EventBusName: + Ref: bus707364D1 + Principal: "*" diff --git a/tests/aws/templates/eventbridge_policy_singlepolicy.yaml b/tests/aws/templates/eventbridge_policy_singlepolicy.yaml new file mode 100644 index 0000000000000..e4c3894355f76 --- /dev/null +++ b/tests/aws/templates/eventbridge_policy_singlepolicy.yaml @@ -0,0 +1,16 @@ +Parameters: + EventBusName: + Type: String +Resources: + bus707364D1: + Type: AWS::Events::EventBus + Properties: + Name: !Ref EventBusName + eventPolicy: + Type: AWS::Events::EventBusPolicy + Properties: + StatementId: pol1 + Action: events:PutEvents + EventBusName: + Ref: bus707364D1 + Principal: "111122223333" diff --git a/tests/aws/templates/eventbridge_policy_statement.yaml b/tests/aws/templates/eventbridge_policy_statement.yaml new file mode 100644 index 0000000000000..4083153a11b4f --- /dev/null +++ b/tests/aws/templates/eventbridge_policy_statement.yaml @@ -0,0 +1,21 @@ +Parameters: + EventBusName: + Type: String + StatementId: + Type: String + +Resources: + LsEventBus: + Type: AWS::Events::EventBus + Properties: + Name: !Ref EventBusName + LsEventPolicy: + Type: AWS::Events::EventBusPolicy + Properties: + StatementId: !Ref StatementId + Statement: + Effect: "Allow" + Principal: "*" + Action: "events:PutEvents" + Resource: !GetAtt LsEventBus.Arn + EventBusName: !Ref LsEventBus diff --git a/tests/aws/templates/events_apidestination.yml b/tests/aws/templates/events_apidestination.yml new file mode 100644 index 0000000000000..444cb9bbf4dd5 --- /dev/null +++ b/tests/aws/templates/events_apidestination.yml @@ -0,0 +1,91 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Parameters: + Region: + Type: String + Default: 'us-east-1' + +Resources: + LocalEventBus: + Type: AWS::Events::EventBus + Properties: + Name: my-test-bus + + LocalEventConnection: + Type: AWS::Events::Connection + Properties: + Name: my-test-conn + AuthorizationType: API_KEY + AuthParameters: + ApiKeyAuthParameters: + ApiKeyName: apikey123 + ApiKeyValue: secretapikey123 + Description: test events connection + + MyLambdaFunctionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: LambdaBasicExecution + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + + MyLambdaFunction: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + def handler(event, ctx): + return {"statusCode": 200, "body": "hello"} + Role: !GetAtt MyLambdaFunctionRole.Arn + Handler: index.handler + Runtime: python3.8 + FunctionName: my-test-function + + MyApiGatewayResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref MyApiGateway + ParentId: !GetAtt MyApiGateway.RootResourceId + PathPart: 'myendpoint' + + MyApiGatewayMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref MyApiGateway + ResourceId: !Ref MyApiGatewayResource + HttpMethod: 'POST' + AuthorizationType: 'NONE' + Integration: + IntegrationHttpMethod: 'POST' + Type: 'AWS_PROXY' + Uri: !Sub 'arn:aws:apigateway:${Region}:lambda:path/2015-03-31/functions/${MyLambdaFunction.Arn}/invocations' + + MyApiGateway: + Type: AWS::ApiGateway::RestApi + Properties: + Name: MyApiGateway + + LocalEventApiDestination: + Type: AWS::Events::ApiDestination + Properties: + Name: my-test-destination + ConnectionArn: !GetAtt LocalEventConnection.Arn + Description: test events api destination + HttpMethod: POST + InvocationEndpoint: + Fn::Sub: 'https://${MyApiGateway}.execute-api.${Region}.amazonaws.com/myendpoint' diff --git a/tests/aws/templates/events_loggroup.yaml b/tests/aws/templates/events_loggroup.yaml new file mode 100644 index 0000000000000..ccd49f529f558 --- /dev/null +++ b/tests/aws/templates/events_loggroup.yaml @@ -0,0 +1,57 @@ +Parameters: + EventBusName: + Type: String + EventRuleName: + Type: String + LogGroupName: + Type: String + PolicyName: + Type: String + +Resources: + TestBusF2C65FE8: + Type: AWS::Events::EventBus + Properties: + Name: !Ref EventBusName + TestLogGroup4EEF7AD4: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Ref LogGroupName + RetentionInDays: 731 + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + TestRule98A50909: + Type: AWS::Events::Rule + Properties: + EventBusName: + Ref: TestBusF2C65FE8 + EventPattern: + version: + - "0" + Name: !Ref EventRuleName + State: ENABLED + Targets: + - Arn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":logs:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":log-group:" + - Ref: TestLogGroup4EEF7AD4 + Id: Target0 + TestLogEventResourcePolicy: + Type: AWS::Logs::ResourcePolicy + Properties: + PolicyDocument: + Fn::Join: + - "" + - - '{"Statement":[{"Action":"*","Effect":"Allow","Principal":{"AWS":"*"},"Resource":"' + - Fn::GetAtt: + - TestLogGroup4EEF7AD4 + - Arn + - '"}],"Version":"2012-10-17"}' + PolicyName: !Ref PolicyName diff --git a/tests/aws/templates/events_rule_pattern.yml b/tests/aws/templates/events_rule_pattern.yml new file mode 100644 index 0000000000000..d2b11fc099e43 --- /dev/null +++ b/tests/aws/templates/events_rule_pattern.yml @@ -0,0 +1,29 @@ +Resources: + TestLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/events/test-log-group-${AWS::AccountId}" + + TestRule: + Type: AWS::Events::Rule + Properties: + EventPattern: + source: + - aws.s3 + detail-type: + - Object Created + detail: + bucket: + name: + - test-s3-bucket + object: + key: + - suffix: /test.json + Targets: + - Id: "TestLogGroupTarget" + Arn: !GetAtt TestLogGroup.Arn + +Outputs: + RuleName: + Description: Name of the EventBridge Rule + Value: !Ref TestRule diff --git a/tests/aws/templates/events_rule_properties.yaml b/tests/aws/templates/events_rule_properties.yaml new file mode 100644 index 0000000000000..5c13762c30c16 --- /dev/null +++ b/tests/aws/templates/events_rule_properties.yaml @@ -0,0 +1,55 @@ +Parameters: + EventBusName: + Type: String + + RuleName: + Type: String + +Resources: + Bus: + Type: AWS::Events::EventBus + Properties: + Name: !Ref EventBusName + + RuleWithoutName: + Type: AWS::Events::Rule + Properties: + EventPattern: + resources: + - "*" + EventBusName: !GetAtt Bus.Name + + RuleWithName: + Type: AWS::Events::Rule + Properties: + Name: !Ref RuleName + EventPattern: + resources: + - "*" + EventBusName: !GetAtt Bus.Name + + RuleWithoutBus: + Type: AWS::Events::Rule + Properties: + EventPattern: + resources: + - "*" + +Outputs: + RuleWithoutNameArn: + Value: !GetAtt RuleWithoutName.Arn + + RuleWithoutNameRef: + Value: !Ref RuleWithoutName + + RuleWithNameArn: + Value: !GetAtt RuleWithName.Arn + + RuleWithNameRef: + Value: !Ref RuleWithName + + RuleWithoutBusArn: + Value: !GetAtt RuleWithoutBus.Arn + + RuleWithoutBusRef: + Value: !Ref RuleWithoutBus diff --git a/tests/aws/templates/events_rule_without_targets.yaml b/tests/aws/templates/events_rule_without_targets.yaml new file mode 100644 index 0000000000000..507e063b9ab2b --- /dev/null +++ b/tests/aws/templates/events_rule_without_targets.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + EventRuleName: + Type: String +Resources: + TestRule99A50909: + Type: AWS::Events::Rule + Properties: + Name: + Ref: EventRuleName + ScheduleExpression: 'cron(0 1 * * ? *)' \ No newline at end of file diff --git a/tests/aws/templates/firehose_kinesis_as_source.yaml b/tests/aws/templates/firehose_kinesis_as_source.yaml new file mode 100644 index 0000000000000..d061a585b8df6 --- /dev/null +++ b/tests/aws/templates/firehose_kinesis_as_source.yaml @@ -0,0 +1,78 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + BucketName: + Type: String + StreamName: + Type: String + DeliveryStreamName: + Type: String +Resources: + bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName + stream: + Type: AWS::Kinesis::Stream + Properties: + Name: !Ref StreamName + ShardCount: 2 + role: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: '' + Effect: Allow + Principal: + Service: firehose.amazonaws.com + Action: 'sts:AssumeRole' + - Sid: '' + Effect: Allow + Principal: + Service: kinesis.amazonaws.com + Action: 'sts:AssumeRole' + Policies: + - PolicyName: root + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: "*" + Resource: "*" + deliveryStream: + DependsOn: + - bucket + - stream + Type: AWS::KinesisFirehose::DeliveryStream + Properties: + DeliveryStreamName: !Ref DeliveryStreamName + DeliveryStreamType: KinesisStreamAsSource + KinesisStreamSourceConfiguration: + KinesisStreamARN: !GetAtt 'stream.Arn' + RoleARN: !GetAtt 'role.Arn' + ExtendedS3DestinationConfiguration: + BucketARN: !GetAtt 'bucket.Arn' + RoleARN: !GetAtt 'role.Arn' + BufferingHints: + IntervalInSeconds: 60 + SizeInMBs: 64 + ProcessingConfiguration: + Enabled: true + Processors: + - Type: MetadataExtraction + Parameters: + - ParameterName: "MetadataExtractionQuery" + ParameterValue: "{s3Prefix: .tableName}" + - ParameterName: "JsonParsingEngine" + ParameterValue: "JQ-1.6" + DynamicPartitioningConfiguration: + Enabled: true + DataFormatConversionConfiguration: + Enabled: false + Prefix: "firehoseTest/!{partitionKeyFromQuery:s3Prefix}" + ErrorOutputPrefix: "firehoseTest-errors/!{firehose:error-output-type}/" +Outputs: + deliveryStreamRef: + Value: + Ref: deliveryStream diff --git a/tests/aws/templates/for_removal_remove.yaml b/tests/aws/templates/for_removal_remove.yaml new file mode 100644 index 0000000000000..b196c96781b99 --- /dev/null +++ b/tests/aws/templates/for_removal_remove.yaml @@ -0,0 +1,10 @@ +Resources: + TestBucket560B80BC: + Type: AWS::S3::Bucket + Properties: + BucketName: {{ first_bucket_name }} + UpdateReplacePolicy: Delete + DeletionPolicy: Delete +Outputs: + FirstBucket: + Value: !Ref TestBucket560B80BC diff --git a/tests/aws/templates/for_removal_setup.yaml b/tests/aws/templates/for_removal_setup.yaml new file mode 100644 index 0000000000000..49d8c9b44941d --- /dev/null +++ b/tests/aws/templates/for_removal_setup.yaml @@ -0,0 +1,18 @@ +Resources: + TestBucket560B80BC: + Type: AWS::S3::Bucket + Properties: + BucketName: {{ first_bucket_name }} + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + HestBucketABE4AE1C: + Type: AWS::S3::Bucket + Properties: + BucketName: {{ second_bucket_name }} + UpdateReplacePolicy: Delete + DeletionPolicy: Delete +Outputs: + FirstBucket: + Value: !Ref TestBucket560B80BC + SecondBucket: + Value: !Ref HestBucketABE4AE1C diff --git a/tests/aws/templates/function_find_in_map.yml b/tests/aws/templates/function_find_in_map.yml new file mode 100644 index 0000000000000..8fde0362711b8 --- /dev/null +++ b/tests/aws/templates/function_find_in_map.yml @@ -0,0 +1,32 @@ +Parameters: + SelectedRegion: + Type: String + Default: east + AllowedValues: + - east + - west +Mappings: + east: + region: + key: us-east-1 + west: + region: + key: us-west-1 + +Resources: + FunctionTest: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::FindInMap: + - !Ref SelectedRegion + - region + - key + +Outputs: + Result: + Value: + Fn::GetAtt: + - FunctionTest + - Value \ No newline at end of file diff --git a/tests/aws/templates/function_to_json_string.yml b/tests/aws/templates/function_to_json_string.yml new file mode 100644 index 0000000000000..aad59a36966ae --- /dev/null +++ b/tests/aws/templates/function_to_json_string.yml @@ -0,0 +1,24 @@ +Parameters: + Value1: + Type: String + Value2: + Type: String + +Transform: "AWS::LanguageExtensions" + +Resources: + JsonTest: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::ToJsonString: + key1: !Ref Value1 + key2: !Ref Value2 + +Outputs: + Result: + Value: + Fn::GetAtt: + - JsonTest + - Value \ No newline at end of file diff --git a/tests/aws/templates/functions_cidr.yml b/tests/aws/templates/functions_cidr.yml new file mode 100644 index 0000000000000..c923a6d40b96d --- /dev/null +++ b/tests/aws/templates/functions_cidr.yml @@ -0,0 +1,24 @@ +Parameters: + IpBlock: + Type: String + Count: + Type: Number + CidrBits: + Type: Number + Select: + Type: Number +Resources: + SsmParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::Select: + - !Ref Select + - !Cidr [!Ref IpBlock, !Ref Count, !Ref CidrBits] +Outputs: + Address: + Value: + Fn::GetAtt: + - SsmParameter + - Value diff --git a/tests/aws/templates/functions_get_azs.yml b/tests/aws/templates/functions_get_azs.yml new file mode 100644 index 0000000000000..09f40efe00321 --- /dev/null +++ b/tests/aws/templates/functions_get_azs.yml @@ -0,0 +1,19 @@ +Parameters: + DeployRegion: + Type: String + +Resources: + SsmParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::Join: + - ";" + - Fn::GetAZs: !Ref DeployRegion +Outputs: + Zones: + Value: + Fn::GetAtt: + - SsmParameter + - Value diff --git a/tests/aws/templates/functions_getatt_sub_base64.yml b/tests/aws/templates/functions_getatt_sub_base64.yml new file mode 100644 index 0000000000000..2945d2550104d --- /dev/null +++ b/tests/aws/templates/functions_getatt_sub_base64.yml @@ -0,0 +1,20 @@ +Parameters: + OriginalString: + Type: String +Resources: + SsmParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::Base64: + Fn::Sub: + - "${value}" + - value: + Ref: OriginalString +Outputs: + Encoded: + Value: + Fn::GetAtt: + - SsmParameter + - Value diff --git a/tests/aws/templates/functions_select_split_join.yml b/tests/aws/templates/functions_select_split_join.yml new file mode 100644 index 0000000000000..1efc2a5b6f46b --- /dev/null +++ b/tests/aws/templates/functions_select_split_join.yml @@ -0,0 +1,52 @@ +Parameters: + MultipleValues: + Type: String + Value1: + Type: String + Value2: + Type: String + +Transform: "AWS::LanguageExtensions" + +Resources: + SplitTest: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Select + - "0" + - !Split + - ";" + - !Ref MultipleValues + + JoinTest: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Join ["_", [!Ref Value1, !Ref Value2]] + + SplitJoinTest: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Join ["_", !Split [ ";", !Ref MultipleValues]] + +Outputs: + SplitResult: + Value: + Fn::GetAtt: + - SplitTest + - Value + JoinResult: + Value: + Fn::GetAtt: + - JoinTest + - Value + SplitJoin: + Value: + Fn::GetAtt: + - SplitJoinTest + - Value + LengthResult: + Value: + Fn::Length: !Split [ ";", !Ref MultipleValues] diff --git a/tests/aws/templates/iam_access_key.yaml b/tests/aws/templates/iam_access_key.yaml new file mode 100644 index 0000000000000..14836688d168c --- /dev/null +++ b/tests/aws/templates/iam_access_key.yaml @@ -0,0 +1,34 @@ +Parameters: + UserName: + Type: String + Status: + Type: String + Default: Active + Serial: + Type: String + Default: 1 + +Resources: + User: + Type: AWS::IAM::User + Properties: + UserName: + Ref: UserName + + Credentials: + Type: AWS::IAM::AccessKey + Properties: + UserName: + Ref: UserName + Serial: + Ref: Serial + Status: + Ref: Status + DependsOn: User + +Outputs: + AccessKeyId: + Value: + !Ref Credentials + SecretAccessKey: + Value: !GetAtt Credentials.SecretAccessKey \ No newline at end of file diff --git a/tests/aws/templates/iam_policy.yml b/tests/aws/templates/iam_policy.yml new file mode 100644 index 0000000000000..da67cca6d414d --- /dev/null +++ b/tests/aws/templates/iam_policy.yml @@ -0,0 +1,15 @@ +Parameters: + Name: + Type: String +Resources: + Policy: + Type: 'AWS::IAM::Policy' + Properties: + PolicyName: + Ref: Name + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: '*' + Resource: '*' \ No newline at end of file diff --git a/tests/aws/templates/iam_policy_attachments.yaml b/tests/aws/templates/iam_policy_attachments.yaml new file mode 100644 index 0000000000000..ece646ae6f73a --- /dev/null +++ b/tests/aws/templates/iam_policy_attachments.yaml @@ -0,0 +1,70 @@ +Resources: + roleC7B7E775: + Type: AWS::IAM::Role + Properties: + RoleName: {{ role_name }} + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: "*" + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/AdministratorAccess + Policies: + - PolicyName: {{ role_name }}-s3 + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - "s3:*" + Resource: + - "arn:aws:s3:::bucket" + - !Ref AWS::NoValue + serviceLinkedRole754232: + Type: AWS::IAM::ServiceLinkedRole + Properties: + AWSServiceName: elasticbeanstalk.amazonaws.com + Description: service linked role {{ service_linked_role_id }} + groupC397F008: + Type: AWS::IAM::Group + Properties: + GroupName: {{ group_name }} + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/AdministratorAccess + user2C2B57AE: + Type: AWS::IAM::User + Properties: + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/AdministratorAccess + UserName: {{ user_name }} + policyE16B4B70: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: "*" + Effect: Allow + Resource: "*" + Version: "2012-10-17" + PolicyName: {{ policy_name }} + Groups: + - Ref: groupC397F008 + Roles: + - Ref: roleC7B7E775 + Users: + - Ref: user2C2B57AE diff --git a/tests/aws/templates/iam_policy_invalid.yaml b/tests/aws/templates/iam_policy_invalid.yaml new file mode 100644 index 0000000000000..3a4fad95b8084 --- /dev/null +++ b/tests/aws/templates/iam_policy_invalid.yaml @@ -0,0 +1,15 @@ +Parameters: + Name: + Type: String +Resources: + BrokenPolicy: + Type: 'AWS::IAM::Policy' + Properties: + PolicyName: + Ref: Name + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: '*' + Resource: '*' diff --git a/tests/aws/templates/iam_policy_role.yaml b/tests/aws/templates/iam_policy_role.yaml new file mode 100644 index 0000000000000..85cc200b11b3a --- /dev/null +++ b/tests/aws/templates/iam_policy_role.yaml @@ -0,0 +1,56 @@ +Parameters: + UserName: + Type: String + Default: "RandomUser" + RoleName: + Type: String + Default: "RandomRole" + PolicyName: + Type: String + Default: "RandomPolicy" +Resources: + MyUser: + Type: "AWS::IAM::User" + Properties: + UserName: !Ref UserName + MyRole: + Type: "AWS::IAM::Role" + Properties: + RoleName: !Ref RoleName + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "ec2.amazonaws.com" + Action: + - "sts:AssumeRole" + UserPolicy: + Type: "AWS::IAM::Policy" + Properties: + PolicyName: !Ref PolicyName + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: + - "s3:PutObject" + - "s3:ListBucket" + Resource: "*" + Users: + - Ref: MyUser + RolePolicy: + Type: "AWS::IAM::Policy" + Properties: + PolicyName: !Ref PolicyName + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: + - "s3:GetObject" + - "s3:ListBucket" + Resource: "*" + Roles: + - Ref: MyRole \ No newline at end of file diff --git a/tests/aws/templates/iam_policy_role_updated.yaml b/tests/aws/templates/iam_policy_role_updated.yaml new file mode 100644 index 0000000000000..8f856545c970c --- /dev/null +++ b/tests/aws/templates/iam_policy_role_updated.yaml @@ -0,0 +1,54 @@ +Parameters: + UserName: + Type: String + Default: "RandomUser" + RoleName: + Type: String + Default: "RandomRole" + PolicyName: + Type: String + Default: "RandomPolicy" +Resources: + MyUser: + Type: "AWS::IAM::User" + Properties: + UserName: !Ref UserName + MyRole: + Type: "AWS::IAM::Role" + Properties: + RoleName: !Ref RoleName + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "ec2.amazonaws.com" + Action: + - "sts:AssumeRole" + UserPolicy: + Type: "AWS::IAM::Policy" + Properties: + PolicyName: !Ref PolicyName + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: + - "s3:PutObject" + Resource: "*" + Users: + - Ref: MyUser + RolePolicy: + Type: "AWS::IAM::Policy" + Properties: + PolicyName: !Ref PolicyName + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: + - "s3:ListBucket" + Resource: "*" + Roles: + - Ref: MyRole \ No newline at end of file diff --git a/tests/aws/templates/iam_role_defaults.yml b/tests/aws/templates/iam_role_defaults.yml new file mode 100644 index 0000000000000..403ba6af8fe72 --- /dev/null +++ b/tests/aws/templates/iam_role_defaults.yml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: 2010-09-09 +Resources: + IamRoleLambdaExecution: + Type: 'AWS::IAM::Role' + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "ec2.amazonaws.com" + Action: + - "sts:AssumeRole" + Path: "/test-role-prefix/" diff --git a/tests/aws/templates/iam_role_policy.yaml b/tests/aws/templates/iam_role_policy.yaml new file mode 100644 index 0000000000000..1978669ee4a9b --- /dev/null +++ b/tests/aws/templates/iam_role_policy.yaml @@ -0,0 +1,22 @@ +Parameters: + RoleName: + Type: String + +Resources: + roleC7B7E775: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: "*" + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/AdministratorAccess + RoleName: !Ref RoleName diff --git a/tests/aws/templates/iam_server_certificate.yaml b/tests/aws/templates/iam_server_certificate.yaml new file mode 100644 index 0000000000000..7c6f3bfa0c6b3 --- /dev/null +++ b/tests/aws/templates/iam_server_certificate.yaml @@ -0,0 +1,70 @@ +Parameters: + certificateName: + Type: String + +Resources: + ServerCertificate: + Type: AWS::IAM::ServerCertificate + Properties: + ServerCertificateName: !Ref certificateName + PrivateKey: | + -----BEGIN PRIVATE KEY----- + MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJykIUBqUbsXDu + URfFTMgJlnuK7OF1nqHVUH5WLSVWPpgoPyYnZoGHtRGF7xNkKn9V84tjtqBCSUgL + P7Plm/dcs7p56B1Uf+bkx3Yzdnuh0YRJlBZSbnKJ45ZgcVSntSxuEWvMqyQPhNLU + tpM6xgizXBaYIUPfNhiY7r0VpvZwALAiT5uzfGvxptaKTHVJIh9W/93eO9MLoIrM + +n2ER0OtVCGH4zVFpYP7VdbYRjknAAdkcrrECXUweNtgUAJaQ+E01/Lo/FndBoWI + 6TPA1lqUlDhbKGDhwXN2z6+3LaTqCBvIGXLOesw8GYvQlXWuL4kv/Bz5wq/I12kF + taasD/HpAgMBAAECggEAKePgBdI/UllqrT6OZboDyOHBcdytDULKK8NTBsbGenny + EmDRpdpEx4xSP/CaoO+lkY1GgYO3DyuxVgx6Zw8Ssd7ptkb2V8VZhGLX6eUN01Dw + WmnwnForUu65F/pO7aXRvGPHciyRBtu2/MuOEuRrh/h1BE3bjinnv0/IVwdbH3LW + pLiJoxzlSJDDomaIAOtB3u6Lw1/6kXiYT9lvXnUpBzR+1uMApTPQN0NJuxLiA0Rs + es2kBTZ/weEQW+GeJaSYmEXX9zCKGMVCq5EZfS3sH0TrkDENVqW40J+OF3Ee6r12 + CoWLWkC+DPtfHvwh1zp89HFYZ7I6lyycBb31yHb1kQKBgQDuURbpgWxP7XaSgPuI + 6rv2ApjZQav58kNj1K1pRIcnoZsfz3LX3xfft0PKyoKDmndN8nS9KKL9T//XIBaO + PeD3XzlSvQQ/SvNdaBHqOzkkwldGng3swR3c8RELoaKU9yBdhlMFYXkZsIp5hZgG + MPVdihamFfUk9J/sdYAr9vjnVQKBgQDYw1TWyBi4UTkMox62hqSUgWw3llaliHkP + tEinMKF3i0oZzGzWDIHV9YoPPuu2L5cy+j2wLe8r6DWvsKd0dqeNS/yXYj7eIDVz + fff9SmP25RdtV8h6fkAiLD708G7P0w94G+LhakuVpeTpMNSDPWUk6bl+K81ZRvm6 + DKS7aOM4RQKBgEhQFrG38dO27Fm8BZcgEvStCRAzWym2lzg9mnjssE4YPWfDnMdg + DHB3vXxVQpEIV9cxELctE3flxG3UcMOshwzIui4e6KED7yCSqYz3d3lt9umYoAUM + /DDEfTWYUCr/abS3Q43Ia+SdqwcAwIZwaKN/eSvgUchq6fPoG4I7qH8ZAoGBAMRS + ndtuHZ2Kyw3cC6wrZJKwabAq9M02PtdvZMIwdH3OZU3abdSsPUfo/KL0TQ6UKfBc + 31RbNhzhUwaODAyajwSVhvAhZmlOaLryo5IAN2vdcAtzjzsKb9HDmz3DKcoHEiKp + tyKMYGrodtyRglhfWeVF3uAckf9DHllYrDalN+61AoGAP9OrCgoDnjtTasFzibZ8 + jb+xYG9E42smB2gep03Jj8l5gqnWTFh0TyA1Z7+RJNvSzkqK8bU/uAH/TgJAqviE + 7XA7a2yuaf/Ww4vToy5bo1HqhQBak1PP2wzuWiUkJcyTRTGryLvnIR9fDonJ9TAd + 0GsjqdfyAqjsvycLNvwR0wk= + -----END PRIVATE KEY----- + CertificateBody: | + -----BEGIN CERTIFICATE----- + MIIEHTCCAwWgAwIBAgIDAJojMA0GCSqGSIb3DQEBCwUAMIGLMQswCQYDVQQGEwJV + UzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEX + MBUGA1UECgwOTXlPcmdhbml6YXRpb24xHTAbBgNVBAsMFE15T3JnYW5pemF0aW9u + YWxVbml0MRcwFQYDVQQDDA5NeSBvd24gUm9vdCBDQTAeFw0yMTAzMTExNTAwNDla + Fw0zMDAzMDkxNTAwNDlaMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZv + cm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEXMBUGA1UECgwOTXlPcmdhbml6 + YXRpb24xHTAbBgNVBAsMFE15T3JnYW5pemF0aW9uYWxVbml0MRQwEgYDVQQDDAtl + eGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMnKQhQG + pRuxcO5RF8VMyAmWe4rs4XWeodVQflYtJVY+mCg/JidmgYe1EYXvE2Qqf1Xzi2O2 + oEJJSAs/s+Wb91yzunnoHVR/5uTHdjN2e6HRhEmUFlJuconjlmBxVKe1LG4Ra8yr + JA+E0tS2kzrGCLNcFpghQ982GJjuvRWm9nAAsCJPm7N8a/Gm1opMdUkiH1b/3d47 + 0wugisz6fYRHQ61UIYfjNUWlg/tV1thGOScAB2RyusQJdTB422BQAlpD4TTX8uj8 + Wd0GhYjpM8DWWpSUOFsoYOHBc3bPr7ctpOoIG8gZcs56zDwZi9CVda4viS/8HPnC + r8jXaQW1pqwP8ekCAwEAAaOBijCBhzAJBgNVHRMEAjAAMB0GA1UdDgQWBBTaOaPu + XmtLDTJVv++VYBiQr9gHCTAfBgNVHSMEGDAWgBTaOaPuXmtLDTJVv++VYBiQr9gH + CTATBgNVHSUEDDAKBggrBgEFBQcDATALBgNVHQ8EBAMCB4AwGAYDVR0RBBEwD4IN + Ki5leGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAWIZu4sma7MmWTXSMwKSP + stQDWdIvcwthD8ozHkLsNdl5eKqOEndAc0wb7mSk1z8rRkSsd0D0T2zaKyduCYrs + eBAMhS2+NnHWcXxhn0VOkmXhw5kO8Un14KIptRH0y8FIqHMJ8LrSiK9g9fWCRlI9 + g7eBipu43hzGyMiBP3K0EQ4m49QXlIEwG3OIWak5hdR29h3cD6xXMXaUtlOswsAN + 3PDG/gcjZWZpkwPlaVzwjV8MRsYLmQIYdHPr/qF1FWddYPvK89T0nzpgiuFdBOTY + W6I1TeTAXFXG2Qf4trXsh5vsFNAisxlRF3mkpixYP5OmVXTOyN7cCOSPOUh6Uctv + eg== + -----END CERTIFICATE----- + +Outputs: + ServerCertificateName: + Value: !Ref ServerCertificate + Arn: + Value: !GetAtt ServerCertificate.Arn diff --git a/tests/aws/templates/integration_events_sns_sqs_lambda.yaml b/tests/aws/templates/integration_events_sns_sqs_lambda.yaml new file mode 100644 index 0000000000000..38788927ce6bb --- /dev/null +++ b/tests/aws/templates/integration_events_sns_sqs_lambda.yaml @@ -0,0 +1,206 @@ +Parameters: + FunctionName: + Type: String + QueueName: + Type: String + TopicName: + Type: String + BusName: + Type: String + RuleName: + Type: String +Resources: + LogFnServiceRoleA176C900: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + LogFnServiceRoleDefaultPolicy9B17FD14: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:ReceiveMessage + - sqs:ChangeMessageVisibility + - sqs:GetQueueUrl + - sqs:DeleteMessage + - sqs:GetQueueAttributes + Effect: Allow + Resource: + Fn::GetAtt: + - queue276F7297 + - Arn + Version: "2012-10-17" + PolicyName: LogFnServiceRoleDefaultPolicy9B17FD14 + Roles: + - Ref: LogFnServiceRoleA176C900 + LogFn234F4FB3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + + def handler(event,context): + print(event) + return "Hello World" + Role: + Fn::GetAtt: + - LogFnServiceRoleA176C900 + - Arn + FunctionName: !Ref FunctionName + Handler: index.handler + Runtime: python3.11 + DependsOn: + - LogFnServiceRoleDefaultPolicy9B17FD14 + - LogFnServiceRoleA176C900 + LogFnEventInvokeConfigD2016CD9: + Type: AWS::Lambda::EventInvokeConfig + Properties: + FunctionName: + Ref: LogFn234F4FB3 + Qualifier: $LATEST + MaximumRetryAttempts: 0 + LogFnSqsEventSourceGovTestEventsStackqueue8C97EB0598728401: + Type: AWS::Lambda::EventSourceMapping + Properties: + FunctionName: + Ref: LogFn234F4FB3 + BatchSize: 1 + EventSourceArn: + Fn::GetAtt: + - queue276F7297 + - Arn + LogFnAllowInvokeGovTestEventsStacktopic8E8111AE4FC418DD: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LogFn234F4FB3 + - Arn + Principal: sns.amazonaws.com + SourceArn: + Ref: topic69831491 + LogFntopic224FEF02: + Type: AWS::SNS::Subscription + Properties: + Protocol: lambda + TopicArn: + Ref: topic69831491 + Endpoint: + Fn::GetAtt: + - LogFn234F4FB3 + - Arn + queue276F7297: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + queuePolicy89DB7105: + Type: AWS::SQS::QueuePolicy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:SendMessage + - sqs:GetQueueAttributes + - sqs:GetQueueUrl + Condition: + ArnEquals: + aws:SourceArn: + Fn::GetAtt: + - ruleF2C1DCDC + - Arn + Effect: Allow + Principal: + Service: events.amazonaws.com + Resource: + Fn::GetAtt: + - queue276F7297 + - Arn + Version: "2012-10-17" + Queues: + - Ref: queue276F7297 + topic69831491: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + topicPolicyBC9D8025: + Type: AWS::SNS::TopicPolicy + Properties: + PolicyDocument: + Statement: + - Action: sns:Publish + Effect: Allow + Principal: + Service: events.amazonaws.com + Resource: + Ref: topic69831491 + Sid: "0" + Version: "2012-10-17" + Topics: + - Ref: topic69831491 + bus707364D1: + Type: AWS::Events::EventBus + Properties: + Name: !Ref BusName + ruleF2C1DCDC: + Type: AWS::Events::Rule + Properties: + EventBusName: + Ref: bus707364D1 + EventPattern: + version: + - "0" + Name: !Ref RuleName + State: ENABLED + Targets: + - Arn: + Fn::GetAtt: + - queue276F7297 + - Arn + Id: Target0 + - Arn: + Ref: topic69831491 + Id: Target1 +Outputs: + TopicArn: + Value: + Ref: topic69831491 + QueueArn: + Value: + Fn::GetAtt: + - queue276F7297 + - Arn + QueueUrl: + Value: + Ref: queue276F7297 + FnArn: + Value: + Fn::GetAtt: + - LogFn234F4FB3 + - Arn + FnName: + Value: + Ref: LogFn234F4FB3 + EventBusArn: + Value: + Fn::GetAtt: + - bus707364D1 + - Arn + EventBusName: + Value: + Ref: bus707364D1 diff --git a/tests/aws/templates/internet_gateway.yml b/tests/aws/templates/internet_gateway.yml new file mode 100644 index 0000000000000..aecf86ad5dc56 --- /dev/null +++ b/tests/aws/templates/internet_gateway.yml @@ -0,0 +1,21 @@ +Resources: + Gateway: + Type: AWS::EC2::InternetGateway + Vpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + GatewayAttachment: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: + Ref: Vpc + InternetGatewayId: + !GetAtt [Gateway,InternetGatewayId] +Outputs: + RefAttachment: + Value: + Ref: Gateway + IdAttachment: + Value: + !GetAtt [Gateway,InternetGatewayId] diff --git a/tests/aws/templates/invalid_stack_policy.json b/tests/aws/templates/invalid_stack_policy.json new file mode 100644 index 0000000000000..7c8a1da46ce78 --- /dev/null +++ b/tests/aws/templates/invalid_stack_policy.json @@ -0,0 +1,3 @@ +{ + "Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*" +} \ No newline at end of file diff --git a/tests/aws/templates/kinesis_default.yaml b/tests/aws/templates/kinesis_default.yaml new file mode 100644 index 0000000000000..93d8392fcf5a6 --- /dev/null +++ b/tests/aws/templates/kinesis_default.yaml @@ -0,0 +1,12 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Simple CloudFormation Test Template +Resources: + KinesisStream: + Type: AWS::Kinesis::Stream + Properties: + ShardCount: 1 + + +Outputs: + KinesisStreamName: + Value: !Ref KinesisStream diff --git a/tests/aws/templates/kinesis_stream_consumer.yaml b/tests/aws/templates/kinesis_stream_consumer.yaml new file mode 100644 index 0000000000000..148fe0c4034b0 --- /dev/null +++ b/tests/aws/templates/kinesis_stream_consumer.yaml @@ -0,0 +1,21 @@ +Parameters: + TestConsumerName: + Type: String + +Resources: + KinesisStream: + Type: AWS::Kinesis::Stream + Properties: + ShardCount: 1 + StreamConsumer: + Type: AWS::Kinesis::StreamConsumer + Properties: + StreamARN: !GetAtt KinesisStream.Arn + ConsumerName: !Ref TestConsumerName +Outputs: + KinesisStreamArn: + Description: The ARN of the created Kinesis Stream + Value: !GetAtt StreamConsumer.StreamARN + KinesisSConsumerARN: + Description: The ARN of the created Kinesis Stream + Value: !GetAtt StreamConsumer.ConsumerARN diff --git a/tests/aws/templates/kms_key_disabled.yaml b/tests/aws/templates/kms_key_disabled.yaml new file mode 100644 index 0000000000000..18406f35bad4e --- /dev/null +++ b/tests/aws/templates/kms_key_disabled.yaml @@ -0,0 +1,26 @@ +Resources: + TestKey4CACAF33: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: kms:* + Effect: Allow + Principal: + AWS: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":iam::" + - Ref: AWS::AccountId + - :root + Resource: "*" + Version: "2012-10-17" + Enabled: false + UpdateReplacePolicy: Delete + DeletionPolicy: Delete +Outputs: + KeyIdOutput: + Value: + Ref: TestKey4CACAF33 diff --git a/tests/aws/templates/lambda_dynamodb_event_filter.yaml b/tests/aws/templates/lambda_dynamodb_event_filter.yaml new file mode 100644 index 0000000000000..a5a168310adfa --- /dev/null +++ b/tests/aws/templates/lambda_dynamodb_event_filter.yaml @@ -0,0 +1,115 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: >- + Sample SAM Template for Lambda fn Event-filtering with DynamoDB Streams + +Parameters: + FunctionName: + Type: String + TableName: + Type: String + Filter: + Type: String + +Globals: + Function: + Timeout: 3 + MemorySize: 128 + +Resources: + LambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Path: "/" + Policies: + - PolicyName: root + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + - logs:DescribeLogGroups + - logs:DescribeLogStreams + Resource: arn:aws:logs:*:*:* + - Effect: Allow + Action: + - dynamodb:DescribeStream + - dynamodb:GetRecords + - dynamodb:GetShardIterator + - dynamodb:ListStreams + Resource: !GetAtt StreamsSampleDDBTable.StreamArn + + StreamsSampleDDBTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Ref TableName + AttributeDefinitions: + - AttributeName: "PK" + AttributeType: "S" + - AttributeName: "SK" + AttributeType: "S" + KeySchema: + - AttributeName: "PK" + KeyType: "HASH" + - AttributeName: "SK" + KeyType: "RANGE" + StreamSpecification: + StreamViewType: "NEW_AND_OLD_IMAGES" + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + + DBEventStreamProcessor: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Ref FunctionName + Timeout: 300 + Role: !GetAtt LambdaExecutionRole.Arn + InlineCode: exports.handler = async (event, context) => + { + console.log('Hello world!'); + console.log(JSON.stringify(event)) + } + Handler: index.handler + Runtime: nodejs20.x + Architectures: + - x86_64 + Events: + DBProfileEventStream: + Type: DynamoDB + Properties: + Stream: !GetAtt StreamsSampleDDBTable.StreamArn + ParallelizationFactor: 10 + FunctionResponseTypes: + - ReportBatchItemFailures + StartingPosition: TRIM_HORIZON + BatchSize: 5 + FilterCriteria: + Filters: + - Pattern: | + { + "dynamodb": { + "NewImage": { + "homemade": { + "S": [ + { + "exists": false + } + ] + } + } + } + } + Enabled: true diff --git a/tests/aws/templates/lambda_dynamodb_filtering.yaml b/tests/aws/templates/lambda_dynamodb_filtering.yaml new file mode 100644 index 0000000000000..be4792d824b0e --- /dev/null +++ b/tests/aws/templates/lambda_dynamodb_filtering.yaml @@ -0,0 +1,56 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: >- + SAM template for Lambda fn Event-filtering with DynamoDB Streams + +Transform: AWS::Serverless-2016-10-31 +Parameters: + FunctionName: + Type: String + TableName: + Type: String + Filter: + Type: String + +Resources: + TriggerFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Ref FunctionName + InlineCode: exports.handler = async (event, context) => { console.log(JSON.stringify(event))} + Handler: index.handler + Runtime: nodejs20.x + MemorySize: 128 + Timeout: 100 + Description: DynamoDB put event trigger. + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref DynamoDBTable + Events: + DynamoDBTable: + Type: DynamoDB + Properties: + Stream: + !GetAtt DynamoDBTable.StreamArn + StartingPosition: TRIM_HORIZON + BatchSize: 1 + FilterCriteria: + Filters: + # Filter pattern + - Pattern: !Ref Filter + + DynamoDBTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Ref TableName + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + diff --git a/tests/aws/templates/lambda_function_update.yml b/tests/aws/templates/lambda_function_update.yml new file mode 100644 index 0000000000000..56f79c73a7f6d --- /dev/null +++ b/tests/aws/templates/lambda_function_update.yml @@ -0,0 +1,59 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + Environment: + Type: String + Default: 'ORIGINAL' + AllowedValues: + - 'ORIGINAL' + - 'UPDATED' + FunctionName: + Type: String + +Resources: + PullMarketsRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Path: "/" + Policies: + - PolicyName: AWSLambdaBasicExecutionRole + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:PutLogEvents + Resource: "*" + SomeNameFunction: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + exports.handler = async function(event, context) { + console.log('event:', event); + return event; + }; + Handler: index.handler + MemorySize: 1024 + Role: + Fn::GetAtt: + - PullMarketsRole + - Arn + Runtime: nodejs18.x + Timeout: 6 + FunctionName: !Ref FunctionName + Environment: + Variables: + TEST: !Ref Environment + +Outputs: + LambdaName: + Value: !Ref SomeNameFunction diff --git a/tests/aws/templates/lambda_layer_version.yml b/tests/aws/templates/lambda_layer_version.yml new file mode 100644 index 0000000000000..6b346ce55bc87 --- /dev/null +++ b/tests/aws/templates/lambda_layer_version.yml @@ -0,0 +1,71 @@ +Parameters: + LayerBucket: + Type: String + LayerName: + Type: String +Resources: + FunctionServiceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Layer: + Type: AWS::Lambda::LayerVersion + Properties: + LayerName: !Ref LayerName + CompatibleArchitectures: + - arm64 + CompatibleRuntimes: + - python3.11 + - python3.12 + Content: + S3Bucket: !Ref LayerBucket + S3Key: layer.zip + Description: "layer to test cfn" + Function: + Type: AWS::Lambda::Function + Properties: + Description: "function to test lambda layer" + Layers: + - !Ref Layer + Code: + ZipFile: | + def handler(event, *args, **kwargs): + return "CRUD test" + Role: + Fn::GetAtt: + - FunctionServiceRole + - Arn + Handler: index.handler + Runtime: python3.12 + + DependsOn: + - FunctionServiceRole +Outputs: + LambdaName: + Value: + Ref: Function + LambdaArn: + Value: + Fn::GetAtt: + - Function + - Arn + LayerVersionRef: + Value: + Ref: Layer + LayerVersionArn: + Value: + Fn::GetAtt: + - Layer + - LayerVersionArn diff --git a/tests/aws/templates/lambda_simple.yml b/tests/aws/templates/lambda_simple.yml new file mode 100644 index 0000000000000..6e578faec1c76 --- /dev/null +++ b/tests/aws/templates/lambda_simple.yml @@ -0,0 +1,46 @@ +Resources: + FunctionServiceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Function: + Type: AWS::Lambda::Function + Properties: + Description: "function to test lambda function url" + Code: + ZipFile: | + def handler(event, context): + return {"hello": "world"} + Role: + Fn::GetAtt: + - FunctionServiceRole + - Arn + Handler: index.handler + Runtime: python3.9 + Environment: + Variables: + ENDPOINT_URL: aws.amazon.com + + DependsOn: + - FunctionServiceRole +Outputs: + LambdaName: + Value: + Ref: Function + LambdaArn: + Value: + Fn::GetAtt: + - Function + - Arn diff --git a/tests/aws/templates/lambda_url.yaml b/tests/aws/templates/lambda_url.yaml new file mode 100644 index 0000000000000..84ac2a622815f --- /dev/null +++ b/tests/aws/templates/lambda_url.yaml @@ -0,0 +1,65 @@ +Resources: + FunctionServiceRole675BB04A: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Function76856677: + Type: AWS::Lambda::Function + Properties: + Description: "function to test lambda function url" + Code: + ZipFile: | + def handler(event, context): + return {"hello": "world"} + Role: + Fn::GetAtt: + - FunctionServiceRole675BB04A + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - FunctionServiceRole675BB04A + FunctioninvokefunctionurlA70D8F37: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunctionUrl + FunctionName: + Fn::GetAtt: + - Function76856677 + - Arn + Principal: "*" + FunctionUrlAuthType: NONE + UrlD4FAABD0: + Type: AWS::Lambda::Url + Properties: + AuthType: NONE + TargetFunctionArn: + Fn::GetAtt: + - Function76856677 + - Arn +Outputs: + LambdaName: + Value: + Ref: Function76856677 + LambdaUrl: + Value: + Fn::GetAtt: + - UrlD4FAABD0 + - FunctionUrl + LambdaArn: + Value: + Fn::GetAtt: + - Function76856677 + - Arn diff --git a/tests/aws/templates/legacy_transitive_ref.yaml b/tests/aws/templates/legacy_transitive_ref.yaml new file mode 100644 index 0000000000000..950b7db13460d --- /dev/null +++ b/tests/aws/templates/legacy_transitive_ref.yaml @@ -0,0 +1,23 @@ +Parameters: + QueueName: + Type: String + Qualifier: + Type: String +Resources: + TestQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + Tags: + - Key: test + Value: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion}" + CdkBootstrapVersion: + Type: "AWS::SSM::Parameter" + Properties: + Type: String + Name: !Sub "/cdk-bootstrap/${Qualifier}/version" + Value: "..." +Outputs: + QueueURL: + Value: + Ref: TestQueue diff --git a/tests/aws/templates/logs_group.yml b/tests/aws/templates/logs_group.yml new file mode 100644 index 0000000000000..b0aaa510dcfae --- /dev/null +++ b/tests/aws/templates/logs_group.yml @@ -0,0 +1,11 @@ +Resources: + logGroup68A52FBE: + Type: AWS::Logs::LogGroup + Properties: + RetentionInDays: 731 + +Outputs: + LogGroupNameOutput: + Value: + Ref: logGroup68A52FBE + diff --git a/tests/aws/templates/logs_group_and_stream.yaml b/tests/aws/templates/logs_group_and_stream.yaml new file mode 100644 index 0000000000000..5b855facafa64 --- /dev/null +++ b/tests/aws/templates/logs_group_and_stream.yaml @@ -0,0 +1,21 @@ +Resources: + logGroup68A52FBE: + Type: AWS::Logs::LogGroup + Properties: + RetentionInDays: 731 + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + stream19075594: + Type: AWS::Logs::LogStream + Properties: + LogGroupName: + Ref: logGroup68A52FBE + UpdateReplacePolicy: Retain + DeletionPolicy: Retain +Outputs: + LogStreamNameOutput: + Value: + Ref: stream19075594 + LogGroupNameOutput: + Value: + Ref: logGroup68A52FBE diff --git a/tests/aws/templates/macro_resource.yml b/tests/aws/templates/macro_resource.yml new file mode 100644 index 0000000000000..5706b40989df7 --- /dev/null +++ b/tests/aws/templates/macro_resource.yml @@ -0,0 +1,20 @@ +Parameters: + FunctionName: + Type: String + MacroName: + Type: String + +Resources: + Macro: + Type: AWS::CloudFormation::Macro + Properties: + Name: + Ref: MacroName + FunctionName: + Ref: FunctionName + +Outputs: + MacroRef: + Value: + Ref: Macro + diff --git a/tests/aws/templates/macros/add_role.py b/tests/aws/templates/macros/add_role.py new file mode 100644 index 0000000000000..7453a6f6da5e4 --- /dev/null +++ b/tests/aws/templates/macros/add_role.py @@ -0,0 +1,31 @@ +import random + + +def handler(event, context): + fragment = add_role(event["fragment"]) + + return {"requestId": event["requestId"], "status": "success", "fragment": fragment} + + +def add_role(fragment): + role = {} + role["Type"] = "AWS::IAM::Role" + role["Properties"] = { + "AssumeRolePolicyDocument": { + "Statement": [ + {"Action": "sts:AssumeRole", "Effect": "Allow", "Principal": {"AWS": "*"}} + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + ["arn:", {"Ref": "AWS::Partition"}, ":iam::aws:policy/AdministratorAccess"], + ] + } + ], + "RoleName": f"role-{str(random.randrange(0, 1000))}", + } + fragment["Resources"]["Role"] = role + return fragment diff --git a/tests/aws/templates/macros/add_standard_attributes.py b/tests/aws/templates/macros/add_standard_attributes.py new file mode 100644 index 0000000000000..2b7045615e98a --- /dev/null +++ b/tests/aws/templates/macros/add_standard_attributes.py @@ -0,0 +1,11 @@ +def handler(event, context): + fragment = add_standard_attributes(event["fragment"]) + + return {"requestId": event["requestId"], "status": "success", "fragment": fragment} + + +def add_standard_attributes(fragment): + fragment["FifoTopic"] = True + fragment["ContentBasedDeduplication"] = True + + return fragment diff --git a/tests/aws/templates/macros/format_template.py b/tests/aws/templates/macros/format_template.py new file mode 100644 index 0000000000000..8809fa228f456 --- /dev/null +++ b/tests/aws/templates/macros/format_template.py @@ -0,0 +1,18 @@ +def handler(event, context): + parameters = event["templateParameterValues"] + fragment = walk(event["fragment"], parameters) + + resp = {"requestId": event["requestId"], "status": "success", "fragment": fragment} + + return resp + + +def walk(node, context): + if isinstance(node, dict): + return {k: walk(v, context) for k, v in node.items()} + elif isinstance(node, list): + return [walk(elem, context) for elem in node] + elif isinstance(node, str): + return node.format(**context) + else: + return node diff --git a/tests/aws/templates/macros/print_internals.py b/tests/aws/templates/macros/print_internals.py new file mode 100644 index 0000000000000..b17d4e96c6bd2 --- /dev/null +++ b/tests/aws/templates/macros/print_internals.py @@ -0,0 +1,18 @@ +import json + + +def handler(event, context): + fragment = event["fragment"] + + fragment["Resources"]["Parameter"]["Properties"]["Value"] = json.dumps( + { + "Event": event, + } + ) + + return { + "requestId": event["requestId"], + "status": "success", + "fragment": fragment, + "errorMessage": "test-error message", + } diff --git a/tests/aws/templates/macros/print_references.py b/tests/aws/templates/macros/print_references.py new file mode 100644 index 0000000000000..a578cfc6ad0d1 --- /dev/null +++ b/tests/aws/templates/macros/print_references.py @@ -0,0 +1,16 @@ +import json + + +def handler(event, context): + template = event["fragment"] + params = event["params"] + + template["Resources"]["Parameter"]["Properties"]["Value"] = json.dumps( + { + "Params": params, + "FunctionValue": template["Resources"]["Parameter2"]["Properties"]["Value"], + "ValueOfRef": template["Resources"]["Parameter"]["Properties"]["Value"], + } + ) + + return {"requestId": event["requestId"], "status": "success", "fragment": template} diff --git a/tests/aws/templates/macros/raise_error.py b/tests/aws/templates/macros/raise_error.py new file mode 100644 index 0000000000000..755c2e7506837 --- /dev/null +++ b/tests/aws/templates/macros/raise_error.py @@ -0,0 +1,2 @@ +def handler(event, context): + raise Exception("Exception raised") diff --git a/tests/aws/templates/macros/replace_string.py b/tests/aws/templates/macros/replace_string.py new file mode 100644 index 0000000000000..befb6db7ec178 --- /dev/null +++ b/tests/aws/templates/macros/replace_string.py @@ -0,0 +1,18 @@ +def handler(event, context): + parameters = event.get("params", {}) + fragment = walk(event["fragment"], parameters) + + resp = {"requestId": event["requestId"], "status": "success", "fragment": fragment} + + return resp + + +def walk(node, context): + if isinstance(node, dict): + return {k: walk(v, context) for k, v in node.items()} + elif isinstance(node, list): + return [walk(elem, context) for elem in node] + elif isinstance(node, str) and "" in node: + return node.replace("", f"{context.get('Input')} ") + else: + return node diff --git a/tests/aws/templates/macros/return_invalid_template.py b/tests/aws/templates/macros/return_invalid_template.py new file mode 100644 index 0000000000000..4db14cb423d75 --- /dev/null +++ b/tests/aws/templates/macros/return_invalid_template.py @@ -0,0 +1,3 @@ +def handler(event, context): + # anything else than success is considered failed + return {"requestId": event["requestId"], "status": "success", "fragment": "invalid"} diff --git a/tests/aws/templates/macros/return_random_string.py b/tests/aws/templates/macros/return_random_string.py new file mode 100644 index 0000000000000..b74030ecc0e06 --- /dev/null +++ b/tests/aws/templates/macros/return_random_string.py @@ -0,0 +1,14 @@ +import random +import string + + +def handler(event, context): + parameters = event["templateParameterValues"] + fragment = f"{parameters['Input']}-{random_string(5)}" + resp = {"requestId": event["requestId"], "status": "success", "fragment": fragment} + + return resp + + +def random_string(length): + return "".join(random.choice(string.ascii_lowercase) for i in range(length)) diff --git a/tests/aws/templates/macros/return_unsuccessful_with_message.py b/tests/aws/templates/macros/return_unsuccessful_with_message.py new file mode 100644 index 0000000000000..309e34b149f02 --- /dev/null +++ b/tests/aws/templates/macros/return_unsuccessful_with_message.py @@ -0,0 +1,10 @@ +def handler(event, context): + template = event["fragment"] + + # anything else than success is considered failed + return { + "requestId": event["requestId"], + "status": "failed", + "fragment": template, + "errorMessage": "failed because it is a test", + } diff --git a/tests/aws/templates/macros/return_unsuccessful_without_message.py b/tests/aws/templates/macros/return_unsuccessful_without_message.py new file mode 100644 index 0000000000000..aa702cbc000e5 --- /dev/null +++ b/tests/aws/templates/macros/return_unsuccessful_without_message.py @@ -0,0 +1,2 @@ +def handler(event, context): + return {"requestId": event["requestId"], "status": "failed", "fragment": event["fragment"]} diff --git a/tests/aws/templates/mappings/mapping-aws-ref-map-key.yaml b/tests/aws/templates/mappings/mapping-aws-ref-map-key.yaml new file mode 100644 index 0000000000000..8716cb22e5bb3 --- /dev/null +++ b/tests/aws/templates/mappings/mapping-aws-ref-map-key.yaml @@ -0,0 +1,35 @@ +Mappings: + {{StackName}}: + us-east-1: + {{StackName}}: "true" + us-east-2: + {{StackName}}: "true" + us-west-1: + {{StackName}}: "true" + us-west-2: + {{StackName}}: "true" + ap-southeast-2: + {{StackName}}: "true" + ap-northeast-1: + {{StackName}}: "true" + eu-central-1: + {{StackName}}: "true" + eu-west-1: + {{StackName}}: "true" + + +Conditions: + MyCondition: !Equals + - !FindInMap [ !Ref AWS::StackName, !Ref AWS::Region, !Ref AWS::StackName ] + - "true" + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Condition: MyCondition + + +Outputs: + TopicArn: + Value: !Ref MyTopic + diff --git a/tests/aws/templates/mappings/mapping-ref-map-key.yaml b/tests/aws/templates/mappings/mapping-ref-map-key.yaml new file mode 100644 index 0000000000000..fd3cc37601d47 --- /dev/null +++ b/tests/aws/templates/mappings/mapping-ref-map-key.yaml @@ -0,0 +1,36 @@ +Mappings: + MyMap: + A: + value: "true" + B: + value: "false" + +Conditions: + MyCondition: !Equals + - !FindInMap [ !Ref MapName, !Ref MapKey, value ] + - "true" + +Parameters: + MapName: + Type: String + + MapKey: + Type: String + + TopicName: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + Condition: MyCondition + + Dummy: + Type: AWS::SNS::Topic + +Outputs: + TopicArn: + Value: !Ref MyTopic + Condition: MyCondition diff --git a/tests/aws/templates/mappings/simple-mapping-invalid-ref.yaml b/tests/aws/templates/mappings/simple-mapping-invalid-ref.yaml new file mode 100644 index 0000000000000..00492fabf5097 --- /dev/null +++ b/tests/aws/templates/mappings/simple-mapping-invalid-ref.yaml @@ -0,0 +1,26 @@ +Parameters: + TopicName: + Type: String + + TopicNameSuffix: + Type: String + + TopicNameSuffixSelector: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: + "Fn::Join": + - "-" + - - !Ref TopicName + - !FindInMap [TopicSuffixMap, !Ref TopicNameSuffixSelector, Suffix] + +Mappings: + TopicSuffixMap: + A: + Suffix: !Ref TopicNameSuffix + B: + Suffix: suffix-b diff --git a/tests/aws/templates/mappings/simple-mapping-nesting-depth.yaml b/tests/aws/templates/mappings/simple-mapping-nesting-depth.yaml new file mode 100644 index 0000000000000..91f102ed10d86 --- /dev/null +++ b/tests/aws/templates/mappings/simple-mapping-nesting-depth.yaml @@ -0,0 +1,25 @@ +Parameters: + TopicName: + Type: String + + TopicNameSuffixSelector: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: + "Fn::Join": + - "-" + - - !Ref TopicName + - !FindInMap [TopicSuffixMap, !Ref TopicNameSuffixSelector, Suffix, SuffixValue] + +Mappings: + TopicSuffixMap: + A: + Suffix: + SuffixValue: suffix-a + B: + Suffix: + SuffixValue: suffix-b diff --git a/tests/aws/templates/mappings/simple-mapping-single-level.yaml b/tests/aws/templates/mappings/simple-mapping-single-level.yaml new file mode 100644 index 0000000000000..3ed167a3067b1 --- /dev/null +++ b/tests/aws/templates/mappings/simple-mapping-single-level.yaml @@ -0,0 +1,21 @@ +Parameters: + TopicName: + Type: String + + TopicNameSuffixSelector: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: + "Fn::Join": + - "-" + - - !Ref TopicName + - !FindInMap [TopicSuffixMap, !Ref TopicNameSuffixSelector] + +Mappings: + TopicSuffixMap: + A: suffix-a + B: suffix-b diff --git a/tests/aws/templates/mappings/simple-mapping.yaml b/tests/aws/templates/mappings/simple-mapping.yaml new file mode 100644 index 0000000000000..5d7694e7a5a8b --- /dev/null +++ b/tests/aws/templates/mappings/simple-mapping.yaml @@ -0,0 +1,27 @@ +Parameters: + TopicName: + Type: String + + TopicNameSuffixSelector: + Type: String + + TopicAttributeSelector: + Type: String + Default: Suffix + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: + "Fn::Join": + - "-" + - - !Ref TopicName + - !FindInMap [TopicSuffixMap, !Ref TopicNameSuffixSelector, !Ref TopicAttributeSelector] + +Mappings: + TopicSuffixMap: + A: + Suffix: suffix-a + B: + Suffix: suffix-b diff --git a/tests/aws/templates/multiple_bucket.yaml b/tests/aws/templates/multiple_bucket.yaml new file mode 100644 index 0000000000000..cbd5bbf18d3a2 --- /dev/null +++ b/tests/aws/templates/multiple_bucket.yaml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Parameters: + BucketName1: + Type: String + + BucketName2: + Type: String + +Resources: + Bucket1: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName1 + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + + Bucket2: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName2 + UpdateReplacePolicy: Delete + DeletionPolicy: Delete diff --git a/tests/aws/templates/multiple_bucket_update.yaml b/tests/aws/templates/multiple_bucket_update.yaml new file mode 100644 index 0000000000000..7ec9638c4612e --- /dev/null +++ b/tests/aws/templates/multiple_bucket_update.yaml @@ -0,0 +1,23 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Parameters: + Days: + Type: String + Default: '30' + +Resources: + Bucket1: + Type: AWS::S3::Bucket + + Bucket2: + Type: AWS::S3::Bucket + Properties: + VersioningConfiguration: + Status: 'Enabled' + LifecycleConfiguration: + Rules: + - Id: 'InvalidTransitionRule' + Status: 'Enabled' + Transitions: + - StorageClass: 'GLACIER' + TransitionInDays: !Ref Days diff --git a/tests/aws/templates/nested-stack-conditions.nested.yaml b/tests/aws/templates/nested-stack-conditions.nested.yaml new file mode 100644 index 0000000000000..8874c2d5bb93c --- /dev/null +++ b/tests/aws/templates/nested-stack-conditions.nested.yaml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + Mode: + Type: String + BucketBaseName: + Type: String +Conditions: + ProdMode: !Equals [ !Ref Mode, prod ] + TestMode: !Equals [ !Ref Mode, test ] +Resources: + TestS3: + Type: AWS::S3::Bucket + Condition: TestMode + Properties: + BucketName: + Fn::Join: + - "-" + - - !Ref BucketBaseName + - test + ProdS3: + Type: AWS::S3::Bucket + Condition: ProdMode + Properties: + BucketName: + Fn::Join: + - "-" + - - !Ref BucketBaseName + - prod +Outputs: + TestBucket: + Condition: TestMode + Value: !Ref TestS3 + ProdBucket: + Condition: ProdMode + Value: !Ref ProdS3 diff --git a/tests/aws/templates/nested-stack-conditions.yaml b/tests/aws/templates/nested-stack-conditions.yaml new file mode 100644 index 0000000000000..0ea21392853c6 --- /dev/null +++ b/tests/aws/templates/nested-stack-conditions.yaml @@ -0,0 +1,27 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + S3BucketPath: + Type: String + S3BucketName: + Type: String + +Resources: + Bucket: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: + Fn::Join: + - "" + - - https://s3. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - Ref: S3BucketPath + Parameters: + BucketBaseName: !Ref S3BucketName + Mode: prod +Outputs: + ProdBucket: + Value: !GetAtt Bucket.Outputs.ProdBucket + NestedStackArn: + Value: !Ref Bucket diff --git a/tests/aws/templates/nested-stack-output-refs.nested.yaml b/tests/aws/templates/nested-stack-output-refs.nested.yaml new file mode 100644 index 0000000000000..66062be9d18c3 --- /dev/null +++ b/tests/aws/templates/nested-stack-output-refs.nested.yaml @@ -0,0 +1,13 @@ +Parameters: + NestedBucketName: + Type: String +Resources: + CustomBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Ref: NestedBucketName +Outputs: + InnerCustomOutput: + Value: + Ref: CustomBucket diff --git a/tests/aws/templates/nested-stack-output-refs.yaml b/tests/aws/templates/nested-stack-output-refs.yaml new file mode 100644 index 0000000000000..219451cb788f2 --- /dev/null +++ b/tests/aws/templates/nested-stack-output-refs.yaml @@ -0,0 +1,28 @@ +Resources: + CustomNestedStack: + Type: AWS::CloudFormation::Stack + Properties: + Parameters: + NestedBucketName: {{ nested_bucket_name }} + TemplateURL: + Fn::Join: + - "" + - - https://s3. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - {{ s3_bucket_url }} + UpdateReplacePolicy: Delete + DeletionPolicy: Delete +Outputs: + CustomNestedStackId: + Value: + Ref: CustomNestedStack + CustomOutput: + Value: + Fn::Join: + - "-" + - - Fn::GetAtt: + - CustomNestedStack + - Outputs.InnerCustomOutput + - suffix diff --git a/tests/aws/templates/nested-stack-outputref/root.yaml b/tests/aws/templates/nested-stack-outputref/root.yaml new file mode 100644 index 0000000000000..2ab90e3dd9793 --- /dev/null +++ b/tests/aws/templates/nested-stack-outputref/root.yaml @@ -0,0 +1,28 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + Sub1TemplateUrl: + Type: String + Sub2TemplateUrl: + Type: String + TopicName: + Type: String + RoleName: + Type: String + +Resources: + Sub1: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: !Ref Sub1TemplateUrl + Parameters: + TopicName: !Ref TopicName + + Sub2: + Type: AWS::CloudFormation::Stack + DependsOn: + - Sub1 + Properties: + TemplateURL: !Ref Sub2TemplateUrl + Parameters: + TopicArn: !GetAtt Sub1.Outputs.OutputA + RoleName: !Ref RoleName diff --git a/tests/aws/templates/nested-stack-outputref/sub1.yaml b/tests/aws/templates/nested-stack-outputref/sub1.yaml new file mode 100644 index 0000000000000..e11109165eaad --- /dev/null +++ b/tests/aws/templates/nested-stack-outputref/sub1.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: 2010-09-09 + +Parameters: + TopicName: + Type: String + +Resources: + MyTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + +Outputs: + OutputA: + Value: !Ref MyTopic diff --git a/tests/aws/templates/nested-stack-outputref/sub2.yaml b/tests/aws/templates/nested-stack-outputref/sub2.yaml new file mode 100644 index 0000000000000..0016c4da00cee --- /dev/null +++ b/tests/aws/templates/nested-stack-outputref/sub2.yaml @@ -0,0 +1,30 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + TopicArn: + Type: String + RoleName: + Type: String + +Resources: + MyPolicy: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref RoleName + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: "Allow" + Principal: + Service: lambda.amazonaws.com + Action: + - "sts:AssumeRole" + Policies: + - PolicyName: PolicyA + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - sns:Publish + Resource: + - !Ref TopicArn diff --git a/tests/aws/templates/nested_child.yml b/tests/aws/templates/nested_child.yml new file mode 100644 index 0000000000000..2339f488299c5 --- /dev/null +++ b/tests/aws/templates/nested_child.yml @@ -0,0 +1,16 @@ +Parameters: + NameForBucketToCreate: + Type: String + +Resources: + Bucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref NameForBucketToCreate +# BucketName: "asrsdfasfd" + +Outputs: + BucketArn: + Value: !GetAtt [Bucket, Arn] + Export: + Name: NestedBucketArn diff --git a/tests/aws/templates/nested_child_ssm.yaml b/tests/aws/templates/nested_child_ssm.yaml new file mode 100644 index 0000000000000..dc95253bbe844 --- /dev/null +++ b/tests/aws/templates/nested_child_ssm.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + KeyValue: + Type: String +Resources: + Param: + Type: AWS::SSM::Parameter + Properties: + Name: child-param-name + Type: String + Value: !Ref KeyValue +Outputs: + Name: + Value: !Ref Param + Value: + Value: !GetAtt Param.Value diff --git a/tests/aws/templates/nested_grand_parent.yml b/tests/aws/templates/nested_grand_parent.yml new file mode 100644 index 0000000000000..8d1e0e8842256 --- /dev/null +++ b/tests/aws/templates/nested_grand_parent.yml @@ -0,0 +1,35 @@ +Parameters: + ChildStackURL: + Type: String + + BucketToCreate: + Type: String + + ParentStackURL: + Type: String + +Resources: + myImportingStack: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: !Ref ParentStackURL + Parameters: + ChildStackURL: !Ref ChildStackURL + BucketName: !Ref BucketToCreate + + parameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !GetAtt [myImportingStack, Outputs.arnOfImportedBucket] + + + DependsOn: + - myImportingStack + +Outputs: + parameterValue: + Value: + Fn::GetAtt: + - parameter + - Value \ No newline at end of file diff --git a/tests/aws/templates/nested_parent.yml b/tests/aws/templates/nested_parent.yml new file mode 100644 index 0000000000000..34031f1c75d3c --- /dev/null +++ b/tests/aws/templates/nested_parent.yml @@ -0,0 +1,29 @@ +Parameters: + ChildStackURL: + Type: String + BucketName: + Type: String + +Resources: + myStackWithParams: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: !Ref ChildStackURL + Parameters: + NameForBucketToCreate: !Ref BucketName + + + parameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !GetAtt [myStackWithParams, Outputs.BucketArn] + DependsOn: + - myStackWithParams + +Outputs: + arnOfImportedBucket: + Value: + Fn::GetAtt: + - parameter + - Value \ No newline at end of file diff --git a/tests/aws/templates/nested_parent_ssm.yaml b/tests/aws/templates/nested_parent_ssm.yaml new file mode 100644 index 0000000000000..b7eb3459a30bb --- /dev/null +++ b/tests/aws/templates/nested_parent_ssm.yaml @@ -0,0 +1,25 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + ChildStackURL: + Type: String + KeyValue: + Type: String +Resources: + ChildParam: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: !Ref ChildStackURL + Parameters: + KeyValue: !Ref KeyValue + Param: + DependsOn: ChildParam + Type: AWS::SSM::Parameter + Properties: + Name: test-param + Type: String + Value: !GetAtt ChildParam.Outputs.Value +Outputs: + Name: + Value: !Ref Param + Value: + Value: !GetAtt Param.Value diff --git a/tests/aws/templates/opensearch_domain.yaml b/tests/aws/templates/opensearch_domain.yaml new file mode 100644 index 0000000000000..a5d35b7274045 --- /dev/null +++ b/tests/aws/templates/opensearch_domain.yaml @@ -0,0 +1,10 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + OpenSearchDomainName: + Type: String +Resources: + OpenSearchServiceDomain: + Type: AWS::OpenSearchService::Domain + Properties: + DomainName: + Ref: OpenSearchDomainName \ No newline at end of file diff --git a/tests/aws/templates/opensearch_domain.yml b/tests/aws/templates/opensearch_domain.yml new file mode 100644 index 0000000000000..900cd25b61dac --- /dev/null +++ b/tests/aws/templates/opensearch_domain.yml @@ -0,0 +1,39 @@ +Resources: + Domain66AC69E0: + Type: "AWS::OpenSearchService::Domain" + Properties: + AdvancedOptions: + rest.action.multi.allow_explicit_index: "false" + ClusterConfig: + DedicatedMasterEnabled: false + InstanceCount: 1 + InstanceType: "r5.large.search" + ZoneAwarenessEnabled: false + DomainEndpointOptions: + EnforceHTTPS: false + TLSSecurityPolicy: "Policy-Min-TLS-1-0-2019-07" + EBSOptions: + EBSEnabled: true + Iops: 0 + VolumeSize: 10 + VolumeType: "gp2" + EncryptionAtRestOptions: + Enabled: false + EngineVersion: "OpenSearch_2.5" + LogPublishingOptions: {} + NodeToNodeEncryptionOptions: + Enabled: false + Tags: + - Key: foo + Value: bar + - Key: anotherkey + Value: hello + UpdateReplacePolicy: "Retain" + DeletionPolicy: "Delete" +Outputs: + SearchDomain: + Value: !Ref "Domain66AC69E0" + SearchDomainEndpoint: + Value: !GetAtt Domain66AC69E0.DomainEndpoint + SearchDomainArn: + Value: !GetAtt Domain66AC69E0.DomainArn diff --git a/tests/aws/templates/opensearch_domain_alternative_types.yml b/tests/aws/templates/opensearch_domain_alternative_types.yml new file mode 100644 index 0000000000000..f021f6965f831 --- /dev/null +++ b/tests/aws/templates/opensearch_domain_alternative_types.yml @@ -0,0 +1,37 @@ +# sample from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-opensearchservice-domain.html +Resources: + OpenSearchServiceDomain: + Type: AWS::OpenSearchService::Domain + Properties: + DomainName: 'test-opensearch-domain' + EngineVersion: 'OpenSearch_1.0' + ClusterConfig: + DedicatedMasterEnabled: true + InstanceCount: '2' + ZoneAwarenessEnabled: true + InstanceType: 't3.small.search' + DedicatedMasterType: 't3.small.search' + DedicatedMasterCount: '3' + EBSOptions: + EBSEnabled: true + Iops: '0' + VolumeSize: '20' + VolumeType: 'gp2' + AccessPolicies: + Version: '2012-10-17' + Statement: + - + Effect: 'Allow' + Principal: + AWS: + Fn::Sub: 'arn:aws:iam::${AWS::AccountId}:root' + Action: 'es:*' + Resource: + Fn::Sub: 'arn:aws:es:${AWS::Region}:${AWS::AccountId}:domain/test-opensearch-domain/*' + AdvancedOptions: + rest.action.multi.allow_explicit_index: 'true' + override_main_response_version: 'true' +Outputs: + SearchDomain: + Value: + Ref: OpenSearchServiceDomain diff --git a/tests/aws/templates/pyplate_deploy_template.yml b/tests/aws/templates/pyplate_deploy_template.yml new file mode 100644 index 0000000000000..092737b476c8b --- /dev/null +++ b/tests/aws/templates/pyplate_deploy_template.yml @@ -0,0 +1,112 @@ +AWSTemplateFormatVersion: "2010-09-09" + +Description: Macro allowing you to run arbitrary Python code in your CloudFormation templates + +Parameters: + LambdaTimeout: + Description: "Optional setting of the Lambda's execution timeout (in seconds). \nThe default of 3 seconds won't be enough if you call AWS services; \nthen at least 10 seconds is recommended, more depending on complexity.\n" + Type: Number + Default: 10 + MinValue: 3 + +Resources: + TransformExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + Path: / + Policies: + - PolicyName: root + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:* + Resource: arn:aws:logs:*:*:* + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/AdministratorAccess + + TransformFunction: + Type: AWS::Lambda::Function + Metadata: + guard: + SuppressedRules: + - LAMBDA_INSIDE_VPC + Properties: + Description: Support for the PyPlate CloudFormation macro + Code: + ZipFile: | + #pylint: disable=exec-used + + import traceback + import json + + def obj_iterate(obj, params): + "Iterate over template resources and execute any PyPlate directives" + if isinstance(obj, dict): + for k in obj: + obj[k] = obj_iterate(obj[k], params) + elif isinstance(obj, list): + for i, v in enumerate(obj): + obj[i] = obj_iterate(v, params) + elif isinstance(obj, str): + if obj.startswith("#!PyPlate"): + params["output"] = None + exec(obj, params) + obj = params["output"] + return obj + + def handler(event, _): + "Lambda handler" + print(json.dumps(event)) + + macro_response = {"requestId": event["requestId"], "status": "success"} + try: + params = { + "params": event["templateParameterValues"], + "template": event["fragment"], + "account_id": event["accountId"], + "region": event["region"], + } + response = event["fragment"] + macro_response["fragment"] = obj_iterate(response, params) + except Exception as e: + traceback.print_exc() + macro_response["status"] = "failure" + macro_response["errorMessage"] = str(e) + return macro_response + Handler: index.handler + Runtime: python3.11 + Role: !GetAtt TransformExecutionRole.Arn + Timeout: !Ref LambdaTimeout + + TransformFunctionPermissions: + Type: AWS::Lambda::Permission + Metadata: + guard: + SuppressedRules: + - LAMBDA_FUNCTION_PUBLIC_ACCESS_PROHIBITED + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt TransformFunction.Arn + Principal: cloudformation.amazonaws.com + + Transform: + Type: AWS::CloudFormation::Macro + Properties: + Name: !Sub PyPlate + Description: Processes inline python in templates + FunctionName: !GetAtt TransformFunction.Arn diff --git a/tests/aws/templates/pyplate_example.yml b/tests/aws/templates/pyplate_example.yml new file mode 100644 index 0000000000000..f35f344bb1a05 --- /dev/null +++ b/tests/aws/templates/pyplate_example.yml @@ -0,0 +1,26 @@ +AWSTemplateFormatVersion: "2010-09-09" + +Description: tests String macro functions + +Parameters: + Tags: + Type: CommaDelimitedList + +Transform: + - PyPlate + +Resources: + S3Bucket: + Type: "AWS::S3::Bucket" + Properties: + Tags: | + #!PyPlate + output = [] + for tag in params['Tags']: + key, value = tag.split('=') + output.append({"Key": key, "Value": value}) + +Outputs: + BucketName: + Value: + Ref: S3Bucket diff --git a/tests/aws/templates/registry/module.yml b/tests/aws/templates/registry/module.yml new file mode 100644 index 0000000000000..54e270a00eb05 --- /dev/null +++ b/tests/aws/templates/registry/module.yml @@ -0,0 +1,10 @@ +Parameters: + BucketName: + Type: String + +Resources: + BucketModule: + Type: LocalStack::Testing::TestModule::MODULE + Properties: + BucketName: + Ref: BucketName \ No newline at end of file diff --git a/tests/aws/templates/registry/resource-provider.yml b/tests/aws/templates/registry/resource-provider.yml new file mode 100644 index 0000000000000..eb674cf8547ab --- /dev/null +++ b/tests/aws/templates/registry/resource-provider.yml @@ -0,0 +1,10 @@ +Parameters: + Name: + Type: String + +Resources: + MyCustomResource: + Type: "LocalStack::Testing::DeployableResource" + Properties: + Name: + Ref: Name \ No newline at end of file diff --git a/tests/aws/templates/resolve_secretsmanager.yaml b/tests/aws/templates/resolve_secretsmanager.yaml new file mode 100644 index 0000000000000..366811037f999 --- /dev/null +++ b/tests/aws/templates/resolve_secretsmanager.yaml @@ -0,0 +1,15 @@ +Parameters: + DynamicParameter: + Type: String + +Resources: + topic69831491: + Type: AWS::SNS::Topic + Properties: + TopicName: !Join [ "", [ "{{resolve:secretsmanager:", !Ref DynamicParameter, "}}" ] ] +Outputs: + TopicName: + Value: + Fn::GetAtt: + - topic69831491 + - TopicName diff --git a/tests/aws/templates/resolve_secretsmanager_full.yaml b/tests/aws/templates/resolve_secretsmanager_full.yaml new file mode 100644 index 0000000000000..d717611140653 --- /dev/null +++ b/tests/aws/templates/resolve_secretsmanager_full.yaml @@ -0,0 +1,26 @@ +Parameters: + DynamicParameter: + Type: String + +Resources: + topic69831491: + Type: AWS::SNS::Topic + Properties: + TopicName: + Fn::Join: + - "" + - - "{{resolve:secretsmanager:arn:" + - Ref: AWS::Partition + - ":secretsmanager:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":secret:" + - Ref: DynamicParameter + - ":SecretString:::}}" +Outputs: + TopicName: + Value: + Fn::GetAtt: + - topic69831491 + - TopicName diff --git a/tests/aws/templates/resolve_secretsmanager_partial.yaml b/tests/aws/templates/resolve_secretsmanager_partial.yaml new file mode 100644 index 0000000000000..517c9dace918a --- /dev/null +++ b/tests/aws/templates/resolve_secretsmanager_partial.yaml @@ -0,0 +1,27 @@ +Parameters: + DynamicParameter: + Type: String + +Resources: + topic69831491: + Type: AWS::SNS::Topic + Properties: + TopicName: + Fn::Join: + - "" + - - "{{resolve:secretsmanager:arn:" + - Ref: AWS::Partition + - ":secretsmanager:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":secret:" + - Ref: DynamicParameter + - ":SecretString:}}" + +Outputs: + TopicName: + Value: + Fn::GetAtt: + - topic69831491 + - TopicName diff --git a/tests/aws/templates/resolve_ssm.yaml b/tests/aws/templates/resolve_ssm.yaml new file mode 100644 index 0000000000000..d67220bb00a3d --- /dev/null +++ b/tests/aws/templates/resolve_ssm.yaml @@ -0,0 +1,15 @@ +Parameters: + DynamicParameter: + Type: String + +Resources: + topic69831491: + Type: AWS::SNS::Topic + Properties: + TopicName: !Join [ "", [ "{{resolve:ssm:", !Ref DynamicParameter, "}}" ] ] +Outputs: + TopicName: + Value: + Fn::GetAtt: + - topic69831491 + - TopicName diff --git a/tests/aws/templates/resolve_ssm_secure.yaml b/tests/aws/templates/resolve_ssm_secure.yaml new file mode 100644 index 0000000000000..a22c007877d50 --- /dev/null +++ b/tests/aws/templates/resolve_ssm_secure.yaml @@ -0,0 +1,15 @@ +Parameters: + DynamicParameter: + Type: String + +Resources: + topic69831491: + Type: AWS::SNS::Topic + Properties: + TopicName: !Join [ "", [ "{{resolve:ssm-secure:", !Ref DynamicParameter, "}}" ] ] +Outputs: + TopicName: + Value: + Fn::GetAtt: + - topic69831491 + - TopicName diff --git a/tests/aws/templates/resource_group_defaults.yml b/tests/aws/templates/resource_group_defaults.yml new file mode 100644 index 0000000000000..7bd1b9e9c5763 --- /dev/null +++ b/tests/aws/templates/resource_group_defaults.yml @@ -0,0 +1,12 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + ApplicationResourceGroup: + Type: AWS::ResourceGroups::Group + Properties: + Name: testgroup + ResourceQuery: + Type: CLOUDFORMATION_STACK_1_0 + +Outputs: + ResourceGroup: + Value: !Ref ApplicationResourceGroup diff --git a/tests/aws/templates/route53_healthcheck.yml b/tests/aws/templates/route53_healthcheck.yml new file mode 100644 index 0000000000000..07c831b987785 --- /dev/null +++ b/tests/aws/templates/route53_healthcheck.yml @@ -0,0 +1,25 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Resources: + HealthCheck: + Type: 'AWS::Route53::HealthCheck' + Properties: + HealthCheckConfig: + IPAddress: 1.1.1.1 + Port: 80 + Type: HTTP + ResourcePath: '/health' + FullyQualifiedDomainName: localstacktest.com + RequestInterval: 30 + FailureThreshold: 3 + HealthCheckTags: + - + Key: SampleKey1 + Value: SampleValue1 + - + Key: SampleKey2 + Value: SampleValue2 + +Outputs: + HealthCheckId: + Value: !Ref HealthCheck \ No newline at end of file diff --git a/tests/aws/templates/route53_hostedzoneid_template.yaml b/tests/aws/templates/route53_hostedzoneid_template.yaml new file mode 100644 index 0000000000000..3efa28a1169c9 --- /dev/null +++ b/tests/aws/templates/route53_hostedzoneid_template.yaml @@ -0,0 +1,16 @@ +Parameters: + Name: + Type: String + HostedZoneId: + Type: String + +Resources: + myDNSWithNameRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref Name + ResourceRecords: + - 192.0.2.99 + TTL: "900" + Type: A diff --git a/tests/aws/templates/route53_hostedzonename_template.yaml b/tests/aws/templates/route53_hostedzonename_template.yaml new file mode 100644 index 0000000000000..a408d21bca850 --- /dev/null +++ b/tests/aws/templates/route53_hostedzonename_template.yaml @@ -0,0 +1,16 @@ +Parameters: + Name: + Type: String + HostedZoneName: + Type: String + +Resources: + myDNSWithNameRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneName: !Ref HostedZoneName + Name: !Ref Name + ResourceRecords: + - 192.0.2.99 + TTL: 900 + Type: A diff --git a/tests/aws/templates/route53_recordset_without_resource_records.yaml b/tests/aws/templates/route53_recordset_without_resource_records.yaml new file mode 100644 index 0000000000000..1b7268e2cc50a --- /dev/null +++ b/tests/aws/templates/route53_recordset_without_resource_records.yaml @@ -0,0 +1,16 @@ +Parameters: + Name: + Type: String + HostedZoneId: + Type: String + +Resources: + myDNSWithNameRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref Name + ResourceRecords: + - 192.0.2.99 + TTL: 900 + Type: A diff --git a/tests/aws/templates/s3_bucket_autoname.yaml b/tests/aws/templates/s3_bucket_autoname.yaml new file mode 100644 index 0000000000000..065b9e810cca6 --- /dev/null +++ b/tests/aws/templates/s3_bucket_autoname.yaml @@ -0,0 +1,9 @@ +Resources: + myb3B4550BC: + Type: AWS::S3::Bucket + UpdateReplacePolicy: Retain + DeletionPolicy: Retain +Outputs: + BucketNameOutput: + Value: + Ref: myb3B4550BC diff --git a/tests/aws/templates/s3_bucket_name.yml b/tests/aws/templates/s3_bucket_name.yml new file mode 100644 index 0000000000000..d12507e893009 --- /dev/null +++ b/tests/aws/templates/s3_bucket_name.yml @@ -0,0 +1,14 @@ +Parameters: + Name: + Type: String + +Resources: + myb3B4550BC: + Type: AWS::S3::Bucket + Properties: + BucketName: + Ref: Name +Outputs: + BucketNameOutput: + Value: + Ref: myb3B4550BC diff --git a/tests/aws/templates/s3_bucket_website_config.yaml b/tests/aws/templates/s3_bucket_website_config.yaml new file mode 100644 index 0000000000000..65c537facbd83 --- /dev/null +++ b/tests/aws/templates/s3_bucket_website_config.yaml @@ -0,0 +1,33 @@ +Parameters: + BucketName: + Type: String +Resources: + S3Bucket: + Type: 'AWS::S3::Bucket' + Properties: + BucketName: !Ref BucketName + # this config is required to run the sample with AWS + PublicAccessBlockConfiguration: + BlockPublicAcls: False + OwnershipControls: + Rules: + - ObjectOwnership: ObjectWriter + # website config is what we actually want to test + WebsiteConfiguration: + IndexDocument: index.html + ErrorDocument: error.html + RoutingRules: + - RoutingRuleCondition: + HttpErrorCodeReturnedEquals: '404' + KeyPrefixEquals: out1/ + RedirectRule: + ReplaceKeyWith: "redirected.html" +Outputs: + BucketNameOutput: + Value: + Ref: S3Bucket + WebsiteURL: + Value: !GetAtt + - S3Bucket + - WebsiteURL + Description: URL for website hosted on S3 diff --git a/tests/aws/templates/s3_bucketpolicy.yaml b/tests/aws/templates/s3_bucketpolicy.yaml new file mode 100644 index 0000000000000..403bea29cc0b1 --- /dev/null +++ b/tests/aws/templates/s3_bucketpolicy.yaml @@ -0,0 +1,39 @@ +Parameters: + BucketName: + Type: String +Resources: + bucket43879C71: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName + PublicAccessBlockConfiguration: + BlockPublicPolicy: False + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + {% if include_policy %} + bucketPolicy638F945D: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: bucket43879C71 + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Effect: Allow + Principal: + AWS: "*" + Resource: + - Fn::GetAtt: + - bucket43879C71 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - bucket43879C71 + - Arn + - /* + Version: "2012-10-17" + {% endif %} diff --git a/tests/aws/templates/s3_cors_bucket.yaml b/tests/aws/templates/s3_cors_bucket.yaml new file mode 100644 index 0000000000000..5e7a120ba676b --- /dev/null +++ b/tests/aws/templates/s3_cors_bucket.yaml @@ -0,0 +1,34 @@ +Resources: + LocalBucket: + Type: AWS::S3::Bucket + Properties: + CorsConfiguration: + CorsRules: + - AllowedHeaders: + - '*' + - x-amz-* + AllowedMethods: + - GET + AllowedOrigins: + - '*' + ExposedHeaders: + - Date + Id: "test-cors-id" + MaxAge: 3600 + + LocalBucket2: + Type: AWS::S3::Bucket + Properties: + CorsConfiguration: + CorsRules: + - AllowedMethods: + - GET + AllowedOrigins: + - '*' + + +Outputs: + BucketNameAllParameters: + Value: !Ref LocalBucket + BucketNameOnlyRequired: + Value: !Ref LocalBucket2 diff --git a/tests/aws/templates/s3_notification_sqs.yml b/tests/aws/templates/s3_notification_sqs.yml new file mode 100644 index 0000000000000..f0938bded10ba --- /dev/null +++ b/tests/aws/templates/s3_notification_sqs.yml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: 2010-09-09 +Resources: + TestQueue: + Type: AWS::SQS::Queue + Properties: + ReceiveMessageWaitTimeSeconds: 0 + VisibilityTimeout: 30 + MessageRetentionPeriod: 1209600 + + QueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref TestQueue + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: "*" + Action: sqs:SendMessage + Resource: !GetAtt TestQueue.Arn + + TestBucket: + Type: AWS::S3::Bucket + Properties: + NotificationConfiguration: + QueueConfigurations: + - Event: s3:ObjectCreated:* + Queue: !GetAtt TestQueue.Arn + +Outputs: + BucketName: + Value: !Ref TestBucket + QueueName: + Value: !Ref TestQueue diff --git a/tests/aws/templates/s3_object_lock_config.yaml b/tests/aws/templates/s3_object_lock_config.yaml new file mode 100644 index 0000000000000..8f8a48d287013 --- /dev/null +++ b/tests/aws/templates/s3_object_lock_config.yaml @@ -0,0 +1,22 @@ +Resources: + LocalBucket: + Type: AWS::S3::Bucket + Properties: + ObjectLockEnabled: true + ObjectLockConfiguration: + ObjectLockEnabled: "Enabled" + Rule: + DefaultRetention: + Mode: "GOVERNANCE" + Days: 2 + + LocalBucket2: + Type: AWS::S3::Bucket + Properties: + ObjectLockEnabled: true + +Outputs: + LockConfigAllParameters: + Value: !Ref LocalBucket + LockConfigOnlyRequired: + Value: !Ref LocalBucket2 diff --git a/tests/aws/templates/s3_versioned_bucket.yaml b/tests/aws/templates/s3_versioned_bucket.yaml new file mode 100644 index 0000000000000..7d6cc29acd0ca --- /dev/null +++ b/tests/aws/templates/s3_versioned_bucket.yaml @@ -0,0 +1,17 @@ +Resources: + Bucket83908E77: + Type: AWS::S3::Bucket + Properties: + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + VersioningConfiguration: + Status: Enabled + UpdateReplacePolicy: Delete + DeletionPolicy: Delete +Outputs: + BucketName: + Value: + Ref: Bucket83908E77 diff --git a/tests/aws/templates/sam_api.yml b/tests/aws/templates/sam_api.yml new file mode 100644 index 0000000000000..ef0b671165c94 --- /dev/null +++ b/tests/aws/templates/sam_api.yml @@ -0,0 +1,30 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template with a simple API definition +Resources: + Api: + Type: AWS::Serverless::Api + Properties: + StageName: prod + Lambda: + Type: AWS::Serverless::Function + Properties: + Events: + ApiEvent: + Type: Api + Properties: + Path: / + Method: get + RestApiId: + Ref: Api + Runtime: python3.11 + Handler: index.handler + InlineCode: | + def handler(event, context): + return {'body': 'Hello World!', 'statusCode': 200} + +Outputs: + ApiId: + Value: !Ref Api + LambdaFunction: + Value: !Ref Lambda diff --git a/tests/aws/templates/sam_function-policies.yaml b/tests/aws/templates/sam_function-policies.yaml new file mode 100644 index 0000000000000..beb503fe8017f --- /dev/null +++ b/tests/aws/templates/sam_function-policies.yaml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + InlineCode: |+ + import json + + def handler(event, context): + return { + "statusCode": 200, + "body": json.dumps({ + "message": "hello world", + }), + } + + Runtime: python3.9 + Handler: index.handler + Architectures: + - x86_64 + Policies: + - AmazonSNSFullAccess + Events: + HelloWorld: + Type: Api + Properties: + Path: /hello + Method: get +Outputs: + HelloWorldFunctionIamRoleArn: + Value: !GetAtt HelloWorldFunctionRole.Arn + HelloWorldFunctionIamRoleName: + Value: !Ref HelloWorldFunctionRole diff --git a/tests/aws/templates/sam_sqs_template.yml b/tests/aws/templates/sam_sqs_template.yml new file mode 100644 index 0000000000000..2c9985c9c5a03 --- /dev/null +++ b/tests/aws/templates/sam_sqs_template.yml @@ -0,0 +1,60 @@ +AWSTemplateFormatVersion: 2010-09-09 +Transform: AWS::Serverless-2016-10-31 + +Description: | + Example of SAM EventSourceMapping failure + +Globals: + Function: + Timeout: 10 + +Parameters: + ResultKey: + Type: String + +Resources: + Bucket: + Type: AWS::S3::Bucket + + Lambda: + Type: AWS::Serverless::Function + Properties: + AutoPublishAlias: live + InlineCode: | + import boto3 + import json + import os + def handler(event, context): + client = boto3.client("s3") + body = json.dumps(event) + client.put_object(Bucket=os.environ["BUCKET_NAME"], Key=os.environ["RESULT_KEY"], Body=body) + return {"event": event} + Handler: index.handler + Policies: + - S3CrudPolicy: + BucketName: !Ref Bucket + Environment: + Variables: + BUCKET_NAME: !Ref Bucket + RESULT_KEY: !Ref ResultKey + Runtime: python3.11 + Events: + SourceSQSEvent: + Type: SQS + Properties: + Queue: !GetAtt SQSQueue.Arn + BatchSize: 10 + MaximumBatchingWindowInSeconds: 10 + + SQSQueue: + Type: AWS::SQS::Queue + Properties: + MessageRetentionPeriod: 3600 + VisibilityTimeout: 60 + + +Outputs: + QueueUrl: + Value: !GetAtt SQSQueue.QueueUrl + BucketName: + Value: !Ref Bucket diff --git a/tests/aws/templates/secretsmanager_secret.yml b/tests/aws/templates/secretsmanager_secret.yml new file mode 100644 index 0000000000000..59436780d2959 --- /dev/null +++ b/tests/aws/templates/secretsmanager_secret.yml @@ -0,0 +1,21 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + SecretName: + Type: String + Description: The name of the secret + Default: "my-secret" +Resources: + Secret: + Type: 'AWS::SecretsManager::Secret' + Properties: + Description: Aurora Password + Name: !Ref SecretName + GenerateSecretString: + SecretStringTemplate: '{"username": "localstack-user"}' + GenerateStringKey: "password" + PasswordLength: 30 + IncludeSpace: false + ExcludePunctuation: true +Outputs: + SecretARN: + Value: !Ref Secret diff --git a/tests/aws/templates/secretsmanager_secret_policy.yml b/tests/aws/templates/secretsmanager_secret_policy.yml new file mode 100644 index 0000000000000..d54619fabafb6 --- /dev/null +++ b/tests/aws/templates/secretsmanager_secret_policy.yml @@ -0,0 +1,37 @@ +Parameters: + BlockPublicPolicy: + Type: String + AllowedValues: + - "true" + - "default" + +Conditions: + ShouldBlockPublicPolicy: + !Equals [!Ref BlockPublicPolicy, "true"] + +Resources: + MySecret: + Type: AWS::SecretsManager::Secret + Properties: + Description: This is a secret that I want to attach a resource-based policy to + MySecretResourcePolicy: + Type: AWS::SecretsManager::ResourcePolicy + Properties: + BlockPublicPolicy: !If [ShouldBlockPublicPolicy, True, !Ref AWS::NoValue] + SecretId: + Ref: MySecret + ResourcePolicy: + Version: '2012-10-17' + Statement: + - Resource: "*" + Action: secretsmanager:ReplicateSecretToRegions + Effect: Allow + Principal: + AWS: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:root +Outputs: + SecretId: + Value: !GetAtt MySecret.Id + + SecretPolicyArn: + Value: !Ref MySecretResourcePolicy diff --git a/tests/aws/templates/serverless-apigw-lambda.create.json b/tests/aws/templates/serverless-apigw-lambda.create.json new file mode 100644 index 0000000000000..be9e0ec62242d --- /dev/null +++ b/tests/aws/templates/serverless-apigw-lambda.create.json @@ -0,0 +1,82 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "The AWS CloudFormation template for this Serverless application", + "Resources": { + "ServerlessDeploymentBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + } + } + }, + "ServerlessDeploymentBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "ServerlessDeploymentBucket" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:*", + "Effect": "Deny", + "Principal": "*", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket" + }, + "/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket" + } + ] + ] + } + ], + "Condition": { + "Bool": { + "aws:SecureTransport": false + } + } + } + ] + } + } + } + }, + "Outputs": { + "ServerlessDeploymentBucketName": { + "Value": { + "Ref": "ServerlessDeploymentBucket" + } + } + } +} diff --git a/tests/aws/templates/serverless-apigw-lambda.update.json b/tests/aws/templates/serverless-apigw-lambda.update.json new file mode 100644 index 0000000000000..3ce6c832a4559 --- /dev/null +++ b/tests/aws/templates/serverless-apigw-lambda.update.json @@ -0,0 +1,392 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "The AWS CloudFormation template for this Serverless application", + "Resources": { + "ServerlessDeploymentBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + } + } + }, + "ServerlessDeploymentBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "ServerlessDeploymentBucket" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:*", + "Effect": "Deny", + "Principal": "*", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket" + }, + "/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket" + } + ] + ] + } + ], + "Condition": { + "Bool": { + "aws:SecureTransport": false + } + } + } + ] + } + } + }, + "ApiLogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": "/aws/lambda/test-service-local-api" + } + }, + "IamRoleLambdaExecution": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + } + ] + }, + "Policies": [ + { + "PolicyName": { + "Fn::Join": [ + "-", + [ + "test-service", + "local", + "lambda" + ] + ] + }, + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:TagResource" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/test-service-local*:*" + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "logs:PutLogEvents" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/test-service-local*:*:*" + } + ] + } + ] + } + } + ], + "Path": "/", + "RoleName": { + "Fn::Join": [ + "-", + [ + "test-service", + "local", + { + "Ref": "AWS::Region" + }, + "lambdaRole" + ] + ] + } + } + }, + "ApiLambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "ServerlessDeploymentBucket" + }, + "S3Key": "serverless/test-service/local/1708076358388-2024-02-16T09:39:18.388Z/api.zip" + }, + "Handler": "index.handler", + "Runtime": "python3.12", + "FunctionName": "test-service-local-api", + "MemorySize": 256, + "Timeout": 30, + "Role": { + "Fn::GetAtt": [ + "IamRoleLambdaExecution", + "Arn" + ] + } + }, + "DependsOn": [ + "ApiLogGroup" + ] + }, + "ApiGatewayRestApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "local-test-service", + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + }, + "Policy": "" + } + }, + "ApiGatewayResourceAnyVar": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId" + ] + }, + "PathPart": "{any+}", + "RestApiId": { + "Ref": "ApiGatewayRestApi" + } + } + }, + "ApiGatewayMethodAny": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "RequestParameters": {}, + "ResourceId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "ApiLambdaFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "MethodResponses": [] + }, + "DependsOn": [ + "ApiLambdaPermissionApiGateway" + ] + }, + "ApiGatewayMethodAnyVarAny": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "RequestParameters": {}, + "ResourceId": { + "Ref": "ApiGatewayResourceAnyVar" + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "ApiLambdaFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "MethodResponses": [] + }, + "DependsOn": [ + "ApiLambdaPermissionApiGateway" + ] + }, + "ApiGatewayDeployment1708076354025": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "StageName": "local" + }, + "DependsOn": [ + "ApiGatewayMethodAny", + "ApiGatewayMethodAnyVarAny" + ] + }, + "ApiLambdaPermissionApiGateway": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "FunctionName": { + "Fn::GetAtt": [ + "ApiLambdaFunction", + "Arn" + ] + }, + "Action": "lambda:InvokeFunction", + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "ApiGatewayRestApi" + }, + "/*/*" + ] + ] + } + } + } + }, + "Outputs": { + "ServerlessDeploymentBucketName": { + "Value": { + "Ref": "ServerlessDeploymentBucket" + }, + "Export": { + "Name": "sls-test-service-local-ServerlessDeploymentBucketName" + } + }, + "ServiceEndpoint": { + "Description": "URL of the service endpoint", + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "ApiGatewayRestApi" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/local" + ] + ] + }, + "Export": { + "Name": "sls-test-service-local-ServiceEndpoint" + } + } + } +} \ No newline at end of file diff --git a/tests/aws/templates/serverless-apigw-lambda.update2.json b/tests/aws/templates/serverless-apigw-lambda.update2.json new file mode 100644 index 0000000000000..f1fdb4ba11019 --- /dev/null +++ b/tests/aws/templates/serverless-apigw-lambda.update2.json @@ -0,0 +1,392 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "The AWS CloudFormation template for this Serverless application", + "Resources": { + "ServerlessDeploymentBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + } + } + }, + "ServerlessDeploymentBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "ServerlessDeploymentBucket" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:*", + "Effect": "Deny", + "Principal": "*", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket" + }, + "/*" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "ServerlessDeploymentBucket" + } + ] + ] + } + ], + "Condition": { + "Bool": { + "aws:SecureTransport": false + } + } + } + ] + } + } + }, + "ApiLogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": "/aws/lambda/test-service-local-api" + } + }, + "IamRoleLambdaExecution": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + } + ] + }, + "Policies": [ + { + "PolicyName": { + "Fn::Join": [ + "-", + [ + "test-service", + "local", + "lambda" + ] + ] + }, + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:TagResource" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/test-service-local*:*" + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "logs:PutLogEvents" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/test-service-local*:*:*" + } + ] + } + ] + } + } + ], + "Path": "/", + "RoleName": { + "Fn::Join": [ + "-", + [ + "test-service", + "local", + { + "Ref": "AWS::Region" + }, + "lambdaRole" + ] + ] + } + } + }, + "ApiLambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "ServerlessDeploymentBucket" + }, + "S3Key": "serverless/test-service/local/1708076568092-2024-02-16T09:42:48.092Z/api.zip" + }, + "Handler": "index.handler2", + "Runtime": "python3.12", + "FunctionName": "test-service-local-api", + "MemorySize": 256, + "Timeout": 30, + "Role": { + "Fn::GetAtt": [ + "IamRoleLambdaExecution", + "Arn" + ] + } + }, + "DependsOn": [ + "ApiLogGroup" + ] + }, + "ApiGatewayRestApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "local-test-service", + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + }, + "Policy": "" + } + }, + "ApiGatewayResourceAnyVar": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId" + ] + }, + "PathPart": "{any+}", + "RestApiId": { + "Ref": "ApiGatewayRestApi" + } + } + }, + "ApiGatewayMethodAny": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "RequestParameters": {}, + "ResourceId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "ApiLambdaFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "MethodResponses": [] + }, + "DependsOn": [ + "ApiLambdaPermissionApiGateway" + ] + }, + "ApiGatewayMethodAnyVarAny": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "RequestParameters": {}, + "ResourceId": { + "Ref": "ApiGatewayResourceAnyVar" + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "ApiLambdaFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "MethodResponses": [] + }, + "DependsOn": [ + "ApiLambdaPermissionApiGateway" + ] + }, + "ApiGatewayDeployment1708076563720": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "StageName": "local" + }, + "DependsOn": [ + "ApiGatewayMethodAny", + "ApiGatewayMethodAnyVarAny" + ] + }, + "ApiLambdaPermissionApiGateway": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "FunctionName": { + "Fn::GetAtt": [ + "ApiLambdaFunction", + "Arn" + ] + }, + "Action": "lambda:InvokeFunction", + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "ApiGatewayRestApi" + }, + "/*/*" + ] + ] + } + } + } + }, + "Outputs": { + "ServerlessDeploymentBucketName": { + "Value": { + "Ref": "ServerlessDeploymentBucket" + }, + "Export": { + "Name": "sls-test-service-local-ServerlessDeploymentBucketName" + } + }, + "ServiceEndpoint": { + "Description": "URL of the service endpoint", + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "ApiGatewayRestApi" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/local" + ] + ] + }, + "Export": { + "Name": "sls-test-service-local-ServiceEndpoint" + } + } + } +} \ No newline at end of file diff --git a/tests/aws/templates/sfn_apigateway.yaml b/tests/aws/templates/sfn_apigateway.yaml new file mode 100644 index 0000000000000..70c1683107035 --- /dev/null +++ b/tests/aws/templates/sfn_apigateway.yaml @@ -0,0 +1,375 @@ +Resources: + LsFnServiceRoleFE24FAB1: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + LsFnB43B12A0: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: |- + exports.handler = async (event, ctx) => { + console.log(event); + return { + statusCode: 200, + body: "hello from stepfunctions" + }; + }; + Role: + Fn::GetAtt: + - LsFnServiceRoleFE24FAB1 + - Arn + Handler: index.handler + Runtime: nodejs14.x + DependsOn: + - LsFnServiceRoleFE24FAB1 + LsApi42D61DD0: + Type: AWS::ApiGateway::RestApi + LsApiCloudWatchRoleC538B6E8: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + LsApiAccountC4165108: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: + Fn::GetAtt: + - LsApiCloudWatchRoleC538B6E8 + - Arn + DependsOn: + - LsApi42D61DD0 + LsApiDeployment91771F3772cb22de3018868307c5d4c1339b075a: + Type: AWS::ApiGateway::Deployment + Properties: + RestApiId: + Ref: LsApi42D61DD0 + Description: Automatically created by the RestApi construct + DependsOn: + - LsApiproxyANYB17F6382 + - LsApiproxyE04C65C3 + - LsApiANY7BA2F440 + LsApiDeploymentStageprod82D8C5E7: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: + Ref: LsApi42D61DD0 + DeploymentId: + Ref: LsApiDeployment91771F3772cb22de3018868307c5d4c1339b075a + StageName: prod + DependsOn: + - LsApiAccountC4165108 + LsApiproxyE04C65C3: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: + Fn::GetAtt: + - LsApi42D61DD0 + - RootResourceId + PathPart: "{proxy+}" + RestApiId: + Ref: LsApi42D61DD0 + LsApiproxyANYApiPermissionStepfunctionsGatewayStackLsApiF631C9DDANYproxyDBAA49CB: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFnB43B12A0 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - /*/* + LsApiproxyANYApiPermissionTestStepfunctionsGatewayStackLsApiF631C9DDANYproxyCD0CCEC1: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFnB43B12A0 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - /test-invoke-stage/*/* + LsApiproxyANYB17F6382: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: ANY + ResourceId: + Ref: LsApiproxyE04C65C3 + RestApiId: + Ref: LsApi42D61DD0 + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - LsFnB43B12A0 + - Arn + - /invocations + LsApiANYApiPermissionStepfunctionsGatewayStackLsApiF631C9DDANY529C16EB: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFnB43B12A0 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - /*/ + LsApiANYApiPermissionTestStepfunctionsGatewayStackLsApiF631C9DDANY5CD1E87C: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFnB43B12A0 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - /test-invoke-stage/*/ + LsApiANY7BA2F440: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: ANY + ResourceId: + Fn::GetAtt: + - LsApi42D61DD0 + - RootResourceId + RestApiId: + Ref: LsApi42D61DD0 + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - LsFnB43B12A0 + - Arn + - /invocations + LsStateMachineRole0FA72689: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: + Fn::FindInMap: + - ServiceprincipalMap + - Ref: AWS::Region + - states + Version: "2012-10-17" + LsStateMachineRoleDefaultPolicyF0A7F6AB: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: execute-api:Invoke + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - /GET/* + Version: "2012-10-17" + PolicyName: LsStateMachineRoleDefaultPolicyF0A7F6AB + Roles: + - Ref: LsStateMachineRole0FA72689 + LsStateMachineC3258D1E: + Type: AWS::StepFunctions::StateMachine + Properties: + RoleArn: + Fn::GetAtt: + - LsStateMachineRole0FA72689 + - Arn + DefinitionString: + Fn::Join: + - "" + - - '{"StartAt":"LsCallApi","States":{"LsCallApi":{"End":true,"Type":"Task","Resource":"arn:' + - Ref: AWS::Partition + - :states:::apigateway:invoke","Parameters":{"ApiEndpoint":" + - Ref: LsApi42D61DD0 + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - '","Method":"GET","Stage":"' + - Ref: LsApiDeploymentStageprod82D8C5E7 + - '","AuthType":"NO_AUTH"}}}}' + StateMachineType: STANDARD + DependsOn: + - LsStateMachineRoleDefaultPolicyF0A7F6AB + - LsStateMachineRole0FA72689 +Outputs: + LsApiEndpointA06D37E8: + Value: + Fn::Join: + - "" + - - https:// + - Ref: LsApi42D61DD0 + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - / + statemachineOutput: + Value: + Ref: LsStateMachineC3258D1E +Mappings: + ServiceprincipalMap: + af-south-1: + states: states.af-south-1.amazonaws.com + ap-east-1: + states: states.ap-east-1.amazonaws.com + ap-northeast-1: + states: states.ap-northeast-1.amazonaws.com + ap-northeast-2: + states: states.ap-northeast-2.amazonaws.com + ap-northeast-3: + states: states.ap-northeast-3.amazonaws.com + ap-south-1: + states: states.ap-south-1.amazonaws.com + ap-southeast-1: + states: states.ap-southeast-1.amazonaws.com + ap-southeast-2: + states: states.ap-southeast-2.amazonaws.com + ap-southeast-3: + states: states.ap-southeast-3.amazonaws.com + ca-central-1: + states: states.ca-central-1.amazonaws.com + cn-north-1: + states: states.cn-north-1.amazonaws.com + cn-northwest-1: + states: states.cn-northwest-1.amazonaws.com + eu-central-1: + states: states.eu-central-1.amazonaws.com + eu-north-1: + states: states.eu-north-1.amazonaws.com + eu-south-1: + states: states.eu-south-1.amazonaws.com + eu-south-2: + states: states.eu-south-2.amazonaws.com + eu-west-1: + states: states.eu-west-1.amazonaws.com + eu-west-2: + states: states.eu-west-2.amazonaws.com + eu-west-3: + states: states.eu-west-3.amazonaws.com + me-south-1: + states: states.me-south-1.amazonaws.com + sa-east-1: + states: states.sa-east-1.amazonaws.com + us-east-1: + states: states.us-east-1.amazonaws.com + us-east-2: + states: states.us-east-2.amazonaws.com + us-gov-east-1: + states: states.us-gov-east-1.amazonaws.com + us-gov-west-1: + states: states.us-gov-west-1.amazonaws.com + us-iso-east-1: + states: states.amazonaws.com + us-iso-west-1: + states: states.amazonaws.com + us-isob-east-1: + states: states.amazonaws.com + us-west-1: + states: states.us-west-1.amazonaws.com + us-west-2: + states: states.us-west-2.amazonaws.com diff --git a/tests/aws/templates/sfn_apigateway_two_integrations.yaml b/tests/aws/templates/sfn_apigateway_two_integrations.yaml new file mode 100644 index 0000000000000..687cfd422344e --- /dev/null +++ b/tests/aws/templates/sfn_apigateway_two_integrations.yaml @@ -0,0 +1,413 @@ +Resources: + LsFnServiceRoleFE24FAB1: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + LsFnB43B12A0: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: |- + exports.handler = async (event, ctx) => { + console.log(event); + return { + statusCode: 200, + body: JSON.stringify("hello from stepfunctions") + }; + }; + Role: + Fn::GetAtt: + - LsFnServiceRoleFE24FAB1 + - Arn + Handler: index.handler + Runtime: nodejs14.x + DependsOn: + - LsFnServiceRoleFE24FAB1 + LsFn2ServiceRoleF6685547: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + LsFn2CFAB3A95: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: |- + exports.handler = async (event, ctx) => { + console.log(event); + return { + statusCode: 200, + body: JSON.stringify("hello_with_path from stepfunctions") + }; + }; + Role: + Fn::GetAtt: + - LsFn2ServiceRoleF6685547 + - Arn + Handler: index.handler + Runtime: nodejs14.x + DependsOn: + - LsFn2ServiceRoleF6685547 + LsApi42D61DD0: + Type: AWS::ApiGateway::RestApi + Properties: + Name: LsApi + LsApiCloudWatchRoleC538B6E8: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + LsApiAccountC4165108: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: + Fn::GetAtt: + - LsApiCloudWatchRoleC538B6E8 + - Arn + DependsOn: + - LsApi42D61DD0 + LsApiDeployment91771F3725db7d8c256259a6ceeec74dbae61212: + Type: AWS::ApiGateway::Deployment + Properties: + RestApiId: + Ref: LsApi42D61DD0 + Description: Automatically created by the RestApi construct + DependsOn: + - LsApiGET7AECB186 + - LsApitestsfnGETE99F6C0F + - LsApitestsfnE3C7A38F + LsApiDeploymentStageprod82D8C5E7: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: + Ref: LsApi42D61DD0 + DeploymentId: + Ref: LsApiDeployment91771F3725db7d8c256259a6ceeec74dbae61212 + StageName: prod + DependsOn: + - LsApiAccountC4165108 + LsApiGETApiPermissionStepfunctionsGatewayStackLsApiF631C9DDGETF2B3AE22: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFnB43B12A0 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - /GET/ + LsApiGETApiPermissionTestStepfunctionsGatewayStackLsApiF631C9DDGETBA080860: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFnB43B12A0 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - /test-invoke-stage/GET/ + LsApiGET7AECB186: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: GET + ResourceId: + Fn::GetAtt: + - LsApi42D61DD0 + - RootResourceId + RestApiId: + Ref: LsApi42D61DD0 + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - LsFnB43B12A0 + - Arn + - /invocations + LsApitestsfnE3C7A38F: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: + Fn::GetAtt: + - LsApi42D61DD0 + - RootResourceId + PathPart: test-sfn + RestApiId: + Ref: LsApi42D61DD0 + LsApitestsfnGETApiPermissionStepfunctionsGatewayStackLsApiF631C9DDGETtestsfn8424795A: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFn2CFAB3A95 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - /GET/test-sfn + LsApitestsfnGETApiPermissionTestStepfunctionsGatewayStackLsApiF631C9DDGETtestsfn5FA753F2: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: + Fn::GetAtt: + - LsFn2CFAB3A95 + - Arn + Principal: apigateway.amazonaws.com + SourceArn: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - /test-invoke-stage/GET/test-sfn + LsApitestsfnGETE99F6C0F: + Type: AWS::ApiGateway::Method + Properties: + HttpMethod: GET + ResourceId: + Ref: LsApitestsfnE3C7A38F + RestApiId: + Ref: LsApi42D61DD0 + AuthorizationType: NONE + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":apigateway:" + - Ref: AWS::Region + - :lambda:path/2015-03-31/functions/ + - Fn::GetAtt: + - LsFn2CFAB3A95 + - Arn + - /invocations + LsStateMachineRole0FA72689: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: + Fn::FindInMap: + - ServiceprincipalMap + - Ref: AWS::Region + - states + Version: "2012-10-17" + LsStateMachineRoleDefaultPolicyF0A7F6AB: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: execute-api:Invoke + Effect: Allow + Resource: + Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - ":execute-api:" + - Ref: AWS::Region + - ":" + - Ref: AWS::AccountId + - ":" + - Ref: LsApi42D61DD0 + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - /GET/test-sfn + Version: "2012-10-17" + PolicyName: LsStateMachineRoleDefaultPolicyF0A7F6AB + Roles: + - Ref: LsStateMachineRole0FA72689 + LsStateMachineC3258D1E: + Type: AWS::StepFunctions::StateMachine + Properties: + RoleArn: + Fn::GetAtt: + - LsStateMachineRole0FA72689 + - Arn + DefinitionString: + Fn::Join: + - "" + - - '{"StartAt":"LsCallApi","States":{"LsCallApi":{"End":true,"Type":"Task","Resource":"arn:' + - Ref: AWS::Partition + - :states:::apigateway:invoke","Parameters":{"ApiEndpoint":" + - Ref: LsApi42D61DD0 + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - '","Method":"GET","Stage":"' + - Ref: LsApiDeploymentStageprod82D8C5E7 + - '","Path":"/test-sfn","AuthType":"NO_AUTH"}}}}' + StateMachineType: STANDARD + DependsOn: + - LsStateMachineRoleDefaultPolicyF0A7F6AB + - LsStateMachineRole0FA72689 +Outputs: + LsApiEndpointA06D37E8: + Value: + Fn::Join: + - "" + - - https:// + - Ref: LsApi42D61DD0 + - .execute-api. + - Ref: AWS::Region + - "." + - Ref: AWS::URLSuffix + - / + - Ref: LsApiDeploymentStageprod82D8C5E7 + - / + statemachineOutput: + Value: + Ref: LsStateMachineC3258D1E +Mappings: + ServiceprincipalMap: + af-south-1: + states: states.af-south-1.amazonaws.com + ap-east-1: + states: states.ap-east-1.amazonaws.com + ap-northeast-1: + states: states.ap-northeast-1.amazonaws.com + ap-northeast-2: + states: states.ap-northeast-2.amazonaws.com + ap-northeast-3: + states: states.ap-northeast-3.amazonaws.com + ap-south-1: + states: states.ap-south-1.amazonaws.com + ap-southeast-1: + states: states.ap-southeast-1.amazonaws.com + ap-southeast-2: + states: states.ap-southeast-2.amazonaws.com + ap-southeast-3: + states: states.ap-southeast-3.amazonaws.com + ca-central-1: + states: states.ca-central-1.amazonaws.com + cn-north-1: + states: states.cn-north-1.amazonaws.com + cn-northwest-1: + states: states.cn-northwest-1.amazonaws.com + eu-central-1: + states: states.eu-central-1.amazonaws.com + eu-north-1: + states: states.eu-north-1.amazonaws.com + eu-south-1: + states: states.eu-south-1.amazonaws.com + eu-south-2: + states: states.eu-south-2.amazonaws.com + eu-west-1: + states: states.eu-west-1.amazonaws.com + eu-west-2: + states: states.eu-west-2.amazonaws.com + eu-west-3: + states: states.eu-west-3.amazonaws.com + me-south-1: + states: states.me-south-1.amazonaws.com + sa-east-1: + states: states.sa-east-1.amazonaws.com + us-east-1: + states: states.us-east-1.amazonaws.com + us-east-2: + states: states.us-east-2.amazonaws.com + us-gov-east-1: + states: states.us-gov-east-1.amazonaws.com + us-gov-west-1: + states: states.us-gov-west-1.amazonaws.com + us-iso-east-1: + states: states.amazonaws.com + us-iso-west-1: + states: states.amazonaws.com + us-isob-east-1: + states: states.amazonaws.com + us-west-1: + states: states.us-west-1.amazonaws.com + us-west-2: + states: states.us-west-2.amazonaws.com diff --git a/tests/aws/templates/sfn_nested_sync2.json b/tests/aws/templates/sfn_nested_sync2.json new file mode 100644 index 0000000000000..358a427ecedbc --- /dev/null +++ b/tests/aws/templates/sfn_nested_sync2.json @@ -0,0 +1,453 @@ +{ + "Resources": { + "fnServiceRole5D180AFD": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "fn5FF616E3": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\ndef handler(event,context):\n print(event)\n return {\"Value\": event[\"Value\"] + 1}\n" + }, + "Role": { + "Fn::GetAtt": [ + "fnServiceRole5D180AFD", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "python3.8" + }, + "DependsOn": [ + "fnServiceRole5D180AFD" + ] + }, + "incrementFnServiceRole8BFC8AB9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "incrementFn82FDC2D5": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "\ndef handler(event,context):\n print(event)\n return {\"Value\": event[\"Value\"] + 1}\n" + }, + "Role": { + "Fn::GetAtt": [ + "incrementFnServiceRole8BFC8AB9", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "python3.8" + }, + "DependsOn": [ + "incrementFnServiceRole8BFC8AB9" + ] + }, + "childMachineRole46BED827": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::FindInMap": [ + "ServiceprincipalMap", + { + "Ref": "AWS::Region" + }, + "states" + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "childMachineRoleDefaultPolicyF9E7D578": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "incrementFn82FDC2D5", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "childMachineRoleDefaultPolicyF9E7D578", + "Roles": [ + { + "Ref": "childMachineRole46BED827" + } + ] + } + }, + "childMachineC2BF6790": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "RoleArn": { + "Fn::GetAtt": [ + "childMachineRole46BED827", + "Arn" + ] + }, + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"invokeIncr\",\"States\":{\"invokeIncr\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"", + { + "Fn::GetAtt": [ + "incrementFn82FDC2D5", + "Arn" + ] + }, + "\"}}}" + ] + ] + }, + "StateMachineType": "STANDARD" + }, + "DependsOn": [ + "childMachineRoleDefaultPolicyF9E7D578", + "childMachineRole46BED827" + ] + }, + "parentMachineRole149F9B3F": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::FindInMap": [ + "ServiceprincipalMap", + { + "Ref": "AWS::Region" + }, + "states" + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "parentMachineRoleDefaultPolicy582F5496": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "states:StartExecution", + "Effect": "Allow", + "Resource": { + "Ref": "childMachineC2BF6790" + } + }, + { + "Action": [ + "states:DescribeExecution", + "states:StopExecution" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":states:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":execution:", + { + "Fn::Select": [ + 6, + { + "Fn::Split": [ + ":", + { + "Ref": "childMachineC2BF6790" + } + ] + } + ] + }, + "*" + ] + ] + } + }, + { + "Action": [ + "events:PutTargets", + "events:PutRule", + "events:DescribeRule" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":events:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":rule/StepFunctionsGetEventsForStepFunctionsExecutionRule" + ] + ] + } + }, + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "fn5FF616E3", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "parentMachineRoleDefaultPolicy582F5496", + "Roles": [ + { + "Ref": "parentMachineRole149F9B3F" + } + ] + } + }, + "parentMachineFFF9E148": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "RoleArn": { + "Fn::GetAtt": [ + "parentMachineRole149F9B3F", + "Arn" + ] + }, + "DefinitionString": { + "Fn::Join": [ + "", + [ + "{\"StartAt\":\"nestedInvoke\",\"States\":{\"nestedInvoke\":{\"Next\":\"logTask\",\"Type\":\"Task\",\"Resource\":\"arn:", + { + "Ref": "AWS::Partition" + }, + ":states:::states:startExecution.sync:2\",\"Parameters\":{\"Input.$\":\"$\",\"StateMachineArn\":\"", + { + "Ref": "childMachineC2BF6790" + }, + "\"}},\"logTask\":{\"End\":true,\"Type\":\"Task\",\"InputPath\":\"$.Output\",\"Resource\":\"", + { + "Fn::GetAtt": [ + "fn5FF616E3", + "Arn" + ] + }, + "\"}}}" + ] + ] + }, + "StateMachineType": "STANDARD" + }, + "DependsOn": [ + "parentMachineRoleDefaultPolicy582F5496", + "parentMachineRole149F9B3F" + ] + } + }, + "Outputs": { + "ParentStateMachineArnOutput": { + "Value": { + "Ref": "parentMachineFFF9E148" + } + } + }, + "Mappings": { + "ServiceprincipalMap": { + "af-south-1": { + "states": "states.af-south-1.amazonaws.com" + }, + "ap-east-1": { + "states": "states.ap-east-1.amazonaws.com" + }, + "ap-northeast-1": { + "states": "states.ap-northeast-1.amazonaws.com" + }, + "ap-northeast-2": { + "states": "states.ap-northeast-2.amazonaws.com" + }, + "ap-northeast-3": { + "states": "states.ap-northeast-3.amazonaws.com" + }, + "ap-south-1": { + "states": "states.ap-south-1.amazonaws.com" + }, + "ap-southeast-1": { + "states": "states.ap-southeast-1.amazonaws.com" + }, + "ap-southeast-2": { + "states": "states.ap-southeast-2.amazonaws.com" + }, + "ap-southeast-3": { + "states": "states.ap-southeast-3.amazonaws.com" + }, + "ca-central-1": { + "states": "states.ca-central-1.amazonaws.com" + }, + "cn-north-1": { + "states": "states.cn-north-1.amazonaws.com" + }, + "cn-northwest-1": { + "states": "states.cn-northwest-1.amazonaws.com" + }, + "eu-central-1": { + "states": "states.eu-central-1.amazonaws.com" + }, + "eu-north-1": { + "states": "states.eu-north-1.amazonaws.com" + }, + "eu-south-1": { + "states": "states.eu-south-1.amazonaws.com" + }, + "eu-south-2": { + "states": "states.eu-south-2.amazonaws.com" + }, + "eu-west-1": { + "states": "states.eu-west-1.amazonaws.com" + }, + "eu-west-2": { + "states": "states.eu-west-2.amazonaws.com" + }, + "eu-west-3": { + "states": "states.eu-west-3.amazonaws.com" + }, + "me-south-1": { + "states": "states.me-south-1.amazonaws.com" + }, + "sa-east-1": { + "states": "states.sa-east-1.amazonaws.com" + }, + "us-east-1": { + "states": "states.us-east-1.amazonaws.com" + }, + "us-east-2": { + "states": "states.us-east-2.amazonaws.com" + }, + "us-gov-east-1": { + "states": "states.us-gov-east-1.amazonaws.com" + }, + "us-gov-west-1": { + "states": "states.us-gov-west-1.amazonaws.com" + }, + "us-iso-east-1": { + "states": "states.amazonaws.com" + }, + "us-iso-west-1": { + "states": "states.amazonaws.com" + }, + "us-isob-east-1": { + "states": "states.amazonaws.com" + }, + "us-west-1": { + "states": "states.us-west-1.amazonaws.com" + }, + "us-west-2": { + "states": "states.us-west-2.amazonaws.com" + } + } + } +} diff --git a/tests/aws/templates/sfn_retry_catch.yaml b/tests/aws/templates/sfn_retry_catch.yaml new file mode 100644 index 0000000000000..fcc475fe9dee8 --- /dev/null +++ b/tests/aws/templates/sfn_retry_catch.yaml @@ -0,0 +1,184 @@ +Resources: + fnServiceRole5D180AFD: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + fn5FF616E3: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: |+ + + def handler(event, ctx): + raise Exception("test") + + Role: + Fn::GetAtt: + - fnServiceRole5D180AFD + - Arn + Handler: index.handler + Runtime: python3.9 + DependsOn: + - fnServiceRole5D180AFD + queue276F7297: + Type: AWS::SQS::Queue + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + statemRoleBB8FDC89: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: + Fn::FindInMap: + - ServiceprincipalMap + - Ref: AWS::Region + - states + Version: "2012-10-17" + statemRoleDefaultPolicy3451B92B: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: lambda:InvokeFunction + Effect: Allow + Resource: + - Fn::GetAtt: + - fn5FF616E3 + - Arn + - Fn::Join: + - "" + - - Fn::GetAtt: + - fn5FF616E3 + - Arn + - :* + - Action: sqs:SendMessage + Effect: Allow + Resource: + Fn::GetAtt: + - queue276F7297 + - Arn + Version: "2012-10-17" + PolicyName: statemRoleDefaultPolicy3451B92B + Roles: + - Ref: statemRoleBB8FDC89 + statem555CAA27: + Type: AWS::StepFunctions::StateMachine + Properties: + RoleArn: + Fn::GetAtt: + - statemRoleBB8FDC89 + - Arn + DefinitionString: + Fn::Join: + - "" + - - '{"StartAt":"InvokeFunction","States":{"InvokeFunction":{"Next":"SendSuccess","Retry":[{"ErrorEquals":["Lambda.ServiceException","Lambda.AWSLambdaException","Lambda.SdkClientException"],"IntervalSeconds":2,"MaxAttempts":6,"BackoffRate":2},{"ErrorEquals":["Exception"],"IntervalSeconds":1,"MaxAttempts":3,"BackoffRate":2}],"Catch":[{"ErrorEquals":["States.ALL"],"Next":"SendToDLQ"}],"Type":"Task","Resource":"arn:' + - Ref: AWS::Partition + - :states:::lambda:invoke","Parameters":{"FunctionName":" + - Fn::GetAtt: + - fn5FF616E3 + - Arn + - '","Payload.$":"$"}},"SendSuccess":{"End":true,"Type":"Task","Resource":"arn:' + - Ref: AWS::Partition + - :states:::sqs:sendMessage","Parameters":{"QueueUrl":" + - Ref: queue276F7297 + - '","MessageBody":"Success"}},"SendToDLQ":{"End":true,"Type":"Task","Resource":"arn:' + - Ref: AWS::Partition + - :states:::sqs:sendMessage","Parameters":{"QueueUrl":" + - Ref: queue276F7297 + - '","MessageBody":"Fail"}}}}' + DependsOn: + - statemRoleDefaultPolicy3451B92B + - statemRoleBB8FDC89 +Outputs: + smArnOutput: + Value: + Ref: statem555CAA27 + smNameOutput: + Value: + Fn::GetAtt: + - statem555CAA27 + - Name + fnNameOutput: + Value: + Ref: fn5FF616E3 + queueUrlOutput: + Value: + Ref: queue276F7297 +Mappings: + ServiceprincipalMap: + af-south-1: + states: states.af-south-1.amazonaws.com + ap-east-1: + states: states.ap-east-1.amazonaws.com + ap-northeast-1: + states: states.ap-northeast-1.amazonaws.com + ap-northeast-2: + states: states.ap-northeast-2.amazonaws.com + ap-northeast-3: + states: states.ap-northeast-3.amazonaws.com + ap-south-1: + states: states.ap-south-1.amazonaws.com + ap-southeast-1: + states: states.ap-southeast-1.amazonaws.com + ap-southeast-2: + states: states.ap-southeast-2.amazonaws.com + ap-southeast-3: + states: states.ap-southeast-3.amazonaws.com + ca-central-1: + states: states.ca-central-1.amazonaws.com + cn-north-1: + states: states.cn-north-1.amazonaws.com + cn-northwest-1: + states: states.cn-northwest-1.amazonaws.com + eu-central-1: + states: states.eu-central-1.amazonaws.com + eu-north-1: + states: states.eu-north-1.amazonaws.com + eu-south-1: + states: states.eu-south-1.amazonaws.com + eu-south-2: + states: states.eu-south-2.amazonaws.com + eu-west-1: + states: states.eu-west-1.amazonaws.com + eu-west-2: + states: states.eu-west-2.amazonaws.com + eu-west-3: + states: states.eu-west-3.amazonaws.com + me-south-1: + states: states.me-south-1.amazonaws.com + sa-east-1: + states: states.sa-east-1.amazonaws.com + us-east-1: + states: states.us-east-1.amazonaws.com + us-east-2: + states: states.us-east-2.amazonaws.com + us-gov-east-1: + states: states.us-gov-east-1.amazonaws.com + us-gov-west-1: + states: states.us-gov-west-1.amazonaws.com + us-iso-east-1: + states: states.amazonaws.com + us-iso-west-1: + states: states.amazonaws.com + us-isob-east-1: + states: states.amazonaws.com + us-west-1: + states: states.us-west-1.amazonaws.com + us-west-2: + states: states.us-west-2.amazonaws.com diff --git a/tests/aws/templates/simple_api.update.yaml b/tests/aws/templates/simple_api.update.yaml new file mode 100644 index 0000000000000..b87af0d30efff --- /dev/null +++ b/tests/aws/templates/simple_api.update.yaml @@ -0,0 +1,14 @@ +AWSTemplateFormatVersion: "2010-09-09" + +Parameters: + ApiName: + Type: String + +Resources: + Api: + Type: 'AWS::ApiGateway::RestApi' + Properties: + Name: + Ref: ApiName + Bucket: + Type: AWS::S3::Bucket diff --git a/tests/aws/templates/simple_api.yaml b/tests/aws/templates/simple_api.yaml new file mode 100644 index 0000000000000..2655e29972232 --- /dev/null +++ b/tests/aws/templates/simple_api.yaml @@ -0,0 +1,12 @@ +AWSTemplateFormatVersion: "2010-09-09" + +Parameters: + ApiName: + Type: String + +Resources: + Api: + Type: 'AWS::ApiGateway::RestApi' + Properties: + Name: + Ref: ApiName diff --git a/tests/aws/templates/simple_no_change.yaml b/tests/aws/templates/simple_no_change.yaml new file mode 100644 index 0000000000000..679d564db69a6 --- /dev/null +++ b/tests/aws/templates/simple_no_change.yaml @@ -0,0 +1,3 @@ +Resources: + MyTopic: + Type: AWS::SNS::Topic diff --git a/tests/aws/templates/simple_no_change_with_transformation.yaml b/tests/aws/templates/simple_no_change_with_transformation.yaml new file mode 100644 index 0000000000000..28450ca27ee7f --- /dev/null +++ b/tests/aws/templates/simple_no_change_with_transformation.yaml @@ -0,0 +1,4 @@ +Transform: AWS::Serverless-2016-10-31 +Resources: + MyTopic: + Type: AWS::SNS::Topic diff --git a/tests/aws/templates/sns_subscription.yml b/tests/aws/templates/sns_subscription.yml new file mode 100644 index 0000000000000..d7c99ae0bf27f --- /dev/null +++ b/tests/aws/templates/sns_subscription.yml @@ -0,0 +1,20 @@ +Parameters: + TopicArn: + Type: String + Description: The ARN of the SNS topic to subscribe to + QueueArn: + Type: String + Description: The URL of the SQS queue to send messages to +Resources: + SnsSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref TopicArn + Endpoint: !Ref QueueArn + RawMessageDelivery: true + +Outputs: + SubscriptionArn: + Value: !Ref SnsSubscription + Description: The ARN of the SNS subscription diff --git a/tests/aws/templates/sns_subscription_cross_region.yml b/tests/aws/templates/sns_subscription_cross_region.yml new file mode 100644 index 0000000000000..773f708547eb6 --- /dev/null +++ b/tests/aws/templates/sns_subscription_cross_region.yml @@ -0,0 +1,24 @@ +Parameters: + TopicArn: + Type: String + Description: The ARN of the SNS topic to subscribe to + QueueArn: + Type: String + Description: The URL of the SQS queue to send messages to + TopicRegion: + Type: String + Description: The region of the SNS Topic +Resources: + SnsSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref TopicArn + Endpoint: !Ref QueueArn + RawMessageDelivery: true + Region: !Ref TopicRegion + +Outputs: + SubscriptionArn: + Value: !Ref SnsSubscription + Description: The ARN of the SNS subscription diff --git a/tests/aws/templates/sns_subscription_update.yml b/tests/aws/templates/sns_subscription_update.yml new file mode 100644 index 0000000000000..5bbec3d89c67c --- /dev/null +++ b/tests/aws/templates/sns_subscription_update.yml @@ -0,0 +1,20 @@ +Parameters: + TopicArn: + Type: String + Description: The ARN of the SNS topic to subscribe to + QueueArn: + Type: String + Description: The URL of the SQS queue to send messages to +Resources: + SnsSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref TopicArn + Endpoint: !Ref QueueArn + RawMessageDelivery: false + +Outputs: + SubscriptionArn: + Value: !Ref SnsSubscription + Description: The ARN of the SNS subscription diff --git a/tests/aws/templates/sns_topic_fifo_dedup.yaml b/tests/aws/templates/sns_topic_fifo_dedup.yaml new file mode 100644 index 0000000000000..0fb841e7082b9 --- /dev/null +++ b/tests/aws/templates/sns_topic_fifo_dedup.yaml @@ -0,0 +1,14 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + TopicName: + Type: String + Default: topic-name +Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + FifoTopic: true + ContentBasedDeduplication: true + UpdateReplacePolicy: Delete + DeletionPolicy: Delete diff --git a/tests/aws/templates/sns_topic_parameter.update.yml b/tests/aws/templates/sns_topic_parameter.update.yml new file mode 100644 index 0000000000000..d4826b12f5365 --- /dev/null +++ b/tests/aws/templates/sns_topic_parameter.update.yml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + TopicName: + Type: String + Default: sns-topic-simple +Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: + Ref: TopicName + DeletionPolicy: Delete + +Outputs: + TopicArn: + Value: + Fn::GetAtt: + - topic123 + - TopicArn \ No newline at end of file diff --git a/tests/aws/templates/sns_topic_parameter.yml b/tests/aws/templates/sns_topic_parameter.yml new file mode 100644 index 0000000000000..cc2c26fa4d433 --- /dev/null +++ b/tests/aws/templates/sns_topic_parameter.yml @@ -0,0 +1,20 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + TopicName: + Type: String + Default: sns-topic-simple +Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: + Ref: TopicName + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + +Outputs: + TopicArn: + Value: + Fn::GetAtt: + - topic123 + - TopicArn \ No newline at end of file diff --git a/tests/aws/templates/sns_topic_simple.yaml b/tests/aws/templates/sns_topic_simple.yaml new file mode 100644 index 0000000000000..f491e6f14f5c4 --- /dev/null +++ b/tests/aws/templates/sns_topic_simple.yaml @@ -0,0 +1,14 @@ +AWSTemplateFormatVersion: '2010-09-09' +Metadata: + TopicName: sns-topic-simple +Parameters: + TopicName: + Type: String + Default: sns-topic-simple +Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + UpdateReplacePolicy: Delete + DeletionPolicy: Delete diff --git a/tests/aws/templates/sns_topic_subscription.yaml b/tests/aws/templates/sns_topic_subscription.yaml new file mode 100644 index 0000000000000..02d2c40d2d267 --- /dev/null +++ b/tests/aws/templates/sns_topic_subscription.yaml @@ -0,0 +1,28 @@ +Parameters: + TopicName: + Type: String + Default: topic-name + QueueName: + Type: String + Default: queue-name +Resources: + queue276F7297: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + topic: + Type: AWS::SNS::Topic + Properties: + Subscription: + - Endpoint: + Fn::GetAtt: + - queue276F7297 + - Arn + Protocol: sqs + TopicName: !Ref TopicName +Outputs: + TopicArnOutput: + Value: + Ref: topic diff --git a/tests/aws/templates/sns_topic_template.json b/tests/aws/templates/sns_topic_template.json new file mode 100644 index 0000000000000..e9025da2b58eb --- /dev/null +++ b/tests/aws/templates/sns_topic_template.json @@ -0,0 +1,17 @@ +{ + "Resources": { + "topic69831491": { + "Type": "AWS::SNS::Topic" + } + }, + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "topic69831491", + "TopicName" + ] + } + } + } +} diff --git a/tests/aws/templates/sns_topic_template.yaml b/tests/aws/templates/sns_topic_template.yaml new file mode 100644 index 0000000000000..f3101065d6495 --- /dev/null +++ b/tests/aws/templates/sns_topic_template.yaml @@ -0,0 +1,9 @@ +Resources: + topic69831491: + Type: AWS::SNS::Topic +Outputs: + TopicName: + Value: + Fn::GetAtt: + - topic69831491 + - TopicName diff --git a/tests/aws/templates/sns_topic_update.yaml b/tests/aws/templates/sns_topic_update.yaml new file mode 100644 index 0000000000000..3b08ee394f0d0 --- /dev/null +++ b/tests/aws/templates/sns_topic_update.yaml @@ -0,0 +1,30 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + TopicName: + Type: String + DisplayName: + Type: String + Environment: + Type: String + Project: + Type: String +Resources: + TestTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + DisplayName: !Ref DisplayName + Tags: + - Key: Environment + Value: !Ref Environment + - Key: Project + Value: !Ref Project + FixedTestSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: email + Endpoint: "test@example.com" + TopicArn: !Ref TestTopic +Outputs: + TopicArn: + Value: !Ref TestTopic diff --git a/tests/aws/templates/sqs_export.yml b/tests/aws/templates/sqs_export.yml new file mode 100644 index 0000000000000..4c5e63115a3ae --- /dev/null +++ b/tests/aws/templates/sqs_export.yml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + QueueName: + Type: String + Default: test-queue + Description: The name of the SQS queue +Resources: + MyQueue: + Type: 'AWS::SQS::Queue' + Properties: + QueueName: !Ref QueueName +Outputs: + TestOutput26: + Value: !GetAtt MyQueue.Arn + Export: + Name: TestQueueArn26 diff --git a/tests/aws/templates/sqs_fifo_autogenerate_name.yaml b/tests/aws/templates/sqs_fifo_autogenerate_name.yaml new file mode 100644 index 0000000000000..62e85cf2aa551 --- /dev/null +++ b/tests/aws/templates/sqs_fifo_autogenerate_name.yaml @@ -0,0 +1,22 @@ +Parameters: + IsFifo: + Type: String + +Conditions: + IsFifo: !Equals [ !Ref IsFifo, "true"] + +Resources: + FooQueueA2A23E59: + Type: AWS::SQS::Queue + Properties: + ContentBasedDeduplication: !If [ IsFifo, "true", !Ref AWS::NoValue ] + FifoQueue: !If [ IsFifo, "true", !Ref AWS::NoValue ] + VisibilityTimeout: 300 + UpdateReplacePolicy: Delete + DeletionPolicy: Delete +Outputs: + FooQueueName: + Value: + Fn::GetAtt: + - FooQueueA2A23E59 + - QueueName diff --git a/tests/aws/templates/sqs_fifo_queue.yml b/tests/aws/templates/sqs_fifo_queue.yml new file mode 100644 index 0000000000000..6d1077a421c4c --- /dev/null +++ b/tests/aws/templates/sqs_fifo_queue.yml @@ -0,0 +1,18 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + QueueName: + Type: String + Default: "test-queue" + Description: "Name of the SQS queue" +Resources: + FifoQueue: + Type: 'AWS::SQS::Queue' + Properties: + QueueName: !Sub "${QueueName}.fifo" + ContentBasedDeduplication: "false" + FifoQueue: "true" + +Outputs: + QueueURL: + Value: !GetAtt FifoQueue.QueueName + Description: "URL of the SQS queue" diff --git a/tests/aws/templates/sqs_import.yml b/tests/aws/templates/sqs_import.yml new file mode 100644 index 0000000000000..584cafaeebf00 --- /dev/null +++ b/tests/aws/templates/sqs_import.yml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + QueueName: + Type: String + Default: test-queue + Description: The name of the SQS queue +Resources: + MessageQueue: + Type: 'AWS::SQS::Queue' + Properties: + QueueName: !Ref QueueName + RedrivePolicy: + deadLetterTargetArn: !ImportValue TestQueueArn26 + maxReceiveCount: 3 +Outputs: + MessageQueueArn1: + Value: !ImportValue TestQueueArn26 + MessageQueueArn2: + Value: !GetAtt MessageQueue.Arn diff --git a/tests/aws/templates/sqs_queue_update.yml b/tests/aws/templates/sqs_queue_update.yml new file mode 100644 index 0000000000000..adc88150ccbc7 --- /dev/null +++ b/tests/aws/templates/sqs_queue_update.yml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + QueueName: + Type: String +Resources: + MyQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: + Ref: QueueName + DeletionPolicy: Delete +Outputs: + QueueUrl: + Value: !Ref MyQueue + QueueArn: + Value: !GetAtt MyQueue.Arn diff --git a/tests/aws/templates/sqs_queue_update_no_change.yml b/tests/aws/templates/sqs_queue_update_no_change.yml new file mode 100644 index 0000000000000..dad49efefd6fb --- /dev/null +++ b/tests/aws/templates/sqs_queue_update_no_change.yml @@ -0,0 +1,29 @@ +Parameters: + AddBucket: + Type: String + AllowedValues: + - "true" + - "false" + + BucketName: + Type: String + +Conditions: + ShouldDeployBucket: !Equals ["true", !Ref AddBucket] + +Resources: + Queue: + Type: AWS::SQS::Queue + + Bucket: + Type: AWS::S3::Bucket + Condition: ShouldDeployBucket + Properties: + BucketName: !Ref BucketName + +Outputs: + QueueUrl: + Value: !Ref Queue + + QueueArn: + Value: !GetAtt Queue.Arn diff --git a/tests/aws/templates/sqs_with_queuepolicy.yaml b/tests/aws/templates/sqs_with_queuepolicy.yaml new file mode 100644 index 0000000000000..0fabf18902847 --- /dev/null +++ b/tests/aws/templates/sqs_with_queuepolicy.yaml @@ -0,0 +1,25 @@ +Resources: + Queue4A7E3555: + Type: AWS::SQS::Queue + QueuePolicy25439813: + Type: AWS::SQS::QueuePolicy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:SendMessage + - sqs:GetQueueAttributes + - sqs:GetQueueUrl + Effect: Allow + Principal: "*" + Resource: + Fn::GetAtt: + - Queue4A7E3555 + - Arn + Version: "2012-10-17" + Queues: + - Ref: Queue4A7E3555 +Outputs: + QueueUrlOutput: + Value: + Ref: Queue4A7E3555 diff --git a/tests/aws/templates/sqs_with_queuepolicy_updated.yaml b/tests/aws/templates/sqs_with_queuepolicy_updated.yaml new file mode 100644 index 0000000000000..17818bddc3fd2 --- /dev/null +++ b/tests/aws/templates/sqs_with_queuepolicy_updated.yaml @@ -0,0 +1,25 @@ +Resources: + Queue4A7E3555: + Type: AWS::SQS::Queue + QueuePolicy25439813: + Type: AWS::SQS::QueuePolicy + Properties: + PolicyDocument: + Statement: + - Action: + - sqs:SendMessage + - sqs:GetQueueAttributes + - sqs:GetQueueUrl + Effect: Deny + Principal: "*" + Resource: + Fn::GetAtt: + - Queue4A7E3555 + - Arn + Version: "2012-10-17" + Queues: + - Ref: Queue4A7E3555 +Outputs: + QueueUrlOutput: + Value: + Ref: Queue4A7E3555 diff --git a/tests/aws/templates/ssm_maintenance_window.yml b/tests/aws/templates/ssm_maintenance_window.yml new file mode 100644 index 0000000000000..690c5ef9bc45d --- /dev/null +++ b/tests/aws/templates/ssm_maintenance_window.yml @@ -0,0 +1,92 @@ +Resources: + PatchServerMaintenanceWindow: + Type: AWS::SSM::MaintenanceWindow + Properties: + Name: patch-server-window + AllowUnassociatedTargets: false + Cutoff: 0 + Duration: 1 + Schedule: cron(0 0 0 ? * * *) + PatchServerMaintenanceWindowTarget: + Type: AWS::SSM::MaintenanceWindowTarget + Properties: + Name: patch-server-target + WindowId: !Ref PatchServerMaintenanceWindow + ResourceType: INSTANCE + Targets: + - Key: tag:Name + Values: + - "patch-server" + PatchServerTask: + Type: AWS::SSM::MaintenanceWindowTask + Properties: + Name: patching-task + Priority: 1 + MaxErrors: "1" + MaxConcurrency: "1" + TaskArn: AWS-RunPatchBaseline + TaskType: RUN_COMMAND + WindowId: !Ref PatchServerMaintenanceWindow + TaskInvocationParameters: + MaintenanceWindowRunCommandParameters: + Parameters: + Operation: + - Install + Targets: + - Key: WindowTargetIds + Values: + - Ref: PatchServerMaintenanceWindowTarget + PatchBaselineAML: + Type: "AWS::SSM::PatchBaseline" + Properties: + Name: aml-baseline + Description: "Custom Linux patch baseline which targets Amazon Linux instances for Critical, High, Medium patching" + OperatingSystem: "AMAZON_LINUX" + PatchGroups: + - patch-group + ApprovalRules: + PatchRules: + - ApproveAfterDays: 3 + PatchFilterGroup: + PatchFilters: + - Key: "PRODUCT" + Values: + - "*" + - Key: "CLASSIFICATION" + Values: + - "Security" + - "Bugfix" + - "Recommended" + - Key: "SEVERITY" + Values: + - "Critical" + - "Important" + - "Medium" + EnableNonSecurity: false + PatchBaselineAML2: + Type: "AWS::SSM::PatchBaseline" + Properties: + Name: aml2-baseline + Description: "Custom Linux patch baseline which targets Amazon Linux 2 instances for Critical, High, Medium patching" + OperatingSystem: "AMAZON_LINUX_2" + PatchGroups: + - patch-group + ApprovalRules: + PatchRules: + - ApproveAfterDays: 3 + PatchFilterGroup: + PatchFilters: + - Key: "PRODUCT" + Values: + - "*" + - Key: "CLASSIFICATION" + Values: + - "Security" + - "Bugfix" + - "Recommended" + - Key: "SEVERITY" + Values: + - "Critical" + - "Important" + - "Medium" + EnableNonSecurity: false diff --git a/tests/aws/templates/ssm_parameter_defaultname.yaml b/tests/aws/templates/ssm_parameter_defaultname.yaml new file mode 100644 index 0000000000000..ebf175b46f29e --- /dev/null +++ b/tests/aws/templates/ssm_parameter_defaultname.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + Input: + Type: String + +Resources: + CustomParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Ref Input + +Outputs: + CustomParameterOutput: + Value: !Ref CustomParameter diff --git a/tests/aws/templates/ssm_parameter_defaultname_withtags.yaml b/tests/aws/templates/ssm_parameter_defaultname_withtags.yaml new file mode 100644 index 0000000000000..17e33d25cfa75 --- /dev/null +++ b/tests/aws/templates/ssm_parameter_defaultname_withtags.yaml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + Input: + Type: String + TagValue: + Type: String + +Resources: + CustomParameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: !Ref Input + Tags: + A: !Ref TagValue + +Outputs: + CustomParameterOutput: + Value: !Ref CustomParameter diff --git a/tests/aws/templates/ssm_patch_baseline.yml b/tests/aws/templates/ssm_patch_baseline.yml new file mode 100644 index 0000000000000..58291ca5b7b76 --- /dev/null +++ b/tests/aws/templates/ssm_patch_baseline.yml @@ -0,0 +1,48 @@ +Resources: + myPatchBaseline: + Type: AWS::SSM::PatchBaseline + Properties: + Name: myPatchBaseline + Description: Baseline containing all updates approved for Windows instances + OperatingSystem: WINDOWS + PatchGroups: + - myPatchGroup + ApprovalRules: + PatchRules: + - PatchFilterGroup: + PatchFilters: + - Values: + - Critical + - Important + - Moderate + Key: MSRC_SEVERITY + - Values: + - SecurityUpdates + - CriticalUpdates + Key: CLASSIFICATION + - Values: + - WindowsServer2019 + Key: PRODUCT + ApproveAfterDays: 7 + ComplianceLevel: CRITICAL + - PatchFilterGroup: + PatchFilters: + - Values: + - Critical + - Important + - Moderate + Key: MSRC_SEVERITY + - Values: + - "*" + Key: CLASSIFICATION + - Values: + - APPLICATION + Key: PATCH_SET + - Values: + - Active Directory Rights Management Services Client 2.0 + Key: PRODUCT + - Values: + - Active Directory + Key: PRODUCT_FAMILY + ApproveAfterDays: 7 + ComplianceLevel: CRITICAL diff --git a/tests/aws/templates/stack_policy.json b/tests/aws/templates/stack_policy.json new file mode 100644 index 0000000000000..aea78db9a10ce --- /dev/null +++ b/tests/aws/templates/stack_policy.json @@ -0,0 +1,3 @@ +{ + "Statement": [{"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}] +} \ No newline at end of file diff --git a/tests/aws/templates/stack_policy_test.yaml b/tests/aws/templates/stack_policy_test.yaml new file mode 100644 index 0000000000000..33ca4d960b98f --- /dev/null +++ b/tests/aws/templates/stack_policy_test.yaml @@ -0,0 +1,20 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Parameters: + TopicName: + Type: String + BucketName: + Type: String + +Resources: + topic123: + Type: AWS::SNS::Topic + Properties: + TopicName: + Ref: TopicName + + bucket123: + Type: AWS::S3::Bucket + Properties: + BucketName: + Ref: BucketName diff --git a/tests/aws/templates/stack_update_1.yaml b/tests/aws/templates/stack_update_1.yaml new file mode 100644 index 0000000000000..f4e5f9c421e35 --- /dev/null +++ b/tests/aws/templates/stack_update_1.yaml @@ -0,0 +1,15 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + Topic1Name: + Type: String + Topic2Name: + Type: String + Topic3Name: + Type: String +Resources: + topic1: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref Topic1Name + UpdateReplacePolicy: Delete + DeletionPolicy: Delete diff --git a/tests/aws/templates/stack_update_2.yaml b/tests/aws/templates/stack_update_2.yaml new file mode 100644 index 0000000000000..3f2c841c28ea3 --- /dev/null +++ b/tests/aws/templates/stack_update_2.yaml @@ -0,0 +1,21 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + Topic1Name: + Type: String + Topic2Name: + Type: String + Topic3Name: + Type: String +Resources: + topic1: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref Topic1Name + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + topic2: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref Topic2Name + UpdateReplacePolicy: Delete + DeletionPolicy: Delete diff --git a/tests/aws/templates/stack_update_3.yaml b/tests/aws/templates/stack_update_3.yaml new file mode 100644 index 0000000000000..e8f1d4ff3eba3 --- /dev/null +++ b/tests/aws/templates/stack_update_3.yaml @@ -0,0 +1,27 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + Topic1Name: + Type: String + Topic2Name: + Type: String + Topic3Name: + Type: String +Resources: + topic1: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref Topic1Name + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + topic2: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref Topic2Name + UpdateReplacePolicy: Delete + DeletionPolicy: Delete + topic3: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref Topic3Name + UpdateReplacePolicy: Delete + DeletionPolicy: Delete diff --git a/tests/aws/templates/statemachine_machine_default_s3_location.yml b/tests/aws/templates/statemachine_machine_default_s3_location.yml new file mode 100644 index 0000000000000..cf89842900637 --- /dev/null +++ b/tests/aws/templates/statemachine_machine_default_s3_location.yml @@ -0,0 +1,33 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Parameters: + BucketName: + Type: String + + ObjectKey: + Type: String + +Resources: + StateMachineRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: states.amazonaws.com + Action: sts:AssumeRole + + StateMachine: + Type: AWS::StepFunctions::StateMachine + Properties: + StateMachineType: STANDARD + RoleArn: !GetAtt StateMachineRole.Arn + DefinitionS3Location: + Bucket: !Ref BucketName + Key: !Ref ObjectKey + +Outputs: + StateMachineArnOutput: + Value: !Ref StateMachine diff --git a/tests/aws/templates/statemachine_machine_logging_configuration.yml b/tests/aws/templates/statemachine_machine_logging_configuration.yml new file mode 100644 index 0000000000000..10694785acccb --- /dev/null +++ b/tests/aws/templates/statemachine_machine_logging_configuration.yml @@ -0,0 +1,52 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Resources: + StateMachineRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: states.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: StateMachineFullAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: "*" + Resource: "*" + + LogGroup: + Type: AWS::Logs::LogGroup + Properties: + RetentionInDays: 14 + + StateMachine: + Type: AWS::StepFunctions::StateMachine + Properties: + StateMachineType: STANDARD + RoleArn: !GetAtt StateMachineRole.Arn + DefinitionString: | + { + "StartAt": "S0", + "States": { + "S0": { + "Type": "Pass", + "End": true + } + } + } + LoggingConfiguration: + Destinations: + - CloudWatchLogsLogGroup: + LogGroupArn: !GetAtt LogGroup.Arn + IncludeExecutionData: true + Level: ALL + +Outputs: + StateMachineArnOutput: + Value: !Ref StateMachine diff --git a/tests/aws/templates/statemachine_machine_with_activity.yml b/tests/aws/templates/statemachine_machine_with_activity.yml new file mode 100644 index 0000000000000..36a709ea1eaf0 --- /dev/null +++ b/tests/aws/templates/statemachine_machine_with_activity.yml @@ -0,0 +1,43 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + StateMachineName: + Type: String + Default: "MyStateMachine" + Description: Name of the Step Functions State Machine + ActivityName: + Type: String + Default: "MyActivity" + Description: Name of the Step Functions Activity + +Resources: + LambdaRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + + StateMachine: + Type: AWS::StepFunctions::StateMachine + Properties: + StateMachineName: !Ref StateMachineName + DefinitionString: "{\"Comment\": \"A Hello World example of the Amazon States Language using Pass states\", \"StartAt\": \"Hello\", \"States\": {\"Hello\": {\"Type\": \"Pass\", \"Result\": \"Hello\", \"Next\": \"World\"}, \"World\": {\"Type\": \"Pass\", \"Result\": \"World\", \"End\": true } } }" + RoleArn: !GetAtt + - LambdaRole + - Arn + DependsOn: + - Activity + + Activity: + Type: AWS::StepFunctions::Activity + Properties: + Name: !Ref ActivityName + Tags: + - Key: test-key + Value: test-value diff --git a/tests/aws/templates/stepfunctions_statemachine_substitutions.yaml b/tests/aws/templates/stepfunctions_statemachine_substitutions.yaml new file mode 100644 index 0000000000000..2289e3a90ebcf --- /dev/null +++ b/tests/aws/templates/stepfunctions_statemachine_substitutions.yaml @@ -0,0 +1,86 @@ +Resources: + mystaterole904FED2D: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: "*" + Version: "2012-10-17" + mystateroleDefaultPolicy4DD169B3: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: lambda:InvokeFunction + Effect: Allow + Resource: + Fn::GetAtt: + - startFnB78B119D + - Arn + Version: "2012-10-17" + PolicyName: mystateroleDefaultPolicy4DD169B3 + Roles: + - Ref: mystaterole904FED2D + startFnServiceRoleB86C6980: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + - Fn::Join: + - "" + - - "arn:" + - Ref: AWS::Partition + - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + startFnB78B119D: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: " + + \ exports.handler = async function(event) { + + \ console.log(JSON.stringify({event})); + + \ return JSON.stringify({message: \"hello from statemachine lambda\"}); + + \ } + + \ " + Role: + Fn::GetAtt: + - startFnServiceRoleB86C6980 + - Arn + Handler: index.handler + Runtime: nodejs16.x + DependsOn: + - startFnServiceRoleB86C6980 + stm: + Type: AWS::StepFunctions::StateMachine + Properties: + StateMachineType: STANDARD + RoleArn: + Fn::GetAtt: + - mystaterole904FED2D + - Arn + DefinitionString: '{"StartAt": "HelloWorld", "States": {"HelloWorld": {"Type": "Task", "Resource": "${StartFn}", "End": true}}}' + DefinitionSubstitutions: + StartFn: + Fn::GetAtt: + - startFnB78B119D + - Arn + +Outputs: + StateMachineArnOutput: + Value: + Fn::GetAtt: + - stm + - Arn diff --git a/tests/aws/templates/sub_dependencies.yaml b/tests/aws/templates/sub_dependencies.yaml new file mode 100644 index 0000000000000..86fb2dadc959d --- /dev/null +++ b/tests/aws/templates/sub_dependencies.yaml @@ -0,0 +1,17 @@ +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::Sub: + - arn:${AWS::Partition}:sqs:${AWS::Region}:${AWS::AccountId}:${queueName} + - queueName: + Fn::GetAtt: + - Queue + - QueueName + + Queue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Join ["-", [!Ref AWS::StackName, MyQueue]] diff --git a/tests/aws/templates/sub_number_type.yml b/tests/aws/templates/sub_number_type.yml new file mode 100644 index 0000000000000..11bcd19e6cfb5 --- /dev/null +++ b/tests/aws/templates/sub_number_type.yml @@ -0,0 +1,43 @@ +AWSTemplateFormatVersion: '2010-09-09' +Parameters: + ResourceNamePrefix: + Type: String + RestLatencyPreemptiveAlarmPeriod: + Type: Number + RestLatencyPreemptiveAlarmEvaluationPeriods: + Type: Number + Default: 1 + RestLatencyPreemptiveAlarmThreshold: + Type: Number + +Resources: + RestLatencyPreemptiveAlarm: + Type: AWS::CloudWatch::Alarm + Properties: + ActionsEnabled: true + AlarmActions: + - Fn::Sub: arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:opsitem:2 + AlarmDescription: Test alarm + AlarmName: + Fn::Sub: "${ResourceNamePrefix}-${RestLatencyPreemptiveAlarmPeriod}" + Namespace: AWS/ApiGateway + MetricName: Latency + Dimensions: + - Name: ApiName + Value: dummy-api + Statistic: Average + Period: + Fn::Sub: ${RestLatencyPreemptiveAlarmPeriod} + EvaluationPeriods: + Fn::Sub: ${RestLatencyPreemptiveAlarmEvaluationPeriods} + Threshold: + Fn::Sub: ${RestLatencyPreemptiveAlarmThreshold} + ComparisonOperator: GreaterThanThreshold + +Outputs: + AlarmName: + Value: !Ref RestLatencyPreemptiveAlarm + Threshold: + Value: !Ref RestLatencyPreemptiveAlarmThreshold + Period: + Value: !Ref RestLatencyPreemptiveAlarmPeriod diff --git a/tests/aws/templates/template31.yaml b/tests/aws/templates/template31.yaml new file mode 100644 index 0000000000000..b198d3ea2f087 --- /dev/null +++ b/tests/aws/templates/template31.yaml @@ -0,0 +1,16 @@ +Resources: + localEventBus: + Type: AWS::Events::EventBus + Properties: + Name: my-test-bus + + localEventConnection: + Type: AWS::Events::Connection + Properties: + Name: my-test-conn + AuthorizationType: API_KEY + AuthParameters: + ApiKeyAuthParameters: + ApiKeyName: apikey123 + ApiKeyValue: secretapikey123 + Description: test events connection diff --git a/tests/aws/templates/template33.yaml b/tests/aws/templates/template33.yaml new file mode 100644 index 0000000000000..bac94ded8c546 --- /dev/null +++ b/tests/aws/templates/template33.yaml @@ -0,0 +1,27 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Environment: + Type: String + Default: 'companyname-ci' +Resources: + VPC: + Type: AWS::EC2::VPC + Properties: + EnableDnsSupport: true + EnableDnsHostnames: true + CidrBlock: "100.0.0.0/20" + + RouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: VPC + Tags: + - Key: env + Value: production + + +Outputs: + RouteTableId: + Value: + Ref: RouteTable diff --git a/tests/aws/templates/template34.yaml b/tests/aws/templates/template34.yaml new file mode 100644 index 0000000000000..dc8182c3195a9 --- /dev/null +++ b/tests/aws/templates/template34.yaml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: Test stack + +Parameters: + AliasName: + Type: String + +Resources: + KMSKey: + Type: AWS::KMS::Key + Properties: + Description: Sample KMS + KeyPolicy: + Version: '2012-10-17' + Id: 'default' + Statement: + - Sid: Enable IAM User Permissions + Effect: Allow + Principal: + AWS: !Sub "arn:aws:iam::${AWS::AccountId}:root" + Action: kms:* + Resource: '*' + + KMSKeyAlias: + Type: AWS::KMS::Alias + DependsOn: KMSKey + Properties: + AliasName: !Ref AliasName + TargetKeyId: !Ref KMSKey + +Outputs: + KeyAlias: + Value: !Ref KMSKeyAlias + KeyArn: + Value: !GetAtt KMSKey.Arn diff --git a/tests/aws/templates/template35.yaml b/tests/aws/templates/template35.yaml new file mode 100644 index 0000000000000..199bc71baf2f4 --- /dev/null +++ b/tests/aws/templates/template35.yaml @@ -0,0 +1,107 @@ +AWSTemplateFormatVersion: 2010-09-09 +Description: Celeste Service + +Parameters: + ServiceName: + Type: String + Default: celeste + Environment: + Type: String + Default: local + BuildKey: + Type: String + Default: celeste-local.zip + +Resources: + Gateway: + Type: AWS::ApiGateway::RestApi + Properties: + EndpointConfiguration: + Types: + - REGIONAL + Name: + !Join + - '-' + - - !Ref ServiceName + - 'Gateway' + - !Ref Environment + GatewayV1: + Type: AWS::ApiGateway::Stage + Properties: + DeploymentId: !Ref GatewayDeployment + RestApiId: !Ref Gateway + StageName: 'v0' + GatewayDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - CreateAccount + Properties: + RestApiId: !Ref Gateway + DefaultModel: + Type: AWS::ApiGateway::Model + Properties: + ContentType: 'application/json' + RestApiId: !Ref Gateway + Schema: {} + CreateAccountResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: + !GetAtt + - Gateway + - RootResourceId + PathPart: 'account' + RestApiId: !Ref Gateway + CreateAccountRequestModel: + Type: AWS::ApiGateway::Model + Properties: + ContentType: 'application/json' + RestApiId: !Ref Gateway + Schema: + $schema: 'http://json-schema.org/draft-04/schema#' + title: AccountCreate + type: object + properties: + field: + type: string + email: + type: string + format: email + CreateAccount: + Type: AWS::ApiGateway::Method + Properties: + ApiKeyRequired: false + AuthorizationType: NONE + HttpMethod: POST + RequestParameters: + method.request.path.account: True + Integration: + ConnectionType: INTERNET + IntegrationResponses: + - ResponseTemplates: + application/json: "{\"operation\":\"celeste_account_create\",\"data\":{\"key\":\"123e4567-e89b-12d3-a456-426614174000\",\"secret\":\"123e4567-e89b-12d3-a456-426614174000\"}}" + SelectionPattern: '2\d{2}' + StatusCode: '202' + - ResponseTemplates: + application/json: "{\"message\":\"Unknown Error\"}" + SelectionPattern: '5\d{2}' + StatusCode: '500' + - ResponseTemplates: + application/json: "{\"message\":\"Not Found\"}" + SelectionPattern: 404 + StatusCode: '404' + PassthroughBehavior: WHEN_NO_TEMPLATES + RequestTemplates: + application/json: !Ref CreateAccountRequestModel + Type: MOCK + TimeoutInMillis: 29000 + MethodResponses: + - ResponseModels: + application/json: !Ref DefaultModel + StatusCode: '202' + - ResponseModels: + application/json: !Ref DefaultModel + StatusCode: '500' + OperationName: 'create_account' + ResourceId: !Ref CreateAccountResource + RestApiId: !Ref Gateway diff --git a/tests/aws/templates/template36.yaml b/tests/aws/templates/template36.yaml new file mode 100644 index 0000000000000..1d94d20c8c5a5 --- /dev/null +++ b/tests/aws/templates/template36.yaml @@ -0,0 +1,66 @@ +AWSTemplateFormatVersion: "2010-09-09" +Parameters: + Environment: + Type: String + Default: 'companyname-ci' + +Resources: + VPC: + Type: AWS::EC2::VPC + Properties: + EnableDnsSupport: true + EnableDnsHostnames: true + CidrBlock: "100.0.0.0/20" + + RouteTableProduction: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: VPC + Tags: + - Key: env + Value: production + + RouteTableQa: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: VPC + Tags: + - Key: env + Value: qa + + RouteTableQa2: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: VPC + Tags: + - Key: env + Value: qa + + InternetGateway: + Type: AWS::EC2::InternetGateway + + VPCGatewayAttachment: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: !Ref VPC + InternetGatewayId: !Ref InternetGateway + + PublicRoute: + Type: AWS::EC2::Route + Properties: + DestinationCidrBlock: 0.0.0.0/0 + RouteTableId: !Ref RouteTableProduction + GatewayId: !Ref InternetGateway + +Outputs: + PublicRoute: + Value: + Ref: PublicRoute + Export: + Name: 'publicRoute-identify' + VPC: + Value: + Ref: VPC diff --git a/tests/aws/templates/template37.yaml b/tests/aws/templates/template37.yaml new file mode 100644 index 0000000000000..b37e3e33b7cb7 --- /dev/null +++ b/tests/aws/templates/template37.yaml @@ -0,0 +1,70 @@ +AWSTemplateFormatVersion: "2010-09-09" + +Resources: + VPC: + Type: AWS::EC2::VPC + Properties: + CidrBlock: "100.0.0.0/20" + + RouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: VPC + Tags: + - Key: env + Value: production + + SubnetA: + Type: AWS::EC2::Subnet + Properties: + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: + Ref: AWS::Region + CidrBlock: "100.0.0.0/24" + VpcId: + Ref: VPC + + SubnetB: + Type: AWS::EC2::Subnet + Properties: + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: + Ref: AWS::Region + CidrBlock: "100.0.2.0/24" + VpcId: + Ref: VPC + + RouteTableAssociationA: + Type: "AWS::EC2::SubnetRouteTableAssociation" + Properties: + SubnetId: + Ref: SubnetA + RouteTableId: + Ref: RouteTable + DependsOn: + - SubnetA + - RouteTable + + RouteTableAssociationB: + Type: "AWS::EC2::SubnetRouteTableAssociation" + Properties: + SubnetId: + Ref: SubnetB + RouteTableId: + Ref: RouteTable + DependsOn: + - SubnetB + - RouteTable + +Outputs: + RouteTable: + Value: + Ref: RouteTable + VpcId: + Value: + Ref: VPC diff --git a/tests/aws/templates/template4.yaml b/tests/aws/templates/template4.yaml new file mode 100644 index 0000000000000..0ff5bc9736553 --- /dev/null +++ b/tests/aws/templates/template4.yaml @@ -0,0 +1,32 @@ +AWSTemplateFormatVersion: 2010-09-09 +Transform: AWS::Serverless-2016-10-31 +Parameters: + LambdaRuntime: + Type: String + Default: python3.9 + FunctionName: + Type: String +Resources: + MyRole: + Type: AWS::IAM::Role + Properties: + RoleName: test-role-123 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + MyFunc: + Type: AWS::Serverless::Function + Properties: + FunctionName: + Ref: FunctionName + Handler: index.handler + Role: !GetAtt 'MyRole.Arn' + Runtime: + Ref: LambdaRuntime + InlineCode: | + def handler(event, context): + return {'hello': 'world'} diff --git a/tests/aws/templates/template5.yaml b/tests/aws/templates/template5.yaml new file mode 100644 index 0000000000000..c203114aa34e0 --- /dev/null +++ b/tests/aws/templates/template5.yaml @@ -0,0 +1,10 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + LocalParam: + Description: Local stack parameter (passed from parent stack) + Type: String +Resources: + S3Setup: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub 'test-${LocalParam}' \ No newline at end of file diff --git a/tests/aws/templates/template6.yaml b/tests/aws/templates/template6.yaml new file mode 100644 index 0000000000000..575fafbc42025 --- /dev/null +++ b/tests/aws/templates/template6.yaml @@ -0,0 +1,12 @@ +AWSTemplateFormatVersion: 2010-09-09 +Parameters: + GlobalParam: + Description: Global stack parameter + Type: String +Resources: + NestedStack: + Type: AWS::CloudFormation::Stack + Properties: + TemplateURL: http://localhost:4566/%s/%s + Parameters: + LocalParam: !Ref GlobalParam diff --git a/tests/aws/templates/template7.json b/tests/aws/templates/template7.json new file mode 100644 index 0000000000000..7f6e3241ff93c --- /dev/null +++ b/tests/aws/templates/template7.json @@ -0,0 +1,66 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Template for AWS::AWS::Function.", + "Parameters": { + "LambdaFunctionName": { + "Type": "String" + }, + "LambdaRoleName": { + "Type": "String" + } + }, + "Resources": { + "LambdaFunction1": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": { + "Ref": "LambdaFunctionName" + }, + "Code": { + "ZipFile": "file.zip" + }, + "Runtime": "nodejs18.x", + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "LambdaExecutionRole", + "Arn" + ] + }, + "Timeout": 300 + } + }, + "LambdaExecutionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": { + "Ref": "LambdaRoleName" + }, + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "ALLOW", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + } + } + } + }, + "Outputs": { + "TestStackFunctionName": { + "Value": { + "Ref": "LambdaFunctionName" + } + }, + "TestStackRoleName": { + "Value": { + "Ref": "LambdaRoleName" + } + } + } +} diff --git a/tests/aws/templates/transformation_add_role.yml b/tests/aws/templates/transformation_add_role.yml new file mode 100644 index 0000000000000..9cd0bc979da4f --- /dev/null +++ b/tests/aws/templates/transformation_add_role.yml @@ -0,0 +1,14 @@ +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: "not-important" + Type: String + +Outputs: + ParameterName: + Value: + Ref: Parameter + +Transform: + - AddRole \ No newline at end of file diff --git a/tests/aws/templates/transformation_global_parameter.yml b/tests/aws/templates/transformation_global_parameter.yml new file mode 100644 index 0000000000000..a0a1b45d98560 --- /dev/null +++ b/tests/aws/templates/transformation_global_parameter.yml @@ -0,0 +1,19 @@ +Parameters: + Substitution: + Type: String + Default: SubstitutionDefault + +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: "{Substitution}" + Type: String + +Outputs: + ParameterName: + Value: + Ref: Parameter + +Transform: + Name: SubstitutionMacro \ No newline at end of file diff --git a/tests/aws/templates/transformation_macro_as_reference.yml b/tests/aws/templates/transformation_macro_as_reference.yml new file mode 100644 index 0000000000000..be23acdcd7907 --- /dev/null +++ b/tests/aws/templates/transformation_macro_as_reference.yml @@ -0,0 +1,23 @@ +Parameters: + Substitution: + Type: String + Default: SubstitutionDefault + + MacroName: + Type: String + +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: "{Substitution}" + Type: String + +Outputs: + ParameterName: + Value: + Ref: Parameter + +Transform: + - Name: + Ref: MacroName \ No newline at end of file diff --git a/tests/aws/templates/transformation_macro_params_as_reference.yml b/tests/aws/templates/transformation_macro_params_as_reference.yml new file mode 100644 index 0000000000000..298e4ec16921f --- /dev/null +++ b/tests/aws/templates/transformation_macro_params_as_reference.yml @@ -0,0 +1,30 @@ +Parameters: + Substitution: + Type: String + Default: SubstitutionDefault + + MacroInput: + Type: String + +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: + Ref: Substitution + Type: String + + Parameter2: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::Join: + - " " + - - "Hello" + - "World" +Transform: + - Name: PrintReferences + Parameters: + Input: + Ref: MacroInput diff --git a/tests/aws/templates/transformation_multiple_scope_parameter.yml b/tests/aws/templates/transformation_multiple_scope_parameter.yml new file mode 100644 index 0000000000000..f977039b8a7e1 --- /dev/null +++ b/tests/aws/templates/transformation_multiple_scope_parameter.yml @@ -0,0 +1,22 @@ +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: "" + Type: String + Fn::Transform: + - Name: ReplaceString + Parameters: + Input: "snippet-transform" + - Name: ReplaceString + Parameters: + Input: "second-snippet-transform" + +Transform: + - Name: ReplaceString + Parameters: + Input: "global-transform" + + - Name: ReplaceString + Parameters: + Input: "second-global-transform" diff --git a/tests/aws/templates/transformation_print_internals.yml b/tests/aws/templates/transformation_print_internals.yml new file mode 100644 index 0000000000000..2906c2ffd4d4f --- /dev/null +++ b/tests/aws/templates/transformation_print_internals.yml @@ -0,0 +1,15 @@ +Parameters: + ExampleParameter: + Type: String + Default: example-value +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: "" + Type: String + +Transform: + - Name: PrintInternals + Parameters: + Input: "test-input" diff --git a/tests/aws/templates/transformation_resource_att.yml b/tests/aws/templates/transformation_resource_att.yml new file mode 100644 index 0000000000000..bcd6446ff5d3f --- /dev/null +++ b/tests/aws/templates/transformation_resource_att.yml @@ -0,0 +1,20 @@ +Parameters: + Input: + Type: String + +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: + Fn::Transform: + Name: GenerateRandom + Parameters: + Prefix: !Ref Input +Outputs: + Parameter: + Value: + Fn::GetAtt: + - Parameter + - Value diff --git a/tests/aws/templates/transformation_snippet_topic.json b/tests/aws/templates/transformation_snippet_topic.json new file mode 100644 index 0000000000000..2e884c8109ef9 --- /dev/null +++ b/tests/aws/templates/transformation_snippet_topic.json @@ -0,0 +1,28 @@ +{ + "Parameters": { + "TopicName": { + "Type": "String" + } + }, + "Resources": { + "Topic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": { + "Ref": "TopicName" + }, + "Fn::Transform": "ConvertTopicToFifo" + } + } + }, + "Outputs": { + "TopicName": { + "Value": { + "Fn::GetAtt": [ + "Topic", + "TopicName" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/aws/templates/transformation_snippet_topic.yml b/tests/aws/templates/transformation_snippet_topic.yml new file mode 100644 index 0000000000000..d49943d31947b --- /dev/null +++ b/tests/aws/templates/transformation_snippet_topic.yml @@ -0,0 +1,18 @@ +Parameters: + TopicName: + Type: String + +Resources: + Topic: + Type: AWS::SNS::Topic + Properties: + TopicName: + Ref: TopicName + Fn::Transform: ConvertTopicToFifo + +Outputs: + TopicName: + Value: + Fn::GetAtt: + - Topic + - TopicName diff --git a/tests/aws/templates/transformation_unsuccessful.yml b/tests/aws/templates/transformation_unsuccessful.yml new file mode 100644 index 0000000000000..b4c5a56d66c53 --- /dev/null +++ b/tests/aws/templates/transformation_unsuccessful.yml @@ -0,0 +1,9 @@ +Resources: + Parameter: + Type: AWS::SSM::Parameter + Properties: + Value: "" + Type: String + +Transform: + - Name: Unsuccessful diff --git a/tests/aws/templates/transit_gateway_attachment.yml b/tests/aws/templates/transit_gateway_attachment.yml new file mode 100644 index 0000000000000..3fed06c8676a5 --- /dev/null +++ b/tests/aws/templates/transit_gateway_attachment.yml @@ -0,0 +1,103 @@ +Resources: + Vpc8378EB38: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/20 + EnableDnsHostnames: true + EnableDnsSupport: true + InstanceTenancy: default + myTransitGateway: + Type: "AWS::EC2::TransitGateway" + Properties: + AmazonSideAsn: 65000 + Description: "TGW Route Integration Test" + AutoAcceptSharedAttachments: "disable" + DefaultRouteTableAssociation: "enable" + DnsSupport: "enable" + VpnEcmpSupport: "enable" + Tags: + - Key: Application + Value: !Ref 'AWS::StackId' + VpcIsolatedSubnet1SubnetE48C5737: + Type: AWS::EC2::Subnet + Properties: + AvailabilityZone: + Fn::Select: + - 0 + - Fn::GetAZs: '' + CidrBlock: 10.0.0.0/24 + MapPublicIpOnLaunch: false + VpcId: + Ref: Vpc8378EB38 + VpcIsolatedSubnet1RouteTable4771E3E5: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: Vpc8378EB38 + VpcIsolatedSubnet1RouteTableAssociationD300FCBB: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: VpcIsolatedSubnet1RouteTable4771E3E5 + SubnetId: + Ref: VpcIsolatedSubnet1SubnetE48C5737 + VpcIsolatedSubnet1TransitGatewayRouteA907B32D: + Type: AWS::EC2::Route + Properties: + DestinationCidrBlock: 0.0.0.0/0 + RouteTableId: + Ref: VpcIsolatedSubnet1RouteTable4771E3E5 + TransitGatewayId: !Ref myTransitGateway + DependsOn: + - TransitGatewayVpcAttachment + VpcIsolatedSubnet2Subnet16364B91: + Type: AWS::EC2::Subnet + Properties: + AvailabilityZone: + Fn::Select: + - 1 + - Fn::GetAZs: '' + CidrBlock: 10.0.1.0/24 + MapPublicIpOnLaunch: false + VpcId: + Ref: Vpc8378EB38 + VpcIsolatedSubnet2RouteTable1D30AF7D: + Type: AWS::EC2::RouteTable + Properties: + VpcId: + Ref: Vpc8378EB38 + VpcIsolatedSubnet2RouteTableAssociationF7B18CCA: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: + Ref: VpcIsolatedSubnet2RouteTable1D30AF7D + SubnetId: + Ref: VpcIsolatedSubnet2Subnet16364B91 + VpcIsolatedSubnet2TransitGatewayRoute1E0D0BF2: + Type: AWS::EC2::Route + Properties: + DestinationCidrBlock: 0.0.0.0/0 + RouteTableId: + Ref: VpcIsolatedSubnet2RouteTable1D30AF7D + TransitGatewayId: !Ref myTransitGateway + DependsOn: + - TransitGatewayVpcAttachment + TransitGatewayVpcAttachment: + Type: AWS::EC2::TransitGatewayAttachment + Properties: + SubnetIds: + - Ref: VpcIsolatedSubnet1SubnetE48C5737 + - Ref: VpcIsolatedSubnet2Subnet16364B91 + Tags: + - Key: Name + Value: example-tag + TransitGatewayId: !Ref myTransitGateway + VpcId: + Ref: Vpc8378EB38 +Outputs: + TransitGateway: + Value: + Ref: myTransitGateway + Attachment: + Value: + Ref: TransitGatewayVpcAttachment diff --git a/tests/aws/templates/valid_template.json b/tests/aws/templates/valid_template.json new file mode 100644 index 0000000000000..413ade8901e60 --- /dev/null +++ b/tests/aws/templates/valid_template.json @@ -0,0 +1,39 @@ +{ + "Parameters": { + "KeyExample": { + "Description": "The EC2 Key Pair to allow SSH access to the instance", + "Type": "AWS::EC2::KeyPair::KeyName" + } + }, + "Resources": { + "Ec2Instance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "SecurityGroups": [ + { + "Ref": "SecurityGroupExample" + } + ], + "KeyName": { + "Ref": "KeyExample" + }, + "ImageId": "ami-0e6d2e8684d4ccb3e", + "InstanceType": "t2.micro" + } + }, + "SecurityGroupExample": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "this is an example", + "SecurityGroupIngress": [ + { + "IpProtocol": "tcp", + "FromPort": "22", + "ToPort": "22", + "CidrIp": "0.0.0.0/0" + } + ] + } + } + } +} diff --git a/tests/aws/terraform/.auto.tfvars b/tests/aws/terraform/.auto.tfvars new file mode 100644 index 0000000000000..55b35be33a67a --- /dev/null +++ b/tests/aws/terraform/.auto.tfvars @@ -0,0 +1,11 @@ +# TODO: set these from the tests +region_name = "us-east-1" +function_name = "tf-lambda" +role_name = "iam_for_lambda" +table1_name = "tf_dynamotable1" +table2_name = "tf_dynamotable2" +table3_name = "tf_dynamotable3" +restapi_name = "service_api" +sg_name = "test-sg-5249" +bucket_name = "tf-bucket" +sqs_name = "tf-queue" diff --git a/tests/aws/terraform/acm.tf b/tests/aws/terraform/acm.tf new file mode 100644 index 0000000000000..ee76d3887c00d --- /dev/null +++ b/tests/aws/terraform/acm.tf @@ -0,0 +1,12 @@ +resource "aws_acm_certificate" "cert" { + domain_name = "example.com" + validation_method = "DNS" + + tags = { + Environment = "test" + } + + lifecycle { + create_before_destroy = true + } +} diff --git a/tests/aws/terraform/apigateway.tf b/tests/aws/terraform/apigateway.tf new file mode 100644 index 0000000000000..f720e9fc1f45d --- /dev/null +++ b/tests/aws/terraform/apigateway.tf @@ -0,0 +1,55 @@ +module "api-gateway" { + source = "clouddrove/api-gateway/aws" + version = "0.15.0" + name = "tf-apigateway" + environment = "test" + label_order = ["environment", "name"] + enabled = true + + # Api Gateway Resource + path_parts = ["mytestresource", "mytestresource1"] + + # Api Gateway Method + method_enabled = true + http_methods = ["GET", "GET"] + + # Api Gateway Integration + integration_types = ["MOCK", "AWS_PROXY"] + integration_http_methods = ["POST", "POST"] + uri = ["", "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:tf-lambda/invocations"] + integration_request_parameters = [{ + "integration.request.header.X-Authorization" = "'static'" + }, {}] + request_templates = [{ + "application/xml" = < + + $inputRoot.body + + EOF + }, {}] + + # Api Gateway Deployment + deployment_enabled = true + stage_name = "deploy" + + # Api Gateway Stage + stage_enabled = true + stage_names = ["qa", "dev"] +} diff --git a/tests/aws/terraform/apigw.tf b/tests/aws/terraform/apigw.tf new file mode 100644 index 0000000000000..56e6f0da86b79 --- /dev/null +++ b/tests/aws/terraform/apigw.tf @@ -0,0 +1,20 @@ +variable "restapi_name" { + type = string +} +resource "aws_api_gateway_rest_api" "service_api" { + name = var.restapi_name + + policy = < 0 + return rs["Messages"][0] + + message = retry(receive_message, retries=15, sleep=2) + assert event["message"] == message["Body"] + + @parametrize_python_runtimes + @markers.aws.unknown + def test_lambda_put_item_to_dynamodb( + self, + create_lambda_function, + dynamodb_create_table, + runtime, + lambda_su_role, + aws_client, + ): + """Put item into dynamodb from python lambda""" + table_name = f"ddb-table-{short_uid()}" + function_name = f"test-function-{short_uid()}" + + dynamodb_create_table(table_name=table_name, partition_key="id") + + create_lambda_function( + handler_file=TEST_LAMBDA_PUT_ITEM_FILE, + func_name=function_name, + runtime=runtime, + role=lambda_su_role, + client=aws_client.lambda_, + ) + + data = {short_uid(): f"data-{i}" for i in range(3)} + + event = { + "table_name": table_name, + "region_name": aws_client.dynamodb.meta.region_name, + "items": [{"id": k, "data": v} for k, v in data.items()], + } + + def wait_for_table_created(): + return ( + aws_client.dynamodb.describe_table(TableName=table_name)["Table"]["TableStatus"] + == "ACTIVE" + ) + + assert poll_condition(wait_for_table_created, timeout=30) + + aws_client.lambda_.invoke(FunctionName=function_name, Payload=json.dumps(event)) + + rs = aws_client.dynamodb.scan(TableName=table_name) + + items = rs["Items"] + + assert len(items) == len(data.keys()) + for item in items: + assert data[item["id"]["S"]] == item["data"]["S"] + + @parametrize_python_runtimes + @markers.aws.unknown + def test_lambda_start_stepfunctions_execution( + self, create_lambda_function, runtime, lambda_su_role, cleanups, aws_client + ): + """Start stepfunctions machine execution from lambda""" + function_name = f"test-function-{short_uid()}" + resource_lambda_name = f"test-resource-{short_uid()}" + state_machine_name = f"state-machine-{short_uid()}" + + create_lambda_function( + handler_file=TEST_LAMBDA_START_EXECUTION_FILE, + func_name=function_name, + runtime=runtime, + role=lambda_su_role, + client=aws_client.lambda_, + ) + + resource_lambda_arn = create_lambda_function( + handler_file=TEST_LAMBDA_PYTHON_ECHO, + func_name=resource_lambda_name, + runtime=runtime, + role=lambda_su_role, + client=aws_client.lambda_, + )["CreateFunctionResponse"]["FunctionArn"] + + state_machine_def = { + "StartAt": "step1", + "States": { + "step1": { + "Type": "Task", + "Resource": resource_lambda_arn, + "ResultPath": "$.result_value", + "End": True, + } + }, + } + + rs = aws_client.stepfunctions.create_state_machine( + name=state_machine_name, + definition=json.dumps(state_machine_def), + roleArn=lambda_su_role, + ) + sm_arn = rs["stateMachineArn"] + cleanups.append( + lambda: aws_client.stepfunctions.delete_state_machine(stateMachineArn=sm_arn) + ) + + aws_client.lambda_.invoke( + FunctionName=function_name, + Payload=json.dumps( + { + "state_machine_arn": sm_arn, + "region_name": aws_client.stepfunctions.meta.region_name, + "input": {}, + } + ), + ) + time.sleep(1) + + rs = aws_client.stepfunctions.list_executions(stateMachineArn=sm_arn) + + # assert that state machine got executed 1 time + assert 1 == len([ex for ex in rs["executions"] if ex["stateMachineArn"] == sm_arn]) + + +# --------------- +# HELPER METHODS +# --------------- + + +def get_event_source_arn(stream_name, client) -> str: + return client.describe_stream(StreamName=stream_name)["StreamDescription"]["StreamARN"] diff --git a/tests/aws/test_moto.py b/tests/aws/test_moto.py new file mode 100644 index 0000000000000..e28b4fd71ebc9 --- /dev/null +++ b/tests/aws/test_moto.py @@ -0,0 +1,390 @@ +from io import BytesIO +from typing import Optional + +import pytest +from moto.core import DEFAULT_ACCOUNT_ID as DEFAULT_MOTO_ACCOUNT_ID +from rolo import Request + +import localstack.aws.accounts +from localstack.aws.api import RequestContext, ServiceException, handler +from localstack.aws.forwarder import NotImplementedAvoidFallbackError +from localstack.aws.spec import load_service +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.services import moto +from localstack.services.moto import MotoFallbackDispatcher +from localstack.testing.pytest import markers +from localstack.utils.common import short_uid + + +@markers.aws.only_localstack +def test_call_with_sqs_creates_state_correctly(): + qname = f"queue-{short_uid()}" + + response = moto.call_moto( + moto.create_aws_request_context("sqs", "CreateQueue", {"QueueName": qname}), + include_response_metadata=True, + ) + url = response["QueueUrl"] + + try: + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert response["QueueUrl"].endswith(f"/{qname}") + + response = moto.call_moto(moto.create_aws_request_context("sqs", "ListQueues")) + assert url in response["QueueUrls"] + finally: + moto.call_moto(moto.create_aws_request_context("sqs", "DeleteQueue", {"QueueUrl": url})) + + response = moto.call_moto(moto.create_aws_request_context("sqs", "ListQueues")) + assert url not in response.get("QueueUrls", []) + + +@markers.aws.only_localstack +def test_call_sqs_invalid_call_raises_http_exception(): + with pytest.raises(ServiceException) as e: + moto.call_moto( + moto.create_aws_request_context( + "sqs", + "DeleteQueue", + { + "QueueUrl": "http://0.0.0.0/nonexistingqueue", + }, + ) + ) + e.match("The specified queue does not exist") + + +@markers.aws.only_localstack +def test_call_non_implemented_operation(): + with pytest.raises(NotImplementedError): + # we'll need to keep finding methods that moto doesn't implement ;-) + moto.call_moto( + moto.create_aws_request_context("athena", "DeleteDataCatalog", {"Name": "foo"}) + ) + + +@markers.aws.only_localstack +def test_call_with_sqs_modifies_state_in_moto_backend(): + """Whitebox test to check that moto backends are populated correctly""" + from moto.sqs.models import sqs_backends + + qname = f"queue-{short_uid()}" + + response = moto.call_moto( + moto.create_aws_request_context("sqs", "CreateQueue", {"QueueName": qname}) + ) + url = response["QueueUrl"] + assert qname in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID][AWS_REGION_US_EAST_1].queues + moto.call_moto(moto.create_aws_request_context("sqs", "DeleteQueue", {"QueueUrl": url})) + assert qname not in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID][AWS_REGION_US_EAST_1].queues + + +@pytest.mark.parametrize( + "payload", ["foobar", b"foobar", BytesIO(b"foobar")], ids=["str", "bytes", "IO[bytes]"] +) +@markers.aws.only_localstack +def test_call_s3_with_streaming_trait(payload, monkeypatch): + monkeypatch.setenv("MOTO_S3_CUSTOM_ENDPOINTS", "s3.localhost.localstack.cloud:4566") + + # In this test we use low-level interface with Moto and skip the standard setup + # In the absence of below patch, Moto and LocalStack uses difference AWS Account IDs causing the test to fail + monkeypatch.setattr(localstack.aws.accounts, "DEFAULT_AWS_ACCOUNT_ID", DEFAULT_MOTO_ACCOUNT_ID) + + bucket_name = f"bucket-{short_uid()}" + key_name = f"key-{short_uid()}" + + # create the bucket + moto.call_moto(moto.create_aws_request_context("s3", "CreateBucket", {"Bucket": bucket_name})) + + moto.call_moto( + moto.create_aws_request_context( + "s3", "PutObject", {"Bucket": bucket_name, "Key": key_name, "Body": payload} + ) + ) + + # check whether it was created/received correctly + response = moto.call_moto( + moto.create_aws_request_context("s3", "GetObject", {"Bucket": bucket_name, "Key": key_name}) + ) + assert hasattr(response["Body"], "read"), ( + f"expected Body to be readable, was {type(response['Body'])}" + ) + assert response["Body"].read() == b"foobar" + + # cleanup + moto.call_moto( + moto.create_aws_request_context( + "s3", "DeleteObject", {"Bucket": bucket_name, "Key": key_name} + ) + ) + moto.call_moto(moto.create_aws_request_context("s3", "DeleteBucket", {"Bucket": bucket_name})) + + +@markers.aws.only_localstack +def test_call_include_response_metadata(): + ctx = moto.create_aws_request_context("sqs", "ListQueues") + + response = moto.call_moto(ctx) + assert "ResponseMetadata" not in response + + response = moto.call_moto(ctx, include_response_metadata=True) + assert "ResponseMetadata" in response + + +@markers.aws.only_localstack +def test_call_with_modified_request(): + from moto.sqs.models import sqs_backends + + qname1 = f"queue-{short_uid()}" + qname2 = f"queue-{short_uid()}" + + context = moto.create_aws_request_context("sqs", "CreateQueue", {"QueueName": qname1}) + response = moto.call_moto_with_request(context, {"QueueName": qname2}) # overwrite old request + + url = response["QueueUrl"] + assert qname2 in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID][AWS_REGION_US_EAST_1].queues + assert qname1 not in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID][AWS_REGION_US_EAST_1].queues + + moto.call_moto(moto.create_aws_request_context("sqs", "DeleteQueue", {"QueueUrl": url})) + + +@markers.aws.only_localstack +def test_call_with_es_creates_state_correctly(): + domain_name = f"domain-{short_uid()}" + response = moto.call_moto( + moto.create_aws_request_context( + "es", + "CreateElasticsearchDomain", + { + "DomainName": domain_name, + "ElasticsearchVersion": "7.10", + }, + ), + include_response_metadata=True, + ) + + try: + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + assert response["DomainStatus"]["DomainName"] == domain_name + assert response["DomainStatus"]["ElasticsearchVersion"] == "7.10" + finally: + response = moto.call_moto( + moto.create_aws_request_context( + "es", "DeleteElasticsearchDomain", {"DomainName": domain_name} + ), + include_response_metadata=True, + ) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + +@markers.aws.only_localstack +def test_call_multi_region_backends(): + from moto.sqs.models import sqs_backends + + qname_us = f"queue-us-{short_uid()}" + qname_eu = f"queue-eu-{short_uid()}" + + moto.call_moto( + moto.create_aws_request_context( + "sqs", "CreateQueue", {"QueueName": qname_us}, region="us-east-1" + ) + ) + moto.call_moto( + moto.create_aws_request_context( + "sqs", "CreateQueue", {"QueueName": qname_eu}, region="eu-central-1" + ) + ) + + assert qname_us in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID]["us-east-1"].queues + assert qname_eu not in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID]["us-east-1"].queues + + assert qname_us not in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID]["eu-central-1"].queues + assert qname_eu in sqs_backends[DEFAULT_MOTO_ACCOUNT_ID]["eu-central-1"].queues + + del sqs_backends[DEFAULT_MOTO_ACCOUNT_ID]["us-east-1"].queues[qname_us] + del sqs_backends[DEFAULT_MOTO_ACCOUNT_ID]["eu-central-1"].queues[qname_eu] + + +@markers.aws.only_localstack +def test_call_with_sqs_invalid_call_raises_exception(): + with pytest.raises(ServiceException): + moto.call_moto( + moto.create_aws_request_context( + "sqs", + "DeleteQueue", + { + "QueueUrl": "http://0.0.0.0/nonexistingqueue", + }, + ) + ) + + +@markers.aws.only_localstack +def test_call_with_sqs_returns_service_response(): + qname = f"queue-{short_uid()}" + + create_queue_response = moto.call_moto( + moto.create_aws_request_context("sqs", "CreateQueue", {"QueueName": qname}) + ) + + assert "QueueUrl" in create_queue_response + assert create_queue_response["QueueUrl"].endswith(qname) + + +@markers.aws.only_localstack +def test_call_with_sns_with_full_uri(): + # when requests are being forwarded by a Proxy, the HTTP request can contain the full URI and not only the path + # see https://github.com/localstack/localstack/pull/8962 + # by using `request.path`, we would use a full URI in the request, as Werkzeug has issue parsing those proxied + # requests + topic_name = f"queue-{short_uid()}" + sns_request = Request( + "POST", + "/", + raw_path="http://localhost:4566/", + body=f"Action=CreateTopic&Name={topic_name}&Version=2010-03-31", + headers={"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"}, + ) + sns_service = load_service("sns") + context = RequestContext(sns_request) + context.account = "test" + context.region = "us-west-1" + context.service = sns_service + context.operation = sns_service.operation_model("CreateTopic") + + create_topic_response = moto.call_moto(context) + + assert create_topic_response["TopicArn"].endswith(topic_name) + + +class FakeSqsApi: + @handler("ListQueues", expand=False) + def list_queues(self, context, request): + raise NotImplementedError + + @handler("CreateQueue", expand=False) + def create_queue(self, context, request): + raise NotImplementedError + + +class FakeSqsProvider(FakeSqsApi): + def __init__(self) -> None: + super().__init__() + self.calls = [] + + @handler("ListQueues", expand=False) + def list_queues(self, context, request): + self.calls.append(context) + return moto.call_moto(context) + + +@markers.aws.only_localstack +def test_moto_fallback_dispatcher(): + provider = FakeSqsProvider() + dispatcher = MotoFallbackDispatcher(provider) + + assert "ListQueues" in dispatcher + assert "CreateQueue" in dispatcher + + def _dispatch(action, params): + context = moto.create_aws_request_context("sqs", action, params) + return dispatcher[action](context, params) + + qname = f"queue-{short_uid()}" + # when falling through the dispatcher returns the appropriate ServiceResponse (in this case a CreateQueueResult) + create_queue_response = _dispatch("CreateQueue", {"QueueName": qname}) + assert "QueueUrl" in create_queue_response + + # this returns a ListQueuesResult + list_queues_response = _dispatch("ListQueues", None) + assert len(provider.calls) == 1 + assert len([url for url in list_queues_response["QueueUrls"] if qname in url]) + + +class FakeS3Provider: + class FakeNoSuchBucket(ServiceException): + code: str = "NoSuchBucket" + sender_fault: bool = False + status_code: int = 404 + BucketName: Optional[str] + + def __init__(self) -> None: + super().__init__() + self.calls = [] + + @handler("GetObject", expand=False) + def get_object(self, _, __): + # Test fall-through raises exception + raise NotImplementedError + + @handler("ListObjects", expand=False) + def list_objects(self, _, request): + # Test provider implementation raises exception + ex = self.FakeNoSuchBucket() + ex.BucketName = request["Bucket"] + raise ex + + @handler("ListObjectsV2", expand=False) + def list_objects_v2(self, context, _): + # Test call_moto raises exception + return moto.call_moto(context) + + @handler("PutObject", expand=False) + def put_object(self, _, __): + # Test avoiding a fall-through, but raise a not implemented directly + raise NotImplementedAvoidFallbackError + + +@markers.aws.only_localstack +def test_moto_fallback_dispatcher_error_handling(monkeypatch): + """ + This test checks if the error handling (marshalling / unmarshalling) works correctly on all levels, including + additional (even non-officially supported) fields on exception (like NoSuchBucket#BucketName). + """ + monkeypatch.setenv("MOTO_S3_CUSTOM_ENDPOINTS", "s3.localhost.localstack.cloud:4566") + + provider = FakeS3Provider() + dispatcher = MotoFallbackDispatcher(provider) + + def _dispatch(action, params): + context = moto.create_aws_request_context("s3", action, params) + return dispatcher[action](context, params) + + bucket_name = f"bucket-{short_uid()}" + # Test fallback implementation raises a service exception which has the additional attribute "BucketName" + with pytest.raises(ServiceException) as e: + _dispatch("GetObject", {"Bucket": bucket_name, "Key": "key"}) + assert e.value.BucketName == bucket_name + + # Test provider implementation raises a service exception + with pytest.raises(ServiceException) as e: + _dispatch("ListObjects", {"Bucket": bucket_name}) + assert e.value.BucketName == bucket_name + + # Test provider uses call_moto, which raises a service exception + with pytest.raises(ServiceException) as e: + _dispatch("ListObjectsV2", {"Bucket": bucket_name}) + assert e.value.BucketName == bucket_name + + # Test provider raises NotImplementedAvoidFallbackError, avoiding a fall-through, raising the "not implemented" directly + with pytest.raises(NotImplementedError) as e: + _dispatch("PutObject", {"Bucket": bucket_name, "Key": "key"}) + + +@markers.aws.only_localstack +def test_request_with_response_header_location_fields(): + # CreateHostedZoneResponse has a member "Location" that's located in the headers + zone_name = f"zone-{short_uid()}.com" + request = moto.create_aws_request_context( + "route53", "CreateHostedZone", {"Name": zone_name, "CallerReference": "test"} + ) + response = moto.call_moto(request, include_response_metadata=True) + # assert response["Location"] # FIXME: this is required according to the spec, but not returned by moto + assert response["HostedZone"]["Id"] + + # clean up + moto.call_moto( + moto.create_aws_request_context( + "route53", "DeleteHostedZone", {"Id": response["HostedZone"]["Id"]} + ) + ) diff --git a/tests/aws/test_multi_accounts.py b/tests/aws/test_multi_accounts.py new file mode 100644 index 0000000000000..604d8493be087 --- /dev/null +++ b/tests/aws/test_multi_accounts.py @@ -0,0 +1,226 @@ +import pytest + +from localstack.testing.pytest import markers +from localstack.utils.strings import short_uid + + +@pytest.fixture +def client_factory(aws_client_factory): + def _client_factory(service: str, aws_access_key_id: str, region_name: str = "eu-central-1"): + return aws_client_factory.get_client( + service_name=service, + region_name=region_name, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key="test", + ) + + yield _client_factory + + +class TestMultiAccounts: + @markers.aws.only_localstack + def test_account_id_namespacing_for_moto_backends(self, client_factory): + # + # ACM + # + + account_id1 = "420420420420" + account_id2 = "133713371337" + + # Ensure resources are isolated by account ID namespaces + acm_client1 = client_factory("acm", account_id1) + acm_client2 = client_factory("acm", account_id2) + + acm_client1.request_certificate(DomainName="example.com") + + certs = acm_client1.list_certificates() + assert len(certs["CertificateSummaryList"]) == 1 + + certs = acm_client2.list_certificates() + assert len(certs["CertificateSummaryList"]) == 0 + + # + # EC2 + # + + ec2_client1 = client_factory("ec2", account_id1) + ec2_client2 = client_factory("ec2", account_id2) + + # Ensure resources are namespaced by account ID + ec2_client1.create_key_pair(KeyName="lorem") + pairs = ec2_client1.describe_key_pairs() + assert len(pairs["KeyPairs"]) == 1 + + pairs = ec2_client2.describe_key_pairs() + assert len(pairs["KeyPairs"]) == 0 + + # Ensure name conflicts don't happen across namespaces + ec2_client2.create_key_pair(KeyName="lorem") + ec2_client2.create_key_pair(KeyName="ipsum") + + pairs = ec2_client2.describe_key_pairs() + assert len(pairs["KeyPairs"]) == 2 + + pairs = ec2_client1.describe_key_pairs() + assert len(pairs["KeyPairs"]) == 1 + + # Ensure account ID resolver is correctly patched in Moto + # Calls originating in Moto must make use of client provided account ID + ec2_client1.create_vpc(CidrBlock="10.1.0.0/16") + vpcs = ec2_client1.describe_vpcs()["Vpcs"] + assert all(vpc["OwnerId"] == account_id1 for vpc in vpcs) + + @markers.aws.only_localstack + def test_account_id_namespacing_for_localstack_backends(self, client_factory): + # Ensure resources are isolated by account ID namespaces + account_id1 = "420420420420" + account_id2 = "133713371337" + + sns_client1 = client_factory("sns", account_id1) + sns_client2 = client_factory("sns", account_id2) + + arn1 = sns_client1.create_topic(Name="foo")["TopicArn"] + + assert len(sns_client1.list_topics()["Topics"]) == 1 + assert len(sns_client2.list_topics()["Topics"]) == 0 + + arn2 = sns_client2.create_topic(Name="foo")["TopicArn"] + arn3 = sns_client2.create_topic(Name="bar")["TopicArn"] + + assert len(sns_client1.list_topics()["Topics"]) == 1 + assert len(sns_client2.list_topics()["Topics"]) == 2 + + sns_client1.tag_resource(ResourceArn=arn1, Tags=[{"Key": "foo", "Value": "1"}]) + + assert len(sns_client1.list_tags_for_resource(ResourceArn=arn1)["Tags"]) == 1 + assert len(sns_client2.list_tags_for_resource(ResourceArn=arn2)["Tags"]) == 0 + assert len(sns_client2.list_tags_for_resource(ResourceArn=arn3)["Tags"]) == 0 + + sns_client2.tag_resource(ResourceArn=arn2, Tags=[{"Key": "foo", "Value": "1"}]) + sns_client2.tag_resource(ResourceArn=arn2, Tags=[{"Key": "bar", "Value": "1"}]) + sns_client2.tag_resource(ResourceArn=arn3, Tags=[{"Key": "foo", "Value": "1"}]) + + assert len(sns_client1.list_tags_for_resource(ResourceArn=arn1)["Tags"]) == 1 + assert len(sns_client2.list_tags_for_resource(ResourceArn=arn2)["Tags"]) == 2 + assert len(sns_client2.list_tags_for_resource(ResourceArn=arn3)["Tags"]) == 1 + + @markers.aws.only_localstack + def test_multi_accounts_dynamodb(self, client_factory, cleanups): + """DynamoDB depends on an external service - DynamoDB Local""" + account_id1 = "420420420420" + account_id2 = "133713371337" + + ddb_client1 = client_factory("dynamodb", account_id1, region_name="ap-south-1") + ddb_client2 = client_factory("dynamodb", account_id1) + ddb_client3 = client_factory("dynamodb", account_id2) + + tab1 = f"table-{short_uid()}" + + # The CreateTable call gets forwarded to DDBLocal. + # The assertions below test whether DDBLocal correctly namespaces the tables. + + response1 = ddb_client1.create_table( + TableName=tab1, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + cleanups.append(lambda: ddb_client1.delete_table(TableName=tab1)) + assert ( + response1["TableDescription"]["TableArn"] + == f"arn:aws:dynamodb:ap-south-1:{account_id1}:table/{tab1}" + ) + + # Create table with the same name in a different region + response2 = ddb_client2.create_table( + TableName=tab1, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + cleanups.append(lambda: ddb_client2.delete_table(TableName=tab1)) + assert ( + response2["TableDescription"]["TableArn"] + == f"arn:aws:dynamodb:eu-central-1:{account_id1}:table/{tab1}" + ) + + # Create table with the same name in a different account + response3 = ddb_client3.create_table( + TableName=tab1, + KeySchema=[{"AttributeName": "Username", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "Username", "AttributeType": "S"}], + ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5}, + ) + cleanups.append(lambda: ddb_client3.delete_table(TableName=tab1)) + assert ( + response3["TableDescription"]["TableArn"] + == f"arn:aws:dynamodb:eu-central-1:{account_id2}:table/{tab1}" + ) + + ddb_client1.delete_table(TableName=tab1) + ddb_client2.delete_table(TableName=tab1) + + # CreateTable uses a different mechanism than other calls. Test that other mechanisms work; + # Ensure PutWriteItems work in multi-accounts + ddb_client3.batch_write_item( + RequestItems={ + tab1: [ + {"PutRequest": {"Item": {"Username": {"S": "Alice"}}}}, + {"PutRequest": {"Item": {"Username": {"S": "Bob"}}}}, + {"PutRequest": {"Item": {"Username": {"S": "Fred"}}}}, + ] + } + ) + # Ensure items are inserted to correct namespace resource + response = ddb_client3.describe_table(TableName=tab1) + assert response["Table"]["ItemCount"] == 3 + + @markers.aws.only_localstack + def test_multi_accounts_kinesis(self, client_factory): + """Test that multi-accounts work with external dependency, Kinesis Mock.""" + account_id1 = "420420420420" + account_id2 = "133713371337" + + kin_client1 = client_factory("kinesis", account_id1, region_name="ap-south-1") + kin_client2 = client_factory("kinesis", account_id1) + kin_client3 = client_factory("kinesis", account_id2) + + stream_name = f"stream-{short_uid()}" + + # In Kinesis, calls are forwarded to Kinesis Mock + # The assertions below test whether resources are correctly namespaced + + kin_client1.create_stream( + StreamName=stream_name, + ) + response1 = kin_client1.describe_stream( + StreamName=stream_name, + ) + assert ( + response1["StreamDescription"]["StreamARN"] + == f"arn:aws:kinesis:ap-south-1:{account_id1}:stream/{stream_name}" + ) + + # Create stream with the same name in a different region + kin_client2.create_stream( + StreamName=stream_name, + ) + response2 = kin_client2.describe_stream( + StreamName=stream_name, + ) + assert ( + response2["StreamDescription"]["StreamARN"] + == f"arn:aws:kinesis:eu-central-1:{account_id1}:stream/{stream_name}" + ) + + # Create stream with the same name in a different account + kin_client3.create_stream( + StreamName=stream_name, + ) + response3 = kin_client3.describe_stream( + StreamName=stream_name, + ) + assert ( + response3["StreamDescription"]["StreamARN"] + == f"arn:aws:kinesis:eu-central-1:{account_id2}:stream/{stream_name}" + ) diff --git a/tests/aws/test_multiregion.py b/tests/aws/test_multiregion.py new file mode 100644 index 0000000000000..73dd75db6aa0c --- /dev/null +++ b/tests/aws/test_multiregion.py @@ -0,0 +1,95 @@ +import base64 +import json + +import requests + +from localstack import config +from localstack.constants import PATH_USER_REQUEST +from localstack.services.apigateway.legacy.helpers import connect_api_gateway_to_sqs +from localstack.testing.pytest import markers +from localstack.utils.aws import arns, queries +from localstack.utils.common import short_uid, to_str + +REGION1 = "us-east-1" +REGION2 = "us-east-2" +REGION3 = "us-west-1" +REGION4 = "eu-central-1" + + +class TestMultiRegion: + @markers.aws.validated + def test_multi_region_sns(self, aws_client_factory): + sns_1 = aws_client_factory(region_name=REGION1).sns + sns_2 = aws_client_factory(region_name=REGION2).sns + len_1 = len(sns_1.list_topics()["Topics"]) + len_2 = len(sns_2.list_topics()["Topics"]) + + topic_name1 = "t-%s" % short_uid() + sns_1.create_topic(Name=topic_name1) + result1 = sns_1.list_topics()["Topics"] + result2 = sns_2.list_topics()["Topics"] + assert len(result1) == len_1 + 1 + assert len(result2) == len_2 + assert REGION1 in result1[0]["TopicArn"] + + topic_name2 = "t-%s" % short_uid() + sns_2.create_topic(Name=topic_name2) + result2 = sns_2.list_topics()["Topics"] + assert len(result2) == len_2 + 1 + assert REGION2 in result2[0]["TopicArn"] + + @markers.aws.needs_fixing + def test_multi_region_api_gateway(self, aws_client_factory, account_id): + gw_1 = aws_client_factory(region_name=REGION1).apigateway + gw_2 = aws_client_factory(region_name=REGION2).apigateway + gw_3 = aws_client_factory(region_name=REGION3).apigateway + sqs_1 = aws_client_factory(region_name=REGION3).sqs + + len_1 = len(gw_1.get_rest_apis()["items"]) + len_2 = len(gw_2.get_rest_apis()["items"]) + + api_name1 = "a-%s" % short_uid() + gw_1.create_rest_api(name=api_name1) + result1 = gw_1.get_rest_apis()["items"] + assert len(result1) == len_1 + 1 + assert len(gw_2.get_rest_apis()["items"]) == len_2 + + api_name2 = "a-%s" % short_uid() + gw_2.create_rest_api(name=api_name2) + result2 = gw_2.get_rest_apis()["items"] + assert len(gw_1.get_rest_apis()["items"]) == len_1 + 1 + assert len(result2) == len_2 + 1 + + api_name3 = "a-%s" % short_uid() + queue_name1 = "q-%s" % short_uid() + sqs_1.create_queue(QueueName=queue_name1) + queue_arn = arns.sqs_queue_arn(queue_name1, region_name=REGION3, account_id=account_id) + + result = connect_api_gateway_to_sqs( + api_name3, + stage_name="test", + queue_arn=queue_arn, + path="/data", + account_id=account_id, + region_name=REGION3, + ) + + api_id = result["id"] + result = gw_3.get_rest_apis()["items"] + assert result[-1]["name"] == api_name3 + + # post message and receive from SQS + url = self._gateway_request_url(api_id=api_id, stage_name="test", path="/data") + test_data = {"foo": "bar"} + result = requests.post(url, data=json.dumps(test_data)) + assert result.status_code == 200 + messages = queries.sqs_receive_message(queue_arn)["Messages"] + assert len(messages) == 1 + assert json.loads(to_str(base64.b64decode(to_str(messages[0]["Body"])))) == test_data + + def _gateway_request_url(self, api_id, stage_name, path): + pattern = "%s/restapis/{api_id}/{stage_name}/%s{path}" % ( + config.internal_service_url(), + PATH_USER_REQUEST, + ) + return pattern.format(api_id=api_id, stage_name=stage_name, path=path) diff --git a/tests/aws/test_multiregion.validation.json b/tests/aws/test_multiregion.validation.json new file mode 100644 index 0000000000000..49ff5b7ddc0a8 --- /dev/null +++ b/tests/aws/test_multiregion.validation.json @@ -0,0 +1,5 @@ +{ + "tests/aws/test_multiregion.py::TestMultiRegion::test_multi_region_sns": { + "last_validated_date": "2024-06-10T06:09:20+00:00" + } +} diff --git a/tests/aws/test_network_configuration.py b/tests/aws/test_network_configuration.py new file mode 100644 index 0000000000000..de6a7dcdbdd3b --- /dev/null +++ b/tests/aws/test_network_configuration.py @@ -0,0 +1,260 @@ +from localstack.constants import AWS_REGION_US_EAST_1 +from localstack.testing.pytest import markers +from localstack.utils.urls import localstack_host + +""" +This test file captures the _current_ state of returning URLs before making +sweeping changes. This is to ensure that the refactoring does not cause +external breaking behaviour. In the future we can update this test suite to +correspond to the behaviour we want, and we get a todo list of things to +change πŸ˜‚ +""" +import json + +import pytest +import requests +import xmltodict +from botocore.auth import SigV4Auth + +from localstack import config +from localstack.aws.api.lambda_ import Runtime +from localstack.utils.files import new_tmp_file, save_file +from localstack.utils.strings import short_uid + + +class TestOpenSearch: + """ + OpenSearch does not respect any customisations and just returns a domain with localhost.localstack.cloud in. + """ + + @markers.aws.only_localstack + def test_default_strategy( + self, opensearch_create_domain, assert_host_customisation, aws_client + ): + domain_name = f"domain-{short_uid()}" + opensearch_create_domain(DomainName=domain_name) + endpoint = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"][ + "Endpoint" + ] + + assert_host_customisation(endpoint) + + @markers.aws.only_localstack + @pytest.mark.skipif( + not config.in_docker(), reason="Replacement does not work in host mode, currently" + ) + def test_port_strategy( + self, + monkeypatch, + opensearch_create_domain, + assert_host_customisation, + aws_client, + ): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "port") + + domain_name = f"domain-{short_uid()}" + opensearch_create_domain(DomainName=domain_name) + endpoint = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"][ + "Endpoint" + ] + + assert_host_customisation(endpoint) + + @markers.aws.only_localstack + def test_path_strategy( + self, + monkeypatch, + opensearch_create_domain, + assert_host_customisation, + aws_client, + ): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "path") + + domain_name = f"domain-{short_uid()}" + opensearch_create_domain(DomainName=domain_name) + endpoint = aws_client.opensearch.describe_domain(DomainName=domain_name)["DomainStatus"][ + "Endpoint" + ] + + assert_host_customisation(endpoint) + + +class TestS3: + @markers.aws.only_localstack + def test_non_us_east_1_location( + self, s3_empty_bucket, cleanups, assert_host_customisation, aws_client_factory + ): + client_us_east_1 = aws_client_factory(region_name=AWS_REGION_US_EAST_1).s3 + bucket_name = f"bucket-{short_uid()}" + res = client_us_east_1.create_bucket( + Bucket=bucket_name, + CreateBucketConfiguration={ + "LocationConstraint": "eu-west-1", + }, + ) + + def cleanup(): + s3_empty_bucket(bucket_name) + client_us_east_1.delete_bucket(Bucket=bucket_name) + + cleanups.append(cleanup) + + assert_host_customisation(res["Location"]) + + @markers.aws.only_localstack + def test_multipart_upload(self, s3_bucket, assert_host_customisation, aws_client): + key_name = f"key-{short_uid()}" + upload_id = aws_client.s3.create_multipart_upload(Bucket=s3_bucket, Key=key_name)[ + "UploadId" + ] + part_etag = aws_client.s3.upload_part( + Bucket=s3_bucket, Key=key_name, Body=b"bytes", PartNumber=1, UploadId=upload_id + )["ETag"] + res = aws_client.s3.complete_multipart_upload( + Bucket=s3_bucket, + Key=key_name, + MultipartUpload={"Parts": [{"ETag": part_etag, "PartNumber": 1}]}, + UploadId=upload_id, + ) + + assert_host_customisation(res["Location"]) + + @markers.aws.only_localstack + def test_201_response(self, s3_bucket, assert_host_customisation, aws_client): + key_name = f"key-{short_uid()}" + body = "body" + presigned_request = aws_client.s3.generate_presigned_post( + Bucket=s3_bucket, + Key=key_name, + Fields={"success_action_status": "201"}, + Conditions=[{"bucket": s3_bucket}, ["eq", "$success_action_status", "201"]], + ) + files = {"file": ("my-file", body)} + res = requests.post( + presigned_request["url"], + data=presigned_request["fields"], + files=files, + verify=False, + ) + res.raise_for_status() + json_response = xmltodict.parse(res.content)["PostResponse"] + + assert_host_customisation(json_response["Location"]) + + +class TestSQS: + """ + Test all combinations of: + + * SQS_ENDPOINT_STRATEGY + * LOCALSTACK_HOST + """ + + @markers.aws.only_localstack + def test_off_strategy_without_external_port( + self, monkeypatch, sqs_create_queue, assert_host_customisation + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "off") + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + assert_host_customisation(queue_url) + assert queue_name in queue_url + + @markers.aws.only_localstack + def test_off_strategy_with_external_port( + self, monkeypatch, sqs_create_queue, assert_host_customisation + ): + external_port = 12345 + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "off") + monkeypatch.setattr( + config, + "LOCALSTACK_HOST", + config.HostAndPort(host=localstack_host().host, port=external_port), + ) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + assert_host_customisation(queue_url) + assert queue_name in queue_url + assert f":{external_port}" in queue_url + + @markers.aws.only_localstack + @pytest.mark.parametrize("strategy", ["standard", "domain"]) + def test_domain_based_strategies( + self, strategy, monkeypatch, sqs_create_queue, assert_host_customisation + ): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", strategy) + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + assert_host_customisation(queue_url) + assert queue_name in queue_url + + @markers.aws.only_localstack + def test_path_strategy(self, monkeypatch, sqs_create_queue, assert_host_customisation): + monkeypatch.setattr(config, "SQS_ENDPOINT_STRATEGY", "path") + + queue_name = f"queue-{short_uid()}" + queue_url = sqs_create_queue(QueueName=queue_name) + + assert_host_customisation(queue_url) + assert queue_name in queue_url + + +class TestLambda: + @markers.aws.only_localstack + def test_function_url(self, assert_host_customisation, create_lambda_function, aws_client): + function_name = f"function-{short_uid()}" + handler_code = "" + handler_file = new_tmp_file() + save_file(handler_file, handler_code) + + create_lambda_function( + func_name=function_name, + handler_file=handler_file, + runtime=Runtime.python3_12, + ) + + function_url = aws_client.lambda_.create_function_url_config( + FunctionName=function_name, + AuthType="NONE", + )["FunctionUrl"] + + assert_host_customisation(function_url) + + @pytest.mark.skip(reason="Not implemented for new provider (was tested for old provider)") + @markers.aws.only_localstack + def test_http_api_for_function_url( + self, assert_host_customisation, create_lambda_function, aws_http_client_factory + ): + function_name = f"function-{short_uid()}" + handler_code = "" + handler_file = new_tmp_file() + save_file(handler_file, handler_code) + + create_lambda_function( + func_name=function_name, + handler_file=handler_file, + runtime=Runtime.python3_12, + ) + + client = aws_http_client_factory("lambda", signer_factory=SigV4Auth) + url = f"/2021-10-31/functions/{function_name}/url" + r = client.post( + url, + data=json.dumps( + { + "AuthType": "NONE", + } + ), + params={"Qualifier": "$LATEST"}, + ) + r.raise_for_status() + + function_url = r.json()["FunctionUrl"] + + assert_host_customisation(function_url) diff --git a/tests/aws/test_notifications.py b/tests/aws/test_notifications.py new file mode 100644 index 0000000000000..984618a89844e --- /dev/null +++ b/tests/aws/test_notifications.py @@ -0,0 +1,64 @@ +from localstack.testing.pytest import markers +from localstack.utils.common import retry, short_uid + +PUBLICATION_TIMEOUT = 1 +PUBLICATION_RETRIES = 20 + + +class TestNotifications: + @markers.aws.validated + def test_sqs_queue_names(self, aws_client): + queue_name = f"{short_uid()}.fifo" + + # make sure we can create *.fifo queues + try: + queue = aws_client.sqs.create_queue( + QueueName=queue_name, Attributes={"FifoQueue": "true"} + ) + assert queue_name in queue["QueueUrl"] + finally: + aws_client.sqs.delete_queue(QueueUrl=queue["QueueUrl"]) + + @markers.aws.validated + def test_sns_to_sqs( + self, + sqs_create_queue, + sns_create_topic, + sns_allow_topic_sqs_queue, + sqs_receive_num_messages, + aws_client, + ): + # create topic and queue + queue_url = sqs_create_queue() + topic_info = sns_create_topic() + topic_arn = topic_info["TopicArn"] + # subscribe SQS to SNS, publish message + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + subscription = aws_client.sns.subscribe( + TopicArn=topic_arn, + Protocol="sqs", + Endpoint=queue_arn, + ) + sns_allow_topic_sqs_queue( + sqs_queue_url=queue_url, sqs_queue_arn=queue_arn, sns_topic_arn=topic_arn + ) + test_value = short_uid() + aws_client.sns.publish( + TopicArn=topic_arn, + Message="test message for SQS", + MessageAttributes={"attr1": {"DataType": "String", "StringValue": test_value}}, + ) + + def assert_message(): + # receive, and delete message from SQS + expected = {"attr1": {"Type": "String", "Value": test_value}} + messages = sqs_receive_num_messages(queue_url, expected_messages=1) + assert messages[0]["TopicArn"] == topic_arn + assert expected == messages[0]["MessageAttributes"] + + retry(assert_message, retries=PUBLICATION_RETRIES, sleep=PUBLICATION_TIMEOUT) + + # cleanup + aws_client.sns.unsubscribe(SubscriptionArn=subscription["SubscriptionArn"]) diff --git a/tests/aws/test_serverless.py b/tests/aws/test_serverless.py new file mode 100644 index 0000000000000..0a1b1505faea0 --- /dev/null +++ b/tests/aws/test_serverless.py @@ -0,0 +1,250 @@ +import json +import logging +import os + +import pytest + +from localstack.testing.aws.util import is_aws_cloud +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID +from localstack.testing.pytest import markers +from localstack.utils.aws import arns +from localstack.utils.common import retry, run +from localstack.utils.testutil import get_lambda_log_events + + +def get_base_dir() -> str: + return os.path.join(os.path.dirname(__file__), "serverless") + + +LOG = logging.getLogger(__name__) + + +class TestServerless: + @pytest.fixture(scope="class") + def delenv(self): + # Workaround for the inability to use the standard `monkeypatch` fixture in `class` scope + from _pytest.monkeypatch import MonkeyPatch + + mkypatch = MonkeyPatch() + yield mkypatch.delenv + mkypatch.undo() + + @pytest.fixture(scope="class") + def get_deployed_stage(self): + return "dev" if is_aws_cloud() else "local" + + @pytest.fixture(scope="class") + def setup_and_teardown(self, aws_client, region_name, delenv, get_deployed_stage): + if not is_aws_cloud(): + delenv("AWS_PROFILE", raising=False) + base_dir = get_base_dir() + if not os.path.exists(os.path.join(base_dir, "node_modules")): + # install dependencies + run(["npm", "install"], cwd=base_dir) + + # list apigateway before sls deployment + apis = aws_client.apigateway.get_rest_apis()["items"] + existing_api_ids = [api["id"] for api in apis] + + # deploy serverless app + if is_aws_cloud(): + run( + ["npm", "run", "deploy-aws", "--", f"--region={region_name}"], + cwd=base_dir, + ) + else: + run( + ["npm", "run", "deploy", "--", f"--region={region_name}"], + cwd=base_dir, + env_vars={"AWS_ACCESS_KEY_ID": TEST_AWS_ACCESS_KEY_ID}, + ) + + yield existing_api_ids + + try: + # cleanup s3 bucket content + bucket_name = f"testing-bucket-sls-test-{get_deployed_stage}-{region_name}" + response = aws_client.s3.list_objects_v2(Bucket=bucket_name) + objects = [{"Key": obj["Key"]} for obj in response.get("Contents", [])] + if objects: + aws_client.s3.delete_objects( + Bucket=bucket_name, + Delete={"Objects": objects}, + ) + # TODO the cleanup still fails due to inability to find ECR service in community + command = "undeploy-aws" if is_aws_cloud() else "undeploy" + run(["npm", "run", command, "--", f"--region={region_name}"], cwd=base_dir) + except Exception: + LOG.error("Unable to clean up serverless stack") + + @markers.skip_offline + @markers.aws.validated + def test_event_rules_deployed(self, aws_client, setup_and_teardown): + events = aws_client.events + rules = events.list_rules()["Rules"] + + rule = ([r for r in rules if r["Name"] == "sls-test-cf-event"] or [None])[0] + assert rule + assert "Arn" in rule + pattern = json.loads(rule["EventPattern"]) + assert ["aws.cloudformation"] == pattern["source"] + assert "detail-type" in pattern + + event_bus_name = "customBus" + rule = events.list_rules(EventBusName=event_bus_name)["Rules"][0] + assert rule + assert {"source": ["customSource"]} == json.loads(rule["EventPattern"]) + + @markers.skip_offline + @markers.aws.validated + def test_dynamodb_stream_handler_deployed( + self, aws_client, setup_and_teardown, get_deployed_stage + ): + function_name = f"sls-test-{get_deployed_stage}-dynamodbStreamHandler" + table_name = "Test" + + lambda_client = aws_client.lambda_ + dynamodb_client = aws_client.dynamodb + + resp = lambda_client.list_functions() + function = [fn for fn in resp["Functions"] if fn["FunctionName"] == function_name][0] + assert "handler.processItem" == function["Handler"] + + resp = lambda_client.list_event_source_mappings(FunctionName=function_name) + events = resp["EventSourceMappings"] + assert 1 == len(events) + event_source_arn = events[0]["EventSourceArn"] + + resp = dynamodb_client.describe_table(TableName=table_name) + assert event_source_arn == resp["Table"]["LatestStreamArn"] + + @markers.skip_offline + @markers.aws.validated + @pytest.mark.skip(reason="flaky") + def test_kinesis_stream_handler_deployed( + self, aws_client, setup_and_teardown, get_deployed_stage + ): + function_name = f"sls-test-{get_deployed_stage}-kinesisStreamHandler" + function_name2 = f"sls-test-{get_deployed_stage}-kinesisConsumerHandler" + stream_name = "KinesisTestStream" + + lambda_client = aws_client.lambda_ + kinesis_client = aws_client.kinesis + + resp = lambda_client.list_functions() + function = [fn for fn in resp["Functions"] if fn["FunctionName"] == function_name][0] + assert "handler.processKinesis" == function["Handler"] + + resp = lambda_client.list_event_source_mappings(FunctionName=function_name) + mappings = resp["EventSourceMappings"] + assert len(mappings) == 1 + event_source_arn = mappings[0]["EventSourceArn"] + + resp = kinesis_client.describe_stream(StreamName=stream_name) + assert event_source_arn == resp["StreamDescription"]["StreamARN"] + + # assert that stream consumer is properly connected and Lambda gets invoked + def assert_invocations(): + events = get_lambda_log_events(function_name2, logs_client=aws_client.logs) + assert len(events) == 1 + + kinesis_client.put_record(StreamName=stream_name, Data=b"test123", PartitionKey="key1") + retry(assert_invocations, sleep=2, retries=20) + + @markers.skip_offline + @markers.aws.needs_fixing + def test_queue_handler_deployed( + self, aws_client, account_id, region_name, setup_and_teardown, get_deployed_stage + ): + function_name = f"sls-test-{get_deployed_stage}-queueHandler" + queue_name = f"sls-test-{get_deployed_stage}-CreateQueue" + + lambda_client = aws_client.lambda_ + sqs_client = aws_client.sqs + + resp = lambda_client.list_functions() + function = [fn for fn in resp["Functions"] if fn["FunctionName"] == function_name][0] + assert "handler.createQueue" == function["Handler"] + + resp = lambda_client.list_event_source_mappings(FunctionName=function_name) + events = resp["EventSourceMappings"] + assert 1 == len(events) + event_source_arn = events[0]["EventSourceArn"] + + queue_arn = arns.sqs_queue_arn(queue_name, account_id=account_id, region_name=region_name) + + assert event_source_arn == queue_arn + queue_url = sqs_client.get_queue_url( + QueueName=queue_name, QueueOwnerAWSAccountId=account_id + )["QueueUrl"] + + result = sqs_client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=[ + "RedrivePolicy", + ], + ) + redrive_policy = json.loads(result["Attributes"]["RedrivePolicy"]) + assert 3 == redrive_policy["maxReceiveCount"] + + @markers.skip_offline + @markers.aws.validated + def test_lambda_with_configs_deployed(self, aws_client, setup_and_teardown, get_deployed_stage): + function_name = f"sls-test-{get_deployed_stage}-test" + + lambda_client = aws_client.lambda_ + + resp = lambda_client.list_functions() + function = [fn for fn in resp["Functions"] if fn["FunctionName"] == function_name][0] + assert "Version" in function + version = function["Version"] + + resp = lambda_client.get_function_event_invoke_config( + FunctionName=function_name, Qualifier=version + ) + assert 2 == resp.get("MaximumRetryAttempts") + assert 7200 == resp.get("MaximumEventAgeInSeconds") + + @markers.skip_offline + @markers.aws.needs_fixing + def test_apigateway_deployed( + self, aws_client, account_id, region_name, setup_and_teardown, get_deployed_stage + ): + function_name = f"sls-test-{get_deployed_stage}-router" + existing_api_ids = setup_and_teardown + + lambda_client = aws_client.lambda_ + + resp = lambda_client.list_functions() + function = [fn for fn in resp["Functions"] if fn["FunctionName"] == function_name][0] + assert "handler.createHttpRouter" == function["Handler"] + + apigw_client = aws_client.apigateway + apis = apigw_client.get_rest_apis()["items"] + api_ids = [api["id"] for api in apis if api["id"] not in existing_api_ids] + assert 1 == len(api_ids) + + resources = apigw_client.get_resources(restApiId=api_ids[0])["items"] + proxy_resources = [res for res in resources if res["path"] == "/foo/bar"] + assert 1 == len(proxy_resources) + + proxy_resource = proxy_resources[0] + for method in ["DELETE", "POST", "PUT"]: + assert method in proxy_resource["resourceMethods"] + resource_method = proxy_resource["resourceMethods"][method] + # TODO - needs fixing: this assertion doesn't hold for AWS, as there is no "methodIntegration" key + # on AWS -> "resourceMethods": {'DELETE': {}, 'POST': {}, 'PUT': {}} + assert ( + arns.lambda_function_arn(function_name, account_id, region_name) + in resource_method["methodIntegration"]["uri"] + ) + + @markers.skip_offline + @markers.aws.validated + def test_s3_bucket_deployed( + self, aws_client, setup_and_teardown, region_name, get_deployed_stage + ): + s3_client = aws_client.s3 + bucket_name = f"testing-bucket-sls-test-{get_deployed_stage}-{region_name}" + response = s3_client.head_bucket(Bucket=bucket_name) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 diff --git a/tests/aws/test_serverless.validation.json b/tests/aws/test_serverless.validation.json new file mode 100644 index 0000000000000..efa20a55d879b --- /dev/null +++ b/tests/aws/test_serverless.validation.json @@ -0,0 +1,20 @@ +{ + "tests/aws/test_serverless.py::TestServerless::test_dynamodb_stream_handler_deployed": { + "last_validated_date": "2024-09-10T07:58:51+00:00" + }, + "tests/aws/test_serverless.py::TestServerless::test_event_rules_deployed": { + "last_validated_date": "2024-09-10T07:58:50+00:00" + }, + "tests/aws/test_serverless.py::TestServerless::test_kinesis_stream_handler_deployed": { + "last_validated_date": "2024-09-09T14:19:10+00:00" + }, + "tests/aws/test_serverless.py::TestServerless::test_lambda_with_configs_deployed": { + "last_validated_date": "2024-09-10T07:58:53+00:00" + }, + "tests/aws/test_serverless.py::TestServerless::test_queue_handler_deployed": { + "last_validated_date": "2024-09-10T07:58:52+00:00" + }, + "tests/aws/test_serverless.py::TestServerless::test_s3_bucket_deployed": { + "last_validated_date": "2024-09-10T07:58:54+00:00" + } +} diff --git a/tests/aws/test_terraform.py b/tests/aws/test_terraform.py new file mode 100644 index 0000000000000..e6f8656addd3a --- /dev/null +++ b/tests/aws/test_terraform.py @@ -0,0 +1,246 @@ +import os +import re +import threading + +import pytest + +from localstack.packages.terraform import terraform_package +from localstack.testing.config import ( + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_REGION_NAME, + TEST_AWS_SECRET_ACCESS_KEY, +) +from localstack.testing.pytest import markers +from localstack.utils.common import is_command_available, rm_rf, run, start_worker_thread + +# TODO: remove all of these + +BUCKET_NAME = "tf-bucket" +QUEUE_NAME = "tf-queue" +QUEUE_ARN = "arn:aws:sqs:us-east-1:{account_id}:tf-queue" + +# lambda Testing Variables +LAMBDA_NAME = "tf-lambda" +LAMBDA_ARN = "arn:aws:lambda:us-east-1:{account_id}:function:{lambda_name}" +LAMBDA_HANDLER = "index.handler" +LAMBDA_RUNTIME = "python3.8" +LAMBDA_ROLE = "arn:aws:iam::{account_id}:role/iam_for_lambda" + +INIT_LOCK = threading.RLock() + +# set after calling install() +TERRAFORM_BIN = None + + +def check_terraform_version(): + if not is_command_available(TERRAFORM_BIN): + return False, None + + ver_string = run([TERRAFORM_BIN, "-version"]) + ver_string = re.search(r"v(\d+\.\d+\.\d+)", ver_string).group(1) + if ver_string is None: + return False, None + return True, ver_string + + +@pytest.fixture(scope="module", autouse=True) +def setup_test(account_id, region_name): + with INIT_LOCK: + available, version = check_terraform_version() + + if not available: + msg = "could not find a compatible version of terraform" + if version: + msg += f" (version = {version})" + else: + msg += " (command not found)" + + return pytest.skip(msg) + + env_vars = { + "AWS_ACCESS_KEY_ID": account_id, + "AWS_SECRET_ACCESS_KEY": account_id, + "AWS_REGION": region_name, + } + + run( + "cd %s; %s apply -input=false tfplan" % (get_base_dir(), TERRAFORM_BIN), + env_vars=env_vars, + ) + + yield + + # clean up + run("cd %s; %s destroy -auto-approve" % (get_base_dir(), TERRAFORM_BIN), env_vars=env_vars) + + +def get_base_dir(): + return os.path.join(os.path.dirname(__file__), "terraform") + + +# TODO: replace "clouddrove/api-gateway/aws" with normal apigateway module and update terraform +# TODO: rework this setup for multiple (potentially parallel) terraform tests by providing variables (see .auto.tfvars) +# TODO: fetch generated ARNs from terraform instead of static/building ARNs +@pytest.mark.skip(reason="disabled until further notice due to flakiness and lacking quality") +class TestTerraform: + @classmethod + def init_async(cls): + def _run(*args): + with INIT_LOCK: + terraform_package.install() + global TERRAFORM_BIN + TERRAFORM_BIN = terraform_package.get_installer().get_executable_path() + base_dir = get_base_dir() + env_vars = { + "AWS_ACCESS_KEY_ID": TEST_AWS_ACCESS_KEY_ID, + "AWS_SECRET_ACCESS_KEY": TEST_AWS_SECRET_ACCESS_KEY, + "AWS_REGION": TEST_AWS_REGION_NAME, + } + if not os.path.exists(os.path.join(base_dir, ".terraform", "plugins")): + run(f"cd {base_dir}; {TERRAFORM_BIN} init -input=false", env_vars=env_vars) + # remove any cache files from previous runs + for tf_file in [ + "tfplan", + "terraform.tfstate", + "terraform.tfstate.backup", + ]: + rm_rf(os.path.join(base_dir, tf_file)) + # create TF plan + run( + f"cd {base_dir}; {TERRAFORM_BIN} plan -out=tfplan -input=false", + env_vars=env_vars, + ) + + start_worker_thread(_run) + + @markers.skip_offline + @markers.aws.needs_fixing + def test_bucket_exists(self, aws_client): + response = aws_client.s3.head_bucket(Bucket=BUCKET_NAME) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + cors = { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "PUT", "POST"], + "AllowedOrigins": ["*"], + "ExposeHeaders": ["ETag", "x-amz-version-id"], + "MaxAgeSeconds": 3000, + } + + response = aws_client.s3.get_bucket_cors(Bucket=BUCKET_NAME) + assert response["CORSRules"][0] == cors + + response = aws_client.s3.get_bucket_versioning(Bucket=BUCKET_NAME) + assert response["Status"] == "Enabled" + + @markers.skip_offline + @markers.aws.needs_fixing + def test_sqs(self, aws_client): + queue_url = aws_client.sqs.get_queue_url(QueueName=QUEUE_NAME)["QueueUrl"] + response = aws_client.sqs.get_queue_attributes(QueueUrl=queue_url, AttributeNames=["All"]) + + assert response["Attributes"]["DelaySeconds"] == "90" + assert response["Attributes"]["MaximumMessageSize"] == "2048" + assert response["Attributes"]["MessageRetentionPeriod"] == "86400" + assert response["Attributes"]["ReceiveMessageWaitTimeSeconds"] == "10" + + @markers.skip_offline + @markers.aws.needs_fixing + def test_lambda(self, aws_client, account_id): + response = aws_client.lambda_.get_function(FunctionName=LAMBDA_NAME) + assert response["Configuration"]["FunctionName"] == LAMBDA_NAME + assert response["Configuration"]["Handler"] == LAMBDA_HANDLER + assert response["Configuration"]["Runtime"] == LAMBDA_RUNTIME + assert response["Configuration"]["Role"] == LAMBDA_ROLE.format(account_id=account_id) + + @markers.skip_offline + @markers.aws.needs_fixing + def test_event_source_mapping(self, aws_client, account_id): + queue_arn = QUEUE_ARN.format(account_id=account_id) + lambda_arn = LAMBDA_ARN.format(account_id=account_id, lambda_name=LAMBDA_NAME) + all_mappings = aws_client.lambda_.list_event_source_mappings( + EventSourceArn=queue_arn, FunctionName=LAMBDA_NAME + ) + function_mapping = all_mappings.get("EventSourceMappings")[0] + assert function_mapping["FunctionArn"] == lambda_arn + assert function_mapping["EventSourceArn"] == queue_arn + + @markers.skip_offline + @pytest.mark.skip(reason="flaky") + @markers.aws.needs_fixing + def test_apigateway(self, aws_client): + rest_apis = aws_client.apigateway.get_rest_apis() + + rest_id = None + for rest_api in rest_apis["items"]: + if rest_api["name"] == "test-tf-apigateway": + rest_id = rest_api["id"] + break + + assert rest_id + resources = aws_client.apigateway.get_resources(restApiId=rest_id)["items"] + + # We always have 1 default root resource (with path "/") + assert len(resources) == 3 + + res1 = [r for r in resources if r.get("pathPart") == "mytestresource"] + assert res1 + assert res1[0]["path"] == "/mytestresource" + assert len(res1[0]["resourceMethods"]) == 2 + assert res1[0]["resourceMethods"]["GET"]["methodIntegration"]["type"] == "MOCK" + + res2 = [r for r in resources if r.get("pathPart") == "mytestresource1"] + assert res2 + assert res2[0]["path"] == "/mytestresource1" + assert len(res2[0]["resourceMethods"]) == 2 + assert res2[0]["resourceMethods"]["GET"]["methodIntegration"]["type"] == "AWS_PROXY" + assert res2[0]["resourceMethods"]["GET"]["methodIntegration"]["uri"] + + @markers.skip_offline + @markers.aws.needs_fixing + def test_route53(self, aws_client): + response = aws_client.route53.create_hosted_zone(Name="zone123", CallerReference="ref123") + assert response["ResponseMetadata"]["HTTPStatusCode"] == 201 + change_id = response.get("ChangeInfo", {}).get("Id", "change123") + + response = aws_client.route53.get_change(Id=change_id) + assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 + + @markers.skip_offline + @markers.aws.needs_fixing + def test_acm(self, aws_client): + certs = aws_client.acm.list_certificates()["CertificateSummaryList"] + certs = [c for c in certs if c.get("DomainName") == "example.com"] + assert len(certs) == 1 + + @markers.skip_offline + @pytest.mark.skip(reason="flaky") + @markers.aws.needs_fixing + def test_apigateway_escaped_policy(self, aws_client): + rest_apis = aws_client.apigateway.get_rest_apis() + + service_apis = [] + + for rest_api in rest_apis["items"]: + if rest_api["name"] == "service_api": + service_apis.append(rest_api) + + assert len(service_apis) == 1 + + @markers.skip_offline + @markers.aws.needs_fixing + def test_dynamodb(self, aws_client): + def _table_exists(tablename, dynamotables): + return any(name for name in dynamotables["TableNames"] if name == tablename) + + tables = aws_client.dynamodb.list_tables() + assert _table_exists("tf_dynamotable1", tables) + assert _table_exists("tf_dynamotable2", tables) + assert _table_exists("tf_dynamotable3", tables) + + @markers.skip_offline + @markers.aws.needs_fixing + def test_security_groups(self, aws_client): + rules = aws_client.ec2.describe_security_groups(MaxResults=100)["SecurityGroups"] + matching = [r for r in rules if r["Description"] == "TF SG with ingress / egress rules"] + assert matching diff --git a/tests/aws/test_validate.py b/tests/aws/test_validate.py new file mode 100644 index 0000000000000..11d8197beb352 --- /dev/null +++ b/tests/aws/test_validate.py @@ -0,0 +1,88 @@ +from localstack.testing.pytest import markers + +"""This is to demonstrate how to write tests for server-side request validation. Ideally these tests are part of the +service test suite.""" +import pytest +from botocore.auth import SigV4Auth + + +@pytest.mark.skip(reason="there is no generalized way of server-side request validation yet") +class TestMissingParameter: + @markers.aws.validated + def test_opensearch(self, aws_http_client_factory): + client = aws_http_client_factory("es", signer_factory=SigV4Auth) + + response = client.post( + "/2021-01-01/opensearch/domain", + data='{"foobar": "bazed"}', + ) + + assert ( + response.text + == '{"message":"1 validation error detected: Value null at \'domainName\' failed to satisfy constraint: ' + 'Member must not be null"}' + ) + + @markers.aws.validated + def test_sns(self, aws_http_client_factory): + client = aws_http_client_factory("sns", region="us-east-1") + + response = client.post( + "/?Action=CreatePlatformApplication&Name=Foobar&Platform=Bar", + ) + + assert "ValidationError" in response.text + assert ( + "1 validation error detected: Value null at 'attributes' failed to satisfy constraint: Member " + "must not be null" in response.text + ) + + @markers.aws.validated + def test_elasticache(self, aws_http_client_factory): + client = aws_http_client_factory("elasticache") + + response = client.post( + "/", + params={ + "Action": "CreateCacheCluster", + }, + ) + + assert "InvalidParameterValue" in response.text + assert ( + "The parameter CacheClusterIdentifier must be provided and must not be blank." + in response.text + ) + + @markers.aws.validated + def test_sqs_create_queue(self, aws_http_client_factory): + client = aws_http_client_factory("sqs") + + response = client.post( + "/", + params={ + "Action": "CreateQueue", + "FooBar": "baz", + }, + ) + + assert "InvalidParameterValue" in response.text + assert ( + "Value for parameter QueueName is invalid. Reason: Must specify a queue name." + in response.text + ) + + @markers.aws.validated + def test_sqs_send_message(self, aws_http_client_factory, sqs_queue): + client = aws_http_client_factory("sqs") + + response = client.post( + "/", + params={"Action": "SetQueueAttributes", "Version": "2012-11-05", "QueueUrl": sqs_queue}, + ) + + assert "MissingParameter" in response.text + assert ( + "The request must contain the parameter Attribute.Name." + in response.text + ) diff --git a/tests/bin/README.md b/tests/bin/README.md new file mode 100644 index 0000000000000..870357ba103ee --- /dev/null +++ b/tests/bin/README.md @@ -0,0 +1,58 @@ +# BATS bash tests + +The tests in this folder are not regular Pytest tests. +They are implemented with [BATS](https://github.com/bats-core/bats-core) to test scripts in the `bin` folder of this repo. + +## Prerequisites + +**Install BATS**: If you don't have BATS installed, you need to install it first. On a Unix-like system, you can usually install it using a package manager. + +For Debian-based systems (e.g., Ubuntu): +```bash +sudo apt-get update +sudo apt-get install bats +``` + +For macOS using Homebrew: +```bash +brew install bats-core +``` + +Alternatively, you can install BATS manually by cloning the repository and adding the `bin` folder to your `PATH` environment variable. +```bash +git clone https://github.com/bats-core/bats-core.git +cd bats-core +sudo ./install.sh /usr/local +``` + +## Writing tests + +Create a file with a `.bats` extension, for example, `test_example.bats`. Here’s a simple test file: + +```bash +#!/usr/bin/env bats +@test "test description" { + run echo "hello" + [ "$status" -eq 0 ] + [ "$output" = "hello" ] +} +``` + +## Running Tests +To run the tests, simply execute the bats command followed by the test file or directory containing test files: + +```bash +bats test_example.bats +``` + +You can also run all `.bats` files in a directory: + +```bash +bats tests/bin +``` + +To run with some debug information, you can use this: + +```bash +bats --trace --verbose-run --print-output-on-failure -r tests/bin/ +``` \ No newline at end of file diff --git a/tests/bin/test.docker-helper.bats b/tests/bin/test.docker-helper.bats new file mode 100755 index 0000000000000..d05277c98e5d5 --- /dev/null +++ b/tests/bin/test.docker-helper.bats @@ -0,0 +1,268 @@ +#!/usr/bin/env bats + +setup_file() { + # mock the docker binary and just print the command + function docker() { + echo "docker $@" + } + export -f docker + + # mock the git binary and just print the command + function git() { + case $1 in + "branch") + echo "main" + ;; + "remote") + echo "origin git@github.com:localstack/localstack.git (push)" + ;; + *) + echo "git $@" + esac + } + export -f git + + # mock python3 / pip + setuptools_scm + function python3() { + case $2 in + "setuptools_scm") + # setuptools_scm returns out test version + echo "$TEST_SPECIFIC_VERSION" + ;; + "pip") + # pip exits with $TEST_PIP_EXIT_CODE + echo "python3 $@" + if [ -n "${TEST_PIP_FAIL-}" ]; then + return 1 + fi + ;; + *) + # everything else just prints the command + echo "python3 $@" + esac + } + export -f python3 +} + +@test "help command output" { + run bin/docker-helper.sh help + [ "$status" -eq 0 ] +} + +@test "non-zero exit on unknown command" { + run bin/docker-helper.sh unknown + [ "$status" -ne 0 ] +} + +@test "build fails on missing IMAGE_NAME" { + run bin/docker-helper.sh build + [ "$status" -ne 0 ] +} + +# build + +@test "build creates image from custom Dockerfile" { + export IMAGE_NAME="localstack/test" + export DOCKERFILE="tests/bin/files/Dockerfile" + export TEST_SPECIFIC_VERSION="3.6.1.dev45" + run bin/docker-helper.sh build + [ "$status" -eq 0 ] + [[ "$output" =~ "-f tests/bin/files/Dockerfile" ]] +} + +# save + +@test "save fails without platform" { + export IMAGE_NAME="localstack/test" + run bin/docker-helper.sh save + [ "$status" -ne 0 ] +} + +@test "save calls docker save" { + export IMAGE_NAME="localstack/test" + export PLATFORM="amd64" + export IMAGE_FILENAME="$(mktemp)" + export GITHUB_OUTPUT="$(mktemp)" + + run bin/docker-helper.sh save + [ "$status" -eq 0 ] + # our mocking actually only exports the command to stdin, so the IMAGE_FILENAME will actually contain the command + cat $IMAGE_FILENAME | grep -v "docker save" + cat $IMAGE_FILENAME | grep -v "$IMAGE_NAME" + cat $IMAGE_FILENAME | grep -v "docker-image-$PLATFORM.tar.gz" + # check that it sets the github output + cat $GITHUB_OUTPUT | grep -v "SAVED_IMAGE_FILENAME=" +} + +# load + +@test "load fails without platform" { + export IMAGE_NAME="localstack/test" + run bin/docker-helper.sh load + [ "$status" -ne 0 ] +} + +@test "load calls docker load" { + export IMAGE_NAME="localstack/test" + export PLATFORM="amd64" + run bin/docker-helper.sh load + [ "$status" -eq 0 ] + # check for parts of the output + [[ "$output" =~ "docker load" ]] + [[ "$output" =~ "docker-image-$PLATFORM.tar" ]] +} + +# push + +@test "push fails on non-default branch" { + export MAIN_BRANCH="non-existing-branch" + export IMAGE_NAME="localstack/test" + export PLATFORM=amd64 + run bin/docker-helper.sh push + [ "$status" -ne 0 ] + [[ "$output" =~ "is not non-existing-branch" ]] +} + +@test "push fails without PLATFORM" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + run bin/docker-helper.sh push + [ "$status" -ne 0 ] + [[ "$output" =~ "PLATFORM is missing" ]] +} + +@test "push pushes built image wo versions" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + export PLATFORM=arm64 + export TEST_SPECIFIC_VERSION="3.6.1.dev45" + run bin/docker-helper.sh push + [ "$status" -eq 0 ] + [[ "$output" =~ "docker push $IMAGE_NAME:latest-$PLATFORM" ]] + [[ "$output" =~ "Not pushing any other tags" ]] + ! [[ "$output" =~ "docker push $IMAGE_NAME:3-$PLATFORM" ]] + ! [[ "$output" =~ "docker push $IMAGE_NAME:3.6-$PLATFORM" ]] + ! [[ "$output" =~ "docker push $IMAGE_NAME:3.6.1-$PLATFORM" ]] + ! [[ "$output" =~ "docker push $IMAGE_NAME:stable-$PLATFORM" ]] +} + +@test "push pushes built image w versions" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + export PLATFORM=arm64 + export TEST_SPECIFIC_VERSION="4.0.0" + run bin/docker-helper.sh push + [ "$status" -eq 0 ] + [[ "$output" =~ "docker push $IMAGE_NAME:latest-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:4-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:4.0-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:4.0.0-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:stable-$PLATFORM" ]] +} + +@test "push pushes built image w custom IMAGE_TAG and DEFAULT_TAG" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + export PLATFORM=arm64 + export DEFAULT_TAG="custom-default-tag" + export IMAGE_TAG=1.2.3 + export TEST_SPECIFIC_VERSION="4.0.0" + run bin/docker-helper.sh push + [ "$status" -eq 0 ] + [[ "$output" =~ "docker push $IMAGE_NAME:custom-default-tag-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:latest-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:1-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:1.2-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:1.2.3-$PLATFORM" ]] + [[ "$output" =~ "docker push $IMAGE_NAME:stable-$PLATFORM" ]] +} + +# push-manifests + +@test "push-manifests pushes built image wo versions" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + export TEST_SPECIFIC_VERSION="3.6.1.dev45" + run bin/docker-helper.sh push-manifests + [ "$status" -eq 0 ] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:latest" ]] + [[ "$output" =~ "Not pushing any other tags" ]] + ! [[ "$output" =~ "docker manifest push $IMAGE_NAME:3" ]] + ! [[ "$output" =~ "docker manifest push $IMAGE_NAME:3.6" ]] + ! [[ "$output" =~ "docker manifest push $IMAGE_NAME:3.6.1" ]] + ! [[ "$output" =~ "docker manifest push $IMAGE_NAME:stable" ]] +} + +@test "push-manifests pushes built image w versions" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + export TEST_SPECIFIC_VERSION="4.0.0" + run bin/docker-helper.sh push-manifests + [ "$status" -eq 0 ] + [[ "$output" =~ "docker manifest create $IMAGE_NAME:latest --amend $IMAGE_NAME:latest-amd64 --amend $IMAGE_NAME:latest-arm64" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:$IMAGE_TAG" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:latest" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:4" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:4.0" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:4.0.0" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:stable" ]] +} + +@test "push-manifests pushes built image w custom IMAGE_TAG and DEFAULT_TAG" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + export DEFAULT_TAG="custom-default-tag" + export TEST_SPECIFIC_VERSION="4.0.0" + export IMAGE_TAG=1.2.3 + run bin/docker-helper.sh push-manifests + [ "$status" -eq 0 ] + [[ "$output" =~ "docker manifest create $IMAGE_NAME:custom-default-tag --amend $IMAGE_NAME:custom-default-tag-amd64 --amend $IMAGE_NAME:custom-default-tag-arm64" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:$IMAGE_TAG" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:custom-default-tag" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:latest" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:1" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:1.2" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:1.2.3" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:stable" ]] +} + +@test "push-manifests always pushes latest tag w versions" { + export IMAGE_NAME="localstack/test" + export MAIN_BRANCH="main" + export DOCKER_USERNAME=test + export DOCKER_PASSWORD=test + export DEFAULT_TAG="dev" + export TEST_SPECIFIC_VERSION="4.0.0" + run bin/docker-helper.sh push-manifests + [ "$status" -eq 0 ] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:stable" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:latest" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:dev" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:4" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:4.0" ]] + [[ "$output" =~ "docker manifest push $IMAGE_NAME:4.0.0" ]] +} + + +@test "cmd-build throws error when setuptools-scm is not installed" { + export TEST_PIP_FAIL=1 + export IMAGE_NAME="localstack/test" + + run bin/docker-helper.sh build + [ "$status" -eq 1 ] + [[ "$output" =~ "ERROR" ]] +} diff --git a/tests/bin/test.release-helper.bats b/tests/bin/test.release-helper.bats new file mode 100644 index 0000000000000..0d95096929d61 --- /dev/null +++ b/tests/bin/test.release-helper.bats @@ -0,0 +1,116 @@ +#!/usr/bin/env bats + +setup_file() { + # mock the git binary and just print the command + function git() { + echo "git $@" + } + export -f git + + # mock python3 / pip + setuptools_scm + function python3() { + case $2 in + "setuptools_scm") + # setuptools_scm returns out test version + echo "$TEST_SPECIFIC_VERSION" + ;; + "pip") + # pip exits with $TEST_PIP_EXIT_CODE + echo "python3 $@" + if [ -n "${TEST_PIP_FAIL-}" ]; then + return 1 + fi + ;; + *) + # everything else just prints the command + echo "python3 $@" + esac + } + export -f python3 +} + +_setup_tmp_dependency_file() { + TMP_DIR=$(mktemp -d -t "release-helper-tmp-dep-file-XXXX") + cp $BATS_TEST_DIRNAME/../../pyproject.toml $TMP_DIR + echo "$TMP_DIR/pyproject.toml" +} + +@test "help command output" { + run bin/release-helper.sh help + + [ "$status" -eq 0 ] +} + +@test "non-zero exit on unknown command" { + run bin/release-helper.sh unknown + + [ "$status" -ne 0 ] +} + +@test "get-ver prints a correct version" { + export TEST_SPECIFIC_VERSION="3.6.1.dev45" + + run bin/release-helper.sh get-ver + + [ "$status" -eq 0 ] + [[ "$output" == "3.6.1.dev45" ]] +} + +@test "set-dep-ver sets dependency version in dependency file" { + export DEPENDENCY_FILE=$(_setup_tmp_dependency_file) + + run bin/release-helper.sh set-dep-ver "botocore" "==0.0.0" + + [ "$status" -eq 0 ] + echo $DEPENDENCY_FILE + cat $DEPENDENCY_FILE | grep "botocore==0.0.0" +} + +@test "github-outputs appends metadata to GITHUB_OUTPUT" { + export GITHUB_OUTPUT=$(mktemp) + export TEST_SPECIFIC_VERSION="3.6.1.dev45" + run bin/release-helper.sh github-outputs "patch" + + cat $GITHUB_OUTPUT + [ "$status" -eq 0 ] + cat $GITHUB_OUTPUT | grep "current=3.6.1.dev45" + cat $GITHUB_OUTPUT | grep "release=3.6.1" + cat $GITHUB_OUTPUT | grep "develop=3.6.2.dev" + cat $GITHUB_OUTPUT | grep "boundary=3.7" +} + +@test "explain-steps command output" { + export TEST_SPECIFIC_VERSION="3.6.1.dev45" + run bin/release-helper.sh explain-steps "minor" + + [ "$status" -eq 0 ] +} + +@test "pip-download-retry succeeds on successful 'pip download' call" { + run bin/release-helper.sh pip-download-retry "testdep" "0.0.1" + + [ "$status" -eq 0 ] +} + +@test "git-commit-release creates (potentially empty) commit and tag" { + run bin/release-helper.sh git-commit-release "1.0.0" + + [ "$status" -eq 0 ] + [[ "$output" =~ "git commit --allow-empty -m release version 1.0.0" ]] + [[ "$output" =~ "git tag -a v1.0.0" ]] +} + +@test "git-commit-increment creates (potentially empty) commit" { + run bin/release-helper.sh git-commit-increment + + [ "$status" -eq 0 ] + [[ "$output" =~ "git commit --allow-empty -m prepare next development iteration" ]] +} + +@test "get-ver throws error when setuptools-scm is not installed" { + export TEST_PIP_FAIL=1 + + run bin/release-helper.sh get-ver + [ "$status" -eq 1 ] + [[ "$output" =~ "ERROR" ]] +} diff --git a/tests/bootstrap/__init__.py b/tests/bootstrap/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/bootstrap/cdk_templates/LocalStackHostBootstrap/ClusterStack.json b/tests/bootstrap/cdk_templates/LocalStackHostBootstrap/ClusterStack.json new file mode 100644 index 0000000000000..b3d13d04488aa --- /dev/null +++ b/tests/bootstrap/cdk_templates/LocalStackHostBootstrap/ClusterStack.json @@ -0,0 +1,556 @@ +{ + "Resources": { + "ResultsBucketA95A2103": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "Domain66AC69E0": { + "Type": "AWS::OpenSearchService::Domain", + "Properties": { + "ClusterConfig": { + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.search", + "ZoneAwarenessEnabled": false + }, + "DomainEndpointOptions": { + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "DomainName": "domain-938a03ea", + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "EncryptionAtRestOptions": { + "Enabled": false + }, + "EngineVersion": "OpenSearch_2.3", + "LogPublishingOptions": {}, + "NodeToNodeEncryptionOptions": { + "Enabled": false + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "Queue4A7E3555": { + "Type": "AWS::SQS::Queue", + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "QueuePolicy25439813": { + "Type": "AWS::SQS::QueuePolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sqs:SendMessage", + "Condition": { + "ArnEquals": { + "aws:SourceArn": { + "Ref": "TopicBFC7AF6E" + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Resource": { + "Fn::GetAtt": [ + "Queue4A7E3555", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "Queues": [ + { + "Ref": "Queue4A7E3555" + } + ] + } + }, + "QueueClusterStackTopicBE55F55AB4F9C07F": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Fn::GetAtt": [ + "Queue4A7E3555", + "Arn" + ] + }, + "Protocol": "sqs", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + }, + "DependsOn": [ + "QueuePolicy25439813" + ] + }, + "TopicBFC7AF6E": { + "Type": "AWS::SNS::Topic" + }, + "ApiHandlerFnServiceRole70F766AA": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "ApiHandlerFn96B5BE01": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "bootstrap-bucket", + "S3Key": "fn-apihandlerfn" + }, + "Environment": { + "Variables": { + "CUSTOM_LOCALSTACK_HOSTNAME": "foo.invalid", + "TOPIC_ARN": { + "Ref": "TopicBFC7AF6E" + } + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "ApiHandlerFnServiceRole70F766AA", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "ApiHandlerFnServiceRole70F766AA" + ] + }, + "RestApi0C43BF4B": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "RestApi" + } + }, + "RestApiCloudWatchRoleE3ED6605": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "RestApiAccount7C83CF5A": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "RestApiCloudWatchRoleE3ED6605", + "Arn" + ] + } + }, + "DependsOn": [ + "RestApi0C43BF4B" + ], + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "RestApiDeployment180EC5035e91cf9b45e2e822ce17f2f264a06fe3": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "Description": "Automatically created by the RestApi construct", + "RestApiId": { + "Ref": "RestApi0C43BF4B" + } + }, + "DependsOn": [ + "RestApiuploadPOST0F5E2849", + "RestApiuploadB3DA5A15" + ] + }, + "RestApiDeploymentStageprod3855DE66": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "DeploymentId": { + "Ref": "RestApiDeployment180EC5035e91cf9b45e2e822ce17f2f264a06fe3" + }, + "RestApiId": { + "Ref": "RestApi0C43BF4B" + }, + "StageName": "prod" + }, + "DependsOn": [ + "RestApiAccount7C83CF5A" + ] + }, + "RestApiuploadB3DA5A15": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "RestApi0C43BF4B", + "RootResourceId" + ] + }, + "PathPart": "upload", + "RestApiId": { + "Ref": "RestApi0C43BF4B" + } + } + }, + "RestApiuploadPOSTApiPermissionClusterStackRestApi40286FD9POSTuploadE91A7ADE": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ApiHandlerFn96B5BE01", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "RestApi0C43BF4B" + }, + "/", + { + "Ref": "RestApiDeploymentStageprod3855DE66" + }, + "/POST/upload" + ] + ] + } + } + }, + "RestApiuploadPOSTApiPermissionTestClusterStackRestApi40286FD9POSTupload2805A171": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ApiHandlerFn96B5BE01", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "RestApi0C43BF4B" + }, + "/test-invoke-stage/POST/upload" + ] + ] + } + } + }, + "RestApiuploadPOST0F5E2849": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "AuthorizationType": "NONE", + "HttpMethod": "POST", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "ApiHandlerFn96B5BE01", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "ResourceId": { + "Ref": "RestApiuploadB3DA5A15" + }, + "RestApiId": { + "Ref": "RestApi0C43BF4B" + } + } + }, + "EventHandlerFnServiceRoleC1FDCF6F": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "EventHandlerFnServiceRoleDefaultPolicyC178F440": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "sqs:ReceiveMessage", + "sqs:ChangeMessageVisibility", + "sqs:GetQueueUrl", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "Queue4A7E3555", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EventHandlerFnServiceRoleDefaultPolicyC178F440", + "Roles": [ + { + "Ref": "EventHandlerFnServiceRoleC1FDCF6F" + } + ] + } + }, + "EventHandlerFnFCB55A70": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": "bootstrap-bucket", + "S3Key": "fn-eventhandlerfn" + }, + "Environment": { + "Variables": { + "CUSTOM_LOCALSTACK_HOSTNAME": "foo.invalid", + "DOMAIN_ENDPOINT": { + "Fn::GetAtt": [ + "Domain66AC69E0", + "DomainEndpoint" + ] + }, + "RESULTS_BUCKET": { + "Ref": "ResultsBucketA95A2103" + }, + "RESULTS_KEY": "result" + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "EventHandlerFnServiceRoleC1FDCF6F", + "Arn" + ] + }, + "Runtime": "python3.10" + }, + "DependsOn": [ + "EventHandlerFnServiceRoleDefaultPolicyC178F440", + "EventHandlerFnServiceRoleC1FDCF6F" + ] + }, + "EventHandlerFnSqsEventSourceClusterStackQueueEDD98E89D69A7573": { + "Type": "AWS::Lambda::EventSourceMapping", + "Properties": { + "EventSourceArn": { + "Fn::GetAtt": [ + "Queue4A7E3555", + "Arn" + ] + }, + "FunctionName": { + "Ref": "EventHandlerFnFCB55A70" + } + } + } + }, + "Outputs": { + "ResultsBucketName": { + "Value": { + "Ref": "ResultsBucketA95A2103" + } + }, + "DomainEndpoint": { + "Value": { + "Fn::GetAtt": [ + "Domain66AC69E0", + "DomainEndpoint" + ] + } + }, + "RestApiEndpoint0551178A": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "RestApi0C43BF4B" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "RestApiDeploymentStageprod3855DE66" + }, + "/" + ] + ] + } + }, + "ApiUrl": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "RestApi0C43BF4B" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "RestApiDeploymentStageprod3855DE66" + }, + "/" + ] + ] + } + } + } +} diff --git a/tests/bootstrap/conftest.py b/tests/bootstrap/conftest.py new file mode 100644 index 0000000000000..9886d22853fa9 --- /dev/null +++ b/tests/bootstrap/conftest.py @@ -0,0 +1,39 @@ +import os +from typing import Optional + +import pytest + +from localstack import constants +from localstack.testing.scenario.provisioning import InfraProvisioner + +pytest_plugins = [ + "localstack.testing.pytest.bootstrap", +] + + +@pytest.fixture(scope="session") +def cdk_template_path(): + return os.path.abspath(os.path.join(os.path.dirname(__file__), "cdk_templates")) + + +# Duplicated from tests/aws/conftest.py so we can use CDK with bootstrap tests +@pytest.fixture(scope="session") +def infrastructure_setup(cdk_template_path, aws_client_factory): + def _infrastructure_setup( + namespace: str, force_synth: Optional[bool] = False, port: int = constants.DEFAULT_PORT_EDGE + ) -> InfraProvisioner: + """ + :param namespace: repo-unique identifier for this CDK app. + A directory with this name will be created at `tests/aws/cdk_templates//` + :param force_synth: set to True to always re-synth the CDK app + :return: an instantiated CDK InfraProvisioner which can be used to deploy a CDK app + """ + return InfraProvisioner( + base_path=cdk_template_path, + aws_client=aws_client_factory(endpoint_url=f"http://localhost:{port}"), + namespace=namespace, + force_synth=force_synth, + persist_output=True, + ) + + return _infrastructure_setup diff --git a/tests/bootstrap/resources/apigw_handler.py b/tests/bootstrap/resources/apigw_handler.py new file mode 100644 index 0000000000000..b8c7bfc5aa706 --- /dev/null +++ b/tests/bootstrap/resources/apigw_handler.py @@ -0,0 +1,47 @@ +import json +import os +from typing import TYPE_CHECKING + +import boto3 + +if TYPE_CHECKING: + from mypy_boto3_sns import SNSClient + + +client: "SNSClient" = boto3.client("sns", endpoint_url=os.environ["AWS_ENDPOINT_URL"]) + + +def handler(event, context): + print(f"API Gateway handler {context.function_name} invoked ({event=})") + topic_arn = os.environ["TOPIC_ARN"] + + message = json.loads(event["body"]) + + try: + client.publish( + TopicArn=topic_arn, + Message=json.dumps(message), + ) + print("Publish successful") + return { + "isBase64Encoded": False, + "statusCode": 200, + "headers": {}, + "body": json.dumps( + { + "status": "ok", + } + ), + } + except Exception as e: + return { + "isBase64Encoded": False, + "statusCode": 500, + "headers": {}, + "body": json.dumps( + { + "status": "error", + "error": str(e), + } + ), + } diff --git a/tests/bootstrap/resources/event_handler.py b/tests/bootstrap/resources/event_handler.py new file mode 100644 index 0000000000000..8e6176523365d --- /dev/null +++ b/tests/bootstrap/resources/event_handler.py @@ -0,0 +1,43 @@ +import json +import os + +import boto3 +import requests + +client = boto3.client("s3", endpoint_url=os.environ["AWS_ENDPOINT_URL"]) + + +def handler(event, context): + custom_localstack_hostname = os.environ["CUSTOM_LOCALSTACK_HOSTNAME"] + domain_endpoint = os.environ["DOMAIN_ENDPOINT"] + results_bucket = os.environ["RESULTS_BUCKET"] + results_key = os.environ["RESULTS_KEY"] + assert custom_localstack_hostname in domain_endpoint, ( + f"{custom_localstack_hostname} not in {domain_endpoint}" + ) + + print(f"Event handler function {context.function_name} invoked") + + for record in event["Records"]: + body = json.loads(record["body"]) + message = json.loads(body["Message"]) + print(f"Got message: {message}") + + # wait for cluster ready + try: + r = requests.get( + f"http://{domain_endpoint}/_cluster/health?wait_for_status=yellow,timeout=50s", + ) + r.raise_for_status() + except Exception as e: + print(f"Error fetching cluster health status: {e!r}") + + assert custom_localstack_hostname in body["UnsubscribeURL"] + + # write the result to s3 + client.put_object( + Bucket=results_bucket, Key=results_key, Body=message["message"].encode("utf8") + ) + + # just take the first record for now + return diff --git a/tests/bootstrap/test_container_configurators.py b/tests/bootstrap/test_container_configurators.py new file mode 100644 index 0000000000000..6482d067facdb --- /dev/null +++ b/tests/bootstrap/test_container_configurators.py @@ -0,0 +1,233 @@ +import textwrap + +import requests + +from localstack.utils.bootstrap import ( + Container, + ContainerConfigurators, + configure_container, + get_gateway_url, +) +from localstack.utils.common import external_service_ports +from localstack.utils.container_utils.container_client import BindMount + + +def test_common_container_fixture_configurators( + container_factory, wait_for_localstack_ready, tmp_path +): + volume = tmp_path / "localstack-volume" + volume.mkdir(parents=True) + + container: Container = container_factory( + configurators=[ + ContainerConfigurators.random_container_name, + ContainerConfigurators.random_gateway_port, + ContainerConfigurators.random_service_port_range(20), + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.mount_localstack_volume(volume), + ContainerConfigurators.env_vars( + { + "FOOBAR": "foobar", + "MY_TEST_ENV": "test", + } + ), + ] + ) + + running_container = container.start() + wait_for_localstack_ready(running_container) + url = get_gateway_url(container) + + # port was exposed correctly + response = requests.get(f"{url}/_localstack/health") + assert response.ok + + # volume was mounted and directories were created correctly + assert (volume / "cache" / "machine.json").exists() + + inspect = running_container.inspect() + # volume was mounted correctly + assert { + "Type": "bind", + "Source": str(volume), + "Destination": "/var/lib/localstack", + "Mode": "", + "RW": True, + "Propagation": "rprivate", + } in inspect["Mounts"] + # docker socket was mounted correctly + assert { + "Type": "bind", + "Source": "/var/run/docker.sock", + "Destination": "/var/run/docker.sock", + "Mode": "", + "RW": True, + "Propagation": "rprivate", + } in inspect["Mounts"] + + # debug was set + assert "DEBUG=1" in inspect["Config"]["Env"] + # environment variables were set + assert "FOOBAR=foobar" in inspect["Config"]["Env"] + assert "MY_TEST_ENV=test" in inspect["Config"]["Env"] + # container name was set + assert f"MAIN_CONTAINER_NAME={container.config.name}" in inspect["Config"]["Env"] + + +def test_custom_command_configurator(container_factory, tmp_path, stream_container_logs): + tmp_dir = tmp_path + + script = tmp_dir / "my-command.sh" + script.write_text( + textwrap.dedent( + """ + #!/bin/bash + echo "foobar" + echo "$@" + """ + ).strip() + ) + script.chmod(0o777) + + container: Container = container_factory( + configurators=[ + ContainerConfigurators.random_container_name, + ContainerConfigurators.custom_command( + ["/tmp/pytest-tmp-path/my-command.sh", "hello", "world"] + ), + ContainerConfigurators.volume(BindMount(str(tmp_path), "/tmp/pytest-tmp-path")), + ], + remove=False, + ) + + running_container = container.start() + assert running_container.wait_until_ready(timeout=5) + assert running_container.get_logs().strip() == "foobar\nhello world" + + +def test_default_localstack_container_configurator( + container_factory, wait_for_localstack_ready, tmp_path, monkeypatch, stream_container_logs +): + volume = tmp_path / "localstack-volume" + volume.mkdir(parents=True) + + # overwrite a few config variables + from localstack import config + + monkeypatch.setenv("DEBUG", "1") + monkeypatch.setenv("LOCALSTACK_AUTH_TOKEN", "") + monkeypatch.setenv("LOCALSTACK_API_KEY", "") + monkeypatch.setenv("ACTIVATE_PRO", "0") + monkeypatch.setattr(config, "DEBUG", True) + monkeypatch.setattr(config, "VOLUME_DIR", str(volume)) + monkeypatch.setattr(config, "DOCKER_FLAGS", "-p 23456:4566 -e MY_TEST_VAR=foobar") + + container: Container = container_factory() + configure_container(container) + + stream_container_logs(container) + wait_for_localstack_ready(container.start()) + + # check startup works correctly + response = requests.get("http://localhost:4566/_localstack/health") + assert response.ok + + # check docker-flags was created correctly + response = requests.get("http://localhost:23456/_localstack/health") + assert response.ok, "couldn't reach localstack on port 23456 - does DOCKER_FLAGS work?" + + response = requests.get("http://localhost:4566/_localstack/diagnose") + assert response.ok, "couldn't reach diagnose endpoint. is DEBUG=1 set?" + diagnose = response.json() + + # a few smoke tests of important configs + assert diagnose["config"]["GATEWAY_LISTEN"] == ["0.0.0.0:4566"] + # check that docker-socket was mounted correctly + assert diagnose["docker-inspect"], "was the docker socket mounted?" + assert diagnose["docker-inspect"]["Config"]["Image"] == "localstack/localstack" + assert diagnose["docker-inspect"]["Path"] == "docker-entrypoint.sh" + assert { + "Type": "bind", + "Source": str(volume), + "Destination": "/var/lib/localstack", + "Mode": "", + "RW": True, + "Propagation": "rprivate", + } in diagnose["docker-inspect"]["Mounts"] + + # from DOCKER_FLAGS + assert "MY_TEST_VAR=foobar" in diagnose["docker-inspect"]["Config"]["Env"] + + # check that external service ports were mapped correctly + ports = diagnose["docker-inspect"]["NetworkSettings"]["Ports"] + for port in external_service_ports: + assert ports[f"{port}/tcp"] == [{"HostIp": "127.0.0.1", "HostPort": f"{port}"}] + + +def test_container_configurator_deprecation_warning(container_factory, monkeypatch, caplog): + # set non-prefixed well-known environment variable on the mocked OS env + monkeypatch.setenv("SERVICES", "1") + + # config the container + container: Container = container_factory() + configure_container(container) + + # assert the deprecation warning + assert "Non-prefixed environment variable" in caplog.text + assert "SERVICES" in container.config.env_vars + + +def test_container_configurator_no_deprecation_warning_on_prefix( + container_factory, monkeypatch, caplog +): + # set non-prefixed well-known environment variable on the mocked OS env + monkeypatch.setenv("LOCALSTACK_SERVICES", "1") + + container: Container = container_factory() + configure_container(container) + + assert "Non-prefixed environment variable" not in caplog.text + assert "LOCALSTACK_SERVICES" in container.config.env_vars + + +def test_container_configurator_no_deprecation_warning_for_ci_env_var( + container_factory, monkeypatch, caplog +): + # set the "CI" env var indicating that we are running in a CI environment + monkeypatch.setenv("CI", "1") + + container: Container = container_factory() + configure_container(container) + + assert "Non-prefixed environment variable" not in caplog.text + assert "CI" in container.config.env_vars + + +def test_container_configurator_no_deprecation_warning_on_profile( + container_factory, monkeypatch, caplog, tmp_path +): + from localstack import config + + # create a test profile + tmp_config_dir = tmp_path + test_profile = tmp_config_dir / "testprofile.env" + test_profile.write_text( + textwrap.dedent( + """ + SERVICES=1 + """ + ).strip() + ) + + # patch the profile config / env + monkeypatch.setattr(config, "CONFIG_DIR", tmp_config_dir) + monkeypatch.setattr(config, "LOADED_PROFILES", ["testprofile"]) + monkeypatch.setenv("SERVICES", "1") + + container: Container = container_factory() + configure_container(container) + + # assert that profile env vars do not raise a deprecation warning + assert "Non-prefixed environment variable SERVICES" not in caplog.text + assert "SERVICES" in container.config.env_vars diff --git a/tests/bootstrap/test_container_listen_configuration.py b/tests/bootstrap/test_container_listen_configuration.py new file mode 100644 index 0000000000000..d504f6ecb7cbd --- /dev/null +++ b/tests/bootstrap/test_container_listen_configuration.py @@ -0,0 +1,104 @@ +import pytest +import requests + +from localstack.config import in_docker +from localstack.testing.pytest.container import ContainerFactory +from localstack.utils.bootstrap import ContainerConfigurators +from localstack.utils.net import get_free_tcp_port + +pytestmarks = pytest.mark.skipif( + condition=in_docker(), reason="cannot run bootstrap tests in docker" +) + + +class TestContainerConfiguration: + def test_defaults( + self, container_factory: ContainerFactory, stream_container_logs, wait_for_localstack_ready + ): + """ + The default configuration is to listen on 0.0.0.0:4566 + """ + port = get_free_tcp_port() + container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.port(port, 4566), + ] + ) + running_container = container.start(attach=False) + stream_container_logs(container) + wait_for_localstack_ready(running_container) + + r = requests.get(f"http://127.0.0.1:{port}/_localstack/health") + assert r.status_code == 200 + + def test_gateway_listen_single_value( + self, container_factory: ContainerFactory, stream_container_logs, wait_for_localstack_ready + ): + """ + Test using GATEWAY_LISTEN to change the hypercorn port + """ + port1 = get_free_tcp_port() + container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.port(port1, 5000), + ContainerConfigurators.env_vars( + { + "GATEWAY_LISTEN": "0.0.0.0:5000", + } + ), + ] + ) + running_container = container.start(attach=False) + stream_container_logs(container) + wait_for_localstack_ready(running_container) + + # check the ports listening on 0.0.0.0 + r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") + assert r.status_code == 200 + + def test_gateway_listen_multiple_values( + self, + container_factory: ContainerFactory, + docker_network, + stream_container_logs, + wait_for_localstack_ready, + ): + """ + Test multiple container ports + """ + port1 = get_free_tcp_port() + port2 = get_free_tcp_port() + + container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.network(docker_network), + ContainerConfigurators.port(port1, 5000), + ContainerConfigurators.port(port2, 2000), + ContainerConfigurators.env_vars( + { + "GATEWAY_LISTEN": ",".join( + [ + "0.0.0.0:5000", + "0.0.0.0:2000", + ] + ), + } + ), + ] + ) + running_container = container.start(attach=False) + stream_container_logs(container) + wait_for_localstack_ready(running_container) + + # check the ports listening on 0.0.0.0 + r = requests.get(f"http://127.0.0.1:{port1}/_localstack/health") + assert r.ok + + r = requests.get(f"http://127.0.0.1:{port2}/_localstack/health") + assert r.ok diff --git a/tests/bootstrap/test_cosmetic_configuration.py b/tests/bootstrap/test_cosmetic_configuration.py new file mode 100644 index 0000000000000..37610216c3b25 --- /dev/null +++ b/tests/bootstrap/test_cosmetic_configuration.py @@ -0,0 +1,311 @@ +import io +import os +from typing import Generator, Type +from urllib.parse import urlparse + +import aws_cdk as cdk +import pytest +import requests +from botocore.exceptions import ClientError + +from localstack import constants +from localstack.aws.connect import ServiceLevelClientFactory +from localstack.config import in_docker +from localstack.testing.pytest import markers +from localstack.testing.pytest.container import ContainerFactory, LogStreamFactory +from localstack.testing.scenario.cdk_lambda_helper import load_python_lambda_to_s3 +from localstack.testing.scenario.provisioning import InfraProvisioner +from localstack.utils.bootstrap import ContainerConfigurators +from localstack.utils.net import get_free_tcp_port +from localstack.utils.strings import short_uid +from localstack.utils.sync import retry + +pytestmarks = [ + pytest.mark.skipif(condition=in_docker(), reason="cannot run bootstrap tests in docker"), + markers.aws.only_localstack, +] + +STACK_NAME = "ClusterStack" +RESULT_KEY = "result" + + +@pytest.fixture(scope="class") +def port() -> int: + return get_free_tcp_port() + + +@pytest.fixture(scope="class") +def chosen_localstack_host() -> str: + """ + Choose a domain name that is guaranteed never to resolve, except by the LocalStack DNS server + + https://www.rfc-editor.org/rfc/rfc6761.html#section-6.4 + """ + return "foo.invalid" + + +# these fixtures have been copied from the pre-existing fixtures +@pytest.fixture(scope="class") +def class_container_factory() -> Generator[ContainerFactory, None, None]: + factory = ContainerFactory() + yield factory + factory.remove_all_containers() + + +@pytest.fixture(scope="class") +def class_stream_container_logs() -> Generator[LogStreamFactory, None, None]: + factory = LogStreamFactory() + yield factory + factory.close() + + +@pytest.fixture(scope="class", autouse=True) +def container( + port, + class_container_factory: ContainerFactory, + class_stream_container_logs, + wait_for_localstack_ready, + chosen_localstack_host, +): + ls_container = class_container_factory( + configurators=[ + ContainerConfigurators.mount_localstack_volume(), + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.gateway_listen(port), + ContainerConfigurators.env_vars( + { + "LOCALSTACK_HOST": chosen_localstack_host, + } + ), + ] + ) + with ls_container.start() as running_container: + class_stream_container_logs(ls_container) + wait_for_localstack_ready(running_container) + yield running_container + + +def raise_exception_with_cloudwatch_logs( + aws_client: ServiceLevelClientFactory, exc_class: Type[Exception] = AssertionError +): + out = io.StringIO() + + log_group_names = [ + every["logGroupName"] + for every in aws_client.logs.describe_log_groups(logGroupNamePrefix="/aws/lambda")[ + "logGroups" + ] + ] + for name in log_group_names: + print(f"Logs for {name}:", file=out) + streams = [ + every["logStreamName"] + for every in aws_client.logs.describe_log_streams(logGroupName=name)["logStreams"] + ] + for stream in streams: + records = aws_client.logs.get_log_events( + logGroupName=name, + logStreamName=stream, + )["events"] + for record in records: + print(record["message"], file=out) + + raise exc_class(out.getvalue()) + + +class TestLocalStackHost: + """ + Scenario test that runs LocalStack in a docker container with `LOCALSTACK_HOST` set to a + non-default value. This ensures that setting the cosmetic "LOCALSTACK_HOST" does not affect + the internal functionality of LocalStack. + """ + + @pytest.fixture(scope="class", autouse=True) + def infrastructure( + self, + aws_client_factory, + infrastructure_setup, + port, + chosen_localstack_host, + region_name, + ): + aws_client = aws_client_factory( + endpoint_url=f"http://localhost:{port}", + region_name=region_name, + ) + + infra: InfraProvisioner = infrastructure_setup( + namespace="LocalStackHostBootstrap", + port=port, + ) + + stack = cdk.Stack(infra.cdk_app, STACK_NAME) + + # results bucket + results_bucket = cdk.aws_s3.Bucket(stack, "ResultsBucket") + cdk.CfnOutput(stack, "ResultsBucketName", value=results_bucket.bucket_name) + + # assets bucket + assets_bucket_name = "bootstrap-bucket" + + # OpenSearch domain + domain_name = f"domain-{short_uid()}" + domain = cdk.aws_opensearchservice.Domain( + stack, + "Domain", + domain_name=domain_name, + version=cdk.aws_opensearchservice.EngineVersion.OPENSEARCH_2_3, + ) + cdk.CfnOutput(stack, "DomainEndpoint", value=domain.domain_endpoint) + + def create_lambda_function( + stack: cdk.Stack, + resource_name: str, + resources_path: str, + additional_packages: list[str] | None = None, + runtime: cdk.aws_lambda.Runtime = cdk.aws_lambda.Runtime.PYTHON_3_10, + environment: dict[str, str] | None = None, + **kwargs, + ) -> cdk.aws_lambda.Function: + # needs to be deterministic so we can turn `infrastructure_setup(force_synth=True)` off + key_name = f"fn-{resource_name.lower()}" + assert os.path.isfile(resources_path), f"Cannot find function file {resources_path}" + + infra.add_custom_setup( + lambda: load_python_lambda_to_s3( + s3_client=aws_client.s3, + bucket_name=assets_bucket_name, + key_name=key_name, + code_path=resources_path, + additional_python_packages=additional_packages or [], + ) + ) + + given_environment = environment or {} + base_environment = {"CUSTOM_LOCALSTACK_HOSTNAME": chosen_localstack_host} + full_environment = {**base_environment, **given_environment} + return cdk.aws_lambda.Function( + stack, + resource_name, + handler="index.handler", + code=cdk.aws_lambda.S3Code(bucket=asset_bucket, key=key_name), + runtime=runtime, + environment=full_environment, + **kwargs, + ) + + # SQS queue + queue = cdk.aws_sqs.Queue(stack, "Queue") + + # SNS topic + topic = cdk.aws_sns.Topic(stack, "Topic") + topic.add_subscription(cdk.aws_sns_subscriptions.SqsSubscription(queue)) + + # API Gateway + asset_bucket = cdk.aws_s3.Bucket.from_bucket_name( + stack, + "BucketName", + bucket_name=assets_bucket_name, + ) + apigw_handler_fn = create_lambda_function( + stack, + resource_name="ApiHandlerFn", + resources_path=os.path.join(os.path.dirname(__file__), "resources/apigw_handler.py"), + environment={ + "TOPIC_ARN": topic.topic_arn, + }, + ) + + api = cdk.aws_apigateway.RestApi(stack, "RestApi") + upload_url_resource = api.root.add_resource("upload") + upload_url_resource.add_method( + "POST", cdk.aws_apigateway.LambdaIntegration(apigw_handler_fn) + ) + cdk.CfnOutput(stack, "ApiUrl", value=api.url) + + # event handler lambda + create_lambda_function( + stack, + resource_name="EventHandlerFn", + resources_path=os.path.join(os.path.dirname(__file__), "resources/event_handler.py"), + additional_packages=["requests", "boto3"], + events=[ + cdk.aws_lambda_event_sources.SqsEventSource(queue), + ], + environment={ + "DOMAIN_ENDPOINT": domain.domain_endpoint, + "RESULTS_BUCKET": results_bucket.bucket_name, + "RESULTS_KEY": RESULT_KEY, + }, + ) + + with infra.provisioner() as prov: + yield prov + + def test_scenario( + self, port, infrastructure, aws_client_factory, chosen_localstack_host, region_name + ): + """ + Scenario: + * API Gateway handles web request + * Broadcasts message onto SNS topic + * Lambda subscribes via SQS and queries the OpenSearch domain health endpoint + """ + # check cluster health endpoint + + aws_client = aws_client_factory( + endpoint_url=f"http://localhost:{port}", + region_name=region_name, + ) + + stack_outputs = infrastructure.get_stack_outputs(STACK_NAME) + assert chosen_localstack_host in stack_outputs["DomainEndpoint"] + health_url = stack_outputs["DomainEndpoint"].replace( + chosen_localstack_host, constants.LOCALHOST_HOSTNAME + ) + # we only have a route matcher for the domain with localstack_host in the URL, + # but have to make the request against localhost so set the host header to the custom + # domain and make the request against the rewritten domain + host = urlparse(f"http://{stack_outputs['DomainEndpoint']}").hostname + r = requests.get(f"http://{health_url}/_cluster/health", headers={"Host": host}) + r.raise_for_status() + + assert chosen_localstack_host in stack_outputs["ApiUrl"] + api_url = ( + stack_outputs["ApiUrl"] + .rstrip("/") + .replace(chosen_localstack_host, constants.LOCALHOST_HOSTNAME) + ) + + url = f"{api_url}/upload" + + message = short_uid() + r = requests.post(url, json={"message": message}) + r.raise_for_status() + + result_bucket = stack_outputs["ResultsBucketName"] + + def _is_result_file_ready(): + aws_client.s3.head_object( + Bucket=result_bucket, + Key=RESULT_KEY, + ) + + # wait a maximum of 10 seconds + try: + retry(_is_result_file_ready, retries=10) + except ClientError as e: + if "Not Found" not in str(e): + raise + + # we could not find the file in S3 after the retry period, so fail the test with some + # useful information + raise_exception_with_cloudwatch_logs(aws_client) + + body = ( + aws_client.s3.get_object(Bucket=result_bucket, Key=RESULT_KEY)["Body"] + .read() + .decode("utf8") + ) + assert body.strip() == message diff --git a/tests/bootstrap/test_dns_server.py b/tests/bootstrap/test_dns_server.py new file mode 100644 index 0000000000000..fbb31df4f3a83 --- /dev/null +++ b/tests/bootstrap/test_dns_server.py @@ -0,0 +1,174 @@ +import logging + +import pytest + +from localstack import constants +from localstack.config import in_docker +from localstack.constants import LOCALHOST_HOSTNAME +from localstack.testing.pytest.container import ContainerFactory +from localstack.utils.bootstrap import ContainerConfigurators +from localstack.utils.strings import short_uid + +LOG = logging.getLogger(__name__) + +pytestmarks = pytest.mark.skipif( + condition=in_docker(), reason="cannot run bootstrap tests in docker" +) + + +def test_default_network( + container_factory: ContainerFactory, + stream_container_logs, + wait_for_localstack_ready, + dns_query_from_container, +): + ls_container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ] + ) + running_container = ls_container.start() + stream_container_logs(ls_container) + wait_for_localstack_ready(running_container) + + container_ip = running_container.ip_address() + + stdout, _ = dns_query_from_container(name=LOCALHOST_HOSTNAME, ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() + + stdout, _ = dns_query_from_container(name=f"foo.{LOCALHOST_HOSTNAME}", ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() + + +def test_user_defined_network( + docker_network, + container_factory: ContainerFactory, + stream_container_logs, + wait_for_localstack_ready, + dns_query_from_container, +): + ls_container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.network(docker_network), + ] + ) + running_ls_container = ls_container.start() + stream_container_logs(ls_container) + wait_for_localstack_ready(running_ls_container) + + container_ip = running_ls_container.ip_address(docker_network=docker_network) + stdout, _ = dns_query_from_container( + name=LOCALHOST_HOSTNAME, ip_address=container_ip, network=docker_network + ) + assert container_ip in stdout.decode().splitlines() + + stdout, _ = dns_query_from_container( + name=f"foo.{LOCALHOST_HOSTNAME}", ip_address=container_ip, network=docker_network + ) + assert container_ip in stdout.decode().splitlines() + + +@pytest.mark.parametrize( + "prefix,suffix", + [("", ""), ("'", "'"), ('"', '"'), ("\"'", "'\""), ("' ", "' ")], + ids=[ + "no-quotes", + "single-quotes", + "double-quotes", + "single-and-double-quotes", + "single-quotes-with-spaces", + ], +) +def test_skip_pattern( + docker_network, + container_factory: ContainerFactory, + stream_container_logs, + wait_for_localstack_ready, + dns_query_from_container, + prefix, + suffix, +): + """ + Add a skip pattern of localhost.localstack.cloud to ensure that we prioritise skips before + local name resolution + """ + ls_container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.network(docker_network), + ContainerConfigurators.env_vars( + { + "DNS_NAME_PATTERNS_TO_RESOLVE_UPSTREAM": rf"{prefix}.*localhost.localstack.cloud{suffix}", + } + ), + ] + ) + running_ls_container = ls_container.start() + stream_container_logs(ls_container) + wait_for_localstack_ready(running_ls_container) + + container_ip = running_ls_container.ip_address(docker_network=docker_network) + stdout, _ = dns_query_from_container( + name=LOCALHOST_HOSTNAME, ip_address=container_ip, network=docker_network + ) + assert container_ip not in stdout.decode().splitlines() + assert constants.LOCALHOST_IP in stdout.decode().splitlines() + + stdout, _ = dns_query_from_container( + name=f"foo.{LOCALHOST_HOSTNAME}", ip_address=container_ip, network=docker_network + ) + assert container_ip not in stdout.decode().splitlines() + assert constants.LOCALHOST_IP in stdout.decode().splitlines() + + +def test_resolve_localstack_host( + container_factory: ContainerFactory, + stream_container_logs, + wait_for_localstack_ready, + dns_query_from_container, +): + localstack_host = f"host-{short_uid()}" + ls_container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.env_vars( + { + "LOCALSTACK_HOST": localstack_host, + }, + ), + ], + ) + running_container = ls_container.start() + stream_container_logs(ls_container) + wait_for_localstack_ready(running_container) + + container_ip = running_container.ip_address() + + # domain + stdout, _ = dns_query_from_container(name=LOCALHOST_HOSTNAME, ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() + + # domain with known hostPrefix (see test_host_prefix_no_subdomain) + stdout, _ = dns_query_from_container(name=f"data-{LOCALHOST_HOSTNAME}", ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() + + # subdomain + stdout, _ = dns_query_from_container(name=f"foo.{LOCALHOST_HOSTNAME}", ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() + + # domain + stdout, _ = dns_query_from_container(name=localstack_host, ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() + + # domain with known hostPrefix (see test_host_prefix_no_subdomain) + stdout, _ = dns_query_from_container(name=f"data-{localstack_host}", ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() + + # subdomain + stdout, _ = dns_query_from_container(name=f"foo.{localstack_host}", ip_address=container_ip) + assert container_ip in stdout.decode().splitlines() diff --git a/tests/bootstrap/test_init.py b/tests/bootstrap/test_init.py new file mode 100644 index 0000000000000..6bd4455860890 --- /dev/null +++ b/tests/bootstrap/test_init.py @@ -0,0 +1,109 @@ +import json + +import pytest +import requests + +from localstack.config import in_docker +from localstack.testing.pytest.container import ContainerFactory +from localstack.utils.bootstrap import ContainerConfigurators +from localstack.utils.container_utils.container_client import BindMount + +pytestmarks = pytest.mark.skipif( + condition=in_docker(), reason="cannot run bootstrap tests in docker" +) + + +class TestInitHooks: + def test_shutdown_hooks( + self, + container_factory: ContainerFactory, + stream_container_logs, + wait_for_localstack_ready, + tmp_path, + ): + volume = tmp_path / "volume" + + # prepare shutdown hook scripts + shutdown_hooks = tmp_path / "shutdown.d" + shutdown_hooks.mkdir() + shutdown_00 = shutdown_hooks / "on_shutdown_00.sh" + shutdown_01 = shutdown_hooks / "on_shutdown_01.sh" + shutdown_00.touch(mode=0o777) + shutdown_01.touch(mode=0o777) + shutdown_00.write_text("#!/bin/bash\necho 'foobar' > /var/lib/localstack/shutdown_00.log") + shutdown_01.write_text( + "#!/bin/bash\ncurl -s localhost:4566/_localstack/init &> /var/lib/localstack/shutdown_01.log" + ) + + # set up container + container = container_factory( + configurators=[ + ContainerConfigurators.debug, + ContainerConfigurators.mount_docker_socket, + ContainerConfigurators.default_gateway_port, + ContainerConfigurators.mount_localstack_volume(volume), + ContainerConfigurators.volume( + BindMount(str(shutdown_hooks), "/etc/localstack/init/shutdown.d") + ), + ] + ) + running_container = container.start(attach=False) + stream_container_logs(container) + wait_for_localstack_ready(running_container) + + # check that the init scripts are registered correctly + r = requests.get("http://127.0.0.1:4566/_localstack/init") + assert r.status_code == 200 + assert r.json() == { + "completed": { + "BOOT": True, + "READY": True, + "SHUTDOWN": False, + "START": True, + }, + "scripts": [ + { + "name": "on_shutdown_00.sh", + "stage": "SHUTDOWN", + "state": "UNKNOWN", + }, + { + "name": "on_shutdown_01.sh", + "stage": "SHUTDOWN", + "state": "UNKNOWN", + }, + ], + } + + # programmatically shut down the container to trigger the shutdown hooks + running_container.shutdown() + + # verify that they were executed correctly by checking their logs + shutdown_00_log = volume / "shutdown_00.log" + shutdown_01_log = volume / "shutdown_01.log" + + assert shutdown_00_log.is_file() + assert shutdown_00_log.read_text() == "foobar\n" + + assert shutdown_01_log.is_file() + # check the state of hook scripts + assert json.loads(shutdown_01_log.read_text()) == { + "completed": { + "BOOT": True, + "READY": True, + "SHUTDOWN": False, + "START": True, + }, + "scripts": [ + { + "name": "on_shutdown_00.sh", + "stage": "SHUTDOWN", + "state": "SUCCESSFUL", + }, + { + "name": "on_shutdown_01.sh", + "stage": "SHUTDOWN", + "state": "RUNNING", + }, + ], + } diff --git a/tests/bootstrap/test_localstack_container_server.py b/tests/bootstrap/test_localstack_container_server.py new file mode 100644 index 0000000000000..29dea53c7b9cf --- /dev/null +++ b/tests/bootstrap/test_localstack_container_server.py @@ -0,0 +1,57 @@ +import pytest +import requests + +from localstack import config +from localstack.config import in_docker +from localstack.utils.bootstrap import LocalstackContainerServer +from localstack.utils.sync import poll_condition + + +@pytest.mark.skipif(condition=in_docker(), reason="cannot run bootstrap tests in docker") +class TestLocalstackContainerServer: + def test_lifecycle(self): + server = LocalstackContainerServer() + server.container.config.ports.add(config.GATEWAY_LISTEN[0].port) + + assert not server.is_up() + try: + server.start() + assert server.wait_is_up(60) + + health_response = requests.get("http://localhost:4566/_localstack/health") + assert health_response.ok, ( + "expected health check to return OK: %s" % health_response.text + ) + + restart_response = requests.post( + "http://localhost:4566/_localstack/health", json={"action": "restart"} + ) + assert restart_response.ok, ( + "expected restart command via health endpoint to return OK: %s" + % restart_response.text + ) + + def check_restart_successful(): + logs = server.container.get_logs() + if logs.count("Ready.") < 2: + # second ready marker still missing + return False + + health_response_after_retry = requests.get( + "http://localhost:4566/_localstack/health" + ) + if not health_response_after_retry.ok: + # health endpoint not yet ready again + return False + + # second restart marker found and health endpoint returned with 200! + return True + + assert poll_condition(check_restart_successful, 45, 1), ( + "expected two Ready markers in the logs after triggering restart via health endpoint" + ) + finally: + server.shutdown() + + server.join(30) + assert not server.is_up() diff --git a/tests/bootstrap/test_service_loading.py b/tests/bootstrap/test_service_loading.py new file mode 100644 index 0000000000000..cf5a1b2959e56 --- /dev/null +++ b/tests/bootstrap/test_service_loading.py @@ -0,0 +1,145 @@ +import pytest +import requests +from botocore.exceptions import ClientError + +from localstack.config import in_docker +from localstack.testing.pytest.container import ContainerFactory +from localstack.utils.bootstrap import ContainerConfigurators, get_gateway_url + +pytestmarks = pytest.mark.skipif( + condition=in_docker(), reason="cannot run bootstrap tests in docker" +) + + +def test_strict_service_loading( + container_factory: ContainerFactory, + wait_for_localstack_ready, + aws_client_factory, +): + ls_container = container_factory( + configurators=[ + ContainerConfigurators.random_container_name, + ContainerConfigurators.random_gateway_port, + ContainerConfigurators.random_service_port_range(20), + ContainerConfigurators.env_vars( + { + "STRICT_SERVICE_LOADING": "1", # this is the default value + "EAGER_SERVICE_LOADING": "0", # this is the default value + "SERVICES": "s3,sqs,sns", + } + ), + ] + ) + running_container = ls_container.start() + wait_for_localstack_ready(running_container) + url = get_gateway_url(ls_container) + + # check service-status returned by health endpoint + response = requests.get(f"{url}/_localstack/health") + assert response.ok + + services = response.json().get("services") + + assert services.pop("sqs") == "available" + assert services.pop("s3") == "available" + assert services.pop("sns") == "available" + + assert services + assert all(services.get(key) == "disabled" for key in services.keys()) + + # activate sqs service + client = aws_client_factory(endpoint_url=url) + result = client.sqs.list_queues() + assert result + + # verify cloudwatch is not activated + with pytest.raises(ClientError) as e: + client.cloudwatch.list_metrics() + + e.match( + "Service 'cloudwatch' is not enabled. Please check your 'SERVICES' configuration variable." + ) + assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 501 + + # check status again + response = requests.get(f"{url}/_localstack/health") + assert response.ok + + services = response.json().get("services") + + # sqs should be running now + assert services.get("sqs") == "running" + assert services.get("s3") == "available" + assert services.get("sns") == "available" + assert services.get("cloudwatch") == "disabled" + + +def test_eager_service_loading( + container_factory: ContainerFactory, + wait_for_localstack_ready, + aws_client_factory, +): + ls_container = container_factory( + configurators=[ + ContainerConfigurators.random_container_name, + ContainerConfigurators.random_gateway_port, + ContainerConfigurators.random_service_port_range(20), + ContainerConfigurators.env_vars( + {"EAGER_SERVICE_LOADING": "1", "SERVICES": "s3,sqs,sns"} + ), + ] + ) + running_container = ls_container.start() + wait_for_localstack_ready(running_container) + url = get_gateway_url(ls_container) + + # check service-status returned by health endpoint + response = requests.get(f"{url}/_localstack/health") + assert response.ok + + services = response.json().get("services") + + assert services.pop("sqs") == "running" + assert services.pop("s3") == "running" + assert services.pop("sns") == "running" + + assert services + assert all(services.get(key) == "disabled" for key in services.keys()) + + +def test_eager_and_strict_service_loading( + container_factory: ContainerFactory, + wait_for_localstack_ready, + aws_client_factory, +): + # this is undocumented behavior, to allow eager loading of specific services while not restricting services loading + ls_container = container_factory( + configurators=[ + ContainerConfigurators.random_container_name, + ContainerConfigurators.random_gateway_port, + ContainerConfigurators.random_service_port_range(20), + ContainerConfigurators.env_vars( + { + "EAGER_SERVICE_LOADING": "1", + "SERVICES": "s3,sqs,sns", + "STRICT_SERVICE_LOADING": "0", + } + ), + ] + ) + running_container = ls_container.start() + wait_for_localstack_ready(running_container) + url = get_gateway_url(ls_container) + + # check service-status returned by health endpoint + response = requests.get(f"{url}/_localstack/health") + assert response.ok + + services = response.json().get("services") + + assert services.pop("sqs") == "running" + assert services.pop("s3") == "running" + assert services.pop("sns") == "running" + + assert services + assert all(services.get(key) == "available" for key in services.keys()) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000000000..4c3a166dbd7a6 --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1,2 @@ +"""Bootstrapping is the process of starting localstack. This is not always testable in the regular integration test +environment. This is what this module is for.""" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000000000..0579c8a9122cb --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from localstack import config + + +@pytest.fixture(autouse=True) +def _setup_cli_environment(monkeypatch): + # normally we are setting LOCALSTACK_CLI in localstack/cli/main.py, which is not actually run in the tests + monkeypatch.setenv("LOCALSTACK_CLI", "1") + monkeypatch.setattr(config, "dirs", config.Directories.for_cli()) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000000000..aef3f08abd50d --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,306 @@ +import json +import logging +import os.path + +import pytest +import requests +from click.testing import CliRunner + +import localstack.utils.container_utils.docker_cmd_client +from localstack import config, constants +from localstack.cli.localstack import localstack as cli +from localstack.config import Directories, in_docker +from localstack.constants import MODULE_MAIN_PATH +from localstack.utils import bootstrap +from localstack.utils.bootstrap import in_ci +from localstack.utils.common import poll_condition +from localstack.utils.container_utils.container_client import ContainerClient, NoSuchImage +from localstack.utils.files import mkdir +from localstack.utils.net import get_free_udp_port +from localstack.utils.run import run, to_str + +LOG = logging.getLogger(__name__) + + +@pytest.fixture +def runner(): + return CliRunner() + + +def container_exists(client: ContainerClient, container_name: str) -> bool: + try: + container_id = client.get_container_id(container_name) + return True if container_id else False + except Exception: + return False + + +@pytest.fixture(autouse=True) +def container_client(): + client = localstack.utils.container_utils.docker_cmd_client.CmdDockerClient() + + yield client + + try: + client.stop_container(config.MAIN_CONTAINER_NAME, timeout=5) + except Exception: + pass + + # wait until container has been removed + assert poll_condition( + lambda: not container_exists(client, config.MAIN_CONTAINER_NAME), timeout=20 + ) + + +@pytest.fixture +def backup_and_remove_image(monkeypatch, container_client: ContainerClient): + """ + To test whether the image is pulled correctly, we must remove the image. + However we do not want to do this and remove the current image, so "back it + up" - i.e. tag it with another tag, and restore it afterwards. + """ + + source_image_name = f"{constants.DOCKER_IMAGE_NAME}:latest" + tagged_image_name = f"{constants.DOCKER_IMAGE_NAME}:backup" + container_client.tag_image(source_image_name, tagged_image_name) + container_client.remove_image(source_image_name, force=True) + monkeypatch.setenv("IMAGE_NAME", source_image_name) + + yield + + container_client.tag_image(tagged_image_name, source_image_name) + + +@pytest.mark.skipif(condition=in_docker(), reason="cannot run CLI tests in docker") +class TestCliContainerLifecycle: + def test_start_wait_stop(self, runner, container_client): + result = runner.invoke(cli, ["start", "-d"]) + assert result.exit_code == 0 + assert "starting LocalStack" in result.output + + result = runner.invoke(cli, ["wait", "-t", "60"]) + assert result.exit_code == 0 + + assert container_client.is_container_running(config.MAIN_CONTAINER_NAME), ( + "container name was not running after wait" + ) + + # Note: if `LOCALSTACK_HOST` is set to a domain that does not resolve to `127.0.0.1` then + # this test will fail + health = requests.get(config.external_service_url() + "/_localstack/health") + assert health.ok, "health request did not return OK: %s" % health.text + + result = runner.invoke(cli, ["stop"]) + assert result.exit_code == 0 + + with pytest.raises(requests.ConnectionError): + requests.get(config.external_service_url() + "/_localstack/health") + + @pytest.mark.usefixtures("backup_and_remove_image") + def test_pulling_image_message(self, runner, container_client: ContainerClient): + image_name_and_tag = f"{constants.DOCKER_IMAGE_NAME}:latest" + with pytest.raises(NoSuchImage): + container_client.inspect_image(image_name_and_tag, pull=False) + + result = runner.invoke(cli, ["start", "-d"]) + + assert result.exit_code == 0, result.output + + # we cannot check for "Pulling container image" which would be more accurate + # since it is printed in a temporary status line and may not be present in + # the output if the docker pull is fast enough, but we can check for the + # presence of another message which is present when pulling the image. + assert "download complete" in result.output + + def test_start_already_running(self, runner, container_client): + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "180"]) + result = runner.invoke(cli, ["start"]) + assert container_exists(container_client, config.MAIN_CONTAINER_NAME) + assert result.exit_code == 1 + assert "Error" in result.output + assert "is already running" in result.output + + def test_wait_timeout_raises_exception(self, runner, container_client): + # assume a wait without start fails + result = runner.invoke(cli, ["wait", "-t", "0.5"]) + assert result.exit_code != 0 + + def test_logs(self, runner, container_client): + result = runner.invoke(cli, ["logs"]) + assert result.exit_code != 0 + + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + result = runner.invoke(cli, ["logs", "--tail", "20"]) + assert constants.READY_MARKER_OUTPUT in result.output.splitlines() + + def test_restart(self, runner, container_client): + result = runner.invoke(cli, ["restart"]) + assert result.exit_code != 0 + + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + result = runner.invoke(cli, ["restart"]) + assert result.exit_code == 0 + assert "restarted" in result.output + + def test_status_services(self, runner): + result = runner.invoke(cli, ["status", "services"]) + assert result.exit_code != 0 + assert "could not connect to LocalStack health endpoint" in result.output + + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + result = runner.invoke(cli, ["status", "services"]) + + # just a smoke test + assert "dynamodb" in result.output + for line in result.output.splitlines(): + if "dynamodb" in line: + assert "available" in line + + def test_custom_docker_flags(self, runner, tmp_path, monkeypatch, container_client): + volume = tmp_path / "volume" + volume.mkdir() + + monkeypatch.setattr(config, "DOCKER_FLAGS", f"-p 42069 -v {volume}:{volume}") + + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + inspect = container_client.inspect_container(config.MAIN_CONTAINER_NAME) + assert "42069/tcp" in inspect["HostConfig"]["PortBindings"] + assert f"{volume}:{volume}" in inspect["HostConfig"]["Binds"] + + def test_volume_dir_mounted_correctly(self, runner, tmp_path, monkeypatch, container_client): + volume_dir = tmp_path / "volume" + + # set different directories and make sure they are mounted correctly + monkeypatch.setenv("LOCALSTACK_VOLUME_DIR", str(volume_dir)) + monkeypatch.setattr(config, "VOLUME_DIR", str(volume_dir)) + + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + # check that mounts were created correctly + inspect = container_client.inspect_container(config.MAIN_CONTAINER_NAME) + binds = inspect["HostConfig"]["Binds"] + assert f"{volume_dir}:{constants.DEFAULT_VOLUME_DIR}" in binds + + def test_container_starts_non_root(self, runner, monkeypatch, container_client): + user = "localstack" + monkeypatch.setattr(config, "DOCKER_FLAGS", f"--user={user}") + + if in_ci() and os.path.exists("/home/runner"): + volume_dir = "/home/runner/.cache/localstack/volume/" + mkdir(volume_dir) + run(["sudo", "chmod", "-R", "777", volume_dir]) + + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + cmd = ["awslocal", "stepfunctions", "list-state-machines"] + output = container_client.exec_in_container(config.MAIN_CONTAINER_NAME, cmd) + result = json.loads(output[0]) + assert "stateMachines" in result + + output = container_client.exec_in_container(config.MAIN_CONTAINER_NAME, ["ps", "-fu", user]) + assert "localstack-supervisor" in to_str(output[0]) + + def test_start_cli_within_container(self, runner, container_client, tmp_path): + output = container_client.run_container( + # CAVEAT: Updates to the Docker image are not immediately reflected when using the latest image from + # DockerHub in the CI. Re-build the Docker image locally through + # `IMAGE_NAME="localstack/localstack" ./bin/docker-helper.sh build` for local testing. + "localstack/localstack", + remove=True, + entrypoint="", + command=["bin/localstack", "start", "-d"], + volumes=[ + ("/var/run/docker.sock", "/var/run/docker.sock"), + (MODULE_MAIN_PATH, "/opt/code/localstack/localstack"), + ], + env_vars={"LOCALSTACK_VOLUME_DIR": f"{tmp_path}/ls-volume"}, + ) + stdout = to_str(output[0]) + assert "starting LocalStack" in stdout + assert "detaching" in stdout + + # assert that container is running + runner.invoke(cli, ["wait", "-t", "60"]) + + +@pytest.mark.skipif(condition=in_docker(), reason="cannot run CLI tests in docker") +class TestDNSServer: + def test_dns_port_published_with_flag(self, runner, container_client, monkeypatch): + port = get_free_udp_port() + monkeypatch.setenv("DEBUG", "1") + monkeypatch.setenv("DNS_PORT", str(port)) + monkeypatch.setattr(config, "DNS_PORT", port) + + runner.invoke(cli, ["start", "-d", "--host-dns"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + inspect = container_client.inspect_container(config.MAIN_CONTAINER_NAME) + assert f"{port}/udp" in inspect["HostConfig"]["PortBindings"] + + def test_dns_port_not_published_by_default(self, runner, container_client, monkeypatch): + monkeypatch.setenv("DEBUG", "1") + + runner.invoke(cli, ["start", "-d"]) + runner.invoke(cli, ["wait", "-t", "60"]) + + inspect = container_client.inspect_container(config.MAIN_CONTAINER_NAME) + assert "53/udp" not in inspect["HostConfig"]["PortBindings"] + + +class TestHooks: + def test_prepare_host_hook_called_with_correct_dirs(self, runner, monkeypatch): + """ + Assert that the prepare_host(..) hook is called with the appropriate dirs layout (e.g., cache + dir writeable). Required, for example, for API key activation and local key caching. + """ + + # simulate that we're running in Docker + monkeypatch.setattr(config, "is_in_docker", True) + + result_configs = [] + + def _prepare_host(*args, **kwargs): + # store the configs that will be passed to prepare_host hooks (Docker status, infra process, dirs layout) + result_configs.append((config.is_in_docker, None, config.dirs)) + + # patch the prepare_host function which calls the hooks + monkeypatch.setattr(bootstrap, "prepare_host", _prepare_host) + + def noop(*args, **kwargs): + pass + + # patch start_infra_in_docker to be a no-op (we don't actually want to start the container for this test) + assert bootstrap.start_infra_in_docker + monkeypatch.setattr(bootstrap, "start_infra_in_docker", noop) + + # run the 'start' command, which should call the prepare_host hooks + runner.invoke(cli, ["start"]) + + # assert that result configs are as expected + assert len(result_configs) == 1 + dirs: Directories + in_docker, is_infra_process, dirs = result_configs[0] + assert in_docker is False + # cache dir should exist and be writeable + assert os.path.exists(dirs.cache) + assert os.access(dirs.cache, os.W_OK) + + +class TestImports: + """Simple tests to assert that certain code paths can be imported from the CLI""" + + def test_import_venv(self): + from localstack.utils.venv import VirtualEnvironment + + assert VirtualEnvironment diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000..2a23489c537bc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,89 @@ +import os + +import pytest + +os.environ["LOCALSTACK_INTERNAL_TEST_RUN"] = "1" + +pytest_plugins = [ + "localstack.testing.pytest.fixtures", + "localstack.testing.pytest.container", + "localstack_snapshot.pytest.snapshot", + "localstack.testing.pytest.filters", + "localstack.testing.pytest.fixture_conflicts", + "localstack.testing.pytest.marking", + "localstack.testing.pytest.marker_report", + "localstack.testing.pytest.in_memory_localstack", + "localstack.testing.pytest.validation_tracking", + "localstack.testing.pytest.path_filter", + "localstack.testing.pytest.stepfunctions.fixtures", + "localstack.testing.pytest.cloudformation.fixtures", +] + + +@pytest.fixture(scope="session") +def aws_session(): + """ + This fixture returns the Boto Session instance for testing. + """ + from localstack.testing.aws.util import base_aws_session + + return base_aws_session() + + +@pytest.fixture(scope="session") +def secondary_aws_session(): + """ + This fixture returns the Boto Session instance for testing a secondary account. + """ + from localstack.testing.aws.util import secondary_aws_session + + return secondary_aws_session() + + +@pytest.fixture(scope="session") +def aws_client_factory(aws_session): + """ + This fixture returns a client factory for testing. + + Use this fixture if you need to use custom endpoint or Boto config. + """ + from localstack.testing.aws.util import base_aws_client_factory + + return base_aws_client_factory(aws_session) + + +@pytest.fixture(scope="session") +def secondary_aws_client_factory(secondary_aws_session): + """ + This fixture returns a client factory for testing a secondary account. + + Use this fixture if you need to use custom endpoint or Boto config. + """ + from localstack.testing.aws.util import base_aws_client_factory + + return base_aws_client_factory(secondary_aws_session) + + +@pytest.fixture(scope="session") +def aws_client(aws_client_factory): + """ + This fixture can be used to obtain Boto clients for testing. + + The clients are configured with the primary testing credentials. + """ + from localstack.testing.aws.util import base_testing_aws_client + + return base_testing_aws_client(aws_client_factory) + + +@pytest.fixture(scope="session") +def secondary_aws_client(secondary_aws_client_factory): + """ + This fixture can be used to obtain Boto clients for testing a secondary account. + + The clients are configured with the secondary testing credentials. + The region is not overridden. + """ + from localstack.testing.aws.util import base_testing_aws_client + + return base_testing_aws_client(secondary_aws_client_factory) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index dfbd3b5bf9c75..e69de29bb2d1d 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,23 +0,0 @@ -import os -from localstack.constants import ENV_INTERNAL_TEST_RUN -from localstack.services import infra -from localstack.utils.common import cleanup, safe_requests - - -def setup_package(): - try: - os.environ[ENV_INTERNAL_TEST_RUN] = '1' - # disable SSL verification for local tests - safe_requests.verify_ssl = False - # start infrastructure services - infra.start_infra(async=True) - except Exception as e: - # make sure to tear down the infrastructure - infra.stop_infra() - raise e - - -def teardown_package(): - print('Shutdown') - cleanup(files=True) - infra.stop_infra() diff --git a/tests/integration/aws/__init__.py b/tests/integration/aws/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/integration/aws/test_app.py b/tests/integration/aws/test_app.py new file mode 100644 index 0000000000000..59341e5e9c5a5 --- /dev/null +++ b/tests/integration/aws/test_app.py @@ -0,0 +1,282 @@ +import json +import threading + +import httpx +import pytest +import requests +import websocket +from werkzeug import Request, Response +from werkzeug.exceptions import Forbidden + +from localstack import config +from localstack.http import route +from localstack.http.websocket import WebSocketRequest +from localstack.services.edge import ROUTER + + +class TestExceptionHandlers: + def test_internal_failure_handler_http_errors(self): + response = requests.delete(config.internal_service_url() + "/_localstack/health") + assert response.status_code == 405 + assert response.json() == { + "error": "Method Not Allowed", + "message": "The method is not allowed for the requested URL.", + } + assert "Allow" in response.headers + + @pytest.mark.skip( + reason="fails until the service request parser stops detecting custom route requests as s3 requests" + ) + def test_router_handler_get_http_errors(self, cleanups): + def _raise_error(_request): + raise Forbidden() + + rule = ROUTER.add("/_raise_error", _raise_error) + cleanups.append(lambda: ROUTER.remove(rule)) + + response = requests.get(config.internal_service_url() + "/_raise_error") + assert response.status_code == 403 + assert response.json() == { + "error": "Forbidden", + "message": "You don't have the permission to access the requested resource. It is " + "either read-protected or not readable by the server.", + } + + def test_router_handler_patch_http_errors(self, cleanups): + # this one works because PATCH operations are not detected by the service name parser as s3 requests + def _raise_error(_request): + raise Forbidden() + + rule = ROUTER.add("/_raise_error", _raise_error, methods=["PATCH"]) + cleanups.append(lambda: ROUTER.remove(rule)) + + response = requests.patch(config.internal_service_url() + "/_raise_error") + assert response.status_code == 403 + assert response.json() == { + "error": "Forbidden", + "message": "You don't have the permission to access the requested resource. It is " + "either read-protected or not readable by the server.", + } + + @pytest.mark.skip( + reason="fails until the service request parser stops detecting custom route requests as s3 requests" + ) + def test_router_handler_get_unexpected_errors(self, cleanups): + def _raise_error(_request): + raise ValueError("oh noes (this is expected)") + + rule = ROUTER.add("/_raise_error", _raise_error) + cleanups.append(lambda: ROUTER.remove(rule)) + + response = requests.get(config.internal_service_url() + "/_raise_error") + assert response.status_code == 500 + assert response.json() == { + "error": "Unexpected exception", + "message": "oh noes (this is expected)", + "type": "ValueError", + } + + def test_404_unfortunately_detected_as_s3_request(self): + # FIXME: this is because unknown routes have to be interpreted as s3 requests + response = requests.get(config.internal_service_url() + "/_raise_error") + assert response.status_code == 404 + assert "NoSuchBucket" in response.text + + +class TestWerkzeugIntegration: + def test_response_close_handlers_called_with_router(self, cleanups): + closed = threading.Event() + + def _test_route(_request): + r = Response("ok", 200) + r.call_on_close(closed.set) + return r + + rule = ROUTER.add("/_test/test_route", _test_route) + cleanups.append(lambda: ROUTER.remove(rule)) + + response = requests.get(config.internal_service_url() + "/_test/test_route") + assert response.status_code == 200, response.text + assert response.text == "ok" + + assert closed.wait(timeout=3), "expected closed.set to be called" + + def test_chunked_response_streaming(self, cleanups): + chunks = [bytes(f"{n:2}", "utf-8") for n in range(0, 100)] + + def chunk_generator(): + for chunk in chunks: + yield chunk + + def stream_response_handler(_request) -> Response: + return Response(response=chunk_generator()) + + rule = ROUTER.add("/_test/test_chunked_response", stream_response_handler) + cleanups.append(lambda: ROUTER.remove(rule)) + + with requests.get( + config.internal_service_url() + "/_test/test_chunked_response", stream=True + ) as r: + r.raise_for_status() + chunk_iterator = r.iter_content(chunk_size=None) + for i, chunk in enumerate(chunk_iterator): + assert chunk == chunks[i] + + def test_chunked_request_streaming(self, cleanups): + chunks = [bytes(f"{n:2}", "utf-8") for n in range(0, 100)] + + def handler(request: Request) -> Response: + data = request.get_data(parse_form_data=False) + return Response(response=data) + + rule = ROUTER.add("/_test/test_chunked_request", handler) + cleanups.append(lambda: ROUTER.remove(rule)) + + def chunk_generator(): + for chunk in chunks: + yield chunk + + response = requests.post( + config.internal_service_url() + "/_test/test_chunked_request", data=chunk_generator() + ) + assert response.content == b"".join(chunks) + + def test_raw_header_handling(self, cleanups): + def handler(request: Request) -> Response: + response = Response() + response.data = json.dumps({"headers": dict(request.headers)}) + response.mimetype = "application/json" + response.headers["X-fOO_bar"] = "FooBar" + return response + + rule = ROUTER.add("/_test/test_raw_header_handling", handler) + cleanups.append(lambda: ROUTER.remove(rule)) + + response = requests.get( + config.internal_service_url() + "/_test/test_raw_header_handling", + headers={"x-mIxEd-CaSe": "myheader", "X-UPPER__CASE": "uppercase"}, + ) + returned_headers = response.json()["headers"] + assert "X-UPPER__CASE" in returned_headers + assert "x-mIxEd-CaSe" in returned_headers + assert "X-fOO_bar" in dict(response.headers) + + +class TestHttps: + def test_default_cert_works(self): + response = requests.get( + config.internal_service_url(host="localhost.localstack.cloud", protocol="https") + + "/_localstack/health", + ) + assert response.ok + + +@pytest.mark.skipif( + condition=config.GATEWAY_SERVER not in ["hypercorn"], + reason=f"websockets not supported with {config.GATEWAY_SERVER}", +) +class TestWebSocketIntegration: + """ + Test for the WebSocket/HandlerChain integration. + """ + + def test_websockets_served_through_edge_router(self, cleanups): + @route("/_ws/", methods=["WEBSOCKET"]) + def _echo_websocket_handler(request: WebSocketRequest, param: str): + with request.accept() as ws: + ws.send(f"hello {param}") + for data in iter(ws): + ws.send(f"echo {data}") + if data == "exit": + return + + rule = ROUTER.add(_echo_websocket_handler) + cleanups.append(lambda: ROUTER.remove(rule)) + + url = config.internal_service_url(protocol="ws") + "/_ws/world" + + socket = websocket.WebSocket() + socket.connect(url) + assert socket.connected + assert socket.recv() == "hello world" + socket.send("foobar") + assert socket.recv() == "echo foobar" + socket.send("exit") + assert socket.recv() == "echo exit" + + socket.shutdown() + + def test_return_response(self, cleanups): + @route("/_ws/", methods=["WEBSOCKET"]) + def _echo_websocket_handler(request: WebSocketRequest, param: str): + # if the websocket isn't rejected or accepted, we can use the router to return a response + return Response("oh noes", 501) + + rule = ROUTER.add(_echo_websocket_handler) + cleanups.append(lambda: ROUTER.remove(rule)) + + url = config.internal_service_url(protocol="ws") + "/_ws/world" + + socket = websocket.WebSocket() + with pytest.raises(websocket.WebSocketBadStatusException) as e: + socket.connect(url) + + assert e.value.status_code == 501 + assert e.value.resp_body == b"oh noes" + + def test_websocket_reject_through_edge_router(self, cleanups): + @route("/_ws/", methods=["WEBSOCKET"]) + def _echo_websocket_handler(request: WebSocketRequest, param: str): + request.reject(Response("nope", 403)) + + rule = ROUTER.add(_echo_websocket_handler) + cleanups.append(lambda: ROUTER.remove(rule)) + + url = config.internal_service_url(protocol="ws") + "/_ws/world" + + socket = websocket.WebSocket() + with pytest.raises(websocket.WebSocketBadStatusException) as e: + socket.connect(url) + + assert e.value.status_code == 403 + assert e.value.resp_body == b"nope" + + def test_ssl_websockets(self, cleanups): + @route("/_ws/", methods=["WEBSOCKET"]) + def _echo_websocket_handler(request: WebSocketRequest, param: str): + with request.accept() as ws: + ws.send(f"hello {param}") + + rule = ROUTER.add(_echo_websocket_handler) + cleanups.append(lambda: ROUTER.remove(rule)) + + url = ( + config.internal_service_url(host="localhost.localstack.cloud", protocol="wss") + + "/_ws/world" + ) + socket = websocket.WebSocket() + socket.connect(url) + assert socket.connected + assert socket.recv() == "hello world" + + +class TestHTTP2Support: + @pytest.fixture(autouse=True) + def _fix_proxy(self, monkeypatch): + # on linux it also includes [::1], somehow leading to weird URL parsing issues in httpx + monkeypatch.setenv("no_proxy", "localhost.localstack.cloud,localhost,127.0.0.1") + + def test_http2_http(self): + host = config.internal_service_url(host="localhost.localstack.cloud", protocol="http") + with httpx.Client(http1=False, http2=True) as client: + assert client.get(f"{host}/_localstack/health").status_code == 200 + + def test_http2_https(self): + host = config.internal_service_url(host="localhost.localstack.cloud", protocol="https") + with httpx.Client(http1=False, http2=True) as client: + assert client.get(f"{host}/_localstack/health").status_code == 200 + + def test_http2_https_localhost(self): + host = config.internal_service_url(host="localhost", protocol="https") + with httpx.Client(http1=False, http2=True, verify=False) as client: + assert client.get(f"{host}/_localstack/health").status_code == 200 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000000000..66e0a5abc4fdc --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,14 @@ +from _pytest.config import Config + +from localstack import config as localstack_config +from localstack import constants + + +def pytest_configure(config: Config): + # FIXME: note that this should be the same as in tests/aws/conftest.py since both are currently run in + # the same CI test step, but only one localstack instance is started for both. + config.option.start_localstack = True + localstack_config.FORCE_SHUTDOWN = False + localstack_config.GATEWAY_LISTEN = localstack_config.UniqueHostAndPortList( + [localstack_config.HostAndPort(host="0.0.0.0", port=constants.DEFAULT_PORT_EDGE)] + ) diff --git a/tests/integration/docker_utils/__init__.py b/tests/integration/docker_utils/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/integration/docker_utils/conftest.py b/tests/integration/docker_utils/conftest.py new file mode 100644 index 0000000000000..39860824f8a58 --- /dev/null +++ b/tests/integration/docker_utils/conftest.py @@ -0,0 +1,46 @@ +import os +from typing import Type + +import pytest + +from localstack.config import is_env_not_false +from localstack.utils.container_utils.container_client import ContainerClient +from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient +from localstack.utils.container_utils.docker_sdk_client import SdkDockerClient + + +def _check_skip(client: ContainerClient): + if not is_env_not_false("SKIP_DOCKER_TESTS"): + pytest.skip("SKIP_DOCKER_TESTS is set") + + if not client.has_docker(): + pytest.skip("Docker is not available") + + +@pytest.fixture( + params=[CmdDockerClient, SdkDockerClient], + ids=["CmdDockerClient", "SdkDockerClient"], + scope="class", +) +def docker_client_class(request) -> Type[ContainerClient]: + return request.param + + +@pytest.fixture(scope="class") +def docker_client(docker_client_class): + client = docker_client_class() + _check_skip( + client + ) # this is a hack to get a global skip for all tests that require the docker client + yield client + + +def is_podman_test(): + return os.environ.get("DOCKER_CMD") == "podman" + + +# marker to indicate tests that don't work against Podman (i.e., should only be run against Docker) +skip_for_podman = pytest.mark.skipif( + is_podman_test(), + reason="Test not applicable when run against Podman (only Docker)", +) diff --git a/tests/integration/docker_utils/test_docker.py b/tests/integration/docker_utils/test_docker.py new file mode 100644 index 0000000000000..4f62f0076bfce --- /dev/null +++ b/tests/integration/docker_utils/test_docker.py @@ -0,0 +1,2070 @@ +import datetime +import ipaddress +import json +import logging +import os +import re +import textwrap +import time +from typing import Callable, NamedTuple, Type + +import pytest +from docker.models.containers import Container + +from localstack import config +from localstack.config import in_docker +from localstack.testing.pytest import markers +from localstack.utils import docker_utils +from localstack.utils.common import is_ipv4_address, save_file, short_uid, to_str +from localstack.utils.container_utils.container_client import ( + AccessDenied, + ContainerClient, + ContainerException, + DockerContainerStats, + DockerContainerStatus, + DockerNotAvailable, + LogConfig, + NoSuchContainer, + NoSuchImage, + NoSuchNetwork, + PortMappings, + RegistryConnectionError, + Ulimit, + Util, + VolumeInfo, +) +from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient +from localstack.utils.container_utils.docker_sdk_client import SdkDockerClient +from localstack.utils.docker_utils import ( + container_ports_can_be_bound, + is_container_port_reserved, + is_port_available_for_containers, + reserve_available_container_port, + reserve_container_port, +) +from localstack.utils.net import Port, PortNotAvailableException, get_free_tcp_port +from localstack.utils.strings import to_bytes +from localstack.utils.sync import retry +from localstack.utils.threads import FuncThread +from tests.integration.docker_utils.conftest import is_podman_test, skip_for_podman + +ContainerInfo = NamedTuple( + "ContainerInfo", + [ + ("container_id", str), + ("container_name", str), + ], +) + +LOG = logging.getLogger(__name__) + +container_name_prefix = "lst_test_" + + +def _random_container_name() -> str: + return f"{container_name_prefix}{short_uid()}" + + +def _is_podman_test() -> bool: + """Return whether this is a test running against Podman""" + return os.getenv("DOCKER_CMD") == "podman" + + +def _assert_container_state(docker_client: ContainerClient, name: str, is_running: bool): + assert docker_client.is_container_running(name) == is_running + + +@pytest.fixture +def dummy_container(create_container): + """Returns a container that is created but not started""" + return create_container("alpine", command=["sh", "-c", "while true; do sleep 1; done"]) + + +@pytest.fixture +def create_container(docker_client: ContainerClient, create_network): + """ + Uses the factory as fixture pattern to wrap ContainerClient.create_container as a factory that + removes the containers after the fixture is cleaned up. + + Depends on create network for correct cleanup order + """ + containers = [] + + def _create_container(image_name: str, **kwargs) -> ContainerInfo: + kwargs["name"] = kwargs.get("name", _random_container_name()) + cid = docker_client.create_container(image_name, **kwargs) + cid = cid.strip() + containers.append(cid) + return ContainerInfo(cid, kwargs["name"]) # FIXME name should come from docker_client + + yield _create_container + + for c in containers: + try: + docker_client.remove_container(c) + except Exception: + LOG.warning("failed to remove test container %s", c) + + +@pytest.fixture +def create_network(docker_client: ContainerClient): + """ + Uses the factory as fixture pattern to wrap the creation of networks as a factory that + removes the networks after the fixture is cleaned up. + """ + networks = [] + + def _create_network(network_name: str): + network_id = docker_client.create_network(network_name=network_name) + networks.append(network_id) + return network_id + + yield _create_network + + for network in networks: + try: + LOG.debug("Removing network %s", network) + docker_client.delete_network(network_name=network) + except ContainerException as e: + LOG.debug("Error while cleaning up network %s: %s", network, e) + + +class TestDockerClient: + def test_get_system_info(self, docker_client: ContainerClient): + info = docker_client.get_system_info() + assert "ID" in info + assert "OperatingSystem" in info + assert "Architecture" in info + + def test_get_system_id(self, docker_client: ContainerClient): + assert len(docker_client.get_system_id()) > 1 + assert docker_client.get_system_id() == docker_client.get_system_id() + + def test_container_lifecycle_commands(self, docker_client: ContainerClient): + container_name = _random_container_name() + output = docker_client.create_container( + "alpine", + name=container_name, + command=["sh", "-c", "for i in `seq 30`; do sleep 1; echo $i; done"], + ) + container_id = output.strip() + assert container_id + + try: + docker_client.start_container(container_id) + assert DockerContainerStatus.UP == docker_client.get_container_status(container_name) + + # consider different "paused" statuses for Docker / Podman + docker_client.pause_container(container_id) + expected_statuses = (DockerContainerStatus.PAUSED, DockerContainerStatus.DOWN) + container_status = docker_client.get_container_status(container_name) + assert container_status in expected_statuses + + docker_client.unpause_container(container_id) + assert DockerContainerStatus.UP == docker_client.get_container_status(container_name) + + docker_client.restart_container(container_id) + assert docker_client.get_container_status(container_name) == DockerContainerStatus.UP + + docker_client.stop_container(container_id) + assert DockerContainerStatus.DOWN == docker_client.get_container_status(container_name) + finally: + docker_client.remove_container(container_id) + + assert DockerContainerStatus.NON_EXISTENT == docker_client.get_container_status( + container_name + ) + + def test_create_container_remove_removes_container( + self, docker_client: ContainerClient, create_container + ): + info = create_container("alpine", remove=True, command=["echo", "foobar"]) + # make sure it was correctly created + assert 1 == len(docker_client.list_containers(f"id={info.container_id}")) + + # start the container + output, _ = docker_client.start_container(info.container_id, attach=True) + output = to_str(output) + time.sleep(1) # give the docker daemon some time to remove the container after execution + + assert 0 == len(docker_client.list_containers(f"id={info.container_id}")) + + # it takes a while for it to be removed + assert "foobar" in output + + @pytest.mark.parametrize( + "entrypoint", + [ + "echo", + ["echo"], + ], + ) + def test_set_container_entrypoint( + self, + docker_client: ContainerClient, + create_container: Callable[..., ContainerInfo], + entrypoint: list[str] | str, + ): + info = create_container("alpine", entrypoint=entrypoint, command=["true"]) + assert 1 == len(docker_client.list_containers(f"id={info.container_id}")) + + # start the container + output, _ = docker_client.start_container(info.container_id, attach=True) + output = to_str(output).strip() + + assert output == "true" + + @markers.skip_offline + def test_create_container_non_existing_image(self, docker_client: ContainerClient): + with pytest.raises(NoSuchImage): + docker_client.create_container("this_image_does_hopefully_not_exist_42069") + + def test_exec_in_container( + self, docker_client: ContainerClient, dummy_container: ContainerInfo + ): + docker_client.start_container(dummy_container.container_id) + + output, _ = docker_client.exec_in_container( + dummy_container.container_id, command=["echo", "foobar"] + ) + output = to_str(output) + assert "foobar" == output.strip() + + def test_exec_in_container_not_running_raises_exception( + self, docker_client: ContainerClient, dummy_container + ): + with pytest.raises(ContainerException): + # can't exec into a non-running container + docker_client.exec_in_container( + dummy_container.container_id, command=["echo", "foobar"] + ) + + def test_exec_in_container_with_workdir(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + workdir = "/proc/sys" + + output, _ = docker_client.exec_in_container( + dummy_container.container_id, command=["pwd"], workdir=workdir + ) + assert to_str(output).strip() == workdir + + def test_exec_in_container_with_env(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + + env = {"MYVAR": "foo_var"} + + output, _ = docker_client.exec_in_container( + dummy_container.container_id, env_vars=env, command=["env"] + ) + output = output.decode(config.DEFAULT_ENCODING) + assert "MYVAR=foo_var" in output + + @skip_for_podman + def test_exec_in_container_with_env_deletion( + self, docker_client: ContainerClient, create_container + ): + container_info = create_container( + "alpine", + command=["sh", "-c", "env; while true; do sleep 1; done"], + env_vars={"MYVAR": "SHOULD_BE_OVERWRITTEN"}, + ) + docker_client.start_container(container_info.container_id) + log_output = docker_client.get_container_logs( + container_name_or_id=container_info.container_id + ) + assert "MYVAR=SHOULD_BE_OVERWRITTEN" in log_output + + env = {"MYVAR": "test123"} + output, _ = docker_client.exec_in_container( + container_info.container_id, env_vars=env, command=["env"] + ) + assert "MYVAR=test123" in to_str(output) + + # TODO: doesn't work for podman CmdDockerClient - check if we're relying on this behavior + env = {"MYVAR": None} + output, _ = docker_client.exec_in_container( + container_info.container_id, env_vars=env, command=["env"] + ) + assert "MYVAR" not in to_str(output) + + def test_exec_error_in_container(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + + with pytest.raises(ContainerException) as ex: + docker_client.exec_in_container( + dummy_container.container_id, command=["./doesnotexist"] + ) + + # consider different error messages for Docker/Podman + error_messages = ("doesnotexist: no such file or directory", "No such file or directory") + assert any(msg in str(ex) for msg in error_messages) + + def test_create_container_with_max_env_vars( + self, docker_client: ContainerClient, create_container + ): + # default ARG_MAX=131072 in Docker + env = {f"IVAR_{i:05d}": f"VAL_{i:05d}" for i in range(2000)} + + # make sure we're really triggering the relevant code + assert len(str(dict(env))) >= Util.MAX_ENV_ARGS_LENGTH + + info = create_container("alpine", env_vars=env, command=["env"]) + output, _ = docker_client.start_container(info.container_id, attach=True) + output = output.decode(config.DEFAULT_ENCODING) + + assert "IVAR_00001=VAL_00001" in output + assert "IVAR_01000=VAL_01000" in output + assert "IVAR_01999=VAL_01999" in output + + def test_run_container(self, docker_client: ContainerClient): + container_name = _random_container_name() + try: + output, _ = docker_client.run_container( + "alpine", + name=container_name, + command=["echo", "foobared"], + ) + output = output.decode(config.DEFAULT_ENCODING) + assert "foobared" in output + finally: + docker_client.remove_container(container_name) + + def test_run_container_error(self, docker_client: ContainerClient): + container_name = _random_container_name() + try: + with pytest.raises(ContainerException): + docker_client.run_container( + "alpine", + name=container_name, + command=["./doesnotexist"], + ) + finally: + docker_client.remove_container(container_name) + + def test_stop_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.stop_container("this_container_does_not_exist") + + def test_restart_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.restart_container("this_container_does_not_exist") + + def test_pause_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.pause_container("this_container_does_not_exist") + + def test_unpause_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.pause_container("this_container_does_not_exist") + + def test_remove_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.remove_container("this_container_does_not_exist", force=False) + + def test_start_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.start_container("this_container_does_not_exist") + + def test_docker_not_available(self, docker_client_class: Type[ContainerClient], monkeypatch): + monkeypatch.setattr(config, "DOCKER_CMD", "non-existing-binary") + monkeypatch.setenv("DOCKER_HOST", "/var/run/docker.sock1") + # initialize the client after mocking the environment + docker_client = docker_client_class() + with pytest.raises(DockerNotAvailable): + # perform a command to trigger the exception + docker_client.list_containers() + + def test_create_container_with_init(self, docker_client, create_container): + try: + container_name = _random_container_name() + docker_client.create_container( + "alpine", init=True, command=["sh", "-c", "/bin/true"], name=container_name + ) + assert docker_client.inspect_container(container_name)["HostConfig"]["Init"] + finally: + docker_client.remove_container(container_name) + + def test_run_container_with_init(self, docker_client, create_container): + try: + container_name = _random_container_name() + docker_client.run_container( + "alpine", init=True, command=["sh", "-c", "/bin/true"], name=container_name + ) + assert docker_client.inspect_container(container_name)["HostConfig"]["Init"] + finally: + docker_client.remove_container(container_name) + + # TODO: currently failing under Podman in CI (works locally under macOS) + @pytest.mark.skipif( + condition=_is_podman_test(), + reason="Podman get_networks(..) does not return list of networks in CI", + ) + def test_get_network(self, docker_client: ContainerClient, dummy_container): + networks = docker_client.get_networks(dummy_container.container_name) + expected_networks = [_get_default_network()] + assert networks == expected_networks + + # TODO: skipped due to "Error: "slirp4netns" is not supported: invalid network mode" in CI + @skip_for_podman + def test_get_network_multiple_networks( + self, docker_client: ContainerClient, dummy_container, create_network + ): + network_name = f"test-network-{short_uid()}" + network_id = create_network(network_name) + docker_client.connect_container_to_network( + network_name=network_id, container_name_or_id=dummy_container.container_id + ) + docker_client.start_container(dummy_container.container_id) + networks = docker_client.get_networks(dummy_container.container_id) + assert network_name in networks + assert _get_default_network() in networks + assert len(networks) == 2 + + # TODO: skipped due to "Error: "slirp4netns" is not supported: invalid network mode" in CI + @skip_for_podman + def test_get_container_ip_for_network( + self, docker_client: ContainerClient, dummy_container, create_network + ): + network_name = f"test-network-{short_uid()}" + network_id = create_network(network_name) + docker_client.connect_container_to_network( + network_name=network_id, container_name_or_id=dummy_container.container_id + ) + docker_client.start_container(dummy_container.container_id) + default_network = _get_default_network() + result_bridge_network = docker_client.get_container_ipv4_for_network( + container_name_or_id=dummy_container.container_id, container_network=default_network + ).strip() + assert is_ipv4_address(result_bridge_network) + bridge_network = docker_client.inspect_network(default_network)["IPAM"]["Config"][0][ + "Subnet" + ] + assert ipaddress.IPv4Address(result_bridge_network) in ipaddress.IPv4Network(bridge_network) + result_custom_network = docker_client.get_container_ipv4_for_network( + container_name_or_id=dummy_container.container_id, container_network=network_name + ).strip() + assert is_ipv4_address(result_custom_network) + assert result_custom_network != result_bridge_network + custom_network = docker_client.inspect_network(network_name)["IPAM"]["Config"][0]["Subnet"] + assert ipaddress.IPv4Address(result_custom_network) in ipaddress.IPv4Network(custom_network) + + # TODO: currently failing under Podman + @pytest.mark.skipif( + condition=_is_podman_test(), + reason="Podman inspect_network does not return `Containers` attribute", + ) + def test_get_container_ip_for_network_wrong_network( + self, docker_client: ContainerClient, dummy_container, create_network + ): + network_name = f"test-network-{short_uid()}" + create_network(network_name) + docker_client.start_container(dummy_container.container_id) + result_bridge_network = docker_client.get_container_ipv4_for_network( + container_name_or_id=dummy_container.container_id, + container_network=_get_default_network(), + ).strip() + assert is_ipv4_address(result_bridge_network) + + with pytest.raises(ContainerException): + docker_client.get_container_ipv4_for_network( + container_name_or_id=dummy_container.container_id, container_network=network_name + ) + + # TODO: currently failing under Podman in CI (works locally under macOS) + @pytest.mark.skipif( + condition=_is_podman_test(), + reason="Podman get_networks(..) does not return list of networks in CI", + ) + def test_get_container_ip_for_host_network( + self, docker_client: ContainerClient, create_container + ): + container = create_container( + "alpine", command=["sh", "-c", "while true; do sleep 1; done"], network="host" + ) + assert "host" == docker_client.get_networks(container.container_name)[0] + # host network containers have no dedicated IP, so it will throw an exception here + with pytest.raises(ContainerException): + docker_client.get_container_ipv4_for_network( + container_name_or_id=container.container_name, container_network="host" + ) + + def test_get_container_ip_for_network_non_existent_network( + self, docker_client: ContainerClient, dummy_container, create_network + ): + network_name = f"invalid-test-network-{short_uid()}" + docker_client.start_container(dummy_container.container_id) + with pytest.raises(NoSuchNetwork): + docker_client.get_container_ipv4_for_network( + container_name_or_id=dummy_container.container_id, container_network=network_name + ) + + # TODO: currently failing under Podman in CI (works locally under macOS) + @pytest.mark.skipif( + condition=_is_podman_test(), + reason="Podman get_networks(..) does not return list of networks in CI", + ) + def test_create_with_host_network(self, docker_client: ContainerClient, create_container): + info = create_container("alpine", network="host") + network = docker_client.get_networks(info.container_name) + assert ["host"] == network + + def test_create_with_port_mapping(self, docker_client: ContainerClient, create_container): + ports = PortMappings() + ports.add(45122, 22) + ports.add(45180, 80) + create_container("alpine", ports=ports) + + # TODO: This test must be fixed for SdkDockerClient + def test_create_with_exposed_ports(self, docker_client: ContainerClient, create_container): + if isinstance(docker_client, SdkDockerClient): + pytest.skip("Test skipped for SdkDockerClient") + exposed_ports = ["45000", "45001/udp"] + container = create_container( + "alpine", + command=["sh", "-c", "while true; do sleep 1; done"], + exposed_ports=exposed_ports, + ) + docker_client.start_container(container.container_id) + inspection_result = docker_client.inspect_container(container.container_id) + assert inspection_result["Config"]["ExposedPorts"] == { + f"{port}/tcp" if "/" not in port else port: {} for port in exposed_ports + } + assert inspection_result["NetworkSettings"]["Ports"] == { + f"{port}/tcp" if "/" not in port else port: None for port in exposed_ports + } + + @pytest.mark.skipif( + condition=in_docker(), reason="cannot test volume mounts from host when in docker" + ) + def test_create_with_volume(self, tmpdir, docker_client: ContainerClient, create_container): + volumes = [(tmpdir.realpath(), "/tmp/mypath")] + + c = create_container( + "alpine", + command=["sh", "-c", "echo 'foobar' > /tmp/mypath/foo.log"], + volumes=volumes, + ) + docker_client.start_container(c.container_id) + assert tmpdir.join("foo.log").isfile(), "foo.log was not created in mounted dir" + + @pytest.mark.skipif( + condition=in_docker(), reason="cannot test volume mounts from host when in docker" + ) + @skip_for_podman # TODO: Volume mounting test currently not working against Podman + def test_inspect_container_volumes( + self, tmpdir, docker_client: ContainerClient, create_container + ): + volumes = [ + (tmpdir.realpath() / "foo", "/tmp/mypath/foo"), + ("some_named_volume", "/tmp/mypath/volume"), + ] + + c = create_container( + "alpine", + command=["sh", "-c", "while true; do sleep 1; done"], + volumes=volumes, + ) + docker_client.start_container(c.container_id) + + vols = docker_client.inspect_container_volumes(c.container_id) + + # FIXME cmd docker client creates different default permission mode flags + if isinstance(docker_client, CmdDockerClient): + vol1 = VolumeInfo( + type="bind", + source=f"{tmpdir}/foo", + destination="/tmp/mypath/foo", + mode="", + rw=True, + propagation="rprivate", + name=None, + driver=None, + ) + vol2 = VolumeInfo( + type="volume", + source="/var/lib/docker/volumes/some_named_volume/_data", + destination="/tmp/mypath/volume", + mode="z", + rw=True, + propagation="", + name="some_named_volume", + driver="local", + ) + else: + vol1 = VolumeInfo( + type="bind", + source=f"{tmpdir}/foo", + destination="/tmp/mypath/foo", + mode="rw", + rw=True, + propagation="rprivate", + name=None, + driver=None, + ) + vol2 = VolumeInfo( + type="volume", + source="/var/lib/docker/volumes/some_named_volume/_data", + destination="/tmp/mypath/volume", + mode="rw", + rw=True, + propagation="", + name="some_named_volume", + driver="local", + ) + + assert vol1 in vols + assert vol2 in vols + + def test_inspect_container_volumes_with_no_volumes( + self, docker_client: ContainerClient, dummy_container + ): + docker_client.start_container(dummy_container.container_id) + assert len(docker_client.inspect_container_volumes(dummy_container.container_id)) == 0 + + def test_copy_into_container(self, tmpdir, docker_client: ContainerClient, create_container): + local_path = tmpdir.join("myfile.txt") + container_path = "/tmp/myfile_differentpath.txt" + + self._test_copy_into_container( + docker_client, + create_container, + ["cat", container_path], + local_path, + local_path, + container_path, + ) + + def test_copy_into_non_existent_container(self, tmpdir, docker_client: ContainerClient): + local_path = tmpdir.mkdir("test_dir") + file_path = local_path.join("test_file") + with file_path.open(mode="w") as fd: + fd.write("foobared\n") + with pytest.raises(NoSuchContainer): + docker_client.copy_into_container( + f"hopefully_non_existent_container_{short_uid()}", str(file_path), "test_file" + ) + + def test_copy_into_container_without_target_filename( + self, tmpdir, docker_client: ContainerClient, create_container + ): + local_path = tmpdir.join("myfile.txt") + container_path = "/tmp" + + self._test_copy_into_container( + docker_client, + create_container, + ["cat", "/tmp/myfile.txt"], + local_path, + local_path, + container_path, + ) + + def test_copy_directory_into_container( + self, tmpdir, docker_client: ContainerClient, create_container + ): + local_path = tmpdir.join("fancy_folder") + local_path.mkdir() + + file_path = local_path.join("myfile.txt") + container_path = "/tmp/fancy_other_folder" + + self._test_copy_into_container( + docker_client, + create_container, + ["cat", "/tmp/fancy_other_folder/myfile.txt"], + file_path, + local_path, + container_path, + ) + + def _test_copy_into_container( + self, docker_client, create_container, command, file_path, local_path, container_path + ): + c = create_container("alpine", command=command) + + with file_path.open(mode="w") as fd: + fd.write("foobared\n") + + docker_client.copy_into_container(c.container_name, str(local_path), container_path) + + output, _ = docker_client.start_container(c.container_id, attach=True) + output = output.decode(config.DEFAULT_ENCODING) + + assert "foobared" in output + + def test_copy_into_container_with_existing_target( + self, tmpdir, docker_client: ContainerClient, dummy_container + ): + local_path = tmpdir.join("myfile.txt") + container_path = "/tmp/myfile.txt" + + with local_path.open(mode="w") as fd: + fd.write("foo\n") + + docker_client.start_container(dummy_container.container_id) + docker_client.exec_in_container( + dummy_container.container_id, command=["sh", "-c", f"echo bar > {container_path}"] + ) + + out, _ = docker_client.exec_in_container( + dummy_container.container_id, + command=[ + "cat", + "/tmp/myfile.txt", + ], + ) + assert "bar" in out.decode(config.DEFAULT_ENCODING) + docker_client.copy_into_container( + dummy_container.container_id, str(local_path), container_path + ) + out, _ = docker_client.exec_in_container( + dummy_container.container_id, + command=[ + "cat", + "/tmp/myfile.txt", + ], + ) + assert "foo" in out.decode(config.DEFAULT_ENCODING) + + def test_copy_directory_content_into_container( + self, tmpdir, docker_client: ContainerClient, dummy_container + ): + local_path = tmpdir.join("fancy_folder") + local_path.mkdir() + + file_path = local_path.join("myfile.txt") + with file_path.open(mode="w") as fd: + fd.write("foo\n") + file_path = local_path.join("myfile2.txt") + with file_path.open(mode="w") as fd: + fd.write("bar\n") + container_path = "/tmp/fancy_other_folder" + docker_client.start_container(dummy_container.container_id) + docker_client.exec_in_container( + dummy_container.container_id, command=["mkdir", "-p", container_path] + ) + docker_client.copy_into_container( + dummy_container.container_id, f"{str(local_path)}/.", container_path + ) + out, _ = docker_client.exec_in_container( + dummy_container.container_id, + command=[ + "cat", + "/tmp/fancy_other_folder/myfile.txt", + "/tmp/fancy_other_folder/myfile2.txt", + ], + ) + assert "foo" in out.decode(config.DEFAULT_ENCODING) + assert "bar" in out.decode(config.DEFAULT_ENCODING) + + def test_copy_directory_structure_into_container( + self, tmpdir, docker_client: ContainerClient, create_container + ): + container = create_container( + image_name="public.ecr.aws/lambda/python:3.9", + entrypoint="", + command=["sh", "-c", "while true; do sleep 1; done"], + ) + local_path = tmpdir.join("fancy_folder") + local_path.mkdir() + sub_path = local_path.join("inner_folder") + sub_path.mkdir() + sub_sub_path = sub_path.join("innerinner_folder") + sub_sub_path.mkdir() + + file_path = sub_sub_path.join("myfile.txt") + with file_path.open(mode="w") as fd: + fd.write("foo\n") + container_path = "/" + docker_client.copy_into_container(container.container_id, str(local_path), container_path) + docker_client.start_container(container.container_id) + out, _ = docker_client.exec_in_container( + container.container_id, + command=[ + "cat", + "/fancy_folder/inner_folder/innerinner_folder/myfile.txt", + ], + ) + assert "foo" in out.decode(config.DEFAULT_ENCODING) + + def test_create_file_in_container( + self, tmpdir, docker_client: ContainerClient, create_container + ): + content = b"fancy content" + container_path = "/tmp/myfile.txt" + + c = create_container("alpine", command=["cat", container_path]) + + docker_client.create_file_in_container(c.container_name, content, container_path) + + output, _ = docker_client.start_container(c.container_id, attach=True) + assert output == content + + def test_get_network_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(ContainerException): + docker_client.get_networks("this_container_does_not_exist") + + def test_list_containers(self, docker_client: ContainerClient, create_container): + c1 = create_container("alpine", command=["echo", "1"]) + c2 = create_container("alpine", command=["echo", "2"]) + c3 = create_container("alpine", command=["echo", "3"]) + + container_list = docker_client.list_containers() + + assert len(container_list) >= 3 + + image_names = [info["name"] for info in container_list] + + assert c1.container_name in image_names + assert c2.container_name in image_names + assert c3.container_name in image_names + + def test_list_containers_filter_non_existing(self, docker_client: ContainerClient): + container_list = docker_client.list_containers(filter="id=DOES_NOT_EXST") + assert 0 == len(container_list) + + def test_list_containers_filter_illegal_filter(self, docker_client: ContainerClient): + with pytest.raises(ContainerException): + docker_client.list_containers(filter="illegalfilter=foobar") + + def test_list_containers_filter(self, docker_client: ContainerClient, create_container): + name_prefix = "filter_tests_" + cn1 = name_prefix + _random_container_name() + cn2 = name_prefix + _random_container_name() + cn3 = name_prefix + _random_container_name() + + c1 = create_container("alpine", name=cn1, command=["echo", "1"]) + c2 = create_container("alpine", name=cn2, command=["echo", "2"]) + c3 = create_container("alpine", name=cn3, command=["echo", "3"]) + + # per id + container_list = docker_client.list_containers(filter=f"id={c2.container_id}") + assert 1 == len(container_list) + assert c2.container_id.startswith(container_list[0]["id"]) + assert c2.container_name == container_list[0]["name"] + # note: Docker returns "created", Podman returns "configured" + assert container_list[0]["status"] in ["created", "configured"] + + # per name pattern + container_list = docker_client.list_containers(filter=f"name={name_prefix}") + assert 3 == len(container_list) + image_names = [info["name"] for info in container_list] + assert c1.container_name in image_names + assert c2.container_name in image_names + assert c3.container_name in image_names + + # multiple patterns + container_list = docker_client.list_containers( + filter=[ + f"id={c1.container_id}", + f"name={container_name_prefix}", + ] + ) + assert 1 == len(container_list) + assert c1.container_name == container_list[0]["name"] + + def test_list_containers_with_podman_image_ref_format( + self, docker_client: ContainerClient, create_container, cleanups, monkeypatch + ): + # create custom image tag + image_name = f"alpine:tag-{short_uid()}" + _pull_image_if_not_exists(docker_client, "alpine") + docker_client.tag_image("alpine", image_name) + cleanups.append(lambda: docker_client.remove_image(image_name)) + + # apply patch to simulate podman behavior + container_init_orig = Container.__init__ + + def container_init(self, attrs=None, *args, **kwargs): + # Simulate podman API response, Docker returns "sha:..." for Image, podman returns ":". + # See https://github.com/containers/podman/issues/8329 + attrs["Image"] = image_name + container_init_orig(self, *args, attrs=attrs, **kwargs) + + monkeypatch.setattr(Container, "__init__", container_init) + + # start a container from the custom image tag + c1 = create_container(image_name, command=["sleep", "3"]) + docker_client.start_container(c1.container_id, attach=False) + + # list containers, assert that container is contained in the list + container_list = docker_client.list_containers() + running_containers = [cnt for cnt in container_list if cnt["status"] == "running"] + assert running_containers + container_names = [info["name"] for info in container_list] + assert c1.container_name in container_names + + # assert that get_running_container_names(..) call is successful as well + container_names = docker_client.get_running_container_names() + assert len(running_containers) == len(container_names) + assert c1.container_name in container_names + + def test_get_container_entrypoint(self, docker_client: ContainerClient): + entrypoint = docker_client.get_image_entrypoint("alpine") + assert "" == entrypoint + + def test_get_container_entrypoint_non_existing_image(self, docker_client: ContainerClient): + with pytest.raises(NoSuchImage): + docker_client.get_image_entrypoint("thisdoesnotexist") + + def test_get_container_entrypoint_not_pulled_image(self, docker_client: ContainerClient): + try: + docker_client.get_image_cmd("alpine", pull=False) + docker_client.remove_image("alpine") + except ContainerException: + pass + entrypoint = docker_client.get_image_entrypoint("alpine") + assert "" == entrypoint + + def test_get_container_command(self, docker_client: ContainerClient): + command = docker_client.get_image_cmd("alpine") + assert ["/bin/sh"] == command + + def test_get_container_command_not_pulled_image(self, docker_client: ContainerClient): + try: + docker_client.get_image_cmd("alpine", pull=False) + docker_client.remove_image("alpine") + except ContainerException: + pass + command = docker_client.get_image_cmd("alpine") + assert ["/bin/sh"] == command + + def test_get_container_command_non_existing_image(self, docker_client: ContainerClient): + with pytest.raises(NoSuchImage): + docker_client.get_image_cmd("thisdoesnotexist") + + @pytest.mark.parametrize("attach", [True, False]) + def test_create_start_container_with_stdin_to_stdout( + self, attach: bool, docker_client: ContainerClient + ): + if isinstance(docker_client, CmdDockerClient) and _is_podman_test() and not attach: + # TODO: Podman behavior deviates from Docker if attach=False (prints container ID instead of stdin) + pytest.skip("Podman output deviates from Docker if attach=False") + container_name = _random_container_name() + message = "test_message_stdin" + try: + docker_client.create_container( + "alpine", + name=container_name, + interactive=True, + command=["cat"], + ) + + output, _ = docker_client.start_container( + container_name, interactive=True, stdin=to_bytes(message), attach=attach + ) + output = to_str(output) + + assert message == output.strip() + finally: + docker_client.remove_container(container_name) + + @pytest.mark.parametrize("attach", [True, False]) + def test_create_start_container_with_stdin_to_file( + self, tmpdir, attach, docker_client: ContainerClient + ): + if isinstance(docker_client, CmdDockerClient) and _is_podman_test() and not attach: + # TODO: Podman behavior deviates from Docker if attach=False (prints container ID instead of stdin) + pytest.skip("Podman output deviates from Docker if attach=False") + + container_name = _random_container_name() + message = "test_message_stdin" + try: + docker_client.create_container( + "alpine", + name=container_name, + interactive=True, + command=["sh", "-c", "cat > test_file"], + ) + + output, _ = docker_client.start_container( + container_name, + interactive=True, + stdin=message.encode(config.DEFAULT_ENCODING), + attach=attach, + ) + target_path = tmpdir.join("test_file") + docker_client.copy_from_container(container_name, str(target_path), "test_file") + + assert message == target_path.read().strip() + finally: + docker_client.remove_container(container_name) + + def test_run_container_with_stdin(self, docker_client: ContainerClient): + container_name = _random_container_name() + message = "test_message_stdin" + try: + output, _ = docker_client.run_container( + "alpine", + name=container_name, + interactive=True, + stdin=message.encode(config.DEFAULT_ENCODING), + command=["cat"], + ) + + assert message == output.decode(config.DEFAULT_ENCODING).strip() + finally: + docker_client.remove_container(container_name) + + def test_exec_in_container_with_stdin(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + message = "test_message_stdin" + output, _ = docker_client.exec_in_container( + dummy_container.container_id, + interactive=True, + stdin=message.encode(config.DEFAULT_ENCODING), + command=["cat"], + ) + + assert message == output.decode(config.DEFAULT_ENCODING).strip() + + def test_exec_in_container_with_stdin_stdout_stderr( + self, docker_client: ContainerClient, dummy_container + ): + docker_client.start_container(dummy_container.container_id) + message = "test_message_stdin" + output, stderr = docker_client.exec_in_container( + dummy_container.container_id, + interactive=True, + stdin=message.encode(config.DEFAULT_ENCODING), + command=["sh", "-c", "cat; >&2 echo stderrtest"], + ) + + assert message == output.decode(config.DEFAULT_ENCODING).strip() + assert "stderrtest" == stderr.decode(config.DEFAULT_ENCODING).strip() + + def test_run_detached_with_logs(self, docker_client: ContainerClient): + container_name = _random_container_name() + message = "test_message" + try: + output, _ = docker_client.run_container( + "alpine", + name=container_name, + detach=True, + command=["echo", message], + ) + container_id = output.decode(config.DEFAULT_ENCODING).strip() + logs = docker_client.get_container_logs(container_id) + + assert message == logs.strip() + finally: + docker_client.remove_container(container_name) + + def test_get_logs_non_existent_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.get_container_logs("container_hopefully_does_not_exist", safe=False) + + assert "" == docker_client.get_container_logs( + "container_hopefully_does_not_exist", safe=True + ) + + def test_get_logs(self, docker_client: ContainerClient): + container_name = _random_container_name() + try: + docker_client.run_container( + "alpine", + name=container_name, + detach=True, + command=["env"], + ) + + logs = docker_client.get_container_logs(container_name) + assert "PATH=" in logs + assert "HOSTNAME=" in logs + assert "HOME=/root" in logs + + finally: + docker_client.remove_container(container_name) + + def test_stream_logs_non_existent_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.stream_container_logs("container_hopefully_does_not_exist") + + def test_stream_logs(self, docker_client: ContainerClient): + container_name = _random_container_name() + try: + docker_client.run_container( + "alpine", + name=container_name, + detach=True, + command=["env"], + ) + + stream = docker_client.stream_container_logs(container_name) + for line in stream: + line = line.decode("utf-8") + assert line.split("=")[0] in ["HOME", "PATH", "HOSTNAME"] + + stream.close() + + finally: + docker_client.remove_container(container_name) + + @markers.skip_offline + def test_pull_docker_image(self, docker_client: ContainerClient): + try: + docker_client.get_image_cmd("alpine", pull=False) + docker_client.remove_image("alpine") + except ContainerException: + pass + with pytest.raises(NoSuchImage): + docker_client.get_image_cmd("alpine", pull=False) + docker_client.pull_image("alpine") + assert ["/bin/sh"] == docker_client.get_image_cmd("alpine", pull=False) + + @markers.skip_offline + def test_pull_non_existent_docker_image(self, docker_client: ContainerClient): + with pytest.raises(NoSuchImage): + docker_client.pull_image("localstack_non_existing_image_for_tests") + + @markers.skip_offline + def test_pull_docker_image_with_tag(self, docker_client: ContainerClient): + try: + docker_client.get_image_cmd("alpine", pull=False) + docker_client.remove_image("alpine") + except ContainerException: + pass + with pytest.raises(NoSuchImage): + docker_client.get_image_cmd("alpine", pull=False) + docker_client.pull_image("alpine:3.13") + assert ["/bin/sh"] == docker_client.get_image_cmd("alpine:3.13", pull=False) + assert "alpine:3.13" in docker_client.inspect_image("alpine:3.13", pull=False)["RepoTags"] + + @markers.skip_offline + def test_pull_docker_image_with_hash(self, docker_client: ContainerClient): + try: + docker_client.get_image_cmd("alpine", pull=False) + docker_client.remove_image("alpine") + except ContainerException: + pass + with pytest.raises(NoSuchImage): + docker_client.get_image_cmd("alpine", pull=False) + docker_client.pull_image( + "alpine@sha256:e1c082e3d3c45cccac829840a25941e679c25d438cc8412c2fa221cf1a824e6a" + ) + assert ["/bin/sh"] == docker_client.get_image_cmd( + "alpine@sha256:e1c082e3d3c45cccac829840a25941e679c25d438cc8412c2fa221cf1a824e6a", + pull=False, + ) + assert ( + "alpine@sha256:e1c082e3d3c45cccac829840a25941e679c25d438cc8412c2fa221cf1a824e6a" + in docker_client.inspect_image( + "alpine@sha256:e1c082e3d3c45cccac829840a25941e679c25d438cc8412c2fa221cf1a824e6a", + pull=False, + )["RepoDigests"] + ) + + @markers.skip_offline + def test_pull_docker_image_with_log_handler(self, docker_client: ContainerClient): + log_result: list[str] = [] + + def _process(line: str): + log_result.append(line) + + docker_client.pull_image("alpine", log_handler=_process) + + assert any("Pulling from library/alpine" in log for log in log_result), ( + f"Should display useful logs in {log_result}" + ) + + @markers.skip_offline + def test_run_container_automatic_pull(self, docker_client: ContainerClient): + try: + docker_client.remove_image("alpine") + except ContainerException: + pass + message = "test message" + stdout, _ = docker_client.run_container("alpine", command=["echo", message], remove=True) + assert message == stdout.decode(config.DEFAULT_ENCODING).strip() + + @markers.skip_offline + def test_push_non_existent_docker_image(self, docker_client: ContainerClient): + with pytest.raises(NoSuchImage): + docker_client.push_image("localstack_non_existing_image_for_tests") + + @markers.skip_offline + def test_push_access_denied(self, docker_client: ContainerClient): + with pytest.raises(AccessDenied): + docker_client.push_image("alpine") + with pytest.raises(AccessDenied): + docker_client.push_image("alpine:latest") + + @markers.skip_offline + def test_push_invalid_registry(self, docker_client: ContainerClient): + image_name = f"localhost:{get_free_tcp_port()}/localstack_dummy_image" + try: + docker_client.tag_image("alpine", image_name) + with pytest.raises(RegistryConnectionError): + docker_client.push_image(image_name) + finally: + docker_client.remove_image(image_name) + + @markers.skip_offline + def test_tag_image(self, docker_client: ContainerClient): + if _is_podman_test() and isinstance(docker_client, SdkDockerClient): + # TODO: Podman raises "normalizing image: normalizing name for compat API: invalid reference format" + pytest.skip("Image tagging not fully supported using SDK client against Podman API") + + _pull_image_if_not_exists(docker_client, "alpine") + img_refs = [ + "localstack_dummy_image", + "localstack_dummy_image:latest", + "localstack_dummy_image:test", + "docker.io/localstack_dummy_image:test2", + "example.com:4510/localstack_dummy_image:test3", + ] + try: + for img_ref in img_refs: + docker_client.tag_image("alpine", img_ref) + images = docker_client.get_docker_image_names(strip_latest=":latest" not in img_ref) + expected = img_ref.split("/")[-1] if len(img_ref.split(":")) < 3 else img_ref + assert expected in images + finally: + for img_ref in img_refs: + try: + docker_client.remove_image(img_ref) + except Exception as e: + LOG.info("Unable to remove image '%s': %s", img_ref, e) + + @markers.skip_offline + def test_tag_non_existing_image(self, docker_client: ContainerClient): + with pytest.raises(NoSuchImage): + docker_client.tag_image( + "localstack_non_existing_image_for_tests", "localstack_dummy_image" + ) + + @markers.skip_offline + @pytest.mark.parametrize("custom_context", [True, False]) + @pytest.mark.parametrize("dockerfile_as_dir", [True, False]) + def test_build_image( + self, docker_client: ContainerClient, custom_context, dockerfile_as_dir, tmp_path, cleanups + ): + if custom_context and is_podman_test(): + # TODO: custom context currently failing with Podman + pytest.skip("Test not applicable when run against Podman (only Docker)") + + dockerfile_dir = tmp_path / "dockerfile" + tmp_file = short_uid() + ctx_dir = tmp_path / "context" if custom_context else dockerfile_dir + dockerfile_path = os.path.join(dockerfile_dir, "Dockerfile") + dockerfile = f""" + FROM alpine + ADD {tmp_file} . + ENV foo=bar + EXPOSE 45329 + """ + save_file(dockerfile_path, dockerfile) + save_file(os.path.join(ctx_dir, tmp_file), "test content 123") + + kwargs = {"context_path": str(ctx_dir)} if custom_context else {} + dockerfile_ref = str(dockerfile_dir) if dockerfile_as_dir else dockerfile_path + + image_name = f"img-{short_uid()}" + build_logs = docker_client.build_image( + dockerfile_path=dockerfile_ref, image_name=image_name, **kwargs + ) + # The exact log files are very different between the CMD and SDK + # We just run some smoke tests + assert build_logs + assert isinstance(build_logs, str) + assert "ADD" in build_logs + + cleanups.append(lambda: docker_client.remove_image(image_name, force=True)) + + assert image_name in docker_client.get_docker_image_names() + result = docker_client.inspect_image(image_name, pull=False) + assert "foo=bar" in result["Config"]["Env"] + assert "45329/tcp" in result["Config"]["ExposedPorts"] + + @markers.skip_offline + def test_run_container_non_existent_image(self, docker_client: ContainerClient): + try: + docker_client.remove_image("alpine") + except ContainerException: + pass + with pytest.raises(NoSuchImage): + stdout, _ = docker_client.run_container( + "localstack_non_existing_image_for_tests", command=["echo", "test"], remove=True + ) + + def test_running_container_names(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + name = dummy_container.container_name + retry( + lambda: _assert_container_state(docker_client, name, is_running=True), + sleep=2, + retries=5, + ) + docker_client.stop_container(name) + retry( + lambda: _assert_container_state(docker_client, name, is_running=False), + sleep=2, + retries=5, + ) + + def test_is_container_running(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + name = dummy_container.container_name + + retry( + lambda: _assert_container_state(docker_client, name, is_running=True), + sleep=2, + retries=5, + ) + docker_client.restart_container(name) + retry( + lambda: _assert_container_state(docker_client, name, is_running=True), + sleep=2, + retries=5, + ) + docker_client.stop_container(name) + retry( + lambda: _assert_container_state(docker_client, name, is_running=False), + sleep=2, + retries=5, + ) + + @markers.skip_offline + def test_docker_image_names(self, docker_client: ContainerClient): + try: + docker_client.remove_image("alpine") + except ContainerException: + pass + assert "alpine:latest" not in docker_client.get_docker_image_names() + assert "alpine" not in docker_client.get_docker_image_names() + docker_client.pull_image("alpine") + assert "alpine:latest" in docker_client.get_docker_image_names() + assert "alpine:latest" not in docker_client.get_docker_image_names(include_tags=False) + assert "alpine" in docker_client.get_docker_image_names(include_tags=False) + assert "alpine" in docker_client.get_docker_image_names() + assert "alpine" not in docker_client.get_docker_image_names(strip_latest=False) + + def test_get_container_name(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + assert dummy_container.container_name == docker_client.get_container_name( + dummy_container.container_id + ) + + def test_get_container_name_not_existing(self, docker_client: ContainerClient): + not_existent_container = "not_existing_container" + with pytest.raises(NoSuchContainer): + docker_client.get_container_name(not_existent_container) + + def test_get_container_id(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + assert dummy_container.container_id == docker_client.get_container_id( + dummy_container.container_name + ) + + def test_get_container_id_not_existing(self, docker_client: ContainerClient): + not_existent_container = "not_existing_container" + with pytest.raises(NoSuchContainer): + docker_client.get_container_id(not_existent_container) + + def test_inspect_container(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + for identifier in [dummy_container.container_id, dummy_container.container_name]: + assert dummy_container.container_id == docker_client.inspect_container(identifier)["Id"] + # considering container names with (Docker) and without (Podman) leading slashes + candidates = (f"/{dummy_container.container_name}", dummy_container.container_name) + assert docker_client.inspect_container(identifier)["Name"] in candidates + + @markers.skip_offline + def test_inspect_image(self, docker_client: ContainerClient): + _pull_image_if_not_exists(docker_client, "alpine") + assert "alpine" in docker_client.inspect_image("alpine")["RepoTags"][0] + + # TODO: currently failing under Podman + @pytest.mark.skipif( + condition=_is_podman_test(), reason="Podman inspect_network does not return `Id` attribute" + ) + def test_inspect_network(self, docker_client: ContainerClient, create_network): + network_name = f"ls_test_network_{short_uid()}" + network_id = create_network(network_name) + result = docker_client.inspect_network(network_name) + assert network_name == result["Name"] + assert network_id == result["Id"] + + def test_inspect_network_non_existent_network(self, docker_client: ContainerClient): + network_name = "ls_test_network_non_existent" + with pytest.raises(NoSuchNetwork): + docker_client.inspect_network(network_name) + + def test_copy_from_container(self, tmpdir, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + local_path = tmpdir.join("test_file") + self._test_copy_from_container( + local_path, local_path, "test_file", docker_client, dummy_container + ) + + def test_copy_from_container_to_different_file( + self, tmpdir, docker_client: ContainerClient, dummy_container + ): + docker_client.start_container(dummy_container.container_id) + local_path = tmpdir.join("test_file_2") + self._test_copy_from_container( + local_path, local_path, "test_file", docker_client, dummy_container + ) + + def test_copy_from_container_into_directory( + self, tmpdir, docker_client: ContainerClient, dummy_container + ): + docker_client.start_container(dummy_container.container_id) + local_path = tmpdir.mkdir("test_dir") + file_path = local_path.join("test_file") + self._test_copy_from_container( + local_path, file_path, "test_file", docker_client, dummy_container + ) + + def test_copy_from_non_existent_container(self, tmpdir, docker_client: ContainerClient): + local_path = tmpdir.mkdir("test_dir") + with pytest.raises(NoSuchContainer): + docker_client.copy_from_container( + f"hopefully_non_existent_container_{short_uid()}", str(local_path), "test_file" + ) + + def _test_copy_from_container( + self, + local_path, + file_path, + container_file_name, + docker_client: ContainerClient, + dummy_container, + ): + docker_client.exec_in_container( + dummy_container.container_id, + command=["sh", "-c", f"echo TEST_CONTENT > {container_file_name}"], + ) + docker_client.copy_from_container( + dummy_container.container_id, + local_path=str(local_path), + container_path=container_file_name, + ) + assert "TEST_CONTENT" == file_path.read().strip() + + def test_get_container_ip_non_existing_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.get_container_ip(f"hopefully_non_existent_container_{short_uid()}") + + # TODO: getting container IP not yet working against Podman + @skip_for_podman + def test_get_container_ip(self, docker_client: ContainerClient, dummy_container): + docker_client.start_container(dummy_container.container_id) + ip = docker_client.get_container_ip(dummy_container.container_id) + assert is_ipv4_address(ip) + assert "127.0.0.1" != ip + + +class TestRunWithAdditionalArgs: + def test_run_with_additional_arguments(self, docker_client: ContainerClient): + env_variable = "TEST_FLAG=test_str" + stdout, _ = docker_client.run_container( + "alpine", remove=True, command=["env"], additional_flags=f"-e {env_variable}" + ) + assert env_variable in stdout.decode(config.DEFAULT_ENCODING) + stdout, _ = docker_client.run_container( + "alpine", + remove=True, + command=["env"], + additional_flags=f"-e {env_variable}", + env_vars={"EXISTING_VAR": "test_var"}, + ) + stdout = stdout.decode(config.DEFAULT_ENCODING) + assert env_variable in stdout + assert "EXISTING_VAR=test_var" in stdout + + def test_run_with_additional_arguments_add_host(self, docker_client: ContainerClient): + additional_flags = "--add-host sometest.localstack.cloud:127.0.0.1" + stdout, _ = docker_client.run_container( + "alpine", + remove=True, + command=["getent", "hosts", "sometest.localstack.cloud"], + additional_flags=additional_flags, + ) + stdout = to_str(stdout) + assert "127.0.0.1" in stdout + assert "sometest.localstack.cloud" in stdout + + @pytest.mark.parametrize("pass_dns_in_run_container", [True, False]) + def test_run_with_additional_arguments_add_dns( + self, docker_client: ContainerClient, pass_dns_in_run_container + ): + kwargs = {} + additional_flags = "--dns 1.2.3.4" + if pass_dns_in_run_container: + kwargs["dns"] = "5.6.7.8" + else: + additional_flags += " --dns 5.6.7.8" + + container_name = f"c-{short_uid()}" + stdout, _ = docker_client.run_container( + "alpine", + name=container_name, + remove=True, + command=["sleep", "3"], + additional_flags=additional_flags, + detach=True, + **kwargs, + ) + result = docker_client.inspect_container(container_name) + assert set(result["HostConfig"]["Dns"]) == {"1.2.3.4", "5.6.7.8"} + + def test_run_with_additional_arguments_random_port( + self, docker_client: ContainerClient, create_container + ): + container = create_container( + "alpine", + command=["sh", "-c", "while true; do sleep 1; done"], + additional_flags="-p 0:80", + ) + docker_client.start_container(container.container_id) + inspect_result = docker_client.inspect_container( + container_name_or_id=container.container_id + ) + automatic_host_port = int( + inspect_result["NetworkSettings"]["Ports"]["80/tcp"][0]["HostPort"] + ) + assert automatic_host_port > 0 + + def test_run_with_ulimit(self, docker_client: ContainerClient): + container_name = f"c-{short_uid()}" + stdout, _ = docker_client.run_container( + "alpine", + name=container_name, + remove=True, + command=["sh", "-c", "ulimit -n"], + ulimits=[Ulimit(name="nofile", soft_limit=1024, hard_limit=1024)], + ) + assert stdout.decode(config.DEFAULT_ENCODING).strip() == "1024" + + def test_run_with_additional_arguments_env_files( + self, docker_client: ContainerClient, tmp_path, monkeypatch + ): + env_variable = "TEST1=VAL1" + env_file = tmp_path / "env1" + env_vars = textwrap.dedent(""" + # Some comment + TEST1=OVERRIDDEN + TEST2=VAL2 + TEST3=${TEST2} + TEST4=VAL # end comment + TEST5="VAL" + """) + env_file.write_text(env_vars) + + stdout, _ = docker_client.run_container( + "alpine", + remove=True, + command=["env"], + additional_flags=f"-e {env_variable} --env-file {env_file}", + ) + env_output = stdout.decode(config.DEFAULT_ENCODING) + # behavior differs here from more advanced env file parsers + assert env_variable in env_output + assert "TEST1=VAL1" in env_output + assert "TEST2=VAL2" in env_output + assert "TEST3=${TEST2}" in env_output + assert "TEST4=VAL # end comment" in env_output + assert 'TEST5="VAL"' in env_output + + env_vars = textwrap.dedent(""" + # Some comment + TEST1 + """) + env_file.write_text(env_vars) + + stdout, _ = docker_client.run_container( + "alpine", + remove=True, + command=["env"], + additional_flags=f"--env-file {env_file}", + ) + env_output = stdout.decode(config.DEFAULT_ENCODING) + assert "TEST1" not in env_output + + monkeypatch.setenv("TEST1", "VAL1") + stdout, _ = docker_client.run_container( + "alpine", + remove=True, + command=["env"], + additional_flags=f"--env-file {env_file}", + ) + env_output = stdout.decode(config.DEFAULT_ENCODING) + assert "TEST1=VAL1" in env_output + + env_vars = textwrap.dedent(""" + # Some comment + TEST1= + """) + env_file.write_text(env_vars) + + stdout, _ = docker_client.run_container( + "alpine", + remove=True, + command=["env"], + additional_flags=f"--env-file {env_file}", + ) + env_output = stdout.decode(config.DEFAULT_ENCODING) + assert "TEST1=" in env_output.splitlines() + + +class TestDockerImages: + def test_commit_creates_image_from_running_container(self, docker_client: ContainerClient): + image_name = "lorem" + image_tag = "ipsum" + image = f"{image_name}:{image_tag}" + container_name = _random_container_name() + + try: + docker_client.run_container( + "alpine", + name=container_name, + command=["sleep", "60"], + detach=True, + ) + docker_client.commit(container_name, image_name, image_tag) + assert image in docker_client.get_docker_image_names() + finally: + docker_client.remove_container(container_name) + docker_client.remove_image(image, force=True) + + def test_commit_image_raises_for_nonexistent_container(self, docker_client: ContainerClient): + with pytest.raises(NoSuchContainer): + docker_client.commit("nonexistent_container", "image_name", "should_not_matter") + + def test_remove_image_raises_for_nonexistent_image(self, docker_client: ContainerClient): + image_name = "this_image" + image_tag = "does_not_exist" + image = f"{image_name}:{image_tag}" + + with pytest.raises(NoSuchImage): + docker_client.remove_image(image, force=False) + + +# TODO: most of these tests currently failing under Podman in our CI pipeline, due +# to "Error: "slirp4netns" is not supported: invalid network mode" in CI +@skip_for_podman +class TestDockerNetworking: + def test_network_lifecycle(self, docker_client: ContainerClient): + network_name = f"test-network-{short_uid()}" + network_id = docker_client.create_network(network_name=network_name) + assert network_name == docker_client.inspect_network(network_name=network_name)["Name"] + assert network_id == docker_client.inspect_network(network_name=network_name)["Id"] + docker_client.delete_network(network_name=network_name) + with pytest.raises(NoSuchNetwork): + docker_client.inspect_network(network_name=network_name) + + def test_get_container_ip_with_network( + self, docker_client: ContainerClient, create_container, create_network + ): + network_name = f"ls_test_network_{short_uid()}" + create_network(network_name) + container = create_container( + "alpine", network=network_name, command=["sh", "-c", "while true; do sleep 1; done"] + ) + docker_client.start_container(container.container_id) + ip = docker_client.get_container_ip(container.container_id) + assert re.match( + r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", + ip, + ) + assert "127.0.0.1" != ip + + def test_set_container_workdir(self, docker_client: ContainerClient): + result = docker_client.run_container("alpine", command=["pwd"], workdir="/tmp", remove=True) + assert "/tmp" == to_str(result[0]).strip() + + def test_connect_container_to_network( + self, docker_client: ContainerClient, create_network, create_container + ): + network_name = f"ls_test_network_{short_uid()}" + create_network(network_name) + container = create_container("alpine", command=["sh", "-c", "while true; do sleep 1; done"]) + docker_client.start_container(container.container_id) + docker_client.connect_container_to_network( + network_name, container_name_or_id=container.container_id + ) + # TODO: podman CmdDockerClient currently not returning `Containers` list + assert ( + container.container_id + in docker_client.inspect_network(network_name).get("Containers").keys() + ) + + def test_connect_container_to_network_with_link_local_address( + self, docker_client, create_network, create_container + ): + network_name = f"ls_test_network_{short_uid()}" + create_network(network_name) + container = create_container("alpine", command=["sh", "-c", "sleep infinity"]) + docker_client.connect_container_to_network( + network_name, + container_name_or_id=container.container_id, + link_local_ips=["169.254.169.10"], + ) + assert docker_client.inspect_container(container.container_id)["NetworkSettings"][ + "Networks" + ][network_name]["IPAMConfig"]["LinkLocalIPs"] == ["169.254.169.10"] + + def test_connect_container_to_nonexistent_network( + self, docker_client: ContainerClient, create_container + ): + container = create_container("alpine", command=["sh", "-c", "while true; do sleep 1; done"]) + docker_client.start_container(container.container_id) + with pytest.raises(NoSuchNetwork): + docker_client.connect_container_to_network( + f"invalid_network_{short_uid()}", container_name_or_id=container.container_id + ) + + def test_disconnect_container_from_nonexistent_network( + self, docker_client: ContainerClient, create_container + ): + container = create_container("alpine", command=["sh", "-c", "while true; do sleep 1; done"]) + docker_client.start_container(container.container_id) + with pytest.raises(NoSuchNetwork): + docker_client.disconnect_container_from_network( + f"invalid_network_{short_uid()}", container_name_or_id=container.container_id + ) + + def test_connect_nonexistent_container_to_network( + self, docker_client: ContainerClient, create_network, create_container + ): + network_name = f"ls_test_network_{short_uid()}" + create_network(network_name) + with pytest.raises(NoSuchContainer): + docker_client.connect_container_to_network( + network_name, container_name_or_id=f"some-invalid-container-{short_uid()}" + ) + + def test_disconnect_nonexistent_container_from_network( + self, docker_client: ContainerClient, create_network, create_container + ): + network_name = f"ls_test_network_{short_uid()}" + create_network(network_name) + with pytest.raises(NoSuchContainer): + docker_client.disconnect_container_from_network( + network_name, container_name_or_id=f"some-invalid-container-{short_uid()}" + ) + + def test_connect_container_to_network_with_alias_and_disconnect( + self, docker_client: ContainerClient, create_network, create_container + ): + network_name = f"ls_test_network_{short_uid()}" + container_alias = f"test-container-{short_uid()}.localstack.cloud" + create_network(network_name) + container = create_container("alpine", command=["sh", "-c", "while true; do sleep 1; done"]) + docker_client.start_container(container.container_id) + docker_client.connect_container_to_network( + network_name, container_name_or_id=container.container_id, aliases=[container_alias] + ) + container_2 = create_container( + "alpine", command=["ping", "-c", "1", container_alias], network=network_name + ) + docker_client.start_container(container_name_or_id=container_2.container_id, attach=True) + docker_client.disconnect_container_from_network(network_name, container.container_id) + with pytest.raises(ContainerException): + docker_client.start_container( + container_name_or_id=container_2.container_id, attach=True + ) + + @skip_for_podman # note: manually creating SdkDockerClient can fail for clients + def test_docker_sdk_timeout_seconds(self, monkeypatch): + # check that the timeout seconds are defined by the config variable + monkeypatch.setattr(config, "DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS", 1337) + sdk_client = SdkDockerClient() + assert sdk_client.docker_client.api.timeout == 1337 + # check that the config variable is reloaded when the client is recreated + monkeypatch.setattr(config, "DOCKER_SDK_DEFAULT_TIMEOUT_SECONDS", 987) + sdk_client = SdkDockerClient() + assert sdk_client.docker_client.api.timeout == 987 + + def test_docker_sdk_no_retries(self, monkeypatch): + monkeypatch.setattr(config, "DOCKER_SDK_DEFAULT_RETRIES", 0) + # change the env for the docker socket (such that it cannot be initialized) + monkeypatch.setenv("DOCKER_HOST", "tcp://non_existing_docker_client:2375/") + sdk_client = SdkDockerClient() + assert sdk_client.docker_client is None + + def test_docker_sdk_retries_on_init(self, monkeypatch): + # increase the number of retries + monkeypatch.setattr(config, "DOCKER_SDK_DEFAULT_RETRIES", 10) + # change the env for the docker socket (such that it cannot be initialized) + monkeypatch.setenv("DOCKER_HOST", "tcp://non_existing_docker_client:2375/") + global sdk_client + + def on_demand_init(*args): + global sdk_client + sdk_client = SdkDockerClient() + assert sdk_client.docker_client is not None + + # start initializing the client in another thread (with 10 retries) + init_thread = FuncThread(func=on_demand_init) + init_thread.start() + # reset / fix the DOCKER_HOST config + monkeypatch.delenv("DOCKER_HOST") + # wait for the init thread to finish + init_thread.join() + # verify that the client is available + assert sdk_client.docker_client is not None + + def test_docker_sdk_retries_after_init(self, monkeypatch): + # increase the number of retries + monkeypatch.setattr(config, "DOCKER_SDK_DEFAULT_RETRIES", 0) + # change the env for the docker socket (such that it cannot be initialized) + monkeypatch.setenv("DOCKER_HOST", "tcp://non_existing_docker_client:2375/") + sdk_client = SdkDockerClient() + assert sdk_client.docker_client is None + monkeypatch.setattr(config, "DOCKER_SDK_DEFAULT_RETRIES", 10) + + def on_demand_init(*args): + internal_sdk_client = sdk_client.client() + assert internal_sdk_client is not None + + # start initializing the client in another thread (with 10 retries) + init_thread = FuncThread(func=on_demand_init) + init_thread.start() + # reset / fix the DOCKER_HOST config + monkeypatch.delenv("DOCKER_HOST") + # wait for the init thread to finish + init_thread.join() + # verify that the client is available + assert sdk_client.docker_client is not None + + +class TestDockerLogging: + def test_docker_logging_none_disables_logs( + self, docker_client: ContainerClient, create_container + ): + container = create_container( + "alpine", command=["sh", "-c", "echo test"], log_config=LogConfig("none") + ) + docker_client.start_container(container.container_id, attach=True) + with pytest.raises(ContainerException): + docker_client.get_container_logs(container_name_or_id=container.container_id) + + def test_docker_logging_fluentbit(self, docker_client: ContainerClient, create_container): + ports = PortMappings(bind_host="0.0.0.0") + ports.add(24224, 24224) + fluentd_container = create_container( + "fluent/fluent-bit", + command=["-i", "forward", "-o", "stdout", "-p", "format=json_lines", "-f", "1", "-q"], + ports=ports, + ) + docker_client.start_container(fluentd_container.container_id) + + container = create_container( + "alpine", + command=["sh", "-c", "echo test"], + log_config=LogConfig( + "fluentd", config={"fluentd-address": "127.0.0.1:24224", "fluentd-async": "true"} + ), + ) + docker_client.start_container(container.container_id, attach=True) + + def _get_logs(): + logs = docker_client.get_container_logs( + container_name_or_id=fluentd_container.container_id + ) + message = None + for log in logs.splitlines(): + if log.strip(): + message = json.loads(log.strip()) + assert message + return message + + log = retry(_get_logs, retries=10, sleep=1) + assert log["log"] == "test" + assert log["source"] == "stdout" + assert log["container_id"] == container.container_id + assert log["container_name"] == f"/{container.container_name}" + + +class TestDockerPermissions: + def test_container_with_cap_add(self, docker_client: ContainerClient, create_container): + container = create_container( + "alpine", + cap_add=["NET_ADMIN"], + command=[ + "sh", + "-c", + "ip link add dummy0 type dummy && ip link delete dummy0 && echo test", + ], + ) + stdout, _ = docker_client.start_container( + container_name_or_id=container.container_id, attach=True + ) + assert "test" in to_str(stdout) + container = create_container( + "alpine", + command=[ + "sh", + "-c", + "ip link add dummy0 type dummy && ip link delete dummy0 && echo test", + ], + ) + with pytest.raises(ContainerException): + stdout, _ = docker_client.start_container( + container_name_or_id=container.container_id, attach=True + ) + + def test_container_with_cap_drop(self, docker_client: ContainerClient, create_container): + container = create_container("alpine", command=["sh", "-c", "chown nobody / && echo test"]) + stdout, _ = docker_client.start_container( + container_name_or_id=container.container_id, attach=True + ) + assert "test" in to_str(stdout) + container = create_container( + "alpine", cap_drop=["CHOWN"], command=["sh", "-c", "chown nobody / && echo test"] + ) + with pytest.raises(ContainerException): + stdout, _ = docker_client.start_container( + container_name_or_id=container.container_id, attach=True + ) + + # TODO: currently fails in Podman with "Apparmor is not enabled on this system" + @skip_for_podman + def test_container_with_sec_opt(self, docker_client: ContainerClient, create_container): + security_opt = ["apparmor=unrestricted"] + container = create_container( + "alpine", + security_opt=security_opt, + command=["sh", "-c", "while true; do sleep 1; done"], + ) + inspect_result = docker_client.inspect_container( + container_name_or_id=container.container_id + ) + assert security_opt == inspect_result["HostConfig"]["SecurityOpt"] + + +@pytest.fixture +def set_ports_check_image_alpine(monkeypatch): + """Set the ports check Docker image to 'alpine', to avoid pulling the larger localstack image in the tests""" + + def _get_ports_check_docker_image(): + return "alpine" + + monkeypatch.setattr( + docker_utils, "_get_ports_check_docker_image", _get_ports_check_docker_image + ) + + +@pytest.mark.parametrize("protocol", [None, "tcp", "udp"]) +class TestDockerPorts: + def test_reserve_container_port(self, docker_client, set_ports_check_image_alpine, protocol): + if isinstance(docker_client, CmdDockerClient): + pytest.skip("Running test only for one Docker executor") + + # reserve available container port + port = reserve_available_container_port(duration=1, protocol=protocol) + port = Port(port, protocol or "tcp") + assert is_container_port_reserved(port) + assert container_ports_can_be_bound(port) + assert not is_port_available_for_containers(port) + + # reservation should fail immediately after + with pytest.raises(PortNotAvailableException): + reserve_container_port(port) + + # reservation should work after expiry time + time.sleep(1) + assert not is_container_port_reserved(port) + assert is_port_available_for_containers(port) + reserve_container_port(port, duration=1) + assert is_container_port_reserved(port) + assert container_ports_can_be_bound(port) + + # reservation should work on privileged port + port = reserve_available_container_port(duration=1, port_start=1, port_end=1024) + assert is_container_port_reserved(port) + assert container_ports_can_be_bound(port) + assert not is_port_available_for_containers(port) + + def test_container_port_can_be_bound( + self, docker_client, set_ports_check_image_alpine, protocol + ): + if isinstance(docker_client, CmdDockerClient): + pytest.skip("Running test only for one Docker executor") + + # reserve available container port + port = reserve_available_container_port(duration=1) + start_time = datetime.datetime.now() + assert container_ports_can_be_bound(port) + assert not is_port_available_for_containers(port) + + # run test container with port exposed + ports = PortMappings() + ports.add(port, port) + name = f"c-{short_uid()}" + docker_client.run_container( + "alpine", + name=name, + command=["sleep", "5"], + entrypoint="", + ports=ports, + detach=True, + ) + # assert that port can no longer be bound by new containers + assert not container_ports_can_be_bound(port) + + # remove container, assert that port can be bound again + docker_client.remove_container(name, force=True) + assert container_ports_can_be_bound(port) + delta = (datetime.datetime.now() - start_time).total_seconds() + if delta <= 1: + time.sleep(1.01 - delta) + assert is_port_available_for_containers(port) + + +class TestDockerLabels: + def test_create_container_with_labels(self, docker_client, create_container): + labels = {"foo": "bar", short_uid(): short_uid()} + container = create_container("alpine", command=["dummy"], labels=labels) + result = docker_client.inspect_container(container.container_id) + result_labels = result.get("Config", {}).get("Labels") + assert result_labels == labels + + def test_run_container_with_labels(self, docker_client): + labels = {"foo": "bar", short_uid(): short_uid()} + container_name = _random_container_name() + try: + docker_client.run_container( + image_name="alpine", + command=["sh", "-c", "while true; do sleep 1; done"], + labels=labels, + name=container_name, + detach=True, + ) + result = docker_client.inspect_container(container_name_or_id=container_name) + result_labels = result.get("Config", {}).get("Labels") + assert result_labels == labels + finally: + docker_client.remove_container(container_name=container_name, force=True) + + def test_list_containers_with_labels(self, docker_client, create_container): + labels = {"foo": "bar", short_uid(): short_uid()} + container = create_container( + "alpine", command=["sh", "-c", "while true; do sleep 1; done"], labels=labels + ) + docker_client.start_container(container.container_id) + + containers = docker_client.list_containers(filter=f"id={container.container_id}") + assert len(containers) == 1 + container = containers[0] + assert container["labels"] == labels + + def test_get_container_stats(self, docker_client, create_container): + container = create_container("alpine", command=["sh", "-c", "while true; do sleep 1; done"]) + docker_client.start_container(container.container_id) + stats: DockerContainerStats = docker_client.get_container_stats(container.container_id) + assert stats["Name"] == container.container_name + assert container.container_id.startswith(stats["ID"]) + assert 0.0 <= stats["MemPerc"] <= 100.0 + + +def _pull_image_if_not_exists(docker_client: ContainerClient, image_name: str): + if image_name not in docker_client.get_docker_image_names(): + docker_client.pull_image(image_name) + + +def _get_default_network() -> str: + """Return the default container network name - `bridge` for Docker, `podman` for Podman.""" + return "podman" if _is_podman_test() else "bridge" diff --git a/tests/integration/lambdas/lambda_environment.py b/tests/integration/lambdas/lambda_environment.py deleted file mode 100644 index 98fb2c6957e9c..0000000000000 --- a/tests/integration/lambdas/lambda_environment.py +++ /dev/null @@ -1,6 +0,0 @@ -import os - - -def handler(event, context): - """ Simple Lambda function that returns the value of the "Hello" environment variable """ - return {'Hello': os.environ.get('Hello')} diff --git a/tests/integration/lambdas/lambda_integration.js b/tests/integration/lambdas/lambda_integration.js deleted file mode 100644 index 1990cbde34f2f..0000000000000 --- a/tests/integration/lambdas/lambda_integration.js +++ /dev/null @@ -1,9 +0,0 @@ -exports.handler = function(event, context, callback) { - console.log('Node.js Lambda handler executing.'); - var result = {}; - if(callback) { - callback(result); - } else { - context.succeed(result); - } -}; diff --git a/tests/integration/lambdas/lambda_integration.py b/tests/integration/lambdas/lambda_integration.py deleted file mode 100644 index 67d73a8550735..0000000000000 --- a/tests/integration/lambdas/lambda_integration.py +++ /dev/null @@ -1,96 +0,0 @@ -import json -import base64 -import boto3.dynamodb.types -from io import BytesIO -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_str, to_bytes - -TEST_BUCKET_NAME = 'test_bucket' -KINESIS_STREAM_NAME = 'test_stream_1' -MSG_BODY_RAISE_ERROR_FLAG = 'raise_error' -MSG_BODY_MESSAGE_TARGET = 'message_target' - - -# Subclass of boto's TypeDeserializer for DynamoDB -# to adjust for DynamoDB Stream format. -class TypeDeserializer(boto3.dynamodb.types.TypeDeserializer): - def _deserialize_n(self, value): - return float(value) - - def _deserialize_b(self, value): - return value # already in Base64 - - -def handler(event, context): - """ Generic event forwarder Lambda. """ - - if 'httpMethod' in event: - # looks like this is a call from an AWS_PROXY API Gateway - body = json.loads(event['body']) - body['pathParameters'] = event.get('pathParameters') - return { - 'body': body, - 'statusCode': body.get('return_status_code', 200), - 'headers': body.get('return_headers', {}) - } - - if 'Records' not in event: - return event - - raw_event_messages = [] - for record in event['Records']: - # Deserialize into Python dictionary and extract the - # "NewImage" (the new version of the full ddb document) - ddb_new_image = deserialize_event(record) - - if MSG_BODY_RAISE_ERROR_FLAG in ddb_new_image.get('data', {}): - raise Exception('Test exception (this is intentional)') - - # Place the raw event message document into the Kinesis message format - kinesis_record = { - 'PartitionKey': 'key123', - 'Data': json.dumps(ddb_new_image) - } - - if MSG_BODY_MESSAGE_TARGET in ddb_new_image.get('data', {}): - forwarding_target = ddb_new_image['data'][MSG_BODY_MESSAGE_TARGET] - target_name = forwarding_target.split(':')[-1] - if forwarding_target.startswith('kinesis:'): - ddb_new_image['data'][MSG_BODY_MESSAGE_TARGET] = 's3:/test_chain_result' - kinesis_record['Data'] = json.dumps(ddb_new_image['data']) - forward_event_to_target_stream(kinesis_record, target_name) - elif forwarding_target.startswith('s3:'): - s3_client = aws_stack.connect_to_service('s3') - test_data = to_bytes(json.dumps({'test_data': ddb_new_image['data']['test_data']})) - s3_client.upload_fileobj(BytesIO(test_data), TEST_BUCKET_NAME, target_name) - else: - raw_event_messages.append(kinesis_record) - - # Forward messages to Kinesis - forward_events(raw_event_messages) - - -def deserialize_event(event): - # Deserialize into Python dictionary and extract the "NewImage" (the new version of the full ddb document) - ddb = event.get('dynamodb') - if ddb: - ddb_deserializer = TypeDeserializer() - return ddb_deserializer.deserialize({'M': ddb.get('NewImage')}) - kinesis = event.get('kinesis') - if kinesis: - assert kinesis['sequenceNumber'] - kinesis['data'] = json.loads(to_str(base64.b64decode(kinesis['data']))) - return kinesis - return event.get('Sns') - - -def forward_events(records): - if not records: - return - kinesis = aws_stack.connect_to_service('kinesis') - kinesis.put_records(StreamName=KINESIS_STREAM_NAME, Records=records) - - -def forward_event_to_target_stream(record, stream_name): - kinesis = aws_stack.connect_to_service('kinesis') - kinesis.put_record(StreamName=stream_name, Data=record['Data'], PartitionKey=record['PartitionKey']) diff --git a/tests/integration/lambdas/lambda_python3.py b/tests/integration/lambdas/lambda_python3.py deleted file mode 100644 index 051da6fa4cd60..0000000000000 --- a/tests/integration/lambdas/lambda_python3.py +++ /dev/null @@ -1,8 +0,0 @@ -# simple test function that uses python 3 features (e.g., f-strings) -# see https://github.com/localstack/localstack/issues/264 - - -def handler(event, context): - # the following line is Python 3.6+ specific - msg = f'Successfully processed {event}' # noqa This code is Python 3.6+ only - return event diff --git a/tests/integration/services/__init__.py b/tests/integration/services/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/integration/services/test_internal.py b/tests/integration/services/test_internal.py new file mode 100644 index 0000000000000..c32d4d8676b3e --- /dev/null +++ b/tests/integration/services/test_internal.py @@ -0,0 +1,63 @@ +import pytest +import requests + +from localstack import config + + +@pytest.mark.usefixtures("openapi_validate") +class TestInitScriptsResource: + def test_stages_have_completed(self): + response = requests.get(config.internal_service_url() + "/_localstack/init") + assert response.status_code == 200 + doc = response.json() + + assert doc["completed"] == { + "BOOT": True, + "START": True, + "READY": True, + "SHUTDOWN": False, + } + + def test_query_nonexisting_stage(self): + response = requests.get(config.internal_service_url() + "/_localstack/init/does_not_exist") + assert response.status_code == 404 + + @pytest.mark.parametrize( + ("stage", "completed"), + [("boot", True), ("start", True), ("ready", True), ("shutdown", False)], + ) + def test_query_individual_stage_completed(self, stage, completed): + response = requests.get(config.internal_service_url() + f"/_localstack/init/{stage}") + assert response.status_code == 200 + assert response.json()["completed"] == completed + + +@pytest.mark.usefixtures("openapi_validate") +class TestHealthResource: + def test_get(self): + response = requests.get(config.internal_service_url() + "/_localstack/health") + assert response.ok + assert "services" in response.json() + assert "edition" in response.json() + + def test_head(self): + response = requests.head(config.internal_service_url() + "/_localstack/health") + assert response.ok + assert not response.text + + +@pytest.mark.usefixtures("openapi_validate") +class TestInfoEndpoint: + def test_get(self): + response = requests.get(config.internal_service_url() + "/_localstack/info") + assert response.ok + doc = response.json() + + from localstack.constants import VERSION + + # we're being specifically vague here since we want this test to be robust against pro or community + assert doc["version"].startswith(str(VERSION)) + assert doc["session_id"] + assert doc["machine_id"] + assert doc["system"] + assert type(doc["is_license_activated"]) == bool diff --git a/tests/integration/templates/resource_providers/sqs/queue.yaml b/tests/integration/templates/resource_providers/sqs/queue.yaml new file mode 100644 index 0000000000000..d549d251fe678 --- /dev/null +++ b/tests/integration/templates/resource_providers/sqs/queue.yaml @@ -0,0 +1,19 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Template to exercise AWS::SQS::Queue +Parameters: + AttributeName: + Type: String + Description: Name of the attribute to fetch from the resource +Resources: + MyResource: + Type: AWS::SQS::Queue + Properties: {} +Outputs: + MyRef: + Value: + Ref: MyResource + MyOutput: + Value: + Fn::GetAtt: + - MyResource + - Ref: AttributeName diff --git a/tests/integration/templates/sns_topic_update.yaml b/tests/integration/templates/sns_topic_update.yaml new file mode 100644 index 0000000000000..f4a41b6c93c6f --- /dev/null +++ b/tests/integration/templates/sns_topic_update.yaml @@ -0,0 +1,13 @@ +Parameters: + QueueName: + Type: String + +Resources: + MyQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + +Outputs: + QueueUrl: + Value: !Ref MyQueue diff --git a/tests/integration/templates/template1.yaml b/tests/integration/templates/template1.yaml deleted file mode 100644 index fdfe7e1bb3dfd..0000000000000 --- a/tests/integration/templates/template1.yaml +++ /dev/null @@ -1,16 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Description: Simple CloudFormation Test Template -Resources: - S3Bucket: - Type: AWS::S3::Bucket - Properties: - AccessControl: PublicRead - BucketName: cf-test-bucket-1 - SQSQueue: - Type: AWS::SQS::Queue - Properties: - QueueName: cf-test-queue-1 - KinesisStream: - Type: AWS::Kinesis::Stream - Properties: - Name: cf-test-stream-1 diff --git a/tests/integration/test_api_gateway.py b/tests/integration/test_api_gateway.py deleted file mode 100644 index 26fd1e154bb6b..0000000000000 --- a/tests/integration/test_api_gateway.py +++ /dev/null @@ -1,189 +0,0 @@ -import re -import json -from requests.models import Response -from localstack.constants import DEFAULT_REGION -from localstack.config import INBOUND_GATEWAY_URL_PATTERN -from localstack.utils import testutil -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_str, load_file -from localstack.utils.common import safe_requests as requests -from localstack.services.generic_proxy import GenericProxy, ProxyListener -from localstack.services.awslambda.lambda_api import (LAMBDA_RUNTIME_PYTHON27) -from .test_lambda import TEST_LAMBDA_PYTHON, TEST_LAMBDA_LIBS - -# template used to transform incoming requests at the API Gateway (stream name to be filled in later) -APIGATEWAY_DATA_INBOUND_TEMPLATE = """{ - "StreamName": "%s", - "Records": [ - #set( $numRecords = $input.path('$.records').size() ) - #if($numRecords > 0) - #set( $maxIndex = $numRecords - 1 ) - #foreach( $idx in [0..$maxIndex] ) - #set( $elem = $input.path("$.records[${idx}]") ) - #set( $elemJsonB64 = $util.base64Encode($elem.data) ) - { - "Data": "$elemJsonB64", - "PartitionKey": #if( $elem.partitionKey != '')"$elem.partitionKey" - #else"$elemJsonB64.length()"#end - }#if($foreach.hasNext),#end - #end - #end - ] -}""" -# endpoint paths -API_PATH_DATA_INBOUND = '/data' -API_PATH_HTTP_BACKEND = '/hello_world' -API_PATH_LAMBDA_PROXY_BACKEND = '/lambda/{test_param1}' -# name of Kinesis stream connected to API Gateway -TEST_STREAM_KINESIS_API_GW = 'test-stream-api-gw' -TEST_STAGE_NAME = 'testing' -TEST_LAMBDA_PROXY_BACKEND = 'test_lambda_apigw_backend' - - -def connect_api_gateway_to_kinesis(gateway_name, kinesis_stream): - resources = {} - template = APIGATEWAY_DATA_INBOUND_TEMPLATE % (kinesis_stream) - resource_path = API_PATH_DATA_INBOUND.replace('/', '') - resources[resource_path] = [{ - 'httpMethod': 'POST', - 'authorizationType': 'NONE', - 'integrations': [{ - 'type': 'AWS', - 'uri': 'arn:aws:apigateway:%s:kinesis:action/PutRecords' % DEFAULT_REGION, - 'requestTemplates': { - 'application/json': template - } - }] - }] - return aws_stack.create_api_gateway(name=gateway_name, resources=resources, - stage_name=TEST_STAGE_NAME) - - -def connect_api_gateway_to_http(gateway_name, target_url, methods=[], path=None): - if not methods: - methods = ['GET', 'POST'] - if not path: - path = '/' - resources = {} - resource_path = path.replace('/', '') - resources[resource_path] = [] - for method in methods: - resources[resource_path].append({ - 'httpMethod': method, - 'integrations': [{ - 'type': 'HTTP', - 'uri': target_url - }] - }) - return aws_stack.create_api_gateway(name=gateway_name, resources=resources, - stage_name=TEST_STAGE_NAME) - - -def connect_api_gateway_to_http_with_lambda_proxy(gateway_name, target_uri, methods=[], path=None): - if not methods: - methods = ['GET', 'POST'] - if not path: - path = '/' - resources = {} - resource_path = path.lstrip('/') - resources[resource_path] = [] - for method in methods: - resources[resource_path].append({ - 'httpMethod': method, - 'integrations': [{ - 'type': 'AWS_PROXY', - 'uri': target_uri - }] - }) - return aws_stack.create_api_gateway(name=gateway_name, resources=resources, - stage_name=TEST_STAGE_NAME) - - -def test_api_gateway_kinesis_integration(): - # create target Kinesis stream - aws_stack.create_kinesis_stream(TEST_STREAM_KINESIS_API_GW) - - # create API Gateway and connect it to the target stream - result = connect_api_gateway_to_kinesis('test_gateway1', TEST_STREAM_KINESIS_API_GW) - - # generate test data - test_data = {'records': [ - {'data': '{"foo": "bar1"}'}, - {'data': '{"foo": "bar2"}'}, - {'data': '{"foo": "bar3"}'} - ]} - - url = INBOUND_GATEWAY_URL_PATTERN.format(api_id=result['id'], - stage_name=TEST_STAGE_NAME, path=API_PATH_DATA_INBOUND) - result = requests.post(url, data=json.dumps(test_data)) - result = json.loads(to_str(result.content)) - assert result['FailedRecordCount'] == 0 - assert len(result['Records']) == len(test_data['records']) - - -def test_api_gateway_http_integration(): - test_port = 12123 - backend_url = 'http://localhost:%s%s' % (test_port, API_PATH_HTTP_BACKEND) - - # create target HTTP backend - class TestListener(ProxyListener): - - def forward_request(self, **kwargs): - response = Response() - response.status_code = 200 - response._content = kwargs.get('data') or '{}' - return response - - proxy = GenericProxy(test_port, update_listener=TestListener()) - proxy.start() - - # create API Gateway and connect it to the HTTP backend - result = connect_api_gateway_to_http('test_gateway2', backend_url, path=API_PATH_HTTP_BACKEND) - - url = INBOUND_GATEWAY_URL_PATTERN.format(api_id=result['id'], - stage_name=TEST_STAGE_NAME, path=API_PATH_HTTP_BACKEND) - - # make sure CORS headers are present - origin = 'localhost' - result = requests.options(url, headers={'origin': origin}) - assert result.status_code == 200 - assert re.match(result.headers['Access-Control-Allow-Origin'].replace('*', '.*'), origin) - assert 'POST' in result.headers['Access-Control-Allow-Methods'] - - # make test request to gateway - result = requests.get(url) - assert result.status_code == 200 - assert to_str(result.content) == '{}' - data = {'data': 123} - result = requests.post(url, data=json.dumps(data)) - assert result.status_code == 200 - assert json.loads(to_str(result.content)) == data - - # clean up - proxy.stop() - - -def test_api_gateway_lambda_proxy_integration(): - # create lambda function - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_PYTHON), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=TEST_LAMBDA_PROXY_BACKEND, - zip_file=zip_file, runtime=LAMBDA_RUNTIME_PYTHON27) - - # create API Gateway and connect it to the Lambda proxy backend - lambda_uri = aws_stack.lambda_function_arn(TEST_LAMBDA_PROXY_BACKEND) - target_uri = 'arn:aws:apigateway:%s:lambda:path/2015-03-31/functions/%s/invocations' % (DEFAULT_REGION, lambda_uri) - result = connect_api_gateway_to_http_with_lambda_proxy('test_gateway2', target_uri, - path=API_PATH_LAMBDA_PROXY_BACKEND) - - # make test request to gateway and check response - path = API_PATH_LAMBDA_PROXY_BACKEND.replace('{test_param1}', 'foo1') - url = INBOUND_GATEWAY_URL_PATTERN.format(api_id=result['id'], stage_name=TEST_STAGE_NAME, path=path) - data = {'return_status_code': 203, 'return_headers': {'foo': 'bar123'}} - result = requests.post(url, data=json.dumps(data)) - assert result.status_code == 203 - assert result.headers.get('foo') == 'bar123' - parsed_body = json.loads(to_str(result.content)) - assert parsed_body.get('return_status_code') == 203 - assert parsed_body.get('return_headers') == {'foo': 'bar123'} - assert parsed_body.get('pathParameters') == {'test_param1': 'foo1'} diff --git a/tests/integration/test_cloudformation.py b/tests/integration/test_cloudformation.py deleted file mode 100644 index e1efe34bdc084..0000000000000 --- a/tests/integration/test_cloudformation.py +++ /dev/null @@ -1,84 +0,0 @@ -import os -import unittest -from localstack.utils.aws import aws_stack -from localstack.utils.common import load_file, retry -from localstack.utils.cloudformation import template_deployer -from botocore.exceptions import ClientError -from botocore.parsers import ResponseParserError - -THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) -TEST_TEMPLATE_1 = os.path.join(THIS_FOLDER, 'templates', 'template1.yaml') - -TEST_STACK_NAME = 'test-cf-stack-1' - - -def bucket_exists(name): - s3_client = aws_stack.connect_to_service('s3') - buckets = s3_client.list_buckets() - for bucket in buckets['Buckets']: - if bucket['Name'] == name: - return True - - -def queue_exists(name): - sqs_client = aws_stack.connect_to_service('sqs') - queues = sqs_client.list_queues() - for queue_url in queues['QueueUrls']: - if queue_url.endswith('/%s' % name): - return True - - -def stream_exists(name): - kinesis_client = aws_stack.connect_to_service('kinesis') - streams = kinesis_client.list_streams() - return name in streams['StreamNames'] - - -def get_stack_details(stack_name): - cloudformation = aws_stack.connect_to_service('cloudformation') - stacks = cloudformation.describe_stacks(StackName=TEST_STACK_NAME) - for stack in stacks['Stacks']: - if stack['StackName'] == stack_name: - return stack - - -class CloudFormationTest(unittest.TestCase): - - def test_apply_template(self): - cloudformation = aws_stack.connect_to_resource('cloudformation') - template = template_deployer.template_to_json(load_file(TEST_TEMPLATE_1)) - - # deploy template - cloudformation.create_stack(StackName=TEST_STACK_NAME, TemplateBody=template) - - # wait for deployment to finish - def check_stack(): - stack = get_stack_details(TEST_STACK_NAME) - assert stack['StackStatus'] == 'CREATE_COMPLETE' - - retry(check_stack, retries=3, sleep=2) - - # assert that bucket has been created - assert bucket_exists('cf-test-bucket-1') - # assert that queue has been created - assert queue_exists('cf-test-queue-1') - # assert that stream has been created - assert stream_exists('cf-test-stream-1') - - def test_validate_template(self): - cloudformation = aws_stack.connect_to_service('cloudformation') - template = template_deployer.template_to_json(load_file(TEST_TEMPLATE_1)) - response = cloudformation.validate_template(TemplateBody=template) - assert response['ResponseMetadata']['HTTPStatusCode'] == 200 - - def test_validate_invalid_json_template_should_fail(self): - cloudformation = aws_stack.connect_to_service('cloudformation') - invalid_json = '{"this is invalid JSON"="bobbins"}' - - try: - cloudformation.validate_template(TemplateBody=invalid_json) - self.fail('Should raise ValidationError') - except (ClientError, ResponseParserError) as err: - if isinstance(err, ClientError): - assert err.response['ResponseMetadata']['HTTPStatusCode'] == 400 - assert err.response['Error']['Message'] == 'Template Validation Error' diff --git a/tests/integration/test_config_endpoint.py b/tests/integration/test_config_endpoint.py new file mode 100644 index 0000000000000..52b19662c6422 --- /dev/null +++ b/tests/integration/test_config_endpoint.py @@ -0,0 +1,54 @@ +import pytest +import requests + +from localstack import config +from localstack.http import Resource +from localstack.services.internal import ConfigResource, get_internal_apis +from localstack.utils import config_listener + + +@pytest.fixture +def config_endpoint(monkeypatch): + if config.ENABLE_CONFIG_UPDATES: + return + + router = get_internal_apis() + monkeypatch.setattr(config, "ENABLE_CONFIG_UPDATES", True) + # will listen on /_localstack/config + rules = router.add(Resource("/_localstack/config", ConfigResource())) + yield + router.remove(rules) + + +def test_config_endpoint(config_endpoint): + key = value = None + + def custom_listener(config_key, config_value): + nonlocal key, value + key = config_key + value = config_value + + config.FOO = None + config_listener.CONFIG_LISTENERS.append(custom_listener) + + # test the Route + body = {"variable": "FOO", "value": "BAZ"} + # test the ProxyListener + url = f"{config.internal_service_url()}/_localstack/config" + response = requests.post(url, json=body) + assert response.ok + response_body = response.json() + assert body == response_body + assert body["value"] == config.FOO + assert body["variable"] == key + assert body["value"] == value + + # test numeric value update + body = {"variable": "FOO", "value": 0.9} + response = requests.post(url, json=body) + assert response.ok + assert config.FOO == 0.9 + assert isinstance(config.FOO, float) + + del config.FOO + config_listener.CONFIG_LISTENERS.remove(custom_listener) diff --git a/tests/integration/test_config_service.py b/tests/integration/test_config_service.py new file mode 100644 index 0000000000000..03dad8d204de5 --- /dev/null +++ b/tests/integration/test_config_service.py @@ -0,0 +1,91 @@ +import json + +import pytest + +from localstack.utils.common import short_uid + +TEST_CONFIG_RECORDER_NAME = "test-recorder-name" +TEST_RESOURCE_TYPES = "AWS::EC2::Instance" +ASSUME_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Action": "sts:AssumeRole", "Principal": {"Service": "lambda.amazonaws.com"}}], +} + + +class TestConfigService: + @pytest.fixture + def create_configuration_recorder(self, aws_client): + def _create_config_recorder(iam_role_arn: str): + aws_client.config.put_configuration_recorder( + ConfigurationRecorder={ + "name": TEST_CONFIG_RECORDER_NAME, + "roleARN": iam_role_arn, + "recordingGroup": { + "allSupported": False, + "includeGlobalResourceTypes": False, + "resourceTypes": [TEST_RESOURCE_TYPES], + }, + } + ) + + yield _create_config_recorder + + def test_put_configuration_recorder( + self, aws_client, create_role, create_configuration_recorder + ): + iam_role_name = "role-{}".format(short_uid()) + iam_role_arn = create_role( + RoleName=iam_role_name, AssumeRolePolicyDocument=json.dumps(ASSUME_POLICY_DOCUMENT) + )["Role"]["Arn"] + + create_configuration_recorder(iam_role_arn) + configuration_recorder_data = aws_client.config.describe_configuration_recorders()[ + "ConfigurationRecorders" + ] + + assert TEST_CONFIG_RECORDER_NAME in configuration_recorder_data[0]["name"] + assert iam_role_arn in configuration_recorder_data[0]["roleARN"] + assert ( + TEST_RESOURCE_TYPES in configuration_recorder_data[0]["recordingGroup"]["resourceTypes"] + ) + assert len(configuration_recorder_data) == 1 + + aws_client.config.delete_configuration_recorder( + ConfigurationRecorderName=TEST_CONFIG_RECORDER_NAME + ) + + def test_put_delivery_channel( + self, aws_client, s3_create_bucket, create_role, create_configuration_recorder + ): + iam_role_name = "role-{}".format(short_uid()) + iam_role_arn = create_role( + RoleName=iam_role_name, AssumeRolePolicyDocument=json.dumps(ASSUME_POLICY_DOCUMENT) + )["Role"]["Arn"] + + create_configuration_recorder(iam_role_arn) + + test_bucket_name = f"test-bucket-{short_uid()}" + s3_create_bucket(Bucket=test_bucket_name) + + sns_client = aws_client.sns + sns_topic_arn = sns_client.create_topic(Name="test-sns-topic")["TopicArn"] + + delivery_channel_name = "test-delivery-channel" + aws_client.config.put_delivery_channel( + DeliveryChannel={ + "name": delivery_channel_name, + "s3BucketName": test_bucket_name, + "snsTopicARN": sns_topic_arn, + "configSnapshotDeliveryProperties": {"deliveryFrequency": "Twelve_Hours"}, + } + ) + + delivery_channels = aws_client.config.describe_delivery_channels()["DeliveryChannels"] + assert test_bucket_name in delivery_channels[0]["s3BucketName"] + assert sns_topic_arn in delivery_channels[0]["snsTopicARN"] + assert len(delivery_channels) == 1 + + aws_client.config.delete_delivery_channel(DeliveryChannelName=delivery_channel_name) + aws_client.config.delete_configuration_recorder( + ConfigurationRecorderName=TEST_CONFIG_RECORDER_NAME + ) diff --git a/tests/integration/test_dynamodb.py b/tests/integration/test_dynamodb.py deleted file mode 100644 index a11c9cc7a418b..0000000000000 --- a/tests/integration/test_dynamodb.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- - -import unittest -from localstack.utils import testutil -from localstack.utils.aws import aws_stack -from localstack.utils.common import json_safe - -TEST_DDB_TABLE_NAME = 'test-ddb-table-1' -TEST_DDB_TABLE_NAME_2 = 'test-ddb-table-2' -PARTITION_KEY = 'id' - - -class DynamoDBIntegrationTest (unittest.TestCase): - - def test_non_ascii_chars(self): - dynamodb = aws_stack.connect_to_resource('dynamodb') - - testutil.create_dynamodb_table(TEST_DDB_TABLE_NAME, partition_key=PARTITION_KEY) - table = dynamodb.Table(TEST_DDB_TABLE_NAME) - - # write some items containing non-ASCII characters - items = { - 'id1': {PARTITION_KEY: 'id1', 'data': 'foobar123 βœ“'}, - 'id2': {PARTITION_KEY: 'id2', 'data': 'foobar123 Β£'}, - 'id3': {PARTITION_KEY: 'id3', 'data': 'foobar123 Β’'} - } - for k, item in items.items(): - table.put_item(Item=item) - - for item_id in items.keys(): - item = table.get_item(Key={PARTITION_KEY: item_id})['Item'] - # need to fix up the JSON and convert str to unicode for Python 2 - item1 = json_safe(item) - item2 = json_safe(items[item_id]) - assert item1 == item2 - - def test_large_data_download(self): - dynamodb = aws_stack.connect_to_resource('dynamodb') - dynamodb_client = aws_stack.connect_to_service('dynamodb') - - testutil.create_dynamodb_table(TEST_DDB_TABLE_NAME_2, partition_key=PARTITION_KEY) - table = dynamodb.Table(TEST_DDB_TABLE_NAME_2) - - # Create a large amount of items - num_items = 20 - for i in range(0, num_items): - item = {PARTITION_KEY: 'id%s' % i, 'data1': 'foobar123 ' * 1000} - table.put_item(Item=item) - - # Retrieve the items. The data will be transmitted to the client with chunked transfer encoding - result = table.scan(TableName=TEST_DDB_TABLE_NAME_2) - assert len(result['Items']) == num_items - - # Clean up - dynamodb_client.delete_table(TableName=TEST_DDB_TABLE_NAME_2) diff --git a/tests/integration/test_elasticsearch.py b/tests/integration/test_elasticsearch.py deleted file mode 100644 index 98974db8d6260..0000000000000 --- a/tests/integration/test_elasticsearch.py +++ /dev/null @@ -1,103 +0,0 @@ -import json -import time -from botocore.exceptions import ClientError -from nose.tools import assert_raises, assert_equal, assert_true, assert_false -from localstack.utils.aws import aws_stack -from localstack.utils.common import safe_requests as requests - -ES_URL = aws_stack.get_local_service_url('elasticsearch') -TEST_INDEX = 'megacorp' -TEST_DOC_ID = 1 -COMMON_HEADERS = { - 'content-type': 'application/json', - 'Accept-encoding': 'identity' -} -TEST_DOMAIN_NAME = 'test_es_domain_1' - - -def setUp(): - document = { - 'first_name': 'Jane', - 'last_name': 'Smith', - 'age': 32, - 'about': 'I like to collect rock albums', - 'interests': ['music'] - } - resp = add_document(TEST_DOC_ID, document) - assert_equal(201, resp.status_code, - msg='Request failed({}): {}'.format(resp.status_code, resp.text)) - - -def tearDown(): - delete_document(TEST_DOC_ID) - - -def add_document(id, document): - article_path = '{}/{}/employee/{}?pretty'.format(ES_URL, TEST_INDEX, id) - resp = requests.put( - article_path, - data=json.dumps(document), - headers=COMMON_HEADERS) - # Pause to allow the document to be indexed - time.sleep(1) - return resp - - -def delete_document(id): - article_path = '{}/{}/employee/{}?pretty'.format(ES_URL, TEST_INDEX, id) - resp = requests.delete(article_path, headers=COMMON_HEADERS) - # Pause to allow the document to be indexed - time.sleep(1) - return resp - - -def test_domain_creation(): - es_client = aws_stack.connect_to_service('es') - - # create ES domain - es_client.create_elasticsearch_domain(DomainName=TEST_DOMAIN_NAME) - assert_true(TEST_DOMAIN_NAME in - [d['DomainName'] for d in es_client.list_domain_names()['DomainNames']]) - - # make sure we cannot re-create same domain name - assert_raises(ClientError, es_client.create_elasticsearch_domain, DomainName=TEST_DOMAIN_NAME) - - # get domain status - status = es_client.describe_elasticsearch_domain(DomainName=TEST_DOMAIN_NAME) - assert_equal(status['DomainStatus']['DomainName'], TEST_DOMAIN_NAME) - assert_true(status['DomainStatus']['Created']) - assert_false(status['DomainStatus']['Deleted']) - - # make sure domain deletion works - es_client.delete_elasticsearch_domain(DomainName=TEST_DOMAIN_NAME) - assert_false(TEST_DOMAIN_NAME in - [d['DomainName'] for d in es_client.list_domain_names()['DomainNames']]) - - -def test_elasticsearch_get_document(): - article_path = '{}/{}/employee/{}?pretty'.format( - ES_URL, TEST_INDEX, TEST_DOC_ID) - resp = requests.get(article_path, headers=COMMON_HEADERS) - - assert_true('I like to collect rock albums' in resp.text, - msg='Document not found({}): {}'.format(resp.status_code, resp.text)) - - -def test_elasticsearch_search(): - search_path = '{}/{}/employee/_search?pretty'.format(ES_URL, TEST_INDEX) - - search = { - 'query': { - 'match': { - 'last_name': 'Smith' - } - } - } - - resp = requests.get( - search_path, - data=json.dumps(search), - headers=COMMON_HEADERS) - - assert_true('I like to collect rock albums' in resp.text, - msg='Search failed({}): {}'.format(resp.status_code, resp.text)) diff --git a/tests/integration/test_error_injection.py b/tests/integration/test_error_injection.py deleted file mode 100644 index 8e9fceb30e201..0000000000000 --- a/tests/integration/test_error_injection.py +++ /dev/null @@ -1,66 +0,0 @@ -import os -from nose.tools import assert_raises, assert_equal -from botocore.exceptions import ClientError -from localstack import config -from localstack.utils.common import short_uid -from localstack.utils.aws import aws_stack -from localstack.utils import testutil -from .lambdas import lambda_integration -from .test_integration import TEST_TABLE_NAME, PARTITION_KEY - -TEST_STREAM_NAME = lambda_integration.KINESIS_STREAM_NAME - - -def test_kinesis_error_injection(): - if not do_run(): - return - - kinesis = aws_stack.connect_to_service('kinesis') - aws_stack.create_kinesis_stream(TEST_STREAM_NAME) - - records = [ - { - 'Data': '0', - 'ExplicitHashKey': '0', - 'PartitionKey': '0' - } - ] - - # by default, no errors - test_no_errors = kinesis.put_records(StreamName=TEST_STREAM_NAME, Records=records) - assert_equal(test_no_errors['FailedRecordCount'], 0) - - # with a probability of 1, always throw errors - config.KINESIS_ERROR_PROBABILITY = 1.0 - test_all_errors = kinesis.put_records(StreamName=TEST_STREAM_NAME, Records=records) - assert_equal(test_all_errors['FailedRecordCount'], 1) - - # reset probability to zero - config.KINESIS_ERROR_PROBABILITY = 0.0 - - -def test_dynamodb_error_injection(): - if not do_run(): - return - - dynamodb = aws_stack.connect_to_resource('dynamodb') - # create table with stream forwarding config - testutil.create_dynamodb_table(TEST_TABLE_NAME, partition_key=PARTITION_KEY) - table = dynamodb.Table(TEST_TABLE_NAME) - - # by default, no errors - test_no_errors = table.put_item(Item={PARTITION_KEY: short_uid(), 'data': 'foobar123'}) - assert_equal(test_no_errors['ResponseMetadata']['HTTPStatusCode'], 200) - - # with a probability of 1, always throw errors - config.DYNAMODB_ERROR_PROBABILITY = 1.0 - assert_raises(ClientError, table.put_item, Item={PARTITION_KEY: short_uid(), 'data': 'foobar123'}) - - # reset probability to zero - config.DYNAMODB_ERROR_PROBABILITY = 0.0 - - -def do_run(): - # Only run the tests if the $TEST_ERROR_INJECTION environment variable is set. This is to reduce the - # testing time, because the injected errors result in retries and timeouts that slow down the tests overall. - return os.environ.get('TEST_ERROR_INJECTION') in ('true', '1') diff --git a/tests/integration/test_forwarder.py b/tests/integration/test_forwarder.py new file mode 100644 index 0000000000000..562fe4b6f8915 --- /dev/null +++ b/tests/integration/test_forwarder.py @@ -0,0 +1,47 @@ +import pytest + +from localstack.aws.api import ( + RequestContext, + ServiceException, + ServiceRequest, + ServiceResponse, + handler, +) +from localstack.aws.forwarder import ForwardingFallbackDispatcher, NotImplementedAvoidFallbackError + + +def test_forwarding_fallback_dispatcher(): + # create a dummy provider which raises a NotImplementedError (triggering the fallthrough) + class TestProvider: + @handler(operation="TestOperation") + def test_method(self, context): + raise NotImplementedError + + test_provider = TestProvider() + + # create a dummy fallback function + def test_request_forwarder(_, __) -> ServiceResponse: + return "fallback-result" + + # invoke the function and expect the result from the fallback function + dispatcher = ForwardingFallbackDispatcher(test_provider, test_request_forwarder) + assert dispatcher["TestOperation"](RequestContext(None), ServiceRequest()) == "fallback-result" + + +def test_forwarding_fallback_dispatcher_avoid_fallback(): + # create a dummy provider which raises a NotImplementedAvoidFallbackError (avoiding the fallthrough) + class TestProvider: + @handler(operation="TestOperation") + def test_method(self, context): + raise NotImplementedAvoidFallbackError + + test_provider = TestProvider() + + # create a dummy forwarding function which raises a ServiceException + def test_request_forwarder(_, __) -> ServiceResponse: + raise ServiceException + + # expect a NotImplementedError exception (and not the ServiceException from the fallthrough) + dispatcher = ForwardingFallbackDispatcher(test_provider, test_request_forwarder) + with pytest.raises(NotImplementedError): + dispatcher["TestOperation"](RequestContext(None), ServiceRequest()) diff --git a/tests/integration/test_iam_credentials.py b/tests/integration/test_iam_credentials.py deleted file mode 100755 index 6019c84ee3676..0000000000000 --- a/tests/integration/test_iam_credentials.py +++ /dev/null @@ -1,23 +0,0 @@ -import os -import logging -from localstack.utils.kinesis import kinesis_connector - - -def run_kcl_with_iam_assume_role(): - env_vars = {} - if os.environ.get('AWS_ASSUME_ROLE_ARN'): - env_vars['AWS_ASSUME_ROLE_ARN'] = os.environ.get('AWS_ASSUME_ROLE_ARN') - env_vars['AWS_ASSUME_ROLE_SESSION_NAME'] = os.environ.get('AWS_ASSUME_ROLE_SESSION_NAME') - env_vars['ENV'] = os.environ.get('ENV') or 'main' - - def process_records(records): - print(records) - - # start Kinesis client - stream_name = 'test-foobar' - kinesis_connector.listen_to_kinesis( - stream_name=stream_name, - listener_func=process_records, - env_vars=env_vars, - kcl_log_level=logging.INFO, - wait_until_started=True) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py deleted file mode 100644 index 2a8f47ad68ee8..0000000000000 --- a/tests/integration/test_integration.py +++ /dev/null @@ -1,249 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -import time -import logging -from datetime import datetime, timedelta -from nose.tools import assert_raises -from localstack.utils import testutil -from localstack.utils.common import load_file, short_uid, clone, to_bytes, to_str, run_safe, retry -from localstack.services.awslambda.lambda_api import LAMBDA_RUNTIME_PYTHON27 -from localstack.utils.kinesis import kinesis_connector -from localstack.utils.aws import aws_stack -from localstack.utils.cloudwatch import cloudwatch_util -from .lambdas import lambda_integration -from .test_lambda import TEST_LAMBDA_PYTHON, TEST_LAMBDA_LIBS - -TEST_STREAM_NAME = lambda_integration.KINESIS_STREAM_NAME -TEST_LAMBDA_SOURCE_STREAM_NAME = 'test_source_stream' -TEST_TABLE_NAME = 'test_stream_table' -TEST_LAMBDA_NAME_DDB = 'test_lambda_ddb' -TEST_LAMBDA_NAME_STREAM = 'test_lambda_stream' -TEST_FIREHOSE_NAME = 'test_firehose' -TEST_BUCKET_NAME = lambda_integration.TEST_BUCKET_NAME -TEST_TOPIC_NAME = 'test_topic' -# constants for forward chain K1->L1->K2->L2 -TEST_CHAIN_STREAM1_NAME = 'test_chain_stream_1' -TEST_CHAIN_STREAM2_NAME = 'test_chain_stream_2' -TEST_CHAIN_LAMBDA1_NAME = 'test_chain_lambda_1' -TEST_CHAIN_LAMBDA2_NAME = 'test_chain_lambda_2' - -EVENTS = [] - -PARTITION_KEY = 'id' - -# set up logger -LOGGER = logging.getLogger(__name__) - - -def test_firehose_s3(): - - s3_resource = aws_stack.connect_to_resource('s3') - firehose = aws_stack.connect_to_service('firehose') - - s3_prefix = '/testdata' - test_data = '{"test": "firehose_data_%s"}' % short_uid() - # create Firehose stream - stream = firehose.create_delivery_stream( - DeliveryStreamName=TEST_FIREHOSE_NAME, - S3DestinationConfiguration={ - 'RoleARN': aws_stack.iam_resource_arn('firehose'), - 'BucketARN': aws_stack.s3_bucket_arn(TEST_BUCKET_NAME), - 'Prefix': s3_prefix - } - ) - assert stream - assert TEST_FIREHOSE_NAME in firehose.list_delivery_streams()['DeliveryStreamNames'] - # create target S3 bucket - s3_resource.create_bucket(Bucket=TEST_BUCKET_NAME) - - # put records - firehose.put_record( - DeliveryStreamName=TEST_FIREHOSE_NAME, - Record={ - 'Data': to_bytes(test_data) - } - ) - # check records in target bucket - all_objects = testutil.list_all_s3_objects() - testutil.assert_objects(json.loads(to_str(test_data)), all_objects) - - -def test_kinesis_lambda_sns_ddb_streams(): - - ddb_lease_table_suffix = '-kclapp' - dynamodb = aws_stack.connect_to_resource('dynamodb') - dynamodb_service = aws_stack.connect_to_service('dynamodb') - dynamodbstreams = aws_stack.connect_to_service('dynamodbstreams') - kinesis = aws_stack.connect_to_service('kinesis') - sns = aws_stack.connect_to_service('sns') - - LOGGER.info('Creating test streams...') - run_safe(lambda: dynamodb_service.delete_table( - TableName=TEST_STREAM_NAME + ddb_lease_table_suffix), print_error=False) - aws_stack.create_kinesis_stream(TEST_STREAM_NAME, delete=True) - aws_stack.create_kinesis_stream(TEST_LAMBDA_SOURCE_STREAM_NAME) - - # subscribe to inbound Kinesis stream - def process_records(records, shard_id): - EVENTS.extend(records) - - # start the KCL client process in the background - kinesis_connector.listen_to_kinesis(TEST_STREAM_NAME, listener_func=process_records, - wait_until_started=True, ddb_lease_table_suffix=ddb_lease_table_suffix) - - LOGGER.info('Kinesis consumer initialized.') - - # create table with stream forwarding config - testutil.create_dynamodb_table(TEST_TABLE_NAME, partition_key=PARTITION_KEY, - stream_view_type='NEW_AND_OLD_IMAGES') - - # list DDB streams and make sure the table stream is there - streams = dynamodbstreams.list_streams() - ddb_event_source_arn = None - for stream in streams['Streams']: - if stream['TableName'] == TEST_TABLE_NAME: - ddb_event_source_arn = stream['StreamArn'] - assert ddb_event_source_arn - - # deploy test lambda connected to DynamoDB Stream - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_PYTHON), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_DDB, - zip_file=zip_file, event_source_arn=ddb_event_source_arn, runtime=LAMBDA_RUNTIME_PYTHON27) - # make sure we cannot create Lambda with same name twice - assert_raises(Exception, testutil.create_lambda_function, func_name=TEST_LAMBDA_NAME_DDB, - zip_file=zip_file, event_source_arn=ddb_event_source_arn, runtime=LAMBDA_RUNTIME_PYTHON27) - - # deploy test lambda connected to Kinesis Stream - kinesis_event_source_arn = kinesis.describe_stream( - StreamName=TEST_LAMBDA_SOURCE_STREAM_NAME)['StreamDescription']['StreamARN'] - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_STREAM, - zip_file=zip_file, event_source_arn=kinesis_event_source_arn, runtime=LAMBDA_RUNTIME_PYTHON27) - - # put items to table - num_events_ddb = 15 - num_put_items = 7 - num_batch_items = 3 - num_updates_ddb = num_events_ddb - num_put_items - num_batch_items - LOGGER.info('Putting %s items to table...' % num_events_ddb) - table = dynamodb.Table(TEST_TABLE_NAME) - for i in range(0, num_put_items): - table.put_item(Item={ - PARTITION_KEY: 'testId%s' % i, - 'data': 'foobar123' - }) - # batch write some items containing non-ASCII characters - dynamodb.batch_write_item(RequestItems={TEST_TABLE_NAME: [ - {'PutRequest': {'Item': {PARTITION_KEY: short_uid(), 'data': 'foobar123 βœ“'}}}, - {'PutRequest': {'Item': {PARTITION_KEY: short_uid(), 'data': 'foobar123 Β£'}}}, - {'PutRequest': {'Item': {PARTITION_KEY: short_uid(), 'data': 'foobar123 Β’'}}} - ]}) - # update some items, which also triggers notification events - for i in range(0, num_updates_ddb): - dynamodb_service.update_item(TableName=TEST_TABLE_NAME, - Key={PARTITION_KEY: {'S': 'testId%s' % i}}, - AttributeUpdates={'data': { - 'Action': 'PUT', - 'Value': {'S': 'foobar123_updated'} - }}) - - # put items to stream - num_events_kinesis = 10 - LOGGER.info('Putting %s items to stream...' % num_events_kinesis) - kinesis.put_records( - Records=[ - { - 'Data': '{}', - 'PartitionKey': 'testId%s' % i - } for i in range(0, num_events_kinesis) - ], StreamName=TEST_LAMBDA_SOURCE_STREAM_NAME - ) - - # put 1 item to stream that will trigger an error in the Lambda - kinesis.put_record(Data='{"%s": 1}' % lambda_integration.MSG_BODY_RAISE_ERROR_FLAG, - PartitionKey='testIderror', StreamName=TEST_LAMBDA_SOURCE_STREAM_NAME) - - # create SNS topic, connect it to the Lambda, publish test message - num_events_sns = 3 - response = sns.create_topic(Name=TEST_TOPIC_NAME) - sns.subscribe(TopicArn=response['TopicArn'], Protocol='lambda', - Endpoint=aws_stack.lambda_function_arn(TEST_LAMBDA_NAME_STREAM)) - for i in range(0, num_events_sns): - sns.publish(TopicArn=response['TopicArn'], Message='test message %s' % i) - - # get latest records - latest = aws_stack.kinesis_get_latest_records(TEST_LAMBDA_SOURCE_STREAM_NAME, - shard_id='shardId-000000000000', count=10) - assert len(latest) == 10 - - LOGGER.info('Waiting some time before finishing test.') - time.sleep(2) - - num_events = num_events_ddb + num_events_kinesis + num_events_sns - - def check_events(): - if len(EVENTS) != num_events: - LOGGER.warning(('DynamoDB and Kinesis updates retrieved ' + - '(actual/expected): %s/%s') % (len(EVENTS), num_events)) - assert len(EVENTS) == num_events - - # this can take a long time in CI, make sure we give it enough time/retries - retry(check_events, retries=7, sleep=3) - - # check cloudwatch notifications - stats1 = get_lambda_metrics(TEST_LAMBDA_NAME_STREAM) - assert len(stats1['Datapoints']) == 2 + num_events_sns - stats2 = get_lambda_metrics(TEST_LAMBDA_NAME_STREAM, 'Errors') - assert len(stats2['Datapoints']) == 1 - stats3 = get_lambda_metrics(TEST_LAMBDA_NAME_DDB) - assert len(stats3['Datapoints']) == num_events_ddb - - -def test_kinesis_lambda_forward_chain(): - kinesis = aws_stack.connect_to_service('kinesis') - s3 = aws_stack.connect_to_service('s3') - - aws_stack.create_kinesis_stream(TEST_CHAIN_STREAM1_NAME, delete=True) - aws_stack.create_kinesis_stream(TEST_CHAIN_STREAM2_NAME, delete=True) - s3.create_bucket(Bucket=TEST_BUCKET_NAME) - - # deploy test lambdas connected to Kinesis streams - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_PYTHON), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=TEST_CHAIN_LAMBDA1_NAME, zip_file=zip_file, - event_source_arn=get_event_source_arn(TEST_CHAIN_STREAM1_NAME), runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=TEST_CHAIN_LAMBDA2_NAME, zip_file=zip_file, - event_source_arn=get_event_source_arn(TEST_CHAIN_STREAM2_NAME), runtime=LAMBDA_RUNTIME_PYTHON27) - - # publish test record - test_data = {'test_data': 'forward_chain_data_%s' % short_uid()} - data = clone(test_data) - data[lambda_integration.MSG_BODY_MESSAGE_TARGET] = 'kinesis:%s' % TEST_CHAIN_STREAM2_NAME - kinesis.put_record(Data=to_bytes(json.dumps(data)), PartitionKey='testId', StreamName=TEST_CHAIN_STREAM1_NAME) - - # check results - time.sleep(5) - all_objects = testutil.list_all_s3_objects() - testutil.assert_objects(test_data, all_objects) - - -# --------------- -# HELPER METHODS -# --------------- - -def get_event_source_arn(stream_name): - kinesis = aws_stack.connect_to_service('kinesis') - return kinesis.describe_stream(StreamName=stream_name)['StreamDescription']['StreamARN'] - - -def get_lambda_metrics(func_name, metric='Invocations'): - return cloudwatch_util.get_metric_statistics( - Namespace='AWS/Lambda', - MetricName=metric, - Dimensions=[{'Name': 'FunctionName', 'Value': func_name}], - Period=60, - StartTime=datetime.now() - timedelta(minutes=1), - EndTime=datetime.now(), - Statistics=['Sum'] - ) diff --git a/tests/integration/test_lambda.py b/tests/integration/test_lambda.py deleted file mode 100644 index addc13bfb0dc5..0000000000000 --- a/tests/integration/test_lambda.py +++ /dev/null @@ -1,252 +0,0 @@ -import os -import json -import time -from io import BytesIO -from localstack.constants import LOCALSTACK_ROOT_FOLDER, LOCALSTACK_MAVEN_VERSION -from localstack.utils import testutil -from localstack.utils.aws import aws_stack -from localstack.utils.common import short_uid, load_file, to_str, mkdir, download -from localstack.services.awslambda import lambda_api, lambda_executors -from localstack.services.awslambda.lambda_api import (LAMBDA_RUNTIME_NODEJS, - LAMBDA_RUNTIME_PYTHON27, LAMBDA_RUNTIME_PYTHON36, LAMBDA_RUNTIME_JAVA8, use_docker) - -THIS_FOLDER = os.path.dirname(os.path.realpath(__file__)) -TEST_LAMBDA_PYTHON = os.path.join(THIS_FOLDER, 'lambdas', 'lambda_integration.py') -TEST_LAMBDA_PYTHON3 = os.path.join(THIS_FOLDER, 'lambdas', 'lambda_python3.py') -TEST_LAMBDA_NODEJS = os.path.join(THIS_FOLDER, 'lambdas', 'lambda_integration.js') -TEST_LAMBDA_JAVA = os.path.join(LOCALSTACK_ROOT_FOLDER, 'localstack', 'ext', 'java', 'target', - 'localstack-utils-tests.jar') -TEST_LAMBDA_ENV = os.path.join(THIS_FOLDER, 'lambdas', 'lambda_environment.py') - -TEST_LAMBDA_NAME_PY = 'test_lambda_py' -TEST_LAMBDA_NAME_PY3 = 'test_lambda_py3' -TEST_LAMBDA_NAME_JS = 'test_lambda_js' -TEST_LAMBDA_NAME_JAVA = 'test_lambda_java' -TEST_LAMBDA_NAME_JAVA_STREAM = 'test_lambda_java_stream' -TEST_LAMBDA_NAME_ENV = 'test_lambda_env' - -TEST_LAMBDA_JAR_URL = ('https://repo.maven.apache.org/maven2/cloud/localstack/' + - 'localstack-utils/{version}/localstack-utils-{version}-tests.jar').format(version=LOCALSTACK_MAVEN_VERSION) - -TEST_LAMBDA_LIBS = ['localstack', 'localstack_client', 'requests', 'psutil', 'urllib3', 'chardet', 'certifi', 'idna'] - - -def test_upload_lambda_from_s3(): - - s3_client = aws_stack.connect_to_service('s3') - lambda_client = aws_stack.connect_to_service('lambda') - - lambda_name = 'test_lambda_%s' % short_uid() - bucket_name = 'test_bucket_lambda' - bucket_key = 'test_lambda.zip' - - # upload zip file to S3 - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_PYTHON), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - s3_client.create_bucket(Bucket=bucket_name) - s3_client.upload_fileobj(BytesIO(zip_file), bucket_name, bucket_key) - - # create lambda function - lambda_client.create_function( - FunctionName=lambda_name, Handler='handler.handler', - Runtime=lambda_api.LAMBDA_RUNTIME_PYTHON27, Role='r1', - Code={ - 'S3Bucket': bucket_name, - 'S3Key': bucket_key - } - ) - - # invoke lambda function - data_before = b'{"foo": "bar"}' - result = lambda_client.invoke(FunctionName=lambda_name, Payload=data_before) - data_after = result['Payload'].read() - assert json.loads(to_str(data_before)) == json.loads(to_str(data_after)) - - -def test_lambda_runtimes(): - - lambda_client = aws_stack.connect_to_service('lambda') - - # deploy and invoke lambda - Python 2.7 - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_PYTHON), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_PY, - zip_file=zip_file, runtime=LAMBDA_RUNTIME_PYTHON27) - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_PY, Payload=b'{}') - assert result['StatusCode'] == 200 - result_data = result['Payload'].read() - assert to_str(result_data).strip() == '{}' - - if use_docker(): - # deploy and invoke lambda - Python 3.6 - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_PYTHON3), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON36) - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_PY3, - zip_file=zip_file, runtime=LAMBDA_RUNTIME_PYTHON36) - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_PY3, Payload=b'{}') - assert result['StatusCode'] == 200 - result_data = result['Payload'].read() - assert to_str(result_data).strip() == '{}' - - # deploy and invoke lambda - Java - if not os.path.exists(TEST_LAMBDA_JAVA): - mkdir(os.path.dirname(TEST_LAMBDA_JAVA)) - download(TEST_LAMBDA_JAR_URL, TEST_LAMBDA_JAVA) - zip_file = testutil.create_zip_file(TEST_LAMBDA_JAVA, get_content=True) - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_JAVA, zip_file=zip_file, - runtime=LAMBDA_RUNTIME_JAVA8, handler='cloud.localstack.sample.LambdaHandler') - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_JAVA, Payload=b'{}') - assert result['StatusCode'] == 200 - result_data = result['Payload'].read() - assert 'LinkedHashMap' in to_str(result_data) - - # test SNSEvent - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_JAVA, InvocationType='Event', - Payload=b'{"Records": [{"Sns": {"Message": "{}"}}]}') - assert result['StatusCode'] == 200 - result_data = result['Payload'].read() - assert json.loads(to_str(result_data)) == {'async': 'True'} - - # test KinesisEvent - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_JAVA, - Payload=b'{"Records": [{"Kinesis": {"Data": "data", "PartitionKey": "partition"}}]}') - assert result['StatusCode'] == 200 - result_data = result['Payload'].read() - assert 'KinesisEvent' in to_str(result_data) - - # deploy and invoke lambda - Java with stream handler - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_JAVA_STREAM, zip_file=zip_file, - runtime=LAMBDA_RUNTIME_JAVA8, handler='cloud.localstack.sample.LambdaStreamHandler') - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_JAVA_STREAM, Payload=b'{}') - assert result['StatusCode'] == 200 - result_data = result['Payload'].read() - assert to_str(result_data).strip() == '{}' - - if use_docker(): - # deploy and invoke lambda - Node.js - zip_file = testutil.create_zip_file(TEST_LAMBDA_NODEJS, get_content=True) - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_JS, - zip_file=zip_file, handler='lambda_integration.handler', runtime=LAMBDA_RUNTIME_NODEJS) - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_JS, Payload=b'{}') - assert result['StatusCode'] == 200 - result_data = result['Payload'].read() - assert to_str(result_data).strip() == '{}' - - -def test_lambda_environment(): - - lambda_client = aws_stack.connect_to_service('lambda') - - # deploy and invoke lambda without Docker - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_ENV), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=TEST_LAMBDA_NAME_ENV, - zip_file=zip_file, runtime=LAMBDA_RUNTIME_PYTHON27, envvars={'Hello': 'World'}) - result = lambda_client.invoke(FunctionName=TEST_LAMBDA_NAME_ENV, Payload=b'{}') - assert result['StatusCode'] == 200 - result_data = result['Payload'] - assert json.load(result_data) == {'Hello': 'World'} - - -def test_prime_and_destroy_containers(): - - # run these tests only for the "reuse containers" Lambda executor - if not isinstance(lambda_api.LAMBDA_EXECUTOR, lambda_executors.LambdaExecutorReuseContainers): - return - - executor = lambda_api.LAMBDA_EXECUTOR - func_name = 'test_prime_and_destroy_containers' - - # create a new lambda - lambda_client = aws_stack.connect_to_service('lambda') - - func_arn = lambda_api.func_arn(func_name) - - # make sure existing containers are gone - executor.cleanup() - assert len(executor.get_all_container_names()) == 0 - - # deploy and invoke lambda without Docker - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_ENV), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=func_name, zip_file=zip_file, - runtime=LAMBDA_RUNTIME_PYTHON27, envvars={'Hello': 'World'}) - - assert len(executor.get_all_container_names()) == 0 - - assert executor.function_invoke_times == {} - - # invoke a few times. - durations = [] - num_iterations = 3 - - for i in range(0, num_iterations + 1): - prev_invoke_time = None - if i > 0: - prev_invoke_time = executor.function_invoke_times[func_arn] - - start_time = time.time() - lambda_client.invoke(FunctionName=func_name, Payload=b'{}') - duration = time.time() - start_time - - assert len(executor.get_all_container_names()) == 1 - - # ensure the last invoke time is being updated properly. - if i > 0: - assert executor.function_invoke_times[func_arn] > prev_invoke_time - else: - assert executor.function_invoke_times[func_arn] > 0 - - durations.append(duration) - - # the first call would have created the container. subsequent calls would reuse and be faster. - for i in range(1, num_iterations + 1): - assert durations[i] < durations[0] - - status = executor.get_docker_container_status(func_arn) - assert status == 1 - - executor.cleanup() - status = executor.get_docker_container_status(func_arn) - assert status == 0 - - assert len(executor.get_all_container_names()) == 0 - - -def test_destroy_idle_containers(): - - # run these tests only for the "reuse containers" Lambda executor - if not isinstance(lambda_api.LAMBDA_EXECUTOR, lambda_executors.LambdaExecutorReuseContainers): - return - - executor = lambda_api.LAMBDA_EXECUTOR - func_name = 'test_destroy_idle_containers' - - # create a new lambda - lambda_client = aws_stack.connect_to_service('lambda') - - func_arn = lambda_api.func_arn(func_name) - - # make sure existing containers are gone - executor.destroy_existing_docker_containers() - assert len(executor.get_all_container_names()) == 0 - - # deploy and invoke lambda without Docker - zip_file = testutil.create_lambda_archive(load_file(TEST_LAMBDA_ENV), get_content=True, - libs=TEST_LAMBDA_LIBS, runtime=LAMBDA_RUNTIME_PYTHON27) - testutil.create_lambda_function(func_name=func_name, - zip_file=zip_file, runtime=LAMBDA_RUNTIME_PYTHON27, envvars={'Hello': 'World'}) - - assert len(executor.get_all_container_names()) == 0 - - lambda_client.invoke(FunctionName=func_name, Payload=b'{}') - assert len(executor.get_all_container_names()) == 1 - - # try to destroy idle containers. - executor.idle_container_destroyer() - assert len(executor.get_all_container_names()) == 1 - - # simulate an idle container - executor.function_invoke_times[func_arn] = time.time() - 610 - executor.idle_container_destroyer() - assert len(executor.get_all_container_names()) == 0 diff --git a/tests/integration/test_notifications.py b/tests/integration/test_notifications.py deleted file mode 100644 index 02f2dbbc7c367..0000000000000 --- a/tests/integration/test_notifications.py +++ /dev/null @@ -1,172 +0,0 @@ -import json -from io import BytesIO -from localstack.utils import testutil -from localstack.utils.aws import aws_stack -from localstack.utils.common import to_str, short_uid - -TEST_BUCKET_NAME_WITH_NOTIFICATIONS = 'test_bucket_notif_1' -TEST_QUEUE_NAME_FOR_S3 = 'test_queue' -TEST_TOPIC_NAME = 'test_topic_name_for_sqs' -TEST_QUEUE_NAME_FOR_SNS = 'test_queue_for_sns' - - -def receive_assert_delete(queue_url, assertions, sqs_client=None): - if not sqs_client: - sqs_client = aws_stack.connect_to_service('sqs') - - response = sqs_client.receive_message(QueueUrl=queue_url) - messages = [json.loads(to_str(m['Body'])) for m in response['Messages']] - testutil.assert_objects(assertions, messages) - for message in response['Messages']: - sqs_client.delete_message(QueueUrl=queue_url, ReceiptHandle=message['ReceiptHandle']) - - -def test_sqs_queue_names(): - sqs_client = aws_stack.connect_to_service('sqs') - queue_name = '%s.fifo' % short_uid() - # make sure we can create *.fifo queues - queue_url = sqs_client.create_queue(QueueName=queue_name)['QueueUrl'] - sqs_client.delete_queue(QueueUrl=queue_url) - - -def test_sns_to_sqs(): - sqs_client = aws_stack.connect_to_service('sqs') - sns_client = aws_stack.connect_to_service('sns') - - # create topic and queue - queue_info = sqs_client.create_queue(QueueName=TEST_QUEUE_NAME_FOR_SNS) - topic_info = sns_client.create_topic(Name=TEST_TOPIC_NAME) - - # subscribe SQS to SNS, publish message - sns_client.subscribe(TopicArn=topic_info['TopicArn'], Protocol='sqs', - Endpoint=aws_stack.sqs_queue_arn(TEST_QUEUE_NAME_FOR_SNS)) - test_value = short_uid() - sns_client.publish(TopicArn=topic_info['TopicArn'], Message='test message for SQS', - MessageAttributes={'attr1': {'DataType': 'String', 'StringValue': test_value}}) - - # receive, assert, and delete message from SQS - queue_url = queue_info['QueueUrl'] - assertions = [] - # make sure we receive the correct topic ARN in notifications - assertions.append({'TopicArn': topic_info['TopicArn']}) - # make sure the notification contains message attributes - assertions.append({'Value': test_value}) - receive_assert_delete(queue_url, assertions, sqs_client) - - -def _delete_notification_config(): - s3_client = aws_stack.connect_to_service('s3') - s3_client.put_bucket_notification_configuration( - Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS, NotificationConfiguration={}) - config = s3_client.get_bucket_notification_configuration(Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS) - assert not config.get('QueueConfigurations') - - -def test_bucket_notifications(): - - s3_resource = aws_stack.connect_to_resource('s3') - s3_client = aws_stack.connect_to_service('s3') - sqs_client = aws_stack.connect_to_service('sqs') - - # create test bucket and queue - s3_resource.create_bucket(Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS) - queue_info = sqs_client.create_queue(QueueName=TEST_QUEUE_NAME_FOR_S3) - - # create notification on bucket - queue_url = queue_info['QueueUrl'] - queue_arn = aws_stack.sqs_queue_arn(TEST_QUEUE_NAME_FOR_S3) - events = ['s3:ObjectCreated:*', 's3:ObjectRemoved:Delete'] - filter_rules = { - 'FilterRules': [{ - 'Name': 'prefix', - 'Value': 'testupload/' - }, { - 'Name': 'suffix', - 'Value': 'testfile.txt' - }] - } - s3_client.put_bucket_notification_configuration( - Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS, - NotificationConfiguration={ - 'QueueConfigurations': [{ - 'Id': 'id123456', - 'QueueArn': queue_arn, - 'Events': events, - 'Filter': { - 'Key': filter_rules - } - }] - } - ) - - # retrieve and check notification config - config = s3_client.get_bucket_notification_configuration(Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS) - config = config['QueueConfigurations'][0] - assert events == config['Events'] - assert filter_rules == config['Filter']['Key'] - - # upload file to S3 (this should NOT trigger a notification) - test_key1 = '/testdata' - test_data1 = b'{"test": "bucket_notification1"}' - s3_client.upload_fileobj(BytesIO(test_data1), TEST_BUCKET_NAME_WITH_NOTIFICATIONS, test_key1) - - # upload file to S3 (this should trigger a notification) - test_key2 = 'testupload/dir1/testfile.txt' - test_data2 = b'{"test": "bucket_notification2"}' - s3_client.upload_fileobj(BytesIO(test_data2), TEST_BUCKET_NAME_WITH_NOTIFICATIONS, test_key2) - - # receive, assert, and delete message from SQS - receive_assert_delete(queue_url, [{'key': test_key2}, {'name': TEST_BUCKET_NAME_WITH_NOTIFICATIONS}], sqs_client) - - # delete notification config - _delete_notification_config() - - # put notification config with single event type - event = 's3:ObjectCreated:*' - s3_client.put_bucket_notification_configuration(Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS, - NotificationConfiguration={ - 'QueueConfigurations': [{ - 'Id': 'id123456', - 'QueueArn': queue_arn, - 'Events': [event] - }] - } - ) - config = s3_client.get_bucket_notification_configuration(Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS) - config = config['QueueConfigurations'][0] - assert config['Events'] == [event] - - # put notification config with single event type - event = 's3:ObjectCreated:*' - filter_rules = { - 'FilterRules': [{ - 'Name': 'prefix', - 'Value': 'testupload/' - }] - } - s3_client.put_bucket_notification_configuration(Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS, - NotificationConfiguration={ - 'QueueConfigurations': [{ - 'Id': 'id123456', - 'QueueArn': queue_arn, - 'Events': [event], - 'Filter': { - 'Key': filter_rules - } - }] - } - ) - config = s3_client.get_bucket_notification_configuration(Bucket=TEST_BUCKET_NAME_WITH_NOTIFICATIONS) - config = config['QueueConfigurations'][0] - assert config['Events'] == [event] - assert filter_rules == config['Filter']['Key'] - - # upload file to S3 (this should trigger a notification) - test_key2 = 'testupload/dir1/testfile.txt' - test_data2 = b'{"test": "bucket_notification2"}' - s3_client.upload_fileobj(BytesIO(test_data2), TEST_BUCKET_NAME_WITH_NOTIFICATIONS, test_key2) - # receive, assert, and delete message from SQS - receive_assert_delete(queue_url, [{'key': test_key2}, {'name': TEST_BUCKET_NAME_WITH_NOTIFICATIONS}], sqs_client) - - # delete notification config - _delete_notification_config() diff --git a/tests/integration/test_s3.py b/tests/integration/test_s3.py deleted file mode 100644 index 774edf73e9483..0000000000000 --- a/tests/integration/test_s3.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -from localstack.utils.aws import aws_stack - -TEST_BUCKET_NAME_WITH_POLICY = 'test_bucket_policy_1' - - -def test_bucket_policy(): - - s3_resource = aws_stack.connect_to_resource('s3') - s3_client = aws_stack.connect_to_service('s3') - - # create test bucket - s3_resource.create_bucket(Bucket=TEST_BUCKET_NAME_WITH_POLICY) - - # put bucket policy - policy = { - 'Version': '2012-10-17', - 'Statement': { - 'Action': ['s3:GetObject'], - 'Effect': 'Allow', - 'Resource': 'arn:aws:s3:::bucketName/*', - 'Principal': { - 'AWS': ['*'] - } - } - } - response = s3_client.put_bucket_policy( - Bucket=TEST_BUCKET_NAME_WITH_POLICY, - Policy=json.dumps(policy) - ) - assert response['ResponseMetadata']['HTTPStatusCode'] == 204 - - # retrieve and check policy config - saved_policy = s3_client.get_bucket_policy(Bucket=TEST_BUCKET_NAME_WITH_POLICY)['Policy'] - assert json.loads(saved_policy) == policy diff --git a/tests/integration/test_security.py b/tests/integration/test_security.py new file mode 100644 index 0000000000000..76db9d7143043 --- /dev/null +++ b/tests/integration/test_security.py @@ -0,0 +1,222 @@ +import pytest +import requests + +from localstack import config +from localstack.aws.handlers import cors as cors_handler +from localstack.aws.handlers.cors import _get_allowed_cors_origins +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID, TEST_AWS_REGION_NAME +from localstack.utils.aws.request_context import mock_aws_request_headers +from localstack.utils.strings import short_uid, to_str + + +class TestCSRF: + def test_CSRF(self): + headers = {"Origin": "http://attacker.com"} + # Test if lambdas are enumerable + response = requests.get( + f"{config.internal_service_url()}/2015-03-31/functions/", headers=headers + ) + assert response.status_code == 403 + + # Test if config endpoint is reachable + config_body = {"variable": "harmful", "value": "config"} + + response = requests.post( + f"{config.internal_service_url()}/?_config_", headers=headers, json=config_body + ) + assert response.status_code == 403 + + # Test if endpoints are reachable without origin header + response = requests.get(f"{config.internal_service_url()}/2015-03-31/functions/") + assert response.status_code == 200 + + def test_default_cors_headers(self): + headers = {"Origin": "https://app.localstack.cloud"} + response = requests.get( + f"{config.internal_service_url()}/2015-03-31/functions/", headers=headers + ) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "https://app.localstack.cloud" + assert "GET" in response.headers["access-control-allow-methods"].split(",") + assert response.headers["Vary"] == "Origin" + assert response.headers["Access-Control-Allow-Credentials"] == "true" + + @pytest.mark.parametrize("path", ["/_localstack/health"]) + def test_internal_route_cors_headers(self, path): + headers = {"Origin": "https://app.localstack.cloud"} + response = requests.get(f"{config.internal_service_url()}{path}", headers=headers) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "https://app.localstack.cloud" + assert "GET" in response.headers["access-control-allow-methods"].split(",") + + def test_cors_s3_override(self, s3_bucket, monkeypatch, aws_client): + monkeypatch.setattr(config, "DISABLE_CUSTOM_CORS_S3", True) + + BUCKET_CORS_CONFIG = { + "CORSRules": [ + { + "AllowedOrigins": ["https://localhost:4200"], + "AllowedMethods": ["GET", "PUT"], + "MaxAgeSeconds": 3000, + "AllowedHeaders": ["*"], + } + ] + } + + aws_client.s3.put_bucket_cors(Bucket=s3_bucket, CORSConfiguration=BUCKET_CORS_CONFIG) + + # create signed url + url = aws_client.s3.generate_presigned_url( + ClientMethod="put_object", + Params={ + "Bucket": s3_bucket, + "Key": "424f6bae-c48f-42d8-9e25-52046aecc64d/document.pdf", + "ContentType": "application/pdf", + "ACL": "bucket-owner-full-control", + }, + ExpiresIn=3600, + ) + result = requests.put( + url, + data="something", + verify=False, + headers={ + "Origin": "https://localhost:4200", + "Content-Type": "application/pdf", + }, + ) + assert result.status_code == 403 + + def test_disable_cors_checks(self, monkeypatch): + """Test DISABLE_CORS_CHECKS=1 (most permissive setting)""" + headers = {"Origin": "https://invalid.localstack.cloud"} + url = f"{config.internal_service_url()}/2015-03-31/functions/" + response = requests.get(url, headers=headers) + assert response.status_code == 403 + + monkeypatch.setattr(config, "DISABLE_CORS_CHECKS", True) + response = requests.get(url, headers=headers) + assert response.status_code == 200 + # assert that because the invalid Origin was not in the AllowedOrigin, we set '*' + assert response.headers["access-control-allow-origin"] == "*" + assert "GET" in response.headers["access-control-allow-methods"].split(",") + # because the Allow-Origin is '*', we cannot allow credentials (the browser won't allow it) + assert "Access-Control-Allow-Credentials" not in response.headers + + def test_disable_cors_headers(self, monkeypatch): + """Test DISABLE_CORS_CHECKS=1 (most restrictive setting, not sending any CORS headers)""" + headers = mock_aws_request_headers( + "sns", aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, region_name=TEST_AWS_REGION_NAME + ) + headers["Origin"] = "https://app.localstack.cloud" + url = config.internal_service_url() + data = {"Action": "ListTopics", "Version": "2010-03-31"} + response = requests.post(url, headers=headers, data=data) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == headers["Origin"] + assert "authorization" in response.headers["access-control-allow-headers"].lower() + assert "GET" in response.headers["access-control-allow-methods"].split(",") + assert " 0 - assert response['Parameters'][0]['Name'] == 'test_put' - assert response['Parameters'][0]['Value'] == '1' diff --git a/tests/integration/test_stores.py b/tests/integration/test_stores.py new file mode 100644 index 0000000000000..e412c3e2d67e7 --- /dev/null +++ b/tests/integration/test_stores.py @@ -0,0 +1,22 @@ +from localstack.utils.strings import short_uid + + +def test_nonstandard_regions(monkeypatch, aws_client_factory): + """ + Ensure that non-standard AWS regions can be used vertically. + """ + monkeypatch.setenv("MOTO_ALLOW_NONEXISTENT_REGION", "true") + monkeypatch.setattr("localstack.config.ALLOW_NONSTANDARD_REGIONS", True) + + # Create a resource in Moto backend + ec2_client = aws_client_factory(region_name="uranus-south-1").ec2 + key_name = f"k-{short_uid()}" + ec2_client.create_key_pair(KeyName=key_name) + assert ec2_client.describe_key_pairs(KeyNames=[key_name]) + + # Create a resource in LocalStack store + sqs_client = aws_client_factory(region_name="pluto-central-2a").sqs + queue_name = f"q-{short_uid()}" + sqs_client.create_queue(QueueName=queue_name) + queue_url = sqs_client.get_queue_url(QueueName=queue_name)["QueueUrl"] + sqs_client.delete_queue(QueueUrl=queue_url) diff --git a/tests/integration/test_web_ui.py b/tests/integration/test_web_ui.py deleted file mode 100644 index 884779746662e..0000000000000 --- a/tests/integration/test_web_ui.py +++ /dev/null @@ -1,9 +0,0 @@ -from localstack.dashboard import infra - - -def test_infra_graph_generation(): - graph = infra.get_graph() - assert 'nodes' in graph - assert 'edges' in graph - - # TODO add more tests/assertions diff --git a/tests/integration/utils/__init__.py b/tests/integration/utils/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/integration/utils/test_diagnose.py b/tests/integration/utils/test_diagnose.py new file mode 100644 index 0000000000000..3162f5710c316 --- /dev/null +++ b/tests/integration/utils/test_diagnose.py @@ -0,0 +1,16 @@ +from localstack import config +from localstack.http import Request +from localstack.services.internal import DiagnoseResource + + +def test_diagnose_resource(): + # simple smoke test diagnose resource + resource = DiagnoseResource() + result = resource.on_get(Request(path="/_localstack/diagnose")) + + assert "/tmp" in result["file-tree"] + assert "/var/lib/localstack" in result["file-tree"] + assert result["config"]["DATA_DIR"] == config.DATA_DIR + assert result["config"]["GATEWAY_LISTEN"] == [config.HostAndPort("0.0.0.0", 4566)] + assert result["important-endpoints"]["localhost.localstack.cloud"].startswith("127.0.") + assert result["logs"]["docker"] diff --git a/tests/performance/test_dynamodb_performance.py b/tests/performance/test_dynamodb_performance.py new file mode 100644 index 0000000000000..a100ae20ec59b --- /dev/null +++ b/tests/performance/test_dynamodb_performance.py @@ -0,0 +1,45 @@ +import time + +import boto3 + +PORT_DYNAMODB = 4566 + + +def connect(): + return boto3.client("dynamodb", endpoint_url="http://localhost:%s" % PORT_DYNAMODB) + + +def create(): + client = connect() + client.create_table( + TableName="customers", + BillingMode="PAY_PER_REQUEST", + AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}], + KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], + ) + + +def insert(count): + client = connect() + start = time.time() + for i in range(count): + if i > 0 and i % 100 == 0: + delta = time.time() - start + print("%s sec for %s items = %s req/sec" % (delta, i, i / delta)) + client.put_item( + TableName="customers", + Item={ + "id": {"S": str(i)}, + "name": {"S": "Test name"}, + "zip_code": {"N": "12345"}, + }, + ) + + +def main(): + create() + insert(10000) + + +if __name__ == "__main__": + main() diff --git a/tests/performance/test_sqs_performance.py b/tests/performance/test_sqs_performance.py new file mode 100644 index 0000000000000..329b6ced480e1 --- /dev/null +++ b/tests/performance/test_sqs_performance.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from localstack.aws.connect import connect_externally_to +from localstack.testing.config import ( + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_REGION_NAME, + TEST_AWS_SECRET_ACCESS_KEY, +) +from localstack.utils.aws.arns import sqs_queue_url_for_arn + +QUEUE_NAME = "test-perf-3610" +NUM_MESSAGES = 300 + + +def print_duration(start, num_msgs, action): + if num_msgs % 100 != 0: + return + duration = datetime.now() - start + duration = duration.total_seconds() + req_sec = num_msgs / duration + print("%s %s messages in %s seconds (%s req/sec)" % (action, num_msgs, duration, req_sec)) + + +def send_messages(): + sqs = connect_externally_to( + region_name=TEST_AWS_REGION_NAME, + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=TEST_AWS_SECRET_ACCESS_KEY, + ).sqs + queue_url = sqs.create_queue(QueueName=QUEUE_NAME)["QueueUrl"] + + print("Starting to send %s messages" % NUM_MESSAGES) + start = datetime.now() + for i in range(1, NUM_MESSAGES + 1): + sqs.send_message(QueueUrl=queue_url, MessageBody="test123") + print_duration(start, i, action="Sent") + + +def receive_messages(): + sqs = connect_externally_to( + region_name=TEST_AWS_REGION_NAME, + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=TEST_AWS_SECRET_ACCESS_KEY, + ).sqs + queue_url = sqs_queue_url_for_arn(QUEUE_NAME) + messages = [] + + start = datetime.now() + while len(messages) < NUM_MESSAGES: + result = sqs.receive_message(QueueUrl=queue_url) + messages.extend(result.get("Messages") or []) + print_duration(start, len(messages), action="Received") + print("All %s messages received" % len(messages)) + + +def main(): + send_messages() + receive_messages() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index e69de29bb2d1d..3fecbff4cd95a 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +name = "unit" diff --git a/tests/unit/aws/__init__.py b/tests/unit/aws/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/aws/api/__init__.py b/tests/unit/aws/api/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/aws/api/test_asf_providers.py b/tests/unit/aws/api/test_asf_providers.py new file mode 100644 index 0000000000000..9d0f08b9f71e4 --- /dev/null +++ b/tests/unit/aws/api/test_asf_providers.py @@ -0,0 +1,14 @@ +import pytest + +from localstack.testing.aws.asf_utils import ( + check_provider_signature, + collect_implemented_provider_operations, +) + + +@pytest.mark.parametrize( + "sub_class,base_class,method_name", + collect_implemented_provider_operations(), +) +def test_provider_signatures(sub_class: type, base_class: type, method_name: str): + check_provider_signature(sub_class, base_class, method_name) diff --git a/tests/unit/aws/handlers/__init__.py b/tests/unit/aws/handlers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/aws/handlers/analytics.py b/tests/unit/aws/handlers/analytics.py new file mode 100644 index 0000000000000..26e52c02a26dc --- /dev/null +++ b/tests/unit/aws/handlers/analytics.py @@ -0,0 +1,123 @@ +from unittest.mock import MagicMock, call + +import pytest + +from localstack import config +from localstack.aws.api import RequestContext +from localstack.aws.chain import HandlerChain +from localstack.aws.forwarder import create_aws_request_context +from localstack.aws.handlers.analytics import ServiceRequestCounter +from localstack.http import Response +from localstack.utils.analytics.service_request_aggregator import ServiceRequestInfo + + +@pytest.fixture(autouse=True) +def enable_analytics(monkeypatch): + monkeypatch.setattr(config, "DISABLE_EVENTS", False) + + +class TestServiceRequestCounter: + def test_starts_aggregator_after_first_call(self): + aggregator = MagicMock() + + counter = ServiceRequestCounter(service_request_aggregator=aggregator) + aggregator.start.assert_not_called() + + context = create_aws_request_context("s3", "ListBuckets") + chain = HandlerChain([counter]) + chain.handle(context, Response()) + + aggregator.start.assert_called_once() + + context = create_aws_request_context("s3", "ListBuckets") + chain = HandlerChain([counter]) + chain.handle(context, Response()) + + aggregator.start.assert_called_once() + + def test_ignores_requests_without_service(self): + aggregator = MagicMock() + counter = ServiceRequestCounter(service_request_aggregator=aggregator) + + chain = HandlerChain([counter]) + chain.handle(RequestContext(None), Response()) + + aggregator.start.assert_not_called() + aggregator.add_request.assert_not_called() + + def test_ignores_requests_when_analytics_is_disabled(self, monkeypatch): + monkeypatch.setattr(config, "DISABLE_EVENTS", True) + + aggregator = MagicMock() + counter = ServiceRequestCounter(service_request_aggregator=aggregator) + + chain = HandlerChain([counter]) + chain.handle( + create_aws_request_context("s3", "ListBuckets"), + Response(), + ) + + aggregator.start.assert_not_called() + aggregator.add_request.assert_not_called() + + def test_calls_aggregator(self): + aggregator = MagicMock() + counter = ServiceRequestCounter(service_request_aggregator=aggregator) + + chain = HandlerChain([counter]) + chain.handle( + create_aws_request_context("s3", "ListBuckets"), + Response(), + ) + counter( + chain, + create_aws_request_context("s3", "HeadBucket", {"Bucket": "foobar"}), + Response(), + ) + + aggregator.add_request.assert_has_calls( + [ + call(ServiceRequestInfo("s3", "ListBuckets", 200)), + call(ServiceRequestInfo("s3", "HeadBucket", 200)), + ] + ) + + def test_parses_error_correctly(self): + aggregator = MagicMock() + counter = ServiceRequestCounter(service_request_aggregator=aggregator) + + chain = HandlerChain([counter]) + chain.handle( + create_aws_request_context("opensearch", "DescribeDomain", {"DomainName": "foobar"}), + Response( + b'{"__type": "ResourceNotFoundException", "message": "Domain not found: foobar"}', + 404, + ), + ) + + aggregator.add_request.assert_has_calls( + [ + call( + ServiceRequestInfo( + "opensearch", "DescribeDomain", 404, "ResourceNotFoundException" + ) + ), + ] + ) + + def test_invalid_error_behaves_like_botocore(self): + aggregator = MagicMock() + counter = ServiceRequestCounter(service_request_aggregator=aggregator) + + chain = HandlerChain([counter]) + chain.handle( + create_aws_request_context("opensearch", "DescribeDomain", {"DomainName": "foobar"}), + Response(b'{"__type": "ResourceN}', 404), + ) + + # for some reason botocore returns the status as the error Code when it parses an invalid error response + aggregator.add_request.assert_has_calls( + [ + call(ServiceRequestInfo("opensearch", "DescribeDomain", 404, "404")), + ] + ) diff --git a/tests/unit/aws/handlers/openapi.py b/tests/unit/aws/handlers/openapi.py new file mode 100644 index 0000000000000..fa4b78fe0d60f --- /dev/null +++ b/tests/unit/aws/handlers/openapi.py @@ -0,0 +1,178 @@ +import json + +import pytest +import yaml +from openapi_core import OpenAPI +from rolo import Request, Response +from rolo.gateway import RequestContext +from rolo.gateway.handlers import EmptyResponseHandler + +from localstack import config +from localstack.aws.chain import HandlerChain +from localstack.aws.handlers.validation import OpenAPIRequestValidator + +test_spec = """ +openapi: 3.0.0 +info: + title: Test API + version: 0.0.1 + description: Sample +paths: + /_localstack/dummy/{entityId}: + get: + parameters: + - name: entityId + in: path + required: true + schema: + type: number + example: 4 + responses: + '200': + description: Response list + content: + application/json: {} +""" + + +@pytest.fixture() +def openapi() -> OpenAPI: + spec = yaml.safe_load(test_spec) + return OpenAPI.from_dict(spec) + + +@pytest.fixture(autouse=True) +def enable_validation_flag(monkeypatch): + monkeypatch.setattr(config, "OPENAPI_VALIDATE_REQUEST", "1") + + +class TestOpenAPIRequestValidator: + def test_valid_request(self): + chain = HandlerChain([OpenAPIRequestValidator()]) + context = RequestContext( + Request( + path="/_localstack/diagnose", + method="GET", + scheme="http", + headers={"Host": "localhost.localstack.cloud:4566"}, + ) + ) + response = Response() + chain.handle(context=context, response=response) + assert response.status_code == 200 + + # make sure the request work with a different host value + context = RequestContext( + Request( + path="/_localstack/diagnose", + method="GET", + scheme="http", + headers={"Host": "localhost:4588"}, + ) + ) + response = Response() + chain.handle(context=context, response=response) + assert response.status_code == 200 + + def test_path_not_found(self): + chain = HandlerChain( + [ + OpenAPIRequestValidator(), + EmptyResponseHandler(404, b'{"message": "Not Found"}'), + ] + ) + context = RequestContext( + Request( + path="/_localstack/not_existing_endpoint", + method="GET", + scheme="http", + headers={"Host": "localhost.localstack.cloud:4566"}, + ) + ) + response = Response(status=0) + chain.handle(context=context, response=response) + # We leave this case to the last handler in the request handler chain. + assert response.status_code == 404 + assert response.data == b'{"message": "Not Found"}' + + def test_both_validation_and_server_error(self): + # Request with invalid host and body validation error + chain = HandlerChain([OpenAPIRequestValidator()]) + context = RequestContext( + Request( + path="/_localstack/config", + method="POST", + body=json.dumps({"variable": "", "value": "BAZ"}), + scheme="http", + headers={ + "Host": "unknown:4566", + "Content-Type": "application/json", + }, + ) + ) + response = Response() + chain.handle(context=context, response=response) + assert response.status_code == 400 + assert response.json["error"] == "Bad Request" + assert response.json["message"] == "Request body validation error" + + def test_body_validation_errors(self): + body = {"variable": "FOO", "value": "BAZ"} + chain = HandlerChain([OpenAPIRequestValidator()]) + request = Request( + path="/_localstack/config", + method="POST", + body=json.dumps(body), + scheme="http", + headers={"Host": "localhost.localstack.cloud:4566", "Content-Type": "application/json"}, + ) + context = RequestContext(request) + response = Response() + chain.handle(context=context, response=response) + assert response.status_code == 200 + + # Request without the content type + request.headers = {"Host": "localhost.localstack.cloud:4566"} + context = RequestContext(request) + response = Response() + chain.handle(context=context, response=response) + assert response.status_code == 400 + assert response.json["error"] == "Bad Request" + assert response.json["message"] == "Request body validation error" + + # Request with invalid body + context = RequestContext( + Request( + path="/_localstack/config", + method="POST", + body=json.dumps({"variable": "", "value": "BAZ"}), + scheme="http", + headers={ + "Host": "localhost.localstack.cloud:4566", + "Content-Type": "application/json", + }, + ) + ) + response = Response() + chain.handle(context=context, response=response) + assert response.status_code == 400 + assert response.json["error"] == "Bad Request" + assert response.json["message"] == "Request body validation error" + + def test_multiple_specs(self, openapi): + validator = OpenAPIRequestValidator() + validator.open_apis.append(openapi) + chain = HandlerChain([validator]) + context = RequestContext( + Request( + path="/_localstack/dummy/dummyName", + method="GET", + scheme="http", + headers={"Host": "localhost.localstack.cloud:4566"}, + ) + ) + response = Response() + chain.handle(context=context, response=response) + assert response.status_code == 400 + assert response.json["error"] == "Bad Request" + assert "Path parameter error" in response.json["message"] diff --git a/tests/unit/aws/handlers/response_enrichment.py b/tests/unit/aws/handlers/response_enrichment.py new file mode 100644 index 0000000000000..4f5f3d7e8ab6d --- /dev/null +++ b/tests/unit/aws/handlers/response_enrichment.py @@ -0,0 +1,32 @@ +import pytest + +from localstack.aws.chain import HandlerChain +from localstack.aws.forwarder import create_aws_request_context +from localstack.aws.handlers.response import ResponseMetadataEnricher +from localstack.constants import HEADER_LOCALSTACK_IDENTIFIER +from localstack.http import Response + + +@pytest.fixture +def response_handler_chain() -> HandlerChain: + return HandlerChain(response_handlers=[ResponseMetadataEnricher()]) + + +class TestResponseMetadataEnricher: + def test_adds_header_to_successful_response(self, response_handler_chain): + context = create_aws_request_context("s3", "ListBuckets") + response = Response("success", 200) + + response_handler_chain.handle(context, response) + + assert response.headers[HEADER_LOCALSTACK_IDENTIFIER] == "true" + + def test_adds_header_to_error_response(self, response_handler_chain): + context = create_aws_request_context( + "opensearch", "DescribeDomain", {"DomainName": "foobar"} + ) + response = Response(b'{"__type": "ResourceNotFoundException"}', 409) + + response_handler_chain.handle(context, response) + + assert response.headers[HEADER_LOCALSTACK_IDENTIFIER] == "true" diff --git a/tests/unit/aws/handlers/service.py b/tests/unit/aws/handlers/service.py new file mode 100644 index 0000000000000..c6f039a29e5cd --- /dev/null +++ b/tests/unit/aws/handlers/service.py @@ -0,0 +1,145 @@ +import pytest + +from localstack.aws.api import CommonServiceException, RequestContext +from localstack.aws.chain import HandlerChain +from localstack.aws.forwarder import create_aws_request_context +from localstack.aws.handlers.service import ServiceExceptionSerializer, ServiceResponseParser +from localstack.aws.protocol.serializer import create_serializer +from localstack.http import Request, Response + + +@pytest.fixture +def service_response_handler_chain() -> HandlerChain: + """Returns a dummy chain for testing.""" + return HandlerChain(response_handlers=[ServiceResponseParser()]) + + +class TestServiceResponseHandler: + def test_use_set_response(self, service_response_handler_chain): + context = create_aws_request_context("opensearch", "CreateDomain", {"DomainName": "foobar"}) + context.service_response = {"sure": "why not"} + + service_response_handler_chain.handle(context, Response(status=200)) + assert context.service_response == {"sure": "why not"} + + def test_parse_response(self, service_response_handler_chain): + context = create_aws_request_context("sqs", "CreateQueue", {"QueueName": "foobar"}) + backend_response = {"QueueUrl": "http://localhost:4566/000000000000/foobar"} + http_response = create_serializer(context.service).serialize_to_response( + backend_response, context.operation, context.request.headers, context.request_id + ) + + service_response_handler_chain.handle(context, http_response) + assert context.service_response == backend_response + + def test_parse_response_with_streaming_response(self, service_response_handler_chain): + context = create_aws_request_context("s3", "GetObject", {"Bucket": "foo", "Key": "bar.bin"}) + backend_response = {"Body": b"\x00\x01foo", "ContentType": "application/octet-stream"} + http_response = create_serializer(context.service).serialize_to_response( + backend_response, context.operation, context.request.headers, context.request_id + ) + + service_response_handler_chain.handle(context, http_response) + assert context.service_response["ContentLength"] == 5 + assert context.service_response["ContentType"] == "application/octet-stream" + assert context.service_response["Body"].read() == b"\x00\x01foo" + + def test_common_service_exception(self, service_response_handler_chain): + context = create_aws_request_context("opensearch", "CreateDomain", {"DomainName": "foobar"}) + context.service_exception = CommonServiceException( + "MyCommonException", "oh noes", status_code=409, sender_fault=True + ) + + service_response_handler_chain.handle(context, Response(status=409)) + assert context.service_exception.message == "oh noes" + assert context.service_exception.code == "MyCommonException" + assert context.service_exception.sender_fault + assert context.service_exception.status_code == 409 + + def test_service_exception(self, service_response_handler_chain): + from localstack.aws.api.opensearch import ResourceAlreadyExistsException + + context = create_aws_request_context("opensearch", "CreateDomain", {"DomainName": "foobar"}) + context.service_exception = ResourceAlreadyExistsException("oh noes") + + response = create_serializer(context.service).serialize_error_to_response( + context.service_exception, context.operation, context.request.headers + ) + + service_response_handler_chain.handle(context, response) + assert context.service_exception.message == "oh noes" + assert context.service_exception.code == "ResourceAlreadyExistsException" + assert not context.service_exception.sender_fault + assert context.service_exception.status_code == 409 + + def test_service_exception_with_code_from_spec(self, service_response_handler_chain): + from localstack.aws.api.sqs import QueueDoesNotExist + + context = create_aws_request_context( + "sqs", + "SendMessage", + {"QueueUrl": "http://localhost:4566/000000000000/foobared", "MessageBody": "foo"}, + ) + context.service_exception = QueueDoesNotExist() + + response = create_serializer(context.service).serialize_error_to_response( + context.service_exception, context.operation, context.request.headers + ) + + service_response_handler_chain.handle(context, response) + + assert context.service_exception.message == "" + assert context.service_exception.code == "AWS.SimpleQueueService.NonExistentQueue" + assert context.service_exception.sender_fault + assert context.service_exception.status_code == 400 + + def test_sets_exception_from_error_response(self, service_response_handler_chain): + context = create_aws_request_context( + "opensearch", "DescribeDomain", {"DomainName": "foobar"} + ) + response = Response( + b'{"__type": "ResourceNotFoundException", "message": "Domain not found: foobar"}', + 409, + ) + service_response_handler_chain.handle(context, response) + + assert context.service_exception.message == "Domain not found: foobar" + assert context.service_exception.code == "ResourceNotFoundException" + assert not context.service_exception.sender_fault + assert context.service_exception.status_code == 409 + + assert context.service_response is None + + def test_nothing_set_does_nothing(self, service_response_handler_chain): + context = RequestContext(request=Request("GET", "/_localstack/health")) + + service_response_handler_chain.handle(context, Response("ok", 200)) + + assert context.service_exception is None + assert context.service_response is None + + def test_invalid_exception_does_nothing(self, service_response_handler_chain): + context = create_aws_request_context( + "opensearch", "DescribeDomain", {"DomainName": "foobar"} + ) + context.service_exception = ValueError() + service_response_handler_chain.handle(context, Response(status=500)) + + assert context.service_response is None + assert isinstance(context.service_exception, ValueError) + + @pytest.mark.parametrize( + "message, output", [("", "not yet implemented or pro feature"), ("Ups!", "Ups!")] + ) + def test_not_implemented_error(self, service_response_handler_chain, message, output): + context = create_aws_request_context( + "opensearch", "DescribeDomain", {"DomainName": "foobar"} + ) + not_implemented_exception = NotImplementedError(message) + + ServiceExceptionSerializer().create_exception_response(not_implemented_exception, context) + + assert output in context.service_exception.message + assert context.service_exception.code == "InternalFailure" + assert not context.service_exception.sender_fault + assert context.service_exception.status_code == 501 diff --git a/tests/unit/aws/protocol/__init__.py b/tests/unit/aws/protocol/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/aws/protocol/test_op_router.py b/tests/unit/aws/protocol/test_op_router.py new file mode 100644 index 0000000000000..e42db6fcc512c --- /dev/null +++ b/tests/unit/aws/protocol/test_op_router.py @@ -0,0 +1,144 @@ +import pytest +from werkzeug.exceptions import NotFound +from werkzeug.routing import Map, Rule + +from localstack.aws.protocol.op_router import RestServiceOperationRouter +from localstack.aws.spec import list_services, load_service +from localstack.http import Request +from localstack.http.router import GreedyPathConverter + + +def _collect_services(): + for service in list_services(): + if service.protocol.startswith("rest"): + yield service.service_name + + +@pytest.mark.parametrize( + "service", + _collect_services(), +) +@pytest.mark.param +def test_create_op_router_works_for_every_service(service): + router = RestServiceOperationRouter(load_service(service)) + + try: + router.match(Request("GET", "/")) + except NotFound: + pass + + +def test_greedy_path_converter(): + # this test is mostly to document behavior + + router = Map(converters={"path": GreedyPathConverter}, merge_slashes=False) + + router.add(Rule("/test-bucket/")) + router.add(Rule("/some-route//bar")) + + matcher = router.bind("") + # open-ended case + assert matcher.match("/test-bucket//foo/bar") == (None, {"p": "/foo/bar"}) + assert matcher.match("/test-bucket//foo//bar") == (None, {"p": "/foo//bar"}) + assert matcher.match("/test-bucket//foo/bar/") == (None, {"p": "/foo/bar/"}) + + # with a matching suffix + assert matcher.match("/some-route//foo/bar") == (None, {"p": "/foo"}) + assert matcher.match("/some-route//foo//bar") == (None, {"p": "/foo/"}) + assert matcher.match("/some-route//foo/bar/bar") == (None, {"p": "/foo/bar"}) + with pytest.raises(NotFound): + matcher.match("/some-route//foo/baz") + + +def test_s3_head_request(): + router = RestServiceOperationRouter(load_service("s3")) + + op, _ = router.match(Request("GET", "/my-bucket/my-key/")) + assert op.name == "GetObject" + + op, _ = router.match(Request("HEAD", "/my-bucket/my-key/")) + assert op.name == "HeadObject" + + +def test_basic_param_extraction(): + router = RestServiceOperationRouter(load_service("apigateway")) + + op, params = router.match(Request("POST", "/restapis/myrestapi/deployments")) + assert op.name == "CreateDeployment" + assert params == {"restapi_id": "myrestapi"} + + with pytest.raises(NotFound): + # note: this is to document the behavior of werkzeug, not necessarily what we want for the op router. + # the behavior of double slashes in parameters probably cannot be generalized and will be handled by + # services case-by-case + router.match(Request("POST", "/restapis/myrestapi//deployments")) + + +def test_trailing_slashes_are_not_strict(): + # this is tested against AWS. AWS is not strict about trailing slashes when routing operations. + + router = RestServiceOperationRouter(load_service("lambda")) + + op, _ = router.match(Request("GET", "/2015-03-31/functions")) + assert op.name == "ListFunctions" + + op, _ = router.match(Request("GET", "/2015-03-31/functions/")) + assert op.name == "ListFunctions" + + op, _ = router.match(Request("POST", "/2015-03-31/functions")) + assert op.name == "CreateFunction" + + op, _ = router.match(Request("POST", "/2015-03-31/functions/")) + assert op.name == "CreateFunction" + + +def test_s3_query_args_routing(): + router = RestServiceOperationRouter(load_service("s3")) + + op, params = router.match(Request("DELETE", "/mybucket?delete")) + assert op.name == "DeleteBucket" + assert params == {"Bucket": "mybucket"} + + op, params = router.match(Request("DELETE", "/mybucket/?delete")) + assert op.name == "DeleteBucket" + assert params == {"Bucket": "mybucket"} + + op, params = router.match(Request("DELETE", "/mybucket/mykey?delete")) + assert op.name == "DeleteObject" + assert params == {"Bucket": "mybucket", "Key": "mykey"} + + op, params = router.match(Request("DELETE", "/mybucket/mykey/?delete")) + assert op.name == "DeleteObject" + assert params == {"Bucket": "mybucket", "Key": "mykey"} + + +def test_s3_bucket_operation_with_trailing_slashes(): + router = RestServiceOperationRouter(load_service("s3")) + + op, params = router.match(Request("GET", "/mybucket")) + assert op.name == "ListObjects" + assert params == {"Bucket": "mybucket"} + + op, params = router.match(Request("Get", "/mybucket/")) + assert op.name == "ListObjects" + assert params == {"Bucket": "mybucket"} + + +def test_s3_object_operation_with_trailing_slashes(): + router = RestServiceOperationRouter(load_service("s3")) + + op, params = router.match(Request("GET", "/mybucket/mykey")) + assert op.name == "GetObject" + assert params == {"Bucket": "mybucket", "Key": "mykey"} + + op, params = router.match(Request("GET", "/mybucket/mykey/")) + assert op.name == "GetObject" + assert params == {"Bucket": "mybucket", "Key": "mykey"} + + +def test_s3_bucket_operation_with_double_slashes(): + router = RestServiceOperationRouter(load_service("s3")) + + op, params = router.match(Request("GET", "/mybucket//mykey")) + assert op.name == "GetObject" + assert params == {"Bucket": "mybucket", "Key": "/mykey"} diff --git a/tests/unit/aws/protocol/test_parser.py b/tests/unit/aws/protocol/test_parser.py new file mode 100644 index 0000000000000..f647daed9d4be --- /dev/null +++ b/tests/unit/aws/protocol/test_parser.py @@ -0,0 +1,1411 @@ +from datetime import datetime, timezone +from io import BytesIO +from urllib.parse import unquote, urlencode, urlsplit + +import pytest +from botocore.awsrequest import prepare_request_dict +from botocore.serialize import create_serializer + +from localstack.aws.protocol.parser import ( + OperationNotFoundParserError, + ProtocolParserError, + QueryRequestParser, + RestJSONRequestParser, + UnknownParserError, + create_parser, +) +from localstack.aws.spec import load_service +from localstack.http import Request as HttpRequest +from localstack.utils.common import to_bytes, to_str + + +def test_query_parser(): + """Basic test for the QueryParser with a simple example (SQS SendMessage request).""" + parser = QueryRequestParser(load_service("sqs")) + request = HttpRequest( + body=to_bytes( + "Action=SendMessage&Version=2012-11-05&" + "QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&" + "MessageBody=%7B%22foo%22%3A+%22bared%22%7D&" + "DelaySeconds=2" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + operation, params = parser.parse(request) + assert operation.name == "SendMessage" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/tf-acc-test-queue", + "MessageBody": '{"foo": "bared"}', + "DelaySeconds": 2, + } + + +def test_sqs_query_parse_tag_map_with_member_name_as_location(): + # see https://github.com/localstack/localstack/issues/4391 + parser = create_parser(load_service("sqs-query")) + + # with "Tag." it works (this is the default request) + request = HttpRequest( + "POST", + "/", + body="Action=TagQueue&" + "Version=2012-11-05&" + "QueueUrl=http://localhost:4566/000000000000/foobar&" + "Tag.1.Key=returnly%3Aenv&" + "Tag.1.Value=local&" + "Tag.2.Key=returnly%3Acreator&" + "Tag.2.Value=rma-api-svc", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + operation, params = parser.parse(request) + assert operation.name == "TagQueue" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/foobar", + "Tags": {"returnly:creator": "rma-api-svc", "returnly:env": "local"}, + } + + # apparently this is how the Java AWS SDK generates the TagQueue request, see see + # https://github.com/localstack/localstack/issues/4391 + request = HttpRequest( + "POST", + "/", + body="Action=TagQueue&" + "Version=2012-11-05&" + "QueueUrl=http://localhost:4566/000000000000/foobar&" + "Tags.1.Key=returnly%3Aenv&" + "Tags.1.Value=local&" + "Tags.2.Key=returnly%3Acreator&" + "Tags.2.Value=rma-api-svc", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + operation, params = parser.parse(request) + assert operation.name == "TagQueue" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/foobar", + "Tags": {"returnly:creator": "rma-api-svc", "returnly:env": "local"}, + } + + +def test_sqs_query_parse_map_with_nested_dict(): + # see https://github.com/localstack/localstack/issues/10949 + parser = create_parser(load_service("sqs-query")) + + # with "MessageAttribute." it works (this is the default request) + request = HttpRequest( + "POST", + "/", + body="Action=SendMessage&" + "MessageBody=foobar&" + "QueueUrl=http://localhost:4566/000000000000/foobar&" + "MessageAttribute.1.Name=Foo&" + "MessageAttribute.1.Value.DataType=String&" + "MessageAttribute.1.Value.StringValue=Bar", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + operation, params = parser.parse(request) + assert operation.name == "SendMessage" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/foobar", + "MessageBody": "foobar", + "MessageAttributes": {"Foo": {"DataType": "String", "StringValue": "Bar"}}, + } + + # Aws also accepts MessageAttributes. Most likely related to issue + # https://github.com/localstack/localstack/issues/4391 + request = HttpRequest( + "POST", + "/", + body="Action=SendMessage&" + "MessageBody=foobar&" + "QueueUrl=http://localhost:4566/000000000000/foobar&" + "MessageAttributes.1.Name=Foo&" + "MessageAttributes.1.Value.DataType=String&" + "MessageAttributes.1.Value.StringValue=Bar", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + operation, params = parser.parse(request) + assert operation.name == "SendMessage" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/foobar", + "MessageAttributes": {"Foo": {"DataType": "String", "StringValue": "Bar"}}, + "MessageBody": "foobar", + } + + +def test_query_parser_uri(): + """ + Basic test for the QueryParser with a simple example (SQS SendMessage request), + where the parameters are encoded in the URI instead of the body. + """ + parser = QueryRequestParser(load_service("sqs")) + request = HttpRequest( + query_string="Action=SendMessage&Version=2012-11-05&" + "QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&" + "MessageBody=%7B%22foo%22%3A+%22bared%22%7D&" + "DelaySeconds=2", + method="POST", + path="", + ) + operation, params = parser.parse(request) + assert operation.name == "SendMessage" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/tf-acc-test-queue", + "MessageBody": '{"foo": "bared"}', + "DelaySeconds": 2, + } + + +def test_query_parser_flattened_map(): + """Simple test with a flattened map (SQS SetQueueAttributes request).""" + parser = QueryRequestParser(load_service("sqs-query")) + request = HttpRequest( + body=to_bytes( + "Action=SetQueueAttributes&Version=2012-11-05&" + "QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&" + "Attribute.1.Name=DelaySeconds&" + "Attribute.1.Value=10&" + "Attribute.2.Name=MaximumMessageSize&" + "Attribute.2.Value=131072&" + "Attribute.3.Name=MessageRetentionPeriod&" + "Attribute.3.Value=259200&" + "Attribute.4.Name=ReceiveMessageWaitTimeSeconds&" + "Attribute.4.Value=20&" + "Attribute.5.Name=RedrivePolicy&" + "Attribute.5.Value=%7B%22deadLetterTargetArn%22%3A%22arn%3Aaws%3Asqs%3Aus-east-1%3A80398EXAMPLE%3AMyDeadLetterQueue%22%2C%22maxReceiveCount%22%3A%221000%22%7D&" + "Attribute.6.Name=VisibilityTimeout&Attribute.6.Value=60" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + operation, params = parser.parse(request) + assert operation.name == "SetQueueAttributes" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/tf-acc-test-queue", + "Attributes": { + "DelaySeconds": "10", + "MaximumMessageSize": "131072", + "MessageRetentionPeriod": "259200", + "ReceiveMessageWaitTimeSeconds": "20", + "RedrivePolicy": '{"deadLetterTargetArn":"arn:aws:sqs:us-east-1:80398EXAMPLE:MyDeadLetterQueue","maxReceiveCount":"1000"}', + "VisibilityTimeout": "60", + }, + } + + +def test_query_parser_non_flattened_map(): + """Simple test with a flattened map (SQS SetQueueAttributes request).""" + parser = QueryRequestParser(load_service("sns")) + request = HttpRequest( + body=to_bytes( + "Action=SetEndpointAttributes&" + "EndpointArn=arn%3Aaws%3Asns%3Aus-west-2%3A123456789012%3Aendpoint%2FGCM%2Fgcmpushapp%2F5e3e9847-3183-3f18-a7e8-671c3a57d4b3&" + "Attributes.entry.1.key=CustomUserData&" + "Attributes.entry.1.value=My+custom+userdata&" + "Version=2010-03-31&" + "AUTHPARAMS" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + operation, params = parser.parse(request) + assert operation.name == "SetEndpointAttributes" + assert params == { + "Attributes": {"CustomUserData": "My custom userdata"}, + "EndpointArn": "arn:aws:sns:us-west-2:123456789012:endpoint/GCM/gcmpushapp/5e3e9847-3183-3f18-a7e8-671c3a57d4b3", + } + + +def test_query_parser_non_flattened_list_structure(): + """Simple test with a non-flattened list structure (CloudFormation CreateChangeSet).""" + parser = QueryRequestParser(load_service("cloudformation")) + request = HttpRequest( + body=to_bytes( + "Action=CreateChangeSet&" + "ChangeSetName=SampleChangeSet&" + "Parameters.member.1.ParameterKey=KeyName&" + "Parameters.member.1.UsePreviousValue=true&" + "Parameters.member.2.ParameterKey=Purpose&" + "Parameters.member.2.ParameterValue=production&" + "StackName=arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/1a2345b6-0000-00a0-a123-00abc0abc000&" + "UsePreviousTemplate=true&" + "Version=2010-05-15&" + "X-Amz-Algorithm=AWS4-HMAC-SHA256&" + "X-Amz-Credential=[Access-key-ID-and-scope]&" + "X-Amz-Date=20160316T233349Z&" + "X-Amz-SignedHeaders=content-type;host&" + "X-Amz-Signature=[Signature]" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + operation, params = parser.parse(request) + assert operation.name == "CreateChangeSet" + assert params == { + "StackName": "arn:aws:cloudformation:us-east-1:123456789012:stack/SampleStack/1a2345b6-0000-00a0-a123-00abc0abc000", + "UsePreviousTemplate": True, + "Parameters": [ + {"ParameterKey": "KeyName", "UsePreviousValue": True}, + {"ParameterKey": "Purpose", "ParameterValue": "production"}, + ], + "ChangeSetName": "SampleChangeSet", + } + + +def test_query_parser_non_flattened_list_structure_changed_name(): + """Simple test with a non-flattened list structure where the name of the list differs from the shape's name + (CloudWatch PutMetricData).""" + parser = QueryRequestParser(load_service("cloudwatch")) + request = HttpRequest( + body=to_bytes( + "Action=PutMetricData&" + "Version=2010-08-01&" + "Namespace=TestNamespace&" + "MetricData.member.1.MetricName=buffers&" + "MetricData.member.1.Unit=Bytes&" + "MetricData.member.1.Value=231434333&" + "MetricData.member.1.Dimensions.member.1.Name=InstanceType&" + "MetricData.member.1.Dimensions.member.1.Value=m1.small&" + "AUTHPARAMS" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + operation, params = parser.parse(request) + assert operation.name == "PutMetricData" + assert params == { + "MetricData": [ + { + "Dimensions": [{"Name": "InstanceType", "Value": "m1.small"}], + "MetricName": "buffers", + "Unit": "Bytes", + "Value": 231434333.0, + } + ], + "Namespace": "TestNamespace", + } + + +def test_query_parser_flattened_list_structure(): + """Simple test with a flattened list of structures.""" + parser = QueryRequestParser(load_service("sqs-query")) + request = HttpRequest( + body=to_bytes( + "Action=DeleteMessageBatch&" + "Version=2012-11-05&" + "QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&" + "DeleteMessageBatchRequestEntry.1.Id=bar&" + "DeleteMessageBatchRequestEntry.1.ReceiptHandle=foo&" + "DeleteMessageBatchRequestEntry.2.Id=bar&" + "DeleteMessageBatchRequestEntry.2.ReceiptHandle=foo" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + operation, params = parser.parse(request) + assert operation.name == "DeleteMessageBatch" + assert params == { + "QueueUrl": "http://localhost:4566/000000000000/tf-acc-test-queue", + "Entries": [{"Id": "bar", "ReceiptHandle": "foo"}, {"Id": "bar", "ReceiptHandle": "foo"}], + } + + +def test_query_parser_pass_str_as_int_raises_error(): + """Test to make sure that invalid types correctly raise a ProtocolParserError.""" + parser = QueryRequestParser(load_service("sts")) + request = HttpRequest( + body=to_bytes( + "Action=AssumeRole&" + "RoleArn=arn:aws:iam::000000000000:role/foobared&" + "RoleSessionName=foobared&" + "DurationSeconds=abcd" # illegal argument (should be an int) + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + with pytest.raises(ProtocolParserError): + parser.parse(request) + + +def _botocore_parser_integration_test( + service: str, action: str, headers: dict = None, expected: dict = None, **kwargs +): + # Load the appropriate service + service = load_service(service) + # Use the serializer from botocore to serialize the request params + serializer = create_serializer(service.protocol) + + operation_model = service.operation_model(action) + serialized_request = serializer.serialize_to_request(kwargs, operation_model) + + # botocore >= 1.28 might modify the url path of the request dict (specifically for S3). + # It will then set the original url path as "auth_path". If the auth_path is set, we reset the url_path. + # Since botocore 1.31.2, botocore will strip the query from the `authPart` + # We need to add it back from `requestUri` field + if auth_path := serialized_request.get("auth_path"): + path, sep, query = serialized_request["url_path"].partition("?") + serialized_request["url_path"] = f"{auth_path}{sep}{query}" + + prepare_request_dict(serialized_request, "") + split_url = urlsplit(serialized_request.get("url")) + path = split_url.path + query_string = split_url.query + body = serialized_request["body"] + # use custom headers (if provided), or headers from serialized request as default + headers = serialized_request.get("headers") if headers is None else headers + + if service.protocol in ["query", "ec2"]: + # Serialize the body as query parameter + body = urlencode(serialized_request["body"]) + + # Use our parser to parse the serialized body + parser = create_parser(service) + parsed_operation_model, parsed_request = parser.parse( + HttpRequest( + method=serialized_request.get("method") or "GET", + path=unquote(path), + query_string=to_str(query_string), + headers=headers, + body=body, + raw_path=path, + ) + ) + + # Check if the determined operation_model is correct + assert parsed_operation_model == operation_model + + # Check if the result is equal to the given "expected" dict or the kwargs (if "expected" has not been set) + expected = expected or kwargs + # The parser adds None for none-existing members on purpose. Remove those for the assert + expected = {key: value for key, value in expected.items() if value is not None} + parsed_request = {key: value for key, value in parsed_request.items() if value is not None} + assert parsed_request == expected + + +def test_query_parser_sqs_with_botocore(): + _botocore_parser_integration_test( + service="sqs", + action="SendMessage", + QueueUrl="string", + MessageBody="string", + DelaySeconds=123, + MessageAttributes={ + "string": { + "StringValue": "string", + "BinaryValue": b"bytes", + "StringListValues": [ + "string", + ], + "BinaryListValues": [ + b"bytes", + ], + "DataType": "string", + } + }, + MessageSystemAttributes={ + "string": { + "StringValue": "string", + "BinaryValue": b"bytes", + "StringListValues": [ + "string", + ], + "BinaryListValues": [ + b"bytes", + ], + "DataType": "string", + } + }, + MessageDeduplicationId="string", + MessageGroupId="string", + ) + + +def test_query_parser_empty_required_members_sqs_with_botocore(): + _botocore_parser_integration_test( + service="sqs-query", + action="SendMessageBatch", + QueueUrl="string", + Entries=[], + expected={"QueueUrl": "string"}, + ) + + +def test_query_parser_no_input_shape_autoscaling_with_botocore(): + _botocore_parser_integration_test( + service="autoscaling", + action="DescribeMetricCollectionTypes", + ) + + +def test_query_parser_iot_with_botocore(): + """Test if timestamp for 'rest-json' is parsed correctly""" + start = datetime(2023, 1, 10, tzinfo=timezone.utc) + end = datetime(2023, 1, 11, tzinfo=timezone.utc) + _botocore_parser_integration_test( + service="iot", + action="ListAuditMitigationActionsTasks", + endTime=end, + startTime=start, + expected={ + "endTime": end, + "startTime": start, + }, + ) + + +def test_query_parser_cloudformation_with_botocore(): + _botocore_parser_integration_test( + service="cloudformation", + action="CreateStack", + StackName="string", + TemplateBody="string", + TemplateURL="string", + Parameters=[ + { + "ParameterKey": "string", + "ParameterValue": "string", + "UsePreviousValue": True, + "ResolvedValue": "string", + }, + ], + DisableRollback=False, + RollbackConfiguration={ + "RollbackTriggers": [ + {"Arn": "string", "Type": "string"}, + ], + "MonitoringTimeInMinutes": 123, + }, + TimeoutInMinutes=123, + NotificationARNs=[ + "string", + ], + Capabilities=[ + "CAPABILITY_IAM", + ], + ResourceTypes=[ + "string", + ], + RoleARN="12345678901234567890", + OnFailure="DO_NOTHING", + StackPolicyBody="string", + StackPolicyURL="string", + Tags=[ + {"Key": "string", "Value": "string"}, + ], + ClientRequestToken="string", + EnableTerminationProtection=False, + ) + + +def test_query_parser_unflattened_list_of_maps(): + _botocore_parser_integration_test( + service="rds", + action="CreateDBCluster", + DBClusterIdentifier="mydbcluster", + Engine="aurora", + Tags=[{"Key": "Hello", "Value": "There"}, {"Key": "Hello1", "Value": "There1"}], + ) + + +def test_restxml_parser_route53_with_botocore(): + _botocore_parser_integration_test( + service="route53", + action="CreateHostedZone", + Name="string", + VPC={"VPCRegion": "us-east-1", "VPCId": "string"}, + CallerReference="string", + HostedZoneConfig={"Comment": "string", "PrivateZone": True}, + DelegationSetId="string", + ) + + +def test_json_parser_cognito_with_botocore(): + _botocore_parser_integration_test( + service="cognito-idp", + action="CreateUserPool", + headers={"X-Amz-Target": "AWSCognitoIdentityProviderService.CreateUserPool"}, + PoolName="string", + Policies={ + "PasswordPolicy": { + "MinimumLength": 123, + "RequireUppercase": True, + "RequireLowercase": True, + "RequireNumbers": True, + "RequireSymbols": True, + "TemporaryPasswordValidityDays": 123, + } + }, + LambdaConfig={ + "PreSignUp": "12345678901234567890", + "CustomMessage": "12345678901234567890", + "PostConfirmation": "12345678901234567890", + "PreAuthentication": "12345678901234567890", + "PostAuthentication": "12345678901234567890", + "DefineAuthChallenge": "12345678901234567890", + "CreateAuthChallenge": "12345678901234567890", + "VerifyAuthChallengeResponse": "12345678901234567890", + "PreTokenGeneration": "12345678901234567890", + "UserMigration": "12345678901234567890", + "CustomSMSSender": {"LambdaVersion": "V1_0", "LambdaArn": "12345678901234567890"}, + "CustomEmailSender": {"LambdaVersion": "V1_0", "LambdaArn": "12345678901234567890"}, + "KMSKeyID": "12345678901234567890", + }, + AutoVerifiedAttributes=[ + "phone_number", + ], + AliasAttributes=[ + "phone_number", + ], + UsernameAttributes=[ + "phone_number", + ], + SmsVerificationMessage="string", + EmailVerificationMessage="string", + EmailVerificationSubject="string", + VerificationMessageTemplate={ + "SmsMessage": "string", + "EmailMessage": "string", + "EmailSubject": "string", + "EmailMessageByLink": "string", + "EmailSubjectByLink": "string", + "DefaultEmailOption": "CONFIRM_WITH_LINK", + }, + SmsAuthenticationMessage="string", + MfaConfiguration="OFF", + DeviceConfiguration={ + "ChallengeRequiredOnNewDevice": True, + "DeviceOnlyRememberedOnUserPrompt": True, + }, + EmailConfiguration={ + "SourceArn": "12345678901234567890", + "ReplyToEmailAddress": "string", + "EmailSendingAccount": "COGNITO_DEFAULT", + "From": "string", + "ConfigurationSet": "string", + }, + SmsConfiguration={"SnsCallerArn": "12345678901234567890", "ExternalId": "string"}, + UserPoolTags={"string": "string"}, + AdminCreateUserConfig={ + "AllowAdminCreateUserOnly": True, + "UnusedAccountValidityDays": 123, + "InviteMessageTemplate": { + "SMSMessage": "string", + "EmailMessage": "string", + "EmailSubject": "string", + }, + }, + Schema=[ + { + "Name": "string", + "AttributeDataType": "String", + "DeveloperOnlyAttribute": True, + "Mutable": True, + "Required": True, + "NumberAttributeConstraints": {"MinValue": "string", "MaxValue": "string"}, + "StringAttributeConstraints": {"MinLength": "string", "MaxLength": "string"}, + }, + ], + UserPoolAddOns={"AdvancedSecurityMode": "OFF"}, + UsernameConfiguration={"CaseSensitive": True}, + AccountRecoverySetting={ + "RecoveryMechanisms": [ + {"Priority": 123, "Name": "verified_email"}, + ] + }, + ) + + +def test_json_cbor_blob_parsing(): + serialized_request = { + "url_path": "/", + "query_string": "", + "method": "POST", + "headers": { + "Host": "localhost:4566", + "amz-sdk-invocation-id": "d77968c6-b536-155d-7228-d4dfe6372154", + "amz-sdk-request": "attempt=1; max=3", + "Content-Length": "103", + "Content-Type": "application/x-amz-cbor-1.1", + "X-Amz-Date": "20220721T081553Z", + "X-Amz-Target": "Kinesis_20131202.PutRecord", + "x-localstack-tgt-api": "kinesis", + }, + "body": b"\xbfjStreamNamedtestdDataMhello, world!lPartitionKeylpartitionkey\xff", + "url": "/", + "context": {}, + } + + prepare_request_dict(serialized_request, "") + split_url = urlsplit(serialized_request.get("url")) + path = split_url.path + query_string = split_url.query + + # Use our parser to parse the serialized body + # Load the appropriate service + service = load_service("kinesis") + operation_model = service.operation_model("PutRecord") + parser = create_parser(service) + parsed_operation_model, parsed_request = parser.parse( + HttpRequest( + method=serialized_request.get("method") or "GET", + path=unquote(path), + query_string=to_str(query_string), + headers=serialized_request.get("headers"), + body=serialized_request["body"], + raw_path=path, + ) + ) + + # Check if the determined operation_model is correct + assert parsed_operation_model == operation_model + + assert "Data" in parsed_request + assert parsed_request["Data"] == b"hello, world!" + assert "StreamName" in parsed_request + assert parsed_request["StreamName"] == "test" + assert "PartitionKey" in parsed_request + assert parsed_request["PartitionKey"] == "partitionkey" + + +def test_json_cbor_blob_parsing_w_timestamp(snapshot): + serialized_request = { + "url_path": "/", + "query_string": "", + "method": "POST", + "headers": { + "Host": "localhost:4566", + "amz-sdk-invocation-id": "d77968c6-b536-155d-7228-d4dfe6372154", + "amz-sdk-request": "attempt=1; max=3", + "Content-Length": "103", + "Content-Type": "application/x-amz-cbor-1.1", + "X-Amz-Date": "20220721T081553Z", + "X-Amz-Target": "Kinesis_20131202.SubscribeToShard", + "x-localstack-tgt-api": "kinesis", + }, + "body": b"\xa3kConsumerARNsgShardIdopStartingPosition\xa2dTypelAT_TIMESTAMPiTimestampm1718960048123", + "url": "/", + "context": {}, + } + + prepare_request_dict(serialized_request, "") + split_url = urlsplit(serialized_request.get("url")) + path = split_url.path + query_string = split_url.query + + # Use our parser to parse the serialized body + # Load the appropriate service + service = load_service("kinesis") + operation_model = service.operation_model("SubscribeToShard") + parser = create_parser(service) + parsed_operation_model, parsed_request = parser.parse( + HttpRequest( + method=serialized_request.get("method"), + path=unquote(path), + query_string=to_str(query_string), + headers=serialized_request.get("headers"), + body=serialized_request["body"], + raw_path=path, + ) + ) + + # Check if the determined operation_model is correct + assert parsed_operation_model == operation_model + snapshot.match("parsed_request", parsed_request) + + +def test_restjson_parser_xray_with_botocore(): + _botocore_parser_integration_test( + service="xray", + action="PutTelemetryRecords", + TelemetryRecords=[ + { + "Timestamp": datetime(2015, 1, 1), + "SegmentsReceivedCount": 123, + "SegmentsSentCount": 123, + "SegmentsSpilloverCount": 123, + "SegmentsRejectedCount": 123, + "BackendConnectionErrors": { + "TimeoutCount": 123, + "ConnectionRefusedCount": 123, + "HTTPCode4XXCount": 123, + "HTTPCode5XXCount": 123, + "UnknownHostCount": 123, + "OtherCount": 123, + }, + }, + ], + EC2InstanceId="string", + Hostname="string", + ResourceARN="string", + ) + + +def test_restjson_path_location_opensearch_with_botocore(): + _botocore_parser_integration_test( + service="opensearch", + action="DeleteDomain", + DomainName="test-domain", + ) + + +def test_restjson_query_location_opensearch_with_botocore(): + _botocore_parser_integration_test( + service="opensearch", + action="ListVersions", + NextToken="test-token", + ) + + +def test_restjson_opensearch_with_botocore(): + _botocore_parser_integration_test( + service="opensearch", + action="UpdateDomainConfig", + DomainName="string", + ClusterConfig={ + "InstanceType": "m3.medium.search", + "InstanceCount": 123, + "DedicatedMasterEnabled": True, + "ZoneAwarenessEnabled": True, + "ZoneAwarenessConfig": {"AvailabilityZoneCount": 123}, + "DedicatedMasterType": "m3.medium.search", + "DedicatedMasterCount": 123, + "WarmEnabled": True, + "WarmType": "ultrawarm1.medium.search", + "WarmCount": 123, + "ColdStorageOptions": {"Enabled": True}, + }, + EBSOptions={"EBSEnabled": False, "VolumeType": "standard", "VolumeSize": 123, "Iops": 123}, + SnapshotOptions={"AutomatedSnapshotStartHour": 123}, + VPCOptions={ + "SubnetIds": [ + "string", + ], + "SecurityGroupIds": [ + "string", + ], + }, + CognitoOptions={ + "Enabled": True, + "UserPoolId": "string", + "IdentityPoolId": "string", + "RoleArn": "12345678901234567890", + }, + AdvancedOptions={"string": "string"}, + AccessPolicies="string", + LogPublishingOptions={ + "string": {"CloudWatchLogsLogGroupArn": "12345678901234567890", "Enabled": True} + }, + EncryptionAtRestOptions={"Enabled": False, "KmsKeyId": "string"}, + DomainEndpointOptions={ + "EnforceHTTPS": True, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07", + "CustomEndpointEnabled": True, + "CustomEndpoint": "string", + "CustomEndpointCertificateArn": "12345678901234567890", + }, + NodeToNodeEncryptionOptions={"Enabled": True}, + AdvancedSecurityOptions={ + "Enabled": True, + "InternalUserDatabaseEnabled": True, + "MasterUserOptions": { + "MasterUserARN": "12345678901234567890", + "MasterUserName": "string", + "MasterUserPassword": "12345678", + }, + "SAMLOptions": { + "Enabled": True, + "Idp": {"MetadataContent": "string", "EntityId": "12345678"}, + "MasterUserName": "string", + "MasterBackendRole": "string", + "SubjectKey": "string", + "RolesKey": "string", + "SessionTimeoutMinutes": 123, + }, + }, + AutoTuneOptions={ + "DesiredState": "ENABLED", + "RollbackOnDisable": "DEFAULT_ROLLBACK", + "MaintenanceSchedules": [ + { + "StartAt": datetime(2015, 1, 1), + "Duration": {"Value": 123, "Unit": "HOURS"}, + "CronExpressionForRecurrence": "string", + }, + ], + }, + ) + + +def test_restjson_lambda_invoke_with_botocore(): + _botocore_parser_integration_test( + service="lambda", + action="Invoke", + FunctionName="test-function", + ) + + +def test_ec2_parser_ec2_with_botocore(): + _botocore_parser_integration_test( + service="ec2", + action="CreateImage", + BlockDeviceMappings=[ + { + "DeviceName": "string", + "VirtualName": "string", + "Ebs": { + "DeleteOnTermination": True, + "Iops": 123, + "SnapshotId": "string", + "VolumeSize": 123, + "VolumeType": "standard", + "KmsKeyId": "string", + "Throughput": 123, + "OutpostArn": "string", + "Encrypted": True, + }, + "NoDevice": "string", + }, + ], + Description="string", + DryRun=True | False, + InstanceId="string", + Name="string", + NoReboot=True | False, + TagSpecifications=[ + { + "ResourceType": "capacity-reservation", + "Tags": [ + {"Key": "string", "Value": "string"}, + ], + }, + ], + ) + + +def test_restjson_parser_path_params_with_slashes(): + _botocore_parser_integration_test( + service="qldb", + action="ListTagsForResource", + ResourceArn="arn:aws:qldb:eu-central-1:000000000000:ledger/c-c67c827a", + ) + + +def test_parse_cloudtrail_with_botocore(): + _botocore_parser_integration_test( + service="cloudtrail", + action="DescribeTrails", + trailNameList=["t1"], + ) + + +def test_parse_cloudfront_uri_location_with_botocore(): + _botocore_parser_integration_test( + service="cloudfront", + action="GetDistribution", + Id="001", + ) + + +def test_parse_cloudfront_payload_with_botocore(): + _botocore_parser_integration_test( + service="cloudfront", + action="CreateOriginRequestPolicy", + OriginRequestPolicyConfig={ + "Comment": "comment1", + "Name": "name", + "HeadersConfig": {"HeaderBehavior": "none"}, + "CookiesConfig": {"CookieBehavior": "all"}, + "QueryStringsConfig": {"QueryStringBehavior": "all"}, + }, + ) + + +def test_parse_opensearch_conflicting_request_uris(): + """ + Tests if the operation detection works with conflicting regular expressions: + - OpenSearch's DescribeDomain (/2021-01-01/opensearch/domain/{DomainName}) + - OpenSearch's DescribeDomainConfig (/2021-01-01/opensearch/domain/{DomainName}/config) + Since the path parameters are greedy (they might contain slashes), "better" matches need to be preferred. + """ + _botocore_parser_integration_test( + service="opensearch", + action="DescribeDomainConfig", + DomainName="test-domain", + ) + _botocore_parser_integration_test( + service="opensearch", + action="DescribeDomain", + DomainName="test-domain", + ) + + +def test_parse_appconfig_non_json_blob_payload(): + """ + Tests if the parsing works correctly if the request contains a blob payload shape which does not contain valid JSON. + """ + _botocore_parser_integration_test( + service="appconfig", + action="CreateHostedConfigurationVersion", + ApplicationId="test-application-id", + ConfigurationProfileId="test-configuration-profile-id", + Content=BytesIO(b""), + ContentType="application/html", + ) + + +def test_parse_appconfig_deprecated_operation(): + """ + Tests if the parsing works correctly if the request targets a deprecated operation (without alternative, i.e. + another function having the same signature). + """ + _botocore_parser_integration_test( + service="appconfig", + action="GetConfiguration", + Application="test-application", + Environment="test-environment", + Configuration="test-configuration", + ClientId="test-client-id", + ) + + +def test_parse_s3_with_extended_uri_pattern(): + """ + Tests if the parsing works for operations where the operation defines a request URI with a "+" in the variable name, + (for example "requestUri":"/{Bucket}/{Key+}"). + The parameter with the "+" directive is greedy. There can only be one explicitly greedy param. + The corresponding shape definition does not contain the "+" in the "locationName" directive. + """ + _botocore_parser_integration_test( + service="s3", action="ListParts", Bucket="foo", Key="bar/test", UploadId="test-upload-id" + ) + + +def test_parse_s3_utf8_url(): + """Test the parsing of a map with the location trait 'headers'.""" + _botocore_parser_integration_test( + service="s3", + action="PutObject", + ContentLength=0, + Bucket="test-bucket", + Key="Δ€0", + Metadata={"Key": "value", "Key2": "value2"}, + ) + + +def test_parse_restjson_uri_location(): + """Tests if the parsing of uri parameters works correctly for the rest-json protocol""" + _botocore_parser_integration_test( + service="lambda", + action="AddPermission", + Action="lambda:InvokeFunction", + FunctionName="arn:aws:lambda:us-east-1:000000000000:function:test-forward-sns", + Principal="sns.amazonaws.com", + StatementId="2e25f762", + ) + + +def test_parse_restjson_header_parsing(): + """Tests parsing shapes from the header location.""" + _botocore_parser_integration_test( + service="ebs", + action="CompleteSnapshot", + SnapshotId="123", + ChangedBlocksCount=5, + Checksum="test-checksum-header-field", + ) + + +def test_parse_restjson_querystring_list_parsing(): + """Tests the parsing of lists of shapes with location querystring.""" + _botocore_parser_integration_test( + service="amplify", + action="UntagResource", + resourceArn="arn:aws:lambda:us-east-1:000000000000:function:test-forward-sns", + tagKeys=["Tag1", "Tag2"], + ) + + +def test_restjson_operation_detection_with_query_suffix_in_requesturi(): + """ + Test if the correct operation is detected if the requestURI pattern of the specification contains the first query + parameter, f.e. API Gateway's ImportRestApi: "/restapis?mode=import + """ + _botocore_parser_integration_test( + service="apigateway", + action="ImportRestApi", + body=BytesIO(b"Test"), + ) + + +def test_rest_url_parameter_with_dashes(): + """ + Test if requestUri parameters with dashes in them (e.g., "/v2/tags/{resource-arn}") are parsed correctly. + """ + _botocore_parser_integration_test( + service="apigatewayv2", + action="GetTags", + ResourceArn="arn:aws:apigatewayv2:us-east-1:000000000000:foobar", + ) + + +def test_rest_url_parameter_with_slashes(): + """Test if the parsing works for requests with (encoded) slashes in a parameter.""" + _botocore_parser_integration_test( + service="backup", + action="ListRecoveryPointsByResource", + ResourceArn="arn:aws:dynamodb:us-east-1:000000000000:table/table-104f455b", + ) + + +def test_restxml_operation_detection_with_query_suffix_without_value_in_requesturi(): + """ + Test if the correct operation is detected if the requestURI pattern of the specification contains the first query + parameter without a specific value, f.e. CloudFront's CreateDistributionWithTags: + "/2020-05-31/distribution?WithTags" + """ + _botocore_parser_integration_test( + service="cloudfront", + action="CreateDistributionWithTags", + DistributionConfigWithTags={ + "DistributionConfig": { + "CallerReference": "string", + "Origins": { + "Quantity": 1, + "Items": [ + { + "Id": "string", + "DomainName": "string", + } + ], + }, + "Comment": "string", + "Enabled": True, + "DefaultCacheBehavior": { + "TargetOriginId": "string", + "ViewerProtocolPolicy": "allow-all", + }, + }, + "Tags": { + "Items": [ + {"Key": "string", "Value": "string"}, + ] + }, + }, + ) + + +def test_restjson_operation_detection_with_length_prio(): + """ + Tests if the correct operation is detected if the requestURI patterns are conflicting and the length of the + normalized regular expression for the path matching solves the conflict. + For example: The detection of API Gateway PutIntegrationResponse (without the normalization PutMethodResponse would + be detected). + """ + _botocore_parser_integration_test( + service="apigateway", + action="PutIntegrationResponse", + restApiId="rest-api-id", + resourceId="resource-id", + httpMethod="POST", + statusCode="201", + ) + + +def test_restjson_operation_detection_with_subpath(): + """ + Tests if the operation lookup correctly fails for a subpath of an operation. + For example: The detection of a URL which is routed through API Gateway. + """ + service = load_service("apigateway") + parser = create_parser(service) + with pytest.raises(OperationNotFoundParserError): + parser.parse( + HttpRequest( + method="GET", + path="/restapis/cmqinv79uh/local/_user_request_/", + raw_path="/restapis/cmqinv79uh/local/_user_request_/", + ) + ) + + +def test_s3_get_operation_detection(): + """ + Test if the S3 operation detection works for ambiguous operations. GetObject is the worst, because it is + overloaded with the exact same requestURI by another non-deprecated function where the only distinction is the + matched required parameter. + """ + _botocore_parser_integration_test( + service="s3", action="GetObject", Bucket="test-bucket", Key="foo/bar/test.json" + ) + + +def test_s3_head_operation_detection(): + """Test if the S3 operation detection works for HEAD operations.""" + _botocore_parser_integration_test( + service="s3", action="HeadObject", Bucket="test-bucket", Key="foo/bar/test.json" + ) + + +def test_s3_put_object_keys_with_slashes(): + _botocore_parser_integration_test( + service="s3", + action="PutObject", + Bucket="test-bucket", + Key="/test-key", + ContentLength=6, + Body=BytesIO(b"foobar"), + Metadata={}, + ) + + +def test_s3_get_object_keys_with_slashes(): + _botocore_parser_integration_test( + service="s3", + action="GetObject", + Bucket="test-bucket", + Key="/test-key", + ) + + +def test_s3_put_object_keys_with_trailing_slash_and_special_characters(): + _botocore_parser_integration_test( + service="s3", + action="PutObject", + Bucket="test-bucket", + Key="test@key/", + ContentLength=0, + Metadata={}, + ) + + +def test_restxml_headers_parsing(): + """Test the parsing of a map with the location trait 'headers'.""" + _botocore_parser_integration_test( + service="s3", + action="PutObject", + ContentLength=0, + Bucket="test-bucket", + Key="test.json", + Metadata={"Key": "value", "Key2": "value2"}, + ) + + +def test_restxml_header_list_parsing(): + """Tests that list attributes that are encoded into headers are parsed correctly.""" + _botocore_parser_integration_test( + service="s3", + action="GetObjectAttributes", + Bucket="test-bucket", + Key="/test-key", + # ObjectAttributesList is a list of strings with location:"header" + ObjectAttributes=["ObjectSize", "StorageClass"], + ) + + +def test_restxml_header_optional_list_parsing(): + """Tests that non-existing header list attributes are working correctly.""" + # OptionalObjectAttributes (the "x-amz-optional-object-attributes") in ListObjectsV2Request is optional + _botocore_parser_integration_test(service="s3", action="ListObjectsV2", Bucket="test-bucket") + + +def test_restxml_header_date_parsing(): + """Test the parsing of a map with the location trait 'headers'.""" + _botocore_parser_integration_test( + service="s3", + action="PutObject", + Bucket="test-bucket", + Key="test-key", + ContentLength=3, + Body=BytesIO(b"foo"), + Metadata={}, + Expires=datetime(2015, 1, 1, 0, 0, tzinfo=timezone.utc), + ) + + +def test_s3_virtual_host_addressing(): + """Test the parsing of an S3 bucket request using the bucket encoded in the domain.""" + request = HttpRequest(method="PUT", headers={"host": "test-bucket.s3.example.com"}) + parser = create_parser(load_service("s3")) + parsed_operation_model, parsed_request = parser.parse(request) + assert parsed_operation_model.name == "CreateBucket" + assert "Bucket" in parsed_request + assert parsed_request["Bucket"] == "test-bucket" + + +def test_s3_path_addressing(): + """Test the parsing of an S3 bucket request using the bucket encoded in the path.""" + request = HttpRequest(method="PUT", path="/test-bucket") + parser = create_parser(load_service("s3")) + parsed_operation_model, parsed_request = parser.parse(request) + assert parsed_operation_model.name == "CreateBucket" + assert "Bucket" in parsed_request + assert parsed_request["Bucket"] == "test-bucket" + + +def test_s3_list_buckets_with_localhost(): + # this is the canonical request of `awslocal s3 ls` when running on a standard port + request = HttpRequest("GET", "/", headers={"host": "localhost"}) + parser = create_parser(load_service("s3")) + parsed_operation_model, parsed_request = parser.parse(request) + assert parsed_operation_model.name == "ListBuckets" + + +def test_s3_get_object_attributes_with_whitespace(): + # optional whitespace is accepted for ObjectAttributesList, a list of strings with location:"header" + request = HttpRequest( + "GET", + "/bucket/key?attributes", + query_string="attributes", + headers={ + "x-amz-object-attributes": "ETag, Checksum, ObjectParts, StorageClass, ObjectSize", + }, + ) + parser = create_parser(load_service("s3")) + parsed_operation_model, parsed_request = parser.parse(request) + assert parsed_operation_model.name == "GetObjectAttributes" + assert parsed_request["ObjectAttributes"] == [ + "ETag", + "Checksum", + "ObjectParts", + "StorageClass", + "ObjectSize", + ] + + # assert that with no whitespace, it is identical + request = HttpRequest( + "GET", + "/bucket/key", + query_string="attributes", + headers={ + "x-amz-object-attributes": "ETag,Checksum,ObjectParts,StorageClass,ObjectSize", + }, + ) + parser = create_parser(load_service("s3")) + parsed_operation_model, parsed_request = parser.parse(request) + assert parsed_operation_model.name == "GetObjectAttributes" + assert parsed_request["ObjectAttributes"] == [ + "ETag", + "Checksum", + "ObjectParts", + "StorageClass", + "ObjectSize", + ] + + +def test_s3_list_buckets_with_localhost_and_port(): + # this is the canonical request of `awslocal s3 ls` + request = HttpRequest("GET", "/", headers={"host": "localhost:4566"}) + parser = create_parser(load_service("s3")) + parsed_operation_model, parsed_request = parser.parse(request) + assert parsed_operation_model.name == "ListBuckets" + + +def test_query_parser_error_on_protocol_error(): + """Test that the parser raises a ProtocolParserError in case of invalid data to parse.""" + parser = QueryRequestParser(load_service("sqs")) + request = HttpRequest( + body=to_bytes( + "Action=UnknownOperation&Version=2012-11-05&" + "QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&" + "MessageBody=%7B%22foo%22%3A+%22bared%22%7D&" + "DelaySeconds=2" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + with pytest.raises(ProtocolParserError): + parser.parse(request) + + +def test_restjson_parser_error_on_protocol_error(): + request = HttpRequest( + body="invalid}", + method="POST", + path="/2021-01-01/opensearch/domain", + ) + parser = RestJSONRequestParser(load_service("opensearch")) + + with pytest.raises(ProtocolParserError): + parser.parse(request) + + +def test_parser_error_on_unknown_error(): + """Test that the parser raises a UnknownParserError in case of an unknown exception.""" + parser = QueryRequestParser(load_service("sqs")) + + request = HttpRequest( + body=to_bytes( + "Action=SendMessage&Version=2012-11-05&" + "QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&" + "MessageBody=%7B%22foo%22%3A+%22bared%22%7D&" + "DelaySeconds=2" + ), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + path="", + ) + + # An unknown error is obviously hard to trigger (because we would fix it if we would know of a way to trigger it), + # therefore we patch a function to raise an unexpected error + def raise_error(*args, **kwargs): + raise NotImplementedError() + + parser._process_member = raise_error + with pytest.raises(UnknownParserError): + parser.parse(request) + + +def test_restjson_get_element_from_location(): + """ + Some GET requests expect a body. While it is allowed in principle by HTTP, it is discouraged and the server + should ignore the body of a GET request: https://stackoverflow.com/a/983458/804840. + + However, as of May 7, 2022, the following AWS GET requests expect a JSON body: + + - quicksight GET ListIAMPolicyAssignments (member=AssignmentStatus) + - sesv2 GET ListContacts (member=Filter) + - sesv2 GET ListImportJobs (member=ImportDestinationType) + """ + + _botocore_parser_integration_test( + service="sesv2", + action="ListContacts", + ContactListName="foobar", + Filter={ + "FilteredStatus": "OPT_IN", + "TopicFilter": { + "TopicName": "atopic", + "UseDefaultIfPreferenceUnavailable": False, + }, + }, + ) + + +def test_restjson_raises_error_on_non_json_body(): + request = HttpRequest("GET", "/2021-01-01/opensearch/domain/mydomain", body="foobar") + parser = create_parser(load_service("opensearch")) + + with pytest.raises(ProtocolParserError): + parser.parse(request) + + +def test_restxml_ignores_get_body(): + request = HttpRequest("GET", "/test-bucket/foo", body="foobar") + parser = create_parser(load_service("s3")) + parsed_operation_model, parsed_request = parser.parse(request) + assert parsed_operation_model.name == "GetObject" + assert "Bucket" in parsed_request + assert parsed_request["Bucket"] == "test-bucket" + assert parsed_request["Key"] == "foo" diff --git a/tests/unit/aws/protocol/test_parser.snapshot.json b/tests/unit/aws/protocol/test_parser.snapshot.json new file mode 100644 index 0000000000000..bf3fab6bc744b --- /dev/null +++ b/tests/unit/aws/protocol/test_parser.snapshot.json @@ -0,0 +1,15 @@ +{ + "tests/unit/aws/protocol/test_parser.py::test_json_cbor_blob_parsing_w_timestamp": { + "recorded-date": "21-06-2024, 13:58:29", + "recorded-content": { + "parsed_request": { + "ConsumerARN": "", + "ShardId": "", + "StartingPosition": { + "Timestamp": "2024-06-21 08:54:08.123000", + "Type": "AT_TIMESTAMP" + } + } + } + } +} diff --git a/tests/unit/aws/protocol/test_parser.validation.json b/tests/unit/aws/protocol/test_parser.validation.json new file mode 100644 index 0000000000000..d667ef29c28c0 --- /dev/null +++ b/tests/unit/aws/protocol/test_parser.validation.json @@ -0,0 +1,5 @@ +{ + "tests/unit/aws/protocol/test_parser.py::test_json_cbor_blob_parsing_w_timestamp": { + "last_validated_date": "2024-06-21T13:58:29+00:00" + } +} diff --git a/tests/unit/aws/protocol/test_parser_validate.py b/tests/unit/aws/protocol/test_parser_validate.py new file mode 100644 index 0000000000000..8bfe6d1ad615f --- /dev/null +++ b/tests/unit/aws/protocol/test_parser_validate.py @@ -0,0 +1,123 @@ +from urllib.parse import urlencode + +import pytest + +from localstack.aws.protocol.parser import create_parser +from localstack.aws.protocol.validate import ( + InvalidLength, + InvalidRange, + MissingRequiredField, + ParamValidator, + validate_request, +) +from localstack.aws.spec import load_service +from localstack.http import Request as HttpRequest + + +class TestExceptions: + def test_missing_required_field_restjson(self): + parser = create_parser(load_service("opensearch")) + + op, params = parser.parse( + HttpRequest( + "POST", + "/2021-01-01/tags", + body='{"ARN":"somearn"}', + ) + ) + + with pytest.raises(MissingRequiredField) as e: + validate_request(op, params).raise_first() + + assert e.value.error.reason == "missing required field" + assert e.value.required_name == "TagList" + + def test_missing_required_field_query(self): + parser = create_parser(load_service("sqs-query")) + + op, params = parser.parse( + HttpRequest( + "POST", + "/", + body=( + "Action=SendMessage&Version=2012-11-05&" + "QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&" + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + ) + + validator = ParamValidator() + errors = validator.validate(params, op.input_shape) + assert errors.has_errors() + + with pytest.raises(MissingRequiredField) as e: + errors.raise_first() + + assert e.match("MessageBody") + assert e.value.error.reason == "missing required field" + assert e.value.required_name == "MessageBody" + + def test_missing_required_field_restxml(self): + parser = create_parser(load_service("route53")) + + op, params = parser.parse( + HttpRequest( + "POST", + "/2013-04-01/hostedzone", + body="foobar.com", + ) + ) + + with pytest.raises(MissingRequiredField) as e: + validate_request(op, params).raise_first() + + assert e.value.error.reason == "missing required field" + assert e.value.required_name == "CallerReference" + + def test_invalid_range_query(self): + parser = create_parser(load_service("sts")) + + op, params = parser.parse( + HttpRequest( + "POST", + "/", + body=urlencode( + query={ + "Action": "AssumeRole", + "RoleArn": "arn:aws:iam::000000000000:role/foobared", + "RoleSessionName": "foobared", + "DurationSeconds": "100", + } + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + ) + + with pytest.raises(InvalidRange) as e: + validate_request(op, params).raise_first() + + e.match("DurationSeconds") + + def test_invalid_length_query(self): + parser = create_parser(load_service("sts")) + + op, params = parser.parse( + HttpRequest( + "POST", + "/", + body=urlencode( + query={ + "Action": "AssumeRole", + "RoleArn": "arn:aws", # min=8 + "RoleSessionName": "foobared", + } + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + ) + + with pytest.raises(InvalidLength) as e: + validate_request(op, params).raise_first() + + e.match("RoleArn") diff --git a/tests/unit/aws/protocol/test_serializer.py b/tests/unit/aws/protocol/test_serializer.py new file mode 100644 index 0000000000000..156abc589644e --- /dev/null +++ b/tests/unit/aws/protocol/test_serializer.py @@ -0,0 +1,1984 @@ +import copy +import io +import json +import re +from datetime import datetime +from io import BytesIO +from typing import Any, Dict, Iterator, List, Optional +from xml.etree import ElementTree + +import pytest +from botocore.awsrequest import HeadersDict +from botocore.endpoint import convert_to_response_dict +from botocore.parsers import ResponseParser, create_parser + +# cbor2: explicitly load from private _decoder module to avoid using the (non-patched) C-version +from cbor2._decoder import loads as cbor2_loads +from dateutil.tz import tzlocal, tzutc +from requests.models import Response as RequestsResponse +from urllib3 import HTTPResponse as UrlLibHttpResponse +from werkzeug.datastructures import Headers +from werkzeug.wrappers import ResponseStream + +from localstack.aws.api import CommonServiceException, ServiceException +from localstack.aws.api.dynamodb import ( + AttributeValue, + CancellationReason, + TransactionCanceledException, +) +from localstack.aws.api.kinesis import GetRecordsOutput, Record +from localstack.aws.api.lambda_ import ResourceNotFoundException +from localstack.aws.api.route53 import NoSuchHostedZone +from localstack.aws.api.sns import VerificationException +from localstack.aws.api.sqs import ( + InvalidMessageContents, + ReceiptHandleIsInvalid, + UnsupportedOperation, +) +from localstack.aws.api.sts import Credentials, GetSessionTokenResponse +from localstack.aws.protocol.serializer import ( + ProtocolSerializerError, + QueryResponseSerializer, + UnknownSerializerError, + aws_response_serializer, + create_serializer, +) +from localstack.aws.spec import load_service +from localstack.constants import APPLICATION_AMZ_CBOR_1_1 +from localstack.http import Request, Response +from localstack.utils.common import to_str +from localstack.utils.strings import long_uid + +_skip_assert = {} + + +def _botocore_serializer_integration_test( + service: str, + action: str, + response: dict, + status_code=200, + expected_response_content: dict = None, +) -> dict: + """ + Performs an integration test for the serializer using botocore as parser. + It executes the following steps: + - Load the given service (f.e. "sqs") + - Serialize the response with the appropriate serializer from the AWS Serivce Framework + - Parse the serialized response using the botocore parser + - Checks if the metadata is correct (status code, requestID,...) + - Checks if the parsed response content is equal to the input to the serializer + + :param service: to load the correct service specification, serializer, and parser + :param action: to load the correct service specification, serializer, and parser + :param response: which should be serialized and tested against + :param status_code: Optional - expected status code of the response - defaults to 200 + :param expected_response_content: Optional - if the input data ("response") differs from the actually expected data + (because f.e. it contains None values) + :return: boto-parsed serialized response + """ + + # Load the appropriate service + service = load_service(service) + + # Use our serializer to serialize the response + response_serializer = create_serializer(service) + # The serializer changes the incoming dict, therefore copy it before passing it to the serializer + response_to_parse = copy.deepcopy(response) + serialized_response = response_serializer.serialize_to_response( + response_to_parse, service.operation_model(action), None, long_uid() + ) + + # Use the parser from botocore to parse the serialized response + response_parser = create_parser(service.protocol) + parsed_response = response_parser.parse( + serialized_response.to_readonly_response_dict(), + service.operation_model(action).output_shape, + ) + + return_response = copy.deepcopy(parsed_response) + + # Check if the result is equal to the initial response params + assert "ResponseMetadata" in parsed_response + assert "HTTPStatusCode" in parsed_response["ResponseMetadata"] + assert parsed_response["ResponseMetadata"]["HTTPStatusCode"] == status_code + assert "RequestId" in parsed_response["ResponseMetadata"] + assert len(parsed_response["ResponseMetadata"]["RequestId"]) == 36 + del parsed_response["ResponseMetadata"] + + if expected_response_content is None: + expected_response_content = response + if expected_response_content is not _skip_assert: + assert parsed_response == expected_response_content + + return return_response + + +def _botocore_error_serializer_integration_test( + service_model_name: str, + action: str, + exception: ServiceException, + code: str, + status_code: int, + message: Optional[str], + is_sender_fault: bool = False, + **additional_error_fields: Dict[str, Any], +) -> dict: + """ + Performs an integration test for the error serialization using botocore as parser. + It executes the following steps: + - Load the given service (f.e. "sqs") + - Serialize the _error_ response with the appropriate serializer from the AWS Serivce Framework + - Parse the serialized error response using the botocore parser + - Checks if the metadata is correct (status code, requestID,...) + - Checks if the parsed error response content is correct + + :param service_model_name: to load the correct service specification, serializer, and parser + :param action: to load the correct service specification, serializer, and parser + :param exception: which should be serialized and tested against + :param code: expected "code" of the exception (i.e. the AWS specific exception ID, f.e. + "CloudFrontOriginAccessIdentityAlreadyExists") + :param status_code: expected HTTP response status code + :param message: expected error message + :param is_sender_fault: expected fault type is sender + :param additional_error_fields: additional fields which need to be present (for exception shapes with members) + :return: boto-parsed serialized error response + """ + + # Load the appropriate service + service = load_service(service_model_name) + + # Use our serializer to serialize the response + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model(action), None, long_uid() + ) + + # Use the parser from botocore to parse the serialized response + response_dict = serialized_response.to_readonly_response_dict() + + # botocore converts the headers to lower-case keys + # f.e. needed for x-amzn-errortype + response_dict["headers"] = HeadersDict(response_dict["headers"]) + + response_parser: ResponseParser = create_parser(service.protocol) + parsed_response = response_parser.parse( + response_dict, + service.operation_model(action).output_shape, + ) + # Add the modeled error shapes + error_shape = service.shape_for_error_code(exception.code) + modeled_parse = response_parser.parse(response_dict, error_shape) + parsed_response.update(modeled_parse) + + # Check if the result is equal to the initial response params + assert "Error" in parsed_response + assert "Code" in parsed_response["Error"] + assert "Message" in parsed_response["Error"] + assert parsed_response["Error"]["Code"] == code + assert parsed_response["Error"]["Message"] == message + + assert "ResponseMetadata" in parsed_response + assert "RequestId" in parsed_response["ResponseMetadata"] + assert len(parsed_response["ResponseMetadata"]["RequestId"]) == 36 + assert "HTTPStatusCode" in parsed_response["ResponseMetadata"] + assert parsed_response["ResponseMetadata"]["HTTPStatusCode"] == status_code + type = parsed_response["Error"].get("Type") + if is_sender_fault: + assert type == "Sender" + else: + assert type is None + if additional_error_fields: + for key, value in additional_error_fields.items(): + assert key in parsed_response + assert parsed_response[key] == value + return parsed_response + + +def _botocore_event_streaming_test( + service: str, action: str, response: dict, response_root_tag: str, expected_events: List[dict] +): + """ + Tests the serialization of event streaming responses using botocore. + + :param service: to load the correct service specification, serializer, and parser + :param action: to load the correct service specification, serializer, and parser + :param response: which should be serialized + :param response_root_tag: name of the root element in the response + :param expected_events: events which are streamed and should be contained in the fully streamed and deserialized response + :return: None + """ + # Serialize the response + service = load_service(service) + operation_model = service.operation_model(action) + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_to_response( + response, operation_model, None, long_uid() + ) + + # Convert the Werkzeug response from our serializer to a response botocore can work with + urllib_response = UrlLibHttpResponse( + body=_iterable_to_stream(serialized_response.response), + headers={ + entry_tuple[0].lower(): entry_tuple[1] for entry_tuple in serialized_response.headers + }, + status=serialized_response.status_code, + decode_content=False, + preload_content=False, + ) + requests_response = RequestsResponse() + requests_response.headers = urllib_response.headers + requests_response.status_code = urllib_response.status + requests_response.raw = urllib_response + botocore_response = convert_to_response_dict(requests_response, operation_model) + + # parse the response using botocore + response_parser: ResponseParser = create_parser(service.protocol) + parsed_response = response_parser.parse( + botocore_response, + operation_model.output_shape, + ) + + # check that the response contains the event stream + assert response_root_tag in parsed_response + + # fetch all events and check their content + actual_events = list(parsed_response[response_root_tag]) + assert len(actual_events) == len(expected_events) + assert actual_events == expected_events + + +def test_rest_xml_serializer_cloudfront_with_botocore(): + parameters = { + "TestResult": { + "FunctionSummary": { + "Name": "string", + "Status": "string", + "FunctionConfig": {"Comment": "string", "Runtime": "cloudfront-js-1.0"}, + "FunctionMetadata": { + "FunctionARN": "string", + "Stage": "LIVE", + # Test the timestamp precision by adding hours, minutes, seconds and some milliseconds + # (as microseconds). + "CreatedTime": datetime(2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc()), + "LastModifiedTime": datetime(2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc()), + }, + }, + "ComputeUtilization": "string", + "FunctionExecutionLogs": [ + "string", + ], + "FunctionErrorMessage": "string", + "FunctionOutput": "string", + } + } + _botocore_serializer_integration_test("cloudfront", "TestFunction", parameters) + + +def test_rest_xml_serializer_route53_with_botocore(): + parameters = { + "HostedZone": { + "Id": "/hostedzone/9WXI4LV03NAZVS1", + "Name": "fuu.", + "Config": {"PrivateZone": False}, + "ResourceRecordSetCount": 0, + }, + "DelegationSet": {"NameServers": ["dns.localhost.localstack.cloud"]}, + } + _botocore_serializer_integration_test("route53", "CreateHostedZone", parameters, 201) + + +def test_rest_xml_serializer_s3_with_botocore(): + parameters = { + "AnalyticsConfiguration": { + "Id": "string", + "Filter": { + "Prefix": "string", + "Tag": {"Key": "string", "Value": "string"}, + "And": { + "Prefix": "string", + "Tags": [ + {"Key": "string", "Value": "string"}, + ], + }, + }, + "StorageClassAnalysis": { + "DataExport": { + "OutputSchemaVersion": "V_1", + "Destination": { + "S3BucketDestination": { + "Format": "CSV", + "BucketAccountId": "string", + "Bucket": "string", + "Prefix": "string", + } + }, + } + }, + } + } + _botocore_serializer_integration_test("s3", "GetBucketAnalyticsConfiguration", parameters) + + +def test_rest_xml_serializer_s3_2_with_botocore(): + # These date fields in this response are encoded in the header. The max precision is seconds. + parameters = { + "Body": "body", + "DeleteMarker": True, + "AcceptRanges": "string", + "Expiration": "string", + "Restore": "string", + "LastModified": datetime(2015, 1, 1, 23, 59, 59, tzinfo=tzutc()), + "ContentLength": 4, + "ETag": "string", + "MissingMeta": 123, + "VersionId": "string", + "CacheControl": "string", + "ContentDisposition": "string", + "ContentEncoding": "string", + "ContentLanguage": "string", + "ContentRange": "string", + "ContentType": "string", + "Expires": datetime(2015, 1, 1, 23, 59, 59, tzinfo=tzutc()), + "WebsiteRedirectLocation": "string", + "ServerSideEncryption": "AES256", + "Metadata": {"string": "string"}, + "SSECustomerAlgorithm": "string", + "SSECustomerKeyMD5": "string", + "SSEKMSKeyId": "string", + "BucketKeyEnabled": True | False, + "StorageClass": "STANDARD", + "RequestCharged": "requester", + "ReplicationStatus": "COMPLETE", + "PartsCount": 123, + "TagCount": 123, + "ObjectLockMode": "GOVERNANCE", + "ObjectLockRetainUntilDate": datetime(2015, 1, 1, 23, 59, 59, tzinfo=tzutc()), + "ObjectLockLegalHoldStatus": "ON", + "StatusCode": 200, + } + _botocore_serializer_integration_test("s3", "GetObject", parameters) + + +def test_query_serializer_cloudformation_with_botocore(): + parameters = { + "StackResourceDrift": { + "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/MyStack/d0a825a0-e4cd-xmpl-b9fb-061c69e99204", + "LogicalResourceId": "MyFunction", + "PhysicalResourceId": "my-function-SEZV4XMPL4S5", + "ResourceType": "AWS::Lambda::Function", + "ExpectedProperties": '{"Description":"Write a file to S3.","Environment":{"Variables":{"bucket":"my-stack-bucket-1vc62xmplgguf"}},"Handler":"index.handler","MemorySize":128,"Role":"arn:aws:iam::123456789012:role/my-functionRole-HIZXMPLEOM9E","Runtime":"nodejs10.x","Tags":[{"Key":"lambda:createdBy","Value":"SAM"}],"Timeout":900,"TracingConfig":{"Mode":"Active"}}', + "ActualProperties": '{"Description":"Write a file to S3.","Environment":{"Variables":{"bucket":"my-stack-bucket-1vc62xmplgguf"}},"Handler":"index.handler","MemorySize":256,"Role":"arn:aws:iam::123456789012:role/my-functionRole-HIZXMPLEOM9E","Runtime":"nodejs10.x","Tags":[{"Key":"lambda:createdBy","Value":"SAM"}],"Timeout":22,"TracingConfig":{"Mode":"Active"}}', + "PropertyDifferences": [ + { + "PropertyPath": "/MemorySize", + "ExpectedValue": "128", + "ActualValue": "256", + "DifferenceType": "NOT_EQUAL", + }, + { + "PropertyPath": "/Timeout", + "ExpectedValue": "900", + "ActualValue": "22", + "DifferenceType": "NOT_EQUAL", + }, + ], + "StackResourceDriftStatus": "MODIFIED", + "Timestamp": datetime(2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc()), + } + } + _botocore_serializer_integration_test("cloudformation", "DetectStackResourceDrift", parameters) + + +def test_query_serializer_redshift_with_botocore(): + parameters = { + "Marker": "string", + "ClusterDbRevisions": [ + { + "ClusterIdentifier": "string", + "CurrentDatabaseRevision": "string", + "DatabaseRevisionReleaseDate": datetime( + 2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc() + ), + "RevisionTargets": [ + { + "DatabaseRevision": "string", + "Description": "string", + "DatabaseRevisionReleaseDate": datetime( + 2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc() + ), + }, + ], + }, + ], + } + _botocore_serializer_integration_test("redshift", "DescribeClusterDbRevisions", parameters) + + +def test_query_serializer_sqs_empty_return_shape_with_botocore(): + _botocore_serializer_integration_test("sqs", "SetQueueAttributes", {}) + + +def test_query_serializer_sqs_flattened_list_with_botocore(): + response = { + "QueueUrls": [ + "http://localhost:4566/000000000000/myqueue1", + "http://localhost:4566/000000000000/myqueue2", + ] + } + _botocore_serializer_integration_test("sqs", "ListQueues", response) + + +def test_query_serializer_sqs_flattened_map_with_botocore(): + response = { + "Attributes": { + "QueueArn": "arn:aws:sqs:us-east-1:000000000000:test-queue-01", + "DelaySeconds": "0", + } + } + _botocore_serializer_integration_test("sqs", "GetQueueAttributes", response) + + +def test_query_serializer_sqs_flattened_list_map_with_botocore(): + response = { + "Messages": [ + { + "MessageId": "ac9baa5c-13b1-4206-aa28-2ac45ae168af", + "ReceiptHandle": "AQEBZ14sCjWJuot0T8G2Eg3S8C+sJGg+QRKYCJjfd8iiOsrPfUzbXSjlQquT9NZP1Mxxkcud3HcaxvS7I1gxoM9MSjbpenKgkti8TPCc7nQBUk9y6xXYWlhysjgAi9YjExUIxO2ozYZuwyksOvIxS4NZs2aBctyR74N3XjOO/t8GByAz2u7KR5vYJu418Y9apAuYB1n6ZZ6aE1NrjIK9mjGCKSqE3YxN5SNkKXf1zRwTUjq8cE73F7cK7DBXNFWBTZSYkLLnFg/QuqKh0dfwGgLseeKhHUxw2KiP9qH4kvXBn2UdeI8jkFMbPERiSf2KMrGKyMCtz3jL+YVRYkB4BB0hx15Brrgo/zhePXHbT692VxKF98MIMQc/v+dc6aewQZldjuq6ANrp4RM+LdjlTPg7ow==", + "MD5OfBody": "13c0c73bbf11056450c43bf3159b3585", + "Body": '{"foo": "bared"}', + "Attributes": {"SenderId": "000000000000", "SentTimestamp": "1652963711"}, + "MD5OfMessageAttributes": "fca026605781cb4126a1e9044df24232", + "MessageAttributes": { + "Hello": {"StringValue": "There", "DataType": "String"}, + "General": {"StringValue": "Kenobi", "DataType": "String"}, + }, + } + ] + } + _botocore_serializer_integration_test("sqs", "ReceiveMessage", response) + + +def test_query_serializer_sqs_none_value_in_map(): + response = { + "Messages": [ + { + "MessageId": "ac9baa5c-13b1-4206-aa28-2ac45ae168af", + "ReceiptHandle": "AQEBZ14sCjWJuot0T8G2Eg3S8C+sJGg+QRKYCJjfd8iiOsrPfUzbXSjlQquT9NZP1Mxxkcud3HcaxvS7I1gxoM9MSjbpenKgkti8TPCc7nQBUk9y6xXYWlhysjgAi9YjExUIxO2ozYZuwyksOvIxS4NZs2aBctyR74N3XjOO/t8GByAz2u7KR5vYJu418Y9apAuYB1n6ZZ6aE1NrjIK9mjGCKSqE3YxN5SNkKXf1zRwTUjq8cE73F7cK7DBXNFWBTZSYkLLnFg/QuqKh0dfwGgLseeKhHUxw2KiP9qH4kvXBn2UdeI8jkFMbPERiSf2KMrGKyMCtz3jL+YVRYkB4BB0hx15Brrgo/zhePXHbT692VxKF98MIMQc/v+dc6aewQZldjuq6ANrp4RM+LdjlTPg7ow==", + "Attributes": None, + "MD5OfBody": "13c0c73bbf11056450c43bf3159b3585", + "Body": '{"foo": "bared"}', + } + ] + } + expected_response = copy.deepcopy(response) + del expected_response["Messages"][0]["Attributes"] + _botocore_serializer_integration_test("sqs", "ReceiveMessage", response, 200, expected_response) + + +def test_query_protocol_error_serialization(): + exception = InvalidMessageContents("Exception message!") + _botocore_error_serializer_integration_test( + "sqs-query", "SendMessage", exception, "InvalidMessageContents", 400, "Exception message!" + ) + + +def test_query_protocol_error_serialization_plain(): + exception = ReceiptHandleIsInvalid( + 'The input receipt handle "garbage" is not a valid receipt handle.' + ) + + # Load the SQS service + service = load_service("sqs-query") + + # Use our serializer to serialize the response + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model("ChangeMessageVisibility"), None, long_uid() + ) + serialized_response_dict = serialized_response.to_readonly_response_dict() + # Replace the random request ID with a static value for comparison + serialized_response_body = re.sub( + ".*", + "static_request_id", + to_str(serialized_response_dict["body"]), + ) + + # This expected_response_body differs from the actual response in the following ways: + # - The original response does not define an encoding. + # - There is no newline after the XML declaration. + # - The response does not contain a Type nor Detail tag (since they aren't contained in the spec). + # - The original response uses double quotes for the xml declaration. + # Most of these differences should be handled equally by parsing clients, however, we might adopt some of these + # changes in the future. + expected_response_body = ( + "\n" + '' + "" + "ReceiptHandleIsInvalid" + "The input receipt handle "garbage" is not a valid receipt handle." + "" + "" + "static_request_id" + "" + ) + + assert serialized_response_body == expected_response_body + assert serialized_response_dict["headers"].get("Content-Type") is not None + assert serialized_response_dict["headers"]["Content-Type"] == "text/xml" + + +def test_query_protocol_custom_error_serialization(): + exception = CommonServiceException("InvalidParameterValue", "Parameter x was invalid!") + _botocore_error_serializer_integration_test( + "sqs-query", + "SendMessage", + exception, + "InvalidParameterValue", + 400, + "Parameter x was invalid!", + ) + + +def test_query_protocol_error_serialization_sender_fault(): + exception = UnsupportedOperation("Operation not supported.") + _botocore_error_serializer_integration_test( + "sqs-query", + "SendMessage", + exception, + "AWS.SimpleQueueService.UnsupportedOperation", + 400, + "Operation not supported.", + True, + ) + + +def test_sqs_json_protocol_error_serialization_sender_fault(): + exception = UnsupportedOperation("Operation not supported.") + _botocore_error_serializer_integration_test( + "sqs", + "SendMessage", + exception, + "AWS.SimpleQueueService.UnsupportedOperation", + 400, + "Operation not supported.", + True, + ) + + +def test_restxml_protocol_error_serialization_not_specified_for_operation(): + """ + Tests if the serializer can serialize an error which is not explicitly defined as an error shape for the + specific operation. + This can happen if the specification is not specific enough (f.e. S3's GetBucketAcl does not define the NoSuchBucket + error, even though it obviously can be raised). + """ + + class NoSuchBucket(ServiceException): + code: str = "NoSuchBucket" + sender_fault: bool = False + status_code: int = 400 + + exception = NoSuchBucket("Exception message!") + _botocore_error_serializer_integration_test( + "s3", + "GetBucketAcl", + exception, + "NoSuchBucket", + 400, + "Exception message!", + ) + + +def test_restxml_protocol_error_serialization(): + class CloudFrontOriginAccessIdentityAlreadyExists(ServiceException): + code: str = "CloudFrontOriginAccessIdentityAlreadyExists" + sender_fault: bool = False + status_code: int = 409 + + exception = CloudFrontOriginAccessIdentityAlreadyExists("Exception message!") + _botocore_error_serializer_integration_test( + "cloudfront", + "CreateCloudFrontOriginAccessIdentity", + exception, + "CloudFrontOriginAccessIdentityAlreadyExists", + 409, + "Exception message!", + ) + + +def test_restxml_protocol_custom_error_serialization(): + exception = CommonServiceException( + "APIAccessCensorship", + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + status_code=451, + ) + _botocore_error_serializer_integration_test( + "cloudfront", + "CreateCloudFrontOriginAccessIdentity", + exception, + "APIAccessCensorship", + 451, + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + ) + + +def test_s3_xml_protocol_custom_error_serialization_headers(): + class NoSuchKey(ServiceException): + code: str = "NoSuchKey" + sender_fault: bool = False + status_code: int = 404 + DeleteMarker: Optional[bool] + VersionId: Optional[str] + + exception = NoSuchKey( + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + DeleteMarker=True, + VersionId="version-id", + ) + + response = _botocore_error_serializer_integration_test( + "s3", + "GetObject", + exception, + "NoSuchKey", + 404, + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + ) + assert response["ResponseMetadata"]["HTTPHeaders"]["x-amz-delete-marker"] == "true" + assert response["ResponseMetadata"]["HTTPHeaders"]["x-amz-version-id"] == "version-id" + + +def test_json_protocol_error_serialization(): + class UserPoolTaggingException(ServiceException): + code: str = "UserPoolTaggingException" + sender_fault: bool = False + status_code: int = 400 + + exception = UserPoolTaggingException("Exception message!") + response = _botocore_error_serializer_integration_test( + "cognito-idp", + "CreateUserPool", + exception, + "UserPoolTaggingException", + 400, + "Exception message!", + ) + + # some clients also expect the X-Amzn-Errortype header according to + # https://awslabs.github.io/smithy/1.0/spec/aws/aws-json-1_1-protocol.html#operation-error-serialization + assert ( + response.get("ResponseMetadata", {}).get("HTTPHeaders", {}).get("x-amzn-errortype") + == "UserPoolTaggingException" + ) + + +def test_json_protocol_custom_error_serialization(): + exception = CommonServiceException( + "APIAccessCensorship", + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + status_code=451, + ) + response = _botocore_error_serializer_integration_test( + "cognito-idp", + "CreateUserPool", + exception, + "APIAccessCensorship", + 451, + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + ) + + # some clients also expect the X-Amzn-Errortype header according to + # https://awslabs.github.io/smithy/1.0/spec/aws/aws-json-1_1-protocol.html#operation-error-serialization + assert ( + response.get("ResponseMetadata", {}).get("HTTPHeaders", {}).get("x-amzn-errortype") + == "APIAccessCensorship" + ) + + +def test_json_protocol_error_serialization_with_additional_members(): + exception = TransactionCanceledException("Exception message!") + cancellation_reasons = [ + CancellationReason( + Code="TestCancellationReasonCode", + Message="TestCancellationReasonMessage", + Item={"TestAttributeName": AttributeValue(S="TestAttributeValue")}, + ) + ] + exception.CancellationReasons = cancellation_reasons + _botocore_error_serializer_integration_test( + "dynamodb", + "ExecuteTransaction", + exception, + "TransactionCanceledException", + 400, + "Exception message!", + CancellationReasons=cancellation_reasons, + Message="Exception message!", + ) + + +def test_json_protocol_error_serialization_with_shaped_default_members_on_root(): + exception = TransactionCanceledException("Exception message!") + service = load_service("dynamodb") + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model("ExecuteTransaction"), None, long_uid() + ) + body = serialized_response.data + parsed_body = json.loads(body) + # assert Message with first character in upper-case as specified in the specs + assert "Message" in parsed_body + assert "message" not in parsed_body + + +def test_rest_json_protocol_error_serialization_with_additional_members(): + class NotFoundException(ServiceException): + code: str = "NotFoundException" + sender_fault: bool = False + status_code: int = 404 + ResourceType: str + + exception = NotFoundException("Exception message!") + resource_type = "Resource Type Exception Message Body" + exception.ResourceType = resource_type + _botocore_error_serializer_integration_test( + "apigatewayv2", + "CreateApi", + exception, + "NotFoundException", + 404, + "Exception message!", + ResourceType=resource_type, + Message="Exception message!", + ) + + +def test_rest_json_protocol_error_serialization_with_shaped_default_members_on_root(): + exception = ResourceNotFoundException("Exception message!") + exception.Type = "User" + service = load_service("lambda") + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model("GetLayerVersion"), None, long_uid() + ) + body = serialized_response.data + parsed_body = json.loads(body) + # assert Message and Type with first character in upper-case as specified in the specs + assert "Message" in parsed_body + assert "message" not in parsed_body + assert "Type" in parsed_body + assert parsed_body["Type"] == "User" + assert "type" not in parsed_body + + +def test_query_protocol_error_serialization_with_additional_members(): + exception = VerificationException("Exception message!") + status = "Status Exception Message Body" + exception.Status = status + _botocore_error_serializer_integration_test( + "sns", + "VerifySMSSandboxPhoneNumber", + exception, + "VerificationException", + 400, + "Exception message!", + Status=status, + Message="Exception message!", + ) + + +def test_query_protocol_error_serialization_with_default_members_not_on_root(): + exception = VerificationException("Exception message!") + status = "Status Exception Message Body" + exception.Status = status + service = load_service("sns") + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model("VerifySMSSandboxPhoneNumber"), None, long_uid() + ) + body = serialized_response.data + parser = ElementTree.XMLParser(target=ElementTree.TreeBuilder()) + parser.feed(body) + root = parser.close() + # The root tag contains a possible namespace, f.e. {http://ec2.amazonaws.com/doc/2016-11-15}Response. + assert len([child for child in root if "Message" in child.tag]) == 0 + + +def test_rest_xml_protocol_error_serialization_with_default_members_not_on_root(): + exception = NoSuchHostedZone("Exception message!") + service = load_service("route53") + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model("DeleteHostedZone"), None, long_uid() + ) + body = serialized_response.data + parser = ElementTree.XMLParser(target=ElementTree.TreeBuilder()) + parser.feed(body) + root = parser.close() + # The root tag contains a possible namespace, f.e. {http://ec2.amazonaws.com/doc/2016-11-15}Response. + assert len([child for child in root if "Message" in child.tag]) == 0 + + +def test_rest_xml_protocol_error_serialization_with_additional_members(): + class InvalidObjectState(ServiceException): + code: str = "InvalidObjectState" + sender_fault: bool = False + status_code: int = 400 + StorageClass: str + AccessTier: str + + exception = InvalidObjectState("Exception message!") + exception.AccessTier = "ARCHIVE_ACCESS" + exception.StorageClass = "STANDARD" + _botocore_error_serializer_integration_test( + "s3", + "GetObject", + exception, + "InvalidObjectState", + 400, + "Exception message!", + AccessTier="ARCHIVE_ACCESS", + StorageClass="STANDARD", + ) + + +def test_json_protocol_content_type_1_0(): + """AppRunner defines the jsonVersion 1.0, therefore the Content-Type needs to be application/x-amz-json-1.0.""" + service = load_service("apprunner") + response_serializer = create_serializer(service) + result: Response = response_serializer.serialize_to_response( + {}, service.operation_model("DeleteConnection"), None, long_uid() + ) + assert result is not None + assert result.content_type is not None + assert result.content_type == "application/x-amz-json-1.0" + + +def test_json_protocol_content_type_1_1(): + """Logs defines the jsonVersion 1.1, therefore the Content-Type needs to be application/x-amz-json-1.1.""" + service = load_service("logs") + response_serializer = create_serializer(service) + result: Response = response_serializer.serialize_to_response( + {}, service.operation_model("DeleteLogGroup"), None, long_uid() + ) + assert result is not None + assert result.content_type is not None + assert result.content_type == "application/x-amz-json-1.1" + + +def test_json_serializer_cognito_with_botocore(): + parameters = { + "UserPool": { + "Id": "string", + "Name": "string", + "Policies": { + "PasswordPolicy": { + "MinimumLength": 123, + "RequireUppercase": True, + "RequireLowercase": True, + "RequireNumbers": True, + "RequireSymbols": True, + "TemporaryPasswordValidityDays": 123, + } + }, + "LambdaConfig": { + "PreSignUp": "string", + "CustomMessage": "string", + "PostConfirmation": "string", + "PreAuthentication": "string", + "PostAuthentication": "string", + "DefineAuthChallenge": "string", + "CreateAuthChallenge": "string", + "VerifyAuthChallengeResponse": "string", + "PreTokenGeneration": "string", + "UserMigration": "string", + "CustomSMSSender": {"LambdaVersion": "V1_0", "LambdaArn": "string"}, + "CustomEmailSender": {"LambdaVersion": "V1_0", "LambdaArn": "string"}, + "KMSKeyID": "string", + }, + "Status": "Enabled", + "LastModifiedDate": datetime(2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc()), + "CreationDate": datetime(2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc()), + "SchemaAttributes": [ + { + "Name": "string", + "AttributeDataType": "String", + "DeveloperOnlyAttribute": True, + "Mutable": True, + "Required": True, + "NumberAttributeConstraints": {"MinValue": "string", "MaxValue": "string"}, + "StringAttributeConstraints": {"MinLength": "string", "MaxLength": "string"}, + }, + ], + "AutoVerifiedAttributes": [ + "phone_number", + ], + "AliasAttributes": [ + "phone_number", + ], + "UsernameAttributes": [ + "phone_number", + ], + "SmsVerificationMessage": "string", + "EmailVerificationMessage": "string", + "EmailVerificationSubject": "string", + "VerificationMessageTemplate": { + "SmsMessage": "string", + "EmailMessage": "string", + "EmailSubject": "string", + "EmailMessageByLink": "string", + "EmailSubjectByLink": "string", + "DefaultEmailOption": "CONFIRM_WITH_LINK", + }, + "SmsAuthenticationMessage": "string", + "MfaConfiguration": "OFF", + "DeviceConfiguration": { + "ChallengeRequiredOnNewDevice": True, + "DeviceOnlyRememberedOnUserPrompt": True, + }, + "EstimatedNumberOfUsers": 123, + "EmailConfiguration": { + "SourceArn": "string", + "ReplyToEmailAddress": "string", + "EmailSendingAccount": "COGNITO_DEFAULT", + "From": "string", + "ConfigurationSet": "string", + }, + "SmsConfiguration": {"SnsCallerArn": "string", "ExternalId": "string"}, + "UserPoolTags": {"string": "string"}, + "SmsConfigurationFailure": "string", + "EmailConfigurationFailure": "string", + "Domain": "string", + "CustomDomain": "string", + "AdminCreateUserConfig": { + "AllowAdminCreateUserOnly": True, + "UnusedAccountValidityDays": 123, + "InviteMessageTemplate": { + "SMSMessage": "string", + "EmailMessage": "string", + "EmailSubject": "string", + }, + }, + "UserPoolAddOns": {"AdvancedSecurityMode": "OFF"}, + "UsernameConfiguration": {"CaseSensitive": True}, + "Arn": "string", + "AccountRecoverySetting": { + "RecoveryMechanisms": [ + {"Priority": 123, "Name": "verified_email"}, + ] + }, + } + } + _botocore_serializer_integration_test("cognito-idp", "DescribeUserPool", parameters) + + +def test_json_serializer_date_serialization_with_botocore(): + parameters = { + "UserPool": { + "LastModifiedDate": datetime(2022, 2, 8, 9, 17, 40, 122939, tzinfo=tzlocal()), + } + } + _botocore_serializer_integration_test("cognito-idp", "DescribeUserPool", parameters) + + +def test_restjson_protocol_error_serialization(): + class ThrottledException(ServiceException): + code: str = "ThrottledException" + sender_fault: bool = False + status_code: int = 429 + + exception = ThrottledException("Exception message!") + response = _botocore_error_serializer_integration_test( + "xray", + "UpdateSamplingRule", + exception, + "ThrottledException", + 429, + "Exception message!", + ) + + # some clients also expect the X-Amzn-Errortype header according to + # https://awslabs.github.io/smithy/1.0/spec/aws/aws-restjson1-protocol.html#operation-error-serialization + assert ( + response.get("ResponseMetadata", {}).get("HTTPHeaders", {}).get("x-amzn-errortype") + == "ThrottledException" + ) + + +def test_restjson_protocol_custom_error_serialization(): + exception = CommonServiceException( + "APIAccessCensorship", + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + status_code=451, + ) + response = _botocore_error_serializer_integration_test( + "xray", + "UpdateSamplingRule", + exception, + "APIAccessCensorship", + 451, + "You shall not access this API! Sincerely, your friendly neighbourhood firefighter.", + ) + + # some clients also expect the X-Amzn-Errortype header according to + # https://awslabs.github.io/smithy/1.0/spec/aws/aws-restjson1-protocol.html#operation-error-serialization + assert ( + response.get("ResponseMetadata", {}).get("HTTPHeaders", {}).get("x-amzn-errortype") + == "APIAccessCensorship" + ) + + +def test_restjson_serializer_xray_with_botocore(): + parameters = { + "SamplingRuleRecord": { + "SamplingRule": { + "RuleName": "string", + "RuleARN": "123456789001234567890", + "ResourceARN": "123456789001234567890", + "Priority": 123, + "FixedRate": 123.0, + "ReservoirSize": 123, + "ServiceName": "string", + "ServiceType": "string", + "Host": "string", + "HTTPMethod": "string", + "URLPath": "string", + "Version": 123, + "Attributes": {"string": "string"}, + }, + "CreatedAt": datetime(2015, 1, 1, 23, 59, 59, 6000, tzinfo=tzutc()), + "ModifiedAt": datetime(2015, 1, 1, 23, 59, 59, 1000, tzinfo=tzutc()), + } + } + + _botocore_serializer_integration_test("xray", "UpdateSamplingRule", parameters) + + +def test_restjson_header_target_serialization(): + """ + Tests the serialization of attributes into a specified header key based on this example from glacier: + + "InitiateJobOutput":{ + "type":"structure", + "members":{ + "location":{ + "shape":"string", + "location":"header", + "locationName":"Location" + }, + "jobId":{ + "shape":"string", + "location":"header", + "locationName":"x-amz-job-id" + }, + "jobOutputPath":{ + "shape":"string", + "location":"header", + "locationName":"x-amz-job-output-path" + } + }, + "documentation":"

Contains the Amazon S3 Glacier response to your request.

" + }, + """ + response = { + "location": "/here", + "jobId": "42069", + "jobOutputPath": "/there", + } + + result = _botocore_serializer_integration_test( + "glacier", + "InitiateJob", + response, + status_code=202, + ) + + headers = result["ResponseMetadata"]["HTTPHeaders"] + assert "location" in headers + assert "x-amz-job-id" in headers + assert "x-amz-job-output-path" in headers + assert "locationName" not in headers + assert "jobOutputPath" not in headers + + assert headers["location"] == "/here" + assert headers["x-amz-job-id"] == "42069" + assert headers["x-amz-job-output-path"] == "/there" + + +def test_restjson_headers_target_serialization(): + # SendApiAssetResponse + response = { + "Body": "hello", + "ResponseHeaders": { + "foo": "bar", + "baz": "ed", + }, + } + + # skipping assert here, because the response will contain all HTTP headers (given the nature of "ResponseHeaders" + # attribute). + result = _botocore_serializer_integration_test( + "dataexchange", "SendApiAsset", response, expected_response_content=_skip_assert + ) + + assert result["Body"] == "hello" + assert result["ResponseHeaders"]["foo"] == "bar" + assert result["ResponseHeaders"]["baz"] == "ed" + + headers = result["ResponseMetadata"]["HTTPHeaders"] + assert "foo" in headers + assert "baz" in headers + assert headers["foo"] == "bar" + assert headers["baz"] == "ed" + + +def test_restjson_statuscode_target_serialization(): + _botocore_serializer_integration_test( + "lambda", + "Invoke", + { + "StatusCode": 203, + "LogResult": "Log Message!", + "ExecutedVersion": "Latest", + "Payload": "test payload", + }, + status_code=203, + ) + + +def test_restjson_payload_serialization(): + """ + Tests the serialization of specific member attributes as payload, based on an appconfig example: + + "Configuration":{ + "type":"structure", + "members":{ + "Content":{ + "shape":"Blob", + }, + "ConfigurationVersion":{ + "shape":"Version", + "location":"header", + "locationName":"Configuration-Version" + }, + "ContentType":{ + "shape":"String", + "location":"header", + "locationName":"Content-Type" + } + }, + "payload":"Content" + }, + """ + + response = { + "Content": '{"foo": "bar"}', + "ConfigurationVersion": "123", + "ContentType": "application/json", + } + + result = _botocore_serializer_integration_test( + "appconfig", + "GetConfiguration", + response, + status_code=200, + ) + headers = result["ResponseMetadata"]["HTTPHeaders"] + assert "configuration-version" in headers + assert headers["configuration-version"] == "123" + assert headers["content-type"] == "application/json" + + +def test_restjson_none_serialization(): + parameters = { + "FunctionName": "test-name", + "VpcConfig": {"SubnetIds": None, "SecurityGroupIds": [None], "VpcId": "123"}, + "TracingConfig": None, + "DeadLetterConfig": {}, + } + expected = { + "FunctionName": "test-name", + "VpcConfig": {"SecurityGroupIds": [], "VpcId": "123"}, + "DeadLetterConfig": {}, + } + _botocore_serializer_integration_test( + "lambda", "CreateFunction", parameters, status_code=201, expected_response_content=expected + ) + exception = CommonServiceException("CodeVerificationFailedException", None) + _botocore_error_serializer_integration_test( + "lambda", + "CreateFunction", + exception, + "CodeVerificationFailedException", + 400, + "", + ) + + +def test_restxml_none_serialization(): + # Structure = None + _botocore_serializer_integration_test( + "route53", "ListHostedZonesByName", {}, expected_response_content={} + ) + # Structure Value = None + parameters = {"HostedZones": None} + _botocore_serializer_integration_test( + "route53", "ListHostedZonesByName", parameters, expected_response_content={} + ) + # List Value = None + parameters = {"HostedZones": [None]} + expected = {"HostedZones": []} + _botocore_serializer_integration_test( + "route53", "ListHostedZonesByName", parameters, expected_response_content=expected + ) + # Exception without a message + exception = CommonServiceException("NoSuchKeySigningKey", None) + _botocore_error_serializer_integration_test( + "route53", + "DeleteKeySigningKey", + exception, + "NoSuchKeySigningKey", + 400, + "", + ) + + +def test_restjson_int_header_serialization(): + response = { + "Configuration": '{"foo": "bar"}', + "ContentType": "application/json", + "NextPollConfigurationToken": "abcdefg", + "NextPollIntervalInSeconds": 42, + } + _botocore_serializer_integration_test("appconfigdata", "GetLatestConfiguration", response) + + +def test_ec2_serializer_ec2_with_botocore(): + parameters = { + "InstanceEventWindow": { + "InstanceEventWindowId": "string", + "TimeRanges": [ + { + "StartWeekDay": "sunday", + "StartHour": 123, + "EndWeekDay": "sunday", + "EndHour": 123, + }, + ], + "Name": "string", + "CronExpression": "string", + "AssociationTarget": { + "InstanceIds": [ + "string", + ], + "Tags": [ + {"Key": "string", "Value": "string"}, + ], + "DedicatedHostIds": [ + "string", + ], + }, + "State": "creating", + "Tags": [ + {"Key": "string", "Value": "string"}, + ], + } + } + + _botocore_serializer_integration_test("ec2", "CreateInstanceEventWindow", parameters) + + +def test_ec2_serializer_ec2_with_empty_response(): + _botocore_serializer_integration_test("ec2", "CreateTags", {}) + + +def test_ec2_protocol_custom_error_serialization(): + exception = CommonServiceException( + "IdempotentParameterMismatch", "Different payload, same token?!" + ) + _botocore_error_serializer_integration_test( + "ec2", + "StartInstances", + exception, + "IdempotentParameterMismatch", + 400, + "Different payload, same token?!", + ) + + +def test_ec2_protocol_errors_have_response_root_element(): + exception = CommonServiceException( + "InvalidSubnetID.NotFound", "The subnet ID 'vpc-test' does not exist" + ) + service = load_service("ec2") + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model("DescribeSubnets"), None, long_uid() + ) + body = serialized_response.data + parser = ElementTree.XMLParser(target=ElementTree.TreeBuilder()) + parser.feed(body) + root = parser.close() + # The root tag contains a possible namespace, f.e. {http://ec2.amazonaws.com/doc/2016-11-15}Response. + assert re.sub(r"^{.*}", "", root.tag) == "Response" + + +def test_restxml_s3_errors_have_error_root_element(): + exception = CommonServiceException("NoSuchBucket", "The specified bucket does not exist") + service = load_service("s3") + response_serializer = create_serializer(service) + serialized_response = response_serializer.serialize_error_to_response( + exception, service.operation_model("GetObject"), None, long_uid() + ) + body = serialized_response.data + parser = ElementTree.XMLParser(target=ElementTree.TreeBuilder()) + parser.feed(body) + root = parser.close() + assert root.tag == "Error" + + +def test_restxml_without_output_shape(): + _botocore_serializer_integration_test("cloudfront", "DeleteDistribution", {}, status_code=204) + + +def test_restxml_header_location(): + """Tests fields with the location trait "header" for rest-xml.""" + _botocore_serializer_integration_test( + "cloudfront", + "CreateCloudFrontOriginAccessIdentity", + { + "Location": "location-header-field", + "ETag": "location-etag-field", + "CloudFrontOriginAccessIdentity": {}, + }, + status_code=201, + ) + # Test a boolean header location field + parameters = { + "ContentLength": 0, + "Body": "", + "DeleteMarker": True, + "ContentType": "string", + "Metadata": {"string": "string"}, + "StatusCode": 200, + } + _botocore_serializer_integration_test("s3", "GetObject", parameters) + + +def test_restxml_headers_location(): + """Tests fields with the location trait "headers" for rest-xml.""" + _botocore_serializer_integration_test( + "s3", + "HeadObject", + { + "DeleteMarker": False, + "Metadata": {"headers_key1": "headers_value1", "headers_key2": "headers_value2"}, + "ContentType": "application/octet-stream", + # The content length should explicitly be tested here. + "ContentLength": 159, + "StatusCode": 200, + }, + ) + + +def test_restjson_header_location(): + """Tests fields with the location trait "header" for rest-xml.""" + _botocore_serializer_integration_test( + "ebs", "GetSnapshotBlock", {"BlockData": "binary-data", "DataLength": 15} + ) + + +def test_restjson_headers_location(): + """Tests fields with the location trait "headers" for rest-json.""" + response = _botocore_serializer_integration_test( + "dataexchange", + "SendApiAsset", + { + "ResponseHeaders": {"headers_key1": "headers_value1", "headers_key2": "headers_value2"}, + }, + expected_response_content=_skip_assert, + ) + # The spec does not define a locationName for ResponseHeaders, which means there is no header field prefix. + # Therefore, _all_ header fields are parsed by botocore (which is technically correct). + # We only check if the two header fields are present. + assert "ResponseHeaders" in response + assert "headers_key1" in response["ResponseHeaders"] + assert "headers_key2" in response["ResponseHeaders"] + assert "headers_value1" == response["ResponseHeaders"]["headers_key1"] + assert "headers_value2" == response["ResponseHeaders"]["headers_key2"] + + +def _iterable_to_stream(iterable, buffer_size=io.DEFAULT_BUFFER_SIZE): + """ + Concerts a given iterable (generator) to a stream for testing purposes. + This wrapper does not "real streaming". The event serializer will wait for the end of the stream / generator. + """ + + class IterStream(io.RawIOBase): + def __init__(self): + self.leftover = None + + def readable(self): + return True + + def readinto(self, b): + try: + chunk = next(iterable) + b[: len(chunk)] = chunk + return len(chunk) + except StopIteration: + return 0 + + return io.BufferedReader(IterStream(), buffer_size=buffer_size) + + +def test_json_event_streaming(): + # Create test events from Kinesis' SubscribeToShard operation + event_1 = { + "SubscribeToShardEvent": { + "Records": [ + { + "SequenceNumber": "1", + "Data": b"event_data_1_record_1", + "PartitionKey": "event_1_partition_key_record_1", + }, + { + "SequenceNumber": "2", + "Data": b"event_data_1_record_2", + "PartitionKey": "event_1_partition_key_record_1", + }, + ], + "ContinuationSequenceNumber": "1", + "MillisBehindLatest": 1337, + } + } + event_2 = { + "SubscribeToShardEvent": { + "Records": [ + { + "SequenceNumber": "3", + "Data": b"event_data_2_record_1", + "PartitionKey": "event_2_partition_key_record_1", + }, + { + "SequenceNumber": "4", + "Data": b"event_data_2_record_2", + "PartitionKey": "event_2_partition_key_record_2", + }, + ], + "ContinuationSequenceNumber": "2", + "MillisBehindLatest": 1338, + } + } + + # Create the response which contains the generator + def event_generator() -> Iterator: + yield event_1 + yield event_2 + + response = {"EventStream": event_generator()} + _botocore_event_streaming_test( + "kinesis", "SubscribeToShard", response, "EventStream", [event_1, event_2] + ) + + +def test_s3_event_streaming(): + event_1 = {"Records": {"Payload": b"Streamed-Body-Content"}} + event_2 = {"End": {}} + + # Create the response which contains the generator + def event_generator() -> Iterator: + yield event_1 + yield event_2 + + response = {"Payload": event_generator()} + _botocore_event_streaming_test( + "s3", "SelectObjectContent", response, "Payload", [event_1, event_2] + ) + + +def test_all_non_existing_key(): + """Tests the different protocols to allow non-existing keys in structures / dicts.""" + # query + _botocore_serializer_integration_test( + "cloudformation", + "DetectStackResourceDrift", + { + "StackResourceDrift": { + "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/MyStack/d0a825a0-e4cd-xmpl-b9fb-061c69e99204", + "unknown": {"foo": "bar"}, + } + }, + expected_response_content={ + "StackResourceDrift": { + "StackId": "arn:aws:cloudformation:us-west-2:123456789012:stack/MyStack/d0a825a0-e4cd-xmpl-b9fb-061c69e99204", + } + }, + ) + # ec2 + _botocore_serializer_integration_test( + "ec2", + "CreateInstanceEventWindow", + { + "InstanceEventWindow": { + "InstanceEventWindowId": "string", + "unknown": {"foo": "bar"}, + }, + "unknown": {"foo": "bar"}, + }, + expected_response_content={ + "InstanceEventWindow": { + "InstanceEventWindowId": "string", + } + }, + ) + # json + _botocore_serializer_integration_test( + "cognito-idp", + "DescribeUserPool", + { + "UserPool": { + "Id": "string", + "Unknown": "Ignored", + } + }, + expected_response_content={ + "UserPool": { + "Id": "string", + } + }, + ) + # rest-json + _botocore_serializer_integration_test( + "xray", + "UpdateSamplingRule", + { + "SamplingRuleRecord": { + "SamplingRule": { + "ResourceARN": "123456789001234567890", + "Unknown": "Ignored", + }, + } + }, + expected_response_content={ + "SamplingRuleRecord": { + "SamplingRule": { + "ResourceARN": "123456789001234567890", + }, + } + }, + ) + # rest-xml + _botocore_serializer_integration_test( + "cloudfront", + "TestFunction", + { + "TestResult": { + "FunctionErrorMessage": "string", + }, + "Unknown": "Ignored", + }, + expected_response_content={ + "TestResult": { + "FunctionErrorMessage": "string", + }, + }, + ) + + +def test_no_mutation_of_parameters(): + service = load_service("appconfig") + response_serializer = create_serializer(service) + + parameters = { + "ApplicationId": "app_id", + "ConfigurationProfileId": "conf_id", + "VersionNumber": 1, + "Content": b'{"Id":"foo"}', + "ContentType": "application/json", + } + expected = parameters.copy() + + # serialize response and check whether parameters are unchanged + _ = response_serializer.serialize_to_response( + parameters, service.operation_model("CreateHostedConfigurationVersion"), None, long_uid() + ) + assert parameters == expected + + +def test_serializer_error_on_protocol_error_invalid_exception(): + """Test that the serializer raises a ProtocolSerializerError in case of invalid exception to serialize.""" + service = load_service("sqs") + operation_model = service.operation_model("SendMessage") + serializer = QueryResponseSerializer() + with pytest.raises(ProtocolSerializerError): + # a known protocol error would be if we try to serialize an exception which is not a CommonServiceException and + # also not a generated exception + serializer.serialize_error_to_response( + NotImplementedError(), operation_model, None, long_uid() + ) + + +def test_serializer_error_on_protocol_error_invalid_data(): + """Test that the serializer raises a ProtocolSerializerError in case of invalid data to serialize.""" + service = load_service("dynamodbstreams") + operation_model = service.operation_model("DescribeStream") + serializer = QueryResponseSerializer() + with pytest.raises(ProtocolSerializerError): + serializer.serialize_to_response( + {"StreamDescription": {"CreationRequestDateTime": "invalid_timestamp"}}, + operation_model, + None, + long_uid(), + ) + + +def test_serializer_error_on_unknown_error(): + """Test that the serializer raises a UnknownSerializerError in case of an unknown exception.""" + service = load_service("sqs") + operation_model = service.operation_model("SendMessage") + serializer = QueryResponseSerializer() + + # An unknown error is obviously hard to trigger (because we would fix it if we would know of a way to trigger it), + # therefore we patch a function to raise an unexpected error + def raise_error(*args, **kwargs): + raise NotImplementedError() + + serializer._serialize_response = raise_error + with pytest.raises(UnknownSerializerError): + serializer.serialize_to_response({}, operation_model, None, long_uid()) + + +class ComparableBytesIO(BytesIO): + """ + BytesIO object that's treated like a value object when comparing it to other streams. + """ + + def __eq__(self, other): + if hasattr(other, "read"): + return other.read() == self.read() + + if isinstance(other, ResponseStream): + return other.response.data == self.read() + + return super(ComparableBytesIO, self).__eq__(other) + + +class ComparableBytesList(list): + """ + Makes a list of bytes comparable to strings. + """ + + def __eq__(self, other): + if isinstance(other, str): + return b"".join(self) == other.encode("utf-8") + + return super(ComparableBytesList, self).__eq__(other) + + +class ComparableBytesIterator(Iterator[bytes]): + def __init__(self, bytes_list: List[bytes]): + self.gen = iter(bytes_list) + self.value = b"".join(bytes_list) + + def __next__(self) -> bytes: + return next(self.gen) + + def __iter__(self) -> Iterator[bytes]: + return self.gen + + def __eq__(self, other): + if hasattr(other, "read"): + return other.read() == self.value + + if isinstance(other, ResponseStream): + return other.response.data == self.value + + return super(ComparableBytesIterator, self).__eq__(other) + + +@pytest.mark.parametrize( + "payload", + [ + "", + ComparableBytesList([b"<", b"foo-bar", b"/>"]), + ComparableBytesIO(b""), + ComparableBytesIterator([b"<", b"foo-bar", b"/>"]), + ], + ids=["Literal", "List[byte]", "IO[byte]", "Iterator[byte]"], +) +def test_restxml_streaming_payload(payload): + """Tests an operation where the payload can be streaming for rest-xml. We're testing four cases, + two non-streaming and two streaming: a literal, a list (that's treated specially by werkzeug), a file-like + ``IO[bytes]`` object, and an iterator. Since the _botocore_serializer_integration_test does equality checks on + parameters, and we're receiving different objects for streams, we wrap the payloads in custom classes that can be + compared to strings.""" + parameters = { + "ContentLength": 10, + "Body": payload, + "ContentType": "text/xml", + "Metadata": {}, + "StatusCode": 200, + } + _botocore_serializer_integration_test("s3", "GetObject", parameters) + + +@pytest.mark.parametrize( + "payload", + [ + '{"foo":"bar"}', + ComparableBytesList([b"{", b'"foo"', b":", b'"bar"', b"}"]), + ComparableBytesIO(b'{"foo":"bar"}'), + ComparableBytesIterator([b"{", b'"foo"', b":", b'"bar"', b"}"]), + ], + ids=["Literal", "List[byte]", "IO[byte]", "Iterator[byte]"], +) +def test_restjson_streaming_payload(payload): + """See docs for ``test_restxml_streaming_payload``.""" + _botocore_serializer_integration_test( + "lambda", + "Invoke", + { + "StatusCode": 200, + "Payload": payload, + }, + ) + + +@pytest.mark.parametrize( + "service,accept_header,content_type_header,expected_mime_type", + [ + # Test default S3 + ("s3", None, None, "application/xml"), + # Test default STS + ("sts", None, None, "text/xml"), + # Test STS for "any" Accept header + ("sts", "*/*", None, "text/xml"), + # Test STS for "any" Accept header and xml content + ("sts", "*/*", "text/xml", "text/xml"), + # Test STS without Accept and xml content + ("sts", None, "text/xml", "text/xml"), + # Test STS without Accept and JSON content + ("sts", None, "application/json", "application/json"), + # Test STS with JSON Accept and XML content + ("sts", "application/json", "text/xml", "application/json"), + # Test default Kinesis + ("kinesis", None, None, "application/json"), + # Test Kinesis for "any" Accept header + ("kinesis", "*/*", None, "application/json"), + # Test Kinesis for "any" Accept header and JSON content + ("kinesis", "*/*", "application/json", "application/json"), + # Test Kinesis without Accept and CBOR content + ("kinesis", None, "application/cbor", "application/cbor"), + # Test Kinesis without Accept and CBOR content + ("kinesis", None, "application/cbor", "application/cbor"), + # Test Kinesis with JSON Accept and CBOR content + ("kinesis", "application/json", "application/cbor", "application/json"), + # Test Kinesis with CBOR Accept and JSON content + ("kinesis", "application/cbor", "application/json", "application/cbor"), + # Test Kinesis with CBOR 1.1 Accept and JSON content + ("kinesis", APPLICATION_AMZ_CBOR_1_1, "application/json", APPLICATION_AMZ_CBOR_1_1), + # Test Kinesis with non-supported Accept header and without Content-Type + ("kinesis", "unknown/content-type", None, "application/json"), + # Test Kinesis with non-supported Accept header and CBOR Content-Type + ("kinesis", "unknown/content-type", "application/cbor", "application/json"), + # Test Kinesis with non-supported Content-Type + ("kinesis", None, "unknown/content-type", "application/json"), + ], +) +def test_accept_header_detection( + service: str, + accept_header: Optional[str], + content_type_header: Optional[str], + expected_mime_type: str, +): + service_model = load_service(service) + response_serializer = create_serializer(service_model) + headers = Headers() + if accept_header: + headers["Accept"] = accept_header + if content_type_header: + headers["Content-Type"] = content_type_header + mime_type = response_serializer._get_mime_type(headers) + assert mime_type == expected_mime_type, ( + f"Detected mime type ({mime_type}) was not as expected ({expected_mime_type})" + ) + + +@pytest.mark.parametrize( + "headers_dict", + [{"Content-Type": "application/json"}, {"Accept": "application/json"}], +) +def test_query_protocol_json_serialization(headers_dict): + service = load_service("sts") + response_serializer = create_serializer(service) + headers = Headers(headers_dict) + utc_timestamp = 1661255665.123 + response_data = GetSessionTokenResponse( + Credentials=Credentials( + AccessKeyId="accessKeyId", + SecretAccessKey="secretAccessKey", + SessionToken="sessionToken", + Expiration=datetime.utcfromtimestamp(utc_timestamp), + ) + ) + result: Response = response_serializer.serialize_to_response( + response_data, service.operation_model("GetSessionToken"), headers, long_uid() + ) + assert result is not None + assert result.content_type is not None + assert result.content_type == "application/json" + parsed_data = json.loads(result.data) + # Ensure the structure is the same as for query-xml (f.e. with "SOAP"-like root element), but just JSON encoded + assert "GetSessionTokenResponse" in parsed_data + assert "ResponseMetadata" in parsed_data["GetSessionTokenResponse"] + assert "GetSessionTokenResult" in parsed_data["GetSessionTokenResponse"] + # Make sure the timestamp is formatted as str(int(utc float)) + assert parsed_data["GetSessionTokenResponse"]["GetSessionTokenResult"].get( + "Credentials", {} + ).get("Expiration") == str(int(utc_timestamp)) + + +@pytest.mark.parametrize( + "headers_dict", + [{"Content-Type": "application/cbor"}, {"Accept": "application/cbor"}], +) +def test_json_protocol_cbor_serialization(headers_dict): + service = load_service("kinesis") + response_serializer = create_serializer(service) + headers = Headers(headers_dict) + response_data = GetRecordsOutput( + Records=[ + Record( + SequenceNumber="test_sequence_number", + Data=b"test_data", + PartitionKey="test_partition_key", + ) + ] + ) + result: Response = response_serializer.serialize_to_response( + response_data, service.operation_model("GetRecords"), headers, long_uid() + ) + assert result is not None + assert result.content_type is not None + assert result.content_type == "application/cbor" + parsed_data = cbor2_loads(result.data) + assert parsed_data == response_data + + +class TestAwsResponseSerializerDecorator: + def test_query_internal_error(self): + @aws_response_serializer("sqs-query", "ListQueues") + def fn(request: Request): + raise ValueError("oh noes!") + + response = fn(Request("POST", "/", body="Action=ListQueues")) + assert response.status_code == 500 + assert b"InternalError" in response.data + + def test_query_service_error(self): + @aws_response_serializer("sqs-query", "ListQueues") + def fn(request: Request): + raise UnsupportedOperation("Operation not supported.") + + response = fn(Request("POST", "/", body="Action=ListQueues")) + assert response.status_code == 400 + assert b"AWS.SimpleQueueService.UnsupportedOperation" in response.data + assert b"Operation not supported." in response.data + + def test_query_valid_response(self): + @aws_response_serializer("sqs-query", "ListQueues") + def fn(request: Request): + from localstack.aws.api.sqs import ListQueuesResult + + return ListQueuesResult( + QueueUrls=[ + "https://localhost:4566/000000000000/my-queue-1", + "https://localhost:4566/000000000000/my-queue-2", + ] + ) + + response = fn(Request("POST", "/", body="Action=ListQueues")) + assert response.status_code == 200 + assert ( + b"https://localhost:4566/000000000000/my-queue-1" in response.data + ) + assert ( + b"https://localhost:4566/000000000000/my-queue-2" in response.data + ) + + def test_query_valid_response_content_negotiation(self): + # this test verifies that request header values are passed correctly to perform content negotation + @aws_response_serializer("sqs-query", "ListQueues") + def fn(request: Request): + from localstack.aws.api.sqs import ListQueuesResult + + return ListQueuesResult( + QueueUrls=[ + "https://localhost:4566/000000000000/my-queue-1", + "https://localhost:4566/000000000000/my-queue-2", + ] + ) + + response = fn( + Request("POST", "/", body="Action=ListQueues", headers={"Accept": "application/json"}) + ) + assert response.status_code == 200 + assert response.json["ListQueuesResponse"]["ListQueuesResult"] == { + "QueueUrl": [ + "https://localhost:4566/000000000000/my-queue-1", + "https://localhost:4566/000000000000/my-queue-2", + ] + } + + def test_return_invalid_none_type_causes_internal_error(self): + @aws_response_serializer("sqs-query", "ListQueues") + def fn(request: Request): + return None + + response = fn(Request("POST", "/", body="Action=ListQueues")) + assert response.status_code == 500 + assert b"InternalError" in response.data + + def test_response_pass_through(self): + # returning a response directly will forego the serializer + @aws_response_serializer("sqs-query", "ListQueues") + def fn(request: Request): + return Response(b"ok", status=201) + + response = fn(Request("POST", "/", body="Action=ListQueues")) + assert response.status_code == 201 + assert response.data == b"ok" + + def test_invoke_using_kwargs(self): + @aws_response_serializer("sqs", "ListQueues") + def fn(request: Request): + return Response(b"ok", status=201) + + response = fn(request=Request("POST", "/", body="Action=ListQueues")) + assert response.status_code == 201 + assert response.data == b"ok" + + def test_invoke_on_bound_method(self): + class MyHandler: + @aws_response_serializer("sqs-query", "ListQueues") + def handle(self, request: Request): + from localstack.aws.api.sqs import ListQueuesResult + + return ListQueuesResult( + QueueUrls=[ + "https://localhost:4566/000000000000/my-queue-1", + "https://localhost:4566/000000000000/my-queue-2", + ] + ) + + response = MyHandler().handle( + Request("POST", "/", body="Action=ListQueues", headers={"Accept": "application/json"}) + ) + assert response.status_code == 200 + assert response.json["ListQueuesResponse"]["ListQueuesResult"] == { + "QueueUrl": [ + "https://localhost:4566/000000000000/my-queue-1", + "https://localhost:4566/000000000000/my-queue-2", + ] + } diff --git a/tests/unit/aws/test_chain.py b/tests/unit/aws/test_chain.py new file mode 100644 index 0000000000000..c2ddaf91ef1e8 --- /dev/null +++ b/tests/unit/aws/test_chain.py @@ -0,0 +1,154 @@ +from unittest import mock + +from localstack.aws.api import RequestContext +from localstack.aws.chain import CompositeHandler, HandlerChain +from localstack.http import Response + + +class TestCompositeHandler: + def test_composite_handler_stops_handler_chain(self): + def inner1(_chain: HandlerChain, request: RequestContext, response: Response): + _chain.stop() + + inner2 = mock.MagicMock() + outer1 = mock.MagicMock() + outer2 = mock.MagicMock() + response1 = mock.MagicMock() + finalizer = mock.MagicMock() + + chain = HandlerChain() + + composite = CompositeHandler() + composite.handlers.append(inner1) + composite.handlers.append(inner2) + + chain.request_handlers.append(outer1) + chain.request_handlers.append(composite) + chain.request_handlers.append(outer2) + chain.response_handlers.append(response1) + chain.finalizers.append(finalizer) + + chain.handle(RequestContext(None), Response()) + outer1.assert_called_once() + outer2.assert_not_called() + inner2.assert_not_called() + response1.assert_called_once() + finalizer.assert_called_once() + + def test_composite_handler_terminates_handler_chain(self): + def inner1(_chain: HandlerChain, request: RequestContext, response: Response): + _chain.terminate() + + inner2 = mock.MagicMock() + outer1 = mock.MagicMock() + outer2 = mock.MagicMock() + response1 = mock.MagicMock() + finalizer = mock.MagicMock() + + chain = HandlerChain() + + composite = CompositeHandler() + composite.handlers.append(inner1) + composite.handlers.append(inner2) + + chain.request_handlers.append(outer1) + chain.request_handlers.append(composite) + chain.request_handlers.append(outer2) + chain.response_handlers.append(response1) + chain.finalizers.append(finalizer) + + chain.handle(RequestContext(None), Response()) + outer1.assert_called_once() + outer2.assert_not_called() + inner2.assert_not_called() + response1.assert_not_called() + finalizer.assert_called_once() + + def test_composite_handler_with_not_return_on_stop(self): + def inner1(_chain: HandlerChain, request: RequestContext, response: Response): + _chain.stop() + + inner2 = mock.MagicMock() + outer1 = mock.MagicMock() + outer2 = mock.MagicMock() + response1 = mock.MagicMock() + finalizer = mock.MagicMock() + + chain = HandlerChain() + + composite = CompositeHandler(return_on_stop=False) + composite.handlers.append(inner1) + composite.handlers.append(inner2) + + chain.request_handlers.append(outer1) + chain.request_handlers.append(composite) + chain.request_handlers.append(outer2) + chain.response_handlers.append(response1) + chain.finalizers.append(finalizer) + + chain.handle(RequestContext(None), Response()) + outer1.assert_called_once() + outer2.assert_not_called() + inner2.assert_called_once() + response1.assert_called_once() + finalizer.assert_called_once() + + def test_composite_handler_continues_handler_chain(self): + inner1 = mock.MagicMock() + inner2 = mock.MagicMock() + outer1 = mock.MagicMock() + outer2 = mock.MagicMock() + response1 = mock.MagicMock() + finalizer = mock.MagicMock() + + chain = HandlerChain() + + composite = CompositeHandler() + composite.handlers.append(inner1) + composite.handlers.append(inner2) + + chain.request_handlers.append(outer1) + chain.request_handlers.append(composite) + chain.request_handlers.append(outer2) + chain.response_handlers.append(response1) + chain.finalizers.append(finalizer) + + chain.handle(RequestContext(None), Response()) + outer1.assert_called_once() + outer2.assert_called_once() + inner1.assert_called_once() + inner2.assert_called_once() + response1.assert_called_once() + finalizer.assert_called_once() + + def test_composite_handler_exception_calls_outer_exception_handlers(self): + def inner1(_chain: HandlerChain, request: RequestContext, response: Response): + raise ValueError() + + inner2 = mock.MagicMock() + outer1 = mock.MagicMock() + outer2 = mock.MagicMock() + exception_handler = mock.MagicMock() + response1 = mock.MagicMock() + finalizer = mock.MagicMock() + + chain = HandlerChain() + + composite = CompositeHandler() + composite.handlers.append(inner1) + composite.handlers.append(inner2) + + chain.request_handlers.append(outer1) + chain.request_handlers.append(composite) + chain.request_handlers.append(outer2) + chain.exception_handlers.append(exception_handler) + chain.response_handlers.append(response1) + chain.finalizers.append(finalizer) + + chain.handle(RequestContext(None), Response()) + outer1.assert_called_once() + outer2.assert_not_called() + inner2.assert_not_called() + exception_handler.assert_called_once() + response1.assert_called_once() + finalizer.assert_called_once() diff --git a/tests/unit/aws/test_client.py b/tests/unit/aws/test_client.py new file mode 100644 index 0000000000000..131620babb9aa --- /dev/null +++ b/tests/unit/aws/test_client.py @@ -0,0 +1,135 @@ +import boto3 +import pytest + +from localstack.aws.api import RequestContext, ServiceException +from localstack.aws.client import ( + GatewayShortCircuit, + _ResponseStream, + botocore_in_memory_endpoint_patch, + parse_service_exception, +) +from localstack.aws.connect import get_service_endpoint +from localstack.http import Response + + +def test_parse_service_exception(): + response = Response(status=400) + parsed_response = { + "Error": { + "Code": "InvalidSubnetID.NotFound", + "Message": "The subnet ID 'vpc-test' does not exist", + } + } + exception = parse_service_exception(response, parsed_response) + assert exception + assert isinstance(exception, ServiceException) + assert exception.code == "InvalidSubnetID.NotFound" + assert exception.message == "The subnet ID 'vpc-test' does not exist" + assert exception.status_code == 400 + assert not exception.sender_fault + # Ensure that the parsed exception does not have the "Error" field from the botocore response dict + assert not hasattr(exception, "Error") + assert not hasattr(exception, "error") + + +class TestResponseStream: + def test_read(self): + response = Response(b"foobar") + + with _ResponseStream(response) as stream: + assert stream.read(3) == b"foo" + assert stream.read(3) == b"bar" + + def test_read_with_generator_response(self): + def _gen(): + yield b"foo" + yield b"bar" + + response = Response(_gen()) + + with _ResponseStream(response) as stream: + assert stream.read(2) == b"fo" + # currently the response stream will not buffer across the next line + assert stream.read(4) == b"o" + assert stream.read(4) == b"bar" + + def test_as_iterator(self): + def _gen(): + yield b"foo" + yield b"bar" + + response = Response(_gen()) + + with _ResponseStream(response) as stream: + assert next(stream) == b"foo" + assert next(stream) == b"bar" + with pytest.raises(StopIteration): + next(stream) + + +class TestGatewayShortCircuit: + @pytest.fixture(scope="class", autouse=True) + def patch_boto_endpoint(self): + if botocore_in_memory_endpoint_patch.is_applied: + return + + botocore_in_memory_endpoint_patch.apply() + yield + botocore_in_memory_endpoint_patch.undo() + + def test_query_request(self): + class MockGateway: + def handle(self, context: RequestContext, response: Response): + assert context.operation.name == "DeleteTopic" + assert context.service.service_name == "sns" + assert context.service_request == { + "TopicArn": "arn:aws:sns:us-east-1:000000000000:test-topic", + "Action": "DeleteTopic", + "Version": "2010-03-31", + } + data = b""" + + f3aa9ac9-3c3d-11df-8235-9dab105e9c32 + + """ + response.data = data + response.status_code = 200 + + gateway = MockGateway() + + client = boto3.client("sns", endpoint_url=get_service_endpoint()) + GatewayShortCircuit.modify_client(client, gateway) + delete_topic = client.delete_topic(TopicArn="arn:aws:sns:us-east-1:000000000000:test-topic") + assert delete_topic["ResponseMetadata"]["HTTPStatusCode"] == 200 + + def test_query_exception(self): + class MockGateway: + def handle(self, context: RequestContext, response: Response): + raise ValueError("oh noes") + + gateway = MockGateway() + + client = boto3.client("sns", endpoint_url=get_service_endpoint()) + GatewayShortCircuit.modify_client(client, gateway) + + # FIXME currently, exceptions in the gateway will be handed down to the client and not translated into 500 + # errors + with pytest.raises(ValueError): + client.list_topics() + + def test_query_response(self): + class MockGateway: + def handle(self, context: RequestContext, response: Response): + response.data = b"arn:aws:sns:us-east-1:000000000000:test-1d5a154d" + response.status_code = 202 + + gateway = MockGateway() + + client = boto3.client("sns", endpoint_url=get_service_endpoint()) + GatewayShortCircuit.modify_client(client, gateway) + + list_topics = client.list_topics() + assert list_topics["Topics"] == [ + {"TopicArn": "arn:aws:sns:us-east-1:000000000000:test-1d5a154d"} + ] + assert list_topics["ResponseMetadata"]["HTTPStatusCode"] == 202 diff --git a/tests/unit/aws/test_connect.py b/tests/unit/aws/test_connect.py new file mode 100644 index 0000000000000..85962c4b41964 --- /dev/null +++ b/tests/unit/aws/test_connect.py @@ -0,0 +1,483 @@ +from unittest.mock import ANY, MagicMock, patch + +import boto3 +import botocore +import pytest +from botocore.config import Config + +from localstack.aws.api import RequestContext +from localstack.aws.chain import Handler, HandlerChain +from localstack.aws.connect import ( + ExternalAwsClientFactory, + ExternalClientFactory, + InternalClientFactory, + attribute_name_to_service_name, +) +from localstack.aws.gateway import Gateway +from localstack.aws.handlers import add_internal_request_params, add_region_from_header +from localstack.config import HostAndPort +from localstack.constants import INTERNAL_AWS_SECRET_ACCESS_KEY +from localstack.http import Response +from localstack.http.duplex_socket import enable_duplex_socket +from localstack.http.hypercorn import GatewayServer +from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID +from localstack.utils.aws.client_types import ServicePrincipal +from localstack.utils.aws.request_context import extract_access_key_id_from_auth_header +from localstack.utils.net import get_free_tcp_port + + +class TestClientFactory: + @pytest.fixture + def create_dummy_request_parameter_gateway(self): + server = None + + def _create(request_handlers: list[Handler]) -> str: + nonlocal server + + # explicitly enable the duplex socket support here + enable_duplex_socket() + + gateway = Gateway() + gateway.request_handlers.append(add_internal_request_params) + for handler in request_handlers: + gateway.request_handlers.append(handler) + port = get_free_tcp_port() + gateway_listen = HostAndPort(host="127.0.0.1", port=port) + server = GatewayServer(gateway, gateway_listen, use_ssl=True) + server.start() + server.wait_is_up(timeout=10) + return f"http://localhost:{port}" + + yield _create + if server: + server.shutdown() + + def test_internal_client_dto_is_registered(self): + factory = InternalClientFactory() + factory._session = MagicMock() + + mock = factory.get_client("sns", "eu-central-1") + mock.meta.events.register.assert_called_with("before-call.*.*", handler=ANY) + + def test_external_client_dto_is_not_registered(self): + factory = ExternalClientFactory() + factory._session = MagicMock() + + mock = factory.get_client( + "sqs", "eu-central-1", aws_access_key_id="foo", aws_secret_access_key="bar" + ) + mock.meta.events.register.assert_not_called() + + @patch.object(ExternalClientFactory, "_get_client") + def test_external_client_credentials_origin(self, mock, region_name, monkeypatch): + connect_to = ExternalClientFactory(use_ssl=True) + connect_to.get_client( + "abc", region_name="xx-south-1", aws_access_key_id="foo", aws_secret_access_key="bar" + ) + mock.assert_called_once_with( + service_name="abc", + region_name="xx-south-1", + use_ssl=True, + verify=False, + endpoint_url="http://localhost:4566", + aws_access_key_id="foo", + aws_secret_access_key="bar", + aws_session_token=None, + config=connect_to._config, + ) + + mock.reset_mock() + + connect_to.get_client( + "def", region_name=None, aws_secret_access_key=None, aws_access_key_id=None + ) + mock.assert_called_once_with( + service_name="def", + region_name=region_name, + use_ssl=True, + verify=False, + endpoint_url="http://localhost:4566", + aws_access_key_id=None, + aws_secret_access_key=None, + aws_session_token=None, + config=connect_to._config, + ) + + mock.reset_mock() + + connect_to.get_client("def", region_name=None, aws_access_key_id=TEST_AWS_ACCESS_KEY_ID) + mock.assert_called_once_with( + service_name="def", + region_name=region_name, + use_ssl=True, + verify=False, + endpoint_url="http://localhost:4566", + aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, + aws_secret_access_key=INTERNAL_AWS_SECRET_ACCESS_KEY, + aws_session_token=None, + config=connect_to._config, + ) + + @patch.object(ExternalAwsClientFactory, "_get_client") + def test_external_aws_client_credentials_loaded_from_env_if_set_to_none( + self, mock, region_name, monkeypatch + ): + session = boto3.Session() + connect_to = ExternalAwsClientFactory(use_ssl=True, session=session) + connect_to.get_client( + "abc", region_name="xx-south-1", aws_access_key_id="foo", aws_secret_access_key="bar" + ) + mock.assert_called_once_with( + service_name="abc", + region_name="xx-south-1", + use_ssl=True, + verify=True, + endpoint_url=None, + aws_access_key_id="foo", + aws_secret_access_key="bar", + aws_session_token=None, + config=connect_to._config, + ) + + mock.reset_mock() + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "lorem") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "ipsum") + + connect_to.get_client( + "def", region_name=None, aws_secret_access_key=None, aws_access_key_id=None + ) + mock.assert_called_once_with( + service_name="def", + region_name=region_name, + use_ssl=True, + verify=True, + endpoint_url=None, + aws_access_key_id=None, + aws_secret_access_key=None, + aws_session_token=None, + config=connect_to._config, + ) + + @pytest.mark.parametrize( + "service", + [ + "acm", + "amplify", + "apigateway", + "apigatewayv2", + "appconfig", + "appsync", + "athena", + "autoscaling", + "lambda_", + "backup", + "batch", + "ce", + "cloudformation", + "cloudfront", + "cloudtrail", + "cloudwatch", + "codecommit", + "cognito_identity", + "cognito_idp", + "docdb", + "dynamodb", + "dynamodbstreams", + "ec2", + "ecr", + "ecs", + "eks", + "elasticbeanstalk", + "elbv2", + "emr", + "es", + "events", + "firehose", + "glacier", + "glue", + "iam", + "iot", + "iot_data", + "iotanalytics", + "iotwireless", + "kafka", + "kinesis", + "kms", + "lakeformation", + "logs", + "mediastore", + "mq", + "mwaa", + "neptune", + "opensearch", + "organizations", + "pi", + "qldb", + "qldb_session", + "rds", + "rds_data", + "redshift", + "redshift_data", + "resource_groups", + "resourcegroupstaggingapi", + "route53", + "route53resolver", + "s3", + "s3control", + "sagemaker", + "sagemaker_runtime", + "secretsmanager", + "serverlessrepo", + "servicediscovery", + "ses", + "sesv2", + "sns", + "sqs", + "ssm", + "stepfunctions", + "sts", + "timestream_query", + "timestream_write", + "transcribe", + "xray", + ], + ) + def test_typed_client_creation(self, service): + """Test the created client actually matching the requested service""" + factory = InternalClientFactory() + client = getattr(factory(), service) + assert client.meta.service_model.service_name == attribute_name_to_service_name(service) + + def test_client_caching(self): + """Test client caching. Same factory for the same service should result in the same client. + Different factories should result in different (identity wise) clients""" + # This test might get flaky if some internal boto3 caching is introduced at some point + # TODO does it really make sense to test the caching? + # TODO pretty ugly way of accessing the internal client + factory = InternalClientFactory() + assert factory().s3._client is factory().s3._client + factory_2 = InternalClientFactory() + assert factory().s3._client != factory_2().s3._client + + def test_client_caching_with_config(self): + """Test client caching. Same factory for the same service should result in the same client. + Different factories should result in different (identity wise) clients""" + # This test might get flaky if some internal boto3 caching is introduced at some point + config = Config(read_timeout=2, signature_version=botocore.UNSIGNED) + second_config = Config(read_timeout=2, signature_version=botocore.UNSIGNED) + third_config = Config(read_timeout=3, signature_version=botocore.UNSIGNED) + factory = InternalClientFactory() + client_1 = factory(config=config).s3._client + client_2 = factory(config=config).s3._client + client_3 = factory(config=second_config).s3._client + client_4 = factory(config=third_config).s3._client + assert client_1 is client_2 + assert client_2 is client_3 + assert client_3 is not client_4 + + def test_client_caching_with_merged_configs(self): + """Test client caching. Same factory for the same service should result in the same client. + Different factories should result in different (identity wise) clients""" + # This test might get flaky if some internal boto3 caching is introduced at some point + config_1 = Config(read_timeout=2) + config_2 = Config(signature_version=botocore.UNSIGNED) + config_3 = config_1.merge(config_2) + config_4 = config_1.merge(config_2) + factory = InternalClientFactory() + client_1 = factory(config=config_1).s3._client + client_2 = factory(config=config_2).s3._client + client_3 = factory(config=config_3).s3._client + client_4 = factory(config=config_4).s3._client + assert client_1 is not client_2 + assert client_2 is not client_3 + assert client_1 is not client_3 + assert client_3 is client_4 + + def test_internal_request_parameters(self, create_dummy_request_parameter_gateway): + internal_dto = None + + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + nonlocal internal_dto + internal_dto = context.internal_request_params + response.status_code = 200 + response.headers = context.request.headers + + endpoint_url = create_dummy_request_parameter_gateway([echo_request_handler]) + + sent_dto = { + "service_principal": "apigateway", + "source_arn": "arn:aws:apigateway:us-east-1::/apis/api-id", + } + internal_factory = InternalClientFactory() + internal_lambda_client = internal_factory(endpoint_url=endpoint_url).lambda_ + internal_lambda_client.request_metadata( + service_principal=sent_dto["service_principal"], source_arn=sent_dto["source_arn"] + ).list_functions() + assert internal_dto == sent_dto + external_factory = ExternalClientFactory() + external_lambda_client = external_factory(endpoint_url=endpoint_url).lambda_ + external_lambda_client.list_functions() + assert internal_dto is None + + def test_internal_call(self, create_dummy_request_parameter_gateway): + """Test the creation of a strictly internal client""" + # TODO add utility to simplify (second iteration) + factory = InternalClientFactory() + test_params = {} + + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + test_params["is_internal"] = context.is_internal_call + if context.internal_request_params: + test_params.update(context.internal_request_params) + response.status_code = 200 + + endpoint_url = create_dummy_request_parameter_gateway([echo_request_handler]) + + factory(endpoint_url=endpoint_url).lambda_.list_functions() + + assert test_params == {"is_internal": True} + + def test_internal_call_from_principal(self, create_dummy_request_parameter_gateway): + """Test the creation of a client based on some principal credentials""" + + factory = InternalClientFactory() + test_params = {} + + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + test_params["is_internal"] = context.is_internal_call + if context.internal_request_params: + test_params.update(context.internal_request_params) + test_params["access_key_id"] = extract_access_key_id_from_auth_header( + context.request.headers + ) + response.status_code = 200 + + endpoint_url = create_dummy_request_parameter_gateway([echo_request_handler]) + + factory( + endpoint_url=endpoint_url, + aws_access_key_id="AKIAQAAAAAAALX6GRE2E", + aws_secret_access_key="something", + ).lambda_.list_functions() + + assert test_params == {"is_internal": True, "access_key_id": "AKIAQAAAAAAALX6GRE2E"} + + def test_internal_call_from_role(self, create_dummy_request_parameter_gateway): + """Test the creation of a client living in the apigateway service assuming a role and creating a client with it""" + factory = InternalClientFactory() + test_params = {} + + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + test_params["is_internal"] = context.is_internal_call + if context.internal_request_params: + test_params.update(context.internal_request_params) + if "sts" in context.request.headers["Authorization"]: + response.set_response( + b"\nASIAQAAAAAAAKZ4L3POJJuXSf5FLeQ359frafiJ4JpjDEoB7HQLnLQEFBRlMFQoGZXIvYXdzEBYaDCjqXzwpBOq025tqq/z0qkio4HkWpvPGsLW3y4G5kcPcKpPrJ1ZVnnVMcx7JP35kzhPssefI7P08HuQKjX15L7r+mFoPCBHVZYqx5yqflWM7Di6vOfWm51DMY6RCe7cXH/n5SwSxeb0RQokIKMOZ0jK+bZN2KPqmWaH4hkAaDAsFGVBgpuEpNZm4VU75m29kxoUw2//6aTMoxgIFzuwb22dNidJYdoxzLFcAy89kJaYYYQjJ/SFKtZPlgSaekEMr6E4VCr+g9zHVUlO33YLTLaxlb3pf/+Dgq8CJCpmBo/suHJFPvfYH5zdsvUlKcczd7Svyr8RqxjbexG8uXH4=2023-03-13T11:29:08.200000ZAROAQAAAAAAANUGUEO76V:test-sessionarn:aws:sts::000000000000:assumed-role/test-role/test-session6P3CY3HH8R03LT28I31X212IQWLSY0WCECRPXPSMOTFVUAV3I8Q5A" + ) + else: + test_params["access_key_id"] = extract_access_key_id_from_auth_header( + context.request.headers + ) + response.status_code = 200 + + endpoint_url = create_dummy_request_parameter_gateway([echo_request_handler]) + + client = factory.with_assumed_role( + role_arn="arn:aws:iam::000000000000:role/test-role", + service_principal=ServicePrincipal.apigateway, + endpoint_url=endpoint_url, + ) + assert test_params == {"is_internal": True, "service_principal": "apigateway"} + test_params = {} + + client.lambda_.list_functions() + + assert test_params == {"is_internal": True, "access_key_id": "ASIAQAAAAAAAKZ4L3POJ"} + + def test_internal_call_from_service(self, create_dummy_request_parameter_gateway): + """Test the creation of a client from a service on behalf of some resource""" + factory = InternalClientFactory() + test_params = {} + + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + test_params["is_internal"] = context.is_internal_call + if context.internal_request_params: + test_params.update(context.internal_request_params) + response.status_code = 200 + + endpoint_url = create_dummy_request_parameter_gateway([echo_request_handler]) + clients = factory( + endpoint_url=endpoint_url, + ) + + expected_result = { + "is_internal": True, + "service_principal": "apigatway", + "source_arn": "arn:aws:apigateway:us-east-1::/apis/a1a1a1a1", + } + clients.lambda_.request_metadata( + source_arn=expected_result["source_arn"], + service_principal=expected_result["service_principal"], + ).list_functions() + + assert test_params == expected_result + + def test_external_call_to_provider(self, create_dummy_request_parameter_gateway): + """Test the creation of a client to be used to connect to a downstream provider implementation""" + factory = ExternalClientFactory() + test_params = {} + + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + test_params["is_internal"] = context.is_internal_call + test_params["params"] = context.internal_request_params + response.status_code = 200 + + endpoint_url = create_dummy_request_parameter_gateway([echo_request_handler]) + clients = factory( + endpoint_url=endpoint_url, + ) + + expected_result = {"is_internal": False, "params": None} + clients.lambda_.list_functions() + + assert test_params == expected_result + + def test_external_call_from_test(self, create_dummy_request_parameter_gateway): + """Test the creation of a client to be used to connect in a test""" + factory = ExternalClientFactory() + test_params = {} + + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + test_params["is_internal"] = context.is_internal_call + test_params["params"] = context.internal_request_params + test_params["region"] = context.region + response.status_code = 200 + + endpoint_url = create_dummy_request_parameter_gateway( + [add_region_from_header, echo_request_handler] + ) + clients = factory( + region_name="eu-central-1", + endpoint_url=endpoint_url, + aws_access_key_id="test", + aws_secret_access_key="test", + ) + + expected_result = {"is_internal": False, "params": None, "region": "eu-central-1"} + clients.lambda_.list_functions() + + assert test_params == expected_result + + def test_region_override(self): + # Boto has an odd behaviour when using a non-default (any other region than us-east-1) in config + # If the region in arg is non-default, it gives the arg the precedence + # But if the region in arg is default (us-east-1), it gives precedence to one in config + # This test asserts that this behaviour is handled by client factories and always give precedence to arg region + + factory = ExternalClientFactory() + + config = botocore.config.Config(region_name="eu-north-1") + + assert factory(region_name="us-east-1", config=config).s3.meta.region_name == "us-east-1" + assert factory(region_name="us-west-1", config=config).s3.meta.region_name == "us-west-1" diff --git a/tests/unit/aws/test_gateway.py b/tests/unit/aws/test_gateway.py new file mode 100644 index 0000000000000..7dd6be124c26f --- /dev/null +++ b/tests/unit/aws/test_gateway.py @@ -0,0 +1,65 @@ +import asyncio + +import pytest +import requests +from hypercorn import Config + +from localstack.aws.api import RequestContext +from localstack.aws.chain import HandlerChain +from localstack.aws.gateway import Gateway +from localstack.aws.serving.asgi import AsgiGateway +from localstack.http import Response +from localstack.http.hypercorn import HypercornServer +from localstack.utils import net +from localstack.utils.sync import poll_condition + + +@pytest.fixture +def serve_gateway_hypercorn(): + _servers = [] + + def _create(gateway: Gateway) -> HypercornServer: + config = Config() + config.h11_pass_raw_headers = True + config.bind = f"localhost:{net.get_free_tcp_port()}" + loop = asyncio.new_event_loop() + srv = HypercornServer(AsgiGateway(gateway, event_loop=loop), config, loop=loop) + _servers.append(srv) + srv.start() + assert srv.wait_is_up(timeout=10), "gave up waiting for server to start up" + return srv + + yield _create + + for server in _servers: + server.shutdown() + assert poll_condition(lambda: not server.is_up(), timeout=10), ( + "gave up waiting for server to shut down" + ) + + +def test_gateway_served_through_hypercorn_preserves_client_headers(serve_gateway_hypercorn): + def echo_request_headers(chain: HandlerChain, context: RequestContext, response: Response): + response.set_json({"headers": [(k, v) for k, v in context.request.headers.items()]}) + chain.stop() + + gateway = Gateway() + gateway.request_handlers.append(echo_request_headers) + + server = serve_gateway_hypercorn(gateway=gateway) + + response = requests.get( + server.url, + headers={ + "x-my-header": "value1", + "Some-Title-Case-Header": "value2", + "X-UPPER": "value3", + "KEEPS__underscores_-": "value4", + }, + ) + headers = response.json()["headers"] + + assert ["x-my-header", "value1"] in headers + assert ["Some-Title-Case-Header", "value2"] in headers + assert ["X-UPPER", "value3"] in headers + assert ["KEEPS__underscores_-", "value4"] in headers diff --git a/tests/unit/aws/test_mocking.py b/tests/unit/aws/test_mocking.py new file mode 100644 index 0000000000000..87db62b2967c6 --- /dev/null +++ b/tests/unit/aws/test_mocking.py @@ -0,0 +1,61 @@ +import pytest + +from localstack.aws.forwarder import create_aws_request_context +from localstack.aws.mocking import generate_request, generate_response, get_mocking_skeleton +from localstack.aws.protocol.serializer import create_serializer as create_response_serializer +from localstack.aws.protocol.validate import validate_request +from localstack.aws.spec import load_service +from localstack.utils.strings import long_uid + + +# currently, checking all operations just takes too long and is potentially flaky due to nondeterminism when +# generating strings. so we only test a few methods here. +@pytest.mark.parametrize( + "service_name, operation_name", + [ + ("dynamodb", "GetItem"), # this input shape has a cycle + ("ec2", "DescribeInstances"), + ("lambda", "CreateFunction"), + ("rds", "CreateDBCluster"), + ], +) +def test_generate_request(service_name, operation_name): + service = load_service(service_name) + operation = service.operation_model(operation_name) + request = generate_request(operation) + + assert request + + result = validate_request(operation, request) + assert not result.has_errors() + + +@pytest.mark.parametrize( + "service_name, operation_name", + [ + ("dynamodb", "GetItem"), + ("ec2", "DescribeInstances"), + ("lambda", "CreateFunction"), + ("rds", "CreateDBCluster"), + ], +) +def test_generate_response(service_name, operation_name): + service = load_service(service_name) + operation = service.operation_model(operation_name) + + response = generate_response(operation) + assert response + + # make sure we can serialize the response + serializer = create_response_serializer(service) + assert serializer.serialize_to_response(response, operation, {}, long_uid()) + + +def test_get_mocking_skeleton(): + skeleton = get_mocking_skeleton("sqs") + + request = {"QueueName": "my-queue-name"} + context = create_aws_request_context("sqs", "CreateQueue", request) + response = skeleton.invoke(context) + # just a smoke test + assert b"QueueUrl" in response.data diff --git a/tests/unit/aws/test_scaffold.py b/tests/unit/aws/test_scaffold.py new file mode 100644 index 0000000000000..d4aecba857b31 --- /dev/null +++ b/tests/unit/aws/test_scaffold.py @@ -0,0 +1,42 @@ +from types import ModuleType + +import pytest +from click.testing import CliRunner + +from localstack.aws.scaffold import generate +from localstack.testing.pytest import markers + + +@markers.skip_offline +@pytest.mark.parametrize( + "service", + [ + "apigateway", + "autoscaling", + "cloudformation", + "dynamodb", + "glue", + "kafka", + "kinesis", + "sqs", + "s3", + ], +) +def test_generated_code_compiles(service, caplog): + # Deactivate logging on CLI (https://github.com/pallets/click/issues/824#issuecomment-562581313) + caplog.set_level(100000) + + runner = CliRunner() + result = runner.invoke(generate, [service, "--no-doc", "--print"]) + assert result.exit_code == 0 + + # Get the generated code + code = result.output + + # Make sure the code is compilable + compiled = compile(code, "", "exec") + + # Make sure the code is importable + # (f.e. Kafka contains types with double underscores in the spec, which would result in an import error) + module = ModuleType(service) + exec(compiled, module.__dict__) diff --git a/tests/unit/aws/test_service_router.py b/tests/unit/aws/test_service_router.py new file mode 100644 index 0000000000000..cba7fd1f6e95a --- /dev/null +++ b/tests/unit/aws/test_service_router.py @@ -0,0 +1,265 @@ +from datetime import datetime +from typing import Any, Dict, Tuple +from urllib.parse import urlsplit + +import pytest +from botocore.awsrequest import AWSRequest, create_request_object +from botocore.config import Config +from botocore.model import OperationModel, ServiceModel, Shape, StructureShape + +from localstack.aws.protocol.service_router import determine_aws_service_model +from localstack.aws.spec import get_service_catalog +from localstack.http import Request +from localstack.utils.run import to_str + + +def _collect_operations() -> Tuple[ServiceModel, OperationModel]: + """ + Collects all service<>operation combinations to test. + """ + service_catalog = get_service_catalog() + for service_name in service_catalog.service_names: + service = service_catalog.get(service_name) + for operation_name in service.operation_names: + # FIXME try to support more and more services, get these exclusions down! + # Exclude all operations for the following, currently _not_ supported services + if service.service_name in [ + "bedrock-agent", + "bedrock-agent-runtime", + "bedrock-data-automation", + "bedrock-data-automation-runtime", + "chime", + "chime-sdk-identity", + "chime-sdk-media-pipelines", + "chime-sdk-meetings", + "chime-sdk-messaging", + "chime-sdk-voice", + "codecatalyst", + "connect", + "connect-contact-lens", + "connectcampaigns", + "connectcampaignsv2", + "greengrassv2", + "iot1click", + "iot1click-devices", + "iot1click-projects", + "ivs", + "ivs-realtime", + "kinesis-video-archived", + "kinesis-video-archived-media", + "kinesis-video-media", + "kinesis-video-signaling", + "kinesis-video-webrtc-storage", + "kinesisvideo", + "lex-models", + "lex-runtime", + "lexv2-models", + "lexv2-runtime", + "mailmanager", + "marketplace-catalog", + "marketplace-deployment", + "marketplace-reporting", + "personalize", + "personalize-events", + "personalize-runtime", + "pinpoint-sms-voice", + "qconnect", + "sagemaker-edge", + "sagemaker-featurestore-runtime", + "sagemaker-metrics", + "sms-voice", + "sso", + "sso-oidc", + "wisdom", + "workdocs", + ]: + yield pytest.param( + service, + service.protocol, + service.operation_model(operation_name), + marks=pytest.mark.skip( + reason=f"{service.service_name} is currently not supported by the service router" + ), + ) + # Exclude services / operations which have ambiguities and where the service routing needs to resolve those + elif ( + service.service_name in ["docdb", "neptune"] # maps to rds + or service.service_name in "timestream-write" # maps to timestream-query + or ( + service.service_name == "sesv2" + and operation_name == "PutEmailIdentityDkimSigningAttributes" + ) + ): + yield pytest.param( + service, + service.protocol, + service.operation_model(operation_name), + marks=pytest.mark.skip( + reason=f"{service.service_name} may differ due to ambiguities in the service specs" + ), + ) + else: + yield service, service.protocol, service.operation_model(operation_name) + + +def _botocore_request_to_localstack_request(request_object: AWSRequest) -> Request: + """Converts a botocore request (AWSRequest) to our HTTP framework's Request object based on Werkzeug.""" + split_url = urlsplit(request_object.url) + path = split_url.path + query_string = split_url.query + body = request_object.body + headers = request_object.headers + return Request( + method=request_object.method or "GET", + path=path, + query_string=to_str(query_string), + headers=dict(headers), + body=body, + raw_path=path, + ) + + +# Simple dummy value mapping for the different shape types +_dummy_values = { + "string": "dummy-value", + "list": [], + "integer": 0, + "long": 0, + "timestamp": datetime.now(), + "boolean": True, +} + + +def _create_dummy_request_args(operation_model: OperationModel) -> Dict: + """Creates a dummy request param dict for the given operation.""" + input_shape: StructureShape = operation_model.input_shape + if not input_shape: + return {} + result = {} + for required_member in input_shape.required_members: + required_shape: Shape = input_shape.members[required_member] + location = required_shape.serialization.get("location") + if location in ["uri", "querystring", "header", "headers"]: + result[required_member] = _dummy_values[required_shape.type_name] + return result + + +def _generate_test_name(param: Any): + """Simple helper function to generate readable test names.""" + if isinstance(param, ServiceModel): + return param.service_name + elif isinstance(param, OperationModel): + return param.name + return param + + +@pytest.mark.parametrize( + "service, protocol, operation", + _collect_operations(), + ids=_generate_test_name, +) +def test_service_router_works_for_every_service( + service: ServiceModel, protocol: str, operation: OperationModel, caplog, aws_client_factory +): + caplog.set_level("CRITICAL", "botocore") + + # if we test the routing to the internalized sqs query, we want to use the service name "sqs-query" in order to + # instruct botocore to load the internalized spec instead of the default (json) + service_name = ( + "sqs-query" + if service.service_name == "sqs" and protocol == "query" + else service.service_name + ) + + # Create a dummy request for the service router + client = aws_client_factory.get_client( + service_name, + config=Config( + connect_timeout=1_000, + read_timeout=1_000, + retries={"total_max_attempts": 1}, + parameter_validation=False, + user_agent="aws-cli/1.33.7", + ), + ) + + request_context = { + "client_region": client.meta.region_name, + "client_config": client.meta.config, + "has_streaming_input": operation.has_streaming_input, + "auth_type": operation.auth_type, + } + request_args = _create_dummy_request_args(operation) + + # pre-process the request args (some params are modified using botocore event handlers) + request_args = client._emit_api_params(request_args, operation, request_context) + # The endpoint URL is mandatory here, just set a dummy (doesn't _need_ to be localstack specific) + request_dict = client._convert_to_request_dict( + request_args, operation, "http://localhost.localstack.cloud", request_context + ) + request_object = create_request_object(request_dict) + client._request_signer.sign(operation.name, request_object) + request: Request = _botocore_request_to_localstack_request(request_object) + + # Execute the service router + detected_service_model = determine_aws_service_model(request) + + # Make sure the detected service is the same as the one we generated the request for + assert detected_service_model.service_name == service.service_name + assert detected_service_model.protocol == service.protocol + + +def test_endpoint_prefix_based_routing(): + # TODO could be generalized using endpoint resolvers and replacing "amazonaws.com" with "localhost.localstack.cloud" + detected_service_model = determine_aws_service_model( + Request(method="GET", path="/", headers={"Host": "kms.localhost.localstack.cloud"}) + ) + assert detected_service_model.service_name == "kms" + + detected_service_model = determine_aws_service_model( + Request( + method="POST", + path="/app-instances", + headers={"Host": "identity-chime.localhost.localstack.cloud"}, + ) + ) + assert detected_service_model.service_name == "chime-sdk-identity" + + +def test_endpoint_prefix_based_routing_s3_virtual_host(): + detected_service_model = determine_aws_service_model( + Request(method="GET", path="/", headers={"Host": "pictures.s3.localhost.localstack.cloud"}) + ) + assert detected_service_model.service_name == "s3" + + detected_service_model = determine_aws_service_model( + Request( + method="POST", + path="/app-instances", + headers={"Host": "kms.s3.localhost.localstack.cloud"}, + ) + ) + assert detected_service_model.service_name == "s3" + + +def test_endpoint_prefix_based_routing_for_sqs(): + # test without content type + detected_service_model = determine_aws_service_model( + Request(method="GET", path="/", headers={"Host": "sqs.localhost.localstack.cloud"}) + ) + assert detected_service_model.service_name == "sqs" + assert detected_service_model.protocol == "query" + + # test explicitly with JSON + detected_service_model = determine_aws_service_model( + Request( + method="GET", + path="/", + headers={ + "Host": "sqs.localhost.localstack.cloud", + "Content-Type": "application/x-amz-json-1.0", + }, + ) + ) + assert detected_service_model.service_name == "sqs" + assert detected_service_model.protocol == "json" diff --git a/tests/unit/aws/test_skeleton.py b/tests/unit/aws/test_skeleton.py new file mode 100644 index 0000000000000..846d41340cd18 --- /dev/null +++ b/tests/unit/aws/test_skeleton.py @@ -0,0 +1,366 @@ +from typing import Dict, List, TypedDict + +import pytest +from botocore.parsers import create_parser + +from localstack.aws.api import ( + CommonServiceException, + RequestContext, + ServiceException, + ServiceRequest, + handler, +) +from localstack.aws.api.sqs import SendMessageRequest +from localstack.aws.skeleton import DispatchTable, ServiceRequestDispatcher, Skeleton +from localstack.aws.spec import load_service +from localstack.http import Request + +""" Stripped down version of the SQS API generated by the Scaffold. """ + +String = str +StringList = List[String] + +Binary = bytes +BinaryList = List[Binary] +Integer = int + + +class MessageAttributeValue(TypedDict): + StringValue: String + BinaryValue: Binary + StringListValues: StringList + BinaryListValues: BinaryList + DataType: String + + +class MessageSystemAttributeName(str): + SenderId = "SenderId" + SentTimestamp = "SentTimestamp" + ApproximateReceiveCount = "ApproximateReceiveCount" + ApproximateFirstReceiveTimestamp = "ApproximateFirstReceiveTimestamp" + SequenceNumber = "SequenceNumber" + MessageDeduplicationId = "MessageDeduplicationId" + MessageGroupId = "MessageGroupId" + AWSTraceHeader = "AWSTraceHeader" + + +class MessageSystemAttributeNameForSends(str): + AWSTraceHeader = "AWSTraceHeader" + + +class SendMessageResult(TypedDict): + MD5OfMessageBody: String + MD5OfMessageAttributes: String + MD5OfMessageSystemAttributes: String + MessageId: String + SequenceNumber: String + + +class MessageSystemAttributeValue(TypedDict): + StringValue: String + BinaryValue: Binary + StringListValues: StringList + BinaryListValues: BinaryList + DataType: String + + +MessageBodyAttributeMap = Dict[String, MessageAttributeValue] +MessageSystemAttributeMap = Dict[MessageSystemAttributeName, String] +MessageBodySystemAttributeMap = Dict[ + MessageSystemAttributeNameForSends, MessageSystemAttributeValue +] + + +class InvalidMessageContents(ServiceException): + pass + + +class UnsupportedOperation(ServiceException): + pass + + +class TestSqsApi: + service = "sqs" + version = "2012-11-05" + + @handler("SendMessage") + def send_message( + self, + context: RequestContext, + queue_url: String, + message_body: String, + delay_seconds: Integer = None, + message_attributes: MessageBodyAttributeMap = None, + message_system_attributes: MessageBodySystemAttributeMap = None, + message_deduplication_id: String = None, + message_group_id: String = None, + ) -> SendMessageResult: + return { + "MD5OfMessageBody": "String", + "MD5OfMessageAttributes": "String", + "MD5OfMessageSystemAttributes": "String", + "MessageId": "String", + "SequenceNumber": "String", + } + + +class TestSqsApiNotImplemented: + service = "sqs" + version = "2012-11-05" + + @handler("SendMessage") + def send_message( + self, + context: RequestContext, + queue_url: String, + message_body: String, + delay_seconds: Integer = None, + message_attributes: MessageBodyAttributeMap = None, + message_system_attributes: MessageBodySystemAttributeMap = None, + message_deduplication_id: String = None, + message_group_id: String = None, + ) -> SendMessageResult: + raise NotImplementedError + + +class TestSqsApiNotImplementedWithMessage: + service = "sqs" + version = "2012-11-05" + + @handler("SendMessage", expand=False) + def send_message( + self, + context: RequestContext, + request: SendMessageRequest, + ) -> SendMessageResult: + raise NotImplementedError("We will implement it soon, that's a promise!") + + +""" Test implementations """ + + +def _get_sqs_request_headers(): + return { + "Remote-Addr": "127.0.0.1", + "Host": "localhost:4566", + "Accept-Encoding": "identity", + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + "User-Agent": "aws-cli/1.20.47 Python/3.8.10 Linux/5.4.0-88-generic botocore/1.21.47", + "X-Amz-Date": "20211009T185815Z", + "Authorization": "AWS4-HMAC-SHA256 Credential=test/20211009/us-east-1/sqs/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=d9f93b13a07dda8cba650fba583fab92e0c72465e5e02fb56a3bb4994aefc339", + "Content-Length": "169", + "x-localstack-request-url": "http://localhost:4566/", + "X-Forwarded-For": "127.0.0.1, localhost:4566", + } + + +def test_skeleton_e2e_sqs_send_message(): + sqs_service = load_service("sqs-query") + skeleton = Skeleton(sqs_service, TestSqsApi()) + request = Request( + **{ + "method": "POST", + "path": "/", + "body": "Action=SendMessage&Version=2012-11-05&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&MessageBody=%7B%22foo%22%3A+%22bared%22%7D&DelaySeconds=2", + "headers": _get_sqs_request_headers(), + } + ) + context = RequestContext(request) + context.account = "test" + context.region = "us-west-1" + context.service = sqs_service + result = skeleton.invoke(context) + + # Use the parser from botocore to parse the serialized response + response_parser = create_parser("query") + parsed_response = response_parser.parse( + result.to_readonly_response_dict(), sqs_service.operation_model("SendMessage").output_shape + ) + + # Test the ResponseMetadata and delete it afterwards + assert "ResponseMetadata" in parsed_response + assert "RequestId" in parsed_response["ResponseMetadata"] + assert len(parsed_response["ResponseMetadata"]["RequestId"]) == 36 + assert "HTTPStatusCode" in parsed_response["ResponseMetadata"] + assert parsed_response["ResponseMetadata"]["HTTPStatusCode"] == 200 + del parsed_response["ResponseMetadata"] + + # Compare the (remaining) actual payload + assert parsed_response == { + "MD5OfMessageBody": "String", + "MD5OfMessageAttributes": "String", + "MD5OfMessageSystemAttributes": "String", + "MessageId": "String", + "SequenceNumber": "String", + } + + +@pytest.mark.parametrize( + "api_class, oracle_message", + [ + ( + TestSqsApiNotImplemented(), + ( + "The API action 'SendMessage' for service 'sqs' is either not available " + "in your current license plan or has not yet been emulated by LocalStack. " + "Please refer to https://docs.localstack.cloud/references/coverage/coverage_sqs for more information." + ), + ), + ( + TestSqsApiNotImplementedWithMessage(), + "We will implement it soon, that's a promise!", + ), + ], +) +def test_skeleton_e2e_sqs_send_message_not_implemented(api_class, oracle_message): + sqs_service = load_service("sqs-query") + skeleton = Skeleton(sqs_service, api_class) + request = Request( + **{ + "method": "POST", + "path": "/", + "body": "Action=SendMessage&Version=2012-11-05&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue&MessageBody=%7B%22foo%22%3A+%22bared%22%7D&DelaySeconds=2", + "headers": _get_sqs_request_headers(), + } + ) + context = RequestContext(request) + context.account = "test" + context.region = "us-west-1" + context.service = sqs_service + result = skeleton.invoke(context) + + # Use the parser from botocore to parse the serialized response + response_parser = create_parser(sqs_service.protocol) + parsed_response = response_parser.parse( + result.to_readonly_response_dict(), sqs_service.operation_model("SendMessage").output_shape + ) + + # Test the ResponseMetadata + assert "ResponseMetadata" in parsed_response + assert "RequestId" in parsed_response["ResponseMetadata"] + assert len(parsed_response["ResponseMetadata"]["RequestId"]) == 36 + assert "HTTPStatusCode" in parsed_response["ResponseMetadata"] + assert parsed_response["ResponseMetadata"]["HTTPStatusCode"] == 501 + + # Compare the (remaining) actual error payload + assert "Error" in parsed_response + assert parsed_response["Error"] == { + "Code": "InternalFailure", + "Message": oracle_message, + } + + +def test_dispatch_common_service_exception(): + def delete_queue(_context: RequestContext, _request: ServiceRequest): + raise CommonServiceException("NonExistentQueue", "No such queue") + + table: DispatchTable = {} + table["DeleteQueue"] = delete_queue + + sqs_service = load_service("sqs-query") + skeleton = Skeleton(sqs_service, table) + + request = Request( + **{ + "method": "POST", + "path": "/", + "body": "Action=DeleteQueue&Version=2012-11-05&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue", + "headers": _get_sqs_request_headers(), + } + ) + context = RequestContext(request) + context.account = "test" + context.region = "us-west-1" + context.service = sqs_service + result = skeleton.invoke(context) + + # Use the parser from botocore to parse the serialized response + response_parser = create_parser(sqs_service.protocol) + parsed_response = response_parser.parse( + result.to_readonly_response_dict(), sqs_service.operation_model("SendMessage").output_shape + ) + + assert "Error" in parsed_response + assert parsed_response["Error"] == { + "Code": "NonExistentQueue", + "Message": "No such queue", + } + + +def test_dispatch_missing_method_returns_internal_failure(): + table: DispatchTable = {} + + sqs_service = load_service("sqs-query") + skeleton = Skeleton(sqs_service, table) + + request = Request( + **{ + "method": "POST", + "path": "/", + "body": "Action=DeleteQueue&Version=2012-11-05&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2F000000000000%2Ftf-acc-test-queue", + "headers": _get_sqs_request_headers(), + } + ) + context = RequestContext(request) + context.account = "test" + context.region = "us-west-1" + context.service = sqs_service + + result = skeleton.invoke(context) + # Use the parser from botocore to parse the serialized response + response_parser = create_parser(sqs_service.protocol) + parsed_response = response_parser.parse( + result.to_readonly_response_dict(), sqs_service.operation_model("SendMessage").output_shape + ) + assert "Error" in parsed_response + assert parsed_response["Error"] == { + "Code": "InternalFailure", + "Message": ( + "The API action 'DeleteQueue' for service 'sqs' is either not available in your " + "current license plan or has not yet been emulated by LocalStack. " + "Please refer to https://docs.localstack.cloud/references/coverage/coverage_sqs for more information." + ), + } + + +class TestServiceRequestDispatcher: + def test_default_dispatcher(self): + class SomeAction(ServiceRequest): + ArgOne: str + ArgTwo: int + + def fn(context, arg_one, arg_two): + assert type(context) == RequestContext + assert arg_one == "foo" + assert arg_two == 69 + + dispatcher = ServiceRequestDispatcher(fn, "SomeAction") + dispatcher(RequestContext(None), SomeAction(ArgOne="foo", ArgTwo=69)) + + def test_without_context_without_expand(self): + def fn(*args): + assert len(args) == 1 + assert type(args[0]) == dict + + dispatcher = ServiceRequestDispatcher( + fn, "SomeAction", pass_context=False, expand_parameters=False + ) + dispatcher(RequestContext(None), ServiceRequest()) + + def test_without_expand(self): + def fn(*args): + assert len(args) == 2 + assert type(args[0]) == RequestContext + assert type(args[1]) == dict + + dispatcher = ServiceRequestDispatcher( + fn, "SomeAction", pass_context=True, expand_parameters=False + ) + dispatcher(RequestContext(None), ServiceRequest()) + + def test_dispatch_without_args(self): + def fn(context): + assert type(context) == RequestContext + + dispatcher = ServiceRequestDispatcher(fn, "SomeAction") + dispatcher(RequestContext(None), ServiceRequest()) diff --git a/tests/unit/aws/test_spec.py b/tests/unit/aws/test_spec.py new file mode 100644 index 0000000000000..b20b57ad76087 --- /dev/null +++ b/tests/unit/aws/test_spec.py @@ -0,0 +1,127 @@ +from typing import Type + +import pytest +from botocore.exceptions import UnknownServiceError +from botocore.model import ServiceModel, StringShape + +from localstack.aws.spec import ( + CustomLoader, + LazyServiceCatalogIndex, + ProtocolName, + ServiceName, + UnknownServiceProtocolError, + load_service, + load_service_index_cache, + save_service_index_cache, +) + + +def test_pickled_index_equals_lazy_index(tmp_path): + file_path = tmp_path / "index-cache.pickle" + + lazy_index = LazyServiceCatalogIndex() + + save_service_index_cache(lazy_index, str(file_path)) + cached_index = load_service_index_cache(str(file_path)) + + assert cached_index.service_names == lazy_index.service_names + assert cached_index.target_prefix_index == lazy_index.target_prefix_index + assert cached_index.signing_name_index == lazy_index.signing_name_index + assert cached_index.operations_index == lazy_index.operations_index + assert cached_index.endpoint_prefix_index == lazy_index.endpoint_prefix_index + + +def test_patching_loaders(): + # first test that specs remain intact + loader = CustomLoader({}) + description = loader.load_service_model("s3", "service-2") + + model = ServiceModel(description, "s3") + + shape = model.shape_for("NoSuchBucket") + # by default, the s3 error shapes have no members, but AWS will actually return additional attributes + assert not shape.members + assert shape.metadata.get("exception") + + # now try it with a patch + loader = CustomLoader( + { + "s3/2006-03-01/service-2": [ + { + "op": "add", + "path": "/shapes/NoSuchBucket/members/BucketName", + "value": {"shape": "BucketName"}, + }, + { + "op": "add", + "path": "/shapes/NoSuchBucket/error", + "value": {"httpStatusCode": 404}, + }, + ], + } + ) + description = loader.load_service_model("s3", "service-2", "2006-03-01") + model = ServiceModel(description, "s3") + + shape = model.shape_for("NoSuchBucket") + assert "BucketName" in shape.members + assert isinstance(shape.members["BucketName"], StringShape) + assert shape.metadata["error"]["httpStatusCode"] == 404 + assert shape.metadata.get("exception") + + +def test_loading_own_specs(): + """Ensure that the internalized specifications (f.e. the sqs-query spec) can be handled by the CustomLoader.""" + loader = CustomLoader({}) + # first test that specs remain intact + sqs_query_description = loader.load_service_model("sqs", "service-2") + assert sqs_query_description["metadata"]["protocol"] == "json" + sqs_json_description = loader.load_service_model("sqs-query", "service-2") + assert sqs_json_description["metadata"]["protocol"] == "query" + + +@pytest.mark.parametrize( + "service_name,protocol,expected_service_name,expected_protocol", + [ + # basic / default use case for service loading + ("s3", "rest-xml", "s3", "rest-xml"), + # if protocol is not set, the default protocol for the service should be used + ("s3", None, "s3", "rest-xml"), + # tests with a default and a specific protocol (SQS) + ("sqs", "query", "sqs", "query"), + ("sqs", "json", "sqs", "json"), + ("sqs", None, "sqs", "json"), + ("sqs-query", None, "sqs", "query"), + ("sqs-query", "query", "sqs", "query"), + ], +) +def test_protocol_specific_loading( + service_name: ServiceName, + protocol: ProtocolName, + expected_service_name: ServiceName, + expected_protocol: ProtocolName, +): + """Ensure the protocol specific loading is working correctly.""" + service_model = load_service(service=service_name, protocol=protocol) + assert expected_service_name == service_model.service_name + assert expected_protocol == service_model.protocol + + +@pytest.mark.parametrize( + "service_name,protocol,expected_exception", + [ + # service-protocol naming convention is only supported for internalized (non-default) services + ("s3-rest-xml", None, UnknownServiceError), + # unknown service name raises error + ("non-existing-service", None, UnknownServiceError), + # unknown protocol raises error + ("sqs", "nonexistingprotocol", UnknownServiceProtocolError), + # non-matching protocol in service naming convention and explicitly defined protocol + ("sqs-query", "json", UnknownServiceProtocolError), + ], +) +def test_invalid_service_loading( + service_name: ServiceName, protocol: ProtocolName, expected_exception: Type[Exception] +): + with pytest.raises(expected_exception): + load_service(service=service_name, protocol=protocol) diff --git a/tests/unit/cli/__init__.py b/tests/unit/cli/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py new file mode 100644 index 0000000000000..0313ded90f218 --- /dev/null +++ b/tests/unit/cli/test_cli.py @@ -0,0 +1,336 @@ +import json +import logging +import sys +import threading +from queue import Queue + +import click +import pytest +from click.testing import CliRunner + +import localstack.constants +import localstack.utils.analytics.cli +from localstack import config +from localstack.cli.localstack import create_with_plugins, is_frozen_bundle +from localstack.cli.localstack import localstack as cli +from localstack.config import HostAndPort +from localstack.constants import VERSION +from localstack.http import Request +from localstack.utils.common import is_command_available +from localstack.utils.container_utils.container_client import ContainerException, DockerNotAvailable + +cli: click.Group + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.mark.parametrize( + "exception,expected_message", + [ + (KeyboardInterrupt(), "Aborted!"), + (DockerNotAvailable(), "Docker could not be found on the system"), + (ContainerException("example message"), "example message"), + (click.ClickException("example message"), "example message"), + (click.exceptions.Exit(code=1), ""), + ], +) +def test_error_handling(runner: CliRunner, monkeypatch, exception, expected_message): + """Test different globally handled exceptions, their status code, and error message.""" + + def mock_call(*args, **kwargs): + raise exception + + from localstack.utils import bootstrap + + monkeypatch.setattr(bootstrap, "start_infra_locally", mock_call) + result = runner.invoke(cli, ["start", "--host"]) + assert result.exit_code == 1 + assert expected_message in result.output + + +def test_error_handling_help(runner): + """Make sure the help command is not interpreted as an error (Exit exception is raised).""" + result = runner.invoke(cli, ["-h"]) + assert result.exit_code == 0 + assert "Usage: localstack" in result.output + + +def test_create_with_plugins(runner): + localstack_cli = create_with_plugins() + result = runner.invoke(localstack_cli.group, ["--version"]) + assert result.exit_code == 0 + assert result.output.strip() == f"LocalStack CLI {VERSION}" + + +def test_version(runner): + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert result.output.strip() == f"LocalStack CLI {VERSION}" + + +def test_status_services_error(runner): + result = runner.invoke(cli, ["status", "services"]) + assert result.exit_code == 1 + assert "Error" in result.output + + +@pytest.mark.parametrize("command", ["ssh", "stop"]) +def test_container_not_runnin_error(runner, command): + result = runner.invoke(cli, [command]) + assert result.exit_code == 1 + assert "Error" in result.output + assert "Expected a running LocalStack container" in result.output + + +def test_start_docker_is_default(runner, monkeypatch): + from localstack.utils import bootstrap + + called = threading.Event() + + def mock_call(*args, **kwargs): + called.set() + + monkeypatch.setattr(bootstrap, "start_infra_in_docker", mock_call) + runner.invoke(cli, ["start"]) + assert called.is_set() + + +def test_start_host(runner, monkeypatch): + from localstack.utils import bootstrap + + called = threading.Event() + + def mock_call(*args, **kwargs): + called.set() + + monkeypatch.setattr(bootstrap, "start_infra_locally", mock_call) + runner.invoke(cli, ["start", "--host"]) + assert called.is_set() + + +def test_status_services(runner, httpserver, monkeypatch): + # configure LOCALSTACK_HOST because the services endpoint makes a request against the + # external URL of LocalStack, which may be different to the edge port + monkeypatch.setattr( + config, + "LOCALSTACK_HOST", + HostAndPort( + host="localhost.localstack.cloud", + port=httpserver.port, + ), + ) + + services = {"dynamodb": "starting", "s3": "running"} + httpserver.expect_request("/_localstack/health", method="GET").respond_with_json( + {"services": services} + ) + + result = runner.invoke(cli, ["status", "services"]) + + assert result.exit_code == 0, result + + assert "dynamodb" in result.output + assert "s3" in result.output + + for line in result.output.splitlines(): + if "dynamodb" in line: + assert "starting" in line + assert "running" not in line + if "s3" in line: + assert "running" in line + assert "starting" not in line + + +def test_validate_config(runner, monkeypatch, tmp_path): + if not is_command_available("docker-compose"): + pytest.skip("config validation needs the docker-compose command") + + file = tmp_path / "docker-compose.yml" + file.touch() + + file.write_text( + """version: "3.3" +services: + localstack: + container_name: "${LOCALSTACK_DOCKER_NAME-localstack-main}" + image: localstack/localstack + network_mode: bridge + ports: + - "127.0.0.1:53:53" + - "127.0.0.1:53:53/udp" + - "127.0.0.1:443:443" + - "127.0.0.1:4566:4566" + - "127.0.0.1:4571:4571" + environment: + - SERVICES=${SERVICES- } + - DEBUG=${DEBUG- } + - DATA_DIR=${DATA_DIR- } + - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN- } + - KINESIS_ERROR_PROBABILITY=${KINESIS_ERROR_PROBABILITY- } + - DOCKER_HOST=unix:///var/run/docker.sock + volumes: + - "${TMPDIR:-/tmp/localstack}:/tmp/localstack" + - "/var/run/docker.sock:/var/run/docker.sock" +""" + ) + + result = runner.invoke(cli, ["config", "validate", "--file", str(file)]) + + assert result.exit_code == 0 + assert "config valid" in result.output + + +def test_validate_config_syntax_error(runner, monkeypatch, tmp_path): + if not is_command_available("docker-compose"): + pytest.skip("config validation needs the docker-compose command") + + file = tmp_path / "docker-compose.yml" + file.touch() + + file.write_text("foobar.---\n") + + result = runner.invoke(cli, ["config", "validate", "--file", str(file)]) + + assert result.exit_code == 1 + assert "Error" in result.output + + +@pytest.mark.parametrize( + "cli_input,expected_cmd,expected_params", + [ + ("stop", "localstack stop", []), + ("config show", "localstack config show", ["format_"]), + ("--debug config show --format plain", "localstack config show", ["format_"]), + ], +) +def test_publish_analytics_event_on_command_invocation( + cli_input, expected_cmd, expected_params, runner, monkeypatch, caplog, httpserver +): + # must suppress pytest logging due to weird issue with click https://github.com/pytest-dev/pytest/issues/3344 + caplog.set_level(logging.CRITICAL) + monkeypatch.setattr(localstack.utils.analytics.cli, "ANALYTICS_API_RESPONSE_TIMEOUT_SECS", 3) + request_data = Queue() + input = cli_input.split(" ") + + def _handler(_request: Request): + request_data.put(_request.data) + + httpserver.expect_request("").respond_with_handler(_handler) + monkeypatch.setattr(localstack.constants, "ANALYTICS_API", httpserver.url_for("/")) + runner.invoke(cli, input) + request_payload = request_data.get(timeout=5) + + assert request_data.qsize() == 0 + payload = json.loads(request_payload) + events = payload["events"] + assert len(events) == 1 + event = events[0] + metadata = event["metadata"] + assert "client_time" in metadata + assert "session_id" in metadata + assert event["name"] == "cli_cmd" + assert event["payload"]["cmd"] == expected_cmd + assert event["payload"]["params"] == expected_params + + +@pytest.mark.parametrize( + "cli_input", + [ + "invalid", + "status services", + "config show --format invalid", + ], +) +def test_do_not_publish_analytics_event_on_invalid_command_invocation( + cli_input, runner, monkeypatch, caplog, httpserver +): + # must suppress pytest logging due to weird issue with click https://github.com/pytest-dev/pytest/issues/3344 + caplog.set_level(logging.CRITICAL) + monkeypatch.setattr(localstack.utils.analytics.cli, "ANALYTICS_API_RESPONSE_TIMEOUT_SECS", 3) + request_data = [] + input = cli_input.split(" ") + + def _handler(_request: Request): + request_data.append(_request.data) + + httpserver.expect_request("").respond_with_handler(_handler) + monkeypatch.setenv("ANALYTICS_API", httpserver.url_for("/")) + runner.invoke(cli, input) + assert len(request_data) == 0, ( + "analytics API should not be invoked when an invalid command is supplied" + ) + + +def test_disable_publish_analytics_event_on_command_invocation( + runner, monkeypatch, caplog, httpserver +): + # must suppress pytest logging due to weird issue with click https://github.com/pytest-dev/pytest/issues/3344 + caplog.set_level(logging.CRITICAL) + monkeypatch.setattr(localstack.utils.analytics.cli, "ANALYTICS_API_RESPONSE_TIMEOUT_SECS", 3) + monkeypatch.setattr(localstack.config, "DISABLE_EVENTS", True) + request_data = [] + + def _handler(_request: Request): + request_data.append(_request.data) + + httpserver.expect_request("").respond_with_handler(_handler) + monkeypatch.setenv("ANALYTICS_API", httpserver.url_for("/")) + runner.invoke(cli, ["config", "show"]) + assert len(request_data) == 0, "analytics API should not be invoked when DISABLE_EVENTS is set" + + +def test_timeout_publishing_command_invocation(runner, monkeypatch, caplog, httpserver): + # must suppress pytest logging due to weird issue with click https://github.com/pytest-dev/pytest/issues/3344 + caplog.set_level(logging.CRITICAL) + monkeypatch.setattr( + # simulate slow API call by turning timeout way down + localstack.utils.analytics.cli, + "ANALYTICS_API_RESPONSE_TIMEOUT_SECS", + 0.001, + ) + request_data = [] + + def _handler(_request: Request): + request_data.append(_request.data) + + httpserver.expect_request("").respond_with_handler(_handler) + monkeypatch.setenv("ANALYTICS_API", httpserver.url_for("/")) + runner.invoke(cli, ["config", "show"]) + assert len(request_data) == 0, ( + "analytics event publisher process should time out if request is taking too long" + ) + + +def test_is_frozen(monkeypatch): + # mimic a frozen pyinstaller binary according to https://pyinstaller.org/en/stable/runtime-information.html + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", "/absolute/path/to/bundle/folder", raising=False) + assert is_frozen_bundle() + + +def test_not_is_frozen(monkeypatch): + # mimic running from source + monkeypatch.delattr(sys, "frozen", raising=False) + assert not is_frozen_bundle() + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.delattr(sys, "_MEIPASS", raising=False) + assert not is_frozen_bundle() + + +@pytest.mark.parametrize("shell", ["bash", "zsh", "fish"]) +def test_completion(monkeypatch, runner, shell: str): + test_binary_name = "testbinaryname" + monkeypatch.setattr(localstack.config, "DISABLE_EVENTS", True) + monkeypatch.setattr(sys, "argv", [test_binary_name]) + result = runner.invoke(cli, ["completion", shell]) + assert result.exit_code == 0 + assert f"_{test_binary_name.upper()}_COMPLETE={shell}_complete" in result.output + + +def test_completion_unknown_shell(monkeypatch, runner): + monkeypatch.setattr(localstack.config, "DISABLE_EVENTS", True) + result = runner.invoke(cli, ["completion", "unknown_shell"]) + assert result.exit_code != 0 diff --git a/tests/unit/cli/test_lpm.py b/tests/unit/cli/test_lpm.py new file mode 100644 index 0000000000000..605aac7ef00ad --- /dev/null +++ b/tests/unit/cli/test_lpm.py @@ -0,0 +1,116 @@ +import os.path +from typing import List + +import pytest +from click.testing import CliRunner + +from localstack.cli.lpm import cli, console +from localstack.packages import InstallTarget, Package, PackageException, PackageInstaller +from localstack.packages.api import PackagesPluginManager +from localstack.testing.pytest import markers +from localstack.utils.patch import Patch + + +@pytest.fixture +def runner(): + return CliRunner() + + +@markers.skip_offline +def test_list(runner, monkeypatch): + monkeypatch.setattr(console, "no_color", True) + + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0 + assert "kinesis-mock/community" in result.output + + +@markers.skip_offline +def test_install_with_non_existing_package_fails(runner): + result = runner.invoke(cli, ["install", "kinesis-mock", "funny"]) + assert result.exit_code == 1 + assert "unable to locate installer for package funny" in result.output + + +@markers.skip_offline +def test_install_with_non_existing_version_fails(runner): + result = runner.invoke(cli, ["install", "kinesis-mock", "--version", "non-existing-version"]) + assert result.exit_code == 1 + assert ( + "unable to locate installer for package kinesis-mock and version non-existing-version" + in result.output + ) + + +@markers.skip_offline +def test_install_failure_returns_non_zero_exit_code(runner, monkeypatch): + class FailingPackage(Package): + def __init__(self): + super().__init__("Failing Installer", "latest") + + def get_versions(self) -> List[str]: + return ["latest"] + + def _get_installer(self, version: str) -> PackageInstaller: + return FailingInstaller() + + class FailingInstaller(PackageInstaller): + def __init__(self): + super().__init__("failing-installer", "latest") + + def _get_install_marker_path(self, install_dir: str) -> str: + # Return a non-existing path to force calling the installer + return "/non-existing" + + def _install(self, target: InstallTarget) -> None: + raise PackageException("Failing!") + + class SuccessfulPackage(Package): + def __init__(self): + super().__init__("Successful Installer", "latest") + + def get_versions(self) -> List[str]: + return ["latest"] + + def _get_installer(self, version: str) -> PackageInstaller: + return SuccessfulInstaller() + + class SuccessfulInstaller(PackageInstaller): + def __init__(self): + super().__init__("successful-installer", "latest") + + def _get_install_marker_path(self, install_dir: str) -> str: + # Return a non-existing path to force calling the installer + return "/non-existing" + + def _install(self, target: InstallTarget) -> None: + pass + + def patched_get_packages(*_) -> List[Package]: + return [FailingPackage(), SuccessfulPackage()] + + with Patch.function(target=PackagesPluginManager.get_packages, fn=patched_get_packages): + result = runner.invoke(cli, ["install", "successful-installer", "failing-installer"]) + assert result.exit_code == 1 + assert "one or more package installations failed." in result.output + + +@markers.skip_offline +def test_install_with_package(runner): + from localstack.services.kinesis.packages import kinesismock_package + + result = runner.invoke(cli, ["install", "kinesis-mock"]) + assert result.exit_code == 0 + assert os.path.exists(kinesismock_package.get_installed_dir()) + + +@markers.skip_offline +def test_install_with_package_override(runner, monkeypatch): + from localstack import config + from localstack.services.kinesis.packages import kinesismock_scala_package + + monkeypatch.setattr(config, "KINESIS_MOCK_PROVIDER_ENGINE", "scala") + + result = runner.invoke(cli, ["install", "kinesis-mock"]) + assert result.exit_code == 0 + assert os.path.exists(kinesismock_scala_package.get_installed_dir()) diff --git a/tests/unit/cli/test_profiles.py b/tests/unit/cli/test_profiles.py new file mode 100644 index 0000000000000..c48fd4b9e739d --- /dev/null +++ b/tests/unit/cli/test_profiles.py @@ -0,0 +1,148 @@ +import os +import sys + +from localstack.cli.profiles import set_and_remove_profile_from_sys_argv + + +def profile_test(monkeypatch, input_args, expected_profile, expected_argv): + monkeypatch.setattr(sys, "argv", input_args) + monkeypatch.setenv("CONFIG_PROFILE", "") + set_and_remove_profile_from_sys_argv() + assert os.environ["CONFIG_PROFILE"] == expected_profile + assert sys.argv == expected_argv + + +def test_profiles_equals_notation(monkeypatch): + profile_test( + monkeypatch, + input_args=["--profile=non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=[], + ) + + +def test_profiles_separate_args_notation(monkeypatch): + profile_test( + monkeypatch, + input_args=["--profile", "non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=[], + ) + + +def test_p_equals_notation(monkeypatch): + profile_test( + monkeypatch, + input_args=["-p=non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=["-p=non-existing-test-profile"], + ) + + +def test_p_separate_args_notation(monkeypatch): + profile_test( + monkeypatch, + input_args=["-p", "non-existing-test-profile"], + expected_profile="non-existing-test-profile", + expected_argv=["-p", "non-existing-test-profile"], + ) + + +def test_profiles_args_before_and_after(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "-D", "--profile=non-existing-test-profile", "start"], + expected_profile="non-existing-test-profile", + expected_argv=["cli", "-D", "start"], + ) + + +def test_profiles_args_before_and_after_separate(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "-D", "--profile", "non-existing-test-profile", "start"], + expected_profile="non-existing-test-profile", + expected_argv=["cli", "-D", "start"], + ) + + +def test_p_args_before_and_after_separate(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "-D", "-p", "non-existing-test-profile", "start"], + expected_profile="non-existing-test-profile", + expected_argv=["cli", "-D", "-p", "non-existing-test-profile", "start"], + ) + + +def test_profiles_args_multiple(monkeypatch): + profile_test( + monkeypatch, + input_args=[ + "cli", + "--profile", + "non-existing-test-profile", + "start", + "--profile", + "another-profile", + ], + expected_profile="another-profile", + expected_argv=["cli", "start"], + ) + + +def test_p_args_multiple(monkeypatch): + profile_test( + monkeypatch, + input_args=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "-p", + "another-profile", + ], + expected_profile="non-existing-test-profile", + expected_argv=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "-p", + "another-profile", + ], + ) + + +def test_p_and_profile_args(monkeypatch): + profile_test( + monkeypatch, + input_args=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "--profile", + "the_profile", + "-p", + "another-profile", + ], + expected_profile="the_profile", + expected_argv=[ + "cli", + "-p", + "non-existing-test-profile", + "start", + "-p", + "another-profile", + ], + ) + + +def test_trailing_p_argument(monkeypatch): + profile_test( + monkeypatch, + input_args=["cli", "start", "-p"], + expected_profile="", + expected_argv=["cli", "start", "-p"], + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000000000..36795715baa15 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from localstack.testing.config import ( + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_REGION_NAME, + TEST_AWS_SECRET_ACCESS_KEY, +) + + +@pytest.fixture(autouse=True) +def set_boto_test_credentials_and_region(monkeypatch): + """ + Automatically sets the default credentials and region for all unit tests. + """ + monkeypatch.setenv("AWS_ACCESS_KEY_ID", TEST_AWS_ACCESS_KEY_ID) + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", TEST_AWS_SECRET_ACCESS_KEY) + monkeypatch.setenv("AWS_DEFAULT_REGION", TEST_AWS_REGION_NAME) diff --git a/tests/unit/http_/__init__.py b/tests/unit/http_/__init__.py new file mode 100644 index 0000000000000..ff6fcb60a4104 --- /dev/null +++ b/tests/unit/http_/__init__.py @@ -0,0 +1,3 @@ +# This module cannot be named http, since pycharm cannot run in tests in the module above anymore in debug mode +# - https://youtrack.jetbrains.com/issue/PY-54265 +# - https://youtrack.jetbrains.com/issue/PY-35220 diff --git a/tests/unit/http_/conftest.py b/tests/unit/http_/conftest.py new file mode 100644 index 0000000000000..a22d3718db131 --- /dev/null +++ b/tests/unit/http_/conftest.py @@ -0,0 +1,88 @@ +import asyncio +import logging +from asyncio import AbstractEventLoop + +import pytest +from hypercorn import Config +from hypercorn.typing import ASGIFramework +from werkzeug.datastructures import Headers +from werkzeug.wrappers import Request as WerkzeugRequest + +from localstack.http import Response +from localstack.http.asgi import ASGIAdapter, ASGILifespanListener, WebSocketListener +from localstack.http.hypercorn import HypercornServer +from localstack.utils import net +from localstack.utils.sync import poll_condition + +LOG = logging.getLogger(__name__) + + +@pytest.fixture() +def serve_asgi_app(): + _servers = [] + + def _create( + app: ASGIFramework, config: Config = None, event_loop: AbstractEventLoop = None + ) -> HypercornServer: + if not config: + config = Config() + config.h11_pass_raw_headers = True + config.bind = f"localhost:{net.get_free_tcp_port()}" + + srv = HypercornServer(app, config, loop=event_loop) + _servers.append(srv) + srv.start() + assert srv.wait_is_up(timeout=10), "gave up waiting for server to start up" + return srv + + yield _create + + for server in _servers: + server.shutdown() + assert poll_condition(lambda: not server.is_up(), timeout=10), ( + "gave up waiting for server to shut down" + ) + + +@pytest.fixture() +def serve_asgi_adapter(serve_asgi_app): + def _create( + wsgi_app, + lifespan_listener: ASGILifespanListener = None, + websocket_listener: WebSocketListener = None, + ): + loop = asyncio.new_event_loop() + return serve_asgi_app( + ASGIAdapter( + wsgi_app, + event_loop=loop, + lifespan_listener=lifespan_listener, + websocket_listener=websocket_listener, + ), + event_loop=loop, + ) + + yield _create + + +@pytest.fixture() +def httpserver_echo_request_metadata(): + def httpserver_handler(request: WerkzeugRequest) -> Response: + """ + Simple request handler that returns the incoming request metadata (method, path, url, headers). + + :param request: the incoming HTTP request + :return: an HTTP response + """ + response = Response() + response.set_json( + { + "method": request.method, + "path": request.path, + "url": request.url, + "headers": dict(Headers(request.headers)), + } + ) + return response + + return httpserver_handler diff --git a/tests/unit/http_/test_asgi.py b/tests/unit/http_/test_asgi.py new file mode 100644 index 0000000000000..dbed33d8be862 --- /dev/null +++ b/tests/unit/http_/test_asgi.py @@ -0,0 +1,404 @@ +import json +import logging +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from queue import Queue +from threading import Thread +from typing import List + +import pytest +import requests +from werkzeug import Request, Response + +from localstack.http.asgi import ASGILifespanListener + +LOG = logging.getLogger(__name__) + + +def test_serve_asgi_adapter(serve_asgi_adapter): + request_list: List[Request] = [] + + @Request.application + def app(request: Request) -> Response: + request_list.append(request) + return Response("ok", 200) + + server = serve_asgi_adapter(app) + + response0 = requests.get(server.url + "/foobar?foo=bar", headers={"x-amz-target": "testing"}) + assert response0.ok + assert response0.text == "ok" + + response1 = requests.get(server.url + "/compute", data='{"foo": "bar"}') + assert response1.ok + assert response1.text == "ok" + + request0 = request_list[0] + assert request0.path == "/foobar" + assert request0.query_string == b"foo=bar" + assert request0.full_path == "/foobar?foo=bar" + assert request0.headers["x-amz-target"] == "testing" + assert dict(request0.args) == {"foo": "bar"} + + request1 = request_list[1] + assert request1.path == "/compute" + assert request1.get_data() == b'{"foo": "bar"}' + + +def test_requests_are_not_blocking_the_server(serve_asgi_adapter): + queue = Queue() + + @Request.application + def app(request: Request) -> Response: + time.sleep(1) + queue.put_nowait(request) + return Response("ok", 200) + + server = serve_asgi_adapter(app) + + then = time.time() + + Thread(target=requests.get, args=(server.url,)).start() + Thread(target=requests.get, args=(server.url,)).start() + Thread(target=requests.get, args=(server.url,)).start() + Thread(target=requests.get, args=(server.url,)).start() + + # get the four responses + queue.get(timeout=5) + queue.get(timeout=5) + queue.get(timeout=5) + queue.get(timeout=5) + + assert (time.time() - then) < 4, "requests did not seem to be parallelized" + + +def test_chunked_transfer_encoding_response(serve_asgi_adapter): + # this test makes sure that creating a response with a generator automatically creates a + # transfer-encoding=chunked response + + @Request.application + def app(_request: Request) -> Response: + def _gen(): + yield "foo" + yield "bar\n" + yield "baz\n" + + return Response(_gen(), 200) + + server = serve_asgi_adapter(app) + + response = requests.get(server.url) + + assert response.headers["Transfer-Encoding"] == "chunked" + + it = response.iter_lines() + + assert next(it) == b"foobar" + assert next(it) == b"baz" + + +def test_chunked_transfer_encoding_client_timeout(serve_asgi_adapter): + # this test makes sure that creating a response with a generator automatically creates a + # transfer-encoding=chunked response + + generator_exited = threading.Event() + continue_request = threading.Event() + + @Request.application + def app(_request: Request) -> Response: + def _gen(): + try: + yield "foo" + yield "bar\n" + continue_request.wait() + # only three are needed, let's send some more to make sure + for _ in range(10): + yield "baz\n" + except GeneratorExit: + generator_exited.set() + + return Response(_gen(), 200) + + server = serve_asgi_adapter(app) + + with requests.get(server.url, stream=True) as response: + assert response.headers["Transfer-Encoding"] == "chunked" + + it = response.iter_lines() + + assert next(it) == b"foobar" + + # request is now closed, continue the response generator + continue_request.set() + # this flag is only set when generator is exited + assert generator_exited.wait(timeout=10) + + +def test_chunked_transfer_encoding_request(serve_asgi_adapter): + request_list: List[Request] = [] + + @Request.application + def app(request: Request) -> Response: + request_list.append(request) + + stream = request.stream + data = bytearray() + + for i, item in enumerate(stream): + data.extend(item) + + if i == 0: + assert item == b"foobar\n" + if i == 1: + assert item == b"baz" + + return Response(data.decode("utf-8"), 200) + + server = serve_asgi_adapter(app) + + def gen(): + yield b"foo" + yield b"bar\n" + yield b"baz" + + response = requests.post(server.url, gen()) + assert response.ok + assert response.text == "foobar\nbaz" + + assert request_list[0].headers["Transfer-Encoding"].lower() == "chunked" + + +def test_close_iterable_response(serve_asgi_adapter): + class IterableResponse: + def __init__(self, data: list[bytes]): + self.data = data + self.closed = False + + def __iter__(self): + for packet in self.data: + yield packet + + def close(self): + # should be called through the werkzeug layers + self.closed = True + + iterable = IterableResponse([b"foo", b"bar"]) + + @Request.application + def app(request: Request) -> Response: + return Response(iterable, 200) + + server = serve_asgi_adapter(app) + + response = requests.get(server.url, stream=True) + + gen = response.iter_content(chunk_size=3) + assert next(gen) == b"foo" + assert next(gen) == b"bar" + assert not iterable.closed + + with pytest.raises(StopIteration): + next(gen) + + assert iterable.closed + + +def test_input_stream_methods(serve_asgi_adapter): + @Request.application + def app(request: Request) -> Response: + assert request.stream.read(1) == b"f" + assert request.stream.readline(10) == b"ood\n" + assert request.stream.readline(3) == b"bar" + assert next(request.stream) == b"ber\n" + assert request.stream.readlines(3) == [b"fizz\n"] + assert request.stream.readline() == b"buzz\n" + assert request.stream.read() == b"really\ndone" + assert request.stream.read(10) == b"" + + return Response("ok", 200) + + server = serve_asgi_adapter(app) + + def gen(): + yield b"fo" + yield b"od\n" + yield b"barber\n" + yield b"fizz\n" + yield b"buzz\n" + yield b"really\n" + yield b"done" + + response = requests.post(server.url, data=gen()) + assert response.ok + assert response.text == "ok" + + +def test_input_stream_readlines(serve_asgi_adapter): + @Request.application + def app(request: Request) -> Response: + assert request.stream.readlines() == [b"fizz\n", b"buzz\n", b"done"] + return Response("ok", 200) + + server = serve_asgi_adapter(app) + + def gen(): + yield b"fizz\n" + yield b"buzz\n" + yield b"done" + + response = requests.post(server.url, data=gen()) + assert response.ok + assert response.text == "ok" + + +def test_input_stream_readlines_with_limit(serve_asgi_adapter): + @Request.application + def app(request: Request) -> Response: + assert request.stream.readlines(1000) == [b"fizz\n", b"buzz\n", b"done"] + return Response("ok", 200) + + server = serve_asgi_adapter(app) + + def gen(): + yield b"fizz\n" + yield b"buzz\n" + yield b"done" + + response = requests.post(server.url, data=gen()) + assert response.ok + assert response.text == "ok" + + +def test_multipart_post(serve_asgi_adapter): + @Request.application + def app(request: Request) -> Response: + assert request.mimetype == "multipart/form-data" + + result = {} + for k, file_storage in request.files.items(): + result[k] = file_storage.stream.read().decode("utf-8") + + return Response(json.dumps(result), 200) + + server = serve_asgi_adapter(app) + + response = requests.post(server.url, files={"foo": "bar", "baz": "ed"}) + assert response.ok + assert response.json() == {"foo": "bar", "baz": "ed"} + + +def test_multipart_post_large_payload(serve_asgi_adapter): + @Request.application + def app(request: Request) -> Response: + try: + assert request.mimetype == "multipart/form-data" + + result = {} + for k, file_storage in request.files.items(): + result[k] = len(file_storage.stream.read()) + + return Response(json.dumps(result), 200) + except Exception: + LOG.exception("error") + raise + + server = serve_asgi_adapter(app) + + payload = ( + "\0" * 70_000 + ) # there's a chunk size of 65536 configured in werkzeug which is what we're testing here + + response = requests.post(server.url, files={"file": payload}) + assert response.ok + assert response.json() == {"file": 70_000} + + +def test_utf8_path(serve_asgi_adapter): + @Request.application + def app(request: Request) -> Response: + assert request.path == "/foo/Δ€0Γ„" + assert request.environ["PATH_INFO"] == "/foo/Γ„\x800Γƒ\x84" + + return Response("ok", 200) + + server = serve_asgi_adapter(app) + + response = requests.get(server.url + "/foo/Δ€0Γ„") + assert response.ok + + +def test_serve_multiple_apps(serve_asgi_adapter): + @Request.application + def app0(request: Request) -> Response: + return Response("ok0", 200) + + @Request.application + def app1(request: Request) -> Response: + return Response("ok1", 200) + + server0 = serve_asgi_adapter(app0) + server1 = serve_asgi_adapter(app1) + + executor = ThreadPoolExecutor(6) + + response0_ftr = executor.submit(requests.get, server0.url) + response1_ftr = executor.submit(requests.get, server1.url) + response2_ftr = executor.submit(requests.get, server0.url) + response3_ftr = executor.submit(requests.get, server1.url) + response4_ftr = executor.submit(requests.get, server0.url) + response5_ftr = executor.submit(requests.get, server1.url) + + executor.shutdown() + + result0 = response0_ftr.result(timeout=2) + assert result0.ok + assert result0.text == "ok0" + result1 = response1_ftr.result(timeout=2) + assert result1.ok + assert result1.text == "ok1" + result2 = response2_ftr.result(timeout=2) + assert result2.ok + assert result2.text == "ok0" + result3 = response3_ftr.result(timeout=2) + assert result3.ok + assert result3.text == "ok1" + result4 = response4_ftr.result(timeout=2) + assert result4.ok + assert result4.text == "ok0" + result5 = response5_ftr.result(timeout=2) + assert result5.ok + assert result5.text == "ok1" + + +def test_lifespan_listener(serve_asgi_adapter): + events = Queue() + + @Request.application + def app(request: Request) -> Response: + events.put("request") + return Response("ok", 200) + + class LifespanListener(ASGILifespanListener): + def on_startup(self): + events.put("startup") + + def on_shutdown(self): + events.put("shutdown") + + listener = LifespanListener() + + server = serve_asgi_adapter(app, listener) + + assert events.get(timeout=5) == "startup" + assert events.qsize() == 0 + + assert requests.get(server.url).ok + + assert events.get(timeout=5) == "request" + assert events.qsize() == 0 + + server.shutdown() + + assert events.get(timeout=5) == "shutdown" + assert events.qsize() == 0 diff --git a/tests/unit/http_/test_client.py b/tests/unit/http_/test_client.py new file mode 100644 index 0000000000000..6967c7b936367 --- /dev/null +++ b/tests/unit/http_/test_client.py @@ -0,0 +1,74 @@ +import contextlib +import ssl + +import certifi +import pytest +from pytest_httpserver import HTTPServer +from requests.exceptions import SSLError + +from localstack.http import Request +from localstack.http.client import SimpleRequestsClient +from localstack.utils.ssl import create_ssl_cert + + +@pytest.fixture(scope="session") +def custom_httpserver_with_ssl(): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + _, cert_file_name, key_file_name = create_ssl_cert() + context.load_cert_chain(cert_file_name, key_file_name) + return context + + +@pytest.fixture(scope="session") +def make_ssl_httpserver(custom_httpserver_with_ssl): + # we don't want to override SSL for every httpserver fixture + # see https://pytest-httpserver.readthedocs.io/en/latest/fixtures.html#make-httpserver + server = HTTPServer(ssl_context=custom_httpserver_with_ssl) + server.start() + yield server + server.clear() + if server.is_running(): + server.stop() + + +@pytest.fixture +def ssl_httpserver(make_ssl_httpserver): + server = make_ssl_httpserver + yield server + server.clear() + + +@pytest.mark.parametrize("verify", [True, False]) +@pytest.mark.parametrize("cert_env", [None, "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) +def test_http_clients_respect_verify(verify, cert_env, ssl_httpserver, monkeypatch): + # If we want to test that a certain environment variable, setting the CA bundle, is set, we + # just set the same path as requests uses anyway (the issues is caused just by the variables being set). + if cert_env: + monkeypatch.setenv(cert_env, certifi.where()) + + client = SimpleRequestsClient() + client.session.verify = verify + + # Configure the SSL http server fixture + expected_response = {"Result": "This request has not been verified!"} + ssl_httpserver.expect_request("/").respond_with_json(expected_response) + request = Request( + scheme="https", + ) + + # Test requests where the verification would fail: + # Either expect an SSL error (if verify = True), or expect the request to be successful (i.e. not raise anything) + context_manager = pytest.raises(SSLError) if verify else contextlib.suppress() + with context_manager: + # Send the request to the server's host, this is never in the SAN of the cert and fails when being verified + response = client.request( + request, server=f"{ssl_httpserver.host}:{ssl_httpserver.port}" + ).json + assert response == expected_response + + # Test requests where the verification is successful: + # Send the request to "localhost.localstack.cloud", which is in the SAN and can be verified + response = client.request( + request, server=f"localhost.localstack.cloud:{ssl_httpserver.port}" + ).json + assert response == expected_response diff --git a/tests/unit/http_/test_dispatcher.py b/tests/unit/http_/test_dispatcher.py new file mode 100644 index 0000000000000..1a4b2421b5e26 --- /dev/null +++ b/tests/unit/http_/test_dispatcher.py @@ -0,0 +1,67 @@ +from typing import Any, Dict + +import pytest +from werkzeug.exceptions import NotFound + +from localstack.http import Request, Response, Router +from localstack.http.dispatcher import handler_dispatcher + + +class TestHandlerDispatcher: + def test_handler_dispatcher(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler_foo(_request: Request) -> Response: + return Response("ok") + + def handler_bar(_request: Request, bar, baz) -> Dict[str, any]: + response = Response() + response.set_json({"bar": bar, "baz": baz}) + return response + + router.add("/foo", handler_foo) + router.add("/bar//", handler_bar) + + assert router.dispatch(Request("GET", "/foo")).data == b"ok" + assert router.dispatch(Request("GET", "/bar/420/ed")).json == {"bar": 420, "baz": "ed"} + + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/bar/asfg/ed")) + + def test_handler_dispatcher_invalid_signature(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, arg1) -> Response: # invalid signature + return Response("ok") + + router.add("/foo//", handler) + + with pytest.raises(TypeError): + router.dispatch(Request("GET", "/foo/a/b")) + + def test_handler_dispatcher_with_dict_return(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, arg1) -> Dict[str, Any]: + return {"arg1": arg1, "hello": "there"} + + router.add("/foo/", handler) + assert router.dispatch(Request("GET", "/foo/a")).json == {"arg1": "a", "hello": "there"} + + def test_handler_dispatcher_with_text_return(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, arg1) -> str: + return f"hello: {arg1}" + + router.add("/", handler) + assert router.dispatch(Request("GET", "/world")).data == b"hello: world" + + def test_handler_dispatcher_with_none_return(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request): + return None + + router.add("/", handler) + assert router.dispatch(Request("GET", "/")).status_code == 200 diff --git a/tests/unit/http_/test_hypercorn.py b/tests/unit/http_/test_hypercorn.py new file mode 100644 index 0000000000000..85693337c9228 --- /dev/null +++ b/tests/unit/http_/test_hypercorn.py @@ -0,0 +1,146 @@ +import re +from contextlib import contextmanager +from typing import Optional + +import requests +from werkzeug.datastructures import Headers +from werkzeug.wrappers import Request as WerkzeugRequest + +from localstack.aws.api import RequestContext +from localstack.aws.chain import HandlerChain +from localstack.aws.gateway import Gateway +from localstack.config import HostAndPort +from localstack.http import Response +from localstack.http.hypercorn import GatewayServer, ProxyServer +from localstack.utils.net import IP_REGEX, get_free_tcp_port +from localstack.utils.serving import Server + + +@contextmanager +def server_context(server: Server, timeout: Optional[float] = 10): + server.start() + server.wait_is_up(timeout) + try: + yield server + finally: + server.shutdown() + + +def test_gateway_server(): + def echo_request_handler(_: HandlerChain, context: RequestContext, response: Response): + response.set_response(context.request.data) + response.status_code = 200 + response.headers = context.request.headers + + gateway = Gateway() + gateway.request_handlers.append(echo_request_handler) + gateway_listen = HostAndPort(host="127.0.0.1", port=get_free_tcp_port()) + server = GatewayServer(gateway, gateway_listen, use_ssl=True) + with server_context(server): + get_response = requests.get( + f"https://localhost.localstack.cloud:{gateway_listen.port}", + data="Let's see if this works...", + ) + assert get_response.text == "Let's see if this works..." + + +def test_proxy_server(httpserver): + httpserver.expect_request("/base-path/relative-path").respond_with_data("Reached Mock Server.") + gateway_listen = HostAndPort(host="127.0.0.1", port=get_free_tcp_port()) + proxy_server = ProxyServer(httpserver.url_for("/base-path"), gateway_listen, use_ssl=True) + with server_context(proxy_server): + # Test that only the base path is added by the proxy + response = requests.get( + f"https://localhost.localstack.cloud:{gateway_listen.port}/relative-path", data="data" + ) + assert response.text == "Reached Mock Server." + + +def test_proxy_server_properly_handles_headers(httpserver): + gateway_listen = HostAndPort(host="127.0.0.1", port=get_free_tcp_port()) + + def header_echo_handler(request: WerkzeugRequest) -> Response: + # The proxy needs to preserve multi-value headers in the request to the backend + headers = Headers(request.headers) + assert "Multi-Value-Header" in headers + assert headers["Multi-Value-Header"] == "Value-1,Value-2" + + # The proxy needs to preserve the Host header (some backend systems use the host header to construct Location URLs) + assert headers["Host"] == f"localhost.localstack.cloud:{gateway_listen.port}" + + # The proxy needs to correctly set the "X-Forwarded-For" header + # It contains the previous XFF header, as well as the IP of the machine which sent the request to the proxy + assert len(request.access_route) == 2 + assert request.access_route[0] == "127.0.0.3" + assert re.match(IP_REGEX, request.access_route[1]) + + # return the headers + return Response(headers=headers) + + httpserver.expect_request("").respond_with_handler(header_echo_handler) + proxy_server = ProxyServer(httpserver.url_for("/"), gateway_listen, use_ssl=True) + + with server_context(proxy_server): + response = requests.request( + "GET", + f"https://localhost.localstack.cloud:{gateway_listen.port}/", + headers={"Multi-Value-Header": "Value-1,Value-2", "X-Forwarded-For": "127.0.0.3"}, + ) + + # The proxy needs to preserve multi-value headers in the response from the backend + assert "Multi-Value-Header" in response.headers + assert response.headers["Multi-Value-Header"] == "Value-1,Value-2" + + +def test_proxy_server_with_chunked_request(httpserver, httpserver_echo_request_metadata): + chunks = [bytes(f"{n:2}", "utf-8") for n in range(0, 100)] + + def handler(request: WerkzeugRequest) -> Response: + # TODO Change this assertion to check for each sent chunk (once the proxy supports that). + # Currently, the proxy does not support streaming the individual chunks directly to the backend. + # Instead, the proxy receives the whole payload from the client and then forwards it + # (maybe in chunks of different size) to the backend. + assert b"".join(chunks) == request.get_data(parse_form_data=False) + return Response() + + httpserver.expect_request("/").respond_with_handler(handler) + gateway_listen = HostAndPort(host="127.0.0.1", port=get_free_tcp_port()) + proxy_server = ProxyServer(httpserver.url_for("/"), gateway_listen, use_ssl=True) + + def chunk_generator(): + for chunk in chunks: + yield chunk + + with server_context(proxy_server): + response = requests.get( + f"https://localhost.localstack.cloud:{gateway_listen.port}/", data=chunk_generator() + ) + assert response + + +def test_proxy_server_with_streamed_response(httpserver): + chunks = [bytes(f"{n:2}", "utf-8") for n in range(0, 100)] + + def chunk_generator(): + for chunk in chunks: + yield chunk + + def stream_response_handler(_: WerkzeugRequest) -> Response: + return Response(response=chunk_generator()) + + httpserver.expect_request("").respond_with_handler(stream_response_handler) + gateway_listen = HostAndPort(host="127.0.0.1", port=get_free_tcp_port()) + proxy_server = ProxyServer(httpserver.url_for("/"), gateway_listen, use_ssl=True) + + with server_context(proxy_server): + with requests.get( + f"https://localhost.localstack.cloud:{gateway_listen.port}/", stream=True + ) as r: + r.raise_for_status() + chunk_iterator = r.iter_content(chunk_size=None) + # TODO Change this assertion to check for each chunk (once the proxy supports that). + # Currently, the proxy does not support streaming the individual chunks directly to the client. + # Instead, the proxy receives the whole payload from the backend and then forwards it + # (maybe in chunks of different size) to the client. + received_chunks = list(chunk_iterator) + assert b"".join(chunks) == b"".join(received_chunks) diff --git a/tests/unit/http_/test_proxy.py b/tests/unit/http_/test_proxy.py new file mode 100644 index 0000000000000..e7b6318bbbd85 --- /dev/null +++ b/tests/unit/http_/test_proxy.py @@ -0,0 +1,258 @@ +import json +from typing import Tuple + +import pytest +import requests +from pytest_httpserver import HTTPServer +from werkzeug import Request as WerkzeugRequest + +from localstack.http import Request, Response, Router +from localstack.http.client import SimpleRequestsClient +from localstack.http.dispatcher import handler_dispatcher +from localstack.http.hypercorn import HypercornServer +from localstack.http.proxy import Proxy, ProxyHandler, forward + + +@pytest.fixture +def router_server(serve_asgi_adapter) -> Tuple[Router, HypercornServer]: + """Creates a new Router with a handler dispatcher, serves it through a newly created ASGI server, and returns + both the router and the server. + """ + router = Router(dispatcher=handler_dispatcher()) + app = WerkzeugRequest.application(router.dispatch) + return router, serve_asgi_adapter(app) + + +class TestPathForwarder: + def test_get_with_path_rule(self, router_server, httpserver: HTTPServer): + router, proxy = router_server + backend = httpserver + + backend.expect_request("/").respond_with_data("ok/") + backend.expect_request("/bar").respond_with_data("ok/bar") + backend.expect_request("/bar/ed").respond_with_data("ok/bar/ed") + + router.add("/foo/", ProxyHandler(backend.url_for("/"))) + + response = requests.get(proxy.url + "/foo/bar") + assert response.ok + assert response.text == "ok/bar" + + response = requests.get(proxy.url + "/foo/bar/ed") + assert response.ok + assert response.text == "ok/bar/ed" + + response = requests.get(proxy.url) + assert not response.ok + + response = requests.get(proxy.url + "/bar") + assert not response.ok + + backend.check() + + def test_get_with_plain_rule(self, router_server, httpserver: HTTPServer): + router, proxy = router_server + backend = httpserver + + backend.expect_request("/").respond_with_data("ok") + + router.add("/foo", ProxyHandler(backend.url_for("/"))) + + response = requests.get(proxy.url + "/foo") + assert response.ok + assert response.text == "ok" + + response = requests.get(proxy.url + "/foo/bar") + assert not response.ok + + def test_get_with_different_base_url(self, router_server, httpserver: HTTPServer): + router, proxy = router_server + backend = httpserver + + backend.expect_request("/bar/ed").respond_with_data("ok/bar/ed") + backend.expect_request("/bar/ed/baz").respond_with_data("ok/bar/ed/baz") + + router.add("/foo/", ProxyHandler(backend.url_for("/bar"))) + + response = requests.get(proxy.url + "/foo/ed") + assert response.ok + assert response.text == "ok/bar/ed" + + response = requests.get(proxy.url + "/foo/ed/baz") + assert response.ok + assert response.text == "ok/bar/ed/baz" + + def test_get_with_different_base_url_plain_rule(self, router_server, httpserver: HTTPServer): + router, proxy = router_server + backend = httpserver + + backend.expect_request("/bar").respond_with_data("ok/bar") + backend.expect_request("/bar/").respond_with_data("ok/bar/") + + router.add("/foo", ProxyHandler(backend.url_for("/bar"))) + + response = requests.get(proxy.url + "/foo") + assert response.ok + assert response.text == "ok/bar/" # it's calling /bar/ because it's part of the root URL + + def test_xff_header(self, router_server, httpserver: HTTPServer): + router, proxy = router_server + backend = httpserver + + def _echo_headers(request): + return Response(json.dumps(dict(request.headers)), mimetype="application/json") + + backend.expect_request("/echo").respond_with_handler(_echo_headers) + + router.add("/", ProxyHandler(backend.url_for("/"))) + + response = requests.get(proxy.url + "/echo") + assert response.ok + headers = response.json() + assert headers["X-Forwarded-For"] == "127.0.0.1" + + # check that it appends remote address correctly if a header is already present + response = requests.get(proxy.url + "/echo", headers={"X-Forwarded-For": "127.0.0.2"}) + assert response.ok + headers = response.json() + assert headers["X-Forwarded-For"] == "127.0.0.2, 127.0.0.1" + + def test_post_form_data_with_query_args(self, router_server, httpserver: HTTPServer): + router, proxy = router_server + backend = httpserver + + def _handler(request: WerkzeugRequest): + data = { + "args": request.args, + "form": request.form, + } + return Response(json.dumps(data), mimetype="application/json") + + backend.expect_request("/form").respond_with_handler(_handler) + + router.add("/", ProxyHandler(backend.url_for("/"))) + + response = requests.post( + proxy.url + "/form?q=yes", + data={"foo": "bar", "baz": "ed"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.ok + doc = response.json() + assert doc == {"args": {"q": "yes"}, "form": {"foo": "bar", "baz": "ed"}} + + def test_path_encoding_preservation(self, router_server, httpserver: HTTPServer): + router, proxy = router_server + backend = httpserver + + def _handler(request: WerkzeugRequest): + from localstack.http.request import get_raw_path + + data = {"path": get_raw_path(request), "query": request.query_string.decode("utf-8")} + return Response(json.dumps(data), mimetype="application/json") + + backend.expect_request("").respond_with_handler(_handler) + + router.add("/", ProxyHandler(backend.url_for("/"))) + + response = requests.get( + proxy.url + + "/arn%3Aaws%3Aservice%3Aeu-west-1%3A000000000000%3Aroot-arn-path%2Fsub-arn-path%2F%2A/%E4%B8%8A%E6%B5%B7%2B%E4%B8%AD%E5%9C%8B?%E4%B8%8A%E6%B5%B7%2B%E4%B8%AD%E5%9C%8B=%E4%B8%8A%E6%B5%B7%2B%E4%B8%AD%E5%9C%8B", + ) + assert response.ok + doc = response.json() + assert doc == { + "path": "/arn%3Aaws%3Aservice%3Aeu-west-1%3A000000000000%3Aroot-arn-path%2Fsub-arn-path%2F%2A/%E4%B8%8A%E6%B5%B7%2B%E4%B8%AD%E5%9C%8B", + "query": "%E4%B8%8A%E6%B5%B7%2B%E4%B8%AD%E5%9C%8B=%E4%B8%8A%E6%B5%B7%2B%E4%B8%AD%E5%9C%8B", + } + + +class TestProxy: + def test_proxy_with_custom_client( + self, httpserver: HTTPServer, httpserver_echo_request_metadata + ): + """The Proxy class allows the injection of a custom HTTP client which can attach default headers to every + request. this test verifies that this works through the proxy implementation.""" + httpserver.expect_request("/").respond_with_handler(httpserver_echo_request_metadata) + + with SimpleRequestsClient() as client: + client.session.headers["X-My-Custom-Header"] = "hello world" + + proxy = Proxy(httpserver.url_for("/").lstrip("/"), client) + + request = Request( + path="/", + method="POST", + body="foobar", + remote_addr="127.0.0.10", + headers={"Host": "127.0.0.1:80"}, + ) + + response = proxy.request(request) + + assert "X-My-Custom-Header" in response.json["headers"] + assert response.json["method"] == "POST" + assert response.json["headers"]["X-My-Custom-Header"] == "hello world" + assert response.json["headers"]["X-Forwarded-For"] == "127.0.0.10" + assert response.json["headers"]["Host"] == "127.0.0.1:80" + + +@pytest.mark.parametrize("consume_data", [True, False]) +def test_forward_files_and_form_data_proxy_consumes_data( + consume_data, serve_asgi_adapter, tmp_path +): + """Tests that, when the proxy consumes (or doesn't consume) the request object's data prior to forwarding, + the request is forwarded correctly. not using httpserver here because it consumes werkzeug data incorrectly (it + calls ``request.get_data()``).""" + + @WerkzeugRequest.application + def _backend_handler(request: WerkzeugRequest): + data = { + "data": request.data.decode("utf-8"), + "args": request.args, + "form": request.form, + "files": { + name: storage.stream.read().decode("utf-8") + for name, storage in request.files.items() + }, + } + return Response(json.dumps(data), mimetype="application/json") + + @WerkzeugRequest.application + def _proxy_handler(request: WerkzeugRequest): + # heuristic to check whether the stream has been consumed + assert getattr(request, "_cached_data", None) is None, "data has already been cached" + + if consume_data: + assert ( + not request.data + ) # data should be empty because it is consumed by parsing form data + + return forward(request, forward_base_url=backend.url) + + backend = serve_asgi_adapter(_backend_handler) + proxy = serve_asgi_adapter(_proxy_handler) + + tmp_file_1 = tmp_path / "temp_file_1.txt" + tmp_file_1.write_text("1: hello\nworld") + + tmp_file_2 = tmp_path / "temp_file_2.txt" + tmp_file_2.write_text("2: foobar") + + response = requests.post( + proxy.url, + params={"q": "yes"}, + data={"foo": "bar", "baz": "ed"}, + files={"upload_file_1": open(tmp_file_1, "rb"), "upload_file_2": open(tmp_file_2, "rb")}, + ) + assert response.ok + doc = response.json() + assert doc == { + "data": "", + "args": {"q": "yes"}, + "form": {"foo": "bar", "baz": "ed"}, + "files": { + "upload_file_1": "1: hello\nworld", + "upload_file_2": "2: foobar", + }, + } diff --git a/tests/unit/http_/test_request.py b/tests/unit/http_/test_request.py new file mode 100644 index 0000000000000..83380e402b0e1 --- /dev/null +++ b/tests/unit/http_/test_request.py @@ -0,0 +1,208 @@ +import wsgiref.validate + +import pytest +from werkzeug.exceptions import BadRequest + +from localstack.http.request import Request, dummy_wsgi_environment, get_raw_path + + +def test_get_json(): + r = Request( + "POST", + "/", + headers={"Content-Type": "application/json"}, + body=b'{"foo": "bar", "baz": 420}', + ) + assert r.json == {"foo": "bar", "baz": 420} + assert r.content_type == "application/json" + + +def test_get_json_force(): + r = Request("POST", "/", body=b'{"foo": "bar", "baz": 420}') + assert r.get_json(force=True) == {"foo": "bar", "baz": 420} + + +def test_get_json_invalid(): + r = Request("POST", "/", body=b'{"foo": "') + + with pytest.raises(BadRequest): + assert r.get_json(force=True) + + assert r.get_json(force=True, silent=True) is None + + +def test_get_data(): + r = Request("GET", "/", body="foobar") + assert r.data == b"foobar" + + +def test_get_data_as_text(): + r = Request("GET", "/", body="foobar") + assert r.get_data(as_text=True) == "foobar" + + +def test_get_stream(): + r = Request("GET", "/", body=b"foobar") + assert r.stream.read(3) == b"foo" + assert r.stream.read(3) == b"bar" + + +def test_args(): + r = Request("GET", "/", query_string="foo=420&bar=69") + assert len(r.args) == 2 + assert r.args["foo"] == "420" + assert r.args["bar"] == "69" + + +def test_values(): + r = Request("GET", "/", query_string="foo=420&bar=69") + assert len(r.values) == 2 + assert r.values["foo"] == "420" + assert r.values["bar"] == "69" + + +def test_form_empty(): + r = Request("POST", "/") + assert len(r.form) == 0 + + +def test_post_form_urlencoded_and_query(): + # see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST#example + r = Request( + "POST", + "/form", + query_string="query1=foo&query2=bar", + body=b"field1=value1&field2=value2", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + assert len(r.form) == 2 + assert r.form["field1"] == "value1" + assert r.form["field2"] == "value2" + + assert len(r.args) == 2 + assert r.args["query1"] == "foo" + assert r.args["query2"] == "bar" + + assert len(r.values) == 4 + assert r.values["field1"] == "value1" + assert r.values["field2"] == "value2" + assert r.args["query1"] == "foo" + assert r.args["query2"] == "bar" + + +def test_validate_dummy_environment(): + def validate(*args, **kwargs): + assert wsgiref.validate.check_environ(dummy_wsgi_environment(*args, **kwargs)) is None + + validate(path="/foo/bar", body="foo") + validate(path="/foo/bar", query_string="foo=420&bar=69") + validate(server=("localstack.cloud", 4566)) + validate(server=("localstack.cloud", None)) + validate(remote_addr="127.0.0.1") + validate(headers={"Content-Type": "text/xml"}, body=b"") + validate(headers={"Content-Type": "text/xml", "x-amz-target": "foobar"}, body=b"") + + +def test_content_length_is_set_automatically(): + # checking that the value is calculated automatically + request = Request("GET", "/", body="foobar") + assert request.content_length == 6 + + +def test_content_length_is_overwritten(): + # checking that the value passed from headers take precedence + request = Request("GET", "/", body="foobar", headers={"Content-Length": "7"}) + assert request.content_length == 7 + + +def test_get_custom_headers(): + request = Request("GET", "/", body="foobar", headers={"x-amz-target": "foobar"}) + assert request.headers["x-amz-target"] == "foobar" + + +def test_get_raw_path(): + request = Request("GET", "/foo/bar/ed", raw_path="/foo%2Fbar/ed") + + assert request.path == "/foo/bar/ed" + assert request.environ["RAW_URI"] == "/foo%2Fbar/ed" + assert get_raw_path(request) == "/foo%2Fbar/ed" + + +def test_get_raw_path_with_query(): + request = Request("GET", "/foo/bar/ed", raw_path="/foo%2Fbar/ed?fizz=buzz") + + assert request.path == "/foo/bar/ed" + assert request.environ["RAW_URI"] == "/foo%2Fbar/ed?fizz=buzz" + assert get_raw_path(request) == "/foo%2Fbar/ed" + + +def test_get_raw_path_with_prefix_slashes(): + request = Request("GET", "/foo/bar/ed", raw_path="//foo%2Fbar/ed?fizz=buzz") + + assert request.path == "/foo/bar/ed" + assert request.environ["RAW_URI"] == "//foo%2Fbar/ed?fizz=buzz" + assert get_raw_path(request) == "//foo%2Fbar/ed" + + +def test_get_raw_path_with_full_uri(): + # raw_path is actually raw_uri in the WSGI environment + # it can be a full URL + request = Request("GET", "/foo/bar/ed", raw_path="http://localhost:4566/foo%2Fbar/ed") + + assert request.path == "/foo/bar/ed" + assert request.environ["RAW_URI"] == "http://localhost:4566/foo%2Fbar/ed" + assert get_raw_path(request) == "/foo%2Fbar/ed" + + +def test_headers_retain_dashes(): + request = Request("GET", "/foo/bar/ed", {"X-Amz-Meta--foo_bar-ed": "foobar"}) + assert "x-amz-meta--foo_bar-ed" in request.headers + assert request.headers["x-amz-meta--foo_bar-ed"] == "foobar" + + +def test_headers_retain_case(): + request = Request("GET", "/foo/bar/ed", {"X-Amz-Meta--FOO_BaR-ed": "foobar"}) + keys = list(request.headers.keys()) + for k in keys: + if k.lower().startswith("x-amz-meta"): + assert k == "X-Amz-Meta--FOO_BaR-ed" + return + pytest.fail(f"key not in header keys {keys}") + + +def test_multipart_parsing(): + body = ( + b"--4efd159eae0c4f4e125a5a509e073d85" + b"\r\n" + b'Content-Disposition: form-data; name="foo"; filename="foo"' + b"\r\n\r\n" + b"bar" + b"\r\n" + b"--4efd159eae0c4f4e125a5a509e073d85" + b"\r\n" + b'Content-Disposition: form-data; name="baz"; filename="baz"' + b"\r\n\r\n" + b"ed" + b"\r\n--4efd159eae0c4f4e125a5a509e073d85--" + b"\r\n" + ) + + request = Request( + "POST", + path="/", + body=body, + headers={"Content-Type": "multipart/form-data; boundary=4efd159eae0c4f4e125a5a509e073d85"}, + ) + result = {} + for k, file_storage in request.files.items(): + result[k] = file_storage.stream.read().decode("utf-8") + + assert result == {"foo": "bar", "baz": "ed"} + + +def test_utf8_path(): + r = Request("GET", "/foo/Δ€0Γ„") + + assert r.path == "/foo/Δ€0Γ„" + assert r.environ["PATH_INFO"] == "/foo/Γ„\x800Γƒ\x84" # quoted and latin-1 encoded diff --git a/tests/unit/http_/test_resource.py b/tests/unit/http_/test_resource.py new file mode 100644 index 0000000000000..c444e96e27e7f --- /dev/null +++ b/tests/unit/http_/test_resource.py @@ -0,0 +1,132 @@ +import pytest +from werkzeug.exceptions import MethodNotAllowed + +from localstack.http import Request, Resource, Response, Router, resource +from localstack.http.dispatcher import handler_dispatcher + + +class TestResource: + def test_resource_decorator_dispatches_correctly(self): + router = Router(dispatcher=handler_dispatcher()) + + requests = [] + + @resource("/_localstack/health") + class TestResource: + def on_get(self, req): + requests.append(req) + return "GET/OK" + + def on_post(self, req): + requests.append(req) + return {"ok": "POST"} + + def on_head(self, req): + # this is ignored + requests.append(req) + return "HEAD/OK" + + router.add(TestResource()) + + request1 = Request("GET", "/_localstack/health") + request2 = Request("POST", "/_localstack/health") + request3 = Request("HEAD", "/_localstack/health") + assert router.dispatch(request1).get_data(True) == "GET/OK" + assert router.dispatch(request1).get_data(True) == "GET/OK" + assert router.dispatch(request2).json == {"ok": "POST"} + assert router.dispatch(request3).get_data(True) == "HEAD/OK" + assert len(requests) == 4 + assert requests[0] is request1 + assert requests[1] is request1 + assert requests[2] is request2 + assert requests[3] is request3 + + def test_resource_dispatches_correctly(self): + router = Router(dispatcher=handler_dispatcher()) + + class TestResource: + def on_get(self, req): + return "GET/OK" + + def on_post(self, req): + return "POST/OK" + + def on_head(self, req): + return "HEAD/OK" + + router.add(Resource("/_localstack/health", TestResource())) + + request1 = Request("GET", "/_localstack/health") + request2 = Request("POST", "/_localstack/health") + request3 = Request("HEAD", "/_localstack/health") + assert router.dispatch(request1).get_data(True) == "GET/OK" + assert router.dispatch(request2).get_data(True) == "POST/OK" + assert router.dispatch(request3).get_data(True) == "HEAD/OK" + + def test_dispatch_to_non_existing_method_raises_exception(self): + router = Router(dispatcher=handler_dispatcher()) + + @resource("/_localstack/health") + class TestResource: + def on_post(self, request): + return "POST/OK" + + router.add(TestResource()) + + with pytest.raises(MethodNotAllowed): + assert router.dispatch(Request("GET", "/_localstack/health")) + assert router.dispatch(Request("POST", "/_localstack/health")).get_data(True) == "POST/OK" + + def test_resource_with_default_dispatcher(self): + router = Router() + + @resource("/_localstack/") + class TestResource: + def on_get(self, req, args): + return Response.for_json({"message": "GET/OK", "path": args["path"]}) + + def on_post(self, req, args): + return Response.for_json({"message": "POST/OK", "path": args["path"]}) + + router.add(TestResource()) + assert router.dispatch(Request("GET", "/_localstack/health")).json == { + "message": "GET/OK", + "path": "health", + } + assert router.dispatch(Request("POST", "/_localstack/foobar")).json == { + "message": "POST/OK", + "path": "foobar", + } + + def test_resource_overwrite_with_resource_wrapper(self): + router = Router(dispatcher=handler_dispatcher()) + + @resource("/_localstack/health") + class TestResourceHealth: + def on_get(self, req): + return Response.for_json({"message": "GET/OK", "path": req.path}) + + def on_post(self, req): + return Response.for_json({"message": "POST/OK", "path": req.path}) + + endpoints = TestResourceHealth() + router.add(endpoints) + router.add(Resource("/health", endpoints)) + + assert router.dispatch(Request("GET", "/_localstack/health")).json == { + "message": "GET/OK", + "path": "/_localstack/health", + } + assert router.dispatch(Request("POST", "/_localstack/health")).json == { + "message": "POST/OK", + "path": "/_localstack/health", + } + + assert router.dispatch(Request("GET", "/health")).json == { + "message": "GET/OK", + "path": "/health", + } + assert router.dispatch(Request("POST", "/health")).json == { + "message": "POST/OK", + "path": "/health", + } diff --git a/tests/unit/http_/test_router.py b/tests/unit/http_/test_router.py new file mode 100644 index 0000000000000..149cdba1ff005 --- /dev/null +++ b/tests/unit/http_/test_router.py @@ -0,0 +1,618 @@ +import threading +from typing import List, Tuple + +import pytest +import requests +import werkzeug +from werkzeug.exceptions import MethodNotAllowed, NotFound +from werkzeug.routing import RequestRedirect, Submount + +from localstack.http import Request, Response, Router +from localstack.http.router import ( + E, + GreedyPathConverter, + RequestArguments, + RuleAdapter, + WithHost, + route, +) +from localstack.utils.common import get_free_tcp_port + + +def noop(*args, **kwargs): + """Test dispatcher that does nothing""" + return Response() + + +def echo_params_json(request: Request, params: dict[str, str]): + """Test dispatcher that echoes the url match parameters as json""" + r = Response() + r.set_json(params) + return r + + +class RequestCollector: + """Test dispatcher that collects requests into a list""" + + requests: List[Tuple[Request, E, RequestArguments]] + + def __init__(self) -> None: + super().__init__() + self.requests = [] + + def __call__(self, request: Request, endpoint: E, args: RequestArguments) -> Response: + self.requests.append((request, endpoint, args)) + return Response() + + +class TestRouter: + # these are sanity check for the router and dispatching logic. since the matching is done by werkzeug's Map, + # there is no need for thorough testing URL matching. + + def test_dispatch_raises_not_found(self): + router = Router() + router.add("/foobar", noop) + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/foo")) + + def test_default_dispatcher_invokes_correct_endpoint(self): + router = Router() + + def index(_: Request, args) -> Response: + response = Response() + response.set_json(args) + return response + + def users(_: Request, args) -> Response: + response = Response() + response.set_json(args) + return response + + router.add("/", index) + router.add("/users/", users) + + assert router.dispatch(Request("GET", "/")).json == {} + assert router.dispatch(Request("GET", "/users/12")).json == {"user_id": 12} + + def test_dispatch_with_host_matching(self): + router = Router() + + def ep_all(_: Request, args) -> Response: + response = Response() + response.set_json(dict(method="all", **args)) + return response + + def ep_index1(_: Request, args) -> Response: + response = Response() + response.set_json(dict(method="1", **args)) + return response + + def ep_index2(_: Request, args) -> Response: + response = Response() + response.set_json(dict(method="2", **args)) + return response + + router.add("/", ep_index1, host="localhost:") + router.add("/", ep_index2, host="localhost:12345") + router.add("/all", ep_all, host="") + + def invoke(path, server, port): + return router.dispatch(Request("GET", path, server=(server, port))).json + + assert invoke("/", "localhost", 4566) == {"method": "1", "port": "4566"} + assert invoke("/", "localhost", 12345) == {"method": "2"} + assert invoke("/all", "127.0.0.1", None) == {"method": "all", "host": "127.0.0.1"} + assert invoke("/all", "127.0.0.1", 12345) == {"method": "all", "host": "127.0.0.1:12345"} + + with pytest.raises(NotFound): + invoke("/", "localstack.cloud", None) + + def test_custom_dispatcher(self): + collector = RequestCollector() + router = Router(dispatcher=collector) + + router.add("/", "index") + router.add("/users/", "users") + + router.dispatch(Request("GET", "/")) + router.dispatch(Request("GET", "/users/12")) + + _, endpoint, args = collector.requests[0] + assert endpoint == "index" + assert args == {} + + _, endpoint, args = collector.requests[1] + assert endpoint == "users" + assert args == {"id": 12} + + def test_regex_path_dispatcher(self): + router = Router() + rgx = r"([^.]+)endpoint(.*)" + regex = f"//" + router.add(path=regex, endpoint=noop) + assert router.dispatch(Request(method="GET", path="/test-endpoint")) + with pytest.raises(NotFound): + router.dispatch(Request(method="GET", path="/test-not-point")) + + def test_regex_host_dispatcher(self): + router = Router() + rgx = r"\.cloudfront.(net|localhost\.localstack\.cloud)" + router.add(path="/", endpoint=noop, host=f":") + assert router.dispatch( + Request( + method="GET", + headers={"Host": "ad91f538.cloudfront.localhost.localstack.cloud:5446"}, + ) + ) + with pytest.raises(NotFound): + router.dispatch( + Request( + method="GET", + headers={"Host": "ad91f538.cloudfront.amazon.aws.com:5446"}, + ) + ) + + def test_port_host_dispatcher(self): + collector = RequestCollector() + router = Router(dispatcher=collector) + router.add(path="/", endpoint=noop, host="localhost.localstack.cloud") + # matches with the port! + assert router.dispatch( + Request( + method="GET", + headers={"Host": "localhost.localstack.cloud:4566"}, + ) + ) + assert collector.requests.pop()[2] == {"port": 4566} + # matches without the port! + assert router.dispatch( + Request( + method="GET", + headers={"Host": "localhost.localstack.cloud"}, + ) + ) + assert collector.requests.pop()[2] == {"port": None} + + # invalid port + with pytest.raises(NotFound): + router.dispatch( + Request( + method="GET", + headers={"Host": "localhost.localstack.cloud:544a6"}, + ) + ) + + # does not match the host + with pytest.raises(NotFound): + router.dispatch( + Request( + method="GET", + headers={"Host": "localstack.cloud:5446"}, + ) + ) + + def test_path_converter(self): + router = Router() + router.add(path="/", endpoint=echo_params_json) + + assert router.dispatch(Request(path="/my")).json == {"path": "my"} + assert router.dispatch(Request(path="/my/")).json == {"path": "my/"} + assert router.dispatch(Request(path="/my//path")).json == {"path": "my//path"} + assert router.dispatch(Request(path="/my//path/")).json == {"path": "my//path/"} + assert router.dispatch(Request(path="/my/path foobar")).json == {"path": "my/path foobar"} + assert router.dispatch(Request(path="//foobar")).json == {"path": "foobar"} + assert router.dispatch(Request(path="//foobar/")).json == {"path": "foobar/"} + + def test_path_converter_with_args(self): + router = Router() + router.add(path="/with-args//", endpoint=echo_params_json) + + assert router.dispatch(Request(path="/with-args/123456/my")).json == { + "some_id": "123456", + "path": "my", + } + + # werkzeug no longer removes trailing slashes in matches + assert router.dispatch(Request(path="/with-args/123456/my/")).json == { + "some_id": "123456", + "path": "my/", + } + + # works with sub paths + assert router.dispatch(Request(path="/with-args/123456/my/path")).json == { + "some_id": "123456", + "path": "my/path", + } + + # no sub path raises 404 + with pytest.raises(NotFound): + router.dispatch(Request(path="/with-args/123456")) + + with pytest.raises(NotFound): + router.dispatch(Request(path="/with-args/123456/")) + + # with the default slash behavior of the URL map (merge_slashes=False), werkzeug tries to redirect + # the call to /with-args/123456/my/ (note: this is desirable for web servers, not always for us + # though) + with pytest.raises(RequestRedirect): + assert router.dispatch(Request(path="/with-args/123456//my/")) + + def test_path_converter_and_regex_converter_in_host(self): + router = Router() + router.add( + path="/", + host="foobar.us-east-1.opensearch.localhost.localstack.cloud", + endpoint=echo_params_json, + ) + assert router.dispatch( + Request( + method="GET", + path="/_cluster/health", + headers={"Host": "foobar.us-east-1.opensearch.localhost.localstack.cloud:4566"}, + ) + ).json == {"path": "_cluster/health", "port": ":4566"} + + def test_path_converter_and_port_converter_in_host(self): + router = Router() + router.add( + path="/", + host="foobar.us-east-1.opensearch.localhost.localstack.cloud", + endpoint=echo_params_json, + ) + assert router.dispatch( + Request( + method="GET", + path="/_cluster/health", + headers={"Host": "foobar.us-east-1.opensearch.localhost.localstack.cloud:4566"}, + ) + ).json == {"path": "_cluster/health", "port": 4566} + + assert router.dispatch( + Request( + method="GET", + path="/_cluster/health", + headers={"Host": "foobar.us-east-1.opensearch.localhost.localstack.cloud"}, + ) + ).json == {"path": "_cluster/health", "port": None} + + def test_path_converter_and_greedy_regex_in_host(self): + router = Router() + router.add( + path="/", + # note how the regex '.*' will also include the port (so port will not do anything) + host="foobar.us-east-1.opensearch.", + endpoint=echo_params_json, + ) + assert router.dispatch( + Request( + method="GET", + path="/_cluster/health", + headers={"Host": "foobar.us-east-1.opensearch.localhost.localstack.cloud:4566"}, + ) + ).json == { + "path": "_cluster/health", + "host": "localhost.localstack.cloud:4566", + "port": None, + } + + def test_greedy_path_converter(self): + router = Router(converters={"greedy_path": GreedyPathConverter}) + router.add(path="/", endpoint=echo_params_json) + + assert router.dispatch(Request(path="/my")).json == {"path": "my"} + assert router.dispatch(Request(path="/my/")).json == {"path": "my/"} + assert router.dispatch(Request(path="/my//path")).json == {"path": "my//path"} + assert router.dispatch(Request(path="/my//path/")).json == {"path": "my//path/"} + assert router.dispatch(Request(path="/my/path foobar")).json == {"path": "my/path foobar"} + assert router.dispatch(Request(path="//foobar")).json == {"path": "foobar"} + assert router.dispatch(Request(path="//foobar/")).json == {"path": "foobar/"} + + def test_greedy_path_converter_with_args(self): + router = Router(converters={"greedy_path": GreedyPathConverter}) + router.add(path="/with-args//", endpoint=echo_params_json) + + assert router.dispatch(Request(path="/with-args/123456/my")).json == { + "some_id": "123456", + "path": "my", + } + + # werkzeug no longer removes trailing slashes in matches + assert router.dispatch(Request(path="/with-args/123456/my/")).json == { + "some_id": "123456", + "path": "my/", + } + + # works with sub paths + assert router.dispatch(Request(path="/with-args/123456/my/path")).json == { + "some_id": "123456", + "path": "my/path", + } + + # no sub path with no trailing slash raises 404 + with pytest.raises(NotFound): + router.dispatch(Request(path="/with-args/123456")) + + # greedy path accepts empty sub path if there's a trailing slash + assert router.dispatch(Request(path="/with-args/123456/")).json == { + "some_id": "123456", + "path": "", + } + + # with the GreedyPath converter, we no longer redirect and accept the request + # in order the retrieve the double slash between parameter, we might need to use the RAW_URI + assert router.dispatch(Request(path="/with-args/123456//my/test//")).json == { + "some_id": "123456", + "path": "/my/test//", + } + + def test_remove_rule(self): + router = Router() + + def index(_: Request, args) -> Response: + return Response(b"index") + + def users(_: Request, args) -> Response: + return Response(b"users") + + rule0 = router.add("/", index) + rule1 = router.add("/users/", users) + + assert router.dispatch(Request("GET", "/")).data == b"index" + assert router.dispatch(Request("GET", "/users/12")).data == b"users" + + router.remove(rule1) + + assert router.dispatch(Request("GET", "/")).data == b"index" + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/users/12")) + + router.remove(rule0) + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/")) + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/users/12")) + + def test_remove_rules(self): + router = Router() + + class MyRoutes: + @route("/a") + @route("/a2") + def route_a(self, request, args): + return Response(b"a") + + @route("/b") + def route_b(self, request, args): + return Response(b"b") + + rules = router.add(MyRoutes()) + + assert router.dispatch(Request("GET", "/a")).data == b"a" + assert router.dispatch(Request("GET", "/a2")).data == b"a" + assert router.dispatch(Request("GET", "/b")).data == b"b" + + router.remove(rules) + + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/a")) + + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/a2")) + + with pytest.raises(NotFound): + assert router.dispatch(Request("GET", "/b")) + + def test_remove_non_existing_rule(self): + router = Router() + + def index(_: Request, args) -> Response: + return Response(b"index") + + rule = router.add("/", index) + router.remove(rule) + + with pytest.raises(KeyError) as e: + router.remove(rule) + e.match("no such rule") + + def test_router_route_decorator(self): + router = Router() + + @router.route("/users") + @router.route("/alternative-users") + def user(_: Request, args): + assert not args + return Response("user") + + @router.route("/users/") + def user_id(_: Request, args): + assert args + return Response(f"{args['user_id']}") + + assert router.dispatch(Request("GET", "/users")).data == b"user" + assert router.dispatch(Request("GET", "/alternative-users")).data == b"user" + assert router.dispatch(Request("GET", "/users/123")).data == b"123" + + def test_add_route_endpoint_with_object(self): + class MySuperApi: + @route("/users") + def user(self, _: Request, args): + # should be inherited + assert not args + return Response("user") + + class MyApi(MySuperApi): + @route("/users/") + def user_id(self, _: Request, args): + assert args + return Response(f"{args['user_id']}") + + def foo(self, _: Request, args): + # should be ignored + raise NotImplementedError + + api = MyApi() + router = Router() + rules = router.add(api) + assert len(rules) == 2 + + assert router.dispatch(Request("GET", "/users")).data == b"user" + assert router.dispatch(Request("GET", "/users/123")).data == b"123" + + def test_add_route_endpoint_with_object_per_method(self): + # tests whether there can be multiple rules with different methods to the same URL + class MyApi: + @route("/my_api", methods=["GET"]) + def do_get(self, request: Request, _args): + # should be inherited + return Response(f"{request.path}/do-get") + + @route("/my_api", methods=["HEAD"]) + def do_head(self, request: Request, _args): + # should be inherited + return Response(f"{request.path}/do-head") + + @route("/my_api", methods=["POST", "PUT"]) + def do_post(self, request: Request, _args): + # should be inherited + return Response(f"{request.path}/do-post-or-put") + + api = MyApi() + router = Router() + rules = router.add(api) + assert len(rules) == 3 + + assert router.dispatch(Request("GET", "/my_api")).data == b"/my_api/do-get" + assert router.dispatch(Request("HEAD", "/my_api")).data == b"/my_api/do-head" + assert router.dispatch(Request("POST", "/my_api")).data == b"/my_api/do-post-or-put" + assert router.dispatch(Request("PUT", "/my_api")).data == b"/my_api/do-post-or-put" + + with pytest.raises(MethodNotAllowed): + router.dispatch(Request("DELETE", "/my_api")) + + def test_head_requests_are_routed_to_get_handlers(self): + @route("/my_api", methods=["GET"]) + def do_get(request: Request, _args): + # should be inherited + return Response(f"{request.path}/do-get") + + router = Router() + router.add(do_get) + + assert router.dispatch(Request("GET", "/my_api")).data == b"/my_api/do-get" + assert router.dispatch(Request("HEAD", "/my_api")).data == b"/my_api/do-get" + + def test_submount_rule_adapter(self): + @route("/my_api", methods=["GET"]) + def do_get(request: Request, _args): + # should be inherited + return Response(f"{request.path}/do-get") + + def hello(request: Request, _args): + return Response("hello world") + + router = Router() + + # base endpoints + endpoints = RuleAdapter([do_get, RuleAdapter("/hello", hello)]) + + router.add([endpoints, Submount("/foo", [endpoints])]) + + assert router.dispatch(Request("GET", "/foo/my_api")).data == b"/foo/my_api/do-get" + assert router.dispatch(Request("GET", "/my_api")).data == b"/my_api/do-get" + + assert router.dispatch(Request("GET", "/foo/hello")).data == b"hello world" + assert router.dispatch(Request("GET", "/hello")).data == b"hello world" + + def test_with_host_and_submount(self): + @route("/my_api", methods=["GET"]) + def do_get(request: Request, _args): + response = Response() + response.set_json({"path": request.path, "host": request.host}) + return response + + router = Router() + + router.add( + [ + WithHost( + "foo.localhost.localstack.cloud:4566", + [RuleAdapter(do_get)], + ), + Submount( + "/foo", + [RuleAdapter(do_get)], + ), + ] + ) + + request = Request("GET", "/foo/my_api") + assert router.dispatch(request).json == { + "host": "127.0.0.1", + "path": "/foo/my_api", + } + + request = Request("GET", "/my_api", server=("foo.localhost.localstack.cloud", 4566)) + assert router.dispatch(request).json == { + "path": "/my_api", + "host": "foo.localhost.localstack.cloud:4566", + } + + request = Request("GET", "/my_api", server=("localhost.localstack.cloud", 4566)) + with pytest.raises(NotFound): + router.dispatch(request) + + +class TestWsgiIntegration: + def test_with_werkzeug(self): + # setup up router + router = Router() + + def index(_: Request, args) -> Response: + return Response(b"index") + + def echo_json(request: Request, args) -> Response: + response = Response() + response.set_json(request.json) + return response + + def users(_: Request, args) -> Response: + response = Response() + response.set_json(args) + return response + + router.add("/", index) + router.add("/users/", users, host=":") + router.add("/echo/", echo_json, methods=["POST"]) + + # serve router through werkzeug + @werkzeug.Request.application + def app(request: werkzeug.Request) -> werkzeug.Response: + return router.dispatch(request) + + host = "localhost" + port = get_free_tcp_port() + url = f"http://{host}:{port}" + + server = werkzeug.serving.make_server(host, port, app=app, threaded=True) + t = threading.Thread(target=server.serve_forever) + t.start() + + try: + resp = requests.get(f"{url}/") + assert resp.ok + assert resp.content == b"index" + + resp = requests.get(f"{url}/users/123") + assert resp.ok + assert resp.json() == {"user_id": 123, "host": host, "port": str(port)} + + resp = requests.get(f"{url}/users") + assert not resp.ok + + resp = requests.post(f"{url}/echo", json={"foo": "bar", "a": 420}) + assert resp.ok + assert resp.json() == {"foo": "bar", "a": 420} + finally: + server.shutdown() + t.join(timeout=10) diff --git a/tests/unit/http_/test_websockets.py b/tests/unit/http_/test_websockets.py new file mode 100644 index 0000000000000..78b6480f80d05 --- /dev/null +++ b/tests/unit/http_/test_websockets.py @@ -0,0 +1,158 @@ +import json +import threading +from queue import Queue + +import pytest +import websocket +from werkzeug.datastructures import Headers + +from localstack.http import Router +from localstack.http.websocket import ( + WebSocketDisconnectedError, + WebSocketProtocolError, + WebSocketRequest, +) + + +def test_websocket_basic_interaction(serve_asgi_adapter): + raised = threading.Event() + + @WebSocketRequest.listener + def app(request: WebSocketRequest): + with request.accept() as ws: + ws.send("hello") + assert ws.receive() == "foobar" + ws.send("world") + + with pytest.raises(WebSocketDisconnectedError): + ws.receive() + + raised.set() + + server = serve_asgi_adapter(wsgi_app=None, websocket_listener=app) + + client = websocket.WebSocket() + client.connect(server.url.replace("http://", "ws://")) + assert client.recv() == "hello" + client.send("foobar") + assert client.recv() == "world" + client.close() + + assert raised.wait(timeout=3) + + +def test_websocket_disconnect_while_iter(serve_asgi_adapter): + """Makes sure that the ``for line in iter(ws)`` pattern works smoothly when the client disconnects.""" + returned = threading.Event() + received = [] + + @WebSocketRequest.listener + def app(request: WebSocketRequest): + with request.accept() as ws: + for line in iter(ws): + received.append(line) + + returned.set() + + server = serve_asgi_adapter(wsgi_app=None, websocket_listener=app) + + client = websocket.WebSocket() + client.connect(server.url.replace("http://", "ws://")) + + client.send("foo") + client.send("bar") + client.close() + + assert returned.wait(timeout=3) + assert received[0] == "foo" + assert received[1] == "bar" + + +def test_websocket_headers(serve_asgi_adapter): + @WebSocketRequest.listener + def echo_headers(request: WebSocketRequest): + with request.accept(headers=Headers({"x-foo-bar": "foobar"})) as ws: + ws.send(json.dumps(dict(request.headers))) + + server = serve_asgi_adapter(wsgi_app=None, websocket_listener=echo_headers) + + client = websocket.WebSocket() + client.connect( + server.url.replace("http://", "ws://"), header=["Authorization: Basic let-me-in"] + ) + + assert client.handshake_response.status == 101 + assert client.getheaders()["x-foo-bar"] == "foobar" + doc = client.recv() + headers = json.loads(doc) + assert headers["Connection"] == "Upgrade" + assert headers["Authorization"] == "Basic let-me-in" + + +def test_binary_and_text_mode(serve_asgi_adapter): + received = Queue() + + @WebSocketRequest.listener + def echo_headers(request: WebSocketRequest): + with request.accept() as ws: + ws.send(b"foo") + ws.send("textfoo") + received.put(ws.receive()) + received.put(ws.receive()) + + server = serve_asgi_adapter(wsgi_app=None, websocket_listener=echo_headers) + + client = websocket.WebSocket() + client.connect(server.url.replace("http://", "ws://")) + + assert client.handshake_response.status == 101 + data = client.recv() + assert data == b"foo" + + data = client.recv() + assert data == "textfoo" + + client.send("textbar") + client.send_binary(b"bar") + + assert received.get(timeout=5) == "textbar" + assert received.get(timeout=5) == b"bar" + + +def test_send_non_confirming_data(serve_asgi_adapter): + match = Queue() + + @WebSocketRequest.listener + def echo_headers(request: WebSocketRequest): + with request.accept() as ws: + with pytest.raises(WebSocketProtocolError) as e: + ws.send({"foo": "bar"}) + match.put(e) + + server = serve_asgi_adapter(wsgi_app=None, websocket_listener=echo_headers) + + client = websocket.WebSocket() + client.connect(server.url.replace("http://", "ws://")) + + e = match.get(timeout=5) + assert e.match("Cannot send data type over websocket") + + +def test_router_integration(serve_asgi_adapter): + router = Router() + + def _handler(request: WebSocketRequest, request_args: dict): + with request.accept() as ws: + ws.send("foo") + ws.send(f"id={request_args['id']}") + + router.add("/foo/", _handler) + + server = serve_asgi_adapter( + wsgi_app=None, + websocket_listener=WebSocketRequest.listener(router.dispatch), + ) + client = websocket.WebSocket() + client.connect(server.url.replace("http://", "ws://") + "/foo/bar") + assert client.recv() == "foo" + assert client.recv() == "id=bar" diff --git a/tests/unit/lambda_debug_mode/__init__.py b/tests/unit/lambda_debug_mode/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/lambda_debug_mode/test_config_parsing.py b/tests/unit/lambda_debug_mode/test_config_parsing.py new file mode 100644 index 0000000000000..5dd4ad45468c5 --- /dev/null +++ b/tests/unit/lambda_debug_mode/test_config_parsing.py @@ -0,0 +1,134 @@ +import pytest + +from localstack.utils.lambda_debug_mode.lambda_debug_mode_config import ( + load_lambda_debug_mode_config, +) + +DEBUG_CONFIG_EMPTY = "" + +DEBUG_CONFIG_NULL_FUNCTIONS = """ +functions: + null +""" + +DEBUG_CONFIG_NULL_FUNCTION_CONFIG = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST: + null +""" + +DEBUG_CONFIG_NULL_DEBUG_PORT = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: null +""" + +DEBUG_CONFIG_NULL_ENFORCE_TIMEOUTS = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: null + enforce-timeouts: null +""" + +DEBUG_CONFIG_DUPLICATE_DEBUG_PORT = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname1: + debug-port: 19891 + arn:aws:lambda:eu-central-1:000000000000:function:functionname2: + debug-port: 19891 +""" + +DEBUG_CONFIG_DUPLICATE_ARN = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19891 + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19892 +""" + +DEBUG_CONFIG_INVALID_MISSING_QUALIFIER_ARN = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname:: + debug-port: 19891 +""" + +DEBUG_CONFIG_INVALID_ARN_STRUCTURE = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function: + debug-port: 19891 +""" + +DEBUG_CONFIG_DUPLICATE_IMPLICIT_ARN = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19891 + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST: + debug-port: 19892 +""" + +DEBUG_CONFIG_BASE = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST: + debug-port: 19891 +""" + +DEBUG_CONFIG_BASE_UNQUALIFIED = """ +functions: + arn:aws:lambda:eu-central-1:000000000000:function:functionname: + debug-port: 19891 +""" + + +@pytest.mark.parametrize( + "yaml_config", + [ + DEBUG_CONFIG_EMPTY, + DEBUG_CONFIG_NULL_FUNCTIONS, + DEBUG_CONFIG_NULL_FUNCTION_CONFIG, + DEBUG_CONFIG_DUPLICATE_DEBUG_PORT, + DEBUG_CONFIG_DUPLICATE_ARN, + DEBUG_CONFIG_DUPLICATE_IMPLICIT_ARN, + DEBUG_CONFIG_NULL_ENFORCE_TIMEOUTS, + DEBUG_CONFIG_INVALID_ARN_STRUCTURE, + DEBUG_CONFIG_INVALID_MISSING_QUALIFIER_ARN, + ], + ids=[ + "empty", + "null_functions", + "null_function_config", + "duplicate_debug_port", + "deplicate_arn", + "duplicate_implicit_arn", + "null_enforce_timeouts", + "invalid_arn_structure", + "invalid_missing_qualifier_arn", + ], +) +def test_debug_config_invalid(yaml_config: str): + assert load_lambda_debug_mode_config(yaml_config) is None + + +def test_debug_config_null_debug_port(): + config = load_lambda_debug_mode_config(DEBUG_CONFIG_NULL_DEBUG_PORT) + assert list(config.functions.values())[0].debug_port is None + + +@pytest.mark.parametrize( + "yaml_config", + [ + DEBUG_CONFIG_BASE, + DEBUG_CONFIG_BASE_UNQUALIFIED, + ], + ids=[ + "base", + "base_unqualified", + ], +) +def test_debug_config_base(yaml_config): + config = load_lambda_debug_mode_config(yaml_config) + assert len(config.functions) == 1 + assert ( + "arn:aws:lambda:eu-central-1:000000000000:function:functionname:$LATEST" in config.functions + ) + assert list(config.functions.values())[0].debug_port == 19891 + assert list(config.functions.values())[0].enforce_timeouts is False diff --git a/tests/unit/logging_/__init__.py b/tests/unit/logging_/__init__.py new file mode 100644 index 0000000000000..5c6abdfc5e1fd --- /dev/null +++ b/tests/unit/logging_/__init__.py @@ -0,0 +1,3 @@ +# This module cannot be named logging, since pycharm cannot run in tests in the module above anymore in debug mode +# - https://youtrack.jetbrains.com/issue/PY-54265 +# - https://youtrack.jetbrains.com/issue/PY-35220 diff --git a/tests/unit/logging_/test_format.py b/tests/unit/logging_/test_format.py new file mode 100644 index 0000000000000..fc4ab72adc2c0 --- /dev/null +++ b/tests/unit/logging_/test_format.py @@ -0,0 +1,185 @@ +import logging + +import pytest + +from localstack.logging.format import ( + AddFormattedAttributes, + AwsTraceLoggingFormatter, + MaskSensitiveInputFilter, + TraceLoggingFormatter, + compress_logger_name, +) + + +def test_compress_logger_name(): + assert compress_logger_name("log", 1) == "l" + assert compress_logger_name("log", 2) == "lo" + assert compress_logger_name("log", 3) == "log" + assert compress_logger_name("log", 5) == "log" + assert compress_logger_name("my.very.long.logger.name", 1) == "m.v.l.l.n" + assert compress_logger_name("my.very.long.logger.name", 11) == "m.v.l.l.nam" + assert compress_logger_name("my.very.long.logger.name", 12) == "m.v.l.l.name" + assert compress_logger_name("my.very.long.logger.name", 16) == "m.v.l.l.name" + assert compress_logger_name("my.very.long.logger.name", 17) == "m.v.l.logger.name" + assert compress_logger_name("my.very.long.logger.name", 24) == "my.very.long.logger.name" + + +class TestHandler(logging.Handler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.messages = [] + + def emit(self, record): + self.messages.append(self.format(record)) + + +class CustomMaskSensitiveInputFilter(MaskSensitiveInputFilter): + sensitive_keys = ["sensitive_key"] + + def __init__(self): + super(CustomMaskSensitiveInputFilter, self).__init__(self.sensitive_keys) + + +@pytest.fixture +def get_logger(): + handlers: list[logging.Handler] = [] + logger = logging.getLogger("test.logger") + + def _get_logger(handler: logging.Handler) -> logging.Logger: + handlers.append(handler) + + # avoid propagation to parent loggers + logger.propagate = False + logger.addHandler(handler) + return logger + + yield _get_logger + + for handler in handlers: + logger.removeHandler(handler) + + +class TestTraceLoggingFormatter: + @pytest.fixture + def handler(self): + handler = TestHandler() + + handler.setLevel(logging.DEBUG) + handler.setFormatter(AwsTraceLoggingFormatter()) + handler.addFilter(AddFormattedAttributes()) + return handler + + def test_aws_trace_logging_contains_payload(self, handler, get_logger): + logger = get_logger(handler) + logger.info( + "AWS %s.%s => %s", + "TestService", + "Operation", + "201", + extra={ + # context + "account_id": "123123123123", + "region": "invalid-region", + # request + "input_type": "RequestShape", + "input": {"test": "request"}, + "request_headers": {"request": "header"}, + # response + "output_type": "OutputShape", + "output": {"test": "response"}, + "response_headers": {"response": "header"}, + }, + ) + log_message = handler.messages[0] + assert "TestService" in log_message + assert "RequestShape" in log_message + assert "OutputShape" in log_message + assert "{'test': 'request'}" in log_message + assert "{'test': 'response'}" in log_message + + assert "{'request': 'header'}" in log_message + assert "{'response': 'header'}" in log_message + + def test_aws_trace_logging_replaces_bigger_blobs(self, handler, get_logger): + logger = get_logger(handler) + logger.info( + "AWS %s.%s => %s", + "TestService", + "Operation", + "201", + extra={ + # context + "account_id": "123123123123", + "region": "invalid-region", + # request + "input_type": "RequestShape", + "input": {"request": b"a" * 1024}, + "request_headers": {"request": "header"}, + # response + "output_type": "OutputShape", + "output": {"response": b"a" * 1025}, + "response_headers": {"response": "header"}, + }, + ) + log_message = handler.messages[0] + assert "TestService" in log_message + assert "RequestShape" in log_message + assert "OutputShape" in log_message + assert "{'request': 'Bytes(1.024KB)'}" in log_message + assert "{'response': 'Bytes(1.025KB)'}" in log_message + + assert "{'request': 'header'}" in log_message + assert "{'response': 'header'}" in log_message + + +class TestMaskSensitiveInputFilter: + @pytest.fixture + def handler(self): + handler = TestHandler() + + handler.setLevel(logging.DEBUG) + handler.setFormatter(TraceLoggingFormatter()) + handler.addFilter(AddFormattedAttributes()) + handler.addFilter(CustomMaskSensitiveInputFilter()) + return handler + + def test_input_payload_masked(self, handler, get_logger): + logger = get_logger(handler) + logger.info( + "%s %s => %d", + "POST", + "/_localstack/path", + 200, + extra={ + # request + "input_type": "Request", + "input": b'{"sensitive_key": "sensitive", "other_key": "value"}', + "request_headers": {}, + # response + "output_type": "Response", + "output": "StreamingBody(unknown)", + "response_headers": {}, + }, + ) + log_message = handler.messages[0] + assert """b'{"sensitive_key": "******", "other_key": "value"}'""" in log_message + + def test_input_leave_null_unmasked(self, handler, get_logger): + logger = get_logger(handler) + logger.info( + "%s %s => %d", + "POST", + "/_localstack/path", + 200, + extra={ + "input_type": "Request", + "input": b'{"sensitive_key": null, "other_key": "value"}', + "request_headers": {}, + # response + "output_type": "Response", + "output": "StreamingBody(unknown)", + "response_headers": {}, + }, + ) + log_message = handler.messages[0] + assert """b'{"sensitive_key": null, "other_key": "value"}'""" in log_message diff --git a/tests/unit/packages/test_api.py b/tests/unit/packages/test_api.py new file mode 100644 index 0000000000000..608970f25d7c1 --- /dev/null +++ b/tests/unit/packages/test_api.py @@ -0,0 +1,143 @@ +import os +from pathlib import Path +from queue import Queue +from threading import Event, RLock +from typing import List, Optional + +import pytest + +from localstack.packages import InstallTarget, Package, PackageInstaller +from localstack.utils.files import rm_rf +from localstack.utils.threads import FuncThread + + +class TestPackage(Package): + def __init__(self): + super().__init__("Test Package", "test-version") + + def get_versions(self) -> List[str]: + return ["test-version"] + + def _get_installer(self, version: str) -> PackageInstaller: + return TestPackageInstaller(version=version) + + +class TestPackageInstaller(PackageInstaller): + def __init__(self, version: str, install_lock: Optional[RLock] = None): + super().__init__("test-installer", version, install_lock) + + def _get_install_marker_path(self, install_dir: str) -> str: + return os.path.join(install_dir, "test-installer-marker") + + def _install(self, target: InstallTarget) -> None: + path = Path(os.path.join(self._get_install_dir(target), "test-installer-marker")) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + +@pytest.fixture(scope="module") +def test_package(): + package = TestPackage() + if package.get_installed_dir(): + rm_rf(package.get_installed_dir()) + + yield package + + if package.get_installed_dir(): + rm_rf(package.get_installed_dir()) + + +def test_package_get_installer_caches_installers(test_package): + assert test_package.get_installer() is test_package.get_installer(test_package.default_version) + + +def test_package_get_installed_dir_returns_none(test_package): + assert test_package.get_installed_dir() is None + + +def test_package_get_installed_dir_returns_install_dir(test_package): + test_package.install() + assert test_package.get_installed_dir() is not None + + +class LockingTestPackageInstaller(PackageInstaller): + """ + Package installer class used for testing the locking behavior. + """ + + def __init__(self, queue: Queue = None, install_lock: Optional[RLock] = None): + super().__init__("lock-test-installer", "test", install_lock) + self.queue = queue or Queue() + self.about_to_wait = Event() + + def _get_install_marker_path(self, target: InstallTarget) -> str: + return "/non-existing-path" + + def set_event(self, event: Event, name: str): + self.event = event + self.name = name + + def _install(self, target: InstallTarget) -> None: + # Store the object references before waiting for the event (it might be changed in the meantime) + event_at_the_time = self.event + name_at_the_time = self.name + self.about_to_wait.set() + event_at_the_time.wait() + self.queue.put(name_at_the_time) + + +def test_package_installer_default_lock(): + # Create a single instance of the installer (by default single instance installer's install methods are mutex) + installer = LockingTestPackageInstaller() + + # Set that the installer should wait for event 1 and start + event_installer_1 = Event() + installer.set_event(event_installer_1, "installer1") + # Run the installer in a new thread + FuncThread(func=installer.install).start() + # Wait for installer 1 to wait for the event + installer.about_to_wait.wait() + # Create a new event and set it as the new event to wait for + event_installer_2 = Event() + installer.set_event(event_installer_2, "installer2") + # Again, run the installer in a new thread + FuncThread(func=installer.install).start() + # Release the second installer (by setting the event) + event_installer_2.set() + # Afterwards release the first installer + event_installer_1.set() + # Since the first installer should have the lock when being first run, ensure it finishes first + assert installer.queue.get() == "installer1" + + +@pytest.mark.skip(reason="sometimes blocks in CI, probably due to a race condition in the test") +def test_package_installer_custom_lock(): + shared_lock = RLock() + shared_queue = Queue() + + # Create the two installers with the same shared lock + installer_1 = LockingTestPackageInstaller(queue=shared_queue, install_lock=shared_lock) + installer_2 = LockingTestPackageInstaller(queue=shared_queue, install_lock=shared_lock) + + # Set that the installer 1 should wait for event 1 and start + event_installer_1 = Event() + installer_1.set_event(event_installer_1, "installer1") + FuncThread(func=installer_1.install).start() + + # Create a new event and set it as the new event for installer 2 to wait for + event_installer_2 = Event() + installer_2.set_event(event_installer_2, "installer2") + # Again, run the installer in a new thread + FuncThread(func=installer_2.install).start() + + # Wait for installer 1 to wait for the event (it acquired the shared lock) + installer_1.about_to_wait.wait() + + # Release the second installer (by setting the event) + event_installer_2.set() + # Afterwards release the first installer + event_installer_1.set() + + first_finished_installer = shared_queue.get(block=True) + # Since the first installer should have the lock when being first run, ensure it finishes first + assert first_finished_installer == "installer1" diff --git a/tests/unit/packages/test_core.py b/tests/unit/packages/test_core.py new file mode 100644 index 0000000000000..f616cc557a7fd --- /dev/null +++ b/tests/unit/packages/test_core.py @@ -0,0 +1,30 @@ +import pytest + +from localstack.packages import PackageException +from localstack.packages.core import GitHubReleaseInstaller + + +class TestGitHubPackageInstaller(GitHubReleaseInstaller): + def __init__(self): + super().__init__( + "test-package", "test-default-version", "non-existing-user/non-existing-repo" + ) + + def _get_github_asset_name(self): + return "test-asset-name" + + +def test_github_installer_does_not_fetch_versions_on_presence_check(): + """ + This test makes ensures that the check if a package installed via the GitHubReleaseInstaller does not require + requests to GitHub. + """ + installer = TestGitHubPackageInstaller() + # Assert that the non-existing package is not installed (a request to a non-existing repo would raise an exception) + assert not installer.is_installed() + + +def test_github_installer_raises_exception_on_install_with_non_existing_repo(): + installer = TestGitHubPackageInstaller() + with pytest.raises(PackageException): + installer.install() diff --git a/tests/unit/runtime/__init__.py b/tests/unit/runtime/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/runtime/test_init.py b/tests/unit/runtime/test_init.py new file mode 100644 index 0000000000000..81784a531d979 --- /dev/null +++ b/tests/unit/runtime/test_init.py @@ -0,0 +1,303 @@ +import os +import pathlib +import textwrap + +import pytest + +from localstack.runtime.init import InitScriptManager, Script, Stage, State + + +@pytest.fixture +def manager(tmp_path) -> InitScriptManager: + script_root = tmp_path / "etc" / "init" + script_root.mkdir(parents=True) + return InitScriptManager(script_root=str(script_root)) + + +class TestInitScriptManager: + def test_scripts_returns_empty_lists(self, manager): + # create empty dir to demonstrate it's treated like non-existing + (pathlib.Path(manager.script_root) / "ready.d").mkdir() + + assert manager.scripts == { + Stage.BOOT: [], + Stage.START: [], + Stage.READY: [], + Stage.SHUTDOWN: [], + } + + def test_scripts_returns_scripts_in_alphanumerical_order(self, manager): + script_root = pathlib.Path(manager.script_root) + boot_d = script_root / "boot.d" + start_d = script_root / "start.d" + ready_d = script_root / "ready.d" + shutdown_d = script_root / "shutdown.d" + + # noise + (script_root / "not-a-stage.d").mkdir() + + # create boot scripts + boot_d.mkdir() + (boot_d / "01_boot.sh").touch() + (boot_d / "02_boot.py").touch() + (boot_d / "03_boot.txt").touch() # ignored since there is no runner + (boot_d / "04_boot.sh").touch() + (boot_d / "notafile").mkdir() + + # create start scripts + start_d.mkdir() + (start_d / "01_start.sh").touch() + (start_d / "03_start.sh").touch() + (start_d / "03_start.txt").touch() # ignored since there is no runner + (start_d / "02_start.py").touch() + + # create ready scripts + ready_d.mkdir() + (ready_d / "a_ready.sh").touch() + (ready_d / "b_ready.py").touch() + + # create ready scripts + shutdown_d.mkdir() + (shutdown_d / "shutdown.sh").touch() + (shutdown_d / "shutdown.py").touch() + + assert manager.scripts == { + Stage.BOOT: [ + Script( + path=os.path.join(manager.script_root, "boot.d/01_boot.sh"), + stage=Stage.BOOT, + state=State.UNKNOWN, + ), + Script( + path=os.path.join(manager.script_root, "boot.d/02_boot.py"), + stage=Stage.BOOT, + state=State.UNKNOWN, + ), + Script( + path=os.path.join(manager.script_root, "boot.d/04_boot.sh"), + stage=Stage.BOOT, + state=State.UNKNOWN, + ), + ], + Stage.START: [ + Script( + path=os.path.join(manager.script_root, "start.d/01_start.sh"), + stage=Stage.START, + state=State.UNKNOWN, + ), + Script( + path=os.path.join(manager.script_root, "start.d/02_start.py"), + stage=Stage.START, + state=State.UNKNOWN, + ), + Script( + path=os.path.join(manager.script_root, "start.d/03_start.sh"), + stage=Stage.START, + state=State.UNKNOWN, + ), + ], + Stage.READY: [ + Script( + path=os.path.join(manager.script_root, "ready.d/a_ready.sh"), + stage=Stage.READY, + state=State.UNKNOWN, + ), + Script( + path=os.path.join(manager.script_root, "ready.d/b_ready.py"), + stage=Stage.READY, + state=State.UNKNOWN, + ), + ], + Stage.SHUTDOWN: [ + Script( + path=os.path.join(manager.script_root, "shutdown.d/shutdown.py"), + stage=Stage.SHUTDOWN, + state=State.UNKNOWN, + ), + Script( + path=os.path.join(manager.script_root, "shutdown.d/shutdown.sh"), + stage=Stage.SHUTDOWN, + state=State.UNKNOWN, + ), + ], + } + + def test_run_stage_executes_scripts_correctly(self, manager, tmp_path): + script_root = pathlib.Path(manager.script_root) + ready_d = script_root / "ready.d" + + ready_d.mkdir() + + script_01 = ready_d / "script_01.sh" + script_02 = ready_d / "script_02_fails.sh" + script_03 = ready_d / "script_03.py" + + script_01.touch(mode=0o777) + script_02.touch(mode=0o777) + + script_01.write_text("#!/bin/bash\necho 'hello 1' >> %s/script_01.out" % tmp_path) + script_02.write_text("#!/bin/bash\nexit 1") + script_03.write_text( + "import pathlib; pathlib.Path('%s').write_text('hello 3')" + % (tmp_path / "script_03.out") + ) + + assert manager.stage_completed == { + Stage.BOOT: False, + Stage.START: False, + Stage.READY: False, + Stage.SHUTDOWN: False, + } + result = manager.run_stage(Stage.READY) + + # check completed state + assert manager.stage_completed == { + Stage.BOOT: False, + Stage.START: False, + Stage.READY: True, + Stage.SHUTDOWN: False, + } + + # check script results + assert result == [ + Script( + path=os.path.join(manager.script_root, "ready.d/script_01.sh"), + stage=Stage.READY, + state=State.SUCCESSFUL, + ), + Script( + path=os.path.join(manager.script_root, "ready.d/script_02_fails.sh"), + stage=Stage.READY, + state=State.ERROR, + ), + Script( + path=os.path.join(manager.script_root, "ready.d/script_03.py"), + stage=Stage.READY, + state=State.SUCCESSFUL, + ), + ] + + # check script output + assert (tmp_path / "script_01.out").read_text().strip() == "hello 1" + assert (tmp_path / "script_03.out").read_text().strip() == "hello 3" + + def test_python_globals(self, manager, tmp_path): + """ + https://github.com/localstack/localstack/issues/7135 + """ + script_root = pathlib.Path(manager.script_root) + ready_d = script_root / "ready.d" + ready_d.mkdir() + + python_script = ready_d / "script.py" + python_script.touch(mode=0o777) + src = textwrap.dedent( + """ + import os + + TOPICS = ("user-profile", "group") + + + def create_topic(topic): + os.system(f"echo {topic} creating") + + + def init_topics(): + # access of global variable within scope + with open('%s', 'w') as outfile: + outfile.write('\\n'.join(TOPICS)) + + init_topics() + """ + % (tmp_path / "script.out") + ) + python_script.write_text(src) + + assert manager.stage_completed == { + Stage.BOOT: False, + Stage.START: False, + Stage.READY: False, + Stage.SHUTDOWN: False, + } + result = manager.run_stage(Stage.READY) + + # check completed state + assert manager.stage_completed == { + Stage.BOOT: False, + Stage.START: False, + Stage.READY: True, + Stage.SHUTDOWN: False, + } + + # check script results + assert result == [ + Script( + path=os.path.join(manager.script_root, "ready.d/script.py"), + stage=Stage.READY, + state=State.SUCCESSFUL, + ), + ] + + assert (tmp_path / "script.out").read_text().strip() == "user-profile\ngroup" + + def test_recursion(self, manager, tmp_path): + script_root = pathlib.Path(manager.script_root) + ready_d = script_root / "ready.d" + + dir_a = ready_d / "a" + dir_aa = ready_d / "a" / "aa" + dir_b = ready_d / "b" + + dir_aa.mkdir(parents=True) + dir_b.mkdir(parents=True) + + script_00 = ready_d / "script_00.sh" + script_01 = dir_a / "script_01.sh" + script_02 = dir_aa / "script_02.sh" + script_03 = dir_b / "script_03.sh" + + script_00.touch(mode=0o777) + script_01.touch(mode=0o777) + script_02.touch(mode=0o777) + script_03.touch(mode=0o777) + + script_00.write_text("#!/bin/bash\necho 'hello 0' >> %s/script_00.out" % tmp_path) + script_01.write_text("#!/bin/bash\necho 'hello 1' >> %s/script_01.out" % tmp_path) + script_02.write_text("#!/bin/bash\necho 'hello 2' >> %s/script_02.out" % tmp_path) + script_03.write_text("#!/bin/bash\necho 'hello 3' >> %s/script_03.out" % tmp_path) + + result = manager.run_stage(Stage.READY) + + # check script results + assert result == [ + Script( + path=os.path.join(manager.script_root, "ready.d/script_00.sh"), + stage=Stage.READY, + state=State.SUCCESSFUL, + ), + Script( + path=os.path.join(manager.script_root, "ready.d/a/script_01.sh"), + stage=Stage.READY, + state=State.SUCCESSFUL, + ), + Script( + path=os.path.join(manager.script_root, "ready.d/a/aa/script_02.sh"), + stage=Stage.READY, + state=State.SUCCESSFUL, + ), + Script( + path=os.path.join(manager.script_root, "ready.d/b/script_03.sh"), + stage=Stage.READY, + state=State.SUCCESSFUL, + ), + ] + + assert "script_00.out" in os.listdir(tmp_path) + assert "script_01.out" in os.listdir(tmp_path) + assert "script_02.out" in os.listdir(tmp_path) + assert "script_03.out" in os.listdir(tmp_path) + + def test_empty_init_path(self): + manager = InitScriptManager(script_root=None) + scripts = manager.scripts + assert scripts == {} diff --git a/tests/unit/services/__init__.py b/tests/unit/services/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/apigateway/__init__.py b/tests/unit/services/apigateway/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/apigateway/test_apigateway_common.py b/tests/unit/services/apigateway/test_apigateway_common.py new file mode 100644 index 0000000000000..a3df3f21ffa9c --- /dev/null +++ b/tests/unit/services/apigateway/test_apigateway_common.py @@ -0,0 +1,1297 @@ +import json +import unittest +import xml +from json import JSONDecodeError +from typing import Any, Dict +from unittest.mock import MagicMock, Mock + +import boto3 +import pytest +import xmltodict + +from localstack.aws.api.apigateway import Model +from localstack.constants import ( + APPLICATION_JSON, + APPLICATION_XML, + AWS_REGION_US_EAST_1, + DEFAULT_AWS_ACCOUNT_ID, +) +from localstack.services.apigateway.helpers import ( + ModelResolver, + OpenAPISpecificationResolver, + apply_json_patch_safe, +) +from localstack.services.apigateway.legacy.helpers import ( + RequestParametersResolver, + extract_path_params, + extract_query_string_params, + get_resource_for_path, +) +from localstack.services.apigateway.legacy.integration import ( + LambdaProxyIntegration, + apply_request_parameters, +) +from localstack.services.apigateway.legacy.invocations import ( + ApiInvocationContext, + BadRequestBody, + RequestValidator, +) +from localstack.services.apigateway.legacy.templates import ( + RequestTemplates, + ResponseTemplates, + VelocityUtilApiGateway, +) +from localstack.services.apigateway.models import ApiGatewayStore, RestApiContainer +from localstack.testing.config import TEST_AWS_REGION_NAME +from localstack.utils.aws.aws_responses import requests_response +from localstack.utils.common import clone + + +class TestApiGatewayPaths: + def test_extract_query_params(self): + path, query_params = extract_query_string_params("/foo/bar?foo=foo&bar=bar&bar=baz") + assert path == "/foo/bar" + assert query_params == {"foo": "foo", "bar": ["bar", "baz"]} + + @pytest.mark.parametrize( + "path,path_part,expected", + [ + ("/foo/bar", "/foo/{param1}", {"param1": "bar"}), + ("/foo/bar1/bar2", "/foo/{param1}/{param2}", {"param1": "bar1", "param2": "bar2"}), + ("/foo/bar", "/foo/bar", {}), + ("/foo/bar/baz", "/foo/{proxy+}", {"proxy": "bar/baz"}), + ], + ) + def test_extract_path_params(self, path, path_part, expected): + assert extract_path_params(path, path_part) == expected + + @pytest.mark.parametrize( + "path,path_parts,expected", + [ + ("/foo/bar", ["/foo/{param1}"], "/foo/{param1}"), + ("/foo/bar", ["/foo/bar", "/foo/{param1}"], "/foo/bar"), + ("/foo/bar", ["/foo/{param1}", "/foo/bar"], "/foo/bar"), + ("/foo/bar/baz", ["/foo/bar", "/foo/{proxy+}"], "/foo/{proxy+}"), + ("/foo/bar/baz", ["/{proxy+}", "/foo/{proxy+}"], "/foo/{proxy+}"), + ("/foo/bar", ["/foo/bar1", "/foo/bar2"], None), + ("/foo/bar", ["/{param1}/bar1", "/foo/bar2"], None), + ("/foo/bar", ["/{param1}/{param2}/foo/{param3}", "/{param}/bar"], "/{param}/bar"), + ("/foo/bar", ["/{param1}/{param2}", "/{param}/bar"], "/{param}/bar"), + ("/foo/bar", ["/{param}/bar", "/{param1}/{param2}"], "/{param}/bar"), + ("/foo/bar", ["/foo/bar", "/foo/{param+}"], "/foo/bar"), + ("/foo/bar", ["/foo/{param+}", "/foo/bar"], "/foo/bar"), + ( + "/foo/bar/baz", + ["/{param1}/{param2}/baz", "/{param1}/bar/{param2}"], + "/{param1}/{param2}/baz", + ), + ("/foo/bar/baz", ["/foo123/{param1}/baz"], None), + ("/foo/bar/baz", ["/foo/{param1}/baz", "/foo/{param1}/{param2}"], "/foo/{param1}/baz"), + ("/foo/bar/baz", ["/foo/{param1}/{param2}", "/foo/{param1}/baz"], "/foo/{param1}/baz"), + ], + ) + def test_path_matches(self, path, path_parts, expected): + default_resource = {"resourceMethods": {"GET": {}}} + + path_map = dict.fromkeys(path_parts, default_resource) + matched_path, _ = get_resource_for_path(path, "GET", path_map) + assert matched_path == expected + + def test_path_routing_with_method(self): + """Not using parametrization as testing a simple scenario, AWS validated""" + paths_map = { + "/{proxy+}": {"resourceMethods": {"OPTIONS": {}}}, + "/foo": {"resourceMethods": {"POST": {}}}, + "/foo/bar": {"resourceMethods": {"ANY": {}}}, + } + # If there is an exact match on the path but the resource on that path does not match on the method, try + # greedy path then + + path, _ = get_resource_for_path("/foo", "GET", paths_map) + # we can see that /foo would match 1:1, but it does not have a "GET" method, so it will try to match {proxy+}, + # but proxy does not have a GET either, so it will not match anything + assert path is None + + path, _ = get_resource_for_path("/foo", "OPTIONS", paths_map) + # now OPTIONS matches proxy + assert path == "/{proxy+}" + + path, _ = get_resource_for_path("/foo", "POST", paths_map) + # now POST directly matches /foo + assert path == "/foo" + + path, _ = get_resource_for_path("/foo/bar", "GET", paths_map) + # with this nested path, it will try to match the exact 1:1, and this one contains ANY, which will properly + # match before trying {proxy+} + assert path == "/foo/bar" + + path, _ = get_resource_for_path("/foo/bar", "OPTIONS", paths_map) + # with this nested path, it will try to match the exact 1:1, and this one contains ANY, which will properly + # match before trying {proxy+} even if it has the right OPTIONS method + assert path == "/foo/bar" + + def test_apply_request_parameters(self): + integration = { + "type": "HTTP_PROXY", + "httpMethod": "ANY", + "uri": "https://httpbin.org/anything/{proxy}", + "requestParameters": {"integration.request.path.proxy": "method.request.path.proxy"}, + "passthroughBehavior": "WHEN_NO_MATCH", + "timeoutInMillis": 29000, + "cacheNamespace": "041fa782", + "cacheKeyParameters": [], + } + + uri = apply_request_parameters( + uri="https://httpbin.org/anything/{proxy}", + integration=integration, + path_params={"proxy": "foo/bar/baz"}, + query_params={"param": "foobar"}, + ) + assert uri == "https://httpbin.org/anything/foo/bar/baz?param=foobar" + + +class TestApiGatewayRequestValidator(unittest.TestCase): + def test_if_request_is_valid_with_no_resource_methods(self): + ctx = ApiInvocationContext( + method="POST", + path="/", + data=b"", + headers={}, + ) + ctx.account_id = DEFAULT_AWS_ACCOUNT_ID + ctx.region_name = TEST_AWS_REGION_NAME + validator = RequestValidator(ctx, Mock()) + assert validator.validate_request() is None + + def test_if_request_is_valid_with_no_matching_method(self): + ctx = ApiInvocationContext( + method="POST", + path="/", + data=b"", + headers={}, + ) + ctx.resource = {"resourceMethods": {"GET": {}}} + ctx.account_id = DEFAULT_AWS_ACCOUNT_ID + ctx.region_name = TEST_AWS_REGION_NAME + validator = RequestValidator(ctx, Mock()) + assert validator.validate_request() is None + + def test_if_request_is_valid_with_no_validator(self): + ctx = ApiInvocationContext( + method="POST", + path="/", + data=b"", + headers={}, + ) + ctx.resource = {"resourceMethods": {"GET": {}}} + ctx.account_id = DEFAULT_AWS_ACCOUNT_ID + ctx.region_name = TEST_AWS_REGION_NAME + ctx.api_id = "deadbeef" + ctx.resource = {"resourceMethods": {"POST": {"requestValidatorId": " "}}} + validator = RequestValidator(ctx, Mock()) + assert validator.validate_request() is None + + def test_if_request_has_body_validator(self): + ctx = ApiInvocationContext( + method="POST", + path="/", + data=b"", + headers={}, + ) + ctx.account_id = DEFAULT_AWS_ACCOUNT_ID + ctx.region_name = TEST_AWS_REGION_NAME + ctx.api_id = "deadbeef" + model_name = "schemaName" + request_validator_id = "112233" + ctx.resource = { + "resourceMethods": { + "POST": { + "requestValidatorId": model_name, + "requestModels": {"application/json": request_validator_id}, + } + } + } + store = self._mock_store() + container = RestApiContainer(rest_api={}) + container.validators[request_validator_id] = {"validateRequestBody": True} + container.models[model_name] = {"schema": '{"type": "object"}'} + store.rest_apis["deadbeef"] = container + validator = RequestValidator(ctx, store) + assert validator.validate_request() is None + + def test_request_validate_body_with_no_request_model(self): + ctx = ApiInvocationContext( + method="POST", + path="/", + data=b"", + headers={}, + ) + ctx.account_id = DEFAULT_AWS_ACCOUNT_ID + ctx.region_name = TEST_AWS_REGION_NAME + ctx.api_id = "deadbeef" + request_validator_id = "112233" + empty_schema = json.dumps( + { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Empty Schema", + "type": "object", + } + ) + + ctx.resource = { + "resourceMethods": { + "POST": { + "requestValidatorId": request_validator_id, + "requestModels": None, + } + } + } + store = self._mock_store() + container = RestApiContainer(rest_api={}) + container.validators = MagicMock() + container.validators.get.return_value = {"validateRequestBody": True} + container.models = MagicMock() + container.models.get.return_value = {"schema": empty_schema} + store.rest_apis["deadbeef"] = container + validator = RequestValidator(ctx, store) + assert validator.validate_request() is None + + container.validators.get.assert_called_with("112233") + container.models.get.assert_called_with("Empty") + + def test_request_validate_body_with_no_model_for_schema_name(self): + ctx = ApiInvocationContext( + method="POST", + path="/", + data='{"id":"1"}', + headers={}, + ) + ctx.account_id = DEFAULT_AWS_ACCOUNT_ID + ctx.region_name = TEST_AWS_REGION_NAME + ctx.api_id = "deadbeef" + model_name = "schemaName" + request_validator_id = "112233" + ctx.resource = { + "resourceMethods": { + "POST": { + "requestValidatorId": model_name, + "requestModels": {"application/json": request_validator_id}, + } + } + } + store = self._mock_store() + container = RestApiContainer(rest_api={}) + container.validators = MagicMock() + container.validators.get.return_value = {"validateRequestBody": True} + container.models = MagicMock() + container.models.get.return_value = None + store.rest_apis["deadbeef"] = container + validator = RequestValidator(ctx, store) + with pytest.raises(BadRequestBody): + validator.validate_request() + + def test_request_validate_body_with_circular_and_recursive_model(self): + def _create_context_with_data(body_data: dict): + ctx = ApiInvocationContext( + method="POST", + path="/", + data=json.dumps(body_data), + headers={}, + ) + ctx.account_id = DEFAULT_AWS_ACCOUNT_ID + ctx.region_name = TEST_AWS_REGION_NAME + ctx.api_id = "deadbeef" + ctx.resource = { + "resourceMethods": { + "POST": { + "requestValidatorId": request_validator_id, + "requestModels": {APPLICATION_JSON: "Person"}, + } + } + } + return ctx + + container = RestApiContainer(rest_api={}) + + request_validator_id = "112233" + container.validators[request_validator_id] = {"validateRequestBody": True} + + # set up the model, Person, which references House + model_id_person = "model1" + model_name_person = "Person" + model_schema_person = { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + "house": { + "$ref": "https://domain.com/restapis/deadbeef/models/House", + }, + }, + "required": ["name"], + } + + model_person = Model( + id=model_id_person, + name=model_name_person, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_person), + ) + container.models[model_name_person] = model_person + + # set up the model House, which references the Person model, we have a circular ref, and House itself + model_id_house = "model2" + model_name_house = "House" + model_schema_house = { + "type": "object", + "required": ["houseType"], + "properties": { + "houseType": { + "type": "string", + }, + "contains": { + "type": "array", + "items": { + "$ref": "https://domain.com/restapis/deadbeef/models/Person", + }, + }, + "houses": { + "type": "array", + "items": { + "$ref": "https://domain.com/restapis/deadbeef/models/House", + }, + }, + }, + } + + model_house = Model( + id=model_id_house, + name=model_name_house, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_house), + ) + container.models[model_name_house] = model_house + + store = self._mock_store() + store.rest_apis["deadbeef"] = container + + invocation_context = _create_context_with_data( + { + "name": "test", + "house": { # the House object is missing "houseType" + "contains": [{"name": "test"}], # the Person object has the required "name" + "houses": [{"coucou": "test"}], # the House object is missing "houseType" + }, + } + ) + + validator = RequestValidator(invocation_context, store) + with pytest.raises(BadRequestBody): + validator.validate_request() + + invocation_context = _create_context_with_data( + { + "name": "test", + "house": { + "houseType": "random", # the House object has the required ""houseType" + "contains": [{"name": "test"}], # the Person object has the required "name" + "houses": [{"houseType": "test"}], # the House object is missing "houseType" + }, + } + ) + + validator = RequestValidator(invocation_context, store) + assert validator.validate_request() is None + + def _mock_client(self): + return Mock(boto3.client("apigateway", region_name=AWS_REGION_US_EAST_1)) + + def _mock_store(self): + return ApiGatewayStore() + + +def test_render_template_values(): + util = VelocityUtilApiGateway() + + encoded = util.urlEncode("x=a+b") + assert encoded == "x%3Da%2Bb" + + decoded = util.urlDecode("x=a+b") + assert decoded == "x=a b" + + escape_tests = ( + ("it's", "it's"), + ("0010", "0010"), + ("true", "true"), + ("True", "True"), + ("1.021", "1.021"), + ('""', '\\"\\"'), + ('"""', '\\"\\"\\"'), + ('{"foo": 123}', '{\\"foo\\": 123}'), + ('{"foo"": 123}', '{\\"foo\\"\\": 123}'), + (1, "1"), + (None, "null"), + ) + for string, expected in escape_tests: + escaped = util.escapeJavaScript(string) + assert escaped == expected + + +class TestVelocityUtilApiGatewayFunctions: + def test_parse_json(self): + util = VelocityUtilApiGateway() + + # write table tests for the following input + a = {"array": "[1,2,3]"} + obj = util.parseJson(a["array"]) + assert obj[0] == 1 + + o = {"object": '{"key1":"var1","key2":{"arr":[1,2,3]}}'} + obj = util.parseJson(o["object"]) + assert obj.key2.arr[0] == 1 + + s = '"string"' + obj = util.parseJson(s) + assert obj == "string" + + n = {"number": "1"} + obj = util.parseJson(n["number"]) + assert obj == 1 + + b = {"boolean": "true"} + obj = util.parseJson(b["boolean"]) + assert obj is True + + z = {"zero_length_array": "[]"} + obj = util.parseJson(z["zero_length_array"]) + assert obj == [] + + +class TestJSONPatch(unittest.TestCase): + def test_apply_json_patch(self): + apply = apply_json_patch_safe + + # test replacing array index + subject = {"root": [{"arr": ["1", "abc"]}]} + result = apply(clone(subject), {"op": "replace", "path": "/root/0/arr/0", "value": 2}) + self.assertEqual({"arr": [2, "abc"]}, result["root"][0]) + + # test replacing endpoint config type + operation = {"op": "replace", "path": "/endpointConfiguration/types/0", "value": "EDGE"} + subject = { + "id": "b5d563g3yx", + "endpointConfiguration": {"types": ["REGIONAL"], "vpcEndpointIds": []}, + } + result = apply(clone(subject), operation) + self.assertEqual(["EDGE"], result["endpointConfiguration"]["types"]) + + # test replacing endpoint config type + operation = {"op": "add", "path": "/features/-", "value": "feat2"} + subject = {"features": ["feat1"]} + result = apply(clone(subject), operation) + self.assertEqual(["feat1", "feat2"], result["features"]) + + +class TestApplyTemplate(unittest.TestCase): + def test_apply_template(self): + api_context = ApiInvocationContext( + method="POST", + path="/foo/bar?baz=test", + data='{"action":"$default","message":"foobar"}', + headers={"content-type": APPLICATION_JSON}, + stage="local", + ) + api_context.response = requests_response({}) + api_context.integration = { + "requestTemplates": { + APPLICATION_JSON: "$util.escapeJavaScript($input.json('$.message'))" + }, + } + + rendered_request = RequestTemplates().render(api_context=api_context) + + self.assertEqual('\\"foobar\\"', rendered_request) + + def test_apply_template_no_json_payload(self): + api_context = ApiInvocationContext( + method="POST", + path="/foo/bar?baz=test", + data=b'"#foobar123"', + headers={"content-type": APPLICATION_JSON}, + stage="local", + ) + api_context.integration = { + "requestTemplates": { + APPLICATION_JSON: "$util.escapeJavaScript($input.json('$.message'))" + }, + } + + rendered_request = RequestTemplates().render(api_context=api_context) + + self.assertEqual("[]", rendered_request) + + +RESPONSE_TEMPLATE_JSON = """ + +#set( $body = $input.json("$") ) +#define( $loop ) +{ + #foreach($e in $map.keySet()) + #set( $k = $e ) + #set( $v = $map.get($k)) + "$k": "$v" + #if( $foreach.hasNext ) , #end + #end +} +#end + { + "body": $body, + "method": "$context.httpMethod", + "principalId": "$context.authorizer.principalId", + "stage": "$context.stage", + "cognitoPoolClaims" : { + "sub": "$context.authorizer.claims.sub" + }, + #set( $map = $context.authorizer ) + "enhancedAuthContext": $loop, + + #set( $map = $input.params().header ) + "headers": $loop, + + #set( $map = $input.params().querystring ) + "query": $loop, + + #set( $map = $input.params().path ) + "path": $loop, + + #set( $map = $context.identity ) + "identity": $loop, + + #set( $map = $stageVariables ) + "stageVariables": $loop, + + "requestPath": "$context.resourcePath" +} +""" + +RESPONSE_TEMPLATE_WRONG_JSON = """ +#set( $body = $input.json("$") ) + { + "body": $body, + "method": $context.httpMethod, + } +""" + +RESPONSE_TEMPLATE_XML = """ + +#set( $body = $input.json("$") ) +#define( $loop ) + #foreach($e in $map.keySet()) + #set( $k = $e ) + #set( $v = $map.get($k)) + <$k>$v + #end +#end + + $body + $context.stage + + $context.authorizer.claims.sub + + + #set( $map = $context.authorizer ) + $loop + + #set( $map = $input.params().header ) + $loop + + #set( $map = $input.params().querystring ) + $loop + + #set( $map = $input.params().path ) + $loop + + #set( $map = $context.identity ) + $loop + + #set( $map = $stageVariables ) + $loop + +""" + +RESPONSE_TEMPLATE_WRONG_XML = """ +#set( $body = $input.json("$") ) + + $body + $context.stage + +""" + + +class TestTemplates: + @pytest.mark.parametrize( + "template,accept_content_type", + [ + (RequestTemplates(), APPLICATION_JSON), + (ResponseTemplates(), APPLICATION_JSON), + (RequestTemplates(), "*/*"), + (ResponseTemplates(), "*/*"), + ], + ) + def test_render_custom_template(self, template, accept_content_type): + api_context = ApiInvocationContext( + method="POST", + path="/foo/bar?baz=test", + data=b'{"spam": "eggs"}', + headers={"content-type": APPLICATION_JSON, "accept": accept_content_type}, + stage="local", + ) + api_context.integration = { + "requestTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE_JSON}, + "integrationResponses": { + "200": {"responseTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE_JSON}} + }, + } + api_context.resource_path = "/{proxy+}" + api_context.path_params = {"id": "bar"} + api_context.response = requests_response({"spam": "eggs"}) + api_context.context = { + "httpMethod": api_context.method, + "stage": api_context.stage, + "authorizer": {"principalId": "12233"}, + "identity": {"accountId": "00000", "apiKey": "11111"}, + "resourcePath": api_context.resource_path, + } + api_context.stage_variables = {"stageVariable1": "value1", "stageVariable2": "value2"} + + rendered_request = template.render(api_context=api_context) + result_as_json = json.loads(rendered_request) + + assert result_as_json.get("body") == {"spam": "eggs"} + assert result_as_json.get("method") == "POST" + assert result_as_json.get("principalId") == "12233" + assert result_as_json.get("stage") == "local" + assert result_as_json.get("enhancedAuthContext") == {"principalId": "12233"} + assert result_as_json.get("identity") == {"accountId": "00000", "apiKey": "11111"} + assert result_as_json.get("headers") == { + "content-type": APPLICATION_JSON, + "accept": accept_content_type, + } + assert result_as_json.get("query") == {"baz": "test"} + assert result_as_json.get("path") == {"id": "bar"} + assert result_as_json.get("stageVariables") == { + "stageVariable1": "value1", + "stageVariable2": "value2", + } + + def test_render_valid_booleans_in_json(self): + template = ResponseTemplates() + + # assert that boolean results of _render_json_result(..) are JSON-parseable + tstring = '{"mybool": $boolTrue}' + result = template._render_as_text(tstring, {"boolTrue": "true"}) + assert json.loads(result) == {"mybool": True} + result = template._render_as_text(tstring, {"boolTrue": True}) + assert json.loads(result) == {"mybool": True} + + # older versions of `airspeed` were rendering booleans as False/True, which is no longer valid now + tstring = '{"mybool": False}' + with pytest.raises(JSONDecodeError): + result = template._render_as_text(tstring, {}) + template._validate_json(result) + + def test_error_when_render_invalid_json(self): + api_context = ApiInvocationContext( + method="POST", + path="/foo/bar?baz=test", + data=b"", + headers={}, + ) + api_context.integration = { + "integrationResponses": { + "200": {"responseTemplates": {APPLICATION_JSON: RESPONSE_TEMPLATE_WRONG_JSON}} + }, + } + api_context.response = requests_response({"spam": "eggs"}) + api_context.context = {} + api_context.stage_variables = {} + + template = ResponseTemplates() + with pytest.raises(JSONDecodeError): + template.render(api_context=api_context) + + @pytest.mark.parametrize("template", [RequestTemplates(), ResponseTemplates()]) + def test_render_custom_template_in_xml(self, template): + api_context = ApiInvocationContext( + method="POST", + path="/foo/bar?baz=test", + data=b'{"spam": "eggs"}', + headers={"content-type": APPLICATION_XML, "accept": APPLICATION_XML}, + stage="local", + ) + api_context.integration = { + "requestTemplates": {APPLICATION_XML: RESPONSE_TEMPLATE_XML}, + "integrationResponses": { + "200": {"responseTemplates": {APPLICATION_XML: RESPONSE_TEMPLATE_XML}} + }, + } + api_context.resource_path = "/{proxy+}" + api_context.path_params = {"id": "bar"} + api_context.response = requests_response({"spam": "eggs"}) + api_context.context = { + "httpMethod": api_context.method, + "stage": api_context.stage, + "authorizer": {"principalId": "12233"}, + "identity": {"accountId": "00000", "apiKey": "11111"}, + "resourcePath": api_context.resource_path, + } + api_context.stage_variables = {"stageVariable1": "value1", "stageVariable2": "value2"} + + rendered_request = template.render(api_context=api_context, template_key=APPLICATION_XML) + result_as_xml = xmltodict.parse(rendered_request).get("root", {}) + + assert result_as_xml.get("body") == '{"spam": "eggs"}' + assert result_as_xml.get("@method") == "POST" + assert result_as_xml.get("@principalId") == "12233" + assert result_as_xml.get("stage") == "local" + assert result_as_xml.get("enhancedAuthContext") == {"principalId": "12233"} + assert result_as_xml.get("identity") == {"accountId": "00000", "apiKey": "11111"} + assert result_as_xml.get("headers") == { + "content-type": APPLICATION_XML, + "accept": APPLICATION_XML, + } + assert result_as_xml.get("query") == {"baz": "test"} + assert result_as_xml.get("path") == {"id": "bar"} + assert result_as_xml.get("stageVariables") == { + "stageVariable1": "value1", + "stageVariable2": "value2", + } + + def test_error_when_render_invalid_xml(self): + api_context = ApiInvocationContext( + method="POST", + path="/foo/bar?baz=test", + data=b"", + headers={"content-type": APPLICATION_XML, "accept": APPLICATION_XML}, + stage="local", + ) + api_context.integration = { + "integrationResponses": { + "200": {"responseTemplates": {APPLICATION_XML: RESPONSE_TEMPLATE_WRONG_XML}} + }, + } + api_context.resource_path = "/{proxy+}" + api_context.response = requests_response({"spam": "eggs"}) + api_context.context = {} + api_context.stage_variables = {} + + template = ResponseTemplates() + with pytest.raises(xml.parsers.expat.ExpatError): + template.render(api_context=api_context, template_key=APPLICATION_XML) + + +def test_openapi_resolver_given_unresolvable_references(): + document = { + "schema": {"$ref": "#/definitions/NotFound"}, + "definitions": {"Found": {"type": "string"}}, + } + resolver = OpenAPISpecificationResolver(document, allow_recursive=True, rest_api_id="123") + result = resolver.resolve_references() + assert result == {"schema": None, "definitions": {"Found": {"type": "string"}}} + + +def test_openapi_resolver_given_invalid_references(): + document = {"schema": {"$ref": ""}, "definitions": {"Found": {"type": "string"}}} + resolver = OpenAPISpecificationResolver(document, allow_recursive=True, rest_api_id="123") + result = resolver.resolve_references() + assert result == {"schema": None, "definitions": {"Found": {"type": "string"}}} + + +def test_openapi_resolver_given_schema_list_references(): + # We shouldn't resolve when the $ref is targeting a schema (Model) + document = { + "schema": {"$ref": "#/definitions/Found"}, + "definitions": {"Found": {"value": ["v1", "v2"]}}, + } + resolver = OpenAPISpecificationResolver(document, allow_recursive=True, rest_api_id="123") + result = resolver.resolve_references() + assert result == document + + +def test_openapi_resolver_given_list_references(): + document = { + "responses": {"$ref": "#/definitions/ResponsePost"}, + "definitions": {"ResponsePost": {"value": ["v1", "v2"]}}, + } + resolver = OpenAPISpecificationResolver(document, allow_recursive=True, rest_api_id="123") + result = resolver.resolve_references() + assert result == { + "responses": {"value": ["v1", "v2"]}, + "definitions": {"ResponsePost": {"value": ["v1", "v2"]}}, + } + + +def test_create_invocation_headers(): + invocation_context = ApiInvocationContext( + method="GET", path="/", data="", headers={"X-Header": "foobar"} + ) + invocation_context.integration = { + "requestParameters": {"integration.request.header.X-Custom": "'Event'"} + } + headers = invocation_context.headers + + req_params_resolver = RequestParametersResolver() + req_params = req_params_resolver.resolve(invocation_context) + + headers.update(req_params.get("headers", {})) + assert headers == {"X-Header": "foobar", "X-Custom": "Event"} + + invocation_context.integration = { + "requestParameters": {"integration.request.path.foobar": "'CustomValue'"} + } + + req_params = req_params_resolver.resolve(invocation_context) + headers.update(req_params.get("headers", {})) + assert headers == {"X-Header": "foobar", "X-Custom": "Event"} + + path = req_params.get("path", {}) + assert path == {"foobar": "CustomValue"} + + +class TestApigatewayEvents: + # TODO: remove this tests, assertion are wrong + def test_construct_invocation_event(self): + tt = [ + { + "method": "GET", + "path": "/test/path", + "headers": {}, + "data": None, + "query_string_params": None, + "is_base64_encoded": False, + "expected": { + "path": "/test/path", + "headers": {}, + "multiValueHeaders": {}, + "body": None, + "isBase64Encoded": False, + "httpMethod": "GET", + "queryStringParameters": None, + "multiValueQueryStringParameters": None, + }, + }, + { + "method": "GET", + "path": "/test/path", + "headers": {}, + "data": None, + "query_string_params": {}, + "is_base64_encoded": False, + "expected": { + "path": "/test/path", + "headers": {}, + "multiValueHeaders": {}, + "body": None, + "isBase64Encoded": False, + "httpMethod": "GET", + "queryStringParameters": None, + "multiValueQueryStringParameters": None, + }, + }, + { + "method": "GET", + "path": "/test/path", + "headers": {}, + "data": None, + "query_string_params": {"foo": "bar"}, + "is_base64_encoded": False, + "expected": { + "path": "/test/path", + "headers": {}, + "multiValueHeaders": {}, + "body": None, + "isBase64Encoded": False, + "httpMethod": "GET", + "queryStringParameters": {"foo": "bar"}, + "multiValueQueryStringParameters": {"foo": ["bar"]}, + }, + }, + { + "method": "GET", + "path": "/test/path?baz=qux", + "headers": {}, + "data": None, + "query_string_params": {"foo": "bar"}, + "is_base64_encoded": False, + "expected": { + "path": "/test/path?baz=qux", + "headers": {}, + "multiValueHeaders": {}, + "body": None, + "isBase64Encoded": False, + "httpMethod": "GET", + "queryStringParameters": {"foo": "bar"}, + "multiValueQueryStringParameters": {"foo": ["bar"]}, + }, + }, + ] + + for t in tt: + result = LambdaProxyIntegration.construct_invocation_event( + t["method"], + t["path"], + t["headers"], + t["data"], + t["query_string_params"], + t["is_base64_encoded"], + ) + assert result == t["expected"] + + +class TestRequestParameterResolver: + def test_resolve_request_parameters(self): + integration: Dict[str, Any] = { + "requestParameters": { + "integration.request.path.pathParam": "method.request.path.id", + "integration.request.querystring.baz": "method.request.querystring.baz", + "integration.request.querystring.token": "method.request.header.Authorization", + "integration.request.querystring.env": "stageVariables.enviroment", + "integration.request.header.Content-Type": "'application/json'", + "integration.request.header.body-header": "method.request.body", + "integration.request.header.testContext": "context.authorizer.myvalue", + } + } + + context = ApiInvocationContext( + method="POST", + path="/foo/bar?baz=test", + data="spam_eggs", + headers={"Authorization": "Bearer 1234"}, + stage="local", + ) + context.path_params = {"id": "bar"} + context.integration = integration + context.stage_variables = {"enviroment": "dev"} + context.auth_context["authorizer"] = {"MyValue": 1} + resolver = RequestParametersResolver() + result = resolver.resolve(context) + + assert result == { + "path": {"pathParam": "bar"}, + "querystring": {"baz": "test", "token": "Bearer 1234", "env": "dev"}, + "headers": { + "Content-Type": "application/json", + "body-header": "spam_eggs", + "testContext": "1", + }, + } + + +class TestModelResolver: + def test_resolve_regular_model(self): + container = RestApiContainer(rest_api={}) + # set up the model + model_id = "model1" + model_name = "Pet" + model_schema = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "type": {"type": "string"}, + "price": {"type": "number"}, + }, + } + + model = Model( + id=model_id, + name=model_name, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema), + ) + + container.models[model_name] = model + + resolver = ModelResolver(rest_api_container=container, model_name=model_name) + + resolved_model = resolver.get_resolved_model() + # there are no $ref to resolve, the schema should identical + assert resolved_model == model_schema + + def test_resolve_non_existent_model(self): + container = RestApiContainer(rest_api={}) + + resolver = ModelResolver(rest_api_container=container, model_name="deadbeef") + + resolved_model = resolver.get_resolved_model() + # the Model does not exist, verify it returns None + assert resolved_model is None + + def test_resolve_regular_model_with_nested_ref(self): + container = RestApiContainer(rest_api={}) + + # set up the model PetType + model_id_pet_type = "model0" + model_name_pet_type = "PetType" + model_schema_pet_type = {"type": "string", "enum": ["dog", "cat", "fish", "bird", "gecko"]} + + model = Model( + id=model_id_pet_type, + name=model_name_pet_type, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_pet_type), + ) + container.models[model_name_pet_type] = model + + # set up the model Pet + model_id_pet = "model1" + model_name_pet = "Pet" + model_schema_pet = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "type": {"$ref": "https://domain.com/restapis/deadbeef/models/PetType"}, + "price": {"type": "number"}, + }, + } + + model = Model( + id=model_id_pet, + name=model_name_pet, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_pet), + ) + container.models[model_name_pet] = model + + # set up the model NewPetResponse + model_id_new_response_pet = "model2" + model_name_new_response_pet = "NewPetResponse" + model_schema_new_response_pet = { + "type": "object", + "properties": { + "pet": {"$ref": "https://domain.com/restapis/deadbeef/models/Pet"}, + "message": {"type": "string"}, + }, + } + + model_2 = Model( + id=model_id_new_response_pet, + name=model_name_new_response_pet, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_new_response_pet), + ) + container.models[model_name_new_response_pet] = model_2 + + resolver = ModelResolver( + rest_api_container=container, model_name=model_name_new_response_pet + ) + + resolved_model = resolver.get_resolved_model() + # assert that the Pet Model has been resolved and set in $defs for NewPetResponse Model + assert resolved_model["properties"]["pet"]["$ref"] == "#/$defs/Pet" + assert resolved_model["$defs"]["Pet"]["type"] == model_schema_pet["type"] + assert ( + resolved_model["$defs"]["Pet"]["properties"]["id"] + == model_schema_pet["properties"]["id"] + ) + + # assert that the PetType Model has been resolved in $defs and also set in $defs for Pet Model + assert resolved_model["$defs"]["Pet"]["properties"]["type"]["$ref"] == "#/$defs/PetType" + assert resolved_model["$defs"]["PetType"] == model_schema_pet_type + + def test_resolve_regular_model_with_missing_ref(self): + container = RestApiContainer(rest_api={}) + # set up the model + model_id_new_response_pet = "model2" + model_name_new_response_pet = "NewPetResponse" + model_schema_new_response_pet = { + "type": "object", + "properties": { + "pet": { + "$ref": "https://domain.com/restapis/deadbeef/models/Pet" # this ref is not present + }, + "message": {"type": "string"}, + }, + } + + model_2 = Model( + id=model_id_new_response_pet, + name=model_name_new_response_pet, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_new_response_pet), + ) + container.models[model_name_new_response_pet] = model_2 + + resolver = ModelResolver( + rest_api_container=container, model_name=model_name_new_response_pet + ) + + resolved_model = resolver.get_resolved_model() + assert resolved_model is None + + def test_resolve_model_circular_ref(self): + container = RestApiContainer(rest_api={}) + # set up the model, Person, which references House + model_id_person = "model1" + model_name_person = "Person" + model_schema_person = { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + "house": {"$ref": "https://domain.com/restapis/deadbeef/models/House"}, + }, + } + + model_person = Model( + id=model_id_person, + name=model_name_person, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_person), + ) + container.models[model_name_person] = model_person + + # set up the model House, which references the Person model, we have a circular ref + model_id_house = "model2" + model_name_house = "House" + model_schema_house = { + "type": "object", + "properties": { + "contains": { + "type": "array", + "items": {"$ref": "https://domain.com/restapis/deadbeef/models/Person"}, + } + }, + } + + model_house = Model( + id=model_id_house, + name=model_name_house, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_house), + ) + container.models[model_name_house] = model_house + + # we resolve the Person model containing the House model (which contains the Person model) + resolver = ModelResolver(rest_api_container=container, model_name=model_name_person) + + resolved_model = resolver.get_resolved_model() + # assert that the House Model has been resolved and set in $defs for Person Model + assert resolved_model["properties"]["house"]["$ref"] == "#/$defs/House" + + # now assert that the Person $ref in House has been properly resolved to #, indicating a recursive $ref to its + # own model + assert resolved_model["$defs"]["House"]["properties"]["contains"]["items"]["$ref"] == "#" + + # now we need to resolve the House schema to see if the cached Person is properly set in $defs with proper + # references + resolver = ModelResolver(rest_api_container=container, model_name=model_name_house) + + resolved_model = resolver.get_resolved_model() + # assert that the Person Model has been resolved and set in $defs for House Model + assert resolved_model["properties"]["contains"]["items"]["$ref"] == "#/$defs/Person" + + # now assert that the House $ref in Person has been properly resolved to #, indicating a recursive $ref to its + # own model + assert resolved_model["$defs"]["Person"]["properties"]["house"]["$ref"] == "#" + + def test_resolve_model_recursive_ref(self): + container = RestApiContainer(rest_api={}) + # set up the model, Person, which references Person (recursive ref) + model_id_person = "model1" + model_name_person = "Person" + model_schema_person = { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + "children": { + "type": "array", + "items": {"$ref": "https://domain.com/restapis/deadbeef/models/Person"}, + }, + }, + } + + model_person = Model( + id=model_id_person, + name=model_name_person, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_person), + ) + container.models[model_name_person] = model_person + + # we resolve the Person model containing the House model (which contains the Person model) + resolver = ModelResolver(rest_api_container=container, model_name=model_name_person) + + resolved_model = resolver.get_resolved_model() + # assert that the Person Model has been resolved, and the recursive $ref set to # + assert resolved_model["properties"]["children"]["items"]["$ref"] == "#" + + def test_resolve_model_circular_ref_with_recursive_ref(self): + container = RestApiContainer(rest_api={}) + # set up the model, Person, which references House + model_id_person = "model1" + model_name_person = "Person" + model_schema_person = { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + "house": {"$ref": "https://domain.com/restapis/deadbeef/models/House"}, + }, + } + + model_person = Model( + id=model_id_person, + name=model_name_person, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_person), + ) + container.models[model_name_person] = model_person + + # set up the model House, which references the Person model, we have a circular ref, and House itself + model_id_house = "model2" + model_name_house = "House" + model_schema_house = { + "type": "object", + "properties": { + "contains": { + "type": "array", + "items": {"$ref": "https://domain.com/restapis/deadbeef/models/Person"}, + }, + "houses": { + "type": "array", + "items": {"$ref": "https://domain.com/restapis/deadbeef/models/House"}, + }, + }, + } + + model_house = Model( + id=model_id_house, + name=model_name_house, + contentType=APPLICATION_JSON, + schema=json.dumps(model_schema_house), + ) + container.models[model_name_house] = model_house + + # we resolve the Person model containing the House model (which contains the Person model) + resolver = ModelResolver(rest_api_container=container, model_name=model_name_person) + + resolved_model = resolver.get_resolved_model() + # assert that the House Model has been resolved and set in $defs for Person Model + assert resolved_model["properties"]["house"]["$ref"] == "#/$defs/House" + + # now assert that the Person $ref in House has been properly resolved to #, indicating a recursive $ref to its + # own model + assert resolved_model["$defs"]["House"]["properties"]["contains"]["items"]["$ref"] == "#" + + # now assert that the Person $ref in House has been properly resolved to #, indicating a recursive $ref to its + # own model + assert ( + resolved_model["$defs"]["House"]["properties"]["houses"]["items"]["$ref"] + == "#/$defs/House" + ) diff --git a/tests/unit/services/apigateway/test_handler_api_key_validation.py b/tests/unit/services/apigateway/test_handler_api_key_validation.py new file mode 100644 index 0000000000000..51715f40941ec --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_api_key_validation.py @@ -0,0 +1,244 @@ +import pytest +from moto.apigateway.models import APIGatewayBackend, apigateway_backends +from werkzeug.datastructures.headers import Headers + +from localstack.aws.api.apigateway import ApiKeySourceType, Method +from localstack.http import Request, Response +from localstack.services.apigateway.models import MergedRestApi, RestApiDeployment +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import ( + InvocationRequest, + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.gateway_response import InvalidAPIKeyError +from localstack.services.apigateway.next_gen.execute_api.handlers import ApiKeyValidationHandler +from localstack.services.apigateway.next_gen.execute_api.variables import ( + ContextVariables, + ContextVarsIdentity, +) +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME +from localstack.utils.strings import short_uid + +TEST_API_ID = "testapi" +TEST_API_STAGE = "dev" + + +@pytest.fixture +def moto_backend(): + """ + because we depend on Moto here, we have to use the backend because the keys and usage plans + are fetched at runtime in the store directly. We should avoid using this fixture directly in + the tests and favor reusable fixture that could later on be replaced to populate the Localstack + store instead without impacting the tests + """ + moto_backend: APIGatewayBackend = apigateway_backends[TEST_AWS_ACCOUNT_ID][TEST_AWS_REGION_NAME] + yield moto_backend + moto_backend.reset() + + +@pytest.fixture +def create_context(): + """ + Create a context populated with what we would expect to receive from the chain at runtime. + We assume that the parser and other handler have successfully populated the context to this point. + """ + + def _create_context( + method: Method = None, + api_key_source: ApiKeySourceType = None, + headers: dict[str, str] = None, + api_key: str = None, + ): + context = RestApiInvocationContext(Request()) + + # The api key validator only relies on the raw headers from the invocation requests + context.invocation_request = InvocationRequest(headers=Headers(headers)) + + # Frozen deployment populated by the router + context.deployment = RestApiDeployment( + account_id=TEST_AWS_ACCOUNT_ID, + region=TEST_AWS_REGION_NAME, + rest_api=MergedRestApi( + # TODO validate that this value is always populated by localstack. AWS defaults to HEADERS on all new apis + rest_api={"apiKeySource": api_key_source or ApiKeySourceType.HEADER} + ), + ) + + # Context populated by parser handler + context.region = TEST_AWS_REGION_NAME + context.account_id = TEST_AWS_ACCOUNT_ID + context.stage = TEST_API_STAGE + context.api_id = TEST_API_ID + context.resource_method = method or Method() + context.context_variables = ContextVariables() + context.context_variables["identity"] = ContextVarsIdentity() + + # Context populated by a Lambda Authorizer + if api_key is not None: + context.context_variables["identity"]["apiKey"] = api_key + return context + + return _create_context + + +@pytest.fixture +def api_key_validation_handler(): + """Returns a dummy api key validation handler invoker for testing.""" + + def _handler_invoker(context: RestApiInvocationContext): + return ApiKeyValidationHandler()(RestApiGatewayHandlerChain(), context, Response()) + + return _handler_invoker + + +@pytest.fixture +def create_usage_plan(moto_backend): + def _create_usage_plan(attach_stage: bool, attach_key_id: str = None, backend=None): + backend = backend or moto_backend + stage_config = {"name": short_uid()} + if attach_stage: + stage_config["apiStages"] = [{"apiId": TEST_API_ID, "stage": TEST_API_STAGE}] + usage_plan = backend.create_usage_plan(stage_config) + if attach_key_id: + backend.create_usage_plan_key( + usage_plan_id=usage_plan.id, payload={"keyId": attach_key_id, "keyType": "API_KEY"} + ) + return usage_plan + + return _create_usage_plan + + +@pytest.fixture +def create_api_key(moto_backend): + def _create_api_key(key_value: str, enabled: bool = True, backend=None): + backend = backend or moto_backend + return backend.create_api_key({"enabled": enabled, "value": key_value}) + + return _create_api_key + + +class TestHandlerApiKeyValidation: + def test_no_api_key_required(self, create_context, api_key_validation_handler): + api_key_validation_handler(create_context()) + + def test_api_key_headers_valid( + self, create_context, api_key_validation_handler, create_usage_plan, create_api_key + ): + method = Method(apiKeyRequired=True) + api_key_value = "01234567890123456789" + + # create api key + api_key = create_api_key(api_key_value) + # create usage plan and attach key + create_usage_plan(attach_stage=True, attach_key_id=api_key.id) + # pass the key in the request headers + ctx = create_context(method=method, headers={"x-api-key": api_key_value}) + + # Call handler + api_key_validation_handler(context=ctx) + + assert ctx.context_variables["identity"]["apiKey"] == api_key_value + assert ctx.context_variables["identity"]["apiKeyId"] == api_key.id + + def test_api_key_headers_absent( + self, create_context, api_key_validation_handler, create_api_key, create_usage_plan + ): + method = Method(apiKeyRequired=True) + api_key_value = "01234567890123456789" + + # create api key + api_key = create_api_key(api_key_value) + # create usage plan and attach key + create_usage_plan(attach_stage=True, attach_key_id=api_key.id) + + with pytest.raises(InvalidAPIKeyError) as e: + api_key_validation_handler( + # missing headers will raise error + context=create_context(method=method, headers={}) + ) + assert e.value.message == "Forbidden" + + def test_api_key_no_api_key( + self, create_context, api_key_validation_handler, create_usage_plan + ): + method = Method(apiKeyRequired=True) + api_key_value = "01234567890123456789" + + # Create usage plan with no keys + create_usage_plan(attach_stage=True) + + with pytest.raises(InvalidAPIKeyError) as e: + api_key_validation_handler( + context=create_context(method=method, headers={"x-api-key": api_key_value}) + ) + assert e.value.message == "Forbidden" + + def test_api_key_no_usage_plan_key( + self, create_context, api_key_validation_handler, create_api_key, create_usage_plan + ): + method = Method(apiKeyRequired=True) + api_key_value = "01234567890123456789" + + # create api key + create_api_key(api_key_value) + # Create usage plan but the key won't be associated + create_usage_plan(attach_stage=True) + + with pytest.raises(InvalidAPIKeyError) as e: + api_key_validation_handler( + context=create_context(method=method, headers={"x-api-key": api_key_value}) + ) + assert e.value.message == "Forbidden" + + def test_api_key_disabled( + self, create_context, api_key_validation_handler, create_api_key, create_usage_plan + ): + method = Method(apiKeyRequired=True) + api_key_value = "01234567890123456789" + + # Create api key but set `Enabled` to False + api_key = create_api_key(api_key_value, enabled=False) + # create usage plan and attach key + create_usage_plan(attach_stage=True, attach_key_id=api_key.id) + + with pytest.raises(InvalidAPIKeyError) as e: + api_key_validation_handler( + context=create_context(method=method, headers={"x-api-key": api_key_value}) + ) + assert e.value.message == "Forbidden" + + def test_api_key_in_identity_context( + self, create_context, api_key_validation_handler, create_api_key, create_usage_plan + ): + method = Method(apiKeyRequired=True) + api_key_value = "01234567890123456789" + + # create api key + api_key = create_api_key(api_key_value) + # create usage plan and attach key + create_usage_plan(attach_stage=True, attach_key_id=api_key.id) + + api_key_validation_handler( + context=create_context( + # The frozen api has key source set to AUTHORIZER and the api_key was populated by the Authorizer + method=method, + api_key=api_key_value, + api_key_source=ApiKeySourceType.AUTHORIZER, + ) + ) + + def test_api_key_in_identity_context_api_not_configured( + self, create_context, api_key_validation_handler, create_api_key, create_usage_plan + ): + method = Method(apiKeyRequired=True) + api_key_value = "01234567890123456789" + + # create api key + api_key = create_api_key(api_key_value) + # create usage plan and attach key + create_usage_plan(attach_stage=True, attach_key_id=api_key.id) + + with pytest.raises(InvalidAPIKeyError) as e: + # The api_key was populated by the Authorizer, but missing frozen api configuration + api_key_validation_handler(context=create_context(method=method, api_key=api_key_value)) + assert e.value.message == "Forbidden" diff --git a/tests/unit/services/apigateway/test_handler_exception.py b/tests/unit/services/apigateway/test_handler_exception.py new file mode 100644 index 0000000000000..e7d3e4386b714 --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_exception.py @@ -0,0 +1,139 @@ +import pytest + +from localstack.aws.api.apigateway import GatewayResponse, GatewayResponseType +from localstack.http import Request +from localstack.services.apigateway.models import MergedRestApi, RestApiDeployment +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import RestApiInvocationContext +from localstack.services.apigateway.next_gen.execute_api.gateway_response import ( + AccessDeniedError, + BaseGatewayException, + UnauthorizedError, +) +from localstack.services.apigateway.next_gen.execute_api.handlers import GatewayExceptionHandler +from localstack.services.apigateway.next_gen.execute_api.router import ApiGatewayEndpoint +from localstack.services.apigateway.next_gen.execute_api.variables import ContextVariables +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME + + +class TestGatewayResponse: + def test_base_response(self): + with pytest.raises(BaseGatewayException) as e: + raise BaseGatewayException() + assert e.value.message == "Unimplemented Response" + + def test_subclassed_response(self): + with pytest.raises(BaseGatewayException) as e: + raise AccessDeniedError("Access Denied") + assert e.value.message == "Access Denied" + assert e.value.type == GatewayResponseType.ACCESS_DENIED + + +@pytest.fixture +def apigw_response(): + return ApiGatewayEndpoint.create_response(Request()) + + +class TestGatewayResponseHandler: + @pytest.fixture + def get_context(self): + def _create_context_with_deployment(gateway_responses=None) -> RestApiInvocationContext: + context = RestApiInvocationContext(Request()) + context.deployment = RestApiDeployment( + account_id=TEST_AWS_ACCOUNT_ID, + region=TEST_AWS_REGION_NAME, + rest_api=MergedRestApi(None), + ) + context.context_variables = ContextVariables(requestId="REQUEST_ID") + if gateway_responses: + context.deployment.rest_api.gateway_responses = gateway_responses + return context + + return _create_context_with_deployment + + def test_non_gateway_exception(self, get_context, apigw_response): + exception_handler = GatewayExceptionHandler() + + # create a default Exception that should not be handled by the handler + exception = Exception("Unhandled exception") + + exception_handler( + chain=RestApiGatewayHandlerChain(), + exception=exception, + context=get_context(), + response=apigw_response, + ) + + assert apigw_response.status_code == 500 + assert apigw_response.data == b"Error in apigateway invocation: Unhandled exception" + + def test_gateway_exception(self, get_context, apigw_response): + exception_handler = GatewayExceptionHandler() + + # Create an UnauthorizedError exception with no Gateway Response configured + exception = UnauthorizedError("Unauthorized") + exception_handler( + chain=RestApiGatewayHandlerChain(), + exception=exception, + context=get_context(), + response=apigw_response, + ) + + assert apigw_response.status_code == 401 + assert apigw_response.json == {"message": "Unauthorized"} + assert apigw_response.headers.get("x-amzn-errortype") == "UnauthorizedException" + + def test_gateway_exception_with_default_4xx(self, get_context, apigw_response): + exception_handler = GatewayExceptionHandler() + + # Configure DEFAULT_4XX response + gateway_responses = {GatewayResponseType.DEFAULT_4XX: GatewayResponse(statusCode="400")} + + # Create an UnauthorizedError exception with DEFAULT_4xx configured + exception = UnauthorizedError("Unauthorized") + exception_handler( + chain=RestApiGatewayHandlerChain(), + exception=exception, + context=get_context(gateway_responses), + response=apigw_response, + ) + + assert apigw_response.status_code == 400 + assert apigw_response.json == {"message": "Unauthorized"} + assert apigw_response.headers.get("x-amzn-errortype") == "UnauthorizedException" + + def test_gateway_exception_with_gateway_response(self, get_context, apigw_response): + exception_handler = GatewayExceptionHandler() + + # Configure Access Denied response + gateway_responses = {GatewayResponseType.UNAUTHORIZED: GatewayResponse(statusCode="405")} + + # Create an UnauthorizedError exception with UNAUTHORIZED configured + exception = UnauthorizedError("Unauthorized") + exception_handler( + chain=RestApiGatewayHandlerChain(), + exception=exception, + context=get_context(gateway_responses), + response=apigw_response, + ) + + assert apigw_response.status_code == 405 + assert apigw_response.json == {"message": "Unauthorized"} + assert apigw_response.headers.get("x-amzn-errortype") == "UnauthorizedException" + + def test_gateway_exception_access_denied(self, get_context, apigw_response): + # special case where the `Message` field is capitalized + exception_handler = GatewayExceptionHandler() + + # Create an AccessDeniedError exception with no Gateway Response configured + exception = AccessDeniedError("Access Denied") + exception_handler( + chain=RestApiGatewayHandlerChain(), + exception=exception, + context=get_context(), + response=apigw_response, + ) + + assert apigw_response.status_code == 403 + assert apigw_response.json == {"Message": "Access Denied"} + assert apigw_response.headers.get("x-amzn-errortype") == "AccessDeniedException" diff --git a/tests/unit/services/apigateway/test_handler_integration_request.py b/tests/unit/services/apigateway/test_handler_integration_request.py new file mode 100644 index 0000000000000..72b021e4b2d63 --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_integration_request.py @@ -0,0 +1,343 @@ +from http import HTTPMethod + +import pytest + +from localstack.aws.api.apigateway import Integration, IntegrationType +from localstack.http import Request, Response +from localstack.services.apigateway.models import MergedRestApi, RestApiDeployment +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import ( + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.gateway_response import ( + UnsupportedMediaTypeError, +) +from localstack.services.apigateway.next_gen.execute_api.handlers import ( + IntegrationRequestHandler, + InvocationRequestParser, +) +from localstack.services.apigateway.next_gen.execute_api.handlers.integration_request import ( + PassthroughBehavior, +) +from localstack.services.apigateway.next_gen.execute_api.variables import ( + ContextVariableOverrides, + ContextVariables, + ContextVarsRequestOverride, + ContextVarsResponseOverride, +) +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME + +TEST_API_ID = "test-api" +TEST_API_STAGE = "stage" + +BINARY_DATA_1 = b"\x1f\x8b\x08\x00\x14l\xeec\x02\xffK\xce\xcf-(J-.NMQHI,I\xd4Q(\xce\xc8/\xcdIQHJU\xc8\xcc+K\xcc\xc9LQ\x08\rq\xd3\xb5P(.)\xca\xccK\x07\x00\xb89\x10W/\x00\x00\x00" +BINARY_DATA_2 = b"\x1f\x8b\x08\x00\x14l\xeec\x02\xffK\xce\xcf-(J-.NMQHI,IT0\xd2Q(\xce\xc8/\xcdIQHJU\xc8\xcc+K\xcc\xc9LQ\x08\rq\xd3\xb5P(.)\xca\xccKWH*-QH\xc9LKK-J\xcd+\x01\x00\x99!\xedI?\x00\x00\x00" +BINARY_DATA_1_SAFE = b"\x1f\xef\xbf\xbd\x08\x00\x14l\xef\xbf\xbdc\x02\xef\xbf\xbdK\xef\xbf\xbd\xef\xbf\xbd-(J-.NMQHI,I\xef\xbf\xbdQ(\xef\xbf\xbd\xef\xbf\xbd/\xef\xbf\xbdIQHJU\xef\xbf\xbd\xef\xbf\xbd+K\xef\xbf\xbd\xef\xbf\xbdLQ\x08\rq\xd3\xb5P(.)\xef\xbf\xbd\xef\xbf\xbdK\x07\x00\xef\xbf\xbd9\x10W/\x00\x00\x00" + + +@pytest.fixture +def default_context(): + """ + Create a context populated with what we would expect to receive from the chain at runtime. + We assume that the parser and other handler have successfully populated the context to this point. + """ + + context = RestApiInvocationContext( + Request( + method=HTTPMethod.POST, + headers={"header": ["header1", "header2"]}, + path=f"{TEST_API_STAGE}/resource/path", + query_string="qs=qs1&qs=qs2", + ) + ) + + # Frozen deployment populated by the router + context.deployment = RestApiDeployment( + account_id=TEST_AWS_ACCOUNT_ID, + region=TEST_AWS_REGION_NAME, + rest_api=MergedRestApi(rest_api={}), + ) + + # Context populated by parser handler before creating the invocation request + context.region = TEST_AWS_REGION_NAME + context.account_id = TEST_AWS_ACCOUNT_ID + context.stage = TEST_API_STAGE + context.api_id = TEST_API_ID + + request = InvocationRequestParser().create_invocation_request(context) + context.invocation_request = request + + # add path_parameters from the router parser + request["path_parameters"] = {"proxy": "path"} + + context.integration = Integration( + type=IntegrationType.HTTP, + requestParameters=None, + uri="https://example.com", + httpMethod="POST", + ) + context.context_variables = ContextVariables( + resourceId="resource-id", + apiId=TEST_API_ID, + httpMethod="POST", + path=f"{TEST_API_STAGE}/resource/{{proxy}}", + resourcePath="/resource/{proxy}", + stage=TEST_API_STAGE, + ) + context.context_variable_overrides = ContextVariableOverrides( + requestOverride=ContextVarsRequestOverride(header={}, path={}, querystring={}), + responseOverride=ContextVarsResponseOverride(header={}, status=0), + ) + + return context + + +@pytest.fixture +def integration_request_handler(): + """Returns a dummy integration request handler invoker for testing.""" + + def _handler_invoker(context: RestApiInvocationContext): + return IntegrationRequestHandler()(RestApiGatewayHandlerChain(), context, Response()) + + return _handler_invoker + + +class TestHandlerIntegrationRequest: + def test_noop(self, integration_request_handler, default_context): + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b"" + assert default_context.integration_request["headers"]["Accept"] == "application/json" + assert default_context.integration_request["http_method"] == "POST" + assert default_context.integration_request["query_string_parameters"] == {} + assert default_context.integration_request["uri"] == "https://example.com" + + def test_passthrough_never(self, integration_request_handler, default_context): + default_context.integration["passthroughBehavior"] = PassthroughBehavior.NEVER + + # With no template, it is expected to raise + with pytest.raises(UnsupportedMediaTypeError) as e: + integration_request_handler(default_context) + assert e.match("Unsupported Media Type") + + # With a non-matching template it should raise + default_context.integration["requestTemplates"] = {"application/xml": "#Empty"} + with pytest.raises(UnsupportedMediaTypeError) as e: + integration_request_handler(default_context) + assert e.match("Unsupported Media Type") + + # With a matching template it should use it + default_context.integration["requestTemplates"] = {"application/json": '{"foo":"bar"}'} + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b'{"foo":"bar"}' + + def test_passthrough_when_no_match(self, integration_request_handler, default_context): + default_context.integration["passthroughBehavior"] = PassthroughBehavior.WHEN_NO_MATCH + # When no template are created it should passthrough + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b"" + + # when a non matching template is found it should passthrough + default_context.integration["requestTemplates"] = {"application/xml": '{"foo":"bar"}'} + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b"" + + # when a matching template is found, it should use it + default_context.integration["requestTemplates"] = {"application/json": '{"foo":"bar"}'} + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b'{"foo":"bar"}' + + def test_passthrough_when_no_templates(self, integration_request_handler, default_context): + default_context.integration["passthroughBehavior"] = PassthroughBehavior.WHEN_NO_TEMPLATES + # If a non matching template is found, it should raise + default_context.integration["requestTemplates"] = {"application/xml": ""} + with pytest.raises(UnsupportedMediaTypeError) as e: + integration_request_handler(default_context) + assert e.match("Unsupported Media Type") + + # If a matching template is found, it should use it + default_context.integration["requestTemplates"] = {"application/json": '{"foo":"bar"}'} + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b'{"foo":"bar"}' + + # If no template were created, it should passthrough + default_context.integration["requestTemplates"] = {} + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b"" + + def test_default_template(self, integration_request_handler, default_context): + # if no matching template, use the default + default_context.integration["requestTemplates"] = {"$default": '{"foo":"bar"}'} + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b'{"foo":"bar"}' + + # If there is a matching template, use it instead + default_context.integration["requestTemplates"] = { + "$default": '{"foo":"bar"}', + "application/json": "Matching Template", + } + integration_request_handler(default_context) + assert default_context.integration_request["body"] == b"Matching Template" + + def test_request_parameters(self, integration_request_handler, default_context): + default_context.integration["requestParameters"] = { + "integration.request.path.path": "method.request.path.proxy", + "integration.request.querystring.qs": "method.request.querystring.qs", + "integration.request.header.header": "method.request.header.header", + } + default_context.integration["uri"] = "https://example.com/{path}" + integration_request_handler(default_context) + # TODO this test will fail when we implement uri mapping + assert default_context.integration_request["uri"] == "https://example.com/path" + assert default_context.integration_request["query_string_parameters"] == {"qs": "qs2"} + headers = default_context.integration_request["headers"] + assert headers.get("Accept") == "application/json" + assert headers.get("header") == "header2" + + def test_request_override(self, integration_request_handler, default_context): + default_context.integration["requestParameters"] = { + "integration.request.path.path": "method.request.path.path", + "integration.request.querystring.qs": "method.request.multivaluequerystring.qs", + "integration.request.header.header": "method.request.header.header", + } + default_context.integration["uri"] = "https://example.com/{path}" + default_context.integration["requestTemplates"] = {"application/json": REQUEST_OVERRIDE} + integration_request_handler(default_context) + assert default_context.integration_request["uri"] == "https://example.com/pathOverride" + assert default_context.integration_request["query_string_parameters"] == { + "qs": "queryOverride" + } + headers = default_context.integration_request["headers"] + assert headers.get("Accept") == "application/json" + assert headers.get("header") == "headerOverride" + assert headers.getlist("multivalue") == ["1header", "2header"] + + def test_request_override_casing(self, integration_request_handler, default_context): + default_context.integration["requestParameters"] = { + "integration.request.header.myHeader": "method.request.header.header", + } + default_context.integration["requestTemplates"] = { + "application/json": '#set($context.requestOverride.header.myheader = "headerOverride")' + } + integration_request_handler(default_context) + # TODO: for now, it's up to the integration to properly merge headers (`requests` does it automatically) + headers = default_context.integration_request["headers"] + assert headers.get("Accept") == "application/json" + assert headers.getlist("myHeader") == ["header2", "headerOverride"] + assert headers.getlist("myheader") == ["header2", "headerOverride"] + + def test_multivalue_mapping(self, integration_request_handler, default_context): + default_context.integration["requestParameters"] = { + "integration.request.header.multi": "method.request.multivalueheader.header", + "integration.request.querystring.multi": "method.request.multivaluequerystring.qs", + } + integration_request_handler(default_context) + assert default_context.integration_request["headers"]["multi"] == "header1,header2" + assert default_context.integration_request["query_string_parameters"]["multi"] == [ + "qs1", + "qs2", + ] + + def test_integration_uri_path_params_undefined( + self, integration_request_handler, default_context + ): + default_context.integration["requestParameters"] = { + "integration.request.path.path": "method.request.path.wrongvalue", + } + default_context.integration["uri"] = "https://example.com/{path}" + integration_request_handler(default_context) + assert default_context.integration_request["uri"] == "https://example.com/{path}" + + def test_integration_uri_stage_variables(self, integration_request_handler, default_context): + default_context.stage_variables = { + "stageVar": "stageValue", + } + default_context.integration["requestParameters"] = { + "integration.request.path.path": "method.request.path.proxy", + } + default_context.integration["uri"] = "https://example.com/{path}/${stageVariables.stageVar}" + integration_request_handler(default_context) + assert default_context.integration_request["uri"] == "https://example.com/path/stageValue" + + +class TestIntegrationRequestBinaryHandling: + """ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html + When AWS differentiates between "text" and "binary" types, it means if the MIME type of the Content-Type or Accept + header matches one of the binaryMediaTypes configured + """ + + @pytest.mark.parametrize( + "request_content_type,binary_medias,content_handling, expected", + [ + (None, None, None, "utf8"), + (None, None, "CONVERT_TO_BINARY", "b64-decoded"), + (None, None, "CONVERT_TO_TEXT", "utf8"), + ("text/plain", ["image/png"], None, "utf8"), + ("text/plain", ["image/png"], "CONVERT_TO_BINARY", "b64-decoded"), + ("text/plain", ["image/png"], "CONVERT_TO_TEXT", "utf8"), + ("image/png", ["image/png"], None, None), + ("image/png", ["image/png"], "CONVERT_TO_BINARY", None), + ("image/png", ["image/png"], "CONVERT_TO_TEXT", "b64-encoded"), + ], + ) + @pytest.mark.parametrize( + "input_data,possible_values", + [ + ( + BINARY_DATA_1, + { + "b64-encoded": b"H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA==", + "b64-decoded": None, + "utf8": BINARY_DATA_1_SAFE.decode(), + }, + ), + ( + b"H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSVQw0lEozsgvzUlRSEpVyMwrS8zJTFEIDXHTtVAoLinKzEtXSCotUUjJTEtLLUrNKwEAmSHtST8AAAA=", + { + "b64-encoded": b"SDRzSUFCUnM3bU1DLzB2T3p5MG9TaTB1VGsxUlNFa3NTVlF3MGxFb3pzZ3Z6VWxSU0VwVnlNd3JTOHpKVEZFSURYSFR0VkFvTGluS3pFdFhTQ290VVVqSlRFdExMVXJOS3dFQW1TSHRTVDhBQUFBPQ==", + "b64-decoded": BINARY_DATA_2, + "utf8": "H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSVQw0lEozsgvzUlRSEpVyMwrS8zJTFEIDXHTtVAoLinKzEtXSCotUUjJTEtLLUrNKwEAmSHtST8AAAA=", + }, + ), + ( + b"my text string", + { + "b64-encoded": b"bXkgdGV4dCBzdHJpbmc=", + "b64-decoded": b"\x9b+^\xc6\xdb-\xae)\xe0", + "utf8": "my text string", + }, + ), + ], + ids=["binary", "b64-encoded", "text"], + ) + def test_convert_binary( + self, + request_content_type, + binary_medias, + content_handling, + expected, + input_data, + possible_values, + default_context, + ): + default_context.invocation_request["headers"]["Content-Type"] = request_content_type + default_context.invocation_request["body"] = input_data + default_context.deployment.rest_api.rest_api["binaryMediaTypes"] = binary_medias + default_context.integration["contentHandling"] = content_handling + convert = IntegrationRequestHandler.convert_body + + outcome = possible_values.get(expected, input_data) + if outcome is None: + with pytest.raises(Exception): + convert(context=default_context) + else: + converted_body = convert(context=default_context) + assert converted_body == outcome + + +REQUEST_OVERRIDE = """ +#set($context.requestOverride.header.header = "headerOverride") +#set($context.requestOverride.header.multivalue = ["1header", "2header"]) +#set($context.requestOverride.path.path = "pathOverride") +#set($context.requestOverride.querystring.qs = "queryOverride") +""" diff --git a/tests/unit/services/apigateway/test_handler_integration_response.py b/tests/unit/services/apigateway/test_handler_integration_response.py new file mode 100644 index 0000000000000..122af7c5bbc13 --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_integration_response.py @@ -0,0 +1,341 @@ +import pytest +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import Integration, IntegrationResponse, IntegrationType +from localstack.http import Request, Response +from localstack.services.apigateway.models import MergedRestApi, RestApiDeployment +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import ( + EndpointResponse, + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.gateway_response import ( + ApiConfigurationError, +) +from localstack.services.apigateway.next_gen.execute_api.handlers import ( + IntegrationResponseHandler, + InvocationRequestParser, +) +from localstack.services.apigateway.next_gen.execute_api.variables import ( + ContextVariableOverrides, + ContextVarsRequestOverride, + ContextVarsResponseOverride, +) +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME + +TEST_API_ID = "test-api" +TEST_API_STAGE = "stage" + +BINARY_DATA_1 = b"\x1f\x8b\x08\x00\x14l\xeec\x02\xffK\xce\xcf-(J-.NMQHI,I\xd4Q(\xce\xc8/\xcdIQHJU\xc8\xcc+K\xcc\xc9LQ\x08\rq\xd3\xb5P(.)\xca\xccK\x07\x00\xb89\x10W/\x00\x00\x00" +BINARY_DATA_2 = b"\x1f\x8b\x08\x00\x14l\xeec\x02\xffK\xce\xcf-(J-.NMQHI,IT0\xd2Q(\xce\xc8/\xcdIQHJU\xc8\xcc+K\xcc\xc9LQ\x08\rq\xd3\xb5P(.)\xca\xccKWH*-QH\xc9LKK-J\xcd+\x01\x00\x99!\xedI?\x00\x00\x00" +BINARY_DATA_1_SAFE = b"\x1f\xef\xbf\xbd\x08\x00\x14l\xef\xbf\xbdc\x02\xef\xbf\xbdK\xef\xbf\xbd\xef\xbf\xbd-(J-.NMQHI,I\xef\xbf\xbdQ(\xef\xbf\xbd\xef\xbf\xbd/\xef\xbf\xbdIQHJU\xef\xbf\xbd\xef\xbf\xbd+K\xef\xbf\xbd\xef\xbf\xbdLQ\x08\rq\xd3\xb5P(.)\xef\xbf\xbd\xef\xbf\xbdK\x07\x00\xef\xbf\xbd9\x10W/\x00\x00\x00" + + +class TestSelectionPattern: + def test_selection_pattern_status_code(self): + integration_responses = { + "2OO": IntegrationResponse( + statusCode="200", + ), + "400": IntegrationResponse( + statusCode="400", + selectionPattern="400", + ), + "500": IntegrationResponse( + statusCode="500", + selectionPattern=r"5\d{2}", + ), + } + + def select_int_response(selection_value: str) -> IntegrationResponse: + return IntegrationResponseHandler.select_integration_response( + selection_value=selection_value, + integration_responses=integration_responses, + ) + + int_response = select_int_response("200") + assert int_response["statusCode"] == "200" + + int_response = select_int_response("400") + assert int_response["statusCode"] == "400" + + int_response = select_int_response("404") + # fallback to default + assert int_response["statusCode"] == "200" + + int_response = select_int_response("500") + assert int_response["statusCode"] == "500" + + int_response = select_int_response("501") + assert int_response["statusCode"] == "500" + + def test_selection_pattern_no_default(self): + integration_responses = { + "2OO": IntegrationResponse( + statusCode="200", + selectionPattern="200", + ), + } + + with pytest.raises(ApiConfigurationError) as e: + IntegrationResponseHandler.select_integration_response( + selection_value="404", + integration_responses=integration_responses, + ) + assert e.value.message == "Internal server error" + + def test_selection_pattern_string(self): + integration_responses = { + "2OO": IntegrationResponse( + statusCode="200", + ), + "400": IntegrationResponse( + statusCode="400", + selectionPattern="Malformed.*", + ), + "500": IntegrationResponse( + statusCode="500", + selectionPattern="Internal.*", + ), + } + + def select_int_response(selection_value: str) -> IntegrationResponse: + return IntegrationResponseHandler.select_integration_response( + selection_value=selection_value, + integration_responses=integration_responses, + ) + + # this would basically no error message from AWS lambda + int_response = select_int_response("") + assert int_response["statusCode"] == "200" + + int_response = select_int_response("Malformed request") + assert int_response["statusCode"] == "400" + + int_response = select_int_response("Internal server error") + assert int_response["statusCode"] == "500" + + int_response = select_int_response("Random error") + assert int_response["statusCode"] == "200" + + +@pytest.fixture +def ctx(): + """ + Create a context populated with what we would expect to receive from the chain at runtime. + We assume that the parser and other handler have successfully populated the context to this point. + """ + + context = RestApiInvocationContext(Request()) + + # Frozen deployment populated by the router + context.deployment = RestApiDeployment( + account_id=TEST_AWS_ACCOUNT_ID, + region=TEST_AWS_REGION_NAME, + rest_api=MergedRestApi(rest_api={}), + ) + + # Context populated by parser handler before creating the invocation request + context.region = TEST_AWS_REGION_NAME + context.account_id = TEST_AWS_ACCOUNT_ID + context.stage = TEST_API_STAGE + context.api_id = TEST_API_ID + + request = InvocationRequestParser().create_invocation_request(context) + context.invocation_request = request + + context.integration = Integration(type=IntegrationType.HTTP) + context.context_variables = {} + context.context_variable_overrides = ContextVariableOverrides( + requestOverride=ContextVarsRequestOverride(header={}, path={}, querystring={}), + responseOverride=ContextVarsResponseOverride(header={}, status=0), + ) + context.endpoint_response = EndpointResponse( + body=b'{"foo":"bar"}', + status_code=200, + headers=Headers({"content-type": "application/json", "header": ["multi", "header"]}), + ) + return context + + +@pytest.fixture +def integration_response_handler(): + """Returns a dummy integration response handler invoker for testing.""" + + def _handler_invoker(context: RestApiInvocationContext): + return IntegrationResponseHandler()(RestApiGatewayHandlerChain(), context, Response()) + + return _handler_invoker + + +class TestHandlerIntegrationResponse: + def test_status_code(self, ctx, integration_response_handler): + integration_response = IntegrationResponse( + statusCode="300", + selectionPattern="", + responseParameters=None, + responseTemplates=None, + ) + ctx.integration["integrationResponses"] = {"200": integration_response} + # take the status code from the integration response + integration_response_handler(ctx) + assert ctx.invocation_response["status_code"] == 300 + + # take the status code from the response override + integration_response["responseTemplates"] = { + "application/json": "#set($context.responseOverride.status = 500)" + } + integration_response_handler(ctx) + assert ctx.invocation_response["status_code"] == 500 + + # invalid values from response override are not taken into account > 599 + integration_response["responseTemplates"] = { + "application/json": "#set($context.responseOverride.status = 600)" + } + integration_response_handler(ctx) + assert ctx.invocation_response["status_code"] == 300 + + # invalid values from response override are not taken into account < 100 + integration_response["responseTemplates"] = { + "application/json": "#set($context.responseOverride.status = 99)" + } + integration_response_handler(ctx) + assert ctx.invocation_response["status_code"] == 300 + + def test_headers(self, ctx, integration_response_handler): + integration_response = IntegrationResponse( + statusCode="200", + selectionPattern="", + responseParameters={"method.response.header.header": "'from params'"}, + responseTemplates=None, + ) + ctx.integration["integrationResponses"] = {"200": integration_response} + + # set constant + integration_response_handler(ctx) + assert ctx.invocation_response["headers"]["header"] == "from params" + + # set to body + integration_response["responseParameters"] = { + "method.response.header.header": "integration.response.body" + } + integration_response_handler(ctx) + assert ctx.invocation_response["headers"]["header"] == '{"foo":"bar"}' + + # override + integration_response["responseTemplates"] = { + "application/json": "#set($context.responseOverride.header.header = 'from override')" + } + integration_response_handler(ctx) + assert ctx.invocation_response["headers"]["header"] == "from override" + + def test_default_template_selection_behavior(self, ctx, integration_response_handler): + integration_response = IntegrationResponse( + statusCode="200", + selectionPattern="", + responseParameters=None, + responseTemplates={}, + ) + ctx.integration["integrationResponses"] = {"200": integration_response} + # if none are set return the original body + integration_response_handler(ctx) + assert ctx.invocation_response["body"] == b'{"foo":"bar"}' + + # if no template match, picks the "first" + integration_response["responseTemplates"]["application/xml"] = "xml" + integration_response_handler(ctx) + assert ctx.invocation_response["body"] == b"xml" + + # Match with json + integration_response["responseTemplates"]["application/json"] = "json" + integration_response_handler(ctx) + assert ctx.invocation_response["body"] == b"json" + + # Aws favors json when not math + ctx.endpoint_response["headers"]["content-type"] = "text/html" + integration_response_handler(ctx) + assert ctx.invocation_response["body"] == b"json" + + +class TestIntegrationResponseBinaryHandling: + """ + https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-payload-encodings-workflow.html + When AWS differentiates between "text" and "binary" types, it means if the MIME type of the Content-Type or Accept + header matches one of the binaryMediaTypes configured + """ + + @pytest.mark.parametrize( + "response_content_type,client_accept,binary_medias,content_handling, expected", + [ + (None, None, None, None, "utf8"), + (None, None, None, "CONVERT_TO_BINARY", "b64-decoded"), + (None, None, None, "CONVERT_TO_TEXT", "utf8"), + ("text/plain", "text/plain", ["image/png"], None, "utf8"), + ("text/plain", "text/plain", ["image/png"], "CONVERT_TO_BINARY", "b64-decoded"), + ("text/plain", "text/plain", ["image/png"], "CONVERT_TO_TEXT", "utf8"), + ("text/plain", "image/png", ["image/png"], None, "b64-decoded"), + ("text/plain", "image/png", ["image/png"], "CONVERT_TO_BINARY", "b64-decoded"), + ("text/plain", "image/png", ["image/png"], "CONVERT_TO_TEXT", "utf8"), + ("image/png", "text/plain", ["image/png"], None, "b64-encoded"), + ("image/png", "text/plain", ["image/png"], "CONVERT_TO_BINARY", None), + ("image/png", "text/plain", ["image/png"], "CONVERT_TO_TEXT", "b64-encoded"), + ("image/png", "image/png", ["image/png"], None, None), + ("image/png", "image/png", ["image/png"], "CONVERT_TO_BINARY", None), + ("image/png", "image/png", ["image/png"], "CONVERT_TO_TEXT", "b64-encoded"), + ], + ) + @pytest.mark.parametrize( + "input_data,possible_values", + [ + ( + BINARY_DATA_1, + { + "b64-encoded": b"H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSdRRKM7IL81JUUhKVcjMK0vMyUxRCA1x07VQKC4pysxLBwC4ORBXLwAAAA==", + "b64-decoded": None, + "utf8": BINARY_DATA_1_SAFE.decode(), + }, + ), + ( + b"H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSVQw0lEozsgvzUlRSEpVyMwrS8zJTFEIDXHTtVAoLinKzEtXSCotUUjJTEtLLUrNKwEAmSHtST8AAAA=", + { + "b64-encoded": b"SDRzSUFCUnM3bU1DLzB2T3p5MG9TaTB1VGsxUlNFa3NTVlF3MGxFb3pzZ3Z6VWxSU0VwVnlNd3JTOHpKVEZFSURYSFR0VkFvTGluS3pFdFhTQ290VVVqSlRFdExMVXJOS3dFQW1TSHRTVDhBQUFBPQ==", + "b64-decoded": BINARY_DATA_2, + "utf8": "H4sIABRs7mMC/0vOzy0oSi0uTk1RSEksSVQw0lEozsgvzUlRSEpVyMwrS8zJTFEIDXHTtVAoLinKzEtXSCotUUjJTEtLLUrNKwEAmSHtST8AAAA=", + }, + ), + ( + b"my text string", + { + "b64-encoded": b"bXkgdGV4dCBzdHJpbmc=", + "b64-decoded": b"\x9b+^\xc6\xdb-\xae)\xe0", + "utf8": "my text string", + }, + ), + ], + ids=["binary", "b64-encoded", "text"], + ) + def test_convert_binary( + self, + response_content_type, + client_accept, + binary_medias, + content_handling, + expected, + input_data, + possible_values, + ctx, + ): + ctx.endpoint_response["headers"]["Content-Type"] = response_content_type + ctx.invocation_request["headers"]["Accept"] = client_accept + ctx.deployment.rest_api.rest_api["binaryMediaTypes"] = binary_medias + convert = IntegrationResponseHandler.convert_body + + outcome = possible_values.get(expected, input_data) + if outcome is None: + with pytest.raises(Exception): + convert(body=input_data, context=ctx, content_handling=content_handling) + else: + converted_body = convert( + body=input_data, context=ctx, content_handling=content_handling + ) + assert converted_body == outcome diff --git a/tests/unit/services/apigateway/test_handler_method_request.py b/tests/unit/services/apigateway/test_handler_method_request.py new file mode 100644 index 0000000000000..2ff10ea76ced4 --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_method_request.py @@ -0,0 +1,282 @@ +import json + +import pytest +from werkzeug.datastructures import Headers + +from localstack.aws.api.apigateway import Method, Model, RequestValidator, RestApi +from localstack.http import Request, Response +from localstack.services.apigateway.models import MergedRestApi, RestApiDeployment +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import ( + InvocationRequest, + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.gateway_response import ( + BadRequestBodyError, + BadRequestParametersError, +) +from localstack.services.apigateway.next_gen.execute_api.handlers import MethodRequestHandler +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME + + +@pytest.fixture +def method_request_handler(): + """Returns a dummy request handler invoker for testing.""" + + def _handler_invoker(context: RestApiInvocationContext): + return MethodRequestHandler()(RestApiGatewayHandlerChain(), context, Response()) + + return _handler_invoker + + +@pytest.fixture +def dummy_context(): + context = RestApiInvocationContext(Request()) + context.deployment = RestApiDeployment( + TEST_AWS_ACCOUNT_ID, + TEST_AWS_REGION_NAME, + rest_api=MergedRestApi(rest_api=RestApi()), + ) + context.resource_method = Method() + return context + + +class TestMethodRequestHandler: + def test_no_validator(self, method_request_handler, dummy_context): + method_request_handler(dummy_context) + + def test_validator_no_validation_required(self, method_request_handler, dummy_context): + validator_id = "validatorId" + validator = RequestValidator(id=validator_id, validateRequestParameters=False) + dummy_context.deployment.rest_api.validators = {validator_id: validator} + dummy_context.resource_method = Method( + requestValidatorId=validator_id, + requestParameters={ + "method.request.querystring.foo": True, + "method.request.header.foo": True, + }, + ) + method_request_handler(dummy_context) + + def test_validator_no_params_to_validate(self, method_request_handler, dummy_context): + validator_id = "validatorId" + validator = RequestValidator(id=validator_id, validateRequestParameters=False) + dummy_context.deployment.rest_api.validators = {validator_id: validator} + dummy_context.resource_method = Method( + requestValidatorId=validator_id, + requestParameters={ + "method.request.querystring.foo": False, + "method.request.header.foo": False, + }, + ) + method_request_handler(dummy_context) + + def test_validator_request_parameters(self, method_request_handler, dummy_context): + validator_id = "validatorId" + validator = RequestValidator(id=validator_id, validateRequestParameters=True) + dummy_context.deployment.rest_api.validators = {validator_id: validator} + dummy_context.resource_method = Method( + requestValidatorId=validator_id, + requestParameters={ + "method.request.querystring.query": True, + "method.request.header.x-header": True, + "method.request.path.proxy": True, + }, + ) + + # Invocation with no valid element + dummy_context.invocation_request = InvocationRequest( + headers=Headers(), query_string_parameters={}, path="" + ) + with pytest.raises(BadRequestParametersError) as e: + method_request_handler(dummy_context) + assert e.value.message == "Missing required request parameters: [x-header, proxy, query]" + + # invocation with valid header + dummy_context.invocation_request["headers"]["x-header"] = "foobar" + with pytest.raises(BadRequestParametersError) as e: + method_request_handler(dummy_context) + assert e.value.message == "Missing required request parameters: [proxy, query]" + + # invocation with valid header and querystring + dummy_context.invocation_request["query_string_parameters"]["query"] = "result" + with pytest.raises(BadRequestParametersError) as e: + method_request_handler(dummy_context) + assert e.value.message == "Missing required request parameters: [proxy]" + + # invocation with valid request + dummy_context.invocation_request["path_parameters"] = {"proxy": "path"} + method_request_handler(dummy_context) + + def test_validator_request_body_empty_model(self, method_request_handler, dummy_context): + validator_id = "validatorId" + model_id = "model_id" + validator = RequestValidator(id=validator_id, validateRequestBody=True) + dummy_context.deployment.rest_api.validators = {validator_id: validator} + dummy_context.deployment.rest_api.models = { + model_id: Model( + id=model_id, + name=model_id, + schema=json.dumps({"$schema": "http://json-schema.org/draft-04/schema#"}), + contentType="application/json", + ) + } + dummy_context.resource_method = Method( + requestValidatorId=validator_id, requestModels={"application/json": model_id} + ) + + # Invocation with no body + dummy_context.invocation_request = InvocationRequest(body=b"{}") + method_request_handler(dummy_context) + + # Invocation with a body + dummy_context.invocation_request = InvocationRequest(body=b'{"foo": "bar"}') + method_request_handler(dummy_context) + + def test_validator_validate_body_with_schema(self, method_request_handler, dummy_context): + validator_id = "validatorId" + model_id = "model_id" + validator = RequestValidator(id=validator_id, validateRequestBody=True) + dummy_context.deployment.rest_api.validators = {validator_id: validator} + dummy_context.deployment.rest_api.models = { + model_id: Model( + id=model_id, + name=model_id, + schema=json.dumps( + {"$schema": "http://json-schema.org/draft-04/schema#", "required": ["foo"]} + ), + contentType="application/json", + ) + } + dummy_context.resource_method = Method( + requestValidatorId=validator_id, requestModels={"application/json": model_id} + ) + + # Invocation with no body + dummy_context.invocation_request = InvocationRequest(body=b"{}") + with pytest.raises(BadRequestBodyError) as e: + method_request_handler(dummy_context) + assert e.value.message == "Invalid request body" + + # Invocation with an invalid body + dummy_context.invocation_request = InvocationRequest(body=b'{"not": "foo"}') + with pytest.raises(BadRequestBodyError) as e: + method_request_handler(dummy_context) + assert e.value.message == "Invalid request body" + + # Invocation with a valid body + dummy_context.invocation_request = InvocationRequest(body=b'{"foo": "bar"}') + method_request_handler(dummy_context) + + def test_validator_validate_body_with_no_model_for_schema_name( + self, method_request_handler, dummy_context + ): + # TODO verify this is required as it might not be a possible scenario on aws + validator_id = "validatorId" + model_id = "model_id" + validator = RequestValidator(id=validator_id, validateRequestBody=True) + dummy_context.deployment.rest_api.validators = {validator_id: validator} + dummy_context.resource_method = Method( + requestValidatorId=validator_id, requestModels={"application/json": model_id} + ) + + dummy_context.invocation_request = InvocationRequest(body=b"{}") + with pytest.raises(BadRequestBodyError) as e: + method_request_handler(dummy_context) + assert e.value.message == "Invalid request body" + + def test_validate_body_with_circular_and_recursive_model( + self, method_request_handler, dummy_context + ): + validator_id = "validatorId" + model_1 = "Person" + model_2 = "House" + validator = RequestValidator(id=validator_id, validateRequestBody=True) + dummy_context.deployment.rest_api.validators = {validator_id: validator} + dummy_context.deployment.rest_api.models = { + # set up the model, Person, which references House + model_1: Model( + id=model_1, + name=model_1, + schema=json.dumps( + { + "type": "object", + "properties": { + "name": { + "type": "string", + }, + "house": { + "$ref": "House", + }, + }, + "required": ["name"], + } + ), + contentType="application/json", + ), + # set up the model House, which references the Person model, we have a circular ref, and House itself + model_2: Model( + id=model_2, + name=model_2, + schema=json.dumps( + { + "type": "object", + "required": ["houseType"], + "properties": { + "houseType": { + "type": "string", + }, + "contains": { + "type": "array", + "items": { + "$ref": "Person", + }, + }, + "houses": { + "type": "array", + "items": { + "$ref": "/House", + }, + }, + }, + } + ), + contentType="application/json", + ), + } + dummy_context.resource_method = Method( + requestValidatorId=validator_id, requestModels={"application/json": model_1} + ) + + # Invalid body + dummy_context.invocation_request = InvocationRequest( + body=json.dumps( + { + "name": "test", + "house": { # the House object is missing "houseType" + "contains": [{"name": "test"}], # the Person object has the required "name" + "houses": [{"coucou": "test"}], # the House object is missing "houseType" + }, + } + ).encode() + ) + with pytest.raises(BadRequestBodyError) as e: + method_request_handler(dummy_context) + assert e.value.message == "Invalid request body" + + # Valid body + dummy_context.invocation_request = InvocationRequest( + body=json.dumps( + { + "name": "test", + "house": { + "houseType": "random", # the House object has the required ""houseType" + "contains": [{"name": "test"}], # the Person object has the required "name" + "houses": [ + {"houseType": "test"} # the House object has the required "houseType" + ], + }, + } + ).encode() + ) + method_request_handler(dummy_context) diff --git a/tests/unit/services/apigateway/test_handler_method_response.py b/tests/unit/services/apigateway/test_handler_method_response.py new file mode 100644 index 0000000000000..c2bc8431b4e8c --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_method_response.py @@ -0,0 +1,79 @@ +import pytest +from werkzeug.datastructures.headers import Headers + +from localstack.aws.api.apigateway import Integration, IntegrationType +from localstack.http import Request +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import ( + InvocationResponse, + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.handlers import MethodResponseHandler +from localstack.services.apigateway.next_gen.execute_api.router import ApiGatewayEndpoint + + +@pytest.fixture +def ctx(): + """ + Create a context populated with what we would expect to receive from the chain at runtime. + We assume that the parser and other handler have successfully populated the context to this point. + """ + + context = RestApiInvocationContext(Request()) + context.integration = Integration(type=IntegrationType.HTTP) + context.invocation_response = InvocationResponse( + body=b"", + status_code=200, + headers=Headers(), + ) + + return context + + +@pytest.fixture +def method_response_handler(): + """Returns a dummy integration response handler invoker for testing.""" + + def _handler_invoker(context: RestApiInvocationContext, response): + return MethodResponseHandler()(RestApiGatewayHandlerChain(), context, response) + + return _handler_invoker + + +@pytest.fixture +def apigw_response(): + return ApiGatewayEndpoint.create_response(Request()) + + +class TestHandlerMethodResponse: + def test_empty(self, method_response_handler, ctx, apigw_response): + method_response_handler(ctx, apigw_response) + assert apigw_response.data == b"" + assert apigw_response.status_code == 200 + assert apigw_response.headers["Content-Type"] == "application/json" + + def test_json_body(self, method_response_handler, ctx, apigw_response): + ctx.invocation_response["body"] = b"{}" + method_response_handler(ctx, apigw_response) + assert apigw_response.data == b"{}" + assert apigw_response.status_code == 200 + assert apigw_response.headers["Content-Type"] == "application/json" + + def test_remap_headers(self, method_response_handler, ctx, apigw_response): + ctx.invocation_response["headers"] = Headers( + {"Connection": "from-common", "Authorization": "from-non-proxy"} + ) + method_response_handler(ctx, apigw_response) + assert apigw_response.headers["x-amzn-Remapped-Authorization"] == "from-non-proxy" + assert apigw_response.headers["x-amzn-Remapped-Connection"] == "from-common" + assert apigw_response.headers["Connection"] == "keep-alive" + assert not apigw_response.headers.get("Authorization") + + def test_drop_headers(self, method_response_handler, ctx, apigw_response): + ctx.integration["type"] = IntegrationType.HTTP_PROXY + ctx.invocation_response["headers"] = Headers( + {"Transfer-Encoding": "from-common", "Via": "from-http-proxy"} + ) + method_response_handler(ctx, apigw_response) + assert not apigw_response.headers.get("Transfer-Encoding") + assert not apigw_response.headers.get("Via") diff --git a/tests/unit/services/apigateway/test_handler_request.py b/tests/unit/services/apigateway/test_handler_request.py new file mode 100644 index 0000000000000..1aec3d05e32a7 --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_request.py @@ -0,0 +1,610 @@ +import pytest +from moto.apigateway.models import APIGatewayBackend, Stage, apigateway_backends +from moto.apigateway.models import RestAPI as MotoRestAPI +from werkzeug.datastructures import Headers + +from localstack.http import Request, Response +from localstack.services.apigateway.models import RestApiContainer +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import RestApiInvocationContext +from localstack.services.apigateway.next_gen.execute_api.gateway_response import ( + MissingAuthTokenError, +) +from localstack.services.apigateway.next_gen.execute_api.handlers.parse import ( + InvocationRequestParser, +) +from localstack.services.apigateway.next_gen.execute_api.handlers.resource_router import ( + InvocationRequestRouter, +) +from localstack.services.apigateway.next_gen.execute_api.helpers import ( + freeze_rest_api, + parse_trace_id, +) +from localstack.services.apigateway.next_gen.execute_api.moto_helpers import get_stage_configuration +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME + +TEST_API_ID = "testapi" +TEST_API_STAGE = "dev" + + +@pytest.fixture +def dummy_deployment(): + # because we depend on Moto here, we have to use the backend because the `MotoRestAPI` uses the backend internally + # if we only create the RestAPI outside the store, it will fail + + moto_backend: APIGatewayBackend = apigateway_backends[TEST_AWS_ACCOUNT_ID][TEST_AWS_REGION_NAME] + moto_rest_api = MotoRestAPI( + account_id=TEST_AWS_ACCOUNT_ID, + region_name=TEST_AWS_REGION_NAME, + api_id=TEST_API_ID, + name="test API", + description="", + ) + + moto_backend.apis[TEST_API_ID] = moto_rest_api + moto_rest_api.stages[TEST_API_STAGE] = Stage( + name=TEST_API_STAGE, + variables={"foo": "bar"}, + ) + + yield freeze_rest_api( + account_id=TEST_AWS_ACCOUNT_ID, + region=TEST_AWS_REGION_NAME, + moto_rest_api=moto_rest_api, + localstack_rest_api=RestApiContainer(rest_api={}), + ) + + moto_backend.reset() + + +@pytest.fixture +def get_invocation_context(): + def _create_context(request: Request) -> RestApiInvocationContext: + context = RestApiInvocationContext(request) + context.api_id = TEST_API_ID + context.stage = TEST_API_STAGE + context.account_id = TEST_AWS_ACCOUNT_ID + context.region = TEST_AWS_REGION_NAME + context.stage_configuration = get_stage_configuration( + account_id=TEST_AWS_ACCOUNT_ID, + region=TEST_AWS_REGION_NAME, + api_id=TEST_API_ID, + stage_name=TEST_API_STAGE, + ) + return context + + return _create_context + + +@pytest.fixture +def parse_handler_chain() -> RestApiGatewayHandlerChain: + """Returns a dummy chain for testing.""" + chain = RestApiGatewayHandlerChain(request_handlers=[InvocationRequestParser()]) + chain.raise_on_error = True + return chain + + +class TestParsingHandler: + def test_parse_request(self, dummy_deployment, parse_handler_chain, get_invocation_context): + host_header = f"{TEST_API_ID}.execute-api.host.com" + headers = Headers( + { + "test-header": "value1", + "test-header-multi": ["value2", "value3"], + "host": host_header, + } + ) + body = b"random-body" + request = Request( + body=body, + headers=headers, + query_string="test-param=1&test-param-2=2&test-multi=val1&test-multi=val2", + path=f"/{TEST_API_STAGE}/normal-path", + ) + context = get_invocation_context(request) + context.deployment = dummy_deployment + + parse_handler_chain.handle(context, Response()) + + assert context.request == request + assert context.account_id == TEST_AWS_ACCOUNT_ID + assert context.region == TEST_AWS_REGION_NAME + + assert context.invocation_request["http_method"] == "GET" + assert context.invocation_request["headers"] == Headers( + { + "host": host_header, + "test-header": "value1", + "test-header-multi": ["value2", "value3"], + } + ) + assert context.invocation_request["body"] == body + assert ( + context.invocation_request["path"] + == context.invocation_request["raw_path"] + == "/normal-path" + ) + + assert context.context_variables["domainName"] == host_header + assert context.context_variables["domainPrefix"] == TEST_API_ID + assert context.context_variables["path"] == f"/{TEST_API_STAGE}/normal-path" + + assert "Root=" in context.trace_id + + def test_parse_raw_path(self, dummy_deployment, parse_handler_chain, get_invocation_context): + request = Request( + "GET", + path=f"/{TEST_API_STAGE}/foo/bar/ed", + raw_path=f"/{TEST_API_STAGE}//foo%2Fbar/ed", + ) + + context = get_invocation_context(request) + context.deployment = dummy_deployment + + parse_handler_chain.handle(context, Response()) + + # depending on the usage, we need the forward slashes or not + # for example, for routing, we need the singular forward slash + # but for passing the path to a lambda proxy event for example, we need the raw path as it was in the environ + assert context.invocation_request["path"] == "/foo%2Fbar/ed" + assert context.invocation_request["raw_path"] == "//foo%2Fbar/ed" + + def test_parse_user_request_path( + self, dummy_deployment, parse_handler_chain, get_invocation_context + ): + # simulate a path request + request = Request( + "GET", + path=f"/restapis/{TEST_API_ID}/{TEST_API_STAGE}/_user_request_/foo/bar/ed", + raw_path=f"/restapis/{TEST_API_ID}/{TEST_API_STAGE}/_user_request_//foo%2Fbar/ed", + ) + + context = get_invocation_context(request) + context.deployment = dummy_deployment + + parse_handler_chain.handle(context, Response()) + + # assert that the user request prefix has been stripped off + assert context.invocation_request["path"] == "/foo%2Fbar/ed" + assert context.invocation_request["raw_path"] == "//foo%2Fbar/ed" + + def test_parse_localstack_only_path( + self, dummy_deployment, parse_handler_chain, get_invocation_context + ): + # simulate a path request + request = Request( + "GET", + path=f"/_aws/execute-api/{TEST_API_ID}/{TEST_API_STAGE}/foo/bar/ed", + raw_path=f"/_aws/execute-api/{TEST_API_ID}/{TEST_API_STAGE}//foo%2Fbar/ed", + ) + + context = get_invocation_context(request) + context.deployment = dummy_deployment + + parse_handler_chain.handle(context, Response()) + + # assert that the user request prefix has been stripped off + assert context.invocation_request["path"] == "/foo%2Fbar/ed" + assert context.invocation_request["raw_path"] == "//foo%2Fbar/ed" + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_parse_path_same_as_stage( + self, dummy_deployment, parse_handler_chain, get_invocation_context, addressing + ): + path = TEST_API_STAGE + if addressing == "host": + full_path = f"/{TEST_API_STAGE}/{path}" + elif addressing == "path_style": + full_path = f"/_aws/execute-api/{TEST_API_ID}/{TEST_API_STAGE}/{path}" + else: + full_path = f"/restapis/{TEST_API_ID}/{TEST_API_STAGE}/_user_request_/{path}" + + # simulate a path request + request = Request("GET", path=full_path) + + context = get_invocation_context(request) + context.deployment = dummy_deployment + + parse_handler_chain.handle(context, Response()) + + # assert that the user request prefix has been stripped off + assert context.invocation_request["path"] == f"/{TEST_API_STAGE}" + assert context.invocation_request["raw_path"] == f"/{TEST_API_STAGE}" + + @pytest.mark.parametrize("addressing", ["user_request", "path_style"]) + def test_cased_api_id_in_path( + self, dummy_deployment, parse_handler_chain, get_invocation_context, addressing + ): + if addressing == "path_style": + full_path = f"/_aws/execute-api/TestApi/{TEST_API_STAGE}/test" + else: + full_path = f"/restapis/{TEST_API_ID}/TestApi/_user_request_/test" + + # simulate a path request + request = Request("GET", path=full_path) + + context = get_invocation_context(request) + context.deployment = dummy_deployment + + parse_handler_chain.handle(context, Response()) + + # assert that the user request prefix has been stripped off + assert context.invocation_request["path"] == "/test" + assert context.invocation_request["raw_path"] == "/test" + + def test_trace_id_logic(self): + headers = Headers({"x-amzn-trace-id": "Root=trace;Parent=parent"}) + trace = InvocationRequestParser.populate_trace_id(headers) + assert trace == "Root=trace;Parent=parent;Sampled=1" + + no_trace_headers = Headers() + trace = InvocationRequestParser.populate_trace_id(no_trace_headers) + parsed_trace = parse_trace_id(trace) + assert len(parsed_trace["Root"]) == 35 + assert len(parsed_trace["Parent"]) == 16 + assert parsed_trace["Sampled"] == "0" + + no_parent_headers = Headers({"x-amzn-trace-id": "Root=trace"}) + trace = InvocationRequestParser.populate_trace_id(no_parent_headers) + parsed_trace = parse_trace_id(trace) + assert parsed_trace["Root"] == "trace" + assert len(parsed_trace["Parent"]) == 16 + assert parsed_trace["Sampled"] == "0" + + +class TestRoutingHandler: + @pytest.fixture + def deployment_with_routes(self, dummy_deployment): + """ + This can be represented by the following routes: + - (No method) - / + - GET - /foo + - PUT - /foo/{param} + - (No method) - /proxy + - DELETE - /proxy/{proxy+} + - (No method) - /proxy/bar + - DELETE - /proxy/bar/{param} + + Note: we have the base `/proxy` route to not have greedy matching on the base route, and the other child routes + are to assert the `{proxy+} has less priority than hardcoded routes + """ + moto_backend: APIGatewayBackend = apigateway_backends[TEST_AWS_ACCOUNT_ID][ + TEST_AWS_REGION_NAME + ] + moto_rest_api = moto_backend.apis[TEST_API_ID] + + # path: / + root_resource = moto_rest_api.default + # path: /foo + hard_coded_resource = moto_rest_api.add_child(path="foo", parent_id=root_resource.id) + # path: /foo/{param} + param_resource = moto_rest_api.add_child( + path="{param}", + parent_id=hard_coded_resource.id, + ) + # path: /proxy + hard_coded_resource_2 = moto_rest_api.add_child(path="proxy", parent_id=root_resource.id) + # path: /proxy/bar + hard_coded_resource_3 = moto_rest_api.add_child( + path="bar", parent_id=hard_coded_resource_2.id + ) + # path: /proxy/bar/{param} + param_resource_2 = moto_rest_api.add_child( + path="{param}", + parent_id=hard_coded_resource_3.id, + ) + # path: /proxy/{proxy+} + proxy_resource = moto_rest_api.add_child( + path="{proxy+}", + parent_id=hard_coded_resource_2.id, + ) + hard_coded_resource.add_method( + method_type="GET", + authorization_type="NONE", + api_key_required=False, + ) + param_resource.add_method( + method_type="PUT", + authorization_type="NONE", + api_key_required=False, + ) + proxy_resource.add_method( + method_type="DELETE", + authorization_type="NONE", + api_key_required=False, + ) + param_resource_2.add_method( + method_type="DELETE", + authorization_type="NONE", + api_key_required=False, + ) + + return freeze_rest_api( + account_id=dummy_deployment.account_id, + region=dummy_deployment.region, + moto_rest_api=moto_rest_api, + localstack_rest_api=dummy_deployment.rest_api, + ) + + @pytest.fixture + def deployment_with_any_routes(self, dummy_deployment): + """ + This can be represented by the following routes: + - (No method) - / + - GET - /foo + - ANY - /foo + - PUT - /foo/{param} + - ANY - /foo/{param} + """ + moto_backend: APIGatewayBackend = apigateway_backends[TEST_AWS_ACCOUNT_ID][ + TEST_AWS_REGION_NAME + ] + moto_rest_api = moto_backend.apis[TEST_API_ID] + + # path: / + root_resource = moto_rest_api.default + # path: /foo + hard_coded_resource = moto_rest_api.add_child(path="foo", parent_id=root_resource.id) + # path: /foo/{param} + param_resource = moto_rest_api.add_child( + path="{param}", + parent_id=hard_coded_resource.id, + ) + + hard_coded_resource.add_method( + method_type="GET", + authorization_type="NONE", + api_key_required=False, + ) + hard_coded_resource.add_method( + method_type="ANY", + authorization_type="NONE", + api_key_required=False, + ) + # we test different order of setting the Method, to make sure ANY is always matched last + # because this will influence the original order of the Werkzeug Rules in the Map + # Because we only return the `Resource` as the endpoint, we always fetch manually the right + # `resourceMethod` from the request method. + param_resource.add_method( + method_type="ANY", + authorization_type="NONE", + api_key_required=False, + ) + param_resource.add_method( + method_type="PUT", + authorization_type="NONE", + api_key_required=False, + ) + + return freeze_rest_api( + account_id=dummy_deployment.account_id, + region=dummy_deployment.region, + moto_rest_api=moto_rest_api, + localstack_rest_api=dummy_deployment.rest_api, + ) + + @staticmethod + def get_path_from_addressing(path: str, addressing: str) -> str: + if addressing == "host": + return f"/{TEST_API_STAGE}{path}" + elif addressing == "user_request": + return f"/restapis/{TEST_API_ID}/{TEST_API_STAGE}/_user_request_/{path}" + else: + # this new style allows following the regular order in an easier way, stage is always before path + return f"/_aws/execute-api/{TEST_API_ID}/{TEST_API_STAGE}{path}" + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_route_request_no_param( + self, deployment_with_routes, parse_handler_chain, get_invocation_context, addressing + ): + request = Request( + "GET", + path=self.get_path_from_addressing("/foo", addressing), + ) + + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + handler = InvocationRequestRouter() + handler(parse_handler_chain, context, Response()) + + assert context.resource["pathPart"] == "foo" + assert context.resource["path"] == "/foo" + assert context.resource["resourceMethods"]["GET"] + # TODO: maybe assert more regarding the data inside Resource Methods, but we don't use it yet + + assert context.resource_method == context.resource["resourceMethods"]["GET"] + assert context.invocation_request["path_parameters"] == {} + assert context.stage_variables == {"foo": "bar"} + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_route_request_with_path_parameter( + self, deployment_with_routes, parse_handler_chain, get_invocation_context, addressing + ): + request = Request( + "PUT", + path=self.get_path_from_addressing("/foo/random-value", addressing), + ) + + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + handler = InvocationRequestRouter() + handler(parse_handler_chain, context, Response()) + + assert context.resource["pathPart"] == "{param}" + assert context.resource["path"] == "/foo/{param}" + # TODO: maybe assert more regarding the data inside Resource Methods, but we don't use it yet + assert context.resource_method == context.resource["resourceMethods"]["PUT"] + + assert context.invocation_request["path_parameters"] == {"param": "random-value"} + assert context.context_variables["resourcePath"] == "/foo/{param}" + assert context.context_variables["resourceId"] == context.resource["id"] + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_route_request_with_greedy_parameter( + self, deployment_with_routes, parse_handler_chain, get_invocation_context, addressing + ): + # assert that a path which does not contain `/proxy/bar` will be routed to {proxy+} + request = Request( + "DELETE", + path=self.get_path_from_addressing("/proxy/this/is/a/proxy/req2%Fuest", addressing), + ) + router_handler = InvocationRequestRouter() + + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + router_handler(parse_handler_chain, context, Response()) + + assert context.resource["pathPart"] == "{proxy+}" + assert context.resource["path"] == "/proxy/{proxy+}" + # TODO: maybe assert more regarding the data inside Resource Methods, but we don't use it yet + assert context.resource_method == context.resource["resourceMethods"]["DELETE"] + + assert context.invocation_request["path_parameters"] == { + "proxy": "this/is/a/proxy/req2%Fuest" + } + + # assert that a path which does contain `/proxy/bar` will be routed to `/proxy/bar/{param}` if it has only + # one resource after `bar` + request = Request( + "DELETE", + path=self.get_path_from_addressing("/proxy/bar/foobar", addressing), + ) + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + router_handler(parse_handler_chain, context, Response()) + + assert context.resource["path"] == "/proxy/bar/{param}" + assert context.invocation_request["path_parameters"] == {"param": "foobar"} + + # assert that a path which does contain `/proxy/bar` will be routed to {proxy+} if it does not conform to + # `/proxy/bar/{param}` + # TODO: validate this with AWS + request = Request( + "DELETE", + path=self.get_path_from_addressing("/proxy/test2/is/a/proxy/req2%Fuest", addressing), + ) + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + router_handler(parse_handler_chain, context, Response()) + + assert context.resource["path"] == "/proxy/{proxy+}" + assert context.invocation_request["path_parameters"] == { + "proxy": "test2/is/a/proxy/req2%Fuest" + } + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_route_request_no_match_on_path( + self, deployment_with_routes, parse_handler_chain, get_invocation_context, addressing + ): + request = Request( + "GET", + path=self.get_path_from_addressing("/wrong-test", addressing), + ) + + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + # manually invoking the handler here as exceptions would be swallowed by the chain + handler = InvocationRequestRouter() + with pytest.raises(MissingAuthTokenError): + handler(parse_handler_chain, context, Response()) + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_route_request_no_match_on_method( + self, deployment_with_routes, parse_handler_chain, get_invocation_context, addressing + ): + request = Request( + "POST", + path=self.get_path_from_addressing("/test", addressing), + ) + + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + # manually invoking the handler here as exceptions would be swallowed by the chain + handler = InvocationRequestRouter() + with pytest.raises(MissingAuthTokenError): + handler(parse_handler_chain, context, Response()) + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_route_request_with_double_slash_and_trailing_and_encoded( + self, deployment_with_routes, parse_handler_chain, get_invocation_context, addressing + ): + request = Request( + "PUT", + path=self.get_path_from_addressing("/foo/foo%2Fbar/", addressing), + raw_path=self.get_path_from_addressing("//foo/foo%2Fbar/", addressing), + ) + + context = get_invocation_context(request) + context.deployment = deployment_with_routes + + parse_handler_chain.handle(context, Response()) + handler = InvocationRequestRouter() + handler(parse_handler_chain, context, Response()) + + assert context.resource["path"] == "/foo/{param}" + assert context.invocation_request["path_parameters"] == {"param": "foo%2Fbar"} + + @pytest.mark.parametrize("addressing", ["host", "user_request", "path_style"]) + def test_route_request_any_is_last( + self, deployment_with_any_routes, parse_handler_chain, get_invocation_context, addressing + ): + handler = InvocationRequestRouter() + + def handle(_request: Request) -> RestApiInvocationContext: + _context = get_invocation_context(_request) + _context.deployment = deployment_with_any_routes + parse_handler_chain.handle(_context, Response()) + handler(parse_handler_chain, _context, Response()) + return _context + + request = Request( + "GET", + path=self.get_path_from_addressing("/foo", addressing), + ) + context = handle(request) + + assert context.resource["path"] == "/foo" + assert context.resource["resourceMethods"]["GET"] + + request = Request( + "DELETE", + path=self.get_path_from_addressing("/foo", addressing), + ) + context = handle(request) + + assert context.resource["path"] == "/foo" + assert context.resource["resourceMethods"]["ANY"] + + request = Request( + "PUT", + path=self.get_path_from_addressing("/foo/random-value", addressing), + ) + + context = handle(request) + + assert context.resource["path"] == "/foo/{param}" + assert context.resource_method == context.resource["resourceMethods"]["PUT"] + + request = Request( + "GET", + path=self.get_path_from_addressing("/foo/random-value", addressing), + ) + + context = handle(request) + + assert context.resource["path"] == "/foo/{param}" + assert context.resource_method == context.resource["resourceMethods"]["ANY"] diff --git a/tests/unit/services/apigateway/test_handler_response_enricher.py b/tests/unit/services/apigateway/test_handler_response_enricher.py new file mode 100644 index 0000000000000..58ff5f1b0ef38 --- /dev/null +++ b/tests/unit/services/apigateway/test_handler_response_enricher.py @@ -0,0 +1,86 @@ +import pytest + +from localstack.aws.api.apigateway import Integration, IntegrationType +from localstack.http import Request +from localstack.services.apigateway.next_gen.execute_api.api import RestApiGatewayHandlerChain +from localstack.services.apigateway.next_gen.execute_api.context import ( + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.handlers import ( + InvocationResponseEnricher, +) +from localstack.services.apigateway.next_gen.execute_api.router import ApiGatewayEndpoint +from localstack.services.apigateway.next_gen.execute_api.variables import ( + ContextVariables, + GatewayResponseContextVarsError, +) + +TEST_API_ID = "test-api" +TEST_REQUEST_ID = "request-id" + + +@pytest.fixture +def ctx(): + """ + Create a context populated with what we would expect to receive from the chain at runtime. + We assume that the parser and other handler have successfully populated the context to this point. + """ + + context = RestApiInvocationContext(Request()) + context.context_variables = ContextVariables(requestId=TEST_REQUEST_ID) + context.integration = Integration(type=IntegrationType.HTTP) + + return context + + +@pytest.fixture +def response_enricher_handler(): + """Returns a dummy integration response handler invoker for testing.""" + + def _handler_invoker(context: RestApiInvocationContext, response): + return InvocationResponseEnricher()(RestApiGatewayHandlerChain(), context, response) + + return _handler_invoker + + +@pytest.fixture +def apigw_response(): + return ApiGatewayEndpoint.create_response(Request()) + + +class TestResponseEnricherHandler: + def test_empty_response(self, ctx, response_enricher_handler, apigw_response): + response_enricher_handler(ctx, apigw_response) + assert apigw_response.headers.get("Content-Type") == "application/json" + assert apigw_response.headers.get("Connection") == "keep-alive" + assert apigw_response.headers.get("x-amzn-RequestId") == TEST_REQUEST_ID + assert apigw_response.headers.get("x-amz-apigw-id") is not None + assert apigw_response.headers.get("X-Amzn-Trace-Id") is not None + + def test_http_proxy_no_trace_id(self, ctx, response_enricher_handler, apigw_response): + ctx.integration["type"] = IntegrationType.HTTP_PROXY + response_enricher_handler(ctx, apigw_response) + assert apigw_response.headers.get("Content-Type") == "application/json" + assert apigw_response.headers.get("Connection") == "keep-alive" + assert apigw_response.headers.get("x-amzn-RequestId") == TEST_REQUEST_ID + assert apigw_response.headers.get("x-amz-apigw-id") is not None + assert apigw_response.headers.get("X-Amzn-Trace-Id") is None + + def test_error_no_trace_id(self, ctx, response_enricher_handler, apigw_response): + ctx.context_variables["error"] = GatewayResponseContextVarsError(message="error") + response_enricher_handler(ctx, apigw_response) + assert apigw_response.headers.get("Content-Type") == "application/json" + assert apigw_response.headers.get("Connection") == "keep-alive" + assert apigw_response.headers.get("x-amzn-RequestId") == TEST_REQUEST_ID + assert apigw_response.headers.get("x-amz-apigw-id") is not None + assert apigw_response.headers.get("X-Amzn-Trace-Id") is None + + def test_error_at_routing(self, ctx, response_enricher_handler, apigw_response): + # in the case where we fail early, in routing for example, we do not have the integration in the context yet + ctx.integration = None + response_enricher_handler(ctx, apigw_response) + assert apigw_response.headers.get("Content-Type") == "application/json" + assert apigw_response.headers.get("Connection") == "keep-alive" + assert apigw_response.headers.get("x-amzn-RequestId") == TEST_REQUEST_ID + assert apigw_response.headers.get("x-amz-apigw-id") is not None + assert apigw_response.headers.get("X-Amzn-Trace-Id") is None diff --git a/tests/unit/services/apigateway/test_helpers.py b/tests/unit/services/apigateway/test_helpers.py new file mode 100644 index 0000000000000..7016279b1c8b3 --- /dev/null +++ b/tests/unit/services/apigateway/test_helpers.py @@ -0,0 +1,37 @@ +import datetime +import time + +import pytest + +from localstack.services.apigateway.next_gen.execute_api.helpers import ( + generate_trace_id, + parse_trace_id, +) + + +def test_generate_trace_id(): + # See https://docs.aws.amazon.com/xray/latest/devguide/xray-api-sendingdata.html#xray-api-traceids for the format + trace_id = generate_trace_id() + version, hex_time, unique_id = trace_id.split("-") + assert version == "1" + trace_time = datetime.datetime.fromtimestamp(int(hex_time, 16), tz=datetime.UTC) + now = time.time() + assert now - 10 <= trace_time.timestamp() <= now + assert len(unique_id) == 24 + + +@pytest.mark.parametrize( + "trace,expected", + [ + ( + "Root=trace;Parent=parent;Sampled=0;lineage=lineage:0", + {"Root": "trace", "Parent": "parent", "Sampled": "0", "Lineage": "lineage:0"}, + ), + ("Root=trace", {"Root": "trace"}), + ("Root=trace;Test", {"Root": "trace"}), + ("Root=trace;Test=", {"Root": "trace", "Test": ""}), + ("Root=trace;Test=value;", {"Root": "trace", "Test": "value"}), + ], +) +def test_parse_trace_id(trace, expected): + assert parse_trace_id(trace) == expected diff --git a/tests/unit/services/apigateway/test_mock_integration.py b/tests/unit/services/apigateway/test_mock_integration.py new file mode 100644 index 0000000000000..2fd1799d1c594 --- /dev/null +++ b/tests/unit/services/apigateway/test_mock_integration.py @@ -0,0 +1,93 @@ +import pytest + +from localstack.http import Request +from localstack.services.apigateway.next_gen.execute_api.context import ( + IntegrationRequest, + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.gateway_response import InternalServerError +from localstack.services.apigateway.next_gen.execute_api.integrations.mock import ( + RestApiMockIntegration, +) +from localstack.utils.strings import to_bytes + + +@pytest.fixture +def create_default_context(): + def _create_context(body: str) -> RestApiInvocationContext: + context = RestApiInvocationContext(request=Request()) + context.integration_request = IntegrationRequest(body=to_bytes(body)) + return context + + return _create_context + + +class TestMockIntegration: + def test_mock_integration(self, create_default_context): + mock_integration = RestApiMockIntegration() + + ctx = create_default_context(body='{"statusCode": 200}') + response = mock_integration.invoke(ctx) + assert response["status_code"] == 200 + + # It needs to be an integer + ctx = create_default_context(body='{"statusCode": "200"}') + with pytest.raises(InternalServerError) as exc_info: + mock_integration.invoke(ctx) + assert exc_info.match("Internal server error") + + # Any integer will do + ctx = create_default_context(body='{"statusCode": 0}') + response = mock_integration.invoke(ctx) + assert response["status_code"] == 0 + + # Literally any + ctx = create_default_context(body='{"statusCode": -1000}') + response = mock_integration.invoke(ctx) + assert response["status_code"] == -1000 + + # Malformed Json + ctx = create_default_context(body='{"statusCode": 200') + with pytest.raises(InternalServerError) as exc_info: + mock_integration.invoke(ctx) + assert exc_info.match("Internal server error") + + def test_custom_parser(self, create_default_context): + mock_integration = RestApiMockIntegration() + + valid_templates = [ + "{ statusCode: 200 }", # this is what the CDK creates when configuring CORS for rest apis + "{statusCode: 200,super{ f}oo: [ba r]}", + "{statusCode: 200, \"value\": 'goog'}", + "{statusCode: 200, foo}: [ba r]}", + "{statusCode: 200, foo'}: [ba r]}", + "{statusCode: 200, }foo: [ba r]}", + "{statusCode: 200, }foo: ''}", + '{statusCode: 200, " ": " "}', + '{statusCode: 200, "": ""}', + "{'statusCode': 200, '': ''}", + '{"statusCode": 200, "": ""}', + '{"statusCode": 200 , }', + '{"statusCode": 200 ,, }', # Because?? :cry-bear: + '{"statusCode": 200 , null: null }', + ] + invalid_templates = [ + "{\"statusCode': 200 }", + "{'statusCode\": 200 }", + "{'statusCode: 200 }", + "statusCode: 200", + "{statusCode: 200, {foo: [ba r]}", + # This test fails as we do not support nested objects + # "{statusCode: 200, what:{}foo: [ba r]}}" + ] + + for valid_template in valid_templates: + ctx = create_default_context(body=valid_template) + response = mock_integration.invoke(ctx) + assert response["status_code"] == 200, valid_template + + for invalid_template in invalid_templates: + ctx = create_default_context(body=invalid_template) + with pytest.raises(InternalServerError) as exc_info: + mock_integration.invoke(ctx) + assert exc_info.match("Internal server error") diff --git a/tests/unit/services/apigateway/test_parameters_mapping.py b/tests/unit/services/apigateway/test_parameters_mapping.py new file mode 100644 index 0000000000000..a8645a7acdc2a --- /dev/null +++ b/tests/unit/services/apigateway/test_parameters_mapping.py @@ -0,0 +1,692 @@ +import json +from http import HTTPMethod + +import pytest +from rolo import Request +from werkzeug.datastructures import Headers + +from localstack.services.apigateway.next_gen.execute_api.context import ( + EndpointResponse, + InvocationRequest, + RestApiInvocationContext, +) +from localstack.services.apigateway.next_gen.execute_api.gateway_response import ( + Default4xxError, + Default5xxError, +) +from localstack.services.apigateway.next_gen.execute_api.handlers import InvocationRequestParser +from localstack.services.apigateway.next_gen.execute_api.parameters_mapping import ParametersMapper +from localstack.services.apigateway.next_gen.execute_api.variables import ( + ContextVariables, + ContextVarsIdentity, +) +from localstack.utils.strings import to_bytes + +TEST_API_ID = "test-api" +TEST_API_STAGE = "stage" +TEST_IDENTITY_API_KEY = "random-api-key" +TEST_USER_AGENT = "test/user-agent" + + +@pytest.fixture +def default_context_variables() -> ContextVariables: + return ContextVariables( + resourceId="resource-id", + apiId=TEST_API_ID, + identity=ContextVarsIdentity( + apiKey=TEST_IDENTITY_API_KEY, + userAgent=TEST_USER_AGENT, + ), + ) + + +@pytest.fixture +def default_invocation_request() -> InvocationRequest: + context = RestApiInvocationContext( + Request( + method=HTTPMethod.POST, + headers=Headers({"header_value": "test-header-value"}), + path=f"{TEST_API_STAGE}/test/test-path-value", + query_string="qs_value=test-qs-value", + ) + ) + # Context populated by parser handler before creating the invocation request + context.stage = TEST_API_STAGE + context.api_id = TEST_API_ID + + invocation_request = InvocationRequestParser().create_invocation_request(context) + invocation_request["path_parameters"] = {"path_value": "test-path-value"} + return invocation_request + + +@pytest.fixture +def default_endpoint_response() -> EndpointResponse: + return EndpointResponse( + body=b"", + headers=Headers(), + status_code=200, + ) + + +class TestApigatewayRequestParametersMapping: + def test_default_request_mapping(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.test": "method.request.querystring.qs_value", + "integration.request.querystring.test": "method.request.path.path_value", + "integration.request.path.test": "method.request.header.header_value", + } + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": { + "test": "test-qs-value", + }, + "path": {"test": "test-header-value"}, + "querystring": {"test": "test-path-value"}, + } + + def test_context_variables(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.api_id": "context.apiId", + } + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": { + "api_id": TEST_API_ID, + }, + "path": {}, + "querystring": {}, + } + + def test_nested_context_var(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.my_api_key": "context.identity.apiKey", + "integration.request.querystring.userAgent": "context.identity.userAgent", + } + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": { + "my_api_key": TEST_IDENTITY_API_KEY, + }, + "path": {}, + "querystring": {"userAgent": TEST_USER_AGENT}, + } + + def test_stage_variable_mapping(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.my_stage_var": "stageVariables.test_var", + } + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={"test_var": "a stage variable"}, + ) + + assert mapping == { + "header": { + "my_stage_var": "a stage variable", + }, + "path": {}, + "querystring": {}, + } + + def test_body_mapping(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.body_value": "method.request.body", + } + default_invocation_request["body"] = b"" + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": { + "body_value": "", + }, + "path": {}, + "querystring": {}, + } + + def test_body_mapping_empty(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.body_value": "method.request.body", + } + default_invocation_request["body"] = b"" + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + # this was validated against AWS + # it does not forward the body even with passthrough, but the content of `method.request.body` is `{}` + # if the body is empty + assert mapping == { + "header": { + "body_value": "{}", + }, + "path": {}, + "querystring": {}, + } + + def test_json_body_mapping(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.body_value": "method.request.body.petstore.pets[0].name", + } + + default_invocation_request["body"] = to_bytes( + json.dumps( + { + "petstore": { + "pets": [ + {"name": "nested pet name value", "type": "Dog"}, + {"name": "second nested value", "type": "Cat"}, + ] + } + } + ) + ) + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": { + "body_value": "nested pet name value", + }, + "path": {}, + "querystring": {}, + } + + def test_json_body_mapping_not_found( + self, default_invocation_request, default_context_variables + ): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.body_value": "method.request.body.petstore.pets[0].name", + } + + default_invocation_request["body"] = to_bytes( + json.dumps( + { + "petstore": { + "pets": { + "name": "nested pet name value", + "type": "Dog", + } + } + } + ) + ) + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": {}, + "path": {}, + "querystring": {}, + } + + def test_invalid_json_body_mapping(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + # the only way AWS raises wrong JSON is if the body starts with `{` + default_invocation_request["body"] = b"\n{wrongjson" + + request_parameters = { + "integration.request.header.body_value": "method.request.body.petstore.pets[0].name", + } + + with pytest.raises(Default4xxError) as e: + mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + assert e.value.status_code == 400 + assert e.value.message == "Invalid JSON in request body" + + request_parameters = { + "integration.request.header.body_value": "method.request.body", + } + + # this is weird, but even if `method.request.body` should not expect JSON and can accept any string (as a + # string is valid JSON per definition), it fails if it's malformed JSON. + # maybe this is because the AWS console sends `Content-Type: application/json` by default? + # TODO: write more AWS validated tests about this + with pytest.raises(Default4xxError) as e: + mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert e.value.status_code == 400 + assert e.value.message == "Invalid JSON in request body" + + def test_multi_headers_mapping(self, default_invocation_request, default_context_variables): + # this behavior has been tested manually with the AWS console. TODO: write an AWS validated test + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.test": "method.request.header.testMultiHeader", + "integration.request.header.test_multi": "method.request.multivalueheader.testMultiHeader", + "integration.request.header.test_multi_solo": "method.request.multivalueheader.testHeader", + } + + headers = {"testMultiHeader": ["value1", "value2"], "testHeader": "value"} + + default_invocation_request["headers"] = Headers(headers) + # this is how AWS maps to the variables passed to proxy integration, it only picks the first of the multi values + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + # it seems the mapping picks the last value of the multivalues, but the `headers` part of the context picks the + # first one + assert mapping == { + "header": { + "test": "value2", + "test_multi": "value1,value2", + "test_multi_solo": "value", + }, + "path": {}, + "querystring": {}, + } + + def test_multi_qs_mapping(self, default_invocation_request, default_context_variables): + # this behavior has been tested manually with the AWS console. TODO: write an AWS validated test + mapper = ParametersMapper() + request_parameters = { + "integration.request.querystring.test": "method.request.querystring.testMultiQuery", + "integration.request.querystring.test_multi": "method.request.multivaluequerystring.testMultiQuery", + "integration.request.querystring.test_multi_solo": "method.request.multivaluequerystring.testQuery", + } + + default_invocation_request["query_string_parameters"] = { + "testMultiQuery": "value1", + "testQuery": "value", + } + default_invocation_request["multi_value_query_string_parameters"] = { + "testMultiQuery": ["value1", "value2"], + "testQuery": ["value"], + } + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + # it seems the mapping picks the last value of the multivalues, but the `headers` part of the context picks the + # first one + assert mapping == { + "header": {}, + "path": {}, + "querystring": { + "test": "value2", + "test_multi": ["value1", "value2"], + "test_multi_solo": "value", + }, + } + + def test_default_request_mapping_missing_request_values(self, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.test": "method.request.querystring.qs_value", + "integration.request.querystring.test": "method.request.path.path_value", + "integration.request.path.test": "method.request.header.header_value", + } + + request = InvocationRequest( + headers=Headers(), + query_string_parameters={}, + multi_value_query_string_parameters={}, + path_parameters={}, + ) + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": {}, + "path": {}, + "querystring": {}, + } + + def test_request_mapping_casing(self, default_invocation_request, default_context_variables): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.test": "method.request.querystring.QS_value", + "integration.request.querystring.test": "method.request.path.PATH_value", + "integration.request.path.test": "method.request.header.HEADER_value", + } + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": {}, + "path": {}, + "querystring": {}, + } + + def test_default_values_headers_request_mapping_override( + self, default_invocation_request, default_context_variables + ): + mapper = ParametersMapper() + request_parameters = { + "integration.request.header.Content-Type": "method.request.header.header_value", + "integration.request.header.accept": "method.request.header.header_value", + } + default_invocation_request["headers"].add("Content-Type", "application/json") + default_invocation_request["headers"].add("Accept", "application/json") + + mapping = mapper.map_integration_request( + request_parameters=request_parameters, + invocation_request=default_invocation_request, + context_variables=default_context_variables, + stage_variables={}, + ) + assert mapping == { + "header": { + "Content-Type": "test-header-value", + "accept": "test-header-value", + }, + "path": {}, + "querystring": {}, + } + + +class TestApigatewayResponseParametersMapping: + """ + Only `method.response` headers can be mapping from `responseParameters`. + https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html#mapping-response-parameters + """ + + def test_default_request_mapping( + self, default_invocation_request, default_endpoint_response, default_context_variables + ): + # as the scope is more limited for ResponseParameters, we test header fetching, context variables and + # stage variables in the same test, as it re-uses the same logic as the TestApigatewayRequestParametersMapping + mapper = ParametersMapper() + response_parameters = { + "method.response.header.method_test": "integration.response.header.test", + "method.response.header.api_id": "context.apiId", + "method.response.header.my_api_key": "context.identity.apiKey", + "method.response.header.my_stage_var": "stageVariables.test_var", + # missing value in the Response + "method.response.header.missing_test": "integration.response.header.missingtest", + } + + default_endpoint_response["headers"] = Headers({"test": "value"}) + + mapping = mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={"test_var": "a stage variable"}, + ) + + assert mapping == { + "header": { + "method_test": "value", + "api_id": TEST_API_ID, + "my_api_key": TEST_IDENTITY_API_KEY, + "my_stage_var": "a stage variable", + }, + } + + def test_body_mapping( + self, default_invocation_request, default_endpoint_response, default_context_variables + ): + mapper = ParametersMapper() + response_parameters = { + "method.response.header.body_value": "integration.response.body", + } + + default_endpoint_response["body"] = b"" + + mapping = mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": {"body_value": ""}, + } + + def test_body_mapping_empty( + self, default_invocation_request, default_endpoint_response, default_context_variables + ): + mapper = ParametersMapper() + response_parameters = { + "method.response.header.body_value": "integration.response.body", + } + + mapping = mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + + # this was validated against AWS + assert mapping == { + "header": {"body_value": "{}"}, + } + + def test_json_body_mapping( + self, default_invocation_request, default_endpoint_response, default_context_variables + ): + mapper = ParametersMapper() + response_parameters = { + "method.response.header.body_value": "integration.response.body.petstore.pets[0].name", + } + + default_endpoint_response["body"] = to_bytes( + json.dumps( + { + "petstore": { + "pets": [ + {"name": "nested pet name value", "type": "Dog"}, + {"name": "second nested value", "type": "Cat"}, + ] + } + } + ) + ) + + mapping = mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": {"body_value": "nested pet name value"}, + } + + def test_json_body_mapping_not_found( + self, default_invocation_request, default_context_variables, default_endpoint_response + ): + mapper = ParametersMapper() + response_parameters = { + "method.response.header.body_value": "integration.response.body.petstore.pets[0].name", + } + + default_endpoint_response["body"] = to_bytes( + json.dumps( + { + "petstore": { + "pets": { + "name": "nested pet name value", + "type": "Dog", + } + } + } + ) + ) + + mapping = mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": {}, + } + + def test_invalid_json_body_mapping( + self, default_invocation_request, default_endpoint_response, default_context_variables + ): + mapper = ParametersMapper() + # the only way AWS raises wrong JSON is if the body starts with `{` + default_endpoint_response["body"] = b"\n{wrongjson" + + response_parameters = { + "method.response.header.body_value": "integration.response.body.petstore.pets[0].name", + } + + with pytest.raises(Default5xxError) as e: + mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + assert e.value.status_code == 500 + assert e.value.message == "Internal server error" + + response_parameters = { + "method.response.header.body_value": "integration.response.body", + } + + with pytest.raises(Default5xxError) as e: + mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert e.value.status_code == 500 + assert e.value.message == "Internal server error" + + def test_multi_headers_mapping( + self, default_invocation_request, default_endpoint_response, default_context_variables + ): + mapper = ParametersMapper() + response_parameters = { + "method.response.header.test": "integration.response.header.testMultiHeader", + "method.response.header.test_multi": "integration.response.multivalueheader.testMultiHeader", + "method.response.header.test_multi_solo": "integration.response.multivalueheader.testHeader", + } + default_endpoint_response["headers"] = Headers( + { + "testMultiHeader": ["value1", "value2"], + "testHeader": "value", + } + ) + + mapping = mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + + # it seems the mapping picks the last value of the multivalues, but the `headers` part of the context picks the + # first one + assert mapping == { + "header": {"test": "value2", "test_multi": "value1,value2", "test_multi_solo": "value"}, + } + + def test_response_mapping_casing( + self, default_invocation_request, default_endpoint_response, default_context_variables + ): + mapper = ParametersMapper() + response_parameters = { + "method.response.header.test": "integration.response.header.test", + "method.response.header.test2": "integration.response.header.TEST2", + "method.response.header.testmulti": "integration.response.multivalueheader.testmulti", + } + default_endpoint_response["headers"] = Headers( + { + "Test": "test", + "test2": "test", + "TestMulti": ["test", "test2"], + } + ) + + mapping = mapper.map_integration_response( + response_parameters=response_parameters, + integration_response=default_endpoint_response, + context_variables=default_context_variables, + stage_variables={}, + ) + + assert mapping == { + "header": {}, + } diff --git a/tests/unit/services/apigateway/test_router.py b/tests/unit/services/apigateway/test_router.py new file mode 100644 index 0000000000000..cd65bcfb71e52 --- /dev/null +++ b/tests/unit/services/apigateway/test_router.py @@ -0,0 +1,19 @@ +from localstack.http import Request +from localstack.services.apigateway.next_gen.execute_api.router import ApiGatewayEndpoint + + +class TestApiGatewayEndpoint: + def test_create_response_connection(self): + no_connection_header = ApiGatewayEndpoint.create_response(Request()) + assert no_connection_header.headers.get("Connection") == "keep-alive" + + close_header = ApiGatewayEndpoint.create_response(Request(headers={"Connection": "close"})) + assert close_header.headers.get("Connection") is None + + keep_alive_header = ApiGatewayEndpoint.create_response( + Request(headers={"Connection": "keep-alive"}) + ) + assert keep_alive_header.headers.get("Connection") == "keep-alive" + + unknown_header = ApiGatewayEndpoint.create_response(Request(headers={"Connection": "foo"})) + assert unknown_header.headers.get("Connection") == "keep-alive" diff --git a/tests/unit/services/apigateway/test_template_mapping.py b/tests/unit/services/apigateway/test_template_mapping.py new file mode 100644 index 0000000000000..4c6c6a4a175ab --- /dev/null +++ b/tests/unit/services/apigateway/test_template_mapping.py @@ -0,0 +1,427 @@ +import json +from json import JSONDecodeError + +import pytest +import xmltodict + +from localstack.constants import APPLICATION_JSON, APPLICATION_XML +from localstack.services.apigateway.next_gen.execute_api.template_mapping import ( + ApiGatewayVtlTemplate, + MappingTemplateInput, + MappingTemplateParams, + MappingTemplateVariables, + VelocityUtilApiGateway, +) +from localstack.services.apigateway.next_gen.execute_api.variables import ( + ContextVariableOverrides, + ContextVariables, + ContextVarsAuthorizer, + ContextVarsIdentity, + ContextVarsRequestOverride, + ContextVarsResponseOverride, +) + + +class TestVelocityUtilApiGatewayFunctions: + def test_render_template_values(self): + util = VelocityUtilApiGateway() + + encoded = util.urlEncode("x=a+b") + assert encoded == "x%3Da%2Bb" + + decoded = util.urlDecode("x=a+b") + assert decoded == "x=a b" + + escape_tests = ( + ("it's", "it's"), + ("0010", "0010"), + ("true", "true"), + ("True", "True"), + ("1.021", "1.021"), + ('""', '\\"\\"'), + ('"""', '\\"\\"\\"'), + ('{"foo": 123}', '{\\"foo\\": 123}'), + ('{"foo"": 123}', '{\\"foo\\"\\": 123}'), + (1, "1"), + (None, "null"), + ) + for string, expected in escape_tests: + escaped = util.escapeJavaScript(string) + assert escaped == expected + + def test_parse_json(self): + util = VelocityUtilApiGateway() + + # write table tests for the following input + a = {"array": "[1,2,3]"} + obj = util.parseJson(a["array"]) + assert obj[0] == 1 + + o = {"object": '{"key1":"var1","key2":{"arr":[1,2,3]}}'} + obj = util.parseJson(o["object"]) + assert obj.key2.arr[0] == 1 + + s = '"string"' + obj = util.parseJson(s) + assert obj == "string" + + n = {"number": "1"} + obj = util.parseJson(n["number"]) + assert obj == 1 + + b = {"boolean": "true"} + obj = util.parseJson(b["boolean"]) + assert obj is True + + z = {"zero_length_array": "[]"} + obj = util.parseJson(z["zero_length_array"]) + assert obj == [] + + +class TestApiGatewayVtlTemplate: + def test_apply_template(self): + variables = MappingTemplateVariables( + input=MappingTemplateInput(body='{"action":"$default","message":"foobar"}') + ) + + template = "$util.escapeJavaScript($input.json('$.message'))" + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert '\\"foobar\\"' == rendered_request + + def test_apply_template_no_json_payload(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body='"#foobar123"')) + + template = "$input.json('$.message')" + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == '""' + + def test_apply_template_no_json_payload_non_quoted(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body="not json")) + + template = "$input.json('$.message')" + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == '""' + + def test_apply_template_no_json_payload_nested(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body='"#foobar123"')) + + template = "$input.json('$.message').testAccess" + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == "" + + def test_apply_template_no_json_payload_escaped(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body='"#foobar123"')) + + template = "$util.escapeJavaScript($input.json('$.message'))" + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == '\\"\\"' + + @pytest.mark.parametrize("format", [APPLICATION_JSON, APPLICATION_XML]) + def test_render_custom_template(self, format): + variables = MappingTemplateVariables( + input=MappingTemplateInput( + body='{"spam": "eggs"}', + params=MappingTemplateParams( + path={"proxy": "path"}, + querystring={"baz": "test"}, + header={"content-type": format, "accept": format}, + ), + ), + context=ContextVariables( + httpMethod="POST", + stage="local", + authorizer=ContextVarsAuthorizer(principalId="12233"), + identity=ContextVarsIdentity(accountId="00000", apiKey="11111"), + resourcePath="/{proxy}", + ), + stageVariables={"stageVariable1": "value1", "stageVariable2": "value2"}, + ) + context_overrides = ContextVariableOverrides( + requestOverride=ContextVarsRequestOverride(header={}, path={}, querystring={}), + responseOverride=ContextVarsResponseOverride(header={}, status=0), + ) + + template = TEMPLATE_JSON if format == APPLICATION_JSON else TEMPLATE_XML + template += REQUEST_OVERRIDE + + rendered_request, context_variable = ApiGatewayVtlTemplate().render_request( + template=template, variables=variables, context_overrides=context_overrides + ) + request_override = context_variable["requestOverride"] + if format == APPLICATION_JSON: + rendered_request = json.loads(rendered_request) + assert rendered_request.get("body") == {"spam": "eggs"} + assert rendered_request.get("method") == "POST" + assert rendered_request.get("principalId") == "12233" + else: + rendered_request = xmltodict.parse(rendered_request).get("root", {}) + # TODO Verify that those difference between xml and json are expected + assert rendered_request.get("body") == '{"spam": "eggs"}' + assert rendered_request.get("@method") == "POST" + assert rendered_request.get("@principalId") == "12233" + + assert rendered_request.get("stage") == "local" + assert rendered_request.get("enhancedAuthContext") == {"principalId": "12233"} + assert rendered_request.get("identity") == {"accountId": "00000", "apiKey": "11111"} + assert rendered_request.get("headers") == { + "content-type": format, + "accept": format, + } + assert rendered_request.get("query") == {"baz": "test"} + assert rendered_request.get("path") == {"proxy": "path"} + assert rendered_request.get("stageVariables") == { + "stageVariable1": "value1", + "stageVariable2": "value2", + } + + assert request_override == { + "header": {"multivalue": ["1header", "2header"], "oHeader": "1header"}, + "path": {"proxy": "proxy"}, + "querystring": {"query": "query"}, + } + + def test_render_valid_booleans_in_json(self): + template = ApiGatewayVtlTemplate() + + # assert that boolean results of _render_json_result(..) are JSON-parseable + tstring = '{"mybool": $boolTrue}' + result = template.render_vtl(tstring, {"boolTrue": "true"}) + assert json.loads(result) == {"mybool": True} + result = template.render_vtl(tstring, {"boolTrue": True}) + assert json.loads(result) == {"mybool": True} + tstring = '{"mybool": false}' + result = template.render_vtl(tstring, {}) + assert json.loads(result) == {"mybool": False} + + # older versions of `airspeed` were rendering booleans as False/True, which is no longer valid now + tstring = '{"mybool": False}' + with pytest.raises(JSONDecodeError): + result = template.render_vtl(tstring, {}) + assert json.loads(result) == {"mybool": False} + + @pytest.mark.parametrize("format", [APPLICATION_JSON, APPLICATION_XML]) + def test_render_response_template(self, format): + variables = MappingTemplateVariables( + input=MappingTemplateInput( + body='{"spam": "eggs"}', + params=MappingTemplateParams( + path={"proxy": "path"}, + querystring={"baz": "test"}, + header={"content-type": format, "accept": format}, + ), + ), + context=ContextVariables( + httpMethod="POST", + stage="local", + authorizer=ContextVarsAuthorizer(principalId="12233"), + identity=ContextVarsIdentity(accountId="00000", apiKey="11111"), + resourcePath="/{proxy}", + ), + stageVariables={"stageVariable1": "value1", "stageVariable2": "value2"}, + ) + context_overrides = ContextVariableOverrides( + requestOverride=ContextVarsRequestOverride(header={}, path={}, querystring={}), + responseOverride=ContextVarsResponseOverride(header={}, status=0), + ) + template = TEMPLATE_JSON if format == APPLICATION_JSON else TEMPLATE_XML + template += RESPONSE_OVERRIDE + + rendered_response, response_override = ApiGatewayVtlTemplate().render_response( + template=template, variables=variables, context_overrides=context_overrides + ) + if format == APPLICATION_JSON: + rendered_response = json.loads(rendered_response) + assert rendered_response.get("body") == {"spam": "eggs"} + assert rendered_response.get("method") == "POST" + assert rendered_response.get("principalId") == "12233" + else: + rendered_response = xmltodict.parse(rendered_response).get("root", {}) + # TODO Verify that those difference between xml and json are expected + assert rendered_response.get("body") == '{"spam": "eggs"}' + assert rendered_response.get("@method") == "POST" + assert rendered_response.get("@principalId") == "12233" + + assert rendered_response.get("stage") == "local" + assert rendered_response.get("enhancedAuthContext") == {"principalId": "12233"} + assert rendered_response.get("identity") == {"accountId": "00000", "apiKey": "11111"} + assert rendered_response.get("headers") == { + "content-type": format, + "accept": format, + } + assert rendered_response.get("query") == {"baz": "test"} + assert rendered_response.get("path") == {"proxy": "path"} + assert rendered_response.get("stageVariables") == { + "stageVariable1": "value1", + "stageVariable2": "value2", + } + + assert response_override == { + "header": {"multivalue": ["1header", "2header"], "oHeader": "1header"}, + "status": 400, + } + + def test_input_empty_body(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body="")) + + template = "$input.body" + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == "{}" + + def test_input_url_encode_empty_body(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body="")) + + template = "$util.urlEncode($input.body)" + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == "%7B%7D" + + def test_input_path_empty_body(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body="")) + + template = '$input.path("$.myVar")' + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == "" + + def test_input_path_not_json_body(self): + variables = MappingTemplateVariables(input=MappingTemplateInput(body="not json")) + + template = '$input.path("$.myVar")' + rendered_request = ApiGatewayVtlTemplate().render_vtl( + template=template, variables=variables + ) + + assert rendered_request == "" + + +TEMPLATE_JSON = """ + +#set( $body = $input.json("$") ) +#define( $loop ) +{ + #foreach($e in $map.keySet()) + #set( $k = $e ) + #set( $v = $map.get($k)) + "$k": "$v" + #if( $foreach.hasNext ) , #end + #end +} +#end + { + "body": $body, + "method": "$context.httpMethod", + "principalId": "$context.authorizer.principalId", + "stage": "$context.stage", + "cognitoPoolClaims" : { + "sub": "$context.authorizer.claims.sub" + }, + #set( $map = $context.authorizer ) + "enhancedAuthContext": $loop, + + #set( $map = $input.params().header ) + "headers": $loop, + + #set( $map = $input.params().querystring ) + "query": $loop, + + #set( $map = $input.params().path ) + "path": $loop, + + #set( $map = $context.identity ) + "identity": $loop, + + #set( $map = $stageVariables ) + "stageVariables": $loop, + + "requestPath": "$context.resourcePath" +} +""" + +TEMPLATE_WRONG_JSON = """ +#set( $body = $input.json("$") ) + { + "body": $body, + "method": $context.httpMethod, + } +""" + +TEMPLATE_XML = """ + +#set( $body = $input.json("$") ) + #define( $loop ) + #foreach($e in $map.keySet()) + #set( $k = $e ) + #set( $v = $map.get($k)) + <$k>$v + #end +#end + + $body + $context.stage + + $context.authorizer.claims.sub + + + #set( $map = $context.authorizer ) + $loop + + #set( $map = $input.params().header ) + $loop + + #set( $map = $input.params().querystring ) + $loop + + #set( $map = $input.params().path ) + $loop + + #set( $map = $context.identity ) + $loop + + #set( $map = $stageVariables ) +$loop + +""" +REQUEST_OVERRIDE = """ + +#set($context.requestOverride.header.oHeader = "1header") +#set($context.requestOverride.header.multivalue = ["1header", "2header"]) +#set($context.requestOverride.path.proxy = "proxy") +#set($context.requestOverride.querystring.query = "query") +""" + +RESPONSE_OVERRIDE = """ + +#set($context.responseOverride.header.oHeader = "1header") +#set($context.responseOverride.header.multivalue = ["1header", "2header"]) +#set($context.responseOverride.status = 400) +""" + +TEMPLATE_WRONG_XML = """ +#set( $body = $input.json("$") ) + + $body + $context.stage + +""" diff --git a/tests/unit/services/apigateway/test_uri_interpolation.py b/tests/unit/services/apigateway/test_uri_interpolation.py new file mode 100644 index 0000000000000..847fa5bea18d5 --- /dev/null +++ b/tests/unit/services/apigateway/test_uri_interpolation.py @@ -0,0 +1,134 @@ +import pytest + +from localstack.services.apigateway.next_gen.execute_api.helpers import ( + render_uri_with_path_parameters, + render_uri_with_stage_variables, +) + + +class TestUriInterpolationStageVariables: + @pytest.mark.parametrize( + "uri,expected", + [ + ( + # A full URI without protocol + "https://${stageVariables.stageDomain}", + "https://example.com", + ), + ( + # A full domain + "https://${stageVariables.stageDomain}/resource/operation", + "https://example.com/resource/operation", + ), + ( + # A subdomain + "https://${stageVariables.stageVar}.example.com/resource/operation", + "https://stageValue.example.com/resource/operation", + ), + ( + # A path + "https://example.com/${stageVariables.stageVar}/bar", + "https://example.com/stageValue/bar", + ), + ( + # A query string + "https://example.com/foo?q=${stageVariables.stageVar}", + "https://example.com/foo?q=stageValue", + ), + ( + # AWS URI action or path components + "arn:aws:apigateway:::${stageVariables.stageVar}", + "arn:aws:apigateway:::stageValue", + ), + ( + # AWS integration Lambda function name + "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:${stageVariables.stageVar}/invocations", + "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function:stageValue/invocations", + ), + ( + # AWS integration Lambda function version/alias + "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function::${stageVariables.stageVar}/invocations", + "arn:aws:apigateway::lambda:path/2015-03-31/functions/arn:aws:lambda:::function::stageValue/invocations", + ), + ( + # Amazon Cognito user pool for a COGNITO_USER_POOLS authorizer. + "arn:aws:cognito-idp:::userpool/${stageVariables.stageVar}", + "arn:aws:cognito-idp:::userpool/stageValue", + ), + ( + # AWS user/role integration credentials ARN + "arn:aws:iam:::${stageVariables.stageVar}", + "arn:aws:iam:::stageValue", + ), + ], + ) + def test_uri_stage_variables_interpolation(self, uri, expected): + # test values taken from the documentation + # https://docs.aws.amazon.com/apigateway/latest/developerguide/aws-api-gateway-stage-variables-reference.html#stage-variables-in-integration-HTTP-uris + stage_variables = { + "stageVar": "stageValue", + "stageDomain": "example.com", + "moreVar": "var", + } + rendered = render_uri_with_stage_variables(uri=uri, stage_variables=stage_variables) + assert rendered == expected + + def test_uri_interpolating_with_only_curly_braces(self): + uri_with_only_braces = "https://test.domain.example/root/{path}" + stage_variables = {"path": "value"} + rendered = render_uri_with_stage_variables( + uri=uri_with_only_braces, stage_variables=stage_variables + ) + assert "{path}" in rendered + + def test_uri_interpolating_with_no_variable(self): + uri = "https://test.domain.example/root/${stageVariables.path}" + stage_variables = {} + rendered = render_uri_with_stage_variables(uri=uri, stage_variables=stage_variables) + assert rendered == "https://test.domain.example/root/" + + def test_uri_interpolation_with_no_stage_var_prefix(self): + uri_with_no_prefix = "https://test.domain.example/root/${path}" + stage_variables = {} + rendered = render_uri_with_stage_variables( + uri=uri_with_no_prefix, stage_variables=stage_variables + ) + assert rendered == "https://test.domain.example/root/${path}" + + def test_uri_interpolating_with_bad_format(self): + # tested against AWS in an integration URI + uri_with_bad_format = r"https://test.domain.example/root/${path\}" + stage_variables = {"path": "value"} + rendered = render_uri_with_stage_variables( + uri=uri_with_bad_format, stage_variables=stage_variables + ) + assert rendered == uri_with_bad_format + + +class TestUriInterpolationPathParameters: + def test_uri_render_path_param(self): + uri_with_only_braces = "https://test.domain.example/root/{path}" + path_parameters = {"path": "value"} + rendered = render_uri_with_path_parameters( + uri=uri_with_only_braces, + path_parameters=path_parameters, + ) + assert rendered == "https://test.domain.example/root/value" + + def test_uri_render_missing_path_param(self): + uri_with_only_braces = "https://test.domain.example/root/{unknown}" + path_parameters = {"path": "value"} + rendered = render_uri_with_path_parameters( + uri=uri_with_only_braces, + path_parameters=path_parameters, + ) + assert rendered == "https://test.domain.example/root/{unknown}" + + def test_uri_render_partial_missing_path_param(self): + uri_with_only_braces = "https://test.domain.example/root/{unknown}/{path}" + path_parameters = {"path": "value"} + rendered = render_uri_with_path_parameters( + uri=uri_with_only_braces, + path_parameters=path_parameters, + ) + assert rendered == "https://test.domain.example/root/{unknown}/value" diff --git a/tests/unit/services/cloudformation/__init__.py b/tests/unit/services/cloudformation/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/cloudformation/test_cloudformation.py b/tests/unit/services/cloudformation/test_cloudformation.py new file mode 100644 index 0000000000000..98644ff410e6c --- /dev/null +++ b/tests/unit/services/cloudformation/test_cloudformation.py @@ -0,0 +1,62 @@ +from localstack.services.cloudformation.api_utils import is_local_service_url +from localstack.services.cloudformation.deployment_utils import ( + PLACEHOLDER_AWS_NO_VALUE, + remove_none_values, +) +from localstack.services.cloudformation.engine.template_deployer import order_resources + + +def test_is_local_service_url(): + local_urls = [ + "http://localhost", + "https://localhost", + "http://localhost:4566", + "https://localhost:4566", + "http://localhost.localstack.cloud:4566", + "https://s3.localhost.localstack.cloud", + "http://mybucket.s3.localhost.localstack.cloud:4566", + "https://mybucket.s3.localhost", + ] + remote_urls = [ + "https://mybucket.s3.amazonaws.com", + "http://mybucket.s3.us-east-1.amazonaws.com", + ] + for url in local_urls: + assert is_local_service_url(url) + for url in remote_urls: + assert not is_local_service_url(url) + + +def test_remove_none_values(): + template = { + "Properties": { + "prop1": 123, + "nested": {"test1": PLACEHOLDER_AWS_NO_VALUE, "test2": None}, + "list": [1, 2, PLACEHOLDER_AWS_NO_VALUE, 3, None], + } + } + result = remove_none_values(template) + assert result == {"Properties": {"prop1": 123, "nested": {}, "list": [1, 2, 3]}} + + +def test_order_resources(): + resources: dict[str, dict] = { + "B": { + "Type": "AWS::SSM::Parameter", + "Properties": { + "Type": "String", + "Value": { + "Ref": "A", + }, + }, + }, + "A": { + "Type": "AWS::SNS::Topic", + }, + } + + sorted_resources = order_resources( + resources=resources, resolved_conditions={}, resolved_parameters={} + ) + + assert list(sorted_resources.keys()) == ["A", "B"] diff --git a/tests/unit/services/cloudformation/test_deploy_ui.py b/tests/unit/services/cloudformation/test_deploy_ui.py new file mode 100644 index 0000000000000..d5db5387bc579 --- /dev/null +++ b/tests/unit/services/cloudformation/test_deploy_ui.py @@ -0,0 +1,12 @@ +from rolo import Request + +from localstack.services.cloudformation.deploy_ui import CloudFormationUi + + +class TestCloudFormationUiResource: + def test_get(self): + resource = CloudFormationUi() + response = resource.on_get(Request("GET", "/", body=b"None")) + assert response.status == "200 OK" + assert "" in response.get_data(as_text=True), "deploy UI did not render HTML" + assert "text/html" in response.headers.get("content-type", "") diff --git a/tests/unit/services/cloudformation/test_deployment_utils.py b/tests/unit/services/cloudformation/test_deployment_utils.py new file mode 100644 index 0000000000000..d1df32f079edd --- /dev/null +++ b/tests/unit/services/cloudformation/test_deployment_utils.py @@ -0,0 +1,24 @@ +from localstack.services.cloudformation.deployment_utils import fix_boto_parameters_based_on_report + + +class TestFixBotoParametersBasedOnReport: + def test_nested_parameters_are_fixed(self): + params = {"LaunchTemplate": {"Version": 1}} + message = ( + "Invalid type for parameter LaunchTemplate.Version, " + "value: 1, type: , valid types: " + ) + + fixed_params = fix_boto_parameters_based_on_report(params, message) + value = fixed_params["LaunchTemplate"]["Version"] + assert value == "1" + assert type(value) == str + + def test_top_level_parameters_are_converted(self): + params = {"Version": 1} + message = "Invalid type for parameter Version, value: 1, type: , valid types: " + + fixed_params = fix_boto_parameters_based_on_report(params, message) + value = fixed_params["Version"] + assert value == "1" + assert type(value) == str diff --git a/tests/unit/services/cloudformation/test_provider_utils.py b/tests/unit/services/cloudformation/test_provider_utils.py new file mode 100644 index 0000000000000..5fec01d31d662 --- /dev/null +++ b/tests/unit/services/cloudformation/test_provider_utils.py @@ -0,0 +1,137 @@ +import boto3 + +import localstack.services.cloudformation.provider_utils as utils + + +class TestDictUtils: + def test_convert_values_to_numbers(self): + original = {"Parameter": "1", "SecondParameter": ["2", "2"], "ThirdParameter": "3"} + transformed = utils.convert_values_to_numbers(original, ["ThirdParameter"]) + + assert transformed == {"Parameter": 1, "SecondParameter": [2, 2], "ThirdParameter": "3"} + + def test_drop_unknown(self): + svc_name = "events" + operation_name = "PutTargets" + operation = boto3.client(svc_name).meta.service_model.operation_model(operation_name) + input_shape = operation.input_shape + original_dict = { + "EventBusName": "my-event-bus", + "UnknownKey": "somevalue", + } + transformed_dict = utils.convert_request_kwargs(original_dict, input_shape) + + assert transformed_dict == { + "EventBusName": "my-event-bus", + } + + def test_convert_type_integers(self): + svc_name = "efs" + operation_name = "CreateAccessPoint" + operation = boto3.client(svc_name).meta.service_model.operation_model(operation_name) + input_shape = operation.input_shape + original_dict = { + "FileSystemId": "fs-29d6b02c", + "PosixUser": {"Gid": "1322", "SecondaryGids": ["1344", "1452"], "Uid": "13234"}, + "RootDirectory": { + "CreationInfo": { + "OwnerGid": "708798", + "OwnerUid": "7987987", + "Permissions": "0755", + }, + "Path": "/testcfn/abc", + }, + } + transformed_dict = utils.convert_request_kwargs(original_dict, input_shape) + assert transformed_dict == { + "FileSystemId": "fs-29d6b02c", + "PosixUser": {"Gid": 1322, "SecondaryGids": [1344, 1452], "Uid": 13234}, + "RootDirectory": { + "CreationInfo": {"OwnerGid": 708798, "OwnerUid": 7987987, "Permissions": "0755"}, + "Path": "/testcfn/abc", + }, + } + + def test_convert_type_boolean(self): + svc_name = "events" + operation_name = "PutTargets" + operation = boto3.client(svc_name).meta.service_model.operation_model(operation_name) + input_shape = operation.input_shape + original_dict = { + "EventBusName": "my-event-bus", + "Targets": [ + { + "Id": "an-id", + "EcsParameters": {"EnableECSManagedTags": "false"}, + } + ], + } + transformed_dict = utils.convert_request_kwargs(original_dict, input_shape) + + assert transformed_dict == { + "EventBusName": "my-event-bus", + "Targets": [ + { + "Id": "an-id", + "EcsParameters": {"EnableECSManagedTags": False}, + } + ], + } + + def test_convert_key_casing(self): + svc_name = "events" + operation_name = "PutTargets" + operation = boto3.client(svc_name).meta.service_model.operation_model(operation_name) + input_shape = operation.input_shape + original_dict = { + "EventBusName": "my-event-bus", + "Targets": [ + { + "Id": "an-id", + "EcsParameters": { + "NetworkConfiguration": { + "AwsVpcConfiguration": { # wrong casing! + "AssignPublicIp": "ENABLED", + } + } + }, + } + ], + } + transformed_dict = utils.convert_request_kwargs(original_dict, input_shape) + + assert transformed_dict == { + "EventBusName": "my-event-bus", + "Targets": [ + { + "Id": "an-id", + "EcsParameters": { + "NetworkConfiguration": { + "awsvpcConfiguration": { # fixed casing + "AssignPublicIp": "ENABLED", + } + } + }, + } + ], + } + + def test_lower_camelcase_to_pascalcase(self): + original_dict = { + "eventBusName": "my-event-bus", + "targets": [ + { + "id": "an-id", + } + ], + } + + converted_dict = utils.keys_lower_camelcase_to_pascalcase(original_dict) + assert converted_dict == { + "EventBusName": "my-event-bus", + "Targets": [ + { + "Id": "an-id", + } + ], + } diff --git a/tests/unit/services/cloudformation/test_transforms.py b/tests/unit/services/cloudformation/test_transforms.py new file mode 100644 index 0000000000000..fb29f97fadb4e --- /dev/null +++ b/tests/unit/services/cloudformation/test_transforms.py @@ -0,0 +1,48 @@ +from unittest.mock import MagicMock + +from localstack.services.cloudformation.engine.transformers import ( + expand_fn_foreach, +) + + +class TestExpandForeach: + def test_expand_aws_example(self): + foreach_body = [ + "TopicName", + ["a", "b", "c"], + { + "SnsTopic${TopicName}": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": {"Fn::Join": [".", [{"Ref": "TopicName"}, "fifo"]]}, + "FifoTopic": True, + }, + } + }, + ] + + expanded = expand_fn_foreach(foreach_body, resolve_context=MagicMock()) + + assert expanded == { + "SnsTopica": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "a.fifo", + "FifoTopic": True, + }, + }, + "SnsTopicb": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "b.fifo", + "FifoTopic": True, + }, + }, + "SnsTopicc": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "c.fifo", + "FifoTopic": True, + }, + }, + } diff --git a/tests/unit/services/cloudwatch/__init__.py b/tests/unit/services/cloudwatch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/cloudwatch/test_cloudwatch.py b/tests/unit/services/cloudwatch/test_cloudwatch.py new file mode 100644 index 0000000000000..13f0bc5b40561 --- /dev/null +++ b/tests/unit/services/cloudwatch/test_cloudwatch.py @@ -0,0 +1,260 @@ +from unittest.mock import ANY, Mock, call + +import pytest + +from localstack.services.cloudwatch import alarm_scheduler +from localstack.services.cloudwatch.alarm_scheduler import COMPARISON_OPS +from localstack.utils.patch import Patch, Patches + + +class TestAlarmScheduler: + TEST_1_DATA = "'0 - X - X'" + TEST_2_DATA = "'0 - - - -'" + TEST_3_DATA = "'- - - - -'" + TEST_4_DATA = "'0 X X - X'" + TEST_5_DATA = "'- - X - -'" + + TEST_1_M_OF_N = "'0 - X - X'" + TEST_2_M_OF_N = "'0 0 X 0 X'" + TEST_3_M_OF_N = "'0 - X - - '" + TEST_4_M_OF_N = "'- - - - 0'" + TEST_5_M_OF_N = "'- - - X -'" + + def test_comparison_operation_mapping(self): + a = 3 + b = 4 + + assert not COMPARISON_OPS.get("GreaterThanOrEqualToThreshold")(a, b) + assert COMPARISON_OPS.get("GreaterThanOrEqualToThreshold")(a, a) + assert COMPARISON_OPS.get("GreaterThanOrEqualToThreshold")(b, a) + + assert not COMPARISON_OPS.get("GreaterThanThreshold")(a, b) + assert not COMPARISON_OPS.get("GreaterThanThreshold")(a, a) + assert COMPARISON_OPS.get("GreaterThanThreshold")(b, a) + + assert COMPARISON_OPS.get("LessThanThreshold")(a, b) + assert not COMPARISON_OPS.get("LessThanThreshold")(a, a) + assert not COMPARISON_OPS.get("LessThanThreshold")(b, a) + + assert COMPARISON_OPS.get("LessThanOrEqualToThreshold")(a, b) + assert COMPARISON_OPS.get("LessThanOrEqualToThreshold")(a, a) + assert not COMPARISON_OPS.get("LessThanOrEqualToThreshold")(b, a) + + @pytest.mark.parametrize( + "initial_state,expected_state,expected_calls,treat_missing,metric_data", + [ + # 0 - X - X + ("ALARM", "OK", 1, "missing", TEST_1_DATA), + ("ALARM", "OK", 1, "ignore", TEST_1_DATA), + ("ALARM", "OK", 1, "breaching", TEST_1_DATA), + ("ALARM", "OK", 1, "notBreaching", TEST_1_DATA), + # 0 - - - - + ("ALARM", "OK", 1, "missing", TEST_2_DATA), + ("ALARM", "OK", 1, "ignore", TEST_2_DATA), + ("ALARM", "OK", 1, "breaching", TEST_2_DATA), + ("ALARM", "OK", 1, "notBreaching", TEST_2_DATA), + # - - - - - + ("OK", "INSUFFICIENT_DATA", 1, "missing", TEST_3_DATA), + ("OK", "OK", 0, "ignore", TEST_3_DATA), + ("OK", "ALARM", 1, "breaching", TEST_3_DATA), + ("OK", "OK", 0, "notBreaching", TEST_3_DATA), + # 0 X X - X + ("OK", "ALARM", 1, "missing", TEST_4_DATA), + ("OK", "ALARM", 1, "ignore", TEST_4_DATA), + ("OK", "ALARM", 1, "breaching", TEST_4_DATA), + ("OK", "ALARM", 1, "notBreaching", TEST_4_DATA), + # - - X - - + ("INSUFFICIENT_DATA", "ALARM", 1, "missing", TEST_5_DATA), + ("INSUFFICIENT_DATA", "INSUFFICIENT_DATA", 0, "ignore", TEST_5_DATA), + ("INSUFFICIENT_DATA", "ALARM", 1, "breaching", TEST_5_DATA), + ("INSUFFICIENT_DATA", "OK", 1, "notBreaching", TEST_5_DATA), + ], + ) + def test_calculate_alarm_state_3_out_of_3( + self, initial_state, expected_state, expected_calls, treat_missing, metric_data + ): + """ + Tests Table 1 depicted in the docs: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html#alarm-evaluation + Datapoints to Alarm and Evaluation Periods are both 3. + Cloudwatch uses 5 latest datapoints for evaluation: + 0 = ok + X = breaking threshold + - = no data available + |Test | Data points | MISSING | IGNORE | BREACHING | NOT BREACHING | # missing datapoints | + |-------|--------------|------------------|-------------|-----------|---------------| ---------------------| + |TEST_1 | 0 - X - X | OK | OK | OK | OK | 0 | + |TEST_2 | 0 - - - - | OK | OK | OK | OK | 2 | + |TEST_3 | - - - - - | INSUFFICIENT_DATA| Retain state| ALARM | OK | 3 | + |TEST_4 | 0 X X - X | ALARM | ALARM | ALARM | ALARM | 0 | + |TEST_5 | - - X - - | ALARM | Retain state| ALARM | OK | 2 -> Premature alarm | + """ + + def mock_metric_alarm_details(alarm_arn): + details = { + "AlarmName": "test-alarm", + "StateValue": initial_state, + "TreatMissingData": treat_missing, + "DatapointsToAlarm": 3, + "EvaluationPeriods": 3, + "Period": 5, + "ComparisonOperator": "LessThanThreshold", + "Threshold": 2, + } + return details + + def mock_collect_metric_data(alarm_details, client): + if metric_data == self.TEST_1_DATA: + return [2.5, None, 1, None, 1.7] + if metric_data == self.TEST_2_DATA: + return [3.0, None, None, None, None] + if metric_data == self.TEST_3_DATA: + return [None, None, None, None, None] + if metric_data == self.TEST_4_DATA: + return [3.0, 1.5, 1.0, None, 1.5] + if metric_data == self.TEST_5_DATA: + return [None, None, 1.0, None, None] + + run_and_assert_calculate_alarm_state( + mock_metric_alarm_details, mock_collect_metric_data, expected_calls, expected_state + ) + + @pytest.mark.parametrize( + "initial_state,expected_state,expected_calls,treat_missing,metric_data", + [ + # 0 - X - X + ("OK", "ALARM", 1, "missing", TEST_1_M_OF_N), + ("OK", "ALARM", 1, "ignore", TEST_1_M_OF_N), + ("OK", "ALARM", 1, "breaching", TEST_1_M_OF_N), + ("OK", "ALARM", 1, "notBreaching", TEST_1_M_OF_N), + # 0 0 X 0 X + ("OK", "ALARM", 1, "missing", TEST_2_M_OF_N), + ("OK", "ALARM", 1, "ignore", TEST_2_M_OF_N), + ("OK", "ALARM", 1, "breaching", TEST_2_M_OF_N), + ("OK", "ALARM", 1, "notBreaching", TEST_2_M_OF_N), + # 0 - X - - + ("INSUFFICIENT_DATA", "OK", 1, "missing", TEST_3_M_OF_N), + ("INSUFFICIENT_DATA", "OK", 1, "ignore", TEST_3_M_OF_N), + ("INSUFFICIENT_DATA", "ALARM", 1, "breaching", TEST_3_M_OF_N), + ("INSUFFICIENT_DATA", "OK", 1, "notBreaching", TEST_3_M_OF_N), + # - - - - 0 + ("INSUFFICIENT_DATA", "OK", 1, "missing", TEST_4_M_OF_N), + ("INSUFFICIENT_DATA", "OK", 1, "ignore", TEST_4_M_OF_N), + ("INSUFFICIENT_DATA", "ALARM", 1, "breaching", TEST_4_M_OF_N), + ("INSUFFICIENT_DATA", "OK", 1, "notBreaching", TEST_4_M_OF_N), + # - - - X - + ("INSUFFICIENT_DATA", "ALARM", 1, "missing", TEST_5_M_OF_N), + ("INSUFFICIENT_DATA", "INSUFFICIENT_DATA", 0, "ignore", TEST_5_M_OF_N), + ("INSUFFICIENT_DATA", "ALARM", 1, "breaching", TEST_5_M_OF_N), + ("INSUFFICIENT_DATA", "OK", 1, "notBreaching", TEST_5_M_OF_N), + ], + ) + def test_calculate_alarm_state_2_out_of_3( + self, initial_state, expected_state, expected_calls, treat_missing, metric_data + ): + """ + Tests Table 2 depicted in the docs: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html#alarm-evaluation + Datapoints to Alarm = 2 and Evaluation Periods = 3 + Cloudwatch uses 5 latest datapoints for evaluation: + 0 = ok + X = breaking threshold + - = no data available + |Test | Data points | MISSING | IGNORE | BREACHING | NOT BREACHING | # missing datapoints | + |--------------|--------------|------------------|-------------|-----------|---------------| ---------------------| + |TEST_1_M_OF_N | 0 - X - X | ALARM | ALARM | ALARM | ALARM | 0 | + |TEST_2_M_OF_N | 0 0 X 0 X | ALARM | ALARM | ALARM | ALARM | 0 | + |TEST_3_M_OF_N | 0 - X - - | OK | OK | ALARM | OK | 1 | + |TEST_4_M_OF_N | - - - - 0 | OK | OK | ALARM | OK | 2 | + |TEST_5_M_OF_N | - - - X - | ALARM | Retain state| ALARM | OK | 2 -> Premature alarm | + """ + + def mock_metric_alarm_details(alarm_arn): + details = { + "AlarmName": "test-alarm", + "StateValue": initial_state, + "TreatMissingData": treat_missing, + "DatapointsToAlarm": 2, + "EvaluationPeriods": 3, + "Period": 5, + "ComparisonOperator": "LessThanThreshold", + "Threshold": 2, + } + return details + + def mock_collect_metric_data(alarm_details, client): + if metric_data == self.TEST_1_M_OF_N: + return [2.5, None, 1, None, 1.7] + if metric_data == self.TEST_2_M_OF_N: + return [3.0, 4.0, 1.0, 3.5, 1.5] + if metric_data == self.TEST_3_M_OF_N: + return [4.0, None, 1.2, None, None] + if metric_data == self.TEST_4_M_OF_N: + return [None, None, None, None, 8] + if metric_data == self.TEST_5_M_OF_N: + return [None, None, None, 1.0, None] + + run_and_assert_calculate_alarm_state( + mock_metric_alarm_details, mock_collect_metric_data, expected_calls, expected_state + ) + + def test_calculate_alarm_state_with_datapoints_value_zero(self): + def mock_metric_alarm_details(alarm_arn): + details = { + "AlarmName": "test-alarm", + "StateValue": "OK", + "TreatMissingData": "notBreaching", + "EvaluationPeriods": 1, + "Period": 5, + "ComparisonOperator": "LessThanThreshold", + "Threshold": 1, + } + return details + + def mock_collect_metric_data(alarm_details, client): + return [0.0, None, 0.0] + + run_and_assert_calculate_alarm_state( + mock_metric_alarm_details, mock_collect_metric_data, 1, "ALARM" + ) + + +def run_and_assert_calculate_alarm_state( + mock_metric_alarm_details, mock_collect_metric_data, expected_calls, expected_state +): + mock_client = Mock() + + def mock_cloudwatch_client(alarm_arn): + return mock_client + + patches = Patches( + [ + Patch.function( + alarm_scheduler.get_metric_alarm_details_for_alarm_arn, + mock_metric_alarm_details, + pass_target=False, + ), + Patch.function( + alarm_scheduler.get_cloudwatch_client_for_region_of_alarm, + mock_cloudwatch_client, + pass_target=False, + ), + Patch.function( + alarm_scheduler.collect_metric_data, + mock_collect_metric_data, + pass_target=False, + ), + ] + ) + + with patches: + alarm_scheduler.calculate_alarm_state("helloworld") + assert len(mock_client.mock_calls) == expected_calls + if expected_calls != 0: + expected_calls = [ + call.set_alarm_state( + AlarmName="test-alarm", + StateValue=expected_state, + StateReason=ANY, + StateReasonData=ANY, + ) + ] + mock_client.assert_has_calls(expected_calls) diff --git a/tests/unit/services/config/__init__.py b/tests/unit/services/config/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/config/test_config.py b/tests/unit/services/config/test_config.py new file mode 100644 index 0000000000000..17b213b7102ae --- /dev/null +++ b/tests/unit/services/config/test_config.py @@ -0,0 +1,444 @@ +import textwrap + +import pytest + +from localstack import config +from localstack.config import HostAndPort, external_service_url, internal_service_url + + +class TestProviderConfig: + def test_provider_default_value(self): + default_value = "default_value" + override_value = "override_value" + provider_config = config.ServiceProviderConfig(default_value=default_value) + assert provider_config.get_provider("ec2") == default_value + provider_config.set_provider("ec2", override_value) + assert provider_config.get_provider("ec2") == override_value + + def test_provider_set_if_not_exists(self): + default_value = "default_value" + override_value = "override_value" + provider_config = config.ServiceProviderConfig(default_value=default_value) + provider_config.set_provider("ec2", default_value) + provider_config.set_provider_if_not_exists("ec2", override_value) + assert provider_config.get_provider("ec2") == default_value + + def test_provider_config_overrides(self, monkeypatch): + default_value = "default_value" + override_value = "override_value" + provider_config = config.ServiceProviderConfig(default_value=default_value) + monkeypatch.setenv("PROVIDER_OVERRIDE_EC2", override_value) + provider_config.load_from_environment() + assert provider_config.get_provider("ec2") == override_value + assert provider_config.get_provider("sqs") == default_value + monkeypatch.setenv("PROVIDER_OVERRIDE_SQS", override_value) + provider_config.load_from_environment() + assert provider_config.get_provider("sqs") == override_value + + def test_empty_provider_config_override(self, monkeypatch): + default_value = "default_value" + override_value = "" + provider_config = config.ServiceProviderConfig(default_value=default_value) + monkeypatch.setenv("PROVIDER_OVERRIDE_S3", override_value) + monkeypatch.setenv("PROVIDER_OVERRIDE_LAMBDA", override_value) + provider_config.load_from_environment() + assert provider_config.get_provider("s3") == default_value + assert provider_config.get_provider("lambda") == default_value + + def test_bulk_set_if_not_exists(self): + default_value = "default_value" + custom_value = "custom_value" + override_value = "override_value" + override_services = ["sqs", "sns", "lambda", "ec2"] + provider_config = config.ServiceProviderConfig(default_value=default_value) + provider_config.set_provider("ec2", default_value) + provider_config.set_provider("lambda", custom_value) + provider_config.bulk_set_provider_if_not_exists(override_services, override_value) + assert provider_config.get_provider("sqs") == override_value + assert provider_config.get_provider("sns") == override_value + assert provider_config.get_provider("lambda") == custom_value + assert provider_config.get_provider("ec2") == default_value + assert provider_config.get_provider("kinesis") == default_value + + +def ip() -> str: + if config.is_in_docker: + return "0.0.0.0" + else: + return "127.0.0.1" + + +class TestEdgeVariablesDerivedCorrectly: + """We are deriving GATEWAY_LISTEN and LOCALSTACK_HOST from provided environment variables. + + Implementation note: monkeypatching the config module is hard, and causes + tests run after these ones to import the wrong config. Instead, we test the + function that populates the configuration variables. + """ + + # This parameterised test forms a table of scenarios we need to cover. Each + # input variable (gateway_listen, localstack_host) has four unique + # combinations of inputs: + # * default + # * host only + # * ip only + # * host and ip + # and there are two variables so 16 total tests + @pytest.mark.parametrize( + [ + "gateway_listen", + "localstack_host", + "expected_gateway_listen", + "expected_localstack_host", + ], + [ + ### + (None, None, [f"{ip()}:4566"], "localhost.localstack.cloud:4566"), + ("1.1.1.1", None, ["1.1.1.1:4566"], "localhost.localstack.cloud:4566"), + (":5555", None, [f"{ip()}:5555"], "localhost.localstack.cloud:5555"), + ("1.1.1.1:5555", None, ["1.1.1.1:5555"], "localhost.localstack.cloud:5555"), + ### + (None, "foo.bar", [f"{ip()}:4566"], "foo.bar:4566"), + ("1.1.1.1", "foo.bar", ["1.1.1.1:4566"], "foo.bar:4566"), + (":5555", "foo.bar", [f"{ip()}:5555"], "foo.bar:5555"), + ("1.1.1.1:5555", "foo.bar", ["1.1.1.1:5555"], "foo.bar:5555"), + ### + (None, ":7777", [f"{ip()}:4566"], "localhost.localstack.cloud:7777"), + ("1.1.1.1", ":7777", ["1.1.1.1:4566"], "localhost.localstack.cloud:7777"), + (":5555", ":7777", [f"{ip()}:5555"], "localhost.localstack.cloud:7777"), + ("1.1.1.1:5555", ":7777", ["1.1.1.1:5555"], "localhost.localstack.cloud:7777"), + ### + (None, "foo.bar:7777", [f"{ip()}:4566"], "foo.bar:7777"), + ("1.1.1.1", "foo.bar:7777", ["1.1.1.1:4566"], "foo.bar:7777"), + (":5555", "foo.bar:7777", [f"{ip()}:5555"], "foo.bar:7777"), + ("1.1.1.1:5555", "foo.bar:7777", ["1.1.1.1:5555"], "foo.bar:7777"), + ], + ) + def test_edge_configuration( + self, + gateway_listen: str | None, + localstack_host: str | None, + expected_gateway_listen: list[str], + expected_localstack_host: str, + ): + environment = {} + if gateway_listen is not None: + environment["GATEWAY_LISTEN"] = gateway_listen + if localstack_host is not None: + environment["LOCALSTACK_HOST"] = localstack_host + + ( + actual_ls_host, + actual_gateway_listen, + ) = config.populate_edge_configuration(environment) + + assert actual_ls_host == expected_localstack_host + assert actual_gateway_listen == expected_gateway_listen + + def test_gateway_listen_multiple_addresses(self): + environment = {"GATEWAY_LISTEN": "0.0.0.0:9999,0.0.0.0:443"} + ( + _, + gateway_listen, + ) = config.populate_edge_configuration(environment) + + assert gateway_listen == [ + HostAndPort(host="0.0.0.0", port=9999), + HostAndPort(host="0.0.0.0", port=443), + ] + + def test_legacy_variables_ignored_if_given(self): + """Providing legacy variables removed in 3.0 should not affect the default configuration. + This test can be removed around >3.1-4.0.""" + environment = { + "EDGE_BIND_HOST": "192.168.0.1", + "EDGE_PORT": "10101", + "EDGE_PORT_HTTP": "20202", + } + ( + localstack_host, + gateway_listen, + ) = config.populate_edge_configuration(environment) + + assert localstack_host == "localhost.localstack.cloud:4566" + assert gateway_listen == [ + HostAndPort(host=ip(), port=4566), + ] + + +class TestUniquePortList: + def test_construction(self): + ports = config.UniqueHostAndPortList( + [ + HostAndPort("127.0.0.1", 53), + HostAndPort("127.0.0.1", 53), + ] + ) + assert ports == [ + HostAndPort("127.0.0.1", 53), + ] + + def test_add_separate_values(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("127.0.0.1", 42)) + ports.append(HostAndPort("127.0.0.1", 43)) + + assert ports == [HostAndPort("127.0.0.1", 42), HostAndPort("127.0.0.1", 43)] + + def test_add_same_value(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("127.0.0.1", 42)) + ports.append(HostAndPort("127.0.0.1", 42)) + + assert ports == [ + HostAndPort("127.0.0.1", 42), + ] + + def test_add_all_interfaces_value(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("0.0.0.0", 42)) + ports.append(HostAndPort("127.0.0.1", 42)) + + assert ports == [ + HostAndPort("0.0.0.0", 42), + ] + + def test_add_all_interfaces_value_ipv6(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("::", 42)) + ports.append(HostAndPort("::1", 42)) + + assert ports == [ + HostAndPort("::", 42), + ] + + def test_add_all_interfaces_value_mixed_ipv6_wins(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("0.0.0.0", 42)) + ports.append(HostAndPort("::", 42)) + ports.append(HostAndPort("127.0.0.1", 42)) + ports.append(HostAndPort("::1", 42)) + + assert ports == [ + HostAndPort("::", 42), + ] + + def test_add_all_interfaces_value_after(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("127.0.0.1", 42)) + ports.append(HostAndPort("0.0.0.0", 42)) + + assert ports == [ + HostAndPort("0.0.0.0", 42), + ] + + def test_add_all_interfaces_value_after_ipv6(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("::1", 42)) + ports.append(HostAndPort("::", 42)) + + assert ports == [ + HostAndPort("::", 42), + ] + + def test_add_all_interfaces_value_after_mixed_ipv6_wins(self): + ports = config.UniqueHostAndPortList() + ports.append(HostAndPort("::1", 42)) + ports.append(HostAndPort("127.0.0.1", 42)) + ports.append(HostAndPort("::", 42)) + ports.append(HostAndPort("0.0.0.0", 42)) + + assert ports == [HostAndPort("::", 42)] + + def test_index_access(self): + ports = config.UniqueHostAndPortList( + [ + HostAndPort("0.0.0.0", 42), + ] + ) + + assert ports[0] == HostAndPort("0.0.0.0", 42) + with pytest.raises(IndexError): + _ = ports[10] + + def test_iteration(self): + ports = config.UniqueHostAndPortList( + [ + HostAndPort("127.0.0.1", 42), + HostAndPort("127.0.0.1", 43), + ] + ) + n = 0 + for _ in ports: + n += 1 + + assert n == len(ports) == 2 + + +class TestHostAndPort: + def test_parsing_hostname_and_ip(self): + h = config.HostAndPort.parse("0.0.0.0:1000", default_host="", default_port=0) + assert h == HostAndPort(host="0.0.0.0", port=1000) + + def test_parsing_with_default_host(self): + h = config.HostAndPort.parse(":1000", default_host="192.168.0.1", default_port=0) + assert h == HostAndPort(host="192.168.0.1", port=1000) + + def test_parsing_with_default_port(self): + h = config.HostAndPort.parse("1.2.3.4", default_host="", default_port=9876) + assert h == HostAndPort(host="1.2.3.4", port=9876) + + def test_parsing_with_empty_host(self): + h = config.HostAndPort.parse(":4566", default_host="", default_port=9876) + assert h == HostAndPort(host="", port=4566) + + def test_invalid_port(self): + with pytest.raises(ValueError) as exc_info: + config.HostAndPort.parse("0.0.0.0:not-a-port", default_host="127.0.0.1", default_port=0) + + assert "specified port not-a-port not a number" in str(exc_info) + + def test_parsing_ipv6_with_port(self): + h = config.HostAndPort.parse( + "[5601:f95d:0:10:4978::2]:1000", default_host="", default_port=9876 + ) + assert h == HostAndPort(host="5601:f95d:0:10:4978::2", port=1000) + + def test_parsing_ipv6_with_default_port(self): + h = config.HostAndPort.parse("[5601:f95d:0:10:4978::2]", default_host="", default_port=9876) + assert h == HostAndPort(host="5601:f95d:0:10:4978::2", port=9876) + + def test_parsing_ipv6_all_interfaces_with_default_port(self): + h = config.HostAndPort.parse("[::]", default_host="", default_port=9876) + assert h == HostAndPort(host="::", port=9876) + + def test_parsing_ipv6_with_invalid_address(self): + with pytest.raises(ValueError) as exc_info: + config.HostAndPort.parse("[i-am-invalid]", default_host="", default_port=9876) + + assert "input looks like an IPv6 address" in str(exc_info) + + @pytest.mark.parametrize("port", [-1000, -1, 2**16, 100_000]) + def test_port_out_of_range(self, port): + with pytest.raises(ValueError) as exc_info: + config.HostAndPort.parse( + f"localhost:{port}", default_host="localhost", default_port=1234 + ) + + assert "port out of range" in str(exc_info) + + +class TestServiceUrlHelpers: + @pytest.mark.parametrize( + ["protocol", "subdomains", "host", "port", "expected_service_url"], + [ + # Default + (None, None, None, None, "http://localhost.localstack.cloud:4566"), + # Customize each part with defaults + ("https", None, None, None, "https://localhost.localstack.cloud:4566"), + (None, "s3", None, None, "http://s3.localhost.localstack.cloud:4566"), + (None, None, "localstack-container", None, "http://localstack-container:4566"), + (None, None, None, 5555, "http://localhost.localstack.cloud:5555"), + # Multiple subdomains + ( + None, + "abc123.execute-api.lambda", + None, + None, + "http://abc123.execute-api.lambda.localhost.localstack.cloud:4566", + ), + # Customize everything + ( + "https", + "abc.execute-api", + "localstack-container", + 5555, + "https://abc.execute-api.localstack-container:5555", + ), + ], + ) + def test_external_service_url( + self, + protocol: str | None, + subdomains: str | None, + host: str | None, + port: int | None, + expected_service_url: str, + ): + url = external_service_url(host=host, port=port, protocol=protocol, subdomains=subdomains) + assert url == expected_service_url + + def test_internal_service_url(self): + # defaults + assert internal_service_url() == "http://localhost:4566" + # subdomains + assert ( + internal_service_url(subdomains="abc.execute-api") + == "http://abc.execute-api.localhost:4566" + ) + + +class TestConfigProfiles: + @pytest.fixture + def profile_folder(self, monkeypatch, tmp_path): + monkeypatch.setattr(config, "CONFIG_DIR", tmp_path) + return tmp_path + + def test_multiple_profiles(self, profile_folder): + profile_1_content = textwrap.dedent( + """ + VAR1=test1 + VAR2=test2 + VAR3=test3 + """ + ) + profile_2_content = textwrap.dedent( + """ + VAR4=test4 + """ + ) + profile_1 = profile_folder / "profile_1.env" + profile_1.write_text(profile_1_content) + profile_2 = profile_folder / "profile_2.env" + profile_2.write_text(profile_2_content) + + environment = {} + + config.load_environment(profiles="profile_1,profile_2", env=environment) + + assert environment == { + "VAR1": "test1", + "VAR2": "test2", + "VAR3": "test3", + "VAR4": "test4", + } + + def test_multiple_profiles_override_behavior(self, profile_folder): + profile_1_content = textwrap.dedent( + """ + VAR1=test1 + VAR2=test2 + VAR3=test3 + """ + ) + profile_2_content = textwrap.dedent( + """ + VAR3=override3 + VAR4=test4 + """ + ) + profile_1 = profile_folder / "profile_1.env" + profile_1.write_text(profile_1_content) + profile_2 = profile_folder / "profile_2.env" + profile_2.write_text(profile_2_content) + + environment = {"VAR1": "can't touch this"} + + config.load_environment(profiles="profile_1,profile_2", env=environment) + + assert environment == { + "VAR1": "can't touch this", + "VAR2": "test2", + "VAR3": "override3", + "VAR4": "test4", + } diff --git a/tests/unit/services/dynamodb/__init__.py b/tests/unit/services/dynamodb/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/dynamodb/test_dynamodb.py b/tests/unit/services/dynamodb/test_dynamodb.py new file mode 100644 index 0000000000000..981103ac4206f --- /dev/null +++ b/tests/unit/services/dynamodb/test_dynamodb.py @@ -0,0 +1,153 @@ +from unittest.mock import patch + +import pytest + +from localstack.services.dynamodb.provider import DynamoDBProvider, get_store +from localstack.services.dynamodb.utils import ( + SCHEMA_CACHE, + ItemSet, + SchemaExtractor, + dynamize_value, +) +from localstack.testing.config import ( + TEST_AWS_ACCESS_KEY_ID, + TEST_AWS_ACCOUNT_ID, + TEST_AWS_REGION_NAME, +) +from localstack.utils.aws.arns import dynamodb_table_arn +from localstack.utils.aws.request_context import mock_aws_request_headers + + +def test_fix_region_in_headers(): + # the NoSQL Workbench sends "localhost" or "local" as the region name + # TODO: this may need to be updated once we migrate DynamoDB to ASF + + for region_name in ["local", "localhost"]: + headers = mock_aws_request_headers( + "dynamodb", aws_access_key_id=TEST_AWS_ACCESS_KEY_ID, region_name=region_name + ) + assert TEST_AWS_REGION_NAME not in headers.get("Authorization") + + # Ensure that the correct namespacing key is passed as Access Key ID to DynamoDB Local + DynamoDBProvider.prepare_request_headers( + headers, account_id="000011112222", region_name="ap-south-1" + ) + assert "000011112222apsouth1" in headers.get("Authorization") + + +def test_lookup_via_item_set(): + items1 = [ + {"id": {"S": "n1"}, "attr1": {"N": "1"}}, + {"id": {"S": "n2"}}, + {"id": {"S": "n3"}}, + ] + key_schema1 = [{"AttributeName": "id", "KeyType": "HASH"}] + + items2 = [ + {"id": {"S": "id1"}, "num": {"N": "1"}}, + {"id": {"S": "id2"}, "num": {"N": "2"}}, + {"id": {"S": "id3"}, "num": {"N": "2"}}, + ] + key_schema2 = [ + {"AttributeName": "id", "KeyType": "HASH"}, + {"AttributeName": "num", "KeyType": "RANGE"}, + ] + + samples = ((items1, key_schema1), (items2, key_schema2)) + + for items, key_schema in samples: + item_set = ItemSet(items, key_schema=key_schema) + for item in items: + assert item_set.find_item(item) == item + for item in items: + assert not item_set.find_item({**item, "id": {"S": item["id"]["S"] + "-new"}}) + + +@patch("localstack.services.dynamodb.utils.SchemaExtractor.get_table_schema") +def test_get_key_schema_without_table_definition(mock_get_table_schema): + schema_extractor = SchemaExtractor() + + key_schema = [{"AttributeName": "id", "KeyType": "HASH"}] + attr_definitions = [ + {"AttributeName": "Artist", "AttributeType": "S"}, + {"AttributeName": "SongTitle", "AttributeType": "S"}, + ] + table_name = "nonexistent_table" + + mock_get_table_schema.return_value = { + "Table": {"KeySchema": key_schema, "AttributeDefinitions": attr_definitions} + } + + schema = schema_extractor.get_key_schema( + table_name, account_id=TEST_AWS_ACCOUNT_ID, region_name=TEST_AWS_REGION_NAME + ) + + # Assert output is expected from the get_table_schema (fallback) + assert schema == key_schema + # Assert table_definitions has new table entry (cache) + dynamodb_store = get_store(account_id=TEST_AWS_ACCOUNT_ID, region_name=TEST_AWS_REGION_NAME) + assert table_name in dynamodb_store.table_definitions + # Assert table_definitions has the correct content + assert ( + dynamodb_store.table_definitions[table_name] == mock_get_table_schema.return_value["Table"] + ) + + +def test_invalidate_table_schema(): + schema_extractor = SchemaExtractor() + + key_schema = [{"AttributeName": "id", "KeyType": "HASH"}] + attr_definitions = [ + {"AttributeName": "Artist", "AttributeType": "S"}, + {"AttributeName": "SongTitle", "AttributeType": "S"}, + ] + table_name = "nonexistent_table" + + key = dynamodb_table_arn( + table_name=table_name, account_id=TEST_AWS_ACCOUNT_ID, region_name=TEST_AWS_REGION_NAME + ) + + table_schema = {"Table": {"KeySchema": key_schema, "AttributeDefinitions": attr_definitions}} + # This isn't great but we need to inject into the cache here so that we're not trying to hit dynamodb + # to look up the table later on + SCHEMA_CACHE[key] = table_schema + + schema = schema_extractor.get_table_schema( + table_name, account_id=TEST_AWS_ACCOUNT_ID, region_name=TEST_AWS_REGION_NAME + ) + + # Assert output is expected from the get_table_schema (fallback) + assert schema == table_schema + # Invalidate the cache now for the table + schema_extractor.invalidate_table_schema(table_name, TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME) + # Assert that the key is now set to None + assert SCHEMA_CACHE.get(key) is None + + +@pytest.mark.parametrize( + "value, result", + [ + (True, {"BOOL": True}), + (None, {"NULL": True}), + ("test", {"S": "test"}), + (1, {"N": "1"}), + ({"test", "test1"}, {"SS": ["test", "test1"]}), + ({1, 2}, {"NS": ["1", "2"]}), + ({b"test", b"test1"}, {"BS": [b"test", b"test1"]}), + (b"test", {"B": b"test"}), + ({"key": "val"}, {"M": {"key": {"S": "val"}}}), + (["val", 2], {"L": [{"S": "val"}, {"N": "2"}]}), + ], +) +def test_dynamize_value(value, result): + # we need to set a special case for SS, NS and BS because sets are unordered, and won't keep the order when + # transformed into lists + if isinstance(value, (set, frozenset)): + dynamized = dynamize_value(value) + assert dynamized.keys() == result.keys() + for key, val in dynamized.items(): + result[key].sort() + val.sort() + assert result[key] == val + else: + assert dynamize_value(value) == result diff --git a/tests/unit/services/events/test_event_ruler.py b/tests/unit/services/events/test_event_ruler.py new file mode 100644 index 0000000000000..1d968407556f3 --- /dev/null +++ b/tests/unit/services/events/test_event_ruler.py @@ -0,0 +1,69 @@ +import pytest + +from localstack.services.events.event_rule_engine import EventRuleEngine + + +class TestEventRuler: + @pytest.mark.parametrize( + "input_pattern,flat_patterns", + [ + ( + {"filter": [{"anything-but": {"prefix": "type"}}]}, + [{"filter": [{"anything-but": {"prefix": "type"}}]}], + ), + ( + {"field1": {"field2": {"field3": "val1", "field4": "val2"}}}, + [{"field1.field2.field3": "val1", "field1.field2.field4": "val2"}], + ), + ( + {"$or": [{"field1": "val1"}, {"field2": "val2"}], "field3": "val3"}, + [{"field1": "val1", "field3": "val3"}, {"field2": "val2", "field3": "val3"}], + ), + ], + ids=["simple", "simple-with-dots", "$or-pattern"], + ) + def test_flatten_patterns(self, input_pattern, flat_patterns): + engine = EventRuleEngine() + assert engine.flatten_pattern(input_pattern) == flat_patterns + + @pytest.mark.parametrize( + "input_payload,flat_patterns,flat_payload", + [ + ( + {"field1": "val1", "field3": "val3"}, + [{"field1": "val1", "field3": "val3"}, {"field2": "val2", "field3": "val3"}], + [{"field1": "val1", "field3": "val3"}], + ), + ( + {"f1": {"f2": {"f3": "v3"}}, "f4": "v4"}, + [{"f4": "test1"}], + [{"f4": "v4"}], + ), + ( + {"f1": {"f2": {"f3": {"f4": [{"f5": "v5"}]}, "f6": [{"f8": "v8"}]}}}, + [{"f1.f2.f3": "val1", "f1.f2.f4": "val2"}], + [{}], + ), + ( + {"f1": {"f2": {"f3": {"f4": [{"f5": "v5"}]}, "f6": [{"f7": "v7"}]}}}, + [{"f1.f2.f3.f4.f5": "val1", "f1.f2.f4": "val2"}], + [{"f1.f2.f3.f4.f5": "v5"}], + ), + ( + {"f1": {"f2": {"f3": {"f4": [{"f5": "v5"}]}, "f6": [{"f7": "v7"}]}}}, + [{"f1.f2.f3.f4.f5": "test1", "f1.f2.f6.f7": "test2"}], + [{"f1.f2.f3.f4.f5": "v5", "f1.f2.f6.f7": "v7"}], + ), + ], + ids=[ + "simple-with-or-pattern-flat", + "simple-pattern-filter", + "nested-payload-no-result", + "nested-payload-1-match", + "nested-payload-2-match", + ], + ) + def test_flatten_payload(self, input_payload, flat_patterns, flat_payload): + engine = EventRuleEngine() + + assert engine.flatten_payload(input_payload, flat_patterns) == flat_payload diff --git a/tests/unit/services/events/test_utils.py b/tests/unit/services/events/test_utils.py new file mode 100644 index 0000000000000..883a7091f7f47 --- /dev/null +++ b/tests/unit/services/events/test_utils.py @@ -0,0 +1,28 @@ +import re + +import pytest + +from localstack.services.events.utils import is_nested_in_string + + +@pytest.mark.parametrize( + "template, expected", + [ + # Basic cases + ('"users-service/users/"', True), + ('""', True), + # Edge cases with commas and braces + ('{"path": "users/", "id": }', True), + ('{"id": }', False), + # Multiple placeholders + ('"users//profile/"', True), + # Nested JSON structures + ('{"data": {"path": "users/"}}', True), + ('{"data": }', False), + ('{"data": ""}', True), + ], +) +def test_is_nested_in_string(template, expected): + pattern = re.compile(r"<.*?>") + match = pattern.search(template) + assert is_nested_in_string(template, match) == expected diff --git a/tests/unit/services/kms/__init__.py b/tests/unit/services/kms/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/kms/test_kms.py b/tests/unit/services/kms/test_kms.py new file mode 100644 index 0000000000000..ffdec68c06b58 --- /dev/null +++ b/tests/unit/services/kms/test_kms.py @@ -0,0 +1,206 @@ +import pytest + +from localstack.aws.api import RequestContext +from localstack.aws.api.kms import ( + CreateKeyRequest, + DryRunOperationException, + UnsupportedOperationException, +) +from localstack.services.kms.exceptions import ValidationException +from localstack.services.kms.provider import KmsProvider +from localstack.services.kms.utils import ( + execute_dry_run_capable, + validate_alias_name, +) + + +def test_alias_name_validator(): + with pytest.raises(Exception): + validate_alias_name("test-alias") + + +@pytest.fixture +def provider(): + return KmsProvider() + + +def test_execute_dry_run_capable_runs_when_not_dry(): + result = execute_dry_run_capable(lambda: 1 + 1, dry_run=False) + assert result == 2 + + +def test_execute_dry_run_capable_raises_when_dry(): + with pytest.raises(DryRunOperationException): + execute_dry_run_capable(lambda: "should not run", dry_run=True) + + +@pytest.mark.parametrize( + "invalid_spec", + [ + "INVALID_SPEC", + "AES_256", # Symmetric, not key pair + "", + "foo", + ], +) +@pytest.mark.parametrize("dry_run", [True, False]) +def test_generate_data_key_pair_invalid_spec_raises_unsupported_exception( + provider, invalid_spec, dry_run +): + # Arrange + context = RequestContext(None) + context.account_id = "000000000000" + context.region = "us-east-1" + + key_request = CreateKeyRequest(Description="Test key") + key = provider.create_key(context, key_request) + key_id = key["KeyMetadata"]["KeyId"] + + # Act & Assert + with pytest.raises(UnsupportedOperationException): + provider.generate_data_key_pair( + context=context, + key_id=key_id, + key_pair_spec=invalid_spec, + dry_run=dry_run, + ) + + +@pytest.mark.parametrize( + "invalid_spec", + [ + "RSA_1024", + "ECC_FAKE", # Symmetric, not key pair + "HMAC_222", + ], +) +@pytest.mark.parametrize("dry_run", [True, False]) +def test_generate_data_key_pair_invalid_spec_raises_validation_exception( + provider, invalid_spec, dry_run +): + # Arrange + context = RequestContext(None) + context.account_id = "000000000000" + context.region = "us-east-1" + + key_request = CreateKeyRequest(Description="Test key") + key = provider.create_key(context, key_request) + key_id = key["KeyMetadata"]["KeyId"] + + # Act & Assert + with pytest.raises(ValidationException): + provider.generate_data_key_pair( + context=context, + key_id=key_id, + key_pair_spec=invalid_spec, + dry_run=dry_run, + ) + + +def test_generate_data_key_pair_real_key(provider): + # Arrange + account_id = "000000000000" + region_name = "us-east-1" + context = RequestContext(None) + context.account_id = account_id + context.region = region_name + + # Note: we're using `provider.create_key` to set up the test, which introduces a hidden dependency. + # If `create_key` fails or changes its behavior, this test might fail incorrectly even if the logic + # under test (`generate_data_key_pair`) is still correct. Ideally, we would decouple the store + # through dependency injection (e.g., by abstracting the KMS store), so that + # we could stub it or inject a pre-populated instance directly in the test setup. + key_request = CreateKeyRequest(Description="Test key") + key = provider.create_key(context, key_request) + key_id = key["KeyMetadata"]["KeyId"] + + # # Act + response = provider.generate_data_key_pair( + context=context, + key_id=key_id, + key_pair_spec="RSA_2048", + dry_run=False, + ) + + # # Assert + assert response["KeyId"] == key["KeyMetadata"]["Arn"] + assert response["KeyPairSpec"] == "RSA_2048" + + +def test_generate_data_key_pair_dry_run(provider): + # Arrange + account_id = "000000000000" + region_name = "us-east-1" + context = RequestContext(None) + context.account_id = account_id + context.region = region_name + + # Note: we're using `provider.create_key` to set up the test, which introduces a hidden dependency. + # If `create_key` fails or changes its behavior, this test might fail incorrectly even if the logic + # under test (`generate_data_key_pair`) is still correct. Ideally, we would decouple the store + # through dependency injection (e.g., by abstracting the KMS store), so that + # we could stub it or inject a pre-populated instance directly in the test setup. + key_request = CreateKeyRequest(Description="Test key") + key = provider.create_key(context, key_request) + key_id = key["KeyMetadata"]["KeyId"] + + # Act & Assert + with pytest.raises(DryRunOperationException): + provider.generate_data_key_pair( + context=context, + key_id=key_id, + key_pair_spec="RSA_2048", + dry_run=True, + ) + + +def test_generate_data_key_pair_without_plaintext(provider): + # Arrange + account_id = "000000000000" + region_name = "us-east-1" + context = RequestContext(None) + context.account_id = account_id + context.region = region_name + + # Note: we're using `provider.create_key` to set up the test, which introduces a hidden dependency. + # If `create_key` fails or changes its behavior, this test might fail incorrectly even if the logic + # under test (`generate_data_key_pair_without_plaintext`) is still correct. Ideally, we would decouple + # the store through dependency injection to isolate test concerns. + key_request = CreateKeyRequest(Description="Test key") + key = provider.create_key(context, key_request) + key_id = key["KeyMetadata"]["KeyId"] + + # Act + response = provider.generate_data_key_pair_without_plaintext( + context=context, + key_id=key_id, + key_pair_spec="RSA_2048", + dry_run=False, + ) + + # Assert + assert response["KeyId"] == key["KeyMetadata"]["Arn"] + assert response["KeyPairSpec"] == "RSA_2048" + assert "PrivateKeyPlaintext" not in response # Confirm plaintext was removed + + +def test_generate_data_key_pair_without_plaintext_dry_run(provider): + # Arrange + account_id = "000000000000" + region_name = "us-east-1" + context = RequestContext(None) + context.account_id = account_id + context.region = region_name + + key_request = CreateKeyRequest(Description="Test key") + key = provider.create_key(context, key_request) + key_id = key["KeyMetadata"]["KeyId"] + + # Act & Assert + with pytest.raises(DryRunOperationException): + provider.generate_data_key_pair_without_plaintext( + context=context, + key_id=key_id, + key_pair_spec="RSA_2048", + dry_run=True, + ) diff --git a/tests/unit/services/lambda_/__init__.py b/tests/unit/services/lambda_/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/lambda_/test_api_utils.py b/tests/unit/services/lambda_/test_api_utils.py new file mode 100644 index 0000000000000..b7871a3e5ae84 --- /dev/null +++ b/tests/unit/services/lambda_/test_api_utils.py @@ -0,0 +1,68 @@ +from localstack.services.lambda_.api_utils import ( + is_qualifier_expression, + qualifier_is_alias, + qualifier_is_version, +) +from localstack.services.lambda_.runtimes import ( + ALL_RUNTIMES, + IMAGE_MAPPING, + MISSING_RUNTIMES, + SUPPORTED_RUNTIMES, + TESTED_RUNTIMES, + VALID_LAYER_RUNTIMES, + VALID_RUNTIMES, +) + + +class TestApiUtils: + def test_check_runtime(self): + """ + Ensure that we keep the runtime lists consistent. The supported runtimes through image mappings + should not diverge from the API-validated inputs nor the tested runtimes. + """ + # Ensure that we have image mappings for all runtimes used in LocalStack (motivated by #9020) + assert set(ALL_RUNTIMES) == set(IMAGE_MAPPING.keys()) + + # Ensure that we test all supported runtimes + assert set(SUPPORTED_RUNTIMES) == set(TESTED_RUNTIMES), ( + "mismatch between supported and tested runtimes" + ) + + # Ensure that valid runtimes (i.e., API-level validation) match the actually supported runtimes + # HINT: Update your botocore version if this check fails + valid_runtimes = VALID_RUNTIMES[1:-1].split(", ") + assert set(SUPPORTED_RUNTIMES).union(MISSING_RUNTIMES) == set(valid_runtimes), ( + "mismatch between supported and API-valid runtimes" + ) + + # Ensure that valid layer runtimes (includes some extra runtimes) contain the actually supported runtimes + valid_layer_runtimes = VALID_LAYER_RUNTIMES[1:-1].split(", ") + assert set(ALL_RUNTIMES).issubset(set(valid_layer_runtimes)), ( + "supported runtimes not part of compatible runtimes for layers" + ) + + def test_is_qualifier_expression(self): + assert is_qualifier_expression("abczABCZ") + assert is_qualifier_expression("a01239") + assert is_qualifier_expression("1numeric") + assert is_qualifier_expression("-") + assert is_qualifier_expression("_") + assert is_qualifier_expression("valid-with-$-inside") + assert not is_qualifier_expression("invalid-with-?-char") + assert not is_qualifier_expression("") + + def test_qualifier_is_version(self): + assert qualifier_is_version("0") + assert qualifier_is_version("42") + assert not qualifier_is_version("$LATEST") + assert not qualifier_is_version("a77") + assert not qualifier_is_version("77a") + + def test_qualifier_is_alias(self): + assert qualifier_is_alias("abczABCZ") + assert qualifier_is_alias("a01239") + assert qualifier_is_alias("1numeric") + assert qualifier_is_alias("2024-01-01") + assert not qualifier_is_alias("20240101") + assert not qualifier_is_alias("invalid-with-$-char") + assert not qualifier_is_alias("invalid-with-?-char") diff --git a/tests/unit/services/lambda_/test_image_resolver.py b/tests/unit/services/lambda_/test_image_resolver.py new file mode 100644 index 0000000000000..545b52ab669ae --- /dev/null +++ b/tests/unit/services/lambda_/test_image_resolver.py @@ -0,0 +1,48 @@ +import json + +from localstack.aws.api.lambda_ import Runtime +from localstack.services.lambda_.invocation.docker_runtime_executor import RuntimeImageResolver + + +def test_custom_pattern_mapping(): + resolver = RuntimeImageResolver() + resolved_image = resolver._resolve( + Runtime.python3_9, custom_image_mapping="custom/__:new" + ) + assert resolved_image == "custom/_python3.9_:new" + + +def test_custom_json_mapping(): + resolver = RuntimeImageResolver() + resolved_image = resolver._resolve( + Runtime.python3_9, + custom_image_mapping=json.dumps({Runtime.python3_9: "custom/py.thon.3:9"}), + ) + assert resolved_image == "custom/py.thon.3:9" + + +def test_custom_json_mapping_fallback(): + # if the runtime was not in the provided json, it should fall back to default + resolver = RuntimeImageResolver() + resolved_image = resolver._resolve( + Runtime.python3_8, + custom_image_mapping=json.dumps({Runtime.python3_9: "custom/py.thon.3:9"}), + ) + assert resolved_image is not None + assert resolved_image != "custom/py.thon.3:9" + assert "custom" not in resolved_image + + +def test_default_mapping(): + resolver = RuntimeImageResolver() + resolved_image = resolver._resolve(Runtime.python3_9) + assert "custom" not in resolved_image + + +def test_custom_default_mapping(): + def custom_default(a): + return f"custom-{a}" + + resolver = RuntimeImageResolver(default_resolve_fn=custom_default) + resolved_image = resolver._resolve(Runtime.python3_9) + assert resolved_image == "custom-python3.9" diff --git a/tests/unit/services/lambda_/test_lambda_utils.py b/tests/unit/services/lambda_/test_lambda_utils.py new file mode 100644 index 0000000000000..4276d3b180fa1 --- /dev/null +++ b/tests/unit/services/lambda_/test_lambda_utils.py @@ -0,0 +1,37 @@ +from localstack.aws.api.lambda_ import Runtime +from localstack.services.lambda_.lambda_utils import format_name_to_path, get_handler_file_from_name + + +class TestLambdaUtils: + def test_format_name_to_path(self): + assert ".build/handler.js" == format_name_to_path(".build/handler.execute", ".", ".js") + assert "handler" == format_name_to_path("handler.execute", ".", "") + assert "CSharpHandlers.dll" == format_name_to_path( + "./CSharpHandlers::AwsDotnetCsharp.Handler::CreateProfileAsync", + ":", + ".dll", + ) + assert "test/handler.rb" == format_name_to_path("test.handler.execute", ".", ".rb") + assert "test.handler.py" == format_name_to_path("./test.handler.execute", ".", ".py") + assert "../handler.js" == format_name_to_path("../handler.execute", ".", ".js") + + def test_get_handler_file_from_name(self): + assert ".build/handler.js" == get_handler_file_from_name( + ".build/handler.execute", Runtime.nodejs16_x + ) + assert "./.build/handler.execute" == get_handler_file_from_name( + "./.build/handler.execute", Runtime.go1_x + ) + assert "CSharpHandlers.dll" == get_handler_file_from_name( + "./CSharpHandlers::AwsDotnetCsharp.Handler::CreateProfileAsync", + Runtime.dotnetcore3_1, + ) + assert "test/handler.rb" == get_handler_file_from_name( + "test.handler.execute", Runtime.ruby3_2 + ) + assert "test.handler.execute" == get_handler_file_from_name( + "test.handler.execute", Runtime.go1_x + ) + assert "main" == get_handler_file_from_name("main", Runtime.go1_x) + assert "../handler.py" == get_handler_file_from_name("../handler.execute") + assert "bootstrap" == get_handler_file_from_name("", Runtime.provided) diff --git a/tests/unit/services/logs/__init__.py b/tests/unit/services/logs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/logs/test_logs.py b/tests/unit/services/logs/test_logs.py new file mode 100644 index 0000000000000..7b395a43a9d29 --- /dev/null +++ b/tests/unit/services/logs/test_logs.py @@ -0,0 +1,14 @@ +from localstack.services.logs.provider import get_pattern_matcher + + +class TestCloudWatchLogs: + def test_get_pattern_matcher(self): + def assert_match(filter_pattern, log_event, expected): + matches = get_pattern_matcher(filter_pattern) + assert matches(filter_pattern, log_event) == expected + + # expect to always be True until proper filter methods are available + assert_match('{$.message = "Failed"}', {"message": '{"message":"Failed"}'}, True) + assert_match("ERROR", {"message": "Failed"}, True) + assert_match("", {"message": "FooBar"}, True) + assert_match("[w1=Failed]", {"message": "Failed"}, True) diff --git a/tests/unit/services/opensearch/__init__.py b/tests/unit/services/opensearch/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/opensearch/test_cluster_manager.py b/tests/unit/services/opensearch/test_cluster_manager.py new file mode 100644 index 0000000000000..662bb7e10f026 --- /dev/null +++ b/tests/unit/services/opensearch/test_cluster_manager.py @@ -0,0 +1,93 @@ +import pytest + +from localstack import config +from localstack.aws.api.opensearch import EngineType +from localstack.services.opensearch.cluster_manager import DomainKey, build_cluster_endpoint +from localstack.testing.config import TEST_AWS_ACCOUNT_ID + + +class TestBuildClusterEndpoint: + def test_endpoint_strategy_port(self, monkeypatch): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "port") + endpoint = build_cluster_endpoint(DomainKey("my-domain", "us-east-1", TEST_AWS_ACCOUNT_ID)) + parts = endpoint.split(":") + assert parts[0] == "localhost.localstack.cloud" + assert int(parts[1]) in range( + config.EXTERNAL_SERVICE_PORTS_START, config.EXTERNAL_SERVICE_PORTS_END + ) + + @pytest.mark.skipif( + condition=config.in_docker(), reason="port mapping differs when being run in the container" + ) + @pytest.mark.parametrize( + "engine", [(EngineType.OpenSearch, "opensearch"), (EngineType.Elasticsearch, "es")] + ) + def test_endpoint_strategy_path(self, monkeypatch, engine): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "path") + engine_type = engine[0] + engine_path_prefix = engine[1] + + endpoint = build_cluster_endpoint( + DomainKey("my-domain", "us-east-1", TEST_AWS_ACCOUNT_ID), engine_type=engine_type + ) + assert ( + endpoint == f"localhost.localstack.cloud:4566/{engine_path_prefix}/us-east-1/my-domain" + ) + + endpoint = build_cluster_endpoint( + DomainKey("my-domain-1", "eu-central-1", TEST_AWS_ACCOUNT_ID), engine_type=engine_type + ) + assert ( + endpoint + == f"localhost.localstack.cloud:4566/{engine_path_prefix}/eu-central-1/my-domain-1" + ) + + @pytest.mark.skipif( + condition=config.in_docker(), reason="port mapping differs when being run in the container" + ) + @pytest.mark.parametrize( + "engine", [(EngineType.OpenSearch, "opensearch"), (EngineType.Elasticsearch, "es")] + ) + def test_endpoint_strategy_domain(self, monkeypatch, engine): + monkeypatch.setattr(config, "OPENSEARCH_ENDPOINT_STRATEGY", "domain") + engine_type = engine[0] + engine_path_prefix = engine[1] + + endpoint = build_cluster_endpoint( + domain_key=DomainKey("my-domain", "us-east-1", TEST_AWS_ACCOUNT_ID), + engine_type=engine_type, + ) + assert ( + endpoint == f"my-domain.us-east-1.{engine_path_prefix}.localhost.localstack.cloud:4566" + ) + + endpoint = build_cluster_endpoint( + domain_key=DomainKey("my-domain-1", "eu-central-1", TEST_AWS_ACCOUNT_ID), + engine_type=engine_type, + ) + assert ( + endpoint + == f"my-domain-1.eu-central-1.{engine_path_prefix}.localhost.localstack.cloud:4566" + ) + + +class TestDomainKey: + def test_from_arn(self): + domain_key = DomainKey.from_arn("arn:aws:es:us-east-1:012345678901:domain/my-es-domain") + + assert domain_key.domain_name == "my-es-domain" + assert domain_key.region == "us-east-1" + assert domain_key.account == "012345678901" + + def test_arn(self): + domain_key = DomainKey( + domain_name="my-es-domain", + region="us-east-1", + account="012345678901", + ) + + assert domain_key.arn == "arn:aws:es:us-east-1:012345678901:domain/my-es-domain" + + def test_from_arn_wrong_service(self): + with pytest.raises(ValueError): + DomainKey.from_arn("arn:aws:sqs:us-east-1:012345678901:my-queue") diff --git a/tests/unit/services/s3/__init__.py b/tests/unit/services/s3/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/s3/test_s3.py b/tests/unit/services/s3/test_s3.py new file mode 100644 index 0000000000000..a01fe9d58f8c3 --- /dev/null +++ b/tests/unit/services/s3/test_s3.py @@ -0,0 +1,728 @@ +import datetime +import os +import re +import string +import zoneinfo +from io import BytesIO +from urllib.parse import urlparse + +import pytest + +from localstack.aws.api import RequestContext +from localstack.aws.api.s3 import InvalidArgument +from localstack.config import S3_VIRTUAL_HOSTNAME +from localstack.constants import LOCALHOST +from localstack.http import Request +from localstack.services.s3 import presigned_url +from localstack.services.s3 import utils as s3_utils +from localstack.services.s3.codec import AwsChunkedDecoder +from localstack.services.s3.constants import S3_CHUNK_SIZE +from localstack.services.s3.exceptions import MalformedXML +from localstack.services.s3.models import S3Multipart, S3Object, S3Part +from localstack.services.s3.storage.ephemeral import EphemeralS3ObjectStore +from localstack.services.s3.validation import validate_canned_acl + + +class TestS3Utils: + @pytest.mark.parametrize( + "path, headers, expected_bucket, expected_key", + [ + ("/bucket/keyname", {"host": f"{LOCALHOST}:4566"}, "bucket", "keyname"), + ("/bucket//keyname", {"host": f"{LOCALHOST}:4566"}, "bucket", "/keyname"), + ("/keyname", {"host": f"bucket.{S3_VIRTUAL_HOSTNAME}:4566"}, "bucket", "keyname"), + ("//keyname", {"host": f"bucket.{S3_VIRTUAL_HOSTNAME}:4566"}, "bucket", "/keyname"), + ("/", {"host": f"{S3_VIRTUAL_HOSTNAME}:4566"}, None, None), + ("/", {"host": "bucket.s3-ap-northeast-1.amazonaws.com:4566"}, "bucket", None), + ("/", {"host": "bucket.s3-ap-south-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3-eu-west-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.ap-northeast-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.ap-southeast-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.ca-central-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.cn-north-1.amazonaws.com.cn"}, "bucket", None), + ("/", {"host": "bucket.s3.dualstack.ap-northeast-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.dualstack.eu-west-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.eu-central-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.eu-west-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.sa-east-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.us-east-1.amazonaws.com"}, "bucket", None), + ("/", {"host": "bucket.s3.localhost.localstack.cloud"}, "bucket", None), + ("/", {"host": "bucket-1.s3-website.localhost.localstack.cloud"}, "bucket-1", None), + ("/", {"host": "bucket.localhost.localstack.cloud"}, None, None), + ("/", {"host": "localhost.localstack.cloud"}, None, None), + ("/", {"host": "test.dynamodb.amazonaws.com"}, None, None), + ("/", {"host": "dynamodb.amazonaws.com"}, None, None), + ("/", {"host": "bucket.s3.randomdomain.com"}, "bucket", None), + ("/", {"host": "bucket.s3.example.domain.com:4566"}, "bucket", None), + ], + ) + def test_extract_bucket_name_and_key_from_headers_and_path( + self, path, headers, expected_bucket, expected_key + ): + bucket, key = s3_utils.extract_bucket_name_and_key_from_headers_and_path(headers, path) + assert bucket == expected_bucket + assert key == expected_key + + # test whether method correctly distinguishes between hosted and path style bucket references + # path style format example: https://s3.{region}.localhost.localstack.cloud:4566/{bucket-name}/{key-name} + # hosted style format example: http://{bucket-name}.s3.{region}localhost.localstack.cloud:4566/ + # region is optional in localstack + def test_uses_virtual_host_addressing(self): + addresses = [ + ({"host": f"aws.{LOCALHOST}:4566"}, None), + ({"host": f"{LOCALHOST}.aws:4566"}, None), + ({"host": f"{LOCALHOST}.swa:4566"}, None), + ({"host": f"swa.{LOCALHOST}:4566"}, None), + ({"host": "bucket.s3.localhost.localstack.cloud"}, "bucket"), + ({"host": "bucket.s3.eu-west-1.amazonaws.com"}, "bucket"), + ({"host": "s3.eu-west-1.localhost.localstack.cloud/bucket"}, None), + ({"host": "s3.localhost.localstack.cloud"}, None), + ({"host": "s3.localhost.localstack.cloud:4566"}, None), + ({"host": "bucket.s3.eu-west-1.localhost.localstack.cloud"}, "bucket"), + ({"host": "bucket.s3.localhost.localstack.cloud/key"}, "bucket"), + ({"host": "bucket.s3.eu-west-1.amazonaws.com"}, "bucket"), + ({"host": "bucket.s3.amazonaws.com"}, "bucket"), + ({"host": "notabucket.amazonaws.com"}, None), + ({"host": "s3.amazonaws.com"}, None), + ({"host": "s3.eu-west-1.amazonaws.com"}, None), + ({"host": "tests3.eu-west-1.amazonaws.com"}, None), + ] + for headers, expected_result in addresses: + assert s3_utils.uses_host_addressing(headers) == expected_result + + def test_virtual_host_matching(self): + hosts = [ + ("bucket.s3.localhost.localstack.cloud", "bucket", None), + ("bucket.s3.eu-west-1.amazonaws.com", "bucket", "eu-west-1"), + ("test-bucket.s3.eu-west-1.localhost.localstack.cloud", "test-bucket", "eu-west-1"), + ("bucket.s3.notrealregion-west-1.localhost.localstack.cloud", "bucket", None), + ("mybucket.s3.amazonaws.com", "mybucket", None), + ] + compiled_regex = re.compile(s3_utils.S3_VIRTUAL_HOSTNAME_REGEX) + for host, bucket_name, region_name in hosts: + result = compiled_regex.match(host) + assert result.group("bucket") == bucket_name + assert result.group("region") == region_name + + def test_is_valid_canonical_id(self): + canonical_ids = [ + ( + "0f84b30102b8e116121884e982fedc9d76715877fc810605f7ba5dca143b3bb0", + True, + ), # 64 len hex string + ("f945fc46e86d3af9b2ebf8bda159f94b8f6be81413a5a2e21e8fd3a059de55a9", True), + ("73E7AFD3413526244BDA3D3E08CF191115773EFF5D875B4860963A71AB7C13E6", True), + ("0f84b30102b8e116121884e982fedc9d76715877fc810605f7ba5dca143b3bb", False), + ("0f84b30102b8e116121884e982fedc9d76715877fc810605f7ba5dca143b3bb00", False), + ("0f84b30102b8e116121884e982fedc9d76715877fc810605f7ba5dca143b3bbz", False), + ("KXy1MCaCAUmbwQGOqVkJrzIDEbDPg4mLwMMzj8CyFdmbZx-JAm158soGrLlPZwXG", False), + ("KXy1MCaCAUmbwQGOqVkJrzIDEbDPg4mLwMMzj8CyFdmbZx", False), + ] + for canonical_id, expected_result in canonical_ids: + assert s3_utils.is_valid_canonical_id(canonical_id) == expected_result + + @pytest.mark.parametrize( + "request_member, permission, response_header", + [ + ("GrantFullControl", "FULL_CONTROL", "x-amz-grant-full-control"), + ("GrantRead", "READ", "x-amz-grant-read"), + ("GrantReadACP", "READ_ACP", "x-amz-grant-read-acp"), + ("GrantWrite", "WRITE", "x-amz-grant-write"), + ("GrantWriteACP", "WRITE_ACP", "x-amz-grant-write-acp"), + ], + ) + def test_get_permission_from_request_header_to_response_header( + self, request_member, permission, response_header + ): + """ + Test to transform shape member names into their header location + We could maybe use the specs for this + """ + parsed_permission = s3_utils.get_permission_from_header(request_member) + assert parsed_permission == permission + assert s3_utils.get_permission_header_name(parsed_permission) == response_header + + @pytest.mark.parametrize( + "canned_acl, raise_exception", + [ + ("private", False), + ("public-read", False), + ("public-read-write", False), + ("authenticated-read", False), + ("aws-exec-read", False), + ("bucket-owner-read", False), + ("bucket-owner-full-control", False), + ("not-a-canned-one", True), + ("aws--exec-read", True), + ("log-delivery-write", False), + ], + ) + def test_validate_canned_acl(self, canned_acl, raise_exception): + if raise_exception: + with pytest.raises(InvalidArgument) as e: + validate_canned_acl(canned_acl) + assert e.value.ArgumentName == "x-amz-acl" + assert e.value.ArgumentValue == canned_acl + + else: + validate_canned_acl(canned_acl) + + def test_s3_bucket_name(self): + bucket_names = [ + ("docexamplebucket1", True), + ("log-delivery-march-2020", True), + ("my-hosted-content", True), + ("docexamplewebsite.com", True), + ("www.docexamplewebsite.com", True), + ("my.example.s3.bucket", True), + ("doc_example_bucket", False), + ("DocExampleBucket", False), + ("doc-example-bucket-", False), + ] + + for bucket_name, expected_result in bucket_names: + assert s3_utils.is_bucket_name_valid(bucket_name) == expected_result + + @pytest.mark.parametrize( + "presign_url, expected_output_bucket, expected_output_key", + [ + pytest.param( + "http://s3.localhost.localstack.cloud:4566/test-output-bucket-2/test-transcribe-job-e1895bdf.json?AWSAccessKeyId=000000000000&Signature=2Yc%2BvwhXx8UzmH8imzySfLOW6OI%3D&Expires=1688561914", + "test-output-bucket-2", + "test-transcribe-job-e1895bdf.json", + id="output key as a single file", + ), + pytest.param( + "http://s3.localhost.localstack.cloud:4566/test-output-bucket-5/test-files/test-output.json?AWSAccessKeyId=000000000000&Signature=F6bwF1M2N%2BLzEXTZnUtjE23S%2Bb0%3D&Expires=1688561920", + "test-output-bucket-5", + "test-files/test-output.json", + id="output key with subdirectories", + ), + pytest.param( + "http://s3.localhost.localstack.cloud:4566/test-output-bucket-2?AWSAccessKeyId=000000000000&Signature=2Yc%2BvwhXx8UzmH8imzySfLOW6OI%3D&Expires=1688561914", + "test-output-bucket-2", + "", + id="output key as None", + ), + ], + ) + def test_bucket_and_key_presign_url( + self, presign_url, expected_output_bucket, expected_output_key + ): + bucket, key = s3_utils.get_bucket_and_key_from_presign_url(presign_url) + assert bucket == expected_output_bucket + assert key == expected_output_key + + @pytest.mark.parametrize( + "header, dateobj, rule_id", + [ + ( + 'expiry-date="Sat, 15 Jul 2023 00:00:00 GMT", rule-id="rule1"', + datetime.datetime(day=15, month=7, year=2023, tzinfo=zoneinfo.ZoneInfo(key="GMT")), + "rule1", + ), + ( + 'expiry-date="Mon, 29 Dec 2030 00:00:00 GMT", rule-id="rule2"', + datetime.datetime(day=29, month=12, year=2030, tzinfo=zoneinfo.ZoneInfo(key="GMT")), + "rule2", + ), + ( + 'expiry-date="Tes, 32 Jul 2023 00:00:00 GMT", rule-id="rule3"', + None, + None, + ), + ( + 'expiry="Sat, 15 Jul 2023 00:00:00 GMT", rule-id="rule4"', + None, + None, + ), + ( + 'expiry-date="Sat, 15 Jul 2023 00:00:00 GMT"', + None, + None, + ), + ], + ) + def test_parse_expiration_header(self, header, dateobj, rule_id): + parsed_dateobj, parsed_rule_id = s3_utils.parse_expiration_header(header) + assert parsed_dateobj == dateobj + assert parsed_rule_id == rule_id + + @pytest.mark.parametrize( + "rule_id, lifecycle_exp, last_modified, header", + [ + ( + "rule1", + { + "Date": datetime.datetime( + day=15, month=7, year=2023, tzinfo=zoneinfo.ZoneInfo(key="GMT") + ) + }, + datetime.datetime( + day=15, + month=9, + year=2024, + hour=0, + minute=0, + second=0, + microsecond=0, + tzinfo=None, + ), + 'expiry-date="Sat, 15 Jul 2023 00:00:00 GMT", rule-id="rule1"', + ), + ( + "rule2", + {"Days": 5}, + datetime.datetime(day=15, month=7, year=2023, tzinfo=None), + 'expiry-date="Fri, 21 Jul 2023 00:00:00 GMT", rule-id="rule2"', + ), + ( + "rule3", + {"Days": 3}, + datetime.datetime(day=31, month=12, year=2030, microsecond=1, tzinfo=None), + 'expiry-date="Sat, 04 Jan 2031 00:00:00 GMT", rule-id="rule3"', + ), + ], + ) + def test_serialize_expiration_header(self, rule_id, lifecycle_exp, last_modified, header): + serialized_header = s3_utils.serialize_expiration_header( + rule_id, lifecycle_exp, last_modified + ) + assert serialized_header == header + + @pytest.mark.parametrize( + "data, required, optional, result", + [ + ( + {"field1": "", "field2": "", "field3": ""}, + {"field1"}, + {"field2", "field3"}, + True, + ), + ( + {"field1": ""}, + {"field1"}, + {"field2", "field3"}, + True, + ), + ( + {"field1": "", "field2": "", "field3": ""}, # field3 is not a field + {"field1"}, + {"field2"}, + False, + ), + ( + {"field2": ""}, # missing field1 + {"field1"}, + {"field2"}, + False, + ), + ( + {"field3": ""}, # missing field1 and field3 is not a field + {"field1"}, + {"field2"}, + False, + ), + ], + ) + def test_validate_dict_fields(self, data, required, optional, result): + assert s3_utils.validate_dict_fields(data, required, optional) == result + + @pytest.mark.parametrize( + "tagging, result", + [ + ( + "TagNameTagValue", + {"TagName": "TagValue"}, + ), + ( + "TagNameTagValueTagName2TagValue2", + {"TagName": "TagValue", "TagName2": "TagValue2"}, + ), + ( + "", + None, + ), + ], + ids=["single", "list", "invalid"], + ) + def test_parse_post_object_tagging_xml(self, tagging, result): + assert s3_utils.parse_post_object_tagging_xml(tagging) == result + + def test_parse_post_object_tagging_xml_exception(self): + with pytest.raises(MalformedXML) as e: + s3_utils.parse_post_object_tagging_xml("not-xml") + e.match( + "The XML you provided was not well-formed or did not validate against our published schema" + ) + + @pytest.mark.parametrize( + "s3_uri, bucket, object_key", + [ + ("s3://test-bucket/key/test", "test-bucket", "key/test"), + ("test-bucket/key/test", "test-bucket", "key/test"), + ("s3://test-bucket", "test-bucket", ""), + ("", "", ""), + ("s3://test-bucket/test%2Ftest", "test-bucket", "test%2Ftest"), + ], + ) + def test_get_bucket_and_key_from_s3_uri(self, s3_uri, bucket, object_key): + assert s3_utils.get_bucket_and_key_from_s3_uri(s3_uri) == (bucket, object_key) + + +class TestS3PresignedUrl: + """ + Testing utils from the new Presigned URL validation with ASF + """ + + @staticmethod + def _create_fake_context_from_path(path: str, method: str = "GET"): + request = Request( + method=method, path=path, query_string=urlparse(f"http://localhost{path}").query + ) + fake_context = RequestContext(request) + return fake_context + + def test_is_presigned_url_request(self): + request_paths = [ + ( + "GET", + "/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + ), + ( + "PUT", + "/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + ), + ( + "GET", + "/?acl&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + ), + ( + "GET", + "/?acl&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + ), + ( + "GET", + "/?acl&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=testX-Amz-Expires=test&X-Amz-SignedHeaders=host", + True, + ), + ( + "GET", + "/?X-Amz-Credential=test&X-Amz-Date=testX-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + ), + ("GET", "/?AWSAccessKeyId=test&Signature=test&Expires=test", True), + ("GET", "/?acl&AWSAccessKeyId=test&Signature=test&Expires=test", True), + ("GET", "/?acl&AWSAccessKey=test", False), + ("GET", "/?acl", False), + ( + "GET", + "/?x-Amz-Credential=test&x-Amz-Date=testx-Amz-Expires=test&x-Amz-SignedHeaders=host&x-Amz-Signature=test", + False, + ), + ] + + for method, request_path, expected_result in request_paths: + fake_context = self._create_fake_context_from_path(path=request_path, method=method) + assert presigned_url.is_presigned_url_request(fake_context) == expected_result, ( + request_path + ) + + def test_is_valid_presigned_url_v2(self): + # structure: method, path, is_sig_v2, will_raise + request_paths = [ + ( + "GET", + "/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + False, + False, + ), + ("GET", "/?acl", False, False), + ("GET", "/?AWSAccessKeyId=test&Signature=test&Expires=test", True, False), + ("GET", "/?acl&AWSAccessKeyId=test&Signature=test&Expires=test", True, False), + ("GET", "/?acl&AWSAccessKey=test", False, False), + ("GET", "/?acl&AWSAccessKeyId=test", False, True), + ] + + for method, request_path, is_sig_v2, will_raise in request_paths: + fake_context = self._create_fake_context_from_path(request_path, method) + query_args = set(fake_context.request.args) + if not will_raise: + assert presigned_url.is_valid_sig_v2(query_args) == is_sig_v2 + else: + with pytest.raises(Exception): + presigned_url.is_valid_sig_v2(query_args) + + def test_is_valid_presigned_url_v4(self): + # structure: method, path, is_sig_v4, will_raise + request_paths = [ + ( + "GET", + "/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + False, + ), + ("GET", "/?acl", False, False), + ("GET", "/?AWSAccessKeyId=test&Signature=test&Expires=test", False, False), + ( + "GET", + "/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + False, + ), + ( + "PUT", + "/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + False, + ), + ( + "GET", + "/?acl&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=test&X-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + False, + ), + ( + "GET", + "/?acl&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=testX-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + True, + ), + ( + "GET", + "/?acl&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test&X-Amz-Date=testX-Amz-Expires=test&X-Amz-SignedHeaders=host", + True, + True, + ), + ( + "GET", + "/?X-Amz-Credential=test&X-Amz-Date=testX-Amz-Expires=test&X-Amz-SignedHeaders=host&X-Amz-Signature=test", + True, + True, + ), + ] + + for method, request_path, is_sig_v4, will_raise in request_paths: + fake_context = self._create_fake_context_from_path(request_path, method) + query_args = set(fake_context.request.args) + if not will_raise: + assert presigned_url.is_valid_sig_v4(query_args) == is_sig_v4 + else: + with pytest.raises(Exception): + presigned_url.is_valid_sig_v4(query_args) + + +class TestS3AwsChunkedDecoder: + """See https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html""" + + def test_s3_aws_chunked_decoder(self): + body = "Hello\r\n\r\n\r\n\r\n" + decoded_content_length = len(body) + data = ( + "d;chunk-signature=af5e6c0a698b0192e9aa5d9083553d4d241d81f69ec62b184d05c509ad5166af\r\n" + f"{body}\r\n0;chunk-signature=f2a50a8c0ad4d212b579c2489c6d122db88d8a0d0b987ea1f3e9d081074a5937\r\n" + ) + + stream = AwsChunkedDecoder(BytesIO(data.encode()), decoded_content_length) + assert stream.read() == body.encode() + + def test_s3_aws_chunked_decoder_with_trailing_headers(self): + body = "Hello Blob" + decoded_content_length = len(body) + + data = ( + "a;chunk-signature=b5311ac60a88890e740a41e74f3d3b03179fd058b1e24bb3ab224042377c4ec9\r\n" + f"{body}\r\n" + "0;chunk-signature=78fae1c533e34dbaf2b83ad64ff02e4b64b7bc681ea76b6acf84acf1c48a83cb\r\n" + f"x-amz-checksum-sha256:abcdef1234\r\n" + "x-amz-trailer-signature:712fb67227583c88ac32f468fc30a249cf9ceeb0d0e947ea5e5209a10b99181c\r\n\r\n" + ) + + stream = AwsChunkedDecoder(BytesIO(data.encode()), decoded_content_length) + assert stream.read() == body.encode() + assert stream.trailing_headers == { + "x-amz-checksum-sha256": "abcdef1234", + "x-amz-trailer-signature": "712fb67227583c88ac32f468fc30a249cf9ceeb0d0e947ea5e5209a10b99181c", + } + + def test_s3_aws_chunked_decoder_multiple_chunks(self): + total_body = os.urandom(66560) + decoded_content_length = len(total_body) + chunk_size = 8192 + encoded_data = b"" + + for index in range(0, decoded_content_length, chunk_size): + chunk = total_body[index : min(index + chunk_size, decoded_content_length)] + chunk_size_hex = str(hex(len(chunk)))[2:].encode() + info_chunk = ( + chunk_size_hex + + b";chunk-signature=af5e6c0a698b0192e9aa5d9083553d4d241d81f69ec62b184d05c509ad5166af\r\n" + ) + encoded_data += info_chunk + encoded_data += chunk + b"\r\n" + + encoded_data += b"0;chunk-signature=f2a50a8c0ad4d212b579c2489c6d122db88d8a0d0b987ea1f3e9d081074a5937\r\n" + + stream = AwsChunkedDecoder(BytesIO(encoded_data), decoded_content_length) + assert stream.read() == total_body + + stream = AwsChunkedDecoder(BytesIO(encoded_data), decoded_content_length) + # assert that even if we read more than a chunk size, we will get max chunk_size + assert stream.read(chunk_size + 1000) == total_body[:chunk_size] + # assert that even if we read more, when accessing the rest, we're still at the same position + assert stream.read(10) == total_body[chunk_size : chunk_size + 10] + + def test_s3_aws_chunked_decoder_access_trailing(self): + body = "Hello\r\n\r\n\r\n\r\n" + decoded_content_length = len(body) + data = ( + "d;chunk-signature=af5e6c0a698b0192e9aa5d9083553d4d241d81f69ec62b184d05c509ad5166af\r\n" + f"{body}\r\n0;chunk-signature=f2a50a8c0ad4d212b579c2489c6d122db88d8a0d0b987ea1f3e9d081074a5937\r\n" + ) + + stream = AwsChunkedDecoder(BytesIO(data.encode()), decoded_content_length) + with pytest.raises(AttributeError) as e: + _ = stream.trailing_headers + e.match("The stream has not been fully read yet, the trailing headers are not available.") + + stream.read() + assert stream.trailing_headers == {} + + def test_s3_aws_chunked_decoder_chunk_bigger_than_s3_chunk(self): + total_body = os.urandom(S3_CHUNK_SIZE * 2) + decoded_content_length = len(total_body) + chunk_size = S3_CHUNK_SIZE + 10 + encoded_data = b"" + + for index in range(0, decoded_content_length, chunk_size): + chunk = total_body[index : min(index + chunk_size, decoded_content_length)] + chunk_size_hex = str(hex(len(chunk)))[2:].encode() + info_chunk = ( + chunk_size_hex + + b";chunk-signature=af5e6c0a698b0192e9aa5d9083553d4d241d81f69ec62b184d05c509ad5166af\r\n" + ) + encoded_data += info_chunk + encoded_data += chunk + b"\r\n" + + encoded_data += b"0;chunk-signature=f2a50a8c0ad4d212b579c2489c6d122db88d8a0d0b987ea1f3e9d081074a5937\r\n" + + stream = AwsChunkedDecoder(BytesIO(encoded_data), decoded_content_length) + assert stream.read() == total_body + + stream = AwsChunkedDecoder(BytesIO(encoded_data), decoded_content_length) + # assert that even if we read more than a chunk size, we will get max chunk_size + assert stream.read(chunk_size + 1000) == total_body[:chunk_size] + # assert that even if we read more, when accessing the rest, we're still at the same position + assert stream.read(10) == total_body[chunk_size : chunk_size + 10] + + +class TestS3TemporaryStorageBackend: + def test_get_fileobj_no_bucket(self, tmpdir): + temp_storage_backend = EphemeralS3ObjectStore(root_directory=tmpdir) + fake_object = S3Object(key="test-key") + with temp_storage_backend.open("test-bucket", fake_object, mode="w") as s3_stored_object: + s3_stored_object.write(BytesIO(b"abc")) + + assert s3_stored_object.read() == b"abc" + + s3_stored_object.seek(1) + assert s3_stored_object.read() == b"bc" + + s3_stored_object.seek(0) + assert s3_stored_object.read(1) == b"a" + + temp_storage_backend.remove("test-bucket", fake_object) + assert s3_stored_object.file.closed + + temp_storage_backend.close() + + def test_ephemeral_multipart(self, tmpdir): + temp_storage_backend = EphemeralS3ObjectStore(root_directory=tmpdir) + fake_multipart = S3Multipart(key="test-multipart") + + s3_stored_multipart = temp_storage_backend.get_multipart("test-bucket", fake_multipart) + parts = [] + stored_parts = [] + for i in range(1, 6): + fake_s3_part = S3Part(part_number=i) + with s3_stored_multipart.open(fake_s3_part, mode="w") as stored_part: + stored_part.write(BytesIO(b"abc")) + parts.append(fake_s3_part) + stored_parts.append(stored_part) + + s3_stored_multipart.complete_multipart(parts=parts) + temp_storage_backend.remove_multipart("test-bucket", fake_multipart) + + fake_object = S3Object(key="test-multipart") + with temp_storage_backend.open( + bucket="test-bucket", s3_object=fake_object, mode="r" + ) as s3_stored_object: + assert s3_stored_object.read() == b"abc" * 5 + + assert all(stored_part.file.closed for stored_part in stored_parts) + + temp_storage_backend.close() + assert s3_stored_object.file.closed + + def test_concurrent_file_access(self, tmpdir): + temp_storage_backend = EphemeralS3ObjectStore(root_directory=tmpdir) + fake_object = S3Object(key="test-key") + + with temp_storage_backend.open("test-bucket", fake_object, mode="w") as s3_object_writer: + s3_object_writer.write(BytesIO(b"abc")) + + with ( + temp_storage_backend.open("test-bucket", fake_object, mode="r") as s3_stored_object_1, + temp_storage_backend.open("test-bucket", fake_object, mode="r") as s3_stored_object_2, + ): + assert s3_stored_object_1.read() == b"abc" + + # assert that another StoredObject moving the position does not influence the other object + s3_stored_object_1.seek(1) + s3_stored_object_2.seek(2) + assert s3_stored_object_1.read() == b"bc" + assert s3_stored_object_2.read() == b"c" + + s3_stored_object_1.seek(0) + assert s3_stored_object_1.read(1) == b"a" + + temp_storage_backend.remove("test-bucket", fake_object) + assert s3_stored_object_1.file.closed + assert s3_stored_object_2.file.closed + + temp_storage_backend.close() + + def test_s3_context_manager(self, tmpdir): + temp_storage_backend = EphemeralS3ObjectStore(root_directory=tmpdir) + fake_object = S3Object(key="test-key") + s3_stored_object_1 = temp_storage_backend.open("test-bucket", fake_object, mode="w") + s3_stored_object_1.write(BytesIO(b"abc")) + s3_stored_object_1.close() + # you can't call a context manager __enter__ on a closed S3 Object + with pytest.raises(ValueError): + with s3_stored_object_1: + pass + + temp_storage_backend.close() + + +class TestS3VersionIdGenerator: + def test_version_is_xml_safe(self): + # assert than we don't have unsafe characters in 500 different versions id + safe_characters = string.ascii_letters + string.digits + "._" + assert all( + all(char in safe_characters for char in s3_utils.generate_safe_version_id()) + for _ in range(500) + ) + + def test_version_id_ordering(self): + version_ids = [s3_utils.generate_safe_version_id() for _ in range(500)] + + # assert that every version id can be ordered with each other + for index, version_id in enumerate(version_ids[1:]): + previous_version = version_ids[index] + assert s3_utils.is_version_older_than_other(previous_version, version_id) diff --git a/tests/unit/services/s3/test_s3_checksum.py b/tests/unit/services/s3/test_s3_checksum.py new file mode 100644 index 0000000000000..790f171aeb1d4 --- /dev/null +++ b/tests/unit/services/s3/test_s3_checksum.py @@ -0,0 +1,65 @@ +import base64 + +import pytest + +from localstack.services.s3 import checksums +from localstack.services.s3.utils import S3CRC32Checksum + + +@pytest.mark.parametrize("checksum_type", ["CRC32", "CRC32C", "CRC64NVME"]) +def test_s3_checksum_combine(checksum_type): + match checksum_type: + case "CRC32": + checksum = S3CRC32Checksum + combine_function = checksums.combine_crc32 + case "CRC32C": + from botocore.httpchecksum import CrtCrc32cChecksum + + checksum = CrtCrc32cChecksum + combine_function = checksums.combine_crc32c + case "CRC64NVME": + from botocore.httpchecksum import CrtCrc64NvmeChecksum + + checksum = CrtCrc64NvmeChecksum + combine_function = checksums.combine_crc64_nvme + case _: + raise f"Bad parameter value! {checksum_type}" + + part_1 = b"123" + part_2 = b"456" + part_3 = b"789" + + checksum_1 = checksum() + checksum_2 = checksum() + checksum_3 = checksum() + + checksum_1.update(part_1) + checksum_2.update(part_2) + checksum_3.update(part_3) + + # those are the validation checksums + checksum_sum_1 = checksum() + checksum_sum_total = checksum() + + checksum_sum_1.update(part_1 + part_2) + checksum_sum_total.update(part_1 + part_2 + part_3) + + digest_1 = checksum_1.digest() + digest_2 = checksum_2.digest() + digest_3 = checksum_3.digest() + + digest_sum_1 = checksum_sum_1.digest() + digest_sum_total = checksum_sum_total.digest() + + crc_partial_1 = base64.b64encode(digest_sum_1).decode() + crc_total = base64.b64encode(digest_sum_total).decode() + + # we combine the part 1 and part 2 + combined = combine_function(digest_1, digest_2, len(part_2)) + assert combined == digest_sum_1 + assert base64.b64encode(combined).decode() == crc_partial_1 + + # we now combine the partial checksum of 1 + 2 with the last part + combined_partial_and_last_part = combine_function(combined, digest_3, len(part_3)) + assert combined_partial_and_last_part == digest_sum_total + assert base64.b64encode(combined_partial_and_last_part).decode() == crc_total diff --git a/tests/unit/services/sns/__init__.py b/tests/unit/services/sns/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/sns/test_sns.py b/tests/unit/services/sns/test_sns.py new file mode 100644 index 0000000000000..545ea6d39c1ed --- /dev/null +++ b/tests/unit/services/sns/test_sns.py @@ -0,0 +1,988 @@ +import base64 +import json +import re +import uuid +from base64 import b64encode + +import dateutil.parser +import pytest + +from localstack.aws.api.sns import InvalidParameterException +from localstack.services.sns.filter import FilterPolicyValidator, SubscriptionFilter +from localstack.services.sns.models import SnsMessage +from localstack.services.sns.provider import ( + encode_subscription_token_with_region, + get_region_from_subscription_token, + is_raw_message_delivery, +) +from localstack.services.sns.publisher import ( + compute_canonical_string, + create_sns_message_body, +) +from localstack.utils.time import timestamp_millis + + +@pytest.fixture +def subscriber(): + return { + "SubscriptionArn": "arn:aws:sns:jupiter-south-1:123456789012:MyTopic:6b0e71bd-7e97-4d97-80ce-4a0994e55286", + "Protocol": "sqs", + "RawMessageDelivery": "false", + "TopicArn": "arn", + } + + +@pytest.mark.usefixtures("subscriber") +class TestSns: + def test_create_sns_message_body_raw_message_delivery(self, subscriber): + subscriber["RawMessageDelivery"] = "true" + message_ctx = SnsMessage( + message="msg", + type="Notification", + ) + result = create_sns_message_body(message_ctx, subscriber) + assert "msg" == result + + def test_create_sns_message_body(self, subscriber): + message_ctx = SnsMessage( + message="msg", + type="Notification", + ) + result_str = create_sns_message_body(message_ctx, subscriber) + result = json.loads(result_str) + try: + uuid.UUID(result.pop("MessageId")) + except KeyError: + raise AssertionError("MessageId missing in SNS response message body") + except ValueError: + raise AssertionError("SNS response MessageId not a valid UUID") + + try: + dateutil.parser.parse(result.pop("Timestamp")) + except KeyError: + raise AssertionError("Timestamp missing in SNS response message body") + except ValueError: + raise AssertionError("SNS response Timestamp not a valid ISO 8601 date") + + try: + base64.b64decode(result.pop("Signature")) + except KeyError: + raise AssertionError("Signature missing in SNS response message body") + except ValueError: + raise AssertionError("SNS response Signature is not a valid base64 encoded value") + + expected_sns_body = { + "Message": "msg", + "SignatureVersion": "1", + "SigningCertURL": "http://localhost.localstack.cloud:4566/_aws/sns/SimpleNotificationService-6c6f63616c737461636b69736e696365.pem", + "TopicArn": "arn", + "Type": "Notification", + "UnsubscribeURL": f"http://localhost.localstack.cloud:4566/?Action=Unsubscribe&SubscriptionArn={subscriber['SubscriptionArn']}", + } + + assert expected_sns_body == result + + # Now add a subject and message attributes + message_attributes = { + "attr1": { + "DataType": "String", + "StringValue": "value1", + }, + "attr2": { + "DataType": "Binary", + "BinaryValue": b"\x02\x03\x04", + }, + } + message_ctx = SnsMessage( + type="Notification", + message="msg", + subject="subject", + message_attributes=message_attributes, + ) + result_str = create_sns_message_body(message_ctx, subscriber) + result = json.loads(result_str) + del result["MessageId"] + del result["Timestamp"] + del result["Signature"] + msg = { + "Message": "msg", + "Subject": "subject", + "SignatureVersion": "1", + "SigningCertURL": "http://localhost.localstack.cloud:4566/_aws/sns/SimpleNotificationService-6c6f63616c737461636b69736e696365.pem", + "TopicArn": "arn", + "Type": "Notification", + "UnsubscribeURL": f"http://localhost.localstack.cloud:4566/?Action=Unsubscribe&SubscriptionArn={subscriber['SubscriptionArn']}", + "MessageAttributes": { + "attr1": { + "Type": "String", + "Value": "value1", + }, + "attr2": { + "Type": "Binary", + "Value": b64encode(b"\x02\x03\x04").decode("utf-8"), + }, + }, + } + assert msg == result + + def test_create_sns_message_body_json_structure(self, subscriber): + message_ctx = SnsMessage( + type="Notification", + message=json.loads('{"default": {"message": "abc"}}'), + message_structure="json", + ) + + result_str = create_sns_message_body(message_ctx, subscriber) + result = json.loads(result_str) + + assert {"message": "abc"} == result["Message"] + + def test_create_sns_message_body_json_structure_raw_delivery(self, subscriber): + subscriber["RawMessageDelivery"] = "true" + message_ctx = SnsMessage( + type="Notification", + message=json.loads('{"default": {"message": "abc"}}'), + message_structure="json", + ) + + result = create_sns_message_body(message_ctx, subscriber) + + assert {"message": "abc"} == result + + def test_create_sns_message_body_json_structure_sqs_protocol(self, subscriber): + message_ctx = SnsMessage( + type="Notification", + message=json.loads('{"default": "default message", "sqs": "sqs message"}'), + message_structure="json", + ) + + result_str = create_sns_message_body(message_ctx, subscriber) + result = json.loads(result_str) + assert "sqs message" == result["Message"] + + def test_create_sns_message_body_json_structure_raw_delivery_sqs_protocol(self, subscriber): + subscriber["RawMessageDelivery"] = "true" + message_ctx = SnsMessage( + type="Notification", + message=json.loads( + '{"default": {"message": "default version"}, "sqs": {"message": "sqs version"}}' + ), + message_structure="json", + ) + + result = create_sns_message_body(message_ctx, subscriber) + + assert {"message": "sqs version"} == result + + def test_create_sns_message_timestamp_millis(self, subscriber): + message_ctx = SnsMessage( + type="Notification", + message="msg", + ) + + result_str = create_sns_message_body(message_ctx, subscriber) + result = json.loads(result_str) + timestamp = result.pop("Timestamp") + end = timestamp[-5:] + matcher = re.compile(r"\.[0-9]{3}Z") + match = matcher.match(end) + assert match + + def test_filter_policy(self): + test_data = [ + ("no filter with no attributes", {}, {}, True), + ( + "no filter with attributes", + {}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "exact string filter", + {"filter": "type1"}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "exact string filter on an array", + {"filter": "soccer"}, + { + "filter": { + "Type": "String.Array", + "Value": '["soccer", "rugby", "hockey"]', + } + }, + True, + ), + ("exact string filter with no attributes", {"filter": "type1"}, {}, False), + ( + "exact string filter with no match", + {"filter": "type1"}, + {"filter": {"Type": "String", "Value": "type2"}}, + False, + ), + ( + "or string filter with match", + {"filter": ["type1", "type2"]}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "or string filter with other match", + {"filter": ["type1", "type2"]}, + {"filter": {"Type": "String", "Value": "type2"}}, + True, + ), + ( + "or string filter match with an array", + {"filter": ["soccer", "basketball"]}, + { + "filter": { + "Type": "String.Array", + "Value": '["soccer", "rugby", "hockey"]', + } + }, + True, + ), + ( + "or string filter with no attributes", + {"filter": ["type1", "type2"]}, + {}, + False, + ), + ( + "or string filter with no match", + {"filter": ["type1", "type2"]}, + {"filter": {"Type": "String", "Value": "type3"}}, + False, + ), + ( + "or string filter no match with an array", + {"filter": ["volleyball", "basketball"]}, + { + "filter": { + "Type": "String.Array", + "Value": '["soccer", "rugby", "hockey"]', + } + }, + False, + ), + ( + "anything-but string filter with match", + {"filter": [{"anything-but": "type1"}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + False, + ), + ( + "anything-but string filter with no match", + {"filter": [{"anything-but": "type1"}]}, + {"filter": {"Type": "String", "Value": "type2"}}, + True, + ), + ( + "anything-but list filter with match", + {"filter": [{"anything-but": ["type1", "type2"]}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + False, + ), + ( + "anything-but list filter with no match", + {"filter": [{"anything-but": ["type1", "type3"]}]}, + {"filter": {"Type": "String", "Value": "type2"}}, + True, + ), + ( + "anything-but string filter with prefix match", + {"filter": [{"anything-but": {"prefix": "type"}}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + False, + ), + ( + "anything-but string filter with no prefix match", + {"filter": [{"anything-but": {"prefix": "type-"}}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "prefix string filter with match", + {"filter": [{"prefix": "typ"}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "prefix string filter match with an array", + {"filter": [{"prefix": "soc"}]}, + { + "filter": { + "Type": "String.Array", + "Value": '["soccer", "rugby", "hockey"]', + } + }, + True, + ), + ( + "prefix string filter with no match", + {"filter": [{"prefix": "test"}]}, + {"filter": {"Type": "String", "Value": "type2"}}, + False, + ), + ( + "suffix string filter with match", + {"filter": [{"suffix": "pe1"}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "suffix string filter match with an array", + {"filter": [{"suffix": "gby"}]}, + { + "filter": { + "Type": "String.Array", + "Value": '["soccer", "rugby", "hockey"]', + } + }, + True, + ), + ( + "suffix string filter with no match", + {"filter": [{"suffix": "test"}]}, + {"filter": {"Type": "String", "Value": "type2"}}, + False, + ), + ( + "equals-ignore-case string filter with match", + {"filter": [{"equals-ignore-case": "TYPE1"}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "equals-ignore-case string filter match with an array", + {"filter": [{"equals-ignore-case": "RuGbY"}]}, + { + "filter": { + "Type": "String.Array", + "Value": '["soccer", "rugby", "hockey"]', + } + }, + True, + ), + ( + "equals-ignore-case string filter with no match", + {"filter": [{"equals-ignore-case": "test"}]}, + {"filter": {"Type": "String", "Value": "type2"}}, + False, + ), + ( + "numeric = filter with match", + {"filter": [{"numeric": ["=", 300]}]}, + {"filter": {"Type": "Number", "Value": 300}}, + True, + ), + ( + "numeric = filter with no match", + {"filter": [{"numeric": ["=", 300]}]}, + {"filter": {"Type": "Number", "Value": 301}}, + False, + ), + ( + "numeric > filter with match", + {"filter": [{"numeric": [">", 300]}]}, + {"filter": {"Type": "Number", "Value": 301}}, + True, + ), + ( + "numeric > filter with no match", + {"filter": [{"numeric": [">", 300]}]}, + {"filter": {"Type": "Number", "Value": 300}}, + False, + ), + ( + "numeric < filter with match", + {"filter": [{"numeric": ["<", 300]}]}, + {"filter": {"Type": "Number", "Value": 299}}, + True, + ), + ( + "numeric < filter with no match", + {"filter": [{"numeric": ["<", 300]}]}, + {"filter": {"Type": "Number", "Value": 300}}, + False, + ), + ( + "numeric >= filter with match", + {"filter": [{"numeric": [">=", 300]}]}, + {"filter": {"Type": "Number", "Value": 300}}, + True, + ), + ( + "numeric >= filter with no match", + {"filter": [{"numeric": [">=", 300]}]}, + {"filter": {"Type": "Number", "Value": 299}}, + False, + ), + ( + "numeric <= filter with match", + {"filter": [{"numeric": ["<=", 300]}]}, + {"filter": {"Type": "Number", "Value": 300}}, + True, + ), + ( + "numeric <= filter with no match", + {"filter": [{"numeric": ["<=", 300]}]}, + {"filter": {"Type": "Number", "Value": 301}}, + False, + ), + ( + "numeric filter with bad data", + {"filter": [{"numeric": ["=", 300]}]}, + {"filter": {"Type": "String", "Value": "test"}}, + False, + ), + ( + "logical OR with match", + {"filter": ["test1", "test2", {"prefix": "typ"}]}, + {"filter": {"Type": "String", "Value": "test2"}}, + True, + ), + ( + "logical OR with match", + {"filter": ["test1", "test2", {"prefix": "typ"}]}, + {"filter": {"Type": "String", "Value": "test1"}}, + True, + ), + ( + "logical OR with match on an array", + {"filter": ["test1", "test2", {"prefix": "typ"}]}, + {"filter": {"Type": "String.Array", "Value": '["test1", "other"]'}}, + True, + ), + ( + "logical OR no match", + {"filter": ["test1", "test2", {"prefix": "typ"}]}, + {"filter": {"Type": "String", "Value": "test3"}}, + False, + ), + ( + "logical OR no match on an array", + {"filter": ["test1", "test2", {"prefix": "typ"}]}, + { + "filter": { + "Type": "String.Array", + "Value": '["anything", "something"]', + } + }, + False, + ), + ( + "logical AND with match", + {"filter": [{"numeric": ["=", 300]}], "other": [{"prefix": "typ"}]}, + { + "filter": {"Type": "Number", "Value": 300}, + "other": {"Type": "String", "Value": "type1"}, + }, + True, + ), + ( + "logical AND missing first attribute", + {"filter": [{"numeric": ["=", 300]}], "other": [{"prefix": "typ"}]}, + {"other": {"Type": "String", "Value": "type1"}}, + False, + ), + ( + "logical AND missing second attribute", + {"filter": [{"numeric": ["=", 300]}], "other": [{"prefix": "typ"}]}, + {"filter": {"Type": "Number", "Value": 300}}, + False, + ), + ( + "logical AND no match", + {"filter": [{"numeric": ["=", 300]}], "other": [{"prefix": "typ"}]}, + { + "filter": {"Type": "Number", "Value": 299}, + "other": {"Type": "String", "Value": "type1"}, + }, + False, + ), + ( + "multiple numeric filters with first match", + {"filter": [{"numeric": ["=", 300]}, {"numeric": ["=", 500]}]}, + {"filter": {"Type": "Number", "Value": 300}}, + True, + ), + ( + "multiple numeric filters with second match", + {"filter": [{"numeric": ["=", 300]}, {"numeric": ["=", 500]}]}, + {"filter": {"Type": "Number", "Value": 500}}, + True, + ), + ( + "multiple prefix filters with first match", + {"filter": [{"prefix": "typ"}, {"prefix": "tes"}]}, + {"filter": {"Type": "String", "Value": "type1"}}, + True, + ), + ( + "multiple prefix filters with second match", + {"filter": [{"prefix": "typ"}, {"prefix": "tes"}]}, + {"filter": {"Type": "String", "Value": "test"}}, + True, + ), + ( + "multiple anything-but filters with second match", + {"filter": [{"anything-but": "type1"}, {"anything-but": "type2"}]}, + {"filter": {"Type": "String", "Value": "type2"}}, + True, + ), + ( + "multiple numeric conditions", + {"filter": [{"numeric": [">", 0, "<=", 150]}]}, + {"filter": {"Type": "Number", "Value": 122}}, + True, + ), + ( + "multiple numeric conditions", + {"filter": [{"numeric": [">", 0, "<=", 150]}]}, + {"filter": {"Type": "Number", "Value": 200}}, + False, + ), + ( + "multiple numeric conditions", + {"filter": [{"numeric": [">", 0, "<=", 150]}]}, + {"filter": {"Type": "Number", "Value": -1}}, + False, + ), + ( + "multiple conditions on an array", + {"filter": ["test1", "test2", {"prefix": "som"}]}, + { + "filter": { + "Type": "String.Array", + "Value": '["anything", "something"]', + } + }, + True, + ), + ( + "exists with existing attribute", + {"field": [{"exists": True}]}, + {"field": {"Type": "String", "Value": "anything"}}, + True, + ), + ( + "exists without existing attribute", + {"field": [{"exists": True}]}, + {"other_field": {"Type": "String", "Value": "anything"}}, + False, + ), + ( + "does not exists without existing attribute", + {"field": [{"exists": False}]}, + {"other_field": {"Type": "String", "Value": "anything"}}, + True, + ), + ( + "does not exists with existing attribute", + {"field": [{"exists": False}]}, + {"field": {"Type": "String", "Value": "anything"}}, + False, + ), + ( + "can match on String.Array containing boolean", + {"field": [True]}, + {"field": {"Type": "String.Array", "Value": "[true]"}}, + True, + ), + ( + "can not match on values that are not valid JSON strings", + {"field": ["anything"]}, + {"field": {"Type": "String.Array", "Value": "['anything']"}}, + False, + ), + ( + "$or ", + {"f1": ["v1"], "$or": [{"f2": ["v2"]}, {"f3": ["v3"]}]}, + {"f1": {"Type": "String", "Value": "v1"}, "f3": {"Type": "String", "Value": "v3"}}, + True, + ), + ( + "$or ", + {"f1": ["v1"], "$or": [{"f2": ["v2"]}, {"f3": ["v3"]}]}, + {"f1": {"Type": "String", "Value": "v2"}, "f3": {"Type": "String", "Value": "v3"}}, + False, + ), + ( + "$or2", + { + "f1": ["v1"], + "$or": [ + {"f2": ["v2", "v3"]}, + {"f3": ["v4"], "$or": [{"f4": ["v5", "v6"]}, {"f5": ["v7", "v8"]}]}, + ], + }, + {"f1": {"Type": "String", "Value": "v1"}, "f2": {"Type": "String", "Value": "v2"}}, + True, + ), + ( + "$or3", + { + "f1": ["v1"], + "$or": [ + {"f2": ["v2", "v3"]}, + {"f3": ["v4"], "$or": [{"f4": ["v5", "v6"]}, {"f5": ["v7", "v8"]}]}, + ], + }, + { + "f1": {"Type": "String", "Value": "v1"}, + "f3": {"Type": "String", "Value": "v4"}, + "f4": {"Type": "String", "Value": "v6"}, + }, + True, + ), + ( + "cidr filter with no match", + {"filter": [{"cidr": "10.0.0.0/24"}]}, + {"filter": {"Type": "String", "Value": "10.0.0.256"}}, + False, + ), + ( + "cidr filter with no match 2", + {"filter": [{"cidr": "10.0.0.0/24"}]}, + {"filter": {"Type": "String", "Value": "10.0.1.255"}}, + False, + ), + ( + "cidr filter with match", + {"filter": [{"cidr": "10.0.0.0/24"}]}, + {"filter": {"Type": "String", "Value": "10.0.0.255"}}, + True, + ), + ] + + sub_filter = SubscriptionFilter() + for test in test_data: + _, filter_policy, attributes, expected = test + assert ( + sub_filter.check_filter_policy_on_message_attributes(filter_policy, attributes) + == expected + ) + + def test_is_raw_message_delivery(self, subscriber): + valid_true_values = ["true", "True", True] + + for true_value in valid_true_values: + subscriber["RawMessageDelivery"] = true_value + assert is_raw_message_delivery(subscriber) + + def test_is_not_raw_message_delivery(self, subscriber): + invalid_values = ["false", "False", False, "somevalue", ""] + + for value in invalid_values: + subscriber["RawMessageDelivery"] = value + assert not is_raw_message_delivery(subscriber) + + del subscriber["RawMessageDelivery"] + assert not is_raw_message_delivery(subscriber) + + def test_filter_policy_on_message_body(self): + test_data = [ + ( + {"f1": ["v1", "v2"]}, # f1 must be v1 OR v2 (f1=v1 OR f1=v2) + ( + ({"f1": "v1", "f2": "v4"}, True), + ({"f1": "v2", "f2": "v5"}, True), + ({"f1": "v3", "f2": "v5"}, False), + ), + ), + ( + {"f1": ["v1"]}, # f1 must be v1 (f1=v1) + ( + ({"f1": "v1", "f2": "v4"}, True), + ({"f1": "v2", "f2": "v5"}, False), + ({"f1": "v3", "f2": "v5"}, False), + ), + ), + ( + {"f1": ["v1"], "f2": ["v4"]}, # f1 must be v1 AND f2 must be v4 (f1=v1 AND f2=v4) + ( + ({"f1": "v1", "f2": "v4"}, True), + ({"f1": "v2", "f2": "v5"}, False), + ({"f1": "v3", "f2": "v5"}, False), + ), + ), + ( + {"f2": ["v5"]}, # f2 must be v5 (f2=v5) + ( + ({"f1": "v1", "f2": "v4"}, False), + ({"f1": "v2", "f2": "v5"}, True), + ({"f1": "v3", "f2": "v5"}, True), + ), + ), + ( + { + "f1": ["v1", "v2"], + "f2": ["v4"], + }, # f1 must be v1 or v2 AND f2 must be v4 ((f1=v1 OR f1=v2) AND f2=v4) + ( + ({"f1": "v1", "f2": "v4"}, True), + ({"f1": "v2", "f2": "v5"}, False), + ({"f1": "v3", "f2": "v5"}, False), + ), + ), + ( + {"f1": ["v1", "v2"]}, # f1 must be v1 OR v2 (f1=v1 OR f1=v2) + ( + ({"f1": ["v1"], "f2": "v4"}, True), + ({"f1": ["v2", "v3"], "f2": "v5"}, True), + ({"f1": ["v3", "v4"], "f2": "v5"}, False), + ), + ), + ( + {"f1": {"f2": ["v1"]}}, # f1.f2 must be v1 + ( + ({"f1": {"f2": "v1"}, "f3": "v4"}, True), + ({"f1": {"f2": ["v1"]}, "f3": "v4"}, True), + ({"f1": {"f4": "v1"}, "f3": "v4"}, False), + ({"f1": ["v1", "v3"], "f3": "v5"}, False), + ({"f1": "v1", "f3": "v5"}, False), + ), + ), + ( + {"f1": {"f2": {"f3": {"f4": ["v1"]}}}}, + ( + ({"f1": {"f2": {"f3": {"f4": "v1"}}}}, True), + ({"f1": [{"f2": {"f3": {"f4": "v1"}}}]}, True), + ({"f1": [{"f2": [{"f3": {"f4": "v1"}}]}]}, True), + ({"f1": [{"f2": [[{"f3": {"f4": "v1"}}]]}]}, True), + ({"f1": [{"f2": [{"f3": {"f4": "v1"}, "f5": {"f6": "v2"}}]}]}, True), + ({"f1": [{"f2": [[{"f3": {"f4": "v2"}}, {"f3": {"f4": "v1"}}]]}]}, True), + ({"f1": [{"f2": {"f3": {"f4": "v2"}}}]}, False), + ({"f1": [{"f2": {"fx": {"f4": "v1"}}}]}, False), + ({"f1": [{"fx": {"f3": {"f4": "v1"}}}]}, False), + ({"fx": [{"f2": {"f3": {"f4": "v1"}}}]}, False), + ({"f1": [{"f2": [{"f3": {"f4": "v2"}, "f5": {"f6": "v3"}}]}]}, False), + ({"f1": [{"f2": [[{"f3": {"f4": "v2"}}, {"f3": {"f4": "v3"}}]]}]}, False), + ), + ), + ( + {"f1": {"f2": ["v2"]}}, + [ + ({"f3": ["v3"], "f1": {"f2": "v2"}}, True), + ], + ), + ( + { + "$or": [{"f1": ["v1", "v2"]}, {"f2": ["v3", "v4"]}], + "f3": { + "f4": ["v5"], + "$or": [ + {"f5": ["v6"]}, + {"f6": ["v7"]}, + ], + }, + }, + ( + ({"f1": "v1", "f3": {"f4": "v5", "f5": "v6"}}, True), + ({"f1": "v2", "f3": {"f4": "v5", "f5": "v6"}}, True), + ({"f2": "v3", "f3": {"f4": "v5", "f5": "v6"}}, True), + ({"f2": "v4", "f3": {"f4": "v5", "f5": "v6"}}, True), + ({"f1": "v1", "f3": {"f4": "v5", "f6": "v7"}}, True), + ({"f1": "v3", "f3": {"f4": "v5", "f6": "v7"}}, False), + ({"f2": "v1", "f3": {"f4": "v5", "f6": "v7"}}, False), + ({"f1": "v1", "f3": {"f4": "v6", "f6": "v7"}}, False), + ({"f1": "v1", "f3": {"f4": "v5", "f6": "v1"}}, False), + ({"f1": "v1", "f3": {"f6": "v7"}}, False), + ({"f1": "v1", "f3": {"f4": "v5"}}, False), + ), + ), + ] + + sub_filter = SubscriptionFilter() + for filter_policy, messages in test_data: + for message_body, expected in messages: + assert ( + sub_filter.check_filter_policy_on_message_body( + filter_policy, message_body=json.dumps(message_body) + ) + == expected + ), (filter_policy, message_body) + + @pytest.mark.parametrize("region", ["us-east-1", "eu-central-1", "us-west-2", "my-region"]) + def test_region_encoded_subscription_token(self, region): + token = encode_subscription_token_with_region(region) + assert len(token) == 64 + token_region = get_region_from_subscription_token(token) + assert token_region == region + + @pytest.mark.parametrize( + "token", ["abcdef123", "mynothexstring", "us-west-2", b"test", b"test2f", "test2f"] + ) + def test_decode_token_with_no_region_encoded(self, token): + with pytest.raises(InvalidParameterException) as e: + get_region_from_subscription_token(token) + + assert e.match("Invalid parameter: Token") + + def test_canonical_string_calculation(self): + timestamp = timestamp_millis() + data = { + "Type": "Notification", + "MessageId": "abdcdef", + "TopicArn": "arn", + "Message": "test content", + "Subject": "random", + "Timestamp": timestamp, + "UnsubscribeURL": "http://randomurl.com", + } + + canonical_string = compute_canonical_string(data, notification_type="Notification") + assert ( + canonical_string + == f"Message\ntest content\nMessageId\nabdcdef\nSubject\nrandom\nTimestamp\n{timestamp}\nTopicArn\narn\nType\nNotification\n" + ) + + data_unsub = { + "Type": "SubscriptionConfirmation", + "MessageId": "abdcdef", + "TopicArn": "arn", + "Message": "test content", + "Subject": "random", + "Timestamp": timestamp, + "UnsubscribeURL": "http://randomurl.com", + "SubscribeURL": "http://randomurl.com", + "Token": "randomtoken", + } + + canonical_string = compute_canonical_string( + data_unsub, notification_type="SubscriptionConfirmation" + ) + assert ( + canonical_string + == f"Message\ntest content\nMessageId\nabdcdef\nSubscribeURL\nhttp://randomurl.com\nTimestamp\n{timestamp}\nToken\nrandomtoken\nTopicArn\narn\nType\nSubscriptionConfirmation\n" + ) + + def test_filter_policy_complexity(self): + # examples taken from https://docs.aws.amazon.com/sns/latest/dg/subscription-filter-policy-constraints.html + # and https://docs.aws.amazon.com/sns/latest/dg/and-or-logic.html + validator_flat = FilterPolicyValidator(scope="MessageAttributes", is_subscribe_call=True) + validator_nested = FilterPolicyValidator(scope="MessageBody", is_subscribe_call=True) + + filter_policy = { + "key_a": { + "key_b": {"key_c": ["value_one", "value_two", "value_three", "value_four"]}, + }, + "key_d": {"key_e": ["value_one", "value_two", "value_three"]}, + "key_f": ["value_one", "value_two", "value_three"], + } + rules, combinations = validator_nested.aggregate_rules(filter_policy) + assert combinations == 216 + + filter_policy = { + "source": ["aws.cloudwatch", "aws.events", "aws.test", "aws.test2"], + "$or": [ + {"metricName": ["CPUUtilization", "ReadLatency", "t1", "t2", "t3", "t4"]}, + { + "metricType": ["MetricType", "TestType", "TestType2", "TestType3"], + "$or": [{"metricId": [1234, 4321, 5678, 9012]}, {"spaceId": [1, 2, 3, 4]}], + }, + ], + } + + rules, combinations = validator_flat.aggregate_rules(filter_policy) + assert combinations == 152 + + filter_policy = { + "$or": [ + {"metricName": ["CPUUtilization", "ReadLatency", "TestValue"]}, + {"namespace": ["AWS/EC2", "AWS/ES"]}, + ], + "detail": { + "scope": ["Service", "Test"], + "$or": [ + {"source": ["aws.cloudwatch"]}, + {"type": ["CloudWatch Alarm State Change", "TestValue", "TestValue2"]}, + ], + }, + } + + rules, combinations = validator_nested.aggregate_rules(filter_policy) + assert combinations == 160 + + filter_policy = { + "source": ["aws.cloudwatch", "aws.events", "aws.test"], + "$or": [ + { + "metricName": [ + "CPUUtilization", + "ReadLatency", + "TestVal", + "TestVal2", + "TestVal3", + "TestVal4", + ] + }, + { + "metricType": ["MetricType", "TestType", "TestType2", "TestType3"], + "$or": [ + {"metricId": [1234, 4321, 5678, 9012]}, + {"spaceId": [1, 2, 3, 4, 5, 6, 7]}, + ], + }, + ], + } + rules, combinations = validator_flat.aggregate_rules(filter_policy) + assert combinations == 150 + + @pytest.mark.parametrize( + "payload,flat_policy,expected", + [ + ( + {"f3": ["v3"], "f1": {"f2": "v2"}}, + [{"f3": "v3"}, {"f1.f2": "v2"}], + [{"f3": "v3", "f1.f2": "v2"}], + ), + ( + {"f3": ["v3", "v4"], "f1": {"f2": "v2"}}, + [{"f3": "v3", "f1.f2": "v2"}], + [{"f3": "v3", "f1.f2": "v2"}, {"f3": "v4", "f1.f2": "v2"}], + ), + ( + {"f3": ["v3"], "f1": {"f2": "v2"}}, + [{"f3": "v3"}], + [{"f3": "v3"}], + ), + ( + {"f1": {"f2": {"f3": {"f4": [{"f5": "v5"}]}, "f6": [{"f8": "v8"}]}}}, + [{"f1.f2.f3": "val1", "f1.f2.f4": "val2"}], + [{}], + ), + ( + {"f1": {"f2": {"f3": {"f4": [{"f5": "v5"}]}, "f6": [{"f7": "v7"}]}}}, + [{"f1.f2.f3.f4.f5": "test1", "f1.f2.f6.f7": "test2"}], + [{"f1.f2.f3.f4.f5": "v5", "f1.f2.f6.f7": "v7"}], + ), + ], + ) + def test_filter_flatten_payload(self, payload, flat_policy, expected): + sub_filter = SubscriptionFilter() + assert sub_filter.flatten_payload(payload, flat_policy) == expected + + @pytest.mark.parametrize( + "policy,expected", + [ + ( + {"filter": [{"anything-but": {"prefix": "type"}}]}, + [{"filter": [{"anything-but": {"prefix": "type"}}]}], + ), + ( + {"field1": {"field2": {"field3": "val1", "field4": "val2"}}}, + [{"field1.field2.field3": "val1", "field1.field2.field4": "val2"}], + ), + ( + {"$or": [{"field1": "val1"}, {"field2": "val2"}], "field3": "val3"}, + [{"field1": "val1", "field3": "val3"}, {"field2": "val2", "field3": "val3"}], + ), + ], + ) + def test_filter_flatten_policy(self, policy, expected): + sub_filter = SubscriptionFilter() + assert sub_filter.flatten_policy(policy) == expected diff --git a/tests/unit/services/sqs/__init__.py b/tests/unit/services/sqs/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/sqs/test_sqs.py b/tests/unit/services/sqs/test_sqs.py new file mode 100644 index 0000000000000..47232c8cf29e1 --- /dev/null +++ b/tests/unit/services/sqs/test_sqs.py @@ -0,0 +1,208 @@ +import pytest + +import localstack.services.sqs.exceptions +import localstack.services.sqs.models +from localstack.services.sqs import provider +from localstack.services.sqs.constants import DEFAULT_MAXIMUM_MESSAGE_SIZE +from localstack.services.sqs.provider import _create_message_attribute_hash +from localstack.services.sqs.utils import ( + guess_endpoint_strategy_and_host, + is_sqs_queue_url, + parse_queue_url, +) +from localstack.utils.common import convert_to_printable_chars + + +def test_sqs_message_attrs_md5(): + msg_attrs = { + "timestamp": { + "StringValue": "1493147359900", + "DataType": "Number", + } + } + md5 = _create_message_attribute_hash(msg_attrs) + assert md5 == "235c5c510d26fb653d073faed50ae77c" + + +def test_convert_non_printable_chars(): + string = "invalid characters - %s %s %s" % (chr(8), chr(11), chr(12)) + result = convert_to_printable_chars(string) + assert result == "invalid characters - " + result = convert_to_printable_chars({"foo": [string]}) + assert result == {"foo": ["invalid characters - "]} + + string = "valid characters - %s %s %s %s" % (chr(9), chr(10), chr(13), chr(32)) + result = convert_to_printable_chars(string) + assert result == string + + +def test_parse_max_receive_count_string_in_redrive_policy(): + # fmt: off + policy = {"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:DeadLetterQueue\",\"maxReceiveCount\": \"5\" }"} + # fmt: on + queue = localstack.services.sqs.models.SqsQueue("TestQueue", "us-east-1", "123456789", policy) + assert queue.max_receive_count == 5 + + +def test_except_check_message_max_size(): + message_attributes = {"k": {"DataType": "String", "StringValue": "x"}} + message_attributes_size = len("k") + len("String") + len("x") + message_body = "a" * (DEFAULT_MAXIMUM_MESSAGE_SIZE - message_attributes_size + 1) + with pytest.raises(localstack.services.sqs.exceptions.InvalidParameterValueException): + provider.check_message_max_size( + message_body, message_attributes, DEFAULT_MAXIMUM_MESSAGE_SIZE + ) + + +def test_check_message_max_size(): + message_body = "a" + message_attributes = {"k": {"DataType": "String", "StringValue": "x"}} + provider.check_message_max_size(message_body, message_attributes, DEFAULT_MAXIMUM_MESSAGE_SIZE) + + +def test_except_check_message_min_size(): + message_body = "" + with pytest.raises(localstack.services.sqs.exceptions.MissingRequiredParameterException): + provider.check_message_min_size(message_body) + + +def test_check_message_min_size(): + message_body = "a" + provider.check_message_min_size(message_body) + + +def test_parse_queue_url_valid(): + assert parse_queue_url("http://localhost:4566/queue/eu-central-2/000000000001/my-queue") == ( + "000000000001", + "eu-central-2", + "my-queue", + ) + assert parse_queue_url("http://localhost:4566/000000000001/my-queue") == ( + "000000000001", + None, + "my-queue", + ) + assert parse_queue_url("http://localhost/000000000001/my-queue") == ( + "000000000001", + None, + "my-queue", + ) + + assert parse_queue_url("http://localhost/queue/eu-central-2/000000000001/my-queue") == ( + "000000000001", + "eu-central-2", + "my-queue", + ) + + assert parse_queue_url( + "http://queue.localhost.localstack.cloud:4566/000000000001/my-queue" + ) == ( + "000000000001", + "us-east-1", + "my-queue", + ) + + assert parse_queue_url( + "http://eu-central-2.queue.localhost.localstack.cloud:4566/000000000001/my-queue" + ) == ( + "000000000001", + "eu-central-2", + "my-queue", + ) + + # in this case, eu-central-2.foobar... is treated as a regular hostname + assert parse_queue_url( + "http://eu-central-2.foobar.localhost.localstack.cloud:4566/000000000001/my-queue" + ) == ( + "000000000001", + None, + "my-queue", + ) + + +def test_parse_queue_url_invalid(): + with pytest.raises(ValueError): + parse_queue_url("http://localhost:4566/my-queue") + + with pytest.raises(ValueError): + parse_queue_url("http://localhost:4566/eu-central-1/000000000001/my-queue") + + with pytest.raises(ValueError): + parse_queue_url("http://localhost:4566/foobar/eu-central-1/000000000001/my-queue") + + with pytest.raises(ValueError): + parse_queue_url( + "http://eu-central-2.queue.localhost.localstack.cloud:4566/000000000001/my-queue/foobar" + ) + + with pytest.raises(ValueError): + parse_queue_url( + "http://queue.localhost.localstack.cloud:4566/us-east-1/000000000001/my-queue" + ) + + with pytest.raises(ValueError): + assert parse_queue_url("queue.localhost.localstack.cloud:4566/000000000001/my-queue") + + with pytest.raises(ValueError): + assert parse_queue_url( + "http://foo.bar.queue.localhost.localstack.cloud:4566/000000000001/my-queue" + ) + + +def test_is_sqs_queue_url(): + # General cases + assert is_sqs_queue_url("http://localstack.cloud") is False + assert is_sqs_queue_url("https://localstack.cloud:4566") is False + assert is_sqs_queue_url("local.localstack.cloud:4566") is False + + # Without proto prefix + assert ( + is_sqs_queue_url("sqs.us-east-1.localhost.localstack.cloud:4566/111111111111/foo") is True + ) + assert ( + is_sqs_queue_url("us-east-1.queue.localhost.localstack.cloud:4566/111111111111/foo") is True + ) + assert is_sqs_queue_url("localhost:4566/queue/ap-south-1/222222222222/bar") is True + assert is_sqs_queue_url("localhost:4566/111111111111/bar") is True + + # With proto prefix + assert ( + is_sqs_queue_url( + "http://sqs.us-east-1.localhost.localstack.cloud:4566/111111111111/foo.fifo" + ) + is True + ) + assert ( + is_sqs_queue_url("http://us-east-1.queue.localhost.localstack.cloud:4566/111111111111/foo1") + is True + ) + assert is_sqs_queue_url("http://localhost:4566/queue/ap-south-1/222222222222/my-queue") is True + assert is_sqs_queue_url("http://localhost:4566/111111111111/bar") is True + + # Path strategy uses any domain name + assert is_sqs_queue_url("foo.bar:4566/queue/ap-south-1/222222222222/bar") is True + # Domain strategy may omit region + assert is_sqs_queue_url("http://queue.localhost.localstack.cloud:4566/111111111111/foo") is True + + # Custom domain name + assert is_sqs_queue_url("http://foo.bar:4566/queue/us-east-1/111111111111/foo") is True + assert is_sqs_queue_url("http://us-east-1.queue.foo.bar:4566/111111111111/foo") is True + assert is_sqs_queue_url("http://queue.foo.bar:4566/111111111111/foo") is True + assert is_sqs_queue_url("http://sqs.us-east-1.foo.bar:4566/111111111111/foo") is True + + +def test_guess_endpoint_strategy_and_host(): + assert guess_endpoint_strategy_and_host("localhost:4566") == ("path", "localhost:4566") + assert guess_endpoint_strategy_and_host("example.com") == ("path", "example.com") + assert guess_endpoint_strategy_and_host("sqs.us-east-1.amazonaws.com") == ( + "standard", + "amazonaws.com", + ) + assert guess_endpoint_strategy_and_host("queue.localhost.localstack.cloud") == ( + "domain", + "localhost.localstack.cloud", + ) + assert guess_endpoint_strategy_and_host("us-east-1.queue.localhost.localstack.cloud") == ( + "domain", + "localhost.localstack.cloud", + ) diff --git a/tests/unit/services/stepfunctions/__init__.py b/tests/unit/services/stepfunctions/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/services/stepfunctions/test_usage_metrics_static_analyser.py b/tests/unit/services/stepfunctions/test_usage_metrics_static_analyser.py new file mode 100644 index 0000000000000..2d2d8aa6b4931 --- /dev/null +++ b/tests/unit/services/stepfunctions/test_usage_metrics_static_analyser.py @@ -0,0 +1,157 @@ +import json + +import pytest + +from localstack.services.stepfunctions.asl.component.common.query_language import QueryLanguageMode +from localstack.services.stepfunctions.asl.static_analyser.usage_metrics_static_analyser import ( + UsageMetricsStaticAnalyser, +) + +BASE_PASS_JSONATA = json.dumps( + { + "QueryLanguage": "JSONata", + "StartAt": "StartState", + "States": { + "StartState": {"Type": "Pass", "End": True}, + }, + } +) + +BASE_PASS_JSONPATH = json.dumps( + { + "QueryLanguage": "JSONPath", + "StartAt": "StartState", + "States": { + "StartState": {"Type": "Pass", "End": True}, + }, + } +) + +BASE_PASS_JSONATA_OVERRIDE = json.dumps( + { + "QueryLanguage": "JSONPath", + "StartAt": "StartState", + "States": { + "StartState": {"QueryLanguage": "JSONata", "Type": "Pass", "End": True}, + }, + } +) + +BASE_PASS_JSONATA_OVERRIDE_DEFAULT = json.dumps( + { + "StartAt": "StartState", + "States": { + "StartState": {"QueryLanguage": "JSONata", "Type": "Pass", "End": True}, + }, + } +) + +JSONPATH_TO_JSONATA_DATAFLOW = json.dumps( + { + "StartAt": "StateJsonPath", + "States": { + "StateJsonPath": {"Type": "Pass", "Assign": {"var": 42}, "Next": "StateJsonata"}, + "StateJsonata": { + "QueryLanguage": "JSONata", + "Type": "Pass", + "Output": "{% $var %}", + "End": True, + }, + }, + } +) + +ASSIGN_BASE_EMPTY = json.dumps( + {"StartAt": "State0", "States": {"State0": {"Type": "Pass", "Assign": {}, "End": True}}} +) + +ASSIGN_BASE_SCOPE_MAP = json.dumps( + { + "StartAt": "State0", + "States": { + "State0": { + "Type": "Map", + "ItemProcessor": { + "ProcessorConfig": {"Mode": "INLINE"}, + "StartAt": "Inner", + "States": { + "Inner": { + "Type": "Pass", + "Assign": {}, + "End": True, + }, + }, + }, + "End": True, + } + }, + } +) + + +class TestUsageMetricsStaticAnalyser: + @pytest.mark.parametrize( + "definition", + [ + BASE_PASS_JSONATA, + BASE_PASS_JSONATA_OVERRIDE, + BASE_PASS_JSONATA_OVERRIDE_DEFAULT, + ], + ids=[ + "BASE_PASS_JSONATA", + "BASE_PASS_JSONATA_OVERRIDE", + "BASE_PASS_JSONATA_OVERRIDE_DEFAULT", + ], + ) + def test_jsonata(self, definition): + analyser = UsageMetricsStaticAnalyser.process(definition) + assert not analyser.uses_variables + assert QueryLanguageMode.JSONata in analyser.query_language_modes + + @pytest.mark.parametrize( + "definition", + [ + BASE_PASS_JSONATA_OVERRIDE, + BASE_PASS_JSONATA_OVERRIDE_DEFAULT, + ], + ids=[ + "BASE_PASS_JSONATA_OVERRIDE", + "BASE_PASS_JSONATA_OVERRIDE_DEFAULT", + ], + ) + def test_both_query_languages(self, definition): + analyser = UsageMetricsStaticAnalyser.process(definition) + assert not analyser.uses_variables + assert QueryLanguageMode.JSONata in analyser.query_language_modes + assert QueryLanguageMode.JSONPath in analyser.query_language_modes + + @pytest.mark.parametrize("definition", [BASE_PASS_JSONPATH], ids=["BASE_PASS_JSONPATH"]) + def test_jsonpath(self, definition): + analyser = UsageMetricsStaticAnalyser.process(definition) + assert QueryLanguageMode.JSONata not in analyser.query_language_modes + assert not analyser.uses_variables + + @pytest.mark.parametrize( + "definition", [JSONPATH_TO_JSONATA_DATAFLOW], ids=["JSONPATH_TO_JSONATA_DATAFLOW"] + ) + def test_jsonata_and_variable_sampling(self, definition): + analyser = UsageMetricsStaticAnalyser.process(definition) + assert QueryLanguageMode.JSONPath in analyser.query_language_modes + assert QueryLanguageMode.JSONata in analyser.query_language_modes + assert analyser.uses_variables + + @pytest.mark.parametrize( + "definition", + [ + ASSIGN_BASE_EMPTY, + ASSIGN_BASE_SCOPE_MAP, + ], + ids=[ + "ASSIGN_BASE_EMPTY", + "ASSIGN_BASE_SCOPE_MAP", + ], + ) + def test_jsonpath_and_variable_sampling(self, definition): + analyser = UsageMetricsStaticAnalyser.process(definition) + assert QueryLanguageMode.JSONata not in analyser.query_language_modes + assert analyser.uses_variables diff --git a/tests/unit/services/test_internal.py b/tests/unit/services/test_internal.py new file mode 100644 index 0000000000000..189857e05e093 --- /dev/null +++ b/tests/unit/services/test_internal.py @@ -0,0 +1,67 @@ +from unittest import mock + +from localstack.constants import VERSION +from localstack.http import Request +from localstack.services.internal import HealthResource +from localstack.services.plugins import ServiceManager, ServiceState + + +class TestHealthResource: + def test_put_and_get(self): + service_manager = ServiceManager() + service_manager.get_states = mock.MagicMock(return_value={"foo": ServiceState.AVAILABLE}) + + resource = HealthResource(service_manager) + + resource.on_put( + Request( + "PUT", + "/", + body=b'{"features:initScripts": "initializing","features:persistence": "disabled"}', + ) + ) + + state = resource.on_get(Request("GET", "/", body=b"None")) + # edition may return a different value depending on how the tests are run + state.pop("edition", None) + + assert state == { + "features": { + "initScripts": "initializing", + "persistence": "disabled", + }, + "services": { + "foo": "available", + }, + "version": VERSION, + } + + def test_put_overwrite_and_get(self): + service_manager = ServiceManager() + service_manager.get_states = mock.MagicMock(return_value={"foo": ServiceState.AVAILABLE}) + + resource = HealthResource(service_manager) + + resource.on_put( + Request( + "PUT", + "/", + body=b'{"features:initScripts": "initializing","features:persistence": "disabled"}', + ) + ) + + resource.on_put(Request("PUT", "/", body=b'{"features:initScripts": "initialized"}')) + + state = resource.on_get(Request("GET", "/", body=b"None")) + state.pop("edition", None) + + assert state == { + "features": { + "initScripts": "initialized", + "persistence": "disabled", + }, + "services": { + "foo": "available", + }, + "version": VERSION, + } diff --git a/tests/unit/services/test_plugins.py b/tests/unit/services/test_plugins.py new file mode 100644 index 0000000000000..efd043cb00a66 --- /dev/null +++ b/tests/unit/services/test_plugins.py @@ -0,0 +1,71 @@ +import threading +from queue import Queue + +from localstack.services.plugins import ServicePluginManager +from localstack.services.sqs.provider import SqsProvider + + +class TestServicePluginManager: + def test_get_service_calls_init_hook_once(self, monkeypatch): + manager = ServicePluginManager() + + calls_to_on_after_init = [] + + def _on_after_init(_self): + calls_to_on_after_init.append(_self) + + monkeypatch.setattr(SqsProvider, "on_after_init", _on_after_init) + + s1 = manager.get_service("sqs") + s2 = manager.get_service("sqs") + + assert s1 is s2, "instantiated two different services" + assert len(calls_to_on_after_init) == 1, "on_after_init should be called once" + + def test_concurrent_get_service_calls_init_hook_once(self, monkeypatch): + manager = ServicePluginManager() + + calls_to_get_service = Queue() + calls_to_on_after_init = [] + + def _call_get_service(): + service = manager.get_service("sqs") + calls_to_get_service.put(service) + + def _on_after_init(_self): + calls_to_on_after_init.append(_self) + + monkeypatch.setattr(SqsProvider, "on_after_init", _on_after_init) + + threading.Thread(target=_call_get_service).start() + threading.Thread(target=_call_get_service).start() + + s1 = calls_to_get_service.get() + s2 = calls_to_get_service.get() + + assert s1 is s2, "instantiated two different services" + assert len(calls_to_on_after_init) == 1, "on_after_init should be called once" + + def test_nested_concurrent_get_service_calls_init_hook_once(self, monkeypatch): + manager = ServicePluginManager() + + calls_to_get_service = Queue() + calls_to_on_after_init = [] + + def _call_get_service(): + service = manager.get_service("sqs") + calls_to_get_service.put(service) + + def _on_after_init(_self): + calls_to_on_after_init.append(_self) + threading.Thread(target=_call_get_service).start() + + monkeypatch.setattr(SqsProvider, "on_after_init", _on_after_init) + + threading.Thread(target=_call_get_service).start() + + s1 = calls_to_get_service.get() + s2 = calls_to_get_service.get() + + assert s1 is s2, "instantiated two different services" + assert len(calls_to_on_after_init) == 1, "on_after_init should be called once" diff --git a/tests/unit/state/__init__.py b/tests/unit/state/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/state/test_core.py b/tests/unit/state/test_core.py new file mode 100644 index 0000000000000..26fe2f0c6dd43 --- /dev/null +++ b/tests/unit/state/test_core.py @@ -0,0 +1,20 @@ +import pathlib + +import pytest + +from localstack.state import AssetDirectory + + +def test_asset_directory(tmp_path): + asset_dir = AssetDirectory("sqs", tmp_path) + assert isinstance(asset_dir.path, pathlib.Path) + + asset_dir_str = AssetDirectory("sqs", str(tmp_path)) + assert isinstance(asset_dir_str.path, pathlib.Path) + assert asset_dir.path == asset_dir_str.path + + with pytest.raises(ValueError): + AssetDirectory("sqs", "") + + with pytest.raises(ValueError): + AssetDirectory("", tmp_path) diff --git a/tests/unit/state/test_inspect.py b/tests/unit/state/test_inspect.py new file mode 100644 index 0000000000000..25393f4ac1749 --- /dev/null +++ b/tests/unit/state/test_inspect.py @@ -0,0 +1,57 @@ +import pytest +from moto.core.base_backend import BackendDict, BaseBackend +from moto.sns import models as sns_models + +from localstack.services.sqs import models as sqs_models +from localstack.state.inspect import ReflectionStateLocator, ServiceBackendCollectorVisitor + + +@pytest.fixture() +def sample_backend_dict() -> BackendDict: + class SampleBackend(BaseBackend): + def __init__(self, region_name, account_id): + super().__init__(region_name, account_id) + self.attributes = {} + + return BackendDict(SampleBackend, "sns") + + +class TestReflectionStateLocator: + def test_collect_store(self, sample_stores, monkeypatch): + """Ensures that the visitor can effectively collect store backend""" + account = "696969696969" + eu_region = "eu-central-1" + + store = sample_stores[account][eu_region] + monkeypatch.setattr(sqs_models, "sqs_stores", sample_stores) + + visitor = ServiceBackendCollectorVisitor() + state_manager = ReflectionStateLocator(service="sqs") + state_manager.accept_state_visitor(visitor=visitor) + backends = visitor.collect() + + store_backend = backends.get("localstack") + assert store_backend + + oracle = {account: {eu_region: store}} + assert store_backend == oracle + + def test_collect_backend_dict(self, sample_backend_dict, monkeypatch): + """Ensures that the visitor can effectively collect backend dict backends""" + + account = "696969696969" + eu_region = "eu-central-1" + + store = sample_backend_dict[account][eu_region] + store.attributes = {"key": "value"} + + monkeypatch.setattr(sns_models, "sns_backends", sample_backend_dict) + + visitor = ServiceBackendCollectorVisitor() + state_manager = ReflectionStateLocator(service="sns") + state_manager.accept_state_visitor(visitor=visitor) + backends = visitor.collect() + + store_backend = backends.get("moto") + assert store_backend + assert store_backend[account][eu_region] == store diff --git a/tests/unit/state/test_pickle.py b/tests/unit/state/test_pickle.py new file mode 100644 index 0000000000000..42167e936a13d --- /dev/null +++ b/tests/unit/state/test_pickle.py @@ -0,0 +1,107 @@ +from queue import PriorityQueue + +import pytest + +from localstack.state import pickle + + +def test_pickle_priority_queue(): + obj = PriorityQueue() + obj.put(2) + obj.put(1) + obj.put(3) + + obj = pickle.loads(pickle.dumps(obj)) + + assert obj.get_nowait() == 1 + assert obj.get_nowait() == 2 + assert obj.get_nowait() == 3 + + +class ClassWithGenerator: + n: int + + def __init__(self, n: int): + self.n = n + self.gen = self._count() + + def _count(self): + for i in range(self.n): + yield i + + +class SubclassWithGenerator(ClassWithGenerator): + pass + + +def test_pickle_generators_doesnt_work(): + with pytest.raises(TypeError): + pickle.dumps(ClassWithGenerator(0)) + + +def test_reducer(): + @pickle.reducer(ClassWithGenerator) + def reduce(obj: ClassWithGenerator): + return (obj.n,) + + cwg = pickle.loads(pickle.dumps(ClassWithGenerator(2))) + assert next(cwg.gen) == 0 + assert next(cwg.gen) == 1 + + with pytest.raises(TypeError): + pickle.dumps(SubclassWithGenerator(0)) + + pickle.remove_dispatch_entry(ClassWithGenerator) + + with pytest.raises(TypeError): + pickle.dumps(ClassWithGenerator(0)) + + +def test_remove_dispatch_entry_on_non_existing_entry_does_noting(): + pickle.remove_dispatch_entry(ClassWithGenerator) + pickle.remove_dispatch_entry(ClassWithGenerator) + + +def test_reducer_with_subclasses(): + @pickle.reducer(ClassWithGenerator, subclasses=True) + def reduce(obj: ClassWithGenerator): + return (obj.n,) + + cwg = pickle.loads(pickle.dumps(ClassWithGenerator(2))) + assert next(cwg.gen) == 0 + assert next(cwg.gen) == 1 + + cwg = pickle.loads(pickle.dumps(SubclassWithGenerator(2))) + assert next(cwg.gen) == 0 + assert next(cwg.gen) == 1 + + pickle.remove_dispatch_entry(ClassWithGenerator) + + with pytest.raises(TypeError): + pickle.dumps(ClassWithGenerator(0)) + + with pytest.raises(TypeError): + pickle.dumps(SubclassWithGenerator(0)) + + +class CustomObjectStateReducer(pickle.ObjectStateReducer): + cls = ClassWithGenerator + + def prepare(self, obj, state): + del state["gen"] + + def restore(self, obj, state): + state["gen"] = obj._count() + + +def test_object_state_reducer(): + pickle.register()(CustomObjectStateReducer) + + cwg = pickle.loads(pickle.dumps(ClassWithGenerator(2))) + assert next(cwg.gen) == 0 + assert next(cwg.gen) == 1 + + pickle.remove_dispatch_entry(ClassWithGenerator) + + with pytest.raises(TypeError): + pickle.dumps(ClassWithGenerator(0)) diff --git a/tests/unit/test_apigateway.py b/tests/unit/test_apigateway.py deleted file mode 100644 index 4ae5a0d52decf..0000000000000 --- a/tests/unit/test_apigateway.py +++ /dev/null @@ -1,34 +0,0 @@ -import unittest -from localstack.services.apigateway import apigateway_listener - - -class ApiGatewayPathsTest (unittest.TestCase): - - def test_extract_path_params(self): - params = apigateway_listener.extract_path_params('/foo/bar', '/foo/{param1}') - self.assertEqual(params, {'param1': 'bar'}) - - params = apigateway_listener.extract_path_params('/foo/bar1/bar2', '/foo/{param1}/{param2}') - self.assertEqual(params, {'param1': 'bar1', 'param2': 'bar2'}) - - params = apigateway_listener.extract_path_params('/foo/bar', '/foo/bar') - self.assertEqual(params, {}) - - params = apigateway_listener.extract_path_params('/foo/bar/baz', '/foo/{proxy+}') - self.assertEqual(params, {'proxy+': 'bar/baz'}) - - def test_path_matches(self): - path, details = apigateway_listener.get_resource_for_path('/foo/bar', {'/foo/{param1}': {}}) - self.assertEqual(path, '/foo/{param1}') - - path, details = apigateway_listener.get_resource_for_path('/foo/bar', {'/foo/bar': {}, '/foo/{param1}': {}}) - self.assertEqual(path, '/foo/bar') - - path, details = apigateway_listener.get_resource_for_path('/foo/bar/baz', {'/foo/bar': {}, '/foo/{proxy+}': {}}) - self.assertEqual(path, '/foo/{proxy+}') - - result = apigateway_listener.get_resource_for_path('/foo/bar', {'/foo/bar1': {}, '/foo/bar2': {}}) - self.assertEqual(result, None) - - result = apigateway_listener.get_resource_for_path('/foo/bar', {'/{param1}/bar1': {}, '/foo/bar2': {}}) - self.assertEqual(result, None) diff --git a/tests/unit/test_checksum.py b/tests/unit/test_checksum.py new file mode 100644 index 0000000000000..b13bbb61de6c6 --- /dev/null +++ b/tests/unit/test_checksum.py @@ -0,0 +1,311 @@ +from localstack.utils.checksum import ( + ApacheBSDFormat, + BSDFormat, + ChecksumParser, + StandardFormat, +) + + +class TestStandardFormat: + """Test cases for StandardFormat parser.""" + + def test_can_parse_standard_format(self): + """Test detection of standard checksum format.""" + parser = StandardFormat() + + # Valid standard formats + assert parser.can_parse("d41d8cd98f00b204e9800998ecf8427e file.txt") + assert parser.can_parse( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 test.zip" + ) + assert parser.can_parse("da39a3ee5e6b4b0d3255bfef95601890afd80709 *binary.exe") + + # Multiple lines + content = """ +d41d8cd98f00b204e9800998ecf8427e file1.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file2.zip +""" + assert parser.can_parse(content) + + # Invalid formats + assert not parser.can_parse("SHA256 (file.txt) = d41d8cd98f00b204e9800998ecf8427e") + assert not parser.can_parse("file.txt: d41d8cd98f00b204e9800998ecf8427e") + assert not parser.can_parse("just some random text") + assert not parser.can_parse("") + + def test_parse_standard_format(self): + """Test parsing of standard checksum format.""" + parser = StandardFormat() + + content = """ +d41d8cd98f00b204e9800998ecf8427e file1.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file2.zip +# This is a comment +da39a3ee5e6b4b0d3255bfef95601890afd80709 *binary.exe + +1234567890abcdef1234567890abcdef12345678 file with spaces.txt +ABCDEF1234567890ABCDEF1234567890ABCDEF12 UPPERCASE.TXT + """ + + result = parser.parse(content) + + assert len(result) == 5 + assert result["file1.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + assert ( + result["file2.zip"] + == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + assert result["binary.exe"] == "da39a3ee5e6b4b0d3255bfef95601890afd80709" + assert result["file with spaces.txt"] == "1234567890abcdef1234567890abcdef12345678" + assert ( + result["UPPERCASE.TXT"] == "abcdef1234567890abcdef1234567890abcdef12" + ) # Should be lowercase + + def test_parse_empty_content(self): + """Test parsing empty content.""" + parser = StandardFormat() + result = parser.parse("") + assert result == {} + + def test_parse_comments_only(self): + """Test parsing content with only comments.""" + parser = StandardFormat() + content = """ +# Comment 1 +# Comment 2 + """ + result = parser.parse(content) + assert result == {} + + +class TestBSDFormat: + """Test cases for BSDFormat parser.""" + + def test_can_parse_bsd_format(self): + """Test detection of BSD checksum format.""" + parser = BSDFormat() + + # Valid BSD formats + assert parser.can_parse("MD5 (file.txt) = d41d8cd98f00b204e9800998ecf8427e") + assert parser.can_parse( + "SHA256 (test.zip) = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + assert parser.can_parse( + "SHA512 (binary.exe) = cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + ) + assert parser.can_parse("SHA1 (test) = da39a3ee5e6b4b0d3255bfef95601890afd80709") + + # With spaces + assert parser.can_parse( + "SHA256 (file with spaces.txt) = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + # Invalid formats + assert not parser.can_parse("d41d8cd98f00b204e9800998ecf8427e file.txt") + assert not parser.can_parse("file.txt: d41d8cd98f00b204e9800998ecf8427e") + assert not parser.can_parse( + "SHA3 (file.txt) = d41d8cd98f00b204e9800998ecf8427e" + ) # Unsupported algorithm + + def test_parse_bsd_format(self): + """Test parsing of BSD checksum format.""" + parser = BSDFormat() + + content = """ +MD5 (file1.txt) = d41d8cd98f00b204e9800998ecf8427e +SHA256 (file2.zip) = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +SHA1 (binary.exe) = da39a3ee5e6b4b0d3255bfef95601890afd80709 +SHA512 (large.bin) = cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e +SHA256 (file with (parentheses).txt) = 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef +MD5 (UPPERCASE.TXT) = ABCDEF1234567890ABCDEF1234567890 + """ + + result = parser.parse(content) + + assert len(result) == 6 + assert result["file1.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + assert ( + result["file2.zip"] + == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + assert result["binary.exe"] == "da39a3ee5e6b4b0d3255bfef95601890afd80709" + assert ( + result["large.bin"] + == "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + ) + assert ( + result["file with (parentheses).txt"] + == "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ) + assert result["UPPERCASE.TXT"] == "abcdef1234567890abcdef1234567890" # Should be lowercase + + def test_parse_mixed_algorithms(self): + """Test parsing BSD format with mixed algorithms.""" + parser = BSDFormat() + + content = """ +MD5 (file1.txt) = d41d8cd98f00b204e9800998ecf8427e +SHA1 (file1.txt) = da39a3ee5e6b4b0d3255bfef95601890afd80709 +SHA256 (file1.txt) = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + """ + + result = parser.parse(content) + + # Should keep the last one for duplicate filenames + assert len(result) == 1 + assert ( + result["file1.txt"] + == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + +class TestApacheBSDFormat: + """Test cases for ApacheBSDFormat parser.""" + + def test_can_parse_apache_bsd_format(self): + """Test detection of Apache BSD checksum format.""" + parser = ApacheBSDFormat() + + # Valid Apache BSD format + assert parser.can_parse("file.txt: d41d8cd9 8f00b204\n e9800998 ecf8427e") + assert parser.can_parse("test.zip: e3b0c442 98fc1c14") + assert parser.can_parse("file: abcd1234") + + # Invalid formats + assert not parser.can_parse("d41d8cd98f00b204e9800998ecf8427e file.txt") + assert not parser.can_parse("MD5 (file.txt) = d41d8cd98f00b204e9800998ecf8427e") + assert not parser.can_parse("no colon here") + assert not parser.can_parse("") + + def test_parse_apache_bsd_format_single_line(self): + """Test parsing Apache BSD format with single-line checksums.""" + parser = ApacheBSDFormat() + + content = """ +file1.txt: d41d8cd98f00b204e9800998ecf8427e +file2.zip: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + """ + + result = parser.parse(content) + + assert len(result) == 2 + assert result["file1.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + assert ( + result["file2.zip"] + == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + def test_parse_apache_bsd_format_multi_line(self): + """Test parsing Apache BSD format with multi-line checksums.""" + parser = ApacheBSDFormat() + + content = """ +file1.txt: d41d8cd9 8f00b204 + e9800998 ecf8427e +file2.zip: e3b0c442 98fc1c14 9afbf4c8 996fb924 + 27ae41e4 649b934c a495991b 7852b855 +binary.exe: da39a3ee 5e6b4b0d + 3255bfef 95601890 + afd80709 +single.txt: 1234567890abcdef1234567890abcdef12345678 + """ + + result = parser.parse(content) + + assert len(result) == 4 + assert result["file1.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + assert ( + result["file2.zip"] + == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + assert result["binary.exe"] == "da39a3ee5e6b4b0d3255bfef95601890afd80709" + assert result["single.txt"] == "1234567890abcdef1234567890abcdef12345678" + + def test_parse_apache_bsd_format_with_spaces(self): + """Test parsing Apache BSD format with various spacing.""" + parser = ApacheBSDFormat() + + content = """ +file with spaces.txt: d41d8cd9 8f00b204 + e9800998 ecf8427e +another file.zip: ABCD1234 5678ABCD + 9012ABCD 3456CDEF + """ + + result = parser.parse(content) + + assert len(result) == 2 + assert result["file with spaces.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + assert ( + result["another file.zip"] == "abcd12345678abcd9012abcd3456cdef" + ) # Should be lowercase + + def test_parse_apache_bsd_invalid_checksum(self): + """Test parsing Apache BSD format with invalid checksums.""" + parser = ApacheBSDFormat() + + content = """ +valid.txt: d41d8cd98f00b204e9800998ecf8427e +invalid.txt: this is not a valid checksum! +mixed.txt: d41d8cd9 NOTVALID + e9800998 ecf8427e + """ + + result = parser.parse(content) + + # Only valid checksums should be included + assert len(result) == 1 + assert result["valid.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + assert "invalid.txt" not in result + assert "mixed.txt" not in result + + +class TestChecksumParser: + """Test cases for the main ChecksumParser.""" + + def test_parse_standard_format(self): + """Test parser with standard format.""" + parser = ChecksumParser() + + content = "d41d8cd98f00b204e9800998ecf8427e file.txt" + result = parser.parse(content) + + assert result["file.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + + def test_parse_bsd_format(self): + """Test parser with BSD format.""" + parser = ChecksumParser() + + content = ( + "SHA256 (file.txt) = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + result = parser.parse(content) + + assert ( + result["file.txt"] == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + def test_parse_apache_bsd_format(self): + """Test parser with Apache BSD format.""" + parser = ChecksumParser() + + content = """file.txt: d41d8cd9 8f00b204 + e9800998 ecf8427e""" + result = parser.parse(content) + + assert result["file.txt"] == "d41d8cd98f00b204e9800998ecf8427e" + + def test_parse_empty_content(self): + """Test parser with empty content.""" + parser = ChecksumParser() + + result = parser.parse("") + assert result == {} + + def test_parse_unknown_format(self): + """Test parser with unknown format.""" + parser = ChecksumParser() + + content = "This is not a valid checksum format" + result = parser.parse(content) + assert result == {} diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py new file mode 100644 index 0000000000000..2988ecaff64b8 --- /dev/null +++ b/tests/unit/test_common.py @@ -0,0 +1,655 @@ +import base64 +import io +import itertools +import os +import threading +import time +import zipfile +from datetime import date, datetime, timezone +from zoneinfo import ZoneInfo + +import pytest +import yaml + +from localstack import config +from localstack.utils import common +from localstack.utils.archives import unzip +from localstack.utils.common import ( + ExternalServicePortsManager, + PaginatedList, + PortNotAvailableException, + fully_qualified_class_name, + get_free_tcp_port, + is_empty_dir, + load_file, + mkdir, + new_tmp_dir, + rm_rf, + save_file, + short_uid, +) +from localstack.utils.objects import Mock +from localstack.utils.strings import base64_decode, to_bytes +from localstack.utils.testutil import create_zip_file + + +class TestCommon: + def test_first_char_to_lower(self): + env = common.first_char_to_lower("Foobar") + assert env == "foobar" + + def test_truncate(self): + env = common.truncate("foobar", 3) + assert env == "foo..." + + def test_isoformat_milliseconds(self): + env = common.isoformat_milliseconds(datetime(2010, 3, 20, 7, 24, 00, 0)) + assert env == "2010-03-20T07:24:00.000" + + def test_base64_to_hex(self): + env = common.base64_to_hex("Zm9vIGJhcg ==") + assert env == b"666f6f20626172" + + def test_base64_decode(self): + def roundtrip(data): + encoded = base64.urlsafe_b64encode(to_bytes(data)) + result = base64_decode(encoded) + assert to_bytes(data) == result + + # simple examples + roundtrip("test") + roundtrip(b"test \x64 \x01 \x55") + + # strings that require urlsafe encoding (containing "-" or "/" in base64 encoded form) + examples = ((b"=@~", b"PUB+"), (b"???", b"Pz8/")) + for decoded, encoded in examples: + assert base64.b64encode(decoded) == encoded + expected = encoded.replace(b"+", b"-").replace(b"/", b"_") + assert base64.urlsafe_b64encode(decoded) == expected + roundtrip(decoded) + + def test_now(self): + env = common.now() + test = time.time() + assert test == pytest.approx(env, 1) + + def test_now_utc(self): + env = common.now_utc() + test = datetime.now(timezone.utc).timestamp() + assert test == pytest.approx(env, 1) + + def test_is_number(self): + assert common.is_number(5) + + def test_is_ip_address(self): + assert common.is_ip_address("10.0.0.1") + assert not common.is_ip_address("abcde") + + def test_is_base64(self): + assert not common.is_base64("foobar") + + def test_mktime(self): + now = common.mktime(datetime.now()) + assert int(now) == int(time.time()) + + def test_mktime_with_tz(self): + # see https://en.wikipedia.org/wiki/File:1000000000seconds.jpg + dt = datetime(2001, 9, 9, 1, 46, 40, 0, tzinfo=timezone.utc) + assert int(common.mktime(dt)) == 1000000000 + + dt = datetime(2001, 9, 9, 1, 46, 40, 0, tzinfo=ZoneInfo("EST")) + assert int(common.mktime(dt)) == 1000000000 + (5 * 60 * 60) # EST is UTC-5 + + def test_mktime_millis_with_tz(self): + # see https://en.wikipedia.org/wiki/File:1000000000 + dt = datetime(2001, 9, 9, 1, 46, 40, 0, tzinfo=timezone.utc) + assert int(common.mktime(dt, millis=True) / 1000) == 1000000000 + + dt = datetime(2001, 9, 9, 1, 46, 40, 0, tzinfo=ZoneInfo("EST")) + assert int(common.mktime(dt, millis=True)) / 1000 == 1000000000 + ( + 5 * 60 * 60 + ) # EST is UTC-5 + + def test_mktime_millis(self): + now = common.mktime(datetime.now(), millis=True) + assert int(time.time()) == int(now / 1000) + + def test_timestamp_millis(self): + result = common.timestamp_millis(datetime.now()) + assert "T" in result + result = common.timestamp_millis(date.today()) + assert "00:00:00" in result + assert "T" in result + + def test_extract_jsonpath(self): + obj = {"a": {"b": [{"c": 123}, "foo"]}, "e": 234} + result = common.extract_jsonpath(obj, "$.a.b") + assert result == [{"c": 123}, "foo"] + result = common.extract_jsonpath(obj, "$.a.b.c") + assert not result + result = common.extract_jsonpath(obj, "$.foobar") + assert not result + result = common.extract_jsonpath(obj, "$.e") + assert result == 234 + result = common.extract_jsonpath(obj, "$.a.b[0]") + assert result == {"c": 123} + result = common.extract_jsonpath(obj, "$.a.b[0].c") + assert result == 123 + result = common.extract_jsonpath(obj, "$.a.b[1]") + assert result == "foo" + + def test_str_to_bool(self): + assert common.str_to_bool("true") is True + assert common.str_to_bool("True") is True + + assert common.str_to_bool("1") is False + assert common.str_to_bool("0") is False + assert common.str_to_bool("TRUE") is False + assert common.str_to_bool("false") is False + assert common.str_to_bool("False") is False + + assert common.str_to_bool(0) == 0 + + def test_parse_yaml_nodes(self): + obj = {"test": yaml.ScalarNode("tag:yaml.org,2002:int", "123")} + result = common.clone_safe(obj) + assert result == {"test": 123} + obj = { + "foo": [ + yaml.ScalarNode("tag:yaml.org,2002:str", "value"), + yaml.ScalarNode("tag:yaml.org,2002:int", "123"), + yaml.ScalarNode("tag:yaml.org,2002:float", "1.23"), + yaml.ScalarNode("tag:yaml.org,2002:bool", "true"), + ] + } + result = common.clone_safe(obj) + assert result == {"foo": ["value", 123, 1.23, True]} + + def test_to_unique_item_list(self): + assert common.to_unique_items_list([1, 1, 2, 2, 3]) == [1, 2, 3] + assert common.to_unique_items_list(["a"]) == ["a"] + assert common.to_unique_items_list(["a", "b", "a"]) == ["a", "b"] + assert common.to_unique_items_list("aba") == ["a", "b"] + assert common.to_unique_items_list([]) == [] + + def comparator_lower(first, second): + return first.lower() == second.lower() + + assert common.to_unique_items_list(["a", "A", "a"]) == ["a", "A"] + assert common.to_unique_items_list(["a", "A", "a"], comparator_lower) == ["a"] + assert common.to_unique_items_list(["a", "A", "a"], comparator_lower) == ["a"] + + def comparator_str_int(first, second): + return int(first) - int(second) + + assert common.to_unique_items_list(["1", "2", "1", "2"], comparator_str_int) == ["1", "2"] + + def test_retry(self): + exceptions = [] + count = itertools.count() + + def fn(): + i = next(count) + e = RuntimeError("exception %d" % i) + exceptions.append(e) + + if i == 2: + return "two" + + raise e + + ret = common.retry(fn, retries=3, sleep=0.001) + assert ret == "two" + assert len(exceptions) == 3 + + def test_retry_raises_last_exception(self): + exceptions = [] + count = itertools.count() + + def fn(): + i = next(count) + e = RuntimeError("exception %d" % i) + exceptions.append(e) + + raise e + + with pytest.raises(RuntimeError) as ctx: + common.retry(fn, retries=3, sleep=0.001) + + assert exceptions[-1] is ctx.value + assert len(exceptions) == 4 + + def test_run(self): + cmd = "echo 'foobar'" + result = common.run(cmd) + assert result.strip() == "foobar" + + def test_is_command_available(self): + assert common.is_command_available("python3") + assert not common.is_command_available("hopefullydoesntexist") + + def test_camel_to_snake_case(self): + fn = common.camel_to_snake_case + + assert fn("Foo") == "foo" + assert fn("FoobarEd") == "foobar_ed" + assert fn("FooBarEd") == "foo_bar_ed" + assert fn("Foo_Bar") == "foo_bar" + assert fn("Foo__Bar") == "foo__bar" + assert fn("FooBAR") == "foo_bar" + assert fn("HTTPRequest") == "http_request" + assert fn("HTTP_Request") == "http_request" + assert fn("VerifyHTTPRequest") == "verify_http_request" + assert fn("IsHTTP") == "is_http" + assert fn("IsHTTP2Request") == "is_http2_request" + + def test_snake_to_camel_case(self): + fn = common.snake_to_camel_case + + assert fn("foo") == "Foo" + assert fn("foobar_ed") == "FoobarEd" + assert fn("foo_bar_ed") == "FooBarEd" + assert fn("foo_bar") == "FooBar" + assert fn("foo__bar") == "FooBar" + assert fn("foo_b_a_r") == "FooBAR" + + def test_obj_to_xml(self): + fn = common.obj_to_xml + # primitive + assert fn(42) == "42" + assert fn(False) == "False" + assert fn("a") == "a" + # dict only + assert fn({"foo": "bar"}) == "bar" + assert fn({"a": 42}) == "42" + assert fn({"a": 42, "foo": "bar"}) == "42bar" + # list of dicts + assert fn([{"a": 42}, {"a": 43}]) == "4243" + # dict with lists + assert fn({"f": [{"a": 42}, {"a": 43}]}) == "4243" + # empty types + assert fn(None) == "None" + assert fn("") == "" + + def test_parse_json_or_yaml_with_json(self): + markup = """{"foo": "bar", "why": 42, "mylist": [1,2,3]}""" + + doc = common.parse_json_or_yaml(markup) + assert doc == {"foo": "bar", "why": 42, "mylist": [1, 2, 3]} + + def test_parse_json_or_yaml_with_yaml(self): + markup = """ + foo: bar + why: 42 + mylist: + - 1 + - 2 + - 3 + """ + doc = common.parse_json_or_yaml(markup) + assert doc == {"foo": "bar", "why": 42, "mylist": [1, 2, 3]} + + def test_parse_json_or_yaml_with_invalid_syntax_returns_content(self): + markup = "baz" + doc = common.parse_json_or_yaml(markup) + assert doc == markup # FIXME: not sure if this is good behavior + + def test_parse_json_or_yaml_with_empty_string_returns_none(self): + doc = common.parse_json_or_yaml("") + assert doc is None + + def test_format_bytes(self): + fn = common.format_bytes + + assert fn(1) == "1B" + assert fn(100) == "100B" + assert fn(999) == "999B" + assert fn(1e3) == "1KB" + assert fn(1e6) == "1MB" + assert fn(1e7) == "10MB" + assert fn(1e8) == "100MB" + assert fn(1e9) == "1GB" + assert fn(1e12) == "1TB" + + # comma values + assert fn(1e12 + 1e11) == "1.1TB" + assert fn(1e15) == "1000TB" + + # string input + assert fn("123") == "123B" + # invalid number + assert fn("abc") == "n/a" + # negative number + assert fn(-1) == "n/a" # TODO: seems we could support this case + + def test_format_number(self): + fn = common.format_number + assert fn(12, decimals=0) == "12" + assert fn(12, decimals=1) == "12" + assert fn(12.421, decimals=0) == "12" + assert fn(12.521, decimals=0) == "13" + assert fn(12.521, decimals=2) == "12.52" + assert fn(12.521, decimals=3) == "12.521" + assert fn(12.521, decimals=4) == "12.521" + assert fn(-12.521, decimals=4) == "-12.521" + assert fn(-1.2234354123e3, decimals=4) == "-1223.4354" + + def test_cleanup_threads_and_processes_calls_shutdown_hooks(self): + # TODO: move all run/concurrency related tests into separate class + + started = threading.Event() + done = threading.Event() + + def run_method(*args, **kwargs): + started.set() + func_thread = kwargs["_thread"] + # thread waits until it is stopped + func_thread._stop_event.wait() + done.set() + + common.start_thread(run_method) + assert started.wait(timeout=2) + common.cleanup_threads_and_processes() + assert done.wait(timeout=2) + + def test_proxy_map(self): + old_http_proxy = config.OUTBOUND_HTTP_PROXY + old_https_proxy = config.OUTBOUND_HTTPS_PROXY + config.OUTBOUND_HTTP_PROXY = "http://localhost" + config.OUTBOUND_HTTPS_PROXY = "https://localhost" + assert common.get_proxies() == { + "http": config.OUTBOUND_HTTP_PROXY, + "https": config.OUTBOUND_HTTPS_PROXY, + } + config.OUTBOUND_HTTP_PROXY = "" + assert common.get_proxies() == {"https": config.OUTBOUND_HTTPS_PROXY} + config.OUTBOUND_HTTPS_PROXY = "" + assert common.get_proxies() == {} + config.OUTBOUND_HTTP_PROXY = old_http_proxy + config.OUTBOUND_HTTPS_PROXY = old_https_proxy + + def test_fully_qualified_class_name(self): + assert fully_qualified_class_name(Mock) == "localstack.utils.objects.Mock" + + +class TestCommonFileOperations: + def test_disk_usage(self, tmp_path): + f1 = tmp_path / "f1.blob" + f1.write_bytes(b"0" * 100) + + f2 = tmp_path / "f2.blob" + f2.write_bytes(b"0" * 100) + + # subdir + f3_dir = tmp_path / "foo" + f3_dir.mkdir() + f3 = f3_dir / "f3.blob" + f3.write_bytes(b"0" * 100) + + # trees + assert common.disk_usage(tmp_path) == pytest.approx(300, abs=5) + assert common.disk_usage(f3_dir) == pytest.approx(100, abs=5) + + # single file + assert common.disk_usage(f3) == pytest.approx(100, abs=5) + + # invalid path + assert common.disk_usage(tmp_path / "not_in_path") == 0 + + # None + with pytest.raises(TypeError): + assert common.disk_usage(None) == 0 + + def test_replace_in_file(self, tmp_path): + content = """ + 1: {search} + 2: {search} + 3: {sear} + """ + expected = """ + 1: foo + 2: foo + 3: {sear} + """ + + fp = tmp_path / "file.txt" + fp.write_text(content) + + common.replace_in_file("{search}", "foo", fp) + assert fp.read_text() == expected + + # try again, nothing should change + common.replace_in_file("{search}", "foo", fp) + assert fp.read_text() == expected + + def test_replace_in_file_with_non_existing_path(self, tmp_path): + fp = tmp_path / "non_existing_file.txt" + + assert not fp.exists() + common.replace_in_file("foo", "bar", fp) + assert not fp.exists() + + def test_cp_r(self, tmp_path): + source = tmp_path / "source" + target = tmp_path / "target" + + f1 = source / "f1.txt" + f2 = source / "d1" / "f2.txt" + f3 = source / "d1" / "d2" / "f3.txt" + + source.mkdir() + target.mkdir() + f3.parent.mkdir(parents=True) + f1.write_text("f1") + f2.write_text("f2") + f3.write_text("f3") + + common.cp_r(source, target) + + assert (target / "f1.txt").is_file() + assert (target / "d1" / "f2.txt").is_file() + assert (target / "d1" / "f2.txt").is_file() + assert (target / "d1" / "d2" / "f3.txt").is_file() + assert (target / "d1" / "d2" / "f3.txt").read_text() == "f3" + + def test_is_dir_empty(self): + tmp_dir = new_tmp_dir() + assert is_empty_dir(tmp_dir) + + def _check(fname, is_dir): + test_entry = os.path.join(tmp_dir, fname) + mkdir(test_entry) if is_dir else save_file(test_entry, "test content") + assert not is_empty_dir(tmp_dir) + assert is_empty_dir(tmp_dir, ignore_hidden=True) == (fname == ".hidden") + rm_rf(test_entry) + assert is_empty_dir(tmp_dir) + + for name in ["regular", ".hidden"]: + for is_dir in [True, False]: + _check(name, is_dir) + + def test_create_archive(self): + # create archive from empty directory + tmp_dir = new_tmp_dir() + content = create_zip_file(tmp_dir, get_content=True) + zip_obj = zipfile.ZipFile(io.BytesIO(content)) + assert zip_obj.infolist() == [] + rm_rf(tmp_dir) + + # create archive from non-empty directory + tmp_dir = new_tmp_dir() + save_file(os.path.join(tmp_dir, "testfile"), "content 123") + content = create_zip_file(tmp_dir, get_content=True) + zip_obj = zipfile.ZipFile(io.BytesIO(content)) + assert len(zip_obj.infolist()) == 1 + assert zip_obj.infolist()[0].filename == "testfile" + rm_rf(tmp_dir) + + def test_unzip_bad_crc(self): + """Test unzipping of files with incorrect CRC codes - usually works with native `unzip` command, + but seems to fail with zipfile module under certain Python versions (extracts 0-bytes files) + """ + + # base64-encoded zip file with a single entry with incorrect CRC (created by Node.js 18 / Serverless) + zip_base64 = """ + UEsDBBQAAAAIAAAAIQAAAAAAJwAAAAAAAAAjAAAAbm9kZWpzL25vZGVfbW9kdWxlcy9sb2Rhc2gvaW5k + ZXguanPLzU8pzUnVS60oyC8qKVawVShKLSzNLErVUNfTz8lPSSzOUNe0BgBQSwECLQMUAAAACAAAACEA + AAAAACcAAAAAAAAAIwAAAAAAAAAAACAApIEAAAAAbm9kZWpzL25vZGVfbW9kdWxlcy9sb2Rhc2gvaW5k + ZXguanNQSwUGAAAAAAEAAQBRAAAAaAAAAAAA + """ + tmp_dir = new_tmp_dir() + zip_file = os.path.join(tmp_dir, "test.zip") + save_file(zip_file, base64.b64decode(zip_base64)) + unzip(zip_file, tmp_dir) + content = load_file(os.path.join(tmp_dir, "nodejs", "node_modules", "lodash", "index.js")) + assert content.strip() == "module.exports = require('./lodash');" + rm_rf(tmp_dir) + + +def test_save_load_file(tmp_path): + file_name = tmp_path / ("normal_permissions_%s" % short_uid()) + content = "some_content_%s" % short_uid() + more_content = "some_more_content_%s" % short_uid() + + save_file(file_name, content) + assert content == load_file(file_name) + save_file(file_name, more_content, append=True) + assert content + more_content == load_file(file_name) + + +def test_save_load_file_with_permissions(tmp_path): + file_name = tmp_path / ("special_permissions_%s" % short_uid()) + content = "some_content_%s" % short_uid() + more_content = "some_more_content_%s" % short_uid() + permissions = 0o600 + + save_file(file_name, content, permissions=permissions) + assert permissions == os.stat(file_name).st_mode & 0o777 + assert content == load_file(file_name) + save_file(file_name, more_content, append=True) + assert permissions == os.stat(file_name).st_mode & 0o777 + assert content + more_content == load_file(file_name) + + +def test_save_load_file_with_changing_permissions(tmp_path): + file_name = tmp_path / ("changing_permissions_%s" % short_uid()) + content = "some_content_%s" % short_uid() + more_content = "some_more_content_%s" % short_uid() + permissions = 0o600 + + save_file(file_name, content) + assert permissions != os.stat(file_name).st_mode & 0o777 + assert content == load_file(file_name) + # setting the permissions on append should not change the permissions + save_file(file_name, more_content, append=True, permissions=permissions) + assert permissions != os.stat(file_name).st_mode & 0o777 + assert content + more_content == load_file(file_name) + # overwriting the file also will not change the permissions + save_file(file_name, content, permissions=permissions) + assert permissions != os.stat(file_name).st_mode & 0o777 + assert content == load_file(file_name) + + +@pytest.fixture() +def external_service_ports_manager(): + previous_start = config.EXTERNAL_SERVICE_PORTS_START + previous_end = config.EXTERNAL_SERVICE_PORTS_END + # Limit the range to only contain a single port + config.EXTERNAL_SERVICE_PORTS_START = get_free_tcp_port() + config.EXTERNAL_SERVICE_PORTS_END = config.EXTERNAL_SERVICE_PORTS_START + 1 + yield ExternalServicePortsManager() + config.EXTERNAL_SERVICE_PORTS_END = previous_end + config.EXTERNAL_SERVICE_PORTS_START = previous_start + + +class TestExternalServicePortsManager: + def test_reserve_port_within_range( + self, external_service_ports_manager: ExternalServicePortsManager + ): + port = external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_START) + assert port == config.EXTERNAL_SERVICE_PORTS_START + + def test_reserve_port_outside_range( + self, external_service_ports_manager: ExternalServicePortsManager + ): + with pytest.raises(PortNotAvailableException): + external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_END + 1) + + def test_reserve_any_port_within_range( + self, external_service_ports_manager: ExternalServicePortsManager + ): + port = external_service_ports_manager.reserve_port() + assert port == config.EXTERNAL_SERVICE_PORTS_START + + def test_reserve_port_all_reserved( + self, external_service_ports_manager: ExternalServicePortsManager + ): + # the external service ports manager fixture only has 2 ports available, + # reserving 3 has to raise an error, but this could also happen earlier + # (if one of the ports is blocked by something else, like a previous test) + with pytest.raises(PortNotAvailableException): + external_service_ports_manager.reserve_port() + external_service_ports_manager.reserve_port() + external_service_ports_manager.reserve_port() + + def test_reserve_same_port_twice( + self, external_service_ports_manager: ExternalServicePortsManager + ): + external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_START) + with pytest.raises(PortNotAvailableException): + external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_START) + + def test_reserve_custom_expiry( + self, external_service_ports_manager: ExternalServicePortsManager + ): + external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_START, duration=1) + with pytest.raises(PortNotAvailableException): + external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_START) + time.sleep(1) + external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_START) + + def test_check_is_port_reserved( + self, external_service_ports_manager: ExternalServicePortsManager + ): + assert not external_service_ports_manager.is_port_reserved( + config.EXTERNAL_SERVICE_PORTS_START + ) + external_service_ports_manager.reserve_port(config.EXTERNAL_SERVICE_PORTS_START) + assert external_service_ports_manager.is_port_reserved(config.EXTERNAL_SERVICE_PORTS_START) + + +@pytest.fixture() +def paginated_list(): + yield PaginatedList([{"Id": i, "Filter": i.upper()} for i in ["a", "b", "c", "d", "e"]]) + + +class TestPaginatedList: + def test_list_smaller_than_max(self, paginated_list): + page, next_token = paginated_list.get_page(lambda i: i["Id"], page_size=6) + assert len(page) == 5 + assert next_token is None + + def test_next_token(self, paginated_list): + page, next_token = paginated_list.get_page(lambda i: i["Id"], page_size=2) + assert len(page) == 2 + assert next_token == "c" + + def test_continuation(self, paginated_list): + page, next_token = paginated_list.get_page(lambda i: i["Id"], page_size=2, next_token="c") + assert len(page) == 2 + assert next_token == "e" + + def test_end(self, paginated_list): + page, next_token = paginated_list.get_page(lambda i: i["Id"], page_size=2, next_token="e") + assert len(page) == 1 + assert next_token is None + + def test_filter(self, paginated_list): + page, next_token = paginated_list.get_page( + lambda i: i["Id"], page_size=6, filter_function=lambda i: i["Filter"] in ["B", "E"] + ) + assert len(page) == 2 + ids = [i["Id"] for i in page] + assert "b" in ids and "e" in ids + assert "a" not in ids + assert next_token is None diff --git a/tests/unit/test_cors.py b/tests/unit/test_cors.py new file mode 100644 index 0000000000000..b024c0483f86d --- /dev/null +++ b/tests/unit/test_cors.py @@ -0,0 +1,87 @@ +from werkzeug.datastructures import Headers + +from localstack import config +from localstack.aws.handlers import cors +from localstack.config import HostAndPort + +# The default host depends on whether running in Docker (see config.py::default_ip) but that's good enough for testing: +default_gateway_listen = [HostAndPort(host="0.0.0.0", port=4566)] +default_gateway_listen_ext = [ + HostAndPort(host="0.0.0.0", port=4566), + HostAndPort(host="0.0.0.0", port=443), +] + + +def test_allowed_cors_origins_different_ports_and_protocols(monkeypatch): + # test allowed origins for default config (:4566) + # GATEWAY_LISTEN binds each host-port configuration to both protocols (http and https) + monkeypatch.setattr(config, "GATEWAY_LISTEN", default_gateway_listen) + origins = cors._get_allowed_cors_origins() + assert "http://localhost:4566" in origins + assert "http://localhost.localstack.cloud:4566" in origins + assert "http://localhost:433" not in origins + assert "https://localhost.localstack.cloud:443" not in origins + + # test allowed origins for extended config (:4566,:443) + monkeypatch.setattr(config, "GATEWAY_LISTEN", default_gateway_listen_ext) + origins = cors._get_allowed_cors_origins() + assert "http://localhost:4566" in origins + assert "http://localhost:443" in origins + assert "http://localhost.localstack.cloud:4566" in origins + assert "https://localhost.localstack.cloud:443" in origins + + +def test_dynamic_allowed_cors_origins(monkeypatch): + assert _origin_allowed("http://test.s3-website.localhost.localstack.cloud") + assert _origin_allowed("https://test.s3-website.localhost.localstack.cloud") + assert _origin_allowed("http://test.cloudfront.localhost.localstack.cloud") + + assert not _origin_allowed("https://test.appsync.localhost.localstack.cloud") + assert not _origin_allowed("https://testcloudfront.localhost.localstack.cloud") + assert not _origin_allowed("http://test.cloudfront.custom-domain.com") + + +def test_dynamic_allowed_cors_origins_different_ports(monkeypatch): + # test dynamic allowed origins for default config (:4566) + monkeypatch.setattr(config, "GATEWAY_LISTEN", default_gateway_listen) + monkeypatch.setattr(cors, "_ALLOWED_INTERNAL_PORTS", cors._get_allowed_cors_ports()) + + assert _origin_allowed("http://test.s3-website.localhost.localstack.cloud:4566") + assert _origin_allowed("http://test.s3-website.localhost.localstack.cloud") + assert _origin_allowed("https://test.s3-website.localhost.localstack.cloud:4566") + assert _origin_allowed("https://test.s3-website.localhost.localstack.cloud") + assert _origin_allowed("http://test.cloudfront.localhost.localstack.cloud") + + assert not _origin_allowed("https://test.cloudfront.localhost.localstack.cloud:443") + assert not _origin_allowed("http://test.cloudfront.localhost.localstack.cloud:123") + + # test allowed origins for extended config (:4566,:443) + monkeypatch.setattr(config, "GATEWAY_LISTEN", default_gateway_listen_ext) + monkeypatch.setattr(cors, "_ALLOWED_INTERNAL_PORTS", cors._get_allowed_cors_ports()) + + assert _origin_allowed("https://test.cloudfront.localhost.localstack.cloud:443") + + +def test_dynamic_allowed_cors_origins_different_domains(monkeypatch): + # test dynamic allowed origins for default config (edge port 4566) + monkeypatch.setattr(config, "GATEWAY_LISTEN", default_gateway_listen) + monkeypatch.setattr( + config, + "LOCALSTACK_HOST", + config.HostAndPort(host="my-custom-domain.com", port=config.GATEWAY_LISTEN[0].port), + ) + + monkeypatch.setattr( + cors, "_ALLOWED_INTERNAL_DOMAINS", cors._get_allowed_cors_internal_domains() + ) + + assert _origin_allowed("http://test.cloudfront.my-custom-domain.com") + assert _origin_allowed("http://test.s3-website.my-custom-domain.com:4566") + + assert not _origin_allowed("http://test.s3-website.my-wrong-domain.com") + assert not _origin_allowed("http://test.s3-website.my-wrong-domain.com:4566") + + +def _origin_allowed(url) -> bool: + headers = Headers({"Origin": url}) + return cors.CorsEnforcer.is_cors_origin_allowed(headers) diff --git a/tests/unit/test_dns_server.py b/tests/unit/test_dns_server.py new file mode 100644 index 0000000000000..96ffd172b1f7a --- /dev/null +++ b/tests/unit/test_dns_server.py @@ -0,0 +1,491 @@ +import threading +from pathlib import Path + +import dns +import pytest + +from localstack import config +from localstack.aws.spec import iterate_service_operations +from localstack.constants import LOCALHOST_HOSTNAME +from localstack.dns.models import AliasTarget, RecordType, SOARecord, TargetRecord +from localstack.dns.server import ( + HOST_PREFIXES_NO_SUBDOMAIN, + NAME_PATTERNS_POINTING_TO_LOCALSTACK, + DnsServer, + add_resolv_entry, + get_fallback_dns_server, +) +from localstack.utils.net import get_free_udp_port +from localstack.utils.sync import retry + + +class TestDNSServer: + @pytest.fixture + def dns_server(self): + dns_port = get_free_udp_port() + upstream_dns = get_fallback_dns_server() + dns_server = DnsServer( + port=dns_port, protocols=["udp"], host="127.0.0.1", upstream_dns=upstream_dns + ) + dns_server.start() + assert dns_server.wait_is_up(5) + yield dns_server + dns_server.shutdown() + + @pytest.fixture + def query_dns(self, dns_server): + def _query(name: str, record_type: str) -> dns.message.Message: + request = dns.message.make_query(name, record_type) + + def _do_query(): + return dns.query.udp(request, "127.0.0.1", port=dns_server.port, timeout=1) + + return retry(_do_query, retries=5) + + return _query + + def test_dns_server_fallback(self, dns_server, query_dns): + """Test querying an unconfigured DNS server for its upstream requests""" + answer = query_dns("localhost.localstack.cloud", "A") + assert answer.answer + assert "127.0.0.1" in answer.to_text() + + def test_dns_server_add_host_lifecycle(self, dns_server, query_dns): + """Check dns server host entry lifecycle""" + # add ipv4 host + dns_server.add_host("example.org", TargetRecord("122.122.122.122", RecordType.A)) + answer = query_dns("example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + # add ipv6 host + dns_server.add_host("example.org", TargetRecord("::a1", RecordType.AAAA)) + answer = query_dns("example.org", "AAAA") + assert answer.answer + assert "122.122.122.122" not in answer.to_text() + assert "::a1" in answer.to_text() + + # assert ipv6 is not returned in A request + answer = query_dns("example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + assert "::a1" not in answer.to_text() + + # delete ipv4 host + dns_server.delete_host("example.org", TargetRecord("122.122.122.122", RecordType.A)) + answer = query_dns("example.org", "A") + assert answer.answer + assert "122.122.122.122" not in answer.to_text() + + # check that ipv6 host is unaffected + answer = query_dns("example.org", "AAAA") + assert answer.answer + assert "122.122.122.122" not in answer.to_text() + assert "::a1" in answer.to_text() + + # delete ipv6 host + dns_server.delete_host("example.org", TargetRecord("::a1", RecordType.AAAA)) + answer = query_dns("example.org", "AAAA") + assert answer.answer + assert "122.122.122.122" not in answer.to_text() + assert "::a1" not in answer.to_text() + + def test_dns_server_add_host_lifecycle_with_ids(self, dns_server, query_dns): + """Check if deletion with and without ids works as expected""" + # add ipv4 hosts + dns_server.add_host("example.org", TargetRecord("1.1.1.1", RecordType.A, record_id="1")) + dns_server.add_host("example.org", TargetRecord("2.2.2.2", RecordType.A, record_id="2")) + dns_server.add_host("example.org", TargetRecord("3.3.3.3", RecordType.A)) + dns_server.add_host("example.org", TargetRecord("4.4.4.4", RecordType.A)) + + # check if all are returned + answer = query_dns("example.org", "A") + assert answer.answer + assert "1.1.1.1" in answer.to_text() + assert "2.2.2.2" in answer.to_text() + assert "3.3.3.3" in answer.to_text() + assert "4.4.4.4" in answer.to_text() + + # delete by id, check if others are still present + dns_server.delete_host("example.org", TargetRecord("", RecordType.A, record_id="1")) + answer = query_dns("example.org", "A") + assert answer.answer + assert "2.2.2.2" in answer.to_text() + assert "3.3.3.3" in answer.to_text() + assert "4.4.4.4" in answer.to_text() + assert "1.1.1.1" not in answer.to_text() + + # delete without id, check if others are still present + dns_server.delete_host("example.org", TargetRecord("", RecordType.A)) + answer = query_dns("example.org", "A") + assert answer.answer + assert "2.2.2.2" in answer.to_text() + assert "3.3.3.3" not in answer.to_text() + assert "4.4.4.4" not in answer.to_text() + assert "1.1.1.1" not in answer.to_text() + + def test_dns_server_add_multiple_hosts(self, dns_server, query_dns): + """Test whether the dns server correctly works when multiple hosts are added""" + # add ipv4 host + dns_server.add_host(".*.example.org", TargetRecord("122.122.122.122", RecordType.A)) + dns_server.add_host(".*.notmatching.org", TargetRecord("123.123.123.123", RecordType.A)) + answer = query_dns("something.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + answer = query_dns("something.notmatching.org", "A") + assert answer.answer + assert "123.123.123.123" in answer.to_text() + + def test_overriding_with_dns_resolve_ip(self, dns_server, query_dns, monkeypatch): + monkeypatch.setattr(config, "DNS_RESOLVE_IP", "2.2.2.2") + + dns_server.add_host_pointing_to_localstack("example.org") + + answer = query_dns("example.org", "A") + + assert answer.answer + assert "2.2.2.2" in answer.to_text() + + def test_dns_server_soa_record_suffix_matching(self, dns_server, query_dns): + """Check if soa records work with suffix matching""" + # add ipv4 host + soa_target = "something.org." + soa_rname = "noc.something.org." + dns_server.add_host("example.org", SOARecord(soa_target, soa_rname, RecordType.SOA)) + answer = query_dns("something.example.org", "A") + assert answer.answer + assert "something.org." in answer.to_text() + assert "noc.something.org." in answer.to_text() + + def test_dns_server_subdomain_of_route(self, dns_server, query_dns): + """Test querying a subdomain of a record entry without a wildcard""" + # add ipv4 host + dns_server.add_host("example.org", TargetRecord("127.0.0.1", RecordType.A)) + answer = query_dns("nonexistent.example.org", "A") + assert not answer.answer + # should still have authority section + # TODO uncomment once it is clear why in CI the authority section is missing + # assert "ns.icann.org." in answer.to_text() + assert answer.rcode() == dns.rcode.NXDOMAIN + + def test_dns_server_wildcard_matching_with_skip(self, dns_server, query_dns): + """Test a wildcard matching and the skip bypass""" + # add ipv4 host + dns_server.add_host("*.example.org", TargetRecord("122.122.122.122", RecordType.A)) + answer = query_dns("subdomain.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + dns_server.add_skip("skip.example.org") + answer = query_dns("skip.example.org", "A") + assert not answer.answer + # test if skip does not affect other requests + answer = query_dns("subdomain.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + def test_dns_server_specific_name_overrides_wildcard(self, dns_server, query_dns): + dns_server.add_host("*.example.org", TargetRecord("1.2.3.4", RecordType.A)) + dns_server.add_host("foo.example.org", TargetRecord("5.6.7.8", RecordType.A)) + + answer = query_dns("foo.example.org", "A") + + assert answer.answer + assert "5.6.7.8" in answer.to_text() + assert "1.2.3.4" not in answer.to_text() + + def test_redirect_to_localstack_lifecycle(self, dns_server, query_dns): + """Test adding records pointing to LS at all times""" + dns_server.add_host_pointing_to_localstack("*.example.org") + answer = query_dns("subdomain.example.org", "A") + assert answer.answer + assert "127.0.0.1" in answer.to_text() + + # delete host pointing to localstack again + dns_server.delete_host_pointing_to_localstack("*.example.org") + answer = query_dns("subdomain.example.org", "A") + assert not answer.answer + assert "127.0.0.1" not in answer.to_text() + + def test_skip_lifecycle(self, dns_server, query_dns): + """Test adding and removing skip patterns""" + # add ipv4 host + dns_server.add_host("*.example.org", TargetRecord("122.122.122.122", RecordType.A)) + answer = query_dns("subdomain.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + # add skip and check if it works + dns_server.add_skip("skip.example.org") + answer = query_dns("skip.example.org", "A") + assert not answer.answer + + # delete skip again + dns_server.delete_skip("skip.example.org") + answer = query_dns("skip.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + def test_redirect_to_localstack_with_skip(self, dns_server, query_dns): + """Test to-localstack redirects with skip patterns for certain names""" + # add ipv4 host + dns_server.add_host_pointing_to_localstack("*.example.org") + answer = query_dns("subdomain.example.org", "A") + assert answer.answer + assert "127.0.0.1" in answer.to_text() + + dns_server.add_skip("skip.example.org") + answer = query_dns("skip.example.org", "A") + assert not answer.answer + # test if skip does not affect other requests + answer = query_dns("subdomain.example.org", "A") + assert answer.answer + assert "127.0.0.1" in answer.to_text() + + def test_dns_server_clear(self, dns_server, query_dns): + """Check if a clear call resets all added entries in the dns server""" + dns_server.add_host( + "*.subdomain.example.org", TargetRecord("122.122.122.122", RecordType.A) + ) + answer = query_dns("sub.subdomain.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + dns_server.add_skip("skip.subdomain.example.org") + answer = query_dns("skip.subdomain.example.org", "A") + assert not answer.answer + # test if skip does not affect other requests + answer = query_dns("sub.subdomain.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + # add alias + dns_server.add_alias( + source_name="name.example.org", + record_type=RecordType.A, + target=AliasTarget(target="sub.subdomain.example.org"), + ) + answer = query_dns("name.example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + # clear + dns_server.clear() + answer = query_dns("subdomain.example.org", "A") + assert not answer.answer + answer = query_dns("skip.example.org", "A") + assert not answer.answer + answer = query_dns("name.example.org", "A") + assert not answer.answer + + def test_dns_server_alias_lifecycle(self, dns_server, query_dns): + """Test adding and deleting aliases""" + dns_server.add_host("example.org", TargetRecord("122.122.122.122", RecordType.A)) + dns_server.add_alias( + source_name="foo.something.org", + record_type=RecordType.A, + target=AliasTarget(target="example.org"), + ) + answer = query_dns("foo.something.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + # delete alias and try again + dns_server.delete_alias( + source_name="foo.something.org", record_type=RecordType.A, target=AliasTarget(target="") + ) + answer = query_dns("foo.something.org", "A") + assert not answer.answer + + # check if add_host is still available + answer = query_dns("example.org", "A") + assert answer.answer + assert "122.122.122.122" in answer.to_text() + + def test_dns_server_add_alias_lifecycle_with_ids(self, dns_server, query_dns): + """Check if deletion with and without ids works as expected""" + # add ipv4 hosts + dns_server.add_host("target1.example.org", TargetRecord("1.1.1.1", RecordType.A)) + dns_server.add_host("target2.example.org", TargetRecord("2.2.2.2", RecordType.A)) + dns_server.add_host("target3.example.org", TargetRecord("3.3.3.3", RecordType.A)) + dns_server.add_host("target4.example.org", TargetRecord("4.4.4.4", RecordType.A)) + dns_server.add_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target="target1.example.org", alias_id="1"), + ) + dns_server.add_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target="target2.example.org"), + ) + dns_server.add_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target="target3.example.org"), + ) + dns_server.add_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target="target4.example.org", alias_id="4"), + ) + answer = query_dns("alias1.example.org", "A") + assert answer.answer + assert "1.1.1.1" in answer.to_text() + + dns_server.delete_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target="", alias_id="1"), + ) + answer = query_dns("alias1.example.org", "A") + assert answer.answer + assert "2.2.2.2" in answer.to_text() + + dns_server.delete_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target=""), + ) + answer = query_dns("alias1.example.org", "A") + assert answer.answer + assert "4.4.4.4" in answer.to_text() + + def test_dns_server_alias_health_checks(self, dns_server, query_dns): + """Check if aliases work correctly with their health checks""" + # add ipv4 hosts + dns_server.add_host("target1.example.org", TargetRecord("1.1.1.1", RecordType.A)) + dns_server.add_host("target2.example.org", TargetRecord("2.2.2.2", RecordType.A)) + error = threading.Event() + + def health_check(): + nonlocal error + return not error.is_set() + + dns_server.add_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target="target1.example.org", health_check=health_check), + ) + dns_server.add_alias( + source_name="alias1.example.org", + record_type=RecordType.A, + target=AliasTarget(target="target2.example.org"), + ) + answer = query_dns("alias1.example.org", "A") + assert answer.answer + assert "1.1.1.1" in answer.to_text() + + # make health check failing + error.set() + answer = query_dns("alias1.example.org", "A") + assert answer.answer + assert "2.2.2.2" in answer.to_text() + + # make health check pass again + error.clear() + answer = query_dns("alias1.example.org", "A") + assert answer.answer + assert "1.1.1.1" in answer.to_text() + + def test_delete_operations_of_nonexistent_entries(self, dns_server): + """Test that delete operations return a value error if the record/pattern does not exist""" + with pytest.raises(ValueError): + dns_server.delete_host("example.org", TargetRecord("122.122.122.122", RecordType.A)) + + with pytest.raises(ValueError): + dns_server.delete_host_pointing_to_localstack("*.example.org") + + with pytest.raises(ValueError): + dns_server.delete_skip("skip.example.org") + + with pytest.raises(ValueError): + dns_server.delete_alias( + source_name="foo.something.org", + record_type=RecordType.A, + target=AliasTarget(target=""), + ) + + +class TestDnsUtils: + def test_resolv_conf_overwriting(self, tmp_path: Path, monkeypatch): + from localstack.dns import server + + monkeypatch.setattr(server, "in_docker", lambda: True) + + file = tmp_path.joinpath("resolv.conf") + with file.open("w") as outfile: + print("nameserver 127.0.0.11", file=outfile) + + add_resolv_entry(file) + + with file.open() as infile: + new_contents = infile.read() + + assert "nameserver 127.0.0.1" in new_contents.splitlines() + + def test_exising_resolv_conf_contents(self, tmp_path: Path, monkeypatch): + from localstack.dns import server + + monkeypatch.setattr(server, "in_docker", lambda: True) + + file = tmp_path.joinpath("resolv.conf") + with file.open("w") as outfile: + print( + "nameserver 127.0.0.11\n" + "search default.svc.cluster.local svc.cluster.local cluster.local\n" + "options ndots:5", + file=outfile, + ) + + add_resolv_entry(file) + + with file.open() as infile: + new_contents = infile.read() + + lines = new_contents.splitlines() + assert "nameserver 127.0.0.1" in lines + assert "search default.svc.cluster.local svc.cluster.local cluster.local" in lines + assert "options ndots:5" in lines + + # check the previous value is _not_ in the file + assert "nameserver 127.0.0.11" not in lines + + def test_no_resolv_conf_overwriting_on_host(self, tmp_path: Path, monkeypatch): + from localstack.dns import server + + monkeypatch.setattr(server, "in_docker", lambda: False) + + file = tmp_path.joinpath("resolv.conf") + with file.open("w") as outfile: + print("nameserver 127.0.0.11", file=outfile) + + add_resolv_entry(file) + + with file.open() as infile: + new_contents = infile.read() + + assert "nameserver 127.0.0.1" not in new_contents.splitlines() + assert "nameserver 127.0.0.11" in new_contents.splitlines() + + def test_host_prefix_no_subdomain( + self, + ): + """This tests help to detect any potential future new host prefix domains added to the botocore specs. + If this test fails: + 1) Add the new entry to `HOST_PREFIXES_NO_SUBDOMAIN` to reflect any changes + 2) IMPORTANT: Add a public DNS entry for the given host prefix! + """ + unique_prefixes = set() + for service_model, operation in iterate_service_operations(): + if operation.endpoint and operation.endpoint.get("hostPrefix"): + unique_prefixes.add(operation.endpoint["hostPrefix"]) + + non_dot_unique_prefixes = [prefix for prefix in unique_prefixes if not prefix.endswith(".")] + # Intermediary validation to easily summarize all differences + assert set(HOST_PREFIXES_NO_SUBDOMAIN) == set(non_dot_unique_prefixes) + + # Real validation of NAME_PATTERNS_POINTING_TO_LOCALSTACK + for host_prefix in non_dot_unique_prefixes: + assert f"{host_prefix}{LOCALHOST_HOSTNAME}" in NAME_PATTERNS_POINTING_TO_LOCALSTACK diff --git a/tests/unit/test_docker_utils.py b/tests/unit/test_docker_utils.py new file mode 100644 index 0000000000000..6f3afa121dfe9 --- /dev/null +++ b/tests/unit/test_docker_utils.py @@ -0,0 +1,105 @@ +from unittest import mock + +from localstack.utils.container_utils.container_client import VolumeDirMount, VolumeInfo +from localstack.utils.docker_utils import get_host_path_for_path_in_docker + + +class TestDockerUtils: + def test_host_path_for_path_in_docker_windows(self): + with ( + mock.patch("localstack.utils.docker_utils.get_default_volume_dir_mount") as get_volume, + mock.patch("localstack.config.is_in_docker", True), + ): + get_volume.return_value = VolumeInfo( + type="bind", + source=r"C:\Users\localstack\volume\mount", + destination="/var/lib/localstack", + mode="rw", + rw=True, + propagation="rprivate", + ) + result = get_host_path_for_path_in_docker("/var/lib/localstack/some/test/file") + get_volume.assert_called_once() + # this path style is kinda weird, but windows will accept it - no need for manual conversion of / to \ + assert result == r"C:\Users\localstack\volume\mount/some/test/file" + + def test_host_path_for_path_in_docker_linux(self): + with ( + mock.patch("localstack.utils.docker_utils.get_default_volume_dir_mount") as get_volume, + mock.patch("localstack.config.is_in_docker", True), + ): + get_volume.return_value = VolumeInfo( + type="bind", + source="/home/some-user/.cache/localstack/volume", + destination="/var/lib/localstack", + mode="rw", + rw=True, + propagation="rprivate", + ) + result = get_host_path_for_path_in_docker("/var/lib/localstack/some/test/file") + get_volume.assert_called_once() + assert result == "/home/some-user/.cache/localstack/volume/some/test/file" + + def test_host_path_for_path_in_docker_linux_volume_dir(self): + with ( + mock.patch("localstack.utils.docker_utils.get_default_volume_dir_mount") as get_volume, + mock.patch("localstack.config.is_in_docker", True), + ): + get_volume.return_value = VolumeInfo( + type="bind", + source="/home/some-user/.cache/localstack/volume", + destination="/var/lib/localstack", + mode="rw", + rw=True, + propagation="rprivate", + ) + result = get_host_path_for_path_in_docker("/var/lib/localstack") + get_volume.assert_called_once() + assert result == "/home/some-user/.cache/localstack/volume" + + def test_host_path_for_path_in_docker_linux_wrong_path(self): + with ( + mock.patch("localstack.utils.docker_utils.get_default_volume_dir_mount") as get_volume, + mock.patch("localstack.config.is_in_docker", True), + ): + get_volume.return_value = VolumeInfo( + type="bind", + source="/home/some-user/.cache/localstack/volume", + destination="/var/lib/localstack", + mode="rw", + rw=True, + propagation="rprivate", + ) + result = get_host_path_for_path_in_docker("/var/lib/localstacktest") + get_volume.assert_called_once() + assert result == "/var/lib/localstacktest" + result = get_host_path_for_path_in_docker("/etc/some/path") + assert result == "/etc/some/path" + + def test_volume_dir_mount_linux(self): + with ( + mock.patch("localstack.utils.docker_utils.get_default_volume_dir_mount") as get_volume, + mock.patch("localstack.config.is_in_docker", True), + ): + get_volume.return_value = VolumeInfo( + type="bind", + source="/home/some-user/.cache/localstack/volume", + destination="/var/lib/localstack", + mode="rw", + rw=True, + propagation="rprivate", + ) + volume_dir_mount = VolumeDirMount( + "/var/lib/localstack/some/test/file", "/target/file", read_only=False + ) + result = volume_dir_mount.to_docker_sdk_parameters() + get_volume.assert_called_once() + assert result == ( + "/home/some-user/.cache/localstack/volume/some/test/file", + { + "bind": "/target/file", + "mode": "rw", + }, + ) + result = volume_dir_mount.to_str() + assert result == "/home/some-user/.cache/localstack/volume/some/test/file:/target/file" diff --git a/tests/unit/test_dockerclient.py b/tests/unit/test_dockerclient.py new file mode 100644 index 0000000000000..03f9e5cdc63a8 --- /dev/null +++ b/tests/unit/test_dockerclient.py @@ -0,0 +1,391 @@ +import json +import logging +import textwrap +from typing import List +from unittest.mock import patch + +import pytest + +from localstack import config +from localstack.utils.bootstrap import extract_port_flags +from localstack.utils.container_utils.container_client import ( + DockerContainerStatus, + DockerPlatform, + PortMappings, + Ulimit, + Util, +) +from localstack.utils.container_utils.docker_cmd_client import CmdDockerClient + +LOG = logging.getLogger(__name__) + + +class TestDockerClient: + def _docker_cmd(self) -> List[str]: + """Return the string to be used for running Docker commands.""" + return config.DOCKER_CMD.split() + + @patch("localstack.utils.container_utils.docker_cmd_client.run") + def test_list_containers(self, run_mock): + mock_container = { + "ID": "00000000a1", + "Image": "localstack/localstack", + "Names": "localstack-main", + "Labels": "authors=LocalStack Contributors", + "State": "running", + } + return_container = { + "id": mock_container["ID"], + "image": mock_container["Image"], + "name": mock_container["Names"], + "labels": {"authors": "LocalStack Contributors"}, + "status": mock_container["State"], + } + run_mock.return_value = json.dumps(mock_container) + docker_client = CmdDockerClient() + container_list = docker_client.list_containers() + call_arguments = run_mock.call_args[0][0] + LOG.info("Intercepted call arguments: %s", call_arguments) + assert container_list[0] == return_container + assert list_in(self._docker_cmd() + ["ps"], call_arguments) + assert "-a" in call_arguments + assert "--format" in call_arguments + + @patch("localstack.utils.container_utils.docker_cmd_client.run") + def test_container_status(self, run_mock): + test_output = "Up 2 minutes - localstack-main" + run_mock.return_value = test_output + docker_client = CmdDockerClient() + status = docker_client.get_container_status("localstack-main") + assert status == DockerContainerStatus.UP + run_mock.return_value = "Exited (0) 1 minute ago - localstack-main" + status = docker_client.get_container_status("localstack-main") + assert status == DockerContainerStatus.DOWN + run_mock.return_value = "STATUS NAME" + status = docker_client.get_container_status("localstack-main") + assert status == DockerContainerStatus.NON_EXISTENT + + +class TestArgumentParsing: + def test_parsing_with_defaults(self): + test_env_string = "-e TEST_ENV_VAR=test_string=123" + test_mount_string = "-v /var/test:/opt/test" + test_network_string = "--network bridge" + test_platform_string = "--platform linux/arm64" + test_privileged_string = "--privileged" + test_port_string = "-p 80:8080/udp" + test_port_string_with_host = "-p 127.0.0.1:6000:7000/tcp" + test_port_string_many_to_one = "-p 9230-9231:9230" + test_ulimit_string = "--ulimit nofile=768:1024 --ulimit nproc=3" + test_user_string = "-u sbx_user1051" + test_dns_string = "--dns 1.2.3.4 --dns 5.6.7.8" + argument_string = " ".join( + [ + test_env_string, + test_mount_string, + test_network_string, + test_port_string, + test_port_string_with_host, + test_port_string_many_to_one, + test_platform_string, + test_privileged_string, + test_ulimit_string, + test_user_string, + test_dns_string, + ] + ) + env_vars = {} + mounts = [] + network = "host" + platform = DockerPlatform.linux_amd64 + privileged = False + ports = PortMappings() + user = "root" + ulimits = [Ulimit(name="nproc", soft_limit=10, hard_limit=10)] + flags = Util.parse_additional_flags( + argument_string, + env_vars=env_vars, + volumes=mounts, + network=network, + platform=platform, + privileged=privileged, + ports=ports, + ulimits=ulimits, + user=user, + ) + assert env_vars == {"TEST_ENV_VAR": "test_string=123"} + assert mounts == [("/var/test", "/opt/test")] + assert flags.network == "bridge" + assert flags.platform == "linux/arm64" + assert flags.privileged + assert ports.to_str() == "-p 80:8080/udp -p 6000:7000 -p 9230-9231:9230" + assert flags.ulimits == [ + Ulimit(name="nproc", soft_limit=3, hard_limit=3), + Ulimit(name="nofile", soft_limit=768, hard_limit=1024), + ] + assert flags.user == "sbx_user1051" + assert flags.dns == ["1.2.3.4", "5.6.7.8"] + + argument_string = ( + "--add-host host.docker.internal:host-gateway --add-host arbitrary.host:127.0.0.1" + ) + flags = Util.parse_additional_flags( + argument_string, env_vars=env_vars, ports=ports, volumes=mounts + ) + assert { + "host.docker.internal": "host-gateway", + "arbitrary.host": "127.0.0.1", + } == flags.extra_hosts + + def test_parsing_exceptions(self): + with pytest.raises(NotImplementedError): + argument_string = "--somerandomargument" + Util.parse_additional_flags(argument_string) + with pytest.raises(ValueError): + argument_string = "--publish 80:80:80:80" + Util.parse_additional_flags(argument_string) + with pytest.raises(NotImplementedError): + argument_string = "--ulimit nofile=768:1024 nproc=3" + Util.parse_additional_flags(argument_string) + + def test_file_paths(self): + argument_string = r'-v "/tmp/test.jar:/tmp/foo bar/test.jar"' + flags = Util.parse_additional_flags(argument_string) + assert flags.volumes == [(r"/tmp/test.jar", "/tmp/foo bar/test.jar")] + argument_string = r'-v "/tmp/test-foo_bar.jar:/tmp/test-foo_bar2.jar"' + flags = Util.parse_additional_flags(argument_string) + assert flags.volumes == [(r"/tmp/test-foo_bar.jar", "/tmp/test-foo_bar2.jar")] + + def test_labels(self): + argument_string = r"--label foo=bar.123" + flags = Util.parse_additional_flags(argument_string) + assert flags.labels == {"foo": "bar.123"} + argument_string = r'--label foo="bar 123"' # test with whitespaces + flags = Util.parse_additional_flags(argument_string) + assert flags.labels == {"foo": "bar 123"} + argument_string = r'--label foo1="bar" --label foo2="baz"' # test with multiple labels + flags = Util.parse_additional_flags(argument_string) + assert flags.labels == {"foo1": "bar", "foo2": "baz"} + argument_string = r"--label foo=bar=baz" # assert label values that contain equal signs + flags = Util.parse_additional_flags(argument_string) + assert flags.labels == {"foo": "bar=baz"} + argument_string = r'--label ""' # assert that we gracefully handle invalid labels + flags = Util.parse_additional_flags(argument_string) + assert flags.labels == {} + argument_string = r"--label =bar" # assert that we ignore empty labels + flags = Util.parse_additional_flags(argument_string) + assert flags.labels == {} + + def test_network(self): + argument_string = r'-v "/tmp/test.jar:/tmp/foo bar/test.jar" --network mynet123' + flags = Util.parse_additional_flags(argument_string) + assert flags.network == "mynet123" + + def test_platform(self): + argument_string = "--platform linux/arm64" + flags = Util.parse_additional_flags(argument_string) + assert flags.platform == DockerPlatform.linux_arm64 + + def test_privileged(self): + argument_string = r"--privileged" + flags = Util.parse_additional_flags(argument_string) + assert flags.privileged + argument_string = "" + flags = Util.parse_additional_flags(argument_string) + assert not flags.privileged + + def test_ulimits(self): + argument_string = r"--ulimit nofile=1024" + flags = Util.parse_additional_flags(argument_string) + assert flags.ulimits == [Ulimit(name="nofile", soft_limit=1024, hard_limit=1024)] + + def test_user(self): + argument_string = r"-u nobody" + flags = Util.parse_additional_flags(argument_string) + assert flags.user == "nobody" + + def test_dns(self): + argument_string = "--dns 1.2.3.4" + flags = Util.parse_additional_flags(argument_string) + assert flags.dns == ["1.2.3.4"] + + argument_string = "--dns 1.2.3.4 --dns 5.6.7.8" + flags = Util.parse_additional_flags(argument_string) + assert flags.dns == ["1.2.3.4", "5.6.7.8"] + + argument_string = "" + flags = Util.parse_additional_flags(argument_string) + assert flags.dns == [] + + def test_windows_paths(self): + argument_string = r'-v "C:\Users\SomeUser\SomePath:/var/task"' + flags = Util.parse_additional_flags(argument_string) + assert flags.volumes == [(r"C:\Users\SomeUser\SomePath", "/var/task")] + argument_string = r'-v "C:\Users\SomeUser\SomePath:/var/task:ro"' + flags = Util.parse_additional_flags(argument_string) + assert flags.volumes == [(r"C:\Users\SomeUser\SomePath", "/var/task")] + argument_string = r'-v "C:\Users\Some User\Some Path:/var/task:ro"' + flags = Util.parse_additional_flags(argument_string) + assert flags.volumes == [(r"C:\Users\Some User\Some Path", "/var/task")] + argument_string = r'-v "/var/test:/var/task:ro"' + flags = Util.parse_additional_flags(argument_string) + assert flags.volumes == [("/var/test", "/var/task")] + + def test_random_ports(self): + argument_string = r"-p 0:80" + ports = PortMappings() + Util.parse_additional_flags(argument_string, ports=ports) + assert ports.to_str() == "-p 0:80" + assert ports.to_dict() == {"80/tcp": None} + + def test_env_files(self, tmp_path): + env_file_1 = tmp_path / "env1" + env_file_2 = tmp_path / "env2" + env_vars_1 = textwrap.dedent(""" + # Some comment + TEST1=VAL1 + TEST2=VAL2 + TEST3=${TEST2} + """) + env_vars_2 = textwrap.dedent(""" + # Some comment + TEST3=VAL3_OVERRIDE + """) + env_file_1.write_text(env_vars_1) + env_file_2.write_text(env_vars_2) + + argument_string = f"--env-file {env_file_1}" + flags = Util.parse_additional_flags(argument_string) + assert flags.env_vars == { + "TEST1": "VAL1", + "TEST2": "VAL2", + "TEST3": "${TEST2}", + } + + argument_string = f"-e TEST2=VAL2_OVERRIDE --env-file {env_file_1} --env-file {env_file_2}" + flags = Util.parse_additional_flags(argument_string) + assert flags.env_vars == { + "TEST1": "VAL1", + "TEST2": "VAL2_OVERRIDE", + "TEST3": "VAL3_OVERRIDE", + } + + def test_compose_env_files(self, tmp_path): + env_file_1 = tmp_path / "env1" + env_file_2 = tmp_path / "env2" + env_vars_1 = textwrap.dedent(""" + # Some comment + TEST1=VAL1 + TEST2=VAL2 + TEST3=${TEST2} + TEST4="VAL4" + """) + env_vars_2 = textwrap.dedent(""" + # Some comment + TEST3=VAL3_OVERRIDE + """) + env_file_1.write_text(env_vars_1) + env_file_2.write_text(env_vars_2) + + argument_string = f"--compose-env-file {env_file_1}" + flags = Util.parse_additional_flags(argument_string) + assert flags.env_vars == { + "TEST1": "VAL1", + "TEST2": "VAL2", + "TEST3": "VAL2", + "TEST4": "VAL4", + } + + argument_string = f"-e TEST2=VAL2_OVERRIDE --compose-env-file {env_file_1} --compose-env-file {env_file_2}" + flags = Util.parse_additional_flags(argument_string) + assert flags.env_vars == { + "TEST1": "VAL1", + "TEST2": "VAL2_OVERRIDE", + "TEST3": "VAL3_OVERRIDE", + "TEST4": "VAL4", + } + + +def list_in(a, b): + return len(a) <= len(b) and any((b[x : x + len(a)] == a for x in range(len(b) - len(a) + 1))) + + +class TestPortMappings: + def test_extract_port_flags(self): + port_mappings = PortMappings() + flags = extract_port_flags("foo -p 1234:1234 bar", port_mappings=port_mappings) + assert flags == "foo bar" + mapping_str = port_mappings.to_str() + assert mapping_str == "-p 1234:1234" + + port_mappings = PortMappings() + flags = extract_port_flags( + "foo -p 1234:1234 bar -p 80-90:81-91 baz", port_mappings=port_mappings + ) + assert flags == "foo bar baz" + mapping_str = port_mappings.to_str() + assert "-p 1234:1234" in mapping_str + assert "-p 80-90:81-91" in mapping_str + + def test_overlapping_port_ranges(self): + port_mappings = PortMappings() + port_mappings.add(4590) + port_mappings.add(4591) + port_mappings.add(4593) + port_mappings.add(4592) + port_mappings.add(4593) + result = port_mappings.to_str() + # assert that ranges are non-overlapping, i.e., no duplicate ports + assert "-p 4593:4593" in result + assert "-p 4590-4592:4590-4592" in result + + def test_port_ranges_with_bind_host(self): + port_mappings = PortMappings(bind_host="0.0.0.0") + port_mappings.add(5000) + port_mappings.add(5001) + port_mappings.add(5003) + port_mappings.add([5004, 5006], 9000) + result = port_mappings.to_str() + assert ( + result + == "-p 0.0.0.0:5000-5001:5000-5001 -p 0.0.0.0:5003:5003 -p 0.0.0.0:5004-5006:9000" + ) + + def test_port_ranges_with_bind_host_to_dict(self): + port_mappings = PortMappings(bind_host="0.0.0.0") + port_mappings.add(5000, 6000) + port_mappings.add(5001, 7000) + port_mappings.add(5003, 8000) + port_mappings.add([5004, 5006], 9000) + result = port_mappings.to_dict() + expected_result = { + "6000/tcp": ("0.0.0.0", 5000), + "7000/tcp": ("0.0.0.0", 5001), + "8000/tcp": ("0.0.0.0", 5003), + "9000/tcp": ("0.0.0.0", [5004, 5005, 5006]), + } + assert result == expected_result + + def test_many_to_one_adjacent_to_uniform(self): + port_mappings = PortMappings() + port_mappings.add(5002) + port_mappings.add(5003) + port_mappings.add([5004, 5006], 5004) + expected_result = { + "5002/tcp": 5002, + "5003/tcp": 5003, + "5004/tcp": [5004, 5005, 5006], + } + result = port_mappings.to_dict() + assert result == expected_result + + def test_adjacent_port_to_many_to_one(self): + port_mappings = PortMappings() + port_mappings.add([7000, 7002], 7000) + port_mappings.add(6999) + expected_result = { + "6999/tcp": 6999, + "7000/tcp": [7000, 7001, 7002], + } + result = port_mappings.to_dict() + assert result == expected_result diff --git a/tests/unit/test_edge.py b/tests/unit/test_edge.py new file mode 100644 index 0000000000000..09eb91c580e46 --- /dev/null +++ b/tests/unit/test_edge.py @@ -0,0 +1,76 @@ +from typing import List + +import pytest +import requests +from pytest_httpserver.httpserver import HTTPServer + +from localstack.config import HostAndPort +from localstack.services.edge import start_proxy +from localstack.utils.net import get_free_tcp_port + + +def gateway_listen_value(httpserver: HTTPServer) -> List[HostAndPort]: + return [HostAndPort(host=httpserver.host, port=httpserver.port)] + + +def test_edge_tcp_proxy(httpserver): + # Prepare the target server + httpserver.expect_request("/").respond_with_data( + "Target Server Response", status=200, content_type="text/plain" + ) + + # Point the Edge TCP proxy towards the target server + gateway_listen = gateway_listen_value(httpserver) + + # Start the TCP proxy + port = get_free_tcp_port() + proxy_server = start_proxy( + listen_str=f"127.0.0.1:{port}", + target_address=gateway_listen[0], + asynchronous=True, + ) + proxy_server.wait_is_up() + + # Check that the forwarding works correctly + try: + response = requests.get(f"http://localhost:{port}") + assert response.status_code == 200 + assert response.text == "Target Server Response" + finally: + proxy_server.shutdown() + + +def test_edge_tcp_proxy_does_not_terminate_on_connection_error(): + # Point the Edge TCP proxy towards a port which is not bound to any server + dst_port = get_free_tcp_port() + + # Start the TCP proxy + port = get_free_tcp_port() + proxy_server = start_proxy( + listen_str=f"127.0.0.1:{port}", + target_address=HostAndPort(host="127.0.0.1", port=dst_port), + asynchronous=True, + ) + try: + proxy_server.wait_is_up() + # Start the proxy server and send a request (which is proxied towards a non-bound port) + with pytest.raises(requests.exceptions.ConnectionError): + requests.get(f"http://localhost:{port}") + + # Bind an HTTP server to the target port + httpserver = HTTPServer(host="localhost", port=dst_port, ssl_context=None) + try: + httpserver.start() + httpserver.expect_request("/").respond_with_data( + "Target Server Response", status=200, content_type="text/plain" + ) + # Now that the target server is up and running, the proxy request is successful + response = requests.get(f"http://localhost:{port}") + assert response.status_code == 200 + assert response.text == "Target Server Response" + finally: + httpserver.clear() + if httpserver.is_running(): + httpserver.stop() + finally: + proxy_server.shutdown() diff --git a/tests/unit/test_iputils.py b/tests/unit/test_iputils.py new file mode 100644 index 0000000000000..b52b3037e014a --- /dev/null +++ b/tests/unit/test_iputils.py @@ -0,0 +1,22 @@ +from ipaddress import IPv4Address + +import pytest + +from localstack.utils import iputils + +# Only run these tests if `ip` is available on the test host +pytestmark = [ + pytest.mark.skipif(condition=not iputils.ip_available, reason="ip command must be available"), +] + + +def test_ip_route_show(): + # test that the command runs for now + for _ in list(iputils.get_routes()): + pass + + +def test_default_gateway(): + gateway = iputils.get_default_gateway() + + assert isinstance(gateway, IPv4Address) diff --git a/tests/unit/test_lambda.py b/tests/unit/test_lambda.py deleted file mode 100644 index 131d70239d870..0000000000000 --- a/tests/unit/test_lambda.py +++ /dev/null @@ -1,211 +0,0 @@ -import unittest -import json -from localstack.services.awslambda import lambda_api, lambda_executors -from localstack.utils.aws.aws_models import LambdaFunction - - -class TestLambdaAPI(unittest.TestCase): - CODE_SIZE = 50 - HANDLER = 'index.handler' - RUNTIME = 'node.js4.3' - TIMEOUT = 60 # Default value, hardcoded - FUNCTION_NAME = 'test1' - ALIAS_NAME = 'alias1' - ALIAS2_NAME = 'alias2' - RESOURCENOTFOUND_EXCEPTION = 'ResourceNotFoundException' - RESOURCENOTFOUND_MESSAGE = 'Function not found: %s' - ALIASEXISTS_EXCEPTION = 'ResourceConflictException' - ALIASEXISTS_MESSAGE = 'Alias already exists: %s' - ALIASNOTFOUND_EXCEPTION = 'ResourceNotFoundException' - ALIASNOTFOUND_MESSAGE = 'Alias not found: %s' - TEST_UUID = 'Test' - - def setUp(self): - lambda_api.cleanup() - self.maxDiff = None - self.app = lambda_api.app - self.app.testing = True - self.client = self.app.test_client() - - def test_delete_event_source_mapping(self): - with self.app.test_request_context(): - lambda_api.event_source_mappings.append({'UUID': self.TEST_UUID}) - result = lambda_api.delete_event_source_mapping(self.TEST_UUID) - self.assertEqual(json.loads(result.get_data()).get('UUID'), self.TEST_UUID) - self.assertEqual(0, len(lambda_api.event_source_mappings)) - - def test_publish_function_version(self): - with self.app.test_request_context(): - self._create_function(self.FUNCTION_NAME) - - result = json.loads(lambda_api.publish_version(self.FUNCTION_NAME).get_data()) - result2 = json.loads(lambda_api.publish_version(self.FUNCTION_NAME).get_data()) - - expected_result = dict() - expected_result['CodeSize'] = self.CODE_SIZE - expected_result['FunctionArn'] = str(lambda_api.func_arn(self.FUNCTION_NAME)) + ':1' - expected_result['FunctionName'] = str(self.FUNCTION_NAME) - expected_result['Handler'] = str(self.HANDLER) - expected_result['Runtime'] = str(self.RUNTIME) - expected_result['Timeout'] = self.TIMEOUT - expected_result['Version'] = '1' - expected_result2 = dict(expected_result) - expected_result2['FunctionArn'] = str(lambda_api.func_arn(self.FUNCTION_NAME)) + ':2' - expected_result2['Version'] = '2' - self.assertDictEqual(expected_result, result) - self.assertDictEqual(expected_result2, result2) - - def test_publish_non_existant_function_version_returns_error(self): - with self.app.test_request_context(): - result = json.loads(lambda_api.publish_version(self.FUNCTION_NAME).get_data()) - self.assertEqual(self.RESOURCENOTFOUND_EXCEPTION, result['__type']) - self.assertEqual(self.RESOURCENOTFOUND_MESSAGE % lambda_api.func_arn(self.FUNCTION_NAME), - result['message']) - - def test_list_function_versions(self): - with self.app.test_request_context(): - self._create_function(self.FUNCTION_NAME) - lambda_api.publish_version(self.FUNCTION_NAME) - lambda_api.publish_version(self.FUNCTION_NAME) - - result = json.loads(lambda_api.list_versions(self.FUNCTION_NAME).get_data()) - - latest_version = dict() - latest_version['CodeSize'] = self.CODE_SIZE - latest_version['FunctionArn'] = str(lambda_api.func_arn(self.FUNCTION_NAME)) + ':$LATEST' - latest_version['FunctionName'] = str(self.FUNCTION_NAME) - latest_version['Handler'] = str(self.HANDLER) - latest_version['Runtime'] = str(self.RUNTIME) - latest_version['Timeout'] = self.TIMEOUT - latest_version['Version'] = '$LATEST' - version1 = dict(latest_version) - version1['FunctionArn'] = str(lambda_api.func_arn(self.FUNCTION_NAME)) + ':1' - version1['Version'] = '1' - version2 = dict(latest_version) - version2['FunctionArn'] = str(lambda_api.func_arn(self.FUNCTION_NAME)) + ':2' - version2['Version'] = '2' - expected_result = {'Versions': sorted([latest_version, version1, version2], - key=lambda k: str(k.get('Version')))} - self.assertDictEqual(expected_result, result) - - def test_list_non_existant_function_versions_returns_error(self): - with self.app.test_request_context(): - result = json.loads(lambda_api.list_versions(self.FUNCTION_NAME).get_data()) - self.assertEqual(self.RESOURCENOTFOUND_EXCEPTION, result['__type']) - self.assertEqual(self.RESOURCENOTFOUND_MESSAGE % lambda_api.func_arn(self.FUNCTION_NAME), - result['message']) - - def test_create_alias(self): - self._create_function(self.FUNCTION_NAME) - self.client.post('{0}/functions/{1}/versions'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME)) - - response = self.client.post('{0}/functions/{1}/aliases'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME), - data=json.dumps({'Name': self.ALIAS_NAME, 'FunctionVersion': '1', - 'Description': ''})) - result = json.loads(response.get_data()) - - expected_result = {'AliasArn': lambda_api.func_arn(self.FUNCTION_NAME) + ':' + self.ALIAS_NAME, - 'FunctionVersion': '1', 'Description': '', 'Name': self.ALIAS_NAME} - self.assertDictEqual(expected_result, result) - - def test_create_alias_on_non_existant_function_returns_error(self): - with self.app.test_request_context(): - result = json.loads(lambda_api.create_alias(self.FUNCTION_NAME).get_data()) - self.assertEqual(self.RESOURCENOTFOUND_EXCEPTION, result['__type']) - self.assertEqual(self.RESOURCENOTFOUND_MESSAGE % lambda_api.func_arn(self.FUNCTION_NAME), - result['message']) - - def test_create_alias_returns_error_if_already_exists(self): - self._create_function(self.FUNCTION_NAME) - self.client.post('{0}/functions/{1}/versions'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME)) - data = json.dumps({'Name': self.ALIAS_NAME, 'FunctionVersion': '1', 'Description': ''}) - self.client.post('{0}/functions/{1}/aliases'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME), data=data) - - response = self.client.post('{0}/functions/{1}/aliases'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME), - data=data) - result = json.loads(response.get_data()) - - alias_arn = lambda_api.func_arn(self.FUNCTION_NAME) + ':' + self.ALIAS_NAME - self.assertEqual(self.ALIASEXISTS_EXCEPTION, result['__type']) - self.assertEqual(self.ALIASEXISTS_MESSAGE % alias_arn, - result['message']) - - def test_update_alias(self): - self._create_function(self.FUNCTION_NAME) - self.client.post('{0}/functions/{1}/versions'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME)) - self.client.post('{0}/functions/{1}/aliases'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME), - data=json.dumps({ - 'Name': self.ALIAS_NAME, 'FunctionVersion': '1', 'Description': ''})) - - response = self.client.put('{0}/functions/{1}/aliases/{2}'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME, - self.ALIAS_NAME), - data=json.dumps({'FunctionVersion': '$LATEST', 'Description': 'Test-Description'})) - result = json.loads(response.get_data()) - - expected_result = {'AliasArn': lambda_api.func_arn(self.FUNCTION_NAME) + ':' + self.ALIAS_NAME, - 'FunctionVersion': '$LATEST', 'Description': 'Test-Description', - 'Name': self.ALIAS_NAME} - self.assertDictEqual(expected_result, result) - - def test_update_alias_on_non_existant_function_returns_error(self): - with self.app.test_request_context(): - result = json.loads(lambda_api.update_alias(self.FUNCTION_NAME, self.ALIAS_NAME).get_data()) - self.assertEqual(self.RESOURCENOTFOUND_EXCEPTION, result['__type']) - self.assertEqual(self.RESOURCENOTFOUND_MESSAGE % lambda_api.func_arn(self.FUNCTION_NAME), - result['message']) - - def test_update_alias_on_non_existant_alias_returns_error(self): - with self.app.test_request_context(): - self._create_function(self.FUNCTION_NAME) - result = json.loads(lambda_api.update_alias(self.FUNCTION_NAME, self.ALIAS_NAME).get_data()) - alias_arn = lambda_api.func_arn(self.FUNCTION_NAME) + ':' + self.ALIAS_NAME - self.assertEqual(self.ALIASNOTFOUND_EXCEPTION, result['__type']) - self.assertEqual(self.ALIASNOTFOUND_MESSAGE % alias_arn, result['message']) - - def test_list_aliases(self): - self._create_function(self.FUNCTION_NAME) - self.client.post('{0}/functions/{1}/versions'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME)) - - self.client.post('{0}/functions/{1}/aliases'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME), - data=json.dumps({'Name': self.ALIAS2_NAME, 'FunctionVersion': '$LATEST'})) - self.client.post('{0}/functions/{1}/aliases'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME), - data=json.dumps({'Name': self.ALIAS_NAME, 'FunctionVersion': '1', - 'Description': self.ALIAS_NAME})) - - response = self.client.get('{0}/functions/{1}/aliases'.format(lambda_api.PATH_ROOT, self.FUNCTION_NAME)) - result = json.loads(response.get_data()) - expected_result = {'Aliases': [ - { - 'AliasArn': lambda_api.func_arn(self.FUNCTION_NAME) + ':' + self.ALIAS_NAME, - 'FunctionVersion': '1', - 'Name': self.ALIAS_NAME, - 'Description': self.ALIAS_NAME - }, - { - 'AliasArn': lambda_api.func_arn(self.FUNCTION_NAME) + ':' + self.ALIAS2_NAME, - 'FunctionVersion': '$LATEST', - 'Name': self.ALIAS2_NAME, - 'Description': '' - } - ]} - self.assertDictEqual(expected_result, result) - - def test_list_non_existant_function_aliases_returns_error(self): - with self.app.test_request_context(): - result = json.loads(lambda_api.list_aliases(self.FUNCTION_NAME).get_data()) - self.assertEqual(self.RESOURCENOTFOUND_EXCEPTION, result['__type']) - self.assertEqual(self.RESOURCENOTFOUND_MESSAGE % lambda_api.func_arn(self.FUNCTION_NAME), - result['message']) - - def test_get_container_name(self): - executor = lambda_executors.EXECUTOR_CONTAINERS_REUSE - name = executor.get_container_name('arn:aws:lambda:us-east-1:00000000:function:my_function_name') - self.assertEqual(name, 'localstack_lambda_arn_aws_lambda_us-east-1_00000000_function_my_function_name') - - def _create_function(self, function_name): - arn = lambda_api.func_arn(function_name) - lambda_api.arn_to_lambda[arn] = LambdaFunction(arn) - lambda_api.arn_to_lambda[arn].versions = {'$LATEST': {'CodeSize': self.CODE_SIZE}} - lambda_api.arn_to_lambda[arn].handler = self.HANDLER - lambda_api.arn_to_lambda[arn].runtime = self.RUNTIME - lambda_api.arn_to_lambda[arn].envvars = {} diff --git a/tests/unit/test_message_transformation.py b/tests/unit/test_message_transformation.py deleted file mode 100644 index 334f02e9c027d..0000000000000 --- a/tests/unit/test_message_transformation.py +++ /dev/null @@ -1,84 +0,0 @@ -import json -import base64 -from localstack.utils.aws.aws_stack import render_velocity_template -from localstack.utils.common import to_str - - -# template used to transform incoming requests at the API Gateway (forward to Kinesis) -APIGATEWAY_TRANSFORMATION_TEMPLATE = """{ - "StreamName": "stream-1", - "Records": [ - #set( $numRecords = $input.path('$.records').size() ) - #if($numRecords > 0) - #set( $maxIndex = $numRecords - 1 ) - #foreach( $idx in [0..$maxIndex] ) - #set( $elem = $input.path("$.records[${idx}]") ) - #set( $elemJsonB64 = $util.base64Encode($input.json("$.records[${idx}].data")) ) - { - "Data": "$elemJsonB64", - "PartitionKey": #if( $elem.partitionKey != '')"$elem.partitionKey" - #else"$elemJsonB64.length()"#end - }#if($foreach.hasNext),#end - #end - #end - ] -}""" - - -def test_array_size(): - template = "#set($list = $input.path('$.records')) $list.size()" - context = { - 'records': [{ - 'data': {'foo': 'bar1'} - }, { - 'data': {'foo': 'bar2'} - }] - } - result = render_velocity_template(template, context) - assert(result == ' 2') - result = render_velocity_template(template, json.dumps(context)) - assert(result == ' 2') - - -def test_message_transformation(): - template = APIGATEWAY_TRANSFORMATION_TEMPLATE - records = [ - { - 'data': { - 'foo': 'foo1', - 'bar': 'bar2' - } - }, - { - 'data': { - 'foo': 'foo1', - 'bar': 'bar2' - }, - 'partitionKey': 'key123' - } - ] - context = { - 'records': records - } - # try rendering the template - result = render_velocity_template(template, context, as_json=True) - result_decoded = json.loads(to_str(base64.b64decode(result['Records'][0]['Data']))) - assert result_decoded == records[0]['data'] - assert len(result['Records'][0]['PartitionKey']) > 0 - assert result['Records'][1]['PartitionKey'] == 'key123' - # try again with context as string - context = json.dumps(context) - result = render_velocity_template(template, context, as_json=True) - result_decoded = json.loads(to_str(base64.b64decode(result['Records'][0]['Data']))) - assert result_decoded == records[0]['data'] - assert len(result['Records'][0]['PartitionKey']) > 0 - assert result['Records'][1]['PartitionKey'] == 'key123' - - # test with empty array - records = [] - context = { - 'records': records - } - # try rendering the template - result = render_velocity_template(template, context, as_json=True) - assert result['Records'] == [] diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index c42eaa17b548d..98ff9a7aceafa 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -1,48 +1,158 @@ +import asyncio +import concurrent.futures +import datetime import time -from requests.models import Response -from localstack.utils.aws import aws_stack -from localstack.services.generic_proxy import GenericProxy, ProxyListener -from localstack.utils.common import download, parallelize, TMP_FILES, load_file +import unittest +import yaml -def test_environment(): - env = aws_stack.Environment.from_json({'prefix': 'foobar1'}) - assert env.prefix == 'foobar1' - env = aws_stack.Environment.from_string('foobar2') - assert env.prefix == 'foobar2' +from localstack import config +from localstack.utils import async_utils, config_listener +from localstack.utils.common import json_safe, now_utc +from localstack.utils.container_utils.container_client import PortMappings +from localstack.utils.http import create_chunked_data, parse_chunked_data -# This test is not enabled in CI, it is just used for manual -# testing to debug https://github.com/localstack/localstack/issues/213 -def run_parallel_download(): +class TestMisc(unittest.TestCase): + def test_parse_chunked_data(self): + # See: https://en.wikipedia.org/wiki/Chunked_transfer_encoding + chunked = "4\r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n" + expected = "Wikipedia in\r\n\r\nchunks." - file_length = 10000000 + # test parsing + parsed = parse_chunked_data(chunked) + self.assertEqual(expected.strip(), parsed.strip()) - class DownloadListener(ProxyListener): + # test roundtrip + chunked_computed = create_chunked_data(expected) + parsed = parse_chunked_data(chunked_computed) + self.assertEqual(expected.strip(), parsed.strip()) - def forward_request(self, method, path, data, headers): - sleep_time = int(path.replace('/', '')) - time.sleep(sleep_time) - response = Response() - response.status_code = 200 - response._content = ('%s' % sleep_time) * file_length - return response + def test_convert_yaml_date_strings(self): + yaml_source = "Version: 2012-10-17" + obj = yaml.safe_load(yaml_source) + self.assertIn(type(obj["Version"]), (datetime.date, str)) + if isinstance(obj["Version"], datetime.date): + obj = json_safe(obj) + self.assertEqual(str, type(obj["Version"])) + self.assertEqual("2012-10-17T00:00:00.000Z", obj["Version"]) - test_port = 12124 - tmp_file_pattern = '/tmp/test.%s' + def test_timstamp_millis(self): + t1 = now_utc() + t2 = now_utc(millis=True) / 1000 + self.assertAlmostEqual(t1, t2, delta=1) - proxy = GenericProxy(port=test_port, update_listener=DownloadListener()) - proxy.start() + def test_port_mappings(self): + map = PortMappings() + map.add(123) + self.assertEqual("-p 123:123", map.to_str()) + map.add(124) + self.assertEqual("-p 123-124:123-124", map.to_str()) + map.add(234) + self.assertEqual("-p 123-124:123-124 -p 234:234", map.to_str()) + map.add(345, 346) + self.assertEqual("-p 123-124:123-124 -p 234:234 -p 345:346", map.to_str()) + map.add([456, 458]) + self.assertEqual( + "-p 123-124:123-124 -p 234:234 -p 345:346 -p 456-458:456-458", map.to_str() + ) - def do_download(param): - tmp_file = tmp_file_pattern % param - TMP_FILES.append(tmp_file) - download('http://localhost:%s/%s' % (test_port, param), tmp_file) + map = PortMappings() + map.add([123, 124]) + self.assertEqual("-p 123-124:123-124", map.to_str()) + map.add([234, 237], [345, 348]) + self.assertEqual("-p 123-124:123-124 -p 234-237:345-348", map.to_str()) - values = (1, 2, 3) - parallelize(do_download, values) - proxy.stop() + map = PortMappings() + map.add(0, 123) + self.assertEqual("-p 0:123", map.to_str()) - for val in values: - tmp_file = tmp_file_pattern % val - assert len(load_file(tmp_file)) == file_length + def test_port_mappings_single_protocol(self): + map = PortMappings() + map.add(port=53, protocol="udp") + self.assertEqual("-p 53:53/udp", map.to_str()) + + def test_port_mappings_single_protocol_range(self): + map = PortMappings() + map.add(port=[123, 1337], protocol="tcp") + map.add(port=[124, 1338], protocol="tcp") + self.assertEqual("-p 123-1338:123-1338", map.to_str()) + + def test_port_mappings_multi_protocol(self): + map = PortMappings() + map.add(port=53, protocol="tcp") + map.add(port=53, protocol="udp") + self.assertEqual("-p 53:53 -p 53:53/udp", map.to_str()) + + def test_port_mappings_multi_protocol_range(self): + map = PortMappings() + map.add(port=[122, 1336], protocol="tcp") + map.add(port=[123, 1337], protocol="udp") + + map.add(port=[123, 1337], protocol="tcp") + map.add(port=[124, 1338], protocol="udp") + self.assertEqual("-p 122-1337:122-1337 -p 123-1338:123-1338/udp", map.to_str()) + + def test_port_mappings_dict(self): + map = PortMappings() + map.add(port=[122, 124], protocol="tcp") + map.add(port=[123, 125], protocol="udp") + + map.add(port=[123, 125], protocol="tcp") + map.add(port=[124, 126], protocol="udp") + self.assertEqual( + { + "122/tcp": 122, + "123/tcp": 123, + "123/udp": 123, + "124/tcp": 124, + "124/udp": 124, + "125/tcp": 125, + "125/udp": 125, + "126/udp": 126, + }, + map.to_dict(), + ) + + map = PortMappings() + map.add(port=0, mapped=123, protocol="tcp") + self.assertEqual( + { + "123/tcp": None, + }, + map.to_dict(), + ) + + def test_port_mappings_list(self): + map = PortMappings() + map.add(port=[122, 124], protocol="tcp") + map.add(port=[123, 125], protocol="udp") + + map.add(port=[123, 125], protocol="tcp") + map.add(port=[124, 126], protocol="udp") + self.assertEqual(["-p", "122-125:122-125", "-p", "123-126:123-126/udp"], map.to_list()) + + map = PortMappings() + map.add(port=0, mapped=123, protocol="tcp") + self.assertEqual(["-p", "0:123"], map.to_list()) + + def test_update_config_variable(self): + config_listener.update_config_variable("foo", "bar") + self.assertEqual("bar", config.foo) + + def test_async_parallelization(self): + def handler(): + time.sleep(0.1) + results.append(1) + + async def run(): + await async_utils.run_sync(handler, thread_pool=thread_pool) + + loop = asyncio.get_event_loop() + thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=100) + results = [] + num_items = 1000 + handlers = [run() for i in range(num_items)] + loop.run_until_complete(asyncio.gather(*handlers)) + self.assertEqual(num_items, len(results)) + thread_pool.shutdown() diff --git a/tests/unit/test_moto.py b/tests/unit/test_moto.py new file mode 100644 index 0000000000000..10ab416d54ead --- /dev/null +++ b/tests/unit/test_moto.py @@ -0,0 +1,13 @@ +import pytest + +from localstack.services.moto import get_dispatcher + + +def test_get_dispatcher_for_path_with_optional_slashes(): + assert get_dispatcher("route53", "/2013-04-01/hostedzone/BOR36Z3H458JKS9/rrset/") + assert get_dispatcher("route53", "/2013-04-01/hostedzone/BOR36Z3H458JKS9/rrset") + + +def test_get_dispatcher_for_non_existing_path_raises_not_implemented(): + with pytest.raises(NotImplementedError): + get_dispatcher("route53", "/non-existing") diff --git a/tests/unit/test_s3.py b/tests/unit/test_s3.py deleted file mode 100644 index 7f6bfcf15c872..0000000000000 --- a/tests/unit/test_s3.py +++ /dev/null @@ -1,102 +0,0 @@ -import unittest -from localstack.services.s3 import s3_listener - - -class S3ListenerTest (unittest.TestCase): - - def test_expand_redirect_url(self): - url1 = s3_listener.expand_redirect_url('http://example.org', 'K', 'B') - self.assertEqual(url1, 'http://example.org?key=K&bucket=B') - - url2 = s3_listener.expand_redirect_url('http://example.org/?id=I', 'K', 'B') - self.assertEqual(url2, 'http://example.org/?id=I&key=K&bucket=B') - - def test_find_multipart_redirect_url(self): - headers = {'Host': '10.0.1.19:4572', 'User-Agent': 'curl/7.51.0', - 'Accept': '*/*', 'Content-Length': '992', 'Expect': '100-continue', - 'Content-Type': 'multipart/form-data; boundary=------------------------3c48c744237517ac'} - - data1 = (b'--------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="key"\r\n\r\n' - b'uploads/20170826T181315.679087009Z/upload/pixel.png\r\n--------------------------3c48c744237517ac' - b'\r\nContent-Disposition: form-data; name="success_action_redirect"\r\n\r\nhttp://127.0.0.1:5000/' - b'?id=20170826T181315.679087009Z\r\n--------------------------3c48c744237517ac--\r\n') - - data2 = (b'--------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="key"\r\n\r\n' - b'uploads/20170826T181315.679087009Z/upload/pixel.png\r\n--------------------------3c48c744237517ac' - b'--\r\n') - - data3 = (b'--------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="success_action_' - b'redirect"\r\n\r\nhttp://127.0.0.1:5000/?id=20170826T181315.679087009Z\r\n--------------------------' - b'3c48c744237517ac--\r\n') - - key1, url1 = s3_listener.find_multipart_redirect_url(data1, headers) - - self.assertEqual(key1, 'uploads/20170826T181315.679087009Z/upload/pixel.png') - self.assertEqual(url1, 'http://127.0.0.1:5000/?id=20170826T181315.679087009Z') - - key2, url2 = s3_listener.find_multipart_redirect_url(data2, headers) - - self.assertEqual(key2, 'uploads/20170826T181315.679087009Z/upload/pixel.png') - self.assertIsNone(url2, 'Should not get a redirect URL without success_action_redirect') - - key3, url3 = s3_listener.find_multipart_redirect_url(data3, headers) - - self.assertIsNone(key3, 'Should not get a key without provided key') - self.assertIsNone(url3, 'Should not get a redirect URL without provided key') - - def test_expand_multipart_filename(self): - headers = {'Host': '10.0.1.19:4572', 'User-Agent': 'curl/7.51.0', - 'Accept': '*/*', 'Content-Length': '992', 'Expect': '100-continue', - 'Content-Type': 'multipart/form-data; boundary=------------------------3c48c744237517ac'} - - data1 = (b'--------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="key"\r\n\r\n' - b'uploads/20170826T181315.679087009Z/upload/${filename}\r\n--------------------------3c48c744237517ac' - b'\r\nContent-Disposition: form-data; name="AWSAccessKeyId"\r\n\r\nWHAT\r\n--------------------------' - b'3c48c744237517ac\r\nContent-Disposition: form-data; name="policy"\r\n\r\nNO\r\n--------------------' - b'------3c48c744237517ac\r\nContent-Disposition: form-data; name="signature"\r\n\r\nYUP\r\n----------' - b'----------------3c48c744237517ac\r\nContent-Disposition: form-data; name="acl"\r\n\r\nprivate\r\n--' - b'------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="success_action_re' - b'direct"\r\n\r\nhttp://127.0.0.1:5000/\r\n--------------------------3c48c744237517ac\r\nContent-Disp' - b'osition: form-data; name="file"; filename="pixel.png"\r\nContent-Type: application/octet-stream\r\n' - b'\r\n\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15' - b'\xc4\x89\x00\x00\x00\x19tEXtSoftware\x00Adobe ImageReadyq\xc9e<\x00\x00\x00\x0eIDATx\xdabb\x00\x02' - b'\x80\x00\x03\x00\x00\x0f\x00\x03`|\xce\xe9\x00\x00\x00\x00IEND\xaeB`\x82\r\n-----------------------' - b'---3c48c744237517ac--\r\n') - - data2 = (b'--------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="key"\r\n\r\n' - b'uploads/20170826T181315.679087009Z/upload/pixel.png\r\n--------------------------3c48c744237517ac' - b'\r\nContent-Disposition: form-data; name="AWSAccessKeyId"\r\n\r\nWHAT\r\n--------------------------' - b'3c48c744237517ac\r\nContent-Disposition: form-data; name="policy"\r\n\r\nNO\r\n--------------------' - b'------3c48c744237517ac\r\nContent-Disposition: form-data; name="signature"\r\n\r\nYUP\r\n----------' - b'----------------3c48c744237517ac\r\nContent-Disposition: form-data; name="acl"\r\n\r\nprivate\r\n--' - b'------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="success_action_re' - b'direct"\r\n\r\nhttp://127.0.0.1:5000/\r\n--------------------------3c48c744237517ac\r\nContent-Disp' - b'osition: form-data; name="file"; filename="pixel.png"\r\nContent-Type: application/octet-stream\r\n' - b'\r\n\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15' - b'\xc4\x89\x00\x00\x00\x19tEXtSoftware\x00Adobe ImageReadyq\xc9e<\x00\x00\x00\x0eIDATx\xdabb\x00\x02' - b'\x80\x00\x03\x00\x00\x0f\x00\x03`|\xce\xe9\x00\x00\x00\x00IEND\xaeB`\x82\r\n-----------------------' - b'---3c48c744237517ac--\r\n') - - data3 = (u'--------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="key"\r\n\r\n' - u'uploads/20170826T181315.679087009Z/upload/${filename}\r\n--------------------------3c48c744237517ac' - u'\r\nContent-Disposition: form-data; name="AWSAccessKeyId"\r\n\r\nWHAT\r\n--------------------------' - u'3c48c744237517ac\r\nContent-Disposition: form-data; name="policy"\r\n\r\nNO\r\n--------------------' - u'------3c48c744237517ac\r\nContent-Disposition: form-data; name="signature"\r\n\r\nYUP\r\n----------' - u'----------------3c48c744237517ac\r\nContent-Disposition: form-data; name="acl"\r\n\r\nprivate\r\n--' - u'------------------------3c48c744237517ac\r\nContent-Disposition: form-data; name="success_action_re' - u'direct"\r\n\r\nhttp://127.0.0.1:5000/\r\n--------------------------3c48c744237517ac\r\nContent-Disp' - u'osition: form-data; name="file"; filename="pixel.txt"\r\nContent-Type: text/plain\r\n\r\nHello World' - u'\r\n--------------------------3c48c744237517ac--\r\n') - - expanded1 = s3_listener.expand_multipart_filename(data1, headers) - self.assertIsNot(expanded1, data1, 'Should have changed content of data with filename to interpolate') - self.assertIn(b'uploads/20170826T181315.679087009Z/upload/pixel.png', expanded1, - 'Should see the interpolated filename') - - expanded2 = s3_listener.expand_multipart_filename(data2, headers) - self.assertIs(expanded2, data2, 'Should not have changed content of data with no filename to interpolate') - - expanded3 = s3_listener.expand_multipart_filename(data3, headers) - self.assertIsNot(expanded3, data3, 'Should have changed content of string data with filename to interpolate') - self.assertIn(b'uploads/20170826T181315.679087009Z/upload/pixel.txt', expanded3, - 'Should see the interpolated filename') diff --git a/tests/unit/test_sns_listener.py b/tests/unit/test_sns_listener.py deleted file mode 100644 index 8b2fdbe3142c6..0000000000000 --- a/tests/unit/test_sns_listener.py +++ /dev/null @@ -1,88 +0,0 @@ -import json -import uuid -from nose.tools import assert_equal -from localstack.services.sns import sns_listener - - -def test_unsubscribe_without_arn_should_error(): - sns = sns_listener.ProxyListenerSNS() - error = sns.forward_request('POST', '/', 'Action=Unsubscribe', '') - assert(error is not None) - assert(error.status_code == 400) - - -def test_unsubscribe_should_remove_listener(): - sub_arn = 'arn:aws:sns:us-east-1:123456789012:test-topic:45e61c7f-dca5-4fcd-be2b-4e1b0d6eef72' - topic_arn = 'arn:aws:sns:us-east-1:123456789012:test-topic' - - assert(sns_listener.get_topic_by_arn(topic_arn) is None) - sns_listener.do_create_topic(topic_arn) - assert(sns_listener.get_topic_by_arn(topic_arn) is not None) - sns_listener.do_subscribe(topic_arn, - 'http://localhost:1234/listen', - 'http', - sub_arn) - assert(sns_listener.get_subscription_by_arn(sub_arn) is not None) - sns_listener.do_unsubscribe(sub_arn) - assert(sns_listener.get_subscription_by_arn(sub_arn) is None) - - -def test_create_sns_message_body_raw_message_delivery(): - subscriber = { - 'RawMessageDelivery': 'true' - } - action = { - 'Message': ['msg'] - } - result = sns_listener.create_sns_message_body(subscriber, action) - assert (result == 'msg') - - -def test_create_sns_message_body(): - subscriber = { - 'TopicArn': 'arn', - 'RawMessageDelivery': 'false', - } - action = { - 'Message': ['msg'] - } - result_str = sns_listener.create_sns_message_body(subscriber, action) - result = json.loads(result_str) - try: - uuid.UUID(result.pop('MessageId')) - except KeyError: - assert False, 'MessageId missing in SNS response message body' - except ValueError: - assert False, 'SNS response MessageId not a valid UUID' - assert_equal(result, {'Message': 'msg', 'Type': 'Notification', 'TopicArn': 'arn'}) - - # Now add a subject - action = { - 'Message': ['msg'], - 'Subject': ['subject'], - 'MessageAttributes.entry.1.Name': ['attr1'], - 'MessageAttributes.entry.1.Value.DataType': ['String'], - 'MessageAttributes.entry.1.Value.StringValue': ['value1'], - 'MessageAttributes.entry.1.Value.BinaryValue': ['value1'], - 'MessageAttributes.entry.2.Name': ['attr2'], - 'MessageAttributes.entry.2.Value.DataType': ['String'], - 'MessageAttributes.entry.2.Value.StringValue': ['value2'], - 'MessageAttributes.entry.2.Value.BinaryValue': ['value2'], - } - result_str = sns_listener.create_sns_message_body(subscriber, action) - result = json.loads(result_str) - del result['MessageId'] - expected = json.dumps({'Message': 'msg', - 'TopicArn': 'arn', - 'Type': 'Notification', - 'Subject': 'subject', - 'MessageAttributes': { - 'attr1': { - 'Type': 'String', - 'Value': 'value1', - }, 'attr2': { - 'Type': 'String', - 'Value': 'value2', - } - }}) - assert_equal(result, json.loads(expected)) diff --git a/tests/unit/test_stores.py b/tests/unit/test_stores.py new file mode 100644 index 0000000000000..307c4f3b70f52 --- /dev/null +++ b/tests/unit/test_stores.py @@ -0,0 +1,173 @@ +import unittest + +import pytest + +from localstack.services.stores import AccountRegionBundle, BaseStore + + +class SampleStore(BaseStore): + pass + + +class TestStores: + def test_store_reset(self, sample_stores): + """Ensure reset functionality of Stores and encapsulation works.""" + account1 = "696969696969" + account2 = "424242424242" + + eu_region = "eu-central-1" + ap_region = "ap-south-1" + + store1 = sample_stores[account1][eu_region] + store2 = sample_stores[account1][ap_region] + store3 = sample_stores[account2][ap_region] + + store1.region_specific_attr.extend([1, 2, 3]) + store1.CROSS_REGION_ATTR.extend(["a", "b", "c"]) + store1.CROSS_ACCOUNT_ATTR.extend([100j, 200j, 300j]) + store2.region_specific_attr.extend([4, 5, 6]) + store2.CROSS_ACCOUNT_ATTR.extend([400j]) + store3.region_specific_attr.extend([7, 8, 9]) + store3.CROSS_REGION_ATTR.extend([0.1, 0.2, 0.3]) + store3.CROSS_ACCOUNT_ATTR.extend([500j]) + + # Ensure all stores are affected by cross-account attributes + assert store1.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j, 500j] + assert store2.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j, 500j] + assert store3.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j, 500j] + + assert store1.CROSS_ACCOUNT_ATTR.pop() == 500j + + assert store2.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j] + assert store3.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j] + + # Ensure other account stores are not affected by RegionBundle reset + # Ensure cross-account attributes are not affected by RegionBundle reset + sample_stores[account1].reset() + + assert store1.region_specific_attr == [] + assert store1.CROSS_REGION_ATTR == [] + assert store1.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j] + assert store2.region_specific_attr == [] + assert store2.CROSS_REGION_ATTR == [] + assert store2.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j] + assert store3.region_specific_attr == [7, 8, 9] + assert store3.CROSS_REGION_ATTR == [0.1, 0.2, 0.3] + assert store3.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j, 400j] + + # Ensure AccountRegionBundle reset + sample_stores.reset() + + assert store1.CROSS_ACCOUNT_ATTR == [] + assert store2.CROSS_ACCOUNT_ATTR == [] + assert store3.region_specific_attr == [] + assert store3.CROSS_REGION_ATTR == [] + assert store3.CROSS_ACCOUNT_ATTR == [] + + # Ensure essential properties are retained after reset + assert store1._region_name == eu_region + assert store2._region_name == ap_region + assert store3._region_name == ap_region + assert store1._account_id == account1 + assert store2._account_id == account1 + assert store3._account_id == account2 + + def test_store_namespacing(self, sample_stores): + account1 = "696969696969" + account2 = "424242424242" + + eu_region = "eu-central-1" + ap_region = "ap-south-1" + + # + # For Account 1 + # + # Get backends for same account but different regions + backend1_eu = sample_stores[account1][eu_region] + assert backend1_eu._account_id == account1 + assert backend1_eu._region_name == eu_region + + backend1_ap = sample_stores[account1][ap_region] + assert backend1_ap._account_id == account1 + assert backend1_ap._region_name == ap_region + + # Ensure region-specific data isolation + backend1_eu.region_specific_attr.extend([1, 2, 3]) + assert backend1_ap.region_specific_attr == [] + + # Ensure cross-region data sharing + backend1_eu.CROSS_REGION_ATTR.extend([4, 5, 6]) + assert backend1_ap.CROSS_REGION_ATTR == [4, 5, 6] + + # Ensure global attributes are shared across regions + assert ( + id(backend1_ap._global) + == id(backend1_eu._global) + == id(sample_stores[account1]._global) + ) + + # + # For Account 2 + # + # Get backends for a different AWS account + backend2_eu = sample_stores[account2][eu_region] + assert backend2_eu._account_id == account2 + assert backend2_eu._region_name == eu_region + + backend2_ap = sample_stores[account2][ap_region] + assert backend2_ap._account_id == account2 + assert backend2_ap._region_name == ap_region + + # Ensure account-specific data isolation + assert backend2_eu.CROSS_REGION_ATTR == [] + assert backend2_ap.CROSS_REGION_ATTR == [] + + assert backend2_eu.region_specific_attr == [] + assert backend2_ap.region_specific_attr == [] + + # Ensure global attributes are shared for same account ID across regions + assert ( + id(backend2_ap._global) + == id(backend2_eu._global) + == id(sample_stores[account2]._global) + != id(backend1_ap._global) + ) + + # Ensure cross-account data sharing + backend1_eu.CROSS_ACCOUNT_ATTR.extend([100j, 200j, 300j]) + assert backend1_ap.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j] + assert backend1_eu.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j] + assert backend2_ap.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j] + assert backend2_eu.CROSS_ACCOUNT_ATTR == [100j, 200j, 300j] + assert ( + id(backend1_ap._universal) + == id(backend1_eu._universal) + == id(backend2_ap._universal) + == id(backend2_eu._universal) + ) + + def test_valid_regions(self): + stores = AccountRegionBundle("sns", SampleStore) + account1 = "696969696969" + + # assert regular regions work + assert stores[account1]["us-east-1"] + # assert extended regions work + assert stores[account1]["cn-north-1"] + assert stores[account1]["us-gov-west-1"] + # assert invalid regions don't pass validation + with pytest.raises(Exception) as exc: + assert stores[account1]["invalid-region"] + exc.match("not a valid AWS region") + + @unittest.mock.patch("localstack.config.ALLOW_NONSTANDARD_REGIONS", True) + def test_nonstandard_regions(self): + stores = AccountRegionBundle("sns", SampleStore) + account1 = "696969696969" + + # assert regular and extended regions work + assert stores[account1]["us-east-1"] + assert stores[account1]["us-gov-west-1"] + + # assert non-standard regions work + assert stores[account1]["pluto-south-3"] diff --git a/tests/unit/test_tagging.py b/tests/unit/test_tagging.py new file mode 100644 index 0000000000000..876fa15753485 --- /dev/null +++ b/tests/unit/test_tagging.py @@ -0,0 +1,47 @@ +import pytest + +from localstack.utils.tagging import TaggingService + + +class TestTaggingService: + @pytest.fixture + def tagging_service(self): + def _factory(**kwargs): + return TaggingService(**kwargs) + + return _factory + + def test_list_empty(self, tagging_service): + svc = tagging_service() + result = svc.list_tags_for_resource("test") + assert result == {"Tags": []} + + def test_create_tag(self, tagging_service): + svc = tagging_service() + tags = [{"Key": "key_key", "Value": "value_value"}] + svc.tag_resource("arn", tags) + actual = svc.list_tags_for_resource("arn") + expected = {"Tags": [{"Key": "key_key", "Value": "value_value"}]} + assert actual == expected + + def test_delete_tag(self, tagging_service): + svc = tagging_service() + tags = [{"Key": "key_key", "Value": "value_value"}] + svc.tag_resource("arn", tags) + svc.untag_resource("arn", ["key_key"]) + result = svc.list_tags_for_resource("arn") + assert result == {"Tags": []} + + def test_list_empty_delete(self, tagging_service): + svc = tagging_service() + svc.untag_resource("arn", ["key_key"]) + result = svc.list_tags_for_resource("arn") + assert result == {"Tags": []} + + def test_field_name_override(self, tagging_service): + svc = tagging_service(key_field="keY", value_field="valuE") + tags = [{"keY": "my", "valuE": "congratulations"}] + svc.tag_resource("arn", tags) + assert svc.list_tags_for_resource("arn") == { + "Tags": [{"keY": "my", "valuE": "congratulations"}] + } diff --git a/tests/unit/test_templating.py b/tests/unit/test_templating.py new file mode 100644 index 0000000000000..454e158c8d951 --- /dev/null +++ b/tests/unit/test_templating.py @@ -0,0 +1,277 @@ +import json +import re + +from localstack.services.apigateway.legacy.templates import ApiGatewayVtlTemplate +from localstack.utils.aws.templating import render_velocity_template + +# template used to transform incoming requests at the API Gateway (forward to Kinesis) +APIGW_TEMPLATE_TRANSFORM_KINESIS = """{ + "StreamName": "stream-1", + "Records": [ + #set( $numRecords = $input.path('$.records').size() ) + #if($numRecords > 0) + #set( $maxIndex = $numRecords - 1 ) + #foreach( $idx in [0..$maxIndex] ) + #set( $elem = $input.path("$.records[${idx}]") ) + #set( $elemJsonB64 = $util.base64Encode($input.json("$.records[${idx}].data")) ) + { + "Data": "$elemJsonB64", + "PartitionKey": #if( $elem.partitionKey != '')"$elem.partitionKey" + #else"$elemJsonB64"#end + }#if($foreach.hasNext),#end + #end + #end + ] +}""" + +# template used to construct JSON via #define method +APIGW_TEMPLATE_CONSTRUCT_JSON = """ +#set( $body = $input.json("$") ) + +#define( $loop $map ) +{ + #foreach($key in $map.keySet()) + #set( $k = $util.escapeJavaScript($key) ) + #set( $v = $util.escapeJavaScript($map.get($key))) + "$k": "$v" + #if( $foreach.hasNext ) , #end + #end +} +#end +{ + "p0": true, + "p1": $loop($input.path('$.p1')), + "p2": $loop($input.path('$.p2')) +} +""" + +APIGW_TEMPLATE_CUSTOM_BODY = """ +#set( $body = $input.json("$") ) + +#define( $loop ) +{ + #foreach($key in $map.keySet()) + #set( $k = $util.escapeJavaScript($key) ) + #set( $v = $util.escapeJavaScript($map.get($key)).replaceAll("\\'", "'") ) + "$k": "$v" + #if( $foreach.hasNext ) , #end + #end +} +#end + + { + #set( $map = $context.authorizer ) + "enhancedAuthContext": $loop, + + #set( $map = $input.params().header ) + "headers": $loop, + + #set( $map = $input.params().querystring ) + "query": $loop, + + #set( $map = $input.params().path ) + "path": $loop, + + #set( $map = $context.identity ) + "identity": $loop, + + #set( $map = $stageVariables ) + "stageVariables": $loop, +} +""" + + +class TestMessageTransformationBasic: + def test_return_macro(self): + template = """ + #set($v1 = {}) + $v1.put('foo', 'bar') + #return($v1) + """ + result = render_velocity_template(template, {}) + expected = {"foo": "bar"} + assert json.loads(result) == expected + + def test_quiet_return_function(self): + # render .put(..) without quiet function + template = """ + #set($v1 = {}) + $v1.put('foo', 'bar1')$v1.put('foo', 'bar2') + #return($v1) + """ + result = render_velocity_template(template, {}) + result = re.sub(r"\s+", " ", result).strip() + assert result == 'bar1 {"foo": "bar2"}' + # render .put(..) with quiet function + template = """ + #set($v1 = {})\n$v1.put('foo', 'bar1')$util.qr($v1.put('foo', 'bar2'))\n#return($v1) + """ + result = render_velocity_template(template, {}) + result = re.sub(r"\s+", " ", result).strip() + assert result == '{"foo": "bar2"}' + + def test_quiet_return_put(self): + template = "#set($v1 = {})\n$util.qr($v1.put('value', 'hi2'))\n#return($v1)" + result = render_velocity_template(template, {}) + assert json.loads(result) == {"value": "hi2"} + template = "#set($v1 = {})\n$util.qr($v1.put('value', 'hi2'))\n" + result = render_velocity_template(template, {}) + assert result.strip() == "" + + def test_map_put_all(self): + template = """ + #set($v1 = {}) + $v1.putAll({'foo1': 'bar', 'foo2': 'bar'}) + result: $v1 + """ + result = render_velocity_template(template, {}) + result = re.sub(r"\s+", " ", result).strip() + assert result == "result: {'foo1': 'bar', 'foo2': 'bar'}" + + def test_assign_var_loop_return(self): + template = """ + #foreach($x in [1, 2, 3]) + #if($x == 1 or $x == 3) + #set($context.return__val = "loop$x") + #set($context.return__flag = true) + #return($context.return__val) + #end + #end + #return('end') + """ + result = render_velocity_template(template, {"context": {}}) + result = re.sub(r"\s+", " ", result).strip() + assert result == "loop1 loop3 end" + + def test_put_value_to_dict(self): + template = r""" + $util.qr($ctx.test.put("foo", "bar")) + $ctx.test + """ + result = render_velocity_template(template, {"ctx": {"test": {}}}) + assert result.strip() == str({"foo": "bar"}) + + def test_put_value_to_nested_dict(self): + template = r""" + $ctx.test.get('a').get('b').put('foo', 'bar') + $ctx.test.get('a') + """ + wrapped = {"a": {"b": {"c": "foobar"}}} + result = render_velocity_template(template, {"ctx": {"test": wrapped}}) + assert result.strip() == str({"b": {"c": "foobar", "foo": "bar"}}) + + +class TestMessageTransformationApiGateway: + def test_construct_json_using_define(self): + template = APIGW_TEMPLATE_CONSTRUCT_JSON + variables = {"input": {"body": {"p1": {"test": 123}, "p2": {"foo": "bar", "foo2": False}}}} + result = ApiGatewayVtlTemplate().render_vtl(template, variables) + result = re.sub(r"\s+", " ", result).strip() + result = json.loads(result) + assert result == {"p0": True, "p1": {"test": "123"}, "p2": {"foo": "bar", "foo2": "false"}} + + def test_array_size(self): + template = "#set($list = $input.path('$.records')) $list.size()" + body = {"records": [{"data": {"foo": "bar1"}}, {"data": {"foo": "bar2"}}]} + variables = { + "input": { + "body": body, + }, + } + + result = ApiGatewayVtlTemplate().render_vtl(template, variables) + assert result == " 2" + + def test_message_transformation(self): + template = APIGW_TEMPLATE_TRANSFORM_KINESIS + records = [ + {"data": {"foo": "foo1", "bar": "bar2"}}, + {"data": {"foo": "foo1", "bar": "bar2"}, "partitionKey": "key123"}, + ] + variables = {"input": {"body": {"records": records}}} + + def do_test(_vars): + res = ApiGatewayVtlTemplate().render_vtl(template, _vars, as_json=True) + data_encoded = res["Records"][0]["Data"] + assert res["Records"][0]["PartitionKey"] == data_encoded + assert res["Records"][1]["PartitionKey"] == "key123" + + # try rendering the template + do_test(variables) + + # test with empty array + records = [] + variables = {"input": {"body": {"records": records}}} + # try rendering the template + result = ApiGatewayVtlTemplate().render_vtl(template, variables, as_json=True) + assert result["Records"] == [] + + def test_array_in_set_expr(self): + template = "#set ($bar = $input.path('$.foo')[1]) \n $bar" + variables = {"input": {"body": {"foo": ["e1", "e2", "e3", "e4"]}}} + result = ApiGatewayVtlTemplate().render_vtl(template, variables).strip() + assert result == "e2" + + template = "#set ($bar = $input.path('$.foo')[1][1][1]) $bar" + variables = {"input": {"body": {"foo": [["e1"], ["e2", ["e3", "e4"]]]}}} + result = ApiGatewayVtlTemplate().render_vtl(template, variables).strip() + assert result == "e4" + + def test_string_methods(self): + context = {"foo": {"bar": "BAZ baz"}} + variables = {"input": {"body": context}} + template1 = "${foo.bar.strip().lower().replace(' ','-')}" + template2 = "${foo.bar.trim().toLowerCase().replace(' ','-')}" + template3 = "${foo.bar.toString().lower().replace(' ','-')}" + template4 = '${foo.bar.trim().toLowerCase().replaceAll("^(.*)\\s(.*)$","$1-$2")}' + for template in [template1, template2, template3, template4]: + result = ApiGatewayVtlTemplate().render_vtl(template, variables=variables) + assert result == "baz-baz" + + contains_template1 = ("${foo.bar.toString().lower().contains('baz')}", "true") + contains_template2 = ("${foo.bar.toString().lower().contains('bar')}", "false") + for template, expected_result in [contains_template1, contains_template2]: + result = ApiGatewayVtlTemplate().render_vtl(template, variables=variables) + assert result == expected_result + + def test_render_urlencoded_string_data(self): + template = "MessageBody=$util.base64Encode($input.json('$'))" + variables = {"input": {"body": {"spam": "eggs"}}} + result = ApiGatewayVtlTemplate().render_vtl(template, variables) + assert result == "MessageBody=eyJzcGFtIjogImVnZ3MifQ==" + + def test_keyset_functions(self): + template = "#set($list = $input.path('$..var1[1]').keySet()) #foreach($e in $list)$e#end" + body = {"var1": [{"a": 1}, {"b": 2}]} + variables = {"input": {"body": body}} + result = ApiGatewayVtlTemplate().render_vtl(template, variables) + assert result == " b" + + def test_dash_in_variable_name(self): + template = "#set($start = 1)#set($end = 5)#foreach($i in [$start .. $end])$i -#end" + result = ApiGatewayVtlTemplate().render_vtl(template, {}) + assert result == "1 -2 -3 -4 -5 -" + + template = """ + $method.request.header.X-My-Header + """ + variables = {"method": {"request": {"header": {"X-My-Header": "my-header-value"}}}} + result = ApiGatewayVtlTemplate().render_vtl(template, variables).strip() + assert result == "my-header-value" + + def test_boolean_in_variable(self): + # Inspired by authorizer context from Lambda authorizer: + # https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html + # The returned values are all stringified. Notice that you cannot set a JSON + # object or array as a valid value of any key in the context map. + template = '{"booleanKeyTrue": $booleanKeyTrue, "booleanKeyFalse": $booleanKeyFalse}' + variables = { + "booleanKeyTrue": "true", + "booleanKeyFalse": "false", + } + result = ApiGatewayVtlTemplate().render_vtl(template, variables) + assert "true" in result + assert "false" in result + assert result == '{"booleanKeyTrue": true, "booleanKeyFalse": false}' + # test is valid json + json.loads(result) diff --git a/tests/unit/testing/__init__.py b/tests/unit/testing/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/testing/testselection/__init__.py b/tests/unit/testing/testselection/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/testing/testselection/test_matching.py b/tests/unit/testing/testselection/test_matching.py new file mode 100644 index 0000000000000..885d4d83ceff4 --- /dev/null +++ b/tests/unit/testing/testselection/test_matching.py @@ -0,0 +1,140 @@ +import glob +import os +from pathlib import Path + +import pytest + +from localstack.testing.testselection.matching import ( + MATCHING_RULES, + SENTINEL_ALL_TESTS, + Matchers, + check_rule_has_matches, + generic_service_test_matching_rule, + resolve_dependencies, +) +from localstack.testing.testselection.testselection import get_affected_tests_from_changes + + +def test_service_dependency_resolving_no_deps(): + api_dependencies = {"lambda": ["s3"], "cloudformation": ["s3", "sts"]} + svc_including_deps = resolve_dependencies("lambda_", api_dependencies) + assert len(svc_including_deps) == 0 + + +def test_service_dependency_resolving_with_dependencies(): + api_dependencies = { + "lambda": ["s3"], + "cloudformation": ["s3"], + "transcribe": ["s3"], + "s3": ["sts"], + } + svc_including_deps = resolve_dependencies("s3", api_dependencies) + assert svc_including_deps >= {"lambda", "cloudformation", "transcribe"} + + +def test_generic_service_matching_rule(): + assert generic_service_test_matching_rule("localstack/aws/api/cloudformation/__init__.py") == { + "tests/aws/services/cloudformation/", + } + assert generic_service_test_matching_rule( + "localstack/services/cloudformation/test_somefile.py" + ) == { + "tests/aws/services/cloudformation/", + } + assert generic_service_test_matching_rule( + "tests/aws/services/cloudformation/templates/sometemplate.yaml" + ) == { + "tests/aws/services/cloudformation/", + } + + +def test_generic_service_matching_rule_with_dependencies(): + api_dependencies = { + "lambda": ["s3"], + "cloudformation": ["s3"], + "transcribe": ["s3"], + } + assert generic_service_test_matching_rule( + "localstack/aws/api/s3/__init__.py", api_dependencies + ) == { + "tests/aws/services/cloudformation/", + "tests/aws/services/lambda_/", + "tests/aws/services/s3/", + "tests/aws/services/transcribe/", + } + + +def test_generic_service_matching_rule_defaults_to_api_deps(): + """ + Test that the generic service test matching rule uses both API_DEPENDENCIES and API_DEPENDENCIES_OPTIONAL + if no api dependencies are explicitly set. + """ + # match on code associated with OpenSearch + result = generic_service_test_matching_rule("localstack/services/opensearch/test_somefile.py") + # the result needs to contain at least: + # - elasticsearch since it has opensearch as a mandatory requirement + assert "tests/aws/services/es/" in result + # - firehose since it has opensearch as an optional dependency used for one of its integrations + assert "tests/aws/services/firehose/" in result + # - opensearch because it is the actually changed service + assert "tests/aws/services/opensearch/" + + +def test_service_dependency_resolving_with_co_dependencies(): + """ + Test to validate that we don't encounter issue when services are co-dependent on each other + """ + api_dependencies = { + "ses": ["sns"], + "sns": ["sqs", "lambda", "firehose", "ses", "logs"], + "logs": ["lambda", "kinesis", "firehose"], + "lambda": ["logs", "cloudwatch"], + } + svc_including_deps = resolve_dependencies("ses", api_dependencies) + assert svc_including_deps >= {"sns"} + + svc_including_deps = resolve_dependencies("logs", api_dependencies) + assert svc_including_deps >= {"sns", "lambda"} + + svc_including_deps = resolve_dependencies("lambda", api_dependencies) + assert svc_including_deps >= {"sns", "logs"} + + +@pytest.mark.skip(reason="mostly just useful for local execution as a sanity check") +def test_rules_are_matching_at_least_one_file(): + root_dir = Path(__file__).parent.parent.parent.parent.parent + files = glob.glob(f"{root_dir}/**", root_dir=root_dir, recursive=True, include_hidden=True) + files = [os.path.relpath(f, root_dir) for f in files] + for rule_id, rule in enumerate(MATCHING_RULES): + assert check_rule_has_matches(rule, files), f"no match for rule {rule_id}" + + +def test_directory_rules_with_paths(): + feature_path = "localstack/my_feature" + test_path = "test/my_feature" + matcher = Matchers.glob(f"{feature_path}/**").directory(paths=[test_path]) + selected_tests = get_affected_tests_from_changes([f"{feature_path}/__init__.py"], [matcher]) + + assert selected_tests == [test_path] + + +def test_directory_rules_no_paths(): + conftest_path = "**/conftest.py" + matcher = Matchers.glob(conftest_path).directory() + + selected_tests = get_affected_tests_from_changes( + ["tests/aws/service/sns/conftest.py"], [matcher] + ) + + assert selected_tests == ["tests/aws/service/sns/"] + + +def test_directory_rules_no_match(): + feature_path = "localstack/my_feature" + test_path = "test/my_feature" + matcher = Matchers.glob(f"{feature_path}/**").directory(paths=[test_path]) + selected_tests = get_affected_tests_from_changes( + ["localstack/not_my_feature/__init__.py"], [matcher] + ) + + assert selected_tests == [SENTINEL_ALL_TESTS] diff --git a/tests/unit/utils/__init__.py b/tests/unit/utils/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/utils/analytics/__init__.py b/tests/unit/utils/analytics/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/utils/analytics/conftest.py b/tests/unit/utils/analytics/conftest.py new file mode 100644 index 0000000000000..825aee2a51845 --- /dev/null +++ b/tests/unit/utils/analytics/conftest.py @@ -0,0 +1,47 @@ +import pytest + +from localstack import config +from localstack.runtime.current import get_current_runtime, set_current_runtime +from localstack.utils.analytics.metrics import ( + MetricRegistry, +) + + +@pytest.fixture(autouse=True) +def enable_analytics(monkeypatch): + """Makes sure that all tests in this package are executed with analytics enabled.""" + monkeypatch.setattr(target=config, name="DISABLE_EVENTS", value=False) + + +@pytest.fixture(scope="function", autouse=False) +def disable_analytics(monkeypatch): + """Makes sure that all tests in this package are executed with analytics enabled.""" + monkeypatch.setattr(target=config, name="DISABLE_EVENTS", value=True) + + +@pytest.fixture(scope="function", autouse=True) +def reset_metric_registry() -> None: + """Ensures each test starts with a fresh MetricRegistry.""" + registry = MetricRegistry() + registry.registry.clear() # Reset all registered metrics before each test + + +class MockComponents: + name = "mock-product" + + +class MockRuntime: + components = MockComponents() + + +@pytest.fixture(autouse=True) +def mock_runtime(): + try: + # don't do anything if a runtime is set + get_current_runtime() + yield + except ValueError: + # set a mock runtime if no runtime is set + set_current_runtime(MockRuntime()) + yield + set_current_runtime(None) diff --git a/tests/unit/utils/analytics/test_client.py b/tests/unit/utils/analytics/test_client.py new file mode 100644 index 0000000000000..a84a3e3c8b7c2 --- /dev/null +++ b/tests/unit/utils/analytics/test_client.py @@ -0,0 +1,81 @@ +import datetime +from queue import Queue + +from pytest_httpserver import HTTPServer + +from localstack.constants import VERSION +from localstack.utils.analytics.client import AnalyticsClient +from localstack.utils.analytics.events import Event, EventMetadata +from localstack.utils.analytics.metadata import get_client_metadata, get_session_id +from localstack.utils.sync import poll_condition + + +def new_event(payload=None) -> Event: + return Event( + "test", + EventMetadata(get_session_id(), str(datetime.datetime.now())), + payload=payload, + ) + + +def test_append_events(httpserver: HTTPServer): + request_data = Queue() + + httpserver.expect_request("/events").respond_with_data("", 200) + + client = AnalyticsClient(httpserver.url_for("/")) + + e1 = new_event({"val": 1}) + e2 = new_event({"val": 2}) + e3 = new_event({"val": 3}) + + client.append_events([e1, e2]) # batch 1 + client.append_events([e3]) # batch 2 + + assert poll_condition(lambda: len(httpserver.log) >= 2, 10) + + request1, _ = httpserver.log[0] + request2, _ = httpserver.log[1] + + assert request_data.qsize() == 0 + + # assert that http request/payload is correct + assert request1.path == "/events" + assert request2.path == "/events" + + doc1 = request1.get_json(force=True) + doc2 = request2.get_json(force=True) + assert isinstance(doc1["events"], list) + assert len(doc1["events"]) == 2 + assert isinstance(doc2["events"], list) + assert len(doc2["events"]) == 1 + + # assert headers are set + assert request1.headers["Localstack-Session-Id"] == get_session_id() + assert request1.headers["User-Agent"] == f"localstack/{VERSION}" + + # assert content is correct + e1 = doc1["events"][0] + e2 = doc1["events"][1] + e3 = doc2["events"][0] + + assert e1["name"] == "test" + assert e2["name"] == "test" + assert e3["name"] == "test" + + assert e1["metadata"]["session_id"] == get_session_id() + assert e2["metadata"]["session_id"] == get_session_id() + assert e3["metadata"]["session_id"] == get_session_id() + + assert e1["payload"]["val"] == 1 + assert e2["payload"]["val"] == 2 + assert e3["payload"]["val"] == 3 + + +def test_start_session(httpserver): + httpserver.expect_request("/session", method="POST").respond_with_json({"track_events": True}) + + client = AnalyticsClient(httpserver.url_for("/")) + response = client.start_session(get_client_metadata()) + + assert response.track_events() diff --git a/tests/unit/utils/analytics/test_logger.py b/tests/unit/utils/analytics/test_logger.py new file mode 100644 index 0000000000000..ae0438832c783 --- /dev/null +++ b/tests/unit/utils/analytics/test_logger.py @@ -0,0 +1,94 @@ +from queue import Queue + +import pytest + +from localstack.utils.analytics.events import Event, EventHandler +from localstack.utils.analytics.logger import EventLogger +from localstack.utils.analytics.metadata import get_session_id + + +class EventCollector(EventHandler): + def __init__(self): + self.queue = Queue() + + def handle(self, event: Event): + self.queue.put(event) + + def next(self, timeout=None) -> Event: + block = timeout is not None + return self.queue.get(block=block, timeout=timeout) + + +@pytest.fixture +def collector(): + return EventCollector() + + +def test_logger_uses_default_session_id(collector): + log = EventLogger(collector) + log.event("foo") + + assert log.session_id == get_session_id() + assert collector.next().metadata.session_id == get_session_id() + + +def test_logger_can_overwrite_session_id(collector): + log = EventLogger(collector, "420") + log.event("foo") + assert log.session_id == "420" + assert collector.next().metadata.session_id == "420" + + +def test_hash_strings(): + h = EventLogger.hash("foobar") + assert h + assert h != "foobar" + + assert EventLogger.hash("foobar") == h, "hash should be deterministic" + + +def test_hash_numbers(): + h = EventLogger.hash(12) + assert h + assert len(h) > 2 + + assert EventLogger.hash(12) == h, "hash should be deterministic" + + +def test_event_with_payload(collector): + log = EventLogger(collector) + + log.event("foo", {"bar": "ed", "ans": 42}) + log.event("bar", {"foo": "zed", "ans": 420}) + + e1 = collector.next() + e2 = collector.next() + + assert e1.name == "foo" + assert e2.name == "bar" + + assert e1.payload == {"bar": "ed", "ans": 42} + assert e2.payload == {"foo": "zed", "ans": 420} + + +def test_event_with_kwargs_produces_dict_payload(collector): + log = EventLogger(collector) + + log.event("foo", bar="ed", ans=42) + log.event("bar", foo="zed", ans=420) + + e1 = collector.next() + e2 = collector.next() + + assert e1.name == "foo" + assert e2.name == "bar" + + assert e1.payload == {"bar": "ed", "ans": 42} + assert e2.payload == {"foo": "zed", "ans": 420} + + +def test_event_with_kwargs_and_payload_raises_error(collector): + log = EventLogger(collector) + + with pytest.raises(ValueError): + log.event("foo", payload={}, foo=1) diff --git a/tests/unit/utils/analytics/test_metadata.py b/tests/unit/utils/analytics/test_metadata.py new file mode 100644 index 0000000000000..74922ad547d09 --- /dev/null +++ b/tests/unit/utils/analytics/test_metadata.py @@ -0,0 +1,81 @@ +import multiprocessing +import os.path +import threading +from queue import Queue + +import pytest + +from localstack import config +from localstack.utils.analytics.metadata import ( + get_client_metadata, + get_localstack_edition, + get_session_id, +) + + +def test_get_client_metadata_cache(): + c1 = get_client_metadata() + c2 = get_client_metadata() + + assert c1 is not None + assert c2 is not None + assert c1 is c2 + + +def test_get_session_id_cache_not_thread_local(): + calls = Queue() + + def _do_get_session_id(): + calls.put(get_session_id()) + + threading.Thread(target=_do_get_session_id).start() + threading.Thread(target=_do_get_session_id).start() + + sid1 = calls.get(timeout=2) + sid2 = calls.get(timeout=2) + + assert sid1 == sid2 + + +def test_get_session_id_cache_not_process_local(): + calls = multiprocessing.Queue() + + def _do_get_session_id(): + calls.put(get_session_id()) + + try: + multiprocessing.Process(target=_do_get_session_id).start() + multiprocessing.Process(target=_do_get_session_id).start() + + sid1 = calls.get(timeout=2) + sid2 = calls.get(timeout=2) + + assert sid1 == sid2 + except AttributeError as e: + # fix for macOS (and potentially other systems) where local functions cannot be used for multiprocessing + if "Can't pickle local object" not in str(e): + raise + + +@pytest.mark.parametrize( + "expected_edition, version_file", + [ + ("enterprise", ".enterprise-version"), + ("pro", ".pro-version"), + ("community", ".community-version"), + ("azure-alpha", ".azure-alpha-version"), + ("unknown", "non-hidden-version"), + ("unknown", ".hidden-file"), + ("unknown", "not-a-version-file"), + ], +) +def test_get_localstack_edition(expected_edition, version_file): + # put the version file in the expected location + file_location = os.path.join(config.dirs.static_libs, version_file) + with open(file_location, "w") as f: + f.write("") + + assert get_localstack_edition() == expected_edition + + # cleanup + os.remove(file_location) diff --git a/tests/unit/utils/analytics/test_metrics.py b/tests/unit/utils/analytics/test_metrics.py new file mode 100644 index 0000000000000..1695aeea340d7 --- /dev/null +++ b/tests/unit/utils/analytics/test_metrics.py @@ -0,0 +1,274 @@ +import threading + +import pytest + +from localstack.utils.analytics.metrics import ( + Counter, + LabeledCounter, + MetricRegistry, + MetricRegistryKey, +) + + +def test_metric_registry_singleton(): + registry_1 = MetricRegistry() + registry_2 = MetricRegistry() + assert registry_1 is registry_2, "Only one instance of MetricRegistry should exist at any time" + + +def test_counter_increment(): + counter = Counter(namespace="test_namespace", name="test_counter") + counter.increment() + counter.increment(value=3) + collected = counter.collect() + assert collected[0].value == 4, ( + f"Unexpected counter value: expected 4, got {collected[0]['value']}" + ) + + +def test_counter_reset(): + counter = Counter(namespace="test_namespace", name="test_counter") + counter.increment(value=5) + counter.reset() + collected = counter.collect() + assert collected == list(), f"Unexpected counter value: expected 0, got {collected}" + + +def test_labeled_counter_increment(): + labeled_counter = LabeledCounter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status"] + ) + labeled_counter.labels(status="success").increment(value=2) + labeled_counter.labels(status="error").increment(value=3) + collected_metrics = labeled_counter.collect() + + assert any( + metric.value == 2 and metric.labels and metric.labels.get("status") == "success" + for metric in collected_metrics + ), "Unexpected counter value for label success" + + assert any( + metric.value == 3 and metric.labels and metric.labels.get("status") == "error" + for metric in collected_metrics + ), "Unexpected counter value for label error" + + +def test_labeled_counter_reset(): + labeled_counter = LabeledCounter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status"] + ) + labeled_counter.labels(status="success").increment(value=5) + labeled_counter.labels(status="error").increment(value=4) + + labeled_counter.labels(status="success").reset() + + collected_metrics = labeled_counter.collect() + + # Assert that no metric with label "success" is present anymore + assert all( + not metric.labels or metric.labels.get("status") != "success" + for metric in collected_metrics + ), "Metric for label 'success' should not appear after reset." + + # Assert that metric with label "error" is still there with correct value + assert any( + metric.value == 4 and metric.labels and metric.labels.get("status") == "error" + for metric in collected_metrics + ), "Unexpected counter value for label error" + + +def test_counter_when_events_disabled(disable_analytics): + counter = Counter(namespace="test_namespace", name="test_counter") + counter.increment(value=10) + assert counter.collect() == [], "Counter should not collect any data" + + +def test_labeled_counter_when_events_disabled_(disable_analytics): + labeled_counter = LabeledCounter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status"] + ) + labeled_counter.labels(status="status").increment(value=5) + assert labeled_counter.collect() == [], "Counter should not collect any data" + + +def test_metric_registry_register_and_collect(): + counter = Counter(namespace="test_namespace", name="test_counter") + registry = MetricRegistry() + + # Ensure the counter is already registered + assert MetricRegistryKey("test_namespace", "test_counter") in registry._registry, ( + "Counter should automatically register itself" + ) + counter.increment(value=7) + collected_metrics = registry.collect() + assert any(metric.value == 7 for metric in collected_metrics.payload), ( + f"Unexpected collected metrics: {collected_metrics}" + ) + + +def test_metric_registry_register_duplicate_counter(): + counter = Counter(namespace="test_namespace", name="test_counter") + registry = MetricRegistry() + + # Attempt to manually register the counter again, expecting a ValueError + with pytest.raises( + ValueError, + match=f"A metric named '{counter.name}' already exists in the '{counter.namespace}' namespace", + ): + registry.register(counter) + + +def test_thread_safety(): + counter = Counter(namespace="test_namespace", name="test_counter") + + def increment(): + for _ in range(1000): + counter.increment() + + threads = [threading.Thread(target=increment) for _ in range(5)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + collected_metrics = counter.collect() + assert collected_metrics[0].value == 5000, ( + f"Unexpected counter value: expected 5000, got {collected_metrics[0].value}" + ) + + +def test_max_labels_limit(): + with pytest.raises(ValueError, match="Too many labels: counters allow a maximum of 6."): + LabeledCounter( + namespace="test_namespace", + name="test_counter", + labels=["l1", "l2", "l3", "l4", "l5", "l6", "l7"], + ) + + +def test_counter_raises_error_if_namespace_is_empty(): + with pytest.raises(ValueError, match="Namespace must be non-empty string."): + Counter(namespace="", name="") + + with pytest.raises(ValueError, match="Metric name must be non-empty string."): + Counter(namespace="test_namespace", name=" ") + + +def test_counter_raises_error_if_name_is_empty(): + with pytest.raises(ValueError, match="Metric name must be non-empty string."): + Counter(namespace="test_namespace", name="") + + with pytest.raises(ValueError, match="Metric name must be non-empty string."): + Counter(namespace="test_namespace", name=" ") + + +def test_counter_raises_if_label_values_off(): + with pytest.raises( + ValueError, match="At least one label is required; the labels list cannot be empty." + ): + LabeledCounter(namespace="test_namespace", name="test_counter", labels=[]).labels(l1="a") + + with pytest.raises(ValueError): + LabeledCounter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + l1="a", non_existing="asdf" + ) + + with pytest.raises(ValueError): + LabeledCounter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + l1="a" + ) + + with pytest.raises(ValueError): + LabeledCounter(namespace="test_namespace", name="test_counter", labels=["l1", "l2"]).labels( + l1="a", l2="b", l3="c" + ) + + +def test_label_kwargs_order_independent(): + labeled_counter = LabeledCounter( + namespace="test_namespace", name="test_multilabel_counter", labels=["status", "type"] + ) + labeled_counter.labels(status="success", type="counter").increment(value=2) + labeled_counter.labels(type="counter", status="success").increment(value=3) + labeled_counter.labels(type="counter", status="error").increment(value=3) + collected_metrics = labeled_counter.collect() + + assert any( + metric.value == 5 and metric.labels and metric.labels.get("status") == "success" + for metric in collected_metrics + ), "Unexpected counter value for label success" + assert any( + metric.value == 3 and metric.labels and metric.labels.get("status") == "error" + for metric in collected_metrics + ), "Unexpected counter value for label error" + + +def test_default_schema_version_for_counter(): + counter = Counter(namespace="test_namespace", name="test_name") + counter.increment() + collected_metrics = counter.collect() + assert collected_metrics[0].schema_version == 1, ( + "Default schema_version for Counter should be 1" + ) + + +def test_custom_schema_version_for_counter(): + counter = Counter(namespace="test_namespace", name="test_name", schema_version=3) + counter.increment() + collected_metrics = counter.collect() + assert collected_metrics[0].schema_version == 3 + + +def test_default_schema_version_for_labeled_counter(): + labeled_counter = LabeledCounter(namespace="test_namespace", name="test_name", labels=["type"]) + labeled_counter.labels(type="success").increment() + collected_metrics = labeled_counter.collect() + assert collected_metrics[0].schema_version == 1, ( + "Default schema_version for LabeledCounter should be 1" + ) + + +def test_custom_schema_version_for_labeled_counter(): + labeled_counter = LabeledCounter( + namespace="test_namespace", + name="test_name", + labels=["type"], + schema_version=5, + ) + labeled_counter.labels(type="success").increment() + collected_metrics = labeled_counter.collect() + assert collected_metrics[0].schema_version == 5 + + +def test_labeled_counter_schema_version_none_raises_value_error(): + with pytest.raises( + ValueError, match="An explicit schema_version is required for Counter metrics" + ): + LabeledCounter( + namespace="test_namespace", + name="test_name", + labels=["type"], + schema_version=None, + ) + + +@pytest.mark.parametrize("invalid_version", ["1", "invalid"]) +def test_labeled_counter_schema_version_non_int_raises_type_error(invalid_version): + with pytest.raises(TypeError, match="Schema version must be an integer."): + LabeledCounter( + namespace="test_namespace", + name="test_name", + labels=["type"], + schema_version=invalid_version, + ) + + +@pytest.mark.parametrize("invalid_version", [0, -5]) +def test_labeled_counter_schema_version_non_positive_raises_value_error(invalid_version): + with pytest.raises(ValueError, match="Schema version must be greater than zero."): + LabeledCounter( + namespace="test_namespace", + name="test_name", + labels=["type"], + schema_version=invalid_version, + ) diff --git a/tests/unit/utils/analytics/test_publisher.py b/tests/unit/utils/analytics/test_publisher.py new file mode 100644 index 0000000000000..a74096b3f256d --- /dev/null +++ b/tests/unit/utils/analytics/test_publisher.py @@ -0,0 +1,151 @@ +import datetime +import threading +from queue import Queue +from typing import List + +import pytest + +from localstack.utils.analytics import GlobalAnalyticsBus +from localstack.utils.analytics.client import AnalyticsClient +from localstack.utils.analytics.events import Event, EventMetadata +from localstack.utils.analytics.metadata import get_session_id +from localstack.utils.analytics.publisher import Publisher, PublisherBuffer +from localstack.utils.sync import retry + + +def new_event(payload=None) -> Event: + return Event( + "test", + EventMetadata(get_session_id(), str(datetime.datetime.now())), + payload=payload, + ) + + +class TestPublisherBuffer: + def test_basic(self): + calls = Queue() + + class QueuePublisher(Publisher): + def publish(self, _events: List[Event]): + calls.put(_events) + + buffer = PublisherBuffer(QueuePublisher(), flush_size=2, flush_interval=1000) + + t = threading.Thread(target=buffer.run) + t.start() + + try: + e1 = new_event() + e2 = new_event() + e3 = new_event() + + buffer.handle(e1) + buffer.handle(e2) + + c1 = calls.get(timeout=2) + assert len(c1) == 2 + + buffer.handle(e3) # should flush after close despite flush_size = 2 + finally: + buffer.close() + + c2 = calls.get(timeout=2) + assert len(c2) == 1 + + assert c1[0] == e1 + assert c1[1] == e2 + assert c2[0] == e3 + + t.join(10) + + def test_interval(self): + calls = Queue() + + class QueuePublisher(Publisher): + def publish(self, _events: List[Event]): + calls.put(_events) + + buffer = PublisherBuffer(QueuePublisher(), flush_size=10, flush_interval=1) + + t = threading.Thread(target=buffer.run) + t.start() + + try: + e1 = new_event() + e2 = new_event() + e3 = new_event() + e4 = new_event() + + buffer.handle(e1) + buffer.handle(e2) + c1 = calls.get(timeout=2) + + buffer.handle(e3) + buffer.handle(e4) + c2 = calls.get(timeout=2) + finally: + buffer.close() + + assert len(c1) == 2 + assert len(c2) == 2 + t.join(10) + + +class TestGlobalAnalyticsBus: + def test(self, httpserver): + httpserver.expect_request("/v0/session").respond_with_json({"track_events": True}) + httpserver.expect_request("/v0/events").respond_with_data(b"") + + client = AnalyticsClient(httpserver.url_for("/v0")) + bus = GlobalAnalyticsBus(client=client, flush_size=2) + bus.force_tracking = True + + assert not httpserver.log + + bus.handle(new_event()) + + # first event should trigger registration + request, response = retry(httpserver.log.pop, sleep=0.2, retries=10) + assert request.path == "/v0/session" + assert response.json["track_events"] + assert not bus.tracking_disabled + bus.handle(new_event()) # should flush here because of flush_size 2 + + request, _ = retry(httpserver.log.pop, sleep=0.2, retries=10) + assert request.path == "/v0/events" + assert len(request.json["events"]) == 2 + + @pytest.mark.parametrize("status_code", [200, 403]) + def test_with_track_events_disabled(self, httpserver, status_code): + httpserver.expect_request("/v1/session").respond_with_json( + {"track_events": False}, + status=status_code, + ) + httpserver.expect_request("/v1/events").respond_with_data(b"") + + client = AnalyticsClient(httpserver.url_for("/v1")) + bus = GlobalAnalyticsBus(client=client, flush_size=2) + bus.force_tracking = True + + bus.handle(new_event()) + + # first event should trigger registration + request, response = retry(httpserver.log.pop, sleep=0.2, retries=10) + assert request.path == "/v1/session" + assert not response.json["track_events"] + assert bus.tracking_disabled + + def test_with_session_error_response(self, httpserver): + httpserver.expect_request("/v1/session").respond_with_data(b"oh noes", status=418) + httpserver.expect_request("/v1/events").respond_with_data(b"") + + client = AnalyticsClient(httpserver.url_for("/v1")) + bus = GlobalAnalyticsBus(client=client, flush_size=2) + bus.force_tracking = True + + bus.handle(new_event()) + + # first event should trigger registration + request, response = retry(httpserver.log.pop, sleep=0.2, retries=10) + assert request.path == "/v1/session" + assert bus.tracking_disabled diff --git a/tests/unit/utils/analytics/test_service_call_aggregator.py b/tests/unit/utils/analytics/test_service_call_aggregator.py new file mode 100644 index 0000000000000..765570a19a2f9 --- /dev/null +++ b/tests/unit/utils/analytics/test_service_call_aggregator.py @@ -0,0 +1,115 @@ +import time +from queue import Queue +from typing import List + +import dateutil.parser +import pytest + +from localstack.utils import analytics +from localstack.utils.analytics.events import Event +from localstack.utils.analytics.service_request_aggregator import ( + EVENT_NAME, + ServiceRequestAggregator, + ServiceRequestInfo, +) + + +def test_whitebox_create_analytics_payload(): + agg = ServiceRequestAggregator() + + agg.add_request(ServiceRequestInfo("test1", "test", 200, None)) + agg.add_request(ServiceRequestInfo("test1", "test", 200, None)) + agg.add_request(ServiceRequestInfo("test2", "test", 404, "ResourceNotFound")) + agg.add_request(ServiceRequestInfo("test3", "test", 200, None)) + + payload = agg._create_analytics_payload() + + aggregations = payload["api_calls"] + assert len(aggregations) == 3 + + period_start = dateutil.parser.isoparse(payload["period_start_time"]) + period_end = dateutil.parser.isoparse(payload["period_end_time"]) + assert period_end > period_start + + for record in aggregations: + service = record["service"] + if service == "test1": + assert record["count"] == 2 + assert "err_type" not in record + elif service == "test2": + assert record["count"] == 1 + assert record["err_type"] == "ResourceNotFound" + elif service == "test3": + assert record["count"] == 1 + assert "err_type" not in record + else: + pytest.fail(f"unexpected service name in payload: '{service}'") + + +def test_whitebox_flush(): + flushed_payloads = Queue() + + def mock_emit_payload(_payload): + flushed_payloads.put(_payload) + + agg = ServiceRequestAggregator(flush_interval=0.1) + agg._emit_payload = mock_emit_payload + + agg.add_request(ServiceRequestInfo("test1", "test", 200)) + agg.add_request(ServiceRequestInfo("test1", "test", 200)) + + assert len(agg.counter) == 1 + + agg.start() + + payload = flushed_payloads.get(timeout=1) + + assert payload["api_calls"] == [ + {"count": 2, "operation": "test", "service": "test1", "status_code": 200} + ] + assert len(agg.counter) == 0 + + +def test_integration(monkeypatch): + events: List[Event] = [] + + def _handle(_event: Event): + events.append(_event) + + monkeypatch.setattr(analytics.log.handler, "handle", _handle) + + agg = ServiceRequestAggregator(flush_interval=1) + + agg.add_request(ServiceRequestInfo("s3", "ListBuckets", 200)) + agg.add_request(ServiceRequestInfo("s3", "CreateBucket", 200)) + agg.add_request(ServiceRequestInfo("s3", "HeadBucket", 200)) + agg.add_request(ServiceRequestInfo("s3", "HeadBucket", 200)) + + agg.start() + time.sleep(1.2) + + assert len(events) == 1, f"expected events to be flushed {events}" + + agg.add_request(ServiceRequestInfo("s3", "HeadBucket", 404)) + agg.add_request(ServiceRequestInfo("s3", "CreateBucket", 200)) + agg.add_request(ServiceRequestInfo("s3", "HeadBucket", 200)) + + assert len(events) == 1, f"did not expect events to be flushed {events}" + + agg.shutdown() # should flush + + assert len(events) == 2, f"expected events to be flushed {events}" + + event = events[0] + assert event.name == EVENT_NAME + calls = event.payload["api_calls"] + assert {"count": 1, "operation": "ListBuckets", "service": "s3", "status_code": 200} in calls + assert {"count": 1, "operation": "CreateBucket", "service": "s3", "status_code": 200} in calls + assert {"count": 2, "operation": "HeadBucket", "service": "s3", "status_code": 200} in calls + + event = events[1] + assert event.name == EVENT_NAME + calls = event.payload["api_calls"] + assert {"count": 1, "operation": "CreateBucket", "service": "s3", "status_code": 200} in calls + assert {"count": 1, "operation": "HeadBucket", "service": "s3", "status_code": 200} in calls + assert {"count": 1, "operation": "HeadBucket", "service": "s3", "status_code": 404} in calls diff --git a/tests/unit/utils/aws/__init__.py b/tests/unit/utils/aws/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/utils/aws/test_aws_responses.py b/tests/unit/utils/aws/test_aws_responses.py new file mode 100644 index 0000000000000..e6e64e04f58cf --- /dev/null +++ b/tests/unit/utils/aws/test_aws_responses.py @@ -0,0 +1,64 @@ +from localstack.utils.aws.aws_responses import parse_query_string + +result_raw = { + "DescribeChangeSetResult": { + # ... + "Changes": [ + { + "ResourceChange": { + "Replacement": False, + "Scope": ["Tags"], + }, + "Type": "Resource", + } + ] + } +} + +result_raw_none_element = {"a": {"b": None}} + +result_raw_empty_list = {"a": {"b": []}} +result_raw_multiple_members = {"a": {"b": ["c", "d"]}} + + +class SomeClass: + pass + + +result_raw_class_value = {"a": {"b": SomeClass()}} +multiple_root = {"a": "b", "c": "d"} +empty_dict = {} + + +def test_parse_query_string(): + assert parse_query_string("") == {} + assert parse_query_string("?a=1") == {"a": "1"} + assert parse_query_string("?a=1&b=foo2") == {"a": "1", "b": "foo2"} + + assert parse_query_string("http://example.com") == {} + assert parse_query_string("http://example.com/foo/bar") == {} + assert parse_query_string("http://example.com/foo/bar#test") == {} + assert parse_query_string("http://example.com/foo/bar?a=1") == {"a": "1"} + assert parse_query_string("http://example.com/foo/bar?foo=1&1=2") == {"foo": "1", "1": "2"} + assert parse_query_string( + "http://example.com/foo/bar?foo=1&redirect=http://test.com/redirect" + ) == { + "foo": "1", + "redirect": "http://test.com/redirect", + } + assert parse_query_string( + "http://example.com/foo/bar?foo=1&redirect=http%3A%2F%2Flocalhost%3A3001%2Fredirect" + ) == {"foo": "1", "redirect": "http://localhost:3001/redirect"} + assert parse_query_string("http://example.com/foo/bar?foo=1&1=2") == {"foo": "1", "1": "2"} + + assert parse_query_string("?foo=1&foo=2&", multi_values=True) == {"foo": ["1", "2"]} + assert parse_query_string("?a=1&a=2&b=0&a=3", multi_values=True) == { + "a": ["1", "2", "3"], + "b": ["0"], + } + assert parse_query_string("ws://example.com/foo/bar?foo=1&1=2") == {"foo": "1", "1": "2"} + assert parse_query_string("ws://example.com/foo/bar") == {} + assert parse_query_string("wss://example.com/foo/bar?foo=1&1=2") == {"foo": "1", "1": "2"} + assert parse_query_string("wss://example.com/foo/bar") == {} + assert parse_query_string("https://example.com/foo/bar?foo=1&1=2") == {"foo": "1", "1": "2"} + assert parse_query_string("https://example.com/foo/bar") == {} diff --git a/tests/unit/utils/aws/test_aws_stack.py b/tests/unit/utils/aws/test_aws_stack.py new file mode 100644 index 0000000000000..a15402d958035 --- /dev/null +++ b/tests/unit/utils/aws/test_aws_stack.py @@ -0,0 +1,67 @@ +import pytest +from botocore.utils import InvalidArnException + +from localstack.utils.aws.arns import extract_region_from_arn, lambda_function_name, parse_arn +from localstack.utils.aws.aws_stack import inject_test_credentials_into_env + + +def test_inject_test_credentials_into_env_already_with_none_adds_both(): + env = {} + inject_test_credentials_into_env(env) + assert env.get("AWS_ACCESS_KEY_ID") == "test" + assert env.get("AWS_SECRET_ACCESS_KEY") == "test" + + +def test_inject_test_credentials_into_env_already_with_access_key_does_nothing(): + access_key = "an-access-key" + expected_env = {"AWS_ACCESS_KEY_ID": access_key} + env = expected_env.copy() + inject_test_credentials_into_env(env) + assert env == expected_env + + +def test_inject_test_credentials_into_env_already_with_secret_key_does_nothing(): + secret_key = "a-secret-key" + expected_env = {"AWS_SECRET_ACCESS_KEY": secret_key} + env = expected_env.copy() + inject_test_credentials_into_env(env) + assert env == expected_env + + +class TestArn: + def test_parse_arn(self): + arn = parse_arn("arn:aws:lambda:aws-region:acct-id:function:helloworld:42") + assert arn["partition"] == "aws" + assert arn["service"] == "lambda" + assert arn["region"] == "aws-region" + assert arn["account"] == "acct-id" + assert arn["resource"] == "function:helloworld:42" + + def test_parse_arn_invalid(self): + with pytest.raises(InvalidArnException): + parse_arn("arn:aws:lambda:aws-region:acct-id") + + with pytest.raises(InvalidArnException): + parse_arn("") + + def test_extract_region_from_arn(self): + assert ( + extract_region_from_arn("arn:aws:lambda:aws-region:acct-id:function:helloworld:42") + == "aws-region" + ) + assert extract_region_from_arn("foo:bar") is None + assert extract_region_from_arn("") is None + + def test_lambda_function_name(self): + assert ( + lambda_function_name("arn:aws:lambda:aws-region:acct-id:function:helloworld:42") + == "helloworld" + ) + assert lambda_function_name("helloworld") == "helloworld" + + def test_lambda_function_name_invalid(self): + with pytest.raises(InvalidArnException): + assert lambda_function_name("arn:aws:lambda:aws-region:acct-id") is None + + with pytest.raises(ValueError): + assert lambda_function_name("arn:aws:sqs:aws-region:acct-id:foo") is None diff --git a/tests/unit/utils/generic/__init__.py b/tests/unit/utils/generic/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/unit/utils/generic/test_dict_utils.py b/tests/unit/utils/generic/test_dict_utils.py new file mode 100644 index 0000000000000..37d0f26233f34 --- /dev/null +++ b/tests/unit/utils/generic/test_dict_utils.py @@ -0,0 +1,133 @@ +import unittest + +from localstack.utils.collections import ( + dict_multi_values, + get_safe, + pick_attributes, + set_safe_mutable, +) + + +class GenericDictUtilsTest(unittest.TestCase): + def test_get_safe(self): + dictionary = { + "level_one_1": { + "level_two_1": { + "level_three_1": "level_three_1_value", + "level_three_2": ["one", "two"], + }, + "level_two_2": "level_two_2_value", + }, + "level_one_2": "level_one_2_value", + } + + self.assertEqual( + dictionary["level_one_1"]["level_two_1"], + get_safe(dictionary, "$.level_one_1.level_two_1"), + ) + + self.assertEqual( + dictionary["level_one_1"]["level_two_1"], + get_safe(dictionary, ["$", "level_one_1", "level_two_1"]), + ) + + self.assertEqual( + "level_three_1_value", + get_safe(dictionary, "$.level_one_1.level_two_1.level_three_1"), + ) + + self.assertEqual( + "level_three_1_value", + get_safe(dictionary, ["$", "level_one_1", "level_two_1", "level_three_1"]), + ) + + self.assertIsNone( + get_safe(dictionary, ["$", "level_one_1", "level_two_1", "random", "value"]) + ) + + self.assertEqual( + "default_value", + get_safe( + dictionary, + ["$", "level_one_1", "level_two_1", "random", "value"], + "default_value", + ), + ) + + self.assertEqual( + "one", + get_safe(dictionary, ["$", "level_one_1", "level_two_1", "level_three_2", "0"]), + ) + + self.assertEqual("two", get_safe(dictionary, "$.level_one_1.level_two_1.level_three_2.1")) + + def test_set_safe_mutable(self): + mutable_dictionary = {} + expected_dictionary = { + "level_one_1": { + "level_two_1": {"level_three_1": "level_three_1_value"}, + "level_two_2": "level_two_2_value", + }, + "level_one_2": "level_one_2_value", + } + + set_safe_mutable( + mutable_dictionary, + "$.level_one_1.level_two_1.level_three_1", + "level_three_1_value", + ) + set_safe_mutable( + mutable_dictionary, ["$", "level_one_1", "level_two_2"], "level_two_2_value" + ) + set_safe_mutable(mutable_dictionary, "$.level_one_2", "level_one_2_value") + + self.assertEqual(expected_dictionary, mutable_dictionary) + + def test_pick_attributes(self): + dictionary = { + "level_one_1": { + "level_two_1": {"level_three_1": "level_three_1_value"}, + "level_two_2": "level_two_2_value", + }, + "level_one_2": "level_one_2_value", + } + + whitelisted_dictionary = pick_attributes( + dictionary, + [ + "$.level_one_1.level_two_1.level_three_1", + ["$", "level_one_2"], + "$.random.attribute", + ], + ) + + expected_whitelisted_dictionary = { + "level_one_1": { + "level_two_1": {"level_three_1": "level_three_1_value"}, + }, + "level_one_2": "level_one_2_value", + } + self.assertEqual(expected_whitelisted_dictionary, whitelisted_dictionary) + + def test_dict_multi_values(self): + tt = [ + { + "input": {"a": 1, "b": 2}, + "expected": {"a": [1], "b": [2]}, + }, + { + "input": ["a", "b"], + "expected": {"a": ["b"]}, + }, + { + "input": [["a", "1"], ["b", "2"], ["b", "3"]], + "expected": {"a": ["1"], "b": ["2", "3"]}, + }, + { + "input": {"a": [1, 2], "b": [3, 4]}, + "expected": {"a": [1, 2], "b": [3, 4]}, + }, + ] + + for t in tt: + self.assertEqual(t["expected"], dict_multi_values(t["input"])) diff --git a/tests/unit/utils/generic/test_file_utils.py b/tests/unit/utils/generic/test_file_utils.py new file mode 100644 index 0000000000000..ee39e940a144f --- /dev/null +++ b/tests/unit/utils/generic/test_file_utils.py @@ -0,0 +1,80 @@ +import os + +import pytest + +from localstack import config +from localstack.testing.pytest.util import run_as_os_user +from localstack.utils.common import new_tmp_file, save_file +from localstack.utils.files import idempotent_chmod, new_tmp_dir, parse_config_file, rm_rf + +CONFIG_FILE_SECTION = """ +[section{section}] +var1=foo bar 123 +var2=123.45 +# test comment +var3=Test string' 10 MB, nicely compressable + + def _handler(_: Request) -> Response: + import gzip + + compressed_content = gzip.compress(content) + headers = {"Content-Encoding": "gzip"} + if total_size_known: + headers["Content-Length"] = len(compressed_content) + body = compressed_content + else: + + def _generator(): + yield compressed_content + + # use a generator to avoid werkzeug determining / setting the content length + body = _generator() + return Response(body, status=200, headers=headers) + + httpserver.expect_request("/").respond_with_handler(_handler) + http_endpoint = httpserver.url_for("/") + tmp_file = new_tmp_file() + + # wait 200 ms to make sure the server is ready + time.sleep(0.1) + + download(http_endpoint, tmp_file) + + with open(tmp_file, mode="rb") as opened_tmp_file: + downloaded_content = opened_tmp_file.read() + # assert the downloaded content is equal to the one sent by the server + assert content == downloaded_content + + # clean up + rm_rf(tmp_file) + + if total_size_known: + # check for percentage logs in case the total size is set by the server + assert re.search(r"Downloaded \d+% \(total \d+K of \d+K\) to ", caplog.text) + + # check that the final message has been logged + assert "Done downloading " in caplog.text + + +def test_download_with_timeout(): + def _handler(_: Request) -> Response: + time.sleep(2) + return Response(b"", status=200) + + tmp_file = new_tmp_file() + # it seems this test is not properly cleaning up for other unit tests, this step is normally not necessary + # we should use the fixture `httpserver` instead of HTTPServer directly + with HTTPServer() as server: + server.expect_request("/").respond_with_data(b"tmp_file", status=200) + server.expect_request("/sleep").respond_with_handler(_handler) + http_endpoint = server.url_for("/") + + download(http_endpoint, tmp_file) + assert load_file(tmp_file) == "tmp_file" + with pytest.raises(TimeoutError): + download(f"{http_endpoint}/sleep", tmp_file, timeout=1) + + # clean up + rm_rf(tmp_file) + + +def test_download_with_headers(httpserver): + test_headers = { + "Authorization": "Beeearer Token Test Header", + "Random-Header": "Another non-specified header", + } + + # only match for the specific headers + httpserver.expect_request("/", headers=test_headers).respond_with_data("OK") + + http_endpoint = httpserver.url_for("/") + tmp_file = new_tmp_file() + download(http_endpoint, tmp_file, request_headers=test_headers) diff --git a/tests/unit/utils/test_id_generator.py b/tests/unit/utils/test_id_generator.py new file mode 100644 index 0000000000000..74024d4bf570e --- /dev/null +++ b/tests/unit/utils/test_id_generator.py @@ -0,0 +1,142 @@ +import pytest + +from localstack.constants import TAG_KEY_CUSTOM_ID +from localstack.services.cloudformation.engine.entities import StackIdentifier +from localstack.testing.config import TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME +from localstack.utils.id_generator import ( + ResourceIdentifier, + generate_short_uid, + generate_str_id, + generate_uid, + localstack_id_manager, +) +from localstack.utils.strings import long_uid, short_uid + +TEST_NAME = "test-name" + + +@pytest.fixture +def default_resource_identifier(): + return StackIdentifier(TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME, TEST_NAME) + + +@pytest.fixture +def configure_custom_id(unset_configured_custom_id, default_resource_identifier): + set_identifier = [default_resource_identifier] + + def _configure_custom_id(custom_id: str, resource_identifier=None): + localstack_id_manager.set_custom_id( + resource_identifier or default_resource_identifier, custom_id=custom_id + ) + if resource_identifier: + set_identifier.append(resource_identifier) + + yield _configure_custom_id + + # we reset the ids after each test + for identifier in set_identifier: + unset_configured_custom_id(identifier) + + +@pytest.fixture +def unset_configured_custom_id(default_resource_identifier): + def _unset(resource_identifier: ResourceIdentifier = None): + localstack_id_manager.unset_custom_id(resource_identifier or default_resource_identifier) + + return _unset + + +def test_generate_short_id( + configure_custom_id, unset_configured_custom_id, default_resource_identifier +): + custom_id = short_uid() + configure_custom_id(custom_id) + + generated = generate_short_uid(default_resource_identifier) + assert generated == custom_id + + unset_configured_custom_id() + generated = generate_short_uid(default_resource_identifier) + assert generated != custom_id + + +def test_generate_uid(configure_custom_id, unset_configured_custom_id, default_resource_identifier): + custom_id = long_uid() + configure_custom_id(custom_id) + + generated = generate_uid(default_resource_identifier) + assert generated == custom_id + + unset_configured_custom_id() + + # test configured length + generated = generate_uid(default_resource_identifier, length=9) + assert generated != custom_id + assert len(generated) == 9 + + +def test_generate_str_id( + configure_custom_id, unset_configured_custom_id, default_resource_identifier +): + custom_id = "RandomString" + configure_custom_id(custom_id) + + generated = generate_str_id(default_resource_identifier) + assert generated == custom_id + + unset_configured_custom_id() + + # test configured length + generated = generate_str_id(default_resource_identifier, length=9) + assert generated != custom_id + assert len(generated) == 9 + + +def test_generate_with_custom_id_tag( + configure_custom_id, unset_configured_custom_id, default_resource_identifier +): + custom_id = "set_id" + tag_custom_id = "id_from_tag" + configure_custom_id(custom_id) + + # If the tags are passed, they should have priority + generated = generate_str_id( + default_resource_identifier, tags={TAG_KEY_CUSTOM_ID: tag_custom_id} + ) + assert generated == tag_custom_id + generated = generate_str_id(default_resource_identifier) + assert generated == custom_id + + +def test_generate_from_unique_identifier_string( + unset_configured_custom_id, default_resource_identifier, cleanups +): + custom_id = "set_id" + unique_identifier_string = default_resource_identifier.unique_identifier + + localstack_id_manager.set_custom_id_by_unique_identifier(unique_identifier_string, custom_id) + cleanups.append(lambda: unset_configured_custom_id(default_resource_identifier)) + + generated = generate_str_id(default_resource_identifier) + assert generated == custom_id + + +def test_custom_id_context_manager(default_resource_identifier): + custom_id = "set_id" + with localstack_id_manager.custom_id(default_resource_identifier, custom_id): + # Within context, the custom id is used + assert default_resource_identifier.generate() == custom_id + + # Outside the context the id is no longer present and a random id is generated + assert default_resource_identifier.generate() != custom_id + + +def test_custom_id_context_manager_exception_handling(default_resource_identifier): + custom_id = "set_id" + + with pytest.raises(Exception): + with localstack_id_manager.custom_id(default_resource_identifier, custom_id): + assert default_resource_identifier.generate() == custom_id + raise Exception() + + assert default_resource_identifier.generate() != custom_id diff --git a/tests/unit/utils/test_java.py b/tests/unit/utils/test_java.py new file mode 100644 index 0000000000000..b561763bfb32e --- /dev/null +++ b/tests/unit/utils/test_java.py @@ -0,0 +1,65 @@ +from unittest.mock import MagicMock + +from localstack import config +from localstack.utils import java + + +def test_java_system_properties_proxy(monkeypatch): + # Ensure various combinations of env config options are properly converted into expected sys props + + monkeypatch.setattr(config, "OUTBOUND_HTTP_PROXY", "http://lorem.com:69") + monkeypatch.setattr(config, "OUTBOUND_HTTPS_PROXY", "") + output = java.java_system_properties_proxy() + assert len(output) == 2 + assert output["http.proxyHost"] == "lorem.com" + assert output["http.proxyPort"] == "69" + + monkeypatch.setattr(config, "OUTBOUND_HTTP_PROXY", "") + monkeypatch.setattr(config, "OUTBOUND_HTTPS_PROXY", "http://ipsum.com") + output = java.java_system_properties_proxy() + assert len(output) == 2 + assert output["https.proxyHost"] == "ipsum.com" + assert output["https.proxyPort"] == "443" + + # Ensure no explicit port defaults to 80 + monkeypatch.setattr(config, "OUTBOUND_HTTP_PROXY", "http://baz.com") + monkeypatch.setattr(config, "OUTBOUND_HTTPS_PROXY", "http://qux.com:42") + output = java.java_system_properties_proxy() + assert len(output) == 4 + assert output["http.proxyHost"] == "baz.com" + assert output["http.proxyPort"] == "80" + assert output["https.proxyHost"] == "qux.com" + assert output["https.proxyPort"] == "42" + + +def test_java_system_properties_ssl(monkeypatch): + mock = MagicMock() + mock.return_value = "/baz/qux" + monkeypatch.setattr(java, "build_trust_store", mock) + + # Ensure that no sys props are returned if CA bundle is not set + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + + output = java.java_system_properties_ssl("/path/keytool", {"enable_this": "true"}) + assert output == {} + mock.assert_not_called() + + # Ensure that expected sys props are returned when CA bundle is set + mock.reset_mock() + monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/foo/bar") + + output = java.java_system_properties_ssl("/path/to/keytool", {"disable_this": "true"}) + assert len(output) == 3 + assert output["javax.net.ssl.trustStore"] == "/baz/qux" + assert output["javax.net.ssl.trustStorePassword"] == "localstack" + assert output["javax.net.ssl.trustStoreType"] == "jks" + mock.assert_called_with("/path/to/keytool", "/foo/bar", {"disable_this": "true"}, "localstack") + + +def test_system_properties_to_cli_args(): + assert java.system_properties_to_cli_args({}) == [] + assert java.system_properties_to_cli_args({"foo": "bar"}) == ["-Dfoo=bar"] + assert java.system_properties_to_cli_args({"foo": "bar", "baz": "qux"}) == [ + "-Dfoo=bar", + "-Dbaz=qux", + ] diff --git a/tests/unit/utils/test_json.py b/tests/unit/utils/test_json.py new file mode 100644 index 0000000000000..7680ab7793b61 --- /dev/null +++ b/tests/unit/utils/test_json.py @@ -0,0 +1,9 @@ +import json + +from localstack.utils.json import BytesEncoder + + +def test_json_encoder(): + payload = {"foo": b"foobar"} + result = json.dumps(payload, cls=BytesEncoder) + assert result == '{"foo": "Zm9vYmFy"}' diff --git a/tests/unit/utils/test_net_utils.py b/tests/unit/utils/test_net_utils.py new file mode 100644 index 0000000000000..60b86eca3ae45 --- /dev/null +++ b/tests/unit/utils/test_net_utils.py @@ -0,0 +1,158 @@ +import socket +from unittest.mock import MagicMock + +import pytest as pytest + +from localstack import config +from localstack.constants import LOCALHOST +from localstack.testing.pytest import markers +from localstack.utils import net +from localstack.utils.common import short_uid +from localstack.utils.net import ( + Port, + PortNotAvailableException, + PortRange, + dynamic_port_range, + get_addressable_container_host, + get_free_tcp_port, + get_free_tcp_port_range, + get_free_udp_port, + is_ip_address, + port_can_be_bound, + resolve_hostname, +) + + +@markers.skip_offline +def test_resolve_hostname(): + assert "127." in resolve_hostname(LOCALHOST) + assert resolve_hostname("example.com") + assert resolve_hostname(f"non-existing-host-{short_uid()}") is None + + +@pytest.mark.parametrize("protocol", ["tcp", "udp"]) +def test_port_open(protocol): + if protocol == "tcp": + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # bind socket + sock.bind(("", 0)) + addr, port = sock.getsockname() + + # assert that port cannot be bound + port = Port(port, protocol=protocol) + assert not port_can_be_bound(port) + + # close socket, assert that port can be bound + sock.close() + assert port_can_be_bound(port) + + +def test_get_free_udp_port(): + port = get_free_udp_port() + assert port_can_be_bound(Port(port, "udp")) + + +def test_free_tcp_port_blocklist_raises_exception(): + blocklist = range(0, 70000) # blocklist all existing ports + with pytest.raises(Exception) as ctx: + get_free_tcp_port(blocklist) + + assert "Unable to determine free TCP" in str(ctx.value) + + +def test_port_can_be_bound(): + port = get_free_tcp_port() + assert port_can_be_bound(port) + + +def test_port_can_be_bound_illegal_port(): + assert not port_can_be_bound(9999999999) + + +def test_port_can_be_bound_already_bound(): + tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + tcp.bind(("", 0)) + addr, port = tcp.getsockname() + assert not port_can_be_bound(port) + finally: + tcp.close() + + assert port_can_be_bound(port) + + +def test_get_free_tcp_port_range(): + port_range = get_free_tcp_port_range(20) + + assert len(port_range) == 20 + + for port in port_range: + assert dynamic_port_range.is_port_reserved(port) + + for port in port_range: + assert port_can_be_bound(port) + + +def test_subrange(): + r = PortRange(50000, 60000) + r.mark_reserved(50000) + r.mark_reserved(50001) + r.mark_reserved(50002) + r.mark_reserved(50003) + + sr = r.subrange(end=50005) + assert sr.as_range() == range(50000, 50006) + + assert sr.is_port_reserved(50000) + assert sr.is_port_reserved(50001) + assert sr.is_port_reserved(50002) + assert sr.is_port_reserved(50003) + assert not sr.is_port_reserved(50004) + assert not sr.is_port_reserved(50005) + + sr.mark_reserved(50005) + assert r.is_port_reserved(50005) + + +def test_get_free_tcp_port_range_fails_if_reserved(monkeypatch): + mock = MagicMock() + mock.return_value = True + + monkeypatch.setattr(dynamic_port_range, "is_port_reserved", mock) + + with pytest.raises(PortNotAvailableException): + get_free_tcp_port_range(20) + + assert mock.call_count == 50 + + +@pytest.mark.skip(reason="flaky") +def test_get_free_tcp_port_range_fails_if_cannot_be_bound(monkeypatch): + mock = MagicMock() + mock.return_value = False + + monkeypatch.setattr(net, "port_can_be_bound", mock) + + with pytest.raises(PortNotAvailableException): + get_free_tcp_port_range(20, max_attempts=10) + + assert mock.call_count == 10 + + +def test_port_range_iter(): + ports = PortRange(10, 13) + assert list(ports) == [10, 11, 12, 13] + + +def test_get_addressable_container_host(monkeypatch): + if not config.is_in_docker: + monkeypatch.setattr(config, "is_in_docker", True) + monkeypatch.setattr(config, "in_docker", lambda: True) + assert is_ip_address(get_addressable_container_host()) + + monkeypatch.setattr(config, "is_in_docker", False) + monkeypatch.setattr(config, "in_docker", lambda: False) + assert get_addressable_container_host(default_local_hostname="test.abc") == "test.abc" diff --git a/tests/unit/utils/test_objects.py b/tests/unit/utils/test_objects.py new file mode 100644 index 0000000000000..d981b99b59279 --- /dev/null +++ b/tests/unit/utils/test_objects.py @@ -0,0 +1,108 @@ +from unittest.mock import MagicMock + +import pytest + +from localstack.utils.objects import SubtypesInstanceManager, singleton_factory + + +def test_subtypes_instance_manager(): + class BaseClass(SubtypesInstanceManager): + def foo(self): + pass + + class C1(BaseClass): + @staticmethod + def impl_name() -> str: + return "c1" + + def foo(self): + return "bar" + + instance1 = BaseClass.get("c1") + assert instance1 + assert BaseClass.get("c1") == instance1 + assert instance1.foo() == "bar" + with pytest.raises(Exception): + assert BaseClass.get("c2") + + class C2(BaseClass): + @staticmethod + def impl_name() -> str: + return "c2" + + def foo(self): + return "baz" + + instance2 = BaseClass.get("c2") + assert BaseClass.get("c2") == instance2 + assert instance2.foo() == "baz" + + +class TestSingletonFactory: + def test_call_and_clear(self): + mock = MagicMock() + mock.return_value = "foobar" + + @singleton_factory + def my_singleton(): + return mock() + + assert my_singleton() == mock.return_value + mock.assert_called_once() + + assert my_singleton() == mock.return_value + mock.assert_called_once() + + my_singleton.clear() + + assert my_singleton() == mock.return_value + mock.assert_has_calls([(), ()]) + + assert my_singleton() == mock.return_value + mock.assert_has_calls([(), ()]) + + def test_exception_does_not_set_a_value(self): + mock = MagicMock() + + @singleton_factory + def my_singleton(): + mock() + raise ValueError("oh noes") + + with pytest.raises(ValueError): + my_singleton() + + mock.assert_has_calls([()]) + + with pytest.raises(ValueError): + my_singleton() + + mock.assert_has_calls([(), ()]) + + def test_set_none_value_does_not_set_singleton(self): + mock = MagicMock() + mock.return_value = None + + @singleton_factory + def my_singleton(): + return mock() + + assert my_singleton() is None + mock.assert_has_calls([()]) + + assert my_singleton() is None + mock.assert_has_calls([(), ()]) + + def test_set_falsy_value_sets_singleton(self): + mock = MagicMock() + mock.return_value = False + + @singleton_factory + def my_singleton(): + return mock() + + assert my_singleton() is False + mock.assert_called_once() + + assert my_singleton() is False + mock.assert_called_once() diff --git a/tests/unit/utils/test_patch.py b/tests/unit/utils/test_patch.py new file mode 100644 index 0000000000000..3b6d2685e4a17 --- /dev/null +++ b/tests/unit/utils/test_patch.py @@ -0,0 +1,230 @@ +import pytest + +from localstack.utils.patch import Patch, get_defining_object, patch + + +def echo(arg): + return f"echo: {arg}" + + +class MyEchoer: + def do_echo(self, arg): + return f"do_echo: {arg}" + + @classmethod + def do_class_echo(cls, arg): + return f"do_class_echo: {arg}" + + @staticmethod + def do_static_echo(arg): + return f"do_static_echo: {arg}" + + +def test_patch_context_manager(): + assert echo("foo") == "echo: foo" + + def monkey(arg): + return f"monkey: {arg}" + + with Patch(get_defining_object(echo), "echo", monkey): + assert echo("foo") == "monkey: foo" + + assert echo("foo") == "echo: foo" + + +def test_patch_with_pass_target_context_manager(): + assert echo("foo") == "echo: foo" + + def uppercase(target, arg): + return target(arg).upper() + + with Patch(get_defining_object(echo), "echo", uppercase): + assert echo("foo") == "ECHO: FOO" + + assert echo("foo") == "echo: foo" + + +def test_patch_decorator(): + @patch(target=echo, pass_target=False) + def monkey(arg): + return f"monkey: {arg}" + + assert echo("foo") == "monkey: foo" + monkey.patch.undo() + assert echo("foo") == "echo: foo" + + +def test_patch_decorator_with_pass_target(): + @patch(target=echo) + def uppercase(target, arg): + return target(arg).upper() + + assert echo("foo") == "ECHO: FOO" + uppercase.patch.undo() + assert echo("foo") == "echo: foo" + + +def test_patch_decorator_on_method(): + @patch(target=MyEchoer.do_echo) + def uppercase(target, self, arg): + return target(self, arg).upper() + + obj = MyEchoer() + + assert obj.do_echo("foo") == "DO_ECHO: FOO" + uppercase.patch.undo() + assert obj.do_echo("foo") == "do_echo: foo" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + + +def test_patch_decorator_on_bound_method_with_pass_target(): + obj = MyEchoer() + + @patch(target=obj.do_echo) + def uppercase(self, target, arg): + return target(arg).upper() + + assert obj.do_echo("foo") == "DO_ECHO: FOO" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + uppercase.patch.undo() + + assert obj.do_echo("foo") == "do_echo: foo" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + + +def test_patch_decorator_on_bound_method(): + obj = MyEchoer() + + @patch(target=obj.do_echo, pass_target=False) + def monkey(self, arg): + return f"monkey: {arg}" + + assert obj.do_echo("foo") == "monkey: foo" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + monkey.patch.undo() + + assert obj.do_echo("foo") == "do_echo: foo" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + + +def test_patch_decorator_twice_on_method(): + @patch(target=MyEchoer.do_echo) + def monkey1(self, *args): + return f"monkey: {args[-1]}" + + @patch(target=MyEchoer.do_echo) + def monkey2(fn, self, *args): + return f"monkey 2: {fn(*args)}" + + obj = MyEchoer() + + try: + assert obj.do_echo("foo") == "monkey 2: monkey: foo" + assert MyEchoer().do_echo("foo") == "monkey 2: monkey: foo" + finally: + monkey2.patch.undo() + monkey1.patch.undo() + + assert obj.do_echo("foo") == "do_echo: foo" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + + +@pytest.mark.parametrize("pass_target", [True, False]) +def test_patch_decorator_twice_on_bound_method(pass_target): + obj = MyEchoer() + + @patch(target=obj.do_echo, pass_target=pass_target) + def monkey1(self, *args): + return f"monkey: {args[-1]}" + + @patch(target=obj.do_echo, pass_target=True) + def monkey2(self, fn, *args): + return f"monkey 2: {fn(*args)}" + + assert obj.do_echo("foo") == "monkey 2: monkey: foo" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + monkey2.patch.undo() + monkey1.patch.undo() + + assert obj.do_echo("foo") == "do_echo: foo" + assert MyEchoer().do_echo("foo") == "do_echo: foo" + + +def test_patch_decorator_on_class_method(): + @patch(target=MyEchoer.do_class_echo) + def uppercase(target, *args): + if len(args) > 1: + # this happens when the method is called on an object, the first arg will be the object + arg = args[1] + else: + arg = args[0] + + return target(arg).upper() + + assert MyEchoer.do_class_echo("foo") == "DO_CLASS_ECHO: FOO" + assert MyEchoer().do_class_echo("foo") == "DO_CLASS_ECHO: FOO" + uppercase.patch.undo() + assert MyEchoer.do_class_echo("foo") == "do_class_echo: foo" + assert MyEchoer().do_class_echo("foo") == "do_class_echo: foo" + + +def test_get_defining_object(): + from localstack.utils import strings + from localstack.utils.strings import short_uid + + # module + assert get_defining_object(short_uid) == strings + + # unbound method (=function defined by a class) + assert get_defining_object(MyEchoer.do_echo) == MyEchoer + + obj = MyEchoer() + # bound method + assert get_defining_object(obj.do_echo) == obj + + # class method referenced by an object + assert get_defining_object(obj.do_class_echo) == MyEchoer + + # class method referenced by the class + assert get_defining_object(MyEchoer.do_class_echo) == MyEchoer + + # static method (= function defined by a class) + assert get_defining_object(MyEchoer.do_static_echo) == MyEchoer + + +def test_to_string(): + @patch(MyEchoer.do_echo) + def monkey(self, *args): + return f"monkey: {args[-1]}" + + applied = [str(p) for p in Patch.applied_patches] + + value = "Patch(function(tests.unit.utils.test_patch:MyEchoer.do_echo) -> function(tests.unit.utils.test_patch:test_to_string..monkey), applied=True)" + assert value in applied + assert str(monkey.patch) == value + monkey.patch.undo() + + +def test_patch_class_type(): + @patch(MyEchoer) + def new_echo(self, *args): + return args[1] + + echoer = MyEchoer() + assert echoer.new_echo(1, 2, 3) == 2 + new_echo.patch.undo() + with pytest.raises(AttributeError): + echoer.new_echo("Hello world!") + + @patch(MyEchoer) + def do_echo(self, arg): + return arg + + echoer = MyEchoer() + assert echoer.do_echo(1) == "do_echo: 1", "existing method is overridden" + + with pytest.raises(AttributeError): + + @patch(MyEchoer.new_echo) + def new_echo(self, *args): + pass diff --git a/tests/unit/utils/test_scheduler.py b/tests/unit/utils/test_scheduler.py new file mode 100644 index 0000000000000..82bd0f8ffe5a6 --- /dev/null +++ b/tests/unit/utils/test_scheduler.py @@ -0,0 +1,184 @@ +import threading +import time +from concurrent.futures.thread import ThreadPoolExecutor +from typing import Tuple + +import pytest + +from localstack.utils.scheduler import ScheduledTask, Scheduler +from localstack.utils.sync import poll_condition + + +class DummyTask: + def __init__(self, fn=None) -> None: + super().__init__() + self.i = 0 + self.invocations = list() + self.completions = list() + self.fn = fn + + def __call__(self, *args, **kwargs): + self.invoke(*args, **kwargs) + + def invoke(self, *args, **kwargs): + self.i += 1 + invoked = time.time() + self.invocations.append((self.i, invoked, args, kwargs)) + + if self.fn: + self.fn(*args, **kwargs) + + self.completions.append((self.i, time.time(), args, kwargs)) + + +@pytest.fixture +def dispatcher(): + executor = ThreadPoolExecutor(4) + yield executor + executor.shutdown() + + +class TestScheduler: + @staticmethod + def create_and_start(dispatcher) -> Tuple[Scheduler, threading.Thread]: + scheduler = Scheduler(executor=dispatcher) + thread = threading.Thread(target=scheduler.run) + thread.start() + + return scheduler, thread + + def test_single_scheduled_run(self, dispatcher): + scheduler, thread = self.create_and_start(dispatcher) + + task = DummyTask() + invocation_time = time.time() + 0.2 + + scheduler.schedule(task, start=invocation_time) + + assert poll_condition(lambda: len(task.invocations) >= 1, timeout=5) + + scheduler.close() + thread.join(5) + + assert len(task.invocations) == 1 + assert task.invocations[0][0] == 1 + + assert task.invocations[0][1] == pytest.approx(invocation_time, 0.1) + + def test_period_run_nonfixed(self): + task = DummyTask() + scheduler, thread = self.create_and_start(None) + + scheduler.schedule(task, period=0.1, fixed_rate=False) + scheduler.schedule(scheduler.close, start=time.time() + 0.5) + thread.join(5) + + assert task.invocations[1][1] + 0.1 == pytest.approx(task.invocations[2][1], 0.05) + assert task.invocations[2][1] + 0.1 == pytest.approx(task.invocations[3][1], 0.05) + assert task.invocations[3][1] + 0.1 == pytest.approx(task.invocations[4][1], 0.05) + + def test_periodic_run_fixed_with_longer_task(self): + task = DummyTask(fn=lambda: time.sleep(1)) + + scheduler, thread = self.create_and_start(None) + + scheduler.schedule(task, period=0.5, fixed_rate=True) + scheduler.schedule(scheduler.close, start=time.time() + 1.25) + + thread.join(5) + + assert len(task.invocations) == 3 + + first = task.invocations[0][1] + assert first + 0.5 == pytest.approx(task.invocations[1][1], 0.1) + assert first + 1 == pytest.approx(task.invocations[2][1], 0.1) + + assert poll_condition(lambda: len(task.completions) >= 3, timeout=5) + + def test_periodic_change_period(self, dispatcher): + task = DummyTask() + scheduler, thread = self.create_and_start(dispatcher) + + stask = scheduler.schedule(task, period=1, fixed_rate=True) + + def change_period(t: ScheduledTask, period: float): + t.period = period + + scheduler.schedule(change_period, start=time.time() + 1.25, args=(stask, 0.5)) + scheduler.schedule(scheduler.close, start=time.time() + 3) + + thread.join(5) + + first = task.invocations[0][1] + second = task.invocations[1][1] + third = task.invocations[2][1] + fourth = task.invocations[3][1] + assert first + 1 == pytest.approx(second, 0.1) + assert second + 1 == pytest.approx(third, 0.1) + # changed to 0.5 + assert third + 0.5 == pytest.approx(fourth, 0.1) + + def test_cancel_task(self, dispatcher): + task1 = DummyTask() + task2 = DummyTask() + scheduler, thread = self.create_and_start(dispatcher) + + scheduler.schedule(task2.invoke, period=0.5) + stask = scheduler.schedule(task1.invoke, period=0.5) + + scheduler.schedule(stask.cancel, start=time.time() + 0.75) + scheduler.schedule(scheduler.close, start=time.time() + 1.5) + + thread.join(5) + + assert len(task1.invocations) == 2 + assert len(task2.invocations) == 4 + + def test_error_handler(self): + scheduler = Scheduler() + + event = threading.Event() + + def invoke(): + raise ValueError("unittest") + + def on_error(e): + event.set() + + scheduler.schedule(invoke, on_error=on_error) + scheduler.schedule(scheduler.close) + + scheduler.run() + + assert event.wait(5) + + def test_scheduling_reordering(self, dispatcher): + task = DummyTask() + scheduler, thread = self.create_and_start(dispatcher) + + t = time.time() + scheduler.schedule(task, args=("task2",), start=t + 1) # task two gets scheduled first + time.sleep(0.25) + scheduler.schedule( + task, args=("task1",), start=t + 0.5 + ) # but task one has the shorter deadline + + scheduler.schedule(scheduler.close, start=t + 1.5) + + thread.join(5) + + assert len(task.invocations) == 2 + assert task.invocations[0][2][0] == "task1" + assert task.invocations[1][2][0] == "task2" + + def test_close_interrupts_waiting_tasks(self, dispatcher): + task = DummyTask() + scheduler, thread = self.create_and_start(dispatcher) + + scheduler.schedule(task, start=time.time() + 1) + time.sleep(0.25) + scheduler.close() + + thread.join(5) + + assert len(task.invocations) == 0 diff --git a/tests/unit/utils/test_ssl.py b/tests/unit/utils/test_ssl.py new file mode 100644 index 0000000000000..4f51abd24a9f9 --- /dev/null +++ b/tests/unit/utils/test_ssl.py @@ -0,0 +1,19 @@ +import pytest + +from localstack import config +from localstack.utils.ssl import get_cert_pem_file_path + + +def test_custom_ssl_cert_path_is_used(monkeypatch): + monkeypatch.setattr(config, "CUSTOM_SSL_CERT_PATH", "/custom/path/server.cert.pem") + assert get_cert_pem_file_path() == "/custom/path/server.cert.pem" + + +@pytest.mark.parametrize( + "custom_ssl_cert_path_config", + [None, ""], +) +def test_custom_ssl_cert_path_not_used_if_not_set(monkeypatch, custom_ssl_cert_path_config): + monkeypatch.setattr(config, "CUSTOM_SSL_CERT_PATH", custom_ssl_cert_path_config) + # the cache folder can differ for different environments, we only check the suffix + assert get_cert_pem_file_path().endswith("/cache/server.test.pem") diff --git a/tests/unit/utils/test_strings.py b/tests/unit/utils/test_strings.py new file mode 100644 index 0000000000000..8b550c161eb6c --- /dev/null +++ b/tests/unit/utils/test_strings.py @@ -0,0 +1,24 @@ +from localstack.utils.strings import ( + key_value_pairs_to_dict, + prepend_with_slash, +) + + +def test_prepend_with_slash(): + assert prepend_with_slash("hello") == "/hello" + assert prepend_with_slash("/world") == "/world" + assert prepend_with_slash("//world") == "//world" + + +def test_key_value_pairs_to_dict(): + assert key_value_pairs_to_dict("a=1,b=2,c=3") == {"a": "1", "b": "2", "c": "3"} + assert key_value_pairs_to_dict("a=1;b=2;c=3", delimiter=";", separator="=") == { + "a": "1", + "b": "2", + "c": "3", + } + assert key_value_pairs_to_dict("a=1;b=2;c=3", delimiter=";", separator=":") == { + "a=1": "", + "b=2": "", + "c=3": "", + } diff --git a/tests/unit/utils/test_sync.py b/tests/unit/utils/test_sync.py new file mode 100644 index 0000000000000..a1cda2ed38d1d --- /dev/null +++ b/tests/unit/utils/test_sync.py @@ -0,0 +1,19 @@ +import threading + +from localstack.utils.sync import SynchronizedDefaultDict + + +def test_synchronized_defaultdict(): + d = SynchronizedDefaultDict(int) + + d["a"] = 1 + d["b"] = 2 + + assert d["a"] == 1 + assert d["b"] == 2 + assert d["c"] == 0 + + d = SynchronizedDefaultDict(threading.RLock) + + with d["a"]: + assert isinstance(d["a"], type(threading.RLock())) diff --git a/tests/unit/utils/test_tcp_proxy.py b/tests/unit/utils/test_tcp_proxy.py new file mode 100644 index 0000000000000..53105f203edec --- /dev/null +++ b/tests/unit/utils/test_tcp_proxy.py @@ -0,0 +1,78 @@ +import socket +import threading +from threading import Thread + +import pytest + +from localstack.utils.net import get_free_tcp_port, is_port_open +from localstack.utils.server.tcp_proxy import TCPProxy + + +class TestTCPProxy: + @pytest.fixture + def tcp_proxy(self): + proxies: list[TCPProxy] = [] + + def _create_proxy(target_address: str, target_port: int) -> TCPProxy: + port = get_free_tcp_port() + proxy = TCPProxy( + target_address=target_address, target_port=target_port, port=port, host="127.0.0.1" + ) + proxies.append(proxy) + return proxy + + yield _create_proxy + + for proxy in proxies: + proxy.shutdown() + + @pytest.fixture + def tcp_echo_server_port(self): + """Single threaded TCP echo server""" + stopped = threading.Event() + s_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s_sock.bind(("127.0.0.1", 0)) + port = s_sock.getsockname()[1] + + def _run_echo_server(): + with s_sock: + s_sock.listen(1) + while not stopped.is_set(): + try: + conn, _ = s_sock.accept() + except OSError: + # this happens when we shut down the server socket + pass + with conn: + while not stopped.is_set(): + data = conn.recv(1024) + if not data: + break + conn.sendall(data) + + echo_server_thread = Thread(target=_run_echo_server) + echo_server_thread.start() + + yield port + + stopped.set() + s_sock.shutdown(socket.SHUT_RDWR) + s_sock.close() + echo_server_thread.join(5) + assert not echo_server_thread.is_alive() + + def test_tcp_proxy_lifecycle(self, tcp_proxy, tcp_echo_server_port): + proxy = tcp_proxy(target_address="127.0.0.1", target_port=tcp_echo_server_port) + + proxy.start() + proxy.wait_is_up(timeout=5) + + with socket.create_connection(("127.0.0.1", proxy.port)) as c_sock: + data = b"test data" + c_sock.sendall(data) + received_data = c_sock.recv(1024) + assert received_data == data + + proxy.shutdown() + proxy.join(5) + assert not is_port_open(proxy.port)